Skip to content

Commit bc12958

Browse files
authored
feat(perps): add deeplink support for perps (MetaMask#18568)
<!-- Please submit this PR as a draft initially. Do not mark it as "Ready for review" until the template has been completely filled out, and PR status checks have passed at least once. --> ## **Description** This PR implements deeplink support for the Perps (perpetual futures) feature, enabling direct navigation to Perps markets from external sources like marketing campaigns, notifications, and social media. The implementation supports two main deeplink types: 1. **Perps Market Overview**: Routes users to the main Perps tab (with tutorial flow for first-time users) 2. **Specific Asset Details**: Routes users directly to a specific perps asset (e.g., BTC, ETH) ## **Changelog** CHANGELOG entry: Added deeplink support for Perps markets, allowing direct navigation to Perps tab and specific asset details ## **Related issues** Fixes: TAT-1344 ## **Manual testing steps** ### Testing Deeplinks #### iOS Testing ```bash # Test Perps market overview deeplink xcrun simctl openurl booted "https://link-test.metamask.io/perps" # Test specific asset deeplinks xcrun simctl openurl booted "https://link-test.metamask.io/perps-asset?symbol=BTC" xcrun simctl openurl booted "https://link-test.metamask.io/perps-asset?symbol=ETH" xcrun simctl openurl booted "https://link-test.metamask.io/perps-asset?symbol=SOL" ``` #### Android Testing ```bash # Test Perps market overview deeplink adb shell am start -W -a android.intent.action.VIEW -d "https://link-test.metamask.io/perps" io.metamask.debug # Test specific asset deeplinks adb shell am start -W -a android.intent.action.VIEW -d "https://link-test.metamask.io/perps-asset?symbol=BTC" io.metamask.debug adb shell am start -W -a android.intent.action.VIEW -d "https://link-test.metamask.io/perps-asset?symbol=ETH" io.metamask.debug ``` https://github.com/user-attachments/assets/d391fca1-be2a-4d65-96b2-2c5f2ef2ae34 ### Resetting First-Time User State To test the tutorial flow for first-time users, you need to reset the Perps state: 1. **Via Redux DevTools (Development builds):** - Open the app with Redux DevTools enabled - Navigate to Redux state - Find `engine.backgroundState.PerpsController` - Set `isFirstTimeUser: { testnet: true, mainnet: true }` 2. **Via Settings Menu:** - Go to Settings → Advanced → Reset Account - This will reset all account data including Perps state ### Test Scenarios ```gherkin Feature: Perps Deeplinks Navigation Scenario: First-time user navigates via perps deeplink Given the user has MetaMask Mobile installed And the user has never used Perps before (reset state if needed) When user clicks on deeplink "https://link-test.metamask.io/perps" Then the app opens to the Perps Tutorial screen And after completing or skipping the tutorial, user sees the Perps tab selected Scenario: Returning user navigates via perps deeplink Given the user has MetaMask Mobile installed And the user has completed the Perps tutorial When user clicks on deeplink "https://link-test.metamask.io/perps" Then the app opens directly to the Wallet home with Perps tab selected Scenario: User navigates to specific BTC asset via deeplink Given the user has MetaMask Mobile installed When user clicks on deeplink "https://link-test.metamask.io/perps-asset?symbol=BTC" Then the app opens directly to the BTC perps market details screen Scenario: User navigates to specific ETH asset via deeplink Given the user has MetaMask Mobile installed When user clicks on deeplink "https://link-test.metamask.io/perps-asset?symbol=ETH" Then the app opens directly to the ETH perps market details screen Scenario: User navigates with invalid asset symbol Given the user has MetaMask Mobile installed When user clicks on deeplink "https://link-test.metamask.io/perps-asset?symbol=INVALID" Then the app opens to the Perps tab (fallback behavior) Scenario: Tutorial skip navigates correctly from deeplink Given the user is a first-time Perps user And the user arrived via deeplink When user skips the tutorial Then the app navigates to Wallet home with Perps tab selected And the user can see the Perps markets list ``` ### Expected Results 1. **First-time users**: Should see the tutorial carousel with 6 steps, then navigate to Perps tab 2. **Returning users**: Should navigate directly to Wallet home with Perps tab selected 3. **Asset deeplinks**: Should open the specific market details view 4. **Invalid symbols**: Should fallback to Perps markets list 5. **Tab selection**: The Perps tab should be visually selected and active after navigation ## **Screenshots/Recordings** ### **Before** - No deeplink support for Perps feature - Users had to manually navigate to Perps tab through the app ### **After** - Direct deeplink navigation to Perps markets - Support for specific asset deeplinks (BTC, ETH, SOL, etc.) - Smart routing based on user state (tutorial vs direct navigation) https://github.com/user-attachments/assets/28b55198-995a-4e55-abc1-6715e528d9ac ## **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.
1 parent 7e4b249 commit bc12958

19 files changed

Lines changed: 1442 additions & 225 deletions

File tree

app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.test.tsx

Lines changed: 152 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,13 @@
11
import React from 'react';
22
import { render, screen, fireEvent, act } from '@testing-library/react-native';
3-
import { useNavigation } from '@react-navigation/native';
3+
import { useNavigation, useRoute } from '@react-navigation/native';
44
import { useSafeAreaInsets } from 'react-native-safe-area-context';
55
import Routes from '../../../../../constants/navigation/Routes';
66
import PerpsTutorialCarousel, {
77
PERPS_RIVE_ARTBOARD_NAMES,
88
} from './PerpsTutorialCarousel';
99
import { strings } from '../../../../../../locales/i18n';
10+
import { PERFORMANCE_CONFIG } from '../../constants/perpsConfig';
1011

1112
// Mock .riv file to prevent Jest parsing binary data
1213
jest.mock(
@@ -51,6 +52,7 @@ jest.mock('rive-react-native', () => {
5152
// Mock dependencies
5253
jest.mock('@react-navigation/native', () => ({
5354
useNavigation: jest.fn(),
55+
useRoute: jest.fn(),
5456
}));
5557

5658
jest.mock('react-native-safe-area-context', () => ({
@@ -120,17 +122,25 @@ describe('PerpsTutorialCarousel', () => {
120122
const mockNavigation = {
121123
navigate: jest.fn(),
122124
goBack: jest.fn(),
125+
setParams: jest.fn(),
123126
};
124127

125128
beforeEach(() => {
126129
jest.clearAllMocks();
130+
jest.useFakeTimers();
127131
mockMarkTutorialCompleted.mockClear();
128132
mockTrack.mockClear();
129133
mockDepositWithConfirmation.mockClear();
130134
(useNavigation as jest.Mock).mockReturnValue(mockNavigation);
135+
(useRoute as jest.Mock).mockReturnValue({ params: {} });
131136
(useSafeAreaInsets as jest.Mock).mockReturnValue({ top: 0, bottom: 0 });
132137
});
133138

139+
afterEach(() => {
140+
jest.runOnlyPendingTimers();
141+
jest.useRealTimers();
142+
});
143+
134144
describe('Component Rendering', () => {
135145
it('renders correct artboard names for each tutorial screen', async () => {
136146
const expectedArtboards = [
@@ -226,9 +236,7 @@ describe('PerpsTutorialCarousel', () => {
226236
const continueButton = screen.getByText(
227237
strings('perps.tutorial.continue'),
228238
);
229-
await act(async () => {
230-
fireEvent.press(continueButton);
231-
});
239+
fireEvent.press(continueButton);
232240
}
233241

234242
// Verify we're on the last screen
@@ -300,4 +308,144 @@ describe('PerpsTutorialCarousel', () => {
300308
expect(mockDepositWithConfirmation).toHaveBeenCalled();
301309
});
302310
});
311+
312+
describe('Deeplink Navigation', () => {
313+
it('should navigate to wallet home with Perps tab when skipping from deeplink', () => {
314+
// Mock route params to indicate deeplink origin
315+
(useRoute as jest.Mock).mockReturnValue({
316+
params: {
317+
isFromDeeplink: true,
318+
},
319+
});
320+
321+
render(<PerpsTutorialCarousel />);
322+
323+
// Press skip button
324+
act(() => {
325+
fireEvent.press(screen.getByText(strings('perps.tutorial.skip')));
326+
});
327+
328+
// Should navigate to wallet home instead of goBack
329+
expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.WALLET.HOME);
330+
expect(mockNavigation.goBack).not.toHaveBeenCalled();
331+
332+
// Fast-forward timer to trigger setParams
333+
jest.advanceTimersByTime(PERFORMANCE_CONFIG.NAVIGATION_PARAMS_DELAY_MS);
334+
335+
// Should set params to select Perps tab
336+
expect(mockNavigation.setParams).toHaveBeenCalledWith({
337+
initialTab: 'perps',
338+
shouldSelectPerpsTab: true,
339+
});
340+
});
341+
342+
it('should navigate to wallet home with Perps tab when skipping from last screen with deeplink', async () => {
343+
// Mock route params to indicate deeplink origin
344+
(useRoute as jest.Mock).mockReturnValue({
345+
params: {
346+
isFromDeeplink: true,
347+
},
348+
});
349+
350+
render(<PerpsTutorialCarousel />);
351+
352+
// Navigate to the last screen
353+
for (let i = 0; i < 5; i++) {
354+
const continueButton = screen.getByText(
355+
strings('perps.tutorial.continue'),
356+
);
357+
fireEvent.press(continueButton);
358+
}
359+
360+
// Press "Got it" button on last screen
361+
act(() => {
362+
fireEvent.press(screen.getByText(strings('perps.tutorial.got_it')));
363+
});
364+
365+
// Should mark tutorial as completed
366+
expect(mockMarkTutorialCompleted).toHaveBeenCalled();
367+
368+
// Should navigate to wallet home with Perps tab
369+
expect(mockNavigation.navigate).toHaveBeenCalledWith(Routes.WALLET.HOME);
370+
expect(mockNavigation.goBack).not.toHaveBeenCalled();
371+
372+
// Fast-forward timer to trigger setParams
373+
jest.advanceTimersByTime(PERFORMANCE_CONFIG.NAVIGATION_PARAMS_DELAY_MS);
374+
375+
// Should set params to select Perps tab
376+
expect(mockNavigation.setParams).toHaveBeenCalledWith({
377+
initialTab: 'perps',
378+
shouldSelectPerpsTab: true,
379+
});
380+
});
381+
382+
it('should use goBack when not from deeplink', () => {
383+
// Default params (not from deeplink)
384+
(useRoute as jest.Mock).mockReturnValue({
385+
params: {},
386+
});
387+
388+
render(<PerpsTutorialCarousel />);
389+
390+
// Press skip button
391+
act(() => {
392+
fireEvent.press(screen.getByText(strings('perps.tutorial.skip')));
393+
});
394+
395+
// Should use goBack instead of navigate
396+
expect(mockNavigation.goBack).toHaveBeenCalled();
397+
expect(mockNavigation.navigate).not.toHaveBeenCalled();
398+
});
399+
400+
it('should handle undefined route params gracefully', () => {
401+
// Mock route without params
402+
(useRoute as jest.Mock).mockReturnValue({
403+
params: undefined,
404+
});
405+
406+
render(<PerpsTutorialCarousel />);
407+
408+
// Press skip button
409+
act(() => {
410+
fireEvent.press(screen.getByText(strings('perps.tutorial.skip')));
411+
});
412+
413+
// Should default to goBack behavior
414+
expect(mockNavigation.goBack).toHaveBeenCalled();
415+
expect(mockNavigation.navigate).not.toHaveBeenCalled();
416+
});
417+
418+
it('should handle deposit confirmation error gracefully', async () => {
419+
// Mock deposit failure
420+
const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation();
421+
mockDepositWithConfirmation.mockRejectedValue(
422+
new Error('Deposit failed'),
423+
);
424+
425+
render(<PerpsTutorialCarousel />);
426+
427+
// Navigate to last screen and press Add funds
428+
for (let i = 0; i < 5; i++) {
429+
const continueButton = screen.getByText(
430+
strings('perps.tutorial.continue'),
431+
);
432+
fireEvent.press(continueButton);
433+
}
434+
435+
// Press Add funds button
436+
fireEvent.press(screen.getByText(strings('perps.tutorial.add_funds')));
437+
438+
// The depositWithConfirmation is called asynchronously
439+
// We need to wait for the next tick for the promise to reject
440+
await Promise.resolve();
441+
442+
// Should log error
443+
expect(consoleErrorSpy).toHaveBeenCalledWith(
444+
'Failed to initialize deposit:',
445+
expect.any(Error),
446+
);
447+
448+
consoleErrorSpy.mockRestore();
449+
});
450+
});
303451
});

app/components/UI/Perps/components/PerpsTutorialCarousel/PerpsTutorialCarousel.tsx

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
import { NavigationProp, useNavigation } from '@react-navigation/native';
1+
import {
2+
NavigationProp,
3+
useNavigation,
4+
useRoute,
5+
RouteProp,
6+
} from '@react-navigation/native';
27
import React, {
38
useCallback,
49
useEffect,
@@ -25,6 +30,7 @@ import {
2530
PerpsEventProperties,
2631
PerpsEventValues,
2732
} from '../../constants/eventNames';
33+
import { PERFORMANCE_CONFIG } from '../../constants/perpsConfig';
2834
import type { PerpsNavigationParamList } from '../../controllers/types';
2935
import { usePerpsFirstTimeUser, usePerpsTrading } from '../../hooks';
3036
import { usePerpsEventTracking } from '../../hooks/usePerpsEventTracking';
@@ -91,9 +97,16 @@ const tutorialScreens: TutorialScreen[] = [
9197
},
9298
];
9399

100+
interface PerpsTutorialRouteParams {
101+
isFromDeeplink?: boolean;
102+
}
103+
94104
const PerpsTutorialCarousel: React.FC = () => {
95105
const { styles } = useStyles(createStyles, {});
96106
const navigation = useNavigation<NavigationProp<PerpsNavigationParamList>>();
107+
const route =
108+
useRoute<RouteProp<{ params: PerpsTutorialRouteParams }, 'params'>>();
109+
const isFromDeeplink = route.params?.isFromDeeplink || false;
97110
const { markTutorialCompleted } = usePerpsFirstTimeUser();
98111
const { track } = usePerpsEventTracking();
99112
const { depositWithConfirmation } = usePerpsTrading();
@@ -156,6 +169,8 @@ const PerpsTutorialCarousel: React.FC = () => {
156169
markTutorialCompleted();
157170

158171
// Navigate immediately to confirmations screen for instant UI response
172+
// Note: When from deeplink, user will go through deposit flow
173+
// and should return to markets after completion
159174
navigation.navigate(Routes.PERPS.ROOT, {
160175
screen: Routes.FULL_SCREEN_CONFIRMATIONS.REDESIGNED_CONFIRMATIONS,
161176
});
@@ -203,8 +218,31 @@ const PerpsTutorialCarousel: React.FC = () => {
203218
// Mark tutorial as completed
204219
markTutorialCompleted();
205220
}
206-
navigation.goBack();
207-
}, [isLastScreen, markTutorialCompleted, navigation, currentTab, track]);
221+
222+
// Navigate based on deeplink flag
223+
if (isFromDeeplink) {
224+
// Navigate to wallet home first
225+
navigation.navigate(Routes.WALLET.HOME);
226+
227+
// The timeout is REQUIRED - React Navigation needs time to complete
228+
// the navigation before params can be set on the new screen
229+
setTimeout(() => {
230+
navigation.setParams({
231+
initialTab: 'perps',
232+
shouldSelectPerpsTab: true,
233+
});
234+
}, PERFORMANCE_CONFIG.NAVIGATION_PARAMS_DELAY_MS);
235+
} else {
236+
navigation.goBack();
237+
}
238+
}, [
239+
isLastScreen,
240+
markTutorialCompleted,
241+
navigation,
242+
currentTab,
243+
track,
244+
isFromDeeplink,
245+
]);
208246

209247
const renderTabBar = () => <View />;
210248

app/components/UI/Perps/constants/perpsConfig.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,11 @@ export const PERFORMANCE_CONFIG = {
6969
// Prevents excessive validation calls during rapid form input changes
7070
VALIDATION_DEBOUNCE_MS: 1000,
7171

72+
// Navigation params delay (milliseconds)
73+
// Required for React Navigation to complete state transitions before setting params
74+
// This ensures navigation context is available when programmatically selecting tabs
75+
NAVIGATION_PARAMS_DELAY_MS: 100,
76+
7277
// Market data cache duration (milliseconds)
7378
// How long to cache market list data before fetching fresh data
7479
MARKET_DATA_CACHE_DURATION_MS: 5 * 60 * 1000, // 5 minutes

0 commit comments

Comments
 (0)