diff --git a/__tests__/reducers/amountFormat.test.js b/__tests__/reducers/amountFormat.test.js new file mode 100644 index 000000000..3354eb40d --- /dev/null +++ b/__tests__/reducers/amountFormat.test.js @@ -0,0 +1,18 @@ +import { describe, it, expect } from '@jest/globals'; +import { reducer } from '../../src/reducers/reducer'; +import { setAmountFormat } from '../../src/actions'; +import { AMOUNT_FORMAT, AMOUNT_FORMAT_DEFAULT } from '../../src/constants'; + +// initialState is not exported; derive it by initializing the reducer. +const getInitialState = () => reducer(undefined, { type: '@@INIT' }); + +describe('amountFormat reducer', () => { + it('defaults to the expanded format', () => { + expect(getInitialState().amountFormat).toBe(AMOUNT_FORMAT_DEFAULT); + }); + + it('sets the amount format', () => { + const next = reducer(getInitialState(), setAmountFormat(AMOUNT_FORMAT.COMPRESSED)); + expect(next.amountFormat).toBe(AMOUNT_FORMAT.COMPRESSED); + }); +}); diff --git a/__tests__/utils/amountFormat.test.js b/__tests__/utils/amountFormat.test.js new file mode 100644 index 000000000..2f433943d --- /dev/null +++ b/__tests__/utils/amountFormat.test.js @@ -0,0 +1,149 @@ +import { describe, it, expect } from '@jest/globals'; +import { + compressAmountString, renderValue, getDisplayAmountFormat, normalizeAmountFormat, + capDecimalsByMagnitude, renderHomeValue, +} from '../../src/utils'; +import { AMOUNT_FORMAT, AMOUNT_FORMAT_DEFAULT, AMOUNT_FORMAT_FEATURE_TOGGLE } from '../../src/constants'; + +const stateWith = (flagOn, amountFormat) => ({ + featureToggles: { [AMOUNT_FORMAT_FEATURE_TOGGLE]: flagOn }, + amountFormat, +}); + +describe('compressAmountString', () => { + // Compresses only the leading fractional zero run into subscript notation and + // keeps every later digit verbatim (including inner and trailing zeros), so the + // compressed form shows the same significant digits as the Expanded form. + it.each([ + ['0.0000005195', '0.0₆5195'], + ['0.000000012345', '0.0₇12345'], + ['0.00012', '0.0₃12'], // exactly 3 leading zeros (threshold) + ['-0.0000005195', '-0.0₆5195'], // keeps the negative sign + ['-0.00012', '-0.0₃12'], + ['0.0000005000', '0.0₆5000'], // trailing zeros are kept, not trimmed + ['0.00000000000050', '0.0₁₂50'], // deep run + trailing zero kept verbatim + ['0.000000000012', '0.0₁₀12'], // multi-digit subscript (10 zeros) + ['0.000000045600000123', '0.0₇45600000123'], // inner zeros kept verbatim + ])('compresses %s -> %s', (input, expected) => { + expect(compressAmountString(input)).toBe(expected); + }); + + it.each([ + ['0.05'], // 1 leading zero, below threshold + ['0.0012'], // 2 leading zeros, below threshold + ['0.005195'], // 2 leading zeros, below threshold + ['1.0000005195'], // non-zero integer part + ['10.0000005'], // non-zero integer part + ['1,234.00005'], // non-zero (grouped) integer part + ['0.000000'], // effectively zero, nothing significant after the run + ['1,234'], // no fractional part + ['1234'], // no fractional part + ])('leaves %s unchanged', (input) => { + expect(compressAmountString(input)).toBe(input); + }); +}); + +describe('capDecimalsByMagnitude', () => { + // < 1 -> 8, 1-999 -> 4, 1000-9999 -> 3, >= 10000 -> 2; truncated, not padded. + it.each([ + ['0.12345678', '0.12345678'], // < 1, keeps up to 8 + ['0.123456789', '0.12345678'], // < 1, truncated to 8 + ['0.5', '0.5'], // < 1, no padding + ['1.12345678', '1.1234'], // 1-999 -> 4 + ['999.98765', '999.9876'], // 999 -> 4 + ['1000.12345', '1000.123'], // 1000-9999 -> 3 + ['9999.9999', '9999.999'], // 9999 -> 3 + ['10000.98765', '10000.98'], // >= 10000 -> 2 + ['100000.12345678', '100000.12'], + ['9223372036854775808.12345678', '9223372036854775808.12'], + ['-0.123456789', '-0.12345678'], // sign preserved + ['1,234.56789', '1,234.567'], // grouping preserved, magnitude 1234 -> 3 + ['1234', '1234'], // no fractional part unchanged + ])('%s -> %s', (input, expected) => { + expect(capDecimalsByMagnitude(input)).toBe(expected); + }); +}); + +describe('renderValue with amount format', () => { + it('renders with the network decimal places it is given', () => { + // 12 base units at 8 decimals => 0.00000012 + expect(renderValue(12n, false, 8)).toBe('0.00000012'); + }); + + it('falls back to the lib default decimals when unset', () => { + // 1234 base units at default 2 decimals => 12.34 + expect(renderValue(1234n, false)).toBe('12.34'); + }); + + it('defaults to expanded (unchanged behavior)', () => { + expect(renderValue(1234n, false, 8)).toBe(renderValue(1234n, false, 8, AMOUNT_FORMAT.EXPANDED)); + }); + + it('routes COMPRESSED output through compressAmountString', () => { + const amount = 500000n; + expect(renderValue(amount, false, 8, AMOUNT_FORMAT.COMPRESSED)) + .toBe(compressAmountString(renderValue(amount, false, 8, AMOUNT_FORMAT.EXPANDED))); + }); + + it('compresses sub-1 amounts once the network precision allows it', () => { + // 5195 base units at 8 decimals => 0.00005195 => 0.0₄5195. At the lib + // default of 2 decimals there is no zero run long enough to compress. + expect(renderValue(5195n, false, 8, AMOUNT_FORMAT.COMPRESSED)).toBe('0.0₄5195'); + }); + + it('is a no-op for NFT amounts (no fractional part)', () => { + expect(renderValue(12345n, true, 8, AMOUNT_FORMAT.COMPRESSED)).toBe('12,345'); + }); +}); + +describe('renderHomeValue (magnitude rule + network decimals)', () => { + it('renders small amounts with up to 8 decimals from the network decimal places', () => { + // 12 base units at 8 decimals => 0.00000012 (< 1 => 8 dp) + expect(renderHomeValue(12n, false, 8, AMOUNT_FORMAT.EXPANDED)).toBe('0.00000012'); + }); + + it('caps large amounts to 2 decimals by magnitude', () => { + // 10000012345678 at 8 decimals => 100,000.12345678 (>= 10000 => 2 dp) + expect(renderHomeValue(10000012345678n, false, 8, AMOUNT_FORMAT.EXPANDED)).toBe('100,000.12'); + }); + + it('applies Compressed notation after the magnitude cap', () => { + expect(renderHomeValue(12n, false, 8, AMOUNT_FORMAT.COMPRESSED)).toBe('0.0₆12'); + }); + + it('falls back to the lib default decimals when unset', () => { + // 1234 base units at default 2 decimals => 12.34 + expect(renderHomeValue(1234n, false)).toBe('12.34'); + }); +}); + +describe('getDisplayAmountFormat (flag gating)', () => { + it('returns the stored preference while the flag is on', () => { + expect(getDisplayAmountFormat(stateWith(true, AMOUNT_FORMAT.COMPRESSED))) + .toBe(AMOUNT_FORMAT.COMPRESSED); + expect(getDisplayAmountFormat(stateWith(true, AMOUNT_FORMAT.EXPANDED))) + .toBe(AMOUNT_FORMAT.EXPANDED); + }); + + it('forces Expanded when the flag is off, ignoring the stored preference', () => { + expect(getDisplayAmountFormat(stateWith(false, AMOUNT_FORMAT.COMPRESSED))) + .toBe(AMOUNT_FORMAT.EXPANDED); + }); + + it('falls back to the default when nothing is stored', () => { + expect(getDisplayAmountFormat(stateWith(true, undefined))).toBe(AMOUNT_FORMAT.EXPANDED); + }); +}); + +describe('normalizeAmountFormat', () => { + it('passes through known enum values', () => { + expect(normalizeAmountFormat(AMOUNT_FORMAT.EXPANDED)).toBe(AMOUNT_FORMAT.EXPANDED); + expect(normalizeAmountFormat(AMOUNT_FORMAT.COMPRESSED)).toBe(AMOUNT_FORMAT.COMPRESSED); + }); + + it('falls back to the default for unknown, null or undefined values', () => { + expect(normalizeAmountFormat('bogus')).toBe(AMOUNT_FORMAT_DEFAULT); + expect(normalizeAmountFormat(null)).toBe(AMOUNT_FORMAT_DEFAULT); + expect(normalizeAmountFormat(undefined)).toBe(AMOUNT_FORMAT_DEFAULT); + }); +}); diff --git a/locale/da/texts.po b/locale/da/texts.po index adef483ec..8cfdc6331 100644 --- a/locale/da/texts.po +++ b/locale/da/texts.po @@ -57,7 +57,7 @@ msgid "[Last] dddd [•] HH:mm" msgstr "[Sidste] dddd [•] HH:mm" #. See https://momentjs.com/docs/#/displaying/calendar-time/ -#: src/models.js:107 src/utils.js:665 +#: src/models.js:107 src/utils.js:752 msgid "DD MMM YYYY [•] HH:mm" msgstr "DD MMM YYYY [•] HH:mm" @@ -65,17 +65,17 @@ msgstr "DD MMM YYYY [•] HH:mm" msgid "Invalid address" msgstr "Ugyldig adresse" -#: src/utils.js:650 +#: src/utils.js:737 msgid "CREATE DEPOSIT TOKEN" msgstr "" -#: src/utils.js:652 +#: src/utils.js:739 msgid "CREATE FEE TOKEN" msgstr "" #: src/screens/CreateTokenAmount.js:36 src/screens/CreateTokenConfirm.js:87 #: src/screens/CreateTokenDepositNotice.js:42 -#: src/screens/CreateTokenTypeNotice.js:94 src/utils.js:654 +#: src/screens/CreateTokenTypeNotice.js:94 src/utils.js:741 msgid "CREATE TOKEN" msgstr "OPRET TOKEN" @@ -215,7 +215,7 @@ msgid "" "wallet are already in use." msgstr "" -#: src/screens/AddressMode.js:261 +#: src/screens/AddressMode.js:261 src/screens/AmountFormat.js:145 msgid "SAVE PREFERENCES" msgstr "" @@ -225,6 +225,47 @@ msgid "" "address mode..." msgstr "" +#: src/screens/AmountFormat.js:83 +msgid "Default" +msgstr "" + +#: src/screens/AmountFormat.js:96 +msgid "AMOUNT FORMAT" +msgstr "" + +#: src/screens/AmountFormat.js:102 +msgid "" +"Choose how amounts are displayed across your wallet. You can change your " +"default anytime." +msgstr "" + +#: src/screens/AmountFormat.js:108 +msgid "Expanded" +msgstr "" + +#: src/screens/AmountFormat.js:109 +msgid "" +"Standard notation. Leading zeros are written out in full (ex: 0.0000005195)." +msgstr "" + +#: src/screens/AmountFormat.js:115 +msgid "Compressed" +msgstr "" + +#: src/screens/AmountFormat.js:116 +msgid "" +"Compresses leading zeros into a subscript count for shorter, easier-to-read " +"small values (ex: 0.0₆5195)." +msgstr "" + +#: src/screens/AmountFormat.js:121 +msgid "PREVIEW" +msgstr "" + +#: src/screens/AmountFormat.js:123 +msgid "Full amount" +msgstr "" + #: src/screens/BackupWords.js:184 msgid "To make sure you saved," msgstr "For at sikre dig, at du har gemt," @@ -704,7 +745,7 @@ msgid "There's been an error connecting to the server." msgstr "" #: src/screens/LoadWalletErrorScreen.js:28 src/screens/PinScreen.js:318 -#: src/screens/Settings.js:177 +#: src/screens/Settings.js:181 msgid "Reset wallet" msgstr "Nulstil wallet" @@ -712,31 +753,31 @@ msgstr "Nulstil wallet" msgid "Connect to mainnet" msgstr "" -#: src/screens/MainScreen.js:136 +#: src/screens/MainScreen.js:138 msgid "No transactions" msgstr "Ingen transaktioner" -#: src/screens/MainScreen.js:139 +#: src/screens/MainScreen.js:141 msgid "|share:Share your address| with friends and start exchanging tokens" msgstr "|share:Del din adresse| med venner og begynd at udveksle tokens" -#: src/screens/MainScreen.js:156 +#: src/screens/MainScreen.js:158 msgid "Loading transactions" msgstr "Indlæser dine transaktioner" -#: src/screens/MainScreen.js:165 +#: src/screens/MainScreen.js:167 msgid "There was an error loading your transaction history" msgstr "" -#: src/screens/MainScreen.js:168 +#: src/screens/MainScreen.js:170 msgid "Please |tryAgain:try again|" msgstr "" -#: src/screens/MainScreen.js:551 src/screens/MainScreen.js:580 +#: src/screens/MainScreen.js:564 src/screens/MainScreen.js:597 msgid "Available Balance" msgstr "Tilgængelig saldo" -#: src/screens/MainScreen.js:560 +#: src/screens/MainScreen.js:573 msgid "Locked" msgstr "Låst" @@ -1114,15 +1155,19 @@ msgstr "" msgid "Address Mode" msgstr "" -#: src/screens/Settings.js:185 +#: src/screens/Settings.js:177 +msgid "Amount format" +msgstr "" + +#: src/screens/Settings.js:189 msgid "About" msgstr "Om" -#: src/screens/Settings.js:193 +#: src/screens/Settings.js:197 msgid "Unique app identifier" msgstr "" -#: src/screens/Settings.js:206 +#: src/screens/Settings.js:210 msgid "Connected to" msgstr "Forbundet til" @@ -1620,21 +1665,21 @@ msgstr "Transaktion" msgid "Open" msgstr "Åben" -#: src/sagas/wallet.js:908 +#: src/sagas/wallet.js:917 msgid "Wallet is not ready to load addresses." msgstr "" #. This will show the message in the feedback content at SelectAddressModal -#: src/sagas/wallet.js:922 +#: src/sagas/wallet.js:931 msgid "There was an error while loading wallet addresses. Try again." msgstr "" -#: src/sagas/wallet.js:932 +#: src/sagas/wallet.js:941 msgid "Wallet is not ready to load the first address." msgstr "" #. This will show the message in the feedback content -#: src/sagas/wallet.js:948 +#: src/sagas/wallet.js:957 msgid "There was an error while loading first wallet address. Try again." msgstr "" diff --git a/locale/pt-br/texts.po b/locale/pt-br/texts.po index e56992ea1..6b3c352d3 100644 --- a/locale/pt-br/texts.po +++ b/locale/pt-br/texts.po @@ -57,7 +57,7 @@ msgid "[Last] dddd [•] HH:mm" msgstr "[Última] dddd [•] HH:mm" #. See https://momentjs.com/docs/#/displaying/calendar-time/ -#: src/models.js:107 src/utils.js:665 +#: src/models.js:107 src/utils.js:752 msgid "DD MMM YYYY [•] HH:mm" msgstr "DD MMM YYYY [•] HH:mm" @@ -65,17 +65,17 @@ msgstr "DD MMM YYYY [•] HH:mm" msgid "Invalid address" msgstr "Endereço inválido" -#: src/utils.js:650 +#: src/utils.js:737 msgid "CREATE DEPOSIT TOKEN" msgstr "CRIAR TOKEN DE DEPÓSITO" -#: src/utils.js:652 +#: src/utils.js:739 msgid "CREATE FEE TOKEN" msgstr "CRIAR TOKEN DE TAXA" #: src/screens/CreateTokenAmount.js:36 src/screens/CreateTokenConfirm.js:87 #: src/screens/CreateTokenDepositNotice.js:42 -#: src/screens/CreateTokenTypeNotice.js:94 src/utils.js:654 +#: src/screens/CreateTokenTypeNotice.js:94 src/utils.js:741 msgid "CREATE TOKEN" msgstr "CRIAR TOKEN" @@ -226,7 +226,7 @@ msgstr "" "Você não pode mudar para o modo de endereço único porque outros endereços da " "sua carteira já estão em uso." -#: src/screens/AddressMode.js:261 +#: src/screens/AddressMode.js:261 src/screens/AmountFormat.js:145 msgid "SAVE PREFERENCES" msgstr "SALVAR PREFERÊNCIAS" @@ -238,6 +238,53 @@ msgstr "" "Alterando\n" "modo de endereço..." +#: src/screens/AmountFormat.js:83 +msgid "Default" +msgstr "Padrão" + +#: src/screens/AmountFormat.js:96 +msgid "AMOUNT FORMAT" +msgstr "FORMATO DE VALOR" + +#: src/screens/AmountFormat.js:102 +msgid "" +"Choose how amounts are displayed across your wallet. You can change your " +"default anytime." +msgstr "" +"Escolha como os valores são exibidos em toda a sua carteira. Você pode " +"alterar seu padrão a qualquer momento." + +#: src/screens/AmountFormat.js:108 +msgid "Expanded" +msgstr "Expandido" + +#: src/screens/AmountFormat.js:109 +msgid "" +"Standard notation. Leading zeros are written out in full (ex: 0.0000005195)." +msgstr "" +"Notação padrão. Os zeros à esquerda são escritos por extenso (ex: " +"0.0000005195)." + +#: src/screens/AmountFormat.js:115 +msgid "Compressed" +msgstr "Comprimido" + +#: src/screens/AmountFormat.js:116 +msgid "" +"Compresses leading zeros into a subscript count for shorter, easier-to-read " +"small values (ex: 0.0₆5195)." +msgstr "" +"Comprime os zeros à esquerda em uma contagem em subscrito, tornando valores " +"pequenos mais curtos e fáceis de ler (ex: 0.0₆5195)." + +#: src/screens/AmountFormat.js:121 +msgid "PREVIEW" +msgstr "PRÉVIA" + +#: src/screens/AmountFormat.js:123 +msgid "Full amount" +msgstr "Valor completo" + #: src/screens/BackupWords.js:184 msgid "To make sure you saved," msgstr "Para conferir que você salvou," @@ -728,7 +775,7 @@ msgid "There's been an error connecting to the server." msgstr "Ocorreu um erro ao conectar com o servidor." #: src/screens/LoadWalletErrorScreen.js:28 src/screens/PinScreen.js:318 -#: src/screens/Settings.js:177 +#: src/screens/Settings.js:181 msgid "Reset wallet" msgstr "Resetar Wallet" @@ -736,33 +783,33 @@ msgstr "Resetar Wallet" msgid "Connect to mainnet" msgstr "Conectar à mainnet" -#: src/screens/MainScreen.js:136 +#: src/screens/MainScreen.js:138 msgid "No transactions" msgstr "Nenhuma transação" -#: src/screens/MainScreen.js:139 +#: src/screens/MainScreen.js:141 msgid "|share:Share your address| with friends and start exchanging tokens" msgstr "" "|share:Compartilhe seu endereço| com seus amigos e comece a trocar seus " "tokens" -#: src/screens/MainScreen.js:156 +#: src/screens/MainScreen.js:158 msgid "Loading transactions" msgstr "Carregando transações" -#: src/screens/MainScreen.js:165 +#: src/screens/MainScreen.js:167 msgid "There was an error loading your transaction history" msgstr "Ocorreu um erro ao carregar seu histórico de transações" -#: src/screens/MainScreen.js:168 +#: src/screens/MainScreen.js:170 msgid "Please |tryAgain:try again|" msgstr "Por favor |tryAgain:tente novamente|" -#: src/screens/MainScreen.js:551 src/screens/MainScreen.js:580 +#: src/screens/MainScreen.js:564 src/screens/MainScreen.js:597 msgid "Available Balance" msgstr "Saldo Disponível" -#: src/screens/MainScreen.js:560 +#: src/screens/MainScreen.js:573 msgid "Locked" msgstr "Bloqueado" @@ -1143,15 +1190,19 @@ msgstr "Configurações de Rede" msgid "Address Mode" msgstr "Modo de Endereço" -#: src/screens/Settings.js:185 +#: src/screens/Settings.js:177 +msgid "Amount format" +msgstr "Formato de Valor" + +#: src/screens/Settings.js:189 msgid "About" msgstr "Sobre" -#: src/screens/Settings.js:193 +#: src/screens/Settings.js:197 msgid "Unique app identifier" msgstr "Identificador único do aplicativo" -#: src/screens/Settings.js:206 +#: src/screens/Settings.js:210 msgid "Connected to" msgstr "Conectado ao servidor" @@ -1663,21 +1714,21 @@ msgstr "Transação" msgid "Open" msgstr "Abrir" -#: src/sagas/wallet.js:908 +#: src/sagas/wallet.js:917 msgid "Wallet is not ready to load addresses." msgstr "A wallet não está pronta para carregar os endereços." #. This will show the message in the feedback content at SelectAddressModal -#: src/sagas/wallet.js:922 +#: src/sagas/wallet.js:931 msgid "There was an error while loading wallet addresses. Try again." msgstr "Ocorreu um erro ao carregar os endereços da wallet. Tente novamente." -#: src/sagas/wallet.js:932 +#: src/sagas/wallet.js:941 msgid "Wallet is not ready to load the first address." msgstr "A wallet não está pronta para carregar o primeiro endereço." #. This will show the message in the feedback content -#: src/sagas/wallet.js:948 +#: src/sagas/wallet.js:957 msgid "There was an error while loading first wallet address. Try again." msgstr "" "Ocorreu um erro ao carregar o primeiro endereço da wallet. Tente novamente." diff --git a/locale/ru-ru/texts.po b/locale/ru-ru/texts.po index fdac4baaf..ebabbd04c 100644 --- a/locale/ru-ru/texts.po +++ b/locale/ru-ru/texts.po @@ -58,7 +58,7 @@ msgid "[Last] dddd [•] HH:mm" msgstr "[Последний] dddd [•] HH:mm" #. See https://momentjs.com/docs/#/displaying/calendar-time/ -#: src/models.js:107 src/utils.js:665 +#: src/models.js:107 src/utils.js:752 msgid "DD MMM YYYY [•] HH:mm" msgstr "DD MMM YYYY [•] HH:mm" @@ -66,17 +66,17 @@ msgstr "DD MMM YYYY [•] HH:mm" msgid "Invalid address" msgstr "Неправильный адрес" -#: src/utils.js:650 +#: src/utils.js:737 msgid "CREATE DEPOSIT TOKEN" msgstr "" -#: src/utils.js:652 +#: src/utils.js:739 msgid "CREATE FEE TOKEN" msgstr "" #: src/screens/CreateTokenAmount.js:36 src/screens/CreateTokenConfirm.js:87 #: src/screens/CreateTokenDepositNotice.js:42 -#: src/screens/CreateTokenTypeNotice.js:94 src/utils.js:654 +#: src/screens/CreateTokenTypeNotice.js:94 src/utils.js:741 msgid "CREATE TOKEN" msgstr "СОЗДАТЬ ТОКЕН" @@ -216,7 +216,7 @@ msgid "" "wallet are already in use." msgstr "" -#: src/screens/AddressMode.js:261 +#: src/screens/AddressMode.js:261 src/screens/AmountFormat.js:145 msgid "SAVE PREFERENCES" msgstr "" @@ -226,6 +226,47 @@ msgid "" "address mode..." msgstr "" +#: src/screens/AmountFormat.js:83 +msgid "Default" +msgstr "" + +#: src/screens/AmountFormat.js:96 +msgid "AMOUNT FORMAT" +msgstr "" + +#: src/screens/AmountFormat.js:102 +msgid "" +"Choose how amounts are displayed across your wallet. You can change your " +"default anytime." +msgstr "" + +#: src/screens/AmountFormat.js:108 +msgid "Expanded" +msgstr "" + +#: src/screens/AmountFormat.js:109 +msgid "" +"Standard notation. Leading zeros are written out in full (ex: 0.0000005195)." +msgstr "" + +#: src/screens/AmountFormat.js:115 +msgid "Compressed" +msgstr "" + +#: src/screens/AmountFormat.js:116 +msgid "" +"Compresses leading zeros into a subscript count for shorter, easier-to-read " +"small values (ex: 0.0₆5195)." +msgstr "" + +#: src/screens/AmountFormat.js:121 +msgid "PREVIEW" +msgstr "" + +#: src/screens/AmountFormat.js:123 +msgid "Full amount" +msgstr "" + #: src/screens/BackupWords.js:184 msgid "To make sure you saved," msgstr "Чтобы убедиться в том, что вы сохранили," @@ -705,7 +746,7 @@ msgid "There's been an error connecting to the server." msgstr "" #: src/screens/LoadWalletErrorScreen.js:28 src/screens/PinScreen.js:318 -#: src/screens/Settings.js:177 +#: src/screens/Settings.js:181 msgid "Reset wallet" msgstr "Сбросить кошелек" @@ -713,32 +754,32 @@ msgstr "Сбросить кошелек" msgid "Connect to mainnet" msgstr "" -#: src/screens/MainScreen.js:136 +#: src/screens/MainScreen.js:138 msgid "No transactions" msgstr "Нет транзакций" -#: src/screens/MainScreen.js:139 +#: src/screens/MainScreen.js:141 msgid "|share:Share your address| with friends and start exchanging tokens" msgstr "" "|share:Поделитесь своим адресом| с друзьями и начните обменивать свои токены" -#: src/screens/MainScreen.js:156 +#: src/screens/MainScreen.js:158 msgid "Loading transactions" msgstr "Загрузка ваших транзакций" -#: src/screens/MainScreen.js:165 +#: src/screens/MainScreen.js:167 msgid "There was an error loading your transaction history" msgstr "" -#: src/screens/MainScreen.js:168 +#: src/screens/MainScreen.js:170 msgid "Please |tryAgain:try again|" msgstr "" -#: src/screens/MainScreen.js:551 src/screens/MainScreen.js:580 +#: src/screens/MainScreen.js:564 src/screens/MainScreen.js:597 msgid "Available Balance" msgstr "Доступный Баланс" -#: src/screens/MainScreen.js:560 +#: src/screens/MainScreen.js:573 msgid "Locked" msgstr "Заблокированный" @@ -1117,15 +1158,19 @@ msgstr "" msgid "Address Mode" msgstr "" -#: src/screens/Settings.js:185 +#: src/screens/Settings.js:177 +msgid "Amount format" +msgstr "" + +#: src/screens/Settings.js:189 msgid "About" msgstr "О нас" -#: src/screens/Settings.js:193 +#: src/screens/Settings.js:197 msgid "Unique app identifier" msgstr "" -#: src/screens/Settings.js:206 +#: src/screens/Settings.js:210 msgid "Connected to" msgstr "Подключены к" @@ -1625,21 +1670,21 @@ msgstr "" msgid "Open" msgstr "Открыть" -#: src/sagas/wallet.js:908 +#: src/sagas/wallet.js:917 msgid "Wallet is not ready to load addresses." msgstr "" #. This will show the message in the feedback content at SelectAddressModal -#: src/sagas/wallet.js:922 +#: src/sagas/wallet.js:931 msgid "There was an error while loading wallet addresses. Try again." msgstr "" -#: src/sagas/wallet.js:932 +#: src/sagas/wallet.js:941 msgid "Wallet is not ready to load the first address." msgstr "" #. This will show the message in the feedback content -#: src/sagas/wallet.js:948 +#: src/sagas/wallet.js:957 msgid "There was an error while loading first wallet address. Try again." msgstr "" diff --git a/locale/texts.pot b/locale/texts.pot index cb3022d20..1b889287a 100644 --- a/locale/texts.pot +++ b/locale/texts.pot @@ -49,7 +49,7 @@ msgid "[Last] dddd [•] HH:mm" msgstr "" #: src/models.js:107 -#: src/utils.js:665 +#: src/utils.js:752 #. See https://momentjs.com/docs/#/displaying/calendar-time/ msgid "DD MMM YYYY [•] HH:mm" msgstr "" @@ -58,11 +58,11 @@ msgstr "" msgid "Invalid address" msgstr "" -#: src/utils.js:650 +#: src/utils.js:737 msgid "CREATE DEPOSIT TOKEN" msgstr "" -#: src/utils.js:652 +#: src/utils.js:739 msgid "CREATE FEE TOKEN" msgstr "" @@ -70,7 +70,7 @@ msgstr "" #: src/screens/CreateTokenConfirm.js:87 #: src/screens/CreateTokenDepositNotice.js:42 #: src/screens/CreateTokenTypeNotice.js:94 -#: src/utils.js:654 +#: src/utils.js:741 msgid "CREATE TOKEN" msgstr "" @@ -211,6 +211,7 @@ msgid "" msgstr "" #: src/screens/AddressMode.js:261 +#: src/screens/AmountFormat.js:145 msgid "SAVE PREFERENCES" msgstr "" @@ -220,6 +221,46 @@ msgid "" "address mode..." msgstr "" +#: src/screens/AmountFormat.js:83 +msgid "Default" +msgstr "" + +#: src/screens/AmountFormat.js:96 +msgid "AMOUNT FORMAT" +msgstr "" + +#: src/screens/AmountFormat.js:102 +msgid "" +"Choose how amounts are displayed across your wallet. You can change your " +"default anytime." +msgstr "" + +#: src/screens/AmountFormat.js:108 +msgid "Expanded" +msgstr "" + +#: src/screens/AmountFormat.js:109 +msgid "Standard notation. Leading zeros are written out in full (ex: 0.0000005195)." +msgstr "" + +#: src/screens/AmountFormat.js:115 +msgid "Compressed" +msgstr "" + +#: src/screens/AmountFormat.js:116 +msgid "" +"Compresses leading zeros into a subscript count for shorter, easier-to-read " +"small values (ex: 0.0₆5195)." +msgstr "" + +#: src/screens/AmountFormat.js:121 +msgid "PREVIEW" +msgstr "" + +#: src/screens/AmountFormat.js:123 +msgid "Full amount" +msgstr "" + #: src/screens/BackupWords.js:184 msgid "To make sure you saved," msgstr "" @@ -701,7 +742,7 @@ msgstr "" #: src/screens/LoadWalletErrorScreen.js:28 #: src/screens/PinScreen.js:318 -#: src/screens/Settings.js:177 +#: src/screens/Settings.js:181 msgid "Reset wallet" msgstr "" @@ -709,32 +750,32 @@ msgstr "" msgid "Connect to mainnet" msgstr "" -#: src/screens/MainScreen.js:136 +#: src/screens/MainScreen.js:138 msgid "No transactions" msgstr "" -#: src/screens/MainScreen.js:139 +#: src/screens/MainScreen.js:141 msgid "|share:Share your address| with friends and start exchanging tokens" msgstr "" -#: src/screens/MainScreen.js:156 +#: src/screens/MainScreen.js:158 msgid "Loading transactions" msgstr "" -#: src/screens/MainScreen.js:165 +#: src/screens/MainScreen.js:167 msgid "There was an error loading your transaction history" msgstr "" -#: src/screens/MainScreen.js:168 +#: src/screens/MainScreen.js:170 msgid "Please |tryAgain:try again|" msgstr "" -#: src/screens/MainScreen.js:551 -#: src/screens/MainScreen.js:580 +#: src/screens/MainScreen.js:564 +#: src/screens/MainScreen.js:597 msgid "Available Balance" msgstr "" -#: src/screens/MainScreen.js:560 +#: src/screens/MainScreen.js:573 msgid "Locked" msgstr "" @@ -1125,15 +1166,19 @@ msgstr "" msgid "Address Mode" msgstr "" -#: src/screens/Settings.js:185 +#: src/screens/Settings.js:177 +msgid "Amount format" +msgstr "" + +#: src/screens/Settings.js:189 msgid "About" msgstr "" -#: src/screens/Settings.js:193 +#: src/screens/Settings.js:197 msgid "Unique app identifier" msgstr "" -#: src/screens/Settings.js:206 +#: src/screens/Settings.js:210 msgid "Connected to" msgstr "" @@ -1633,20 +1678,20 @@ msgstr "" msgid "Open" msgstr "" -#: src/sagas/wallet.js:908 +#: src/sagas/wallet.js:917 msgid "Wallet is not ready to load addresses." msgstr "" -#: src/sagas/wallet.js:922 +#: src/sagas/wallet.js:931 #. This will show the message in the feedback content at SelectAddressModal msgid "There was an error while loading wallet addresses. Try again." msgstr "" -#: src/sagas/wallet.js:932 +#: src/sagas/wallet.js:941 msgid "Wallet is not ready to load the first address." msgstr "" -#: src/sagas/wallet.js:948 +#: src/sagas/wallet.js:957 #. This will show the message in the feedback content msgid "There was an error while loading first wallet address. Try again." msgstr "" diff --git a/src/App.js b/src/App.js index c03748554..7852685f2 100644 --- a/src/App.js +++ b/src/App.js @@ -74,6 +74,7 @@ import CreateTokenName from './screens/CreateTokenName'; import CreateTokenSymbol from './screens/CreateTokenSymbol'; import About from './screens/About'; import AddressMode from './screens/AddressMode'; +import AmountFormat from './screens/AmountFormat'; import Security from './screens/Security'; import PushNotification from './screens/PushNotification'; import ChangePin from './screens/ChangePin'; @@ -466,6 +467,7 @@ const AppStack = () => { case 'RegisterOptions': case 'RegisterTokenManual': case 'AddressMode': + case 'AmountFormat': case 'ImportTokensScreen': case 'ConfirmImportScreen': newEdges = ['bottom']; @@ -499,6 +501,7 @@ const AppStack = () => { + diff --git a/src/actions.js b/src/actions.js index 20f64ee30..e9751cd20 100644 --- a/src/actions.js +++ b/src/actions.js @@ -104,6 +104,7 @@ export const types = { WALLET_REFRESH_SHARED_ADDRESS: 'WALLET_REFRESH_SHARED_ADDRESS', SHARED_ADDRESS_UPDATE: 'SHARED_ADDRESS_UPDATE', SET_ADDRESS_MODE: 'SET_ADDRESS_MODE', + SET_AMOUNT_FORMAT: 'SET_AMOUNT_FORMAT', EXCEPTION_CAPTURED: 'EXCEPTION_CAPTURED', SET_FEATURE_TOGGLES: 'SET_FEATURE_TOGGLES', // Feature Toggle actions @@ -988,6 +989,15 @@ export const setAddressMode = (mode) => ({ payload: mode, }); +/** + * Set the wallet-wide amount display format + * @param {'expanded'|'compressed'} format + */ +export const setAmountFormat = (format) => ({ + type: types.SET_AMOUNT_FORMAT, + payload: format, +}); + /** * Stores the reown instance on the redux store * diff --git a/src/components/NanoContract/NanoContractTransactionActionListItem.js b/src/components/NanoContract/NanoContractTransactionActionListItem.js index 7cc4a02a8..f60d2d12f 100644 --- a/src/components/NanoContract/NanoContractTransactionActionListItem.js +++ b/src/components/NanoContract/NanoContractTransactionActionListItem.js @@ -111,7 +111,8 @@ const ContentWrapper = ({ title, isAuthorityAction: isAuthority }) => { */ const TokenAmount = ({ amount, isNft, type }) => { const isReceivingToken = type === NANO_CONTRACT_ACTION.withdrawal; - const amountToRender = renderValue(amount, isNft); + const decimalPlaces = useSelector((state) => state.serverInfo?.decimal_places); + const amountToRender = renderValue(amount, isNft, decimalPlaces); return ( diff --git a/src/components/PushTxDetailsModal.js b/src/components/PushTxDetailsModal.js index 823563578..79600452d 100644 --- a/src/components/PushTxDetailsModal.js +++ b/src/components/PushTxDetailsModal.js @@ -39,11 +39,14 @@ const getTimestampFormat = (tx) => { }; const getTokenTitle = (token) => `${token.symbol} - ${token.name}`; -const getTokenBalance = (token, isNFT) => renderValue(token.balance, isNFT); +const getTokenBalance = (token, isNFT, decimalPlaces) => ( + renderValue(token.balance, isNFT, decimalPlaces) +); export default function PushTxDetailsModal(props) { const { tx, tokens } = props; const tokenMetadata = useSelector((state) => state.tokenMetadata); + const decimalPlaces = useSelector((state) => state.serverInfo?.decimal_places); const dispatch = useDispatch(); const idStr = getShortHash(tx.txId, 12); @@ -70,7 +73,9 @@ export default function PushTxDetailsModal(props) { title={getTokenTitle(token)} titleStyle={token.isRegistered && styles.registeredToken} button={( - {getTokenBalance(token, isTokenNFT(token.uid, tokenMetadata))} + + {getTokenBalance(token, isTokenNFT(token.uid, tokenMetadata), decimalPlaces)} + )} /> ))} diff --git a/src/components/TokenSelect.js b/src/components/TokenSelect.js index b3d950a42..7e66e4d04 100644 --- a/src/components/TokenSelect.js +++ b/src/components/TokenSelect.js @@ -15,7 +15,7 @@ import { faCircleExclamation } from '@fortawesome/free-solid-svg-icons'; import Spinner from './Spinner'; import TokenAvatar from './TokenAvatar'; -import { renderValue, isTokenNFT } from '../utils'; +import { renderValue, renderHomeValue, isTokenNFT } from '../utils'; import { TOKEN_DOWNLOAD_STATUS } from '../sagas/tokens'; import { COLORS } from '../styles/themes'; import { HathorFlatList } from './HathorFlatList'; @@ -35,12 +35,23 @@ import { HathorFlatList } from './HathorFlatList'; * @param {unknown} props.header * @param {function} props.onItemPress * @param {boolean} [props.ignoreLoading] + * @param {boolean} [props.useHomeDisplay] Apply the Home display rules + * (magnitude decimal cap + amount-format preference). Off elsewhere, so + * token-list reuse (swap, change-token) keeps regular precision. + * @param {string} [props.amountFormat] AMOUNT_FORMAT for the Home display + * (defaults to Expanded; ignored unless useHomeDisplay). + * @param {number} [props.decimalPlaces] Network decimal places (serverInfo.decimal_places), + * used by both display modes. */ const TokenSelect = (props) => { const tokens = Object.values(props.tokens); const renderItem = ({ item }) => { const balance = get(props.tokensBalance, `${item.uid}.data.available`, 0); const tokenState = get(props.tokensBalance, `${item.uid}.status`, props.ignoreLoading ? 'ready' : 'loading'); + const isNFT = isTokenNFT(item.uid, props.tokenMetadata); + const balanceLabel = props.useHomeDisplay + ? renderHomeValue(balance, isNFT, props.decimalPlaces, props.amountFormat) + : renderValue(balance, isNFT, props.decimalPlaces); return ( { {tokenState === TOKEN_DOWNLOAD_STATUS.READY && ( - {renderValue(balance, isTokenNFT(item.uid, props.tokenMetadata))} + {balanceLabel} )} {tokenState === TOKEN_DOWNLOAD_STATUS.FAILED && ( diff --git a/src/components/TxDetailsModal.js b/src/components/TxDetailsModal.js index 16a02f979..dd0dd7a84 100644 --- a/src/components/TxDetailsModal.js +++ b/src/components/TxDetailsModal.js @@ -106,7 +106,13 @@ class TxDetailsModal extends Component { swipeThreshold={100} > - + @@ -159,8 +165,8 @@ class BalanceView extends Component { }); render() { - const { tx, isNFT } = this.props; - const balanceStr = renderValue(tx.balance, isNFT); + const { tx, isNFT, amountFormat, decimalPlaces } = this.props; + const balanceStr = renderValue(tx.balance, isNFT, decimalPlaces, amountFormat); return ( `${ADDRESS_MODE_KEY_PREFIX}${network}`; +/** + * Amount display format (wallet-wide, network-independent). + */ +export const AMOUNT_FORMAT = { + EXPANDED: 'expanded', + COMPRESSED: 'compressed', +}; + +export const AMOUNT_FORMAT_DEFAULT = AMOUNT_FORMAT.EXPANDED; + +// 'wallet:' prefix: STORE.clearItems(true) sweeps it on resetWallet, so it resets to Expanded. +export const AMOUNT_FORMAT_KEY = 'wallet:amount_format'; + /** * this is the message key for localization of new transaction when show amount is enabled */ @@ -197,6 +210,7 @@ export const SAFE_BIOMETRY_MODE_FEATURE_TOGGLE = 'safe-biometry-mode.rollout' export const TOKEN_SWAP_FEATURE_TOGGLE = 'token-swap.rollout'; export const FBT_FEATURE_TOGGLE = 'fee-based-tokens.rollout'; export const SINGLE_ADDRESS_FEATURE_TOGGLE = 'single-address-mobile.rollout'; +export const AMOUNT_FORMAT_FEATURE_TOGGLE = 'amount-format-mobile.rollout'; /** * Default feature toggle values. @@ -216,6 +230,7 @@ export const FEATURE_TOGGLE_DEFAULTS = { [TOKEN_SWAP_FEATURE_TOGGLE]: false, [FBT_FEATURE_TOGGLE]: false, [SINGLE_ADDRESS_FEATURE_TOGGLE]: false, + [AMOUNT_FORMAT_FEATURE_TOGGLE]: false, }; // Project id configured in https://walletconnect.com diff --git a/src/reducers/reducer.js b/src/reducers/reducer.js index 1b1430e5f..8c0da6c3c 100644 --- a/src/reducers/reducer.js +++ b/src/reducers/reducer.js @@ -22,6 +22,7 @@ import { TOKEN_SWAP_ALLOWED_TOKEN_STATUS, TOKEN_SWAP_QUOTE_STATUS, TOKEN_IMPORT_MODAL_STATE, + AMOUNT_FORMAT_DEFAULT, } from '../constants'; import { types } from '../actions'; import { TOKEN_DOWNLOAD_STATUS } from '../sagas/tokens'; @@ -270,6 +271,7 @@ const initialState = { lastSharedAddress: null, lastSharedIndex: null, addressMode: null, + amountFormat: AMOUNT_FORMAT_DEFAULT, reown: { client: null, modal: { @@ -694,6 +696,8 @@ export const reducer = (state = initialState, action) => { return onSharedAddressUpdate(state, action); case types.SET_ADDRESS_MODE: return onSetAddressMode(state, action); + case types.SET_AMOUNT_FORMAT: + return onSetAmountFormat(state, action); case types.SET_UNLEASH_CLIENT: return onSetUnleashClient(state, action); case types.SET_FEATURE_TOGGLES: @@ -1544,6 +1548,14 @@ const onSetAddressMode = (state, action) => ({ addressMode: action.payload, }); +/** + * @param {'expanded'|'compressed'} action.payload The amount display format + */ +const onSetAmountFormat = (state, action) => ({ + ...state, + amountFormat: action.payload, +}); + /** * @param {Reown} action.payload The wallet connect instance */ diff --git a/src/sagas/wallet.js b/src/sagas/wallet.js index ee81ed8cd..0c2f4a83e 100644 --- a/src/sagas/wallet.js +++ b/src/sagas/wallet.js @@ -42,6 +42,7 @@ import { ADDRESS_MODE, addressModeKey, networkSettingsKeyMap, + AMOUNT_FORMAT_KEY, } from '../constants'; import { STORE } from '../store'; import { @@ -77,6 +78,7 @@ import { setFullNodeNetworkName, setAddressMode, tokenImportFetchRequested, + setAmountFormat, } from '../actions'; import { fetchTokenData } from './tokens'; import { @@ -93,6 +95,7 @@ import { monitorFeatureFlags } from './featureToggle'; import { getAllAddresses, getFirstAddress, + normalizeAmountFormat, } from '../utils'; import { logger } from '../logger'; @@ -194,6 +197,11 @@ export function* startWallet(action) { yield call([storage.store, storage.store.cleanMetadata]); // clean metadata on memory yield call([storage, storage.cleanStorage], true); // clean transaction history + // startWallet re-runs on every unlock, so re-apply the persisted amount format + // onto the freshly-initialized redux state (which defaults to Expanded). + const storedAmountFormat = normalizeAmountFormat(STORE.getItem(AMOUNT_FORMAT_KEY)); + yield put(setAmountFormat(storedAmountFormat)); + // As this is a core setting for the wallet, it should be loaded first. // Network settings either from store or redux state let networkSettings; diff --git a/src/screens/AmountFormat.js b/src/screens/AmountFormat.js new file mode 100644 index 000000000..06f7b5131 --- /dev/null +++ b/src/screens/AmountFormat.js @@ -0,0 +1,282 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useState } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, +} from 'react-native'; +import { useSelector, useDispatch } from 'react-redux'; +import { t } from 'ttag'; +import HathorHeader from '../components/HathorHeader'; +import { COLORS } from '../styles/themes'; +import { STORE } from '../store'; +import { + AMOUNT_FORMAT, + AMOUNT_FORMAT_DEFAULT, + AMOUNT_FORMAT_KEY, + DEFAULT_TOKEN, +} from '../constants'; +import { setAmountFormat } from '../actions'; +import { compressAmountString } from '../utils'; + +const PREVIEW_SAMPLE = '0.0000005195'; + +/** + * Screen that lets the user pick the wallet-wide amount display format + * (Expanded or Compressed) and persist it. + * + * @param {Object} props + * @param {Object} props.navigation - React Navigation navigation object + */ +export default function AmountFormat({ navigation }) { + const dispatch = useDispatch(); + const currentFormat = useSelector((state) => state.amountFormat ?? AMOUNT_FORMAT_DEFAULT); + const nativeTokenSymbol = useSelector( + (state) => state.serverInfo?.native_token?.symbol ?? DEFAULT_TOKEN.symbol + ); + const [selectedFormat, setSelectedFormat] = useState(currentFormat); + + const isSaveDisabled = selectedFormat === currentFormat; + + const onSave = () => { + if (isSaveDisabled) return; + STORE.setItem(AMOUNT_FORMAT_KEY, selectedFormat); + dispatch(setAmountFormat(selectedFormat)); + navigation.goBack(); + }; + + const previewValue = selectedFormat === AMOUNT_FORMAT.COMPRESSED + ? compressAmountString(PREVIEW_SAMPLE) + : PREVIEW_SAMPLE; + + const renderRadioIcon = (isSelected) => ( + + {isSelected && } + + ); + + const renderOption = (format, label, description, showDefaultBadge) => { + const isSelected = selectedFormat === format; + return ( + setSelectedFormat(format)} + style={styles.option} + > + {renderRadioIcon(isSelected)} + + + {label} + {showDefaultBadge && ( + + {t`Default`} + + )} + + {description} + + + ); + }; + + return ( + + navigation.goBack()} + /> + + + + {t`Choose how amounts are displayed across your wallet. You can change your default anytime.`} + + + + {renderOption( + AMOUNT_FORMAT.EXPANDED, + t`Expanded`, + t`Standard notation. Leading zeros are written out in full (ex: 0.0000005195).`, + true, + )} + + {renderOption( + AMOUNT_FORMAT.COMPRESSED, + t`Compressed`, + t`Compresses leading zeros into a subscript count for shorter, easier-to-read small values (ex: 0.0₆5195).`, + false, + )} + + + {t`PREVIEW`} + + {t`Full amount`} + {`${previewValue} ${nativeTokenSymbol}`} + + + + + + + + + {t`SAVE PREFERENCES`} + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: COLORS.white, + }, + content: { + marginHorizontal: 16, + marginTop: 16, + }, + intro: { + fontSize: 14, + lineHeight: 20, + color: COLORS.black, + }, + card: { + marginTop: 24, + borderWidth: 1, + borderColor: COLORS.borderColor, + borderRadius: 16, + paddingHorizontal: 16, + paddingVertical: 24, + }, + option: { + flexDirection: 'row', + alignItems: 'flex-start', + }, + optionTextWrapper: { + flex: 1, + marginLeft: 16, + }, + optionTitleRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + optionTitle: { + fontSize: 16, + fontWeight: '600', + color: COLORS.black, + }, + defaultBadge: { + backgroundColor: COLORS.lowContrastDetail, + borderRadius: 12, + paddingHorizontal: 10, + paddingVertical: 2, + }, + defaultBadgeText: { + fontSize: 12, + color: COLORS.darkContrastDetail, + }, + optionDescription: { + marginTop: 4, + fontSize: 12, + lineHeight: 20, + color: COLORS.midContrastDetail, + }, + divider: { + height: 1, + backgroundColor: COLORS.borderColor, + marginVertical: 24, + }, + previewHeader: { + marginTop: 24, + fontSize: 14, + fontWeight: '600', + color: COLORS.textColorShadow, + }, + previewPanel: { + marginTop: 24, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + backgroundColor: COLORS.lowContrastDetail, + borderRadius: 16, + padding: 16, + }, + previewLabel: { + fontSize: 14, + color: COLORS.darkContrastDetail, + }, + previewValue: { + fontSize: 14, + fontWeight: '500', + color: COLORS.black, + }, + bottomSpacer: { + flex: 1, + }, + buttonContainer: { + marginHorizontal: 16, + marginBottom: 32, + }, + saveButton: { + borderRadius: 8, + paddingVertical: 16, + alignItems: 'center', + justifyContent: 'center', + }, + saveButtonDisabled: { + backgroundColor: COLORS.borderColorMid, + }, + saveButtonActive: { + backgroundColor: COLORS.black, + }, + saveButtonText: { + fontSize: 14, + fontWeight: '600', + }, + saveButtonTextDisabled: { + color: COLORS.darkContrastDetail, + }, + saveButtonTextActive: { + color: COLORS.white, + }, + radioOuter: { + width: 24, + height: 24, + borderRadius: 12, + borderWidth: 2, + justifyContent: 'center', + alignItems: 'center', + }, + radioInner: { + width: 12, + height: 12, + borderRadius: 6, + backgroundColor: COLORS.primary, + }, +}); diff --git a/src/screens/ChangeToken.js b/src/screens/ChangeToken.js index 9a9036426..138d26ca1 100644 --- a/src/screens/ChangeToken.js +++ b/src/screens/ChangeToken.js @@ -25,6 +25,7 @@ const mapStateToProps = (state) => ({ tokens: state.tokens, tokensBalance: state.tokensBalance, tokenMetadata: state.tokenMetadata, + decimalPlaces: state.serverInfo?.decimal_places, }); /** @@ -61,6 +62,7 @@ class ChangeToken extends React.Component { tokens={this.props.tokens} tokensBalance={this.props.tokensBalance} tokenMetadata={this.props.tokenMetadata} + decimalPlaces={this.props.decimalPlaces} /> ); } diff --git a/src/screens/ConfirmImportScreen.js b/src/screens/ConfirmImportScreen.js index ba87b0333..3e5ee8005 100644 --- a/src/screens/ConfirmImportScreen.js +++ b/src/screens/ConfirmImportScreen.js @@ -12,7 +12,7 @@ import { import { useDispatch, useSelector } from 'react-redux'; import { useNavigation } from '@react-navigation/native'; import { t } from 'ttag'; -import { numberUtils, constants } from '@hathor/wallet-lib'; +import { numberUtils } from '@hathor/wallet-lib'; import HathorHeader from '../components/HathorHeader'; import NewHathorButton from '../components/NewHathorButton'; import ImportTokensModal from '../components/ImportTokensModal'; @@ -37,6 +37,7 @@ const ConfirmImportScreen = () => { const { tokens } = useParams(); const importStatus = useSelector((state) => state.tokenImport.importStatus); const tokensBalance = useSelector((state) => state.tokensBalance); + const decimalPlaces = useSelector((state) => state.serverInfo?.decimal_places); const [showModal, setShowModal] = useState(false); @@ -71,7 +72,7 @@ const ConfirmImportScreen = () => { return `— ${token.symbol}`; } const available = entry.data?.available ?? 0n; - return `${numberUtils.prettyValue(available, constants.DECIMAL_PLACES)} ${token.symbol}`; + return `${numberUtils.prettyValue(available, decimalPlaces)} ${token.symbol}`; }; const renderTokenItem = ({ item, index }) => ( diff --git a/src/screens/CreateTokenAmount.js b/src/screens/CreateTokenAmount.js index 75eeb2532..a4de7a1c0 100644 --- a/src/screens/CreateTokenAmount.js +++ b/src/screens/CreateTokenAmount.js @@ -148,7 +148,7 @@ const CreateTokenAmount = () => { const amountStyle = getAmountStyle(); const amountAvailableText = ( - {hathorLib.numberUtils.prettyValue(balance.available)} {nativeSymbol} + {hathorLib.numberUtils.prettyValue(balance.available, decimalPlaces)} {nativeSymbol} ); @@ -163,7 +163,7 @@ const CreateTokenAmount = () => { if (tokenVersion === TokenVersion.FEE) { return [ {t`Network fee:`} - {hathorLib.numberUtils.prettyValue(networkFee)} {nativeSymbol} + {hathorLib.numberUtils.prettyValue(networkFee, decimalPlaces)} {nativeSymbol} , availableText, {t`A small fee will be applied to each future transaction of this token.`}, @@ -172,7 +172,7 @@ const CreateTokenAmount = () => { return [ {t`Deposit:`} - {hathorLib.numberUtils.prettyValue(networkFee)} {nativeSymbol} + {hathorLib.numberUtils.prettyValue(networkFee, decimalPlaces)} {nativeSymbol} , availableText, ]; diff --git a/src/screens/CreateTokenConfirm.js b/src/screens/CreateTokenConfirm.js index 28d32640c..da5774cdc 100644 --- a/src/screens/CreateTokenConfirm.js +++ b/src/screens/CreateTokenConfirm.js @@ -224,7 +224,7 @@ const CreateTokenConfirm = () => { { )} @@ -256,7 +256,7 @@ const CreateTokenConfirm = () => { )} diff --git a/src/screens/Dashboard.js b/src/screens/Dashboard.js index a235ecf48..7646f1378 100644 --- a/src/screens/Dashboard.js +++ b/src/screens/Dashboard.js @@ -25,7 +25,7 @@ import TokenImportBanner from '../components/TokenImportBanner'; import { COLORS } from '../styles/themes'; import { TOKEN_DOWNLOAD_STATUS } from '../sagas/tokens'; import { NanoContractsList } from '../components/NanoContract/NanoContractsList'; -import { isNanoContractsEnabled } from '../utils'; +import { isNanoContractsEnabled, getDisplayAmountFormat } from '../utils'; import ShowPushNotificationTxDetails from '../components/ShowPushNotificationTxDetails'; /** @@ -96,6 +96,8 @@ export const Dashboard = () => { tokensMetadata, } = useSelector(getTokensState); const isNanoEnabled = useSelector(isNanoContractsEnabled); + const amountFormat = useSelector(getDisplayAmountFormat); + const decimalPlaces = useSelector((state) => state.serverInfo?.decimal_places); const [currList, selectList] = useState(listOption.tokens); const navigation = useNavigation(); @@ -160,6 +162,9 @@ export const Dashboard = () => { tokens={tokens} tokensBalance={tokensBalance} tokenMetadata={tokensMetadata} + useHomeDisplay + amountFormat={amountFormat} + decimalPlaces={decimalPlaces} /> ) } diff --git a/src/screens/ImportTokensScreen.js b/src/screens/ImportTokensScreen.js index 5587d4260..62f072bf9 100644 --- a/src/screens/ImportTokensScreen.js +++ b/src/screens/ImportTokensScreen.js @@ -18,7 +18,7 @@ import { import { useSelector } from 'react-redux'; import { useNavigation } from '@react-navigation/native'; import { t } from 'ttag'; -import { numberUtils, constants } from '@hathor/wallet-lib'; +import { numberUtils } from '@hathor/wallet-lib'; import HathorHeader from '../components/HathorHeader'; import NewHathorButton from '../components/NewHathorButton'; import TokenListCard from '../components/TokenListCard'; @@ -40,6 +40,7 @@ const ImportTokensScreen = () => { const loading = useSelector((state) => state.tokenImport.loading); const tokensBalance = useSelector((state) => state.tokensBalance); const explorerUrl = useSelector((state) => state.networkSettings.explorerUrl); + const decimalPlaces = useSelector((state) => state.serverInfo?.decimal_places); // Selection lives locally; the visible list is derived from the store so any // newly detected unregistered tokens appear without needing to re-enter the screen. @@ -114,7 +115,7 @@ const ImportTokensScreen = () => { return `— ${token.symbol}`; } const available = entry.data?.available ?? 0n; - return `${numberUtils.prettyValue(available, constants.DECIMAL_PLACES)} ${token.symbol}`; + return `${numberUtils.prettyValue(available, decimalPlaces)} ${token.symbol}`; }; const renderTokenItem = ({ item, index }) => ( diff --git a/src/screens/MainScreen.js b/src/screens/MainScreen.js index 9ce38dc4a..164bfba92 100644 --- a/src/screens/MainScreen.js +++ b/src/screens/MainScreen.js @@ -27,7 +27,9 @@ import SimpleButton from '../components/SimpleButton'; import TxDetailsModal from '../components/TxDetailsModal'; import OfflineBar from '../components/OfflineBar'; import { HathorList } from '../components/HathorList'; -import { Strong, str2jsx, renderValue, isTokenNFT } from '../utils'; +import { + Strong, str2jsx, renderValue, isTokenNFT, getDisplayAmountFormat, renderHomeValue, +} from '../utils'; import chevronUp from '../assets/icons/chevron-up.png'; import chevronDown from '../assets/icons/chevron-down.png'; import { IS_MULTI_TOKEN } from '../constants'; @@ -54,6 +56,8 @@ const mapStateToProps = (state) => ({ isOnline: state.isOnline, wallet: state.wallet, tokenMetadata: state.tokenMetadata, + amountFormat: getDisplayAmountFormat(state), + decimalPlaces: state.serverInfo?.decimal_places, }); const mapDispatchToProps = (dispatch) => ({ @@ -114,6 +118,8 @@ class MainScreen extends React.Component { token={this.props.selectedToken} onRequestClose={this.closeTxDetails} isNFT={this.isNFT()} + amountFormat={this.props.amountFormat} + decimalPlaces={this.props.decimalPlaces} /> ); this.setState({ modal: txDetailsModal }); @@ -197,6 +203,8 @@ class MainScreen extends React.Component { wallet={this.props.wallet} updateTokenHistory={this.props.updateTokenHistory} isNFT={this.isNFT()} + amountFormat={this.props.amountFormat} + decimalPlaces={this.props.decimalPlaces} /> ); } @@ -235,6 +243,8 @@ class MainScreen extends React.Component { balance={this.props.balance} token={this.props.selectedToken} isNFT={this.isNFT()} + amountFormat={this.props.amountFormat} + decimalPlaces={this.props.decimalPlaces} /> {renderTxHistory()} @@ -260,6 +270,8 @@ class TxHistoryView extends React.Component { token={this.props.token} onTxPress={this.props.onTxPress} isNFT={this.props.isNFT} + amountFormat={this.props.amountFormat} + decimalPlaces={this.props.decimalPlaces} /> ); } @@ -327,6 +339,7 @@ class TxListItem extends React.Component { }, middleView: { flex: 1, + maxWidth: '45%', }, icon: { marginLeft: 16, @@ -340,6 +353,9 @@ class TxListItem extends React.Component { balance: { fontSize: 16, marginRight: 16, + maxWidth: '45%', + marginLeft: 'auto', + textAlign: 'right', }, description: { fontSize: 14, @@ -469,7 +485,12 @@ class TxListItem extends React.Component { const style = this.getStyle(item); const image = this.getImage(item); - const balanceStr = renderValue(item.balance, this.props.isNFT); + const balanceStr = renderValue( + item.balance, + this.props.isNFT, + this.props.decimalPlaces, + this.props.amountFormat + ); const description = item.getDescription(this.props.token); const { timestamp } = this.state; return ( @@ -484,7 +505,7 @@ class TxListItem extends React.Component { {timestamp} {item.getVersionInfo()} - {balanceStr} + {balanceStr} ); @@ -534,8 +555,18 @@ class BalanceView extends React.Component { } renderExpanded() { - const availableStr = renderValue(this.props.balance.available, this.props.isNFT); - const lockedStr = renderValue(this.props.balance.locked, this.props.isNFT); + const availableStr = renderHomeValue( + this.props.balance.available, + this.props.isNFT, + this.props.decimalPlaces, + this.props.amountFormat + ); + const lockedStr = renderHomeValue( + this.props.balance.locked, + this.props.isNFT, + this.props.decimalPlaces, + this.props.amountFormat + ); const { token } = this.props; const { style } = this; return ( @@ -564,7 +595,12 @@ class BalanceView extends React.Component { } renderSimple() { - const availableStr = renderValue(this.props.balance.available, this.props.isNFT); + const availableStr = renderHomeValue( + this.props.balance.available, + this.props.isNFT, + this.props.decimalPlaces, + this.props.amountFormat + ); const { token } = this.props; const { style } = this; return ( diff --git a/src/screens/Settings.js b/src/screens/Settings.js index 1af9644bf..0030a51d5 100644 --- a/src/screens/Settings.js +++ b/src/screens/Settings.js @@ -23,6 +23,7 @@ import { NETWORK_SETTINGS_FEATURE_TOGGLE, REOWN_FEATURE_TOGGLE, SINGLE_ADDRESS_FEATURE_TOGGLE, + AMOUNT_FORMAT_FEATURE_TOGGLE, } from '../constants'; import CopyClipboard from '../components/CopyClipboard'; import { COLORS } from '../styles/themes'; @@ -55,6 +56,7 @@ const mapStateToProps = (state) => { reownEnabled: state.featureToggles[REOWN_FEATURE_TOGGLE] && isNanoContractsEnabled(state), networkSettingsEnabled: state.featureToggles[NETWORK_SETTINGS_FEATURE_TOGGLE], singleAddressEnabled: state.featureToggles[SINGLE_ADDRESS_FEATURE_TOGGLE], + amountFormatEnabled: state.featureToggles[AMOUNT_FORMAT_FEATURE_TOGGLE], addressMode: state.addressMode, }; }; @@ -173,6 +175,13 @@ export class Settings extends React.Component { onPress={() => this.props.navigation.navigate('AddressMode')} /> )} + {this.props.amountFormatEnabled + && ( + this.props.navigation.navigate('AmountFormat')} + /> + )} this.props.navigation.navigate('ResetWallet')} diff --git a/src/utils.js b/src/utils.js index 0fb779512..214d14d62 100644 --- a/src/utils.js +++ b/src/utils.js @@ -16,7 +16,7 @@ import { Linking, Platform, Text } from 'react-native'; import { getStatusBarHeight } from 'react-native-status-bar-height'; import moment from 'moment'; import baseStyle from './styles/init'; -import { KEYCHAIN_USER, NETWORK_MAINNET, NANO_CONTRACT_FEATURE_TOGGLE, SAFE_BIOMETRY_MODE_FEATURE_TOGGLE, TOKEN_SWAP_FEATURE_TOGGLE, FBT_FEATURE_TOGGLE } from './constants'; +import { KEYCHAIN_USER, NETWORK_MAINNET, NANO_CONTRACT_FEATURE_TOGGLE, SAFE_BIOMETRY_MODE_FEATURE_TOGGLE, TOKEN_SWAP_FEATURE_TOGGLE, FBT_FEATURE_TOGGLE, AMOUNT_FORMAT, AMOUNT_FORMAT_DEFAULT, AMOUNT_FORMAT_FEATURE_TOGGLE } from './constants'; import { STORE, IS_BIOMETRY_ENABLED_KEY, IS_OLD_BIOMETRY_ENABLED_KEY, SUPPORTED_BIOMETRY_KEY, SAFE_BIOMETRY_FEATURE_FLAG_KEY, FEATURE_TOGGLES_LAST_KNOWN_VALUES_KEY } from './store'; import { TxHistory } from './models'; import { COLORS, STYLE } from './styles/themes'; @@ -372,21 +372,190 @@ export const getLightBackground = (alpha) => { return `${COLORS.primary}${hex}`; }; +const SUBSCRIPT_DIGITS = ['₀', '₁', '₂', '₃', '₄', '₅', '₆', '₇', '₈', '₉']; + +// Shortest zero run worth compressing. +const COMPRESS_MIN_ZERO_RUN = 3; + /** - * Render value in a formatted way for display + * Render a non-negative integer as a Unicode subscript string (e.g. 12 -> "₁₂"). + * + * @param {number} n + * @return {string} + */ +const toSubscript = (n) => String(n) + .split('') + .map((digit) => SUBSCRIPT_DIGITS[Number(digit)]) + .join(''); + +/** + * Collapse the leading run of fractional zeros into subscript notation, + * e.g. "0.0000005195" -> "0.0₆5195". Only that leading run is compressed; every + * later digit is kept verbatim, including trailing zeros ("0.0000005000" -> + * "0.0₆5000"), so the significant digits match the Expanded form. Returns the + * input unchanged when nothing qualifies: value >= 1, integer, zero, or a run + * shorter than COMPRESS_MIN_ZERO_RUN. + * + * @param {string} formatted Amount string from prettyValue + * @return {string} + */ +export const compressAmountString = (formatted) => { + const isNegative = formatted.startsWith('-'); + const unsigned = isNegative ? formatted.slice(1) : formatted; + + const dotIndex = unsigned.indexOf('.'); + if (dotIndex === -1) { + return formatted; + } + + const integerPart = unsigned.slice(0, dotIndex); + const decimalPart = unsigned.slice(dotIndex + 1); + + // Only sub-1 values have a leading-zero run worth compressing. + if (integerPart.replace(/,/g, '') !== '0') { + return formatted; + } + + // Count the leading zero run only (trailing zeros are intentionally preserved). + let leadingZeros = 0; + while (leadingZeros < decimalPart.length && decimalPart[leadingZeros] === '0') { + leadingZeros += 1; + } + + // Nothing significant after the run (value is zero) or the run is too short. + const significant = decimalPart.slice(leadingZeros); + if (significant === '' || leadingZeros < COMPRESS_MIN_ZERO_RUN) { + return formatted; + } + + const sign = isNegative ? '-' : ''; + return `${sign}0.0${toSubscript(leadingZeros)}${significant}`; +}; + +/** + * Render value in a formatted way for display. * * @param {bigint} amount The token amount as BigInt - * @param {boolean} isInteger Whether the token is an NFT or regular token + * @param {boolean} isNFT Whether the token is an NFT (rendered as an integer) + * @param {number} [decimalPlaces] Network decimal places (serverInfo.decimal_places). + * Callers with store access should always pass it: the lib default it falls back + * to is 2, which under-renders every network that uses more precision (and, with + * only two fractional digits, makes the Compressed format a no-op). + * @param {string} [amountFormat] AMOUNT_FORMAT.EXPANDED (default) or COMPRESSED * @return {string} Formatted value for display */ -export const renderValue = (amount, isInteger) => { - if (isInteger) { - return hathorLib.numberUtils.prettyValue(amount, 0); +export const renderValue = ( + amount, + isNFT, + decimalPlaces, + amountFormat = AMOUNT_FORMAT.EXPANDED, +) => { + const formatted = isNFT + ? hathorLib.numberUtils.prettyValue(amount, 0) + : hathorLib.numberUtils.prettyValue(amount, decimalPlaces); + + if (amountFormat === AMOUNT_FORMAT.COMPRESSED) { + return compressAmountString(formatted); } - return hathorLib.numberUtils.prettyValue(amount); + return formatted; }; +/** + * Home display precision rule: cap the shown fractional digits by the value's + * integer magnitude — < 1 -> 8, 1-999 -> 4, 1000-9999 -> 3, >= 10000 -> 2. + * Excess digits are truncated (not rounded, to never overstate a balance); + * shorter fractions are left as-is (no padding). Operates on prettyValue's output + * string, so it composes with compressAmountString. + * + * @param {string} formatted Amount string from prettyValue + * @return {string} + */ +export const capDecimalsByMagnitude = (formatted) => { + const isNegative = formatted.startsWith('-'); + const unsigned = isNegative ? formatted.slice(1) : formatted; + + const dotIndex = unsigned.indexOf('.'); + if (dotIndex === -1) { + return formatted; + } + + const integerPart = unsigned.slice(0, dotIndex); + const decimalPart = unsigned.slice(dotIndex + 1); + const integerDigits = integerPart.replace(/,/g, ''); + + let maxDecimals; + if (integerDigits === '0') { + // Assumes network precision <= 8: on a network with decimal_places > 8, a + // balance below 1e-8 truncates to all-zeros and renders as 0. + maxDecimals = 8; + } else if (integerDigits.length <= 3) { + maxDecimals = 4; + } else if (integerDigits.length === 4) { + maxDecimals = 3; + } else { + maxDecimals = 2; + } + + const capped = decimalPart.slice(0, maxDecimals); + const sign = isNegative ? '-' : ''; + return capped === '' ? `${sign}${integerPart}` : `${sign}${integerPart}.${capped}`; +}; + +/** + * Home value display: format the amount via prettyValue using the network's + * decimal places, cap the shown decimals by magnitude, then apply the Compressed + * subscript notation when selected. + * + * @param {bigint} amount The token amount as BigInt + * @param {boolean} isNFT Whether the token is an NFT (rendered as an integer) + * @param {number} [decimalPlaces] Network decimal places (serverInfo.decimal_places); + * falls back to the lib default when unset + * @param {string} [amountFormat] AMOUNT_FORMAT.EXPANDED (default) or COMPRESSED + * @return {string} + */ +export const renderHomeValue = ( + amount, + isNFT, + decimalPlaces, + amountFormat = AMOUNT_FORMAT.EXPANDED, +) => { + const formatted = isNFT + ? hathorLib.numberUtils.prettyValue(amount, 0) + : hathorLib.numberUtils.prettyValue(amount, decimalPlaces); + const capped = capDecimalsByMagnitude(formatted); + return amountFormat === AMOUNT_FORMAT.COMPRESSED ? compressAmountString(capped) : capped; +}; + +/** + * Coerce a persisted amount-format value to a known enum member, defaulting for + * anything unrecognized (e.g. a corrupted storage read). Guards the hydration + * path, where the value comes from untrusted storage rather than the picker. + * + * @param {unknown} value + * @return {string} A valid AMOUNT_FORMAT value + */ +export const normalizeAmountFormat = (value) => ( + Object.values(AMOUNT_FORMAT).includes(value) ? value : AMOUNT_FORMAT_DEFAULT +); + +/** + * Amount format to render with, gated by the feature flag. + * + * `state.amountFormat` is the user's stored preference; this returns it only + * while the flag is on, else forces Expanded. That stops a remote flag rollback + * from stranding the wallet in Compressed with the Settings entry hidden (no + * in-app way back). Display sites should read this, not state.amountFormat. + * + * @param {Object} state Redux store state + * @return {string} AMOUNT_FORMAT.EXPANDED or AMOUNT_FORMAT.COMPRESSED + */ +export const getDisplayAmountFormat = (state) => ( + state.featureToggles[AMOUNT_FORMAT_FEATURE_TOGGLE] + ? (state.amountFormat ?? AMOUNT_FORMAT_DEFAULT) + : AMOUNT_FORMAT.EXPANDED +); + /** * Format a BigInt amount as a decimal string without thousand separators. * Useful for input fields where thousand separators are not allowed. diff --git a/src/utils/tokenSwap.js b/src/utils/tokenSwap.js index 6b9908539..9645e676d 100644 --- a/src/utils/tokenSwap.js +++ b/src/utils/tokenSwap.js @@ -355,9 +355,10 @@ export function selectTokenSwapContractId(state) { * Render amount and token symbol for UI components. * @param {number|bigint} amount * @param {TokenData} token + * @param {number} [decimalPlaces] Network decimal places (serverInfo.decimal_places) */ -export function renderAmountAndSymbol(amount, token) { - return `${renderValue(amount, false)} ${token.symbol}`; +export function renderAmountAndSymbol(amount, token, decimalPlaces) { + return `${renderValue(amount, false, decimalPlaces)} ${token.symbol}`; } /** @@ -367,10 +368,17 @@ export function renderAmountAndSymbol(amount, token) { * @param {number|bigint} amount * @param {TokenData} token * @param {number} slippage + * @param {number} [decimalPlaces] Network decimal places (serverInfo.decimal_places) */ -export function renderAmountAndSymbolWithSlippage(direction, amount, token, slippage) { +export function renderAmountAndSymbolWithSlippage( + direction, + amount, + token, + slippage, + decimalPlaces, +) { const newAmount = calcAmountWithSlippage(direction, amount, slippage); - return renderAmountAndSymbol(newAmount, token); + return renderAmountAndSymbol(newAmount, token, decimalPlaces); } /** @@ -378,21 +386,25 @@ export function renderAmountAndSymbolWithSlippage(direction, amount, token, slip * @param {TokenSwapQuote} quote * @param {TokenData} inToken * @param {TokenData} outToken + * @param {number} [decimalPlaces] Network decimal places (serverInfo.decimal_places) */ -export function renderConversionRate(quote, inToken, outToken) { +export function renderConversionRate(quote, inToken, outToken, decimalPlaces) { if (!quote) { return null; } + // Reference amount the rate is quoted against: one whole token in the + // network's smallest-unit scale. + const oneToken = 10 ** (decimalPlaces ?? hathorLib.constants.DECIMAL_PLACES); if ((!quote.amount_in) || quote.amount_in === 0) { // Invalid but we should avoid rendering errors - return `${renderAmountAndSymbol(100, outToken)} = ${renderAmountAndSymbol(0, inToken)}`; + return `${renderAmountAndSymbol(oneToken, outToken, decimalPlaces)} = ${renderAmountAndSymbol(0, inToken, decimalPlaces)}`; } - let exchangeRate = (100 * Number(quote.amount_out)) / Number(quote.amount_in); + let exchangeRate = (oneToken * Number(quote.amount_out)) / Number(quote.amount_in); let extraRate = 1; /** * This loop will gradually increase the reference so we do not use fractional rates. - * The cutoff for the reference value is 1000.00 + * The cutoff for the reference is 1000 whole tokens. */ while (!Number.isInteger(exchangeRate)) { if (extraRate === 1000) { @@ -402,7 +414,7 @@ export function renderConversionRate(quote, inToken, outToken) { extraRate *= 10; exchangeRate *= 10; } - return `${renderAmountAndSymbol(exchangeRate, outToken)} = ${renderAmountAndSymbol(100 * extraRate, inToken)}`; + return `${renderAmountAndSymbol(exchangeRate, outToken, decimalPlaces)} = ${renderAmountAndSymbol(oneToken * extraRate, inToken, decimalPlaces)}`; } /** diff --git a/src/workers/pushNotificationHandler.js b/src/workers/pushNotificationHandler.js index 043a84fec..0bffc869d 100644 --- a/src/workers/pushNotificationHandler.js +++ b/src/workers/pushNotificationHandler.js @@ -38,6 +38,11 @@ export function setNotificationError(error) { /** * Render the balance value in decimal format. + * + * Runs in the background message handler, where the Redux store is not + * available, so it cannot pass the network's decimal places and falls back to + * the wallet-lib default. + * * @param {string} tokenBalance * @returns {string} - the rendered balance value with the token symbol. * @example