import { EtherscanLinkSuffixes, utils as sharedUtils } from '@0x/react-shared'; import { BigNumber, errorUtils } from '@0x/utils'; import * as _ from 'lodash'; import ActionAccountBalanceWallet from 'material-ui/svg-icons/action/account-balance-wallet'; import * as React from 'react'; import firstBy from 'thenby'; import { Blockchain } from 'ts/blockchain'; import { AccountConnection } from 'ts/components/ui/account_connection'; import { Balance } from 'ts/components/ui/balance'; import { Container } from 'ts/components/ui/container'; import { DropDown, DropdownMouseEvent } from 'ts/components/ui/drop_down'; import { IconButton } from 'ts/components/ui/icon_button'; import { Identicon } from 'ts/components/ui/identicon'; import { Island } from 'ts/components/ui/island'; import { PointerDirection } from 'ts/components/ui/pointer'; import { CopyAddressSimpleMenuItem, DifferentWalletSimpleMenuItem, GoToAccountManagementSimpleMenuItem, SimpleMenu, SimpleMenuItem, } from 'ts/components/ui/simple_menu'; import { Text } from 'ts/components/ui/text'; import { TokenIcon } from 'ts/components/ui/token_icon'; import { BodyOverlay } from 'ts/components/wallet/body_overlay'; import { NullTokenRow } from 'ts/components/wallet/null_token_row'; import { PlaceHolder } from 'ts/components/wallet/placeholder'; import { StandardIconRow } from 'ts/components/wallet/standard_icon_row'; import { WrapEtherItem } from 'ts/components/wallet/wrap_ether_item'; import { AllowanceStateToggle } from 'ts/containers/inputs/allowance_state_toggle'; import { Dispatcher } from 'ts/redux/dispatcher'; import { colors } from 'ts/style/colors'; import { AccountState, BlockchainErrs, ProviderType, ScreenWidths, Side, Token, TokenByAddress, TokenState, TokenStateByAddress, } from 'ts/types'; import { analytics } from 'ts/utils/analytics'; import { constants } from 'ts/utils/constants'; import { utils } from 'ts/utils/utils'; export interface WalletProps { userAddress: string; networkId: number; blockchain: Blockchain; blockchainIsLoaded: boolean; blockchainErr: BlockchainErrs; dispatcher: Dispatcher; tokenByAddress: TokenByAddress; trackedTokens: Token[]; userEtherBalanceInWei?: BigNumber; lastForceTokenStateRefetch: number; injectedProviderName: string; providerType: ProviderType; screenWidth: ScreenWidths; location: Location; trackedTokenStateByAddress: TokenStateByAddress; onToggleLedgerDialog: () => void; onAddToken: () => void; onRemoveToken: () => void; refetchTokenStateAsync: (tokenAddress: string) => Promise; style: React.CSSProperties; toggleTooltipDirection?: PointerDirection; } interface WalletState { wrappedEtherDirection?: Side; isHoveringSidebar: boolean; } interface AllowanceStateToggleConfig { token: Token; tokenState: TokenState; } interface AccessoryItemConfig { wrappedEtherDirection?: Side; allowanceStateToggleConfig?: AllowanceStateToggleConfig; } const ETHER_ICON_PATH = '/images/ether.png'; const ICON_DIMENSION = 28; const BODY_ITEM_KEY = 'BODY'; const HEADER_ITEM_KEY = 'HEADER'; const ETHER_ITEM_KEY = 'ETHER'; const WRAP_ROW_ALLOWANCE_TOGGLE_WIDTH = 67; const ALLOWANCE_TOGGLE_WIDTH = 56; const PLACEHOLDER_COLOR = colors.grey300; const LOADING_ROWS_COUNT = 6; export class Wallet extends React.Component { public static defaultProps = { style: {}, }; constructor(props: WalletProps) { super(props); this.state = { wrappedEtherDirection: undefined, isHoveringSidebar: false, }; } public componentDidUpdate(prevProps: WalletProps): void { const currentTrackedTokens = this.props.trackedTokens; const differentTrackedTokens = _.difference(currentTrackedTokens, prevProps.trackedTokens); const firstDifferentTrackedToken = _.head(differentTrackedTokens); // check if there is only one different token, and if that token is a member of the current tracked tokens // this means that the token was added, not removed if ( !_.isUndefined(firstDifferentTrackedToken) && _.size(differentTrackedTokens) === 1 && _.includes(currentTrackedTokens, firstDifferentTrackedToken) ) { document.getElementById(firstDifferentTrackedToken.address).scrollIntoView(); } } public render(): React.ReactNode { return ( {this._isBlockchainReady() ? this._renderLoadedRows() : this._renderLoadingRows()} ); } private _renderLoadingRows(): React.ReactNode { return _.concat(this._renderLoadingHeaderRows(), this._renderLoadingBodyRows()); } private _renderLoadingHeaderRows(): React.ReactElement<{}> { return this._renderPlainHeaderRow('Loading...'); } private _renderLoadingBodyRows(): React.ReactElement<{}> { const bodyStyle = this._getBodyStyle(); const loadingRowsRange = _.range(LOADING_ROWS_COUNT); return (
{_.map(loadingRowsRange, index => { return ; })}
); } private _renderLoadedRows(): React.ReactNode { const isAddressAvailable = !_.isEmpty(this.props.userAddress); return isAddressAvailable ? _.concat(this._renderConnectedHeaderRows(), this._renderBody()) : _.concat(this._renderDisconnectedHeaderRows(), this._renderLoadingBodyRows()); } private _renderDisconnectedHeaderRows(): React.ReactElement<{}> { const isExternallyInjectedProvider = utils.isExternallyInjected( this.props.providerType, this.props.injectedProviderName, ); const text = isExternallyInjectedProvider ? 'Please unlock MetaMask...' : 'Please connect a wallet...'; return this._renderPlainHeaderRow(text); } private _renderPlainHeaderRow(text: string): React.ReactElement<{}> { return ( } main={ {text} // https://github.com/palantir/tslint-react/issues/140 // tslint:disable-next-line:jsx-curly-spacing } minHeight="60px" backgroundColor={colors.white} /> ); } private _renderConnectedHeaderRows(): React.ReactElement<{}> { const isMobile = this.props.screenWidth === ScreenWidths.Sm; const userAddress = this.props.userAddress; const accountState = this._getAccountState(); const main = (
{utils.getAddressBeginAndEnd(userAddress)}
); const onClick = _.noop.bind(_); const accessory = ( } popoverContent={ {!isMobile && } {!isMobile && } } anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} targetOrigin={{ horizontal: 'right', vertical: 'top' }} zDepth={1} activateEvent={DropdownMouseEvent.Click} closeEvent={DropdownMouseEvent.Click} /> ); return ( } main={main} accessory={accessory} minHeight="60px" backgroundColor={colors.white} /> ); } private _renderBody(): React.ReactElement<{}> { const bodyStyle = this._getBodyStyle(); return (
{this._renderEthRows()} {this._renderTokenRows()}
); } private _getBodyStyle(): React.CSSProperties { return { overflow: 'auto', WebkitOverflowScrolling: 'touch', position: 'relative', overflowY: this.state.isHoveringSidebar ? 'scroll' : 'hidden', marginRight: this.state.isHoveringSidebar ? 0 : 4, minHeight: '250px', maxHeight: !utils.isMobileWidth(this.props.screenWidth) ? 'calc(90vh - 300px)' : undefined, }; } private _onSidebarHover(_event: React.FormEvent): void { this.setState({ isHoveringSidebar: true, }); } private _onSidebarHoverOff(): void { this.setState({ isHoveringSidebar: false, }); } private _renderEthRows(): React.ReactNode { const icon = ; const primaryText = this._renderAmount( this.props.userEtherBalanceInWei || new BigNumber(0), constants.DECIMAL_PLACES_ETH, constants.ETHER_SYMBOL, _.isUndefined(this.props.userEtherBalanceInWei), ); const etherToken = this._getEthToken(); const etherTokenState = this.props.trackedTokenStateByAddress[etherToken.address]; const etherPrice = etherTokenState.price; const secondaryText = this._renderValue( this.props.userEtherBalanceInWei || new BigNumber(0), constants.DECIMAL_PLACES_ETH, etherPrice, _.isUndefined(this.props.userEtherBalanceInWei) || !etherTokenState.isLoaded, ); const accessoryItemConfig = { wrappedEtherDirection: Side.Deposit, }; const key = ETHER_ITEM_KEY; return this._renderBalanceRow(key, icon, primaryText, secondaryText, accessoryItemConfig); } private _renderTokenRows(): React.ReactNode { const trackedTokens = this.props.trackedTokens; const trackedTokensStartingWithEtherToken = trackedTokens.sort( firstBy((t: Token) => t.symbol !== constants.ETHER_TOKEN_SYMBOL) .thenBy((t: Token) => t.symbol !== constants.ZRX_TOKEN_SYMBOL) .thenBy('trackedTimestamp'), ); return _.map(trackedTokensStartingWithEtherToken, this._renderTokenRow.bind(this)); } private _renderTokenRow(token: Token): React.ReactNode { const tokenState = this.props.trackedTokenStateByAddress[token.address]; if (_.isUndefined(tokenState)) { return null; } const tokenLink = sharedUtils.getEtherScanLinkIfExists( token.address, this.props.networkId, EtherscanLinkSuffixes.Address, ); const icon = ; const isWeth = token.symbol === constants.ETHER_TOKEN_SYMBOL; const wrappedEtherDirection = isWeth ? Side.Receive : undefined; const primaryText = this._renderAmount(tokenState.balance, token.decimals, token.symbol, !tokenState.isLoaded); const secondaryText = this._renderValue( tokenState.balance, token.decimals, tokenState.price, !tokenState.isLoaded, ); const accessoryItemConfig: AccessoryItemConfig = { wrappedEtherDirection, allowanceStateToggleConfig: { token, tokenState, }, }; const key = token.address; return this._renderBalanceRow(key, icon, primaryText, secondaryText, accessoryItemConfig); } private _renderBalanceRow( key: string, icon: React.ReactNode, primaryText: React.ReactNode, secondaryText: React.ReactNode, accessoryItemConfig: AccessoryItemConfig, className?: string, ): React.ReactNode { const shouldShowWrapEtherItem = !_.isUndefined(this.state.wrappedEtherDirection) && this.state.wrappedEtherDirection === accessoryItemConfig.wrappedEtherDirection && !_.isUndefined(this.props.userEtherBalanceInWei); const etherToken = this._getEthToken(); const wrapEtherItem = shouldShowWrapEtherItem ? ( this.props.refetchTokenStateAsync(etherToken.address)} /> ) : null; return (
{this.state.wrappedEtherDirection === Side.Receive && wrapEtherItem} {primaryText} {secondaryText}
} accessory={this._renderAccessoryItems(accessoryItemConfig)} /> {this.state.wrappedEtherDirection === Side.Deposit && wrapEtherItem} ); } private _renderAccessoryItems(config: AccessoryItemConfig): React.ReactElement<{}> { const shouldShowWrappedEtherAction = !_.isUndefined(config.wrappedEtherDirection); const shouldShowToggle = !_.isUndefined(config.allowanceStateToggleConfig); // if we don't have a toggle, we still want some space to the right of the "wrap" button so that it aligns with // the "unwrap" button in the row below const isWrapEtherRow = shouldShowWrappedEtherAction && config.wrappedEtherDirection === Side.Deposit; const width = isWrapEtherRow ? WRAP_ROW_ALLOWANCE_TOGGLE_WIDTH : ALLOWANCE_TOGGLE_WIDTH; const toggle = ( {shouldShowToggle && this._renderAllowanceToggle(config.allowanceStateToggleConfig)} ); return (
{shouldShowWrappedEtherAction && this._renderWrappedEtherButton(config.wrappedEtherDirection)}
{toggle}
); } private _renderAllowanceToggle(config: AllowanceStateToggleConfig): React.ReactNode { // TODO: Error handling return ( this.props.refetchTokenStateAsync(config.token.address)} /> ); } private _renderAmount( amount: BigNumber, decimals: number, symbol: string, isLoading: boolean = false, ): React.ReactNode { if (isLoading) { return ( 0.00 XXX ); } else { return ; } } private _renderValue( amount: BigNumber, decimals: number, price?: BigNumber, isLoading: boolean = false, ): React.ReactNode { const result = !isLoading ? _.isUndefined(price) ? '--' : utils.getUsdValueFormattedAmount(amount, decimals, price) : '$0.00'; return ( {result} ); } private _renderWrappedEtherButton(wrappedEtherDirection: Side): React.ReactNode { const isWrappedEtherDirectionOpen = this.state.wrappedEtherDirection === wrappedEtherDirection; let buttonLabel; let buttonIconName; if (isWrappedEtherDirectionOpen) { buttonLabel = 'cancel'; buttonIconName = 'zmdi-close'; } else { switch (wrappedEtherDirection) { case Side.Deposit: buttonLabel = 'wrap'; buttonIconName = 'zmdi-long-arrow-down'; break; case Side.Receive: buttonLabel = 'unwrap'; buttonIconName = 'zmdi-long-arrow-up'; break; default: throw errorUtils.spawnSwitchErr('wrappedEtherDirection', wrappedEtherDirection); } } const onClick = isWrappedEtherDirectionOpen ? this._closeWrappedEtherActionRow.bind(this, wrappedEtherDirection) : this._openWrappedEtherActionRow.bind(this, wrappedEtherDirection); return ( ); } private _openWrappedEtherActionRow(wrappedEtherDirection: Side): void { const action = wrappedEtherDirection === Side.Deposit ? 'Wallet - Wrap ETH Opened' : 'Wallet - Unwrap WETH Opened'; analytics.track(action); this.setState({ wrappedEtherDirection, }); } private _closeWrappedEtherActionRow(wrappedEtherDirection: Side): void { const action = wrappedEtherDirection === Side.Deposit ? 'Wallet - Wrap ETH Closed' : 'Wallet - Unwrap WETH Closed'; analytics.track(action); this.setState({ wrappedEtherDirection: undefined, }); } private _getEthToken(): Token { return utils.getEthToken(this.props.tokenByAddress); } private _isBlockchainReady(): boolean { return this.props.blockchainIsLoaded && !_.isUndefined(this.props.blockchain); } private _getAccountState(): AccountState { return utils.getAccountState( this._isBlockchainReady(), this.props.providerType, this.props.injectedProviderName, this.props.userAddress, ); } } // tslint:disable:max-file-line-count