From 4346806a235dfedd3d4ae78b9809dc0d5fba5941 Mon Sep 17 00:00:00 2001 From: EC Wireless <40322776+ECWireless@users.noreply.github.com> Date: Wed, 10 Jun 2020 09:08:55 -0600 Subject: [PATCH] Agent: use `formatTokenAmount()` and add rounding tooltip (#1148) --- apps/agent/app/package.json | 2 +- apps/agent/app/src/app-state-reducer.js | 29 +-- apps/agent/app/src/components/BalanceToken.js | 83 +++++-- apps/agent/app/src/components/Balances.js | 210 ++++++++++++------ apps/agent/app/src/components/Transactions.js | 20 +- .../app/src/components/useDownloadData.js | 9 +- apps/agent/app/src/lib/locales.js | 1 - apps/agent/app/src/lib/math-utils.js | 16 -- apps/agent/app/src/lib/utils.js | 34 --- 9 files changed, 225 insertions(+), 179 deletions(-) delete mode 100644 apps/agent/app/src/lib/locales.js diff --git a/apps/agent/app/package.json b/apps/agent/app/package.json index 73ec356236..cd8fc94338 100644 --- a/apps/agent/app/package.json +++ b/apps/agent/app/package.json @@ -7,7 +7,7 @@ "@aragon/api": "^2.0.0-beta.9", "@aragon/api-react": "^2.0.0-beta.9", "@aragon/templates-tokens": "^1.2.0", - "@aragon/ui": "^1.3.0", + "@aragon/ui": "^1.4.0", "@babel/polyfill": "^7.8.3", "date-fns": "2.0.0-alpha.22", "file-saver": "^2.0.2", diff --git a/apps/agent/app/src/app-state-reducer.js b/apps/agent/app/src/app-state-reducer.js index 3939855858..c818fe76af 100644 --- a/apps/agent/app/src/app-state-reducer.js +++ b/apps/agent/app/src/app-state-reducer.js @@ -22,36 +22,29 @@ function appStateReducer(state) { ...balance, amount: new BN(balance.amount), decimals: new BN(balance.decimals), - - // Note that numbers in `numData` are not safe for accurate - // computations (but are useful for making divisions easier). - numData: { - amount: parseInt(balance.amount, 10), - decimals: parseInt(balance.decimals, 10), - }, })) .sort(compareBalancesByEthAndSymbol) const transactionsBn = transactions.map(transaction => ({ ...transaction, - onlyOne: transaction.tokenTransfers.length === 1, isIncoming: transaction.tokenTransfers.some(({ from }) => !!from), isOutgoing: transaction.tokenTransfers.some(({ to }) => !!to), + tokenTransfers: transaction.tokenTransfers.map(transfer => ({ + ...transfer, + amount: new BN(transfer.amount), + })), })) return { ...state, - tokens: balancesBn.map( - ({ address, name, symbol, numData: { amount, decimals }, verified }) => ({ - address, - amount, - decimals, - name, - symbol, - verified, - }) - ), + tokens: balancesBn.map(({ address, decimals, name, symbol, verified }) => ({ + address, + decimals: decimals.toNumber(), + name, + symbol, + verified, + })), // Filter out empty balances balances: balancesBn.filter(balance => !balance.amount.isZero()), diff --git a/apps/agent/app/src/components/BalanceToken.js b/apps/agent/app/src/components/BalanceToken.js index 2a53c04ccf..bf6430f836 100644 --- a/apps/agent/app/src/components/BalanceToken.js +++ b/apps/agent/app/src/components/BalanceToken.js @@ -1,31 +1,37 @@ import React from 'react' -import { textStyle, useTheme } from '@aragon/ui' +import PropTypes from 'prop-types' +import BN from 'bn.js' +import { GU, Help, formatTokenAmount, textStyle, useTheme } from '@aragon/ui' import { useNetwork } from '@aragon/api-react' import { tokenIconUrl } from '../lib/icon-utils' -import { formatTokenAmount } from '../lib/utils' - function BalanceToken({ - address = '', + address, amount, + compact, + convertedAmount, + decimals, symbol, verified, - convertedAmount = -1, }) { const theme = useTheme() const network = useNetwork() + const amountFormatted = formatTokenAmount(amount, decimals, { + digits: decimals, + }) + const amountFormattedRounded = formatTokenAmount(amount, decimals, { + digits: 3, + }) + const amountWasRounded = amountFormatted !== amountFormattedRounded return ( - +
{verified && address && ( @@ -34,6 +40,9 @@ function BalanceToken({ width="20" height="20" src={tokenIconUrl(address, symbol, network && network.type)} + css={` + margin-right: ${0.75 * GU}px; + `} /> )} {symbol || '?'} @@ -42,9 +51,25 @@ function BalanceToken({
- + {amountWasRounded && '~'} + + {amountWasRounded && ( +
+ + Total: {amountFormatted} {symbol} + +
+ )}
- {convertedAmount >= 0 - ? `$${formatTokenAmount(convertedAmount.toFixed(2))}` - : '−'} + {convertedAmount.isNeg() + ? '−' + : `$${formatTokenAmount(convertedAmount, decimals)}`}
- +
) } -function SplitAmount({ amount }) { - const [integer, fractional] = formatTokenAmount(amount).split('.') +BalanceToken.defaultProps = { + convertedAmount: new BN(-1), +} + +BalanceToken.propTypes = { + address: PropTypes.string.isRequired, + amount: PropTypes.instanceOf(BN).isRequired, + compact: PropTypes.bool.isRequired, + convertedAmount: PropTypes.instanceOf(BN), + decimals: PropTypes.instanceOf(BN).isRequired, + symbol: PropTypes.string.isRequired, + verified: PropTypes.bool.isRequired, +} +function SplitAmount({ amountFormatted }) { + const [integer, fractional] = amountFormatted.split('.') return ( {integer} - {fractional && .{fractional}} + {fractional && ( + + .{fractional} + + )} ) } - export default BalanceToken diff --git a/apps/agent/app/src/components/Balances.js b/apps/agent/app/src/components/Balances.js index 05f5bfe0f5..1e5a103f3c 100644 --- a/apps/agent/app/src/components/Balances.js +++ b/apps/agent/app/src/components/Balances.js @@ -1,108 +1,172 @@ -import React, { useState, useEffect } from 'react' -import { Box, GU, useLayout } from '@aragon/ui' +import React, { useEffect, useMemo, useState } from 'react' +import BN from 'bn.js' +import { Box, GU, textStyle, useTheme, useLayout } from '@aragon/ui' import BalanceToken from './BalanceToken' -import { round } from '../lib/math-utils' -const CONVERT_API_BASE = 'https://min-api.cryptocompare.com/data' +const CONVERT_API_RETRY_DELAY = 2000 -const convertApiUrl = symbols => - `${CONVERT_API_BASE}/price?fsym=USD&tsyms=${symbols.join(',')}` +function convertRatesUrl(symbolsQuery) { + return `https://min-api.cryptocompare.com/data/price?fsym=USD&tsyms=${symbolsQuery}` +} -const Balances = React.memo(function Balances({ balances }) { - const [convertRates, setConvertRates] = useState({}) - const [balanceItems, setBalanceItems] = useState([]) - const { layoutName } = useLayout - const compactMode = layoutName === 'small' +function useConvertRates(symbols) { + const [rates, setRates] = useState({}) + + const symbolsQuery = symbols.join(',') useEffect(() => { let cancelled = false + let retryTimer = null - // Fetches the conversion rate for the verified tokens - const updateConvertedRates = async balances => { - const verifiedSymbols = balances - .filter(({ verified }) => verified) - .map(({ symbol }) => symbol) - - if (!verifiedSymbols.length) { + const update = async () => { + if (!symbolsQuery) { + setRates({}) return } - const res = await fetch(convertApiUrl(verifiedSymbols)) - const convertRates = await res.json() - if (!cancelled) { - setConvertRates(convertRates) + try { + const response = await fetch(convertRatesUrl(symbolsQuery)) + const rates = await response.json() + if (!cancelled) { + setRates(rates) + } + } catch (err) { + // The !cancelled check is needed in case: + // 1. The fetch() request is ongoing. + // 2. The component gets unmounted. + // 3. An error gets thrown. + // + // Assuming the fetch() request keeps throwing, it would create new + // requests even though the useEffect() got cancelled. + if (!cancelled) { + retryTimer = setTimeout(update, CONVERT_API_RETRY_DELAY) + } } } + update() - updateConvertedRates(balances) return () => { cancelled = true + clearTimeout(retryTimer) } - }, [balances]) + }, [symbolsQuery]) - useEffect(() => { - const balanceItems = balances.map( - ({ address, numData: { amount, decimals }, symbol, verified }) => { - const adjustedAmount = amount / Math.pow(10, decimals) - const convertedAmount = - verified && convertRates[symbol] - ? adjustedAmount / convertRates[symbol] - : -1 - - return { - address, - symbol, - verified, - amount: round(adjustedAmount, 5), - convertedAmount: round(convertedAmount, 5), - } - } - ) - setBalanceItems(balanceItems) + return rates +} + +// Prepare the balances for the BalanceToken component +function useBalanceItems(balances) { + const verifiedSymbols = balances + .filter(({ verified }) => verified) + .map(({ symbol }) => symbol) + + const convertRates = useConvertRates(verifiedSymbols) + + const balanceItems = useMemo(() => { + return balances.map(({ address, amount, decimals, symbol, verified }) => ({ + address, + amount, + convertedAmount: convertRates[symbol] + ? amount.divn(convertRates[symbol]) + : new BN(-1), + decimals, + symbol, + verified, + })) }, [balances, convertRates]) + return balanceItems +} + +function Balances({ balances }) { + const theme = useTheme() + const { layoutName } = useLayout() + const balanceItems = useBalanceItems(balances) + + const compact = layoutName === 'small' + return ( - +
-
    - {balanceItems.map( - ({ address, amount, convertedAmount, symbol, verified }) => ( -
  • -
    - -
    -
  • - ) + {balanceItems.length === 0 ? ( +
    + No token balances yet. +
    + ) : ( +
      + {balanceItems.map( + ({ + address, + amount, + convertedAmount, + decimals, + symbol, + verified, + }) => ( +
    • + +
    • + ) + )} +
    )} -
+
) -}) +} export default Balances diff --git a/apps/agent/app/src/components/Transactions.js b/apps/agent/app/src/components/Transactions.js index 8425e1385d..080024defe 100644 --- a/apps/agent/app/src/components/Transactions.js +++ b/apps/agent/app/src/components/Transactions.js @@ -14,6 +14,7 @@ import { ContextMenu, ContextMenuItem, DataView, + formatTokenAmount, GU, IconExternal, IconLabel, @@ -28,7 +29,6 @@ import TransactionFilters from './TransactionFilters' import { TRANSACTION_TYPES_LABELS } from '../transaction-types' import useDownloadData from './useDownloadData' import useFilteredTransactions from './useFilteredTransactions' -import { formatTokenAmount, ROUNDING_AMOUNT } from '../lib/utils' import { ISO_FORMAT, MMDDYY_FUNC_FORMAT } from '../lib/date-utils' import { addressesEqual, toChecksumAddress } from '../lib/web3-utils' import AgentSvg from './assets/agent_badge.svg' @@ -169,11 +169,11 @@ const Transactions = React.memo(function Transactions({ description: reference, type, tokenTransfers, - onlyOne, isIncoming, targetContract, }) => { const [{ token, amount, to, from } = {}] = tokenTransfers + const onlyOne = tokenTransfers.length === 1 const entity = to || from const formattedDate = format(date, ISO_FORMAT) const isValidEntity = @@ -257,11 +257,9 @@ const Transactions = React.memo(function Transactions({ const { symbol, decimals } = tokenDetails[toChecksumAddress(token)] const formattedAmount = formatTokenAmount( - amount, - isIncoming, + isIncoming ? amount : amount.neg(), decimals, - true, - { rounding: ROUNDING_AMOUNT } + { displaySign: true, digits: 5, symbol } ) const amountColor = isIncoming ? theme.positive : theme.negative return ( @@ -271,7 +269,7 @@ const Transactions = React.memo(function Transactions({ color: ${amountColor}; `} > - {formattedAmount} {symbol} + {formattedAmount} ) })() @@ -302,11 +300,9 @@ const Transactions = React.memo(function Transactions({ const isIncoming = Boolean(from) const { symbol, decimals } = tokenDetails[toChecksumAddress(token)] const formattedAmount = formatTokenAmount( - amount, - isIncoming, + isIncoming ? amount : amount.neg(), decimals, - true, - { rounding: ROUNDING_AMOUNT } + { displaySign: true, digits: 5, symbol } ) const amountColor = isIncoming ? theme.positive : theme.negative @@ -397,7 +393,7 @@ const Transactions = React.memo(function Transactions({ color: ${amountColor}; `} > - {formattedAmount} {symbol} + {formattedAmount} diff --git a/apps/agent/app/src/components/useDownloadData.js b/apps/agent/app/src/components/useDownloadData.js index 2ba8a3baaa..c94a5f83d5 100644 --- a/apps/agent/app/src/components/useDownloadData.js +++ b/apps/agent/app/src/components/useDownloadData.js @@ -1,9 +1,9 @@ import { useContext, useCallback } from 'react' import { format } from 'date-fns' import { saveAs } from 'file-saver' +import { formatTokenAmount } from '@aragon/ui' import { IdentityContext } from './IdentityManager/IdentityManager' import { toChecksumAddress } from '../lib/web3-utils' -import { formatTokenAmount, ROUNDING_AMOUNT } from '../lib/utils' import { formatDate, ISO_SHORT_FORMAT } from '../lib/date-utils' import { TRANSACTION_TYPES_LABELS } from '../transaction-types' @@ -26,12 +26,11 @@ async function getDownloadData({ transactions, tokenDetails, resolveAddress }) { tokenTransfers.map(async ({ amount, from, to, token }) => { const { symbol, decimals } = tokenDetails[toChecksumAddress(token)] const formattedAmount = formatTokenAmount( - amount, - Boolean(from), + from ? amount : amount.neg(), decimals, - true, - { rounding: ROUNDING_AMOUNT } + { displaySign: true, digits: 5 } ) + const [source, recipient] = await Promise.all( [from, to].map(address => { return address diff --git a/apps/agent/app/src/lib/locales.js b/apps/agent/app/src/lib/locales.js deleted file mode 100644 index 773cce2e8a..0000000000 --- a/apps/agent/app/src/lib/locales.js +++ /dev/null @@ -1 +0,0 @@ -export const LOCALE_US_FORMAT = 'en-US' diff --git a/apps/agent/app/src/lib/math-utils.js b/apps/agent/app/src/lib/math-utils.js index 79a3facc0d..ba60b3ee87 100644 --- a/apps/agent/app/src/lib/math-utils.js +++ b/apps/agent/app/src/lib/math-utils.js @@ -1,19 +1,3 @@ -/** - * Generic round function, see: - * - https://stackoverflow.com/a/18358056/1375656 - * - https://stackoverflow.com/a/19722641/1375656 - * - * Fixed for NaNs on really small values - * - * @param {number} num Number to round - * @param {number} [places=2] Number of places to round to - * @returns {number} Rounded number - */ -export function round(num, places = 2) { - const rounded = Number(Math.round(num + 'e+' + places) + 'e-' + places) - return Number.isNaN(rounded) ? Number(num.toFixed(places)) : rounded -} - /** * Get the whole and decimal parts from a number. * Trims leading and trailing zeroes. diff --git a/apps/agent/app/src/lib/utils.js b/apps/agent/app/src/lib/utils.js index 6766e23175..177804c7ab 100644 --- a/apps/agent/app/src/lib/utils.js +++ b/apps/agent/app/src/lib/utils.js @@ -1,35 +1 @@ -import { round } from './math-utils' -import { LOCALE_US_FORMAT } from './locales' - -export const ROUNDING_AMOUNT = 5 - export function noop() {} - -export function formatDecimals(value, digits) { - try { - return value.toLocaleString(LOCALE_US_FORMAT, { - style: 'decimal', - maximumFractionDigits: digits, - }) - } catch (err) { - if (err.name === 'RangeError') { - // Fallback to Number.prototype.toString() - // if the language tag is not supported. - return value.toString() - } - throw err - } -} - -export function formatTokenAmount( - amount, - isIncoming, - decimals = 0, - displaySign = false, - { rounding = 2 } = {} -) { - return ( - (displaySign ? (isIncoming ? '+' : '-') : '') + - formatDecimals(round(amount / Math.pow(10, decimals), rounding), 18) - ) -}