diff --git a/cashtab/extension/public/manifest.json b/cashtab/extension/public/manifest.json --- a/cashtab/extension/public/manifest.json +++ b/cashtab/extension/public/manifest.json @@ -3,7 +3,7 @@ "name": "Cashtab", "description": "A browser-integrated eCash wallet from Bitcoin ABC", - "version": "3.11.0", + "version": "3.12.0", "content_scripts": [ { "matches": ["file://*/*", "http://*/*", "https://*/*"], diff --git a/cashtab/package-lock.json b/cashtab/package-lock.json --- a/cashtab/package-lock.json +++ b/cashtab/package-lock.json @@ -1,12 +1,12 @@ { "name": "cashtab", - "version": "2.11.2", + "version": "2.12.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "cashtab", - "version": "2.11.2", + "version": "2.12.0", "dependencies": { "@ant-design/icons": "^5.3.0", "@bitgo/utxo-lib": "^9.33.0", diff --git a/cashtab/package.json b/cashtab/package.json --- a/cashtab/package.json +++ b/cashtab/package.json @@ -1,6 +1,6 @@ { "name": "cashtab", - "version": "2.11.2", + "version": "2.12.0", "private": true, "scripts": { "start": "node scripts/start.js", diff --git a/cashtab/src/components/Airdrop/Airdrop.js b/cashtab/src/components/Airdrop/Airdrop.js --- a/cashtab/src/components/Airdrop/Airdrop.js +++ b/cashtab/src/components/Airdrop/Airdrop.js @@ -16,7 +16,7 @@ isValidXecAirdrop, isValidAirdropExclusionArray, } from 'validation'; -import { SidePaddingCtn } from 'components/Common/Atoms'; +import { SidePaddingCtn, SwitchLabel } from 'components/Common/Atoms'; import { getAirdropTx, getEqualAirdropTx } from 'airdrop'; import Communist from 'assets/communist.png'; import { toast } from 'react-toastify'; @@ -47,10 +47,6 @@ text-align: center; justify-content: center; `; -const SwitchLabel = styled.div` - color: ${props => props.theme.contrast}; - font-size: 18px; -`; const Airdrop = ({ passLoadingStatus }) => { const ContextValue = React.useContext(WalletContext); diff --git a/cashtab/src/components/Common/Atoms.js b/cashtab/src/components/Common/Atoms.js --- a/cashtab/src/components/Common/Atoms.js +++ b/cashtab/src/components/Common/Atoms.js @@ -79,3 +79,9 @@ border: solid 1px silver; border-radius: 10px; `; + +export const SwitchLabel = styled.div` + text-align: left; + color: ${props => props.theme.contrast}; + font-size: 18px; +`; diff --git a/cashtab/src/components/Common/BalanceHeaderToken.js b/cashtab/src/components/Common/BalanceHeaderToken.js --- a/cashtab/src/components/Common/BalanceHeaderToken.js +++ b/cashtab/src/components/Common/BalanceHeaderToken.js @@ -4,7 +4,6 @@ import * as React from 'react'; import PropTypes from 'prop-types'; -import { formatTokenBalance } from 'utils/formatting'; import styled from 'styled-components'; const TokenBalance = styled.div` @@ -19,19 +18,23 @@ } `; -const BalanceHeaderToken = ({ balance, ticker, tokenDecimals }) => { +const BalanceHeaderToken = ({ + formattedDecimalizedTokenBalance, + name, + ticker, +}) => { return ( - {formatTokenBalance(balance, tokenDecimals)} {ticker} + {formattedDecimalizedTokenBalance} {name} ({ticker}) ); }; // balance may be a string (XEC balance) or a BigNumber object (token balance) BalanceHeaderToken.propTypes = { - balance: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), + formattedDecimalizedTokenBalance: PropTypes.string, + name: PropTypes.string, ticker: PropTypes.string, - tokenDecimals: PropTypes.number, }; export default BalanceHeaderToken; diff --git a/cashtab/src/components/Common/Modal.js b/cashtab/src/components/Common/Modal.js --- a/cashtab/src/components/Common/Modal.js +++ b/cashtab/src/components/Common/Modal.js @@ -40,7 +40,8 @@ top: 0; left: 0; width: 100%; - height: ${props => props.height - MODAL_HEIGHT_DELTA}px; + height: ${props => + props.showButtons ? props.height - MODAL_HEIGHT_DELTA : props.height}px; overflow: auto; padding: 6px; word-wrap: break-word; @@ -135,7 +136,7 @@ return ( X - + {typeof title !== 'undefined' && ( {title} )} diff --git a/cashtab/src/components/Common/Switch.js b/cashtab/src/components/Common/Switch.js --- a/cashtab/src/components/Common/Switch.js +++ b/cashtab/src/components/Common/Switch.js @@ -6,6 +6,9 @@ import styled from 'styled-components'; import PropTypes from 'prop-types'; +const Container = styled.div` + width: ${props => props.switchWidth}px; +`; const ToggleSwitch = styled.div` position: relative; width: ${props => props.switchWidth}px; @@ -110,28 +113,30 @@ } return ( - - - - + + - - - + name={name} + id={name} + data-testid={name} + /> + + + + + + ); }; diff --git a/cashtab/src/components/Send/SendToken.js b/cashtab/src/components/Send/SendToken.js --- a/cashtab/src/components/Send/SendToken.js +++ b/cashtab/src/components/Send/SendToken.js @@ -5,11 +5,10 @@ import React, { useState, useEffect } from 'react'; import { Link, useParams } from 'react-router-dom'; import { WalletContext } from 'wallet/context'; -import { message, Button } from 'antd'; import PrimaryButton, { SecondaryButton, } from 'components/Common/PrimaryButton'; -import { SidePaddingCtn, TxLink } from 'components/Common/Atoms'; +import { SidePaddingCtn, TxLink, SwitchLabel } from 'components/Common/Atoms'; import BalanceHeaderToken from 'components/Common/BalanceHeaderToken'; import { useNavigate } from 'react-router-dom'; import { BN } from 'slp-mdm'; @@ -44,24 +43,77 @@ import CopyToClipboard from 'components/Common/CopyToClipboard'; import { ThemedCopySolid } from 'components/Common/CustomIcons'; import { decimalizedTokenQtyToLocaleFormat } from 'utils/formatting'; +import Switch from 'components/Common/Switch'; -const TokenStatsTable = styled.div` +const TokenIconExpandButton = styled.button` + cursor: pointer; + border: none; + background-color: transparent; +`; +const SendTokenForm = styled.div` display: flex; flex-direction: column; align-items: center; + justify-content: space-between; + width: 100%; + margin-bottom: 12px; +`; +const SendTokenFormRow = styled.div` + width: 100%; + display: flex; + justify-content: space-between; + gap: 12px; + margin: 3px; +`; + +const TokenStatsTable = styled.div` + display: flex; + flex-wrap: wrap; + align-items: center; justify-content: center; width: 100%; color: ${props => props.theme.contrast}; + gap: 12px; margin-bottom: 12px; `; const TokenStatsRow = styled.div` width: 100%; display: flex; + flex-wrap: wrap; + align-items: center; text-align: center; justify-content: center; gap: 3px; `; -const TokenStatsCol = styled.div``; +const TokenStatsCol = styled.div` + align-items: center; + flex-wrap: wrap; +`; +const TokenStatsTableRow = styled.div` + width: 100%; + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: flex-start; + gap: 3px; +`; + +const TokenStatsLabel = styled.div` + font-weight: bold; + justify-content: flex-end; + text-align: right; + display: flex; + width: 106px; +`; +const SwitchHolder = styled.div` + width: 100%; + display: flex; + justify-content: flex-start; + gap: 12px; + align-content: center; + align-items: center; + margin: 12px; +`; const TokenSentLink = styled.a` color: ${props => props.theme.walletBackground}; @@ -112,6 +164,10 @@ const [confirmationOfEtokenToBeBurnt, setConfirmationOfEtokenToBeBurnt] = useState(''); const [aliasInputAddress, setAliasInputAddress] = useState(false); + const [showSend, setShowSend] = useState(true); + const [showBurn, setShowBurn] = useState(false); + const [showAirdrop, setShowAirdrop] = useState(false); + const [showLargeIconModal, setShowLargeIconModal] = useState(false); // Load with QR code open if device is mobile const openWithScanner = @@ -339,9 +395,6 @@ } catch (err) { console.error(`Error in onMax:`); console.error(err); - message.error( - 'Unable to calculate the max value due to network errors', - ); } }; @@ -474,6 +527,17 @@ {tokenBalance && typeof cashtabCache.tokens.get(tokenId) !== 'undefined' && ( + {showLargeIconModal && ( + + setShowLargeIconModal(false) + } + > + + + )} {isModalVisible && ( )} - - - - - - - - - - - Token Id: {tokenId.slice(0, 3)}... - {tokenId.slice(-3)} - - - - - - - - - - {decimals} decimal places - - - {url} - - Minted{' '} - {typeof cachedInfo.block !== 'undefined' - ? formatDate( - cachedInfo.block.timestamp, - navigator.language, - ) - : formatDate( - cachedInfo.timeFirstSeen, - navigator.language, - )} - - - Genesis Supply:{' '} - {decimalizedTokenQtyToLocaleFormat( - genesisSupply, - userLocale, + + + setShowLargeIconModal(true)} + > + + + + + + Token Id: + + + {tokenId.slice(0, 3)}... + {tokenId.slice(-3)} + + + + + + + + + + decimals: + {decimals} + + {url && url.startsWith('https://') && ( + + url: + + + {`${url.slice(8, 19)}...`} + + + )} - - - {genesisMintBatons === 0 - ? 'Fixed Supply' - : 'Variable Supply'} - + + created: + + {typeof cachedInfo.block !== 'undefined' + ? formatDate( + cachedInfo.block.timestamp, + navigator.language, + ) + : formatDate( + cachedInfo.timeFirstSeen, + navigator.language, + )} + + + + + Genesis Qty: + + + {decimalizedTokenQtyToLocaleFormat( + genesisSupply, + userLocale, + )} + + + + Supply: + + {genesisMintBatons === 0 + ? 'Fixed' + : 'Variable'} + + + - - - - {aliasInputAddress && - `${aliasInputAddress.slice( - 0, - 10, - )}...${aliasInputAddress.slice(-5)}`} - - -
- - - - checkForConfirmationBeforeSendEtoken() - } - > - Send {tokenName} - {apiError && } - - - + + { + if (!showSend) { + // If showSend is being set to true here, make sure burn and airdrop are false + setShowAirdrop(false); + setShowBurn(false); + } + setShowSend(!showSend); }} - > - + + Send {tokenName} ({tokenTicker}) + + + {showSend && ( + <> + + + + + + + {aliasInputAddress && + `${aliasInputAddress.slice( + 0, + 10, + )}...${aliasInputAddress.slice( + -5, + )}`} + + + + + + + + + checkForConfirmationBeforeSendEtoken() + } + > + Send {tokenTicker} + + + + )} + + { + if (!showAirdrop) { + // If showAirdrop is being set to true here, make sure burn and send are false + setShowBurn(false); + setShowSend(false); + } + setShowAirdrop(!showAirdrop); + }} + /> + + Airdrop XEC to {tokenTicker} holders + + + {showAirdrop && ( + + - Airdrop - - - - - - + Airdrop Calculator + + + + )} + + { + if (!showBurn) { + // If showBurn is being set to true here, make sure airdrop and send are false + setShowAirdrop(false); + setShowSend(false); } - handleOnMax={onMaxBurn} - /> + setShowBurn(!showBurn); + }} + /> + Burn {tokenTicker} + + {showBurn && ( + + + - - - - + + Burn {tokenTicker} + + + + )} +
)} diff --git a/cashtab/src/components/Send/__tests__/SendToken.test.js b/cashtab/src/components/Send/__tests__/SendToken.test.js --- a/cashtab/src/components/Send/__tests__/SendToken.test.js +++ b/cashtab/src/components/Send/__tests__/SendToken.test.js @@ -493,12 +493,17 @@ // Wait for element to get token info and load expect((await screen.findAllByText(/BEAR/))[0]).toBeInTheDocument(); - // Click the Burn button - // Note we button title is the token ticker - await user.click(await screen.findByRole('button', { name: /Burn/ })); + // Click the burn switch to show the burn interface + await user.click(screen.getByTestId('burn-switch')); await user.type(screen.getByPlaceholderText('Burn Amount'), '1'); + // Click the Burn button + // Note we button title is the token ticker + await user.click( + await screen.findByRole('button', { name: /Burn BEAR/ }), + ); + // We see a modal and enter the correct confirmation msg await user.type( screen.getByPlaceholderText(`Type "burn BEAR" to confirm`), diff --git a/cashtab/src/utils/__tests__/formatting.test.js b/cashtab/src/utils/__tests__/formatting.test.js --- a/cashtab/src/utils/__tests__/formatting.test.js +++ b/cashtab/src/utils/__tests__/formatting.test.js @@ -2,12 +2,10 @@ // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. -import { BN } from 'slp-mdm'; import { formatDate, formatFiatBalance, formatBalance, - formatTokenBalance, decimalizedTokenQtyToLocaleFormat, } from 'utils/formatting'; import vectors from 'utils/fixtures/vectors'; @@ -109,25 +107,6 @@ it(`test formatFiatBalance with undefined input`, () => { expect(formatFiatBalance(undefined, 'en-US')).toBe(undefined); }); - it(`returns undefined formatTokenBalance with undefined inputs`, () => { - expect(formatTokenBalance(undefined, undefined)).toBe(undefined); - }); - it(`test formatTokenBalance with valid balance & decimal inputs`, () => { - const testBalance = new BN(100.00000001); - expect(formatTokenBalance(testBalance, 8)).toBe('100.00000001'); - }); - it(`returns undefined when passed invalid decimals parameter`, () => { - const testBalance = new BN(100.00000001); - expect(formatTokenBalance(testBalance, 'cheese')).toBe(undefined); - }); - it(`returns undefined when passed invalid balance parameter`, () => { - const testBalance = '100.000010122'; - expect(formatTokenBalance(testBalance, 9)).toBe(undefined); - }); - it(`maintains trailing zeros in balance per tokenDecimal parameter`, () => { - const testBalance = new BN(10000); - expect(formatTokenBalance(testBalance, 8)).toBe('10,000.00000000'); - }); describe('We can format decimalized token strings for userLocale', () => { const { expectedReturns } = vectors.decimalizedTokenQtyToLocaleFormat; expectedReturns.forEach(vector => { diff --git a/cashtab/src/utils/formatting.js b/cashtab/src/utils/formatting.js --- a/cashtab/src/utils/formatting.js +++ b/cashtab/src/utils/formatting.js @@ -2,7 +2,6 @@ // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. -import { BN } from 'slp-mdm'; import appConfig from 'config/app'; export const formatDate = (dateString, userLocale = 'en') => { const options = { month: 'short', day: 'numeric', year: 'numeric' }; @@ -55,47 +54,6 @@ } }; -// unformattedBalance will always be a BigNumber, tokenDecimal will always be a number -export const formatTokenBalance = ( - unformattedBalance, - tokenDecimal, - defaultLocale = 'en', -) => { - let formattedTokenBalance; - let convertedTokenBalance; - let locale = defaultLocale; - try { - if ( - tokenDecimal === undefined || - unformattedBalance === undefined || - typeof tokenDecimal !== 'number' || - !BN.isBigNumber(unformattedBalance) - ) { - return undefined; - } - if (navigator && navigator.language) { - locale = navigator.language; - } - - // Use toFixed to get a string with the correct decimal places - formattedTokenBalance = new BN(unformattedBalance).toFixed( - tokenDecimal, - ); - // formattedTokenBalance is converted into a number as toLocaleString does not work with a string - convertedTokenBalance = parseFloat( - formattedTokenBalance, - ).toLocaleString(locale, { - minimumFractionDigits: tokenDecimal, - }); - - return convertedTokenBalance; - } catch (err) { - console.error(`Error in formatTokenBalance for ${unformattedBalance}`); - console.error(err); - return unformattedBalance; - } -}; - /** * Add locale number formatting to a decimalized token quantity * @param {string} decimalizedTokenQty e.g. 100.123