diff --git a/web/cashtab-v2/.dockerignore b/web/cashtab-v2/.dockerignore
new file mode 100644
index 000000000..40b878db5
--- /dev/null
+++ b/web/cashtab-v2/.dockerignore
@@ -0,0 +1 @@
+node_modules/
\ No newline at end of file
diff --git a/web/cashtab-v2/.env b/web/cashtab-v2/.env
new file mode 100644
index 000000000..e7bac11a0
--- /dev/null
+++ b/web/cashtab-v2/.env
@@ -0,0 +1,3 @@
+REACT_APP_NETWORK=mainnet
+REACT_APP_BCHA_APIS=https://rest.kingbch.com/v4/,https://rest.bitcoinabc.org/v4/
+REACT_APP_BCHA_APIS_TEST=https://free-test.fullstack.cash/v3/
diff --git a/web/cashtab-v2/.eslintrc.js b/web/cashtab-v2/.eslintrc.js
new file mode 100644
index 000000000..082864780
--- /dev/null
+++ b/web/cashtab-v2/.eslintrc.js
@@ -0,0 +1,22 @@
+module.exports = {
+ env: {
+ browser: true,
+ es2021: true,
+ node: true,
+ },
+ extends: [
+ 'eslint:recommended',
+ 'plugin:react/recommended',
+ 'plugin:jest/recommended',
+ ],
+ parserOptions: {
+ ecmaFeatures: {
+ jsx: true,
+ },
+ ecmaVersion: 12,
+ sourceType: 'module',
+ },
+ plugins: ['react', 'jest'],
+ rules: { 'jest/no-mocks-import': 'off' },
+ settings: { react: { version: 'detect' } },
+};
diff --git a/web/cashtab-v2/.gitignore b/web/cashtab-v2/.gitignore
new file mode 100644
index 000000000..b156dff39
--- /dev/null
+++ b/web/cashtab-v2/.gitignore
@@ -0,0 +1,29 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# production
+/build
+
+# misc
+.DS_Store
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+
+# Chrome Extension testing
+*.crx
+*.zip
+*.pem
+dist/
diff --git a/web/cashtab-v2/.nvmrc b/web/cashtab-v2/.nvmrc
new file mode 100644
index 000000000..3f10ffe7a
--- /dev/null
+++ b/web/cashtab-v2/.nvmrc
@@ -0,0 +1 @@
+15
\ No newline at end of file
diff --git a/web/cashtab-v2/Dockerfile b/web/cashtab-v2/Dockerfile
new file mode 100644
index 000000000..895e317eb
--- /dev/null
+++ b/web/cashtab-v2/Dockerfile
@@ -0,0 +1,34 @@
+# Multi-stage
+# 1) Node image for building frontend assets
+# 2) nginx stage to serve frontend assets
+
+# Stage 1
+FROM node:15-buster-slim AS builder
+
+# Install some dependencies before building
+RUN apt-get update && \
+ apt-get upgrade -y && \
+ apt-get install -y git && \
+ apt-get install -y python
+
+WORKDIR /app
+
+# Copy only the package files and install necessary dependencies.
+# This reduces cache busting when source files are changed.
+COPY package.json .
+COPY package-lock.json .
+RUN npm ci
+
+# Copy the rest of the project files and build
+COPY . .
+RUN npm run build
+
+# Stage 2
+FROM nginx
+COPY nginx.conf /etc/nginx/conf.d/default.conf
+# Set working directory to nginx asset directory
+# Copy static assets from builder stage
+COPY --from=builder /app/build /usr/share/nginx/html/
+EXPOSE 80
+# Containers run nginx with global directives and daemon off
+ENTRYPOINT ["nginx", "-g", "daemon off;"]
diff --git a/web/cashtab-v2/README.md b/web/cashtab-v2/README.md
new file mode 100644
index 000000000..35a64b9df
--- /dev/null
+++ b/web/cashtab-v2/README.md
@@ -0,0 +1,137 @@
+# Cashtab
+
+## eCash Web Wallet
+
+![CashAppHome](./screenshots/ss-readme.png)
+
+### Features
+
+- Send & Receive XEC
+- Import existing wallets
+
+## Development
+
+```
+npm install
+npm start
+```
+
+Runs the app in the development mode.
+Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
+
+The page will reload if you make edits.
+You will also see any lint errors in the console.
+
+## Testing
+
+### 1. Regression Tests
+
+Existing functions that are impacted by your diff must be regression tested to ensure no unintended behavior. For example, if you're adding a function to facilitate One To Many XEC transactions, you must test that the existing One to One XEC transactions work as intended.
+
+### 2. Unit Tests
+
+Where applicable, add unit tests for new functions created into the corresponding \*.test.js files and they will get picked up as part of the unit test suite.
+
+Run the tests in watch mode (interactive):
+
+```
+npm test
+```
+
+Run the tests and generate a coverage report (non-interactive):
+
+```
+npm run test:coverage
+```
+
+You can then browse the HTML coverage report by opening the
+`coverage/lcov-report/index.html` file in your web browser.
+
+### 3. System/Integration Tests
+
+Once your unit tests have passed successfully, execute the test plan outlined in your diff via manual testing of your new Cashtab feature.
+
+This includes:
+
+- testing across Chrome and Firefox browsers to pick up any browser specific issues
+- testing via the Extension plugin (see Browser Extension below) to pick up on any extension specific issues
+
+### 4. Mobile Tests
+
+Ensure the latest feature functions correctly in a mobile setting and dimension.
+Start by updating the build folder with your changes included.
+
+```
+npm run build
+```
+
+Then create a new site on [Netlify](https://www.netlify.com/) by choosing to "Deploy manually" and dragging in the /web/cashtab/build folder. Your diff will now be accessible via [projectname].netlify.app, which you can load up on your iOS or Android mobile devices for testing.
+
+### 5. Edge Tests
+
+If your diff is complex in nature, then consider potential edge cases which may not get picked up through the testing approaches above.
+
+This includes but is not limited to:
+
+- interactions with fresh new wallets with no transaction history
+- interactions with wallets which have old transactions relevant to the diff
+- interactions with applications outside of Cashtab such as ElectrumABC or outputs generated by custom nodejs scripts.
+
+## Production
+
+In the project directory, run:
+
+```
+npm run build
+```
+
+Builds the app for production to the `build` folder.
+It correctly bundles React in production mode and optimizes the build for the best performance.
+
+The build is minified and the filenames include the hashes.
+Your app is ready to be deployed!
+
+See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
+
+## Browser Extension
+
+1. `npm run extension`
+2. Open Chrome or Brave
+3. Navigate to `chrome://extensions/` (or `brave://extensions/`)
+4. Enable Developer Mode
+5. Click "Load unpacked"
+6. Select the `extension/dist` folder that was created with `npm run extension`
+
+## Docker deployment
+
+```
+npm install
+docker-compose build
+docker-compose up
+```
+
+## Redundant APIs
+
+Cashtab accepts multiple instances of `bch-api` as its backend. Input your desired API URLs separated commas into the `REACT_APP_BCHA_APIS` variable. For example, to run Cashtab with three redundant APIs, use:
+
+```
+REACT_APP_BCHA_APIS=https://rest.kingbch.com/v4/,https://wallet-service-prod.bitframe.org/v4/,https://free-main.fullstack.cash/v4/
+```
+
+You can also run Cashtab with a single API, e.g.
+
+```
+REACT_APP_BCHA_APIS=https://rest.kingbch.com/v4/
+```
+
+Cashtab will start with the first API in your list. If it receives an error from that API, it will try the next one.
+
+Navigate to `localhost:8080` to see the app.
+
+## Cashtab Roadmap
+
+The following features are under active development:
+
+- Transaction history
+- Simple Ledger Postage Protocol Support
+- Cashtab browser extension
diff --git a/web/cashtab-v2/docker-compose.yml b/web/cashtab-v2/docker-compose.yml
new file mode 100644
index 000000000..000189599
--- /dev/null
+++ b/web/cashtab-v2/docker-compose.yml
@@ -0,0 +1,12 @@
+version: '2'
+
+services:
+ webserver:
+ container_name: cashtabwebserver
+ build:
+ context: .
+ dockerfile: Dockerfile
+ ports:
+ - 8080:80
+ environment:
+ - NODE_ENV=production
diff --git a/web/cashtab-v2/extension/README.md b/web/cashtab-v2/extension/README.md
new file mode 100644
index 000000000..3f357de96
--- /dev/null
+++ b/web/cashtab-v2/extension/README.md
@@ -0,0 +1,9 @@
+# CashTab extension
+
+Some minor but important code changes are required to build CashTab as a browser extension.
+
+1. Add option to open extension in tab
+2. Unique format of manifest.json with sha256 hash of any external scripts
+3. CSS rules for extension pop-up sizing
+
+Source files unique to the browser extension are kept in the `extension/` directory.
diff --git a/web/cashtab-v2/extension/public/manifest.json b/web/cashtab-v2/extension/public/manifest.json
new file mode 100644
index 000000000..a8012d2e8
--- /dev/null
+++ b/web/cashtab-v2/extension/public/manifest.json
@@ -0,0 +1,30 @@
+{
+ "manifest_version": 2,
+
+ "name": "Cashtab",
+ "description": "A browser-integrated eCash wallet from Bitcoin ABC",
+ "version": "1.0.6",
+ "content_scripts": [
+ {
+ "matches": ["file://*/*", "http://*/*", "https://*/*"],
+ "js": ["contentscript.js"],
+ "run_at": "document_idle",
+ "all_frames": true
+ }
+ ],
+ "background": {
+ "scripts": ["background.js"],
+ "persistent": false
+ },
+ "browser_action": {
+ "default_popup": "index.html",
+ "default_title": "Cashtab"
+ },
+ "icons": {
+ "16": "ecash16.png",
+ "48": "ecash48.png",
+ "128": "ecash128.png",
+ "192": "ecash192.png",
+ "512": "ecash512.png"
+ }
+}
diff --git a/web/cashtab-v2/extension/src/assets/popout.svg b/web/cashtab-v2/extension/src/assets/popout.svg
new file mode 100644
index 000000000..cd00040d6
--- /dev/null
+++ b/web/cashtab-v2/extension/src/assets/popout.svg
@@ -0,0 +1,21 @@
+
+
+
+ popout
+ Created with Sketch.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/web/cashtab-v2/extension/src/background.js b/web/cashtab-v2/extension/src/background.js
new file mode 100644
index 000000000..cce83c6a3
--- /dev/null
+++ b/web/cashtab-v2/extension/src/background.js
@@ -0,0 +1,115 @@
+const extension = require('extensionizer');
+
+const NOTIFICATION_HEIGHT = 600;
+const NOTIFICATION_WIDTH = 400;
+
+let popupIsOpen = false;
+let notificationIsOpen = false;
+const openMetamaskTabsIDs = {};
+const requestAccountTabIds = {};
+
+// This starts listening to the port created with `extension.runtime.connect` in contentscript.js
+extension.runtime.onConnect.addListener(function (port) {
+ console.assert(port.name == 'cashtabPort');
+ port.onMessage.addListener(function (msg) {
+ console.log('msg received in background.js');
+ console.log(msg.text);
+ if (msg.text == `CashTab` && msg.txInfo) {
+ console.log(`Caught, opening popup`);
+ triggerUi(msg.txInfo);
+ }
+ });
+});
+
+/**
+ * Opens the browser popup for user confirmation
+ */
+/*
+Breaking this function down
+1) Get all active tabs in browser
+2) Determine if the extension UI is currently open
+3) If extension is not open AND no other UI triggered popups are open, then open one
+
+Eventually will need similar model. Note that it actually goes much deeper than this in MetaMask.
+
+To start, just open a popup
+*/
+async function triggerUi(txInfo) {
+ /*
+ const tabs = await extension.getActiveTabs();
+ const currentlyActiveCashtabTab = Boolean(tabs.find(tab => openMetamaskTabsIDs[tab.id]));
+ if (!popupIsOpen && !currentlyActiveCashtabTab) {
+ await notificationManager.showPopup();
+ }
+ */
+ // Open a pop-up
+ let left = 0;
+ let top = 0;
+ try {
+ const lastFocused = await getLastFocusedWindow();
+ // Position window in top right corner of lastFocused window.
+ top = lastFocused.top;
+ left = lastFocused.left + (lastFocused.width - NOTIFICATION_WIDTH);
+ } catch (_) {
+ // The following properties are more than likely 0, due to being
+ // opened from the background chrome process for the extension that
+ // has no physical dimensions
+ const { screenX, screenY, outerWidth } = window;
+ top = Math.max(screenY, 0);
+ left = Math.max(screenX + (outerWidth - NOTIFICATION_WIDTH), 0);
+ }
+
+ console.log(`txInfo`);
+ console.log(txInfo);
+
+ const queryString = Object.keys(txInfo)
+ .map(key => key + '=' + txInfo[key])
+ .join('&');
+
+ // create new notification popup
+ const popupWindow = await openWindow({
+ url: `index.html#/send?${queryString}`,
+ type: 'popup',
+ width: NOTIFICATION_WIDTH,
+ height: NOTIFICATION_HEIGHT,
+ left,
+ top,
+ });
+}
+
+async function openWindow(options) {
+ return new Promise((resolve, reject) => {
+ extension.windows.create(options, newWindow => {
+ const error = checkForError();
+ if (error) {
+ return reject(error);
+ }
+ return resolve(newWindow);
+ });
+ });
+}
+
+function checkForError() {
+ const { lastError } = extension.runtime;
+ if (!lastError) {
+ return undefined;
+ }
+ // if it quacks like an Error, its an Error
+ if (lastError.stack && lastError.message) {
+ return lastError;
+ }
+ // repair incomplete error object (eg chromium v77)
+ return new Error(lastError.message);
+}
+
+async function getLastFocusedWindow() {
+ return new Promise((resolve, reject) => {
+ extension.windows.getLastFocused(windowObject => {
+ const error = checkForError();
+ if (error) {
+ return reject(error);
+ }
+ return resolve(windowObject);
+ });
+ });
+}
diff --git a/web/cashtab-v2/extension/src/components/App.css b/web/cashtab-v2/extension/src/components/App.css
new file mode 100644
index 000000000..aaa4956e4
--- /dev/null
+++ b/web/cashtab-v2/extension/src/components/App.css
@@ -0,0 +1,53 @@
+@import '~antd/dist/antd.less';
+@import '~@fortawesome/fontawesome-free/css/all.css';
+@import url('https://fonts.googleapis.com/css?family=Khula&display=swap&.css');
+
+/* 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 {
+ min-width: 400px;
+ min-height: 600px;
+ 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/extension/src/components/App.js b/web/cashtab-v2/extension/src/components/App.js
new file mode 100644
index 000000000..04b523792
--- /dev/null
+++ b/web/cashtab-v2/extension/src/components/App.js
@@ -0,0 +1,358 @@
+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';
+// Extension-only import used for open in new tab link
+import PopOut from '@assets/popout.svg';
+
+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;
+ 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;
+ 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: 'Gilroy', sans-serif;
+ 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: space-between;
+ width: 100%;
+ padding: 15px;
+`;
+
+export const CashTabLogo = styled.img`
+ width: 120px;
+ @media (max-width: 768px) {
+ width: 110px;
+ }
+`;
+
+// Extension only styled components
+const OpenInTabBtn = styled.button`
+ background: none;
+ border: none;
+`;
+
+const ExtTabImg = styled.img`
+ max-width: 20px;
+`;
+
+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) : '';
+ // openInTab is an extension-only method
+ const openInTab = () => {
+ window.open(`index.html#/${selectedKey}`);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+ {/*Begin extension-only components*/}
+ openInTab()}
+ >
+
+
+ {/*End extension-only components*/}
+
+ {/*Note that the extension does not support biometric security*/}
+ {/*Hence is not pulled in*/}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ (
+
+ )}
+ />
+
+
+
+
+
+
+
+
+
+
+ {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/extension/src/contentscript.js b/web/cashtab-v2/extension/src/contentscript.js
new file mode 100644
index 000000000..6caf80b4c
--- /dev/null
+++ b/web/cashtab-v2/extension/src/contentscript.js
@@ -0,0 +1,35 @@
+const extension = require('extensionizer');
+
+// Insert flag into window object to denote CashTab is available and active as a browser extension
+// Could use a div or other approach for now, but emulate MetaMask this way so it is extensible to other items
+// Try window object approach
+var cashTabInject = document.createElement('script');
+cashTabInject.innerHTML = `window.bitcoinAbc = 'cashtab'`;
+document.head.appendChild(cashTabInject);
+
+// Process page messages
+// Chrome extensions communicate with web pages through the DOM
+// Page sends a message to itself, chrome extension intercepts it
+var port = extension.runtime.connect({ name: 'cashtabPort' });
+//console.log(`port: ${JSON.stringify(port)}`);
+//console.log(port);
+
+window.addEventListener(
+ 'message',
+ function (event) {
+ if (typeof event.data.text !== 'undefined') {
+ console.log('Message received:');
+ console.log(event.data.text);
+ }
+
+ // We only accept messages from ourselves
+ if (event.source != window) return;
+
+ if (event.data.type && event.data.type == 'FROM_PAGE') {
+ console.log(event);
+ console.log('Content script received: ' + event.data.text);
+ port.postMessage(event.data);
+ }
+ },
+ false,
+);
diff --git a/web/cashtab-v2/manifest.json b/web/cashtab-v2/manifest.json
new file mode 100644
index 000000000..b41a3e4ad
--- /dev/null
+++ b/web/cashtab-v2/manifest.json
@@ -0,0 +1,41 @@
+{
+ "name": "App",
+ "icons": [
+ {
+ "src": "/android-icon-36x36.png",
+ "sizes": "36x36",
+ "type": "image/png",
+ "density": "0.75"
+ },
+ {
+ "src": "/android-icon-48x48.png",
+ "sizes": "48x48",
+ "type": "image/png",
+ "density": "1.0"
+ },
+ {
+ "src": "/android-icon-72x72.png",
+ "sizes": "72x72",
+ "type": "image/png",
+ "density": "1.5"
+ },
+ {
+ "src": "/android-icon-96x96.png",
+ "sizes": "96x96",
+ "type": "image/png",
+ "density": "2.0"
+ },
+ {
+ "src": "/android-icon-144x144.png",
+ "sizes": "144x144",
+ "type": "image/png",
+ "density": "3.0"
+ },
+ {
+ "src": "/android-icon-192x192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "density": "4.0"
+ }
+ ]
+}
diff --git a/web/cashtab-v2/nginx.conf b/web/cashtab-v2/nginx.conf
new file mode 100644
index 000000000..fe1f64483
--- /dev/null
+++ b/web/cashtab-v2/nginx.conf
@@ -0,0 +1,41 @@
+gzip on;
+
+gzip_vary on;
+gzip_proxied any;
+gzip_comp_level 9;
+gzip_buffers 16 8k;
+gzip_http_version 1.1;
+gzip_min_length 256;
+gzip_types
+ application/atom+xml
+ application/geo+json
+ application/javascript
+ application/x-javascript
+ application/json
+ application/ld+json
+ application/manifest+json
+ application/rdf+xml
+ application/rss+xml
+ application/xhtml+xml
+ application/xml
+ font/eot
+ font/otf
+ font/ttf
+ image/svg+xml
+ text/css
+ text/javascript
+ text/plain
+ text/xml;
+
+server {
+ listen 80;
+ location / {
+ root /usr/share/nginx/html;
+ index index.html index.htm;
+ try_files $uri $uri/ /index.html;
+ }
+ error_page 500 502 503 504 /50x.html;
+ location = /50x.html {
+ root /usr/share/nginx/html;
+ }
+}
\ No newline at end of file
diff --git a/web/cashtab-v2/public/browserconfig.xml b/web/cashtab-v2/public/browserconfig.xml
new file mode 100644
index 000000000..12e825dda
--- /dev/null
+++ b/web/cashtab-v2/public/browserconfig.xml
@@ -0,0 +1,2 @@
+
+#ffffff
\ No newline at end of file
diff --git a/web/cashtab-v2/public/cashtab_bg.png b/web/cashtab-v2/public/cashtab_bg.png
new file mode 100644
index 000000000..bed03771d
Binary files /dev/null and b/web/cashtab-v2/public/cashtab_bg.png differ
diff --git a/web/cashtab-v2/public/cashtab_twitter.png b/web/cashtab-v2/public/cashtab_twitter.png
new file mode 100644
index 000000000..ee3268618
Binary files /dev/null and b/web/cashtab-v2/public/cashtab_twitter.png differ
diff --git a/web/cashtab-v2/public/ecash128.png b/web/cashtab-v2/public/ecash128.png
new file mode 100644
index 000000000..dc688f213
Binary files /dev/null and b/web/cashtab-v2/public/ecash128.png differ
diff --git a/web/cashtab-v2/public/ecash16.png b/web/cashtab-v2/public/ecash16.png
new file mode 100644
index 000000000..da2ef0ba9
Binary files /dev/null and b/web/cashtab-v2/public/ecash16.png differ
diff --git a/web/cashtab-v2/public/ecash192.png b/web/cashtab-v2/public/ecash192.png
new file mode 100644
index 000000000..192561d17
Binary files /dev/null and b/web/cashtab-v2/public/ecash192.png differ
diff --git a/web/cashtab-v2/public/ecash48.png b/web/cashtab-v2/public/ecash48.png
new file mode 100644
index 000000000..ac86afb20
Binary files /dev/null and b/web/cashtab-v2/public/ecash48.png differ
diff --git a/web/cashtab-v2/public/ecash512.png b/web/cashtab-v2/public/ecash512.png
new file mode 100644
index 000000000..a7d1ad32d
Binary files /dev/null and b/web/cashtab-v2/public/ecash512.png differ
diff --git a/web/cashtab-v2/public/favicon.ico b/web/cashtab-v2/public/favicon.ico
new file mode 100644
index 000000000..a4f74e528
Binary files /dev/null and b/web/cashtab-v2/public/favicon.ico differ
diff --git a/web/cashtab-v2/public/index.html b/web/cashtab-v2/public/index.html
new file mode 100644
index 000000000..a434a6136
--- /dev/null
+++ b/web/cashtab-v2/public/index.html
@@ -0,0 +1,54 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Cashtab
+
+
+
+ You need to enable JavaScript to run this app.
+
+
+
diff --git a/web/cashtab-v2/public/manifest.json b/web/cashtab-v2/public/manifest.json
new file mode 100644
index 000000000..251b81c50
--- /dev/null
+++ b/web/cashtab-v2/public/manifest.json
@@ -0,0 +1,36 @@
+{
+ "short_name": "Cashtab",
+ "name": "Cashtab",
+ "icons": [
+ {
+ "src": "favicon.ico",
+ "sizes": "64x64 32x32 24x24 16x16",
+ "type": "image/x-icon"
+ },
+ {
+ "src": "ecash48.png",
+ "type": "image/png",
+ "sizes": "48x48"
+ },
+ {
+ "src": "ecash128.png",
+ "type": "image/png",
+ "sizes": "128x128"
+ },
+ {
+ "src": "ecash192.png",
+ "type": "image/png",
+ "sizes": "192x192",
+ "purpose": "any maskable"
+ },
+ {
+ "src": "ecash512.png",
+ "type": "image/png",
+ "sizes": "512x512"
+ }
+ ],
+ "start_url": ".",
+ "display": "standalone",
+ "theme_color": "#273498",
+ "background_color": "#ffffff"
+}
diff --git a/web/cashtab-v2/public/robots.txt b/web/cashtab-v2/public/robots.txt
new file mode 100644
index 000000000..01b0f9a10
--- /dev/null
+++ b/web/cashtab-v2/public/robots.txt
@@ -0,0 +1,2 @@
+# https://www.robotstxt.org/robotstxt.html
+User-agent: *
diff --git a/web/cashtab-v2/screenshots/ss-readme.png b/web/cashtab-v2/screenshots/ss-readme.png
new file mode 100644
index 000000000..633cf932d
Binary files /dev/null and b/web/cashtab-v2/screenshots/ss-readme.png differ
diff --git a/web/cashtab-v2/screenshots/ss01.png b/web/cashtab-v2/screenshots/ss01.png
new file mode 100644
index 000000000..3da382db2
Binary files /dev/null and b/web/cashtab-v2/screenshots/ss01.png differ
diff --git a/web/cashtab-v2/screenshots/ss02.png b/web/cashtab-v2/screenshots/ss02.png
new file mode 100644
index 000000000..b1e6b91b0
Binary files /dev/null and b/web/cashtab-v2/screenshots/ss02.png differ
diff --git a/web/cashtab-v2/screenshots/ss03.png b/web/cashtab-v2/screenshots/ss03.png
new file mode 100644
index 000000000..254298db1
Binary files /dev/null and b/web/cashtab-v2/screenshots/ss03.png differ
diff --git a/web/cashtab-v2/screenshots/ss04.png b/web/cashtab-v2/screenshots/ss04.png
new file mode 100644
index 000000000..b41c0a673
Binary files /dev/null and b/web/cashtab-v2/screenshots/ss04.png differ
diff --git a/web/cashtab-v2/scripts/addGenerated.sh b/web/cashtab-v2/scripts/addGenerated.sh
new file mode 100755
index 000000000..5dae05384
--- /dev/null
+++ b/web/cashtab-v2/scripts/addGenerated.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+
+export LC_ALL=C
+
+set -euo pipefail
+
+# Build the generated marker without having this script being marked as
+# generated itself.
+AT=@
+AT_GENERATED="${AT}generated"
+
+TOPLEVEL=$(git rev-parse --show-toplevel)
+
+# Note that this won't work if the with files containing a space in their name.
+# This can be solved by using a null char delimiter (-d '\0') for read in
+# combination with find's option -print0 to get a compatible output, but this is
+# not supported by all find variants so we'll do without it for now.
+find "${TOPLEVEL}/web/cashtab/src" -name "*.test.js.snap" | while read i; do
+ # It is possible to avoid the grep and have sed do it all, but:
+ # 1. This is a more complex sed usage
+ # 2. It will add the generated mark in files that have it already on another
+ # line.
+ if ! grep -q "${AT_GENERATED}" "${i}"; then
+ # The -i.bak option tells sed to do in-place replacement and create a backup
+ # file with a .bak suffix. This is mandatory for BSD/OSX compatibility.
+ sed -i.bak "1 s/$/ ${AT_GENERATED}/" "${i}" && rm -f "${i}.bak"
+ fi
+done
diff --git a/web/cashtab-v2/scripts/extension.sh b/web/cashtab-v2/scripts/extension.sh
new file mode 100755
index 000000000..d30cede7f
--- /dev/null
+++ b/web/cashtab-v2/scripts/extension.sh
@@ -0,0 +1,64 @@
+#!/usr/bin/env bash
+
+export LC_ALL=C
+set -euo pipefail
+
+# Build Cashtab as a Chrome/Brave browser extension
+
+# Create a working directory for stashing non-extension files
+WORKDIR=$(mktemp -d)
+echo Using workdir ${WORKDIR}
+
+# Delete workdir on script finish
+function cleanup {
+ echo Deleting workdir ${WORKDIR}
+ rm -rf "${WORKDIR}"
+}
+trap cleanup EXIT
+
+# Stash web files that require extension changes in workdir
+mv public/manifest.json ${WORKDIR}
+mv src/components/App.js ${WORKDIR}
+mv src/components/App.css ${WORKDIR}
+
+# Move extension src files into place for npm build
+cp extension/src/assets/popout.svg src/assets/
+cp extension/public/manifest.json public/
+cp extension/src/components/App.js src/components/
+cp extension/src/components/App.css src/components/
+
+# Delete the last extension build
+if [ -d "extension/dist/" ]; then rm -Rf extension/dist/; fi
+
+# Build the extension
+mkdir extension/dist/
+echo 'Building Extension...'
+
+# Required for extension build rules
+export INLINE_RUNTIME_CHUNK=false
+export GENERATE_SOURCEMAP=false
+
+npm run build
+
+# Copy extension build files to extension/ folder
+cp -r build/* extension/dist
+
+# Browserify contentscript.js and background.js to pull in their imports
+browserify extension/src/contentscript.js -o extension/dist/contentscript.js
+browserify extension/src/background.js -o extension/dist/background.js
+
+# Delete extension build from build/ folder (reserved for web app builds)
+rm -Rf build
+
+# Replace original web files
+rm src/assets/popout.svg
+rm public/manifest.json
+rm src/components/App.js
+rm src/components/App.css
+
+# Note, src/assets/popout.svg does not need to be replaced, not used by web app
+mv ${WORKDIR}/manifest.json public/
+mv ${WORKDIR}/App.js src/components/
+mv ${WORKDIR}/App.css src/components/
+
+echo 'Extension built and web files replaced!'
\ No newline at end of file
diff --git a/web/cashtab-v2/src/assets/airdrop-icon.svg b/web/cashtab-v2/src/assets/airdrop-icon.svg
new file mode 100644
index 000000000..ad707930c
--- /dev/null
+++ b/web/cashtab-v2/src/assets/airdrop-icon.svg
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/cashtab-v2/src/assets/alert-circle.svg b/web/cashtab-v2/src/assets/alert-circle.svg
new file mode 100644
index 000000000..7c01d8f61
--- /dev/null
+++ b/web/cashtab-v2/src/assets/alert-circle.svg
@@ -0,0 +1 @@
+Alert Circle
\ No newline at end of file
diff --git a/web/cashtab-v2/src/assets/cashtab_xec.png b/web/cashtab-v2/src/assets/cashtab_xec.png
new file mode 100644
index 000000000..51bd8541e
Binary files /dev/null and b/web/cashtab-v2/src/assets/cashtab_xec.png differ
diff --git a/web/cashtab-v2/src/assets/cog.svg b/web/cashtab-v2/src/assets/cog.svg
new file mode 100644
index 000000000..10c3c4fda
--- /dev/null
+++ b/web/cashtab-v2/src/assets/cog.svg
@@ -0,0 +1 @@
+Cog
\ No newline at end of file
diff --git a/web/cashtab-v2/src/assets/copy.svg b/web/cashtab-v2/src/assets/copy.svg
new file mode 100644
index 000000000..37b371f6a
--- /dev/null
+++ b/web/cashtab-v2/src/assets/copy.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/cashtab-v2/src/assets/edit.svg b/web/cashtab-v2/src/assets/edit.svg
new file mode 100644
index 000000000..876a956c4
--- /dev/null
+++ b/web/cashtab-v2/src/assets/edit.svg
@@ -0,0 +1 @@
+Rename
\ No newline at end of file
diff --git a/web/cashtab-v2/src/assets/external-link-square-alt.svg b/web/cashtab-v2/src/assets/external-link-square-alt.svg
new file mode 100644
index 000000000..e2b6e07ee
--- /dev/null
+++ b/web/cashtab-v2/src/assets/external-link-square-alt.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/cashtab-v2/src/assets/fingerprint-solid.svg b/web/cashtab-v2/src/assets/fingerprint-solid.svg
new file mode 100644
index 000000000..65a12406d
--- /dev/null
+++ b/web/cashtab-v2/src/assets/fingerprint-solid.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/cashtab-v2/src/assets/flask.svg b/web/cashtab-v2/src/assets/flask.svg
new file mode 100644
index 000000000..7f8593ab4
--- /dev/null
+++ b/web/cashtab-v2/src/assets/flask.svg
@@ -0,0 +1 @@
+Flask
\ No newline at end of file
diff --git a/web/cashtab-v2/src/assets/fonts/Poppins-Bold.ttf b/web/cashtab-v2/src/assets/fonts/Poppins-Bold.ttf
new file mode 100755
index 000000000..b94d47f3a
Binary files /dev/null and b/web/cashtab-v2/src/assets/fonts/Poppins-Bold.ttf differ
diff --git a/web/cashtab-v2/src/assets/fonts/Poppins-Regular.ttf b/web/cashtab-v2/src/assets/fonts/Poppins-Regular.ttf
new file mode 100755
index 000000000..be06e7fdc
Binary files /dev/null and b/web/cashtab-v2/src/assets/fonts/Poppins-Regular.ttf differ
diff --git a/web/cashtab-v2/src/assets/fonts/RobotoMono-Regular.ttf b/web/cashtab-v2/src/assets/fonts/RobotoMono-Regular.ttf
new file mode 100755
index 000000000..7c4ce36a4
Binary files /dev/null and b/web/cashtab-v2/src/assets/fonts/RobotoMono-Regular.ttf differ
diff --git a/web/cashtab-v2/src/assets/hammer-solid.svg b/web/cashtab-v2/src/assets/hammer-solid.svg
new file mode 100644
index 000000000..7f2c70953
--- /dev/null
+++ b/web/cashtab-v2/src/assets/hammer-solid.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/web/cashtab-v2/src/assets/home.svg b/web/cashtab-v2/src/assets/home.svg
new file mode 100644
index 000000000..94cf77d4c
--- /dev/null
+++ b/web/cashtab-v2/src/assets/home.svg
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
diff --git a/web/cashtab-v2/src/assets/ios-paperplane.svg b/web/cashtab-v2/src/assets/ios-paperplane.svg
new file mode 100644
index 000000000..04109dd0b
--- /dev/null
+++ b/web/cashtab-v2/src/assets/ios-paperplane.svg
@@ -0,0 +1,49 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/cashtab-v2/src/assets/logo_primary.png b/web/cashtab-v2/src/assets/logo_primary.png
new file mode 100644
index 000000000..f739100b3
Binary files /dev/null and b/web/cashtab-v2/src/assets/logo_primary.png differ
diff --git a/web/cashtab-v2/src/assets/logo_secondary.png b/web/cashtab-v2/src/assets/logo_secondary.png
new file mode 100644
index 000000000..5223c3e1d
Binary files /dev/null and b/web/cashtab-v2/src/assets/logo_secondary.png differ
diff --git a/web/cashtab-v2/src/assets/logo_topright.png b/web/cashtab-v2/src/assets/logo_topright.png
new file mode 100644
index 000000000..42b1251ff
Binary files /dev/null and b/web/cashtab-v2/src/assets/logo_topright.png differ
diff --git a/web/cashtab-v2/src/assets/receive.svg b/web/cashtab-v2/src/assets/receive.svg
new file mode 100644
index 000000000..8b3aaeb92
--- /dev/null
+++ b/web/cashtab-v2/src/assets/receive.svg
@@ -0,0 +1,16 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/web/cashtab-v2/src/assets/send.svg b/web/cashtab-v2/src/assets/send.svg
new file mode 100644
index 000000000..f7b9fb467
--- /dev/null
+++ b/web/cashtab-v2/src/assets/send.svg
@@ -0,0 +1 @@
+Send
\ No newline at end of file
diff --git a/web/cashtab-v2/src/assets/styles/theme.js b/web/cashtab-v2/src/assets/styles/theme.js
new file mode 100644
index 000000000..7aad7a75d
--- /dev/null
+++ b/web/cashtab-v2/src/assets/styles/theme.js
@@ -0,0 +1,72 @@
+export const theme = {
+ eCashBlue: '#00ABE7',
+ eCashPurple: '#ff21d0',
+ darkBlue: '#273498',
+ contrast: '#fff',
+ backgroundImage: `url("/cashtab_bg.png")`,
+ backgroundColor: '#d5d5d7',
+ walletBackground: '#152b45',
+ walletInfoContainer: '#255173',
+ footerBackground: '#152b45',
+ navActive: '#00ABE7',
+ encryptionRed: '#DC143C',
+ genesisGreen: '#00e781',
+ receivedMessage: 'rgba(0,171,231,0.2)',
+ sentMessage: 'rgba(255, 255, 255, 0.1)',
+ lightWhite: 'rgba(255,255,255,0.4)',
+ dropdownText: '#000',
+ shadow: 'rgba(0, 0, 0, 0.4)',
+ switchButtonActiveText: '#fff',
+ advancedCollapse: {
+ background: '#255173',
+ color: '#fff',
+ icon: '#fff',
+ expandedBackground: 'rgba(0,0,0,0.2)',
+ },
+ forms: {
+ error: '#FF21D0',
+ border: '#17171f',
+ text: '#fff',
+ addonBackground: '#255173',
+ addonForeground: '#fff',
+ selectionBackground: '#255173',
+ placeholder: 'rgba(255,255,255,0.3)',
+ highlightBox: '#00ABE7',
+ },
+ icons: { outlined: '#fff' },
+ settings: {
+ delete: '#CD0BC3',
+ background: 'rgba(0,0,0,0.4)',
+ },
+ qr: {
+ background: '#fff',
+ },
+ buttons: {
+ primary: {
+ backgroundImage:
+ 'linear-gradient(270deg, #0074C2 0%, #273498 100%)',
+ color: '#fff',
+ hoverShadow: '0px 3px 10px -5px rgba(0, 0, 0, 0.75)',
+ disabledOverlay: 'rgba(255, 255, 255, 0.5)',
+ },
+ secondary: {
+ background: '#4b67e1',
+ color: '#fff',
+ hoverShadow: '0px 3px 10px -5px rgba(0, 0, 0, 0.75)',
+ disabledOverlay: 'rgba(255, 255, 255, 0.5)',
+ },
+ styledLink: '#ffffff',
+ },
+ collapses: {
+ background: '#255173',
+ expandedBackground: '#26415a',
+ border: '#17171f',
+ color: '#fff',
+ },
+ modal: {
+ background: '#255173',
+ border: '#17171f',
+ color: '#fff',
+ buttonBackground: '#26415a',
+ },
+};
diff --git a/web/cashtab-v2/src/assets/tabcash.png b/web/cashtab-v2/src/assets/tabcash.png
new file mode 100644
index 000000000..b00b35798
Binary files /dev/null and b/web/cashtab-v2/src/assets/tabcash.png differ
diff --git a/web/cashtab-v2/src/assets/trashcan.svg b/web/cashtab-v2/src/assets/trashcan.svg
new file mode 100644
index 000000000..8d244f7c2
--- /dev/null
+++ b/web/cashtab-v2/src/assets/trashcan.svg
@@ -0,0 +1 @@
+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
index 000000000..aa3d0ffc7
--- /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
index 000000000..648a84c2b
--- /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
index 000000000..1fcf007e5
--- /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
index 000000000..fdc009cb0
--- /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
index 000000000..72e401847
--- /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
index 000000000..5220a81c4
--- /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
index 000000000..b9f48bddb
--- /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
index 000000000..dbe1d6b99
--- /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
index 000000000..3a9b6f13e
--- /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
index 000000000..6bd49a4d3
--- /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
index 000000000..cd5c866d4
--- /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
index 000000000..a48cd96fe
--- /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
index 000000000..01c8bedea
--- /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
index 000000000..d60e8e053
--- /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
index 000000000..ed8a23389
--- /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
index 000000000..1ec0596a5
--- /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
index 000000000..11b3e156c
--- /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
index 000000000..d6469f7bc
--- /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
index 000000000..b00cd09bc
--- /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
index 000000000..e98eb1be6
--- /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
index 000000000..68c486165
--- /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
index 000000000..2d01ee92c
--- /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
index 000000000..e7b0ccbff
--- /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
index 000000000..c08aafc9b
--- /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
index 000000000..3d1f37b50
--- /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
index 000000000..0aacbf10e
--- /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
index 000000000..46ff3250c
--- /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
index 000000000..92bf37144
--- /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
index 000000000..5e3e45352
--- /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
index 000000000..2f8425626
--- /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
index 000000000..75a753385
--- /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
index 000000000..32d1f778e
--- /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
index 000000000..83617fe68
--- /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
index 000000000..21364d613
--- /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
index 000000000..2c36ae4e6
--- /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
index 000000000..cb5dce457
--- /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
index 000000000..75257b8bb
--- /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
index 000000000..2757f3a7d
--- /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
index 000000000..971f014c9
--- /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
index 000000000..1e04a721a
--- /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
index 000000000..899551074
--- /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
index 000000000..9bb4fa7c3
--- /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
index 000000000..bbb5ed93f
--- /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