diff --git a/web/cashtab/src/components/Common/ApiError.js b/web/cashtab/src/components/Common/ApiError.js index 0e991dbd6..3a9b6f13e 100644 --- a/web/cashtab/src/components/Common/ApiError.js +++ b/web/cashtab/src/components/Common/ApiError.js @@ -1,15 +1,17 @@ import * as React from 'react'; import { CashLoader } from '@components/Common/CustomIcons'; import { AlertMsg } from '@components/Common/Atoms'; -export const ApiError = () => { +const ApiError = () => { return ( <> API connection lost.
Re-establishing connection...
); }; + +export default ApiError; diff --git a/web/cashtab/src/components/Common/BalanceHeader.js b/web/cashtab/src/components/Common/BalanceHeader.js index a59db76db..dfb7e5282 100644 --- a/web/cashtab/src/components/Common/BalanceHeader.js +++ b/web/cashtab/src/components/Common/BalanceHeader.js @@ -1,11 +1,20 @@ import * as React from 'react'; +import PropTypes from 'prop-types'; import { formatBalance } from '@utils/cashMethods'; import { BalanceHeaderWrap } from '@components/Common/Atoms'; -export const BalanceHeader = ({ balance, ticker }) => { +const BalanceHeader = ({ balance, ticker }) => { return ( {formatBalance(balance)} {ticker} ); }; + +// balance may be a number (XEC balance) or a BigNumber object (token balance) +BalanceHeader.propTypes = { + balance: PropTypes.oneOfType([PropTypes.number, PropTypes.object]), + ticker: PropTypes.string, +}; + +export default BalanceHeader; diff --git a/web/cashtab/src/components/Common/BalanceHeaderFiat.js b/web/cashtab/src/components/Common/BalanceHeaderFiat.js index 84cd32688..9fa83c4b8 100644 --- a/web/cashtab/src/components/Common/BalanceHeaderFiat.js +++ b/web/cashtab/src/components/Common/BalanceHeaderFiat.js @@ -1,19 +1,28 @@ import * as React from 'react'; +import PropTypes from 'prop-types'; import { BalanceHeaderFiatWrap } from '@components/Common/Atoms'; import { currency } from '@components/Common/Ticker.js'; -export const BalanceHeaderFiat = ({ balance, settings, fiatPrice }) => { +const BalanceHeaderFiat = ({ balance, settings, fiatPrice }) => { return ( {settings ? `${currency.fiatCurrencies[settings.fiatCurrency].symbol} ` : '$ '} {parseFloat((balance * fiatPrice).toFixed(2)).toLocaleString()}{' '} {settings ? `${currency.fiatCurrencies[ settings.fiatCurrency ].slug.toUpperCase()} ` : 'USD'} ); }; + +BalanceHeaderFiat.propTypes = { + balance: PropTypes.number, + settings: PropTypes.object, + fiatPrice: PropTypes.number, +}; + +export default BalanceHeaderFiat; diff --git a/web/cashtab/src/components/Common/CustomIcons.js b/web/cashtab/src/components/Common/CustomIcons.js index 851e44d91..82ea5202c 100644 --- a/web/cashtab/src/components/Common/CustomIcons.js +++ b/web/cashtab/src/components/Common/CustomIcons.js @@ -1,58 +1,58 @@ import * as React from 'react'; import styled from 'styled-components'; import { CopyOutlined, DollarOutlined, LoadingOutlined, WalletOutlined, QrcodeOutlined, } from '@ant-design/icons'; -import { Avatar, Image } from 'antd'; +import { Image } from 'antd'; import { currency } from '@components/Common/Ticker'; export const CashLoadingIcon = ; export const CashReceivedNotificationIcon = () => ( ); export const TokenReceivedNotificationIcon = () => ( ); export const ThemedCopyOutlined = styled(CopyOutlined)` color: ${props => props.theme.icons.outlined} !important; `; export const ThemedDollarOutlined = styled(DollarOutlined)` color: ${props => props.theme.icons.outlined} !important; `; export const ThemedWalletOutlined = styled(WalletOutlined)` color: ${props => props.theme.icons.outlined} !important; `; export const ThemedQrcodeOutlined = styled(QrcodeOutlined)` color: ${props => props.theme.icons.outlined} !important; `; export const LoadingBlock = styled.div` width: 100%; display: flex; align-items: center; justify-content: center; padding: 24px; flex-direction: column; svg { width: 50px; height: 50px; fill: ${props => props.theme.primary}; } `; export const CashLoader = () => ( ); diff --git a/web/cashtab/src/components/Common/EnhancedInputs.js b/web/cashtab/src/components/Common/EnhancedInputs.js index 2bba5a13c..cd3667f42 100644 --- a/web/cashtab/src/components/Common/EnhancedInputs.js +++ b/web/cashtab/src/components/Common/EnhancedInputs.js @@ -1,311 +1,330 @@ import * as React from 'react'; +import PropTypes from 'prop-types'; import { Form, Input, Select } from 'antd'; import { ThemedDollarOutlined, ThemedWalletOutlined, } from '@components/Common/CustomIcons'; import styled, { css } from 'styled-components'; -import { ScanQRCode } from './ScanQRCode'; +import ScanQRCode from './ScanQRCode'; import useBCH from '@hooks/useBCH'; import { currency } from '@components/Common/Ticker.js'; export const AntdFormCss = css` .ant-input-group-addon { background-color: ${props => props.theme.forms.addonBackground} !important; border: 1px solid ${props => props.theme.forms.border}; color: ${props => props.theme.forms.addonForeground} !important; } input.ant-input, .ant-select-selection { background-color: ${props => props.theme.forms.selectionBackground} !important; box-shadow: none !important; border-radius: 4px; font-weight: bold; color: ${props => props.theme.forms.text}; opacity: 1; height: 50px; } .ant-input-affix-wrapper { background-color: ${props => props.theme.forms.selectionBackground}; border: 1px solid ${props => props.theme.wallet.borders.color} !important; } .ant-select-selector { height: 60px !important; border: 1px solid ${props => props.theme.wallet.borders.color} !important; } .ant-form-item-has-error > div > div.ant-form-item-control-input > div > span > span > span.ant-input-affix-wrapper { background-color: ${props => props.theme.forms.selectionBackground}; border-color: ${props => props.theme.forms.error} !important; } .ant-form-item-has-error .ant-input, .ant-form-item-has-error .ant-input-affix-wrapper, .ant-form-item-has-error .ant-input:hover, .ant-form-item-has-error .ant-input-affix-wrapper:hover { background-color: ${props => props.theme.forms.selectionBackground}; border-color: ${props => props.theme.forms.error} !important; } .ant-form-item-has-error .ant-select:not(.ant-select-disabled):not(.ant-select-customize-input) .ant-select-selector { background-color: ${props => props.theme.forms.selectionBackground}; border-color: ${props => props.theme.forms.error} !important; } .ant-select-single .ant-select-selector .ant-select-selection-item, .ant-select-single .ant-select-selector .ant-select-selection-placeholder { line-height: 60px; text-align: left; color: ${props => props.theme.forms.text}; font-weight: bold; } .ant-form-item-has-error .ant-input-group-addon { color: ${props => props.theme.forms.error} !important; border-color: ${props => props.theme.forms.error} !important; } .ant-form-item-explain.ant-form-item-explain-error { color: ${props => props.theme.forms.error} !important; } `; export const AntdFormWrapper = styled.div` ${AntdFormCss} `; export const InputAddonText = styled.span` width: 100%; height: 100%; display: block; ${props => props.disabled ? ` cursor: not-allowed; ` : `cursor: pointer;`} `; export const InputNumberAddonText = styled.span` background-color: ${props => props.theme.forms.addonBackground} !important; border: 1px solid ${props => props.theme.forms.border}; color: ${props => props.theme.forms.addonForeground} !important; height: 50px; line-height: 47px; * { color: ${props => props.theme.forms.addonForeground} !important; } ${props => props.disabled ? ` cursor: not-allowed; ` : `cursor: pointer;`} `; export const SendBchInput = ({ onMax, inputProps, selectProps, activeFiatCode, ...otherProps }) => { const { Option } = Select; const currencies = [ { value: currency.ticker, label: currency.ticker, }, { value: activeFiatCode ? activeFiatCode : 'USD', label: activeFiatCode ? activeFiatCode : 'USD', }, ]; const currencyOptions = currencies.map(currency => { return ( ); }); const CurrencySelect = ( ); return ( ) : ( ) } {...inputProps} /> {CurrencySelect} max ); }; +SendBchInput.propTypes = { + onMax: PropTypes.func, + inputProps: PropTypes.object, + selectProps: PropTypes.object, + activeFiatCode: PropTypes.string, +}; + export const FormItemWithMaxAddon = ({ onMax, inputProps, ...otherProps }) => { return ( } addonAfter={ max } {...inputProps} /> ); }; +FormItemWithMaxAddon.propTypes = { + onMax: PropTypes.func, + inputProps: PropTypes.object, +}; + // loadWithCameraOpen prop: if true, load page with camera scanning open export const FormItemWithQRCodeAddon = ({ onScan, loadWithCameraOpen, inputProps, ...otherProps }) => { return ( } autoComplete="off" addonAfter={ } {...inputProps} /> ); }; +FormItemWithQRCodeAddon.propTypes = { + onScan: PropTypes.func, + loadWithCameraOpen: PropTypes.bool, + inputProps: PropTypes.object, +}; + export const CurrencySelectDropdown = selectProps => { const { Option } = Select; // Build select dropdown from currency.fiatCurrencies const currencyMenuOptions = []; const currencyKeys = Object.keys(currency.fiatCurrencies); for (let i = 0; i < currencyKeys.length; i += 1) { const currencyMenuOption = {}; currencyMenuOption.value = currency.fiatCurrencies[currencyKeys[i]].slug; currencyMenuOption.label = `${ currency.fiatCurrencies[currencyKeys[i]].name } (${currency.fiatCurrencies[currencyKeys[i]].symbol})`; currencyMenuOptions.push(currencyMenuOption); } const currencyOptions = currencyMenuOptions.map(currencyMenuOption => { return ( ); }); return ( ); }; export const AddressValidators = () => { const { BCH } = useBCH(); return { safelyDetectAddressFormat: value => { try { return BCH.Address.detectAddressFormat(value); } catch (error) { return null; } }, isSLPAddress: value => AddressValidators.safelyDetectAddressFormat(value) === 'slpaddr', isBCHAddress: value => AddressValidators.safelyDetectAddressFormat(value) === 'cashaddr', isLegacyAddress: value => AddressValidators.safelyDetectAddressFormat(value) === 'legacy', }(); }; diff --git a/web/cashtab/src/components/Common/ScanQRCode.js b/web/cashtab/src/components/Common/ScanQRCode.js index 33eaf4acd..a0d19196a 100644 --- a/web/cashtab/src/components/Common/ScanQRCode.js +++ b/web/cashtab/src/components/Common/ScanQRCode.js @@ -1,170 +1,177 @@ import React, { useState } from 'react'; +import PropTypes from 'prop-types'; import { Alert, Modal } from 'antd'; import { ThemedQrcodeOutlined } from '@components/Common/CustomIcons'; import styled from 'styled-components'; import { BrowserQRCodeReader } from '@zxing/library'; import { currency, isValidCashPrefix, isValidTokenPrefix, } from '@components/Common/Ticker.js'; import { Event } from '@utils/GoogleAnalytics'; const StyledScanQRCode = styled.span` display: block; `; const StyledModal = styled(Modal)` width: 400px !important; height: 400px !important; .ant-modal-close { top: 0 !important; right: 0 !important; } `; const QRPreview = styled.video` width: 100%; `; -export const ScanQRCode = ({ - width, +const ScanQRCode = ({ loadWithCameraOpen, onScan = () => null, ...otherProps }) => { const [visible, setVisible] = useState(loadWithCameraOpen); const [error, setError] = useState(false); // Use these states to debug video errors on mobile // Note: iOS chrome/brave/firefox does not support accessing camera, will throw error // iOS users can use safari // todo only show scanner with safari //const [mobileError, setMobileError] = useState(false); //const [mobileErrorMsg, setMobileErrorMsg] = useState(false); const [activeCodeReader, setActiveCodeReader] = useState(null); const teardownCodeReader = codeReader => { if (codeReader !== null) { codeReader.reset(); codeReader.stop(); codeReader = null; setActiveCodeReader(codeReader); } }; const parseContent = content => { let type = 'unknown'; let values = {}; // If what scanner reads from QR code begins with 'bitcoincash:' or 'simpleledger:' or their successor prefixes if (isValidCashPrefix(content) || isValidTokenPrefix(content)) { type = 'address'; values = { address: content }; // Event("Category", "Action", "Label") // Track number of successful QR code scans // BCH or slp? let eventLabel = currency.ticker; const isToken = content.split(currency.tokenPrefix).length > 1; if (isToken) { eventLabel = currency.tokenTicker; } Event('ScanQRCode.js', 'Address Scanned', eventLabel); } return { type, values }; }; const scanForQrCode = async () => { const codeReader = new BrowserQRCodeReader(); setActiveCodeReader(codeReader); try { // Need to execute this before you can decode input // eslint-disable-next-line no-unused-vars const videoInputDevices = await codeReader.getVideoInputDevices(); //console.log(`videoInputDevices`, videoInputDevices); //setMobileError(JSON.stringify(videoInputDevices)); // choose your media device (webcam, frontal camera, back camera, etc.) // TODO implement if necessary //const selectedDeviceId = videoInputDevices[0].deviceId; //const previewElem = document.querySelector("#test-area-qr-code-webcam"); const content = await codeReader.decodeFromInputVideoDevice( undefined, 'test-area-qr-code-webcam', ); const result = parseContent(content.text); // stop scanning and fill form if it's an address if (result.type === 'address') { // Hide the scanner setVisible(false); onScan(result.values.address); return teardownCodeReader(codeReader); } } catch (err) { console.log(`Error in QR scanner:`); console.log(err); console.log(JSON.stringify(err.message)); //setMobileErrorMsg(JSON.stringify(err.message)); setError(err); teardownCodeReader(codeReader); } // stop scanning after 20s no matter what }; React.useEffect(() => { if (!visible) { setError(false); // Stop the camera if user closes modal if (activeCodeReader !== null) { teardownCodeReader(activeCodeReader); } } else { scanForQrCode(); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [visible]); return ( <> setVisible(!visible)} > setVisible(false)} footer={null} > {visible ? (
{error ? ( <> {/*

{mobileError}

{mobileErrorMsg}

*/} ) : ( )}
) : null}
); }; + +ScanQRCode.propTypes = { + loadWithCameraOpen: PropTypes.bool, + onScan: PropTypes.func, +}; + +export default ScanQRCode; diff --git a/web/cashtab/src/components/Common/WalletLabel.js b/web/cashtab/src/components/Common/WalletLabel.js index 475e8e480..93e9b4481 100644 --- a/web/cashtab/src/components/Common/WalletLabel.js +++ b/web/cashtab/src/components/Common/WalletLabel.js @@ -1,25 +1,30 @@ import * as React from 'react'; +import PropTypes from 'prop-types'; import styled from 'styled-components'; const WalletName = styled.h4` font-size: 20px; font-weight: bold; display: inline-block; color: ${props => props.theme.primary}; margin-bottom: 0px; @media (max-width: 400px) { font-size: 16px; } `; const WalletLabel = ({ name }) => { return ( <> {name && typeof name === 'string' && ( {name} )} ); }; +WalletLabel.propTypes = { + name: PropTypes.string, +}; + export default WalletLabel; diff --git a/web/cashtab/src/components/Configure/Configure.js b/web/cashtab/src/components/Configure/Configure.js index 0d5fb447f..99ba1e7ac 100644 --- a/web/cashtab/src/components/Configure/Configure.js +++ b/web/cashtab/src/components/Configure/Configure.js @@ -1,605 +1,605 @@ /* eslint-disable react-hooks/exhaustive-deps */ import React, { useState, useEffect } from 'react'; import styled from 'styled-components'; import { Collapse, Form, Input, Modal, Alert } from 'antd'; import { PlusSquareOutlined, WalletFilled, ImportOutlined, LockOutlined, } from '@ant-design/icons'; import { WalletContext } from '@utils/context'; import { StyledCollapse } from '@components/Common/StyledCollapse'; import { AntdFormWrapper, CurrencySelectDropdown, } from '@components/Common/EnhancedInputs'; import PrimaryButton, { SecondaryButton, SmartButton, } from '@components/Common/PrimaryButton'; import { ThemedCopyOutlined, ThemedWalletOutlined, ThemedDollarOutlined, } from '@components/Common/CustomIcons'; import { ReactComponent as Trashcan } from '@assets/trashcan.svg'; import { ReactComponent as Edit } from '@assets/edit.svg'; import { Event } from '@utils/GoogleAnalytics'; -import { ApiError } from '@components/Common/ApiError'; +import ApiError from '@components/Common/ApiError'; const { Panel } = Collapse; const SettingsLink = styled.a` text-decoration: underline; color: ${props => props.theme.primary}; :visited { text-decoration: underline; color: ${props => props.theme.primary}; } :hover { color: ${props => props.theme.brandSecondary}; } `; const SWRow = styled.div` border-radius: 3px; padding: 10px 0; display: flex; align-items: center; justify-content: center; margin-bottom: 6px; @media (max-width: 500px) { flex-direction: column; margin-bottom: 12px; } `; const SWName = styled.div` width: 50%; display: flex; align-items: center; justify-content: space-between; word-wrap: break-word; hyphens: auto; @media (max-width: 500px) { width: 100%; justify-content: center; margin-bottom: 15px; } h3 { font-size: 16px; color: ${props => props.theme.wallet.text.secondary}; margin: 0; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } `; const SWButtonCtn = styled.div` width: 50%; display: flex; align-items: center; justify-content: flex-end; @media (max-width: 500px) { width: 100%; justify-content: center; } button { cursor: pointer; @media (max-width: 768px) { font-size: 14px; } } svg { stroke: ${props => props.theme.wallet.text.secondary}; fill: ${props => props.theme.wallet.text.secondary}; width: 25px; height: 25px; margin-right: 20px; cursor: pointer; :first-child:hover { stroke: ${props => props.theme.primary}; fill: ${props => props.theme.primary}; } :hover { stroke: ${props => props.theme.settings.delete}; fill: ${props => props.theme.settings.delete}; } } `; const AWRow = styled.div` padding: 10px 0; display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; h3 { font-size: 16px; display: inline-block; color: ${props => props.theme.wallet.text.secondary}; margin: 0; text-align: left; font-weight: bold; @media (max-width: 500px) { font-size: 14px; } } h4 { font-size: 16px; display: inline-block; color: ${props => props.theme.primary} !important; margin: 0; text-align: right; } @media (max-width: 500px) { flex-direction: column; margin-bottom: 12px; } `; const StyledConfigure = styled.div` h2 { color: ${props => props.theme.wallet.text.primary}; font-size: 25px; } p { color: ${props => props.theme.wallet.text.secondary}; } `; const StyledSpacer = styled.div` height: 1px; width: 100%; background-color: ${props => props.theme.wallet.borders.color}; margin: 60px 0 50px; `; const Configure = () => { const ContextValue = React.useContext(WalletContext); const { wallet, apiError } = ContextValue; const { addNewSavedWallet, activateWallet, renameWallet, deleteWallet, validateMnemonic, getSavedWallets, cashtabSettings, changeCashtabSettings, } = ContextValue; const [savedWallets, setSavedWallets] = useState([]); const [formData, setFormData] = useState({ dirty: true, mnemonic: '', }); const [showRenameWalletModal, setShowRenameWalletModal] = useState(false); const [showDeleteWalletModal, setShowDeleteWalletModal] = useState(false); const [walletToBeRenamed, setWalletToBeRenamed] = useState(null); const [walletToBeDeleted, setWalletToBeDeleted] = useState(null); const [newWalletName, setNewWalletName] = useState(''); const [ confirmationOfWalletToBeDeleted, setConfirmationOfWalletToBeDeleted, ] = useState(''); const [newWalletNameIsValid, setNewWalletNameIsValid] = useState(null); const [walletDeleteValid, setWalletDeleteValid] = useState(null); const [seedInput, openSeedInput] = useState(false); const showPopulatedDeleteWalletModal = walletInfo => { setWalletToBeDeleted(walletInfo); setShowDeleteWalletModal(true); }; const showPopulatedRenameWalletModal = walletInfo => { setWalletToBeRenamed(walletInfo); setShowRenameWalletModal(true); }; const cancelRenameWallet = () => { // Delete form value setNewWalletName(''); setShowRenameWalletModal(false); }; const cancelDeleteWallet = () => { setWalletToBeDeleted(null); setConfirmationOfWalletToBeDeleted(''); setShowDeleteWalletModal(false); }; const updateSavedWallets = async activeWallet => { if (activeWallet) { let savedWallets; try { savedWallets = await getSavedWallets(activeWallet); setSavedWallets(savedWallets); } catch (err) { console.log(`Error in getSavedWallets()`); console.log(err); } } }; const [isValidMnemonic, setIsValidMnemonic] = useState(null); useEffect(() => { // Update savedWallets every time the active wallet changes updateSavedWallets(wallet); }, [wallet]); // Need this function to ensure that savedWallets are updated on new wallet creation const updateSavedWalletsOnCreate = async importMnemonic => { // Event("Category", "Action", "Label") // Track number of times a different wallet is activated Event('Configure.js', 'Create Wallet', 'New'); const walletAdded = await addNewSavedWallet(importMnemonic); if (!walletAdded) { Modal.error({ title: 'This wallet already exists!', content: 'Wallet not added', }); } else { Modal.success({ content: 'Wallet added to your saved wallets', }); } await updateSavedWallets(wallet); }; // Same here // TODO you need to lock UI here until this is complete // Otherwise user may try to load an already-loading wallet, wreak havoc with indexedDB const updateSavedWalletsOnLoad = async walletToActivate => { // Event("Category", "Action", "Label") // Track number of times a different wallet is activated Event('Configure.js', 'Activate', ''); await activateWallet(walletToActivate); }; async function submit() { setFormData({ ...formData, dirty: false, }); // Exit if no user input if (!formData.mnemonic) { return; } // Exit if mnemonic is invalid if (!isValidMnemonic) { return; } // Event("Category", "Action", "Label") // Track number of times a different wallet is activated Event('Configure.js', 'Create Wallet', 'Imported'); updateSavedWalletsOnCreate(formData.mnemonic); } const handleChange = e => { const { value, name } = e.target; // Validate mnemonic on change // Import button should be disabled unless mnemonic is valid setIsValidMnemonic(validateMnemonic(value)); setFormData(p => ({ ...p, [name]: value })); }; const changeWalletName = async () => { if (newWalletName === '' || newWalletName.length > 24) { setNewWalletNameIsValid(false); return; } // Hide modal setShowRenameWalletModal(false); // Change wallet name console.log( `Changing wallet ${walletToBeRenamed.name} name to ${newWalletName}`, ); const renameSuccess = await renameWallet( walletToBeRenamed.name, newWalletName, ); if (renameSuccess) { Modal.success({ content: `Wallet "${walletToBeRenamed.name}" renamed to "${newWalletName}"`, }); } else { Modal.error({ content: `Rename failed. All wallets must have a unique name.`, }); } await updateSavedWallets(wallet); // Clear wallet name for form setNewWalletName(''); }; const deleteSelectedWallet = async () => { if (!walletDeleteValid && walletDeleteValid !== null) { return; } if ( confirmationOfWalletToBeDeleted !== `delete ${walletToBeDeleted.name}` ) { setWalletDeleteValid(false); return; } // Hide modal setShowDeleteWalletModal(false); // Change wallet name console.log(`Deleting wallet "${walletToBeDeleted.name}"`); const walletDeletedSuccess = await deleteWallet(walletToBeDeleted); if (walletDeletedSuccess) { Modal.success({ content: `Wallet "${walletToBeDeleted.name}" successfully deleted`, }); } else { Modal.error({ content: `Error deleting ${walletToBeDeleted.name}.`, }); } await updateSavedWallets(wallet); // Clear wallet delete confirmation from form setConfirmationOfWalletToBeDeleted(''); }; const handleWalletNameInput = e => { const { value } = e.target; // validation if (value && value.length && value.length < 24) { setNewWalletNameIsValid(true); } else { setNewWalletNameIsValid(false); } setNewWalletName(value); }; const handleWalletToDeleteInput = e => { const { value } = e.target; if (value && value === `delete ${walletToBeDeleted.name}`) { setWalletDeleteValid(true); } else { setWalletDeleteValid(false); } setConfirmationOfWalletToBeDeleted(value); }; return ( {walletToBeRenamed !== null && ( cancelRenameWallet()} >
} placeholder="Enter new wallet name" name="newName" value={newWalletName} onChange={e => handleWalletNameInput(e)} />
)} {walletToBeDeleted !== null && ( cancelDeleteWallet()} >
} placeholder={`Type "delete ${walletToBeDeleted.name}" to confirm`} name="walletToBeDeletedInput" value={confirmationOfWalletToBeDeleted} onChange={e => handleWalletToDeleteInput(e)} />
)}

