diff --git a/.cursor/rules/feature-flag-guidelines.mdc b/.cursor/rules/feature-flag-guidelines.mdc deleted file mode 100644 index 424dae1d93e..00000000000 --- a/.cursor/rules/feature-flag-guidelines.mdc +++ /dev/null @@ -1,91 +0,0 @@ ---- -globs: "**/*" -alwaysApply: true ---- - -# Feature Flag Guidelines - -## Core Principle - -**ALWAYS** use the `useFeatureFlag` hook instead of creating new feature flag selectors. - -## Forbidden Patterns - -### ❌ NEVER Create New Feature Flag Selectors - -**DO NOT** create new selectors using `createSelector` for feature flags: - -```typescript -// ❌ FORBIDDEN - Do not create new feature flag selectors -export const selectMyFeatureEnabledFlag = createSelector( - selectRemoteFeatureFlags, - (remoteFeatureFlags) => { - // ... selector logic - }, -); -``` - -**DO NOT** add new feature flag selectors in: -- `app/selectors/featureFlagController/**/*.ts` -- `app/components/**/selectors/featureFlags/**/*.ts` -- Any other location that creates feature flag selectors - -## Required Pattern - -### ✅ ALWAYS Use the `useFeatureFlag` Hook - -**MUST** use the `useFeatureFlag` hook from `app/components/hooks/FeatureFlags/useFeatureFlag.ts`: - -```typescript -// ✅ REQUIRED - Use the hook instead -import { useFeatureFlag, FeatureFlagNames } from '../../../hooks/FeatureFlags/useFeatureFlag'; - -const MyComponent = () => { - const isFeatureEnabled = useFeatureFlag(FeatureFlagNames.rewardsEnabled); - - // Use the flag value - if (isFeatureEnabled) { - // ... feature logic - } -}; -``` - -## Steps to Use Feature Flags - -1. **Add the flag name** to the `FeatureFlagNames` enum in `app/components/hooks/FeatureFlags/useFeatureFlag.ts`: - ```typescript - export enum FeatureFlagNames { - rewardsEnabled = 'rewardsEnabled', - myNewFeature = 'myNewFeature', // Add your new flag here - } - ``` - -2. **Use the hook** in your component: - ```typescript - const isMyFeatureEnabled = useFeatureFlag(FeatureFlagNames.myNewFeature); - ``` - -3. **Do NOT** create a selector for the feature flag - -## Migration Pattern - -If you encounter existing feature flag selectors, prefer migrating to the hook: - -```typescript -// ❌ Old pattern (existing code - do not replicate) -const isFeatureEnabled = useSelector(selectMyFeatureEnabledFlag); - -// ✅ New pattern (use this instead) -const isFeatureEnabled = useFeatureFlag(FeatureFlagNames.myNewFeature); -``` - -## Enforcement - -- **REJECT** any code that creates new `createSelector` instances for feature flags -- **REJECT** any new files in `app/selectors/featureFlagController/` directories -- **REQUIRE** use of `useFeatureFlag` hook for all feature flag access -- **REQUIRE** adding flag names to `FeatureFlagNames` enum before use - -## Exception - -The only exception is the base selector `selectRemoteFeatureFlags` in `app/selectors/featureFlagController/index.ts`, which is used internally by the `useFeatureFlag` hook infrastructure. diff --git a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx index 75bad8b1331..6e8668efef8 100644 --- a/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx +++ b/app/components/UI/Perps/Views/PerpsHomeView/PerpsHomeView.tsx @@ -22,7 +22,9 @@ import { ButtonSize, } from '@metamask/design-system-react-native'; import { useStyles } from '../../../../../component-library/hooks'; +import { TextColor } from '../../../../../component-library/components/Texts/Text'; import { strings } from '../../../../../../locales/i18n'; +import { formatPnl, formatPercentage } from '../../utils/formatUtils'; import Routes from '../../../../../constants/navigation/Routes'; import { usePerpsHomeData, @@ -93,6 +95,10 @@ const PerpsHomeView = () => { const totalBalance = perpsAccount?.totalBalance || '0'; const isBalanceEmpty = BigNumber(totalBalance).isZero(); + // Calculate P&L for positions subtitle + const unrealizedPnl = perpsAccount?.unrealizedPnl || '0'; + const roe = parseFloat(perpsAccount?.returnOnEquity || '0'); + // Fetch all home screen data const { positions, @@ -106,6 +112,40 @@ const PerpsHomeView = () => { isLoading, } = usePerpsHomeData({}); + // Calculate positions subtitle with P&L + const hasPositions = positions.length > 0; + const { positionsSubtitle, positionsSubtitleColor, positionsSubtitleSuffix } = + useMemo(() => { + const pnlNum = parseFloat(unrealizedPnl); + const isPnlZero = BigNumber(unrealizedPnl).isZero(); + + // Only show subtitle when there are positions and P&L is non-zero + if (!hasPositions || isPnlZero) { + return { + positionsSubtitle: undefined, + positionsSubtitleColor: undefined, + positionsSubtitleSuffix: undefined, + }; + } + + const color = + pnlNum > 0 + ? TextColor.Success + : pnlNum < 0 + ? TextColor.Error + : TextColor.Alternative; + + // Format: "-$18.47 (2.1%)" colored + "Unrealized PnL" in default color + const subtitle = `${formatPnl(pnlNum)} (${formatPercentage(roe, 1)})`; + const suffix = strings('perps.unrealized_pnl'); + + return { + positionsSubtitle: subtitle, + positionsSubtitleColor: color, + positionsSubtitleSuffix: suffix, + }; + }, [hasPositions, unrealizedPnl, roe]); + // Determine if any data is loading for initial load tracking // Orders and activity load via WebSocket instantly, only track positions and markets const isAnyLoading = isLoading.positions || isLoading.markets; @@ -248,13 +288,16 @@ const PerpsHomeView = () => { > {/* Balance Actions Component */} {/* Positions Section */} = () => { throttleMs: 1000, }); + // Get current price from the last candle's close price for chart synchronization + // This ensures the current price line matches the live candle close price exactly + const chartCurrentPrice = useMemo(() => { + if (!candleData?.candles?.length) return 0; + const lastCandle = candleData.candles.at(-1); + return lastCandle?.close ? Number.parseFloat(lastCandle.close) : 0; + }, [candleData]); + // Auto-zoom to latest candle when interval changes and new data arrives // This ensures the chart shows the most recent data after interval change useEffect(() => { @@ -394,10 +402,10 @@ const PerpsMarketDetailsView: React.FC = () => { }, [existingPosition, orderFills]); // Compute TP/SL lines for the chart based on existing position - // Always include currentPrice to ensure chart price line matches header (TAT-2112) + // Use chartCurrentPrice (from candle close) to ensure price line syncs with live candle const tpslLines = useMemo(() => { - const currentPriceStr = - currentPrice > 0 ? currentPrice.toString() : undefined; + const chartPriceStr = + chartCurrentPrice > 0 ? chartCurrentPrice.toString() : undefined; if (existingPosition) { return { @@ -405,13 +413,13 @@ const PerpsMarketDetailsView: React.FC = () => { takeProfitPrice: existingPosition.takeProfitPrice, stopLossPrice: existingPosition.stopLossPrice, liquidationPrice: existingPosition.liquidationPrice || undefined, - currentPrice: currentPriceStr, + currentPrice: chartPriceStr, }; } // Even without position, show current price line on chart - return currentPriceStr ? { currentPrice: currentPriceStr } : undefined; - }, [existingPosition, currentPrice]); + return chartPriceStr ? { currentPrice: chartPriceStr } : undefined; + }, [existingPosition, chartCurrentPrice]); // Stop loss prompt banner logic // Hook handles visibility orchestration including fade-out animation @@ -906,6 +914,7 @@ const PerpsMarketDetailsView: React.FC = () => { onFullscreenPress={handleFullscreenChartOpen} isFavorite={isWatchlist} testID={PerpsMarketDetailsViewSelectorsIDs.HEADER} + currentPrice={chartCurrentPrice} /> diff --git a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx index 15767bb1600..8cbe5d202d3 100644 --- a/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx +++ b/app/components/UI/Perps/Views/PerpsOrderBookView/PerpsOrderBookView.tsx @@ -341,7 +341,11 @@ const PerpsOrderBookView: React.FC = ({ return ( {market ? ( - + ) : ( = ({ return ( {/* Market Header */} - {market && } + {market && ( + + )} {/* Controls Row - Unit Toggle and Grouping */} diff --git a/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.test.tsx b/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.test.tsx index b5ffc657404..b42af592c17 100644 --- a/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.test.tsx +++ b/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.test.tsx @@ -30,26 +30,10 @@ describe('LivePriceHeader', () => { it('should render without crashing', () => { mockUsePerpsLivePrices.mockReturnValue({}); - const component = render(); + const component = render(); expect(component).toBeDefined(); }); - it('should show placeholders when no price data available', () => { - mockUsePerpsLivePrices.mockReturnValue({}); - const { getByText } = render(); - expect(getByText('$---')).toBeTruthy(); - expect(getByText('--%')).toBeTruthy(); - }); - - it('should show placeholders when price data is undefined', () => { - mockUsePerpsLivePrices.mockReturnValue({ - ETH: undefined as unknown as PriceUpdate, - }); - const { getByText } = render(); - expect(getByText('$---')).toBeTruthy(); - expect(getByText('--%')).toBeTruthy(); - }); - it('should show placeholders when price is invalid (zero)', () => { mockUsePerpsLivePrices.mockReturnValue({ ETH: { @@ -59,35 +43,25 @@ describe('LivePriceHeader', () => { timestamp: Date.now(), }, }); - const { getByText } = render(); + const { getByText } = render( + , + ); expect(getByText('$---')).toBeTruthy(); expect(getByText('--%')).toBeTruthy(); }); it('should show placeholders when price is invalid (negative)', () => { - mockUsePerpsLivePrices.mockReturnValue({ - ETH: { - coin: 'ETH', - price: '-100', - percentChange24h: '5', - timestamp: Date.now(), - }, - }); - const { getByText } = render(); + const { getByText } = render( + , + ); expect(getByText('$---')).toBeTruthy(); expect(getByText('--%')).toBeTruthy(); }); it('should show placeholders when price is invalid (NaN)', () => { - mockUsePerpsLivePrices.mockReturnValue({ - ETH: { - coin: 'ETH', - price: 'invalid', - percentChange24h: '5', - timestamp: Date.now(), - }, - }); - const { getByText } = render(); + const { getByText } = render( + , + ); expect(getByText('$---')).toBeTruthy(); expect(getByText('--%')).toBeTruthy(); }); @@ -101,7 +75,9 @@ describe('LivePriceHeader', () => { timestamp: Date.now(), }, }); - const { getByText } = render(); + const { getByText } = render( + , + ); expect(getByText('$3,000')).toBeTruthy(); // 4 sig figs, no trailing zeros expect(getByText('+5.50%')).toBeTruthy(); }); @@ -115,7 +91,9 @@ describe('LivePriceHeader', () => { timestamp: Date.now(), }, }); - const { getByText } = render(); + const { getByText } = render( + , + ); expect(getByText('$2,500')).toBeTruthy(); // 4 sig figs, no trailing zeros expect(getByText('-3.20%')).toBeTruthy(); }); @@ -129,7 +107,9 @@ describe('LivePriceHeader', () => { timestamp: Date.now(), }, }); - const { getByText } = render(); + const { getByText } = render( + , + ); expect(getByText('$2,000')).toBeTruthy(); // 4 sig figs, no trailing zeros expect(getByText('+0.00%')).toBeTruthy(); }); @@ -143,45 +123,22 @@ describe('LivePriceHeader', () => { timestamp: Date.now(), }, }); - const { getByText } = render(); - expect(getByText('$100')).toBeTruthy(); // 4 sig figs, no trailing zeros - expect(getByText('+2.10%')).toBeTruthy(); - }); - - it('uses fallback price but shows loading state for change when no live data', () => { - mockUsePerpsLivePrices.mockReturnValue({}); const { getByText } = render( - , + , ); - expect(getByText('$1,500')).toBeTruthy(); // 4 sig figs, no trailing zeros - expect(getByText('--%')).toBeTruthy(); + expect(getByText('$100')).toBeTruthy(); // 4 sig figs, no trailing zeros + expect(getByText('+2.10%')).toBeTruthy(); }); it('should show placeholders when fallback price is invalid', () => { mockUsePerpsLivePrices.mockReturnValue({}); const { getByText } = render( - , + , ); expect(getByText('$---')).toBeTruthy(); expect(getByText('--%')).toBeTruthy(); }); - it('should prefer live data over fallback', () => { - mockUsePerpsLivePrices.mockReturnValue({ - BTC: { - coin: 'BTC', - price: '50000', - percentChange24h: '3.0', - timestamp: Date.now(), - }, - }); - const { getByText } = render( - , - ); - expect(getByText('$50,000')).toBeTruthy(); // 4 sig figs, no trailing zeros - expect(getByText('+3.00%')).toBeTruthy(); - }); - describe('Color behavior for percentage change', () => { it('uses neutral color for loading state when percentChange is undefined', () => { mockUsePerpsLivePrices.mockReturnValue({ @@ -193,7 +150,9 @@ describe('LivePriceHeader', () => { }, }); - const { UNSAFE_getAllByType } = render(); + const { UNSAFE_getAllByType } = render( + , + ); const textElements = UNSAFE_getAllByType(Text); const changeText = textElements.find((el) => el.props.children === '--%'); @@ -205,7 +164,7 @@ describe('LivePriceHeader', () => { mockUsePerpsLivePrices.mockReturnValue({}); const { UNSAFE_getAllByType } = render( - , + , ); const textElements = UNSAFE_getAllByType(Text); @@ -224,7 +183,9 @@ describe('LivePriceHeader', () => { }, }); - const { UNSAFE_getAllByType } = render(); + const { UNSAFE_getAllByType } = render( + , + ); const textElements = UNSAFE_getAllByType(Text); const changeText = textElements.find( @@ -244,7 +205,9 @@ describe('LivePriceHeader', () => { }, }); - const { UNSAFE_getAllByType } = render(); + const { UNSAFE_getAllByType } = render( + , + ); const textElements = UNSAFE_getAllByType(Text); const changeText = textElements.find( @@ -264,7 +227,9 @@ describe('LivePriceHeader', () => { }, }); - const { UNSAFE_getAllByType } = render(); + const { UNSAFE_getAllByType } = render( + , + ); const textElements = UNSAFE_getAllByType(Text); const changeText = textElements.find( @@ -286,7 +251,9 @@ describe('LivePriceHeader', () => { }, }); - const { getByText } = render(); + const { getByText } = render( + , + ); expect(getByText('$3,000')).toBeTruthy(); expect(getByText('--%')).toBeTruthy(); @@ -301,7 +268,9 @@ describe('LivePriceHeader', () => { } as PriceUpdate, }); - const { getByText } = render(); + const { getByText } = render( + , + ); expect(getByText('$2,500')).toBeTruthy(); expect(getByText('--%')).toBeTruthy(); @@ -318,7 +287,7 @@ describe('LivePriceHeader', () => { }); const { getByText, queryByText } = render( - , + , ); expect(getByText('$2,000')).toBeTruthy(); @@ -330,32 +299,13 @@ describe('LivePriceHeader', () => { mockUsePerpsLivePrices.mockReturnValue({}); const { getByText } = render( - , + , ); expect(getByText('$1,800')).toBeTruthy(); expect(getByText('--%')).toBeTruthy(); }); - it('uses live percentChange when available', () => { - mockUsePerpsLivePrices.mockReturnValue({ - BTC: { - coin: 'BTC', - price: '55000', - percentChange24h: '4.2', - timestamp: Date.now(), - }, - }); - - const { getByText, queryByText } = render( - , - ); - - expect(getByText('$55,000')).toBeTruthy(); - expect(getByText('+4.20%')).toBeTruthy(); - expect(queryByText('--%')).toBeNull(); - }); - it('displays loading state when live price exists but percentChange is undefined', () => { mockUsePerpsLivePrices.mockReturnValue({ ETH: { @@ -367,7 +317,7 @@ describe('LivePriceHeader', () => { }); const { getByText } = render( - , + , ); expect(getByText('$3,100')).toBeTruthy(); diff --git a/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.tsx b/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.tsx index acf17b8fb1e..15faa66a86e 100644 --- a/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.tsx +++ b/app/components/UI/Perps/components/LivePriceDisplay/LivePriceHeader.tsx @@ -15,10 +15,11 @@ import { PERPS_CONSTANTS } from '../../constants/perpsConfig'; interface LivePriceHeaderProps { symbol: string; - fallbackPrice?: string; testIDPrice?: string; testIDChange?: string; throttleMs?: number; + /** Current price from candle stream - syncs header with chart */ + currentPrice: number; } const styleSheet = () => @@ -32,16 +33,17 @@ const styleSheet = () => /** * Component that displays live price and change for header - * Subscribes to price stream independently to avoid parent re-renders + * Uses currentPrice prop from candle stream, subscribes to price stream for 24h change only */ const LivePriceHeader: React.FC = ({ symbol, - fallbackPrice = '0', testIDPrice, testIDChange, throttleMs = 1000, // Balanced updates for header (1 update per second) + currentPrice, }) => { const { styles } = useStyles(styleSheet, {}); + // Subscribe to price stream only for 24h change percentage const prices = usePerpsLivePrices({ symbols: [symbol], throttleMs, @@ -49,18 +51,13 @@ const LivePriceHeader: React.FC = ({ const priceData = prices[symbol]; - // Use fallback data if no live data yet - const displayPrice = priceData - ? parseFloat(priceData.price) - : parseFloat(fallbackPrice); - // Use null to indicate loading state - only use actual values (including 0) when available // When we have live price data, only use percentChange from that data - don't fall back - const displayChange = priceData - ? priceData.percentChange24h !== undefined - ? parseFloat(priceData.percentChange24h) - : null - : null; + const displayChange = useMemo(() => { + if (!priceData) return null; + if (priceData.percentChange24h === undefined) return null; + return Number.parseFloat(priceData.percentChange24h); + }, [priceData]); // Only determine change color when we have actual data (not loading) const isPositiveChange = displayChange !== null && displayChange >= 0; @@ -74,19 +71,19 @@ const LivePriceHeader: React.FC = ({ // Format price display with edge case handling const formattedPrice = useMemo(() => { // Handle invalid or edge case values - if (!displayPrice || displayPrice <= 0 || !Number.isFinite(displayPrice)) { + if (!currentPrice || currentPrice <= 0 || !Number.isFinite(currentPrice)) { return PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY; } try { - return formatPerpsFiat(displayPrice, { + return formatPerpsFiat(currentPrice, { ranges: PRICE_RANGES_UNIVERSAL, }); } catch { // Fallback if formatPrice throws return PERPS_CONSTANTS.FALLBACK_PRICE_DISPLAY; } - }, [displayPrice]); + }, [currentPrice]); const formattedChange = useMemo(() => { // If displayChange is null, we're still loading - show loading indicator @@ -94,7 +91,7 @@ const LivePriceHeader: React.FC = ({ return PERPS_CONSTANTS.FALLBACK_PERCENTAGE_DISPLAY; } - if (!displayPrice || displayPrice <= 0 || !Number.isFinite(displayPrice)) { + if (!currentPrice || currentPrice <= 0 || !Number.isFinite(currentPrice)) { return PERPS_CONSTANTS.FALLBACK_PERCENTAGE_DISPLAY; } @@ -103,7 +100,7 @@ const LivePriceHeader: React.FC = ({ } catch { return PERPS_CONSTANTS.FALLBACK_PERCENTAGE_DISPLAY; } - }, [displayPrice, displayChange]); + }, [currentPrice, displayChange]); return ( diff --git a/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.test.tsx b/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.test.tsx index d5886f08949..af78f06b81d 100644 --- a/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.test.tsx +++ b/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.test.tsx @@ -3,6 +3,8 @@ import { render, fireEvent } from '@testing-library/react-native'; import { View, Text } from 'react-native'; import PerpsHomeSection from './PerpsHomeSection'; +import { TextColor } from '../../../../../component-library/components/Texts/Text'; + describe('PerpsHomeSection', () => { const mockSkeleton = () => ; const mockChildren = Content; @@ -403,4 +405,137 @@ describe('PerpsHomeSection', () => { expect(mockOnActionPress).not.toHaveBeenCalled(); }); }); + + describe('subtitle rendering', () => { + it('renders subtitle when provided', () => { + const { getByText } = render( + + {mockChildren} + , + ); + + expect(getByText('-$18.47 (2.1%) Unrealized P&L')).toBeTruthy(); + }); + + it('does not render subtitle when not provided', () => { + const { queryByText } = render( + + {mockChildren} + , + ); + + // Should only have the title, no subtitle + expect(queryByText('Test Section')).toBeTruthy(); + }); + + it('renders subtitle with custom color', () => { + const { getByText } = render( + + {mockChildren} + , + ); + + expect(getByText('+$50.00 (5.0%) Unrealized P&L')).toBeTruthy(); + }); + + it('applies subtitleTestID when provided', () => { + const { getByTestId } = render( + + {mockChildren} + , + ); + + expect(getByTestId('custom-subtitle-testid')).toBeTruthy(); + }); + + it('renders subtitle alongside title and action button', () => { + const mockOnActionPress = jest.fn(); + + const { getByText } = render( + + {mockChildren} + , + ); + + expect(getByText('Positions')).toBeTruthy(); + expect(getByText('-$18.47 (2.1%)')).toBeTruthy(); + + // Action should still work + fireEvent.press(getByText('Positions')); + expect(mockOnActionPress).toHaveBeenCalledTimes(1); + }); + + it('renders subtitle with suffix', () => { + const { getByTestId } = render( + + {mockChildren} + , + ); + + // Verify both subtitle and suffix are rendered via testIDs + expect(getByTestId('test-subtitle')).toBeTruthy(); + expect(getByTestId('test-subtitle-suffix')).toBeTruthy(); + }); + + it('does not render suffix when subtitle is not provided', () => { + const { queryByTestId } = render( + + {mockChildren} + , + ); + + // Suffix should not render without a subtitle + expect(queryByTestId('test-subtitle')).toBeNull(); + expect(queryByTestId('test-subtitle-suffix')).toBeNull(); + }); + }); }); diff --git a/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.tsx b/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.tsx index fde4687a1e4..0e45614a26e 100644 --- a/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.tsx +++ b/app/components/UI/Perps/components/PerpsHomeSection/PerpsHomeSection.tsx @@ -15,6 +15,22 @@ export interface PerpsHomeSectionProps { * Section title */ title: string; + /** + * Optional subtitle text (e.g., P&L value and percentage) + */ + subtitle?: string; + /** + * Color for subtitle text (e.g., Success for profit, Error for loss) + */ + subtitleColor?: TextColor; + /** + * Optional suffix for subtitle (rendered in default color, e.g., "Unrealized PnL") + */ + subtitleSuffix?: string; + /** + * Test ID for subtitle element + */ + subtitleTestID?: string; /** * Whether the section is loading */ @@ -51,14 +67,16 @@ const styles = StyleSheet.create({ section: { marginBottom: 24, }, - header: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', + headerContainer: { paddingHorizontal: 16, marginBottom: 8, marginTop: 12, }, + titleRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, content: { // Content styling handled by children }, @@ -89,6 +107,10 @@ const styles = StyleSheet.create({ */ const PerpsHomeSection: React.FC = ({ title, + subtitle, + subtitleColor = TextColor.Alternative, + subtitleSuffix, + subtitleTestID, isLoading, isEmpty, showWhenEmpty = false, @@ -104,7 +126,8 @@ const PerpsHomeSection: React.FC = ({ const showAction = onActionPress && !isLoading && !isEmpty; - const headerContent = ( + // Title row content (pressable when action is available) + const titleRowContent = ( <> {title} @@ -122,13 +145,37 @@ const PerpsHomeSection: React.FC = ({ return ( {/* Section Header */} - {showAction ? ( - - {headerContent} - - ) : ( - {headerContent} - )} + + {/* Title row - only this is pressable */} + {showAction ? ( + + {titleRowContent} + + ) : ( + {titleRowContent} + )} + + {/* Subtitle - NOT pressable */} + {subtitle && ( + + {subtitle} + {subtitleSuffix && ( + + {' '} + {subtitleSuffix} + + )} + + )} + {/* Section Content */} diff --git a/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx b/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx index bf25e778eb6..fc0a8ddbdd3 100644 --- a/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx +++ b/app/components/UI/Perps/components/PerpsMarketBalanceActions/PerpsMarketBalanceActions.tsx @@ -29,14 +29,9 @@ import PerpsBottomSheetTooltip from '../PerpsBottomSheetTooltip'; import { usePerpsLiveAccount } from '../../hooks/stream'; import { formatPerpsFiat, - formatPnl, - formatPercentage, PRICE_RANGES_MINIMAL_VIEW, } from '../../utils/formatUtils'; -import type { - PerpsNavigationParamList, - Position, -} from '../../controllers/types'; +import type { PerpsNavigationParamList } from '../../controllers/types'; import { PerpsMarketBalanceActionsSelectorsIDs } from '../../../../../../e2e/selectors/Perps/Perps.selectors'; import { BigNumber } from 'bignumber.js'; import { INITIAL_AMOUNT_UI_PROGRESS } from '../../constants/hyperLiquidConfig'; @@ -51,7 +46,6 @@ import { RootState } from '../../../../../reducers'; import { selectSelectedInternalAccountByScope } from '../../../../../selectors/multichainAccounts/accounts'; interface PerpsMarketBalanceActionsProps { - positions?: Position[]; showActionButtons?: boolean; } @@ -76,7 +70,6 @@ const PerpsMarketBalanceActionsSkeleton: React.FC = () => { }; const PerpsMarketBalanceActions: React.FC = ({ - positions = [], showActionButtons = true, }) => { const tw = useTailwind(); @@ -218,17 +211,7 @@ const PerpsMarketBalanceActions: React.FC = ({ const totalBalance = perpsAccount?.totalBalance || '0'; const availableBalance = perpsAccount?.availableBalance || '0'; - const unrealizedPnl = perpsAccount?.unrealizedPnl || '0'; - const roe = parseFloat(perpsAccount?.returnOnEquity || '0'); const isBalanceEmpty = BigNumber(totalBalance).isZero(); - const hasPositions = positions.length > 0; - - const pnlNum = useMemo(() => parseFloat(unrealizedPnl), [unrealizedPnl]); - const pnlColor = useMemo(() => { - if (pnlNum > 0) return TextColor.Success; - if (pnlNum < 0) return TextColor.Error; - return TextColor.Alternative; - }, [pnlNum]); const handleLearnMore = useCallback(() => { navigation.navigate(Routes.PERPS.TUTORIAL, { @@ -346,38 +329,20 @@ const PerpsMarketBalanceActions: React.FC = ({ {formatPerpsFiat(totalBalance)} - - - {formatPerpsFiat(availableBalance, { - ranges: PRICE_RANGES_MINIMAL_VIEW, - stripTrailingZeros: false, - })}{' '} - {strings('perps.available')} - - {hasPositions && !BigNumber(unrealizedPnl).isZero() && ( - <> - - {' · P&L '} - - - {formatPnl(pnlNum)} ({formatPercentage(roe, 1)}) - - - )} - + + {formatPerpsFiat(availableBalance, { + ranges: PRICE_RANGES_MINIMAL_VIEW, + stripTrailingZeros: false, + })}{' '} + {strings('perps.available')} + {/* Action Buttons */} {showActionButtons && ( { , { state: initialState }, ); @@ -47,6 +48,7 @@ describe('PerpsMarketHeader', () => { market={mockMarket} onBackPress={onBackPress} testID={PerpsMarketHeaderSelectorsIDs.CONTAINER} + currentPrice={45000} />, { state: initialState }, ); @@ -64,6 +66,7 @@ describe('PerpsMarketHeader', () => { market={mockMarket} onMorePress={onMorePress} testID={PerpsMarketHeaderSelectorsIDs.CONTAINER} + currentPrice={45000} />, { state: initialState }, ); diff --git a/app/components/UI/Perps/components/PerpsMarketHeader/PerpsMarketHeader.tsx b/app/components/UI/Perps/components/PerpsMarketHeader/PerpsMarketHeader.tsx index d64cf2a074e..839887f9b94 100644 --- a/app/components/UI/Perps/components/PerpsMarketHeader/PerpsMarketHeader.tsx +++ b/app/components/UI/Perps/components/PerpsMarketHeader/PerpsMarketHeader.tsx @@ -29,6 +29,8 @@ interface PerpsMarketHeaderProps { onFullscreenPress?: () => void; isFavorite?: boolean; testID?: string; + /** Current price from candle stream - syncs header with chart */ + currentPrice: number; } const PerpsMarketHeader: React.FC = ({ @@ -39,6 +41,7 @@ const PerpsMarketHeader: React.FC = ({ onFullscreenPress, isFavorite = false, testID, + currentPrice, }) => { const { styles } = useStyles(styleSheet, {}); @@ -80,10 +83,10 @@ const PerpsMarketHeader: React.FC = ({ diff --git a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts index 2bfe7e8d46f..bfcec001b34 100644 --- a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts +++ b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.test.ts @@ -10,7 +10,7 @@ import { type PublishBatchHookTransaction, } from '@metamask/transaction-controller'; -import { toHex } from '@metamask/controller-utils'; +import { ORIGIN_METAMASK, toHex } from '@metamask/controller-utils'; import { MOCK_ANY_NAMESPACE, MockAnyNamespace } from '@metamask/messenger'; import { Hex } from '@metamask/utils'; import { selectSwapsChainFeatureFlags } from '../../../../reducers/swaps'; @@ -487,20 +487,87 @@ describe('Transaction Controller Init', () => { expect(updateTransactionsProp).toBe(true); }); - it('determines if automatic gas fee update is enabled based on transaction type', () => { - const option = testConstructorOption('isAutomaticGasFeeUpdateEnabled'); - const isEnabledFn = option as ({ type }: { type: string }) => boolean; + describe('isAutomaticGasFeeUpdateEnabled', () => { + it('returns true for redesigned transaction types', () => { + const option = testConstructorOption('isAutomaticGasFeeUpdateEnabled'); + const isEnabledFn = option as ({ + type, + }: { + type: string; + origin?: string; + }) => boolean; + + expect(isEnabledFn({ type: TransactionType.stakingDeposit })).toBe(true); + expect(isEnabledFn({ type: TransactionType.stakingUnstake })).toBe(true); + expect(isEnabledFn({ type: TransactionType.stakingClaim })).toBe(true); + expect(isEnabledFn({ type: TransactionType.contractInteraction })).toBe( + true, + ); + }); - // Redesigned transaction types - expect(isEnabledFn({ type: TransactionType.stakingDeposit })).toBe(true); - expect(isEnabledFn({ type: TransactionType.stakingUnstake })).toBe(true); - expect(isEnabledFn({ type: TransactionType.stakingClaim })).toBe(true); - expect(isEnabledFn({ type: TransactionType.contractInteraction })).toBe( - true, - ); + it('returns false for non-redesigned transaction types', () => { + const option = testConstructorOption('isAutomaticGasFeeUpdateEnabled'); + const isEnabledFn = option as ({ + type, + }: { + type: string; + origin?: string; + }) => boolean; + + expect(isEnabledFn({ type: TransactionType.bridge })).toBe(false); + }); + + it('returns false for transaction with nested relayDeposit type', () => { + const option = testConstructorOption('isAutomaticGasFeeUpdateEnabled'); + const isEnabledFn = option as (transaction: { + type: string; + origin?: string; + nestedTransactions?: { type: string }[]; + }) => boolean; + + const result = isEnabledFn({ + type: TransactionType.contractInteraction, + nestedTransactions: [{ type: TransactionType.relayDeposit }], + }); + + expect(result).toBe(false); + }); + + it('returns false for tokenMethodApprove with ORIGIN_METAMASK', () => { + const option = testConstructorOption('isAutomaticGasFeeUpdateEnabled'); + const isEnabledFn = option as ({ + type, + origin, + }: { + type: string; + origin?: string; + }) => boolean; + + const result = isEnabledFn({ + type: TransactionType.tokenMethodApprove, + origin: ORIGIN_METAMASK, + }); + + expect(result).toBe(false); + }); + + it('returns true for tokenMethodApprove with non-MetaMask origin', () => { + const option = testConstructorOption('isAutomaticGasFeeUpdateEnabled'); + const isEnabledFn = option as ({ + type, + origin, + }: { + type: string; + origin?: string; + }) => boolean; + + const result = isEnabledFn({ + type: TransactionType.tokenMethodApprove, + origin: 'https://external-dapp.com', + }); - // Non-redesigned transaction types - expect(isEnabledFn({ type: TransactionType.bridge })).toBe(false); + expect(result).toBe(true); + }); }); it('gets network state from network controller on option getNetworkState', () => { diff --git a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts index 057ebe79764..b80b7371779 100644 --- a/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts +++ b/app/core/Engine/controllers/transaction-controller/transaction-controller-init.ts @@ -50,7 +50,8 @@ import { trace } from '../../../../util/trace'; import { Delegation7702PublishHook } from '../../../../util/transactions/hooks/delegation-7702-publish'; import { isSendBundleSupported } from '../../../../util/transactions/sentinel-api'; import { NetworkClientId } from '@metamask/network-controller'; -import { toHex } from '@metamask/controller-utils'; +import { ORIGIN_METAMASK, toHex } from '@metamask/controller-utils'; +import { hasTransactionType } from '../../../../components/Views/confirmations/utils/transaction'; export const TransactionControllerInit: ControllerInitFunction< TransactionController, @@ -78,8 +79,7 @@ export const TransactionControllerInit: ControllerInitFunction< try { const transactionController: TransactionController = new TransactionController({ - isAutomaticGasFeeUpdateEnabled: ({ type }) => - REDESIGNED_TRANSACTION_TYPES.includes(type as TransactionType), + isAutomaticGasFeeUpdateEnabled, disableHistory: true, disableSendFlowHistory: true, disableSwaps: true, @@ -359,6 +359,23 @@ function beforeSign( return predictController.beforeSign(hookRequest); } +function isAutomaticGasFeeUpdateEnabled(transaction: TransactionMeta) { + if (hasTransactionType(transaction, [TransactionType.relayDeposit])) { + return false; + } + + if ( + transaction.origin === ORIGIN_METAMASK && + transaction.type === TransactionType.tokenMethodApprove + ) { + return false; + } + + return REDESIGNED_TRANSACTION_TYPES.includes( + transaction.type as TransactionType, + ); +} + function addTransactionControllerListeners( transactionEventHandlerRequest: TransactionEventHandlerRequest, ) { diff --git a/e2e/selectors/Perps/Perps.selectors.ts b/e2e/selectors/Perps/Perps.selectors.ts index 4f09fe707f3..deabae97d83 100644 --- a/e2e/selectors/Perps/Perps.selectors.ts +++ b/e2e/selectors/Perps/Perps.selectors.ts @@ -220,6 +220,7 @@ export const PerpsHomeViewSelectorsIDs = { SCROLL_CONTENT: 'scroll-content', WITHDRAW_BUTTON: 'perps-home-withdraw-button', ADD_FUNDS_BUTTON: 'perps-home-add-funds-button', + POSITIONS_PNL_VALUE: 'perps-home-positions-pnl-value', // TabBar mock items (for testing) TAB_BAR_WALLET: 'tab-bar-item-wallet', TAB_BAR_BROWSER: 'tab-bar-item-browser',