diff --git a/.storybook/storybook.requires.js b/.storybook/storybook.requires.js index d92a50aee5b..f625af5a214 100644 --- a/.storybook/storybook.requires.js +++ b/.storybook/storybook.requires.js @@ -131,8 +131,8 @@ const getStories = () => { "./app/components/UI/CollectiblesEmptyState/CollectiblesEmptyState.stories.tsx": require("../app/components/UI/CollectiblesEmptyState/CollectiblesEmptyState.stories.tsx"), "./app/components/UI/DefiEmptyState/DefiEmptyState.stories.tsx": require("../app/components/UI/DefiEmptyState/DefiEmptyState.stories.tsx"), "./app/components/UI/Perps/Views/PerpsEmptyState/PerpsEmptyState.stories.tsx": require("../app/components/UI/Perps/Views/PerpsEmptyState/PerpsEmptyState.stories.tsx"), + "./app/components/UI/Ramp/Aggregator/components/ShapesBackgroundAnimation/index.stories.tsx": require("../app/components/UI/Ramp/Aggregator/components/ShapesBackgroundAnimation/index.stories.tsx"), "./app/components/UI/Rewards/components/RewardPointsAnimation/RewardPointsAnimation.stories.tsx": require("../app/components/UI/Rewards/components/RewardPointsAnimation/RewardPointsAnimation.stories.tsx"), - "./app/components/UI/Swaps/components/LoadingAnimation/ShapesBackgroundAnimation.stories.tsx": require("../app/components/UI/Swaps/components/LoadingAnimation/ShapesBackgroundAnimation.stories.tsx"), "./app/components/Views/AssetDetails/AssetDetailsActions/AssetDetailsActions.stories.tsx": require("../app/components/Views/AssetDetails/AssetDetailsActions/AssetDetailsActions.stories.tsx"), "./app/components/Views/confirmations/components/deposit-keyboard/deposit-keyboard.stories.tsx": require("../app/components/Views/confirmations/components/deposit-keyboard/deposit-keyboard.stories.tsx"), "./app/components/Views/confirmations/components/edit-amount-keyboard/edit-amount-keyboard.stories.tsx": require("../app/components/Views/confirmations/components/edit-amount-keyboard/edit-amount-keyboard.stories.tsx"), diff --git a/app/components/Nav/Main/MainNavigator.js b/app/components/Nav/Main/MainNavigator.js index 47ee95f3fac..9c56e4ff831 100644 --- a/app/components/Nav/Main/MainNavigator.js +++ b/app/components/Nav/Main/MainNavigator.js @@ -52,6 +52,7 @@ import ContactForm from '../../Views/Settings/Contacts/ContactForm'; import ActivityView from '../../Views/ActivityView'; import RewardsNavigator from '../../UI/Rewards/RewardsNavigator'; import TrendingView from '../../Views/TrendingView/TrendingView'; +import SitesListView from '../../Views/TrendingView/SitesListView'; import SwapsAmountView from '../../UI/Swaps'; import SwapsQuotesView from '../../UI/Swaps/QuotesView'; import CollectiblesDetails from '../../UI/CollectibleModal'; @@ -291,6 +292,26 @@ const TrendingHome = () => ( component={TrendingView} options={{ headerShown: false }} /> + ({ + cardStyle: { + transform: [ + { + translateX: current.progress.interpolate({ + inputRange: [0, 1], + outputRange: [layouts.screen.width, 0], + }), + }, + ], + }, + }), + }} + /> ); diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx index 60de7c24aef..3d28dcf9de5 100644 --- a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx +++ b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.test.tsx @@ -75,16 +75,20 @@ jest.mock('../../hooks/useBridgeQuoteData', () => ({ priceImpact: '-0.06%', slippage: '0.5%', }, + shouldShowPriceImpactWarning: false, })), })); -// Mock Engine for rewards functionality -jest.mock('../../../../../core/Engine', () => ({ - controllerMessenger: { - call: jest.fn(), - subscribe: jest.fn(), - unsubscribe: jest.fn(), - }, +// Mock useRewards hook +jest.mock('../../hooks/useRewards', () => ({ + useRewards: jest.fn().mockImplementation(() => ({ + estimatedPoints: null, + isLoading: false, + shouldShowRewardsRow: false, + hasError: false, + accountOptedIn: null, + rewardsAccountScope: null, + })), })); // Mock formatChainIdToCaip for AddRewardsAccount component @@ -109,6 +113,24 @@ jest.mock('../../../../UI/Rewards/hooks/useLinkAccountAddress', () => ({ })), })); +// Mock AddRewardsAccount component +jest.mock( + '../../../../UI/Rewards/components/AddRewardsAccount/AddRewardsAccount', + () => { + const React = jest.requireActual('react'); + const { View, Text } = jest.requireActual('react-native'); + return { + __esModule: true, + default: ({ testID }: { testID?: string }) => + React.createElement( + View, + { testID }, + React.createElement(Text, null, 'Add Rewards Account'), + ), + }; + }, +); + // Mock the bridge selectors jest.mock('../../../../../core/redux/slices/bridge', () => ({ ...jest.requireActual('../../../../../core/redux/slices/bridge'), @@ -505,41 +527,34 @@ describe('QuoteDetailsCard', () => { }); describe('rewards functionality', () => { - const mockEngine = jest.requireMock('../../../../../core/Engine'); + const { useRewards } = jest.requireMock('../../hooks/useRewards'); beforeEach(() => { - // Reset Engine mocks jest.clearAllMocks(); // Default to rewards disabled - mockEngine.controllerMessenger.call.mockImplementation(() => - Promise.resolve(false), - ); + (useRewards as jest.Mock).mockReturnValue({ + estimatedPoints: null, + isLoading: false, + shouldShowRewardsRow: false, + hasError: false, + accountOptedIn: null, + rewardsAccountScope: null, + }); }); it('displays rewards row when rewards are enabled and user has opted in', async () => { // Given rewards feature is enabled and user has opted in - mockEngine.controllerMessenger.call.mockImplementation( - (method: string) => { - // Note: In the actual implementation, these are commented out as TODO - // But we'll mock them as if they were working - if (method === 'RewardsController:isRewardsFeatureEnabled') { - return Promise.resolve(true); - } - if (method === 'RewardsController:getFirstSubscriptionId') { - return Promise.resolve('subscription-id-1'); - } - if (method === 'RewardsController:getHasAccountOptedIn') { - return Promise.resolve(true); - } - if (method === 'RewardsController:estimatePoints') { - return Promise.resolve({ pointsEstimate: 100 }); - } - return Promise.resolve(null); - }, - ); + (useRewards as jest.Mock).mockReturnValue({ + estimatedPoints: 100, + isLoading: false, + shouldShowRewardsRow: true, + hasError: false, + accountOptedIn: true, + rewardsAccountScope: null, + }); // When rendering the component - const { getByText } = renderScreen( + const { getByText, getByTestId } = renderScreen( QuoteDetailsCard, { name: Routes.BRIDGE.ROOT }, { state: testState }, @@ -548,29 +563,20 @@ describe('QuoteDetailsCard', () => { // Then the rewards row should be displayed await waitFor(() => { expect(getByText(strings('bridge.points'))).toBeOnTheScreen(); + expect(getByTestId('mock-rive-animation')).toBeOnTheScreen(); }); }); it('displays rewards row without points when estimation fails', async () => { // Given rewards estimation fails but feature is enabled and user has opted in - mockEngine.controllerMessenger.call.mockImplementation( - (method: string) => { - if (method === 'RewardsController:isRewardsFeatureEnabled') { - return Promise.resolve(true); - } - if (method === 'RewardsController:getFirstSubscriptionId') { - return Promise.resolve('subscription-id-1'); - } - if (method === 'RewardsController:getHasAccountOptedIn') { - return Promise.resolve(true); - } - if (method === 'RewardsController:estimatePoints') { - // Throw error to simulate failure - throw new Error('Estimation failed'); - } - return Promise.resolve(null); - }, - ); + (useRewards as jest.Mock).mockReturnValue({ + estimatedPoints: null, + isLoading: false, + shouldShowRewardsRow: true, + hasError: true, + accountOptedIn: true, + rewardsAccountScope: null, + }); // When rendering the component const { getByText, getByTestId } = renderScreen( @@ -591,14 +597,14 @@ describe('QuoteDetailsCard', () => { it('does not display rewards row when rewards feature is disabled', async () => { // Given rewards feature is disabled - mockEngine.controllerMessenger.call.mockImplementation( - (method: string) => { - if (method === 'RewardsController:isRewardsFeatureEnabled') { - return Promise.resolve(false); - } - return Promise.resolve(null); - }, - ); + (useRewards as jest.Mock).mockReturnValue({ + estimatedPoints: null, + isLoading: false, + shouldShowRewardsRow: false, + hasError: false, + accountOptedIn: null, + rewardsAccountScope: null, + }); // When rendering the component const { queryByText } = renderScreen( @@ -613,25 +619,27 @@ describe('QuoteDetailsCard', () => { }); }); - it('displays AddRewardsAccount when user has not opted in', async () => { - // Given rewards feature is enabled but user has not opted in - mockEngine.controllerMessenger.call.mockImplementation( - (method: string) => { - if (method === 'RewardsController:isRewardsFeatureEnabled') { - return Promise.resolve(true); - } - if (method === 'RewardsController:getFirstSubscriptionId') { - return Promise.resolve('subscription-id-1'); - } - if (method === 'RewardsController:getHasAccountOptedIn') { - return Promise.resolve(false); - } - if (method === 'RewardsController:isOptInSupported') { - return Promise.resolve(true); - } - return Promise.resolve(null); + it('displays AddRewardsAccount when user has not opted in but has rewards account scope', async () => { + // Given rewards feature is enabled but user has not opted in, but has account scope + const mockAccount = { + id: 'test-account-id', + address: '0x1234567890123456789012345678901234567890', + name: 'Test Account', + type: 'eip155:eoa', + scopes: ['eip155:1'], + metadata: { + lastSelected: 0, }, - ); + }; + + (useRewards as jest.Mock).mockReturnValue({ + estimatedPoints: null, + isLoading: false, + shouldShowRewardsRow: true, + hasError: false, + accountOptedIn: false, + rewardsAccountScope: mockAccount, + }); // When rendering the component const { getByText, getByTestId, queryByTestId } = renderScreen( @@ -652,25 +660,16 @@ describe('QuoteDetailsCard', () => { }); }); - it('displays rewards image when rewards row is shown', async () => { + it('displays rewards animation when rewards row is shown', async () => { // Given rewards should be shown - mockEngine.controllerMessenger.call.mockImplementation( - (method: string) => { - if (method === 'RewardsController:isRewardsFeatureEnabled') { - return Promise.resolve(true); - } - if (method === 'RewardsController:getFirstSubscriptionId') { - return Promise.resolve('subscription-id-1'); - } - if (method === 'RewardsController:getHasAccountOptedIn') { - return Promise.resolve(true); - } - if (method === 'RewardsController:estimatePoints') { - return Promise.resolve({ pointsEstimate: 150 }); - } - return Promise.resolve(null); - }, - ); + (useRewards as jest.Mock).mockReturnValue({ + estimatedPoints: 150, + isLoading: false, + shouldShowRewardsRow: true, + hasError: false, + accountOptedIn: true, + rewardsAccountScope: null, + }); // When rendering the component const { getByTestId } = renderScreen( @@ -679,34 +678,22 @@ describe('QuoteDetailsCard', () => { { state: testState }, ); - // Then the MetaMask rewards points image should be displayed + // Then the MetaMask rewards points animation should be displayed await waitFor(() => { expect(getByTestId('mock-rive-animation')).toBeOnTheScreen(); }); }); it('does not display points value when rewards are loading', async () => { - // Given rewards are being estimated (pending promise) - mockEngine.controllerMessenger.call.mockImplementation( - (method: string) => { - if (method === 'RewardsController:isRewardsFeatureEnabled') { - return Promise.resolve(true); - } - if (method === 'RewardsController:getFirstSubscriptionId') { - return Promise.resolve('subscription-id-1'); - } - if (method === 'RewardsController:getHasAccountOptedIn') { - return Promise.resolve(true); - } - if (method === 'RewardsController:estimatePoints') { - // Return a pending promise to simulate loading - return new Promise(() => { - // Never resolves to simulate loading state - }); - } - return Promise.resolve(null); - }, - ); + // Given rewards are being estimated (loading state) + (useRewards as jest.Mock).mockReturnValue({ + estimatedPoints: null, + isLoading: true, + shouldShowRewardsRow: true, + hasError: false, + accountOptedIn: true, + rewardsAccountScope: null, + }); // When rendering the component const { getByText, getByTestId } = renderScreen( @@ -724,25 +711,16 @@ describe('QuoteDetailsCard', () => { expect(getByText('0')).toBeOnTheScreen(); }); - it('displays rewards row but no points when engine returns zero', async () => { + it('displays rewards row but no points when estimatedPoints is zero', async () => { // Given rewards estimation returns zero with feature enabled and user opted in - mockEngine.controllerMessenger.call.mockImplementation( - (method: string) => { - if (method === 'RewardsController:isRewardsFeatureEnabled') { - return Promise.resolve(true); - } - if (method === 'RewardsController:getFirstSubscriptionId') { - return Promise.resolve('subscription-id-1'); - } - if (method === 'RewardsController:getHasAccountOptedIn') { - return Promise.resolve(true); - } - if (method === 'RewardsController:estimatePoints') { - return Promise.resolve({ pointsEstimate: 0 }); - } - return Promise.resolve(null); - }, - ); + (useRewards as jest.Mock).mockReturnValue({ + estimatedPoints: 0, + isLoading: false, + shouldShowRewardsRow: true, + hasError: false, + accountOptedIn: true, + rewardsAccountScope: null, + }); // When rendering the component const { getByText, getByTestId } = renderScreen( @@ -756,30 +734,18 @@ describe('QuoteDetailsCard', () => { expect(getByText(strings('bridge.points'))).toBeOnTheScreen(); expect(getByTestId('mock-rive-animation')).toBeOnTheScreen(); }); - - // When points are 0, we may show "0" or no value at all - // This behavior will depend on how useRewards handles the response }); it('displays rewards tooltip when rewards row is shown', async () => { // Given rewards should be shown - mockEngine.controllerMessenger.call.mockImplementation( - (method: string) => { - if (method === 'RewardsController:isRewardsFeatureEnabled') { - return Promise.resolve(true); - } - if (method === 'RewardsController:getFirstSubscriptionId') { - return Promise.resolve('subscription-id-1'); - } - if (method === 'RewardsController:getHasAccountOptedIn') { - return Promise.resolve(true); - } - if (method === 'RewardsController:estimatePoints') { - return Promise.resolve({ pointsEstimate: 100 }); - } - return Promise.resolve(null); - }, - ); + (useRewards as jest.Mock).mockReturnValue({ + estimatedPoints: 100, + isLoading: false, + shouldShowRewardsRow: true, + hasError: false, + accountOptedIn: true, + rewardsAccountScope: null, + }); // When rendering the component const { getByLabelText } = renderScreen( @@ -797,23 +763,14 @@ describe('QuoteDetailsCard', () => { it('displays rewards row when all conditions are met', async () => { // Given rewards feature is enabled, user has opted in, and estimation succeeds - mockEngine.controllerMessenger.call.mockImplementation( - (method: string) => { - if (method === 'RewardsController:isRewardsFeatureEnabled') { - return Promise.resolve(true); - } - if (method === 'RewardsController:getFirstSubscriptionId') { - return Promise.resolve('subscription-id-1'); - } - if (method === 'RewardsController:getHasAccountOptedIn') { - return Promise.resolve(true); - } - if (method === 'RewardsController:estimatePoints') { - return Promise.resolve({ pointsEstimate: 500 }); - } - return Promise.resolve(null); - }, - ); + (useRewards as jest.Mock).mockReturnValue({ + estimatedPoints: 500, + isLoading: false, + shouldShowRewardsRow: true, + hasError: false, + accountOptedIn: true, + rewardsAccountScope: null, + }); // When rendering the component const { getByText, getByTestId } = renderScreen( @@ -831,23 +788,14 @@ describe('QuoteDetailsCard', () => { it('handles rewards estimation with null estimatedPoints', async () => { // Given rewards with null estimated points - mockEngine.controllerMessenger.call.mockImplementation( - (method: string) => { - if (method === 'RewardsController:isRewardsFeatureEnabled') { - return Promise.resolve(true); - } - if (method === 'RewardsController:getFirstSubscriptionId') { - return Promise.resolve('subscription-id-1'); - } - if (method === 'RewardsController:getHasAccountOptedIn') { - return Promise.resolve(true); - } - if (method === 'RewardsController:estimatePoints') { - return Promise.resolve({ pointsEstimate: null }); - } - return Promise.resolve(null); - }, - ); + (useRewards as jest.Mock).mockReturnValue({ + estimatedPoints: null, + isLoading: false, + shouldShowRewardsRow: true, + hasError: false, + accountOptedIn: true, + rewardsAccountScope: null, + }); // When rendering the component const { getByText, getByTestId } = renderScreen( @@ -879,29 +827,18 @@ describe('QuoteDetailsCard', () => { priceImpact: '-0.06%', slippage: '0.5%', }, + shouldShowPriceImpactWarning: false, })); - // Mock Engine to simulate rewards loading - mockEngine.controllerMessenger.call.mockImplementation( - (method: string) => { - if (method === 'RewardsController:isRewardsFeatureEnabled') { - return Promise.resolve(true); - } - if (method === 'RewardsController:getFirstSubscriptionId') { - return Promise.resolve('subscription-id-1'); - } - if (method === 'RewardsController:getHasAccountOptedIn') { - return Promise.resolve(true); - } - if (method === 'RewardsController:estimatePoints') { - // Return a pending promise to simulate loading - return new Promise(() => { - // Never resolves to simulate loading state - }); - } - return Promise.resolve(null); - }, - ); + // Mock useRewards to simulate rewards loading + (useRewards as jest.Mock).mockReturnValue({ + estimatedPoints: null, + isLoading: true, + shouldShowRewardsRow: true, + hasError: false, + accountOptedIn: true, + rewardsAccountScope: null, + }); // When rendering the component const { getByText, getByTestId } = renderScreen( @@ -919,5 +856,32 @@ describe('QuoteDetailsCard', () => { // RewardPointsAnimation displays 0 while loading (estimatedPoints is null) expect(getByText('0')).toBeOnTheScreen(); }); + + it('displays error tooltip when rewards has error', async () => { + // Given rewards has an error + (useRewards as jest.Mock).mockReturnValue({ + estimatedPoints: null, + isLoading: false, + shouldShowRewardsRow: true, + hasError: true, + accountOptedIn: true, + rewardsAccountScope: null, + }); + + // When rendering the component + const { getByLabelText } = renderScreen( + QuoteDetailsCard, + { name: Routes.BRIDGE.ROOT }, + { state: testState }, + ); + + // Then the error tooltip should be available + await waitFor(() => { + const errorTooltip = getByLabelText( + new RegExp(`${strings('bridge.points_error')} tooltip`, 'i'), + ); + expect(errorTooltip).toBeOnTheScreen(); + }); + }); }); }); diff --git a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx index 44228d03afb..fe6694df100 100644 --- a/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx +++ b/app/components/UI/Bridge/components/QuoteDetailsCard/QuoteDetailsCard.tsx @@ -71,6 +71,7 @@ const QuoteDetailsCard: React.FC = () => { shouldShowRewardsRow, hasError: hasRewardsError, accountOptedIn, + rewardsAccountScope, } = useRewards({ activeQuote, isQuoteLoading, @@ -323,8 +324,13 @@ const QuoteDetailsCard: React.FC = () => { : RewardAnimationState.Idle } /> + ) : rewardsAccountScope ? ( + ) : ( - + <> )} ), diff --git a/app/components/UI/Bridge/hooks/useRewards/useRewards.test.ts b/app/components/UI/Bridge/hooks/useRewards/useRewards.test.ts index 35ff166f366..7a9b0536e75 100644 --- a/app/components/UI/Bridge/hooks/useRewards/useRewards.test.ts +++ b/app/components/UI/Bridge/hooks/useRewards/useRewards.test.ts @@ -171,6 +171,8 @@ const mockActiveQuote = { describe('useRewards', () => { const mockCall = Engine.controllerMessenger.call as jest.Mock; + const mockSubscribe = Engine.controllerMessenger.subscribe as jest.Mock; + const mockUnsubscribe = Engine.controllerMessenger.unsubscribe as jest.Mock; const defaultSourceToken = { address: '0x0000000000000000000000000000000000000000' as Hex, @@ -190,6 +192,9 @@ describe('useRewards', () => { beforeEach(() => { jest.clearAllMocks(); + // Reset subscription handler storage + mockSubscribe.mockClear(); + mockUnsubscribe.mockClear(); }); describe('when rewards feature is disabled', () => { @@ -225,6 +230,7 @@ describe('useRewards', () => { estimatedPoints: null, hasError: false, accountOptedIn: null, + rewardsAccountScope: expect.any(Object), }); }); @@ -240,7 +246,7 @@ describe('useRewards', () => { if (method === 'RewardsController:isRewardsFeatureEnabled') { return Promise.resolve(true); } - if (method === 'RewardsController:getFirstSubscriptionId') { + if (method === 'RewardsController:getCandidateSubscriptionId') { return Promise.resolve('subscription-id-1'); } if (method === 'RewardsController:getHasAccountOptedIn') { @@ -276,11 +282,12 @@ describe('useRewards', () => { estimatedPoints: null, hasError: false, accountOptedIn: false, + rewardsAccountScope: expect.any(Object), }); }); expect(mockCall).toHaveBeenCalledWith( - 'RewardsController:getFirstSubscriptionId', + 'RewardsController:getCandidateSubscriptionId', ); expect(mockCall).toHaveBeenCalledWith( 'RewardsController:getHasAccountOptedIn', @@ -297,7 +304,7 @@ describe('useRewards', () => { if (method === 'RewardsController:isRewardsFeatureEnabled') { return Promise.resolve(true); } - if (method === 'RewardsController:getFirstSubscriptionId') { + if (method === 'RewardsController:getCandidateSubscriptionId') { return Promise.resolve('subscription-id-1'); } if (method === 'RewardsController:getHasAccountOptedIn') { @@ -333,11 +340,12 @@ describe('useRewards', () => { estimatedPoints: null, hasError: false, accountOptedIn: false, + rewardsAccountScope: expect.any(Object), }); }); expect(mockCall).toHaveBeenCalledWith( - 'RewardsController:getFirstSubscriptionId', + 'RewardsController:getCandidateSubscriptionId', ); expect(mockCall).toHaveBeenCalledWith( 'RewardsController:getHasAccountOptedIn', @@ -356,7 +364,7 @@ describe('useRewards', () => { if (method === 'RewardsController:isRewardsFeatureEnabled') { return Promise.resolve(true); } - if (method === 'RewardsController:getFirstSubscriptionId') { + if (method === 'RewardsController:getCandidateSubscriptionId') { return Promise.resolve('subscription-id-1'); } if (method === 'RewardsController:getHasAccountOptedIn') { @@ -392,6 +400,7 @@ describe('useRewards', () => { estimatedPoints: 100, hasError: false, accountOptedIn: true, + rewardsAccountScope: expect.any(Object), }); }); @@ -426,7 +435,7 @@ describe('useRewards', () => { if (method === 'RewardsController:isRewardsFeatureEnabled') { return Promise.resolve(true); } - if (method === 'RewardsController:getFirstSubscriptionId') { + if (method === 'RewardsController:getCandidateSubscriptionId') { return Promise.resolve('subscription-id-1'); } if (method === 'RewardsController:getHasAccountOptedIn') { @@ -499,6 +508,7 @@ describe('useRewards', () => { estimatedPoints: null, hasError: false, accountOptedIn: null, + rewardsAccountScope: expect.any(Object), }); // Should not call Engine methods @@ -529,6 +539,7 @@ describe('useRewards', () => { estimatedPoints: null, hasError: false, accountOptedIn: null, + rewardsAccountScope: null, }); }); @@ -556,6 +567,7 @@ describe('useRewards', () => { estimatedPoints: null, hasError: false, accountOptedIn: null, + rewardsAccountScope: expect.any(Object), }); }); @@ -583,6 +595,7 @@ describe('useRewards', () => { estimatedPoints: null, hasError: false, accountOptedIn: null, + rewardsAccountScope: expect.any(Object), }); }); }); @@ -593,7 +606,7 @@ describe('useRewards', () => { if (method === 'RewardsController:isRewardsFeatureEnabled') { return Promise.resolve(true); } - if (method === 'RewardsController:getFirstSubscriptionId') { + if (method === 'RewardsController:getCandidateSubscriptionId') { return Promise.resolve(null); } return Promise.resolve(null); @@ -623,6 +636,7 @@ describe('useRewards', () => { estimatedPoints: null, hasError: false, accountOptedIn: null, + rewardsAccountScope: expect.any(Object), }); }); @@ -630,7 +644,7 @@ describe('useRewards', () => { 'RewardsController:isRewardsFeatureEnabled', ); expect(mockCall).toHaveBeenCalledWith( - 'RewardsController:getFirstSubscriptionId', + 'RewardsController:getCandidateSubscriptionId', ); expect(mockCall).not.toHaveBeenCalledWith( 'RewardsController:getHasAccountOptedIn', @@ -645,7 +659,7 @@ describe('useRewards', () => { if (method === 'RewardsController:isRewardsFeatureEnabled') { return Promise.resolve(true); } - if (method === 'RewardsController:getFirstSubscriptionId') { + if (method === 'RewardsController:getCandidateSubscriptionId') { return Promise.resolve('subscription-id-1'); } if (method === 'RewardsController:getHasAccountOptedIn') { @@ -681,6 +695,7 @@ describe('useRewards', () => { estimatedPoints: null, hasError: true, accountOptedIn: true, + rewardsAccountScope: expect.any(Object), }); }); }); @@ -717,6 +732,7 @@ describe('useRewards', () => { estimatedPoints: null, hasError: true, accountOptedIn: null, + rewardsAccountScope: expect.any(Object), }); }); }); @@ -726,7 +742,7 @@ describe('useRewards', () => { if (method === 'RewardsController:isRewardsFeatureEnabled') { return Promise.resolve(true); } - if (method === 'RewardsController:getFirstSubscriptionId') { + if (method === 'RewardsController:getCandidateSubscriptionId') { return Promise.resolve('subscription-id-1'); } if (method === 'RewardsController:getHasAccountOptedIn') { @@ -759,6 +775,7 @@ describe('useRewards', () => { estimatedPoints: null, hasError: true, accountOptedIn: null, + rewardsAccountScope: expect.any(Object), }); }); }); @@ -769,7 +786,7 @@ describe('useRewards', () => { if (method === 'RewardsController:isRewardsFeatureEnabled') { return Promise.resolve(true); } - if (method === 'RewardsController:getFirstSubscriptionId') { + if (method === 'RewardsController:getCandidateSubscriptionId') { return Promise.resolve('subscription-id-1'); } if (method === 'RewardsController:getHasAccountOptedIn') { @@ -808,7 +825,7 @@ describe('useRewards', () => { if (method === 'RewardsController:isRewardsFeatureEnabled') { return Promise.resolve(true); } - if (method === 'RewardsController:getFirstSubscriptionId') { + if (method === 'RewardsController:getCandidateSubscriptionId') { return Promise.resolve('subscription-id-1'); } if (method === 'RewardsController:getHasAccountOptedIn') { @@ -840,6 +857,7 @@ describe('useRewards', () => { estimatedPoints: 100, hasError: false, accountOptedIn: true, + rewardsAccountScope: expect.any(Object), }); }); }); diff --git a/app/components/UI/Bridge/hooks/useRewards/useRewards.ts b/app/components/UI/Bridge/hooks/useRewards/useRewards.ts index c191efcf93f..d0300dff234 100644 --- a/app/components/UI/Bridge/hooks/useRewards/useRewards.ts +++ b/app/components/UI/Bridge/hooks/useRewards/useRewards.ts @@ -23,6 +23,7 @@ import { import { useBridgeQuoteData } from '../useBridgeQuoteData'; import Logger from '../../../../../util/Logger'; import usePrevious from '../../../../hooks/usePrevious'; +import { InternalAccount } from '@metamask/keyring-internal-api'; /** * @@ -69,6 +70,7 @@ interface UseRewardsResult { estimatedPoints: number | null; hasError: boolean; accountOptedIn: boolean | null; + rewardsAccountScope: InternalAccount | null; } /** @@ -152,11 +154,11 @@ export const useRewards = ({ } // Check if there's a subscription first - const firstSubscriptionId = await Engine.controllerMessenger.call( - 'RewardsController:getFirstSubscriptionId', + const candidateSubscriptionId = await Engine.controllerMessenger.call( + 'RewardsController:getCandidateSubscriptionId', ); - if (!firstSubscriptionId) { + if (!candidateSubscriptionId) { setEstimatedPoints(null); setShouldShowRewardsRow(false); setAccountOptedIn(null); @@ -312,10 +314,12 @@ export const useRewards = ({ }, [estimatePoints]); return { - shouldShowRewardsRow, + shouldShowRewardsRow: + shouldShowRewardsRow && (accountOptedIn || Boolean(selectedAccount)), isLoading: isLoading || isQuoteLoading, estimatedPoints, hasError, accountOptedIn, + rewardsAccountScope: selectedAccount ?? null, }; }; diff --git a/app/components/UI/CollectibleModal/CollectibleModal.test.tsx b/app/components/UI/CollectibleModal/CollectibleModal.test.tsx index 2257ce4d35a..37248845987 100644 --- a/app/components/UI/CollectibleModal/CollectibleModal.test.tsx +++ b/app/components/UI/CollectibleModal/CollectibleModal.test.tsx @@ -12,6 +12,7 @@ import { } from '../../../selectors/preferencesController'; import { useSelector } from 'react-redux'; import { selectSendRedesignFlags } from '../../../selectors/featureFlagController/confirmations'; +import { selectChainId } from '../../../selectors/networkController'; import { MOCK_ACCOUNTS_CONTROLLER_STATE } from '../../../util/test/accountsControllerTestUtils'; import { mockNetworkState } from '../../../util/test/network'; @@ -42,35 +43,58 @@ jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), useSelector: jest.fn(), })); + +const mockTrackEvent = jest.fn(); +const mockCreateEventBuilder = jest.fn(() => ({ + addProperties: jest.fn().mockReturnThis(), + build: jest.fn().mockReturnValue({ + properties: { chain_id: 1 }, + }), +})); + +jest.mock('../../hooks/useMetrics', () => ({ + useMetrics: () => ({ + trackEvent: mockTrackEvent, + createEventBuilder: mockCreateEventBuilder, + }), +})); + const mockedNavigate = jest.fn(); const mockedReplace = jest.fn(); +const mockUseRoute = jest.fn(); -jest.mock('@react-navigation/native', () => { - const actualNav = jest.requireActual('@react-navigation/native'); - - const navigation = { - params: { - contractName: 'Opensea', - collectible: { name: 'Leopard', tokenId: 6904, address: '0x123' }, - }, - }; - return { - ...actualNav, - useNavigation: () => ({ - navigate: mockedNavigate, - replace: mockedReplace, - }), - useRoute: jest.fn(() => ({ params: navigation.params })), - }; -}); +jest.mock('@react-navigation/native', () => ({ + ...jest.requireActual('@react-navigation/native'), + useNavigation: () => ({ + navigate: mockedNavigate, + replace: mockedReplace, + }), + useRoute: () => mockUseRoute(), +})); describe('CollectibleModal', () => { + beforeEach(() => { + jest.clearAllMocks(); + // Default route params without source + mockUseRoute.mockReturnValue({ + params: { + contractName: 'Opensea', + collectible: { name: 'Leopard', tokenId: 6904, address: '0x123' }, + }, + }); + }); + afterEach(() => { (useSelector as jest.Mock).mockClear(); }); - it('should render correctly', async () => { + it('renders correctly', async () => { (useSelector as jest.Mock).mockImplementation((selector) => { + if (selector === collectiblesSelector) return collectibles; + if (selector === selectIsIpfsGatewayEnabled) return false; + if (selector === selectDisplayNftMedia) return false; if (selector === selectSendRedesignFlags) return { enabled: false }; + if (selector === selectChainId) return '0x1'; + return undefined; }); const { toJSON } = renderWithProvider(, { state: mockInitialState, @@ -79,12 +103,14 @@ describe('CollectibleModal', () => { expect(toJSON()).toMatchSnapshot(); }); - it('should render the correct token name and ID', async () => { + it('renders the correct token name and ID', async () => { (useSelector as jest.Mock).mockImplementation((selector) => { if (selector === collectiblesSelector) return collectibles; if (selector === selectIsIpfsGatewayEnabled) return true; if (selector === selectDisplayNftMedia) return true; if (selector === selectSendRedesignFlags) return { enabled: false }; + if (selector === selectChainId) return '0x1'; + return undefined; }); const { findAllByText } = renderWithProvider(, { @@ -94,4 +120,94 @@ describe('CollectibleModal', () => { expect(await findAllByText('#6904')).toBeDefined(); expect(await findAllByText('Leopard')).toBeDefined(); }); + + it('tracks NFT Details Opened event', () => { + (useSelector as jest.Mock).mockImplementation((selector) => { + if (selector === collectiblesSelector) return collectibles; + if (selector === selectIsIpfsGatewayEnabled) return false; + if (selector === selectDisplayNftMedia) return false; + if (selector === selectSendRedesignFlags) return { enabled: false }; + if (selector === selectChainId) return '0x1'; + return undefined; + }); + + renderWithProvider(, { + state: mockInitialState, + }); + + expect(mockCreateEventBuilder).toHaveBeenCalled(); + expect(mockTrackEvent).toHaveBeenCalled(); + }); + + it('tracks NFT Details Opened event with mobile-nft-list source', () => { + const mockAddProperties = jest.fn().mockReturnThis(); + const mockBuild = jest.fn(); + mockCreateEventBuilder.mockReturnValue({ + addProperties: mockAddProperties, + build: mockBuild, + }); + + mockUseRoute.mockReturnValue({ + params: { + contractName: 'Opensea', + collectible: { name: 'Leopard', tokenId: 6904, address: '0x123' }, + source: 'mobile-nft-list', + }, + }); + + (useSelector as jest.Mock).mockImplementation((selector) => { + if (selector === collectiblesSelector) return collectibles; + if (selector === selectIsIpfsGatewayEnabled) return false; + if (selector === selectDisplayNftMedia) return false; + if (selector === selectSendRedesignFlags) return { enabled: false }; + if (selector === selectChainId) return '0x1'; + return undefined; + }); + + renderWithProvider(, { + state: mockInitialState, + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'mobile-nft-list', + }), + ); + }); + + it('tracks NFT Details Opened event with mobile-nft-list-page source', () => { + const mockAddProperties = jest.fn().mockReturnThis(); + const mockBuild = jest.fn(); + mockCreateEventBuilder.mockReturnValue({ + addProperties: mockAddProperties, + build: mockBuild, + }); + + mockUseRoute.mockReturnValue({ + params: { + contractName: 'Opensea', + collectible: { name: 'Leopard', tokenId: 6904, address: '0x123' }, + source: 'mobile-nft-list-page', + }, + }); + + (useSelector as jest.Mock).mockImplementation((selector) => { + if (selector === collectiblesSelector) return collectibles; + if (selector === selectIsIpfsGatewayEnabled) return false; + if (selector === selectDisplayNftMedia) return false; + if (selector === selectSendRedesignFlags) return { enabled: false }; + if (selector === selectChainId) return '0x1'; + return undefined; + }); + + renderWithProvider(, { + state: mockInitialState, + }); + + expect(mockAddProperties).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'mobile-nft-list-page', + }), + ); + }); }); diff --git a/app/components/UI/CollectibleModal/CollectibleModal.tsx b/app/components/UI/CollectibleModal/CollectibleModal.tsx index 240f76837ff..e2a8f33f482 100644 --- a/app/components/UI/CollectibleModal/CollectibleModal.tsx +++ b/app/components/UI/CollectibleModal/CollectibleModal.tsx @@ -38,7 +38,8 @@ const CollectibleModal = () => { const { trackEvent, createEventBuilder } = useMetrics(); const chainId = useSelector(selectChainId); - const { contractName, collectible } = useParams(); + const { contractName, collectible, source } = + useParams(); const modalRef = useRef(null); @@ -73,13 +74,16 @@ const CollectibleModal = () => { useEffect(() => { trackEvent( createEventBuilder(MetaMetricsEvents.NFT_DETAILS_OPENED) - .addProperties({ chain_id: getDecimalChainId(chainId) }) + .addProperties({ + chain_id: getDecimalChainId(chainId), + ...(source && { source }), + }) .build(), ); // The linter wants `trackEvent` to be added as a dependency, // But the event fires twice if I do that. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [chainId]); + }, [chainId, source]); const onSend = useCallback(async () => { dispatch(newAssetTransaction({ contractName, ...collectible })); diff --git a/app/components/UI/CollectibleModal/CollectibleModal.types.ts b/app/components/UI/CollectibleModal/CollectibleModal.types.ts index 5b066ad52ef..dd942afca12 100644 --- a/app/components/UI/CollectibleModal/CollectibleModal.types.ts +++ b/app/components/UI/CollectibleModal/CollectibleModal.types.ts @@ -3,6 +3,7 @@ import { Nft } from '@metamask/assets-controllers'; export interface CollectibleModalParams { contractName: string; collectible: Nft; + source?: 'mobile-nft-list' | 'mobile-nft-list-page'; } export interface ReusableModalRef { diff --git a/app/components/UI/CollectibleModal/__snapshots__/CollectibleModal.test.tsx.snap b/app/components/UI/CollectibleModal/__snapshots__/CollectibleModal.test.tsx.snap index 78ce89a4052..cac674b5104 100644 --- a/app/components/UI/CollectibleModal/__snapshots__/CollectibleModal.test.tsx.snap +++ b/app/components/UI/CollectibleModal/__snapshots__/CollectibleModal.test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`CollectibleModal should render correctly 1`] = ` +exports[`CollectibleModal renders correctly 1`] = ` { }); }); + it('tracks analytics event when view all button is clicked', async () => { + const mockCollectibles = { + '0x1': Array.from({ length: 20 }, (_, i) => ({ + ...mockNft, + tokenId: `${i}`, + })), + }; + mockUseSelector + .mockReturnValueOnce(false) // isNftFetchingProgress + .mockReturnValueOnce(true) // selectHomepageRedesignV1Enabled (maxItems = 18) + .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector + const store = mockStore(initialState); + + const { getByTestId } = render( + + + , + ); + + act(() => { + jest.advanceTimersByTime(100); + }); + + await waitFor(() => { + expect(getByTestId('view-all-nfts-button')).toBeOnTheScreen(); + }); + + fireEvent.press(getByTestId('view-all-nfts-button')); + + expect(mockTrackEvent).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'View All Assets Clicked', + properties: expect.objectContaining({ + asset_type: 'NFT', + }), + }), + ); + }); + it('hides view all button when homepage redesign is disabled', async () => { const mockCollectibles = { '0x1': Array.from({ length: 20 }, (_, i) => ({ @@ -547,6 +586,65 @@ describe('NftGrid', () => { expect(mockNavigate).toHaveBeenCalledWith('NftDetails', { collectible: mockNft, + source: 'mobile-nft-list', + }); + }); + }); + + it('passes mobile-nft-list source when navigating from homepage view', async () => { + const mockCollectibles = { '0x1': [mockNft] }; + mockUseSelector + .mockReturnValueOnce(false) // isNftFetchingProgress + .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled + .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector + const store = mockStore(initialState); + + const { getByTestId } = render( + + + , + ); + + act(() => { + jest.advanceTimersByTime(100); + }); + + await waitFor(() => { + const nftItem = getByTestId('collectible-Test NFT-456'); + fireEvent.press(nftItem); + + expect(mockNavigate).toHaveBeenCalledWith('NftDetails', { + collectible: mockNft, + source: 'mobile-nft-list', + }); + }); + }); + + it('passes mobile-nft-list-page source when navigating from full view', async () => { + const mockCollectibles = { '0x1': [mockNft] }; + mockUseSelector + .mockReturnValueOnce(false) // isNftFetchingProgress + .mockReturnValueOnce(false) // selectHomepageRedesignV1Enabled + .mockReturnValueOnce(mockCollectibles); // multichainCollectiblesByEnabledNetworksSelector + const store = mockStore(initialState); + + const { getByTestId } = render( + + + , + ); + + act(() => { + jest.advanceTimersByTime(100); + }); + + await waitFor(() => { + const nftItem = getByTestId('collectible-Test NFT-456'); + fireEvent.press(nftItem); + + expect(mockNavigate).toHaveBeenCalledWith('NftDetails', { + collectible: mockNft, + source: 'mobile-nft-list-page', }); }); }); diff --git a/app/components/UI/NftGrid/NftGrid.tsx b/app/components/UI/NftGrid/NftGrid.tsx index 724be654f22..067602ea879 100644 --- a/app/components/UI/NftGrid/NftGrid.tsx +++ b/app/components/UI/NftGrid/NftGrid.tsx @@ -52,9 +52,11 @@ interface NftGridProps { const NftRow = ({ items, onLongPress, + source, }: { items: Nft[]; onLongPress: (nft: Nft) => void; + source?: 'mobile-nft-list' | 'mobile-nft-list-page'; }) => ( {items.map((item, index) => { @@ -62,7 +64,7 @@ const NftRow = ({ const uniqueKey = `${item.address}-${item.tokenId}-${item.chainId}-${index}`; return ( - + ); })} @@ -90,6 +92,8 @@ const NftGrid = ({ isFullView = false }: NftGridProps) => { const actionSheetRef = useRef(); + const nftSource = isFullView ? 'mobile-nft-list-page' : 'mobile-nft-list'; + const collectiblesByEnabledNetworks: Record = useSelector( multichainCollectiblesByEnabledNetworksSelector, ); @@ -139,8 +143,13 @@ const NftGrid = ({ isFullView = false }: NftGridProps) => { }, [navigation, trackEvent, createEventBuilder]); const handleViewAllNfts = useCallback(() => { + trackEvent( + createEventBuilder(MetaMetricsEvents.VIEW_ALL_ASSETS_CLICKED) + .addProperties({ asset_type: 'NFT' }) + .build(), + ); navigation.navigate(Routes.WALLET.NFTS_FULL_VIEW); - }, [navigation]); + }, [navigation, trackEvent, createEventBuilder]); const nftRowList = !isFullView && isHomepageRedesignV1Enabled ? ( @@ -152,6 +161,7 @@ const NftGrid = ({ isFullView = false }: NftGridProps) => { key={`nft-row-${index}`} items={items} onLongPress={setLongPressedCollectible} + source={nftSource} /> ))} @@ -161,7 +171,11 @@ const NftGrid = ({ isFullView = false }: NftGridProps) => { ListHeaderComponent={} data={groupedCollectibles} renderItem={({ item }) => ( - + )} keyExtractor={(_, index) => `nft-row-${index}`} testID={RefreshTestId} @@ -171,6 +185,28 @@ const NftGrid = ({ isFullView = false }: NftGridProps) => { /> ); + const renderNftContent = () => { + if (isNftFetchingProgress) { + return ; + } + + if (allFilteredCollectibles.length > 0) { + return nftRowList; + } + + return ( + + ); + }; + return ( <> { hideSort style={isFullView ? tw`px-4 pb-4` : tw`pb-3`} /> - {isNftFetchingProgress ? ( - - ) : allFilteredCollectibles.length > 0 ? ( - nftRowList - ) : ( - - )} + {renderNftContent()} {/* View all NFTs button - shown when there are more items than maxItems */} {maxItems && allFilteredCollectibles.length > maxItems && ( diff --git a/app/components/UI/NftGrid/NftGridItem.tsx b/app/components/UI/NftGrid/NftGridItem.tsx index 7a08b6ae94f..29ebf89e1bf 100644 --- a/app/components/UI/NftGrid/NftGridItem.tsx +++ b/app/components/UI/NftGrid/NftGridItem.tsx @@ -12,23 +12,25 @@ import { import { useTailwind } from '@metamask/design-system-twrnc-preset'; import CollectibleMedia from '../CollectibleMedia'; -const debouncedNavigation = debounce((navigation, collectible) => { - navigation.navigate('NftDetails', { collectible }); +const debouncedNavigation = debounce((navigation, collectible, source) => { + navigation.navigate('NftDetails', { collectible, source }); }, 0); const NftGridItem = ({ item, onLongPress, + source, }: { item: Nft; onLongPress: (nft: Nft) => void; + source?: 'mobile-nft-list' | 'mobile-nft-list-page'; }) => { const navigation = useNavigation(); const tw = useTailwind(); const onPress = useCallback(() => { - debouncedNavigation(navigation, item); - }, [navigation, item]); + debouncedNavigation(navigation, item, source); + }, [navigation, item, source]); return ( { }, { label: strings('perps.transactions.order.limit_price'), - value: formatPerpsFiat(transaction.order?.limitPrice ?? 0), + value: formatPositiveFiat(transaction.order?.limitPrice ?? 0), }, { label: strings('perps.transactions.order.filled'), @@ -109,15 +109,15 @@ const PerpsOrderTransactionView: React.FC = () => { const feeRows = [ { label: strings('perps.transactions.order.metamask_fee'), - value: formatFee(isFilled ? metamaskFee : 0), + value: formatPositiveFiat(isFilled ? metamaskFee : 0), }, { label: strings('perps.transactions.order.hyperliquid_fee'), - value: formatFee(isFilled ? protocolFee : 0), + value: formatPositiveFiat(isFilled ? protocolFee : 0), }, { label: strings('perps.transactions.order.total_fee'), - value: formatFee(isFilled ? totalFee : 0), + value: formatPositiveFiat(isFilled ? totalFee : 0), }, ]; diff --git a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx index 6f9db04cfd6..96ad227fa4a 100644 --- a/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx +++ b/app/components/UI/Perps/Views/PerpsTransactionsView/PerpsPositionTransactionView.tsx @@ -30,10 +30,8 @@ import { PerpsTransaction, } from '../../types/transactionHistory'; import { - formatFee, - formatPerpsFiat, + formatPositiveFiat, formatTransactionDate, - PRICE_RANGES_UNIVERSAL, } from '../../utils/formatUtils'; import { styleSheet } from './PerpsPositionTransactionView.styles'; @@ -91,7 +89,7 @@ const PerpsPositionTransactionView: React.FC = () => { }, transaction.fill?.amount && { label: strings('perps.transactions.position.size'), - value: `${formatPerpsFiat( + value: `${formatPositiveFiat( Math.abs( BigNumber(transaction.fill?.size || '0') .times(transaction.fill?.entryPrice || '0') @@ -105,9 +103,7 @@ const PerpsPositionTransactionView: React.FC = () => { transaction.fill?.action === 'Closed' ? strings('perps.transactions.position.close_price') : strings('perps.transactions.position.entry_price'), - value: formatPerpsFiat(transaction.fill.entryPrice, { - ranges: PRICE_RANGES_UNIVERSAL, - }), + value: formatPositiveFiat(transaction.fill.entryPrice), }, ].filter(Boolean); @@ -116,7 +112,7 @@ const PerpsPositionTransactionView: React.FC = () => { transaction.fill?.fee !== undefined && transaction.fill?.fee !== null && { label: strings('perps.transactions.position.fees'), - value: formatFee(transaction.fill.fee), + value: formatPositiveFiat(transaction.fill.fee), textColor: TextColor.Default, }, ].filter(Boolean); diff --git a/app/components/UI/Perps/components/TradingViewChart/TradingViewChartTemplate.tsx b/app/components/UI/Perps/components/TradingViewChart/TradingViewChartTemplate.tsx index aba32d2824d..acb63cada00 100644 --- a/app/components/UI/Perps/components/TradingViewChart/TradingViewChartTemplate.tsx +++ b/app/components/UI/Perps/components/TradingViewChart/TradingViewChartTemplate.tsx @@ -56,6 +56,22 @@ export const createTradingViewChartTemplate = ( window.allCandleData = []; // Store all loaded data for zoom functionality window.visiblePriceRange = null; // Track visible price range for dynamic decimal precision + // Helper function to get date string in user's timezone (YYYY-MM-DD) + window.getDateString = function(date, userTimezone) { + const year = date.toLocaleString('en-US', { year: 'numeric', timeZone: userTimezone }); + const month = date.toLocaleString('en-US', { month: '2-digit', timeZone: userTimezone }); + const day = date.toLocaleString('en-US', { day: '2-digit', timeZone: userTimezone }); + return year + '-' + month + '-' + day; + }; + + // Helper function to check if a date is today in user's timezone + window.isToday = function(date, userTimezone) { + const now = new Date(); + const todayString = window.getDateString(now, userTimezone); + const dateString = window.getDateString(date, userTimezone); + return todayString === dateString; + }; + // Cache for Intl.NumberFormat instances to avoid expensive recreation // Key: decimal count (e.g., "0", "2", "4"), Value: NumberFormat instance window.formatterCache = new Map(); @@ -200,25 +216,46 @@ export const createTradingViewChartTemplate = ( timeZone: userTimezone }); case 'DayOfMonth': - return date.toLocaleString('en-US', { - month: 'short', + // Always show day + month for DayOfMonth tick type (e.g., 1D candles) + // Format: "17 Nov" (day before month) + const day = date.toLocaleString('en-US', { day: 'numeric', timeZone: userTimezone }); - case 'Hour': - return date.toLocaleString('en-US', { - hour: '2-digit', - minute: '2-digit', - hour12: false, + const month = date.toLocaleString('en-US', { + month: 'short', timeZone: userTimezone }); + return day + ' ' + month; + case 'Hour': case 'Minute': - return date.toLocaleString('en-US', { - hour: '2-digit', - minute: '2-digit', - hour12: false, - timeZone: userTimezone - }); + // Show date + time if not today, otherwise just time + if (!window.isToday(date, userTimezone)) { + // Format: "17 Nov 00:15" + const day = date.toLocaleString('en-US', { + day: 'numeric', + timeZone: userTimezone + }); + const month = date.toLocaleString('en-US', { + month: 'short', + timeZone: userTimezone + }); + const timeStr = date.toLocaleString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: userTimezone + }); + return day + ' ' + month + ' ' + timeStr; + } else { + // Show time only for today + return date.toLocaleString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: userTimezone + }); + } case 'Second': return date.toLocaleString('en-US', { hour: '2-digit', @@ -241,46 +278,95 @@ export const createTradingViewChartTemplate = ( const timeSpanHours = (visibleRange.to - visibleRange.from) / 3600; if (timeSpanHours <= 24) { - // Less than 24 hours: show time only - return date.toLocaleString('en-US', { - hour: '2-digit', - minute: '2-digit', - hour12: false, - timeZone: userTimezone - }); + // Less than 24 hours: show date + time if not today, otherwise just time + if (!window.isToday(date, userTimezone)) { + // Format: "17 Nov 00:15" + const day = date.toLocaleString('en-US', { + day: 'numeric', + timeZone: userTimezone + }); + const month = date.toLocaleString('en-US', { + month: 'short', + timeZone: userTimezone + }); + const timeStr = date.toLocaleString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: userTimezone + }); + return day + ' ' + month + ' ' + timeStr; + } else { + // Show time only for today + return date.toLocaleString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: userTimezone + }); + } } else if (timeSpanHours <= 24 * 7) { - // Less than a week: show date only - return date.toLocaleString('en-US', { - month: 'short', - day: 'numeric', - timeZone: userTimezone - }); - } else if (timeSpanHours <= 24 * 30) { - // Less than a month: show date only - return date.toLocaleString('en-US', { - month: 'short', + // Less than a week: show date + time if not today, otherwise just time + if (!window.isToday(date, userTimezone)) { + // Format: "17 Nov 00:15" + const day = date.toLocaleString('en-US', { + day: 'numeric', + timeZone: userTimezone + }); + const month = date.toLocaleString('en-US', { + month: 'short', + timeZone: userTimezone + }); + const timeStr = date.toLocaleString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: userTimezone + }); + return day + ' ' + month + ' ' + timeStr; + } else { + // Show time only for today + return date.toLocaleString('en-US', { + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: userTimezone + }); + } + } else { + // Longer ranges: always show day + month (e.g., "17 Nov") + // This is especially important for 1D candles + const day = date.toLocaleString('en-US', { day: 'numeric', timeZone: userTimezone }); - } else { - // More than a month: show month only - return date.toLocaleString('en-US', { + const month = date.toLocaleString('en-US', { month: 'short', timeZone: userTimezone }); + return day + ' ' + month; } } } // Final fallback: show date and time - return date.toLocaleString('en-US', { - month: 'short', + // Format: "17 Nov 00:15" + const day = date.toLocaleString('en-US', { day: 'numeric', + timeZone: userTimezone + }); + const month = date.toLocaleString('en-US', { + month: 'short', + timeZone: userTimezone + }); + const timeStr = date.toLocaleString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false, timeZone: userTimezone }); + + return day + ' ' + month + ' ' + timeStr; } }; diff --git a/app/components/UI/Perps/utils/formatUtils.test.ts b/app/components/UI/Perps/utils/formatUtils.test.ts index cf231c46565..15cd85c0be8 100644 --- a/app/components/UI/Perps/utils/formatUtils.test.ts +++ b/app/components/UI/Perps/utils/formatUtils.test.ts @@ -15,9 +15,9 @@ import { formatTransactionDate, formatDateSection, formatFundingRate, - formatFee, PRICE_RANGES_UNIVERSAL, PRICE_RANGES_MINIMAL_VIEW, + formatPositiveFiat, } from './formatUtils'; import { FUNDING_RATE_CONFIG } from '../constants/perpsConfig'; @@ -195,13 +195,13 @@ describe('formatUtils', () => { }); }); - describe('formatFee', () => { + describe('formatPositiveFiat', () => { it('returns "$0" when fee is exactly zero', () => { // Given a fee of exactly zero const fee = 0; // When formatting the fee - const result = formatFee(fee); + const result = formatPositiveFiat(fee); // Then it returns "$0" expect(result).toBe('$0'); @@ -212,7 +212,7 @@ describe('formatUtils', () => { const fee = 0.005; // When formatting the fee - const result = formatFee(fee); + const result = formatPositiveFiat(fee); // Then it returns "< $0.01" expect(result).toBe('< $0.01'); @@ -223,7 +223,7 @@ describe('formatUtils', () => { const fee = 0.01; // When formatting the fee - const result = formatFee(fee); + const result = formatPositiveFiat(fee); // Then it formats normally expect(result).toBe('$0.01'); @@ -234,7 +234,7 @@ describe('formatUtils', () => { const fee = 1.5; // When formatting the fee - const result = formatFee(fee); + const result = formatPositiveFiat(fee); // Then it formats normally expect(result).toBe('$1.50'); @@ -245,7 +245,7 @@ describe('formatUtils', () => { const fee = 0.0001; // When formatting the fee - const result = formatFee(fee); + const result = formatPositiveFiat(fee); // Then it returns "< $0.01" expect(result).toBe('< $0.01'); @@ -256,7 +256,7 @@ describe('formatUtils', () => { const fee = 0.0099; // When formatting the fee - const result = formatFee(fee); + const result = formatPositiveFiat(fee); // Then it returns "< $0.01" expect(result).toBe('< $0.01'); @@ -267,7 +267,7 @@ describe('formatUtils', () => { const fee = 0.0101; // When formatting the fee - const result = formatFee(fee); + const result = formatPositiveFiat(fee); // Then it formats normally (rounded to $0.01) expect(result).toBe('$0.01'); @@ -278,7 +278,7 @@ describe('formatUtils', () => { const fee = 123.45; // When formatting the fee - const result = formatFee(fee); + const result = formatPositiveFiat(fee); // Then it formats with proper decimals expect(result).toBe('$123.45'); @@ -289,7 +289,7 @@ describe('formatUtils', () => { const fee = 100; // When formatting the fee - const result = formatFee(fee); + const result = formatPositiveFiat(fee); // Then trailing zeros are stripped expect(result).toBe('$100'); @@ -300,7 +300,7 @@ describe('formatUtils', () => { const fee = 1.23456789; // When formatting the fee - const result = formatFee(fee); + const result = formatPositiveFiat(fee); // Then it rounds appropriately expect(result).toBe('$1.23'); @@ -311,7 +311,7 @@ describe('formatUtils', () => { const fee = -0; // When formatting the fee - const result = formatFee(fee); + const result = formatPositiveFiat(fee); // Then it returns "$0" expect(result).toBe('$0'); @@ -322,7 +322,7 @@ describe('formatUtils', () => { const fee = 0.00000001; // When formatting the fee - const result = formatFee(fee); + const result = formatPositiveFiat(fee); // Then it returns "< $0.01" expect(result).toBe('< $0.01'); diff --git a/app/components/UI/Perps/utils/formatUtils.ts b/app/components/UI/Perps/utils/formatUtils.ts index 8a965b150ab..9d33b710e4a 100644 --- a/app/components/UI/Perps/utils/formatUtils.ts +++ b/app/components/UI/Perps/utils/formatUtils.ts @@ -346,11 +346,11 @@ export const formatPerpsFiat = ( * Formats a fee value as USD currency with appropriate decimal places * @param fee - Raw numeric or string fee value (e.g., 1234.56, not token minimal denomination) * @returns Formatted currency string with variable decimals based on configured ranges - * @example formatFee(1234.56) => "$1,234.56" - * @example formatFee(0.005) => "< $0.01" - * @example formatFee(0) => "$0" + * @example formatPositiveFiat(1234.56) => "$1,234.56" + * @example formatPositiveFiat(0.005) => "< $0.01" + * @example formatPositiveFiat(0) => "$0" */ -export const formatFee = (fee: number | string): string => { +export const formatPositiveFiat = (fee: number | string): string => { const smallFeeThreshold = 0.01; if (BigNumber(fee).isEqualTo(0)) { diff --git a/app/components/UI/Predict/components/PredictFeeSummary/PredictFeeSummary.test.tsx b/app/components/UI/Predict/components/PredictFeeSummary/PredictFeeSummary.test.tsx index 355c2a5934b..54780e04b41 100644 --- a/app/components/UI/Predict/components/PredictFeeSummary/PredictFeeSummary.test.tsx +++ b/app/components/UI/Predict/components/PredictFeeSummary/PredictFeeSummary.test.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { render, fireEvent } from '@testing-library/react-native'; import PredictFeeSummary from './PredictFeeSummary'; +import { InternalAccount } from '@metamask/keyring-internal-api'; jest.mock('../../utils/format', () => ({ formatPrice: jest.fn((value, options) => @@ -271,11 +272,21 @@ describe('PredictFeeSummary', () => { expect(getByText('0 points')).toBeOnTheScreen(); }); - it('displays AddRewardsAccount when accountOptedIn is false', () => { + it('displays AddRewardsAccount when rewardsAccountScope is provided and accountOptedIn is false', () => { + const mockRewardsAccountScope = { + id: 'account-1', + address: '0x1234567890123456789012345678901234567890', + name: 'Test Account', + type: 'eip155:eoa', + scopes: [], + metadata: {}, + }; const props = { ...defaultProps, shouldShowRewardsRow: true, accountOptedIn: false, + rewardsAccountScope: + mockRewardsAccountScope as unknown as InternalAccount, estimatedPoints: 100, }; @@ -287,11 +298,21 @@ describe('PredictFeeSummary', () => { expect(queryByTestId('rewards-animation')).toBeNull(); }); - it('displays AddRewardsAccount when accountOptedIn is null', () => { + it('displays AddRewardsAccount when rewardsAccountScope is provided and accountOptedIn is null', () => { + const mockRewardsAccountScope = { + id: 'account-1', + address: '0x1234567890123456789012345678901234567890', + name: 'Test Account', + type: 'eip155:eoa', + scopes: [], + metadata: {}, + }; const props = { ...defaultProps, shouldShowRewardsRow: true, accountOptedIn: null, + rewardsAccountScope: + mockRewardsAccountScope as unknown as InternalAccount, estimatedPoints: 100, }; @@ -303,6 +324,50 @@ describe('PredictFeeSummary', () => { expect(queryByTestId('rewards-animation')).toBeNull(); }); + it('does not display rewards row when both accountOptedIn and rewardsAccountScope are null/false', () => { + const props = { + ...defaultProps, + shouldShowRewardsRow: true, + accountOptedIn: false, + rewardsAccountScope: null, + estimatedPoints: 100, + }; + + const { queryByText, queryByTestId } = render( + , + ); + + expect(queryByText('Est. points')).toBeNull(); + expect(queryByTestId('rewards-animation')).toBeNull(); + expect(queryByTestId('add-rewards-account')).toBeNull(); + }); + + it('displays RewardsAnimations when accountOptedIn is true even if rewardsAccountScope is provided', () => { + const mockRewardsAccountScope = { + id: 'account-1', + address: '0x1234567890123456789012345678901234567890', + name: 'Test Account', + type: 'eip155:eoa', + scopes: [], + metadata: {}, + }; + const props = { + ...defaultProps, + shouldShowRewardsRow: true, + accountOptedIn: true, + rewardsAccountScope: + mockRewardsAccountScope as unknown as InternalAccount, + estimatedPoints: 50, + }; + + const { getByTestId, queryByTestId } = render( + , + ); + + expect(getByTestId('rewards-animation')).toBeOnTheScreen(); + expect(queryByTestId('add-rewards-account')).toBeNull(); + }); + it('displays loading state when isLoadingRewards is true', () => { const props = { ...defaultProps, diff --git a/app/components/UI/Predict/components/PredictFeeSummary/PredictFeeSummary.tsx b/app/components/UI/Predict/components/PredictFeeSummary/PredictFeeSummary.tsx index 6748bcf84ac..3a53366ca56 100644 --- a/app/components/UI/Predict/components/PredictFeeSummary/PredictFeeSummary.tsx +++ b/app/components/UI/Predict/components/PredictFeeSummary/PredictFeeSummary.tsx @@ -22,6 +22,7 @@ import RewardsAnimations, { } from '../../../Rewards/components/RewardPointsAnimation'; import { formatPrice } from '../../utils/format'; import AddRewardsAccount from '../../../Rewards/components/AddRewardsAccount/AddRewardsAccount'; +import { InternalAccount } from '@metamask/keyring-internal-api'; interface PredictFeeSummaryProps { disabled: boolean; @@ -30,6 +31,7 @@ interface PredictFeeSummaryProps { total: number; shouldShowRewardsRow?: boolean; accountOptedIn?: boolean | null; + rewardsAccountScope?: InternalAccount | null; estimatedPoints?: number | null; isLoadingRewards?: boolean; hasRewardsError?: boolean; @@ -43,6 +45,7 @@ const PredictFeeSummary: React.FC = ({ total, shouldShowRewardsRow = false, accountOptedIn = null, + rewardsAccountScope = null, estimatedPoints = 0, isLoadingRewards = false, hasRewardsError = false, @@ -90,7 +93,7 @@ const PredictFeeSummary: React.FC = ({ {/* Estimated Points Row */} - {shouldShowRewardsRow && ( + {shouldShowRewardsRow && (accountOptedIn || rewardsAccountScope) && ( = ({ : RewardAnimationState.Idle } /> + ) : rewardsAccountScope ? ( + ) : ( - + <> )} ), diff --git a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.styles.ts b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.styles.ts index 4bbe5297621..5d7258fe2b6 100644 --- a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.styles.ts +++ b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.styles.ts @@ -13,8 +13,13 @@ const styleSheet = (params: { ...(vars.isCarousel && { height: '100%' }), backgroundColor: theme.colors.background.section, borderRadius: 16, - padding: vars.isCarousel ? 12 : 16, + padding: 16, marginVertical: vars.isCarousel ? 0 : 8, + paddingVertical: vars.isCarousel ? 8 : 16, + ...(vars.isCarousel && { + flexDirection: 'column', + justifyContent: 'space-between', + }), }, buttonContainer: { flexDirection: 'row', @@ -25,12 +30,12 @@ const styleSheet = (params: { buttonYes: { color: theme.colors.success.default, backgroundColor: theme.colors.success.muted, - width: 68, + width: vars.isCarousel ? 60 : 68, }, buttonNo: { color: theme.colors.error.default, backgroundColor: theme.colors.error.muted, - width: 68, + width: vars.isCarousel ? 60 : 68, }, }); }; diff --git a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx index 778591eb91d..5374c0910ed 100644 --- a/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx +++ b/app/components/UI/Predict/components/PredictMarketMultiple/PredictMarketMultiple.tsx @@ -157,7 +157,7 @@ const PredictMarketMultiple: React.FC = ({ }} > - + = ({ > = ({ {getOutcomePercentage( @@ -243,6 +235,7 @@ const PredictMarketMultiple: React.FC = ({ size={isCarousel ? ButtonSize.Sm : ButtonSize.Md} label={ = ({ width={ButtonWidthTypes.Full} label={ = ({ ); })} + + + + {filteredOutcomes.length > 3 + ? `+${filteredOutcomes.length - 3} ${ + filteredOutcomes.length - 3 === 1 + ? strings('predict.outcomes_singular') + : strings('predict.outcomes_plural') + }` + : ''} + - {filteredOutcomes.length > 3 - ? `+${filteredOutcomes.length - 3} ${ - filteredOutcomes.length - 3 === 1 - ? strings('predict.outcomes_singular') - : strings('predict.outcomes_plural') - }` - : ''} + ${totalVolumeDisplay} {strings('predict.volume_abbreviated')} - - - ${totalVolumeDisplay} {strings('predict.volume_abbreviated')} - - {market.recurrence && market.recurrence !== Recurrence.NONE && ( - + + - - - {strings( - `predict.recurrence.${market.recurrence.toLowerCase()}`, - )} - - - )} - + {strings( + `predict.recurrence.${market.recurrence.toLowerCase()}`, + )} + + + )} diff --git a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.styles.ts b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.styles.ts index be3b4ab523d..65a4c6256b4 100644 --- a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.styles.ts +++ b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.styles.ts @@ -16,8 +16,9 @@ const styleSheet = (params: { ...(vars.isCarousel && { height: '100%' }), backgroundColor: theme.colors.background.section, borderRadius: 16, - padding: vars.isCarousel ? 12 : 16, + padding: 16, marginVertical: vars.isCarousel ? 0 : 8, + paddingVertical: vars.isCarousel ? 8 : 16, }, marketHeader: { flexDirection: 'row', @@ -36,7 +37,7 @@ const styleSheet = (params: { justifyContent: 'flex-end', alignItems: 'flex-end', width: '100%', - marginTop: 8, + marginTop: vars.isCarousel ? 0 : 8, }, buttonContainer: { flexDirection: 'row', diff --git a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx index a6ff0216c10..101529f8d63 100644 --- a/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx +++ b/app/components/UI/Predict/components/PredictMarketSingle/PredictMarketSingle.tsx @@ -235,10 +235,16 @@ const PredictMarketSingle: React.FC = ({