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) { @@ -325,6 +328,7 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { tradeAmountUSD: parameters.quote.tradeAmountUSD, degenMode: isDegenModeEnabled, isSwappingToPopularAsset, + isSwappingToTrendingAsset, errorMessage, isHardwareWallet, }); @@ -389,6 +393,7 @@ export const SwapProvider = ({ children }: SwapProviderProps) => { tradeAmountUSD: parameters.quote.tradeAmountUSD, degenMode: isDegenModeEnabled, isSwappingToPopularAsset, + isSwappingToTrendingAsset, isHardwareWallet, }); } catch (error) { @@ -403,6 +408,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..673e71a810d 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 { TrendingToken } 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: TrendingToken['address']; + chainId: TrendingToken['chainId']; + symbol: TrendingToken['symbol']; + name: TrendingToken['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/components/DappBrowser/control-panel/ControlPanel.tsx b/src/components/DappBrowser/control-panel/ControlPanel.tsx index 99c24dd0597..94977e35f14 100644 --- a/src/components/DappBrowser/control-panel/ControlPanel.tsx +++ b/src/components/DappBrowser/control-panel/ControlPanel.tsx @@ -311,7 +311,7 @@ export const ControlPanel = () => { ); }; -const TapToDismiss = memo(function TapToDismiss() { +export const TapToDismiss = memo(function TapToDismiss() { const { goBack } = useNavigation(); return ( 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 89% rename from src/screens/discover/components/DiscoverHome.tsx rename to src/components/Discover/DiscoverHome.tsx index 7132c19c016..019328a92b6 100644 --- a/src/screens/discover/components/DiscoverHome.tsx +++ b/src/components/Discover/DiscoverHome.tsx @@ -6,9 +6,10 @@ 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'; +import { Inline, Inset, Stack, Box, Separator } from '@/design-system'; import { useAccountSettings, useWallets } from '@/hooks'; import { ENSCreateProfileCard } from '@/components/cards/ENSCreateProfileCard'; import { ENSSearchCard } from '@/components/cards/ENSSearchCard'; @@ -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,12 @@ 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 { nativeCurrency } = useAccountSettings(); + const remoteConfig = useRemoteConfig(); + const { chainId, category, timeframe, sort } = useTrendingTokensStore(state => ({ + chainId: state.chainId, + category: state.category, + timeframe: state.timeframe, + sort: state.sort, + })); + + const walletAddress = useFarcasterAccountForWallets(); + + return useTrendingTokens({ + chainId, + category, + timeframe, + sortBy: sort, + sortDirection: SortDirection.Desc, + limit: remoteConfig.trending_tokens_limit, + walletAddress: walletAddress, + currency: nativeCurrency, + }); +} + +function ReportAnalytics() { + const activeSwipeRoute = useNavigationStore(state => state.activeSwipeRoute); + const { category, chainId } = useTrendingTokensStore(state => ({ category: state.category, chainId: state.chainId })); + const { data: trendingTokens, isLoading } = useTrendingTokensData(); + + useEffect(() => { + if (isLoading || activeSwipeRoute !== Routes.DISCOVER_SCREEN) return; + + const isEmpty = (trendingTokens?.length ?? 0) === 0; + const isLimited = !isEmpty && (trendingTokens?.length ?? 0) < 6; + + analyticsV2.track(analyticsV2.event.viewRankedCategory, { + category, + chainId, + isLimited, + isEmpty, + }); + }, [isLoading, activeSwipeRoute, trendingTokens?.length, category, chainId]); + + return null; +} + +function CategoryFilterButton({ + category, + icon, + iconWidth = 16, + iconColor, + label, + highlightedBackgroundColor, +}: { + category: TrendingCategory; + 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 FriendPfp({ pfp_url }: { pfp_url: string }) { + const backgroundColor = useBackgroundColor('surfacePrimary'); + return ( + + ); +} +function FriendHolders({ friends }: { friends: FarcasterUser[] }) { + if (friends.length === 0) return null; + const howManyOthers = Math.max(1, friends.length - 2); + const separator = howManyOthers === 1 && friends.length === 2 ? ` ${i18n.t(t.and)} ` : ', '; + + return ( + + + + {friends[1] && } + + + + + {friends[0].username} + {friends[1] && ( + <> + + {separator} + + {friends[1].username} + + )} + + {friends.length > 2 && ( + + {' '} + {i18n.t('trending_tokens.and_others', { count: howManyOthers })} + + )} + + + ); +} + +function TrendingTokenLoadingRow() { + const backgroundColor = useBackgroundColor('surfacePrimary'); + const { isDarkMode } = useTheme(); + return ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ); +} + +function getPriceChangeColor(priceChange: number) { + if (priceChange === 0) return 'labelTertiary'; + return priceChange > 0 ? 'green' : 'red'; +} + +function TrendingTokenRow({ token }: { token: TrendingToken }) { + const separatorColor = useForegroundColor('separator'); + + const price = formatCurrency(token.price); + const marketCap = formatNumber(token.marketCap, { useOrderSuffix: true, decimals: 1, style: '$' }); + const volume = formatNumber(token.volume, { useOrderSuffix: true, decimals: 1, style: '$' }); + + const handleNavigateToToken = useCallback(() => { + analyticsV2.track(analyticsV2.event.viewTrendingToken, { + address: token.address, + chainId: token.chainId, + symbol: token.symbol, + name: token.name, + highlightedFriends: token.highlightedFriends.length, + }); + + swapsStore.setState({ + lastNavigatedTrendingToken: token.uniqueId, + }); + + Navigation.handleAction(Routes.EXPANDED_ASSET_SHEET, { + asset: token, + type: 'token', + }); + }, [token]); + + if (!token) return null; + + return ( + + + + + + + + + + + + {token.name} + + + {token.symbol} + + + {price} + + + + + + + VOL + + + {volume} + + + + + | + + + + + MCAP + + + {marketCap} + + + + + + + + + {formatNumber(token.priceChange.day, { decimals: 2, useOrderSuffix: true })}% + + + + + 1H + + + {formatNumber(token.priceChange.hr, { 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 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 = !chainId ? i18n.t(t.all) : useBackendNetworksStore.getState().getChainsLabel()[chainId]; + + const icon = useMemo(() => { + if (!chainId) return '􀤆'; + return ; + }, [chainId]); + + const navigateToNetworkSelector = useCallback(() => { + Navigation.handleAction(Routes.NETWORK_SELECTOR, { + selected, + setSelected, + }); + }, [selected, setSelected]); + + return ; +} + +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 ( + s !== 'RECOMMENDED') + .map(sort => ({ + actionTitle: i18n.t(t.filters.sort[sort]), + actionKey: sort, + })), + }} + side="bottom" + onPressMenuItem={selection => { + if (selection === sort) return useTrendingTokensStore.getState().setSort(TrendingSort.Recommended); + useTrendingTokensStore.getState().setSort(selection); + }} + > + + 􀄬 + + } + /> + + ); +} + +function TrendingTokensLoader() { + const { trending_tokens_limit } = useRemoteConfig(); + + return ( + + {Array.from({ length: trending_tokens_limit }).map((_, index) => ( + + ))} + + ); +} + +function TrendingTokenData() { + const { data: trendingTokens, isLoading } = useTrendingTokensData(); + if (isLoading) return ; + + return ( + } + data={trendingTokens} + renderItem={({ item }) => } + /> + ); +} + +const padding = 20; + +export function TrendingTokens() { + 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..50c4e13d639 --- /dev/null +++ b/src/components/NetworkSwitcher.tsx @@ -0,0 +1,805 @@ +/* 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 { AnimatedBlurView } from '@/components/AnimatedComponents/AnimatedBlurView'; +import { ButtonPressAnimation } from '@/components/animations'; +import { SPRING_CONFIGS, TIMING_CONFIGS } from '@/components/animations/animationConfigs'; +import { AnimatedChainImage, ChainImage } from '@/components/coin-icon/ChainImage'; +import { AnimatedText, Box, 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 deviceUtils, { DEVICE_WIDTH } from '@/utils/deviceUtils'; +import MaskedView from '@react-native-masked-view/masked-view'; +import chroma from 'chroma-js'; +import { PropsWithChildren, useEffect } from 'react'; +import React, { Pressable, StyleSheet, View } from 'react-native'; +import { RouteProp, useRoute } from '@react-navigation/native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import LinearGradient from 'react-native-linear-gradient'; +import Animated, { + FadeIn, + FadeOutUp, + LinearTransition, + runOnJS, + SharedValue, + useAnimatedReaction, + useAnimatedStyle, + useDerivedValue, + useSharedValue, + withDelay, + withSequence, + withSpring, + withTiming, +} from 'react-native-reanimated'; +import Svg, { Path } from 'react-native-svg'; +import { + customizeNetworksBannerStore, + defaultPinnedNetworks, + dismissCustomizeNetworksBanner, + networkSwitcherStore, + shouldShowCustomizeNetworksBanner, +} from '@/state/networkSwitcher/networkSwitcher'; +import { RootStackParamList } from '@/navigation/types'; +import { IS_IOS } from '@/env'; +import { safeAreaInsetValues } from '@/utils'; +import { noop } from 'lodash'; +import { TapToDismiss } from './DappBrowser/control-panel/ControlPanel'; + +const t = i18n.l.network_switcher; + +const translations = { + edit: i18n.t(t.edit), + done: i18n.t(i18n.l.done), + networks: i18n.t(t.networks), + more: i18n.t(t.more), + 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 = !shouldShowCustomizeNetworksBanner(customizeNetworksBannerStore.getState().dismissedAt) + ? () => 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)} + + + {i18n.t(t.customize_networks_banner.tap_the)}{' '} + + {i18n.t(t.edit)} + {' '} + {i18n.t(t.customize_networks_banner.button_to_set_up)} + + + + + 􀆄 + + + + + + + + ); + }; + +const useNetworkOptionStyle = (isSelected: SharedValue, color?: string) => { + const { isDarkMode } = useColorMode(); + const label = useForegroundColor('labelTertiary'); + + 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 || label])(0.16) + .hex(), + borderColor: chroma(color || label) + .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, TIMING_CONFIGS.fastFadeConfig) : withTiming(1, TIMING_CONFIGS.fastFadeConfig), + height: withTiming( + editing.value ? 0 : ITEM_HEIGHT + 14, // 14 is the gap to the separator + TIMING_CONFIGS.fastFadeConfig + ), + marginTop: editing.value ? 0 : 14, + pointerEvents: editing.value ? 'none' : 'auto', + })); + return ( + + + + + ); +} + +function NetworkOption({ chainId, selected }: { chainId: ChainId; selected: SharedValue }) { + const chainName = useBackendNetworksStore.getState().getChainsLabel()[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, + separator, + 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, TIMING_CONFIGS.fastFadeConfig) + : withDelay(100, withTiming(1, TIMING_CONFIGS.fadeConfig)); + + 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)); + 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({ + sectionsOffsets, + editing, + expanded, + networks, +}: { + sectionsOffsets: SharedValue>; + editing: SharedValue; + expanded: SharedValue; + networks: SharedValue>; +}) { + const pressed = useSharedValue(false); + + const showExpandButtonAsNetworkChip = useDerivedValue(() => { + return !expanded.value && !editing.value && networks.value[Section.pinned].length % 2 !== 0; + }); + + const visible = useDerivedValue(() => { + return networks.value[Section.unpinned].length > 0 || editing.value; + }); + + const tapExpand = Gesture.Tap() + .onTouchesDown((e, s) => { + if (editing.value || !visible.value) return s.fail(); + pressed.value = true; + }) + .onEnd(() => { + pressed.value = false; + expanded.value = !expanded.value; + }); + + const text = useDerivedValue(() => { + if (editing.value) return translations.drag_to_rearrange; + if (showExpandButtonAsNetworkChip.value) return translations.more; + 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(); + + const separatorContainerStyles = useAnimatedStyle(() => { + if (showExpandButtonAsNetworkChip.value) { + const position = positionFromIndex(networks.value[Section.pinned].length, sectionsOffsets.value[Section.pinned]); + return { + backgroundColor: isDarkMode ? globalColors.white10 : globalColors.grey20, + borderColor: '#F5F8FF05', + height: ITEM_HEIGHT, + width: ITEM_WIDTH, + flexDirection: 'row', + alignItems: 'center', + borderRadius: 24, + borderWidth: 1.33, + transform: [{ translateX: position.x }, { translateY: position.y }], + }; + } + + return { + backgroundColor: 'transparent', + opacity: visible.value ? 1 : 0, + transform: [{ translateY: sectionsOffsets.value[Section.separator].y }, { scale: withTiming(pressed.value ? 0.95 : 1) }], + position: 'absolute', + width: '100%', + height: SEPARATOR_HEIGHT, + }; + }); + + return ( + + + + + {unpinnedNetworksLength} + + + + {text} + + + + {showMoreOrLessIcon} + + + + + ); +} + +function EmptyUnpinnedPlaceholder({ + sectionsOffsets, + networks, + isUnpinnedHidden, +}: { + sectionsOffsets: SharedValue>; + networks: SharedValue>; + isUnpinnedHidden: SharedValue; +}) { + const styles = useAnimatedStyle(() => { + const isVisible = networks.value[Section.unpinned].length === 0 && !isUnpinnedHidden.value; + return { + opacity: isVisible ? withTiming(1, { duration: 800 }) : 0, + transform: [{ translateY: sectionsOffsets.value[Section.unpinned].y }], + }; + }); + const { isDarkMode } = useTheme(); + return ( + + + {i18n.t(t.drag_here_to_unpin)} + + + ); +} + +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 () => { + if (networks.value[Section.pinned].length > 0) { + networkSwitcherStore.setState({ pinnedNetworks: networks.value[Section.pinned] }); + } else { + networkSwitcherStore.setState({ pinnedNetworks: defaultPinnedNetworks }); + } + }; + }, [networks]); + + const expanded = useSharedValue(false); + const isUnpinnedHidden = useDerivedValue(() => !expanded.value && !editing.value); + + const dragging = useSharedValue(null); + + const sectionsOffsets = useDerivedValue(() => { + const pinnedHeight = Math.ceil(networks.value[Section.pinned].length / 2) * (ITEM_HEIGHT + GAP) - GAP; + return { + [Section.pinned]: { y: 0 }, + [Section.separator]: { y: pinnedHeight }, + [Section.unpinned]: { y: pinnedHeight + SEPARATOR_HEIGHT }, + }; + }); + const containerHeight = useDerivedValue(() => { + const length = networks.value[Section.unpinned].length; + const paddingBottom = 32; + const unpinnedHeight = isUnpinnedHidden.value + ? length === 0 + ? -SEPARATOR_HEIGHT + paddingBottom + : 0 + : length === 0 + ? ITEM_HEIGHT + paddingBottom + : Math.ceil((length + 1) / 2) * (ITEM_HEIGHT + GAP) - GAP + paddingBottom; + const height = sectionsOffsets.value[Section.unpinned].y + unpinnedHeight; + return height; + }); + const containerStyle = useAnimatedStyle(() => ({ + height: withDelay(expanded.value ? 0 : 25, withTiming(containerHeight.value, TIMING_CONFIGS.slowerFadeConfig)), + })); + + 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 sectionNetworks = networks.value[section]; + const chainId = sectionNetworks[index]; + + if (!chainId || (section === Section.pinned && sectionNetworks.length === 1)) { + 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 - SEPARATOR_HEIGHT / 2 ? 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].push(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 tapNetwork = Gesture.Tap() + .onTouchesDown((e, s) => { + if (editing.value) return 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.map(chainId => ( + + + + ))} + + + ); +} + +function Sheet({ children, editing, onClose }: PropsWithChildren<{ editing: SharedValue; onClose: VoidFunction }>) { + const { isDarkMode } = useTheme(); + const surfacePrimary = useBackgroundColor('surfacePrimary'); + const backgroundColor = isDarkMode ? '#191A1C' : surfacePrimary; + const separatorSecondary = useForegroundColor('separatorSecondary'); + + // make sure the onClose function is called when the sheet unmounts + useEffect(() => { + return () => onClose?.(); + }, [onClose]); + + return ( + <> + +
+ {children} + + + + ); +} + +export function NetworkSelector() { + const { + params: { onClose = noop, selected, setSelected }, + } = useRoute>(); + + const editing = useSharedValue(false); + + return ( + + + + + + ); +} + +const sx = StyleSheet.create({ + sheet: { + flex: 1, + width: deviceUtils.dimensions.width - 16, + bottom: Math.max(safeAreaInsetValues.bottom + 5, IS_IOS ? 8 : 30), + pointerEvents: 'box-none', + position: 'absolute', + zIndex: 30000, + left: 8, + right: 8, + paddingHorizontal: 16, + borderRadius: 42, + borderWidth: 1.33, + }, +}); diff --git a/src/components/coin-icon/ChainImage.tsx b/src/components/coin-icon/ChainImage.tsx index 1aa5c479b8e..d7d250858f5 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, { useMemo, forwardRef } from 'react'; import { ChainId } from '@/state/backendNetworks/types'; import ApechainBadge from '@/assets/badges/apechain.png'; @@ -19,10 +19,21 @@ import PolygonBadge from '@/assets/badges/polygon.png'; // import ScrollBadge from '@/assets/badges/scroll.png'; // import ZksyncBadge from '@/assets/badges/zksync.png'; import ZoraBadge from '@/assets/badges/zora.png'; +import FastImage, { FastImageProps, Source } from 'react-native-fast-image'; +import Animated from 'react-native-reanimated'; -import FastImage, { Source } from 'react-native-fast-image'; - -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 +80,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/components/sheet/sheet-action-buttons/SwapActionButton.tsx b/src/components/sheet/sheet-action-buttons/SwapActionButton.tsx index 9a7ea341748..81f82de844b 100644 --- a/src/components/sheet/sheet-action-buttons/SwapActionButton.tsx +++ b/src/components/sheet/sheet-action-buttons/SwapActionButton.tsx @@ -31,7 +31,7 @@ function SwapActionButton({ asset, color: givenColor, inputType, label, weight = const goToSwap = useCallback(async () => { const chainsIdByName = useBackendNetworksStore.getState().getChainsIdByName(); const chainsName = useBackendNetworksStore.getState().getChainsName(); - const chainId = chainsIdByName[asset.network]; + const chainId = asset.chainId || chainsIdByName[asset.network]; const uniqueId = `${asset.address}_${chainId}`; const userAsset = userAssetsStore.getState().userAssets.get(uniqueId); 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/graphql/queries/arc.graphql b/src/graphql/queries/arc.graphql index 68e797864e5..17dccca539c 100644 --- a/src/graphql/queries/arc.graphql +++ b/src/graphql/queries/arc.graphql @@ -522,6 +522,7 @@ query trendingTokens( $sortBy: TrendingSort $sortDirection: SortDirection $walletAddress: String + $limit: Int ) { trendingTokens( chainId: $chainId @@ -531,6 +532,7 @@ query trendingTokens( sortBy: $sortBy sortDirection: $sortDirection walletAddress: $walletAddress + limit: $limit ) { data { colors { diff --git a/src/helpers/strings.ts b/src/helpers/strings.ts index 4e7fd76ab91..986c23a665a 100644 --- a/src/helpers/strings.ts +++ b/src/helpers/strings.ts @@ -1,4 +1,8 @@ +import store from '@/redux/store'; import { memoFn } from '../utils/memoFn'; +import { supportedNativeCurrencies } from '@/references'; +import { NativeCurrencyKey } from '@/entities'; +import { convertAmountToNativeDisplayWorklet } from './utilities'; /** * @desc subtracts two numbers * @param {String} str @@ -10,3 +14,127 @@ 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 === '%' ? '%' : '') + ); +} + +type CurrencyFormatterOptions = { + decimals?: number; + valueIfNaN?: string; + currency?: NativeCurrencyKey; +}; + +const toSubscript = (str: string | number) => str.toString().replace(/[0-9]/g, num => String.fromCharCode(0x2080 + +num)); + +/* + converts 6.9e-7 to 0.00000069 +*/ +const toDecimalString = (num: number): string => { + const [coefficient, exponent] = num.toExponential(20).split('e'); + const exp = parseInt(exponent); + const digits = coefficient.replace('.', '').replace(/0+$/, ''); + + if (exp >= 0) { + const position = exp + 1; + if (position >= digits.length) return digits + '0'.repeat(position - digits.length); + return digits.slice(0, position) + (digits.slice(position) && '.' + digits.slice(position)); + } + return '0.' + '0'.repeat(Math.abs(exp) - 1) + digits; +}; + +/* + formats a numeric string like 0000069 to 0₅69 +*/ +function formatFraction(fraction: string) { + const leadingZeros = fraction.match(/^[0]+/)?.[0].length || 0; + if (+fraction === 0) return '00'; + + const significantDigits = fraction.slice(leadingZeros, leadingZeros + 2); + if (+significantDigits === 0) return '00'; + + if (leadingZeros >= 4) return `0${toSubscript(leadingZeros)}${significantDigits}`; + return `${'0'.repeat(leadingZeros)}${significantDigits}`; +} + +export function formatCurrency( + value: string | number, + { valueIfNaN = '', currency = store.getState().settings.nativeCurrency }: CurrencyFormatterOptions = {} +): string { + const numericString = typeof value === 'number' ? toDecimalString(value) : String(value); + if (isNaN(+numericString)) return valueIfNaN; + + const currencySymbol = supportedNativeCurrencies[currency].symbol; + const [whole, fraction = ''] = numericString.split('.'); + + if (fraction === '') { + // if the fraction is empty and the numeric string is less than 6 characters, we can just run it through our native currency display worklet + if (numericString.length <= 6) { + return convertAmountToNativeDisplayWorklet(numericString, currency, false, true); + } + + const decimals = supportedNativeCurrencies[currency].decimals; + // otherwise for > 6 figs native value we need to format in compact notation + const formattedWhole = formatNumber(numericString, { decimals, useOrderSuffix: true }); + return `${currencySymbol}${formattedWhole}`; + } + + const formattedWhole = formatNumber(whole, { decimals: 0, useOrderSuffix: true }); + const formattedFraction = formatFraction(fraction); + // if it ends with a non-numeric character, it's in compact notation like '1.2K' + if (isNaN(+formattedWhole[formattedWhole.length - 1])) return `${currencySymbol}${formattedWhole}`; + + return `${currencySymbol}${formattedWhole}.${formattedFraction}`; +} 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/hooks/useFarcasterAccountForWallets.ts b/src/hooks/useFarcasterAccountForWallets.ts index 4c5702051b7..80873b867e3 100644 --- a/src/hooks/useFarcasterAccountForWallets.ts +++ b/src/hooks/useFarcasterAccountForWallets.ts @@ -12,12 +12,12 @@ import { AllRainbowWallets } from '@/model/wallet'; type SummaryData = ReturnType['data']; -const getWalletForAddress = (wallets: AllRainbowWallets, address: string) => { +const getWalletForAddress = (wallets: AllRainbowWallets | null, address: string) => { return Object.values(wallets || {}).find(wallet => wallet.addresses.some(addr => isLowerCaseMatch(addr.address, address))); }; -export const useFarcasterWalletAddress = () => { - const [farcasterWalletAddress, setFarcasterWalletAddress] = useState(null); +export const useFarcasterAccountForWallets = () => { + const [farcasterWalletAddress, setFarcasterWalletAddress] = useState
(); const { accountAddress } = useAccountSettings(); const { wallets } = useWallets(); @@ -33,31 +33,32 @@ export const useFarcasterWalletAddress = () => { currency: store.getState().settings.nativeCurrency, }) ); - if (isEmpty(summaryData?.data.addresses) || isEmpty(wallets)) { - setFarcasterWalletAddress(null); + const addresses = summaryData?.data.addresses; + + if (!addresses || isEmpty(addresses) || isEmpty(wallets)) { + setFarcasterWalletAddress(undefined); return; } - const selectedAddressFid = summaryData?.data.addresses[accountAddress as Address]?.meta?.farcaster?.fid; - - if (selectedAddressFid && getWalletForAddress(wallets || {}, accountAddress)?.type !== walletTypes.readOnly) { + const selectedAddressFid = addresses[accountAddress]?.meta?.farcaster?.fid; + if (selectedAddressFid && getWalletForAddress(wallets, accountAddress)?.type !== walletTypes.readOnly) { setFarcasterWalletAddress(accountAddress); return; } - const farcasterWalletAddress = Object.keys(summaryData?.data.addresses || {}).find(addr => { + const farcasterWalletAddress = Object.keys(addresses).find(addr => { const address = addr as Address; const faracsterId = summaryData?.data.addresses[address]?.meta?.farcaster?.fid; - if (faracsterId && getWalletForAddress(wallets || {}, address)?.type !== walletTypes.readOnly) { - return faracsterId; + if (faracsterId && getWalletForAddress(wallets, address)?.type !== walletTypes.readOnly) { + return address; } - }); + }) as Address | undefined; if (farcasterWalletAddress) { setFarcasterWalletAddress(farcasterWalletAddress); return; } - setFarcasterWalletAddress(null); + setFarcasterWalletAddress(undefined); }, [wallets, allAddresses, accountAddress]); return farcasterWalletAddress; diff --git a/src/languages/en_US.json b/src/languages/en_US.json index 725d9b674d1..4bfe7a65a0d 100644 --- a/src/languages/en_US.json +++ b/src/languages/en_US.json @@ -3018,6 +3018,54 @@ "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." + }, + "and": "and", + "and_others": { + "one": "and %{count} other", + "other": "and %{count} others" + }, + "filters": { + "categories": { + "TRENDING": "Trending", + "NEW": "New", + "FARCASTER": "Farcaster" + }, + "sort": { + "RECOMMENDED": "Sort", + "VOLUME": "Volume", + "MARKET_CAP": "Market Cap", + "TOP_GAINERS": "Top Gainers", + "TOP_LOSERS": "Top Losers" + }, + "time": { + "H12": "12h", + "H24": "24h", + "D7": "1 Week", + "D3": "1 Month" + } + } + }, + "network_switcher": { + "customize_networks_banner": { + "title": "Customize Networks", + "tap_the": "Tap the", + "button_to_set_up": "button below to set up" + }, + "drag_here_to_unpin": "Drag here to unpin networks", + "edit": "Edit", + "networks": "Networks", + "drag_to_rearrange": "Drag to rearrange", + "show_less": "Show less", + "more": "More", + "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..3341a3a0000 100644 --- a/src/model/remoteConfig.ts +++ b/src/model/remoteConfig.ts @@ -57,6 +57,8 @@ export interface RainbowConfig extends Record featured_results: boolean; claimables: boolean; nfts_enabled: boolean; + + trending_tokens_limit: number; } export const DEFAULT_CONFIG: RainbowConfig = { @@ -147,6 +149,8 @@ export const DEFAULT_CONFIG: RainbowConfig = { featured_results: true, claimables: true, nfts_enabled: true, + + trending_tokens_limit: 10, }; export async function fetchRemoteConfig(): Promise { @@ -205,6 +209,8 @@ export async function fetchRemoteConfig(): Promise { key === 'nfts_enabled' ) { config[key] = entry.asBoolean(); + } else if (key === 'trending_tokens_limit') { + config[key] = entry.asNumber(); } else { config[key] = entry.asString(); } diff --git a/src/navigation/Routes.android.tsx b/src/navigation/Routes.android.tsx index d246a004f6a..05b09059948 100644 --- a/src/navigation/Routes.android.tsx +++ b/src/navigation/Routes.android.tsx @@ -90,6 +90,7 @@ import { ControlPanel } from '@/components/DappBrowser/control-panel/ControlPane import { ClaimRewardsPanel } from '@/screens/points/claim-flow/ClaimRewardsPanel'; import { ClaimClaimablePanel } from '@/screens/claimables/ClaimPanel'; import { RootStackParamList } from './types'; +import { NetworkSelector } from '@/components/NetworkSwitcher'; const Stack = createStackNavigator(); const OuterStack = createStackNavigator(); @@ -242,6 +243,7 @@ function BSNavigator() { + diff --git a/src/navigation/Routes.ios.tsx b/src/navigation/Routes.ios.tsx index e65e309538a..c07ef6294b5 100644 --- a/src/navigation/Routes.ios.tsx +++ b/src/navigation/Routes.ios.tsx @@ -70,6 +70,7 @@ import { swapConfig, checkIdentifierSheetConfig, recieveModalSheetConfig, + networkSelectorConfig, } from './config'; import { addCashSheet, emojiPreset, emojiPresetWallet, overlayExpandedPreset, sheetPreset } from './effects'; import { InitialRouteContext } from './initialRoute'; @@ -102,6 +103,7 @@ import { ControlPanel } from '@/components/DappBrowser/control-panel/ControlPane import { ClaimRewardsPanel } from '@/screens/points/claim-flow/ClaimRewardsPanel'; import { ClaimClaimablePanel } from '@/screens/claimables/ClaimPanel'; import { RootStackParamList } from './types'; +import { NetworkSelector } from '@/components/NetworkSwitcher'; const Stack = createStackNavigator(); const NativeStack = createNativeStackNavigator(); @@ -273,6 +275,7 @@ function NativeStackNavigator() { + 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/navigation/config.tsx b/src/navigation/config.tsx index 5428d27e37b..38b7a0032fe 100644 --- a/src/navigation/config.tsx +++ b/src/navigation/config.tsx @@ -248,6 +248,20 @@ export const consoleSheetConfig = { }), }; +export const networkSelectorConfig = { + options: ({ route: { params = {} } }) => ({ + ...buildCoolModalConfig({ + ...params, + backgroundColor: '#000000B2', + backgroundOpacity: 0.7, + cornerRadius: 0, + springDamping: 1, + topOffset: 0, + transitionDuration: 0.3, + }), + }), +}; + export const panelConfig = { options: ({ route: { params = {} } }) => ({ ...buildCoolModalConfig({ diff --git a/src/navigation/routesNames.ts b/src/navigation/routesNames.ts index 96cc67fb146..ff4372906e3 100644 --- a/src/navigation/routesNames.ts +++ b/src/navigation/routesNames.ts @@ -100,6 +100,7 @@ const Routes = { SETTINGS_SECTION_NOTIFICATIONS: 'NotificationsSection', SETTINGS_SECTION_PRIVACY: 'PrivacySection', DAPP_BROWSER_CONTROL_PANEL: 'DappBrowserControlPanel', + NETWORK_SELECTOR: 'NetworkSelector', CLAIM_REWARDS_PANEL: 'ClaimRewardsPanel', } as const; diff --git a/src/navigation/types.ts b/src/navigation/types.ts index cc2c8842ab5..c1877d015da 100644 --- a/src/navigation/types.ts +++ b/src/navigation/types.ts @@ -10,6 +10,9 @@ import { Claimable } from '@/resources/addys/claimables/types'; import { WalletconnectApprovalSheetRouteParams, WalletconnectResultType } from '@/walletConnect/types'; import { WalletConnectApprovalSheetType } from '@/helpers/walletConnectApprovalSheetTypes'; import { RainbowPosition } from '@/resources/defi/types'; +import { Address } from 'viem'; +import { SharedValue } from 'react-native-reanimated'; +import { ChainId } from '@/state/backendNetworks/types'; export type PartialNavigatorConfigOptions = Pick['Screen']>[0]>, 'options'>; @@ -31,7 +34,7 @@ export type RootStackParamList = { [Routes.CHANGE_WALLET_SHEET]: { watchOnly: boolean; currentAccountAddress: string; - onChangeWallet: (address: string) => void; + onChangeWallet: (address: Address) => void; }; [Routes.SPEED_UP_AND_CANCEL_BOTTOM_SHEET]: { accentColor?: string; @@ -104,4 +107,9 @@ export type RootStackParamList = { [Routes.POSITION_SHEET]: { position: RainbowPosition; }; + [Routes.NETWORK_SELECTOR]: { + onClose?: VoidFunction; + selected: SharedValue; + setSelected: (chainId: ChainId | undefined) => void; + }; }; 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/redux/settings.ts b/src/redux/settings.ts index ce19a5f6131..4535437ea7c 100644 --- a/src/redux/settings.ts +++ b/src/redux/settings.ts @@ -24,6 +24,7 @@ import { getProvider } from '@/handlers/web3'; import { AppState } from '@/redux/store'; import { logger, RainbowError } from '@/logger'; import { Network, ChainId } from '@/state/backendNetworks/types'; +import { Address } from 'viem'; // -- Constants ------------------------------------------------------------- // const SETTINGS_UPDATE_SETTINGS_ADDRESS = 'settings/SETTINGS_UPDATE_SETTINGS_ADDRESS'; @@ -41,7 +42,7 @@ const SETTINGS_UPDATE_ACCOUNT_SETTINGS_SUCCESS = 'settings/SETTINGS_UPDATE_ACCOU */ interface SettingsState { appIcon: string; - accountAddress: string; + accountAddress: Address; chainId: number; language: Language; nativeCurrency: NativeCurrencyKey; @@ -205,7 +206,7 @@ export const settingsChangeAppIcon = (appIcon: string) => (dispatch: Dispatch async (dispatch: Dispatch) => { dispatch({ - payload: accountAddress, + payload: accountAddress as Address, type: SETTINGS_UPDATE_SETTINGS_ADDRESS, }); }; @@ -254,7 +255,7 @@ export const settingsChangeNativeCurrency = // -- Reducer --------------------------------------------------------------- // export const INITIAL_STATE: SettingsState = { - accountAddress: '', + accountAddress: '' as Address, appIcon: 'og', chainId: 1, language: Language.EN_US, diff --git a/src/resources/trendingTokens/trendingTokens.ts b/src/resources/trendingTokens/trendingTokens.ts index 169a24ab6f4..4bbb62e9308 100644 --- a/src/resources/trendingTokens/trendingTokens.ts +++ b/src/resources/trendingTokens/trendingTokens.ts @@ -2,23 +2,117 @@ import { QueryConfigWithSelect, createQueryKey } from '@/react-query'; import { useQuery } from '@tanstack/react-query'; import { arcClient } from '@/graphql'; -export type TrendingTokensVariables = Parameters['0']; -export type TrendingTokens = Awaited>; +import { TrendingCategory, TrendingSort, TrendingTimeframe } from '@/state/trendingTokens/trendingTokens'; +import { Address } from 'viem'; +import { NativeCurrencyKey } from '@/entities'; +import store from '@/redux/store'; +import { SortDirection } from '@/graphql/__generated__/arc'; +import { UniqueId } from '@/__swaps__/types/assets'; +import { ChainId } from '@/state/backendNetworks/types'; + +export type FarcasterUser = { + username: string; + pfp_url: string; +}; +export type TrendingToken = { + uniqueId: UniqueId; + chainId: ChainId; + address: string; + name: string; + symbol: string; + decimals: number; + price: number; + priceChange: { + hr: number; + day: number; + }; + marketCap: number; + volume: number; + highlightedFriends: FarcasterUser[]; + colors: { + primary: string; + }; + icon_url: string; +}; // /////////////////////////////////////////////// // Query Key -export const trendingTokensQueryKey = (props: TrendingTokensVariables) => createQueryKey('trending-tokens', props, { persisterVersion: 0 }); +export const trendingTokensQueryKey = (props: FetchTrendingTokensArgs) => createQueryKey('trending-tokens', props, { persisterVersion: 2 }); export type TrendingTokensQueryKey = ReturnType; +type FetchTrendingTokensArgs = { + chainId?: ChainId; + category: TrendingCategory; + sortBy: TrendingSort; + sortDirection: SortDirection | undefined; + timeframe: TrendingTimeframe; + walletAddress: Address | undefined; + limit?: number; + currency?: NativeCurrencyKey; +}; + +async function fetchTrendingTokens({ + queryKey: [ + { currency = store.getState().settings.nativeCurrency, category, sortBy, sortDirection, timeframe, walletAddress, chainId, limit }, + ], +}: { + queryKey: TrendingTokensQueryKey; +}) { + const response = await arcClient.trendingTokens({ + category, + sortBy, + sortDirection, + timeframe, + walletAddress, + limit, + chainId, + currency: currency.toLowerCase(), + }); + const trendingTokens: TrendingToken[] = []; + + for (const token of response.trendingTokens.data) { + const { uniqueId, address, name, symbol, chainId, decimals, trending, market, icon_url, colors } = token; + const { bought_stats } = trending.swap_data; + const highlightedFriends = (bought_stats.farcaster_users || []).reduce((friends, friend) => { + const { username, pfp_url } = friend; + if (username && pfp_url) friends.push({ username, pfp_url }); + return friends; + }, [] as FarcasterUser[]); + + trendingTokens.push({ + uniqueId, + chainId: chainId as ChainId, + address, + name, + symbol, + decimals, + price: market.price?.value || 0, + priceChange: { + hr: trending.pool_data.h1_price_change || 0, + day: trending.pool_data.h24_price_change || 0, + }, + marketCap: market.market_cap?.value || 0, + volume: market.volume_24h || 0, + highlightedFriends, + icon_url, + colors: { + primary: colors.primary, + }, + }); + } + + return trendingTokens; +} + // /////////////////////////////////////////////// // Query Hook -export function useTrendingTokens( - props: TrendingTokensVariables, - config: QueryConfigWithSelect = {} +export function useTrendingTokens( + args: FetchTrendingTokensArgs, + config: QueryConfigWithSelect = {} ) { - return useQuery(trendingTokensQueryKey(props), () => arcClient.trendingTokens(props), { + return useQuery(trendingTokensQueryKey(args), fetchTrendingTokens, { ...config, staleTime: 60_000, // 1 minute cacheTime: 60_000 * 30, // 30 minutes 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..aa82dc85a44 --- /dev/null +++ b/src/state/networkSwitcher/networkSwitcher.ts @@ -0,0 +1,57 @@ +import { ChainId } from '@/state/backendNetworks/types'; +import { createRainbowStore } from '../internal/createRainbowStore'; +import { analyticsV2 } from '@/analytics'; +import { nonceStore } from '@/state/nonces'; +import { logger } from '@/logger'; + +export const defaultPinnedNetworks = [ChainId.base, ChainId.mainnet, ChainId.optimism, ChainId.arbitrum, ChainId.polygon, ChainId.zora]; + +function getMostUsedChains() { + try { + 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; + } + } + + const mostUsedNetworks = Object.entries(summedNoncesByChainId) + .sort((a, b) => b[1] - a[1]) + .map(([chainId]) => parseInt(chainId)); + + return mostUsedNetworks.length ? mostUsedNetworks.slice(0, 5) : defaultPinnedNetworks; + } catch (error) { + logger.warn('[networkSwitcher]: Error getting most used chains', { error }); + return defaultPinnedNetworks; + } +} + +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() }); +}; 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..644ccd45fba --- /dev/null +++ b/src/state/trendingTokens/trendingTokens.ts @@ -0,0 +1,59 @@ +import { analyticsV2 } from '@/analytics'; +import { ChainId } from '@/state/backendNetworks/types'; +import { createRainbowStore } from '../internal/createRainbowStore'; +import { + TrendingCategory as ArcTrendingCategory, + Timeframe as ArcTimeframe, + TrendingSort as ArcTrendingSort, +} from '@/graphql/__generated__/arc'; + +export const categories = [ArcTrendingCategory.Trending, ArcTrendingCategory.New, ArcTrendingCategory.Farcaster] as const; +export type TrendingCategory = (typeof categories)[number]; +export const sortFilters = [ + ArcTrendingSort.Recommended, + ArcTrendingSort.Volume, + ArcTrendingSort.MarketCap, + ArcTrendingSort.TopGainers, + ArcTrendingSort.TopLosers, +] as const; +export type TrendingSort = (typeof sortFilters)[number]; +export const timeFilters = [ArcTimeframe.H24, ArcTimeframe.D7, ArcTimeframe.D3] as const; +export type TrendingTimeframe = (typeof timeFilters)[number]; + +type TrendingTokensState = { + category: (typeof categories)[number]; + chainId: undefined | ChainId; + timeframe: (typeof timeFilters)[number]; + sort: (typeof sortFilters)[number]; + + 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: ArcTrendingCategory.Trending, + chainId: undefined, + timeframe: ArcTimeframe.H24, + sort: ArcTrendingSort.Recommended, + 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: 1, + } +); diff --git a/src/walletConnect/sheets/AuthRequest.tsx b/src/walletConnect/sheets/AuthRequest.tsx index 338acad39b4..724cae00de2 100644 --- a/src/walletConnect/sheets/AuthRequest.tsx +++ b/src/walletConnect/sheets/AuthRequest.tsx @@ -149,7 +149,7 @@ export function AuthRequest({ navigate(Routes.CHANGE_WALLET_SHEET, { watchOnly: true, currentAccountAddress: address, - onChangeWallet(address: string) { + onChangeWallet(address) { setAddress(address); goBack(); },