diff --git a/.changeset/moody-mugs-grin.md b/.changeset/moody-mugs-grin.md new file mode 100644 index 000000000000..fee9fc1c2457 --- /dev/null +++ b/.changeset/moody-mugs-grin.md @@ -0,0 +1,6 @@ +--- +"live-mobile": minor +"@ledgerhq/live-common": minor +--- + +When llmNetworkBasedAddAccount feature flag is enabled, all the ctas that open an add account process will be redirected to the new flow and support currency and related route params. diff --git a/apps/ledger-live-mobile/src/components/FabActions/actionsList/market/index.tsx b/apps/ledger-live-mobile/src/components/FabActions/actionsList/market/index.tsx index 0d45d8472ebf..13d6c84bd7b2 100644 --- a/apps/ledger-live-mobile/src/components/FabActions/actionsList/market/index.tsx +++ b/apps/ledger-live-mobile/src/components/FabActions/actionsList/market/index.tsx @@ -5,6 +5,7 @@ import { CryptoCurrency, TokenCurrency } from "@ledgerhq/types-cryptoassets"; import useAssetActions from "../../hooks/useAssetActions"; import { FabButtonBarProvider } from "../../index"; import FabButtonBar from "../../FabButtonBar"; +import { CurrenciesByProviderId } from "@ledgerhq/live-common/deposit/type"; type Props = { defaultAccount?: AccountLike; @@ -12,6 +13,7 @@ type Props = { currency?: CryptoCurrency | TokenCurrency; accounts?: AccountLike[]; eventProperties?: { [key: string]: unknown }; + currencyNetworkProviders?: CurrenciesByProviderId; }; const FabMarketActionsComponent: React.FC = ({ @@ -19,10 +21,12 @@ const FabMarketActionsComponent: React.FC = ({ accounts, defaultAccount, eventProperties, + currencyNetworkProviders, }) => { const { mainActions } = useAssetActions({ currency, accounts, + currencyNetworkProviders, }); return ( diff --git a/apps/ledger-live-mobile/src/components/FabActions/hooks/useAssetActions.tsx b/apps/ledger-live-mobile/src/components/FabActions/hooks/useAssetActions.tsx index 278b741daeb4..082db1b90d4c 100644 --- a/apps/ledger-live-mobile/src/components/FabActions/hooks/useAssetActions.tsx +++ b/apps/ledger-live-mobile/src/components/FabActions/hooks/useAssetActions.tsx @@ -17,10 +17,12 @@ import { useFetchCurrencyAll } from "@ledgerhq/live-common/exchange/swap/hooks/i import { flattenAccountsSelector } from "~/reducers/accounts"; import { PtxToast } from "../../Toast/PtxToast"; import { getStakeLabelLocaleBased } from "~/helpers/getStakeLabelLocaleBased"; +import { CurrenciesByProviderId } from "@ledgerhq/live-common/deposit/type"; type useAssetActionsProps = { currency?: CryptoCurrency | TokenCurrency; accounts?: AccountLikeArray; + currencyNetworkProviders?: CurrenciesByProviderId; }; const iconBuy = IconsLegacy.PlusMedium; @@ -31,13 +33,18 @@ const iconSend = IconsLegacy.ArrowTopMedium; const iconAddAccount = IconsLegacy.WalletMedium; const iconStake = IconsLegacy.CoinsMedium; -export default function useAssetActions({ currency, accounts }: useAssetActionsProps): { +export default function useAssetActions({ + currency, + accounts, + currencyNetworkProviders, +}: useAssetActionsProps): { mainActions: ActionButtonEvent[]; } { const route = useRoute(); const { data: currenciesAll } = useFetchCurrencyAll(); const ptxServiceCtaScreens = useFeature("ptxServiceCtaScreens"); + const llmNetworkBasedAddAccountFlow = useFeature("llmNetworkBasedAddAccountFlow"); const { t } = useTranslation(); const stakeLabel = getStakeLabelLocaleBased(); @@ -69,6 +76,68 @@ export default function useAssetActions({ currency, accounts }: useAssetActionsP ? getParentAccount(defaultAccount, totalAccounts) : undefined; + const addAccountNavigationConfig = useMemo(() => { + if (llmNetworkBasedAddAccountFlow?.enabled) { + const context = "addAccounts"; + // if it's a token + if (currency?.type === "TokenCurrency") { + if (!currencyNetworkProviders) + return [ + NavigatorName.AssetSelection, + { + screen: ScreenName.AddAccountsSelectCrypto, + params: { + filterCurrencyIds: currency ? [currency.parentCurrency.id] : undefined, + context, + }, + }, + ] as const; + // if it's a token and it has only one parent currency + if (currencyNetworkProviders && currencyNetworkProviders?.currenciesByNetwork.length === 1) + return [ + NavigatorName.DeviceSelection, + { + screen: ScreenName.SelectDevice, + params: { + currency: currency.parentCurrency, + createTokenAccount: true, + context, + }, + }, + ] as const; + else if ( + currencyNetworkProviders && + currencyNetworkProviders?.currenciesByNetwork.length > 1 + ) + return [ + NavigatorName.AssetSelection, + { + screen: ScreenName.SelectNetwork, + currency: currency.id, + context, + }, + ] as const; + } else + return [ + NavigatorName.DeviceSelection, + { + currency: currency?.id, + context, + }, + ] as const; + } else { + return [ + NavigatorName.AddAccounts, + { + screen: ScreenName.AddAccountsSelectCrypto, + params: { + filterCurrencyIds: currency ? [currency.id] : undefined, + }, + }, + ] as const; + } + }, [llmNetworkBasedAddAccountFlow?.enabled, currency, currencyNetworkProviders]); + const actions = useMemo(() => { const isPtxServiceCtaScreensDisabled = !(ptxServiceCtaScreens?.enabled ?? true); @@ -225,15 +294,7 @@ export default function useAssetActions({ currency, accounts }: useAssetActionsP id: "add_account", label: t("addAccountsModal.ctaAdd"), Icon: iconAddAccount, - navigationParams: [ - NavigatorName.AddAccounts, - { - screen: ScreenName.AddAccountsSelectCrypto, - params: { - filterCurrencyIds: currency ? [currency.id] : undefined, - }, - }, - ] as const, + navigationParams: addAccountNavigationConfig, }, ] : []), @@ -254,6 +315,7 @@ export default function useAssetActions({ currency, accounts }: useAssetActionsP stakeLabel, t, route, + addAccountNavigationConfig, ]); return { diff --git a/apps/ledger-live-mobile/src/components/RootNavigator/SwapNavigator.tsx b/apps/ledger-live-mobile/src/components/RootNavigator/SwapNavigator.tsx index a05b4ca37be8..c7b920345091 100644 --- a/apps/ledger-live-mobile/src/components/RootNavigator/SwapNavigator.tsx +++ b/apps/ledger-live-mobile/src/components/RootNavigator/SwapNavigator.tsx @@ -41,9 +41,7 @@ export default function SwapNavigator( title: t("transfer.swap2.form.title"), headerLeft: () => null, }} - initialParams={{ - ...params, - }} + initialParams={params as Partial} /> >; [NavigatorName.AssetSelection]?: Partial< - NavigatorScreenParams - > & - CommonAddAccountNavigatorParamsList; + NavigatorScreenParams & { + context?: "addAccounts" | "receiveFunds"; + token?: TokenCurrency; + } // in some cases we need to pass directly the context to the navigator and let it handle the logic + >; [NavigatorName.Assets]?: Partial>; }; diff --git a/apps/ledger-live-mobile/src/components/RootNavigator/types/RequestAccountNavigator.ts b/apps/ledger-live-mobile/src/components/RootNavigator/types/RequestAccountNavigator.ts index 6f246774dd1a..885c5e3efd06 100644 --- a/apps/ledger-live-mobile/src/components/RootNavigator/types/RequestAccountNavigator.ts +++ b/apps/ledger-live-mobile/src/components/RootNavigator/types/RequestAccountNavigator.ts @@ -6,6 +6,7 @@ import { Observable } from "rxjs"; import { NavigatorScreenParams } from "@react-navigation/native"; import { NavigatorName, ScreenName } from "~/const"; import { AddAccountsNavigatorParamList } from "./AddAccountsNavigator"; +import { DeviceSelectionNavigatorParamsList } from "LLM/features/DeviceSelection/types"; export type RequestAccountNavigatorParamList = { [ScreenName.RequestAccountsSelectCrypto]: { @@ -30,4 +31,14 @@ export type RequestAccountNavigatorParamList = { analyticsPropertyFlow?: string; onSuccess?: (account: AccountLike, parentAccount?: Account) => void; }>; + [NavigatorName.DeviceSelection]: Partial< + NavigatorScreenParams & + Partial<{ + token?: TokenCurrency; + inline?: boolean; + returnToSwap?: boolean; + analyticsPropertyFlow?: string; + onSuccess?: (account: AccountLike, parentAccount?: Account) => void; + }> + >; }; diff --git a/apps/ledger-live-mobile/src/components/RootNavigator/types/SwapNavigator.ts b/apps/ledger-live-mobile/src/components/RootNavigator/types/SwapNavigator.ts index fabdb2249ade..658bf9fc1eb1 100644 --- a/apps/ledger-live-mobile/src/components/RootNavigator/types/SwapNavigator.ts +++ b/apps/ledger-live-mobile/src/components/RootNavigator/types/SwapNavigator.ts @@ -44,7 +44,9 @@ import type { Transaction as CasperTransaction } from "@ledgerhq/live-common/fam import type { Transaction as TonTransaction } from "@ledgerhq/live-common/families/ton/types"; import BigNumber from "bignumber.js"; import { Account, Operation } from "@ledgerhq/types-live"; -import { ScreenName } from "~/const"; +import { NavigatorName, ScreenName } from "~/const"; +import { NavigatorScreenParams } from "@react-navigation/core"; +import { AssetSelectionNavigatorParamsList } from "LLM/features/AssetSelection/types"; type Target = "from" | "to"; @@ -329,4 +331,7 @@ export type SwapNavigatorParamList = { | ScreenName.SendSelectDevice | ScreenName.SwapForm; }; + [NavigatorName.AssetSelection]?: Partial< + NavigatorScreenParams + >; }; diff --git a/apps/ledger-live-mobile/src/locales/en/common.json b/apps/ledger-live-mobile/src/locales/en/common.json index 30f0df5aeb49..f71ba841699a 100644 --- a/apps/ledger-live-mobile/src/locales/en/common.json +++ b/apps/ledger-live-mobile/src/locales/en/common.json @@ -7123,5 +7123,13 @@ } } } + }, + "assetSelection": { + "selectCrypto": { + "title": "Select Asset" + }, + "selectNetwork": { + "title": "Select the blockchain network the asset belongs to" + } } } diff --git a/apps/ledger-live-mobile/src/newArch/features/Accounts/Navigator.tsx b/apps/ledger-live-mobile/src/newArch/features/Accounts/Navigator.tsx index fe91bf4831d6..7323190f4406 100644 --- a/apps/ledger-live-mobile/src/newArch/features/Accounts/Navigator.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/Accounts/Navigator.tsx @@ -48,6 +48,7 @@ export default function Navigator() { options={{ headerTitle: "", }} + initialParams={route.params} /> {/* Scan accounts from device */} diff --git a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/components/SelectAddAccountMethod/useSelectAddAccountMethodViewModel.ts b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/components/SelectAddAccountMethod/useSelectAddAccountMethodViewModel.ts index 6617519af156..08f5c3c673a4 100644 --- a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/components/SelectAddAccountMethod/useSelectAddAccountMethodViewModel.ts +++ b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/components/SelectAddAccountMethod/useSelectAddAccountMethodViewModel.ts @@ -27,12 +27,22 @@ const useSelectAddAccountMethodViewModel = ({ const hasCurrency = !!currency; const navigationParams = useMemo(() => { - return hasCurrency - ? currency.type === "TokenCurrency" - ? { token: currency } - : { currency } - : {}; - }, [hasCurrency, currency]); + if (hasCurrency) { + if (currency?.type === "TokenCurrency") { + return { + token: currency, + ...(llmNetworkBasedAddAccountFlow?.enabled && { context: "addAccounts" }), + }; + } else { + return { + currency, + ...(llmNetworkBasedAddAccountFlow?.enabled && { context: "addAccounts" }), + }; + } + } else { + return llmNetworkBasedAddAccountFlow?.enabled ? { context: "addAccounts" } : {}; + } + }, [hasCurrency, currency, llmNetworkBasedAddAccountFlow?.enabled]); const trackButtonClick = useCallback((button: string) => { track("button_clicked", { @@ -58,6 +68,9 @@ const useSelectAddAccountMethodViewModel = ({ const EntryNavigatorName = llmNetworkBasedAddAccountFlow?.enabled ? NavigatorName.AssetSelection : NavigatorName.AddAccounts; + // to delete after llmNetworkBasedAddAccountFlow is fully enabled (ts inference not working well based on navigationParams) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore navigation.navigate(EntryNavigatorName, navigationParams); }, [ navigation, diff --git a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/types.ts b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/types.ts index cff50a753d86..dc5f7686c794 100644 --- a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/types.ts +++ b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/types.ts @@ -2,12 +2,16 @@ import { CryptoCurrency, TokenCurrency } from "@ledgerhq/types-cryptoassets"; import { ScreenName } from "~/const"; import { Device } from "@ledgerhq/types-devices"; +type CommonParams = { + context?: "addAccounts" | "receiveFunds"; + onSuccess?: () => void; +}; export type NetworkBasedAddAccountNavigator = { - [ScreenName.SelectAccounts]: { + [ScreenName.SelectAccounts]: CommonParams & { currency: CryptoCurrency | TokenCurrency; createTokenAccount?: boolean; }; - [ScreenName.ScanDeviceAccounts]: { + [ScreenName.ScanDeviceAccounts]: CommonParams & { currency: CryptoCurrency | TokenCurrency; device: Device; onSuccess?: (_?: unknown) => void; diff --git a/apps/ledger-live-mobile/src/newArch/features/AssetSelection/Navigator.tsx b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/Navigator.tsx index 1a6cd42457fc..d6aae06a7341 100644 --- a/apps/ledger-live-mobile/src/newArch/features/AssetSelection/Navigator.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/Navigator.tsx @@ -3,7 +3,7 @@ import { Platform } from "react-native"; import { createStackNavigator } from "@react-navigation/stack"; import { useTheme } from "styled-components/native"; import { useRoute } from "@react-navigation/native"; -import { ScreenName } from "~/const"; +import { NavigatorName, ScreenName } from "~/const"; import { getStackNavigatorConfig } from "~/navigation/navigatorConfig"; import { track } from "~/analytics"; import { Flex } from "@ledgerhq/native-ui"; @@ -16,13 +16,19 @@ import SelectNetwork from "LLM/features/AssetSelection/screens/SelectNetwork"; import { NavigationHeaderCloseButtonAdvanced } from "~/components/NavigationHeaderCloseButton"; import { NavigationHeaderBackButton } from "~/components/NavigationHeaderBackButton"; import { AssetSelectionNavigatorParamsList } from "./types"; +import { BaseComposite, StackNavigatorProps } from "~/components/RootNavigator/types/helpers"; + +type NavigationProps = BaseComposite< + StackNavigatorProps +>; export default function Navigator() { const { colors } = useTheme(); - const route = useRoute(); - + const route = useRoute(); const hasClosedNetworkBanner = useSelector(hasClosedNetworkBannerSelector); + const { token, currency } = route.params || {}; + const onClose = useCallback(() => { track("button_clicked", { button: "Close", @@ -44,15 +50,19 @@ export default function Navigator() { ...stackNavigationConfig, gestureEnabled: Platform.OS === "ios", }} + initialRouteName={ + token || currency ? ScreenName.SelectNetwork : ScreenName.AddAccountsSelectCrypto + } > , - headerTitle: "", + title: "", headerRight: () => , }} + initialParams={route.params} /> ), }} + initialParams={route.params} /> ); diff --git a/apps/ledger-live-mobile/src/newArch/features/AssetSelection/__integrations__/assetSelection.integration.test.tsx b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/__integrations__/assetSelection.integration.test.tsx new file mode 100644 index 000000000000..442cf001d3a3 --- /dev/null +++ b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/__integrations__/assetSelection.integration.test.tsx @@ -0,0 +1,124 @@ +import React from "react"; +import { render, screen } from "@tests/test-renderer"; +import AssetSelectionNavigator from "../Navigator"; +import { useRoute, useNavigation } from "@react-navigation/native"; +import { useGroupedCurrenciesByProvider } from "@ledgerhq/live-common/deposit/index"; +import { + mockGroupedCurrenciesBySingleProviderData, + mockGroupedCurrenciesWithMultipleProviderData, +} from "./mockData"; + +const MockUseRoute = useRoute as jest.Mock; +const MockUseGroupedCurrenciesByProvider = useGroupedCurrenciesByProvider as jest.Mock; +const mockNavigate = jest.fn(); + +(useNavigation as jest.Mock).mockReturnValue({ + navigate: mockNavigate, +}); + +jest.mock("../components/NetworkBanner", () => { + return { + __esModule: true, + default: () =>
Mock Network Banner
, + }; +}); + +jest.mock("@ledgerhq/live-common/deposit/index", () => ({ + useGroupedCurrenciesByProvider: jest.fn(), +})); + +jest.mock("@react-navigation/native", () => ({ + ...jest.requireActual("@react-navigation/native"), + useRoute: jest.fn(), + useNavigation: jest.fn(), +})); + +jest.useFakeTimers(); + +describe("Asset Selection test suite", () => { + it("should render crypto selection screen when no currency is defined in the navigation route showing a loader", () => { + MockUseRoute.mockReturnValue({ + params: { + context: "addAccounts", + }, + }); + + MockUseGroupedCurrenciesByProvider.mockReturnValue({ + result: { currenciesByProvider: [], sortedCryptoCurrencies: [] }, + loadingStatus: "pending", + }); + + render(); + + const screenTitle = screen.getByText(/select asset/i); + const selectCryptoViewArea = screen.getByTestId("select-crypto-view-area"); + const loader = screen.getByTestId("loader"); + expect(screenTitle).toBeVisible(); + expect(screenTitle).toHaveProp("testID", "select-crypto-header-step1-title"); + expect(selectCryptoViewArea).toBeVisible(); + expect(loader).toBeVisible(); + }); + + it("should render crypto selection screen with empty list when useGroupedCurrenciesByProvider finish loading with empty result", () => { + MockUseRoute.mockReturnValue({ + params: { + context: "addAccounts", + }, + }); + + MockUseGroupedCurrenciesByProvider.mockReturnValue({ + result: { currenciesByProvider: [], sortedCryptoCurrencies: [] }, + loadingStatus: "success", + }); + + render(); + + const emptyList = screen.getByText(/no crypto assets found/i); + expect(emptyList).toBeVisible(); + }); + it("should display a list of cryptocurrencies when useGroupedCurrenciesByProvider successfully loads data", () => { + MockUseRoute.mockReturnValue({ + params: { + context: "addAccounts", + }, + }); + + MockUseGroupedCurrenciesByProvider.mockReturnValue({ + result: mockGroupedCurrenciesBySingleProviderData, + loadingStatus: "success", + }); + + render(); + + const selectCryptoViewArea = screen.getByTestId("select-crypto-view-area"); + const cryptoCurrencyRow = screen.getByText(/bitcoin/i); + expect(selectCryptoViewArea).toBeVisible(); + expect(cryptoCurrencyRow).toBeVisible(); + }); + it("should navigate to network selection when currency has more than one network provider", () => { + MockUseRoute.mockReturnValue({ + params: { + context: "addAccounts", + currency: "ethereum", + }, + }); + + MockUseGroupedCurrenciesByProvider.mockReturnValue({ + result: mockGroupedCurrenciesWithMultipleProviderData, + loadingStatus: "success", + }); + + render(); + + const selectNetwork = screen.getByTestId("select-network-view-area"); + const title = screen.getByText(/Select the blockchain network the asset belongs to/i); + const arbitrum = screen.getByText(/arbitrum/i); + const blast = screen.getByText(/blast/i); + const boba = screen.getByText(/boba/i); + expect(selectNetwork).toBeVisible(); + expect(title).toBeVisible(); + [arbitrum, blast, boba].forEach(network => { + expect(network).toBeVisible(); + }); + }); +}); diff --git a/apps/ledger-live-mobile/src/newArch/features/AssetSelection/__integrations__/mockData.ts b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/__integrations__/mockData.ts new file mode 100644 index 000000000000..2210f4883bc2 --- /dev/null +++ b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/__integrations__/mockData.ts @@ -0,0 +1,365 @@ +const mockGroupedCurrenciesBySingleProviderData = { + currenciesByProvider: [ + { + providerId: "bitcoin", + currenciesByNetwork: [ + { + type: "CryptoCurrency", + id: "bitcoin", + coinType: 0, + name: "Bitcoin", + managerAppName: "Bitcoin", + ticker: "BTC", + scheme: "bitcoin", + color: "#ffae35", + symbol: "Ƀ", + units: [ + { name: "bitcoin", code: "BTC", magnitude: 8 }, + { name: "mBTC", code: "mBTC", magnitude: 5 }, + { name: "bit", code: "bit", magnitude: 2 }, + { name: "satoshi", code: "sat", magnitude: 0 }, + ], + supportsSegwit: true, + supportsNativeSegwit: true, + family: "bitcoin", + blockAvgTime: 900, + bitcoinLikeInfo: { P2PKH: 0, P2SH: 5, XPUBVersion: 76067358 }, + explorerViews: [ + { + address: "https://blockstream.info/address/$address", + tx: "https://blockstream.info/tx/$hash", + }, + { + address: "https://www.blockchain.com/btc/address/$address", + tx: "https://blockchain.info/btc/tx/$hash", + }, + ], + keywords: ["btc", "bitcoin"], + explorerId: "btc", + }, + ], + }, + ], + sortedCryptoCurrencies: [ + { + type: "CryptoCurrency", + id: "bitcoin", + coinType: 0, + name: "Bitcoin", + managerAppName: "Bitcoin", + ticker: "BTC", + scheme: "bitcoin", + color: "#ffae35", + symbol: "Ƀ", + units: [ + { name: "bitcoin", code: "BTC", magnitude: 8 }, + { name: "mBTC", code: "mBTC", magnitude: 5 }, + { name: "bit", code: "bit", magnitude: 2 }, + { name: "satoshi", code: "sat", magnitude: 0 }, + ], + supportsSegwit: true, + supportsNativeSegwit: true, + family: "bitcoin", + blockAvgTime: 900, + bitcoinLikeInfo: { P2PKH: 0, P2SH: 5, XPUBVersion: 76067358 }, + explorerViews: [ + { + address: "https://blockstream.info/address/$address", + tx: "https://blockstream.info/tx/$hash", + }, + { + address: "https://www.blockchain.com/btc/address/$address", + tx: "https://blockchain.info/btc/tx/$hash", + }, + ], + keywords: ["btc", "bitcoin"], + explorerId: "btc", + }, + ], +}; + +const mockGroupedCurrenciesWithMultipleProviderData = { + currenciesByProvider: [ + { + providerId: "ethereum", + currenciesByNetwork: [ + { + type: "CryptoCurrency", + id: "ethereum", + coinType: 60, + name: "Ethereum", + managerAppName: "Ethereum", + ticker: "ETH", + scheme: "ethereum", + color: "#0ebdcd", + symbol: "Ξ", + family: "evm", + blockAvgTime: 15, + units: [ + { name: "ether", code: "ETH", magnitude: 18 }, + { name: "Gwei", code: "Gwei", magnitude: 9 }, + { name: "Mwei", code: "Mwei", magnitude: 6 }, + { name: "Kwei", code: "Kwei", magnitude: 3 }, + { name: "wei", code: "wei", magnitude: 0 }, + ], + ethereumLikeInfo: { chainId: 1 }, + explorerViews: [ + { + tx: "https://etherscan.io/tx/$hash", + address: "https://etherscan.io/address/$address", + token: "https://etherscan.io/token/$contractAddress?a=$address", + }, + ], + keywords: ["eth", "ethereum"], + explorerId: "eth", + }, + { + type: "CryptoCurrency", + id: "zksync", + coinType: 60, + name: "ZKsync", + managerAppName: "Ethereum", + ticker: "ETH", + scheme: "zksync", + color: "#000000", + family: "evm", + units: [ + { name: "ETH", code: "ETH", magnitude: 18 }, + { name: "Gwei", code: "Gwei", magnitude: 9 }, + { name: "Mwei", code: "Mwei", magnitude: 6 }, + { name: "Kwei", code: "Kwei", magnitude: 3 }, + { name: "wei", code: "wei", magnitude: 0 }, + ], + ethereumLikeInfo: { chainId: 324 }, + explorerViews: [ + { + tx: "https://explorer.zksync.io/tx/$hash", + address: "https://explorer.zksync.io/address/$address", + token: "https://explorer.zksync.io/token/$contractAddress?a=$address", + }, + ], + }, + { + type: "CryptoCurrency", + id: "scroll", + coinType: 60, + name: "Scroll", + managerAppName: "Ethereum", + ticker: "ETH", + scheme: "scroll", + color: "#ebc28e", + family: "evm", + units: [ + { name: "ETH", code: "ETH", magnitude: 18 }, + { name: "Gwei", code: "Gwei", magnitude: 9 }, + { name: "Mwei", code: "Mwei", magnitude: 6 }, + { name: "Kwei", code: "Kwei", magnitude: 3 }, + { name: "wei", code: "wei", magnitude: 0 }, + ], + disableCountervalue: false, + ethereumLikeInfo: { chainId: 534352 }, + explorerViews: [ + { + tx: "https://scrollscan.com/tx/$hash", + address: "https://scrollscan.com/address/$address", + token: "https://scrollscan.com/token/$address", + }, + ], + }, + { + type: "CryptoCurrency", + id: "optimism", + coinType: 60, + name: "OP Mainnet", + managerAppName: "Ethereum", + ticker: "ETH", + scheme: "optimism", + color: "#FF0421", + family: "evm", + units: [ + { name: "ether", code: "ETH", magnitude: 18 }, + { name: "Gwei", code: "Gwei", magnitude: 9 }, + { name: "Mwei", code: "Mwei", magnitude: 6 }, + { name: "Kwei", code: "Kwei", magnitude: 3 }, + { name: "wei", code: "wei", magnitude: 0 }, + ], + ethereumLikeInfo: { chainId: 10 }, + explorerViews: [ + { + tx: "https://optimistic.etherscan.io/tx/$hash", + address: "https://optimistic.etherscan.io/address/$address", + token: "https://optimistic.etherscan.io/token/$contractAddress?a=$address", + }, + ], + keywords: ["optimism"], + }, + { + type: "CryptoCurrency", + id: "linea", + coinType: 60, + name: "Linea", + managerAppName: "Ethereum", + ticker: "ETH", + scheme: "linea", + color: "#000000", + family: "evm", + units: [ + { name: "ETH", code: "ETH", magnitude: 18 }, + { name: "Gwei", code: "Gwei", magnitude: 9 }, + { name: "Mwei", code: "Mwei", magnitude: 6 }, + { name: "Kwei", code: "Kwei", magnitude: 3 }, + { name: "wei", code: "wei", magnitude: 0 }, + ], + disableCountervalue: false, + ethereumLikeInfo: { chainId: 59144 }, + explorerViews: [ + { + tx: "https://lineascan.build/tx/$hash", + address: "https://lineascan.build/address/$address", + token: "https://lineascan.build/token/$address", + }, + ], + }, + { + type: "CryptoCurrency", + id: "base", + coinType: 60, + name: "Base", + managerAppName: "Ethereum", + ticker: "ETH", + scheme: "base", + color: "#1755FE", + family: "evm", + units: [ + { name: "ETH", code: "ETH", magnitude: 18 }, + { name: "Gwei", code: "Gwei", magnitude: 9 }, + { name: "Mwei", code: "Mwei", magnitude: 6 }, + { name: "Kwei", code: "Kwei", magnitude: 3 }, + { name: "wei", code: "wei", magnitude: 0 }, + ], + ethereumLikeInfo: { chainId: 8453 }, + explorerViews: [ + { + tx: "https://basescan.org/tx/$hash", + address: "https://basescan.org/address/$address", + token: "https://basescan.org/token/$contractAddress?a=$address", + }, + ], + }, + { + type: "CryptoCurrency", + id: "arbitrum", + coinType: 60, + name: "Arbitrum", + managerAppName: "Ethereum", + ticker: "ETH", + scheme: "arbitrum", + color: "#28a0f0", + family: "evm", + units: [ + { name: "ETH", code: "ETH", magnitude: 18 }, + { name: "Gwei", code: "Gwei", magnitude: 9 }, + { name: "Mwei", code: "Mwei", magnitude: 6 }, + { name: "Kwei", code: "Kwei", magnitude: 3 }, + { name: "wei", code: "wei", magnitude: 0 }, + ], + ethereumLikeInfo: { chainId: 42161 }, + explorerViews: [ + { + tx: "https://arbiscan.io/tx/$hash", + address: "https://arbiscan.io/address/$address", + token: "https://arbiscan.io/token/$contractAddress?a=$address", + }, + ], + }, + { + type: "CryptoCurrency", + id: "blast", + coinType: 60, + name: "Blast", + managerAppName: "Ethereum", + ticker: "ETH", + scheme: "blast", + color: "#FCFC06", + family: "evm", + units: [ + { name: "ETH", code: "ETH", magnitude: 18 }, + { name: "Gwei", code: "Gwei", magnitude: 9 }, + { name: "Mwei", code: "Mwei", magnitude: 6 }, + { name: "Kwei", code: "Kwei", magnitude: 3 }, + { name: "wei", code: "wei", magnitude: 0 }, + ], + disableCountervalue: false, + ethereumLikeInfo: { chainId: 81457 }, + explorerViews: [ + { + tx: "https://blastscan.io/tx/$hash", + address: "https://blastscan.io/address/$address", + token: "https://blastscan.io/token/$address", + }, + ], + }, + { + type: "CryptoCurrency", + id: "boba", + coinType: 60, + name: "Boba", + managerAppName: "Ethereum", + ticker: "ETH", + scheme: "boba", + color: "#CBFF00", + family: "evm", + units: [ + { name: "ETH", code: "ETH", magnitude: 18 }, + { name: "Gwei", code: "Gwei", magnitude: 9 }, + { name: "Mwei", code: "Mwei", magnitude: 6 }, + { name: "Kwei", code: "Kwei", magnitude: 3 }, + { name: "wei", code: "wei", magnitude: 0 }, + ], + ethereumLikeInfo: { chainId: 288 }, + explorerViews: [ + { + tx: "https://bobascan.com/tx/$hash", + address: "https://bobascan.com/address/$address", + token: "https://bobascan.com/token/$contractAddress?a=$address", + }, + ], + }, + ], + }, + ], + sortedCryptoCurrencies: [ + { + type: "CryptoCurrency", + id: "ethereum", + coinType: 60, + name: "Ethereum", + managerAppName: "Ethereum", + ticker: "ETH", + scheme: "ethereum", + color: "#0ebdcd", + symbol: "Ξ", + family: "evm", + blockAvgTime: 15, + units: [ + { name: "ether", code: "ETH", magnitude: 18 }, + { name: "Gwei", code: "Gwei", magnitude: 9 }, + { name: "Mwei", code: "Mwei", magnitude: 6 }, + { name: "Kwei", code: "Kwei", magnitude: 3 }, + { name: "wei", code: "wei", magnitude: 0 }, + ], + ethereumLikeInfo: { chainId: 1 }, + explorerViews: [ + { + tx: "https://etherscan.io/tx/$hash", + address: "https://etherscan.io/address/$address", + token: "https://etherscan.io/token/$contractAddress?a=$address", + }, + ], + keywords: ["eth", "ethereum"], + explorerId: "eth", + }, + ], +}; + +export { mockGroupedCurrenciesBySingleProviderData, mockGroupedCurrenciesWithMultipleProviderData }; diff --git a/apps/ledger-live-mobile/src/newArch/features/AssetSelection/components/NetworkBanner/index.tsx b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/components/NetworkBanner/index.tsx new file mode 100644 index 000000000000..79bcc4faca50 --- /dev/null +++ b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/components/NetworkBanner/index.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import { BannerCard, Flex } from "@ledgerhq/native-ui"; + +import { ChartNetworkMedium } from "@ledgerhq/native-ui/assets/icons"; +import { useTranslation } from "react-i18next"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +type BannerProps = { + hideBanner: () => void; + onPress: () => void; +}; + +const NetworkBanner = ({ onPress, hideBanner }: BannerProps) => { + const { t } = useTranslation(); + const insets = useSafeAreaInsets(); + return ( + + } + onPressDismiss={hideBanner} + onPress={onPress} + /> + + ); +}; + +export default NetworkBanner; diff --git a/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectCrypto/index.tsx b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectCrypto/index.tsx index 9e5bd36b0b15..dae63aa1ce15 100644 --- a/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectCrypto/index.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectCrypto/index.tsx @@ -1,28 +1,23 @@ -import React, { useCallback, useEffect, useMemo } from "react"; -import { Trans, useTranslation } from "react-i18next"; +import React, { useCallback } from "react"; +import { Trans } from "react-i18next"; import { FlatList } from "react-native"; -import debounce from "lodash/debounce"; -import { useSelector } from "react-redux"; import type { CryptoCurrency, CryptoOrTokenCurrency, TokenCurrency, } from "@ledgerhq/types-cryptoassets"; -import { findCryptoCurrencyByKeyword } from "@ledgerhq/live-common/currencies/index"; import { getEnv } from "@ledgerhq/live-env"; -import { useGroupedCurrenciesByProvider } from "@ledgerhq/live-common/deposit/index"; import SafeAreaView from "~/components/SafeAreaView"; import { Flex, InfiniteLoader, Text } from "@ledgerhq/native-ui"; -import { NavigatorName, ScreenName } from "~/const"; -import { track, TrackScreen } from "~/analytics"; +import { ScreenName } from "~/const"; +import { TrackScreen } from "~/analytics"; import FilteredSearchBar from "~/components/FilteredSearchBar"; import BigCurrencyRow from "~/components/BigCurrencyRow"; -import { flattenAccountsSelector } from "~/reducers/accounts"; import { StackNavigatorProps } from "~/components/RootNavigator/types/helpers"; -import { findAccountByCurrency } from "~/logic/deposit"; import { AssetSelectionNavigatorParamsList } from "../../types"; +import useSelectCryptoViewModel from "./useSelectCryptoViewModel"; const SEARCH_KEYS = getEnv("CRYPTO_ASSET_SEARCH_KEYS"); @@ -37,82 +32,17 @@ const renderEmptyList = () => ( ); export default function SelectCrypto({ - navigation, route, }: StackNavigatorProps) { - const paramsCurrency = route?.params?.currency; - const filterCurrencyIds = route?.params?.filterCurrencyIds; - const filterCurrencyIdsSet = useMemo( - () => (filterCurrencyIds ? new Set(filterCurrencyIds) : null), - [filterCurrencyIds], - ); - - const { t } = useTranslation(); - const accounts = useSelector(flattenAccountsSelector); - - const { currenciesByProvider, sortedCryptoCurrencies } = useGroupedCurrenciesByProvider(); - - const onPressItem = useCallback( - (curr: CryptoCurrency | TokenCurrency) => { - track("asset_clicked", { - asset: curr.name, - page: "Choose a crypto to secure", - }); - - const provider = currenciesByProvider.find(elem => - elem.currenciesByNetwork.some( - currencyByNetwork => (currencyByNetwork as CryptoCurrency | TokenCurrency).id === curr.id, - ), - ); - - // If the selected currency exists on multiple networks we redirect to the SelectNetwork screen - if (provider && provider?.currenciesByNetwork.length > 1) { - navigation.navigate(ScreenName.SelectNetwork, { - provider, - filterCurrencyIds, - }); - return; - } - - const isToken = curr.type === "TokenCurrency"; - const currency = isToken ? curr.parentCurrency : curr; - const currencyAccounts = findAccountByCurrency(accounts, currency); - - if (currencyAccounts.length > 0) { - // If we found one or more accounts of the currency then we select account - navigation.navigate(NavigatorName.AddAccounts, { - screen: ScreenName.SelectAccounts, - params: { - currency, - }, - }); - } else { - // If we didn't find any account of the parent currency then we add one - navigation.navigate(NavigatorName.DeviceSelection, { - screen: ScreenName.SelectDevice, - params: { - currency, - createTokenAccount: isToken || undefined, - }, - }); - } - }, - [currenciesByProvider, accounts, navigation, filterCurrencyIds], - ); - - useEffect(() => { - if (paramsCurrency) { - const selectedCurrency = findCryptoCurrencyByKeyword(paramsCurrency.toUpperCase()); - - if (selectedCurrency) { - onPressItem(selectedCurrency); - } - } - }, [onPressItem, paramsCurrency]); - - const debounceTrackOnSearchChange = debounce((newQuery: string) => { - track("asset_searched", { page: "Choose a crypto to secure", asset: newQuery }); - }, 1500); + const { filterCurrencyIds, currency: paramsCurrency, context } = route?.params || {}; + const { + titleText, + titleTestId, + list, + onPressItem, + debounceTrackOnSearchChange, + providersLoadingStatus, + } = useSelectCryptoViewModel({ context, filterCurrencyIds, paramsCurrency }); const renderList = useCallback( (items: CryptoOrTokenCurrency[]) => ( @@ -129,35 +59,41 @@ export default function SelectCrypto({ [onPressItem], ); - const list = useMemo( - () => - filterCurrencyIdsSet - ? sortedCryptoCurrencies.filter(crypto => filterCurrencyIdsSet.has(crypto.id)) - : sortedCryptoCurrencies, - [filterCurrencyIdsSet, sortedCryptoCurrencies], - ); + const renderListView = useCallback(() => { + switch (providersLoadingStatus) { + case "success": + return list.length > 0 ? ( + + + + ) : ( + renderEmptyList() + ); + case "error": + // TODO: in an improvement feature, when the network fetch status is on error, implement a clean error message with a retry CTA + return renderEmptyList(); + default: + return ( + + + + ); + } + }, [providersLoadingStatus, list, renderList, debounceTrackOnSearchChange]); return ( - + - - {t("transfer.receive.selectCrypto.title")} + + {titleText} - {list.length > 0 ? ( - - - - ) : ( - - - - )} + {renderListView()} ); } diff --git a/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectCrypto/useSelectCryptoViewModel.ts b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectCrypto/useSelectCryptoViewModel.ts new file mode 100644 index 000000000000..f82bb464f7a6 --- /dev/null +++ b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectCrypto/useSelectCryptoViewModel.ts @@ -0,0 +1,137 @@ +import { useCallback, useEffect, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useSelector } from "react-redux"; +import debounce from "lodash/debounce"; +import { useNavigation } from "@react-navigation/core"; + +import { findCryptoCurrencyByKeyword } from "@ledgerhq/live-common/currencies/index"; + +import { NavigatorName, ScreenName } from "~/const"; +import { track } from "~/analytics"; +import { flattenAccountsSelector } from "~/reducers/accounts"; +import { findAccountByCurrency } from "~/logic/deposit"; + +import type { CryptoCurrency, TokenCurrency } from "@ledgerhq/types-cryptoassets"; +import { AssetSelectionNavigationProps } from "../../types"; +import { useGroupedCurrenciesByProvider } from "@ledgerhq/live-common/deposit/index"; +import { LoadingBasedGroupedCurrencies } from "@ledgerhq/live-common/deposit/type"; + +export default function useSelectCryptoViewModel({ + context, + filterCurrencyIds, + paramsCurrency, +}: { + context?: "addAccounts" | "receiveFunds"; + filterCurrencyIds: string[] | undefined; + paramsCurrency: string | undefined; +}) { + const { t } = useTranslation(); + const filterCurrencyIdsSet = useMemo( + () => (filterCurrencyIds ? new Set(filterCurrencyIds) : null), + [filterCurrencyIds], + ); + + const accounts = useSelector(flattenAccountsSelector); + const navigation = useNavigation(); + + const { result, loadingStatus: providersLoadingStatus } = useGroupedCurrenciesByProvider( + true, + ) as LoadingBasedGroupedCurrencies; + const { currenciesByProvider, sortedCryptoCurrencies } = result; + + const onPressItem = useCallback( + (curr: CryptoCurrency | TokenCurrency) => { + track("asset_clicked", { + asset: curr.name, + page: "Choose a crypto to secure", + }); + + const provider = currenciesByProvider.find(elem => + elem.currenciesByNetwork.some( + currencyByNetwork => (currencyByNetwork as CryptoCurrency | TokenCurrency).id === curr.id, + ), + ); + + // If the selected currency exists on multiple networks we redirect to the SelectNetwork screen + if (provider && provider?.currenciesByNetwork.length > 1) { + navigation.navigate(ScreenName.SelectNetwork, { + context, + currency: curr.id, + ...(context === "receiveFunds" && { filterCurrencyIds }), + }); + return; + } + + const isToken = curr.type === "TokenCurrency"; + const currency = isToken ? curr.parentCurrency : curr; + const currencyAccounts = findAccountByCurrency(accounts, currency); + + if (currencyAccounts.length > 0) { + // If we found one or more accounts of the currency then we select account + navigation.navigate(NavigatorName.AddAccounts, { + screen: ScreenName.SelectAccounts, + params: { + currency, + }, + }); + } else { + // If we didn't find any account of the parent currency then we add one + navigation.navigate(NavigatorName.DeviceSelection, { + screen: ScreenName.SelectDevice, + params: { + currency, + createTokenAccount: isToken || undefined, + context, + }, + }); + } + }, + [currenciesByProvider, accounts, navigation, filterCurrencyIds, context], + ); + + useEffect(() => { + if (paramsCurrency) { + const selectedCurrency = findCryptoCurrencyByKeyword(paramsCurrency.toUpperCase()); + if (selectedCurrency && providersLoadingStatus === "success") { + onPressItem(selectedCurrency); + } + } + }, [onPressItem, paramsCurrency, providersLoadingStatus]); + + const debounceTrackOnSearchChange = debounce((newQuery: string) => { + track("asset_searched", { page: "Choose a crypto to secure", asset: newQuery }); + }, 1500); + + const list = useMemo( + () => + filterCurrencyIdsSet + ? sortedCryptoCurrencies.filter(crypto => filterCurrencyIdsSet.has(crypto.id)) + : sortedCryptoCurrencies, + [filterCurrencyIdsSet, sortedCryptoCurrencies], + ); + const { titleText, titleTestId } = useMemo(() => { + switch (context) { + case "addAccounts": + return { + titleText: t("assetSelection.selectCrypto.title"), + titleTestId: "select-crypto-header-step1-title", + }; + case "receiveFunds": + return { + titleText: t("transfer.receive.selectCrypto.title"), + titleTestId: "receive-header-step1-title", + }; + default: + return {}; + } + }, [context, t]); + + return { + titleText, + titleTestId, + list, + debounceTrackOnSearchChange, + onPressItem, + providersLoadingStatus, + }; +} diff --git a/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectNetwork/index.tsx b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectNetwork/index.tsx index d4787a452db0..5d92f015da33 100644 --- a/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectNetwork/index.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectNetwork/index.tsx @@ -1,168 +1,39 @@ -import React, { useCallback, useMemo, useState } from "react"; +import React, { useCallback } from "react"; import { useTranslation } from "react-i18next"; -import { StyleSheet, FlatList, Linking } from "react-native"; -import type { CryptoCurrency, TokenCurrency } from "@ledgerhq/types-cryptoassets"; -import { findCryptoCurrencyById } from "@ledgerhq/live-common/currencies/index"; -import { useCurrenciesByMarketcap } from "@ledgerhq/live-common/currencies/hooks"; - -import { BannerCard, Flex, Text } from "@ledgerhq/native-ui"; -import { useDispatch, useSelector } from "react-redux"; -import { NavigatorName, ScreenName } from "~/const"; -import { track, TrackScreen } from "~/analytics"; -import { flattenAccountsSelector } from "~/reducers/accounts"; +import { StyleSheet, FlatList } from "react-native"; +import { Flex, InfiniteLoader, Text } from "@ledgerhq/native-ui"; +import { ScreenName } from "~/const"; +import { TrackScreen } from "~/analytics"; import { StackNavigatorProps } from "~/components/RootNavigator/types/helpers"; -import { ChartNetworkMedium } from "@ledgerhq/native-ui/assets/icons"; -import { useSafeAreaInsets } from "react-native-safe-area-context"; import * as Animatable from "react-native-animatable"; -import { setCloseNetworkBanner } from "~/actions/settings"; -import { hasClosedNetworkBannerSelector } from "~/reducers/settings"; import BigCurrencyRow from "~/components/BigCurrencyRow"; -import { findAccountByCurrency } from "~/logic/deposit"; -import { urls } from "~/utils/urls"; import { CryptoWithAccounts } from "./types"; import { AssetSelectionNavigatorParamsList } from "../../types"; +import useSelectNetworkViewModel from "./useSelectNetworkViewModel"; +import NetworkBanner from "../../components/NetworkBanner"; const keyExtractor = (elem: CryptoWithAccounts) => elem.crypto.id; const AnimatedView = Animatable.View; export default function SelectNetwork({ - navigation, route, }: StackNavigatorProps) { - const provider = route?.params?.provider; - const filterCurrencyIds = route?.params?.filterCurrencyIds; - - const networks = useMemo( - () => - provider?.currenciesByNetwork.map(elem => - elem.type === "TokenCurrency" ? elem.parentCurrency.id : elem.id, - ) || [], - [provider?.currenciesByNetwork], - ); - - const dispatch = useDispatch(); - - const hasClosedNetworkBanner = useSelector(hasClosedNetworkBannerSelector); - const [displayBanner, setBanner] = useState(!hasClosedNetworkBanner); - + const { filterCurrencyIds, context, currency } = route.params; const { t } = useTranslation(); - - const cryptoCurrencies = useMemo(() => { - if (!networks) { - return []; - } else { - const list = filterCurrencyIds - ? networks.filter(network => filterCurrencyIds.includes(network)) - : networks; - - return list.map(net => { - const selectedCurrency = findCryptoCurrencyById(net); - if (selectedCurrency) return selectedCurrency; - else return null; - }); - } - }, [filterCurrencyIds, networks]); - - const accounts = useSelector(flattenAccountsSelector); - - const sortedCryptoCurrencies = useCurrenciesByMarketcap( - cryptoCurrencies.filter(e => !!e) as CryptoCurrency[], - ); - - const sortedCryptoCurrenciesWithAccounts: CryptoWithAccounts[] = useMemo( - () => - sortedCryptoCurrencies - .map(crypto => { - const accs = findAccountByCurrency(accounts, crypto); - return { - crypto, - accounts: accs, - }; - }) - .sort((a, b) => b.accounts.length - a.accounts.length), - [accounts, sortedCryptoCurrencies], - ); - - const onPressItem = useCallback( - (currency: CryptoCurrency | TokenCurrency) => { - track("network_clicked", { - network: currency.name, - page: "Choose a network", - }); - - const cryptoToSend = provider?.currenciesByNetwork.find(curByNetwork => - curByNetwork.type === "TokenCurrency" - ? curByNetwork.parentCurrency.id === currency.id - : curByNetwork.id === currency.id, - ); - - if (!cryptoToSend) return; - - const accs = findAccountByCurrency(accounts, cryptoToSend); - - if (accs.length > 0) { - // if we found one or more accounts of the given currency we go to select account - navigation.navigate(NavigatorName.AddAccounts, { - screen: ScreenName.SelectAccounts, - params: { - currency: cryptoToSend, - }, - }); - } else if (cryptoToSend.type === "TokenCurrency") { - // cases for token currencies - const parentAccounts = findAccountByCurrency(accounts, cryptoToSend.parentCurrency); - - if (parentAccounts.length > 0) { - // if we found one or more accounts of the parent currency we select account - - navigation.navigate(NavigatorName.AddAccounts, { - screen: ScreenName.SelectAccounts, - params: { - currency: cryptoToSend, - createTokenAccount: true, - }, - }); - } else { - // if we didn't find any account of the parent currency we add and create one - navigation.navigate(NavigatorName.DeviceSelection, { - screen: ScreenName.SelectDevice, - params: { - currency: cryptoToSend.parentCurrency, - createTokenAccount: true, - }, - }); - } - } else { - // else we create a currency account - navigation.navigate(NavigatorName.DeviceSelection, { - screen: ScreenName.SelectDevice, - params: { - currency: cryptoToSend, - }, - }); - } - }, - [accounts, navigation, provider], - ); - - const hideBanner = useCallback(() => { - track("button_clicked", { - button: "Close network article", - page: "Choose a network", - }); - dispatch(setCloseNetworkBanner(true)); - setBanner(false); - }, [dispatch]); - - const clickLearn = () => { - track("button_clicked", { - button: "Choose a network article", - type: "card", - page: "Choose a network", - }); - Linking.openURL(urls.chooseNetwork); - }; + const { + hideBanner, + clickLearn, + sortedCryptoCurrenciesWithAccounts, + onPressItem, + displayBanner, + titleText, + subtitleText, + titleTestId, + subTitleTestId, + listTestId, + providersLoadingStatus, + } = useSelectNetworkViewModel({ filterCurrencyIds, context, currency }); const renderItem = useCallback( ({ item }: { item: CryptoWithAccounts }) => ( @@ -182,29 +53,37 @@ export default function SelectNetwork({ return ( <> - - - {t("transfer.receive.selectNetwork.title")} - - - {t("transfer.receive.selectNetwork.subtitle")} + + + {titleText} + {subtitleText && ( + + {subtitleText} + + )} - + {["success", "error"].includes(providersLoadingStatus) ? ( + + ) : ( + + + + )} {displayBanner ? ( @@ -219,27 +98,6 @@ export default function SelectNetwork({ ); } -type BannerProps = { - hideBanner: () => void; - onPress: () => void; -}; - -const NetworkBanner = ({ onPress, hideBanner }: BannerProps) => { - const { t } = useTranslation(); - const insets = useSafeAreaInsets(); - return ( - - } - onPressDismiss={hideBanner} - onPress={onPress} - /> - - ); -}; - const styles = StyleSheet.create({ list: { paddingBottom: 32, diff --git a/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectNetwork/useSelectNetworkViewModel.ts b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectNetwork/useSelectNetworkViewModel.ts new file mode 100644 index 000000000000..d35fd90da84f --- /dev/null +++ b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectNetwork/useSelectNetworkViewModel.ts @@ -0,0 +1,217 @@ +import { useCallback, useMemo, useState } from "react"; +import { useDispatch, useSelector } from "react-redux"; +import { useNavigation } from "@react-navigation/core"; +import { useTranslation } from "react-i18next"; +import { Linking } from "react-native"; +import type { CryptoCurrency, TokenCurrency } from "@ledgerhq/types-cryptoassets"; +import { findCryptoCurrencyById } from "@ledgerhq/live-common/currencies/index"; +import { useCurrenciesByMarketcap } from "@ledgerhq/live-common/currencies/hooks"; +import { hasClosedNetworkBannerSelector } from "~/reducers/settings"; +import { flattenAccountsSelector } from "~/reducers/accounts"; +import { setCloseNetworkBanner } from "~/actions/settings"; +import { findAccountByCurrency } from "~/logic/deposit"; +import { track } from "~/analytics"; +import { NavigatorName, ScreenName } from "~/const"; +import { urls } from "~/utils/urls"; +import { AssetSelectionNavigationProps, SelectNetworkRouteParams } from "../../types"; +import { CryptoWithAccounts } from "./types"; +import { LoadingBasedGroupedCurrencies } from "@ledgerhq/live-common/deposit/type"; +import { useGroupedCurrenciesByProvider } from "@ledgerhq/live-common/deposit/index"; + +export default function useSelectNetworkViewModel({ + filterCurrencyIds, + context, + currency, +}: SelectNetworkRouteParams) { + const navigation = useNavigation(); + + const { result, loadingStatus: providersLoadingStatus } = useGroupedCurrenciesByProvider( + true, + ) as LoadingBasedGroupedCurrencies; + + const { currenciesByProvider } = result; + + const provider = useMemo( + () => + currenciesByProvider.find(elem => + elem.currenciesByNetwork.some( + currencyByNetwork => + (currencyByNetwork as CryptoCurrency | TokenCurrency).id === currency, + ), + ), + [currenciesByProvider, currency], + ); + + const networks = useMemo( + () => + provider?.currenciesByNetwork.map(elem => + elem.type === "TokenCurrency" ? elem?.parentCurrency?.id : elem.id, + ) || [], + [provider?.currenciesByNetwork], + ); + + const dispatch = useDispatch(); + + const hasClosedNetworkBanner = useSelector(hasClosedNetworkBannerSelector); + const [displayBanner, setBanner] = useState(!hasClosedNetworkBanner); + + const { t } = useTranslation(); + + const cryptoCurrencies = useMemo(() => { + if (!networks) { + return []; + } else { + const list = filterCurrencyIds + ? networks.filter(network => filterCurrencyIds.includes(network)) + : networks; + + return list.map(net => { + const selectedCurrency = findCryptoCurrencyById(net); + if (selectedCurrency) return selectedCurrency; + else return null; + }); + } + }, [filterCurrencyIds, networks]); + + const accounts = useSelector(flattenAccountsSelector); + + const sortedCryptoCurrencies = useCurrenciesByMarketcap( + cryptoCurrencies.filter(e => !!e) as CryptoCurrency[], + ); + + const sortedCryptoCurrenciesWithAccounts: CryptoWithAccounts[] = useMemo( + () => + sortedCryptoCurrencies + .map(crypto => { + const accs = findAccountByCurrency(accounts, crypto); + return { + crypto, + accounts: accs, + }; + }) + .sort((a, b) => b.accounts.length - a.accounts.length), + [accounts, sortedCryptoCurrencies], + ); + + const processNetworkSelection = useCallback( + (selectedCurrency: CryptoCurrency | TokenCurrency) => { + track("network_clicked", { + network: selectedCurrency.name, + page: "Choose a network", + }); + + const cryptoToSend = provider?.currenciesByNetwork.find(curByNetwork => + curByNetwork.type === "TokenCurrency" + ? curByNetwork.parentCurrency.id === selectedCurrency.id + : curByNetwork.id === selectedCurrency.id, + ); + + if (!cryptoToSend) return; + + const accs = findAccountByCurrency(accounts, cryptoToSend); + + if (accs.length > 0) { + // if we found one or more accounts of the given currency we go to select account + navigation.navigate(NavigatorName.AddAccounts, { + screen: ScreenName.SelectAccounts, + params: { + currency: cryptoToSend, + context, + }, + }); + } else if (cryptoToSend.type === "TokenCurrency") { + // cases for token currencies + const parentAccounts = findAccountByCurrency(accounts, cryptoToSend.parentCurrency); + + if (parentAccounts.length > 0) { + // if we found one or more accounts of the parent currency we select account + + navigation.navigate(NavigatorName.AddAccounts, { + screen: ScreenName.SelectAccounts, + params: { + currency: cryptoToSend, + createTokenAccount: true, + context, + }, + }); + } else { + // if we didn't find any account of the parent currency we add and create one + navigation.navigate(NavigatorName.DeviceSelection, { + screen: ScreenName.SelectDevice, + params: { + currency: cryptoToSend.parentCurrency, + createTokenAccount: true, + context, + }, + }); + } + } else { + // else we create a currency account + navigation.navigate(NavigatorName.DeviceSelection, { + screen: ScreenName.SelectDevice, + params: { + currency: cryptoToSend, + context, + }, + }); + } + }, + [accounts, navigation, provider, context], + ); + + const hideBanner = useCallback(() => { + track("button_clicked", { + button: "Close network article", + page: "Choose a network", + }); + dispatch(setCloseNetworkBanner(true)); + setBanner(false); + }, [dispatch]); + + const clickLearn = () => { + track("button_clicked", { + button: "Choose a network article", + type: "card", + page: "Choose a network", + }); + Linking.openURL(urls.chooseNetwork); + }; + + const { titleText, subtitleText, titleTestId, subTitleTestId, listTestId } = useMemo((): Record< + string, + string + > => { + switch (context) { + case "receiveFunds": + return { + titleText: t("selectNetwork.swap.title"), + titleTestId: "receive-header-step2-title", + subtitleText: t("selectNetwork.swap.subtitle"), + subTitleTestId: "transfer.receive.selectNetwork.subtitle", + listTestId: "receive-header-step2-networks", + }; + case "addAccounts": + return { + titleText: t("assetSelection.selectNetwork.title"), + titleTestId: "addAccounts-header-step2-title", + listTestId: "addAccounts-header-step2-networks", + }; + default: + return {}; + } + }, [context, t]); + + return { + hideBanner, + clickLearn, + sortedCryptoCurrenciesWithAccounts, + onPressItem: processNetworkSelection, + displayBanner, + titleText, + subtitleText, + titleTestId, + subTitleTestId, + listTestId, + providersLoadingStatus, + }; +} diff --git a/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/TokenCurrencyDisclamer/index.tsx b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/TokenCurrencyDisclamer/index.tsx new file mode 100644 index 000000000000..5819d8fc8586 --- /dev/null +++ b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/TokenCurrencyDisclamer/index.tsx @@ -0,0 +1,172 @@ +import React, { useCallback } from "react"; +import { Trans, useTranslation } from "react-i18next"; +import { StyleSheet, View, SafeAreaView } from "react-native"; +import { useSelector } from "react-redux"; +import { findTokenAccountByCurrency } from "@ledgerhq/live-common/account/index"; +import { CompositeScreenProps, useTheme } from "@react-navigation/native"; +import { accountsSelector } from "~/reducers/accounts"; +import CurrencyIcon from "~/components/CurrencyIcon"; +import Button from "~/components/Button"; +import Alert from "~/components/Alert"; +import LText from "~/components/LText"; +import { urls } from "~/utils/urls"; +import { ScreenName, NavigatorName } from "~/const"; +import { TrackScreen } from "~/analytics"; +import { + StackNavigatorNavigation, + StackNavigatorProps, +} from "~/components/RootNavigator/types/helpers"; +import { BaseNavigatorStackParamList } from "~/components/RootNavigator/types/BaseNavigator"; +import { AssetSelectionNavigatorParamsList } from "../../types"; + +type Props = CompositeScreenProps< + StackNavigatorProps< + AssetSelectionNavigatorParamsList, + ScreenName.AddAccountsTokenCurrencyDisclaimer + >, + StackNavigatorProps +>; + +export default function TokenCurrencyDisclaimer({ navigation, route }: Props) { + const { colors } = useTheme(); + const { t } = useTranslation(); + const accounts = useSelector(accountsSelector); + const token = route.params.token; + const tokenName = `${token.name} (${token.ticker})`; + const parentCurrency = token.parentCurrency; + const accountData = findTokenAccountByCurrency(token, accounts); + const parentTokenAccount = accountData ? accountData.parentAccount : null; + const onClose = useCallback(() => { + navigation.getParent>().pop(); + }, [navigation]); + // specific cta in case of token accounts + const onTokenCta = useCallback(() => { + if (parentTokenAccount && parentTokenAccount.type === "Account") { + onClose(); + navigation.navigate(NavigatorName.ReceiveFunds, { + screen: ScreenName.ReceiveConfirmation, + params: { + // prefilter with token curency + accountId: parentTokenAccount.id, + currency: token, + createTokenAccount: true, + }, + }); + } else { + // set parentCurrency in already opened add account flow and continue + navigation.navigate(NavigatorName.DeviceSelection, { + screen: ScreenName.SelectDevice, + params: { + ...route.params, + currency: parentCurrency, + }, + }); + } + }, [parentTokenAccount, onClose, navigation, token, route.params, parentCurrency]); + return ( + + + + + + + + + {tokenName} + + + + + + + + +