From da288b5d01046ed010ea080130a123353eea74e6 Mon Sep 17 00:00:00 2001 From: Jony Bursztyn Date: Tue, 4 Jun 2024 12:26:36 +0100 Subject: [PATCH] feat: adds "data collection for marketing" toggles (#9687) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds data collection for marketing toggles (and toasts/warnings) on: - Onboarding - Toast in Wallet - Settings page ## **Description** ## **Related issues** Fixes: https://github.com/MetaMask/MetaMask-planning/issues/2525 https://github.com/MetaMask/MetaMask-planning/issues/2439 https://github.com/MetaMask/MetaMask-planning/issues/2440 ## **Manual testing steps** Onboarding checkbox: Set `isPastPrivacyPolicyDate` to true in OptInMetrics 1. Start a new account 2. There should be a new checkbox that asks for marketing consent 3. Checking it should set the marketing consent to true (check at Settings, Securty tab page) Security tab: 1. Go to Security tab 2. When checking the "Data collection for marketing" to `true`, the "Participate in MetaMetrics" toggle should turn to `true` 3. When checking "Participate in MetaMetrics" to `false`, "Data collection for marketing" should be set to `false` 4. When "Participate in Metametrics" is `true` and "Data collection for marketing" is `true`, and the latter is set to `false`, a warning message should appear. Toast: An already onboarded user will have the "dataCollectionForMarketing" value as `null` (neither `true` or `false`). This will trigger the toast. 1. By clicking on "I accept", it should set the "Data collection for marketing" to `true`. 2. By closing the toast or clicking on "No thanks", it should set the "Data collection for marketing" to `false`. All of these actions should trigger subsequent Segment events. ## **Screenshots/Recordings** ### **Before** ### **After** https://github.com/MetaMask/metamask-mobile/assets/11148144/d0ebee90-38e1-4362-92ad-73728c82df72 Screenshot 2024-05-20 at 19 18 19 Screenshot 2024-05-20 at 19 19 30 ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --------- Co-authored-by: EtherWizard33 Co-authored-by: sethkfman <10342624+sethkfman@users.noreply.github.com> Co-authored-by: Curtis David Co-authored-by: tommasini <46944231+tommasini@users.noreply.github.com> Co-authored-by: salimtb --- app/actions/security/index.ts | 14 +- app/actions/security/state.ts | 2 + app/components/Nav/App/index.js | 10 + app/components/UI/OptinMetrics/index.js | 57 ++++- .../__snapshots__/index.test.tsx.snap | 223 ++++++++++++++++++ .../Views/DataCollectionModal/index.test.tsx | 55 +++++ .../Views/DataCollectionModal/index.tsx | 64 +++++ .../Views/DataCollectionModal/styles.ts | 24 ++ .../__snapshots__/index.test.tsx.snap | 15 ++ .../ExperienceEnhancerModal/index.test.tsx | 90 +++++++ .../Views/ExperienceEnhancerModal/index.tsx | 144 +++++++++++ .../Views/ExperienceEnhancerModal/styles.ts | 30 +++ .../SecuritySettings.constants.ts | 2 + .../SecuritySettings/SecuritySettings.tsx | 87 ++++++- .../SecuritySettings.test.tsx.snap | 117 +++++++++ app/components/Views/Wallet/index.test.tsx | 3 + app/components/Views/Wallet/index.tsx | 25 ++ app/constants/navigation/Routes.ts | 2 + app/constants/urls.ts | 3 + app/reducers/legalNotices/index.ts | 5 +- app/reducers/security/index.test.ts | 93 ++++++++ app/reducers/security/index.ts | 6 + .../Modals/DataCollectionModal.selectors.js | 5 + .../ExperienceEnhancerModal.selectors.js | 14 ++ .../SecurityPrivacyView.selectors.js | 1 + 25 files changed, 1083 insertions(+), 8 deletions(-) create mode 100644 app/components/Views/DataCollectionModal/__snapshots__/index.test.tsx.snap create mode 100644 app/components/Views/DataCollectionModal/index.test.tsx create mode 100644 app/components/Views/DataCollectionModal/index.tsx create mode 100644 app/components/Views/DataCollectionModal/styles.ts create mode 100644 app/components/Views/ExperienceEnhancerModal/__snapshots__/index.test.tsx.snap create mode 100644 app/components/Views/ExperienceEnhancerModal/index.test.tsx create mode 100644 app/components/Views/ExperienceEnhancerModal/index.tsx create mode 100644 app/components/Views/ExperienceEnhancerModal/styles.ts create mode 100644 app/reducers/security/index.test.ts create mode 100644 e2e/selectors/Modals/DataCollectionModal.selectors.js create mode 100644 e2e/selectors/Modals/ExperienceEnhancerModal.selectors.js diff --git a/app/actions/security/index.ts b/app/actions/security/index.ts index 2b5e2d5be46..1b6ce9ae390 100644 --- a/app/actions/security/index.ts +++ b/app/actions/security/index.ts @@ -6,6 +6,7 @@ export enum ActionType { SET_AUTOMATIC_SECURITY_CHECKS = 'SET_AUTOMATIC_SECURITY_CHECKS', USER_SELECTED_AUTOMATIC_SECURITY_CHECKS_OPTION = 'USER_SELECTED_AUTOMATIC_SECURITY_CHECKS_OPTION', SET_AUTOMATIC_SECURITY_CHECKS_MODAL_OPEN = 'SET_AUTOMATIC_SECURITY_CHECKS_MODAL_OPEN', + SET_DATA_COLLECTION_FOR_MARKETING = 'SET_DATA_COLLECTION_FOR_MARKETING', } export interface AllowLoginWithRememberMeUpdated @@ -28,11 +29,17 @@ export interface SetAutomaticSecurityChecksModalOpen open: boolean; } +export interface SetDataCollectionForMarketing + extends ReduxAction { + enabled: boolean; +} + export type Action = | AllowLoginWithRememberMeUpdated | AutomaticSecurityChecks | UserSelectedAutomaticSecurityChecksOptions - | SetAutomaticSecurityChecksModalOpen; + | SetAutomaticSecurityChecksModalOpen + | SetDataCollectionForMarketing; export const setAllowLoginWithRememberMe = ( enabled: boolean, @@ -60,3 +67,8 @@ export const setAutomaticSecurityChecksModalOpen = ( type: ActionType.SET_AUTOMATIC_SECURITY_CHECKS_MODAL_OPEN, open, }); + +export const setDataCollectionForMarketing = (enabled: boolean) => ({ + type: ActionType.SET_DATA_COLLECTION_FOR_MARKETING, + enabled, +}); diff --git a/app/actions/security/state.ts b/app/actions/security/state.ts index 068f26c3e5a..8c393555156 100644 --- a/app/actions/security/state.ts +++ b/app/actions/security/state.ts @@ -3,4 +3,6 @@ export interface SecuritySettingsState { automaticSecurityChecksEnabled: boolean; hasUserSelectedAutomaticSecurityCheckOption: boolean; isAutomaticSecurityChecksModalOpen: boolean; + // 'null' represents the user not having set his preference over dataCollectionForMarketing yet + dataCollectionForMarketing: boolean | null; } diff --git a/app/components/Nav/App/index.js b/app/components/Nav/App/index.js index 04a238d7784..966eccf606a 100644 --- a/app/components/Nav/App/index.js +++ b/app/components/Nav/App/index.js @@ -10,6 +10,7 @@ import { Animated, Linking } from 'react-native'; import { createStackNavigator } from '@react-navigation/stack'; import Login from '../../Views/Login'; import QRScanner from '../../Views/QRScanner'; +import DataCollectionModal from '../../Views/DataCollectionModal'; import Onboarding from '../../Views/Onboarding'; import OnboardingCarousel from '../../Views/OnboardingCarousel'; import ChoosePassword from '../../Views/ChoosePassword'; @@ -100,6 +101,7 @@ import ShowDisplayNftMediaSheet from '../../Views/ShowDisplayMediaNFTSheet/ShowD import AmbiguousAddressSheet from '../../../../app/components/Views/Settings/Contacts/AmbiguousAddressSheet/AmbiguousAddressSheet'; import SDKDisconnectModal from '../../../../app/components/Views/SDKDisconnectModal/SDKDisconnectModal'; import SDKSessionModal from '../../../../app/components/Views/SDKSessionModal/SDKSessionModal'; +import ExperienceEnhancerModal from '../../../../app/components/Views/ExperienceEnhancerModal'; import { MetaMetrics } from '../../../core/Analytics'; import trackErrorAsAnalytics from '../../../util/metrics/TrackError/trackErrorAsAnalytics'; import generateDeviceAnalyticsMetaData from '../../../util/metrics/DeviceAnalyticsMetaData/generateDeviceAnalyticsMetaData'; @@ -612,6 +614,14 @@ const App = ({ userLoggedIn }) => { name={Routes.SHEET.SDK_MANAGE_CONNECTIONS} component={SDKSessionModal} /> + + = newPrivacyPolicyDate; - const createStyles = ({ colors }) => StyleSheet.create({ root: { @@ -52,6 +51,13 @@ const createStyles = ({ colors }) => crossIcon: { color: colors.error.default, }, + checkbox: { + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + gap: 15, + marginRight: 25, + }, icon: { marginRight: 5, }, @@ -118,6 +124,8 @@ const createStyles = ({ colors }) => */ class OptinMetrics extends PureComponent { static propTypes = { + isDataCollectionForMarketingEnabled: PropTypes.bool, + setDataCollectionForMarketing: PropTypes.func, /** /* navigation object required to push and pop other views */ @@ -337,6 +345,21 @@ class OptinMetrics extends PureComponent { this.props.clearOnboardingEvents(); + if (this.props.isDataCollectionForMarketingEnabled) { + const traits = { + is_metrics_opted_in: true, + has_marketing_consent: Boolean( + this.props.setDataCollectionForMarketing, + ), + }; + + metrics.addTraitsToUser(traits); + metrics.trackEvent(MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED, { + ...traits, + location: 'onboarding_metametrics', + }); + } + // track event for user opting in metrics.trackEvent(MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED, { analytics_option_selected: 'Metrics Opt In', @@ -524,6 +547,11 @@ class OptinMetrics extends PureComponent { }; render() { + const { + isDataCollectionForMarketingEnabled, + setDataCollectionForMarketing, + } = this.props; + const styles = this.getStyles(); return ( @@ -569,6 +597,23 @@ class OptinMetrics extends PureComponent { ? this.renderAction(action, i) : this.renderLegacyAction(action, i), )} + {isPastPrivacyPolicyDate ? ( + + + setDataCollectionForMarketing( + !isDataCollectionForMarketingEnabled, + ) + } + /> + + {strings('privacy_policy.checkbox')} + + + ) : null} {this.renderPrivacyPolicy()} @@ -582,11 +627,15 @@ OptinMetrics.contextType = ThemeContext; const mapStateToProps = (state) => ({ events: state.onboarding.events, + isDataCollectionForMarketingEnabled: + state.security.dataCollectionForMarketing, }); const mapDispatchToProps = (dispatch) => ({ setOnboardingWizardStep: (step) => dispatch(setOnboardingWizardStep(step)), clearOnboardingEvents: () => dispatch(clearOnboardingEvents()), + setDataCollectionForMarketing: (value) => + dispatch(setDataCollectionForMarketing(value)), }); export default connect( diff --git a/app/components/Views/DataCollectionModal/__snapshots__/index.test.tsx.snap b/app/components/Views/DataCollectionModal/__snapshots__/index.test.tsx.snap new file mode 100644 index 00000000000..d7d6eea8295 --- /dev/null +++ b/app/components/Views/DataCollectionModal/__snapshots__/index.test.tsx.snap @@ -0,0 +1,223 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DataCollectionModal should render expected snapshot 1`] = ` + + + + + + + + + + + + + + Mocked string + + + + + + Mocked string + + + + + + + +`; diff --git a/app/components/Views/DataCollectionModal/index.test.tsx b/app/components/Views/DataCollectionModal/index.test.tsx new file mode 100644 index 00000000000..6bead7d2b69 --- /dev/null +++ b/app/components/Views/DataCollectionModal/index.test.tsx @@ -0,0 +1,55 @@ +import React from 'react'; +import { render } from '@testing-library/react-native'; +import DataCollectionModal from './'; // Adjust the import path as necessary +import { strings } from '../../../../locales/i18n'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { DataCollectionModalSelectorsIDs } from '../../../../e2e/selectors/Modals/DataCollectionModal.selectors'; + +jest.mock('../../../../locales/i18n', () => ({ + strings: jest.fn().mockReturnValue('Mocked string'), +})); + +jest.mock('react-native-safe-area-context', () => { + const inset = { top: 1, right: 2, bottom: 3, left: 4 }; + const frame = { width: 5, height: 6, x: 7, y: 8 }; + return { + SafeAreaProvider: jest.fn().mockImplementation(({ children }) => children), + SafeAreaConsumer: jest + .fn() + .mockImplementation(({ children }) => children(inset)), + useSafeAreaInsets: jest.fn().mockImplementation(() => inset), + useSafeAreaFrame: jest.fn().mockImplementation(() => frame), + }; +}); + +jest.mock('@react-navigation/native', () => { + const actualNav = jest.requireActual('@react-navigation/native'); + return { + ...actualNav, + useNavigation: () => ({ + navigate: jest.fn(), + }), + }; +}); + +describe('DataCollectionModal', () => { + it('renders icon and content', () => { + const { getByTestId } = render(); + + expect( + getByTestId(DataCollectionModalSelectorsIDs.ICON_WARNING), + ).toBeTruthy(); // Assuming you add testID='icon-warning' to your Icon component + + expect(strings).toHaveBeenCalledWith('data_collection_modal.content'); + expect(strings).toHaveBeenCalledWith('data_collection_modal.accept'); + }); + + it('should render expected snapshot', () => { + const { toJSON } = render( + + + , + ); + expect(toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/components/Views/DataCollectionModal/index.tsx b/app/components/Views/DataCollectionModal/index.tsx new file mode 100644 index 00000000000..0b95dabc66e --- /dev/null +++ b/app/components/Views/DataCollectionModal/index.tsx @@ -0,0 +1,64 @@ +import React, { useRef } from 'react'; +import { View } from 'react-native'; + +import { strings } from '../../../../locales/i18n'; +import BottomSheet, { + BottomSheetRef, +} from '../../../component-library/components/BottomSheets/BottomSheet'; +import Text, { + TextVariant, +} from '../../../component-library/components/Texts/Text'; +import BottomSheetFooter, { + ButtonsAlignment, +} from '../../../component-library/components/BottomSheets/BottomSheetFooter'; +import { + ButtonSize, + ButtonVariants, +} from '../../../component-library/components/Buttons/Button'; +import { ButtonProps } from '../../../component-library/components/Buttons/Button/Button.types'; +import Icon, { + IconColor, + IconName, + IconSize, +} from '../../../component-library/components/Icons/Icon'; +import createStyles from './styles'; +import { DataCollectionModalSelectorsIDs } from '../../../../e2e/selectors/Modals/DataCollectionModal.selectors'; + +const DataCollectionModal = () => { + const styles = createStyles(); + const bottomSheetRef = useRef(null); + + const acceptButtonProps: ButtonProps = { + variant: ButtonVariants.Primary, + label: strings('data_collection_modal.accept'), + size: ButtonSize.Lg, + onPress: () => { + bottomSheetRef.current?.onCloseBottomSheet(); + }, + testID: DataCollectionModalSelectorsIDs.ACCEPT_BUTTON, + }; + + return ( + + + + + + {strings('data_collection_modal.content')} + + + + + + ); +}; + +export default DataCollectionModal; diff --git a/app/components/Views/DataCollectionModal/styles.ts b/app/components/Views/DataCollectionModal/styles.ts new file mode 100644 index 00000000000..f358e1ef406 --- /dev/null +++ b/app/components/Views/DataCollectionModal/styles.ts @@ -0,0 +1,24 @@ +import { StyleSheet } from 'react-native'; + +const createStyles = () => + StyleSheet.create({ + title: { + textAlign: 'center', + marginTop: 15, + }, + content: { + margin: 20, + gap: 10, + display: 'flex', + flexDirection: 'column', + }, + wrapper: { + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + marginTop: 10, + marginBottom: 10, + }, + }); + +export default createStyles; diff --git a/app/components/Views/ExperienceEnhancerModal/__snapshots__/index.test.tsx.snap b/app/components/Views/ExperienceEnhancerModal/__snapshots__/index.test.tsx.snap new file mode 100644 index 00000000000..251370276bc --- /dev/null +++ b/app/components/Views/ExperienceEnhancerModal/__snapshots__/index.test.tsx.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ExperienceEnhancerModal should render correctly 1`] = ` + +`; diff --git a/app/components/Views/ExperienceEnhancerModal/index.test.tsx b/app/components/Views/ExperienceEnhancerModal/index.test.tsx new file mode 100644 index 00000000000..b7817724a95 --- /dev/null +++ b/app/components/Views/ExperienceEnhancerModal/index.test.tsx @@ -0,0 +1,90 @@ +import { fireEvent, render } from '@testing-library/react-native'; +import React from 'react'; +import { Linking } from 'react-native'; +import { SafeAreaProvider } from 'react-native-safe-area-context'; +import { useDispatch } from 'react-redux'; +import { setDataCollectionForMarketing } from '../../../actions/security'; +import { HOW_TO_MANAGE_METRAMETRICS_SETTINGS } from '../../../constants/urls'; +import ExperienceEnhancerModal from './'; +import { ExperienceEnhancerModalSelectorsIDs } from '../../../../e2e/selectors/Modals/ExperienceEnhancerModal.selectors.js'; + +// Mock the BottomSheet component +jest.mock( + '../../../component-library/components/BottomSheets/BottomSheet', + () => { + const mockBottomSheet = ({ children }: any) => <>{children}; + return mockBottomSheet; + }, +); + +jest.mock('react-redux', () => ({ + useDispatch: jest.fn(), +})); + +jest.mock('react-native/Libraries/Linking/Linking', () => ({ + openURL: jest.fn(), +})); + +describe('ExperienceEnhancerModal', () => { + const dispatchMock = jest.fn(); + + beforeEach(() => { + (useDispatch as jest.Mock).mockReturnValue(dispatchMock); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render correctly', () => { + const { toJSON } = render( + + + , + ); + + expect(toJSON()).toMatchSnapshot(); + }); + + it('should handle cancel button press correctly', () => { + const { getByTestId } = render(); + + const cancelButton = getByTestId( + ExperienceEnhancerModalSelectorsIDs.CANCEL_BUTTON, + ); + expect(cancelButton).toBeTruthy(); + + fireEvent.press(cancelButton); + expect(dispatchMock).toHaveBeenCalledWith( + setDataCollectionForMarketing(false), + ); + }); + + it('should handle accept button press correctly', () => { + const { getByTestId } = render(); + + const acceptButton = getByTestId( + ExperienceEnhancerModalSelectorsIDs.ACCEPT_BUTTON, + ); + expect(acceptButton).toBeTruthy(); + + fireEvent.press(acceptButton); + expect(dispatchMock).toHaveBeenCalledWith( + setDataCollectionForMarketing(true), + ); + }); + + it('should open URL when link button is pressed', () => { + const { getByTestId } = render(); + + const linkButton = getByTestId( + ExperienceEnhancerModalSelectorsIDs.LINK_BUTTON, + ); + expect(linkButton).toBeTruthy(); + + fireEvent.press(linkButton); + expect(Linking.openURL).toHaveBeenCalledWith( + HOW_TO_MANAGE_METRAMETRICS_SETTINGS, + ); + }); +}); diff --git a/app/components/Views/ExperienceEnhancerModal/index.tsx b/app/components/Views/ExperienceEnhancerModal/index.tsx new file mode 100644 index 00000000000..81ddaecb605 --- /dev/null +++ b/app/components/Views/ExperienceEnhancerModal/index.tsx @@ -0,0 +1,144 @@ +import React, { useRef } from 'react'; +import { Linking, View } from 'react-native'; +import { useDispatch } from 'react-redux'; + +import { strings } from '../../../../locales/i18n'; +import BottomSheet, { + BottomSheetRef, +} from '../../../component-library/components/BottomSheets/BottomSheet'; +import Text, { + TextVariant, +} from '../../../component-library/components/Texts/Text'; +import createStyles from './styles'; +import BottomSheetFooter, { + ButtonsAlignment, +} from '../../../component-library/components/BottomSheets/BottomSheetFooter'; +import Button, { + ButtonSize, + ButtonVariants, +} from '../../../component-library/components/Buttons/Button'; +import { ButtonProps } from '../../../component-library/components/Buttons/Button/Button.types'; +import { setDataCollectionForMarketing } from '../../../actions/security'; +import { + MetaMetricsEvents, + useMetrics, +} from '../../../components/hooks/useMetrics'; +import { HOW_TO_MANAGE_METRAMETRICS_SETTINGS } from '../../../constants/urls'; +import { ExperienceEnhancerModalSelectorsIDs } from '../../../../e2e/selectors/Modals/ExperienceEnhancerModal.selectors.js'; + +const ExperienceEnhancerModal = () => { + const dispatch = useDispatch(); + const styles = createStyles(); + const { trackEvent, addTraitsToUser } = useMetrics(); + const bottomSheetRef = useRef(null); + + const cancelButtonProps: ButtonProps = { + variant: ButtonVariants.Secondary, + label: strings('experience_enhancer_modal.cancel'), + size: ButtonSize.Lg, + onPress: () => { + dispatch(setDataCollectionForMarketing(false)); + bottomSheetRef.current?.onCloseBottomSheet(); + + const traits = { + has_marketing_consent: false, + }; + addTraitsToUser(traits); + trackEvent(MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED, { + ...traits, + location: 'marketing_consent_modal', + }); + }, + testID: ExperienceEnhancerModalSelectorsIDs.CANCEL_BUTTON, + }; + + const acceptButtonProps: ButtonProps = { + variant: ButtonVariants.Primary, + label: strings('experience_enhancer_modal.accept'), + size: ButtonSize.Lg, + onPress: () => { + dispatch(setDataCollectionForMarketing(true)); + bottomSheetRef.current?.onCloseBottomSheet(); + + const traits = { has_marketing_consent: true }; + addTraitsToUser(traits); + trackEvent(MetaMetricsEvents.ANALYTICS_PREFERENCE_SELECTED, { + ...traits, + location: 'marketing_consent_modal', + }); + }, + testID: ExperienceEnhancerModalSelectorsIDs.ACCEPT_BUTTON, + }; + + return ( + + + {strings('experience_enhancer_modal.title')} + + + + {strings('experience_enhancer_modal.paragraph1a')} +