Skip to content

Commit 02c85c2

Browse files
authored
feat: add world cup predictions in explore (FF gated) (MetaMask#30854)
<!-- 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** Add world cup predictions in explore (FF gated) <!-- 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 world cup predictions in explore (FF gated) ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/ASSETS-3203 & https://consensyssoftware.atlassian.net/browse/ASSETS-3204 ## **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** https://github.com/user-attachments/assets/68bb1434-b489-46d0-af3a-aa82b6ea3c48 <!-- [screenshots/recordings] --> ## **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. --> - [ ] 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). - [ ] I've completed the PR template to the best of my ability - [ ] I've included tests if applicable - [ ] I've documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] 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. <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > UI and navigation changes behind existing Predict/World Cup flags; no auth or payment logic touched. > > **Overview** > Explore **Now** and **Sports** can show a **World Cup predictions** carousel when the World Cup screen feature flag is on, instead of the usual category feeds. A new `useWorldCupPredictionsFeed` loads World Cup markets and disables the standard `usePredictionsFeed` while active so only one preview fetches at a time. > > **View all** routes to the dedicated Predict World Cup screen (`navigateToExploreWorldCupPredictions`) with explore entry point and the **All** tab; otherwise behavior is unchanged. List navigation now always passes an explicit `tab` (including `trending`). Copy adds `predict.world_cup.predictions_title`. Tests cover navigation and both tab variants. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 2026668. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent eacbd59 commit 02c85c2

9 files changed

Lines changed: 403 additions & 22 deletions

File tree

app/components/Views/TrendingView/feeds/predictions/predictionsNavigation.test.ts

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import type { AppNavigationProp } from '../../../../../core/NavigationService/types';
22
import Routes from '../../../../../constants/navigation/Routes';
33
import { PredictEventValues } from '../../../../UI/Predict/constants/eventNames';
4+
import { PREDICT_WORLD_CUP_TAB_KEYS } from '../../../../UI/Predict/constants/worldCupTabs';
45
import {
56
navigateToExplorePredictionsList,
7+
navigateToExploreWorldCupPredictions,
68
navigateToPredictionsList,
79
} from './predictionsNavigation';
810

911
describe('navigateToPredictionsList', () => {
10-
it('navigates with an explicit entryPoint and no tab for trending variant', () => {
12+
it('navigates with an explicit entryPoint and trending tab for trending variant', () => {
1113
const navigate = jest.fn();
1214
const navigation = { navigate } as unknown as AppNavigationProp;
1315

@@ -19,7 +21,10 @@ describe('navigateToPredictionsList', () => {
1921

2022
expect(navigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
2123
screen: Routes.PREDICT.MARKET_LIST,
22-
params: { entryPoint: PredictEventValues.ENTRY_POINT.EXPLORE },
24+
params: {
25+
entryPoint: PredictEventValues.ENTRY_POINT.EXPLORE,
26+
tab: 'trending',
27+
},
2328
});
2429
});
2530

@@ -73,7 +78,10 @@ describe('navigateToPredictionsList', () => {
7378

7479
expect(navigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
7580
screen: Routes.PREDICT.MARKET_LIST,
76-
params: { entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED },
81+
params: {
82+
entryPoint: PredictEventValues.ENTRY_POINT.PREDICT_FEED,
83+
tab: 'trending',
84+
},
7785
});
7886
});
7987

@@ -85,7 +93,27 @@ describe('navigateToPredictionsList', () => {
8593

8694
expect(navigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
8795
screen: Routes.PREDICT.MARKET_LIST,
88-
params: { entryPoint: PredictEventValues.ENTRY_POINT.EXPLORE },
96+
params: {
97+
entryPoint: PredictEventValues.ENTRY_POINT.EXPLORE,
98+
tab: 'trending',
99+
},
100+
});
101+
});
102+
});
103+
104+
describe('navigateToExploreWorldCupPredictions', () => {
105+
it('navigates to the dedicated World Cup screen', () => {
106+
const navigate = jest.fn();
107+
const navigation = { navigate } as unknown as AppNavigationProp;
108+
109+
navigateToExploreWorldCupPredictions(navigation);
110+
111+
expect(navigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
112+
screen: Routes.PREDICT.WORLD_CUP,
113+
params: {
114+
entryPoint: PredictEventValues.ENTRY_POINT.EXPLORE,
115+
initialTab: PREDICT_WORLD_CUP_TAB_KEYS.ALL,
116+
},
89117
});
90118
});
91119
});

app/components/Views/TrendingView/feeds/predictions/predictionsNavigation.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import type { AppNavigationProp } from '../../../../../core/NavigationService/types';
22
import Routes from '../../../../../constants/navigation/Routes';
33
import { PredictEventValues } from '../../../../UI/Predict/constants/eventNames';
4+
import { PREDICT_WORLD_CUP_TAB_KEYS } from '../../../../UI/Predict/constants/worldCupTabs';
45
import type { PredictEntryPoint } from '../../../../UI/Predict/types/navigation';
56
import type { PredictionsVariant } from './usePredictionsFeed';
67

7-
const VARIANT_TO_TAB: Record<PredictionsVariant, string | undefined> = {
8-
trending: undefined,
8+
const VARIANT_TO_TAB: Record<PredictionsVariant, string> = {
9+
trending: 'trending',
910
sports: 'sports',
1011
crypto: 'crypto',
1112
politics: 'politics',
@@ -22,7 +23,7 @@ export const navigateToPredictionsList = (
2223
screen: Routes.PREDICT.MARKET_LIST,
2324
params: {
2425
entryPoint,
25-
...(tab && { tab }),
26+
tab,
2627
},
2728
});
2829
};
@@ -38,3 +39,16 @@ export const navigateToExplorePredictionsList = (
3839
PredictEventValues.ENTRY_POINT.EXPLORE,
3940
);
4041
};
42+
43+
/** Navigate from Explore World Cup prediction sections to the dedicated Predict World Cup screen. */
44+
export const navigateToExploreWorldCupPredictions = (
45+
navigation: AppNavigationProp,
46+
): void => {
47+
navigation.navigate(Routes.PREDICT.ROOT, {
48+
screen: Routes.PREDICT.WORLD_CUP,
49+
params: {
50+
entryPoint: PredictEventValues.ENTRY_POINT.EXPLORE,
51+
initialTab: PREDICT_WORLD_CUP_TAB_KEYS.ALL,
52+
},
53+
});
54+
};

