diff options
author | Dan J Miller <danjm.com@gmail.com> | 2019-03-05 23:45:01 +0800 |
---|---|---|
committer | GitHub <noreply@github.com> | 2019-03-05 23:45:01 +0800 |
commit | c7573663557b0db778a2907eaf2fd1918ced4914 (patch) | |
tree | 3fed2dbe12a845704bff3268e9ee610d55bcc8c2 /ui | |
parent | 1765864e40f548b805cdbdbe59ada3e0c445a52d (diff) | |
download | tangerine-wallet-browser-c7573663557b0db778a2907eaf2fd1918ced4914.tar tangerine-wallet-browser-c7573663557b0db778a2907eaf2fd1918ced4914.tar.gz tangerine-wallet-browser-c7573663557b0db778a2907eaf2fd1918ced4914.tar.bz2 tangerine-wallet-browser-c7573663557b0db778a2907eaf2fd1918ced4914.tar.lz tangerine-wallet-browser-c7573663557b0db778a2907eaf2fd1918ced4914.tar.xz tangerine-wallet-browser-c7573663557b0db778a2907eaf2fd1918ced4914.tar.zst tangerine-wallet-browser-c7573663557b0db778a2907eaf2fd1918ced4914.zip |
Metametrics (#6171)
* Add metametrics provider and util.
* Add backend api and state for participating in metametrics.
* Add frontend action for participating in metametrics.
* Add metametrics opt-in screen.
* Add metametrics events to first time flow.
* Add metametrics events for route changes
* Add metametrics events for send and confirm screens
* Add metametrics events to dropdowns, transactions, log in and out, settings, sig requests and main screen
* Ensures each log in is measured as a new visit by metametrics.
* Ensure metametrics is called with an empty string for dimensions params if specified
* Adds opt in metametrics modal after unlock for existing users
* Adds settings page toggle for opting in and out of MetaMetrics
* Switch metametrics dimensions to page level scope
* Lint, test and translation fixes for metametrics.
* Update design for metametrics opt-in screen
* Complete responsive styling of metametrics-opt-in modal
* Use new chart image on metrics opt in screens
* Incorporate the metametrics opt-in screen into the new onboarding flow
* Update e2e tests to accomodate metametrics changes
* Mock out metametrics network requests in integration tests
* Fix tx-list integration test to support metametrics provider.
* Send number of tokens and accounts data with every metametrics event.
* Update metametrics event descriptor schema and add new events.
* Fix import tos bug and send gas button bug due to metametrics changes.
* Various small fixes on the metametrics branch.
* Add origin custom variable type to metametrics.util
* Fix names of onboarding complete actions (metametrics).
* Fix names of Metrics Options actions (metametrics).
* Clean up code related to metametrics.
* Fix bad merge conflict resolution and improve promise handling in sendMetaMetrics event and confrim tx base
* Don't send a second metrics event if user has gone back during first time flow.
* Collect metametrics on going back from onboarding create/import.
* Add missing custom variable constants for metametrics
* Fix metametrics provider
* Make height of opt-in modal responsive.
* Adjust text content for opt-in modal.
* Update metametrics event names and clean up code in opt-in-modal
* Put phishing warning step next to last in onboarding flow
* Link terms of service on create and import screens of first time flow
* Add subtext to options on the onboarding select action screen.
* Fix styling of bullet points on end of onboarding screen.
* Combine phishing warning and congratulations screens.
* Fix placement of users if unlocking after an incomplete onboarding import flow.
* Fix capitalization in opt-in screen
* Fix last onboarding screen translations
* Add link to 'Learn More' on the last screen of onboarding
* Code clean up: metametrics branch
* Update e2e tests for phishing warning step removal
* e2e tests passing on metametrics branch
* Different tracking urls for metametrics on development and prod
Diffstat (limited to 'ui')
81 files changed, 2014 insertions, 134 deletions
diff --git a/ui/app/actions.js b/ui/app/actions.js index 1d01a72ad..d8363eba6 100644 --- a/ui/app/actions.js +++ b/ui/app/actions.js @@ -300,6 +300,11 @@ var actions = { SET_USE_BLOCKIE: 'SET_USE_BLOCKIE', setUseBlockie, + SET_PARTICIPATE_IN_METAMETRICS: 'SET_PARTICIPATE_IN_METAMETRICS', + SET_METAMETRICS_SEND_COUNT: 'SET_METAMETRICS_SEND_COUNT', + setParticipateInMetaMetrics, + setMetaMetricsSendCount, + // locale SET_CURRENT_LOCALE: 'SET_CURRENT_LOCALE', SET_LOCALE_MESSAGES: 'SET_LOCALE_MESSAGES', @@ -348,6 +353,9 @@ var actions = { approveProviderRequest, rejectProviderRequest, clearApprovedOrigins, + + setFirstTimeFlowType, + SET_FIRST_TIME_FLOW_TYPE: 'SET_FIRST_TIME_FLOW_TYPE', } module.exports = actions @@ -2607,6 +2615,49 @@ function toggleAccountMenu () { } } +function setParticipateInMetaMetrics (val) { + return (dispatch) => { + log.debug(`background.setParticipateInMetaMetrics`) + return new Promise((resolve, reject) => { + background.setParticipateInMetaMetrics(val, (err, metaMetricsId) => { + log.debug(err) + if (err) { + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + + dispatch({ + type: actions.SET_PARTICIPATE_IN_METAMETRICS, + value: val, + }) + + resolve([val, metaMetricsId]) + }) + }) + } +} + +function setMetaMetricsSendCount (val) { + return (dispatch) => { + log.debug(`background.setMetaMetricsSendCount`) + return new Promise((resolve, reject) => { + background.setMetaMetricsSendCount(val, (err) => { + if (err) { + dispatch(actions.displayWarning(err.message)) + return reject(err) + } + + dispatch({ + type: actions.SET_METAMETRICS_SEND_COUNT, + value: val, + }) + + resolve(val) + }) + }) + } +} + function setUseBlockie (val) { return (dispatch) => { dispatch(actions.showLoadingIndication()) @@ -2693,3 +2744,18 @@ function clearApprovedOrigins () { background.clearApprovedOrigins() } } + +function setFirstTimeFlowType (type) { + return (dispatch) => { + log.debug(`background.setFirstTimeFlowType`) + background.setFirstTimeFlowType(type, (err) => { + if (err) { + return dispatch(actions.displayWarning(err.message)) + } + }) + dispatch({ + type: actions.SET_FIRST_TIME_FLOW_TYPE, + value: type, + }) + } +} diff --git a/ui/app/app.js b/ui/app/app.js index 1001adc9a..b9f6cafe7 100644 --- a/ui/app/app.js +++ b/ui/app/app.js @@ -15,6 +15,7 @@ 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' @@ -82,6 +83,20 @@ class App extends Component { 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 () { @@ -159,6 +174,18 @@ class App extends Component { 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, @@ -198,6 +225,7 @@ class App extends Component { transitionName={sidebarTransitionName} type={sidebarType} sidebarProps={sidebar.props} + onOverlayClose={sidebarOnOverlayClose} /> <NetworkDropdown provider={provider} @@ -406,6 +434,7 @@ function mapDispatchToProps (dispatch, ownProps) { App.contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } module.exports = compose( diff --git a/ui/app/components/account-dropdowns.js b/ui/app/components/account-dropdowns.js index 06376e48b..b05ba219c 100644 --- a/ui/app/components/account-dropdowns.js +++ b/ui/app/components/account-dropdowns.js @@ -233,6 +233,7 @@ class AccountDropdowns extends Component { } render () { + const { metricsEvent } = this.context const { style, enableAccountsSelector, enableAccountOptions } = this.props const { optionsMenuActive, accountSelectorActive } = this.state @@ -272,6 +273,17 @@ class AccountDropdowns extends Component { fontSize: '1.8em', }, onClick: (event) => { + metricsEvent({ + eventOpts: { + category: 'Accounts', + action: 'userClick', + name: 'accountsOpenedMenu', + }, + pageOpts: { + section: 'header', + component: 'accountDropdownIcon', + }, + }) event.stopPropagation() this.setState({ accountSelectorActive: false, @@ -318,6 +330,7 @@ const mapDispatchToProps = (dispatch) => { AccountDropdowns.contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } module.exports = { diff --git a/ui/app/components/account-menu/account-menu.component.js b/ui/app/components/account-menu/account-menu.component.js index b2fec647a..ce7482108 100644 --- a/ui/app/components/account-menu/account-menu.component.js +++ b/ui/app/components/account-menu/account-menu.component.js @@ -20,6 +20,7 @@ import { export default class AccountMenu extends PureComponent { static contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } static propTypes = { @@ -73,7 +74,16 @@ export default class AccountMenu extends PureComponent { return ( <div className="account-menu__account menu__item--clickable" - onClick={() => showAccountDetail(identity.address)} + 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"> @@ -197,6 +207,7 @@ export default class AccountMenu extends PureComponent { lockMetamask, history, } = this.props + const { metricsEvent } = this.context return ( <Menu @@ -230,6 +241,13 @@ export default class AccountMenu extends PureComponent { <Item onClick={() => { toggleAccountMenu() + metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Main Menu', + name: 'Clicked Create Account', + }, + }) history.push(NEW_ACCOUNT_ROUTE) }} icon={ @@ -243,6 +261,13 @@ export default class AccountMenu extends PureComponent { <Item onClick={() => { toggleAccountMenu() + metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Main Menu', + name: 'Clicked Import Account', + }, + }) history.push(IMPORT_ACCOUNT_ROUTE) }} icon={ @@ -256,7 +281,13 @@ export default class AccountMenu extends PureComponent { <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 { @@ -286,6 +317,13 @@ export default class AccountMenu extends PureComponent { onClick={() => { toggleAccountMenu() history.push(SETTINGS_ROUTE) + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Main Menu', + name: 'Opened Settings', + }, + }) }} icon={ <img diff --git a/ui/app/components/app-header/app-header.component.js b/ui/app/components/app-header/app-header.component.js index f7d8c8598..14f8b9f30 100644 --- a/ui/app/components/app-header/app-header.component.js +++ b/ui/app/components/app-header/app-header.component.js @@ -18,10 +18,12 @@ export default class AppHeader extends PureComponent { isUnlocked: PropTypes.bool, hideNetworkIndicator: PropTypes.bool, disabled: PropTypes.bool, + isAccountMenuOpen: PropTypes.bool, } static contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } handleNetworkIndicatorClick (event) { @@ -30,20 +32,40 @@ export default class AppHeader extends PureComponent { const { networkDropdownOpen, showNetworkDropdown, hideNetworkDropdown } = this.props - return networkDropdownOpen === false - ? showNetworkDropdown() - : hideNetworkDropdown() + if (networkDropdownOpen === false) { + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Home', + name: 'Opened Network Menu', + }, + }) + showNetworkDropdown() + } else { + hideNetworkDropdown() + } } renderAccountMenu () { - const { isUnlocked, toggleAccountMenu, selectedAddress, disabled } = this.props + const { isUnlocked, toggleAccountMenu, selectedAddress, disabled, isAccountMenuOpen } = this.props return isUnlocked && ( <div className={classnames('account-menu__icon', { 'account-menu__icon--disabled': disabled, })} - onClick={() => disabled || toggleAccountMenu()} + onClick={() => { + if (!disabled) { + !isAccountMenuOpen && this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Home', + name: 'Opened Main Menu', + }, + }) + toggleAccountMenu() + } + }} > <Identicon address={selectedAddress} diff --git a/ui/app/components/app-header/app-header.container.js b/ui/app/components/app-header/app-header.container.js index 30d3f8cc4..1abc2afeb 100644 --- a/ui/app/components/app-header/app-header.container.js +++ b/ui/app/components/app-header/app-header.container.js @@ -13,6 +13,7 @@ const mapStateToProps = state => { provider, selectedAddress, isUnlocked, + isAccountMenuOpen, } = metamask return { @@ -21,6 +22,7 @@ const mapStateToProps = state => { provider, selectedAddress, isUnlocked, + isAccountMenuOpen, } } diff --git a/ui/app/components/customize-gas-modal/index.js b/ui/app/components/customize-gas-modal/index.js index e67fbe45b..fd660ead2 100644 --- a/ui/app/components/customize-gas-modal/index.js +++ b/ui/app/components/customize-gas-modal/index.js @@ -3,6 +3,7 @@ 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' @@ -112,6 +113,7 @@ function CustomizeGasModal (props) { CustomizeGasModal.contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } module.exports = connect(mapStateToProps, mapDispatchToProps)(CustomizeGasModal) @@ -148,6 +150,7 @@ CustomizeGasModal.prototype.componentWillReceiveProps = function (nextProps) { } CustomizeGasModal.prototype.save = function (gasPrice, gasLimit, gasTotal) { + const { metricsEvent } = this.context const { setGasPrice, setGasLimit, @@ -159,6 +162,9 @@ CustomizeGasModal.prototype.save = function (gasPrice, gasLimit, gasTotal) { updateSendAmount, updateSendErrors, } = this.props + const { + originalState, + } = this.state if (maxModeOn && !selectedToken) { const maxAmount = subtractCurrencies( @@ -169,6 +175,22 @@ CustomizeGasModal.prototype.save = function (gasPrice, gasLimit, gasTotal) { updateSendAmount(maxAmount) } + metricsEvent({ + eventOpts: { + category: 'Activation', + action: 'userCloses', + name: 'closeCustomizeGas', + }, + pageOpts: { + section: 'customizeGasModal', + component: 'customizeGasSaveButton', + }, + customVariables: { + gasPriceChange: (new BigNumber(ethUtil.addHexPrefix(gasPrice))).minus(new BigNumber(ethUtil.addHexPrefix(originalState.gasPrice))).toString(10), + gasLimitChange: (new BigNumber(ethUtil.addHexPrefix(gasLimit))).minus(new BigNumber(ethUtil.addHexPrefix(originalState.gasLimit))).toString(10), + }, + }) + setGasPrice(ethUtil.addHexPrefix(gasPrice)) setGasLimit(ethUtil.addHexPrefix(gasLimit)) setGasTotal(ethUtil.addHexPrefix(gasTotal)) diff --git a/ui/app/components/dropdowns/account-details-dropdown.js b/ui/app/components/dropdowns/account-details-dropdown.js index 7476cfdd9..bda8b9517 100644 --- a/ui/app/components/dropdowns/account-details-dropdown.js +++ b/ui/app/components/dropdowns/account-details-dropdown.js @@ -10,6 +10,7 @@ const { Menu, Item, CloseArea } = require('./components/menu') AccountDetailsDropdown.contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } module.exports = connect(mapStateToProps, mapDispatchToProps)(AccountDetailsDropdown) @@ -72,6 +73,13 @@ AccountDetailsDropdown.prototype.render = function () { h(Item, { onClick: (e) => { e.stopPropagation() + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Account Options', + name: 'Clicked Expand View', + }, + }) global.platform.openExtensionInBrowser() this.props.onClose() }, @@ -82,6 +90,13 @@ AccountDetailsDropdown.prototype.render = function () { onClick: (e) => { e.stopPropagation() showAccountDetailModal() + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Account Options', + name: 'Viewed Account Details', + }, + }) this.props.onClose() }, text: this.context.t('accountDetails'), @@ -90,6 +105,13 @@ AccountDetailsDropdown.prototype.render = function () { h(Item, { onClick: (e) => { e.stopPropagation() + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Account Options', + name: 'Clicked View on Etherscan', + }, + }) viewOnEtherscan(address, network) this.props.onClose() }, diff --git a/ui/app/components/dropdowns/network-dropdown.js b/ui/app/components/dropdowns/network-dropdown.js index 6e002219a..86a5a5268 100644 --- a/ui/app/components/dropdowns/network-dropdown.js +++ b/ui/app/components/dropdowns/network-dropdown.js @@ -60,6 +60,7 @@ function NetworkDropdown () { NetworkDropdown.contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } module.exports = compose( @@ -120,7 +121,7 @@ NetworkDropdown.prototype.render = function () { { key: 'main', closeMenu: () => this.props.hideNetworkDropdown(), - onClick: () => props.setProviderType('mainnet'), + onClick: () => this.handleClick('mainnet'), style: { ...dropdownMenuItemStyle, borderColor: '#038789' }, }, [ @@ -142,7 +143,7 @@ NetworkDropdown.prototype.render = function () { { key: 'ropsten', closeMenu: () => this.props.hideNetworkDropdown(), - onClick: () => props.setProviderType('ropsten'), + onClick: () => this.handleClick('ropsten'), style: dropdownMenuItemStyle, }, [ @@ -164,7 +165,7 @@ NetworkDropdown.prototype.render = function () { { key: 'kovan', closeMenu: () => this.props.hideNetworkDropdown(), - onClick: () => props.setProviderType('kovan'), + onClick: () => this.handleClick('kovan'), style: dropdownMenuItemStyle, }, [ @@ -186,7 +187,7 @@ NetworkDropdown.prototype.render = function () { { key: 'rinkeby', closeMenu: () => this.props.hideNetworkDropdown(), - onClick: () => props.setProviderType('rinkeby'), + onClick: () => this.handleClick('rinkeby'), style: dropdownMenuItemStyle, }, [ @@ -208,7 +209,7 @@ NetworkDropdown.prototype.render = function () { { key: 'default', closeMenu: () => this.props.hideNetworkDropdown(), - onClick: () => props.setProviderType('localhost'), + onClick: () => this.handleClick('localhost'), style: dropdownMenuItemStyle, }, [ @@ -252,6 +253,23 @@ NetworkDropdown.prototype.render = function () { ]) } +NetworkDropdown.prototype.handleClick = function (newProviderType) { + const { providerType, setProviderType } = this.props + const { metricsEvent } = this.context + + metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Home', + name: 'Opened Network Menu', + }, + customVariables: { + fromNetwork: providerType, + toNetwork: newProviderType, + }, + }) + setProviderType(newProviderType) +} NetworkDropdown.prototype.getNetworkName = function () { const { provider } = this.props diff --git a/ui/app/components/menu-bar/menu-bar.component.js b/ui/app/components/menu-bar/menu-bar.component.js index e64809f3f..24f84516d 100644 --- a/ui/app/components/menu-bar/menu-bar.component.js +++ b/ui/app/components/menu-bar/menu-bar.component.js @@ -7,6 +7,7 @@ import AccountDetailsDropdown from '../dropdowns/account-details-dropdown.js' export default class MenuBar extends PureComponent { static contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } static propTypes = { @@ -31,7 +32,16 @@ export default class MenuBar extends PureComponent { > <div className="fa fa-bars menu-bar__sidebar-button" - onClick={() => sidebarOpen ? hideSidebar() : showSidebar()} + onClick={() => { + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Home', + name: 'Copied Address', + }, + }) + sidebarOpen ? hideSidebar() : showSidebar() + }} /> </Tooltip> <SelectedAccount /> diff --git a/ui/app/components/menu-bar/menu-bar.container.js b/ui/app/components/menu-bar/menu-bar.container.js index ae32882ae..4c5276402 100644 --- a/ui/app/components/menu-bar/menu-bar.container.js +++ b/ui/app/components/menu-bar/menu-bar.container.js @@ -1,4 +1,5 @@ import { connect } from 'react-redux' +import { WALLET_VIEW_SIDEBAR } from '../sidebars/sidebar.constants' import MenuBar from './menu-bar.component' import { showSidebar, hideSidebar } from '../../actions' @@ -16,7 +17,7 @@ const mapDispatchToProps = dispatch => { showSidebar: () => { dispatch(showSidebar({ transitionName: 'sidebar-right', - type: 'wallet-view', + type: WALLET_VIEW_SIDEBAR, })) }, hideSidebar: () => dispatch(hideSidebar()), diff --git a/ui/app/components/modals/customize-gas/customize-gas.component.js b/ui/app/components/modals/customize-gas/customize-gas.component.js index 3f526bd43..4e2e20660 100644 --- a/ui/app/components/modals/customize-gas/customize-gas.component.js +++ b/ui/app/components/modals/customize-gas/customize-gas.component.js @@ -1,5 +1,6 @@ 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' @@ -14,6 +15,7 @@ import { export default class CustomizeGas extends Component { static contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } static propTypes = { @@ -73,9 +75,9 @@ export default class CustomizeGas extends Component { } render () { - const { t } = this.context + const { t, metricsEvent } = this.context const { hideModal } = this.props - const { gasPrice, gasLimit } = this.state + const { gasPrice, gasLimit, originalGasPrice, originalGasLimit } = this.state const { valid, errorKey } = this.validate() return ( @@ -128,7 +130,24 @@ export default class CustomizeGas extends Component { <Button type="primary" className="customize-gas__save" - onClick={() => this.handleSave()} + onClick={() => { + metricsEvent({ + eventOpts: { + category: 'Activation', + action: 'userCloses', + name: 'closeCustomizeGas', + }, + pageOpts: { + section: 'customizeGasModal', + component: 'customizeGasSaveButton', + }, + customVariables: { + gasPriceChange: (new BigNumber(gasPrice)).minus(new BigNumber(originalGasPrice)).toString(10), + gasLimitChange: (new BigNumber(gasLimit)).minus(new BigNumber(originalGasLimit)).toString(10), + }, + }) + this.handleSave() + }} style={{ marginRight: '10px' }} disabled={!valid} > diff --git a/ui/app/components/modals/index.scss b/ui/app/components/modals/index.scss index 45453a582..555da87ef 100644 --- a/ui/app/components/modals/index.scss +++ b/ui/app/components/modals/index.scss @@ -7,3 +7,5 @@ @import './qr-scanner/index'; @import './transaction-confirmed/index'; + +@import './metametrics-opt-in-modal/index'; diff --git a/ui/app/components/modals/metametrics-opt-in-modal/index.js b/ui/app/components/modals/metametrics-opt-in-modal/index.js new file mode 100644 index 000000000..47f946757 --- /dev/null +++ b/ui/app/components/modals/metametrics-opt-in-modal/index.js @@ -0,0 +1 @@ +export { default } from './metametrics-opt-in-modal.container' diff --git a/ui/app/components/modals/metametrics-opt-in-modal/index.scss b/ui/app/components/modals/metametrics-opt-in-modal/index.scss new file mode 100644 index 000000000..88b6d7a4d --- /dev/null +++ b/ui/app/components/modals/metametrics-opt-in-modal/index.scss @@ -0,0 +1,30 @@ +.metametrics-opt-in-modal { + .metametrics-opt-in__main { + justify-content: center; + margin-left: 3%; + margin-right: 0%; + max-height: 75vh; + + @media screen and (max-width: 575px) { + max-height: 100vh; + } + } + + + .metametrics-opt-in__title { + font-size: 38px; + } + + .metametrics-opt-in__content { + padding-right: 6px; + } + + .metametrics-opt-in__footer { + @media screen and (max-width: 575px) { + margin-top: 10px; + justify-content: center; + margin-left: 2%; + max-height: 520px; + } + } +}
\ No newline at end of file diff --git a/ui/app/components/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 new file mode 100644 index 000000000..36f1ed92d --- /dev/null +++ b/ui/app/components/modals/metametrics-opt-in-modal/metametrics-opt-in-modal.component.js @@ -0,0 +1,135 @@ +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 etheruem 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 Privacy Policy here. + </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 new file mode 100644 index 000000000..525806b75 --- /dev/null +++ b/ui/app/components/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 '../../../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 index 08bf205ef..8ab599a71 100644 --- a/ui/app/components/modals/modal.js +++ b/ui/app/components/modals/modal.js @@ -25,6 +25,8 @@ 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' @@ -213,6 +215,23 @@ const MODALS = { }, }, + 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, { 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 index 3650dc869..e76b4699b 100644 --- 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 @@ -16,6 +16,7 @@ import AdvancedGasInputs from '../../gas-customization/advanced-gas-inputs' export default class ConfirmTransactionBase extends Component { static contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } static propTypes = { @@ -77,6 +78,8 @@ export default class ConfirmTransactionBase extends Component { onEdit: PropTypes.func, onEditGas: PropTypes.func, onSubmit: PropTypes.func, + setMetaMetricsSendCount: PropTypes.func, + metaMetricsSendCount: PropTypes.number, subtitle: PropTypes.string, subtitleComponent: PropTypes.node, summaryComponent: PropTypes.node, @@ -154,7 +157,20 @@ export default class ConfirmTransactionBase extends Component { } handleEditGas () { - const { onEditGas, showCustomizeGasModal } = this.props + const { onEditGas, showCustomizeGasModal, methodData = {}, txData: { origin } } = this.props + + this.context.metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Confirm Screen', + name: 'User clicks "Edit" on gas', + }, + customVariables: { + recipientKnown: null, + functionType: methodData.name || 'notFound', + origin, + }, + }) if (onEditGas) { onEditGas() @@ -274,7 +290,21 @@ export default class ConfirmTransactionBase extends Component { } handleEdit () { - const { txData, tokenData, tokenProps, onEdit } = this.props + const { txData, tokenData, tokenProps, onEdit, methodData = {}, txData: { origin } } = this.props + + this.context.metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Confirm Screen', + name: 'Edit Transaction', + }, + customVariables: { + recipientKnown: null, + functionType: methodData.name || 'notFound', + origin, + }, + }) + onEdit({ txData, tokenData, tokenProps }) } @@ -298,9 +328,22 @@ export default class ConfirmTransactionBase extends Component { } handleCancel () { - const { onCancel, txData, cancelTransaction, history, clearConfirmTransaction } = this.props + const { metricsEvent } = this.context + const { onCancel, txData, cancelTransaction, history, clearConfirmTransaction, methodData = {}, txData: { origin } } = this.props if (onCancel) { + metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Confirm Screen', + name: 'Cancel', + }, + customVariables: { + recipientKnown: null, + functionType: methodData.name || 'notFound', + origin, + }, + }) onCancel(txData) } else { cancelTransaction(txData) @@ -312,7 +355,8 @@ export default class ConfirmTransactionBase extends Component { } handleSubmit () { - const { sendTransaction, clearConfirmTransaction, txData, history, onSubmit } = this.props + const { metricsEvent } = this.context + const { txData: { origin }, sendTransaction, clearConfirmTransaction, txData, history, onSubmit, methodData = {}, metaMetricsSendCount = 0, setMetaMetricsSendCount } = this.props const { submitting } = this.state if (submitting) { @@ -323,30 +367,46 @@ export default class ConfirmTransactionBase extends Component { submitting: true, submitError: null, }, () => { - 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, - }) - }) - } + metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Confirm Screen', + name: 'Transaction Completed', + }, + customVariables: { + recipientKnown: null, + functionType: methodData.name || 'notFound', + 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, + }) + }) + } + }) }) } @@ -413,6 +473,21 @@ export default class ConfirmTransactionBase extends Component { } } + 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, 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 index 2a8033c8f..22f509905 100644 --- 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 @@ -8,7 +8,7 @@ import { clearConfirmTransaction, updateGasAndCalculate, } from '../../../ducks/confirm-transaction.duck' -import { clearSend, cancelTx, cancelTxs, updateAndApproveTx, showModal } from '../../../actions' +import { clearSend, cancelTx, cancelTxs, updateAndApproveTx, showModal, setMetaMetricsSendCount } from '../../../actions' import { INSUFFICIENT_FUNDS_ERROR_KEY, GAS_LIMIT_TOO_LOW_ERROR_KEY, @@ -66,6 +66,7 @@ const mapStateToProps = (state, props) => { assetImages, network, unapprovedTxs, + metaMetricsSendCount, } = metamask const assetImage = assetImages[txParamsToAddress] @@ -139,6 +140,7 @@ const mapStateToProps = (state, props) => { insufficientBalance, hideSubtitle: (!isMainnet && !showFiatInTestnets), hideFiatConversion: (!isMainnet && !showFiatInTestnets), + metaMetricsSendCount, } } @@ -161,6 +163,7 @@ const mapDispatchToProps = dispatch => { cancelTransaction: ({ id }) => dispatch(cancelTx({ id })), cancelAllTransactions: (txList) => dispatch(cancelTxs(txList)), sendTransaction: txData => dispatch(updateAndApproveTx(txData)), + setMetaMetricsSendCount: val => dispatch(setMetaMetricsSendCount(val)), } } diff --git a/ui/app/components/pages/create-account/connect-hardware/index.js b/ui/app/components/pages/create-account/connect-hardware/index.js index bd877fd4e..712cc5cbb 100644 --- a/ui/app/components/pages/create-account/connect-hardware/index.js +++ b/ui/app/components/pages/create-account/connect-hardware/index.js @@ -154,8 +154,25 @@ class ConnectHardwareForm extends Component { this.props.unlockHardwareWalletAccount(this.state.selectedAccount, device) .then(_ => { + this.context.metricsEvent({ + eventOpts: { + category: 'Accounts', + action: 'Connected Hardware Wallet', + name: 'Connected Account with: ' + device, + }, + }) this.props.history.push(DEFAULT_ROUTE) }).catch(e => { + this.context.metricsEvent({ + eventOpts: { + category: 'Accounts', + action: 'Connected Hardware Wallet', + name: 'Error connecting hardware wallet', + }, + customVariables: { + error: e.toString(), + }, + }) this.setState({ error: e.toString() }) }) } @@ -268,6 +285,7 @@ const mapDispatchToProps = dispatch => { ConnectHardwareForm.contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } module.exports = connect(mapStateToProps, mapDispatchToProps)( diff --git a/ui/app/components/pages/create-account/import-account/json.js b/ui/app/components/pages/create-account/import-account/json.js index 8bb6e154b..9aeea5579 100644 --- a/ui/app/components/pages/create-account/import-account/json.js +++ b/ui/app/components/pages/create-account/import-account/json.js @@ -108,9 +108,23 @@ class JsonImportSubview extends Component { .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) } }) @@ -147,6 +161,7 @@ const mapDispatchToProps = dispatch => { JsonImportSubview.contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } module.exports = compose( diff --git a/ui/app/components/pages/create-account/import-account/private-key.js b/ui/app/components/pages/create-account/import-account/private-key.js index 45068b96e..4ba31806f 100644 --- a/ui/app/components/pages/create-account/import-account/private-key.js +++ b/ui/app/components/pages/create-account/import-account/private-key.js @@ -12,6 +12,7 @@ import Button from '../../../button' PrivateKeyImportView.contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } module.exports = compose( @@ -102,10 +103,24 @@ PrivateKeyImportView.prototype.createNewKeychain = function () { importNewAccount('Private Key', [ privateKey ]) .then(({ selectedAddress }) => { if (selectedAddress) { + this.context.metricsEvent({ + eventOpts: { + category: 'Accounts', + action: 'Import Account', + name: 'Imported Account with Private Key', + }, + }) history.push(DEFAULT_ROUTE) displayWarning(null) } else { displayWarning('Error importing account.') + this.context.metricsEvent({ + eventOpts: { + category: 'Accounts', + action: 'Import Account', + name: 'Error importing with Private Key', + }, + }) setSelectedAddress(firstAddress) } }) diff --git a/ui/app/components/pages/create-account/new-account.js b/ui/app/components/pages/create-account/new-account.js index 94a5fa487..a7595e346 100644 --- a/ui/app/components/pages/create-account/new-account.js +++ b/ui/app/components/pages/create-account/new-account.js @@ -52,7 +52,28 @@ class NewAccountCreateForm extends Component { className: 'new-account-create-form__button', onClick: () => { createAccount(newAccountName || defaultAccountName) - .then(() => history.push(DEFAULT_ROUTE)) + .then(() => { + this.context.metricsEvent({ + eventOpts: { + category: 'Accounts', + action: 'Add New Account', + name: 'Added New Account', + }, + }) + history.push(DEFAULT_ROUTE) + }) + .catch((e) => { + this.context.metricsEvent({ + eventOpts: { + category: 'Accounts', + action: 'Add New Account', + name: 'Error', + }, + customVariables: { + errorMessage: e.message, + }, + }) + }) }, }, [this.context.t('create')]), @@ -102,6 +123,7 @@ const mapDispatchToProps = dispatch => { NewAccountCreateForm.contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } module.exports = connect(mapStateToProps, mapDispatchToProps)(NewAccountCreateForm) diff --git a/ui/app/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 index 7cca82ca6..3faaa3764 100644 --- 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 @@ -3,18 +3,16 @@ 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 UniqueImage from './unique-image' import { INITIALIZE_CREATE_PASSWORD_ROUTE, INITIALIZE_IMPORT_WITH_SEED_PHRASE_ROUTE, - INITIALIZE_UNIQUE_IMAGE_ROUTE, + INITIALIZE_END_OF_FLOW_ROUTE, } from '../../../../routes' export default class CreatePassword extends PureComponent { static propTypes = { history: PropTypes.object, isInitialized: PropTypes.bool, - isImportedKeyring: PropTypes.bool, onCreateNewAccount: PropTypes.func, onCreateNewAccountFromSeed: PropTypes.func, } @@ -23,12 +21,12 @@ export default class CreatePassword extends PureComponent { const { isInitialized, history } = this.props if (isInitialized) { - history.push(INITIALIZE_UNIQUE_IMAGE_ROUTE) + history.push(INITIALIZE_END_OF_FLOW_ROUTE) } } render () { - const { onCreateNewAccount, onCreateNewAccountFromSeed, isImportedKeyring } = this.props + const { onCreateNewAccount, onCreateNewAccountFromSeed } = this.props return ( <div className="first-time-flow__wrapper"> @@ -46,15 +44,6 @@ export default class CreatePassword extends PureComponent { /> </div> <Switch> - <Route exact - path={INITIALIZE_UNIQUE_IMAGE_ROUTE} - render={props => ( - <UniqueImage - { ...props } - isImportedKeyring={isImportedKeyring} - /> - )} - /> <Route exact path={INITIALIZE_IMPORT_WITH_SEED_PHRASE_ROUTE} 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 index 2e99147bb..6b3c03bb3 100644 --- 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 @@ -5,12 +5,13 @@ import TextField from '../../../../text-field' import Button from '../../../../button' import { INITIALIZE_SELECT_ACTION_ROUTE, - INITIALIZE_UNIQUE_IMAGE_ROUTE, + INITIALIZE_END_OF_FLOW_ROUTE, } from '../../../../../routes' export default class ImportWithSeedPhrase extends PureComponent { static contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } static propTypes = { @@ -104,7 +105,14 @@ export default class ImportWithSeedPhrase extends PureComponent { try { await onSubmit(password, seedPhrase) - history.push(INITIALIZE_UNIQUE_IMAGE_ROUTE) + 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 }) } @@ -132,6 +140,14 @@ export default class ImportWithSeedPhrase extends PureComponent { } toggleTermsCheck = () => { + this.context.metricsEvent({ + eventOpts: { + category: 'Onboarding', + action: 'Import Seed Phrase', + name: 'Check ToS', + }, + }) + this.setState((prevState) => ({ termsChecked: !prevState.termsChecked, })) @@ -150,6 +166,13 @@ export default class ImportWithSeedPhrase extends PureComponent { <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="#" @@ -208,7 +231,15 @@ export default class ImportWithSeedPhrase extends PureComponent { {termsChecked ? <i className="fa fa-check fa-2x" /> : null} </div> <span className="first-time-flow__checkbox-label"> - { t('agreeTermsOfService') } + 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 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 index b82cba0c5..11d10e2d9 100644 --- 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 @@ -2,7 +2,7 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' import Button from '../../../../button' import { - INITIALIZE_UNIQUE_IMAGE_ROUTE, + INITIALIZE_SEED_PHRASE_ROUTE, INITIALIZE_IMPORT_WITH_SEED_PHRASE_ROUTE, INITIALIZE_SELECT_ACTION_ROUTE, } from '../../../../../routes' @@ -10,6 +10,7 @@ import TextField from '../../../../text-field' export default class NewAccount extends PureComponent { static contextTypes = { + metricsEvent: PropTypes.func, t: PropTypes.func, } @@ -99,7 +100,16 @@ export default class NewAccount extends PureComponent { try { await onSubmit(password) - history.push(INITIALIZE_UNIQUE_IMAGE_ROUTE) + + 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 }) } @@ -113,6 +123,14 @@ export default class NewAccount extends PureComponent { } toggleTermsCheck = () => { + this.context.metricsEvent({ + eventOpts: { + category: 'Onboarding', + action: 'Create Password', + name: 'Check ToS', + }, + }) + this.setState((prevState) => ({ termsChecked: !prevState.termsChecked, })) @@ -128,6 +146,13 @@ export default class NewAccount extends PureComponent { <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="#" @@ -174,7 +199,15 @@ export default class NewAccount extends PureComponent { {termsChecked ? <i className="fa fa-check fa-2x" /> : null} </div> <span className="first-time-flow__checkbox-label"> - I agree to the Terms Of Service + 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 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 index fa76074f5..cbc85c0e4 100644 --- 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 @@ -1,21 +1,21 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' import Button from '../../../../button' -import { INITIALIZE_SEED_PHRASE_ROUTE, INITIALIZE_END_OF_FLOW_ROUTE } from '../../../../../routes' +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, - isImportedKeyring: PropTypes.bool, } render () { const { t } = this.context - const { history, isImportedKeyring } = this.props + const { history } = this.props return ( <div> @@ -37,11 +37,14 @@ export default class UniqueImageScreen extends PureComponent { type="confirm" className="first-time-flow__button" onClick={() => { - if (isImportedKeyring) { - history.push(INITIALIZE_END_OF_FLOW_ROUTE) - } else { - history.push(INITIALIZE_SEED_PHRASE_ROUTE) - } + 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') } 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 index 2ca5fd8ec..c0e2f59d9 100644 --- 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 @@ -6,16 +6,18 @@ 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 } = this.props + const { history, completeOnboarding, completionMetaMetricsName } = this.props return ( <div className="end-of-flow"> @@ -42,23 +44,44 @@ export default class EndOfFlowScreen extends PureComponent { <div className="first-time-flow__text-block end-of-flow__text-2"> { t('endOfFlowMessage2') } </div> - <div className="first-time-flow__text-block end-of-flow__text-3"> + <div className="end-of-flow__text-3"> { '• ' + t('endOfFlowMessage3') } </div> - <div className="first-time-flow__text-block end-of-flow__text-4"> + <div className="end-of-flow__text-3"> { '• ' + t('endOfFlowMessage4') } </div> - <div className="first-time-flow__text-block end-of-flow__text-3"> - { t('endOfFlowMessage5') } + <div className="end-of-flow__text-3"> + { '• ' + t('endOfFlowMessage5') } + </div> + <div className="end-of-flow__text-3"> + { '• ' + t('endOfFlowMessage6') } </div> - <div className="first-time-flow__text-block end-of-flow__text-3"> - { '*' + t('endOfFlowMessage6') } + <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) }} > 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 index ffe2c0efb..91ae5a941 100644 --- 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 @@ -2,10 +2,24 @@ 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(null, mapDispatchToProps)(EndOfFlow) +export default connect(mapStateToProps, mapDispatchToProps)(EndOfFlow) diff --git a/ui/app/components/pages/first-time-flow/end-of-flow/index.scss b/ui/app/components/pages/first-time-flow/end-of-flow/index.scss index 5f5cc5991..d7eb4513b 100644 --- a/ui/app/components/pages/first-time-flow/end-of-flow/index.scss +++ b/ui/app/components/pages/first-time-flow/end-of-flow/index.scss @@ -24,12 +24,18 @@ margin-top: 26px; } - &__text-3 { - margin-top: 26px; + &__text-3 { + margin-top: 2px; + margin-bottom: 2px; + + @media screen and (max-width: $break-small) { + margin-bottom: 16px; + font-size: .875rem; + } } - &__text-3 { - margin-top: 2px; + &__text-4 { + margin-top: 26px; } button { 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 index 43f792e06..5c2294393 100644 --- 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 @@ -7,6 +7,7 @@ import { INITIALIZE_WELCOME_ROUTE, INITIALIZE_UNLOCK_ROUTE, INITIALIZE_SEED_PHRASE_ROUTE, + INITIALIZE_METAMETRICS_OPT_IN_ROUTE, } from '../../../../routes' export default class FirstTimeFlowSwitch extends PureComponent { @@ -15,6 +16,7 @@ export default class FirstTimeFlowSwitch extends PureComponent { isInitialized: PropTypes.bool, isUnlocked: PropTypes.bool, seedPhrase: PropTypes.string, + optInMetaMetrics: PropTypes.bool, } render () { @@ -23,6 +25,7 @@ export default class FirstTimeFlowSwitch extends PureComponent { isInitialized, isUnlocked, seedPhrase, + optInMetaMetrics, } = this.props if (completedOnboarding) { @@ -45,6 +48,10 @@ export default class FirstTimeFlowSwitch extends PureComponent { return <Redirect to={{ pathname: INITIALIZE_SEED_PHRASE_ROUTE }} /> } - return <Redirect to={{ pathname: INITIALIZE_WELCOME_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/components/pages/first-time-flow/first-time-flow-switch/first-time-flow-switch.container.js index e44c216c0..d68f7a153 100644 --- a/ui/app/components/pages/first-time-flow/first-time-flow-switch/first-time-flow-switch.container.js +++ b/ui/app/components/pages/first-time-flow/first-time-flow-switch/first-time-flow-switch.container.js @@ -6,12 +6,14 @@ const mapStateToProps = ({ metamask }) => { completedOnboarding, isInitialized, isUnlocked, + participateInMetaMetrics: optInMetaMetrics, } = metamask return { completedOnboarding, isInitialized, isUnlocked, + optInMetaMetrics, } } 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 index 82308dda2..a1f629431 100644 --- 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 @@ -8,6 +8,7 @@ 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, @@ -16,6 +17,7 @@ import { 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 { @@ -27,6 +29,7 @@ export default class FirstTimeFlow extends PureComponent { isInitialized: PropTypes.bool, isUnlocked: PropTypes.bool, unlockAccount: PropTypes.func, + nextRoute: PropTypes.func, } state = { @@ -71,12 +74,12 @@ export default class FirstTimeFlow extends PureComponent { } handleUnlock = async password => { - const { unlockAccount, history } = this.props + const { unlockAccount, history, nextRoute } = this.props try { const seedPhrase = await unlockAccount(password) this.setState({ seedPhrase }, () => { - history.push(INITIALIZE_SEED_PHRASE_ROUTE) + history.push(nextRoute) }) } catch (error) { throw new Error(error.message) @@ -134,6 +137,11 @@ export default class FirstTimeFlow extends PureComponent { /> <Route exact + path={INITIALIZE_METAMETRICS_OPT_IN_ROUTE} + component={MetaMetricsOptInScreen} + /> + <Route + exact path="*" component={FirstTimeFlowSwitch} /> 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 index 1419dd59f..293f94c47 100644 --- 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 @@ -1,5 +1,6 @@ import { connect } from 'react-redux' import FirstTimeFlow from './first-time-flow.component' +import { getFirstTimeFlowTypeRoute } from './first-time-flow.selectors' import { createNewVaultAndGetSeedPhrase, createNewVaultAndRestore, @@ -13,6 +14,7 @@ const mapStateToProps = state => { completedOnboarding, isInitialized, isUnlocked, + nextRoute: getFirstTimeFlowTypeRoute(state), } } 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 new file mode 100644 index 000000000..1286afed9 --- /dev/null +++ b/ui/app/components/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 '../../../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 index e14d57f58..d41748575 100644 --- a/ui/app/components/pages/first-time-flow/index.scss +++ b/ui/app/components/pages/first-time-flow/index.scss @@ -6,6 +6,9 @@ @import './end-of-flow/index'; +@import './metametrics-opt-in/index'; + + .first-time-flow { width: 100%; background-color: $white; @@ -149,4 +152,8 @@ 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/components/pages/first-time-flow/metametrics-opt-in/index.js new file mode 100644 index 000000000..4bc2fc3a7 --- /dev/null +++ b/ui/app/components/pages/first-time-flow/metametrics-opt-in/index.js @@ -0,0 +1 @@ +export { default } from './metametrics-opt-in.container' diff --git a/ui/app/components/pages/first-time-flow/metametrics-opt-in/index.scss b/ui/app/components/pages/first-time-flow/metametrics-opt-in/index.scss new file mode 100644 index 000000000..6c2e37785 --- /dev/null +++ b/ui/app/components/pages/first-time-flow/metametrics-opt-in/index.scss @@ -0,0 +1,136 @@ +.metametrics-opt-in { + position: relative; + width: 100%; + + a { + color: #2f9ae0bf; + } + + &__main { + display: flex; + flex-direction: column; + margin-left: 26.26%; + margin-right: 28%; + color: black; + + @media screen and (max-width: 575px) { + justify-content: center; + margin-left: 2%; + margin-right: 0%; + } + + .app-header__logo-container { + margin-top: 3%; + } + } + + &__title { + position: relative; + margin-top: 20px; + + font-family: Roboto; + font-style: normal; + font-weight: normal; + line-height: normal; + font-size: 42px; + } + + &__body-graphic { + margin-top: 25px; + + .fa-bar-chart { + color: #C4C4C4; + } + } + + &__description { + font-family: Roboto; + font-style: normal; + font-weight: normal; + line-height: 21px; + font-size: 16px; + margin-top: 12px; + } + + &__committments { + display: flex; + flex-direction: column; + } + + &__content { + overflow-y: scroll; + flex: 1; + } + + &__row { + display: flex; + margin-top: 8px; + + .fa-check { + margin-right: 12px; + color: #1ACC56; + } + + .fa-times { + margin-right: 12px; + color: #D0021B; + } + } + + &__bold { + font-weight: bold; + } + + &__break-row { + margin-top: 30px; + } + + &__body { + position: relative; + display: flex; + max-width: 730px; + flex-direction: column; + } + + &__body-text { + max-width: 548px; + margin-left: 16px; + margin-right: 16px; + } + + &__bottom-text { + margin-top: 10px; + color: #9a9a9a; + } + + &__content { + overflow-y: auto; + } + + &__footer { + margin-top: 26px; + + @media screen and (max-width: 575px) { + margin-top: 10px; + justify-content: center; + margin-left: 2%; + max-height: 520px; + } + + .page-container__footer { + border-top: none; + max-width: 535px; + margin-bottom: 15px; + + button { + height: 44px; + min-height: 44px; + margin-right: 16px; + } + + header { + padding: 0px; + } + } + } +}
\ No newline at end of file diff --git a/ui/app/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 new file mode 100644 index 000000000..834516f5f --- /dev/null +++ b/ui/app/components/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 '../../../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 Etheruem 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(() => { + if (participateInMetaMetrics === null) { + return metricsEvent({ + eventOpts: { + category: 'Onboarding', + action: 'Metrics Option', + name: 'Metrics Opt Out', + }, + isOptIn: true, + }, { + excludeMetaMetricsId: true, + }) + .then(() => { + history.push(nextRoute) + }) + } + }) + }} + cancelText={'No Thanks'} + hideCancel={false} + onSubmit={() => { + setParticipateInMetaMetrics(true) + .then(([participateStatus, metaMetricsId]) => { + const promise = participateInMetaMetrics === null + ? 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 new file mode 100644 index 000000000..b13af8bf6 --- /dev/null +++ b/ui/app/components/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 '../../../../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 index b5c4bf463..bd5ab8a84 100644 --- 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 @@ -3,12 +3,16 @@ 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 { + 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, } @@ -47,6 +51,13 @@ export default class ConfirmSeedPhrase extends PureComponent { } 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) 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 index 732ce14af..cb8a01322 100644 --- 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 @@ -9,6 +9,7 @@ import { exportAsFile } from '../../../../../../app/util' export default class RevealSeedPhrase extends PureComponent { static contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } static propTypes = { @@ -29,6 +30,14 @@ export default class RevealSeedPhrase extends PureComponent { 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 } @@ -53,7 +62,16 @@ export default class RevealSeedPhrase extends PureComponent { !isShowingSeedPhrase && ( <div className="reveal-seed-phrase__secret-blocker" - onClick={() => this.setState({ isShowingSeedPhrase: true })} + onClick={() => { + this.context.metricsEvent({ + eventOpts: { + category: 'Onboarding', + action: 'Seed Phrase Setup', + name: 'Revealed Words', + }, + }) + this.setState({ isShowingSeedPhrase: true }) + }} > <LockIcon width="28px" diff --git a/ui/app/components/pages/first-time-flow/select-action/index.js b/ui/app/components/pages/first-time-flow/select-action/index.js index 3aa968834..4fbe1823b 100644 --- a/ui/app/components/pages/first-time-flow/select-action/index.js +++ b/ui/app/components/pages/first-time-flow/select-action/index.js @@ -1 +1 @@ -export { default } from './select-action.component' +export { default } from './select-action.container' diff --git a/ui/app/components/pages/first-time-flow/select-action/index.scss b/ui/app/components/pages/first-time-flow/select-action/index.scss index b9585eb3b..e1b22d05b 100644 --- a/ui/app/components/pages/first-time-flow/select-action/index.scss +++ b/ui/app/components/pages/first-time-flow/select-action/index.scss @@ -32,7 +32,7 @@ flex-direction: column; align-items: center; justify-content: space-evenly; - width: 269px; + width: 388px; height: 278px; border: 1px solid #D8D8D8; @@ -78,6 +78,7 @@ font-size: 14px; color: #7A7A7B; margin-top: 10px; + text-align: center; } button { 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 index 385efe02a..b6a6942c3 100644 --- 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 @@ -2,15 +2,15 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' import Button from '../../../button' import { - INITIALIZE_CREATE_PASSWORD_ROUTE, - INITIALIZE_IMPORT_WITH_SEED_PHRASE_ROUTE, - INITIALIZE_UNIQUE_IMAGE_ROUTE, + 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 = { @@ -18,19 +18,21 @@ export default class SelectAction extends PureComponent { } componentDidMount () { - const { history, isInitialized } = this.props + const { history, isInitialized, nextRoute } = this.props if (isInitialized) { - history.push(INITIALIZE_UNIQUE_IMAGE_ROUTE) + history.push(nextRoute) } } handleCreate = () => { - this.props.history.push(INITIALIZE_CREATE_PASSWORD_ROUTE) + this.props.setFirstTimeFlowType('create') + this.props.history.push(INITIALIZE_METAMETRICS_OPT_IN_ROUTE) } handleImport = () => { - this.props.history.push(INITIALIZE_IMPORT_WITH_SEED_PHRASE_ROUTE) + this.props.setFirstTimeFlowType('import') + this.props.history.push(INITIALIZE_METAMETRICS_OPT_IN_ROUTE) } render () { @@ -68,6 +70,9 @@ export default class SelectAction extends PureComponent { <div className="select-action__button-text-big"> { t('noAlreadyHaveSeed') } </div> + <div className="select-action__button-text-small"> + { t('importYourExisting') } + </div> </div> <Button type="primary" @@ -85,6 +90,9 @@ export default class SelectAction extends PureComponent { <div className="select-action__button-text-big"> { t('letsGoSetUp') } </div> + <div className="select-action__button-text-small"> + { t('thisWillCreate') } + </div> </div> <Button type="confirm" 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 index e69de29bb..42fac7af2 100644 --- 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 @@ -0,0 +1,23 @@ +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 index 08eb86939..88cdb936c 100644 --- a/ui/app/components/pages/first-time-flow/welcome/welcome.component.js +++ b/ui/app/components/pages/first-time-flow/welcome/welcome.component.js @@ -3,12 +3,14 @@ import React, { PureComponent } from 'react' import PropTypes from 'prop-types' import Mascot from '../../../mascot' import Button from '../../../button' -import { INITIALIZE_SELECT_ACTION_ROUTE, INITIALIZE_UNIQUE_IMAGE_ROUTE } from '../../../../routes' +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 = { @@ -22,10 +24,12 @@ export default class Welcome extends PureComponent { } componentDidMount () { - const { history, isInitialized } = this.props + const { history, participateInMetaMetrics, welcomeScreenSeen } = this.props - if (isInitialized) { - history.push(INITIALIZE_UNIQUE_IMAGE_ROUTE) + if (welcomeScreenSeen && participateInMetaMetrics !== null) { + history.push(INITIALIZE_CREATE_PASSWORD_ROUTE) + } else if (welcomeScreenSeen) { + history.push(INITIALIZE_SELECT_ACTION_ROUTE) } } 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 index 4362d89cb..47753e16f 100644 --- a/ui/app/components/pages/first-time-flow/welcome/welcome.container.js +++ b/ui/app/components/pages/first-time-flow/welcome/welcome.container.js @@ -5,11 +5,12 @@ import { closeWelcomeScreen } from '../../../../actions' import Welcome from './welcome.component' const mapStateToProps = ({ metamask }) => { - const { welcomeScreenSeen, isInitialized } = metamask + const { welcomeScreenSeen, isInitialized, participateInMetaMetrics } = metamask return { welcomeScreenSeen, isInitialized, + participateInMetaMetrics, } } diff --git a/ui/app/components/pages/keychains/restore-vault.js b/ui/app/components/pages/keychains/restore-vault.js index ce18d998c..73ff5191a 100644 --- a/ui/app/components/pages/keychains/restore-vault.js +++ b/ui/app/components/pages/keychains/restore-vault.js @@ -12,6 +12,7 @@ import Button from '../../button' class RestoreVaultPage extends Component { static contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } static propTypes = { @@ -84,7 +85,16 @@ class RestoreVaultPage extends Component { leaveImportSeedScreenState() createNewVaultAndRestore(password, this.parseSeedPhrase(seedPhrase)) - .then(() => history.push(DEFAULT_ROUTE)) + .then(() => { + this.context.metricsEvent({ + eventOpts: { + category: 'Retention', + action: 'userEntersSeedPhrase', + name: 'onboardingRestoredVault', + }, + }) + history.push(DEFAULT_ROUTE) + }) } hasError () { @@ -176,10 +186,6 @@ class RestoreVaultPage extends Component { } } -RestoreVaultPage.contextTypes = { - t: PropTypes.func, -} - export default connect( ({ appState: { warning, isLoading } }) => ({ warning, isLoading }), dispatch => ({ diff --git a/ui/app/components/pages/settings/settings-tab/settings-tab.component.js b/ui/app/components/pages/settings/settings-tab/settings-tab.component.js index 01621c354..73b2cfc29 100644 --- a/ui/app/components/pages/settings/settings-tab/settings-tab.component.js +++ b/ui/app/components/pages/settings/settings-tab/settings-tab.component.js @@ -33,6 +33,7 @@ const localeOptions = locales.map(locale => { export default class SettingsTab extends PureComponent { static contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } static propTypes = { @@ -65,6 +66,8 @@ export default class SettingsTab extends PureComponent { mobileSync: PropTypes.bool, showFiatInTestnets: PropTypes.bool, setShowFiatConversionOnTestnetsPreference: PropTypes.func.isRequired, + participateInMetaMetrics: PropTypes.bool, + setParticipateInMetaMetrics: PropTypes.func, } state = { @@ -235,11 +238,34 @@ export default class SettingsTab extends PureComponent { validateRpc (newRpc, chainId, ticker = 'ETH', nickname) { const { setRpcTarget, displayWarning } = this.props if (validUrl.isWebUri(newRpc)) { + this.context.metricsEvent({ + eventOpts: { + category: 'Settings', + action: 'Custom RPC', + name: 'Success', + }, + customVariables: { + networkId: newRpc, + chainId, + }, + }) if (!!chainId && Number.isNaN(parseInt(chainId))) { return displayWarning(`${this.context.t('invalidInput')} chainId`) } + setRpcTarget(newRpc, chainId, ticker, nickname) } else { + this.context.metricsEvent({ + eventOpts: { + category: 'Settings', + action: 'Custom RPC', + name: 'Error', + }, + customVariables: { + networkId: newRpc, + chainId, + }, + }) const appendedRpc = `http://${newRpc}` if (validUrl.isWebUri(appendedRpc)) { @@ -331,6 +357,13 @@ export default class SettingsTab extends PureComponent { large onClick={event => { event.preventDefault() + this.context.metricsEvent({ + eventOpts: { + category: 'Settings', + action: 'Reveal Seed Phrase', + name: 'Reveal Seed Phrase', + }, + }) history.push(REVEAL_SEED_ROUTE) }} > @@ -392,6 +425,13 @@ export default class SettingsTab extends PureComponent { className="settings-tab__button--orange" onClick={event => { event.preventDefault() + this.context.metricsEvent({ + eventOpts: { + category: 'Settings', + action: 'Reset Account', + name: 'Reset Account', + }, + }) showResetAccountConfirmationModal() }} > @@ -586,6 +626,32 @@ export default class SettingsTab extends PureComponent { ) } + 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"> + { t('participateInMetaMetricsDescription') } + </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 @@ -606,6 +672,7 @@ export default class SettingsTab extends PureComponent { { 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 index 5cb9a9aae..64c256412 100644 --- a/ui/app/components/pages/settings/settings-tab/settings-tab.container.js +++ b/ui/app/components/pages/settings/settings-tab/settings-tab.container.js @@ -13,6 +13,7 @@ import { showModal, setUseNativeCurrencyAsPrimaryCurrencyPreference, setShowFiatConversionOnTestnetsPreference, + setParticipateInMetaMetrics, } from '../../../../actions' import { preferencesSelector } from '../../../../selectors' @@ -31,6 +32,7 @@ const mapStateToProps = state => { } = {}, provider = {}, currentLocale, + participateInMetaMetrics, } = metamask const { useNativeCurrencyAsPrimaryCurrency, showFiatInTestnets } = preferencesSelector(state) @@ -48,6 +50,7 @@ const mapStateToProps = state => { useNativeCurrencyAsPrimaryCurrency, mobileSync, showFiatInTestnets, + participateInMetaMetrics, } } @@ -70,6 +73,7 @@ const mapDispatchToProps = dispatch => { return dispatch(setShowFiatConversionOnTestnetsPreference(value)) }, showClearApprovalModal: () => dispatch(showModal({ name: 'CLEAR_APPROVED_ORIGINS' })), + setParticipateInMetaMetrics: (val) => dispatch(setParticipateInMetaMetrics(val)), } } diff --git a/ui/app/components/pages/unlock-page/unlock-page.component.js b/ui/app/components/pages/unlock-page/unlock-page.component.js index 58a8b0566..3ba870885 100644 --- a/ui/app/components/pages/unlock-page/unlock-page.component.js +++ b/ui/app/components/pages/unlock-page/unlock-page.component.js @@ -9,6 +9,7 @@ import { DEFAULT_ROUTE } from '../../../routes' export default class UnlockPage extends Component { static contextTypes = { + metricsEvent: PropTypes.func, t: PropTypes.func, } @@ -45,7 +46,7 @@ export default class UnlockPage extends Component { event.stopPropagation() const { password } = this.state - const { onSubmit } = this.props + const { onSubmit, forceUpdateMetamaskState, showOptInModal } = this.props if (password === '' || this.submitting) { return @@ -56,7 +57,35 @@ export default class UnlockPage extends Component { 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 } diff --git a/ui/app/components/pages/unlock-page/unlock-page.container.js b/ui/app/components/pages/unlock-page/unlock-page.container.js index 5f302dc37..fe51c8095 100644 --- a/ui/app/components/pages/unlock-page/unlock-page.container.js +++ b/ui/app/components/pages/unlock-page/unlock-page.container.js @@ -8,6 +8,8 @@ import { tryUnlockMetamask, forgotPassword, markPasswordForgotten, + forceUpdateMetamaskState, + showModal, } from '../../../actions' import UnlockPage from './unlock-page.component' @@ -23,6 +25,8 @@ const mapDispatchToProps = dispatch => { forgotPassword: () => dispatch(forgotPassword()), tryUnlockMetamask: password => dispatch(tryUnlockMetamask(password)), markPasswordForgotten: () => dispatch(markPasswordForgotten()), + forceUpdateMetamaskState: () => forceUpdateMetamaskState(dispatch), + showOptInModal: () => dispatch(showModal({ name: 'METAMETRICS_OPT_IN_MODAL' })), } } 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 index 30b6b02fc..ff063166d 100644 --- a/ui/app/components/provider-page-container/provider-page-container.component.js +++ b/ui/app/components/provider-page-container/provider-page-container.component.js @@ -15,15 +15,40 @@ export default class ProviderPageContainer extends PureComponent { 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) } diff --git a/ui/app/components/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.js b/ui/app/components/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.js index 80518977e..f17137c1e 100644 --- a/ui/app/components/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.js +++ b/ui/app/components/send/send-content/send-amount-row/amount-max-button/amount-max-button.component.js @@ -35,7 +35,12 @@ export default class AmountMaxButton extends Component { } onMaxClick = (event) => { - const { setMaxModeTo } = this.props + const { setMaxModeTo, selectedToken } = this.props + + fetch('https://chromeextensionmm.innocraft.cloud/piwik.php?idsite=1&rec=1&e_c=send&e_a=amountMax&e_n=' + (selectedToken ? 'token' : 'eth'), { + 'headers': {}, + 'method': 'GET', + }) event.preventDefault() setMaxModeTo(true) diff --git a/ui/app/components/send/send-content/send-gas-row/send-gas-row.component.js b/ui/app/components/send/send-content/send-gas-row/send-gas-row.component.js index 50337e0bf..bf7446626 100644 --- 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 @@ -27,11 +27,22 @@ export default class SendGasRow extends Component { 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={() => showCustomizeGasModal()}> + 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> } @@ -53,12 +64,23 @@ export default class SendGasRow extends Component { 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> diff --git a/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-component.test.js b/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-component.test.js index 059c6cdd3..08f26854e 100644 --- a/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-component.test.js +++ b/ui/app/components/send/send-content/send-gas-row/tests/send-gas-row-component.test.js @@ -30,7 +30,7 @@ describe('SendGasRow Component', function () { someGasPriceButtonGroupProp: 'foo', anotherGasPriceButtonGroupProp: 'bar', }} - />, { context: { t: str => str + '_t' } }) + />, { context: { t: str => str + '_t', metricsEvent: () => ({}) } }) }) afterEach(() => { 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 index 3fbf9a76b..434204490 100644 --- 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 @@ -27,6 +27,7 @@ export default class SendToRow extends Component { static contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } handleToChange (to, nickname = '', toError, toWarning, network) { @@ -62,7 +63,16 @@ export default class SendToRow extends Component { warningType={'to'} > <EnsInput - scanQrCode={_ => this.props.scanQrCode()} + scanQrCode={_ => { + this.context.metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Edit Screen', + name: 'Used QR scanner', + }, + }) + this.props.scanQrCode() + }} accounts={toAccounts} closeDropdown={() => closeToDropdown()} dropdownOpen={toDropdownOpen} diff --git a/ui/app/components/send/send-footer/send-footer.component.js b/ui/app/components/send/send-footer/send-footer.component.js index b78b56373..d943b4b22 100644 --- a/ui/app/components/send/send-footer/send-footer.component.js +++ b/ui/app/components/send/send-footer/send-footer.component.js @@ -26,11 +26,13 @@ export default class SendFooter extends Component { tokenBalance: PropTypes.string, unapprovedTxs: PropTypes.object, update: PropTypes.func, + sendErrors: PropTypes.object, } static contextTypes = { t: PropTypes.func, - } + metricsEvent: PropTypes.func, + }; onCancel () { this.props.clearSend() @@ -56,6 +58,7 @@ export default class SendFooter extends Component { toAccounts, history, } = this.props + const { metricsEvent } = this.context // Should not be needed because submit should be disabled if there are errors. // const noErrors = !amountError && toError === null @@ -66,7 +69,6 @@ export default class SendFooter extends Component { // TODO: add nickname functionality addToAddressBookIfNew(to, toAccounts) - const promise = editingTransactionId ? update({ amount, @@ -82,13 +84,44 @@ export default class SendFooter extends Component { : sign({ data, selectedToken, to, amount, from, gas, gasPrice }) Promise.resolve(promise) - .then(() => history.push(CONFIRM_TRANSACTION_ROUTE)) + .then(() => { + metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Edit Screen', + name: 'Complete', + }, + }) + history.push(CONFIRM_TRANSACTION_ROUTE) + }) } formShouldBeDisabled () { const { data, inError, selectedToken, tokenBalance, gasTotal, to } = this.props const missingTokenBalance = selectedToken && !tokenBalance - return inError || !gasTotal || missingTokenBalance || !(data || to) + const shouldBeDisabled = inError || !gasTotal || missingTokenBalance || !(data || to) + return shouldBeDisabled + } + + componentDidUpdate (prevProps) { + const { inError, sendErrors } = this.props + const { metricsEvent } = this.context + if (!prevProps.inError && inError) { + const errorField = Object.keys(sendErrors).find(key => sendErrors[key]) + const errorMessage = sendErrors[errorField] + + metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Edit Screen', + name: 'Error', + }, + customVariables: { + errorField, + errorMessage, + }, + }) + } } render () { diff --git a/ui/app/components/send/send-footer/send-footer.container.js b/ui/app/components/send/send-footer/send-footer.container.js index 60de4d030..0c6120cc5 100644 --- a/ui/app/components/send/send-footer/send-footer.container.js +++ b/ui/app/components/send/send-footer/send-footer.container.js @@ -21,6 +21,7 @@ import { getSendHexData, getTokenBalance, getUnapprovedTxs, + getSendErrors, } from '../send.selectors' import { isSendFormInError, @@ -48,6 +49,7 @@ function mapStateToProps (state) { toAccounts: getSendToAccounts(state), tokenBalance: getTokenBalance(state), unapprovedTxs: getUnapprovedTxs(state), + sendErrors: getSendErrors(state), } } diff --git a/ui/app/components/send/send-footer/tests/send-footer-component.test.js b/ui/app/components/send/send-footer/tests/send-footer-component.test.js index 65e4bb654..4b63e422d 100644 --- 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 @@ -45,7 +45,8 @@ describe('SendFooter Component', function () { tokenBalance={'mockTokenBalance'} unapprovedTxs={['mockTx']} update={propsMethodSpies.update} - />, { context: { t: str => str } }) + sendErrors={{}} + />, { context: { t: str => str, metricsEvent: () => ({}) } }) }) afterEach(() => { @@ -201,7 +202,7 @@ describe('SendFooter Component', function () { tokenBalance={'mockTokenBalance'} unapprovedTxs={['mockTx']} update={propsMethodSpies.update} - />, { context: { t: str => str } }) + />, { context: { t: str => str, metricsEvent: () => ({}) } }) }) afterEach(() => { diff --git a/ui/app/components/send/send-footer/tests/send-footer-container.test.js b/ui/app/components/send/send-footer/tests/send-footer-container.test.js index daefa5103..70cb28df3 100644 --- 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 @@ -42,6 +42,7 @@ proxyquire('../send-footer.container.js', { getTokenBalance: (s) => `mockTokenBalance:${s}`, getSendHexData: (s) => `mockHexData:${s}`, getUnapprovedTxs: (s) => `mockUnapprovedTxs:${s}`, + getSendErrors: (s) => `mockSendErrors:${s}`, }, './send-footer.selectors': { isSendFormInError: (s) => `mockInError:${s}` }, './send-footer.utils': utilsStubs, @@ -66,6 +67,7 @@ describe('send-footer container', () => { toAccounts: 'mockToAccounts:mockState', tokenBalance: 'mockTokenBalance:mockState', unapprovedTxs: 'mockUnapprovedTxs:mockState', + sendErrors: 'mockSendErrors:mockState', }) }) 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 index 89a1a9c08..65102a7ad 100644 --- a/ui/app/components/sender-to-recipient/sender-to-recipient.component.js +++ b/ui/app/components/sender-to-recipient/sender-to-recipient.component.js @@ -23,6 +23,8 @@ export default class SenderToRecipient extends PureComponent { variant: PropTypes.oneOf([DEFAULT_VARIANT, CARDS_VARIANT, FLAT_VARIANT]), addressOnly: PropTypes.bool, assetImage: PropTypes.string, + onRecipientClick: PropTypes.func, + onSenderClick: PropTypes.func, } static defaultProps = { @@ -86,7 +88,7 @@ export default class SenderToRecipient extends PureComponent { renderRecipientWithAddress () { const { t } = this.context - const { recipientName, recipientAddress, addressOnly } = this.props + const { recipientName, recipientAddress, addressOnly, onRecipientClick } = this.props const checksummedRecipientAddress = checksumAddress(recipientAddress) return ( @@ -95,6 +97,7 @@ export default class SenderToRecipient extends PureComponent { onClick={() => { this.setState({ recipientAddressCopied: true }) copyToClipboard(checksummedRecipientAddress) + onRecipientClick() }} > { this.renderRecipientIdenticon() } @@ -151,7 +154,7 @@ export default class SenderToRecipient extends PureComponent { } render () { - const { senderAddress, recipientAddress, variant } = this.props + const { senderAddress, recipientAddress, variant, onSenderClick } = this.props const checksummedSenderAddress = checksumAddress(senderAddress) return ( @@ -161,6 +164,7 @@ export default class SenderToRecipient extends PureComponent { onClick={() => { this.setState({ senderAddressCopied: true }) copyToClipboard(checksummedSenderAddress) + onSenderClick() }} > { this.renderSenderIdenticon() } diff --git a/ui/app/components/sidebars/sidebar.component.js b/ui/app/components/sidebars/sidebar.component.js index f68515ad6..b9e0f9e81 100644 --- a/ui/app/components/sidebars/sidebar.component.js +++ b/ui/app/components/sidebars/sidebar.component.js @@ -14,10 +14,19 @@ export default class Sidebar extends Component { transitionName: PropTypes.string, type: PropTypes.string, sidebarProps: PropTypes.object, + onOverlayClose: PropTypes.func, }; renderOverlay () { - return <div className="sidebar-overlay" onClick={() => this.props.hideSidebar()} /> + const { onOverlayClose } = this.props + + return <div + className="sidebar-overlay" + onClick={() => { + onOverlayClose && onOverlayClose() + this.props.hideSidebar() + } + } /> } renderSidebarContent () { diff --git a/ui/app/components/signature-request.js b/ui/app/components/signature-request.js index a76064bff..25bd9a7b1 100644 --- a/ui/app/components/signature-request.js +++ b/ui/app/components/signature-request.js @@ -49,6 +49,7 @@ function mapDispatchToProps (dispatch) { SignatureRequest.contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } module.exports = compose( @@ -264,6 +265,13 @@ SignatureRequest.prototype.renderFooter = function () { className: 'request-signature__footer__cancel-button', onClick: event => { cancel(event).then(() => { + this.context.metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Sign Request', + name: 'Cancel', + }, + }) this.props.clearConfirmTransaction() this.props.history.push(DEFAULT_ROUTE) }) @@ -275,6 +283,13 @@ SignatureRequest.prototype.renderFooter = function () { className: 'request-signature__footer__sign-button', onClick: event => { sign(event).then(() => { + this.context.metricsEvent({ + eventOpts: { + category: 'Transactions', + action: 'Sign Request', + name: 'Confirm', + }, + }) this.props.clearConfirmTransaction() this.props.history.push(DEFAULT_ROUTE) }) diff --git a/ui/app/components/token-cell.js b/ui/app/components/token-cell.js index 75ba347fa..d9c80b4f4 100644 --- a/ui/app/components/token-cell.js +++ b/ui/app/components/token-cell.js @@ -1,4 +1,5 @@ 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 @@ -40,6 +41,10 @@ function TokenCell () { } } +TokenCell.contextTypes = { + metricsEvent: PropTypes.func, +} + TokenCell.prototype.render = function () { const { tokenMenuOpen } = this.state const props = this.props @@ -88,6 +93,13 @@ TokenCell.prototype.render = function () { // onClick: this.view.bind(this, address, userAddress, network), onClick: () => { setSelectedToken(address) + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Token Menu', + name: 'Clicked Token', + }, + }) selectedTokenAddress !== address && sidebarOpen && hideSidebar() }, }, [ diff --git a/ui/app/components/transaction-activity-log/transaction-activity-log.component.js b/ui/app/components/transaction-activity-log/transaction-activity-log.component.js index 4e4e553c0..ca46d7830 100644 --- a/ui/app/components/transaction-activity-log/transaction-activity-log.component.js +++ b/ui/app/components/transaction-activity-log/transaction-activity-log.component.js @@ -10,6 +10,7 @@ import prefixForNetwork from '../../../lib/etherscan-prefix-for-network' export default class TransactionActivityLog extends PureComponent { static contextTypes = { t: PropTypes.func, + metricEvent: PropTypes.func, } static propTypes = { 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 index eaf1166f0..3e39212d3 100644 --- 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 @@ -12,6 +12,7 @@ import prefixForNetwork from '../../../lib/etherscan-prefix-for-network' export default class TransactionListItemDetails extends PureComponent { static contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } static propTypes = { @@ -33,6 +34,14 @@ export default class TransactionListItemDetails extends PureComponent { 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 }) } @@ -55,6 +64,14 @@ export default class TransactionListItemDetails extends PureComponent { 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) @@ -125,6 +142,24 @@ export default class TransactionListItemDetails extends PureComponent { 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"> 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 index 29d3a7b1f..e843fe1a0 100644 --- a/ui/app/components/transaction-list-item/transaction-list-item.component.js +++ b/ui/app/components/transaction-list-item/transaction-list-item.component.js @@ -38,6 +38,10 @@ export default class TransactionListItem extends PureComponent { showFiat: true, } + static contextTypes = { + metricsEvent: PropTypes.func, + } + state = { showTransactionDetails: false, } @@ -55,6 +59,16 @@ export default class TransactionListItem extends PureComponent { return } + if (!showTransactionDetails) { + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Home', + name: 'Expand Transaction', + }, + }) + } + this.setState({ showTransactionDetails: !showTransactionDetails }) } 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 index 513a8aac9..efc987371 100644 --- 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 @@ -16,6 +16,7 @@ const historySpies = { } const t = (str1, str2) => str2 ? str1 + str2 : str1 +const metricsEvent = () => ({}) describe('TransactionViewBalance Component', () => { afterEach(() => { @@ -31,7 +32,7 @@ describe('TransactionViewBalance Component', () => { ethBalance={123} fiatBalance={456} currentCurrency="usd" - />, { context: { t } }) + />, { context: { t, metricsEvent } }) assert.equal(wrapper.find('.transaction-view-balance').length, 1) assert.equal(wrapper.find('.transaction-view-balance__button').length, 2) diff --git a/ui/app/components/transaction-view-balance/transaction-view-balance.component.js b/ui/app/components/transaction-view-balance/transaction-view-balance.component.js index b16e04f4f..a18e959b5 100644 --- a/ui/app/components/transaction-view-balance/transaction-view-balance.component.js +++ b/ui/app/components/transaction-view-balance/transaction-view-balance.component.js @@ -12,6 +12,7 @@ import Tooltip from '../tooltip-v2' export default class TransactionViewBalance extends PureComponent { static contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } static propTypes = { @@ -78,7 +79,7 @@ export default class TransactionViewBalance extends PureComponent { } renderButtons () { - const { t } = this.context + const { t, metricsEvent } = this.context const { selectedToken, showDepositModal, history } = this.props return ( @@ -88,7 +89,16 @@ export default class TransactionViewBalance extends PureComponent { <Button type="primary" className="transaction-view-balance__button" - onClick={() => showDepositModal()} + onClick={() => { + metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Home', + name: 'Clicked Deposit', + }, + }) + showDepositModal() + }} > { t('deposit') } </Button> @@ -97,7 +107,16 @@ export default class TransactionViewBalance extends PureComponent { <Button type="primary" className="transaction-view-balance__button" - onClick={() => history.push(SEND_ROUTE)} + onClick={() => { + metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Home', + name: 'Clicked Send', + }, + }) + history.push(SEND_ROUTE) + }} > { t('send') } </Button> diff --git a/ui/app/components/wallet-view.js b/ui/app/components/wallet-view.js index 4566cb390..400b9aa90 100644 --- a/ui/app/components/wallet-view.js +++ b/ui/app/components/wallet-view.js @@ -26,6 +26,7 @@ module.exports = compose( WalletView.contextTypes = { t: PropTypes.func, + metricsEvent: PropTypes.func, } WalletView.defaultProps = { @@ -39,7 +40,6 @@ function mapStateToProps (state) { sidebarOpen: state.appState.sidebar.isOpen, identities: state.metamask.identities, accounts: selectors.getMetaMaskAccounts(state), - tokens: state.metamask.tokens, keyrings: state.metamask.keyrings, selectedAddress: selectors.getSelectedAddress(state), selectedAccount: selectors.getSelectedAccount(state), @@ -110,6 +110,13 @@ WalletView.prototype.renderAddToken = function () { return h(AddTokenButton, { onClick () { history.push(ADD_TOKEN_ROUTE) + this.context.metricsEvent({ + eventOpts: { + category: 'Navigation', + action: 'Token Menu', + name: 'Clicked "Add Token"', + }, + }) if (sidebarOpen) { hideSidebar() } @@ -197,6 +204,13 @@ WalletView.prototype.render = function () { }), onClick: () => { copyToClipboard(checksummedAddress) + this.context.metricsEvent({ + eventOpts: { + category: 'Activation', + action: 'userClicks', + name: 'navCopyToClipboard', + }, + }) this.setState({ hasCopied: true }) setTimeout(() => this.setState({ hasCopied: false }), 3000) }, diff --git a/ui/app/ducks/confirm-transaction.duck.js b/ui/app/ducks/confirm-transaction.duck.js index c6e2c1be3..f75ff809a 100644 --- a/ui/app/ducks/confirm-transaction.duck.js +++ b/ui/app/ducks/confirm-transaction.duck.js @@ -372,6 +372,7 @@ export function setTransactionToConfirm (transactionId) { try { dispatch(setFetchingData(true)) const methodData = await getMethodData(data) + dispatch(updateMethodData(methodData)) } catch (error) { dispatch(updateMethodData({})) diff --git a/ui/app/metametrics/metametrics.provider.js b/ui/app/metametrics/metametrics.provider.js new file mode 100644 index 000000000..5ff0294e5 --- /dev/null +++ b/ui/app/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' +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/metametrics/metametrics.util.js b/ui/app/metametrics/metametrics.util.js new file mode 100644 index 000000000..2da7e2da8 --- /dev/null +++ b/ui/app/metametrics/metametrics.util.js @@ -0,0 +1,188 @@ +/* eslint camelcase: 0 */ + +const ethUtil = require('ethereumjs-util') + +const inDevelopment = process.env.METAMETRICS_URL === 'development' + +const METAMETRICS_BASE_URL = 'https://chromeextensionmm.innocraft.cloud/piwik.php' +const METAMETRICS_REQUIRED_PARAMS = `?idsite=${inDevelopment ? 1 : 2}&rec=1&apiv=1` +const METAMETRICS_BASE_FULL = METAMETRICS_BASE_URL + METAMETRICS_REQUIRED_PARAMS + +const METAMETRICS_TRACKING_URL = inDevelopment + ? 'http://www.metamask.io/metametrics' + : 'http://www.metamask.io/metametrics-prod' + +const METAMETRICS_CUSTOM_HAD_ERROR = 'hadError' +const METAMETRICS_CUSTOM_HEX_DATA = 'hexData' +const METAMETRICS_CUSTOM_FUNCTION_TYPE = 'functionType' +const METAMETRICS_CUSTOM_GAS_LIMIT_CHANGE = 'gasLimitChange' +const METAMETRICS_CUSTOM_GAS_PRICE_CHANGE = 'gasPriceChange' +const METAMETRICS_CUSTOM_RECIPIENT_KNOWN = 'recipientKnown' +const METAMETRICS_CUSTOM_CONFIRM_SCREEN_ORIGIN = 'origin' +const METAMETRICS_CUSTOM_FROM_NETWORK = 'fromNetwork' +const METAMETRICS_CUSTOM_TO_NETWORK = 'toNetwork' +const METAMETRICS_CUSTOM_ERROR_FIELD = 'errorField' +const METAMETRICS_CUSTOM_ERROR_MESSAGE = 'errorMessage' +const METAMETRICS_CUSTOM_RPC_NETWORK_ID = 'networkId' +const METAMETRICS_CUSTOM_RPC_CHAIN_ID = 'chainId' + +const METAMETRICS_CUSTOM_NETWORK = 'network' +const METAMETRICS_CUSTOM_ENVIRONMENT_TYPE = 'environmentType' +const METAMETRICS_CUSTOM_ACTIVE_CURRENCY = 'activeCurrency' +const METAMETRICS_CUSTOM_ACCOUNT_TYPE = 'accountType' +const METAMETRICS_CUSTOM_NUMBER_OF_TOKENS = 'numberOfTokens' +const METAMETRICS_CUSTOM_NUMBER_OF_ACCOUNTS = 'numberOfAccounts' + +const customVariableNameIdMap = { + [METAMETRICS_CUSTOM_HAD_ERROR]: 1, + [METAMETRICS_CUSTOM_HEX_DATA]: 2, + [METAMETRICS_CUSTOM_FUNCTION_TYPE]: 3, + [METAMETRICS_CUSTOM_GAS_LIMIT_CHANGE]: 4, + [METAMETRICS_CUSTOM_GAS_PRICE_CHANGE]: 5, + [METAMETRICS_CUSTOM_RECIPIENT_KNOWN]: 6, + [METAMETRICS_CUSTOM_CONFIRM_SCREEN_ORIGIN]: 7, + [METAMETRICS_CUSTOM_FROM_NETWORK]: 8, + [METAMETRICS_CUSTOM_TO_NETWORK]: 9, + [METAMETRICS_CUSTOM_RPC_NETWORK_ID]: 10, + [METAMETRICS_CUSTOM_RPC_CHAIN_ID]: 11, + [METAMETRICS_CUSTOM_ERROR_FIELD]: 12, + [METAMETRICS_CUSTOM_ERROR_MESSAGE]: 13, +} + +const customDimensionsNameIdMap = { + [METAMETRICS_CUSTOM_NETWORK]: 5, + [METAMETRICS_CUSTOM_ENVIRONMENT_TYPE]: 6, + [METAMETRICS_CUSTOM_ACTIVE_CURRENCY]: 7, + [METAMETRICS_CUSTOM_ACCOUNT_TYPE]: 8, + [METAMETRICS_CUSTOM_NUMBER_OF_TOKENS]: 9, + [METAMETRICS_CUSTOM_NUMBER_OF_ACCOUNTS]: 10, +} + +function composeUrlRefParamAddition (previousPath, confirmTransactionOrigin) { + const externalOrigin = confirmTransactionOrigin && confirmTransactionOrigin !== 'MetaMask' + return `&urlref=${externalOrigin ? 'EXTERNAL' : encodeURIComponent(previousPath.replace(/chrome-extension:\/\/\w+/, METAMETRICS_TRACKING_URL))}` +} + +function composeCustomDimensionParamAddition (customDimensions) { + const customDimensionParamStrings = Object.keys(customDimensions).reduce((acc, name) => { + return [...acc, `dimension${customDimensionsNameIdMap[name]}=${customDimensions[name]}`] + }, []) + return `&${customDimensionParamStrings.join('&')}` +} + +function composeCustomVarParamAddition (customVariables) { + const customVariableIdValuePairs = Object.keys(customVariables).reduce((acc, name) => { + return { + [customVariableNameIdMap[name]]: [name, customVariables[name]], + ...acc, + } + }, {}) + return `&cvar=${encodeURIComponent(JSON.stringify(customVariableIdValuePairs))}` +} + +function composeParamAddition (paramValue, paramName) { + return paramValue !== 0 && !paramValue + ? '' + : `&${paramName}=${paramValue}` +} + +function composeUrl (config, permissionPreferences = {}) { + const { + eventOpts = {}, + customVariables = '', + pageOpts = '', + network, + environmentType, + activeCurrency, + accountType, + numberOfTokens, + numberOfAccounts, + previousPath = '', + currentPath, + metaMetricsId, + confirmTransactionOrigin, + url: configUrl, + excludeMetaMetricsId, + isNewVisit, + } = config + const base = METAMETRICS_BASE_FULL + + const e_c = composeParamAddition(eventOpts.category, 'e_c') + const e_a = composeParamAddition(eventOpts.action, 'e_a') + const e_n = composeParamAddition(eventOpts.name, 'e_n') + const new_visit = isNewVisit ? `&new_visit=1` : '' + + const cvar = customVariables && composeCustomVarParamAddition(customVariables) || '' + + const action_name = '' + + const urlref = previousPath && composeUrlRefParamAddition(previousPath, confirmTransactionOrigin) + + const dimensions = !pageOpts.hideDimensions ? composeCustomDimensionParamAddition({ + network, + environmentType, + activeCurrency, + accountType, + numberOfTokens: customVariables && customVariables.numberOfTokens || numberOfTokens, + numberOfAccounts: customVariables && customVariables.numberOfAccounts || numberOfAccounts, + }) : '' + const url = configUrl || `&url=${encodeURIComponent(currentPath.replace(/chrome-extension:\/\/\w+/, METAMETRICS_TRACKING_URL))}` + const _id = metaMetricsId && !excludeMetaMetricsId ? `&_id=${metaMetricsId.slice(2, 18)}` : '' + const rand = `&rand=${String(Math.random()).slice(2)}` + const pv_id = `&pv_id=${ethUtil.bufferToHex(ethUtil.sha3(url || currentPath.match(/chrome-extension:\/\/\w+\/(.+)/)[0])).slice(2, 8)}` + const uid = metaMetricsId && !excludeMetaMetricsId + ? `&uid=${metaMetricsId.slice(2, 18)}` + : excludeMetaMetricsId + ? '&uid=0000000000000000' + : '' + + return [ base, e_c, e_a, e_n, cvar, action_name, urlref, dimensions, url, _id, rand, pv_id, uid, new_visit ].join('') +} + +export function sendMetaMetricsEvent (config, permissionPreferences) { + return fetch(composeUrl(config, permissionPreferences), { + 'headers': {}, + 'method': 'GET', + }) +} + +export function verifyUserPermission (config, props) { + const { + eventOpts = {}, + } = config + const { userPermissionPreferences } = props + const { + allowAll, + allowNone, + allowSendMetrics, + } = userPermissionPreferences + + if (allowNone) { + return false + } else if (allowAll) { + return true + } else if (allowSendMetrics && eventOpts.name === 'send') { + return true + } else { + return false + } +} + +const trackableSendCounts = { + 1: true, + 10: true, + 30: true, + 50: true, + 100: true, + 250: true, + 500: true, + 1000: true, + 2500: true, + 5000: true, + 10000: true, + 25000: true, +} + +export function sendCountIsTrackable (sendCount) { + return Boolean(trackableSendCounts[sendCount]) +} diff --git a/ui/app/reducers/metamask.js b/ui/app/reducers/metamask.js index fb0fd7130..c1aa20bf7 100644 --- a/ui/app/reducers/metamask.js +++ b/ui/app/reducers/metamask.js @@ -55,8 +55,11 @@ function reduceMetamask (state, action) { useNativeCurrencyAsPrimaryCurrency: true, showFiatInTestnets: false, }, + firstTimeFlowType: null, completedOnboarding: false, knownMethodData: {}, + participateInMetaMetrics: null, + metaMetricsSendCount: 0, }, state.metamask) switch (action.type) { @@ -338,6 +341,16 @@ function reduceMetamask (state, action) { coinOptions, }) + case actions.SET_PARTICIPATE_IN_METAMETRICS: + return extend(metamaskState, { + participateInMetaMetrics: action.value, + }) + + case actions.SET_METAMETRICS_SEND_COUNT: + return extend(metamaskState, { + metaMetricsSendCount: action.value, + }) + case actions.SET_USE_BLOCKIE: return extend(metamaskState, { useBlockie: action.value, @@ -395,6 +408,12 @@ function reduceMetamask (state, action) { }) } + 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 index f9e3709a0..c95c56581 100644 --- a/ui/app/root.js +++ b/ui/app/root.js @@ -5,6 +5,7 @@ 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 () { @@ -15,8 +16,10 @@ class Root extends Component { h(HashRouter, { hashType: 'noslash', }, [ - h(I18nProvider, [ - h(App), + h(MetaMetricsProvider, [ + h(I18nProvider, [ + h(App), + ]), ]), ]), ]) diff --git a/ui/app/routes.js b/ui/app/routes.js index 7c4e805ab..932dfa7df 100644 --- a/ui/app/routes.js +++ b/ui/app/routes.js @@ -29,6 +29,7 @@ const INITIALIZE_SELECT_ACTION_ROUTE = '/initialize/select-action' const INITIALIZE_SEED_PHRASE_ROUTE = '/initialize/seed-phrase' const INITIALIZE_END_OF_FLOW_ROUTE = '/initialize/end-of-flow' const INITIALIZE_CONFIRM_SEED_PHRASE_ROUTE = '/initialize/seed-phrase/confirm' +const INITIALIZE_METAMETRICS_OPT_IN_ROUTE = '/initialize/metametrics-opt-in' const CONFIRM_TRANSACTION_ROUTE = '/confirm-transaction' const CONFIRM_SEND_ETHER_PATH = '/send-ether' @@ -78,4 +79,5 @@ module.exports = { CONFIRM_TRANSFER_FROM_PATH, CONFIRM_TOKEN_METHOD_PATH, SIGNATURE_REQUEST_PATH, + INITIALIZE_METAMETRICS_OPT_IN_ROUTE, } diff --git a/ui/app/selectors.js b/ui/app/selectors.js index a36671b42..663c3f12b 100644 --- a/ui/app/selectors.js +++ b/ui/app/selectors.js @@ -1,4 +1,5 @@ import {NETWORK_TYPES} from './constants/common' +import { stripHexPrefix } from 'ethereumjs-util' const abi = require('human-standard-token-abi') import { @@ -40,6 +41,12 @@ const selectors = { isBalanceCached, getAdvancedInlineGasShown, getIsMainnet, + getCurrentNetworkId, + getSelectedAsset, + getCurrentKeyring, + getAccountType, + getNumberOfAccounts, + getNumberOfTokens, } module.exports = selectors @@ -50,6 +57,46 @@ function getNetworkIdentifier (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] @@ -63,6 +110,15 @@ function getSelectedIdentity (state) { return identities[selectedAddress] } +function getNumberOfAccounts (state) { + return Object.keys(state.metamask.accounts).length +} + +function getNumberOfTokens (state) { + const tokens = state.metamask.tokens + return tokens ? tokens.length : 0 +} + function getMetaMaskAccounts (state) { const currentAccounts = state.metamask.accounts const cachedBalances = state.metamask.cachedBalances[state.metamask.network] diff --git a/ui/app/selectors/confirm-transaction.js b/ui/app/selectors/confirm-transaction.js index 23ef26d95..ccd16fadd 100644 --- a/ui/app/selectors/confirm-transaction.js +++ b/ui/app/selectors/confirm-transaction.js @@ -95,7 +95,7 @@ export const currentCurrencySelector = state => state.metamask.currentCurrency export const conversionRateSelector = state => state.metamask.conversionRate export const getNativeCurrency = state => state.metamask.nativeCurrency -const txDataSelector = state => state.confirmTransaction.txData +export const txDataSelector = state => state.confirmTransaction.txData const tokenDataSelector = state => state.confirmTransaction.tokenData const tokenPropsSelector = state => state.confirmTransaction.tokenProps |