523 lines
21 KiB
TypeScript
523 lines
21 KiB
TypeScript
import { EtherscanLinkSuffixes, utils as sharedUtils } from '@0xproject/react-shared';
|
|
import { BigNumber, errorUtils } from '@0xproject/utils';
|
|
import * as _ from 'lodash';
|
|
|
|
import ActionAccountBalanceWallet from 'material-ui/svg-icons/action/account-balance-wallet';
|
|
import * as React from 'react';
|
|
import firstBy = require('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 {
|
|
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 { AllowanceToggle } from 'ts/containers/inputs/allowance_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<void>;
|
|
style: React.CSSProperties;
|
|
}
|
|
|
|
interface WalletState {
|
|
wrappedEtherDirection?: Side;
|
|
isHoveringSidebar: boolean;
|
|
}
|
|
|
|
interface AllowanceToggleConfig {
|
|
token: Token;
|
|
tokenState: TokenState;
|
|
}
|
|
|
|
interface AccessoryItemConfig {
|
|
wrappedEtherDirection?: Side;
|
|
allowanceToggleConfig?: AllowanceToggleConfig;
|
|
}
|
|
|
|
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 NO_ALLOWANCE_TOGGLE_SPACE_WIDTH = 56;
|
|
const PLACEHOLDER_COLOR = colors.grey300;
|
|
const LOADING_ROWS_COUNT = 6;
|
|
|
|
export class Wallet extends React.Component<WalletProps, WalletState> {
|
|
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 (
|
|
<Island className="flex flex-column wallet" style={this.props.style}>
|
|
{this._isBlockchainReady() ? this._renderLoadedRows() : this._renderLoadingRows()}
|
|
</Island>
|
|
);
|
|
}
|
|
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 (
|
|
<div key={BODY_ITEM_KEY} className="flex flex-column" style={bodyStyle}>
|
|
{_.map(loadingRowsRange, index => {
|
|
return <NullTokenRow key={index} iconDimension={ICON_DIMENSION} fillColor={PLACEHOLDER_COLOR} />;
|
|
})}
|
|
<Container
|
|
className="flex items-center"
|
|
position="absolute"
|
|
width="100%"
|
|
height="100%"
|
|
maxHeight={bodyStyle.maxHeight}
|
|
>
|
|
<div className="mx-auto">
|
|
<BodyOverlay
|
|
dispatcher={this.props.dispatcher}
|
|
userAddress={this.props.userAddress}
|
|
injectedProviderName={this.props.injectedProviderName}
|
|
providerType={this.props.providerType}
|
|
onToggleLedgerDialog={this.props.onToggleLedgerDialog}
|
|
blockchain={this.props.blockchain}
|
|
blockchainIsLoaded={this.props.blockchainIsLoaded}
|
|
/>
|
|
</div>
|
|
</Container>
|
|
</div>
|
|
);
|
|
}
|
|
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 (
|
|
<StandardIconRow
|
|
key={HEADER_ITEM_KEY}
|
|
icon={<ActionAccountBalanceWallet color={colors.grey} />}
|
|
main={
|
|
<Text fontSize="16px" fontColor={colors.grey}>
|
|
{text}
|
|
</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 = (
|
|
<div className="flex flex-column">
|
|
<Text fontSize="16px" lineHeight="19px" fontWeight={500}>
|
|
{utils.getAddressBeginAndEnd(userAddress)}
|
|
</Text>
|
|
<AccountConnection accountState={accountState} injectedProviderName={this.props.injectedProviderName} />
|
|
</div>
|
|
);
|
|
const onClick = _.noop;
|
|
const accessory = (
|
|
<DropDown
|
|
activeNode={
|
|
// this container gives the menu button more of a hover target for the drop down
|
|
// it prevents accidentally closing the menu by moving off of the button
|
|
<Container paddingLeft="100px" paddingRight="15px">
|
|
<Text
|
|
className="zmdi zmdi-more-horiz"
|
|
Tag="i"
|
|
fontSize="32px"
|
|
fontFamily="Material-Design-Iconic-Font"
|
|
fontColor={colors.darkGrey}
|
|
onClick={onClick}
|
|
hoverColor={colors.mediumBlue}
|
|
/>
|
|
</Container>
|
|
}
|
|
popoverContent={
|
|
<SimpleMenu minWidth="150px">
|
|
<CopyAddressSimpleMenuItem userAddress={this.props.userAddress} />
|
|
{!isMobile && <DifferentWalletSimpleMenuItem onClick={this.props.onToggleLedgerDialog} />}
|
|
<SimpleMenuItem displayText="Add Tokens..." onClick={this.props.onAddToken} />
|
|
<SimpleMenuItem displayText="Remove Tokens..." onClick={this.props.onRemoveToken} />
|
|
{!isMobile && <GoToAccountManagementSimpleMenuItem />}
|
|
</SimpleMenu>
|
|
}
|
|
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
|
targetOrigin={{ horizontal: 'right', vertical: 'top' }}
|
|
zDepth={1}
|
|
activateEvent={DropdownMouseEvent.Click}
|
|
closeEvent={DropdownMouseEvent.Click}
|
|
/>
|
|
);
|
|
return (
|
|
<StandardIconRow
|
|
key={HEADER_ITEM_KEY}
|
|
icon={<Identicon address={userAddress} diameter={ICON_DIMENSION} />}
|
|
main={main}
|
|
accessory={accessory}
|
|
minHeight="60px"
|
|
backgroundColor={colors.white}
|
|
/>
|
|
);
|
|
}
|
|
private _renderBody(): React.ReactElement<{}> {
|
|
const bodyStyle = this._getBodyStyle();
|
|
return (
|
|
<div
|
|
style={bodyStyle}
|
|
key={BODY_ITEM_KEY}
|
|
onMouseEnter={this._onSidebarHover.bind(this)}
|
|
onMouseLeave={this._onSidebarHoverOff.bind(this)}
|
|
>
|
|
{this._renderEthRows()}
|
|
{this._renderTokenRows()}
|
|
</div>
|
|
);
|
|
}
|
|
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<HTMLInputElement>): void {
|
|
this.setState({
|
|
isHoveringSidebar: true,
|
|
});
|
|
}
|
|
private _onSidebarHoverOff(): void {
|
|
this.setState({
|
|
isHoveringSidebar: false,
|
|
});
|
|
}
|
|
private _renderEthRows(): React.ReactNode {
|
|
const icon = <img style={{ width: ICON_DIMENSION, height: ICON_DIMENSION }} src={ETHER_ICON_PATH} />;
|
|
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 = <TokenIcon token={token} diameter={ICON_DIMENSION} link={tokenLink} />;
|
|
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,
|
|
allowanceToggleConfig: {
|
|
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 ? (
|
|
<WrapEtherItem
|
|
userAddress={this.props.userAddress}
|
|
networkId={this.props.networkId}
|
|
blockchain={this.props.blockchain}
|
|
dispatcher={this.props.dispatcher}
|
|
userEtherBalanceInWei={this.props.userEtherBalanceInWei}
|
|
direction={accessoryItemConfig.wrappedEtherDirection}
|
|
etherToken={etherToken}
|
|
lastForceTokenStateRefetch={this.props.lastForceTokenStateRefetch}
|
|
onConversionSuccessful={this._closeWrappedEtherActionRow.bind(this)}
|
|
// tslint:disable:jsx-no-lambda
|
|
refetchEthTokenStateAsync={async () => this.props.refetchTokenStateAsync(etherToken.address)}
|
|
/>
|
|
) : null;
|
|
return (
|
|
<div id={key} key={key} className={`flex flex-column ${className || ''}`}>
|
|
{this.state.wrappedEtherDirection === Side.Receive && wrapEtherItem}
|
|
<StandardIconRow
|
|
icon={icon}
|
|
main={
|
|
<div className="flex flex-column">
|
|
{primaryText}
|
|
<Container marginTop="3px">{secondaryText}</Container>
|
|
</div>
|
|
}
|
|
accessory={this._renderAccessoryItems(accessoryItemConfig)}
|
|
/>
|
|
{this.state.wrappedEtherDirection === Side.Deposit && wrapEtherItem}
|
|
</div>
|
|
);
|
|
}
|
|
private _renderAccessoryItems(config: AccessoryItemConfig): React.ReactElement<{}> {
|
|
const shouldShowWrappedEtherAction = !_.isUndefined(config.wrappedEtherDirection);
|
|
const shouldShowToggle = !_.isUndefined(config.allowanceToggleConfig);
|
|
// 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 toggle = shouldShowToggle ? (
|
|
this._renderAllowanceToggle(config.allowanceToggleConfig)
|
|
) : (
|
|
<div style={{ width: NO_ALLOWANCE_TOGGLE_SPACE_WIDTH }} />
|
|
);
|
|
return (
|
|
<div className="flex items-center">
|
|
<div className="flex-auto">
|
|
{shouldShowWrappedEtherAction && this._renderWrappedEtherButton(config.wrappedEtherDirection)}
|
|
</div>
|
|
<div className="flex-last pl2">{toggle}</div>
|
|
</div>
|
|
);
|
|
}
|
|
private _renderAllowanceToggle(config: AllowanceToggleConfig): React.ReactNode {
|
|
// TODO: Error handling
|
|
return (
|
|
<AllowanceToggle
|
|
blockchain={this.props.blockchain}
|
|
token={config.token}
|
|
tokenState={config.tokenState}
|
|
isDisabled={!config.tokenState.isLoaded}
|
|
refetchTokenStateAsync={async () => this.props.refetchTokenStateAsync(config.token.address)}
|
|
/>
|
|
);
|
|
}
|
|
private _renderAmount(
|
|
amount: BigNumber,
|
|
decimals: number,
|
|
symbol: string,
|
|
isLoading: boolean = false,
|
|
): React.ReactNode {
|
|
if (isLoading) {
|
|
return (
|
|
<PlaceHolder hideChildren={isLoading} fillColor={PLACEHOLDER_COLOR}>
|
|
<Text fontSize="16px" fontWeight="bold" lineHeight="1em">
|
|
0.00 XXX
|
|
</Text>
|
|
</PlaceHolder>
|
|
);
|
|
} else {
|
|
return <Balance amount={amount} decimals={decimals} symbol={symbol} />;
|
|
}
|
|
}
|
|
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 (
|
|
<PlaceHolder hideChildren={isLoading} fillColor={PLACEHOLDER_COLOR}>
|
|
<Text fontSize="14px" fontColor={colors.darkGrey} lineHeight="1em">
|
|
{result}
|
|
</Text>
|
|
</PlaceHolder>
|
|
);
|
|
}
|
|
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 (
|
|
<IconButton iconName={buttonIconName} labelText={buttonLabel} onClick={onClick} color={colors.mediumBlue} />
|
|
);
|
|
}
|
|
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
|