Skip to content
Draft
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/loud-pandas-walk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"live-mobile": minor
---

Add stablecoins section to portfolio screen with categorized asset display
1 change: 1 addition & 0 deletions apps/ledger-live-mobile/src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -7805,6 +7805,7 @@
"wallet": {
"tabs": {
"crypto": "Crypto",
"stablecoins": "Stablecoin",
"market": "Market"
},
"referralProgram": "$20"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { PortfolioCategorizedAssetsSection } from "./views/PortfolioCategorizedAssetsSection";
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import React from "react";
import { Box } from "@ledgerhq/native-ui";
import { PortfolioCryptosSection } from "../../PortfolioCryptosSection";
import { PortfolioStablecoinsSection } from "../../PortfolioStablecoinsSection";
import { SeeAllAssetsButton } from "../../SeeAllAssetsButton";

export const PortfolioCategorizedAssetsSection: React.FC = () => (
<>
<Box px={6}>
<PortfolioCryptosSection />
</Box>
<Box px={6} pt={6}>
<PortfolioStablecoinsSection />
</Box>
<Box px={6}>
<SeeAllAssetsButton />
</Box>
</>
);
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { act } from "@testing-library/react-native";
import { renderHook } from "@tests/test-renderer";
import { NavigatorName, ScreenName } from "~/const";
import { useDistribution } from "~/actions/general";
import { Asset } from "~/types/asset";
import { State } from "~/reducers/types";
import usePortfolioCryptosSectionViewModel from "../hooks/usePortfolioCryptosSectionViewModel";
import { CategorizedAssets } from "@ledgerhq/asset-aggregation/assetCategorization/types";
import { bitcoin, ethereum, createCryptoAsset } from "./shared";

const mockNavigate = jest.fn();
Expand All @@ -15,35 +15,56 @@ jest.mock("@react-navigation/native", () => ({
useRoute: () => ({ name: "Portfolio" }),
}));

jest.mock("~/actions/general", () => ({
...jest.requireActual("~/actions/general"),
useDistribution: jest.fn(),
}));

jest.mock("@ledgerhq/live-common/dada-client/hooks/useAssetsData", () => ({
useAssetsData: () => ({ data: undefined, isLoading: false }),
}));

const mockCategorizedAssets = jest.fn();

jest.mock("LLM/hooks/useCategorizedAssetsFromPortfolio", () => ({
useCategorizedAssetsFromPortfolio: () => mockCategorizedAssets(),
}));

const mockReadOnlyCoins = jest.fn();

jest.mock("~/hooks/useReadOnlyCoins", () => ({
useReadOnlyCoins: (opts: { maxDisplayed: number }) => mockReadOnlyCoins(opts),
}));

const mockDistribution = (list: Asset[] = [], isAvailable = true) => {
(useDistribution as jest.Mock).mockReturnValue({ isAvailable, list });
const toCategorizedItem = (asset: Asset) => ({
currency: asset.currency,
balance: asset.amount,
value: 0,
distribution: 0,
accounts: asset.accounts,
});

const mockPortfolioWithCryptos = (
cryptoAssets: Asset[] = [],
stablecoinAssets: Asset[] = [],
stablecoinTickers: string[] = [],
): void => {
const categorizedAssets: CategorizedAssets = {
cryptos: cryptoAssets.map(toCategorizedItem),
stablecoins: stablecoinAssets.map(toCategorizedItem),
};
mockCategorizedAssets.mockReturnValue({
categorizedAssets,
stablecoinTickers: new Set(stablecoinTickers.map(t => t.toUpperCase())),
isLoadingStablecoinTickers: false,
});
};

describe("usePortfolioCryptosSectionViewModel", () => {
beforeEach(() => {
jest.clearAllMocks();
mockDistribution();
mockPortfolioWithCryptos();
mockReadOnlyCoins.mockReturnValue({ sortedCryptoCurrencies: [] });
});

describe("asset filtering and display", () => {
it("should return empty assets when distribution is not available", () => {
mockDistribution([], false);
it("should return empty assets when there are no cryptos in distribution", () => {
mockPortfolioWithCryptos([]);

const { result } = renderHook(() => usePortfolioCryptosSectionViewModel());

Expand All @@ -52,8 +73,10 @@ describe("usePortfolioCryptosSectionViewModel", () => {
});

it("should return all assets when fewer than 6 are available", () => {
const mockAssets = [createCryptoAsset(bitcoin, 100000), createCryptoAsset(ethereum, 2000)];
mockDistribution(mockAssets);
mockPortfolioWithCryptos([
createCryptoAsset(bitcoin, 100000),
createCryptoAsset(ethereum, 2000),
]);

const { result } = renderHook(() => usePortfolioCryptosSectionViewModel());

Expand All @@ -65,21 +88,34 @@ describe("usePortfolioCryptosSectionViewModel", () => {
const mockAssets = Array.from({ length: 10 }, (_, i) =>
createCryptoAsset(bitcoin, 10000 * (10 - i)),
);
mockDistribution(mockAssets);
mockPortfolioWithCryptos(mockAssets);

const { result } = renderHook(() => usePortfolioCryptosSectionViewModel());

expect(result.current.assetsCount).toBe(10);
expect(result.current.assetsToDisplay).toHaveLength(6);
});

it("should exclude stablecoins — they are in the stablecoins bucket, not cryptos", () => {
mockPortfolioWithCryptos(
[createCryptoAsset(bitcoin, 100000)],
[createCryptoAsset(ethereum, 2000)],
[ethereum.ticker],
);

const { result } = renderHook(() => usePortfolioCryptosSectionViewModel());

expect(result.current.assetsCount).toBe(1);
expect(result.current.assetsToDisplay[0].currency).toBe(bitcoin);
});
});

describe("hasMore", () => {
it("should be false when there are 6 assets or fewer", () => {
const mockAssets = Array.from({ length: 6 }, (_, i) =>
createCryptoAsset(bitcoin, 10000 * (6 - i)),
);
mockDistribution(mockAssets);
mockPortfolioWithCryptos(mockAssets);

const { result } = renderHook(() => usePortfolioCryptosSectionViewModel());

Expand All @@ -90,7 +126,7 @@ describe("usePortfolioCryptosSectionViewModel", () => {
const mockAssets = Array.from({ length: 7 }, (_, i) =>
createCryptoAsset(bitcoin, 10000 * (7 - i)),
);
mockDistribution(mockAssets);
mockPortfolioWithCryptos(mockAssets);

const { result } = renderHook(() => usePortfolioCryptosSectionViewModel());

Expand Down Expand Up @@ -147,11 +183,11 @@ describe("usePortfolioCryptosSectionViewModel", () => {

describe("onItemPress", () => {
it("should navigate to Asset detail screen with the currency", () => {
const mockAsset = createCryptoAsset(ethereum, 5000);
mockPortfolioWithCryptos([createCryptoAsset(ethereum, 5000)]);
const { result } = renderHook(() => usePortfolioCryptosSectionViewModel());

act(() => {
result.current.onItemPress(mockAsset);
result.current.onItemPress(result.current.assetsToDisplay[0]);
});

expect(mockNavigate).toHaveBeenCalledWith(NavigatorName.Accounts, {
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import { useCallback, useMemo } from "react";
import useEnv from "@ledgerhq/live-common/hooks/useEnv";
import { useFeature } from "@ledgerhq/live-common/featureFlags/index";
import { useNavigation } from "@react-navigation/native";
import { NativeStackNavigationProp } from "@react-navigation/native-stack";
import { CategorizedAssetItem } from "@ledgerhq/asset-aggregation/assetCategorization/types";
import { BaseNavigatorStackParamList } from "~/components/RootNavigator/types/BaseNavigator";
import { useSelector } from "~/context/hooks";
import { useDistribution } from "~/actions/general";
import { NavigatorName, ScreenName } from "~/const";
import { blacklistedTokenIdsSelector } from "~/reducers/settings";
import { Asset } from "~/types/asset";
import { track } from "~/analytics";
import { useDefaultAssets } from "./useDefaultAssets";
import { useDefaultAssetsByCategory } from "LLM/hooks/useDefaultAssetsByCategory";
import { useReadOnlyCoins } from "~/hooks/useReadOnlyCoins";
import { useCategorizedAssetsFromPortfolio } from "LLM/hooks/useCategorizedAssetsFromPortfolio";

export const MAX_ASSETS_TO_DISPLAY = 6;
export const EMPTY_STATE_MAX_ASSETS = 4;
Expand All @@ -32,42 +32,51 @@ interface UsePortfolioCryptosSectionViewModelOptions {
isReadOnly?: boolean;
}

const toAsset = (item: CategorizedAssetItem): Asset => ({
currency: item.currency,
accounts: item.accounts,
amount: item.balance,
countervalue: item.value,
distribution: item.distribution,
isPlaceholder: false,
});

const usePortfolioCryptosSectionViewModel = ({
isEmptyState = false,
isReadOnly = false,
}: UsePortfolioCryptosSectionViewModelOptions = {}): PortfolioCryptosSectionViewModelResult => {
const hideEmptyTokenAccount = useEnv("HIDE_EMPTY_TOKEN_ACCOUNTS");
const isAccountListUIEnabled = useFeature("llmAccountListUI")?.enabled;
const navigation = useNavigation<NativeStackNavigationProp<BaseNavigatorStackParamList>>();

const blacklistedTokenIds = useSelector(blacklistedTokenIdsSelector);
const blacklistedTokenIdsSet = useMemo(() => new Set(blacklistedTokenIds), [blacklistedTokenIds]);

const distribution = useDistribution({
showEmptyAccounts: true,
hideEmptyTokenAccount,
});

const distributionAssets = useMemo(
() => (distribution.isAvailable && distribution.list.length > 0 ? distribution.list : []),
[distribution],
);
const { categorizedAssets, stablecoinTickers } = useCategorizedAssetsFromPortfolio();

const filteredAssets = useMemo(
() =>
distributionAssets.filter(
({ currency }) =>
currency.type !== "TokenCurrency" || !blacklistedTokenIdsSet.has(currency.id),
),
[distributionAssets, blacklistedTokenIdsSet],
categorizedAssets.cryptos
.filter(
({ currency }) =>
currency.type !== "TokenCurrency" || !blacklistedTokenIdsSet.has(currency.id),
)
.map(toAsset),
[categorizedAssets.cryptos, blacklistedTokenIdsSet],
);

const { assets: defaultAssets, isLoading, isError } = useDefaultAssets(isEmptyState);
const {
cryptos: defaultAssets,
isLoading,
isError,
} = useDefaultAssetsByCategory(isEmptyState, stablecoinTickers, EMPTY_STATE_MAX_ASSETS, 0);

const { sortedCryptoCurrencies } = useReadOnlyCoins({ maxDisplayed: READ_ONLY_MAX_ASSETS });
const readOnlyAssets = useMemo<Asset[]>(
() => sortedCryptoCurrencies.map(currency => ({ amount: 0, accounts: [], currency })),
[sortedCryptoCurrencies],
() =>
sortedCryptoCurrencies
.filter(currency => !stablecoinTickers.has(currency.ticker.toUpperCase()))
.map(currency => ({ amount: 0, accounts: [], currency })),
[sortedCryptoCurrencies, stablecoinTickers],
);

const assets = useMemo<Asset[]>(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import React from "react";
import {
Box,
Button,
Subheader,
SubheaderCount,
SubheaderRow,
Expand Down Expand Up @@ -53,17 +52,6 @@ const PortfolioCryptosSectionComponent: React.FC<PortfolioCryptosSectionProps> =
onItemPress={onItemPress}
/>
</Box>
{hasMore && (
<Button
appearance="gray"
size="lg"
isFull
onPress={onPressShowAll}
lx={{ marginTop: "s24", marginBottom: "s24" }}
>
{t("portfolio.seeAllAssets")}
</Button>
)}
</Box>
);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import MarketBanner from "LLM/features/MarketBanner";
import { ScreenName } from "~/const";
import { PortfolioBannersSection } from "../PortfolioBannersSection";
import { PortfolioCryptosSection } from "../PortfolioCryptosSection";
import { PortfolioStablecoinsSection } from "../PortfolioStablecoinsSection";
import AddAccountDrawer from "LLM/features/Accounts/screens/AddAccount";
import { useTranslation } from "~/context/Locale";
import TrackScreen from "~/analytics/TrackScreen";
Expand Down Expand Up @@ -33,7 +34,14 @@ const PortfolioNoAccountsContent = ({
<TransferDrawer />
<PortfolioBannersSection isFirst={true} isLNSUpsellBannerShown={isLNSUpsellBannerShown} />
<MarketBanner />
{shouldDisplayAssetSection && <PortfolioCryptosSection isEmptyState />}
{shouldDisplayAssetSection && (
<>
<PortfolioCryptosSection isEmptyState />
<Box lx={{ marginTop: "s24" }}>
<PortfolioStablecoinsSection isEmptyState />
</Box>
</>
)}
<Button
appearance="gray"
size="lg"
Expand Down
Loading
Loading