diff --git a/BlueApp.js b/BlueApp.js index 8ede27e90..5b59e6c01 100644 --- a/BlueApp.js +++ b/BlueApp.js @@ -5,7 +5,7 @@ import { AppStorage } from './class'; import DeviceQuickActions from './class/quick-actions'; const prompt = require('./prompt'); const EV = require('./events'); -const currency = require('./currency'); +const currency = require('./blue_modules/currency'); const loc = require('./loc'); const BlueElectrum = require('./BlueElectrum'); // eslint-disable-line no-unused-vars diff --git a/BlueComponents.js b/BlueComponents.js index 8d3141233..bd81f7661 100644 --- a/BlueComponents.js +++ b/BlueComponents.js @@ -40,6 +40,7 @@ const BlueApp = require('./BlueApp'); const { height, width } = Dimensions.get('window'); const aspectRatio = height / width; const BigNumber = require('bignumber.js'); +const currency = require('./blue_modules/currency'); let isIpad; if (aspectRatio > 1.6) { isIpad = false; @@ -579,7 +580,6 @@ export class BlueText extends Component { ); } } - export class BlueTextCentered extends Component { render() { return ; @@ -828,6 +828,10 @@ export class BlueUseAllFundsButton extends Component { onUseAllPressed: PropTypes.func.isRequired, }; + static defaultProps = { + unit: BitcoinUnit.BTC, + }; + render() { const inputView = ( { return ( { + onPressedOut(); onPress(index); + onPressedOut(); }} /> ); @@ -1828,7 +1834,9 @@ const WalletCarouselItem = ({ item, index, onPress, handleLongPress }) => { onPressOut={item.getIsFailure() ? onPressedOut : null} onPress={() => { if (item.getIsFailure()) { + onPressedOut(); onPress(index); + onPressedOut(); } }} > @@ -1896,7 +1904,9 @@ const WalletCarouselItem = ({ item, index, onPress, handleLongPress }) => { onPressOut={onPressedOut} onLongPress={handleLongPress} onPress={() => { + onPressedOut(); onPress(index); + onPressedOut(); }} > @@ -2240,14 +2250,104 @@ export class BlueReplaceFeeSuggestions extends Component { export class BlueBitcoinAmount 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, + /** + * callback thats fired to notify of currently selected denomination, returns + */ + onAmountUnitChange: PropTypes.func, disabled: PropTypes.bool, - unit: PropTypes.string, }; - static defaultProps = { - unit: BitcoinUnit.BTC, + /** + * cache of conversions fiat amount => satoshi + * @type {{}} + */ + static conversionCache = {}; + + static getCachedSatoshis(amount) { + return BlueBitcoinAmount.conversionCache[amount + BitcoinUnit.LOCAL_CURRENCY] || false; + } + + constructor(props) { + super(props); + this.state = { unit: props.unit || BitcoinUnit.BTC, previousUnit: BitcoinUnit.SATS }; + } + + /** + * 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; + console.log('was:', amount, previousUnit, '; converting to', newUnit); + 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 && BlueBitcoinAmount.conversionCache[amount + previousUnit]) { + // cache hit! we reuse old value that supposedly doesnt have rounding errors + sats = BlueBitcoinAmount.conversionCache[amount + previousUnit]; + } + console.log('so, in sats its', sats); + + const newInputValue = loc.formatBalancePlain(sats, newUnit, false); + console.log('and in', newUnit, 'its', newInputValue); + + if (newUnit === BitcoinUnit.LOCAL_CURRENCY && previousUnit === BitcoinUnit.SATS) { + // we cache conversion, so when we will need reverse conversion there wont be a rounding error + BlueBitcoinAmount.conversionCache[newInputValue + newUnit] = amount; + } + this.props.onChangeText(newInputValue); + if (this.props.onAmountUnitChange) this.props.onAmountUnitChange(newUnit); + } + + /** + * responsible for cycling currently selected denomination, BTC->SAT->LOCAL_CURRENCY->BTC + */ + changeAmountUnit = () => { + let previousUnit = this.state.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.setState({ unit: newUnit, previousUnit }, () => this.onAmountUnitChange(previousUnit, newUnit)); + }; + + maxLength = () => { + switch (this.state.unit) { + case BitcoinUnit.BTC: + return 10; + case BitcoinUnit.SATS: + return 15; + default: + return 15; + } }; textInput = React.createRef(); @@ -2257,85 +2357,155 @@ export class BlueBitcoinAmount extends Component { }; render() { - const amount = this.props.amount || '0'; - let localCurrency = loc.formatBalanceWithoutSuffix(amount, BitcoinUnit.LOCAL_CURRENCY, false); - if (this.props.unit === BitcoinUnit.BTC) { - let sat = new BigNumber(amount); - sat = sat.multipliedBy(100000000).toString(); - localCurrency = loc.formatBalanceWithoutSuffix(sat, BitcoinUnit.LOCAL_CURRENCY, false); - } else { - localCurrency = loc.formatBalanceWithoutSuffix(amount.toString(), BitcoinUnit.LOCAL_CURRENCY, false); - } - if (amount === BitcoinUnit.MAX) localCurrency = ''; // we dont want to display NaN - return ( - - - - { - text = text.trim(); - 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, ''); - text = text.replace(/(\..*)\./g, '$1'); + const amount = this.props.amount || 0; + let secondaryDisplayCurrency = loc.formatBalanceWithoutSuffix(amount, BitcoinUnit.LOCAL_CURRENCY, false); - if (text.startsWith('.')) { - text = '0.'; - } - text = text.replace(/(0{1,}.)\./g, '$1'); - if (this.props.unit !== BitcoinUnit.BTC) { - text = text.replace(/[^0-9.]/g, ''); - } - this.props.onChangeText(text); - }} - onBlur={() => { - if (this.props.onBlur) this.props.onBlur(); - }} - onFocus={() => { - if (this.props.onFocus) this.props.onFocus(); - }} - placeholder="0" - maxLength={10} - ref={this.textInput} - editable={!this.props.isLoading && !this.props.disabled} - value={amount} - placeholderTextColor={this.props.disabled ? BlueApp.settings.buttonDisabledTextColor : BlueApp.settings.alternativeTextColor2} - style={{ - color: this.props.disabled ? BlueApp.settings.buttonDisabledTextColor : BlueApp.settings.alternativeTextColor2, - fontSize: 36, - fontWeight: '600', - }} - /> - this.textInput.focus()}> + + {!this.props.disabled && } + + - {' ' + this.props.unit} - - - - {localCurrency} + {this.state.unit === BitcoinUnit.LOCAL_CURRENCY && ( + + {currency.getCurrencySymbol() + ' '} + + )} + { + text = text.trim(); + if (this.state.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.state.unit === BitcoinUnit.BTC ? text.replace(/[^0-9.]/g, '') : text.replace(/[^0-9]/g, ''); + text = text.replace(/(\..*)\./g, '$1'); + + if (text.startsWith('.')) { + text = '0.'; + } + text = text.replace(/(0{1,}.)\./g, '$1'); + if (this.state.unit !== BitcoinUnit.BTC) { + text = text.replace(/[^0-9.]/g, ''); + } + } else if (this.state.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; + } + text = text.replace(/[^\d.,-]/g, ''); // remove all but numberd, dots & commas + } + + this.props.onChangeText(text); + }} + 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 && !this.props.disabled} + value={parseFloat(amount) > 0 || amount === BitcoinUnit.MAX ? amount : undefined} + placeholderTextColor={ + this.props.disabled ? BlueApp.settings.buttonDisabledTextColor : BlueApp.settings.alternativeTextColor2 + } + style={{ + color: this.props.disabled ? BlueApp.settings.buttonDisabledTextColor : BlueApp.settings.alternativeTextColor2, + fontWeight: 'bold', + fontSize: amount.length > 10 ? 20 : 36, + }} + /> + {this.state.unit !== BitcoinUnit.LOCAL_CURRENCY && ( + + {' ' + this.state.unit} + + )} + + + + {this.state.unit === BitcoinUnit.LOCAL_CURRENCY && amount !== BitcoinUnit.MAX + ? loc.removeTrailingZeros(secondaryDisplayCurrency) + : secondaryDisplayCurrency} + {this.state.unit === BitcoinUnit.LOCAL_CURRENCY && amount !== BitcoinUnit.MAX ? ` ${BitcoinUnit.BTC}` : null} + + + {!this.props.disabled && ( + + + + )} ); } } - const styles = StyleSheet.create({ balanceBlur: { height: 30, diff --git a/currency.js b/blue_modules/currency.js similarity index 79% rename from currency.js rename to blue_modules/currency.js index ac75b0e8f..7e1545ed4 100644 --- a/currency.js +++ b/blue_modules/currency.js @@ -1,9 +1,9 @@ import Frisbee from 'frisbee'; import AsyncStorage from '@react-native-community/async-storage'; -import { AppStorage } from './class'; -import { FiatUnit } from './models/fiatUnit'; +import { AppStorage } from '../class'; +import { FiatUnit } from '../models/fiatUnit'; import DefaultPreference from 'react-native-default-preference'; -import DeviceQuickActions from './class/quick-actions'; +import DeviceQuickActions from '../class/quick-actions'; const BigNumber = require('bignumber.js'); let preferredFiatCurrency = FiatUnit.USD; const exchangeRates = {}; @@ -129,13 +129,49 @@ function satoshiToBTC(satoshi) { return b.toString(10); } +function btcToSatoshi(btc) { + return new BigNumber(btc).multipliedBy(100000000).toNumber(); +} + +function fiatToBTC(fiatFloat) { + let b = new BigNumber(fiatFloat); + b = b.dividedBy(exchangeRates['BTC_' + preferredFiatCurrency.endPointKey]).toFixed(8); + return b; +} + +function getCurrencySymbol() { + return preferredFiatCurrency.symbol; +} + +/** + * Used to mock data in tests + * + * @param {object} currency, one of FiatUnit.* + */ +function _setPreferredFiatCurrency(currency) { + preferredFiatCurrency = currency; +} + +/** + * Used to mock data in tests + * + * @param {string} pair as expected by rest of this module, e.g 'BTC_JPY' or 'BTC_USD' + * @param {number} rate exchange rate + */ +function _setExchangeRate(pair, rate) { + exchangeRates[pair] = rate; +} + module.exports.updateExchangeRate = updateExchangeRate; module.exports.startUpdater = startUpdater; module.exports.STRUCT = STRUCT; module.exports.satoshiToLocalCurrency = satoshiToLocalCurrency; +module.exports.fiatToBTC = fiatToBTC; module.exports.satoshiToBTC = satoshiToBTC; module.exports.BTCToLocalCurrency = BTCToLocalCurrency; module.exports.setPrefferedCurrency = setPrefferedCurrency; module.exports.getPreferredCurrency = getPreferredCurrency; -module.exports.exchangeRates = exchangeRates; // export it to mock data in tests -module.exports.preferredFiatCurrency = preferredFiatCurrency; // export it to mock data in tests +module.exports.btcToSatoshi = btcToSatoshi; +module.exports.getCurrencySymbol = getCurrencySymbol; +module.exports._setPreferredFiatCurrency = _setPreferredFiatCurrency; // export it to mock data in tests +module.exports._setExchangeRate = _setExchangeRate; // export it to mock data in tests diff --git a/img/round-compare-arrows-24-px.png b/img/round-compare-arrows-24-px.png new file mode 100755 index 000000000..c081c2a4d Binary files /dev/null and b/img/round-compare-arrows-24-px.png differ diff --git a/img/round-compare-arrows-24-px@2x.png b/img/round-compare-arrows-24-px@2x.png new file mode 100755 index 000000000..c0d35bcd6 Binary files /dev/null and b/img/round-compare-arrows-24-px@2x.png differ diff --git a/img/round-compare-arrows-24-px@3x.png b/img/round-compare-arrows-24-px@3x.png new file mode 100755 index 000000000..6296b84e7 Binary files /dev/null and b/img/round-compare-arrows-24-px@3x.png differ diff --git a/loc/index.js b/loc/index.js index 536d6b362..1ea4e45d4 100644 --- a/loc/index.js +++ b/loc/index.js @@ -4,7 +4,7 @@ import { AppStorage } from '../class'; import { BitcoinUnit } from '../models/bitcoinUnits'; import relativeTime from 'dayjs/plugin/relativeTime'; const dayjs = require('dayjs'); -const currency = require('../currency'); +const currency = require('../blue_modules/currency'); const BigNumber = require('bignumber.js'); dayjs.extend(relativeTime); @@ -149,7 +149,7 @@ strings.transactionTimeToReadable = time => { return ret; }; -function removeTrailingZeros(value) { +strings.removeTrailingZeros = value => { value = value.toString(); if (value.indexOf('.') === -1) { @@ -159,21 +159,22 @@ function removeTrailingZeros(value) { value = value.substr(0, value.length - 1); } return value; -} +}; /** * - * @param balance {Number} Float amount of bitcoins + * @param balance {number} Satoshis * @param toUnit {String} Value from models/bitcoinUnits.js + * @param withFormatting {boolean} Works only with `BitcoinUnit.SATS`, makes spaces wetween groups of 000 * @returns {string} */ -strings.formatBalance = (balance, toUnit, withFormatting = false) => { +function formatBalance(balance, toUnit, withFormatting = false) { if (toUnit === undefined) { return balance + ' ' + BitcoinUnit.BTC; } if (toUnit === BitcoinUnit.BTC) { const value = new BigNumber(balance).dividedBy(100000000).toFixed(8); - return removeTrailingZeros(value) + ' ' + BitcoinUnit.BTC; + return strings.removeTrailingZeros(value) + ' ' + BitcoinUnit.BTC; } else if (toUnit === BitcoinUnit.SATS) { return ( (balance < 0 ? '-' : '') + @@ -184,22 +185,23 @@ strings.formatBalance = (balance, toUnit, withFormatting = false) => { } else if (toUnit === BitcoinUnit.LOCAL_CURRENCY) { return currency.satoshiToLocalCurrency(balance); } -}; +} /** * * @param balance {Integer} Satoshis - * @param toUnit {String} Value from models/bitcoinUnits.js + * @param toUnit {String} Value from models/bitcoinUnits.js, for example `BitcoinUnit.SATS` + * @param withFormatting {boolean} Works only with `BitcoinUnit.SATS`, makes spaces wetween groups of 000 * @returns {string} */ -strings.formatBalanceWithoutSuffix = (balance = 0, toUnit, withFormatting = false) => { +function formatBalanceWithoutSuffix(balance = 0, toUnit, withFormatting = false) { if (toUnit === undefined) { return balance; } if (balance !== 0) { if (toUnit === BitcoinUnit.BTC) { const value = new BigNumber(balance).dividedBy(100000000).toFixed(8); - return removeTrailingZeros(value); + return strings.removeTrailingZeros(value); } else if (toUnit === BitcoinUnit.SATS) { return (balance < 0 ? '-' : '') + (withFormatting ? new Intl.NumberFormat().format(balance).replace(/[^0-9]/g, ' ') : balance); } else if (toUnit === BitcoinUnit.LOCAL_CURRENCY) { @@ -207,6 +209,36 @@ strings.formatBalanceWithoutSuffix = (balance = 0, toUnit, withFormatting = fals } } return balance.toString(); -}; +} + +/** + * Should be used when we need a simple string to be put in text input, for example + * + * @param balance {integer} Satoshis + * @param toUnit {String} Value from models/bitcoinUnits.js, for example `BitcoinUnit.SATS` + * @param withFormatting {boolean} Works only with `BitcoinUnit.SATS`, makes spaces wetween groups of 000 + * @returns {string} + */ +function formatBalancePlain(balance = 0, toUnit, withFormatting = false) { + const newInputValue = formatBalanceWithoutSuffix(balance, toUnit, withFormatting); + return _leaveNumbersAndDots(newInputValue); +} + +function _leaveNumbersAndDots(newInputValue) { + newInputValue = newInputValue.replace(/[^\d.,-]/g, ''); // filtering, leaving only numbers, dots & commas + if (newInputValue.endsWith('.00') || newInputValue.endsWith(',00')) newInputValue = newInputValue.substring(0, newInputValue.length - 3); + + if (newInputValue[newInputValue.length - 3] === ',') { + // this is a fractional value, lets replace comma to dot so it represents actual fractional value for normal people + newInputValue = newInputValue.substring(0, newInputValue.length - 3) + '.' + newInputValue.substring(newInputValue.length - 2); + } + newInputValue = newInputValue.replace(/,/gi, ''); + + return newInputValue; +} module.exports = strings; +module.exports.formatBalanceWithoutSuffix = formatBalanceWithoutSuffix; +module.exports.formatBalance = formatBalance; +module.exports.formatBalancePlain = formatBalancePlain; +module.exports._leaveNumbersAndDots = _leaveNumbersAndDots; diff --git a/models/bitcoinTransactionInfo.js b/models/bitcoinTransactionInfo.js index f5daf18e0..9eb4e097f 100644 --- a/models/bitcoinTransactionInfo.js +++ b/models/bitcoinTransactionInfo.js @@ -1,6 +1,13 @@ export class BitcoinTransaction { - constructor(address = '', amount) { + /** + * + * @param address + * @param amount {number} + * @param amountSats {integer} satoshi + */ + constructor(address = '', amount, amountSats) { this.address = address; this.amount = amount; + this.amountSats = amountSats; } } diff --git a/screen/lnd/lndCreateInvoice.js b/screen/lnd/lndCreateInvoice.js index 13941f77b..e4421bb81 100644 --- a/screen/lnd/lndCreateInvoice.js +++ b/screen/lnd/lndCreateInvoice.js @@ -25,6 +25,7 @@ import { BitcoinUnit, Chain } from '../../models/bitcoinUnits'; import NavigationService from '../../NavigationService'; import ReactNativeHapticFeedback from 'react-native-haptic-feedback'; import { Icon } from 'react-native-elements'; +const currency = require('../../blue_modules/currency'); const BlueApp = require('../../BlueApp'); const EV = require('../../events'); const loc = require('../../loc'); @@ -137,6 +138,7 @@ export default class LNDCreateInvoice extends Component { super(props); this.keyboardDidShowListener = Keyboard.addListener('keyboardDidShow', this._keyboardDidShow); this.keyboardDidHideListener = Keyboard.addListener('keyboardDidHide', this._keyboardDidHide); + /** @type LightningCustodianWallet */ let fromWallet; if (props.route.params.fromWallet) fromWallet = props.route.params.fromWallet; @@ -152,6 +154,7 @@ export default class LNDCreateInvoice extends Component { this.state = { fromWallet, amount: '', + unit: fromWallet.preferredBalanceUnit, description: '', lnurl: '', lnurlParams: null, @@ -170,6 +173,7 @@ export default class LNDCreateInvoice extends Component { }; componentDidMount() { + console.log('lnd/lndCreateInvoice mounted'); if (this.state.fromWallet.getUserHasSavedExport()) { this.renderReceiveDetails(); } else { @@ -201,7 +205,22 @@ export default class LNDCreateInvoice extends Component { async createInvoice() { this.setState({ isLoading: true }, async () => { try { - const invoiceRequest = await this.state.fromWallet.addInvoice(this.state.amount, this.state.description); + this.setState({ isLoading: false }); + let amount = this.state.amount; + switch (this.state.unit) { + case BitcoinUnit.SATS: + amount = parseInt(amount); // basically nop + break; + case BitcoinUnit.BTC: + amount = currency.btcToSatoshi(amount); + break; + case BitcoinUnit.LOCAL_CURRENCY: + // trying to fetch cached sat equivalent for this fiat amount + amount = BlueBitcoinAmount.getCachedSatoshis(amount) || currency.btcToSatoshi(currency.fiatToBTC(amount)); + break; + } + + const invoiceRequest = await this.state.fromWallet.addInvoice(amount, this.state.description); EV(EV.enum.TRANSACTIONS_COUNT_CHANGED); ReactNativeHapticFeedback.trigger('notificationSuccess', { ignoreAndroidSystemSettings: false }); @@ -379,6 +398,9 @@ export default class LNDCreateInvoice extends Component { { + this.setState({ unit }); + }} onChangeText={text => { if (this.state.lnurlParams) { // in this case we prevent the user from changing the amount to < min or > max @@ -394,7 +416,7 @@ export default class LNDCreateInvoice extends Component { this.setState({ amount: text }); }} disabled={this.state.isLoading || (this.state.lnurlParams && this.state.lnurlParams.fixed)} - unit={BitcoinUnit.SATS} + unit={this.state.unit} inputAccessoryViewID={BlueDismissKeyboardInputAccessory.InputAccessoryViewID} /> diff --git a/screen/lnd/scanLndInvoice.js b/screen/lnd/scanLndInvoice.js index cfc01755b..cc4c0beb2 100644 --- a/screen/lnd/scanLndInvoice.js +++ b/screen/lnd/scanLndInvoice.js @@ -21,6 +21,7 @@ import Biometric from '../../class/biometrics'; const BlueApp = require('../../BlueApp'); const EV = require('../../events'); const loc = require('../../loc'); +const currency = require('../../blue_modules/currency'); const styles = StyleSheet.create({ walletSelectRoot: { @@ -138,6 +139,7 @@ export default class ScanLndInvoice extends React.Component { this.state = { fromWallet, fromSecret, + unit: BitcoinUnit.SATS, destination: '', }; } @@ -174,6 +176,8 @@ export default class ScanLndInvoice extends React.Component { return { invoice: data, decoded, + unit: state.unit, + amount: decoded.num_satoshis, expiresIn, destination: data, isAmountInitiallyEmpty: decoded.num_satoshis === '0', @@ -220,6 +224,19 @@ export default class ScanLndInvoice extends React.Component { } } + let amountSats = this.state.amount; + switch (this.state.unit) { + case BitcoinUnit.SATS: + amountSats = parseInt(amountSats); // nop + break; + case BitcoinUnit.BTC: + amountSats = currency.btcToSatoshi(amountSats); + break; + case BitcoinUnit.LOCAL_CURRENCY: + amountSats = currency.btcToSatoshi(currency.fiatToBTC(amountSats)); + break; + } + this.setState( { isLoading: true, @@ -245,7 +262,7 @@ export default class ScanLndInvoice extends React.Component { } try { - await fromWallet.payInvoice(this.state.invoice, this.state.decoded.num_satoshis); + await fromWallet.payInvoice(this.state.invoice, amountSats); } catch (Err) { console.log(Err.message); this.setState({ isLoading: false }); @@ -255,7 +272,7 @@ export default class ScanLndInvoice extends React.Component { EV(EV.enum.REMOTE_TRANSACTIONS_COUNT_CHANGED); // someone should fetch txs this.props.navigation.navigate('Success', { - amount: this.state.decoded.num_satoshis, + amount: amountSats, amountUnit: BitcoinUnit.SATS, invoiceDescription: this.state.decoded.description, }); @@ -275,11 +292,12 @@ export default class ScanLndInvoice extends React.Component { if (typeof this.state.decoded !== 'object') { return true; } else { - if (!this.state.decoded.num_satoshis) { + if (!this.state.amount) { return true; } } - return this.state.decoded.num_satoshis <= 0 || this.state.isLoading || isNaN(this.state.decoded.num_satoshis); + return !(this.state.amount > 0); + // return this.state.decoded.num_satoshis <= 0 || this.state.isLoading || isNaN(this.state.decoded.num_satoshis); }; renderWalletSelectionButton = () => { @@ -327,6 +345,10 @@ export default class ScanLndInvoice extends React.Component { }); }; + async componentDidMount() { + console.log('scanLndInvoice did mount'); + } + render() { if (!this.state.fromWallet) { return ; @@ -340,16 +362,25 @@ export default class ScanLndInvoice extends React.Component { { + this.setState({ unit }); + }} onChangeText={text => { - if (typeof this.state.decoded === 'object') { + this.setState({ amount: text }); + + /* if (typeof this.state.decoded === 'object') { text = parseInt(text || 0); const decoded = this.state.decoded; decoded.num_satoshis = text; this.setState({ decoded: decoded }); - } + } */ }} - disabled={typeof this.state.decoded !== 'object' || this.state.isLoading} + disabled={ + typeof this.state.decoded !== 'object' || + this.state.isLoading || + (this.state.decoded && this.state.decoded.num_satoshis > 0) + } unit={BitcoinUnit.SATS} inputAccessoryViewID={BlueDismissKeyboardInputAccessory.InputAccessoryViewID} /> diff --git a/screen/receive/details.js b/screen/receive/details.js index aed52bd6c..8c3c7e41b 100644 --- a/screen/receive/details.js +++ b/screen/receive/details.js @@ -1,7 +1,7 @@ import React, { useEffect, useState, useCallback } from 'react'; import { View, InteractionManager, Platform, TextInput, KeyboardAvoidingView, Keyboard, StyleSheet, ScrollView } from 'react-native'; import QRCode from 'react-native-qrcode-svg'; -import { useNavigation, useRoute, useIsFocused } from '@react-navigation/native'; +import { useNavigation, useRoute } from '@react-navigation/native'; import { BlueLoading, SafeBlueArea, @@ -25,20 +25,21 @@ import Handoff from 'react-native-handoff'; /** @type {AppStorage} */ const BlueApp = require('../../BlueApp'); const loc = require('../../loc'); +const currency = require('../../blue_modules/currency'); const ReceiveDetails = () => { const { secret } = useRoute().params; - const [wallet, setWallet] = useState(); + const wallet = BlueApp.getWallets().find(w => w.getSecret() === secret); const [isHandOffUseEnabled, setIsHandOffUseEnabled] = useState(false); const [address, setAddress] = useState(''); const [customLabel, setCustomLabel] = useState(); const [customAmount, setCustomAmount] = useState(0); + const [customUnit, setCustomUnit] = useState(BitcoinUnit.BTC); const [bip21encoded, setBip21encoded] = useState(); const [qrCodeSVG, setQrCodeSVG] = useState(); const [isCustom, setIsCustom] = useState(false); const [isCustomModalVisible, setIsCustomModalVisible] = useState(false); const { navigate, goBack } = useNavigation(); - const isFocused = useIsFocused(); const renderReceiveDetails = useCallback(async () => { console.log('receive/details - componentDidMount'); @@ -82,10 +83,6 @@ const ReceiveDetails = () => { }, [wallet]); useEffect(() => { - Privacy.enableBlur(); - - setWallet(BlueApp.getWallets().find(w => w.getSecret() === secret)); - if (wallet) { if (!wallet.getUserHasSavedExport()) { BlueAlertWalletExportReminder({ @@ -102,6 +99,7 @@ const ReceiveDetails = () => { } } HandoffSettings.isHandoffUseEnabled().then(setIsHandOffUseEnabled); + Privacy.enableBlur(); return () => Privacy.disableBlur(); }, [goBack, navigate, renderReceiveDetails, secret, wallet]); @@ -117,7 +115,24 @@ const ReceiveDetails = () => { const createCustomAmountAddress = () => { setIsCustom(true); setIsCustomModalVisible(false); - setBip21encoded(DeeplinkSchemaMatch.bip21encode(address, { amount: customAmount, label: customLabel })); + let amount = customAmount; + switch (customUnit) { + case BitcoinUnit.BTC: + // nop + break; + case BitcoinUnit.SATS: + amount = currency.satoshiToBTC(customAmount); + break; + case BitcoinUnit.LOCAL_CURRENCY: + if (BlueBitcoinAmount.conversionCache[amount + BitcoinUnit.LOCAL_CURRENCY]) { + // cache hit! we reuse old value that supposedly doesnt have rounding errors + amount = currency.satoshiToBTC(BlueBitcoinAmount.conversionCache[amount + BitcoinUnit.LOCAL_CURRENCY]); + } else { + amount = currency.fiatToBTC(customAmount); + } + break; + } + setBip21encoded(DeeplinkSchemaMatch.bip21encode(address, { amount, label: customLabel })); }; const clearCustomAmount = () => { @@ -133,7 +148,12 @@ const ReceiveDetails = () => { - + { } }; + /** + * @returns {string} BTC amount, accounting for current `customUnit` and `customUnit` + */ + const getDisplayAmount = () => { + switch (customUnit) { + case BitcoinUnit.BTC: + return customAmount + ' BTC'; + case BitcoinUnit.SATS: + return currency.satoshiToBTC(customAmount) + ' BTC'; + case BitcoinUnit.LOCAL_CURRENCY: + return currency.fiatToBTC(customAmount) + ' BTC'; + } + return customAmount + ' ' + customUnit; + }; + return ( {isHandOffUseEnabled && address !== undefined && ( @@ -181,19 +216,19 @@ const ReceiveDetails = () => { url={`https://blockstream.info/address/${address}`} /> )} - + {isCustom && ( <> - {customAmount} {BitcoinUnit.BTC} + {getDisplayAmount()} {customLabel} )} - {bip21encoded === undefined && isFocused ? ( + {bip21encoded === undefined ? ( diff --git a/screen/send/confirm.js b/screen/send/confirm.js index 006b58ec4..cba6a3ee8 100644 --- a/screen/send/confirm.js +++ b/screen/send/confirm.js @@ -19,7 +19,7 @@ import { } from '../../class'; const loc = require('../../loc'); const EV = require('../../events'); -const currency = require('../../currency'); +const currency = require('../../blue_modules/currency'); const BlueElectrum = require('../../BlueElectrum'); const Bignumber = require('bignumber.js'); /** @type {AppStorage} */ @@ -125,6 +125,11 @@ export default class Confirm extends Component { {' ' + BitcoinUnit.BTC} + + {item.value !== BitcoinUnit.MAX && item.value + ? currency.satoshiToLocalCurrency(item.value) + : currency.satoshiToLocalCurrency(this.state.fromWallet.getBalance() - this.state.feeSatoshi)} + {loc.send.create.to} {item.address} @@ -212,6 +217,13 @@ const styles = StyleSheet.create({ fontSize: 15, marginBottom: 20, }, + transactionAmountFiat: { + color: '#9aa0aa', + fontWeight: '500', + fontSize: 15, + marginVertical: 20, + textAlign: 'center', + }, valueWrap: { flexDirection: 'row', justifyContent: 'center', @@ -247,6 +259,7 @@ const styles = StyleSheet.create({ marginTop: 16, alignItems: 'center', justifyContent: 'space-between', + backgroundColor: '#FFFFFF', }, flat: { maxHeight: '55%', @@ -256,6 +269,7 @@ const styles = StyleSheet.create({ justifyContent: 'center', paddingTop: 16, paddingBottom: 16, + backgroundColor: '#FFFFFF', }, cardText: { color: '#37c0a1', diff --git a/screen/send/create.js b/screen/send/create.js index e46c5dbf8..52f289d8a 100644 --- a/screen/send/create.js +++ b/screen/send/create.js @@ -25,7 +25,7 @@ import RNFS from 'react-native-fs'; /** @type {AppStorage} */ const BlueApp = require('../../BlueApp'); const loc = require('../../loc'); -const currency = require('../../currency'); +const currency = require('../../blue_modules/currency'); export default class SendCreate extends Component { static navigationOptions = ({ navigation, route }) => { diff --git a/screen/send/details.js b/screen/send/details.js index fb5bdd615..1dea1b51d 100644 --- a/screen/send/details.js +++ b/screen/send/details.js @@ -1,6 +1,7 @@ /* global alert */ import React, { Component } from 'react'; import { + ActivityIndicator, View, TextInput, Alert, @@ -13,7 +14,6 @@ import { Dimensions, Platform, ScrollView, - ActivityIndicator, Text, } from 'react-native'; import { Icon } from 'react-native-elements'; @@ -24,6 +24,7 @@ import { BlueBitcoinAmount, BlueAddressInput, BlueDismissKeyboardInputAccessory, + BlueLoading, BlueUseAllFundsButton, BlueListItem, BlueText, @@ -41,6 +42,7 @@ import DocumentPicker from 'react-native-document-picker'; import RNFS from 'react-native-fs'; import DeeplinkSchemaMatch from '../../class/deeplink-schema-match'; const bitcoin = require('bitcoinjs-lib'); +const currency = require('../../blue_modules/currency'); const BigNumber = require('bignumber.js'); const { width } = Dimensions.get('window'); const BlueApp: AppStorage = require('../../BlueApp'); @@ -52,6 +54,7 @@ const styles = StyleSheet.create({ loading: { flex: 1, paddingTop: 20, + backgroundColor: '#FFFFFF', }, root: { flex: 1, @@ -254,10 +257,12 @@ export default class SendDetails extends Component { recipientsScrollIndex: 0, fromWallet, addresses: [], + units: [], memo: '', networkTransactionFees: new NetworkTransactionFee(1, 1, 1), fee: 1, feeSliderValue: 1, + amountUnit: fromWallet.preferredBalanceUnit, // default for whole screen bip70TransactionExpiration: null, renderWalletSelectionButtonHidden: false, }; @@ -289,18 +294,22 @@ export default class SendDetails extends Component { feeSliderValue: bip70.feeSliderValue, fee: bip70.fee, isLoading: false, + amountUnit: BitcoinUnit.BTC, bip70TransactionExpiration: bip70.bip70TransactionExpiration, }); } else { - console.warn('2'); const recipients = this.state.addresses; const dataWithoutSchema = data.replace('bitcoin:', '').replace('BITCOIN:', ''); if (this.state.fromWallet.isAddressValid(dataWithoutSchema)) { recipients[[this.state.recipientsScrollIndex]].address = dataWithoutSchema; + const units = this.state.units; + units[this.state.recipientsScrollIndex] = BitcoinUnit.BTC; // also resetting current unit to BTC this.setState({ address: recipients, bip70TransactionExpiration: null, isLoading: false, + amountUnit: BitcoinUnit.BTC, + units, }); } else { let address = ''; @@ -322,6 +331,8 @@ export default class SendDetails extends Component { } console.log(options); if (btcAddressRx.test(address) || address.indexOf('bc1') === 0 || address.indexOf('BC1') === 0) { + const units = this.state.units; + units[this.state.recipientsScrollIndex] = BitcoinUnit.BTC; // also resetting current unit to BTC recipients[[this.state.recipientsScrollIndex]].address = address; recipients[[this.state.recipientsScrollIndex]].amount = options.amount; this.setState({ @@ -329,6 +340,8 @@ export default class SendDetails extends Component { memo: options.label || options.message, bip70TransactionExpiration: null, isLoading: false, + amountUnit: BitcoinUnit.BTC, + units, }); } else { this.setState({ isLoading: false }); @@ -342,6 +355,7 @@ export default class SendDetails extends Component { this.renderNavigationHeader(); console.log('send/details - componentDidMount'); StatusBar.setBarStyle('dark-content'); + /** @type {BitcoinTransaction[]} */ const addresses = []; let initialMemo = ''; if (this.props.route.params.uri) { @@ -350,13 +364,13 @@ export default class SendDetails extends Component { const { recipient, memo, fee, feeSliderValue } = await this.processBIP70Invoice(uri); addresses.push(recipient); initialMemo = memo; - this.setState({ addresses, memo: initialMemo, fee, feeSliderValue, isLoading: false }); + this.setState({ addresses, memo: initialMemo, fee, feeSliderValue, isLoading: false, amountUnit: BitcoinUnit.BTC }); } else { try { const { address, amount, memo } = this.decodeBitcoinUri(uri); - addresses.push(new BitcoinTransaction(address, amount)); + addresses.push(new BitcoinTransaction(address, amount, currency.btcToSatoshi(amount))); initialMemo = memo; - this.setState({ addresses, memo: initialMemo, isLoading: false }); + this.setState({ addresses, memo: initialMemo, isLoading: false, amountUnit: BitcoinUnit.BTC }); } catch (error) { console.log(error); alert('Error: Unable to decode Bitcoin address'); @@ -365,7 +379,7 @@ export default class SendDetails extends Component { } else if (this.props.route.params.address) { addresses.push(new BitcoinTransaction(this.props.route.params.address)); if (this.props.route.params.memo) initialMemo = this.props.route.params.memo; - this.setState({ addresses, memo: initialMemo, isLoading: false }); + this.setState({ addresses, memo: initialMemo, isLoading: false, amountUnit: BitcoinUnit.BTC }); } else { this.setState({ addresses: [new BitcoinTransaction()], isLoading: false }); } @@ -446,33 +460,13 @@ export default class SendDetails extends Component { return { address, amount, memo }; } - recalculateAvailableBalance(balance, amount, fee) { - if (!amount) amount = 0; - if (!fee) fee = 0; - let availableBalance; - try { - availableBalance = new BigNumber(balance); - availableBalance = availableBalance.div(100000000); // sat2btc - availableBalance = availableBalance.minus(amount); - availableBalance = availableBalance.minus(fee); - availableBalance = availableBalance.toString(10); - } catch (err) { - return balance; - } - - return (availableBalance === 'NaN' && balance) || availableBalance; - } - async processBIP70Invoice(text) { try { if (BitcoinBIP70TransactionDecode.matchesPaymentURL(text)) { Keyboard.dismiss(); return BitcoinBIP70TransactionDecode.decode(text) .then(response => { - const recipient = new BitcoinTransaction( - response.address, - loc.formatBalanceWithoutSuffix(response.amount, BitcoinUnit.BTC, false), - ); + const recipient = new BitcoinTransaction(response.address, currency.satoshiToBTC(response.amount), response.amount); return { recipient, memo: response.memo, @@ -507,7 +501,7 @@ export default class SendDetails extends Component { } else if (!transaction.address) { error = loc.send.details.address_field_is_not_valid; console.log('validation error'); - } else if (this.recalculateAvailableBalance(this.state.fromWallet.getBalance(), transaction.amount, 0) < 0) { + } else if (this.state.fromWallet.getBalance() - transaction.amountSats < 0) { // first sanity check is that sending amount is not bigger than available balance error = loc.send.details.total_exceeds_balance; console.log('validation error'); @@ -577,7 +571,7 @@ export default class SendDetails extends Component { targets = [{ address: transaction.address }]; break; } - const value = new BigNumber(transaction.amount).multipliedBy(100000000).toNumber(); + const value = parseInt(transaction.amountSats); if (value > 0) { targets.push({ address: transaction.address, value }); } @@ -787,7 +781,7 @@ export default class SendDetails extends Component { const isSendMaxUsed = this.state.addresses.some(element => element.amount === BitcoinUnit.MAX); return ( { Keyboard.dismiss(); @@ -835,6 +829,8 @@ export default class SendDetails extends Component { () => { this.scrollView.scrollToEnd(); if (this.state.addresses.length > 1) this.scrollView.flashScrollIndicators(); + // after adding recipient it automatically scrolls to the last one + this.setState({ recipientsScrollIndex: this.state.addresses.length - 1 }); }, ); }} @@ -854,7 +850,8 @@ export default class SendDetails extends Component { }, () => { if (this.state.addresses.length > 1) this.scrollView.flashScrollIndicators(); - this.setState({ recipientsScrollIndex: this.scrollViewCurrentIndex }); + // after deletion it automatically scrolls to the last one + this.setState({ recipientsScrollIndex: this.state.addresses.length - 1 }); }, ); }} @@ -941,12 +938,49 @@ export default class SendDetails extends Component { { + const units = this.state.units; + units[index] = unit; + + const addresses = this.state.addresses; + const item = addresses[index]; + + switch (unit) { + case BitcoinUnit.SATS: + item.amountSats = parseInt(item.amount); + break; + case BitcoinUnit.BTC: + item.amountSats = currency.btcToSatoshi(item.amount); + break; + case BitcoinUnit.LOCAL_CURRENCY: + // also accounting for cached fiat->sat conversion to avoid rounding error + item.amountSats = + BlueBitcoinAmount.getCachedSatoshis(item.amount) || currency.btcToSatoshi(currency.fiatToBTC(item.amount)); + break; + } + + addresses[index] = item; + this.setState({ units, addresses }); + }} onChangeText={text => { item.amount = text; - const transactions = this.state.addresses; - transactions[index] = item; - this.setState({ addresses: transactions }); + switch (this.state.units[index] || this.state.amountUnit) { + case BitcoinUnit.BTC: + item.amountSats = currency.btcToSatoshi(item.amount); + break; + case BitcoinUnit.LOCAL_CURRENCY: + item.amountSats = currency.btcToSatoshi(currency.fiatToBTC(item.amount)); + break; + default: + case BitcoinUnit.SATS: + item.amountSats = parseInt(text); + break; + } + const addresses = this.state.addresses; + addresses[index] = item; + this.setState({ addresses }); }} + unit={this.state.units[index] || this.state.amountUnit} inputAccessoryViewID={this.state.fromWallet.allowSendMax() ? BlueUseAllFundsButton.InputAccessoryViewID : null} /> + + + ); + } return ( - + - - - (this.scrollView = ref)} - onContentSizeChange={() => this.scrollView.scrollToEnd()} - onLayout={() => this.scrollView.scrollToEnd()} - onMomentumScrollEnd={this.handlePageChange} - scrollEnabled={this.state.addresses.length > 1} - scrollIndicatorInsets={{ top: 0, left: 8, bottom: 0, right: 8 }} - > - {this.renderBitcoinTransactionInfoFields()} - - - this.setState({ memo: text })} - placeholder={loc.send.details.note_placeholder} - placeholderTextColor="#81868e" - value={this.state.memo} - numberOfLines={1} - style={styles.memoText} - editable={!this.state.isLoading} - onSubmitEditing={Keyboard.dismiss} - inputAccessoryViewID={BlueDismissKeyboardInputAccessory.InputAccessoryViewID} - /> + + (this.scrollView = ref)} + keyboardShouldPersistTaps="always" + onContentSizeChange={() => this.scrollView.scrollToEnd()} + onLayout={() => this.scrollView.scrollToEnd()} + onMomentumScrollEnd={this.handlePageChange} + scrollEnabled={this.state.addresses.length > 1} + scrollIndicatorInsets={{ top: 0, left: 8, bottom: 0, right: 8 }} + > + {this.renderBitcoinTransactionInfoFields()} + + + this.setState({ memo: text })} + placeholder={loc.send.details.note_placeholder} + placeholderTextColor="#81868e" + value={this.state.memo} + numberOfLines={1} + style={styles.memoText} + editable={!this.state.isLoading} + onSubmitEditing={Keyboard.dismiss} + inputAccessoryViewID={BlueDismissKeyboardInputAccessory.InputAccessoryViewID} + /> + + this.setState({ isFeeSelectionModalVisible: true })} + disabled={this.state.isLoading} + style={styles.fee} + > + Fee + + {this.state.fee} + sat/b - this.setState({ isFeeSelectionModalVisible: true })} - disabled={this.state.isLoading} - style={styles.fee} - > - Fee - - {this.state.fee} - sat/b - - - {this.renderFeeSelectionModal()} - {this.renderAdvancedTransactionOptionsModal()} - - - {this.renderCreateButton()} + + {this.renderCreateButton()} + {this.renderFeeSelectionModal()} + {this.renderAdvancedTransactionOptionsModal()} + {Platform.select({ - ios: , + ios: ( + + ), android: this.state.isAmountToolbarVisibleForAndroid && ( - + ), })} diff --git a/screen/settings/currency.js b/screen/settings/currency.js index 154e3f1d6..5db59df8e 100644 --- a/screen/settings/currency.js +++ b/screen/settings/currency.js @@ -5,7 +5,7 @@ import PropTypes from 'prop-types'; import { Icon } from 'react-native-elements'; import { FiatUnit } from '../../models/fiatUnit'; const loc = require('../../loc'); -const currency = require('../../currency'); +const currency = require('../../blue_modules/currency'); const data = Object.values(FiatUnit); diff --git a/screen/wallets/buyBitcoin.js b/screen/wallets/buyBitcoin.js index 50b7f10b8..2f1ba7deb 100644 --- a/screen/wallets/buyBitcoin.js +++ b/screen/wallets/buyBitcoin.js @@ -4,7 +4,7 @@ import { BlueNavigationStyle, BlueLoading, SafeBlueArea } from '../../BlueCompon import PropTypes from 'prop-types'; import { WebView } from 'react-native-webview'; import { AppStorage, LightningCustodianWallet, WatchOnlyWallet } from '../../class'; -const currency = require('../../currency'); +const currency = require('../../blue_modules/currency'); const BlueApp: AppStorage = require('../../BlueApp'); const loc = require('../../loc'); diff --git a/screen/wallets/reorderWallets.js b/screen/wallets/reorderWallets.js index a981f9fd4..fdc6a2717 100644 --- a/screen/wallets/reorderWallets.js +++ b/screen/wallets/reorderWallets.js @@ -86,6 +86,8 @@ export default class ReorderWallets extends Component { }; } + sortableList = React.createRef(); + componentDidMount() { this.props.navigation.setParams({ customCloseButtonFunction: async () => { @@ -160,7 +162,7 @@ export default class ReorderWallets extends Component { return ( (this.sortableList = ref)} + ref={this.sortableList} style={styles.root} data={this.state.data} renderRow={this._renderItem} diff --git a/tests/integration/Currency.test.js b/tests/integration/Currency.test.js index 861af96c0..5fadbffca 100644 --- a/tests/integration/Currency.test.js +++ b/tests/integration/Currency.test.js @@ -8,7 +8,7 @@ jest.useFakeTimers(); describe('currency', () => { it('fetches exchange rate and saves to AsyncStorage', async () => { jasmine.DEFAULT_TIMEOUT_INTERVAL = 15000; - const currency = require('../../currency'); + const currency = require('../../blue_modules/currency'); await currency.startUpdater(); let cur = await AsyncStorage.getItem(AppStorage.EXCHANGE_RATES); cur = JSON.parse(cur); diff --git a/tests/unit/Currency.test.js b/tests/unit/Currency.test.js index 978b3e5ba..bec396f8d 100644 --- a/tests/unit/Currency.test.js +++ b/tests/unit/Currency.test.js @@ -4,8 +4,8 @@ const assert = require('assert'); describe('currency', () => { it('formats everything correctly', async () => { - const currency = require('../../currency'); - currency.exchangeRates.BTC_USD = 10000; + const currency = require('../../blue_modules/currency'); + currency._setExchangeRate('BTC_USD', 10000); assert.strictEqual(currency.satoshiToLocalCurrency(1), '$0.0001'); assert.strictEqual(currency.satoshiToLocalCurrency(-1), '-$0.0001'); @@ -26,10 +26,8 @@ describe('currency', () => { assert.strictEqual(currency.satoshiToBTC(100000000), '1'); assert.strictEqual(currency.satoshiToBTC(123456789123456789), '1234567891.2345678'); - currency.preferredFiatCurrency.endPointKey = FiatUnit.JPY.endPointKey; - currency.preferredFiatCurrency.symbol = FiatUnit.JPY.symbol; - currency.preferredFiatCurrency.locale = FiatUnit.JPY.locale; - currency.exchangeRates.BTC_JPY = 1043740.8614; + currency._setPreferredFiatCurrency(FiatUnit.JPY); + currency._setExchangeRate('BTC_JPY', 1043740.8614); assert.strictEqual(currency.satoshiToLocalCurrency(1), '¥0.01'); }); diff --git a/tests/unit/Loc.test.js b/tests/unit/Loc.test.js index 824962a3a..6b96ed00b 100644 --- a/tests/unit/Loc.test.js +++ b/tests/unit/Loc.test.js @@ -1,8 +1,88 @@ /* global it, describe */ +import { BitcoinUnit } from '../../models/bitcoinUnits'; +import { FiatUnit } from '../../models/fiatUnit'; const assert = require('assert'); const fs = require('fs'); +const loc = require('../../loc/'); +const currency = require('../../blue_modules/currency'); describe('Localization', () => { + it('internal formatter', () => { + assert.strictEqual(loc._leaveNumbersAndDots('1,00 ₽'), '1'); + assert.strictEqual(loc._leaveNumbersAndDots('0,50 ₽"'), '0.50'); + assert.strictEqual(loc._leaveNumbersAndDots('RUB 1,00'), '1'); + }); + + it('formatBalancePlain() && formatBalancePlain()', () => { + currency._setExchangeRate('BTC_RUB', 660180.143); + currency._setPreferredFiatCurrency(FiatUnit.RUB); + let newInputValue = loc.formatBalanceWithoutSuffix(152, BitcoinUnit.LOCAL_CURRENCY, false); + assert.strictEqual(newInputValue, 'RUB 1.00'); + newInputValue = loc.formatBalancePlain(152, BitcoinUnit.LOCAL_CURRENCY, false); + assert.strictEqual(newInputValue, '1'); + + newInputValue = loc.formatBalanceWithoutSuffix(1515, BitcoinUnit.LOCAL_CURRENCY, false); + assert.strictEqual(newInputValue, 'RUB 10.00'); + newInputValue = loc.formatBalancePlain(1515, BitcoinUnit.LOCAL_CURRENCY, false); + assert.strictEqual(newInputValue, '10'); + + newInputValue = loc.formatBalanceWithoutSuffix(16793829, BitcoinUnit.LOCAL_CURRENCY, false); + assert.strictEqual(newInputValue, 'RUB 110,869.52'); + newInputValue = loc.formatBalancePlain(16793829, BitcoinUnit.LOCAL_CURRENCY, false); + assert.strictEqual(newInputValue, '110869.52'); + + newInputValue = loc.formatBalancePlain(76, BitcoinUnit.LOCAL_CURRENCY, false); + assert.strictEqual(newInputValue, '0.50'); + + currency._setExchangeRate('BTC_USD', 10000); + currency._setPreferredFiatCurrency(FiatUnit.USD); + newInputValue = loc.formatBalanceWithoutSuffix(16793829, BitcoinUnit.LOCAL_CURRENCY, false); + assert.strictEqual(newInputValue, '$1,679.38'); + newInputValue = loc.formatBalancePlain(16793829, BitcoinUnit.LOCAL_CURRENCY, false); + assert.strictEqual(newInputValue, '1679.38'); + + newInputValue = loc.formatBalancePlain(16000000, BitcoinUnit.LOCAL_CURRENCY, false); + assert.strictEqual(newInputValue, '1600'); + }); + + it.each([ + [123000000, BitcoinUnit.SATS, false, '123000000', false], + [123000000, BitcoinUnit.SATS, true, '123 000 000', false], + [123456000, BitcoinUnit.BTC, true, '1.23456', false], + ['123456000', BitcoinUnit.BTC, true, '1.23456', false], // can handle strings + [100000000, BitcoinUnit.BTC, true, '1', false], + [10000000, BitcoinUnit.BTC, true, '0.1', false], + [1, BitcoinUnit.BTC, true, '0.00000001', false], + [10000000, BitcoinUnit.LOCAL_CURRENCY, true, '...', true], // means unknown since we did not receive exchange rate + ])( + 'can formatBalanceWithoutSuffix', + async (balance, toUnit, withFormatting, expectedResult, shouldResetRate) => { + currency._setExchangeRate('BTC_USD', 1); + currency._setPreferredFiatCurrency(FiatUnit.USD); + if (shouldResetRate) { + currency._setExchangeRate('BTC_USD', false); + } + const actualResult = loc.formatBalanceWithoutSuffix(balance, toUnit, withFormatting); + assert.strictEqual(actualResult, expectedResult); + }, + 240000, + ); + + it.each([ + [123000000, BitcoinUnit.SATS, false, '123000000 sats'], + [123000000, BitcoinUnit.BTC, false, '1.23 BTC'], + [123000000, BitcoinUnit.LOCAL_CURRENCY, false, '$1.23'], + ])( + 'can formatBalance', + async (balance, toUnit, withFormatting, expectedResult) => { + currency._setExchangeRate('BTC_USD', 1); + currency._setPreferredFiatCurrency(FiatUnit.USD); + const actualResult = loc.formatBalance(balance, toUnit, withFormatting); + assert.strictEqual(actualResult, expectedResult); + }, + 240000, + ); + it('has all keys in all locales', async () => { const en = require('../../loc/en'); let issues = 0;