From 57e4e01772b273d1082364f588971df0624323ac Mon Sep 17 00:00:00 2001 From: Myroslav Sviderok Date: Thu, 19 Dec 2024 12:56:39 +0200 Subject: [PATCH] Move new swap screen to a separate component --- src/components/TokenEnterAmount.tsx | 2 +- src/navigator/Navigator.tsx | 2 +- src/statsig/types.ts | 1 + src/swap/SwapAmountInput.tsx | 233 ++++ src/swap/SwapScreen.test.tsx | 253 ++-- src/swap/SwapScreen.tsx | 1490 ++++++++++---------- src/swap/SwapScreenV2.test.tsx | 1991 +++++++++++++++++++++++++++ src/swap/SwapScreenV2.tsx | 1151 ++++++++++++++++ src/swap/types.ts | 2 +- 9 files changed, 4205 insertions(+), 920 deletions(-) create mode 100644 src/swap/SwapAmountInput.tsx create mode 100644 src/swap/SwapScreenV2.test.tsx create mode 100644 src/swap/SwapScreenV2.tsx diff --git a/src/components/TokenEnterAmount.tsx b/src/components/TokenEnterAmount.tsx index 2c221b9124e..2c1ed442f5a 100644 --- a/src/components/TokenEnterAmount.tsx +++ b/src/components/TokenEnterAmount.tsx @@ -434,7 +434,7 @@ export default function TokenEnterAmount({ forwardedRef={inputRef} onChangeText={(value) => { handleSetStartPosition(undefined) - onInputChange?.(value.startsWith(localCurrencySymbol) ? value.slice(1) : value) + onInputChange?.(value) }} value={formattedInputValue} placeholderTextColor={Colors.gray3} diff --git a/src/navigator/Navigator.tsx b/src/navigator/Navigator.tsx index f105daa998a..46ebba8fb2c 100644 --- a/src/navigator/Navigator.tsx +++ b/src/navigator/Navigator.tsx @@ -113,7 +113,7 @@ import ValidateRecipientIntro, { validateRecipientIntroScreenNavOptions, } from 'src/send/ValidateRecipientIntro' import variables from 'src/styles/variables' -import SwapScreen from 'src/swap/SwapScreen' +import SwapScreen from 'src/swap/SwapScreenV2' import TokenDetailsScreen from 'src/tokens/TokenDetails' import TokenImportScreen from 'src/tokens/TokenImport' import TransactionDetailsScreen from 'src/transactions/feed/TransactionDetailsScreen' diff --git a/src/statsig/types.ts b/src/statsig/types.ts index c97ddcdcb64..1a788348f22 100644 --- a/src/statsig/types.ts +++ b/src/statsig/types.ts @@ -34,6 +34,7 @@ export enum StatsigFeatureGates { SHOW_UK_COMPLIANT_VARIANT = 'show_uk_compliant_variant', ALLOW_EARN_PARTIAL_WITHDRAWAL = 'allow_earn_partial_withdrawal', SHOW_ZERION_TRANSACTION_FEED = 'show_zerion_transaction_feed', + SHOW_NEW_ENTER_AMOUNT_FOR_SWAP = 'show_new_enter_amount_for_swap', } export enum StatsigExperiments { diff --git a/src/swap/SwapAmountInput.tsx b/src/swap/SwapAmountInput.tsx new file mode 100644 index 00000000000..44e860d021e --- /dev/null +++ b/src/swap/SwapAmountInput.tsx @@ -0,0 +1,233 @@ +import BigNumber from 'bignumber.js' +import React, { useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + Platform, + TextInput as RNTextInput, + StyleProp, + StyleSheet, + Text, + View, + ViewStyle, +} from 'react-native' +import SkeletonPlaceholder from 'react-native-skeleton-placeholder' +import TextInput from 'src/components/TextInput' +import TokenDisplay from 'src/components/TokenDisplay' +import TokenIcon, { IconSize } from 'src/components/TokenIcon' +import Touchable from 'src/components/Touchable' +import DownArrowIcon from 'src/icons/DownArrowIcon' +import { NETWORK_NAMES } from 'src/shared/conts' +import Colors from 'src/styles/colors' +import { typeScale } from 'src/styles/fonts' +import { Spacing } from 'src/styles/styles' +import { TokenBalance } from 'src/tokens/slice' + +interface Props { + onInputChange?(value: string): void + inputValue?: string | null + parsedInputValue?: BigNumber | null + onSelectToken(): void + token?: TokenBalance + loading: boolean + autoFocus?: boolean + inputError?: boolean + style?: StyleProp + buttonPlaceholder: string + editable?: boolean + borderRadius?: number +} + +const SwapAmountInput = ({ + onInputChange, + inputValue, + parsedInputValue, + onSelectToken, + token, + loading, + autoFocus, + inputError, + style, + buttonPlaceholder, + editable = true, + borderRadius, +}: Props) => { + const { t } = useTranslation() + + // the startPosition and textInputRef variables exist to ensure TextInput + // displays the start of the value for long values on Android + // https://github.com/facebook/react-native/issues/14845 + const [startPosition, setStartPosition] = useState(0) + const textInputRef = useRef(null) + + const handleSetStartPosition = (value?: number) => { + if (Platform.OS === 'android') { + setStartPosition(value) + } + } + + const touchableBorderStyle = token + ? { + borderTopLeftRadius: borderRadius, + borderTopRightRadius: borderRadius, + } + : borderRadius + + return ( + + + + {token ? ( + + + + {token.symbol} + + {t('swapScreen.onNetwork', { networkName: NETWORK_NAMES[token.networkId] })} + + + + ) : ( + {buttonPlaceholder} + )} + + + + {token && ( + + + { + handleSetStartPosition(undefined) + onInputChange?.(value) + }} + value={inputValue || undefined} + placeholder="0" + // hide input when loading so that the value is not visible under the loader + style={{ opacity: loading ? 0 : 1 }} + editable={editable && !loading} + keyboardType="decimal-pad" + // Work around for RN issue with Samsung keyboards + // https://github.com/facebook/react-native/issues/22005 + autoCapitalize="words" + autoFocus={autoFocus} + // unset lineHeight to allow ellipsis on long inputs on iOS + inputStyle={[styles.inputText, inputError ? styles.inputError : {}]} + testID="SwapAmountInput/Input" + onBlur={() => { + handleSetStartPosition(0) + }} + onFocus={() => { + handleSetStartPosition(inputValue?.length ?? 0) + }} + onSelectionChange={() => { + handleSetStartPosition(undefined) + }} + selection={ + Platform.OS === 'android' && typeof startPosition === 'number' + ? { start: startPosition } + : undefined + } + /> + {loading && ( + + + + + + )} + + {!loading && parsedInputValue?.gt(0) && token && ( + + + + )} + + )} + + ) +} + +const styles = StyleSheet.create({ + container: { + backgroundColor: Colors.gray1, + borderColor: Colors.gray2, + borderWidth: 1, + }, + tokenInfo: { + alignItems: 'center', + flexDirection: 'row', + }, + contentContainer: { + height: 64, + paddingHorizontal: Spacing.Regular16, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + bottomContainer: { + borderColor: Colors.gray2, + borderTopWidth: 1, + }, + inputContainer: { + flex: 1, + }, + inputError: { + color: Colors.error, + }, + inputText: { + ...typeScale.titleSmall, + fontSize: 26, + lineHeight: undefined, + paddingVertical: Spacing.Smallest8, + }, + loaderContainer: { + paddingVertical: Spacing.Small12, + }, + loader: { + height: '100%', + width: '100%', + }, + tokenName: { + ...typeScale.labelSemiBoldXSmall, + paddingHorizontal: 4, + }, + tokenNetwork: { + ...typeScale.bodyXSmall, + color: Colors.gray4, + paddingHorizontal: 4, + }, + tokenInfoText: { + paddingLeft: Spacing.Smallest8, + }, + tokenNamePlaceholder: { + ...typeScale.labelMedium, + paddingHorizontal: 4, + color: Colors.gray3, + }, + fiatValue: { + ...typeScale.bodyXSmall, + paddingLeft: Spacing.Smallest8, + maxWidth: '40%', + color: Colors.gray4, + paddingVertical: Spacing.Smallest8, + }, +}) + +export default SwapAmountInput diff --git a/src/swap/SwapScreen.test.tsx b/src/swap/SwapScreen.test.tsx index c3ffe6a7ea5..4252a6bcc63 100644 --- a/src/swap/SwapScreen.test.tsx +++ b/src/swap/SwapScreen.test.tsx @@ -9,7 +9,7 @@ import { showError } from 'src/alert/actions' import AppAnalytics from 'src/analytics/AppAnalytics' import { SwapEvents } from 'src/analytics/Events' import { ErrorMessages } from 'src/app/ErrorMessages' -import { APPROX_SYMBOL, getDisplayTokenName } from 'src/components/TokenEnterAmount' +import { APPROX_SYMBOL } from 'src/components/TokenEnterAmount' import { navigate } from 'src/navigator/NavigationService' import { Screens } from 'src/navigator/Screens' import { @@ -258,7 +258,7 @@ const selectSingleSwapToken = ( fireEvent.press(within(swapScreen).getByText('swapScreen.noUsdPriceWarning.ctaConfirm')) } - expect(within(swapAmountContainer).getByText(getDisplayTokenName(token!))).toBeTruthy() + expect(within(swapAmountContainer).getByText(tokenSymbol)).toBeTruthy() if (swapFieldType === Field.TO && !token!.priceUsd) { expect( @@ -342,10 +342,10 @@ describe('SwapScreen', () => { expect(getByText('swapScreen.title')).toBeTruthy() expect(getByText('swapScreen.confirmSwap')).toBeDisabled() - expect(within(swapFromContainer).getByText('tokenEnterAmount.selectToken')).toBeTruthy() + expect(within(swapFromContainer).getByText('swapScreen.selectTokenLabel')).toBeTruthy() expect(within(swapFromContainer).getByTestId('SwapAmountInput/TokenSelect')).toBeTruthy() - expect(within(swapToContainer).getByText('tokenEnterAmount.selectToken')).toBeTruthy() + expect(within(swapToContainer).getByText('swapScreen.selectTokenLabel')).toBeTruthy() expect(within(swapToContainer).getByTestId('SwapAmountInput/TokenSelect')).toBeTruthy() }) @@ -371,15 +371,15 @@ describe('SwapScreen', () => { it('should display the token set via fromTokenId prop', () => { const { swapFromContainer, swapToContainer } = renderScreen({ fromTokenId: mockCeurTokenId }) - expect(within(swapFromContainer).getByText('cEUR on Celo Alfajores')).toBeTruthy() - expect(within(swapToContainer).getByText('tokenEnterAmount.selectToken')).toBeTruthy() + expect(within(swapFromContainer).getByText('cEUR')).toBeTruthy() + expect(within(swapToContainer).getByText('swapScreen.selectTokenLabel')).toBeTruthy() }) it('should allow selecting tokens', async () => { const { swapFromContainer, swapToContainer, swapScreen } = renderScreen({}) - expect(within(swapFromContainer).getByText('tokenEnterAmount.selectToken')).toBeTruthy() - expect(within(swapToContainer).getByText('tokenEnterAmount.selectToken')).toBeTruthy() + expect(within(swapFromContainer).getByText('swapScreen.selectTokenLabel')).toBeTruthy() + expect(within(swapToContainer).getByText('swapScreen.selectTokenLabel')).toBeTruthy() selectSwapTokens('CELO', 'cUSD', swapScreen) @@ -427,7 +427,7 @@ describe('SwapScreen', () => { // finish the token selection fireEvent.press(within(fromTokenBottomSheet).getByText('Celo Dollar')) - expect(within(swapFromContainer).getByText('cUSD on Celo Alfajores')).toBeTruthy() + expect(within(swapFromContainer).getByText('cUSD')).toBeTruthy() fireEvent.press(within(swapToContainer).getByTestId('SwapAmountInput/TokenSelect')) @@ -459,7 +459,7 @@ describe('SwapScreen', () => { ) ).toBeFalsy() expect(tokenBottomSheet).toBeVisible() - expect(within(swapToContainer).getByText('tokenEnterAmount.selectToken')).toBeTruthy() + expect(within(swapToContainer).getByText('swapScreen.selectTokenLabel')).toBeTruthy() expect(AppAnalytics.track).not.toHaveBeenCalledWith( SwapEvents.swap_screen_confirm_token, expect.anything() @@ -473,8 +473,8 @@ describe('SwapScreen', () => { selectSingleSwapToken(swapToContainer, 'cUSD', swapScreen, Field.TO) selectSingleSwapToken(swapFromContainer, 'cUSD', swapScreen, Field.FROM) - expect(within(swapFromContainer).getByText('cUSD on Celo Alfajores')).toBeTruthy() - expect(within(swapToContainer).getByText('CELO on Celo Alfajores')).toBeTruthy() + expect(within(swapFromContainer).getByText('cUSD')).toBeTruthy() + expect(within(swapToContainer).getByText('CELO')).toBeTruthy() }) it('should swap the to/from tokens even if the to token was not selected', async () => { @@ -482,8 +482,8 @@ describe('SwapScreen', () => { selectSwapTokens('CELO', 'CELO', swapScreen) - expect(within(swapFromContainer).getByText('tokenEnterAmount.selectToken')).toBeTruthy() - expect(within(swapToContainer).getByText('CELO on Celo Alfajores')).toBeTruthy() + expect(within(swapFromContainer).getByText('swapScreen.selectTokenLabel')).toBeTruthy() + expect(within(swapToContainer).getByText('CELO')).toBeTruthy() }) it('should keep the to amount in sync with the exchange rate', async () => { @@ -494,10 +494,7 @@ describe('SwapScreen', () => { selectSwapTokens('CELO', 'cUSD', swapScreen) - fireEvent.changeText( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'), - '1.234' - ) + fireEvent.changeText(within(swapFromContainer).getByTestId('SwapAmountInput/Input'), '1.234') await act(() => { jest.runOnlyPendingTimers() @@ -506,16 +503,14 @@ describe('SwapScreen', () => { expect(getByTestId('SwapTransactionDetails/ExchangeRate')).toHaveTextContent( '1 CELO ≈ 1.23456 cUSD' ) - expect( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value - ).toBe('1.234') - expect( - within(swapFromContainer).getByTestId('SwapAmountInput/ExchangeAmount') - ).toHaveTextContent(`${APPROX_SYMBOL} ₱21.43`) - expect( - within(swapToContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value - ).toBe('1.5234566652') - expect(within(swapToContainer).getByTestId('SwapAmountInput/ExchangeAmount')).toHaveTextContent( + expect(within(swapFromContainer).getByTestId('SwapAmountInput/Input').props.value).toBe('1.234') + expect(within(swapFromContainer).getByTestId('SwapAmountInput/FiatValue')).toHaveTextContent( + `${APPROX_SYMBOL} ₱21.43` + ) + expect(within(swapToContainer).getByTestId('SwapAmountInput/Input').props.value).toBe( + '1.5234566652' + ) + expect(within(swapToContainer).getByTestId('SwapAmountInput/FiatValue')).toHaveTextContent( `${APPROX_SYMBOL} ₱2.03` ) expect(getByText('swapScreen.confirmSwap')).not.toBeDisabled() @@ -528,10 +523,7 @@ describe('SwapScreen', () => { ) selectSwapTokens('CELO', 'cUSD', swapScreen) - fireEvent.changeText( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'), - '1.234' - ) + fireEvent.changeText(within(swapFromContainer).getByTestId('SwapAmountInput/Input'), '1.234') await act(() => { jest.runOnlyPendingTimers() @@ -551,12 +543,10 @@ describe('SwapScreen', () => { expect(getByTestId('SwapTransactionDetails/ExchangeRate')).toHaveTextContent( '1 CELO ≈ 1.23456 cUSD' ) - expect( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value - ).toBe('1.234') - expect( - within(swapToContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value - ).toBe('1.5234566652') + expect(within(swapFromContainer).getByTestId('SwapAmountInput/Input').props.value).toBe('1.234') + expect(within(swapToContainer).getByTestId('SwapAmountInput/Input').props.value).toBe( + '1.5234566652' + ) expect(getByText('swapScreen.confirmSwap')).not.toBeDisabled() }) @@ -596,10 +586,7 @@ describe('SwapScreen', () => { }) selectSwapTokens('CELO', 'USDC', swapScreen) - fireEvent.changeText( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'), - '10' - ) + fireEvent.changeText(within(swapFromContainer).getByTestId('SwapAmountInput/Input'), '10') await act(() => { jest.runOnlyPendingTimers() }) @@ -634,10 +621,7 @@ describe('SwapScreen', () => { }) selectSwapTokens('cUSD', 'USDC', swapScreen) - fireEvent.changeText( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'), - '10' - ) + fireEvent.changeText(within(swapFromContainer).getByTestId('SwapAmountInput/Input'), '10') await act(() => { jest.runOnlyPendingTimers() }) @@ -671,10 +655,7 @@ describe('SwapScreen', () => { }) selectSwapTokens('CELO', 'USDC', swapScreen) - fireEvent.changeText( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'), - '5' - ) + fireEvent.changeText(within(swapFromContainer).getByTestId('SwapAmountInput/Input'), '5') await act(() => { jest.runOnlyPendingTimers() }) @@ -718,10 +699,7 @@ describe('SwapScreen', () => { // select 100000 CELO to cUSD swap selectSwapTokens('CELO', 'cUSD', swapScreen) - fireEvent.changeText( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'), - '100000' - ) + fireEvent.changeText(within(swapFromContainer).getByTestId('SwapAmountInput/Input'), '100000') await act(() => { jest.runOnlyPendingTimers() }) @@ -749,10 +727,7 @@ describe('SwapScreen', () => { ) // select 100 CELO to cUSD swap - fireEvent.changeText( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'), - '100' - ) + fireEvent.changeText(within(swapFromContainer).getByTestId('SwapAmountInput/Input'), '100') await act(() => { jest.runOnlyPendingTimers() }) @@ -794,10 +769,7 @@ describe('SwapScreen', () => { // select 100000 CELO to cUSD swap selectSwapTokens('CELO', 'cUSD', swapScreen) - fireEvent.changeText( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'), - '100000' - ) + fireEvent.changeText(within(swapFromContainer).getByTestId('SwapAmountInput/Input'), '100000') await act(() => { jest.runOnlyPendingTimers() }) @@ -825,10 +797,7 @@ describe('SwapScreen', () => { ) // select 100 CELO to cUSD swap - fireEvent.changeText( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'), - '100' - ) + fireEvent.changeText(within(swapFromContainer).getByTestId('SwapAmountInput/Input'), '100') await act(() => { jest.runOnlyPendingTimers() }) @@ -855,10 +824,7 @@ describe('SwapScreen', () => { }) selectSwapTokens('CELO', 'POOF', swapScreen) // no priceUsd - fireEvent.changeText( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'), - '100' - ) + fireEvent.changeText(within(swapFromContainer).getByTestId('SwapAmountInput/Input'), '100') await act(() => { jest.runOnlyPendingTimers() }) @@ -886,10 +852,7 @@ describe('SwapScreen', () => { ) selectSwapTokens('CELO', 'cUSD', swapScreen) - fireEvent.changeText( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'), - '1,234' - ) + fireEvent.changeText(within(swapFromContainer).getByTestId('SwapAmountInput/Input'), '1,234') await act(() => { jest.runOnlyPendingTimers() @@ -909,16 +872,14 @@ describe('SwapScreen', () => { expect(getByTestId('SwapTransactionDetails/ExchangeRate')).toHaveTextContent( '1 CELO ≈ 1,23456 cUSD' ) - expect( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value - ).toBe('1,234') - expect( - within(swapFromContainer).getByTestId('SwapAmountInput/ExchangeAmount') - ).toHaveTextContent(`${APPROX_SYMBOL} ₱21,43`) - expect( - within(swapToContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value - ).toBe('1,5234566652') - expect(within(swapToContainer).getByTestId('SwapAmountInput/ExchangeAmount')).toHaveTextContent( + expect(within(swapFromContainer).getByTestId('SwapAmountInput/Input').props.value).toBe('1,234') + expect(within(swapFromContainer).getByTestId('SwapAmountInput/FiatValue')).toHaveTextContent( + `${APPROX_SYMBOL} ₱21,43` + ) + expect(within(swapToContainer).getByTestId('SwapAmountInput/Input').props.value).toBe( + '1,5234566652' + ) + expect(within(swapToContainer).getByTestId('SwapAmountInput/FiatValue')).toHaveTextContent( `${APPROX_SYMBOL} ₱2,03` ) expect(getByTestId('SwapTransactionDetails/Slippage')).toHaveTextContent('0,3%') @@ -972,12 +933,12 @@ describe('SwapScreen', () => { '1 CELO ≈ 1.23456 cUSD' ) ) - expect( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value - ).toBe(expectedFromAmount) - expect( - within(swapToContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value - ).toBe(expectedToAmount) + expect(within(swapFromContainer).getByTestId('SwapAmountInput/Input').props.value).toBe( + expectedFromAmount + ) + expect(within(swapToContainer).getByTestId('SwapAmountInput/Input').props.value).toBe( + expectedToAmount + ) expect(getByText('swapScreen.confirmSwap')).not.toBeDisabled() } ) @@ -1002,10 +963,7 @@ describe('SwapScreen', () => { uri: mockTxFeesLearnMoreUrl, }) - fireEvent.changeText( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'), - '1.234' - ) + fireEvent.changeText(within(swapFromContainer).getByTestId('SwapAmountInput/Input'), '1.234') await waitFor(() => expect(queryByTestId('MaxSwapAmountWarning')).toBeFalsy()) }) @@ -1037,17 +995,10 @@ describe('SwapScreen', () => { expect(mockFetch.mock.calls.length).toEqual(1) expect(getByText('swapScreen.confirmSwap')).not.toBeDisabled() - fireEvent.changeText( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'), - '' - ) + fireEvent.changeText(within(swapFromContainer).getByTestId('SwapAmountInput/Input'), '') - expect( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value - ).toBe('') - expect( - within(swapToContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value - ).toBe('') + expect(within(swapFromContainer).getByTestId('SwapAmountInput/Input').props.value).toBe('') + expect(within(swapToContainer).getByTestId('SwapAmountInput/Input').props.value).toBe('') expect(getByText('swapScreen.confirmSwap')).toBeDisabled() expect(mockFetch.mock.calls.length).toEqual(1) @@ -1060,14 +1011,12 @@ describe('SwapScreen', () => { expect(getByTestId('SwapTransactionDetails/ExchangeRate')).toHaveTextContent( '1 CELO ≈ 1.23456 cUSD' ) - expect( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value - ).toBe( + expect(within(swapFromContainer).getByTestId('SwapAmountInput/Input').props.value).toBe( '10' // matching the value inside the mocked store ) - expect( - within(swapToContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value - ).toBe('12.345678') + expect(within(swapToContainer).getByTestId('SwapAmountInput/Input').props.value).toBe( + '12.345678' + ) expect(getByText('swapScreen.confirmSwap')).not.toBeDisabled() expect(mockFetch.mock.calls.length).toEqual(2) }) @@ -1081,12 +1030,8 @@ describe('SwapScreen', () => { selectSwapTokens('CELO', 'cUSD', swapScreen) await selectMaxFromAmount(swapScreen) - expect( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value - ).toBe('0') - expect( - within(swapToContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value - ).toBe('') + expect(within(swapFromContainer).getByTestId('SwapAmountInput/Input').props.value).toBe('0') + expect(within(swapToContainer).getByTestId('SwapAmountInput/Input').props.value).toBe('') expect(mockFetch).not.toHaveBeenCalled() expect(getByText('swapScreen.confirmSwap')).toBeDisabled() }) @@ -1097,10 +1042,7 @@ describe('SwapScreen', () => { const { swapFromContainer, getByText, store, swapScreen } = renderScreen({}) selectSwapTokens('CELO', 'cUSD', swapScreen) - fireEvent.changeText( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'), - '1.234' - ) + fireEvent.changeText(within(swapFromContainer).getByTestId('SwapAmountInput/Input'), '1.234') await act(() => { jest.runOnlyPendingTimers() @@ -1118,10 +1060,7 @@ describe('SwapScreen', () => { const { swapFromContainer, getByText, swapScreen } = renderScreen({}) selectSwapTokens('CELO', 'cUSD', swapScreen) - fireEvent.changeText( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'), - '1.234' - ) + fireEvent.changeText(within(swapFromContainer).getByTestId('SwapAmountInput/Input'), '1.234') await act(() => { jest.runOnlyPendingTimers() @@ -1193,10 +1132,7 @@ describe('SwapScreen', () => { const { getByText, store, swapScreen, swapFromContainer } = renderScreen({}) selectSwapTokens('cUSD', 'CELO', swapScreen) - fireEvent.changeText( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'), - '10' - ) + fireEvent.changeText(within(swapFromContainer).getByTestId('SwapAmountInput/Input'), '10') await act(() => { jest.runOnlyPendingTimers() @@ -1244,10 +1180,7 @@ describe('SwapScreen', () => { const { swapScreen, swapFromContainer, getByText, store } = renderScreen({}) selectSwapTokens('CELO', 'cUSD', swapScreen) - fireEvent.changeText( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'), - '1.5' - ) + fireEvent.changeText(within(swapFromContainer).getByTestId('SwapAmountInput/Input'), '1.5') await act(() => { jest.runOnlyPendingTimers() @@ -1355,8 +1288,8 @@ describe('SwapScreen', () => { }) const { swapFromContainer, swapToContainer } = renderScreen({}) - expect(within(swapFromContainer).queryByTestId('SwapAmountInput/TokenAmountInput')).toBeFalsy() - expect(within(swapToContainer).queryByTestId('SwapAmountInput/TokenAmountInput')).toBeFalsy() + expect(within(swapFromContainer).queryByTestId('SwapAmountInput/Input')).toBeFalsy() + expect(within(swapToContainer).queryByTestId('SwapAmountInput/Input')).toBeFalsy() }) it('should be able to switch tokens by pressing arrow button', async () => { @@ -1367,13 +1300,13 @@ describe('SwapScreen', () => { selectSwapTokens('CELO', 'cUSD', swapScreen) - expect(within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput')).toBeTruthy() - expect(within(swapToContainer).getByTestId('SwapAmountInput/TokenAmountInput')).toBeTruthy() + expect(within(swapFromContainer).getByTestId('SwapAmountInput/Input')).toBeTruthy() + expect(within(swapToContainer).getByTestId('SwapAmountInput/Input')).toBeTruthy() fireEvent.press(getByTestId('SwapScreen/SwitchTokens')) - expect(within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput')).toBeTruthy() - expect(within(swapToContainer).getByTestId('SwapAmountInput/TokenAmountInput')).toBeTruthy() + expect(within(swapFromContainer).getByTestId('SwapAmountInput/Input')).toBeTruthy() + expect(within(swapToContainer).getByTestId('SwapAmountInput/Input')).toBeTruthy() }) it('should disable editing of the buy token amount', () => { @@ -1381,12 +1314,8 @@ describe('SwapScreen', () => { selectSwapTokens('CELO', 'cUSD', swapScreen) - expect( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.editable - ).toBe(true) - expect( - within(swapToContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.editable - ).toBe(false) + expect(within(swapFromContainer).getByTestId('SwapAmountInput/Input').props.editable).toBe(true) + expect(within(swapToContainer).getByTestId('SwapAmountInput/Input').props.editable).toBe(false) }) it('should display the correct transaction details', async () => { @@ -1397,10 +1326,7 @@ describe('SwapScreen', () => { }) selectSwapTokens('CELO', 'cUSD', swapScreen) - fireEvent.changeText( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'), - '2' - ) + fireEvent.changeText(within(swapFromContainer).getByTestId('SwapAmountInput/Input'), '2') await act(() => { jest.runOnlyPendingTimers() @@ -1509,10 +1435,7 @@ describe('SwapScreen', () => { expect(getByText('swapScreen.confirmSwap')).not.toBeDisabled() // Now change some input, and the warning should disappear - fireEvent.changeText( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'), - '2' - ) + fireEvent.changeText(within(swapFromContainer).getByTestId('SwapAmountInput/Input'), '2') expect(queryByText('swapScreen.confirmSwapFailedWarning.title')).toBeFalsy() expect(queryByText('swapScreen.confirmSwapFailedWarning.body')).toBeFalsy() @@ -1649,9 +1572,7 @@ describe('SwapScreen', () => { jest.runOnlyPendingTimers() }) - expect( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value - ).toBe( + expect(within(swapFromContainer).getByTestId('SwapAmountInput/Input').props.value).toBe( '1.234' // matching the value inside the mocked store ) @@ -1674,9 +1595,7 @@ describe('SwapScreen', () => { // Now, decrease the swap amount fireEvent.press(confirmDecrease) - expect( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value - ).toBe( + expect(within(swapFromContainer).getByTestId('SwapAmountInput/Input').props.value).toBe( '1.2077776' // 1.234 minus the max fee calculated for the swap ) @@ -1704,10 +1623,7 @@ describe('SwapScreen', () => { }) selectSwapTokens('CELO', 'cUSD', swapScreen) - fireEvent.changeText( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'), - '1.233' - ) + fireEvent.changeText(within(swapFromContainer).getByTestId('SwapAmountInput/Input'), '1.233') await act(() => { jest.runOnlyPendingTimers() @@ -1732,9 +1648,7 @@ describe('SwapScreen', () => { // Now, decrease the swap amount fireEvent.press(confirmDecrease) - expect( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value - ).toBe( + expect(within(swapFromContainer).getByTestId('SwapAmountInput/Input').props.value).toBe( '1.2077776' // 1.234 (max balance) minus the max fee calculated for the swap ) @@ -1762,10 +1676,7 @@ describe('SwapScreen', () => { }) selectSwapTokens('CELO', 'cUSD', swapScreen) - fireEvent.changeText( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput'), - '1' - ) + fireEvent.changeText(within(swapFromContainer).getByTestId('SwapAmountInput/Input'), '1') await act(() => { jest.runOnlyPendingTimers() @@ -1793,9 +1704,7 @@ describe('SwapScreen', () => { jest.runOnlyPendingTimers() }) - expect( - within(swapFromContainer).getByTestId('SwapAmountInput/TokenAmountInput').props.value - ).toBe( + expect(within(swapFromContainer).getByTestId('SwapAmountInput/Input').props.value).toBe( '1.234' // matching the value inside the mocked store ) diff --git a/src/swap/SwapScreen.tsx b/src/swap/SwapScreen.tsx index 6661e919288..43812c1d1ec 100644 --- a/src/swap/SwapScreen.tsx +++ b/src/swap/SwapScreen.tsx @@ -1,9 +1,11 @@ import { NativeStackScreenProps } from '@react-navigation/native-stack' +import { PayloadAction, createSlice } from '@reduxjs/toolkit' import BigNumber from 'bignumber.js' -import React, { useEffect, useMemo, useRef, useState } from 'react' +import React, { useEffect, useMemo, useReducer, useRef } from 'react' import { Trans, useTranslation } from 'react-i18next' -import { TextInput as RNTextInput, StyleSheet, Text, View } from 'react-native' +import { StyleSheet, Text, View } from 'react-native' import { ScrollView } from 'react-native-gesture-handler' +import { getNumberFormatSettings } from 'react-native-localize' import { SafeAreaView } from 'react-native-safe-area-context' import { showError } from 'src/alert/actions' import AppAnalytics from 'src/analytics/AppAnalytics' @@ -15,10 +17,6 @@ import Button, { BtnSizes, BtnTypes } from 'src/components/Button' import InLineNotification, { NotificationVariant } from 'src/components/InLineNotification' import Toast from 'src/components/Toast' import TokenBottomSheet, { TokenPickerOrigin } from 'src/components/TokenBottomSheet' -import TokenEnterAmount, { - FETCH_UPDATED_TRANSACTIONS_DEBOUNCE_TIME_MS, - useEnterAmount, -} from 'src/components/TokenEnterAmount' import Touchable from 'src/components/Touchable' import CustomHeader from 'src/components/header/CustomHeader' import ArrowDown from 'src/icons/ArrowDown' @@ -39,15 +37,16 @@ import { typeScale } from 'src/styles/fonts' import { Spacing } from 'src/styles/styles' import variables from 'src/styles/variables' import FeeInfoBottomSheet from 'src/swap/FeeInfoBottomSheet' +import SwapAmountInput from 'src/swap/SwapAmountInput' import SwapTransactionDetails from 'src/swap/SwapTransactionDetails' import getCrossChainFee from 'src/swap/getCrossChainFee' import { getSwapTxsAnalyticsProperties } from 'src/swap/getSwapTxsAnalyticsProperties' import { currentSwapSelector, priceImpactWarningThresholdSelector } from 'src/swap/selectors' import { swapStart } from 'src/swap/slice' -import { AppFeeAmount, Field, SwapFeeAmount } from 'src/swap/types' +import { AppFeeAmount, Field, SwapAmount, SwapFeeAmount } from 'src/swap/types' import useFilterChips from 'src/swap/useFilterChips' import useSwapQuote, { NO_QUOTE_ERROR_MESSAGE, QuoteResult } from 'src/swap/useSwapQuote' -import { useSwappableTokens } from 'src/tokens/hooks' +import { useSwappableTokens, useTokenInfo } from 'src/tokens/hooks' import { feeCurrenciesSelector, feeCurrenciesWithPositiveBalancesSelector, @@ -57,6 +56,7 @@ import { TokenBalance } from 'src/tokens/slice' import { getSupportedNetworkIdsForSwap } from 'src/tokens/utils' import { NetworkId } from 'src/transactions/types' import Logger from 'src/utils/Logger' +import { parseInputAmount } from 'src/utils/parsing' import { getFeeCurrencyAndAmounts } from 'src/viem/prepareTransactions' import { getSerializablePreparedTransactions } from 'src/viem/preparedTransactionSerialization' import networkConfig from 'src/web3/networkConfig' @@ -64,7 +64,153 @@ import { v4 as uuidv4 } from 'uuid' const TAG = 'SwapScreen' -function getNetworkFee(quote: QuoteResult | null): SwapFeeAmount | undefined { +const FETCH_UPDATED_QUOTE_DEBOUNCE_TIME = 200 +const DEFAULT_INPUT_SWAP_AMOUNT: SwapAmount = { + [Field.FROM]: '', + [Field.TO]: '', +} + +type SelectingNoUsdPriceToken = TokenBalance & { + tokenPositionInList: number +} +interface SwapState { + fromTokenId: string | undefined + toTokenId: string | undefined + // Raw input values (can contain region specific decimal separators) + inputSwapAmount: SwapAmount + selectingField: Field | null + selectingNoUsdPriceToken: SelectingNoUsdPriceToken | null + confirmingSwap: boolean + // Keep track of which swap is currently being executed from this screen + // This is because there could be multiple swaps happening at the same time + startedSwapId: string | null + switchedToNetworkId: NetworkId | null + selectedPercentage: number | null +} + +function getInitialState(fromTokenId?: string, toTokenId?: string): SwapState { + return { + fromTokenId, + toTokenId, + inputSwapAmount: DEFAULT_INPUT_SWAP_AMOUNT, + selectingField: null, + selectingNoUsdPriceToken: null, + confirmingSwap: false, + startedSwapId: null, + switchedToNetworkId: null, + selectedPercentage: null, + } +} + +const swapSlice = createSlice({ + name: 'swapSlice', + initialState: getInitialState, + reducers: { + changeAmount: (state, action: PayloadAction<{ value: string }>) => { + const { value } = action.payload + state.confirmingSwap = false + state.startedSwapId = null + if (!value) { + state.inputSwapAmount = DEFAULT_INPUT_SWAP_AMOUNT + return + } + // Regex to match only numbers and one decimal separator + const sanitizedValue = value.match(/^(?:\d+[.,]?\d*|[.,]\d*|[.,])$/)?.join('') + if (!sanitizedValue) { + return + } + state.inputSwapAmount[Field.FROM] = sanitizedValue + state.selectedPercentage = null + }, + chooseFromAmountPercentage: ( + state, + action: PayloadAction<{ fromTokenBalance: BigNumber; percentage: number }> + ) => { + const { fromTokenBalance, percentage } = action.payload + state.confirmingSwap = false + state.startedSwapId = null + state.selectedPercentage = percentage + // If the max percentage is selected, try the current balance first, and we will prompt the user if it's too high + state.inputSwapAmount[Field.FROM] = fromTokenBalance.multipliedBy(percentage).toFormat({ + decimalSeparator: getNumberFormatSettings().decimalSeparator, + }) + }, + startSelectToken: (state, action: PayloadAction<{ fieldType: Field }>) => { + state.selectingField = action.payload.fieldType + state.confirmingSwap = false + }, + selectNoUsdPriceToken: ( + state, + action: PayloadAction<{ + token: SelectingNoUsdPriceToken + }> + ) => { + state.selectingNoUsdPriceToken = action.payload.token + }, + unselectNoUsdPriceToken: (state) => { + state.selectingNoUsdPriceToken = null + }, + selectTokens: ( + state, + action: PayloadAction<{ + fromTokenId: string | undefined + toTokenId: string | undefined + switchedToNetworkId: NetworkId | null + }> + ) => { + const { fromTokenId, toTokenId, switchedToNetworkId } = action.payload + state.confirmingSwap = false + if (fromTokenId !== state.fromTokenId || toTokenId !== state.toTokenId) { + state.startedSwapId = null + } + state.fromTokenId = fromTokenId + state.toTokenId = toTokenId + state.switchedToNetworkId = switchedToNetworkId + state.selectingNoUsdPriceToken = null + state.selectedPercentage = null + }, + quoteUpdated: (state, action: PayloadAction<{ quote: QuoteResult | null }>) => { + const { quote } = action.payload + state.confirmingSwap = false + if (!quote) { + state.inputSwapAmount[Field.TO] = '' + return + } + + const { decimalSeparator } = getNumberFormatSettings() + const parsedAmount = parseInputAmount(state.inputSwapAmount[Field.FROM], decimalSeparator) + + const newAmount = parsedAmount.multipliedBy(new BigNumber(quote.price)) + state.inputSwapAmount[Field.TO] = newAmount.toFormat({ + decimalSeparator, + }) + }, + // When the user presses the confirm swap button + startConfirmSwap: (state) => { + state.confirmingSwap = true + }, + // When the swap is ready to be executed + startSwap: (state, action: PayloadAction<{ swapId: string }>) => { + state.startedSwapId = action.payload.swapId + }, + }, +}) + +const { + changeAmount, + chooseFromAmountPercentage, + startSelectToken, + selectTokens, + quoteUpdated, + startConfirmSwap, + startSwap, + selectNoUsdPriceToken, + unselectNoUsdPriceToken, +} = swapSlice.actions + +const swapStateReducer = swapSlice.reducer + +function getNetworkFee(quote: QuoteResult | null) { const { feeCurrency, maxFeeAmount, estimatedFeeAmount } = getFeeCurrencyAndAmounts( quote?.preparedTransactions ) @@ -82,355 +228,150 @@ type Props = NativeStackScreenProps export function SwapScreen({ route }: Props) { const { t } = useTranslation() const dispatch = useDispatch() - const allowCrossChainSwaps = getFeatureGate(StatsigFeatureGates.ALLOW_CROSS_CHAIN_SWAPS) - const showUKCompliantVariant = getFeatureGate(StatsigFeatureGates.SHOW_UK_COMPLIANT_VARIANT) - const { swappableFromTokens, swappableToTokens, areSwapTokensShuffled } = useSwappableTokens() - const { links } = getDynamicConfigParams(DynamicConfigs[StatsigDynamicConfigs.APP_CONFIG]) - const { maxSlippagePercentage, enableAppFee } = getDynamicConfigParams( - DynamicConfigs[StatsigDynamicConfigs.SWAP_CONFIG] - ) - - const inputFromRef = useRef(null) - const inputToRef = useRef(null) const tokenBottomSheetFromRef = useRef(null) const tokenBottomSheetToRef = useRef(null) + const tokenBottomSheetRefs = { + [Field.FROM]: tokenBottomSheetFromRef, + [Field.TO]: tokenBottomSheetToRef, + } const exchangeRateInfoBottomSheetRef = useRef(null) const feeInfoBottomSheetRef = useRef(null) const slippageInfoBottomSheetRef = useRef(null) const estimatedDurationBottomSheetRef = useRef(null) - const [noUsdPriceToken, setNoUsdPriceToken] = useState< - { token: TokenBalance; tokenPositionInList: number } | undefined - >(undefined) - const [selectedPercentage, setSelectedPercentage] = useState(null) - const [startedSwapId, setStartedSwapId] = useState(undefined) - const [switchedToNetworkId, setSwitchedToNetworkId] = useState<{ - networkId: NetworkId - field: Field - } | null>(null) - const [fromToken, setFromToken] = useState(() => { - if (!route.params?.fromTokenId) return undefined - return swappableFromTokens.find((token) => token.tokenId === route.params!.fromTokenId) - }) - const [toToken, setToToken] = useState(() => { - if (!route.params?.toTokenId) return undefined - return swappableToTokens.find((token) => token.tokenId === route.params!.toTokenId) - }) + const allowCrossChainSwaps = getFeatureGate(StatsigFeatureGates.ALLOW_CROSS_CHAIN_SWAPS) + const showUKCompliantVariant = getFeatureGate(StatsigFeatureGates.SHOW_UK_COMPLIANT_VARIANT) + + const { decimalSeparator } = getNumberFormatSettings() + + const { maxSlippagePercentage, enableAppFee } = getDynamicConfigParams( + DynamicConfigs[StatsigDynamicConfigs.SWAP_CONFIG] + ) + const { links } = getDynamicConfigParams(DynamicConfigs[StatsigDynamicConfigs.APP_CONFIG]) + const parsedSlippagePercentage = new BigNumber(maxSlippagePercentage).toFormat() + + const { swappableFromTokens, swappableToTokens, areSwapTokensShuffled } = useSwappableTokens() - const currentSwap = useSelector(currentSwapSelector) - const localCurrency = useSelector(getLocalCurrencyCode) const priceImpactWarningThreshold = useSelector(priceImpactWarningThresholdSelector) + const tokensById = useSelector((state) => tokensByIdSelector(state, getSupportedNetworkIdsForSwap()) ) - const crossChainFeeCurrency = useSelector((state) => - feeCurrenciesSelector(state, fromToken?.networkId || networkConfig.defaultNetworkId) - ).find((token) => token.isNative) + + const initialFromTokenId = route.params?.fromTokenId + const initialToTokenId = route.params?.toTokenId + const initialToTokenNetworkId = route.params?.toTokenNetworkId + const [state, localDispatch] = useReducer( + swapStateReducer, + getInitialState(initialFromTokenId, initialToTokenId) + ) + const { + fromTokenId, + toTokenId, + inputSwapAmount, + selectingField, + selectingNoUsdPriceToken, + confirmingSwap, + switchedToNetworkId, + startedSwapId, + selectedPercentage, + } = state + + const filterChipsFrom = useFilterChips(Field.FROM) + const filterChipsTo = useFilterChips(Field.TO, initialToTokenNetworkId) + + const { fromToken, toToken } = useMemo(() => { + const fromToken = swappableFromTokens.find((token) => token.tokenId === fromTokenId) + const toToken = swappableToTokens.find((token) => token.tokenId === toTokenId) + return { fromToken, toToken } + }, [fromTokenId, toTokenId, swappableFromTokens, swappableToTokens]) + + const fromTokenBalance = useTokenInfo(fromToken?.tokenId)?.balance ?? new BigNumber(0) + + const currentSwap = useSelector(currentSwapSelector) + const swapStatus = startedSwapId === currentSwap?.id ? currentSwap.status : null + const feeCurrenciesWithPositiveBalances = useSelector((state) => feeCurrenciesWithPositiveBalancesSelector( state, fromToken?.networkId || networkConfig.defaultNetworkId ) ) + const localCurrency = useSelector(getLocalCurrencyCode) const { quote, refreshQuote, fetchSwapQuoteError, fetchingSwapQuote, clearQuote } = useSwapQuote({ networkId: fromToken?.networkId || networkConfig.defaultNetworkId, slippagePercentage: maxSlippagePercentage, enableAppFee: enableAppFee, - onError: (error) => { - if (!error.message.includes(NO_QUOTE_ERROR_MESSAGE)) { - dispatch(showError(ErrorMessages.FETCH_SWAP_QUOTE_FAILED)) - } - }, - onSuccess: (newQuote) => { - if (!newQuote) { - replaceAmountTo('') - return - } + }) - if (!processedAmountsFrom.token.bignum) { - return - } + // Parsed swap amounts (BigNumber) + const parsedSwapAmount = useMemo( + () => ({ + [Field.FROM]: parseInputAmount(inputSwapAmount[Field.FROM], decimalSeparator), + [Field.TO]: parseInputAmount(inputSwapAmount[Field.TO], decimalSeparator), + }), + [inputSwapAmount] + ) - const newAmount = processedAmountsFrom.token.bignum - .multipliedBy(new BigNumber(newQuote.price)) - .toString() + const shouldShowMaxSwapAmountWarning = + feeCurrenciesWithPositiveBalances.length === 1 && + fromToken?.tokenId === feeCurrenciesWithPositiveBalances[0].tokenId && + fromTokenBalance.gt(0) && + parsedSwapAmount[Field.FROM].gte(fromTokenBalance) - replaceAmountTo(newAmount) - }, - }) + const fromSwapAmountError = confirmingSwap && parsedSwapAmount[Field.FROM].gt(fromTokenBalance) - const { - amount: amountFrom, - amountType, - processedAmounts: processedAmountsFrom, - handleAmountInputChange, - handleToggleAmountType, - handleSelectPercentageAmount, - } = useEnterAmount({ - inputRef: inputFromRef, - token: fromToken, - onHandleAmountInputChange: () => { - setSelectedPercentage(null) - }, - }) + const quoteUpdatePending = + (quote && + (quote.fromTokenId !== fromToken?.tokenId || + quote.toTokenId !== toToken?.tokenId || + !quote.swapAmount.eq(parsedSwapAmount[Field.FROM]))) || + fetchingSwapQuote - const { - amount: amountTo, - processedAmounts: processedAmountsTo, - replaceAmount: replaceAmountTo, - } = useEnterAmount({ token: toToken, inputRef: inputToRef }) - - const filterChipsFrom = useFilterChips(Field.FROM) - const filterChipsTo = useFilterChips(Field.TO, route.params?.toTokenNetworkId) - const parsedSlippagePercentage = new BigNumber(maxSlippagePercentage).toFormat() - const crossChainFee = getCrossChainFee(quote, crossChainFeeCurrency) - const swapStatus = startedSwapId === currentSwap?.id ? currentSwap?.status : null const confirmSwapIsLoading = swapStatus === 'started' const confirmSwapFailed = swapStatus === 'error' - const switchedToNetworkName = switchedToNetworkId && NETWORK_NAMES[switchedToNetworkId.networkId] - const showCrossChainSwapNotification = - toToken && fromToken && toToken.networkId !== fromToken.networkId && allowCrossChainSwaps - const feeCurrencies = - quote && quote.preparedTransactions.type === 'not-enough-balance-for-gas' - ? quote.preparedTransactions.feeCurrencies.map((feeCurrency) => feeCurrency.symbol).join(', ') - : '' - - const networkFee = useMemo(() => getNetworkFee(quote), [fromToken, quote]) - const feeToken = networkFee?.token ? tokensById[networkFee.token.tokenId] : undefined - - const appFee: AppFeeAmount | undefined = useMemo(() => { - if (!quote || !fromToken || !processedAmountsFrom.token.bignum) { - return undefined - } - - const percentage = new BigNumber(quote.appFeePercentageIncludedInPrice || 0) - - return { - amount: processedAmountsFrom.token.bignum.multipliedBy(percentage).dividedBy(100), - token: fromToken, - percentage, - } - }, [quote, processedAmountsFrom.token.bignum, fromToken]) - - const shouldShowSkeletons = useMemo(() => { - if (fetchingSwapQuote) return true - if ( - quote && - (quote.fromTokenId !== fromToken?.tokenId || quote.toTokenId !== toToken?.tokenId) - ) { - return true - } + useEffect(() => { + AppAnalytics.track(SwapEvents.swap_screen_open) + }, []) - if ( - quote && - processedAmountsFrom.token.bignum && - !quote.swapAmount.eq(processedAmountsFrom.token.bignum) - ) { - return true + useEffect(() => { + if (fetchSwapQuoteError) { + if (!fetchSwapQuoteError.message.includes(NO_QUOTE_ERROR_MESSAGE)) { + dispatch(showError(ErrorMessages.FETCH_SWAP_QUOTE_FAILED)) + } } + }, [fetchSwapQuoteError]) - return false - }, [fetchingSwapQuote, quote, fromToken, toToken, processedAmountsFrom]) - - const warnings = useMemo(() => { - const shouldShowMaxSwapAmountWarning = - feeCurrenciesWithPositiveBalances.length === 1 && + useEffect(() => { + // since we use the quote to update the parsedSwapAmount, + // this hook will be triggered after the quote is first updated. this + // variable prevents the quote from needlessly being fetched again. + const quoteKnown = fromToken && - fromToken.tokenId === feeCurrenciesWithPositiveBalances[0].tokenId && - fromToken.balance.gt(0) && - processedAmountsFrom.token.bignum && - processedAmountsFrom.token.bignum.gte(fromToken.balance) - - // NOTE: If a new condition is added here, make sure to update `allowSwap` below if - // the condition should prevent the user from swapping. - const checks = { - showSwitchedToNetworkWarning: !!switchedToNetworkId, - showUnsupportedTokensWarning: - !shouldShowSkeletons && fetchSwapQuoteError?.message.includes(NO_QUOTE_ERROR_MESSAGE), - showInsufficientBalanceWarning: - fromToken && - processedAmountsFrom.token.bignum && - processedAmountsFrom.token.bignum.gt(fromToken.balance), - showCrossChainFeeWarning: - !shouldShowSkeletons && crossChainFee?.nativeTokenBalanceDeficit.lt(0), - showDecreaseSpendForGasWarning: - !shouldShowSkeletons && - quote?.preparedTransactions.type === 'need-decrease-spend-amount-for-gas', - showNotEnoughBalanceForGasWarning: - !shouldShowSkeletons && quote?.preparedTransactions.type === 'not-enough-balance-for-gas', - showMaxSwapAmountWarning: shouldShowMaxSwapAmountWarning && !confirmSwapFailed, - showNoUsdPriceWarning: - !confirmSwapFailed && !shouldShowSkeletons && toToken && !toToken.priceUsd, - showPriceImpactWarning: - !confirmSwapFailed && - !shouldShowSkeletons && - (quote?.estimatedPriceImpact - ? new BigNumber(quote.estimatedPriceImpact).gte(priceImpactWarningThreshold) - : false), - showMissingPriceImpactWarning: !shouldShowSkeletons && quote && !quote.estimatedPriceImpact, - } - - // Only ever show a single warning, according to precedence as above. - // Warnings that prevent the user from confirming the swap should - // take higher priority over others. - return Object.entries(checks).reduce( - (acc, [name, status]) => { - acc[name] = Object.values(acc).some(Boolean) ? false : !!status - return acc - }, - {} as Record - ) - }, [ - feeCurrenciesWithPositiveBalances, - fromToken, - toToken, - processedAmountsFrom, - switchedToNetworkId, - shouldShowSkeletons, - fetchSwapQuoteError, - crossChainFee, - quote, - confirmSwapFailed, - priceImpactWarningThreshold, - ]) - - const allowSwap = useMemo( - () => - !warnings.showDecreaseSpendForGasWarning && - !warnings.showNotEnoughBalanceForGasWarning && - !warnings.showInsufficientBalanceWarning && - !warnings.showCrossChainFeeWarning && - !confirmSwapIsLoading && - !shouldShowSkeletons && - processedAmountsFrom.token.bignum && - processedAmountsFrom.token.bignum.gt(0) && - processedAmountsTo.token.bignum && - processedAmountsTo.token.bignum.gt(0), - [ - processedAmountsFrom.token.bignum, - processedAmountsTo.token.bignum, - shouldShowSkeletons, - confirmSwapIsLoading, - warnings.showInsufficientBalanceWarning, - warnings.showDecreaseSpendForGasWarning, - warnings.showNotEnoughBalanceForGasWarning, - warnings.showCrossChainFeeWarning, - ] - ) - - useEffect( - function refreshTransactionQuote() { - setStartedSwapId(undefined) - if (!processedAmountsFrom.token.bignum) { - clearQuote() - replaceAmountTo('') - return - } - - const debounceTimeout = setTimeout(() => { - const bothTokensPresent = !!(fromToken && toToken) - const amountIsTooSmall = - !processedAmountsFrom.token.bignum || processedAmountsFrom.token.bignum.lte(0) - - if (!bothTokensPresent || amountIsTooSmall) { - return - } - - // This variable prevents the quote from needlessly being fetched again. - const quoteIsTheSameAsTheLastOne = - quote && - quote.toTokenId === toToken.tokenId && - quote.fromTokenId === fromToken.tokenId && - processedAmountsFrom.token.bignum && - quote.swapAmount.eq(processedAmountsFrom.token.bignum) - - if (!quoteIsTheSameAsTheLastOne) { - replaceAmountTo('') - void refreshQuote( - fromToken, - toToken, - { FROM: processedAmountsFrom.token.bignum, TO: null }, - Field.FROM - ) - } - }, FETCH_UPDATED_TRANSACTIONS_DEBOUNCE_TIME_MS) - - return () => { - clearTimeout(debounceTimeout) - } - }, - [ - processedAmountsFrom.token.bignum?.toString(), - fromToken, - toToken, - quote, - refreshQuote, - // clearQuote, - // replaceAmountTo, - ] - ) - - useEffect(function trackSwapScreenOpen() { - AppAnalytics.track(SwapEvents.swap_screen_open) - }, []) + toToken && + quote && + quote.toTokenId === toToken.tokenId && + quote.fromTokenId === fromToken.tokenId && + quote.swapAmount.eq(parsedSwapAmount[Field.FROM]) - useEffect( - function trackImpactWarningDisplayed() { - if (warnings.showPriceImpactWarning || warnings.showMissingPriceImpactWarning) { - if (!quote) { - return - } - const fromToken = tokensById[quote.fromTokenId] - const toToken = tokensById[quote.toTokenId] - - if (!fromToken || !toToken) { - // Should never happen - Logger.error(TAG, 'fromToken or toToken not found') - return - } - - AppAnalytics.track(SwapEvents.swap_price_impact_warning_displayed, { - toToken: toToken.address, - toTokenId: toToken.tokenId, - toTokenNetworkId: toToken.networkId, - toTokenIsImported: !!toToken.isManuallyImported, - fromToken: fromToken.address, - fromTokenId: fromToken.tokenId, - fromTokenNetworkId: fromToken?.networkId, - fromTokenIsImported: !!fromToken.isManuallyImported, - amount: processedAmountsFrom.token.bignum - ? processedAmountsFrom.token.bignum.toString() - : '', - amountType: 'sellAmount', - priceImpact: quote.estimatedPriceImpact, - provider: quote.provider, - }) + const debouncedRefreshQuote = setTimeout(() => { + if (fromToken && toToken && parsedSwapAmount[Field.FROM].gt(0) && !quoteKnown) { + void refreshQuote(fromToken, toToken, parsedSwapAmount, Field.FROM) } - }, - [warnings.showPriceImpactWarning || warnings.showMissingPriceImpactWarning] - ) + }, FETCH_UPDATED_QUOTE_DEBOUNCE_TIME) - function onOpenTokenPickerFrom() { - AppAnalytics.track(SwapEvents.swap_screen_select_token, { fieldType: Field.FROM }) - // use requestAnimationFrame so that the bottom sheet open animation is done - // after the selectingField value is updated, so that the title of the - // bottom sheet (which depends on selectingField) does not change on the - // screen - requestAnimationFrame(() => tokenBottomSheetFromRef.current?.snapToIndex(0)) - } + return () => { + clearTimeout(debouncedRefreshQuote) + } + }, [fromToken, toToken, parsedSwapAmount, quote]) - function onOpenTokenPickerTo() { - AppAnalytics.track(SwapEvents.swap_screen_select_token, { fieldType: Field.TO }) - // use requestAnimationFrame so that the bottom sheet open animation is done - // after the selectingField value is updated, so that the title of the - // bottom sheet (which depends on selectingField) does not change on the - // screen - requestAnimationFrame(() => tokenBottomSheetToRef.current?.snapToIndex(0)) - } + useEffect(() => { + localDispatch(quoteUpdated({ quote })) + }, [quote]) - function handleConfirmSwap() { + const handleConfirmSwap = () => { if (!quote) { return // this should never happen, because the button must be disabled in that cases } @@ -443,16 +384,14 @@ export function SwapScreen({ route }: Props) { return } + localDispatch(startConfirmSwap()) + const userInput = { toTokenId: toToken.tokenId, fromTokenId: fromToken.tokenId, swapAmount: { - [Field.FROM]: processedAmountsFrom.token.bignum - ? processedAmountsFrom.token.bignum.toString() - : '', - [Field.TO]: processedAmountsTo.token.bignum - ? processedAmountsTo.token.bignum.toString() - : '', + [Field.FROM]: parsedSwapAmount[Field.FROM].toString(), + [Field.TO]: parsedSwapAmount[Field.TO].toString(), }, updatedField: Field.FROM, } @@ -476,7 +415,7 @@ export function SwapScreen({ route }: Props) { fromTokenId: fromToken.tokenId, fromTokenNetworkId: fromToken.networkId, fromTokenIsImported: !!fromToken.isManuallyImported, - amount: processedAmountsFrom.token.bignum?.toString() || '', + amount: inputSwapAmount[Field.FROM], amountType: 'sellAmount', allowanceTarget, estimatedPriceImpact, @@ -493,7 +432,7 @@ export function SwapScreen({ route }: Props) { }) const swapId = uuidv4() - setStartedSwapId(swapId) + localDispatch(startSwap({ swapId })) dispatch( swapStart({ swapId, @@ -521,45 +460,71 @@ export function SwapScreen({ route }: Props) { } } - function handleSwitchTokens() { - AppAnalytics.track(SwapEvents.swap_switch_tokens, { - fromTokenId: fromToken?.tokenId, - toTokenId: toToken?.tokenId, - }) + const handleSwitchTokens = () => { + AppAnalytics.track(SwapEvents.swap_switch_tokens, { fromTokenId, toTokenId }) + localDispatch( + selectTokens({ + fromTokenId: toTokenId, + toTokenId: fromTokenId, + switchedToNetworkId: null, + }) + ) + } + + const handleShowTokenSelect = (fieldType: Field) => () => { + AppAnalytics.track(SwapEvents.swap_screen_select_token, { fieldType }) + localDispatch(startSelectToken({ fieldType })) - setFromToken(toToken) - setToToken(fromToken) - replaceAmountTo('') + // use requestAnimationFrame so that the bottom sheet open animation is done + // after the selectingField value is updated, so that the title of the + // bottom sheet (which depends on selectingField) does not change on the + // screen + requestAnimationFrame(() => { + tokenBottomSheetRefs[fieldType].current?.snapToIndex(0) + }) } - function handleConfirmSelectTokenNoUsdPrice() { - if (noUsdPriceToken) { - onSelectTokenTo(noUsdPriceToken.token, noUsdPriceToken.tokenPositionInList) - setNoUsdPriceToken(undefined) + const handleConfirmSelectToken = (selectedToken: TokenBalance, tokenPositionInList: number) => { + if (!selectingField) { + // Should never happen + Logger.error(TAG, 'handleSelectToken called without selectingField') + return } - } - function handleDismissSelectTokenNoUsdPrice() { - setNoUsdPriceToken(undefined) - } + let newSwitchedToNetworkId: NetworkId | null = null + let newFromToken = fromToken + let newToToken = toToken + + if ( + (selectingField === Field.FROM && toToken?.tokenId === selectedToken.tokenId) || + (selectingField === Field.TO && fromToken?.tokenId === selectedToken.tokenId) + ) { + newFromToken = toToken + newToToken = fromToken + } else if (selectingField === Field.FROM) { + newFromToken = selectedToken + newSwitchedToNetworkId = + toToken && toToken.networkId !== newFromToken.networkId && !allowCrossChainSwaps + ? newFromToken.networkId + : null + if (newSwitchedToNetworkId) { + // reset the toToken if the user is switching networks + newToToken = undefined + } + } else if (selectingField === Field.TO) { + newToToken = selectedToken + newSwitchedToNetworkId = + fromToken && fromToken.networkId !== newToToken.networkId && !allowCrossChainSwaps + ? newToToken.networkId + : null + if (newSwitchedToNetworkId) { + // reset the fromToken if the user is switching networks + newFromToken = undefined + } + } - function trackConfirmToken({ - selectedToken, - field, - newFromToken, - newToToken, - newSwitchedToNetworkId, - tokenPositionInList, - }: { - selectedToken: TokenBalance - field: Field - newFromToken: TokenBalance | undefined - newToToken: TokenBalance | undefined - newSwitchedToNetworkId: NetworkId | null - tokenPositionInList: number - }) { AppAnalytics.track(SwapEvents.swap_screen_confirm_token, { - fieldType: field, + fieldType: selectingField, tokenSymbol: selectedToken.symbol, tokenId: selectedToken.tokenId, tokenNetworkId: selectedToken.networkId, @@ -573,149 +538,59 @@ export function SwapScreen({ route }: Props) { areSwapTokensShuffled, tokenPositionInList, }) - } - function onSelectTokenFrom(selectedToken: TokenBalance, tokenPositionInList: number) { - // if in "from" we select the same token as in "to" then just swap - if (toToken?.tokenId === selectedToken.tokenId) { - setFromToken(toToken) - setToToken(fromToken) - setStartedSwapId(undefined) - setSwitchedToNetworkId(null) - replaceAmountTo('') - - trackConfirmToken({ - field: Field.FROM, - selectedToken, - newFromToken: toToken, - newToToken: fromToken, - newSwitchedToNetworkId: null, - tokenPositionInList, + localDispatch( + selectTokens({ + fromTokenId: newFromToken?.tokenId, + toTokenId: newToToken?.tokenId, + switchedToNetworkId: allowCrossChainSwaps ? null : newSwitchedToNetworkId, }) + ) - /** - * Use requestAnimationFrame so that the bottom sheet and keyboard dismiss - * animation can be synchronised and starts after the state changes above. - * Without this, the keyboard animation lags behind the state updates while - * the bottom sheet does not. - */ - requestAnimationFrame(() => tokenBottomSheetFromRef.current?.close()) - return - } - - setFromToken(selectedToken) - replaceAmountTo('') - - const newSwitchedToNetwork = - toToken && toToken.networkId !== selectedToken.networkId && !allowCrossChainSwaps - ? { networkId: selectedToken.networkId, field: Field.FROM } - : null - - setSwitchedToNetworkId(allowCrossChainSwaps ? null : newSwitchedToNetwork) - - trackConfirmToken({ - field: Field.FROM, - selectedToken, - newFromToken: selectedToken, - newToToken: newSwitchedToNetwork ? undefined : toToken, - newSwitchedToNetworkId: newSwitchedToNetwork?.networkId ?? null, - tokenPositionInList, - }) - - if (selectedToken?.tokenId !== fromToken?.tokenId) { - setStartedSwapId(undefined) - } - - if (newSwitchedToNetwork) { - // reset the toToken if the user is switching networks - setToToken(undefined) + if (newSwitchedToNetworkId) { clearQuote() } - /** - * Use requestAnimationFrame so that the bottom sheet and keyboard dismiss - * animation can be synchronised and starts after the state changes above. - * Without this, the keyboard animation lags behind the state updates while - * the bottom sheet does not. - */ - requestAnimationFrame(() => tokenBottomSheetFromRef.current?.close()) + // use requestAnimationFrame so that the bottom sheet and keyboard dismiss + // animation can be synchronised and starts after the state changes above. + // without this, the keyboard animation lags behind the state updates while + // the bottom sheet does not + requestAnimationFrame(() => { + tokenBottomSheetRefs[selectingField].current?.close() + }) } - function onSelectTokenTo(selectedToken: TokenBalance, tokenPositionInList: number) { - if (!selectedToken.priceUsd && !noUsdPriceToken) { - setNoUsdPriceToken({ token: selectedToken, tokenPositionInList }) - return + const handleConfirmSelectTokenNoUsdPrice = () => { + if (selectingNoUsdPriceToken) { + handleConfirmSelectToken( + selectingNoUsdPriceToken, + selectingNoUsdPriceToken.tokenPositionInList + ) } + } - if (fromToken?.tokenId === selectedToken.tokenId) { - setFromToken(toToken) - setToToken(fromToken) - setStartedSwapId(undefined) - setSwitchedToNetworkId(null) - replaceAmountTo('') - - trackConfirmToken({ - field: Field.TO, - selectedToken, - newFromToken: toToken, - newToToken: fromToken, - newSwitchedToNetworkId: null, - tokenPositionInList, - }) - - /** - * Use requestAnimationFrame so that the bottom sheet and keyboard dismiss - * animation can be synchronised and starts after the state changes above. - * Without this, the keyboard animation lags behind the state updates while - * the bottom sheet does not. - */ - requestAnimationFrame(() => tokenBottomSheetToRef.current?.close()) + const handleDismissSelectTokenNoUsdPrice = () => { + localDispatch(unselectNoUsdPriceToken()) + } + const handleSelectToken = (selectedToken: TokenBalance, tokenPositionInList: number) => { + if (!selectedToken.priceUsd && selectingField === Field.TO) { + localDispatch(selectNoUsdPriceToken({ token: { ...selectedToken, tokenPositionInList } })) return } - setToToken(selectedToken) - replaceAmountTo('') - - const newSwitchedToNetwork = - fromToken && fromToken.networkId !== selectedToken.networkId && !allowCrossChainSwaps - ? { networkId: selectedToken.networkId, field: Field.TO } - : null - - setSwitchedToNetworkId(allowCrossChainSwaps ? null : newSwitchedToNetwork) - - trackConfirmToken({ - field: Field.TO, - selectedToken, - newFromToken: newSwitchedToNetwork ? undefined : fromToken, - newToToken: selectedToken, - newSwitchedToNetworkId: newSwitchedToNetwork?.networkId ?? null, - tokenPositionInList, - }) - - if (selectedToken?.tokenId !== toToken?.tokenId) { - setStartedSwapId(undefined) - } + handleConfirmSelectToken(selectedToken, tokenPositionInList) + } - if (newSwitchedToNetwork) { - // reset the fromToken if the user is switching networks - setFromToken(undefined) + const handleChangeAmount = (value: string) => { + localDispatch(changeAmount({ value })) + if (!value) { clearQuote() } - - /** - * Use requestAnimationFrame so that the bottom sheet and keyboard dismiss - * animation can be synchronised and starts after the state changes above. - * Without this, the keyboard animation lags behind the state updates while - * the bottom sheet does not. - */ - requestAnimationFrame(() => tokenBottomSheetToRef.current?.close()) } - function handleSelectAmountPercentage(percentage: number) { - handleSelectPercentageAmount(percentage) - setSelectedPercentage(percentage) - + const handleSelectAmountPercentage = (percentage: number) => { + localDispatch(chooseFromAmountPercentage({ fromTokenBalance, percentage })) if (!fromToken) { // Should never happen return @@ -728,16 +603,168 @@ export function SwapScreen({ route }: Props) { }) } - function onPressLearnMore() { + const onPressLearnMore = () => { AppAnalytics.track(SwapEvents.swap_learn_more) navigate(Screens.WebViewScreen, { uri: links.swapLearnMore }) } - function onPressLearnMoreFees() { + const onPressLearnMoreFees = () => { AppAnalytics.track(SwapEvents.swap_gas_fees_learn_more) navigate(Screens.WebViewScreen, { uri: links.transactionFeesLearnMore }) } + const switchedToNetworkName = switchedToNetworkId && NETWORK_NAMES[switchedToNetworkId] + + const showCrossChainSwapNotification = + toToken && fromToken && toToken.networkId !== fromToken.networkId && allowCrossChainSwaps + + const crossChainFeeCurrency = useSelector((state) => + feeCurrenciesSelector(state, fromToken?.networkId || networkConfig.defaultNetworkId) + ).find((token) => token.isNative) + const crossChainFee = getCrossChainFee(quote, crossChainFeeCurrency) + + const getWarningStatuses = () => { + // NOTE: If a new condition is added here, make sure to update `allowSwap` below if + // the condition should prevent the user from swapping. + const checks = { + showSwitchedToNetworkWarning: !!switchedToNetworkId, + showUnsupportedTokensWarning: + !quoteUpdatePending && fetchSwapQuoteError?.message.includes(NO_QUOTE_ERROR_MESSAGE), + showInsufficientBalanceWarning: parsedSwapAmount[Field.FROM].gt(fromTokenBalance), + showCrossChainFeeWarning: + !quoteUpdatePending && crossChainFee?.nativeTokenBalanceDeficit.lt(0), + showDecreaseSpendForGasWarning: + !quoteUpdatePending && + quote?.preparedTransactions.type === 'need-decrease-spend-amount-for-gas', + showNotEnoughBalanceForGasWarning: + !quoteUpdatePending && quote?.preparedTransactions.type === 'not-enough-balance-for-gas', + showMaxSwapAmountWarning: shouldShowMaxSwapAmountWarning && !confirmSwapFailed, + showNoUsdPriceWarning: + !confirmSwapFailed && !quoteUpdatePending && toToken && !toToken.priceUsd, + showPriceImpactWarning: + !confirmSwapFailed && + !quoteUpdatePending && + (quote?.estimatedPriceImpact + ? new BigNumber(quote.estimatedPriceImpact).gte(priceImpactWarningThreshold) + : false), + showMissingPriceImpactWarning: !quoteUpdatePending && quote && !quote.estimatedPriceImpact, + } + + // Only ever show a single warning, according to precedence as above. + // Warnings that prevent the user from confirming the swap should + // take higher priority over others. + return Object.entries(checks).reduce( + (acc, [name, status]) => { + acc[name] = Object.values(acc).some(Boolean) ? false : !!status + return acc + }, + {} as Record + ) + } + + const { + showCrossChainFeeWarning, + showDecreaseSpendForGasWarning, + showNotEnoughBalanceForGasWarning, + showInsufficientBalanceWarning, + showSwitchedToNetworkWarning, + showMaxSwapAmountWarning, + showNoUsdPriceWarning, + showPriceImpactWarning, + showUnsupportedTokensWarning, + showMissingPriceImpactWarning, + } = getWarningStatuses() + + const allowSwap = useMemo( + () => + !showDecreaseSpendForGasWarning && + !showNotEnoughBalanceForGasWarning && + !showInsufficientBalanceWarning && + !showCrossChainFeeWarning && + !confirmSwapIsLoading && + !quoteUpdatePending && + Object.values(parsedSwapAmount).every((amount) => amount.gt(0)), + [ + parsedSwapAmount, + quoteUpdatePending, + confirmSwapIsLoading, + showInsufficientBalanceWarning, + showDecreaseSpendForGasWarning, + showNotEnoughBalanceForGasWarning, + showCrossChainFeeWarning, + ] + ) + const networkFee: SwapFeeAmount | undefined = useMemo(() => { + return getNetworkFee(quote) + }, [fromToken, quote]) + + const feeToken = networkFee?.token ? tokensById[networkFee.token.tokenId] : undefined + + const appFee: AppFeeAmount | undefined = useMemo(() => { + if (!quote || !fromToken) { + return undefined + } + + const percentage = new BigNumber(quote.appFeePercentageIncludedInPrice || 0) + + return { + amount: parsedSwapAmount[Field.FROM].multipliedBy(percentage).dividedBy(100), + token: fromToken, + percentage, + } + }, [quote, parsedSwapAmount, fromToken]) + + useEffect(() => { + if (showPriceImpactWarning || showMissingPriceImpactWarning) { + if (!quote) { + return + } + const fromToken = tokensById[quote.fromTokenId] + const toToken = tokensById[quote.toTokenId] + + if (!fromToken || !toToken) { + // Should never happen + Logger.error(TAG, 'fromToken or toToken not found') + return + } + + AppAnalytics.track(SwapEvents.swap_price_impact_warning_displayed, { + toToken: toToken.address, + toTokenId: toToken.tokenId, + toTokenNetworkId: toToken.networkId, + toTokenIsImported: !!toToken.isManuallyImported, + fromToken: fromToken.address, + fromTokenId: fromToken.tokenId, + fromTokenNetworkId: fromToken?.networkId, + fromTokenIsImported: !!fromToken.isManuallyImported, + amount: parsedSwapAmount[Field.FROM].toString(), + amountType: 'sellAmount', + priceImpact: quote.estimatedPriceImpact, + provider: quote.provider, + }) + } + }, [showPriceImpactWarning || showMissingPriceImpactWarning]) + + const feeCurrencies = + quote && quote.preparedTransactions.type === 'not-enough-balance-for-gas' + ? quote.preparedTransactions.feeCurrencies.map((feeCurrency) => feeCurrency.symbol).join(', ') + : '' + + const tokenBottomSheetsConfig = [ + { + fieldType: Field.FROM, + tokens: swappableFromTokens, + filterChips: filterChipsFrom, + origin: TokenPickerOrigin.SwapFrom, + }, + { + fieldType: Field.TO, + tokens: swappableToTokens, + filterChips: filterChipsTo, + origin: TokenPickerOrigin.SwapTo, + }, + ] + return ( - - - - - - - - - - - - - - - - {showCrossChainSwapNotification && ( - - - - {t('swapScreen.crossChainNotification')} - - - )} - - - - - - {warnings.showCrossChainFeeWarning && ( - - )} - {warnings.showDecreaseSpendForGasWarning && ( - { - if ( - !quote || - quote.preparedTransactions.type !== 'need-decrease-spend-amount-for-gas' - ) - return - handleAmountInputChange( - quote.preparedTransactions.decreasedSpendAmount.toString() - ) - }} - ctaLabel={t('swapScreen.decreaseSwapAmountForGasWarning.cta')} - style={styles.warning} - /> - )} - {warnings.showNotEnoughBalanceForGasWarning && ( - - )} - {warnings.showInsufficientBalanceWarning && ( - - )} - {warnings.showUnsupportedTokensWarning && ( - - )} - {warnings.showSwitchedToNetworkWarning && ( - - )} - {warnings.showMaxSwapAmountWarning && ( - - )} - {warnings.showPriceImpactWarning && ( - - )} - {warnings.showNoUsdPriceWarning && ( - - )} - {warnings.showMissingPriceImpactWarning && ( - - )} - {confirmSwapFailed && ( - - )} + + + + + + + + - + - - - - - - -