Skip to content

Commit 171fbc3

Browse files
authored
feat(onboarding): add post-opt-in interest questionnaire (MMCPR-392) (#30056)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until this PR meets the canonical Definition of Ready For Review in `docs/readme/ready-for-review.md`. In short: the template must be materially complete (not just section titles present), all status checks must be currently passing, and the only expected follow-up commits must be reviewer-driven. --> ## **Description** <!-- Write a short description of the changes included in this pull request, also include relevant motivation and context. Have in mind the following questions: 1. What is the reason for the change? 2. What is the improvement/solution? --> After users opt in to basic usage metrics on the onboarding privacy screen, this change optionally routes them to a short **interest questionnaire** (multi-select, design-system UI) when they pass eligibility sampling. If basic usage data is unchecked, navigation is unchanged. Eligibility uses `generateDeterministicRandomNumber` from `@metamask/remote-feature-flag-controller` with the MetaMetrics analytics id and compares it to a rollout threshold. **MetaMetrics:** `Onboarding Interest Question Viewed` on mount and `Onboarding Interest Question Submitted` on Continue (including `selected_interests`, `item_count`, and `skipped` when the user continues with no selection). Adds route/screen wiring, i18n (`en.json`), option imagery, and unit tests for the screen and OptinMetrics navigation. Figma: https://www.figma.com/design/z0panHXrMSMUSof2SaPkd4/Home-2026?node-id=10434-81757&m=dev ## **Changelog** <!-- If this PR is not End-User-Facing and should not show up in the CHANGELOG, you can choose to either: 1. Write `CHANGELOG entry: null` 2. Label with `no-changelog` If this PR is End-User-Facing, please write a short User-Facing description in the past tense like: `CHANGELOG entry: Added a new tab for users to see their NFTs` `CHANGELOG entry: Fixed a bug that was causing some NFTs to flicker` (This helps the Release Engineer do their job more quickly and accurately) --> CHANGELOG entry: Added an optional onboarding interest questionnaire after metrics opt-in for eligible users. ## **Related issues** Refs: MMCPR-392 ## **Manual testing steps** ```gherkin Feature: Onboarding interest questionnaire after metrics opt-in Scenario: User with basic usage checked and in rollout sees the questionnaire Given a fresh install or cleared onboarding state When the user completes wallet creation through the metrics opt-in screen And "Gather basic usage data" remains checked And the user confirms opt-in Then the interest questionnaire may appear before the next onboarding step When the user selects one or more interests and taps Continue Then the app records submission and continues the onboarding flow Scenario: User with basic usage unchecked skips the questionnaire Given the user reaches the metrics opt-in screen When the user unchecks "Gather basic usage data" and confirms Then the questionnaire is not shown and onboarding continues as before ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** https://github.com/user-attachments/assets/6b081ec5-ee2a-4d22-ae49-3b79dab65207 | Light Mode | Dark Mode | |------------|-----------| | <img width="300" alt="questionnaire_light" src="https://github.com/user-attachments/assets/dafd9077-f68d-4ca8-9bed-5362947608ad" /> | <img width="300" alt="questionnaire_dark" src="https://github.com/user-attachments/assets/16629619-d53d-4fec-a277-b128429a044b" /> | ## **Pre-merge author checklist** <!-- Every checklist item must be consciously assessed before marking this PR as "Ready for review". A checked box means you deliberately considered that responsibility, not that you literally performed every action listed. Unchecked boxes are ambiguous: they are not an implicit "N/A" and they are not a silent "skip". See `docs/readme/ready-for-review.md` for the full checklist semantics. --> - [x] I've followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I've included tests if applicable - [x] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] 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. #### Performance checks (if applicable) - [ ] I've tested on Android - Ideally on a mid-range device; emulator is acceptable - [ ] I've tested with a power user scenario - Use these [power-user SRPs](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/edit-v2/401401446401?draftShareId=9d77e1e1-4bdc-4be1-9ebb-ccd916988d93) to import wallets with many accounts and tokens - [ ] I've instrumented key operations with Sentry traces for production performance metrics - See [`trace()`](/app/util/trace.ts) for usage and [`addToken`](/app/components/Views/AddAsset/components/AddCustomToken/AddCustomToken.tsx#L274) for an example For performance guidelines and tooling, see the [Performance Guide](https://consensyssoftware.atlassian.net/wiki/spaces/TL1/pages/400085549067/Performance+Guide+for+Engineers). ## **Pre-merge reviewer checklist** <!-- Reviewer checklist items follow the same semantics as the author checklist: an unchecked box is ambiguous, a checked box means the reviewer consciously assessed that responsibility. See `docs/readme/ready-for-review.md`. --> - [ ] 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. <!-- Generated with the help of the pr-description AI skill --> <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes onboarding navigation after metrics opt-in and adds new analytics events, so regressions could block onboarding progression or affect tracking/consent behavior. Logic is gated by deterministic sampling and covered by new unit tests, reducing risk. > > **Overview** > Adds a new onboarding screen, `OnboardingInterestQuestionnaire`, and registers it under `Routes.ONBOARDING.INTEREST_QUESTIONNAIRE` in the onboarding stack. > > Updates `OptinMetrics` so that after a user opts into *basic usage metrics*, the flow deterministically samples eligibility (via a new `useOnboardingInterestQuestionnaireEligibility` hook) and either navigates to the questionnaire (passing an `onComplete` callback and optional `accountType`) or continues onboarding as before; eligibility check failures are logged and fall back to continuing. > > Introduces two new MetaMetrics events for the questionnaire (*Viewed* on mount and *Submitted* on continue with selected interests/skipped metadata), adds English i18n strings, and expands test coverage (new screen tests plus OptinMetrics navigation-branch tests and route registration assertions). > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit f5bd26c. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent dc9bd4f commit 171fbc3

22 files changed

Lines changed: 1198 additions & 3 deletions

app/components/Nav/App/App.test.tsx

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,9 @@ jest.mock('../../Views/QRTabSwitcher', () => () => (
123123
jest.mock('../../UI/OptinMetrics', () => () => (
124124
<MockView testID="mock-optin" />
125125
));
126+
jest.mock('../../Views/OnboardingInterestQuestionnaire', () => () => (
127+
<MockView testID="mock-onboarding-interest-questionnaire" />
128+
));
126129
jest.mock('../../Views/AccountStatus', () => () => (
127130
<MockView testID="mock-account-status" />
128131
));
@@ -1728,7 +1731,43 @@ describe('App', () => {
17281731
const { getByTestId } = renderAppAtRoute(routeState);
17291732

17301733
await waitFor(() => {
1731-
expect(getByTestId('mock-onboarding')).toBeTruthy();
1734+
expect(getByTestId('mock-onboarding')).toBeOnTheScreen();
1735+
});
1736+
});
1737+
1738+
it('renders OnboardingInterestQuestionnaire when it is the active OnboardingNav route', async () => {
1739+
const routeState = {
1740+
index: 0,
1741+
routes: [
1742+
{
1743+
name: 'OnboardingRootNav',
1744+
state: {
1745+
index: 0,
1746+
routes: [
1747+
{
1748+
name: 'OnboardingNav',
1749+
state: {
1750+
index: 0,
1751+
routes: [
1752+
{
1753+
name: Routes.ONBOARDING.INTEREST_QUESTIONNAIRE,
1754+
params: { onComplete: jest.fn() },
1755+
},
1756+
],
1757+
},
1758+
},
1759+
],
1760+
},
1761+
},
1762+
],
1763+
};
1764+
1765+
const { getByTestId } = renderAppAtRoute(routeState);
1766+
1767+
await waitFor(() => {
1768+
expect(
1769+
getByTestId('mock-onboarding-interest-questionnaire'),
1770+
).toBeOnTheScreen();
17321771
});
17331772
});
17341773

app/components/Nav/App/App.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import ImportFromSecretRecoveryPhrase from '../../Views/ImportFromSecretRecovery
1919
import DeleteWalletModal from '../../../components/UI/DeleteWalletModal';
2020
import Main from '../Main';
2121
import OptinMetrics from '../../UI/OptinMetrics';
22+
import OnboardingInterestQuestionnaire from '../../Views/OnboardingInterestQuestionnaire';
2223
import SimpleWebview from '../../Views/SimpleWebview';
2324
import Logger from '../../../util/Logger';
2425
import { useSelector } from 'react-redux';
@@ -305,6 +306,11 @@ const OnboardingNav = () => {
305306
component={OptinMetrics}
306307
options={{ headerShown: false }}
307308
/>
309+
<Stack.Screen
310+
name={Routes.ONBOARDING.INTEREST_QUESTIONNAIRE}
311+
component={OnboardingInterestQuestionnaire}
312+
options={{ headerShown: false, gestureEnabled: false }}
313+
/>
308314
<Stack.Screen
309315
name="AccountStatus"
310316
component={AccountStatus as ScreenComponent}
Lines changed: 324 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,324 @@
1+
import React from 'react';
2+
import OptinMetrics from './index.tsx';
3+
import { renderScreen } from '../../../util/test/renderWithProvider';
4+
import { fireEvent, screen, waitFor } from '@testing-library/react-native';
5+
import { strings } from '../../../../locales/i18n';
6+
import Device from '../../../util/device';
7+
import { createMockUseAnalyticsHook } from '../../../util/test/analyticsMock';
8+
import { useAnalytics } from '../../hooks/useAnalytics/useAnalytics';
9+
import { AnalyticsEventBuilder } from '../../../util/analytics/AnalyticsEventBuilder';
10+
import Routes from '../../../constants/navigation/Routes';
11+
import { analytics } from '../../../util/analytics/analytics';
12+
import Logger from '../../../util/Logger';
13+
14+
jest.mock('../../hooks/useAnalytics/useAnalytics');
15+
16+
jest.mock('../../../util/Logger', () => ({
17+
__esModule: true,
18+
default: {
19+
error: jest.fn(),
20+
},
21+
}));
22+
23+
const mockOptinMetricsTestOnboardingSlice = {
24+
events: [] as unknown[],
25+
accountType: undefined as string | undefined,
26+
};
27+
28+
jest.mock('react-redux', () => {
29+
const actual = jest.requireActual('react-redux');
30+
const rootState = jest.requireActual(
31+
'../../../util/test/initial-root-state',
32+
).default;
33+
return {
34+
...actual,
35+
useSelector: jest.fn((selector) =>
36+
selector({
37+
...rootState,
38+
onboarding: {
39+
...rootState.onboarding,
40+
events: mockOptinMetricsTestOnboardingSlice.events,
41+
accountType: mockOptinMetricsTestOnboardingSlice.accountType,
42+
},
43+
}),
44+
),
45+
};
46+
});
47+
48+
const mockGetShouldShow = jest.fn();
49+
jest.mock(
50+
'../../Views/OnboardingInterestQuestionnaire/useOnboardingInterestQuestionnaireEligibility',
51+
() => ({
52+
useOnboardingInterestQuestionnaireEligibility: () => mockGetShouldShow,
53+
}),
54+
);
55+
56+
jest.mock('../../../util/analytics/analytics', () => ({
57+
analytics: {
58+
isEnabled: jest.fn(() => true),
59+
trackEvent: jest.fn(),
60+
optIn: jest.fn().mockResolvedValue(undefined),
61+
optOut: jest.fn().mockResolvedValue(undefined),
62+
getAnalyticsId: jest
63+
.fn()
64+
.mockResolvedValue('123e4567-e89b-12d3-a456-426614174000'),
65+
identify: jest.fn(),
66+
trackView: jest.fn(),
67+
isOptedIn: jest.fn().mockResolvedValue(false),
68+
},
69+
}));
70+
71+
jest.mock('../../../core/Analytics/MetaMetrics', () => ({
72+
MetaMetricsEvents: jest.requireActual('../../../core/Analytics/MetaMetrics')
73+
.MetaMetricsEvents,
74+
getInstance: jest.fn(() => ({
75+
createDataDeletionTask: jest.fn(),
76+
checkDataDeleteStatus: jest.fn(),
77+
getDeleteRegulationCreationDate: jest.fn(),
78+
getDeleteRegulationId: jest.fn(),
79+
isDataRecorded: jest.fn(),
80+
updateDataRecordingFlag: jest.fn(),
81+
})),
82+
}));
83+
84+
jest.mock(
85+
'../../../util/metrics/UserSettingsAnalyticsMetaData/generateUserProfileAnalyticsMetaData',
86+
() => jest.fn().mockReturnValue({}),
87+
);
88+
89+
jest.mock('../../../util/metrics/MultichainAPI/networkMetricUtils', () => ({
90+
getConfiguredCaipChainIds: jest.fn().mockReturnValue(['eip155:1']),
91+
}));
92+
93+
jest.mock(
94+
'../../../util/metrics/DeviceAnalyticsMetaData/generateDeviceAnalyticsMetaData',
95+
() => jest.fn().mockReturnValue({}),
96+
);
97+
98+
jest.mock('../../../reducers/legalNotices', () => ({
99+
isPastPrivacyPolicyDate: jest.fn().mockReturnValue(true),
100+
}));
101+
102+
jest.mock('../../../util/device', () => ({
103+
isMediumDevice: jest.fn(),
104+
isAndroid: jest.fn(),
105+
isIos: jest.fn(),
106+
isLargeDevice: jest.fn(),
107+
isIphoneX: jest.fn(),
108+
}));
109+
110+
const mockNavigate = jest.fn();
111+
const mockReset = jest.fn();
112+
113+
jest.mock('@react-navigation/native', () => {
114+
const actual = jest.requireActual('@react-navigation/native');
115+
return {
116+
...actual,
117+
useNavigation: () => ({
118+
navigate: mockNavigate,
119+
reset: mockReset,
120+
setOptions: jest.fn(),
121+
goBack: jest.fn(),
122+
dispatch: jest.fn(),
123+
}),
124+
useRoute: () => ({
125+
key: 'OptinMetrics',
126+
name: 'OptinMetrics',
127+
params: undefined,
128+
}),
129+
};
130+
});
131+
132+
const mockAnalytics = analytics as jest.Mocked<typeof analytics>;
133+
134+
describe('OptinMetrics — interest questionnaire navigation branching', () => {
135+
beforeEach(() => {
136+
mockOptinMetricsTestOnboardingSlice.events = [];
137+
mockOptinMetricsTestOnboardingSlice.accountType = undefined;
138+
jest.clearAllMocks();
139+
jest.mocked(useAnalytics).mockReturnValue(
140+
createMockUseAnalyticsHook({
141+
trackEvent: (event) => mockAnalytics.trackEvent(event),
142+
createEventBuilder: AnalyticsEventBuilder.createEventBuilder,
143+
enable: async (enable) => {
144+
if (enable === false) {
145+
await mockAnalytics.optOut();
146+
} else {
147+
await mockAnalytics.optIn();
148+
}
149+
},
150+
identify: async (traits) => {
151+
mockAnalytics.identify(traits);
152+
},
153+
isEnabled: () => mockAnalytics.isEnabled(),
154+
getAnalyticsId: () => mockAnalytics.getAnalyticsId(),
155+
}),
156+
);
157+
(Device.isMediumDevice as jest.Mock).mockReturnValue(false);
158+
(Device.isAndroid as jest.Mock).mockReturnValue(false);
159+
(Device.isIos as jest.Mock).mockReturnValue(true);
160+
(Device.isLargeDevice as jest.Mock).mockReturnValue(false);
161+
(Device.isIphoneX as jest.Mock).mockReturnValue(false);
162+
});
163+
164+
describe('when basic usage data is unchecked', () => {
165+
it('does not navigate to the interest questionnaire regardless of eligibility', async () => {
166+
mockGetShouldShow.mockResolvedValue(true);
167+
168+
renderScreen(OptinMetrics, { name: 'OptinMetrics' }, { state: {} });
169+
170+
fireEvent.press(
171+
screen.getByText(strings('privacy_policy.gather_basic_usage_title')),
172+
);
173+
174+
fireEvent.press(
175+
screen.getByRole('button', {
176+
name: strings('privacy_policy.continue'),
177+
}),
178+
);
179+
180+
await waitFor(() => {
181+
expect(mockNavigate).not.toHaveBeenCalledWith(
182+
Routes.ONBOARDING.INTEREST_QUESTIONNAIRE,
183+
expect.anything(),
184+
);
185+
});
186+
});
187+
});
188+
189+
describe('when basic usage data is checked and eligibility returns false', () => {
190+
it('calls navigation reset to HomeNav instead of navigating to questionnaire', async () => {
191+
mockGetShouldShow.mockResolvedValue(false);
192+
193+
renderScreen(OptinMetrics, { name: 'OptinMetrics' }, { state: {} });
194+
195+
fireEvent.press(
196+
screen.getByRole('button', {
197+
name: strings('privacy_policy.continue'),
198+
}),
199+
);
200+
201+
await waitFor(() => {
202+
expect(mockNavigate).not.toHaveBeenCalledWith(
203+
Routes.ONBOARDING.INTEREST_QUESTIONNAIRE,
204+
expect.anything(),
205+
);
206+
expect(mockReset).toHaveBeenCalledWith(
207+
expect.objectContaining({
208+
routes: [{ name: Routes.ONBOARDING.HOME_NAV }],
209+
}),
210+
);
211+
});
212+
});
213+
});
214+
215+
describe('when basic usage data is checked and eligibility throws', () => {
216+
it('falls back to resetting navigation so onboarding is not blocked', async () => {
217+
mockGetShouldShow.mockRejectedValue(new Error('eligibility failed'));
218+
219+
renderScreen(OptinMetrics, { name: 'OptinMetrics' }, { state: {} });
220+
221+
fireEvent.press(
222+
screen.getByRole('button', {
223+
name: strings('privacy_policy.continue'),
224+
}),
225+
);
226+
227+
await waitFor(() => {
228+
expect(mockNavigate).not.toHaveBeenCalledWith(
229+
Routes.ONBOARDING.INTEREST_QUESTIONNAIRE,
230+
expect.anything(),
231+
);
232+
expect(mockReset).toHaveBeenCalledWith(
233+
expect.objectContaining({
234+
routes: [{ name: Routes.ONBOARDING.HOME_NAV }],
235+
}),
236+
);
237+
});
238+
});
239+
240+
it('logs Error rejections from the eligibility check', async () => {
241+
mockGetShouldShow.mockRejectedValue(new Error('eligibility failed'));
242+
243+
renderScreen(OptinMetrics, { name: 'OptinMetrics' }, { state: {} });
244+
245+
fireEvent.press(
246+
screen.getByRole('button', {
247+
name: strings('privacy_policy.continue'),
248+
}),
249+
);
250+
251+
await waitFor(() => {
252+
expect(Logger.error).toHaveBeenCalledWith(
253+
expect.objectContaining({ message: 'eligibility failed' }),
254+
'OptinMetrics: interest questionnaire eligibility check failed',
255+
);
256+
});
257+
});
258+
259+
it('wraps non-Error rejections before logging', async () => {
260+
mockGetShouldShow.mockRejectedValue('string failure');
261+
262+
renderScreen(OptinMetrics, { name: 'OptinMetrics' }, { state: {} });
263+
264+
fireEvent.press(
265+
screen.getByRole('button', {
266+
name: strings('privacy_policy.continue'),
267+
}),
268+
);
269+
270+
await waitFor(() => {
271+
expect(Logger.error).toHaveBeenCalledWith(
272+
expect.objectContaining({ message: 'string failure' }),
273+
'OptinMetrics: interest questionnaire eligibility check failed',
274+
);
275+
});
276+
});
277+
});
278+
279+
describe('when basic usage data is checked and eligibility returns true', () => {
280+
it('navigates to the interest questionnaire with onComplete callback', async () => {
281+
mockGetShouldShow.mockResolvedValue(true);
282+
283+
renderScreen(OptinMetrics, { name: 'OptinMetrics' }, { state: {} });
284+
285+
fireEvent.press(
286+
screen.getByRole('button', {
287+
name: strings('privacy_policy.continue'),
288+
}),
289+
);
290+
291+
await waitFor(() => {
292+
expect(mockNavigate).toHaveBeenCalledWith(
293+
Routes.ONBOARDING.INTEREST_QUESTIONNAIRE,
294+
expect.objectContaining({
295+
onComplete: expect.any(Function),
296+
}),
297+
);
298+
});
299+
});
300+
301+
it('includes accountType in navigation params when onboarding account type is set in Redux', async () => {
302+
mockGetShouldShow.mockResolvedValue(true);
303+
mockOptinMetricsTestOnboardingSlice.accountType = 'imported';
304+
305+
renderScreen(OptinMetrics, { name: 'OptinMetrics' }, { state: {} });
306+
307+
fireEvent.press(
308+
screen.getByRole('button', {
309+
name: strings('privacy_policy.continue'),
310+
}),
311+
);
312+
313+
await waitFor(() => {
314+
expect(mockNavigate).toHaveBeenCalledWith(
315+
Routes.ONBOARDING.INTEREST_QUESTIONNAIRE,
316+
expect.objectContaining({
317+
onComplete: expect.any(Function),
318+
accountType: 'imported',
319+
}),
320+
);
321+
});
322+
});
323+
});
324+
});

0 commit comments

Comments
 (0)