Delete
\ No newline at end of file
diff --git a/web/cashtab-v2/src/components/Airdrop/Airdrop.js b/web/cashtab-v2/src/components/Airdrop/Airdrop.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Airdrop/Airdrop.js
@@ -0,0 +1,468 @@
+import React, { useState, useEffect } from 'react';
+import { useLocation } from 'react-router-dom';
+import PropTypes from 'prop-types';
+import BigNumber from 'bignumber.js';
+import styled from 'styled-components';
+import { WalletContext } from '@utils/context';
+import { AntdFormWrapper } from '@components/Common/EnhancedInputs';
+import { AdvancedCollapse } from '@components/Common/StyledCollapse';
+import { Form, Alert, Collapse, Input, Modal, Spin, Progress } from 'antd';
+const { Panel } = Collapse;
+const { TextArea } = Input;
+import { Row, Col } from 'antd';
+import { SmartButton } from '@components/Common/PrimaryButton';
+import useBCH from '@hooks/useBCH';
+import {
+ errorNotification,
+ generalNotification,
+} from '@components/Common/Notifications';
+import { currency } from '@components/Common/Ticker.js';
+import BalanceHeader from '@components/Common/BalanceHeader';
+import BalanceHeaderFiat from '@components/Common/BalanceHeaderFiat';
+import {
+ getWalletState,
+ convertEtokenToEcashAddr,
+ fromSmallestDenomination,
+} from '@utils/cashMethods';
+import {
+ isValidTokenId,
+ isValidXecAirdrop,
+ isValidAirdropOutputsArray,
+} from '@utils/validation';
+import { CustomSpinner } from '@components/Common/CustomIcons';
+import * as etokenList from 'etoken-list';
+import {
+ ZeroBalanceHeader,
+ SidePaddingCtn,
+ WalletInfoCtn,
+} from '@components/Common/Atoms';
+import WalletLabel from '@components/Common/WalletLabel.js';
+import { Link } from 'react-router-dom';
+
+const AirdropActions = styled.div`
+ text-align: center;
+ width: 100%;
+ padding: 10px;
+ border-radius: 5px;
+ a {
+ color: ${props => props.theme.contrast};
+ margin: 0;
+ font-size: 11px;
+ border: 1px solid ${props => props.theme.contrast};
+ border-radius: 5px;
+ padding: 2px 10px;
+ opacity: 0.6;
+ }
+ a:hover {
+ opacity: 1;
+ border-color: ${props => props.theme.eCashBlue};
+ color: ${props => props.theme.contrast};
+ background: ${props => props.theme.eCashBlue};
+ }
+ ${({ received, ...props }) =>
+ received &&
+ `
+ text-align: left;
+ background: ${props.theme.receivedMessage};
+ `}
+`;
+
+// Note jestBCH is only used for unit tests; BCHJS must be mocked for jest
+const Airdrop = ({ jestBCH, passLoadingStatus }) => {
+ const ContextValue = React.useContext(WalletContext);
+ const { wallet, fiatPrice, cashtabSettings } = ContextValue;
+ const location = useLocation();
+ const walletState = getWalletState(wallet);
+ const { balances } = walletState;
+
+ const [bchObj, setBchObj] = useState(false);
+ const [isAirdropCalcModalVisible, setIsAirdropCalcModalVisible] =
+ useState(false);
+ const [airdropCalcModalProgress, setAirdropCalcModalProgress] = useState(0); // the dynamic % progress bar
+
+ useEffect(() => {
+ // jestBCH is only ever specified for unit tests, otherwise app will use getBCH();
+ const BCH = jestBCH ? jestBCH : getBCH();
+
+ // set the BCH instance to state, for other functions to reference
+ setBchObj(BCH);
+
+ if (location && location.state && location.state.airdropEtokenId) {
+ setFormData({
+ ...formData,
+ tokenId: location.state.airdropEtokenId,
+ });
+ handleTokenIdInput({
+ target: {
+ value: location.state.airdropEtokenId,
+ },
+ });
+ }
+ }, []);
+
+ const [formData, setFormData] = useState({
+ tokenId: '',
+ totalAirdrop: '',
+ });
+
+ const [tokenIdIsValid, setTokenIdIsValid] = useState(null);
+ const [totalAirdropIsValid, setTotalAirdropIsValid] = useState(null);
+ const [airdropRecipients, setAirdropRecipients] = useState('');
+ const [airdropOutputIsValid, setAirdropOutputIsValid] = useState(true);
+ const [etokenHolders, setEtokenHolders] = useState(new BigNumber(0));
+ const [showAirdropOutputs, setShowAirdropOutputs] = useState(false);
+
+ const { getBCH } = useBCH();
+
+ const handleTokenIdInput = e => {
+ const { name, value } = e.target;
+ setTokenIdIsValid(isValidTokenId(value));
+ setFormData(p => ({
+ ...p,
+ [name]: value,
+ }));
+ };
+
+ const handleTotalAirdropInput = e => {
+ const { name, value } = e.target;
+ setTotalAirdropIsValid(isValidXecAirdrop(value));
+ setFormData(p => ({
+ ...p,
+ [name]: value,
+ }));
+ };
+
+ const calculateXecAirdrop = async () => {
+ // display airdrop calculation message modal
+ setIsAirdropCalcModalVisible(true);
+ setShowAirdropOutputs(false); // hide any previous airdrop outputs
+ passLoadingStatus(true);
+ setAirdropCalcModalProgress(25); // updated progress bar to 25%
+
+ let latestBlock;
+ try {
+ latestBlock = await bchObj.Blockchain.getBlockCount();
+ } catch (err) {
+ errorNotification(
+ err,
+ 'Error retrieving latest block height',
+ 'bchObj.Blockchain.getBlockCount() error',
+ );
+ setIsAirdropCalcModalVisible(false);
+ passLoadingStatus(false);
+ return;
+ }
+
+ setAirdropCalcModalProgress(50);
+
+ etokenList.Config.SetUrl(currency.tokenDbUrl);
+
+ let airdropList;
+ try {
+ airdropList = await etokenList.List.GetAddressListFor(
+ formData.tokenId,
+ latestBlock,
+ true,
+ );
+ } catch (err) {
+ errorNotification(
+ err,
+ 'Error retrieving airdrop recipients',
+ 'etokenList.List.GetAddressListFor() error',
+ );
+ setIsAirdropCalcModalVisible(false);
+ passLoadingStatus(false);
+ return;
+ }
+
+ if (!airdropList) {
+ errorNotification(
+ null,
+ 'No recipients found for tokenId ' + formData.tokenId,
+ 'Airdrop Calculation Error',
+ );
+ setIsAirdropCalcModalVisible(false);
+ passLoadingStatus(false);
+ return;
+ }
+
+ setAirdropCalcModalProgress(75);
+
+ let totalTokenAmongstRecipients = new BigNumber(0);
+ let totalHolders = new BigNumber(airdropList.size); // amount of addresses that hold this eToken
+ setEtokenHolders(totalHolders);
+
+ // keep a cumulative total of each eToken holding in each address in airdropList
+ airdropList.forEach(
+ index =>
+ (totalTokenAmongstRecipients = totalTokenAmongstRecipients.plus(
+ new BigNumber(index),
+ )),
+ );
+
+ let circToAirdropRatio = new BigNumber(formData.totalAirdrop).div(
+ totalTokenAmongstRecipients,
+ );
+
+ let resultString = '';
+
+ airdropList.forEach(
+ (element, index) =>
+ (resultString +=
+ convertEtokenToEcashAddr(index) +
+ ',' +
+ new BigNumber(element)
+ .multipliedBy(circToAirdropRatio)
+ .decimalPlaces(currency.cashDecimals) +
+ '\n'),
+ );
+
+ resultString = resultString.substring(0, resultString.length - 1); // remove the final newline
+ setAirdropRecipients(resultString);
+
+ setAirdropCalcModalProgress(100);
+
+ if (!resultString) {
+ errorNotification(
+ null,
+ 'No holders found for eToken ID: ' + formData.tokenId,
+ 'Airdrop Calculation Error',
+ );
+ return;
+ }
+
+ // validate the airdrop values for each recipient
+ // Note: addresses are not validated as they are retrieved directly from onchain
+ setAirdropOutputIsValid(isValidAirdropOutputsArray(resultString));
+ setShowAirdropOutputs(true); // display the airdrop outputs TextArea
+ setIsAirdropCalcModalVisible(false);
+ passLoadingStatus(false);
+ };
+
+ const handleAirdropCalcModalCancel = () => {
+ setIsAirdropCalcModalVisible(false);
+ passLoadingStatus(false);
+ };
+
+ let airdropCalcInputIsValid = tokenIdIsValid && totalAirdropIsValid;
+
+ return (
+ <>
+
+
+ {!balances.totalBalance ? (
+
+ You currently have 0 {currency.ticker}
+
+ Deposit some funds to use this feature
+
+ ) : (
+ <>
+
+ {fiatPrice !== null && (
+
+ )}
+ >
+ )}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ handleTokenIdInput(e)
+ }
+ />
+
+
+
+ handleTotalAirdropInput(e)
+ }
+ />
+
+
+
+ calculateXecAirdrop()
+ }
+ disabled={
+ !airdropCalcInputIsValid ||
+ !tokenIdIsValid
+ }
+ >
+ Calculate Airdrop
+
+
+ {showAirdropOutputs && (
+ <>
+ {!airdropOutputIsValid &&
+ etokenHolders > 0 && (
+ <>
+
+
+ >
+ )}
+
+ One to Many Airdrop Payment
+ Outputs
+
+
+
+
+
+ Copy to Send screen
+
+
+ {
+ navigator.clipboard.writeText(
+ airdropRecipients,
+ );
+ generalNotification(
+ 'Airdrop recipients copied to clipboard',
+ 'Copied',
+ );
+ }}
+ >
+ Copy to Clipboard
+
+
+
+ >
+ )}
+
+
+
+
+
+
+
+ >
+ );
+};
+
+Airdrop.defaultProps = {
+ passLoadingStatus: status => {
+ console.log(status);
+ },
+};
+
+Airdrop.propTypes = {
+ jestBCH: PropTypes.object,
+ passLoadingStatus: PropTypes.func,
+};
+
+export default Airdrop;
diff --git a/web/cashtab-v2/src/components/Airdrop/__tests__/Airdrop.test.js b/web/cashtab-v2/src/components/Airdrop/__tests__/Airdrop.test.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Airdrop/__tests__/Airdrop.test.js
@@ -0,0 +1,115 @@
+import React from 'react';
+import renderer from 'react-test-renderer';
+import { ThemeProvider } from 'styled-components';
+import { theme } from '@assets/styles/theme';
+import Airdrop from '@components/Airdrop/Airdrop';
+import BCHJS from '@psf/bch-js';
+import {
+ walletWithBalancesAndTokens,
+ walletWithBalancesMock,
+ walletWithoutBalancesMock,
+ walletWithBalancesAndTokensWithCorrectState,
+} from '../../Home/__mocks__/walletAndBalancesMock';
+import { BrowserRouter as Router } from 'react-router-dom';
+
+let realUseContext;
+let useContextMock;
+
+beforeEach(() => {
+ realUseContext = React.useContext;
+ useContextMock = React.useContext = jest.fn();
+
+ // Mock method not implemented in JSDOM
+ // See reference at https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: jest.fn().mockImplementation(query => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: jest.fn(), // Deprecated
+ removeListener: jest.fn(), // Deprecated
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+ })),
+ });
+});
+
+afterEach(() => {
+ React.useContext = realUseContext;
+});
+
+test('Wallet without BCH balance', () => {
+ useContextMock.mockReturnValue(walletWithoutBalancesMock);
+ const testBCH = new BCHJS();
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
+
+test('Wallet with BCH balances', () => {
+ useContextMock.mockReturnValue(walletWithBalancesMock);
+ const testBCH = new BCHJS();
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
+
+test('Wallet with BCH balances and tokens', () => {
+ useContextMock.mockReturnValue(walletWithBalancesAndTokens);
+ const testBCH = new BCHJS();
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
+
+test('Wallet with BCH balances and tokens and state field', () => {
+ useContextMock.mockReturnValue(walletWithBalancesAndTokensWithCorrectState);
+ const testBCH = new BCHJS();
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
+
+test('Without wallet defined', () => {
+ useContextMock.mockReturnValue({
+ wallet: {},
+ balances: { totalBalance: 0 },
+ loading: false,
+ });
+ const testBCH = new BCHJS();
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
diff --git a/web/cashtab-v2/src/components/Airdrop/__tests__/__snapshots__/Airdrop.test.js.snap b/web/cashtab-v2/src/components/Airdrop/__tests__/__snapshots__/Airdrop.test.js.snap
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Airdrop/__tests__/__snapshots__/Airdrop.test.js.snap
@@ -0,0 +1,400 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP @generated
+
+exports[`Wallet with BCH balances 1`] = `
+Array [
+
+
+ MigrationTestAlpha
+
+
+ You currently have 0
+ XEC
+
+ Deposit some funds to use this feature
+
+
,
+ ,
+
+
+
+
+
+
+
+
+
+
+
+ XEC Airdrop Calculator
+
+
+
+
+
+
,
+]
+`;
+
+exports[`Wallet with BCH balances and tokens 1`] = `
+Array [
+
+
+ MigrationTestAlpha
+
+
+ You currently have 0
+ XEC
+
+ Deposit some funds to use this feature
+
+
,
+ ,
+
+
+
+
+
+
+
+
+
+
+
+ XEC Airdrop Calculator
+
+
+
+
+
+
,
+]
+`;
+
+exports[`Wallet with BCH balances and tokens and state field 1`] = `
+Array [
+
+
+ MigrationTestAlpha
+
+
+ 0.06
+
+ XEC
+
+
,
+ ,
+
+
+
+
+
+
+
+
+
+
+
+ XEC Airdrop Calculator
+
+
+
+
+
+
,
+]
+`;
+
+exports[`Wallet without BCH balance 1`] = `
+Array [
+
+
+ MigrationTestAlpha
+
+
+ You currently have 0
+ XEC
+
+ Deposit some funds to use this feature
+
+
,
+ ,
+
+
+
+
+
+
+
+
+
+
+
+ XEC Airdrop Calculator
+
+
+
+
+
+
,
+]
+`;
+
+exports[`Without wallet defined 1`] = `
+Array [
+
+
+ You currently have 0
+ XEC
+
+ Deposit some funds to use this feature
+
+
,
+ ,
+
+
+
+
+
+
+
+
+
+
+
+ XEC Airdrop Calculator
+
+
+
+
+
+
,
+]
+`;
diff --git a/web/cashtab-v2/src/components/App.css b/web/cashtab-v2/src/components/App.css
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/App.css
@@ -0,0 +1,65 @@
+@import '~antd/dist/antd.less';
+@import '~@fortawesome/fontawesome-free/css/all.css';
+@import url('https://fonts.googleapis.com/css?family=Khula&display=swap&.css');
+
+@font-face {
+ font-family: 'Poppins';
+ src: local('Poppins'),
+ url(../assets/fonts/Poppins-Regular.ttf) format('truetype');
+ font-weight: normal;
+}
+
+@font-face {
+ font-family: 'Poppins';
+ src: local('Poppins'),
+ url(../assets/fonts/Poppins-Bold.ttf) format('truetype');
+ font-weight: 700;
+}
+
+/* Hide scrollbars but keep functionality*/
+/* Hide scrollbar for Chrome, Safari and Opera */
+body::-webkit-scrollbar {
+ display: none;
+}
+
+/* Hide scrollbar for IE, Edge and Firefox */
+body {
+ -ms-overflow-style: none; /* IE and Edge */
+ scrollbar-width: none; /* Firefox */
+}
+/* Hide up and down arros on input type="number" */
+/* Chrome, Safari, Edge, Opera */
+input::-webkit-outer-spin-button,
+input::-webkit-inner-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+
+/* Hide up and down arros on input type="number" */
+/* Firefox */
+input[type='number'] {
+ -moz-appearance: textfield;
+}
+
+html,
+body {
+ max-width: 100%;
+ overflow-x: hidden;
+}
+
+/* Hide scroll bars on antd modals*/
+.ant-modal-wrap.ant-modal-centered::-webkit-scrollbar {
+ display: none;
+}
+
+/* ITEMS BELOW TO BE MOVED TO STYLED COMPONENTS*/
+
+/* useWallet.js, useBCH.js */
+@media (max-width: 768px) {
+ .ant-notification {
+ width: 100%;
+ top: 20px !important;
+ max-width: unset;
+ margin-right: 0;
+ }
+}
diff --git a/web/cashtab-v2/src/components/App.js b/web/cashtab-v2/src/components/App.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/App.js
@@ -0,0 +1,378 @@
+import React, { useState } from 'react';
+import 'antd/dist/antd.less';
+import { Spin } from 'antd';
+import {
+ CashLoadingIcon,
+ HomeIcon,
+ SendIcon,
+ ReceiveIcon,
+ SettingsIcon,
+ AirdropIcon,
+} from '@components/Common/CustomIcons';
+import '../index.css';
+import styled, { ThemeProvider, createGlobalStyle } from 'styled-components';
+import { theme } from '@assets/styles/theme';
+import Home from '@components/Home/Home';
+import Receive from '@components/Receive/Receive';
+import Tokens from '@components/Tokens/Tokens';
+import Send from '@components/Send/Send';
+import SendToken from '@components/Send/SendToken';
+import Airdrop from '@components/Airdrop/Airdrop';
+import Configure from '@components/Configure/Configure';
+import NotFound from '@components/NotFound';
+import CashTab from '@assets/cashtab_xec.png';
+import './App.css';
+import { WalletContext } from '@utils/context';
+import { isValidStoredWallet } from '@utils/cashMethods';
+import {
+ Route,
+ Redirect,
+ Switch,
+ useLocation,
+ useHistory,
+} from 'react-router-dom';
+// Easter egg imports not used in extension/src/components/App.js
+import TabCash from '@assets/tabcash.png';
+import { checkForTokenById } from '@utils/tokenMethods.js';
+// Biometric security import not used in extension/src/components/App.js
+import ProtectableComponentWrapper from './Authentication/ProtectableComponentWrapper';
+
+const GlobalStyle = createGlobalStyle`
+ *::placeholder {
+ color: ${props => props.theme.forms.placeholder} !important;
+ }
+ *::selection {
+ background: ${props => props.theme.eCashBlue} !important;
+ }
+ .ant-modal-content, .ant-modal-header, .ant-modal-title {
+ background-color: ${props => props.theme.modal.background} !important;
+ color: ${props => props.theme.modal.color} !important;
+ }
+ .ant-modal-content svg {
+ fill: ${props => props.theme.modal.color};
+ }
+ .ant-modal-footer button {
+ background-color: ${props =>
+ props.theme.modal.buttonBackground} !important;
+ color: ${props => props.theme.modal.color} !important;
+ border-color: ${props => props.theme.modal.border} !important;
+ :hover {
+ background-color: ${props => props.theme.eCashBlue} !important;
+ }
+ }
+ .ant-modal-wrap > div > div.ant-modal-content > div > div > div.ant-modal-confirm-btns > button, .ant-modal > button, .ant-modal-confirm-btns > button, .ant-modal-footer > button, #cropControlsConfirm {
+ border-radius: 3px;
+ background-color: ${props =>
+ props.theme.modal.buttonBackground} !important;
+ color: ${props => props.theme.modal.color} !important;
+ border-color: ${props => props.theme.modal.border} !important;
+ :hover {
+ background-color: ${props => props.theme.eCashBlue} !important;
+ }
+ text-shadow: none !important;
+ }
+
+ .ant-modal-wrap > div > div.ant-modal-content > div > div > div.ant-modal-confirm-btns > button:hover,.ant-modal-confirm-btns > button:hover, .ant-modal-footer > button:hover, #cropControlsConfirm:hover {
+ color: ${props => props.theme.contrast};
+ transition: all 0.3s;
+ background-color: ${props => props.theme.eCashBlue};
+ border-color: ${props => props.theme.eCashBlue};
+ }
+ .selectedCurrencyOption, .ant-select-dropdown {
+ text-align: left;
+ color: ${props => props.theme.contrast} !important;
+ background-color: ${props =>
+ props.theme.collapses.expandedBackground} !important;
+ }
+ .cashLoadingIcon {
+ color: ${props => props.theme.eCashBlue} !important;
+ font-size: 48px !important;
+ }
+ .selectedCurrencyOption:hover {
+ color: ${props => props.theme.contrast} !important;
+ background-color: ${props => props.theme.eCashBlue} !important;
+ }
+ #addrSwitch, #cropSwitch {
+ .ant-switch-checked {
+ background-color: white !important;
+ }
+ }
+ #addrSwitch.ant-switch-checked, #cropSwitch.ant-switch-checked {
+ background-image: ${props =>
+ props.theme.buttons.primary.backgroundImage} !important;
+ }
+
+ .ant-slider-rail {
+ background-color: ${props => props.theme.forms.border} !important;
+ }
+ .ant-slider-track {
+ background-color: ${props => props.theme.eCashBlue} !important;
+ }
+ .ant-descriptions-bordered .ant-descriptions-row {
+ background: ${props => props.theme.contrast};
+ }
+ .ant-modal-confirm-content, .ant-modal-confirm-title {
+ color: ${props => props.theme.contrast} !important;
+ }
+`;
+
+const CustomApp = styled.div`
+ text-align: center;
+ font-family: 'Poppins', sans-serif;
+ background-color: ${props => props.theme.backgroundColor};
+ background-size: 100px 171px;
+ background-image: ${props => props.theme.backgroundImage};
+ background-attachment: fixed;
+`;
+
+const Footer = styled.div`
+ z-index: 2;
+ height: 80px;
+ border-top: 1px solid rgba(255, 255, 255, 0.5);
+ background-color: ${props => props.theme.footerBackground};
+ position: fixed;
+ bottom: 0;
+ width: 500px;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0 50px;
+ @media (max-width: 768px) {
+ width: 100%;
+ padding: 0 20px;
+ }
+`;
+
+export const NavButton = styled.button`
+ :focus,
+ :active {
+ outline: none;
+ }
+ cursor: pointer;
+ padding: 0;
+ background: none;
+ border: none;
+ font-size: 10px;
+ svg {
+ fill: ${props => props.theme.contrast};
+ width: 26px;
+ height: auto;
+ }
+ ${({ active, ...props }) =>
+ active &&
+ `
+ color: ${props.theme.navActive};
+ svg {
+ fill: ${props.theme.navActive};
+ }
+ `}
+`;
+
+export const WalletBody = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ min-height: 100vh;
+`;
+
+export const WalletCtn = styled.div`
+ position: relative;
+ width: 500px;
+ min-height: 100vh;
+ padding: 0 0 100px;
+ background: ${props => props.theme.walletBackground};
+ -webkit-box-shadow: 0px 0px 24px 1px ${props => props.theme.shadow};
+ -moz-box-shadow: 0px 0px 24px 1px ${props => props.theme.shadow};
+ box-shadow: 0px 0px 24px 1px ${props => props.theme.shadow};
+ @media (max-width: 768px) {
+ width: 100%;
+ -webkit-box-shadow: none;
+ -moz-box-shadow: none;
+ box-shadow: none;
+ }
+`;
+
+export const HeaderCtn = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ padding: 15px 0;
+`;
+
+export const CashTabLogo = styled.img`
+ width: 120px;
+ @media (max-width: 768px) {
+ width: 110px;
+ }
+`;
+
+// AbcLogo styled component not included in extension, replaced by open in new tab link
+export const AbcLogo = styled.img`
+ width: 150px;
+ @media (max-width: 768px) {
+ width: 120px;
+ }
+`;
+
+// Easter egg styled component not used in extension/src/components/App.js
+export const EasterEgg = styled.img`
+ position: fixed;
+ bottom: -195px;
+ margin: 0;
+ right: 10%;
+ transition-property: bottom;
+ transition-duration: 1.5s;
+ transition-timing-function: ease-out;
+
+ :hover {
+ bottom: 0;
+ }
+
+ @media screen and (max-width: 1250px) {
+ display: none;
+ }
+`;
+
+const App = () => {
+ const ContextValue = React.useContext(WalletContext);
+ const { wallet, loading } = ContextValue;
+ const [loadingUtxosAfterSend, setLoadingUtxosAfterSend] = useState(false);
+ // If wallet is unmigrated, do not show page until it has migrated
+ // An invalid wallet will be validated/populated after the next API call, ETA 10s
+ const validWallet = isValidStoredWallet(wallet);
+ const location = useLocation();
+ const history = useHistory();
+ const selectedKey =
+ location && location.pathname ? location.pathname.substr(1) : '';
+
+ // Easter egg boolean not used in extension/src/components/App.js
+ const hasTab = validWallet
+ ? checkForTokenById(
+ wallet.state.tokens,
+ '50d8292c6255cda7afc6c8566fed3cf42a2794e9619740fe8f4c95431271410e',
+ )
+ : false;
+
+ return (
+
+
+
+
+
+
+
+
+ {/*Begin component not included in extension as desktop only*/}
+ {hasTab && (
+
+ )}
+ {/*End component not included in extension as desktop only*/}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ (
+
+ )}
+ />
+
+
+
+
+
+
+
+
+
+
+
+ {wallet ? (
+
+ history.push('/wallet')}
+ >
+
+
+
+ history.push('/send')}
+ >
+
+
+ history.push('receive')}
+ >
+
+
+ history.push('/airdrop')}
+ >
+
+
+ history.push('/configure')}
+ >
+
+
+
+ ) : null}
+
+
+
+
+ );
+};
+
+export default App;
diff --git a/web/cashtab-v2/src/components/Authentication/ProtectableComponentWrapper.js b/web/cashtab-v2/src/components/Authentication/ProtectableComponentWrapper.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Authentication/ProtectableComponentWrapper.js
@@ -0,0 +1,32 @@
+import React, { useContext } from 'react';
+import { AuthenticationContext } from '@utils/context';
+import SignUp from './SignUp';
+import SignIn from './SignIn';
+
+const ProtectableComponentWrapper = ({ children }) => {
+ const authentication = useContext(AuthenticationContext);
+
+ if (authentication) {
+ const { loading, isAuthenticationRequired, isSignedIn } =
+ authentication;
+
+ if (loading) {
+ return Loading authenticaion data...
;
+ }
+
+ // prompt if user would like to enable biometric lock when the app first run
+ if (isAuthenticationRequired === undefined) {
+ return ;
+ }
+
+ // prompt user to sign in
+ if (isAuthenticationRequired && !isSignedIn) {
+ return ;
+ }
+ }
+
+ // authentication = null => authentication is not supported
+ return <>{children}>;
+};
+
+export default ProtectableComponentWrapper;
diff --git a/web/cashtab-v2/src/components/Authentication/SignIn.js b/web/cashtab-v2/src/components/Authentication/SignIn.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Authentication/SignIn.js
@@ -0,0 +1,155 @@
+import React, { useContext, useEffect, useState } from 'react';
+import { Modal, Spin } from 'antd';
+import styled from 'styled-components';
+import { AuthenticationContext } from '@utils/context';
+import { ThemedLockOutlined } from '@components/Common/CustomIcons';
+import PrimaryButton from '@components/Common/PrimaryButton';
+import { ReactComponent as FingerprintSVG } from '@assets/fingerprint-solid.svg';
+
+const StyledSignIn = styled.div`
+ h2 {
+ color: ${props => props.theme.contrast};
+ font-size: 25px;
+ }
+ p {
+ color: ${props => props.theme.darkBlue};
+ }
+`;
+
+const UnlockButton = styled(PrimaryButton)`
+ position: relative;
+ width: auto;
+ margin: 30px auto;
+ padding: 20px 30px;
+
+ svg {
+ fill: ${props => props.theme.buttons.primary.color};
+ }
+
+ @media (max-width: 768px) {
+ font-size: 16px;
+ padding: 15px 20px;
+ }
+
+ :disabled {
+ cursor: not-allowed;
+ box-shadow: none;
+ ::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: ${props => props.theme.buttons.primary.disabledOverlay};
+ z-index: 10;
+ }
+ }
+`;
+
+const StyledFingerprintIcon = styled.div`
+ width: 48px;
+ height: 48px;
+ margin: auto;
+`;
+
+const SignIn = () => {
+ const authentication = useContext(AuthenticationContext);
+ const [isVisible, setIsVisible] = useState(false);
+ const [isLoading, setIsLoading] = useState(false);
+
+ const handleDocVisibilityChange = () => {
+ document.visibilityState === 'visible'
+ ? setIsVisible(true)
+ : setIsVisible(false);
+ };
+
+ const handleSignIn = async () => {
+ try {
+ setIsLoading(true);
+ await authentication.signIn();
+ } catch (err) {
+ Modal.error({
+ title: 'Authentication Error',
+ content: 'Cannot get Credential from your device',
+ centered: true,
+ });
+ }
+ setIsLoading(false);
+ };
+
+ const handleSignInAndSuppressError = async () => {
+ try {
+ setIsLoading(true);
+ await authentication.signIn();
+ } catch (err) {
+ // fail silently
+ }
+ setIsLoading(false);
+ };
+
+ useEffect(() => {
+ if (document.visibilityState === 'visible') {
+ setIsVisible(true);
+ }
+ document.addEventListener(
+ 'visibilitychange',
+ handleDocVisibilityChange,
+ );
+
+ return () => {
+ document.removeEventListener(
+ 'visibilitychange',
+ handleDocVisibilityChange,
+ );
+ };
+ }, []);
+
+ useEffect(() => {
+ // This will trigger the plaform authenticator as soon as the component becomes visible
+ // (when switch back to this app), without any user gesture (such as clicking a button)
+ // In platforms that require user gesture in order to invoke the platform authenticator,
+ // this will fail. We want it to fail silently, and then show user a button to activate
+ // the platform authenticator
+ if (isVisible && authentication) {
+ handleSignInAndSuppressError();
+ }
+ }, [isVisible]);
+
+ let signInBody;
+ if (authentication) {
+ signInBody = (
+ <>
+
+ This wallet can be unlocked with your{' '}
+ fingerprint / device pin
+
+
+
+
+
+ Unlock
+
+
+ {isLoading ? : ''}
+
+ >
+ );
+ } else {
+ signInBody = Authentication is not supported
;
+ }
+
+ return (
+
+
+ Wallet Unlock
+
+ {signInBody}
+
+ );
+};
+
+export default SignIn;
diff --git a/web/cashtab-v2/src/components/Authentication/SignUp.js b/web/cashtab-v2/src/components/Authentication/SignUp.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Authentication/SignUp.js
@@ -0,0 +1,76 @@
+import React, { useContext } from 'react';
+import { Modal } from 'antd';
+import styled from 'styled-components';
+import { AuthenticationContext } from '@utils/context';
+import { ThemedLockOutlined } from '@components/Common/CustomIcons';
+import PrimaryButton, {
+ SecondaryButton,
+} from '@components/Common/PrimaryButton';
+
+const StyledSignUp = styled.div`
+ padding: 0px 30px;
+ margin-top: 20px;
+ h2 {
+ color: ${props => props.theme.contrast};
+ font-size: 25px;
+ }
+ p {
+ color: ${props => props.theme.contrast};
+ }
+`;
+
+const SignUp = () => {
+ const authentication = useContext(AuthenticationContext);
+
+ const handleSignUp = async () => {
+ try {
+ await authentication.signUp();
+ } catch (err) {
+ Modal.error({
+ title: 'Registration Error',
+ content: 'Cannot create Credential on your device',
+ centered: true,
+ });
+ }
+ };
+
+ let signUpBody;
+ if (authentication) {
+ signUpBody = (
+
+
Enable wallet lock to protect your funds.
+
+ You will need to unlock with your{' '}
+ fingerprint / device pin in order to access
+ the wallet.
+
+
+ This lock can also be enabled / disabled under
+
+ Settings / General Settings / App Lock
+
+
+ Enable Lock
+
+
authentication.turnOffAuthentication()}
+ >
+ Skip
+
+
+ );
+ } else {
+ signUpBody = Authentication is not supported
;
+ }
+
+ return (
+
+
+ Wallet Lock
+
+ {signUpBody}
+
+ );
+};
+
+export default SignUp;
diff --git a/web/cashtab-v2/src/components/Common/ApiError.js b/web/cashtab-v2/src/components/Common/ApiError.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Common/ApiError.js
@@ -0,0 +1,17 @@
+import * as React from 'react';
+import { CashLoader } from '@components/Common/CustomIcons';
+import { AlertMsg } from '@components/Common/Atoms';
+
+const ApiError = () => {
+ return (
+ <>
+
+ API connection lost.
+ Re-establishing connection...
+
+
+ >
+ );
+};
+
+export default ApiError;
diff --git a/web/cashtab-v2/src/components/Common/Atoms.js b/web/cashtab-v2/src/components/Common/Atoms.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Common/Atoms.js
@@ -0,0 +1,98 @@
+import styled from 'styled-components';
+import { Link } from 'react-router-dom';
+
+export const WarningFont = styled.div`
+ color: ${props => props.theme.wallet.text.primary};
+`;
+
+export const LoadingCtn = styled.div`
+ width: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ height: 400px;
+ flex-direction: column;
+
+ svg {
+ width: 50px;
+ height: 50px;
+ fill: ${props => props.theme.eCashBlue};
+ }
+`;
+
+export const SidePaddingCtn = styled.div`
+ padding: 0px 30px;
+ @media (max-width: 768px) {
+ padding: 0px 15px;
+ }
+`;
+
+export const FormLabel = styled.label`
+ font-size: 16px;
+ margin-bottom: 5px;
+ text-align: left;
+ width: 100%;
+ display: inline-block;
+ color: ${props => props.theme.contrast};
+`;
+
+export const WalletInfoCtn = styled.div`
+ background: ${props => props.theme.walletInfoContainer};
+ width: 100%;
+ padding: 40px 20px;
+`;
+
+export const BalanceHeaderFiatWrap = styled.div`
+ color: ${props => props.theme.contrast};
+ width: 100%;
+ font-size: 16px;
+ @media (max-width: 768px) {
+ font-size: 16px;
+ }
+`;
+
+export const BalanceHeaderWrap = styled.div`
+ color: ${props => props.theme.contrast};
+ width: 100%;
+ font-size: 28px;
+ margin-bottom: 0px;
+ font-weight: bold;
+ line-height: 1.4em;
+ @media (max-width: 768px) {
+ font-size: 24px;
+ }
+`;
+
+export const ZeroBalanceHeader = styled.div`
+ color: ${props => props.theme.contrast};
+ width: 100%;
+ font-size: 14px;
+ margin-bottom: 5px;
+`;
+
+export const TokenParamLabel = styled.span`
+ font-weight: bold;
+`;
+
+export const AlertMsg = styled.p`
+ color: ${props => props.theme.forms.error} !important;
+`;
+
+export const ConvertAmount = styled.div`
+ color: ${props => props.theme.contrast};
+ width: 100%;
+ font-size: 14px;
+ margin-bottom: 10px;
+ @media (max-width: 768px) {
+ font-size: 12px;
+ }
+`;
+
+export const StyledLink = styled(Link)`
+ color: ${props => props.theme.buttons.styledLink};
+ text-decoration: none;
+ padding: 8px;
+ position: relative;
+ border: solid 1px silver;
+ border-radius: 10px;
+`;
diff --git a/web/cashtab-v2/src/components/Common/BalanceHeader.js b/web/cashtab-v2/src/components/Common/BalanceHeader.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Common/BalanceHeader.js
@@ -0,0 +1,20 @@
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { formatBalance } from '@utils/formatting';
+import { BalanceHeaderWrap } from '@components/Common/Atoms';
+
+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-v2/src/components/Common/BalanceHeaderFiat.js b/web/cashtab-v2/src/components/Common/BalanceHeaderFiat.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Common/BalanceHeaderFiat.js
@@ -0,0 +1,49 @@
+import * as React from 'react';
+import styled from 'styled-components';
+import PropTypes from 'prop-types';
+import { BalanceHeaderFiatWrap } from '@components/Common/Atoms';
+import { currency } from '@components/Common/Ticker.js';
+const FiatCurrencyToXEC = styled.p`
+ margin: 0 auto;
+ padding: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ color: ${props => props.theme.lightWhite};
+`;
+const BalanceHeaderFiat = ({ balance, settings, fiatPrice }) => {
+ return (
+ <>
+ {fiatPrice && (
+
+ {settings
+ ? `${
+ currency.fiatCurrencies[settings.fiatCurrency]
+ .symbol
+ }`
+ : '$'}
+ {parseFloat(
+ (balance * fiatPrice).toFixed(2),
+ ).toLocaleString()}{' '}
+ {settings
+ ? `${currency.fiatCurrencies[
+ settings.fiatCurrency
+ ].slug.toUpperCase()} `
+ : 'USD'}
+
+ 1 {currency.ticker} ={' '}
+ {fiatPrice.toFixed(9).toLocaleString()}{' '}
+ {settings.fiatCurrency.toUpperCase()}
+
+
+ )}
+ >
+ );
+};
+
+BalanceHeaderFiat.propTypes = {
+ balance: PropTypes.number,
+ settings: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]),
+ fiatPrice: PropTypes.number,
+};
+
+export default BalanceHeaderFiat;
diff --git a/web/cashtab-v2/src/components/Common/CropControlModal.js b/web/cashtab-v2/src/components/Common/CropControlModal.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Common/CropControlModal.js
@@ -0,0 +1,59 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import styled from 'styled-components';
+import { Card, Modal } from 'antd';
+
+const CropModal = styled(Modal)`
+ .ant-modal-close-x {
+ font-size: 2px;
+ }
+`;
+
+export const CropperContainer = styled.div`
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 175px;
+`;
+export const ControlsContainer = styled.div`
+ position: absolute;
+ padding: 12px;
+ bottom: 0;
+ left: 50%;
+ width: 50%;
+ transform: translateX(-50%);
+ height: 175px;
+ display: block;
+ align-items: center;
+`;
+
+export const CropControlModal = ({
+ expand,
+ renderExpanded = () => null,
+ onClose,
+ style,
+ ...otherProps
+}) => {
+ return (
+
+
+ {renderExpanded()}
+
+
+ );
+};
+CropControlModal.propTypes = {
+ expand: PropTypes.bool,
+ renderExpanded: PropTypes.func,
+ onClose: PropTypes.func,
+ style: PropTypes.object,
+};
diff --git a/web/cashtab-v2/src/components/Common/CustomIcons.js b/web/cashtab-v2/src/components/Common/CustomIcons.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Common/CustomIcons.js
@@ -0,0 +1,110 @@
+import * as React from 'react';
+import styled from 'styled-components';
+import {
+ CopyOutlined,
+ DollarOutlined,
+ LoadingOutlined,
+ WalletOutlined,
+ QrcodeOutlined,
+ SettingOutlined,
+ LockOutlined,
+} from '@ant-design/icons';
+import { Image } from 'antd';
+import { currency } from '@components/Common/Ticker';
+import { ReactComponent as Send } from '@assets/send.svg';
+import { ReactComponent as Receive } from '@assets/receive.svg';
+import { ReactComponent as Genesis } from '@assets/flask.svg';
+import { ReactComponent as Unparsed } from '@assets/alert-circle.svg';
+import { ReactComponent as Home } from '@assets/home.svg';
+import { ReactComponent as Settings } from '@assets/cog.svg';
+import { ReactComponent as CopySolid } from '@assets/copy.svg';
+import { ReactComponent as LinkSolid } from '@assets/external-link-square-alt.svg';
+import { ReactComponent as Airdrop } from '@assets/airdrop-icon.svg';
+
+export const CashLoadingIcon = ;
+
+export const CashReceivedNotificationIcon = () => (
+
+);
+export const TokenReceivedNotificationIcon = () => (
+
+);
+
+export const MessageSignedNotificationIcon = () => (
+
+);
+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.walletBackground} !important;
+`;
+export const ThemedSettingOutlined = styled(SettingOutlined)`
+ color: ${props => props.theme.icons.outlined} !important;
+`;
+export const ThemedLockOutlined = styled(LockOutlined)`
+ color: ${props => props.theme.icons.outlined} !important;
+`;
+
+export const ThemedCopySolid = styled(CopySolid)`
+ fill: ${props => props.theme.contrast};
+ padding: 0rem 0rem 0.27rem 0rem;
+ height: 1.3em;
+ width: 1.3em;
+`;
+
+export const ThemedLinkSolid = styled(LinkSolid)`
+ fill: ${props => props.theme.contrast};
+ padding: 0.15rem 0rem 0.18rem 0rem;
+ height: 1.3em;
+ width: 1.3em;
+`;
+
+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.eCashBlue};
+ }
+`;
+
+export const CashLoader = () => (
+
+
+
+);
+
+export const ReceiveIcon = () => ;
+export const GenesisIcon = () => ;
+export const UnparsedIcon = () => ;
+export const HomeIcon = () => ;
+export const SettingsIcon = () => ;
+
+export const AirdropIcon = () => ;
+
+export const SendIcon = styled(Send)`
+ transform: rotate(-35deg);
+`;
+export const CustomSpinner = ;
diff --git a/web/cashtab-v2/src/components/Common/EnhancedInputs.js b/web/cashtab-v2/src/components/Common/EnhancedInputs.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Common/EnhancedInputs.js
@@ -0,0 +1,375 @@
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import { Form, Input, Select } from 'antd';
+const { TextArea } = Input;
+import {
+ ThemedDollarOutlined,
+ ThemedWalletOutlined,
+} from '@components/Common/CustomIcons';
+import styled, { css } from 'styled-components';
+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: 45px;
+ }
+ textarea.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;
+ min-height: 100px;
+ }
+ .ant-input-affix-wrapper {
+ background-color: ${props => props.theme.forms.selectionBackground};
+ border: 1px solid ${props => props.theme.forms.border} !important;
+ }
+ .ant-input-wrapper .anticon-qrcode {
+ color: ${props => props.theme.forms.addonForeground} !important;
+ }
+ input.ant-input::placeholder,
+ .ant-select-selection::placeholder {
+ color: ${props => props.theme.forms.placeholder} !important;
+ }
+ .ant-select-selector {
+ height: 55px !important;
+ border: 1px solid ${props => props.theme.forms.border} !important;
+ background-color: ${props =>
+ props.theme.forms.selectionBackground}!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-input:hover {
+ border-color: ${props => props.theme.forms.highlightBox};
+ }
+
+ .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: 55px;
+ 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 (
+
+ {currency.label}
+
+ );
+ });
+
+ const CurrencySelect = (
+
+ {currencyOptions}
+
+ );
+ return (
+
+
+
+
+ ) : (
+
+ )
+ }
+ {...inputProps}
+ />
+ {CurrencySelect}
+
+ max
+
+
+
+
+ );
+};
+
+SendBchInput.propTypes = {
+ onMax: PropTypes.func,
+ inputProps: PropTypes.object,
+ selectProps: PropTypes.object,
+ activeFiatCode: PropTypes.string,
+};
+
+export const DestinationAmount = ({ onMax, inputProps, ...otherProps }) => {
+ return (
+
+
+
+ }
+ addonAfter={
+
+ max
+
+ }
+ {...inputProps}
+ />
+
+
+ );
+};
+
+DestinationAmount.propTypes = {
+ onMax: PropTypes.func,
+ inputProps: PropTypes.object,
+};
+
+// loadWithCameraOpen prop: if true, load page with camera scanning open
+export const DestinationAddressSingle = ({
+ onScan,
+ loadWithCameraOpen,
+ inputProps,
+ ...otherProps
+}) => {
+ return (
+
+
+ }
+ autoComplete="off"
+ addonAfter={
+
+ }
+ {...inputProps}
+ />
+
+
+ );
+};
+
+DestinationAddressSingle.propTypes = {
+ onScan: PropTypes.func,
+ loadWithCameraOpen: PropTypes.bool,
+ inputProps: PropTypes.object,
+};
+
+export const DestinationAddressMulti = ({ inputProps, ...otherProps }) => {
+ return (
+
+
+ }
+ autoComplete="off"
+ {...inputProps}
+ />
+
+
+ );
+};
+
+DestinationAddressMulti.propTypes = {
+ 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 (
+
+ {currencyMenuOption.label}
+
+ );
+ });
+
+ return (
+
+ {currencyOptions}
+
+ );
+};
+
+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-v2/src/components/Common/Notifications.js b/web/cashtab-v2/src/components/Common/Notifications.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Common/Notifications.js
@@ -0,0 +1,210 @@
+import * as React from 'react';
+import { notification } from 'antd';
+import {
+ CashReceivedNotificationIcon,
+ TokenReceivedNotificationIcon,
+} from '@components/Common/CustomIcons';
+import Paragraph from 'antd/lib/typography/Paragraph';
+import { currency } from '@components/Common/Ticker';
+import { MessageSignedNotificationIcon } from '@components/Common/CustomIcons';
+import { isMobile } from 'react-device-detect';
+
+const getDeviceNotificationStyle = () => {
+ if (isMobile) {
+ const notificationStyle = {
+ width: '100%',
+ marginTop: '10%',
+ };
+ return notificationStyle;
+ }
+ if (!isMobile) {
+ const notificationStyle = {
+ width: '100%',
+ };
+ return notificationStyle;
+ }
+};
+
+// Success Notifications:
+const sendXecNotification = link => {
+ const notificationStyle = getDeviceNotificationStyle();
+ notification.success({
+ message: 'Success',
+ description: (
+
+
+ Transaction successful. Click to view in block explorer.
+
+
+ ),
+ duration: currency.notificationDurationShort,
+ icon: ,
+ style: notificationStyle,
+ });
+};
+
+const createTokenNotification = link => {
+ const notificationStyle = getDeviceNotificationStyle();
+ notification.success({
+ message: 'Success',
+ description: (
+
+
+ Token created! Click to view in block explorer.
+
+
+ ),
+ icon: ,
+ style: notificationStyle,
+ });
+};
+
+const tokenIconSubmitSuccess = () => {
+ const notificationStyle = getDeviceNotificationStyle();
+ notification.success({
+ message: 'Success',
+ description: (
+ Your eToken icon was successfully submitted.
+ ),
+ icon: ,
+ style: notificationStyle,
+ });
+};
+
+const sendTokenNotification = link => {
+ const notificationStyle = getDeviceNotificationStyle();
+ notification.success({
+ message: 'Success',
+ description: (
+
+
+ Transaction successful. Click to view in block explorer.
+
+
+ ),
+ duration: currency.notificationDurationShort,
+ icon: ,
+ style: notificationStyle,
+ });
+};
+
+const burnTokenNotification = link => {
+ const notificationStyle = getDeviceNotificationStyle();
+ notification.success({
+ message: 'Success',
+ description: (
+
+
+ eToken burn successful. Click to view in block explorer.
+
+
+ ),
+ duration: currency.notificationDurationLong,
+ icon: ,
+ style: notificationStyle,
+ });
+};
+
+const xecReceivedNotification = (
+ balances,
+ previousBalances,
+ cashtabSettings,
+ fiatPrice,
+) => {
+ const notificationStyle = getDeviceNotificationStyle();
+ notification.success({
+ message: 'Transaction received',
+ description: (
+
+ +{' '}
+ {parseFloat(
+ Number(
+ balances.totalBalance - previousBalances.totalBalance,
+ ).toFixed(currency.cashDecimals),
+ ).toLocaleString()}{' '}
+ {currency.ticker}{' '}
+ {cashtabSettings &&
+ cashtabSettings.fiatCurrency &&
+ `(${
+ currency.fiatCurrencies[cashtabSettings.fiatCurrency]
+ .symbol
+ }${(
+ Number(
+ balances.totalBalance -
+ previousBalances.totalBalance,
+ ) * fiatPrice
+ ).toFixed(
+ currency.cashDecimals,
+ )} ${cashtabSettings.fiatCurrency.toUpperCase()})`}
+
+ ),
+ duration: currency.notificationDurationShort,
+ icon: ,
+ style: notificationStyle,
+ });
+};
+
+const eTokenReceivedNotification = (
+ currency,
+ receivedSlpTicker,
+ receivedSlpQty,
+ receivedSlpName,
+) => {
+ const notificationStyle = getDeviceNotificationStyle();
+ notification.success({
+ message: `${currency.tokenTicker} transaction received: ${receivedSlpTicker}`,
+ description: (
+
+ You received {receivedSlpQty.toString()} {receivedSlpName}
+
+ ),
+ duration: currency.notificationDurationShort,
+ icon: ,
+ style: notificationStyle,
+ });
+};
+
+// Error Notification:
+
+const errorNotification = (error, message, stringDescribingCallEvent) => {
+ const notificationStyle = getDeviceNotificationStyle();
+ console.log(error, message, stringDescribingCallEvent);
+ notification.error({
+ message: 'Error',
+ description: message,
+ duration: currency.notificationDurationLong,
+ style: notificationStyle,
+ });
+};
+
+const messageSignedNotification = msgSignature => {
+ const notificationStyle = getDeviceNotificationStyle();
+ notification.success({
+ message: 'Message Signature Generated',
+ description: {msgSignature} ,
+ icon: ,
+ style: notificationStyle,
+ });
+};
+
+const generalNotification = (data, msgStr) => {
+ const notificationStyle = getDeviceNotificationStyle();
+ notification.success({
+ message: msgStr,
+ description: data,
+ style: notificationStyle,
+ });
+};
+
+export {
+ sendXecNotification,
+ createTokenNotification,
+ tokenIconSubmitSuccess,
+ sendTokenNotification,
+ xecReceivedNotification,
+ eTokenReceivedNotification,
+ errorNotification,
+ messageSignedNotification,
+ generalNotification,
+ burnTokenNotification,
+};
diff --git a/web/cashtab-v2/src/components/Common/PrimaryButton.js b/web/cashtab-v2/src/components/Common/PrimaryButton.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Common/PrimaryButton.js
@@ -0,0 +1,98 @@
+import styled from 'styled-components';
+
+const PrimaryButton = styled.button`
+ border: 2px solid ${props => props.theme.eCashBlue};
+ color: ${props => props.theme.buttons.primary.color};
+ background: none;
+ font-weight: bold;
+ background-color: ${props => props.theme.eCashBlue};
+ transition: all 0.5s ease;
+ background-size: 200% auto;
+ font-size: 18px;
+ width: 100%;
+ padding: 20px 0;
+ border-radius: 0px;
+ margin-bottom: 20px;
+ cursor: pointer;
+ :hover {
+ background-position: right center;
+ -webkit-box-shadow: ${props => props.theme.buttons.primary.hoverShadow};
+ -moz-box-shadow: ${props => props.theme.buttons.primary.hoverShadow};
+ box-shadow: ${props => props.theme.buttons.primary.hoverShadow};
+ }
+ svg {
+ fill: ${props => props.theme.buttons.primary.color};
+ }
+ @media (max-width: 768px) {
+ font-size: 16px;
+ padding: 15px 0;
+ }
+`;
+
+const SecondaryButton = styled.button`
+ border: none;
+ color: ${props => props.theme.buttons.secondary.color};
+ background: ${props => props.theme.buttons.secondary.background};
+ transition: all 0.5s ease;
+ font-size: 18px;
+ width: 100%;
+ padding: 15px 0;
+ border-radius: 4px;
+ cursor: not-allowed;
+ outline: none;
+ margin-bottom: 20px;
+ :hover {
+ -webkit-box-shadow: ${props =>
+ props.theme.buttons.secondary.hoverShadow};
+ -moz-box-shadow: ${props => props.theme.buttons.secondary.hoverShadow};
+ box-shadow: ${props => props.theme.buttons.secondary.hoverShadow};
+ }
+ svg {
+ fill: ${props => props.theme.buttons.secondary.color};
+ }
+ @media (max-width: 768px) {
+ font-size: 16px;
+ padding: 12px 0;
+ }
+`;
+
+const SmartButton = styled.button`
+ ${({ disabled = false, ...props }) =>
+ disabled === true
+ ? `
+ background-image: 'none';
+ color: ${props.theme.buttons.secondary.color};
+ background: ${props.theme.buttons.secondary.background};
+ opacity: 0.3;
+ svg {
+ fill: ${props.theme.buttons.secondary.color};
+ }
+ `
+ : `
+ opacity: 1;
+ background-image: 'none';
+ color: ${props.theme.buttons.secondary.color};
+ background: ${props.theme.buttons.secondary.background};
+ svg {
+ fill: ${props.theme.buttons.secondary.color};
+ }
+ `}
+
+ border: none;
+ transition: all 0.5s ease;
+ font-size: 18px;
+ width: 100%;
+ padding: 15px 0;
+ border-radius: 4px;
+ cursor: pointer;
+ outline: none;
+ margin-bottom: 20px;
+
+ @media (max-width: 768px) {
+ font-size: 16px;
+ padding: 12px 0;
+ }
+`;
+
+export default PrimaryButton;
+export { SecondaryButton, SmartButton };
diff --git a/web/cashtab-v2/src/components/Common/QRCode.js b/web/cashtab-v2/src/components/Common/QRCode.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Common/QRCode.js
@@ -0,0 +1,244 @@
+import React, { useState } from 'react';
+import styled from 'styled-components';
+import RawQRCode from 'qrcode.react';
+import { currency } from '@components/Common/Ticker.js';
+import { CopyToClipboard } from 'react-copy-to-clipboard';
+import { Event } from '@utils/GoogleAnalytics';
+import { convertToEcashPrefix } from '@utils/cashMethods';
+
+export const StyledRawQRCode = styled(RawQRCode)`
+ cursor: pointer;
+ border-radius: 10px;
+ background: ${props => props.theme.qr.background};
+ margin-bottom: 10px;
+ path:first-child {
+ fill: ${props => props.theme.qr.background};
+ }
+ :hover {
+ border-color: ${({ xec = 0, ...props }) =>
+ xec === 1 ? props.theme.eCashBlue : props.theme.eCashPurple};
+ }
+ @media (max-width: 768px) {
+ border-radius: 18px;
+ width: 170px;
+ height: 170px;
+ }
+`;
+
+const Copied = styled.div`
+ font-size: 18px;
+ font-weight: bold;
+ width: 100%;
+ text-align: center;
+ background-color: ${({ xec = 0, ...props }) =>
+ xec === 1 ? props.theme.eCashBlue : props.theme.eCashPurple};
+ border: 1px solid;
+ border-color: ${({ xec = 0, ...props }) =>
+ xec === 1 ? props.theme.eCashBlue : props.theme.eCashPurple};
+ color: ${props => props.theme.contrast};
+ position: absolute;
+ top: 65px;
+ padding: 30px 0;
+ @media (max-width: 768px) {
+ top: 52px;
+ padding: 20px 0;
+ }
+`;
+const PrefixLabel = styled.span`
+ text-align: right;
+ font-weight: bold;
+ color: ${({ xec = 0, ...props }) =>
+ xec === 1 ? props.theme.eCashBlue : props.theme.eCashPurple};
+ @media (max-width: 768px) {
+ font-size: 12px;
+ }
+ @media (max-width: 400px) {
+ font-size: 10px;
+ }
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+`;
+const AddressHighlightTrim = styled.span`
+ font-weight: bold;
+ color: ${props => props.theme.contrast};
+ @media (max-width: 768px) {
+ font-size: 12px;
+ }
+ @media (max-width: 400px) {
+ font-size: 10px;
+ }
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+`;
+
+const CustomInput = styled.div`
+ font-size: 14px;
+ color: ${props => props.theme.lightWhite};
+ text-align: center;
+ cursor: pointer;
+ margin-bottom: 10px;
+ padding: 6px 0;
+ font-family: 'Roboto Mono', monospace;
+ border-radius: 5px;
+ -webkit-touch-callout: none;
+ -webkit-user-select: none;
+ -khtml-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+
+ input {
+ border: none;
+ width: 100%;
+ text-align: center;
+ -webkit-user-select: none;
+ -moz-user-select: none;
+ -ms-user-select: none;
+ user-select: none;
+ cursor: pointer;
+ color: ${props => props.theme.contrast};
+ padding: 10px 0;
+ background: transparent;
+ margin-bottom: 15px;
+ display: none;
+ }
+ input:focus {
+ outline: none;
+ }
+ input::selection {
+ background: transparent;
+ color: ${props => props.theme.contrast};
+ }
+ @media (max-width: 768px) {
+ font-size: 10px;
+ input {
+ font-size: 10px;
+ margin-bottom: 10px;
+ }
+ }
+ @media (max-width: 400px) {
+ font-size: 7px;
+ input {
+ font-size: 10px;
+ margin-bottom: 10px;
+ }
+ }
+`;
+
+export const QRCode = ({
+ address,
+ isCashAddress,
+ size = 210,
+ onClick = () => null,
+ ...otherProps
+}) => {
+ address = address ? convertToEcashPrefix(address) : '';
+
+ const [visible, setVisible] = useState(false);
+ const trimAmount = 8;
+ const address_trim = address ? address.length - trimAmount : '';
+ const addressSplit = address ? address.split(':') : [''];
+ const addressPrefix = addressSplit[0];
+ const prefixLength = addressPrefix.length + 1;
+
+ const txtRef = React.useRef(null);
+
+ const handleOnClick = evt => {
+ setVisible(true);
+ setTimeout(() => {
+ setVisible(false);
+ }, 1500);
+ onClick(evt);
+ };
+
+ const handleOnCopy = () => {
+ // Event.("Category", "Action", "Label")
+ // xec or etoken?
+ let eventLabel = currency.ticker;
+ if (address && !isCashAddress) {
+ eventLabel = currency.tokenTicker;
+ // Event('Category', 'Action', 'Label')
+ Event('Wallet', 'Copy Address', eventLabel);
+ }
+
+ setVisible(true);
+ setTimeout(() => {
+ txtRef.current.select();
+ }, 100);
+ };
+
+ return (
+
+
+
+ Copied
+ {address}
+
+
+
+
+ {address && (
+
+
+
+ {address.slice(0, prefixLength)}
+
+
+ {address.slice(
+ prefixLength,
+ prefixLength + trimAmount,
+ )}
+
+ {address.slice(prefixLength + trimAmount, address_trim)}
+
+ {address.slice(-trimAmount)}
+
+
+ )}
+
+
+ );
+};
diff --git a/web/cashtab-v2/src/components/Common/ScanQRCode.js b/web/cashtab-v2/src/components/Common/ScanQRCode.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Common/ScanQRCode.js
@@ -0,0 +1,187 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import { Alert, Modal } from 'antd';
+import { ThemedQrcodeOutlined } from '@components/Common/CustomIcons';
+import { errorNotification } from './Notifications';
+import styled from 'styled-components';
+import { BrowserQRCodeReader } from '@zxing/library';
+import { currency, parseAddressForParams } from '@components/Common/Ticker.js';
+import { Event } from '@utils/GoogleAnalytics';
+import { isValidXecAddress, isValidEtokenAddress } from '@utils/validation';
+
+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%;
+`;
+
+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 = {};
+ const addressInfo = parseAddressForParams(content);
+
+ // If what scanner reads from QR code is a valid eCash or eToken address
+ if (
+ isValidXecAddress(addressInfo.address) ||
+ isValidEtokenAddress(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");
+
+ let result = { type: 'unknown', values: {} };
+
+ while (result.type !== 'address') {
+ const content = await codeReader.decodeFromInputVideoDevice(
+ undefined,
+ 'test-area-qr-code-webcam',
+ );
+ result = parseContent(content.text);
+ if (result.type !== 'address') {
+ errorNotification(
+ content.text,
+ `${content.text} is not a valid eCash address`,
+ `${content.text} is not a valid eCash address`,
+ );
+ }
+ }
+ // When you scan a valid address, stop scanning and fill form
+ // 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);
+ return teardownCodeReader(codeReader);
+ }
+ };
+
+ 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-v2/src/components/Common/StyledCollapse.js b/web/cashtab-v2/src/components/Common/StyledCollapse.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Common/StyledCollapse.js
@@ -0,0 +1,114 @@
+import styled from 'styled-components';
+import { Collapse } from 'antd';
+
+export const StyledCollapse = styled(Collapse)`
+ background: ${props => props.theme.collapses.background} !important;
+ border: 1px solid ${props => props.theme.collapses.border} !important;
+
+ .ant-collapse-content {
+ border-top: none;
+ background-color: ${props =>
+ props.theme.collapses.expandedBackground} !important;
+ }
+
+ .ant-collapse-item {
+ border-bottom: none !important;
+ }
+
+ *:not(button) {
+ color: ${props => props.theme.collapses.color} !important;
+ }
+`;
+
+export const TokenCollapse = styled(Collapse)`
+ ${({ disabled = false, ...props }) =>
+ disabled === true
+ ? `
+ background: ${props.theme.buttons.secondary.background} !important;
+ .ant-collapse-header {
+ font-size: 18px;
+ font-weight: bold;
+ color: ${props.theme.buttons.secondary.color} !important;
+ svg {
+ color: ${props.theme.buttons.secondary.color} !important;
+ }
+ }
+ .ant-collapse-arrow {
+ font-size: 18px;
+ }
+ `
+ : `
+ background: ${props.theme.eCashBlue} !important;
+ .ant-collapse-header {
+ font-size: 18px;
+ font-weight: bold;
+ color: ${props.theme.contrast} !important;
+ svg {
+ color: ${props.theme.contrast} !important;
+ }
+ }
+ .ant-collapse-arrow {
+ font-size: 18px;
+ }
+ `}
+`;
+
+export const AdvancedCollapse = styled(Collapse)`
+ .ant-collapse-content {
+ background-color: ${props =>
+ props.theme.advancedCollapse.expandedBackground} !important;
+ }
+ ${({ disabled = false, ...props }) =>
+ disabled === true
+ ? `
+ background: ${props.theme.buttons.secondary.background} !important;
+ .ant-collapse-header {
+ font-size: 18px;
+ font-weight: normal;
+ color: ${props.theme.buttons.secondary.color} !important;
+ svg {
+ color: ${props.theme.buttons.secondary.color} !important;
+ }
+ }
+ .ant-collapse-arrow {
+ font-size: 18px;
+ }
+ `
+ : `
+ background: ${props.theme.advancedCollapse.background} !important;
+ .ant-collapse-header {
+ font-size: 18px;
+ font-weight: bold;
+ color: ${props.theme.advancedCollapse.color} !important;
+ svg {
+ color: ${props.theme.advancedCollapse.icon} !important;
+ }
+ }
+ .ant-collapse-arrow {
+ font-size: 18px;
+ }
+
+ `}
+`;
+
+export const AntdContextCollapseWrapper = styled.div`
+ .ant-collapse {
+ border: none !important;
+ background-color: transparent !important;
+ }
+ .ant-collapse-item {
+ border: none !important;
+ }
+ .ant-collapse-header {
+ padding: 0 !important;
+ color: ${props => props.theme.forms.text} !important;
+ }
+ border-radius: 16px;
+ .ant-collapse-content-box {
+ padding-right: 0 !important;
+ }
+
+ @media screen and (max-width: 500px) {
+ grid-template-columns: 24px 30% 50%;
+ }
+`;
diff --git a/web/cashtab-v2/src/components/Common/Ticker.js b/web/cashtab-v2/src/components/Common/Ticker.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Common/Ticker.js
@@ -0,0 +1,140 @@
+import mainLogo from '@assets/logo_primary.png';
+import tokenLogo from '@assets/logo_secondary.png';
+import BigNumber from 'bignumber.js';
+
+export const currency = {
+ name: 'eCash',
+ ticker: 'XEC',
+ logo: mainLogo,
+ legacyPrefix: 'bitcoincash',
+ prefixes: ['ecash'],
+ coingeckoId: 'ecash',
+ defaultFee: 2.01,
+ dustSats: 550,
+ etokenSats: 546,
+ cashDecimals: 2,
+ blockExplorerUrl: 'https://explorer.bitcoinabc.org',
+ tokenExplorerUrl: 'https://explorer.be.cash',
+ blockExplorerUrlTestnet: 'https://texplorer.bitcoinabc.org',
+ tokenName: 'eToken',
+ tokenTicker: 'eToken',
+ tokenIconSubmitApi: 'https://icons.etokens.cash/new',
+ tokenLogo: tokenLogo,
+ tokenPrefixes: ['etoken'],
+ tokenIconsUrl: 'https://etoken-icons.s3.us-west-2.amazonaws.com',
+ tokenDbUrl: 'https://tokendb.kingbch.com',
+ txHistoryCount: 10,
+ xecApiBatchSize: 20,
+ defaultSettings: { fiatCurrency: 'usd', sendModal: false },
+ notificationDurationShort: 3,
+ notificationDurationLong: 5,
+ newTokenDefaultUrl: 'https://cashtab.com/',
+ opReturn: {
+ opReturnPrefixHex: '6a',
+ opReturnAppPrefixLengthHex: '04',
+ opPushDataOne: '4c',
+ appPrefixesHex: {
+ eToken: '534c5000',
+ cashtab: '00746162',
+ cashtabEncrypted: '65746162',
+ },
+ encryptedMsgCharLimit: 94,
+ unencryptedMsgCharLimit: 160,
+ },
+ settingsValidation: {
+ fiatCurrency: [
+ 'usd',
+ 'idr',
+ 'krw',
+ 'cny',
+ 'zar',
+ 'vnd',
+ 'cad',
+ 'nok',
+ 'eur',
+ 'gbp',
+ 'jpy',
+ 'try',
+ 'rub',
+ 'inr',
+ 'brl',
+ 'php',
+ 'ils',
+ 'clp',
+ 'twd',
+ 'hkd',
+ 'bhd',
+ 'sar',
+ 'aud',
+ 'nzd',
+ 'chf',
+ ],
+ sendModal: [true, false],
+ },
+ fiatCurrencies: {
+ usd: { name: 'US Dollar', symbol: '$', slug: 'usd' },
+ aud: { name: 'Australian Dollar', symbol: '$', slug: 'aud' },
+ bhd: { name: 'Bahraini Dinar', symbol: 'BD', slug: 'bhd' },
+ brl: { name: 'Brazilian Real', symbol: 'R$', slug: 'brl' },
+ gbp: { name: 'British Pound', symbol: '£', slug: 'gbp' },
+ cad: { name: 'Canadian Dollar', symbol: '$', slug: 'cad' },
+ clp: { name: 'Chilean Peso', symbol: '$', slug: 'clp' },
+ cny: { name: 'Chinese Yuan', symbol: '元', slug: 'cny' },
+ eur: { name: 'Euro', symbol: '€', slug: 'eur' },
+ hkd: { name: 'Hong Kong Dollar', symbol: 'HK$', slug: 'hkd' },
+ inr: { name: 'Indian Rupee', symbol: '₹', slug: 'inr' },
+ idr: { name: 'Indonesian Rupiah', symbol: 'Rp', slug: 'idr' },
+ ils: { name: 'Israeli Shekel', symbol: '₪', slug: 'ils' },
+ jpy: { name: 'Japanese Yen', symbol: '¥', slug: 'jpy' },
+ krw: { name: 'Korean Won', symbol: '₩', slug: 'krw' },
+ nzd: { name: 'New Zealand Dollar', symbol: '$', slug: 'nzd' },
+ nok: { name: 'Norwegian Krone', symbol: 'kr', slug: 'nok' },
+ php: { name: 'Philippine Peso', symbol: '₱', slug: 'php' },
+ rub: { name: 'Russian Ruble', symbol: 'р.', slug: 'rub' },
+ twd: { name: 'New Taiwan Dollar', symbol: 'NT$', slug: 'twd' },
+ sar: { name: 'Saudi Riyal', symbol: 'SAR', slug: 'sar' },
+ zar: { name: 'South African Rand', symbol: 'R', slug: 'zar' },
+ chf: { name: 'Swiss Franc', symbol: 'Fr.', slug: 'chf' },
+ try: { name: 'Turkish Lira', symbol: '₺', slug: 'try' },
+ vnd: { name: 'Vietnamese đồng', symbol: 'đ', slug: 'vnd' },
+ },
+};
+
+export function parseAddressForParams(addressString) {
+ // Build return obj
+ const addressInfo = {
+ address: '',
+ queryString: null,
+ amount: null,
+ };
+ // Parse address string for parameters
+ const paramCheck = addressString.split('?');
+
+ let cleanAddress = paramCheck[0];
+ addressInfo.address = cleanAddress;
+
+ // Check for parameters
+ // only the amount param is currently supported
+ let queryString = null;
+ let amount = null;
+ if (paramCheck.length > 1) {
+ queryString = paramCheck[1];
+ addressInfo.queryString = queryString;
+
+ const addrParams = new URLSearchParams(queryString);
+
+ if (addrParams.has('amount')) {
+ // Amount in satoshis
+ try {
+ amount = new BigNumber(parseInt(addrParams.get('amount')))
+ .div(10 ** currency.cashDecimals)
+ .toString();
+ } catch (err) {
+ amount = null;
+ }
+ }
+ }
+
+ addressInfo.amount = amount;
+ return addressInfo;
+}
diff --git a/web/cashtab-v2/src/components/Common/WalletLabel.js b/web/cashtab-v2/src/components/Common/WalletLabel.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Common/WalletLabel.js
@@ -0,0 +1,29 @@
+import * as React from 'react';
+import PropTypes from 'prop-types';
+import styled from 'styled-components';
+
+const WalletName = styled.h4`
+ font-size: 16px;
+ display: inline-block;
+ color: ${props => props.theme.lightWhite};
+ 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-v2/src/components/Common/__mocks__/copy-to-clipboard.js b/web/cashtab-v2/src/components/Common/__mocks__/copy-to-clipboard.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Common/__mocks__/copy-to-clipboard.js
@@ -0,0 +1,2 @@
+const copy = jest.fn();
+export default copy;
diff --git a/web/cashtab-v2/src/components/Common/__tests__/QRCode.test.js b/web/cashtab-v2/src/components/Common/__tests__/QRCode.test.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Common/__tests__/QRCode.test.js
@@ -0,0 +1,62 @@
+import React from 'react';
+import { render, fireEvent, act } from '@testing-library/react';
+import { QRCode } from '../QRCode';
+import { ThemeProvider } from 'styled-components';
+import { theme } from '@assets/styles/theme';
+
+describe(' ', () => {
+ jest.useFakeTimers();
+
+ it('QRCode copying ecash address', async () => {
+ const OnClick = jest.fn();
+ const { container } = render(
+
+
+ ,
+ );
+
+ const qrCodeElement = container.querySelector('#borderedQRCode');
+ fireEvent.click(qrCodeElement);
+
+ act(() => {
+ jest.runAllTimers();
+ });
+ expect(OnClick).toHaveBeenCalled();
+ expect(setTimeout).toHaveBeenCalled();
+ });
+
+ it('QRCode copying eToken address', () => {
+ const OnClick = jest.fn();
+ const { container } = render(
+
+
+ ,
+ );
+ const qrCodeElement = container.querySelector('#borderedQRCode');
+ fireEvent.click(qrCodeElement);
+ expect(OnClick).toHaveBeenCalled();
+ });
+
+ it('QRCode without address', () => {
+ const { container } = render(
+
+
+ ,
+ );
+
+ const qrCodeElement = container.querySelector('#borderedQRCode');
+ fireEvent.click(qrCodeElement);
+ expect(setTimeout).toHaveBeenLastCalledWith(expect.any(Function), 1500);
+ expect(setTimeout).toHaveBeenCalled();
+ });
+});
diff --git a/web/cashtab-v2/src/components/Common/__tests__/StyledCollapse.test.js b/web/cashtab-v2/src/components/Common/__tests__/StyledCollapse.test.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Common/__tests__/StyledCollapse.test.js
@@ -0,0 +1,15 @@
+import React from 'react';
+import renderer from 'react-test-renderer';
+import { StyledCollapse } from '../StyledCollapse';
+import { ThemeProvider } from 'styled-components';
+import { theme } from '@assets/styles/theme';
+
+test('Render StyledCollapse component', () => {
+ const component = renderer.create(
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
diff --git a/web/cashtab-v2/src/components/Common/__tests__/__snapshots__/StyledCollapse.test.js.snap b/web/cashtab-v2/src/components/Common/__tests__/__snapshots__/StyledCollapse.test.js.snap
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Common/__tests__/__snapshots__/StyledCollapse.test.js.snap
@@ -0,0 +1,8 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP @generated
+
+exports[`Render StyledCollapse component 1`] = `
+
+`;
diff --git a/web/cashtab-v2/src/components/Configure/Configure.js b/web/cashtab-v2/src/components/Configure/Configure.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Configure/Configure.js
@@ -0,0 +1,806 @@
+/* eslint-disable react-hooks/exhaustive-deps */
+import React, { useState, useEffect } from 'react';
+import styled from 'styled-components';
+import { Collapse, Form, Input, Modal, Alert, Switch, Tag } from 'antd';
+import {
+ PlusSquareOutlined,
+ WalletFilled,
+ ImportOutlined,
+ LockOutlined,
+ CheckOutlined,
+ CloseOutlined,
+ LockFilled,
+ ExclamationCircleFilled,
+} from '@ant-design/icons';
+import { WalletContext, AuthenticationContext } from '@utils/context';
+import { SidePaddingCtn } from '@components/Common/Atoms';
+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,
+ ThemedSettingOutlined,
+} 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 { formatSavedBalance } from '@utils/formatting';
+
+const { Panel } = Collapse;
+
+const SettingsLink = styled.a`
+ text-decoration: underline;
+ color: ${props => props.theme.eCashBlue};
+ :visited {
+ text-decoration: underline;
+ color: ${props => props.theme.eCashBlue};
+ }
+ :hover {
+ color: ${props => props.theme.eCashPurple};
+ }
+`;
+
+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.darkBlue};
+ margin: 0;
+ text-align: center;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ h3.overflow {
+ width: 100px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ h3.overflow:hover {
+ background-color: ${props => props.theme.settings.background};
+ overflow: visible;
+ inline-size: 100px;
+ white-space: normal;
+ }
+`;
+
+const SWBalance = 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;
+ }
+ div {
+ font-size: 13px;
+ color: ${props => props.theme.darkBlue};
+ margin: 0;
+ text-align: center;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ div.overflow {
+ width: 150px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ }
+ div.overflow:hover {
+ background-color: ${props => props.theme.settings.background};
+ overflow: visible;
+ inline-size: 150px;
+ white-space: normal;
+ }
+`;
+
+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;
+ background: transparent;
+ border: 1px solid #fff;
+ box-shadow: none;
+ color: #fff;
+ border-radius: 3px;
+ opacity: 0.6;
+ transition: all 200ms ease-in-out;
+
+ :hover {
+ opacity: 1;
+ background: ${props => props.theme.eCashBlue};
+ border-color: ${props => props.theme.eCashBlue};
+ }
+
+ @media (max-width: 768px) {
+ font-size: 14px;
+ }
+ }
+
+ svg {
+ stroke: ${props => props.theme.eCashBlue};
+ fill: ${props => props.theme.eCashBlue};
+ width: 25px;
+ height: 25px;
+ margin-right: 20px;
+ cursor: pointer;
+
+ :first-child:hover {
+ stroke: ${props => props.theme.eCashBlue};
+ fill: ${props => props.theme.eCashBlue};
+ }
+ :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.darkBlue};
+ 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.eCashBlue} !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.contrast};
+ font-size: 25px;
+ }
+ svg {
+ fill: ${props => props.theme.eCashBlue};
+ }
+ p {
+ color: ${props => props.theme.darkBlue};
+ }
+`;
+
+const StyledSpacer = styled.div`
+ height: 1px;
+ width: 100%;
+ background-color: ${props => props.theme.lightWhite};
+ margin: 60px 0 50px;
+`;
+
+const GeneralSettingsItem = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ .ant-switch svg {
+ fill: #717171;
+ }
+ .title {
+ color: ${props => props.theme.contrast};
+ }
+ .anticon {
+ color: ${props => props.theme.contrast};
+ }
+ .ant-switch {
+ background-color: #bdbdbd;
+ }
+ .ant-switch-checked {
+ background-color: ${props => props.theme.eCashBlue};
+ svg {
+ fill: ${props => props.theme.contrast};
+ }
+ }
+`;
+
+const Configure = () => {
+ const ContextValue = React.useContext(WalletContext);
+ const authentication = React.useContext(AuthenticationContext);
+ 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 [showTranslationWarning, setShowTranslationWarning] = 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]);
+
+ useEffect(() => {
+ const detectedBrowserLang = navigator.language;
+ if (!detectedBrowserLang.includes('en-')) {
+ setShowTranslationWarning(true);
+ }
+ }, []);
+
+ // 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);
+ };
+
+ const handleAppLockToggle = (checked, e) => {
+ if (checked) {
+ // if there is an existing credential, that means user has registered
+ // simply turn on the Authentication Required flag
+ if (authentication.credentialId) {
+ authentication.turnOnAuthentication();
+ } else {
+ // there is no existing credential, that means user has not registered
+ // user need to register
+ authentication.signUp();
+ }
+ } else {
+ authentication.turnOffAuthentication();
+ }
+ };
+
+ const handleSendModalToggle = checkedState => {
+ changeCashtabSettings('sendModal', checkedState);
+ };
+
+ 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
+
+
+ {showTranslationWarning && (
+
+ )}
+ {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}
+
+
+
+
+ [
+ {sw && sw.state
+ ? formatSavedBalance(
+ sw.state.balances
+ .totalBalance,
+ )
+ : 'N/A'}{' '}
+ XEC]
+
+
+
+
+ showPopulatedRenameWalletModal(
+ sw,
+ )
+ }
+ />
+
+ showPopulatedDeleteWalletModal(
+ sw,
+ )
+ }
+ />
+
+ updateSavedWalletsOnLoad(
+ sw,
+ )
+ }
+ >
+ Activate
+
+
+
+ ))}
+
+
+
+ >
+ )}
+
+
+ Fiat Currency
+
+
+
+ changeCashtabSettings('fiatCurrency', fiatCode)
+ }
+ />
+
+
+
+ General Settings
+
+
+
+ Lock App
+
+ {authentication ? (
+ }
+ unCheckedChildren={ }
+ checked={
+ authentication.isAuthenticationRequired &&
+ authentication.credentialId
+ ? true
+ : false
+ }
+ // checked={false}
+ onChange={handleAppLockToggle}
+ />
+ ) : (
+ }>
+ Not Supported
+
+ )}
+
+
+
+ Send Confirmations
+
+ }
+ unCheckedChildren={ }
+ checked={
+ cashtabSettings ? cashtabSettings.sendModal : false
+ }
+ onChange={handleSendModalToggle}
+ />
+
+ [
+
+ Documentation
+
+ ]
+
+
+ );
+};
+
+export default Configure;
diff --git a/web/cashtab-v2/src/components/Configure/__tests__/Configure.test.js b/web/cashtab-v2/src/components/Configure/__tests__/Configure.test.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Configure/__tests__/Configure.test.js
@@ -0,0 +1,36 @@
+import React from 'react';
+import renderer from 'react-test-renderer';
+import Configure from '../Configure';
+import { ThemeProvider } from 'styled-components';
+import { theme } from '@assets/styles/theme';
+let realUseContext;
+let useContextMock;
+beforeEach(() => {
+ realUseContext = React.useContext;
+ useContextMock = React.useContext = jest.fn();
+});
+afterEach(() => {
+ React.useContext = realUseContext;
+});
+
+test('Configure without a wallet', () => {
+ useContextMock.mockReturnValue({ wallet: undefined });
+ const component = renderer.create(
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
+
+test('Configure with a wallet', () => {
+ useContextMock.mockReturnValue({ wallet: { mnemonic: 'test mnemonic' } });
+ const component = renderer.create(
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
diff --git a/web/cashtab-v2/src/components/Configure/__tests__/__snapshots__/Configure.test.js.snap b/web/cashtab-v2/src/components/Configure/__tests__/__snapshots__/Configure.test.js.snap
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Configure/__tests__/__snapshots__/Configure.test.js.snap
@@ -0,0 +1,915 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP @generated
+
+exports[`Configure with a wallet 1`] = `
+
+
+
+
+
+
+
+
+ Backup your wallet
+
+
+
+
+
+
+
+
+
+
+
+ Your seed phrase is the only way to restore your wallet. Write it down. Keep it safe.
+
+
+
+
+
+
+
+
+
+
+
+ Click to reveal seed phrase
+
+
+
+
+
+
+
+
+
+
+ Manage Wallets
+
+
+
+
+
+
+
+
+ New Wallet
+
+
+
+
+
+
+
+ Import Wallet
+
+
+
+
+
+
+
+
+ Fiat Currency
+
+
+
+
+
+
+
+
+ US Dollar ($)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ General Settings
+
+
+
+
+
+
+
+
+
+ Send Confirmations
+
+
+
+
+
+
+
+
+
+
+
+
+
+ [
+
+ Documentation
+
+ ]
+
+
+`;
+
+exports[`Configure without a wallet 1`] = `
+
+
+
+
+
+
+
+
+ Backup your wallet
+
+
+
+
+
+
+
+
+
+
+
+ Your seed phrase is the only way to restore your wallet. Write it down. Keep it safe.
+
+
+
+
+
+
+
+
+
+
+ Manage Wallets
+
+
+
+
+
+
+
+
+ New Wallet
+
+
+
+
+
+
+
+ Import Wallet
+
+
+
+
+
+
+
+
+ Fiat Currency
+
+
+
+
+
+
+
+
+ US Dollar ($)
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ General Settings
+
+
+
+
+
+
+
+
+
+ Send Confirmations
+
+
+
+
+
+
+
+
+
+
+
+
+
+ [
+
+ Documentation
+
+ ]
+
+
+`;
diff --git a/web/cashtab-v2/src/components/Home/Home.js b/web/cashtab-v2/src/components/Home/Home.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Home/Home.js
@@ -0,0 +1,257 @@
+import React from 'react';
+import styled from 'styled-components';
+import { WalletContext } from '@utils/context';
+import OnBoarding from '@components/OnBoarding/OnBoarding';
+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 {
+ LoadingCtn,
+ WalletInfoCtn,
+ SidePaddingCtn,
+} from '@components/Common/Atoms';
+import { getWalletState } from '@utils/cashMethods';
+import WalletLabel from '@components/Common/WalletLabel.js';
+
+export const Tabs = styled.div`
+ margin: auto;
+ display: inline-block;
+ text-align: center;
+ width: 100%;
+ margin: 20px 0;
+`;
+
+export const TabLabel = styled.button`
+ :focus,
+ :active {
+ outline: none;
+ }
+ color: ${props => props.theme.lightWhite};
+ border: none;
+ background: none;
+ font-size: 18px;
+ cursor: pointer;
+ margin: 0 20px;
+ padding: 0;
+
+ @media (max-width: 400px) {
+ font-size: 16px;
+ }
+
+ ${({ active, ...props }) =>
+ active &&
+ `
+ color: ${props.theme.contrast};
+ border-bottom: 2px solid ${props.theme.eCashBlue}
+
+ `}
+ ${({ token, ...props }) =>
+ token &&
+ `
+ border-color:${props.theme.eCashPurple}
+ `}
+`;
+
+export const TabPane = styled.div`
+ color: ${props => props.theme.contrast};
+ ${({ active }) =>
+ !active &&
+ `
+ display: none;
+ `}
+`;
+
+export const Links = styled(Link)`
+ color: ${props => props.theme.darkBlue};
+ width: 100%;
+ font-size: 16px;
+ margin: 10px 0 20px 0;
+ border: 1px solid ${props => props.theme.darkBlue};
+ padding: 14px 0;
+ display: inline-block;
+ border-radius: 3px;
+ transition: all 200ms ease-in-out;
+ svg {
+ fill: ${props => props.theme.darkBlue};
+ }
+ :hover {
+ color: ${props => props.theme.eCashBlue};
+ border-color: ${props => props.theme.eCashBlue};
+ svg {
+ fill: ${props => props.theme.eCashBlue};
+ }
+ }
+ @media (max-width: 768px) {
+ padding: 10px 0;
+ font-size: 14px;
+ }
+`;
+
+export const ExternalLink = styled.a`
+ color: ${props => props.theme.darkBlue};
+ width: 100%;
+ font-size: 16px;
+ margin: 0 0 20px 0;
+ border: 1px solid ${props => props.theme.darkBlue};
+ padding: 14px 0;
+ display: inline-block;
+ border-radius: 3px;
+ transition: all 200ms ease-in-out;
+ svg {
+ fill: ${props => props.theme.darkBlue};
+ transition: all 200ms ease-in-out;
+ }
+ :hover {
+ color: ${props => props.theme.eCashBlue};
+ border-color: ${props => props.theme.eCashBlue};
+ svg {
+ fill: ${props => props.theme.eCashBlue};
+ }
+ }
+ @media (max-width: 768px) {
+ padding: 10px 0;
+ font-size: 14px;
+ }
+`;
+
+export const AddrSwitchContainer = styled.div`
+ text-align: center;
+ padding: 6px 0 12px 0;
+`;
+
+const CreateToken = styled(Link)`
+ color: ${props => props.theme.contrast};
+ border: 1px solid ${props => props.theme.contrast};
+ padding: 8px 15px;
+ border-radius: 5px;
+ margin-top: 10px;
+ margin-bottom: 20px;
+ display: inline-block;
+ width: 100%;
+ :hover {
+ background: ${props => props.theme.eCashPurple};
+ border-color: ${props => props.theme.eCashPurple};
+ color: ${props => props.theme.contrast};
+ }
+`;
+
+const WalletInfo = () => {
+ const ContextValue = React.useContext(WalletContext);
+ const { wallet, fiatPrice, apiError, cashtabSettings } = ContextValue;
+ const walletState = getWalletState(wallet);
+ const { balances, parsedTxHistory, tokens } = walletState;
+ const [activeTab, setActiveTab] = React.useState('txHistory');
+
+ const hasHistory = parsedTxHistory && parsedTxHistory.length > 0;
+
+ return (
+ <>
+
+
+
+
+
+ {apiError && }
+
+
+
+ setActiveTab('txHistory')}
+ >
+ Transactions
+
+ setActiveTab('tokens')}
+ >
+ eTokens
+
+
+
+
+
+ {!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
+ >
+ )}
+
+
+
+ Create eToken
+
+ {tokens && tokens.length > 0 ? (
+
+ ) : (
+
+ Tokens sent to your {currency.tokenTicker} address
+ will appear here
+
+ )}
+
+
+ >
+ );
+};
+
+const Home = () => {
+ const ContextValue = React.useContext(WalletContext);
+ const { wallet, previousWallet, loading } = ContextValue;
+
+ return (
+ <>
+ {loading ? (
+
+ ) : (
+ <>
+ {(wallet && wallet.Path1899) ||
+ (previousWallet && previousWallet.path1899) ? (
+
+ ) : (
+
+ )}
+ >
+ )}
+ >
+ );
+};
+
+export default Home;
diff --git a/web/cashtab-v2/src/components/Home/TokenList.js b/web/cashtab-v2/src/components/Home/TokenList.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Home/TokenList.js
@@ -0,0 +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/formatting';
+
+const TokenList = ({ tokens }) => {
+ return (
+
+ {tokens.map(token => (
+
+
+
+ ))}
+
+ );
+};
+
+TokenList.propTypes = {
+ tokens: PropTypes.array,
+};
+
+export default TokenList;
diff --git a/web/cashtab-v2/src/components/Home/TokenListItem.js b/web/cashtab-v2/src/components/Home/TokenListItem.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Home/TokenListItem.js
@@ -0,0 +1,56 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import styled from 'styled-components';
+import TokenIcon from '@components/Tokens/TokenIcon';
+
+const TokenIconWrapper = styled.div`
+ margin-right: 10px;
+`;
+
+const TokenNameCtn = styled.div`
+ display: flex;
+ align-items: center;
+`;
+
+const Wrapper = styled.div`
+ display: flex;
+ align-items: center;
+ border-top: 1px solid rgba(255, 255, 255, 0.12);
+ color: ${props => props.theme.contrast};
+ padding: 10px 0;
+ justify-content: space-between;
+ h4 {
+ font-size: 16px;
+ color: ${props => props.theme.contrast};
+ margin: 0;
+ font-weight: bold;
+ }
+ :hover {
+ h4 {
+ color: ${props => props.theme.eCashPurple};
+ }
+ }
+`;
+
+const TokenListItem = ({ ticker, balance, tokenId }) => {
+ return (
+
+
+
+
+
+ {ticker}
+
+
+ {balance}
+
+ );
+};
+
+TokenListItem.propTypes = {
+ ticker: PropTypes.string,
+ balance: PropTypes.string,
+ tokenId: PropTypes.string,
+};
+
+export default TokenListItem;
diff --git a/web/cashtab-v2/src/components/Home/Tx.js b/web/cashtab-v2/src/components/Home/Tx.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Home/Tx.js
@@ -0,0 +1,707 @@
+import React from 'react';
+import { Link } from 'react-router-dom';
+import PropTypes from 'prop-types';
+import styled, { css } from 'styled-components';
+import {
+ SendIcon,
+ ReceiveIcon,
+ GenesisIcon,
+ UnparsedIcon,
+} from '@components/Common/CustomIcons';
+import { currency } from '@components/Common/Ticker';
+import { fromLegacyDecimals } from '@utils/cashMethods';
+import { formatBalance, formatDate } from '@utils/formatting';
+import TokenIcon from '@components/Tokens/TokenIcon';
+import { Collapse } from 'antd';
+import { AntdContextCollapseWrapper } from '@components/Common/StyledCollapse';
+import { generalNotification } from '@components/Common/Notifications';
+import { CopyToClipboard } from 'react-copy-to-clipboard';
+import {
+ ThemedCopySolid,
+ ThemedLinkSolid,
+} from '@components/Common/CustomIcons';
+const TxIcon = styled.div`
+ svg {
+ width: 20px;
+ height: 20px;
+ }
+ height: 40px;
+ width: 40px;
+ border: 1px solid #fff;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 100px;
+`;
+
+const SentTx = styled(TxIcon)`
+ svg {
+ margin-right: -3px;
+ }
+ fill: ${props => props.theme.contrast};
+`;
+const ReceivedTx = styled(TxIcon)`
+ svg {
+ fill: ${props => props.theme.eCashBlue};
+ }
+ border-color: ${props => props.theme.eCashBlue};
+`;
+const GenesisTx = styled(TxIcon)`
+ border-color: ${props => props.theme.genesisGreen};
+ svg {
+ fill: ${props => props.theme.genesisGreen};
+ }
+`;
+const UnparsedTx = styled(TxIcon)`
+ color: ${props => props.theme.eCashBlue} !important;
+`;
+const DateType = styled.div`
+ text-align: left;
+ padding: 12px;
+ @media screen and (max-width: 500px) {
+ font-size: 0.8rem;
+ }
+`;
+
+const LeftTextCtn = styled.div`
+ text-align: left;
+ display: flex;
+ align-items: left;
+ flex-direction: column;
+ margin-left: 10px;
+ h3 {
+ color: ${props => props.theme.contrast};
+ font-size: 14px;
+ font-weight: 700;
+ margin: 0;
+ }
+ .genesis {
+ color: ${props => props.theme.genesisGreen};
+ }
+ .received {
+ color: ${props => props.theme.eCashBlue};
+ }
+ h4 {
+ font-size: 12px;
+ color: ${props => props.theme.lightWhite};
+ margin: 0;
+ }
+`;
+
+const RightTextCtn = styled.div`
+ text-align: right;
+ display: flex;
+ align-items: left;
+ flex-direction: column;
+ margin-left: 10px;
+ h3 {
+ color: ${props => props.theme.contrast};
+ font-size: 14px;
+ font-weight: 700;
+ margin: 0;
+ }
+ .genesis {
+ color: ${props => props.theme.genesisGreen};
+ }
+ .received {
+ color: ${props => props.theme.eCashBlue};
+ }
+ h4 {
+ font-size: 12px;
+ color: ${props => props.theme.lightWhite};
+ margin: 0;
+ }
+`;
+const OpReturnType = styled.div`
+ text-align: right;
+ width: 100%;
+ padding: 10px;
+ border-radius: 5px;
+ background: ${props => props.theme.sentMessage};
+ margin-top: 15px;
+ h4 {
+ color: ${props => props.theme.lightWhite};
+ margin: 0;
+ font-size: 12px;
+ display: inline-block;
+ }
+ p {
+ color: ${props => props.theme.contrast};
+ margin: 0;
+ font-size: 14px;
+ margin-bottom: 10px;
+ overflow-wrap: break-word;
+ }
+ a {
+ color: ${props => props.theme.contrast};
+ margin: 0;
+ font-size: 10px;
+ border: 1px solid ${props => props.theme.contrast};
+ border-radius: 5px;
+ padding: 2px 10px;
+ opacity: 0.6;
+ }
+ a:hover {
+ opacity: 1;
+ border-color: ${props => props.theme.eCashBlue};
+ color: ${props => props.theme.contrast};
+ background: ${props => props.theme.eCashBlue};
+ }
+ ${({ received, ...props }) =>
+ received &&
+ `
+ text-align: left;
+ background: ${props.theme.receivedMessage};
+ `}
+`;
+const SentLabel = styled.span`
+ font-weight: bold;
+ color: ${props => props.theme.secondary} !important;
+`;
+const ReceivedLabel = styled.span`
+ font-weight: bold;
+ color: ${props => props.theme.eCashBlue} !important;
+`;
+const GenesisLabel = styled.span`
+ font-weight: bold;
+ color: ${props => props.theme.genesisGreen} !important;
+`;
+const CashtabMessageLabel = styled.span`
+ text-align: left;
+ font-weight: bold;
+ color: ${props => props.theme.eCashBlue} !important;
+ white-space: nowrap;
+`;
+const EncryptionMessageLabel = styled.span`
+ font-weight: bold;
+ font-size: 12px;
+ color: ${props => props.theme.encryptionRed};
+ white-space: nowrap;
+`;
+const UnauthorizedDecryptionMessage = styled.span`
+ text-align: left;
+ color: ${props => props.theme.encryptionRed};
+ white-space: nowrap;
+ font-style: italic;
+`;
+const MessageLabel = styled.span`
+ text-align: left;
+ font-weight: bold;
+ color: ${props => props.theme.secondary} !important;
+ white-space: nowrap;
+`;
+const ReplyMessageLabel = styled.span`
+ color: ${props => props.theme.eCashBlue} !important;
+`;
+
+const TxInfo = styled.div`
+ text-align: right;
+ display: flex;
+ align-items: left;
+ flex-direction: column;
+ margin-left: 10px;
+ flex-grow: 2;
+ h3 {
+ color: ${props => props.theme.contrast};
+ font-size: 14px;
+ font-weight: 700;
+ margin: 0;
+ }
+ .genesis {
+ color: ${props => props.theme.genesisGreen};
+ }
+ .received {
+ color: ${props => props.theme.eCashBlue};
+ }
+ h4 {
+ font-size: 12px;
+ color: ${props => props.theme.lightWhite};
+ margin: 0;
+ }
+
+ @media screen and (max-width: 500px) {
+ font-size: 0.8rem;
+ }
+`;
+
+const TokenInfo = styled.div`
+ display: flex;
+ flex-grow: 1;
+ justify-content: flex-end;
+
+ color: ${props =>
+ props.outgoing ? props.theme.secondary : props.theme.eCashBlue};
+
+ @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.h3`
+ text-align: right;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+`;
+const TokenName = styled.h4`
+ text-align: right;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+`;
+
+const TxWrapper = styled.div`
+ display: flex;
+ align-items: center;
+ border-top: 1px solid rgba(255, 255, 255, 0.12);
+ color: ${props => props.theme.contrast};
+ padding: 10px 0;
+ flex-wrap: wrap;
+`;
+
+const Panel = Collapse.Panel;
+
+const DropdownIconWrapper = styled.div`
+ display: flex;
+ align-items: center;
+ gap: 4px;
+`;
+
+const TextLayer = styled.div`
+ font-size: 12px;
+ color: ${props => props.theme.contrast};
+`;
+
+const DropdownButton = styled.button`
+ display: flex;
+ justify-content: flex-end;
+ background-color: ${props => props.theme.walletBackground};
+ border: none;
+ cursor: pointer;
+ padding: 0;
+ &:hover {
+ div {
+ color: ${props => props.theme.eCashBlue}!important;
+ }
+ svg {
+ fill: ${props => props.theme.eCashBlue}!important;
+ }
+ }
+`;
+const PanelCtn = styled.div`
+ display: flex;
+ justify-content: flex-end;
+ right: 0;
+ gap: 8px;
+`;
+
+export const TxLink = styled.a`
+ color: ${props => props.theme.primary};
+`;
+
+const Tx = ({ data, fiatPrice, fiatCurrency }) => {
+ const txDate =
+ typeof data.blocktime === 'undefined'
+ ? formatDate()
+ : formatDate(data.blocktime, navigator.language);
+ // if data only includes height and txid, then the tx could not be parsed by cashtab
+ // render as such but keep link to block explorer
+ let unparsedTx = false;
+ if (!Object.keys(data).includes('outgoingTx')) {
+ unparsedTx = true;
+ }
+ return (
+ <>
+ {unparsedTx ? (
+
+
+
+
+
+
+ Unparsed
+
+ {txDate}
+
+ Open in Explorer
+
+ ) : (
+
+
+
+
+ {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 ? (
+ <>
+
+
+
+ {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
+ }
+
+ )}
+ >
+ )}
+
+ >
+ )}
+ {data.opReturnMessage && (
+ <>
+
+ {data.isCashtabMessage ? (
+ Cashtab Message
+ ) : (
+
+ External Message
+
+ )}
+ {data.isEncryptedMessage ? (
+
+ - Encrypted
+
+ ) : (
+ ''
+ )}
+
+ {/*unencrypted OP_RETURN Message*/}
+ {data.opReturnMessage &&
+ !data.isEncryptedMessage ? (
+
+ {
+ data.opReturnMessage
+ }
+
+ ) : (
+ ''
+ )}
+ {/*encrypted and wallet is authorized to view OP_RETURN Message*/}
+ {data.opReturnMessage &&
+ data.isEncryptedMessage &&
+ data.decryptionSuccess ? (
+
+ {
+ data.opReturnMessage
+ }
+
+ ) : (
+ ''
+ )}
+ {/*encrypted but wallet is not authorized to view OP_RETURN Message*/}
+ {data.opReturnMessage &&
+ data.isEncryptedMessage &&
+ !data.decryptionSuccess ? (
+
+ {
+ data.opReturnMessage
+ }
+
+ ) : (
+ ''
+ )}
+ {!data.outgoingTx &&
+ data.replyAddress ? (
+
+ Reply To Message
+
+ ) : (
+ ''
+ )}
+
+ >
+ )}
+
+ >
+ }
+ >
+
+
+ {
+ generalNotification(
+ data.txid,
+ 'Tx ID copied to clipboard',
+ );
+ }}
+ >
+
+ Copy Tx ID
+
+
+
+
+
+
+
+
+
+ View on be.cash
+
+
+
+
+
+
+
+
+
+
+ )}
+ >
+ );
+};
+
+Tx.propTypes = {
+ data: PropTypes.object,
+ fiatPrice: PropTypes.number,
+ fiatCurrency: PropTypes.string,
+};
+
+export default Tx;
diff --git a/web/cashtab-v2/src/components/Home/TxHistory.js b/web/cashtab-v2/src/components/Home/TxHistory.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Home/TxHistory.js
@@ -0,0 +1,27 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import styled from 'styled-components';
+import Tx from './Tx';
+
+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-v2/src/components/Home/__mocks__/walletAndBalancesMock.js b/web/cashtab-v2/src/components/Home/__mocks__/walletAndBalancesMock.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Home/__mocks__/walletAndBalancesMock.js
@@ -0,0 +1,268 @@
+// @generated
+
+export const walletWithBalancesMock = {
+ wallet: {
+ name: 'MigrationTestAlpha',
+ Path245: {
+ cashAddress:
+ 'bitcoincash:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy54hkry298',
+ slpAddress:
+ 'simpleledger:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy5evac32me',
+ fundingWif: 'KwgNkyijAaxFr5XQdnaYyNMXVSZobgHzSoKKfWiC3Q7Xr4n7iYMG',
+ fundingAddress:
+ 'simpleledger:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy5evac32me',
+ legacyAddress: '1EgPUfBgU7ekho3EjtGze87dRADnUE8ojP',
+ },
+ Path145: {
+ cashAddress:
+ 'bitcoincash:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9v0lgx569z',
+ slpAddress:
+ 'simpleledger:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9vryrap6mu',
+ fundingWif: 'L2xvTe6CdNxroR6pbdpGWNjAa55AZX5Wm59W5TXMuH31ihNJdDjt',
+ fundingAddress:
+ 'simpleledger:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9vryrap6mu',
+ legacyAddress: '1511T3ynXKgCwXhFijCUWKuTfqbPxFV1AF',
+ },
+ Path1899: {
+ cashAddress:
+ 'bitcoincash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7cptzgcqy6',
+ slpAddress:
+ 'simpleledger:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7cdsfndq6y',
+ fundingWif: 'Kx4FiBMvKK1iXjFk5QTaAK6E4mDGPjmwDZ2HDKGUZpE4gCXMaPe9',
+ fundingAddress:
+ 'simpleledger:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7cdsfndq6y',
+ legacyAddress: '1J1Aq5tAAYxZgSDRo8soKM2Rb41z3xrYpm',
+ },
+ },
+ balances: {
+ totalBalanceInSatoshis: 6047469,
+ totalBalance: 0.06047469,
+ },
+ loading: false,
+};
+
+export const walletWithoutBalancesMock = {
+ wallet: {
+ name: 'MigrationTestAlpha',
+ Path245: {
+ cashAddress:
+ 'bitcoincash:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy54hkry298',
+ slpAddress:
+ 'simpleledger:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy5evac32me',
+ fundingWif: 'KwgNkyijAaxFr5XQdnaYyNMXVSZobgHzSoKKfWiC3Q7Xr4n7iYMG',
+ fundingAddress:
+ 'simpleledger:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy5evac32me',
+ legacyAddress: '1EgPUfBgU7ekho3EjtGze87dRADnUE8ojP',
+ },
+ Path145: {
+ cashAddress:
+ 'bitcoincash:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9v0lgx569z',
+ slpAddress:
+ 'simpleledger:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9vryrap6mu',
+ fundingWif: 'L2xvTe6CdNxroR6pbdpGWNjAa55AZX5Wm59W5TXMuH31ihNJdDjt',
+ fundingAddress:
+ 'simpleledger:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9vryrap6mu',
+ legacyAddress: '1511T3ynXKgCwXhFijCUWKuTfqbPxFV1AF',
+ },
+ Path1899: {
+ cashAddress:
+ 'bitcoincash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7cptzgcqy6',
+ slpAddress:
+ 'simpleledger:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7cdsfndq6y',
+ fundingWif: 'Kx4FiBMvKK1iXjFk5QTaAK6E4mDGPjmwDZ2HDKGUZpE4gCXMaPe9',
+ fundingAddress:
+ 'simpleledger:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7cdsfndq6y',
+ legacyAddress: '1J1Aq5tAAYxZgSDRo8soKM2Rb41z3xrYpm',
+ },
+ },
+ tokens: [],
+ balances: {
+ totalBalance: 0,
+ },
+ loading: false,
+};
+
+export const walletWithBalancesAndTokens = {
+ wallet: {
+ name: 'MigrationTestAlpha',
+ Path245: {
+ cashAddress:
+ 'bitcoincash:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy54hkry298',
+ slpAddress:
+ 'simpleledger:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy5evac32me',
+ fundingWif: 'KwgNkyijAaxFr5XQdnaYyNMXVSZobgHzSoKKfWiC3Q7Xr4n7iYMG',
+ fundingAddress:
+ 'simpleledger:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy5evac32me',
+ legacyAddress: '1EgPUfBgU7ekho3EjtGze87dRADnUE8ojP',
+ },
+ Path145: {
+ cashAddress:
+ 'bitcoincash:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9v0lgx569z',
+ slpAddress:
+ 'simpleledger:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9vryrap6mu',
+ fundingWif: 'L2xvTe6CdNxroR6pbdpGWNjAa55AZX5Wm59W5TXMuH31ihNJdDjt',
+ fundingAddress:
+ 'simpleledger:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9vryrap6mu',
+ legacyAddress: '1511T3ynXKgCwXhFijCUWKuTfqbPxFV1AF',
+ },
+ Path1899: {
+ cashAddress:
+ 'bitcoincash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7cptzgcqy6',
+ slpAddress:
+ 'simpleledger:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7cdsfndq6y',
+ fundingWif: 'Kx4FiBMvKK1iXjFk5QTaAK6E4mDGPjmwDZ2HDKGUZpE4gCXMaPe9',
+ fundingAddress:
+ 'simpleledger:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7cdsfndq6y',
+ legacyAddress: '1J1Aq5tAAYxZgSDRo8soKM2Rb41z3xrYpm',
+ },
+ },
+ balances: {
+ totalBalanceInSatoshis: 6047469,
+ totalBalance: 0.06047469,
+ },
+ tokens: [
+ {
+ info: {
+ height: 666987,
+ tx_hash:
+ 'e7d554c317db71fd5b50fcf0b2cb4cbdce54a09f1732cfaade0820659318e30a',
+ tx_pos: 2,
+ value: 546,
+ satoshis: 546,
+ txid: 'e7d554c317db71fd5b50fcf0b2cb4cbdce54a09f1732cfaade0820659318e30a',
+ vout: 2,
+ utxoType: 'token',
+ transactionType: 'send',
+ tokenId:
+ 'bd1acc4c986de57af8d6d2a64aecad8c30ee80f37ae9d066d758923732ddc9ba',
+ tokenTicker: 'TBS',
+ tokenName: 'TestBits',
+ tokenDocumentUrl: 'https://thecryptoguy.com/',
+ tokenDocumentHash: '',
+ decimals: 9,
+ tokenType: 1,
+ tokenQty: '6.001',
+ isValid: true,
+ address:
+ 'bitcoincash:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy54hkry298',
+ },
+ tokenId:
+ 'bd1acc4c986de57af8d6d2a64aecad8c30ee80f37ae9d066d758923732ddc9ba',
+ balance: '6.001',
+ hasBaton: false,
+ },
+ ],
+ loading: false,
+};
+
+export const walletWithBalancesAndTokensWithCorrectState = {
+ wallet: {
+ name: 'MigrationTestAlpha',
+ Path245: {
+ cashAddress:
+ 'bitcoincash:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy54hkry298',
+ slpAddress:
+ 'simpleledger:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy5evac32me',
+ fundingWif: 'KwgNkyijAaxFr5XQdnaYyNMXVSZobgHzSoKKfWiC3Q7Xr4n7iYMG',
+ fundingAddress:
+ 'simpleledger:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy5evac32me',
+ legacyAddress: '1EgPUfBgU7ekho3EjtGze87dRADnUE8ojP',
+ },
+ Path145: {
+ cashAddress:
+ 'bitcoincash:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9v0lgx569z',
+ slpAddress:
+ 'simpleledger:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9vryrap6mu',
+ fundingWif: 'L2xvTe6CdNxroR6pbdpGWNjAa55AZX5Wm59W5TXMuH31ihNJdDjt',
+ fundingAddress:
+ 'simpleledger:qq47pcxfn8n7w7jy86njd7pvgsv39l9f9vryrap6mu',
+ legacyAddress: '1511T3ynXKgCwXhFijCUWKuTfqbPxFV1AF',
+ },
+ Path1899: {
+ cashAddress:
+ 'bitcoincash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7cptzgcqy6',
+ slpAddress:
+ 'simpleledger:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7cdsfndq6y',
+ fundingWif: 'Kx4FiBMvKK1iXjFk5QTaAK6E4mDGPjmwDZ2HDKGUZpE4gCXMaPe9',
+ fundingAddress:
+ 'simpleledger:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7cdsfndq6y',
+ legacyAddress: '1J1Aq5tAAYxZgSDRo8soKM2Rb41z3xrYpm',
+ },
+ state: {
+ balances: {
+ totalBalanceInSatoshis: 6047469,
+ totalBalance: 0.06047469,
+ },
+ tokens: [
+ {
+ info: {
+ height: 666987,
+ tx_hash:
+ 'e7d554c317db71fd5b50fcf0b2cb4cbdce54a09f1732cfaade0820659318e30a',
+ tx_pos: 2,
+ value: 546,
+ satoshis: 546,
+ txid: 'e7d554c317db71fd5b50fcf0b2cb4cbdce54a09f1732cfaade0820659318e30a',
+ vout: 2,
+ utxoType: 'token',
+ transactionType: 'send',
+ tokenId:
+ 'bd1acc4c986de57af8d6d2a64aecad8c30ee80f37ae9d066d758923732ddc9ba',
+ tokenTicker: 'TBS',
+ tokenName: 'TestBits',
+ tokenDocumentUrl: 'https://thecryptoguy.com/',
+ tokenDocumentHash: '',
+ decimals: 9,
+ tokenType: 1,
+ tokenQty: '6.001',
+ isValid: true,
+ address:
+ 'bitcoincash:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy54hkry298',
+ },
+ tokenId:
+ 'bd1acc4c986de57af8d6d2a64aecad8c30ee80f37ae9d066d758923732ddc9ba',
+ balance: '6.001',
+ hasBaton: false,
+ },
+ ],
+ parsedTxHistory: [],
+ },
+ },
+ balances: {
+ totalBalanceInSatoshis: 6047469,
+ totalBalance: 0.06047469,
+ },
+ tokens: [
+ {
+ info: {
+ height: 666987,
+ tx_hash:
+ 'e7d554c317db71fd5b50fcf0b2cb4cbdce54a09f1732cfaade0820659318e30a',
+ tx_pos: 2,
+ value: 546,
+ satoshis: 546,
+ txid: 'e7d554c317db71fd5b50fcf0b2cb4cbdce54a09f1732cfaade0820659318e30a',
+ vout: 2,
+ utxoType: 'token',
+ transactionType: 'send',
+ tokenId:
+ 'bd1acc4c986de57af8d6d2a64aecad8c30ee80f37ae9d066d758923732ddc9ba',
+ tokenTicker: 'TBS',
+ tokenName: 'TestBits',
+ tokenDocumentUrl: 'https://thecryptoguy.com/',
+ tokenDocumentHash: '',
+ decimals: 9,
+ tokenType: 1,
+ tokenQty: '6.001',
+ isValid: true,
+ address:
+ 'bitcoincash:qztqe8k4v8ckn8cvfxt5659nhd7dcyvxy54hkry298',
+ },
+ tokenId:
+ 'bd1acc4c986de57af8d6d2a64aecad8c30ee80f37ae9d066d758923732ddc9ba',
+ balance: '6.001',
+ hasBaton: false,
+ },
+ ],
+ loading: false,
+};
diff --git a/web/cashtab-v2/src/components/Home/__tests__/Home.test.js b/web/cashtab-v2/src/components/Home/__tests__/Home.test.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Home/__tests__/Home.test.js
@@ -0,0 +1,92 @@
+import React from 'react';
+import renderer from 'react-test-renderer';
+import { ThemeProvider } from 'styled-components';
+import { theme } from '@assets/styles/theme';
+import Home from '../Home';
+import {
+ walletWithBalancesAndTokens,
+ walletWithBalancesMock,
+ walletWithoutBalancesMock,
+ walletWithBalancesAndTokensWithCorrectState,
+} from '../__mocks__/walletAndBalancesMock';
+import { BrowserRouter as Router } from 'react-router-dom';
+
+let realUseContext;
+let useContextMock;
+
+beforeEach(() => {
+ realUseContext = React.useContext;
+ useContextMock = React.useContext = jest.fn();
+});
+
+afterEach(() => {
+ React.useContext = realUseContext;
+});
+
+test('Wallet without BCH balance', () => {
+ useContextMock.mockReturnValue(walletWithoutBalancesMock);
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
+
+test('Wallet with BCH balances', () => {
+ useContextMock.mockReturnValue(walletWithBalancesMock);
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
+
+test('Wallet with BCH balances and tokens', () => {
+ useContextMock.mockReturnValue(walletWithBalancesAndTokens);
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
+
+test('Wallet with BCH balances and tokens and state field', () => {
+ useContextMock.mockReturnValue(walletWithBalancesAndTokensWithCorrectState);
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
+
+test('Without wallet defined', () => {
+ useContextMock.mockReturnValue({
+ wallet: {},
+ balances: { totalBalance: 0 },
+ });
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
diff --git a/web/cashtab-v2/src/components/Home/__tests__/__snapshots__/Home.test.js.snap b/web/cashtab-v2/src/components/Home/__tests__/__snapshots__/Home.test.js.snap
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Home/__tests__/__snapshots__/Home.test.js.snap
@@ -0,0 +1,449 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP @generated
+
+exports[`Wallet with BCH balances 1`] = `
+Array [
+
+
+ MigrationTestAlpha
+
+
+ 0
+
+ XEC
+
+
,
+
+
+
+ Transactions
+
+
+ eTokens
+
+
+
+
+
+ 🎉
+
+ Congratulations on your new wallet!
+
+
+ 🎉
+
+
+ Start using the wallet immediately to receive
+
+ XEC
+ payments, or load it up with
+
+ XEC
+ to send to others
+
+
+
,
+]
+`;
+
+exports[`Wallet with BCH balances and tokens 1`] = `
+Array [
+
+
+ MigrationTestAlpha
+
+
+ 0
+
+ XEC
+
+
,
+
+
+
+ Transactions
+
+
+ eTokens
+
+
+
+
+
+ 🎉
+
+ Congratulations on your new wallet!
+
+
+ 🎉
+
+
+ Start using the wallet immediately to receive
+
+ XEC
+ payments, or load it up with
+
+ XEC
+ to send to others
+
+
+
,
+]
+`;
+
+exports[`Wallet with BCH balances and tokens and state field 1`] = `
+Array [
+
+
+ MigrationTestAlpha
+
+
+ 0.06
+
+ XEC
+
+
,
+
+
+
+ Transactions
+
+
+ eTokens
+
+
+
+
+
+ 🎉
+
+ Congratulations on your new wallet!
+
+
+ 🎉
+
+
+ Start using the wallet immediately to receive
+
+ XEC
+ payments, or load it up with
+
+ XEC
+ to send to others
+
+
+
,
+]
+`;
+
+exports[`Wallet without BCH balance 1`] = `
+Array [
+
+
+ MigrationTestAlpha
+
+
+ 0
+
+ XEC
+
+
,
+
+
+
+ Transactions
+
+
+ eTokens
+
+
+
+
+
+ 🎉
+
+ Congratulations on your new wallet!
+
+
+ 🎉
+
+
+ Start using the wallet immediately to receive
+
+ XEC
+ payments, or load it up with
+
+ XEC
+ to send to others
+
+
+
,
+]
+`;
+
+exports[`Without wallet defined 1`] = `
+
+`;
diff --git a/web/cashtab-v2/src/components/NotFound.js b/web/cashtab-v2/src/components/NotFound.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/NotFound.js
@@ -0,0 +1,12 @@
+import React from 'react';
+import { Row, Col } from 'antd';
+
+const NotFound = () => (
+
+
+ Page not found
+
+
+);
+
+export default NotFound;
diff --git a/web/cashtab-v2/src/components/OnBoarding/OnBoarding.js b/web/cashtab-v2/src/components/OnBoarding/OnBoarding.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/OnBoarding/OnBoarding.js
@@ -0,0 +1,168 @@
+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 WelcomeCtn = styled.div`
+ margin-top: 20px;
+ padding: 0px 30px;
+ color: ${props => props.theme.contrast};
+ h2 {
+ color: ${props => props.theme.contrast};
+ }
+`;
+
+export const WelcomeText = styled.p`
+ width: 100%;
+ font-size: 16px;
+ margin-bottom: 60px;
+ text-align: left;
+`;
+
+export const WelcomeLink = styled.a`
+ text-decoration: underline;
+ color: ${props => props.theme.eCashBlue};
+ :hover {
+ color: ${props => props.theme.eCashPurple} !important;
+ text-decoration: underline !important;
+ }
+`;
+
+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: ,
+ cancelButtonProps: { style: { display: 'none' } },
+ 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-v2/src/components/Receive/Receive.js b/web/cashtab-v2/src/components/Receive/Receive.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Receive/Receive.js
@@ -0,0 +1,137 @@
+import React from 'react';
+import styled from 'styled-components';
+import { WalletContext } from '@utils/context';
+import OnBoarding from '@components/OnBoarding/OnBoarding';
+import { QRCode } from '@components/Common/QRCode';
+import { currency } from '@components/Common/Ticker.js';
+import { LoadingCtn } from '@components/Common/Atoms';
+
+export const ReceiveCtn = styled.div`
+ width: 100%;
+ margin-top: 100px;
+ h2 {
+ color: ${props => props.theme.contrast};
+ margin: 0 0 20px;
+ }
+`;
+
+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.walletBackground};
+ background: ${props => props.theme.contrast} !important;
+ opacity: 0.7;
+ box-shadow: none !important;
+ }
+ .slpActive {
+ background: ${props => props.theme.eCashPurple} !important;
+ }
+`;
+
+export const SwitchBtn = styled.div`
+ font-weight: bold;
+ display: inline-block;
+ cursor: pointer;
+ color: ${props => props.theme.switchButtonActiveText};
+ font-size: 14px;
+ padding: 6px 0;
+ width: 100px;
+ margin: 0 1px;
+ text-decoration: none;
+ background: ${props => props.theme.eCashBlue};
+ user-select: none;
+ :first-child {
+ border-radius: 100px 0 0 100px;
+ }
+ :nth-child(2) {
+ border-radius: 0 100px 100px 0;
+ }
+`;
+
+const WalletInfo = () => {
+ const ContextValue = React.useContext(WalletContext);
+ const { wallet } = ContextValue;
+ const [isCashAddress, setIsCashAddress] = React.useState(true);
+
+ const handleChangeAddress = () => {
+ setIsCashAddress(!isCashAddress);
+ };
+
+ return (
+
+ Receive {isCashAddress ? 'XEC' : 'eToken'}
+ {wallet && ((wallet.Path245 && wallet.Path145) || wallet.Path1899) && (
+ <>
+ {wallet.Path1899 ? (
+ <>
+
+ >
+ ) : (
+ <>
+
+ >
+ )}
+ >
+ )}
+
+
+ handleChangeAddress()}
+ className={isCashAddress ? null : 'nonactiveBtn'}
+ >
+ {currency.ticker}
+
+ handleChangeAddress()}
+ className={isCashAddress ? 'nonactiveBtn' : 'slpActive'}
+ >
+ {currency.tokenTicker}
+
+
+
+ );
+};
+
+const Receive = () => {
+ const ContextValue = React.useContext(WalletContext);
+ const { wallet, previousWallet, loading } = ContextValue;
+
+ return (
+ <>
+ {loading ? (
+
+ ) : (
+ <>
+ {(wallet && wallet.Path1899) ||
+ (previousWallet && previousWallet.path1899) ? (
+
+ ) : (
+
+ )}
+ >
+ )}
+ >
+ );
+};
+
+export default Receive;
diff --git a/web/cashtab-v2/src/components/Receive/__tests__/Receive.test.js b/web/cashtab-v2/src/components/Receive/__tests__/Receive.test.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Receive/__tests__/Receive.test.js
@@ -0,0 +1,109 @@
+import React from 'react';
+import renderer from 'react-test-renderer';
+import { ThemeProvider } from 'styled-components';
+import { theme } from '@assets/styles/theme';
+import Receive from '@components/Receive/Receive';
+import {
+ walletWithBalancesAndTokens,
+ walletWithBalancesMock,
+ walletWithoutBalancesMock,
+ walletWithBalancesAndTokensWithCorrectState,
+} from '../../Home/__mocks__/walletAndBalancesMock';
+import { BrowserRouter as Router } from 'react-router-dom';
+
+let realUseContext;
+let useContextMock;
+
+beforeEach(() => {
+ realUseContext = React.useContext;
+ useContextMock = React.useContext = jest.fn();
+
+ // Mock method not implemented in JSDOM
+ // See reference at https://jestjs.io/docs/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom
+ Object.defineProperty(window, 'matchMedia', {
+ writable: true,
+ value: jest.fn().mockImplementation(query => ({
+ matches: false,
+ media: query,
+ onchange: null,
+ addListener: jest.fn(), // Deprecated
+ removeListener: jest.fn(), // Deprecated
+ addEventListener: jest.fn(),
+ removeEventListener: jest.fn(),
+ dispatchEvent: jest.fn(),
+ })),
+ });
+});
+
+afterEach(() => {
+ React.useContext = realUseContext;
+});
+
+test('Wallet without BCH balance', () => {
+ useContextMock.mockReturnValue(walletWithoutBalancesMock);
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
+
+test('Wallet with BCH balances', () => {
+ useContextMock.mockReturnValue(walletWithBalancesMock);
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
+
+test('Wallet with BCH balances and tokens', () => {
+ useContextMock.mockReturnValue(walletWithBalancesAndTokens);
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
+
+test('Wallet with BCH balances and tokens and state field', () => {
+ useContextMock.mockReturnValue(walletWithBalancesAndTokensWithCorrectState);
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
+
+test('Without wallet defined', () => {
+ useContextMock.mockReturnValue({
+ wallet: {},
+ balances: { totalBalance: 0 },
+ loading: false,
+ });
+ const component = renderer.create(
+
+
+
+
+ ,
+ );
+ let tree = component.toJSON();
+ expect(tree).toMatchSnapshot();
+});
diff --git a/web/cashtab-v2/src/components/Receive/__tests__/__snapshots__/Receive.test.js.snap b/web/cashtab-v2/src/components/Receive/__tests__/__snapshots__/Receive.test.js.snap
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Receive/__tests__/__snapshots__/Receive.test.js.snap
@@ -0,0 +1,534 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP @generated
+
+exports[`Wallet with BCH balances 1`] = `
+
+
+ Receive
+ XEC
+
+
+
+ Copied
+
+
+ ecash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7ccxkrr6zd
+
+
+
+
+
+
+
+
+
+
+ ecash:
+
+
+ qzagy47m
+
+ vh6qxkvcn3acjnz73rkhkc6y7c
+
+ cxkrr6zd
+
+
+
+
+
+`;
+
+exports[`Wallet with BCH balances and tokens 1`] = `
+
+
+ Receive
+ XEC
+
+
+
+ Copied
+
+
+ ecash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7ccxkrr6zd
+
+
+
+
+
+
+
+
+
+
+ ecash:
+
+
+ qzagy47m
+
+ vh6qxkvcn3acjnz73rkhkc6y7c
+
+ cxkrr6zd
+
+
+
+
+
+`;
+
+exports[`Wallet with BCH balances and tokens and state field 1`] = `
+
+
+ Receive
+ XEC
+
+
+
+ Copied
+
+
+ ecash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7ccxkrr6zd
+
+
+
+
+
+
+
+
+
+
+ ecash:
+
+
+ qzagy47m
+
+ vh6qxkvcn3acjnz73rkhkc6y7c
+
+ cxkrr6zd
+
+
+
+
+
+`;
+
+exports[`Wallet without BCH balance 1`] = `
+
+
+ Receive
+ XEC
+
+
+
+ Copied
+
+
+ ecash:qzagy47mvh6qxkvcn3acjnz73rkhkc6y7ccxkrr6zd
+
+
+
+
+
+
+
+
+
+
+ ecash:
+
+
+ qzagy47m
+
+ vh6qxkvcn3acjnz73rkhkc6y7c
+
+ cxkrr6zd
+
+
+
+
+
+`;
+
+exports[`Without wallet defined 1`] = `
+
+`;
diff --git a/web/cashtab-v2/src/components/Send/Send.js b/web/cashtab-v2/src/components/Send/Send.js
new file mode 100644
--- /dev/null
+++ b/web/cashtab-v2/src/components/Send/Send.js
@@ -0,0 +1,1092 @@
+import React, { useState, useEffect } from 'react';
+import { useLocation } from 'react-router-dom';
+import PropTypes from 'prop-types';
+import { WalletContext } from '@utils/context';
+import {
+ AntdFormWrapper,
+ SendBchInput,
+ DestinationAddressSingle,
+ DestinationAddressMulti,
+} from '@components/Common/EnhancedInputs';
+import { AdvancedCollapse } from '@components/Common/StyledCollapse';
+import { Form, message, Modal, Alert, Collapse, Input, Button } from 'antd';
+const { Panel } = Collapse;
+const { TextArea } = Input;
+import { Row, Col, Switch } from 'antd';
+import PrimaryButton, {
+ SecondaryButton,
+ SmartButton,
+} from '@components/Common/PrimaryButton';
+import useBCH from '@hooks/useBCH';
+import useWindowDimensions from '@hooks/useWindowDimensions';
+import {
+ sendXecNotification,
+ errorNotification,
+ messageSignedNotification,
+} from '@components/Common/Notifications';
+import { isMobile, isIOS, isSafari } from 'react-device-detect';
+import { currency, parseAddressForParams } from '@components/Common/Ticker.js';
+import { Event } from '@utils/GoogleAnalytics';
+import {
+ fiatToCrypto,
+ shouldRejectAmountInput,
+ isValidXecAddress,
+ isValidEtokenAddress,
+ isValidXecSendAmount,
+} from '@utils/validation';
+import BalanceHeader from '@components/Common/BalanceHeader';
+import BalanceHeaderFiat from '@components/Common/BalanceHeaderFiat';
+import {
+ ZeroBalanceHeader,
+ ConvertAmount,
+ AlertMsg,
+ WalletInfoCtn,
+ SidePaddingCtn,
+ FormLabel,
+} from '@components/Common/Atoms';
+import {
+ getWalletState,
+ convertToEcashPrefix,
+ toLegacyCash,
+ toLegacyCashArray,
+ fromSmallestDenomination,
+} from '@utils/cashMethods';
+import ApiError from '@components/Common/ApiError';
+import { formatFiatBalance, formatBalance } from '@utils/formatting';
+import { TokenParamLabel } from '@components/Common/Atoms';
+import { PlusSquareOutlined } from '@ant-design/icons';
+import styled from 'styled-components';
+import { CopyToClipboard } from 'react-copy-to-clipboard';
+import WalletLabel from '@components/Common/WalletLabel.js';
+
+const SignMessageLabel = styled.div`
+ text-align: left;
+ color: ${props => props.theme.forms.text};
+`;
+
+const TextAreaLabel = styled.div`
+ text-align: left;
+ color: ${props => props.theme.forms.text};
+ padding-left: 1px;
+`;
+
+const AmountPreviewCtn = styled.div`
+ margin-top: -30px;
+`;
+
+const SendInputCtn = styled.div`
+ .ant-form-item-with-help {
+ margin-bottom: 32px;
+ }
+`;
+
+const LocaleFormattedValue = styled.h3`
+ color: ${props => props.theme.contrast};
+ font-weight: bold;
+ margin-bottom: 0;
+`;
+// 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 location = useLocation();
+ const { wallet, fiatPrice, apiError, cashtabSettings } = ContextValue;
+ const walletState = getWalletState(wallet);
+ const { balances, slpBalancesAndUtxos } = walletState;
+ // Modal settings
+ const [showConfirmMsgToSign, setShowConfirmMsgToSign] = useState(false);
+ const [msgToSign, setMsgToSign] = useState('');
+ const [signMessageIsValid, setSignMessageIsValid] = useState(null);
+ const [isOneToManyXECSend, setIsOneToManyXECSend] = useState(false);
+ const [opReturnMsg, setOpReturnMsg] = useState(false);
+ const [isEncryptedOptionalOpReturnMsg, setIsEncryptedOptionalOpReturnMsg] =
+ useState(false);
+ const [bchObj, setBchObj] = 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({
+ 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 [messageSignature, setMessageSignature] = useState('');
+ const [sigCopySuccess, setSigCopySuccess] = useState('');
+ const userLocale = navigator.language;
+ const clearInputForms = () => {
+ setFormData({
+ value: '',
+ address: '',
+ });
+ setOpReturnMsg(''); // OP_RETURN message has its own state field
+ };
+
+ const checkForConfirmationBeforeSendXec = () => {
+ if (txInfoFromUrl) {
+ setIsModalVisible(true);
+ } else if (cashtabSettings.sendModal) {
+ setIsModalVisible(cashtabSettings.sendModal);
+ } else {
+ // if the user does not have the send confirmation enabled in settings then send directly
+ send();
+ }
+ };
+
+ const handleOk = () => {
+ setIsModalVisible(false);
+ send();
+ };
+
+ const handleCancel = () => {
+ setIsModalVisible(false);
+ };
+
+ const { getBCH, getRestUrl, sendXec, calcFee, signPkMessage } = useBCH();
+
+ // 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(() => {
+ // jestBCH is only ever specified for unit tests, otherwise app will use getBCH();
+ const BCH = jestBCH ? jestBCH : getBCH();
+
+ // set the BCH instance to state, for other functions to reference
+ setBchObj(BCH);
+
+ // Manually parse for txInfo object on page load when Send.js is loaded with a query string
+
+ // if this was routed from Wallet screen's Reply to message link then prepopulate the address and value field
+ if (location && location.state && location.state.replyAddress) {
+ setFormData({
+ address: location.state.replyAddress,
+ value: `${fromSmallestDenomination(currency.dustSats)}`,
+ });
+ }
+
+ // if this was routed from the Airdrop screen's Airdrop Calculator then
+ // switch to multiple recipient mode and prepopulate the recipients field
+ if (location && location.state && location.state.airdropRecipients) {
+ setIsOneToManyXECSend(true);
+ setFormData({
+ address: location.state.airdropRecipients,
+ });
+
+ // validate the airdrop outputs from the calculator
+ handleMultiAddressChange({
+ target: {
+ value: location.state.airdropRecipients,
+ },
+ });
+ }
+
+ // 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,
+ });
+ }
+ }
+
+ function handleSendXecError(errorObj, oneToManyFlag) {
+ // 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 (!errorObj.error && !errorObj.message) {
+ message = `Transaction failed: no response from ${getRestUrl()}.`;
+ } else if (
+ /Could not communicate with full node or other external service/.test(
+ errorObj.error,
+ )
+ ) {
+ message = 'Could not communicate with API. Please try again.';
+ } else if (
+ errorObj.error &&
+ errorObj.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 =
+ errorObj.message || errorObj.error || JSON.stringify(errorObj);
+ }
+
+ if (oneToManyFlag) {
+ errorNotification(errorObj, message, 'Sending XEC one to many');
+ } else {
+ errorNotification(errorObj, message, 'Sending XEC');
+ }
+ }
+
+ async function send() {
+ setFormData({
+ ...formData,
+ });
+
+ if (isOneToManyXECSend) {
+ // this is a one to many XEC send transactions
+
+ // ensure multi-recipient input is not blank
+ if (!formData.address) {
+ return;
+ }
+
+ // Event("Category", "Action", "Label")
+ // Track number of XEC send-to-many transactions
+ Event('Send.js', 'SendToMany', selectedCurrency);
+
+ passLoadingStatus(true);
+ const { address } = formData;
+
+ //convert each line from TextArea input
+ let addressAndValueArray = address.split('\n');
+
+ try {
+ // construct array of XEC->BCH addresses due to bch-api constraint
+ let cleanAddressAndValueArray =
+ toLegacyCashArray(addressAndValueArray);
+
+ const link = await sendXec(
+ bchObj,
+ wallet,
+ slpBalancesAndUtxos.nonSlpUtxos,
+ currency.defaultFee,
+ opReturnMsg,
+ true, // indicate send mode is one to many
+ cleanAddressAndValueArray,
+ );
+ sendXecNotification(link);
+ clearInputForms();
+ } catch (e) {
+ handleSendXecError(e, isOneToManyXECSend);
+ }
+ } else {
+ // standard one to one XEC send transaction
+
+ 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 = toLegacyCash(cleanAddress);
+
+ // Calculate the amount in BCH
+ let bchValue = value;
+
+ if (selectedCurrency !== 'XEC') {
+ bchValue = fiatToCrypto(value, fiatPrice);
+ }
+
+ // encrypted message limit truncation
+ let optionalOpReturnMsg;
+ if (isEncryptedOptionalOpReturnMsg) {
+ optionalOpReturnMsg = opReturnMsg.substring(
+ 0,
+ currency.opReturn.encryptedMsgCharLimit,
+ );
+ } else {
+ optionalOpReturnMsg = opReturnMsg;
+ }
+
+ try {
+ const link = await sendXec(
+ bchObj,
+ wallet,
+ slpBalancesAndUtxos.nonSlpUtxos,
+ currency.defaultFee,
+ optionalOpReturnMsg,
+ false, // sendToMany boolean flag
+ null, // address array not applicable for one to many tx
+ cleanAddress,
+ bchValue,
+ isEncryptedOptionalOpReturnMsg,
+ );
+ sendXecNotification(link);
+ clearInputForms();
+ } catch (e) {
+ handleSendXecError(e, isOneToManyXECSend);
+ }
+ }
+ }
+
+ const handleAddressChange = e => {
+ const { value, name } = e.target;
+ let error = false;
+ let addressString = value;
+ // parse address for parameters
+ const addressInfo = parseAddressForParams(addressString);
+ // validate address
+ const isValid = isValidXecAddress(addressInfo.address);
+
+ /*
+ Model
+
+ addressInfo =
+ {
+ address: '',
+ queryString: '',
+ amount: null,
+ };
+ */
+
+ const { address, 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 (isValidEtokenAddress(address)) {
+ error = `eToken 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 handleMultiAddressChange = e => {
+ const { value, name } = e.target;
+ let error;
+
+ if (!value) {
+ error = 'Input must not be blank';
+ setSendBchAddressError(error);
+ return setFormData(p => ({
+ ...p,
+ [name]: value,
+ }));
+ }
+
+ //convert each line from the