diff --git a/src/__swaps__/screens/Swap/Swap.tsx b/src/__swaps__/screens/Swap/Swap.tsx index b3b8f05f449..f04d1e23b9f 100644 --- a/src/__swaps__/screens/Swap/Swap.tsx +++ b/src/__swaps__/screens/Swap/Swap.tsx @@ -164,7 +164,8 @@ const WalletAddressObserver = () => { if (didWalletAddressChange) { runOnJS(setNewInputAsset)(); } - } + }, + [] ); return null; diff --git a/src/__swaps__/screens/Swap/components/AnimatedChainImage.ios.tsx b/src/__swaps__/screens/Swap/components/AnimatedChainImage.ios.tsx index 3ed30eca49b..7e34b1ac740 100644 --- a/src/__swaps__/screens/Swap/components/AnimatedChainImage.ios.tsx +++ b/src/__swaps__/screens/Swap/components/AnimatedChainImage.ios.tsx @@ -12,17 +12,14 @@ import AvalancheBadge from '@/assets/badges/avalanche.png'; import BlastBadge from '@/assets/badges/blast.png'; import DegenBadge from '@/assets/badges/degen.png'; import { ChainId } from '@/networks/types'; -import { useAnimatedProps } from 'react-native-reanimated'; -import { AddressOrEth } from '@/__swaps__/types/assets'; +import { useAnimatedProps, useDerivedValue } from 'react-native-reanimated'; import { AnimatedFasterImage } from '@/components/AnimatedComponents/AnimatedFasterImage'; import { DEFAULT_FASTER_IMAGE_CONFIG } from '@/components/images/ImgixImage'; import { globalColors } from '@/design-system'; -import { customChainIdsToAssetNames } from '@/__swaps__/utils/chains'; -import { AddressZero } from '@ethersproject/constants'; -import { ETH_ADDRESS } from '@/references'; import { IS_ANDROID } from '@/env'; import { PIXEL_RATIO } from '@/utils/deviceUtils'; import { useSwapContext } from '../providers/swap-provider'; +import { BLANK_BASE64_PIXEL } from '@/components/DappBrowser/constants'; const networkBadges = { [ChainId.mainnet]: Image.resolveAssetSource(EthereumBadge).uri, @@ -47,19 +44,6 @@ const networkBadges = { [ChainId.degen]: Image.resolveAssetSource(DegenBadge).uri, }; -const getCustomChainIconUrlWorklet = (chainId: ChainId, address: AddressOrEth) => { - 'worklet'; - - if (!chainId || !customChainIdsToAssetNames[chainId]) return ''; - const baseUrl = 'https://raw.githubusercontent.com/rainbow-me/assets/master/blockchains/'; - - if (address === AddressZero || address === ETH_ADDRESS) { - return `${baseUrl}${customChainIdsToAssetNames[chainId]}/info/logo.png`; - } else { - return `${baseUrl}${customChainIdsToAssetNames[chainId]}/assets/${address}/logo.png`; - } -}; - export function AnimatedChainImage({ assetType, showMainnetBadge = false, @@ -70,42 +54,28 @@ export function AnimatedChainImage({ size?: number; }) { const { internalSelectedInputAsset, internalSelectedOutputAsset } = useSwapContext(); - const asset = assetType === 'input' ? internalSelectedInputAsset : internalSelectedOutputAsset; - - const animatedIconSource = useAnimatedProps(() => { - const base = { - source: { - ...DEFAULT_FASTER_IMAGE_CONFIG, - borderRadius: IS_ANDROID ? (size / 2) * PIXEL_RATIO : size / 2, - url: '', - }, - }; - if (!asset?.value) { - if (!showMainnetBadge) { - return base; - } - base.source.url = networkBadges[ChainId.mainnet]; - return base; - } + const url = useDerivedValue(() => { + const asset = assetType === 'input' ? internalSelectedInputAsset : internalSelectedOutputAsset; + const chainId = asset?.value?.chainId; - if (networkBadges[asset.value.chainId]) { - if (!showMainnetBadge && asset.value.chainId === ChainId.mainnet) { - return base; - } - base.source.url = networkBadges[asset.value.chainId]; - return base; - } + let url = 'eth'; - const url = getCustomChainIconUrlWorklet(asset.value.chainId, asset.value.address); - if (url) { - base.source.url = url; - return base; + if (chainId !== undefined && !(!showMainnetBadge && chainId === ChainId.mainnet)) { + url = networkBadges[chainId]; } - - return base; + return url; }); + const animatedIconSource = useAnimatedProps(() => ({ + source: { + ...DEFAULT_FASTER_IMAGE_CONFIG, + base64Placeholder: BLANK_BASE64_PIXEL, + borderRadius: IS_ANDROID ? (size / 2) * PIXEL_RATIO : size / 2, + url: url.value, + }, + })); + return ( {/* ⚠️ TODO: This works but we should figure out how to type this correctly to avoid this error */} diff --git a/src/__swaps__/screens/Swap/components/AnimatedSwapCoinIcon.tsx b/src/__swaps__/screens/Swap/components/AnimatedSwapCoinIcon.tsx index 230b007c130..cb9314825e3 100644 --- a/src/__swaps__/screens/Swap/components/AnimatedSwapCoinIcon.tsx +++ b/src/__swaps__/screens/Swap/components/AnimatedSwapCoinIcon.tsx @@ -41,69 +41,54 @@ export const AnimatedSwapCoinIcon = memo(function FeedCoinIcon({ showBadge?: boolean; }) { const { isDarkMode, colors } = useTheme(); - const { internalSelectedInputAsset, internalSelectedOutputAsset } = useSwapContext(); + const asset = assetType === 'input' ? internalSelectedInputAsset : internalSelectedOutputAsset; + const size = small ? 16 : large ? 36 : 32; const didErrorForUniqueId = useSharedValue(undefined); - const size = small ? 16 : large ? 36 : 32; - // Shield animated props from unnecessary updates to avoid flicker - const coinIconUrl = useDerivedValue(() => asset.value?.icon_url ?? ''); + const coinIconUrl = useDerivedValue(() => asset.value?.icon_url || ''); const animatedIconSource = useAnimatedProps(() => { return { source: { ...DEFAULT_FASTER_IMAGE_CONFIG, borderRadius: IS_ANDROID ? (size / 2) * PIXEL_RATIO : undefined, - transitionDuration: 0, url: coinIconUrl.value, }, }; }); - const animatedCoinIconWrapperStyles = useAnimatedStyle(() => { - const showEmptyState = !asset.value?.uniqueId; - const showFallback = didErrorForUniqueId.value === asset.value?.uniqueId; - const shouldDisplay = !showFallback && !showEmptyState; - - return { - shadowColor: shouldDisplay ? (isDarkMode ? colors.shadow : asset.value?.shadowColor['light']) : 'transparent', - }; - }); - - const animatedCoinIconStyles = useAnimatedStyle(() => { - 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 visibility = useDerivedValue(() => { const showEmptyState = !asset.value?.uniqueId; + const showFallback = !showEmptyState && (didErrorForUniqueId.value === asset.value?.uniqueId || !asset.value?.icon_url); + const showCoinIcon = !showFallback && !showEmptyState; - return { - display: showEmptyState ? 'flex' : 'none', - opacity: withTiming(showEmptyState ? 1 : 0, fadeConfig), - }; + return { showCoinIcon, showEmptyState, showFallback }; }); - const animatedFallbackStyles = useAnimatedStyle(() => { - const showEmptyState = !asset.value?.uniqueId; - const showFallback = !showEmptyState && didErrorForUniqueId.value === asset.value?.uniqueId; - - return { - display: showFallback ? 'flex' : 'none', - pointerEvents: showFallback ? 'auto' : 'none', - opacity: withTiming(showFallback ? 1 : 0, fadeConfig), - }; - }); + const animatedCoinIconWrapperStyles = useAnimatedStyle(() => ({ + shadowColor: visibility.value.showCoinIcon ? (isDarkMode ? colors.shadow : asset.value?.shadowColor['light']) : 'transparent', + })); + + const animatedCoinIconStyles = useAnimatedStyle(() => ({ + display: visibility.value.showCoinIcon ? 'flex' : 'none', + pointerEvents: visibility.value.showCoinIcon ? 'auto' : 'none', + opacity: withTiming(visibility.value.showCoinIcon ? 1 : 0, fadeConfig), + })); + + const animatedEmptyStateStyles = useAnimatedStyle(() => ({ + display: visibility.value.showEmptyState ? 'flex' : 'none', + opacity: withTiming(visibility.value.showEmptyState ? 1 : 0, fadeConfig), + })); + + const animatedFallbackStyles = useAnimatedStyle(() => ({ + display: visibility.value.showFallback ? 'flex' : 'none', + pointerEvents: visibility.value.showFallback ? 'auto' : 'none', + opacity: withTiming(visibility.value.showFallback ? 1 : 0, fadeConfig), + })); return ( diff --git a/src/__swaps__/screens/Swap/components/ExchangeRateBubble.tsx b/src/__swaps__/screens/Swap/components/ExchangeRateBubble.tsx index 8f4de13dece..17a14866819 100644 --- a/src/__swaps__/screens/Swap/components/ExchangeRateBubble.tsx +++ b/src/__swaps__/screens/Swap/components/ExchangeRateBubble.tsx @@ -130,7 +130,8 @@ export const ExchangeRateBubble = () => { break; } } - } + }, + [] ); const bubbleVisibilityWrapper = useAnimatedStyle(() => { diff --git a/src/__swaps__/screens/Swap/components/GasPanel.tsx b/src/__swaps__/screens/Swap/components/GasPanel.tsx index 85167b3e817..d7534155908 100644 --- a/src/__swaps__/screens/Swap/components/GasPanel.tsx +++ b/src/__swaps__/screens/Swap/components/GasPanel.tsx @@ -428,7 +428,8 @@ export function GasPanel() { if (previous === NavigationSteps.SHOW_GAS && current !== NavigationSteps.SHOW_GAS) { runOnJS(saveCustomGasSettings)(); } - } + }, + [] ); const styles = useAnimatedStyle(() => { diff --git a/src/__swaps__/screens/Swap/components/ReviewPanel.tsx b/src/__swaps__/screens/Swap/components/ReviewPanel.tsx index 029407d9310..ecad3bde19a 100644 --- a/src/__swaps__/screens/Swap/components/ReviewPanel.tsx +++ b/src/__swaps__/screens/Swap/components/ReviewPanel.tsx @@ -1,7 +1,6 @@ import { AnimatedChainImage } from '@/__swaps__/screens/Swap/components/AnimatedChainImage'; import { ReviewGasButton } from '@/__swaps__/screens/Swap/components/GasButton'; import { GestureHandlerButton } from '@/__swaps__/screens/Swap/components/GestureHandlerButton'; -import { useNativeAssetForChain } from '@/__swaps__/screens/Swap/hooks/useNativeAssetForChain'; import { ChainNameDisplay, ChainId } from '@/networks/types'; import { useEstimatedTime } from '@/__swaps__/utils/meteorology'; import { @@ -27,11 +26,9 @@ import { useColorMode, useForegroundColor, } from '@/design-system'; -import { useAccountSettings } from '@/hooks'; import * as i18n from '@/languages'; import { useNavigation } from '@/navigation'; import Routes from '@/navigation/routesNames'; -import { getNetworkObject } from '@/networks'; import { swapsStore, useSwapsStore } from '@/state/swaps/swapsStore'; import { getNativeAssetForNetwork } from '@/utils/ethereumUtils'; import { CrosschainQuote, Quote, QuoteError } from '@rainbow-me/swaps'; @@ -64,11 +61,8 @@ const MAX_SLIPPAGE_LABEL = i18n.t(i18n.l.exchange.slippage_tolerance); const ESTIMATED_NETWORK_FEE_LABEL = i18n.t(i18n.l.gas.network_fee); const RainbowFee = () => { - const { nativeCurrency } = useAccountSettings(); const { isDarkMode } = useColorMode(); - const { isFetching, isQuoteStale, quote, internalSelectedInputAsset } = useSwapContext(); - - const { nativeAsset } = useNativeAssetForChain({ inputAsset: internalSelectedInputAsset }); + const { isFetching, isQuoteStale, quote } = useSwapContext(); const index = useSharedValue(0); const rainbowFee = useSharedValue([UNKNOWN_LABEL, UNKNOWN_LABEL]); @@ -104,7 +98,8 @@ const RainbowFee = () => { if (!current.isQuoteStale && !current.isFetching && current.quote && !(current.quote as QuoteError)?.error) { runOnJS(calculateRainbowFeeFromQuoteData)(current.quote as Quote | CrosschainQuote); } - } + }, + [] ); return ( @@ -139,15 +134,10 @@ function EstimatedArrivalTime() { function FlashbotsToggle() { const { SwapSettings } = useSwapContext(); - const inputAssetChainId = swapsStore(state => state.inputAsset?.chainId) ?? ChainId.mainnet; - const isFlashbotsEnabledForNetwork = getNetworkObject({ chainId: inputAssetChainId }).features.flashbots; - const flashbotsToggleValue = useDerivedValue(() => isFlashbotsEnabledForNetwork && SwapSettings.flashbots.value); - return ( diff --git a/src/__swaps__/screens/Swap/components/SearchInput.tsx b/src/__swaps__/screens/Swap/components/SearchInput.tsx index 8b215bf64f7..d764efacd92 100644 --- a/src/__swaps__/screens/Swap/components/SearchInput.tsx +++ b/src/__swaps__/screens/Swap/components/SearchInput.tsx @@ -70,7 +70,8 @@ export const SearchInput = ({ if (output) runOnJS(onOutputSearchQueryChange)(''); else runOnJS(onInputSearchQueryChange)(''); } - } + }, + [] ); return ( diff --git a/src/__swaps__/screens/Swap/components/SearchInputButton.tsx b/src/__swaps__/screens/Swap/components/SearchInputButton.tsx index 999b3ee9c48..30c5a60f673 100644 --- a/src/__swaps__/screens/Swap/components/SearchInputButton.tsx +++ b/src/__swaps__/screens/Swap/components/SearchInputButton.tsx @@ -9,6 +9,7 @@ import * as i18n from '@/languages'; import { THICK_BORDER_WIDTH } from '../constants'; import { useClipboard } from '@/hooks'; import { TIMING_CONFIGS } from '@/components/animations/animationConfigs'; +import { triggerHapticFeedback } from '@/screens/points/constants'; const CANCEL_LABEL = i18n.t(i18n.l.button.cancel); const CLOSE_LABEL = i18n.t(i18n.l.button.close); @@ -51,31 +52,45 @@ export const SearchInputButton = ({ return PASTE_LABEL; }); - const onPaste = useCallback(() => { - Clipboard.getString().then(text => { - // to prevent users from mistakingly pasting long ass texts when copying the wrong thing - // we slice the string to 42 which is the size of a eth address, - // no token name query search should be that big anyway - const v = text.trim().slice(0, 42); - pastedSearchInputValue.value = v; - useSwapsStore.setState({ outputSearchQuery: v }); - }); - }, []); + const onPaste = useCallback( + (isPasteDisabled: boolean) => { + if (isPasteDisabled) { + triggerHapticFeedback('notificationError'); + return; + } - const buttonVisibilityStyle = useAnimatedStyle(() => { + Clipboard.getString().then(text => { + // Slice the pasted text to the length of an ETH address + const v = text.trim().slice(0, 42); + pastedSearchInputValue.value = v; + useSwapsStore.setState({ outputSearchQuery: v }); + }); + }, + [pastedSearchInputValue] + ); + + const buttonInfo = useDerivedValue(() => { const isInputSearchFocused = inputProgress.value === NavigationSteps.SEARCH_FOCUSED; const isOutputSearchFocused = outputProgress.value === NavigationSteps.SEARCH_FOCUSED; + const isOutputTokenListFocused = outputProgress.value === NavigationSteps.TOKEN_LIST_FOCUSED; - const isVisible = - isInputSearchFocused || - isOutputSearchFocused || - (output && (internalSelectedOutputAsset.value || hasClipboardData)) || - (!output && internalSelectedInputAsset.value); + const isVisible = isInputSearchFocused || isOutputSearchFocused || output || (!output && !!internalSelectedInputAsset.value); + const isPasteDisabled = output && !internalSelectedOutputAsset.value && isOutputTokenListFocused && !hasClipboardData; + const visibleOpacity = isPasteDisabled ? 0.4 : 1; + + return { + isPasteDisabled, + isVisible, + visibleOpacity, + }; + }); + + const buttonVisibilityStyle = useAnimatedStyle(() => { return { - display: isVisible ? 'flex' : 'none', - opacity: isVisible ? withTiming(1, TIMING_CONFIGS.slowerFadeConfig) : 0, - pointerEvents: isVisible ? 'auto' : 'none', + display: buttonInfo.value.isVisible ? 'flex' : 'none', + opacity: buttonInfo.value.isVisible ? withTiming(buttonInfo.value.visibleOpacity, TIMING_CONFIGS.tabPressConfig) : 0, + pointerEvents: buttonInfo.value.isVisible ? 'auto' : 'none', }; }); @@ -88,7 +103,7 @@ export const SearchInputButton = ({ onPressWorklet={() => { 'worklet'; if (output && outputProgress.value === NavigationSteps.TOKEN_LIST_FOCUSED && !internalSelectedOutputAsset.value) { - runOnJS(onPaste)(); + runOnJS(onPaste)(buttonInfo.value.isPasteDisabled); } if (isSearchFocused.value || (output && internalSelectedOutputAsset.value) || (!output && internalSelectedInputAsset.value)) { diff --git a/src/__swaps__/screens/Swap/components/SwapActionButton.tsx b/src/__swaps__/screens/Swap/components/SwapActionButton.tsx index 4530a28db5d..4d8f2dab126 100644 --- a/src/__swaps__/screens/Swap/components/SwapActionButton.tsx +++ b/src/__swaps__/screens/Swap/components/SwapActionButton.tsx @@ -194,7 +194,8 @@ const HoldProgress = ({ holdProgress }: { holdProgress: SharedValue }) = if (current && current !== previous) { runOnJS(transformColor)(getColorValueForThemeWorklet(current, isDarkMode, true)); } - } + }, + [] ); return ( diff --git a/src/__swaps__/screens/Swap/components/SwapBottomPanel.tsx b/src/__swaps__/screens/Swap/components/SwapBottomPanel.tsx index 87298a06eb8..1bb554d9617 100644 --- a/src/__swaps__/screens/Swap/components/SwapBottomPanel.tsx +++ b/src/__swaps__/screens/Swap/components/SwapBottomPanel.tsx @@ -22,7 +22,8 @@ import { SwapActionButton } from './SwapActionButton'; import { SettingsPanel } from './SettingsPanel'; import { SPRING_CONFIGS } from '@/components/animations/animationConfigs'; import { triggerHapticFeedback } from '@/screens/points/constants'; -import { LONG_PRESS_DURATION_IN_MS } from '@/components/buttons/hold-to-authorize/constants'; + +const HOLD_TO_SWAP_DURATION_MS = 400; export function SwapBottomPanel() { const { isDarkMode } = useColorMode(); @@ -104,6 +105,7 @@ export function SwapBottomPanel() { icon={icon} iconStyle={confirmButtonIconStyle} label={label} + longPressDuration={HOLD_TO_SWAP_DURATION_MS} disabled={disabled} onPressWorklet={() => { 'worklet'; @@ -132,7 +134,7 @@ export function SwapBottomPanel() { holdProgress.value = 0; holdProgress.value = withTiming( 100, - { duration: LONG_PRESS_DURATION_IN_MS, easing: Easing.inOut(Easing.sin) }, + { duration: HOLD_TO_SWAP_DURATION_MS, easing: Easing.inOut(Easing.sin) }, isFinished => { if (isFinished) { holdProgress.value = 0; diff --git a/src/__swaps__/screens/Swap/components/SwapOutputAsset.tsx b/src/__swaps__/screens/Swap/components/SwapOutputAsset.tsx index b3546fef19c..c2fbd147ea8 100644 --- a/src/__swaps__/screens/Swap/components/SwapOutputAsset.tsx +++ b/src/__swaps__/screens/Swap/components/SwapOutputAsset.tsx @@ -79,7 +79,8 @@ function SwapOutputAmount() { v => { 'worklet'; runOnJS(setIsPasteEnabled)(v); - } + }, + [] ); return ( diff --git a/src/__swaps__/screens/Swap/components/SwapSlider.tsx b/src/__swaps__/screens/Swap/components/SwapSlider.tsx index 5919a88da4b..42581fd1087 100644 --- a/src/__swaps__/screens/Swap/components/SwapSlider.tsx +++ b/src/__swaps__/screens/Swap/components/SwapSlider.tsx @@ -2,7 +2,7 @@ import React, { useCallback, useRef } from 'react'; import { StyleSheet, View } from 'react-native'; import * as i18n from '@/languages'; -import { PanGestureHandler, TapGestureHandler, TapGestureHandlerGestureEvent } from 'react-native-gesture-handler'; +import { PanGestureHandler, State, TapGestureHandler, TapGestureHandlerGestureEvent } from 'react-native-gesture-handler'; import Animated, { interpolate, interpolateColor, @@ -204,21 +204,27 @@ export const SwapSlider = ({ overshoot.value = calculateOvershoot(overshootX, maxOverscroll); } }, - onFinish: (event, ctx: { startX: number }) => { + onFinish: (event, ctx: { exceedsMax?: boolean; startX: number }) => { const onFinished = () => { overshoot.value = withSpring(0, SPRING_CONFIGS.sliderConfig); + if (xPercentage.value >= 0.995) { if (isQuoteStale.value === 1) { runOnJS(onChangeWrapper)(1); } sliderXPosition.value = withSpring(width, SPRING_CONFIGS.snappySpringConfig); + } else if (event.state === State.FAILED) { + SwapInputController.quoteFetchingInterval.start(); + return; } else if (xPercentage.value < 0.005) { runOnJS(onChangeWrapper)(0); sliderXPosition.value = withSpring(0, SPRING_CONFIGS.snappySpringConfig); isQuoteStale.value = 0; isFetching.value = false; - } else { + } else if (ctx.startX !== sliderXPosition.value) { runOnJS(onChangeWrapper)(xPercentage.value); + } else { + SwapInputController.quoteFetchingInterval.start(); } }; @@ -392,9 +398,15 @@ export const SwapSlider = ({ }); return ( - + - + @@ -419,8 +431,8 @@ export const SwapSlider = ({ {MAX_LABEL} diff --git a/src/__swaps__/screens/Swap/components/TokenList/TokenToBuyList.tsx b/src/__swaps__/screens/Swap/components/TokenList/TokenToBuyList.tsx index fd78a631b8c..407e41d7fc6 100644 --- a/src/__swaps__/screens/Swap/components/TokenList/TokenToBuyList.tsx +++ b/src/__swaps__/screens/Swap/components/TokenList/TokenToBuyList.tsx @@ -1,3 +1,4 @@ +import { FlatList } from 'react-native'; import { COIN_ROW_WITH_PADDING_HEIGHT, CoinRow } from '@/__swaps__/screens/Swap/components/CoinRow'; import { ListEmpty } from '@/__swaps__/screens/Swap/components/TokenList/ListEmpty'; import { AssetToBuySectionId, useSearchCurrencyLists } from '@/__swaps__/screens/Swap/hooks/useSearchCurrencyLists'; @@ -16,9 +17,7 @@ import * as i18n from '@/languages'; import { userAssetsStore } from '@/state/assets/userAssets'; import { swapsStore } from '@/state/swaps/swapsStore'; import { DEVICE_WIDTH } from '@/utils/deviceUtils'; -import { FlashList } from '@shopify/flash-list'; -import React, { ComponentType, forwardRef, memo, useCallback, useMemo } from 'react'; -import { ScrollViewProps } from 'react-native'; +import React, { memo, useCallback, useMemo } from 'react'; import Animated, { runOnUI, useAnimatedProps, useAnimatedStyle, withTiming } from 'react-native-reanimated'; import { EXPANDED_INPUT_HEIGHT, FOCUSED_INPUT_HEIGHT } from '../../constants'; import { ChainSelection } from './ChainSelection'; @@ -73,15 +72,25 @@ export type HeaderItem = { listItemType: 'header'; id: AssetToBuySectionId; data export type CoinRowItem = SearchAsset & { listItemType: 'coinRow'; sectionId: AssetToBuySectionId }; export type TokenToBuyListItem = HeaderItem | CoinRowItem; -const ScrollViewWithRef = forwardRef(function ScrollViewWithRef(props, ref) { - const { outputProgress } = useSwapContext(); - const animatedListProps = useAnimatedProps(() => { - const isFocused = outputProgress.value === 2; - return { scrollIndicatorInsets: { bottom: 28 + (isFocused ? EXPANDED_INPUT_HEIGHT - FOCUSED_INPUT_HEIGHT : 0) } }; - }); - // eslint-disable-next-line react/jsx-props-no-spreading - return ; -}); +const getItemLayout = (data: ArrayLike | null | undefined, index: number) => { + if (!data) return { length: 0, offset: 0, index }; + + const item = data[index]; + const length = item?.listItemType === 'header' ? BUY_LIST_HEADER_HEIGHT : COIN_ROW_WITH_PADDING_HEIGHT; + + // Count headers up to this index + let headerCount = 0; + for (let i = 0; i < index; i++) { + if (data[i]?.listItemType === 'header') { + headerCount += 1; + } + } + + const coinRowCount = index - headerCount; + const offset = headerCount * BUY_LIST_HEADER_HEIGHT + coinRowCount * COIN_ROW_WITH_PADDING_HEIGHT; + + return { length, offset, index }; +}; export const TokenToBuyList = () => { const { internalSelectedInputAsset, internalSelectedOutputAsset, isFetching, isQuoteStale, outputProgress, setAsset } = useSwapContext(); @@ -130,12 +139,12 @@ export const TokenToBuyList = () => { return { height: bottomPadding }; }); - const averageItemSize = useMemo(() => { - const numberOfHeaders = sections.filter(section => section.listItemType === 'header').length; - const numberOfCoinRows = sections.filter(section => section.listItemType === 'coinRow').length; - const totalHeight = numberOfHeaders * BUY_LIST_HEADER_HEIGHT + numberOfCoinRows * COIN_ROW_WITH_PADDING_HEIGHT; - return totalHeight / (numberOfHeaders + numberOfCoinRows); - }, [sections]); + const animatedListProps = useAnimatedProps(() => { + const isFocused = outputProgress.value === 2; + return { + scrollIndicatorInsets: { bottom: 28 + (isFocused ? EXPANDED_INPUT_HEIGHT - FOCUSED_INPUT_HEIGHT : 0) }, + }; + }); if (isLoading) return null; @@ -145,18 +154,14 @@ export const TokenToBuyList = () => { return ( - } ListFooterComponent={} ListHeaderComponent={} contentContainerStyle={{ paddingBottom: 16 }} - // For some reason shallow copying the list data allows FlashList to more quickly pick up changes - data={sections.slice(0)} - estimatedFirstItemOffset={BUY_LIST_HEADER_HEIGHT} - estimatedItemSize={averageItemSize || undefined} - estimatedListSize={{ height: EXPANDED_INPUT_HEIGHT - 77, width: DEVICE_WIDTH - 24 }} - getItemType={item => item.listItemType} + data={sections} + getItemLayout={getItemLayout} keyExtractor={item => `${item.listItemType}-${item.listItemType === 'coinRow' ? item.uniqueId : item.id}`} renderItem={({ item }) => { if (item.listItemType === 'header') { @@ -180,7 +185,15 @@ export const TokenToBuyList = () => { /> ); }} - renderScrollComponent={ScrollViewWithRef as ComponentType} + renderScrollComponent={props => { + return ( + + ); + }} style={{ height: EXPANDED_INPUT_HEIGHT - 77, width: DEVICE_WIDTH - 24 }} /> diff --git a/src/__swaps__/screens/Swap/components/TokenList/TokenToSellList.tsx b/src/__swaps__/screens/Swap/components/TokenList/TokenToSellList.tsx index f28acfd8dfb..38e95e2475a 100644 --- a/src/__swaps__/screens/Swap/components/TokenList/TokenToSellList.tsx +++ b/src/__swaps__/screens/Swap/components/TokenList/TokenToSellList.tsx @@ -1,3 +1,4 @@ +import { FlatList } from 'react-native'; import { COIN_ROW_WITH_PADDING_HEIGHT, CoinRow } from '@/__swaps__/screens/Swap/components/CoinRow'; import { ListEmpty } from '@/__swaps__/screens/Swap/components/TokenList/ListEmpty'; import { useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider'; @@ -10,7 +11,6 @@ import * as i18n from '@/languages'; import { userAssetsStore } from '@/state/assets/userAssets'; import { swapsStore } from '@/state/swaps/swapsStore'; import { DEVICE_WIDTH } from '@/utils/deviceUtils'; -import { FlashList } from '@shopify/flash-list'; import React, { useCallback, useMemo } from 'react'; import Animated, { runOnUI, useAnimatedProps, useAnimatedStyle } from 'react-native-reanimated'; import { EXPANDED_INPUT_HEIGHT, FOCUSED_INPUT_HEIGHT } from '../../constants'; @@ -22,6 +22,12 @@ const isInitialInputAssetNull = () => { return !swapsStore.getState().inputAsset; }; +const getItemLayout = (_: ArrayLike | null | undefined, index: number) => ({ + length: COIN_ROW_WITH_PADDING_HEIGHT, + offset: COIN_ROW_WITH_PADDING_HEIGHT * index, + index, +}); + export const TokenToSellList = () => { const skipDelayedMount = useMemo(() => isInitialInputAssetNull(), []); const shouldMount = useDelayedMount({ skipDelayedMount }); @@ -80,16 +86,13 @@ const TokenToSellListComponent = () => { }); return ( - } ListFooterComponent={} ListHeaderComponent={} contentContainerStyle={{ paddingBottom: 16 }} - // For some reason shallow copying the list data allows FlashList to more quickly pick up changes - data={userAssetIds.slice(0)} - estimatedFirstItemOffset={SELL_LIST_HEADER_HEIGHT} - estimatedItemSize={COIN_ROW_WITH_PADDING_HEIGHT} - estimatedListSize={{ height: EXPANDED_INPUT_HEIGHT - 77, width: DEVICE_WIDTH - 24 }} + data={userAssetIds} + getItemLayout={getItemLayout} keyExtractor={uniqueId => uniqueId} renderItem={({ item: uniqueId }) => { return handleSelectToken(asset)} output={false} uniqueId={uniqueId} />; diff --git a/src/__swaps__/screens/Swap/hooks/useNativeAssetForChain.ts b/src/__swaps__/screens/Swap/hooks/useNativeAssetForChain.ts index 95c886d31d0..5d124bde586 100644 --- a/src/__swaps__/screens/Swap/hooks/useNativeAssetForChain.ts +++ b/src/__swaps__/screens/Swap/hooks/useNativeAssetForChain.ts @@ -21,11 +21,12 @@ export const useNativeAssetForChain = ({ inputAsset }: { inputAsset: SharedValue useAnimatedReaction( () => chainId.value, - (currentChainId, previoudChainId) => { - if (currentChainId !== previoudChainId) { + (currentChainId, previousChainId) => { + if (currentChainId !== previousChainId) { runOnJS(getNativeAssetForNetwork)(currentChainId); } - } + }, + [] ); return { diff --git a/src/__swaps__/screens/Swap/hooks/useSearchCurrencyLists.ts b/src/__swaps__/screens/Swap/hooks/useSearchCurrencyLists.ts index a1ba4437ff2..87815861728 100644 --- a/src/__swaps__/screens/Swap/hooks/useSearchCurrencyLists.ts +++ b/src/__swaps__/screens/Swap/hooks/useSearchCurrencyLists.ts @@ -259,23 +259,35 @@ export function useSearchCurrencyLists() { }); // 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. + // a moment to update before the heavy re-renders kicked off by these state changes occur. This is used + // when the user changes the selected chain in the output token list. const debouncedStateSet = useDebouncedCallback(setState, 20, { leading: false, trailing: true }); + // This is used when the input asset is changed. To avoid a heavy re-render while the input bubble is collapsing, + // we use a longer delay as in this case the list is not visible, so it doesn't need to react immediately. + const changedInputAssetStateSet = useDebouncedCallback(setState, 600, { leading: false, trailing: true }); + useAnimatedReaction( () => ({ isCrosschainSearch: assetToSell.value ? assetToSell.value.chainId !== selectedOutputChainId.value : false, toChainId: selectedOutputChainId.value ?? ChainId.mainnet, }), (current, previous) => { - if (previous && (current.isCrosschainSearch !== previous.isCrosschainSearch || current.toChainId !== previous.toChainId)) { - runOnJS(debouncedStateSet)({ - fromChainId: assetToSell.value ? assetToSell.value.chainId ?? ChainId.mainnet : undefined, - isCrosschainSearch: current.isCrosschainSearch, - toChainId: current.toChainId, - }); - } - } + const toChainIdChanged = previous && current.toChainId !== previous.toChainId; + const isCrosschainSearchChanged = previous && current.isCrosschainSearch !== previous.isCrosschainSearch; + + if (!toChainIdChanged && !isCrosschainSearchChanged) return; + + const newState = { + fromChainId: assetToSell.value ? assetToSell.value.chainId ?? ChainId.mainnet : undefined, + isCrosschainSearch: current.isCrosschainSearch, + toChainId: current.toChainId, + }; + + if (toChainIdChanged) runOnJS(debouncedStateSet)(newState); + else if (isCrosschainSearchChanged) runOnJS(changedInputAssetStateSet)(newState); + }, + [] ); const selectTopSearchResults = useCallback( diff --git a/src/__swaps__/screens/Swap/hooks/useSwapInputsController.ts b/src/__swaps__/screens/Swap/hooks/useSwapInputsController.ts index 1953277fb87..3f07c1ec368 100644 --- a/src/__swaps__/screens/Swap/hooks/useSwapInputsController.ts +++ b/src/__swaps__/screens/Swap/hooks/useSwapInputsController.ts @@ -32,6 +32,7 @@ import { useCallback } from 'react'; import { SharedValue, runOnJS, runOnUI, useAnimatedReaction, useDerivedValue, useSharedValue, withSpring } from 'react-native-reanimated'; import { useDebouncedCallback } from 'use-debounce'; import { NavigationSteps } from './useSwapNavigation'; +import { deepEqualWorklet } from '@/worklets/comparisons'; const REMOTE_CONFIG = getRemoteConfig(); @@ -701,7 +702,8 @@ export function useSwapInputsController({ if (areBothAssetsSet) { fetchQuoteAndAssetPrices(); } - } + }, + [] ); /** @@ -722,7 +724,8 @@ export function useSwapInputsController({ }); fetchQuoteAndAssetPrices(); } - } + }, + [] ); /** @@ -752,7 +755,8 @@ export function useSwapInputsController({ } } } - } + }, + [] ); /** @@ -771,11 +775,11 @@ export function useSwapInputsController({ values: inputValues.value, }), (current, previous) => { - if (previous && current !== previous) { + if (previous && !deepEqualWorklet(current, previous)) { // Handle updating input values based on the input method if (inputMethod.value === 'slider' && internalSelectedInputAsset.value && current.sliderXPosition !== previous.sliderXPosition) { // If the slider position changes - if (percentageToSwap.value === 0) { + if (current.sliderXPosition === 0) { resetValuesToZeroWorklet({ updateSlider: false }); } else { // If the change set the slider position to > 0 @@ -870,7 +874,8 @@ export function useSwapInputsController({ } } } - } + }, + [] ); return { debouncedFetchQuote, diff --git a/src/__swaps__/screens/Swap/hooks/useSwapTextStyles.ts b/src/__swaps__/screens/Swap/hooks/useSwapTextStyles.ts index 6404bb425e9..92facea2c77 100644 --- a/src/__swaps__/screens/Swap/hooks/useSwapTextStyles.ts +++ b/src/__swaps__/screens/Swap/hooks/useSwapTextStyles.ts @@ -87,7 +87,10 @@ export function useSwapTextStyles({ }); const isOutputZero = useDerivedValue(() => { - const isZero = !internalSelectedOutputAsset.value || equalWorklet(inputValues.value.outputAmount, 0); + const isZero = + !internalSelectedOutputAsset.value || + (inputValues.value.outputAmount === 0 && inputMethod.value !== 'slider') || + (inputMethod.value === 'slider' && equalWorklet(inputValues.value.outputAmount, 0)); return isZero; }); diff --git a/src/__swaps__/screens/Swap/providers/SyncSwapStateAndSharedValues.tsx b/src/__swaps__/screens/Swap/providers/SyncSwapStateAndSharedValues.tsx index 090ec7a11a7..843fcc616d6 100644 --- a/src/__swaps__/screens/Swap/providers/SyncSwapStateAndSharedValues.tsx +++ b/src/__swaps__/screens/Swap/providers/SyncSwapStateAndSharedValues.tsx @@ -1,26 +1,28 @@ +import BigNumber from 'bignumber.js'; import { divWorklet, greaterThanWorklet, + isNumberStringWorklet, lessThanOrEqualToWorklet, lessThanWorklet, mulWorklet, powWorklet, subWorklet, + sumWorklet, toFixedWorklet, toScaledIntegerWorklet, } from '@/__swaps__/safe-math/SafeMath'; import { ExtendedAnimatedAssetWithColors } from '@/__swaps__/types/assets'; import { ChainId } from '@/networks/types'; -import { add } from '@/__swaps__/utils/numbers'; import { ParsedAddressAsset } from '@/entities'; import { useUserNativeNetworkAsset } from '@/resources/assets/useUserAsset'; import { CrosschainQuote, Quote, QuoteError } from '@rainbow-me/swaps'; +import { deepEqualWorklet } from '@/worklets/comparisons'; import { debounce } from 'lodash'; -import { useEffect } from 'react'; -import { runOnJS, useAnimatedReaction, useSharedValue } from 'react-native-reanimated'; -import { formatUnits } from 'viem'; +import { useEffect, useMemo } from 'react'; +import { runOnJS, runOnUI, useAnimatedReaction, useSharedValue } from 'react-native-reanimated'; import { create } from 'zustand'; -import { calculateGasFee } from '../hooks/useEstimatedGasFee'; +import { GasSettings } from '../hooks/useCustomGas'; import { useSelectedGas } from '../hooks/useSelectedGas'; import { useSwapEstimatedGasLimit } from '../hooks/useSwapEstimatedGasLimit'; import { useSwapContext } from './swap-provider'; @@ -62,7 +64,7 @@ export const SyncQuoteSharedValuesToState = () => { // needed and was previously resulting in errors in useEstimatedGasFee. if (isSwappingMoreThanAvailableBalance) return; - if (!previous || current !== previous) { + if (!deepEqualWorklet(current, previous)) { runOnJS(setInternalSyncedSwapStore)({ assetToBuy: assetToBuy.value, assetToSell: assetToSell.value, @@ -70,18 +72,48 @@ export const SyncQuoteSharedValuesToState = () => { quote: current, }); } - } + }, + [] ); return null; }; -const getHasEnoughFundsForGas = (quote: Quote, gasFee: string, nativeNetworkAsset: ParsedAddressAsset | undefined) => { +export function calculateGasFeeWorklet(gasSettings: GasSettings, gasLimit: string) { + 'worklet'; + const amount = gasSettings.isEIP1559 ? sumWorklet(gasSettings.maxBaseFee, gasSettings.maxPriorityFee || '0') : gasSettings.gasPrice; + return mulWorklet(gasLimit, amount); +} + +export function formatUnitsWorklet(value: string, decimals: number) { + 'worklet'; + let display = value; + const negative = display.startsWith('-'); + if (negative) display = display.slice(1); + + display = display.padStart(decimals, '0'); + + // eslint-disable-next-line prefer-const + let [integer, fraction] = [display.slice(0, display.length - decimals), display.slice(display.length - decimals)]; + fraction = fraction.replace(/(0+)$/, ''); + return `${negative ? '-' : ''}${integer || '0'}${fraction ? `.${fraction}` : ''}`; +} + +const getHasEnoughFundsForGasWorklet = ({ + gasFee, + nativeNetworkAsset, + quoteValue, +}: { + gasFee: string; + nativeNetworkAsset: ParsedAddressAsset | undefined; + quoteValue: string; +}) => { + 'worklet'; if (!nativeNetworkAsset) return false; - const userBalance = nativeNetworkAsset.balance?.amount || '0'; - const quoteValue = quote.value?.toString() || '0'; - const totalNativeSpentInTx = formatUnits(BigInt(add(quoteValue, gasFee)), nativeNetworkAsset.decimals); + const userBalance = nativeNetworkAsset.balance?.amount || '0'; + const safeGasFee = isNumberStringWorklet(gasFee) ? gasFee : '0'; + const totalNativeSpentInTx = formatUnitsWorklet(sumWorklet(quoteValue, safeGasFee), nativeNetworkAsset.decimals); return lessThanOrEqualToWorklet(totalNativeSpentInTx, userBalance); }; @@ -89,7 +121,8 @@ const getHasEnoughFundsForGas = (quote: Quote, gasFee: string, nativeNetworkAsse export function SyncGasStateToSharedValues() { const { hasEnoughFundsForGas, internalSelectedInputAsset } = useSwapContext(); - const { assetToSell, chainId = ChainId.mainnet, quote } = useSyncedSwapQuoteStore(); + const initialChainId = useMemo(() => internalSelectedInputAsset.value?.chainId || ChainId.mainnet, [internalSelectedInputAsset]); + const { assetToSell, chainId = initialChainId, quote } = useSyncedSwapQuoteStore(); const gasSettings = useSelectedGas(chainId); const { data: userNativeNetworkAsset, isLoading: isLoadingNativeNetworkAsset } = useUserNativeNetworkAsset(chainId); @@ -122,35 +155,44 @@ export function SyncGasStateToSharedValues() { }); } } - } + }, + [] ); useEffect(() => { - hasEnoughFundsForGas.value = undefined; - if (!gasSettings || !estimatedGasLimit || !quote || 'error' in quote || isLoadingNativeNetworkAsset) return; + const safeQuoteValue = quote && !('error' in quote) && quote.value ? new BigNumber(quote.value.toString()).toFixed() : '0'; + + runOnUI(() => { + hasEnoughFundsForGas.value = undefined; + if (!gasSettings || !estimatedGasLimit || !quote || 'error' in quote || isLoadingNativeNetworkAsset) return; - if (!userNativeNetworkAsset) { - hasEnoughFundsForGas.value = false; - return; - } + if (!userNativeNetworkAsset) { + hasEnoughFundsForGas.value = false; + return; + } - const gasFee = calculateGasFee(gasSettings, estimatedGasLimit); + const gasFee = calculateGasFeeWorklet(gasSettings, estimatedGasLimit); - const nativeGasFee = divWorklet(gasFee, powWorklet(10, userNativeNetworkAsset.decimals)); + const nativeGasFee = divWorklet(gasFee, powWorklet(10, userNativeNetworkAsset.decimals)); - const isEstimateOutsideRange = !!( - gasFeeRange.value && - (lessThanWorklet(nativeGasFee, gasFeeRange.value[0]) || greaterThanWorklet(nativeGasFee, gasFeeRange.value[1])) - ); + const isEstimateOutsideRange = !!( + gasFeeRange.value && + (lessThanWorklet(nativeGasFee, gasFeeRange.value[0]) || greaterThanWorklet(nativeGasFee, gasFeeRange.value[1])) + ); - // If the gas fee range hasn't been set or the estimated fee is outside the range, calculate the range based on the gas fee - if (nativeGasFee && (!gasFeeRange.value || isEstimateOutsideRange)) { - const lowerBound = toFixedWorklet(mulWorklet(nativeGasFee, 1 - BUFFER_RATIO), userNativeNetworkAsset.decimals); - const upperBound = toFixedWorklet(mulWorklet(nativeGasFee, 1 + BUFFER_RATIO), userNativeNetworkAsset.decimals); - gasFeeRange.value = [lowerBound, upperBound]; - } + // If the gas fee range hasn't been set or the estimated fee is outside the range, calculate the range based on the gas fee + if (nativeGasFee && (!gasFeeRange.value || isEstimateOutsideRange)) { + const lowerBound = toFixedWorklet(mulWorklet(nativeGasFee, 1 - BUFFER_RATIO), userNativeNetworkAsset.decimals); + const upperBound = toFixedWorklet(mulWorklet(nativeGasFee, 1 + BUFFER_RATIO), userNativeNetworkAsset.decimals); + gasFeeRange.value = [lowerBound, upperBound]; + } - hasEnoughFundsForGas.value = getHasEnoughFundsForGas(quote, gasFee, userNativeNetworkAsset); + hasEnoughFundsForGas.value = getHasEnoughFundsForGasWorklet({ + gasFee, + nativeNetworkAsset: userNativeNetworkAsset, + quoteValue: safeQuoteValue, + }); + })(); return () => { hasEnoughFundsForGas.value = undefined; @@ -160,6 +202,7 @@ export function SyncGasStateToSharedValues() { gasFeeRange, gasSettings, hasEnoughFundsForGas, + internalSelectedInputAsset, quote, userNativeNetworkAsset, isLoadingNativeNetworkAsset, diff --git a/src/__swaps__/screens/Swap/providers/swap-provider.tsx b/src/__swaps__/screens/Swap/providers/swap-provider.tsx index 7c069fc1a20..4740f3b61c0 100644 --- a/src/__swaps__/screens/Swap/providers/swap-provider.tsx +++ b/src/__swaps__/screens/Swap/providers/swap-provider.tsx @@ -60,6 +60,7 @@ import { useConnectedToHardhatStore } from '@/state/connectedToHardhat'; const swapping = i18n.t(i18n.l.swap.actions.swapping); const holdToSwap = i18n.t(i18n.l.swap.actions.hold_to_swap); +const holdToBridge = i18n.t(i18n.l.swap.actions.hold_to_bridge); const done = i18n.t(i18n.l.button.done); const enterAmount = i18n.t(i18n.l.swap.actions.enter_amount); const review = i18n.t(i18n.l.swap.actions.review); @@ -165,6 +166,8 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { const slippage = useSharedValue(getDefaultSlippageWorklet(initialSelectedInputAsset?.chainId || ChainId.mainnet, getRemoteConfig())); + const hasEnoughFundsForGas = useSharedValue(undefined); + const SwapInputController = useSwapInputsController({ focusedInput, lastTypedInput, @@ -664,12 +667,18 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { }; }, []); - const hasEnoughFundsForGas = useSharedValue(undefined); + // Stop auto-fetching if there is a quote error or no input asset balance useAnimatedReaction( - () => isFetching.value, - fetching => { - if (fetching) hasEnoughFundsForGas.value = undefined; - } + () => + SwapWarning.swapWarning.value.type === SwapWarningType.no_quote_available || + SwapWarning.swapWarning.value.type === SwapWarningType.no_route_found || + (internalSelectedInputAsset.value && equalWorklet(internalSelectedInputAsset.value.maxSwappableAmount, '0')), + (shouldStop, previous) => { + if (shouldStop && previous === false) { + SwapInputController.quoteFetchingInterval.stop(); + } + }, + [] ); const confirmButtonProps: SwapContextType['confirmButtonProps'] = useDerivedValue(() => { @@ -690,51 +699,55 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { return { label: selectToken, disabled: true, type: 'hold' }; } + const sellAsset = internalSelectedInputAsset.value; + const enoughFundsForSwap = + sellAsset && + !equalWorklet(sellAsset.maxSwappableAmount, '0') && + lessThanOrEqualToWorklet(SwapInputController.inputValues.value.inputAmount, sellAsset.maxSwappableAmount); + + if (!enoughFundsForSwap && hasEnoughFundsForGas.value !== undefined) { + return { label: insufficientFunds, disabled: true, type: 'hold' }; + } + const isInputZero = equalWorklet(SwapInputController.inputValues.value.inputAmount, 0); const isOutputZero = equalWorklet(SwapInputController.inputValues.value.outputAmount, 0); const userHasNotEnteredAmount = SwapInputController.inputMethod.value !== 'slider' && isInputZero && isOutputZero; - const userHasNotMovedSlider = SwapInputController.inputMethod.value === 'slider' && SwapInputController.percentageToSwap.value === 0; if (userHasNotEnteredAmount || userHasNotMovedSlider) { return { label: enterAmount, disabled: true, opacity: 1, type: 'hold' }; } - if ( - [SwapWarningType.no_quote_available, SwapWarningType.no_route_found, SwapWarningType.insufficient_liquidity].includes( - SwapWarning.swapWarning.value.type - ) - ) { - return { icon: '􀕹', label: review, disabled: true, type: 'hold' }; - } - - const sellAsset = internalSelectedInputAsset.value; - const enoughFundsForSwap = - sellAsset && lessThanOrEqualToWorklet(SwapInputController.inputValues.value.inputAmount, sellAsset.maxSwappableAmount); - - if (!enoughFundsForSwap) { - return { label: insufficientFunds, disabled: true, type: 'hold' }; - } + const holdLabel = swapInfo.value.isBridging ? holdToBridge : holdToSwap; + const reviewLabel = SwapSettings.degenMode.value ? holdLabel : review; const isQuoteError = quote.value && 'error' in quote.value; const isLoadingGas = !isQuoteError && hasEnoughFundsForGas.value === undefined; const isReviewSheetOpen = configProgress.value === NavigationSteps.SHOW_REVIEW || SwapSettings.degenMode.value; - if ((isFetching.value || isLoadingGas) && !isQuoteError) { - const disabled = (isReviewSheetOpen && (isFetching.value || isLoadingGas)) || !quote.value; + const isStale = + !!isQuoteStale.value && + (SwapInputController.inputMethod.value !== 'slider' || sliderPressProgress.value === SLIDER_COLLAPSED_HEIGHT / SLIDER_HEIGHT); + + if ((isFetching.value || isLoadingGas || isStale) && !isQuoteError) { + const disabled = (isReviewSheetOpen && (isFetching.value || isLoadingGas || isStale)) || !quote.value; const buttonType = isReviewSheetOpen ? 'hold' : 'tap'; return { label: fetchingPrices, disabled, type: buttonType }; } - const reviewLabel = SwapSettings.degenMode.value ? holdToSwap : review; + const quoteUnavailable = [ + SwapWarningType.no_quote_available, + SwapWarningType.no_route_found, + SwapWarningType.insufficient_liquidity, + ].includes(SwapWarning.swapWarning.value.type); - if (isQuoteError) { + if (quoteUnavailable || isQuoteError) { const icon = isReviewSheetOpen ? undefined : '􀕹'; return { icon, label: isReviewSheetOpen ? quoteError : reviewLabel, disabled: true, type: 'hold' }; } - if (!hasEnoughFundsForGas.value) { + if (hasEnoughFundsForGas.value === false) { const nativeCurrency = RainbowNetworkByChainId[sellAsset?.chainId || ChainId.mainnet].nativeCurrency; return { label: `${insufficient} ${nativeCurrency.symbol}`, @@ -744,23 +757,15 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { } if (isReviewSheetOpen) { - return { icon: '􀎽', label: holdToSwap, disabled: false, type: 'hold' }; + const isDraggingSlider = !!isQuoteStale.value && sliderPressProgress.value !== SLIDER_COLLAPSED_HEIGHT / SLIDER_HEIGHT; + return { icon: '􀎽', label: holdLabel, disabled: isDraggingSlider, type: 'hold' }; } return { icon: '􀕹', label: reviewLabel, disabled: false, type: 'tap' }; }); const confirmButtonIconStyle = useAnimatedStyle(() => { - const isInputZero = equalWorklet(SwapInputController.inputValues.value.inputAmount, 0); - const isOutputZero = equalWorklet(SwapInputController.inputValues.value.outputAmount, 0); - - const sliderCondition = - SwapInputController.inputMethod.value === 'slider' && - (SwapInputController.percentageToSwap.value === 0 || isInputZero || isOutputZero); - const inputCondition = SwapInputController.inputMethod.value !== 'slider' && (isInputZero || isOutputZero) && !isFetching.value; - - const shouldHide = sliderCondition || inputCondition; - + const shouldHide = !confirmButtonProps.value.icon; return { display: shouldHide ? 'none' : 'flex', }; diff --git a/src/__swaps__/utils/swaps.ts b/src/__swaps__/utils/swaps.ts index 10f55dae1ad..04d31a75fdf 100644 --- a/src/__swaps__/utils/swaps.ts +++ b/src/__swaps__/utils/swaps.ts @@ -252,7 +252,7 @@ export function niceIncrementFormatter({ const niceIncrement = findNiceIncrement(inputAssetBalance); const incrementDecimalPlaces = countDecimalPlaces(niceIncrement); - if (percentageToSwap === 0 || equalWorklet(niceIncrement, 0)) return '0'; + if (percentageToSwap === 0 || equalWorklet(niceIncrement, 0)) return 0; if (percentageToSwap === 0.25) { const amount = mulWorklet(inputAssetBalance, 0.25); return valueBasedDecimalFormatter({ diff --git a/src/components/DappBrowser/constants.ts b/src/components/DappBrowser/constants.ts index 482bfd23962..e3bdae2b9bd 100644 --- a/src/components/DappBrowser/constants.ts +++ b/src/components/DappBrowser/constants.ts @@ -14,7 +14,7 @@ export const USER_AGENT = { }; export const USER_AGENT_APPLICATION_NAME = 'Rainbow'; -const BLANK_BASE64_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; +export const BLANK_BASE64_PIXEL = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII='; export const TAB_SCREENSHOT_FASTER_IMAGE_CONFIG: Partial = { // This placeholder avoids an occasional loading spinner flash diff --git a/src/components/animations/AnimatedSpinner.tsx b/src/components/animations/AnimatedSpinner.tsx index 2931e958c55..94647785d39 100644 --- a/src/components/animations/AnimatedSpinner.tsx +++ b/src/components/animations/AnimatedSpinner.tsx @@ -68,7 +68,8 @@ export const AnimatedSpinner = ({ }); } } - } + }, + [] ); return ( diff --git a/src/hooks/useClipboard.ts b/src/hooks/useClipboard.ts index 5d01aedd768..ede5478387d 100644 --- a/src/hooks/useClipboard.ts +++ b/src/hooks/useClipboard.ts @@ -1,5 +1,5 @@ import Clipboard from '@react-native-clipboard/clipboard'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useLayoutEffect, useState } from 'react'; import useAppState from './useAppState'; import { deviceUtils } from '@/utils'; @@ -27,7 +27,7 @@ export default function useClipboard() { ); // Get initial clipboardData - useEffect(() => { + useLayoutEffect(() => { if (deviceUtils.isIOS14) { checkClipboard(); } else if (!deviceUtils.hasClipboardProtection) { @@ -60,7 +60,7 @@ export default function useClipboard() { clipboard: clipboardData, enablePaste: deviceUtils.isIOS14 ? hasClipboardData : deviceUtils.hasClipboardProtection || !!clipboardData, getClipboard, - hasClipboardData, + hasClipboardData: hasClipboardData || !!clipboardData, setClipboard, }; } diff --git a/src/languages/en_US.json b/src/languages/en_US.json index 3b0836c40a4..d158f51291b 100644 --- a/src/languages/en_US.json +++ b/src/languages/en_US.json @@ -1983,6 +1983,7 @@ "swap": { "actions": { "hold_to_swap": "Hold to Swap", + "hold_to_bridge": "Hold to Bridge", "save": "Save", "enter_amount": "Enter Amount", "review": "Review", diff --git a/src/state/assets/userAssets.ts b/src/state/assets/userAssets.ts index 2a97a31f284..0dda7203474 100644 --- a/src/state/assets/userAssets.ts +++ b/src/state/assets/userAssets.ts @@ -10,6 +10,7 @@ import { useConnectedToHardhatStore } from '../connectedToHardhat'; const SEARCH_CACHE_MAX_ENTRIES = 50; +const escapeRegExp = (string: string) => string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const getSearchQueryKey = ({ filter, searchQuery }: { filter: UserAssetFilter; searchQuery: string }) => `${filter}${searchQuery}`; const getDefaultCacheKeys = (): Set => { @@ -160,7 +161,7 @@ export const userAssetsStore = createRainbowStore( return cachedData; } else { const chainIdFilter = filter === 'all' ? null : filter; - const searchRegex = inputSearchQuery.length > 0 ? new RegExp(inputSearchQuery, 'i') : null; + const searchRegex = inputSearchQuery.length > 0 ? new RegExp(escapeRegExp(inputSearchQuery), 'i') : null; const filteredIds = Array.from( selectUserAssetIds(