Skip to content

Commit

Permalink
feat(suite-native): ripple asks for destination tag by default in send
Browse files Browse the repository at this point in the history
  • Loading branch information
vytick committed Feb 11, 2025
1 parent dbdd244 commit 9cd5d10
Show file tree
Hide file tree
Showing 5 changed files with 147 additions and 81 deletions.
10 changes: 9 additions & 1 deletion suite-native/intl/src/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. <link>Learn more.</link>',
receiveAddressCard: {
alert: {
success: 'Receive address has been confirmed on your Trezor.',
Expand Down Expand Up @@ -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: '<link>What’s this?</link>',
},
},
},
fees: {
Expand Down
165 changes: 87 additions & 78 deletions suite-native/module-send/src/components/DestinationTagInput.tsx
Original file line number Diff line number Diff line change
@@ -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, 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<SendStackParamList, SendStackRoutes.SendOutputs>['route'];

export const DestinationTagInput = () => {
const route = useRoute<RouteProps>();
const { accountKey, tokenContract } = route.params;
const inputWrapperRef = useRef<View | null>(null);
const inputRef = useRef<TextInput | null>(null);
const inputHeight = useSharedValue<number | null>(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<SendOutputsFormValues>();
const enableDestinationTagValidationName: SendFieldName = 'enableDestinationTagValidation';

const handleShowInput = () => {
setIsInputDisplayed(true);
const debounce = useDebounce();

// Wait for input element to be mounted.
setTimeout(() => {
inputRef.current?.focus();
});
const { trigger, setValue } = useFormContext<SendOutputsFormValues>();

const handleShowInputChange = () => {
if (!isInputDisplayed) {
setValue(enableDestinationTagValidationName, true);
// Wait for input element to be mounted.
setTimeout(() => {
inputRef.current?.focus();
});
} else {
setValue(enableDestinationTagValidationName, false);
}
trigger(destinationTagFieldName);
setIsInputDisplayed(!isInputDisplayed);
};

const handleInputFocus = () => {
Expand Down Expand Up @@ -101,36 +82,64 @@ export const DestinationTagInput = () => {
};

return (
<Animated.View layout={LinearTransition} ref={inputWrapperRef}>
<Box style={applyStyle(inputWrapperStyle, { isInputDisplayed })}>
<Animated.View layout={LinearTransition}>
<VStack style={applyStyle(inputWrapperStyle)}>
<HStack alignContent="space-between" alignItems="center">
<HStack style={applyStyle(titleTextStyle)}>
<Text variant="hint">
<Translation id="moduleSend.outputs.recipients.destinationTagLabel" />
<Translation id="moduleSend.outputs.recipients.destinationTag.label" />
</Text>
</Animated.View>
{!isInputDisplayed && (
<Animated.View entering={FadeIn} exiting={FadeOut}>
<IconButton
iconName="plus"
colorScheme="tertiaryElevation1"
onPress={handleShowInput}
/>
</Animated.View>
)}
{isInputDisplayed && (
<Animated.View entering={FadeIn} exiting={FadeOut}>
<TextInputField
valueTransformer={integerTransformer}
ref={inputRef}
onChangeText={handleChangeValue}
name={destinationTagFieldName}
testID={destinationTagFieldName}
onFocus={handleInputFocus}
accessibilityLabel="address input"
<Text variant="hint">
<Translation
id="moduleSend.outputs.recipients.destinationTag.linkText"
values={{
link: chunk => (
<Link
label={chunk}
textVariant="hint"
href="https://trezor.io/learn/a/destination-tags"
isUnderlined
textColor="textDefault"
textPressedColor="textSubdued"
/>
),
}}
/>
</Animated.View>
)}
</Box>
</Animated.View>
</Text>
</HStack>
<Switch isChecked={isInputDisplayed} onChange={handleShowInputChange} />
</HStack>
{isInputDisplayed ? (
<Animated.View entering={FadeIn} exiting={FadeOut}>
<TextInputField
valueTransformer={integerTransformer}
ref={inputRef}
onChangeText={handleChangeValue}
name={destinationTagFieldName}
testID={destinationTagFieldName}
onFocus={handleInputFocus}
accessibilityLabel="address input"
/>
</Animated.View>
) : (
<Animated.View entering={FadeIn} exiting={FadeOut}>
<AlertBox
variant="warning"
title={
<Translation id="moduleSend.outputs.recipients.destinationTag.warning" />
}
/>
</Animated.View>
)}
{isInputDisplayed && (
<Animated.View entering={FadeIn}>
<HStack paddingHorizontal="sp12" spacing="sp4">
<Icon name="info" color="iconSubdued" size="medium" />
<Text variant="label" color="textSubdued">
<Translation id="moduleSend.outputs.recipients.destinationTag.info" />
</Text>
</HStack>
</Animated.View>
)}
</VStack>
);
};
1 change: 1 addition & 0 deletions suite-native/module-send/src/screens/SendOutputsScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const getDefaultValues = ({
tokenContract?: TokenAddress;
}): Readonly<SendOutputsFormValues> =>
({
enableDestinationTagValidation: true,
outputs: [
{
amount: '',
Expand Down
28 changes: 27 additions & 1 deletion suite-native/module-send/src/sendOutputsFormSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,10 +194,36 @@ export const sendOutputsFormValidationSchema = yup.object({
}),
)
.required(),
enableDestinationTagValidation: yup.boolean(),
rippleDestinationTag: yup
.string()
.optional()
.when('enableDestinationTagValidation', {
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-set',
'Destination tag was not set.',
(
value,
{
options: { context },
schema: { enableDestinationTagValidation },
}: yup.TestContext<SendFormFormContext>,
) => {
const { symbol } = context!;

if (!symbol) return true;
if (getNetworkType(symbol) !== 'ripple') return true;

// enableDestinationTagValidation is enabled, tag should be set
if (!value && enableDestinationTagValidation) return false;

return true;
},
)
.test(
'is-destination-tag-in-range',
'Destination tag is too high.',
Expand Down
24 changes: 23 additions & 1 deletion suite-native/receive/src/screens/ReceiveAddressScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -87,6 +88,27 @@ export const ReceiveAddressScreen = ({
>
<Box flex={1}>
<VStack marginTop="sp8" spacing="sp16">
<AlertBox
variant="info"
contentColor="textDefault"
title={
<Translation
id="moduleReceive.xrpDestinationTag"
values={{
link: chunk => (
<Link
label={chunk}
textVariant="label"
href="https://trezor.io/learn/a/destination-tags"
isUnderlined
textColor="textDefault"
textPressedColor="textSubdued"
/>
),
}}
/>
}
/>
{isAccountDetailVisible && (
<AccountDetailsCard accountKey={accountKey} tokenContract={tokenContract} />
)}
Expand Down

0 comments on commit 9cd5d10

Please sign in to comment.