diff --git a/app/actions/onboarding/index.js b/app/actions/onboarding/index.js new file mode 100644 index 00000000000..75682bb0f05 --- /dev/null +++ b/app/actions/onboarding/index.js @@ -0,0 +1,20 @@ +/** + * Saves an onboarding analytics event in state + * + * @param {object} event - Event object + */ +export function saveOnboardingEvent(event) { + return { + type: 'SAVE_EVENT', + event + }; +} + +/** + * Erases any event stored in state + */ +export function clearOnboardingEvents() { + return { + type: 'CLEAR_EVENTS' + }; +} diff --git a/app/components/UI/OptinMetrics/index.js b/app/components/UI/OptinMetrics/index.js index 349d77b2f8e..c7098d3923c 100644 --- a/app/components/UI/OptinMetrics/index.js +++ b/app/components/UI/OptinMetrics/index.js @@ -1,5 +1,15 @@ import React, { PureComponent } from 'react'; -import { View, SafeAreaView, Text, StyleSheet, TouchableOpacity, ScrollView, BackHandler, Alert } from 'react-native'; +import { + View, + SafeAreaView, + Text, + StyleSheet, + TouchableOpacity, + ScrollView, + BackHandler, + Alert, + InteractionManager +} from 'react-native'; import PropTypes from 'prop-types'; import { baseStyles, fontStyles, colors } from '../../../styles/common'; import AsyncStorage from '@react-native-community/async-storage'; @@ -12,6 +22,7 @@ import { NavigationActions, withNavigationFocus } from 'react-navigation'; import StyledButton from '../StyledButton'; import Analytics from '../../../core/Analytics'; import ANALYTICS_EVENT_OPTS from '../../../util/analytics'; +import { clearOnboardingEvents } from '../../../actions/onboarding'; const styles = StyleSheet.create({ root: { @@ -97,7 +108,15 @@ class OptinMetrics extends PureComponent { /** * React navigation prop to know if this view is focused */ - isFocused: PropTypes.bool + isFocused: PropTypes.bool, + /** + * Onboarding events array created in previous onboarding views + */ + events: PropTypes.array, + /** + * Action to erase any event stored in onboarding state + */ + clearOnboardingEvents: PropTypes.func }; actionsList = [ @@ -182,7 +201,13 @@ class OptinMetrics extends PureComponent { onCancel = async () => { await AsyncStorage.setItem('@MetaMask:metricsOptIn', 'denied'); Analytics.disable(); - Analytics.trackEvent(ANALYTICS_EVENT_OPTS.ONBOARDING_METRICS_OPT_OUT); + InteractionManager.runAfterInteractions(() => { + if (this.props.events && this.props.events.length) { + this.props.events.forEach(e => Analytics.trackEvent(e)); + } + Analytics.trackEvent(ANALYTICS_EVENT_OPTS.ONBOARDING_METRICS_OPT_OUT); + this.props.clearOnboardingEvents(); + }); this.continue(); }; @@ -192,7 +217,13 @@ class OptinMetrics extends PureComponent { onConfirm = async () => { await AsyncStorage.setItem('@MetaMask:metricsOptIn', 'agreed'); Analytics.enable(); - Analytics.trackEvent(ANALYTICS_EVENT_OPTS.ONBOARDING_METRICS_OPT_IN); + InteractionManager.runAfterInteractions(() => { + if (this.props.events && this.props.events.length) { + this.props.events.forEach(e => Analytics.trackEvent(e)); + } + Analytics.trackEvent(ANALYTICS_EVENT_OPTS.ONBOARDING_METRICS_OPT_IN); + this.props.clearOnboardingEvents(); + }); this.continue(); }; @@ -257,11 +288,16 @@ class OptinMetrics extends PureComponent { } } +const mapStateToProps = state => ({ + events: state.onboarding.events +}); + const mapDispatchToProps = dispatch => ({ - setOnboardingWizardStep: step => dispatch(setOnboardingWizardStep(step)) + setOnboardingWizardStep: step => dispatch(setOnboardingWizardStep(step)), + clearOnboardingEvents: () => dispatch(clearOnboardingEvents()) }); export default connect( - null, + mapStateToProps, mapDispatchToProps )(withNavigationFocus(OptinMetrics)); diff --git a/app/components/UI/OptinMetrics/index.test.js b/app/components/UI/OptinMetrics/index.test.js index c5b170f68b4..84f275d03b2 100644 --- a/app/components/UI/OptinMetrics/index.test.js +++ b/app/components/UI/OptinMetrics/index.test.js @@ -7,7 +7,11 @@ const mockStore = configureMockStore(); describe('OptinMetrics', () => { it('should render correctly', () => { - const initialState = {}; + const initialState = { + onboarding: { + event: 'event' + } + }; const wrapper = shallow(, { context: { store: mockStore(initialState) } diff --git a/app/components/Views/ImportWallet/index.js b/app/components/Views/ImportWallet/index.js index e4c0cc73450..bc33a02d8a1 100644 --- a/app/components/Views/ImportWallet/index.js +++ b/app/components/Views/ImportWallet/index.js @@ -1,6 +1,16 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; -import { Platform, Alert, ActivityIndicator, Image, Text, View, ScrollView, StyleSheet } from 'react-native'; +import { + Platform, + Alert, + ActivityIndicator, + Image, + Text, + View, + ScrollView, + StyleSheet, + InteractionManager +} from 'react-native'; import AsyncStorage from '@react-native-community/async-storage'; import { connect } from 'react-redux'; import { passwordSet, seedphraseBackedUp } from '../../../actions/user'; @@ -16,6 +26,9 @@ import SecureKeychain from '../../../core/SecureKeychain'; import AppConstants from '../../../core/AppConstants'; import PubNubWrapper from '../../../util/syncWithExtension'; import AnimatedFox from 'react-native-animated-fox'; +import Analytics from '../../../core/Analytics'; +import ANALYTICS_EVENT_OPTS from '../../../util/analytics'; +import { saveOnboardingEvent } from '../../../actions/onboarding'; const styles = StyleSheet.create({ scroll: { @@ -120,7 +133,11 @@ class ImportWallet extends PureComponent { /** * Selected address */ - selectedAddress: PropTypes.string + selectedAddress: PropTypes.string, + /** + * Save onboarding event to state + */ + saveOnboardingEvent: PropTypes.func }; seedwords = null; @@ -297,7 +314,19 @@ class ImportWallet extends PureComponent { onPressImport = () => { const { existingUser } = this.state; - const action = () => this.props.navigation.push('ImportFromSeed'); + const action = () => { + this.props.navigation.push('ImportFromSeed'); + InteractionManager.runAfterInteractions(async () => { + if (Analytics.getEnabled()) { + Analytics.trackEvent(ANALYTICS_EVENT_OPTS.ONBOARDING_SELECTED_IMPORT_WITH_SEEDPHRASE); + return; + } + const metricsOptIn = await AsyncStorage.getItem('@MetaMask:metricsOptIn'); + if (!metricsOptIn) { + this.props.saveOnboardingEvent(ANALYTICS_EVENT_OPTS.ONBOARDING_SELECTED_IMPORT_WITH_SEEDPHRASE); + } + }); + }; if (existingUser) { this.alertExistingUser(action); } else { @@ -324,24 +353,17 @@ class ImportWallet extends PureComponent { ); return false; } - - if (this.props.navigation.getParam('existingUser', false)) { - Alert.alert( - strings('sync_with_extension.warning_title'), - strings('sync_with_extension.warning_message'), - [ - { - text: strings('sync_with_extension.warning_cancel_button'), - onPress: () => false, - style: 'cancel' - }, - { text: strings('sync_with_extension.warning_ok_button'), onPress: () => this.showQrCode() } - ], - { cancelable: false } - ); - } else { - this.showQrCode(); - } + InteractionManager.runAfterInteractions(async () => { + if (Analytics.getEnabled()) { + Analytics.trackEvent(ANALYTICS_EVENT_OPTS.ONBOARDING_SELECTED_SYNC_WITH_EXTENSION); + return; + } + const metricsOptIn = await AsyncStorage.getItem('@MetaMask:metricsOptIn'); + if (!metricsOptIn) { + this.props.saveOnboardingEvent(ANALYTICS_EVENT_OPTS.ONBOARDING_SELECTED_SYNC_WITH_EXTENSION); + } + }); + this.showQrCode(); }; renderLoader() { @@ -439,7 +461,8 @@ const mapStateToProps = state => ({ const mapDispatchToProps = dispatch => ({ passwordHasBeenSet: () => dispatch(passwordSet()), setLockTime: time => dispatch(setLockTime(time)), - seedphraseBackedUp: () => dispatch(seedphraseBackedUp()) + seedphraseBackedUp: () => dispatch(seedphraseBackedUp()), + saveOnboardingEvent: event => dispatch(saveOnboardingEvent(event)) }); export default connect( diff --git a/app/components/Views/Onboarding/index.js b/app/components/Views/Onboarding/index.js index a24154ee037..c017ecf6714 100644 --- a/app/components/Views/Onboarding/index.js +++ b/app/components/Views/Onboarding/index.js @@ -1,6 +1,6 @@ import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; -import { Platform, Text, View, ScrollView, StyleSheet, Image, Alert } from 'react-native'; +import { Platform, Text, View, ScrollView, StyleSheet, Image, Alert, InteractionManager } from 'react-native'; import AsyncStorage from '@react-native-community/async-storage'; import StyledButton from '../../UI/StyledButton'; import AnimatedFox from 'react-native-animated-fox'; @@ -15,6 +15,7 @@ import FadeOutOverlay from '../../UI/FadeOutOverlay'; import TermsAndConditions from '../TermsAndConditions'; import Analytics from '../../../core/Analytics'; import ANALYTICS_EVENT_OPTS from '../../../util/analytics'; +import { saveOnboardingEvent } from '../../../actions/onboarding'; const styles = StyleSheet.create({ scroll: { @@ -108,7 +109,11 @@ class Onboarding extends PureComponent { /** * redux flag that indicates if the user set a password */ - passwordSet: PropTypes.bool + passwordSet: PropTypes.bool, + /** + * Save onboarding event to state + */ + saveOnboardingEvent: PropTypes.func }; state = { @@ -141,8 +146,19 @@ class Onboarding extends PureComponent { onPressCreate = () => { const { existingUser } = this.state; - Analytics.trackEvent(ANALYTICS_EVENT_OPTS.ONBOARDING_SELECTED_CREATE_NEW_WALLET); - const action = () => this.props.navigation.navigate('CreateWallet'); + const action = () => { + this.props.navigation.navigate('CreateWallet'); + InteractionManager.runAfterInteractions(async () => { + if (Analytics.getEnabled()) { + Analytics.trackEvent(ANALYTICS_EVENT_OPTS.ONBOARDING_SELECTED_CREATE_NEW_WALLET); + return; + } + const metricsOptIn = await AsyncStorage.getItem('@MetaMask:metricsOptIn'); + if (!metricsOptIn) { + this.props.saveOnboardingEvent(ANALYTICS_EVENT_OPTS.ONBOARDING_SELECTED_CREATE_NEW_WALLET); + } + }); + }; if (existingUser) { this.alertExistingUser(action); } else { @@ -152,7 +168,16 @@ class Onboarding extends PureComponent { onPressImport = () => { this.props.navigation.push('ImportWallet'); - Analytics.trackEvent(ANALYTICS_EVENT_OPTS.ONBOARDING_SELECTED_IMPORT_WALLET); + InteractionManager.runAfterInteractions(async () => { + if (Analytics.getEnabled()) { + Analytics.trackEvent(ANALYTICS_EVENT_OPTS.ONBOARDING_SELECTED_IMPORT_WALLET); + return; + } + const metricsOptIn = await AsyncStorage.getItem('@MetaMask:metricsOptIn'); + if (!metricsOptIn) { + this.props.saveOnboardingEvent(ANALYTICS_EVENT_OPTS.ONBOARDING_SELECTED_IMPORT_WALLET); + } + }); }; alertExistingUser = callback => { @@ -246,4 +271,11 @@ const mapStateToProps = state => ({ passwordSet: state.user.passwordSet }); -export default connect(mapStateToProps)(Onboarding); +const mapDispatchToProps = dispatch => ({ + saveOnboardingEvent: event => dispatch(saveOnboardingEvent(event)) +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(Onboarding); diff --git a/app/reducers/index.js b/app/reducers/index.js index 8cb32b9d636..a9a32c4e6e3 100644 --- a/app/reducers/index.js +++ b/app/reducers/index.js @@ -9,6 +9,7 @@ import transactionReducer from './transaction'; import userReducer from './user'; import wizardReducer from './wizard'; import analyticsReducer from './analytics'; +import onboardingReducer from './onboarding'; import { combineReducers } from 'redux'; const rootReducer = combineReducers({ @@ -22,7 +23,8 @@ const rootReducer = combineReducers({ alert: alertReducer, transaction: transactionReducer, user: userReducer, - wizard: wizardReducer + wizard: wizardReducer, + onboarding: onboardingReducer }); export default rootReducer; diff --git a/app/reducers/onboarding/index.js b/app/reducers/onboarding/index.js new file mode 100644 index 00000000000..6898ee0b30c --- /dev/null +++ b/app/reducers/onboarding/index.js @@ -0,0 +1,31 @@ +import { REHYDRATE } from 'redux-persist'; + +const initialState = { + events: [] +}; + +/** + * Reducer to keep track of user oboarding actions to send it to analytics if the user + * decides to optin after finishing onboarding flow + */ +const onboardingReducer = (state = initialState, action) => { + switch (action.type) { + case REHYDRATE: + if (action.payload && action.payload.onboarding) { + return { ...state, ...action.payload.onboarding }; + } + return state; + case 'SAVE_EVENT': + state.events.push(action.event); + return state; + case 'CLEAR_EVENTS': + return { + ...state, + events: [] + }; + default: + return state; + } +}; + +export default onboardingReducer; diff --git a/app/util/analytics.js b/app/util/analytics.js index 64e15e5f094..5421dc0f6bb 100644 --- a/app/util/analytics.js +++ b/app/util/analytics.js @@ -6,6 +6,8 @@ const NAMES = { ONBOARDING_METRICS_OPT_OUT: 'Metrics Opt Out', ONBOARDING_SELECTED_CREATE_NEW_WALLET: 'Selected Create New Wallet', ONBOARDING_SELECTED_IMPORT_WALLET: 'Selected Import Wallet', + ONBOARDING_SELECTED_SYNC_WITH_EXTENSION: 'Selected Sync with Extension', + ONBOARDING_SELECTED_WITH_SEEDPHRASE: 'Selected Import with Seedphrase', // Navigation Drawer NAVIGATION_TAPS_ACCOUNT_NAME: 'Tapped Account Name / Profile', NAVIGATION_TAPS_SEND: "Taps on 'Send'", @@ -67,6 +69,7 @@ const ACTIONS = { //Onboarding METRICS_OPTS: 'Metrics Option', IMPORT_OR_CREATE: 'Import or Create', + IMPORT_OR_SYNC: 'Import or Sync', // Navigation Drawer NAVIGATION_DRAWER: 'Navigation Drawer', // Common Navigation @@ -126,6 +129,16 @@ const ANALYTICS_EVENT_OPTS = { ACTIONS.IMPORT_OR_CREATE, NAMES.ONBOARDING_SELECTED_IMPORT_WALLET ), + ONBOARDING_SELECTED_IMPORT_WITH_SEEDPHRASE: generateOpt( + CATEGORIES.ONBOARDING, + ACTIONS.IMPORT_OR_SYNC, + NAMES.ONBOARDING_SELECTED_WITH_SEEDPHRASE + ), + ONBOARDING_SELECTED_SYNC_WITH_EXTENSION: generateOpt( + CATEGORIES.ONBOARDING, + ACTIONS.IMPORT_OR_SYNC, + NAMES.ONBOARDING_SELECTED_SYNC_WITH_EXTENSION + ), // Navigation Drawer NAVIGATION_TAPS_ACCOUNT_NAME: generateOpt( CATEGORIES.NAVIGATION_DRAWER,