Skip to content

Commit

Permalink
feat(suite-native): ripple send
Browse files Browse the repository at this point in the history
  • Loading branch information
PeKne committed Nov 19, 2024
1 parent 126b478 commit 57e6730
Show file tree
Hide file tree
Showing 26 changed files with 689 additions and 219 deletions.
1 change: 1 addition & 0 deletions suite-native/atoms/src/Hint.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const hintStyle = prepareNativeStyle(() => ({
const hintTextStyle = prepareNativeStyle<{ color: Color }>((utils, { color }) => ({
...utils.typography.label,
color: utils.colors[color],
flex: 1,
}));

const hintVariants: Record<HintVariant, { iconName: IconName; color: Color }> = {
Expand Down
13 changes: 11 additions & 2 deletions suite-native/atoms/src/Input/InputWrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ReactNode } from 'react';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';

import { prepareNativeStyle, useNativeStyles } from '@trezor/styles';

Expand Down Expand Up @@ -52,8 +53,16 @@ export const InputWrapper = ({ children, label, hint, error }: InputWrapperProps
)}
<Box>{children}</Box>
<Box style={applyStyle(hintStyle, { error, hint })}>
{!!error && <Hint variant="error">{errorMessage}</Hint>}
{!!hint && <Hint>{hint}</Hint>}
{!!error && (
<Animated.View entering={FadeIn} exiting={FadeOut}>
<Hint variant="error">{errorMessage}</Hint>
</Animated.View>
)}
{!!hint && (
<Animated.View entering={FadeIn} exiting={FadeOut}>
<Hint>{hint}</Hint>
</Animated.View>
)}
</Box>
</Box>
);
Expand Down
3 changes: 3 additions & 0 deletions suite-native/intl/src/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1003,6 +1003,7 @@ export const en = {
addressQrLabel: 'Scan recipient address',
amountLabel: 'Amount to be sent',
maxButton: 'Send max',
destinationTagLabel: 'Destination tag',
},
},
fees: {
Expand Down Expand Up @@ -1051,6 +1052,7 @@ export const en = {
primaryButton: 'Reconnect Trezor',
},
lockedToast: 'Device is locked.',
destinationTagTitle: 'Check & confirm XRP destination tag on your Trezor.',
address: {
title: 'Check the address on your Trezor against the original to make sure it’s correct.',
step1: 'Go to the app or place where you originally got the address.',
Expand Down Expand Up @@ -1084,6 +1086,7 @@ export const en = {
title: 'Review with Trezor',
addressLabel: 'Recipient address',
amountLabel: 'Amount',
destinationTagLabel: 'Destination tag',
summary: {
label: 'Total including fee',
totalAmount: 'Total amount',
Expand Down
53 changes: 16 additions & 37 deletions suite-native/module-send/src/components/AddressReviewStepList.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { useCallback, useEffect, useState } from 'react';
import { LayoutChangeEvent, View, AppState } from 'react-native';
import { useDispatch, useSelector } from 'react-redux';
import { useSelector } from 'react-redux';

import { useSetAtom } from 'jotai';
import { isRejected } from '@reduxjs/toolkit';
import { useNavigation, useRoute, useFocusEffect } from '@react-navigation/native';

import {
Expand All @@ -19,19 +18,18 @@ import { AccountsRootState, DeviceRootState, SendRootState } from '@suite-common
import { nativeSpacings } from '@trezor/theme';

import {
cleanupSendFormThunk,
signTransactionNativeThunk as signTransactionThunk,
} from '../sendFormThunks';
import { selectIsFirstTransactionAddressConfirmed } from '../selectors';
selectIsReceiveAddressOutputConfirmed,
selectIsOutputsReviewInProgress,
} from '../selectors';
import { SlidingFooterOverlay } from '../components/SlidingFooterOverlay';
import { AddressReviewStep } from '../components/AddressReviewStep';
import { CompareAddressHelpButton } from '../components/CompareAddressHelpButton';
import { AddressOriginHelpButton } from '../components/AddressOriginHelpButton';
import { useHandleSendReviewFailure } from '../hooks/useHandleSendReviewFailure';
import { useHandleOnDeviceTransactionReview } from '../hooks/useHandleOnDeviceTransactionReview';
import { wasAppLeftDuringReviewAtom } from '../atoms/wasAppLeftDuringReviewAtom';

const NUMBER_OF_STEPS = 3;
const OVERLAY_INITIAL_POSITION = 75;
const OVERLAY_INITIAL_POSITION = 170;
const LIST_VERTICAL_SPACING = nativeSpacings.sp16;

type RouteProps = StackProps<SendStackParamList, SendStackRoutes.SendAddressReview>['route'];
Expand All @@ -43,18 +41,17 @@ type NavigationProps = StackToStackCompositeNavigationProps<

export const AddressReviewStepList = () => {
const route = useRoute<RouteProps>();
const { accountKey, transaction, tokenContract } = route.params;
const { accountKey, tokenContract } = route.params;
const navigation = useNavigation<NavigationProps>();
const dispatch = useDispatch();

const [childHeights, setChildHeights] = useState<number[]>([]);
const [stepIndex, setStepIndex] = useState(0);
const handleSendReviewFailure = useHandleSendReviewFailure({
accountKey,
transaction,
tokenContract,
});
const handleOnDeviceTransactionReview = useHandleOnDeviceTransactionReview();
const setWasAppLeftDuringReview = useSetAtom(wasAppLeftDuringReviewAtom);
const isReviewInProgress = useSelector(
(state: AccountsRootState & DeviceRootState & SendRootState) =>
selectIsOutputsReviewInProgress(state, accountKey, tokenContract),
);

useFocusEffect(
useCallback(() => {
Expand All @@ -72,12 +69,11 @@ export const AddressReviewStepList = () => {
}, [setWasAppLeftDuringReview]),
);

const areAllStepsDone = stepIndex === NUMBER_OF_STEPS - 1;
const isLayoutReady = childHeights.length === NUMBER_OF_STEPS;
const areAllStepsDone = stepIndex === NUMBER_OF_STEPS - 1 || isReviewInProgress;

const isAddressConfirmed = useSelector(
(state: AccountsRootState & DeviceRootState & SendRootState) =>
selectIsFirstTransactionAddressConfirmed(state, accountKey, tokenContract),
selectIsReceiveAddressOutputConfirmed(state, accountKey, tokenContract),
);

useEffect(() => {
Expand All @@ -96,27 +92,11 @@ export const AddressReviewStepList = () => {
});
};

const restartAddressReview = () => {
setStepIndex(0);
dispatch(cleanupSendFormThunk({ accountKey, shouldDeleteDraft: false }));
};

const handleNextStep = async () => {
const handleNextStep = () => {
setStepIndex(prevStepIndex => prevStepIndex + 1);

if (stepIndex === NUMBER_OF_STEPS - 2) {
const response = await dispatch(
signTransactionThunk({
accountKey,
tokenContract,
feeLevel: transaction,
}),
);

if (isRejected(response)) {
restartAddressReview();
handleSendReviewFailure(response);
}
handleOnDeviceTransactionReview();
}
};

Expand Down Expand Up @@ -145,7 +125,6 @@ export const AddressReviewStepList = () => {
</View>
{!areAllStepsDone && (
<SlidingFooterOverlay
isLayoutReady={isLayoutReady}
currentStepIndex={stepIndex}
stepHeights={childHeights}
initialOffset={OVERLAY_INITIAL_POSITION}
Expand Down
12 changes: 8 additions & 4 deletions suite-native/module-send/src/components/AmountErrorMessage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { Hint } from '@suite-native/atoms';
import Animated, { FadeIn, FadeOut } from 'react-native-reanimated';

import { Box, Hint } from '@suite-native/atoms';

Check failure on line 3 in suite-native/module-send/src/components/AmountErrorMessage.tsx

View workflow job for this annotation

GitHub Actions / Linting and formatting

'Box' is defined but never used. Allowed unused vars must match /^_/u
import { useField } from '@suite-native/forms';
import { prepareNativeStyle, useNativeStyles } from '@trezor/styles';

Expand Down Expand Up @@ -26,8 +28,10 @@ export const AmountErrorMessage = ({
if (!errorMessage) return null;

return (
<Hint variant="error" style={applyStyle(errorStyle, { isFiatDisplayed })}>
{errorMessage}
</Hint>
<Animated.View entering={FadeIn} exiting={FadeOut}>
<Hint variant="error" style={applyStyle(errorStyle, { isFiatDisplayed })}>
{errorMessage}
</Hint>
</Animated.View>
);
};
2 changes: 1 addition & 1 deletion suite-native/module-send/src/components/AmountInputs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ type AmountInputProps = {
index: number;
};

type RouteProps = StackProps<SendStackParamList, SendStackRoutes.SendAddressReview>['route'];
type RouteProps = StackProps<SendStackParamList, SendStackRoutes.SendOutputs>['route'];

const ANIMATION_DURATION = 300;
const SCALE_FOCUSED = 1;
Expand Down
133 changes: 133 additions & 0 deletions suite-native/module-send/src/components/DestinationTagInput.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import Animated, {
FadeIn,
FadeOut,
LinearTransition,
useAnimatedStyle,
withTiming,
} from 'react-native-reanimated';
import { useRef, useState } from 'react';
import { findNodeHandle, TextInput, View } from 'react-native';

import { Text, IconButton, Box } from '@suite-native/atoms';
import { TextInputField, useFormContext } from '@suite-native/forms';
import { Translation } from '@suite-native/intl';
import { useDebounce } from '@trezor/react-utils';
import { prepareNativeStyle, useNativeStyles } from '@trezor/styles';
import { useScrollView } from '@suite-native/navigation';

import { SendFieldName, SendOutputsFormValues } from '../sendOutputsFormSchema';

const inputWrapperStyle = prepareNativeStyle<{ isInputDisplayed: boolean }>(
(utils, { isInputDisplayed }) => ({
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',

extend: {
condition: isInputDisplayed,
style: {
alignItems: undefined,
flexDirection: 'column',
gap: utils.spacings.sp12,
},
},
}),
);

const INPUT_OPEN_HEIGHT = 88;
const INPUT_CLOSED_HEIGHT = 45;
const ERROR_HEIGHT = 15;
const SCROLL_TO_DELAY = 500;

export const DestinationTagInput = () => {
const inputWrapperRef = useRef<View | null>(null);
const inputRef = useRef<TextInput | null>(null);
const scrollView = useScrollView();
const { applyStyle } = useNativeStyles();
const [isInputDisplayed, setIsInputDisplayed] = useState(false);
const destinationTagFieldName: SendFieldName = 'rippleDestinationTag';
const debounce = useDebounce();

const {
trigger,
formState: { errors },
} = useFormContext<SendOutputsFormValues>();

const hasError = !!errors[destinationTagFieldName];

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

// Wait for input element to be mounted.
setTimeout(() => {
inputRef.current?.focus();
});
};

const animatedViewStyle = useAnimatedStyle(() => {
const inputOpenedHeight = hasError ? INPUT_OPEN_HEIGHT + ERROR_HEIGHT : INPUT_OPEN_HEIGHT;
const height = isInputDisplayed ? inputOpenedHeight : INPUT_CLOSED_HEIGHT;

return {
height: withTiming(height),
};
}, [hasError, isInputDisplayed]);

const handleInputFocus = () => {
const inputWrapper = inputWrapperRef.current;
const scrollViewNodeHandle = findNodeHandle(scrollView);

if (!inputWrapper || !scrollViewNodeHandle) return;

// Timeout is needed so the position is calculated after keyboard and footer animations are finished.
setTimeout(
() =>
// Scroll so the whole amount inputs section is visible.
inputWrapper.measureLayout(scrollViewNodeHandle, (_, y) => {
scrollView?.scrollTo({ y, animated: true });
}),
SCROLL_TO_DELAY,
);
};

const handleChangeValue = () => {
debounce(() => {
trigger(destinationTagFieldName);
handleInputFocus();
});
};

return (
<Animated.View style={animatedViewStyle} layout={LinearTransition} ref={inputWrapperRef}>
<Box style={applyStyle(inputWrapperStyle, { isInputDisplayed })}>
<Animated.View layout={LinearTransition}>
<Text variant="hint">
<Translation id="moduleSend.outputs.recipients.destinationTagLabel" />
</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
multiline
ref={inputRef}
onChangeText={handleChangeValue}
name={destinationTagFieldName}
testID={destinationTagFieldName}
onFocus={handleInputFocus}
accessibilityLabel="address input"
/>
</Animated.View>
)}
</Box>
</Animated.View>
);
};
20 changes: 20 additions & 0 deletions suite-native/module-send/src/components/RecipientInputs.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,41 @@
import React from 'react';
import { useSelector } from 'react-redux';
import Animated, { LinearTransition } from 'react-native-reanimated';

import { VStack, CardDivider } from '@suite-native/atoms';
import { AccountKey } from '@suite-common/wallet-types';
import { AccountsRootState, selectAccountByKey } from '@suite-common/wallet-core';

import { AmountInputs } from './AmountInputs';
import { AddressInput } from './AddressInput';
import { DestinationTagInput } from './DestinationTagInput';

type RecipientInputsProps = {
index: number;
accountKey: AccountKey;
};
export const RecipientInputs = ({ index, accountKey }: RecipientInputsProps) => {
const account = useSelector((state: AccountsRootState) =>
selectAccountByKey(state, accountKey),
);

if (!account) return null;

const isRipple = account.networkType === 'ripple';

return (
<VStack spacing="sp16">
<AddressInput index={index} accountKey={accountKey} />
<CardDivider />
<AmountInputs index={index} />
{isRipple && (
<Animated.View layout={LinearTransition}>
<VStack spacing="sp16">
<CardDivider />
<DestinationTagInput />
</VStack>
</Animated.View>
)}
</VStack>
);
};
Loading

0 comments on commit 57e6730

Please sign in to comment.