diff --git a/.eslintrc.js b/.eslintrc.js index 6df1e6e1e52..f8cb2150123 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -39,5 +39,6 @@ module.exports = { ], 'jest/expect-expect': 'off', 'jest/no-disabled-tests': 'off', + 'no-nested-ternary': 'off', }, }; diff --git a/src/App.tsx b/src/App.tsx index 157ff68b606..4977067dc60 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -40,6 +40,7 @@ import { IS_ANDROID, IS_DEV } from '@/env'; import { prefetchDefaultFavorites } from '@/resources/favorites'; import Routes from '@/navigation/Routes'; import { BackendNetworks } from '@/components/BackendNetworks'; +import { AbsolutePortalRoot } from './components/AbsolutePortal'; if (IS_DEV) { reactNativeDisableYellowBox && LogBox.ignoreAllLogs(); @@ -73,6 +74,7 @@ function App({ walletReady }: AppProps) { + )} diff --git a/src/__swaps__/screens/Swap/components/AnimatedSwapCoinIcon.tsx b/src/__swaps__/screens/Swap/components/AnimatedSwapCoinIcon.tsx index 2c8ba92b2ea..7d8ec66e8e1 100644 --- a/src/__swaps__/screens/Swap/components/AnimatedSwapCoinIcon.tsx +++ b/src/__swaps__/screens/Swap/components/AnimatedSwapCoinIcon.tsx @@ -14,37 +14,19 @@ import { IS_ANDROID, IS_IOS } from '@/env'; import { PIXEL_RATIO } from '@/utils/deviceUtils'; import { useSwapContext } from '../providers/swap-provider'; -const fallbackIconStyle = { - ...borders.buildCircleAsObject(32), - position: 'absolute' as ViewStyle['position'], -}; - -const largeFallbackIconStyle = { - ...borders.buildCircleAsObject(36), - position: 'absolute' as ViewStyle['position'], -}; - -const smallFallbackIconStyle = { - ...borders.buildCircleAsObject(16), - position: 'absolute' as ViewStyle['position'], -}; - export const AnimatedSwapCoinIcon = memo(function AnimatedSwapCoinIcon({ assetType, - large = true, - small, + size = 32, showBadge = true, }: { assetType: 'input' | 'output'; - large?: boolean; - small?: boolean; + size?: number; 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); @@ -91,15 +73,8 @@ export const AnimatedSwapCoinIcon = memo(function AnimatedSwapCoinIcon({ })); return ( - - + + {/* ⚠️ TODO: This works but we should figure out how to type this correctly to avoid this error */} {/* @ts-expect-error: Doesn't pick up that it's getting a source prop via animatedProps */} @@ -122,29 +97,14 @@ export const AnimatedSwapCoinIcon = memo(function AnimatedSwapCoinIcon({ /> - - + + @@ -153,28 +113,28 @@ export const AnimatedSwapCoinIcon = memo(function AnimatedSwapCoinIcon({ ); }); +const fallbackIconStyle = (size: number) => ({ + ...borders.buildCircleAsObject(size), + position: 'absolute' as ViewStyle['position'], +}); + +const coinIconFallbackStyle = (size: number) => ({ + borderRadius: size / 2, + height: size, + width: size, + overflow: 'visible' as const, +}); + +const containerStyle = (size: number) => ({ + elevation: 6, + height: size, + overflow: 'visible' as const, +}); + const sx = StyleSheet.create({ coinIcon: { overflow: 'hidden', }, - coinIconFallback: { - borderRadius: 16, - height: 32, - overflow: 'visible', - width: 32, - }, - coinIconFallbackLarge: { - borderRadius: 18, - height: 36, - overflow: 'visible', - width: 36, - }, - coinIconFallbackSmall: { - borderRadius: 8, - height: 16, - overflow: 'visible', - width: 16, - }, container: { elevation: 6, height: 32, diff --git a/src/__swaps__/screens/Swap/components/CoinRow.tsx b/src/__swaps__/screens/Swap/components/CoinRow.tsx index c3fb98919b1..dc1894bddf1 100644 --- a/src/__swaps__/screens/Swap/components/CoinRow.tsx +++ b/src/__swaps__/screens/Swap/components/CoinRow.tsx @@ -131,7 +131,7 @@ export function CoinRow({ isFavorite, onPress, output, uniqueId, testID, ...asse iconUrl={icon_url} address={address} mainnetAddress={mainnetAddress} - large + size={36} chainId={chainId} symbol={symbol || ''} color={colors?.primary} diff --git a/src/__swaps__/screens/Swap/components/SwapCoinIcon.tsx b/src/__swaps__/screens/Swap/components/SwapCoinIcon.tsx index bb5069c5092..f5e15d41c8d 100644 --- a/src/__swaps__/screens/Swap/components/SwapCoinIcon.tsx +++ b/src/__swaps__/screens/Swap/components/SwapCoinIcon.tsx @@ -24,20 +24,10 @@ const fallbackTextStyles = { textAlign: 'center', }; -const fallbackIconStyle = { - ...borders.buildCircleAsObject(32), +const fallbackIconStyle = (size: number) => ({ + ...borders.buildCircleAsObject(size), position: 'absolute', -}; - -const largeFallbackIconStyle = { - ...borders.buildCircleAsObject(36), - position: 'absolute', -}; - -const smallFallbackIconStyle = { - ...borders.buildCircleAsObject(16), - position: 'absolute', -}; +}); /** * If mainnet asset is available, get the token under /ethereum/ (token) url. @@ -63,22 +53,22 @@ export const SwapCoinIcon = React.memo(function FeedCoinIcon({ iconUrl, disableShadow = true, forceDarkMode, - large, mainnetAddress, chainId, - small, symbol, + size = 32, + chainSize, }: { address: string; color?: string; iconUrl?: string; disableShadow?: boolean; forceDarkMode?: boolean; - large?: boolean; mainnetAddress?: string; chainId: ChainId; - small?: boolean; symbol: string; + size?: number; + chainSize?: number; }) { const theme = useTheme(); @@ -92,52 +82,52 @@ export const SwapCoinIcon = React.memo(function FeedCoinIcon({ const eth = isETH(resolvedAddress); return ( - + {eth ? ( - + ) : ( - + {() => ( )} )} - {chainId && chainId !== ChainId.mainnet && !small && ( + {chainId && chainId !== ChainId.mainnet && size > 16 && ( - + )} ); }); +const styles = { + container: (size: number) => ({ + elevation: 6, + height: size, + overflow: 'visible' as const, + }), + coinIcon: (size: number) => ({ + borderRadius: size / 2, + height: size, + width: size, + overflow: 'visible' as const, + }), +}; + const sx = StyleSheet.create({ badge: { bottom: -0, @@ -151,39 +141,6 @@ const sx = StyleSheet.create({ shadowRadius: 6, shadowOpacity: 0.2, }, - coinIconFallback: { - borderRadius: 16, - height: 32, - overflow: 'visible', - width: 32, - }, - coinIconFallbackLarge: { - borderRadius: 18, - height: 36, - overflow: 'visible', - width: 36, - }, - coinIconFallbackSmall: { - borderRadius: 8, - height: 16, - overflow: 'visible', - width: 16, - }, - container: { - elevation: 6, - height: 32, - overflow: 'visible', - }, - containerLarge: { - elevation: 6, - height: 36, - overflow: 'visible', - }, - containerSmall: { - elevation: 6, - height: 16, - overflow: 'visible', - }, reactCoinIconContainer: { alignItems: 'center', justifyContent: 'center', diff --git a/src/__swaps__/screens/Swap/components/SwapInputAsset.tsx b/src/__swaps__/screens/Swap/components/SwapInputAsset.tsx index af94a152e8a..23734d39ce8 100644 --- a/src/__swaps__/screens/Swap/components/SwapInputAsset.tsx +++ b/src/__swaps__/screens/Swap/components/SwapInputAsset.tsx @@ -96,7 +96,7 @@ function SwapInputAmount() { function SwapInputIcon() { return ( - + ); } diff --git a/src/__swaps__/screens/Swap/components/SwapOutputAsset.tsx b/src/__swaps__/screens/Swap/components/SwapOutputAsset.tsx index de15b46bee6..93130066142 100644 --- a/src/__swaps__/screens/Swap/components/SwapOutputAsset.tsx +++ b/src/__swaps__/screens/Swap/components/SwapOutputAsset.tsx @@ -108,7 +108,7 @@ function SwapOutputAmount({ handleTapWhileDisabled }: { handleTapWhileDisabled: function SwapOutputIcon() { return ( - + ); } diff --git a/src/__swaps__/screens/Swap/components/SwapSlider.tsx b/src/__swaps__/screens/Swap/components/SwapSlider.tsx index 572f9eb6e80..90ac3ac724c 100644 --- a/src/__swaps__/screens/Swap/components/SwapSlider.tsx +++ b/src/__swaps__/screens/Swap/components/SwapSlider.tsx @@ -412,7 +412,7 @@ export const SwapSlider = ({ - + { const isBridge = swapsStore.getState().inputAsset?.mainnetAddress === swapsStore.getState().outputAsset?.mainnetAddress; const isDegenModeEnabled = swapsStore.getState().degenMode; const isSwappingToPopularAsset = swapsStore.getState().outputAsset?.sectionId === 'popular'; + const lastNavigatedTrendingToken = swapsStore.getState().lastNavigatedTrendingToken; + const isSwappingToTrendingAsset = + lastNavigatedTrendingToken === parameters.assetToBuy.uniqueId || lastNavigatedTrendingToken === parameters.assetToSell.uniqueId; const selectedGas = getSelectedGas(parameters.chainId); if (!selectedGas) { @@ -326,6 +329,7 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { tradeAmountUSD: parameters.quote.tradeAmountUSD, degenMode: isDegenModeEnabled, isSwappingToPopularAsset, + isSwappingToTrendingAsset, errorMessage, isHardwareWallet, }); @@ -397,6 +401,7 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { tradeAmountUSD: parameters.quote.tradeAmountUSD, degenMode: isDegenModeEnabled, isSwappingToPopularAsset, + isSwappingToTrendingAsset, isHardwareWallet, }); } catch (error) { @@ -411,6 +416,11 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { }, }); } + + // reset the last navigated trending token after a swap has taken place + swapsStore.setState({ + lastNavigatedTrendingToken: undefined, + }); }; const executeSwap = performanceTracking.getState().executeFn({ diff --git a/src/analytics/event.ts b/src/analytics/event.ts index adfcbee8a6d..ecf733e263e 100644 --- a/src/analytics/event.ts +++ b/src/analytics/event.ts @@ -9,6 +9,7 @@ import { RequestSource } from '@/utils/requestNavigationHandlers'; import { CrosschainQuote, Quote, QuoteError } from '@rainbow-me/swaps'; import { AnyPerformanceLog, Screen } from '../state/performance/operations'; import { FavoritedSite } from '@/state/browser/favoriteDappsStore'; +import { TrendingTokens } from '@/resources/trendingTokens/trendingTokens'; /** * All events, used by `analytics.track()` @@ -167,6 +168,14 @@ export const event = { // token details tokenDetailsErc20: 'token_details.erc20', tokenDetailsNFT: 'token_details.nft', + + // trending tokens + viewTrendingToken: 'trending_tokens.view_trending_token', + viewRankedCategory: 'trending_tokens.view_ranked_category', + changeNetworkFilter: 'trending_tokens.change_network_filter', + changeTimeframeFilter: 'trending_tokens.change_timeframe_filter', + changeSortFilter: 'trending_tokens.change_sort_filter', + hasLinkedFarcaster: 'trending_tokens.has_linked_farcaster', } as const; type SwapEventParameters = { @@ -186,6 +195,7 @@ type SwapEventParameters = { tradeAmountUSD: number; degenMode: boolean; isSwappingToPopularAsset: boolean; + isSwappingToTrendingAsset: boolean; isHardwareWallet: boolean; }; @@ -706,4 +716,37 @@ export type EventProperties = { eventSentAfterMs: number; available_data: { description: boolean; image_url: boolean; floorPrice: boolean }; }; + + [event.viewTrendingToken]: { + address: TrendingTokens['trendingTokens']['data'][number]['address']; + chainId: TrendingTokens['trendingTokens']['data'][number]['chainId']; + symbol: TrendingTokens['trendingTokens']['data'][number]['symbol']; + name: TrendingTokens['trendingTokens']['data'][number]['name']; + highlightedFriends: number; + }; + + [event.viewRankedCategory]: { + category: string; + chainId: ChainId | undefined; + isLimited: boolean; + isEmpty: boolean; + }; + + [event.changeNetworkFilter]: { + chainId: ChainId | undefined; + }; + + [event.changeTimeframeFilter]: { + timeframe: string; + }; + + [event.changeSortFilter]: { + sort: string | undefined; + }; + + [event.hasLinkedFarcaster]: { + hasFarcaster: boolean; + personalizedTrending: boolean; + walletHash: string; + }; }; diff --git a/src/analytics/userProperties.ts b/src/analytics/userProperties.ts index b42d5518a61..8a467e4b09a 100644 --- a/src/analytics/userProperties.ts +++ b/src/analytics/userProperties.ts @@ -1,3 +1,4 @@ +import { ChainId } from '@/state/backendNetworks/types'; import { NativeCurrencyKey } from '@/entities'; import { Language } from '@/languages'; @@ -36,6 +37,9 @@ export interface UserProperties { hiddenCOins?: string[]; appIcon?: string; + // most used networks at the time the user first opens the network switcher + mostUsedNetworks?: ChainId[]; + // assets NFTs?: number; poaps?: number; diff --git a/src/components/AbsolutePortal.tsx b/src/components/AbsolutePortal.tsx index b578234bd0a..c98b036cd70 100644 --- a/src/components/AbsolutePortal.tsx +++ b/src/components/AbsolutePortal.tsx @@ -32,17 +32,15 @@ export const AbsolutePortalRoot = () => { return () => unsubscribe(); }, []); - return ( - - {nodes} - - ); + return {nodes}; }; export const AbsolutePortal = ({ children }: PropsWithChildren) => { useEffect(() => { absolutePortal.addNode(children); - return () => absolutePortal.removeNode(children); + return () => { + absolutePortal.removeNode(children); + }; }, [children]); return null; diff --git a/src/screens/discover/components/DiscoverFeaturedResultsCard.tsx b/src/components/Discover/DiscoverFeaturedResultsCard.tsx similarity index 100% rename from src/screens/discover/components/DiscoverFeaturedResultsCard.tsx rename to src/components/Discover/DiscoverFeaturedResultsCard.tsx diff --git a/src/screens/discover/components/DiscoverHome.tsx b/src/components/Discover/DiscoverHome.tsx similarity index 93% rename from src/screens/discover/components/DiscoverHome.tsx rename to src/components/Discover/DiscoverHome.tsx index 7132c19c016..54cb5ed71f9 100644 --- a/src/screens/discover/components/DiscoverHome.tsx +++ b/src/components/Discover/DiscoverHome.tsx @@ -6,6 +6,7 @@ import useExperimentalFlag, { MINTS, NFT_OFFERS, FEATURED_RESULTS, + TRENDING_TOKENS, } from '@rainbow-me/config/experimentalHooks'; import { isTestnetChain } from '@/handlers/web3'; import { Inline, Inset, Stack, Box } from '@/design-system'; @@ -28,11 +29,12 @@ import { FeaturedResultStack } from '@/components/FeaturedResult/FeaturedResultS import Routes from '@/navigation/routesNames'; import { useNavigation } from '@/navigation'; import { DiscoverFeaturedResultsCard } from './DiscoverFeaturedResultsCard'; +import { TrendingTokens } from '@/components/Discover/TrendingTokens'; export const HORIZONTAL_PADDING = 20; export default function DiscoverHome() { - const { profiles_enabled, mints_enabled, op_rewards_enabled, featured_results } = useRemoteConfig(); + const { profiles_enabled, mints_enabled, op_rewards_enabled, featured_results, trending_tokens_enabled } = useRemoteConfig(); const { chainId } = useAccountSettings(); const profilesEnabledLocalFlag = useExperimentalFlag(PROFILES); const profilesEnabledRemoteFlag = profiles_enabled; @@ -42,6 +44,7 @@ export default function DiscoverHome() { const mintsEnabled = (useExperimentalFlag(MINTS) || mints_enabled) && !IS_TEST; const opRewardsLocalFlag = useExperimentalFlag(OP_REWARDS); const opRewardsRemoteFlag = op_rewards_enabled; + const trendingTokensEnabled = (useExperimentalFlag(TRENDING_TOKENS) || trending_tokens_enabled) && !IS_TEST; const testNetwork = isTestnetChain({ chainId }); const { navigate } = useNavigation(); const isProfilesEnabled = profilesEnabledLocalFlag && profilesEnabledRemoteFlag; @@ -67,6 +70,7 @@ export default function DiscoverHome() { {isProfilesEnabled && } + {trendingTokensEnabled && } {mintsEnabled && ( diff --git a/src/screens/discover/components/DiscoverScreenContent.tsx b/src/components/Discover/DiscoverScreenContent.tsx similarity index 76% rename from src/screens/discover/components/DiscoverScreenContent.tsx rename to src/components/Discover/DiscoverScreenContent.tsx index 1e3a7650013..99271271ded 100644 --- a/src/screens/discover/components/DiscoverScreenContent.tsx +++ b/src/components/Discover/DiscoverScreenContent.tsx @@ -1,12 +1,12 @@ import React from 'react'; import { View } from 'react-native'; import { FlexItem, Page } from '@/components/layout'; -import DiscoverHome from './DiscoverHome'; -import DiscoverSearch from './DiscoverSearch'; -import DiscoverSearchContainer from './DiscoverSearchContainer'; +import DiscoverHome from '@/components/Discover/DiscoverHome'; +import DiscoverSearch from '@/components/Discover/DiscoverSearch'; +import DiscoverSearchContainer from '@/components/Discover/DiscoverSearchContainer'; import { Box, Inset } from '@/design-system'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; -import { useDiscoverScreenContext } from '../DiscoverScreenContext'; +import { useDiscoverScreenContext } from '@/components/Discover/DiscoverScreenContext'; function Switcher({ children }: { children: React.ReactNode[] }) { const { isSearching } = useDiscoverScreenContext(); diff --git a/src/screens/discover/DiscoverScreenContext.tsx b/src/components/Discover/DiscoverScreenContext.tsx similarity index 96% rename from src/screens/discover/DiscoverScreenContext.tsx rename to src/components/Discover/DiscoverScreenContext.tsx index eb9b276443d..31a8e89106b 100644 --- a/src/screens/discover/DiscoverScreenContext.tsx +++ b/src/components/Discover/DiscoverScreenContext.tsx @@ -2,6 +2,7 @@ import { analytics } from '@/analytics'; import React, { createContext, Dispatch, SetStateAction, RefObject, useState, useRef, useCallback } from 'react'; import { SectionList, TextInput } from 'react-native'; import Animated from 'react-native-reanimated'; +import { useTrackDiscoverScreenTime } from './useTrackDiscoverScreenTime'; type DiscoverScreenContextType = { scrollViewRef: RefObject; @@ -80,6 +81,8 @@ const DiscoverScreenProvider = ({ children }: { children: React.ReactNode }) => setIsSearching(false); }, [searchQuery]); + useTrackDiscoverScreenTime(); + return ( { + pressed.value = true; + if (onPress) runOnJS(onPress)(); + }) + .onFinalize(() => (pressed.value = false)); + + const animatedStyles = useAnimatedStyle(() => ({ + transform: [{ scale: withTiming(pressed.value ? 0.95 : 1, { duration: 100 }) }], + })); + + const backgroundColor = useBackgroundColor('fillTertiary'); + const borderColor = useBackgroundColor('fillSecondary'); + + const iconColor = useForegroundColor('labelQuaternary'); + + return ( + + + {typeof icon === 'string' ? ( + + {icon} + + ) : ( + icon + )} + + {label} + + + 􀆏 + + + + ); +} + +function useTrendingTokensData() { + const { chainId, category, timeframe, sort } = useTrendingTokensStore(state => ({ + chainId: state.chainId, + category: state.category, + timeframe: state.timeframe, + sort: state.sort, + })); + + return useTrendingTokens({ + chainId, + // category, + // timeframe, + // sort, + }); +} + +function ReportAnalytics() { + const activeSwipeRoute = useNavigationStore(state => state.activeSwipeRoute); + const { category, chainId } = useTrendingTokensStore(state => ({ category: state.category, chainId: state.chainId })); + const { data, isLoading } = useTrendingTokensData(); + + useEffect(() => { + if (isLoading || activeSwipeRoute !== Routes.DISCOVER_SCREEN) return; + + const isEmpty = (data?.trendingTokens?.data?.length ?? 0) === 0; + const isLimited = !isEmpty && (data?.trendingTokens?.data?.length ?? 0) < 6; + + analyticsV2.track(analyticsV2.event.viewRankedCategory, { + category, + chainId, + isLimited, + isEmpty, + }); + }, [isLoading, activeSwipeRoute, data?.trendingTokens.data.length, category, chainId]); + + return null; +} + +function CategoryFilterButton({ + category, + icon, + iconWidth = 16, + iconColor, + label, + highlightedBackgroundColor, +}: { + category: (typeof categories)[number]; + icon: string; + iconColor: string; + highlightedBackgroundColor: string; + iconWidth?: number; + label: string; +}) { + const { isDarkMode } = useTheme(); + const fillTertiary = useBackgroundColor('fillTertiary'); + const fillSecondary = useBackgroundColor('fillSecondary'); + + const selected = useTrendingTokensStore(state => state.category === category); + + const borderColor = selected && isDarkMode ? globalColors.white80 : fillSecondary; + + const pressed = useSharedValue(false); + + const selectCategory = useCallback(() => { + useTrendingTokensStore.getState().setCategory(category); + }, [category]); + + const tap = Gesture.Tap() + .onBegin(() => { + pressed.value = true; + }) + .onEnd(() => { + pressed.value = false; + runOnJS(selectCategory)(); + }); + + const animatedStyles = useAnimatedStyle(() => ({ + transform: [{ scale: withTiming(pressed.value ? 0.95 : 1, { duration: 100 }) }], + })); + + return ( + + + + {icon} + + + {label} + + + + ); +} + +function FriendHolders() { + const backgroundColor = useBackgroundColor('surfacePrimary'); + return ( + + + + + + + + mikedemarais{' '} + + and 2 others + + + + ); +} + +function TrendingTokenLoadingRow() { + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +function TrendingTokenRow({ item }: { item: TrendingTokensType['trendingTokens']['data'][number] }) { + const separatorColor = useForegroundColor('separator'); + + const priceValue = item.market.price?.value || 0; + const isPositiveChange = item.market.price?.change_24h && item.market.price?.change_24h > 0; + const price = priceValue < 0.001 ? `< $0.001` : formatNumber(priceValue, { useOrderSuffix: true, decimals: 4, style: '$' }); + const marketCap = formatNumber(item.market.market_cap?.value || 0, { useOrderSuffix: true, decimals: 1, style: '$' }); + const volume = formatNumber(item.market.volume_24h || 0, { useOrderSuffix: true, decimals: 1, style: '$' }); + + const handleNavigateToToken = useCallback(() => { + analyticsV2.track(analyticsV2.event.viewTrendingToken, { + address: item.address, + chainId: item.chainId, + symbol: item.symbol, + name: item.name, + highlightedFriends: 0, // TODO: Once data is available from backend + }); + + swapsStore.setState({ + lastNavigatedTrendingToken: item.uniqueId, + }); + + Navigation.handleAction(Routes.EXPANDED_ASSET_SHEET, { + asset: item, + type: 'token', + }); + }, [item]); + + if (!item) return null; + + return ( + + + + + + + + + + + + {item.name} + + + {item.symbol} + + + {price} + + + + + + + VOL + + + {volume} + + + + + | + + + + + MCAP + + + {marketCap} + + + + + + + + + {isPositiveChange ? '􀄨' : '􀄩'} + + + {formatNumber(item.market.price?.change_24h || 0, { decimals: 2, useOrderSuffix: true })}% + + + + + 1H + + + {formatNumber(item.market.price?.change_24h || 0, { decimals: 2, useOrderSuffix: true })}% + + + + + + + + ); +} + +function NoResults() { + const { isDarkMode } = useTheme(); + const fillQuaternary = useBackgroundColor('fillQuaternary'); + const backgroundColor = isDarkMode ? '#191A1C' : fillQuaternary; + + return ( + + + + {i18n.t(t.no_results.title)} + + + {i18n.t(t.no_results.body)} + + + + + 􀙭 + + + + ); +} + +function NetworkFilter() { + const [isOpen, setOpen] = useState(false); + const selected = useSharedValue(undefined); + + const { chainId, setChainId } = useTrendingTokensStore(state => ({ + chainId: state.chainId, + setChainId: state.setChainId, + })); + + const setSelected = useCallback( + (chainId: ChainId | undefined) => { + 'worklet'; + selected.value = chainId; + runOnJS(setChainId)(chainId); + }, + [selected, setChainId] + ); + + const label = useMemo(() => { + if (!chainId) return i18n.t(t.all); + return useBackendNetworksStore.getState().getChainsLabel()[chainId]; + }, [chainId]); + + const icon = useMemo(() => { + if (!chainId) return '􀤆'; + return ; + }, [chainId]); + + return ( + <> + setOpen(true)} /> + {isOpen && setOpen(false)} />} + + ); +} + +function TimeFilter() { + const timeframe = useTrendingTokensStore(state => state.timeframe); + + return ( + ({ + actionTitle: i18n.t(t.filters.time[time]), + actionKey: time, + })), + }} + side="bottom" + onPressMenuItem={timeframe => useTrendingTokensStore.getState().setTimeframe(timeframe)} + > + + + ); +} + +function SortFilter() { + const sort = useTrendingTokensStore(state => state.sort); + + const iconColor = useForegroundColor('labelQuaternary'); + + return ( + ({ + actionTitle: i18n.t(t.filters.sort[sort]), + actionKey: sort, + })), + }} + side="bottom" + onPressMenuItem={selection => { + if (selection === sort) return useTrendingTokensStore.getState().setSort(undefined); + useTrendingTokensStore.getState().setSort(selection); + }} + > + + 􀄬 + + } + /> + + ); +} + +function TrendingTokenData() { + const { data, isLoading } = useTrendingTokensData(); + if (isLoading) + return ( + + {Array.from({ length: 10 }).map((_, index) => ( + + ))} + + ); + + return ( + } + data={data?.trendingTokens.data} + renderItem={({ item }) => } + /> + ); +} + +export function TrendingTokens() { + const padding = 20; + return ( + + + + + + + + + + + + + + + + + + + ); +} diff --git a/src/components/Discover/useTrackDiscoverScreenTime.ts b/src/components/Discover/useTrackDiscoverScreenTime.ts new file mode 100644 index 00000000000..64e96a50fa1 --- /dev/null +++ b/src/components/Discover/useTrackDiscoverScreenTime.ts @@ -0,0 +1,21 @@ +import { useNavigationStore } from '@/state/navigation/navigationStore'; +import { useEffect } from 'react'; +import Routes from '@/navigation/routesNames'; +import { PerformanceTracking, currentlyTrackedMetrics } from '@/performance/tracking'; +import { PerformanceMetrics } from '@/performance/tracking/types/PerformanceMetrics'; + +export const useTrackDiscoverScreenTime = () => { + const activeSwipeRoute = useNavigationStore(state => state.activeSwipeRoute); + useEffect(() => { + const isOnDiscoverScreen = activeSwipeRoute === Routes.DISCOVER_SCREEN; + const data = currentlyTrackedMetrics.get(PerformanceMetrics.timeSpentOnDiscoverScreen); + + if (!isOnDiscoverScreen && data?.startTimestamp) { + PerformanceTracking.finishMeasuring(PerformanceMetrics.timeSpentOnDiscoverScreen); + } + + if (isOnDiscoverScreen) { + PerformanceTracking.startMeasuring(PerformanceMetrics.timeSpentOnDiscoverScreen); + } + }, [activeSwipeRoute]); +}; diff --git a/src/components/NetworkSwitcher.tsx b/src/components/NetworkSwitcher.tsx new file mode 100644 index 00000000000..da623e9b840 --- /dev/null +++ b/src/components/NetworkSwitcher.tsx @@ -0,0 +1,764 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { getChainColorWorklet } from '@/__swaps__/utils/swaps'; +import { useBackendNetworksStore } from '@/state/backendNetworks/backendNetworks'; +import { ChainId } from '@/state/backendNetworks/types'; +import { AbsolutePortal } from '@/components/AbsolutePortal'; +import { AnimatedBlurView } from '@/components/AnimatedComponents/AnimatedBlurView'; +import { ButtonPressAnimation } from '@/components/animations'; +import { SPRING_CONFIGS } from '@/components/animations/animationConfigs'; +import { AnimatedChainImage, ChainImage } from '@/components/coin-icon/ChainImage'; +import { AnimatedText, DesignSystemProvider, globalColors, Separator, Text, useBackgroundColor, useColorMode } from '@/design-system'; +import { useForegroundColor } from '@/design-system/color/useForegroundColor'; +import * as i18n from '@/languages'; +import { useTheme } from '@/theme'; +import { DEVICE_WIDTH } from '@/utils/deviceUtils'; +import MaskedView from '@react-native-masked-view/masked-view'; +import chroma from 'chroma-js'; +import { PropsWithChildren, ReactElement, useEffect } from 'react'; +import React, { Pressable, View } from 'react-native'; +import { Gesture, GestureDetector, State, TapGesture } from 'react-native-gesture-handler'; +import LinearGradient from 'react-native-linear-gradient'; +import Animated, { + FadeIn, + FadeOut, + FadeOutUp, + LinearTransition, + runOnJS, + SharedValue, + SlideInDown, + SlideOutDown, + useAnimatedReaction, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withDelay, + withSequence, + withSpring, + withTiming, +} from 'react-native-reanimated'; +import Svg, { Path } from 'react-native-svg'; +import { + customizeNetworksBannerStore, + dismissCustomizeNetworksBanner, + networkSwitcherStore, + shouldShowCustomizeNetworksBanner, + showCustomizeNetworksBanner, +} from '@/state/networkSwitcher/networkSwitcher'; + +const t = i18n.l.network_switcher; + +const translations = { + edit: i18n.t(t.edit), + done: i18n.t(i18n.l.done), + networks: i18n.t(t.networks), + show_more: i18n.t(t.show_more), + show_less: i18n.t(t.show_less), + drag_to_rearrange: i18n.t(t.drag_to_rearrange), +}; + +function EditButton({ editing }: { editing: SharedValue }) { + const blue = useForegroundColor('blue'); + const borderColor = chroma(blue).alpha(0.08).hex(); + + const text = useDerivedValue(() => (editing.value ? translations.done : translations.edit)); + + return ( + { + 'worklet'; + editing.value = !editing.value; + }} + scaleTo={0.95} + style={[ + { position: 'absolute', right: 0 }, + { paddingHorizontal: 10, height: 28, justifyContent: 'center' }, + { borderColor, borderWidth: 1.33, borderRadius: 14 }, + ]} + > + + {text} + + + ); +} + +function Header({ editing }: { editing: SharedValue }) { + const separatorTertiary = useForegroundColor('separatorTertiary'); + const fill = useForegroundColor('fill'); + + const title = useDerivedValue(() => { + return editing.value ? translations.edit : translations.networks; + }); + + return ( + + + + + + + + {title} + + + + + + ); +} + +const CustomizeNetworksBanner = !showCustomizeNetworksBanner + ? () => null + : function CustomizeNetworksBanner({ editing }: { editing: SharedValue }) { + useAnimatedReaction( + () => editing.value, + (editing, prev) => { + if (!prev && editing) runOnJS(dismissCustomizeNetworksBanner)(); + } + ); + + const dismissedAt = customizeNetworksBannerStore(s => s.dismissedAt); + if (!shouldShowCustomizeNetworksBanner(dismissedAt)) return null; + + const height = 75; + const blue = '#268FFF'; + + return ( + + + + + + } + > + + + + + 􀍱 + + + + + {i18n.t(t.customize_networks_banner.title)} + + + {/* + is there a way to render a diferent component mid sentence? + like i18n.t(t.customize_networks_banner.description, { Edit: }) + */} + Tap the{' '} + + Edit + {' '} + button below to set up + + + + + 􀆄 + + + + + + + + ); + }; + +const useNetworkOptionStyle = (isSelected: SharedValue, color: string) => { + const { isDarkMode } = useColorMode(); + + const surfacePrimary = useBackgroundColor('surfacePrimary'); + const networkSwitcherBackgroundColor = isDarkMode ? '#191A1C' : surfacePrimary; + + const defaultStyle = { + backgroundColor: isDarkMode ? globalColors.white10 : globalColors.grey20, + borderColor: '#F5F8FF05', + }; + const selectedStyle = { + backgroundColor: chroma.scale([networkSwitcherBackgroundColor, color])(0.16).hex(), + borderColor: chroma(color).alpha(0.16).hex(), + }; + + const scale = useSharedValue(1); + useAnimatedReaction( + () => isSelected.value, + current => { + if (current === true) { + scale.value = withSequence(withTiming(0.95, { duration: 50 }), withTiming(1, { duration: 80 })); + } + } + ); + + const animatedStyle = useAnimatedStyle(() => { + const colors = isSelected.value ? selectedStyle : defaultStyle; + return { + backgroundColor: colors.backgroundColor, + borderColor: colors.borderColor, + transform: [{ scale: scale.value }], + }; + }); + + return { + animatedStyle, + selectedStyle, + defaultStyle, + }; +}; + +function AllNetworksOption({ + selected, + setSelected, +}: { + selected: SharedValue; + setSelected: (chainId: ChainId | undefined) => void; +}) { + const blue = useForegroundColor('blue'); + + const isSelected = useDerivedValue(() => selected.value === undefined); + const { animatedStyle, selectedStyle, defaultStyle } = useNetworkOptionStyle(isSelected, blue); + + const overlappingBadge = useAnimatedStyle(() => { + return { + borderColor: isSelected.value ? selectedStyle.backgroundColor : defaultStyle.backgroundColor, + borderWidth: 1.67, + borderRadius: 16, + marginLeft: -9, + width: 16 + 1.67 * 2, // size + borders + height: 16 + 1.67 * 2, + }; + }); + + const tapGesture = Gesture.Tap().onTouchesDown(() => { + 'worklet'; + setSelected(undefined); + }); + + return ( + + + + + + + + + + {i18n.t(t.all_networks)} + + + + ); +} + +function AllNetworksSection({ + editing, + setSelected, + selected, +}: { + editing: SharedValue; + setSelected: (chainId: ChainId | undefined) => void; + selected: SharedValue; +}) { + const style = useAnimatedStyle(() => ({ + opacity: editing.value ? withTiming(0, { duration: 50 }) : withDelay(250, withTiming(1, { duration: 250 })), + height: withTiming( + editing.value ? 0 : ITEM_HEIGHT + 14, // 14 is the gap to the separator + { duration: 250 } + ), + marginTop: editing.value ? 0 : 14, + pointerEvents: editing.value ? 'none' : 'auto', + })); + return ( + + + + + ); +} + +function NetworkOption({ chainId, selected }: { chainId: ChainId; selected: SharedValue }) { + const chainName = useBackendNetworksStore.getState().getChainsName()[chainId]; + const chainColor = getChainColorWorklet(chainId, true); + const isSelected = useDerivedValue(() => selected.value === chainId); + const { animatedStyle } = useNetworkOptionStyle(isSelected, chainColor); + + return ( + + + + {chainName} + + + ); +} + +const SHEET_OUTER_INSET = 8; +const SHEET_INNER_PADDING = 16; +const GAP = 12; +const ITEM_WIDTH = (DEVICE_WIDTH - SHEET_INNER_PADDING * 2 - SHEET_OUTER_INSET * 2 - GAP) / 2; +const ITEM_HEIGHT = 48; +const SEPARATOR_HEIGHT = 68; +const enum Section { + pinned, + unpinned, +} + +function Draggable({ + children, + dragging, + chainId, + networks, + sectionsOffsets, + isUnpinnedHidden, +}: PropsWithChildren<{ + chainId: ChainId; + dragging: SharedValue; + networks: SharedValue>; + sectionsOffsets: SharedValue>; + isUnpinnedHidden: SharedValue; +}>) { + const zIndex = useSharedValue(0); + useAnimatedReaction( + () => dragging.value?.chainId, + (current, prev) => { + if (current === prev) return; + if (current === chainId) zIndex.value = 2; + if (prev === chainId) zIndex.value = 1; + } + ); + + const draggableStyles = useAnimatedStyle(() => { + const section = networks.value[Section.pinned].includes(chainId) ? Section.pinned : Section.unpinned; + const itemIndex = networks.value[section].indexOf(chainId); + const slotPosition = positionFromIndex(itemIndex, sectionsOffsets.value[section]); + + const opacity = + section === Section.unpinned && isUnpinnedHidden.value ? withTiming(0, { duration: 150 }) : withDelay(150, withTiming(1)); + + const isBeingDragged = dragging.value?.chainId === chainId; + const position = isBeingDragged ? dragging.value!.position : slotPosition; + + return { + opacity, + zIndex: zIndex.value, + transform: [ + { scale: withSpring(isBeingDragged ? 1.05 : 1, SPRING_CONFIGS.springConfig) }, + { translateX: isBeingDragged ? position.x : withSpring(position.x, SPRING_CONFIGS.springConfig) }, + { translateY: isBeingDragged ? position.y : withSpring(position.y, SPRING_CONFIGS.springConfig) }, + ], + }; + }); + + return {children}; +} + +const indexFromPosition = (x: number, y: number, offset: { y: number }) => { + 'worklet'; + const yoffsets = y > offset.y ? offset.y : 0; + const column = x > ITEM_WIDTH + GAP / 2 ? 1 : 0; + const row = Math.floor((y - yoffsets) / (ITEM_HEIGHT + GAP / 2)); + const index = row * 2 + column; + return index < 0 ? 0 : index; // row can be negative if the dragged item is above the first row +}; + +const positionFromIndex = (index: number, offset: { y: number }) => { + 'worklet'; + const column = index % 2; + const row = Math.floor(index / 2); + const position = { x: column * (ITEM_WIDTH + GAP), y: row * (ITEM_HEIGHT + GAP) + offset.y }; + return position; +}; + +type Point = { x: number; y: number }; +type DraggingState = { + chainId: ChainId; + position: Point; +}; + +function SectionSeparator({ + y, + editing, + expanded, + networks, + tapExpand, + pressedExpand, +}: { + y: SharedValue; + editing: SharedValue; + expanded: SharedValue; + networks: SharedValue>; + tapExpand: TapGesture; + pressedExpand: SharedValue; +}) { + const separatorStyles = useAnimatedStyle(() => ({ + transform: [{ translateY: y.value }, { scale: withTiming(pressedExpand.value ? 0.95 : 1) }], + })); + + const text = useDerivedValue(() => { + if (editing.value) return translations.drag_to_rearrange; + return expanded.value ? translations.show_less : translations.show_more; + }); + + const unpinnedNetworksLength = useDerivedValue(() => networks.value[Section.unpinned].length.toString()); + const showMoreAmountStyle = useAnimatedStyle(() => ({ opacity: expanded.value || editing.value ? 0 : 1 })); + const showMoreOrLessIcon = useDerivedValue(() => (expanded.value ? '􀆇' : '􀆈') as string); + const showMoreOrLessIconStyle = useAnimatedStyle(() => ({ opacity: editing.value ? 0 : 1 })); + + const { isDarkMode } = useTheme(); + + return ( + + + + + {unpinnedNetworksLength} + + + + {text} + + + + {showMoreOrLessIcon} + + + + + ); +} + +function NetworksGrid({ + editing, + setSelected, + selected, +}: { + editing: SharedValue; + setSelected: (chainId: ChainId | undefined) => void; + selected: SharedValue; +}) { + const initialPinned = networkSwitcherStore.getState().pinnedNetworks; + const sortedSupportedChainIds = useBackendNetworksStore.getState().getSortedSupportedChainIds(); + const initialUnpinned = sortedSupportedChainIds.filter(chainId => !initialPinned.includes(chainId)); + const networks = useSharedValue({ [Section.pinned]: initialPinned, [Section.unpinned]: initialUnpinned }); + + useEffect(() => { + // persists pinned networks when closing the sheet + // should be the only time this component is unmounted + return () => { + networkSwitcherStore.setState({ pinnedNetworks: networks.value[Section.pinned] }); + }; + }, [networks]); + + const expanded = useSharedValue(false); + const isUnpinnedHidden = useDerivedValue(() => !expanded.value && !editing.value); + + const dragging = useSharedValue(null); + + const pinnedHeight = useDerivedValue(() => Math.ceil(networks.value[Section.pinned].length / 2) * (ITEM_HEIGHT + GAP) - GAP); + const sectionsOffsets = useDerivedValue(() => ({ + [Section.pinned]: { y: 0 }, + [Section.unpinned]: { y: pinnedHeight.value + SEPARATOR_HEIGHT }, + })); + const containerStyle = useAnimatedStyle(() => { + const unpinnedHeight = isUnpinnedHidden.value + ? 0 + : Math.ceil(networks.value[Section.unpinned].length / 2) * (ITEM_HEIGHT + GAP) - GAP + 32; + const height = pinnedHeight.value + SEPARATOR_HEIGHT + unpinnedHeight; + return { height: withTiming(height) }; + }); + + const dragNetwork = Gesture.Pan() + .maxPointers(1) + .onTouchesDown((e, s) => { + if (!editing.value) { + s.fail(); + return; + } + const touch = e.allTouches[0]; + const section = touch.y > sectionsOffsets.value[Section.unpinned].y ? Section.unpinned : Section.pinned; + const sectionOffset = sectionsOffsets.value[section]; + const index = indexFromPosition(touch.x, touch.y, sectionOffset); + const chainId = networks.value[section][index]; + if (!chainId) { + s.fail(); + return; + } + + const position = positionFromIndex(index, sectionOffset); + dragging.value = { chainId, position }; + }) + .onChange(e => { + if (!dragging.value) return; + const chainId = dragging.value.chainId; + if (!chainId) return; + + const section = e.y > sectionsOffsets.value[Section.unpinned].y ? Section.unpinned : Section.pinned; + const sectionArray = networks.value[section]; + + const currentIndex = sectionArray.indexOf(chainId); + const newIndex = Math.min(indexFromPosition(e.x, e.y, sectionsOffsets.value[section]), sectionArray.length - 1); + + networks.modify(networks => { + if (currentIndex === -1) { + // Pin/Unpin + if (section === Section.unpinned) networks[Section.pinned].splice(currentIndex, 1); + else networks[Section.pinned].splice(newIndex, 0, chainId); + networks[Section.unpinned] = sortedSupportedChainIds.filter(chainId => !networks[Section.pinned].includes(chainId)); + } else if (section === Section.pinned && newIndex !== currentIndex) { + // Reorder + networks[Section.pinned].splice(currentIndex, 1); + networks[Section.pinned].splice(newIndex, 0, chainId); + } + return networks; + }); + dragging.modify(dragging => { + if (!dragging) return dragging; + dragging.position.x += e.changeX; + dragging.position.y += e.changeY; + return dragging; + }); + }) + .onFinalize(() => { + dragging.value = null; + }); + + const pressedExpand = useSharedValue(false); + + // TODO: Need to prevent this from firing the tapNetwork as well + const tapExpand = Gesture.Tap() + .onBegin(e => { + if (editing.value) { + e.state = State.FAILED; + } + pressedExpand.value = true; + }) + .onEnd(() => { + pressedExpand.value = false; + expanded.value = !expanded.value; + }); + + const tapNetwork = Gesture.Tap() + .onTouchesDown((e, s) => { + if (editing.value) { + s.fail(); + } + + const touches = e.allTouches[0]; + const section = touches.y > sectionsOffsets.value[Section.unpinned].y ? Section.unpinned : Section.pinned; + const index = indexFromPosition(touches.x, touches.y, sectionsOffsets.value[section]); + const chainId = networks.value[section][index]; + if (!chainId) { + s.fail(); + } + }) + .onEnd(e => { + const section = e.y > sectionsOffsets.value[Section.unpinned].y ? Section.unpinned : Section.pinned; + const index = indexFromPosition(e.x, e.y, sectionsOffsets.value[section]); + const chainId = networks.value[section][index]; + if (!chainId) return; + + setSelected(chainId); + }); + + const gridGesture = Gesture.Exclusive(dragNetwork, tapNetwork); + + return ( + + + {initialPinned.map(chainId => ( + + + + ))} + + + + {/* {initialUnpinned.length === 0 && ( + + + Drag here to unpin networks + + + )} */} + {initialUnpinned.map(chainId => ( + + + + ))} + + + ); +} + +function SheetBackdrop({ onPress }: { onPress: VoidFunction }) { + const tapGesture = Gesture.Tap().onEnd(onPress); + return ( + + + + ); +} + +function Sheet({ children, header, onClose }: PropsWithChildren<{ header: ReactElement; onClose: VoidFunction }>) { + const { isDarkMode } = useTheme(); + const surfacePrimary = useBackgroundColor('surfacePrimary'); + const backgroundColor = isDarkMode ? '#191A1C' : surfacePrimary; + const separatorSecondary = useForegroundColor('separatorSecondary'); + + const translationY = useSharedValue(0); + + const swipeToClose = Gesture.Pan() + .onChange(event => { + if (event.translationY < 0) return; + translationY.value = event.translationY; + }) + .onFinalize(() => { + if (translationY.value > 120) runOnJS(onClose)(); + else translationY.value = withSpring(0); + }); + + const sheetStyle = useAnimatedStyle(() => ({ transform: [{ translateY: translationY.value }] })); + + return ( + + + + + + {header} + {children} + + + + ); +} + +export function NetworkSelector({ + onClose, + selected, + setSelected, +}: { + onClose: VoidFunction; + selected: SharedValue; + setSelected: (chainId: ChainId | undefined) => void; +}) { + const editing = useSharedValue(false); + + return ( + } onClose={onClose}> + + + + + ); +} diff --git a/src/components/coin-icon/ChainImage.tsx b/src/components/coin-icon/ChainImage.tsx index 1aa5c479b8e..8e6da088f55 100644 --- a/src/components/coin-icon/ChainImage.tsx +++ b/src/components/coin-icon/ChainImage.tsx @@ -1,4 +1,4 @@ -import React, { useMemo } from 'react'; +import React, { forwardRef, useMemo } from 'react'; import { ChainId } from '@/state/backendNetworks/types'; import ApechainBadge from '@/assets/badges/apechain.png'; @@ -20,9 +20,21 @@ import PolygonBadge from '@/assets/badges/polygon.png'; // import ZksyncBadge from '@/assets/badges/zksync.png'; import ZoraBadge from '@/assets/badges/zora.png'; -import FastImage, { Source } from 'react-native-fast-image'; +import FastImage, { FastImageProps, Source } from 'react-native-fast-image'; +import Animated from 'react-native-reanimated'; -export function ChainImage({ chainId, size = 20 }: { chainId: ChainId | null | undefined; size?: number }) { +export const ChainImage = forwardRef(function ChainImage( + { + chainId, + size = 20, + style, + }: { + chainId: ChainId | null | undefined; + size?: number; + style?: FastImageProps['style']; + }, + ref +) { const source = useMemo(() => { switch (chainId) { case ChainId.apechain: @@ -69,6 +81,14 @@ export function ChainImage({ chainId, size = 20 }: { chainId: ChainId | null | u if (!chainId) return null; return ( - + ); -} +}); + +export const AnimatedChainImage = Animated.createAnimatedComponent(ChainImage); diff --git a/src/config/experimental.ts b/src/config/experimental.ts index b68e23260ff..cef7e04d7d7 100644 --- a/src/config/experimental.ts +++ b/src/config/experimental.ts @@ -29,6 +29,7 @@ export const DEGEN_MODE = 'Degen Mode'; export const FEATURED_RESULTS = 'Featured Results'; export const CLAIMABLES = 'Claimables'; export const NFTS_ENABLED = 'Nfts Enabled'; +export const TRENDING_TOKENS = 'Trending Tokens'; /** * A developer setting that pushes log lines to an array in-memory so that @@ -66,6 +67,7 @@ export const defaultConfig: Record = { [FEATURED_RESULTS]: { settings: true, value: false }, [CLAIMABLES]: { settings: true, value: false }, [NFTS_ENABLED]: { settings: true, value: !!IS_TEST }, + [TRENDING_TOKENS]: { settings: true, value: false }, }; export const defaultConfigValues: Record = Object.fromEntries( diff --git a/src/helpers/strings.ts b/src/helpers/strings.ts index 4e7fd76ab91..a4151c0dc0b 100644 --- a/src/helpers/strings.ts +++ b/src/helpers/strings.ts @@ -10,3 +10,59 @@ export const containsEmoji = memoFn(str => { // @ts-expect-error ts-migrate(2571) FIXME: Object is of type 'unknown'. return !!str.match(ranges.join('|')); }); + +/* + * Return the given number as a formatted string. The default format is a plain + * integer with thousands-separator commas. The optional parameters facilitate + * other formats: + * - decimals = the number of decimals places to round to and show + * - valueIfNaN = the value to show for non-numeric input + * - style + * - '%': multiplies by 100 and appends a percent symbol + * - '$': prepends a dollar sign + * - useOrderSuffix = whether to use suffixes like k for 1,000, etc. + * - orderSuffixes = the list of suffixes to use + * - minOrder and maxOrder allow the order to be constrained. Examples: + * - minOrder = 1 means the k suffix should be used for numbers < 1,000 + * - maxOrder = 1 means the k suffix should be used for numbers >= 1,000,000 + */ +export function formatNumber( + number: string | number, + { + decimals = 0, + valueIfNaN = '', + style = '', + useOrderSuffix = false, + orderSuffixes = ['', 'K', 'M', 'B', 'T'], + minOrder = 0, + maxOrder = Infinity, + } = {} +) { + let x = parseFloat(`${number}`); + + if (isNaN(x)) return valueIfNaN; + + if (style === '%') x *= 100.0; + + let order; + if (!isFinite(x) || !useOrderSuffix) order = 0; + else if (minOrder === maxOrder) order = minOrder; + else { + const unboundedOrder = Math.floor(Math.log10(Math.abs(x)) / 3); + order = Math.max(0, minOrder, Math.min(unboundedOrder, maxOrder, orderSuffixes.length - 1)); + } + + const orderSuffix = orderSuffixes[order]; + if (order !== 0) x /= Math.pow(10, order * 3); + + return ( + (style === '$' ? '$' : '') + + x.toLocaleString('en-US', { + style: 'decimal', + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }) + + orderSuffix + + (style === '%' ? '%' : '') + ); +} diff --git a/src/hooks/reanimated/useSyncSharedValue.ts b/src/hooks/reanimated/useSyncSharedValue.ts index f8c19c71a0c..c48f83a3643 100644 --- a/src/hooks/reanimated/useSyncSharedValue.ts +++ b/src/hooks/reanimated/useSyncSharedValue.ts @@ -9,14 +9,14 @@ interface BaseSyncParams { /** A boolean or shared value boolean that controls whether synchronization is paused. */ pauseSync?: DerivedValue | SharedValue | boolean; /** The JS state to be synchronized. */ - state: T | undefined; + state: T; } interface SharedToStateParams extends BaseSyncParams { /** The setter function for the JS state (only applicable when `syncDirection` is `'sharedValueToState'`). */ setState: (value: T) => void; /** The shared value to be synchronized. */ - sharedValue: DerivedValue | DerivedValue | SharedValue | SharedValue; + sharedValue: DerivedValue | SharedValue; /** The direction of synchronization. */ syncDirection: 'sharedValueToState'; } @@ -24,7 +24,7 @@ interface SharedToStateParams extends BaseSyncParams { interface StateToSharedParams extends BaseSyncParams { setState?: never; /** The shared value to be synchronized. */ - sharedValue: SharedValue | SharedValue; + sharedValue: SharedValue; /** The direction of synchronization. */ syncDirection: 'stateToSharedValue'; } @@ -73,7 +73,7 @@ export function useSyncSharedValue({ compareDepth = 'deep', pauseSync, setSta }, shouldSync => { if (shouldSync) { - if (syncDirection === 'sharedValueToState' && sharedValue.value !== undefined) { + if (syncDirection === 'sharedValueToState') { runOnJS(setState)(sharedValue.value); } else if (syncDirection === 'stateToSharedValue') { sharedValue.value = state; diff --git a/src/languages/en_US.json b/src/languages/en_US.json index 725d9b674d1..bcc76782c2b 100644 --- a/src/languages/en_US.json +++ b/src/languages/en_US.json @@ -3018,6 +3018,45 @@ "new_tab": "New Tab" } }, + "trending_tokens": { + "all": "All", + "no_results": { + "title": "No results", + "body": "Try browsing a larger timeframe or a different network or category." + }, + "filters": { + "categories": { + "trending": "Trending", + "new": "New", + "farcaster": "Farcaster" + }, + "sort": { + "sort": "Sort", + "volume": "Volume", + "market_cap": "Market Cap", + "top_gainers": "Top Gainers", + "top_losers": "Top Losers" + }, + "time": { + "day": "24h", + "week": "1 Week", + "month": "1 Month" + } + } + }, + "network_switcher": { + "customize_networks_banner": { + "title": "Customize Networks", + "description": "Tap the edit button below to set up" + }, + "edit": "Edit", + "networks": "Networks", + "drag_to_rearrange": "Drag to rearrange", + "show_less": "Show less", + "show_more": "More Networks", + "all_networks": "All Networks" + }, + "done": "Done", "copy": "Copy", "paste": "Paste" } diff --git a/src/model/remoteConfig.ts b/src/model/remoteConfig.ts index 40382af707b..1b131c68a7a 100644 --- a/src/model/remoteConfig.ts +++ b/src/model/remoteConfig.ts @@ -52,6 +52,7 @@ export interface RainbowConfig extends Record dapp_browser: boolean; idfa_check_enabled: boolean; rewards_enabled: boolean; + trending_tokens_enabled: boolean; degen_mode: boolean; featured_results: boolean; @@ -142,6 +143,7 @@ export const DEFAULT_CONFIG: RainbowConfig = { dapp_browser: true, idfa_check_enabled: true, rewards_enabled: true, + trending_tokens_enabled: false, degen_mode: true, featured_results: true, @@ -199,6 +201,7 @@ export async function fetchRemoteConfig(): Promise { key === 'dapp_browser' || key === 'idfa_check_enabled' || key === 'rewards_enabled' || + key === 'trending_tokens_enabled' || key === 'degen_mode' || key === 'featured_results' || key === 'claimables' || diff --git a/src/navigation/SwipeNavigator.tsx b/src/navigation/SwipeNavigator.tsx index 33f84504828..781ca8932d4 100644 --- a/src/navigation/SwipeNavigator.tsx +++ b/src/navigation/SwipeNavigator.tsx @@ -15,7 +15,7 @@ import RecyclerListViewScrollToTopProvider, { useRecyclerListViewScrollToTopContext, } from '@/navigation/RecyclerListViewScrollToTopContext'; import DappBrowserScreen from '@/screens/dapp-browser/DappBrowserScreen'; -import { discoverOpenSearchFnRef } from '@/screens/discover/components/DiscoverSearchContainer'; +import { discoverOpenSearchFnRef } from '@/components/Discover/DiscoverSearchContainer'; import { PointsScreen } from '@/screens/points/PointsScreen'; import WalletScreen from '@/screens/WalletScreen'; import { useTheme } from '@/theme'; @@ -39,7 +39,7 @@ import { TIMING_CONFIGS } from '@/components/animations/animationConfigs'; import { useBrowserStore } from '@/state/browser/browserStore'; import { opacityWorklet } from '@/__swaps__/utils/swaps'; import ProfileScreen from '../screens/ProfileScreen'; -import DiscoverScreen, { discoverScrollToTopFnRef } from '../screens/discover/DiscoverScreen'; +import DiscoverScreen, { discoverScrollToTopFnRef } from '@/screens/DiscoverScreen'; import { ScrollPositionContext } from './ScrollPositionContext'; import SectionListScrollToTopProvider, { useSectionListScrollToTopContext } from './SectionListScrollToTopContext'; import Routes from './routesNames'; diff --git a/src/performance/tracking/index.ts b/src/performance/tracking/index.ts index 425faba867e..81b8fb5e73a 100644 --- a/src/performance/tracking/index.ts +++ b/src/performance/tracking/index.ts @@ -18,7 +18,7 @@ function logDurationIfAppropriate(metric: PerformanceMetricsType, durationInMs: } } -const currentlyTrackedMetrics = new Map(); +export const currentlyTrackedMetrics = new Map(); interface AdditionalParams extends Record { tag?: PerformanceTagsType; diff --git a/src/performance/tracking/types/PerformanceMetrics.ts b/src/performance/tracking/types/PerformanceMetrics.ts index 3baf050eb54..3d272e1e71b 100644 --- a/src/performance/tracking/types/PerformanceMetrics.ts +++ b/src/performance/tracking/types/PerformanceMetrics.ts @@ -11,6 +11,7 @@ export const PerformanceMetrics = { initializeWalletconnect: 'Performance WalletConnect Initialize Time', quoteFetching: 'Performance Quote Fetching Time', + timeSpentOnDiscoverScreen: 'Time spent on the Discover screen', } as const; export type PerformanceMetricsType = (typeof PerformanceMetrics)[keyof typeof PerformanceMetrics]; diff --git a/src/resources/trendingTokens/trendingTokens.ts b/src/resources/trendingTokens/trendingTokens.ts index 169a24ab6f4..06e6f623318 100644 --- a/src/resources/trendingTokens/trendingTokens.ts +++ b/src/resources/trendingTokens/trendingTokens.ts @@ -22,5 +22,6 @@ export function useTrendingTokens( ...config, staleTime: 60_000, // 1 minute cacheTime: 60_000 * 30, // 30 minutes + keepPreviousData: true, }); } diff --git a/src/screens/discover/DiscoverScreen.tsx b/src/screens/DiscoverScreen.tsx similarity index 95% rename from src/screens/discover/DiscoverScreen.tsx rename to src/screens/DiscoverScreen.tsx index 601066d6260..2a0d43df6a2 100644 --- a/src/screens/discover/DiscoverScreen.tsx +++ b/src/screens/DiscoverScreen.tsx @@ -4,7 +4,7 @@ import { useIsFocused } from '@react-navigation/native'; import { Box } from '@/design-system'; import { Page } from '@/components/layout'; import { Navbar } from '@/components/navbar/Navbar'; -import DiscoverScreenContent from './components/DiscoverScreenContent'; +import DiscoverScreenContent from '@/components/Discover/DiscoverScreenContent'; import { ButtonPressAnimation } from '@/components/animations'; import { ContactAvatar } from '@/components/contacts'; import ImageAvatar from '@/components/contacts/ImageAvatar'; @@ -14,7 +14,7 @@ import { useNavigation } from '@/navigation'; import { safeAreaInsetValues } from '@/utils'; import * as i18n from '@/languages'; import Animated, { useAnimatedScrollHandler, useSharedValue } from 'react-native-reanimated'; -import DiscoverScreenProvider, { useDiscoverScreenContext } from './DiscoverScreenContext'; +import DiscoverScreenProvider, { useDiscoverScreenContext } from '@/components/Discover/DiscoverScreenContext'; export let discoverScrollToTopFnRef: () => number | null = () => null; @@ -30,18 +30,18 @@ const Content = () => { navigate(Routes.CHANGE_WALLET_SHEET); }, [navigate]); - React.useEffect(() => { - if (isSearching && !isFocused) { - Keyboard.dismiss(); - } - }, [isFocused, isSearching]); - const scrollHandler = useAnimatedScrollHandler({ onScroll: event => { scrollY.value = event.contentOffset.y; }, }); + useEffect(() => { + if (isSearching && !isFocused) { + Keyboard.dismiss(); + } + }, [isFocused, isSearching]); + useEffect(() => { discoverScrollToTopFnRef = scrollToTop; }, [scrollToTop]); diff --git a/src/state/backendNetworks/backendNetworks.ts b/src/state/backendNetworks/backendNetworks.ts index 9e1882f2601..60736773864 100644 --- a/src/state/backendNetworks/backendNetworks.ts +++ b/src/state/backendNetworks/backendNetworks.ts @@ -19,6 +19,7 @@ export interface BackendNetworksState { getBackendChains: () => Chain[]; getSupportedChains: () => Chain[]; + getSortedSupportedChainIds: () => number[]; getDefaultChains: () => Record; getSupportedChainIds: () => ChainId[]; @@ -75,6 +76,11 @@ export const useBackendNetworksStore = createRainbowStore( return IS_TEST ? [...backendChains, chainHardhat, chainHardhatOptimism] : backendChains; }, + getSortedSupportedChainIds: () => { + const supportedChains = get().getSupportedChains(); + return supportedChains.sort((a, b) => a.name.localeCompare(b.name)).map(c => c.id); + }, + getDefaultChains: () => { const supportedChains = get().getSupportedChains(); return supportedChains.reduce( diff --git a/src/state/internal/createRainbowStore.ts b/src/state/internal/createRainbowStore.ts index 9b7d9a39dd7..e0c14b79ead 100644 --- a/src/state/internal/createRainbowStore.ts +++ b/src/state/internal/createRainbowStore.ts @@ -43,6 +43,12 @@ interface RainbowPersistConfig { * This function will be called when persisted state versions mismatch with the one specified here. */ migrate?: (persistedState: unknown, version: number) => S | Promise; + /** + * A function returning another (optional) function. + * The main function will be called before the state rehydration. + * The returned function will be called after the state rehydration or when an error occurred. + */ + onRehydrateStorage?: PersistOptions>['onRehydrateStorage']; } /** @@ -157,6 +163,7 @@ export function createRainbowStore( storage: persistStorage, version, migrate: persistConfig.migrate, + onRehydrateStorage: persistConfig.onRehydrateStorage, }) ) ); diff --git a/src/state/networkSwitcher/networkSwitcher.ts b/src/state/networkSwitcher/networkSwitcher.ts new file mode 100644 index 00000000000..dae6637446a --- /dev/null +++ b/src/state/networkSwitcher/networkSwitcher.ts @@ -0,0 +1,48 @@ +import { ChainId } from '@/state/backendNetworks/types'; +import { createRainbowStore } from '../internal/createRainbowStore'; +import { analyticsV2 } from '@/analytics'; +import { nonceStore } from '@/state/nonces'; + +function getMostUsedChains() { + const noncesByAddress = nonceStore.getState().nonces; + const summedNoncesByChainId: Record = {}; + for (const addressNonces of Object.values(noncesByAddress)) { + for (const [chainId, { currentNonce }] of Object.entries(addressNonces)) { + summedNoncesByChainId[chainId] ??= 0; + summedNoncesByChainId[chainId] += currentNonce || 0; + } + } + + return Object.entries(summedNoncesByChainId) + .sort((a, b) => b[1] - a[1]) + .map(([chainId]) => parseInt(chainId)); +} + +export const networkSwitcherStore = createRainbowStore<{ + pinnedNetworks: ChainId[]; +}>(() => ({ pinnedNetworks: getMostUsedChains().slice(0, 5) }), { + storageKey: 'network-switcher', + version: 0, + onRehydrateStorage(state) { + // if we are missing pinned networks, use the user most used chains + if (state.pinnedNetworks.length === 0) { + const mostUsedNetworks = getMostUsedChains(); + state.pinnedNetworks = mostUsedNetworks.slice(0, 5); + analyticsV2.identify({ mostUsedNetworks: mostUsedNetworks.filter(Boolean) }); + } + }, +}); + +export const customizeNetworksBannerStore = createRainbowStore<{ + dismissedAt: number; // timestamp +}>(() => ({ dismissedAt: 0 }), { + storageKey: 'CustomizeNetworksBanner', + version: 0, +}); + +const twoWeeks = 1000 * 60 * 60 * 24 * 7 * 2; +export const shouldShowCustomizeNetworksBanner = (dismissedAt: number) => Date.now() - dismissedAt > twoWeeks; +export const dismissCustomizeNetworksBanner = () => { + customizeNetworksBannerStore.setState({ dismissedAt: Date.now() }); +}; +export const showCustomizeNetworksBanner = shouldShowCustomizeNetworksBanner(customizeNetworksBannerStore.getState().dismissedAt); diff --git a/src/state/swaps/swapsStore.ts b/src/state/swaps/swapsStore.ts index 49cc99d0268..7d4d0d99f4b 100644 --- a/src/state/swaps/swapsStore.ts +++ b/src/state/swaps/swapsStore.ts @@ -1,5 +1,5 @@ import { INITIAL_SLIDER_POSITION } from '@/__swaps__/screens/Swap/constants'; -import { ExtendedAnimatedAssetWithColors, ParsedSearchAsset } from '@/__swaps__/types/assets'; +import { ExtendedAnimatedAssetWithColors, ParsedSearchAsset, UniqueId } from '@/__swaps__/types/assets'; import { ChainId } from '@/state/backendNetworks/types'; import { RecentSwap } from '@/__swaps__/types/swap'; import { getDefaultSlippage } from '@/__swaps__/utils/swaps'; @@ -42,6 +42,8 @@ export interface SwapsState { // degen mode preferences preferredNetwork: ChainId | undefined; setPreferredNetwork: (preferredNetwork: ChainId | undefined) => void; + + lastNavigatedTrendingToken: UniqueId | undefined; } type StateWithTransforms = Omit, 'latestSwapAt' | 'recentSwaps'> & { @@ -156,6 +158,8 @@ export const swapsStore = createRainbowStore( latestSwapAt: new Map(latestSwapAt), }); }, + + lastNavigatedTrendingToken: undefined, }), { storageKey: 'swapsStore', diff --git a/src/state/trendingTokens/trendingTokens.ts b/src/state/trendingTokens/trendingTokens.ts new file mode 100644 index 00000000000..fb15fadd862 --- /dev/null +++ b/src/state/trendingTokens/trendingTokens.ts @@ -0,0 +1,45 @@ +import { ChainId } from '@/state/backendNetworks/types'; +import { createRainbowStore } from '../internal/createRainbowStore'; +import { analyticsV2 } from '@/analytics'; + +export const categories = ['trending', 'new', 'farcaster'] as const; +export const sortFilters = ['volume', 'market_cap', 'top_gainers', 'top_losers'] as const; +export const timeFilters = ['day', 'week', 'month'] as const; + +type TrendingTokensState = { + category: 'trending' | 'new' | 'farcaster'; + chainId: undefined | ChainId; + timeframe: (typeof timeFilters)[number]; + sort: (typeof sortFilters)[number] | undefined; + + setCategory: (category: TrendingTokensState['category']) => void; + setChainId: (chainId: TrendingTokensState['chainId']) => void; + setTimeframe: (timeframe: TrendingTokensState['timeframe']) => void; + setSort: (sort: TrendingTokensState['sort']) => void; +}; + +export const useTrendingTokensStore = createRainbowStore( + set => ({ + category: 'trending', + chainId: undefined, + timeframe: 'day', + sort: 'volume', + setCategory: category => set({ category }), + setChainId: chainId => { + analyticsV2.track(analyticsV2.event.changeNetworkFilter, { chainId }); + set({ chainId }); + }, + setTimeframe: timeframe => { + analyticsV2.track(analyticsV2.event.changeTimeframeFilter, { timeframe }); + set({ timeframe }); + }, + setSort: sort => { + analyticsV2.track(analyticsV2.event.changeSortFilter, { sort }); + set({ sort }); + }, + }), + { + storageKey: 'trending-tokens', + version: 0, + } +);