diff --git a/app/__mocks__/rive-react-native.tsx b/app/__mocks__/rive-react-native.tsx index 3bb07b3ad13..470c340b5c0 100644 --- a/app/__mocks__/rive-react-native.tsx +++ b/app/__mocks__/rive-react-native.tsx @@ -1,4 +1,4 @@ -import React, { forwardRef, useImperativeHandle } from 'react'; +import React, { forwardRef, useImperativeHandle, useEffect } from 'react'; import { View, ViewProps } from 'react-native'; export interface RiveRef { @@ -27,6 +27,7 @@ type MockRiveProps = ViewProps & { alignment?: string; autoplay?: boolean; stateMachineName?: string; + onPlay?: () => void; }; const DEFAULT_TEST_ID = 'mock-rive-animation'; @@ -48,12 +49,19 @@ const updateLastMockedMethods = (methods: RiveRef) => { }; const RiveMock = forwardRef( - ({ testID = DEFAULT_TEST_ID, mockedMethods, ...viewProps }, ref) => { + ({ testID = DEFAULT_TEST_ID, mockedMethods, onPlay, ...viewProps }, ref) => { const methods = createMockedMethods(mockedMethods); updateLastMockedMethods(methods); useImperativeHandle(ref, () => methods, [methods]); + useEffect(() => { + if (onPlay) { + onPlay(); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + return ; }, ); diff --git a/app/component-library/components/BottomSheets/BottomSheet/foundation/BottomSheetDialog/BottomSheetDialog.constants.ts b/app/component-library/components/BottomSheets/BottomSheet/foundation/BottomSheetDialog/BottomSheetDialog.constants.ts index 0fe149de8b3..7e7bade3f07 100644 --- a/app/component-library/components/BottomSheets/BottomSheet/foundation/BottomSheetDialog/BottomSheetDialog.constants.ts +++ b/app/component-library/components/BottomSheets/BottomSheet/foundation/BottomSheetDialog/BottomSheetDialog.constants.ts @@ -8,7 +8,7 @@ import { AnimationDuration } from '../../../../../constants/animation.constants' * The animation duration used for initial render. */ export const DEFAULT_BOTTOMSHEETDIALOG_DISPLAY_DURATION = - AnimationDuration.Regularly; + AnimationDuration.Fast; /** * This number represents the swipe speed to meet the velocity threshold. */ diff --git a/app/components/UI/FoxAnimation/FoxAnimation.tsx b/app/components/UI/FoxAnimation/FoxAnimation.tsx index 5b09b4e5b90..59ecef3fea4 100644 --- a/app/components/UI/FoxAnimation/FoxAnimation.tsx +++ b/app/components/UI/FoxAnimation/FoxAnimation.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useRef } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { StyleSheet, Platform, View } from 'react-native'; import Rive, { Alignment, Fit, RiveRef } from 'rive-react-native'; import { useSafeAreaInsets, EdgeInsets } from 'react-native-safe-area-context'; @@ -77,6 +77,8 @@ const FoxAnimation = ({ const insets = useSafeAreaInsets(); const styles = createStyles(hasFooter, insets); + const [isPlaying, setIsPlaying] = useState(false); + const showFoxAnimation = useCallback(async () => { if (foxRef.current && trigger) { try { @@ -88,8 +90,10 @@ const FoxAnimation = ({ }, [foxRef, trigger]); useEffect(() => { - showFoxAnimation(); - }, [showFoxAnimation]); + if (isPlaying) { + showFoxAnimation(); + } + }, [showFoxAnimation, isPlaying]); return ( @@ -99,9 +103,11 @@ const FoxAnimation = ({ source={FoxAnimationRive} fit={Fit.Contain} alignment={Alignment.Center} - autoplay={false} stateMachineName="FoxRaiseUp" testID="fox-animation" + onPlay={() => { + setIsPlaying(true); + }} /> ); diff --git a/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.tsx b/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.tsx index 3111a06a043..d4d208af8e1 100644 --- a/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.tsx +++ b/app/components/UI/MultichainBridgeTransactionListItem/MultichainBridgeTransactionListItem.tsx @@ -23,6 +23,12 @@ import { } from '../Bridge/utils/transaction-history'; import { ethers } from 'ethers'; import { formatAmountWithThreshold } from '../../../util/number'; +import BadgeWrapper from '../../../component-library/components/Badges/BadgeWrapper'; +import Badge, { + BadgeVariant, +} from '../../../component-library/components/Badges/Badge'; +import { getNetworkImageSource } from '../../../util/networks'; +import { parseCaipAssetType } from '@metamask/utils'; const MultichainBridgeTransactionListItem = ({ transaction, @@ -60,7 +66,25 @@ const MultichainBridgeTransactionListItem = ({ appTheme, osColorScheme, ); - return ; + const chainId = parseCaipAssetType( + bridgeHistoryItem.quote.srcAsset.assetId, + ).chainId; + if (!chainId) + return ; + + const networkImageSource = getNetworkImageSource({ chainId }); + return ( + + } + > + + + ); }; // Does not apply to swaps diff --git a/app/components/UI/OnboardingAnimation/OnboardingAnimation.tsx b/app/components/UI/OnboardingAnimation/OnboardingAnimation.tsx index 08120537ead..b2e5d8f88a0 100644 --- a/app/components/UI/OnboardingAnimation/OnboardingAnimation.tsx +++ b/app/components/UI/OnboardingAnimation/OnboardingAnimation.tsx @@ -1,4 +1,10 @@ -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { View, Animated, Easing, StyleSheet } from 'react-native'; import Rive, { Fit, Alignment, RiveRef } from 'rive-react-native'; @@ -67,6 +73,8 @@ const OnboardingAnimation = ({ const { themeAppearance } = useAppThemeFromContext(); const styles = createStyles(); + const [isPlaying, setIsPlaying] = useState(false); + const moveLogoUp = useCallback(() => { Animated.parallel([ Animated.timing(logoPosition, { @@ -118,10 +126,10 @@ const OnboardingAnimation = ({ ]); useEffect(() => { - if (startOnboardingAnimation) { + if (startOnboardingAnimation && isPlaying) { startRiveAnimation(); } - }, [startOnboardingAnimation, startRiveAnimation]); + }, [startRiveAnimation, startOnboardingAnimation, isPlaying]); return ( <> @@ -141,9 +149,11 @@ const OnboardingAnimation = ({ source={MetaMaskWordmarkAnimation} fit={Fit.Contain} alignment={Alignment.Center} - autoplay={false} stateMachineName="WordmarkBuildUp" testID="metamask-wordmark-animation" + onPlay={() => { + setIsPlaying(true); + }} /> diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx index 9c649ad1c7c..b2ce11bec30 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.test.tsx @@ -205,23 +205,25 @@ jest.mock('../../hooks/usePerpsMarketStats', () => ({ }), })); -const mockRefreshCandleData = jest.fn(); -jest.mock('../../hooks/usePerpsPositionData', () => ({ - usePerpsPositionData: () => ({ - candleData: [ - { - time: 1234567890, - open: 45000, - high: 45500, - low: 44500, - close: 45200, - volume: 1000, - }, - ], - isLoadingHistory: false, - error: null, - refreshCandleData: mockRefreshCandleData, +jest.mock('../../hooks/stream/usePerpsLiveCandles', () => ({ + usePerpsLiveCandles: () => ({ + candleData: { + coin: 'BTC', + interval: '1h', + candles: [ + { + time: 1234567890, + open: '45000', + high: '45500', + low: '44500', + close: '45200', + volume: '1000', + }, + ], + }, + isLoading: false, hasHistoricalData: true, + error: null, }), })); @@ -464,7 +466,6 @@ describe('PerpsMarketDetailsView', () => { // Clean up mocks after each test afterEach(() => { jest.clearAllMocks(); - mockRefreshCandleData.mockClear(); mockRefreshOrders.mockClear(); mockRefreshMarketStats.mockClear(); mockNavigate.mockClear(); @@ -756,8 +757,8 @@ describe('PerpsMarketDetailsView', () => { await refreshControl.props.onRefresh(); }); - // Should refresh candle data by default - expect(mockRefreshCandleData).toHaveBeenCalledTimes(1); + // Note: Candle data now uses WebSocket streaming (usePerpsLiveCandles) + // so no manual refresh is needed - data updates automatically }); it('refreshes candle data when position tab is active', async () => { @@ -790,8 +791,7 @@ describe('PerpsMarketDetailsView', () => { await refreshControl.props.onRefresh(); }); - // Assert - Only candle data refreshes since positions update via WebSocket - expect(mockRefreshCandleData).toHaveBeenCalledTimes(1); + // Assert - Candle data uses WebSocket streaming, no manual refresh needed // refreshPosition is a no-op for WebSocket, so we don't expect it to be called expect(mockRefreshPosition).not.toHaveBeenCalled(); }); @@ -845,9 +845,8 @@ describe('PerpsMarketDetailsView', () => { await refreshControl.props.onRefresh(); }); - // Assert - Only candle data refreshes (all other data updates via WebSocket) - expect(mockRefreshCandleData).toHaveBeenCalledTimes(1); - // Market stats, positions, and orders update via WebSocket, no manual refresh + // Assert - All data now updates via WebSocket, no manual refresh needed + // Market stats, candles, positions, and orders update via WebSocket expect(mockRefreshMarketStats).not.toHaveBeenCalled(); expect(mockRefreshPosition).not.toHaveBeenCalled(); expect(mockRefreshOrders).not.toHaveBeenCalled(); @@ -883,8 +882,8 @@ describe('PerpsMarketDetailsView', () => { await refreshControl.props.onRefresh(); }); - // Assert - Only candle data refreshes (positions update via WebSocket) - expect(mockRefreshCandleData).toHaveBeenCalledTimes(1); + // Assert - Candle data now uses WebSocket streaming (no manual refresh) + // Positions also update via WebSocket expect(mockRefreshPosition).not.toHaveBeenCalled(); }); @@ -912,15 +911,13 @@ describe('PerpsMarketDetailsView', () => { await refreshControl.props.onRefresh(); }); - // Verify refresh functions were called - expect(mockRefreshCandleData).toHaveBeenCalledTimes(1); + // Note: Candle data now uses WebSocket streaming (no manual refresh needed) }); - it('handles errors during refresh operation', async () => { - const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); - const mockRefreshPosition = jest - .fn() - .mockRejectedValue(new Error('Refresh failed')); + it('handles refresh gracefully with WebSocket streaming', async () => { + // Note: Candle data now uses WebSocket streaming, so refresh is a no-op + // This test verifies the refresh control doesn't break with WebSocket data + const mockRefreshPosition = jest.fn(); mockUseHasExistingPosition.mockReturnValue({ hasPosition: false, @@ -930,10 +927,6 @@ describe('PerpsMarketDetailsView', () => { refreshPosition: mockRefreshPosition, }); - mockRefreshCandleData.mockRejectedValue( - new Error('Candle data refresh failed'), - ); - const { getByTestId } = renderWithProvider( @@ -949,18 +942,14 @@ describe('PerpsMarketDetailsView', () => { ); const refreshControl = scrollView.props.refreshControl; - // Trigger the refresh + // Trigger the refresh - should complete without errors await act(async () => { await refreshControl.props.onRefresh(); }); - // Should log error - expect(consoleErrorSpy).toHaveBeenCalledWith( - expect.stringContaining('Failed to refresh'), - expect.any(Error), - ); - - consoleErrorSpy.mockRestore(); + // Refresh control should exist and be functional + expect(refreshControl).toBeDefined(); + expect(refreshControl.props.refreshing).toBe(false); }); }); diff --git a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx index 5ab2440f0f3..b3cd4c1964a 100644 --- a/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx +++ b/app/components/UI/Perps/Views/PerpsMarketDetailsView/PerpsMarketDetailsView.tsx @@ -38,7 +38,7 @@ import type { PerpsMarketData, PerpsNavigationParamList, } from '../../controllers/types'; -import { usePerpsPositionData } from '../../hooks/usePerpsPositionData'; +import { usePerpsLiveCandles } from '../../hooks/stream/usePerpsLiveCandles'; import { usePerpsMarketStats } from '../../hooks/usePerpsMarketStats'; import { useHasExistingPosition } from '../../hooks/useHasExistingPosition'; import { CandlePeriod, TimeDuration } from '../../constants/chartConfig'; @@ -65,6 +65,9 @@ import { } from '../../hooks/usePerpsDataMonitor'; import { usePerpsMeasurement } from '../../hooks/usePerpsMeasurement'; import { usePerpsLiveOrders, usePerpsLiveAccount } from '../../hooks/stream'; +import { usePerpsABTest } from '../../utils/abTesting/usePerpsABTest'; +import { BUTTON_COLOR_TEST } from '../../utils/abTesting/tests'; +import { selectPerpsButtonColorTestVariant } from '../../selectors/featureFlags'; import PerpsMarketTabs from '../../components/PerpsMarketTabs/PerpsMarketTabs'; import type { PerpsTabId } from '../../components/PerpsMarketTabs/PerpsMarketTabs.types'; import PerpsOICapWarning from '../../components/PerpsOICapWarning'; @@ -94,7 +97,6 @@ import { useConfirmNavigation } from '../../../../Views/confirmations/hooks/useC import Engine from '../../../../../core/Engine'; import { setPerpsChartPreferredCandlePeriod } from '../../../../../actions/settings'; import { selectPerpsChartPreferredCandlePeriod } from '../../selectors/chartPreferences'; -import Skeleton from '../../../../../component-library/components/Skeleton/Skeleton'; interface MarketDetailsRouteParams { market: PerpsMarketData; @@ -180,6 +182,15 @@ const PerpsMarketDetailsView: React.FC = () => { const { account } = usePerpsLiveAccount(); + // A/B Testing: Button color test (TAT-1937) + const { + variantName: buttonColorVariant, + isEnabled: isButtonColorTestEnabled, + } = usePerpsABTest({ + test: BUTTON_COLOR_TEST, + featureFlagSelector: selectPerpsButtonColorTestVariant, + }); + // TP/SL order selection state - track TP and SL separately const [activeTPOrderId, setActiveTPOrderId] = useState(null); const [activeSLOrderId, setActiveSLOrderId] = useState(null); @@ -325,12 +336,16 @@ const PerpsMarketDetailsView: React.FC = () => { // Get comprehensive market statistics const marketStats = usePerpsMarketStats(market?.symbol || ''); - const { candleData, isLoadingHistory, refreshCandleData, hasHistoricalData } = - usePerpsPositionData({ - coin: market?.symbol || '', - selectedDuration: TimeDuration.YEAR_TO_DATE, - selectedInterval: selectedCandlePeriod, - }); + const { + candleData, + isLoading: isLoadingHistory, + fetchMoreHistory, + } = usePerpsLiveCandles({ + coin: market?.symbol || '', + interval: selectedCandlePeriod, + duration: TimeDuration.YEAR_TO_DATE, + throttleMs: 1000, + }); // Check if user has an existing position for this market const { isLoading: isLoadingPosition, existingPosition } = @@ -394,6 +409,10 @@ const PerpsMarketDetailsView: React.FC = () => { [PerpsEventProperties.SOURCE]: source || PerpsEventValues.SOURCE.PERP_MARKETS, [PerpsEventProperties.OPEN_POSITION]: !!existingPosition, + // A/B Test context (TAT-1937) - for baseline exposure tracking + ...(isButtonColorTestEnabled && { + [PerpsEventProperties.AB_TEST_BUTTON_COLOR]: buttonColorVariant, + }), }, }); @@ -434,15 +453,14 @@ const PerpsMarketDetailsView: React.FC = () => { // Reset chart view to default position chartRef.current?.resetToDefault(); - if (candleData) { - await refreshCandleData(); - } + // WebSocket streaming provides real-time data - no manual refresh needed + // Just reset the UI state and the chart will update automatically } catch (error) { - console.error('Failed to refresh candle data:', error); + console.error('Failed to refresh chart state:', error); } finally { setRefreshing(false); } - }, [candleData, refreshCandleData]); + }, []); // Handle order selection for chart integration const handleOrderSelect = useCallback( @@ -551,6 +569,20 @@ const PerpsMarketDetailsView: React.FC = () => { return; } + // Track AB test on button press (TAT-1937) + if (isButtonColorTestEnabled) { + track(MetaMetricsEvents.PERPS_UI_INTERACTION, { + [PerpsEventProperties.INTERACTION_TYPE]: + PerpsEventValues.INTERACTION_TYPE.TAP, + [PerpsEventProperties.ASSET]: market.symbol, + [PerpsEventProperties.DIRECTION]: + direction === 'long' + ? PerpsEventValues.DIRECTION.LONG + : PerpsEventValues.DIRECTION.SHORT, + [PerpsEventProperties.AB_TEST_BUTTON_COLOR]: buttonColorVariant, + }); + } + navigateToOrder({ direction, asset: market.symbol, @@ -563,6 +595,8 @@ const PerpsMarketDetailsView: React.FC = () => { track, navigateToOrder, market?.symbol, + isButtonColorTestEnabled, + buttonColorVariant, ], ); @@ -697,22 +731,17 @@ const PerpsMarketDetailsView: React.FC = () => { > {/* TradingView Chart Section */} - {hasHistoricalData ? ( - - ) : ( - - )} + {/* Always render chart to avoid WebView remount on interval changes */} + {/* Candle Period Selector */} = () => { {hasLongShortButtons && !isAtOICap && ( - - {strings('perps.market.long')} - + {buttonColorVariant === 'monochrome' ? ( +