import { colors } from '@0xproject/react-shared'; import { BigNumber } from '@0xproject/utils'; import * as _ from 'lodash'; import * as React from 'react'; import * as DocumentTitle from 'react-document-title'; import { Link, Route, RouteComponentProps, Switch } from 'react-router-dom'; import { Blockchain } from 'ts/blockchain'; import { BlockchainErrDialog } from 'ts/components/dialogs/blockchain_err_dialog'; import { LedgerConfigDialog } from 'ts/components/dialogs/ledger_config_dialog'; import { PortalDisclaimerDialog } from 'ts/components/dialogs/portal_disclaimer_dialog'; import { EthWrappers } from 'ts/components/eth_wrappers'; import { FillOrder } from 'ts/components/fill_order'; import { AssetPicker } from 'ts/components/generate_order/asset_picker'; import { BackButton } from 'ts/components/portal/back_button'; import { Loading } from 'ts/components/portal/loading'; import { Menu, MenuTheme } from 'ts/components/portal/menu'; import { Section } from 'ts/components/portal/section'; import { TextHeader } from 'ts/components/portal/text_header'; import { RelayerIndex } from 'ts/components/relayer_index/relayer_index'; import { TokenBalances } from 'ts/components/token_balances'; import { TopBar, TopBarDisplayType } from 'ts/components/top_bar/top_bar'; import { TradeHistory } from 'ts/components/trade_history/trade_history'; import { Container } from 'ts/components/ui/container'; import { FlashMessage } from 'ts/components/ui/flash_message'; import { Image } from 'ts/components/ui/image'; import { Text } from 'ts/components/ui/text'; import { Wallet } from 'ts/components/wallet/wallet'; import { GenerateOrderForm } from 'ts/containers/generate_order_form'; import { PortalOnboardingFlow } from 'ts/containers/portal_onboarding_flow'; import { localStorage } from 'ts/local_storage/local_storage'; import { trackedTokenStorage } from 'ts/local_storage/tracked_token_storage'; import { FullscreenMessage } from 'ts/pages/fullscreen_message'; import { Dispatcher } from 'ts/redux/dispatcher'; import { zIndex } from 'ts/style/z_index'; import { BlockchainErrs, HashData, ItemByAddress, Order, ProviderType, ScreenWidths, Token, TokenByAddress, TokenStateByAddress, TokenVisibility, WebsitePaths, } from 'ts/types'; import { analytics } from 'ts/utils/analytics'; import { backendClient } from 'ts/utils/backend_client'; import { configs } from 'ts/utils/configs'; import { constants } from 'ts/utils/constants'; import { orderParser } from 'ts/utils/order_parser'; import { Translate } from 'ts/utils/translate'; import { utils } from 'ts/utils/utils'; export interface PortalProps { blockchainErr: BlockchainErrs; blockchainIsLoaded: boolean; dispatcher: Dispatcher; hashData: HashData; injectedProviderName: string; networkId: number; nodeVersion: string; orderFillAmount: BigNumber; providerType: ProviderType; screenWidth: ScreenWidths; tokenByAddress: TokenByAddress; userEtherBalanceInWei?: BigNumber; userAddress: string; shouldBlockchainErrDialogBeOpen: boolean; userSuppliedOrderCache: Order; location: Location; flashMessage?: string | React.ReactNode; lastForceTokenStateRefetch: number; translate: Translate; isPortalOnboardingShowing: boolean; portalOnboardingStep: number; } interface PortalState { prevNetworkId: number; prevNodeVersion: string; prevUserAddress: string; prevPathname: string; isDisclaimerDialogOpen: boolean; isLedgerDialogOpen: boolean; tokenManagementState: TokenManagementState; trackedTokenStateByAddress: TokenStateByAddress; } interface AccountManagementItem { pathName: string; headerText?: string; render: () => React.ReactNode; } enum TokenManagementState { Add = 'Add', Remove = 'Remove', None = 'None', } const THROTTLE_TIMEOUT = 100; const TOP_BAR_HEIGHT = TopBar.heightForDisplayType(TopBarDisplayType.Expanded); const LEFT_COLUMN_WIDTH = 346; const MENU_PADDING_LEFT = 185; const LARGE_LAYOUT_MAX_WIDTH = 1200; const SIDE_PADDING = 20; export class Portal extends React.Component { private _blockchain: Blockchain; private _sharedOrderIfExists: Order; private _throttledScreenWidthUpdate: () => void; constructor(props: PortalProps) { super(props); this._sharedOrderIfExists = orderParser.parse(window.location.search); this._throttledScreenWidthUpdate = _.throttle(this._updateScreenWidth.bind(this), THROTTLE_TIMEOUT); const didAcceptPortalDisclaimer = localStorage.getItemIfExists(constants.LOCAL_STORAGE_KEY_ACCEPT_DISCLAIMER); const hasAcceptedDisclaimer = !_.isUndefined(didAcceptPortalDisclaimer) && !_.isEmpty(didAcceptPortalDisclaimer); const initialTrackedTokenStateByAddress = this._getInitialTrackedTokenStateByAddress( this._getCurrentTrackedTokens(), ); this.state = { prevNetworkId: this.props.networkId, prevNodeVersion: this.props.nodeVersion, prevUserAddress: this.props.userAddress, prevPathname: this.props.location.pathname, isDisclaimerDialogOpen: !hasAcceptedDisclaimer, tokenManagementState: TokenManagementState.None, isLedgerDialogOpen: false, trackedTokenStateByAddress: initialTrackedTokenStateByAddress, }; } public componentDidMount(): void { window.addEventListener('resize', this._throttledScreenWidthUpdate); window.scrollTo(0, 0); } public componentWillMount(): void { this._blockchain = new Blockchain(this.props.dispatcher); } public componentWillUnmount(): void { this._blockchain.destroy(); window.removeEventListener('resize', this._throttledScreenWidthUpdate); // We re-set the entire redux state when the portal is unmounted so that when it is re-rendered // the initialization process always occurs from the same base state. This helps avoid // initialization inconsistencies (i.e While the portal was unrendered, the user might have // become disconnected from their backing Ethereum node, changed user accounts, etc...) this.props.dispatcher.resetState(); } public componentDidUpdate(prevProps: PortalProps): void { if (!prevProps.blockchainIsLoaded && this.props.blockchainIsLoaded) { // tslint:disable-next-line:no-floating-promises this._fetchBalancesAndAllowancesAsync(this._getCurrentTrackedTokensAddresses()); } } public componentWillReceiveProps(nextProps: PortalProps): void { if (nextProps.networkId !== this.state.prevNetworkId) { // tslint:disable-next-line:no-floating-promises this._blockchain.networkIdUpdatedFireAndForgetAsync(nextProps.networkId); this.setState({ prevNetworkId: nextProps.networkId, }); } if (nextProps.userAddress !== this.state.prevUserAddress) { const newUserAddress = _.isEmpty(nextProps.userAddress) ? undefined : nextProps.userAddress; // tslint:disable-next-line:no-floating-promises this._blockchain.userAddressUpdatedFireAndForgetAsync(newUserAddress); this.setState({ prevUserAddress: nextProps.userAddress, }); } if (nextProps.nodeVersion !== this.state.prevNodeVersion) { // tslint:disable-next-line:no-floating-promises this._blockchain.nodeVersionUpdatedFireAndForgetAsync(nextProps.nodeVersion); } if (nextProps.location.pathname !== this.state.prevPathname) { this.setState({ prevPathname: nextProps.location.pathname, }); } // If the address changed, but the network did not, we can just refetch the currently tracked tokens. if ( (nextProps.userAddress !== this.props.userAddress && nextProps.networkId === this.props.networkId) || nextProps.lastForceTokenStateRefetch !== this.props.lastForceTokenStateRefetch ) { // tslint:disable-next-line:no-floating-promises this._fetchBalancesAndAllowancesAsync(this._getCurrentTrackedTokensAddresses()); } const nextTrackedTokens = utils.getTrackedTokens(nextProps.tokenByAddress); const trackedTokens = this._getCurrentTrackedTokens(); if (!_.isEqual(nextTrackedTokens, trackedTokens)) { const newTokens = _.difference(nextTrackedTokens, trackedTokens); const newTokenAddresses = _.map(newTokens, token => token.address); // Add placeholder entry for this token to the state, since fetching the // balance/allowance is asynchronous const trackedTokenStateByAddress = { ...this.state.trackedTokenStateByAddress }; for (const tokenAddress of newTokenAddresses) { trackedTokenStateByAddress[tokenAddress] = { balance: new BigNumber(0), allowance: new BigNumber(0), isLoaded: false, }; } this.setState({ trackedTokenStateByAddress, }); // Fetch the actual balance/allowance. // tslint:disable-next-line:no-floating-promises this._fetchBalancesAndAllowancesAsync(newTokenAddresses); } } public render(): React.ReactNode { const updateShouldBlockchainErrDialogBeOpen = this.props.dispatcher.updateShouldBlockchainErrDialogBeOpen.bind( this.props.dispatcher, ); const isAssetPickerDialogOpen = this.state.tokenManagementState !== TokenManagementState.None; const tokenVisibility = this.state.tokenManagementState === TokenManagementState.Add ? TokenVisibility.UNTRACKED : TokenVisibility.TRACKED; return ( ); } private _renderMainRoute(): React.ReactNode { if (this._isSmallScreen()) { return ; } else { return ; } } private _renderOtherRoutes(routeComponentProps: RouteComponentProps): React.ReactNode { if (this._isSmallScreen()) { return ; } else { return ; } } private _renderMenu(routeComponentProps: RouteComponentProps): React.ReactNode { const menuTheme: MenuTheme = { paddingLeft: MENU_PADDING_LEFT, textColor: colors.darkerGrey, iconColor: colors.darkerGrey, selectedIconColor: colors.yellow800, selectedBackgroundColor: 'transparent', }; return (
} body={} /> ); } private _renderWallet(): React.ReactNode { const isMobile = utils.isMobileWidth(this.props.screenWidth); // We need room to scroll down for mobile onboarding const marginBottom = isMobile ? '250px' : '15px'; return (
{isMobile && ( {this._renderStartOnboarding()} )} {!isMobile && {this._renderStartOnboarding()}}
); } private _renderStartOnboarding(): React.ReactNode { const isMobile = utils.isMobileWidth(this.props.screenWidth); const shouldStartOnboarding = !isMobile || this.props.location.pathname === `${WebsitePaths.Portal}/account`; const startOnboarding = ( Set up your account to start trading ); return !shouldStartOnboarding ? ( {startOnboarding} ) : ( startOnboarding ); } private _startOnboarding(): void { analytics.track('Onboarding Started', { reason: 'manual', stepIndex: this.props.portalOnboardingStep, }); this.props.dispatcher.updatePortalOnboardingShowing(true); } private _renderWalletSection(): React.ReactNode { return
} body={this._renderWallet()} />; } private _renderAccountManagement(): React.ReactNode { const accountManagementItems: AccountManagementItem[] = [ { pathName: `${WebsitePaths.Portal}/weth`, headerText: 'Wrapped ETH', render: this._renderEthWrapper.bind(this), }, { pathName: `${WebsitePaths.Portal}/account`, headerText: this._isSmallScreen() ? undefined : 'Your Account', render: this._isSmallScreen() ? this._renderWallet.bind(this) : this._renderTokenBalances.bind(this), }, { pathName: `${WebsitePaths.Portal}/trades`, headerText: 'Trade History', render: this._renderTradeHistory.bind(this), }, { pathName: `${WebsitePaths.Portal}/generate`, headerText: 'Generate Order', render: this._renderGenerateOrderForm.bind(this), }, { pathName: `${WebsitePaths.Portal}/fill`, headerText: 'Fill Order', render: this._renderFillOrder.bind(this), }, ]; return (
{_.map(accountManagementItems, item => { return ( ); })}}
); } private _renderAccountManagementItem(item: AccountManagementItem): React.ReactNode { return (
} body={} /> ); } private _renderEthWrapper(): React.ReactNode { return ( ); } private _renderTradeHistory(): React.ReactNode { return ( ); } private _renderGenerateOrderForm(): React.ReactNode { return ( ); } private _renderFillOrder(): React.ReactNode { const initialFillOrder = !_.isUndefined(this.props.userSuppliedOrderCache) ? this.props.userSuppliedOrderCache : this._sharedOrderIfExists; return ( ); } private _renderTokenBalances(): React.ReactNode { return ( ); } private _renderRelayerIndexSection(): React.ReactNode { const isMobile = utils.isMobileWidth(this.props.screenWidth); return (
} body={ {isMobile && ( {this._renderStartOnboarding()} )} } /> ); } private _renderNotFoundMessage(): React.ReactNode { return ( ); } private _onTokenChosen(tokenAddress: string): void { if (_.isEmpty(tokenAddress)) { this.setState({ tokenManagementState: TokenManagementState.None, }); return; } const token = this.props.tokenByAddress[tokenAddress]; const isDefaultTrackedToken = _.includes(configs.DEFAULT_TRACKED_TOKEN_SYMBOLS, token.symbol); if (this.state.tokenManagementState === TokenManagementState.Remove && !isDefaultTrackedToken) { if (token.isRegistered) { // Remove the token from tracked tokens const newToken: Token = { ...token, trackedTimestamp: undefined, }; this.props.dispatcher.updateTokenByAddress([newToken]); } else { this.props.dispatcher.removeTokenToTokenByAddress(token); } trackedTokenStorage.removeTrackedToken(this.props.userAddress, this.props.networkId, tokenAddress); } else if (isDefaultTrackedToken) { this.props.dispatcher.showFlashMessage(`Cannot remove ${token.name} because it's a default token`); } this.setState({ tokenManagementState: TokenManagementState.None, }); } private _onToggleLedgerDialog(): void { this.setState({ isLedgerDialogOpen: !this.state.isLedgerDialogOpen, }); } private _onAddToken(): void { this.setState({ tokenManagementState: TokenManagementState.Add, }); } private _onRemoveToken(): void { this.setState({ tokenManagementState: TokenManagementState.Remove, }); } private _onPortalDisclaimerAccepted(): void { localStorage.setItem(constants.LOCAL_STORAGE_KEY_ACCEPT_DISCLAIMER, 'set'); this.setState({ isDisclaimerDialogOpen: false, }); } private _updateScreenWidth(): void { const newScreenWidth = utils.getScreenWidth(); this.props.dispatcher.updateScreenWidth(newScreenWidth); } private _isSmallScreen(): boolean { const isSmallScreen = this.props.screenWidth === ScreenWidths.Sm; return isSmallScreen; } private _getCurrentTrackedTokens(): Token[] { return utils.getTrackedTokens(this.props.tokenByAddress); } private _getCurrentTrackedTokensAddresses(): string[] { return _.map(this._getCurrentTrackedTokens(), token => token.address); } private _getInitialTrackedTokenStateByAddress(trackedTokens: Token[]): TokenStateByAddress { const trackedTokenStateByAddress: TokenStateByAddress = {}; _.each(trackedTokens, token => { trackedTokenStateByAddress[token.address] = { balance: new BigNumber(0), allowance: new BigNumber(0), isLoaded: false, }; }); return trackedTokenStateByAddress; } private async _fetchBalancesAndAllowancesAsync(tokenAddresses: string[]): Promise { const trackedTokenStateByAddress = this.state.trackedTokenStateByAddress; const userAddressIfExists = _.isEmpty(this.props.userAddress) ? undefined : this.props.userAddress; const balancesAndAllowances = await Promise.all( tokenAddresses.map(async tokenAddress => { return this._blockchain.getTokenBalanceAndAllowanceAsync(userAddressIfExists, tokenAddress); }), ); const priceByAddress = await this._getPriceByAddressAsync(tokenAddresses); for (let i = 0; i < tokenAddresses.length; i++) { // Order is preserved in Promise.all const [balance, allowance] = balancesAndAllowances[i]; const tokenAddress = tokenAddresses[i]; trackedTokenStateByAddress[tokenAddress] = { balance, allowance, isLoaded: true, price: priceByAddress[tokenAddress], }; } this.setState({ trackedTokenStateByAddress, }); } private async _getPriceByAddressAsync(tokenAddresses: string[]): Promise> { if (_.isEmpty(tokenAddresses)) { return {}; } // for each input token address, search for the corresponding symbol in this.props.tokenByAddress, if it exists // create a mapping from existing symbols -> address const tokenAddressBySymbol: { [symbol: string]: string } = {}; _.each(tokenAddresses, address => { const tokenIfExists = _.get(this.props.tokenByAddress, address); if (!_.isUndefined(tokenIfExists)) { const symbol = tokenIfExists.symbol; tokenAddressBySymbol[symbol] = address; } }); const tokenSymbols = _.keys(tokenAddressBySymbol); try { const priceBySymbol = await backendClient.getPriceInfoAsync(tokenSymbols); const priceByAddress = _.mapKeys(priceBySymbol, (_value, symbol) => _.get(tokenAddressBySymbol, symbol)); const result = _.mapValues(priceByAddress, price => { const priceBigNumber = new BigNumber(price); return priceBigNumber; }); return result; } catch (err) { return {}; } } private async _refetchTokenStateAsync(tokenAddress: string): Promise { await this._fetchBalancesAndAllowancesAsync([tokenAddress]); } } interface LargeLayoutProps { left: React.ReactNode; right: React.ReactNode; } const LargeLayout = (props: LargeLayoutProps) => { return (
{props.left}
{props.right}
); }; interface SmallLayoutProps { content: React.ReactNode; } const SmallLayout = (props: SmallLayoutProps) => { return (
{props.content}
); }; // tslint:disable:max-file-line-count