Skip to content

Commit 8575cca

Browse files
authored
feat(predict): add feed grouping logic for Crypto Up/Down series (#28500)
## **Description** The Crypto Up/Down feature (Epic PRED-777) needs feed-level deduplication so that recurring series events (e.g., "BTC Up or Down 5m" repeating every 5 minutes) don't show multiple cards. This PR implements the Feed Grouping Logic (PRED-785), which ensures only the first occurrence of each Crypto Up/Down series slug appears in any market list. **Architecture:** Deduplication is implemented as a `refine` callback on `usePredictMarketData`, running during data accumulation (not after). This prevents pagination under-fill — the data layer itself produces deduplicated results, so the FlashList never sees duplicates. **What this adds:** - **Predicate**: `isCryptoUpDown(market)` — type guard returning true when a market has both `series` metadata AND the `up-or-down` tag. Narrows to `PredictMarket & { series: PredictSeries }`. - **Filter**: `deduplicateSeriesMarkets(markets)` — pure function that keeps only the first occurrence of each Up/Down series slug, preserving feed order for all other markets. - **Data hook extension**: `usePredictMarketData` now accepts an optional `refine` callback that post-processes accumulated data during fetch. Both the feed and search overlay pass `deduplicateSeriesMarkets` when the flag is on. - **Feature flag**: `predictUpDownEnabled` resolved via `resolvePredictFeatureFlags()`, version-gated, defaults to `false` (off). **What's NOT in scope:** - `PredictUpDownCard` component (renders the consolidated card — separate ticket) - `PredictUpDownDetails` detail screen (separate ticket) - `isCryptoUpDown` branch in `PredictMarket.tsx` (ships with the card component) ## **Changelog** CHANGELOG entry: null ## **Related issues** Fixes: https://consensyssoftware.atlassian.net/browse/PRED-785 ## **Manual testing steps** ```gherkin Feature: Crypto Up/Down Feed Deduplication Background: Given I am logged into MetaMask Mobile And Predict feature is enabled And predictUpDownEnabled feature flag is enabled Scenario: duplicate Crypto Up/Down events are removed from the feed Given I am on the Predict feed And the feed contains multiple events from the same Crypto Up/Down series When the feed loads Then only the first occurrence of each series slug appears in the list And non-series markets are unaffected Scenario: duplicate Crypto Up/Down events are removed from search Given I open the search overlay And I search for a term matching Crypto Up/Down series events When results load Then only the first occurrence of each series slug appears And non-series search results are unaffected Scenario: feed renders normally when flag is disabled Given predictUpDownEnabled feature flag is disabled And I am on the Predict feed When the feed loads Then all markets render as individual cards (no deduplication) And no regressions in existing feed behavior Scenario: pagination does not produce duplicate series cards Given I am on the Predict feed And I scroll to trigger infinite scroll loading When new pages of data are fetched Then no duplicate series cards appear regardless of page boundaries ``` ## **Screenshots/Recordings** **Note:** changed the crypt tab to show only 5m markets for testing purposes ### **Before** <img width="400" alt="Simulator Screenshot - mm-blue - 2026-04-08 at 13 09 54" src="https://github.com/user-attachments/assets/2c66f266-a920-461f-94a8-782a433f0683" /> ### **After** <img width="400" alt="Simulator Screenshot - mm-blue - 2026-04-08 at 13 09 20" src="https://github.com/user-attachments/assets/649e7ec3-3d83-47a4-bf4f-a9155b85b95c" /> ## **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] > **Medium Risk** > Changes how Predict market lists are accumulated/paginated by adding an optional `refine` post-processor, which can affect ordering and pagination behavior when enabled. Guarded by a new version-gated remote flag, but mistakes could hide markets or cause inconsistent list contents. > > **Overview** > Adds an optional `refine(markets)` callback to `usePredictMarketData` so callers can post-process both initial results and accumulated pagination data. > > Introduces Crypto Up/Down feed deduplication utilities (`isCryptoUpDown`, `deduplicateSeriesMarkets`) and wires them into the Predict feed and search results when the new version-gated remote flag `predictUpDown`/`predictUpDownEnabled` is on. > > Extends Predict feature-flag plumbing (types, resolver, selector, registry) and adds/updates unit tests to cover the new flag behavior and deduplication logic. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit c0c2051. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 8e7eb3e commit 8575cca

13 files changed

Lines changed: 465 additions & 6 deletions

File tree

app/components/UI/Predict/hooks/usePredictMarketData.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export interface UsePredictMarketDataOptions {
1313
category?: PredictCategory;
1414
pageSize?: number;
1515
customQueryParams?: string;
16+
refine?: (markets: PredictMarket[]) => PredictMarket[];
1617
}
1718

1819
export interface UsePredictMarketDataResult {
@@ -37,6 +38,7 @@ export const usePredictMarketData = (
3738
q,
3839
pageSize = 20,
3940
customQueryParams,
41+
refine,
4042
} = options;
4143
const [marketData, setMarketData] = useState<PredictMarket[]>([]);
4244
const [isLoading, setIsLoading] = useState(true);
@@ -119,18 +121,19 @@ export const usePredictMarketData = (
119121

120122
if (isLoadMore) {
121123
setMarketData((prevData) => {
122-
// Use a Map to efficiently deduplicate by ID
124+
// Use a Set to efficiently deduplicate by ID
123125
const existingIds = new Set(prevData.map((event) => event.id));
124126
const newEvents = markets.filter(
125127
(event) => !existingIds.has(event.id),
126128
);
127-
return [...prevData, ...newEvents];
129+
const accumulated = [...prevData, ...newEvents];
130+
return refine ? refine(accumulated) : accumulated;
128131
});
129132
setCurrentOffset((prev) => prev + pageSize);
130133
currentOffsetRef.current += pageSize;
131134
} else {
132135
// Replace data for initial load or refresh
133-
setMarketData(markets);
136+
setMarketData(refine ? refine(markets) : markets);
134137
setCurrentOffset(pageSize);
135138
currentOffsetRef.current = pageSize;
136139
}
@@ -188,7 +191,7 @@ export const usePredictMarketData = (
188191
setIsLoadingMore(false);
189192
}
190193
},
191-
[category, q, pageSize, customQueryParams],
194+
[category, q, pageSize, customQueryParams, refine],
192195
);
193196

194197
const loadMore = useCallback(async () => {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ describe('PolymarketProvider', () => {
259259
},
260260
fakOrdersEnabled: false,
261261
predictWithAnyTokenEnabled: false,
262+
predictUpDownEnabled: false,
262263
};
263264
const createProvider = (
264265
featureFlagsOverride?: Partial<PredictFeatureFlags>,

app/components/UI/Predict/selectors/featureFlags/index.test.ts

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
selectPredictGtmOnboardingModalEnabledFlag,
77
selectPredictHomeFeaturedVariant,
88
selectPredictHotTabFlag,
9+
selectPredictUpDownEnabledFlag,
910
selectPredictWithAnyTokenEnabledFlag,
1011
} from '.';
1112
import mockedEngine from '../../../../../core/__mocks__/MockedEngine';
@@ -1122,4 +1123,136 @@ describe('Predict Feature Flag Selectors', () => {
11221123
expect(result).toBe('carousel');
11231124
});
11241125
});
1126+
1127+
describe('selectPredictUpDownEnabledFlag', () => {
1128+
it('returns true when remote flag is enabled and version check passes', () => {
1129+
mockHasMinimumRequiredVersion.mockReturnValue(true);
1130+
const state = {
1131+
engine: {
1132+
backgroundState: {
1133+
RemoteFeatureFlagController: {
1134+
remoteFeatureFlags: {
1135+
predictUpDown: {
1136+
enabled: true,
1137+
minimumVersion: '1.0.0',
1138+
},
1139+
},
1140+
cacheTimestamp: 0,
1141+
},
1142+
},
1143+
},
1144+
};
1145+
1146+
const result = selectPredictUpDownEnabledFlag(state);
1147+
1148+
expect(result).toBe(true);
1149+
});
1150+
1151+
it('returns false when remote flag is disabled', () => {
1152+
mockHasMinimumRequiredVersion.mockReturnValue(true);
1153+
const state = {
1154+
engine: {
1155+
backgroundState: {
1156+
RemoteFeatureFlagController: {
1157+
remoteFeatureFlags: {
1158+
predictUpDown: {
1159+
enabled: false,
1160+
minimumVersion: '1.0.0',
1161+
},
1162+
},
1163+
cacheTimestamp: 0,
1164+
},
1165+
},
1166+
},
1167+
};
1168+
1169+
const result = selectPredictUpDownEnabledFlag(state);
1170+
1171+
expect(result).toBe(false);
1172+
});
1173+
1174+
it('returns false when app version is below minimum required version', () => {
1175+
mockHasMinimumRequiredVersion.mockReturnValue(false);
1176+
const state = {
1177+
engine: {
1178+
backgroundState: {
1179+
RemoteFeatureFlagController: {
1180+
remoteFeatureFlags: {
1181+
predictUpDown: {
1182+
enabled: true,
1183+
minimumVersion: '99.0.0',
1184+
},
1185+
},
1186+
cacheTimestamp: 0,
1187+
},
1188+
},
1189+
},
1190+
};
1191+
1192+
const result = selectPredictUpDownEnabledFlag(state);
1193+
1194+
expect(result).toBe(false);
1195+
});
1196+
1197+
it('defaults to false when remote flag is null', () => {
1198+
const state = {
1199+
engine: {
1200+
backgroundState: {
1201+
RemoteFeatureFlagController: {
1202+
remoteFeatureFlags: {
1203+
predictUpDown: null,
1204+
},
1205+
cacheTimestamp: 0,
1206+
},
1207+
},
1208+
},
1209+
};
1210+
1211+
const result = selectPredictUpDownEnabledFlag(state);
1212+
1213+
expect(result).toBe(false);
1214+
});
1215+
1216+
it('defaults to false when remote feature flags are empty', () => {
1217+
const result = selectPredictUpDownEnabledFlag(mockedEmptyFlagsState);
1218+
1219+
expect(result).toBe(false);
1220+
});
1221+
1222+
it('defaults to false when controller is undefined', () => {
1223+
const state = {
1224+
engine: {
1225+
backgroundState: {
1226+
RemoteFeatureFlagController: undefined,
1227+
},
1228+
},
1229+
};
1230+
1231+
const result = selectPredictUpDownEnabledFlag(state);
1232+
1233+
expect(result).toBe(false);
1234+
});
1235+
1236+
it('defaults to false when remote flag is invalid', () => {
1237+
const state = {
1238+
engine: {
1239+
backgroundState: {
1240+
RemoteFeatureFlagController: {
1241+
remoteFeatureFlags: {
1242+
predictUpDown: {
1243+
enabled: 'invalid',
1244+
minimumVersion: 123,
1245+
},
1246+
},
1247+
cacheTimestamp: 0,
1248+
},
1249+
},
1250+
},
1251+
};
1252+
1253+
const result = selectPredictUpDownEnabledFlag(state);
1254+
1255+
expect(result).toBe(false);
1256+
});
1257+
});
11251258
});

