diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx index df1b578a8161..0acebc0b888c 100644 --- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx +++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.test.tsx @@ -29,6 +29,7 @@ import { useTransactionPayRequiredTokens, useIsTransactionPayLoading, useTransactionPayQuotes, + useTransactionPayQuotesLastUpdated, } from '../../../hooks/pay/useTransactionPayData'; import { useTransactionPayHasSourceAmount } from '../../../hooks/pay/useTransactionPayHasSourceAmount'; import { strings } from '../../../../../../../locales/i18n'; @@ -40,6 +41,7 @@ import { Platform } from 'react-native'; import { TransactionType } from '@metamask/transaction-controller'; import { useConfirmActions } from '../../../hooks/useConfirmActions'; import { CustomAmountInfoTestIds } from './custom-amount-info.testIds'; +import { ConfirmationFooterSelectorIDs } from '../../../ConfirmationView.testIds'; import { useTransactionMetadataRequest } from '../../../hooks/transactions/useTransactionMetadataRequest'; import { useTokenFiatRates } from '../../../hooks/tokens/useTokenFiatRates'; import { useTransactionPayWithdraw } from '../../../hooks/pay/useTransactionPayWithdraw'; @@ -49,6 +51,7 @@ import { usePayWithMoneyAccountSection } from '../../../hooks/pay/sections/usePa import Logger from '../../../../../../util/Logger'; import useClearConfirmationOnBackSwipe from '../../../hooks/ui/useClearConfirmationOnBackSwipe'; import { useAccountNoFundsAlert } from '../../../hooks/alerts/useAccountNoFundsAlert'; +import { mockTheme } from '../../../../../../util/theme'; jest.mock('../../../hooks/ui/useClearConfirmationOnBackSwipe'); jest.mock('../../../hooks/ui/useMMPayNavigation'); @@ -195,37 +198,59 @@ const mockToastRef = { current: { showToast: mockShowToast, closeToast: jest.fn() }, }; -function render( +interface DeferredPromise { + promise: Promise; + reject: (reason?: unknown) => void; + resolve: () => void; +} + +function createDeferredPromise(): DeferredPromise { + let reject: DeferredPromise['reject'] = noop; + let resolve: DeferredPromise['resolve'] = noop; + const promise = new Promise((resolvePromise, rejectPromise) => { + reject = rejectPromise; + resolve = resolvePromise; + }); + + return { promise, reject, resolve }; +} + +function createCustomAmountInfo( props: CustomAmountInfoProps & { transactionType?: TransactionType } = {}, ) { - return renderWithProvider( + return ( - , - { - state: merge( - {}, - simpleSendTransactionControllerMock, - transactionApprovalControllerMock, - otherControllersMock, - { - engine: { - backgroundState: { - TransactionController: { - transactions: [ - { - type: - props.transactionType || - TransactionType.contractInteraction, - }, - ], - }, + + ); +} + +function render( + props: CustomAmountInfoProps & { transactionType?: TransactionType } = {}, +) { + return renderWithProvider(createCustomAmountInfo(props), { + state: merge( + {}, + simpleSendTransactionControllerMock, + transactionApprovalControllerMock, + otherControllersMock, + { + engine: { + backgroundState: { + TransactionController: { + transactions: [ + { + type: + props.transactionType || + TransactionType.contractInteraction, + }, + ], }, }, }, - ), - }, - ); + }, + ), + }); } describe('CustomAmountInfo', () => { @@ -252,6 +277,9 @@ describe('CustomAmountInfo', () => { ); const useTransactionPayQuotesMock = jest.mocked(useTransactionPayQuotes); + const useTransactionPayQuotesLastUpdatedMock = jest.mocked( + useTransactionPayQuotesLastUpdated, + ); const useTransactionPayHasSourceAmountMock = jest.mocked( useTransactionPayHasSourceAmount, @@ -379,6 +407,7 @@ describe('CustomAmountInfo', () => { }); useIsTransactionPayLoadingMock.mockReturnValue(false); useTransactionPayQuotesMock.mockReturnValue([]); + useTransactionPayQuotesLastUpdatedMock.mockReturnValue(undefined); useTransactionPayHasSourceAmountMock.mockReturnValue(false); useTokenFiatRatesMock.mockReturnValue([1, 1]); useTransactionMetadataRequestMock.mockReturnValue({ @@ -602,6 +631,316 @@ describe('CustomAmountInfo', () => { ).toBeOnTheScreen(); }); + describe('Money Account quote preparation', () => { + function arrangePendingPreparation() { + const deferred = createDeferredPromise(); + const updateTokenAmount = jest.fn(() => deferred.promise); + useTransactionMetadataRequestMock.mockReturnValue({ + id: 'money-account-deposit-transaction', + type: TransactionType.moneyAccountDeposit, + txParams: { from: '0x123' }, + } as never); + useTransactionCustomAmountMock.mockReturnValue({ + ...useTransactionCustomAmountMock(), + updateTokenAmount, + }); + + return { deferred, updateTokenAmount }; + } + + it('replaces the keypad with preparation feedback before the amount update resolves', () => { + arrangePendingPreparation(); + const { getByTestId, getByText, queryByTestId, queryByText } = render({ + supportAccountSelection: true, + transactionType: TransactionType.moneyAccountDeposit, + }); + + fireEvent.press(getByTestId('deposit-keyboard-done-button')); + + expect(queryByTestId('deposit-keyboard')).not.toBeOnTheScreen(); + expect(getByTestId('pay-account-selector')).toBeOnTheScreen(); + expect(getByTestId('pay-with')).toBeOnTheScreen(); + expect( + getByTestId(ConfirmationFooterSelectorIDs.CONFIRM_BUTTON), + ).toBeDisabled(); + expect( + getByText(strings('confirm.deposit_edit_amount_done')), + ).toBeOnTheScreen(); + expect( + queryByText(strings('confirm.preparing_order')), + ).not.toBeOnTheScreen(); + expect(getByTestId('bridge-fee-row-skeleton')).toBeOnTheScreen(); + expect(getByTestId('bridge-time-row-skeleton')).toBeOnTheScreen(); + expect(getByTestId('total-row-skeleton')).toBeOnTheScreen(); + }); + + it('blocks review rows, amount editing, and duplicate submission during preparation', () => { + const { updateTokenAmount } = arrangePendingPreparation(); + const { getByTestId } = render({ + supportAccountSelection: true, + transactionType: TransactionType.moneyAccountDeposit, + }); + const doneButton = getByTestId('deposit-keyboard-done-button'); + + act(() => { + fireEvent.press(doneButton); + fireEvent.press(doneButton); + }); + + // Both presses occur before React rerenders, so the synchronous guard + // must prevent the second amount update. + expect(updateTokenAmount).toHaveBeenCalledTimes(1); + expect( + getByTestId(CustomAmountInfoTestIds.REVIEW_ROWS).props.pointerEvents, + ).toBe('none'); + expect(getByTestId('custom-amount-input').props.onPress).toBeUndefined(); + }); + + it('keeps the amount visually enabled during preparation', () => { + arrangePendingPreparation(); + const { getByTestId } = render({ + transactionType: TransactionType.moneyAccountDeposit, + }); + + fireEvent.press(getByTestId('deposit-keyboard-done-button')); + + expect(getByTestId('custom-amount-input')).toHaveStyle({ + color: mockTheme.colors.text.default, + }); + expect(getByTestId('custom-amount-symbol')).toHaveStyle({ + color: mockTheme.colors.text.default, + }); + }); + + it('keeps the loading review throughout quote loading', async () => { + const { deferred } = arrangePendingPreparation(); + const view = render({ + transactionType: TransactionType.moneyAccountDeposit, + }); + fireEvent.press(view.getByTestId('deposit-keyboard-done-button')); + + useIsTransactionPayLoadingMock.mockReturnValue(true); + await act(async () => { + deferred.resolve(); + await deferred.promise; + }); + + expect(view.getByTestId('bridge-fee-row-skeleton')).toBeOnTheScreen(); + expect(view.queryByTestId('bridge-fee-row')).not.toBeOnTheScreen(); + expect( + view.getByTestId(ConfirmationFooterSelectorIDs.CONFIRM_BUTTON), + ).toBeDisabled(); + expect( + view.getByText(strings('confirm.deposit_edit_amount_done')), + ).toBeOnTheScreen(); + expect( + view.queryByText(strings('confirm.preparing_order')), + ).not.toBeOnTheScreen(); + expect( + view.getByTestId(CustomAmountInfoTestIds.REVIEW_ROWS).props + .pointerEvents, + ).toBe('none'); + expect( + view.getByTestId('custom-amount-input').props.onPress, + ).toBeUndefined(); + }); + + it('keeps the loading review through the handoff to controller loading', async () => { + const { deferred } = arrangePendingPreparation(); + const view = render({ + transactionType: TransactionType.moneyAccountDeposit, + }); + fireEvent.press(view.getByTestId('deposit-keyboard-done-button')); + + await act(async () => { + deferred.resolve(); + await deferred.promise; + }); + + expect(view.getByTestId('bridge-fee-row-skeleton')).toBeOnTheScreen(); + expect( + view.getByTestId(ConfirmationFooterSelectorIDs.CONFIRM_BUTTON), + ).toBeDisabled(); + + useIsTransactionPayLoadingMock.mockReturnValue(true); + view.rerender( + createCustomAmountInfo({ + transactionType: TransactionType.moneyAccountDeposit, + }), + ); + + expect(view.getByTestId('bridge-fee-row-skeleton')).toBeOnTheScreen(); + + useIsTransactionPayLoadingMock.mockReturnValue(false); + view.rerender( + createCustomAmountInfo({ + transactionType: TransactionType.moneyAccountDeposit, + }), + ); + + expect(view.getByTestId('bridge-fee-row')).toBeOnTheScreen(); + expect( + view.getByTestId(ConfirmationFooterSelectorIDs.CONFIRM_BUTTON), + ).not.toBeDisabled(); + }); + + it('releases the loading review when a fast quote completes between renders', async () => { + const { deferred } = arrangePendingPreparation(); + const view = render({ + transactionType: TransactionType.moneyAccountDeposit, + }); + fireEvent.press(view.getByTestId('deposit-keyboard-done-button')); + + await act(async () => { + deferred.resolve(); + await deferred.promise; + }); + + expect(view.getByTestId('bridge-fee-row-skeleton')).toBeOnTheScreen(); + + useTransactionPayQuotesLastUpdatedMock.mockReturnValue(123); + view.rerender( + createCustomAmountInfo({ + transactionType: TransactionType.moneyAccountDeposit, + }), + ); + + expect(view.getByTestId('bridge-fee-row')).toBeOnTheScreen(); + expect( + view.getByTestId(ConfirmationFooterSelectorIDs.CONFIRM_BUTTON), + ).not.toBeDisabled(); + }); + + it('ignores stale quote updates while the amount is still committing', async () => { + useTransactionPayQuotesLastUpdatedMock.mockReturnValue(1); + const { deferred } = arrangePendingPreparation(); + const view = render({ + transactionType: TransactionType.moneyAccountDeposit, + }); + fireEvent.press(view.getByTestId('deposit-keyboard-done-button')); + + useTransactionPayQuotesLastUpdatedMock.mockReturnValue(2); + view.rerender( + createCustomAmountInfo({ + transactionType: TransactionType.moneyAccountDeposit, + }), + ); + + expect(view.getByTestId('bridge-fee-row-skeleton')).toBeOnTheScreen(); + + await act(async () => { + deferred.resolve(); + await deferred.promise; + }); + + expect(view.getByTestId('bridge-fee-row-skeleton')).toBeOnTheScreen(); + + useTransactionPayQuotesLastUpdatedMock.mockReturnValue(3); + view.rerender( + createCustomAmountInfo({ + transactionType: TransactionType.moneyAccountDeposit, + }), + ); + + expect(view.getByTestId('bridge-fee-row')).toBeOnTheScreen(); + }); + + it('keeps preparation active while the amount update is pending', async () => { + const { deferred } = arrangePendingPreparation(); + const view = render({ + transactionType: TransactionType.moneyAccountDeposit, + }); + fireEvent.press(view.getByTestId('deposit-keyboard-done-button')); + + useIsTransactionPayLoadingMock.mockReturnValue(true); + view.rerender( + createCustomAmountInfo({ + transactionType: TransactionType.moneyAccountDeposit, + }), + ); + + expect(view.getByTestId('bridge-fee-row-skeleton')).toBeOnTheScreen(); + expect( + view.getByTestId(ConfirmationFooterSelectorIDs.CONFIRM_BUTTON), + ).toBeDisabled(); + + await act(async () => { + deferred.resolve(); + await deferred.promise; + }); + }); + + it('shows the populated post-keypad state after quote loading settles', async () => { + const { deferred } = arrangePendingPreparation(); + const view = render({ + transactionType: TransactionType.moneyAccountDeposit, + }); + fireEvent.press(view.getByTestId('deposit-keyboard-done-button')); + await act(async () => { + deferred.resolve(); + await deferred.promise; + }); + useIsTransactionPayLoadingMock.mockReturnValue(true); + view.rerender( + createCustomAmountInfo({ + transactionType: TransactionType.moneyAccountDeposit, + }), + ); + + useIsTransactionPayLoadingMock.mockReturnValue(false); + view.rerender( + createCustomAmountInfo({ + transactionType: TransactionType.moneyAccountDeposit, + }), + ); + + expect(view.getByTestId('bridge-fee-row')).toBeOnTheScreen(); + expect( + view.getByTestId(ConfirmationFooterSelectorIDs.CONFIRM_BUTTON), + ).not.toBeDisabled(); + expect(view.queryByTestId('deposit-keyboard')).not.toBeOnTheScreen(); + }); + + it('restores amount entry and the existing toast after preparation fails', async () => { + const { deferred } = arrangePendingPreparation(); + const view = render({ + transactionType: TransactionType.moneyAccountDeposit, + }); + fireEvent.press(view.getByTestId('deposit-keyboard-done-button')); + + await act(async () => { + deferred.reject(new Error('update failed')); + await Promise.resolve(); + }); + + expect(view.getByTestId('deposit-keyboard')).toBeOnTheScreen(); + expect(view.getByTestId('custom-amount-input').props.onPress).toEqual( + expect.any(Function), + ); + expect(mockShowToast).toHaveBeenCalledTimes(1); + }); + + it('immediately closes keyboard and disables amount editing for non-Money transactions', async () => { + const deferred = createDeferredPromise(); + useTransactionCustomAmountMock.mockReturnValue({ + ...useTransactionCustomAmountMock(), + updateTokenAmount: jest.fn(() => deferred.promise), + }); + const { getByTestId, queryByTestId } = render(); + + fireEvent.press(getByTestId('deposit-keyboard-done-button')); + + expect(queryByTestId('deposit-keyboard')).not.toBeOnTheScreen(); + expect(getByTestId('custom-amount-input').props.onPress).toBeUndefined(); + expect(getByTestId('bridge-fee-row-skeleton')).toBeOnTheScreen(); + + await act(async () => { + deferred.resolve(); + await deferred.promise; + }); + }); + }); + it('calls onAmountSubmit when Done button is pressed', async () => { const mockOnAmountSubmit = jest.fn(); @@ -648,14 +987,21 @@ describe('CustomAmountInfo', () => { onReject: jest.fn(), }); - const { getByText } = render(); + const view = render(); await act(async () => { - fireEvent.press(getByText(strings('confirm.edit_amount_done'))); + fireEvent.press(view.getByText(strings('confirm.edit_amount_done'))); }); + useIsTransactionPayLoadingMock.mockReturnValue(true); + view.rerender(createCustomAmountInfo()); + useIsTransactionPayLoadingMock.mockReturnValue(false); + view.rerender(createCustomAmountInfo()); + await act(async () => { - fireEvent.press(getByText(strings('confirm.deposit_edit_amount_done'))); + fireEvent.press( + view.getByText(strings('confirm.deposit_edit_amount_done')), + ); }); expect(setIsConfirmationSubmittingMock).toHaveBeenCalledWith(true); @@ -682,7 +1028,7 @@ describe('CustomAmountInfo', () => { updateTokenAmount: updateTokenAmountMock, }); - const { getByText, queryByText } = render({ + const { getByText, queryByText, getByTestId } = render({ onAmountSubmit: mockOnAmountSubmit, }); @@ -698,8 +1044,11 @@ describe('CustomAmountInfo', () => { expect(mockShowToast).toHaveBeenCalledWith( expect.objectContaining({ labelOptions: expect.any(Array) }), ); - // Keyboard stays open: Done button still present + // Keyboard stays open: Done button and amount editing remain available. expect(queryByText(strings('confirm.edit_amount_done'))).toBeOnTheScreen(); + expect(getByTestId('custom-amount-input').props.onPress).toEqual( + expect.any(Function), + ); }); it('renders PayAccountSelector when supportAccountSelection is true', () => { @@ -919,22 +1268,41 @@ describe('CustomAmountInfo', () => { }); describe('showPaymentDetails', () => { - async function pressDone( - getByText: ReturnType['getByText'], - ) { + async function pressDone(view: ReturnType) { await act(async () => { - fireEvent.press(getByText(strings('confirm.edit_amount_done'))); + fireEvent.press(view.getByText(strings('confirm.edit_amount_done'))); }); + + useIsTransactionPayLoadingMock.mockReturnValue(true); + view.rerender(createCustomAmountInfo()); + useIsTransactionPayLoadingMock.mockReturnValue(false); + view.rerender(createCustomAmountInfo()); } + it('clears the handoff for synchronous updates without quote signals', async () => { + const view = render(); + + await act(async () => { + fireEvent.press(view.getByText(strings('confirm.edit_amount_done'))); + }); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(view.getByTestId('bridge-fee-row')).toBeOnTheScreen(); + expect( + view.getByTestId(ConfirmationFooterSelectorIDs.CONFIRM_BUTTON), + ).not.toBeDisabled(); + }); + it('shows fee rows for same-chain payment without quotes', async () => { useTransactionPayHasSourceAmountMock.mockReturnValue(false); useTransactionPayQuotesMock.mockReturnValue([]); - const { getByText, getByTestId } = render(); - await pressDone(getByText); + const view = render(); + await pressDone(view); - expect(getByTestId('bridge-fee-row')).toBeOnTheScreen(); + expect(view.getByTestId('bridge-fee-row')).toBeOnTheScreen(); }); it('hides fee rows when no-quotes alert is present', async () => { @@ -952,20 +1320,20 @@ describe('CustomAmountInfo', () => { fieldAlerts: [] as Alert[], } as AlertsContextParams); - const { getByText, queryByTestId } = render(); - await pressDone(getByText); + const view = render(); + await pressDone(view); - expect(queryByTestId('bridge-fee-row')).toBeNull(); + expect(view.queryByTestId('bridge-fee-row')).toBeNull(); }); it('shows fee rows when quotes exist regardless of source amount', async () => { useTransactionPayHasSourceAmountMock.mockReturnValue(true); useTransactionPayQuotesMock.mockReturnValue([{} as never]); - const { getByText, getByTestId } = render(); - await pressDone(getByText); + const view = render(); + await pressDone(view); - expect(getByTestId('bridge-fee-row')).toBeOnTheScreen(); + expect(view.getByTestId('bridge-fee-row')).toBeOnTheScreen(); }); }); @@ -1063,15 +1431,29 @@ describe('CustomAmountInfo', () => { onReject: jest.fn(), }); - const { getByText } = render({ + const view = render({ transactionType: TransactionType.moneyAccountDeposit, }); await act(async () => { - fireEvent.press(getByText(strings('confirm.edit_amount_done'))); + fireEvent.press(view.getByText(strings('confirm.edit_amount_done'))); }); + useIsTransactionPayLoadingMock.mockReturnValue(true); + view.rerender( + createCustomAmountInfo({ + transactionType: TransactionType.moneyAccountDeposit, + }), + ); + useIsTransactionPayLoadingMock.mockReturnValue(false); + view.rerender( + createCustomAmountInfo({ + transactionType: TransactionType.moneyAccountDeposit, + }), + ); await act(async () => { - fireEvent.press(getByText(strings('confirm.deposit_edit_amount_done'))); + fireEvent.press( + view.getByText(strings('confirm.deposit_edit_amount_done')), + ); }); // Mount-time screen-viewed + three reactive + two imperative CTA events, diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.testIds.ts b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.testIds.ts index fe85a1cc4382..ce403d78313f 100644 --- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.testIds.ts +++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.testIds.ts @@ -1,3 +1,4 @@ export const CustomAmountInfoTestIds = { BOTTOM_BLOCK: 'custom-amount-info-bottom-block', + REVIEW_ROWS: 'custom-amount-info-review-rows', } as const; diff --git a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx index 2da4738fcd4b..4f4fc1494c4e 100644 --- a/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx +++ b/app/components/Views/confirmations/components/info/custom-amount-info/custom-amount-info.tsx @@ -45,6 +45,7 @@ import { useIsTransactionPayLoading, useTransactionPayFiatPayment, useTransactionPayQuotes, + useTransactionPayQuotesLastUpdated, useTransactionPayRequiredTokens, } from '../../../hooks/pay/useTransactionPayData'; import { useTransactionPayHasSourceAmount } from '../../../hooks/pay/useTransactionPayHasSourceAmount'; @@ -190,6 +191,15 @@ export const CustomAmountInfo: React.FC = memo( const [isKeyboardVisible, setIsKeyboardVisible] = useState( !isAddMusdIntent && !isDepositPrefillEnabled, ); + const [isAmountUpdating, setIsAmountUpdating] = useState(false); + const [isAmountUpdateComplete, setIsAmountUpdateComplete] = useState(false); + // React batches rapid presses before the state update rerenders, so keep a + // synchronous guard separate from the render state. + const isAmountUpdateInProgressRef = useRef(false); + const quotesLastUpdatedBeforeAmountUpdateRef = useRef( + undefined, + ); + const quotesLastUpdatedRef = useRef(undefined); const wasKeyboardEverVisible = useRef(isKeyboardVisible); if (isKeyboardVisible) { wasKeyboardEverVisible.current = true; @@ -219,11 +229,16 @@ export const CustomAmountInfo: React.FC = memo( const { toastRef } = useContext(ToastContext); - const isResultReady = - useIsResultReady({ isKeyboardVisible }) || - (isAddMusdIntent && !isKeyboardVisible); + const isTransactionResultReady = useIsResultReady({ isKeyboardVisible }); const quotes = useTransactionPayQuotes(); + const quotesLastUpdated = useTransactionPayQuotesLastUpdated(); + quotesLastUpdatedRef.current = quotesLastUpdated; const isQuotesLoading = useIsTransactionPayLoading(); + const showLoadingReview = isAmountUpdating || isQuotesLoading; + const isResultReady = + showLoadingReview || + isTransactionResultReady || + (isAddMusdIntent && !isKeyboardVisible); const hasSourceAmount = useTransactionPayHasSourceAmount(); const { alerts } = useAlerts(); const accountNoFundsAlert = useAccountNoFundsAlert(); @@ -232,7 +247,7 @@ export const CustomAmountInfo: React.FC = memo( (a) => a.key === AlertKeys.NoPayTokenQuotes, ); const showPaymentDetails = - isQuotesLoading || + showLoadingReview || Boolean(quotes?.length) || (!isAddMusdIntent && !hasSourceAmount && !hasNoQuotesAlert); @@ -249,6 +264,15 @@ export const CustomAmountInfo: React.FC = memo( }); const handleDone = useCallback(async () => { + if (isAmountUpdateInProgressRef.current) { + return; + } + + isAmountUpdateInProgressRef.current = true; + setIsAmountUpdateComplete(false); + setIsAmountUpdating(true); + setIsKeyboardVisible(false); + try { await updateTokenAmount(); if (selectedFiatPaymentMethodId && transactionId) { @@ -262,6 +286,9 @@ export const CustomAmountInfo: React.FC = memo( // Amount committed (pre-quote) funnel event; only fires once the amount // has been successfully applied above (no-op for non-money flows). trackAmountCommitted(); + quotesLastUpdatedBeforeAmountUpdateRef.current = + quotesLastUpdatedRef.current; + setIsAmountUpdateComplete(true); } catch (error) { const prefixed = prefixError(error, AMOUNT_UPDATE_ERROR_PREFIX); toastRef?.current?.showToast( @@ -269,8 +296,13 @@ export const CustomAmountInfo: React.FC = memo( toastRef?.current?.closeToast(), ), ); + setIsKeyboardVisible(true); + setIsAmountUpdateComplete(false); + setIsAmountUpdating(false); // Keep keyboard visible so the user can retry; do not advance the flow. return; + } finally { + isAmountUpdateInProgressRef.current = false; } EngineService.flushState(); setIsKeyboardVisible(false); @@ -285,6 +317,32 @@ export const CustomAmountInfo: React.FC = memo( updateTokenAmount, ]); + useEffect(() => { + const hasCompletedQuoteRequest = + quotesLastUpdated !== undefined && + quotesLastUpdated !== quotesLastUpdatedBeforeAmountUpdateRef.current; + + if ( + isAmountUpdateComplete && + (isQuotesLoading || hasCompletedQuoteRequest) + ) { + setIsAmountUpdateComplete(false); + setIsAmountUpdating(false); + return; + } + + if (!isAmountUpdateComplete) { + return; + } + + const handoffTimeout = setTimeout(() => { + setIsAmountUpdateComplete(false); + setIsAmountUpdating(false); + }, 0); + + return () => clearTimeout(handoffTimeout); + }, [isAmountUpdateComplete, isQuotesLoading, quotesLastUpdated]); + const wasPrefillPending = useRef(isPrefillPending); useEffect(() => { if (wasPrefillPending.current && !isPrefillPending) { @@ -329,9 +387,9 @@ export const CustomAmountInfo: React.FC = memo( !hasAccountNoFunds && (isPrefillPending || isDepositPrefillLoading) } - onPress={handleAmountPress} + onPress={showLoadingReview ? undefined : handleAmountPress} disabled={!hasPaymentOption} - showCursor={isKeyboardVisible} + showCursor={isKeyboardVisible && !showLoadingReview} /> {!hidePayTokenAmount && disablePay !== true && @@ -365,7 +423,10 @@ export const CustomAmountInfo: React.FC = memo( )} {isResultReady && ( - + {supportAccountSelection && !selectedFiatPaymentMethodId && !shouldHideAccountSelector && } @@ -374,26 +435,16 @@ export const CustomAmountInfo: React.FC = memo( {disablePay !== true && hasPaymentOption && ( )} - {!hasAccountNoFunds && - (showPaymentDetails && !isAwaitingPrefillResult ? ( - <> - - - {canSelectWithdrawToken ? ( - - ) : ( - - )} - - ) : ( - (isAddMusdIntent || isAwaitingPrefillResult) && ( - <> - - - - - ) - ))} + {!hasAccountNoFunds && ( + + )} )} @@ -436,6 +487,7 @@ export const CustomAmountInfo: React.FC = memo( isPrefillPending || isAwaitingPrefillResult } + isAmountUpdating={isAmountUpdating} onContinue={trackContinue} /> )} @@ -541,13 +593,65 @@ function BuySection() { ); } +function Quote({ + amountFiat, + canSelectWithdrawToken, + isAddMusdIntent, + isAwaitingPrefillResult, + isLoading, + showPaymentDetails, +}: Readonly<{ + amountFiat: string; + canSelectWithdrawToken: boolean; + isAddMusdIntent: boolean; + isAwaitingPrefillResult: boolean; + isLoading: boolean; + showPaymentDetails: boolean; +}>) { + if (isLoading) { + return ; + } + + if (showPaymentDetails && !isAwaitingPrefillResult) { + return ( + <> + + + {canSelectWithdrawToken ? ( + + ) : ( + + )} + + ); + } + + if (isAddMusdIntent || isAwaitingPrefillResult) { + return ; + } + + return null; +} + +function PaymentDetailsSkeleton() { + return ( + <> + + + + + ); +} + function ConfirmButton({ alertTitle, disableConfirm, + isAmountUpdating, onContinue, }: Readonly<{ alertTitle: string | undefined; disableConfirm?: boolean; + isAmountUpdating?: boolean; onContinue?: () => void; }>) { const { styles } = useStyles(styleSheet, {}); @@ -560,6 +664,7 @@ function ConfirmButton({ hasBlockingAlerts || isLoading || Boolean(disableConfirm) || + isAmountUpdating || isHeadlessBuyInProgress; const buttonLabel = useButtonLabel(); @@ -587,7 +692,7 @@ function ConfirmButton({ onPress={handleConfirm} testID={ConfirmationFooterSelectorIDs.CONFIRM_BUTTON} > - {alertTitle ?? buttonLabel} + {isAmountUpdating ? buttonLabel : (alertTitle ?? buttonLabel)} ); } diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayData.test.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayData.test.ts index f91315efe4d9..aaa90aec53c7 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionPayData.test.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayData.test.ts @@ -11,6 +11,7 @@ import { useIsTransactionPayLoading, useIsTransactionPayQuoteLoading, useTransactionPayQuotes, + useTransactionPayQuotesLastUpdated, useTransactionPayRequiredTokens, useTransactionPaySourceAmounts, useTransactionPayTotals, @@ -46,6 +47,8 @@ const TOTALS_MOCK = { total: { usd: '1000', fiat: '1234' }, } as TransactionPayTotals; +const QUOTES_LAST_UPDATED_MOCK = 123; + const FIAT_PAYMENT_MOCK: TransactionFiatPayment = { selectedPaymentMethodId: 'pm-123', amountFiat: '50.00', @@ -65,6 +68,7 @@ const state = merge( isMaxAmount: true, isPostQuote: true, quotes: [QUOTE_MOCK], + quotesLastUpdated: QUOTES_LAST_UPDATED_MOCK, sourceAmounts: [SOURCE_AMOUNT_MOCK], tokens: [REQUIRED_TOKEN_MOCK], totals: TOTALS_MOCK, @@ -94,6 +98,13 @@ describe('useTransactionPayData', () => { ).toStrictEqual([QUOTE_MOCK]); }); + it('returns the quote update timestamp', () => { + expect( + renderHookWithProvider(useTransactionPayQuotesLastUpdated, { state }) + .result.current, + ).toBe(QUOTES_LAST_UPDATED_MOCK); + }); + it('returns required tokens', () => { expect( renderHookWithProvider(useTransactionPayRequiredTokens, { state }).result diff --git a/app/components/Views/confirmations/hooks/pay/useTransactionPayData.ts b/app/components/Views/confirmations/hooks/pay/useTransactionPayData.ts index 1940330ad91d..f0ea2a985784 100644 --- a/app/components/Views/confirmations/hooks/pay/useTransactionPayData.ts +++ b/app/components/Views/confirmations/hooks/pay/useTransactionPayData.ts @@ -6,6 +6,7 @@ import { selectTransactionPayIsMaxAmountByTransactionId, selectTransactionPayIsPostQuoteByTransactionId, selectTransactionPayQuotesByTransactionId, + selectTransactionPayQuotesLastUpdatedByTransactionId, selectTransactionPaySourceAmountsByTransactionId, selectTransactionPayTokensByTransactionId, selectTransactionPayTotalsByTransactionId, @@ -17,6 +18,12 @@ export function useTransactionPayQuotes() { return useTransactionPayData(selectTransactionPayQuotesByTransactionId); } +export function useTransactionPayQuotesLastUpdated() { + return useTransactionPayData( + selectTransactionPayQuotesLastUpdatedByTransactionId, + ); +} + export function useTransactionPayRequiredTokens() { return useTransactionPayData(selectTransactionPayTokensByTransactionId); } diff --git a/app/selectors/transactionPayController.test.ts b/app/selectors/transactionPayController.test.ts index d570e2520529..808e5f494a93 100644 --- a/app/selectors/transactionPayController.test.ts +++ b/app/selectors/transactionPayController.test.ts @@ -4,6 +4,7 @@ import { selectTransactionPayTotalsByTransactionId, selectIsTransactionPayLoadingByTransactionId, selectTransactionPayQuotesByTransactionId, + selectTransactionPayQuotesLastUpdatedByTransactionId, selectTransactionPayTokensByTransactionId, selectTransactionPaymentTokenByTransactionId, selectTransactionPaySourceAmountsByTransactionId, @@ -140,6 +141,22 @@ describe('transactionPayController selectors', () => { }); }); + describe('selectTransactionPayQuotesLastUpdatedByTransactionId', () => { + it('returns the quote update timestamp from transaction data', () => { + const quotesLastUpdated = 123; + const state = createMockRootState({ + [TRANSACTION_ID_MOCK]: { quotesLastUpdated }, + }); + + const result = selectTransactionPayQuotesLastUpdatedByTransactionId( + state, + TRANSACTION_ID_MOCK, + ); + + expect(result).toBe(quotesLastUpdated); + }); + }); + describe('selectTransactionPayTokensByTransactionId', () => { it('returns tokens from transaction data', () => { const tokens = [{ address: '0x1' }]; diff --git a/app/selectors/transactionPayController.ts b/app/selectors/transactionPayController.ts index 73afb38d3225..6a7f70fdf4ef 100644 --- a/app/selectors/transactionPayController.ts +++ b/app/selectors/transactionPayController.ts @@ -28,6 +28,12 @@ export const selectTransactionPayQuotesByTransactionId = createSelector( (transactionData) => transactionData?.quotes, ); +export const selectTransactionPayQuotesLastUpdatedByTransactionId = + createSelector( + selectTransactionDataByTransactionId, + (transactionData) => transactionData?.quotesLastUpdated, + ); + export const selectTransactionPayTokensByTransactionId = createSelector( selectTransactionDataByTransactionId, (transactionData) => transactionData?.tokens ?? [],