diff --git a/.changeset/violet-cheetahs-shave.md b/.changeset/violet-cheetahs-shave.md new file mode 100644 index 00000000000..45edc808bb3 --- /dev/null +++ b/.changeset/violet-cheetahs-shave.md @@ -0,0 +1,6 @@ +--- +"ledger-live-desktop": minor +"@ledgerhq/live-common": minor +--- + +Feature/live 20724 [LLD][UI] Memo screen diff --git a/.gitignore b/.gitignore index 8b48b751af2..6a4bdf06c78 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,5 @@ libs/ui/packages/react/debug-storybook.log libs/ui/packages/react/rsbuild.config.d.ts libs/ui/packages/react/rsbuild.config.d.ts.map libs/ui/packages/react/rsbuild.config.js -libs/ui/packages/react/rsbuild.config.js.map \ No newline at end of file +libs/ui/packages/react/rsbuild.config.js.map +.zed diff --git a/apps/ledger-live-desktop/src/mvvm/features/ModularDialog/components/Address/formatAddress.ts b/apps/ledger-live-desktop/src/mvvm/features/ModularDialog/components/Address/formatAddress.ts index 086ecbba942..74e57b02d27 100644 --- a/apps/ledger-live-desktop/src/mvvm/features/ModularDialog/components/Address/formatAddress.ts +++ b/apps/ledger-live-desktop/src/mvvm/features/ModularDialog/components/Address/formatAddress.ts @@ -10,7 +10,7 @@ * @returns Formatted address string */ export const formatAddress = ( - address: string, + address: string | undefined, options: { prefixLength?: number; suffixLength?: number; diff --git a/apps/ledger-live-desktop/src/mvvm/features/Send/components/SendHeader.tsx b/apps/ledger-live-desktop/src/mvvm/features/Send/components/SendHeader.tsx index f8a8893b70d..d2e8d56659d 100644 --- a/apps/ledger-live-desktop/src/mvvm/features/Send/components/SendHeader.tsx +++ b/apps/ledger-live-desktop/src/mvvm/features/Send/components/SendHeader.tsx @@ -1,76 +1,82 @@ -import React, { useCallback, useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { BigNumber } from "bignumber.js"; -import { AddressInput, DialogHeader } from "@ledgerhq/lumen-ui-react"; -import { useFlowWizard } from "../../FlowWizard/FlowWizardContext"; -import { useSendFlowData, useSendFlowActions } from "../context/SendFlowContext"; +import { sendFeatures } from "@ledgerhq/live-common/bridge/descriptor"; import { SEND_FLOW_STEP, - type SendFlowStep, type SendFlowBusinessContext, + type SendFlowStep, } from "@ledgerhq/live-common/flows/send/types"; -import type { SendStepConfig } from "../types"; -import { getRecipientDisplayValue, getRecipientSearchPrefillValue } from "./utils"; +import { AddressInput, DialogHeader } from "@ledgerhq/lumen-ui-react"; +import React, { useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { useFlowWizard } from "../../FlowWizard/FlowWizardContext"; +import { useSendFlowActions, useSendFlowData } from "../context/SendFlowContext"; import { useAvailableBalance } from "../hooks/useAvailableBalance"; +import { useSendHeaderModel } from "../hooks/useSendHeaderModel"; +import { MemoTypeSelect } from "../screens/Recipient/components/Memo/MemoTypeSelect"; +import { MemoValueInput } from "../screens/Recipient/components/Memo/MemoValueInput"; +import { SkipMemoSection } from "../screens/Recipient/components/Memo/SkipMemoSection"; +import { useRecipientMemo } from "../screens/Recipient/hooks/useRecipientMemo"; +import type { SendStepConfig } from "../types"; export function SendHeader() { const wizard = useFlowWizard(); const { state, uiConfig, recipientSearch } = useSendFlowData(); const { close, transaction } = useSendFlowActions(); const { t } = useTranslation(); + const availableText = useAvailableBalance(state.account.account); const { navigation, currentStep } = wizard; - const currentStepConfig = wizard.currentStepConfig as SendStepConfig; + const currencyId = state.account.currency?.id; - const currencyName = state.account.currency?.ticker ?? ""; - const availableText = useAvailableBalance(state.account.account); + const memoDefaultOption = useMemo(() => { + return sendFeatures.getMemoDefaultOption(state.account.currency ?? undefined); + }, [state.account.currency]); - const handleBack = useCallback(() => { - if (navigation.canGoBack()) { - // Reset amount-related state when leaving Amount step (back navigation), - // otherwise the transaction amount can persist while the UI remounts empty. - if (currentStep === SEND_FLOW_STEP.AMOUNT) { - transaction.updateTransaction(tx => ({ - ...tx, - amount: new BigNumber(0), - useAllAmount: false, - feesStrategy: null, - })); - } - navigation.goToPreviousStep(); - } else { - close(); - } - }, [close, currentStep, navigation, transaction]); + const memoTypeOptions = useMemo(() => { + return uiConfig.memoOptions ?? []; + }, [uiConfig]); - const showBackButton = navigation.canGoBack(); - const showTitle = currentStepConfig?.showTitle !== false; + const { + hasMemoTypeOptions, + memo, + onMemoTypeChange, + showMemoValueInput, + onMemoValueChange, + showSkipMemo, + skipMemoState, + onSkipMemoRequestConfirm, + onSkipMemoCancelConfirm, + onSkipMemoConfirm, + resetViewState, + } = useRecipientMemo({ + hasMemo: uiConfig.hasMemo, + memoDefaultOption, + memoType: uiConfig.memoType, + memoTypeOptions, + onMemoChange: memo => { + transaction.setRecipient({ ...state.recipient, memo }); + }, + onMemoSkip: () => { + navigation.goToNextStep(); + }, + resetKey: `${state.account.account?.id ?? ""}|${currencyId ?? ""}|${ + recipientSearch.value.length === 0 ? "empty" : "filled" + }`, + }); - const title = showTitle ? t("newSendFlow.title", { currency: currencyName }) : ""; - const descriptionText = - showTitle && availableText ? t("newSendFlow.available", { amount: availableText }) : ""; + const { + addressInputValue, + descriptionText, + handleBack, + handleRecipientInputClick, + showBackButton, + showMemoControls, + showRecipientInput, + title, + transactionErrorName, + } = useSendHeaderModel({ availableText, resetViewState }); - const showRecipientInput = currentStepConfig?.addressInput ?? false; - const isRecipientStep = currentStep === SEND_FLOW_STEP.RECIPIENT; const isAmountStep = currentStep === SEND_FLOW_STEP.AMOUNT; - const addressInputValue = useMemo(() => { - if (isRecipientStep) return recipientSearch.value; - if (isAmountStep) return getRecipientDisplayValue(state.recipient); - return recipientSearch.value; - }, [isRecipientStep, isAmountStep, recipientSearch.value, state.recipient]); - - const handleRecipientInputClick = useCallback(() => { - if (!isAmountStep) return; - - const prefillValue = getRecipientSearchPrefillValue(state.recipient); - if (prefillValue) { - recipientSearch.setValue(prefillValue); - } - - handleBack(); - }, [handleBack, isAmountStep, recipientSearch, state.recipient]); - const recipientInputContent = useMemo(() => { if (!showRecipientInput) return null; @@ -91,30 +97,82 @@ export function SendHeader() { } return ( - recipientSearch.setValue(e.target.value)} - onClear={recipientSearch.clear} - placeholder={ - uiConfig.recipientSupportsDomain - ? t("newSendFlow.placeholder") - : t("newSendFlow.placeholderNoENS") - } - /> + <> + recipientSearch.setValue(e.target.value)} + onClear={recipientSearch.clear} + placeholder={ + uiConfig.recipientSupportsDomain + ? t("newSendFlow.placeholder") + : t("newSendFlow.placeholderNoENS") + } + /> + {showMemoControls && currencyId ? ( +
+
+ {hasMemoTypeOptions ? ( + + ) : null} + + {showMemoValueInput ? ( + + ) : null} +
+ + {showSkipMemo ? ( + + ) : null} +
+ ) : null} + ); }, [ showRecipientInput, isAmountStep, addressInputValue, - handleRecipientInputClick, recipientSearch, uiConfig.recipientSupportsDomain, + uiConfig.memoMaxLength, t, + showMemoControls, + currencyId, + hasMemoTypeOptions, + memoTypeOptions, + memo.type, + memo.value, + onMemoTypeChange, + showMemoValueInput, + transactionErrorName, + onMemoValueChange, + showSkipMemo, + skipMemoState, + onSkipMemoRequestConfirm, + onSkipMemoCancelConfirm, + onSkipMemoConfirm, + handleRecipientInputClick, ]); return ( -
+
; -export function getRecipientDisplayValue(recipient: RecipientLike | null): string { +export function getRecipientDisplayValue(recipient: RecipientLike | null): string | undefined { if (!recipient) return ""; const formattedAddress = formatAddress(recipient.address, { @@ -20,7 +20,9 @@ export function getRecipientDisplayValue(recipient: RecipientLike | null): strin return formattedAddress; } -export function getRecipientSearchPrefillValue(recipient: RecipientLike | null): string { +export function getRecipientSearchPrefillValue( + recipient: RecipientLike | null, +): string | undefined { if (!recipient) return ""; return recipient.ensName?.trim() ? recipient.ensName : recipient.address; } diff --git a/apps/ledger-live-desktop/src/mvvm/features/Send/context/SendFlowContext.tsx b/apps/ledger-live-desktop/src/mvvm/features/Send/context/SendFlowContext.tsx index 10f2953116e..4b3e7ae6938 100644 --- a/apps/ledger-live-desktop/src/mvvm/features/Send/context/SendFlowContext.tsx +++ b/apps/ledger-live-desktop/src/mvvm/features/Send/context/SendFlowContext.tsx @@ -24,7 +24,7 @@ import type { SendStepConfig } from "../types"; */ // Data Context -type DataContextValue = Readonly<{ +export type DataContextValue = Readonly<{ state: SendFlowState; uiConfig: SendFlowUiConfig; recipientSearch: Readonly<{ diff --git a/apps/ledger-live-desktop/src/mvvm/features/Send/hooks/__tests__/useSendFlowTransaction.test.ts b/apps/ledger-live-desktop/src/mvvm/features/Send/hooks/__tests__/useSendFlowTransaction.test.ts index 9d917ba29f1..4029c8c8293 100644 --- a/apps/ledger-live-desktop/src/mvvm/features/Send/hooks/__tests__/useSendFlowTransaction.test.ts +++ b/apps/ledger-live-desktop/src/mvvm/features/Send/hooks/__tests__/useSendFlowTransaction.test.ts @@ -76,7 +76,7 @@ describe("useSendFlowTransaction", () => { act(() => { result.current.actions.setRecipient({ address: "cosmos1abc123", - memo: "test memo", + memo: { value: "test memo" }, }); }); @@ -117,7 +117,7 @@ describe("useSendFlowTransaction", () => { act(() => { result.current.actions.setRecipient({ address: "solana-address", - memo: "solana memo", + memo: { value: "solana memo" }, }); }); @@ -199,7 +199,7 @@ describe("useSendFlowTransaction", () => { act(() => { result.current.actions.setRecipient({ address: "casper-address", - memo: "transfer-id-123", + memo: { value: "transfer-id-123" }, }); }); @@ -236,7 +236,7 @@ describe("useSendFlowTransaction", () => { act(() => { result.current.actions.setRecipient({ address: "xrp-address", - memo: "test", + memo: { value: "test" }, destinationTag: "67890", }); }); diff --git a/apps/ledger-live-desktop/src/mvvm/features/Send/hooks/useSendFlowTransaction.ts b/apps/ledger-live-desktop/src/mvvm/features/Send/hooks/useSendFlowTransaction.ts index 5153077fe76..21d4e1968e6 100644 --- a/apps/ledger-live-desktop/src/mvvm/features/Send/hooks/useSendFlowTransaction.ts +++ b/apps/ledger-live-desktop/src/mvvm/features/Send/hooks/useSendFlowTransaction.ts @@ -1,14 +1,14 @@ import { useCallback, useMemo } from "react"; -import useBridgeTransaction from "@ledgerhq/live-common/bridge/useBridgeTransaction"; -import { getAccountBridge } from "@ledgerhq/live-common/bridge/index"; import { applyMemoToTransaction } from "@ledgerhq/live-common/bridge/descriptor"; -import type { Account, AccountLike } from "@ledgerhq/types-live"; +import { getAccountBridge } from "@ledgerhq/live-common/bridge/index"; +import useBridgeTransaction from "@ledgerhq/live-common/bridge/useBridgeTransaction"; import type { Transaction } from "@ledgerhq/live-common/generated/types"; import type { SendFlowTransactionState, SendFlowTransactionActions, RecipientData, } from "@ledgerhq/live-common/flows/send/types"; +import type { Account, AccountLike } from "@ledgerhq/types-live"; type UseSendFlowTransactionParams = Readonly<{ account: AccountLike | null; @@ -57,7 +57,12 @@ export function useSendFlowTransaction({ if (recipient.memo !== undefined) { Object.assign( updates, - applyMemoToTransaction(transaction.family, recipient.memo, transaction), + applyMemoToTransaction( + transaction.family, + recipient.memo.value, + recipient.memo.type, + transaction, + ), ); } @@ -66,7 +71,7 @@ export function useSendFlowTransaction({ if (Number.isFinite(parsedTag)) { Object.assign( updates, - applyMemoToTransaction(transaction.family, parsedTag, transaction), + applyMemoToTransaction(transaction.family, parsedTag, undefined, transaction), ); } } diff --git a/apps/ledger-live-desktop/src/mvvm/features/Send/hooks/useSendHeaderModel.ts b/apps/ledger-live-desktop/src/mvvm/features/Send/hooks/useSendHeaderModel.ts new file mode 100644 index 00000000000..4c6f4add878 --- /dev/null +++ b/apps/ledger-live-desktop/src/mvvm/features/Send/hooks/useSendHeaderModel.ts @@ -0,0 +1,108 @@ +import { SendFlowStep, SEND_FLOW_STEP } from "@ledgerhq/live-common/flows/send/types"; +import { t } from "i18next"; +import { useMemo, useCallback } from "react"; +import { useFlowWizard } from "../../FlowWizard/FlowWizardContext"; +import { getRecipientDisplayValue, getRecipientSearchPrefillValue } from "../components/utils"; +import { + SendFlowBusinessContext, + useSendFlowActions, + useSendFlowData, +} from "../context/SendFlowContext"; +import { SendStepConfig } from "../types"; +import BigNumber from "bignumber.js"; + +type UseSendHeaderModelParams = Readonly<{ + availableText: string; + resetViewState: () => void; +}>; +type UseSendHeaderModelResult = Readonly<{ + addressInputValue: string | undefined; + descriptionText: string | undefined; + handleBack: () => void; + handleRecipientInputClick: () => void; + showBackButton: boolean; + showRecipientInput: boolean; + showMemoControls: boolean; + title: string | undefined; + transactionErrorName: string | undefined; +}>; + +export function useSendHeaderModel({ + availableText, + resetViewState, +}: UseSendHeaderModelParams): UseSendHeaderModelResult { + const wizard = useFlowWizard(); + const { state, uiConfig, recipientSearch } = useSendFlowData(); + const { close, transaction } = useSendFlowActions(); + + const currencyName = state.account.currency?.ticker ?? ""; + + const { navigation, currentStep } = wizard; + const currentStepConfig = wizard.currentStepConfig as SendStepConfig; + + const showRecipientInput = currentStepConfig?.addressInput ?? false; + const showMemoControls = Boolean( + showRecipientInput && uiConfig.hasMemo && recipientSearch.value.length > 0, + ); + + const showBackButton = navigation.canGoBack(); + + const showTitle = currentStepConfig?.showTitle !== false; + const title = showTitle ? t("newSendFlow.title", { currency: currencyName }) : ""; + const descriptionText = + showTitle && availableText ? t("newSendFlow.available", { amount: availableText }) : ""; + + const isRecipientStep = currentStep === SEND_FLOW_STEP.RECIPIENT; + const isAmountStep = currentStep === SEND_FLOW_STEP.AMOUNT; + + const handleBack = useCallback(() => { + if (navigation.canGoBack()) { + // Reset amount-related state when leaving Amount step (back navigation), + // otherwise the transaction amount can persist while the UI remounts empty. + if (currentStep === SEND_FLOW_STEP.AMOUNT) { + transaction.updateTransaction(tx => ({ + ...tx, + amount: new BigNumber(0), + useAllAmount: false, + feesStrategy: null, + })); + + resetViewState(); + } + navigation.goToPreviousStep(); + } else { + close(); + } + }, [close, currentStep, navigation, resetViewState, transaction]); + + const addressInputValue = useMemo(() => { + if (isRecipientStep) return recipientSearch.value; + if (isAmountStep) return getRecipientDisplayValue(state.recipient); + return recipientSearch.value; + }, [isRecipientStep, isAmountStep, recipientSearch.value, state.recipient]); + + const handleRecipientInputClick = useCallback(() => { + if (!isAmountStep) return; + + const prefillValue = getRecipientSearchPrefillValue(state.recipient); + if (prefillValue) { + recipientSearch.setValue(prefillValue); + } + + handleBack(); + }, [handleBack, isAmountStep, recipientSearch, state.recipient]); + + const transactionErrorName = state.transaction.status?.errors?.transaction?.name; + + return { + addressInputValue, + descriptionText, + handleBack, + handleRecipientInputClick, + showBackButton, + showMemoControls, + showRecipientInput, + title, + transactionErrorName, + }; +} diff --git a/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/RecipientScreen.tsx b/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/RecipientScreen.tsx index 8d31fdf0ae1..42c74273612 100644 --- a/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/RecipientScreen.tsx +++ b/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/RecipientScreen.tsx @@ -1,12 +1,12 @@ -import React, { useCallback, useMemo } from "react"; import { getAccountCurrency } from "@ledgerhq/live-common/account/index"; import type { CryptoCurrency, TokenCurrency } from "@ledgerhq/types-cryptoassets"; +import React, { useCallback, useMemo } from "react"; import { useFlowWizard } from "../../../FlowWizard/FlowWizardContext"; -import { useSendFlowData, useSendFlowActions } from "../../context/SendFlowContext"; +import { useSendFlowActions, useSendFlowData } from "../../context/SendFlowContext"; import { RecipientAddressModal } from "./components/RecipientAddressModal"; export function RecipientScreen() { - const { state, uiConfig, recipientSearch } = useSendFlowData(); + const { state, uiConfig } = useSendFlowData(); const { transaction, close } = useSendFlowActions(); const { navigation } = useFlowWizard(); @@ -19,16 +19,18 @@ export function RecipientScreen() { }, [state.account.currency, account]); const handleAddressSelected = useCallback( - (address: string, ensName?: string) => { + (address: string, ensName?: string, goToNextStep?: boolean) => { transaction.setRecipient({ + ...state.recipient, address, ensName, }); - recipientSearch.clear(); - navigation.goToNextStep(); + if (goToNextStep) { + navigation.goToNextStep(); + } }, - [transaction, navigation, recipientSearch], + [transaction, state.recipient, navigation], ); if (!account || !currency) { diff --git a/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/components/AddressMatchedSection.tsx b/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/components/AddressMatchedSection.tsx index d1078895f66..775366cf44b 100644 --- a/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/components/AddressMatchedSection.tsx +++ b/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/components/AddressMatchedSection.tsx @@ -51,9 +51,11 @@ export function AddressMatchedSection({ return `${ensName} (${formattedAddress})`; }; - const getRecentDescription = (): string => { + const getRecentDescription = (): string | undefined => { if (matchedRecentAddress) { - return `Already used · ${formatRelativeDate(matchedRecentAddress.lastUsedAt)}`; + return t("newSendFlow.alreadyUsed", { + date: formatRelativeDate(matchedRecentAddress.lastUsedAt), + }); } return formattedAddress; }; diff --git a/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/components/Memo/MemoTypeSelect.tsx b/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/components/Memo/MemoTypeSelect.tsx new file mode 100644 index 00000000000..cb5dc81dec2 --- /dev/null +++ b/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/components/Memo/MemoTypeSelect.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { + Select, + SelectContent, + SelectItem, + SelectItemText, + SelectTrigger, +} from "@ledgerhq/lumen-ui-react"; +import { useTranslation } from "react-i18next"; + +type MemoTypeSelectProps = Readonly<{ + currencyId: string; + options: readonly string[]; + value?: string; + onChange: (value: string) => void; +}>; + +function MemoTypeSelectComponent({ currencyId, options, value, onChange }: MemoTypeSelectProps) { + const { t } = useTranslation(); + + return ( + + ); +} + +export const MemoTypeSelect = React.memo(MemoTypeSelectComponent); diff --git a/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/components/Memo/MemoValueInput.tsx b/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/components/Memo/MemoValueInput.tsx new file mode 100644 index 00000000000..550308f28ae --- /dev/null +++ b/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/components/Memo/MemoValueInput.tsx @@ -0,0 +1,52 @@ +import React from "react"; +import { TextInput, Tooltip, TooltipContent, TooltipTrigger } from "@ledgerhq/lumen-ui-react"; +import { Information } from "@ledgerhq/lumen-ui-react/symbols"; +import { useTranslation } from "react-i18next"; + +type MemoValueInputProps = Readonly<{ + currencyId: string; + value: string; + maxLength?: number; + transactionErrorName?: string; + onChange: (value: string) => void; +}>; + +function MemoValueInputComponent({ + currencyId, + value, + maxLength, + transactionErrorName, + onChange, +}: MemoValueInputProps) { + const { t } = useTranslation(); + const memoLabel = t([`families.${currencyId}.memo`, "common.memo"]); + const errorMessage = transactionErrorName ? t(`errors.${transactionErrorName}.title`) : undefined; + + return ( + onChange(e.target.value)} + suffix={ + + + + + +
+ {t("newSendFlow.tagHelp.description", { + currency: currencyId, + memoLabel, + })} +
+
+
+ } + className="w-full" + value={value} + maxLength={maxLength} + errorMessage={errorMessage} + /> + ); +} + +export const MemoValueInput = React.memo(MemoValueInputComponent); diff --git a/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/components/Memo/SkipMemoSection.tsx b/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/components/Memo/SkipMemoSection.tsx new file mode 100644 index 00000000000..5ce9b2efbe2 --- /dev/null +++ b/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/components/Memo/SkipMemoSection.tsx @@ -0,0 +1,87 @@ +import React, { useCallback, useState } from "react"; +import { Banner, Button, Checkbox, Link } from "@ledgerhq/lumen-ui-react"; +import { useTranslation } from "react-i18next"; +import { openURL } from "~/renderer/linking"; +import { useLocalizedUrl } from "~/renderer/hooks/useLocalizedUrls"; +import { urls } from "~/config/urls"; +import type { SkipMemoState } from "../../hooks/useRecipientMemo"; + +type SkipMemoSectionProps = Readonly<{ + currencyId: string; + state: SkipMemoState; + onRequestConfirm: () => void; + onCancelConfirm: () => void; + onConfirm: (doNotAskAgain: boolean) => void; +}>; + +function SkipMemoSectionComponent({ + currencyId, + state, + onRequestConfirm, + onCancelConfirm, + onConfirm, +}: SkipMemoSectionProps) { + const { t } = useTranslation(); + const memoLabel = t([`families.${currencyId}.memo`, "common.memo"]); + const [doNotAskAgain, setDoNotAskAgain] = useState(false); + + const learnMoreUrl = useLocalizedUrl(urls.memoTag.learnMore); + const handleLearnMore = useCallback(() => { + if (learnMoreUrl) { + openURL(learnMoreUrl); + } + }, [learnMoreUrl]); + + const toggleDoNotAskAgain = useCallback(() => { + setDoNotAskAgain(prev => !prev); + }, []); + + const handleOnSkipConfirmed = useCallback(() => { + onConfirm(doNotAskAgain); + }, [onConfirm, doNotAskAgain]); + + if (state === "propose") { + return ( +
+ + {t("newSendFlow.skipMemo.notRequired", { memoLabel })} +   + + + {t("common.skip")} + +
+ ); + } + + return ( +
+ + {t("newSendFlow.skipMemo.confirm")} + + } + secondaryAction={ + + } + /> + +
+ ); +} + +export const SkipMemoSection = React.memo(SkipMemoSectionComponent); diff --git a/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/components/RecipientAddressModal.tsx b/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/components/RecipientAddressModal.tsx index 1136f5474b7..f4f1a4e5449 100644 --- a/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/components/RecipientAddressModal.tsx +++ b/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/components/RecipientAddressModal.tsx @@ -1,8 +1,8 @@ -import React from "react"; -import type { Account, AccountLike } from "@ledgerhq/types-live"; import type { CryptoOrTokenCurrency } from "@ledgerhq/types-cryptoassets"; -import { RecipientAddressModalView } from "./RecipientAddressModalView"; +import type { Account, AccountLike } from "@ledgerhq/types-live"; +import React from "react"; import { useRecipientAddressModalViewModel } from "../hooks/useRecipientAddressModalViewModel"; +import { RecipientAddressModalView } from "./RecipientAddressModalView"; type RecipientAddressModalProps = Readonly<{ isOpen: boolean; @@ -10,7 +10,7 @@ type RecipientAddressModalProps = Readonly<{ account: AccountLike; parentAccount?: Account; currency: CryptoOrTokenCurrency; - onAddressSelected: (address: string, ensName?: string) => void; + onAddressSelected: (address: string, ensName?: string, goToNextStep?: boolean) => void; recipientSupportsDomain: boolean; }>; diff --git a/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/components/RecipientAddressModalView.tsx b/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/components/RecipientAddressModalView.tsx index 9da39eafd3d..2740f8b12c4 100644 --- a/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/components/RecipientAddressModalView.tsx +++ b/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/components/RecipientAddressModalView.tsx @@ -1,18 +1,18 @@ -import React from "react"; -import type { Account } from "@ledgerhq/types-live"; import type { CryptoOrTokenCurrency } from "@ledgerhq/types-cryptoassets"; -import EmptyList from "./EmptyList"; -import { RecentAddressesSection } from "./RecentAddressesSection"; -import { MyAccountsSection } from "./MyAccountsSection"; -import { AddressMatchedSection } from "./AddressMatchedSection"; -import { LoadingState } from "./LoadingState"; -import { ValidationBanner } from "./ValidationBanner"; -import { AddressValidationError } from "./AddressValidationError"; +import type { Account } from "@ledgerhq/types-live"; +import React from "react"; import type { AddressSearchResult, - RecentAddress, AddressValidationError as AddressValidationErrorType, + RecentAddress, } from "../types"; +import { AddressMatchedSection } from "./AddressMatchedSection"; +import { AddressValidationError } from "./AddressValidationError"; +import EmptyList from "./EmptyList"; +import { LoadingState } from "./LoadingState"; +import { MyAccountsSection } from "./MyAccountsSection"; +import { RecentAddressesSection } from "./RecentAddressesSection"; +import { ValidationBanner } from "./ValidationBanner"; type RecipientAddressModalViewProps = Readonly<{ searchValue: string; @@ -40,6 +40,9 @@ type RecipientAddressModalViewProps = Readonly<{ onAccountSelect: (account: Account) => void; onAddressSelect: (address: string, ensName?: string) => void; onRemoveAddress: (address: RecentAddress) => void; + hasMemo: boolean; + hasMemoValidationError: boolean; + hasFilledMemo: boolean; }>; export function RecipientAddressModalView({ @@ -68,6 +71,9 @@ export function RecipientAddressModalView({ onAccountSelect, onAddressSelect, onRemoveAddress, + hasMemo, + hasMemoValidationError, + hasFilledMemo, }: RecipientAddressModalViewProps) { const shouldShowErrorBanner = !isLoading && @@ -95,7 +101,7 @@ export function RecipientAddressModalView({ )} - {showMatchedAddress && ( + {showMatchedAddress && (!hasMemo || (hasFilledMemo && !hasMemoValidationError)) && ( { beforeEach(() => { jest.clearAllMocks(); @@ -67,7 +78,7 @@ describe("useRecipientAddressModalViewModel", () => { mockedSendFeatures.getSelfTransferPolicy.mockReturnValue("impossible"); mockedUseSendFlowData.mockReturnValue({ recipientSearch: mockRecipientSearch, - state: {} as never, + state: DEFAULT_STATE, uiConfig: {} as never, }); mockedUseAddressValidation.mockReturnValue({ @@ -108,7 +119,7 @@ describe("useRecipientAddressModalViewModel", () => { it("shows search results when search value is provided", () => { mockedUseSendFlowData.mockReturnValue({ recipientSearch: { ...mockRecipientSearch, value: "some_address" }, - state: {} as never, + state: DEFAULT_STATE, uiConfig: {} as never, }); @@ -165,7 +176,7 @@ describe("useRecipientAddressModalViewModel", () => { result.current.handleRecentAddressSelect(recentAddress); - expect(onAddressSelected).toHaveBeenCalledWith("recent_address", undefined); + expect(onAddressSelected).toHaveBeenCalledWith("recent_address", undefined, true); }); it("calls onAddressSelected when handleAccountSelect is called", () => { @@ -186,7 +197,7 @@ describe("useRecipientAddressModalViewModel", () => { result.current.handleAccountSelect(selectedAccount); - expect(onAddressSelected).toHaveBeenCalledWith("selected_fresh_address"); + expect(onAddressSelected).toHaveBeenCalledWith("selected_fresh_address", undefined, true); }); it("calls onAddressSelected when handleAddressSelect is called", () => { @@ -203,7 +214,7 @@ describe("useRecipientAddressModalViewModel", () => { result.current.handleAddressSelect("new_address", "ens_name"); - expect(onAddressSelected).toHaveBeenCalledWith("new_address", "ens_name"); + expect(onAddressSelected).toHaveBeenCalledWith("new_address", "ens_name", true); }); it("removes address from recent addresses when handleRemoveAddress is called", () => { @@ -235,7 +246,7 @@ describe("useRecipientAddressModalViewModel", () => { it("shows sanctioned banner when address is sanctioned", () => { mockedUseSendFlowData.mockReturnValue({ recipientSearch: { ...mockRecipientSearch, value: "sanctioned_address" }, - state: {} as never, + state: DEFAULT_STATE, uiConfig: {} as never, }); @@ -275,7 +286,7 @@ describe("useRecipientAddressModalViewModel", () => { it("shows address validation error for incorrect format", () => { mockedUseSendFlowData.mockReturnValue({ recipientSearch: { ...mockRecipientSearch, value: "invalid_address" }, - state: {} as never, + state: DEFAULT_STATE, uiConfig: {} as never, }); @@ -315,7 +326,7 @@ describe("useRecipientAddressModalViewModel", () => { it("shows matched address when validation is valid", () => { mockedUseSendFlowData.mockReturnValue({ recipientSearch: { ...mockRecipientSearch, value: "valid_address" }, - state: {} as never, + state: DEFAULT_STATE, uiConfig: {} as never, }); @@ -354,7 +365,7 @@ describe("useRecipientAddressModalViewModel", () => { it("identifies self-transfer error correctly", () => { mockedUseSendFlowData.mockReturnValue({ recipientSearch: { ...mockRecipientSearch, value: "source_address" }, - state: {} as never, + state: DEFAULT_STATE, uiConfig: {} as never, }); @@ -396,7 +407,7 @@ describe("useRecipientAddressModalViewModel", () => { it("treats InvalidAddress as incorrect format for domain-like strings", () => { mockedUseSendFlowData.mockReturnValue({ recipientSearch: { ...mockRecipientSearch, value: "invalid.eth" }, - state: {} as never, + state: DEFAULT_STATE, uiConfig: {} as never, }); @@ -436,7 +447,7 @@ describe("useRecipientAddressModalViewModel", () => { it("shows empty state when no matches and not complete", () => { mockedUseSendFlowData.mockReturnValue({ recipientSearch: { ...mockRecipientSearch, value: "searching" }, - state: {} as never, + state: DEFAULT_STATE, uiConfig: {} as never, }); diff --git a/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/hooks/useBridgeRecipientValidation.ts b/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/hooks/useBridgeRecipientValidation.ts index 63fdb8e04ef..a99523885d1 100644 --- a/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/hooks/useBridgeRecipientValidation.ts +++ b/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/hooks/useBridgeRecipientValidation.ts @@ -1,8 +1,10 @@ -import { useState, useMemo, useCallback, useRef } from "react"; -import { getAccountBridge } from "@ledgerhq/live-common/bridge/index"; import { getMainAccount } from "@ledgerhq/live-common/account/index"; -import type { Account, AccountLike } from "@ledgerhq/types-live"; +import { applyMemoToTransaction } from "@ledgerhq/live-common/bridge/descriptor"; +import { getAccountBridge } from "@ledgerhq/live-common/bridge/index"; import type { TransactionStatus } from "@ledgerhq/live-common/generated/types"; +import type { Account, AccountLike } from "@ledgerhq/types-live"; +import { useCallback, useMemo, useRef, useState } from "react"; +import type { Memo } from "@ledgerhq/live-common/flows/send/types"; import type { BridgeValidationErrors, BridgeValidationWarnings } from "../types"; export type BridgeRecipientValidationResult = { @@ -17,6 +19,7 @@ type UseBridgeRecipientValidationProps = { recipient: string; account: AccountLike | null; parentAccount?: Account | null; + memo?: Memo; enabled?: boolean; }; @@ -31,6 +34,7 @@ export function useBridgeRecipientValidation({ recipient, account, parentAccount, + memo, enabled = true, }: UseBridgeRecipientValidationProps): BridgeRecipientValidationResult { const [validationState, setValidationState] = useState<{ @@ -92,6 +96,16 @@ export function useBridgeRecipientValidation({ let transaction = bridge.createTransaction(mainAccount); transaction = bridge.updateTransaction(transaction, { recipient }); + if (memo) { + const memoUpdates = applyMemoToTransaction( + transaction.family, + memo.value, + memo.type, + transaction, + ); + transaction = bridge.updateTransaction(transaction, memoUpdates); + } + const preparedTransaction = await bridge.prepareTransaction(mainAccount, transaction); if (signal.aborted) return; @@ -133,7 +147,7 @@ export function useBridgeRecipientValidation({ status: null, }); } - }, [account, parentAccount, recipient, enabled]); + }, [account, recipient, enabled, parentAccount, memo]); if (recipient !== lastRecipientRef.current) { lastRecipientRef.current = recipient; diff --git a/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/hooks/useRecipientAddressModalViewModel.ts b/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/hooks/useRecipientAddressModalViewModel.ts index 2e29c0e4b75..d55d51ec74b 100644 --- a/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/hooks/useRecipientAddressModalViewModel.ts +++ b/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/hooks/useRecipientAddressModalViewModel.ts @@ -1,29 +1,29 @@ -import { useCallback, useMemo, useState } from "react"; -import { useSelector } from "LLD/hooks/redux"; import { getAccountCurrency, getMainAccount, getRecentAddressesStore, } from "@ledgerhq/live-common/account/index"; import { sendFeatures } from "@ledgerhq/live-common/bridge/descriptor"; +import type { CryptoCurrency, TokenCurrency } from "@ledgerhq/types-cryptoassets"; import type { Account, AccountLike, RecentAddress as RecentAddressFromStore, } from "@ledgerhq/types-live"; -import type { CryptoCurrency, TokenCurrency } from "@ledgerhq/types-cryptoassets"; +import { useSelector } from "LLD/hooks/redux"; +import { useCallback, useMemo, useState } from "react"; import { accountsSelector } from "~/renderer/reducers/accounts"; +import { useSendFlowData } from "../../../context/SendFlowContext"; import type { RecentAddress } from "../types"; +import { normalizeLastUsedTimestamp } from "../utils/dateFormatter"; import { useAddressValidation } from "./useAddressValidation"; import { useRecipientSearchState } from "./useRecipientSearchState"; -import { normalizeLastUsedTimestamp } from "../utils/dateFormatter"; -import { useSendFlowData } from "../../../context/SendFlowContext"; type UseRecipientAddressModalViewModelProps = Readonly<{ account: AccountLike; parentAccount?: Account; currency: CryptoCurrency | TokenCurrency; - onAddressSelected: (address: string, ensName?: string) => void; + onAddressSelected: (address: string, ensName?: string, goToNextStep?: boolean) => void; recipientSupportsDomain: boolean; }>; @@ -34,7 +34,7 @@ export function useRecipientAddressModalViewModel({ onAddressSelected, recipientSupportsDomain, }: UseRecipientAddressModalViewModelProps) { - const { recipientSearch } = useSendFlowData(); + const { recipientSearch, state } = useSendFlowData(); const [refreshCounter, setRefreshCounter] = useState(0); const mainAccount = getMainAccount(account, parentAccount); @@ -106,23 +106,50 @@ export function useRecipientAddressModalViewModel({ const hasUserAccounts = userAccountsForCurrency.length > 0; const showInitialEmptyState = showInitialState && !hasRecentAddresses && !hasUserAccounts; + const hasMemo = sendFeatures.hasMemo(currency); + const memoType = sendFeatures.getMemoType(currency); + const memoTypeOptions = sendFeatures.getMemoOptions(currency); + const memoDefaultOption = sendFeatures.getMemoDefaultOption(currency); + const memoMaxLength = sendFeatures.getMemoMaxLength(currency); + + const hasMemoValidationError = useMemo(() => { + if (!hasMemo) return false; + return Boolean(state.transaction.status.errors.transaction); + }, [hasMemo, state.transaction.status.errors.transaction]); + + const hasFilledMemo = useMemo(() => { + if (!hasMemo) return true; + const memo = state.recipient?.memo; + if (!memo) return false; + if (memo.type === "NO_MEMO") return true; + return memo.value.length > 0; + }, [hasMemo, state.recipient?.memo]); + const handleRecentAddressSelect = useCallback( (address: RecentAddress) => { - onAddressSelected(address.address, address.ensName); + if (hasMemo) { + recipientSearch.setValue(address.ensName ?? address.address); + } + + onAddressSelected(address.address, address.ensName, !hasMemo); }, - [onAddressSelected], + [hasMemo, onAddressSelected, recipientSearch], ); const handleAccountSelect = useCallback( (selectedAccount: Account) => { - onAddressSelected(selectedAccount.freshAddress); + if (hasMemo) { + recipientSearch.setValue(selectedAccount.freshAddress); + } + + onAddressSelected(selectedAccount.freshAddress, undefined, !hasMemo); }, - [onAddressSelected], + [hasMemo, onAddressSelected, recipientSearch], ); const handleAddressSelect = useCallback( (address: string, ensName?: string) => { - onAddressSelected(address, ensName); + onAddressSelected(address, ensName, true); }, [onAddressSelected], ); @@ -154,6 +181,13 @@ export function useRecipientAddressModalViewModel({ handleAccountSelect, handleAddressSelect, handleRemoveAddress, + hasMemo, + hasMemoValidationError, + hasFilledMemo, + memoType, + memoTypeOptions, + memoDefaultOption, + memoMaxLength, ...searchState, }; } diff --git a/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/hooks/useRecipientMemo.ts b/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/hooks/useRecipientMemo.ts new file mode 100644 index 00000000000..cb5e452e355 --- /dev/null +++ b/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/hooks/useRecipientMemo.ts @@ -0,0 +1,159 @@ +import { useCallback, useMemo, useRef, useState } from "react"; +import type { Memo } from "@ledgerhq/live-common/flows/send/types"; +import { useDoNotAskAgainSkipMemo } from "~/renderer/actions/settings"; + +export type SkipMemoState = "propose" | "toConfirm" | "confirmed"; + +type UseRecipientMemoProps = Readonly<{ + hasMemo: boolean; + memoDefaultOption?: string; + memoType?: string; + memoTypeOptions?: readonly string[]; + onMemoChange: (memo: Memo) => void; + onMemoSkip: () => void; + resetKey: string; +}>; + +type UseRecipientMemoResult = Readonly<{ + memo: Memo; + hasMemoTypeOptions: boolean; + showMemoValueInput: boolean; + showSkipMemo: boolean; + skipMemoState: SkipMemoState; + hasFilledMemo: boolean; + onMemoValueChange: (value: string) => void; + onMemoTypeChange: (type: string) => void; + onSkipMemoRequestConfirm: () => void; + onSkipMemoCancelConfirm: () => void; + onSkipMemoConfirm: (doNotAskAgain: boolean) => void; + resetViewState: () => void; +}>; + +function buildDefaultMemo(memoDefaultOption?: string): Memo { + return { value: "", type: memoDefaultOption }; +} + +export function useRecipientMemo({ + hasMemo, + memoDefaultOption, + memoType, + memoTypeOptions, + onMemoChange, + onMemoSkip, + resetKey, +}: UseRecipientMemoProps): UseRecipientMemoResult { + const [memo, setMemo] = useState(() => buildDefaultMemo(memoDefaultOption)); + const [skipMemoState, setSkipMemoState] = useState("propose"); + + const lastResetKeyRef = useRef(""); + if (lastResetKeyRef.current !== resetKey) { + lastResetKeyRef.current = resetKey; + const nextDefaultMemo = buildDefaultMemo(memoDefaultOption); + + if (memo.value !== nextDefaultMemo.value || memo.type !== nextDefaultMemo.type) { + setMemo(nextDefaultMemo); + onMemoChange(nextDefaultMemo); + } + + if (skipMemoState !== "propose") { + setSkipMemoState("propose"); + } + } + + const hasMemoTypeOptions = Boolean(memoType === "typed" && memoTypeOptions?.length); + + const showMemoValueInput = memo.type !== "NO_MEMO"; + + const showSkipMemo = useMemo((): boolean => { + if (!hasMemo) return false; + const noMemoWithoutType = !memo.type && memo.value.length === 0; + const noMemoWithType = Boolean(memo.type && memo.type !== "NO_MEMO" && memo.value.length === 0); + return Boolean(noMemoWithoutType || noMemoWithType); + }, [hasMemo, memo.type, memo.value.length]); + + const hasFilledMemo = useMemo(() => { + if (!hasMemo) return true; + return memo.value.length > 0 || memo.type === "NO_MEMO"; + }, [hasMemo, memo.type, memo.value.length]); + + const onMemoValueChange = useCallback( + (value: string) => { + setMemo((prev: Memo) => { + if (skipMemoState !== "propose") { + setSkipMemoState("propose"); + } + + const next = { ...prev, value }; + onMemoChange(next); + return next; + }); + }, + [onMemoChange, skipMemoState], + ); + + const onMemoTypeChange = useCallback( + (type: string) => { + if (skipMemoState !== "propose") { + setSkipMemoState("propose"); + } + + const next: Memo = { value: "", type }; + setMemo(next); + onMemoChange(next); + }, + [onMemoChange, skipMemoState], + ); + + const [doNotAskAgainSkipMemo, setDoNotAskAgainSkipMemo] = useDoNotAskAgainSkipMemo(); + + const onSkipMemoCancelConfirm = useCallback(() => { + setSkipMemoState("propose"); + }, []); + + const onSkipMemoConfirm = useCallback( + (doNotAskAgain: boolean) => { + if (doNotAskAgainSkipMemo !== doNotAskAgain) { + setDoNotAskAgainSkipMemo(doNotAskAgain); + } + + setSkipMemoState("confirmed"); + const next: Memo = { value: "", type: "NO_MEMO" }; + setMemo(next); + onMemoChange(next); + onMemoSkip(); + }, + [onMemoChange, onMemoSkip, setDoNotAskAgainSkipMemo, doNotAskAgainSkipMemo], + ); + + const onSkipMemoRequestConfirm = useCallback(() => { + if (doNotAskAgainSkipMemo) { + onSkipMemoConfirm(doNotAskAgainSkipMemo); + } else { + setSkipMemoState("toConfirm"); + } + }, [doNotAskAgainSkipMemo, onSkipMemoConfirm]); + + const resetViewState = useCallback(() => { + if (skipMemoState === "confirmed") { + const defaultMemo = buildDefaultMemo(memoDefaultOption); + setMemo(defaultMemo); + onMemoChange(defaultMemo); + setSkipMemoState("propose"); + } + }, [memoDefaultOption, onMemoChange, setMemo, skipMemoState, setSkipMemoState]); + + return { + memo, + hasMemoTypeOptions, + showMemoValueInput, + showSkipMemo, + skipMemoState, + hasFilledMemo, + onMemoValueChange, + onMemoTypeChange, + onSkipMemoRequestConfirm, + onSkipMemoCancelConfirm, + onSkipMemoConfirm, + resetViewState, + }; +} diff --git a/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/types.ts b/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/types.ts index bb59c3e7c52..02317a64cc0 100644 --- a/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/types.ts +++ b/apps/ledger-live-desktop/src/mvvm/features/Send/screens/Recipient/types.ts @@ -36,6 +36,7 @@ export type MatchedAccount = Readonly<{ export type BridgeValidationErrors = { recipient?: Error; sender?: Error; + transaction?: Error; }; export type BridgeValidationWarnings = Record; diff --git a/apps/ledger-live-desktop/src/renderer/actions/settings.ts b/apps/ledger-live-desktop/src/renderer/actions/settings.ts index e4434407403..e74ea94c0b0 100644 --- a/apps/ledger-live-desktop/src/renderer/actions/settings.ts +++ b/apps/ledger-live-desktop/src/renderer/actions/settings.ts @@ -20,6 +20,7 @@ import { VaultSigner, SupportedCountervaluesData, CurrencySettings, + doNotAskAgainSkipMemoSelector, } from "~/renderer/reducers/settings"; import { useRefreshAccountsOrdering } from "~/renderer/actions/general"; import { Language, Locale } from "~/config/languages"; @@ -161,6 +162,21 @@ export function useFilterTokenOperationsZeroAmount(): [ ); return [value, setter]; } +export function useDoNotAskAgainSkipMemo(): [boolean, (doNotAskAgainSkipMemo: boolean) => void] { + const dispatch = useDispatch(); + const value = useSelector(doNotAskAgainSkipMemoSelector); + const setter = useCallback( + (doNotAskAgainSkipMemo: boolean) => { + dispatch( + saveSettings({ + doNotAskAgainSkipMemo, + }), + ); + }, + [dispatch], + ); + return [value, setter]; +} export type PortfolioRangeOption = { key: PortfolioRange; value: string; diff --git a/apps/ledger-live-desktop/src/renderer/reducers/settings.ts b/apps/ledger-live-desktop/src/renderer/reducers/settings.ts index 909bff0e2e0..dfa2a0461a4 100644 --- a/apps/ledger-live-desktop/src/renderer/reducers/settings.ts +++ b/apps/ledger-live-desktop/src/renderer/reducers/settings.ts @@ -126,6 +126,7 @@ export type SettingsState = { lastOnboardedDevice: Device | null; alwaysShowMemoTagInfo: boolean; anonymousUserNotifications: { LNSUpsell?: number } & Record; + doNotAskAgainSkipMemo: boolean; }; export const getInitialLanguageAndLocale = (): { language: Language; locale: Locale } => { @@ -224,6 +225,7 @@ export const INITIAL_STATE: SettingsState = { lastOnboardedDevice: null, alwaysShowMemoTagInfo: true, anonymousUserNotifications: {}, + doNotAskAgainSkipMemo: false, }; /* Handlers */ @@ -731,6 +733,8 @@ export const hideEmptyTokenAccountsSelector = (state: State) => state.settings.hideEmptyTokenAccounts; export const filterTokenOperationsZeroAmountSelector = (state: State) => state.settings.filterTokenOperationsZeroAmount; + +export const doNotAskAgainSkipMemoSelector = (state: State) => state.settings.doNotAskAgainSkipMemo; export const lastSeenDeviceSelector = (state: State): DeviceModelInfo | null | undefined => { const { lastSeenDevice } = state.settings; if (!lastSeenDevice || !Object.values(DeviceModelId).includes(lastSeenDevice.modelId)) diff --git a/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Accounts/DoNotAskAgainSkipMemo.tsx b/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Accounts/DoNotAskAgainSkipMemo.tsx new file mode 100644 index 00000000000..d1267e487c5 --- /dev/null +++ b/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Accounts/DoNotAskAgainSkipMemo.tsx @@ -0,0 +1,19 @@ +import React from "react"; +import { useTranslation } from "react-i18next"; +import { SettingsSectionRow as Row } from "~/renderer/screens/settings/SettingsSection"; +import { useDoNotAskAgainSkipMemo } from "~/renderer/actions/settings"; +import Switch from "~/renderer/components/Switch"; + +export default function DoNotAskAgainSkipMemo() { + const [doNotAskAgainSkipMemo, setDoNotAskAgainSkipMemo] = useDoNotAskAgainSkipMemo(); + const { t } = useTranslation(); + + return ( + + + + ); +} diff --git a/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Accounts/index.tsx b/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Accounts/index.tsx index 76debecab48..4e855ef5a9f 100644 --- a/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Accounts/index.tsx +++ b/apps/ledger-live-desktop/src/renderer/screens/settings/sections/Accounts/index.tsx @@ -7,6 +7,7 @@ import FilterTokenOperationsZeroAmount from "./FilterTokenOperationsZeroAmount"; import SectionExport from "./Export"; import Currencies from "./Currencies"; import BlacklistedTokens from "./BlacklistedTokens"; +import DoNotAskAgainSkipMemo from "./DoNotAskAgainSkipMemo"; export default function SectionAccounts() { const { t } = useTranslation(); @@ -22,6 +23,7 @@ export default function SectionAccounts() { + diff --git a/apps/ledger-live-desktop/static/i18n/en/app.json b/apps/ledger-live-desktop/static/i18n/en/app.json index 2656d20268b..068a80313cd 100644 --- a/apps/ledger-live-desktop/static/i18n/en/app.json +++ b/apps/ledger-live-desktop/static/i18n/en/app.json @@ -152,7 +152,8 @@ "quote": "Quote", "memoTag": { "learnMore": "Learn more about Tag/Memo" - } + }, + "memo": "Memo" }, "devices": { "nanoS": "Nano S", @@ -2348,12 +2349,15 @@ "walletNotExist": "Wallet doesn’t exist" }, "skipMemo": { - "title": "Skip adding {{tag}}?", - "warning": "Your asset may be lost if a {{tag}} is required by your recipient" + "title": "Skip adding {{memoLabel}}?", + "description": "Your asset may be lost if a {{memoLabel}} is required by your recipient", + "notRequired": "{{memoLabel}} not required by recipient?", + "confirm": "Yes skip", + "neverAskAgain": "Don't show this warning again" }, "tagHelp": { - "title": "What’s a {{tag}}?", - "description": "A {{tag}} is required when sending {{currency}} to a crypto exchange. It’s a feature used to identify the recipient of the transaction. Not including the {{tag}} memo may result in a loss of funds." + "inputLabel": "Enter {{memoLabel}}", + "description": "A {{memoLabel}} is required when sending {{currency}} to a crypto exchange. It’s a feature used to identify the recipient of the transaction. Not including the {{memoLabel}} memo may result in a loss of funds." }, "networkFeesInFiat": "Network fees in {{currency}}", "feesAmount": "Fees amount ({{unit}})", @@ -4881,6 +4885,10 @@ "filterTokenOperationsZeroAmount": { "title": "Hide token transactions when value is 0", "desc": "Hiding token transactions without value can prevent some potential address poisoning attacks on Eth, Polygon, BSC, Tron..." + }, + "doNotAskAgainSkipMemo": { + "title": "Do not ask again skip memo on send", + "desc": "Disable the warning about skipping the memo when sending funds" } } }, @@ -5795,11 +5803,13 @@ "casper": { "transferIdPlaceholder": "Optional", "transferId": "Transfer ID (Memo)", - "transferIdWarningText": "When using a memo, carefully check the information with the recipient" + "transferIdWarningText": "When using a memo, carefully check the information with the recipient", + "memo": "Transfer ID (Memo)" }, "ton": { "commentPlaceholder": "Optional", - "comment": "Comment" + "comment": "Comment", + "memo": "Comment" }, "canton": { "commentPlaceholder": "Optional", @@ -5957,6 +5967,9 @@ }, "mina": { "memoPlaceholder": "Optional Memo" + }, + "xrp": { + "memo": "Tag" } }, "errors": { diff --git a/apps/ledger-live-desktop/tests/specs/settings/settings.spec.ts-snapshots/settings-accounts-page-linux.png b/apps/ledger-live-desktop/tests/specs/settings/settings.spec.ts-snapshots/settings-accounts-page-linux.png index d79a2753048..9ce264bd9c4 100644 Binary files a/apps/ledger-live-desktop/tests/specs/settings/settings.spec.ts-snapshots/settings-accounts-page-linux.png and b/apps/ledger-live-desktop/tests/specs/settings/settings.spec.ts-snapshots/settings-accounts-page-linux.png differ diff --git a/knip.json b/knip.json index 6fd6df65c22..8e7e20dbe26 100644 --- a/knip.json +++ b/knip.json @@ -47,7 +47,8 @@ ".storybook/fsStub.js", ".storybook/settingsMock.ts", "src/renderer/mockServiceWorker.js", - "src/mvvm/utils/cn.ts" + "src/mvvm/utils/cn.ts", + "src/mvvm/features/Send/hooks/useAvailableBalance.ts" ], "ignoreDependencies": [ "prop-types", diff --git a/libs/ledger-live-common/src/bridge/descriptor.test.ts b/libs/ledger-live-common/src/bridge/descriptor.test.ts index 27f42cf6c6d..3c41bf91e95 100644 --- a/libs/ledger-live-common/src/bridge/descriptor.test.ts +++ b/libs/ledger-live-common/src/bridge/descriptor.test.ts @@ -320,7 +320,7 @@ describe("sendFeatures", () => { it("should get memo default option", () => { const stellar = getCryptoCurrencyById("stellar"); - expect(sendFeatures.getMemoDefaultOption(stellar)).toBe("MEMO_ID"); + expect(sendFeatures.getMemoDefaultOption(stellar)).toBe("MEMO_TEXT"); }); it("should return undefined when memo has no default option", () => { diff --git a/libs/ledger-live-common/src/bridge/descriptor.ts b/libs/ledger-live-common/src/bridge/descriptor.ts index 7f74751e23e..3e8bbfbe5ee 100644 --- a/libs/ledger-live-common/src/bridge/descriptor.ts +++ b/libs/ledger-live-common/src/bridge/descriptor.ts @@ -143,6 +143,7 @@ export type CoinDescriptor = { type MemoApplicationFn = ( memoValue: string | number | undefined, + memoType: string | undefined, currentTransaction: Record, ) => Record; @@ -215,7 +216,7 @@ export function getSendDescriptor( * Helper functions to check send flow capabilities */ const memoApplicationRegistry: Record = { - solana: (memo, transaction) => { + solana: (memo, _type, transaction) => { const currentModel = (transaction.model as Record | undefined) || {}; const currentUiState = (currentModel.uiState as Record | undefined) || {}; return { @@ -234,8 +235,8 @@ const memoApplicationRegistry: Record = { if (typeof memo === "string") return { tag: Number(memo) }; return { tag: undefined }; }, - stellar: memo => ({ memoValue: memo }), - ton: (memo, transaction) => { + stellar: (memo, type) => ({ memoValue: memo, memoType: type }), + ton: (memo, _type, transaction) => { const currentComment = (transaction.comment as Record | undefined) || {}; return { comment: { @@ -249,13 +250,26 @@ const memoApplicationRegistry: Record = { export function applyMemoToTransaction( family: string, memoValue: string | number | undefined, - currentTransaction: Record = {}, + memoTypeOrTransaction?: string | Record | null, + currentTransaction?: Record, ): Record { + const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null; + + const memoType = + memoTypeOrTransaction === undefined || typeof memoTypeOrTransaction === "string" + ? memoTypeOrTransaction + : undefined; + + const transaction = isRecord(memoTypeOrTransaction) + ? memoTypeOrTransaction + : currentTransaction ?? {}; + const applyFn = memoApplicationRegistry[family]; if (!applyFn) { return { memo: memoValue }; } - return applyFn(memoValue, currentTransaction); + return applyFn(memoValue, memoType, transaction); } export const sendFeatures = { @@ -309,7 +323,7 @@ export const sendFeatures = { const descriptor = getSendDescriptor(currency); return descriptor?.inputs.memo?.options; }, - getMemoDefaultOption: (currency: CryptoOrTokenCurrency | undefined): string | undefined => { + getMemoDefaultOption: (currency: CryptoOrTokenCurrency | undefined) => { const descriptor = getSendDescriptor(currency); return descriptor?.inputs.memo?.defaultOption; }, diff --git a/libs/ledger-live-common/src/families/stellar/descriptor.ts b/libs/ledger-live-common/src/families/stellar/descriptor.ts index 79ea3d9d84d..eaadfee8d9f 100644 --- a/libs/ledger-live-common/src/families/stellar/descriptor.ts +++ b/libs/ledger-live-common/src/families/stellar/descriptor.ts @@ -7,7 +7,7 @@ export const descriptor: CoinDescriptor = { memo: { type: "typed", options: StellarMemoType, - defaultOption: "MEMO_ID", + defaultOption: "MEMO_TEXT", }, }, fees: { diff --git a/libs/ledger-live-common/src/flows/send/types.ts b/libs/ledger-live-common/src/flows/send/types.ts index 8f1737f9f36..8cdaa044885 100644 --- a/libs/ledger-live-common/src/flows/send/types.ts +++ b/libs/ledger-live-common/src/flows/send/types.ts @@ -33,10 +33,12 @@ export type SendFlowUiConfig = Readonly<{ hasCoinControl: boolean; }>; +export type Memo = { value: string; type?: string }; + export type RecipientData = Readonly<{ - address: string; + address?: string; ensName?: string; - memo?: string; + memo?: Memo; destinationTag?: string; }>;