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