Repository: joinmarket-webui/joinmarket-webui
Pull request #171 (2022-03-10 16:10:33)

feat: satscomma formatting for bitcoin balances
Uses satscomma-style [^1][^2][^3] formatting for the fractional part of a bitcoin balance. As mentioned in the last community call, this will make it way easier to parse the amount of sats in a balance displayed in bitcoin. Decided to go with a half-space as the digit group separator. Looks the cleanest imo. Also did some minor refactoring of the `Balance` component itself.

## 📸 

<img width="260" alt="Screenshot 2022-03-10 at 17 11 05" src="https://user-images.githubusercontent.com/10026790/157705763-98aef217-34d0-48c3-989b-35ef06944e05.png">

[^1]: https://bitcoin.design/guide/payments/units-and-symbols/#satcomma
[^2]: https://medium.com/@mark.nugent.iv/grouping-bitcoins-fractional-digits-an-idea-whose-time-has-come-22d9dad8ac51
[^3]: https://medium.com/coinmonks/the-satcomma-standard-89f1e7c2aede


Patch:
diff --git a/src/components/Balance.jsx b/src/components/Balance.jsx
index efdb838..4e0c0d9 100644
--- a/src/components/Balance.jsx
+++ b/src/components/Balance.jsx
@@ -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
 
-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>
-    )
-  }
+  return DISPLAY_MODE_HIDDEN
+}
 
-  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 BalanceComponent = ({ symbol, value, symbolIsPrefix }) => {
+  return (
+    <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>
+  )
+}
 
-  const balanceJSX = (symbolJSX, formattedValue, isPrefix = true) => {
+/**
+ * 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 (
-      <span className="balance-wrapper">
-        {isPrefix ? symbolJSX : ''}
-        <span className="balance slashed-zeroes">{formattedValue}</span>
-        {!isPrefix ? symbolJSX : ''}
-      </span>
+      <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 (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 (typeof valueString !== 'string') {
+    console.warn('<Balance /> component expects string input')
+    return <BalanceComponent symbol={''} value={valueString} symbolIsPrefix={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)))
+  // 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
 
-  // Something unexpected happened. Simply render what was passed in the props.
-  return (
-    <span className="balance">
-      {value} {unit}
-    </span>
-  )
+  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} />
 }
diff --git a/src/components/CurrentWalletMagic.jsx b/src/components/CurrentWalletMagic.jsx
index c691536..c79f775 100644
--- a/src/components/CurrentWalletMagic.jsx
+++ b/src/components/CurrentWalletMagic.jsx
@@ -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>
   )
diff --git a/src/components/DisplayAccounts.jsx b/src/components/DisplayAccounts.jsx
index db96ae0..5154e6c 100644
--- a/src/components/DisplayAccounts.jsx
+++ b/src/components/DisplayAccounts.jsx
@@ -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">
diff --git a/src/components/DisplayUTXOs.jsx b/src/components/DisplayUTXOs.jsx
index 52fa47f..9ec6bd7 100644
--- a/src/components/DisplayUTXOs.jsx
+++ b/src/components/DisplayUTXOs.jsx
@@ -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>
diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx
index 4d8219c..57c3ef5 100644
--- a/src/components/Navbar.jsx
+++ b/src/components/Navbar.jsx
@@ -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">
diff --git a/src/index.css b/src/index.css
index 6745b76..8bdaf53 100644
--- a/src/index.css
+++ b/src/index.css
@@ -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 {


Test patch:
diff --git a/src/components/Balance.test.jsx b/src/components/Balance.test.jsx
new file mode 100644
index 0000000..287ede3
--- /dev/null
+++ b/src/components/Balance.test.jsx
@@ -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()
+  })
+})