Skip to content

Commit e5d7c95

Browse files
feat: add support for preview content deep links (#19868)
## **Description** Allows marketing, QA, and others to test [Contentful](https://www.contentful.com/) preview data before publishing. This link can be found internally or inside our Contentful builder (see recording below) <!-- 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? --> ## **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: add preview content deeplink for banners and notifications ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-1326 ## **Manual testing steps** Visit `https://link.metamask.io/home?previewToken=XXX` with the correct preview URL to view preview content. (view internal ticket for preview link) ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** https://www.loom.com/share/1930b4f6aa8542d498ae799e8664e220?sid=56e67b06-2664-4de1-8bc6-f0e167ead9c5 https://github.com/user-attachments/assets/83e09ed8-ade6-4372-a00e-488fdf0fda1b <!-- [screenshots/recordings] --> ## **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** - [ ] 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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > Adds home deeplink support with optional previewToken to enable Contentful preview content and pass token to notifications fetch. > > - **Deeplinks**: > - Add `navigateToHomeUrl` to handle `home` path and parse `previewToken` query param; navigate to `Routes.WALLET.HOME`. > - Update `DeeplinkManager` to pass `homePath` to `_handleOpenHome` and use `navigateToHomeUrl`. > - Update universal link handler to forward `homePath` for `HOME` action. > - Add tests for `navigateToHomeUrl`. > - **Contentful Carousel**: > - Add `getContentfulEnvironmentDetails` to select `environment`, `domain`, and `accessToken` based on `previewToken` and `isProduction()`. > - Update `fetchCarouselSlidesFromContentful` to use selected host/env and token. > - Extend tests to cover environment selection and existing fetch behavior. > - **Notifications**: > - Add `setContentPreviewToken`/`getContentPreviewToken` and store preview token. > - Pass preview token to `NotificationServicesController.fetchAndUpdateMetamaskNotifications(...)`. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit edd255a. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 842a984 commit e5d7c95

File tree

7 files changed

+183
-12
lines changed

7 files changed

+183
-12
lines changed

app/actions/notification/helpers/index.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@ import type { MarkAsReadNotificationsParam } from '@metamask/notification-servic
22
import Engine from '../../../core/Engine';
33
import { isNotificationsFeatureEnabled } from '../../../util/notifications';
44

5+
let previewToken: string | undefined;
6+
7+
export function setContentPreviewToken(newPreviewToken?: string | null) {
8+
if (typeof newPreviewToken === 'string') {
9+
previewToken = newPreviewToken;
10+
}
11+
}
12+
13+
export function getContentPreviewToken() {
14+
return previewToken;
15+
}
16+
517
export const assertIsFeatureEnabled = () => {
618
if (!isNotificationsFeatureEnabled()) {
719
throw new Error(
@@ -106,7 +118,9 @@ export const enableAccounts = async (accounts: string[]) => {
106118
*/
107119
export const fetchNotifications = async () => {
108120
assertIsFeatureEnabled();
109-
await Engine.context.NotificationServicesController.fetchAndUpdateMetamaskNotifications();
121+
await Engine.context.NotificationServicesController.fetchAndUpdateMetamaskNotifications(
122+
getContentPreviewToken(),
123+
);
110124
};
111125

112126
/**

app/components/UI/Carousel/fetchCarouselSlidesFromContentful.test.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@ import { ContentfulClientApi, createClient } from 'contentful';
33
import * as DeviceInfoModule from 'react-native-device-info';
44
import {
55
fetchCarouselSlidesFromContentful,
6+
getContentfulEnvironmentDetails,
67
isActive,
78
} from './fetchCarouselSlidesFromContentful';
89
import { ACCESS_TOKEN, SPACE_ID } from './constants';
10+
import { getContentPreviewToken } from '../../../actions/notification/helpers';
11+
import { isProduction } from '../../../util/environment';
912

1013
jest.mock('contentful', () => ({
1114
createClient: jest.fn(),
@@ -17,6 +20,67 @@ jest.mock('./constants', () => ({
1720
ACCESS_TOKEN: jest.fn().mockReturnValue('mockAccessToken'),
1821
}));
1922

23+
jest.mock('../../../actions/notification/helpers');
24+
25+
jest.mock('../../../util/environment', () => ({
26+
isProduction: jest.fn().mockReturnValue(true),
27+
}));
28+
29+
describe('getContentfulEnvironmentDetails', () => {
30+
beforeEach(() => jest.clearAllMocks());
31+
32+
const arrangeMocks = () => {
33+
const mockGetContentPreviewToken = jest
34+
.mocked(getContentPreviewToken)
35+
.mockReturnValue(undefined);
36+
37+
const mockIsProduction = jest.mocked(isProduction).mockReturnValue(true);
38+
39+
return {
40+
mockGetContentPreviewToken,
41+
mockIsProduction,
42+
};
43+
};
44+
45+
it('returns preview prod environment if token provided', () => {
46+
const mocks = arrangeMocks();
47+
mocks.mockGetContentPreviewToken.mockReturnValue('AAA');
48+
49+
const result = getContentfulEnvironmentDetails();
50+
expect(result).toStrictEqual({
51+
environment: 'master',
52+
domain: 'preview.contentful.com',
53+
accessToken: 'AAA',
54+
spaceId: SPACE_ID(),
55+
});
56+
});
57+
58+
it('returns prod environment is using production', () => {
59+
arrangeMocks();
60+
61+
const result = getContentfulEnvironmentDetails();
62+
expect(result).toStrictEqual({
63+
environment: 'master',
64+
domain: 'cdn.contentful.com',
65+
accessToken: ACCESS_TOKEN(),
66+
spaceId: SPACE_ID(),
67+
});
68+
});
69+
70+
it('returns preview dev environment is not in production', () => {
71+
const mocks = arrangeMocks();
72+
mocks.mockIsProduction.mockReturnValue(false);
73+
74+
const result = getContentfulEnvironmentDetails();
75+
expect(result).toStrictEqual({
76+
environment: 'dev',
77+
domain: 'preview.contentful.com',
78+
accessToken: ACCESS_TOKEN(),
79+
spaceId: SPACE_ID(),
80+
});
81+
});
82+
});
83+
2084
describe('fetchCarouselSlidesFromContentful', () => {
2185
beforeEach(() => {
2286
jest.resetAllMocks();

app/components/UI/Carousel/fetchCarouselSlidesFromContentful.ts

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { isProduction } from '../../../util/environment';
33
import { CarouselSlide } from './types';
44
import { ACCESS_TOKEN, SPACE_ID } from './constants';
55
import { hasMinimumRequiredVersion } from '../../../util/remoteFeatureFlag';
6+
import { getContentPreviewToken } from '../../../actions/notification/helpers';
67

78
export interface ContentfulCarouselSlideFields {
89
headline: string;
@@ -20,11 +21,40 @@ export interface ContentfulCarouselSlideFields {
2021
export type ContentfulSlideSkeleton =
2122
EntrySkeletonType<ContentfulCarouselSlideFields>;
2223

23-
const ENVIRONMENT = isProduction() ? 'master' : 'dev';
24+
export const getContentfulEnvironmentDetails = () => {
25+
// If preview mode, then show preview prod master content
26+
const previewToken = getContentPreviewToken();
27+
if (previewToken) {
28+
return {
29+
environment: 'master',
30+
domain: 'preview.contentful.com',
31+
accessToken: previewToken,
32+
spaceId: SPACE_ID(),
33+
};
34+
}
35+
36+
const isProd = isProduction();
37+
38+
// If production, show prod master content
39+
if (isProd) {
40+
return {
41+
environment: 'master',
42+
domain: 'cdn.contentful.com',
43+
accessToken: ACCESS_TOKEN(),
44+
spaceId: SPACE_ID(),
45+
};
46+
}
47+
48+
// Default to preview dev content
49+
return {
50+
environment: 'dev',
51+
domain: 'preview.contentful.com',
52+
accessToken: ACCESS_TOKEN(),
53+
spaceId: SPACE_ID(),
54+
};
55+
};
56+
2457
const CONTENT_TYPE = 'promotionalBanner';
25-
const DEFAULT_DOMAIN = isProduction()
26-
? 'cdn.contentful.com'
27-
: 'preview.contentful.com';
2858

2959
interface ContentfulSysField {
3060
sys: { id: string };
@@ -34,8 +64,8 @@ export async function fetchCarouselSlidesFromContentful(): Promise<{
3464
prioritySlides: CarouselSlide[];
3565
regularSlides: CarouselSlide[];
3666
}> {
37-
const spaceId = SPACE_ID();
38-
const accessToken = ACCESS_TOKEN();
67+
const { spaceId, accessToken, environment, domain } =
68+
getContentfulEnvironmentDetails();
3969

4070
if (!spaceId || !accessToken) {
4171
console.warn(
@@ -44,14 +74,14 @@ export async function fetchCarouselSlidesFromContentful(): Promise<{
4474
return { prioritySlides: [], regularSlides: [] };
4575
}
4676

47-
const host = `https://${DEFAULT_DOMAIN}/spaces/${spaceId}/environments/${ENVIRONMENT}/entries`;
77+
const host = `https://${domain}/spaces/${spaceId}/environments/${environment}/entries`;
4878

4979
// First try through the Contentful Client
5080
try {
5181
const contentfulClient = createClient({
5282
space: spaceId,
5383
accessToken,
54-
environment: ENVIRONMENT,
84+
environment,
5585
host,
5686
});
5787

app/core/DeeplinkManager/DeeplinkManager.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import parseDeeplink from './ParseManager/parseDeeplink';
1111
import approveTransaction from './TransactionManager/approveTransaction';
1212
import { RampType } from '../../reducers/fiatOrders/types';
1313
import { handleSwapUrl } from './Handlers/handleSwapUrl';
14+
import { navigateToHomeUrl } from './Handlers/handleHomeUrl';
1415
import Routes from '../../constants/navigation/Routes';
1516
import { handleCreateAccountUrl } from './Handlers/handleCreateAccountUrl';
1617
import { handlePerpsUrl } from './Handlers/handlePerpsUrl';
@@ -104,8 +105,8 @@ class DeeplinkManager {
104105
}
105106

106107
// NOTE: open the home screen for new subdomain
107-
_handleOpenHome() {
108-
this.navigation.navigate(Routes.WALLET.HOME);
108+
_handleOpenHome(homePath?: string) {
109+
navigateToHomeUrl({ homePath });
109110
}
110111

111112
// NOTE: this will be used for new deeplink subdomain
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import NavigationService from '../../NavigationService';
2+
import { setContentPreviewToken } from '../../../actions/notification/helpers';
3+
import { navigateToHomeUrl } from './handleHomeUrl';
4+
import Routes from '../../../constants/navigation/Routes';
5+
6+
jest.mock('../../NavigationService');
7+
jest.mock('../../../actions/notification/helpers');
8+
9+
describe('navigateToHomeUrl', () => {
10+
beforeEach(() => jest.clearAllMocks());
11+
12+
const arrangeMocks = () => {
13+
const mockNavigate = jest.fn();
14+
NavigationService.navigation = {
15+
navigate: mockNavigate,
16+
} as unknown as typeof NavigationService.navigation;
17+
18+
const mockSetContentPreviewToken = jest.mocked(setContentPreviewToken);
19+
20+
return {
21+
mockNavigate,
22+
mockSetContentPreviewToken,
23+
};
24+
};
25+
26+
it('navigates to home screen without sending any query params', () => {
27+
const mocks = arrangeMocks();
28+
navigateToHomeUrl({ homePath: 'home' });
29+
30+
expect(mocks.mockSetContentPreviewToken).toHaveBeenCalledWith(null);
31+
expect(mocks.mockNavigate).toHaveBeenCalledWith(Routes.WALLET.HOME);
32+
});
33+
34+
it('sends previewToken and navigates to home screen', () => {
35+
const mocks = arrangeMocks();
36+
navigateToHomeUrl({ homePath: 'home?previewToken=ABC' });
37+
38+
expect(mocks.mockSetContentPreviewToken).toHaveBeenCalledWith('ABC');
39+
expect(mocks.mockNavigate).toHaveBeenCalledWith(Routes.WALLET.HOME);
40+
});
41+
42+
it('falls back to navigated to home sceen when no homePath', () => {
43+
const mocks = arrangeMocks();
44+
navigateToHomeUrl({ homePath: undefined });
45+
46+
expect(mocks.mockSetContentPreviewToken).toHaveBeenCalledWith(null);
47+
expect(mocks.mockNavigate).toHaveBeenCalledWith(Routes.WALLET.HOME);
48+
});
49+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import NavigationService from '../../NavigationService';
2+
import Routes from '../../../constants/navigation/Routes';
3+
import { setContentPreviewToken } from '../../../actions/notification/helpers';
4+
5+
export function navigateToHomeUrl(params: { homePath?: string }) {
6+
const { homePath } = params;
7+
const urlParams = new URLSearchParams(
8+
homePath?.includes('?') ? homePath.split('?')[1] : '',
9+
);
10+
setContentPreviewToken(urlParams.get('previewToken'));
11+
NavigationService.navigation.navigate(Routes.WALLET.HOME);
12+
}

app/core/DeeplinkManager/ParseManager/handleUniversalLink.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,8 @@ async function handleUniversalLink({
173173
const depositCashPath = urlObj.href.replace(BASE_URL_ACTION, '');
174174
instance._handleDepositCash(depositCashPath);
175175
} else if (action === SUPPORTED_ACTIONS.HOME) {
176-
instance._handleOpenHome();
176+
const homePath = urlObj.href.replace(BASE_URL_ACTION, '');
177+
instance._handleOpenHome(homePath);
177178
return;
178179
} else if (action === SUPPORTED_ACTIONS.SWAP) {
179180
const swapPath = urlObj.href.replace(BASE_URL_ACTION, '');

0 commit comments

Comments
 (0)