mirror of
https://github.com/BlueWallet/BlueWallet.git
synced 2024-11-20 02:09:10 +01:00
9fd4a08c6a
I regularly use BlueWallet to reserve hotels/flights using Bitcoin transactions (0.0.....1231 BTC), and I had problems with 1-9 satoshis missing because of the too short text editor input size. It's very frustrating, but this change will probably fix the bug.
402 lines
14 KiB
JavaScript
402 lines
14 KiB
JavaScript
import React, { Component } from 'react';
|
|
import PropTypes from 'prop-types';
|
|
import BigNumber from 'bignumber.js';
|
|
import { Badge, Icon, Text } from 'react-native-elements';
|
|
import { Image, LayoutAnimation, Pressable, StyleSheet, TextInput, TouchableOpacity, TouchableWithoutFeedback, View } from 'react-native';
|
|
import { useTheme } from '@react-navigation/native';
|
|
import confirm from '../helpers/confirm';
|
|
import { BitcoinUnit } from '../models/bitcoinUnits';
|
|
import loc, { formatBalanceWithoutSuffix, formatBalancePlain, removeTrailingZeros } from '../loc';
|
|
import { BlueText } from '../BlueComponents';
|
|
import dayjs from 'dayjs';
|
|
const currency = require('../blue_modules/currency');
|
|
dayjs.extend(require('dayjs/plugin/localizedFormat'));
|
|
|
|
class AmountInput extends Component {
|
|
static propTypes = {
|
|
isLoading: PropTypes.bool,
|
|
/**
|
|
* amount is a sting thats always in current unit denomination, e.g. '0.001' or '9.43' or '10000'
|
|
*/
|
|
amount: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
|
|
/**
|
|
* callback that returns currently typed amount, in current denomination, e.g. 0.001 or 10000 or $9.34
|
|
* (btc, sat, fiat)
|
|
*/
|
|
onChangeText: PropTypes.func.isRequired,
|
|
/**
|
|
* callback thats fired to notify of currently selected denomination, returns <BitcoinUnit.*>
|
|
*/
|
|
onAmountUnitChange: PropTypes.func.isRequired,
|
|
disabled: PropTypes.bool,
|
|
colors: PropTypes.object.isRequired,
|
|
pointerEvents: PropTypes.string,
|
|
unit: PropTypes.string,
|
|
onBlur: PropTypes.func,
|
|
onFocus: PropTypes.func,
|
|
};
|
|
|
|
/**
|
|
* cache of conversions fiat amount => satoshi
|
|
* @type {{}}
|
|
*/
|
|
static conversionCache = {};
|
|
|
|
static getCachedSatoshis = amount => {
|
|
return AmountInput.conversionCache[amount + BitcoinUnit.LOCAL_CURRENCY] || false;
|
|
};
|
|
|
|
static setCachedSatoshis = (amount, sats) => {
|
|
AmountInput.conversionCache[amount + BitcoinUnit.LOCAL_CURRENCY] = sats;
|
|
};
|
|
|
|
constructor() {
|
|
super();
|
|
this.state = { mostRecentFetchedRate: Date(), isRateOutdated: false, isRateBeingUpdated: false };
|
|
}
|
|
|
|
componentDidMount() {
|
|
currency
|
|
.mostRecentFetchedRate()
|
|
.then(mostRecentFetchedRate => {
|
|
this.setState({ mostRecentFetchedRate });
|
|
})
|
|
.finally(() => {
|
|
currency.isRateOutdated().then(isRateOutdated => this.setState({ isRateOutdated }));
|
|
});
|
|
}
|
|
|
|
/**
|
|
* here we must recalculate old amont value (which was denominated in `previousUnit`) to new denomination `newUnit`
|
|
* and fill this value in input box, so user can switch between, for example, 0.001 BTC <=> 100000 sats
|
|
*
|
|
* @param previousUnit {string} one of {BitcoinUnit.*}
|
|
* @param newUnit {string} one of {BitcoinUnit.*}
|
|
*/
|
|
onAmountUnitChange(previousUnit, newUnit) {
|
|
const amount = this.props.amount || 0;
|
|
const log = `${amount}(${previousUnit}) ->`;
|
|
let sats = 0;
|
|
switch (previousUnit) {
|
|
case BitcoinUnit.BTC:
|
|
sats = new BigNumber(amount).multipliedBy(100000000).toString();
|
|
break;
|
|
case BitcoinUnit.SATS:
|
|
sats = amount;
|
|
break;
|
|
case BitcoinUnit.LOCAL_CURRENCY:
|
|
sats = new BigNumber(currency.fiatToBTC(amount)).multipliedBy(100000000).toString();
|
|
break;
|
|
}
|
|
if (previousUnit === BitcoinUnit.LOCAL_CURRENCY && AmountInput.conversionCache[amount + previousUnit]) {
|
|
// cache hit! we reuse old value that supposedly doesnt have rounding errors
|
|
sats = AmountInput.conversionCache[amount + previousUnit];
|
|
}
|
|
|
|
const newInputValue = formatBalancePlain(sats, newUnit, false);
|
|
console.log(`${log} ${sats}(sats) -> ${newInputValue}(${newUnit})`);
|
|
|
|
if (newUnit === BitcoinUnit.LOCAL_CURRENCY && previousUnit === BitcoinUnit.SATS) {
|
|
// we cache conversion, so when we will need reverse conversion there wont be a rounding error
|
|
AmountInput.conversionCache[newInputValue + newUnit] = amount;
|
|
}
|
|
this.props.onChangeText(newInputValue);
|
|
this.props.onAmountUnitChange(newUnit);
|
|
}
|
|
|
|
/**
|
|
* responsible for cycling currently selected denomination, BTC->SAT->LOCAL_CURRENCY->BTC
|
|
*/
|
|
changeAmountUnit = () => {
|
|
let previousUnit = this.props.unit;
|
|
let newUnit;
|
|
if (previousUnit === BitcoinUnit.BTC) {
|
|
newUnit = BitcoinUnit.SATS;
|
|
} else if (previousUnit === BitcoinUnit.SATS) {
|
|
newUnit = BitcoinUnit.LOCAL_CURRENCY;
|
|
} else if (previousUnit === BitcoinUnit.LOCAL_CURRENCY) {
|
|
newUnit = BitcoinUnit.BTC;
|
|
} else {
|
|
newUnit = BitcoinUnit.BTC;
|
|
previousUnit = BitcoinUnit.SATS;
|
|
}
|
|
this.onAmountUnitChange(previousUnit, newUnit);
|
|
};
|
|
|
|
maxLength = () => {
|
|
switch (this.props.unit) {
|
|
case BitcoinUnit.BTC:
|
|
return 11;
|
|
case BitcoinUnit.SATS:
|
|
return 15;
|
|
default:
|
|
return 15;
|
|
}
|
|
};
|
|
|
|
textInput = React.createRef();
|
|
|
|
handleTextInputOnPress = () => {
|
|
this.textInput.current.focus();
|
|
};
|
|
|
|
handleChangeText = text => {
|
|
text = text.trim();
|
|
if (this.props.unit !== BitcoinUnit.LOCAL_CURRENCY) {
|
|
text = text.replace(',', '.');
|
|
const split = text.split('.');
|
|
if (split.length >= 2) {
|
|
text = `${parseInt(split[0], 10)}.${split[1]}`;
|
|
} else {
|
|
text = `${parseInt(split[0], 10)}`;
|
|
}
|
|
|
|
text = this.props.unit === BitcoinUnit.BTC ? text.replace(/[^0-9.]/g, '') : text.replace(/[^0-9]/g, '');
|
|
|
|
if (text.startsWith('.')) {
|
|
text = '0.';
|
|
}
|
|
} else if (this.props.unit === BitcoinUnit.LOCAL_CURRENCY) {
|
|
text = text.replace(/,/gi, '.');
|
|
if (text.split('.').length > 2) {
|
|
// too many dots. stupid code to remove all but first dot:
|
|
let rez = '';
|
|
let first = true;
|
|
for (const part of text.split('.')) {
|
|
rez += part;
|
|
if (first) {
|
|
rez += '.';
|
|
first = false;
|
|
}
|
|
}
|
|
text = rez;
|
|
}
|
|
if (text.startsWith('0') && !(text.includes('.') || text.includes(','))) {
|
|
text = text.replace(/^(0+)/g, '');
|
|
}
|
|
text = text.replace(/[^\d.,-]/g, ''); // remove all but numbers, dots & commas
|
|
text = text.replace(/(\..*)\./g, '$1');
|
|
}
|
|
this.props.onChangeText(text);
|
|
};
|
|
|
|
resetAmount = async () => {
|
|
if (await confirm(loc.send.reset_amount, loc.send.reset_amount_confirm)) {
|
|
this.props.onChangeText();
|
|
}
|
|
};
|
|
|
|
updateRate = () => {
|
|
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
|
this.setState({ isRateBeingUpdated: true }, async () => {
|
|
try {
|
|
await currency.updateExchangeRate();
|
|
currency.mostRecentFetchedRate().then(mostRecentFetchedRate => {
|
|
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
|
this.setState({ mostRecentFetchedRate });
|
|
});
|
|
} finally {
|
|
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
|
|
this.setState({ isRateBeingUpdated: false, isRateOutdated: await currency.isRateOutdated() });
|
|
}
|
|
});
|
|
};
|
|
|
|
render() {
|
|
const { colors, disabled, unit } = this.props;
|
|
const amount = this.props.amount || 0;
|
|
let secondaryDisplayCurrency = formatBalanceWithoutSuffix(amount, BitcoinUnit.LOCAL_CURRENCY, false);
|
|
|
|
// if main display is sat or btc - secondary display is fiat
|
|
// if main display is fiat - secondary dislay is btc
|
|
let sat;
|
|
switch (unit) {
|
|
case BitcoinUnit.BTC:
|
|
sat = new BigNumber(amount).multipliedBy(100000000).toString();
|
|
secondaryDisplayCurrency = formatBalanceWithoutSuffix(sat, BitcoinUnit.LOCAL_CURRENCY, false);
|
|
break;
|
|
case BitcoinUnit.SATS:
|
|
secondaryDisplayCurrency = formatBalanceWithoutSuffix((isNaN(amount) ? 0 : amount).toString(), BitcoinUnit.LOCAL_CURRENCY, false);
|
|
break;
|
|
case BitcoinUnit.LOCAL_CURRENCY:
|
|
secondaryDisplayCurrency = currency.fiatToBTC(parseFloat(isNaN(amount) ? 0 : amount));
|
|
if (AmountInput.conversionCache[isNaN(amount) ? 0 : amount + BitcoinUnit.LOCAL_CURRENCY]) {
|
|
// cache hit! we reuse old value that supposedly doesn't have rounding errors
|
|
const sats = AmountInput.conversionCache[isNaN(amount) ? 0 : amount + BitcoinUnit.LOCAL_CURRENCY];
|
|
secondaryDisplayCurrency = currency.satoshiToBTC(sats);
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (amount === BitcoinUnit.MAX) secondaryDisplayCurrency = ''; // we don't want to display NaN
|
|
|
|
const stylesHook = StyleSheet.create({
|
|
center: { padding: amount === BitcoinUnit.MAX ? 0 : 15 },
|
|
localCurrency: { color: disabled ? colors.buttonDisabledTextColor : colors.alternativeTextColor2 },
|
|
input: { color: disabled ? colors.buttonDisabledTextColor : colors.alternativeTextColor2, fontSize: amount.length > 10 ? 20 : 36 },
|
|
cryptoCurrency: { color: disabled ? colors.buttonDisabledTextColor : colors.alternativeTextColor2 },
|
|
});
|
|
|
|
return (
|
|
<TouchableWithoutFeedback disabled={this.props.pointerEvents === 'none'} onPress={() => this.textInput.focus()}>
|
|
<>
|
|
<View style={styles.root}>
|
|
{!disabled && <View style={[styles.center, stylesHook.center]} />}
|
|
<View style={styles.flex}>
|
|
<View style={styles.container}>
|
|
{unit === BitcoinUnit.LOCAL_CURRENCY && amount !== BitcoinUnit.MAX && (
|
|
<Text style={[styles.localCurrency, stylesHook.localCurrency]}>{currency.getCurrencySymbol() + ' '}</Text>
|
|
)}
|
|
{amount !== BitcoinUnit.MAX ? (
|
|
<TextInput
|
|
{...this.props}
|
|
testID="BitcoinAmountInput"
|
|
keyboardType="numeric"
|
|
adjustsFontSizeToFit
|
|
onChangeText={this.handleChangeText}
|
|
onBlur={() => {
|
|
if (this.props.onBlur) this.props.onBlur();
|
|
}}
|
|
onFocus={() => {
|
|
if (this.props.onFocus) this.props.onFocus();
|
|
}}
|
|
placeholder="0"
|
|
maxLength={this.maxLength()}
|
|
ref={textInput => (this.textInput = textInput)}
|
|
editable={!this.props.isLoading && !disabled}
|
|
value={amount === BitcoinUnit.MAX ? loc.units.MAX : parseFloat(amount) >= 0 ? String(amount) : undefined}
|
|
placeholderTextColor={disabled ? colors.buttonDisabledTextColor : colors.alternativeTextColor2}
|
|
style={[styles.input, stylesHook.input]}
|
|
/>
|
|
) : (
|
|
<Pressable onPress={this.resetAmount}>
|
|
<Text style={[styles.input, stylesHook.input]}>{BitcoinUnit.MAX}</Text>
|
|
</Pressable>
|
|
)}
|
|
{unit !== BitcoinUnit.LOCAL_CURRENCY && amount !== BitcoinUnit.MAX && (
|
|
<Text style={[styles.cryptoCurrency, stylesHook.cryptoCurrency]}>{' ' + loc.units[unit]}</Text>
|
|
)}
|
|
</View>
|
|
<View style={styles.secondaryRoot}>
|
|
<Text style={styles.secondaryText}>
|
|
{unit === BitcoinUnit.LOCAL_CURRENCY && amount !== BitcoinUnit.MAX
|
|
? removeTrailingZeros(secondaryDisplayCurrency)
|
|
: secondaryDisplayCurrency}
|
|
{unit === BitcoinUnit.LOCAL_CURRENCY && amount !== BitcoinUnit.MAX ? ` ${loc.units[BitcoinUnit.BTC]}` : null}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
{!disabled && amount !== BitcoinUnit.MAX && (
|
|
<TouchableOpacity
|
|
accessibilityRole="button"
|
|
testID="changeAmountUnitButton"
|
|
style={styles.changeAmountUnit}
|
|
onPress={this.changeAmountUnit}
|
|
>
|
|
<Image source={require('../img/round-compare-arrows-24-px.png')} />
|
|
</TouchableOpacity>
|
|
)}
|
|
</View>
|
|
{this.state.isRateOutdated && (
|
|
<View style={styles.outdatedRateContainer}>
|
|
<Badge status="warning" />
|
|
<View style={styles.spacing8} />
|
|
<BlueText>
|
|
{loc.formatString(loc.send.outdated_rate, { date: dayjs(this.state.mostRecentFetchedRate.LastUpdated).format('l LT') })}
|
|
</BlueText>
|
|
<View style={styles.spacing8} />
|
|
<TouchableOpacity
|
|
onPress={this.updateRate}
|
|
disabled={this.state.isRateBeingUpdated}
|
|
style={this.state.isRateBeingUpdated ? styles.disabledButton : styles.enabledButon}
|
|
>
|
|
<Icon name="sync" type="font-awesome-5" size={16} color={colors.buttonAlternativeTextColor} />
|
|
</TouchableOpacity>
|
|
</View>
|
|
)}
|
|
</>
|
|
</TouchableWithoutFeedback>
|
|
);
|
|
}
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
root: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'space-between',
|
|
},
|
|
center: {
|
|
alignSelf: 'center',
|
|
},
|
|
flex: {
|
|
flex: 1,
|
|
},
|
|
spacing8: {
|
|
width: 8,
|
|
},
|
|
disabledButton: {
|
|
opacity: 0.5,
|
|
},
|
|
enabledButon: {
|
|
opacity: 1,
|
|
},
|
|
outdatedRateContainer: {
|
|
flexDirection: 'row',
|
|
justifyContent: 'center',
|
|
alignItems: 'center',
|
|
marginVertical: 8,
|
|
},
|
|
container: {
|
|
flexDirection: 'row',
|
|
alignContent: 'space-between',
|
|
justifyContent: 'center',
|
|
paddingTop: 16,
|
|
paddingBottom: 2,
|
|
},
|
|
localCurrency: {
|
|
fontSize: 18,
|
|
marginHorizontal: 4,
|
|
fontWeight: 'bold',
|
|
alignSelf: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
input: {
|
|
fontWeight: 'bold',
|
|
},
|
|
cryptoCurrency: {
|
|
fontSize: 15,
|
|
marginHorizontal: 4,
|
|
fontWeight: '600',
|
|
alignSelf: 'center',
|
|
justifyContent: 'center',
|
|
},
|
|
secondaryRoot: {
|
|
alignItems: 'center',
|
|
marginBottom: 22,
|
|
},
|
|
secondaryText: {
|
|
fontSize: 16,
|
|
color: '#9BA0A9',
|
|
fontWeight: '600',
|
|
},
|
|
changeAmountUnit: {
|
|
alignSelf: 'center',
|
|
marginRight: 16,
|
|
paddingLeft: 16,
|
|
paddingVertical: 16,
|
|
},
|
|
});
|
|
|
|
const AmountInputWithStyle = props => {
|
|
const { colors } = useTheme();
|
|
|
|
return <AmountInput {...props} colors={colors} />;
|
|
};
|
|
|
|
// expose static methods
|
|
AmountInputWithStyle.conversionCache = AmountInput.conversionCache;
|
|
AmountInputWithStyle.getCachedSatoshis = AmountInput.getCachedSatoshis;
|
|
AmountInputWithStyle.setCachedSatoshis = AmountInput.setCachedSatoshis;
|
|
|
|
export default AmountInputWithStyle;
|