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,
+ }
+);