Merge pull request #6854 from BlueWallet/customfee

REF: Custom Fee UX
This commit is contained in:
GLaDOS 2024-07-31 09:35:54 +00:00 committed by GitHub
commit 14476255ac
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 392 additions and 162 deletions

View File

@ -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<BottomModalHandle, SelectFeeModalProps>(
({ networkTransactionFees, feePrecalc, feeRate, setCustomFee, setFeePrecalc }, ref) => {
const [customFee, setCustomFeeState] = useState('');
const feeModalRef = useRef<BottomModalHandle>(null);
const customModalRef = useRef<BottomModalHandle>(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 (
<BottomModal
ref={feeModalRef}
backgroundColor={colors.modal}
contentContainerStyle={[styles.modalContent, styles.modalContentMinHeight]}
footerDefaultMargins
footer={
<View style={styles.feeModalFooter}>
<TouchableOpacity testID="feeCustom" accessibilityRole="button" onPress={handlePressCustom}>
<Text style={[styles.feeModalCustomText, stylesHook.feeModalCustomText]}>{loc.send.fee_custom}</Text>
</TouchableOpacity>
</View>
}
>
<View style={styles.paddingTop66}>
{options.map(({ label, time, fee, rate, active, disabled }) => (
<TouchableOpacity
accessibilityRole="button"
key={label}
disabled={disabled}
onPress={() => handleSelectOption(fee, rate)}
style={[styles.feeModalItem, active && styles.feeModalItemActive, active && !disabled && stylesHook.feeModalItemActive]}
>
<View style={styles.feeModalRow}>
<Text style={[styles.feeModalLabel, disabled ? stylesHook.feeModalItemTextDisabled : stylesHook.feeModalLabel]}>
{label}
</Text>
<View style={[styles.feeModalTime, disabled ? stylesHook.feeModalItemDisabled : stylesHook.feeModalTime]}>
<Text style={stylesHook.feeModalTimeText}>~{time}</Text>
</View>
</View>
<View style={styles.feeModalRow}>
<Text style={disabled ? stylesHook.feeModalItemTextDisabled : stylesHook.feeModalValue}>{fee && formatFee(fee)}</Text>
<Text style={disabled ? stylesHook.feeModalItemTextDisabled : stylesHook.feeModalValue}>
{rate} {loc.units.sat_vbyte}
</Text>
</View>
</TouchableOpacity>
))}
</View>
<BottomModal
ref={customModalRef}
backgroundColor={colors.modal}
showCloseButton={false}
contentContainerStyle={[styles.modalContent, styles.modalContentMinHeight]}
footer={
<View style={[styles.feeModalFooter, styles.feeModalFooterSpacing]}>
<SecondButton title={loc._.cancel} onPress={handleCancel} />
<View style={styles.feeModalFooterSpacing} />
<SecondButton title={loc._.ok} onPress={handleCustomFeeSubmit} disabled={!customFee || Number(customFee) <= 0} />
</View>
}
footerDefaultMargins
>
<View style={styles.paddingTop30}>
<Text style={[styles.feeModalLabel, stylesHook.feeModalLabel]}>{loc.send.insert_custom_fee}</Text>
<View style={styles.optionsContent} />
<TextInput
style={[styles.feeModalLabel, stylesHook.feeModalLabel]}
keyboardType="numeric"
placeholder={loc.send.create_fee}
value={customFee}
onChangeText={setCustomFeeState}
autoFocus
/>
</View>
</BottomModal>
</BottomModal>
);
},
);
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',
},
});

View File

@ -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.",

View File

@ -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 (
<BottomModal
ref={feeModalRef}
backgroundColor={colors.modal}
contentContainerStyle={[styles.modalContent, styles.modalContentMinHeight]}
footerDefaultMargins
footer={
<View style={styles.feeModalFooter}>
<TouchableOpacity
testID="feeCustom"
accessibilityRole="button"
onPress={async () => {
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;
}
}}
>
<Text style={[styles.feeModalCustomText, stylesHook.feeModalCustomText]}>{loc.send.fee_custom}</Text>
</TouchableOpacity>
</View>
}
>
<View style={styles.paddingTop80}>
{options.map(({ label, time, fee, rate, active, disabled }) => (
<TouchableOpacity
accessibilityRole="button"
key={label}
disabled={disabled}
onPress={() => {
setFeePrecalc(fp => ({ ...fp, current: fee }));
feeModalRef.current?.dismiss();
setCustomFee(rate.toString());
}}
style={[styles.feeModalItem, active && styles.feeModalItemActive, active && !disabled && stylesHook.feeModalItemActive]}
>
<View style={styles.feeModalRow}>
<Text style={[styles.feeModalLabel, disabled ? stylesHook.feeModalItemTextDisabled : stylesHook.feeModalLabel]}>
{label}
</Text>
<View style={[styles.feeModalTime, disabled ? stylesHook.feeModalItemDisabled : stylesHook.feeModalTime]}>
<Text style={stylesHook.feeModalTimeText}>~{time}</Text>
</View>
</View>
<View style={styles.feeModalRow}>
<Text style={disabled ? stylesHook.feeModalItemTextDisabled : stylesHook.feeModalValue}>{fee && formatFee(fee)}</Text>
<Text style={disabled ? stylesHook.feeModalItemTextDisabled : stylesHook.feeModalValue}>
{rate} {loc.units.sat_vbyte}
</Text>
</View>
</TouchableOpacity>
))}
</View>
</BottomModal>
);
};
const renderOptionsModal = () => {
const isSendMaxUsed = addresses.some(element => element.amount === BitcoinUnit.MAX);
@ -1591,7 +1467,14 @@ const SendDetails = () => {
)}
</TouchableOpacity>
{renderCreateButton()}
{renderFeeSelectionModal()}
<SelectFeeModal
ref={feeModalRef}
networkTransactionFees={networkTransactionFees}
feePrecalc={feePrecalc}
feeRate={feeRate}
setCustomFee={setCustomFee}
setFeePrecalc={setFeePrecalc}
/>
{renderOptionsModal()}
</KeyboardAvoidingView>
</View>
@ -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,