Skip to content

Commit 2692fda

Browse files
authored
chore(predict): add world cup tab to main predict feed (#30205)
## **Description** This PR adds the World Cup tab to the main Predict feed behind the existing `predictWorldCup.enabled && showMainFeedTab` configuration. It does this by: - adding `world-cup` as a Predict tab/category key and rendering it before Hot and Trending when enabled - moving tab-specific query params onto each `FeedTab`, so Hot and World Cup can coexist without special casing Hot in `PredictFeed` - using `buildPredictWorldCupAllQuery` for the main feed World Cup tab so it mirrors the dedicated World Cup screen All tab market universe - extending Polymarket event query handling so exact custom params are respected for Hot and World Cup categories - preserving existing tab behavior by falling back to the first visible tab when a requested tab is hidden Automated coverage was added or updated for tab injection/order, disabled behavior, custom query params, and World Cup event query generation. Verified with: - `yarn jest app/components/UI/Predict/hooks/usePredictTabs.test.ts` - `yarn jest app/components/UI/Predict/views/PredictFeed/PredictFeed.test.tsx` - `yarn jest app/components/UI/Predict/providers/polymarket/utils.test.ts` ## **Changelog** CHANGELOG entry: Added a World Cup tab to the Predict feed when enabled. ## **Related issues** Fixes: [PRED-874](https://consensyssoftware.atlassian.net/browse/PRED-874) ## **Manual testing steps** ```gherkin Feature: Predict World Cup main feed tab Scenario: World Cup tab appears first when enabled Given Predict is available And the predictWorldCup flag is enabled with showMainFeedTab true When user opens the main Predict feed Then the World Cup tab appears before Trending And World Cup markets load from the World Cup All tab query Scenario: World Cup and Hot tabs coexist when both flags are enabled Given predictWorldCup is enabled with showMainFeedTab true And predictHotTab is enabled When user opens the main Predict feed Then World Cup appears first And Hot appears after World Cup And selecting Hot loads Hot tab markets Scenario: World Cup tab remains hidden when disabled Given predictWorldCup is disabled or showMainFeedTab is false When user opens the main Predict feed Then the World Cup tab is not shown And Trending is the first standard tab Scenario: World Cup tab can be opened from the main feed tab deeplink Given predictWorldCup is enabled with showMainFeedTab true When user opens https://link.metamask.io/predict?tab=world-cup Then the main Predict feed opens with the World Cup tab selected ``` ## **Screenshots/Recordings** https://github.com/user-attachments/assets/f499a887-a570-40c0-b7e8-f434f878beac ### **Before** N/A ### **After** To be added after manual testing. ## **Pre-merge author checklist** - [ ] 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** - [ ] 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. [PRED-874]: https://consensyssoftware.atlassian.net/browse/PRED-874?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes tab composition and Polymarket event query generation for new `world-cup` category, which can affect which markets are fetched and shown when feature flags/deeplinks are used. > > **Overview** > Adds a new `world-cup` category/tab to the main Predict feed (gated by `selectPredictWorldCupMainFeedTabEnabledFlag`), rendered ahead of Hot/Trending and selectable via deeplink when available. > > Refactors tab handling so each `FeedTab` carries optional `customQueryParams`, letting Hot and World Cup supply their own Polymarket query strings without `PredictFeed` special-casing. > > Updates `fetchEventsFromPolymarketApi` to treat Hot and World Cup as *exact-query* categories (skip default liquidity/volume filters when `customQueryParams` are provided) and adds a World Cup default query fallback using `PREDICT_WORLD_CUP_DEFAULT_TAG_SLUG`. Tests are expanded across `usePredictTabs`, `PredictFeed`, and Polymarket utils to cover ordering, deeplink fallback when tabs are hidden, and query param behavior. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 94108b7. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent c1071c4 commit 2692fda

8 files changed

Lines changed: 398 additions & 57 deletions

File tree

app/components/UI/Predict/constants/feedTabs.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import type { PredictCategory } from '../types';
2+
import { PREDICT_WORLD_CUP_FEED_PARAM } from './worldCupTabs';
23

34
export type PredictTabKey = PredictCategory;
45

@@ -21,9 +22,15 @@ export const PREDICT_HOT_TAB: PredictTabConfig = {
2122
labelKey: 'predict.category.hot',
2223
};
2324

25+
export const PREDICT_WORLD_CUP_TAB: PredictTabConfig = {
26+
key: PREDICT_WORLD_CUP_FEED_PARAM,
27+
labelKey: 'predict.world_cup.title',
28+
};
29+
2430
export const PREDICT_ALL_TABS: readonly PredictTabConfig[] = [
2531
...PREDICT_BASE_TABS,
2632
PREDICT_HOT_TAB,
33+
PREDICT_WORLD_CUP_TAB,
2734
];
2835

2936
const PREDICT_TAB_KEYS = PREDICT_ALL_TABS.map((tab) => tab.key);

app/components/UI/Predict/hooks/usePredictTabs.test.ts

Lines changed: 118 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { renderHook, act } from '@testing-library/react-native';
2+
import { DEFAULT_PREDICT_WORLD_CUP_FLAG } from '../constants/flags';
3+
import { buildPredictWorldCupAllQuery } from '../utils/worldCup';
24
import { usePredictTabs } from './usePredictTabs';
35

46
const mockRouteParams: { tab?: string } = {};
@@ -13,9 +15,29 @@ const mockHotTabFlag: { enabled: boolean; queryParams: string | undefined } = {
1315
enabled: false,
1416
queryParams: undefined,
1517
};
18+
let mockIsWorldCupMainFeedTabEnabled = false;
19+
let mockWorldCupConfig = DEFAULT_PREDICT_WORLD_CUP_FLAG;
1620

1721
jest.mock('react-redux', () => ({
18-
useSelector: () => mockHotTabFlag,
22+
useSelector: (selector: string) => {
23+
switch (selector) {
24+
case 'selectPredictHotTabFlag':
25+
return mockHotTabFlag;
26+
case 'selectPredictWorldCupMainFeedTabEnabledFlag':
27+
return mockIsWorldCupMainFeedTabEnabled;
28+
case 'selectPredictWorldCupConfig':
29+
return mockWorldCupConfig;
30+
default:
31+
return undefined;
32+
}
33+
},
34+
}));
35+
36+
jest.mock('../selectors/featureFlags', () => ({
37+
selectPredictHotTabFlag: 'selectPredictHotTabFlag',
38+
selectPredictWorldCupConfig: 'selectPredictWorldCupConfig',
39+
selectPredictWorldCupMainFeedTabEnabledFlag:
40+
'selectPredictWorldCupMainFeedTabEnabledFlag',
1941
}));
2042

2143
jest.mock('../../../../../locales/i18n', () => ({
@@ -28,6 +50,8 @@ describe('usePredictTabs', () => {
2850
mockRouteParams.tab = undefined;
2951
mockHotTabFlag.enabled = false;
3052
mockHotTabFlag.queryParams = undefined;
53+
mockIsWorldCupMainFeedTabEnabled = false;
54+
mockWorldCupConfig = DEFAULT_PREDICT_WORLD_CUP_FLAG;
3155
});
3256

3357
describe('tabs array', () => {
@@ -47,6 +71,42 @@ describe('usePredictTabs', () => {
4771
expect(result.current.tabs[0].key).toBe('hot');
4872
expect(result.current.tabs[1].key).toBe('trending');
4973
});
74+
75+
it('does not include World Cup tab when main feed tab flag is disabled', () => {
76+
const { result } = renderHook(() => usePredictTabs());
77+
78+
expect(result.current.tabs.map((tab) => tab.key)).not.toContain(
79+
'world-cup',
80+
);
81+
});
82+
83+
it('includes World Cup tab at beginning when enabled', () => {
84+
mockIsWorldCupMainFeedTabEnabled = true;
85+
86+
const { result } = renderHook(() => usePredictTabs());
87+
88+
expect(result.current.tabs).toHaveLength(7);
89+
expect(result.current.tabs[0]).toEqual({
90+
key: 'world-cup',
91+
label: 'predict.world_cup.title',
92+
customQueryParams: buildPredictWorldCupAllQuery(mockWorldCupConfig),
93+
});
94+
expect(result.current.tabs[1].key).toBe('trending');
95+
});
96+
97+
it('places World Cup before Hot when both tabs are enabled', () => {
98+
mockHotTabFlag.enabled = true;
99+
mockHotTabFlag.queryParams = 'test=value';
100+
mockIsWorldCupMainFeedTabEnabled = true;
101+
102+
const { result } = renderHook(() => usePredictTabs());
103+
104+
expect(result.current.tabs.map((tab) => tab.key).slice(0, 3)).toEqual([
105+
'world-cup',
106+
'hot',
107+
'trending',
108+
]);
109+
});
50110
});
51111

52112
describe('activeIndex', () => {
@@ -56,6 +116,15 @@ describe('usePredictTabs', () => {
56116
expect(result.current.activeIndex).toBe(0);
57117
});
58118

119+
it('defaults to 0 (World Cup) when World Cup tab is enabled and no tab is requested', () => {
120+
mockIsWorldCupMainFeedTabEnabled = true;
121+
122+
const { result } = renderHook(() => usePredictTabs());
123+
124+
expect(result.current.activeIndex).toBe(0);
125+
expect(result.current.tabs[0].key).toBe('world-cup');
126+
});
127+
59128
it('sets active index to requested tab position', () => {
60129
mockRouteParams.tab = 'crypto';
61130

@@ -72,6 +141,25 @@ describe('usePredictTabs', () => {
72141
expect(result.current.activeIndex).toBe(0);
73142
});
74143

144+
it('defaults to trending when World Cup tab is requested but disabled', () => {
145+
mockRouteParams.tab = 'world-cup';
146+
147+
const { result } = renderHook(() => usePredictTabs());
148+
149+
expect(result.current.activeIndex).toBe(0);
150+
expect(result.current.initialTabKey).toBe('trending');
151+
});
152+
153+
it('sets active index to requested World Cup tab when enabled', () => {
154+
mockRouteParams.tab = 'world-cup';
155+
mockIsWorldCupMainFeedTabEnabled = true;
156+
157+
const { result } = renderHook(() => usePredictTabs());
158+
159+
expect(result.current.activeIndex).toBe(0);
160+
expect(result.current.initialTabKey).toBe('world-cup');
161+
});
162+
75163
it('updates when setActiveIndex is called', () => {
76164
const { result } = renderHook(() => usePredictTabs());
77165

@@ -184,20 +272,44 @@ describe('usePredictTabs', () => {
184272
});
185273
});
186274

187-
describe('hotTabQueryParams', () => {
188-
it('returns undefined when hot tab is disabled', () => {
275+
describe('tab custom query params', () => {
276+
it('does not add custom query params to base tabs', () => {
189277
const { result } = renderHook(() => usePredictTabs());
190278

191-
expect(result.current.hotTabQueryParams).toBeUndefined();
279+
expect(
280+
result.current.tabs.every((tab) => tab.customQueryParams === undefined),
281+
).toBe(true);
192282
});
193283

194-
it('returns query params when hot tab is enabled', () => {
284+
it('adds query params to the hot tab when enabled', () => {
195285
mockHotTabFlag.enabled = true;
196286
mockHotTabFlag.queryParams = 'test=value';
197287

198288
const { result } = renderHook(() => usePredictTabs());
199289

200-
expect(result.current.hotTabQueryParams).toBe('test=value');
290+
expect(result.current.tabs[0]).toEqual({
291+
key: 'hot',
292+
label: 'predict.category.hot',
293+
customQueryParams: 'test=value',
294+
});
295+
});
296+
297+
it('adds exact World Cup query params to the World Cup tab when enabled', () => {
298+
mockIsWorldCupMainFeedTabEnabled = true;
299+
mockWorldCupConfig = {
300+
...DEFAULT_PREDICT_WORLD_CUP_FLAG,
301+
enabled: true,
302+
showMainFeedTab: true,
303+
tagSlug: 'custom-world-cup',
304+
};
305+
306+
const { result } = renderHook(() => usePredictTabs());
307+
308+
expect(result.current.tabs[0]).toEqual({
309+
key: 'world-cup',
310+
label: 'predict.world_cup.title',
311+
customQueryParams: buildPredictWorldCupAllQuery(mockWorldCupConfig),
312+
});
201313
});
202314
});
203315

app/components/UI/Predict/hooks/usePredictTabs.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,42 @@ import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
22
import { useSelector } from 'react-redux';
33
import { useRoute, RouteProp } from '@react-navigation/native';
44
import { strings } from '../../../../../locales/i18n';
5-
import { selectPredictHotTabFlag } from '../selectors/featureFlags';
5+
import {
6+
selectPredictHotTabFlag,
7+
selectPredictWorldCupConfig,
8+
selectPredictWorldCupMainFeedTabEnabledFlag,
9+
} from '../selectors/featureFlags';
610
import {
711
PREDICT_BASE_TABS,
812
PREDICT_HOT_TAB,
13+
PREDICT_WORLD_CUP_TAB,
914
isPredictTabKey,
1015
type PredictTabKey,
1116
} from '../constants/feedTabs';
1217
import type { PredictNavigationParamList } from '../types/navigation';
18+
import { buildPredictWorldCupAllQuery } from '../utils/worldCup';
1319

1420
export interface FeedTab {
1521
key: PredictTabKey;
1622
label: string;
23+
customQueryParams?: string;
1724
}
1825

1926
export interface UsePredictTabsResult {
2027
tabs: FeedTab[];
2128
activeIndex: number;
2229
setActiveIndex: (index: number) => void;
2330
initialTabKey: PredictTabKey;
24-
hotTabQueryParams?: string;
2531
}
2632

2733
export const usePredictTabs = (): UsePredictTabsResult => {
2834
const route =
2935
useRoute<RouteProp<PredictNavigationParamList, 'PredictMarketList'>>();
3036
const hotTabFlag = useSelector(selectPredictHotTabFlag);
37+
const isWorldCupMainFeedTabEnabled = useSelector(
38+
selectPredictWorldCupMainFeedTabEnabledFlag,
39+
);
40+
const worldCupConfig = useSelector(selectPredictWorldCupConfig);
3141

3242
const tabs: FeedTab[] = useMemo(() => {
3343
const baseTabs: FeedTab[] = PREDICT_BASE_TABS.map((tab) => ({
@@ -39,15 +49,32 @@ export const usePredictTabs = (): UsePredictTabsResult => {
3949
baseTabs.unshift({
4050
key: PREDICT_HOT_TAB.key,
4151
label: strings(PREDICT_HOT_TAB.labelKey),
52+
customQueryParams: hotTabFlag.queryParams,
4253
});
4354
}
4455

45-
return baseTabs;
46-
}, [hotTabFlag.enabled]);
56+
if (isWorldCupMainFeedTabEnabled) {
57+
baseTabs.unshift({
58+
key: PREDICT_WORLD_CUP_TAB.key,
59+
label: strings(PREDICT_WORLD_CUP_TAB.labelKey),
60+
customQueryParams: buildPredictWorldCupAllQuery(worldCupConfig),
61+
});
62+
}
4763

48-
const requestedTabKey = isPredictTabKey(route.params?.tab)
64+
return baseTabs;
65+
}, [
66+
hotTabFlag.enabled,
67+
hotTabFlag.queryParams,
68+
isWorldCupMainFeedTabEnabled,
69+
worldCupConfig,
70+
]);
71+
72+
const requestedValidTabKey = isPredictTabKey(route.params?.tab)
4973
? route.params?.tab
5074
: undefined;
75+
const requestedTabKey = tabs.some((tab) => tab.key === requestedValidTabKey)
76+
? requestedValidTabKey
77+
: undefined;
5178

5279
const initialTabKeyRef = useRef<PredictTabKey>(
5380
requestedTabKey ?? tabs[0].key,
@@ -109,6 +136,5 @@ export const usePredictTabs = (): UsePredictTabsResult => {
109136
activeIndex,
110137
setActiveIndex,
111138
initialTabKey: initialTabKeyRef.current,
112-
hotTabQueryParams: hotTabFlag.queryParams,
113139
};
114140
};

app/components/UI/Predict/providers/polymarket/utils.test.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
clearClobMarketInfoSessionState,
1717
createApiKey,
1818
deriveApiKey,
19+
fetchEventsFromPolymarketApi,
1920
getAllowance,
2021
getClobMarketInfo,
2122
getClobMarketInfoSafe,
@@ -204,6 +205,64 @@ describe('polymarket utils', () => {
204205
);
205206
});
206207

208+
describe('fetchEventsFromPolymarketApi', () => {
209+
const mockEventsPaginationResponse = {
210+
ok: true,
211+
json: jest.fn().mockResolvedValue({ data: [] }),
212+
};
213+
214+
beforeEach(() => {
215+
mockEventsPaginationResponse.json.mockClear();
216+
mockFetch.mockResolvedValue(mockEventsPaginationResponse);
217+
});
218+
219+
it('uses exact World Cup custom query params without normal feed filters', async () => {
220+
await fetchEventsFromPolymarketApi({
221+
category: 'world-cup',
222+
limit: 20,
223+
offset: 40,
224+
customQueryParams:
225+
'active=true&archived=false&closed=false&tag_slug=fifa-world-cup&order=volume24hr',
226+
});
227+
228+
expect(mockFetch).toHaveBeenCalledWith(
229+
'https://gamma-api.polymarket.com/events/pagination?limit=20&offset=40&active=true&archived=false&closed=false&tag_slug=fifa-world-cup&order=volume24hr',
230+
);
231+
const requestedUrl = String(mockFetch.mock.calls[0][0]);
232+
expect(requestedUrl).not.toContain('liquidity_min');
233+
expect(requestedUrl).not.toContain('volume_min');
234+
});
235+
236+
it('falls back to default World Cup query params without normal feed filters', async () => {
237+
await fetchEventsFromPolymarketApi({
238+
category: 'world-cup',
239+
limit: 10,
240+
offset: 0,
241+
});
242+
243+
expect(mockFetch).toHaveBeenCalledWith(
244+
'https://gamma-api.polymarket.com/events/pagination?limit=10&offset=0&active=true&archived=false&closed=false&tag_slug=fifa-world-cup&order=volume24hr&ascending=false',
245+
);
246+
const requestedUrl = String(mockFetch.mock.calls[0][0]);
247+
expect(requestedUrl).toContain('ascending=false');
248+
expect(requestedUrl).not.toContain('liquidity_min');
249+
expect(requestedUrl).not.toContain('volume_min');
250+
});
251+
252+
it('keeps Hot category default query on normal feed filters without custom params', async () => {
253+
await fetchEventsFromPolymarketApi({
254+
category: 'hot',
255+
limit: 20,
256+
offset: 0,
257+
});
258+
259+
const requestedUrl = String(mockFetch.mock.calls[0][0]);
260+
expect(requestedUrl).toContain('liquidity_min=10000');
261+
expect(requestedUrl).toContain('volume_min=10000');
262+
expect(requestedUrl).toContain('&order=volume24hr');
263+
});
264+
});
265+
207266
it('creates API keys against the canonical CLOB host', async () => {
208267
mockFetch.mockResolvedValue({
209268
status: 200,

0 commit comments

Comments
 (0)