Skip to content

Commit 8e46b07

Browse files
authored
chore: implement rewards version guard (#27664)
<!-- 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** https://consensyssoftware.atlassian.net/browse/RWDS-1119 Fetch minimum required client versions from backend and prompt user to update if app version is not met <!-- 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: null ## **Related issues** Fixes: ## **Manual testing steps** ```gherkin Feature: my feature name Scenario: user [verb for user action] Given [describe expected initial app state] When user [verb for user action] Then [describe expected outcome] ``` ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> ### **After** <img width="1179" height="2556" alt="Simulator Screenshot - E2E Test - 2026-03-18 at 19 29 55" src="https://github.com/user-attachments/assets/8ee44e97-a20c-4298-aa64-6b151eccfb03" /> <!-- [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** - [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] > **Medium Risk** > Adds a backend-driven minimum-version check that can block entry into the Rewards flow and introduces new API/messenger wiring plus Redux state to support it. Risk is moderate because it changes navigation behavior and introduces a new network call/caching path in the Rewards controller/data service. > > **Overview** > Adds a **Rewards version guard**: the app fetches minimum client version requirements from a new public Rewards API endpoint and stores them in Redux, then uses `selectIsRewardsVersionBlocked` to determine if the current app version is allowed. > > When blocked, `RewardsNavigator` now short-circuits normal navigation/rendering and shows a new `RewardsUpdateRequired` screen with an update CTA (App Store/Play Store) and new MetaMetrics events for view/click tracking. > > Extends the Rewards engine stack (data service + controller + messenger + types) with `getClientVersionRequirements`, adds version-guard reducers/selectors and tests, and refactors candidate subscription ID reset logic to preserve the new version-guard fields (plus other non-subscription-scoped state). > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 3c9d911. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 8a3a0c5 commit 8e46b07

19 files changed

Lines changed: 904 additions & 27 deletions

File tree

app/components/UI/Rewards/RewardsNavigator.test.tsx

Lines changed: 73 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ jest.mock('./Views/CampaignMechanicsView', () => {
9797
jest.mock(
9898
'../../../component-library/components-temp/Skeleton/Skeleton',
9999
() => {
100-
const React = jest.requireActual('react');
100+
const ReactActual = jest.requireActual('react');
101101
const { View } = jest.requireActual('react-native');
102102
return function MockSkeleton({
103103
width,
@@ -106,7 +106,7 @@ jest.mock(
106106
width: string;
107107
height: string;
108108
}) {
109-
return React.createElement(View, {
109+
return ReactActual.createElement(View, {
110110
testID: 'skeleton-loader',
111111
style: { width, height },
112112
});
@@ -156,6 +156,10 @@ jest.mock('../../../selectors/rewards', () => ({
156156
selectRewardsSubscriptionId: jest.fn(),
157157
}));
158158

159+
jest.mock('../../../reducers/rewards/selectors', () => ({
160+
selectIsRewardsVersionBlocked: jest.fn(),
161+
}));
162+
159163
// Mock react-navigation/native hooks
160164
const mockNavigate = jest.fn();
161165
const mockSetOptions = jest.fn();
@@ -193,8 +197,31 @@ jest.mock('./hooks/useGeoRewardsMetadata', () => ({
193197
useGeoRewardsMetadata: jest.fn(),
194198
}));
195199

200+
// Mock useRewardsVersionGuard hook
201+
jest.mock('./hooks/useRewardsVersionGuard', () => ({
202+
__esModule: true,
203+
default: jest.fn().mockReturnValue({ fetchVersionRequirements: jest.fn() }),
204+
}));
205+
206+
// Mock RewardsUpdateRequired component
207+
jest.mock('./components/RewardsUpdateRequired/RewardsUpdateRequired', () => {
208+
const ReactActual = jest.requireActual('react');
209+
const { View, Text } = jest.requireActual('react-native');
210+
return {
211+
__esModule: true,
212+
default: function MockRewardsUpdateRequired() {
213+
return ReactActual.createElement(
214+
View,
215+
{ testID: 'rewards-update-required' },
216+
ReactActual.createElement(Text, null, 'Update Required'),
217+
);
218+
},
219+
};
220+
});
221+
196222
// Import mocked selectors and hooks for setup
197223
import { selectRewardsSubscriptionId } from '../../../selectors/rewards';
224+
import { selectIsRewardsVersionBlocked } from '../../../reducers/rewards/selectors';
198225
import { useSeasonStatus } from './hooks/useSeasonStatus';
199226
import { useGeoRewardsMetadata } from './hooks/useGeoRewardsMetadata';
200227

@@ -203,6 +230,11 @@ const mockSelectRewardsSubscriptionId =
203230
typeof selectRewardsSubscriptionId
204231
>;
205232

233+
const mockSelectIsRewardsVersionBlocked =
234+
selectIsRewardsVersionBlocked as jest.MockedFunction<
235+
typeof selectIsRewardsVersionBlocked
236+
>;
237+
206238
const mockUseSeasonStatus = useSeasonStatus as jest.MockedFunction<
207239
typeof useSeasonStatus
208240
>;
@@ -225,6 +257,7 @@ describe('RewardsNavigator', () => {
225257
mockUseGeoRewardsMetadata.mockReturnValue({
226258
fetchGeoRewardsMetadata: jest.fn(),
227259
});
260+
mockSelectIsRewardsVersionBlocked.mockReturnValue(false);
228261

229262
// Create a mock store
230263
store = configureStore({
@@ -511,4 +544,42 @@ describe('RewardsNavigator', () => {
511544
});
512545
});
513546
});
547+
548+
describe('Version guard', () => {
549+
it('renders RewardsUpdateRequired when version is blocked', () => {
550+
mockSelectIsRewardsVersionBlocked.mockReturnValue(true);
551+
552+
const { getByTestId, queryByTestId } = renderWithNavigation(
553+
<RewardsNavigator />,
554+
);
555+
556+
expect(getByTestId('rewards-update-required')).toBeOnTheScreen();
557+
expect(queryByTestId('rewards-onboarding-navigator')).toBeNull();
558+
expect(queryByTestId('rewards-dashboard-view')).toBeNull();
559+
});
560+
561+
it('does not navigate when version is blocked', async () => {
562+
mockSelectIsRewardsVersionBlocked.mockReturnValue(true);
563+
mockNavigate.mockClear();
564+
565+
renderWithNavigation(<RewardsNavigator />);
566+
567+
await waitFor(() => {
568+
expect(mockNavigate).not.toHaveBeenCalled();
569+
});
570+
});
571+
572+
it('renders normal navigator when version is not blocked', async () => {
573+
mockSelectIsRewardsVersionBlocked.mockReturnValue(false);
574+
575+
const { queryByTestId, getByTestId } = renderWithNavigation(
576+
<RewardsNavigator />,
577+
);
578+
579+
await waitFor(() => {
580+
expect(queryByTestId('rewards-update-required')).toBeNull();
581+
expect(getByTestId('rewards-onboarding-navigator')).toBeOnTheScreen();
582+
});
583+
});
584+
});
514585
});

app/components/UI/Rewards/RewardsNavigator.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,16 +11,22 @@ import CampaignMechanicsView from './Views/CampaignMechanicsView';
1111
import PreviousSeasonView from './Views/PreviousSeasonView';
1212
import { useSelector } from 'react-redux';
1313
import { selectRewardsSubscriptionId } from '../../../selectors/rewards';
14+
import { selectIsRewardsVersionBlocked } from '../../../reducers/rewards/selectors';
1415
import { useCandidateSubscriptionId } from './hooks/useCandidateSubscriptionId';
1516
import { useNavigation } from '@react-navigation/native';
1617
import { useSeasonStatus } from './hooks/useSeasonStatus';
1718
import { useGeoRewardsMetadata } from './hooks/useGeoRewardsMetadata';
19+
import useRewardsVersionGuard from './hooks/useRewardsVersionGuard';
20+
import RewardsUpdateRequired from './components/RewardsUpdateRequired/RewardsUpdateRequired';
1821
const Stack = createStackNavigator();
1922

2023
const RewardsNavigator: React.FC = () => {
2124
const subscriptionId = useSelector(selectRewardsSubscriptionId);
25+
const isVersionBlocked = useSelector(selectIsRewardsVersionBlocked);
2226
const navigation = useNavigation();
2327

28+
useRewardsVersionGuard();
29+
2430
// Set candidate subscription ID in Redux state when component mounts and account changes
2531
useCandidateSubscriptionId();
2632

@@ -42,12 +48,19 @@ const RewardsNavigator: React.FC = () => {
4248
};
4349

4450
useEffect(() => {
51+
if (isVersionBlocked) {
52+
return;
53+
}
4554
if (subscriptionId) {
4655
navigation.navigate(Routes.REWARDS_DASHBOARD);
4756
} else {
4857
navigation.navigate(Routes.REWARDS_ONBOARDING_FLOW);
4958
}
50-
}, [navigation, subscriptionId]);
59+
}, [navigation, subscriptionId, isVersionBlocked]);
60+
61+
if (isVersionBlocked) {
62+
return <RewardsUpdateRequired />;
63+
}
5164

5265
return (
5366
<Stack.Navigator initialRouteName={getInitialRoute()}>
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import React from 'react';
2+
import { render, fireEvent, screen } from '@testing-library/react-native';
3+
import { Linking, Platform } from 'react-native';
4+
import RewardsUpdateRequired from './RewardsUpdateRequired';
5+
import { MetaMetricsEvents } from '../../../../../core/Analytics';
6+
import {
7+
MM_APP_STORE_LINK,
8+
MM_PLAY_STORE_LINK,
9+
} from '../../../../../constants/urls';
10+
11+
const mockTrackEvent = jest.fn();
12+
const mockCreateEventBuilder = jest.fn(() => ({
13+
build: jest.fn(() => 'built-event'),
14+
addProperties: jest.fn().mockReturnThis(),
15+
}));
16+
17+
jest.mock('../../../../hooks/useMetrics', () => ({
18+
useMetrics: () => ({
19+
trackEvent: mockTrackEvent,
20+
createEventBuilder: mockCreateEventBuilder,
21+
}),
22+
}));
23+
24+
jest.mock('@metamask/design-system-twrnc-preset', () => ({
25+
useTailwind: () => ({
26+
style: (...args: string[]) => args,
27+
}),
28+
}));
29+
30+
jest.mock('react-native/Libraries/Linking/Linking', () => ({
31+
canOpenURL: jest.fn().mockResolvedValue(true),
32+
openURL: jest.fn(),
33+
}));
34+
35+
describe('RewardsUpdateRequired', () => {
36+
beforeEach(() => {
37+
jest.clearAllMocks();
38+
});
39+
40+
it('renders the title, description, and update button', () => {
41+
render(<RewardsUpdateRequired />);
42+
43+
expect(
44+
screen.getByTestId('rewards-update-required-title'),
45+
).toBeOnTheScreen();
46+
expect(
47+
screen.getByTestId('rewards-update-required-description'),
48+
).toBeOnTheScreen();
49+
expect(
50+
screen.getByTestId('rewards-update-required-update-button'),
51+
).toBeOnTheScreen();
52+
});
53+
54+
it('tracks the version guard viewed event on mount', () => {
55+
render(<RewardsUpdateRequired />);
56+
57+
expect(mockCreateEventBuilder).toHaveBeenCalledWith(
58+
MetaMetricsEvents.REWARDS_VERSION_GUARD_VIEWED,
59+
);
60+
expect(mockTrackEvent).toHaveBeenCalled();
61+
});
62+
63+
it('opens App Store on iOS when update button is pressed', async () => {
64+
Platform.OS = 'ios';
65+
const { getByTestId } = render(<RewardsUpdateRequired />);
66+
67+
fireEvent.press(getByTestId('rewards-update-required-update-button'));
68+
69+
expect(mockCreateEventBuilder).toHaveBeenCalledWith(
70+
MetaMetricsEvents.REWARDS_VERSION_GUARD_UPDATE_CLICKED,
71+
);
72+
73+
await expect(Linking.canOpenURL).toHaveBeenCalledWith(MM_APP_STORE_LINK);
74+
});
75+
76+
it('opens Play Store on Android when update button is pressed', async () => {
77+
Platform.OS = 'android';
78+
const { getByTestId } = render(<RewardsUpdateRequired />);
79+
80+
fireEvent.press(getByTestId('rewards-update-required-update-button'));
81+
82+
await expect(Linking.canOpenURL).toHaveBeenCalledWith(MM_PLAY_STORE_LINK);
83+
});
84+
});
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import React, { useCallback, useEffect } from 'react';
2+
import { Image, Linking, Platform } from 'react-native';
3+
import {
4+
Box,
5+
BoxAlignItems,
6+
BoxJustifyContent,
7+
Button,
8+
ButtonSize,
9+
ButtonVariant,
10+
IconName,
11+
IconSize,
12+
Text,
13+
TextVariant,
14+
} from '@metamask/design-system-react-native';
15+
import { useTailwind } from '@metamask/design-system-twrnc-preset';
16+
import { strings } from '../../../../../../locales/i18n';
17+
import {
18+
MM_APP_STORE_LINK,
19+
MM_PLAY_STORE_LINK,
20+
} from '../../../../../constants/urls';
21+
import { useMetrics } from '../../../../hooks/useMetrics';
22+
import { MetaMetricsEvents } from '../../../../../core/Analytics';
23+
import Logger from '../../../../../util/Logger';
24+
import foxLogo from '../../../../../images/branding/fox.png';
25+
26+
const RewardsUpdateRequired: React.FC = () => {
27+
const tw = useTailwind();
28+
const { trackEvent, createEventBuilder } = useMetrics();
29+
30+
useEffect(() => {
31+
trackEvent(
32+
createEventBuilder(
33+
MetaMetricsEvents.REWARDS_VERSION_GUARD_VIEWED,
34+
).build(),
35+
);
36+
}, [trackEvent, createEventBuilder]);
37+
38+
const handleUpdate = useCallback(() => {
39+
const link = Platform.OS === 'ios' ? MM_APP_STORE_LINK : MM_PLAY_STORE_LINK;
40+
41+
trackEvent(
42+
createEventBuilder(MetaMetricsEvents.REWARDS_VERSION_GUARD_UPDATE_CLICKED)
43+
.addProperties({ link })
44+
.build(),
45+
);
46+
47+
Linking.canOpenURL(link).then(
48+
(supported) => {
49+
if (supported) {
50+
Linking.openURL(link);
51+
}
52+
},
53+
(err) => Logger.error(err, 'Unable to open store link for update'),
54+
);
55+
}, [trackEvent, createEventBuilder]);
56+
57+
return (
58+
<Box
59+
alignItems={BoxAlignItems.Center}
60+
justifyContent={BoxJustifyContent.Center}
61+
twClassName="flex-1 bg-default px-6"
62+
testID="rewards-update-required-container"
63+
>
64+
<Image
65+
source={foxLogo}
66+
style={tw.style('w-24 h-24 mb-6')}
67+
resizeMode="contain"
68+
/>
69+
70+
<Text
71+
variant={TextVariant.HeadingLg}
72+
twClassName="text-center mb-3"
73+
testID="rewards-update-required-title"
74+
>
75+
{strings('rewards.version_guard.title')}
76+
</Text>
77+
78+
<Text
79+
variant={TextVariant.BodyMd}
80+
twClassName="text-alternative text-center mb-8"
81+
testID="rewards-update-required-description"
82+
>
83+
{strings('rewards.version_guard.description')}
84+
</Text>
85+
86+
<Button
87+
variant={ButtonVariant.Primary}
88+
size={ButtonSize.Lg}
89+
startIconName={IconName.Export}
90+
startIconProps={{ size: IconSize.Sm }}
91+
onPress={handleUpdate}
92+
twClassName="w-full"
93+
testID="rewards-update-required-update-button"
94+
>
95+
{strings('rewards.version_guard.update_button')}
96+
</Button>
97+
</Box>
98+
);
99+
};
100+
101+
export default RewardsUpdateRequired;

app/components/UI/Rewards/components/Tabs/ActivityTab/ActivityEventRow.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ jest.mock('../../../../../../util/networks', () => ({
8888
}));
8989

9090
jest.mock('@metamask/utils', () => ({
91+
...jest.requireActual('@metamask/utils'),
9192
parseCaipAssetType: jest.fn(),
9293
}));
9394

0 commit comments

Comments
 (0)