From e093c7af2b85ed4008871e7d73407c7354bdfe88 Mon Sep 17 00:00:00 2001 From: Matthew Wall Date: Mon, 23 Dec 2024 13:19:21 -0500 Subject: [PATCH] Convert Chain Selection DropdownMenu to Backend Networks (#6344) * start work on converting existing chain selection dropdowns to Zeego * available networks v2 convert * add token icons to claim dropdown and fix but with AvailableNetworks not showing ever * latest changes to try to get hitslop to work --- .../components/TokenList/ChainSelection.tsx | 52 ++--- src/components/DropdownMenu.tsx | 53 ++++- .../expanded-state/AvailableNetworksv2.tsx | 181 +++++++++--------- .../asset/ChartExpandedState.js | 2 +- .../{DropdownMenu.tsx => ClaimableMenu.tsx} | 31 +-- .../components/ClaimCustomization.tsx | 103 ++++------ 6 files changed, 198 insertions(+), 224 deletions(-) rename src/screens/claimables/shared/components/{DropdownMenu.tsx => ClaimableMenu.tsx} (61%) diff --git a/src/__swaps__/screens/Swap/components/TokenList/ChainSelection.tsx b/src/__swaps__/screens/Swap/components/TokenList/ChainSelection.tsx index 96456e309b7..95c8cb7b28f 100644 --- a/src/__swaps__/screens/Swap/components/TokenList/ChainSelection.tsx +++ b/src/__swaps__/screens/Swap/components/TokenList/ChainSelection.tsx @@ -10,15 +10,13 @@ import { ChainId } from '@/state/backendNetworks/types'; import { opacity } from '@/__swaps__/utils/swaps'; import { analyticsV2 } from '@/analytics'; import { ChainImage } from '@/components/coin-icon/ChainImage'; -import { ContextMenuButton } from '@/components/context-menu'; import { AnimatedText, Bleed, Box, Inline, Text, TextIcon, globalColors, useColorMode } from '@/design-system'; import { useAccountAccentColor } from '@/hooks'; import { useSharedValueState } from '@/hooks/reanimated/useSharedValueState'; import { userAssetsStore, useUserAssetsStore } from '@/state/assets/userAssets'; import { swapsStore } from '@/state/swaps/swapsStore'; -import { showActionSheetWithOptions } from '@/utils'; -import { OnPressMenuItemEventObject } from 'react-native-ios-context-menu'; -import { getChainsLabelWorklet, getChainsNameWorklet, useBackendNetworksStore } from '@/state/backendNetworks/backendNetworks'; +import { getChainsBadgeWorklet, getChainsLabelWorklet, useBackendNetworksStore } from '@/state/backendNetworks/backendNetworks'; +import { DropdownMenu, MenuItem } from '@/components/DropdownMenu'; type ChainSelectionProps = { allText?: string; @@ -58,7 +56,7 @@ export const ChainSelection = memo(function ChainSelection({ allText, output }: }); const handleSelectChain = useCallback( - ({ nativeEvent: { actionKey } }: Omit) => { + (actionKey: string) => { analyticsV2.track(analyticsV2.event.swapsChangedChainId, { inputAsset: swapsStore.getState().inputAsset, type: output ? 'output' : 'input', @@ -78,14 +76,16 @@ export const ChainSelection = memo(function ChainSelection({ allText, output }: ); const menuConfig = useMemo(() => { - const supportedChains = balanceSortedChainList.map(chainId => { + let supportedChains: MenuItem[] = []; + supportedChains = balanceSortedChainList.map(chainId => { return { actionKey: `${chainId}`, actionTitle: getChainsLabelWorklet(backendNetworks)[chainId], icon: { - iconType: 'ASSET', - // NOTE: chainsName[chainId] for mainnet is 'mainnet' and we need it to be 'ethereum' - iconValue: chainId === ChainId.mainnet ? 'ethereumBadge' : `${getChainsNameWorklet(backendNetworks)[chainId]}BadgeNoShadow`, + iconType: 'REMOTE', + iconValue: { + uri: getChainsBadgeWorklet(backendNetworks)[chainId], + }, }, }; }); @@ -95,8 +95,8 @@ export const ChainSelection = memo(function ChainSelection({ allText, output }: actionKey: 'all', actionTitle: i18n.t(i18n.l.exchange.all_networks), icon: { - iconType: 'icon', - iconValue: '􀆪', + iconType: 'SYSTEM', + iconValue: 'globe', }, }); } @@ -106,24 +106,6 @@ export const ChainSelection = memo(function ChainSelection({ allText, output }: }; }, [backendNetworks, balanceSortedChainList, output]); - const onShowActionSheet = useCallback(() => { - const chainTitles = menuConfig.menuItems.map(chain => chain.actionTitle); - - showActionSheetWithOptions( - { - options: chainTitles, - showSeparators: true, - }, - (index: number | undefined) => { - // NOTE: When they click away from the menu, the index is undefined - if (typeof index === 'undefined') return; - handleSelectChain({ - nativeEvent: { actionKey: menuConfig.menuItems[index].actionKey, actionTitle: '' }, - }); - } - ); - }, [handleSelectChain, menuConfig.menuItems]); - return ( @@ -166,16 +148,8 @@ export const ChainSelection = memo(function ChainSelection({ allText, output }: )} - + - {/* TODO: We need to add some ethereum utils to handle worklet functions */} {chainName} @@ -184,7 +158,7 @@ export const ChainSelection = memo(function ChainSelection({ allText, output }: 􀆏 - + ); diff --git a/src/components/DropdownMenu.tsx b/src/components/DropdownMenu.tsx index 8d2c6572e7c..5d2c49a4eab 100644 --- a/src/components/DropdownMenu.tsx +++ b/src/components/DropdownMenu.tsx @@ -1,14 +1,35 @@ -import React, { useCallback } from 'react'; +import React, { ComponentProps, useCallback } from 'react'; import * as DropdownMenuPrimitive from 'zeego/dropdown-menu'; import styled from 'styled-components'; import { IconConfig, MenuActionConfig, MenuConfig as _MenuConfig } from 'react-native-ios-context-menu'; import { ImageSystemSymbolConfiguration } from 'react-native-ios-context-menu/lib/typescript/types/ImageItemConfig'; -import { ImageSourcePropType } from 'react-native'; +import { ImageSourcePropType, ImageURISource } from 'react-native'; import type { SFSymbols5_0 } from 'sf-symbols-typescript'; import type { DropdownMenuContentProps } from '@radix-ui/react-dropdown-menu'; +import { ButtonPressAnimation } from './animations'; +import { DebugLayout, HitSlop } from '@/design-system'; + +type ExtendedDropdownMenuTriggerProps = ComponentProps & { + hitSlop?: number; + testID?: string; +}; export const DropdownMenuRoot = DropdownMenuPrimitive.Root; -export const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger; +export const DropdownMenuTrigger = DropdownMenuPrimitive.create( + (props: ExtendedDropdownMenuTriggerProps) => { + // TODO: This hitslop isn't working properly... + return ( + + + + {props.children} + + + + ); + }, + 'Trigger' +); export const DropdownMenuContent = DropdownMenuPrimitive.Content; export const DropdownMenuItem = DropdownMenuPrimitive.create( styled(DropdownMenuPrimitive.CheckboxItem)({ @@ -30,12 +51,18 @@ export type MenuItemAssetImage = { iconValue: ImageSourcePropType; }; -export type MenuItemIcon = Omit & (MenuItemSystemImage | MenuItemAssetImage); +export type MenuItemRemoteAssetImage = { + iconType: 'REMOTE'; + iconValue: ImageURISource; +}; + +export type MenuItemIcon = Omit & + (MenuItemSystemImage | MenuItemAssetImage | MenuItemRemoteAssetImage); export type MenuItem = Omit & { actionKey: T; actionTitle: string; - icon?: MenuItemIcon | { iconType: string; iconValue: string }; + icon?: MenuItemIcon; }; export type MenuConfig = Omit<_MenuConfig, 'menuItems' | 'menuTitle'> & { @@ -47,18 +74,24 @@ type DropDownMenuProps = { children: React.ReactElement; menuConfig: MenuConfig; onPressMenuItem: (actionKey: T) => void; + hitSlop?: number; + testID?: string; } & DropdownMenuContentProps; const buildIconConfig = (icon?: MenuItemIcon) => { if (!icon) return null; - if (icon.iconType === 'SYSTEM' && typeof icon.iconValue === 'string') { + if (icon.iconType === 'SYSTEM') { const ios = { name: icon.iconValue }; return ; } - if (icon.iconType === 'ASSET' && typeof icon.iconValue === 'object') { + if (icon.iconType === 'ASSET') { + return ; + } + + if (icon.iconType === 'REMOTE') { return ; } @@ -75,6 +108,8 @@ export function DropdownMenu({ side = 'right', alignOffset = 5, avoidCollisions = true, + hitSlop = 20, + testID, }: DropDownMenuProps) { const handleSelectItem = useCallback( (actionKey: T) => { @@ -85,7 +120,9 @@ export function DropdownMenu({ return ( - {children} + + {children} + null; +const radialGradientProps = { + center: [0, 1], + colors: colors.gradients.lightGreyWhite, + pointerEvents: 'none', + style: { + ...position.coverAsObject, + overflow: 'hidden', + }, +}; + const AvailableNetworksv2 = ({ asset, networks, @@ -40,16 +50,6 @@ const AvailableNetworksv2 = ({ const { goBack, navigate } = useNavigation(); const { isReadOnlyWallet } = useWallets(); - const radialGradientProps = { - center: [0, 1], - colors: colors.gradients.lightGreyWhite, - pointerEvents: 'none', - style: { - ...position.coverAsObject, - overflow: 'hidden', - }, - }; - const convertAssetAndNavigate = useCallback( (chainId: ChainId) => { if (isReadOnlyWallet && !enableActionsOnReadOnlyWallet) { @@ -115,13 +115,7 @@ const AvailableNetworksv2 = ({ [asset, goBack, isReadOnlyWallet, navigate, networks] ); - const handlePressContextMenu = useCallback( - // @ts-expect-error ContextMenu is an untyped JS component and can't type its onPress handler properly - ({ nativeEvent: { actionKey: chainId } }) => { - convertAssetAndNavigate(chainId); - }, - [convertAssetAndNavigate] - ); + const handlePressContextMenu = useCallback((chainId: string) => convertAssetAndNavigate(+chainId), [convertAssetAndNavigate]); const availableChainIds = useMemo(() => { // we dont want to show mainnet @@ -134,88 +128,101 @@ const AvailableNetworksv2 = ({ convertAssetAndNavigate(availableChainIds[0]); }, [availableChainIds, convertAssetAndNavigate]); - const chainsName = useBackendNetworksStore.getState().getChainsName(); - const networkMenuItems = useBackendNetworksStore + const defaultChains = useBackendNetworksStore.getState().getDefaultChains(); + const chainsLabel = useBackendNetworksStore.getState().getChainsLabel(); + const chainsBadge = useBackendNetworksStore.getState().getChainsBadge(); + + const networkMenuItems: MenuItem[] = useBackendNetworksStore .getState() .getSupportedChainIds() .filter(chainId => chainId !== ChainId.mainnet) .filter(chainId => availableChainIds.includes(chainId)) - .map(chainId => useBackendNetworksStore.getState().getDefaultChains()[chainId]) + .map(chainId => defaultChains[chainId]) .map(chain => ({ actionKey: `${chain.id}`, - actionTitle: useBackendNetworksStore.getState().getChainsLabel()[chain.id], + actionTitle: chainsLabel[chain.id], icon: { - iconType: 'ASSET', - iconValue: chain.id === ChainId.mainnet ? 'ethereumBadge' : `${chainsName[chain.id]}BadgeNoShadow`, + iconType: 'REMOTE', + iconValue: { + uri: chainsBadge[chain.id], + }, }, })); - const MenuWrapper = availableChainIds.length > 1 ? ContextMenuButton : Box; + const Children = useMemo(() => { + return ( + + + + + + + {availableChainIds?.map((chainId, index) => { + return ( + + + + ); + })} + + + + + {availableChainIds?.length > 1 + ? lang.t('expanded_state.asset.available_networks', { + availableNetworks: availableChainIds?.length, + }) + : lang.t('expanded_state.asset.available_networkv2', { + availableNetwork: useBackendNetworksStore.getState().getChainsName()[availableChainIds[0]], + })} + + + + + {availableChainIds?.length > 1 ? '􀁱' : '􀯻'} + + + + + ); + }, [availableChainIds, colors.transparent, handlePressButton, marginHorizontal]); if (availableChainIds.length === 0) return null; + + if (availableChainIds.length === 1) { + return ( + <> + {Children} + {hideDivider ? null : } + + ); + } + return ( <> - - - - - - - - {availableChainIds?.map((chainId, index) => { - return ( - - - - ); - })} - - - - - {availableChainIds?.length > 1 - ? lang.t('expanded_state.asset.available_networks', { - availableNetworks: availableChainIds?.length, - }) - : lang.t('expanded_state.asset.available_networkv2', { - availableNetwork: useBackendNetworksStore.getState().getChainsName()[availableChainIds[0]], - })} - - - - - {availableChainIds?.length > 1 ? '􀁱' : '􀯻'} - - - - - + + {Children} + {hideDivider ? null : } ); diff --git a/src/components/expanded-state/asset/ChartExpandedState.js b/src/components/expanded-state/asset/ChartExpandedState.js index 4704316db9b..438f8a5c966 100644 --- a/src/components/expanded-state/asset/ChartExpandedState.js +++ b/src/components/expanded-state/asset/ChartExpandedState.js @@ -335,7 +335,7 @@ export default function ChartExpandedState({ asset }) { {!data?.networks && isL2 && ( )} - {data?.networks && !hasBalance && ( + {data?.networks && assetWithPrice && ( diff --git a/src/screens/claimables/shared/components/DropdownMenu.tsx b/src/screens/claimables/shared/components/ClaimableMenu.tsx similarity index 61% rename from src/screens/claimables/shared/components/DropdownMenu.tsx rename to src/screens/claimables/shared/components/ClaimableMenu.tsx index 43dcac5ebb4..8f2a0c15ee5 100644 --- a/src/screens/claimables/shared/components/DropdownMenu.tsx +++ b/src/screens/claimables/shared/components/ClaimableMenu.tsx @@ -1,32 +1,20 @@ import { Bleed, Box, Text, useColorMode } from '@/design-system'; import React from 'react'; import { View } from 'react-native'; -import { ContextMenuButton } from '@/components/context-menu'; -import { OnPressMenuItemEventObject } from 'react-native-ios-context-menu'; +import { DropdownMenu, MenuConfig } from '@/components/DropdownMenu'; -export function DropdownMenu({ +export function ClaimableMenu({ disabled, menuConfig, muted, onPressMenuItem, - onShowActionSheet, text, icon, }: { disabled: boolean; - menuConfig: { - menuItems: { - actionKey: string; - actionTitle: string; - icon?: { - iconType: string; - iconValue: string; - }; - }[]; - }; + menuConfig: MenuConfig; muted: boolean; - onPressMenuItem: ({ nativeEvent: { actionKey } }: Omit) => void; - onShowActionSheet: () => void; + onPressMenuItem: (actionKey: string) => void; text: string; icon?: React.ReactNode; // must have size: 16 }) { @@ -34,14 +22,7 @@ export function DropdownMenu({ return ( - + - + ); } diff --git a/src/screens/claimables/transaction/components/ClaimCustomization.tsx b/src/screens/claimables/transaction/components/ClaimCustomization.tsx index 4ca888e266f..516f7d1dc6b 100644 --- a/src/screens/claimables/transaction/components/ClaimCustomization.tsx +++ b/src/screens/claimables/transaction/components/ClaimCustomization.tsx @@ -1,19 +1,18 @@ import { Box, Text } from '@/design-system'; -import { haptics, showActionSheetWithOptions } from '@/utils'; +import { haptics } from '@/utils'; import React, { useCallback, useMemo, useState } from 'react'; -import { ChainId } from '@/state/backendNetworks/types'; import { useBackendNetworksStore } from '@/state/backendNetworks/backendNetworks'; import { useUserAssetsStore } from '@/state/assets/userAssets'; import { ETH_SYMBOL, USDC_ADDRESS } from '@/references'; -import { DropdownMenu } from '../../shared/components/DropdownMenu'; +import { ClaimableMenu } from '../../shared/components/ClaimableMenu'; import { TokenToReceive } from '../types'; import { useTransactionClaimableContext } from '../context/TransactionClaimableContext'; import { useTokenSearch } from '@/__swaps__/screens/Swap/resources/search'; import { SearchAsset } from '@/__swaps__/types/search'; import * as i18n from '@/languages'; import { ChainImage } from '@/components/coin-icon/ChainImage'; -import { OnPressMenuItemEventObject } from 'react-native-ios-context-menu'; import { IS_ANDROID } from '@/env'; +import { MenuItem } from '@/components/DropdownMenu'; type TokenMap = Record; @@ -32,6 +31,7 @@ export function ClaimCustomization() { const chainsLabel = useBackendNetworksStore.getState().getChainsLabel(); const chainsName = useBackendNetworksStore.getState().getChainsName(); + const backendNetworks = useBackendNetworksStore(state => state.backendNetworksSharedValue); const { data: usdcSearchData } = useTokenSearch( { @@ -131,7 +131,8 @@ export function ClaimCustomization() { ]); const tokenMenuConfig = useMemo(() => { - const availableTokens = Object.values(tokens) + let availableTokens: MenuItem[] = []; + availableTokens = Object.values(tokens) .filter(token => { // exclude if token is already selected if (token.symbol === outputToken?.symbol) { @@ -163,10 +164,17 @@ export function ClaimCustomization() { .map(token => ({ actionKey: token.symbol, actionTitle: token.name, - })) - .sort((a, b) => (a.actionTitle < b.actionTitle ? 1 : -1)); + icon: { + iconType: 'REMOTE', + iconValue: { + uri: token.iconUrl, + }, + }, + })); + + availableTokens = availableTokens.sort((a, b) => (a.actionTitle < b.actionTitle ? 1 : -1)); - let menuItems = [ + availableTokens = [ { actionKey: 'reset', actionTitle: 'Reset', @@ -176,28 +184,37 @@ export function ClaimCustomization() { ]; if (IS_ANDROID) { - menuItems = menuItems.reverse(); + availableTokens = availableTokens.reverse(); } return { - menuItems, + menuItems: availableTokens, }; }, [tokens, outputToken?.symbol, isInitialState, outputChainId]); const networkMenuConfig = useMemo(() => { - const supportedChains = balanceSortedChainList + const chainsBadge = useBackendNetworksStore.getState().getChainsBadge(); + + let supportedChains: MenuItem[] = []; + + supportedChains = balanceSortedChainList .filter(chainId => isInitialState || (chainId !== outputChainId && (!outputToken || chainId in outputToken.networks))) .map(chainId => ({ actionKey: `${chainId}`, actionTitle: chainsLabel[chainId], icon: { - iconType: 'ASSET', - iconValue: chainId === ChainId.mainnet ? 'ethereumBadge' : `${chainsName[chainId]}BadgeNoShadow`, + iconType: 'REMOTE', + iconValue: { + uri: chainsBadge[chainId], + }, }, - })) - .reverse(); + })); - let menuItems = [ + if (!IS_ANDROID) { + supportedChains.reverse(); + } + + supportedChains = [ { actionKey: 'reset', actionTitle: 'Reset', @@ -206,17 +223,13 @@ export function ClaimCustomization() { ...supportedChains, ]; - if (IS_ANDROID) { - menuItems = menuItems.reverse(); - } - return { - menuItems, + menuItems: supportedChains, }; - }, [balanceSortedChainList, chainsLabel, chainsName, isInitialState, outputChainId, outputToken]); + }, [balanceSortedChainList, chainsLabel, isInitialState, outputChainId, outputToken]); const handleTokenSelection = useCallback( - ({ nativeEvent: { actionKey } }: Omit) => { + (actionKey: string) => { haptics.selection(); if (actionKey === 'reset') { resetState(); @@ -238,7 +251,7 @@ export function ClaimCustomization() { ); const handleNetworkSelection = useCallback( - ({ nativeEvent: { actionKey } }: Omit) => { + (actionKey: string) => { haptics.selection(); if (actionKey === 'reset') { resetState(); @@ -259,42 +272,6 @@ export function ClaimCustomization() { [resetState, setOutputConfig, setQuoteState, setGasState] ); - const onShowTokenActionSheet = useCallback(() => { - const tokenTitles = tokenMenuConfig.menuItems.map(token => token.actionTitle); - - showActionSheetWithOptions( - { - options: tokenTitles, - showSeparators: true, - }, - (index: number | undefined) => { - // NOTE: When they click away from the menu, the index is undefined - if (typeof index === 'undefined') return; - handleTokenSelection({ - nativeEvent: { actionKey: tokenMenuConfig.menuItems[index].actionKey, actionTitle: '' }, - }); - } - ); - }, [handleTokenSelection, tokenMenuConfig.menuItems]); - - const onShowNetworkActionSheet = useCallback(() => { - const networkTitles = networkMenuConfig.menuItems.map(network => network.actionTitle); - - showActionSheetWithOptions( - { - options: networkTitles, - showSeparators: true, - }, - (index: number | undefined) => { - // NOTE: When they click away from the menu, the index is undefined - if (typeof index === 'undefined') return; - handleNetworkSelection({ - nativeEvent: { actionKey: networkMenuConfig.menuItems[index].actionKey, actionTitle: '' }, - }); - } - ); - }, [handleNetworkSelection, networkMenuConfig.menuItems]); - const isDisabled = claimStatus === 'success' || claimStatus === 'pending' || claimStatus === 'claiming' || claimStatus === 'unrecoverableError'; @@ -303,22 +280,20 @@ export function ClaimCustomization() { {i18n.t(i18n.l.claimables.panel.receive)} - {i18n.t(i18n.l.claimables.panel.on)} - }