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', };