Skip to content

Commit 4372187

Browse files
authored
fix: add useBuildPortfolioUrl hook for centralized metrics tracking (MetaMask#22683)
## **Description** ### Problem We are asking the user to opt into metrics/marketing twice, first time is when the user installs the app, and the second time when the user opens the web browser within the app. We have query params being passed into Portfolio called `marketingEnabled` and `metricsEnabled` which SHOULD opt in the user's tracking preferences thus dismissing the second opt-in modal. The issue is that we are inconsistently passing these params into various entry points ### Solution Pass in the opted in metrics from mobile into the portfolio web app via query params. Created a new `useBuildPortfolioUrl` hook that automatically includes `marketingEnabled` and `metricsEnabled` query parameters when building Portfolio URLs. This centralizes the logic for including user consent preferences and ensures consistency across the app. ### Changes New Hook - Created `app/components/hooks/useBuildPortfolioUrl.ts` - Subscribes to `isDataCollectionForMarketingEnabled` from Redux state - Subscribes to `metricsEnabled` from the `useMetrics` hook - Returns a memoized function that builds Portfolio URLs with consent parameters automatically included - Supports additional parameters via the second argument - Updated all Portfolio URL entry points to use new hook Before: ```typescript const isDataCollectionForMarketingEnabled = useSelector( (state) => state.security.dataCollectionForMarketing, ); const { isEnabled } = useMetrics(); const portfolioUrl = buildPortfolioUrl( AppConstants.PORTFOLIO.URL, { marketingEnabled: isDataCollectionForMarketingEnabled ?? false, metricsEnabled: isEnabled(), srcChain: chainId, }, ); ``` After: ```typescript const buildPortfolioUrlWithMetrics = useBuildPortfolioUrl(); const portfolioUrl = buildPortfolioUrlWithMetrics( AppConstants.PORTFOLIO.URL, { srcChain: chainId }, ); ``` ## **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: Improved Portfolio integration by passing tracking consent from Mobile app ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/TMCU-45 ## **Manual testing steps** ```gherkin Feature: Portfolio tracking consent parameter Scenario: user with no tracking preference opens Portfolio Given user has not set tracking preference in app settings When user taps the Portfolio link from wallet home screen Then Portfolio URL should not include `marketingEnabled` and `metricsEnabled` parameter And Portfolio website should display the consent modal Scenario: user who accepted tracking opens Portfolio Given user has accepted tracking in app settings When user taps the Portfolio link from wallet home screen Then Portfolio URL should include `marketingEnabled` and `metricsEnabled` as true And Portfolio website should skip the consent modal Scenario: user who declined tracking opens Portfolio Given user has declined tracking in app settings When user taps the Portfolio link from wallet home screen Then Portfolio URL should include `marketingEnabled` and `metricsEnabled` as false And Portfolio website should skip the consent modal Scenario: user opens Portfolio Bridge with tracking consent Given user has accepted tracking in app settings When user taps "Bridge" from token asset options Then Portfolio Bridge URL should include `marketingEnabled` and `metricsEnabled` is true And Portfolio Bridge URL should include srcChain and token parameters ``` ## **Screenshots/Recordings** `~` ### **Before** https://github.com/user-attachments/assets/4cdcdbec-0ffc-4b36-827e-eb442316eb33 ### **After** #### No Tracking Accepted https://github.com/user-attachments/assets/58b4ec4b-6fcd-4d52-9f84-f3c4e8de9720 #### Tracking Accepted https://github.com/user-attachments/assets/60921b12-87eb-40bb-9561-135d7e16742e ## **Pre-merge author checklist** - [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. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] 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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds a reusable hook and util to append metrics/marketing consent params to Portfolio URLs and updates Portfolio, Bridge, Stake, Browser, Trending, and AssetOptions flows to use it, with tests. > > - **Hooks/Utils**: > - Add `useBuildPortfolioUrl` to auto-include `marketingEnabled` and `metricsEnabled` when building Portfolio URLs. > - Add `buildPortfolioUrl` in `util/browser` to append standard params; deprecates scattered `appendURLParams` usage for Portfolio. > - **UI Integrations**: > - Update `AccountOverview` to build Portfolio URL with consent and map `security.dataCollectionForMarketing` to props. > - Update Bridge navigation (`useGoToPortfolioBridge`) to use hook and include `srcChain`/`token`. > - Update `StakeButton` to open Stake via hook-built URL when ineligible. > - Update `AssetOptions` Portfolio navigation to use hook. > - Update `Browser` homepage URL generation to use hook. > - Update `TrendingView` to use hook for browser button navigation. > - Export `SectionHeaderProps` interface. > - **Tests**: > - Add tests for `useBuildPortfolioUrl` and `buildPortfolioUrl`. > - Update `StakeButton` and `TrendingView` tests to reflect new URL params. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 57608da. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 25288a6 commit 4372187

13 files changed

Lines changed: 381 additions & 55 deletions

File tree

app/components/UI/AccountOverview/index.js

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import Text, {
4545
} from '../../../component-library/components/Texts/Text';
4646
import { withMetricsAwareness } from '../../../components/hooks/useMetrics';
4747
import { isPortfolioUrl } from '../../../util/url';
48+
import { buildPortfolioUrl } from '../../../util/browser';
4849

4950
const createStyles = (colors) =>
5051
StyleSheet.create({
@@ -189,6 +190,10 @@ class AccountOverview extends PureComponent {
189190
* Metrics injected by withMetricsAwareness HOC
190191
*/
191192
metrics: PropTypes.object,
193+
/**
194+
* Whether data collection for marketing is enabled
195+
*/
196+
isDataCollectionForMarketingEnabled: PropTypes.bool,
192197
};
193198

194199
state = {
@@ -301,7 +306,7 @@ class AccountOverview extends PureComponent {
301306
};
302307

303308
onOpenPortfolio = () => {
304-
const { navigation, browserTabs } = this.props;
309+
const { navigation, browserTabs, metrics } = this.props;
305310
const existingPortfolioTab = browserTabs.find((tab) =>
306311
isPortfolioUrl(tab.url),
307312
);
@@ -310,7 +315,16 @@ class AccountOverview extends PureComponent {
310315
if (existingPortfolioTab) {
311316
existingTabId = existingPortfolioTab.id;
312317
} else {
313-
newTabUrl = `${AppConstants.PORTFOLIO.URL}/?metamaskEntry=mobile`;
318+
const additionalParams = {
319+
metricsEnabled: metrics.isEnabled(),
320+
marketingEnabled:
321+
this.props.isDataCollectionForMarketingEnabled ?? false,
322+
};
323+
const portfolioUrl = buildPortfolioUrl(
324+
AppConstants.PORTFOLIO.URL,
325+
additionalParams,
326+
);
327+
newTabUrl = portfolioUrl.href;
314328
}
315329
const params = {
316330
...(newTabUrl && { newTabUrl }),
@@ -440,6 +454,8 @@ const mapStateToProps = (state) => ({
440454
currentCurrency: selectCurrentCurrency(state),
441455
chainId: selectChainId(state),
442456
browserTabs: state.browser.tabs,
457+
isDataCollectionForMarketingEnabled:
458+
state.security.dataCollectionForMarketing,
443459
});
444460

445461
const mapDispatchToProps = (dispatch) => ({

app/components/UI/Bridge/hooks/useGoToPortfolioBridge.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import type { BrowserParams } from '../../../Views/Browser/Browser.types';
1111
import { getDecimalChainId } from '../../../../util/networks';
1212
import { useMetrics } from '../../../hooks/useMetrics';
1313
import { isBridgeUrl } from '../../../../util/url';
14+
import { useBuildPortfolioUrl } from '../../../hooks/useBuildPortfolioUrl';
1415

1516
/**
1617
* Returns a function that is used to navigate to the MetaMask Bridges webpage.
@@ -24,6 +25,7 @@ export default function useGoToPortfolioBridge(location: string) {
2425
const browserTabs = useSelector((state: any) => state.browser.tabs);
2526
const { navigate } = useNavigation();
2627
const { trackEvent, createEventBuilder } = useMetrics();
28+
const buildPortfolioUrlWithMetrics = useBuildPortfolioUrl();
2729
return (address?: string) => {
2830
const existingBridgeTab = browserTabs.find((tab: BrowserTab) =>
2931
isBridgeUrl(tab.url),
@@ -37,11 +39,20 @@ export default function useGoToPortfolioBridge(location: string) {
3739
params.newTabUrl = undefined;
3840
params.existingTabId = existingBridgeTab.id;
3941
} else {
40-
params.newTabUrl = `${
41-
AppConstants.BRIDGE.URL
42-
}/?metamaskEntry=mobile&srcChain=${getDecimalChainId(chainId)}${
43-
address ? `&token=${address}` : ''
44-
}`;
42+
const additionalParams: Record<string, string | number> = {
43+
srcChain: getDecimalChainId(chainId),
44+
};
45+
46+
if (address) {
47+
additionalParams.token = address;
48+
}
49+
50+
const bridgeUrl = buildPortfolioUrlWithMetrics(
51+
AppConstants.BRIDGE.URL,
52+
additionalParams,
53+
);
54+
55+
params.newTabUrl = bridgeUrl.href;
4556
}
4657

4758
navigate(Routes.BROWSER.HOME, {

app/components/UI/Stake/components/StakeButton/StakeButton.test.tsx

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,16 @@ jest.mock('@react-navigation/native', () => {
8484

8585
jest.mock('../../../../hooks/useMetrics');
8686

87+
jest.mock('../../../../hooks/useBuildPortfolioUrl', () => ({
88+
useBuildPortfolioUrl: jest.fn(() => (baseUrl: string) => {
89+
const url = new URL(baseUrl);
90+
url.searchParams.set('metamaskEntry', 'mobile');
91+
url.searchParams.set('marketingEnabled', 'true');
92+
url.searchParams.set('metricsEnabled', 'true');
93+
return url;
94+
}),
95+
}));
96+
8797
// Mock the environment variables
8898
jest.mock('../../../../../util/environment', () => ({
8999
isProduction: jest.fn().mockReturnValue(false),
@@ -256,7 +266,7 @@ describe('StakeButton', () => {
256266
await waitFor(() => {
257267
expect(mockNavigate).toHaveBeenCalledWith(Routes.BROWSER.HOME, {
258268
params: {
259-
newTabUrl: `${AppConstants.STAKE.URL}?metamaskEntry=mobile`,
269+
newTabUrl: `${AppConstants.STAKE.URL}?metamaskEntry=mobile&marketingEnabled=true&metricsEnabled=true`,
260270
timestamp: expect.any(Number),
261271
},
262272
screen: Routes.BROWSER.VIEW,

app/components/UI/Stake/components/StakeButton/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import Routes from '../../../../../constants/navigation/Routes';
1313
import AppConstants from '../../../../../core/AppConstants';
1414
import Engine from '../../../../../core/Engine';
1515
import { RootState } from '../../../../../reducers';
16+
import { useBuildPortfolioUrl } from '../../../../hooks/useBuildPortfolioUrl';
1617
import {
1718
selectEvmChainId,
1819
selectNetworkConfigurationByChainId,
@@ -57,6 +58,7 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => {
5758
const styles = createStyles(colors);
5859
const navigation = useNavigation();
5960
const { trackEvent, createEventBuilder } = useMetrics();
61+
const buildPortfolioUrlWithMetrics = useBuildPortfolioUrl();
6062

6163
const browserTabs = useSelector((state: RootState) => state.browser.tabs);
6264
const chainId = useSelector(selectEvmChainId);
@@ -149,7 +151,8 @@ const StakeButtonContent = ({ asset }: StakeButtonProps) => {
149151
if (existingStakeTab) {
150152
existingTabId = existingStakeTab.id;
151153
} else {
152-
newTabUrl = `${AppConstants.STAKE.URL}?metamaskEntry=mobile`;
154+
const stakeUrl = buildPortfolioUrlWithMetrics(AppConstants.STAKE.URL);
155+
newTabUrl = stakeUrl.href;
153156
}
154157
const params = {
155158
...(newTabUrl && { newTabUrl }),

app/components/Views/AssetOptions/AssetOptions.tsx

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,8 @@ import {
3030
} from '../../../util/networks';
3131
import { isPortfolioUrl } from '../../../util/url';
3232
import { BrowserTab, TokenI } from '../../../components/UI/Tokens/types';
33-
import { RootState } from '../../../reducers';
3433
import { CaipAssetType, Hex } from '@metamask/utils';
35-
import { appendURLParams } from '../../../util/browser';
34+
import { useBuildPortfolioUrl } from '../../hooks/useBuildPortfolioUrl';
3635
import InAppBrowser from 'react-native-inappbrowser-reborn';
3736
import { isNonEvmChainId } from '../../../core/Multichain/utils';
3837
import { selectSelectedInternalAccountByScope } from '../../../selectors/multichainAccounts/accounts';
@@ -109,13 +108,11 @@ const AssetOptions = (props: Props) => {
109108
const chainId = useSelector(selectEvmChainId);
110109
// eslint-disable-next-line @typescript-eslint/no-explicit-any
111110
const browserTabs = useSelector((state: any) => state.browser.tabs);
112-
const isDataCollectionForMarketingEnabled = useSelector(
113-
(state: RootState) => state.security.dataCollectionForMarketing,
114-
);
115111
// Get the selected account for the current network (works for all non-EVM chains)
116112
const selectInternalAccountByScope = useSelector(
117113
selectSelectedInternalAccountByScope,
118114
);
115+
const buildPortfolioUrlWithMetrics = useBuildPortfolioUrl();
119116
const assets = useSelector(selectAssetsBySelectedAccountGroup);
120117

121118
// Check if token exists in state
@@ -166,7 +163,7 @@ const AssetOptions = (props: Props) => {
166163
networkConfigurations,
167164
providerConfigTokenExplorer,
168165
);
169-
const { trackEvent, isEnabled, createEventBuilder } = useMetrics();
166+
const { trackEvent, createEventBuilder } = useMetrics();
170167

171168
const goToBrowserUrl = (url: string, title: string) => {
172169
modalRef.current?.dismissModal(() => {
@@ -248,13 +245,9 @@ const AssetOptions = (props: Props) => {
248245
if (existingPortfolioTab) {
249246
existingTabId = existingPortfolioTab.id;
250247
} else {
251-
const analyticsEnabled = isEnabled();
252-
253-
const portfolioUrl = appendURLParams(AppConstants.PORTFOLIO.URL, {
254-
metamaskEntry: 'mobile',
255-
metricsEnabled: analyticsEnabled,
256-
marketingEnabled: isDataCollectionForMarketingEnabled ?? false,
257-
});
248+
const portfolioUrl = buildPortfolioUrlWithMetrics(
249+
AppConstants.PORTFOLIO.URL,
250+
);
258251

259252
newTabUrl = portfolioUrl.href;
260253
}

app/components/Views/Browser/index.js

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,8 @@ import URL from 'url-parse';
4040
import { useMetrics } from '../../hooks/useMetrics';
4141
import { useSafeAreaInsets } from 'react-native-safe-area-context';
4242

43-
import {
44-
appendURLParams,
45-
isTokenDiscoveryBrowserEnabled,
46-
} from '../../../util/browser';
43+
import { isTokenDiscoveryBrowserEnabled } from '../../../util/browser';
44+
import { useBuildPortfolioUrl } from '../../hooks/useBuildPortfolioUrl';
4745
import {
4846
THUMB_WIDTH,
4947
THUMB_HEIGHT,
@@ -78,7 +76,7 @@ export const Browser = (props) => {
7876
const previousTabs = useRef(null);
7977
const { top: topInset } = useSafeAreaInsets();
8078
const { styles } = useStyles(styleSheet, { topInset });
81-
const { trackEvent, createEventBuilder, isEnabled } = useMetrics();
79+
const { trackEvent, createEventBuilder } = useMetrics();
8280
const { toastRef } = useContext(ToastContext);
8381
const browserUrl = props.route?.params?.url;
8482
const linkType = props.route?.params?.linkType;
@@ -89,18 +87,13 @@ export const Browser = (props) => {
8987

9088
const accountAvatarType = useSelector(selectAvatarAccountType);
9189

92-
const isDataCollectionForMarketingEnabled = useSelector(
93-
(state) => state.security.dataCollectionForMarketing,
94-
);
9590
const permittedAccountsList = useSelector(selectPermissionControllerState);
9691

92+
const buildPortfolioUrlWithMetrics = useBuildPortfolioUrl();
93+
9794
const homePageUrl = useCallback(
98-
() =>
99-
appendURLParams(AppConstants.HOMEPAGE_URL, {
100-
metricsEnabled: isEnabled(),
101-
marketingEnabled: isDataCollectionForMarketingEnabled ?? false,
102-
}).href,
103-
[isEnabled, isDataCollectionForMarketingEnabled],
95+
() => buildPortfolioUrlWithMetrics(AppConstants.HOMEPAGE_URL).href,
96+
[buildPortfolioUrlWithMetrics],
10497
);
10598

10699
const newTab = useCallback(
@@ -120,7 +113,6 @@ export const Browser = (props) => {
120113
);
121114

122115
const [currentUrl, setCurrentUrl] = useState(browserUrl || homePageUrl());
123-
124116
const updateTabInfo = useCallback(
125117
(tabID, info) => {
126118
updateTab(tabID, info);

app/components/Views/TrendingView/TrendingView.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ jest.mock('../../../components/hooks/useMetrics', () => ({
4848
}));
4949

5050
jest.mock('../../../util/browser', () => ({
51-
appendURLParams: jest.fn((url) => ({
51+
buildPortfolioUrl: jest.fn((url) => ({
5252
href: `${url}?metamaskEntry=mobile&metricsEnabled=true&marketingEnabled=false`,
5353
})),
5454
}));

app/components/Views/TrendingView/TrendingView.tsx

Lines changed: 4 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ import React, { useCallback, useEffect, useState } from 'react';
22
import { ScrollView, TouchableOpacity, RefreshControl } from 'react-native';
33
import { useSafeAreaInsets } from 'react-native-safe-area-context';
44
import { useNavigation } from '@react-navigation/native';
5-
import { useSelector } from 'react-redux';
65
import { createStackNavigator } from '@react-navigation/stack';
6+
import { useSelector } from 'react-redux';
77
import { useTailwind } from '@metamask/design-system-twrnc-preset';
88
import {
99
Box,
@@ -15,8 +15,7 @@ import {
1515
} from '@metamask/design-system-react-native';
1616
import { strings } from '../../../../locales/i18n';
1717
import AppConstants from '../../../core/AppConstants';
18-
import { appendURLParams } from '../../../util/browser';
19-
import { useMetrics } from '../../hooks/useMetrics';
18+
import { useBuildPortfolioUrl } from '../../hooks/useBuildPortfolioUrl';
2019
import { useTheme } from '../../../util/theme';
2120
import Routes from '../../../constants/navigation/Routes';
2221
import {
@@ -37,7 +36,7 @@ const TrendingFeed: React.FC = () => {
3736
const tw = useTailwind();
3837
const insets = useSafeAreaInsets();
3938
const navigation = useNavigation();
40-
const { isEnabled } = useMetrics();
39+
const buildPortfolioUrlWithMetrics = useBuildPortfolioUrl();
4140
const { colors } = useTheme();
4241
const [refreshing, setRefreshing] = useState(false);
4342
const [refreshTrigger, setRefreshTrigger] = useState(0);
@@ -51,25 +50,14 @@ const TrendingFeed: React.FC = () => {
5150
return unsubscribe;
5251
}, [navigation]);
5352

54-
const isDataCollectionForMarketingEnabled = useSelector(
55-
(state: { security: { dataCollectionForMarketing?: boolean } }) =>
56-
state.security.dataCollectionForMarketing,
57-
);
53+
const portfolioUrl = buildPortfolioUrlWithMetrics(AppConstants.PORTFOLIO.URL);
5854

5955
const browserTabsCount = useSelector(
6056
(state: { browser: { tabs: unknown[] } }) => state.browser.tabs.length,
6157
);
62-
// check if basic functionality toggle is on
6358
const isBasicFunctionalityEnabled = useSelector(
6459
selectBasicFunctionalityEnabled,
6560
);
66-
67-
const portfolioUrl = appendURLParams(AppConstants.PORTFOLIO.URL, {
68-
metamaskEntry: 'mobile',
69-
metricsEnabled: isEnabled(),
70-
marketingEnabled: isDataCollectionForMarketingEnabled ?? false,
71-
});
72-
7361
const handleBrowserPress = useCallback(() => {
7462
updateLastTrendingScreen('TrendingBrowser');
7563
navigation.navigate('TrendingBrowser', {

app/components/Views/TrendingView/components/SectionHeader/SectionHeader.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { strings } from '../../../../../../locales/i18n';
1818
import { SectionId, SECTIONS_CONFIG } from '../../config/sections.config';
1919
import { useNavigation } from '@react-navigation/native';
2020

21-
interface SectionHeaderProps {
21+
export interface SectionHeaderProps {
2222
sectionId: SectionId;
2323
}
2424

0 commit comments

Comments
 (0)