From 903dd5c5016f4db4d5127245e6d6503b2d838040 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Vytick=20Vytrhl=C3=ADk?= Date: Tue, 11 Feb 2025 01:35:00 +0100 Subject: [PATCH] feat(suite-native): ripple asks for destination tag by default in send --- suite-native/intl/src/en.ts | 10 +- .../src/components/DestinationTagInput.tsx | 161 +++++++++--------- .../src/screens/SendOutputsScreen.tsx | 8 +- .../module-send/src/sendOutputsFormSchema.ts | 28 ++- .../src/screens/ReceiveAddressScreen.tsx | 28 ++- 5 files changed, 153 insertions(+), 82 deletions(-) diff --git a/suite-native/intl/src/en.ts b/suite-native/intl/src/en.ts index a3f1bac360c..a918051cdfd 100644 --- a/suite-native/intl/src/en.ts +++ b/suite-native/intl/src/en.ts @@ -517,6 +517,8 @@ export const en = { receiveTitle: 'Receive', screenTitle: '{coinSymbol} Receive address', deviceCancelError: 'Address confirmation canceled.', + xrpDestinationTag: + 'When sending XRP to Trezor, your online exchange may require a destination tag, but Trezor doesn’t. Enter any random number to proceed. Learn more.', receiveAddressCard: { alert: { success: 'Receive address has been confirmed on your Trezor.', @@ -1130,7 +1132,13 @@ export const en = { addressQrLabel: 'Scan recipient address', amountLabel: 'Amount to be sent', maxButton: 'Send max', - destinationTagLabel: 'Destination tag', + destinationTag: { + label: 'Destination tag', + warning: + 'Online exchanges require this to identify your account. Get your destination tag from your Ripple account. Make sure you really don’t need it.', + info: 'Online exchanges require this to identify your account. Get your destination tag from your exchange.', + linkText: 'What’s this?', + }, }, }, fees: { diff --git a/suite-native/module-send/src/components/DestinationTagInput.tsx b/suite-native/module-send/src/components/DestinationTagInput.tsx index e583f779126..84d7a7be25a 100644 --- a/suite-native/module-send/src/components/DestinationTagInput.tsx +++ b/suite-native/module-send/src/components/DestinationTagInput.tsx @@ -1,78 +1,59 @@ import { useRef, useState } from 'react'; import { TextInput, View, findNodeHandle } from 'react-native'; -import Animated, { - FadeIn, - FadeOut, - LinearTransition, - useSharedValue, -} from 'react-native-reanimated'; -import { useSelector } from 'react-redux'; +import Animated, { FadeIn, FadeOut, useSharedValue } from 'react-native-reanimated'; -import { useRoute } from '@react-navigation/native'; - -import { Box, IconButton, Text } from '@suite-native/atoms'; +import { AlertBox, AnimatedVStack, HStack, Switch, Text, VStack } from '@suite-native/atoms'; import { TextInputField, useFormContext } from '@suite-native/forms'; +import { Icon } from '@suite-native/icons'; import { Translation } from '@suite-native/intl'; -import { - SendStackParamList, - SendStackRoutes, - StackProps, - useScrollView, -} from '@suite-native/navigation'; +import { Link } from '@suite-native/link'; +import { useScrollView } from '@suite-native/navigation'; import { useDebounce } from '@trezor/react-utils'; import { prepareNativeStyle, useNativeStyles } from '@trezor/styles'; import { integerTransformer } from '../hooks/useSendAmountTransformers'; -import { NativeSendRootState, selectRippleDestinationTagFromDraft } from '../sendFormSlice'; import { SendFieldName, SendOutputsFormValues } from '../sendOutputsFormSchema'; -const inputWrapperStyle = prepareNativeStyle<{ isInputDisplayed: boolean }>( - (utils, { isInputDisplayed }) => ({ - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', +const titleTextStyle = prepareNativeStyle(utils => ({ + flex: 1, + gap: utils.spacings.sp12, +})); - extend: { - condition: isInputDisplayed, - style: { - alignItems: undefined, - flexDirection: 'column', - gap: utils.spacings.sp12, - }, - }, - }), -); +const inputWrapperStyle = prepareNativeStyle(utils => ({ + justifyContent: 'space-between', + flexDirection: 'column', + gap: utils.spacings.sp12, +})); const SCROLL_TO_DELAY = 200; -type RouteProps = StackProps['route']; - export const DestinationTagInput = () => { - const route = useRoute(); - const { accountKey, tokenContract } = route.params; const inputWrapperRef = useRef(null); const inputRef = useRef(null); const inputHeight = useSharedValue(null); const scrollView = useScrollView(); const { applyStyle } = useNativeStyles(); - const isDestinationTagPresentInDraft = !!useSelector((state: NativeSendRootState) => - selectRippleDestinationTagFromDraft(state, accountKey, tokenContract), - ); - - const [isInputDisplayed, setIsInputDisplayed] = useState(isDestinationTagPresentInDraft); + const [isInputDisplayed, setIsInputDisplayed] = useState(true); const destinationTagFieldName: SendFieldName = 'rippleDestinationTag'; - const debounce = useDebounce(); - - const { trigger } = useFormContext(); + const isRippleDestinationTagEnabledFieldName: SendFieldName = 'isRippleDestinationTagEnabled'; - const handleShowInput = () => { - setIsInputDisplayed(true); + const debounce = useDebounce(); - // Wait for input element to be mounted. - setTimeout(() => { - inputRef.current?.focus(); - }); + const { trigger, setValue } = useFormContext(); + + const handleShowInputChange = () => { + if (!isInputDisplayed) { + setValue(isRippleDestinationTagEnabledFieldName, true); + // Wait for input element to be mounted. + setTimeout(() => { + inputRef.current?.focus(); + }); + } else { + setValue(isRippleDestinationTagEnabledFieldName, false); + } + trigger(destinationTagFieldName); + setIsInputDisplayed(!isInputDisplayed); }; const handleInputFocus = () => { @@ -101,36 +82,60 @@ export const DestinationTagInput = () => { }; return ( - - - + + + - + - - {!isInputDisplayed && ( - - - - )} - {isInputDisplayed && ( - - + ( + + ), + }} /> - - )} - - + + + + + {isInputDisplayed ? ( + + + + + + + + + + ) : ( + + + } + /> + + )} + ); }; diff --git a/suite-native/module-send/src/screens/SendOutputsScreen.tsx b/suite-native/module-send/src/screens/SendOutputsScreen.tsx index d67eb96aee0..7b3b6b252b3 100644 --- a/suite-native/module-send/src/screens/SendOutputsScreen.tsx +++ b/suite-native/module-send/src/screens/SendOutputsScreen.tsx @@ -49,10 +49,13 @@ import { constructFormDraft } from '../utils'; const getDefaultValues = ({ tokenContract, + isRippleDestinationTagEnabled, }: { tokenContract?: TokenAddress; + isRippleDestinationTagEnabled: boolean; }): Readonly => ({ + isRippleDestinationTagEnabled, outputs: [ { amount: '', @@ -113,7 +116,10 @@ export const SendOutputsScreen = ({ decimals: tokenInfo?.decimals ?? network?.decimals, isTaprootAvailable: !deviceUnavailableCapabilities?.taproot, }, - defaultValues: getDefaultValues({ tokenContract }), + defaultValues: getDefaultValues({ + tokenContract, + isRippleDestinationTagEnabled: network?.networkType === 'ripple', + }), }); const { diff --git a/suite-native/module-send/src/sendOutputsFormSchema.ts b/suite-native/module-send/src/sendOutputsFormSchema.ts index 38fa1fab856..fb0a1b69b0e 100644 --- a/suite-native/module-send/src/sendOutputsFormSchema.ts +++ b/suite-native/module-send/src/sendOutputsFormSchema.ts @@ -194,10 +194,36 @@ export const sendOutputsFormValidationSchema = yup.object({ }), ) .required(), + isRippleDestinationTagEnabled: yup.boolean(), rippleDestinationTag: yup .string() - .optional() + .when('isRippleDestinationTagEnabled', { + is: true, + then: schema => schema.required('Destination Tag is required'), + otherwise: schema => schema.notRequired(), + }) .matches(/^\d*$/, 'You can only use positive numbers for the destination tag.') + .test( + 'is-destination-tag-required', + 'Destination tag was not set.', + ( + value, + { + options: { context }, + schema: { isRippleDestinationTagEnabled }, + }: yup.TestContext, + ) => { + const { symbol } = context!; + + if (!symbol) return true; + if (getNetworkType(symbol) !== 'ripple') return true; + + // isRippleDestinationTagEnabled is enabled, tag should be set + if (!value && isRippleDestinationTagEnabled) return false; + + return true; + }, + ) .test( 'is-destination-tag-in-range', 'Destination tag is too high.', diff --git a/suite-native/receive/src/screens/ReceiveAddressScreen.tsx b/suite-native/receive/src/screens/ReceiveAddressScreen.tsx index 6073fbb2152..e46df23c349 100644 --- a/suite-native/receive/src/screens/ReceiveAddressScreen.tsx +++ b/suite-native/receive/src/screens/ReceiveAddressScreen.tsx @@ -10,12 +10,13 @@ import { } from '@suite-common/wallet-core'; import { AccountKey, TokenAddress } from '@suite-common/wallet-types'; import { AccountDetailsCard } from '@suite-native/accounts'; -import { Box, ErrorMessage, VStack } from '@suite-native/atoms'; +import { AlertBox, Box, ErrorMessage, VStack } from '@suite-native/atoms'; import { ConfirmOnTrezorImage, selectHasFirmwareAuthenticityCheckHardFailed, } from '@suite-native/device'; import { Translation } from '@suite-native/intl'; +import { Link } from '@suite-native/link'; import { CloseActionType, Screen } from '@suite-native/navigation'; import { ReceiveBlockedDeviceCompromisedScreen } from './ReceiveBlockedDeviceCompromisedScreen'; @@ -66,6 +67,8 @@ export const ReceiveAddressScreen = ({ const isConfirmOnTrezorReady = isUnverifiedAddressRevealed && !isReceiveApproved && hasReceiveButtonRequest; + const showXrpInfo = account.networkType === 'ripple'; + return ( + {showXrpInfo && ( + ( + + ), + }} + /> + } + /> + )} {isAccountDetailVisible && ( )}