diff --git a/.detoxrc.js b/.detoxrc.js index 7fa5dd973c0..efcacc30b6c 100644 --- a/.detoxrc.js +++ b/.detoxrc.js @@ -55,6 +55,11 @@ module.exports = { screenshot: 'failing', }, }, + behavior: { + cleanup: { + shutdownDevice: false, + }, + }, }, 'ios.sim.debug': { app: 'ios.debug', @@ -64,6 +69,11 @@ module.exports = { screenshot: 'failing', }, }, + behavior: { + cleanup: { + shutdownDevice: false, + }, + }, }, 'android.emu.release': { app: 'android.release', diff --git a/.github/workflows/macstadium-e2e.yml b/.github/workflows/macstadium-e2e.yml index 39b2a7b244e..460320dae54 100644 --- a/.github/workflows/macstadium-e2e.yml +++ b/.github/workflows/macstadium-e2e.yml @@ -52,11 +52,11 @@ jobs: run: rm -rf ./artifacts/ - name: Build the app in release mode - run: ./node_modules/.bin/detox build --configuration ios.sim.release + run: ./node_modules/.bin/detox build --configuration ios.sim.release | xcpretty --color - name: Run iOS e2e tests with retry # change the '5' here to how many times you want the tests to rerun on failure - run: ./scripts/run-retry-tests.sh 5 + run: ./scripts/run-retry-tests.sh 3 - name: Upload Test Artifacts if: failure() diff --git a/e2e/helpers.ts b/e2e/helpers.ts index 8234e82ca73..206c3a7ab84 100644 --- a/e2e/helpers.ts +++ b/e2e/helpers.ts @@ -47,6 +47,7 @@ export async function importWalletFlow() { await authenticatePin('1234'); } await device.enableSynchronization(); + await delayTime('very-long'); await checkIfVisible('wallet-screen'); } diff --git a/e2e/jest.e2e.config.js b/e2e/jest.e2e.config.js index f2a863dca9a..cdd8d0519cd 100644 --- a/e2e/jest.e2e.config.js +++ b/e2e/jest.e2e.config.js @@ -4,8 +4,6 @@ const { pathsToModuleNameMapper } = require('ts-jest'); const { compilerOptions } = require('../tsconfig'); module.exports = { - maxWorkers: 1, - bail: 1, setupFilesAfterEnv: ['./init.js'], testEnvironment: './environment', diff --git a/package.json b/package.json index 6a6ac275699..57ead60eb13 100644 --- a/package.json +++ b/package.json @@ -20,10 +20,11 @@ "clean:packager": "watchman watch-del-all && rm -rf $TMPDIR/react-* && rm -rf $TMPDIR/metro-* && rm -rf $TMPDIR/haste-map-*", "clean:node": "rm -rf node_modules", "nuke": "./scripts/nuke.sh", - "detox:android": "detox build -c android.emu.debug && detox test -R 1 -c android.emu.debug --loglevel verbose", - "detox:android:release": "detox build -c android.emu.release && detox test -R 1 -c android.emu.release", - "detox:ios": "detox build -c ios.sim.debug | xcpretty --color && detox test -R 1 -c ios.sim.debug --bail", - "detox:ios:release": "detox build -c ios.sim.release && detox test -R 1 -c ios.sim.release --bail", + "detox:android": "detox build -c android.emu.debug && detox test -c android.emu.debug --loglevel verbose", + "detox:android:release": "detox build -c android.emu.release && detox test -c android.emu.release", + "detox:ios:tests": "detox test -c ios.sim.debug --maxWorkers 2 -- --bail 1", + "detox:ios": "detox build -c ios.sim.debug | xcpretty --color && yarn detox:ios:tests", + "detox:ios:release": "detox build -c ios.sim.release && detox test -c ios.sim.release --maxWorkers 2 -- --bail 1", "ds:install": "yarn install --cwd src/design-system/docs", "ds": "cd src/design-system/docs && yarn dev", "fast": "yarn setup && yarn install-pods-fast", diff --git a/scripts/run-retry-tests.sh b/scripts/run-retry-tests.sh index fc858d48cb9..b3584fbe9cd 100755 --- a/scripts/run-retry-tests.sh +++ b/scripts/run-retry-tests.sh @@ -5,7 +5,7 @@ count=0 until (( count >= max_retries )) do - ./node_modules/.bin/detox test --configuration ios.sim.release --forceExit + ./node_modules/.bin/detox test -c ios.sim.release --maxWorkers 2 -- --forceExit --bail 1 ret_val=$? if [ $ret_val -eq 0 ]; then exit 0 diff --git a/src/__swaps__/safe-math/SafeMath.ts b/src/__swaps__/safe-math/SafeMath.ts index 4d0eb500112..d8bb0216637 100644 --- a/src/__swaps__/safe-math/SafeMath.ts +++ b/src/__swaps__/safe-math/SafeMath.ts @@ -46,6 +46,19 @@ const toStringWorklet = (value: string | number): string => { return typeof value === 'number' ? value.toString() : value; }; +// Converts a numeric string to a scaled integer string, preserving the specified decimal places +export function toScaledIntegerWorklet(num: string, decimalPlaces = 18): string { + 'worklet'; + if (!isNumberStringWorklet(num)) { + throw new Error('Argument must be a numeric string'); + } + const [bigIntNum, numDecimalPlaces] = removeDecimalWorklet(num); + const scaleFactor = BigInt(10) ** BigInt(decimalPlaces - numDecimalPlaces); + const scaledIntegerBigInt = bigIntNum * scaleFactor; + + return scaledIntegerBigInt.toString(); +} + // Sum function export function sumWorklet(num1: string | number, num2: string | number): string { 'worklet'; diff --git a/src/__swaps__/safe-math/__tests__/SafeMath.test.ts b/src/__swaps__/safe-math/__tests__/SafeMath.test.ts index 2db42ca5f90..634d159d41f 100644 --- a/src/__swaps__/safe-math/__tests__/SafeMath.test.ts +++ b/src/__swaps__/safe-math/__tests__/SafeMath.test.ts @@ -16,6 +16,7 @@ import { subWorklet, sumWorklet, toFixedWorklet, + toScaledIntegerWorklet, } from '../SafeMath'; const RESULTS = { @@ -29,12 +30,14 @@ const RESULTS = { toFixed: '1243425.35', ceil: '1243426', floor: '1243425', + toScaledInteger: '57464009350560633', }; const VALUE_A = '1243425.345'; const VALUE_B = '3819.24'; const VALUE_C = '2'; const VALUE_D = '1243425.745'; +const VALUE_E = '0.057464009350560633'; const NEGATIVE_VALUE = '-2412.12'; const ZERO = '0'; const ONE = '1'; @@ -190,6 +193,10 @@ describe('SafeMath', () => { expect(roundWorklet(Number(VALUE_A))).toBe(RESULTS.floor); expect(roundWorklet(Number(VALUE_D))).toBe(RESULTS.ceil); }); + + test('toScaledIntegerWorklet', () => { + expect(toScaledIntegerWorklet(VALUE_E, 18)).toBe(RESULTS.toScaledInteger); + }); }); describe('BigNumber', () => { @@ -243,4 +250,8 @@ describe('BigNumber', () => { expect(new BigNumber(VALUE_B).lte(VALUE_A)).toBe(true); expect(new BigNumber(VALUE_A).lte(VALUE_A)).toBe(true); }); + + test('toScaledInteger', () => { + expect(new BigNumber(VALUE_E).shiftedBy(18).toFixed(0)).toBe(RESULTS.toScaledInteger); + }); }); diff --git a/src/__swaps__/screens/Swap/Swap.tsx b/src/__swaps__/screens/Swap/Swap.tsx index 0a42fc22806..1c3a355ef44 100644 --- a/src/__swaps__/screens/Swap/Swap.tsx +++ b/src/__swaps__/screens/Swap/Swap.tsx @@ -1,6 +1,6 @@ -import React from 'react'; +import React, { useCallback, useEffect } from 'react'; import { StyleSheet, StatusBar } from 'react-native'; -import Animated from 'react-native-reanimated'; +import Animated, { runOnJS, useAnimatedReaction } from 'react-native-reanimated'; import { ScreenCornerRadius } from 'react-native-screen-corner-radius'; import { IS_ANDROID } from '@/env'; @@ -19,8 +19,13 @@ import { SwapNavbar } from '@/__swaps__/screens/Swap/components/SwapNavbar'; import { SliderAndKeyboard } from '@/__swaps__/screens/Swap/components/SliderAndKeyboard'; import { SwapBottomPanel } from '@/__swaps__/screens/Swap/components/SwapBottomPanel'; import { SwapWarning } from './components/SwapWarning'; -import { useSwapContext } from './providers/swap-provider'; -import { UserAssetsSync } from './components/UserAssetsSync'; +import { SwapProvider, useSwapContext } from './providers/swap-provider'; +import { useSwapsStore } from '@/state/swaps/swapsStore'; +import { userAssetsStore } from '@/state/assets/userAssets'; +import { parseSearchAsset } from '@/__swaps__/utils/assets'; +import { SwapAssetType } from '@/__swaps__/types/swap'; +import { ChainId } from '@/__swaps__/types/chains'; +import { useDelayedMount } from '@/hooks/useDelayedMount'; /** README * This prototype is largely driven by Reanimated and Gesture Handler, which @@ -60,38 +65,133 @@ import { UserAssetsSync } from './components/UserAssetsSync'; */ export function SwapScreen() { - const { AnimatedSwapStyles } = useSwapContext(); return ( - - - - - - - - - - - - - - + + + + + + + + + + + + - - - {/* NOTE: The components below render null and are solely for keeping react-query and Zustand in sync */} - - - + + + ); } +const MountAndUnmountHandlers = () => { + useMountSignal(); + useCleanupOnUnmount(); + + return null; +}; + +const useMountSignal = () => { + useEffect(() => { + useSwapsStore.setState({ isSwapsOpen: true }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +}; + +const useCleanupOnUnmount = () => { + useEffect(() => { + return () => { + const highestValueAsset = userAssetsStore.getState().getHighestValueAsset(); + const parsedAsset = highestValueAsset + ? parseSearchAsset({ + assetWithPrice: undefined, + searchAsset: highestValueAsset, + userAsset: highestValueAsset, + }) + : null; + + useSwapsStore.setState({ + inputAsset: parsedAsset, + isSwapsOpen: false, + outputAsset: null, + outputSearchQuery: '', + quote: null, + selectedOutputChainId: parsedAsset?.chainId ?? ChainId.mainnet, + }); + + userAssetsStore.setState({ filter: 'all', inputSearchQuery: '' }); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); +}; + +const WalletAddressObserver = () => { + const currentWalletAddress = userAssetsStore(state => state.associatedWalletAddress); + const { setAsset } = useSwapContext(); + + const setNewInputAsset = useCallback(() => { + const newHighestValueAsset = userAssetsStore.getState().getHighestValueAsset(); + + if (userAssetsStore.getState().filter !== 'all') { + userAssetsStore.setState({ filter: 'all' }); + } + + setAsset({ + type: SwapAssetType.inputAsset, + asset: newHighestValueAsset, + }); + + if (userAssetsStore.getState().userAssets.size === 0) { + setAsset({ + type: SwapAssetType.outputAsset, + asset: null, + }); + } + }, [setAsset]); + + useAnimatedReaction( + () => currentWalletAddress, + (current, previous) => { + const didWalletAddressChange = previous && current !== previous; + + if (didWalletAddressChange) { + runOnJS(setNewInputAsset)(); + } + } + ); + + return null; +}; + +const SliderAndKeyboardAndBottomControls = () => { + const shouldMount = useDelayedMount(); + const { AnimatedSwapStyles } = useSwapContext(); + + return shouldMount ? ( + + + + + ) : null; +}; + +const ExchangeRateBubbleAndWarning = () => { + const { AnimatedSwapStyles } = useSwapContext(); + return ( + + + + + ); +}; + export const styles = StyleSheet.create({ rootViewBackground: { borderRadius: IS_ANDROID ? 20 : ScreenCornerRadius, diff --git a/src/__swaps__/screens/Swap/components/AnimatedChainImage.tsx b/src/__swaps__/screens/Swap/components/AnimatedChainImage.tsx index cc1d22863a7..a7656ca54eb 100644 --- a/src/__swaps__/screens/Swap/components/AnimatedChainImage.tsx +++ b/src/__swaps__/screens/Swap/components/AnimatedChainImage.tsx @@ -101,9 +101,10 @@ export function AnimatedChainImage({ }); return ( - - {/* @ts-expect-error source prop is missing */} - + + {/* ⚠️ TODO: This works but we should figure out how to type this correctly to avoid this error */} + {/* @ts-expect-error: Doesn't pick up that it's getting a source prop via animatedProps */} + ); } @@ -118,7 +119,7 @@ const sx = StyleSheet.create({ height: 4, width: 0, }, - shadowRadius: 6, shadowOpacity: 0.2, + shadowRadius: 6, }, }); diff --git a/src/__swaps__/screens/Swap/components/AnimatedSwapCoinIcon.tsx b/src/__swaps__/screens/Swap/components/AnimatedSwapCoinIcon.tsx index 64d2d2749da..3b1368924df 100644 --- a/src/__swaps__/screens/Swap/components/AnimatedSwapCoinIcon.tsx +++ b/src/__swaps__/screens/Swap/components/AnimatedSwapCoinIcon.tsx @@ -10,6 +10,8 @@ import { AnimatedFasterImage } from '@/components/AnimatedComponents/AnimatedFas import { AnimatedChainImage } from './AnimatedChainImage'; import { fadeConfig } from '../constants'; import { SwapCoinIconTextFallback } from './SwapCoinIconTextFallback'; +import { Box } from '@/design-system'; +import { IS_ANDROID } from '@/env'; const fallbackIconStyle = { ...borders.buildCircleAsObject(32), @@ -26,9 +28,9 @@ const smallFallbackIconStyle = { position: 'absolute' as ViewStyle['position'], }; -export const AmimatedSwapCoinIcon = React.memo(function FeedCoinIcon({ +export const AnimatedSwapCoinIcon = React.memo(function FeedCoinIcon({ asset, - large, + large = true, small, showBadge = true, }: { @@ -39,13 +41,16 @@ export const AmimatedSwapCoinIcon = React.memo(function FeedCoinIcon({ }) { const { isDarkMode, colors } = useTheme(); - const imageLoadingError = useSharedValue(false); + const didErrorForUniqueId = useSharedValue(undefined); + + const size = small ? 16 : large ? 36 : 32; const animatedIconSource = useAnimatedProps(() => { return { source: { ...DEFAULT_FASTER_IMAGE_CONFIG, - borderRadius: (small ? 16 : large ? 36 : 32) / 2, + borderRadius: IS_ANDROID ? size / 2 : undefined, + transitionDuration: 0, url: asset.value?.icon_url ?? '', }, }; @@ -58,17 +63,29 @@ export const AmimatedSwapCoinIcon = React.memo(function FeedCoinIcon({ }); const animatedCoinIconStyles = useAnimatedStyle(() => { - const showFallback = imageLoadingError.value || !asset.value?.icon_url; + const showEmptyState = !asset.value?.uniqueId; + const showFallback = didErrorForUniqueId.value === asset.value?.uniqueId; + const shouldDisplay = !showFallback && !showEmptyState; + + return { + display: shouldDisplay ? 'flex' : 'none', + pointerEvents: shouldDisplay ? 'auto' : 'none', + opacity: withTiming(shouldDisplay ? 1 : 0, fadeConfig), + }; + }); + + const animatedEmptyStateStyles = useAnimatedStyle(() => { + const showEmptyState = !asset.value?.uniqueId; return { - display: showFallback ? 'none' : 'flex', - pointerEvents: showFallback ? 'none' : 'auto', - opacity: withTiming(showFallback ? 0 : 1, fadeConfig), + display: showEmptyState ? 'flex' : 'none', + opacity: withTiming(showEmptyState ? 1 : 0, fadeConfig), }; }); const animatedFallbackStyles = useAnimatedStyle(() => { - const showFallback = imageLoadingError.value || !asset.value?.icon_url; + const showEmptyState = !asset.value?.uniqueId; + const showFallback = !showEmptyState && didErrorForUniqueId.value === asset.value?.uniqueId; return { display: showFallback ? 'flex' : 'none', @@ -88,23 +105,22 @@ export const AmimatedSwapCoinIcon = React.memo(function FeedCoinIcon({ ]} > - {/* @ts-expect-error missing props "source" */} + {/* ⚠️ TODO: This works but we should figure out how to type this correctly to avoid this error */} + {/* @ts-expect-error: Doesn't pick up that it's getting a source prop via animatedProps */} { - 'worklet'; - imageLoadingError.value = true; + didErrorForUniqueId.value = asset.value?.uniqueId; }} onSuccess={() => { - 'worklet'; - imageLoadingError.value = false; + didErrorForUniqueId.value = undefined; }} style={[ sx.coinIcon, { - height: small ? 16 : large ? 36 : 32, - width: small ? 16 : large ? 36 : 32, - borderRadius: (small ? 16 : large ? 36 : 32) / 2, + borderRadius: size / 2, + height: size, + width: size, }, ]} /> @@ -115,11 +131,25 @@ export const AmimatedSwapCoinIcon = React.memo(function FeedCoinIcon({ > + + {showBadge && } @@ -164,6 +194,9 @@ const sx = StyleSheet.create({ height: 16, overflow: 'visible', }, + emptyState: { + pointerEvents: 'none', + }, reactCoinIconContainer: { position: 'relative', alignItems: 'center', diff --git a/src/__swaps__/screens/Swap/components/AnimatedSwitch.tsx b/src/__swaps__/screens/Swap/components/AnimatedSwitch.tsx index bd7b54b0544..7a344be6a26 100644 --- a/src/__swaps__/screens/Swap/components/AnimatedSwitch.tsx +++ b/src/__swaps__/screens/Swap/components/AnimatedSwitch.tsx @@ -1,12 +1,11 @@ /* eslint-disable react/jsx-props-no-spreading */ import React from 'react'; - import { AnimatedText, Box, Inline, globalColors, useColorMode, useForegroundColor } from '@/design-system'; import Animated, { SharedValue, useAnimatedStyle, useDerivedValue, withSpring, withTiming } from 'react-native-reanimated'; -import { fadeConfig, springConfig } from '../constants'; import { opacityWorklet } from '@/__swaps__/utils/swaps'; import { GestureHandlerButtonProps, GestureHandlerV1Button } from './GestureHandlerV1Button'; import { StyleSheet } from 'react-native'; +import { SPRING_CONFIGS, TIMING_CONFIGS } from '@/components/animations/animationConfigs'; type AnimatedSwitchProps = { onToggle: () => void; @@ -25,8 +24,8 @@ export function AnimatedSwitch({ value, onToggle, activeLabel, inactiveLabel, .. const containerStyles = useAnimatedStyle(() => { return { backgroundColor: !value.value - ? withTiming(opacityWorklet(inactiveBg, 0.12), fadeConfig) - : withTiming(opacityWorklet(activeBg, 0.64), fadeConfig), + ? withTiming(opacityWorklet(inactiveBg, 0.12), TIMING_CONFIGS.fadeConfig) + : withTiming(opacityWorklet(activeBg, 0.64), TIMING_CONFIGS.fadeConfig), borderColor: opacityWorklet(border, 0.06), }; }); @@ -35,7 +34,7 @@ export function AnimatedSwitch({ value, onToggle, activeLabel, inactiveLabel, .. return { transform: [ { - translateX: withSpring(value.value ? 11 : 1, springConfig), + translateX: withSpring(value.value ? 11 : 1, SPRING_CONFIGS.springConfig), }, ], }; @@ -57,7 +56,9 @@ export function AnimatedSwitch({ value, onToggle, activeLabel, inactiveLabel, .. return ( - + + {labelItem} + diff --git a/src/__swaps__/screens/Swap/components/BalanceBadge.tsx b/src/__swaps__/screens/Swap/components/BalanceBadge.tsx index d879bb6c743..54e0414dab1 100644 --- a/src/__swaps__/screens/Swap/components/BalanceBadge.tsx +++ b/src/__swaps__/screens/Swap/components/BalanceBadge.tsx @@ -10,8 +10,9 @@ export const BalanceBadge = ({ color, label, weight }: { color?: TextColor; labe const { isDarkMode } = useColorMode(); const labelTextStyle = useAnimatedStyle(() => { + const isPlaceholderLabel = label.value === 'No Balance' || label.value === 'Token to Swap' || label.value === 'Token to Get'; return { - opacity: label.value === 'No Balance' ? (isDarkMode ? 0.6 : 0.75) : undefined, + opacity: isPlaceholderLabel ? (isDarkMode ? 0.6 : 0.75) : 1, }; }); @@ -28,14 +29,9 @@ export const BalanceBadge = ({ color, label, weight }: { color?: TextColor; labe borderWidth: THICK_BORDER_WIDTH, }} > - + + {label} + ); diff --git a/src/__swaps__/screens/Swap/components/CoinRow.tsx b/src/__swaps__/screens/Swap/components/CoinRow.tsx index f1a0468b70b..1aa5cbeced7 100644 --- a/src/__swaps__/screens/Swap/components/CoinRow.tsx +++ b/src/__swaps__/screens/Swap/components/CoinRow.tsx @@ -4,7 +4,6 @@ import { Box, Column, Columns, HitSlop, Inline, Text } from '@/design-system'; import { TextColor } from '@/design-system/color/palettes'; import { CoinRowButton } from '@/__swaps__/screens/Swap/components/CoinRowButton'; import { BalancePill } from '@/__swaps__/screens/Swap/components/BalancePill'; -import { ChainId } from '@/__swaps__/types/chains'; import { toggleFavorite, useFavorites } from '@/resources/favorites'; import { StyleSheet } from 'react-native'; import { SwapCoinIcon } from './SwapCoinIcon'; @@ -16,6 +15,139 @@ import { setClipboard } from '@/hooks/useClipboard'; import { RainbowNetworks } from '@/networks'; import * as i18n from '@/languages'; import { ETH_ADDRESS } from '@/references'; +import { trimTrailingZeros } from '@/__swaps__/utils/swaps'; +import { ParsedSearchAsset } from '@/__swaps__/types/assets'; +import { userAssetsStore } from '@/state/assets/userAssets'; +import { SearchAsset } from '@/__swaps__/types/search'; +import { ChainId } from '@/__swaps__/types/chains'; + +interface InputCoinRowProps { + isTrending?: boolean; + onPress: (asset: ParsedSearchAsset | null) => void; + output?: false | undefined; + uniqueId: string; +} + +type PartialAsset = Pick; + +interface OutputCoinRowProps extends PartialAsset { + onPress: () => void; + output: true; + isTrending?: boolean; +} + +type CoinRowProps = InputCoinRowProps | OutputCoinRowProps; + +export const CoinRow = React.memo(function CoinRow({ onPress, output, uniqueId, isTrending, ...assetProps }: CoinRowProps) { + const { favoritesMetadata } = useFavorites(); + + const inputAsset = userAssetsStore(state => (output ? undefined : state.getUserAsset(uniqueId))); + const outputAsset = output ? (assetProps as PartialAsset) : undefined; + + const asset = output ? outputAsset : inputAsset; + const { address, chainId, colors, icon_url, mainnetAddress, name, symbol } = asset || {}; + + const percentChange = useMemo(() => { + if (isTrending) { + const rawChange = Math.random() * 30; + const isNegative = Math.random() < 0.2; + const prefix = isNegative ? '-' : '+'; + const color: TextColor = isNegative ? 'red' : 'green'; + const change = `${trimTrailingZeros(Math.abs(rawChange).toFixed(1))}%`; + + return { change, color, prefix }; + } + }, [isTrending]); + + const isFavorite = useMemo(() => { + return Object.values(favoritesMetadata).find(fav => { + if (mainnetAddress?.toLowerCase() === ETH_ADDRESS) { + return fav.address.toLowerCase() === ETH_ADDRESS; + } + + return fav.address?.toLowerCase() === address?.toLowerCase(); + }); + }, [favoritesMetadata, address, mainnetAddress]); + + const favoritesIconColor = useMemo(() => { + return isFavorite ? '#FFCB0F' : undefined; + }, [isFavorite]); + + const handleToggleFavorite = useCallback(() => { + // NOTE: It's important to always fetch ETH favorite on mainnet + if (address) { + return toggleFavorite(address, mainnetAddress === ETH_ADDRESS ? 1 : chainId); + } + }, [address, mainnetAddress, chainId]); + + if (!address || !chainId) return null; + + return ( + + + + onPress(inputAsset || null)} scaleTo={0.95}> + + + + + + + {name} + + + + {output ? symbol : `${inputAsset?.balance.display}`} + + {isTrending && percentChange && ( + + + {percentChange.prefix} + + + {percentChange.change} + + + )} + + + + {!output && } + + + + + {output && ( + + + + + + + + + )} + + + ); +}); const InfoButton = ({ address, chainId }: { address: string; chainId: ChainId }) => { const network = RainbowNetworks.find(network => network.id === chainId)?.value; @@ -93,7 +225,7 @@ const InfoButton = ({ address, chainId }: { address: string; chainId: ChainId }) return ( void; - output?: boolean; - symbol: string; -}) => { - const { favoritesMetadata } = useFavorites(); - - const isFavorite = useMemo(() => { - return Object.values(favoritesMetadata).find(fav => { - if (mainnetAddress?.toLowerCase() === ETH_ADDRESS) { - return fav.address.toLowerCase() === ETH_ADDRESS; - } - - return fav.address?.toLowerCase() === address?.toLowerCase(); - }); - }, [favoritesMetadata, address, mainnetAddress]); - - const favoritesIconColor = useMemo(() => { - return isFavorite ? '#FFCB0F' : undefined; - }, [isFavorite]); - - const percentChange = useMemo(() => { - if (isTrending) { - const rawChange = Math.random() * 30; - const isNegative = Math.random() < 0.2; - const prefix = isNegative ? '-' : '+'; - const color: TextColor = isNegative ? 'red' : 'green'; - const change = `${rawChange.toFixed(1)}%`; - - return { change, color, prefix }; - } - }, [isTrending]); - - const handleToggleFavorite = useCallback(() => { - // NOTE: It's important to always fetch ETH favorite on mainnet - return toggleFavorite(address, mainnetAddress === ETH_ADDRESS ? 1 : chainId); - }, [address, mainnetAddress, chainId]); - - return ( - - - - - - - - - - - {name} - - - - {output ? symbol : `${balance}`} - - {isTrending && percentChange && ( - - - {percentChange.prefix} - - - {percentChange.change} - - - )} - - - - {!output && } - - - - - {output && ( - - - - - - - - - )} - - - ); -}; - export const styles = StyleSheet.create({ solidColorCoinIcon: { opacity: 0.4, diff --git a/src/__swaps__/screens/Swap/components/EstimatedSwapGasFee.tsx b/src/__swaps__/screens/Swap/components/EstimatedSwapGasFee.tsx index 9d281c3f425..ff973a98526 100644 --- a/src/__swaps__/screens/Swap/components/EstimatedSwapGasFee.tsx +++ b/src/__swaps__/screens/Swap/components/EstimatedSwapGasFee.tsx @@ -1,12 +1,16 @@ -import { AnimatedText, TextProps } from '@/design-system'; +import { AnimatedText, TextProps, useForegroundColor } from '@/design-system'; import React, { memo } from 'react'; -import { useAnimatedStyle, withRepeat, withSequence, withSpring, withTiming } from 'react-native-reanimated'; +import { SharedValue, useAnimatedStyle, useDerivedValue, withRepeat, withSequence, withSpring, withTiming } from 'react-native-reanimated'; import { pulsingConfig, sliderConfig } from '../constants'; import { GasSettings } from '../hooks/useCustomGas'; import { useSwapEstimatedGasFee } from '../hooks/useEstimatedGasFee'; +import { useSwapContext } from '../providers/swap-provider'; +import { opacity } from '@/__swaps__/utils/swaps'; +import { TIMING_CONFIGS } from '@/components/animations/animationConfigs'; +import { useDelayedValue } from '@/hooks/reanimated/useDelayedValue'; -export const EstimatedSwapGasFee = memo(function EstimatedGasFeeA({ +export const EstimatedSwapGasFee = memo(function EstimatedSwapGasFee({ gasSettings, align, color = 'labelTertiary', @@ -14,23 +18,46 @@ export const EstimatedSwapGasFee = memo(function EstimatedGasFeeA({ weight = 'bold', tabularNumbers = true, }: { gasSettings: GasSettings | undefined } & Partial>) { - const { data: estimatedGasFee = '--', isLoading } = useSwapEstimatedGasFee(gasSettings); + const { data: estimatedGasFee = '--' } = useSwapEstimatedGasFee(gasSettings); - const animatedOpacity = useAnimatedStyle(() => ({ - opacity: isLoading + const label = useDerivedValue(() => estimatedGasFee); + + return ; +}); + +const GasFeeText = memo(function GasFeeText({ + align, + color, + label, + size, + weight, + tabularNumbers, +}: { label: SharedValue } & Pick) { + const { isFetching } = useSwapContext(); + + const labelTertiary = useForegroundColor('labelTertiary'); + const zeroAmountColor = opacity(labelTertiary, 0.3); + + const isFetchingDelayed = useDelayedValue(isFetching, 1500); + const isLoading = useDerivedValue(() => isFetching.value || isFetchingDelayed.value); + + const animatedTextOpacity = useAnimatedStyle(() => ({ + color: withTiming(isLoading.value ? zeroAmountColor : labelTertiary, TIMING_CONFIGS.slowFadeConfig), + opacity: isLoading.value ? withRepeat(withSequence(withTiming(0.5, pulsingConfig), withTiming(1, pulsingConfig)), -1, true) : withSpring(1, sliderConfig), })); return ( + > + {label} + ); }); diff --git a/src/__swaps__/screens/Swap/components/ExchangeRateBubble.tsx b/src/__swaps__/screens/Swap/components/ExchangeRateBubble.tsx index ba0b6c54c6d..3aada27a8d1 100644 --- a/src/__swaps__/screens/Swap/components/ExchangeRateBubble.tsx +++ b/src/__swaps__/screens/Swap/components/ExchangeRateBubble.tsx @@ -1,7 +1,8 @@ import React, { useCallback } from 'react'; -import Animated, { useAnimatedReaction, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated'; +import Animated, { useAnimatedReaction, useAnimatedStyle, useSharedValue, withDelay, withTiming } from 'react-native-reanimated'; +import { TIMING_CONFIGS } from '@/components/animations/animationConfigs'; import { AnimatedText, Box, Inline, TextIcon, useColorMode, useForegroundColor } from '@/design-system'; -import { LIGHT_SEPARATOR_COLOR, SEPARATOR_COLOR, THICK_BORDER_WIDTH, fadeConfig } from '@/__swaps__/screens/Swap/constants'; +import { LIGHT_SEPARATOR_COLOR, SEPARATOR_COLOR, THICK_BORDER_WIDTH } from '@/__swaps__/screens/Swap/constants'; import { opacity, valueBasedDecimalFormatter } from '@/__swaps__/utils/swaps'; import { useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider'; import { AddressZero } from '@ethersproject/constants'; @@ -31,13 +32,7 @@ export const ExchangeRateBubble = () => { internalSelectedInputAsset.value?.chainId !== internalSelectedOutputAsset.value?.chainId; rotatingIndex.value = isSameAssetOnDifferentChains ? 2 : (rotatingIndex.value + 1) % 4; - }, [ - internalSelectedInputAsset.value?.address, - internalSelectedInputAsset.value?.chainId, - internalSelectedOutputAsset.value?.address, - internalSelectedOutputAsset.value?.chainId, - rotatingIndex, - ]); + }, [internalSelectedInputAsset, internalSelectedOutputAsset, rotatingIndex]); const resetValues = useCallback(() => { 'worklet'; @@ -47,19 +42,30 @@ export const ExchangeRateBubble = () => { useAnimatedReaction( () => ({ - inputAsset: internalSelectedInputAsset.value, - outputAsset: internalSelectedOutputAsset.value, - isFetching, - rotatingIndex, + inputAssetUniqueId: internalSelectedInputAsset.value?.uniqueId, + isFetching: isFetching.value, + outputAssetUniqueId: internalSelectedOutputAsset.value?.uniqueId, + rotatingIndex: rotatingIndex.value, }), - ({ inputAsset, outputAsset }) => { - if (!inputAsset || !outputAsset || !inputAsset.nativePrice || !outputAsset.nativePrice) { + (current, previous) => { + if ( + !internalSelectedInputAsset.value || + !internalSelectedOutputAsset.value || + !internalSelectedInputAsset.value.nativePrice || + !internalSelectedOutputAsset.value.nativePrice || + current.inputAssetUniqueId !== previous?.inputAssetUniqueId || + current.outputAssetUniqueId !== previous?.outputAssetUniqueId + ) { resetValues(); return; } - const { symbol: inputAssetSymbol, nativePrice: inputAssetPrice, type: inputAssetType } = inputAsset; - const { symbol: outputAssetSymbol, nativePrice: outputAssetPrice, type: outputAssetType } = outputAsset; + if (current.isFetching && current.rotatingIndex === previous?.rotatingIndex) { + return; + } + + const { symbol: inputAssetSymbol, nativePrice: inputAssetPrice, type: inputAssetType } = internalSelectedInputAsset.value; + const { symbol: outputAssetSymbol, nativePrice: outputAssetPrice, type: outputAssetType } = internalSelectedOutputAsset.value; const isInputAssetStablecoin = inputAssetType === 'stablecoin' ?? false; const isOutputAssetStablecoin = outputAssetType === 'stablecoin' ?? false; @@ -132,11 +138,12 @@ export const ExchangeRateBubble = () => { } ); - const WrapperStyles = useAnimatedStyle(() => ({ - borderColor: isDarkMode ? SEPARATOR_COLOR : LIGHT_SEPARATOR_COLOR, - borderWidth: THICK_BORDER_WIDTH, - opacity: withTiming(fromAssetText.value && toAssetText.value ? 1 : 0, fadeConfig), - })); + const bubbleVisibilityWrapper = useAnimatedStyle(() => { + const shouldDisplay = fromAssetText.value.length > 0 && toAssetText.value.length > 0; + return { + opacity: shouldDisplay ? withDelay(50, withTiming(1, TIMING_CONFIGS.fadeConfig)) : 0, + }; + }); return ( @@ -149,23 +156,21 @@ export const ExchangeRateBubble = () => { style={[AnimatedSwapStyles.hideWhenInputsExpandedOrPriceImpact, { alignSelf: 'center', position: 'absolute', top: 4 }]} > - + + {fromAssetText} + { 􀄭 - + + {toAssetText} + diff --git a/src/__swaps__/screens/Swap/components/FastSwapCoinIconImage.tsx b/src/__swaps__/screens/Swap/components/FastSwapCoinIconImage.tsx index d624ce94733..ed659118401 100644 --- a/src/__swaps__/screens/Swap/components/FastSwapCoinIconImage.tsx +++ b/src/__swaps__/screens/Swap/components/FastSwapCoinIconImage.tsx @@ -6,7 +6,7 @@ import { getUrlForTrustIconFallback } from '@/utils'; export const FastSwapCoinIconImage = React.memo(function FastSwapCoinIconImage({ address, - disableShadow, + disableShadow = true, network, shadowColor, size, diff --git a/src/__swaps__/screens/Swap/components/FlipButton.tsx b/src/__swaps__/screens/Swap/components/FlipButton.tsx index 53d80735fba..c43134dd4e9 100644 --- a/src/__swaps__/screens/Swap/components/FlipButton.tsx +++ b/src/__swaps__/screens/Swap/components/FlipButton.tsx @@ -11,15 +11,21 @@ import { IS_ANDROID, IS_IOS } from '@/env'; import { AnimatedBlurView } from '@/__swaps__/screens/Swap/components/AnimatedBlurView'; import { useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider'; import { TIMING_CONFIGS } from '@/components/animations/animationConfigs'; +import { SwapAssetType } from '@/__swaps__/types/swap'; export const FlipButton = () => { const { isDarkMode } = useColorMode(); - const { AnimatedSwapStyles, internalSelectedOutputAsset } = useSwapContext(); + const { AnimatedSwapStyles, internalSelectedInputAsset, internalSelectedOutputAsset, setAsset } = useSwapContext(); const handleSwapAssets = useCallback(() => { - // TODO: Handle swap assets logic - }, []); + if (internalSelectedInputAsset.value && internalSelectedOutputAsset.value) { + const assetTypeToSet = SwapAssetType.outputAsset; + const assetToSet = internalSelectedInputAsset.value; + + setAsset({ type: assetTypeToSet, asset: assetToSet }); + } + }, [internalSelectedInputAsset, internalSelectedOutputAsset, /* lastTypedInput, */ setAsset]); const flipButtonInnerStyles = useAnimatedStyle(() => { return { diff --git a/src/__swaps__/screens/Swap/components/GasButton.tsx b/src/__swaps__/screens/Swap/components/GasButton.tsx index 1841c8e4005..17020bdf66c 100644 --- a/src/__swaps__/screens/Swap/components/GasButton.tsx +++ b/src/__swaps__/screens/Swap/components/GasButton.tsx @@ -10,7 +10,6 @@ import { Box, Inline, Text, TextIcon, useColorMode, useForegroundColor } from '@ import { IS_ANDROID } from '@/env'; import * as i18n from '@/languages'; import { useSwapsStore } from '@/state/swaps/swapsStore'; -import styled from '@/styled-thing'; import { gasUtils } from '@/utils'; import React, { ReactNode, useCallback, useMemo } from 'react'; import { StyleSheet } from 'react-native'; @@ -23,6 +22,7 @@ import { useSwapContext } from '../providers/swap-provider'; import { EstimatedSwapGasFee } from './EstimatedSwapGasFee'; const { GAS_ICONS } = gasUtils; +const GAS_BUTTON_HIT_SLOP = 16; function EstimatedGasFee() { const chainId = useSwapsStore(s => s.inputAsset?.chainId || ChainId.mainnet); @@ -59,10 +59,6 @@ function SelectedGas() { ); } -const GasSpeedPagerCentered = styled(Centered).attrs(() => ({ - marginHorizontal: 8, -}))({}); - function getEstimatedFeeRangeInGwei(gasSettings: GasSettings | undefined, currentBaseFee: string | undefined) { if (!gasSettings) return undefined; @@ -134,7 +130,7 @@ const GasMenu = ({ children }: { children: ReactNode }) => { if (metereologySuggestions.isLoading) return children; return ( - + {IS_ANDROID ? ( { useActionSheetFallback={false} wrapNativeComponent={false} > - {children} + + {children} + ) : ( { useActionSheetFallback={false} wrapNativeComponent={false} > - {children} + + {children} + )} - + ); }; diff --git a/src/__swaps__/screens/Swap/components/ReviewPanel.tsx b/src/__swaps__/screens/Swap/components/ReviewPanel.tsx index 511f5f3b182..d84688e3403 100644 --- a/src/__swaps__/screens/Swap/components/ReviewPanel.tsx +++ b/src/__swaps__/screens/Swap/components/ReviewPanel.tsx @@ -2,7 +2,7 @@ import * as i18n from '@/languages'; import React, { useCallback } from 'react'; import { ReviewGasButton } from '@/__swaps__/screens/Swap/components/GasButton'; -import { ChainId } from '@/__swaps__/types/chains'; +import { ChainId, ChainNameDisplay } from '@/__swaps__/types/chains'; import { AnimatedText, Box, Inline, Separator, Stack, Text, globalColors, useColorMode } from '@/design-system'; import { StyleSheet, View } from 'react-native'; @@ -24,7 +24,6 @@ import { CrosschainQuote, Quote, QuoteError } from '@rainbow-me/swaps'; import { AnimatedChainImage } from '@/__swaps__/screens/Swap/components/AnimatedChainImage'; import { GestureHandlerV1Button } from '@/__swaps__/screens/Swap/components/GestureHandlerV1Button'; import { useNativeAssetForChain } from '@/__swaps__/screens/Swap/hooks/useNativeAssetForChain'; -import { chainNameForChainIdWithMainnetSubstitutionWorklet } from '@/__swaps__/utils/chains'; import { useEstimatedTime } from '@/__swaps__/utils/meteorology'; import { convertRawAmountToBalance, convertRawAmountToNativeDisplay, handleSignificantDecimals, multiply } from '@/__swaps__/utils/numbers'; import { useSwapsStore } from '@/state/swaps/swapsStore'; @@ -36,7 +35,7 @@ const unknown = i18n.t(i18n.l.swap.unknown); const RainbowFee = () => { const { nativeCurrency } = useAccountSettings(); const { isDarkMode } = useColorMode(); - const { quote, internalSelectedInputAsset } = useSwapContext(); + const { isFetching, isQuoteStale, quote, internalSelectedInputAsset } = useSwapContext(); const { nativeAsset } = useNativeAssetForChain({ inputAsset: internalSelectedInputAsset }); @@ -71,17 +70,19 @@ const RainbowFee = () => { ); useAnimatedReaction( - () => quote.value, - (current, previous) => { - if (current && previous !== current && !(current as QuoteError)?.error) { - runOnJS(calculateRainbowFeeFromQuoteData)(current as Quote | CrosschainQuote); + () => ({ isFetching: isFetching.value, isQuoteStale: isQuoteStale.value, quote: quote.value }), + current => { + if (!current.isQuoteStale && !current.isFetching && current.quote && !(current.quote as QuoteError)?.error) { + runOnJS(calculateRainbowFeeFromQuoteData)(current.quote as Quote | CrosschainQuote); } } ); return ( - + + {feeToDisplay} + ); }; @@ -111,9 +112,7 @@ export function ReviewPanel() { const unknown = i18n.t(i18n.l.swap.unknown); - const chainName = useDerivedValue(() => - chainNameForChainIdWithMainnetSubstitutionWorklet(internalSelectedOutputAsset.value?.chainId ?? ChainId.mainnet) - ); + const chainName = useDerivedValue(() => ChainNameDisplay[internalSelectedOutputAsset.value?.chainId ?? ChainId.mainnet]); const minimumReceived = useDerivedValue(() => { if (!SwapInputController.formattedOutputAmount.value || !internalSelectedOutputAsset.value?.symbol) { @@ -170,8 +169,9 @@ export function ReviewPanel() { size="15pt" weight="heavy" style={{ textTransform: 'capitalize' }} - text={chainName} - /> + > + {chainName} + @@ -186,13 +186,9 @@ export function ReviewPanel() { - + + {minimumReceived} + @@ -270,14 +266,9 @@ export function ReviewPanel() { - + + {SwapSettings.slippage} + % diff --git a/src/__swaps__/screens/Swap/components/SearchInput.tsx b/src/__swaps__/screens/Swap/components/SearchInput.tsx index 982c25a423f..ae2e3b8a5e7 100644 --- a/src/__swaps__/screens/Swap/components/SearchInput.tsx +++ b/src/__swaps__/screens/Swap/components/SearchInput.tsx @@ -1,30 +1,47 @@ -import React, { useCallback, useMemo } from 'react'; -import { NativeSyntheticEvent, TextInputChangeEventData } from 'react-native'; -import Animated, { SharedValue, useAnimatedProps, useDerivedValue, dispatchCommand } from 'react-native-reanimated'; -import { ButtonPressAnimation } from '@/components/animations'; +import React from 'react'; +import Animated, { + SharedValue, + runOnJS, + runOnUI, + useAnimatedProps, + useAnimatedReaction, + useAnimatedStyle, + useDerivedValue, +} from 'react-native-reanimated'; import { Input } from '@/components/inputs'; import { AnimatedText, Bleed, Box, Column, Columns, Text, useColorMode, useForegroundColor } from '@/design-system'; import { LIGHT_SEPARATOR_COLOR, SEPARATOR_COLOR, THICK_BORDER_WIDTH } from '@/__swaps__/screens/Swap/constants'; import { getColorValueForThemeWorklet, opacity } from '@/__swaps__/utils/swaps'; -import { useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider'; +import { NavigationSteps, useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider'; import { userAssetsStore } from '@/state/assets/userAssets'; import { ExtendedAnimatedAssetWithColors } from '@/__swaps__/types/assets'; +import { GestureHandlerV1Button } from './GestureHandlerV1Button'; +import { useDebouncedCallback } from 'use-debounce'; +import { useSwapsStore } from '@/state/swaps/swapsStore'; const AnimatedInput = Animated.createAnimatedComponent(Input); export const SearchInput = ({ asset, - handleExitSearch, - handleFocusSearch, + handleExitSearchWorklet, + handleFocusSearchWorklet, output, }: { asset: SharedValue; - handleExitSearch: () => void; - handleFocusSearch: () => void; - output?: boolean; + handleExitSearchWorklet: () => void; + handleFocusSearchWorklet: () => void; + output: boolean; }) => { - const { searchInputRef, inputProgress, outputProgress, AnimatedSwapStyles } = useSwapContext(); const { isDarkMode } = useColorMode(); + const { + inputProgress, + inputSearchRef, + internalSelectedInputAsset, + internalSelectedOutputAsset, + outputProgress, + outputSearchRef, + AnimatedSwapStyles, + } = useSwapContext(); const fillTertiary = useForegroundColor('fillTertiary'); const label = useForegroundColor('label'); @@ -35,30 +52,66 @@ export const SearchInput = ({ return 'Cancel'; } - return 'Close'; + if ((output && internalSelectedOutputAsset.value) || !output) { + return 'Close'; + } + + // ⚠️ TODO: Add paste functionality to the asset to buy list when no asset is selected + // return 'Paste'; }); - const defaultValue = useMemo(() => { - return userAssetsStore.getState().searchQuery; - }, []); + const buttonVisibilityStyle = useAnimatedStyle(() => { + const isSearchFocused = (output ? outputProgress : inputProgress).value === NavigationSteps.SEARCH_FOCUSED; + const isAssetSelected = output ? internalSelectedOutputAsset.value : internalSelectedInputAsset.value; - const onSearchQueryChange = useCallback((event: NativeSyntheticEvent) => { - userAssetsStore.setState({ searchQuery: event.nativeEvent.text }); - }, []); + return { + opacity: isSearchFocused || isAssetSelected ? 1 : 0, + pointerEvents: isSearchFocused || isAssetSelected ? 'auto' : 'none', + }; + }); - const searchInputValue = useAnimatedProps(() => { - const isFocused = inputProgress.value >= 1 || outputProgress.value >= 1; + const onInputSearchQueryChange = useDebouncedCallback( + (text: string) => { + userAssetsStore.getState().setSearchQuery(text); + }, + 50, + { leading: true, trailing: true } + ); + + const onOutputSearchQueryChange = useDebouncedCallback( + (text: string) => { + useSwapsStore.setState({ outputSearchQuery: text }); + }, + 100, + { leading: false, trailing: true } + ); + const isSearchFocused = useDerivedValue( + () => + (!output && inputProgress.value === NavigationSteps.SEARCH_FOCUSED) || + (output && outputProgress.value === NavigationSteps.SEARCH_FOCUSED) + ); + + const searchInputValue = useAnimatedProps(() => { // Removing the value when the input is focused allows the input to be reset to the correct value on blur - const query = isFocused ? undefined : defaultValue; + const query = isSearchFocused.value ? undefined : ''; return { - defaultValue, text: query, selectionColor: getColorValueForThemeWorklet(asset.value?.highContrastColor, isDarkMode, true), }; }); + useAnimatedReaction( + () => isSearchFocused.value, + (focused, prevFocused) => { + if (focused === false && prevFocused === true) { + if (output) runOnJS(onOutputSearchQueryChange)(''); + else runOnJS(onInputSearchQueryChange)(''); + } + } + ); + return ( @@ -86,21 +139,31 @@ export const SearchInput = ({ { - console.log('here'); - onSearchQueryChange({ - nativeEvent: { - text: '', - }, - } as NativeSyntheticEvent); - handleExitSearch(); + runOnUI(() => { + if (isSearchFocused.value) { + handleExitSearchWorklet(); + } + })(); + + if (isSearchFocused.value) { + if (output) { + if (useSwapsStore.getState().outputSearchQuery !== '') { + useSwapsStore.setState({ outputSearchQuery: '' }); + } + } else { + if (userAssetsStore.getState().inputSearchQuery !== '') { + userAssetsStore.getState().setSearchQuery(''); + } + } + } }} - onFocus={handleFocusSearch} + onFocus={() => runOnUI(handleFocusSearchWorklet)()} placeholder={output ? 'Find a token to buy' : 'Search your tokens'} placeholderTextColor={isDarkMode ? opacity(labelQuaternary, 0.3) : labelQuaternary} selectTextOnFocus - ref={searchInputRef} + ref={output ? outputSearchRef : inputSearchRef} spellCheck={false} style={{ color: label, @@ -115,39 +178,43 @@ export const SearchInput = ({ - { - // TODO: This doesn't cause the blur to happen... - dispatchCommand(searchInputRef, 'blur'); - onSearchQueryChange({ - nativeEvent: { - text: '', - }, - } as NativeSyntheticEvent); - handleExitSearch(); - }} - scaleTo={0.8} - > - + (output ? outputSearchRef : inputSearchRef).current?.blur()} + onPressWorklet={() => { + 'worklet'; + const isSearchFocused = + (output && outputProgress.value === NavigationSteps.SEARCH_FOCUSED) || + (!output && inputProgress.value === NavigationSteps.SEARCH_FOCUSED); + + if (isSearchFocused || (output && internalSelectedOutputAsset.value) || (!output && internalSelectedInputAsset.value)) { + handleExitSearchWorklet(); + } + }} + scaleTo={0.8} > - - - + + + {btnText} + + + + diff --git a/src/__swaps__/screens/Swap/components/SliderAndKeyboard.tsx b/src/__swaps__/screens/Swap/components/SliderAndKeyboard.tsx index f73270813d3..54bea1d2cc4 100644 --- a/src/__swaps__/screens/Swap/components/SliderAndKeyboard.tsx +++ b/src/__swaps__/screens/Swap/components/SliderAndKeyboard.tsx @@ -30,7 +30,7 @@ export function SliderAndKeyboard() { ]} width="full" > - {/* @ts-expect-error */} + {/* @ts-expect-error Property 'children' does not exist on type */} diff --git a/src/__swaps__/screens/Swap/components/SwapActionButton.tsx b/src/__swaps__/screens/Swap/components/SwapActionButton.tsx index 2556d5fc8b5..f447a42eff8 100644 --- a/src/__swaps__/screens/Swap/components/SwapActionButton.tsx +++ b/src/__swaps__/screens/Swap/components/SwapActionButton.tsx @@ -16,7 +16,8 @@ export const SwapActionButton = ({ icon, iconStyle, label, - onPress, + onPressJS, + onPressWorklet, outline, rightIcon, scaleTo, @@ -30,7 +31,8 @@ export const SwapActionButton = ({ icon?: string | DerivedValue; iconStyle?: StyleProp; label: string | DerivedValue; - onPress?: () => void; + onPressJS?: () => void; + onPressWorklet?: () => void; outline?: boolean; rightIcon?: string; scaleTo?: number; @@ -97,7 +99,8 @@ export const SwapActionButton = ({ return ( {icon && ( - + + {iconValue} + )} {typeof label !== 'undefined' && ( - + + {labelValue} + )} {rightIcon && ( - + + {rightIconValue} + )} diff --git a/src/__swaps__/screens/Swap/components/SwapBottomPanel.tsx b/src/__swaps__/screens/Swap/components/SwapBottomPanel.tsx index 587d27f292c..655d50cb14b 100644 --- a/src/__swaps__/screens/Swap/components/SwapBottomPanel.tsx +++ b/src/__swaps__/screens/Swap/components/SwapBottomPanel.tsx @@ -7,7 +7,7 @@ import { PanGestureHandler } from 'react-native-gesture-handler'; import { Box, Column, Columns, Separator, globalColors, useColorMode } from '@/design-system'; import { safeAreaInsetValues } from '@/utils'; -import { LIGHT_SEPARATOR_COLOR, SEPARATOR_COLOR, THICK_BORDER_WIDTH, springConfig } from '@/__swaps__/screens/Swap/constants'; +import { LIGHT_SEPARATOR_COLOR, SEPARATOR_COLOR, THICK_BORDER_WIDTH } from '@/__swaps__/screens/Swap/constants'; import { IS_ANDROID } from '@/env'; import { useSwapContext, NavigationSteps } from '@/__swaps__/screens/Swap/providers/swap-provider'; @@ -17,6 +17,7 @@ import { GasButton } from './GasButton'; import { GasPanel } from './GasPanel'; import { ReviewPanel } from './ReviewPanel'; import { SwapActionButton } from './SwapActionButton'; +import { SPRING_CONFIGS } from '@/components/animations/animationConfigs'; export function SwapBottomPanel() { const { isDarkMode } = useColorMode(); @@ -34,7 +35,12 @@ export function SwapBottomPanel() { const gestureHandlerStyles = useAnimatedStyle(() => { return { - transform: [{ translateY: gestureY.value > 0 ? withSpring(gestureY.value, springConfig) : withSpring(0, springConfig) }], + transform: [ + { + translateY: + gestureY.value > 0 ? withSpring(gestureY.value, SPRING_CONFIGS.springConfig) : withSpring(0, SPRING_CONFIGS.springConfig), + }, + ], }; }); @@ -74,11 +80,11 @@ export function SwapBottomPanel() { diff --git a/src/__swaps__/screens/Swap/components/SwapCoinIcon.tsx b/src/__swaps__/screens/Swap/components/SwapCoinIcon.tsx index 7228bf4907f..c4bce5b2836 100644 --- a/src/__swaps__/screens/Swap/components/SwapCoinIcon.tsx +++ b/src/__swaps__/screens/Swap/components/SwapCoinIcon.tsx @@ -62,7 +62,7 @@ export const SwapCoinIcon = React.memo(function FeedCoinIcon({ address, color, iconUrl, - disableShadow, + disableShadow = true, forceDarkMode, large, mainnetAddress, diff --git a/src/__swaps__/screens/Swap/components/SwapCoinIconTextFallback.tsx b/src/__swaps__/screens/Swap/components/SwapCoinIconTextFallback.tsx index c4305bd4def..306e17711da 100644 --- a/src/__swaps__/screens/Swap/components/SwapCoinIconTextFallback.tsx +++ b/src/__swaps__/screens/Swap/components/SwapCoinIconTextFallback.tsx @@ -11,6 +11,12 @@ const sx = StyleSheet.create({ alignItems: 'center', justifyContent: 'center', }, + defaultTextStyles: { + fontFamily: fonts.family.SFProRounded, + fontWeight: fonts.weight.heavy as TextStyle['fontWeight'], + letterSpacing: fonts.letterSpacing.roundedTight, + textAlign: 'center' as TextStyle['textAlign'], + }, }); function buildFallbackFontSize(symbol: string, width: number) { @@ -39,14 +45,6 @@ type SwapCoinIconTextFallbackProps = { symbol?: string; }; -const defaultTextStyles = { - fontFamily: fonts.family.SFProRounded, - fontWeight: fonts.weight.bold as TextStyle['fontWeight'], - letterSpacing: fonts.letterSpacing.roundedTight, - marginBottom: 0.5, - textAlign: 'center' as TextStyle['textAlign'], -}; - export const SwapCoinIconTextFallback = ({ asset, height, width, style }: SwapCoinIconTextFallbackProps) => { const { isDarkMode } = useColorMode(); @@ -79,7 +77,9 @@ export const SwapCoinIconTextFallback = ({ asset, height, width, style }: SwapCo backgroundColor, ]} > - + + {formattedSymbol} + ); }; diff --git a/src/__swaps__/screens/Swap/components/SwapInput.tsx b/src/__swaps__/screens/Swap/components/SwapInput.tsx index ac33155ea67..cd0cceaf161 100644 --- a/src/__swaps__/screens/Swap/components/SwapInput.tsx +++ b/src/__swaps__/screens/Swap/components/SwapInput.tsx @@ -19,7 +19,7 @@ export const SwapInput = ({ otherInputProgress: SharedValue; progress: SharedValue; }) => { - const { inputStyle, containerStyle } = useSwapInputStyles({ + const { containerStyle, inputStyle } = useSwapInputStyles({ asset, bottomInput, otherInputProgress, diff --git a/src/__swaps__/screens/Swap/components/SwapInputAsset.tsx b/src/__swaps__/screens/Swap/components/SwapInputAsset.tsx index 8e2c16acb57..248850853b1 100644 --- a/src/__swaps__/screens/Swap/components/SwapInputAsset.tsx +++ b/src/__swaps__/screens/Swap/components/SwapInputAsset.tsx @@ -1,7 +1,7 @@ import MaskedView from '@react-native-masked-view/masked-view'; import React from 'react'; import { StyleSheet, StatusBar } from 'react-native'; -import Animated, { runOnUI, useDerivedValue } from 'react-native-reanimated'; +import Animated, { useDerivedValue } from 'react-native-reanimated'; import { ScreenCornerRadius } from 'react-native-screen-corner-radius'; import { AnimatedText, Box, Column, Columns, Stack, useColorMode } from '@/design-system'; @@ -15,9 +15,7 @@ import { TokenList } from '@/__swaps__/screens/Swap/components/TokenList/TokenLi import { BASE_INPUT_WIDTH, INPUT_INNER_WIDTH, INPUT_PADDING, THICK_BORDER_WIDTH } from '@/__swaps__/screens/Swap/constants'; import { IS_ANDROID } from '@/env'; import { useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider'; -import { useAssetsToSell } from '@/__swaps__/screens/Swap/hooks/useAssetsToSell'; -import { isSameAssetWorklet } from '@/__swaps__/utils/assets'; -import { AmimatedSwapCoinIcon } from './AnimatedSwapCoinIcon'; +import { AnimatedSwapCoinIcon } from './AnimatedSwapCoinIcon'; function SwapInputActionButton() { const { isDarkMode } = useColorMode(); @@ -25,7 +23,7 @@ function SwapInputActionButton() { const label = useDerivedValue(() => { const asset = internalSelectedInputAsset.value; - return asset?.symbol ?? ''; + return asset?.symbol ?? (!asset ? 'Select' : ''); }); return ( @@ -34,7 +32,7 @@ function SwapInputActionButton() { disableShadow={isDarkMode} hugContent label={label} - onPress={SwapNavigation.handleInputPress} + onPressWorklet={SwapNavigation.handleInputPress} rightIcon={'􀆏'} small /> @@ -53,14 +51,9 @@ function SwapInputAmount() { }} > } style={styles.inputTextMask}> - + + {SwapInputController.formattedInputAmount} + @@ -74,7 +67,7 @@ function SwapInputIcon() { return ( - + ); } @@ -82,19 +75,12 @@ function SwapInputIcon() { function InputAssetBalanceBadge() { const { internalSelectedInputAsset } = useSwapContext(); - const userAssets = useAssetsToSell(); - const label = useDerivedValue(() => { const asset = internalSelectedInputAsset.value; - if (!asset) return 'No balance'; - - const userAsset = userAssets.find(userAsset => - isSameAssetWorklet(userAsset, { - address: asset.address, - chainId: asset.chainId, - }) - ); - return userAsset?.balance.display ?? 'No balance'; + const hasBalance = Number(asset?.balance.amount) > 0 && asset?.balance.display; + const balance = (hasBalance && asset?.balance.display) || 'No Balance'; + + return asset ? balance : 'Token to Swap'; }); return ; @@ -125,13 +111,9 @@ export function SwapInputAsset() { - + + {SwapInputController.formattedInputNativeValue} + @@ -148,8 +130,9 @@ export function SwapInputAsset() { > diff --git a/src/__swaps__/screens/Swap/components/SwapNavbar.tsx b/src/__swaps__/screens/Swap/components/SwapNavbar.tsx index b12c564c233..71ff00f44d8 100644 --- a/src/__swaps__/screens/Swap/components/SwapNavbar.tsx +++ b/src/__swaps__/screens/Swap/components/SwapNavbar.tsx @@ -1,33 +1,41 @@ import React from 'react'; -import { StyleSheet, Text as RNText, Pressable } from 'react-native'; -import Animated from 'react-native-reanimated'; +import { StyleSheet, /* Text as RNText, */ Pressable } from 'react-native'; +import Animated, { useDerivedValue } from 'react-native-reanimated'; import { ButtonPressAnimation } from '@/components/animations'; import { ContactAvatar } from '@/components/contacts'; import ImageAvatar from '@/components/contacts/ImageAvatar'; import { Navbar } from '@/components/navbar/Navbar'; -import { Bleed, Box, IconContainer, Inset, Text, globalColors, useColorMode, useForegroundColor } from '@/design-system'; +import { AnimatedText, Box, Inset, globalColors, useColorMode } from '@/design-system'; import { useAccountProfile } from '@/hooks'; import * as i18n from '@/languages'; import { useNavigation } from '@/navigation'; import Routes from '@/navigation/routesNames'; import { safeAreaInsetValues } from '@/utils'; - import { THICK_BORDER_WIDTH } from '@/__swaps__/screens/Swap/constants'; - -import { opacity } from '@/__swaps__/utils/swaps'; import { IS_ANDROID, IS_IOS } from '@/env'; import { useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider'; +const SWAP_TITLE_LABEL = i18n.t(i18n.l.swap.modal_types.swap); +const BRIDGE_TITLE_LABEL = i18n.t(i18n.l.swap.modal_types.bridge); + export function SwapNavbar() { const { accountSymbol, accountColor, accountImage } = useAccountProfile(); const { isDarkMode } = useColorMode(); const { navigate, goBack } = useNavigation(); - const { AnimatedSwapStyles } = useSwapContext(); + const { AnimatedSwapStyles, internalSelectedInputAsset, internalSelectedOutputAsset } = useSwapContext(); + + // const separatorSecondary = useForegroundColor('separatorSecondary'); + // const separatorTertiary = useForegroundColor('separatorTertiary'); + + const swapOrBridgeLabel = useDerivedValue(() => { + const areBothAssetsSelected = internalSelectedInputAsset.value && internalSelectedOutputAsset.value; + const isBridging = + areBothAssetsSelected && internalSelectedInputAsset.value?.mainnetAddress === internalSelectedOutputAsset.value?.mainnetAddress; - const separatorSecondary = useForegroundColor('separatorSecondary'); - const separatorTertiary = useForegroundColor('separatorTertiary'); + return isBridging ? BRIDGE_TITLE_LABEL : SWAP_TITLE_LABEL; + }); const onChangeWallet = React.useCallback(() => { navigate(Routes.CHANGE_WALLET_SHEET); @@ -107,9 +115,9 @@ export function SwapNavbar() { rightComponent={null} titleComponent={ - - {i18n.t(i18n.l.swap.modal_types.swap)} - + + {swapOrBridgeLabel} + } /> diff --git a/src/__swaps__/screens/Swap/components/SwapNumberPad.tsx b/src/__swaps__/screens/Swap/components/SwapNumberPad.tsx index 1768701ee61..cbef8159fd6 100644 --- a/src/__swaps__/screens/Swap/components/SwapNumberPad.tsx +++ b/src/__swaps__/screens/Swap/components/SwapNumberPad.tsx @@ -38,11 +38,18 @@ export const SwapNumberPad = () => { const addNumber = (number?: number) => { 'worklet'; - // immediately stop the quote fetching interval + // Immediately stop the quote fetching interval SwapInputController.quoteFetchingInterval.stop(); - isQuoteStale.value = 1; const inputKey = focusedInput.value; + const currentValue = SwapInputController.inputValues.value[inputKey].toString(); + const newValue = currentValue === '0' ? `${number}` : `${currentValue}${number}`; + + // Make the quote stale only when the number in the input actually changes + if (Number(newValue) !== 0 && !(currentValue.includes('.') && number === 0)) { + isQuoteStale.value = 1; + } + if (SwapInputController.inputMethod.value !== inputKey) { SwapInputController.inputMethod.value = inputKey; @@ -58,8 +65,6 @@ export const SwapNumberPad = () => { }); } } - const currentValue = SwapInputController.inputValues.value[inputKey]; - const newValue = currentValue === 0 || currentValue === '0' ? `${number}` : `${currentValue}${number}`; SwapInputController.inputValues.modify(value => { return { @@ -102,7 +107,6 @@ export const SwapNumberPad = () => { const deleteLastCharacter = () => { 'worklet'; const inputKey = focusedInput.value; - isQuoteStale.value = 1; if (SwapInputController.inputMethod.value !== inputKey) { SwapInputController.inputMethod.value = inputKey; @@ -117,9 +121,16 @@ export const SwapNumberPad = () => { }; }); } + const currentValue = SwapInputController.inputValues.value[inputKey].toString(); // Handle deletion, ensuring a placeholder zero remains if the entire number is deleted const newValue = currentValue.length > 1 ? currentValue.slice(0, -1) : 0; + + // Make the quote stale only when the number in the input actually changes + if (!currentValue.endsWith('.') && Number(newValue) !== 0) { + isQuoteStale.value = 1; + } + if (newValue === 0) { SwapInputController.inputValues.modify(values => { return { @@ -285,7 +296,7 @@ const NumberPadKey = ({ }, [isDarkMode]); return ( - // @ts-expect-error + // @ts-expect-error Property 'children' does not exist on type { const asset = internalSelectedOutputAsset.value; - return asset?.symbol ?? ''; + return asset?.symbol ?? (!asset ? 'Select' : ''); }); return ( @@ -34,7 +33,7 @@ function SwapOutputActionButton() { disableShadow={isDarkMode} hugContent label={label} - onPress={SwapNavigation.handleOutputPress} + onPressWorklet={SwapNavigation.handleOutputPress} rightIcon={'􀆏'} small /> @@ -53,14 +52,9 @@ function SwapOutputAmount() { }} > } style={styles.inputTextMask}> - + + {SwapInputController.formattedOutputAmount} + @@ -74,7 +68,7 @@ function SwapInputIcon() { return ( - + ); } @@ -82,19 +76,12 @@ function SwapInputIcon() { function OutputAssetBalanceBadge() { const { internalSelectedOutputAsset } = useSwapContext(); - const userAssets = useAssetsToSell(); - const label = useDerivedValue(() => { const asset = internalSelectedOutputAsset.value; - if (!asset) return 'No balance'; + const hasBalance = Number(asset?.balance.amount) > 0 && asset?.balance.display; + const balance = (hasBalance && asset?.balance.display) || 'No Balance'; - const userAsset = userAssets.find(userAsset => - isSameAssetWorklet(userAsset, { - address: asset.address, - chainId: asset.chainId, - }) - ); - return userAsset?.balance.display ?? 'No balance'; + return asset ? balance : 'Token to Get'; }); return ; @@ -125,13 +112,9 @@ export function SwapOutputAsset() { - + + {SwapInputController.formattedOutputNativeValue} + @@ -149,8 +132,8 @@ export function SwapOutputAsset() { > diff --git a/src/__swaps__/screens/Swap/components/SwapSlider.tsx b/src/__swaps__/screens/Swap/components/SwapSlider.tsx index e96c1e0b478..cbe31f811f3 100644 --- a/src/__swaps__/screens/Swap/components/SwapSlider.tsx +++ b/src/__swaps__/screens/Swap/components/SwapSlider.tsx @@ -6,6 +6,7 @@ import Animated, { interpolate, interpolateColor, runOnJS, + runOnUI, useAnimatedGestureHandler, useAnimatedReaction, useAnimatedStyle, @@ -17,10 +18,10 @@ import Animated, { withSpring, withTiming, } from 'react-native-reanimated'; -import { AnimatedText, Bleed, Box, Column, Columns, Inline, Text, globalColors, useColorMode, useForegroundColor } from '@/design-system'; +import { SPRING_CONFIGS, TIMING_CONFIGS } from '@/components/animations/animationConfigs'; +import { AnimatedText, Bleed, Box, Column, Columns, Inline, globalColors, useColorMode, useForegroundColor } from '@/design-system'; import { IS_IOS } from '@/env'; import { triggerHapticFeedback } from '@/screens/points/constants'; - import { SCRUBBER_WIDTH, SLIDER_COLLAPSED_HEIGHT, @@ -28,15 +29,10 @@ import { SLIDER_WIDTH, THICK_BORDER_WIDTH, pulsingConfig, - sliderConfig, - slowFadeConfig, - snappierSpringConfig, - snappySpringConfig, - springConfig, } from '@/__swaps__/screens/Swap/constants'; -import { clamp, getColorValueForThemeWorklet, opacity, opacityWorklet } from '@/__swaps__/utils/swaps'; import { useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider'; -import { AmimatedSwapCoinIcon } from './AnimatedSwapCoinIcon'; +import { clamp, getColorValueForThemeWorklet, opacity, opacityWorklet } from '@/__swaps__/utils/swaps'; +import { AnimatedSwapCoinIcon } from './AnimatedSwapCoinIcon'; type SwapSliderProps = { dualColor?: boolean; @@ -50,7 +46,7 @@ export const SwapSlider = ({ dualColor, height = SLIDER_HEIGHT, initialPercentage = 0, - snapPoints = [0, 0.25, 0.5, 0.75, 1], // % + snapPoints = [0, 0.25, 0.5, 0.75, 1], // 0%, 25%, 50%, 75%, 100% width = SLIDER_WIDTH, }: SwapSliderProps) => { const { isDarkMode } = useColorMode(); @@ -59,9 +55,10 @@ export const SwapSlider = ({ SwapInputController, internalSelectedInputAsset, internalSelectedOutputAsset, - sliderXPosition, - sliderPressProgress, + isFetching, isQuoteStale, + sliderPressProgress, + sliderXPosition, } = useSwapContext(); const panRef = useRef(); @@ -76,8 +73,8 @@ export const SwapSlider = ({ // Callback function to handle percentage change once slider is at rest const onChangeWrapper = useCallback( - (percentage: number, setStale = true) => { - SwapInputController.onChangedPercentage(percentage, setStale); + (percentage: number) => { + SwapInputController.onChangedPercentage(percentage); }, [SwapInputController] ); @@ -100,19 +97,19 @@ export const SwapSlider = ({ : fillSecondary, })); - // This is the percentage of the slider from the left + // This is the percentage of the slider from the left, from 0 to 1 const xPercentage = useDerivedValue(() => { return clamp((sliderXPosition.value - SCRUBBER_WIDTH / width) / width, 0, 1); - }, [sliderXPosition.value]); + }); // This is a hacky way to prevent the slider from shifting when it reaches the right limit const uiXPercentage = useDerivedValue(() => { return xPercentage.value * (1 - SCRUBBER_WIDTH / width); - }, [xPercentage.value]); + }); const percentageText = useDerivedValue(() => { return `${Math.round((xPercentage.value ?? initialPercentage) * 100)}%`; - }, [xPercentage.value]); + }); useAnimatedReaction( () => ({ x: sliderXPosition.value }), @@ -131,28 +128,29 @@ export const SwapSlider = ({ const onPressDown = useAnimatedGestureHandler({ onStart: () => { - sliderPressProgress.value = withSpring(1, sliderConfig); + sliderPressProgress.value = withSpring(1, SPRING_CONFIGS.sliderConfig); SwapInputController.quoteFetchingInterval.stop(); }, onActive: () => { - sliderPressProgress.value = withSpring(SLIDER_COLLAPSED_HEIGHT / height, sliderConfig); + sliderPressProgress.value = withSpring(SLIDER_COLLAPSED_HEIGHT / height, SPRING_CONFIGS.sliderConfig); }, }); const onSlide = useAnimatedGestureHandler({ onStart: (_, ctx: { startX: number }) => { ctx.startX = sliderXPosition.value; - sliderPressProgress.value = withSpring(1, sliderConfig); + sliderPressProgress.value = withSpring(1, SPRING_CONFIGS.sliderConfig); SwapInputController.inputMethod.value = 'slider'; - // On Android, for some reason waiting until onActive to set SwapInputController.isQuoteStale.value = 1 - // causes the outputAmount text color to break. It's preferable to set it in - // onActive, so we're setting it in onStart for Android only. It's possible that - // migrating this handler to the RNGH v2 API will remove the need for this. + // On Android, for some reason waiting until onActive to set SwapInputController.isQuoteStale.value = 1 causes + // the outputAmount text color to break. It's preferable to set it in onActive, so we're setting it in onStart + // for Android only. It's possible that migrating this handler to the RNGH v2 API will remove the need for this. if (!IS_IOS) isQuoteStale.value = 1; }, onActive: (event, ctx: { startX: number }) => { - if (IS_IOS) isQuoteStale.value = 1; + if (IS_IOS && sliderXPosition.value > 0 && isQuoteStale.value !== 1) { + isQuoteStale.value = 1; + } const rawX = ctx.startX + event.translationX || 0; @@ -186,22 +184,23 @@ export const SwapSlider = ({ }, onFinish: (event, ctx: { startX: number }) => { const onFinished = () => { - overshoot.value = withSpring(0, sliderConfig); + overshoot.value = withSpring(0, SPRING_CONFIGS.sliderConfig); if (xPercentage.value >= 0.995) { if (isQuoteStale.value === 1) { runOnJS(onChangeWrapper)(1); } - sliderXPosition.value = withSpring(width, snappySpringConfig); + sliderXPosition.value = withSpring(width, SPRING_CONFIGS.snappySpringConfig); } else if (xPercentage.value < 0.005) { runOnJS(onChangeWrapper)(0); - sliderXPosition.value = withSpring(0, snappySpringConfig); - // SwapInputController.isQuoteStale.value = 0; + sliderXPosition.value = withSpring(0, SPRING_CONFIGS.snappySpringConfig); + isQuoteStale.value = 0; + isFetching.value = false; } else { runOnJS(onChangeWrapper)(xPercentage.value); } }; - sliderPressProgress.value = withSpring(SLIDER_COLLAPSED_HEIGHT / height, sliderConfig); + sliderPressProgress.value = withSpring(SLIDER_COLLAPSED_HEIGHT / height, SPRING_CONFIGS.sliderConfig); if (snapPoints) { // If snap points are provided and velocity is high enough, snap to the nearest point @@ -240,11 +239,11 @@ export const SwapSlider = ({ nextSnapPoint = nextSnapPoint || 0; } - overshoot.value = withSpring(0, sliderConfig); + overshoot.value = withSpring(0, SPRING_CONFIGS.sliderConfig); runOnJS(onChangeWrapper)(nextSnapPoint / width); // Animate to the next snap point - sliderXPosition.value = withSpring(nextSnapPoint, snappierSpringConfig); + sliderXPosition.value = withSpring(nextSnapPoint, SPRING_CONFIGS.snappierSpringConfig); // if (nextSnapPoint === 0) { // SwapInputController.isQuoteStale.value = 0; @@ -300,7 +299,7 @@ export const SwapSlider = ({ [collapsedPercentage, 1], [colors.value.inactiveColorLeft, colors.value.activeColorLeft] ), - springConfig + SPRING_CONFIGS.springConfig ), borderWidth: interpolate( xPercentage.value, @@ -328,18 +327,15 @@ export const SwapSlider = ({ const pulsingOpacity = useDerivedValue(() => { return isQuoteStale.value === 1 ? withRepeat(withSequence(withTiming(0.5, pulsingConfig), withTiming(1, pulsingConfig)), -1, true) - : withSpring(1, sliderConfig); - }, []); + : withSpring(1, SPRING_CONFIGS.sliderConfig); + }); const percentageTextStyle = useAnimatedStyle(() => { - const isAdjustingInputValue = - SwapInputController.inputMethod.value === 'inputAmount' || SwapInputController.inputMethod.value === 'inputNativeValue'; const isAdjustingOutputValue = SwapInputController.inputMethod.value === 'outputAmount' || SwapInputController.inputMethod.value === 'outputNativeValue'; - const isStale = isQuoteStale.value === 1 && (isAdjustingInputValue || isAdjustingOutputValue) ? 1 : 0; - - const opacity = isStale ? pulsingOpacity.value : withSpring(1, sliderConfig); + const isStale = isQuoteStale.value === 1 && isAdjustingOutputValue ? 1 : 0; + const opacity = isStale ? pulsingOpacity.value : withSpring(1, SPRING_CONFIGS.sliderConfig); return { color: withTiming( @@ -353,14 +349,18 @@ export const SwapSlider = ({ zeroAmountColor, ] ), - slowFadeConfig + TIMING_CONFIGS.slowFadeConfig ), opacity, }; }); - const maxText = useDerivedValue(() => { - return 'Max'; + const sellingOrBridgingLabel = useDerivedValue(() => { + const areBothAssetsSelected = internalSelectedInputAsset.value && internalSelectedOutputAsset.value; + const isBridging = + areBothAssetsSelected && internalSelectedInputAsset.value?.mainnetAddress === internalSelectedOutputAsset.value?.mainnetAddress; + + return isBridging ? 'Bridging' : 'Selling'; }); const maxTextColor = useAnimatedStyle(() => { @@ -370,23 +370,30 @@ export const SwapSlider = ({ }); return ( - // @ts-expect-error + // @ts-expect-error Property 'children' does not exist on type - {/* @ts-expect-error */} + {/* @ts-expect-error Property 'children' does not exist on type */} - + - - Selling - - + + {sellingOrBridgingLabel} + + + {percentageText} + @@ -394,17 +401,17 @@ export const SwapSlider = ({ activeOpacity={0.4} hitSlop={8} onPress={() => { - 'worklet'; - - SwapInputController.quoteFetchingInterval.stop(); - SwapInputController.inputMethod.value = 'slider'; - setTimeout(() => { - sliderXPosition.value = withSpring(width, snappySpringConfig); - onChangeWrapper(1); - }, 10); + runOnUI(() => { + SwapInputController.quoteFetchingInterval.stop(); + SwapInputController.inputMethod.value = 'slider'; + sliderXPosition.value = withSpring(width, SPRING_CONFIGS.snappySpringConfig); + runOnJS(onChangeWrapper)(1); + })(); }} > - + + Max + diff --git a/src/__swaps__/screens/Swap/components/SwapWarning.tsx b/src/__swaps__/screens/Swap/components/SwapWarning.tsx index c8db66929f5..9347f3e0034 100644 --- a/src/__swaps__/screens/Swap/components/SwapWarning.tsx +++ b/src/__swaps__/screens/Swap/components/SwapWarning.tsx @@ -55,9 +55,11 @@ export const SwapWarning = () => { paddingVertical="16px" style={[AnimatedSwapStyles.hideWhenInputsExpandedOrNoPriceImpact, { alignSelf: 'center', position: 'absolute', top: 8 }]} > - + - + + {warningTitle} + { +export const ChainSelection = memo(function ChainSelection({ allText, output }: ChainSelectionProps) { const { isDarkMode } = useColorMode(); const { accentColor: accountColor } = useAccountAccentColor(); const { selectedOutputChainId, setSelectedOutputChainId } = useSwapContext(); - const red = useForegroundColor('red'); - const initialFilter = useMemo(() => { - return userAssetsStore.getState().filter; - }, []); + const balanceSortedChainList = userAssetsStore.getState().getBalanceSortedChainList(); + const inputListFilter = useSharedValue(userAssetsStore.getState().filter); const accentColor = useMemo(() => { if (c.contrast(accountColor, isDarkMode ? '#191A1C' : globalColors.white100) < (isDarkMode ? 2.125 : 1.5)) { @@ -47,51 +40,39 @@ export const ChainSelection = ({ allText, output }: ChainSelectionProps) => { } }, [accountColor, isDarkMode]); - const chainName = useSharedValue( - output - ? chainNameFromChainIdWorklet(selectedOutputChainId.value) - : initialFilter === 'all' + const chainName = useDerivedValue(() => { + return output + ? ChainNameDisplay[selectedOutputChainId.value] + : inputListFilter.value === 'all' ? allText - : chainNameFromChainIdWorklet(initialFilter as ChainId) - ); - - useAnimatedReaction( - () => ({ - outputChainId: selectedOutputChainId.value, - }), - current => { - if (output) { - chainName.value = chainNameForChainIdWithMainnetSubstitutionWorklet(current.outputChainId); - } - } - ); + : ChainNameDisplay[inputListFilter.value as ChainId]; + }); const handleSelectChain = useCallback( ({ nativeEvent: { actionKey } }: Omit) => { if (output) { setSelectedOutputChainId(Number(actionKey) as ChainId); } else { + inputListFilter.value = actionKey === 'all' ? 'all' : (Number(actionKey) as ChainId); userAssetsStore.setState({ filter: actionKey === 'all' ? 'all' : (Number(actionKey) as ChainId), }); - runOnUI(() => { - chainName.value = actionKey === 'all' ? allText : chainNameForChainIdWithMainnetSubstitutionWorklet(Number(actionKey) as ChainId); - }); } }, - [allText, chainName, output, selectedOutputChainId] + [inputListFilter, output, setSelectedOutputChainId] ); const menuConfig = useMemo(() => { - const supportedChains = SUPPORTED_CHAINS({ testnetMode: false }).map(chain => { - const title = chainNameForChainIdWithMainnetSubstitution(chain.id); + const supportedChains = balanceSortedChainList.map(chainId => { + const networkName = chainNameForChainIdWithMainnetSubstitution(chainId); + const displayName = ChainNameDisplay[chainId]; return { - actionKey: `${chain.id}`, - actionTitle: title.charAt(0).toUpperCase() + title.slice(1), + actionKey: `${chainId}`, + actionTitle: displayName, icon: { iconType: 'ASSET', - iconValue: `${title}Badge${isDarkMode ? 'Dark' : ''}`, + iconValue: `${networkName}Badge${chainId === ChainId.mainnet ? '' : 'NoShadow'}`, }, }; }); @@ -110,55 +91,34 @@ export const ChainSelection = ({ allText, output }: ChainSelectionProps) => { return { menuItems: supportedChains, }; - }, [isDarkMode, output]); + }, [balanceSortedChainList, output]); const onShowActionSheet = useCallback(() => { const chainTitles = menuConfig.menuItems.map(chain => chain.actionTitle); - if (!output) { - chainTitles.unshift(i18n.t(i18n.l.exchange.all_networks) as ChainName); - } - showActionSheetWithOptions( { options: chainTitles, showSeparators: true, }, - (index: number) => { + (index: number | undefined) => { + // NOTE: When they click away from the menu, the index is undefined + if (typeof index === 'undefined') return; handleSelectChain({ nativeEvent: { actionKey: menuConfig.menuItems[index].actionKey, actionTitle: '' }, }); } ); - }, [handleSelectChain, menuConfig.menuItems, output]); + }, [handleSelectChain, menuConfig.menuItems]); return ( {output ? ( - - - - - - 􀆪 - - - - - + + 􀆪 + {i18n.t(i18n.l.exchange.filter_by_network)} @@ -194,35 +154,49 @@ export const ChainSelection = ({ allText, output }: ChainSelectionProps) => { )} - - - {/* TODO: We need to add some ethereum utils to handle worklet functions */} - {output && ( - - )} - - - 􀆏 - - - + + {/* TODO: We need to add some ethereum utils to handle worklet functions */} + + + {chainName} + + + 􀆏 + + ); +}); + +const ChainButtonIcon = ({ output }: { output: boolean | undefined }) => { + const { selectedOutputChainId: animatedSelectedOutputChainId } = useSwapContext(); + + const userAssetsFilter = userAssetsStore(state => (output ? undefined : state.filter)); + const selectedOutputChainId = useSharedValueState(animatedSelectedOutputChainId, { pauseSync: !output }); + + return ( + + {output ? ( + + ) : userAssetsFilter && userAssetsFilter !== 'all' ? ( + + ) : ( + <> + )} + + ); }; export const styles = StyleSheet.create({ diff --git a/src/__swaps__/screens/Swap/components/TokenList/ListEmpty.tsx b/src/__swaps__/screens/Swap/components/TokenList/ListEmpty.tsx index 59bced4ad44..e4c1d664ba1 100644 --- a/src/__swaps__/screens/Swap/components/TokenList/ListEmpty.tsx +++ b/src/__swaps__/screens/Swap/components/TokenList/ListEmpty.tsx @@ -5,18 +5,23 @@ import { swapsStore } from '@/state/swaps/swapsStore'; import { isL2Chain } from '@/__swaps__/utils/chains'; type ListEmptyProps = { - output?: boolean; action?: 'swap' | 'bridge'; + isSearchEmptyState?: boolean; + output?: boolean; }; -export const ListEmpty = ({ output = false, action = 'swap' }: ListEmptyProps) => { +export const ListEmpty = ({ action = 'swap', isSearchEmptyState, output = false }: ListEmptyProps) => { // TODO: Might need to make this reactive instead of reading inline getState const isL2 = useMemo(() => { return output ? isL2Chain(swapsStore.getState().selectedOutputChainId) : false; }, [output]); return ( - + diff --git a/src/__swaps__/screens/Swap/components/TokenList/TokenList.tsx b/src/__swaps__/screens/Swap/components/TokenList/TokenList.tsx index 6daf858b8f1..bbd9e9e351d 100644 --- a/src/__swaps__/screens/Swap/components/TokenList/TokenList.tsx +++ b/src/__swaps__/screens/Swap/components/TokenList/TokenList.tsx @@ -12,14 +12,14 @@ import { ExtendedAnimatedAssetWithColors } from '@/__swaps__/types/assets'; export const TokenList = ({ asset, - handleExitSearch, - handleFocusSearch, + handleExitSearchWorklet, + handleFocusSearchWorklet, output, }: { asset: SharedValue; - handleExitSearch: () => void; - handleFocusSearch: () => void; - output?: boolean; + handleExitSearchWorklet: () => void; + handleFocusSearchWorklet: () => void; + output: boolean; }) => { const { inputProgress, outputProgress } = useSwapContext(); const { width: deviceWidth } = useDimensions(); @@ -38,7 +38,12 @@ export const TokenList = ({ return ( - + { - const sections = useAssetsToBuySections(); - - const assetsCount = useMemo(() => sections?.reduce((count, section) => count + section.data.length, 0), [sections]); + const { loading, results: sections } = useSearchCurrencyLists(); return ( @@ -23,7 +20,7 @@ export const TokenToBuyList = () => { ))} - {!assetsCount && } + {!sections.length && !loading && } ); }; diff --git a/src/__swaps__/screens/Swap/components/TokenList/TokenToBuySection.tsx b/src/__swaps__/screens/Swap/components/TokenList/TokenToBuySection.tsx index cbb10ed73aa..e6f0cf370c8 100644 --- a/src/__swaps__/screens/Swap/components/TokenList/TokenToBuySection.tsx +++ b/src/__swaps__/screens/Swap/components/TokenList/TokenToBuySection.tsx @@ -1,12 +1,12 @@ -import React, { useCallback } from 'react'; +import React, { useCallback, useMemo } from 'react'; import { StyleSheet, TextStyle } from 'react-native'; -import Animated, { useDerivedValue } from 'react-native-reanimated'; +import { runOnUI } from 'react-native-reanimated'; import { FlashList } from '@shopify/flash-list'; import * as i18n from '@/languages'; import { CoinRow } from '@/__swaps__/screens/Swap/components/CoinRow'; import { SearchAsset } from '@/__swaps__/types/search'; -import { AnimatedText, Box, Inline, Inset, Stack, Text, useForegroundColor } from '@/design-system'; +import { Box, Inline, Inset, Stack, Text, TextIcon, useForegroundColor } from '@/design-system'; import { AssetToBuySection, AssetToBuySectionId } from '@/__swaps__/screens/Swap/hooks/useSearchCurrencyLists'; import { ChainId } from '@/__swaps__/types/chains'; import { TextColor } from '@/design-system/color/palettes'; @@ -16,6 +16,9 @@ import { parseSearchAsset } from '@/__swaps__/utils/assets'; import { ListEmpty } from '@/__swaps__/screens/Swap/components/TokenList/ListEmpty'; import { SwapAssetType } from '@/__swaps__/types/swap'; import { userAssetsStore } from '@/state/assets/userAssets'; +import { EXPANDED_INPUT_HEIGHT } from '../../constants'; +import { DEVICE_WIDTH } from '@/utils/deviceUtils'; +import { getStandardizedUniqueIdWorklet } from '@/__swaps__/utils/swaps'; interface SectionProp { color: TextStyle['color']; @@ -63,20 +66,29 @@ const bridgeSectionsColorsByChain = { [ChainId.blast]: 'blast' as TextStyle['color'], }; -const AnimatedFlashListComponent = Animated.createAnimatedComponent(FlashList); - export const TokenToBuySection = ({ section }: { section: AssetToBuySection }) => { - const { setAsset, selectedOutputChainId } = useSwapContext(); + const { internalSelectedInputAsset, internalSelectedOutputAsset, isFetching, isQuoteStale, setAsset, selectedOutputChainId } = + useSwapContext(); - const label = useForegroundColor('label'); + const labelTextColor = useForegroundColor('label'); const handleSelectToken = useCallback( (token: SearchAsset) => { + runOnUI(() => { + if ( + internalSelectedInputAsset.value && + getStandardizedUniqueIdWorklet({ address: token.address, chainId: token.chainId }) !== internalSelectedOutputAsset.value?.uniqueId + ) { + isQuoteStale.value = 1; + isFetching.value = true; + } + })(); + const userAsset = userAssetsStore.getState().getUserAsset(token.uniqueId); const parsedAsset = parseSearchAsset({ assetWithPrice: undefined, searchAsset: token, - userAsset, + userAsset: userAsset ?? undefined, }); setAsset({ @@ -84,23 +96,20 @@ export const TokenToBuySection = ({ section }: { section: AssetToBuySection }) = asset: parsedAsset, }); }, - [setAsset] + [internalSelectedInputAsset, internalSelectedOutputAsset, isFetching, isQuoteStale, setAsset] ); const { symbol, title } = sectionProps[section.id]; - const symbolValue = useDerivedValue(() => symbol); - - const color = useDerivedValue(() => { + const color = useMemo(() => { if (section.id !== 'bridge') { if (sectionProps[section.id].color) { return sectionProps[section.id].color as TextColor; } - return label as TextColor; + return labelTextColor as TextColor; } - return bridgeSectionsColorsByChain[selectedOutputChainId.value || ChainId.mainnet] as TextColor; - }); + }, [labelTextColor, section.id, selectedOutputChainId]); return ( @@ -118,37 +127,33 @@ export const TokenToBuySection = ({ section }: { section: AssetToBuySection }) = ) : null} - - + + {symbol} + + {title} - {/* TODO: fix this from causing the UI to be completely slow... */} - } + } + estimatedItemSize={56} keyExtractor={item => `${item.uniqueId}-${section.id}`} renderItem={({ item }) => ( handleSelectToken(item)} - nativeBalance={''} output symbol={item.symbol} + uniqueId={item.uniqueId} /> )} /> diff --git a/src/__swaps__/screens/Swap/components/TokenList/TokenToSellList.tsx b/src/__swaps__/screens/Swap/components/TokenList/TokenToSellList.tsx index c989c27d0f6..811a989805e 100644 --- a/src/__swaps__/screens/Swap/components/TokenList/TokenToSellList.tsx +++ b/src/__swaps__/screens/Swap/components/TokenList/TokenToSellList.tsx @@ -1,65 +1,64 @@ import React, { useCallback } from 'react'; import { StyleSheet } from 'react-native'; import { CoinRow } from '@/__swaps__/screens/Swap/components/CoinRow'; -import { useAssetsToSell } from '@/__swaps__/screens/Swap/hooks/useAssetsToSell'; import { ParsedSearchAsset } from '@/__swaps__/types/assets'; import { Stack } from '@/design-system'; -import Animated from 'react-native-reanimated'; +import { runOnUI } from 'react-native-reanimated'; import { useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider'; -import { parseSearchAsset } from '@/__swaps__/utils/assets'; import { ListEmpty } from '@/__swaps__/screens/Swap/components/TokenList/ListEmpty'; import { FlashList } from '@shopify/flash-list'; import { ChainSelection } from './ChainSelection'; import { SwapAssetType } from '@/__swaps__/types/swap'; import { userAssetsStore } from '@/state/assets/userAssets'; - -const AnimatedFlashListComponent = Animated.createAnimatedComponent(FlashList); +import { EXPANDED_INPUT_HEIGHT } from '../../constants'; +import { DEVICE_WIDTH } from '@/utils/deviceUtils'; +import { getStandardizedUniqueIdWorklet } from '@/__swaps__/utils/swaps'; +import { useDelayedMount } from '@/hooks/useDelayedMount'; export const TokenToSellList = () => { - const { setAsset } = useSwapContext(); - const userAssets = useAssetsToSell(); + const shouldMount = useDelayedMount(); + return shouldMount ? : null; +}; + +const TokenToSellListComponent = () => { + const { internalSelectedInputAsset, internalSelectedOutputAsset, isFetching, isQuoteStale, setAsset } = useSwapContext(); + + const userAssets = userAssetsStore(state => state.getFilteredUserAssetIds()); const handleSelectToken = useCallback( - (token: ParsedSearchAsset) => { - const userAsset = userAssetsStore.getState().getUserAsset(token.uniqueId); - const parsedAsset = parseSearchAsset({ - assetWithPrice: undefined, - searchAsset: token, - userAsset, - }); + (token: ParsedSearchAsset | null) => { + if (!token) return; + + runOnUI(() => { + if ( + internalSelectedOutputAsset.value && + getStandardizedUniqueIdWorklet({ address: token.address, chainId: token.chainId }) !== internalSelectedInputAsset.value?.uniqueId + ) { + isQuoteStale.value = 1; + isFetching.value = true; + } + })(); setAsset({ type: SwapAssetType.inputAsset, - asset: parsedAsset, + asset: token, }); }, - [setAsset] + [internalSelectedInputAsset, internalSelectedOutputAsset, isFetching, isQuoteStale, setAsset] ); return ( - } - keyExtractor={item => item.uniqueId} - renderItem={({ item }) => ( - handleSelectToken(item)} - nativeBalance={item.native.balance.display} - output={false} - symbol={item.symbol} - /> - )} + keyExtractor={uniqueId => uniqueId} + renderItem={({ item: uniqueId }) => { + return handleSelectToken(asset)} output={false} uniqueId={uniqueId} />; + }} /> ); diff --git a/src/__swaps__/screens/Swap/components/UserAssetsSync.tsx b/src/__swaps__/screens/Swap/components/UserAssetsSync.tsx index 6784ae0d04c..c8784f6b353 100644 --- a/src/__swaps__/screens/Swap/components/UserAssetsSync.tsx +++ b/src/__swaps__/screens/Swap/components/UserAssetsSync.tsx @@ -1,33 +1,44 @@ +import { memo } from 'react'; +import { Address } from 'viem'; import { useAccountSettings } from '@/hooks'; -import { useUserAssets } from '../resources/assets'; - -import { selectUserAssetsList, selectorFilterByUserChains } from '@/__swaps__/screens/Swap/resources/_selectors/assets'; -import { Hex } from 'viem'; import { userAssetsStore } from '@/state/assets/userAssets'; +import { useSwapsStore } from '@/state/swaps/swapsStore'; +import { selectUserAssetsList, selectorFilterByUserChains } from '@/__swaps__/screens/Swap/resources/_selectors/assets'; import { ParsedSearchAsset } from '@/__swaps__/types/assets'; +import { ChainId } from '@/__swaps__/types/chains'; +import { useUserAssets } from '../resources/assets'; -export const UserAssetsSync = () => { +export const UserAssetsSync = memo(function UserAssetsSync() { const { accountAddress: currentAddress, nativeCurrency: currentCurrency } = useAccountSettings(); + const userAssetsWalletAddress = userAssetsStore(state => state.associatedWalletAddress); + const isSwapsOpen = useSwapsStore(state => state.isSwapsOpen); + useUserAssets( { - address: currentAddress as Hex, + address: currentAddress as Address, currency: currentCurrency, }, { + enabled: !isSwapsOpen || userAssetsWalletAddress !== currentAddress, select: data => selectorFilterByUserChains({ data, selector: selectUserAssetsList, }), onSuccess: data => { - userAssetsStore.setState({ - userAssetsById: new Set(data.map(d => d.uniqueId)), - userAssets: new Map(data.map(d => [d.uniqueId, d as ParsedSearchAsset])), - }); + if (!isSwapsOpen || userAssetsWalletAddress !== currentAddress) { + userAssetsStore.getState().setUserAssets(currentAddress as Address, data as ParsedSearchAsset[]); + + const inputAsset = userAssetsStore.getState().getHighestValueAsset(); + useSwapsStore.setState({ + inputAsset, + selectedOutputChainId: inputAsset?.chainId ?? ChainId.mainnet, + }); + } }, } ); return null; -}; +}); diff --git a/src/__swaps__/screens/Swap/hooks/formatNumber.ts b/src/__swaps__/screens/Swap/hooks/formatNumber.ts index bbaf4f72d6d..cc7dc70699c 100644 --- a/src/__swaps__/screens/Swap/hooks/formatNumber.ts +++ b/src/__swaps__/screens/Swap/hooks/formatNumber.ts @@ -2,9 +2,11 @@ import store from '@/redux/store'; import { supportedNativeCurrencies } from '@/references'; const decimalSeparator = '.'; +const lessThanPrefix = '<'; + export const formatNumber = (value: string, options?: { decimals?: number }) => { - if (!+value) return `0${decimalSeparator}00`; - if (+value < 0.0001) return `<0${decimalSeparator}0001`; + if (!+value) return `0${decimalSeparator}0`; + if (+value < 0.0001) return `${lessThanPrefix}0${decimalSeparator}0001`; const [whole, fraction = ''] = value.split(decimalSeparator); const decimals = options?.decimals; @@ -26,6 +28,13 @@ const getUserPreferredCurrency = () => { export const formatCurrency = (value: string, currency = getUserPreferredCurrency()) => { const formatted = formatNumber(value); - if (currency.alignment === 'left') return `${currency.symbol}${formatted}`; + + if (currency.alignment === 'left') { + if (formatted.startsWith(lessThanPrefix)) { + return formatted.replace(lessThanPrefix, `${lessThanPrefix}${currency.symbol}`); + } + return `${currency.symbol}${formatted}`; + } + return `${formatted} ${currency.symbol}`; }; diff --git a/src/__swaps__/screens/Swap/hooks/useAssetsToSell.ts b/src/__swaps__/screens/Swap/hooks/useAssetsToSell.ts index 0cf1da08691..87ee1239ffe 100644 --- a/src/__swaps__/screens/Swap/hooks/useAssetsToSell.ts +++ b/src/__swaps__/screens/Swap/hooks/useAssetsToSell.ts @@ -1,5 +1,5 @@ import { useMemo } from 'react'; -import { Hex } from 'viem'; +import { Address } from 'viem'; import { selectUserAssetsList, @@ -25,13 +25,13 @@ export const useAssetsToSell = () => { const { accountAddress: currentAddress, nativeCurrency: currentCurrency } = useAccountSettings(); const filter = userAssetsStore(state => state.filter); - const searchQuery = userAssetsStore(state => state.searchQuery); + const searchQuery = userAssetsStore(state => state.inputSearchQuery); const debouncedAssetToSellFilter = useDebounce(searchQuery, 200); const { data: userAssets = [] } = useUserAssets( { - address: currentAddress as Hex, + address: currentAddress as Address, currency: currentCurrency, }, { diff --git a/src/__swaps__/screens/Swap/hooks/useEstimatedGasFee.ts b/src/__swaps__/screens/Swap/hooks/useEstimatedGasFee.ts index b48ab8696ad..14bda640b15 100644 --- a/src/__swaps__/screens/Swap/hooks/useEstimatedGasFee.ts +++ b/src/__swaps__/screens/Swap/hooks/useEstimatedGasFee.ts @@ -1,14 +1,16 @@ import { ChainId } from '@/__swaps__/types/chains'; import { weiToGwei } from '@/__swaps__/utils/ethereum'; import { add, multiply } from '@/__swaps__/utils/numbers'; -import { useSwapsStore } from '@/state/swaps/swapsStore'; import ethereumUtils, { useNativeAssetForNetwork } from '@/utils/ethereumUtils'; -import { ETH_ADDRESS } from '@rainbow-me/swaps'; -import { useEffect, useMemo } from 'react'; -import { formatUnits, zeroAddress } from 'viem'; +import { useMemo, useState } from 'react'; +import { formatUnits } from 'viem'; import { formatCurrency, formatNumber } from './formatNumber'; import { GasSettings } from './useCustomGas'; import { useSwapEstimatedGasLimit } from './useSwapEstimatedGasLimit'; +import { runOnJS, useAnimatedReaction } from 'react-native-reanimated'; +import { useDebouncedCallback } from 'use-debounce'; +import { useSwapContext } from '../providers/swap-provider'; +import { greaterThanWorklet, toScaledIntegerWorklet } from '@/__swaps__/safe-math/SafeMath'; function safeBigInt(value: string) { try { @@ -47,35 +49,52 @@ export function useEstimatedGasFee({ }, [gasLimit, gasSettings, nativeNetworkAsset]); } -const eth = ETH_ADDRESS.toLowerCase(); -const isEth = (address: string) => [eth, zeroAddress, 'eth'].includes(address.toLowerCase()); -const isSameAddress = (a: string, b: string) => { - if (isEth(a) && isEth(b)) return true; - return a.toLowerCase() === b.toLowerCase(); -}; export function useSwapEstimatedGasFee(gasSettings: GasSettings | undefined) { - const chainId = useSwapsStore(s => s.inputAsset?.chainId || ChainId.mainnet); + const { internalSelectedInputAsset: assetToSell, internalSelectedOutputAsset: assetToBuy, quote } = useSwapContext(); - const assetToSell = useSwapsStore(s => s.inputAsset); - const assetToBuy = useSwapsStore(s => s.outputAsset); - const quote = useSwapsStore(s => s.quote); + const [state, setState] = useState({ + assetToBuy: assetToBuy.value, + assetToSell: assetToSell.value, + chainId: assetToSell.value?.chainId ?? ChainId.mainnet, + quote: quote.value, + }); + + const debouncedStateSet = useDebouncedCallback(setState, 100, { leading: false, trailing: true }); + + // Updates the state as a single block in response to quote changes to ensure the gas fee is cleanly updated once + useAnimatedReaction( + () => quote.value, + (current, previous) => { + if (!assetToSell.value || !assetToBuy.value || !current || !previous || 'error' in current) return; + + const isSwappingMoreThanAvailableBalance = greaterThanWorklet( + current.sellAmount.toString(), + toScaledIntegerWorklet(assetToSell.value.balance.amount, assetToSell.value.decimals) + ); + + // Skip gas fee recalculation if the user is trying to swap more than their available balance, as it isn't + // needed and was previously resulting in errors in useEstimatedGasFee. + if (isSwappingMoreThanAvailableBalance) return; + + if (current !== previous) { + runOnJS(debouncedStateSet)({ + assetToBuy: assetToBuy.value, + assetToSell: assetToSell.value, + chainId: assetToSell.value?.chainId ?? ChainId.mainnet, + quote: current, + }); + } + } + ); const { data: gasLimit, isFetching } = useSwapEstimatedGasLimit( - { chainId, quote, assetToSell }, + { chainId: state.chainId, quote: state.quote, assetToSell: state.assetToSell }, { - enabled: - !!quote && - !!assetToSell && - !!assetToBuy && - !('error' in quote) && - // the quote and the input/output assets are not updated together, - // we shouldn't try to estimate if the assets are not the same as the quote (probably still fetching a quote) - isSameAddress(quote.sellTokenAddress, assetToSell.address) && - isSameAddress(quote.buyTokenAddress, assetToBuy.address), + enabled: !!state.quote && !!state.assetToSell && !!state.assetToBuy && !('error' in quote), } ); - const estimatedFee = useEstimatedGasFee({ chainId, gasLimit, gasSettings }); + const estimatedFee = useEstimatedGasFee({ chainId: state.chainId, gasLimit, gasSettings }); return useMemo(() => ({ isLoading: isFetching, data: estimatedFee }), [estimatedFee, isFetching]); } diff --git a/src/__swaps__/screens/Swap/hooks/useSearchCurrencyLists.ts b/src/__swaps__/screens/Swap/hooks/useSearchCurrencyLists.ts index b3585b9f8c3..09ed07bc200 100644 --- a/src/__swaps__/screens/Swap/hooks/useSearchCurrencyLists.ts +++ b/src/__swaps__/screens/Swap/hooks/useSearchCurrencyLists.ts @@ -1,19 +1,18 @@ import { rankings } from 'match-sorter'; import { useCallback, useMemo, useState } from 'react'; import { runOnJS, useAnimatedReaction } from 'react-native-reanimated'; - -import { useTokenSearch } from '@/__swaps__/screens/Swap/resources/search'; +import { TokenSearchResult, useTokenSearch } from '@/__swaps__/screens/Swap/resources/search'; import { ChainId } from '@/__swaps__/types/chains'; import { SearchAsset, TokenSearchAssetKey, TokenSearchThreshold } from '@/__swaps__/types/search'; import { addHexPrefix } from '@/__swaps__/utils/hex'; import { isLowerCaseMatch } from '@/__swaps__/utils/strings'; import { filterList } from '@/utils'; - import { useFavorites } from '@/resources/favorites'; import { isAddress } from '@ethersproject/address'; import { useSwapContext } from '../providers/swap-provider'; -import { userAssetsStore } from '@/state/assets/userAssets'; import { filterNonTokenIconAssets } from '../resources/_selectors/search'; +import { useDebouncedCallback } from 'use-debounce'; +import { useSwapsStore } from '@/state/swaps/swapsStore'; export type AssetToBuySectionId = 'bridge' | 'favorites' | 'verified' | 'unverified' | 'other_networks'; @@ -22,7 +21,7 @@ export interface AssetToBuySection { id: AssetToBuySectionId; } -const filterBridgeAsset = ({ asset, filter = '' }: { asset?: SearchAsset; filter?: string }) => +const filterBridgeAsset = ({ asset, filter = '' }: { asset: SearchAsset | null | undefined; filter: string }) => asset?.address?.toLowerCase()?.startsWith(filter?.toLowerCase()) || asset?.name?.toLowerCase()?.startsWith(filter?.toLowerCase()) || asset?.symbol?.toLowerCase()?.startsWith(filter?.toLowerCase()); @@ -30,139 +29,145 @@ const filterBridgeAsset = ({ asset, filter = '' }: { asset?: SearchAsset; filter export function useSearchCurrencyLists() { const { internalSelectedInputAsset: assetToSell, selectedOutputChainId } = useSwapContext(); - const searchQuery = userAssetsStore(state => state.searchQuery); + const query = useSwapsStore(state => state.outputSearchQuery.trim().toLowerCase()); - const [inputChainId, setInputChainId] = useState(assetToSell.value?.chainId ?? ChainId.mainnet); - const [toChainId, setToChainId] = useState(selectedOutputChainId.value); - const [assetToSellAddress, setAssetToSellAddress] = useState( - assetToSell.value?.[assetToSell.value?.chainId === ChainId.mainnet ? 'address' : 'mainnetAddress'] - ); + const [state, setState] = useState({ + assetToSellAddress: assetToSell.value?.[assetToSell.value?.chainId === ChainId.mainnet ? 'address' : 'mainnetAddress'], + fromChainId: assetToSell.value?.chainId ?? undefined, + isCrosschainSearch: assetToSell.value ? assetToSell.value.chainId !== selectedOutputChainId.value : false, + toChainId: selectedOutputChainId.value ?? ChainId.mainnet, + }); - const query = useMemo(() => searchQuery.trim().toLowerCase(), [searchQuery]); - const enableUnverifiedSearch = useMemo(() => searchQuery.length > 2, [searchQuery]); + // Delays the state set by a frame or two to give animated UI that responds to selectedOutputChainId.value + // a moment to update before the heavy re-renders kicked off by these state changes occur. + const debouncedStateSet = useDebouncedCallback(setState, 20, { leading: false, trailing: true }); useAnimatedReaction( - () => assetToSell.value, + () => ({ + isCrosschainSearch: assetToSell.value ? assetToSell.value.chainId !== selectedOutputChainId.value : false, + toChainId: selectedOutputChainId.value ?? ChainId.mainnet, + }), (current, previous) => { - if (previous !== current) { - runOnJS(setInputChainId)(current?.chainId ?? ChainId.mainnet); - runOnJS(setAssetToSellAddress)(current?.[current?.chainId === ChainId.mainnet ? 'address' : 'mainnetAddress']); + if (previous && (current.isCrosschainSearch !== previous.isCrosschainSearch || current.toChainId !== previous.toChainId)) { + runOnJS(debouncedStateSet)({ + assetToSellAddress: assetToSell.value?.[assetToSell.value?.chainId === ChainId.mainnet ? 'address' : 'mainnetAddress'], + fromChainId: assetToSell.value?.chainId ?? undefined, + isCrosschainSearch: current.isCrosschainSearch, + toChainId: current.toChainId, + }); } } ); - useAnimatedReaction( - () => selectedOutputChainId.value, - (current, previous) => { - if (previous !== current) { - runOnJS(setToChainId)(current); - } - } + const sliceTopResultsByNetworkWithCoinIconUrls = useCallback( + (data: TokenSearchResult) => { + const matchingNetwork = data.filter(asset => asset.chainId === state.toChainId); + return matchingNetwork.slice(0, 20).filter(asset => asset.icon_url); + }, + [state.toChainId] ); - const isCrosschainSearch = useMemo(() => { - return inputChainId && inputChainId !== toChainId; - }, [inputChainId, toChainId]); - - // provided during swap to filter token search by available routes - const fromChainId = useMemo(() => { - return isCrosschainSearch ? inputChainId : undefined; - }, [inputChainId, isCrosschainSearch]); - - const queryIsAddress = useMemo(() => isAddress(query), [query]); - const keys: TokenSearchAssetKey[] = useMemo(() => (queryIsAddress ? ['address'] : ['name', 'symbol']), [queryIsAddress]); - const threshold: TokenSearchThreshold = useMemo(() => (queryIsAddress ? 'CASE_SENSITIVE_EQUAL' : 'CONTAINS'), [queryIsAddress]); - // static search data const { data: verifiedAssets, isLoading: verifiedAssetsLoading } = useTokenSearch( { list: 'verifiedAssets' }, - { select: filterNonTokenIconAssets, staleTime: 60 * 60 * 1000, cacheTime: 24 * 60 * 60 * 1000 } + { + select: query.length > 0 ? filterNonTokenIconAssets : sliceTopResultsByNetworkWithCoinIconUrls, + staleTime: 60 * 60 * 1000, + cacheTime: 24 * 60 * 60 * 10000, + } ); - // current search + const memoizedData = useMemo(() => { + const fromChainId = state.isCrosschainSearch ? state.fromChainId : undefined; + const queryIsAddress = isAddress(query); + const keys: TokenSearchAssetKey[] = queryIsAddress ? ['address'] : ['name', 'symbol']; + const threshold: TokenSearchThreshold = queryIsAddress ? 'CASE_SENSITIVE_EQUAL' : 'CONTAINS'; + const enableUnverifiedSearch = query.length > 2; + + const bridgeAsset = state.isCrosschainSearch + ? verifiedAssets?.find(asset => isLowerCaseMatch(asset.mainnetAddress, state.assetToSellAddress)) + : null; + const filteredBridgeAsset = bridgeAsset && filterBridgeAsset({ asset: bridgeAsset, filter: query }) ? bridgeAsset : null; + + return { + isCrosschainSearch: state.isCrosschainSearch, + fromChainId, + queryIsAddress, + keys, + threshold, + enableUnverifiedSearch, + verifiedAssetsForChain: verifiedAssets, + filteredBridgeAsset, + }; + }, [state.assetToSellAddress, state.fromChainId, state.isCrosschainSearch, query, verifiedAssets]); + + const { favoritesMetadata: favorites } = useFavorites(); + + const unfilteredFavorites = useMemo(() => { + return Object.values(favorites) + .filter(token => token.networks[state.toChainId]) + .map(favToken => ({ + ...favToken, + chainId: state.toChainId, + mainnetAddress: favToken.mainnet_address, + })) as SearchAsset[]; + }, [favorites, state.toChainId]); + + const favoritesList = useMemo(() => { + if (query === '') { + return unfilteredFavorites; + } else { + return filterList( + unfilteredFavorites || [], + memoizedData.queryIsAddress ? addHexPrefix(query).toLowerCase() : query, + memoizedData.keys, + { + threshold: memoizedData.queryIsAddress ? rankings.CASE_SENSITIVE_EQUAL : rankings.CONTAINS, + } + ); + } + }, [memoizedData.keys, memoizedData.queryIsAddress, query, unfilteredFavorites]); + const { data: targetVerifiedAssets, isLoading: targetVerifiedAssetsLoading } = useTokenSearch({ - chainId: toChainId, - keys, + chainId: state.toChainId, + keys: memoizedData.keys, list: 'verifiedAssets', - threshold, + threshold: memoizedData.threshold, query, - fromChainId, + fromChainId: memoizedData.fromChainId, }); + const { data: targetUnverifiedAssets, isLoading: targetUnverifiedAssetsLoading } = useTokenSearch( { - chainId: toChainId, - keys, + chainId: state.toChainId, + keys: memoizedData.keys, list: 'highLiquidityAssets', - threshold, + threshold: memoizedData.threshold, query, - fromChainId, + fromChainId: memoizedData.fromChainId, }, { - enabled: enableUnverifiedSearch, + enabled: query.length > 0 && memoizedData.enableUnverifiedSearch, } ); - const { favoritesMetadata: favorites } = useFavorites(); - - const favoritesList = useMemo(() => { - const unfilteredFavorites = Object.values(favorites) - .filter(token => token.networks[toChainId]) - .map(favToken => { - return { - ...favToken, - chainId: toChainId, - address: favToken.address, - mainnetAddress: favToken.mainnet_address, - }; - }) as SearchAsset[]; - - if (query === '') { - return unfilteredFavorites; - } else { - const formattedQuery = queryIsAddress ? addHexPrefix(query).toLowerCase() : query; - return filterList(unfilteredFavorites || [], formattedQuery, keys, { - threshold: queryIsAddress ? rankings.CASE_SENSITIVE_EQUAL : rankings.CONTAINS, - }); - } - }, [favorites, keys, toChainId, query, queryIsAddress]); - - const verifiedAssetsForChain = useMemo( + const crosschainExactMatches = useMemo( () => - verifiedAssets - ?.filter(asset => asset.chainId === toChainId) - // temporarily limiting the number of assets to display - // for performance after deprecating `isRainbowCurated` - .slice(0, 50), - [verifiedAssets, toChainId] + verifiedAssets?.filter(t => { + const symbolMatch = isLowerCaseMatch(t?.symbol, query); + const nameMatch = isLowerCaseMatch(t?.name, query); + return symbolMatch || nameMatch; + }), + [query, verifiedAssets] ); - const bridgeAsset = useMemo(() => { - const bridgeAsset = verifiedAssetsForChain?.find(asset => isLowerCaseMatch(asset.mainnetAddress, assetToSellAddress)); - const filteredBridgeAsset = filterBridgeAsset({ - asset: bridgeAsset, - filter: query, - }) - ? bridgeAsset - : null; - return toChainId === inputChainId ? null : filteredBridgeAsset; - }, [verifiedAssetsForChain, toChainId, query, inputChainId, assetToSellAddress]); - - const loading = useMemo(() => { - return query === '' ? verifiedAssetsLoading : targetVerifiedAssetsLoading || targetUnverifiedAssetsLoading; - }, [query, verifiedAssetsLoading, targetVerifiedAssetsLoading, targetUnverifiedAssetsLoading]); - - const crosschainExactMatches = verifiedAssets?.filter(t => { - const symbolMatch = isLowerCaseMatch(t?.symbol, query); - const nameMatch = isLowerCaseMatch(t?.name, query); - return symbolMatch || nameMatch; - }); - const filterAssetsFromBridgeAndAssetToSell = useCallback( (assets?: SearchAsset[]) => assets?.filter( curatedAsset => - !isLowerCaseMatch(curatedAsset?.address, bridgeAsset?.address) && !isLowerCaseMatch(curatedAsset?.address, assetToSellAddress) + !isLowerCaseMatch(curatedAsset?.address, memoizedData.filteredBridgeAsset?.address) && + !isLowerCaseMatch(curatedAsset?.address, state.assetToSellAddress) ) || [], - [assetToSellAddress, bridgeAsset?.address] + [memoizedData.filteredBridgeAsset?.address, state.assetToSellAddress] ); const filterAssetsFromFavoritesBridgeAndAssetToSell = useCallback( @@ -173,15 +178,34 @@ export function useSearchCurrencyLists() { [favoritesList, filterAssetsFromBridgeAndAssetToSell] ); - // the lists below should be filtered by favorite/bridge asset match + const combinedData = useMemo( + () => ({ + bridgeAsset: memoizedData.filteredBridgeAsset, + verifiedAssets: query === '' ? memoizedData.verifiedAssetsForChain : targetVerifiedAssets, + unverifiedAssets: memoizedData.enableUnverifiedSearch ? targetUnverifiedAssets : undefined, + crosschainExactMatches: query !== '' && !targetVerifiedAssets?.length ? crosschainExactMatches : undefined, + }), + [ + crosschainExactMatches, + memoizedData.enableUnverifiedSearch, + memoizedData.filteredBridgeAsset, + memoizedData.verifiedAssetsForChain, + query, + targetUnverifiedAssets, + targetVerifiedAssets, + ] + ); + const results = useMemo(() => { const sections: AssetToBuySection[] = []; - if (bridgeAsset) { + + if (combinedData.bridgeAsset) { sections.push({ - data: [bridgeAsset], + data: [combinedData.bridgeAsset], id: 'bridge', }); } + if (favoritesList?.length) { sections.push({ data: filterAssetsFromBridgeAndAssetToSell(favoritesList), @@ -189,50 +213,45 @@ export function useSearchCurrencyLists() { }); } - if (query === '') { + if (combinedData.verifiedAssets?.length) { sections.push({ - data: filterAssetsFromFavoritesBridgeAndAssetToSell(verifiedAssetsForChain), + data: filterAssetsFromFavoritesBridgeAndAssetToSell(combinedData.verifiedAssets), id: 'verified', }); - } else { - if (targetVerifiedAssets?.length) { - sections.push({ - data: filterAssetsFromFavoritesBridgeAndAssetToSell(targetVerifiedAssets), - id: 'verified', - }); - } + } - if (targetUnverifiedAssets?.length && enableUnverifiedSearch) { - sections.push({ - data: filterAssetsFromFavoritesBridgeAndAssetToSell(targetUnverifiedAssets), - id: 'unverified', - }); - } + if (combinedData.unverifiedAssets?.length) { + sections.push({ + data: filterAssetsFromFavoritesBridgeAndAssetToSell(combinedData.unverifiedAssets), + id: 'unverified', + }); + } - if (!sections.length && crosschainExactMatches?.length) { - sections.push({ - data: filterAssetsFromFavoritesBridgeAndAssetToSell(crosschainExactMatches), - id: 'other_networks', - }); - } + if (!sections.length && combinedData.crosschainExactMatches?.length) { + sections.push({ + data: filterAssetsFromFavoritesBridgeAndAssetToSell(combinedData.crosschainExactMatches), + id: 'other_networks', + }); } return sections; }, [ - bridgeAsset, - crosschainExactMatches, - enableUnverifiedSearch, + combinedData.bridgeAsset, + combinedData.crosschainExactMatches, + combinedData.unverifiedAssets, + combinedData.verifiedAssets, favoritesList, filterAssetsFromBridgeAndAssetToSell, filterAssetsFromFavoritesBridgeAndAssetToSell, - query, - targetUnverifiedAssets, - targetVerifiedAssets, - verifiedAssetsForChain, ]); - return { - loading, - results, - }; + return useMemo(() => { + const isLoading = + verifiedAssetsLoading || targetVerifiedAssetsLoading || (memoizedData.enableUnverifiedSearch && targetUnverifiedAssetsLoading); + + return { + loading: isLoading, + results, + }; + }, [memoizedData.enableUnverifiedSearch, results, targetUnverifiedAssetsLoading, targetVerifiedAssetsLoading, verifiedAssetsLoading]); } diff --git a/src/__swaps__/screens/Swap/hooks/useSwapInputsController.ts b/src/__swaps__/screens/Swap/hooks/useSwapInputsController.ts index df5d5cdfdae..754712da8fe 100644 --- a/src/__swaps__/screens/Swap/hooks/useSwapInputsController.ts +++ b/src/__swaps__/screens/Swap/hooks/useSwapInputsController.ts @@ -1,10 +1,8 @@ -import { useCallback, useRef } from 'react'; +import { useCallback } from 'react'; import { SharedValue, runOnJS, runOnUI, useAnimatedReaction, useDerivedValue, useSharedValue, withSpring } from 'react-native-reanimated'; import { useDebouncedCallback } from 'use-debounce'; - import { SCRUBBER_WIDTH, SLIDER_WIDTH, snappySpringConfig } from '@/__swaps__/screens/Swap/constants'; -import { SWAP_FEE } from '@/__swaps__/screens/Swap/dummyValues'; -import { inputKeys, inputMethods } from '@/__swaps__/types/swap'; +import { RequestNewQuoteParams, inputKeys, inputMethods, inputValuesType } from '@/__swaps__/types/swap'; import { addCommasToNumber, buildQuoteParams, @@ -31,33 +29,61 @@ import { } from '@/resources/assets/externalAssetsQuery'; import { ethereumUtils } from '@/utils'; import { queryClient } from '@/react-query'; +import { userAssetsStore } from '@/state/assets/userAssets'; + +function getInitialInputValues() { + const initialSelectedInputAsset = userAssetsStore.getState().getHighestValueAsset(); + const initialBalance = Number(initialSelectedInputAsset?.balance.amount) ?? 0; + const initialNiceIncrement = findNiceIncrement(initialBalance); + const initialDecimalPlaces = countDecimalPlaces(initialNiceIncrement); + + const initialInputAmount = niceIncrementFormatter({ + incrementDecimalPlaces: initialDecimalPlaces, + inputAssetBalance: initialBalance, + inputAssetUsdPrice: initialSelectedInputAsset?.price?.value ?? 0, + niceIncrement: initialNiceIncrement, + percentageToSwap: 0.5, + sliderXPosition: SLIDER_WIDTH / 2, + stripSeparators: true, + }); + const initialInputNativeValue = addCommasToNumber( + (Number(initialInputAmount) * (initialSelectedInputAsset?.price?.value ?? 0)).toFixed(2) + ); + + return { + initialInputAmount, + initialInputNativeValue, + }; +} export function useSwapInputsController({ focusedInput, - lastTypedInput, inputProgress, - outputProgress, internalSelectedInputAsset, internalSelectedOutputAsset, isFetching, isQuoteStale, - sliderXPosition, + lastTypedInput, + outputProgress, quote, + sliderXPosition, }: { focusedInput: SharedValue; - lastTypedInput: SharedValue; inputProgress: SharedValue; - outputProgress: SharedValue; internalSelectedInputAsset: SharedValue; internalSelectedOutputAsset: SharedValue; isFetching: SharedValue; isQuoteStale: SharedValue; - sliderXPosition: SharedValue; + lastTypedInput: SharedValue; + outputProgress: SharedValue; quote: SharedValue; + sliderXPosition: SharedValue; }) { - const inputValues = useSharedValue<{ [key in inputKeys]: number | string }>({ - inputAmount: 0, - inputNativeValue: 0, + const { initialInputAmount, initialInputNativeValue } = getInitialInputValues(); + + const inputValues = useSharedValue({ + inputAmount: initialInputAmount, + inputNativeValue: initialInputNativeValue, outputAmount: 0, outputNativeValue: 0, }); @@ -73,21 +99,28 @@ export function useSwapInputsController({ }); const incrementDecimalPlaces = useDerivedValue(() => countDecimalPlaces(niceIncrement.value)); + const inputNativePrice = useDerivedValue(() => { + return internalSelectedInputAsset.value?.nativePrice || internalSelectedInputAsset.value?.price?.value || 0; + }); + const outputNativePrice = useDerivedValue(() => { + return internalSelectedOutputAsset.value?.nativePrice || internalSelectedOutputAsset.value?.price?.value || 0; + }); + const formattedInputAmount = useDerivedValue(() => { - if (!internalSelectedInputAsset.value || !internalSelectedInputAsset.value.nativePrice) return '0'; + if (!internalSelectedInputAsset.value) return '0'; if ((inputMethod.value === 'slider' && percentageToSwap.value === 0) || !inputValues.value.inputAmount) { return '0'; } if (inputMethod.value === 'inputAmount' || typeof inputValues.value.inputAmount === 'string') { - return addCommasToNumber(inputValues.value.inputAmount); + return addCommasToNumber(inputValues.value.inputAmount, '0'); } if (inputMethod.value === 'outputAmount') { return valueBasedDecimalFormatter({ amount: inputValues.value.inputAmount, - usdTokenPrice: internalSelectedInputAsset.value.nativePrice, + usdTokenPrice: inputNativePrice.value, roundingMode: 'up', precisionAdjustment: -1, isStablecoin: internalSelectedInputAsset.value?.type === 'stablecoin' ?? false, @@ -95,12 +128,12 @@ export function useSwapInputsController({ }); } - const balance = Number(internalSelectedInputAsset.value?.balance.amount || 0); + const balance = Number(internalSelectedInputAsset.value?.balance.amount ?? 0); return niceIncrementFormatter({ incrementDecimalPlaces: incrementDecimalPlaces.value, inputAssetBalance: balance, - inputAssetUsdPrice: internalSelectedInputAsset.value.nativePrice, + inputAssetUsdPrice: inputNativePrice.value, niceIncrement: niceIncrement.value, percentageToSwap: percentageToSwap.value, sliderXPosition: sliderXPosition.value, @@ -122,19 +155,19 @@ export function useSwapInputsController({ }); const formattedOutputAmount = useDerivedValue(() => { - if (!internalSelectedOutputAsset.value || !internalSelectedOutputAsset.value.nativePrice) return '0'; + if (!internalSelectedOutputAsset.value) return '0'; if ((inputMethod.value === 'slider' && percentageToSwap.value === 0) || !inputValues.value.outputAmount) { return '0'; } if (inputMethod.value === 'outputAmount' || typeof inputValues.value.outputAmount === 'string') { - return addCommasToNumber(inputValues.value.outputAmount); + return addCommasToNumber(inputValues.value.outputAmount, '0'); } return valueBasedDecimalFormatter({ amount: inputValues.value.outputAmount, - usdTokenPrice: internalSelectedOutputAsset.value.nativePrice, + usdTokenPrice: outputNativePrice.value, roundingMode: 'down', precisionAdjustment: -1, isStablecoin: internalSelectedOutputAsset.value?.type === 'stablecoin' ?? false, @@ -156,147 +189,221 @@ export function useSwapInputsController({ return nativeValue || '$0.00'; }); - const animationFrameId = useRef(null); + const updateNativePriceForAsset = useCallback( + ({ price, type }: { price: number; type: string }) => { + 'worklet'; - const resetTimers = useCallback(() => { - if (animationFrameId.current !== null) cancelAnimationFrame(animationFrameId.current); - }, []); + if (type === 'inputAsset') { + internalSelectedInputAsset.modify(prev => ({ ...prev, nativePrice: price })); + } else if (type === 'outputAsset') { + internalSelectedOutputAsset.modify(prev => ({ ...prev, nativePrice: price })); + } + }, + [internalSelectedInputAsset, internalSelectedOutputAsset] + ); const updateQuoteStore = useCallback((data: Quote | CrosschainQuote | QuoteError | null) => { swapsStore.setState({ quote: data }); }, []); + const resetFetchingStatus = useCallback( + ({ + fromError = false, + quoteFetchingInterval, + }: { + fromError: boolean; + quoteFetchingInterval: ReturnType; + }) => { + 'worklet'; + + isFetching.value = false; + isQuoteStale.value = 0; + + // This ensures that after a quote has been applied, if neither token list is expanded, we resume quote fetching interval timer + if (inputProgress.value <= NavigationSteps.INPUT_ELEMENT_FOCUSED && outputProgress.value <= NavigationSteps.INPUT_ELEMENT_FOCUSED) { + quoteFetchingInterval.restart(); + } + + if (!fromError) { + return; + } + + // NOTE: if we encounter a quote error, let's make sure to update the outputAmount and inputAmount to 0 accordingly + if (lastTypedInput.value === 'inputAmount') { + inputValues.modify(prev => { + return { + ...prev, + outputAmount: 0, + outputNativeValue: 0, + }; + }); + } else if (lastTypedInput.value === 'outputAmount') { + inputValues.modify(prev => { + return { + ...prev, + inputAmount: 0, + inputNativeValue: 0, + }; + }); + } + }, + [inputProgress, inputValues, isFetching, isQuoteStale, lastTypedInput, outputProgress] + ); + const setQuote = useCallback( ({ data, - outputAmount, inputAmount, + inputPrice, + originalQuoteParams, + outputAmount, + outputPrice, + quoteFetchingInterval, }: { data: Quote | CrosschainQuote | QuoteError | null; - outputAmount?: number; - inputAmount?: number; + inputAmount: number | undefined; + inputPrice: number | undefined | null; + originalQuoteParams: RequestNewQuoteParams; + outputAmount: number | undefined; + outputPrice: number | undefined | null; + quoteFetchingInterval: ReturnType; }) => { 'worklet'; - // NOTE: Handle updating sliderXPosition based on inputAmount - if (typeof inputAmount !== 'undefined') { - if (inputAmount === 0) { - sliderXPosition.value = withSpring(0, snappySpringConfig); - } else { - const inputBalance = Number(internalSelectedInputAsset.value?.balance.amount || '0'); - const updatedSliderPosition = clamp((inputAmount / inputBalance) * SLIDER_WIDTH, 0, SLIDER_WIDTH); - if (Number.isNaN(updatedSliderPosition)) { - sliderXPosition.value = withSpring(0, snappySpringConfig); - } else { - sliderXPosition.value = withSpring(updatedSliderPosition, snappySpringConfig); - } + // Check whether the quote has been superseded by new user input so we don't introduce conflicting updates + const isLastTypedInputStillValid = originalQuoteParams.lastTypedInput === lastTypedInput.value; + + // Check whether the selected assets are still the same + const isInputUniqueIdStillValid = originalQuoteParams.assetToBuyUniqueId === internalSelectedOutputAsset.value?.uniqueId; + const isOutputUniqueIdStillValid = originalQuoteParams.assetToSellUniqueId === internalSelectedInputAsset.value?.uniqueId; + const areSelectedAssetsStillValid = isInputUniqueIdStillValid && isOutputUniqueIdStillValid; + + // Check whether the input and output amounts are still the same + const isInputAmountStillValid = originalQuoteParams.inputAmount === inputValues.value.inputAmount; + const isOutputAmountStillValid = originalQuoteParams.outputAmount === inputValues.value.outputAmount; + const areInputAmountsStillValid = + originalQuoteParams.lastTypedInput === 'inputAmount' ? isInputAmountStillValid : isOutputAmountStillValid; + + // Set prices first regardless of the quote status, as long as the same assets are still selected + if (inputPrice && isInputUniqueIdStillValid) { + updateNativePriceForAsset({ price: inputPrice, type: 'inputAsset' }); + } + if (outputPrice && isOutputUniqueIdStillValid) { + updateNativePriceForAsset({ price: outputPrice, type: 'outputAsset' }); + } + + const hasQuoteBeenSuperseded = !(isLastTypedInputStillValid && areSelectedAssetsStillValid && areInputAmountsStillValid); + + if (hasQuoteBeenSuperseded) { + // If the quote has been superseded, isQuoteStale and isFetching should already be correctly set in response + // to the newer input, as long as the inputs aren't empty, so we handle the empty inputs case and then return, + // discarding the result of the superseded quote. + const areInputsEmpty = Number(inputValues.value.inputAmount) === 0 && Number(inputValues.value.outputAmount) === 0; + + if (areInputsEmpty) { + isFetching.value = false; + isQuoteStale.value = 0; } + return; } - isFetching.value = false; quote.value = data; - runOnJS(updateQuoteStore)(data); if (!data || (data as QuoteError)?.error) { + resetFetchingStatus({ fromError: true, quoteFetchingInterval }); return; } - if (inputAmount) { - const price = internalSelectedInputAsset.value?.nativePrice || internalSelectedInputAsset.value?.price?.value || 0; + if (inputAmount !== undefined) { inputValues.modify(prev => { return { ...prev, inputAmount, - inputNativeValue: inputAmount * price, + inputNativeValue: inputAmount * (inputPrice || inputNativePrice.value), }; }); } - if (outputAmount) { - const price = internalSelectedOutputAsset.value?.nativePrice || internalSelectedOutputAsset.value?.price?.value || 0; + if (outputAmount !== undefined) { inputValues.modify(prev => { return { ...prev, outputAmount, - outputNativeValue: outputAmount * price, + outputNativeValue: outputAmount * (outputPrice || outputNativePrice.value), }; }); } + + // Handle updating the slider position if the quote was output based + if (originalQuoteParams.lastTypedInput === 'outputAmount' || originalQuoteParams.lastTypedInput === 'outputNativeValue') { + if (!inputAmount || inputAmount === 0) { + sliderXPosition.value = withSpring(0, snappySpringConfig); + } else { + const inputBalance = Number(internalSelectedInputAsset.value?.balance.amount || '0'); + const updatedSliderPosition = inputBalance > 0 ? clamp((inputAmount / inputBalance) * SLIDER_WIDTH, 0, SLIDER_WIDTH) : 0; + sliderXPosition.value = withSpring(updatedSliderPosition, snappySpringConfig); + } + } + + resetFetchingStatus({ fromError: false, quoteFetchingInterval }); + + runOnJS(updateQuoteStore)(data); }, [ + inputNativePrice, inputValues, - internalSelectedInputAsset.value?.balance.amount, - internalSelectedInputAsset.value?.nativePrice, - internalSelectedInputAsset.value?.price?.value, - internalSelectedOutputAsset.value?.nativePrice, - internalSelectedOutputAsset.value?.price?.value, + internalSelectedInputAsset, + internalSelectedOutputAsset, isFetching, + isQuoteStale, + lastTypedInput, + outputNativePrice, quote, + resetFetchingStatus, sliderXPosition, + updateNativePriceForAsset, updateQuoteStore, ] ); - const updateNativePriceForAsset = useCallback( - ({ price, type }: { price: number; type: string }) => { - 'worklet'; - - if (type === 'inputAsset') { - internalSelectedInputAsset.modify(prev => ({ ...prev, nativePrice: price })); - } else if (type === 'outputAsset') { - internalSelectedOutputAsset.modify(prev => ({ ...prev, nativePrice: price })); - } - }, - [internalSelectedInputAsset, internalSelectedOutputAsset] - ); - - const getAssetNativePrice = useCallback( - async ({ asset, type }: { asset: ExtendedAnimatedAssetWithColors | null; type: string }) => { - if (!asset) return; + const getAssetNativePrice = useCallback(async ({ asset }: { asset: ExtendedAnimatedAssetWithColors | null }) => { + if (!asset) return null; - const address = asset.address; - const network = ethereumUtils.getNetworkFromChainId(asset.chainId); - const currency = store.getState().settings.nativeCurrency; - - try { - const tokenData = await fetchExternalToken({ - address, - network, - currency, - }); + const address = asset.address; + const network = ethereumUtils.getNetworkFromChainId(asset.chainId); + const currency = store.getState().settings.nativeCurrency; - if (tokenData?.price.value) { - queryClient.setQueryData(externalTokenQueryKey({ address, network, currency }), tokenData); - runOnUI(updateNativePriceForAsset)({ - price: tokenData.price.value, - type, - }); - } - } catch (error) { - logger.error(new RainbowError('[useSwapInputsController]: get asset prices failed')); - - const now = Date.now(); - const state = queryClient.getQueryState(externalTokenQueryKey({ address, network, currency })); - const price = state?.data?.price.value; - if (price) { - const updatedAt = state.dataUpdatedAt; - // NOTE: if the data is older than 60 seconds, we need to invalidate it and not use it - if (now - updatedAt > EXTERNAL_TOKEN_STALE_TIME) { - queryClient.invalidateQueries(externalTokenQueryKey({ address, network, currency })); - return; - } + try { + const tokenData = await fetchExternalToken({ + address, + network, + currency, + }); - runOnUI(updateNativePriceForAsset)({ - price, - type, - }); + if (tokenData?.price.value) { + queryClient.setQueryData(externalTokenQueryKey({ address, network, currency }), tokenData); + return tokenData.price.value; + } + } catch (error) { + logger.error(new RainbowError('[useSwapInputsController]: get asset prices failed')); + + const now = Date.now(); + const state = queryClient.getQueryState(externalTokenQueryKey({ address, network, currency })); + const price = state?.data?.price.value; + if (price) { + const updatedAt = state.dataUpdatedAt; + // NOTE: if the data is older than 60 seconds, we need to invalidate it and not use it + if (now - updatedAt > EXTERNAL_TOKEN_STALE_TIME) { + queryClient.invalidateQueries(externalTokenQueryKey({ address, network, currency })); + return null; } + return price; } - }, - [updateNativePriceForAsset] - ); + } + return null; + }, []); - const fetchAndUpdatePrices = useCallback( + const fetchAssetPrices = useCallback( async ({ inputAsset, outputAsset, @@ -315,62 +422,26 @@ export function useSwapInputsController({ type: 'outputAsset', }, ].map(getAssetNativePrice) - ); + ).then(([inputPrice, outputPrice]) => ({ inputPrice, outputPrice })); }, [getAssetNativePrice] ); - const fetchAndUpdateQuote = async ({ - inputAmount, - outputAmount, - lastTypedInput, - }: { - inputAmount: string | number; - outputAmount: string | number; - lastTypedInput: inputKeys; - }) => { - const resetFetchingStatus = (fromError = false) => { - 'worklet'; - isQuoteStale.value = 0; - isFetching.value = false; + const fetchAndUpdateQuote = async ({ inputAmount, lastTypedInput: lastTypedInputParam, outputAmount }: RequestNewQuoteParams) => { + const originalInputAssetUniqueId = internalSelectedInputAsset.value?.uniqueId; + const originalOutputAssetUniqueId = internalSelectedOutputAsset.value?.uniqueId; - // NOTE: start the quote fetching interval if the token lists aren't open - // we need this check here because the user can open and close the token list before the quote is fetched and updated - if (inputProgress.value <= NavigationSteps.INPUT_ELEMENT_FOCUSED && outputProgress.value <= NavigationSteps.INPUT_ELEMENT_FOCUSED) { - quoteFetchingInterval.start(); - } - - if (!fromError) { - return; - } - - // NOTE: if we encounter a quote error, let's make sure to update the outputAmount and inputAmount to 0 accordingly - if (lastTypedInput === 'inputAmount') { - inputValues.modify(prev => { - return { - ...prev, - outputAmount: 0, - outputNativeValue: 0, - }; - }); - } else if (lastTypedInput === 'outputAmount') { - inputValues.modify(prev => { - return { - ...prev, - inputAmount: 0, - inputNativeValue: 0, - }; - }); - } - }; + const isSwappingMaxBalance = internalSelectedInputAsset.value && inputMethod.value === 'slider' && percentageToSwap.value >= 1; + const maxAdjustedInputAmount = + (isSwappingMaxBalance && internalSelectedInputAsset.value?.balance.amount) || inputValues.value.inputAmount; const params = buildQuoteParams({ currentAddress: store.getState().settings.accountAddress, - inputAmount, - outputAmount, + inputAmount: maxAdjustedInputAmount, inputAsset: internalSelectedInputAsset.value, + lastTypedInput: lastTypedInputParam, + outputAmount, outputAsset: internalSelectedOutputAsset.value, - lastTypedInput, }); logger.debug(`[useSwapInputsController]: quote params`, { @@ -378,148 +449,164 @@ export function useSwapInputsController({ }); if (!params) { - runOnUI(resetFetchingStatus)(true); + runOnUI(resetFetchingStatus)({ fromError: true, quoteFetchingInterval }); return; } - const response = (params.swapType === SwapType.crossChain ? await getCrosschainQuote(params) : await getQuote(params)) as - | Quote - | CrosschainQuote - | QuoteError; - - logger.debug(`[useSwapInputsController]: quote response`, { - data: response, - }); - - // TODO: Handle native asset inputs - runOnUI(setQuote)({ - data: response, - outputAmount: - lastTypedInput === 'inputAmount' + try { + const [quoteResponse, fetchedPrices] = await Promise.all([ + params.swapType === SwapType.crossChain ? getCrosschainQuote(params) : getQuote(params), + fetchAssetPrices({ + inputAsset: internalSelectedInputAsset.value, + outputAsset: internalSelectedOutputAsset.value, + }), + ]); + + const quotedInputAmount = + lastTypedInputParam === 'outputAmount' ? Number( convertRawAmountToDecimalFormat( - (response as Quote)?.buyAmountMinusFees?.toString(), - internalSelectedOutputAsset.value?.decimals || 18 + (quoteResponse as Quote)?.sellAmount?.toString(), + internalSelectedInputAsset.value?.decimals || 18 ) ) - : undefined, - inputAmount: - lastTypedInput === 'outputAmount' + : undefined; + + const quotedOutputAmount = + lastTypedInputParam === 'inputAmount' ? Number( - convertRawAmountToDecimalFormat((response as Quote)?.sellAmount?.toString(), internalSelectedInputAsset.value?.decimals || 18) + convertRawAmountToDecimalFormat( + (quoteResponse as Quote)?.buyAmountMinusFees?.toString(), + internalSelectedOutputAsset.value?.decimals || 18 + ) ) - : undefined, - }); - runOnUI(resetFetchingStatus)((response as QuoteError)?.error); + : undefined; + + runOnUI(() => { + setQuote({ + data: quoteResponse, + inputAmount: quotedInputAmount, + inputPrice: fetchedPrices.inputPrice, + originalQuoteParams: { + assetToBuyUniqueId: originalOutputAssetUniqueId, + assetToSellUniqueId: originalInputAssetUniqueId, + inputAmount: inputAmount, + lastTypedInput: lastTypedInputParam, + outputAmount: outputAmount, + }, + outputAmount: quotedOutputAmount, + outputPrice: fetchedPrices.outputPrice, + quoteFetchingInterval, + }); + })(); + } catch (error) { + runOnUI(resetFetchingStatus)({ fromError: true, quoteFetchingInterval }); + } }; const fetchQuoteAndAssetPrices = () => { 'worklet'; - // reset the quote data immediately, so we don't use stale data - setQuote({ data: null }); const isSomeInputGreaterThanZero = Number(inputValues.value.inputAmount) > 0 || Number(inputValues.value.outputAmount) > 0; // If both inputs are 0 or the assets aren't set, return early if (!internalSelectedInputAsset.value || !internalSelectedOutputAsset.value || !isSomeInputGreaterThanZero) { + if (isQuoteStale.value !== 0) isQuoteStale.value = 0; + if (isFetching.value) isFetching.value = false; return; } + isFetching.value = true; + if (isQuoteStale.value !== 1) isQuoteStale.value = 1; - runOnJS(fetchAndUpdatePrices)({ - inputAsset: internalSelectedInputAsset.value, - outputAsset: internalSelectedOutputAsset.value, - }); runOnJS(fetchAndUpdateQuote)({ + assetToBuyUniqueId: internalSelectedOutputAsset.value?.uniqueId, + assetToSellUniqueId: internalSelectedInputAsset.value?.uniqueId, inputAmount: inputValues.value.inputAmount, - outputAmount: inputValues.value.outputAmount, lastTypedInput: lastTypedInput.value, + outputAmount: inputValues.value.outputAmount, }); }; const quoteFetchingInterval = useAnimatedInterval({ - intervalMs: 10_000, + intervalMs: 12_000, onIntervalWorklet: fetchQuoteAndAssetPrices, autoStart: false, }); - const onChangedPercentage = useDebouncedCallback((percentage: number, setStale = true) => { - resetTimers(); - lastTypedInput.value = 'inputAmount'; - - if (percentage > 0) { - if (setStale) isQuoteStale.value = 1; - runOnUI(fetchQuoteAndAssetPrices)(); - } else { - isFetching.value = false; - isQuoteStale.value = 0; - } + const onChangedPercentage = useDebouncedCallback( + (percentage: number) => { + lastTypedInput.value = 'inputAmount'; - return () => { - resetTimers(); - }; - }, 200); - - const onTypedNumber = useDebouncedCallback(async (amount: number, inputKey: inputKeys, preserveAmount = true, setStale = true) => { - resetTimers(); - lastTypedInput.value = inputKey; - - if (amount > 0) { - if (setStale) isQuoteStale.value = 1; - const updateWorklet = () => { - 'worklet'; - // if the user types in the inputAmount let's optimistically update the slider position - if (inputKey === 'inputAmount') { - const inputAssetBalance = Number(internalSelectedInputAsset.value?.balance.amount || '0'); - const updatedSliderPosition = clamp((amount / inputAssetBalance) * SLIDER_WIDTH, 0, SLIDER_WIDTH); - - // Update slider position - sliderXPosition.value = withSpring(updatedSliderPosition, snappySpringConfig); - } - - fetchQuoteAndAssetPrices(); - }; + if (percentage > 0) { + runOnUI(fetchQuoteAndAssetPrices)(); + } else { + if (isFetching.value) isFetching.value = false; + if (isQuoteStale.value !== 0) isQuoteStale.value = 0; + } + }, + 200, + { leading: false, trailing: true } + ); - runOnUI(updateWorklet)(); - } else { - const resetValuesToZero = () => { - isFetching.value = false; + const onTypedNumber = useDebouncedCallback( + (amount: number, inputKey: inputKeys, preserveAmount = true) => { + lastTypedInput.value = inputKey; + if (amount > 0) { const updateWorklet = () => { 'worklet'; - const keysToReset = ['inputAmount', 'inputNativeValue', 'outputAmount', 'outputNativeValue']; - const updatedValues = keysToReset.reduce( - (acc, key) => { - const castedKey = key as keyof typeof inputValues.value; - acc[castedKey] = castedKey === inputKey && preserveAmount ? inputValues.value[castedKey] : 0; - return acc; - }, - {} as Partial - ); - inputValues.modify(values => { - return { - ...values, - ...updatedValues, - }; - }); - sliderXPosition.value = withSpring(0, snappySpringConfig); - isQuoteStale.value = 0; - setQuote({ data: null }); - quoteFetchingInterval.stop(); + // If the user enters a new inputAmount, update the slider position ahead of the quote fetch, because + // we can derive the slider position directly from the entered amount. + if (inputKey === 'inputAmount') { + const inputAssetBalance = Number(internalSelectedInputAsset.value?.balance.amount || '0'); + const updatedSliderPosition = clamp((amount / inputAssetBalance) * SLIDER_WIDTH, 0, SLIDER_WIDTH); + sliderXPosition.value = withSpring(updatedSliderPosition, snappySpringConfig); + } + fetchQuoteAndAssetPrices(); }; runOnUI(updateWorklet)(); - }; + } else { + const resetValuesToZero = () => { + if (isFetching.value) isFetching.value = false; + if (isQuoteStale.value !== 0) isQuoteStale.value = 0; + + const updateWorklet = () => { + 'worklet'; + const keysToReset = ['inputAmount', 'inputNativeValue', 'outputAmount', 'outputNativeValue']; + const updatedValues = keysToReset.reduce( + (acc, key) => { + const castedKey = key as keyof typeof inputValues.value; + acc[castedKey] = castedKey === inputKey && preserveAmount ? inputValues.value[castedKey] : 0; + return acc; + }, + {} as Partial + ); + inputValues.modify(values => { + return { + ...values, + ...updatedValues, + }; + }); + sliderXPosition.value = withSpring(0, snappySpringConfig); + isQuoteStale.value = 0; + quoteFetchingInterval.stop(); + }; - animationFrameId.current = requestAnimationFrame(resetValuesToZero); - } + runOnUI(updateWorklet)(); + }; - return () => { - resetTimers(); - }; - }, 400); + resetValuesToZero(); + } + }, + 300, + { leading: false, trailing: true } + ); - // This handles cleaning up typed amounts when the input focus changes + /** + * Observes the user-focused input and cleans up typed amounts when the input focus changes + */ useAnimatedReaction( () => ({ focusedInput: focusedInput.value }), (current, previous) => { @@ -544,94 +631,32 @@ export function useSwapInputsController({ } ); - // This handles the updating of input values based on the input method + /** + * Observes value changes in the active inputMethod, which can be any of the following: + * - inputAmount + * - inputNativeValue (TODO) + * - outputAmount + * - outputNativeValue (TODO) + * - sliderXPosition + * + * And then updates the remaining input methods based on the entered values. + */ useAnimatedReaction( () => ({ sliderXPosition: sliderXPosition.value, values: inputValues.value, - assetToSell: internalSelectedInputAsset.value, - assetToBuy: internalSelectedOutputAsset.value, }), (current, previous) => { - if ( - (!previous?.assetToSell?.uniqueId && current.assetToSell?.uniqueId) || - (current.assetToSell && current.assetToSell.uniqueId !== previous?.assetToSell?.uniqueId) - ) { - const balance = Number(current.assetToSell.balance.amount); - if (!balance || !internalSelectedInputAsset.value?.nativePrice) { - inputValues.modify(values => { - return { - ...values, - inputAmount: 0, - inputNativeValue: 0, - outputAmount: 0, - outputNativeValue: 0, - }; - }); - return; - } - - // If the change set the slider position to > 0 - const inputAmount = niceIncrementFormatter({ - incrementDecimalPlaces: incrementDecimalPlaces.value, - inputAssetBalance: balance, - inputAssetUsdPrice: internalSelectedInputAsset.value?.nativePrice, - niceIncrement: niceIncrement.value, - percentageToSwap: percentageToSwap.value, - sliderXPosition: sliderXPosition.value, - stripSeparators: true, - }); - const inputNativeValue = Number(inputAmount) * internalSelectedInputAsset.value?.nativePrice; - inputValues.modify(values => { - return { - ...values, - inputAmount, - inputNativeValue, - }; - }); - } - - /** - * The current !== previous check is causing troubles because we're using .modify to add the - * nativePrice to the inputAsset and outputAsset. This is triggering this useAnimatedReaction - * and in turn causing the - */ - - if (!previous) { - // Handle setting of initial values using niceIncrementFormatter, - // because we will likely set a percentage-based default input value - if (!current.assetToSell?.nativePrice || !current.assetToBuy?.nativePrice) return; - - const balance = Number(current.assetToSell.balance.amount); - const inputAmount = niceIncrementFormatter({ - incrementDecimalPlaces: incrementDecimalPlaces.value, - inputAssetBalance: balance, - inputAssetUsdPrice: current.assetToSell.nativePrice, - niceIncrement: niceIncrement.value, - percentageToSwap: percentageToSwap.value, - sliderXPosition: sliderXPosition.value, - stripSeparators: true, - }); - - const inputNativeValue = Number(inputAmount) * current.assetToSell.nativePrice; - const outputAmount = (inputNativeValue / current.assetToBuy.nativePrice) * (1 - SWAP_FEE); // TODO: Implement swap fee - const outputNativeValue = outputAmount * current.assetToBuy.nativePrice; - - inputValues.modify(values => { - return { - ...values, - inputAmount, - inputNativeValue, - outputAmount, - outputNativeValue, - }; - }); - } else if (current !== previous) { + if (previous && current !== previous) { // Handle updating input values based on the input method if (inputMethod.value === 'slider' && current.sliderXPosition !== previous.sliderXPosition) { // If the slider position changes if (percentageToSwap.value === 0) { // If the change set the slider position to 0 + quoteFetchingInterval.stop(); + isQuoteStale.value = 0; + isFetching.value = false; + inputValues.modify(values => { return { ...values, @@ -641,14 +666,12 @@ export function useSwapInputsController({ outputNativeValue: 0, }; }); - isQuoteStale.value = 0; - setQuote({ data: null }); - quoteFetchingInterval.stop(); } else { - if (!current.assetToSell) return; + // If the change set the slider position to > 0 + if (!internalSelectedInputAsset.value) return; - const balance = Number(current.assetToSell.balance.amount); - if (!balance || !current.assetToSell.nativePrice) { + const balance = Number(internalSelectedInputAsset.value.balance.amount); + if (!balance) { inputValues.modify(values => { return { ...values, @@ -661,17 +684,17 @@ export function useSwapInputsController({ return; } - // If the change set the slider position to > 0 const inputAmount = niceIncrementFormatter({ incrementDecimalPlaces: incrementDecimalPlaces.value, inputAssetBalance: balance, - inputAssetUsdPrice: current.assetToSell.nativePrice, + inputAssetUsdPrice: inputNativePrice.value, niceIncrement: niceIncrement.value, percentageToSwap: percentageToSwap.value, sliderXPosition: sliderXPosition.value, stripSeparators: true, }); - const inputNativeValue = Number(inputAmount) * current.assetToSell.nativePrice; + + const inputNativeValue = Number(inputAmount) * inputNativePrice.value; inputValues.modify(values => { return { ...values, @@ -679,14 +702,16 @@ export function useSwapInputsController({ inputNativeValue, }; }); - - isQuoteStale.value = 1; } } if (inputMethod.value === 'inputAmount' && Number(current.values.inputAmount) !== Number(previous.values.inputAmount)) { // If the number in the input field changes if (Number(current.values.inputAmount) === 0) { // If the input amount was set to 0 + quoteFetchingInterval.stop(); + isQuoteStale.value = 0; + isFetching.value = false; + const hasDecimal = current.values.inputAmount.toString().includes('.'); sliderXPosition.value = withSpring(0, snappySpringConfig); @@ -705,9 +730,12 @@ export function useSwapInputsController({ runOnJS(onTypedNumber)(0, 'inputAmount'); } } else { - if (!current.assetToSell || !current.assetToSell?.nativePrice) return; // If the input amount was set to a non-zero value - const inputNativeValue = Number(current.values.inputAmount) * current.assetToSell.nativePrice; + if (!internalSelectedInputAsset.value) return; + + if (isQuoteStale.value !== 1) isQuoteStale.value = 1; + const inputNativeValue = Number(current.values.inputAmount) * inputNativePrice.value; + inputValues.modify(values => { return { ...values, @@ -715,6 +743,11 @@ export function useSwapInputsController({ }; }); + const inputAssetBalance = Number(internalSelectedInputAsset.value?.balance.amount || '0'); + const updatedSliderPosition = clamp((Number(current.values.inputAmount) / inputAssetBalance) * SLIDER_WIDTH, 0, SLIDER_WIDTH); + + sliderXPosition.value = withSpring(updatedSliderPosition, snappySpringConfig); + runOnJS(onTypedNumber)(Number(current.values.inputAmount), 'inputAmount', true); } } @@ -722,6 +755,10 @@ export function useSwapInputsController({ // If the number in the output field changes if (Number(current.values.outputAmount) === 0) { // If the output amount was set to 0 + quoteFetchingInterval.stop(); + isQuoteStale.value = 0; + isFetching.value = false; + const hasDecimal = current.values.outputAmount.toString().includes('.'); sliderXPosition.value = withSpring(0, snappySpringConfig); @@ -735,9 +772,6 @@ export function useSwapInputsController({ }; }); - isQuoteStale.value = 0; - setQuote({ data: null }); - if (hasDecimal) { runOnJS(onTypedNumber)(0, 'outputAmount', true); } else { @@ -745,10 +779,10 @@ export function useSwapInputsController({ } } else if (Number(current.values.outputAmount) > 0) { // If the output amount was set to a non-zero value - if (!current.assetToBuy?.nativePrice) return; + if (isQuoteStale.value !== 1) isQuoteStale.value = 1; const outputAmount = Number(current.values.outputAmount); - const outputNativeValue = outputAmount * current.assetToBuy.nativePrice; + const outputNativeValue = outputAmount * outputNativePrice.value; inputValues.modify(values => { return { @@ -761,12 +795,106 @@ export function useSwapInputsController({ } } } + } + ); - if ( - current.assetToSell?.uniqueId !== previous?.assetToSell?.uniqueId || - current.assetToBuy?.uniqueId !== previous?.assetToBuy?.uniqueId - ) { - fetchQuoteAndAssetPrices(); + /** + * This observes changes in the selected assets and initiates new quote fetches when necessary. It also + * handles flipping the inputValues when the assets are flipped. + */ + useAnimatedReaction( + () => ({ + assetToBuy: internalSelectedOutputAsset.value, + assetToSell: internalSelectedInputAsset.value, + }), + (current, previous) => { + const didInputAssetChange = current.assetToSell?.uniqueId !== previous?.assetToSell?.uniqueId; + const didOutputAssetChange = current.assetToBuy?.uniqueId !== previous?.assetToBuy?.uniqueId; + + if (didInputAssetChange || didOutputAssetChange) { + const balance = Number(current.assetToSell?.balance?.amount); + + const areBothAssetsSet = current.assetToSell && current.assetToBuy; + const didFlipAssets = + didInputAssetChange && + didOutputAssetChange && + areBothAssetsSet && + previous && + current.assetToSell?.uniqueId === previous.assetToBuy?.uniqueId; + + if (!didFlipAssets) { + // If either asset was changed but the assets were not flipped + if (!balance) { + isQuoteStale.value = 0; + isFetching.value = false; + inputValues.modify(values => { + return { + ...values, + inputAmount: 0, + inputNativeValue: 0, + outputAmount: 0, + outputNativeValue: 0, + }; + }); + return; + } + + const inputAmount = niceIncrementFormatter({ + incrementDecimalPlaces: incrementDecimalPlaces.value, + inputAssetBalance: balance, + inputAssetUsdPrice: inputNativePrice.value, + niceIncrement: niceIncrement.value, + percentageToSwap: percentageToSwap.value, + sliderXPosition: sliderXPosition.value, + stripSeparators: true, + }); + + const inputNativeValue = Number(inputAmount) * inputNativePrice.value; + inputValues.modify(values => { + return { + ...values, + inputAmount, + inputNativeValue, + }; + }); + } else { + // If the assets were flipped + inputMethod.value = 'inputAmount'; + + const inputNativePrice = internalSelectedInputAsset.value?.nativePrice || internalSelectedInputAsset.value?.price?.value || 0; + const outputNativePrice = internalSelectedOutputAsset.value?.nativePrice || internalSelectedOutputAsset.value?.price?.value || 0; + + const inputAmount = Number( + valueBasedDecimalFormatter({ + amount: + inputNativePrice > 0 + ? Number(inputValues.value.inputNativeValue) / inputNativePrice + : Number(inputValues.value.outputAmount), + usdTokenPrice: inputNativePrice, + roundingMode: 'up', + precisionAdjustment: -1, + isStablecoin: current.assetToSell?.type === 'stablecoin' ?? false, + stripSeparators: true, + }) + ); + + inputValues.modify(values => { + return { + ...values, + inputAmount, + inputNativeValue: Number(inputValues.value.inputNativeValue), + outputAmount: + outputNativePrice > 0 + ? Number(inputValues.value.outputNativeValue) / outputNativePrice + : Number(inputValues.value.inputAmount), + outputNativeValue: Number(inputValues.value.outputNativeValue), + }; + }); + } + + if (current.assetToSell && current.assetToBuy) { + fetchQuoteAndAssetPrices(); + } } } ); @@ -776,8 +904,10 @@ export function useSwapInputsController({ formattedInputNativeValue, formattedOutputAmount, formattedOutputNativeValue, + incrementDecimalPlaces, inputMethod, inputValues, + niceIncrement, onChangedPercentage, percentageToSwap, quoteFetchingInterval, diff --git a/src/__swaps__/screens/Swap/hooks/useSwapNavigation.ts b/src/__swaps__/screens/Swap/hooks/useSwapNavigation.ts index 135f2c0987d..11b1741cc86 100644 --- a/src/__swaps__/screens/Swap/hooks/useSwapNavigation.ts +++ b/src/__swaps__/screens/Swap/hooks/useSwapNavigation.ts @@ -1,7 +1,8 @@ import { useCallback } from 'react'; import { SharedValue, runOnJS, useSharedValue } from 'react-native-reanimated'; import { onCloseGasPanel } from '../components/GasPanel'; -import { useSwapInputsController } from './useSwapInputsController'; +import { ExtendedAnimatedAssetWithColors } from '@/__swaps__/types/assets'; +import { useAnimatedInterval } from '@/hooks/reanimated/useAnimatedInterval'; export const enum NavigationSteps { INPUT_ELEMENT_FOCUSED = 0, @@ -12,17 +13,21 @@ export const enum NavigationSteps { } export function useSwapNavigation({ - SwapInputController, + executeSwap, inputProgress, outputProgress, configProgress, - executeSwap, + quoteFetchingInterval, + selectedInputAsset, + selectedOutputAsset, }: { - SwapInputController: ReturnType; + executeSwap: () => void; inputProgress: SharedValue; outputProgress: SharedValue; configProgress: SharedValue; - executeSwap: () => void; + quoteFetchingInterval: ReturnType; + selectedInputAsset: SharedValue; + selectedOutputAsset: SharedValue; }) { const navigateBackToReview = useSharedValue(false); @@ -62,9 +67,8 @@ export function useSwapNavigation({ const handleDismissGas = useCallback(() => { 'worklet'; - runOnJS(onCloseGasPanel)(); - if (configProgress.value === NavigationSteps.SHOW_GAS) { + runOnJS(onCloseGasPanel)(); configProgress.value = NavigationSteps.INPUT_ELEMENT_FOCUSED; } }, [configProgress]); @@ -73,21 +77,43 @@ export function useSwapNavigation({ 'worklet'; handleDismissReview(); handleDismissGas(); - SwapInputController.fetchQuoteAndAssetPrices(); - if (inputProgress.value === NavigationSteps.TOKEN_LIST_FOCUSED) { + const isInputAssetNull = selectedInputAsset.value === null; + const isOutputAssetNull = selectedOutputAsset.value === null; + const areBothAssetsSelected = !isInputAssetNull && !isOutputAssetNull; + + if (inputProgress.value === NavigationSteps.TOKEN_LIST_FOCUSED && !isInputAssetNull) { inputProgress.value = NavigationSteps.INPUT_ELEMENT_FOCUSED; - } - if (outputProgress.value === NavigationSteps.TOKEN_LIST_FOCUSED) { - outputProgress.value = NavigationSteps.INPUT_ELEMENT_FOCUSED; - } - if (inputProgress.value === NavigationSteps.SEARCH_FOCUSED) { + if (areBothAssetsSelected) { + quoteFetchingInterval.start(); + } else { + outputProgress.value = NavigationSteps.TOKEN_LIST_FOCUSED; + return; + } + } else if (inputProgress.value === NavigationSteps.SEARCH_FOCUSED) { inputProgress.value = NavigationSteps.TOKEN_LIST_FOCUSED; } - if (outputProgress.value === NavigationSteps.SEARCH_FOCUSED) { + + if (outputProgress.value === NavigationSteps.TOKEN_LIST_FOCUSED && !isOutputAssetNull) { + outputProgress.value = NavigationSteps.INPUT_ELEMENT_FOCUSED; + if (areBothAssetsSelected) { + quoteFetchingInterval.start(); + } else { + inputProgress.value = NavigationSteps.TOKEN_LIST_FOCUSED; + return; + } + } else if (outputProgress.value === NavigationSteps.SEARCH_FOCUSED) { outputProgress.value = NavigationSteps.TOKEN_LIST_FOCUSED; } - }, [SwapInputController, handleDismissGas, handleDismissReview, inputProgress, outputProgress]); + }, [ + handleDismissGas, + handleDismissReview, + inputProgress, + outputProgress, + quoteFetchingInterval, + selectedInputAsset, + selectedOutputAsset, + ]); const handleFocusInputSearch = useCallback(() => { 'worklet'; @@ -113,22 +139,21 @@ export function useSwapNavigation({ 'worklet'; handleDismissReview(); handleDismissGas(); - SwapInputController.quoteFetchingInterval.stop(); + quoteFetchingInterval.pause(); if (inputProgress.value === NavigationSteps.INPUT_ELEMENT_FOCUSED) { - console.log('showing token list'); inputProgress.value = NavigationSteps.TOKEN_LIST_FOCUSED; outputProgress.value = NavigationSteps.INPUT_ELEMENT_FOCUSED; } else { inputProgress.value = NavigationSteps.INPUT_ELEMENT_FOCUSED; } - }, [handleDismissReview, handleDismissGas, inputProgress, outputProgress, SwapInputController]); + }, [handleDismissReview, handleDismissGas, inputProgress, outputProgress, quoteFetchingInterval]); const handleOutputPress = useCallback(() => { 'worklet'; handleDismissReview(); handleDismissGas(); - SwapInputController.quoteFetchingInterval.stop(); + quoteFetchingInterval.pause(); if (outputProgress.value === NavigationSteps.INPUT_ELEMENT_FOCUSED) { outputProgress.value = NavigationSteps.TOKEN_LIST_FOCUSED; @@ -136,7 +161,7 @@ export function useSwapNavigation({ } else { outputProgress.value = NavigationSteps.INPUT_ELEMENT_FOCUSED; } - }, [SwapInputController, handleDismissReview, handleDismissGas, inputProgress, outputProgress]); + }, [handleDismissReview, handleDismissGas, inputProgress, outputProgress, quoteFetchingInterval]); const handleSwapAction = useCallback(() => { 'worklet'; @@ -154,7 +179,7 @@ export function useSwapNavigation({ } else { handleShowReview(); } - }, [configProgress.value, executeSwap, handleDismissGas, handleShowReview, navigateBackToReview]); + }, [configProgress, executeSwap, handleDismissGas, handleShowReview, navigateBackToReview]); return { navigateBackToReview, diff --git a/src/__swaps__/screens/Swap/hooks/useSwapTextStyles.ts b/src/__swaps__/screens/Swap/hooks/useSwapTextStyles.ts index 4cffda6fdca..77f2ea45d80 100644 --- a/src/__swaps__/screens/Swap/hooks/useSwapTextStyles.ts +++ b/src/__swaps__/screens/Swap/hooks/useSwapTextStyles.ts @@ -18,12 +18,11 @@ import { SLIDER_HEIGHT, caretConfig, pulsingConfig, - sliderConfig, - slowFadeConfig, } from '@/__swaps__/screens/Swap/constants'; -import { inputKeys, inputMethods } from '@/__swaps__/types/swap'; +import { inputKeys, inputMethods, inputValuesType } from '@/__swaps__/types/swap'; import { getColorValueForThemeWorklet, opacity } from '@/__swaps__/utils/swaps'; import { ExtendedAnimatedAssetWithColors } from '@/__swaps__/types/assets'; +import { SPRING_CONFIGS, TIMING_CONFIGS } from '@/components/animations/animationConfigs'; export function useSwapTextStyles({ inputMethod, @@ -38,7 +37,7 @@ export function useSwapTextStyles({ sliderPressProgress, }: { inputMethod: SharedValue; - inputValues: SharedValue<{ [key in inputKeys]: number | string }>; + inputValues: SharedValue; internalSelectedInputAsset: SharedValue; internalSelectedOutputAsset: SharedValue; isFetching: SharedValue; @@ -72,7 +71,7 @@ export function useSwapTextStyles({ const pulsingOpacity = useDerivedValue(() => { return isQuoteStale.value === 1 ? withRepeat(withSequence(withTiming(0.5, pulsingConfig), withTiming(1, pulsingConfig)), -1, true) - : withSpring(1, sliderConfig); + : withSpring(1, SPRING_CONFIGS.sliderConfig); }); const isInputZero = useDerivedValue(() => { @@ -106,7 +105,9 @@ export function useSwapTextStyles({ ? ETH_COLOR_DARK_ACCENT : inputAssetColor.value; const opacity = - isInputStale.value !== 1 || (isInputZero.value && isOutputZero.value) ? withSpring(1, sliderConfig) : pulsingOpacity.value; + isInputStale.value !== 1 || (isInputZero.value && isOutputZero.value) + ? withSpring(1, SPRING_CONFIGS.sliderConfig) + : pulsingOpacity.value; return { color: interpolateColor(isInputStale.value, [0, 1], [zeroOrAssetColor, zeroAmountColor]), @@ -119,10 +120,12 @@ export function useSwapTextStyles({ const inputNativeValueStyle = useAnimatedStyle(() => { const zeroOrColor = isInputZero.value ? zeroAmountColor : labelTertiary; const opacity = - isInputStale.value !== 1 || (isInputZero.value && isOutputZero.value) ? withSpring(1, sliderConfig) : pulsingOpacity.value; + isInputStale.value !== 1 || (isInputZero.value && isOutputZero.value) + ? withSpring(1, SPRING_CONFIGS.sliderConfig) + : pulsingOpacity.value; return { - color: withTiming(interpolateColor(isInputStale.value, [0, 1], [zeroOrColor, zeroAmountColor]), slowFadeConfig), + color: withTiming(interpolateColor(isInputStale.value, [0, 1], [zeroOrColor, zeroAmountColor]), TIMING_CONFIGS.slowFadeConfig), opacity, }; }); @@ -135,7 +138,9 @@ export function useSwapTextStyles({ ? ETH_COLOR_DARK_ACCENT : outputAssetColor.value; const opacity = - isOutputStale.value !== 1 || (isInputZero.value && isOutputZero.value) ? withSpring(1, sliderConfig) : pulsingOpacity.value; + isOutputStale.value !== 1 || (isInputZero.value && isOutputZero.value) + ? withSpring(1, SPRING_CONFIGS.sliderConfig) + : pulsingOpacity.value; return { color: interpolateColor(isOutputStale.value, [0, 1], [zeroOrAssetColor, zeroAmountColor]), @@ -148,10 +153,12 @@ export function useSwapTextStyles({ const outputNativeValueStyle = useAnimatedStyle(() => { const zeroOrColor = isOutputZero.value ? zeroAmountColor : labelTertiary; const opacity = - isOutputStale.value !== 1 || (isInputZero.value && isOutputZero.value) ? withSpring(1, sliderConfig) : pulsingOpacity.value; + isOutputStale.value !== 1 || (isInputZero.value && isOutputZero.value) + ? withSpring(1, SPRING_CONFIGS.sliderConfig) + : pulsingOpacity.value; return { - color: withTiming(interpolateColor(isOutputStale.value, [0, 1], [zeroOrColor, zeroAmountColor]), slowFadeConfig), + color: withTiming(interpolateColor(isOutputStale.value, [0, 1], [zeroOrColor, zeroAmountColor]), TIMING_CONFIGS.slowFadeConfig), opacity, }; }); diff --git a/src/__swaps__/screens/Swap/hooks/useSwapWarning.ts b/src/__swaps__/screens/Swap/hooks/useSwapWarning.ts index 5c62bad1f63..7090511d83b 100644 --- a/src/__swaps__/screens/Swap/hooks/useSwapWarning.ts +++ b/src/__swaps__/screens/Swap/hooks/useSwapWarning.ts @@ -1,15 +1,15 @@ import { useCallback, useMemo } from 'react'; -import { SharedValue, runOnJS, runOnUI, useAnimatedReaction, useSharedValue } from 'react-native-reanimated'; +import { SharedValue, useAnimatedReaction, useSharedValue } from 'react-native-reanimated'; import * as i18n from '@/languages'; import { useAccountSettings } from '@/hooks'; import { useForegroundColor } from '@/design-system'; import { CrosschainQuote, Quote, QuoteError } from '@rainbow-me/swaps'; - -import { convertAmountToNativeDisplay, divide, greaterThanOrEqualTo, subtract } from '@/__swaps__/utils/numbers'; -import { getCrossChainTimeEstimate, getQuoteServiceTime } from '@/__swaps__/utils/swaps'; +import { getCrossChainTimeEstimateWorklet, getQuoteServiceTimeWorklet } from '@/__swaps__/utils/swaps'; import { ExtendedAnimatedAssetWithColors } from '@/__swaps__/types/assets'; import { highPriceImpactThreshold, severePriceImpactThreshold } from '@/__swaps__/screens/Swap/constants'; -import { useSwapInputsController } from '@/__swaps__/screens/Swap/hooks/useSwapInputsController'; +import { divWorklet, greaterThanOrEqualToWorklet, subWorklet } from '@/__swaps__/safe-math/SafeMath'; +import { supportedNativeCurrencies } from '@/references'; +import { inputKeys, inputValuesType } from '@/__swaps__/types/swap'; export enum SwapWarningType { unknown = 'unknown', @@ -40,8 +40,8 @@ export interface SwapTimeEstimate { } type UsePriceImpactWarningProps = { - SwapInputController: ReturnType; inputAsset: SharedValue; + inputValues: SharedValue; outputAsset: SharedValue; quote: SharedValue; sliderXPosition: SharedValue; @@ -57,10 +57,29 @@ type CurrentProps = { sliderXPosition: number; }; +const I18N_WARNINGS = { + subtitles: { + [SwapWarningType.unknown]: i18n.t(i18n.l.exchange.price_impact.unknown_price.description), + [SwapWarningType.high]: i18n.t(i18n.l.exchange.price_impact.small_market_try_smaller_amount), + [SwapWarningType.long_wait]: i18n.t(i18n.l.exchange.price_impact.long_wait.description_prefix), + [SwapWarningType.severe]: i18n.t(i18n.l.exchange.price_impact.small_market_try_smaller_amount), + }, + titles: { + [SwapWarningType.unknown]: i18n.t(i18n.l.exchange.price_impact.unknown_price.title), + [SwapWarningType.high]: i18n.t(i18n.l.exchange.price_impact.you_are_losing_prefix), + [SwapWarningType.long_wait]: i18n.t(i18n.l.exchange.price_impact.long_wait.title), + [SwapWarningType.severe]: i18n.t(i18n.l.exchange.price_impact.you_are_losing_prefix), + [SwapWarningType.no_quote_available]: i18n.t(i18n.l.exchange.quote_errors.no_quote_available), + [SwapWarningType.insufficient_liquidity]: i18n.t(i18n.l.exchange.quote_errors.insufficient_liquidity), + [SwapWarningType.fee_on_transfer]: i18n.t(i18n.l.exchange.quote_errors.fee_on_transfer), + [SwapWarningType.no_route_found]: i18n.t(i18n.l.exchange.quote_errors.no_route_found), + }, +}; + export const useSwapWarning = ({ - SwapInputController, inputAsset, outputAsset, + inputValues, quote, isFetching, isQuoteStale, @@ -93,7 +112,7 @@ export const useSwapWarning = ({ [label, orange, red] ); - const updateWarning = useCallback( + const updateWarningWorklet = useCallback( (values: SwapWarning) => { 'worklet'; swapWarning.modify(prev => ({ ...prev, ...values })); @@ -101,94 +120,107 @@ export const useSwapWarning = ({ [swapWarning] ); - const getWarning = useCallback( + const getWarningWorklet = useCallback( ({ inputNativeValue, outputNativeValue, quote, isFetching }: CurrentProps) => { - const nativeAmountImpact = subtract(inputNativeValue, outputNativeValue); - const impactInPercentage = divide(nativeAmountImpact, inputNativeValue); - const priceImpactDisplay = convertAmountToNativeDisplay(nativeAmountImpact, currentCurrency); + 'worklet'; + + // ⚠️ TODO: Remove the Number(x).toString() conversions once the safe math functions support BigInt + const nativeAmountImpact = subWorklet(Number(inputNativeValue).toString(), Number(outputNativeValue).toString()); + const impactInPercentage = Number(inputNativeValue) === 0 ? '0' : divWorklet(nativeAmountImpact, Number(inputNativeValue).toString()); + + const nativeCurrency = supportedNativeCurrencies?.[currentCurrency]; + const { alignment: currencyAlignment, decimals: rawDecimals, symbol: currencySymbol } = nativeCurrency; + const decimals = Math.min(rawDecimals, 6); + + const nativeValue = Number(nativeAmountImpact).toLocaleString('en-US', { + useGrouping: true, + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }); + const priceImpactDisplay = `${currencyAlignment === 'left' ? currencySymbol : ''}${nativeValue}${currencyAlignment === 'right' ? currencySymbol : ''}`; + const isSomeInputGreaterThanZero = Number(inputValues.value.inputAmount) > 0 || Number(inputValues.value.outputAmount) > 0; if (!isFetching && (quote as QuoteError)?.error) { const quoteError = quote as QuoteError; - const errorType = (quoteError.error_code || SwapWarningType.no_quote_available).toString(); - const title = i18n.t((i18n.l.exchange.quote_errors as Record)[errorType]); - runOnUI(updateWarning)({ type: errorType as SwapWarningType, title, color: colorMap[errorType], icon: '􀇿', subtitle: '' }); - } else if (!isFetching && !!quote && !(quote as QuoteError)?.error && (!inputNativeValue || !outputNativeValue)) { - runOnUI(updateWarning)({ + const errorType: SwapWarningType = quoteError.error_code || SwapWarningType.no_quote_available; + const title = I18N_WARNINGS.titles[errorType]; + updateWarningWorklet({ type: errorType, title, color: colorMap[errorType], icon: '􀇿', subtitle: '' }); + } else if ( + isSomeInputGreaterThanZero && + !isFetching && + !!quote && + !(quote as QuoteError)?.error && + (!inputNativeValue || !outputNativeValue) + ) { + updateWarningWorklet({ type: SwapWarningType.unknown, icon: '􀇿', - title: i18n.t(i18n.l.exchange.price_impact.unknown_price.title), - subtitle: i18n.t(i18n.l.exchange.price_impact.unknown_price.description), + title: I18N_WARNINGS.titles[SwapWarningType.unknown], + subtitle: I18N_WARNINGS.subtitles[SwapWarningType.unknown], color: colorMap[SwapWarningType.unknown], }); - } else if (!isFetching && !!quote && greaterThanOrEqualTo(impactInPercentage, severePriceImpactThreshold)) { - runOnUI(updateWarning)({ + } else if (!isFetching && !!quote && greaterThanOrEqualToWorklet(impactInPercentage, severePriceImpactThreshold.toString())) { + updateWarningWorklet({ type: SwapWarningType.severe, icon: '􀇿', - title: i18n.t(i18n.l.exchange.price_impact.you_are_losing, { - priceImpact: priceImpactDisplay, - }), - subtitle: i18n.t(i18n.l.exchange.price_impact.small_market_try_smaller_amount), + title: `${I18N_WARNINGS.titles[SwapWarningType.severe]} ${priceImpactDisplay}`, + subtitle: I18N_WARNINGS.subtitles[SwapWarningType.severe], color: colorMap[SwapWarningType.severe], }); - } else if (!isFetching && !!quote && greaterThanOrEqualTo(impactInPercentage, highPriceImpactThreshold)) { - runOnUI(updateWarning)({ + } else if (!isFetching && !!quote && greaterThanOrEqualToWorklet(impactInPercentage, highPriceImpactThreshold.toString())) { + updateWarningWorklet({ type: SwapWarningType.high, icon: '􀇿', - title: i18n.t(i18n.l.exchange.price_impact.you_are_losing, { - priceImpact: priceImpactDisplay, - }), - subtitle: i18n.t(i18n.l.exchange.price_impact.small_market_try_smaller_amount), + title: `${I18N_WARNINGS.titles[SwapWarningType.high]} ${priceImpactDisplay}`, + subtitle: I18N_WARNINGS.subtitles[SwapWarningType.high], color: colorMap[SwapWarningType.high], }); } else if (!!quote && !(quote as QuoteError)?.error) { - const serviceTime = getQuoteServiceTime({ quote: quote as CrosschainQuote }); - const estimatedTimeOfArrival = serviceTime ? getCrossChainTimeEstimate({ serviceTime }) : null; - + const serviceTime = getQuoteServiceTimeWorklet({ quote: quote as CrosschainQuote }); + const estimatedTimeOfArrival = serviceTime ? getCrossChainTimeEstimateWorklet({ serviceTime }) : null; if (estimatedTimeOfArrival?.isLongWait) { - runOnUI(updateWarning)({ + updateWarningWorklet({ type: SwapWarningType.long_wait, icon: '􀇿', - title: i18n.t(i18n.l.exchange.price_impact.long_wait.title), - subtitle: i18n.t(i18n.l.exchange.price_impact.long_wait.description, { - time: estimatedTimeOfArrival.timeEstimateDisplay, - }), + title: I18N_WARNINGS.titles[SwapWarningType.long_wait], + subtitle: `${I18N_WARNINGS.subtitles[SwapWarningType.long_wait]} ${estimatedTimeOfArrival.timeEstimateDisplay}`, color: colorMap[SwapWarningType.long_wait], }); } else { - runOnUI(updateWarning)({ type: SwapWarningType.none, title: '', color: colorMap[SwapWarningType.none], icon: '', subtitle: '' }); + updateWarningWorklet({ type: SwapWarningType.none, title: '', color: colorMap[SwapWarningType.none], icon: '', subtitle: '' }); } } else { - runOnUI(updateWarning)({ type: SwapWarningType.none, title: '', color: colorMap[SwapWarningType.none], icon: '', subtitle: '' }); + updateWarningWorklet({ type: SwapWarningType.none, title: '', color: colorMap[SwapWarningType.none], icon: '', subtitle: '' }); } }, - [colorMap, currentCurrency, updateWarning] + [colorMap, currentCurrency, inputValues, updateWarningWorklet] ); - // TODO: How can we make this more efficient? useAnimatedReaction( () => ({ - inputAsset: inputAsset.value, - outputAsset: outputAsset.value, - inputNativeValue: SwapInputController.inputValues.value.inputNativeValue, - outputNativeValue: SwapInputController.inputValues.value.outputNativeValue, - quote: quote.value, isFetching: isFetching.value, isQuoteStale: isQuoteStale.value, + quote: quote.value, sliderXPosition: sliderXPosition.value, }), (current, previous) => { - if (!current.inputAsset || !current.outputAsset) { - return; - } + const doInputAndOutputAssetsExist = inputAsset.value && outputAsset.value; + if (!doInputAndOutputAssetsExist) return; - if ((previous?.sliderXPosition && previous?.sliderXPosition !== current.sliderXPosition) || current.isQuoteStale) { - updateWarning({ type: SwapWarningType.none, title: '', color: colorMap[SwapWarningType.none], icon: '', subtitle: '' }); - } else if ( - (current.quote && previous?.quote !== current.quote) || - previous?.inputNativeValue !== current.inputNativeValue || - previous?.outputNativeValue !== current.outputNativeValue + if ( + (swapWarning.value.type !== SwapWarningType.none && current.isQuoteStale) || + current.isFetching || + (previous?.sliderXPosition && previous?.sliderXPosition !== current.sliderXPosition) ) { - runOnJS(getWarning)(current); + updateWarningWorklet({ type: SwapWarningType.none, title: '', color: colorMap[SwapWarningType.none], icon: '', subtitle: '' }); + } else if (!current.isQuoteStale && !current.isFetching && previous?.sliderXPosition === current.sliderXPosition) { + getWarningWorklet({ + inputNativeValue: inputValues.value.inputNativeValue, + outputNativeValue: inputValues.value.outputNativeValue, + quote: current.quote, + isFetching: current.isFetching, + sliderXPosition: current.sliderXPosition, + }); } } ); diff --git a/src/__swaps__/screens/Swap/providers/swap-provider.tsx b/src/__swaps__/screens/Swap/providers/swap-provider.tsx index 453a36d543b..4516ac19988 100644 --- a/src/__swaps__/screens/Swap/providers/swap-provider.tsx +++ b/src/__swaps__/screens/Swap/providers/swap-provider.tsx @@ -1,5 +1,5 @@ // @refresh -import React, { createContext, useContext, ReactNode, useEffect } from 'react'; +import React, { ReactNode, createContext, useCallback, useContext, useEffect, useRef } from 'react'; import { StyleProp, TextStyle, TextInput, NativeModules } from 'react-native'; import { AnimatedRef, @@ -23,8 +23,7 @@ import { ExtendedAnimatedAssetWithColors, ParsedSearchAsset } from '@/__swaps__/ import { useSwapWarning } from '@/__swaps__/screens/Swap/hooks/useSwapWarning'; import { useSwapSettings } from '@/__swaps__/screens/Swap/hooks/useSwapSettings'; import { CrosschainQuote, Quote, QuoteError } from '@rainbow-me/swaps'; -import { swapsStore } from '@/state/swaps/swapsStore'; -import { isSameAsset } from '@/__swaps__/utils/assets'; +import { useSwapsStore } from '@/state/swaps/swapsStore'; import { parseAssetAndExtend } from '@/__swaps__/utils/swaps'; import { ChainId } from '@/__swaps__/types/chains'; import { RainbowError, logger } from '@/logger'; @@ -33,7 +32,7 @@ import { Navigation } from '@/navigation'; import { WrappedAlert as Alert } from '@/helpers/alert'; import Routes from '@/navigation/routesNames'; import { ethereumUtils } from '@/utils'; -import { getCachedProviderForNetwork, isHardHat } from '@/handlers/web3'; +import { getCachedProviderForNetwork, getFlashbotsProvider, isHardHat } from '@/handlers/web3'; import { loadWallet } from '@/model/wallet'; import { walletExecuteRap } from '@/raps/execute'; import { queryClient } from '@/react-query'; @@ -41,6 +40,8 @@ import { userAssetsQueryKey } from '@/resources/assets/UserAssetsQuery'; import { useAccountSettings } from '@/hooks'; import { getGasSettingsBySpeed, getSelectedGas } from '../hooks/useSelectedGas'; import { LegacyTransactionGasParamAmounts, TransactionGasParamAmounts } from '@/entities'; +import { getNetworkObj } from '@/networks'; +import { userAssetsStore } from '@/state/assets/userAssets'; const swapping = i18n.t(i18n.l.swap.actions.swapping); const tapToSwap = i18n.t(i18n.l.swap.actions.tap_to_swap); @@ -53,7 +54,9 @@ interface SwapContextType { isFetching: SharedValue; isSwapping: SharedValue; isQuoteStale: SharedValue; - searchInputRef: AnimatedRef; + + inputSearchRef: AnimatedRef; + outputSearchRef: AnimatedRef; // TODO: Combine navigation progress steps into a single shared value inputProgress: SharedValue; @@ -71,7 +74,7 @@ interface SwapContextType { internalSelectedInputAsset: SharedValue; internalSelectedOutputAsset: SharedValue; - setAsset: ({ type, asset }: { type: SwapAssetType; asset: ParsedSearchAsset }) => void; + setAsset: ({ type, asset }: { type: SwapAssetType; asset: ParsedSearchAsset | null }) => void; quote: SharedValue; executeSwap: () => void; @@ -98,13 +101,14 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { const { nativeCurrency } = useAccountSettings(); const isFetching = useSharedValue(false); + const isQuoteStale = useSharedValue(0); // TODO: Convert this to a boolean const isSwapping = useSharedValue(false); - const isQuoteStale = useSharedValue(0); - const searchInputRef = useAnimatedRef(); + const inputSearchRef = useAnimatedRef(); + const outputSearchRef = useAnimatedRef(); const inputProgress = useSharedValue(NavigationSteps.INPUT_ELEMENT_FOCUSED); - const outputProgress = useSharedValue(NavigationSteps.INPUT_ELEMENT_FOCUSED); + const outputProgress = useSharedValue(NavigationSteps.TOKEN_LIST_FOCUSED); const configProgress = useSharedValue(NavigationSteps.INPUT_ELEMENT_FOCUSED); const sliderXPosition = useSharedValue(SLIDER_WIDTH * INITIAL_SLIDER_POSITION); @@ -113,11 +117,12 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { const lastTypedInput = useSharedValue('inputAmount'); const focusedInput = useSharedValue('inputAmount'); - const selectedOutputChainId = useSharedValue(ChainId.mainnet); + const initialSelectedInputAsset = parseAssetAndExtend({ asset: userAssetsStore.getState().getHighestValueAsset() }); - const internalSelectedInputAsset = useSharedValue(null); + const internalSelectedInputAsset = useSharedValue(initialSelectedInputAsset); const internalSelectedOutputAsset = useSharedValue(null); + const selectedOutputChainId = useSharedValue(initialSelectedInputAsset?.chainId || ChainId.mainnet); const quote = useSharedValue(null); const SwapSettings = useSwapSettings({ @@ -153,21 +158,23 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { }; const network = ethereumUtils.getNetworkFromChainId(parameters.chainId); - const provider = getCachedProviderForNetwork(network); + const provider = + parameters.flashbots && getNetworkObj(network).features.flashbots + ? await getFlashbotsProvider() + : getCachedProviderForNetwork(network); const providerUrl = provider?.connection?.url; const connectedToHardhat = isHardHat(providerUrl); - const wallet = await loadWallet(parameters.quote.from, false, provider); - if (!wallet) { + const selectedGas = getSelectedGas(parameters.chainId); + if (!selectedGas) { runOnUI(resetSwappingStatus)(); - Alert.alert(i18n.t(i18n.l.swap.unable_to_load_wallet)); + Alert.alert(i18n.t(i18n.l.gas.unable_to_determine_selected_gas)); return; } - const selectedGas = getSelectedGas(parameters.chainId); - if (!selectedGas) { + const wallet = await loadWallet(parameters.quote.from, false, provider); + if (!wallet) { runOnUI(resetSwappingStatus)(); - // TODO: Show alert or something but this should never happen technically Alert.alert(i18n.t(i18n.l.swap.unable_to_load_wallet)); return; } @@ -192,6 +199,7 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { const { errorMessage } = await walletExecuteRap(wallet, type, { ...parameters, gasParams, + // eslint-disable-next-line @typescript-eslint/no-explicit-any gasFeeParamsBySpeed: gasFeeParamsBySpeed as any, }); runOnUI(resetSwappingStatus)(); @@ -272,16 +280,18 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { }); const SwapNavigation = useSwapNavigation({ - SwapInputController, - inputProgress, - outputProgress, configProgress, executeSwap, + inputProgress, + outputProgress, + quoteFetchingInterval: SwapInputController.quoteFetchingInterval, + selectedInputAsset: internalSelectedInputAsset, + selectedOutputAsset: internalSelectedOutputAsset, }); const SwapWarning = useSwapWarning({ - SwapInputController, inputAsset: internalSelectedInputAsset, + inputValues: SwapInputController.inputValues, outputAsset: internalSelectedOutputAsset, quote, sliderXPosition, @@ -299,35 +309,37 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { isFetching, }); - const handleProgressNavigation = ({ type }: { type: SwapAssetType }) => { - 'worklet'; - - const inputAsset = internalSelectedInputAsset.value; - const outputAsset = internalSelectedOutputAsset.value; + const handleProgressNavigation = useCallback( + ({ type }: { type: SwapAssetType }) => { + 'worklet'; + const inputAsset = internalSelectedInputAsset.value; + const outputAsset = internalSelectedOutputAsset.value; - switch (type) { - case SwapAssetType.inputAsset: - // if there is already an output asset selected, just close both lists - if (outputAsset) { - inputProgress.value = NavigationSteps.INPUT_ELEMENT_FOCUSED; - outputProgress.value = NavigationSteps.INPUT_ELEMENT_FOCUSED; - } else { - inputProgress.value = NavigationSteps.INPUT_ELEMENT_FOCUSED; - outputProgress.value = NavigationSteps.TOKEN_LIST_FOCUSED; - } - break; - case SwapAssetType.outputAsset: - // if there is already an input asset selected, just close both lists - if (inputAsset) { - inputProgress.value = NavigationSteps.INPUT_ELEMENT_FOCUSED; - outputProgress.value = NavigationSteps.INPUT_ELEMENT_FOCUSED; - } else { - inputProgress.value = NavigationSteps.TOKEN_LIST_FOCUSED; - outputProgress.value = NavigationSteps.INPUT_ELEMENT_FOCUSED; - } - break; - } - }; + switch (type) { + case SwapAssetType.inputAsset: + // if there is already an output asset selected, just close both lists + if (outputAsset) { + inputProgress.value = NavigationSteps.INPUT_ELEMENT_FOCUSED; + outputProgress.value = NavigationSteps.INPUT_ELEMENT_FOCUSED; + } else { + inputProgress.value = NavigationSteps.INPUT_ELEMENT_FOCUSED; + outputProgress.value = NavigationSteps.TOKEN_LIST_FOCUSED; + } + break; + case SwapAssetType.outputAsset: + // if there is already an input asset selected, just close both lists + if (inputAsset) { + inputProgress.value = NavigationSteps.INPUT_ELEMENT_FOCUSED; + outputProgress.value = NavigationSteps.INPUT_ELEMENT_FOCUSED; + } else { + inputProgress.value = NavigationSteps.TOKEN_LIST_FOCUSED; + outputProgress.value = NavigationSteps.INPUT_ELEMENT_FOCUSED; + } + break; + } + }, + [internalSelectedInputAsset, internalSelectedOutputAsset, inputProgress, outputProgress] + ); const setSelectedOutputChainId = (chainId: ChainId) => { const updateChainId = (chainId: ChainId) => { @@ -335,64 +347,134 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { selectedOutputChainId.value = chainId; }; - swapsStore.setState({ selectedOutputChainId: chainId }); runOnUI(updateChainId)(chainId); + useSwapsStore.setState({ selectedOutputChainId: chainId }); }; - const setAsset = ({ type, asset }: { type: SwapAssetType; asset: ParsedSearchAsset }) => { - const updateAssetValue = ({ type, asset }: { type: SwapAssetType; asset: ExtendedAnimatedAssetWithColors | null }) => { + const updateAssetValue = useCallback( + ({ type, asset }: { type: SwapAssetType; asset: ExtendedAnimatedAssetWithColors | null }) => { 'worklet'; switch (type) { case SwapAssetType.inputAsset: internalSelectedInputAsset.value = asset; - selectedOutputChainId.value = asset?.chainId ?? ChainId.mainnet; break; case SwapAssetType.outputAsset: internalSelectedOutputAsset.value = asset; break; } + }, + [internalSelectedInputAsset, internalSelectedOutputAsset] + ); - handleProgressNavigation({ - type, - }); - }; + const chainSetTimeoutId = useRef(null); + + const setAsset = useCallback( + ({ type, asset }: { type: SwapAssetType; asset: ParsedSearchAsset | null }) => { + const insertUserAssetBalance = type === SwapAssetType.outputAsset; + const extendedAsset = parseAssetAndExtend({ asset, insertUserAssetBalance }); + + const otherSelectedAsset = type === SwapAssetType.inputAsset ? internalSelectedOutputAsset.value : internalSelectedInputAsset.value; + const isSameAsOtherAsset = !!(otherSelectedAsset && otherSelectedAsset.uniqueId === extendedAsset?.uniqueId); + const flippedAssetOrNull = + (isSameAsOtherAsset && + (type === SwapAssetType.inputAsset ? internalSelectedInputAsset.value : internalSelectedOutputAsset.value)) || + null; + + const didSelectedAssetChange = + type === SwapAssetType.inputAsset + ? internalSelectedInputAsset.value?.uniqueId !== extendedAsset?.uniqueId + : internalSelectedOutputAsset.value?.uniqueId !== extendedAsset?.uniqueId; + + runOnUI(() => { + const didSelectedAssetChange = + type === SwapAssetType.inputAsset + ? internalSelectedInputAsset.value?.uniqueId !== extendedAsset?.uniqueId + : internalSelectedOutputAsset.value?.uniqueId !== extendedAsset?.uniqueId; + + if (didSelectedAssetChange) { + const otherSelectedAsset = + type === SwapAssetType.inputAsset ? internalSelectedOutputAsset.value : internalSelectedInputAsset.value; + const isSameAsOtherAsset = !!(otherSelectedAsset && otherSelectedAsset.uniqueId === extendedAsset?.uniqueId); + + if (isSameAsOtherAsset) { + const flippedAssetOrNull = + type === SwapAssetType.inputAsset ? internalSelectedInputAsset.value : internalSelectedOutputAsset.value; + + updateAssetValue({ + type: type === SwapAssetType.inputAsset ? SwapAssetType.outputAsset : SwapAssetType.inputAsset, + asset: flippedAssetOrNull, + }); + } + updateAssetValue({ type, asset: isSameAsOtherAsset ? otherSelectedAsset : extendedAsset }); + } else { + SwapInputController.quoteFetchingInterval.start(); + } - // const prevAsset = swapsStore.getState()[type]; - const prevOtherAsset = swapsStore.getState()[type === SwapAssetType.inputAsset ? SwapAssetType.outputAsset : SwapAssetType.inputAsset]; - - // TODO: Fix me. This is causing assets to not be set sometimes? - // if we're setting the same asset, exit early as it's a no-op - // if (prevAsset && isSameAsset(prevAsset, asset)) { - // logger.debug(`[setAsset]: Not setting ${type} asset as it's the same as what is already set`); - // handleProgressNavigation({ - // type, - // inputAsset: type === SwapAssetType.inputAsset ? asset : prevOtherAsset, - // outputAsset: type === SwapAssetType.outputAsset ? asset : prevOtherAsset, - // }); - // return; - // } - - // if we're setting the same asset as the other asset, we need to clear the other asset - if (prevOtherAsset && isSameAsset(prevOtherAsset, asset)) { - logger.debug(`[setAsset]: Swapping ${type} asset for ${type === SwapAssetType.inputAsset ? 'output' : 'input'} asset`); - - swapsStore.setState({ - [type === SwapAssetType.inputAsset ? SwapAssetType.outputAsset : SwapAssetType.inputAsset]: null, - }); - runOnUI(updateAssetValue)({ - type: type === SwapAssetType.inputAsset ? SwapAssetType.outputAsset : SwapAssetType.inputAsset, - asset: null, - }); - } + handleProgressNavigation({ type }); + })(); - logger.debug(`[setAsset]: Setting ${type} asset to ${asset.name} on ${asset.chainId}`); + if (didSelectedAssetChange) { + const assetToSet = insertUserAssetBalance + ? { ...asset, balance: (asset && userAssetsStore.getState().getUserAsset(asset.uniqueId)?.balance) || asset?.balance } + : asset; - swapsStore.setState({ - [type]: asset, - }); - runOnUI(updateAssetValue)({ type, asset: parseAssetAndExtend({ asset }) }); - }; + if (isSameAsOtherAsset) { + useSwapsStore.setState({ + [type === SwapAssetType.inputAsset ? SwapAssetType.outputAsset : SwapAssetType.inputAsset]: flippedAssetOrNull, + [type]: otherSelectedAsset, + }); + } else { + useSwapsStore.setState({ [type]: assetToSet }); + } + } else { + SwapInputController.quoteFetchingInterval.start(); + } + + const shouldUpdateSelectedOutputChainId = + type === SwapAssetType.inputAsset && useSwapsStore.getState().selectedOutputChainId !== extendedAsset?.chainId; + const shouldUpdateAnimatedSelectedOutputChainId = + type === SwapAssetType.inputAsset && selectedOutputChainId.value !== extendedAsset?.chainId; + + if (shouldUpdateSelectedOutputChainId || shouldUpdateAnimatedSelectedOutputChainId) { + if (chainSetTimeoutId.current) { + clearTimeout(chainSetTimeoutId.current); + } + + // This causes a heavy re-render in the output token list, so we delay updating the selected output chain until + // the animation is most likely complete. + chainSetTimeoutId.current = setTimeout(() => { + if (shouldUpdateSelectedOutputChainId) { + useSwapsStore.setState({ + selectedOutputChainId: extendedAsset?.chainId ?? ChainId.mainnet, + }); + } + if (shouldUpdateAnimatedSelectedOutputChainId) { + selectedOutputChainId.value = extendedAsset?.chainId ?? ChainId.mainnet; + } + }, 750); + } + + logger.debug(`[setAsset]: Setting ${type} asset to ${extendedAsset?.name} on ${extendedAsset?.chainId}`); + }, + [ + SwapInputController.quoteFetchingInterval, + handleProgressNavigation, + internalSelectedInputAsset, + internalSelectedOutputAsset, + selectedOutputChainId, + updateAssetValue, + ] + ); + + useEffect(() => { + return () => { + if (chainSetTimeoutId.current) { + // Clear the timeout on unmount + clearTimeout(chainSetTimeoutId.current); + } + }; + }, []); const confirmButtonIcon = useDerivedValue(() => { if (isSwapping.value) { @@ -405,23 +487,24 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { return '􀆅'; } - if (isFetching.value) { + if (isQuoteStale.value === 1 && sliderPressProgress.value === 0) { return ''; } const isInputZero = Number(SwapInputController.inputValues.value.inputAmount) === 0; const isOutputZero = Number(SwapInputController.inputValues.value.outputAmount) === 0; - if (SwapInputController.inputMethod.value !== 'slider' && (isInputZero || isOutputZero) && !isFetching.value) { - return ''; - } else if (SwapInputController.inputMethod.value === 'slider' && SwapInputController.percentageToSwap.value === 0) { + if ( + (isInputZero && isOutputZero) || + isFetching.value || + (SwapInputController.inputMethod.value === 'slider' && SwapInputController.percentageToSwap.value === 0) + ) { return ''; } else { return '􀕹'; } }); - // TODO: i18n these const confirmButtonLabel = useDerivedValue(() => { if (isSwapping.value) { return swapping; @@ -433,7 +516,7 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { return save; } - if (isFetching.value) { + if (isFetching.value || (isQuoteStale.value === 1 && SwapInputController.inputMethod.value !== 'slider')) { return fetchingPrices; } @@ -468,27 +551,15 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { }; }); - useEffect(() => { - return () => { - swapsStore.setState({ - inputAsset: null, - outputAsset: null, - quote: null, - }); - - SwapInputController.quoteFetchingInterval.stop(); - }; - }, []); - - console.log('re-rendered swap provider: ', Date.now()); - return ( { export const useSwapContext = () => { const context = useContext(SwapContext); if (context === undefined) { - throw new Error('useSwap must be used within a SwapProvider'); + throw new Error('useSwapContext must be used within a SwapProvider'); } return context; }; diff --git a/src/__swaps__/screens/Swap/resources/assets/userAssets.ts b/src/__swaps__/screens/Swap/resources/assets/userAssets.ts index 7e409673216..93c0c458b56 100644 --- a/src/__swaps__/screens/Swap/resources/assets/userAssets.ts +++ b/src/__swaps__/screens/Swap/resources/assets/userAssets.ts @@ -82,6 +82,9 @@ export const userAssetsSetQueryData = ({ address, currency, userAssets, testnetM }; async function userAssetsQueryFunction({ queryKey: [{ address, currency, testnetMode }] }: QueryFunctionArgs) { + if (!address) { + return {}; + } const cache = queryClient.getQueryCache(); const cachedUserAssets = (cache.find(userAssetsQueryKey({ address, currency, testnetMode }))?.state?.data || {}) as ParsedAssetsDictByChain; diff --git a/src/__swaps__/types/swap.ts b/src/__swaps__/types/swap.ts index 018f0c5f2e2..35cde20cee8 100644 --- a/src/__swaps__/types/swap.ts +++ b/src/__swaps__/types/swap.ts @@ -1,6 +1,9 @@ export type inputKeys = 'inputAmount' | 'inputNativeValue' | 'outputAmount' | 'outputNativeValue'; -export type settingsKeys = 'swapFee' | 'slippage' | 'flashbots'; export type inputMethods = inputKeys | 'slider'; +export type inputValuesType = { [key in inputKeys]: number | string }; + +export type settingsKeys = 'swapFee' | 'slippage' | 'flashbots'; + export enum SortMethod { token = 'token', chain = 'chain', @@ -10,3 +13,11 @@ export enum SwapAssetType { inputAsset = 'inputAsset', outputAsset = 'outputAsset', } + +export interface RequestNewQuoteParams { + assetToBuyUniqueId: string | undefined; + assetToSellUniqueId: string | undefined; + inputAmount: inputValuesType['inputAmount']; + lastTypedInput: inputKeys; + outputAmount: inputValuesType['outputAmount']; +} diff --git a/src/__swaps__/utils/chains.ts b/src/__swaps__/utils/chains.ts index 201986ee90b..71cbee651ed 100644 --- a/src/__swaps__/utils/chains.ts +++ b/src/__swaps__/utils/chains.ts @@ -1,15 +1,11 @@ import { celo, fantom, harmonyOne, moonbeam } from 'viem/chains'; import { NATIVE_ASSETS_PER_CHAIN } from '@/references'; - -import { ChainId, ChainName, ChainNameDisplay, chainIdToNameMapping, chainNameToIdMapping } from '@/__swaps__/types/chains'; - import { AddressOrEth } from '@/__swaps__/types/assets'; - +import { ChainId, ChainName, ChainNameDisplay, chainIdToNameMapping, chainNameToIdMapping } from '@/__swaps__/types/chains'; import { isLowerCaseMatch } from '@/__swaps__/utils/strings'; import { getNetworkFromChainId } from '@/utils/ethereumUtils'; -// eslint-disable-next-line @typescript-eslint/ban-ts-comment -// @ts-ignore +// @ts-expect-error Property '[ChainId.hardhat]' is missing export const customChainIdsToAssetNames: Record = { 42170: 'arbitrumnova', 1313161554: 'aurora', @@ -95,15 +91,6 @@ export function chainNameForChainIdWithMainnetSubstitution(chainId: ChainId) { return chainIdToNameMapping[chainId]; } -export function chainNameForChainIdWithMainnetSubstitutionWorklet(chainId: ChainId) { - 'worklet'; - - if (chainId === ChainId.mainnet) { - return 'ethereum'; - } - return chainIdToNameMapping[chainId]; -} - export function chainNameFromChainId(chainId: ChainId): ChainName { return chainIdToNameMapping[chainId]; } diff --git a/src/__swaps__/utils/swaps.ts b/src/__swaps__/utils/swaps.ts index aebb2101d32..3bc23211bf3 100644 --- a/src/__swaps__/utils/swaps.ts +++ b/src/__swaps__/utils/swaps.ts @@ -9,11 +9,12 @@ import { ChainId, ChainName } from '@/__swaps__/types/chains'; import { RainbowConfig } from '@/model/remoteConfig'; import { CrosschainQuote, ETH_ADDRESS, Quote, QuoteParams, SwapType, WRAPPED_ASSET } from '@rainbow-me/swaps'; import { isLowerCaseMatch } from '@/__swaps__/utils/strings'; -import { ExtendedAnimatedAssetWithColors, ParsedSearchAsset } from '../types/assets'; +import { AddressOrEth, ExtendedAnimatedAssetWithColors, ParsedSearchAsset } from '../types/assets'; import { inputKeys } from '../types/swap'; import { swapsStore } from '../../state/swaps/swapsStore'; import { BigNumberish } from '@ethersproject/bignumber'; import { TokenColors } from '@/graphql/__generated__/metadata'; +import { userAssetsStore } from '@/state/assets/userAssets'; import { colors } from '@/styles'; import { convertAmountToRawAmount } from './numbers'; @@ -166,8 +167,11 @@ export const findNiceIncrement = (availableBalance: number): number => { // /---- 🔵 Worklet utils 🔵 ----/ // // -export function addCommasToNumber(number: string | number) { +export function addCommasToNumber(number: string | number, fallbackValue: T = 0 as T): T | string { 'worklet'; + if (isNaN(Number(number))) { + return fallbackValue; + } const numberString = number.toString(); if (numberString.includes(',')) { @@ -399,7 +403,6 @@ export type Colors = { type ExtractColorValueForColorsProps = { colors: TokenColors; - isDarkMode: boolean; }; export const extractColorValueForColors = ({ @@ -428,10 +431,25 @@ export const extractColorValueForColors = ({ }; }; -export const getQuoteServiceTime = ({ quote }: { quote: Quote | CrosschainQuote }) => - (quote as CrosschainQuote)?.routes?.[0]?.serviceTime || 0; +export const getQuoteServiceTimeWorklet = ({ quote }: { quote: Quote | CrosschainQuote }) => { + 'worklet'; + return (quote as CrosschainQuote)?.routes?.[0]?.serviceTime || 0; +}; + +const I18N_TIME = { + singular: { + hours_long: i18n.t(i18n.l.time.hours.long.singular), + minutes_short: i18n.t(i18n.l.time.minutes.short.singular), + seconds_short: i18n.t(i18n.l.time.seconds.short.singular), + }, + plural: { + hours_long: i18n.t(i18n.l.time.hours.long.plural), + minutes_short: i18n.t(i18n.l.time.minutes.short.plural), + seconds_short: i18n.t(i18n.l.time.seconds.short.plural), + }, +}; -export const getCrossChainTimeEstimate = ({ +export const getCrossChainTimeEstimateWorklet = ({ serviceTime, }: { serviceTime?: number; @@ -440,6 +458,8 @@ export const getCrossChainTimeEstimate = ({ timeEstimate?: number; timeEstimateDisplay: string; } => { + 'worklet'; + let isLongWait = false; let timeEstimateDisplay; const timeEstimate = serviceTime; @@ -449,11 +469,11 @@ export const getCrossChainTimeEstimate = ({ if (hours >= 1) { isLongWait = true; - timeEstimateDisplay = `>${hours} ${i18n.t(i18n.l.time.hours.long[hours === 1 ? 'singular' : 'plural'])}`; + timeEstimateDisplay = `>${hours} ${hours === 1 ? I18N_TIME.singular.hours_long : I18N_TIME.plural.hours_long}`; } else if (minutes >= 1) { - timeEstimateDisplay = `~${minutes} ${i18n.t(i18n.l.time.minutes.short[minutes === 1 ? 'singular' : 'plural'])}`; + timeEstimateDisplay = `~${minutes} ${minutes === 1 ? I18N_TIME.singular.minutes_short : I18N_TIME.plural.minutes_short}`; } else { - timeEstimateDisplay = `~${timeEstimate} ${i18n.t(i18n.l.time.seconds.short[timeEstimate === 1 ? 'singular' : 'plural'])}`; + timeEstimateDisplay = `~${timeEstimate} ${timeEstimate === 1 ? I18N_TIME.singular.seconds_short : I18N_TIME.plural.seconds_short}`; } return { @@ -516,6 +536,7 @@ export const priceForAsset = ({ type ParseAssetAndExtendProps = { asset: ParsedSearchAsset | null; + insertUserAssetBalance?: boolean; }; const ETH_COLORS: Colors = { @@ -524,30 +545,35 @@ const ETH_COLORS: Colors = { shadow: undefined, }; -export const parseAssetAndExtend = ({ asset }: ParseAssetAndExtendProps): ExtendedAnimatedAssetWithColors | null => { +export const getStandardizedUniqueIdWorklet = ({ address, chainId }: { address: AddressOrEth; chainId: ChainId }) => { + 'worklet'; + return `${address.toLowerCase()}_${chainId}`; +}; + +export const parseAssetAndExtend = ({ + asset, + insertUserAssetBalance, +}: ParseAssetAndExtendProps): ExtendedAnimatedAssetWithColors | null => { if (!asset) { return null; } const isAssetEth = asset.isNativeAsset && asset.symbol === 'ETH'; - // TODO: Process and add colors to the asset and anything else we'll need for reanimated stuff const colors = extractColorValueForColors({ colors: (isAssetEth ? ETH_COLORS : asset.colors) as TokenColors, - isDarkMode: true, // TODO: Make this not rely on isDarkMode }); - const priceInfo: Pick = { - nativePrice: undefined, - }; - - if (asset.price) { - priceInfo.nativePrice = asset.price.value; - } + const uniqueId = getStandardizedUniqueIdWorklet({ address: asset.address, chainId: asset.chainId }); return { ...asset, ...colors, - ...priceInfo, + nativePrice: asset.price?.value, + balance: insertUserAssetBalance ? userAssetsStore.getState().getUserAsset(uniqueId)?.balance || asset.balance : asset.balance, + + // For some reason certain assets have a unique ID in the format of `${address}_mainnet` rather than + // `${address}_${chainId}`, so at least for now we ensure consistency by reconstructing the unique ID here. + uniqueId, }; }; diff --git a/src/components/asset-list/RecyclerAssetList2/FastComponents/FastFallbackCoinIconImage.tsx b/src/components/asset-list/RecyclerAssetList2/FastComponents/FastFallbackCoinIconImage.tsx index 1dcc5ec1294..6d1a88edca5 100644 --- a/src/components/asset-list/RecyclerAssetList2/FastComponents/FastFallbackCoinIconImage.tsx +++ b/src/components/asset-list/RecyclerAssetList2/FastComponents/FastFallbackCoinIconImage.tsx @@ -1,82 +1,44 @@ -import React, { useCallback, useState } from 'react'; +/* eslint-disable no-nested-ternary */ +import React, { useState } from 'react'; import { StyleSheet, View } from 'react-native'; import { Network } from '@/networks/types'; -import { ImageWithCachedMetadata, ImgixImage } from '@/components/images'; +import { ImgixImage } from '@/components/images'; import { ThemeContextProps } from '@/theme'; -const ImageState = { - ERROR: 'ERROR', - LOADED: 'LOADED', - NOT_FOUND: 'NOT_FOUND', -} as const; - -const imagesCache: { [imageUrl: string]: keyof typeof ImageState } = {}; - export const FastFallbackCoinIconImage = React.memo(function FastFallbackCoinIconImage({ - size = 40, + children, icon, shadowColor, - theme, - children, + size = 40, }: { - size?: number; + children: () => React.ReactNode; icon?: string; - theme: ThemeContextProps; network: Network; - symbol: string; shadowColor: string; - children: () => React.ReactNode; + size?: number; + symbol: string; + theme: ThemeContextProps; }) { - const { colors } = theme; - - const key = `${icon}`; - - const [cacheStatus, setCacheStatus] = useState(imagesCache[key]); - - const shouldShowImage = cacheStatus !== ImageState.NOT_FOUND; - const isLoaded = cacheStatus === ImageState.LOADED; - - const onLoad = useCallback(() => { - if (isLoaded) { - return; - } - imagesCache[key] = ImageState.LOADED; - setCacheStatus(ImageState.LOADED); - }, [key, isLoaded]); - - const onError = useCallback( - // @ts-expect-error passed to an untyped JS component - err => { - const newError = err?.nativeEvent?.message?.includes('404') ? ImageState.NOT_FOUND : ImageState.ERROR; - - if (cacheStatus === newError) { - return; - } - - imagesCache[key] = newError; - setCacheStatus(newError); - }, - [cacheStatus, key] - ); + const [didErrorForUrl, setDidErrorForUrl] = useState(undefined); return ( - {shouldShowImage && ( - {children()} + ) : ( + { + if (icon?.length > 0) { + setDidErrorForUrl(icon); + } + }} + onLoad={() => setDidErrorForUrl(undefined)} size={size} - style={[ - sx.coinIconFallback, - isLoaded && { backgroundColor: colors.white }, - { height: size, width: size, borderRadius: size / 2 }, - ]} + source={{ uri: icon }} + style={[sx.coinIconFallback, { height: size, width: size, borderRadius: size / 2 }]} /> )} - - {!isLoaded && {children()}} ); }); @@ -104,12 +66,13 @@ const sx = StyleSheet.create({ width: '100%', }, withShadow: { - elevation: 6, - shadowOffset: { - height: 4, - width: 0, - }, - shadowOpacity: 0.3, - shadowRadius: 6, + // ⚠️ CB: Disabling coin icon shadows for now because they are negatively impacting performance + // elevation: 6, + // shadowOffset: { + // height: 4, + // width: 0, + // }, + // shadowOpacity: 0.3, + // shadowRadius: 6, }, }); diff --git a/src/components/asset-list/RecyclerAssetList2/profile-header/ProfileActionButtonsRow.tsx b/src/components/asset-list/RecyclerAssetList2/profile-header/ProfileActionButtonsRow.tsx index 80694a8bdbb..bcc32b81ec5 100644 --- a/src/components/asset-list/RecyclerAssetList2/profile-header/ProfileActionButtonsRow.tsx +++ b/src/components/asset-list/RecyclerAssetList2/profile-header/ProfileActionButtonsRow.tsx @@ -180,7 +180,7 @@ function SwapButton() { android && delayNext(); if (swapsV2Enabled) { - navigate(Routes.SWAP_NAVIGATOR); + navigate(Routes.SWAP); return; } diff --git a/src/components/cards/EthCard.tsx b/src/components/cards/EthCard.tsx index 99998061341..47d912c4e4b 100644 --- a/src/components/cards/EthCard.tsx +++ b/src/components/cards/EthCard.tsx @@ -1,6 +1,6 @@ import { Box, Inline, Stack, Text, AccentColorProvider, Bleed } from '@/design-system'; import { useTheme } from '@/theme'; -import React, { useCallback, useEffect, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { GenericCard } from './GenericCard'; import { ButtonPressAnimation } from '../animations'; import { useAccountSettings, useChartThrottledPoints, useColorForAsset, useWallets } from '@/hooks'; @@ -39,12 +39,15 @@ export const EthCard = () => { currency: nativeCurrency, }); - const ethAsset = { - ...externalEthAsset, - address: ETH_ADDRESS, - network: Network.mainnet, - uniqueId: getUniqueId(ETH_ADDRESS, Network.mainnet), - }; + const ethAsset = useMemo( + () => ({ + ...externalEthAsset, + address: ETH_ADDRESS, + network: Network.mainnet, + uniqueId: getUniqueId(ETH_ADDRESS, Network.mainnet), + }), + [externalEthAsset] + ); const { loaded: accentColorLoaded } = useAccountAccentColor(); const { name: routeName } = useRoute(); diff --git a/src/components/context-menu/ContextMenuButton.js b/src/components/context-menu/ContextMenuButton.js index 3b46cf2ab95..ffa8d2f9fa9 100644 --- a/src/components/context-menu/ContextMenuButton.js +++ b/src/components/context-menu/ContextMenuButton.js @@ -1,9 +1,8 @@ import React from 'react'; - import { ContextMenuButton as IOSContextMenuButton } from 'react-native-ios-context-menu'; import ButtonPressAnimation from '../animations/ButtonPressAnimation'; -export default function ContextMenuButton({ children, menuItems, menuTitle, onPressAndroid, onPressMenuItem, testID }) { +export default function ContextMenuButton({ children, hitSlop = 0, menuItems, menuTitle, onPressAndroid, onPressMenuItem, testID }) { return ( - {children} + + {children} + ); } diff --git a/src/design-system/components/Text/AnimatedText.tsx b/src/design-system/components/Text/AnimatedText.tsx index 945e8aebf18..534a800eeda 100644 --- a/src/design-system/components/Text/AnimatedText.tsx +++ b/src/design-system/components/Text/AnimatedText.tsx @@ -1,7 +1,7 @@ import React, { ElementRef, forwardRef, useMemo } from 'react'; import { StyleProp, TextStyle } from 'react-native'; import AnimateableText from 'react-native-animateable-text'; -import { DerivedValue, useAnimatedProps } from 'react-native-reanimated'; +import { SharedValue, useAnimatedProps } from 'react-native-reanimated'; import { TextColor } from '../../color/palettes'; import { CustomColor } from '../../color/useForegroundColor'; import { createLineHeightFixNode } from '../../typography/createLineHeightFixNode'; @@ -10,32 +10,37 @@ import { useTextStyle } from './useTextStyle'; export type AnimatedTextProps = { align?: 'center' | 'left' | 'right'; - children?: DerivedValue; + children?: SharedValue | string | null | undefined; color?: TextColor | CustomColor; ellipsizeMode?: 'head' | 'middle' | 'tail' | 'clip' | undefined; numberOfLines?: number; selectable?: boolean; size: TextSize; - /** Useful when using `AnimatedText` exclusively to animate text `style`, rather than the text itself. */ + /** + * @deprecated + * Use `children` instead, which now accepts either a string or a shared value that holds a string. + */ staticText?: string; tabularNumbers?: boolean; - /** + /** * @deprecated * You can now pass in a value like this: - * + * + * ``` * * {derivedOrSharedValue} * - * - * This should be a Reanimated shared or derived value. - * - * To create a derived value, use the `useDerivedValue` hook from 'react-native-reanimated'. + * ``` + * + * `derivedOrSharedValue` should be a Reanimated shared or derived value. + * + * To create a derived value, use the `useDerivedValue` hook from 'react-native-reanimated'. * For example: - ``` - const text = useDerivedValue(() => `Hello ${someOtherValue.value}`); - ``` + * ``` + * const text = useDerivedValue(() => `Hello ${someOtherValue.value}`); + * ``` **/ - text?: DerivedValue; + text?: SharedValue; testID?: string; uppercase?: boolean; weight?: TextWeight; @@ -75,7 +80,7 @@ export const AnimatedText = forwardRef, Anima const animatedText = useAnimatedProps(() => { return { - text: children?.value ?? text?.value ?? staticText ?? '', + text: typeof children === 'string' ? children : children?.value ?? text?.value ?? staticText ?? '', }; }); diff --git a/src/hooks/reanimated/useAnimatedInterval.ts b/src/hooks/reanimated/useAnimatedInterval.ts index 379f099f0d0..990ba3dc7e1 100644 --- a/src/hooks/reanimated/useAnimatedInterval.ts +++ b/src/hooks/reanimated/useAnimatedInterval.ts @@ -1,4 +1,5 @@ import { useAnimatedTime } from './useAnimatedTime'; + interface IntervalConfig { /** Whether the interval clock should start automatically. @default true */ autoStart?: boolean; @@ -19,12 +20,13 @@ interface IntervalConfig { * - `onIntervalWorklet` - The worklet function to be executed at each interval. * * @returns An object containing: - * - `reset` - A worklet function to restart the interval clock. + * - `pause` - A worklet function to pause the interval clock. + * - `restart` - A worklet function to restart the interval clock. * - `start` - A worklet function to start the interval clock. * - `stop` - A worklet function to stop the interval clock. * * @example - * const { reset } = useAnimatedInterval({ + * const { restart } = useAnimatedInterval({ * intervalMs: 10000, * onIntervalWorklet: () => { * 'worklet'; @@ -35,12 +37,12 @@ interface IntervalConfig { export function useAnimatedInterval(config: IntervalConfig) { const { autoStart = true, intervalMs, onIntervalWorklet } = config; - const { reset, start, stop } = useAnimatedTime({ + const { pause, restart, start, stop } = useAnimatedTime({ autoStart, durationMs: intervalMs, onEndWorklet: onIntervalWorklet, shouldRepeat: true, }); - return { reset, start, stop }; + return { pause, restart, start, stop }; } diff --git a/src/hooks/reanimated/useAnimatedTime.ts b/src/hooks/reanimated/useAnimatedTime.ts index 70993257c89..8edec6cf011 100644 --- a/src/hooks/reanimated/useAnimatedTime.ts +++ b/src/hooks/reanimated/useAnimatedTime.ts @@ -1,5 +1,5 @@ import { useCallback, useEffect } from 'react'; -import { Easing, SharedValue, useSharedValue, withRepeat, withTiming } from 'react-native-reanimated'; +import { Easing, SharedValue, useSharedValue, withRepeat, withSequence, withTiming } from 'react-native-reanimated'; interface TimerConfig { /** Whether the timer should start automatically. @default false */ @@ -8,60 +8,71 @@ interface TimerConfig { durationMs?: number; /** A worklet function to be called when the timer ends. */ onEndWorklet?: () => void; + /** A worklet function to be called when the timer starts. */ + onStartWorklet?: (currentTime: SharedValue) => void; /** Whether the timer should repeat after completion. @default false */ shouldRepeat?: boolean; } interface TimerResult { - /** A worklet function that resets the timer. */ - reset: () => void; + /** A worklet function that pauses the timer. */ + pause: () => void; + /** A worklet function that restarts the timer. */ + restart: () => void; /** A worklet function that starts the timer. */ start: () => void; /** A worklet function that stops the timer. */ stop: () => void; - /** A read-only shared value representing the timer clock in seconds. */ - timeInSeconds: Readonly>; + /** A shared value representing the timer clock in seconds. */ + timeInSeconds: SharedValue; } /** * ### useAnimatedTime * * Creates a shared value that represents a timer with a specified duration. - * It provides controls to start, stop, and reset the timer, as well as a read-only shared value representing the timer clock in seconds. + * It provides controls to start, stop, pause, and restart the timer, as well as a read-only shared value representing the timer clock in seconds. * * @param config {@link TimerConfig} – Configuration options for the timer: * - `autoStart` – Whether the timer should start automatically. * - `durationMs` – The duration of the timer in milliseconds. * - `onEndWorklet` – A worklet function to be called when the timer ends. + * - `onStartWorklet` – A worklet function to be called when the timer starts. * - `shouldRepeat` – Whether the timer should repeat after completion. * * @returns {TimerResult} {@link TimerResult} – An object containing: - * - `reset` – A worklet function that resets the timer. + * - `pause` – A worklet function that pauses the timer. + * - `restart` – A worklet function that restarts the timer. * - `start` – A worklet function that starts the timer. * - `stop` – A worklet function that stops the timer. - * - `timeInSeconds` – A read-only shared value representing the timer clock in seconds. + * - `timeInSeconds` – A shared value representing the timer clock in seconds. * * @example - * const { reset, start, stop, timeInSeconds } = useAnimatedTime({ + * const { restart, start, stop, timeInSeconds } = useAnimatedTime({ * autoStart: true, * durationMs: 3000, * onEndWorklet: () => { * 'worklet'; * console.log('Timer ended'); * }, + * onStartWorklet: () => { + * console.log('Timer started'); + * }, * shouldRepeat: true, * }); */ export function useAnimatedTime(config: TimerConfig = {}): TimerResult { - const { autoStart = false, durationMs = 1000, onEndWorklet, shouldRepeat = false } = config; + const { autoStart = false, durationMs = 1000, onEndWorklet, onStartWorklet, shouldRepeat = false } = config; + const pausedAt = useSharedValue(0); const timeInSeconds = useSharedValue(0); const start = useCallback(() => { 'worklet'; - if (timeInSeconds.value !== 0) timeInSeconds.value = 0; - timeInSeconds.value = withRepeat( + if (onStartWorklet) onStartWorklet(timeInSeconds); + + const repeatingTimer = withRepeat( withTiming(durationMs / 1000, { duration: durationMs, easing: Easing.linear }, finished => { if (finished && onEndWorklet) { onEndWorklet(); @@ -70,14 +81,48 @@ export function useAnimatedTime(config: TimerConfig = {}): TimerResult { shouldRepeat ? -1 : 1, false ); - }, [durationMs, onEndWorklet, shouldRepeat, timeInSeconds]); + + if (pausedAt.value > 0) { + const remainingMs = durationMs - pausedAt.value * 1000; + pausedAt.value = 0; + + timeInSeconds.value = withSequence( + withTiming( + durationMs / 1000, + { + duration: remainingMs, + easing: Easing.linear, + }, + finished => { + if (finished && onEndWorklet) { + onEndWorklet(); + } + } + ), + repeatingTimer + ); + } else { + if (timeInSeconds.value > 0) { + timeInSeconds.value = 0; + } + timeInSeconds.value = repeatingTimer; + } + }, [durationMs, onEndWorklet, onStartWorklet, pausedAt, shouldRepeat, timeInSeconds]); const stop = useCallback(() => { 'worklet'; + pausedAt.value = 0; timeInSeconds.value = 0; - }, [timeInSeconds]); + }, [pausedAt, timeInSeconds]); + + const pause = useCallback(() => { + 'worklet'; + const currentTime = timeInSeconds.value; + pausedAt.value = currentTime; + timeInSeconds.value = currentTime; + }, [pausedAt, timeInSeconds]); - const reset = useCallback(() => { + const restart = useCallback(() => { 'worklet'; stop(); start(); @@ -91,7 +136,8 @@ export function useAnimatedTime(config: TimerConfig = {}): TimerResult { }, []); return { - reset, + pause, + restart, start, stop, timeInSeconds, diff --git a/src/hooks/reanimated/useDelayedValue.ts b/src/hooks/reanimated/useDelayedValue.ts new file mode 100644 index 00000000000..8658c708849 --- /dev/null +++ b/src/hooks/reanimated/useDelayedValue.ts @@ -0,0 +1,80 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { useCallback } from 'react'; +import { DerivedValue, SharedValue, useAnimatedReaction, useSharedValue } from 'react-native-reanimated'; +import { deepEqualWorklet, shallowEqualWorklet } from '@/worklets/comparisons'; +import { useAnimatedTime } from './useAnimatedTime'; + +interface DelayedValueConfig { + /** The depth of comparison for object values. @default 'deep' */ + compareDepth?: 'shallow' | 'deep'; + /** Controls if the value should be updated on the leading edge of the timeout. @default false */ + leading?: boolean; +} + +/** + * ### useDelayedValue + * + * Creates a delayed version of a shared value that updates after a specified delay. + * + * @param sharedValue The shared value to be delayed. + * @param wait The number of milliseconds to delay. + * @param options The options for the hook. + * - `compareDepth` The depth of comparison for object values. Defaults to 'deep'. + * - `leading` Controls if the value should be updated on the leading edge of the timeout. Defaults to false. + * + * @returns A shared value representing the delayed version of the input shared value. + * + * @example + * const value = useSharedValue(0); + * const delayedValue = useDelayedValue(value, 1000, { leading: true }); + */ +export function useDelayedValue( + sharedValue: DerivedValue | SharedValue, + wait: number, + options: DelayedValueConfig = {} +): SharedValue { + const { compareDepth = 'deep', leading = false } = options; + const delayedValue = useSharedValue(sharedValue.value); + + const updateDelayedValue = useCallback(() => { + 'worklet'; + if ( + typeof sharedValue.value === 'object' && + sharedValue.value !== null && + typeof delayedValue.value === 'object' && + delayedValue.value !== null + ) { + const isEqual = + compareDepth === 'deep' + ? deepEqualWorklet(sharedValue.value, delayedValue.value) + : shallowEqualWorklet(sharedValue.value, delayedValue.value); + + if (!isEqual) { + delayedValue.value = sharedValue.value; + } + } else if (sharedValue.value !== delayedValue.value) { + delayedValue.value = sharedValue.value; + } + }, [compareDepth, delayedValue, sharedValue]); + + const { start } = useAnimatedTime({ + autoStart: false, + durationMs: wait, + onEndWorklet: updateDelayedValue, + onStartWorklet: currentTime => { + 'worklet'; + if (leading && currentTime.value === 0) { + updateDelayedValue(); + } + }, + shouldRepeat: false, + }); + + useAnimatedReaction( + () => sharedValue.value, + () => start() + ); + + return delayedValue; +} diff --git a/src/hooks/reanimated/useSharedValueState.ts b/src/hooks/reanimated/useSharedValueState.ts index 92ba94fb481..ae67ad15081 100644 --- a/src/hooks/reanimated/useSharedValueState.ts +++ b/src/hooks/reanimated/useSharedValueState.ts @@ -11,17 +11,26 @@ import { useSyncSharedValue } from './useSyncSharedValue'; * first, and then pass in that derived value. * * @param sharedValue The shared value to sync to the state. - * @param compareDepth The depth of comparison for object values. Defaults to 'deep'. + * @param options The options for the hook. + * - `compareDepth` The depth of comparison for object values. Defaults to 'deep'. + * - `pauseSync` A boolean or shared value boolean that controls whether synchronization is paused. * @returns A piece of JS state that stays in sync with the shared value. * * @example * const state = useSharedValueState(sharedValue); */ -export function useSharedValueState(sharedValue: DerivedValue | SharedValue, compareDepth: 'shallow' | 'deep' = 'deep'): T { +export function useSharedValueState( + sharedValue: DerivedValue | SharedValue, + options: { + compareDepth?: 'shallow' | 'deep'; + pauseSync?: boolean; + } = { compareDepth: 'deep' } +): T { const [state, setState] = useState(sharedValue.value); useSyncSharedValue({ - compareDepth, + compareDepth: options?.compareDepth, + pauseSync: options?.pauseSync, setState, sharedValue, state, diff --git a/src/hooks/reanimated/useSyncSharedValue.ts b/src/hooks/reanimated/useSyncSharedValue.ts index 947f346e4cb..f1688b93379 100644 --- a/src/hooks/reanimated/useSyncSharedValue.ts +++ b/src/hooks/reanimated/useSyncSharedValue.ts @@ -6,8 +6,8 @@ import { deepEqualWorklet, shallowEqualWorklet } from '@/worklets/comparisons'; interface BaseSyncParams { /** The depth of comparison for object values. @default 'deep' */ compareDepth?: 'shallow' | 'deep'; - /** A derived value or shared value that controls whether the synchronization should be paused. */ - pauseSync?: DerivedValue | SharedValue; + /** A boolean or shared value boolean that controls whether synchronization is paused. */ + pauseSync?: DerivedValue | SharedValue | boolean; /** The JS state to be synchronized. */ state: T | undefined; } @@ -38,7 +38,7 @@ type SyncParams = SharedToStateParams | StateToSharedParams; * * @param {SyncParams} config - Configuration options for synchronization: * - `compareDepth` - The depth of comparison for object values. Default is `'deep'`. - * - `pauseSync` - A derived value or shared value that controls whether synchronization is paused. + * - `pauseSync` - A boolean or shared value boolean that controls whether synchronization is paused. * - `setState` - The setter function for the JS state (only applicable when `syncDirection` is `'sharedValueToState'`). * - `sharedValue` - The shared value to be synchronized. * - `state` - The JS state to be synchronized. @@ -58,9 +58,9 @@ type SyncParams = SharedToStateParams | StateToSharedParams; export function useSyncSharedValue({ compareDepth = 'deep', pauseSync, setState, sharedValue, state, syncDirection }: SyncParams) { useAnimatedReaction( () => { - if (pauseSync?.value) { - return false; - } + const isPaused = !!pauseSync && (typeof pauseSync === 'boolean' || (typeof pauseSync !== 'boolean' && pauseSync.value)); + if (isPaused) return false; + if (typeof sharedValue.value === 'object' && sharedValue.value !== null && typeof state === 'object' && state !== null) { const isEqual = compareDepth === 'deep' @@ -68,6 +68,7 @@ export function useSyncSharedValue({ compareDepth = 'deep', pauseSync, setSta : shallowEqualWorklet(sharedValue.value as Record, state as Record); return !isEqual; } + return sharedValue.value !== state; }, shouldSync => { diff --git a/src/hooks/useDelayedMount.ts b/src/hooks/useDelayedMount.ts new file mode 100644 index 00000000000..591a9de9bee --- /dev/null +++ b/src/hooks/useDelayedMount.ts @@ -0,0 +1,18 @@ +import { useEffect, useState } from 'react'; + +export const useDelayedMount = ({ delay = 0, skipDelayedMount = false } = {}) => { + const [shouldMount, setShouldMount] = useState(skipDelayedMount); + + useEffect(() => { + if (!skipDelayedMount) { + if (delay === 0) { + setShouldMount(true); + } else { + const timeout = setTimeout(() => setShouldMount(true), delay); + return () => clearTimeout(timeout); + } + } + }, [delay, skipDelayedMount]); + + return shouldMount; +}; diff --git a/src/languages/en_US.json b/src/languages/en_US.json index e39bec7287a..d719476b1db 100644 --- a/src/languages/en_US.json +++ b/src/languages/en_US.json @@ -595,7 +595,8 @@ "price_impact": { "long_wait": { "title": "Long wait", - "description": "Approx. %{time}" + "description": "Approx. %{time}", + "description_prefix": "Approx." }, "unknown_price": { "title": "Market Value Unknown", @@ -605,6 +606,7 @@ "small_market": "Small Market", "small_market_try_smaller_amount": "Small market — try a smaller amount", "you_are_losing": "You're losing %{priceImpact}", + "you_are_losing_prefix": "You're losing", "no_data": "Market Value Unknown", "label": "Possible loss", "no_data_subtitle": "If you decide to continue, be sure that you are satisfied with the quoted amount" @@ -1048,6 +1050,7 @@ "urgent": "Urgent", "custom": "Custom" }, + "unable_to_determine_selected_gas": "Unable to determine selected gas", "network_fee": "Est. network fee", "current_base_fee": "Current base fee", "max_base_fee": "Max base fee", @@ -1930,7 +1933,7 @@ "save": "Save", "enter_amount": "Enter Amount", "review": "Review", - "fetching_prices": "Fetching Prices", + "fetching_prices": "Fetching", "swapping": "Swapping" }, "aggregators": { @@ -1963,6 +1966,7 @@ }, "loading": "Loading...", "modal_types": { + "bridge": "Bridge", "confirm": "Confirm", "deposit": "Deposit", "receive": "Receive", diff --git a/src/model/migrations.ts b/src/model/migrations.ts index d67a414af96..76843c3bd8c 100644 --- a/src/model/migrations.ts +++ b/src/model/migrations.ts @@ -639,27 +639,6 @@ export default async function runMigrations() { migrations.push(v18); - /** - * Move favorites (yet again) from react-query to zustand with persistence - * See state/assets/userAssets.ts for the state structure - */ - const v19 = async () => { - const favorites = queryClient.getQueryData>(favoritesQueryKey); - - if (favorites) { - const favoriteAddresses: Hex[] = []; - Object.keys(favorites).forEach((address: string) => { - favoriteAddresses.push(address as Hex); - }); - - userAssetsStore.setState({ - favoriteAssetsById: new Set(favoriteAddresses), - }); - } - }; - - migrations.push(v19); - logger.sentry(`Migrations: ready to run migrations starting on number ${currentVersion}`); // await setMigrationVersion(17); if (migrations.length === currentVersion) { diff --git a/src/navigation/Routes.android.tsx b/src/navigation/Routes.android.tsx index 70b4b99cf9b..93552a50094 100644 --- a/src/navigation/Routes.android.tsx +++ b/src/navigation/Routes.android.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react/jsx-props-no-spreading */ import { NavigationContainer } from '@react-navigation/native'; import { createStackNavigator } from '@react-navigation/stack'; import React, { useContext } from 'react'; @@ -36,7 +37,6 @@ import { stackNavigationConfig, learnWebViewScreenConfig, backupSheetSizes, - dappBrowserControlPanelConfig, } from './config'; import { addWalletNavigatorPreset, @@ -88,7 +88,6 @@ import walletBackupStepTypes from '@/helpers/walletBackupStepTypes'; import AppIconUnlockSheet from '@/screens/AppIconUnlockSheet'; import { SwapScreen } from '@/__swaps__/screens/Swap/Swap'; import { useRemoteConfig } from '@/model/remoteConfig'; -import { SwapProvider } from '@/__swaps__/screens/Swap/providers/swap-provider'; import { ControlPanel } from '@/components/DappBrowser/control-panel/ControlPanel'; const Stack = createStackNavigator(); @@ -140,16 +139,6 @@ function MainOuterNavigator() { ); } -function SwapNavigator() { - return ( - - - - - - ); -} - function BSNavigator() { const remoteConfig = useRemoteConfig(); const profilesEnabled = useExperimentalFlag(PROFILES); @@ -256,7 +245,7 @@ function BSNavigator() { - {swapsV2Enabled && } + {swapsV2Enabled && } ); } diff --git a/src/navigation/Routes.ios.tsx b/src/navigation/Routes.ios.tsx index 1cc04af9e58..872f6a86357 100644 --- a/src/navigation/Routes.ios.tsx +++ b/src/navigation/Routes.ios.tsx @@ -1,3 +1,4 @@ +/* eslint-disable react/jsx-props-no-spreading */ import { NavigationContainer } from '@react-navigation/native'; import { createStackNavigator } from '@react-navigation/stack'; import React, { useContext } from 'react'; @@ -29,7 +30,6 @@ import NoNeedWCSheet from '../screens/NoNeedWCSheet'; import WalletConnectRedirectSheet from '../screens/WalletConnectRedirectSheet'; import { WalletDiagnosticsSheet } from '../screens/Diagnostics'; import WelcomeScreen from '../screens/WelcomeScreen'; -import { useTheme } from '../theme/ThemeContext'; import RegisterENSNavigator from './RegisterENSNavigator'; import { SwipeNavigator } from './SwipeNavigator'; import { @@ -101,15 +101,9 @@ import { PointsProfileProvider } from '@/screens/points/contexts/PointsProfileCo import AppIconUnlockSheet from '@/screens/AppIconUnlockSheet'; import { SwapScreen } from '@/__swaps__/screens/Swap/Swap'; import { useRemoteConfig } from '@/model/remoteConfig'; -import { SwapProvider } from '@/__swaps__/screens/Swap/providers/swap-provider'; import CheckIdentifierScreen from '@/screens/CheckIdentifierScreen'; import { ControlPanel } from '@/components/DappBrowser/control-panel/ControlPanel'; -type StackNavigatorParams = { - [Routes.SEND_SHEET]: unknown; - [Routes.MODAL_SCREEN]: unknown; -}; - const Stack = createStackNavigator(); const NativeStack = createNativeStackNavigator(); @@ -144,19 +138,8 @@ function MainStack() { ); } -function SwapsNavigator() { - return ( - - - - - - ); -} - function NativeStackNavigator() { const remoteConfig = useRemoteConfig(); - const { colors, isDarkMode } = useTheme(); const profilesEnabled = useExperimentalFlag(PROFILES); const swapsV2Enabled = useExperimentalFlag(SWAPS_V2) || remoteConfig.swaps_v2; @@ -302,7 +285,7 @@ function NativeStackNavigator() { - {swapsV2Enabled && } + {swapsV2Enabled && } ); } diff --git a/src/navigation/onNavigationStateChange.js b/src/navigation/onNavigationStateChange.js index 730732cf311..12711d532d8 100644 --- a/src/navigation/onNavigationStateChange.js +++ b/src/navigation/onNavigationStateChange.js @@ -50,6 +50,7 @@ export function onHandleStatusBar(currentState, prevState) { case Routes.DAPP_BROWSER_SCREEN: case Routes.WELCOME_SCREEN: case Routes.CHANGE_WALLET_SHEET: + case Routes.SWAP_NAVIGATOR: case Routes.SWAP: StatusBarHelper.setDarkContent(); break; diff --git a/src/navigation/routesNames.ts b/src/navigation/routesNames.ts index 306a5a022e5..f4bd6911624 100644 --- a/src/navigation/routesNames.ts +++ b/src/navigation/routesNames.ts @@ -20,10 +20,7 @@ const Routes = { CUSTOM_GAS_SHEET: 'CustomGasSheet', DAPP_BROWSER_SCREEN: 'DappBrowserScreen', DAPP_BROWSER: 'DappBrowser', - SWAP_NAVIGATOR: 'SwapNavigator', SWAP: 'Swap', - SWAP_REVIEW: 'SwapReview', - SWAP_GAS: 'SwapGas', DIAGNOSTICS_SHEET: 'DiagnosticsSheet', DISCOVER_SCREEN: 'DiscoverScreen', ENS_ADDITIONAL_RECORDS_SHEET: 'ENSAdditionalRecordsSheet', diff --git a/src/references/index.ts b/src/references/index.ts index 151ae5fb29e..590b8c61158 100644 --- a/src/references/index.ts +++ b/src/references/index.ts @@ -227,30 +227,36 @@ export const SUPPORTED_MAINNET_CHAINS: Chain[] = [mainnet, polygon, optimism, ar export const SUPPORTED_CHAINS = ({ testnetMode = false }: { testnetMode?: boolean }): Chain[] => [ - arbitrum, - arbitrumGoerli, - arbitrumSepolia, - avalanche, - avalancheFuji, + // In default order of appearance + mainnet, base, - baseSepolia, + optimism, + arbitrum, + polygon, + zora, blast, - bsc, - bscTestnet, degen, + avalanche, + bsc, + + // Testnets goerli, holesky, - mainnet, - optimism, + sepolia, + baseSepolia, optimismSepolia, - polygon, + arbitrumGoerli, + arbitrumSepolia, polygonMumbai, - sepolia, - zora, zoraSepolia, - ] - .filter(chain => (testnetMode ? !!chain.testnet : !chain.testnet)) - .map(chain => ({ ...chain, name: ChainNameDisplay[chain.id] })); + avalancheFuji, + bscTestnet, + ].reduce((chainList, chain) => { + if (testnetMode || !chain.testnet) { + chainList.push({ ...chain, name: ChainNameDisplay[chain.id] }); + } + return chainList; + }, [] as Chain[]); export const SUPPORTED_CHAIN_IDS = ({ testnetMode = false }: { testnetMode?: boolean }) => SUPPORTED_CHAINS({ testnetMode }).map(chain => chain.id); diff --git a/src/screens/WalletScreen/index.tsx b/src/screens/WalletScreen/index.tsx index 13e196c688f..1672d5bd227 100644 --- a/src/screens/WalletScreen/index.tsx +++ b/src/screens/WalletScreen/index.tsx @@ -33,6 +33,7 @@ import { AppState } from '@/redux/store'; import { addressCopiedToastAtom } from '@/recoil/addressCopiedToastAtom'; import { usePositions } from '@/resources/defi/PositionsQuery'; import styled from '@/styled-thing'; +import { UserAssetsSync } from '@/__swaps__/screens/Swap/components/UserAssetsSync'; const WalletPage = styled(Page)({ ...position.sizeAsObject('100%'), @@ -169,6 +170,9 @@ const WalletScreen: React.FC = ({ navigation, route }) => { + + {/* NOTE: The components below render null and are solely for keeping react-query and Zustand in sync */} + ); diff --git a/src/state/assets/userAssets.ts b/src/state/assets/userAssets.ts index 68a43a13b37..b7e39bd9cb5 100644 --- a/src/state/assets/userAssets.ts +++ b/src/state/assets/userAssets.ts @@ -1,39 +1,42 @@ -import { Hex } from 'viem'; - +import { Address } from 'viem'; import { ParsedSearchAsset, UniqueId, UserAssetFilter } from '@/__swaps__/types/assets'; -import { deriveAddressAndChainWithUniqueId } from '@/__swaps__/utils/address'; import { createRainbowStore } from '@/state/internal/createRainbowStore'; import { RainbowError, logger } from '@/logger'; +import { ChainId } from '@/__swaps__/types/chains'; +import { SUPPORTED_CHAIN_IDS } from '@/references'; + +const SEARCH_CACHE_MAX_ENTRIES = 50; export interface UserAssetsState { - userAssetsById: Set; - userAssets: Map; + associatedWalletAddress: Address | undefined; + chainBalances: Map; filter: UserAssetFilter; - searchQuery: string; - - favoriteAssetsById: Set; // this is chain agnostic, so we don't want to store a UniqueId here - setFavorites: (favoriteAssetIds: Hex[]) => void; - toggleFavorite: (uniqueId: UniqueId) => void; - isFavorite: (uniqueId: UniqueId) => boolean; - + inputSearchQuery: string; + searchCache: Map; + userAssets: Map; + getBalanceSortedChainList: () => ChainId[]; getFilteredUserAssetIds: () => UniqueId[]; - getUserAsset: (uniqueId: UniqueId) => ParsedSearchAsset | undefined; + getHighestValueAsset: () => ParsedSearchAsset | null; + getUserAsset: (uniqueId: UniqueId) => ParsedSearchAsset | null; + getUserAssets: () => ParsedSearchAsset[]; + getUserAssetsWithZeroPricesFilteredOut: () => Generator<[UniqueId, ParsedSearchAsset], void, unknown>; + selectUserAssets: (selector: (asset: ParsedSearchAsset) => boolean) => Generator<[UniqueId, ParsedSearchAsset], void, unknown>; + setSearchQuery: (query: string) => void; + setUserAssets: (associatedWalletAddress: Address, userAssets: Map | ParsedSearchAsset[]) => void; } // NOTE: We are serializing Map as an Array<[UniqueId, ParsedSearchAsset]> -type UserAssetsStateWithTransforms = Omit, 'userAssetIds' | 'userAssets' | 'favoriteAssetsAddresses'> & { - userAssetIds: Array; +type UserAssetsStateWithTransforms = Omit, 'chainBalances' | 'userAssets'> & { + chainBalances: Array<[ChainId, number]>; userAssets: Array<[UniqueId, ParsedSearchAsset]>; - favoriteAssetsAddresses: Array; }; function serializeUserAssetsState(state: Partial, version?: number) { try { const transformedStateToPersist: UserAssetsStateWithTransforms = { ...state, - userAssetIds: state.userAssetsById ? Array.from(state.userAssetsById) : [], + chainBalances: state.chainBalances ? Array.from(state.chainBalances.entries()) : [], userAssets: state.userAssets ? Array.from(state.userAssets.entries()) : [], - favoriteAssetsAddresses: state.favoriteAssetsById ? Array.from(state.favoriteAssetsById) : [], }; return JSON.stringify({ @@ -57,16 +60,6 @@ function deserializeUserAssetsState(serializedState: string) { const { state, version } = parsedState; - let userAssetIdsData = new Set(); - try { - if (state.userAssetIds.length) { - userAssetIdsData = new Set(state.userAssetIds); - } - } catch (error) { - logger.error(new RainbowError('Failed to convert userAssetIds from user assets storage'), { error }); - throw error; - } - let userAssetsData: Map = new Map(); try { if (state.userAssets.length) { @@ -77,93 +70,156 @@ function deserializeUserAssetsState(serializedState: string) { throw error; } - let favoritesData = new Set(); + let chainBalances = new Map(); try { - if (state.favoriteAssetsAddresses.length) { - favoritesData = new Set(state.favoriteAssetsAddresses); + if (state.chainBalances) { + chainBalances = new Map(state.chainBalances); } } catch (error) { - logger.error(new RainbowError('Failed to convert favoriteAssetsAddresses from user assets storage'), { error }); + logger.error(new RainbowError('Failed to convert chainBalances from user assets storage'), { error }); throw error; } return { state: { ...state, - userAssetIds: userAssetIdsData, + chainBalances, userAssets: userAssetsData, - favoriteAssetsAddresses: favoritesData, }, version, }; } export const userAssetsStore = createRainbowStore( - (_, get) => ({ - userAssetsById: new Set(), - userAssets: new Map(), + (set, get) => ({ + associatedWalletAddress: undefined, + chainBalances: new Map(), filter: 'all', - searchQuery: '', - favoriteAssetsById: new Set(), + inputSearchQuery: '', + searchCache: new Map(), + userAssets: new Map(), - getFilteredUserAssetIds: () => { - const { userAssetsById, userAssets, searchQuery } = get(); + getBalanceSortedChainList: () => Array.from(get().chainBalances.keys()), - // NOTE: No search query let's just return the userAssetIds - if (!searchQuery.trim()) { - return Array.from(userAssetsById.keys()); + getFilteredUserAssetIds: () => { + const { filter, inputSearchQuery } = get(); + const cachedResults = get().searchCache.get(`${inputSearchQuery}-${filter}`); + if (cachedResults) { + return cachedResults; } + return Array.from( + get().selectUserAssets(asset => (asset.price?.value ?? 0) > 0 && (filter === 'all' || asset.chainId === filter)), + ([uniqueId]) => uniqueId + ); + }, - const lowerCaseSearchQuery = searchQuery.toLowerCase(); - const keysToMatch: Partial[] = ['name', 'symbol', 'address']; - - return Object.entries(userAssets).reduce((acc, [uniqueId, asset]) => { - const combinedString = keysToMatch - .map(key => asset?.[key as keyof ParsedSearchAsset] ?? '') - .filter(Boolean) - .join(' ') - .toLowerCase(); - if (combinedString.includes(lowerCaseSearchQuery)) { - acc.push(uniqueId); - } - return acc; - }, [] as UniqueId[]); + getHighestValueAsset: () => get().userAssets.values().next().value || null, + + getUserAsset: (uniqueId: UniqueId) => get().userAssets.get(uniqueId) || null, + + getUserAssets: () => Array.from(get().userAssets.values()) || [], + + getUserAssetsWithZeroPricesFilteredOut: function* () { + yield* get().selectUserAssets(asset => (asset.price?.value ?? 0) > 0); }, - setFavorites: (addresses: Hex[]) => { - const { favoriteAssetsById } = get(); - addresses.forEach(address => { - favoriteAssetsById.add(address); - }); + selectUserAssets: function* (predicate: (asset: ParsedSearchAsset) => boolean) { + for (const [id, asset] of get().userAssets) { + if (predicate(asset)) { + yield [id, asset]; + } + } }, - toggleFavorite: (uniqueId: UniqueId) => { - const { favoriteAssetsById } = get(); - const { address } = deriveAddressAndChainWithUniqueId(uniqueId); - if (favoriteAssetsById.has(address)) { - favoriteAssetsById.delete(address); + setSearchQuery: (query: string) => { + const { filter, searchCache } = get(); + const chainIdFilter = filter === 'all' ? null : filter; + + // Check if the result is already cached + if (searchCache.has(`${query}-${filter}`)) { + set({ inputSearchQuery: query }); + return; + } + + let filteredIds: UniqueId[]; + + // Return all asset IDs if no filter or search query is applied + if (!query && !chainIdFilter) { + filteredIds = Array.from(get().getUserAssetsWithZeroPricesFilteredOut(), ([uniqueId]) => uniqueId); } else { - favoriteAssetsById.add(address); + const searchRegex = query ? new RegExp(query, 'i') : null; + const filteredIdsSet: Set = new Set(); + + // Filter by chain ID and search query + for (const [uniqueId, asset] of get().selectUserAssets( + asset => (asset.price?.value ?? 0) > 0 && (filter === 'all' || asset.chainId === filter) + )) { + if (!searchRegex || searchRegex.test(asset.name) || searchRegex.test(asset.symbol) || searchRegex.test(asset.address)) { + filteredIdsSet.add(uniqueId); + } + } + + filteredIds = Array.from(filteredIdsSet); + } + + set(state => ({ + inputSearchQuery: query, + searchCache: new Map(state.searchCache).set(`${query}-${filter}`, filteredIds), + })); + + // Prune the cache if needed + if (get().searchCache.size > SEARCH_CACHE_MAX_ENTRIES) { + const oldestKey = get().searchCache.keys().next().value; + set(state => { + const newCache = new Map(state.searchCache); + newCache.delete(oldestKey); + return { searchCache: newCache }; + }); } }, - getUserAsset: (uniqueId: UniqueId) => get().userAssets.get(uniqueId), + setUserAssets: (associatedWalletAddress: Address, userAssets: Map | ParsedSearchAsset[]) => { + const unsortedChainBalances = new Map(); + + userAssets.forEach(asset => { + const balance = Number(asset.native.balance.amount) ?? 0; + unsortedChainBalances.set(asset.chainId, (unsortedChainBalances.get(asset.chainId) || 0) + balance); + }); + + // Ensure all supported chains are in the map with a fallback value of 0 + SUPPORTED_CHAIN_IDS({ testnetMode: false }).forEach(chainId => { + if (!unsortedChainBalances.has(chainId)) { + unsortedChainBalances.set(chainId, 0); + } + }); + + // Sort the existing map by balance in descending order + const sortedEntries = Array.from(unsortedChainBalances.entries()).sort(([, balanceA], [, balanceB]) => balanceB - balanceA); + const chainBalances = new Map(); + + sortedEntries.forEach(([chainId, balance]) => chainBalances.set(chainId, balance)); - isFavorite: (uniqueId: UniqueId) => { - const { favoriteAssetsById } = get(); - const { address } = deriveAddressAndChainWithUniqueId(uniqueId); - return favoriteAssetsById.has(address); + if (userAssets instanceof Map) { + set({ associatedWalletAddress, chainBalances, searchCache: new Map(), userAssets }); + } else { + set({ + associatedWalletAddress, + chainBalances, + searchCache: new Map(), + userAssets: new Map(userAssets.map(asset => [asset.uniqueId, asset])), + }); + } }, }), { - storageKey: 'userAssets', - version: 1, + deserializer: deserializeUserAssetsState, partialize: state => ({ - userAssetsById: state.userAssetsById, + associatedWalletAddress: state.associatedWalletAddress, + chainBalances: state.chainBalances, userAssets: state.userAssets, - favoriteAssetsById: state.favoriteAssetsById, }), serializer: serializeUserAssetsState, - deserializer: deserializeUserAssetsState, + storageKey: 'userAssets', + version: 2, } ); diff --git a/src/state/swaps/swapsStore.ts b/src/state/swaps/swapsStore.ts index a10b28b9dce..4be4acaef32 100644 --- a/src/state/swaps/swapsStore.ts +++ b/src/state/swaps/swapsStore.ts @@ -1,4 +1,4 @@ -import { ParsedSearchAsset } from '@/__swaps__/types/assets'; +import { ExtendedAnimatedAssetWithColors, ParsedSearchAsset } from '@/__swaps__/types/assets'; import { ChainId } from '@/__swaps__/types/chains'; import { getDefaultSlippage } from '@/__swaps__/utils/swaps'; import { DEFAULT_CONFIG } from '@/model/remoteConfig'; @@ -6,14 +6,18 @@ import { createRainbowStore } from '@/state/internal/createRainbowStore'; import { CrosschainQuote, Quote, QuoteError, Source } from '@rainbow-me/swaps'; export interface SwapsState { + isSwapsOpen: boolean; + setIsSwapsOpen: (isSwapsOpen: boolean) => void; + // assets - inputAsset: ParsedSearchAsset | null; - outputAsset: ParsedSearchAsset | null; + inputAsset: ParsedSearchAsset | ExtendedAnimatedAssetWithColors | null; + outputAsset: ParsedSearchAsset | ExtendedAnimatedAssetWithColors | null; // quote quote: Quote | CrosschainQuote | QuoteError | null; selectedOutputChainId: ChainId; + outputSearchQuery: string; // settings flashbots: boolean; @@ -26,11 +30,16 @@ export interface SwapsState { export const swapsStore = createRainbowStore( set => ({ + isSwapsOpen: false, + setIsSwapsOpen: (isSwapsOpen: boolean) => set({ isSwapsOpen }), + inputAsset: null, // TODO: Default to their largest balance asset (or ETH mainnet if user has no assets) outputAsset: null, quote: null, + selectedOutputChainId: ChainId.mainnet, + outputSearchQuery: '', flashbots: false, setFlashbots: (flashbots: boolean) => set({ flashbots }), diff --git a/src/worklets/comparisons.ts b/src/worklets/comparisons.ts index e3bfb1e82eb..2fbe6296360 100644 --- a/src/worklets/comparisons.ts +++ b/src/worklets/comparisons.ts @@ -1,9 +1,10 @@ -/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ -export function deepEqualWorklet(obj1: Record, obj2: Record): boolean { +/* eslint-disable @typescript-eslint/no-explicit-any */ + +export function deepEqualWorklet(obj1: Record | null | undefined, obj2: Record | null | undefined): boolean { 'worklet'; // Validate object types upfront to avoid property access errors - if (typeof obj1 !== 'object' || obj1 === null || typeof obj2 !== 'object' || obj2 === null) { - // Handle basic comparison for primitives and nulls + if (typeof obj1 !== 'object' || obj1 === null || obj1 === undefined || typeof obj2 !== 'object' || obj2 === null || obj2 === undefined) { + // Simple and fast comparison for non-objects return obj1 === obj2; } // Early return if the references are the same @@ -24,11 +25,10 @@ export function deepEqualWorklet(obj1: Record, obj2: Record, obj2: Record): boolean { +export function shallowEqualWorklet(obj1: Record | null | undefined, obj2: Record | null | undefined): boolean { 'worklet'; // Validate object types upfront to avoid property access errors - if (typeof obj1 !== 'object' || obj1 === null || typeof obj2 !== 'object' || obj2 === null) { + if (typeof obj1 !== 'object' || obj1 === null || obj1 === undefined || typeof obj2 !== 'object' || obj2 === null || obj2 === undefined) { // Simple and fast comparison for non-objects return obj1 === obj2; }