From 5f7a7995e0ca2fa2d0c1203eda025bc9ae89150d Mon Sep 17 00:00:00 2001 From: cgrellard-ledger Date: Mon, 25 Nov 2024 15:59:25 +0100 Subject: [PATCH 01/13] :bug: fix(llm): improve error message when deleting backup on multiple instances at the same time --- .changeset/beige-rings-move.md | 5 +++++ apps/ledger-live-mobile/src/locales/en/common.json | 5 +++++ .../WalletSync/hooks/useLedgerSyncAnalytics.ts | 1 + .../features/WalletSync/hooks/useSpecificError.tsx | 13 +++++++++++++ .../screens/ManageKey/ManageKeyDrawer.tsx | 8 ++++++++ .../screens/ManageKey/useManageKeyDrawer.ts | 12 +++++++++++- 6 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 .changeset/beige-rings-move.md diff --git a/.changeset/beige-rings-move.md b/.changeset/beige-rings-move.md new file mode 100644 index 000000000000..eac530b6611a --- /dev/null +++ b/.changeset/beige-rings-move.md @@ -0,0 +1,5 @@ +--- +"live-mobile": minor +--- + +LLM - Ledger Sync improve error message when deleting backup on multiple instances at the same time diff --git a/apps/ledger-live-mobile/src/locales/en/common.json b/apps/ledger-live-mobile/src/locales/en/common.json index fa6d09fc50ba..8ee564cec4aa 100644 --- a/apps/ledger-live-mobile/src/locales/en/common.json +++ b/apps/ledger-live-mobile/src/locales/en/common.json @@ -6929,6 +6929,11 @@ "cancel": "Keep sync", "title": "Sure you want delete sync?", "desc": "Your crypto accounts on different phones and computers will stop being in sync. This does not impact your funds in any way." + }, + "error": { + "title": "This app is no longer synced", + "description": "Turn on Ledger Sync on either this Ledger Live or the one you're syncing with.", + "cta": "Turn on Ledger Sync" } }, "manageInstances": { diff --git a/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useLedgerSyncAnalytics.ts b/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useLedgerSyncAnalytics.ts index 4c7dafa7e1f1..b39d3976e138 100644 --- a/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useLedgerSyncAnalytics.ts +++ b/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useLedgerSyncAnalytics.ts @@ -21,6 +21,7 @@ export enum AnalyticsPage { ManageBackup = "Delete sync", ConfirmDeleteBackup = "Confirm delete sync", DeleteBackupSuccess = "Delete sync success", + DeleteBackupError = "Delete sync error", SyncWithNoKey = "Scan attempt with no sync", LedgerSyncActivated = "Ledger Sync activated", AutoRemove = "Can’t remove current instance", diff --git a/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useSpecificError.tsx b/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useSpecificError.tsx index b8c970002fe2..5558481a28f8 100644 --- a/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useSpecificError.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useSpecificError.tsx @@ -16,6 +16,7 @@ export enum ErrorReason { NO_BACKUP = "no-backup", NO_BACKUP_ONBOARDING_DEVICE = "no-backup-onboarding-device", NO_BACKUP_ONBOARDING_QRCODE = "no-backup-onboarding-qrcode", + NO_TRUSTCHAIN = "no-trustchain", } export interface ErrorConfig { @@ -225,6 +226,18 @@ export function useSpecificError({ primaryAction, secondaryAction }: SpecificPro ContinueWihtoutSync({ page: AnalyticsPage.OnBoardingDeviceNoBackup, hasFlow: false }); }, }, + [ErrorReason.NO_TRUSTCHAIN]: { + icon: , + title: t("walletSync.walletSyncActivated.manageKey.error.title"), + description: t("walletSync.walletSyncActivated.manageKey.error.description"), + cta: t("walletSync.walletSyncActivated.manageKey.error.cta"), + analyticsPage: AnalyticsPage.DeleteBackupError, + buttonType: "main" as ButtonProps["type"], + primaryAction: () => { + primaryAction(); + onCreate({ page: AnalyticsPage.DeleteBackupError, hasFlow: false }); + }, + }, }; const getErrorConfig = (error: ErrorReason) => errorConfig[error]; diff --git a/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/ManageKey/ManageKeyDrawer.tsx b/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/ManageKey/ManageKeyDrawer.tsx index 682e3676bddf..b7cd2c8993d0 100644 --- a/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/ManageKey/ManageKeyDrawer.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/ManageKey/ManageKeyDrawer.tsx @@ -6,6 +6,9 @@ import { Flex, InfiniteLoader } from "@ledgerhq/native-ui"; import { ConfirmManageKey } from "../../components/ManageKey/Confirm"; import { HookResult } from "./useManageKeyDrawer"; +import { isNoTrustchainError } from "../../utils/errors"; +import { SpecificError } from "../../components/Error/SpecificError"; +import { ErrorReason } from "../../hooks/useSpecificError"; const ManageKeyDrawer = ({ isDrawerVisible, @@ -13,9 +16,14 @@ const ManageKeyDrawer = ({ deleteMutation, onClickConfirm, handleCancel, + onCreateKey, }: HookResult) => { const getScene = () => { + console.log("deleteMutation", deleteMutation.error); if (deleteMutation.error) { + if (isNoTrustchainError(deleteMutation.error)) { + return ; + } return ( Promise; deleteMutation: UseMutationResult; handleCancel: () => void; + onCreateKey: () => void; }; export const useManageKeyDrawer = () => { @@ -43,6 +45,7 @@ export const useManageKeyDrawer = () => { }, [dispatch]); const navigation = useNavigation>(); + const baseNavigation = useNavigation(); const handleClose = () => { closeDrawer(); @@ -72,6 +75,12 @@ export const useManageKeyDrawer = () => { navigation.navigate(ScreenName.WalletSyncManageKeyDeleteSuccess); }; + const onCreateKey = () => { + baseNavigation.navigate(NavigatorName.WalletSync, { + screen: ScreenName.WalletSyncActivationProcess, + }); + }; + return { isDrawerVisible, openDrawer, @@ -80,5 +89,6 @@ export const useManageKeyDrawer = () => { deleteMutation, handleCancel, handleClose, + onCreateKey, }; }; From 08e1fa907b4d264d0c3fb0f5a3287c920c280b67 Mon Sep 17 00:00:00 2001 From: cgrellard-ledger Date: Mon, 25 Nov 2024 16:09:15 +0100 Subject: [PATCH 02/13] :bug: fix(llm): improve error message when deleting backup on multiple instances at the same time --- .../features/WalletSync/screens/ManageKey/ManageKeyDrawer.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/ManageKey/ManageKeyDrawer.tsx b/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/ManageKey/ManageKeyDrawer.tsx index b7cd2c8993d0..8bc6819b995f 100644 --- a/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/ManageKey/ManageKeyDrawer.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/ManageKey/ManageKeyDrawer.tsx @@ -19,7 +19,6 @@ const ManageKeyDrawer = ({ onCreateKey, }: HookResult) => { const getScene = () => { - console.log("deleteMutation", deleteMutation.error); if (deleteMutation.error) { if (isNoTrustchainError(deleteMutation.error)) { return ; From 306dec68321ab4214287618cb7903a1ae37a54b2 Mon Sep 17 00:00:00 2001 From: cgrellard-ledger Date: Mon, 25 Nov 2024 18:28:10 +0100 Subject: [PATCH 03/13] :bug: fix(llm): improve error message when an unauthorized member tries to delete a backup --- .changeset/silly-cougars-rescue.md | 5 +++++ apps/ledger-live-mobile/src/locales/en/common.json | 7 +++++++ .../features/WalletSync/hooks/useSpecificError.tsx | 13 +++++++++++++ .../screens/ManageKey/ManageKeyDrawer.tsx | 7 ++++++- .../src/newArch/features/WalletSync/utils/errors.ts | 6 ++++++ 5 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 .changeset/silly-cougars-rescue.md diff --git a/.changeset/silly-cougars-rescue.md b/.changeset/silly-cougars-rescue.md new file mode 100644 index 000000000000..95b714377d0f --- /dev/null +++ b/.changeset/silly-cougars-rescue.md @@ -0,0 +1,5 @@ +--- +"live-mobile": minor +--- + +LLM - Ledger Sync improved error message when an unauthorized member tries to delete a backup diff --git a/apps/ledger-live-mobile/src/locales/en/common.json b/apps/ledger-live-mobile/src/locales/en/common.json index 8ee564cec4aa..1a997373b0d2 100644 --- a/apps/ledger-live-mobile/src/locales/en/common.json +++ b/apps/ledger-live-mobile/src/locales/en/common.json @@ -6919,6 +6919,13 @@ "title": "Sync with another Ledger Live app", "description": "Sync your Ledger Live crypto accounts across different phones and computers." }, + "unauthorizeMember": { + "error": { + "title": "This app is no longer synced", + "description": "Turn on Ledger Sync on either this Ledger Live or the one you're syncing with.", + "cta": "Turn on Ledger Sync" + } + }, "manageKey": { "title": "Delete Sync", "description": "Your crypto accounts across different phones and computers will stop synching.", diff --git a/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useSpecificError.tsx b/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useSpecificError.tsx index 5558481a28f8..7a55245e17b4 100644 --- a/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useSpecificError.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useSpecificError.tsx @@ -17,6 +17,7 @@ export enum ErrorReason { NO_BACKUP_ONBOARDING_DEVICE = "no-backup-onboarding-device", NO_BACKUP_ONBOARDING_QRCODE = "no-backup-onboarding-qrcode", NO_TRUSTCHAIN = "no-trustchain", + UNAUTHORIZED_MEMBER = "unauthorized-member", } export interface ErrorConfig { @@ -238,6 +239,18 @@ export function useSpecificError({ primaryAction, secondaryAction }: SpecificPro onCreate({ page: AnalyticsPage.DeleteBackupError, hasFlow: false }); }, }, + [ErrorReason.UNAUTHORIZED_MEMBER]: { + icon: , + title: t("walletSync.walletSyncActivated.unauthorizeMember.error.title"), + description: t("walletSync.walletSyncActivated.unauthorizeMember.error.description"), + cta: t("walletSync.walletSyncActivated.unauthorizeMember.error.cta"), + analyticsPage: AnalyticsPage.DeleteBackupError, + buttonType: "main" as ButtonProps["type"], + primaryAction: () => { + primaryAction(); + onCreate({ page: AnalyticsPage.DeleteBackupError, hasFlow: false }); + }, + }, }; const getErrorConfig = (error: ErrorReason) => errorConfig[error]; diff --git a/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/ManageKey/ManageKeyDrawer.tsx b/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/ManageKey/ManageKeyDrawer.tsx index 8bc6819b995f..7333b9598632 100644 --- a/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/ManageKey/ManageKeyDrawer.tsx +++ b/apps/ledger-live-mobile/src/newArch/features/WalletSync/screens/ManageKey/ManageKeyDrawer.tsx @@ -6,7 +6,7 @@ import { Flex, InfiniteLoader } from "@ledgerhq/native-ui"; import { ConfirmManageKey } from "../../components/ManageKey/Confirm"; import { HookResult } from "./useManageKeyDrawer"; -import { isNoTrustchainError } from "../../utils/errors"; +import { isNoTrustchainError, isUnauthorizedMemberError } from "../../utils/errors"; import { SpecificError } from "../../components/Error/SpecificError"; import { ErrorReason } from "../../hooks/useSpecificError"; @@ -23,6 +23,11 @@ const ManageKeyDrawer = ({ if (isNoTrustchainError(deleteMutation.error)) { return ; } + if (isUnauthorizedMemberError(deleteMutation.error)) { + return ( + + ); + } return ( error.message.includes(ErrorType.NO_TRUSTCHAIN); + +export const isUnauthorizedMemberError = (error: Error) => + error instanceof LedgerAPI4xx && + (error.message.includes("Not a member of trustchain") || + error.message.includes("You are not member")); From ea5c60cb3a3c4727acafb4021224385dce2bc296 Mon Sep 17 00:00:00 2001 From: cgrellard-ledger Date: Tue, 26 Nov 2024 09:52:24 +0100 Subject: [PATCH 04/13] :bug: fix(llm): improve error message when a removed member tries to remove another member --- .changeset/heavy-starfishes-fix.md | 5 +++++ .../WalletSync/hooks/useOnTrustchainRefreshNeeded.ts | 4 ++-- .../src/newArch/features/WalletSync/hooks/useRemoveMember.ts | 4 ++-- 3 files changed, 9 insertions(+), 4 deletions(-) create mode 100644 .changeset/heavy-starfishes-fix.md diff --git a/.changeset/heavy-starfishes-fix.md b/.changeset/heavy-starfishes-fix.md new file mode 100644 index 000000000000..25eb83adf3ff --- /dev/null +++ b/.changeset/heavy-starfishes-fix.md @@ -0,0 +1,5 @@ +--- +"live-mobile": minor +--- + +LLM - Ledger Sync improved error message when a removed member tries to remove another member diff --git a/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useOnTrustchainRefreshNeeded.ts b/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useOnTrustchainRefreshNeeded.ts index a6004762cdf1..ff8779d13c49 100644 --- a/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useOnTrustchainRefreshNeeded.ts +++ b/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useOnTrustchainRefreshNeeded.ts @@ -6,7 +6,7 @@ import { TrustchainSDK, } from "@ledgerhq/ledger-key-ring-protocol/types"; import { setTrustchain, resetTrustchainStore } from "@ledgerhq/ledger-key-ring-protocol/store"; -import { TrustchainEjected } from "@ledgerhq/ledger-key-ring-protocol/errors"; +import { TrustchainEjected, TrustchainNotAllowed } from "@ledgerhq/ledger-key-ring-protocol/errors"; import { log } from "@ledgerhq/logs"; import { AnalyticsEvents } from "~/newArch/features/Analytics/enums"; import { track } from "~/analytics"; @@ -24,7 +24,7 @@ export function useOnTrustchainRefreshNeeded( const newTrustchain = await trustchainSdk.restoreTrustchain(trustchain, memberCredentials); dispatch(setTrustchain(newTrustchain)); } catch (e) { - if (e instanceof TrustchainEjected) { + if (e instanceof TrustchainEjected || e instanceof TrustchainNotAllowed) { dispatch(resetTrustchainStore()); track(AnalyticsEvents.LedgerSyncDeactivated); } diff --git a/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useRemoveMember.ts b/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useRemoveMember.ts index 326fdea533a1..c5619fddf1ea 100644 --- a/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useRemoveMember.ts +++ b/apps/ledger-live-mobile/src/newArch/features/WalletSync/hooks/useRemoveMember.ts @@ -5,7 +5,7 @@ import { } from "@ledgerhq/ledger-key-ring-protocol/store"; import { useDispatch, useSelector } from "react-redux"; import { useTrustchainSdk } from "./useTrustchainSdk"; -import { TrustchainNotAllowed } from "@ledgerhq/ledger-key-ring-protocol/errors"; +import { TrustchainEjected, TrustchainNotAllowed } from "@ledgerhq/ledger-key-ring-protocol/errors"; import { TrustchainMember, Trustchain } from "@ledgerhq/ledger-key-ring-protocol/types"; import { useCallback } from "react"; import { Device } from "@ledgerhq/live-common/hw/actions/types"; @@ -44,7 +44,7 @@ export function useRemoveMember({ device, member }: Props): DrawerProps { if (!member) return; if (!device) return; if (!trustchain || !memberCredentials) { - throw new Error("trustchain or memberCredentials is not set"); + throw new TrustchainEjected("trustchain or memberCredentials is not set"); } const newTrustchain = await sdk.removeMember( device.deviceId, From 322fd58ffcde6d592eb27af1fd93f8c45d33205c Mon Sep 17 00:00:00 2001 From: jiyuzhuang Date: Wed, 27 Nov 2024 14:55:08 +0100 Subject: [PATCH 05/13] feat: remove feature_recover_upsell_redirection ff and unused components --- .changeset/shaggy-days-flash.md | 8 + .../SyncOnboarding/Manual/BackupStep.tsx | 234 ----------------- .../Manual/SyncOnboardingCompanion.tsx | 50 +--- .../useShouldRedirect.ts | 3 - .../useShouldRedirect.ts | 3 - .../SyncOnboardingCompanion.tsx | 58 +---- .../companionSteps/BackupStep.tsx | 236 ------------------ .../src/featureFlags/defaultFeatures.ts | 1 - ...ectToPostOnboardingOrRecoverUpsell.test.ts | 94 ++----- ...RedirectToPostOnboardingOrRecoverUpsell.ts | 10 +- .../packages/types-live/src/feature.ts | 2 - 11 files changed, 53 insertions(+), 646 deletions(-) create mode 100644 .changeset/shaggy-days-flash.md delete mode 100644 apps/ledger-live-desktop/src/renderer/components/SyncOnboarding/Manual/BackupStep.tsx delete mode 100644 apps/ledger-live-mobile/src/screens/SyncOnboarding/companionSteps/BackupStep.tsx diff --git a/.changeset/shaggy-days-flash.md b/.changeset/shaggy-days-flash.md new file mode 100644 index 000000000000..a9bd76b37757 --- /dev/null +++ b/.changeset/shaggy-days-flash.md @@ -0,0 +1,8 @@ +--- +"@ledgerhq/types-live": patch +"ledger-live-desktop": patch +"live-mobile": patch +"@ledgerhq/live-common": patch +--- + +Remove `feature_recover_upsell_redirection` feature flag and unused components diff --git a/apps/ledger-live-desktop/src/renderer/components/SyncOnboarding/Manual/BackupStep.tsx b/apps/ledger-live-desktop/src/renderer/components/SyncOnboarding/Manual/BackupStep.tsx deleted file mode 100644 index 71b4692e4304..000000000000 --- a/apps/ledger-live-desktop/src/renderer/components/SyncOnboarding/Manual/BackupStep.tsx +++ /dev/null @@ -1,234 +0,0 @@ -import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; -import { Device } from "@ledgerhq/live-common/hw/actions/types"; -import { Flex, Icons, IconsLegacy, Link, Tag, Text } from "@ledgerhq/react-ui"; -import { StorylyInstanceID } from "@ledgerhq/types-live"; -import React, { ComponentProps, useCallback, useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import styled, { useTheme } from "styled-components"; -import { track } from "~/renderer/analytics/segment"; -import ButtonV3 from "../../ButtonV3"; -import TrackPage from "~/renderer/analytics/TrackPage"; -import { useStoryly } from "~/storyly/useStoryly"; - -import { StepText, StepSubtitleText } from "./shared"; -import { useCustomPath } from "@ledgerhq/live-common/hooks/recoverFeatureFlag"; -import { useDispatch } from "react-redux"; -import { useHistory } from "react-router"; -import { saveSettings } from "~/renderer/actions/settings"; - -type BackupStepProps = { - device: Device; - onPressKeepManualBackup(): void; -}; - -type ChoiceBodyProps = { - isOpened: boolean; - device: Device; - onPressKeepManualBackup(): void; -}; - -type Choice = { - id: "backup" | "keep_on_paper"; - icon: React.ReactNode; - getTitle: (selected?: boolean) => string; - tag?: React.ReactNode; - body: React.ComponentType; -}; - -const ChoiceText = styled(Text).attrs({ - variant: "paragraph", - color: "neutral.c70", -})``; - -const VideoLink: React.FC<{}> = () => { - const { t } = useTranslation(); - const { ref: storylyRef, dataRef } = useStoryly(StorylyInstanceID.backupRecoverySeed); - return ( - -
- {/* @ts-expect-error the `storyly-web` package doesn't provide any typings yet. */} - -
- { - track("button_clicked", { button: "How it works", flow: "Device onboarding" }); - storylyRef.current?.openStory({ - group: dataRef.current?.at(0)?.id.toString(10), - }); - }} - > - {t("syncOnboarding.manual.backup.backupChoice.howItWorksCta")} - -
- ); -}; - -const BackupBody: React.FC = ({ isOpened }) => { - const { t } = useTranslation(); - - const dispatch = useDispatch(); - const history = useHistory(); - - const servicesConfig = useFeature("protectServicesDesktop"); - - const recoverActivatePath = useCustomPath(servicesConfig, "activate", "lld-onboarding-24") || ""; - - const navigateToRecover = useCallback(() => { - console.log("recoverActivatePath", recoverActivatePath); - const [pathname, search] = recoverActivatePath.split("?"); - dispatch( - saveSettings({ - hasCompletedOnboarding: true, - }), - ); - history.push({ - pathname, - search: search ? `?${search}` : undefined, - state: { fromOnboarding: false }, - }); - }, [dispatch, history, recoverActivatePath]); - - return isOpened ? ( - - {t("syncOnboarding.manual.backup.backupChoice.description")} - - {t("syncOnboarding.manual.backup.backupChoice.tryCta")} - - - {t("syncOnboarding.manual.backup.backupChoice.redeemCodeCta")} - - - - ) : null; -}; - -const KeepOnPaperBody: React.FC = ({ isOpened, onPressKeepManualBackup }) => { - const { t } = useTranslation(); - return isOpened ? ( - - {t("syncOnboarding.manual.backup.manualBackup.description")} - - {t("syncOnboarding.manual.backup.manualBackup.cta")} - - - ) : null; -}; - -const WrappedTag: React.FC<{ text: string } & ComponentProps> = ({ - text, - ...props -}) => { - const { t } = useTranslation(); - return ( - - {t(text)} - - ); -}; - -const choices: Choice[] = [ - { - id: "backup", - getTitle: selected => - selected - ? "syncOnboarding.manual.backup.backupChoice.titleSelected" - : "syncOnboarding.manual.backup.backupChoice.title", - icon: , - tag: ( - - ), - body: BackupBody, - }, - { - id: "keep_on_paper", - getTitle: () => "syncOnboarding.manual.backup.manualBackup.title", - icon: , - body: KeepOnPaperBody, - }, -]; - -const BackupStep: React.FC = props => { - const { device, onPressKeepManualBackup } = props; - const [choice, setChoice] = useState(null); - const { radii } = useTheme(); - - const { t } = useTranslation(); - - useEffect(() => { - if (choice === "backup") { - track("button_clicked", { button: "Ledger Recover", flow: "Device onboarding" }); - } else if (choice === "keep_on_paper") { - track("button_clicked", { button: "Manual Backup", flow: "Device onboarding" }); - } - }, [choice]); - - return ( - - - {/* @ts-expect-error weird props issue with React 18 */} - {t("syncOnboarding.manual.backup.description")} - {choices.map(({ id, getTitle, icon, tag, body: Body }) => ( -
setChoice(id)}> - - - - {icon} - {/* @ts-expect-error weird props issue with React 18 */} - - {t(getTitle(choice === id))} - - - {tag ?? null} - - - -
- ))} -
- ); -}; - -export default BackupStep; diff --git a/apps/ledger-live-desktop/src/renderer/components/SyncOnboarding/Manual/SyncOnboardingCompanion.tsx b/apps/ledger-live-desktop/src/renderer/components/SyncOnboarding/Manual/SyncOnboardingCompanion.tsx index f7aff61d21af..4584d5ae3a42 100644 --- a/apps/ledger-live-desktop/src/renderer/components/SyncOnboarding/Manual/SyncOnboardingCompanion.tsx +++ b/apps/ledger-live-desktop/src/renderer/components/SyncOnboarding/Manual/SyncOnboardingCompanion.tsx @@ -35,7 +35,6 @@ import { setDrawer } from "~/renderer/drawers/Provider"; import LockedDeviceDrawer from "./LockedDeviceDrawer"; import { LockedDeviceError } from "@ledgerhq/errors"; import { useRecoverRestoreOnboarding } from "~/renderer/hooks/useRecoverRestoreOnboarding"; -import BackupStep from "./BackupStep"; const READY_REDIRECT_DELAY_MS = 2000; const POLLING_PERIOD_MS = 1000; @@ -117,8 +116,6 @@ const SyncOnboardingCompanion: React.FC = ({ const [seedPathStatus, setSeedPathStatus] = useState("choice_new_or_restore"); const servicesConfig = useFeature("protectServicesDesktop"); - const recoverUpsellRedirection = useFeature("recoverUpsellRedirection"); - const hasBackupStep = !recoverUpsellRedirection?.enabled; const recoverRestoreStaxPath = useCustomPath(servicesConfig, "restore", "lld-onboarding-24"); @@ -192,22 +189,6 @@ const SyncOnboardingCompanion: React.FC = ({ ), }, - ...(hasBackupStep - ? [ - { - key: StepKey.Backup, - status: "inactive" as StepStatus, - title: t("syncOnboarding.manual.backup.title"), - titleCompleted: t("syncOnboarding.manual.backup.title"), - renderBody: () => ( - setStepKey(StepKey.Apps)} - /> - ), - }, - ] - : []), { key: StepKey.Apps, status: "inactive", @@ -237,7 +218,6 @@ const SyncOnboardingCompanion: React.FC = ({ [ t, deviceName, - hasBackupStep, hasAppLoader, productName, device, @@ -359,24 +339,16 @@ const SyncOnboardingCompanion: React.FC = ({ // When the device is seeded, there are 2 cases before triggering the application install step: // - the user came to the sync onboarding with an non-seeded device and did a full onboarding: onboarding flag `Ready` // - the user came to the sync onboarding with an already seeded device: onboarding flag `WelcomeScreen1` - if (deviceOnboardingState?.isOnboarded && !seededDeviceHandled.current) { - if (deviceOnboardingState?.currentOnboardingStep === DeviceOnboardingStep.Ready) { - // device was just seeded - setStepKey(hasBackupStep ? StepKey.Backup : StepKey.Apps); - seededDeviceHandled.current = true; - return; - } else if ( - deviceOnboardingState?.currentOnboardingStep === DeviceOnboardingStep.WelcomeScreen1 - ) { - // device was already seeded, switch to the apps step - if (hasBackupStep) { - __DEV__ ? setStepKey(StepKey.Backup) : setStepKey(StepKey.Apps); // for ease of testing in dev mode without having to reset the device - } else { - setStepKey(StepKey.Apps); - } - seededDeviceHandled.current = true; - return; - } + if ( + deviceOnboardingState?.isOnboarded && + !seededDeviceHandled.current && + [DeviceOnboardingStep.Ready, DeviceOnboardingStep.WelcomeScreen1].includes( + deviceOnboardingState.currentOnboardingStep, + ) + ) { + setStepKey(StepKey.Apps); + seededDeviceHandled.current = true; + return; } // case DeviceOnboardingStep.SafetyWarning not handled so the previous step (new seed, restore, recover) is kept @@ -427,7 +399,7 @@ const SyncOnboardingCompanion: React.FC = ({ default: break; } - }, [deviceOnboardingState, hasBackupStep, notifySyncOnboardingShouldReset]); + }, [deviceOnboardingState, notifySyncOnboardingShouldReset]); // When the user gets close to the seed generation step, sets the lost synchronization delay // and timers to a higher value. It avoids having a warning message while the connection is lost diff --git a/apps/ledger-live-desktop/src/renderer/hooks/useAutoRedirectToPostOnboarding/useShouldRedirect.ts b/apps/ledger-live-desktop/src/renderer/hooks/useAutoRedirectToPostOnboarding/useShouldRedirect.ts index d1f9bba4c659..125d2f7831b3 100644 --- a/apps/ledger-live-desktop/src/renderer/hooks/useAutoRedirectToPostOnboarding/useShouldRedirect.ts +++ b/apps/ledger-live-desktop/src/renderer/hooks/useAutoRedirectToPostOnboarding/useShouldRedirect.ts @@ -1,4 +1,3 @@ -import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; import { useSelector } from "react-redux"; import { shouldRedirectToPostOnboardingOrRecoverUpsell } from "@ledgerhq/live-common/postOnboarding/logic/shouldRedirectToPostOnboardingOrRecoverUpsell"; import { DeviceModelId } from "@ledgerhq/types-devices"; @@ -17,12 +16,10 @@ export function useShouldRedirect(): { } { const hasBeenUpsoldRecover = useSelector(hasBeenUpsoldRecoverSelector); const hasRedirectedToPostOnboarding = useSelector(hasBeenRedirectedToPostOnboardingSelector); - const recoverUpsellRedirection = useFeature("recoverUpsellRedirection"); const lastOnboardedDevice = useSelector(lastOnboardedDeviceSelector); return shouldRedirectToPostOnboardingOrRecoverUpsell({ hasBeenUpsoldRecover, hasRedirectedToPostOnboarding, - upsellForTouchScreenDevices: Boolean(recoverUpsellRedirection?.enabled), lastConnectedDevice: lastOnboardedDevice, supportedDeviceModels: [ DeviceModelId.nanoSP, diff --git a/apps/ledger-live-mobile/src/hooks/useAutoRedirectToPostOnboarding/useShouldRedirect.ts b/apps/ledger-live-mobile/src/hooks/useAutoRedirectToPostOnboarding/useShouldRedirect.ts index 2538f615eb3e..271a8e4b6818 100644 --- a/apps/ledger-live-mobile/src/hooks/useAutoRedirectToPostOnboarding/useShouldRedirect.ts +++ b/apps/ledger-live-mobile/src/hooks/useAutoRedirectToPostOnboarding/useShouldRedirect.ts @@ -1,4 +1,3 @@ -import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; import { useSelector } from "react-redux"; import { hasBeenRedirectedToPostOnboardingSelector, @@ -17,12 +16,10 @@ export function useShouldRedirect(): { } { const hasBeenUpsoldRecover = useSelector(hasBeenUpsoldProtectSelector); const hasRedirectedToPostOnboarding = useSelector(hasBeenRedirectedToPostOnboardingSelector); - const recoverUpsellRedirection = useFeature("recoverUpsellRedirection"); const lastConnectedDevice = useSelector(lastConnectedDeviceSelector); return shouldRedirectToPostOnboardingOrRecoverUpsell({ hasBeenUpsoldRecover, hasRedirectedToPostOnboarding, - upsellForTouchScreenDevices: Boolean(recoverUpsellRedirection?.enabled), lastConnectedDevice, supportedDeviceModels: [DeviceModelId.nanoX, DeviceModelId.stax, DeviceModelId.europa], }); diff --git a/apps/ledger-live-mobile/src/screens/SyncOnboarding/SyncOnboardingCompanion.tsx b/apps/ledger-live-mobile/src/screens/SyncOnboarding/SyncOnboardingCompanion.tsx index 9a3a9e9e3133..1f0679e48939 100644 --- a/apps/ledger-live-mobile/src/screens/SyncOnboarding/SyncOnboardingCompanion.tsx +++ b/apps/ledger-live-mobile/src/screens/SyncOnboarding/SyncOnboardingCompanion.tsx @@ -38,7 +38,6 @@ import { TrackScreen, screen } from "~/analytics"; import ContinueOnStax from "./assets/ContinueOnStax"; import ContinueOnEuropa from "./assets/ContinueOnEuropa"; import type { SyncOnboardingScreenProps } from "./SyncOnboardingScreenProps"; -import BackupStep from "./companionSteps/BackupStep"; import { useIsFocused } from "@react-navigation/native"; import { useKeepScreenAwake } from "~/hooks/useKeepScreenAwake"; @@ -148,8 +147,6 @@ export const SyncOnboardingCompanion: React.FC = ( const { t } = useTranslation(); const dispatchRedux = useDispatch(); const deviceInitialApps = useFeature("deviceInitialApps"); - const recoverUpsellRedirection = useFeature("recoverUpsellRedirection"); - const hasBackupStep = !recoverUpsellRedirection?.enabled; const productName = getDeviceModel(device.modelId).productName || device.modelId; const deviceName = device.deviceName || productName; @@ -367,28 +364,16 @@ export const SyncOnboardingCompanion: React.FC = ( // When the device is seeded, there are 2 cases before triggering the applications install step: // - the user came to the sync onboarding with an non-seeded device and did a full onboarding: onboarding flag `Ready` // - the user came to the sync onboarding with an already seeded device: onboarding flag `WelcomeScreen1` - - if (deviceOnboardingState?.isOnboarded && !seededDeviceHandled.current) { - if (deviceOnboardingState?.currentOnboardingStep === DeviceOnboardingStep.Ready) { - // device was just seeded - setCompanionStepKey(hasBackupStep ? CompanionStepKey.Backup : CompanionStepKey.Apps); - seededDeviceHandled.current = true; - return; - } else if ( - deviceOnboardingState?.currentOnboardingStep === DeviceOnboardingStep.WelcomeScreen1 - ) { - // device was already seeded - if (hasBackupStep) { - __DEV__ - ? setCompanionStepKey(CompanionStepKey.Backup) // for ease of testing in dev mode without having to reset the device - : setCompanionStepKey(CompanionStepKey.Apps); - } else { - // switch to the apps step - setCompanionStepKey(CompanionStepKey.Apps); - } - seededDeviceHandled.current = true; - return; - } + if ( + deviceOnboardingState?.isOnboarded && + !seededDeviceHandled.current && + [DeviceOnboardingStep.Ready, DeviceOnboardingStep.WelcomeScreen1].includes( + deviceOnboardingState.currentOnboardingStep, + ) + ) { + setCompanionStepKey(CompanionStepKey.Apps); + seededDeviceHandled.current = true; + return; } // case DeviceOnboardingStep.SafetyWarning not handled so the previous step (new seed, restore, recover) is kept @@ -439,12 +424,7 @@ export const SyncOnboardingCompanion: React.FC = ( default: break; } - }, [ - deviceOnboardingState, - notifyEarlySecurityCheckShouldReset, - hasBackupStep, - shouldRestoreApps, - ]); + }, [deviceOnboardingState, notifyEarlySecurityCheckShouldReset, shouldRestoreApps]); // When the user gets close to the seed generation step, sets the lost synchronization delay // and timers to a higher value. It avoids having a warning message while the connection is lost @@ -649,21 +629,6 @@ export const SyncOnboardingCompanion: React.FC = ( ), }, - ...(hasBackupStep - ? [ - { - key: CompanionStepKey.Backup, - title: t("syncOnboarding.backup.title"), - doneTitle: t("syncOnboarding.backup.title"), - renderBody: () => ( - setCompanionStepKey(CompanionStepKey.Apps)} - /> - ), - }, - ] - : []), ...(deviceInitialApps?.enabled ? [ { @@ -697,7 +662,6 @@ export const SyncOnboardingCompanion: React.FC = ( [ t, productName, - hasBackupStep, deviceInitialApps?.enabled, device, seedPathStatus, diff --git a/apps/ledger-live-mobile/src/screens/SyncOnboarding/companionSteps/BackupStep.tsx b/apps/ledger-live-mobile/src/screens/SyncOnboarding/companionSteps/BackupStep.tsx deleted file mode 100644 index 4a2ef48cb529..000000000000 --- a/apps/ledger-live-mobile/src/screens/SyncOnboarding/companionSteps/BackupStep.tsx +++ /dev/null @@ -1,236 +0,0 @@ -import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; -import { Device } from "@ledgerhq/live-common/hw/actions/types"; -import { Flex, Icons, IconsLegacy, Tag, Text, VerticalTimeline } from "@ledgerhq/native-ui"; -import { StorylyInstanceID } from "@ledgerhq/types-live"; -import { useNavigation } from "@react-navigation/core"; -import React, { useCallback, useEffect, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Pressable } from "react-native"; -import styled, { useTheme } from "styled-components/native"; -import Button from "~/components/Button"; -import Stories from "~/components/StorylyStories/index"; -import Link from "~/components/wrappedUi/Link"; -import { NavigatorName, ScreenName } from "~/const"; -import { SyncOnboardingScreenProps } from "../SyncOnboardingScreenProps"; -import { TrackScreen, track } from "~/analytics"; -import { useDispatch } from "react-redux"; -import { completeOnboarding, setHasOrderedNano, setReadOnlyMode } from "~/actions/settings"; -import { RootNavigation } from "~/components/RootNavigator/types/helpers"; - -const { BodyText, SubtitleText } = VerticalTimeline; - -type BackupStepProps = { - device: Device; - onPressKeepManualBackup(): void; -}; - -type ChoiceBodyProps = { - isOpened: boolean; - device: Device; - onPressKeepManualBackup(): void; -}; - -type Choice = { - id: "backup" | "keep_on_paper"; - icon: React.ReactNode; - getTitle: (selected?: boolean) => string; - tag?: string; - tagColor?: string; - tagType?: React.ComponentProps["type"]; - body: React.ComponentType; -}; - -const ChoiceText = styled(Text).attrs({ - variant: "paragraph", - color: "neutral.c70", -})``; - -type VideoLinkProps = { - onPress(): void; -}; - -const VideoLink: React.FC = ({ onPress }) => { - const { t } = useTranslation(); - return ( - - - {t("syncOnboarding.backup.backupChoice.howItWorksCta")} - - - ); -}; - -const BackupBody: React.FC = ({ isOpened, device }) => { - const { t } = useTranslation(); - const { space } = useTheme(); - const dispatchRedux = useDispatch(); - - const navigation = useNavigation(); - - const servicesConfig = useFeature("protectServicesMobile"); - - const navigateToRecover = useCallback(() => { - //Consider Onboarding as completed when user navigates to recover - dispatchRedux(setReadOnlyMode(false)); - dispatchRedux(setHasOrderedNano(false)); - dispatchRedux(completeOnboarding()); - (navigation as unknown as RootNavigation).reset({ - index: 0, - routes: [ - { - name: NavigatorName.Base, - state: { - routes: [ - { - name: NavigatorName.Main, - }, - ], - }, - }, - ], - }); - - //Open Live App Recover - navigation.navigate(NavigatorName.Base, { - screen: ScreenName.Recover, - params: { - device, - redirectTo: "activate", - // platform: "protect-staging", // TODO: remove this, only for testing in debug - platform: servicesConfig?.params?.protectId, // TODO: reenable this - date: new Date().toISOString(), // adding a date to reload the page in case of same device restored again - }, - }); - }, [device, dispatchRedux, navigation, servicesConfig?.params?.protectId]); - - return isOpened ? ( - - {t("syncOnboarding.backup.backupChoice.description")} - - - ) : null; -}; - -const choices: Choice[] = [ - { - id: "backup", - getTitle: selected => - selected - ? "syncOnboarding.backup.backupChoice.titleSelected" - : "syncOnboarding.backup.backupChoice.title", - icon: , - tag: "syncOnboarding.backup.backupChoice.tag", - tagType: "color", - body: BackupBody, - }, - { - id: "keep_on_paper", - getTitle: () => "syncOnboarding.backup.manualBackup.title", - icon: , - body: KeepOnPaperBody, - }, -]; - -const BackupStep: React.FC = props => { - const { device, onPressKeepManualBackup } = props; - const [choice, setChoice] = useState(null); - const { space, radii } = useTheme(); - - const { t } = useTranslation(); - - useEffect(() => { - if (choice === "backup") { - track("button_clicked", { button: "Ledger Recover", flow: "Device onboarding" }); - } else if (choice === "keep_on_paper") { - track("button_clicked", { button: "Manual Backup", flow: "Device onboarding" }); - } - }, [choice]); - - return ( - - - {t("syncOnboarding.backup.description")} - {choices.map(({ id, getTitle, icon, tag, tagColor, tagType, body: Body }) => ( - setChoice(id)}> - - - - {icon} - - {t(getTitle(choice === id))} - - - {tag && ( - - {t(tag)} - - )} - - - - - ))} - - ); -}; - -export default BackupStep; diff --git a/libs/ledger-live-common/src/featureFlags/defaultFeatures.ts b/libs/ledger-live-common/src/featureFlags/defaultFeatures.ts index 029e2c664990..4ad7411cfbbe 100644 --- a/libs/ledger-live-common/src/featureFlags/defaultFeatures.ts +++ b/libs/ledger-live-common/src/featureFlags/defaultFeatures.ts @@ -516,7 +516,6 @@ export const DEFAULT_FEATURES: Features = { warningVisible: true, }, }, - recoverUpsellRedirection: DEFAULT_FEATURE, llMevProtection: DEFAULT_FEATURE, llmNetworkBasedAddAccountFlow: DEFAULT_FEATURE, llCounterValueGranularitiesRates: { diff --git a/libs/ledger-live-common/src/postOnboarding/logic/shouldRedirectToPostOnboardingOrRecoverUpsell.test.ts b/libs/ledger-live-common/src/postOnboarding/logic/shouldRedirectToPostOnboardingOrRecoverUpsell.test.ts index 5527d91b9c64..19398a37dd5b 100644 --- a/libs/ledger-live-common/src/postOnboarding/logic/shouldRedirectToPostOnboardingOrRecoverUpsell.test.ts +++ b/libs/ledger-live-common/src/postOnboarding/logic/shouldRedirectToPostOnboardingOrRecoverUpsell.test.ts @@ -3,7 +3,6 @@ import { shouldRedirectToPostOnboardingOrRecoverUpsell } from "./shouldRedirectT type Scenario = { device: { modelId: DeviceModelId }; - upsellForTouchScreenDevices: boolean; hasBeenUpsoldRecover: boolean; hasRedirectedToPostOnboarding: boolean; expected: { shouldRedirectToRecoverUpsell: boolean; shouldRedirectToPostOnboarding: boolean }; @@ -19,17 +18,10 @@ const mockedSupportedDeviceModels = [ function testScenarios(scenarios: Scenario[]) { it.each(scenarios)( - "should return $expected for $device and upsell for touch screen devices: $upsellForTouchScreenDevices", - ({ - device, - upsellForTouchScreenDevices, - hasBeenUpsoldRecover, - hasRedirectedToPostOnboarding, - expected, - }) => { + "should return $expected for $device and upsell for touch screen devices", + ({ device, hasBeenUpsoldRecover, hasRedirectedToPostOnboarding, expected }) => { const result = shouldRedirectToPostOnboardingOrRecoverUpsell({ lastConnectedDevice: device, - upsellForTouchScreenDevices, hasBeenUpsoldRecover, hasRedirectedToPostOnboarding, supportedDeviceModels: mockedSupportedDeviceModels, @@ -56,52 +48,34 @@ describe("useShouldRedirect", () => { [ { device: { modelId: DeviceModelId.nanoS }, - upsellForTouchScreenDevices: false, expected: { shouldRedirectToRecoverUpsell: false, shouldRedirectToPostOnboarding: true }, }, { device: { modelId: DeviceModelId.nanoS }, - upsellForTouchScreenDevices: true, expected: { shouldRedirectToRecoverUpsell: false, shouldRedirectToPostOnboarding: true }, }, { device: { modelId: DeviceModelId.nanoSP }, - upsellForTouchScreenDevices: false, expected: { shouldRedirectToRecoverUpsell: true, shouldRedirectToPostOnboarding: false }, }, { device: { modelId: DeviceModelId.nanoSP }, - upsellForTouchScreenDevices: true, expected: { shouldRedirectToRecoverUpsell: true, shouldRedirectToPostOnboarding: false }, }, { device: { modelId: DeviceModelId.nanoX }, - upsellForTouchScreenDevices: false, expected: { shouldRedirectToRecoverUpsell: true, shouldRedirectToPostOnboarding: false }, }, { device: { modelId: DeviceModelId.nanoX }, - upsellForTouchScreenDevices: true, expected: { shouldRedirectToRecoverUpsell: true, shouldRedirectToPostOnboarding: false }, }, { device: { modelId: DeviceModelId.stax }, - upsellForTouchScreenDevices: false, - expected: { shouldRedirectToRecoverUpsell: false, shouldRedirectToPostOnboarding: true }, - }, - { - device: { modelId: DeviceModelId.stax }, - upsellForTouchScreenDevices: true, expected: { shouldRedirectToRecoverUpsell: true, shouldRedirectToPostOnboarding: false }, }, { device: { modelId: DeviceModelId.europa }, - upsellForTouchScreenDevices: false, - expected: { shouldRedirectToRecoverUpsell: false, shouldRedirectToPostOnboarding: true }, - }, - { - device: { modelId: DeviceModelId.europa }, - upsellForTouchScreenDevices: true, expected: { shouldRedirectToRecoverUpsell: true, shouldRedirectToPostOnboarding: false }, }, ].map(scenario => ({ ...scenario, ...params })), @@ -116,20 +90,17 @@ describe("useShouldRedirect", () => { DeviceModelId.stax, DeviceModelId.europa, ].forEach(modelId => { - [true, false].forEach(upsellForTouchScreenDevices => - testScenarios([ - { - device: { modelId }, - upsellForTouchScreenDevices, - expected: { - shouldRedirectToRecoverUpsell: false, - shouldRedirectToPostOnboarding: true, - }, - hasBeenUpsoldRecover: true, - hasRedirectedToPostOnboarding: false, + testScenarios([ + { + device: { modelId }, + expected: { + shouldRedirectToRecoverUpsell: false, + shouldRedirectToPostOnboarding: true, }, - ]), - ); + hasBeenUpsoldRecover: true, + hasRedirectedToPostOnboarding: false, + }, + ]); }); }); @@ -141,20 +112,17 @@ describe("useShouldRedirect", () => { DeviceModelId.stax, DeviceModelId.europa, ].forEach(modelId => { - [true, false].forEach(upsellForTouchScreenDevices => - testScenarios([ - { - device: { modelId }, - upsellForTouchScreenDevices, - expected: { - shouldRedirectToRecoverUpsell: false, - shouldRedirectToPostOnboarding: false, - }, - hasBeenUpsoldRecover: true, - hasRedirectedToPostOnboarding: true, + testScenarios([ + { + device: { modelId }, + expected: { + shouldRedirectToRecoverUpsell: false, + shouldRedirectToPostOnboarding: false, }, - ]), - ); + hasBeenUpsoldRecover: true, + hasRedirectedToPostOnboarding: true, + }, + ]); }); }); @@ -167,52 +135,34 @@ describe("useShouldRedirect", () => { [ { device: { modelId: DeviceModelId.nanoS }, - upsellForTouchScreenDevices: false, expected: { shouldRedirectToRecoverUpsell: false, shouldRedirectToPostOnboarding: false }, }, { device: { modelId: DeviceModelId.nanoS }, - upsellForTouchScreenDevices: true, expected: { shouldRedirectToRecoverUpsell: false, shouldRedirectToPostOnboarding: false }, }, { device: { modelId: DeviceModelId.nanoSP }, - upsellForTouchScreenDevices: false, expected: { shouldRedirectToRecoverUpsell: true, shouldRedirectToPostOnboarding: false }, }, { device: { modelId: DeviceModelId.nanoSP }, - upsellForTouchScreenDevices: true, expected: { shouldRedirectToRecoverUpsell: true, shouldRedirectToPostOnboarding: false }, }, { device: { modelId: DeviceModelId.nanoX }, - upsellForTouchScreenDevices: false, expected: { shouldRedirectToRecoverUpsell: true, shouldRedirectToPostOnboarding: false }, }, { device: { modelId: DeviceModelId.nanoX }, - upsellForTouchScreenDevices: true, expected: { shouldRedirectToRecoverUpsell: true, shouldRedirectToPostOnboarding: false }, }, { device: { modelId: DeviceModelId.stax }, - upsellForTouchScreenDevices: false, - expected: { shouldRedirectToRecoverUpsell: false, shouldRedirectToPostOnboarding: false }, - }, - { - device: { modelId: DeviceModelId.stax }, - upsellForTouchScreenDevices: true, expected: { shouldRedirectToRecoverUpsell: true, shouldRedirectToPostOnboarding: false }, }, { device: { modelId: DeviceModelId.europa }, - upsellForTouchScreenDevices: false, - expected: { shouldRedirectToRecoverUpsell: false, shouldRedirectToPostOnboarding: false }, - }, - { - device: { modelId: DeviceModelId.europa }, - upsellForTouchScreenDevices: true, expected: { shouldRedirectToRecoverUpsell: true, shouldRedirectToPostOnboarding: false }, }, ].map(scenario => ({ ...scenario, ...params })), diff --git a/libs/ledger-live-common/src/postOnboarding/logic/shouldRedirectToPostOnboardingOrRecoverUpsell.ts b/libs/ledger-live-common/src/postOnboarding/logic/shouldRedirectToPostOnboardingOrRecoverUpsell.ts index 7ef2c4bed9e5..3da22282fbf9 100644 --- a/libs/ledger-live-common/src/postOnboarding/logic/shouldRedirectToPostOnboardingOrRecoverUpsell.ts +++ b/libs/ledger-live-common/src/postOnboarding/logic/shouldRedirectToPostOnboardingOrRecoverUpsell.ts @@ -4,27 +4,19 @@ import { DeviceModelId } from "@ledgerhq/types-devices"; export function shouldRedirectToPostOnboardingOrRecoverUpsell({ hasBeenUpsoldRecover, hasRedirectedToPostOnboarding, - upsellForTouchScreenDevices, lastConnectedDevice, supportedDeviceModels, }: { hasBeenUpsoldRecover: boolean; hasRedirectedToPostOnboarding: boolean; - upsellForTouchScreenDevices: boolean; lastConnectedDevice: Device; supportedDeviceModels: DeviceModelId[]; }): { shouldRedirectToRecoverUpsell: boolean; shouldRedirectToPostOnboarding: boolean; } { - const eligibleDevicesForUpsell = upsellForTouchScreenDevices - ? supportedDeviceModels - : supportedDeviceModels.filter( - model => ![DeviceModelId.europa, DeviceModelId.stax].includes(model), - ); - const eligibleForUpsell = lastConnectedDevice?.modelId - ? eligibleDevicesForUpsell.includes(lastConnectedDevice.modelId) + ? supportedDeviceModels.includes(lastConnectedDevice.modelId) : false; const shouldRedirectToRecoverUpsell = !hasBeenUpsoldRecover && eligibleForUpsell; diff --git a/libs/ledgerjs/packages/types-live/src/feature.ts b/libs/ledgerjs/packages/types-live/src/feature.ts index 54960fc27da4..cda9e88b5673 100644 --- a/libs/ledgerjs/packages/types-live/src/feature.ts +++ b/libs/ledgerjs/packages/types-live/src/feature.ts @@ -198,7 +198,6 @@ export type Features = CurrencyFeatures & { llmMemoTag: Feature_MemoTag; lldMemoTag: Feature_MemoTag; ldmkTransport: Feature_LdmkTransport; - recoverUpsellRedirection: Feature_RecoverUpsellRedirection; llMevProtection: DefaultFeature; llmNetworkBasedAddAccountFlow: DefaultFeature; llCounterValueGranularitiesRates: Feature_LlCounterValueGranularitiesRates; @@ -568,7 +567,6 @@ export type Feature_lldNftsGalleryNewArch = DefaultFeature; export type Feature_lldnewArchOrdinals = DefaultFeature; export type Feature_SpamFilteringTx = DefaultFeature; export type Feature_MemoTag = DefaultFeature; -export type Feature_RecoverUpsellRedirection = DefaultFeature; export type Feature_LlmRebornLP = Feature<{ variant: ABTestingVariants; From 2bb5bd2ffe2fd19ef88c19560193a9366456fdf2 Mon Sep 17 00:00:00 2001 From: Victor Alber Date: Thu, 28 Nov 2024 16:32:44 +0100 Subject: [PATCH 06/13] =?UTF-8?q?fix:=20=F0=9F=A6=BA=20Removing=20data-tes?= =?UTF-8?q?tID=20+=20update=20account=20for=20counter=20value=20e2e=20test?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/renderer/screens/dashboard/AssetDistribution/Row.tsx | 1 - apps/ledger-live-desktop/tests/page/portfolio.page.ts | 2 +- apps/ledger-live-desktop/tests/specs/speculos/settings.spec.ts | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/ledger-live-desktop/src/renderer/screens/dashboard/AssetDistribution/Row.tsx b/apps/ledger-live-desktop/src/renderer/screens/dashboard/AssetDistribution/Row.tsx index eaffeed3cbb5..c4cdb056cfb6 100644 --- a/apps/ledger-live-desktop/src/renderer/screens/dashboard/AssetDistribution/Row.tsx +++ b/apps/ledger-live-desktop/src/renderer/screens/dashboard/AssetDistribution/Row.tsx @@ -147,7 +147,6 @@ const Row = ({ item: { currency, amount, distribution }, isVisible }: Props) => {distribution ? ( this.page.getByTestId(`asset-row-${asset.toLowerCase()}`); private assetRowValue = (asset: string) => - this.page.getByTestId(`asset-row-${asset.toLowerCase()}-value`); + this.page.getByTestId(`asset-row-${asset.toLowerCase()}`).locator("//div[position()=5]"); private operationRows = this.page.locator("[data-testid^='operation-row-']"); private totalBalance = this.page.getByTestId("total-balance"); private balanceDiff = this.page.getByTestId("balance-diff"); diff --git a/apps/ledger-live-desktop/tests/specs/speculos/settings.spec.ts b/apps/ledger-live-desktop/tests/specs/speculos/settings.spec.ts index 59aa73be5b17..be183cc26793 100644 --- a/apps/ledger-live-desktop/tests/specs/speculos/settings.spec.ts +++ b/apps/ledger-live-desktop/tests/specs/speculos/settings.spec.ts @@ -85,7 +85,7 @@ test.describe("Password", () => { }); test.describe("counter value selection", () => { - const account = Account.ETH_1; + const account = Account.BTC_NATIVE_SEGWIT_1; test.use({ userdata: "skip-onboarding", cliCommands: [ From 22a775b7a5448329acf6a8f37b67d0b3acad73b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A9phane=20Prohaszka?= <104785785+sprohaszka-ledger@users.noreply.github.com> Date: Fri, 29 Nov 2024 10:17:10 +0100 Subject: [PATCH 07/13] [ADA] Add extract derivationPath from Account for Swap (#8493) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ada): add cardano extract derivationpath logic Signed-off-by: Stéphane Prohaszka * chore: remove unused code Signed-off-by: Stéphane Prohaszka --------- Signed-off-by: Stéphane Prohaszka --- .../src/families/cardano/exchange.ts | 16 ++++++++++++++++ .../ledger-live-common/src/generated/exchange.ts | 2 ++ 2 files changed, 18 insertions(+) create mode 100644 libs/ledger-live-common/src/families/cardano/exchange.ts diff --git a/libs/ledger-live-common/src/families/cardano/exchange.ts b/libs/ledger-live-common/src/families/cardano/exchange.ts new file mode 100644 index 000000000000..9b17d06be062 --- /dev/null +++ b/libs/ledger-live-common/src/families/cardano/exchange.ts @@ -0,0 +1,16 @@ +import { bip32asBuffer } from "../../crypto"; + +const getSerializedAddressParameters = ( + path: string, +): { + addressParameters: Buffer; +} => { + const addressPath = bip32asBuffer(path); + return { + addressParameters: addressPath, + }; +}; + +export default { + getSerializedAddressParameters, +}; diff --git a/libs/ledger-live-common/src/generated/exchange.ts b/libs/ledger-live-common/src/generated/exchange.ts index 4069d0b18fe4..dfc9bfc0b732 100644 --- a/libs/ledger-live-common/src/generated/exchange.ts +++ b/libs/ledger-live-common/src/generated/exchange.ts @@ -1,4 +1,5 @@ import bitcoin from "../families/bitcoin/exchange"; +import cardano from "../families/cardano/exchange"; import evm from "../families/evm/exchange"; import polkadot from "../families/polkadot/exchange"; import solana from "../families/solana/exchange"; @@ -10,6 +11,7 @@ import xrp from "../families/xrp/exchange"; export default { bitcoin, + cardano, evm, polkadot, solana, From c45ee457a9f5500ae42f2a8fb7f0cfb7926f319b Mon Sep 17 00:00:00 2001 From: Canestin Ndong <100531716+Canestin@users.noreply.github.com> Date: Fri, 29 Nov 2024 10:37:39 +0100 Subject: [PATCH 08/13] feat: add mev protection for broadcasting (#8375) * feat: add mev protection * add mev protection to swaps * add changeset * use the params parameter of axios * versioning correction * mev param documentation * add unit tests * add test * fix tests * fix test * fix test --- .changeset/few-toes-drive.md | 9 ++ .../Send/steps/GenericStepConnectDevice.tsx | 5 +- .../Swap2/Form/ExchangeDrawer/SwapAction.tsx | 3 + .../src/logic/screenTransactionHooks.ts | 22 ++- .../Platform/exchange/CompleteExchange.tsx | 5 +- .../screens/Swap/Form/Modal/Confirmation.tsx | 5 +- .../unit/api/node/ledger.unit.test.ts | 20 +++ .../src/__tests__/unit/broadcast.unit.test.ts | 129 +++++++++++++++++- .../coin-evm/src/api/node/ledger.ts | 6 +- .../coin-evm/src/api/node/types.ts | 8 +- libs/coin-modules/coin-evm/src/broadcast.ts | 3 +- .../src/hooks/useBroadcast.ts | 14 +- .../packages/types-live/src/bridge.ts | 5 + 13 files changed, 216 insertions(+), 18 deletions(-) create mode 100644 .changeset/few-toes-drive.md diff --git a/.changeset/few-toes-drive.md b/.changeset/few-toes-drive.md new file mode 100644 index 000000000000..63879489940e --- /dev/null +++ b/.changeset/few-toes-drive.md @@ -0,0 +1,9 @@ +--- +"@ledgerhq/types-live": minor +"@ledgerhq/coin-evm": minor +"ledger-live-desktop": minor +"live-mobile": minor +"@ledgerhq/live-common": minor +--- + +add mev protection diff --git a/apps/ledger-live-desktop/src/renderer/modals/Send/steps/GenericStepConnectDevice.tsx b/apps/ledger-live-desktop/src/renderer/modals/Send/steps/GenericStepConnectDevice.tsx index 9b9b41a4e35e..1f2ea48e31b3 100644 --- a/apps/ledger-live-desktop/src/renderer/modals/Send/steps/GenericStepConnectDevice.tsx +++ b/apps/ledger-live-desktop/src/renderer/modals/Send/steps/GenericStepConnectDevice.tsx @@ -1,6 +1,6 @@ import React, { useMemo } from "react"; import { Trans } from "react-i18next"; -import { useDispatch } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; import { Device } from "@ledgerhq/live-common/hw/actions/types"; import DeviceAction from "~/renderer/components/DeviceAction"; import StepProgress from "~/renderer/components/StepProgress"; @@ -12,6 +12,7 @@ import { getEnv } from "@ledgerhq/live-env"; import { mockedEventEmitter } from "~/renderer/components/debug/DebugMock"; import { DeviceBlocker } from "~/renderer/components/DeviceAction/DeviceBlocker"; import { closeModal } from "~/renderer/actions/modals"; +import { mevProtectionSelector } from "~/renderer/reducers/settings"; import connectApp from "@ledgerhq/live-common/hw/connectApp"; const action = createAction(getEnv("MOCK") ? mockedEventEmitter : connectApp); const Result = ( @@ -55,10 +56,12 @@ export default function StepConnectDevice({ onConfirmationHandler?: Function; onFailHandler?: Function; }) { + const mevProtected = useSelector(mevProtectionSelector); const dispatch = useDispatch(); const broadcast = useBroadcast({ account, parentAccount, + broadcastConfig: { mevProtected }, }); const tokenCurrency = (account && account.type === "TokenAccount" && account.token) || undefined; const request = useMemo( diff --git a/apps/ledger-live-desktop/src/renderer/screens/exchange/Swap2/Form/ExchangeDrawer/SwapAction.tsx b/apps/ledger-live-desktop/src/renderer/screens/exchange/Swap2/Form/ExchangeDrawer/SwapAction.tsx index b5c5c6411246..a260b8a66c53 100644 --- a/apps/ledger-live-desktop/src/renderer/screens/exchange/Swap2/Form/ExchangeDrawer/SwapAction.tsx +++ b/apps/ledger-live-desktop/src/renderer/screens/exchange/Swap2/Form/ExchangeDrawer/SwapAction.tsx @@ -19,6 +19,7 @@ import { mockedEventEmitter } from "~/renderer/components/debug/DebugMock"; import DeviceAction from "~/renderer/components/DeviceAction"; import Text from "~/renderer/components/Text"; import { getCurrentDevice } from "~/renderer/reducers/devices"; +import { mevProtectionSelector } from "~/renderer/reducers/settings"; import connectApp from "@ledgerhq/live-common/hw/connectApp"; import initSwap from "@ledgerhq/live-common/exchange/swap/initSwap"; import { Device } from "@ledgerhq/types-devices"; @@ -72,6 +73,7 @@ export default function SwapAction({ }: Props) { const [initData, setInitData] = useState(null); const [signedOperation, setSignedOperation] = useState(null); + const mevProtected = useSelector(mevProtectionSelector); const device = useSelector(getCurrentDevice); const deviceRef = useRef(device); const { account: fromAccount, parentAccount: fromParentAccount } = swapTransaction.swap.from; @@ -83,6 +85,7 @@ export default function SwapAction({ const broadcast = useBroadcast({ account: fromAccount, parentAccount: fromParentAccount, + broadcastConfig: { mevProtected }, }); const exchange = useMemo( diff --git a/apps/ledger-live-mobile/src/logic/screenTransactionHooks.ts b/apps/ledger-live-mobile/src/logic/screenTransactionHooks.ts index be7cafaa0ff8..1d39a05b9231 100644 --- a/apps/ledger-live-mobile/src/logic/screenTransactionHooks.ts +++ b/apps/ledger-live-mobile/src/logic/screenTransactionHooks.ts @@ -5,7 +5,13 @@ import { useState, useCallback, useEffect, useRef } from "react"; import { Platform } from "react-native"; import { log } from "@ledgerhq/logs"; import { useRoute, useNavigation } from "@react-navigation/native"; -import type { Account, AccountLike, SignedOperation, Operation } from "@ledgerhq/types-live"; +import type { + Account, + AccountLike, + SignedOperation, + Operation, + BroadcastConfig, +} from "@ledgerhq/types-live"; import type { Transaction } from "@ledgerhq/live-common/generated/types"; import { UserRefusedOnDevice } from "@ledgerhq/errors"; import { getMainAccount } from "@ledgerhq/live-common/account/helpers"; @@ -22,7 +28,7 @@ import { formatTransaction } from "@ledgerhq/live-common/transaction/index"; import { getAccountBridge } from "@ledgerhq/live-common/bridge/index"; import { execAndWaitAtLeast } from "@ledgerhq/live-common/promise"; import { getEnv } from "@ledgerhq/live-env"; -import { useDispatch } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; import { TransactionRefusedOnDevice } from "@ledgerhq/live-common/errors"; import { StackNavigationProp } from "@react-navigation/stack"; import { updateAccountWithUpdater } from "../actions/accounts"; @@ -37,6 +43,7 @@ import type { SendFundsNavigatorStackParamList } from "../components/RootNavigat import type { SignTransactionNavigatorParamList } from "../components/RootNavigator/types/SignTransactionNavigator"; import type { AlgorandClaimRewardsFlowParamList } from "~/families/algorand/Rewards/ClaimRewardsFlow/type"; import type { StellarAddAssetFlowParamList } from "~/families/stellar/AddAssetFlow/types"; +import { mevProtectionSelector } from "~/reducers/settings"; type Navigation = | StackNavigatorNavigation @@ -196,11 +203,13 @@ export const useSignWithDevice = ({ type SignTransactionArgs = { account: AccountLike; parentAccount: Account | null | undefined; + broadcastConfig?: BroadcastConfig; }; export const broadcastSignedTx = async ( account: AccountLike, parentAccount: Account | null | undefined, signedOperation: SignedOperation, + broadcastConfig?: BroadcastConfig, ): Promise => { invariant(account, "account not present"); const mainAccount = getMainAccount(account, parentAccount); @@ -215,6 +224,7 @@ export const broadcastSignedTx = async ( .broadcast({ account: mainAccount, signedOperation, + broadcastConfig, }) .then(op => { log( @@ -227,11 +237,11 @@ export const broadcastSignedTx = async ( }; // TODO move to live-common -function useBroadcast({ account, parentAccount }: SignTransactionArgs) { +function useBroadcast({ account, parentAccount, broadcastConfig }: SignTransactionArgs) { return useCallback( async (signedOperation: SignedOperation): Promise => - broadcastSignedTx(account, parentAccount, signedOperation), - [account, parentAccount], + broadcastSignedTx(account, parentAccount, signedOperation, broadcastConfig), + [account, parentAccount, broadcastConfig], ); } @@ -242,11 +252,13 @@ export function useSignedTxHandler({ account: AccountLike; parentAccount: Account | null | undefined; }) { + const mevProtected = useSelector(mevProtectionSelector); const navigation = useNavigation(); const route = useRoute(); const broadcast = useBroadcast({ account, parentAccount, + broadcastConfig: { mevProtected }, }); const dispatch = useDispatch(); const mainAccount = getMainAccount(account, parentAccount); diff --git a/apps/ledger-live-mobile/src/screens/Platform/exchange/CompleteExchange.tsx b/apps/ledger-live-mobile/src/screens/Platform/exchange/CompleteExchange.tsx index 65b04df8bf44..e148edfa4b66 100644 --- a/apps/ledger-live-mobile/src/screens/Platform/exchange/CompleteExchange.tsx +++ b/apps/ledger-live-mobile/src/screens/Platform/exchange/CompleteExchange.tsx @@ -1,5 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from "react"; import { StyleSheet } from "react-native"; +import { useSelector } from "react-redux"; import { SafeAreaView } from "react-native-safe-area-context"; import { TokenCurrency } from "@ledgerhq/types-cryptoassets"; import { useBroadcast } from "@ledgerhq/live-common/hooks/useBroadcast"; @@ -8,6 +9,7 @@ import { StackNavigatorProps } from "~/components/RootNavigator/types/helpers"; import { PlatformExchangeNavigatorParamList } from "~/components/RootNavigator/types/PlatformExchangeNavigator"; import { ScreenName } from "~/const"; import { useTransactionDeviceAction, useCompleteExchangeDeviceAction } from "~/hooks/deviceActions"; +import { mevProtectionSelector } from "~/reducers/settings"; import { SignedOperation } from "@ledgerhq/types-live"; import { Transaction } from "@ledgerhq/live-common/generated/types"; @@ -22,12 +24,13 @@ const PlatformCompleteExchange: React.FC = ({ }, navigation, }) => { + const mevProtected = useSelector(mevProtectionSelector); const { fromAccount: account, fromParentAccount: parentAccount } = request.exchange; let tokenCurrency: TokenCurrency | undefined; if (account.type === "TokenAccount") tokenCurrency = account.token; - const broadcast = useBroadcast({ account, parentAccount }); + const broadcast = useBroadcast({ account, parentAccount, broadcastConfig: { mevProtected } }); const [transaction, setTransaction] = useState(); const [signedOperation, setSignedOperation] = useState(); const [error, setError] = useState(); diff --git a/apps/ledger-live-mobile/src/screens/Swap/Form/Modal/Confirmation.tsx b/apps/ledger-live-mobile/src/screens/Swap/Form/Modal/Confirmation.tsx index 33f95ce438a7..127e573defc4 100644 --- a/apps/ledger-live-mobile/src/screens/Swap/Form/Modal/Confirmation.tsx +++ b/apps/ledger-live-mobile/src/screens/Swap/Form/Modal/Confirmation.tsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useState, useMemo, useRef } from "react"; -import { useDispatch } from "react-redux"; +import { useDispatch, useSelector } from "react-redux"; import { StyleSheet, View } from "react-native"; import { useTranslation } from "react-i18next"; import { useNavigation } from "@react-navigation/native"; @@ -31,6 +31,7 @@ import { ScreenName } from "~/const"; import type { SwapNavigatorParamList } from "~/components/RootNavigator/types/SwapNavigator"; import { useInitSwapDeviceAction, useTransactionDeviceAction } from "~/hooks/deviceActions"; import { BigNumber } from "bignumber.js"; +import { mevProtectionSelector } from "~/reducers/settings"; export type DeviceMeta = { result: { installed: InstalledItem[] } | null | undefined; @@ -79,10 +80,12 @@ export function Confirmation({ const [swapData, setSwapData] = useState(null); const [signedOperation, setSignedOperation] = useState(null); + const mevProtected = useSelector(mevProtectionSelector); const dispatch = useDispatch(); const broadcast = useBroadcast({ account: fromAccount, parentAccount: fromParentAccount, + broadcastConfig: { mevProtected }, }); const tokenCurrency = fromAccount && fromAccount.type === "TokenAccount" ? fromAccount.token : null; diff --git a/libs/coin-modules/coin-evm/src/__tests__/unit/api/node/ledger.unit.test.ts b/libs/coin-modules/coin-evm/src/__tests__/unit/api/node/ledger.unit.test.ts index 98064c26fee3..1d4f5b198dca 100644 --- a/libs/coin-modules/coin-evm/src/__tests__/unit/api/node/ledger.unit.test.ts +++ b/libs/coin-modules/coin-evm/src/__tests__/unit/api/node/ledger.unit.test.ts @@ -496,6 +496,26 @@ describe("EVM Family", () => { expect(await LEDGER_API.broadcastTransaction(currency, "0xSigneTx")).toEqual("0xHash"); }); + + it("should include mevProtected=true in the request parameters when specified", async () => { + const mockRequest = jest.spyOn(axios, "request").mockImplementationOnce(async () => ({ + data: { + result: "0xHash", + }, + })); + + await LEDGER_API.broadcastTransaction(currency, "0xSignedTx", { mevProtected: true }); + + expect(mockRequest).toHaveBeenCalledWith( + expect.objectContaining({ + params: expect.objectContaining({ + mevProtected: true, + }), + }), + ); + + mockRequest.mockRestore(); + }); }); describe("getBlockByHeight", () => { diff --git a/libs/coin-modules/coin-evm/src/__tests__/unit/broadcast.unit.test.ts b/libs/coin-modules/coin-evm/src/__tests__/unit/broadcast.unit.test.ts index f23805e0bc5c..bbd68708b650 100644 --- a/libs/coin-modules/coin-evm/src/__tests__/unit/broadcast.unit.test.ts +++ b/libs/coin-modules/coin-evm/src/__tests__/unit/broadcast.unit.test.ts @@ -1,3 +1,4 @@ +import { ethers } from "ethers"; import { encodeNftId } from "@ledgerhq/coin-framework/nft/nftId"; import { encodeERC1155OperationId, @@ -8,7 +9,9 @@ import { getCryptoCurrencyById, getTokenById } from "@ledgerhq/cryptoassets"; import { CryptoCurrency } from "@ledgerhq/types-cryptoassets"; import { Account, TokenAccount } from "@ledgerhq/types-live"; import BigNumber from "bignumber.js"; +import axios from "axios"; import * as API from "../../api/node/rpc.common"; +import LEDGER_API from "../../api/node/ledger"; import broadcast from "../../broadcast"; import buildOptimisticOperation from "../../buildOptimisticOperation"; import { getEstimatedFees } from "../../logic"; @@ -26,6 +29,15 @@ jest.useFakeTimers(); jest.mock("../../config"); const mockGetConfig = jest.mocked(getCoinConfig); +jest.mock("ethers"); +const mockEthers = jest.mocked(ethers); +jest + .spyOn(mockEthers.providers.StaticJsonRpcProvider.prototype, "sendTransaction") + .mockResolvedValue(Promise.resolve({ hash: "0xH4sH" })); + +jest.mock("axios"); +const mockAxios = jest.mocked(axios); + const currency: CryptoCurrency = { ...getCryptoCurrencyById("ethereum"), ethereumLikeInfo: { @@ -44,6 +56,15 @@ const account: Account = makeAccount( ); const mockedBroadcastResponse = "0xH4sH"; +const mockBroadcastTransactions = () => { + jest + .spyOn(API, "broadcastTransaction") + .mockImplementation(async () => mockedBroadcastResponse as any); + jest + .spyOn(LEDGER_API, "broadcastTransaction") + .mockImplementation(async () => mockedBroadcastResponse as any); +}; + describe("EVM Family", () => { beforeAll(() => { mockGetConfig.mockImplementation((): any => { @@ -64,9 +85,7 @@ describe("EVM Family", () => { describe("broadcast.ts", () => { beforeAll(() => { - jest - .spyOn(API, "broadcastTransaction") - .mockImplementation(async () => mockedBroadcastResponse as any); + mockBroadcastTransactions(); }); afterAll(() => { @@ -74,6 +93,110 @@ describe("EVM Family", () => { }); describe("broadcast", () => { + describe("MEV Protection", () => { + beforeAll(() => { + jest.spyOn(LEDGER_API, "broadcastTransaction").mockRestore(); + jest.spyOn(API, "broadcastTransaction").mockRestore(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + afterAll(() => { + mockBroadcastTransactions(); + }); + + const coinTransaction: EvmTransaction = { + amount: new BigNumber(100), + useAllAmount: false, + subAccountId: "id", + recipient: "0x51DF0aF74a0DBae16cB845B46dAF2a35cB1D4168", // michel.eth + feesStrategy: "custom", + family: "evm", + mode: "send", + nonce: 0, + gasLimit: new BigNumber(21000), + chainId: 1, + maxFeePerGas: new BigNumber(100), + maxPriorityFeePerGas: new BigNumber(100), + type: 2, + }; + const broadcastArgs = { + account, + signedOperation: { + operation: buildOptimisticOperation(account, coinTransaction), + signature: "0xS1gn4tUR3", + }, + }; + + it("Ledger node MEV ON/OFF", async () => { + mockGetConfig.mockImplementation((): any => ({ + info: { + node: { + type: "ledger", + }, + }, + })); + + mockAxios.request.mockResolvedValue({ data: mockedBroadcastResponse }); + const axiosSpy = jest.spyOn(mockAxios, "request"); + + // MEV OFF + await broadcast({ + ...broadcastArgs, + broadcastConfig: { mevProtected: false }, + }); + + expect(axiosSpy).toHaveBeenCalled(); + + const requestConfigOff = axiosSpy.mock.calls[0][0] as { params: any }; + const urlParamsOff = new URLSearchParams(requestConfigOff.params).toString(); + + // MEV ON + await broadcast({ + ...broadcastArgs, + broadcastConfig: { mevProtected: true }, + }); + + expect(axiosSpy).toHaveBeenCalledTimes(2); + + const requestConfigOn = axiosSpy.mock.calls[1][0] as { params: any }; + const urlParamsOn = new URLSearchParams(requestConfigOn.params).toString(); + + expect(urlParamsOff).toContain("mevProtected=false"); + expect(urlParamsOn).toContain("mevProtected=true"); + }); + + it("External node MEV ON/OFF", async () => { + mockGetConfig.mockImplementation((): any => ({ + info: { + node: { + type: "external", + uri: "https://my-rpc.com", + }, + }, + })); + + const providerSpy = jest.spyOn(mockEthers.providers, "StaticJsonRpcProvider"); + + // MEV OFF + await broadcast({ + ...broadcastArgs, + broadcastConfig: { mevProtected: false }, + }); + + // MEV ON + await broadcast({ + ...broadcastArgs, + broadcastConfig: { mevProtected: true }, + }); + + expect(providerSpy).toHaveBeenCalledTimes(1); + expect(providerSpy).toHaveBeenNthCalledWith(1, "https://my-rpc.com"); + }); + }); + it("should broadcast the coin transaction and fill the blank in the optimistic transaction", async () => { const coinTransaction: EvmTransaction = { amount: new BigNumber(100), diff --git a/libs/coin-modules/coin-evm/src/api/node/ledger.ts b/libs/coin-modules/coin-evm/src/api/node/ledger.ts index 02598d249ff2..bb70766385b5 100644 --- a/libs/coin-modules/coin-evm/src/api/node/ledger.ts +++ b/libs/coin-modules/coin-evm/src/api/node/ledger.ts @@ -254,10 +254,12 @@ export const getFeeData: NodeApi["getFeeData"] = async (currency, transaction) = /** * Broadcast a serialized transaction and returns its hash + * @param broadcastConfig.mevProtected - Optional flag indicating whether the transaction should be protected against MEV attacks. */ export const broadcastTransaction: NodeApi["broadcastTransaction"] = async ( currency, signedTxHex, + broadcastConfig, ) => { const config = getCoinConfig(currency).info; const { node } = config || /* istanbul ignore next */ {}; @@ -271,8 +273,10 @@ export const broadcastTransaction: NodeApi["broadcastTransaction"] = async ( method: "POST", url: `${getEnv("EXPLORER")}/blockchain/v4/${node.explorerId}/tx/send`, data: { tx: signedTxHex }, + params: { + mevProtected: Boolean(broadcastConfig?.mevProtected), + }, }); - return hash; }; diff --git a/libs/coin-modules/coin-evm/src/api/node/types.ts b/libs/coin-modules/coin-evm/src/api/node/types.ts index 247d6010e1cf..9ff2e619c636 100644 --- a/libs/coin-modules/coin-evm/src/api/node/types.ts +++ b/libs/coin-modules/coin-evm/src/api/node/types.ts @@ -1,5 +1,5 @@ import BigNumber from "bignumber.js"; -import { Account } from "@ledgerhq/types-live"; +import { Account, BroadcastConfig } from "@ledgerhq/types-live"; import { CryptoCurrency } from "@ledgerhq/types-cryptoassets"; import { Transaction as EvmTransaction, FeeData } from "../../types"; import { EvmConfigInfo } from "../../config"; @@ -26,7 +26,11 @@ export type NodeApi = { getTransactionCount: (currency: CryptoCurrency, address: string) => Promise; getGasEstimation: (account: Account, transaction: EvmTransaction) => Promise; getFeeData: (currency: CryptoCurrency, transaction: EvmTransaction) => Promise; - broadcastTransaction: (currency: CryptoCurrency, signedTxHex: string) => Promise; + broadcastTransaction: ( + currency: CryptoCurrency, + signedTxHex: string, + broadcastConfig?: BroadcastConfig, + ) => Promise; getBlockByHeight: ( currency: CryptoCurrency, blockHeight: number | "latest", diff --git a/libs/coin-modules/coin-evm/src/broadcast.ts b/libs/coin-modules/coin-evm/src/broadcast.ts index 910a5016e6e8..29205ed58f7f 100644 --- a/libs/coin-modules/coin-evm/src/broadcast.ts +++ b/libs/coin-modules/coin-evm/src/broadcast.ts @@ -9,9 +9,10 @@ import { getNodeApi } from "./api/node/index"; export const broadcast: AccountBridge["broadcast"] = async ({ account, signedOperation: { signature, operation }, + broadcastConfig, }) => { const nodeApi = getNodeApi(account.currency); - const hash = await nodeApi.broadcastTransaction(account.currency, signature); + const hash = await nodeApi.broadcastTransaction(account.currency, signature, broadcastConfig); return patchOperationWithHash(operation, hash); }; diff --git a/libs/ledger-live-common/src/hooks/useBroadcast.ts b/libs/ledger-live-common/src/hooks/useBroadcast.ts index dd43d701c291..2fc453fbc125 100644 --- a/libs/ledger-live-common/src/hooks/useBroadcast.ts +++ b/libs/ledger-live-common/src/hooks/useBroadcast.ts @@ -1,6 +1,12 @@ import { useCallback } from "react"; import { log } from "@ledgerhq/logs"; -import type { SignedOperation, Operation, AccountLike, Account } from "@ledgerhq/types-live"; +import type { + SignedOperation, + Operation, + AccountLike, + Account, + BroadcastConfig, +} from "@ledgerhq/types-live"; import { getEnv } from "@ledgerhq/live-env"; import { formatOperation, getMainAccount } from "../account/index"; import { getAccountBridge } from "../bridge/index"; @@ -9,9 +15,10 @@ import { execAndWaitAtLeast } from "../promise"; type SignTransactionArgs = { account?: AccountLike | null; parentAccount?: Account | null; + broadcastConfig?: BroadcastConfig; }; -export const useBroadcast = ({ account, parentAccount }: SignTransactionArgs) => { +export const useBroadcast = ({ account, parentAccount, broadcastConfig }: SignTransactionArgs) => { const broadcast = useCallback( async (signedOperation: SignedOperation): Promise => { if (!account) throw new Error("account not present"); @@ -26,6 +33,7 @@ export const useBroadcast = ({ account, parentAccount }: SignTransactionArgs) => const operation = await bridge.broadcast({ account: mainAccount, signedOperation, + broadcastConfig, }); log( "transaction-summary", @@ -34,7 +42,7 @@ export const useBroadcast = ({ account, parentAccount }: SignTransactionArgs) => return operation; }); }, - [account, parentAccount], + [account, parentAccount, broadcastConfig], ); return broadcast; diff --git a/libs/ledgerjs/packages/types-live/src/bridge.ts b/libs/ledgerjs/packages/types-live/src/bridge.ts index d730a207ecf1..5dd0ba17d2dd 100644 --- a/libs/ledgerjs/packages/types-live/src/bridge.ts +++ b/libs/ledgerjs/packages/types-live/src/bridge.ts @@ -48,12 +48,17 @@ export type PreloadStrategy = Partial<{ preloadMaxAge: number; }>; +export type BroadcastConfig = { + mevProtected: boolean; +}; + /** * */ export type BroadcastArg = { account: A; signedOperation: SignedOperation; + broadcastConfig?: BroadcastConfig; }; /** From 90b295032fed0420e44effc6446f8ca2e209c600 Mon Sep 17 00:00:00 2001 From: Louis Paquet Date: Fri, 29 Nov 2024 10:49:58 +0100 Subject: [PATCH 09/13] bugfix(LIVE-15002): fix swapId overflow swap history (#8523) fix(LIVE-15002): fix swapId overflow swap history --- .changeset/honest-feet-trade.md | 5 +++++ .../src/renderer/drawers/SwapOperationDetails/index.tsx | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .changeset/honest-feet-trade.md diff --git a/.changeset/honest-feet-trade.md b/.changeset/honest-feet-trade.md new file mode 100644 index 000000000000..77a76d3615a3 --- /dev/null +++ b/.changeset/honest-feet-trade.md @@ -0,0 +1,5 @@ +--- +"ledger-live-desktop": minor +--- + +fix swap Id in history diff --git a/apps/ledger-live-desktop/src/renderer/drawers/SwapOperationDetails/index.tsx b/apps/ledger-live-desktop/src/renderer/drawers/SwapOperationDetails/index.tsx index e917713c05ec..59424e618989 100644 --- a/apps/ledger-live-desktop/src/renderer/drawers/SwapOperationDetails/index.tsx +++ b/apps/ledger-live-desktop/src/renderer/drawers/SwapOperationDetails/index.tsx @@ -255,9 +255,9 @@ const SwapOperationDetails = ({ - + - {swapId} + From aa7d44a18b42d54c65585a6e6769571f51e5fd2f Mon Sep 17 00:00:00 2001 From: "Valentin D. Pinkman" Date: Fri, 29 Nov 2024 12:06:32 +0100 Subject: [PATCH 10/13] fix(lld): speculos transport --- .changeset/spicy-schools-look.md | 5 +++++ apps/ledger-live-desktop/src/renderer/live-common-setup.ts | 5 ----- 2 files changed, 5 insertions(+), 5 deletions(-) create mode 100644 .changeset/spicy-schools-look.md diff --git a/.changeset/spicy-schools-look.md b/.changeset/spicy-schools-look.md new file mode 100644 index 000000000000..3efbec70043b --- /dev/null +++ b/.changeset/spicy-schools-look.md @@ -0,0 +1,5 @@ +--- +"ledger-live-desktop": patch +--- + +Fix speculos transport diff --git a/apps/ledger-live-desktop/src/renderer/live-common-setup.ts b/apps/ledger-live-desktop/src/renderer/live-common-setup.ts index b0130ba46690..22addafa6fe4 100644 --- a/apps/ledger-live-desktop/src/renderer/live-common-setup.ts +++ b/apps/ledger-live-desktop/src/renderer/live-common-setup.ts @@ -13,7 +13,6 @@ import logger from "./logger"; import { currentMode, setDeviceMode } from "@ledgerhq/live-common/hw/actions/app"; import { getFeature } from "@ledgerhq/live-common/featureFlags/index"; import { FeatureId } from "@ledgerhq/types-live"; -import { getEnv } from "@ledgerhq/live-env"; import { overriddenFeatureFlagsSelector } from "~/renderer/reducers/settings"; import { State } from "./reducers"; import { DeviceManagementKitTransport } from "@ledgerhq/live-dmk"; @@ -31,8 +30,6 @@ const getFeatureWithOverrides = (key: FeatureId, store: Store) => { export function registerTransportModules(store: Store) { setEnvOnAllThreads("USER_ID", getUserId()); const vaultTransportPrefixID = "vault-transport:"; - const isSpeculosEnabled = !!getEnv("SPECULOS_API_PORT"); - const isProxyEnabled = !!getEnv("DEVICE_PROXY_URL"); const ldmkFeatureFlag = getFeatureWithOverrides("ldmkTransport", store); listenLogs(({ id, date, ...log }) => { @@ -44,7 +41,6 @@ export function registerTransportModules(store: Store) { registerTransportModule({ id: "sdk", open: (_id: string, timeoutMs?: number, context?: TraceContext) => { - if (isSpeculosEnabled && isProxyEnabled) return null; trace({ type: "renderer-setup", message: "Open called on registered module", @@ -66,7 +62,6 @@ export function registerTransportModules(store: Store) { registerTransportModule({ id: "ipc", open: (id: string, timeoutMs?: number, context?: TraceContext) => { - if (isSpeculosEnabled || isProxyEnabled) return null; const originalDeviceMode = currentMode; // id could be another type of transport such as vault-transport if (id.startsWith(vaultTransportPrefixID)) return; From e381081aa94d629ca1bee0802b0c95676aee7bdc Mon Sep 17 00:00:00 2001 From: Mounir Hamzaoui Date: Fri, 29 Nov 2024 16:12:26 +0100 Subject: [PATCH 11/13] refactor: copy & rename Receive's reusable step screens for new add account flow (#8377) --- .changeset/eight-ducks-rescue.md | 5 + .../RootNavigator/BaseNavigator.tsx | 33 +- .../RootNavigator/types/BaseNavigator.ts | 27 +- .../src/const/navigation.ts | 9 + .../src/navigation/DeeplinksProvider.tsx | 49 ++- .../newArch/features/Accounts/Navigator.tsx | 60 +++ .../useSelectAddAccountMethodViewModel.ts | 15 +- .../Accounts/screens/AddAccount/types.ts | 15 + .../screens/ScanDeviceAccounts/index.tsx | 367 ++++++++++++++++++ .../Accounts/screens/SelectAccounts/index.tsx | 171 ++++++++ .../features/AssetSelection/Navigator.tsx | 78 ++++ .../screens/SelectCrypto/index.tsx | 163 ++++++++ .../screens/SelectNetwork/index.tsx | 247 ++++++++++++ .../screens/SelectNetwork/types.ts | 4 + .../newArch/features/AssetSelection/types.ts | 32 ++ .../features/DeviceSelection/Navigator.tsx | 94 +++++ .../screens/ConnectDevice/index.tsx | 179 +++++++++ .../screens/SelectDevice/index.tsx | 133 +++++++ .../newArch/features/DeviceSelection/types.ts | 24 ++ 19 files changed, 1673 insertions(+), 32 deletions(-) create mode 100644 .changeset/eight-ducks-rescue.md create mode 100644 apps/ledger-live-mobile/src/newArch/features/Accounts/Navigator.tsx create mode 100644 apps/ledger-live-mobile/src/newArch/features/Accounts/screens/AddAccount/types.ts create mode 100644 apps/ledger-live-mobile/src/newArch/features/Accounts/screens/ScanDeviceAccounts/index.tsx create mode 100644 apps/ledger-live-mobile/src/newArch/features/Accounts/screens/SelectAccounts/index.tsx create mode 100644 apps/ledger-live-mobile/src/newArch/features/AssetSelection/Navigator.tsx create mode 100644 apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectCrypto/index.tsx create mode 100644 apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectNetwork/index.tsx create mode 100644 apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectNetwork/types.ts create mode 100644 apps/ledger-live-mobile/src/newArch/features/AssetSelection/types.ts create mode 100644 apps/ledger-live-mobile/src/newArch/features/DeviceSelection/Navigator.tsx create mode 100644 apps/ledger-live-mobile/src/newArch/features/DeviceSelection/screens/ConnectDevice/index.tsx create mode 100644 apps/ledger-live-mobile/src/newArch/features/DeviceSelection/screens/SelectDevice/index.tsx create mode 100644 apps/ledger-live-mobile/src/newArch/features/DeviceSelection/types.ts diff --git a/.changeset/eight-ducks-rescue.md b/.changeset/eight-ducks-rescue.md new file mode 100644 index 000000000000..fcccba8e9e4b --- /dev/null +++ b/.changeset/eight-ducks-rescue.md @@ -0,0 +1,5 @@ +--- +"live-mobile": minor +--- + +Prepare add account v2 reusable screens diff --git a/apps/ledger-live-mobile/src/components/RootNavigator/BaseNavigator.tsx b/apps/ledger-live-mobile/src/components/RootNavigator/BaseNavigator.tsx index 904848ec7ee2..0d8c92420121 100644 --- a/apps/ledger-live-mobile/src/components/RootNavigator/BaseNavigator.tsx +++ b/apps/ledger-live-mobile/src/components/RootNavigator/BaseNavigator.tsx @@ -91,6 +91,9 @@ import CustomErrorNavigator from "./CustomErrorNavigator"; import WalletSyncNavigator from "LLM/features/WalletSync/WalletSyncNavigator"; import Web3HubNavigator from "LLM/features/Web3Hub/Navigator"; import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; +import AddAccountsV2Navigator from "LLM/features/Accounts/Navigator"; +import DeviceSelectionNavigator from "LLM/features/DeviceSelection/Navigator"; +import AssetSelectionNavigator from "LLM/features/AssetSelection/Navigator"; const Stack = createStackNavigator(); @@ -109,6 +112,7 @@ export default function BaseNavigator() { const isAccountsEmpty = useSelector(hasNoAccountsSelector); const readOnlyModeEnabled = useSelector(readOnlyModeEnabledSelector) && isAccountsEmpty; const web3hub = useFeature("web3hub"); + const llmNetworkBasedAddAccountFlow = useFeature("llmNetworkBasedAddAccountFlow"); return ( <> @@ -267,11 +271,7 @@ export default function BaseNavigator() { component={ClaimRewardsNavigator} options={{ headerShown: false }} /> - + + + ) : null} + + + + ); +} + +function AddingAccountLoading({ currency }: { currency: Currency }) { + const { t } = useTranslation(); + + return ( + + ); +} + +function Loading({ + children, + title, + subtitle, +}: { + children?: React.ReactNode; + title: string; + subtitle?: string; +}) { + const { colors } = useTheme(); + + return ( + <> + + + + + + + {title} + + + {subtitle} + + + {children} + + + ); +} + +export default memo(ScanDeviceAccounts); diff --git a/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/SelectAccounts/index.tsx b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/SelectAccounts/index.tsx new file mode 100644 index 000000000000..431110a4f06c --- /dev/null +++ b/apps/ledger-live-mobile/src/newArch/features/Accounts/screens/SelectAccounts/index.tsx @@ -0,0 +1,171 @@ +import React, { useCallback, useMemo } from "react"; +import { FlatList, ListRenderItemInfo } from "react-native"; +import { useSelector } from "react-redux"; + +import { Button, Flex, Text } from "@ledgerhq/native-ui"; +import { useTranslation } from "react-i18next"; +import { Account, SubAccount, TokenAccount } from "@ledgerhq/types-live"; +import { makeEmptyTokenAccount } from "@ledgerhq/live-common/account/index"; +import { flattenAccountsByCryptoCurrencyScreenSelector } from "~/reducers/accounts"; +import { ScreenName } from "~/const"; +import { track, TrackScreen } from "~/analytics"; + +import { StackNavigatorProps } from "~/components/RootNavigator/types/helpers"; +import AccountCard from "~/components/AccountCard"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; +import { withDiscreetMode } from "~/context/DiscreetModeContext"; +import { walletSelector } from "~/reducers/wallet"; +import { accountNameWithDefaultSelector } from "@ledgerhq/live-wallet/store"; +import { NetworkBasedAddAccountNavigator } from "LLM/features/Accounts/screens/AddAccount/types"; + +type SubAccountEnhanced = SubAccount & { + parentAccount: Account; + triggerCreateAccount: boolean; +}; + +type AccountLikeEnhanced = SubAccountEnhanced | Account | TokenAccount; + +//TODO: (receive flow only) implement this in this ticket https://ledgerhq.atlassian.net/browse/LIVE-14640 +/*type NavigationProps = BaseComposite< + StackNavigatorProps +>;*/ + +function SelectAccount({ + route, +}: StackNavigatorProps) { + const currency = route?.params?.currency; + const { t } = useTranslation(); + //TODO: (receive flow only) implement this in this ticket https://ledgerhq.atlassian.net/browse/LIVE-14640 + //const navigationAccount = useNavigation(); + const insets = useSafeAreaInsets(); + const accounts = useSelector( + currency && currency.type === "CryptoCurrency" + ? flattenAccountsByCryptoCurrencyScreenSelector(currency) + : () => null, + ); + const parentAccounts = useSelector( + currency && currency.type === "TokenCurrency" + ? flattenAccountsByCryptoCurrencyScreenSelector(currency?.parentCurrency) + : () => null, + ); + + const aggregatedAccounts = useMemo( + () => + currency && currency.type === "TokenCurrency" + ? parentAccounts!.reduce((accs, pa) => { + const tokenAccounts = + pa.type === "Account" && pa.subAccounts + ? pa.subAccounts?.filter( + acc => acc.type === "TokenAccount" && acc.token.id === currency.id, + ) + : []; + + if (tokenAccounts && tokenAccounts.length > 0) { + accs.push(...tokenAccounts); + } else if (pa.type === "Account") { + const tokenAcc = makeEmptyTokenAccount(pa, currency); + + const tokenA: SubAccountEnhanced = { + ...tokenAcc, + parentAccount: pa, + triggerCreateAccount: true, + }; + + accs.push(tokenA); + } + + return accs; + }, []) + : accounts, + [accounts, currency, parentAccounts], + ); + + const selectAccount = useCallback((account: AccountLikeEnhanced) => { + // TODO: implement this to support multiple and single selection of accounts (add account / receive) + console.warn("selected account ", account); // TODO: remove this after the implementation in the next ticket + }, []); + + const walletState = useSelector(walletSelector); + + const renderItem = useCallback( + ({ item }: ListRenderItemInfo) => ( + + {accountNameWithDefaultSelector(walletState, item)} + } + onPress={() => selectAccount(item)} + /> + + ), + [walletState, selectAccount], + ); + + const createNewAccount = useCallback(() => { + track("button_clicked", { + button: "Create a new account", + page: "Select account to deposit to", + }); + /** + * TODO: (receive flow only) implement this in this ticket https://ledgerhq.atlassian.net/browse/LIVE-14645 + * if (currency && currency.type === "TokenCurrency") { + navigationAccount.navigate(NavigatorName.AddAccounts, { + screen: undefined, + params: { + token: currency, + }, + }); + } else { + navigationAccount.navigate(NavigatorName.AddAccounts, { + screen: undefined, + currency, + }); + } + */ + }, []); + + const keyExtractor = useCallback((item: AccountLikeEnhanced) => item?.id, []); + + return currency && aggregatedAccounts && aggregatedAccounts.length > 0 ? ( + <> + + + + {t("transfer.receive.selectAccount.title")} + + + {t("transfer.receive.selectAccount.subtitle", { + currencyTicker: currency.ticker, + })} + + + + + + + + + ) : null; +} +export default withDiscreetMode(SelectAccount); diff --git a/apps/ledger-live-mobile/src/newArch/features/AssetSelection/Navigator.tsx b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/Navigator.tsx new file mode 100644 index 000000000000..1a6cd42457fc --- /dev/null +++ b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/Navigator.tsx @@ -0,0 +1,78 @@ +import React, { useCallback, useMemo } from "react"; +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 { getStackNavigatorConfig } from "~/navigation/navigatorConfig"; +import { track } from "~/analytics"; +import { Flex } from "@ledgerhq/native-ui"; +import HelpButton from "~/screens/ReceiveFunds/HelpButton"; +import { useSelector } from "react-redux"; +import { hasClosedNetworkBannerSelector } from "~/reducers/settings"; +import { urls } from "~/utils/urls"; +import SelectCrypto from "LLM/features/AssetSelection/screens/SelectCrypto"; +import SelectNetwork from "LLM/features/AssetSelection/screens/SelectNetwork"; +import { NavigationHeaderCloseButtonAdvanced } from "~/components/NavigationHeaderCloseButton"; +import { NavigationHeaderBackButton } from "~/components/NavigationHeaderBackButton"; +import { AssetSelectionNavigatorParamsList } from "./types"; + +export default function Navigator() { + const { colors } = useTheme(); + const route = useRoute(); + + const hasClosedNetworkBanner = useSelector(hasClosedNetworkBannerSelector); + + const onClose = useCallback(() => { + track("button_clicked", { + button: "Close", + screen: route.name, + }); + }, [route]); + + const stackNavigationConfig = useMemo( + () => ({ + ...getStackNavigatorConfig(colors, true), + headerRight: () => , + }), + [colors, onClose], + ); + + return ( + + , + headerTitle: "", + headerRight: () => , + }} + /> + + , + headerTitle: "", + headerRight: () => ( + + {hasClosedNetworkBanner && ( + + )} + + + ), + }} + /> + + ); +} + +const Stack = createStackNavigator(); 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 new file mode 100644 index 000000000000..9e5bd36b0b15 --- /dev/null +++ b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectCrypto/index.tsx @@ -0,0 +1,163 @@ +import React, { useCallback, useEffect, useMemo } from "react"; +import { Trans, useTranslation } 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 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"; + +const SEARCH_KEYS = getEnv("CRYPTO_ASSET_SEARCH_KEYS"); + +const keyExtractor = (currency: CryptoCurrency | TokenCurrency) => currency.id; + +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 renderList = useCallback( + (items: CryptoOrTokenCurrency[]) => ( + ( + + )} + keyExtractor={keyExtractor} + showsVerticalScrollIndicator={false} + keyboardDismissMode="on-drag" + /> + ), + [onPressItem], + ); + + const list = useMemo( + () => + filterCurrencyIdsSet + ? sortedCryptoCurrencies.filter(crypto => filterCurrencyIdsSet.has(crypto.id)) + : sortedCryptoCurrencies, + [filterCurrencyIdsSet, sortedCryptoCurrencies], + ); + + return ( + + + + {t("transfer.receive.selectCrypto.title")} + + {list.length > 0 ? ( + + + + ) : ( + + + + )} + + ); +} 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 new file mode 100644 index 000000000000..d4787a452db0 --- /dev/null +++ b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectNetwork/index.tsx @@ -0,0 +1,247 @@ +import React, { useCallback, useMemo, useState } 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 { 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"; + +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 { 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 renderItem = useCallback( + ({ item }: { item: CryptoWithAccounts }) => ( + 0 + ? t("transfer.receive.selectNetwork.account", { count: item.accounts.length }) + : "" + } + /> + ), + [onPressItem, t], + ); + + return ( + <> + + + + {t("transfer.receive.selectNetwork.title")} + + + {t("transfer.receive.selectNetwork.subtitle")} + + + + + + {displayBanner ? ( + + + + ) : ( + + + + )} + + ); +} + +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/types.ts b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectNetwork/types.ts new file mode 100644 index 000000000000..19d68b86f759 --- /dev/null +++ b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/screens/SelectNetwork/types.ts @@ -0,0 +1,4 @@ +import { CryptoCurrency } from "@ledgerhq/types-cryptoassets"; +import { AccountLike } from "@ledgerhq/types-live"; + +export type CryptoWithAccounts = { crypto: CryptoCurrency; accounts: AccountLike[] }; diff --git a/apps/ledger-live-mobile/src/newArch/features/AssetSelection/types.ts b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/types.ts new file mode 100644 index 000000000000..833b61018759 --- /dev/null +++ b/apps/ledger-live-mobile/src/newArch/features/AssetSelection/types.ts @@ -0,0 +1,32 @@ +import { CryptoCurrency, CryptoOrTokenCurrency, TokenCurrency } from "@ledgerhq/types-cryptoassets"; +import { NavigatorName, ScreenName } from "~/const"; + +export type AssetSelectionNavigatorParamsList = { + [ScreenName.AddAccountsSelectCrypto]: { + filterCurrencyIds?: string[]; + currency?: string; + }; + [ScreenName.SelectNetwork]: + | { + filterCurrencyIds?: string[]; + provider: { + currenciesByNetwork: CryptoOrTokenCurrency[]; + providerId: string; + }; + } + | undefined; + [NavigatorName.AddAccounts]: { + screen: ScreenName; + params: { + currency: CryptoCurrency | TokenCurrency; + createTokenAccount?: boolean; + }; + }; + [NavigatorName.DeviceSelection]: { + screen: ScreenName; + params: { + currency: CryptoCurrency; + createTokenAccount?: boolean; + }; + }; +}; diff --git a/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/Navigator.tsx b/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/Navigator.tsx new file mode 100644 index 000000000000..72b78053ed57 --- /dev/null +++ b/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/Navigator.tsx @@ -0,0 +1,94 @@ +import React, { useCallback, useMemo } from "react"; +import { Platform } from "react-native"; +import { createStackNavigator } from "@react-navigation/stack"; +import { useTheme } from "styled-components/native"; +import { useTranslation } from "react-i18next"; +import { NavigationProp, useRoute } from "@react-navigation/native"; +import { ScreenName } from "~/const"; +import { getStackNavigatorConfig } from "~/navigation/navigatorConfig"; +import { track } from "~/analytics"; +import SelectDevice, { + addAccountsSelectDeviceHeaderOptions, +} from "LLM/features/DeviceSelection/screens/SelectDevice"; +import ConnectDevice, { + connectDeviceHeaderOptions, +} from "LLM/features/DeviceSelection/screens/ConnectDevice"; +import StepHeader from "~/components/StepHeader"; +import { NavigationHeaderCloseButtonAdvanced } from "~/components/NavigationHeaderCloseButton"; +import { DeviceSelectionNavigatorParamsList } from "./types"; + +export default function Navigator() { + const { colors } = useTheme(); + const { t } = useTranslation(); + const route = useRoute(); + + const onClose = useCallback(() => { + track("button_clicked", { + button: "Close", + screen: route.name, + }); + }, [route]); + + const stackNavigationConfig = useMemo( + () => ({ + ...getStackNavigatorConfig(colors, true), + headerRight: () => , + }), + [colors, onClose], + ); + + const onConnectDeviceBack = useCallback((navigation: NavigationProp>) => { + track("button_clicked", { + button: "Back arrow", + page: ScreenName.ConnectDevice, + }); + navigation.goBack(); + }, []); + + return ( + + {/* Select Device */} + ( + + ), + ...addAccountsSelectDeviceHeaderOptions(onClose), + }} + /> + + {/* Select / Connect Device */} + ({ + headerTitle: () => ( + + ), + ...connectDeviceHeaderOptions(() => onConnectDeviceBack(navigation)), + })} + /> + + ); +} + +const Stack = createStackNavigator(); diff --git a/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/screens/ConnectDevice/index.tsx b/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/screens/ConnectDevice/index.tsx new file mode 100644 index 000000000000..ee3400828799 --- /dev/null +++ b/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/screens/ConnectDevice/index.tsx @@ -0,0 +1,179 @@ +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { View, StyleSheet } from "react-native"; +import { useSelector } from "react-redux"; + +import { Flex } from "@ledgerhq/native-ui"; +import { + getAccountCurrency, + getMainAccount, + getReceiveFlowError, +} from "@ledgerhq/live-common/account/index"; +import type { Device } from "@ledgerhq/live-common/hw/actions/types"; + +import { accountScreenSelector } from "~/reducers/accounts"; +import { ScreenName } from "~/const"; +import { TrackScreen, track } from "~/analytics"; +import SelectDevice2, { SetHeaderOptionsRequest } from "~/components/SelectDevice2"; +import { readOnlyModeEnabledSelector } from "~/reducers/settings"; +import GenericErrorView from "~/components/GenericErrorView"; +import DeviceActionModal from "~/components/DeviceActionModal"; +// TODO: use byFamily in the next feature for device connection (scope Add account v2) +//import byFamily from "~/generated/ConnectDevice"; + +import { + ReactNavigationHeaderOptions, + StackNavigatorProps, +} from "~/components/RootNavigator/types/helpers"; +import { NavigationHeaderCloseButton } from "~/components/NavigationHeaderCloseButton"; +import { NavigationHeaderBackButton } from "~/components/NavigationHeaderBackButton"; +import { useAppDeviceAction } from "~/hooks/deviceActions"; +import ReadOnlyWarning from "~/screens/ReceiveFunds/ReadOnlyWarning"; +import NotSyncedWarning from "~/screens/ReceiveFunds/NotSyncedWarning"; +import { DeviceSelectionNavigatorParamsList } from "../../types"; +// TODO: use SkipSelectDevice in the next feature for device connection if needed (scope Add account v2) +//import SkipSelectDevice from "~/screens/SkipSelectDevice"; + +// Defines some of the header options for this screen to be able to reset back to them. +export const connectDeviceHeaderOptions = ( + onHeaderBackButtonPress: () => void, +): ReactNavigationHeaderOptions => ({ + headerRight: () => , + headerLeft: () => , +}); + +export default function ConnectDevice({ + navigation, + route, +}: StackNavigatorProps) { + const { account, parentAccount } = useSelector(accountScreenSelector(route)); + const readOnlyModeEnabled = useSelector(readOnlyModeEnabledSelector); + const [device, setDevice] = useState(); + const action = useAppDeviceAction(); + + useEffect(() => { + const readOnlyTitle = "transfer.receive.titleReadOnly"; + if (readOnlyModeEnabled && route.params?.title !== readOnlyTitle) { + navigation.setParams({ + title: readOnlyTitle, + }); + } + }, [navigation, readOnlyModeEnabled, route.params]); + + const error = useMemo( + () => (account ? getReceiveFlowError(account, parentAccount) : null), + [account, parentAccount], + ); + + const onResult = () => { + // TODO: implement business logic for both Add account v2 and Receive flow + }; + + const onSkipDevice = useCallback(() => { + if (!account) return; + // TODO: implement business logic for both Add account v2 and Receive flow + }, [account]); + + const onClose = useCallback(() => { + setDevice(undefined); + }, []); + + const onHeaderBackButtonPress = useCallback(() => { + track("button_clicked", { + button: "Back arrow", + page: ScreenName.ReceiveConnectDevice, + }); + navigation.goBack(); + }, [navigation]); + + // Reacts from request to update the screen header + const requestToSetHeaderOptions = useCallback( + (request: SetHeaderOptionsRequest) => { + if (request.type === "set") { + navigation.setOptions({ + headerLeft: request.options.headerLeft, + headerRight: request.options.headerRight, + }); + } else { + // Sets back the header to its initial values set for this screen + navigation.setOptions({ + ...connectDeviceHeaderOptions(onHeaderBackButtonPress), + }); + } + }, + [navigation, onHeaderBackButtonPress], + ); + + if (!account) return null; + + if (error) { + return ( + + + + ); + } + + const mainAccount = getMainAccount(account, parentAccount); + const currency = getAccountCurrency(mainAccount); + if (currency.type !== "CryptoCurrency") return null; // this should not happen: currency of main account is a crypto currency + const tokenCurrency = account && account.type === "TokenAccount" ? account.token : undefined; + + // check for coin specific UI + // TODO: implement business logic for both Add account v2 and Receive flow + //const CustomConnectDevice = byFamily[currency.family as keyof typeof byFamily]; + //if (CustomConnectDevice) return ; + + if (readOnlyModeEnabled) { + return ; + } + + if (!mainAccount.freshAddress) { + return ; + } + + return ( + <> + + {/* + * TODO: implement business logic for both Add account v2 and Receive flow + + */} + + + + setDevice(undefined)} + analyticsPropertyFlow="receive" + /> + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + }, + bodyError: { + flex: 1, + flexDirection: "column", + alignSelf: "center", + justifyContent: "center", + alignItems: "center", + paddingBottom: 16, + }, + scroll: { + flex: 1, + }, + scrollContainer: { + padding: 16, + }, +}); diff --git a/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/screens/SelectDevice/index.tsx b/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/screens/SelectDevice/index.tsx new file mode 100644 index 000000000000..91ee1e5311b4 --- /dev/null +++ b/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/screens/SelectDevice/index.tsx @@ -0,0 +1,133 @@ +import React, { useCallback, useEffect, useState } from "react"; +import { StyleSheet, SafeAreaView } from "react-native"; +import { Flex } from "@ledgerhq/native-ui"; +import type { Device } from "@ledgerhq/live-common/hw/actions/types"; +import { useIsFocused, useTheme } from "@react-navigation/native"; +import { prepareCurrency } from "~/bridge/cache"; +import { ScreenName } from "~/const"; +import { track } from "~/analytics"; +import SelectDevice2, { SetHeaderOptionsRequest } from "~/components/SelectDevice2"; +import DeviceActionModal from "~/components/DeviceActionModal"; + +import { + ReactNavigationHeaderOptions, + StackNavigatorProps, +} from "~/components/RootNavigator/types/helpers"; +import { NavigationHeaderCloseButtonAdvanced } from "~/components/NavigationHeaderCloseButton"; +import { NavigationHeaderBackButton } from "~/components/NavigationHeaderBackButton"; +import { useAppDeviceAction } from "~/hooks/deviceActions"; +import { DeviceSelectionNavigatorParamsList } from "../../types"; +import { NetworkBasedAddAccountNavigator } from "~/newArch/features/Accounts/screens/AddAccount/types"; + +// Defines some of the header options for this screen to be able to reset back to them. +export const addAccountsSelectDeviceHeaderOptions = ( + onClose: () => void, +): ReactNavigationHeaderOptions => ({ + headerRight: () => , + headerLeft: () => , +}); + +export default function SelectDevice({ + navigation, + route, +}: StackNavigatorProps< + DeviceSelectionNavigatorParamsList & Partial, + ScreenName.SelectDevice +>) { + const { currency } = route.params; + const { colors } = useTheme(); + const [device, setDevice] = useState(null); + const action = useAppDeviceAction(); + const isFocused = useIsFocused(); + + const onClose = useCallback(() => { + setDevice(null); + }, []); + + const onResult = useCallback( + // @ts-expect-error should be AppResult but navigation.navigate does not agree + meta => { + setDevice(null); + const arg = { ...route.params, ...meta }; + navigation.navigate(ScreenName.ScanDeviceAccounts, arg); + }, + [navigation, route], + ); + + useEffect(() => { + // load ahead of time + prepareCurrency(currency); + }, [currency]); + + const analyticsPropertyFlow = route.params?.analyticsPropertyFlow; + + const onHeaderCloseButton = useCallback(() => { + track("button_clicked", { + button: "Close 'x'", + page: route.name, + }); + }, [route]); + + const requestToSetHeaderOptions = useCallback( + (request: SetHeaderOptionsRequest) => { + if (request.type === "set") { + navigation.setOptions({ + headerShown: true, + headerLeft: request.options.headerLeft, + headerRight: request.options.headerRight, + }); + } else { + // Sets back the header to its initial values set for this screen + navigation.setOptions({ + ...addAccountsSelectDeviceHeaderOptions(onHeaderCloseButton), + }); + } + }, + [navigation, onHeaderCloseButton], + ); + + return ( + + {/* + TODO: should be rendered only on receive flow context -> TO BE DONE After delivering the add account flow + + */} + + + + setDevice(null)} + analyticsPropertyFlow={analyticsPropertyFlow || "add account"} + /> + + ); +} + +const styles = StyleSheet.create({ + root: { + flex: 1, + }, + scroll: { + flex: 1, + backgroundColor: "transparent", + }, + scrollContainer: { + padding: 16, + }, +}); diff --git a/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/types.ts b/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/types.ts new file mode 100644 index 000000000000..6db86661a4b7 --- /dev/null +++ b/apps/ledger-live-mobile/src/newArch/features/DeviceSelection/types.ts @@ -0,0 +1,24 @@ +import { CryptoCurrency } from "@ledgerhq/types-cryptoassets"; +import { AccountLike } from "@ledgerhq/types-live"; +import { ScreenName } from "~/const"; + +export type DeviceSelectionNavigatorParamsList = { + [ScreenName.ConnectDevice]: { + account?: AccountLike; + accountId: string; + parentId?: string; + notSkippable?: boolean; + title?: string; + appName?: string; + onSuccess?: () => void; + onError?: () => void; + }; + [ScreenName.SelectDevice]: { + accountId?: string; + parentId?: string; + currency: CryptoCurrency; + inline?: boolean; + analyticsPropertyFlow?: string; + createTokenAccount?: boolean; + }; +}; From edb39105aabbd5e3115d69e37e33fb6885bf04af Mon Sep 17 00:00:00 2001 From: Victor <162306106+VicAlbr@users.noreply.github.com> Date: Fri, 29 Nov 2024 16:51:12 +0100 Subject: [PATCH 12/13] Update test send NFT to ENS (#8547) test: fix nft test --- apps/ledger-live-desktop/tests/specs/speculos/send.tx.spec.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/ledger-live-desktop/tests/specs/speculos/send.tx.spec.ts b/apps/ledger-live-desktop/tests/specs/speculos/send.tx.spec.ts index 3e5e604ed3df..46541103cc22 100644 --- a/apps/ledger-live-desktop/tests/specs/speculos/send.tx.spec.ts +++ b/apps/ledger-live-desktop/tests/specs/speculos/send.tx.spec.ts @@ -646,6 +646,8 @@ test.describe("Send flows", () => { await app.send.expectTxSent(); await app.account.navigateToViewDetails(); await app.drawer.close(); + await app.layout.goToAccounts(); + await app.accounts.navigateToAccountByName(transaction.accountToDebit.accountName); await app.account.navigateToNFTOperation(); await app.sendDrawer.expectNftInfos(transaction); }, From d45e871ee6b7e98102a4ffbdf3d7581c8cf838d0 Mon Sep 17 00:00:00 2001 From: Martin CAYUELAS <112866305+mcayuelas-ledger@users.noreply.github.com> Date: Mon, 2 Dec 2024 10:14:43 +0100 Subject: [PATCH 13/13] feat: Rework hiddenCollections to handle more easily displays (#8457) --- .changeset/odd-cooks-glow.md | 7 ++ .../ContextMenu/createContextMenuItems.ts | 1 + .../hooks/useOpenHideCollectionModal.ts | 3 +- .../src/renderer/actions/constants.ts | 3 + .../src/renderer/actions/settings.ts | 34 +++---- .../ContextMenu/NFTCollectionContextMenu.tsx | 1 + .../__tests__/useHideSpamCollection.test.ts | 30 +++++-- .../__tests__/useNftCollectionsStatus.test.ts | 88 +++++++++++++++++++ .../hooks/nfts/useHideSpamCollection.ts | 25 ++++-- .../renderer/hooks/nfts/useNftCollections.ts | 13 +-- .../hooks/nfts/useNftCollectionsStatus.ts | 41 +++++++++ .../src/renderer/hooks/nfts/useNftLinks.ts | 3 +- .../modals/HideNftCollection/Body.tsx | 11 ++- .../modals/HideNftCollection/Footer.tsx | 30 ++++--- .../modals/HideNftCollection/index.tsx | 1 + .../src/renderer/modals/types.ts | 1 + .../src/renderer/reducers/accounts.ts | 14 ++- .../src/renderer/reducers/settings.ts | 58 ++++++------ .../src/renderer/screens/account/index.tsx | 10 +-- .../src/renderer/screens/dashboard/index.tsx | 4 +- .../renderer/screens/nft/Send/SelectNFT.tsx | 5 +- .../Accounts/HiddenNFTCollections/index.tsx | 30 +++++-- .../Accounts/HiddenNFTCollections/row.tsx | 14 ++- libs/live-nft-react/src/hooks/types.ts | 4 +- .../src/hooks/useCheckNftAccount.ts | 5 +- libs/live-nft/src/types.ts | 6 ++ 26 files changed, 322 insertions(+), 120 deletions(-) create mode 100644 .changeset/odd-cooks-glow.md create mode 100644 apps/ledger-live-desktop/src/renderer/hooks/nfts/__tests__/useNftCollectionsStatus.test.ts create mode 100644 apps/ledger-live-desktop/src/renderer/hooks/nfts/useNftCollectionsStatus.ts diff --git a/.changeset/odd-cooks-glow.md b/.changeset/odd-cooks-glow.md new file mode 100644 index 000000000000..2878cc369e16 --- /dev/null +++ b/.changeset/odd-cooks-glow.md @@ -0,0 +1,7 @@ +--- +"ledger-live-desktop": minor +"@ledgerhq/live-nft-react": minor +"@ledgerhq/live-nft": minor +--- + +Rework Hiddencollections diff --git a/apps/ledger-live-desktop/src/newArch/components/ContextMenu/createContextMenuItems.ts b/apps/ledger-live-desktop/src/newArch/components/ContextMenu/createContextMenuItems.ts index ff1d8206cd96..5c546c1422be 100644 --- a/apps/ledger-live-desktop/src/newArch/components/ContextMenu/createContextMenuItems.ts +++ b/apps/ledger-live-desktop/src/newArch/components/ContextMenu/createContextMenuItems.ts @@ -45,6 +45,7 @@ export function createContextMenuItems({ openModal("MODAL_HIDE_NFT_COLLECTION", { collectionName: collectionName ?? collectionAddress, collectionId: `${account.id}|${collectionAddress}`, + blockchain: account.currency.id, onClose: () => { if (goBackToAccount) { setDrawer(); diff --git a/apps/ledger-live-desktop/src/newArch/features/Collectibles/hooks/useOpenHideCollectionModal.ts b/apps/ledger-live-desktop/src/newArch/features/Collectibles/hooks/useOpenHideCollectionModal.ts index 2d4febd2f122..802efd357d1f 100644 --- a/apps/ledger-live-desktop/src/newArch/features/Collectibles/hooks/useOpenHideCollectionModal.ts +++ b/apps/ledger-live-desktop/src/newArch/features/Collectibles/hooks/useOpenHideCollectionModal.ts @@ -29,8 +29,9 @@ export const useOpenHideCollectionModal = ( collectionName: collectionName as string, collectionId: `${account.id}|${nft.contract}`, onClose, + blockchain: account.currency.id, }), ), }; - }, [account.id, dispatch, metadata, nft.contract, onClose, t]); + }, [account.currency.id, account.id, dispatch, metadata, nft.contract, onClose, t]); }; diff --git a/apps/ledger-live-desktop/src/renderer/actions/constants.ts b/apps/ledger-live-desktop/src/renderer/actions/constants.ts index ad79c2d67822..09cf07655e5a 100644 --- a/apps/ledger-live-desktop/src/renderer/actions/constants.ts +++ b/apps/ledger-live-desktop/src/renderer/actions/constants.ts @@ -1,4 +1,7 @@ // Action types + +/** Settings --------- */ export const TOGGLE_MEMOTAG_INFO = "settings/toggleShouldDisplayMemoTagInfo"; export const TOGGLE_MEV = "settings/toggleMEV"; export const TOGGLE_MARKET_WIDGET = "settings/toggleMarketWidget"; +export const UPDATE_NFT_COLLECTION_STATUS = "settings/updateNftCollectionStatus"; diff --git a/apps/ledger-live-desktop/src/renderer/actions/settings.ts b/apps/ledger-live-desktop/src/renderer/actions/settings.ts index 16ed922b65d5..be1df1bef74c 100644 --- a/apps/ledger-live-desktop/src/renderer/actions/settings.ts +++ b/apps/ledger-live-desktop/src/renderer/actions/settings.ts @@ -25,7 +25,14 @@ import { import { useRefreshAccountsOrdering } from "~/renderer/actions/general"; import { Language, Locale } from "~/config/languages"; import { Layout } from "LLD/features/Collectibles/types/Layouts"; -import { TOGGLE_MARKET_WIDGET, TOGGLE_MEMOTAG_INFO, TOGGLE_MEV } from "./constants"; +import { + TOGGLE_MARKET_WIDGET, + TOGGLE_MEMOTAG_INFO, + TOGGLE_MEV, + UPDATE_NFT_COLLECTION_STATUS, +} from "./constants"; +import { BlockchainsType } from "@ledgerhq/live-nft/supported"; +import { NftStatus } from "@ledgerhq/live-nft/types"; export type SaveSettings = (a: Partial) => { type: string; payload: Partial; @@ -215,15 +222,6 @@ export const blacklistToken = (tokenId: string) => ({ type: "BLACKLIST_TOKEN", payload: tokenId, }); -export const hideNftCollection = (collectionId: string) => ({ - type: "HIDE_NFT_COLLECTION", - payload: collectionId, -}); - -export const whitelistNftCollection = (collectionId: string) => ({ - type: "WHITELIST_NFT_COLLECTION", - payload: collectionId, -}); export const hideOrdinalsAsset = (inscriptionId: string) => ({ type: "HIDE_ORDINALS_ASSET", @@ -255,14 +253,16 @@ export const showToken = (tokenId: string) => ({ type: "SHOW_TOKEN", payload: tokenId, }); -export const unhideNftCollection = (collectionId: string) => ({ - type: "UNHIDE_NFT_COLLECTION", - payload: collectionId, -}); -export const unwhitelistNftCollection = (collectionId: string) => ({ - type: "UNWHITELIST_NFT_COLLECTION", - payload: collectionId, + +export const updateNftStatus = ( + blockchain: BlockchainsType, + collectionId: string, + status: NftStatus, +) => ({ + type: UPDATE_NFT_COLLECTION_STATUS, + payload: { blockchain, collectionId, status }, }); + export const unhideOrdinalsAsset = (inscriptionId: string) => ({ type: "UNHIDE_ORDINALS_ASSET", payload: inscriptionId, diff --git a/apps/ledger-live-desktop/src/renderer/components/ContextMenu/NFTCollectionContextMenu.tsx b/apps/ledger-live-desktop/src/renderer/components/ContextMenu/NFTCollectionContextMenu.tsx index d0633a17ed5a..eee1c99ccbcd 100644 --- a/apps/ledger-live-desktop/src/renderer/components/ContextMenu/NFTCollectionContextMenu.tsx +++ b/apps/ledger-live-desktop/src/renderer/components/ContextMenu/NFTCollectionContextMenu.tsx @@ -42,6 +42,7 @@ export default function NFTCollectionContextMenu({ history.replace(`account/${account.id}`); } }, + blockchain: account.currency.id, }), ), }, diff --git a/apps/ledger-live-desktop/src/renderer/hooks/nfts/__tests__/useHideSpamCollection.test.ts b/apps/ledger-live-desktop/src/renderer/hooks/nfts/__tests__/useHideSpamCollection.test.ts index f6406bd162c4..5c6da00ff3ec 100644 --- a/apps/ledger-live-desktop/src/renderer/hooks/nfts/__tests__/useHideSpamCollection.test.ts +++ b/apps/ledger-live-desktop/src/renderer/hooks/nfts/__tests__/useHideSpamCollection.test.ts @@ -1,8 +1,10 @@ -import { hideNftCollection } from "~/renderer/actions/settings"; +import { updateNftStatus } from "~/renderer/actions/settings"; import { useHideSpamCollection } from "../useHideSpamCollection"; import { renderHook } from "tests/testUtils"; import { INITIAL_STATE } from "~/renderer/reducers/settings"; import { useDispatch } from "react-redux"; +import { BlockchainEVM } from "@ledgerhq/live-nft/supported"; +import { NftStatus } from "@ledgerhq/live-nft/types"; jest.mock("react-redux", () => ({ ...jest.requireActual("react-redux"), @@ -17,31 +19,41 @@ describe("useHideSpamCollection", () => { mockDispatch.mockClear(); }); - it("should dispatch hideNftCollection action if collection is not whitelisted", () => { + it("should dispatch updateNftStatus action if collection is not already marked with a status", () => { const { result } = renderHook(() => useHideSpamCollection(), { initialState: { settings: { ...INITIAL_STATE, - whitelistedNftCollections: ["collectionA", "collectionB"], - hiddenNftCollections: [], + nftCollectionsStatusByNetwork: {}, }, }, }); - result.current.hideSpamCollection("collectionC"); + result.current.hideSpamCollection("collectionC", BlockchainEVM.Ethereum); - expect(mockDispatch).toHaveBeenCalledWith(hideNftCollection("collectionC")); + expect(mockDispatch).toHaveBeenCalledWith( + updateNftStatus(BlockchainEVM.Ethereum, "collectionC", NftStatus.spam), + ); }); - it("should not dispatch hideNftCollection action if collection is whitelisted", () => { + it("should not dispatch hideNftCollection action if collection is already marked with a status", () => { const { result } = renderHook(() => useHideSpamCollection(), { initialState: { settings: { - hiddenNftCollections: [], + nftCollectionsStatusByNetwork: { + [BlockchainEVM.Ethereum]: { + collectionA: NftStatus.spam, + }, + [BlockchainEVM.Avalanche]: { + collectionB: NftStatus.spam, + }, + }, whitelistedNftCollections: ["collectionA", "collectionB"], }, }, }); - result.current.hideSpamCollection("collectionA"); + + result.current.hideSpamCollection("collectionA", BlockchainEVM.Ethereum); + result.current.hideSpamCollection("collectionB", BlockchainEVM.Avalanche); expect(mockDispatch).not.toHaveBeenCalled(); }); diff --git a/apps/ledger-live-desktop/src/renderer/hooks/nfts/__tests__/useNftCollectionsStatus.test.ts b/apps/ledger-live-desktop/src/renderer/hooks/nfts/__tests__/useNftCollectionsStatus.test.ts new file mode 100644 index 000000000000..3150504beac7 --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/hooks/nfts/__tests__/useNftCollectionsStatus.test.ts @@ -0,0 +1,88 @@ +import { renderHook } from "tests/testUtils"; +import { INITIAL_STATE } from "~/renderer/reducers/settings"; +import { BlockchainEVM } from "@ledgerhq/live-nft/supported"; +import { NftStatus } from "@ledgerhq/live-nft/types"; +import { useNftCollectionsStatus } from "../useNftCollectionsStatus"; + +describe("useNftCollectionsStatus", () => { + it("should returns only NFTs (contract) with NftStatus !== whitelisted when FF is ON", () => { + const { result } = renderHook(() => useNftCollectionsStatus(), { + initialState: { + settings: { + ...INITIAL_STATE, + overriddenFeatureFlags: { + nftsFromSimplehash: { + enabled: true, + }, + }, + nftCollectionsStatusByNetwork: { + [BlockchainEVM.Ethereum]: { + collectionETHA: NftStatus.whitelisted, + collectionETHB: NftStatus.blacklisted, + collectionETHC: NftStatus.spam, + collectionETHD: NftStatus.spam, + }, + [BlockchainEVM.Avalanche]: { + collectionAVAX1: NftStatus.blacklisted, + collectionAVAX2: NftStatus.spam, + collectionAVAX3: NftStatus.blacklisted, + }, + [BlockchainEVM.Polygon]: { + collectionP1: NftStatus.blacklisted, + collectionP2: NftStatus.whitelisted, + }, + }, + }, + }, + }); + + expect(result.current.hiddenNftCollections).toEqual([ + "collectionETHB", + "collectionETHC", + "collectionETHD", + "collectionAVAX1", + "collectionAVAX2", + "collectionAVAX3", + "collectionP1", + ]); + }); + + it("should returns only NFTs (contract) with NftStatus.blacklisted when FF is oFF ", () => { + const { result } = renderHook(() => useNftCollectionsStatus(), { + initialState: { + settings: { + ...INITIAL_STATE, + overriddenFeatureFlags: { + nftsFromSimplehash: { + enabled: false, + }, + }, + nftCollectionsStatusByNetwork: { + [BlockchainEVM.Ethereum]: { + collectionETHA: NftStatus.whitelisted, + collectionETHB: NftStatus.blacklisted, + collectionETHC: NftStatus.spam, + collectionETHD: NftStatus.spam, + }, + [BlockchainEVM.Avalanche]: { + collectionAVAX1: NftStatus.blacklisted, + collectionAVAX2: NftStatus.spam, + collectionAVAX3: NftStatus.blacklisted, + }, + [BlockchainEVM.Polygon]: { + collectionP1: NftStatus.blacklisted, + collectionP2: NftStatus.whitelisted, + }, + }, + }, + }, + }); + + expect(result.current.hiddenNftCollections).toEqual([ + "collectionETHB", + "collectionAVAX1", + "collectionAVAX3", + "collectionP1", + ]); + }); +}); diff --git a/apps/ledger-live-desktop/src/renderer/hooks/nfts/useHideSpamCollection.ts b/apps/ledger-live-desktop/src/renderer/hooks/nfts/useHideSpamCollection.ts index 7772d51dddee..d185050f4681 100644 --- a/apps/ledger-live-desktop/src/renderer/hooks/nfts/useHideSpamCollection.ts +++ b/apps/ledger-live-desktop/src/renderer/hooks/nfts/useHideSpamCollection.ts @@ -1,25 +1,34 @@ import { useCallback } from "react"; import { useDispatch, useSelector } from "react-redux"; import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; -import { whitelistedNftCollectionsSelector } from "~/renderer/reducers/settings"; -import { hideNftCollection } from "~/renderer/actions/settings"; +import { nftCollectionsStatusByNetworkSelector } from "~/renderer/reducers/settings"; +import { updateNftStatus } from "~/renderer/actions/settings"; +import { BlockchainsType } from "@ledgerhq/live-nft/supported"; +import { NftStatus } from "@ledgerhq/live-nft/types"; export function useHideSpamCollection() { const spamFilteringTxFeature = useFeature("spamFilteringTx"); - const whitelistedNftCollections = useSelector(whitelistedNftCollectionsSelector); + const nftsFromSimplehashFeature = useFeature("nftsFromSimplehash"); + + const nftCollectionsStatusByNetwork = useSelector(nftCollectionsStatusByNetworkSelector); const dispatch = useDispatch(); + const hideSpamCollection = useCallback( - (collection: string) => { - if (!whitelistedNftCollections.includes(collection)) { - dispatch(hideNftCollection(collection)); + (collection: string, blockchain: BlockchainsType) => { + const elem = Object.entries(nftCollectionsStatusByNetwork).find( + ([key]) => key === blockchain, + )?.[1]; + + if (!elem) { + dispatch(updateNftStatus(blockchain, collection, NftStatus.spam)); } }, - [dispatch, whitelistedNftCollections], + [dispatch, nftCollectionsStatusByNetwork], ); return { hideSpamCollection, - enabled: spamFilteringTxFeature?.enabled, + enabled: spamFilteringTxFeature?.enabled && nftsFromSimplehashFeature?.enabled, }; } diff --git a/apps/ledger-live-desktop/src/renderer/hooks/nfts/useNftCollections.ts b/apps/ledger-live-desktop/src/renderer/hooks/nfts/useNftCollections.ts index 16832100f6e5..253ca3c4a8dc 100644 --- a/apps/ledger-live-desktop/src/renderer/hooks/nfts/useNftCollections.ts +++ b/apps/ledger-live-desktop/src/renderer/hooks/nfts/useNftCollections.ts @@ -4,11 +4,7 @@ import { nftsByCollections } from "@ledgerhq/live-nft/index"; import { BlockchainEVM } from "@ledgerhq/live-nft/supported"; import { Account, ProtoNFT } from "@ledgerhq/types-live"; import { useMemo } from "react"; -import { useSelector } from "react-redux"; -import { - hiddenNftCollectionsSelector, - whitelistedNftCollectionsSelector, -} from "~/renderer/reducers/settings"; +import { useNftCollectionsStatus } from "./useNftCollectionsStatus"; export function useNftCollections({ account, @@ -25,19 +21,18 @@ export function useNftCollections({ const threshold = nftsFromSimplehashFeature?.params?.threshold; const simplehashEnabled = nftsFromSimplehashFeature?.enabled; - const whitelistNft = useSelector(whitelistedNftCollectionsSelector); - const hiddenNftCollections = useSelector(hiddenNftCollectionsSelector); + const { hiddenNftCollections, whitelistedNftCollections } = useNftCollectionsStatus(); const nftsOwnedToCheck = useMemo(() => account?.nfts ?? nftsOwned, [account?.nfts, nftsOwned]); const whitelistedNfts = useMemo( () => nftsOwnedToCheck?.filter(nft => - whitelistNft + whitelistedNftCollections .map(collection => decodeCollectionId(collection).contractAddress) .includes(nft.contract), ) ?? [], - [nftsOwnedToCheck, whitelistNft], + [nftsOwnedToCheck, whitelistedNftCollections], ); const { nfts, fetchNextPage, hasNextPage } = useNftGalleryFilter({ diff --git a/apps/ledger-live-desktop/src/renderer/hooks/nfts/useNftCollectionsStatus.ts b/apps/ledger-live-desktop/src/renderer/hooks/nfts/useNftCollectionsStatus.ts new file mode 100644 index 000000000000..72876c2d43c1 --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/hooks/nfts/useNftCollectionsStatus.ts @@ -0,0 +1,41 @@ +import { useMemo } from "react"; +import { useSelector } from "react-redux"; +import { useFeature } from "@ledgerhq/live-common/featureFlags/index"; +import { nftCollectionsStatusByNetworkSelector } from "~/renderer/reducers/settings"; +import { NftStatus } from "@ledgerhq/live-nft/types"; +import { BlockchainEVM } from "@ledgerhq/live-nft/supported"; + +export function useNftCollectionsStatus() { + const nftsFromSimplehashFeature = useFeature("nftsFromSimplehash"); + const nftCollectionsStatusByNetwork = useSelector(nftCollectionsStatusByNetworkSelector); + + const shouldDisplaySpams = !nftsFromSimplehashFeature?.enabled; + + const nftCollectionParser = ( + nftCollection: Record>, + applyFilterFn: (arg0: [string, NftStatus]) => boolean, + ) => + Object.values(nftCollection).flatMap(contracts => + Object.entries(contracts) + .filter(applyFilterFn) + .map(([contract]) => contract), + ); + + const list = useMemo(() => { + return nftCollectionParser(nftCollectionsStatusByNetwork, ([_, status]) => + !shouldDisplaySpams ? status !== NftStatus.whitelisted : status === NftStatus.blacklisted, + ); + }, [nftCollectionsStatusByNetwork, shouldDisplaySpams]); + + const whitelisted = useMemo(() => { + return nftCollectionParser( + nftCollectionsStatusByNetwork, + ([_, status]) => status === NftStatus.whitelisted, + ); + }, [nftCollectionsStatusByNetwork]); + + return { + hiddenNftCollections: list, + whitelistedNftCollections: whitelisted, + }; +} diff --git a/apps/ledger-live-desktop/src/renderer/hooks/nfts/useNftLinks.ts b/apps/ledger-live-desktop/src/renderer/hooks/nfts/useNftLinks.ts index f3ef4a4977e0..3c9801fb601e 100644 --- a/apps/ledger-live-desktop/src/renderer/hooks/nfts/useNftLinks.ts +++ b/apps/ledger-live-desktop/src/renderer/hooks/nfts/useNftLinks.ts @@ -116,11 +116,12 @@ export default ( collectionName: metadata?.tokenName ?? nft.contract, collectionId: `${account.id}|${nft.contract}`, onClose, + blockchain: account.currency.id, }), ); }, }), - [account.id, dispatch, metadata?.tokenName, nft.contract, onClose, t], + [account.currency.id, account.id, dispatch, metadata?.tokenName, nft.contract, onClose, t], ); const customImageUri = useMemo(() => { const mediaTypes = metadata ? getMetadataMediaTypes(metadata) : null; diff --git a/apps/ledger-live-desktop/src/renderer/modals/HideNftCollection/Body.tsx b/apps/ledger-live-desktop/src/renderer/modals/HideNftCollection/Body.tsx index 572412bb44bc..c7d071242fad 100644 --- a/apps/ledger-live-desktop/src/renderer/modals/HideNftCollection/Body.tsx +++ b/apps/ledger-live-desktop/src/renderer/modals/HideNftCollection/Body.tsx @@ -4,14 +4,17 @@ import Text from "~/renderer/components/Text"; import ModalBody from "~/renderer/components/Modal/ModalBody"; import React from "react"; import Footer from "~/renderer/modals/HideNftCollection/Footer"; +import { BlockchainsType } from "@ledgerhq/live-nft/supported"; const Body = ({ onClose, collectionId, collectionName, + blockchain, }: { onClose: () => void; collectionId: string; collectionName: string; + blockchain: string; }) => { const { t } = useTranslation(); return ( @@ -47,7 +50,13 @@ const Body = ({ )} - renderFooter={() =>