diff --git a/src/screens/Wallet.js b/src/screens/Wallet.js index f7f2ced7..f886666d 100644 --- a/src/screens/Wallet.js +++ b/src/screens/Wallet.js @@ -5,11 +5,11 @@ * LICENSE file in the root directory of this source tree. */ -import React from 'react'; +import React, { useContext, useEffect, useRef, useState } from 'react'; import hathorLib from '@hathor/wallet-lib'; import { t } from 'ttag'; import { get } from 'lodash'; -import { connect } from "react-redux"; +import { useDispatch, useSelector } from 'react-redux'; import ReactLoading from 'react-loading'; import SpanFmt from '../components/SpanFmt'; @@ -19,41 +19,16 @@ import WalletAddress from '../components/WalletAddress'; import TokenGeneralInfo from '../components/TokenGeneralInfo'; import TokenAdministrative from '../components/TokenAdministrative'; import HathorAlert from '../components/HathorAlert'; -import tokens from '../utils/tokens'; +import tokensUtils from '../utils/tokens'; import version from '../utils/version'; -import wallet from '../utils/wallet'; +import walletUtils from '../utils/wallet'; import BackButton from '../components/BackButton'; import colors from '../index.scss'; import { TOKEN_DOWNLOAD_STATUS } from '../sagas/tokens'; import { GlobalModalContext, MODAL_TYPES } from '../components/GlobalModal'; -import { - updateWords, - tokenFetchHistoryRequested, - tokenFetchBalanceRequested, -} from '../actions/index'; +import { tokenFetchBalanceRequested, tokenFetchHistoryRequested, updateWords, } from '../actions/index'; import LOCAL_STORE from '../storage'; - - -const mapDispatchToProps = dispatch => { - return { - updateWords: (data) => dispatch(updateWords(data)), - getHistory: (tokenId) => dispatch(tokenFetchHistoryRequested(tokenId)), - getBalance: (tokenId) => dispatch(tokenFetchBalanceRequested(tokenId)), - }; -}; - -const mapStateToProps = (state) => { - return { - selectedToken: state.selectedToken, - tokensHistory: state.tokensHistory, - tokensBalance: state.tokensBalance, - tokenMetadata: state.tokenMetadata || {}, - tokens: state.tokens, - wallet: state.wallet, - walletState: state.walletState, - useWalletService: state.useWalletService, - }; -}; +import { useHistory } from 'react-router-dom'; /** @@ -61,135 +36,170 @@ const mapStateToProps = (state) => { * * @memberof Screens */ -class Wallet extends React.Component { - static contextType = GlobalModalContext; - - constructor(props) { - super(props); - - this.alertSuccessRef = React.createRef(); - } - - /** - * backupDone {boolean} if words backup was already done - * successMessage {string} Message to be shown on alert success - * shouldShowAdministrativeTab {boolean} If we should display the Administrative Tools tab - */ - state = { - backupDone: true, - successMessage: '', - hasTokenSignature: false, - shouldShowAdministrativeTab: false, - totalSupply: null, - canMint: false, - canMelt: false, - transactionsCount: null, - mintCount: null, - meltCount: null, - }; - - // Reference for the unregister confirm modal - unregisterModalRef = React.createRef(); - - componentDidMount = async () => { - this.setState({ - backupDone: LOCAL_STORE.isBackupDone() - }); - - this.initializeWalletScreen(); - } - - componentDidUpdate(prevProps) { - // the selected token changed, we should re-initialize the screen - if (this.props.selectedToken !== prevProps.selectedToken) { - this.initializeWalletScreen(); - } - - // if the new selected token history changed, we should fetch the token details again - const prevTokenHistory = get(prevProps.tokensHistory, this.props.selectedToken, { - status: TOKEN_DOWNLOAD_STATUS.LOADING, - updatedAt: -1, - data: [], - }); - const currentTokenHistory = get(this.props.tokensHistory, this.props.selectedToken, { +function Wallet() { + // Modal context + const context = useContext(GlobalModalContext); + + // State + /** backupDone {boolean} if words backup was already done */ + const [backupDone, setBackupDone] = useState(LOCAL_STORE.isBackupDone()); + /** successMessage {string} Message to be shown on alert success */ + const [successMessage, setSuccessMessage] = useState(''); + /* shouldShowAdministrativeTab {boolean} If we should display the Administrative Tools tab */ + const [shouldShowAdministrativeTab, setShouldShowAdministrativeTab] = useState(false); + // XXX: There is an important `errorMessage` state that was not being set in the previous version + // It should be set for both the tokenMetadata error handling ( that are currently ignored ) + // and the TokenGeneralInfo child component in a future moment + const [totalSupply, setTotalSupply] = useState(null); + const [canMint, setCanMint] = useState(false); + const [canMelt, setCanMelt] = useState(false); + const [transactionsCount, setTransactionsCount] = useState(null); + const [mintCount, setMintCount] = useState(null); + const [meltCount, setMeltCount] = useState(null); + + // Redux state + const { + selectedToken, + tokensHistory, + tokensBalance, + tokenMetadata, + tokens, + wallet, + walletState, + } = useSelector((state) => { + return { + selectedToken: state.selectedToken, + tokensHistory: state.tokensHistory, + tokensBalance: state.tokensBalance, + tokenMetadata: state.tokenMetadata || {}, + tokens: state.tokens, + wallet: state.wallet, + walletState: state.walletState, + }; + }); + + // Token history timestamp to check if we should fetch the token details again + const [tokenHistoryTimestamp, setTokenHistoryTimestamp] = useState(-1); + + // Refs + const alertSuccessRef = useRef(null); + const unregisterModalRef = useRef(null); + + // Navigation and actions + const history = useHistory(); + const dispatch = useDispatch(); + + // Initialize the screen on mount + useEffect(() => { + initializeWalletScreen(); + }, []); + + // Re-initialize the screen when the selected token changes + useEffect(() => { + initializeWalletScreen(); + }, [selectedToken]); + + // When the tokens history changes, check the last timestamp to define if we should fetch the token details again + useEffect(() => { + const selectedTokenHistory = get(tokensHistory, selectedToken, { status: TOKEN_DOWNLOAD_STATUS.LOADING, updatedAt: -1, data: [], }); - if (prevTokenHistory.updatedAt !== currentTokenHistory.updatedAt) { - this.updateTokenInfo(); - this.updateWalletInfo(); + if (tokenHistoryTimestamp !== selectedTokenHistory.updatedAt) { + setTokenHistoryTimestamp(selectedTokenHistory.updatedAt); + updateTokenInfo(selectedToken); + updateWalletInfo(selectedToken); } - } + }, [tokensHistory]); /** * Resets the state data and triggers token information requests */ - async initializeWalletScreen() { - this.shouldShowAdministrativeTab(this.props.selectedToken); - const signature = tokens.getTokenSignature(this.props.selectedToken); - - this.setState({ - hasTokenSignature: !!signature, - totalSupply: null, - canMint: false, - canMelt: false, - transactionsCount: null, - shouldShowAdministrativeTab: false, - }); - - // No need to download token info and wallet info if the token is hathor - if (this.props.selectedToken === hathorLib.constants.HATHOR_TOKEN_CONFIG.uid) { + async function initializeWalletScreen() { + // Reset the screen state + setTotalSupply(null); + setCanMint(false); + setCanMelt(false); + setTransactionsCount(null); + setShouldShowAdministrativeTab(false); + + // No need to download token info and mint/melt info if the token is hathor + if (selectedToken === hathorLib.constants.HATHOR_TOKEN_CONFIG.uid) { return; } - await this.updateTokenInfo(); - await this.updateWalletInfo(); + // Fires the fetching of all token data + calculateShouldShowAdministrativeTab(selectedToken); + updateTokenInfo(selectedToken); + updateWalletInfo(selectedToken); } /** * Update token state after didmount or props update + * @param {string} tokenUid */ - updateWalletInfo = async () => { - const tokenUid = this.props.selectedToken; - const mintUtxos = await this.props.wallet.getMintAuthority(tokenUid, { many: true }); - const meltUtxos = await this.props.wallet.getMeltAuthority(tokenUid, { many: true }); + const updateWalletInfo = async (tokenUid) => { + const mintUtxos = await wallet.getMintAuthority(tokenUid, { many: true }); + const meltUtxos = await wallet.getMeltAuthority(tokenUid, { many: true }); - // The user might have changed token while we are downloading, we should ignore - if (this.props.selectedToken !== tokenUid) { + // If the user has changed the selectedToken while we were fetching the data, discard it + if (selectedToken !== tokenUid) { return; } + // Update the state with the new data const mintCount = mintUtxos.length; const meltCount = meltUtxos.length; - - this.setState({ - mintCount, - meltCount, - }); + setMintCount(mintCount); + setMeltCount(meltCount); } - async updateTokenInfo() { - const tokenUid = this.props.selectedToken; + /** + * Fetches mint and melt data for a token + * @param {string} tokenUid + * @returns {Promise} + */ + async function updateTokenInfo(tokenUid) { + // No need to fetch token info if the token is hathor if (tokenUid === hathorLib.constants.HATHOR_TOKEN_CONFIG.uid) { return; } - const tokenDetails = await this.props.wallet.getTokenDetails(tokenUid); + const tokenDetails = await wallet.getTokenDetails(tokenUid); - // The user might have changed token while we are downloading, we should ignore - if (this.props.selectedToken !== tokenUid) { + // If the user has changed the selectedToken while we were fetching the data, discard it + if (selectedToken !== tokenUid) { return; } - const { totalSupply, totalTransactions, authorities } = tokenDetails; + // Update the state with the new data + const { totalSupply: newTotalSupply, totalTransactions, authorities } = tokenDetails; + setTotalSupply(newTotalSupply); + setCanMint(authorities.mint); + setCanMelt(authorities.melt); + setTransactionsCount(totalTransactions); + } - this.setState({ - totalSupply, - canMint: authorities.mint, - canMelt: authorities.melt, - transactionsCount: totalTransactions, - }); + /** + * We show the administrative tools tab only for the users that one day had an authority output, even if it was already spent + * + * This will set the shouldShowAdministrativeTab state param based on the response of getMintAuthority and getMeltAuthority + */ + const calculateShouldShowAdministrativeTab = async (tokenId) => { + const mintAuthorities = await wallet.getMintAuthority(tokenId, { skipSpent: false }); + + if (mintAuthorities.length > 0) { + return setShouldShowAdministrativeTab(true); + } + + const meltAuthorities = await wallet.getMeltAuthority(tokenId, { skipSpent: false }); + + if (meltAuthorities.length > 0) { + return setShouldShowAdministrativeTab(true); + } + + return setShouldShowAdministrativeTab(false); } /** @@ -197,344 +207,318 @@ class Wallet extends React.Component { * * @param {Object} e Event emitted when user click */ - backupClicked = (e) => { + const backupClicked = (e) => { e.preventDefault(); - this.context.showModal(MODAL_TYPES.BACKUP_WORDS, { + context.showModal(MODAL_TYPES.BACKUP_WORDS, { needPassword: true, - validationSuccess: this.backupSuccess, + validationSuccess: backupSuccess, }); - } - /** - * Called when the backup of words was done with success, then close the modal and show alert success - */ - backupSuccess = () => { - this.context.hideModal(); - LOCAL_STORE.markBackupDone(); - - this.props.updateWords(null); - this.setState({ backupDone: true, successMessage: t`Backup completed!` }, () => { - this.alertSuccessRef.current.show(3000); - }); + /** + * Called when the backup of words was done with success, then close the modal and show alert success + */ + function backupSuccess() { + context.hideModal(); + LOCAL_STORE.markBackupDone(); + + dispatch(updateWords(null)); + setBackupDone(true); + setSuccessMessage(t`Backup completed!`); + alertSuccessRef.current.show(3000); + } } /** * Called when user clicks to unregister the token, then opens the modal */ - unregisterClicked = () => { - this.context.showModal(MODAL_TYPES.CONFIRM, { - ref: this.unregisterModalRef, + const unregisterClicked = () => { + const tokenUid = selectedToken; + const token = tokens.find((token) => token.uid === tokenUid); + if (token === undefined) return; + + context.showModal(MODAL_TYPES.CONFIRM, { + ref: unregisterModalRef, modalID: 'unregisterModal', title: t`Unregister token`, - body: this.getUnregisterBody(), - handleYes: this.unregisterConfirmed, + body: getUnregisterBody(), + handleYes: unregisterConfirmed, }); - } - /** - * Called when user clicks to sign the token, then opens the modal - */ - signClicked = () => { - const token = this.props.tokens.find((token) => token.uid === this.props.selectedToken); - - if (LOCAL_STORE.isHardwareWallet() && version.isLedgerCustomTokenAllowed()) { - this.context.showModal(MODAL_TYPES.LEDGER_SIGN_TOKEN, { - token, - modalId: 'signTokenDataModal', - cb: this.updateTokenSignature, - }) + function getUnregisterBody() { + return ( +
+

{t`Are you sure you want to unregister the token **${token.name} (${token.symbol})**?`}

+

{t`You won't lose your tokens, you just won't see this token on the side bar anymore.`}

+
+ ) } - } - /** - * When user confirms the unregister of the token, hide the modal and execute it - */ - unregisterConfirmed = async () => { - const tokenUid = this.props.selectedToken; - try { - await tokens.unregisterToken(tokenUid); - wallet.setTokenAlwaysShow(tokenUid, false); // Remove this token from "always show" - this.context.hideModal(); - } catch (e) { - this.unregisterModalRef.current.updateErrorMessage(e.message); + /** + * When user confirms the unregistration of the token, hide the modal and execute it + */ + async function unregisterConfirmed() { + try { + await tokensUtils.unregisterToken(tokenUid); + walletUtils.setTokenAlwaysShow(tokenUid, false); // Remove this token from "always show" + context.hideModal(); + } catch (e) { + unregisterModalRef.current.updateErrorMessage(e.message); + } } } - /* - * We show the administrative tools tab only for the users that one day had an authority output, even if it was already spent - * - * This will set the shouldShowAdministrativeTab state param based on the response of getMintAuthority and getMeltAuthority + /** + * Called when user clicks to sign the token, then opens the modal */ - shouldShowAdministrativeTab = async (tokenId) => { - const mintAuthorities = await this.props.wallet.getMintAuthority(tokenId, { skipSpent: false }); - - if (mintAuthorities.length > 0) { - return this.setState({ - shouldShowAdministrativeTab: true, - }); - } - - const meltAuthorities = await this.props.wallet.getMeltAuthority(tokenId, { skipSpent: false }); - - if (meltAuthorities.length > 0) { - return this.setState({ - shouldShowAdministrativeTab: true, - }); + const signClicked = () => { + // Can only sign on a hardware wallet on a version with custom tokens allowed + if (!LOCAL_STORE.isHardwareWallet() || !version.isLedgerCustomTokenAllowed()) { + return; } - return this.setState({ - shouldShowAdministrativeTab: false, - }); - } - - goToAllAddresses = () => { - this.props.history.push('/addresses/'); + const token = tokens.find((token) => token.uid === selectedToken); + context.showModal(MODAL_TYPES.LEDGER_SIGN_TOKEN, { + token, + modalId: 'signTokenDataModal', + }) } - // Trigger a render when we sign a token - updateTokenSignature = (value) => { - this.setState({ - hasTokenSignature: value, - }); + /** + * @deprecated This should be replaced by usage of `useHistory` inside the child component + */ + const goToAllAddresses = () => { + history.push('/addresses/'); } - retryDownload = (e, tokenId) => { + /** + * Retries the download of a single token's balance and history + * @param {object} e Event + * @param {string} tokenId + */ + const retryDownloadClicked = (e, tokenId) => { e.preventDefault(); const balanceStatus = get( - this.props.tokensBalance, - `${this.props.selectedToken}.status`, + tokensBalance, + `${tokenId}.status`, TOKEN_DOWNLOAD_STATUS.LOADING, ); const historyStatus = get( - this.props.tokensHistory, - `${this.props.selectedToken}.status`, + tokensHistory, + `${tokenId}.status`, TOKEN_DOWNLOAD_STATUS.LOADING, ); - // We should only retry the request that failed: - + // We should only retry requests that have failed: if (historyStatus === TOKEN_DOWNLOAD_STATUS.FAILED) { - this.props.getHistory(tokenId); + dispatch(tokenFetchHistoryRequested(tokenId)); } - if (balanceStatus === TOKEN_DOWNLOAD_STATUS.FAILED) { - this.props.getBalance(tokenId); + dispatch(tokenFetchBalanceRequested(tokenId)); } } - getUnregisterBody() { - const token = this.props.tokens.find((token) => token.uid === this.props.selectedToken); - if (token === undefined) return null; - + // Rendering process below + const token = tokens.find((token) => token.uid === selectedToken); + const tokenHistory = get(tokensHistory, selectedToken, { + status: TOKEN_DOWNLOAD_STATUS.LOADING, + data: [], + }); + const tokenBalance = get(tokensBalance, selectedToken, { + status: TOKEN_DOWNLOAD_STATUS.LOADING, + data: { + available: 0, + locked: 0, + }, + }); + + const renderBackupAlert = () => { return ( -
-

{t`Are you sure you want to unregister the token **${token.name} (${token.symbol})**?`}

-

{t`You won't lose your tokens, you just won't see this token on the side bar anymore.`}

+
+ { t`You haven't done the backup of your wallet yet. You should do it as soon as possible for your own safety.` } + backupClicked(e) }>{ t`Do it now` }
) } - render() { - const token = this.props.tokens.find((token) => token.uid === this.props.selectedToken); - const tokenHistory = get(this.props.tokensHistory, this.props.selectedToken, { - status: TOKEN_DOWNLOAD_STATUS.LOADING, - data: [], - }); - const tokenBalance = get(this.props.tokensBalance, this.props.selectedToken, { - status: TOKEN_DOWNLOAD_STATUS.LOADING, - data: { - available: 0, - locked: 0, - }, - }); - - const renderBackupAlert = () => { - return ( -
- {t`You haven't done the backup of your wallet yet. You should do it as soon as possible for your own safety.`} this.backupClicked(e)}>{t`Do it now`} -
- ) - } - - const renderWallet = () => { - return ( -
-
-
- -
- + const renderWallet = () => { + return ( +
+
+
+
-
-
- -
-
+ +
+
+
+ +
-
- +
- ); + +
+ ); + } + + const renderTabAdmin = () => { + if (!shouldShowAdministrativeTab) { + return null; } - const renderTabAdmin = () => { - if (this.state.shouldShowAdministrativeTab) { - return ( -
  • - {t`Administrative Tools`} -
  • - ); - } else { - return null; - } + return ( +
  • + {t`Administrative Tools`} +
  • + ); + } + + const renderTokenData = (token) => { + if (hathorLib.tokensUtils.isHathorToken(selectedToken)) { + return renderWallet(); } - const renderTokenData = (token) => { - if (hathorLib.tokensUtils.isHathorToken(this.props.selectedToken)) { - return renderWallet(); - } else { - return ( -
    - -
    -
    - {renderWallet()} -
    -
    - + +
    +
    + {renderWallet()} +
    +
    + +
    + { + shouldShowAdministrativeTab && ( +
    +
    - { - this.shouldShowAdministrativeTab && ( -
    - -
    - ) - } -
    -
    - ); - } - } - - const renderSignTokenIcon = () => { - // Don't show if it's HTR - if (hathorLib.tokensUtils.isHathorToken(this.props.selectedToken)) return null; + ) + } +
    +
    + ); + } - const signature = tokens.getTokenSignature(this.props.selectedToken); - // Show only if we don't have a signature on storage - if (signature === null) return - return - } + const renderSignTokenIcon = () => { + // Don't show if it's HTR + if (hathorLib.tokensUtils.isHathorToken(selectedToken)) return null; - const renderUnlockedWallet = () => { - let template; - /** - * We only show the loading message if we are syncing the entire history - * This will happen on the first history load and if we lose connection - * to the fullnode. - */ - if (this.props.walletState === hathorLib.HathorWallet.SYNCING) { - template = ( -
    - -

    {t`Loading token information, please wait...`}

    -
    - ) - } else if ( - tokenHistory.status === TOKEN_DOWNLOAD_STATUS.FAILED - || tokenBalance.status === TOKEN_DOWNLOAD_STATUS.FAILED) { - template = ( -
    - -

    - - {t`Token load failed, please`}  - this.retryDownload(e, token.uid)} href="true"> - {t`try again`} - - ... - -

    + const signature = tokensUtils.getTokenSignature(selectedToken); + // Show only if we don't have a signature on storage + if (signature === null) return + return + } -
    - ) - } else { - template = ( - <> -
    -

    - {token ? token.name : ''} - {!hathorLib.tokensUtils.isHathorToken(this.props.selectedToken) && } - {LOCAL_STORE.isHardwareWallet() && version.isLedgerCustomTokenAllowed() && renderSignTokenIcon()} -

    -
    - {renderTokenData(token)} - - ) - } + const renderUnlockedWallet = () => { + let template; + /** + * We only show the loading message if we are syncing the entire history + * This will happen on the first history load and if we lose connection + * to the fullnode. + */ + if (walletState === hathorLib.HathorWallet.SYNCING) { + template = ( +
    + +

    {t`Loading token information, please wait...`}

    +
    + ) + } else if ( + tokenHistory.status === TOKEN_DOWNLOAD_STATUS.FAILED + || tokenBalance.status === TOKEN_DOWNLOAD_STATUS.FAILED) { + template = ( +
    + +

    + + {t`Token load failed, please`}  + retryDownloadClicked(e, token.uid)} href="true"> + {t`try again`} + + ... + +

    - return ( -
    - { template }
    - ); + ) + } else { + template = ( + <> +
    +

    + {token ? token.name : ''} + {!hathorLib.tokensUtils.isHathorToken(selectedToken) && } + {LOCAL_STORE.isHardwareWallet() && version.isLedgerCustomTokenAllowed() && renderSignTokenIcon()} +

    +
    + {renderTokenData(token)} + + ) } return ( -
    - {!this.state.backupDone && renderBackupAlert()} -
    - {/* This back button is not 100% perfect because when the user has just unlocked the wallet, it would go back to it when clicked - * There is no easy way to get the previous path - * I could use a lib (https://github.com/hinok/react-router-last-location) - * Or handle it in our code, saving the last accessed screen - * XXX Is it worth it to do anything about it just to prevent this case? - */} - - {renderUnlockedWallet()} -
    - +
    + { template }
    ); } + + return ( +
    + {!backupDone && renderBackupAlert()} +
    + {/* This back button is not 100% perfect because when the user has just unlocked the wallet, it would go back to it when clicked + * There is no easy way to get the previous path + * I could use a lib (https://github.com/hinok/react-router-last-location) + * Or handle it in our code, saving the last accessed screen + * XXX Is it worth it to do anything about it just to prevent this case? + */} + + {renderUnlockedWallet()} +
    + +
    + ); } -export default connect(mapStateToProps, mapDispatchToProps)(Wallet); +export default Wallet;