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 = ({ 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 ( {label} ~{time} {fee && formatFee(fee)} {rate} {loc.units.sat_vbyte} ); }; type SelectFeeScreenNavigationProp = NativeStackNavigationProp; type SelectFeeScreenRouteProp = RouteProp; const SelectFeeScreen = () => { const navigation = useNavigation(); const route = useRoute(); 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(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 ( {state.options.map(({ label, time, fee, rate, active, disabled, feeType }) => ( handleFeeOptionPress(rate, feeType)} formatFee={formatFee} colors={colors} /> ))} {loc.send.fee_custom} {state.customFeeValue && /^\d+(\.\d+)?$/.test(state.customFeeValue) && Number(state.customFeeValue) > 0 && ( {loc.units.sat_vbyte} )} ); }; 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, }, });