feat: satscomma formatting for bitcoin balances (#171)

* Use satscomma for btc formatting

* Harden balance parsing (a bit)

* Add tests for large values
This commit is contained in:
Daniel 2022-03-11 14:29:06 +01:00 committed by GitHub
parent 022f884d29
commit fe94945f2a
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 216 additions and 77 deletions

View file

@ -1,74 +1,115 @@
import React, { useState, useEffect } from 'react'
import Sprite from './Sprite'
import { BTC, SATS, btcToSats, satsToBtc } from '../utils'
import Sprite from './Sprite'
const UNIT_MODE_BTC = 0
const UNIT_MODE_SATS = 1
const UNIT_MODE_HIDDEN = 2
const DISPLAY_MODE_BTC = 0
const DISPLAY_MODE_SATS = 1
const DISPLAY_MODE_HIDDEN = 2
const getUnitMode = (unit, showBalance) => {
if (showBalance && unit === SATS) return UNIT_MODE_SATS
if (showBalance && unit === BTC) return UNIT_MODE_BTC
const decimalPoint = '\u002E'
const nbHalfSpace = '\u202F'
return UNIT_MODE_HIDDEN
const getDisplayMode = (unit, showBalance) => {
if (showBalance && unit === SATS) return DISPLAY_MODE_SATS
if (showBalance && unit === BTC) return DISPLAY_MODE_BTC
return DISPLAY_MODE_HIDDEN
}
export default function Balance({ value, unit, showBalance = false }) {
const [unitMode, setUnitMode] = useState(getUnitMode(unit, showBalance))
useEffect(() => {
setUnitMode(getUnitMode(unit, showBalance))
}, [unit, showBalance])
if (unitMode === UNIT_MODE_HIDDEN) {
return (
<span className="balance-wrapper">
<span className="balance slashed-zeroes">
<span>*****</span>
<span className="text-muted d-inline-flex align-items-center">
<Sprite symbol="hide" width="1.2em" height="1.2em" className="ps-1" />
</span>
</span>
</span>
)
}
const btcFormatter = new Intl.NumberFormat('en-US', {
const formatBtc = (value) => {
const formatter = new Intl.NumberFormat('en-US', {
minimumIntegerDigits: 1,
minimumFractionDigits: 8,
})
const satFormatter = new Intl.NumberFormat('en-US', {
const numberString = formatter.format(value)
const [integerPart, fractionalPart] = numberString.split(decimalPoint)
const formattedFractionalPart = fractionalPart
.split('')
.map((char, idx) => (idx === 2 || idx === 5 ? `${nbHalfSpace}${char}` : char))
.join('')
return integerPart + decimalPoint + formattedFractionalPart
}
const formatSats = (value) => {
const formatter = new Intl.NumberFormat('en-US', {
minimumIntegerDigits: 1,
minimumFractionDigits: 0,
})
const isSats = value === parseInt(value)
const isBTC = !isSats && typeof value === 'string' && value.indexOf('.') > -1
return formatter.format(value)
}
const btcSymbol = <span className="bitcoin-symbol">{'\u20BF'}</span>
const satSymbol = <Sprite symbol="sats" width="20px" height="20px" />
const balanceJSX = (symbolJSX, formattedValue, isPrefix = true) => {
return (
<span className="balance-wrapper">
{isPrefix ? symbolJSX : ''}
<span className="balance slashed-zeroes">{formattedValue}</span>
{!isPrefix ? symbolJSX : ''}
</span>
)
}
if (isBTC && unitMode === UNIT_MODE_BTC) return balanceJSX(btcSymbol, btcFormatter.format(value))
if (isSats && unitMode === UNIT_MODE_SATS) return balanceJSX(satSymbol, satFormatter.format(value), false)
if (isBTC && unitMode === UNIT_MODE_SATS) return balanceJSX(satSymbol, satFormatter.format(btcToSats(value)), false)
if (isSats && unitMode === UNIT_MODE_BTC) return balanceJSX(btcSymbol, btcFormatter.format(satsToBtc(value)))
// Something unexpected happened. Simply render what was passed in the props.
const BalanceComponent = ({ symbol, value, symbolIsPrefix }) => {
return (
<span className="balance">
{value} {unit}
<span className="d-inline-flex align-items-center">
{symbolIsPrefix && symbol}
<span className="d-inline-flex align-items-center slashed-zeroes">{value}</span>
{!symbolIsPrefix && symbol}
</span>
)
}
/**
* Render balances nicely formatted.
*
* @param {valueString}: The balance value to render.
* Integer values are treated as SATS while decimal numbers with a decimal point (.) are treated as BTC.
* For example:
* - 0, 10, 2100000000000000 are treated as a value in SATS; while
* - 0.00000000, 150.00000001, 21000000.00000000 are treated as a value in BTC.
* @param {convertToUnit}: The unit to convert the `valueString` to.
* Possible options are `BTC` and `SATS` from `src/utils.js`
* @param {showBalance}: A flag indicating whether to render or hide the balance.
* Hidden balances are masked with `*****`.
*/
export default function Balance({ valueString, convertToUnit, showBalance = false }) {
const [displayMode, setDisplayMode] = useState(DISPLAY_MODE_HIDDEN)
useEffect(() => {
setDisplayMode(getDisplayMode(convertToUnit, showBalance))
}, [convertToUnit, showBalance])
if (displayMode === DISPLAY_MODE_HIDDEN) {
return (
<BalanceComponent
symbol={
<span className="d-inline-flex align-items-center text-muted">
<Sprite symbol="hide" width="1.2em" height="1.2em" className="ps-1" />
</span>
}
value={'*****'}
symbolIsPrefix={false}
/>
)
}
if (typeof valueString !== 'string') {
console.warn('<Balance /> component expects string input')
return <BalanceComponent symbol={''} value={valueString} symbolIsPrefix={false} />
}
// Treat integers as sats.
const valueIsSats = valueString === Number.parseInt(valueString).toString()
// Treat decimal numbers as btc.
const valueIsBtc = !valueIsSats && !Number.isNaN(Number.parseFloat(valueString)) && valueString.indexOf('.') > -1
const btcSymbol = <span style={{ paddingRight: '0.1em' }}>{'\u20BF'}</span>
const satSymbol = <Sprite symbol="sats" width="1.2em" height="1.2em" />
if (valueIsBtc && displayMode === DISPLAY_MODE_BTC)
return <BalanceComponent symbol={btcSymbol} value={formatBtc(valueString)} symbolIsPrefix={true} />
if (valueIsSats && displayMode === DISPLAY_MODE_SATS)
return <BalanceComponent symbol={satSymbol} value={formatSats(valueString)} symbolIsPrefix={false} />
if (valueIsBtc && displayMode === DISPLAY_MODE_SATS)
return <BalanceComponent symbol={satSymbol} value={formatSats(btcToSats(valueString))} symbolIsPrefix={false} />
if (valueIsSats && displayMode === DISPLAY_MODE_BTC)
return <BalanceComponent symbol={btcSymbol} value={formatBtc(satsToBtc(valueString))} symbolIsPrefix={true} />
console.warn('<Balance /> component cannot determine balance format')
return <BalanceComponent symbol={''} value={valueString} symbolIsPrefix={false} />
}

View file

@ -0,0 +1,114 @@
import React from 'react'
import { render, screen } from '../testUtils'
import { BTC, SATS } from '../utils'
import Balance from './Balance'
describe('<Balance />', () => {
it('should render BTC using satscomma formatting', () => {
render(<Balance valueString={'123.456'} convertToUnit={BTC} showBalance={true} />)
expect(screen.getByText(`123.45 600 000`)).toBeInTheDocument()
})
it('should hide balance for BTC by default', () => {
render(<Balance valueString={'123.456'} convertToUnit={BTC} />)
expect(screen.getByText(`*****`)).toBeInTheDocument()
expect(screen.queryByText(`123.45 600 000`)).not.toBeInTheDocument()
})
it('should hide balance for SATS by default', () => {
render(<Balance valueString={'123'} convertToUnit={SATS} />)
expect(screen.getByText(`*****`)).toBeInTheDocument()
expect(screen.queryByText(`123`)).not.toBeInTheDocument()
})
it('should render a string BTC value correctly as BTC', () => {
render(<Balance valueString={'123.03224961'} convertToUnit={BTC} showBalance={true} />)
expect(screen.getByText(`123.03 224 961`)).toBeInTheDocument()
})
it('should render a string BTC value correctly as SATS', () => {
render(<Balance valueString={'123.03224961'} convertToUnit={SATS} showBalance={true} />)
expect(screen.getByText(`12,303,224,961`)).toBeInTheDocument()
})
it('should render a zero string BTC value correctly as BTC', () => {
render(<Balance valueString={'0.00000000'} convertToUnit={BTC} showBalance={true} />)
expect(screen.getByText(`0.00 000 000`)).toBeInTheDocument()
})
it('should render a zero string BTC value correctly as SATS', () => {
render(<Balance valueString={'0.00000000'} convertToUnit={SATS} showBalance={true} />)
expect(screen.getByText(`0`)).toBeInTheDocument()
})
it('should render a large string BTC value correctly as BTC', () => {
render(<Balance valueString={'20999999.97690000'} convertToUnit={BTC} showBalance={true} />)
expect(screen.getByText(`20,999,999.97 690 000`)).toBeInTheDocument()
})
it('should render a large string BTC value correctly as SATS', () => {
render(<Balance valueString={'20999999.97690000'} convertToUnit={SATS} showBalance={true} />)
expect(screen.getByText(`2,099,999,997,690,000`)).toBeInTheDocument()
})
it('should render a max string BTC value correctly as BTC', () => {
render(<Balance valueString={'21000000.00000000'} convertToUnit={BTC} showBalance={true} />)
expect(screen.getByText(`21,000,000.00 000 000`)).toBeInTheDocument()
})
it('should render a max string BTC value correctly as SATS', () => {
render(<Balance valueString={'21000000.00000000'} convertToUnit={SATS} showBalance={true} />)
expect(screen.getByText(`2,100,000,000,000,000`)).toBeInTheDocument()
})
it('should render a number BTC value as fallback', () => {
render(<Balance valueString={123.456} convertToUnit={BTC} showBalance={true} />)
expect(screen.getByText(`123.456`)).toBeInTheDocument()
})
it('should render a string SATS value correctly as SATS', () => {
render(<Balance valueString={'43000'} convertToUnit={SATS} showBalance={true} />)
expect(screen.getByText(`43,000`)).toBeInTheDocument()
})
it('should render a string SATS value correctly as BTC', () => {
render(<Balance valueString={'43000'} convertToUnit={BTC} showBalance={true} />)
expect(screen.getByText(`0.00 043 000`)).toBeInTheDocument()
})
it('should render a zero string SATS value correctly as BTC', () => {
render(<Balance valueString={'0'} convertToUnit={BTC} showBalance={true} />)
expect(screen.getByText(`0.00 000 000`)).toBeInTheDocument()
})
it('should render a zero string SATS value correctly as SATS', () => {
render(<Balance valueString={'0'} convertToUnit={SATS} showBalance={true} />)
expect(screen.getByText(`0`)).toBeInTheDocument()
})
it('should render a large string SATS value correctly as BTC', () => {
render(<Balance valueString={'2099999997690000'} convertToUnit={BTC} showBalance={true} />)
expect(screen.getByText(`20,999,999.97 690 000`)).toBeInTheDocument()
})
it('should render a large string SATS value correctly as SATS', () => {
render(<Balance valueString={'2099999997690000'} convertToUnit={SATS} showBalance={true} />)
expect(screen.getByText(`2,099,999,997,690,000`)).toBeInTheDocument()
})
it('should render a max string SATS value correctly as BTC', () => {
render(<Balance valueString={'2100000000000000'} convertToUnit={BTC} showBalance={true} />)
expect(screen.getByText(`21,000,000.00 000 000`)).toBeInTheDocument()
})
it('should render a max string SATS value correctly as SATS', () => {
render(<Balance valueString={'2100000000000000'} convertToUnit={SATS} showBalance={true} />)
expect(screen.getByText(`2,100,000,000,000,000`)).toBeInTheDocument()
})
it('should render a number SATS value as fallback', () => {
render(<Balance valueString={43000} convertToUnit={SATS} showBalance={true} />)
expect(screen.getByText(`43000`)).toBeInTheDocument()
})
})

View file

@ -13,7 +13,7 @@ const WalletHeader = ({ name, balance, unit, showBalance }) => {
<div className="d-flex flex-column align-items-center">
<h6 className="text-secondary">{walletDisplayName(name)}</h6>
<h4>
<Balance value={balance} unit={unit} showBalance={showBalance || false} />
<Balance valueString={balance} convertToUnit={unit} showBalance={showBalance || false} />
</h4>
</div>
)
@ -54,7 +54,7 @@ const PrivacyLevel = ({ numAccounts, level, balance }) => {
{outlinedShields}
</div>
<div className="ps-2">
<Balance value={balance} unit={settings.unit} showBalance={settings.showBalance} />
<Balance valueString={balance} convertToUnit={settings.unit} showBalance={settings.showBalance} />
</div>
</div>
)

View file

@ -18,7 +18,7 @@ const BranchEntry = ({ entry, ...props }) => {
<code className="text-break">{hdPath}</code>
</rb.Col>
<rb.Col lg={{ order: 'last' }} className="d-flex align-items-center justify-content-end">
<Balance value={amount} unit={settings.unit} showBalance={settings.showBalance} />
<Balance valueString={amount} convertToUnit={settings.unit} showBalance={settings.showBalance} />
</rb.Col>
<rb.Col xs={'auto'}>
<code className="text-break">{address}</code> {labels && <span className="badge bg-info">{labels}</span>}
@ -45,7 +45,7 @@ export default function DisplayAccounts({ accounts, ...props }) {
</h5>
</rb.Col>
<rb.Col className="d-flex align-items-center justify-content-end">
<Balance value={balance} unit={settings.unit} showBalance={settings.showBalance} />
<Balance valueString={balance} convertToUnit={settings.unit} showBalance={settings.showBalance} />
</rb.Col>
</rb.Row>
</rb.Accordion.Header>
@ -65,7 +65,7 @@ export default function DisplayAccounts({ accounts, ...props }) {
<h6>{titleize(type)}</h6>
</rb.Col>
<rb.Col className="d-flex align-items-center justify-content-end">
<Balance value={balance} unit={settings.unit} showBalance={settings.showBalance} />
<Balance valueString={balance} convertToUnit={settings.unit} showBalance={settings.showBalance} />
</rb.Col>
</rb.Row>
<rb.Row className="p-3">

View file

@ -61,7 +61,7 @@ const Utxo = ({ utxo, ...props }) => {
<rb.Col sm={6} md={4}>
<rb.Stack className="d-flex align-items-end">
<div>
<Balance value={utxo.value} unit={settings.unit} showBalance={settings.showBalance} />
<Balance valueString={utxo.value} convertToUnit={settings.unit} showBalance={settings.showBalance} />
</div>
<div>
<small className="text-secondary">{utxo.confirmations} Confirmations</small>

View file

@ -15,7 +15,7 @@ const WalletPreview = ({ wallet, walletInfo, unit, showBalance }) => {
{wallet && <div className="fw-normal">{walletDisplayName(wallet.name)}</div>}
{walletInfo && walletInfo?.total_balance && unit ? (
<div className="text-body">
<Balance value={walletInfo.total_balance} unit={unit} showBalance={showBalance || false} />
<Balance valueString={walletInfo.total_balance} convertToUnit={unit} showBalance={showBalance || false} />
</div>
) : (
<div className="invisible">

View file

@ -578,22 +578,6 @@ h2 {
}
}
/* Balance Styles */
.balance-wrapper {
display: inline-flex;
align-items: center;
}
.balance {
display: flex;
align-items: center;
}
.bitcoin-symbol {
padding-right: 0.1em;
}
/* Onboarding */
.onboarding button {