Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/calm-tokens-reset.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"live-mobile": minor
---

Reset mobile navigation state for sequential token deeplinks and redirect invalid asset deeplinks to Market
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -17,6 +31,7 @@ import {

const mockIsCurrencyAvailable = jest.fn().mockReturnValue(false);
const mockIsAcceptedCurrency = jest.fn().mockReturnValue(false);
const mockUseCurrencyById = jest.fn<CurrencyResult, [string]>();

jest.mock("@ledgerhq/live-common/platform/providers/RampCatalogProvider/useRampCatalog", () => ({
useRampCatalog: () => ({ isCurrencyAvailable: mockIsCurrencyAvailable }),
Expand All @@ -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(),
Expand Down Expand Up @@ -54,7 +78,7 @@ jest.mock("@ledgerhq/live-common/bridge/useAccountBridge", () => ({
),
}));

const Stack = createNativeStackNavigator();
const Stack = createNativeStackNavigator<BaseNavigatorStackParamList, typeof BASE_NAVIGATOR_ID>();

type NavigatorParams = {
currencyId: string;
Expand All @@ -68,7 +92,7 @@ function AssetDetailTestNavigator({
params?: NavigatorParams;
} = {}) {
return (
<Stack.Navigator>
<Stack.Navigator id={BASE_NAVIGATOR_ID}>
<Stack.Screen
name={NavigatorName.AssetDetail}
component={AssetDetailNavigator}
Expand All @@ -78,6 +102,7 @@ function AssetDetailTestNavigator({
}}
options={{ headerShown: false }}
/>
<Stack.Screen name={ScreenName.MarketList} component={MarketList} />
</Stack.Navigator>
);
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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(
<AssetDetailTestNavigator
params={{
currencyId: tokenId,
source,
marketState: { id: tokenId, ledgerIds: [tokenId] },
}}
/>,
);

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(
<AssetDetailTestNavigator
params={{
currencyId: token.id,
source: "deeplink_asset",
marketState: { id: token.id, ledgerIds: [token.id] },
}}
/>,
);

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(
<AssetDetailTestNavigator
params={{
currencyId: tokenId,
source: "deeplink_asset",
marketState: { id: tokenId, ledgerIds: [tokenId] },
}}
/>,
);

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(
<AssetDetailTestNavigator
params={{
currencyId: tokenId,
source: "deeplink_market",
marketState: { id: tokenId, ledgerIds: [tokenId] },
}}
/>,
);

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(
<AssetDetailTestNavigator
params={{
currencyId: "market-only-asset",
source: "market_banner",
marketState: { id: "market-only-asset" },
}}
/>,
);

expect(screen.getByTestId(ASSET_DETAIL_TEST_IDS.screen)).toBeVisible();
expect(screen.queryByTestId("market-list")).toBeNull();
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -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<NavigationProps>();

useLayoutEffect(() => {
Expand Down Expand Up @@ -57,5 +57,7 @@ export default function AssetDetail() {
navigation.setOptions(opts);
}, [navigation, currency, coinOptions.openCoinOptions, coinOptions.trailingAccessibilityLabel]);

if (shouldRedirectToMarket) return null;

return <AssetDetailView {...viewModel} />;
}
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -19,7 +22,21 @@ import { isRobinhoodExclusiveAsset } from "./utils/isRobinhoodExclusiveAsset";

type Route = StackNavigatorProps<AssetDetailNavigatorParamsList, ScreenName.AssetDetail>["route"];

type BaseNavigatorNavigation = NativeStackNavigationProp<
BaseNavigatorStackParamList,
keyof BaseNavigatorStackParamList,
typeof BASE_NAVIGATOR_ID
>;

type Navigation = CompositeNavigationProp<
NativeStackNavigationProp<AssetDetailNavigatorParamsList, ScreenName.AssetDetail>,
BaseNavigatorNavigation
>;

const ASSET_DETAIL_DEEPLINK_SOURCES = new Set(["deeplink_asset", "deeplink_market"]);

export function useAssetDetailViewModel() {
const navigation = useNavigation<Navigation>();
const route = useRoute<Route>();
const { currencyId, source, marketState } = route.params;

Expand All @@ -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<BaseNavigatorNavigation>(BASE_NAVIGATOR_ID);
if (baseNavigation) {
baseNavigation.replace(ScreenName.MarketList);
return;
}

navigation.navigate(ScreenName.MarketList);
}, [navigation, shouldRedirectToMarket]);

const { marketApiId, knownLedgerIds, knownMarketId } = useMemo(
() =>
Expand Down Expand Up @@ -107,5 +146,6 @@ export function useAssetDetailViewModel() {
coinOptions,
isLoading,
ledgerIds: receiveLedgerIds,
shouldRedirectToMarket,
};
}
Original file line number Diff line number Diff line change
@@ -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<typeof handleAssetDetailDeeplink>[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");
});
});
Loading
Loading