Backup your wallet

{wallet && wallet.mnemonic && (

{wallet && wallet.mnemonic ? wallet.mnemonic : ''}

)}

Manage Wallets

{apiError ? ( ) : ( <> updateSavedWalletsOnCreate()}> New Wallet openSeedInput(!seedInput)}> Import Wallet {seedInput && ( <>

Copy and paste your mnemonic seed phrase below to import an existing wallet

} type="email" placeholder="mnemonic (seed phrase)" name="mnemonic" autoComplete="off" onChange={e => handleChange(e)} required /> submit()} > Import
)} )} {savedWallets && savedWallets.length > 0 && ( <>

{wallet.name}

Currently active

{savedWallets.map(sw => (

{sw.name}

showPopulatedRenameWalletModal( sw, ) } /> showPopulatedDeleteWalletModal( sw, ) } />
))}
)}

Fiat Currency

changeCashtabSettings('fiatCurrency', fiatCode) } /> [ Documentation ]
); }; export default Configure; diff --git a/web/cashtab/src/components/OnBoarding/OnBoarding.js b/web/cashtab/src/components/OnBoarding/OnBoarding.js index 5b28e7390..09b0df2c6 100644 --- a/web/cashtab/src/components/OnBoarding/OnBoarding.js +++ b/web/cashtab/src/components/OnBoarding/OnBoarding.js @@ -1,153 +1,155 @@ import React, { useState } from 'react'; import styled from 'styled-components'; import { WalletContext } from '@utils/context'; import { Input, Form, Modal } from 'antd'; import { AntdFormWrapper } from '@components/Common/EnhancedInputs'; import { ExclamationCircleOutlined, PlusSquareOutlined, ImportOutlined, LockOutlined, } from '@ant-design/icons'; import PrimaryButton, { SecondaryButton, SmartButton, } from '@components/Common/PrimaryButton'; import { currency } from '@components/Common/Ticker.js'; import { Event } from '@utils/GoogleAnalytics'; export const WelcomeText = styled.p` color: ${props => props.theme.wallet.text.secondary}; width: 100%; font-size: 16px; margin-bottom: 60px; text-align: left; `; export const WelcomeLink = styled.a` text-decoration: underline; color: ${props => props.theme.primary}; `; -export const OnBoarding = ({ history }) => { +const OnBoarding = () => { const ContextValue = React.useContext(WalletContext); const { createWallet, validateMnemonic } = ContextValue; const [formData, setFormData] = useState({ dirty: true, mnemonic: '', }); const [seedInput, openSeedInput] = useState(false); const [isValidMnemonic, setIsValidMnemonic] = useState(false); const { confirm } = Modal; async function submit() { setFormData({ ...formData, dirty: false, }); if (!formData.mnemonic) { return; } // Event("Category", "Action", "Label") // Track number of created wallets from onboarding Event('Onboarding.js', 'Create Wallet', 'Imported'); createWallet(formData.mnemonic); } const handleChange = e => { const { value, name } = e.target; // Validate mnemonic on change // Import button should be disabled unless mnemonic is valid setIsValidMnemonic(validateMnemonic(value)); setFormData(p => ({ ...p, [name]: value })); }; function showBackupConfirmModal() { confirm({ title: "Don't forget to back up your wallet", icon: , content: `Once your wallet is created you can back it up by writing down your 12-word seed. You can find your seed on the Settings page. If you are browsing in Incognito mode or if you clear your browser history, you will lose any funds that are not backed up!`, okText: 'Okay, make me a wallet!', onOk() { // Event("Category", "Action", "Label") // Track number of created wallets from onboarding Event('Onboarding.js', 'Create Wallet', 'New'); createWallet(); }, }); } return ( <>

Welcome to Cashtab!

Cashtab is an{' '} open source, {' '} non-custodial web wallet for {currency.name}.

Want to learn more?{' '} Check out the Cashtab documentation.
showBackupConfirmModal()}> New Wallet openSeedInput(!seedInput)}> Import Wallet {seedInput && (
} type="email" placeholder="mnemonic (seed phrase)" name="mnemonic" autoComplete="off" onChange={e => handleChange(e)} required /> submit()} > Import
)} ); }; + +export default OnBoarding; diff --git a/web/cashtab/src/components/Send/Send.js b/web/cashtab/src/components/Send/Send.js index 351dc090f..1fca6c387 100644 --- a/web/cashtab/src/components/Send/Send.js +++ b/web/cashtab/src/components/Send/Send.js @@ -1,534 +1,540 @@ import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; import { WalletContext } from '@utils/context'; import { Form, notification, message, Modal, Alert } from 'antd'; import { Row, Col } from 'antd'; import Paragraph from 'antd/lib/typography/Paragraph'; import PrimaryButton, { SecondaryButton, } from '@components/Common/PrimaryButton'; import { SendBchInput, FormItemWithQRCodeAddon, } from '@components/Common/EnhancedInputs'; import useBCH from '@hooks/useBCH'; import useWindowDimensions from '@hooks/useWindowDimensions'; import { isMobile, isIOS, isSafari } from 'react-device-detect'; import { currency, isValidTokenPrefix, parseAddress, toLegacy, } from '@components/Common/Ticker.js'; import { Event } from '@utils/GoogleAnalytics'; import { fiatToCrypto, shouldRejectAmountInput } from '@utils/validation'; -import { BalanceHeader } from '@components/Common/BalanceHeader'; -import { BalanceHeaderFiat } from '@components/Common/BalanceHeaderFiat'; +import BalanceHeader from '@components/Common/BalanceHeader'; +import BalanceHeaderFiat from '@components/Common/BalanceHeaderFiat'; import { ZeroBalanceHeader, ConvertAmount, AlertMsg, } from '@components/Common/Atoms'; import { getWalletState } from '@utils/cashMethods'; import { CashReceivedNotificationIcon } from '@components/Common/CustomIcons'; -import { ApiError } from '@components/Common/ApiError'; +import ApiError from '@components/Common/ApiError'; // Note jestBCH is only used for unit tests; BCHJS must be mocked for jest const SendBCH = ({ jestBCH, passLoadingStatus }) => { // use balance parameters from wallet.state object and not legacy balances parameter from walletState, if user has migrated wallet // this handles edge case of user with old wallet who has not opened latest Cashtab version yet // If the wallet object from ContextValue has a `state key`, then check which keys are in the wallet object // Else set it as blank const ContextValue = React.useContext(WalletContext); const { wallet, fiatPrice, apiError, cashtabSettings } = ContextValue; const walletState = getWalletState(wallet); const { balances, slpBalancesAndUtxos } = walletState; // Get device window width // If this is less than 769, the page will open with QR scanner open const { width } = useWindowDimensions(); // Load with QR code open if device is mobile and NOT iOS + anything but safari const scannerSupported = width < 769 && isMobile && !(isIOS && !isSafari); const [formData, setFormData] = useState({ dirty: true, value: '', address: '', }); const [queryStringText, setQueryStringText] = useState(null); const [sendBchAddressError, setSendBchAddressError] = useState(false); const [sendBchAmountError, setSendBchAmountError] = useState(false); const [selectedCurrency, setSelectedCurrency] = useState(currency.ticker); // Support cashtab button from web pages const [txInfoFromUrl, setTxInfoFromUrl] = useState(false); // Show a confirmation modal on transactions created by populating form from web page button const [isModalVisible, setIsModalVisible] = useState(false); const showModal = () => { setIsModalVisible(true); }; const handleOk = () => { setIsModalVisible(false); submit(); }; const handleCancel = () => { setIsModalVisible(false); }; const { getBCH, getRestUrl, sendBch, calcFee } = useBCH(); // jestBCH is only ever specified for unit tests, otherwise app will use getBCH(); const BCH = jestBCH ? jestBCH : getBCH(); // If the balance has changed, unlock the UI // This is redundant, if backend has refreshed in 1.75s timeout below, UI will already be unlocked useEffect(() => { passLoadingStatus(false); }, [balances.totalBalance]); useEffect(() => { // Manually parse for txInfo object on page load when Send.js is loaded with a query string // Do not set txInfo in state if query strings are not present if ( !window.location || !window.location.hash || window.location.hash === '#/send' ) { return; } const txInfoArr = window.location.hash.split('?')[1].split('&'); // Iterate over this to create object const txInfo = {}; for (let i = 0; i < txInfoArr.length; i += 1) { let txInfoKeyValue = txInfoArr[i].split('='); let key = txInfoKeyValue[0]; let value = txInfoKeyValue[1]; txInfo[key] = value; } console.log(`txInfo from page params`, txInfo); setTxInfoFromUrl(txInfo); populateFormsFromUrl(txInfo); }, []); function populateFormsFromUrl(txInfo) { if (txInfo && txInfo.address && txInfo.value) { setFormData({ address: txInfo.address, value: txInfo.value, }); } } async function submit() { setFormData({ ...formData, dirty: false, }); if ( !formData.address || !formData.value || Number(formData.value) <= 0 ) { return; } // Event("Category", "Action", "Label") // Track number of BCHA send transactions and whether users // are sending BCHA or USD Event('Send.js', 'Send', selectedCurrency); passLoadingStatus(true); const { address, value } = formData; // Get the param-free address let cleanAddress = address.split('?')[0]; // Ensure address has bitcoincash: prefix and checksum cleanAddress = toLegacy(cleanAddress); let hasValidCashPrefix; try { hasValidCashPrefix = cleanAddress.startsWith( currency.legacyPrefix + ':', ); } catch (err) { hasValidCashPrefix = false; console.log(`toLegacy() returned an error:`, cleanAddress); } if (!hasValidCashPrefix) { // set loading to false and set address validation to false // Now that the no-prefix case is handled, this happens when user tries to send // BCHA to an SLPA address passLoadingStatus(false); setSendBchAddressError( `Destination is not a valid ${currency.ticker} address`, ); return; } // Calculate the amount in BCH let bchValue = value; if (selectedCurrency !== 'XEC') { bchValue = fiatToCrypto(value, fiatPrice); } try { const link = await sendBch( BCH, wallet, slpBalancesAndUtxos.nonSlpUtxos, cleanAddress, bchValue, currency.defaultFee, ); notification.success({ message: 'Success', description: ( Transaction successful. Click to view in block explorer. ), duration: 3, icon: , style: { width: '100%' }, }); } catch (e) { // Set loading to false here as well, as balance may not change depending on where error occured in try loop passLoadingStatus(false); let message; if (!e.error && !e.message) { message = `Transaction failed: no response from ${getRestUrl()}.`; } else if ( /Could not communicate with full node or other external service/.test( e.error, ) ) { message = 'Could not communicate with API. Please try again.'; } else if ( e.error && e.error.includes( 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 50] (code 64)', ) ) { message = `The ${currency.ticker} you are trying to send has too many unconfirmed ancestors to send (limit 50). Sending will be possible after a block confirmation. Try again in about 10 minutes.`; } else { message = e.message || e.error || JSON.stringify(e); } notification.error({ message: 'Error', description: message, duration: 5, }); console.error(e); } } const handleAddressChange = e => { const { value, name } = e.target; let error = false; let addressString = value; // parse address const addressInfo = parseAddress(BCH, addressString); /* Model addressInfo = { address: '', isValid: false, queryString: '', amount: null, }; */ const { address, isValid, queryString, amount } = addressInfo; // If query string, // Show an alert that only amount and currency.ticker are supported setQueryStringText(queryString); // Is this valid address? if (!isValid) { error = `Invalid ${currency.ticker} address`; // If valid address but token format if (isValidTokenPrefix(address)) { error = `Token addresses are not supported for ${currency.ticker} sends`; } } setSendBchAddressError(error); // Set amount if it's in the query string if (amount !== null) { // Set currency to BCHA setSelectedCurrency(currency.ticker); // Use this object to mimic user input and get validation for the value let amountObj = { target: { name: 'value', value: amount, }, }; handleBchAmountChange(amountObj); setFormData({ ...formData, value: amount, }); } // Set address field to user input setFormData(p => ({ ...p, [name]: value, })); }; const handleSelectedCurrencyChange = e => { setSelectedCurrency(e); // Clear input field to prevent accidentally sending 1 BCH instead of 1 USD setFormData(p => ({ ...p, value: '', })); }; const handleBchAmountChange = e => { const { value, name } = e.target; let bchValue = value; const error = shouldRejectAmountInput( bchValue, selectedCurrency, fiatPrice, balances.totalBalance, ); setSendBchAmountError(error); setFormData(p => ({ ...p, [name]: value, })); }; const onMax = async () => { // Clear amt error setSendBchAmountError(false); // Set currency to BCH setSelectedCurrency(currency.ticker); try { const txFeeSats = calcFee(BCH, slpBalancesAndUtxos.nonSlpUtxos); const txFeeBch = txFeeSats / 10 ** currency.cashDecimals; let value = balances.totalBalance - txFeeBch >= 0 ? (balances.totalBalance - txFeeBch).toFixed( currency.cashDecimals, ) : 0; setFormData({ ...formData, value, }); } catch (err) { console.log(`Error in onMax:`); console.log(err); message.error( 'Unable to calculate the max value due to network errors', ); } }; // Display price in USD below input field for send amount, if it can be calculated let fiatPriceString = ''; if (fiatPrice !== null && !isNaN(formData.value)) { if (selectedCurrency === currency.ticker) { fiatPriceString = `${ cashtabSettings ? `${ currency.fiatCurrencies[cashtabSettings.fiatCurrency] .symbol } ` : '$ ' } ${(fiatPrice * Number(formData.value)).toFixed(2)} ${ cashtabSettings && cashtabSettings.fiatCurrency ? cashtabSettings.fiatCurrency.toUpperCase() : 'USD' }`; } else { fiatPriceString = `${ formData.value ? fiatToCrypto(formData.value, fiatPrice) : '0' } ${currency.ticker}`; } } const priceApiError = fiatPrice === null && selectedCurrency !== 'XEC'; return ( <>

