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;
}