diff --git a/.yarnrc.yml b/.yarnrc.yml index 8a6d20d53c6..d99dd37287e 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -7,6 +7,7 @@ enableScripts: false nodeLinker: node-modules npmAuditIgnoreAdvisories: + - 1109627 # TODO: Upgrade @react-native-community/cli to 17.0.1+ when ready. Suppressing for now to unblock CI. yarnPath: .yarn/releases/yarn-4.10.3.cjs diff --git a/app/components/UI/Predict/components/PredictConsentSheet/PredictConsentSheet.test.tsx b/app/components/UI/Predict/components/PredictConsentSheet/PredictConsentSheet.test.tsx index d232b2cd6a4..602bcf65f6b 100644 --- a/app/components/UI/Predict/components/PredictConsentSheet/PredictConsentSheet.test.tsx +++ b/app/components/UI/Predict/components/PredictConsentSheet/PredictConsentSheet.test.tsx @@ -1,5 +1,6 @@ import React, { useRef, useEffect } from 'react'; import { render, fireEvent, act } from '@testing-library/react-native'; +import { InteractionManager } from 'react-native'; // Internal dependencies import PredictConsentSheet, { @@ -7,6 +8,25 @@ import PredictConsentSheet, { } from './PredictConsentSheet'; import { usePredictAgreement } from '../../hooks/usePredictAgreement'; +const mockNavigate = jest.fn(); +const runAfterInteractionsCallbacks: (() => void)[] = []; +const mockRunAfterInteractions = jest.spyOn( + InteractionManager, + 'runAfterInteractions', +); +const runAfterInteractionsMockImpl: typeof InteractionManager.runAfterInteractions = + (task) => { + if (typeof task === 'function') { + runAfterInteractionsCallbacks.push(task as () => void); + } + + return { + then: jest.fn(), + done: jest.fn(), + cancel: jest.fn(), + } as ReturnType; + }; + // Mock dependencies jest.mock('react-native/Libraries/Linking/Linking', () => ({ openURL: jest.fn(), @@ -36,7 +56,7 @@ jest.mock('@metamask/design-system-twrnc-preset', () => ({ jest.mock('@react-navigation/native', () => ({ useNavigation: () => ({ - navigate: jest.fn(), + navigate: mockNavigate, goBack: jest.fn(), }), })); @@ -184,6 +204,8 @@ describe('PredictConsentSheet', () => { beforeEach(() => { jest.clearAllMocks(); + runAfterInteractionsCallbacks.length = 0; + mockRunAfterInteractions.mockImplementation(runAfterInteractionsMockImpl); (usePredictAgreement as jest.Mock).mockReturnValue({ isAgreementAccepted: false, acceptAgreement: mockAcceptAgreement, @@ -191,7 +213,12 @@ describe('PredictConsentSheet', () => { }); afterEach(() => { - jest.resetAllMocks(); + jest.clearAllMocks(); + mockRunAfterInteractions.mockReset(); + }); + + afterAll(() => { + mockRunAfterInteractions.mockRestore(); }); describe('visibility behavior', () => { @@ -526,4 +553,79 @@ describe('PredictConsentSheet', () => { expect(customAcceptAgreement).toHaveBeenCalledTimes(1); }); }); + + describe('Terms Link', () => { + it('renders learn more text', () => { + const TestComponent = () => { + const ref = useRef(null); + + useEffect(() => { + act(() => { + ref.current?.onOpenBottomSheet(); + }); + }, []); + + return ( + + ); + }; + + const { getByText } = render(); + + expect(getByText('Learn more')).toBeOnTheScreen(); + }); + + it('navigates to polymarket terms webview when description is pressed', () => { + const TestComponent = () => { + const ref = useRef(null); + + useEffect(() => { + act(() => { + ref.current?.onOpenBottomSheet(); + }); + }, []); + + return ( + + ); + }; + + const { getByText } = render(); + + const learnMoreText = getByText('Learn more'); + const touchableParent = learnMoreText.parent; + + expect(touchableParent).toBeTruthy(); + + act(() => { + if (touchableParent) { + fireEvent.press(touchableParent); + } + }); + + expect(mockRunAfterInteractions).toHaveBeenCalledTimes(1); + const callback = runAfterInteractionsCallbacks[0]; + expect(callback).toBeDefined(); + + callback?.(); + + expect(mockNavigate).toHaveBeenCalledWith('Webview', { + screen: 'SimpleWebview', + params: { + url: 'https://polymarket.com/tos', + title: 'Terms and Conditions', + }, + }); + }); + }); }); diff --git a/app/components/UI/Predict/components/PredictConsentSheet/PredictConsentSheet.tsx b/app/components/UI/Predict/components/PredictConsentSheet/PredictConsentSheet.tsx index 646ad236f47..e4b54849382 100644 --- a/app/components/UI/Predict/components/PredictConsentSheet/PredictConsentSheet.tsx +++ b/app/components/UI/Predict/components/PredictConsentSheet/PredictConsentSheet.tsx @@ -4,8 +4,8 @@ import Text, { TextVariant, } from '../../../../../component-library/components/Texts/Text'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; -import React, { forwardRef, useImperativeHandle } from 'react'; -import { Linking } from 'react-native'; +import React, { forwardRef, useCallback, useImperativeHandle } from 'react'; +import { InteractionManager, TouchableOpacity } from 'react-native'; import { strings } from '../../../../../../locales/i18n'; // Internal dependencies. @@ -19,6 +19,7 @@ import { usePredictBottomSheet, type PredictBottomSheetRef, } from '../../hooks/usePredictBottomSheet'; +import { useNavigation } from '@react-navigation/native'; interface PredictConsentSheetProps { providerId: string; @@ -33,6 +34,7 @@ const PredictConsentSheet = forwardRef< PredictConsentSheetProps >(({ providerId, onDismiss, onAgree }, ref) => { const tw = useTailwind(); + const navigation = useNavigation(); const { acceptAgreement } = usePredictAgreement({ providerId }); const { sheetRef, isVisible, closeSheet, handleSheetClosed, getRefHandlers } = usePredictBottomSheet({ onDismiss }); @@ -49,6 +51,18 @@ const PredictConsentSheet = forwardRef< useImperativeHandle(ref, getRefHandlers, [getRefHandlers]); + const handlePolymarketTerms = useCallback(() => { + InteractionManager.runAfterInteractions(() => { + navigation.navigate('Webview', { + screen: 'SimpleWebview', + params: { + url: 'https://polymarket.com/tos', + title: strings('predict.consent_sheet.title'), + }, + }); + }); + }, [navigation]); + if (!isVisible) { return null; } @@ -66,19 +80,18 @@ const PredictConsentSheet = forwardRef< - - {strings('predict.consent_sheet.description')}{' '} - - { - Linking.openURL('https://polymarket.com/tos'); - }} - suppressHighlighting - > - {strings('predict.consent_sheet.learn_more')} - + + + {strings('predict.consent_sheet.description')}{' '} + + {strings('predict.consent_sheet.learn_more')} + + + { }); it('renders loading state when isLoading is true', () => { - const { getByText } = setupTest({ isLoading: true }); + const { getByTestId } = setupTest({ isLoading: true }); - expect(getByText('Loading price history...')).toBeOnTheScreen(); + const lineChart = getByTestId('line-chart'); + expect(lineChart).toBeOnTheScreen(); }); it('renders empty state when no data provided', () => { diff --git a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx index 4cbbcfede2c..35cbf1fc41d 100644 --- a/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx +++ b/app/components/UI/Predict/components/PredictDetailsChart/PredictDetailsChart.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { StyleSheet } from 'react-native'; +import { StyleSheet, ActivityIndicator } from 'react-native'; import { LineChart } from 'react-native-svg-charts'; import { useTailwind } from '@metamask/design-system-twrnc-preset'; import { @@ -97,15 +97,6 @@ const PredictDetailsChart: React.FC = ({ const renderGraph = () => { if (isLoading || !hasData) { - const placeholderLabel = isLoading - ? 'Loading price history...' - : emptyLabel; - const placeholderAxisLabel = isLoading ? 'Loading...' : '\u00A0'; - const placeholderLabels = Array.from( - { length: 4 }, - () => placeholderAxisLabel, - ); - return ( {isMultipleSeries && ( @@ -125,26 +116,23 @@ const PredictDetailsChart: React.FC = ({ - - {placeholderLabel} - + {isLoading ? ( + + ) : ( + + {emptyLabel} + + )} - {placeholderLabels.map((label, index) => ( - - {label} - - ))} - + twClassName="px-4 pt-4 pb-0 min-h-[31px]" + /> ); } @@ -163,11 +151,11 @@ const PredictDetailsChart: React.FC = ({ ); return ( - + {isMultipleSeries && ( )} - + = ({ {axisLabels.map((point, index) => ( {point.label} diff --git a/app/components/UI/Predict/components/PredictDetailsChart/components/ChartArea.tsx b/app/components/UI/Predict/components/PredictDetailsChart/components/ChartArea.tsx index f29482b586d..e85e9e85948 100644 --- a/app/components/UI/Predict/components/PredictDetailsChart/components/ChartArea.tsx +++ b/app/components/UI/Predict/components/PredictDetailsChart/components/ChartArea.tsx @@ -37,7 +37,7 @@ const ChartArea: React.FC = ({ - + diff --git a/app/components/UI/Predict/components/PredictDetailsChart/components/ChartGrid.tsx b/app/components/UI/Predict/components/PredictDetailsChart/components/ChartGrid.tsx index 19b5490bf1c..fbc787e6ca9 100644 --- a/app/components/UI/Predict/components/PredictDetailsChart/components/ChartGrid.tsx +++ b/app/components/UI/Predict/components/PredictDetailsChart/components/ChartGrid.tsx @@ -28,7 +28,7 @@ const ChartGrid: React.FC = ({ {ticks.map((tick: number, index: number) => ( = ({ diff --git a/app/components/UI/Predict/components/PredictDetailsChart/components/ChartLegend.tsx b/app/components/UI/Predict/components/PredictDetailsChart/components/ChartLegend.tsx index 765d94dd8f7..b91b5e6de3f 100644 --- a/app/components/UI/Predict/components/PredictDetailsChart/components/ChartLegend.tsx +++ b/app/components/UI/Predict/components/PredictDetailsChart/components/ChartLegend.tsx @@ -23,7 +23,7 @@ const ChartLegend: React.FC = ({ series, range }) => { {series.map((seriesItem, index) => { const lastPoint = seriesItem.data[seriesItem.data.length - 1]; diff --git a/app/components/UI/Predict/components/PredictDetailsChart/utils.test.ts b/app/components/UI/Predict/components/PredictDetailsChart/utils.test.ts new file mode 100644 index 00000000000..ae8fa618349 --- /dev/null +++ b/app/components/UI/Predict/components/PredictDetailsChart/utils.test.ts @@ -0,0 +1,455 @@ +import { PredictPriceHistoryInterval } from '../../types'; +import { + DEFAULT_EMPTY_LABEL, + LINE_CURVE, + CHART_HEIGHT, + CHART_CONTENT_INSET, + MAX_SERIES, + formatPriceHistoryLabel, + formatTickValue, +} from './utils'; + +describe('PredictDetailsChart utils', () => { + describe('constants', () => { + it('exports empty string as default label', () => { + expect(DEFAULT_EMPTY_LABEL).toBe(''); + }); + + it('exports chart height as 192', () => { + expect(CHART_HEIGHT).toBe(192); + }); + + it('exports max series as 3', () => { + expect(MAX_SERIES).toBe(3); + }); + + it('exports chart content inset with expected values', () => { + expect(CHART_CONTENT_INSET).toEqual({ + top: 8, + bottom: 4, + left: 8, + right: 48, + }); + }); + + it('exports line curve as a function', () => { + expect(typeof LINE_CURVE).toBe('function'); + expect(LINE_CURVE.alpha).toBeDefined(); + }); + }); + + describe('formatPriceHistoryLabel', () => { + beforeEach(() => { + jest.useFakeTimers(); + // Set fixed timezone for consistent test results + jest.setSystemTime(new Date('2024-01-15T12:00:00.000Z')); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('with seconds timestamp', () => { + const createSecondsTimestamp = (dateString: string): number => + Math.floor(new Date(dateString).getTime() / 1000); + + it('formats ONE_HOUR interval as time with hour and minute', () => { + const timestamp = createSecondsTimestamp('2024-01-15T14:30:00.000Z'); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.ONE_HOUR, + ); + + expect(result).toMatch(/^\d{1,2}:\d{2}\s?(AM|PM)?$/); + }); + + it('formats SIX_HOUR interval as time with hour and minute', () => { + const timestamp = createSecondsTimestamp('2024-01-15T09:15:00.000Z'); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.SIX_HOUR, + ); + + expect(result).toMatch(/^\d{1,2}:\d{2}\s?(AM|PM)?$/); + }); + + it('formats ONE_DAY interval as time with hour and minute', () => { + const timestamp = createSecondsTimestamp('2024-01-15T18:45:00.000Z'); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.ONE_DAY, + ); + + expect(result).toMatch(/^\d{1,2}:\d{2}\s?(AM|PM)?$/); + }); + + it('formats ONE_WEEK interval as weekday with AM period for morning hours', () => { + const timestamp = createSecondsTimestamp('2024-01-15T08:30:00.000Z'); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.ONE_WEEK, + ); + + expect(result).toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s(AM|PM)$/); + expect(result).toContain('AM'); + }); + + it('formats ONE_WEEK interval as weekday with PM period for afternoon hours', () => { + const timestamp = createSecondsTimestamp('2024-01-15T20:00:00.000Z'); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.ONE_WEEK, + ); + + expect(result).toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s(AM|PM)$/); + expect(result).toContain('PM'); + }); + + it('formats ONE_WEEK interval as weekday with PM period for noon', () => { + const timestamp = createSecondsTimestamp('2024-01-15T20:00:00.000Z'); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.ONE_WEEK, + ); + + expect(result).toContain('PM'); + }); + + it('formats ONE_MONTH interval as month and day', () => { + const timestamp = createSecondsTimestamp('2024-01-15T12:00:00.000Z'); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.ONE_MONTH, + ); + + expect(result).toMatch(/^[A-Z][a-z]{2}\s\d{1,2}$/); + }); + + it('formats MAX interval as month and 2-digit year', () => { + const timestamp = createSecondsTimestamp('2024-01-15T12:00:00.000Z'); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.MAX, + ); + + expect(result).toMatch(/^[A-Z][a-z]{2}\s\d{2}$/); + }); + + it('formats unknown interval as month and 2-digit year', () => { + const timestamp = createSecondsTimestamp('2024-01-15T12:00:00.000Z'); + + const result = formatPriceHistoryLabel(timestamp, 'unknown-interval'); + + expect(result).toMatch(/^[A-Z][a-z]{2}\s\d{2}$/); + }); + }); + + describe('with milliseconds timestamp', () => { + it('formats milliseconds timestamp for ONE_HOUR interval', () => { + const timestamp = new Date('2024-01-15T14:30:00.000Z').getTime(); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.ONE_HOUR, + ); + + expect(result).toMatch(/^\d{1,2}:\d{2}\s?(AM|PM)?$/); + }); + + it('formats milliseconds timestamp for ONE_WEEK interval', () => { + const timestamp = new Date('2024-01-15T14:30:00.000Z').getTime(); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.ONE_WEEK, + ); + + expect(result).toMatch(/^(Mon|Tue|Wed|Thu|Fri|Sat|Sun)\s(AM|PM)$/); + }); + + it('formats milliseconds timestamp for ONE_MONTH interval', () => { + const timestamp = new Date('2024-01-15T12:00:00.000Z').getTime(); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.ONE_MONTH, + ); + + expect(result).toMatch(/^[A-Z][a-z]{2}\s\d{1,2}$/); + }); + + it('formats milliseconds timestamp for MAX interval', () => { + const timestamp = new Date('2024-01-15T12:00:00.000Z').getTime(); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.MAX, + ); + + expect(result).toMatch(/^[A-Z][a-z]{2}\s\d{2}$/); + }); + }); + + describe('edge cases', () => { + it('handles timestamp at midnight for ONE_WEEK interval with AM period', () => { + const timestamp = Math.floor( + new Date('2024-01-15T08:00:00.000Z').getTime() / 1000, + ); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.ONE_WEEK, + ); + + expect(result).toContain('AM'); + }); + + it('handles timestamp at 11:59 AM for ONE_WEEK interval with AM period', () => { + const timestamp = Math.floor( + new Date('2024-01-15T11:59:00.000Z').getTime() / 1000, + ); + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.ONE_WEEK, + ); + + expect(result).toContain('AM'); + }); + + it('handles timestamp with zero seconds', () => { + const timestamp = 0; + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.ONE_MONTH, + ); + + expect(result).toMatch(/^[A-Z][a-z]{2}\s\d{1,2}$/); + }); + + it('handles large milliseconds timestamp', () => { + const timestamp = 9_999_999_999_999; + + const result = formatPriceHistoryLabel( + timestamp, + PredictPriceHistoryInterval.MAX, + ); + + expect(result).toMatch(/^[A-Z][a-z]{2}\s\d{2}$/); + }); + }); + + describe('parameterized interval tests', () => { + it.each([ + PredictPriceHistoryInterval.ONE_HOUR, + PredictPriceHistoryInterval.SIX_HOUR, + PredictPriceHistoryInterval.ONE_DAY, + ])( + 'formats %s interval with consistent time format', + (interval: PredictPriceHistoryInterval) => { + const timestamp = Math.floor( + new Date('2024-01-15T14:30:00.000Z').getTime() / 1000, + ); + + const result = formatPriceHistoryLabel(timestamp, interval); + + expect(result).toMatch(/^\d{1,2}:\d{2}\s?(AM|PM)?$/); + }, + ); + }); + }); + + describe('formatTickValue', () => { + describe('with non-finite values', () => { + it('returns 0 string when value is NaN', () => { + const result = formatTickValue(NaN, 5); + + expect(result).toBe('0'); + }); + + it('returns 0 string when value is positive Infinity', () => { + const result = formatTickValue(Infinity, 5); + + expect(result).toBe('0'); + }); + + it('returns 0 string when value is negative Infinity', () => { + const result = formatTickValue(-Infinity, 5); + + expect(result).toBe('0'); + }); + }); + + describe('with range less than 1', () => { + it('returns value with 2 decimal places when range is 0.5', () => { + const result = formatTickValue(0.12345, 0.5); + + expect(result).toBe('0.12'); + }); + + it('returns value with 2 decimal places when range is 0.99', () => { + const result = formatTickValue(0.56789, 0.99); + + expect(result).toBe('0.57'); + }); + + it('returns value with 2 decimal places when range is 0', () => { + const result = formatTickValue(0.123, 0); + + expect(result).toBe('0.12'); + }); + + it('rounds up correctly for 2 decimal places', () => { + const result = formatTickValue(0.126, 0.5); + + expect(result).toBe('0.13'); + }); + }); + + describe('with range between 1 and 10', () => { + it('returns value with 1 decimal place when range is 5', () => { + const result = formatTickValue(3.456, 5); + + expect(result).toBe('3.5'); + }); + + it('returns value with 1 decimal place when range is 1', () => { + const result = formatTickValue(1.234, 1); + + expect(result).toBe('1.2'); + }); + + it('returns value with 1 decimal place when range is 9.99', () => { + const result = formatTickValue(7.89, 9.99); + + expect(result).toBe('7.9'); + }); + + it('rounds up correctly for 1 decimal place', () => { + const result = formatTickValue(3.46, 5); + + expect(result).toBe('3.5'); + }); + }); + + describe('with range of 10 or greater', () => { + it('returns value with 0 decimal places when range is 10', () => { + const result = formatTickValue(15.678, 10); + + expect(result).toBe('16'); + }); + + it('returns value with 0 decimal places when range is 100', () => { + const result = formatTickValue(42.3, 100); + + expect(result).toBe('42'); + }); + + it('returns value with 0 decimal places when range is 1000', () => { + const result = formatTickValue(999.9, 1000); + + expect(result).toBe('1000'); + }); + + it('rounds down correctly for 0 decimal places', () => { + const result = formatTickValue(15.4, 10); + + expect(result).toBe('15'); + }); + }); + + describe('edge cases', () => { + it('handles zero value with small range', () => { + const result = formatTickValue(0, 0.5); + + expect(result).toBe('0.00'); + }); + + it('handles zero value with large range', () => { + const result = formatTickValue(0, 100); + + expect(result).toBe('0'); + }); + + it('handles negative value with range less than 1', () => { + const result = formatTickValue(-0.456, 0.5); + + expect(result).toBe('-0.46'); + }); + + it('handles negative value with range between 1 and 10', () => { + const result = formatTickValue(-5.67, 5); + + expect(result).toBe('-5.7'); + }); + + it('handles negative value with range of 10 or greater', () => { + const result = formatTickValue(-42.8, 50); + + expect(result).toBe('-43'); + }); + + it('handles very small positive value', () => { + const result = formatTickValue(0.0001, 0.5); + + expect(result).toBe('0.00'); + }); + + it('handles very large value', () => { + const result = formatTickValue(999999.999, 1000000); + + expect(result).toBe('1000000'); + }); + }); + + describe('parameterized range tests', () => { + it.each([ + [0.5, '0.12'], + [0.8, '0.12'], + [0.99, '0.12'], + ])( + 'formats value with 2 decimals when range is %f', + (range: number, expected: string) => { + const result = formatTickValue(0.123, range); + + expect(result).toBe(expected); + }, + ); + + it.each([ + [1, '5.7'], + [5, '5.7'], + [9.5, '5.7'], + ])( + 'formats value with 1 decimal when range is %f', + (range: number, expected: string) => { + const result = formatTickValue(5.68, range); + + expect(result).toBe(expected); + }, + ); + + it.each([ + [10, '6'], + [50, '6'], + [1000, '6'], + ])( + 'formats value with 0 decimals when range is %f', + (range: number, expected: string) => { + const result = formatTickValue(5.68, range); + + expect(result).toBe(expected); + }, + ); + }); + }); +}); diff --git a/app/components/UI/Predict/components/PredictDetailsChart/utils.ts b/app/components/UI/Predict/components/PredictDetailsChart/utils.ts index cf36da6ff1d..37f80c73b03 100644 --- a/app/components/UI/Predict/components/PredictDetailsChart/utils.ts +++ b/app/components/UI/Predict/components/PredictDetailsChart/utils.ts @@ -2,13 +2,13 @@ import { curveCatmullRom } from 'd3-shape'; import { PredictPriceHistoryInterval } from '../../types'; export const DEFAULT_EMPTY_LABEL = ''; -export const LINE_CURVE = curveCatmullRom.alpha(0.3); +export const LINE_CURVE = curveCatmullRom.alpha(0.2); export const CHART_HEIGHT = 192; export const CHART_CONTENT_INSET = { - top: 20, - bottom: 20, - left: 20, - right: 32, + top: 8, + bottom: 4, + left: 8, + right: 48, }; export const MAX_SERIES = 3; @@ -27,11 +27,13 @@ export const formatPriceHistoryLabel = ( hour: 'numeric', minute: '2-digit', }).format(date); - case PredictPriceHistoryInterval.ONE_WEEK: - return new Intl.DateTimeFormat('en-US', { + case PredictPriceHistoryInterval.ONE_WEEK: { + const weekday = new Intl.DateTimeFormat('en-US', { weekday: 'short', - hour: 'numeric', }).format(date); + const period = date.getHours() >= 12 ? 'PM' : 'AM'; + return `${weekday} ${period}`; + } case PredictPriceHistoryInterval.ONE_MONTH: return new Intl.DateTimeFormat('en-US', { month: 'short', @@ -41,7 +43,7 @@ export const formatPriceHistoryLabel = ( default: return new Intl.DateTimeFormat('en-US', { month: 'short', - year: 'numeric', + year: '2-digit', }).format(date); } }; diff --git a/app/components/UI/Predict/components/PredictPosition/PredictPosition.styles.ts b/app/components/UI/Predict/components/PredictPosition/PredictPosition.styles.ts index 12bfbe1f790..e9127fcbb6a 100644 --- a/app/components/UI/Predict/components/PredictPosition/PredictPosition.styles.ts +++ b/app/components/UI/Predict/components/PredictPosition/PredictPosition.styles.ts @@ -20,8 +20,7 @@ const styleSheet = () => flexDirection: 'column', justifyContent: 'flex-start', alignItems: 'flex-start', - width: '100%', - flex: 5, + flex: 1, }, positionImageContainer: { paddingTop: 4, @@ -36,8 +35,6 @@ const styleSheet = () => flexDirection: 'column', justifyContent: 'flex-end', alignItems: 'flex-end', - width: '100%', - flex: 2, }, marketEntry: { flexDirection: 'column', diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts index 4785cdd6d4c..80a4cdee44c 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.test.ts @@ -823,6 +823,7 @@ describe('PolymarketProvider', () => { mockSubmitClobOrder.mockResolvedValue({ success: true, response: { + success: true, makingAmount: '1000000', orderID: 'order-123', status: 'success', @@ -1131,7 +1132,7 @@ describe('PolymarketProvider', () => { }); mockSubmitClobOrder.mockResolvedValue({ success: true, - response: { orderId: 'test-order' }, + response: { success: true, orderId: 'test-order' }, error: undefined, }); mockCreateApiKey.mockResolvedValue({ @@ -2599,7 +2600,7 @@ describe('PolymarketProvider', () => { }); }; - it('does not set rateLimited for SELL orders', async () => { + it('sets rateLimited for SELL orders after BUY order', async () => { setupPreviewOrderMock(); const { provider, mockSigner } = setupPlaceOrderTest(); @@ -2610,7 +2611,7 @@ describe('PolymarketProvider', () => { preview, }); - // Now try to preview a SELL order - should NOT be rate limited + // Now try to preview a SELL order - should also be rate limited const sellPreview = await provider.previewOrder({ marketId: 'market-1', outcomeId: 'outcome-1', @@ -2620,7 +2621,7 @@ describe('PolymarketProvider', () => { signer: mockSigner, }); - expect(sellPreview.rateLimited).toBeUndefined(); + expect(sellPreview.rateLimited).toBe(true); }); it('does not set rateLimited when signer is not provided', async () => { diff --git a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts index cef3aff544a..e1c65bdbeb3 100644 --- a/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts +++ b/app/components/UI/Predict/providers/polymarket/PolymarketProvider.ts @@ -44,7 +44,7 @@ import { } from '../types'; import { PREDICT_CONSTANTS } from '../../constants/errors'; import { - BUY_ORDER_RATE_LIMIT_MS, + ORDER_RATE_LIMIT_MS, FEE_COLLECTOR_ADDRESS, MATIC_CONTRACTS, POLYGON_MAINNET_CHAIN_ID, @@ -203,7 +203,7 @@ export class PolymarketProvider implements PredictProvider { return false; } const elapsed = Date.now() - lastTimestamp; - return elapsed < BUY_ORDER_RATE_LIMIT_MS; + return elapsed < ORDER_RATE_LIMIT_MS; } public async getMarkets(params?: GetMarketsParams): Promise { @@ -463,7 +463,7 @@ export class PolymarketProvider implements PredictProvider { ): Promise { const basePreview = await previewOrder(params); - if (params.side === Side.BUY && params.signer) { + if (params.signer) { if (this.isRateLimited(params.signer.address)) { return { ...basePreview, @@ -603,13 +603,20 @@ export class PolymarketProvider implements PredictProvider { feeAuthorization, }); - if (!response) { + if (!success) { return { success, error, } as OrderResult; } + if (!response.success) { + return { + success: false, + error: response.errorMsg, + } as OrderResult; + } + if (side === Side.BUY) { this.#lastBuyOrderTimestampByAddress.set(signer.address, Date.now()); } else if (positionId) { diff --git a/app/components/UI/Predict/providers/polymarket/constants.ts b/app/components/UI/Predict/providers/polymarket/constants.ts index aca52d8b7c1..22ef176007e 100644 --- a/app/components/UI/Predict/providers/polymarket/constants.ts +++ b/app/components/UI/Predict/providers/polymarket/constants.ts @@ -13,7 +13,7 @@ export const FEE_COLLECTOR_ADDRESS = */ export const SLIPPAGE = 0.015; // 1.5% -export const BUY_ORDER_RATE_LIMIT_MS = 5000; +export const ORDER_RATE_LIMIT_MS = 5000; export const POLYGON_MAINNET_CHAIN_ID = 137; export const POLYGON_MAINNET_CAIP_CHAIN_ID = diff --git a/app/components/UI/Predict/providers/polymarket/types.ts b/app/components/UI/Predict/providers/polymarket/types.ts index cbafaed97e0..5244352dc9f 100644 --- a/app/components/UI/Predict/providers/polymarket/types.ts +++ b/app/components/UI/Predict/providers/polymarket/types.ts @@ -302,13 +302,13 @@ export interface L2HeaderArgs { // eslint-disable-next-line @typescript-eslint/consistent-type-definitions export type OrderResponse = { - errorMsg: string; - makingAmount: string; - orderID: string; - status: string; + errorMsg?: string; + makingAmount?: string; + orderID?: string; + status?: string; success: boolean; - takingAmount: string; - transactionsHashes: string[]; + takingAmount?: string; + transactionsHashes?: string[]; }; export interface TickSizeResponse { diff --git a/app/components/UI/Predict/providers/polymarket/utils.test.ts b/app/components/UI/Predict/providers/polymarket/utils.test.ts index 2454811f942..64bf92fb52d 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.test.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.test.ts @@ -712,11 +712,25 @@ describe('polymarket utils', () => { response: mockOrderResponse, }); expect(mockFetch).toHaveBeenCalledWith( - 'https://clob.polymarket.com/order', + 'https://predict.api.cx.metamask.io/order', { method: 'POST', - headers: mockHeaders, - body: JSON.stringify(mockClobOrder), + headers: { + POLY_ADDRESS: mockAddress, + POLY_SIGNATURE: 'test-signature_', + POLY_TIMESTAMP: '1704067200', + POLY_API_KEY: 'test-api-key', + POLY_PASSPHRASE: 'test-passphrase', + 'POLY-ADDRESS': mockAddress, + 'POLY-SIGNATURE': 'test-signature_', + 'POLY-TIMESTAMP': '1704067200', + 'POLY-API-KEY': 'test-api-key', + 'POLY-PASSPHRASE': 'test-passphrase', + }, + body: JSON.stringify({ + ...mockClobOrder, + feeAuthorization: undefined, + }), }, ); }); @@ -781,12 +795,24 @@ describe('polymarket utils', () => { }); expect(mockFetch).toHaveBeenCalledWith( - 'https://clob.polymarket.com/order', + 'https://predict.api.cx.metamask.io/order', { method: 'POST', - headers: mockHeaders, + headers: { + POLY_ADDRESS: mockAddress, + POLY_SIGNATURE: 'test-signature_', + POLY_TIMESTAMP: '1704067200', + POLY_API_KEY: 'test-api-key', + POLY_PASSPHRASE: 'test-passphrase', + 'POLY-ADDRESS': mockAddress, + 'POLY-SIGNATURE': 'test-signature_', + 'POLY-TIMESTAMP': '1704067200', + 'POLY-API-KEY': 'test-api-key', + 'POLY-PASSPHRASE': 'test-passphrase', + }, body: JSON.stringify({ ...mockClobOrder, + feeAuthorization: undefined, }), }, ); @@ -820,25 +846,37 @@ describe('polymarket utils', () => { expect(parsedBody.feeAuthorization).toEqual(feeAuthorization); }); - it('uses CLOB endpoint when feeAuthorization is not provided for BUY orders', async () => { + it('uses CLOB_RELAYER endpoint when feeAuthorization is not provided for BUY orders', async () => { await submitClobOrder({ headers: mockHeaders, clobOrder: mockClobOrder, }); expect(mockFetch).toHaveBeenCalledWith( - 'https://clob.polymarket.com/order', + 'https://predict.api.cx.metamask.io/order', { method: 'POST', - headers: mockHeaders, + headers: { + POLY_ADDRESS: mockAddress, + POLY_SIGNATURE: 'test-signature_', + POLY_TIMESTAMP: '1704067200', + POLY_API_KEY: 'test-api-key', + POLY_PASSPHRASE: 'test-passphrase', + 'POLY-ADDRESS': mockAddress, + 'POLY-SIGNATURE': 'test-signature_', + 'POLY-TIMESTAMP': '1704067200', + 'POLY-API-KEY': 'test-api-key', + 'POLY-PASSPHRASE': 'test-passphrase', + }, body: JSON.stringify({ ...mockClobOrder, + feeAuthorization: undefined, }), }, ); }); - it('uses CLOB endpoint for SELL orders even with feeAuthorization', async () => { + it('uses CLOB_RELAYER endpoint for SELL orders with feeAuthorization', async () => { const sellClobOrder: ClobOrderObject = { ...mockClobOrder, order: { @@ -867,12 +905,24 @@ describe('polymarket utils', () => { }); expect(mockFetch).toHaveBeenCalledWith( - 'https://clob.polymarket.com/order', + 'https://predict.api.cx.metamask.io/order', { method: 'POST', - headers: mockHeaders, + headers: { + POLY_ADDRESS: mockAddress, + POLY_SIGNATURE: 'test-signature_', + POLY_TIMESTAMP: '1704067200', + POLY_API_KEY: 'test-api-key', + POLY_PASSPHRASE: 'test-passphrase', + 'POLY-ADDRESS': mockAddress, + 'POLY-SIGNATURE': 'test-signature_', + 'POLY-TIMESTAMP': '1704067200', + 'POLY-API-KEY': 'test-api-key', + 'POLY-PASSPHRASE': 'test-passphrase', + }, body: JSON.stringify({ ...sellClobOrder, + feeAuthorization, }), }, ); @@ -1894,7 +1944,6 @@ describe('polymarket utils', () => { expect(result).toEqual({ success: false, error: 'You are unable to access this provider.', - errorCode: 403, }); }); diff --git a/app/components/UI/Predict/providers/polymarket/utils.ts b/app/components/UI/Predict/providers/polymarket/utils.ts index e75c2ef8976..f74aac379e7 100644 --- a/app/components/UI/Predict/providers/polymarket/utils.ts +++ b/app/components/UI/Predict/providers/polymarket/utils.ts @@ -15,6 +15,7 @@ import { type PredictMarket, type PredictPosition, PredictActivity, + Result, } from '../../types'; import { getRecurrence } from '../../utils/format'; import type { @@ -324,29 +325,24 @@ export const submitClobOrder = async ({ headers: ClobHeaders; clobOrder: ClobOrderObject; feeAuthorization?: SafeFeeAuthorization; -}) => { - const { CLOB_ENDPOINT, CLOB_RELAYER } = getPolymarketEndpoints(); - let url = `${CLOB_ENDPOINT}/order`; - let body: ClobOrderObject & { feeAuthorization?: SafeFeeAuthorization } = { +}): Promise> => { + const { CLOB_RELAYER } = getPolymarketEndpoints(); + const url = `${CLOB_RELAYER}/order`; + const body: ClobOrderObject & { feeAuthorization?: SafeFeeAuthorization } = { ...clobOrder, + feeAuthorization, }; - // If a feeAuthorization is provided, we need to use our clob - // relayer to submit the order and collect the fee. - if (clobOrder.order.side === Side.BUY && feeAuthorization) { - url = `${CLOB_RELAYER}/order`; - body = { ...body, feeAuthorization }; - // For our relayer, we need to replace the underscores with dashes - // since underscores are not standardly allowed in headers - headers = { - ...headers, - ...Object.entries(headers) - .map(([key, value]) => ({ - [key.replace(/_/g, '-')]: value, - })) - .reduce((acc, curr) => ({ ...acc, ...curr }), {}), - }; - } + // For our relayer, we need to replace the underscores with dashes + // since underscores are not standardly allowed in headers + headers = { + ...headers, + ...Object.entries(headers) + .map(([key, value]) => ({ + [key.replace(/_/g, '-')]: value, + })) + .reduce((acc, curr) => ({ ...acc, ...curr }), {}), + }; const response = await fetch(url, { method: 'POST', @@ -359,7 +355,6 @@ export const submitClobOrder = async ({ return { success: false, error: 'You are unable to access this provider.', - errorCode: response.status, }; } const responseData = await response.json(); diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx index ea8ca04ed17..3b21eee1d4e 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.test.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { screen, fireEvent, waitFor, act } from '@testing-library/react-native'; +import { InteractionManager } from 'react-native'; import { NavigationProp, ParamListBase, @@ -12,6 +13,24 @@ import Routes from '../../../../../constants/navigation/Routes'; import { PredictEventValues } from '../../constants/eventNames'; import renderWithProvider from '../../../../../util/test/renderWithProvider'; +const runAfterInteractionsCallbacks: (() => void)[] = []; +const mockRunAfterInteractions = jest.spyOn( + InteractionManager, + 'runAfterInteractions', +); +const runAfterInteractionsMockImpl: typeof InteractionManager.runAfterInteractions = + (task) => { + if (typeof task === 'function') { + runAfterInteractionsCallbacks.push(task as () => void); + } + + return { + then: jest.fn(), + done: jest.fn(), + cancel: jest.fn(), + } as ReturnType; + }; + jest.mock('../../../../../core/Engine', () => ({ context: { PredictController: { @@ -435,6 +454,8 @@ function setupPredictMarketDetailsTest( } = {}, ) { jest.clearAllMocks(); + runAfterInteractionsCallbacks.length = 0; + mockRunAfterInteractions.mockImplementation(runAfterInteractionsMockImpl); const mockNavigate = jest.fn(); const mockSetOptions = jest.fn(); @@ -527,6 +548,15 @@ function setupPredictMarketDetailsTest( } describe('PredictMarketDetails', () => { + afterEach(() => { + jest.clearAllMocks(); + mockRunAfterInteractions.mockReset(); + }); + + afterAll(() => { + mockRunAfterInteractions.mockRestore(); + }); + describe('Component Rendering', () => { it('renders the main screen container', () => { setupPredictMarketDetailsTest(); @@ -611,6 +641,37 @@ describe('PredictMarketDetails', () => { ).toBeOnTheScreen(); expect(screen.getByText('Polymarket')).toBeOnTheScreen(); }); + + it('navigates to polymarket resolution details when pressed', () => { + const { mockNavigate } = setupPredictMarketDetailsTest(); + + const aboutTab = screen.getByTestId( + 'predict-market-details-tab-bar-tab-1', + ); + fireEvent.press(aboutTab); + + const resolutionText = screen.getByText('Polymarket'); + + act(() => { + fireEvent.press(resolutionText); + }); + + expect(mockRunAfterInteractions).toHaveBeenCalledTimes(1); + const callback = runAfterInteractionsCallbacks[0]; + expect(callback).toBeDefined(); + + act(() => { + callback?.(); + }); + + expect(mockNavigate).toHaveBeenCalledWith('Webview', { + screen: 'SimpleWebview', + params: { + url: 'https://docs.polymarket.com/polymarket-learn/markets/how-are-markets-resolved', + title: 'predict.market_details.resolution_details', + }, + }); + }); }); describe('Chart Rendering', () => { diff --git a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx index 8eeb48d971b..793c86af985 100644 --- a/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx +++ b/app/components/UI/Predict/views/PredictMarketDetails/PredictMarketDetails.tsx @@ -7,7 +7,7 @@ import { import React, { useMemo, useState, useEffect, useCallback } from 'react'; import { Image, - Linking, + InteractionManager, Pressable, RefreshControl, ScrollView, @@ -360,6 +360,18 @@ const PredictMarketDetails: React.FC = () => { setIsRefreshing(false); }, [loadPositions, refetchMarket, refetchPriceHistory]); + const handlePolymarketResolution = useCallback(() => { + InteractionManager.runAfterInteractions(() => { + navigation.navigate('Webview', { + screen: 'SimpleWebview', + params: { + url: 'https://docs.polymarket.com/polymarket-learn/markets/how-are-markets-resolved', + title: strings('predict.market_details.resolution_details'), + }, + }); + }); + }, [navigation]); + type TabKey = 'positions' | 'outcomes' | 'about'; const trackMarketDetailsOpened = useCallback( @@ -692,13 +704,7 @@ const PredictMarketDetails: React.FC = () => { alignItems={BoxAlignItems.Center} twClassName="gap-2" > - { - Linking.openURL( - 'https://docs.polymarket.com/polymarket-learn/markets/how-are-markets-resolved', - ); - }} - > + { paddingBottom: 0, gap: 16, }, - positionContainer: { - flexDirection: 'row', - alignItems: 'center', - gap: 16, - width: '100%', - }, - positionDetails: { - flexDirection: 'column', - gap: 4, - flex: 1, - minWidth: 0, - }, detailsLine: { flexDirection: 'row', gap: 16, @@ -62,11 +50,6 @@ const styleSheet = (params: { theme: Theme }) => { flex: 1, minWidth: 0, }, - detailsResolves: { - flex: 1, - minWidth: 0, - color: theme.colors.text.alternative, - }, detailsRight: { flexShrink: 0, color: theme.colors.text.alternative, diff --git a/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.test.tsx b/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.test.tsx index 4fd09af6280..2ca45e6be55 100644 --- a/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.test.tsx +++ b/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.test.tsx @@ -440,16 +440,16 @@ describe('PredictSellPreview', () => { describe('rendering', () => { it('renders cash out screen with position details', () => { - const { getByText, queryByText } = renderWithProvider( + const { getAllByText, getByText, queryByText } = renderWithProvider( , { state: initialState, }, ); - expect(getByText('Cash Out')).toBeOnTheScreen(); + expect(getAllByText('Cash out').length).toBeGreaterThan(0); expect(getByText('Will Bitcoin reach $150,000?')).toBeOnTheScreen(); - expect(getByText('$50.00 on Yes')).toBeOnTheScreen(); + expect(getByText('$50.00 on Yes at 50¢')).toBeOnTheScreen(); expect( queryByText('Funds will be added to your available balance'), @@ -512,6 +512,29 @@ describe('PredictSellPreview', () => { expect(mockFormatPercentage).toHaveBeenCalledWith(-20); }); + it('uses position price when preview sharePrice is undefined', () => { + mockPreview = { + marketId: 'market-1', + outcomeId: 'outcome-456', + outcomeTokenId: 'outcome-token-789', + timestamp: Date.now(), + side: 'SELL', + sharePrice: undefined as unknown as number, + maxAmountSpent: 100, + minAmountReceived: 60, + slippage: 0.005, + tickSize: 0.01, + minOrderSize: 1, + negRisk: false, + }; + + const { getByText } = renderWithProvider(, { + state: initialState, + }); + + expect(getByText('At price: 50¢ per share')).toBeOnTheScreen(); + }); + it('renders position icon with correct source', () => { renderWithProvider(, { state: initialState, diff --git a/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx b/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx index aa693492a80..fea6185aea0 100644 --- a/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx +++ b/app/components/UI/Predict/views/PredictSellPreview/PredictSellPreview.tsx @@ -126,7 +126,8 @@ const PredictSellPreview = () => { }, [dispatch, result]); const currentValue = preview?.minAmountReceived ?? 0; - const { cashPnl, percentPnl } = position; + const currentPrice = preview?.sharePrice ?? position?.price; + const { cashPnl, percentPnl, avgPrice } = position; const signal = useMemo(() => (cashPnl >= 0 ? '+' : '-'), [cashPnl]); @@ -198,7 +199,9 @@ const PredictSellPreview = () => { return ( goBack()}> - Cash Out + + {strings('predict.cash_out')} + { {formatPrice(currentValue, { maximumDecimals: 2 })} + + {strings('predict.at_price_per_share', { + price: (currentPrice * 100).toFixed(0), + })} + 0 ? TextColor.Success : TextColor.Error} @@ -228,30 +239,26 @@ const PredictSellPreview = () => { {strings('predict.order.order_failed_generic')} )} - - + + - - - - {outcomeTitle} - + + + {outcomeTitle} - {formatPrice(initialValue, { maximumDecimals: 2 })} on{' '} - {outcomeSideText} + {strings('predict.cashout_info', { + amount: formatPrice(initialValue, { maximumDecimals: 2 }), + outcome: outcomeSideText, + initialPrice: (avgPrice * 100).toFixed(0), + })} - - + + {renderCashOutButton()} diff --git a/locales/languages/en.json b/locales/languages/en.json index d4f5aa56eda..af5d3310a46 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -1771,6 +1771,8 @@ "sell_position": "Sell Position", "cash_out": "Cash out", "cash_out_info": "Funds will be added to your available balance", + "at_price_per_share": "At price: {{price}}¢ per share", + "cashout_info": "{{amount}} on {{outcome}} at {{initialPrice}}¢", "buy_yes": "Yes", "buy_no": "No", "outcomes": "outcomes",