401 lines
13 KiB
TypeScript
401 lines
13 KiB
TypeScript
import React, { useRef, useCallback, useReducer, useEffect, FC } from 'react';
|
|
import { View, Text, TouchableOpacity, TextInput, StyleSheet, Keyboard, ScrollView, KeyboardAvoidingView, Platform } from 'react-native';
|
|
import { useTheme } from '../components/themes';
|
|
import loc, { formatBalance } from '../loc';
|
|
import { BitcoinUnit } from '../models/bitcoinUnits';
|
|
import { RouteProp, useFocusEffect, useNavigation, useRoute } from '@react-navigation/native';
|
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
|
import { SendDetailsStackParamList } from '../navigation/SendDetailsStackParamList';
|
|
import { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
|
import { NetworkTransactionFeeType } from '../models/networkTransactionFees';
|
|
|
|
enum FeeScreenActions {
|
|
SET_CUSTOM_FEE_VALUE = 'SET_CUSTOM_FEE_VALUE',
|
|
SET_CUSTOM_FEE_FOCUSED = 'SET_CUSTOM_FEE_FOCUSED',
|
|
SET_CUSTOM_FEE_BLURRED = 'SET_CUSTOM_FEE_BLURRED',
|
|
CLEAR_CUSTOM_FEE = 'CLEAR_CUSTOM_FEE',
|
|
SET_OPTIONS = 'SET_OPTIONS',
|
|
}
|
|
|
|
interface FeeOption {
|
|
label: string;
|
|
time: string;
|
|
fee: number | null;
|
|
rate: number;
|
|
feeType: NetworkTransactionFeeType;
|
|
active: boolean;
|
|
disabled?: boolean;
|
|
}
|
|
|
|
interface FeeScreenState {
|
|
customFeeValue: string;
|
|
isCustomFeeFocused: boolean;
|
|
options: FeeOption[];
|
|
isCustomFeeSelected: boolean;
|
|
}
|
|
|
|
type FeeScreenAction =
|
|
| { type: FeeScreenActions.SET_CUSTOM_FEE_VALUE; payload: string }
|
|
| { type: FeeScreenActions.SET_CUSTOM_FEE_FOCUSED }
|
|
| { type: FeeScreenActions.SET_CUSTOM_FEE_BLURRED }
|
|
| { type: FeeScreenActions.CLEAR_CUSTOM_FEE }
|
|
| { type: FeeScreenActions.SET_OPTIONS; payload: { options: FeeOption[]; currentFeeRate: number } };
|
|
|
|
const feeScreenReducer = (state: FeeScreenState, action: FeeScreenAction): FeeScreenState => {
|
|
switch (action.type) {
|
|
case FeeScreenActions.SET_CUSTOM_FEE_VALUE:
|
|
return { ...state, customFeeValue: action.payload };
|
|
case FeeScreenActions.SET_CUSTOM_FEE_FOCUSED:
|
|
return {
|
|
...state,
|
|
isCustomFeeFocused: true,
|
|
isCustomFeeSelected: true,
|
|
options: state.options.map(opt => ({ ...opt, active: false })),
|
|
};
|
|
case FeeScreenActions.SET_CUSTOM_FEE_BLURRED:
|
|
return { ...state, isCustomFeeFocused: false };
|
|
case FeeScreenActions.CLEAR_CUSTOM_FEE:
|
|
return { ...state, customFeeValue: '' };
|
|
case FeeScreenActions.SET_OPTIONS: {
|
|
const { options, currentFeeRate } = action.payload;
|
|
const matchesPresetOption = options.some(option => currentFeeRate === option.rate);
|
|
const updatedOptions = options.map(option => ({
|
|
...option,
|
|
active: !state.isCustomFeeFocused && currentFeeRate === option.rate,
|
|
}));
|
|
return {
|
|
...state,
|
|
options: updatedOptions,
|
|
isCustomFeeSelected: state.isCustomFeeFocused || !matchesPresetOption,
|
|
};
|
|
}
|
|
default:
|
|
return state;
|
|
}
|
|
};
|
|
|
|
interface FeeOptionProps {
|
|
label: string;
|
|
time: string;
|
|
fee: number | null;
|
|
rate: number;
|
|
active: boolean;
|
|
disabled?: boolean;
|
|
onPress: () => void;
|
|
formatFee: (fee: number) => string;
|
|
colors: any;
|
|
}
|
|
|
|
const FeeOption: FC<FeeOptionProps> = ({ label, time, fee, rate, active, disabled, onPress, formatFee, colors }) => {
|
|
const stylesHook = StyleSheet.create({
|
|
feeModalItemActiveBackground: {
|
|
backgroundColor: colors.feeActive,
|
|
},
|
|
feeOptionText: {
|
|
color: colors.successColor,
|
|
},
|
|
feeOptionTextDisabled: {
|
|
color: colors.buttonDisabledTextColor,
|
|
},
|
|
feeTimeBackground: {
|
|
backgroundColor: colors.successColor,
|
|
},
|
|
feeTimeBackgroundDisabled: {
|
|
backgroundColor: colors.buttonDisabledBackgroundColor,
|
|
},
|
|
feeTimeText: {
|
|
color: colors.background,
|
|
},
|
|
});
|
|
|
|
return (
|
|
<TouchableOpacity
|
|
accessibilityRole="button"
|
|
disabled={disabled}
|
|
onPress={onPress}
|
|
style={[styles.feeModalItem, active && styles.feeModalItemActive, active && !disabled && stylesHook.feeModalItemActiveBackground]}
|
|
>
|
|
<View style={styles.feeModalRow}>
|
|
<Text style={[styles.feeModalLabel, disabled ? stylesHook.feeOptionTextDisabled : stylesHook.feeOptionText]}>{label}</Text>
|
|
<View style={[styles.feeModalTime, disabled ? stylesHook.feeTimeBackgroundDisabled : stylesHook.feeTimeBackground]}>
|
|
<Text style={stylesHook.feeTimeText}>~{time}</Text>
|
|
</View>
|
|
</View>
|
|
<View style={styles.feeModalRow}>
|
|
<Text style={disabled ? stylesHook.feeOptionTextDisabled : stylesHook.feeOptionText}>{fee && formatFee(fee)}</Text>
|
|
<Text style={disabled ? stylesHook.feeOptionTextDisabled : stylesHook.feeOptionText}>
|
|
{rate} {loc.units.sat_vbyte}
|
|
</Text>
|
|
</View>
|
|
</TouchableOpacity>
|
|
);
|
|
};
|
|
|
|
type SelectFeeScreenNavigationProp = NativeStackNavigationProp<SendDetailsStackParamList, 'SelectFee'>;
|
|
type SelectFeeScreenRouteProp = RouteProp<SendDetailsStackParamList, 'SelectFee'>;
|
|
|
|
const SelectFeeScreen = () => {
|
|
const navigation = useNavigation<SelectFeeScreenNavigationProp>();
|
|
const route = useRoute<SelectFeeScreenRouteProp>();
|
|
const { colors } = useTheme();
|
|
const insets = useSafeAreaInsets();
|
|
|
|
const { networkTransactionFees, feePrecalc, feeRate, feeUnit = BitcoinUnit.BTC, walletID, customFee } = route.params;
|
|
|
|
const [state, dispatch] = useReducer(feeScreenReducer, {
|
|
customFeeValue: customFee || '',
|
|
isCustomFeeFocused: false,
|
|
options: [],
|
|
isCustomFeeSelected: false,
|
|
});
|
|
|
|
const customFeeInputRef = useRef<TextInput>(null);
|
|
const nf = networkTransactionFees;
|
|
|
|
const stylesHook = StyleSheet.create({
|
|
keyboardAvoidingRoot: {
|
|
backgroundColor: colors.elevated,
|
|
},
|
|
scrollView: {
|
|
backgroundColor: colors.elevated,
|
|
},
|
|
container: {
|
|
backgroundColor: colors.elevated,
|
|
paddingHorizontal: 16,
|
|
paddingTop: 12,
|
|
paddingBottom: Math.max(insets.bottom, 48) + 16,
|
|
},
|
|
feeModalItemActiveBackground: {
|
|
backgroundColor: colors.feeActive,
|
|
},
|
|
customLabelColor: {
|
|
color: colors.successColor,
|
|
},
|
|
satVbyteText: {
|
|
color: colors.successColor,
|
|
},
|
|
customFeeInputColors: {
|
|
color: colors.successColor,
|
|
borderColor: colors.formBorder,
|
|
},
|
|
feeTimeBackground: {
|
|
backgroundColor: colors.successColor,
|
|
},
|
|
feeTimeBackgroundDisabled: {
|
|
backgroundColor: colors.buttonDisabledBackgroundColor,
|
|
},
|
|
feeTimeText: {
|
|
color: colors.background,
|
|
},
|
|
});
|
|
|
|
const formatFee = useCallback((fee: number) => formatBalance(fee, feeUnit, true), [feeUnit]);
|
|
|
|
useEffect(() => {
|
|
const options: FeeOption[] = [
|
|
{
|
|
label: loc.send.fee_fast,
|
|
time: loc.send.fee_10m,
|
|
fee: feePrecalc.fastestFee,
|
|
rate: nf.fastestFee,
|
|
feeType: NetworkTransactionFeeType.FAST,
|
|
active: false,
|
|
},
|
|
{
|
|
label: loc.send.fee_medium,
|
|
time: loc.send.fee_3h,
|
|
fee: feePrecalc.mediumFee,
|
|
rate: nf.mediumFee,
|
|
feeType: NetworkTransactionFeeType.MEDIUM,
|
|
active: false,
|
|
disabled: nf.mediumFee === nf.fastestFee,
|
|
},
|
|
{
|
|
label: loc.send.fee_slow,
|
|
time: loc.send.fee_1d,
|
|
fee: feePrecalc.slowFee,
|
|
rate: nf.slowFee,
|
|
feeType: NetworkTransactionFeeType.SLOW,
|
|
active: false,
|
|
disabled: nf.slowFee === nf.mediumFee || nf.slowFee === nf.fastestFee,
|
|
},
|
|
];
|
|
dispatch({ type: FeeScreenActions.SET_OPTIONS, payload: { options, currentFeeRate: Number(feeRate) } });
|
|
}, [feePrecalc, nf, feeRate]);
|
|
|
|
const navigateWithFee = useCallback(
|
|
(feeRateValue: string, feeType: NetworkTransactionFeeType) => {
|
|
navigation.popTo('SendDetails', { walletID, selectedFeeRate: feeRateValue, selectedFeeType: feeType }, { merge: true });
|
|
},
|
|
[navigation, walletID],
|
|
);
|
|
|
|
const handleFeeOptionPress = useCallback(
|
|
(rate: number, feeType: NetworkTransactionFeeType) => {
|
|
navigateWithFee(rate.toString(), feeType);
|
|
},
|
|
[navigateWithFee],
|
|
);
|
|
|
|
const handleCustomFeeChange = useCallback((value: string) => {
|
|
const cleanValue = value.replace(/[^\d.,]/g, '').replace(/([.,].*?)[.,]/g, '$1');
|
|
dispatch({ type: FeeScreenActions.SET_CUSTOM_FEE_VALUE, payload: cleanValue });
|
|
}, []);
|
|
|
|
const handleCustomFeeSubmit = useCallback(() => {
|
|
const numericValue = state.customFeeValue.replace(',', '.');
|
|
if (numericValue && Number(numericValue) >= 0) {
|
|
navigateWithFee(numericValue, NetworkTransactionFeeType.CUSTOM);
|
|
}
|
|
}, [state.customFeeValue, navigateWithFee]);
|
|
|
|
const handleCustomFeeBlur = useCallback(() => {
|
|
dispatch({ type: FeeScreenActions.SET_CUSTOM_FEE_BLURRED });
|
|
const numericValue = Number(state.customFeeValue.replace(',', '.'));
|
|
if (!state.customFeeValue || numericValue < 0) {
|
|
dispatch({ type: FeeScreenActions.CLEAR_CUSTOM_FEE });
|
|
}
|
|
}, [state.customFeeValue]);
|
|
|
|
const handleCustomFocus = useCallback(() => dispatch({ type: FeeScreenActions.SET_CUSTOM_FEE_FOCUSED }), []);
|
|
const handleCustomPress = useCallback(() => {
|
|
customFeeInputRef.current?.focus();
|
|
}, []);
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
return () => {
|
|
Keyboard.dismiss();
|
|
};
|
|
}, []),
|
|
);
|
|
|
|
return (
|
|
<KeyboardAvoidingView
|
|
style={[styles.keyboardAvoidingRoot, stylesHook.keyboardAvoidingRoot]}
|
|
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
|
|
>
|
|
<View style={[styles.keyboardAvoidingRoot, stylesHook.keyboardAvoidingRoot]}>
|
|
<ScrollView
|
|
style={[styles.scrollView, stylesHook.scrollView]}
|
|
contentContainerStyle={[stylesHook.container, styles.screenContainer]}
|
|
keyboardShouldPersistTaps="handled"
|
|
keyboardDismissMode="on-drag"
|
|
contentInsetAdjustmentBehavior="never"
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
<View style={styles.contentContainer}>
|
|
{state.options.map(({ label, time, fee, rate, active, disabled, feeType }) => (
|
|
<FeeOption
|
|
key={label}
|
|
label={label}
|
|
time={time}
|
|
fee={fee}
|
|
rate={rate}
|
|
active={active}
|
|
disabled={disabled}
|
|
onPress={() => handleFeeOptionPress(rate, feeType)}
|
|
formatFee={formatFee}
|
|
colors={colors}
|
|
/>
|
|
))}
|
|
<TouchableOpacity
|
|
accessibilityRole="button"
|
|
testID="feeCustomContainerButton"
|
|
onPress={handleCustomPress}
|
|
style={[
|
|
styles.feeModalItem,
|
|
styles.customFeeButton,
|
|
state.isCustomFeeSelected && styles.feeModalItemActive,
|
|
state.isCustomFeeSelected && stylesHook.feeModalItemActiveBackground,
|
|
]}
|
|
>
|
|
<View style={styles.feeModalRow}>
|
|
<Text style={[styles.feeModalLabel, stylesHook.customLabelColor]}>{loc.send.fee_custom}</Text>
|
|
<View style={styles.customFeeContainer}>
|
|
<TextInput
|
|
ref={customFeeInputRef}
|
|
style={[styles.customFeeInput, stylesHook.customFeeInputColors]}
|
|
keyboardType="numeric"
|
|
placeholder={loc.send.insert_custom_fee}
|
|
value={state.customFeeValue}
|
|
placeholderTextColor={colors.placeholderTextColor}
|
|
onChangeText={handleCustomFeeChange}
|
|
onSubmitEditing={handleCustomFeeSubmit}
|
|
onFocus={handleCustomFocus}
|
|
onBlur={handleCustomFeeBlur}
|
|
enablesReturnKeyAutomatically
|
|
returnKeyType="done"
|
|
accessibilityLabel={loc.send.create_fee}
|
|
testID="feeCustom"
|
|
/>
|
|
{state.customFeeValue && /^\d+(\.\d+)?$/.test(state.customFeeValue) && Number(state.customFeeValue) > 0 && (
|
|
<Text style={stylesHook.satVbyteText}>{loc.units.sat_vbyte}</Text>
|
|
)}
|
|
</View>
|
|
</View>
|
|
</TouchableOpacity>
|
|
</View>
|
|
</ScrollView>
|
|
</View>
|
|
</KeyboardAvoidingView>
|
|
);
|
|
};
|
|
|
|
export default SelectFeeScreen;
|
|
|
|
const styles = StyleSheet.create({
|
|
keyboardAvoidingRoot: {
|
|
flex: 1,
|
|
},
|
|
scrollView: {
|
|
flex: 1,
|
|
},
|
|
screenContainer: {
|
|
flexGrow: 1,
|
|
paddingBottom: 0,
|
|
},
|
|
contentContainer: {
|
|
paddingTop: 0,
|
|
paddingBottom: 8,
|
|
},
|
|
feeModalItem: {
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 8,
|
|
marginBottom: 10,
|
|
},
|
|
feeModalItemActive: {
|
|
borderRadius: 8,
|
|
},
|
|
feeModalRow: {
|
|
justifyContent: 'space-between',
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
},
|
|
feeModalLabel: {
|
|
fontSize: 22,
|
|
fontWeight: '600',
|
|
},
|
|
customFeeInput: {
|
|
fontSize: 16,
|
|
height: 36,
|
|
textAlign: 'right',
|
|
padding: 0,
|
|
width: 70,
|
|
marginRight: 4,
|
|
},
|
|
customFeeContainer: {
|
|
flexDirection: 'row',
|
|
alignItems: 'center',
|
|
justifyContent: 'flex-end',
|
|
},
|
|
customFeeButton: {
|
|
marginBottom: 0,
|
|
},
|
|
feeModalTime: {
|
|
borderRadius: 5,
|
|
paddingHorizontal: 6,
|
|
paddingVertical: 3,
|
|
},
|
|
});
|