mirror of
https://github.com/joinmarket-webui/jam.git
synced 2026-08-20 13:28:21 +02:00
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:
parent
022f884d29
commit
fe94945f2a
7 changed files with 216 additions and 77 deletions
|
|
@ -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} />
|
||||
}
|
||||
|
|
|
|||
114
src/components/Balance.test.jsx
Normal file
114
src/components/Balance.test.jsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue