From 27a45040c0a78a1c13da7775e002bc63a65bb579 Mon Sep 17 00:00:00 2001 From: Marcos Rodriguez Velez Date: Tue, 30 Jul 2024 19:05:58 -0400 Subject: [PATCH] REF: Custom Fee UX --- components/SelectFeeModal.tsx | 377 ++++++++++++++++++++++++++++++++++ loc/en.json | 1 + screen/send/SendDetails.tsx | 176 ++-------------- 3 files changed, 392 insertions(+), 162 deletions(-) create mode 100644 components/SelectFeeModal.tsx diff --git a/components/SelectFeeModal.tsx b/components/SelectFeeModal.tsx new file mode 100644 index 000000000..45d29d28e --- /dev/null +++ b/components/SelectFeeModal.tsx @@ -0,0 +1,377 @@ +import React, { useState, useRef, forwardRef, useImperativeHandle } from 'react'; +import { View, Text, TouchableOpacity, TextInput, StyleSheet, Platform } from 'react-native'; +import BottomModal, { BottomModalHandle } from './BottomModal'; +import { useTheme } from './themes'; +import loc from '../loc'; +import { SecondButton } from './SecondButton'; + +interface NetworkTransactionFees { + fastestFee: number; + mediumFee: number; + slowFee: number; +} + +interface FeePrecalc { + fastestFee: number | null; + mediumFee: number | null; + slowFee: number | null; + current: number | null; +} + +interface Option { + label: string; + time: string; + fee: number | null; + rate: number; + active: boolean; + disabled?: boolean; +} + +interface SelectFeeModalProps { + networkTransactionFees: NetworkTransactionFees; + feePrecalc: FeePrecalc; + feeRate: number | string; + setCustomFee: (fee: string) => void; + setFeePrecalc: (fn: (fp: FeePrecalc) => FeePrecalc) => void; +} + +const SelectFeeModal = forwardRef( + ({ networkTransactionFees, feePrecalc, feeRate, setCustomFee, setFeePrecalc }, ref) => { + const [customFee, setCustomFeeState] = useState(''); + const feeModalRef = useRef(null); + const customModalRef = useRef(null); + const nf = networkTransactionFees; + + const { colors } = useTheme(); + + const stylesHook = StyleSheet.create({ + loading: { + backgroundColor: colors.background, + }, + root: { + backgroundColor: colors.elevated, + }, + feeModalItemActive: { + backgroundColor: colors.feeActive, + }, + feeModalLabel: { + color: colors.successColor, + }, + feeModalTime: { + backgroundColor: colors.successColor, + }, + feeModalTimeText: { + color: colors.background, + }, + feeModalValue: { + color: colors.successColor, + }, + feeModalCustomText: { + color: colors.buttonAlternativeTextColor, + }, + selectLabel: { + color: colors.buttonTextColor, + }, + of: { + color: colors.feeText, + }, + memo: { + borderColor: colors.formBorder, + borderBottomColor: colors.formBorder, + backgroundColor: colors.inputBackgroundColor, + }, + feeLabel: { + color: colors.feeText, + }, + feeModalItemDisabled: { + backgroundColor: colors.buttonDisabledBackgroundColor, + }, + feeModalItemTextDisabled: { + color: colors.buttonDisabledTextColor, + }, + feeRow: { + backgroundColor: colors.feeLabel, + }, + feeValue: { + color: colors.feeValue, + }, + }); + + useImperativeHandle(ref, () => ({ + present: async () => feeModalRef.current?.present(), + dismiss: async () => feeModalRef.current?.dismiss(), + })); + + const options: Option[] = [ + { + label: loc.send.fee_fast, + time: loc.send.fee_10m, + fee: feePrecalc.fastestFee, + rate: nf.fastestFee, + active: Number(feeRate) === nf.fastestFee, + }, + { + label: loc.send.fee_medium, + time: loc.send.fee_3h, + fee: feePrecalc.mediumFee, + rate: nf.mediumFee, + active: Number(feeRate) === nf.mediumFee, + disabled: nf.mediumFee === nf.fastestFee, + }, + { + label: loc.send.fee_slow, + time: loc.send.fee_1d, + fee: feePrecalc.slowFee, + rate: nf.slowFee, + active: Number(feeRate) === nf.slowFee, + disabled: nf.slowFee === nf.mediumFee || nf.slowFee === nf.fastestFee, + }, + ]; + + const formatFee = (fee: number | null): string => { + return fee ? `${fee} sat/vB` : ''; + }; + + const handleCustomFeeSubmit = async () => { + if (!/^\d+$/.test(customFee) || Number(customFee) <= 0) { + // Handle error if necessary + return; + } + const fee = Number(customFee) < 1 ? '1' : customFee; + setCustomFee(fee); + await customModalRef.current?.dismiss(); + await feeModalRef.current?.dismiss(); + }; + + const handleCancel = async () => { + setCustomFeeState(''); + await customModalRef.current?.dismiss(); + }; + + const handlePressCustom = async () => { + await customModalRef.current?.present(); + }; + + const handleSelectOption = async (fee: number | null, rate: number) => { + setFeePrecalc(fp => ({ ...fp, current: fee })); + await feeModalRef.current?.dismiss(); + setCustomFee(rate.toString()); + }; + + return ( + + + {loc.send.fee_custom} + + + } + > + + {options.map(({ label, time, fee, rate, active, disabled }) => ( + handleSelectOption(fee, rate)} + style={[styles.feeModalItem, active && styles.feeModalItemActive, active && !disabled && stylesHook.feeModalItemActive]} + > + + + {label} + + + ~{time} + + + + {fee && formatFee(fee)} + + {rate} {loc.units.sat_vbyte} + + + + ))} + + + + + + + + } + footerDefaultMargins + > + + {loc.send.insert_custom_fee} + + + + + + ); + }, +); + +export default SelectFeeModal; + +const styles = StyleSheet.create({ + loading: { + flex: 1, + paddingTop: 20, + }, + root: { + flex: 1, + justifyContent: 'space-between', + }, + scrollViewContent: { + flexDirection: 'row', + }, + scrollViewIndicator: { + top: 0, + left: 8, + bottom: 0, + right: 8, + }, + modalContent: { + margin: 22, + }, + modalContentMinHeight: Platform.OS === 'android' ? { minHeight: 400 } : {}, + paddingTop66: { paddingVertical: 66 }, + paddingTop30: { paddingBottom: 60, paddingTop: 30 }, + optionsContent: { + padding: 22, + }, + feeModalItem: { + paddingHorizontal: 16, + paddingVertical: 8, + marginBottom: 10, + }, + feeModalItemActive: { + borderRadius: 8, + }, + feeModalRow: { + justifyContent: 'space-between', + flexDirection: 'row', + alignItems: 'center', + }, + feeModalLabel: { + fontSize: 22, + fontWeight: '600', + }, + feeModalTime: { + borderRadius: 5, + paddingHorizontal: 6, + paddingVertical: 3, + }, + feeModalCustomText: { + fontSize: 15, + fontWeight: '600', + }, + createButton: { + marginVertical: 16, + marginHorizontal: 16, + alignContent: 'center', + minHeight: 44, + }, + select: { + marginBottom: 24, + marginHorizontal: 24, + alignItems: 'center', + }, + selectTouch: { + flexDirection: 'row', + alignItems: 'center', + }, + selectText: { + color: '#9aa0aa', + fontSize: 14, + marginRight: 8, + }, + selectWrap: { + flexDirection: 'row', + alignItems: 'center', + marginVertical: 4, + }, + selectLabel: { + fontSize: 14, + }, + of: { + alignSelf: 'flex-end', + marginRight: 18, + marginVertical: 8, + }, + feeModalFooter: { + paddingBottom: 36, + flexDirection: 'row', + justifyContent: 'space-between', + }, + feeModalFooterSpacing: { + paddingHorizontal: 24, + }, + memo: { + flexDirection: 'row', + borderWidth: 1, + borderBottomWidth: 0.5, + minHeight: 44, + height: 44, + marginHorizontal: 20, + alignItems: 'center', + marginVertical: 8, + borderRadius: 4, + }, + memoText: { + flex: 1, + marginHorizontal: 8, + minHeight: 33, + color: '#81868e', + }, + fee: { + flexDirection: 'row', + marginHorizontal: 20, + justifyContent: 'space-between', + alignItems: 'center', + }, + feeLabel: { + fontSize: 14, + }, + feeRow: { + minWidth: 40, + height: 25, + borderRadius: 4, + justifyContent: 'space-between', + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 10, + }, + advancedOptions: { + minWidth: 40, + height: 40, + justifyContent: 'center', + }, + feeModalCloseButton: { + paddingHorizontal: 10, + paddingVertical: 8, + }, + feeModalCloseButtonText: { + color: '#007AFF', + }, +}); diff --git a/loc/en.json b/loc/en.json index 442210199..5171d3600 100644 --- a/loc/en.json +++ b/loc/en.json @@ -169,6 +169,7 @@ "fee_1d": "1d", "fee_3h": "3h", "fee_custom": "Custom", + "insert_custom_fee": "Insert custom fee", "fee_fast": "Fast", "fee_medium": "Medium", "fee_replace_minvb": "The total fee rate (satoshi per vByte) you want to pay should be higher than {min} sat/vByte.", diff --git a/screen/send/SendDetails.tsx b/screen/send/SendDetails.tsx index 471969e68..bd04f41a3 100644 --- a/screen/send/SendDetails.tsx +++ b/screen/send/SendDetails.tsx @@ -43,7 +43,6 @@ import InputAccessoryAllFunds from '../../components/InputAccessoryAllFunds'; import ListItem from '../../components/ListItem'; import { useTheme } from '../../components/themes'; import ToolTipMenu from '../../components/TooltipMenu'; -import prompt from '../../helpers/prompt'; import { requestCameraAuthorization, scanQrHelper } from '../../helpers/scan-qr'; import loc, { formatBalance, formatBalanceWithoutSuffix } from '../../loc'; import { BitcoinUnit, Chain } from '../../models/bitcoinUnits'; @@ -58,6 +57,7 @@ import { useExtendedNavigation } from '../../hooks/useExtendedNavigation'; import { ContactList } from '../../class/contact-list'; import { useStorage } from '../../hooks/context/useStorage'; import { Action } from '../../components/types'; +import SelectFeeModal from '../../components/SelectFeeModal'; interface IPaymentDestinations { address: string; // btc address or payment code @@ -1164,24 +1164,7 @@ const SendDetails = () => { root: { backgroundColor: colors.elevated, }, - feeModalItemActive: { - backgroundColor: colors.feeActive, - }, - feeModalLabel: { - color: colors.successColor, - }, - feeModalTime: { - backgroundColor: colors.successColor, - }, - feeModalTimeText: { - color: colors.background, - }, - feeModalValue: { - color: colors.successColor, - }, - feeModalCustomText: { - color: colors.buttonAlternativeTextColor, - }, + selectLabel: { color: colors.buttonTextColor, }, @@ -1196,12 +1179,7 @@ const SendDetails = () => { feeLabel: { color: colors.feeText, }, - feeModalItemDisabled: { - backgroundColor: colors.buttonDisabledBackgroundColor, - }, - feeModalItemTextDisabled: { - color: colors.buttonDisabledTextColor, - }, + feeRow: { backgroundColor: colors.feeLabel, }, @@ -1216,108 +1194,6 @@ const SendDetails = () => { return totalWithFee; }; - const renderFeeSelectionModal = () => { - const nf = networkTransactionFees; - const options = [ - { - label: loc.send.fee_fast, - time: loc.send.fee_10m, - fee: feePrecalc.fastestFee, - rate: nf.fastestFee, - active: Number(feeRate) === nf.fastestFee, - }, - { - label: loc.send.fee_medium, - time: loc.send.fee_3h, - fee: feePrecalc.mediumFee, - rate: nf.mediumFee, - active: Number(feeRate) === nf.mediumFee, - disabled: nf.mediumFee === nf.fastestFee, - }, - { - label: loc.send.fee_slow, - time: loc.send.fee_1d, - fee: feePrecalc.slowFee, - rate: nf.slowFee, - active: Number(feeRate) === nf.slowFee, - disabled: nf.slowFee === nf.mediumFee || nf.slowFee === nf.fastestFee, - }, - ]; - - return ( - - { - await feeModalRef.current?.dismiss(); - let error = loc.send.fee_satvbyte; - while (true) { - let fee: number | string; - - try { - fee = await prompt(loc.send.create_fee, error, true, 'numeric'); - } catch (_) { - return; - } - - if (!/^\d+$/.test(fee)) { - error = loc.send.details_fee_field_is_not_valid; - continue; - } - - if (Number(fee) < 1) fee = '1'; - fee = Number(fee).toString(); // this will remove leading zeros if any - setCustomFee(fee); - return; - } - }} - > - {loc.send.fee_custom} - - - } - > - - {options.map(({ label, time, fee, rate, active, disabled }) => ( - { - setFeePrecalc(fp => ({ ...fp, current: fee })); - feeModalRef.current?.dismiss(); - setCustomFee(rate.toString()); - }} - style={[styles.feeModalItem, active && styles.feeModalItemActive, active && !disabled && stylesHook.feeModalItemActive]} - > - - - {label} - - - ~{time} - - - - {fee && formatFee(fee)} - - {rate} {loc.units.sat_vbyte} - - - - ))} - - - ); - }; - const renderOptionsModal = () => { const isSendMaxUsed = addresses.some(element => element.amount === BitcoinUnit.MAX); @@ -1591,7 +1467,14 @@ const SendDetails = () => { )} {renderCreateButton()} - {renderFeeSelectionModal()} + {renderOptionsModal()} @@ -1655,40 +1538,11 @@ const styles = StyleSheet.create({ bottom: 0, right: 8, }, - modalContent: { - margin: 22, - }, - modalContentMinHeight: Platform.OS === 'android' ? { minHeight: 400 } : {}, - paddingTop80: { paddingTop: 80 }, + optionsContent: { padding: 22, }, - feeModalItem: { - paddingHorizontal: 16, - paddingVertical: 8, - marginBottom: 10, - }, - feeModalItemActive: { - borderRadius: 8, - }, - feeModalRow: { - justifyContent: 'space-between', - flexDirection: 'row', - alignItems: 'center', - }, - feeModalLabel: { - fontSize: 22, - fontWeight: '600', - }, - feeModalTime: { - borderRadius: 5, - paddingHorizontal: 6, - paddingVertical: 3, - }, - feeModalCustomText: { - fontSize: 15, - fontWeight: '600', - }, + createButton: { marginVertical: 16, marginHorizontal: 16, @@ -1722,9 +1576,7 @@ const styles = StyleSheet.create({ marginRight: 18, marginVertical: 8, }, - feeModalFooter: { - paddingVertical: 50, - }, + memo: { flexDirection: 'row', borderWidth: 1,