diff --git a/.changeset/calm-tokens-reset.md b/.changeset/calm-tokens-reset.md new file mode 100644 index 000000000000..6e67da8f48e2 --- /dev/null +++ b/.changeset/calm-tokens-reset.md @@ -0,0 +1,5 @@ +--- +"live-mobile": minor +--- + +Reset mobile navigation state for sequential token deeplinks and redirect invalid asset deeplinks to Market diff --git a/apps/ledger-live-mobile/src/mvvm/features/AssetDetail/__integrations__/assetDetail.integration.test.tsx b/apps/ledger-live-mobile/src/mvvm/features/AssetDetail/__integrations__/assetDetail.integration.test.tsx index 06452c366743..88979c0db304 100644 --- a/apps/ledger-live-mobile/src/mvvm/features/AssetDetail/__integrations__/assetDetail.integration.test.tsx +++ b/apps/ledger-live-mobile/src/mvvm/features/AssetDetail/__integrations__/assetDetail.integration.test.tsx @@ -1,11 +1,25 @@ import React from "react"; +import type { CurrencyResult } from "@ledgerhq/cryptoassets/hooks"; import { createNativeStackNavigator } from "@react-navigation/native-stack"; -import { render, screen, waitFor, within, withFlagOverrides } from "@tests/test-renderer"; +import { + render, + renderWithReactQuery, + screen, + waitFor, + within, + withFlagOverrides, +} from "@tests/test-renderer"; import { genAccount } from "@ledgerhq/ledger-wallet-framework/mocks/account"; -import { getCryptoCurrencyById } from "@ledgerhq/live-common/currencies/index"; +import { + findCryptoCurrencyById, + getCryptoCurrencyById, +} from "@ledgerhq/live-common/currencies/index"; +import { createFixtureTokenAccount } from "@ledgerhq/live-common/mock/fixtures/cryptoCurrencies"; import type { Account } from "@ledgerhq/types-live"; -import { NavigatorName, ScreenName } from "~/const"; +import type { BaseNavigatorStackParamList } from "~/components/RootNavigator/types/BaseNavigator"; +import { BASE_NAVIGATOR_ID, NavigatorName, ScreenName } from "~/const"; import type { State } from "~/reducers/types"; +import MarketList from "LLM/features/Market/screens/MarketList"; import AssetDetailNavigator from "../Navigator"; import { ASSET_DETAIL_TEST_IDS } from "../testIds"; import { QUICK_ACTIONS_TEST_IDS } from "LLM/features/QuickActions/testIds"; @@ -17,6 +31,7 @@ import { const mockIsCurrencyAvailable = jest.fn().mockReturnValue(false); const mockIsAcceptedCurrency = jest.fn().mockReturnValue(false); +const mockUseCurrencyById = jest.fn(); jest.mock("@ledgerhq/live-common/platform/providers/RampCatalogProvider/useRampCatalog", () => ({ useRampCatalog: () => ({ isCurrencyAvailable: mockIsCurrencyAvailable }), @@ -26,6 +41,15 @@ jest.mock("@ledgerhq/live-common/modularDrawer/hooks/useAcceptedCurrency", () => useAcceptedCurrency: () => mockIsAcceptedCurrency, })); +jest.mock("@ledgerhq/cryptoassets/hooks", () => ({ + useCurrencyById: (id: string) => mockUseCurrencyById(id), +})); + +jest.mock("@features/platform-currencies", () => ({ + ...jest.requireActual("@features/platform-currencies"), + useCurrencyById: (id: string) => mockUseCurrencyById(id), +})); + jest.mock("@ledgerhq/asset-detail", () => ({ ...jest.requireActual("@ledgerhq/asset-detail"), useTradeAvailability: jest.fn(), @@ -54,7 +78,7 @@ jest.mock("@ledgerhq/live-common/bridge/useAccountBridge", () => ({ ), })); -const Stack = createNativeStackNavigator(); +const Stack = createNativeStackNavigator(); type NavigatorParams = { currencyId: string; @@ -68,7 +92,7 @@ function AssetDetailTestNavigator({ params?: NavigatorParams; } = {}) { return ( - + + ); } @@ -125,6 +150,12 @@ function withBlacklistedTokens(tokenIds: string[]) { describe("AssetDetail screen layout", () => { beforeEach(() => { + mockUseCurrencyById.mockReset(); + mockUseCurrencyById.mockImplementation((id: string) => ({ + currency: findCryptoCurrencyById(id), + loading: false, + error: undefined, + })); mockIsCurrencyAvailable.mockReturnValue(false); mockIsAcceptedCurrency.mockReturnValue(false); setAvailability(); @@ -487,5 +518,117 @@ describe("AssetDetail screen layout", () => { }); expect(screen.getByTestId(ASSET_DETAIL_TEST_IDS.balanceGraph)).toBeVisible(); }); + + it.each(["deeplink_asset", "deeplink_market"])( + "redirects a missing token %s deeplink to Market", + async source => { + const tokenId = "ethereum/erc20/does_not_exist"; + mockUseCurrencyById.mockReturnValue({ + currency: undefined, + loading: false, + error: undefined, + }); + + renderWithReactQuery( + , + ); + + await waitFor(() => expect(screen.getByTestId("market-list")).toBeVisible()); + expect(screen.queryByTestId(ASSET_DETAIL_TEST_IDS.screen)).toBeNull(); + }, + ); + + it("keeps a valid token deeplink on Asset Detail", () => { + const token = createFixtureTokenAccount().token; + mockUseCurrencyById.mockReturnValue({ + currency: token, + loading: false, + error: undefined, + }); + + render( + , + ); + + expect(screen.getByTestId(ASSET_DETAIL_TEST_IDS.screen)).toBeVisible(); + expect(screen.queryByTestId("market-list")).toBeNull(); + }); + + it("keeps Asset Detail visible while the token lookup is loading", () => { + const tokenId = "ethereum/erc20/loading"; + mockUseCurrencyById.mockReturnValue({ + currency: undefined, + loading: true, + error: undefined, + }); + + render( + , + ); + + expect(screen.getByTestId(ASSET_DETAIL_TEST_IDS.screen)).toBeVisible(); + expect(screen.queryByTestId("market-list")).toBeNull(); + }); + + it("keeps the Asset Detail screen on token lookup errors", () => { + const tokenId = "ethereum/erc20/unavailable"; + mockUseCurrencyById.mockReturnValue({ + currency: undefined, + loading: false, + error: new Error("CAL unavailable"), + }); + + render( + , + ); + + expect(screen.getByTestId(ASSET_DETAIL_TEST_IDS.screen)).toBeVisible(); + expect(screen.queryByTestId("market-list")).toBeNull(); + }); + + it("keeps market-only assets on Asset Detail outside deeplinks", () => { + mockUseCurrencyById.mockReturnValue({ + currency: undefined, + loading: false, + error: undefined, + }); + + render( + , + ); + + expect(screen.getByTestId(ASSET_DETAIL_TEST_IDS.screen)).toBeVisible(); + expect(screen.queryByTestId("market-list")).toBeNull(); + }); }); }); diff --git a/apps/ledger-live-mobile/src/mvvm/features/AssetDetail/screens/AssetDetail/index.tsx b/apps/ledger-live-mobile/src/mvvm/features/AssetDetail/screens/AssetDetail/index.tsx index f7c10284819d..b82d999ebaec 100644 --- a/apps/ledger-live-mobile/src/mvvm/features/AssetDetail/screens/AssetDetail/index.tsx +++ b/apps/ledger-live-mobile/src/mvvm/features/AssetDetail/screens/AssetDetail/index.tsx @@ -18,7 +18,7 @@ type NavigationProps = NativeStackNavigationProp< export default function AssetDetail() { const viewModel = useAssetDetailViewModel(); - const { currency, coinOptions } = viewModel; + const { currency, coinOptions, shouldRedirectToMarket } = viewModel; const navigation = useNavigation(); useLayoutEffect(() => { @@ -57,5 +57,7 @@ export default function AssetDetail() { navigation.setOptions(opts); }, [navigation, currency, coinOptions.openCoinOptions, coinOptions.trailingAccessibilityLabel]); + if (shouldRedirectToMarket) return null; + return ; } diff --git a/apps/ledger-live-mobile/src/mvvm/features/AssetDetail/screens/AssetDetail/useAssetDetailViewModel.ts b/apps/ledger-live-mobile/src/mvvm/features/AssetDetail/screens/AssetDetail/useAssetDetailViewModel.ts index a4affa15a635..977bbc773eb5 100644 --- a/apps/ledger-live-mobile/src/mvvm/features/AssetDetail/screens/AssetDetail/useAssetDetailViewModel.ts +++ b/apps/ledger-live-mobile/src/mvvm/features/AssetDetail/screens/AssetDetail/useAssetDetailViewModel.ts @@ -1,10 +1,13 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useCurrencyById } from "@ledgerhq/cryptoassets/hooks"; import useEnv from "@ledgerhq/live-common/hooks/useEnv"; import { useFeature } from "@features/platform-feature-flags"; -import { useRoute } from "@react-navigation/native"; +import { useNavigation, useRoute } from "@react-navigation/native"; +import type { CompositeNavigationProp } from "@react-navigation/native"; +import type { NativeStackNavigationProp } from "@react-navigation/native-stack"; +import type { BaseNavigatorStackParamList } from "~/components/RootNavigator/types/BaseNavigator"; import type { StackNavigatorProps } from "~/components/RootNavigator/types/helpers"; -import { ScreenName } from "~/const"; +import { BASE_NAVIGATOR_ID, ScreenName } from "~/const"; import { useDistribution } from "~/actions/general"; import { resolveAssetMarketInputs, @@ -19,7 +22,21 @@ import { isRobinhoodExclusiveAsset } from "./utils/isRobinhoodExclusiveAsset"; type Route = StackNavigatorProps["route"]; +type BaseNavigatorNavigation = NativeStackNavigationProp< + BaseNavigatorStackParamList, + keyof BaseNavigatorStackParamList, + typeof BASE_NAVIGATOR_ID +>; + +type Navigation = CompositeNavigationProp< + NativeStackNavigationProp, + BaseNavigatorNavigation +>; + +const ASSET_DETAIL_DEEPLINK_SOURCES = new Set(["deeplink_asset", "deeplink_market"]); + export function useAssetDetailViewModel() { + const navigation = useNavigation(); const route = useRoute(); const { currencyId, source, marketState } = route.params; @@ -35,8 +52,30 @@ export function useAssetDetailViewModel() { ); const ledgerIdFallback = marketState?.ledgerIds?.[0] ?? currencyId; - const { currency: ledgerCurrencyById } = useCurrencyById(ledgerIdFallback); + const { + currency: ledgerCurrencyById, + loading: isCurrencyLoading, + error: currencyError, + } = useCurrencyById(ledgerIdFallback); const currency = distributionItem?.currency ?? ledgerCurrencyById; + const shouldRedirectToMarket = + ASSET_DETAIL_DEEPLINK_SOURCES.has(source ?? "") && + !distribution.isLoading && + !isCurrencyLoading && + !currencyError && + !currency; + + useEffect(() => { + if (!shouldRedirectToMarket) return; + + const baseNavigation = navigation.getParent(BASE_NAVIGATOR_ID); + if (baseNavigation) { + baseNavigation.replace(ScreenName.MarketList); + return; + } + + navigation.navigate(ScreenName.MarketList); + }, [navigation, shouldRedirectToMarket]); const { marketApiId, knownLedgerIds, knownMarketId } = useMemo( () => @@ -107,5 +146,6 @@ export function useAssetDetailViewModel() { coinOptions, isLoading, ledgerIds: receiveLedgerIds, + shouldRedirectToMarket, }; } diff --git a/apps/ledger-live-mobile/src/mvvm/features/AssetDetail/utils/__tests__/getActionFromAssetDetailDeeplinkState.test.ts b/apps/ledger-live-mobile/src/mvvm/features/AssetDetail/utils/__tests__/getActionFromAssetDetailDeeplinkState.test.ts new file mode 100644 index 000000000000..630410d88eba --- /dev/null +++ b/apps/ledger-live-mobile/src/mvvm/features/AssetDetail/utils/__tests__/getActionFromAssetDetailDeeplinkState.test.ts @@ -0,0 +1,65 @@ +import { getStateFromPath } from "@react-navigation/native"; +import { ScreenName } from "~/const"; +import { handleAssetDetailDeeplink } from "~/navigation/deeplinks/handleAssetDetailDeeplink"; +import { getActionFromAssetDetailDeeplinkState } from "../getActionFromAssetDetailDeeplinkState"; + +function createAssetDetailState(params: Parameters[0]) { + const state = handleAssetDetailDeeplink(params); + + if (!state) { + throw new Error("Expected an Asset Detail navigation state"); + } + + return state; +} + +describe("getActionFromAssetDetailDeeplinkState", () => { + it("should reset navigation for each sequential token Asset Detail deeplink", () => { + const firstState = createAssetDetailState({ + currencyId: "ethereum/erc20/usd_tether__erc20_", + source: "deeplink_asset", + marketState: { + id: "ethereum/erc20/usd_tether__erc20_", + ledgerIds: ["ethereum/erc20/usd_tether__erc20_"], + }, + }); + const secondState = createAssetDetailState({ + currencyId: "ethereum/erc20/usd__coin", + source: "deeplink_asset", + marketState: { + id: "ethereum/erc20/usd__coin", + ledgerIds: ["ethereum/erc20/usd__coin"], + }, + }); + + expect(getActionFromAssetDetailDeeplinkState(firstState)).toEqual({ + type: "RESET", + payload: firstState, + }); + expect(getActionFromAssetDetailDeeplinkState(secondState)).toEqual({ + type: "RESET", + payload: secondState, + }); + }); + + it("should keep the default navigation action for coin Asset Detail deeplinks", () => { + const state = createAssetDetailState({ + currencyId: "bitcoin", + source: "deeplink_asset", + }); + + expect(getActionFromAssetDetailDeeplinkState(state)?.type).toBe("NAVIGATE"); + }); + + it("should keep the default navigation action for Stake deeplinks", () => { + const state = getStateFromPath("earn?action=stake", { + screens: { [ScreenName.Earn]: "earn" }, + }); + + if (!state) { + throw new Error("Expected a Stake navigation state"); + } + + expect(getActionFromAssetDetailDeeplinkState(state)?.type).toBe("NAVIGATE"); + }); +}); diff --git a/apps/ledger-live-mobile/src/mvvm/features/AssetDetail/utils/getActionFromAssetDetailDeeplinkState.ts b/apps/ledger-live-mobile/src/mvvm/features/AssetDetail/utils/getActionFromAssetDetailDeeplinkState.ts new file mode 100644 index 000000000000..ef59494d408e --- /dev/null +++ b/apps/ledger-live-mobile/src/mvvm/features/AssetDetail/utils/getActionFromAssetDetailDeeplinkState.ts @@ -0,0 +1,46 @@ +import { + CommonActions, + getActionFromState, + type NavigationState, + type PartialState, +} from "@react-navigation/native"; +import { ScreenName } from "~/const"; + +type DeeplinkState = NavigationState | PartialState; + +function hasMarketStateParam(params: unknown): params is { marketState: unknown } { + if (typeof params !== "object" || params === null) { + return false; + } + + return (params as { marketState?: unknown }).marketState !== undefined; +} + +function isAssetDetailTokenState(state: DeeplinkState): boolean { + const route = state.routes[state.index ?? state.routes.length - 1]; + + if (!route) { + return false; + } + + if (route.state && isAssetDetailTokenState(route.state)) { + return true; + } + + return ( + route.name === ScreenName.AssetDetail && + route.params !== undefined && + hasMarketStateParam(route.params) + ); +} + +export function getActionFromAssetDetailDeeplinkState( + state: DeeplinkState, + options?: Parameters[1], +) { + if (isAssetDetailTokenState(state)) { + return CommonActions.reset(state as PartialState); + } + + return getActionFromState(state as PartialState, options); +} diff --git a/apps/ledger-live-mobile/src/navigation/DeeplinksProvider.tsx b/apps/ledger-live-mobile/src/navigation/DeeplinksProvider.tsx index b9aea67c1c38..dd8dcfcc5a37 100644 --- a/apps/ledger-live-mobile/src/navigation/DeeplinksProvider.tsx +++ b/apps/ledger-live-mobile/src/navigation/DeeplinksProvider.tsx @@ -54,6 +54,7 @@ import { handleProductTourDeeplink } from "./deeplinks/handleProductTourDeeplink import { handleBackupHubDeeplink } from "./deeplinks/handleBackupHubDeeplink"; import { SplashScreenHandle } from "LLM/features/LaunchScreen/SplashScreenHandle"; import { useDeeplinkDrawerCleanup } from "./deeplinks/useDeeplinkDrawerCleanup"; +import { getActionFromAssetDetailDeeplinkState } from "LLM/features/AssetDetail/utils/getActionFromAssetDetailDeeplinkState"; const themes: { [key: string]: Theme; @@ -609,6 +610,7 @@ export const DeeplinksProvider = ({ sub.remove(); }; }, + getActionFromState: getActionFromAssetDetailDeeplinkState, getStateFromPath: (path, config) => { const url = new URL(`ledgerwallet://${path}`); const { hostname, searchParams, pathname } = url; diff --git a/apps/ledger-live-mobile/src/navigation/deeplinks/__tests__/resolveMarketOrAssetDeeplinkIntent.test.ts b/apps/ledger-live-mobile/src/navigation/deeplinks/__tests__/resolveMarketOrAssetDeeplinkIntent.test.ts index ccb4452bd181..79c7a71448d4 100644 --- a/apps/ledger-live-mobile/src/navigation/deeplinks/__tests__/resolveMarketOrAssetDeeplinkIntent.test.ts +++ b/apps/ledger-live-mobile/src/navigation/deeplinks/__tests__/resolveMarketOrAssetDeeplinkIntent.test.ts @@ -49,10 +49,10 @@ describe("resolveMarketOrAssetDeeplinkIntent", () => { ).toEqual({ type: "market-banner" }); }); - it("falls back to portfolio for a token on legacy asset", () => { + it("falls back to the market banner for a token on legacy asset", () => { expect( resolveMarketOrAssetDeeplinkIntent({ ...off, hostname: "asset", pathname: tokenPath }), - ).toEqual({ type: "portfolio" }); + ).toEqual({ type: "market-banner" }); }); }); @@ -90,10 +90,10 @@ describe("resolveMarketOrAssetDeeplinkIntent", () => { ).toEqual({ type: "market-banner", category: undefined }); }); - it("routes a non-empty asset path to portfolio", () => { + it("routes an unknown crypto asset id to the market banner", () => { expect( resolveMarketOrAssetDeeplinkIntent({ ...base, hostname: "asset", pathname: "/not-a-coin" }), - ).toEqual({ type: "portfolio" }); + ).toEqual({ type: "market-banner" }); }); it("routes an empty asset path to portfolio when Wallet 4.0 is on", () => { diff --git a/apps/ledger-live-mobile/src/navigation/deeplinks/resolveMarketOrAssetDeeplinkIntent.ts b/apps/ledger-live-mobile/src/navigation/deeplinks/resolveMarketOrAssetDeeplinkIntent.ts index d8b9e79bfd31..88986d45f0fe 100644 --- a/apps/ledger-live-mobile/src/navigation/deeplinks/resolveMarketOrAssetDeeplinkIntent.ts +++ b/apps/ledger-live-mobile/src/navigation/deeplinks/resolveMarketOrAssetDeeplinkIntent.ts @@ -59,8 +59,7 @@ export function resolveMarketOrAssetDeeplinkIntent({ return { type: "legacy-path", currencyId: action.currencyId }; } - // Token id on legacy screens is unsupported. - return hostname === "market" ? { type: "market-banner" } : { type: "portfolio" }; + return { type: "market-banner" }; } const hasPath = pathname.trim().split("/").some(Boolean); @@ -74,7 +73,11 @@ export function resolveMarketOrAssetDeeplinkIntent({ return { type: "market-banner", category }; } - if (hasPath || shouldDisplayAggregatedAssets) { + if (hasPath) { + return { type: "market-banner" }; + } + + if (shouldDisplayAggregatedAssets) { return { type: "portfolio" }; }