From 3cf2cb89bbec6a2cffd59aafdcb6d45aa55269d1 Mon Sep 17 00:00:00 2001 From: Brandon Millman Date: Tue, 20 Mar 2018 09:34:55 -0700 Subject: Implement initial wallet interface --- packages/website/ts/components/wallet.tsx | 382 ++++++++++++++++++++++++++++++ 1 file changed, 382 insertions(+) create mode 100644 packages/website/ts/components/wallet.tsx (limited to 'packages/website/ts/components/wallet.tsx') diff --git a/packages/website/ts/components/wallet.tsx b/packages/website/ts/components/wallet.tsx new file mode 100644 index 000000000..41c10c57a --- /dev/null +++ b/packages/website/ts/components/wallet.tsx @@ -0,0 +1,382 @@ +import { ZeroEx } from '0x.js'; +import { + constants as sharedConstants, + EtherscanLinkSuffixes, + Styles, + utils as sharedUtils, +} from '@0xproject/react-shared'; +import { BigNumber } from '@0xproject/utils'; +import * as _ from 'lodash'; +import FlatButton from 'material-ui/FlatButton'; +import { List, ListItem } from 'material-ui/List'; +import NavigationArrowDownward from 'material-ui/svg-icons/navigation/arrow-downward'; +import NavigationArrowUpward from 'material-ui/svg-icons/navigation/arrow-upward'; +import * as React from 'react'; +import ReactTooltip = require('react-tooltip'); +import firstBy = require('thenby'); + +import { Blockchain } from 'ts/blockchain'; +import { AllowanceToggle } from 'ts/components/inputs/allowance_toggle'; +import { Identicon } from 'ts/components/ui/identicon'; +import { TokenIcon } from 'ts/components/ui/token_icon'; +import { Dispatcher } from 'ts/redux/dispatcher'; +import { BalanceErrs, BlockchainErrs, Token, TokenByAddress } from 'ts/types'; +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; +} + +interface WalletState { + trackedTokenStateByAddress: TokenStateByAddress; +} + +interface TokenStateByAddress { + [address: string]: TokenState; +} + +interface TokenState { + balance: BigNumber; + allowance: BigNumber; + isLoaded: boolean; +} + +enum WrappedEtherAction { + Wrap, + Unwrap, +} + +interface AllowanceToggleConfig { + token: Token; + tokenState: TokenState; +} + +interface AccessoryItemConfig { + wrappedEtherAction?: WrappedEtherAction; + allowanceToggleConfig?: AllowanceToggleConfig; +} + +const styles: Styles = { + wallet: { + width: 346, + backgroundColor: '#ffffff', + borderBottomRightRadius: 10, + borderBottomLeftRadius: 10, + borderTopRightRadius: 10, + borderTopLeftRadius: 10, + boxShadow: '0px 4px 6px rgba(56, 59, 137, 0.2)', + overflow: 'hidden', + }, + list: { + padding: '0px 0px 0px 0px', + }, + tokenItemInnerDiv: { + paddingLeft: 60, + }, + headerItemInnerDiv: { + paddingLeft: 65, + }, + footerItemInnerDiv: { + paddingLeft: 24, + }, + borderedItem: { + borderBottomColor: '#f5f5f6', + borderBottomStyle: 'solid', + borderWidth: 1, + }, + tokenItem: { + backgroundColor: '#fbfbfc', + paddingTop: 8, + paddingBottom: 8, + }, + headerItem: { + paddingTop: 8, + paddingBottom: 8, + }, + wrappedEtherButtonLabel: { + fontSize: 12, + }, + amountLabel: { + fontWeight: 'bold', + color: 'black', + }, +}; + +const ETHER_ICON_PATH = '/images/ether.png'; +const ETHER_TOKEN_SYMBOL = 'WETH'; +const ZRX_TOKEN_SYMBOL = 'ZRX'; +const ETHER_SYMBOL = 'ETH'; +const ICON_DIMENSION = 24; +const TOKEN_AMOUNT_DISPLAY_PRECISION = 3; + +export class Wallet extends React.Component { + private _isUnmounted: boolean; + constructor(props: WalletProps) { + super(props); + this._isUnmounted = false; + const initialTrackedTokenStateByAddress = this._getInitialTrackedTokenStateByAddress(props.trackedTokens); + this.state = { + trackedTokenStateByAddress: initialTrackedTokenStateByAddress, + }; + } + public componentWillMount() { + const trackedTokenAddresses = _.keys(this.state.trackedTokenStateByAddress); + // tslint:disable-next-line:no-floating-promises + this._fetchBalancesAndAllowancesAsync(trackedTokenAddresses); + } + public componentWillUnmount() { + this._isUnmounted = true; + } + public componentWillReceiveProps(nextProps: WalletProps) { + if ( + nextProps.userAddress !== this.props.userAddress || + nextProps.networkId !== this.props.networkId || + nextProps.lastForceTokenStateRefetch !== this.props.lastForceTokenStateRefetch + ) { + const trackedTokenAddresses = _.keys(this.state.trackedTokenStateByAddress); + // tslint:disable-next-line:no-floating-promises + this._fetchBalancesAndAllowancesAsync(trackedTokenAddresses); + } + if (!_.isEqual(nextProps.trackedTokens, this.props.trackedTokens)) { + const newTokens = _.difference(nextProps.trackedTokens, this.props.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() { + const isReadyToRender = this.props.blockchainIsLoaded && this.props.blockchainErr === BlockchainErrs.NoError; + return
{isReadyToRender ? this._renderRows() :
}
; + } + private _renderRows() { + return ( + + {_.concat( + this._renderHeaderRows(), + this._renderEthRows(), + this._renderTokenRows(), + this._renderFooterRows(), + )} + + ); + } + private _renderHeaderRows() { + const userAddress = this.props.userAddress; + const primaryText = utils.getAddressBeginAndEnd(userAddress); + return ( + } + style={{ ...styles.headerItem, ...styles.borderedItem }} + innerDivStyle={styles.headerItemInnerDiv} + /> + ); + } + private _renderFooterRows() { + const primaryText = '+ other tokens'; + return ( + + ); + } + private _renderEthRows() { + const primaryText = this._renderAmount( + this.props.userEtherBalanceInWei, + constants.DECIMAL_PLACES_ETH, + ETHER_SYMBOL, + ); + const accessoryItemConfig = { + wrappedEtherAction: WrappedEtherAction.Wrap, + }; + return ( + } + rightAvatar={this._renderAccessoryItems(accessoryItemConfig)} + style={{ ...styles.tokenItem, ...styles.borderedItem }} + innerDivStyle={styles.tokenItemInnerDiv} + /> + ); + } + private _renderTokenRows() { + const trackedTokens = this.props.trackedTokens; + const trackedTokensStartingWithEtherToken = trackedTokens.sort( + firstBy((t: Token) => t.symbol !== ETHER_TOKEN_SYMBOL) + .thenBy((t: Token) => t.symbol !== ZRX_TOKEN_SYMBOL) + .thenBy('address'), + ); + return _.map(trackedTokensStartingWithEtherToken, this._renderTokenRow.bind(this)); + } + private _renderTokenRow(token: Token) { + const tokenState = this.state.trackedTokenStateByAddress[token.address]; + const tokenLink = sharedUtils.getEtherScanLinkIfExists( + token.address, + this.props.networkId, + EtherscanLinkSuffixes.Address, + ); + const amount = this._renderAmount(tokenState.balance, token.decimals, token.symbol); + const wrappedEtherAction = token.symbol === ETHER_TOKEN_SYMBOL ? WrappedEtherAction.Unwrap : undefined; + const accessoryItemConfig: AccessoryItemConfig = { + wrappedEtherAction, + allowanceToggleConfig: { + token, + tokenState, + }, + }; + return ( + + ); + } + private _renderAccessoryItems(config: AccessoryItemConfig) { + const shouldShowWrappedEtherAction = !_.isUndefined(config.wrappedEtherAction); + const shouldShowToggle = !_.isUndefined(config.allowanceToggleConfig); + return ( +
+
+
+ {shouldShowWrappedEtherAction && this._renderWrappedEtherButton(config.wrappedEtherAction)} +
+
+ {shouldShowToggle && this._renderAllowanceToggle(config.allowanceToggleConfig)} +
+
+
+ ); + } + private _renderAllowanceToggle(config: AllowanceToggleConfig) { + return ( + + ); + } + private _renderAmount(amount: BigNumber, decimals: number, symbol: string) { + const unitAmount = ZeroEx.toUnitAmount(amount, decimals); + const formattedAmount = unitAmount.toPrecision(TOKEN_AMOUNT_DISPLAY_PRECISION); + const result = `${formattedAmount} ${symbol}`; + return
{result}
; + } + private _renderTokenIcon(token: Token, tokenLink?: string) { + const tooltipId = `tooltip-${token.address}`; + const tokenIcon = ; + if (_.isUndefined(tokenLink)) { + return tokenIcon; + } else { + return ( + + {tokenIcon} + + ); + } + } + private _renderWrappedEtherButton(action: WrappedEtherAction) { + let buttonLabel; + let buttonIcon; + switch (action) { + case WrappedEtherAction.Wrap: + buttonLabel = 'wrap'; + buttonIcon = ; + break; + case WrappedEtherAction.Unwrap: + buttonLabel = 'unwrap'; + buttonIcon = ; + break; + default: + throw utils.spawnSwitchErr('wrappedEtherAction', action); + } + return ( + + ); + } + private _getInitialTrackedTokenStateByAddress(trackedTokens: Token[]) { + 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[]) { + const trackedTokenStateByAddress = this.state.trackedTokenStateByAddress; + const userAddressIfExists = _.isEmpty(this.props.userAddress) ? undefined : this.props.userAddress; + for (const tokenAddress of tokenAddresses) { + const [balance, allowance] = await this.props.blockchain.getTokenBalanceAndAllowanceAsync( + userAddressIfExists, + tokenAddress, + ); + trackedTokenStateByAddress[tokenAddress] = { + balance, + allowance, + isLoaded: true, + }; + } + if (!this._isUnmounted) { + this.setState({ + trackedTokenStateByAddress, + }); + } + } + private async _refetchTokenStateAsync(tokenAddress: string) { + const userAddressIfExists = _.isEmpty(this.props.userAddress) ? undefined : this.props.userAddress; + const [balance, allowance] = await this.props.blockchain.getTokenBalanceAndAllowanceAsync( + userAddressIfExists, + tokenAddress, + ); + this.setState({ + trackedTokenStateByAddress: { + ...this.state.trackedTokenStateByAddress, + [tokenAddress]: { + balance, + allowance, + isLoaded: true, + }, + }, + }); + } +} -- cgit From 4e5cd472c2b784ca2314eea76b1a106fd04ef0ad Mon Sep 17 00:00:00 2001 From: Brandon Millman Date: Wed, 21 Mar 2018 12:57:16 -0700 Subject: Refactor TokenState type --- packages/website/ts/components/wallet.tsx | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) (limited to 'packages/website/ts/components/wallet.tsx') diff --git a/packages/website/ts/components/wallet.tsx b/packages/website/ts/components/wallet.tsx index 41c10c57a..4e7d0776e 100644 --- a/packages/website/ts/components/wallet.tsx +++ b/packages/website/ts/components/wallet.tsx @@ -20,7 +20,7 @@ import { AllowanceToggle } from 'ts/components/inputs/allowance_toggle'; import { Identicon } from 'ts/components/ui/identicon'; import { TokenIcon } from 'ts/components/ui/token_icon'; import { Dispatcher } from 'ts/redux/dispatcher'; -import { BalanceErrs, BlockchainErrs, Token, TokenByAddress } from 'ts/types'; +import { BalanceErrs, BlockchainErrs, Token, TokenByAddress, TokenState, TokenStateByAddress } from 'ts/types'; import { constants } from 'ts/utils/constants'; import { utils } from 'ts/utils/utils'; @@ -41,16 +41,6 @@ interface WalletState { trackedTokenStateByAddress: TokenStateByAddress; } -interface TokenStateByAddress { - [address: string]: TokenState; -} - -interface TokenState { - balance: BigNumber; - allowance: BigNumber; - isLoaded: boolean; -} - enum WrappedEtherAction { Wrap, Unwrap, -- cgit From a60c8f7d8c05d4d92e16ff73833606989064cb48 Mon Sep 17 00:00:00 2001 From: Brandon Millman Date: Wed, 21 Mar 2018 14:13:16 -0700 Subject: Updated padding and colors --- packages/website/ts/components/wallet.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) (limited to 'packages/website/ts/components/wallet.tsx') diff --git a/packages/website/ts/components/wallet.tsx b/packages/website/ts/components/wallet.tsx index 4e7d0776e..a3ba92ee1 100644 --- a/packages/website/ts/components/wallet.tsx +++ b/packages/website/ts/components/wallet.tsx @@ -1,5 +1,6 @@ import { ZeroEx } from '0x.js'; import { + colors, constants as sharedConstants, EtherscanLinkSuffixes, Styles, @@ -59,16 +60,16 @@ interface AccessoryItemConfig { const styles: Styles = { wallet: { width: 346, - backgroundColor: '#ffffff', + backgroundColor: colors.white, borderBottomRightRadius: 10, borderBottomLeftRadius: 10, borderTopRightRadius: 10, borderTopLeftRadius: 10, - boxShadow: '0px 4px 6px rgba(56, 59, 137, 0.2)', + boxShadow: `0px 4px 6px ${colors.walletBoxShadow}`, overflow: 'hidden', }, list: { - padding: '0px 0px 0px 0px', + padding: 0, }, tokenItemInnerDiv: { paddingLeft: 60, @@ -80,12 +81,12 @@ const styles: Styles = { paddingLeft: 24, }, borderedItem: { - borderBottomColor: '#f5f5f6', + borderBottomColor: colors.walletBorder, borderBottomStyle: 'solid', borderWidth: 1, }, tokenItem: { - backgroundColor: '#fbfbfc', + backgroundColor: colors.walletDefaultItemBackground, paddingTop: 8, paddingBottom: 8, }, @@ -98,7 +99,7 @@ const styles: Styles = { }, amountLabel: { fontWeight: 'bold', - color: 'black', + color: colors.black, }, }; -- cgit From 3916383dd007debc647dc9ac67878cff66f6ea94 Mon Sep 17 00:00:00 2001 From: Brandon Millman Date: Wed, 21 Mar 2018 14:21:19 -0700 Subject: Other style changes --- packages/website/ts/components/wallet.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) (limited to 'packages/website/ts/components/wallet.tsx') diff --git a/packages/website/ts/components/wallet.tsx b/packages/website/ts/components/wallet.tsx index a3ba92ee1..738f18330 100644 --- a/packages/website/ts/components/wallet.tsx +++ b/packages/website/ts/components/wallet.tsx @@ -144,13 +144,13 @@ export class Wallet extends React.Component { // 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) { + _.each(newTokenAddresses, (tokenAddress: string) => { trackedTokenStateByAddress[tokenAddress] = { balance: new BigNumber(0), allowance: new BigNumber(0), isLoaded: false, }; - } + }); this.setState({ trackedTokenStateByAddress, }); @@ -161,7 +161,7 @@ export class Wallet extends React.Component { } public render() { const isReadyToRender = this.props.blockchainIsLoaded && this.props.blockchainErr === BlockchainErrs.NoError; - return
{isReadyToRender ? this._renderRows() :
}
; + return
{isReadyToRender && this._renderRows()}
; } private _renderRows() { return ( -- cgit From 9f8e41cbfacf98f381bb388180f1134c01501321 Mon Sep 17 00:00:00 2001 From: Brandon Millman Date: Wed, 21 Mar 2018 15:45:11 -0700 Subject: Change blockchain prop to not optional --- packages/website/ts/components/wallet.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) (limited to 'packages/website/ts/components/wallet.tsx') diff --git a/packages/website/ts/components/wallet.tsx b/packages/website/ts/components/wallet.tsx index 738f18330..8c6ef9cad 100644 --- a/packages/website/ts/components/wallet.tsx +++ b/packages/website/ts/components/wallet.tsx @@ -28,7 +28,7 @@ import { utils } from 'ts/utils/utils'; export interface WalletProps { userAddress?: string; networkId?: number; - blockchain?: Blockchain; + blockchain: Blockchain; blockchainIsLoaded: boolean; blockchainErr: BlockchainErrs; dispatcher: Dispatcher; -- cgit