Are you sure you want to send {formData.value}{' '} {currency.ticker} to {formData.address}?

{!balances.totalBalance ? ( You currently have 0 {currency.ticker}
Deposit some funds to use this feature
) : ( <> {fiatPrice !== null && ( )} )}
handleAddressChange({ target: { name: 'address', value: result, }, }) } inputProps={{ placeholder: `${currency.ticker} Address`, name: 'address', onChange: e => handleAddressChange(e), required: true, value: formData.address, }} > handleBchAmountChange(e), required: true, value: formData.value, }} selectProps={{ value: selectedCurrency, disabled: queryStringText !== null, onChange: e => handleSelectedCurrencyChange(e), }} > {priceApiError && ( Error fetching fiat price. Setting send by{' '} {currency.fiatCurrencies[ cashtabSettings.fiatCurrency ].slug.toUpperCase()}{' '} disabled )} {fiatPriceString !== '' && '='} {fiatPriceString}
{!balances.totalBalance || apiError || sendBchAmountError || sendBchAddressError ? ( Send ) : ( <> {txInfoFromUrl ? ( showModal()} > Send ) : ( submit()}> Send )} )}
{queryStringText && ( )} {apiError && }
); }; /* passLoadingStatus must receive a default prop that is a function in order to pass the rendering unit test in Send.test.js status => {console.log(status)} is an arbitrary stub function */ SendBCH.defaultProps = { passLoadingStatus: status => { console.log(status); }, }; +SendBCH.propTypes = { + jestBCH: PropTypes.object, + passLoadingStatus: PropTypes.func, +}; + export default SendBCH; diff --git a/web/cashtab/src/components/Send/SendToken.js b/web/cashtab/src/components/Send/SendToken.js index 65434bd19..0de5723e8 100644 --- a/web/cashtab/src/components/Send/SendToken.js +++ b/web/cashtab/src/components/Send/SendToken.js @@ -1,450 +1,457 @@ import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; import { WalletContext } from '@utils/context'; import { Form, notification, message, Row, Col, Alert, Descriptions, } from 'antd'; import Paragraph from 'antd/lib/typography/Paragraph'; import PrimaryButton, { SecondaryButton, } from '@components/Common/PrimaryButton'; import { FormItemWithMaxAddon, FormItemWithQRCodeAddon, } from '@components/Common/EnhancedInputs'; import useBCH from '@hooks/useBCH'; -import { BalanceHeader } from '@components/Common/BalanceHeader'; +import BalanceHeader from '@components/Common/BalanceHeader'; import { Redirect } from 'react-router-dom'; import useWindowDimensions from '@hooks/useWindowDimensions'; import { isMobile, isIOS, isSafari } from 'react-device-detect'; import { Img } from 'react-image'; import makeBlockie from 'ethereum-blockies-base64'; import BigNumber from 'bignumber.js'; import { currency, parseAddress, isValidTokenPrefix, } from '@components/Common/Ticker.js'; import { Event } from '@utils/GoogleAnalytics'; import { getWalletState, convertEtokenToSimpleledger, } from '@utils/cashMethods'; import { TokenReceivedNotificationIcon } from '@components/Common/CustomIcons'; -import { ApiError } from '@components/Common/ApiError'; +import ApiError from '@components/Common/ApiError'; const SendToken = ({ tokenId, jestBCH, passLoadingStatus }) => { const { wallet, apiError } = React.useContext(WalletContext); const walletState = getWalletState(wallet); const { tokens, slpBalancesAndUtxos } = walletState; const token = tokens.find(token => token.tokenId === tokenId); const [tokenStats, setTokenStats] = useState(null); const [queryStringText, setQueryStringText] = useState(null); const [sendTokenAddressError, setSendTokenAddressError] = useState(false); const [sendTokenAmountError, setSendTokenAmountError] = useState(false); // Get device window width // If this is less than 769, the page will open with QR scanner open const { width } = useWindowDimensions(); // Load with QR code open if device is mobile and NOT iOS + anything but safari const scannerSupported = width < 769 && isMobile && !(isIOS && !isSafari); const [formData, setFormData] = useState({ dirty: true, value: '', address: '', }); const { getBCH, getRestUrl, sendToken, getTokenStats } = useBCH(); // jestBCH is only ever specified for unit tests, otherwise app will use getBCH(); const BCH = jestBCH ? jestBCH : getBCH(); // Fetch token stats if you do not have them and API did not return an error if (tokenStats === null) { getTokenStats(BCH, tokenId).then( result => { setTokenStats(result); }, err => { console.log(`Error getting token stats: ${err}`); }, ); } async function submit() { setFormData({ ...formData, dirty: false, }); if ( !formData.address || !formData.value || Number(formData.value <= 0) || sendTokenAmountError ) { return; } // Event("Category", "Action", "Label") // Track number of SLPA send transactions and // SLPA token IDs Event('SendToken.js', 'Send', tokenId); passLoadingStatus(true); const { address, value } = formData; // Clear params from address let cleanAddress = address.split('?')[0]; // Convert to simpleledger prefix if etoken cleanAddress = convertEtokenToSimpleledger(cleanAddress); try { const link = await sendToken(BCH, wallet, slpBalancesAndUtxos, { tokenId: tokenId, tokenReceiverAddress: cleanAddress, amount: value, }); notification.success({ message: 'Success', description: ( Transaction successful. Click to view in block explorer. ), duration: 3, icon: , style: { width: '100%' }, }); } catch (e) { passLoadingStatus(false); let message; if (!e.error && !e.message) { message = `Transaction failed: no response from ${getRestUrl()}.`; } else if ( /Could not communicate with full node or other external service/.test( e.error, ) ) { message = 'Could not communicate with API. Please try again.'; } else { message = e.message || e.error || JSON.stringify(e); } console.log(e); notification.error({ message: 'Error', description: message, duration: 3, }); console.error(e); } } const handleSlpAmountChange = e => { let error = false; const { value, name } = e.target; // test if exceeds balance using BigNumber let isGreaterThanBalance = false; if (!isNaN(value)) { const bigValue = new BigNumber(value); // Returns 1 if greater, -1 if less, 0 if the same, null if n/a isGreaterThanBalance = bigValue.comparedTo(token.balance); } // Validate value for > 0 if (isNaN(value)) { error = 'Amount must be a number'; } else if (value <= 0) { error = 'Amount must be greater than 0'; } else if (token && token.balance && isGreaterThanBalance === 1) { error = `Amount cannot exceed your ${token.info.tokenTicker} balance of ${token.balance}`; } else if (!isNaN(value) && value.toString().includes('.')) { if (value.toString().split('.')[1].length > token.info.decimals) { error = `This token only supports ${token.info.decimals} decimal places`; } } setSendTokenAmountError(error); setFormData(p => ({ ...p, [name]: value, })); }; const handleTokenAddressChange = e => { const { value, name } = e.target; // validate for token address // validate for parameters // show warning that query strings are not supported let error = false; let addressString = value; const addressInfo = parseAddress(BCH, addressString, true); /* Model addressInfo = { address: '', isValid: false, queryString: '', amount: null, }; */ const { address, isValid, queryString } = addressInfo; // If query string, // Show an alert that only amount and currency.ticker are supported setQueryStringText(queryString); // Is this valid address? if (!isValid) { error = 'Address is not a valid etoken: address'; // If valid address but token format } else if (!isValidTokenPrefix(address)) { error = `Cashtab only supports sending to ${currency.tokenPrefixes[0]} prefixed addresses`; } setSendTokenAddressError(error); setFormData(p => ({ ...p, [name]: value, })); }; const onMax = async () => { // Clear this error before updating field setSendTokenAmountError(false); try { let value = token.balance; setFormData({ ...formData, value, }); } catch (err) { console.log(`Error in onMax:`); console.log(err); message.error( 'Unable to calculate the max value due to network errors', ); } }; useEffect(() => { // If the balance has changed, unlock the UI // This is redundant, if backend has refreshed in 1.75s timeout below, UI will already be unlocked passLoadingStatus(false); }, [token]); return ( <> {!token && } {token && ( <>
handleTokenAddressChange({ target: { name: 'address', value: result, }, }) } inputProps={{ placeholder: `${currency.tokenTicker} Address`, name: 'address', onChange: e => handleTokenAddressChange(e), required: true, value: formData.address, }} /> } /> ) : ( {`identicon ), suffix: token.info.tokenTicker, onChange: e => handleSlpAmountChange(e), required: true, value: formData.value, }} />
{apiError || sendTokenAmountError || sendTokenAddressError ? ( <> Send {token.info.tokenName} ) : ( submit()}> Send {token.info.tokenName} )}
{queryStringText && ( )} {apiError && } {tokenStats !== null && ( {token.info.decimals} {token.tokenId} {tokenStats && ( <> {tokenStats.documentUri} {tokenStats.timestampUnix !== null ? new Date( tokenStats.timestampUnix * 1000, ).toLocaleDateString() : 'Just now (Genesis tx confirming)'} {tokenStats.containsBaton ? 'No' : 'Yes'} {tokenStats.initialTokenQty.toLocaleString()} {tokenStats.totalBurned.toLocaleString()} {tokenStats.totalMinted.toLocaleString()} {tokenStats.circulatingSupply.toLocaleString()} )} )}
)} ); }; /* passLoadingStatus must receive a default prop that is a function in order to pass the rendering unit test in SendToken.test.js status => {console.log(status)} is an arbitrary stub function */ SendToken.defaultProps = { passLoadingStatus: status => { console.log(status); }, }; +SendToken.propTypes = { + tokenId: PropTypes.string, + jestBCH: PropTypes.object, + passLoadingStatus: PropTypes.func, +}; + export default SendToken; diff --git a/web/cashtab/src/components/Tokens/CreateTokenForm.js b/web/cashtab/src/components/Tokens/CreateTokenForm.js index 43beaac58..1dcb72bac 100644 --- a/web/cashtab/src/components/Tokens/CreateTokenForm.js +++ b/web/cashtab/src/components/Tokens/CreateTokenForm.js @@ -1,380 +1,389 @@ import React, { useState } from 'react'; +import PropTypes from 'prop-types'; import { AntdFormWrapper } from '@components/Common/EnhancedInputs'; import { TokenCollapse } from '@components/Common/StyledCollapse'; import { currency } from '@components/Common/Ticker.js'; import { WalletContext } from '@utils/context'; import { isValidTokenName, isValidTokenTicker, isValidTokenDecimals, isValidTokenInitialQty, isValidTokenDocumentUrl, } from '@utils/validation'; import { PlusSquareOutlined } from '@ant-design/icons'; import { SmartButton } from '@components/Common/PrimaryButton'; import { Collapse, Form, Input, Modal, notification } from 'antd'; const { Panel } = Collapse; import Paragraph from 'antd/lib/typography/Paragraph'; import { TokenParamLabel } from '@components/Common/Atoms'; import { TokenReceivedNotificationIcon } from '@components/Common/CustomIcons'; const CreateTokenForm = ({ BCH, getRestUrl, createToken, disabled, passLoadingStatus, }) => { const { wallet } = React.useContext(WalletContext); // New Token Name const [newTokenName, setNewTokenName] = useState(''); const [newTokenNameIsValid, setNewTokenNameIsValid] = useState(null); const handleNewTokenNameInput = e => { const { value } = e.target; // validation setNewTokenNameIsValid(isValidTokenName(value)); setNewTokenName(value); }; // New Token Ticker const [newTokenTicker, setNewTokenTicker] = useState(''); const [newTokenTickerIsValid, setNewTokenTickerIsValid] = useState(null); const handleNewTokenTickerInput = e => { const { value } = e.target; // validation setNewTokenTickerIsValid(isValidTokenTicker(value)); setNewTokenTicker(value); }; // New Token Decimals const [newTokenDecimals, setNewTokenDecimals] = useState(0); const [newTokenDecimalsIsValid, setNewTokenDecimalsIsValid] = useState( true, ); const handleNewTokenDecimalsInput = e => { const { value } = e.target; // validation setNewTokenDecimalsIsValid(isValidTokenDecimals(value)); // Also validate the supply here if it has not yet been set if (newTokenInitialQtyIsValid !== null) { setNewTokenInitialQtyIsValid( isValidTokenInitialQty(value, newTokenDecimals), ); } setNewTokenDecimals(value); }; // New Token Initial Quantity const [newTokenInitialQty, setNewTokenInitialQty] = useState(''); const [newTokenInitialQtyIsValid, setNewTokenInitialQtyIsValid] = useState( null, ); const handleNewTokenInitialQtyInput = e => { const { value } = e.target; // validation setNewTokenInitialQtyIsValid( isValidTokenInitialQty(value, newTokenDecimals), ); setNewTokenInitialQty(value); }; // New Token document URL const [newTokenDocumentUrl, setNewTokenDocumentUrl] = useState(''); // Start with this as true, field is not required const [ newTokenDocumentUrlIsValid, setNewTokenDocumentUrlIsValid, ] = useState(true); const handleNewTokenDocumentUrlInput = e => { const { value } = e.target; // validation setNewTokenDocumentUrlIsValid(isValidTokenDocumentUrl(value)); setNewTokenDocumentUrl(value); }; // New Token fixed supply // Only allow creation of fixed supply tokens until Minting support is added // New Token document hash // Do not include this; questionable value to casual users and requires significant complication // Only enable CreateToken button if all form entries are valid let tokenGenesisDataIsValid = newTokenNameIsValid && newTokenTickerIsValid && newTokenDecimalsIsValid && newTokenInitialQtyIsValid && newTokenDocumentUrlIsValid; // Modal settings const [showConfirmCreateToken, setShowConfirmCreateToken] = useState(false); const createPreviewedToken = async () => { passLoadingStatus(true); // If data is for some reason not valid here, bail out if (!tokenGenesisDataIsValid) { return; } // data must be valid and user reviewed to get here const configObj = { name: newTokenName, ticker: newTokenTicker, documentUrl: newTokenDocumentUrl === '' ? 'https://cashtabapp.com/' : newTokenDocumentUrl, decimals: newTokenDecimals, initialQty: newTokenInitialQty, documentHash: '', }; // create token with data in state fields try { const link = await createToken( BCH, wallet, currency.defaultFee, configObj, ); notification.success({ message: 'Success', description: ( Token created! Click to view in block explorer. ), icon: , style: { width: '100%' }, }); } catch (e) { // Set loading to false here as well, as balance may not change depending on where error occured in try loop passLoadingStatus(false); let message; if (!e.error && !e.message) { message = `Transaction failed: no response from ${getRestUrl()}.`; } else if ( /Could not communicate with full node or other external service/.test( e.error, ) ) { message = 'Could not communicate with API. Please try again.'; } else if ( e.error && e.error.includes( 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 50] (code 64)', ) ) { message = `The ${currency.ticker} you are trying to send has too many unconfirmed ancestors to send (limit 50). Sending will be possible after a block confirmation. Try again in about 10 minutes.`; } else { message = e.message || e.error || JSON.stringify(e); } notification.error({ message: 'Error', description: message, duration: 5, }); console.error(e); } // Hide the modal setShowConfirmCreateToken(false); // Stop spinner passLoadingStatus(false); }; return ( <> setShowConfirmCreateToken(false)} > Name: {newTokenName}
Ticker: {newTokenTicker}
Decimals: {newTokenDecimals}
Supply: {newTokenInitialQty}
Document URL:{' '} {newTokenDocumentUrl === '' ? 'https://cashtabapp.com/' : newTokenDocumentUrl}
<>
handleNewTokenNameInput(e) } /> handleNewTokenTickerInput(e) } /> handleNewTokenDecimalsInput(e) } /> handleNewTokenInitialQtyInput(e) } /> handleNewTokenDocumentUrlInput(e) } />
setShowConfirmCreateToken(true)} disabled={!tokenGenesisDataIsValid} >  Create Token
); }; /* passLoadingStatus must receive a default prop that is a function in order to pass the rendering unit test in CreateTokenForm.test.js status => {console.log(status)} is an arbitrary stub function */ CreateTokenForm.defaultProps = { passLoadingStatus: status => { console.log(status); }, }; +CreateTokenForm.propTypes = { + BCH: PropTypes.object, + getRestUrl: PropTypes.func, + createToken: PropTypes.func, + disabled: PropTypes.bool, + passLoadingStatus: PropTypes.func, +}; + export default CreateTokenForm; diff --git a/web/cashtab/src/components/Tokens/Tokens.js b/web/cashtab/src/components/Tokens/Tokens.js index da475e8a1..a8ba9a6b2 100644 --- a/web/cashtab/src/components/Tokens/Tokens.js +++ b/web/cashtab/src/components/Tokens/Tokens.js @@ -1,120 +1,126 @@ import React from 'react'; +import PropTypes from 'prop-types'; import { WalletContext } from '@utils/context'; import { fromSmallestDenomination, getWalletState } from '@utils/cashMethods'; import CreateTokenForm from '@components/Tokens/CreateTokenForm'; import { currency } from '@components/Common/Ticker.js'; import TokenList from '@components/Wallet/TokenList'; import useBCH from '@hooks/useBCH'; -import { BalanceHeader } from '@components/Common/BalanceHeader'; -import { BalanceHeaderFiat } from '@components/Common/BalanceHeaderFiat'; +import BalanceHeader from '@components/Common/BalanceHeader'; +import BalanceHeaderFiat from '@components/Common/BalanceHeaderFiat'; import { ZeroBalanceHeader, AlertMsg } from '@components/Common/Atoms'; -import { ApiError } from '@components/Common/ApiError'; +import ApiError from '@components/Common/ApiError'; const Tokens = ({ jestBCH, passLoadingStatus }) => { /* Dev note This is the first new page created after the wallet migration to include state in storage As such, it will only load this type of wallet If any user is still migrating at this point, this page will display a loading spinner until their wallet has updated (ETA within 10 seconds) Going forward, this approach will be the model for Wallet, Send, and SendToken, as the legacy wallet state parameters not stored in the wallet object are deprecated */ const { wallet, apiError, fiatPrice, cashtabSettings } = React.useContext( WalletContext, ); const walletState = getWalletState(wallet); const { balances, tokens } = walletState; const { getBCH, getRestUrl, createToken } = useBCH(); // Support using locally installed bchjs for unit tests const BCH = jestBCH ? jestBCH : getBCH(); return ( <> {!balances.totalBalance ? ( <> You need some {currency.ticker} in your wallet to create tokens. ) : ( <> {fiatPrice !== null && !isNaN(balances.totalBalance) && ( )} )} {apiError && } {balances.totalBalanceInSatoshis < currency.dustSats && ( You need at least{' '} {fromSmallestDenomination(currency.dustSats).toString()}{' '} {currency.ticker} ( {cashtabSettings ? `${ currency.fiatCurrencies[ cashtabSettings.fiatCurrency ].symbol } ` : '$ '} {( fromSmallestDenomination(currency.dustSats).toString() * fiatPrice ).toFixed(4)}{' '} {cashtabSettings ? `${currency.fiatCurrencies[ cashtabSettings.fiatCurrency ].slug.toUpperCase()} ` : 'USD'} ) to create a token )} {tokens && tokens.length > 0 ? ( <> ) : ( <>No {currency.tokenTicker} tokens in this wallet )} ); }; /* passLoadingStatus must receive a default prop that is a function in order to pass the rendering unit test in Tokens.test.js status => {console.log(status)} is an arbitrary stub function */ Tokens.defaultProps = { passLoadingStatus: status => { console.log(status); }, }; +Tokens.propTypes = { + jestBCH: PropTypes.object, + passLoadingStatus: PropTypes.func, +}; + export default Tokens; diff --git a/web/cashtab/src/components/Wallet/TokenList.js b/web/cashtab/src/components/Wallet/TokenList.js index 3c1388ef6..d26672f66 100644 --- a/web/cashtab/src/components/Wallet/TokenList.js +++ b/web/cashtab/src/components/Wallet/TokenList.js @@ -1,22 +1,27 @@ import React from 'react'; +import PropTypes from 'prop-types'; import TokenListItem from './TokenListItem'; import { Link } from 'react-router-dom'; import { formatBalance } from '@utils/cashMethods'; const TokenList = ({ tokens }) => { return (
{tokens.map(token => ( ))}
); }; +TokenList.propTypes = { + tokens: PropTypes.array, +}; + export default TokenList; diff --git a/web/cashtab/src/components/Wallet/TokenListItem.js b/web/cashtab/src/components/Wallet/TokenListItem.js index e5c3288fa..ccdb0003b 100644 --- a/web/cashtab/src/components/Wallet/TokenListItem.js +++ b/web/cashtab/src/components/Wallet/TokenListItem.js @@ -1,74 +1,81 @@ import React from 'react'; +import PropTypes from 'prop-types'; import styled from 'styled-components'; import makeBlockie from 'ethereum-blockies-base64'; import { Img } from 'react-image'; import { currency } from '@components/Common/Ticker'; const TokenIcon = styled.div` height: 32px; width: 32px; `; const BalanceAndTicker = styled.div` font-size: 1rem; `; const Wrapper = styled.div` display: flex; justify-content: space-between; align-items: center; padding: 15px 25px; border-radius: 3px; background: ${props => props.theme.tokenListItem.background}; margin-bottom: 3px; box-shadow: ${props => props.theme.tokenListItem.boxShadow}; border: 1px solid ${props => props.theme.tokenListItem.border}; :hover { border-color: ${props => props.theme.tokenListItem.hoverBorder}; } `; const TokenListItem = ({ ticker, balance, tokenId }) => { return ( {currency.tokenIconsUrl !== '' ? ( {`identicon } /> ) : ( {`identicon )} {balance} {ticker} ); }; +TokenListItem.propTypes = { + ticker: PropTypes.string, + balance: PropTypes.string, + tokenId: PropTypes.string, +}; + export default TokenListItem; diff --git a/web/cashtab/src/components/Wallet/Tx.js b/web/cashtab/src/components/Wallet/Tx.js index f868fc23f..bc15ef801 100644 --- a/web/cashtab/src/components/Wallet/Tx.js +++ b/web/cashtab/src/components/Wallet/Tx.js @@ -1,327 +1,334 @@ import React from 'react'; +import PropTypes from 'prop-types'; import styled from 'styled-components'; import { ArrowUpOutlined, ArrowDownOutlined, ExperimentOutlined, } from '@ant-design/icons'; import { currency } from '@components/Common/Ticker'; import makeBlockie from 'ethereum-blockies-base64'; import { Img } from 'react-image'; import { formatBalance, fromLegacyDecimals } from '@utils/cashMethods'; const SentTx = styled(ArrowUpOutlined)` color: ${props => props.theme.secondary} !important; `; const ReceivedTx = styled(ArrowDownOutlined)` color: ${props => props.theme.primary} !important; `; const GenesisTx = styled(ExperimentOutlined)` color: ${props => props.theme.primary} !important; `; const DateType = styled.div` text-align: left; padding: 12px; @media screen and (max-width: 500px) { font-size: 0.8rem; } `; const SentLabel = styled.span` font-weight: bold; color: ${props => props.theme.secondary} !important; `; const ReceivedLabel = styled.span` font-weight: bold; color: ${props => props.theme.primary} !important; `; const TxIcon = styled.div` svg { width: 32px; height: 32px; } height: 32px; width: 32px; @media screen and (max-width: 500px) { svg { width: 24px; height: 24px; } height: 24px; width: 24px; } `; const TxInfo = styled.div` padding: 12px; font-size: 1rem; text-align: right; color: ${props => props.outgoing ? props.theme.secondary : props.theme.primary}; @media screen and (max-width: 500px) { font-size: 0.8rem; } `; const TxFiatPrice = styled.span` font-size: 0.8rem; `; const TokenInfo = styled.div` display: grid; grid-template-rows: 50%; grid-template-columns: 24px auto; padding: 12px; font-size: 1rem; color: ${props => props.outgoing ? props.theme.secondary : props.theme.primary}; @media screen and (max-width: 500px) { font-size: 0.8rem; grid-template-columns: 16px auto; } `; const TxTokenIcon = styled.div` img { height: 24px; width: 24px; } @media screen and (max-width: 500px) { img { height: 16px; width: 16px; } } grid-column-start: 1; grid-column-end: span 1; grid-row-start: 1; grid-row-end: span 2; align-self: center; `; const TokenTxAmt = styled.div` padding-left: 12px; text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; `; const TokenName = styled.div` padding-left: 12px; font-size: 0.8rem; @media screen and (max-width: 500px) { font-size: 0.6rem; } text-align: right; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; `; const TxWrapper = styled.div` display: grid; grid-template-columns: 36px 30% 50%; justify-content: space-between; align-items: center; padding: 15px 25px; border-radius: 3px; background: ${props => props.theme.tokenListItem.background}; margin-bottom: 3px; box-shadow: ${props => props.theme.tokenListItem.boxShadow}; border: 1px solid ${props => props.theme.tokenListItem.border}; :hover { border-color: ${props => props.theme.primary}; } @media screen and (max-width: 500px) { grid-template-columns: 24px 30% 50%; padding: 12px 12px; } `; const Tx = ({ data, fiatPrice, fiatCurrency }) => { const txDate = typeof data.blocktime === 'undefined' ? new Date().toLocaleDateString() : new Date(data.blocktime * 1000).toLocaleDateString(); return ( {data.outgoingTx ? ( <> {data.tokenTx && data.tokenInfo.transactionType === 'GENESIS' ? ( ) : ( )} ) : ( )} {data.outgoingTx ? ( <> {data.tokenTx && data.tokenInfo.transactionType === 'GENESIS' ? ( Genesis ) : ( Sent )} ) : ( Received )}
{txDate}
{data.tokenTx ? ( {data.tokenTx && data.tokenInfo ? ( <> {currency.tokenIconsUrl !== '' ? ( {`identicon } /> ) : ( {`identicon )} {data.outgoingTx ? ( <> {data.tokenInfo.transactionType === 'GENESIS' ? ( <> +{' '} {data.tokenInfo.qtyReceived.toString()}   {data.tokenInfo.tokenTicker} {data.tokenInfo.tokenName} ) : ( <> -{' '} {data.tokenInfo.qtySent.toString()}   {data.tokenInfo.tokenTicker} {data.tokenInfo.tokenName} )} ) : ( <> +{' '} {data.tokenInfo.qtyReceived.toString()}  {data.tokenInfo.tokenTicker} {data.tokenInfo.tokenName} )} ) : ( Token Tx )} ) : ( <> {data.outgoingTx ? ( <> -{' '} {formatBalance( fromLegacyDecimals(data.amountSent), )}{' '} {currency.ticker}
{fiatPrice !== null && !isNaN(data.amountSent) && ( -{' '} { currency.fiatCurrencies[ fiatCurrency ].symbol } {( fromLegacyDecimals( data.amountSent, ) * fiatPrice ).toFixed(2)}{' '} {currency.fiatCurrencies.fiatCurrency} )} ) : ( <> +{' '} {formatBalance( fromLegacyDecimals(data.amountReceived), )}{' '} {currency.ticker}
{fiatPrice !== null && !isNaN(data.amountReceived) && ( +{' '} { currency.fiatCurrencies[ fiatCurrency ].symbol } {( fromLegacyDecimals( data.amountReceived, ) * fiatPrice ).toFixed(2)}{' '} { currency.fiatCurrencies .fiatCurrency } )} )}
)}
); }; +Tx.propTypes = { + data: PropTypes.object, + fiatPrice: PropTypes.number, + fiatCurrency: PropTypes.string, +}; + export default Tx; diff --git a/web/cashtab/src/components/Wallet/TxHistory.js b/web/cashtab/src/components/Wallet/TxHistory.js index 4321cbf53..d57885ddd 100644 --- a/web/cashtab/src/components/Wallet/TxHistory.js +++ b/web/cashtab/src/components/Wallet/TxHistory.js @@ -1,28 +1,35 @@ import React from 'react'; +import PropTypes from 'prop-types'; import styled from 'styled-components'; import Tx from './Tx'; export const TxLink = styled.a``; const TxHistory = ({ txs, fiatPrice, fiatCurrency }) => { return (
{txs.map(tx => ( ))}
); }; +TxHistory.propTypes = { + txs: PropTypes.array, + fiatPrice: PropTypes.number, + fiatCurrency: PropTypes.string, +}; + export default TxHistory; diff --git a/web/cashtab/src/components/Wallet/Wallet.js b/web/cashtab/src/components/Wallet/Wallet.js index d4200451e..da342b9fd 100644 --- a/web/cashtab/src/components/Wallet/Wallet.js +++ b/web/cashtab/src/components/Wallet/Wallet.js @@ -1,335 +1,335 @@ import React from 'react'; import styled from 'styled-components'; import { WalletContext } from '@utils/context'; -import { OnBoarding } from '@components/OnBoarding/OnBoarding'; +import OnBoarding from '@components/OnBoarding/OnBoarding'; import { QRCode } from '@components/Common/QRCode'; import { currency } from '@components/Common/Ticker.js'; import { Link } from 'react-router-dom'; import TokenList from './TokenList'; import TxHistory from './TxHistory'; -import { ApiError } from '@components/Common/ApiError'; -import { BalanceHeader } from '@components/Common/BalanceHeader'; -import { BalanceHeaderFiat } from '@components/Common/BalanceHeaderFiat'; +import ApiError from '@components/Common/ApiError'; +import BalanceHeader from '@components/Common/BalanceHeader'; +import BalanceHeaderFiat from '@components/Common/BalanceHeaderFiat'; import { LoadingCtn, ZeroBalanceHeader } from '@components/Common/Atoms'; import { getWalletState } from '@utils/cashMethods'; export const Tabs = styled.div` margin: auto; margin-bottom: 12px; display: inline-block; text-align: center; `; export const TabLabel = styled.button` :focus, :active { outline: none; } border: none; background: none; font-size: 20px; cursor: pointer; @media (max-width: 400px) { font-size: 16px; } ${({ active, ...props }) => active && ` color: ${props.theme.primary}; `} `; export const TabLine = styled.div` margin: auto; transition: margin-left 0.5s ease-in-out, width 0.5s 0.1s; height: 4px; border-radius: 5px; background-color: ${props => props.theme.primary}; pointer-events: none; margin-left: 72%; width: 28%; ${({ left, ...props }) => left && ` margin-left: 1% width: 69%; `} `; export const TabPane = styled.div` ${({ active }) => !active && ` display: none; `} `; export const SwitchBtnCtn = styled.div` display: flex; align-items: center; justify-content: center; align-content: space-between; margin-bottom: 15px; .nonactiveBtn { color: ${props => props.theme.wallet.text.secondary}; background: ${props => props.theme.wallet.switch.inactive.background} !important; box-shadow: none !important; } .slpActive { background: ${props => props.theme.wallet.switch.activeToken.background} !important; box-shadow: ${props => props.theme.wallet.switch.activeToken.shadow} !important; } `; export const SwitchBtn = styled.div` font-weight: bold; display: inline-block; cursor: pointer; color: ${props => props.theme.contrast}; font-size: 14px; padding: 6px 0; width: 100px; margin: 0 1px; text-decoration: none; background: ${props => props.theme.primary}; box-shadow: ${props => props.theme.wallet.switch.activeCash.shadow}; user-select: none; :first-child { border-radius: 100px 0 0 100px; } :nth-child(2) { border-radius: 0 100px 100px 0; } `; export const Links = styled(Link)` color: ${props => props.theme.wallet.text.secondary}; width: 100%; font-size: 16px; margin: 10px 0 20px 0; border: 1px solid ${props => props.theme.wallet.text.secondary}; padding: 14px 0; display: inline-block; border-radius: 3px; transition: all 200ms ease-in-out; svg { fill: ${props => props.theme.wallet.text.secondary}; } :hover { color: ${props => props.theme.primary}; border-color: ${props => props.theme.primary}; svg { fill: ${props => props.theme.primary}; } } @media (max-width: 768px) { padding: 10px 0; font-size: 14px; } `; export const ExternalLink = styled.a` color: ${props => props.theme.wallet.text.secondary}; width: 100%; font-size: 16px; margin: 0 0 20px 0; border: 1px solid ${props => props.theme.wallet.text.secondary}; padding: 14px 0; display: inline-block; border-radius: 3px; transition: all 200ms ease-in-out; svg { fill: ${props => props.theme.wallet.text.secondary}; transition: all 200ms ease-in-out; } :hover { color: ${props => props.theme.primary}; border-color: ${props => props.theme.primary}; svg { fill: ${props => props.theme.primary}; } } @media (max-width: 768px) { padding: 10px 0; font-size: 14px; } `; export const AddrSwitchContainer = styled.div` text-align: center; padding: 6px 0 12px 0; `; const WalletInfo = () => { const ContextValue = React.useContext(WalletContext); const { wallet, fiatPrice, apiError, cashtabSettings } = ContextValue; const walletState = getWalletState(wallet); const { balances, parsedTxHistory, tokens } = walletState; const [address, setAddress] = React.useState('cashAddress'); const [activeTab, setActiveTab] = React.useState('txHistory'); const hasHistory = parsedTxHistory && parsedTxHistory.length > 0; const handleChangeAddress = () => { setAddress(address === 'cashAddress' ? 'slpAddress' : 'cashAddress'); }; return ( <> {!balances.totalBalance && !apiError && !hasHistory ? ( <> 🎉 Congratulations on your new wallet!{' '} 🎉
Start using the wallet immediately to receive{' '} {currency.ticker} payments, or load it up with{' '} {currency.ticker} to send to others
) : ( <> {fiatPrice !== null && !isNaN(balances.totalBalance) && ( )} )} {apiError && } {wallet && ((wallet.Path245 && wallet.Path145) || wallet.Path1899) && ( <> {wallet.Path1899 ? ( <> ) : ( <> )} )} handleChangeAddress()} className={ address !== 'cashAddress' ? 'nonactiveBtn' : null } > {currency.ticker} handleChangeAddress()} className={ address === 'cashAddress' ? 'nonactiveBtn' : 'slpActive' } > {currency.tokenTicker} {hasHistory && parsedTxHistory && ( <> setActiveTab('txHistory')} > Transaction History setActiveTab('tokens')} > Tokens {tokens && tokens.length > 0 ? ( ) : (

Tokens sent to your {currency.tokenTicker}{' '} address will appear here

)}
)} ); }; const Wallet = () => { const ContextValue = React.useContext(WalletContext); const { wallet, previousWallet, loading } = ContextValue; return ( <> {loading ? ( ) : ( <> {(wallet && wallet.Path1899) || (previousWallet && previousWallet.path1899) ? ( ) : ( )} )} ); }; export default Wallet;