diff --git a/app/components/Views/confirmations/components/info/typed-sign-v3v4/simulation/components/animated-pulse/animated-pulse.test.tsx b/app/components/Views/confirmations/components/UI/animated-pulse/animated-pulse.test.tsx similarity index 80% rename from app/components/Views/confirmations/components/info/typed-sign-v3v4/simulation/components/animated-pulse/animated-pulse.test.tsx rename to app/components/Views/confirmations/components/UI/animated-pulse/animated-pulse.test.tsx index d4916bb4a937..e7bf39b9f3ec 100644 --- a/app/components/Views/confirmations/components/info/typed-sign-v3v4/simulation/components/animated-pulse/animated-pulse.test.tsx +++ b/app/components/Views/confirmations/components/UI/animated-pulse/animated-pulse.test.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { render } from '@testing-library/react-native'; -import Text from '../../../../../../../../../component-library/components/Texts/Text'; +import Text from '../../../../../../component-library/components/Texts/Text'; import AnimatedPulse from './animated-pulse'; describe('AnimatedPulse', () => { diff --git a/app/components/Views/confirmations/components/UI/animated-pulse/animated-pulse.tsx b/app/components/Views/confirmations/components/UI/animated-pulse/animated-pulse.tsx new file mode 100644 index 000000000000..bc62610afc87 --- /dev/null +++ b/app/components/Views/confirmations/components/UI/animated-pulse/animated-pulse.tsx @@ -0,0 +1,135 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import { Animated, View, ViewProps } from 'react-native'; + +interface AnimatedPulseProps extends ViewProps { + children: React.ReactNode; + isPulsing?: boolean; + minCycles?: number; + preventPulse?: boolean; +} + +const DURATION = { + fadeIn: 750, + fadeOut: 750, + fadeInFinal: 750, +} as const; + +const AnimatedPulse = ({ + children, + isPulsing = true, + minCycles = 2, // Default to 2 cycles minimum for a better visual effect + preventPulse = false, + ...props +}: AnimatedPulseProps) => { + const opacity = useRef(new Animated.Value(0.3)).current; + const currentAnimationRef = useRef(null); + const isCurrentlyPulsingRef = useRef(isPulsing); + const cyclesNeededRef = useRef(isPulsing ? minCycles : 0); + const cyclesCompletedRef = useRef(0); + + const cleanup = useCallback(() => { + if (currentAnimationRef.current) { + currentAnimationRef.current.stop(); + currentAnimationRef.current = null; + } + }, []); + + useEffect(() => { + if (isCurrentlyPulsingRef.current && !isPulsing) { + // Set the number of cycles we need to complete + cyclesNeededRef.current = Math.max( + minCycles, + cyclesCompletedRef.current + 1, + ); + } else if (!isCurrentlyPulsingRef.current && isPulsing) { + // Reset cycle count when starting to pulse again + cyclesCompletedRef.current = 0; + } + isCurrentlyPulsingRef.current = isPulsing; + }, [isPulsing, minCycles]); + + // Start a single pulse cycle and decide what to do next + const runSinglePulseCycle = useCallback(() => { + // Create and store the new animation + const sequence = Animated.sequence([ + Animated.timing(opacity, { + toValue: 1, + duration: DURATION.fadeIn, + useNativeDriver: true, + }), + Animated.timing(opacity, { + toValue: 0.3, + duration: DURATION.fadeOut, + useNativeDriver: true, + }), + ]); + + currentAnimationRef.current = sequence; + + // Start the animation + sequence.start(({ finished }) => { + if (finished) { + // Increment completed cycles + cyclesCompletedRef.current += 1; + + // Continue pulsing if: + // 1. isPulsing is true, OR + // 2. We haven't completed the minimum required cycles + if ( + isCurrentlyPulsingRef.current || + cyclesCompletedRef.current < cyclesNeededRef.current + ) { + // Schedule the next cycle + runSinglePulseCycle(); + } else { + // We're done pulsing and have completed required cycles + // Fade to full opacity + Animated.timing(opacity, { + toValue: 1, + duration: DURATION.fadeInFinal, + useNativeDriver: true, + }).start(); + + // Clear animation ref + currentAnimationRef.current = null; + } + } + }); + }, [opacity]); + + // Handle animation lifecycle + useEffect(() => { + // Only start animation if: + // 1. We should be pulsing, OR + // 2. We just stopped pulsing but need to complete cycles + const shouldAnimate = + isPulsing || + (!isPulsing && cyclesCompletedRef.current < cyclesNeededRef.current); + + if (shouldAnimate && !currentAnimationRef.current) { + runSinglePulseCycle(); + } else if (!shouldAnimate && !currentAnimationRef.current) { + // No animation needed, just ensure full opacity + Animated.timing(opacity, { + toValue: 1, + duration: DURATION.fadeInFinal, + useNativeDriver: true, + }).start(); + } + + // Cleanup + return cleanup; + }, [cleanup, isPulsing, opacity, runSinglePulseCycle]); + + if (preventPulse) { + return {children}; + } + + return ( + + {children} + + ); +}; + +export default AnimatedPulse; diff --git a/app/components/Views/confirmations/components/info/typed-sign-v3v4/simulation/components/animated-pulse/index.ts b/app/components/Views/confirmations/components/UI/animated-pulse/index.ts similarity index 100% rename from app/components/Views/confirmations/components/info/typed-sign-v3v4/simulation/components/animated-pulse/index.ts rename to app/components/Views/confirmations/components/UI/animated-pulse/index.ts diff --git a/app/components/Views/confirmations/components/confirm/confirm-component.tsx b/app/components/Views/confirmations/components/confirm/confirm-component.tsx index 21e290c72dce..46a2c8ecb966 100755 --- a/app/components/Views/confirmations/components/confirm/confirm-component.tsx +++ b/app/components/Views/confirmations/components/confirm/confirm-component.tsx @@ -8,6 +8,7 @@ import { UnstakeConfirmationViewProps } from '../../../../UI/Stake/Views/Unstake import useConfirmationAlerts from '../../hooks/alerts/useConfirmationAlerts'; import useApprovalRequest from '../../hooks/useApprovalRequest'; import { AlertsContextProvider } from '../../context/alert-system-context'; +import { ConfirmationContextProvider } from '../../context/confirmation-context'; import { LedgerContextProvider } from '../../context/ledger-context'; import { QRHardwareContextProvider } from '../../context/qr-hardware-context'; import { useConfirmActions } from '../../hooks/useConfirmActions'; @@ -29,22 +30,24 @@ const ConfirmWrapped = ({ const alerts = useConfirmationAlerts(); return ( - - - - - <ScrollView style={styles.scrollView} nestedScrollEnabled> - <TouchableWithoutFeedback> - <> - <GeneralAlertBanner /> - <Info route={route} /> - </> - </TouchableWithoutFeedback> - </ScrollView> - <Footer /> - </LedgerContextProvider> - </QRHardwareContextProvider> - </AlertsContextProvider> + <ConfirmationContextProvider> + <AlertsContextProvider alerts={alerts}> + <QRHardwareContextProvider> + <LedgerContextProvider> + <Title /> + <ScrollView style={styles.scrollView} nestedScrollEnabled> + <TouchableWithoutFeedback> + <> + <GeneralAlertBanner /> + <Info route={route} /> + </> + </TouchableWithoutFeedback> + </ScrollView> + <Footer /> + </LedgerContextProvider> + </QRHardwareContextProvider> + </AlertsContextProvider> + </ConfirmationContextProvider> ); }; diff --git a/app/components/Views/confirmations/components/footer/footer.test.tsx b/app/components/Views/confirmations/components/footer/footer.test.tsx index 41fb19a30131..0e610957d4fc 100644 --- a/app/components/Views/confirmations/components/footer/footer.test.tsx +++ b/app/components/Views/confirmations/components/footer/footer.test.tsx @@ -12,6 +12,7 @@ import { import * as QRHardwareHook from '../../context/qr-hardware-context/qr-hardware-context'; import { Footer } from './footer'; import { useAlerts } from '../../context/alert-system-context'; +import { useConfirmationContext } from '../../context/confirmation-context'; import { useAlertsConfirmed } from '../../../../hooks/useAlertsConfirmed'; import { Severity } from '../../types/alerts'; import { useConfirmationAlertMetrics } from '../../hooks/metrics/useConfirmationAlertMetrics'; @@ -37,6 +38,10 @@ jest.mock('../../context/alert-system-context', () => ({ useAlerts: jest.fn(), })); +jest.mock('../../context/confirmation-context', () => ({ + useConfirmationContext: jest.fn(), +})); + jest.mock('../../../../hooks/useAlertsConfirmed', () => ({ useAlertsConfirmed: jest.fn(), })); @@ -72,7 +77,13 @@ const mockAlerts = [ ]; describe('Footer', () => { + const mockUseConfirmationContext = jest.mocked(useConfirmationContext); beforeEach(() => { + jest.clearAllMocks(); + mockUseConfirmationContext.mockReturnValue({ + isTransactionValueUpdating: false, + setIsTransactionValueUpdating: jest.fn(), + }); (useAlerts as jest.Mock).mockReturnValue({ fieldAlerts: [], hasDangerAlerts: false, @@ -80,7 +91,6 @@ describe('Footer', () => { (useAlertsConfirmed as jest.Mock).mockReturnValue({ hasUnconfirmedDangerAlerts: false, }); - jest.clearAllMocks(); }); it('should render correctly', () => { @@ -168,6 +178,19 @@ describe('Footer', () => { ).toBe(true); }); + it('disables confirm button if there is a blocker alert', () => { + mockUseConfirmationContext.mockReturnValue({ + isTransactionValueUpdating: true, + setIsTransactionValueUpdating: jest.fn(), + }); + const { getByTestId } = renderWithProvider(<Footer />, { + state: personalSignatureConfirmationState, + }); + expect( + getByTestId(ConfirmationFooterSelectorIDs.CONFIRM_BUTTON).props.disabled, + ).toBe(true); + }); + describe('Confirm Alert Modal', () => { const baseMockUseAlerts = { alertKey: '', diff --git a/app/components/Views/confirmations/components/footer/footer.tsx b/app/components/Views/confirmations/components/footer/footer.tsx index a3f86fc92f5b..c517541f2a88 100644 --- a/app/components/Views/confirmations/components/footer/footer.tsx +++ b/app/components/Views/confirmations/components/footer/footer.tsx @@ -19,6 +19,7 @@ import ConfirmAlertModal from '../../components/modals/confirm-alert-modal'; import { useConfirmActions } from '../../hooks/useConfirmActions'; import { useConfirmationAlertMetrics } from '../../hooks/metrics/useConfirmationAlertMetrics'; import { useStandaloneConfirmation } from '../../hooks/ui/useStandaloneConfirmation'; +import { useConfirmationContext } from '../../context/confirmation-context'; import { useQRHardwareContext } from '../../context/qr-hardware-context/qr-hardware-context'; import { useSecurityAlertResponse } from '../../hooks/alerts/useSecurityAlertResponse'; import { useTransactionMetadataRequest } from '../../hooks/transactions/useTransactionMetadataRequest'; @@ -45,6 +46,7 @@ export const Footer = () => { const isStakingConfirmationBool = isStakingConfirmation( transactionMetadata?.type as string, ); + const { isTransactionValueUpdating } = useConfirmationContext(); const [confirmAlertModalVisible, setConfirmAlertModalVisible] = useState(false); @@ -121,7 +123,10 @@ export const Footer = () => { isDanger: securityAlertResponse?.result_type === ResultType.Malicious || hasDangerAlerts, - isDisabled: needsCameraPermission || hasBlockingAlerts, + isDisabled: + needsCameraPermission || + hasBlockingAlerts || + isTransactionValueUpdating, label: confirmButtonLabel(), size: ButtonSize.Lg, onPress: onSignConfirm, diff --git a/app/components/Views/confirmations/components/info/transfer/transfer.tsx b/app/components/Views/confirmations/components/info/transfer/transfer.tsx index 839d019c7932..8bc3142378f2 100644 --- a/app/components/Views/confirmations/components/info/transfer/transfer.tsx +++ b/app/components/Views/confirmations/components/info/transfer/transfer.tsx @@ -8,6 +8,7 @@ import useClearConfirmationOnBackSwipe from '../../../hooks/ui/useClearConfirmat import { useConfirmationMetricEvents } from '../../../hooks/metrics/useConfirmationMetricEvents'; import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; import useNavbar from '../../../hooks/ui/useNavbar'; +import { useMaxValueRefresher } from '../../../hooks/useMaxValueRefresher'; import FromTo from '../../rows/transactions/from-to'; import GasFeesDetails from '../../rows/transactions/gas-fee-details'; import AdvancedDetailsRow from '../../rows/transactions/advanced-details-row/advanced-details-row'; @@ -22,7 +23,7 @@ const Transfer = () => { useClearConfirmationOnBackSwipe(); useNavbar(strings('confirm.review')); - + useMaxValueRefresher(); useEffect(trackPageViewedEvent, [trackPageViewedEvent]); return ( diff --git a/app/components/Views/confirmations/components/info/typed-sign-v3v4/simulation/components/animated-pulse/animated-pulse.tsx b/app/components/Views/confirmations/components/info/typed-sign-v3v4/simulation/components/animated-pulse/animated-pulse.tsx deleted file mode 100644 index e8b355e5e409..000000000000 --- a/app/components/Views/confirmations/components/info/typed-sign-v3v4/simulation/components/animated-pulse/animated-pulse.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React, { useEffect, useRef } from 'react'; -import { Animated, ViewProps } from 'react-native'; - -interface AnimatedPulseProps extends ViewProps { - children: React.ReactNode; - isPulsing?: boolean; -} - -/** - * @todo move to components-temp - * @see {@link https://github.com/MetaMask/metamask-mobile/issues/13117} - * - * AnimatedPulse component - * @param {AnimatedPulseProps} props - The props for the AnimatedPulse component - * @returns {React.ReactNode} The AnimatedPulse component - */ -const AnimatedPulse = ({ - children, - isPulsing = true, - ...props -}: AnimatedPulseProps) => { - const opacity = useRef(new Animated.Value(0.3)).current; - - useEffect(() => { - if (isPulsing) { - const pulse = Animated.sequence([ - Animated.timing(opacity, { - toValue: 1, - duration: 750, - useNativeDriver: true, - }), - Animated.timing(opacity, { - toValue: 0.3, - duration: 750, - useNativeDriver: true, - }), - ]); - - Animated.loop(pulse).start(); - - return () => opacity.stopAnimation(); - } - Animated.timing(opacity, { - toValue: 1, - duration: 300, - useNativeDriver: true, - }).start(); - - }, [isPulsing, opacity]); - - return ( - <Animated.View - style={{ opacity }} - {...props} - > - {children} - </Animated.View> - ); -}; - -export default AnimatedPulse; diff --git a/app/components/Views/confirmations/components/info/typed-sign-v3v4/simulation/components/value-display/value-display.tsx b/app/components/Views/confirmations/components/info/typed-sign-v3v4/simulation/components/value-display/value-display.tsx index ed4c8182a46d..9c203a86f955 100644 --- a/app/components/Views/confirmations/components/info/typed-sign-v3v4/simulation/components/value-display/value-display.tsx +++ b/app/components/Views/confirmations/components/info/typed-sign-v3v4/simulation/components/value-display/value-display.tsx @@ -40,7 +40,7 @@ import BottomModal from '../../../../../UI/bottom-modal'; import styleSheet from './value-display.styles'; import { strings } from '../../../../../../../../../../locales/i18n'; -import AnimatedPulse from '../animated-pulse/animated-pulse'; +import AnimatedPulse from '../../../../../UI/animated-pulse'; import { selectContractExchangeRatesByChainId } from '../../../../../../../../../selectors/tokenRatesController'; import { RootState } from '../../../../../../../../../reducers'; @@ -188,6 +188,7 @@ const SimulationValueDisplay: React.FC<SimulationValueDisplayParams> = ({ {showValueButtonPill && ( <AnimatedPulse isPulsing={isPendingTokenDetails} + minCycles={0} testID="simulation-value-display-loader" > <ButtonPill diff --git a/app/components/Views/confirmations/components/rows/transactions/token-hero/token-hero.tsx b/app/components/Views/confirmations/components/rows/transactions/token-hero/token-hero.tsx index 73dd9ec0fb1e..fa423f5f5828 100644 --- a/app/components/Views/confirmations/components/rows/transactions/token-hero/token-hero.tsx +++ b/app/components/Views/confirmations/components/rows/transactions/token-hero/token-hero.tsx @@ -1,5 +1,6 @@ import React, { useState } from 'react'; import { StyleSheet, TouchableOpacity, View } from 'react-native'; +import { useSelector } from 'react-redux'; import { strings } from '../../../../../../../../locales/i18n'; import Badge, { BadgeVariant, @@ -12,9 +13,12 @@ import Text, { } from '../../../../../../../component-library/components/Texts/Text'; import { useStyles } from '../../../../../../../component-library/hooks'; import images from '../../../../../../../images/image-icons'; +import { selectTransactionState } from '../../../../../../../reducers/transaction'; import TokenIcon from '../../../../../../UI/Swaps/components/TokenIcon'; +import { useConfirmationContext } from '../../../../context/confirmation-context'; import { useTokenValues } from '../../../../hooks/useTokenValues'; import { useFlatConfirmation } from '../../../../hooks/ui/useFlatConfirmation'; +import AnimatedPulse from '../../../UI/animated-pulse'; import { TooltipModal } from '../../../UI/Tooltip/Tooltip'; import styleSheet from './token-hero.styles'; @@ -76,7 +80,9 @@ const AssetFiatConversion = ({ ); const TokenHero = ({ amountWei }: { amountWei?: string }) => { + const { isTransactionValueUpdating } = useConfirmationContext(); const { isFlatConfirmation } = useFlatConfirmation(); + const { maxValueMode } = useSelector(selectTransactionState); const { styles } = useStyles(styleSheet, { isFlatConfirmation, }); @@ -90,30 +96,35 @@ const TokenHero = ({ amountWei }: { amountWei?: string }) => { const tokenSymbol = 'ETH'; return ( - <View style={styles.container}> - <NetworkAndTokenImage tokenSymbol={tokenSymbol} styles={styles} /> - <AssetAmount - tokenAmountDisplayValue={tokenAmountDisplayValue} - tokenSymbol={tokenSymbol} - styles={styles} - setIsModalVisible={ - displayTokenAmountIsRounded ? setIsModalVisible : null - } - /> - <AssetFiatConversion - fiatDisplayValue={fiatDisplayValue} - styles={styles} - /> - {displayTokenAmountIsRounded && ( - <TooltipModal - open={isModalVisible} - setOpen={setIsModalVisible} - content={tokenAmountValue} - title={strings('send.amount')} - tooltipTestId="token-hero-amount" + <AnimatedPulse + isPulsing={isTransactionValueUpdating} + preventPulse={!maxValueMode} + > + <View style={styles.container}> + <NetworkAndTokenImage tokenSymbol={tokenSymbol} styles={styles} /> + <AssetAmount + tokenAmountDisplayValue={tokenAmountDisplayValue} + tokenSymbol={tokenSymbol} + styles={styles} + setIsModalVisible={ + displayTokenAmountIsRounded ? setIsModalVisible : null + } + /> + <AssetFiatConversion + fiatDisplayValue={fiatDisplayValue} + styles={styles} /> - )} - </View> + {displayTokenAmountIsRounded && ( + <TooltipModal + open={isModalVisible} + setOpen={setIsModalVisible} + content={tokenAmountValue} + title={strings('send.amount')} + tooltipTestId="token-hero-amount" + /> + )} + </View> + </AnimatedPulse> ); }; diff --git a/app/components/Views/confirmations/context/confirmation-context/confirmation-context.test.tsx b/app/components/Views/confirmations/context/confirmation-context/confirmation-context.test.tsx new file mode 100644 index 000000000000..8d7dccf87970 --- /dev/null +++ b/app/components/Views/confirmations/context/confirmation-context/confirmation-context.test.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import { renderHook, act } from '@testing-library/react-hooks'; +import { + ConfirmationContextProvider, + useConfirmationContext, +} from './confirmation-context'; + +describe('ConfirmationContext', () => { + const wrapper = ({ children }: { children: React.ReactNode }) => ( + <ConfirmationContextProvider>{children}</ConfirmationContextProvider> + ); + + it('provides initial values', () => { + const { result } = renderHook(() => useConfirmationContext(), { wrapper }); + + expect(result.current.isTransactionValueUpdating).toBe(false); + expect(typeof result.current.setIsTransactionValueUpdating).toBe( + 'function', + ); + }); + + it('updates isTransactionValueUpdating state when calling setIsTransactionValueUpdating', () => { + const { result } = renderHook(() => useConfirmationContext(), { wrapper }); + + act(() => { + result.current.setIsTransactionValueUpdating(true); + }); + + expect(result.current.isTransactionValueUpdating).toBe(true); + + act(() => { + result.current.setIsTransactionValueUpdating(false); + }); + + expect(result.current.isTransactionValueUpdating).toBe(false); + }); +}); diff --git a/app/components/Views/confirmations/context/confirmation-context/confirmation-context.tsx b/app/components/Views/confirmations/context/confirmation-context/confirmation-context.tsx new file mode 100644 index 000000000000..adbdb5cc87a6 --- /dev/null +++ b/app/components/Views/confirmations/context/confirmation-context/confirmation-context.tsx @@ -0,0 +1,49 @@ +import React, { useContext, useMemo, useState } from 'react'; + +export interface ConfirmationContextParams { + isTransactionValueUpdating: boolean; + setIsTransactionValueUpdating: (isTransactionValueUpdating: boolean) => void; +} + +// This context is used to share the valuable information between the components +// that are used to render the confirmation +const ConfirmationContext = React.createContext<ConfirmationContextParams>({ + isTransactionValueUpdating: false, + // eslint-disable-next-line no-empty-function + setIsTransactionValueUpdating: () => {}, +}); + +interface ConfirmationContextProviderProps { + children: React.ReactNode; +} + +export const ConfirmationContextProvider: React.FC< + ConfirmationContextProviderProps +> = ({ children }) => { + const [isTransactionValueUpdating, setIsTransactionValueUpdating] = + useState(false); + + const contextValue = useMemo( + () => ({ + isTransactionValueUpdating, + setIsTransactionValueUpdating, + }), + [isTransactionValueUpdating, setIsTransactionValueUpdating], + ); + + return ( + <ConfirmationContext.Provider value={contextValue}> + {children} + </ConfirmationContext.Provider> + ); +}; + +export const useConfirmationContext = () => { + const context = useContext(ConfirmationContext); + if (!context) { + throw new Error( + 'useConfirmationContext must be used within a ConfirmationContextProvider', + ); + } + return context; +}; diff --git a/app/components/Views/confirmations/context/confirmation-context/index.ts b/app/components/Views/confirmations/context/confirmation-context/index.ts new file mode 100644 index 000000000000..3428caad1637 --- /dev/null +++ b/app/components/Views/confirmations/context/confirmation-context/index.ts @@ -0,0 +1 @@ +export * from './confirmation-context'; diff --git a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts index 224020b950f2..4b2a6f4ce74c 100644 --- a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts +++ b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.test.ts @@ -8,6 +8,7 @@ import { AlertKeys } from '../../constants/alerts'; import { RowAlertKey } from '../../components/UI/info-row/alert-row/constants'; import { Severity } from '../../types/alerts'; import { selectNetworkConfigurations } from '../../../../../selectors/networkController'; +import { selectTransactionState } from '../../../../../reducers/transaction'; jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), @@ -18,8 +19,12 @@ jest.mock('../transactions/useTransactionMetadataRequest'); jest.mock('../useAccountNativeBalance'); jest.mock('../../../../../../locales/i18n'); jest.mock('../../../../../selectors/networkController'); +jest.mock('../../../../../reducers/transaction', () => ({ + selectTransactionState: jest.fn(), +})); describe('useInsufficientBalanceAlert', () => { + const mockSelectTransactionState = jest.mocked(selectTransactionState); const mockUseTransactionMetadataRequest = jest.mocked( useTransactionMetadataRequest, ); @@ -64,6 +69,9 @@ describe('useInsufficientBalanceAlert', () => { } return key; }); + mockSelectTransactionState.mockReturnValue({ + maxValueMode: false, + }); }); it('return empty array when no transaction metadata is available', () => { @@ -73,6 +81,15 @@ describe('useInsufficientBalanceAlert', () => { expect(result.current).toEqual([]); }); + it('return empty array when max value mode is enabled', () => { + mockSelectTransactionState.mockReturnValue({ + maxValueMode: true, + }); + + const { result } = renderHook(() => useInsufficientBalanceAlert()); + expect(result.current).toEqual([]); + }); + it('return alert when balance is insufficient (with maxFeePerGas)', () => { // Transaction needs: value (5) + (maxFeePerGas (3) * gas (2)) = 11 wei // Balance is only 8 wei, so should show alert diff --git a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts index 0b850e943e46..98b563d81e75 100644 --- a/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts +++ b/app/components/Views/confirmations/hooks/alerts/useInsufficientBalanceAlert.ts @@ -11,6 +11,7 @@ import { import { strings } from '../../../../../../locales/i18n'; import { selectNetworkConfigurations } from '../../../../../selectors/networkController'; import { createBuyNavigationDetails } from '../../../../UI/Ramp/routes/utils'; +import { selectTransactionState } from '../../../../../reducers/transaction'; import { RowAlertKey } from '../../components/UI/info-row/alert-row/constants'; import { AlertKeys } from '../../constants/alerts'; import { Severity } from '../../types/alerts'; @@ -27,9 +28,10 @@ export const useInsufficientBalanceAlert = () => { transactionMetadata?.chainId as Hex, transactionMetadata?.txParams?.from as string, ); + const { maxValueMode } = useSelector(selectTransactionState); return useMemo(() => { - if (!transactionMetadata) { + if (!transactionMetadata || maxValueMode) { return []; } @@ -77,5 +79,11 @@ export const useInsufficientBalanceAlert = () => { skipConfirmation: true, }, ]; - }, [navigation, networkConfigurations, transactionMetadata, balanceWeiInHex]); + }, [ + balanceWeiInHex, + maxValueMode, + navigation, + networkConfigurations, + transactionMetadata, + ]); }; diff --git a/app/components/Views/confirmations/hooks/useMaxValueRefresher.test.ts b/app/components/Views/confirmations/hooks/useMaxValueRefresher.test.ts new file mode 100644 index 000000000000..3028eedea262 --- /dev/null +++ b/app/components/Views/confirmations/hooks/useMaxValueRefresher.test.ts @@ -0,0 +1,125 @@ +import { merge } from 'lodash'; +import { useSelector } from 'react-redux'; +import { + DeepPartial, + renderHookWithProvider, +} from '../../../../util/test/renderWithProvider'; +import { RootState } from '../../../../reducers'; +import { useMaxValueRefresher } from './useMaxValueRefresher'; +import { transferConfirmationState } from '../../../../util/test/confirm-data-helpers'; +import { useFeeCalculations } from './gas/useFeeCalculations'; +import { updateEditableParams } from '../../../../util/transaction-controller'; +import { TransactionType } from '@metamask/transaction-controller'; + +jest.mock('../../../../core/Engine', () => ({ + context: { + TokenListController: { + fetchTokenList: jest.fn(), + }, + }, +})); + +jest.mock('../../../../util/transaction-controller', () => ({ + updateEditableParams: jest.fn(), +})); +jest.mock('./useAccountNativeBalance', () => ({ + useAccountNativeBalance: jest.fn().mockReturnValue({ + balanceWeiInHex: '0x10', // 16 wei + }), +})); +jest.mock('./gas/useFeeCalculations', () => ({ + useFeeCalculations: jest.fn(), +})); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), + })); + +describe('useMaxValueRefresher', () => { + const mockUseFeeCalculations = jest.mocked(useFeeCalculations); + const mockUpdateEditableParams = jest.mocked(updateEditableParams); + const mockUseSelector = jest.mocked(useSelector); + + const maxModeState = merge({}, transferConfirmationState, { + transaction: { + maxValueMode: true, + }, + }); + + const normalSendState = merge({}, transferConfirmationState, { + transaction: { + maxValueMode: false, + }, + }); + + const transactionId = + transferConfirmationState.engine.backgroundState.TransactionController + .transactions[0].id; + + beforeEach(() => { + jest.clearAllMocks(); + mockUseFeeCalculations.mockReturnValue({ + preciseNativeFeeInHex: '0x5', + } as unknown as ReturnType<typeof useFeeCalculations>); + + mockUseSelector.mockImplementation( + (fn: (state: DeepPartial<RootState>) => unknown) => fn(maxModeState), + ); + }); + + it('updates transaction value when calculated value is not equal to the current value', () => { + renderHookWithProvider(() => useMaxValueRefresher(), { + state: maxModeState, + }); + + expect(mockUpdateEditableParams).toHaveBeenCalledWith(transactionId, { + value: '0xb', // 16 - 11 = 5 + }); + }); + + describe('does not update transaction value', () => { + it('max mode is off', () => { + mockUseSelector.mockImplementationOnce( + (fn: (state: DeepPartial<RootState>) => unknown) => fn(normalSendState), + ); + + renderHookWithProvider(() => useMaxValueRefresher(), { + state: normalSendState, + }); + + expect(mockUpdateEditableParams).not.toHaveBeenCalled(); + }); + + it('transaction type is not a simple send', () => { + const transferConfirmationStateWithoutSimpleSend = merge( + {}, + maxModeState, + { + engine: { + backgroundState: { + TransactionController: { + transactions: [ + { + type: TransactionType.tokenMethodTransfer, + }, + ], + }, + }, + }, + }, + ); + + mockUseSelector.mockImplementation( + (fn: (state: DeepPartial<RootState>) => unknown) => + fn(transferConfirmationStateWithoutSimpleSend), + ); + + renderHookWithProvider(() => useMaxValueRefresher(), { + state: transferConfirmationStateWithoutSimpleSend, + }); + + expect(mockUpdateEditableParams).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/app/components/Views/confirmations/hooks/useMaxValueRefresher.ts b/app/components/Views/confirmations/hooks/useMaxValueRefresher.ts new file mode 100644 index 000000000000..f7fa8b3d69f0 --- /dev/null +++ b/app/components/Views/confirmations/hooks/useMaxValueRefresher.ts @@ -0,0 +1,68 @@ +import { useState, useEffect } from 'react'; + +import { useSelector } from 'react-redux'; +import { add0x } from '@metamask/utils'; +import { BigNumber } from 'bignumber.js'; +import { + TransactionType, + type TransactionMeta, +} from '@metamask/transaction-controller'; +import { useTransactionMetadataRequest } from './transactions/useTransactionMetadataRequest'; +import { useFeeCalculations } from './gas/useFeeCalculations'; +import { useAccountNativeBalance } from './useAccountNativeBalance'; +import { selectTransactionState } from '../../../../reducers/transaction'; +import { updateEditableParams } from '../../../../util/transaction-controller'; +import { useConfirmationContext } from '../context/confirmation-context'; + +// This hook is used to refresh the max value of the transaction +// when the user is in max amount mode only for the transaction type simpleSend +// It subtracts the native fee from the balance and updates the value of the transaction +export function useMaxValueRefresher() { + const { maxValueMode } = useSelector(selectTransactionState); + const [valueJustUpdated, setValueJustUpdated] = useState(false); + const { setIsTransactionValueUpdating } = useConfirmationContext(); + const transactionMetadata = useTransactionMetadataRequest(); + const { chainId, id, txParams, type } = + transactionMetadata as TransactionMeta; + const { preciseNativeFeeInHex } = useFeeCalculations( + transactionMetadata as TransactionMeta, + ); + const { balanceWeiInHex } = useAccountNativeBalance(chainId, txParams.from); + + useEffect(() => { + if (!maxValueMode || type !== TransactionType.simpleSend) { + // Not compatible with transaction value refresh logic + return; + } + + const balance = new BigNumber(balanceWeiInHex); + const fee = new BigNumber(preciseNativeFeeInHex); + const maxValue = balance.minus(fee); + const maxValueHex = add0x(maxValue.toString(16)); + const shouldUpdate = maxValueHex !== txParams.value; + + if (shouldUpdate && maxValue.isPositive()) { + setIsTransactionValueUpdating(true); + updateEditableParams(id, { + value: maxValueHex, + }); + setValueJustUpdated(true); + } + }, [ + balanceWeiInHex, + id, + maxValueMode, + preciseNativeFeeInHex, + setIsTransactionValueUpdating, + txParams, + type, + ]); + + useEffect(() => { + if (valueJustUpdated) { + // This will run in the next render cycle after the update + setIsTransactionValueUpdating(false); + setValueJustUpdated(false); + } + }, [valueJustUpdated, setIsTransactionValueUpdating]); +} diff --git a/app/reducers/transaction/index.js b/app/reducers/transaction/index.js index 3b37c6201502..d4733abf0801 100644 --- a/app/reducers/transaction/index.js +++ b/app/reducers/transaction/index.js @@ -166,3 +166,5 @@ const transactionReducer = (state = initialState, action) => { } }; export default transactionReducer; + +export const selectTransactionState = (state) => state.transaction;