app/components/Views/TrendingView/feeds/predictions/usePredictionsFeed.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ interface UsePredictionsFeedOptions {
1313
variant?: PredictionsVariant;
1414
query?: string;
1515
refresh?: RefreshConfig;
16+
enabled?: boolean;
1617
/**
1718
* Number of markets to fetch per page. Applies to both the no-query trending
1819
* fetch and the search fetch. Defaults to 6 for home-tab previews.
@@ -36,23 +37,24 @@ export const usePredictionsFeed = ({
3637
variant = 'trending',
3738
query,
3839
refresh,
40+
enabled = true,
3941
pageSize = 6,
4042
}: UsePredictionsFeedOptions = {}): UsePredictionsFeedResult => {
4143
const hasQuery = Boolean(query?.trim());
4244
const feed = usePredictMarketData({
4345
category: variant,
4446
pageSize,
45-
enabled: !hasQuery,
47+
enabled: enabled && !hasQuery,
4648
});
4749
const search = usePredictSearchMarketData({
4850
q: query ?? '',
4951
pageSize,
50-
enabled: hasQuery,
52+
enabled: enabled && hasQuery,
5153
});
5254

5355
const activeResult = hasQuery ? search : feed;
5456

55-
useFeedRefresh(refresh, activeResult.refetch);
57+
useFeedRefresh(enabled ? refresh : undefined, activeResult.refetch);
5658

5759
// When a search query is active, results are already server-ranked by
5860
// relevance — skip Fuse re-ranking to preserve server order across pages.
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { useSelector } from 'react-redux';
2+
import {
3+
selectPredictWorldCupConfig,
4+
selectPredictWorldCupScreenEnabledFlag,
5+
} from '../../../../UI/Predict/selectors/featureFlags';
6+
import { PREDICT_WORLD_CUP_TAB_KEYS } from '../../../../UI/Predict/constants/worldCupTabs';
7+
import { usePredictWorldCupMarkets } from '../../../../UI/Predict/hooks/usePredictWorldCup';
8+
import { useFeedRefresh } from '../../hooks/useFeedRefresh';
9+
import type { RefreshConfig } from '../../hooks/useExploreRefresh';
10+
import type { UsePredictionsFeedResult } from './usePredictionsFeed';
11+
12+
interface UseWorldCupPredictionsFeedOptions {
13+
enabled?: boolean;
14+
refresh?: RefreshConfig;
15+
pageSize?: number;
16+
}
17+
18+
export interface UseWorldCupPredictionsFeedResult
19+
extends UsePredictionsFeedResult {
20+
isEnabled: boolean;
21+
}
22+
23+
export const useWorldCupPredictionsFeed = ({
24+
enabled = true,
25+
refresh,
26+
pageSize = 6,
27+
}: UseWorldCupPredictionsFeedOptions = {}): UseWorldCupPredictionsFeedResult => {
28+
const config = useSelector(selectPredictWorldCupConfig);
29+
const isScreenEnabled = useSelector(selectPredictWorldCupScreenEnabledFlag);
30+
const isEnabled = enabled && isScreenEnabled;
31+
32+
const worldCupMarkets = usePredictWorldCupMarkets({
33+
tabKey: PREDICT_WORLD_CUP_TAB_KEYS.ALL,
34+
config,
35+
enabled: isEnabled,
36+
pageSize,
37+
});
38+
39+
useFeedRefresh(isEnabled ? refresh : undefined, worldCupMarkets.refetch);
40+
41+
return {
42+
data: worldCupMarkets.marketData,
43+
isLoading: worldCupMarkets.isFetching,
44+
refetch: worldCupMarkets.refetch,
45+
fetchMore: worldCupMarkets.fetchMore,
46+
isFetchingMore: worldCupMarkets.isFetchingMore,
47+
hasMore: worldCupMarkets.hasMore,
48+
isEnabled,
49+
};
50+
};

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

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,18 @@ jest.mock('../feeds/predictions/usePredictionsFeed', () => ({
9595
usePredictionsFeed: () => mockUsePredictionsFeed(),
9696
}));
9797

98+
const mockUseWorldCupPredictionsFeed = jest.fn<
99+
{ data: MockPredictionMarket[]; isLoading: boolean; isEnabled: boolean },
100+
[]
101+
>(() => ({
102+
data: [],
103+
isLoading: false,
104+
isEnabled: false,
105+
}));
106+
jest.mock('../feeds/predictions/useWorldCupPredictionsFeed', () => ({
107+
useWorldCupPredictionsFeed: () => mockUseWorldCupPredictionsFeed(),
108+
}));
109+
98110
jest.mock('../feeds/predictions/PredictionRowItem', () => {
99111
// eslint-disable-next-line @typescript-eslint/no-require-imports
100112
const { createElement } = require('react');
@@ -157,6 +169,7 @@ import NowTab from './NowTab';
157169
import type { RefreshConfig } from '../hooks/useExploreRefresh';
158170
import { useTokensFeed } from '../feeds/tokens/useTokensFeed';
159171
import Routes from '../../../../constants/navigation/Routes';
172+
import { PredictEventValues } from '../../../UI/Predict/constants/eventNames';
160173

161174
const defaultRefresh: RefreshConfig = { trigger: 0, silentRefresh: true };
162175
const defaultTabProps = {
@@ -234,6 +247,11 @@ beforeEach(() => {
234247
defaultSortOptionId: 'priceChange' as const,
235248
});
236249
mockUsePredictionsFeed.mockReturnValue({ data: [], isLoading: false });
250+
mockUseWorldCupPredictionsFeed.mockReturnValue({
251+
data: [],
252+
isLoading: false,
253+
isEnabled: false,
254+
});
237255
mockWhatsHappeningImpl.mockReturnValue(null);
238256
});
239257

@@ -501,6 +519,63 @@ describe('NowTab — Perps Movers "View All" navigation', () => {
501519
});
502520
});
503521

522+
describe('NowTab — Predictions navigation', () => {
523+
const mockUseSelector = useSelector as jest.MockedFunction<
524+
typeof useSelector
525+
>;
526+
527+
beforeEach(() => {
528+
jest.clearAllMocks();
529+
mockUseSelector.mockImplementation((selector) => {
530+
if (selector === selectPerpsEnabledFlag) return false;
531+
if (selector === selectPredictEnabledFlag) return true;
532+
if (selector === selectWhatsHappeningEnabled) return false;
533+
return undefined;
534+
});
535+
mockControlAbTest();
536+
mockUsePredictionsFeed.mockReturnValue({
537+
data: [{ id: 'market-1' }],
538+
isLoading: false,
539+
});
540+
});
541+
542+
it('opens the Predict trending tab from the Predictions section title', () => {
543+
renderNowTab();
544+
545+
fireEvent.press(screen.getByTestId(predictSectionTestId));
546+
547+
expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
548+
screen: Routes.PREDICT.MARKET_LIST,
549+
params: {
550+
entryPoint: PredictEventValues.ENTRY_POINT.EXPLORE,
551+
tab: 'trending',
552+
},
553+
});
554+
});
555+
556+
it('opens the World Cup screen from the Predictions section title when World Cup predictions are enabled', () => {
557+
mockUseWorldCupPredictionsFeed.mockReturnValue({
558+
data: [{ id: 'world-cup-market-1' }],
559+
isLoading: false,
560+
isEnabled: true,
561+
});
562+
563+
renderNowTab();
564+
565+
expect(screen.getByText('World Cup predictions')).toBeOnTheScreen();
566+
567+
fireEvent.press(screen.getByTestId(predictSectionTestId));
568+
569+
expect(mockNavigate).toHaveBeenCalledWith(Routes.PREDICT.ROOT, {
570+
screen: Routes.PREDICT.WORLD_CUP,
571+
params: {
572+
entryPoint: PredictEventValues.ENTRY_POINT.EXPLORE,
573+
initialTab: 'all',
574+
},
575+
});
576+
});
577+
});
578+
504579
describe('NowTab — Crypto Movers', () => {
505580
const mockUseSelector = useSelector as jest.MockedFunction<
506581
typeof useSelector

app/components/Views/TrendingView/tabs/NowTab.tsx

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,11 @@ import PerpsPillItem from '../feeds/perps/PerpsPillItem';
3636
import { navigateToPerpsMarketList } from '../feeds/perps/perpsNavigation';
3737
import { usePredictionsFeed } from '../feeds/predictions/usePredictionsFeed';
3838
import PredictionsCarouselSection from '../feeds/predictions/PredictionsCarouselSection';
39-
import { navigateToExplorePredictionsList } from '../feeds/predictions/predictionsNavigation';
39+
import {
40+
navigateToExplorePredictionsList,
41+
navigateToExploreWorldCupPredictions,
42+
} from '../feeds/predictions/predictionsNavigation';
43+
import { useWorldCupPredictionsFeed } from '../feeds/predictions/useWorldCupPredictionsFeed';
4044
import { useStocksFeed } from '../feeds/stocks/useStocksFeed';
4145
import { getCaipChainIdFromAssetId } from '../../../UI/Trending/components/TrendingTokenRowItem/utils';
4246
import CardList from '../components/CardList';
@@ -185,7 +189,17 @@ const NowTab: React.FC<TabProps> = ({ refresh, refreshing, onRefresh }) => {
185189
whatsHappeningRef.current?.refresh();
186190
}, [refresh.trigger]);
187191

188-
const predictions = usePredictionsFeed({ refresh });
192+
const worldCupPredictions = useWorldCupPredictionsFeed({
193+
enabled: isPredictEnabled,
194+
refresh,
195+
});
196+
const predictions = usePredictionsFeed({
197+
refresh,
198+
enabled: !worldCupPredictions.isEnabled,
199+
});
200+
const displayedPredictions = worldCupPredictions.isEnabled
201+
? worldCupPredictions
202+
: predictions;
189203
const cryptoMovers = useTokensFeed({
190204
refresh,
191205
hideRiskyTokens: true,
@@ -232,13 +246,21 @@ const NowTab: React.FC<TabProps> = ({ refresh, refreshing, onRefresh }) => {
232246
const predictionsSection = (
233247
<PredictionsCarouselSection
234248
key="predictions"
235-
feed={predictions}
249+
feed={displayedPredictions}
236250
tabName="Now"
237251
sectionName="predictions_trending"
238-
title={strings('wallet.predict')}
252+
title={
253+
worldCupPredictions.isEnabled
254+
? strings('predict.world_cup.predictions_title')
255+
: strings('wallet.predict')
256+
}
239257
testIdPrefix="predict-market-row-item"
240258
idPrefix="predictions"
241-
onViewAll={() => navigateToExplorePredictionsList(navigation, 'trending')}
259+
onViewAll={() =>
260+
worldCupPredictions.isEnabled
261+
? navigateToExploreWorldCupPredictions(navigation)
262+
: navigateToExplorePredictionsList(navigation, 'trending')
263+
}
242264
isEnabled={isPredictEnabled}
243265
/>
244266
);

0 commit comments

Comments
 (0)