Skip to content

Commit 9ca42b4

Browse files
runway-github[bot]caieujoaoloureirop
authored
chore(runway): cherry-pick feat(predict): cp-7.62.0 add live price updates to action buttons (#24849)
- feat(predict): cp-7.62.0 add live price updates to action buttons (#24838) <!-- 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** Integrate useLiveMarketPrices hook into PredictActionButtons to display real-time prices from WebSocket connection. The component now: - Subscribes to live price updates for YES/NO tokens - Displays live prices when available with automatic fallback to static prices - Disables subscription when market is closed or component is loading - Works for both standard markets and game markets Includes comprehensive test coverage for live price functionality. <!-- 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: https://consensyssoftware.atlassian.net/browse/PRED-490 ## **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** <!-- [screenshots/recordings] --> ## **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. ## **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] > Adds real-time pricing and strengthens live chart behavior. > > - Integrates `useLiveMarketPrices` in `PredictActionButtons` to render live YES/NO (and game team) prices with fallback to static; subscribes only when market is `open` and not loading; omits claim amount for game markets > - Refines `PredictGameChart` live mode: only initialize when both series have data and not fetching, prevent re-initialization, keep series lengths synced, and preserve accumulated live data across history refetches > - Expands tests in `PredictActionButtons.test.tsx`, `PredictGameChart.wrapper.test.tsx`, and related components to cover live prices, subscription params, partial data, loading states, and edge cases > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 99ec6ba. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> [675b1fd](675b1fd) Co-authored-by: Caainã Jeronimo <caainaje@gmail.com> Co-authored-by: João Loureiro <175489935+joaoloureirop@users.noreply.github.com>
1 parent c6c4025 commit 9ca42b4

6 files changed

Lines changed: 330 additions & 9 deletions

File tree

app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.test.tsx

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@ import {
77
PredictOutcome,
88
PredictMarketStatus,
99
Recurrence,
10+
PriceUpdate,
1011
} from '../../types';
12+
import { useLiveMarketPrices } from '../../hooks/useLiveMarketPrices';
1113

1214
jest.mock('../../../../../../locales/i18n', () => ({
1315
strings: jest.fn((key: string, params?: Record<string, string>) => {
@@ -18,6 +20,15 @@ jest.mock('../../../../../../locales/i18n', () => ({
1820
}),
1921
}));
2022

23+
jest.mock('../../hooks/useLiveMarketPrices');
24+
const mockUseLiveMarketPrices = useLiveMarketPrices as jest.MockedFunction<
25+
typeof useLiveMarketPrices
26+
>;
27+
28+
const createMockGetPrice =
29+
(priceMap: Map<string, PriceUpdate>) => (tokenId: string) =>
30+
priceMap.get(tokenId);
31+
2132
const createMockOutcome = (overrides = {}): PredictOutcome => ({
2233
id: 'outcome-1',
2334
providerId: 'polymarket',
@@ -92,6 +103,12 @@ const createDefaultProps = (overrides = {}) => ({
92103
describe('PredictActionButtons', () => {
93104
beforeEach(() => {
94105
jest.clearAllMocks();
106+
mockUseLiveMarketPrices.mockReturnValue({
107+
prices: new Map(),
108+
getPrice: () => undefined,
109+
isConnected: false,
110+
lastUpdateTime: null,
111+
});
95112
});
96113

97114
describe('loading state', () => {
@@ -160,6 +177,20 @@ describe('PredictActionButtons', () => {
160177

161178
expect(screen.queryByText(/Claim/)).not.toBeOnTheScreen();
162179
});
180+
181+
it('renders claim button without amount for game markets', () => {
182+
const mockOnClaimPress = jest.fn();
183+
const props = createDefaultProps({
184+
market: createMockGameMarket(),
185+
claimableAmount: 50.25,
186+
onClaimPress: mockOnClaimPress,
187+
});
188+
189+
renderWithProvider(<PredictActionButtons {...props} />);
190+
191+
expect(screen.getByTestId('action-buttons-claim')).toBeOnTheScreen();
192+
expect(screen.queryByText('Claim $50.25')).not.toBeOnTheScreen();
193+
});
163194
});
164195

165196
describe('bet buttons for standard markets', () => {
@@ -307,4 +338,129 @@ describe('PredictActionButtons', () => {
307338
expect(screen.getByText('NO · 35¢')).toBeOnTheScreen();
308339
});
309340
});
341+
342+
describe('live price updates', () => {
343+
it('displays live prices when available', () => {
344+
const priceMap = new Map<string, PriceUpdate>([
345+
[
346+
'token-yes',
347+
{ tokenId: 'token-yes', price: 0.72, bestBid: 0.71, bestAsk: 0.73 },
348+
],
349+
[
350+
'token-no',
351+
{ tokenId: 'token-no', price: 0.28, bestBid: 0.27, bestAsk: 0.29 },
352+
],
353+
]);
354+
mockUseLiveMarketPrices.mockReturnValue({
355+
prices: priceMap,
356+
getPrice: createMockGetPrice(priceMap),
357+
isConnected: true,
358+
lastUpdateTime: Date.now(),
359+
});
360+
const props = createDefaultProps();
361+
362+
renderWithProvider(<PredictActionButtons {...props} />);
363+
364+
expect(screen.getByText('YES · 72¢')).toBeOnTheScreen();
365+
expect(screen.getByText('NO · 28¢')).toBeOnTheScreen();
366+
});
367+
368+
it('falls back to static prices when live prices unavailable', () => {
369+
mockUseLiveMarketPrices.mockReturnValue({
370+
prices: new Map(),
371+
getPrice: () => undefined,
372+
isConnected: false,
373+
lastUpdateTime: null,
374+
});
375+
const props = createDefaultProps();
376+
377+
renderWithProvider(<PredictActionButtons {...props} />);
378+
379+
expect(screen.getByText('YES · 65¢')).toBeOnTheScreen();
380+
expect(screen.getByText('NO · 35¢')).toBeOnTheScreen();
381+
});
382+
383+
it('uses partial live prices with fallback for missing tokens', () => {
384+
const priceMap = new Map<string, PriceUpdate>([
385+
[
386+
'token-yes',
387+
{ tokenId: 'token-yes', price: 0.8, bestBid: 0.79, bestAsk: 0.81 },
388+
],
389+
]);
390+
mockUseLiveMarketPrices.mockReturnValue({
391+
prices: priceMap,
392+
getPrice: createMockGetPrice(priceMap),
393+
isConnected: true,
394+
lastUpdateTime: Date.now(),
395+
});
396+
const props = createDefaultProps();
397+
398+
renderWithProvider(<PredictActionButtons {...props} />);
399+
400+
expect(screen.getByText('YES · 80¢')).toBeOnTheScreen();
401+
expect(screen.getByText('NO · 35¢')).toBeOnTheScreen();
402+
});
403+
404+
it('subscribes with correct token IDs', () => {
405+
const props = createDefaultProps();
406+
407+
renderWithProvider(<PredictActionButtons {...props} />);
408+
409+
expect(mockUseLiveMarketPrices).toHaveBeenCalledWith(
410+
['token-yes', 'token-no'],
411+
{ enabled: true },
412+
);
413+
});
414+
415+
it('disables subscription when market is closed', () => {
416+
const props = createDefaultProps({
417+
market: createMockMarket({ status: PredictMarketStatus.CLOSED }),
418+
});
419+
420+
renderWithProvider(<PredictActionButtons {...props} />);
421+
422+
expect(mockUseLiveMarketPrices).toHaveBeenCalledWith(
423+
['token-yes', 'token-no'],
424+
{ enabled: false },
425+
);
426+
});
427+
428+
it('disables subscription when loading', () => {
429+
const props = createDefaultProps({ isLoading: true });
430+
431+
renderWithProvider(<PredictActionButtons {...props} />);
432+
433+
expect(mockUseLiveMarketPrices).toHaveBeenCalledWith(
434+
['token-yes', 'token-no'],
435+
{ enabled: false },
436+
);
437+
});
438+
439+
it('displays live prices for game markets', () => {
440+
const priceMap = new Map<string, PriceUpdate>([
441+
[
442+
'token-yes',
443+
{ tokenId: 'token-yes', price: 0.55, bestBid: 0.54, bestAsk: 0.56 },
444+
],
445+
[
446+
'token-no',
447+
{ tokenId: 'token-no', price: 0.45, bestBid: 0.44, bestAsk: 0.46 },
448+
],
449+
]);
450+
mockUseLiveMarketPrices.mockReturnValue({
451+
prices: priceMap,
452+
getPrice: createMockGetPrice(priceMap),
453+
isConnected: true,
454+
lastUpdateTime: Date.now(),
455+
});
456+
const props = createDefaultProps({
457+
market: createMockGameMarket(),
458+
});
459+
460+
renderWithProvider(<PredictActionButtons {...props} />);
461+
462+
expect(screen.getByText('SEA · 55¢')).toBeOnTheScreen();
463+
expect(screen.getByText('DEN · 45¢')).toBeOnTheScreen();
464+
});
465+
});
310466
});

app/components/UI/Predict/components/PredictActionButtons/PredictActionButtons.tsx

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import PredictClaimButton from './PredictClaimButton';
55
import PredictDetailsButtonsSkeleton from '../PredictDetailsButtonsSkeleton';
66
import { PredictActionButtonsProps } from './PredictActionButtons.types';
77
import { PredictMarketStatus } from '../../types';
8+
import { useLiveMarketPrices } from '../../hooks/useLiveMarketPrices';
89

910
const PredictActionButtons: React.FC<PredictActionButtonsProps> = ({
1011
market,
@@ -16,6 +17,16 @@ const PredictActionButtons: React.FC<PredictActionButtonsProps> = ({
1617
testID = 'predict-action-buttons',
1718
}) => {
1819
const isGameMarket = Boolean(market.game);
20+
const isMarketOpen = market.status === PredictMarketStatus.OPEN;
21+
22+
const tokenIds = useMemo(
23+
() => outcome.tokens.map((token) => token.id),
24+
[outcome.tokens],
25+
);
26+
27+
const { getPrice } = useLiveMarketPrices(tokenIds, {
28+
enabled: isMarketOpen && !isLoading,
29+
});
1930

2031
const buttonConfig = useMemo(() => {
2132
const tokens = outcome.tokens;
@@ -26,27 +37,33 @@ const PredictActionButtons: React.FC<PredictActionButtonsProps> = ({
2637
const yesToken = tokens[0];
2738
const noToken = tokens[1];
2839

40+
const yesLivePrice = getPrice(yesToken.id);
41+
const noLivePrice = getPrice(noToken.id);
42+
43+
const yesPrice = yesLivePrice?.price ?? yesToken.price;
44+
const noPrice = noLivePrice?.price ?? noToken.price;
45+
2946
if (isGameMarket && market.game) {
3047
const { awayTeam, homeTeam } = market.game;
3148
return {
3249
yesLabel: awayTeam.abbreviation,
33-
yesPrice: Math.round(yesToken.price * 100),
50+
yesPrice: Math.round(yesPrice * 100),
3451
yesTeamColor: awayTeam.color,
3552
noLabel: homeTeam.abbreviation,
36-
noPrice: Math.round(noToken.price * 100),
53+
noPrice: Math.round(noPrice * 100),
3754
noTeamColor: homeTeam.color,
3855
};
3956
}
4057

4158
return {
4259
yesLabel: yesToken.title,
43-
yesPrice: Math.round(yesToken.price * 100),
60+
yesPrice: Math.round(yesPrice * 100),
4461
yesTeamColor: undefined,
4562
noLabel: noToken.title,
46-
noPrice: Math.round(noToken.price * 100),
63+
noPrice: Math.round(noPrice * 100),
4764
noTeamColor: undefined,
4865
};
49-
}, [outcome.tokens, isGameMarket, market.game]);
66+
}, [outcome.tokens, isGameMarket, market.game, getPrice]);
5067

5168
if (isLoading) {
5269
return <PredictDetailsButtonsSkeleton testID={`${testID}-skeleton`} />;

app/components/UI/Predict/components/PredictGameChart/PredictGameChart.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,14 +99,23 @@ const PredictGameChart: React.FC<PredictGameChartProps> = ({
9999
return;
100100
}
101101

102+
if (initialDataLoadedRef.current) {
103+
return;
104+
}
105+
106+
if (isFetching) {
107+
return;
108+
}
109+
102110
if (
103111
historicalChartData.length === 2 &&
104-
historicalChartData[0].data.length > 0
112+
historicalChartData[0].data.length > 0 &&
113+
historicalChartData[1].data.length > 0
105114
) {
106115
setLiveChartData(historicalChartData);
107116
initialDataLoadedRef.current = true;
108117
}
109-
}, [isLive, historicalChartData]);
118+
}, [isLive, historicalChartData, isFetching]);
110119

111120
const updateLiveData = useCallback(() => {
112121
if (!isLive || !initialDataLoadedRef.current || prices.size === 0) return;

0 commit comments

Comments
 (0)