diff --git a/.eslintrc.js b/.eslintrc.js index 528ff2ac..fc20758d 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -5,6 +5,7 @@ module.exports = { node: true, }, extends: ['airbnb-base', 'plugin:react/recommended', 'plugin:prettier/recommended'], + parser: 'babel-eslint', parserOptions: { ecmaFeatures: { jsx: true, diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 1d695c84..831ebf44 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -2,7 +2,7 @@ name: deploy on: push: - branches: [master] + branches: [release] tags: ['v*'] permissions: @@ -51,7 +51,7 @@ jobs: retention-days: 1 deploy-testnet-explorer: - if: github.ref == 'refs/heads/master' + if: github.ref == 'refs/heads/release' needs: dependencies runs-on: ubuntu-latest steps: @@ -79,7 +79,7 @@ jobs: make deploy site=testnet deploy-nano-testnet-explorer: - if: github.ref == 'refs/heads/master' + if: github.ref == 'refs/heads/release' needs: dependencies runs-on: ubuntu-latest steps: @@ -105,7 +105,7 @@ jobs: make deploy site=nano-testnet deploy-ekvilibro-testnet-explorer: - if: github.ref == 'refs/heads/master' + if: github.ref == 'refs/heads/release' needs: dependencies runs-on: ubuntu-latest steps: diff --git a/.github/workflows/pr-validation.yml b/.github/workflows/pr-validation.yml index eb7274c1..ef5ec6fc 100644 --- a/.github/workflows/pr-validation.yml +++ b/.github/workflows/pr-validation.yml @@ -2,10 +2,10 @@ name: PR validation on: push: - branches: [master, dev] + branches: [master, release] pull_request: branches: - - dev + - release - master jobs: @@ -35,3 +35,7 @@ jobs: - name: Format run: | npm run format:check + + - name: Linting + run: | + npm run lint diff --git a/RELEASING.md b/RELEASING.md index 82b7ebd0..59b33424 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -13,6 +13,6 @@ Create a git tag and a new release on GitHub. ## Deploying Deploys are automated using Github Actions. -To deploy to the `testnet` website, simply commit to the `master` branch. +The `testnet` website will be deployed on commits to the `release` branch. -To deploy to the `mainnet` website, create a release in Github using a tag in the format `v0.0.0`. You should use the same version that you updated in the files in the previous section. +To deploy to the `mainnet` website, create a release in Github using a tag in the format `v0.0.0` and pointing to the `release` branch. You should use the same version that you updated in the files in the previous section. diff --git a/package.json b/package.json index a417a14f..6547b2c5 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "viz.js": "2.1.2" }, "scripts": { - "build-css": "sass src:src", + "build-css": "sass src/index.scss:src/index.css && sass src/newUi.scss:src/newUi.css", "watch-css": "npm run build-css && sass --watch src:src", "start-js": "react-scripts --openssl-legacy-provider start", "start": "npm-run-all -p watch-css start-js", @@ -45,7 +45,7 @@ "build": "npm-run-all build-css build-js", "test": "react-scripts test --env=jsdom", "lint": "eslint 'src/**/*.js'", - "lint:fix": "eslint 'src/**/*.js' --fix", + "lint:fix": "eslint src/**/*.js --fix", "format": "prettier --write 'src/**/*.js'", "format:check": "prettier --check 'src/**/*.js'", "eject": "react-scripts eject" diff --git a/src/App.js b/src/App.js index c5b9a48b..4ba29ac4 100644 --- a/src/App.js +++ b/src/App.js @@ -10,9 +10,11 @@ import React, { useCallback, useEffect } from 'react'; import { Switch, BrowserRouter as Router, Route } from 'react-router-dom'; import { useDispatch, useSelector } from 'react-redux'; import { axios as hathorLibAxios, config as hathorLibConfig } from '@hathor/wallet-lib'; +import { useTheme, useNewUiEnabled, useNewUiLoad } from './hooks'; import GDPRConsent from './components/GDPRConsent'; import Loading from './components/Loading'; import Navigation from './components/Navigation'; +import Footer from './components/Footer'; import PeerAdmin from './screens/PeerAdmin'; import DashboardTx from './screens/DashboardTx'; import TransactionDetail from './screens/TransactionDetail'; @@ -28,6 +30,7 @@ import TokenDetail from './screens/TokenDetail'; import Dag from './screens/Dag'; import Dashboard from './screens/Dashboard'; import VersionError from './screens/VersionError'; +import ErrorMessage from './components/error/ErrorMessage'; import WebSocketHandler from './WebSocketHandler'; import NanoContractDetail from './screens/nano/NanoContractDetail'; import BlueprintDetail from './screens/nano/BlueprintDetail'; @@ -44,17 +47,24 @@ import createRequestInstance from './api/customAxiosInstance'; hathorLibConfig.setServerUrl(BASE_URL); -const NavigationRoute = ({ internalScreen: InternalScreen }) => ( -
- - -
-); +const NavigationRoute = ({ internalScreen: InternalScreen }) => { + const newUiEnabled = useNewUiEnabled(); + + return ( +
+ + + {newUiEnabled ?
+ ); +}; function Root() { + useTheme(); const dispatch = useDispatch(); const isVersionAllowed = useSelector(state => state.isVersionAllowed); const apiLoadError = useSelector(state => state.apiLoadError); + const newUiLoading = useNewUiLoad(); const handleWebsocket = useCallback( wsData => { @@ -97,15 +107,7 @@ function Root() { <> - {apiLoadError ? ( -
-

- Error loading the explorer. Please reload the page to try again. -

-
- ) : ( - - )} + {apiLoadError ? : }
); @@ -115,6 +117,10 @@ function Root() { return ; } + if (newUiLoading) { + return ; + } + return ( <> diff --git a/src/actions/index.js b/src/actions/index.js index 8cf6abb8..4218f022 100644 --- a/src/actions/index.js +++ b/src/actions/index.js @@ -5,6 +5,9 @@ * LICENSE file in the root directory of this source tree. */ +import store from '../store/index'; +import themeUtils from '../utils/theme'; + export const dashboardUpdate = data => ({ type: 'dashboard_update', payload: data }); export const isVersionAllowedUpdate = data => ({ @@ -15,3 +18,12 @@ export const isVersionAllowedUpdate = data => ({ export const apiLoadErrorUpdate = data => ({ type: 'api_load_error_update', payload: data }); export const updateServerInfo = data => ({ type: 'update_server_info', payload: data }); + +export const toggleTheme = () => { + const state = store.getState(); + const currentTheme = state.theme === 'light' ? 'dark' : 'light'; + + themeUtils.applyTheme(currentTheme); + + return { type: 'toggle_theme', payload: currentTheme }; +}; diff --git a/src/assets/fonts/Mona-Sans.woff2 b/src/assets/fonts/Mona-Sans.woff2 new file mode 100644 index 00000000..d88d5ff2 Binary files /dev/null and b/src/assets/fonts/Mona-Sans.woff2 differ diff --git a/src/assets/fonts/MonaSans-Black.woff2 b/src/assets/fonts/MonaSans-Black.woff2 new file mode 100644 index 00000000..5b02fb18 Binary files /dev/null and b/src/assets/fonts/MonaSans-Black.woff2 differ diff --git a/src/assets/fonts/MonaSans-Bold.woff2 b/src/assets/fonts/MonaSans-Bold.woff2 new file mode 100644 index 00000000..6daf6c74 Binary files /dev/null and b/src/assets/fonts/MonaSans-Bold.woff2 differ diff --git a/src/assets/fonts/MonaSansCondensed-Light.woff2 b/src/assets/fonts/MonaSansCondensed-Light.woff2 new file mode 100644 index 00000000..a7034b24 Binary files /dev/null and b/src/assets/fonts/MonaSansCondensed-Light.woff2 differ diff --git a/src/assets/fonts/SFPRODISPLAYREGULAR.OTF b/src/assets/fonts/SFPRODISPLAYREGULAR.OTF new file mode 100644 index 00000000..09aaca9f Binary files /dev/null and b/src/assets/fonts/SFPRODISPLAYREGULAR.OTF differ diff --git a/src/assets/fonts/SFProText-Light.ttf b/src/assets/fonts/SFProText-Light.ttf new file mode 100644 index 00000000..127dc7ae Binary files /dev/null and b/src/assets/fonts/SFProText-Light.ttf differ diff --git a/src/assets/images/alert-icon.svg b/src/assets/images/alert-icon.svg new file mode 100644 index 00000000..342071dc --- /dev/null +++ b/src/assets/images/alert-icon.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/assets/images/alert-warning-icon.svg b/src/assets/images/alert-warning-icon.svg new file mode 100644 index 00000000..c5d70858 --- /dev/null +++ b/src/assets/images/alert-warning-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/arrow-down-nav-dropdown.svg b/src/assets/images/arrow-down-nav-dropdown.svg new file mode 100644 index 00000000..20f10e52 --- /dev/null +++ b/src/assets/images/arrow-down-nav-dropdown.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/chevron-down.svg b/src/assets/images/chevron-down.svg new file mode 100644 index 00000000..dae2afe4 --- /dev/null +++ b/src/assets/images/chevron-down.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/chevron-up.svg b/src/assets/images/chevron-up.svg new file mode 100644 index 00000000..0908699b --- /dev/null +++ b/src/assets/images/chevron-up.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/copy-icon.svg b/src/assets/images/copy-icon.svg new file mode 100644 index 00000000..69c7dd58 --- /dev/null +++ b/src/assets/images/copy-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/download-icon.svg b/src/assets/images/download-icon.svg new file mode 100644 index 00000000..ee5d44cb --- /dev/null +++ b/src/assets/images/download-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/global.svg b/src/assets/images/global.svg new file mode 100644 index 00000000..0c07755c --- /dev/null +++ b/src/assets/images/global.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/icon-info.svg b/src/assets/images/icon-info.svg new file mode 100644 index 00000000..e7f95853 --- /dev/null +++ b/src/assets/images/icon-info.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/leading-icon.svg b/src/assets/images/leading-icon.svg new file mode 100644 index 00000000..54611e52 --- /dev/null +++ b/src/assets/images/leading-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/leading-top-icon.svg b/src/assets/images/leading-top-icon.svg new file mode 100644 index 00000000..1f5a4f27 --- /dev/null +++ b/src/assets/images/leading-top-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/logo-sidebar.svg b/src/assets/images/logo-sidebar.svg new file mode 100644 index 00000000..c3acc634 --- /dev/null +++ b/src/assets/images/logo-sidebar.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/src/assets/images/moon.svg b/src/assets/images/moon.svg new file mode 100644 index 00000000..36ea4c56 --- /dev/null +++ b/src/assets/images/moon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/new-logo.svg b/src/assets/images/new-logo.svg new file mode 100644 index 00000000..35505ab4 --- /dev/null +++ b/src/assets/images/new-logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/search-icon-dark.svg b/src/assets/images/search-icon-dark.svg new file mode 100644 index 00000000..46e4918b --- /dev/null +++ b/src/assets/images/search-icon-dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/search-icon-light.svg b/src/assets/images/search-icon-light.svg new file mode 100644 index 00000000..39652726 --- /dev/null +++ b/src/assets/images/search-icon-light.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/search-icon.svg b/src/assets/images/search-icon.svg new file mode 100644 index 00000000..3eb24e8e --- /dev/null +++ b/src/assets/images/search-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/sidebar-menu.svg b/src/assets/images/sidebar-menu.svg new file mode 100644 index 00000000..bcfd2b0c --- /dev/null +++ b/src/assets/images/sidebar-menu.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/success-icon.svg b/src/assets/images/success-icon.svg new file mode 100644 index 00000000..6be0d3dc --- /dev/null +++ b/src/assets/images/success-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/assets/images/sun-dark.svg b/src/assets/images/sun-dark.svg new file mode 100644 index 00000000..7555cb58 --- /dev/null +++ b/src/assets/images/sun-dark.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/assets/images/sun-light.svg b/src/assets/images/sun-light.svg new file mode 100644 index 00000000..44eb2076 --- /dev/null +++ b/src/assets/images/sun-light.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/components/AddressDetailExplorer.js b/src/components/AddressDetailExplorer.js index d1e861f0..084d646c 100644 --- a/src/components/AddressDetailExplorer.js +++ b/src/components/AddressDetailExplorer.js @@ -5,30 +5,68 @@ * LICENSE file in the root directory of this source tree. */ -import React from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import hathorLib from '@hathor/wallet-lib'; +import ReactLoading from 'react-loading'; +import { find } from 'lodash'; +import { useHistory, useParams } from 'react-router-dom'; +import { useNewUiEnabled } from '../hooks'; import AddressSummary from './AddressSummary'; import AddressHistory from './AddressHistory'; +import Loading from './Loading'; +import ErrorMessageWithIcon from './error/ErrorMessageWithIcon'; import PaginationURL from '../utils/pagination'; -import hathorLib from '@hathor/wallet-lib'; -import ReactLoading from 'react-loading'; import colors from '../index.scss'; import WebSocketHandler from '../WebSocketHandler'; -import { TX_COUNT, TOKEN_COUNT } from '../constants'; -import { isEqual, find } from 'lodash'; +import { TOKEN_COUNT, TX_COUNT } from '../constants'; import metadataApi from '../api/metadataApi'; import addressApi from '../api/addressApi'; import txApi from '../api/txApi'; -import ErrorMessageWithIcon from '../components/error/ErrorMessageWithIcon'; -class AddressDetailExplorer extends React.Component { - pagination = new PaginationURL({ - token: { required: true }, - }); +/** + * Check if the searched address is on the inputs or outputs of the new tx + * + * @param {Object} tx Transaction data received in the websocket + * @param {boolean} checkToken If should also check if token is the same, or just address + * @param {string} queryToken Token of the current query + * @param {string} updateAddress Address of the current query + * + * Note: In its current implementation, if "checkToken" is false, the return will always be false. + * + * @return {boolean} True if should update the list, false otherwise + */ +function shouldUpdate(tx, checkToken, queryToken, updateAddress) { + const arr = [...tx.outputs, ...tx.inputs]; + + for (const element of arr) { + if (element?.decoded?.address === updateAddress) { + // Address is the same, and this is enough to require update + if (!checkToken) { + return true; + } + + // Need to check token and token is the same + if (element.token === queryToken) { + return true; + } + } + } + + return false; +} + +function AddressDetailExplorer() { + const pagination = useRef( + new PaginationURL({ + token: { required: true }, + }) + ); - addressSummaryRef = React.createRef(); + const { address } = useParams(); + const history = useHistory(); + const newUiEnabled = useNewUiEnabled(); /* - * address {String} searched address (from url) * selectedToken {String} UID of the selected token when address has many * balance {Object} Object with balance of the selected token on this address * transactions {Array} List of transactions history to show @@ -46,80 +84,34 @@ class AddressDetailExplorer extends React.Component { * showReloadDataButton {boolean} show a button to reload the screen data * showReloadTokenButton {boolean} show a button to reload the token data */ - state = { - address: null, - page: 0, - selectedToken: '', - balance: {}, - transactions: [], - queryParams: null, - hasAfter: false, - hasBefore: false, - loadingSummary: false, - loadingHistory: false, - loadingTokens: true, - loadingPagination: false, - errorMessage: '', - warningRefreshPage: false, - warnMissingTokens: 0, - selectedTokenMetadata: null, - metadataLoaded: false, - addressTokens: {}, - txCache: {}, - showReloadDataButton: false, - showReloadTokenButton: false, - pageSearchAfter: [ - { - page: 0, - searchAfter: { - lastTx: null, - lastTs: null, - }, + const [page, setPage] = useState(0); + const [selectedToken, setSelectedToken] = useState(''); + const [balance, setBalance] = useState({}); + const [transactions, setTransactions] = useState([]); + const [hasAfter, setHasAfter] = useState(false); + const [hasBefore, setHasBefore] = useState(false); + const [loadingSummary, setLoadingSummary] = useState(false); + const [loadingHistory, setLoadingHistory] = useState(false); + const [loadingTokens, setLoadingTokens] = useState(true); + const [loadingPagination, setLoadingPagination] = useState(false); + const [errorMessage, setErrorMessage] = useState(''); + const [warningRefreshPage, setWarningRefreshPage] = useState(false); + const [warnMissingTokens, setWarnMissingTokens] = useState(0); + const [selectedTokenMetadata, setSelectedTokenMetadata] = useState(null); + const [metadataLoaded, setMetadataLoaded] = useState(false); + const [addressTokens, setAddressTokens] = useState({}); + const [txCache, setTxCache] = useState({}); + const [showReloadDataButton, setShowReloadDataButton] = useState(false); + const [showReloadTokenButton, setShowReloadTokenButton] = useState(false); + const [pageSearchAfter, setPageSearchAfter] = useState([ + { + page: 0, + searchAfter: { + lastTx: null, + lastTs: null, }, - ], - }; - - componentDidMount() { - // Expects address on URL - this.updateAddress(this.props.match.params.address); - - WebSocketHandler.on('network', this.handleWebsocket); - } - - componentWillUnmount() { - WebSocketHandler.removeListener('network', this.handleWebsocket); - } - - componentDidUpdate(prevProps) { - if (prevProps.match.params.address !== this.props.match.params.address) { - // Address on the URL changed - this.pagination.clearOptionalQueryParams(); - this.updateAddress(this.props.match.params.address); - return; - } - - const queryParams = this.pagination.obtainQueryParams(); - - // Do we have new URL params? - if (!isEqual(this.state.queryParams, queryParams)) { - if (queryParams.token !== this.state.queryParams.token && queryParams.token !== null) { - // User selected a new token, so we must go to the first page (clear queryParams) - this.pagination.clearOptionalQueryParams(); - - // Need to get newQueryParams because the optional ones were cleared - // Update state to set the new selected token on it - // If we don't update this state here we might execute a duplicate request - const newQueryParams = this.pagination.obtainQueryParams(); - this.setState({ queryParams: newQueryParams }, () => { - this.reloadTokenData(newQueryParams.token); - }); - return; - } - - // update the query params state - this.setState({ queryParams, loadingHistory: true }); - } - } + }, + ]); /** * Called when 'network' ws message arrives @@ -127,293 +119,268 @@ class AddressDetailExplorer extends React.Component { * * @param {Object} wsData Data from websocket */ - handleWebsocket = wsData => { - if (wsData.type === 'network:new_tx_accepted') { - if (this.shouldUpdate(wsData, false) && !this.state.warningRefreshPage) { + const handleWebsocket = useCallback( + wsData => { + // Ignore events not related to this screen + if (wsData.type !== 'network:new_tx_accepted') { + return; + } + + const { token: queryToken } = pagination.current.obtainQueryParams(); + if (shouldUpdate(wsData, false, queryToken, address)) { // If the search address is in one of the inputs or outputs - this.setState({ warningRefreshPage: true }); + setWarningRefreshPage(true); } + }, + [address] + ); + + // Initialization effect for every address change + useEffect(() => { + // Clear all optional params since the address on the URL has changed + pagination.current.clearOptionalQueryParams(); + + // Validate the new address before executing any queries + const network = hathorLib.config.getNetwork(); + const addressObj = new hathorLib.Address(address, { network }); + if (!addressObj.isValid()) { + setErrorMessage('Invalid address.'); + // No listener was registered, so no unmount function is necessary + return undefined; } - }; - /** - * Check if address is valid and then update the state and fetch data - * If not valid show error message - * - * @param {Object} address New searched address to update state - */ - updateAddress = address => { - this.setState({ queryParams: this.pagination.obtainQueryParams() }, () => { - const network = hathorLib.config.getNetwork(); - const addressObj = new hathorLib.Address(address, { network }); - if (addressObj.isValid()) { - this.setState( - { - address, - loadingTokens: true, - loadingSummary: true, - loadingHistory: true, - addressTokens: {}, - transactions: [], - balance: {}, - errorMessage: '', - }, - () => { - const queryParams = this.pagination.obtainQueryParams(); - if (queryParams.token !== null) { - // User already have a token selected on the URL - this.setState({ selectedToken: queryParams.token }, () => { - this.reloadData(); - }); - } else { - this.reloadData(); - } - } - ); - } else { - this.setState({ errorMessage: 'Invalid address.' }); - } - }); - }; + // Trigger effect that fetches all tokens for this address and loads one of them on screen + setLoadingTokens(true); + + // Starting the Websocket + WebSocketHandler.on('network', handleWebsocket); + // Return a function to remove the Websocket listener + return () => { + WebSocketHandler.removeListener('network', handleWebsocket); + }; + }, [handleWebsocket, address]); /** * Update transactions data state after requesting data from the server * - * @param {Object} queryParams URL parameters + * @param addressToFetch + * @param tokenToFetch + * @param [lastTx] + * @param [lastTs] */ - getHistoryData = (lastTx, lastTs) => { - return addressApi - .getHistory(this.state.address, this.state.selectedToken, TX_COUNT, lastTx, lastTs) - .then(response => { - if (!response) { - // An error happened with the API call - this.setState({ - showReloadTokenButton: true, - }); - return; - } + const getHistoryData = useCallback( + async (addressToFetch, tokenToFetch, lastTx, lastTs) => { + const response = await addressApi.getHistory( + addressToFetch, + tokenToFetch, + TX_COUNT, + lastTx, + lastTs + ); + + if (!response) { + // An error happened with the API call + setShowReloadTokenButton(true); + return undefined; + } - const { has_next, history } = response; - this.setState( - { - transactions: history, - hasAfter: has_next, - }, - () => { - const promises = []; - for (const tx of history) { - if (!this.state.txCache[tx.tx_id]) { - /** - * The explorer-service address api does not retrieve all metadata of the transactions - * So there are some information that are not retrieved, e.g. whether the transaction only has authorities - * We fetch the transaction with all it's metadata to make this assertions. - */ - promises.push(txApi.getTransaction(tx.tx_id)); - } - } + const { has_next, history: responseHistory } = response; + setTransactions(responseHistory); + setHasAfter(has_next); + + // Fetching the data from each of the transactions in the received history and storing in cache + const txPromises = []; + for (const tx of responseHistory) { + if (!txCache[tx.tx_id]) { + /** + * The explorer-service address api does not retrieve all metadata of the transactions + * So there are some information that are not retrieved, e.g. whether the transaction only has authorities + * We fetch the transaction with all it's metadata to make this assertions. + */ + txPromises.push(txApi.getTransaction(tx.tx_id)); + } + } - Promise.all(promises).then(results => { - const cache = { ...this.state.txCache }; - for (const result of results) { - const tx = { ...result.tx, meta: result.meta }; - cache[tx.hash] = tx; - } - this.setState({ txCache: cache }); - }); + Promise.all(txPromises).then(txResults => { + setTxCache(oldCache => { + const newCache = { ...oldCache }; + for (const txData of txResults) { + const tx = { ...txData.tx, meta: txData.meta }; + newCache[tx.hash] = tx; } - ); - return history; - }) - .finally(() => { - this.setState({ loadingHistory: false }); + return newCache; + }); }); - }; + setLoadingHistory(false); - reloadData = () => { - this.setState( - { - loadingTokens: true, - }, - () => { - addressApi - .getTokens(this.state.address, TOKEN_COUNT) - .then(response => { - if (!response) { - // An error happened with the API call - this.setState({ showReloadDataButton: true }); - return; - } + return undefined; + }, + [txCache] + ); - let selectedToken = ''; + /** + * Fetches the token metadata, if necessary. + * Will discard any errors and always set the metadataLoaded to true, even without any data. + * @type {(function(*): Promise)|*} + */ + const getSelectedTokenMetadata = useCallback(async tokenToFetch => { + if (tokenToFetch === hathorLib.constants.NATIVE_TOKEN_UID) { + console.warn(`getSelectedTokenMetadata setting metadata loaded`); + setMetadataLoaded(true); + return; + } - const tokens = response.tokens || {}; - const total = response.total || 0; + const dagData = await metadataApi.getDagMetadata(tokenToFetch); + if (dagData) { + setSelectedTokenMetadata(dagData); + } + setMetadataLoaded(true); + }, []); - if (total > Object.keys(tokens).length) { - // There were unfetched tokens - this.setState({ warnMissingTokens: total }); - } else { - // This will turn off the missing tokens alert - this.setState({ warnMissingTokens: 0 }); - } + const reloadTokenSummaryAndHistory = useCallback( + async (addressToReload, tokenToReload) => { + setLoadingSummary(true); + setLoadingHistory(true); - if (this.state.selectedToken && tokens[this.state.selectedToken]) { - // use has a selected token, we will keep the selected token - selectedToken = this.state.selectedToken; - } else { - const hathorUID = hathorLib.constants.NATIVE_TOKEN_UID; - if (tokens[hathorUID]) { - // If HTR is in the token list of this address, it's the default selection - selectedToken = hathorUID; - } else { - // Otherwise we get the first element, if there is one - const keys = Object.keys(tokens); - if (keys.length === 0) { - // In case the length is 0, we have no transactions for this address - this.setState({ - loadingTokens: false, - loadingSummary: false, - loadingHistory: false, - }); - return; - } - selectedToken = keys[0]; - } - } + try { + const balanceData = await addressApi.getBalance(addressToReload, tokenToReload); + if (!balanceData) { + // An error happened with the API call + setShowReloadTokenButton(true); + return undefined; + } + setBalance(balanceData); - const tokenDidChange = - selectedToken !== this.state.selectedToken || this.state.selectedToken === ''; - - this.setState( - { - addressTokens: tokens, - loadingTokens: false, - selectedToken, - }, - () => { - if (tokenDidChange || !this.state.metadataLoaded) { - this.getSelectedTokenMetadata(selectedToken); - } - - // Update token in the URL - this.updateTokenURL(selectedToken); - this.reloadTokenData(selectedToken); - } - ); - }) - .catch(error => { - this.setState({ - loadingTokens: false, - errorMessage: error.toString(), - }); - }); + await getHistoryData(addressToReload, tokenToReload); + await getSelectedTokenMetadata(tokenToReload); + } catch (error) { + setErrorMessage(error); } - ); - }; + setLoadingSummary(false); + return undefined; + }, + [getHistoryData, getSelectedTokenMetadata] + ); + + // Reloads Tokens for the address. Then, update the balance/summary and history + const reloadTokensForAddress = useCallback( + async (urlAddress, urlSelectedToken) => { + setLoadingTokens(true); + + try { + const tokensResponse = await addressApi.getTokens(urlAddress, TOKEN_COUNT); + if (!tokensResponse) { + // An error happened with the API call + setShowReloadDataButton(true); + return undefined; + } - reloadTokenData = token => { - this.setState( - { - loadingSummary: true, - loadingHistory: true, - }, - () => { - addressApi - .getBalance(this.state.address, token) - .then(response => { - if (!response) { - // An error happened with the API call - this.setState({ showReloadTokenButton: true }); - return; + let newSelectedToken = ''; + + const tokens = tokensResponse.tokens || {}; + const total = tokensResponse.total || 0; + + if (total > Object.keys(tokens).length) { + // There were unfetched tokens + setWarnMissingTokens(total); + } else { + // This will turn off the missing tokens alert + setWarnMissingTokens(0); + } + + if (urlSelectedToken && tokens[urlSelectedToken]) { + // use has a selected token, we will keep the selected token + newSelectedToken = urlSelectedToken; + } else { + const hathorUID = hathorLib.constants.NATIVE_TOKEN_UID; + if (tokens[hathorUID]) { + // If HTR is in the token list of this address, it's the default selection + newSelectedToken = hathorUID; + } else { + // Otherwise we get the first element, if there is one + const keys = Object.keys(tokens); + if (keys.length === 0) { + // In case the length is 0, we have no transactions for this address + setLoadingTokens(false); + setLoadingSummary(false); + setLoadingHistory(false); + return undefined; } - const balance = response; - this.setState({ balance }); - return balance; - }) - .then(balance => { - return this.getHistoryData().then(txhistory => { - if (!this.state.metadataLoaded) { - this.getSelectedTokenMetadata(token); - } - }); - }) - .catch(error => { - this.setState({ errorMessage: error }); - }) - .finally(() => { - this.setState({ loadingSummary: false }); - }); + [newSelectedToken] = keys; + } + } + + setAddressTokens(tokens); + setLoadingTokens(false); + setSelectedToken(newSelectedToken); + + // Once all the tokens for this address are loaded, load the balance and history for the current token + await reloadTokenSummaryAndHistory(urlAddress, newSelectedToken); + } catch (error) { + setLoadingTokens(false); + setErrorMessage(error.message || error.toString()); } - ); - }; - getSelectedTokenMetadata = selectedToken => { - if (selectedToken === hathorLib.constants.NATIVE_TOKEN_UID) { - this.setState({ metadataLoaded: true }); + return undefined; + }, + [reloadTokenSummaryAndHistory] + ); + + // Loads all data on screen once the initial validation is done and a loading flag is triggered + useEffect(() => { + // This effect only runs once it's triggered by the "loadingTokens" flag + if (!loadingTokens) { return; } - metadataApi.getDagMetadata(selectedToken).then(data => { - if (data) { - this.setState({ selectedTokenMetadata: data }); - } - this.setState({ metadataLoaded: true }); - }); - }; - /** - * Callback to be executed when user changes token on select input - * - * @param {String} Value of the selected item - */ - onTokenSelectChanged = value => { - this.setState( - { - selectedToken: value, - metadataLoaded: false, - selectedTokenMetadata: null, - balance: {}, - transactions: [], - }, - () => { - this.updateTokenURL(value); - this.reloadTokenData(value); - } + // Reset all relevant state variables + setAddressTokens({}); + setTransactions([]); + setBalance({}); + setErrorMessage(''); + + // User may already have a token selected on the URL + const refQueryParams = pagination.current.obtainQueryParams(); + const newSelectedToken = refQueryParams.token; + if (newSelectedToken !== null) { + setSelectedToken(newSelectedToken); + } + + // Fetching data from servers to populate the screen + setLoadingSummary(true); + setLoadingHistory(true); + setMetadataLoaded(false); + setSelectedTokenMetadata(null); + reloadTokensForAddress(address, newSelectedToken).catch(e => + console.error('Error reloading tokens for address on address change', e) ); - }; + }, [loadingTokens, address, reloadTokensForAddress]); /** - * Update URL with new selected token and trigger didUpdate + * Callback to be executed when user changes token on select input * - * @param {String} New token selected + * @param {String} tokenUid of the selected item */ - updateTokenURL = token => { - const newURL = this.pagination.setURLParameters({ token }); - this.props.history.push(newURL); + const onTokenSelectChanged = async tokenUid => { + setSelectedToken(tokenUid); + setMetadataLoaded(false); + setSelectedTokenMetadata(null); + setBalance({}); + setTransactions([]); + + updateTokenURL(tokenUid); + await reloadTokenSummaryAndHistory(address, tokenUid); + return undefined; }; /** - * Check if the searched address is on the inputs or outputs of the new tx - * - * @param {Object} tx Transaction data received in the websocket - * @param {boolean} checkToken If should also check if token is the same, or just address + * Update URL with new selected token and trigger didUpdate * - * @return {boolean} True if should update the list, false otherwise + * @param {String} token New selected token uid */ - shouldUpdate = (tx, checkToken) => { - const arr = [...tx.outputs, ...tx.inputs]; - const token = this.pagination.obtainQueryParams().token; - - for (const element of arr) { - if (element.decoded.address === this.state.address) { - // Address is the same - if ((checkToken && element.token === token) || !checkToken) { - // Need to check token and token is the same, or no need to check token - return true; - } - } - } - - return false; + const updateTokenURL = token => { + const newURL = pagination.current.setURLParameters({ token }); + history.push(newURL); }; /** @@ -421,8 +388,8 @@ class AddressDetailExplorer extends React.Component { * * @param {String} hash Hash of tx clicked */ - onRowClicked = hash => { - this.props.history.push(`/transaction/${hash}`); + const onRowClicked = hash => { + history.push(`/transaction/${hash}`); }; /** @@ -430,11 +397,13 @@ class AddressDetailExplorer extends React.Component { * * @param {Event} e Click event */ - refreshTokenData = e => { + const handleRefreshTokenButton = e => { e.preventDefault(); - this.setState({ showReloadTokenButton: false }, () => { - this.reloadTokenData(); - }); + setShowReloadTokenButton(false); + + reloadTokenSummaryAndHistory(address, selectedToken).catch(err => + console.error('Error on handleRefreshTokenButton', err) + ); }; /** @@ -442,16 +411,13 @@ class AddressDetailExplorer extends React.Component { * * @param {Event} e Click event */ - refreshPageData = e => { + const handleRefreshAllPageData = e => { e.preventDefault(); - this.setState( - { - showReloadDataButton: false, - warningRefreshPage: false, - }, - () => { - this.reloadData(); - } + setShowReloadDataButton(false); + setWarningRefreshPage(false); + + reloadTokensForAddress(address, selectedToken).catch(err => + console.error('Error on handleRefreshAllPageData', err) ); }; @@ -460,22 +426,17 @@ class AddressDetailExplorer extends React.Component { * * @param {Event} e Click event */ - reloadPage = e => { - this.pagination.clearOptionalQueryParams(); - this.refreshPageData(e); + const reloadPage = e => { + pagination.current.clearOptionalQueryParams(); + handleRefreshAllPageData(e); }; - lastPage = () => { - return Math.ceil(this.state.balance.transactions / TX_COUNT); - }; - - onNextPageClicked = async () => { - this.setState({ loadingPagination: true }); + const onNextPageClicked = async () => { + setLoadingPagination(true); - const transactions = this.state.transactions; const { timestamp, tx_id } = transactions.at(transactions.length - 1); - const nextPage = this.state.page + 1; + const nextPage = page + 1; const lastTx = tx_id; const lastTs = timestamp; @@ -487,158 +448,164 @@ class AddressDetailExplorer extends React.Component { }; const newPageSearchAfter = [ - ...this.state.pageSearchAfter, + ...pageSearchAfter, { page: nextPage, searchAfter, }, ]; - await this.getHistoryData(searchAfter.lastTx, searchAfter.lastTs); + await getHistoryData(address, selectedToken, searchAfter.lastTx, searchAfter.lastTs); - this.setState({ - pageSearchAfter: newPageSearchAfter, - hasBefore: true, - loadingPagination: false, - page: nextPage, - }); + setPageSearchAfter(newPageSearchAfter); + setHasBefore(true); + setLoadingPagination(false); + setPage(nextPage); }; - onPreviousPageClicked = async () => { - this.setState({ loadingPagination: true }); + const onPreviousPageClicked = async () => { + setLoadingPagination(true); - const nextPage = this.state.page - 1; - const { searchAfter } = find(this.state.pageSearchAfter, { page: nextPage }); + const nextPage = page - 1; + const { searchAfter } = find(pageSearchAfter, { page: nextPage }); - await this.getHistoryData(searchAfter.lastTx, searchAfter.lastTs); + await getHistoryData(address, selectedToken, searchAfter.lastTx, searchAfter.lastTs); - this.setState({ - hasBefore: nextPage > 0, - page: nextPage, - }); + setHasBefore(nextPage > 0); + setPage(nextPage); - this.setState({ loadingPagination: false }); + setLoadingPagination(false); }; - render() { - const renderWarningAlert = () => { - if (this.state.warningRefreshPage) { - return ( -
- There is a new transaction for this address. Please{' '} - - refresh - {' '} - the page to see the newest data. -
- ); - } + const renderWarningAlert = () => { + if (warningRefreshPage) { + return ( +
+ There is a new transaction for this address. Please{' '} + + refresh + {' '} + the page to see the newest data. +
+ ); + } - return null; - }; + return null; + }; - const renderReloadTokenButton = () => { - if (this.state.showReloadTokenButton) { - return ( - - ); - } + const renderReloadTokenButton = () => { + if (showReloadTokenButton) { + return ( + + ); + } - return null; - }; + return null; + }; - const renderReloadDataButton = () => { - if (this.state.showReloadDataButton) { - return ( - - ); - } + const renderReloadDataButton = () => { + if (showReloadDataButton) { + return ( + + ); + } - return null; - }; + return null; + }; - const renderMissingTokensAlert = () => { - if (this.state.warnMissingTokens) { - return ( -
- This address has {this.state.warnMissingTokens} tokens but we are showing only the{' '} - {TOKEN_COUNT} with the most recent activity. -
- ); - } + const renderMissingTokensAlert = () => { + if (warnMissingTokens) { + return ( +
+ This address has {warnMissingTokens} tokens but we are showing only the {TOKEN_COUNT} with + the most recent activity. +
+ ); + } - return null; - }; + return null; + }; - const isNFT = () => { - return this.state.selectedTokenMetadata && this.state.selectedTokenMetadata.nft; - }; + const isNFT = () => { + return selectedTokenMetadata?.nft; + }; - const renderData = () => { - if (this.state.errorMessage) { - return ( -
-

{this.state.errorMessage}

- -
- ); - } else if (this.state.address === null) { - return null; - } else if (this.state.showReloadDataButton || this.state.showReloadTokenButton) { - return ( -
- - {renderReloadDataButton()} - {renderReloadTokenButton()} -
- ); - } else { - if (this.state.loadingSummary || this.state.loadingHistory || this.state.loadingTokens) { - return ; - } else { - return ( -
- {renderWarningAlert()} - {renderMissingTokensAlert()} - - -
- ); - } - } - }; + const renderData = () => { + if (errorMessage) { + return ( +
+

{errorMessage}

+ +
+ ); + } + if (address === null) { + return null; + } + if (showReloadDataButton || showReloadTokenButton) { + return ( +
+ + {renderReloadDataButton()} + {renderReloadTokenButton()} +
+ ); + } + if (loadingSummary || loadingHistory || loadingTokens) { + return newUiEnabled ? ( + + ) : ( + + ); + } - return
{renderData()}
; - } + return ( +
+ {renderWarningAlert()} + {renderMissingTokensAlert()} + + +
+ ); + }; + + return ( +
+ {renderData()} +
+ ); } export default AddressDetailExplorer; diff --git a/src/components/AddressDetailLegacy.js b/src/components/AddressDetailLegacy.js index 4fdc5900..c1778c5e 100644 --- a/src/components/AddressDetailLegacy.js +++ b/src/components/AddressDetailLegacy.js @@ -6,15 +6,15 @@ */ import React from 'react'; +import hathorLib from '@hathor/wallet-lib'; +import ReactLoading from 'react-loading'; +import { isEqual } from 'lodash'; import AddressSummaryLegacy from './AddressSummaryLegacy'; import AddressHistoryLegacy from './AddressHistoryLegacy'; import PaginationURL from '../utils/pagination'; -import hathorLib from '@hathor/wallet-lib'; -import ReactLoading from 'react-loading'; import colors from '../index.scss'; import WebSocketHandler from '../WebSocketHandler'; import { TX_COUNT } from '../constants'; -import { isEqual } from 'lodash'; import helpers from '../utils/helpers'; import metadataApi from '../api/metadataApi'; import addressApiLegacy from '../api/addressApiLegacy'; @@ -130,8 +130,8 @@ class AddressDetailLegacy extends React.Component { // We only add new tx/blocks if it's the first page if (!this.state.hasBefore) { if (this.shouldUpdate(tx, true)) { - let transactions = this.state.transactions; - let hasAfter = this.state.hasAfter || transactions.length === TX_COUNT; + let { transactions } = this.state; + const hasAfter = this.state.hasAfter || transactions.length === TX_COUNT; transactions = helpers.updateListWs(transactions, tx, TX_COUNT); const newNumberOfTransactions = this.state.numberOfTransactions + 1; @@ -252,7 +252,7 @@ class AddressDetailLegacy extends React.Component { this.setState({ loadingSummary: false }); return; } - selectedToken = keys[0]; + [selectedToken] = keys; } } @@ -316,7 +316,7 @@ class AddressDetailLegacy extends React.Component { */ shouldUpdate = (tx, checkToken) => { const arr = [...tx.outputs, ...tx.inputs]; - const token = this.pagination.obtainQueryParams().token; + const { token } = this.pagination.obtainQueryParams(); for (const element of arr) { if (element.decoded.address === this.state.address) { @@ -374,39 +374,38 @@ class AddressDetailLegacy extends React.Component { const renderData = () => { if (this.state.errorMessage) { return

{this.state.errorMessage}

; - } else if (this.state.address === null) { + } + if (this.state.address === null) { return null; - } else { - if (this.state.loadingSummary || this.state.loadingHistory) { - return ; - } else { - return ( -
- {renderWarningAlert()} - - -
- ); - } } + if (this.state.loadingSummary || this.state.loadingHistory) { + return ; + } + return ( +
+ {renderWarningAlert()} + + +
+ ); }; return
{renderData()}
; diff --git a/src/components/AddressHistory.js b/src/components/AddressHistory.js index 7eb64651..4131e4a6 100644 --- a/src/components/AddressHistory.js +++ b/src/components/AddressHistory.js @@ -6,12 +6,16 @@ */ import React from 'react'; -import dateFormatter from '../utils/date'; +import { connect } from 'react-redux'; import hathorLib from '@hathor/wallet-lib'; import PropTypes from 'prop-types'; +import dateFormatter from '../utils/date'; import PaginationURL from '../utils/pagination'; import SortableTable from './SortableTable'; -import { connect } from 'react-redux'; +import EllipsiCell from './EllipsiCell'; +import { ReactComponent as RowBottomIcon } from '../assets/images/leading-icon.svg'; +import { ReactComponent as RowTopIcon } from '../assets/images/leading-top-icon.svg'; +import { COLORS } from '../constants'; const mapStateToProps = state => ({ decimalPlaces: state.serverInfo.decimal_places, @@ -26,7 +30,7 @@ class AddressHistory extends SortableTable { * @return {boolean} If the tx has only authority in the search address */ isAllAuthority = tx => { - for (let txin of tx.inputs) { + for (const txin of tx.inputs) { if ( !hathorLib.transactionUtils.isAuthorityOutput(txin) && txin.decoded.address === this.props.address @@ -35,7 +39,7 @@ class AddressHistory extends SortableTable { } } - for (let txout of tx.outputs) { + for (const txout of tx.outputs) { if ( !hathorLib.transactionUtils.isAuthorityOutput(txout) && txout.decoded.address === this.props.address @@ -48,7 +52,9 @@ class AddressHistory extends SortableTable { }; renderTable(content) { - return ( + return this.props.newUiEnabled ? ( + {content}
+ ) : ( {content}
@@ -56,7 +62,15 @@ class AddressHistory extends SortableTable { } renderTableHead() { - return ( + return this.props.newUiEnabled ? ( + + Type + Hash + Timestamp + + Value + + ) : ( Type Hash @@ -85,8 +99,8 @@ class AddressHistory extends SortableTable { ); } - renderTableBody() { - return this.props.data.map(tx => { + renderTableBodyUi() { + this.props.data.map(tx => { let statusElement = ''; let trClass = ''; let prettyValue = this.renderValue(tx.balance); @@ -121,12 +135,10 @@ class AddressHistory extends SortableTable { ); } trClass = 'input-tr'; - } else { - if (this.props.txCache[tx.tx_id]) { - if (this.isAllAuthority(this.props.txCache[tx.tx_id])) { - statusElement = Authority; - prettyValue = '--'; - } + } else if (this.props.txCache[tx.tx_id]) { + if (this.isAllAuthority(this.props.txCache[tx.tx_id])) { + statusElement = Authority; + prettyValue = '--'; } } @@ -135,7 +147,7 @@ class AddressHistory extends SortableTable { trClass = ''; } return ( - this.props.onRowClicked(tx.tx_id)}> + this.props.onRowClicked(tx.tx_id)}> {hathorLib.transactionUtils.getTxType(tx)} @@ -147,7 +159,7 @@ class AddressHistory extends SortableTable { {statusElement} - {prettyValue} + {prettyValue} {hathorLib.transactionUtils.getTxType(tx)} @@ -160,6 +172,77 @@ class AddressHistory extends SortableTable { ); }); } + + renderNewTableBodyUi() { + return this.props.data.map(tx => { + let statusElement = ''; + let trClass = ''; + let prettyValue = this.renderValue(tx.balance); + + if (tx.balance > 0) { + if (tx.version === hathorLib.constants.CREATE_TOKEN_TX_VERSION) { + statusElement = ( + + Token creation + + ); + } else { + statusElement = ( + + Received + + ); + } + trClass = 'output-tr'; + } else if (tx.balance < 0) { + if (tx.version === hathorLib.constants.CREATE_TOKEN_TX_VERSION) { + statusElement = ( + + Token deposit + + ); + } else { + statusElement = ( + + Sent + + ); + } + trClass = 'input-tr'; + } else if (this.props.txCache[tx.tx_id]) { + if (this.isAllAuthority(this.props.txCache[tx.tx_id])) { + statusElement = Authority; + prettyValue = '--'; + } + } + + if (!this.props.metadataLoaded) { + // We don't show green/red info while metadata is not loaded + trClass = ''; + } + return ( + this.props.onRowClicked(tx.tx_id)}> + {hathorLib.transactionUtils.getTxType(tx)} + + + + + {dateFormatter.parseTimestampNewUi(tx.timestamp)} + + {statusElement} + + + {prettyValue} + + + + ); + }); + } + + renderTableBody() { + return this.props.newUiEnabled ? this.renderNewTableBodyUi() : this.renderTableBodyUi(); + } } /* diff --git a/src/components/AddressHistoryLegacy.js b/src/components/AddressHistoryLegacy.js index c63c1802..3b10b6c6 100644 --- a/src/components/AddressHistoryLegacy.js +++ b/src/components/AddressHistoryLegacy.js @@ -7,11 +7,11 @@ import React from 'react'; import { Link } from 'react-router-dom'; -import dateFormatter from '../utils/date'; import hathorLib, { numberUtils } from '@hathor/wallet-lib'; import PropTypes from 'prop-types'; -import PaginationURL from '../utils/pagination'; import { connect } from 'react-redux'; +import PaginationURL from '../utils/pagination'; +import dateFormatter from '../utils/date'; const mapStateToProps = state => ({ decimalPlaces: state.serverInfo.decimal_places, @@ -29,7 +29,7 @@ class AddressHistory extends React.Component { const token = this.props.selectedToken; let value = 0; - for (let txin of tx.inputs) { + for (const txin of tx.inputs) { if (txin.token === token && txin.decoded.address === this.props.address) { if (!hathorLib.transactionUtils.isAuthorityOutput(txin)) { value -= txin.value; @@ -37,7 +37,7 @@ class AddressHistory extends React.Component { } } - for (let txout of tx.outputs) { + for (const txout of tx.outputs) { if (txout.token === token && txout.decoded.address === this.props.address) { if (!hathorLib.transactionUtils.isAuthorityOutput(txout)) { value += txout.value; @@ -56,7 +56,7 @@ class AddressHistory extends React.Component { * @return {boolean} If the tx has only authority in the search address */ isAllAuthority = tx => { - for (let txin of tx.inputs) { + for (const txin of tx.inputs) { if ( !hathorLib.transactionUtils.isAuthorityOutput(txin) && txin.decoded.address === this.props.address @@ -65,7 +65,7 @@ class AddressHistory extends React.Component { } } - for (let txout of tx.outputs) { + for (const txout of tx.outputs) { if ( !hathorLib.transactionUtils.isAuthorityOutput(txout) && txout.decoded.address === this.props.address @@ -93,39 +93,35 @@ class AddressHistory extends React.Component { const loadPagination = () => { if (this.props.transactions.length === 0) { return null; - } else { - return ( - - ); - } + Next + + + + + ); }; const loadTable = () => { @@ -164,7 +160,7 @@ class AddressHistory extends React.Component { }; const loadTableBody = () => { - return this.props.transactions.map((tx, idx) => { + return this.props.transactions.map((tx, _idx) => { const value = this.calculateAddressBalance(tx); let statusElement = ''; let trClass = ''; @@ -199,11 +195,9 @@ class AddressHistory extends React.Component { ); } trClass = 'input-tr'; - } else { - if (this.isAllAuthority(tx)) { - statusElement = Authority; - prettyValue = '--'; - } + } else if (this.isAllAuthority(tx)) { + statusElement = Authority; + prettyValue = '--'; } if (!this.props.metadataLoaded) { @@ -212,7 +206,7 @@ class AddressHistory extends React.Component { } return ( - this.props.onRowClicked(tx.tx_id)}> + this.props.onRowClicked(tx.tx_id)}> {hathorLib.transactionUtils.getTxType(tx)} diff --git a/src/components/AddressSummary.js b/src/components/AddressSummary.js index 102c873d..6c6fc2e8 100644 --- a/src/components/AddressSummary.js +++ b/src/components/AddressSummary.js @@ -9,6 +9,7 @@ import React from 'react'; import PropTypes from 'prop-types'; import { numberUtils } from '@hathor/wallet-lib'; import { connect } from 'react-redux'; +import HathorSelect from './HathorSelect'; const mapStateToProps = state => ({ decimalPlaces: state.serverInfo.decimal_places, @@ -41,6 +42,29 @@ class AddressSummary extends React.Component { ); }; + const newLoadMainInfo = () => { + return ( +
+
+
Address
+
+

{this.props.address}

+
+
+
+
+ Number of tokens +
+
{Object.keys(this.props.tokens).length}
+
+
+
Token
+ {newRenderTokenData()} +
+
+ ); + }; + // We show 'Loading' until all metadatas are loaded // to prevent switching from decimal to integer if one of the tokens is an NFT const renderType = () => { @@ -50,9 +74,8 @@ class AddressSummary extends React.Component { if (this.props.isNFT) { return 'NFT'; - } else { - return 'Custom token'; } + return 'Custom token'; }; const renderValue = value => { @@ -92,6 +115,63 @@ class AddressSummary extends React.Component { ); }; + const newLoadBalanceInfo = () => { + const token = this.props.tokens[this.props.selectedToken]; + return ( +
+
+
Token
+
{`${token.name} (${token.symbol})`}
+
+
+
Type
+
{renderType()}
+
+
+
Number of transactions
+
{this.props.balance.transactions}
+
+
+
Total received
+
{renderValue(this.props.balance.total_received)}
+
+
+
Total spent
+
+ {renderValue( + this.props.balance.total_received - + this.props.balance.unlocked_balance - + this.props.balance.locked_balance + )} +
+
+
+
Unlocked balance
+
{renderValue(this.props.balance.unlocked_balance)}
+
+
+
Locked balance
+
{renderValue(this.props.balance.locked_balance)}
+
+
+ ); + }; + + const SelectToken = () => { + const uid = Object.keys(this.props.tokens).find(key => key === this.props.selectedToken); + + if (uid) { + const token = this.props.tokens[uid]; + + return { + key: uid, + name: `${token.name} (${token.symbol})`, + }; + } + + return null; + }; + const renderTokenData = () => { if (Object.keys(this.props.tokens).length === 1) { const token = this.props.tokens[this.props.selectedToken]; @@ -100,13 +180,30 @@ class AddressSummary extends React.Component { {token.name} ({token.symbol}) ); - } else { + } + return ( + + ); + }; + + const newRenderTokenData = () => { + if (Object.keys(this.props.tokens).length === 1) { + const token = this.props.tokens[this.props.selectedToken]; return ( - + + {token.name} ({token.symbol}) + ); } + return ( + this.props.tokenSelectChanged(e)} + /> + ); }; const renderTokenOptions = () => { @@ -120,6 +217,16 @@ class AddressSummary extends React.Component { }); }; + const newRenderTokenOptions = () => { + return Object.keys(this.props.tokens).map(uid => { + const token = this.props.tokens[uid]; + return { + key: uid, + name: `${token.name} (${token.symbol})`, + }; + }); + }; + const loadSummary = () => { return (
@@ -129,7 +236,18 @@ class AddressSummary extends React.Component { ); }; - return
{loadSummary()}
; + const newLoadSummary = () => { + return ( +
+ {newLoadMainInfo()} + {newLoadBalanceInfo()} +
+ ); + }; + + return ( +
{this.props.newUiEnabled ? newLoadSummary() : loadSummary()}
+ ); } } diff --git a/src/components/AddressSummaryLegacy.js b/src/components/AddressSummaryLegacy.js index 069c369f..e3b19c61 100644 --- a/src/components/AddressSummaryLegacy.js +++ b/src/components/AddressSummaryLegacy.js @@ -50,9 +50,8 @@ class AddressSummary extends React.Component { if (this.props.isNFT) { return 'NFT'; - } else { - return 'Custom token'; } + return 'Custom token'; }; const renderValue = value => { @@ -93,13 +92,12 @@ class AddressSummary extends React.Component { {balance.name} ({balance.symbol}) ); - } else { - return ( - - ); } + return ( + + ); }; const renderTokenOptions = () => { diff --git a/src/components/ConditionalNavigation.js b/src/components/ConditionalNavigation.js index 030ef24c..03f042d3 100644 --- a/src/components/ConditionalNavigation.js +++ b/src/components/ConditionalNavigation.js @@ -5,7 +5,7 @@ import { useFlag } from '@unleash/proxy-client-react'; const ConditionalNavigation = ({ featureToggle, to, label }) => { return useFlag(featureToggle) ? ( -
  • + { > {label} -
  • + ) : null; }; diff --git a/src/components/DropDetails.js b/src/components/DropDetails.js new file mode 100644 index 00000000..3ef778dc --- /dev/null +++ b/src/components/DropDetails.js @@ -0,0 +1,47 @@ +import React, { useState } from 'react'; +import { ReactComponent as RowDown } from '../assets/images/chevron-up.svg'; + +/** + * DropDetails component renders a collapsible section with a title and content body. + * The body section can be toggled open or closed by clicking on the title. + * Optionally, an external callback function can be triggered when the toggle occurs. + * + * @param {string} title - The title displayed in the header of the dropdown section. + * @param {boolean} startOpen - Determines if the dropdown is open by default when the component is first rendered. + * @param {Function} onToggle - Optional callback function triggered when the dropdown is toggled. + * This function is called every time the user clicks to open or close the dropdown. + * @param {ReactNode} children - The content displayed inside the dropdown body when it's open. + * + * @returns {JSX.Element} The DropDetails component, which includes a title and a collapsible body. + */ +export const DropDetails = ({ title, startOpen, onToggle, children }) => { + const [open, setOpen] = useState(startOpen); + + /** + * Toggles the open/close state of the dropdown and calls the optional onT callback. + */ + const click = () => { + if (onToggle) { + onToggle(); // Trigger the onT callback if provided + } + setOpen(!open); // Toggle the open/close state of the dropdown + }; + + return ( +
    +
    +
    {title}
    +
    + +
    +
    + + {open &&
    {children}
    } +
    + ); +}; diff --git a/src/components/EllipsiCell.js b/src/components/EllipsiCell.js new file mode 100644 index 00000000..2cf47570 --- /dev/null +++ b/src/components/EllipsiCell.js @@ -0,0 +1,29 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { useIsMobile } from '../hooks'; + +const EllipsiCell = ({ id }) => { + const isMobile = useIsMobile(); + const idStart = id.substring(0, isMobile ? 4 : 12); + const idEnd = id.substring(id.length - (isMobile ? 4 : 12), id.length); + + return ( +
    + {idStart} +
    +
    +
    +
    +
    + {idEnd} +
    + ); +}; + +export default EllipsiCell; diff --git a/src/components/Footer.js b/src/components/Footer.js new file mode 100644 index 00000000..808d6f9e --- /dev/null +++ b/src/components/Footer.js @@ -0,0 +1,101 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React from 'react'; +import { useSelector } from 'react-redux'; +import Version from './Version'; +import { ReactComponent as NewLogo } from '../assets/images/new-logo.svg'; + +function Footer() { + const theme = useSelector(state => state.theme); + + return ( +
    +
    +
    +
    + +
    +
    + +
    +
    +
    + + + +
    +
    + + +
    +
    +
    + Hathor Network © 2024 All Rights Reserved +
    +
    + ); +} + +export default Footer; diff --git a/src/components/GDPRConsent.js b/src/components/GDPRConsent.js index 6f949f47..479aa9c8 100644 --- a/src/components/GDPRConsent.js +++ b/src/components/GDPRConsent.js @@ -10,33 +10,80 @@ import React, { useEffect } from 'react'; import CookieConsent from 'react-cookie-consent'; import TagManager from 'react-gtm-module'; import { GTM_ID } from '../constants'; +import { useNewUiEnabled } from '../hooks'; const GDPRConsent = () => { + const newUiEnabled = useNewUiEnabled(); + + const newUiStyle = { + maxWidth: '718px', + width: '95%', + borderRadius: '8px', + backgroundColor: '#4E667499', + fontFamily: 'Mona Sans', + color: '#191C21', + fontSize: '14px', + lineHeight: '20px', + textAlign: 'left', + position: 'fixed', + left: '50%', + transform: 'translate(-50%, -50%)', + flexWrap: 'nowrap', + gap: '0px', + boxSizing: 'border-box', + }; + useEffect(() => { // Just to ensure that when we initialize GTM, the message on this component will be shown TagManager.initialize({ gtmId: GTM_ID }); // To inicialize something the user already consented, we can use getCookieConsentValue method and test the returned value ('true' or 'false') here }, []); - return ( - - This website uses cookies to ensure you get the best experience on our website. - { + return ( + - {' '} - Learn more - - . - - ); + This website uses cookies to ensure you get the best experience on our website. + + Learn more + + . + + ); + }; + + const renderUi = () => { + return ( + + This website uses cookies to ensure you get the best experience on our website. + + Learn more + + . + + ); + }; + + return newUiEnabled ? renderNewUi() : renderUi(); }; export default GDPRConsent; diff --git a/src/components/HathorSelect.js b/src/components/HathorSelect.js new file mode 100644 index 00000000..25198eb0 --- /dev/null +++ b/src/components/HathorSelect.js @@ -0,0 +1,90 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useEffect, useRef, useState } from 'react'; +import { ReactComponent as RowDown } from '../assets/images/arrow-down-nav-dropdown.svg'; + +/** + * HathorSelect component renders a dropdown menu for selecting an option. + * + * @param {Array} options - List of options to display in the dropdown. + * Each option should be an object with the following structure: + * { + * key: , // Unique identifier for each option. + * name: // Display name for the option. + * } + * + * @param {Function} onSelect - Callback function triggered when an option is selected. + * Receives the key of the selected option as an argument. + * + * @param {Object} value - The current selected option, with the same structure as an option: + * { + * key: , // Unique identifier for the current selected option. + * name: // Display name for the current selected option. + * } + * + * @param {string} [background] - Optional background color for the select box. Defaults to a light grey if not provided. + * + */ +const HathorSelect = ({ options, onSelect, value, background }) => { + const [open, setOpen] = useState(false); + + const selectRef = useRef(null); + + /** + * Handles option selection by calling the onSelect function with the selected key, + * and closes the dropdown menu. + * + * @param {string | number} key - The unique identifier of the selected option. + */ + const handleOption = key => { + onSelect(key); + setOpen(false); + }; + + /** + * Closes the dropdown menu if a click occurs outside of the component. + * + * @param {MouseEvent} event - The mouse event triggered on document click. + */ + const handleClickOutside = event => { + if (selectRef.current && !selectRef.current.contains(event.target)) { + setOpen(false); + } + }; + + useEffect(() => { + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + + return ( +
    +
    setOpen(!open)} + style={{ backgroundColor: background || '' }} + > + {value.name} + +
    +
    +
      + {options.map(option => ( +
    • handleOption(option.key)}> + {option.name} +
    • + ))} +
    +
    +
    + ); +}; + +export default HathorSelect; diff --git a/src/components/HathorSnackbar.js b/src/components/HathorSnackbar.js new file mode 100644 index 00000000..cca365a8 --- /dev/null +++ b/src/components/HathorSnackbar.js @@ -0,0 +1,62 @@ +/** + * Copyright (c) Hathor Labs and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import React, { useRef, forwardRef, useImperativeHandle } from 'react'; + +/** + * HathorSnackbar component renders a snackbar (alert) that can be shown for a specific duration. + * This component is designed to provide feedback to the user in the form of a message with different types (e.g., success, error). + * + * @param {string} type - The type of the alert, which defines its appearance and color. + * Common values could include 'success', 'error', 'info', etc. + * + * @param {string} text - The message text to be displayed within the snackbar. + * + * @ref {Object} ref - A reference object that can be used to trigger the `show` method programmatically + * from the parent component. The `show` method will display the snackbar for a given duration. + * + * @returns {JSX.Element} The HathorSnackbar component. + */ +const HathorSnackbar = forwardRef(({ type, text }, ref) => { + const alertDiv = useRef(null); + + /** + * Shows the snackbar for a specified duration. + * + * @param {number} duration - The duration (in milliseconds) for which the snackbar should be visible. + */ + const show = duration => { + if (alertDiv.current) { + alertDiv.current.classList.add('show'); + + setTimeout(() => { + alertDiv.current.classList.remove('show'); + }, duration); + } + }; + + // Exposing the show function to the parent component via the ref + useImperativeHandle(ref, () => ({ + show, + })); + + return ( +
    +

    {text}

    +
    + ); +}); + +// Define the display name for the component +HathorSnackbar.displayName = 'HathorSnackbar'; + +export default HathorSnackbar; diff --git a/src/components/Loading.js b/src/components/Loading.js index d2d35c0b..ec727f40 100644 --- a/src/components/Loading.js +++ b/src/components/Loading.js @@ -8,9 +8,12 @@ import React, { useEffect, useState } from 'react'; import ReactLoading from 'react-loading'; +import Spinner from './Spinner'; import colors from '../index.scss'; +import { useNewUiEnabled } from '../hooks'; const Loading = props => { + const newUiEnabled = useNewUiEnabled(); const slowDelay = props.slowDelay || 3000; const [slowLoad, setSlowLoad] = useState(false); @@ -24,12 +27,26 @@ const Loading = props => { }); const { showSlowLoadMessage, useLoadingWrapper, ...reactLoadProps } = props; - return ( -
    - - {slowLoad && showSlowLoadMessage ? Still loading... Please, be patient. : null} -
    - ); + + const renderUi = () => { + return ( +
    + + {slowLoad && showSlowLoadMessage ? Still loading... Please, be patient. : null} +
    + ); + }; + + const renderNewUi = () => { + return ( +
    + + {slowLoad && showSlowLoadMessage ? Still loading... Please, be patient. : null} +
    + ); + }; + + return newUiEnabled ? renderNewUi() : renderUi(); }; Loading.defaultProps = { diff --git a/src/components/Navigation.js b/src/components/Navigation.js index 58105507..25058151 100644 --- a/src/components/Navigation.js +++ b/src/components/Navigation.js @@ -5,27 +5,52 @@ * LICENSE file in the root directory of this source tree. */ -import React, { useRef } from 'react'; +import React, { useRef, useState } from 'react'; import { NavLink, Link, useHistory } from 'react-router-dom'; import hathorLib from '@hathor/wallet-lib'; import { useFlag } from '@unleash/proxy-client-react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useNewUiEnabled } from '../hooks'; import logo from '../assets/images/hathor-white-logo.png'; +import moon from '../assets/images/moon.svg'; +import sun from '../assets/images/sun-dark.svg'; +import { ReactComponent as NewLogo } from '../assets/images/new-logo.svg'; +import { ReactComponent as GlobeNetwork } from '../assets/images/global.svg'; +import { ReactComponent as SearchIcon } from '../assets/images/search-icon.svg'; +import { ReactComponent as MenuIcon } from '../assets/images/sidebar-menu.svg'; +import { ReactComponent as ArrorDownNavItem } from '../assets/images/arrow-down-nav-dropdown.svg'; import HathorAlert from './HathorAlert'; import Version from './Version'; import ConditionalNavigation from './ConditionalNavigation'; +import Sidebar from './Sidebar'; import { UNLEASH_TOKENS_BASE_FEATURE_FLAG, UNLEASH_TOKEN_BALANCES_FEATURE_FLAG, + REACT_APP_NETWORK, } from '../constants'; +import { toggleTheme } from '../actions'; function Navigation() { const history = useHistory(); + const dispatch = useDispatch(); const alertErrorRef = useRef(null); const txSearchRef = useRef(null); const isTokensBaseEnabled = useFlag(`${UNLEASH_TOKENS_BASE_FEATURE_FLAG}.rollout`); const isTokensBalanceEnabled = useFlag(`${UNLEASH_TOKEN_BALANCES_FEATURE_FLAG}.rollout`); + const theme = useSelector(state => state.theme); + const newUiEnabled = useNewUiEnabled(); + const [showSearchInput, setShowSearchInput] = useState(false); + const [showSidebar, setShowSidebar] = useState(false); const showTokensTab = isTokensBalanceEnabled || isTokensBaseEnabled; + const showSidebarHandler = () => { + setShowSidebar(!showSidebar); + }; + + const toggleSearchInput = () => { + setShowSearchInput(!showSearchInput); + }; + const handleKeyUp = e => { if (e.key === 'Enter') { search(); @@ -57,26 +82,46 @@ function Navigation() { alertErrorRef.current.show(3000); }; - return ( -
    -