app/components/UI/Predict/selectors/featureFlags/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,11 @@ export const selectPredictWithAnyTokenEnabledFlag = createSelector(
137137
(flags) => flags.predictWithAnyTokenEnabled,
138138
);
139139

140+
export const selectPredictUpDownEnabledFlag = createSelector(
141+
selectPredictFeatureFlags,
142+
(flags) => flags.predictUpDownEnabled,
143+
);
144+
140145
export const selectPredictFeaturedCarouselEnabledFlag = createSelector(
141146
selectRemoteFeatureFlags,
142147
(remoteFeatureFlags) =>

app/components/UI/Predict/types/flags.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export interface PredictFeatureFlags {
2424
marketHighlightsFlag: PredictMarketHighlightsFlag;
2525
fakOrdersEnabled: boolean;
2626
predictWithAnyTokenEnabled: boolean;
27+
predictUpDownEnabled: boolean;
2728
}
2829

2930
export interface PredictHotTabFlag extends VersionGatedFeatureFlag {
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { isCryptoUpDown, UP_OR_DOWN_TAG } from './cryptoUpDown';
2+
import { Recurrence, type PredictMarket } from '../types';
3+
4+
const createMockMarket = (
5+
overrides: Partial<PredictMarket> = {},
6+
): PredictMarket =>
7+
({
8+
id: 'market-1',
9+
providerId: 'polymarket',
10+
slug: 'market-1',
11+
title: 'Market 1',
12+
description: 'Description',
13+
image: 'https://example.com/img.png',
14+
status: 'open',
15+
recurrence: Recurrence.NONE,
16+
category: 'crypto',
17+
tags: [],
18+
outcomes: [],
19+
liquidity: 100,
20+
volume: 200,
21+
...overrides,
22+
}) as PredictMarket;
23+
24+
describe('cryptoUpDown utilities', () => {
25+
describe('UP_OR_DOWN_TAG', () => {
26+
it('equals "up-or-down"', () => {
27+
expect(UP_OR_DOWN_TAG).toBe('up-or-down');
28+
});
29+
});
30+
31+
describe('isCryptoUpDown', () => {
32+
it('returns true when market has series and up-or-down tag', () => {
33+
const market = createMockMarket({
34+
series: {
35+
id: 's1',
36+
slug: 'btc-up-or-down-5m',
37+
title: 'BTC Up or Down',
38+
recurrence: '5m',
39+
},
40+
tags: ['crypto', 'up-or-down'],
41+
});
42+
43+
const result = isCryptoUpDown(market);
44+
45+
expect(result).toBe(true);
46+
});
47+
48+
it('returns false when market has series but no up-or-down tag', () => {
49+
const market = createMockMarket({
50+
series: {
51+
id: 's1',
52+
slug: 'elon-tweets-monthly',
53+
title: 'Elon Tweets',
54+
recurrence: 'monthly',
55+
},
56+
tags: ['crypto', 'entertainment'],
57+
});
58+
59+
const result = isCryptoUpDown(market);
60+
61+
expect(result).toBe(false);
62+
});
63+
64+
it('returns false when market has up-or-down tag but no series', () => {
65+
const market = createMockMarket({
66+
series: undefined,
67+
tags: ['crypto', 'up-or-down'],
68+
});
69+
70+
const result = isCryptoUpDown(market);
71+
72+
expect(result).toBe(false);
73+
});
74+
75+
it('returns false when market has neither series nor up-or-down tag', () => {
76+
const market = createMockMarket({
77+
series: undefined,
78+
tags: ['politics'],
79+
});
80+
81+
const result = isCryptoUpDown(market);
82+
83+
expect(result).toBe(false);
84+
});
85+
86+
it('returns false when market has empty tags array and series', () => {
87+
const market = createMockMarket({
88+
series: {
89+
id: 's1',
90+
slug: 'btc-up-or-down-5m',
91+
title: 'BTC',
92+
recurrence: '5m',
93+
},
94+
tags: [],
95+
});
96+
97+
const result = isCryptoUpDown(market);
98+
99+
expect(result).toBe(false);
100+
});
101+
102+
it('returns true when up-or-down tag is among many tags', () => {
103+
const market = createMockMarket({
104+
series: {
105+
id: 's1',
106+
slug: 'eth-up-or-down-1h',
107+
title: 'ETH Up or Down',
108+
recurrence: '1h',
109+
},
110+
tags: ['crypto', 'trending', 'up-or-down', 'featured'],
111+
});
112+
113+
const result = isCryptoUpDown(market);
114+
115+
expect(result).toBe(true);
116+
});
117+
});
118+
});
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import type { PredictMarket, PredictSeries } from '../types';
2+
3+
export const UP_OR_DOWN_TAG = 'up-or-down';
4+
5+
/**
6+
* Type guard: narrows to a market with a guaranteed `series` field.
7+
* Returns true when a market has series metadata AND the "up-or-down" tag.
8+
* Regular series markets (e.g., recurring tweet counts) are excluded.
9+
*/
10+
export function isCryptoUpDown(
11+
market: PredictMarket,
12+
): market is PredictMarket & { series: PredictSeries } {
13+
return market.series != null && market.tags.includes(UP_OR_DOWN_TAG);
14+
}

0 commit comments

Comments
 (0)