diff --git a/src/__swaps__/screens/Swap/Swap.tsx b/src/__swaps__/screens/Swap/Swap.tsx index c503c49d876..056fb7c3b09 100644 --- a/src/__swaps__/screens/Swap/Swap.tsx +++ b/src/__swaps__/screens/Swap/Swap.tsx @@ -73,7 +73,6 @@ export function SwapScreen() { - diff --git a/src/__swaps__/screens/Swap/components/SearchInput.tsx b/src/__swaps__/screens/Swap/components/SearchInput.tsx index 1fd0d9e1ed8..febfc441965 100644 --- a/src/__swaps__/screens/Swap/components/SearchInput.tsx +++ b/src/__swaps__/screens/Swap/components/SearchInput.tsx @@ -1,12 +1,13 @@ -import React, { useMemo } from 'react'; -import { TextInput } from 'react-native'; -import Animated, { runOnUI, useAnimatedRef, useDerivedValue } from 'react-native-reanimated'; +import React, { useCallback, useMemo } from 'react'; +import { NativeSyntheticEvent, TextInputChangeEventData } from 'react-native'; +import Animated, { useAnimatedProps, useDerivedValue } from 'react-native-reanimated'; import { ButtonPressAnimation } from '@/components/animations'; 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 { opacity } from '@/__swaps__/utils/swaps'; import { useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider'; +import { userAssetsStore } from '@/state/assets/userAssets'; const AnimatedInput = Animated.createAnimatedComponent(Input); @@ -21,11 +22,9 @@ export const SearchInput = ({ handleFocusSearch: () => void; output?: boolean; }) => { - const { inputProgress, outputProgress, SwapInputController, AnimatedSwapStyles } = useSwapContext(); + const { searchInputRef, inputProgress, outputProgress, AnimatedSwapStyles } = useSwapContext(); const { isDarkMode } = useColorMode(); - const inputRef = useAnimatedRef(); - const fillTertiary = useForegroundColor('fillTertiary'); const label = useForegroundColor('label'); const labelQuaternary = useForegroundColor('labelQuaternary'); @@ -38,9 +37,22 @@ export const SearchInput = ({ return 'Close'; }); - const initialValue = useMemo(() => { - return SwapInputController.searchQuery.value; - }, [SwapInputController.searchQuery.value]); + const defaultValue = useMemo(() => { + return userAssetsStore.getState().searchQuery; + }, []); + + const onSearchQueryChange = useCallback((event: NativeSyntheticEvent) => { + userAssetsStore.setState({ searchQuery: event.nativeEvent.text }); + }, []); + + const searchInputValue = useAnimatedProps(() => { + const isFocused = inputProgress.value === 1 || outputProgress.value === 1; + + // 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; + + return { defaultValue, text: query }; + }); return ( @@ -68,18 +80,20 @@ export const SearchInput = ({ { - runOnUI(SwapInputController.onChangeSearchQuery)(e.nativeEvent.text); - }} + animatedProps={searchInputValue} + onChange={onSearchQueryChange} onBlur={() => { + onSearchQueryChange({ + nativeEvent: { + text: '', + }, + } as NativeSyntheticEvent); handleExitSearch(); }} - onFocus={() => { - handleFocusSearch(); - }} + onFocus={handleFocusSearch} placeholder={output ? 'Find a token to buy' : 'Search your tokens'} placeholderTextColor={isDarkMode ? opacity(labelQuaternary, 0.3) : labelQuaternary} - ref={inputRef} + ref={searchInputRef} selectionColor={color} spellCheck={false} style={{ @@ -89,7 +103,6 @@ export const SearchInput = ({ height: 44, zIndex: 10, }} - defaultValue={initialValue} /> @@ -98,8 +111,13 @@ export const SearchInput = ({ { + onSearchQueryChange({ + nativeEvent: { + text: '', + }, + } as NativeSyntheticEvent); handleExitSearch(); - inputRef.current?.blur(); + searchInputRef.current?.blur(); }} scaleTo={0.8} > diff --git a/src/__swaps__/screens/Swap/components/SwapBackground.tsx b/src/__swaps__/screens/Swap/components/SwapBackground.tsx index e47b52e703c..24fd9e265af 100644 --- a/src/__swaps__/screens/Swap/components/SwapBackground.tsx +++ b/src/__swaps__/screens/Swap/components/SwapBackground.tsx @@ -21,12 +21,12 @@ export const SwapBackground = () => { const fallbackColor = isDarkMode ? ETH_COLOR_DARK : ETH_COLOR; - const bottomColorDarkened = useSharedValue(getTintedBackgroundColor(fallbackColor, isDarkMode)); - const topColorDarkened = useSharedValue(getTintedBackgroundColor(fallbackColor, isDarkMode)); + const bottomColorDarkened = useSharedValue(getTintedBackgroundColor(fallbackColor)[isDarkMode ? 'dark' : 'light']); + const topColorDarkened = useSharedValue(getTintedBackgroundColor(fallbackColor)[isDarkMode ? 'dark' : 'light']); const getDarkenedColors = ({ topColor, bottomColor }: { topColor: string; bottomColor: string }) => { - bottomColorDarkened.value = getTintedBackgroundColor(bottomColor, isDarkMode); - topColorDarkened.value = getTintedBackgroundColor(topColor, isDarkMode); + bottomColorDarkened.value = getTintedBackgroundColor(bottomColor)[isDarkMode ? 'dark' : 'light']; + topColorDarkened.value = getTintedBackgroundColor(topColor)[isDarkMode ? 'dark' : 'light']; }; useAnimatedReaction( diff --git a/src/__swaps__/screens/Swap/components/SwapSlider.tsx b/src/__swaps__/screens/Swap/components/SwapSlider.tsx index 91c8afe96e3..236aae82e8c 100644 --- a/src/__swaps__/screens/Swap/components/SwapSlider.tsx +++ b/src/__swaps__/screens/Swap/components/SwapSlider.tsx @@ -38,7 +38,6 @@ import { clamp, opacity, opacityWorklet } from '@/__swaps__/utils/swaps'; import { useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider'; import { SwapCoinIcon } from '@/__swaps__/screens/Swap/components/SwapCoinIcon'; import { useTheme } from '@/theme'; -import { useSwapAssetStore } from '@/__swaps__/screens/Swap/state/assets'; import { ethereumUtils } from '@/utils'; import { ChainId } from '@/__swaps__/types/chains'; @@ -61,8 +60,6 @@ export const SwapSlider = ({ const { isDarkMode } = useColorMode(); const { SwapInputController, sliderXPosition, sliderPressProgress } = useSwapContext(); - const { assetToSell } = useSwapAssetStore(); - const panRef = useRef(); const tapRef = useRef(); @@ -377,14 +374,15 @@ export const SwapSlider = ({ + {/* TODO: Migrate this to fast icon image with shared value once we have that */} diff --git a/src/__swaps__/screens/Swap/components/TokenList/ChainSelection.tsx b/src/__swaps__/screens/Swap/components/TokenList/ChainSelection.tsx index 986d5876094..2b678133f3f 100644 --- a/src/__swaps__/screens/Swap/components/TokenList/ChainSelection.tsx +++ b/src/__swaps__/screens/Swap/components/TokenList/ChainSelection.tsx @@ -3,7 +3,7 @@ import c from 'chroma-js'; import * as i18n from '@/languages'; import { Text as RNText, StyleSheet } from 'react-native'; -import Animated, { SharedValue, runOnUI, useAnimatedReaction, useSharedValue } from 'react-native-reanimated'; +import Animated, { runOnUI, useAnimatedReaction, useSharedValue } from 'react-native-reanimated'; import React, { useCallback, useMemo } from 'react'; import { SUPPORTED_CHAINS } from '@/references'; @@ -20,8 +20,8 @@ import { ChainId, ChainName } from '@/__swaps__/types/chains'; import { useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider'; import { ContextMenuButton } from '@/components/context-menu'; import { useAccountAccentColor } from '@/hooks'; -import { UserAssetFilter } from '@/__swaps__/types/assets'; import { OnPressMenuItemEventObject } from 'react-native-ios-context-menu'; +import { userAssetsStore } from '@/state/assets/userAssets'; type ChainSelectionProps = { allText?: string; @@ -31,9 +31,13 @@ type ChainSelectionProps = { export const ChainSelection = ({ allText, output }: ChainSelectionProps) => { const { isDarkMode } = useColorMode(); const { accentColor: accountColor } = useAccountAccentColor(); - const { SwapInputController, userAssetFilter } = useSwapContext(); + const { outputChainId } = useSwapContext(); const red = useForegroundColor('red'); + const initialFilter = useMemo(() => { + return userAssetsStore.getState().filter; + }, []); + const accentColor = useMemo(() => { if (c.contrast(accountColor, isDarkMode ? '#191A1C' : globalColors.white100) < (isDarkMode ? 2.125 : 1.5)) { const shiftedColor = isDarkMode ? c(accountColor).brighten(1).saturate(0.5).css() : c(accountColor).darken(0.5).saturate(0.5).css(); @@ -43,38 +47,42 @@ export const ChainSelection = ({ allText, output }: ChainSelectionProps) => { } }, [accountColor, isDarkMode]); - const propToSet = output ? SwapInputController.outputChainId : userAssetFilter; - const chainName = useSharedValue( - propToSet.value === 'all' ? allText : propToSet.value === ChainId.mainnet ? 'ethereum' : chainNameFromChainIdWorklet(propToSet.value) + output + ? chainNameFromChainIdWorklet(outputChainId.value) + : initialFilter === 'all' + ? allText + : chainNameFromChainIdWorklet(initialFilter as ChainId) ); useAnimatedReaction( () => ({ - outputChainId: SwapInputController.outputChainId.value, - userAssetFilter: userAssetFilter.value, + outputChainId: outputChainId.value, }), current => { if (output) { chainName.value = chainNameForChainIdWithMainnetSubstitutionWorklet(current.outputChainId); - } else { - chainName.value = - current.userAssetFilter === 'all' ? allText : chainNameForChainIdWithMainnetSubstitutionWorklet(current.userAssetFilter); } } ); const handleSelectChain = useCallback( ({ nativeEvent: { actionKey } }: Omit) => { - runOnUI((set: SharedValue) => { - if (actionKey === 'all') { - set.value = 'all'; - } else { - set.value = Number(actionKey) as ChainId; - } - })(propToSet); + if (output) { + runOnUI(() => { + outputChainId.value = Number(actionKey) as ChainId; + chainName.value = chainNameForChainIdWithMainnetSubstitutionWorklet(Number(actionKey) as ChainId); + }); + } else { + userAssetsStore.setState({ + filter: actionKey === 'all' ? 'all' : (Number(actionKey) as ChainId), + }); + runOnUI(() => { + chainName.value = actionKey === 'all' ? allText : chainNameForChainIdWithMainnetSubstitutionWorklet(Number(actionKey) as ChainId); + }); + } }, - [propToSet] + [allText, chainName, output, outputChainId] ); const menuConfig = useMemo(() => { @@ -197,12 +205,8 @@ export const ChainSelection = ({ allText, output }: ChainSelectionProps) => { > - {output && ( - - )} + {/* TODO: We need to add some ethereum utils to handle worklet functions */} + {output && } ); export const TokenToBuySection = ({ section }: { section: AssetToBuySection }) => { - const { SwapInputController } = useSwapContext(); - const userAssets = useAssetsToSell(); + const { setAsset, outputChainId } = useSwapContext(); const handleSelectToken = useCallback( (token: SearchAsset) => { - const userAsset = userAssets.find(asset => isSameAsset(asset, token)); + const userAsset = userAssetsStore.getState().getUserAsset(token.uniqueId); const parsedAsset = parseSearchAsset({ assetWithPrice: undefined, searchAsset: token, userAsset, }); - SwapInputController.onSetAssetToBuy(parsedAsset); + setAsset({ + type: SwapAssetType.outputAsset, + asset: parsedAsset, + }); }, - [SwapInputController, userAssets] + [setAsset] ); const { symbol, title } = sectionProps[section.id]; @@ -91,7 +94,7 @@ export const TokenToBuySection = ({ section }: { section: AssetToBuySection }) = return sectionProps[section.id].color as TextColor; } - return bridgeSectionsColorsByChain[SwapInputController.outputChainId.value || ChainId.mainnet] as TextColor; + return bridgeSectionsColorsByChain[outputChainId.value || ChainId.mainnet] as TextColor; }); if (!section.data.length) return null; diff --git a/src/__swaps__/screens/Swap/components/TokenList/TokenToSellList.tsx b/src/__swaps__/screens/Swap/components/TokenList/TokenToSellList.tsx index c6bca426b76..c989c27d0f6 100644 --- a/src/__swaps__/screens/Swap/components/TokenList/TokenToSellList.tsx +++ b/src/__swaps__/screens/Swap/components/TokenList/TokenToSellList.tsx @@ -4,31 +4,36 @@ 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, { runOnUI } from 'react-native-reanimated'; +import Animated from 'react-native-reanimated'; import { useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider'; -import { parseSearchAsset, isSameAsset } from '@/__swaps__/utils/assets'; +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); export const TokenToSellList = () => { - const { SwapInputController } = useSwapContext(); + const { setAsset } = useSwapContext(); const userAssets = useAssetsToSell(); const handleSelectToken = useCallback( (token: ParsedSearchAsset) => { - const userAsset = userAssets.find(asset => isSameAsset(asset, token)); + const userAsset = userAssetsStore.getState().getUserAsset(token.uniqueId); const parsedAsset = parseSearchAsset({ assetWithPrice: undefined, searchAsset: token, userAsset, }); - runOnUI(SwapInputController.onSetAssetToSell)(parsedAsset); + setAsset({ + type: SwapAssetType.inputAsset, + asset: parsedAsset, + }); }, - [SwapInputController.onSetAssetToSell, userAssets] + [setAsset] ); return ( diff --git a/src/__swaps__/screens/Swap/components/controls/useSwapActionsGestureHandler.ts b/src/__swaps__/screens/Swap/components/controls/useSwapActionsGestureHandler.ts index 88fa0e337cb..f94505b437a 100644 --- a/src/__swaps__/screens/Swap/components/controls/useSwapActionsGestureHandler.ts +++ b/src/__swaps__/screens/Swap/components/controls/useSwapActionsGestureHandler.ts @@ -1,12 +1,5 @@ import { PanGestureHandlerGestureEvent } from 'react-native-gesture-handler'; -import Animated, { - runOnJS, - runOnUI, - useAnimatedGestureHandler, - useAnimatedReaction, - useAnimatedStyle, - useSharedValue, -} from 'react-native-reanimated'; +import { useAnimatedGestureHandler, useSharedValue } from 'react-native-reanimated'; import { useSwapContext } from '../../providers/swap-provider'; export const useSwapActionsGestureHandler = () => { diff --git a/src/__swaps__/screens/Swap/hooks/useAnimatedSwapStyles.ts b/src/__swaps__/screens/Swap/hooks/useAnimatedSwapStyles.ts index 53583979176..92b970ef22f 100644 --- a/src/__swaps__/screens/Swap/hooks/useAnimatedSwapStyles.ts +++ b/src/__swaps__/screens/Swap/hooks/useAnimatedSwapStyles.ts @@ -16,7 +16,6 @@ import { spinnerExitConfig } from '@/__swaps__/components/animations/AnimatedSpi import { NavigationSteps } from './useSwapNavigation'; import { IS_ANDROID } from '@/env'; import { safeAreaInsetValues } from '@/utils'; -import { getSoftMenuBarHeight } from 'react-native-extra-dimensions-android'; export function useAnimatedSwapStyles({ SwapInputController, diff --git a/src/__swaps__/screens/Swap/hooks/useAssetsToSell.ts b/src/__swaps__/screens/Swap/hooks/useAssetsToSell.ts index 91ab60313f1..3c1c0334a80 100644 --- a/src/__swaps__/screens/Swap/hooks/useAssetsToSell.ts +++ b/src/__swaps__/screens/Swap/hooks/useAssetsToSell.ts @@ -1,93 +1,60 @@ -import { useCallback, useRef, useState } from 'react'; +import { useMemo } from 'react'; import { Hex } from 'viem'; -import { runOnJS, useAnimatedReaction } from 'react-native-reanimated'; -import { useDebouncedCallback } from 'use-debounce'; -import { selectUserAssetsList, selectUserAssetsListByChainId } from '@/__swaps__/screens/Swap/resources/_selectors/assets'; +import { + selectUserAssetsList, + selectUserAssetsListByChainId, + selectorFilterByUserChains, +} from '@/__swaps__/screens/Swap/resources/_selectors/assets'; import { useUserAssets } from '@/__swaps__/screens/Swap/resources/assets'; -import { ParsedAssetsDictByChain, ParsedSearchAsset, ParsedUserAsset, UserAssetFilter } from '@/__swaps__/types/assets'; +import { ParsedSearchAsset, UserAssetFilter } from '@/__swaps__/types/assets'; import { useAccountSettings } from '@/hooks'; -import { useSwapContext } from '@/__swaps__/screens/Swap/providers/swap-provider'; - -const sortBy = (userAssetFilter: UserAssetFilter, assets: ParsedAssetsDictByChain) => { - if (userAssetFilter === 'all') { - return () => selectUserAssetsList(assets); +import { useDebounce } from '@/__swaps__/screens/Swap/hooks/useDebounce'; +import { ChainId } from '@/__swaps__/types/chains'; +import { userAssetsStore } from '@/state/assets/userAssets'; + +const sortBy = (by: UserAssetFilter) => { + switch (by) { + case 'all': + return selectUserAssetsList; + default: + return selectUserAssetsListByChainId; } - - return () => selectUserAssetsListByChainId(userAssetFilter, assets); }; export const useAssetsToSell = () => { - const { SwapInputController, userAssetFilter } = useSwapContext(); const { accountAddress: currentAddress, nativeCurrency: currentCurrency } = useAccountSettings(); - const [currentAssets, setCurrentAssets] = useState([]); - const sortMethod = useRef(userAssetFilter.value); + const filter = userAssetsStore(state => state.filter); + const searchQuery = userAssetsStore(state => state.searchQuery); + + const debouncedAssetToSellFilter = useDebounce(searchQuery, 200); const { data: userAssets = [] } = useUserAssets( { address: currentAddress as Hex, currency: currentCurrency, - testnetMode: false, }, { - select: data => { - const filteredAssetsDictByChain = Object.keys(data).reduce((acc, key) => { - const chainKey = Number(key); - acc[chainKey] = data[chainKey]; - return acc; - }, {} as ParsedAssetsDictByChain); - return sortBy(sortMethod.current, filteredAssetsDictByChain)(); - }, - cacheTime: Infinity, - staleTime: Infinity, - } - ); - - const updateSortMethodAndCurrentAssets = useCallback(({ filter, assets }: { filter: UserAssetFilter; assets: ParsedUserAsset[] }) => { - sortMethod.current = filter; - - if (filter === 'all') { - setCurrentAssets(assets as ParsedSearchAsset[]); - } else { - const assetsByChainId = assets.filter(asset => asset.chainId === Number(filter)); - setCurrentAssets(assetsByChainId as ParsedSearchAsset[]); - } - }, []); - - useAnimatedReaction( - () => ({ - filter: userAssetFilter.value, - assets: userAssets, - }), - (current, previous) => { - if (previous?.filter !== current.filter || previous?.assets !== current.assets) { - runOnJS(updateSortMethodAndCurrentAssets)({ - filter: current.filter, - assets: current.assets, - }); - } + select: data => + selectorFilterByUserChains({ + data, + chainId: filter as ChainId, + selector: sortBy(filter), + }), } ); - const filteredAssetsToSell = useDebouncedCallback((query: string) => { - return query - ? setCurrentAssets( - userAssets.filter(({ name, symbol, address }) => - [name, symbol, address].reduce((res, param) => res || param.toLowerCase().startsWith(query.toLowerCase()), false) - ) as ParsedSearchAsset[] + const filteredAssetsToSell = useMemo(() => { + return debouncedAssetToSellFilter + ? userAssets.filter(({ name, symbol, address }) => + [name, symbol, address].reduce( + (res, param) => res || param.toLowerCase().startsWith(debouncedAssetToSellFilter.toLowerCase()), + false + ) ) - : setCurrentAssets(userAssets as ParsedSearchAsset[]); - }, 50); - - useAnimatedReaction( - () => SwapInputController.searchQuery.value, - (current, previous) => { - if (previous !== current) { - runOnJS(filteredAssetsToSell)(current); - } - } - ); + : userAssets; + }, [debouncedAssetToSellFilter, userAssets]) as ParsedSearchAsset[]; - return currentAssets; + return filteredAssetsToSell; }; diff --git a/src/__swaps__/screens/Swap/hooks/useSwapInputsController.ts b/src/__swaps__/screens/Swap/hooks/useSwapInputsController.ts index a2e58a27065..7aff7e19321 100644 --- a/src/__swaps__/screens/Swap/hooks/useSwapInputsController.ts +++ b/src/__swaps__/screens/Swap/hooks/useSwapInputsController.ts @@ -11,7 +11,6 @@ import { clamp, clampJS, countDecimalPlaces, - extractColorValueForColors, findNiceIncrement, getDefaultSlippage, getDefaultSlippageWorklet, @@ -98,17 +97,11 @@ export function useSwapInputsController({ const isQuoteStale = useSharedValue(0); const topColor = useDerivedValue(() => { - return extractColorValueForColors({ - colors: assetToSell.value?.colors, - isDarkMode, - }); + return assetToSell.value?.colors?.primary ?? assetToSell.value?.colors?.fallback ?? (isDarkMode ? ETH_COLOR_DARK : ETH_COLOR); }); const bottomColor = useDerivedValue(() => { - return extractColorValueForColors({ - colors: assetToBuy.value?.colors, - isDarkMode, - }); + return assetToBuy.value?.colors?.primary ?? assetToBuy.value?.colors?.fallback ?? (isDarkMode ? ETH_COLOR_DARK : ETH_COLOR); }); const assetToSellSymbol = useDerivedValue(() => { diff --git a/src/__swaps__/screens/Swap/providers/swap-provider.tsx b/src/__swaps__/screens/Swap/providers/swap-provider.tsx index 56498c9001a..28cb4d47ae7 100644 --- a/src/__swaps__/screens/Swap/providers/swap-provider.tsx +++ b/src/__swaps__/screens/Swap/providers/swap-provider.tsx @@ -1,25 +1,54 @@ // @refresh import React, { createContext, useContext, ReactNode } from 'react'; -import { StyleProp, TextStyle } from 'react-native'; -import { SharedValue, useAnimatedStyle, useDerivedValue, useSharedValue } from 'react-native-reanimated'; -import { inputKeys } from '@/__swaps__/types/swap'; +import { StyleProp, TextStyle, TextInput } from 'react-native'; +import { + AnimatedRef, + SharedValue, + runOnJS, + runOnUI, + useAnimatedRef, + useAnimatedStyle, + useDerivedValue, + useSharedValue, +} from 'react-native-reanimated'; +import { SwapAssetType, inputKeys } from '@/__swaps__/types/swap'; import { INITIAL_SLIDER_POSITION, SLIDER_COLLAPSED_HEIGHT, SLIDER_HEIGHT, SLIDER_WIDTH } from '@/__swaps__/screens/Swap/constants'; import { useAnimatedSwapStyles } from '@/__swaps__/screens/Swap/hooks/useAnimatedSwapStyles'; import { useSwapTextStyles } from '@/__swaps__/screens/Swap/hooks/useSwapTextStyles'; import { useSwapNavigation, NavigationSteps } from '@/__swaps__/screens/Swap/hooks/useSwapNavigation'; import { useSwapInputsController } from '@/__swaps__/screens/Swap/hooks/useSwapInputsController'; -import { UserAssetFilter } from '@/__swaps__/types/assets'; +import { ExtendedAnimatedAssetWithColors, ParsedSearchAsset } from '@/__swaps__/types/assets'; import { useSwapWarning } from '@/__swaps__/screens/Swap/hooks/useSwapWarning'; +import { CrosschainQuote, Quote, QuoteError, SwapType, getCrosschainQuote, getQuote } from '@rainbow-me/swaps'; +import { swapsStore } from '@/state/swaps/swapsStore'; +import { isSameAsset } from '@/__swaps__/utils/assets'; +import { buildQuoteParams, parseAssetAndExtend } from '@/__swaps__/utils/swaps'; +import { ChainId } from '@/__swaps__/types/chains'; interface SwapContextType { - userAssetFilter: SharedValue; + isFetching: SharedValue; + searchInputRef: AnimatedRef; + + // TODO: Combine navigation progress steps into a single shared value inputProgress: SharedValue; outputProgress: SharedValue; reviewProgress: SharedValue; + sliderXPosition: SharedValue; sliderPressProgress: SharedValue; + focusedInput: SharedValue; - isFetching: SharedValue; + + // TODO: Separate this into Zustand + outputChainId: SharedValue; + + internalSelectedInputAsset: SharedValue; + internalSelectedOutputAsset: SharedValue; + setAsset: ({ type, asset }: { type: SwapAssetType; asset: ParsedSearchAsset }) => void; + + quote: SharedValue; + fetchQuote: () => Promise; + SwapInputController: ReturnType; AnimatedSwapStyles: ReturnType; SwapTextStyles: ReturnType; @@ -39,14 +68,100 @@ interface SwapProviderProps { export const SwapProvider = ({ children }: SwapProviderProps) => { const isFetching = useSharedValue(false); + + const searchInputRef = useAnimatedRef(); + const inputProgress = useSharedValue(NavigationSteps.INPUT_ELEMENT_FOCUSED); const outputProgress = useSharedValue(NavigationSteps.INPUT_ELEMENT_FOCUSED); const reviewProgress = useSharedValue(NavigationSteps.INPUT_ELEMENT_FOCUSED); + const sliderXPosition = useSharedValue(SLIDER_WIDTH * INITIAL_SLIDER_POSITION); const sliderPressProgress = useSharedValue(SLIDER_COLLAPSED_HEIGHT / SLIDER_HEIGHT); + const focusedInput = useSharedValue('inputAmount'); + const outputChainId = useSharedValue(ChainId.mainnet); + + const internalSelectedInputAsset = useSharedValue(null); + const internalSelectedOutputAsset = useSharedValue(null); + + const quote = useSharedValue(null); + + const fetchQuote = async () => { + 'worklet'; + + const params = buildQuoteParams({ + inputAmount: SwapInputController.inputValues.value.inputAmount, + outputAmount: SwapInputController.inputValues.value.outputAmount, + focusedInput: focusedInput.value, + }); + + if (!params) return; + + const response = (params.swapType === SwapType.crossChain ? await getCrosschainQuote(params) : await getQuote(params)) as + | Quote + | CrosschainQuote + | QuoteError; + + setQuote({ data: response }); - const userAssetFilter = useSharedValue('all'); + // TODO: Handle setting quote interval AND asset price fetching + }; + + const setQuote = ({ data }: { data: Quote | CrosschainQuote | QuoteError | null }) => { + 'worklet'; + quote.value = data; + runOnJS(swapsStore.setState)({ quote: data }); + }; + + const setAsset = ({ type, asset }: { type: SwapAssetType; asset: ParsedSearchAsset }) => { + console.log({ type, asset }); + + const updateAssetValue = ({ type, asset }: { type: SwapAssetType; asset: ParsedSearchAsset | null }) => { + 'worklet'; + + switch (type) { + case SwapAssetType.inputAsset: + // TODO: Pre-process a bunch of stuff here... + /** + * Colors, price, etc. + */ + internalSelectedInputAsset.value = parseAssetAndExtend({ asset }); + break; + case SwapAssetType.outputAsset: + // TODO: Pre-process a bunch of stuff here... + /** + * Colors, price, etc. + */ + internalSelectedOutputAsset.value = parseAssetAndExtend({ asset }); + break; + } + }; + + const prevAsset = swapsStore.getState()[type]; + const prevOtherAsset = swapsStore.getState()[type === SwapAssetType.inputAsset ? SwapAssetType.outputAsset : SwapAssetType.inputAsset]; + + // if we're setting the same asset, exit early as it's a no-op + if (prevAsset && isSameAsset(prevAsset, asset)) { + return; + } + + // if we're setting the same asset as the other asset, we need to clear the other asset + if (prevOtherAsset && isSameAsset(prevOtherAsset, asset)) { + swapsStore.setState({ + [type === SwapAssetType.inputAsset ? SwapAssetType.outputAsset : SwapAssetType.inputAsset]: null, + }); + runOnUI(updateAssetValue)({ + type: type === SwapAssetType.inputAsset ? SwapAssetType.outputAsset : SwapAssetType.inputAsset, + asset: null, + }); + } + + // TODO: Bunch of logic left to implement here... reset prices, retrigger quote fetching, etc. + swapsStore.setState({ + [type]: asset, + }); + runOnJS(updateAssetValue)({ type, asset }); + }; const SwapNavigation = useSwapNavigation({ inputProgress, @@ -140,19 +255,32 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { return ( ({ + data, + chainId, + selector, +}: { + data: ParsedAssetsDictByChain; + chainId: ChainId; + selector: (data: ParsedAssetsDictByChain, chainId: ChainId) => T; +}): T { + const filteredAssetsDictByChain = Object.keys(data).reduce((acc, key) => { + const chainKey = Number(key); + acc[chainKey] = data[chainKey]; + return acc; + }, {} as ParsedAssetsDictByChain); + return selector(filteredAssetsDictByChain, chainId); +} + export function selectUserAssetsList(assets: ParsedAssetsDictByChain) { return Object.values(assets) .map(chainAssets => Object.values(chainAssets)) @@ -19,10 +36,10 @@ export function selectUserAssetsDictByChain(assets: ParsedAssetsDictByChain) { return assets; } -export function selectUserAssetsListByChainId(chainId: ChainId, assets: ParsedAssetsDictByChain) { - const assetsForChain = assets?.[chainId]; - if (!assetsForChain) return []; - return Object.values(assetsForChain).sort( +export function selectUserAssetsListByChainId(assets: ParsedAssetsDictByChain, chainId: ChainId) { + const assetsForNetwork = assets?.[chainId]; + + return Object.values(assetsForNetwork).sort( (a: ParsedUserAsset, b: ParsedUserAsset) => parseFloat(b?.native?.balance?.amount) - parseFloat(a?.native?.balance?.amount) ); } @@ -49,10 +66,12 @@ export function selectUserAssetWithUniqueId(uniqueId: UniqueId) { }; } -export function selectUserAssetsBalance(assets: ParsedAssetsDictByChain) { +export function selectUserAssetsBalance(assets: ParsedAssetsDictByChain, hidden: (asset: ParsedUserAsset) => boolean) { const networksTotalBalance = Object.values(assets).map(assetsOnject => { const assetsNetwork = Object.values(assetsOnject); + const networkBalance = assetsNetwork + .filter(asset => !hidden(asset)) .map(asset => asset.native.balance.amount) .reduce((prevBalance, currBalance) => add(prevBalance, currBalance), '0'); return networkBalance; diff --git a/src/__swaps__/screens/Swap/state/assets.ts b/src/__swaps__/screens/Swap/state/assets.ts deleted file mode 100644 index 78bc85678a0..00000000000 --- a/src/__swaps__/screens/Swap/state/assets.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { useStore } from 'zustand'; -import { createStore } from '@/state/internal/createStore'; -import { ParsedSearchAsset } from '@/__swaps__/types/assets'; -import { ChainId } from '@/__swaps__/types/chains'; -import { SortMethod } from '@/__swaps__/types/swap'; - -export interface SwapAssetState { - assetToSell: ParsedSearchAsset | null; - assetToBuy: ParsedSearchAsset | null; - outputChainId: ChainId; - sortMethod: SortMethod; - searchFilter: string; - setAssetToSell: (asset: ParsedSearchAsset) => void; - setAssetToBuy: (asset: ParsedSearchAsset) => void; - setOutputChainId: (chainId: ChainId) => void; - setSortMethod: (sortMethod: SortMethod) => void; - setSearchFilter: (searchFilter: string) => void; -} - -export const swapAssetStore = createStore((set, get) => ({ - assetToSell: null, // TODO: Default to their largest balance asset (or ETH mainnet if user has no assets) - assetToBuy: null, - outputChainId: ChainId.mainnet, - sortMethod: SortMethod.token, - searchFilter: '', - - setAssetToSell(asset) { - const assetToBuy = get().assetToBuy; - const prevAssetToSell = get().assetToSell; - - // if the asset to buy is the same as the asset to sell, then clear the asset to buy - if (assetToBuy && asset && assetToBuy.address === asset.address && assetToBuy.chainId === asset.chainId) { - set({ assetToBuy: prevAssetToSell === undefined ? undefined : prevAssetToSell }); - } - - set({ assetToSell: asset, outputChainId: asset.chainId }); - }, - - setAssetToBuy(asset) { - const currentAsset = get().assetToBuy; - const currentAssetToSell = get().assetToSell; - // prevent updating the asset to the same asset - if (currentAsset?.uniqueId === asset.uniqueId) { - return; - } - - if (currentAssetToSell && asset && currentAssetToSell.address === asset.address && currentAssetToSell.chainId === asset.chainId) { - return; - } - - set({ assetToBuy: asset, outputChainId: asset.chainId }); - }, - - setOutputChainId(chainId) { - set({ outputChainId: chainId }); - }, - - setSortMethod(sortMethod) { - set({ sortMethod }); - }, - - setSearchFilter(searchFilter) { - set({ searchFilter }); - }, -})); - -export const useSwapAssetStore = () => useStore(swapAssetStore); diff --git a/src/__swaps__/types/assets.ts b/src/__swaps__/types/assets.ts index 20e1a39c815..c2a7b2ea732 100644 --- a/src/__swaps__/types/assets.ts +++ b/src/__swaps__/types/assets.ts @@ -3,11 +3,18 @@ import type { Address } from 'viem'; import { ETH_ADDRESS } from '@/references'; import { ChainId, ChainName } from '@/__swaps__/types/chains'; import { SearchAsset } from '@/__swaps__/types/search'; +import { ResponseByTheme } from '../utils/swaps'; export type AddressOrEth = Address | typeof ETH_ADDRESS; export type UserAssetFilter = 'all' | ChainId; +export interface ExtendedAnimatedAssetWithColors extends ParsedSearchAsset { + textColor: ResponseByTheme; + tintedBackgroundColor: ResponseByTheme; + highContrastColor: ResponseByTheme; +} + export interface ParsedAsset { address: AddressOrEth; chainId: ChainId; diff --git a/src/__swaps__/types/swap.ts b/src/__swaps__/types/swap.ts index 01ecc1e5021..018f0c5f2e2 100644 --- a/src/__swaps__/types/swap.ts +++ b/src/__swaps__/types/swap.ts @@ -5,3 +5,8 @@ export enum SortMethod { token = 'token', chain = 'chain', } + +export enum SwapAssetType { + inputAsset = 'inputAsset', + outputAsset = 'outputAsset', +} diff --git a/src/__swaps__/utils/swaps.ts b/src/__swaps__/utils/swaps.ts index 94b1b272f8e..c8cf048852d 100644 --- a/src/__swaps__/utils/swaps.ts +++ b/src/__swaps__/utils/swaps.ts @@ -3,13 +3,18 @@ import { SharedValue, convertToRGBA, isColor } from 'react-native-reanimated'; import * as i18n from '@/languages'; import { globalColors } from '@/design-system'; -import { ETH_COLOR, ETH_COLOR_DARK_ACCENT, SCRUBBER_WIDTH, SLIDER_WIDTH } from '@/__swaps__/screens/Swap/constants'; +import { SCRUBBER_WIDTH, SLIDER_WIDTH } from '@/__swaps__/screens/Swap/constants'; import { chainNameFromChainId, chainNameFromChainIdWorklet } from '@/__swaps__/utils/chains'; import { ChainId, ChainName } from '@/__swaps__/types/chains'; import { RainbowConfig } from '@/model/remoteConfig'; -import { CrosschainQuote, ETH_ADDRESS, Quote, WRAPPED_ASSET } from '@rainbow-me/swaps'; +import { CrosschainQuote, ETH_ADDRESS, Quote, QuoteParams, SwapType, WRAPPED_ASSET } from '@rainbow-me/swaps'; import { isLowerCaseMatch } from '@/__swaps__/utils/strings'; -import { ParsedSearchAsset } from '../types/assets'; +import { ExtendedAnimatedAssetWithColors, ParsedSearchAsset } from '../types/assets'; +import { inputKeys } from '../types/swap'; +import { swapsStore } from '../../state/swaps/swapsStore'; +import store from '@/redux/store'; +import { BigNumberish } from '@ethersproject/bignumber'; +import { TokenColors } from '@/graphql/__generated__/metadata'; // /---- 🎨 Color functions 🎨 ----/ // // @@ -17,34 +22,59 @@ export const opacity = (color: string, opacity: number): string => { return c(color).alpha(opacity).css(); }; -export const getHighContrastColor = (color: string, isDarkMode: boolean) => { - const contrast = c.contrast(color, isDarkMode ? globalColors.grey100 : globalColors.white100); - - if (contrast < (isDarkMode ? 3 : 2.5)) { - if (isDarkMode) { - return c(color) - .set('hsl.l', contrast < 1.5 ? 0.88 : 0.8) - .set('hsl.s', `*${contrast < 1.5 ? 0.75 : 0.85}`) - .hex(); - } else { - return c(color) - .set('hsl.s', `*${contrast < 1.5 ? 2 : 1.2}`) - .darken(2.5 - (contrast - (contrast < 1.5 ? 0.5 : 0))) - .hex(); - } +export type ResponseByTheme = { + light: T; + dark: T; +}; + +export const getHighContrastColor = (color: string): ResponseByTheme => { + const lightModeContrast = c.contrast(color, globalColors.white100); + const darkModeContrast = c.contrast(color, globalColors.grey100); + + let lightColor = color; + let darkColor = color; + + if (lightModeContrast < 2.5) { + lightColor = c(color) + .set('hsl.s', `*${lightModeContrast < 1.5 ? 2 : 1.2}`) + .darken(2.5 - (lightModeContrast - (lightModeContrast < 1.5 ? 0.5 : 0))) + .hex(); + } + + if (darkModeContrast < 3) { + darkColor = c(color) + .set('hsl.l', darkModeContrast < 1.5 ? 0.88 : 0.8) + .set('hsl.s', `*${darkModeContrast < 1.5 ? 0.75 : 0.85}`) + .hex(); } - return color; + + return { + light: lightColor, + dark: darkColor, + }; }; export const getMixedColor = (color1: string, color2: string, ratio: number) => { return c.mix(color1, color2, ratio).hex(); }; -export const getTintedBackgroundColor = (color: string, isDarkMode: boolean): string => { - return c - .mix(color, isDarkMode ? globalColors.grey100 : globalColors.white100, isDarkMode ? 0.9875 : 0.94) - .saturate(isDarkMode ? 0 : -0.06) - .hex(); +export const getTextColor = (color: string): ResponseByTheme => { + const contrastWithWhite = c.contrast(color, globalColors.white100); + + return { + light: contrastWithWhite < 2 ? globalColors.grey100 : globalColors.white100, + dark: contrastWithWhite < 2.6 ? globalColors.grey100 : globalColors.white100, + }; +}; + +export const getTintedBackgroundColor = (color: string): ResponseByTheme => { + const lightModeColorToMix = globalColors.white100; + const darkModeColorToMix = globalColors.grey100; + + return { + light: c.mix(color, lightModeColorToMix, 0.94).saturate(-0.06).hex(), + dark: c.mix(color, darkModeColorToMix, 0.9875).saturate(0).hex(), + }; }; // // /---- END color functions ----/ // @@ -296,18 +326,29 @@ export type Colors = { shadow?: string; }; -export const extractColorValueForColors = ({ colors, isDarkMode }: { colors?: Colors; isDarkMode: boolean }): string => { - 'worklet'; +type ExtractColorValueForColorsProps = { + colors: TokenColors; + isDarkMode: boolean; +}; - if (colors?.primary) { - return colors.primary; - } +type ExtractColorValueForColorsResponse = { + textColor: ResponseByTheme; + highContrastColor: ResponseByTheme; + tintedBackgroundColor: ResponseByTheme; +}; - if (colors?.fallback) { - return colors.fallback; - } +export const extractColorValueForColors = ({ colors }: ExtractColorValueForColorsProps): ExtractColorValueForColorsResponse => { + const color = colors.primary ?? colors.fallback; + + // TODO: mod color utils and return light/dark mode colors + + const highContrastColor = getHighContrastColor(color); - return isDarkMode ? ETH_COLOR_DARK_ACCENT : ETH_COLOR; + return { + highContrastColor, + tintedBackgroundColor: getTintedBackgroundColor(color), + textColor: getTextColor(color), + }; }; export const getQuoteServiceTime = ({ quote }: { quote: Quote | CrosschainQuote }) => @@ -394,3 +435,66 @@ export const priceForAsset = ({ } return 0; }; + +type ParseAssetAndExtendProps = { + asset: ParsedSearchAsset | null; +}; + +export const parseAssetAndExtend = ({ asset }: ParseAssetAndExtendProps): ExtendedAnimatedAssetWithColors | null => { + 'worklet'; + + if (!asset) { + return null; + } + + // TODO: Process and add colors to the asset and anything else we'll need for reanimated stuff + const colors = extractColorValueForColors({ + colors: asset.colors as TokenColors, + isDarkMode: true, // TODO: Make this not rely on isDarkMode + }); + + return { + ...asset, + ...colors, + }; +}; + +type BuildQuoteParamsProps = { + inputAmount: BigNumberish; + outputAmount: BigNumberish; + focusedInput: inputKeys; +}; + +/** + * Builds the quote params for the swap based on the current state of the store. + * + * NOTE: Will return null if either asset isn't set. + * @returns data needed to execute a swap or cross-chain swap + */ +export const buildQuoteParams = ({ inputAmount, outputAmount, focusedInput }: BuildQuoteParamsProps): QuoteParams | null => { + // NOTE: Yuck... redux is still heavily integrated into the account logic. + const { accountAddress } = store.getState().settings; + + const { inputAsset, outputAsset, source, slippage } = swapsStore(); + if (!inputAsset || !outputAsset) { + return null; + } + + const isCrosschainSwap = inputAsset.chainId !== outputAsset.chainId; + + return { + source: source === 'auto' ? undefined : source, + swapType: isCrosschainSwap ? SwapType.crossChain : SwapType.normal, + fromAddress: accountAddress, + chainId: inputAsset.chainId, + toChainId: isCrosschainSwap ? outputAsset.chainId : inputAsset.chainId, + sellTokenAddress: inputAsset.isNativeAsset ? ETH_ADDRESS : inputAsset.address, + buyTokenAddress: outputAsset.isNativeAsset ? ETH_ADDRESS : outputAsset.address, + + // TODO: Dunno how we can access these from the + sellAmount: focusedInput === 'inputAmount' || focusedInput === 'inputNativeValue' ? inputAmount : undefined, + buyAmount: focusedInput === 'outputAmount' || focusedInput === 'outputNativeValue' ? outputAmount : undefined, + slippage: Number(slippage), + refuel: false, + }; +}; diff --git a/src/hooks/useSwapDerivedOutputs.ts b/src/hooks/useSwapDerivedOutputs.ts index f6414636b12..f175946fd4c 100644 --- a/src/hooks/useSwapDerivedOutputs.ts +++ b/src/hooks/useSwapDerivedOutputs.ts @@ -110,7 +110,10 @@ const getInputAmount = async ( logger.debug('[getInputAmount]: Getting quote', { rand, quoteParams }); // Do not deleeeet the comment below 😤 // @ts-ignore About to get quote + + console.log(JSON.stringify(quoteParams, null, 2)); const quote = await getQuote(quoteParams); + console.log(JSON.stringify(quote, null, 2)); // if no quote, if quote is error or there's no sell amount if (!quote || (quote as QuoteError).error || !(quote as Quote).sellAmount) { @@ -201,6 +204,8 @@ const getOutputAmount = async ( refuel, }; + console.log(JSON.stringify(quoteParams, null, 2)); + const rand = Math.floor(Math.random() * 100); logger.debug('[getOutputAmount]: Getting quote', { rand, quoteParams }); // Do not deleeeet the comment below 😤 @@ -208,6 +213,8 @@ const getOutputAmount = async ( const quote: Quote | CrosschainQuote | QuoteError | null = await (isCrosschainSwap ? getCrosschainQuote : getQuote)(quoteParams); logger.debug('[getOutputAmount]: Got quote', { rand, quote }); + console.log(JSON.stringify(quote, null, 2)); + if (!quote || (quote as QuoteError)?.error || !(quote as Quote)?.buyAmount) { const quoteError = quote as QuoteError; if (quoteError.error) { diff --git a/src/model/migrations.ts b/src/model/migrations.ts index 20a8ddddbb5..d67a414af96 100644 --- a/src/model/migrations.ts +++ b/src/model/migrations.ts @@ -25,7 +25,6 @@ import { } from '../utils/keychainConstants'; import { hasKey, loadString, publicAccessControlOptions, saveString } from './keychain'; import { DEFAULT_WALLET_NAME, loadAddress, RainbowAccount, RainbowWallet, saveAddress } from './wallet'; -import { isL2Asset } from '@/handlers/assets'; import { getAssets, getHiddenCoins, getPinnedCoins, saveHiddenCoins, savePinnedCoins } from '@/handlers/localstorage/accountLocal'; import { getContacts, saveContacts } from '@/handlers/localstorage/contacts'; import { resolveNameOrAddress } from '@/handlers/web3'; @@ -38,7 +37,8 @@ import { queryClient } from '@/react-query'; import { favoritesQueryKey } from '@/resources/favorites'; import { EthereumAddress, RainbowToken } from '@/entities'; import { getUniqueId } from '@/utils/ethereumUtils'; -import { queryStorage } from '@/storage/legacy'; +import { userAssetsStore } from '@/state/assets/userAssets'; +import { Hex } from 'viem'; export default async function runMigrations() { // get current version @@ -639,6 +639,27 @@ 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/state/assets/userAssets.ts b/src/state/assets/userAssets.ts new file mode 100644 index 00000000000..68a43a13b37 --- /dev/null +++ b/src/state/assets/userAssets.ts @@ -0,0 +1,169 @@ +import { Hex } 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'; + +export interface UserAssetsState { + userAssetsById: Set; + userAssets: 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; + + getFilteredUserAssetIds: () => UniqueId[]; + getUserAsset: (uniqueId: UniqueId) => ParsedSearchAsset | undefined; +} + +// NOTE: We are serializing Map as an Array<[UniqueId, ParsedSearchAsset]> +type UserAssetsStateWithTransforms = Omit, 'userAssetIds' | 'userAssets' | 'favoriteAssetsAddresses'> & { + userAssetIds: Array; + userAssets: Array<[UniqueId, ParsedSearchAsset]>; + favoriteAssetsAddresses: Array; +}; + +function serializeUserAssetsState(state: Partial, version?: number) { + try { + const transformedStateToPersist: UserAssetsStateWithTransforms = { + ...state, + userAssetIds: state.userAssetsById ? Array.from(state.userAssetsById) : [], + userAssets: state.userAssets ? Array.from(state.userAssets.entries()) : [], + favoriteAssetsAddresses: state.favoriteAssetsById ? Array.from(state.favoriteAssetsById) : [], + }; + + return JSON.stringify({ + state: transformedStateToPersist, + version, + }); + } catch (error) { + logger.error(new RainbowError('Failed to serialize state for user assets storage'), { error }); + throw error; + } +} + +function deserializeUserAssetsState(serializedState: string) { + let parsedState: { state: UserAssetsStateWithTransforms; version: number }; + try { + parsedState = JSON.parse(serializedState); + } catch (error) { + logger.error(new RainbowError('Failed to parse serialized state from user assets storage'), { error }); + throw error; + } + + 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) { + userAssetsData = new Map(state.userAssets); + } + } catch (error) { + logger.error(new RainbowError('Failed to convert userAssets from user assets storage'), { error }); + throw error; + } + + let favoritesData = new Set(); + try { + if (state.favoriteAssetsAddresses.length) { + favoritesData = new Set(state.favoriteAssetsAddresses); + } + } catch (error) { + logger.error(new RainbowError('Failed to convert favoriteAssetsAddresses from user assets storage'), { error }); + throw error; + } + + return { + state: { + ...state, + userAssetIds: userAssetIdsData, + userAssets: userAssetsData, + favoriteAssetsAddresses: favoritesData, + }, + version, + }; +} + +export const userAssetsStore = createRainbowStore( + (_, get) => ({ + userAssetsById: new Set(), + userAssets: new Map(), + filter: 'all', + searchQuery: '', + favoriteAssetsById: new Set(), + + getFilteredUserAssetIds: () => { + const { userAssetsById, userAssets, searchQuery } = get(); + + // NOTE: No search query let's just return the userAssetIds + if (!searchQuery.trim()) { + return Array.from(userAssetsById.keys()); + } + + 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[]); + }, + + setFavorites: (addresses: Hex[]) => { + const { favoriteAssetsById } = get(); + addresses.forEach(address => { + favoriteAssetsById.add(address); + }); + }, + + toggleFavorite: (uniqueId: UniqueId) => { + const { favoriteAssetsById } = get(); + const { address } = deriveAddressAndChainWithUniqueId(uniqueId); + if (favoriteAssetsById.has(address)) { + favoriteAssetsById.delete(address); + } else { + favoriteAssetsById.add(address); + } + }, + + getUserAsset: (uniqueId: UniqueId) => get().userAssets.get(uniqueId), + + isFavorite: (uniqueId: UniqueId) => { + const { favoriteAssetsById } = get(); + const { address } = deriveAddressAndChainWithUniqueId(uniqueId); + return favoriteAssetsById.has(address); + }, + }), + { + storageKey: 'userAssets', + version: 1, + partialize: state => ({ + userAssetsById: state.userAssetsById, + userAssets: state.userAssets, + favoriteAssetsById: state.favoriteAssetsById, + }), + serializer: serializeUserAssetsState, + deserializer: deserializeUserAssetsState, + } +); diff --git a/src/state/internal/createRainbowStore.ts b/src/state/internal/createRainbowStore.ts index ade3bd1391f..966914e0e26 100644 --- a/src/state/internal/createRainbowStore.ts +++ b/src/state/internal/createRainbowStore.ts @@ -13,6 +13,8 @@ const rainbowStorage = new MMKV({ id: 'rainbow-storage' }); * Configuration options for creating a persistable Rainbow store. */ interface RainbowPersistConfig { + serializer?: (state: StorageValue>['state'], version: StorageValue>['version']) => string; + deserializer?: (serializedState: string) => StorageValue>; /** * A function that determines which parts of the state should be persisted. * By default, the entire state is persisted. @@ -35,17 +37,23 @@ interface RainbowPersistConfig { * @param config - The configuration options for the persistable Rainbow store. * @returns An object containing the persist storage and version. */ -function createPersistStorage(config: RainbowPersistConfig) { - const { storageKey, version = 0 } = config; +function createPersistStorage(config: RainbowPersistConfig) { + const { storageKey, version = 0, serializer = defaultSerializeState, deserializer = defaultDeserializeState } = config; const persistStorage: PersistOptions>['storage'] = { getItem: (name: string) => { const key = `${storageKey}:${name}`; const serializedValue = rainbowStorage.getString(key); if (!serializedValue) return null; - return deserializeState(serializedValue); + return deserializer(serializedValue); }, - setItem: (name, value) => lazyPersist(storageKey, name, value), + setItem: (name, value) => + lazyPersist({ + serializer, + storageKey, + name, + value, + }), removeItem: (name: string) => { const key = `${storageKey}:${name}`; rainbowStorage.delete(key); @@ -55,25 +63,33 @@ function createPersistStorage(config: RainbowPersistConfig) { return { persistStorage, version }; } +interface LazyPersistParams { + serializer: (state: StorageValue>['state'], version: StorageValue>['version']) => string; + storageKey: string; + name: string; + value: StorageValue>; +} + /** * Initiates a debounced persist operation for a given store state. * @param storageKey - The key prefix for the store in the central MMKV storage. * @param name - The name of the store. * @param value - The state value to be persisted. */ -const lazyPersist = debounce( - (storageKey: string, name: string, value: StorageValue) => { - try { - const key = `${storageKey}:${name}`; - const serializedValue = serializeState(value.state, value.version ?? 0); - rainbowStorage.set(key, serializedValue); - } catch (error) { - logger.error(new RainbowError('Failed to serialize persisted store data'), { error }); - } - }, - PERSIST_RATE_LIMIT_MS, - { leading: false, trailing: true, maxWait: PERSIST_RATE_LIMIT_MS } -); +const lazyPersist = ({ serializer, storageKey, name, value }: LazyPersistParams) => + debounce( + () => { + try { + const key = `${storageKey}:${name}`; + const serializedValue = serializer(value.state, value.version ?? 0); + rainbowStorage.set(key, serializedValue); + } catch (error) { + logger.error(new RainbowError('Failed to serialize persisted store data'), { error }); + } + }, + PERSIST_RATE_LIMIT_MS, + { leading: false, trailing: true, maxWait: PERSIST_RATE_LIMIT_MS } + )(); /** * Serializes the state and version into a JSON string. @@ -81,7 +97,7 @@ const lazyPersist = debounce( * @param version - The version of the state. * @returns The serialized state as a JSON string. */ -function serializeState(state: S, version: number): string { +function defaultSerializeState(state: StorageValue>['state'], version: StorageValue>['version']): string { try { return JSON.stringify({ state, version }); } catch (error) { @@ -95,7 +111,7 @@ function serializeState(state: S, version: number): string { * @param serializedState - The serialized state as a JSON string. * @returns An object containing the deserialized state and version. */ -function deserializeState(serializedState: string): { state: S; version: number } { +function defaultDeserializeState(serializedState: string): StorageValue> { try { return JSON.parse(serializedState); } catch (error) { @@ -110,7 +126,7 @@ function deserializeState(serializedState: string): { state: S; version: numb * @param persistConfig - The configuration options for the persistable Rainbow store. * @returns A Zustand store with the specified state and optional persistence. */ -export function createRainbowStore( +export function createRainbowStore( createState: StateCreator, persistConfig?: RainbowPersistConfig ) { diff --git a/src/state/swaps/swapsStore.ts b/src/state/swaps/swapsStore.ts new file mode 100644 index 00000000000..45287f63735 --- /dev/null +++ b/src/state/swaps/swapsStore.ts @@ -0,0 +1,51 @@ +import { ParsedSearchAsset } from '@/__swaps__/types/assets'; +import { CrosschainQuote, Quote, QuoteError, Source } from '@rainbow-me/swaps'; +import { getDefaultSlippage } from '@/__swaps__/utils/swaps'; +import { ChainId } from '@/__swaps__/types/chains'; +import { DEFAULT_CONFIG } from '@/model/remoteConfig'; +import { createRainbowStore } from '@/state/internal/createRainbowStore'; + +export interface SwapsState { + // assets + inputAsset: ParsedSearchAsset | null; + outputAsset: ParsedSearchAsset | null; + + // quote + quote: Quote | CrosschainQuote | QuoteError | null; + + // settings + flashbots: boolean; + setFlashbots: (flashbots: boolean) => void; + slippage: string; + setSlippage: (slippage: string) => void; + source: Source | 'auto'; + setSource: (source: Source | 'auto') => void; +} + +export const swapsStore = createRainbowStore( + set => ({ + inputAsset: null, // TODO: Default to their largest balance asset (or ETH mainnet if user has no assets) + outputAsset: null, + + quote: null, + + flashbots: false, + setFlashbots: (flashbots: boolean) => set({ flashbots }), + slippage: getDefaultSlippage(ChainId.mainnet, DEFAULT_CONFIG), + setSlippage: (slippage: string) => set({ slippage }), + source: 'auto', + setSource: (source: Source | 'auto') => set({ source }), + }), + { + storageKey: 'swapsStore', + version: 1, + // NOTE: Only persist the settings + partialize(state) { + return { + flashbots: state.flashbots, + source: state.source, + slippage: state.slippage, + }; + }, + } +);