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/analytics/userProperties.ts b/src/analytics/userProperties.ts
index 3a26dafd76d..1a11b70c5e4 100644
--- a/src/analytics/userProperties.ts
+++ b/src/analytics/userProperties.ts
@@ -1,3 +1,4 @@
+import { ChainId } from '@/chains/types';
import { NativeCurrencyKey } from '@/entities';
import { Language } from '@/languages';
@@ -37,6 +38,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/chains/index.ts b/src/chains/index.ts
index ace5273f2de..7915cccdf51 100644
--- a/src/chains/index.ts
+++ b/src/chains/index.ts
@@ -15,6 +15,8 @@ const BACKEND_CHAINS = transformBackendNetworksToChains(backendNetworks.networks
export const SUPPORTED_CHAINS: Chain[] = IS_TEST ? [...BACKEND_CHAINS, chainHardhat, chainHardhatOptimism] : BACKEND_CHAINS;
+export const SUPPORTED_CHAIN_IDS_ALPHABETICAL: ChainId[] = SUPPORTED_CHAINS.sort((a, b) => a.name.localeCompare(b.name)).map(c => c.id);
+
export const defaultChains: Record = SUPPORTED_CHAINS.reduce(
(acc, chain) => {
acc[chain.id] = chain;
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/coin-icon/ChainImage.tsx b/src/components/coin-icon/ChainImage.tsx
index 4db59d9c61f..ed7cb9301e2 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 '@/chains/types';
import ArbitrumBadge from '@/assets/badges/arbitrum.png';
@@ -12,9 +12,21 @@ import AvalancheBadge from '@/assets/badges/avalanche.png';
import BlastBadge from '@/assets/badges/blast.png';
import DegenBadge from '@/assets/badges/degen.png';
import ApechainBadge from '@/assets/badges/apechain.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:
@@ -47,6 +59,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 0e81cb9d1df..72b8da36994 100644
--- a/src/config/experimental.ts
+++ b/src/config/experimental.ts
@@ -30,6 +30,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
@@ -68,6 +69,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/languages/en_US.json b/src/languages/en_US.json
index e49299f792b..d1af4f9caeb 100644
--- a/src/languages/en_US.json
+++ b/src/languages/en_US.json
@@ -2974,6 +2974,44 @@
"back": "Back"
}
},
+ "trending_tokens": {
+ "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/screens/discover/components/DiscoverHome.tsx b/src/screens/discover/components/DiscoverHome.tsx
index 7132c19c016..fc58f141d19 100644
--- a/src/screens/discover/components/DiscoverHome.tsx
+++ b/src/screens/discover/components/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,6 +29,7 @@ import { FeaturedResultStack } from '@/components/FeaturedResult/FeaturedResultS
import Routes from '@/navigation/routesNames';
import { useNavigation } from '@/navigation';
import { DiscoverFeaturedResultsCard } from './DiscoverFeaturedResultsCard';
+import { TrendingTokens } from './TrendingTokens';
export const HORIZONTAL_PADDING = 20;
@@ -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);
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/NetworkSwitcher.tsx b/src/screens/discover/components/NetworkSwitcher.tsx
new file mode 100644
index 00000000000..bc8853ae335
--- /dev/null
+++ b/src/screens/discover/components/NetworkSwitcher.tsx
@@ -0,0 +1,774 @@
+/* eslint-disable @typescript-eslint/no-non-null-assertion */
+import { getChainColorWorklet } from '@/__swaps__/utils/swaps';
+import { analyticsV2 } from '@/analytics';
+import { chainsLabel, SUPPORTED_CHAIN_IDS_ALPHABETICAL } from '@/chains';
+import { ChainId } from '@/chains/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 { createRainbowStore } from '@/state/internal/createRainbowStore';
+import { nonceStore } from '@/state/nonces';
+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 } 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';
+
+const t = i18n.l.network_switcher;
+
+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));
+}
+
+// const pinnedNetworks = getMostUsedChains().slice(0, 5);
+const useNetworkSwitcherStore = createRainbowStore<{
+ pinnedNetworks: ChainId[];
+}>(() => ({ pinnedNetworks: [] }), {
+ 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) });
+ }
+ },
+});
+const setNetworkSwitcherPinned = (pinnedNetworks: ChainId[]) => {
+ useNetworkSwitcherStore.setState({ pinnedNetworks });
+};
+
+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 useCustomizeNetworksBanner = createRainbowStore<{
+ dismissedAt: number; // timestamp
+}>(() => ({ dismissedAt: 0 }), {
+ storageKey: 'CustomizeNetworksBanner',
+ version: 0,
+});
+const twoWeeks = 1000 * 60 * 60 * 24 * 7 * 2;
+const should_show_CustomizeNetworksBanner = (dismissedAt: number) => Date.now() - dismissedAt > twoWeeks;
+const dismissCustomizeNetworksBanner = () => {
+ const { dismissedAt } = useCustomizeNetworksBanner.getState();
+ if (should_show_CustomizeNetworksBanner(dismissedAt)) return;
+ useCustomizeNetworksBanner.setState({ dismissedAt: Date.now() });
+};
+const show_CustomizeNetworksBanner = should_show_CustomizeNetworksBanner(useCustomizeNetworksBanner.getState().dismissedAt);
+
+const CustomizeNetworksBanner = !show_CustomizeNetworksBanner
+ ? () => null
+ : function CustomizeNetworksBanner({ editing }: { editing: SharedValue }) {
+ useAnimatedReaction(
+ () => editing.value,
+ (editing, prev) => {
+ if (!prev && editing) runOnJS(dismissCustomizeNetworksBanner)();
+ }
+ );
+
+ const dismissedAt = useCustomizeNetworksBanner(s => s.dismissedAt);
+ if (!should_show_CustomizeNetworksBanner(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,
+ () => {
+ 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 }: { selected: SharedValue }) {
+ const blue = useForegroundColor('blue');
+
+ const isSelected = useDerivedValue(() => selected.value === 'all');
+ const { animatedStyle, selectedStyle, defaultStyle } = useNetworkOptionStyle(isSelected, blue);
+
+ const overlappingBadge = useAnimatedStyle(() => {
+ return {
+ borderColor: isSelected.value ? selectedStyle.borderColor : defaultStyle.borderColor,
+ borderWidth: 1.67,
+ borderRadius: 16,
+ marginLeft: -9,
+ width: 16 + 1.67 * 2, // size + borders
+ height: 16 + 1.67 * 2,
+ };
+ });
+
+ const tapGesture = Gesture.Tap().onTouchesDown(() => {
+ if (selected.value === 'all') selected.value = [];
+ else selected.value = 'all';
+ });
+
+ return (
+
+
+
+
+
+
+
+
+
+ {i18n.t(t.all_networks)}
+
+
+
+ );
+}
+
+function AllNetworksSection({ editing, selected }: { editing: SharedValue; 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 name = chainsLabel[chainId];
+ if (!name) throw new Error(`: No chain name for chainId ${chainId}`);
+
+ const chainColor = getChainColorWorklet(chainId, true);
+ const isSelected = useDerivedValue(() => selected.value !== 'all' && selected.value.includes(chainId));
+ const { animatedStyle } = useNetworkOptionStyle(isSelected, chainColor);
+
+ return (
+
+
+
+ {name}
+
+
+ );
+}
+
+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,
+}: {
+ y: SharedValue;
+ editing: SharedValue;
+ expanded: SharedValue;
+ networks: SharedValue>;
+}) {
+ const pressed = useSharedValue(false);
+ const tapGesture = Gesture.Tap()
+ .onBegin(e => {
+ if (editing.value) e.state = State.FAILED;
+ else pressed.value = true;
+ })
+ .onEnd(() => {
+ pressed.value = false;
+ expanded.value = !expanded.value;
+ });
+
+ const separatorStyles = useAnimatedStyle(() => ({
+ transform: [{ translateY: y.value }, { scale: withTiming(pressed.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 ? '' : ''));
+ const showMoreOrLessIconStyle = useAnimatedStyle(() => ({ opacity: editing.value ? 0 : 1 }));
+
+ return (
+
+
+
+
+ {unpinnedNetworksLength}
+
+
+
+ {text}
+
+
+
+ {showMoreOrLessIcon}
+
+
+
+
+ );
+}
+
+function NetworksGrid({ editing, selected }: { editing: SharedValue; selected: SharedValue }) {
+ const initialPinned = useNetworkSwitcherStore.getState().pinnedNetworks;
+ const initialUnpinned = SUPPORTED_CHAIN_IDS_ALPHABETICAL.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 () => {
+ setNetworkSwitcherPinned(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];
+ const position = positionFromIndex(index, sectionOffset);
+ dragging.value = { chainId, position };
+ })
+ .onChange(e => {
+ 'worklet';
+ if (!dragging.value) return;
+ const chainId = dragging.value.chainId;
+
+ 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] = SUPPORTED_CHAIN_IDS_ALPHABETICAL.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(() => {
+ 'worklet';
+ dragging.value = null;
+ });
+
+ const tapNetwork = Gesture.Tap().onTouchesDown((e, s) => {
+ 'worklet';
+ const touch = e.allTouches[0];
+ if (editing.value) {
+ s.fail();
+ return;
+ }
+ const section = touch.y > sectionsOffsets.value[Section.unpinned].y ? Section.unpinned : Section.pinned;
+ const index = indexFromPosition(touch.x, touch.y, sectionsOffsets.value[section]);
+ const chainId = networks.value[section][index];
+
+ selected.modify(selected => {
+ if (selected === 'all') {
+ // @ts-expect-error I think something is wrong with reanimated types, not infering right here
+ selected = [chainId];
+ return selected;
+ }
+ const selectedIndex = selected.indexOf(chainId);
+ if (selectedIndex !== -1) selected.splice(selectedIndex, 1);
+ else selected.push(chainId);
+ return selected;
+ });
+ });
+
+ 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) onClose();
+ else translationY.value = withSpring(0);
+ });
+
+ const sheetStyle = useAnimatedStyle(() => ({ transform: [{ translateY: translationY.value }] }));
+
+ return (
+
+
+
+
+
+ {header}
+ {children}
+
+
+
+ );
+}
+
+export function NetworkSelector({
+ onClose,
+ multiple,
+}: {
+ onClose: (selected: ChainId[] | 'all') => void;
+ onSelect: VoidFunction;
+ multiple?: boolean;
+}) {
+ const editing = useSharedValue(false);
+ const selected = useSharedValue([]);
+
+ const close = () => {
+ 'worklet';
+ runOnJS(onClose)(selected.value);
+ };
+
+ return (
+ } onClose={close}>
+
+
+ {multiple && }
+
+
+
+ );
+}
diff --git a/src/screens/discover/components/TrendingTokens.tsx b/src/screens/discover/components/TrendingTokens.tsx
new file mode 100644
index 00000000000..033e603a287
--- /dev/null
+++ b/src/screens/discover/components/TrendingTokens.tsx
@@ -0,0 +1,417 @@
+import { ChainId } from '@/chains/types';
+import { ChainBadge } from '@/components/coin-icon';
+import { DropdownMenu } from '@/components/DropdownMenu';
+import { globalColors, Text, useBackgroundColor } from '@/design-system';
+import { useForegroundColor } from '@/design-system/color/useForegroundColor';
+
+import chroma from 'chroma-js';
+import { useState } from 'react';
+import React, { View } from 'react-native';
+import FastImage from 'react-native-fast-image';
+import { Gesture, GestureDetector } from 'react-native-gesture-handler';
+import LinearGradient from 'react-native-linear-gradient';
+import Animated, { LinearTransition, runOnJS, useAnimatedStyle, useSharedValue, withTiming } from 'react-native-reanimated';
+import { NetworkSelector } from './NetworkSwitcher';
+import * as i18n from '@/languages';
+import { useTheme } from '@/theme';
+
+const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
+
+function FilterButton({ icon, label, onPress }: { onPress?: VoidFunction; label: string; icon: string }) {
+ const pressed = useSharedValue(false);
+
+ const tap = Gesture.Tap()
+ .onBegin(() => {
+ 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 (
+
+
+
+ {icon}
+
+
+ {label}
+
+
+
+
+
+
+ );
+}
+
+function CategoryFilterButton({
+ selected,
+ onPress,
+ icon,
+ iconWidth = 16,
+ iconColor,
+ label,
+}: {
+ onPress: VoidFunction;
+ selected: boolean;
+ icon: string;
+ iconColor: string;
+ iconWidth?: number;
+ label: string;
+}) {
+ const { isDarkMode } = useTheme();
+ const fillTertiary = useBackgroundColor('fillTertiary');
+ const fillSecondary = useBackgroundColor('fillSecondary');
+
+ const borderColor = selected && isDarkMode ? globalColors.white80 : fillSecondary;
+
+ const pressed = useSharedValue(false);
+
+ const tap = Gesture.Tap()
+ .onBegin(() => {
+ pressed.value = true;
+ runOnJS(onPress)();
+ })
+ .onFinalize(() => (pressed.value = false));
+
+ 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 TokenIcon({ uri, chainId }: { uri: string; chainId: ChainId }) {
+ return (
+
+
+ {chainId !== ChainId.mainnet && }
+
+ );
+}
+
+function TrendingTokenRow() {
+ const separatorColor = useForegroundColor('separator');
+
+ const percentChange24h = '3.40%';
+ const percentChange1h = '8.82%';
+
+ const token = {
+ name: 'Uniswap',
+ symbol: 'UNI',
+ price: '$9.21',
+ };
+
+ const volume = '$1.8M';
+ const marketCap = '$1.8M';
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {token.name}
+
+
+ {token.symbol}
+
+
+ {token.price}
+
+
+
+
+
+
+ VOL
+
+
+ {volume}
+
+
+
+
+ |
+
+
+
+
+ MCAP
+
+
+ {marketCap}
+
+
+
+
+
+
+
+
+
+
+
+ {percentChange24h}
+
+
+
+
+ 1H
+
+
+ {percentChange1h}
+
+
+
+
+
+
+ );
+}
+
+const t = i18n.l.trending_tokens;
+
+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);
+
+ return (
+ <>
+ setOpen(true)} />
+ {isOpen && (
+ {
+ console.log(selected);
+ setOpen(false);
+ }}
+ onSelect={() => null}
+ multiple
+ />
+ )}
+ >
+ );
+}
+
+const sortFilters = ['volume', 'market_cap', 'top_gainers', 'top_losers'] as const;
+const timeFilters = ['day', 'week', 'month'] as const;
+type TrendingTokensFilter = {
+ category: 'trending' | 'new' | 'farcaster';
+ network: undefined | ChainId;
+ timeframe: (typeof timeFilters)[number];
+ sort: (typeof sortFilters)[number] | undefined;
+};
+
+export function TrendingTokens() {
+ const [filter, setFilter] = useState({
+ category: 'trending',
+ network: undefined,
+ timeframe: 'day',
+ sort: 'volume',
+ });
+ const setCategory = (category: TrendingTokensFilter['category']) => setFilter(filter => ({ ...filter, category }));
+ return (
+
+
+
+ setCategory('trending')}
+ />
+ setCategory('new')}
+ />
+ setCategory('farcaster')}
+ />
+
+
+
+
+
+ ({
+ actionTitle: i18n.t(t.filters.time[time]),
+ actionKey: time,
+ })),
+ }}
+ side="bottom"
+ onPressMenuItem={timeframe => setFilter(filter => ({ ...filter, timeframe }))}
+ >
+
+
+
+ ({
+ actionTitle: i18n.t(t.filters.sort[sort]),
+ actionKey: sort,
+ })),
+ }}
+ side="bottom"
+ onPressMenuItem={sort =>
+ setFilter(filter => {
+ if (sort === filter.sort) return { ...filter, sort: undefined };
+ return { ...filter, sort };
+ })
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/state/internal/createRainbowStore.ts b/src/state/internal/createRainbowStore.ts
index df1a4d11df0..0f7a164109f 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/styles/colors.ts b/src/styles/colors.ts
index b1bfe39c376..0eeb8b0f104 100644
--- a/src/styles/colors.ts
+++ b/src/styles/colors.ts
@@ -49,7 +49,7 @@ const darkModeColors = {
skeleton: '#191B21',
stackBackground: '#000000',
surfacePrimary: '#000000',
- white: '#12131A',
+ white: '#000',
whiteLabel: '#FFFFFF',
};