Merge branch 'master' into jg-signer-decoding
This commit is contained in:
commit
fbd3738096
@ -107,9 +107,8 @@
|
||||
"postcss-simple-vars": "~3.0.0",
|
||||
"progress": "~1.1.8",
|
||||
"raw-loader": "~0.5.1",
|
||||
"react-addons-perf": "~15.3.2",
|
||||
"react-addons-test-utils": "~15.3.2",
|
||||
"react-dom": "~15.3.2",
|
||||
"react-addons-perf": "~15.4.1",
|
||||
"react-addons-test-utils": "~15.4.1",
|
||||
"react-hot-loader": "~3.0.0-beta.6",
|
||||
"rucksack-css": "~0.8.6",
|
||||
"sinon": "~1.17.4",
|
||||
@ -141,25 +140,25 @@
|
||||
"js-sha3": "~0.5.2",
|
||||
"lodash": "~4.11.1",
|
||||
"marked": "~0.3.6",
|
||||
"material-ui": "0.16.1",
|
||||
"material-ui-chip-input": "~0.8.0",
|
||||
"material-ui": "~0.16.4",
|
||||
"material-ui-chip-input": "~0.11.1",
|
||||
"mobx": "~2.6.1",
|
||||
"mobx-react": "~3.5.8",
|
||||
"mobx-react-devtools": "~4.2.9",
|
||||
"moment": "~2.14.1",
|
||||
"phoneformat.js": "~1.0.3",
|
||||
"qs": "~6.3.0",
|
||||
"react": "~15.3.2",
|
||||
"react": "~15.4.1",
|
||||
"react-ace": "~4.0.0",
|
||||
"react-addons-css-transition-group": "~15.3.2",
|
||||
"react-addons-css-transition-group": "~15.4.1",
|
||||
"react-chartjs-2": "~1.5.0",
|
||||
"react-copy-to-clipboard": "~4.2.3",
|
||||
"react-dom": "~15.3.2",
|
||||
"react-dom": "~15.4.1",
|
||||
"react-dropzone": "~3.7.3",
|
||||
"react-redux": "~4.4.5",
|
||||
"react-router": "~2.6.1",
|
||||
"react-router-redux": "~4.0.5",
|
||||
"react-tap-event-plugin": "~1.0.0",
|
||||
"react-tap-event-plugin": "~2.0.1",
|
||||
"react-tooltip": "~2.0.3",
|
||||
"recharts": "~0.15.2",
|
||||
"redux": "~3.5.2",
|
||||
|
@ -27,9 +27,5 @@ export function sliceData (_data) {
|
||||
data = padAddress('');
|
||||
}
|
||||
|
||||
if (data.length % 64) {
|
||||
throw new Error(`Invalid data length (not mod 64) passed to sliceData, ${data}, % 64 == ${data.length % 64}`);
|
||||
}
|
||||
|
||||
return data.match(/.{1,64}/g);
|
||||
}
|
||||
|
@ -240,9 +240,29 @@ export default class Contract {
|
||||
return this.unsubscribe(subscriptionId);
|
||||
};
|
||||
|
||||
event.getAllLogs = (options = {}) => {
|
||||
return this.getAllLogs(event);
|
||||
};
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
getAllLogs (event, _options) {
|
||||
// Options as first parameter
|
||||
if (!_options && event && event.topics) {
|
||||
return this.getAllLogs(null, event);
|
||||
}
|
||||
|
||||
const options = this._getFilterOptions(event, _options);
|
||||
return this._api.eth
|
||||
.getLogs({
|
||||
fromBlock: 0,
|
||||
toBlock: 'latest',
|
||||
...options
|
||||
})
|
||||
.then((logs) => this.parseEventLogs(logs));
|
||||
}
|
||||
|
||||
_findEvent (eventName = null) {
|
||||
const event = eventName
|
||||
? this._events.find((evt) => evt.name === eventName)
|
||||
@ -256,7 +276,7 @@ export default class Contract {
|
||||
return event;
|
||||
}
|
||||
|
||||
_createEthFilter (event = null, _options) {
|
||||
_getFilterOptions (event = null, _options = {}) {
|
||||
const optionTopics = _options.topics || [];
|
||||
const signature = event && event.signature || null;
|
||||
|
||||
@ -271,6 +291,11 @@ export default class Contract {
|
||||
topics
|
||||
});
|
||||
|
||||
return options;
|
||||
}
|
||||
|
||||
_createEthFilter (event = null, _options) {
|
||||
const options = this._getFilterOptions(event, _options);
|
||||
return this._api.eth.newFilter(options);
|
||||
}
|
||||
|
||||
|
@ -146,7 +146,8 @@ export default class Eth {
|
||||
|
||||
getLogs (options) {
|
||||
return this._transport
|
||||
.execute('eth_getLogs', inFilter(options));
|
||||
.execute('eth_getLogs', inFilter(options))
|
||||
.then((logs) => logs.map(outLog));
|
||||
}
|
||||
|
||||
getLogsEx (options) {
|
||||
|
@ -32,6 +32,10 @@ export function hex2Ascii (_hex) {
|
||||
return str;
|
||||
}
|
||||
|
||||
export function bytesToAscii (bytes) {
|
||||
return bytes.map((b) => String.fromCharCode(b % 512)).join('');
|
||||
}
|
||||
|
||||
export function asciiToHex (string) {
|
||||
return '0x' + string.split('').map((s) => s.charCodeAt(0).toString(16)).join('');
|
||||
}
|
||||
|
@ -8,7 +8,7 @@
|
||||
<style>
|
||||
html, body, #container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: white;
|
||||
|
@ -18,6 +18,8 @@ import BigNumber from 'bignumber.js';
|
||||
import React, { Component, PropTypes } from 'react';
|
||||
import { Checkbox, MenuItem } from 'material-ui';
|
||||
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
import Form, { Input, InputAddressSelect, Select } from '../../../ui/Form';
|
||||
|
||||
import imageUnknown from '../../../../assets/images/contracts/unknown-64x64.png';
|
||||
@ -29,11 +31,101 @@ const CHECK_STYLE = {
|
||||
left: '1em'
|
||||
};
|
||||
|
||||
export default class Details extends Component {
|
||||
class TokenSelect extends Component {
|
||||
static contextTypes = {
|
||||
api: PropTypes.object
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
balance: PropTypes.object.isRequired,
|
||||
images: PropTypes.object.isRequired,
|
||||
tag: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
componentWillMount () {
|
||||
this.computeTokens();
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
const prevTokens = this.props.balance.tokens.map((t) => `${t.token.tag}_${t.value.toNumber()}`);
|
||||
const nextTokens = nextProps.balance.tokens.map((t) => `${t.token.tag}_${t.value.toNumber()}`);
|
||||
|
||||
if (!isEqual(prevTokens, nextTokens)) {
|
||||
this.computeTokens(nextProps);
|
||||
}
|
||||
}
|
||||
|
||||
computeTokens (props = this.props) {
|
||||
const { api } = this.context;
|
||||
const { balance, images } = this.props;
|
||||
|
||||
const items = balance.tokens
|
||||
.filter((token, index) => !index || token.value.gt(0))
|
||||
.map((balance, index) => {
|
||||
const token = balance.token;
|
||||
const isEth = index === 0;
|
||||
let imagesrc = token.image;
|
||||
if (!imagesrc) {
|
||||
imagesrc =
|
||||
images[token.address]
|
||||
? `${api.dappsUrl}${images[token.address]}`
|
||||
: imageUnknown;
|
||||
}
|
||||
let value = 0;
|
||||
|
||||
if (isEth) {
|
||||
value = api.util.fromWei(balance.value).toFormat(3);
|
||||
} else {
|
||||
const format = balance.token.format || 1;
|
||||
const decimals = format === 1 ? 0 : Math.min(3, Math.floor(format / 10));
|
||||
value = new BigNumber(balance.value).div(format).toFormat(decimals);
|
||||
}
|
||||
|
||||
const label = (
|
||||
<div className={ styles.token }>
|
||||
<img src={ imagesrc } />
|
||||
<div className={ styles.tokenname }>
|
||||
{ token.name }
|
||||
</div>
|
||||
<div className={ styles.tokenbalance }>
|
||||
{ value }<small> { token.tag }</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={ token.tag }
|
||||
value={ token.tag }
|
||||
label={ label }>
|
||||
{ label }
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
this.setState({ items });
|
||||
}
|
||||
|
||||
render () {
|
||||
const { tag, onChange } = this.props;
|
||||
const { items } = this.state;
|
||||
|
||||
return (
|
||||
<Select
|
||||
className={ styles.tokenSelect }
|
||||
label='type of token transfer'
|
||||
hint='type of token to transfer'
|
||||
value={ tag }
|
||||
onChange={ onChange }
|
||||
>
|
||||
{ items }
|
||||
</Select>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default class Details extends Component {
|
||||
static propTypes = {
|
||||
address: PropTypes.string,
|
||||
balance: PropTypes.object,
|
||||
@ -115,62 +207,15 @@ export default class Details extends Component {
|
||||
}
|
||||
|
||||
renderTokenSelect () {
|
||||
const { api } = this.context;
|
||||
const { balance, images, tag } = this.props;
|
||||
|
||||
const items = balance.tokens
|
||||
.filter((token, index) => !index || token.value.gt(0))
|
||||
.map((balance, index) => {
|
||||
const token = balance.token;
|
||||
const isEth = index === 0;
|
||||
let imagesrc = token.image;
|
||||
if (!imagesrc) {
|
||||
imagesrc =
|
||||
images[token.address]
|
||||
? `${api.dappsUrl}${images[token.address]}`
|
||||
: imageUnknown;
|
||||
}
|
||||
let value = 0;
|
||||
|
||||
if (isEth) {
|
||||
value = api.util.fromWei(balance.value).toFormat(3);
|
||||
} else {
|
||||
const format = balance.token.format || 1;
|
||||
const decimals = format === 1 ? 0 : Math.min(3, Math.floor(format / 10));
|
||||
value = new BigNumber(balance.value).div(format).toFormat(decimals);
|
||||
}
|
||||
|
||||
const label = (
|
||||
<div className={ styles.token }>
|
||||
<img src={ imagesrc } />
|
||||
<div className={ styles.tokenname }>
|
||||
{ token.name }
|
||||
</div>
|
||||
<div className={ styles.tokenbalance }>
|
||||
{ value }<small> { token.tag }</small>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={ token.tag }
|
||||
value={ token.tag }
|
||||
label={ label }>
|
||||
{ label }
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<Select
|
||||
className={ styles.tokenSelect }
|
||||
label='type of token transfer'
|
||||
hint='type of token to transfer'
|
||||
value={ tag }
|
||||
onChange={ this.onChangeToken }>
|
||||
{ items }
|
||||
</Select>
|
||||
<TokenSelect
|
||||
balance={ balance }
|
||||
images={ images }
|
||||
tag={ tag }
|
||||
onChange={ this.onChangeToken }
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -15,6 +15,7 @@
|
||||
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
const ERRORS = {
|
||||
requireSender: 'a valid sender is required for the transaction',
|
||||
requireRecipient: 'a recipient network address is required for the transaction',
|
||||
invalidAddress: 'the supplied address is an invalid network address',
|
||||
invalidAmount: 'the supplied amount should be a valid positive number',
|
||||
|
463
js/src/modals/Transfer/store.js
Normal file
463
js/src/modals/Transfer/store.js
Normal file
@ -0,0 +1,463 @@
|
||||
// Copyright 2015, 2016 Ethcore (UK) Ltd.
|
||||
// This file is part of Parity.
|
||||
|
||||
// Parity is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
|
||||
// Parity is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import { observable, computed, action, transaction } from 'mobx';
|
||||
import BigNumber from 'bignumber.js';
|
||||
|
||||
import ERRORS from './errors';
|
||||
import { ERROR_CODES } from '../../api/transport/error';
|
||||
import { DEFAULT_GAS, DEFAULT_GASPRICE, MAX_GAS_ESTIMATION } from '../../util/constants';
|
||||
|
||||
const TITLES = {
|
||||
transfer: 'transfer details',
|
||||
sending: 'sending',
|
||||
complete: 'complete',
|
||||
extras: 'extra information',
|
||||
rejected: 'rejected'
|
||||
};
|
||||
const STAGES_BASIC = [TITLES.transfer, TITLES.sending, TITLES.complete];
|
||||
const STAGES_EXTRA = [TITLES.transfer, TITLES.extras, TITLES.sending, TITLES.complete];
|
||||
|
||||
export default class TransferStore {
|
||||
@observable stage = 0;
|
||||
@observable data = '';
|
||||
@observable dataError = null;
|
||||
@observable extras = false;
|
||||
@observable gas = DEFAULT_GAS;
|
||||
@observable gasEst = '0';
|
||||
@observable gasError = null;
|
||||
@observable gasLimitError = null;
|
||||
@observable gasPrice = DEFAULT_GASPRICE;
|
||||
@observable gasPriceError = null;
|
||||
@observable recipient = '';
|
||||
@observable recipientError = ERRORS.requireRecipient;
|
||||
@observable sending = false;
|
||||
@observable tag = 'ETH';
|
||||
@observable total = '0.0';
|
||||
@observable totalError = null;
|
||||
@observable value = '0.0';
|
||||
@observable valueAll = false;
|
||||
@observable valueError = null;
|
||||
@observable isEth = true;
|
||||
@observable busyState = null;
|
||||
@observable rejected = false;
|
||||
|
||||
gasPriceHistogram = {};
|
||||
|
||||
account = null;
|
||||
balance = null;
|
||||
gasLimit = null;
|
||||
onClose = null;
|
||||
|
||||
@computed get steps () {
|
||||
const steps = [].concat(this.extras ? STAGES_EXTRA : STAGES_BASIC);
|
||||
|
||||
if (this.rejected) {
|
||||
steps[steps.length - 1] = TITLES.rejected;
|
||||
}
|
||||
|
||||
return steps;
|
||||
}
|
||||
|
||||
@computed get isValid () {
|
||||
const detailsValid = !this.recipientError && !this.valueError && !this.totalError;
|
||||
const extrasValid = !this.gasError && !this.gasPriceError && !this.totalError;
|
||||
const verifyValid = !this.passwordError;
|
||||
|
||||
switch (this.stage) {
|
||||
case 0:
|
||||
return detailsValid;
|
||||
|
||||
case 1:
|
||||
return this.extras ? extrasValid : verifyValid;
|
||||
|
||||
case 2:
|
||||
return verifyValid;
|
||||
}
|
||||
}
|
||||
|
||||
constructor (api, props) {
|
||||
this.api = api;
|
||||
|
||||
const { account, balance, gasLimit, onClose } = props;
|
||||
|
||||
this.account = account;
|
||||
this.balance = balance;
|
||||
this.gasLimit = gasLimit;
|
||||
this.onClose = onClose;
|
||||
}
|
||||
|
||||
@action onNext = () => {
|
||||
this.stage += 1;
|
||||
}
|
||||
|
||||
@action onPrev = () => {
|
||||
this.stage -= 1;
|
||||
}
|
||||
|
||||
@action onClose = () => {
|
||||
this.onClose && this.onClose();
|
||||
this.stage = 0;
|
||||
}
|
||||
|
||||
@action onUpdateDetails = (type, value) => {
|
||||
switch (type) {
|
||||
case 'all':
|
||||
return this._onUpdateAll(value);
|
||||
|
||||
case 'extras':
|
||||
return this._onUpdateExtras(value);
|
||||
|
||||
case 'data':
|
||||
return this._onUpdateData(value);
|
||||
|
||||
case 'gas':
|
||||
return this._onUpdateGas(value);
|
||||
|
||||
case 'gasPrice':
|
||||
return this._onUpdateGasPrice(value);
|
||||
|
||||
case 'recipient':
|
||||
return this._onUpdateRecipient(value);
|
||||
|
||||
case 'tag':
|
||||
return this._onUpdateTag(value);
|
||||
|
||||
case 'value':
|
||||
return this._onUpdateValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
@action getDefaults = () => {
|
||||
Promise
|
||||
.all([
|
||||
this.api.parity.gasPriceHistogram(),
|
||||
this.api.eth.gasPrice()
|
||||
])
|
||||
.then(([gasPriceHistogram, gasPrice]) => {
|
||||
transaction(() => {
|
||||
this.gasPrice = gasPrice.toString();
|
||||
this.gasPriceDefault = gasPrice.toFormat();
|
||||
this.gasPriceHistogram = gasPriceHistogram;
|
||||
|
||||
this.recalculate();
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('getDefaults', error);
|
||||
});
|
||||
}
|
||||
|
||||
@action onSend = () => {
|
||||
this.onNext();
|
||||
this.sending = true;
|
||||
|
||||
const promise = this.isEth ? this._sendEth() : this._sendToken();
|
||||
|
||||
promise
|
||||
.then((requestId) => {
|
||||
this.busyState = 'Waiting for authorization in the Parity Signer';
|
||||
|
||||
return this.api
|
||||
.pollMethod('parity_checkRequest', requestId)
|
||||
.catch((e) => {
|
||||
if (e.code === ERROR_CODES.REQUEST_REJECTED) {
|
||||
this.rejected = true;
|
||||
return false;
|
||||
}
|
||||
|
||||
throw e;
|
||||
});
|
||||
})
|
||||
.then((txhash) => {
|
||||
transaction(() => {
|
||||
this.onNext();
|
||||
|
||||
this.sending = false;
|
||||
this.txhash = txhash;
|
||||
this.busyState = 'Your transaction has been posted to the network';
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
this.sending = false;
|
||||
this.newError(error);
|
||||
});
|
||||
}
|
||||
|
||||
@action _onUpdateAll = (valueAll) => {
|
||||
this.valueAll = valueAll;
|
||||
this.recalculateGas();
|
||||
}
|
||||
|
||||
@action _onUpdateExtras = (extras) => {
|
||||
this.extras = extras;
|
||||
}
|
||||
|
||||
@action _onUpdateData = (data) => {
|
||||
this.data = data;
|
||||
this.recalculateGas();
|
||||
}
|
||||
|
||||
@action _onUpdateGas = (gas) => {
|
||||
const gasError = this._validatePositiveNumber(gas);
|
||||
|
||||
transaction(() => {
|
||||
this.gas = gas;
|
||||
this.gasError = gasError;
|
||||
|
||||
this.recalculate();
|
||||
});
|
||||
}
|
||||
|
||||
@action _onUpdateGasPrice = (gasPrice) => {
|
||||
const gasPriceError = this._validatePositiveNumber(gasPrice);
|
||||
|
||||
transaction(() => {
|
||||
this.gasPrice = gasPrice;
|
||||
this.gasPriceError = gasPriceError;
|
||||
|
||||
this.recalculate();
|
||||
});
|
||||
}
|
||||
|
||||
@action _onUpdateRecipient = (recipient) => {
|
||||
let recipientError = null;
|
||||
|
||||
if (!recipient || !recipient.length) {
|
||||
recipientError = ERRORS.requireRecipient;
|
||||
} else if (!this.api.util.isAddressValid(recipient)) {
|
||||
recipientError = ERRORS.invalidAddress;
|
||||
}
|
||||
|
||||
transaction(() => {
|
||||
this.recipient = recipient;
|
||||
this.recipientError = recipientError;
|
||||
|
||||
this.recalculateGas();
|
||||
});
|
||||
}
|
||||
|
||||
@action _onUpdateTag = (tag) => {
|
||||
transaction(() => {
|
||||
this.tag = tag;
|
||||
this.isEth = tag.toLowerCase().trim() === 'eth';
|
||||
|
||||
this.recalculateGas();
|
||||
});
|
||||
}
|
||||
|
||||
@action _onUpdateValue = (value) => {
|
||||
let valueError = this._validatePositiveNumber(value);
|
||||
|
||||
if (!valueError) {
|
||||
valueError = this._validateDecimals(value);
|
||||
}
|
||||
|
||||
transaction(() => {
|
||||
this.value = value;
|
||||
this.valueError = valueError;
|
||||
|
||||
this.recalculateGas();
|
||||
});
|
||||
}
|
||||
|
||||
@action recalculateGas = () => {
|
||||
if (!this.isValid) {
|
||||
this.gas = 0;
|
||||
return this.recalculate();
|
||||
}
|
||||
|
||||
const promise = this.isEth ? this._estimateGasEth() : this._estimateGasToken();
|
||||
|
||||
promise
|
||||
.then((gasEst) => {
|
||||
let gas = gasEst;
|
||||
let gasLimitError = null;
|
||||
|
||||
if (gas.gt(DEFAULT_GAS)) {
|
||||
gas = gas.mul(1.2);
|
||||
}
|
||||
|
||||
if (gas.gte(MAX_GAS_ESTIMATION)) {
|
||||
gasLimitError = ERRORS.gasException;
|
||||
} else if (gas.gt(this.gasLimit)) {
|
||||
gasLimitError = ERRORS.gasBlockLimit;
|
||||
}
|
||||
|
||||
transaction(() => {
|
||||
this.gas = gas.toFixed(0);
|
||||
this.gasEst = gasEst.toFormat();
|
||||
this.gasLimitError = gasLimitError;
|
||||
|
||||
this.recalculate();
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('etimateGas', error);
|
||||
this.recalculate();
|
||||
});
|
||||
}
|
||||
|
||||
@action recalculate = () => {
|
||||
const { account, balance } = this;
|
||||
|
||||
if (!account || !balance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { gas, gasPrice, tag, valueAll, isEth } = this;
|
||||
|
||||
const gasTotal = new BigNumber(gasPrice || 0).mul(new BigNumber(gas || 0));
|
||||
const balance_ = balance.tokens.find((b) => tag === b.token.tag);
|
||||
const availableEth = new BigNumber(balance.tokens[0].value);
|
||||
const available = new BigNumber(balance_.value);
|
||||
const format = new BigNumber(balance_.token.format || 1);
|
||||
|
||||
let { value, valueError } = this;
|
||||
let totalEth = gasTotal;
|
||||
let totalError = null;
|
||||
|
||||
if (valueAll) {
|
||||
if (isEth) {
|
||||
const bn = this.api.util.fromWei(availableEth.minus(gasTotal));
|
||||
value = (bn.lt(0) ? new BigNumber(0.0) : bn).toString();
|
||||
} else {
|
||||
value = available.div(format).toString();
|
||||
}
|
||||
}
|
||||
|
||||
if (isEth) {
|
||||
totalEth = totalEth.plus(this.api.util.toWei(value || 0));
|
||||
}
|
||||
|
||||
if (new BigNumber(value || 0).gt(available.div(format))) {
|
||||
valueError = ERRORS.largeAmount;
|
||||
} else if (valueError === ERRORS.largeAmount) {
|
||||
valueError = null;
|
||||
}
|
||||
|
||||
if (totalEth.gt(availableEth)) {
|
||||
totalError = ERRORS.largeAmount;
|
||||
}
|
||||
|
||||
transaction(() => {
|
||||
this.total = this.api.util.fromWei(totalEth).toString();
|
||||
this.totalError = totalError;
|
||||
this.value = value;
|
||||
this.valueError = valueError;
|
||||
});
|
||||
}
|
||||
|
||||
_sendEth () {
|
||||
const { account, data, gas, gasPrice, recipient, value } = this;
|
||||
|
||||
const options = {
|
||||
from: account.address,
|
||||
to: recipient,
|
||||
gas,
|
||||
gasPrice,
|
||||
value: this.api.util.toWei(value || 0)
|
||||
};
|
||||
|
||||
if (data && data.length) {
|
||||
options.data = data;
|
||||
}
|
||||
|
||||
return this.api.parity.postTransaction(options);
|
||||
}
|
||||
|
||||
_sendToken () {
|
||||
const { account, balance } = this;
|
||||
const { gas, gasPrice, recipient, value, tag } = this;
|
||||
|
||||
const token = balance.tokens.find((balance) => balance.token.tag === tag).token;
|
||||
|
||||
return token.contract.instance.transfer
|
||||
.postTransaction({
|
||||
from: account.address,
|
||||
to: token.address,
|
||||
gas,
|
||||
gasPrice
|
||||
}, [
|
||||
recipient,
|
||||
new BigNumber(value).mul(token.format).toFixed(0)
|
||||
]);
|
||||
}
|
||||
|
||||
_estimateGasToken () {
|
||||
const { account, balance } = this;
|
||||
const { recipient, value, tag } = this;
|
||||
|
||||
const token = balance.tokens.find((balance) => balance.token.tag === tag).token;
|
||||
|
||||
return token.contract.instance.transfer
|
||||
.estimateGas({
|
||||
gas: MAX_GAS_ESTIMATION,
|
||||
from: account.address,
|
||||
to: token.address
|
||||
}, [
|
||||
recipient,
|
||||
new BigNumber(value || 0).mul(token.format).toFixed(0)
|
||||
]);
|
||||
}
|
||||
|
||||
_estimateGasEth () {
|
||||
const { account, data, recipient, value } = this;
|
||||
|
||||
const options = {
|
||||
gas: MAX_GAS_ESTIMATION,
|
||||
from: account.address,
|
||||
to: recipient,
|
||||
value: this.api.util.toWei(value || 0)
|
||||
};
|
||||
|
||||
if (data && data.length) {
|
||||
options.data = data;
|
||||
}
|
||||
|
||||
return this.api.eth.estimateGas(options);
|
||||
}
|
||||
|
||||
_validatePositiveNumber (num) {
|
||||
try {
|
||||
const v = new BigNumber(num);
|
||||
if (v.lt(0)) {
|
||||
return ERRORS.invalidAmount;
|
||||
}
|
||||
} catch (e) {
|
||||
return ERRORS.invalidAmount;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
_validateDecimals (num) {
|
||||
const { balance } = this;
|
||||
|
||||
if (this.tag === 'ETH') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = balance.tokens.find((balance) => balance.token.tag === this.tag).token;
|
||||
const s = new BigNumber(num).mul(token.format || 1).toFixed();
|
||||
|
||||
if (s.indexOf('.') !== -1) {
|
||||
return ERRORS.invalidDecimals;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
@ -14,88 +14,50 @@
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import BigNumber from 'bignumber.js';
|
||||
import React, { Component, PropTypes } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { observer } from 'mobx-react';
|
||||
|
||||
import ActionDoneAll from 'material-ui/svg-icons/action/done-all';
|
||||
import ContentClear from 'material-ui/svg-icons/content/clear';
|
||||
import NavigationArrowBack from 'material-ui/svg-icons/navigation/arrow-back';
|
||||
import NavigationArrowForward from 'material-ui/svg-icons/navigation/arrow-forward';
|
||||
|
||||
import { newError } from '../../ui/Errors/actions';
|
||||
import { BusyStep, CompletedStep, Button, IdentityIcon, Modal, TxHash } from '../../ui';
|
||||
import { DEFAULT_GAS, DEFAULT_GASPRICE, MAX_GAS_ESTIMATION } from '../../util/constants';
|
||||
|
||||
import Details from './Details';
|
||||
import Extras from './Extras';
|
||||
import ERRORS from './errors';
|
||||
|
||||
import TransferStore from './store';
|
||||
import styles from './transfer.css';
|
||||
|
||||
import { ERROR_CODES } from '../../api/transport/error';
|
||||
|
||||
const TITLES = {
|
||||
transfer: 'transfer details',
|
||||
sending: 'sending',
|
||||
complete: 'complete',
|
||||
extras: 'extra information',
|
||||
rejected: 'rejected'
|
||||
};
|
||||
const STAGES_BASIC = [TITLES.transfer, TITLES.sending, TITLES.complete];
|
||||
const STAGES_EXTRA = [TITLES.transfer, TITLES.extras, TITLES.sending, TITLES.complete];
|
||||
|
||||
@observer
|
||||
class Transfer extends Component {
|
||||
static contextTypes = {
|
||||
api: PropTypes.object.isRequired,
|
||||
store: PropTypes.object.isRequired
|
||||
api: PropTypes.object.isRequired
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
newError: PropTypes.func.isRequired,
|
||||
gasLimit: PropTypes.object.isRequired,
|
||||
images: PropTypes.object.isRequired,
|
||||
|
||||
account: PropTypes.object,
|
||||
balance: PropTypes.object,
|
||||
balances: PropTypes.object,
|
||||
gasLimit: PropTypes.object.isRequired,
|
||||
images: PropTypes.object.isRequired,
|
||||
onClose: PropTypes.func
|
||||
}
|
||||
|
||||
state = {
|
||||
stage: 0,
|
||||
data: '',
|
||||
dataError: null,
|
||||
extras: false,
|
||||
gas: DEFAULT_GAS,
|
||||
gasEst: '0',
|
||||
gasError: null,
|
||||
gasLimitError: null,
|
||||
gasPrice: DEFAULT_GASPRICE,
|
||||
gasPriceHistogram: {},
|
||||
gasPriceError: null,
|
||||
recipient: '',
|
||||
recipientError: ERRORS.requireRecipient,
|
||||
sending: false,
|
||||
tag: 'ETH',
|
||||
total: '0.0',
|
||||
totalError: null,
|
||||
value: '0.0',
|
||||
valueAll: false,
|
||||
valueError: null,
|
||||
isEth: true,
|
||||
busyState: null,
|
||||
rejected: false
|
||||
}
|
||||
store = new TransferStore(this.context.api, this.props);
|
||||
|
||||
componentDidMount () {
|
||||
this.getDefaults();
|
||||
this.store.getDefaults();
|
||||
}
|
||||
|
||||
render () {
|
||||
const { stage, extras, rejected } = this.state;
|
||||
|
||||
const steps = [].concat(extras ? STAGES_EXTRA : STAGES_BASIC);
|
||||
|
||||
if (rejected) {
|
||||
steps[steps.length - 1] = TITLES.rejected;
|
||||
}
|
||||
const { stage, extras, steps } = this.store;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -134,7 +96,7 @@ class Transfer extends Component {
|
||||
}
|
||||
|
||||
renderPage () {
|
||||
const { extras, stage } = this.state;
|
||||
const { extras, stage } = this.store;
|
||||
|
||||
if (stage === 0) {
|
||||
return this.renderDetailsPage();
|
||||
@ -146,7 +108,7 @@ class Transfer extends Component {
|
||||
}
|
||||
|
||||
renderCompletePage () {
|
||||
const { sending, txhash, busyState, rejected } = this.state;
|
||||
const { sending, txhash, busyState, rejected } = this.store;
|
||||
|
||||
if (rejected) {
|
||||
return (
|
||||
@ -174,83 +136,88 @@ class Transfer extends Component {
|
||||
|
||||
renderDetailsPage () {
|
||||
const { account, balance, images } = this.props;
|
||||
const { valueAll, extras, recipient, recipientError, tag } = this.store;
|
||||
const { total, totalError, value, valueError } = this.store;
|
||||
|
||||
return (
|
||||
<Details
|
||||
address={ account.address }
|
||||
all={ this.state.valueAll }
|
||||
all={ valueAll }
|
||||
balance={ balance }
|
||||
extras={ this.state.extras }
|
||||
extras={ extras }
|
||||
images={ images }
|
||||
recipient={ this.state.recipient }
|
||||
recipientError={ this.state.recipientError }
|
||||
tag={ this.state.tag }
|
||||
total={ this.state.total }
|
||||
totalError={ this.state.totalError }
|
||||
value={ this.state.value }
|
||||
valueError={ this.state.valueError }
|
||||
onChange={ this.onUpdateDetails } />
|
||||
recipient={ recipient }
|
||||
recipientError={ recipientError }
|
||||
tag={ tag }
|
||||
total={ total }
|
||||
totalError={ totalError }
|
||||
value={ value }
|
||||
valueError={ valueError }
|
||||
onChange={ this.store.onUpdateDetails } />
|
||||
);
|
||||
}
|
||||
|
||||
renderExtrasPage () {
|
||||
if (!this.state.gasPriceHistogram) {
|
||||
if (!this.store.gasPriceHistogram) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { isEth, data, dataError, gas, gasEst, gasError, gasPrice } = this.store;
|
||||
const { gasPriceDefault, gasPriceError, gasPriceHistogram, total, totalError } = this.store;
|
||||
|
||||
return (
|
||||
<Extras
|
||||
isEth={ this.state.isEth }
|
||||
data={ this.state.data }
|
||||
dataError={ this.state.dataError }
|
||||
gas={ this.state.gas }
|
||||
gasEst={ this.state.gasEst }
|
||||
gasError={ this.state.gasError }
|
||||
gasPrice={ this.state.gasPrice }
|
||||
gasPriceDefault={ this.state.gasPriceDefault }
|
||||
gasPriceError={ this.state.gasPriceError }
|
||||
gasPriceHistogram={ this.state.gasPriceHistogram }
|
||||
total={ this.state.total }
|
||||
totalError={ this.state.totalError }
|
||||
onChange={ this.onUpdateDetails } />
|
||||
isEth={ isEth }
|
||||
data={ data }
|
||||
dataError={ dataError }
|
||||
gas={ gas }
|
||||
gasEst={ gasEst }
|
||||
gasError={ gasError }
|
||||
gasPrice={ gasPrice }
|
||||
gasPriceDefault={ gasPriceDefault }
|
||||
gasPriceError={ gasPriceError }
|
||||
gasPriceHistogram={ gasPriceHistogram }
|
||||
total={ total }
|
||||
totalError={ totalError }
|
||||
onChange={ this.store.onUpdateDetails } />
|
||||
);
|
||||
}
|
||||
|
||||
renderDialogActions () {
|
||||
const { account } = this.props;
|
||||
const { extras, sending, stage } = this.state;
|
||||
const { extras, sending, stage } = this.store;
|
||||
|
||||
const cancelBtn = (
|
||||
<Button
|
||||
icon={ <ContentClear /> }
|
||||
label='Cancel'
|
||||
onClick={ this.onClose } />
|
||||
onClick={ this.store.onClose } />
|
||||
);
|
||||
const nextBtn = (
|
||||
<Button
|
||||
disabled={ !this.isValid() }
|
||||
disabled={ !this.store.isValid }
|
||||
icon={ <NavigationArrowForward /> }
|
||||
label='Next'
|
||||
onClick={ this.onNext } />
|
||||
onClick={ this.store.onNext } />
|
||||
);
|
||||
const prevBtn = (
|
||||
<Button
|
||||
icon={ <NavigationArrowBack /> }
|
||||
label='Back'
|
||||
onClick={ this.onPrev } />
|
||||
onClick={ this.store.onPrev } />
|
||||
);
|
||||
const sendBtn = (
|
||||
<Button
|
||||
disabled={ !this.isValid() || sending }
|
||||
disabled={ !this.store.isValid || sending }
|
||||
icon={ <IdentityIcon address={ account.address } button /> }
|
||||
label='Send'
|
||||
onClick={ this.onSend } />
|
||||
onClick={ this.store.onSend } />
|
||||
);
|
||||
const doneBtn = (
|
||||
<Button
|
||||
icon={ <ActionDoneAll /> }
|
||||
label='Close'
|
||||
onClick={ this.onClose } />
|
||||
onClick={ this.store.onClose } />
|
||||
);
|
||||
|
||||
switch (stage) {
|
||||
@ -268,7 +235,7 @@ class Transfer extends Component {
|
||||
}
|
||||
|
||||
renderWarning () {
|
||||
const { gasLimitError } = this.state;
|
||||
const { gasLimitError } = this.store;
|
||||
|
||||
if (!gasLimitError) {
|
||||
return null;
|
||||
@ -280,413 +247,17 @@ class Transfer extends Component {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
isValid () {
|
||||
const detailsValid = !this.state.recipientError && !this.state.valueError && !this.state.totalError;
|
||||
const extrasValid = !this.state.gasError && !this.state.gasPriceError && !this.state.totalError;
|
||||
const verifyValid = !this.state.passwordError;
|
||||
|
||||
switch (this.state.stage) {
|
||||
case 0:
|
||||
return detailsValid;
|
||||
|
||||
case 1:
|
||||
return this.state.extras ? extrasValid : verifyValid;
|
||||
|
||||
case 2:
|
||||
return verifyValid;
|
||||
}
|
||||
}
|
||||
|
||||
onNext = () => {
|
||||
this.setState({
|
||||
stage: this.state.stage + 1
|
||||
});
|
||||
}
|
||||
|
||||
onPrev = () => {
|
||||
this.setState({
|
||||
stage: this.state.stage - 1
|
||||
});
|
||||
}
|
||||
|
||||
_onUpdateAll (valueAll) {
|
||||
this.setState({
|
||||
valueAll
|
||||
}, this.recalculateGas);
|
||||
}
|
||||
|
||||
_onUpdateExtras (extras) {
|
||||
this.setState({
|
||||
extras
|
||||
});
|
||||
}
|
||||
|
||||
_onUpdateData (data) {
|
||||
this.setState({
|
||||
data
|
||||
}, this.recalculateGas);
|
||||
}
|
||||
|
||||
validatePositiveNumber (num) {
|
||||
try {
|
||||
const v = new BigNumber(num);
|
||||
if (v.lt(0)) {
|
||||
return ERRORS.invalidAmount;
|
||||
}
|
||||
} catch (e) {
|
||||
return ERRORS.invalidAmount;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
validateDecimals (num) {
|
||||
const { balance } = this.props;
|
||||
const { tag } = this.state;
|
||||
|
||||
if (tag === 'ETH') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const token = balance.tokens.find((balance) => balance.token.tag === tag).token;
|
||||
const s = new BigNumber(num).mul(token.format || 1).toFixed();
|
||||
|
||||
if (s.indexOf('.') !== -1) {
|
||||
return ERRORS.invalidDecimals;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
_onUpdateGas (gas) {
|
||||
const gasError = this.validatePositiveNumber(gas);
|
||||
|
||||
this.setState({
|
||||
gas,
|
||||
gasError
|
||||
}, this.recalculate);
|
||||
}
|
||||
|
||||
_onUpdateGasPrice (gasPrice) {
|
||||
const gasPriceError = this.validatePositiveNumber(gasPrice);
|
||||
|
||||
this.setState({
|
||||
gasPrice,
|
||||
gasPriceError
|
||||
}, this.recalculate);
|
||||
}
|
||||
|
||||
_onUpdateRecipient (recipient) {
|
||||
const { api } = this.context;
|
||||
let recipientError = null;
|
||||
|
||||
if (!recipient || !recipient.length) {
|
||||
recipientError = ERRORS.requireRecipient;
|
||||
} else if (!api.util.isAddressValid(recipient)) {
|
||||
recipientError = ERRORS.invalidAddress;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
recipient,
|
||||
recipientError
|
||||
}, this.recalculateGas);
|
||||
}
|
||||
|
||||
_onUpdateTag (tag) {
|
||||
const { balance } = this.props;
|
||||
|
||||
this.setState({
|
||||
tag,
|
||||
isEth: tag === balance.tokens[0].token.tag
|
||||
}, this.recalculateGas);
|
||||
}
|
||||
|
||||
_onUpdateValue (value) {
|
||||
let valueError = this.validatePositiveNumber(value);
|
||||
|
||||
if (!valueError) {
|
||||
valueError = this.validateDecimals(value);
|
||||
}
|
||||
|
||||
this.setState({
|
||||
value,
|
||||
valueError
|
||||
}, this.recalculateGas);
|
||||
}
|
||||
|
||||
onUpdateDetails = (type, value) => {
|
||||
switch (type) {
|
||||
case 'all':
|
||||
return this._onUpdateAll(value);
|
||||
|
||||
case 'extras':
|
||||
return this._onUpdateExtras(value);
|
||||
|
||||
case 'data':
|
||||
return this._onUpdateData(value);
|
||||
|
||||
case 'gas':
|
||||
return this._onUpdateGas(value);
|
||||
|
||||
case 'gasPrice':
|
||||
return this._onUpdateGasPrice(value);
|
||||
|
||||
case 'recipient':
|
||||
return this._onUpdateRecipient(value);
|
||||
|
||||
case 'tag':
|
||||
return this._onUpdateTag(value);
|
||||
|
||||
case 'value':
|
||||
return this._onUpdateValue(value);
|
||||
}
|
||||
}
|
||||
|
||||
_sendEth () {
|
||||
const { api } = this.context;
|
||||
const { account } = this.props;
|
||||
const { data, gas, gasPrice, recipient, value } = this.state;
|
||||
|
||||
const options = {
|
||||
from: account.address,
|
||||
to: recipient,
|
||||
gas,
|
||||
gasPrice,
|
||||
value: api.util.toWei(value || 0)
|
||||
};
|
||||
|
||||
if (data && data.length) {
|
||||
options.data = data;
|
||||
}
|
||||
|
||||
return api.parity.postTransaction(options);
|
||||
}
|
||||
|
||||
_sendToken () {
|
||||
const { account, balance } = this.props;
|
||||
const { gas, gasPrice, recipient, value, tag } = this.state;
|
||||
const token = balance.tokens.find((balance) => balance.token.tag === tag).token;
|
||||
|
||||
return token.contract.instance.transfer
|
||||
.postTransaction({
|
||||
from: account.address,
|
||||
to: token.address,
|
||||
gas,
|
||||
gasPrice
|
||||
}, [
|
||||
recipient,
|
||||
new BigNumber(value).mul(token.format).toFixed(0)
|
||||
]);
|
||||
}
|
||||
|
||||
onSend = () => {
|
||||
const { api } = this.context;
|
||||
|
||||
this.onNext();
|
||||
|
||||
this.setState({ sending: true }, () => {
|
||||
(this.state.isEth
|
||||
? this._sendEth()
|
||||
: this._sendToken()
|
||||
).then((requestId) => {
|
||||
this.setState({ busyState: 'Waiting for authorization in the Parity Signer' });
|
||||
|
||||
return api
|
||||
.pollMethod('parity_checkRequest', requestId)
|
||||
.catch((e) => {
|
||||
if (e.code === ERROR_CODES.REQUEST_REJECTED) {
|
||||
this.setState({ rejected: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
throw e;
|
||||
});
|
||||
})
|
||||
.then((txhash) => {
|
||||
this.onNext();
|
||||
this.setState({
|
||||
sending: false,
|
||||
txhash,
|
||||
busyState: 'Your transaction has been posted to the network'
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log('send', error);
|
||||
|
||||
this.setState({
|
||||
sending: false
|
||||
});
|
||||
|
||||
this.newError(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onClose = () => {
|
||||
this.setState({ stage: 0 }, () => {
|
||||
this.props.onClose && this.props.onClose();
|
||||
});
|
||||
}
|
||||
|
||||
_estimateGasToken () {
|
||||
const { account, balance } = this.props;
|
||||
const { recipient, value, tag } = this.state;
|
||||
const token = balance.tokens.find((balance) => balance.token.tag === tag).token;
|
||||
|
||||
return token.contract.instance.transfer
|
||||
.estimateGas({
|
||||
gas: MAX_GAS_ESTIMATION,
|
||||
from: account.address,
|
||||
to: token.address
|
||||
}, [
|
||||
recipient,
|
||||
new BigNumber(value || 0).mul(token.format).toFixed(0)
|
||||
]);
|
||||
}
|
||||
|
||||
_estimateGasEth () {
|
||||
const { api } = this.context;
|
||||
const { account } = this.props;
|
||||
const { data, recipient, value } = this.state;
|
||||
const options = {
|
||||
gas: MAX_GAS_ESTIMATION,
|
||||
from: account.address,
|
||||
to: recipient,
|
||||
value: api.util.toWei(value || 0)
|
||||
};
|
||||
|
||||
if (data && data.length) {
|
||||
options.data = data;
|
||||
}
|
||||
|
||||
return api.eth.estimateGas(options);
|
||||
}
|
||||
|
||||
recalculateGas = () => {
|
||||
if (!this.isValid()) {
|
||||
this.setState({
|
||||
gas: '0'
|
||||
}, this.recalculate);
|
||||
return;
|
||||
}
|
||||
|
||||
const { gasLimit } = this.props;
|
||||
|
||||
(this.state.isEth
|
||||
? this._estimateGasEth()
|
||||
: this._estimateGasToken()
|
||||
).then((gasEst) => {
|
||||
let gas = gasEst;
|
||||
let gasLimitError = null;
|
||||
|
||||
if (gas.gt(DEFAULT_GAS)) {
|
||||
gas = gas.mul(1.2);
|
||||
}
|
||||
|
||||
if (gas.gte(MAX_GAS_ESTIMATION)) {
|
||||
gasLimitError = ERRORS.gasException;
|
||||
} else if (gas.gt(gasLimit)) {
|
||||
gasLimitError = ERRORS.gasBlockLimit;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
gas: gas.toFixed(0),
|
||||
gasEst: gasEst.toFormat(),
|
||||
gasLimitError
|
||||
}, this.recalculate);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('etimateGas', error);
|
||||
this.recalculate();
|
||||
});
|
||||
}
|
||||
|
||||
recalculate = () => {
|
||||
const { api } = this.context;
|
||||
const { account, balance } = this.props;
|
||||
|
||||
if (!account || !balance) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { gas, gasPrice, tag, valueAll, isEth } = this.state;
|
||||
const gasTotal = new BigNumber(gasPrice || 0).mul(new BigNumber(gas || 0));
|
||||
const balance_ = balance.tokens.find((b) => tag === b.token.tag);
|
||||
const availableEth = new BigNumber(balance.tokens[0].value);
|
||||
const available = new BigNumber(balance_.value);
|
||||
const format = new BigNumber(balance_.token.format || 1);
|
||||
|
||||
let { value, valueError } = this.state;
|
||||
let totalEth = gasTotal;
|
||||
let totalError = null;
|
||||
|
||||
if (valueAll) {
|
||||
if (isEth) {
|
||||
const bn = api.util.fromWei(availableEth.minus(gasTotal));
|
||||
value = (bn.lt(0) ? new BigNumber(0.0) : bn).toString();
|
||||
} else {
|
||||
value = available.div(format).toString();
|
||||
}
|
||||
}
|
||||
|
||||
if (isEth) {
|
||||
totalEth = totalEth.plus(api.util.toWei(value || 0));
|
||||
}
|
||||
|
||||
if (new BigNumber(value || 0).gt(available.div(format))) {
|
||||
valueError = ERRORS.largeAmount;
|
||||
} else if (valueError === ERRORS.largeAmount) {
|
||||
valueError = null;
|
||||
}
|
||||
|
||||
if (totalEth.gt(availableEth)) {
|
||||
totalError = ERRORS.largeAmount;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
total: api.util.fromWei(totalEth).toString(),
|
||||
totalError,
|
||||
value,
|
||||
valueError
|
||||
});
|
||||
}
|
||||
|
||||
getDefaults = () => {
|
||||
const { api } = this.context;
|
||||
|
||||
Promise
|
||||
.all([
|
||||
api.parity.gasPriceHistogram(),
|
||||
api.eth.gasPrice()
|
||||
])
|
||||
.then(([gasPriceHistogram, gasPrice]) => {
|
||||
this.setState({
|
||||
gasPrice: gasPrice.toString(),
|
||||
gasPriceDefault: gasPrice.toFormat(),
|
||||
gasPriceHistogram
|
||||
}, this.recalculate);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('getDefaults', error);
|
||||
});
|
||||
}
|
||||
|
||||
newError = (error) => {
|
||||
const { store } = this.context;
|
||||
|
||||
store.dispatch({ type: 'newError', error });
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps (state) {
|
||||
const { gasLimit } = state.nodeStatus;
|
||||
|
||||
return { gasLimit };
|
||||
}
|
||||
|
||||
function mapDispatchToProps (dispatch) {
|
||||
return bindActionCreators({}, dispatch);
|
||||
return bindActionCreators({
|
||||
newError
|
||||
}, dispatch);
|
||||
}
|
||||
|
||||
export default connect(
|
||||
|
@ -256,7 +256,8 @@ export function queryTokensFilter (tokensFilter) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tokens = balances.tokens.filter((t) => tokenAddresses.includes(t.address));
|
||||
const tokens = Object.values(balances.tokens)
|
||||
.filter((t) => tokenAddresses.includes(t.address));
|
||||
|
||||
fetchTokensBalances(uniq(addresses), tokens)(dispatch, getState);
|
||||
});
|
||||
|
@ -16,7 +16,6 @@
|
||||
|
||||
import React, { Component, PropTypes } from 'react';
|
||||
import { MenuItem } from 'material-ui';
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
import AutoComplete from '../AutoComplete';
|
||||
import IdentityIcon from '../../IdentityIcon';
|
||||
@ -64,13 +63,6 @@ export default class AddressSelect extends Component {
|
||||
}
|
||||
|
||||
componentWillReceiveProps (newProps) {
|
||||
const entries = this.entriesFromProps();
|
||||
const addresses = Object.keys(entries).sort();
|
||||
|
||||
if (!isEqual(addresses, this.state.addresses)) {
|
||||
this.setState({ entries, addresses });
|
||||
}
|
||||
|
||||
if (newProps.value !== this.props.value) {
|
||||
this.setState({ value: newProps.value });
|
||||
}
|
||||
@ -127,31 +119,33 @@ export default class AddressSelect extends Component {
|
||||
}
|
||||
|
||||
renderItem = (entry) => {
|
||||
const { address, name } = entry;
|
||||
|
||||
return {
|
||||
text: entry.name && entry.name.toUpperCase() || entry.address,
|
||||
value: this.renderSelectEntry(entry),
|
||||
address: entry.address
|
||||
text: name && name.toUpperCase() || address,
|
||||
value: this.renderMenuItem(address),
|
||||
address
|
||||
};
|
||||
}
|
||||
|
||||
renderSelectEntry = (entry) => {
|
||||
renderMenuItem (address) {
|
||||
const item = (
|
||||
<div className={ styles.account }>
|
||||
<IdentityIcon
|
||||
className={ styles.image }
|
||||
inline center
|
||||
address={ entry.address } />
|
||||
address={ address } />
|
||||
<IdentityName
|
||||
className={ styles.name }
|
||||
address={ entry.address } />
|
||||
address={ address } />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
className={ styles.menuItem }
|
||||
key={ entry.address }
|
||||
value={ entry.address }
|
||||
key={ address }
|
||||
value={ address }
|
||||
label={ item }>
|
||||
{ item }
|
||||
</MenuItem>
|
||||
|
@ -19,6 +19,8 @@ import keycode from 'keycode';
|
||||
import { MenuItem, AutoComplete as MUIAutoComplete } from 'material-ui';
|
||||
import { PopoverAnimationVertical } from 'material-ui/Popover';
|
||||
|
||||
import { isEqual } from 'lodash';
|
||||
|
||||
export default class AutoComplete extends Component {
|
||||
static propTypes = {
|
||||
onChange: PropTypes.func.isRequired,
|
||||
@ -42,12 +44,28 @@ export default class AutoComplete extends Component {
|
||||
lastChangedValue: undefined,
|
||||
entry: null,
|
||||
open: false,
|
||||
fakeBlur: false
|
||||
fakeBlur: false,
|
||||
dataSource: []
|
||||
}
|
||||
|
||||
componentWillMount () {
|
||||
const dataSource = this.getDataSource();
|
||||
this.setState({ dataSource });
|
||||
}
|
||||
|
||||
componentWillReceiveProps (nextProps) {
|
||||
const prevEntries = Object.keys(this.props.entries || {}).sort();
|
||||
const nextEntries = Object.keys(nextProps.entries || {}).sort();
|
||||
|
||||
if (!isEqual(prevEntries, nextEntries)) {
|
||||
const dataSource = this.getDataSource(nextProps);
|
||||
this.setState({ dataSource });
|
||||
}
|
||||
}
|
||||
|
||||
render () {
|
||||
const { disabled, error, hint, label, value, className, filter, onUpdateInput } = this.props;
|
||||
const { open } = this.state;
|
||||
const { open, dataSource } = this.state;
|
||||
|
||||
return (
|
||||
<MUIAutoComplete
|
||||
@ -68,7 +86,7 @@ export default class AutoComplete extends Component {
|
||||
menuCloseDelay={ 0 }
|
||||
fullWidth
|
||||
floatingLabelFixed
|
||||
dataSource={ this.getDataSource() }
|
||||
dataSource={ dataSource }
|
||||
menuProps={ { maxHeight: 400 } }
|
||||
ref='muiAutocomplete'
|
||||
onKeyDown={ this.onKeyDown }
|
||||
@ -76,8 +94,8 @@ export default class AutoComplete extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
getDataSource () {
|
||||
const { renderItem, entries } = this.props;
|
||||
getDataSource (props = this.props) {
|
||||
const { renderItem, entries } = props;
|
||||
const entriesArray = (entries instanceof Array)
|
||||
? entries
|
||||
: Object.values(entries);
|
||||
|
17
js/src/ui/Loading/index.js
Normal file
17
js/src/ui/Loading/index.js
Normal file
@ -0,0 +1,17 @@
|
||||
// Copyright 2015, 2016 Ethcore (UK) Ltd.
|
||||
// This file is part of Parity.
|
||||
|
||||
// Parity is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
|
||||
// Parity is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
export default from './loading';
|
23
js/src/ui/Loading/loading.css
Normal file
23
js/src/ui/Loading/loading.css
Normal file
@ -0,0 +1,23 @@
|
||||
/* Copyright 2015, 2016 Ethcore (UK) Ltd.
|
||||
/* This file is part of Parity.
|
||||
/*
|
||||
/* Parity is free software: you can redistribute it and/or modify
|
||||
/* it under the terms of the GNU General Public License as published by
|
||||
/* the Free Software Foundation, either version 3 of the License, or
|
||||
/* (at your option) any later version.
|
||||
/*
|
||||
/* Parity is distributed in the hope that it will be useful,
|
||||
/* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
/* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
/* GNU General Public License for more details.
|
||||
/*
|
||||
/* You should have received a copy of the GNU General Public License
|
||||
/* along with Parity. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
36
js/src/ui/Loading/loading.js
Normal file
36
js/src/ui/Loading/loading.js
Normal file
@ -0,0 +1,36 @@
|
||||
// Copyright 2015, 2016 Ethcore (UK) Ltd.
|
||||
// This file is part of Parity.
|
||||
|
||||
// Parity is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
|
||||
// Parity is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU General Public License for more details.
|
||||
|
||||
// You should have received a copy of the GNU General Public License
|
||||
// along with Parity. If not, see <http://www.gnu.org/licenses/>.
|
||||
|
||||
import React, { Component, PropTypes } from 'react';
|
||||
import CircularProgress from 'material-ui/CircularProgress';
|
||||
|
||||
import styles from './loading.css';
|
||||
|
||||
export default class Loading extends Component {
|
||||
static propTypes = {
|
||||
size: PropTypes.number
|
||||
};
|
||||
|
||||
render () {
|
||||
const size = (this.props.size || 2) * 60;
|
||||
|
||||
return (
|
||||
<div className={ styles.loading }>
|
||||
<CircularProgress size={ size } />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
@ -18,6 +18,20 @@
|
||||
.container {
|
||||
}
|
||||
|
||||
.clickable {
|
||||
border: 1px dashed rgba(255, 255, 255, 0.4);
|
||||
padding: 0.1em 0.3em;
|
||||
margin: 0.1em 0.1em;
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&.noSelect {
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
@ -54,7 +54,9 @@ class MethodDecoding extends Component {
|
||||
isContract: false,
|
||||
isDeploy: false,
|
||||
isReceived: false,
|
||||
isLoading: true
|
||||
isLoading: true,
|
||||
expandInput: false,
|
||||
inputType: 'auto'
|
||||
}
|
||||
|
||||
componentWillMount () {
|
||||
@ -94,6 +96,11 @@ class MethodDecoding extends Component {
|
||||
renderGas () {
|
||||
const { historic, transaction } = this.props;
|
||||
const { gas, gasPrice } = transaction;
|
||||
|
||||
if (!gas || !gasPrice) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const gasValue = gas.mul(gasPrice);
|
||||
|
||||
return (
|
||||
@ -132,26 +139,55 @@ class MethodDecoding extends Component {
|
||||
: this.renderValueTransfer();
|
||||
}
|
||||
|
||||
renderInputValue () {
|
||||
getAscii () {
|
||||
const { api } = this.context;
|
||||
const { transaction } = this.props;
|
||||
const ascii = api.util.hex2Ascii(transaction.input || transaction.data);
|
||||
|
||||
return { value: ascii, valid: ASCII_INPUT.test(ascii) };
|
||||
}
|
||||
|
||||
renderInputValue () {
|
||||
const { transaction } = this.props;
|
||||
const { expandInput, inputType } = this.state;
|
||||
const input = transaction.input || transaction.data;
|
||||
|
||||
if (!/^(0x)?([0]*[1-9a-f]+[0]*)+$/.test(input)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const ascii = api.util.hex2Ascii(input);
|
||||
const text = ASCII_INPUT.test(ascii)
|
||||
? ascii
|
||||
const ascii = this.getAscii();
|
||||
const type = inputType === 'auto'
|
||||
? (ascii.valid ? 'ascii' : 'raw')
|
||||
: inputType;
|
||||
|
||||
const text = type === 'ascii'
|
||||
? ascii.value
|
||||
: input;
|
||||
|
||||
const expandable = text.length > 50;
|
||||
const textToShow = expandInput || !expandable
|
||||
? text
|
||||
: text.slice(0, 50) + '...';
|
||||
|
||||
return (
|
||||
<div className={ styles.description }>
|
||||
<div>
|
||||
<span>with the input </span>
|
||||
<code className={ styles.inputData }>{ text }</code>
|
||||
</div>
|
||||
<span>with the </span>
|
||||
<span
|
||||
onClick={ this.toggleInputType }
|
||||
className={ [ styles.clickable, styles.noSelect ].join(' ') }
|
||||
>
|
||||
{ type === 'ascii' ? 'input' : 'data' }
|
||||
</span>
|
||||
<span> </span>
|
||||
<span
|
||||
onClick={ this.toggleInputExpand }
|
||||
className={ expandable ? styles.clickable : '' }
|
||||
>
|
||||
<code className={ styles.inputData }>
|
||||
{ textToShow }
|
||||
</code>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -375,6 +411,31 @@ class MethodDecoding extends Component {
|
||||
);
|
||||
}
|
||||
|
||||
toggleInputExpand = () => {
|
||||
if (window.getSelection && window.getSelection().type === 'Range') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({
|
||||
expandInput: !this.state.expandInput
|
||||
});
|
||||
}
|
||||
|
||||
toggleInputType = () => {
|
||||
const { inputType } = this.state;
|
||||
|
||||
if (inputType !== 'auto') {
|
||||
return this.setState({
|
||||
inputType: this.state.inputType === 'raw' ? 'ascii' : 'raw'
|
||||
});
|
||||
}
|
||||
|
||||
const ascii = this.getAscii();
|
||||
return this.setState({
|
||||
inputType: ascii.valid ? 'raw' : 'ascii'
|
||||
});
|
||||
}
|
||||
|
||||
lookup () {
|
||||
const { transaction } = this.props;
|
||||
|
||||
|
@ -14,8 +14,9 @@
|
||||
/* You should have received a copy of the GNU General Public License
|
||||
/* along with Parity. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.layout {
|
||||
padding: 0.25em;
|
||||
padding: 0.25em 0.25em 1em 0.25em;
|
||||
}
|
||||
|
||||
.layout>div {
|
||||
|
@ -29,53 +29,24 @@ import Store from './store';
|
||||
|
||||
import styles from './txList.css';
|
||||
|
||||
@observer
|
||||
class TxList extends Component {
|
||||
export class TxRow extends Component {
|
||||
static contextTypes = {
|
||||
api: PropTypes.object.isRequired
|
||||
}
|
||||
};
|
||||
|
||||
static propTypes = {
|
||||
tx: PropTypes.object.isRequired,
|
||||
address: PropTypes.string.isRequired,
|
||||
hashes: PropTypes.oneOfType([
|
||||
PropTypes.array,
|
||||
PropTypes.object
|
||||
]).isRequired,
|
||||
isTest: PropTypes.bool.isRequired
|
||||
}
|
||||
isTest: PropTypes.bool.isRequired,
|
||||
|
||||
store = new Store(this.context.api);
|
||||
|
||||
componentWillMount () {
|
||||
this.store.loadTransactions(this.props.hashes);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this.store.unsubscribe();
|
||||
}
|
||||
|
||||
componentWillReceiveProps (newProps) {
|
||||
this.store.loadTransactions(newProps.hashes);
|
||||
}
|
||||
block: PropTypes.object
|
||||
};
|
||||
|
||||
render () {
|
||||
return (
|
||||
<table className={ styles.transactions }>
|
||||
<tbody>
|
||||
{ this.renderRows() }
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
renderRows () {
|
||||
const { address, isTest } = this.props;
|
||||
|
||||
return this.store.sortedHashes.map((txhash) => {
|
||||
const tx = this.store.transactions[txhash];
|
||||
const { tx, address, isTest } = this.props;
|
||||
|
||||
return (
|
||||
<tr key={ tx.hash }>
|
||||
<tr>
|
||||
{ this.renderBlockNumber(tx.blockNumber) }
|
||||
{ this.renderAddress(tx.from) }
|
||||
<td className={ styles.transaction }>
|
||||
@ -99,7 +70,6 @@ class TxList extends Component {
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
renderAddress (address) {
|
||||
@ -148,8 +118,8 @@ class TxList extends Component {
|
||||
}
|
||||
|
||||
renderBlockNumber (_blockNumber) {
|
||||
const { block } = this.props;
|
||||
const blockNumber = _blockNumber.toNumber();
|
||||
const block = this.store.blocks[blockNumber];
|
||||
|
||||
return (
|
||||
<td className={ styles.timestamp }>
|
||||
@ -160,6 +130,66 @@ class TxList extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
@observer
|
||||
class TxList extends Component {
|
||||
static contextTypes = {
|
||||
api: PropTypes.object.isRequired
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
address: PropTypes.string.isRequired,
|
||||
hashes: PropTypes.oneOfType([
|
||||
PropTypes.array,
|
||||
PropTypes.object
|
||||
]).isRequired,
|
||||
isTest: PropTypes.bool.isRequired
|
||||
}
|
||||
|
||||
store = new Store(this.context.api);
|
||||
|
||||
componentWillMount () {
|
||||
this.store.loadTransactions(this.props.hashes);
|
||||
}
|
||||
|
||||
componentWillUnmount () {
|
||||
this.store.unsubscribe();
|
||||
}
|
||||
|
||||
componentWillReceiveProps (newProps) {
|
||||
this.store.loadTransactions(newProps.hashes);
|
||||
}
|
||||
|
||||
render () {
|
||||
return (
|
||||
<table className={ styles.transactions }>
|
||||
<tbody>
|
||||
{ this.renderRows() }
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
renderRows () {
|
||||
const { address, isTest } = this.props;
|
||||
|
||||
return this.store.sortedHashes.map((txhash) => {
|
||||
const tx = this.store.transactions[txhash];
|
||||
const blockNumber = tx.blockNumber.toNumber();
|
||||
const block = this.store.blocks[blockNumber];
|
||||
|
||||
return (
|
||||
<TxRow
|
||||
key={ tx.hash }
|
||||
tx={ tx }
|
||||
block={ block }
|
||||
address={ address }
|
||||
isTest={ isTest }
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function mapStateToProps (state) {
|
||||
const { isTest } = state.nodeStatus;
|
||||
|
||||
|
@ -33,6 +33,7 @@ import Errors from './Errors';
|
||||
import Form, { AddressSelect, FormWrap, TypedInput, Input, InputAddress, InputAddressSelect, InputChip, InputInline, Select, RadioButtons } from './Form';
|
||||
import IdentityIcon from './IdentityIcon';
|
||||
import IdentityName from './IdentityName';
|
||||
import Loading from './Loading';
|
||||
import MethodDecoding from './MethodDecoding';
|
||||
import Modal, { Busy as BusyStep, Completed as CompletedStep } from './Modal';
|
||||
import muiTheme from './Theme';
|
||||
@ -72,6 +73,7 @@ export {
|
||||
InputAddressSelect,
|
||||
InputChip,
|
||||
InputInline,
|
||||
Loading,
|
||||
Select,
|
||||
IdentityIcon,
|
||||
IdentityName,
|
||||
|
@ -16,6 +16,6 @@
|
||||
|
||||
import { PropTypes } from 'react';
|
||||
|
||||
export default function (type) {
|
||||
export default function nullableProptype (type) {
|
||||
return PropTypes.oneOfType([ PropTypes.oneOf([ null ]), type ]);
|
||||
}
|
||||
|
@ -51,8 +51,6 @@ class Account extends Component {
|
||||
balances: PropTypes.object
|
||||
}
|
||||
|
||||
propName = null
|
||||
|
||||
state = {
|
||||
showDeleteDialog: false,
|
||||
showEditDialog: false,
|
||||
|
@ -65,7 +65,7 @@ button.tabactive:hover {
|
||||
|
||||
.logo {
|
||||
margin: 0 0 0 -24px;
|
||||
padding: 22px 24px 0 24px;
|
||||
padding: 20px 24px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@ -84,6 +84,6 @@ button.tabactive:hover {
|
||||
|
||||
.last {
|
||||
margin: 0 -24px 0 0;
|
||||
padding: 22px 12px 0 12px;
|
||||
padding: 36px 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
@ -15,13 +15,8 @@
|
||||
/* along with Parity. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
.outer,
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding-bottom: 1.6em;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
@ -30,8 +30,6 @@ import Status from './Status';
|
||||
import Store from './store';
|
||||
import TabBar from './TabBar';
|
||||
|
||||
import styles from './application.css';
|
||||
|
||||
const inFrame = window.parent !== window && window.parent.frames.length !== 0;
|
||||
|
||||
@observer
|
||||
@ -62,7 +60,7 @@ class Application extends Component {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={ styles.outer }>
|
||||
<div>
|
||||
{ isDapp ? this.renderDapp() : this.renderApp() }
|
||||
<Connection />
|
||||
<ParityBar dapp={ isDapp } />
|
||||
|
@ -32,7 +32,7 @@ const isProd = ENV === 'production';
|
||||
|
||||
module.exports = {
|
||||
cache: !isProd,
|
||||
devtool: isProd ? '#eval' : '#cheap-module-eval-source-map',
|
||||
devtool: isProd ? '#eval' : '#eval-source-map',
|
||||
|
||||
context: path.join(__dirname, '../src'),
|
||||
entry: Object.assign({}, Shared.dappsEntry, {
|
||||
|
@ -60,7 +60,7 @@ app.use(webpackHotMiddleware(compiler, {
|
||||
|
||||
app.use(webpackDevMiddleware(compiler, {
|
||||
noInfo: false,
|
||||
quiet: false,
|
||||
quiet: true,
|
||||
progress: true,
|
||||
publicPath: webpackConfig.output.publicPath,
|
||||
stats: {
|
||||
|
Loading…
Reference in New Issue
Block a user