Show Recent Txs on Check Tx Page (#1147)

* Save transactions to local storage.

* Checksum more things + reset hash on network change.

* Fix IHexTransaction type, grab from from tx object directly.

* Refactor storage of recent transactions to use redux storage and loading.

* Refactor types to a transactions types file.

* Initial crack at recent transactions tab on account

* Punctuation.

* Transaction Status responsive behavior.

* Refactor transaction helper function out to remove circular dependency.

* Fix typings

* Collapse subtabs to select list when too small.

* s/wallet/address

* Type select onChange

* Get fields from current state if web3 tx
This commit is contained in:
William O'Beirne 2018-03-14 16:10:14 -04:00 committed by Daniel Ternyak
parent 740b191542
commit db6b737cad
29 changed files with 754 additions and 82 deletions

View File

@ -18,3 +18,18 @@ export function setTransactionData(
payload
};
}
export type TResetTransactionData = typeof resetTransactionData;
export function resetTransactionData(): interfaces.ResetTransactionDataAction {
return { type: TypeKeys.TRANSACTIONS_RESET_TRANSACTION_DATA };
}
export type TAddRecentTransaction = typeof addRecentTransaction;
export function addRecentTransaction(
payload: interfaces.AddRecentTransactionAction['payload']
): interfaces.AddRecentTransactionAction {
return {
type: TypeKeys.TRANSACTIONS_ADD_RECENT_TRANSACTION,
payload
};
}

View File

@ -1,5 +1,5 @@
import { TypeKeys } from './constants';
import { TransactionData, TransactionReceipt } from 'libs/nodes';
import { SavedTransaction, TransactionData, TransactionReceipt } from 'types/transactions';
export interface FetchTransactionDataAction {
type: TypeKeys.TRANSACTIONS_FETCH_TRANSACTION_DATA;
@ -16,5 +16,18 @@ export interface SetTransactionDataAction {
};
}
export interface ResetTransactionDataAction {
type: TypeKeys.TRANSACTIONS_RESET_TRANSACTION_DATA;
}
export interface AddRecentTransactionAction {
type: TypeKeys.TRANSACTIONS_ADD_RECENT_TRANSACTION;
payload: SavedTransaction;
}
/*** Union Type ***/
export type TransactionsAction = FetchTransactionDataAction | SetTransactionDataAction;
export type TransactionsAction =
| FetchTransactionDataAction
| SetTransactionDataAction
| ResetTransactionDataAction
| AddRecentTransactionAction;

View File

@ -1,5 +1,7 @@
export enum TypeKeys {
TRANSACTIONS_FETCH_TRANSACTION_DATA = 'TRANSACTIONS_FETCH_TRANSACTION_DATA',
TRANSACTIONS_SET_TRANSACTION_DATA = 'TRANSACTIONS_SET_TRANSACTION_DATA',
TRANSACTIONS_SET_TRANSACTION_ERROR = 'TRANSACTIONS_SET_TRANSACTION_ERROR'
TRANSACTIONS_SET_TRANSACTION_ERROR = 'TRANSACTIONS_SET_TRANSACTION_ERROR',
TRANSACTIONS_RESET_TRANSACTION_DATA = 'TRANSACTIONS_RESET_TRANSACTION_DATA',
TRANSACTIONS_ADD_RECENT_TRANSACTION = 'TRANSACTIONS_ADD_RECENT_TRANSACTION'
}

View File

@ -4,6 +4,9 @@
margin-top: 15px;
&-tabs {
display: inline-block;
white-space: nowrap;
&-link {
display: inline-block;
padding: 8px;
@ -26,4 +29,8 @@
}
}
}
&-select {
margin-bottom: $space-md;
}
}

View File

@ -1,4 +1,5 @@
import React from 'react';
import Select, { Option } from 'react-select';
import { NavLink, RouteComponentProps } from 'react-router-dom';
import './SubTabs.scss';
@ -9,32 +10,135 @@ export interface Tab {
redirect?: string;
}
interface Props {
interface OwnProps {
tabs: Tab[];
match: RouteComponentProps<{}>['match'];
}
export default class SubTabs extends React.PureComponent<Props> {
type Props = OwnProps & RouteComponentProps<{}>;
interface State {
tabsWidth: number;
isCollapsed: boolean;
}
export default class SubTabs extends React.PureComponent<Props, State> {
public state: State = {
tabsWidth: 0,
isCollapsed: false
};
private containerEl: HTMLDivElement | null;
private tabsEl: HTMLDivElement | null;
public componentDidMount() {
this.measureTabsWidth();
window.addEventListener('resize', this.handleResize);
}
public componentWillUnmount() {
window.removeEventListener('resize', this.handleResize);
}
public componentWillReceiveProps(nextProps: Props) {
// When new tabs come in, we'll need to uncollapse so that they can
// be measured and collapsed again, if needed.
if (this.props.tabs !== nextProps.tabs) {
this.setState({ isCollapsed: false });
}
}
public componentDidUpdate(prevProps: Props) {
// New tabs === new measurements
if (this.props.tabs !== prevProps.tabs) {
this.measureTabsWidth();
}
}
public render() {
const { tabs, match } = this.props;
const currentPath = match.url;
const { isCollapsed } = this.state;
const basePath = match.url;
const currentPath = location.pathname;
let content: React.ReactElement<string>;
return (
<div className="SubTabs row">
<div className="SubTabs-tabs col-sm-12">
if (isCollapsed) {
const options = tabs.map(tab => ({
label: tab.name as string,
value: tab.path,
disabled: tab.disabled
}));
content = (
<div className="SubTabs-select">
<Select
options={options}
value={currentPath.split('/').pop()}
onChange={this.handleSelect}
searchable={false}
clearable={false}
/>
</div>
);
} else {
// All tabs visible navigation
content = (
<div className="SubTabs-tabs" ref={el => (this.tabsEl = el)}>
{tabs.map((t, i) => (
// Same as normal Link, but knows when it's active, and applies activeClassName
<NavLink
className={`SubTabs-tabs-link ${t.disabled ? 'is-disabled' : ''}`}
activeClassName="is-active"
to={currentPath + '/' + t.path}
key={i}
>
{t.name}
</NavLink>
<SubTabLink tab={t} basePath={basePath} className="SubTabs-tabs-link" key={i} />
))}
</div>
);
}
return (
<div className="SubTabs" ref={el => (this.containerEl = el)}>
{content}
</div>
);
}
private handleSelect = ({ value }: Option) => {
this.props.history.push(`${this.props.match.url}/${value}`);
};
// Tabs become a dropdown if they would wrap
private handleResize = () => {
if (!this.containerEl) {
return;
}
this.setState({
isCollapsed: this.state.tabsWidth >= this.containerEl.offsetWidth
});
};
// Store the tab width for future
private measureTabsWidth = () => {
if (this.tabsEl) {
this.setState({ tabsWidth: this.tabsEl.offsetWidth }, () => {
this.handleResize();
});
} else {
// Briefly show, measure, collapse again still not enough room
this.setState({ isCollapsed: false }, this.measureTabsWidth);
}
};
}
interface SubTabLinkProps {
tab: Tab;
basePath: string;
className: string;
onClick?(ev: React.MouseEvent<HTMLAnchorElement>): void;
}
const SubTabLink: React.SFC<SubTabLinkProps> = ({ tab, className, basePath, onClick }) => (
<NavLink
className={`${className} ${tab.disabled ? 'is-disabled' : ''}`}
activeClassName="is-active"
to={basePath + '/' + tab.path}
onClick={onClick}
>
{tab.name}
</NavLink>
);

View File

@ -1,7 +1,7 @@
import React from 'react';
import translate from 'translations';
import { Identicon, UnitDisplay, NewTabLink, TextArea, Address } from 'components/ui';
import { TransactionData, TransactionReceipt } from 'libs/nodes';
import { TransactionData, TransactionReceipt } from 'types/transactions';
import { NetworkConfig } from 'types/network';
import './TransactionDataTable.scss';

View File

@ -9,6 +9,7 @@
&-data {
text-align: left;
overflow-x: auto;
}
&-loading {

View File

@ -8,7 +8,7 @@ import { Spinner } from 'components/ui';
import TransactionDataTable from './TransactionDataTable';
import { AppState } from 'reducers';
import { NetworkConfig } from 'types/network';
import { TransactionState } from 'reducers/transactions';
import { TransactionState } from 'types/transactions';
import './TransactionStatus.scss';
interface OwnProps {

View File

@ -1,4 +1,17 @@
@import 'common/sass/variables';
.TxHashInput {
max-width: 700px;
margin: 0 auto;
&-recent {
text-align: left;
&-separator {
display: block;
margin: $space-sm 0;
text-align: center;
color: $gray-light;
}
}
}

View File

@ -1,19 +1,33 @@
import React from 'react';
import { connect } from 'react-redux';
import Select from 'react-select';
import moment from 'moment';
import translate from 'translations';
import { isValidTxHash, isValidETHAddress } from 'libs/validators';
import './TxHashInput.scss';
import { getRecentNetworkTransactions } from 'selectors/transactions';
import { AppState } from 'reducers';
import { Input } from 'components/ui';
import './TxHashInput.scss';
interface Props {
interface OwnProps {
hash?: string;
onSubmit(hash: string): void;
}
interface ReduxProps {
recentTxs: AppState['transactions']['recent'];
}
type Props = OwnProps & ReduxProps;
interface State {
hash: string;
}
export default class TxHashInput extends React.Component<Props, State> {
interface Option {
label: string;
value: string;
}
class TxHashInput extends React.Component<Props, State> {
public constructor(props: Props) {
super(props);
this.state = { hash: props.hash || '' };
@ -26,11 +40,39 @@ export default class TxHashInput extends React.Component<Props, State> {
}
public render() {
const { recentTxs } = this.props;
const { hash } = this.state;
const validClass = hash ? (isValidTxHash(hash) ? '' : 'invalid') : '';
const validClass = hash ? (isValidTxHash(hash) ? 'is-valid' : 'is-invalid') : '';
let selectOptions: Option[] = [];
if (recentTxs && recentTxs.length) {
selectOptions = recentTxs.map(tx => ({
label: `
${moment(tx.time).format('lll')}
-
${tx.from.substr(0, 8)}...
to
${tx.to.substr(0, 8)}...
`,
value: tx.hash
}));
}
return (
<form className="TxHashInput" onSubmit={this.handleSubmit}>
{!!selectOptions.length && (
<div className="TxHashInput-recent">
<Select
value={hash}
onChange={this.handleSelectTx}
options={selectOptions}
placeholder="Select a recent transaction..."
searchable={false}
/>
<em className="TxHashInput-recent-separator">or</em>
</div>
)}
<Input
value={hash}
placeholder="0x16e521..."
@ -55,6 +97,15 @@ export default class TxHashInput extends React.Component<Props, State> {
this.setState({ hash: ev.currentTarget.value });
};
private handleSelectTx = (option: Option) => {
if (option && option.value) {
this.setState({ hash: option.value });
this.props.onSubmit(option.value);
} else {
this.setState({ hash: '' });
}
};
private handleSubmit = (ev: React.FormEvent<HTMLFormElement>) => {
ev.preventDefault();
if (isValidTxHash(this.state.hash)) {
@ -62,3 +113,7 @@ export default class TxHashInput extends React.Component<Props, State> {
}
};
}
export default connect((state: AppState): ReduxProps => ({
recentTxs: getRecentNetworkTransactions(state)
}))(TxHashInput);

View File

@ -33,6 +33,13 @@ class CheckTransaction extends React.Component<Props, State> {
}
}
public componentWillReceiveProps(nextProps: Props) {
const { network } = this.props;
if (network.chainId !== nextProps.network.chainId) {
this.setState({ hash: '' });
}
}
public render() {
const { network } = this.props;
const { hash } = this.state;
@ -59,7 +66,7 @@ class CheckTransaction extends React.Component<Props, State> {
{hash && (
<section className="CheckTransaction-tx Tab-content-pane">
<TransactionStatusComponent txHash={hash} />
<TransactionStatusComponent key={network.chainId} txHash={hash} />
</section>
)}
</div>

View File

@ -28,14 +28,12 @@ const tabs = [
class Contracts extends Component<Props & RouteComponentProps<{}>> {
public render() {
const { match } = this.props;
const { match, location, history } = this.props;
const currentPath = match.url;
return (
<TabSection isUnavailableOffline={true}>
<div className="SubTabs-tabs">
<SubTabs tabs={tabs} match={match} />
</div>
<SubTabs tabs={tabs} match={match} location={location} history={history} />
<section className="Tab-content Contracts">
<div className="Contracts-content">
<Switch>

View File

@ -0,0 +1,66 @@
@import 'common/sass/variables';
@import 'common/sass/mixins';
$hover-speed: 150ms;
$identicon-size: 36px;
$identicon-size-mobile: 24px;
.RecentTx {
line-height: $identicon-size;
border: 1px solid $gray-lighter;
cursor: pointer;
transition: box-shadow $hover-speed ease;
box-shadow: 0 0 $brand-primary inset;
&-to {
width: 100%;
max-width: 0;
@include mono;
@include ellipsis;
.Identicon {
display: inline-block;
width: $identicon-size !important;
height: $identicon-size !important;
margin-right: $space-md;
}
}
&-time {
opacity: 0.88;
}
&-arrow {
padding-left: $space-md;
font-size: 22px;
opacity: 0.3;
transition-property: opacity, color, transform;
transition-duration: $hover-speed;
transition-timing-function: ease;
}
&:hover {
box-shadow: 3px 0 $brand-primary inset;
.RecentTx-arrow {
opacity: 1;
color: $brand-primary;
transform: translateX(3px);
}
}
// Responsive handling
@media (max-width: $screen-md) {
font-size: $font-size-xs;
line-height: $identicon-size-mobile;
&-to .Identicon {
width: $identicon-size-mobile !important;
height: $identicon-size-mobile !important;
}
&-arrow .fa {
display: none;
}
}
}

View File

@ -0,0 +1,44 @@
import React from 'react';
import moment from 'moment';
import { Wei } from 'libs/units';
import { Identicon, Address, UnitDisplay } from 'components/ui';
import { NetworkConfig } from 'types/network';
import { SavedTransaction } from 'types/transactions';
import './RecentTransaction.scss';
interface Props {
tx: SavedTransaction;
network: NetworkConfig;
onClick(hash: string): void;
}
export default class RecentTransaction extends React.Component<Props> {
public render() {
const { tx, network } = this.props;
return (
<tr className="RecentTx" key={tx.time} onClick={this.handleClick}>
<td className="RecentTx-to">
<Identicon address={tx.to} />
<Address address={tx.to} />
</td>
<td className="RecentTx-value">
<UnitDisplay
value={Wei(tx.value)}
unit="ether"
symbol={network.unit}
checkOffline={false}
/>
</td>
<td className="RecentTx-time">{moment(tx.time).format('l LT')}</td>
<td className="RecentTx-arrow">
<i className="fa fa-chevron-right" />
</td>
</tr>
);
}
private handleClick = () => {
this.props.onClick(this.props.tx.hash);
};
}

View File

@ -0,0 +1,65 @@
@import 'common/sass/variables';
.RecentTxs {
position: relative;
&-txs {
width: 100%;
td {
text-align: center;
padding: $space-md;
white-space: nowrap;
&:first-child {
text-align: left;
}
}
thead {
font-size: $font-size-bump-more;
border-bottom: 2px solid $gray-lighter;
td {
padding-top: $space-xs;
padding-bottom: $space-xs;
}
}
}
&-back {
display: block;
max-width: 300px;
margin: $space auto 0;
.fa {
margin-right: $space-xs;
opacity: 0.6;
transition: $transition;
}
&:hover {
.fa {
opacity: 1;
}
}
}
&-empty {
padding: $space * 3;
text-align: center;
&-text {
margin: 0;
line-height: 1.4;
}
}
&-help {
max-width: 540px;
margin: $space * 2 auto 0;
font-size: $font-size-small;
text-align: center;
color: $gray-light;
}
}

View File

@ -0,0 +1,110 @@
import React from 'react';
import { connect } from 'react-redux';
import translate from 'translations';
import { getRecentWalletTransactions } from 'selectors/transactions';
import { getNetworkConfig } from 'selectors/config';
import { NewTabLink } from 'components/ui';
import RecentTransaction from './RecentTransaction';
import { TransactionStatus } from 'components';
import { IWallet } from 'libs/wallet';
import { NetworkConfig } from 'types/network';
import { AppState } from 'reducers';
import './RecentTransactions.scss';
interface OwnProps {
wallet: IWallet;
}
interface StateProps {
recentTransactions: AppState['transactions']['recent'];
network: NetworkConfig;
}
type Props = OwnProps & StateProps;
interface State {
activeTxHash: string;
}
class RecentTransactions extends React.Component<Props> {
public state: State = {
activeTxHash: ''
};
public render() {
const { activeTxHash } = this.state;
let content: React.ReactElement<string>;
if (activeTxHash) {
content = (
<React.Fragment>
<TransactionStatus txHash={activeTxHash} />
<button className="RecentTxs-back btn btn-default" onClick={this.clearActiveTxHash}>
<i className="fa fa-arrow-left" /> Back to Recent Transactions
</button>
</React.Fragment>
);
} else {
content = this.renderTxList();
}
return <div className="RecentTxs Tab-content-pane">{content}</div>;
}
private renderTxList() {
const { wallet, recentTransactions, network } = this.props;
let explorer: React.ReactElement<string>;
if (network.isCustom) {
explorer = <span>an explorer for the {network.name} network</span>;
} else {
explorer = (
<NewTabLink href={network.blockExplorer.addressUrl(wallet.getAddressString())}>
{network.blockExplorer.name}
</NewTabLink>
);
}
return (
<React.Fragment>
{recentTransactions.length ? (
<table className="RecentTxs-txs">
<thead>
<td>{translate('SEND_addr')}</td>
<td>{translate('SEND_amount_short')}</td>
<td>{translate('Sent')}</td>
<td />
</thead>
<tbody>
{recentTransactions.map(tx => (
<RecentTransaction
key={tx.time}
tx={tx}
network={network}
onClick={this.setActiveTxHash}
/>
))}
</tbody>
</table>
) : (
<div className="RecentTxs-empty well">
<h2 className="RecentTxs-empty-text">
No recent MyCrypto transactions found, try checking on {explorer}.
</h2>
</div>
)}
<p className="RecentTxs-help">
Only recent transactions sent from this address via MyCrypto on the {network.name} network
are listed here. If you don't see your transaction, you can view all of them on {explorer}.
</p>
</React.Fragment>
);
}
private setActiveTxHash = (activeTxHash: string) => this.setState({ activeTxHash });
private clearActiveTxHash = () => this.setState({ activeTxHash: '' });
}
export default connect((state: AppState): StateProps => ({
recentTransactions: getRecentWalletTransactions(state),
network: getNetworkConfig(state)
}))(RecentTransactions);

View File

@ -4,3 +4,4 @@ export * from './UnavailableWallets';
export * from './SideBar';
export { default as WalletInfo } from './WalletInfo';
export { default as RequestPayment } from './RequestPayment';
export { default as RecentTransactions } from './RecentTransactions';

View File

@ -3,7 +3,6 @@ import { connect } from 'react-redux';
import translate from 'translations';
import TabSection from 'containers/TabSection';
import { UnlockHeader } from 'components/ui';
import { SideBar } from './components/index';
import { getWalletInst } from 'selectors/wallet';
import { AppState } from 'reducers';
import { RouteComponentProps, Route, Switch, Redirect } from 'react-router';
@ -11,9 +10,11 @@ import { RedirectWithQuery } from 'components/RedirectWithQuery';
import {
WalletInfo,
RequestPayment,
RecentTransactions,
Fields,
UnavailableWallets
} from 'containers/Tabs/SendTransaction/components';
UnavailableWallets,
SideBar
} from './components';
import SubTabs, { Tab } from 'components/SubTabs';
import { RouteNotFound } from 'components/RouteNotFound';
import { isNetworkUnit } from 'selectors/config/wallet';
@ -34,7 +35,7 @@ type Props = StateProps & RouteComponentProps<{}>;
class SendTransaction extends React.Component<Props> {
public render() {
const { wallet, match } = this.props;
const { wallet, match, location, history } = this.props;
const currentPath = match.url;
const tabs: Tab[] = [
{
@ -50,6 +51,10 @@ class SendTransaction extends React.Component<Props> {
{
path: 'info',
name: translate('NAV_ViewWallet')
},
{
path: 'recent-txs',
name: translate('Recent Transactions')
}
];
@ -60,7 +65,7 @@ class SendTransaction extends React.Component<Props> {
{wallet && (
<div className="SubTabs row">
<div className="col-sm-8">
<SubTabs tabs={tabs} match={match} />
<SubTabs tabs={tabs} match={match} location={location} history={history} />
</div>
<div className="col-sm-8">
<Switch>
@ -91,6 +96,11 @@ class SendTransaction extends React.Component<Props> {
exact={true}
render={() => <RequestPayment wallet={wallet} />}
/>
<Route
path={`${currentPath}/recent-txs`}
exact={true}
render={() => <RecentTransactions wallet={wallet} />}
/>
<RouteNotFound />
</Switch>
</div>

View File

@ -19,7 +19,7 @@ export default class SignAndVerifyMessage extends Component<RouteComponentProps<
public changeTab = (activeTab: State['activeTab']) => () => this.setState({ activeTab });
public render() {
const { match } = this.props;
const { match, location, history } = this.props;
const currentPath = match.url;
const tabs = [
@ -36,7 +36,7 @@ export default class SignAndVerifyMessage extends Component<RouteComponentProps<
return (
<TabSection>
<section className="Tab-content SignAndVerifyMsg">
<SubTabs tabs={tabs} match={match} />
<SubTabs tabs={tabs} match={match} location={location} history={history} />
<Switch>
<Route
exact={true}

View File

@ -1,6 +1,7 @@
import { Wei, TokenValue } from 'libs/units';
import { IHexStrTransaction } from 'libs/transaction';
import { Token } from 'types/network';
import { TransactionData, TransactionReceipt } from 'types/transactions';
export interface TxObj {
to: string;
@ -12,33 +13,6 @@ interface TokenBalanceResult {
error: string | null;
}
export interface TransactionData {
hash: string;
nonce: number;
blockHash: string | null;
blockNumber: number | null;
transactionIndex: number | null;
from: string;
to: string;
value: Wei;
gasPrice: Wei;
gas: Wei;
input: string;
}
export interface TransactionReceipt {
transactionHash: string;
transactionIndex: number;
blockHash: string;
blockNumber: number;
cumulativeGasUsed: Wei;
gasUsed: Wei;
contractAddress: string | null;
logs: string[];
logsBloom: string;
status: number;
}
export interface INode {
ping(): Promise<boolean>;
getBalance(address: string): Promise<Wei>;

View File

@ -2,7 +2,8 @@ import BN from 'bn.js';
import { IHexStrTransaction } from 'libs/transaction';
import { Wei, TokenValue } from 'libs/units';
import { stripHexPrefix } from 'libs/values';
import { INode, TxObj, TransactionData, TransactionReceipt } from '../INode';
import { hexToNumber } from 'utils/formatters';
import { INode, TxObj } from '../INode';
import RPCClient from './client';
import RPCRequests from './requests';
import {
@ -17,7 +18,7 @@ import {
isValidRawTxApi
} from 'libs/validators';
import { Token } from 'types/network';
import { hexToNumber } from 'utils/formatters';
import { TransactionData, TransactionReceipt } from 'types/transactions';
export default class RpcNode implements INode {
public client: RPCClient;

View File

@ -15,7 +15,6 @@ const computeIndexingHash = (tx: Buffer) => bufferToHex(makeTransaction(tx).hash
const getTransactionFields = (t: Tx): IHexStrTransaction => {
// For some crazy reason, toJSON spits out an array, not keyed values.
const { data, gasLimit, gasPrice, to, nonce, value } = t;
const chainId = t.getChainId();
return {

View File

@ -1,24 +1,20 @@
import {
FetchTransactionDataAction,
SetTransactionDataAction,
AddRecentTransactionAction,
TransactionsAction,
TypeKeys
} from 'actions/transactions';
import { TransactionData, TransactionReceipt } from 'libs/nodes';
export interface TransactionState {
data: TransactionData | null;
receipt: TransactionReceipt | null;
error: string | null;
isLoading: boolean;
}
import { SavedTransaction, TransactionState } from 'types/transactions';
export interface State {
txData: { [txhash: string]: TransactionState };
recent: SavedTransaction[];
}
export const INITIAL_STATE: State = {
txData: {}
txData: {},
recent: []
};
function fetchTxData(state: State, action: FetchTransactionDataAction): State {
@ -51,12 +47,30 @@ function setTxData(state: State, action: SetTransactionDataAction): State {
};
}
function resetTxData(state: State): State {
return {
...state,
txData: INITIAL_STATE.txData
};
}
function addRecentTx(state: State, action: AddRecentTransactionAction): State {
return {
...state,
recent: [action.payload, ...state.recent].slice(0, 50)
};
}
export function transactions(state: State = INITIAL_STATE, action: TransactionsAction): State {
switch (action.type) {
case TypeKeys.TRANSACTIONS_FETCH_TRANSACTION_DATA:
return fetchTxData(state, action);
case TypeKeys.TRANSACTIONS_SET_TRANSACTION_DATA:
return setTxData(state, action);
case TypeKeys.TRANSACTIONS_RESET_TRANSACTION_DATA:
return resetTxData(state);
case TypeKeys.TRANSACTIONS_ADD_RECENT_TRANSACTION:
return addRecentTx(state, action);
default:
return state;
}

View File

@ -1,8 +1,29 @@
import { setTransactionData, FetchTransactionDataAction, TypeKeys } from 'actions/transactions';
import { SagaIterator } from 'redux-saga';
import { put, select, apply, takeEvery } from 'redux-saga/effects';
import { getNodeLib } from 'selectors/config';
import { INode, TransactionData, TransactionReceipt } from 'libs/nodes';
import { put, select, apply, call, take, takeEvery } from 'redux-saga/effects';
import EthTx from 'ethereumjs-tx';
import { toChecksumAddress } from 'ethereumjs-util';
import {
setTransactionData,
FetchTransactionDataAction,
addRecentTransaction,
resetTransactionData,
TypeKeys
} from 'actions/transactions';
import {
TypeKeys as TxTypeKeys,
BroadcastTransactionQueuedAction,
BroadcastTransactionSucceededAction,
BroadcastTransactionFailedAction
} from 'actions/transaction';
import { getNodeLib, getNetworkConfig } from 'selectors/config';
import { getWalletInst } from 'selectors/wallet';
import { INode } from 'libs/nodes';
import { hexEncodeData } from 'libs/nodes/rpc/utils';
import { getTransactionFields } from 'libs/transaction';
import { TypeKeys as ConfigTypeKeys } from 'actions/config';
import { TransactionData, TransactionReceipt, SavedTransaction } from 'types/transactions';
import { NetworkConfig } from 'types/network';
import { AppState } from 'reducers';
export function* fetchTxData(action: FetchTransactionDataAction): SagaIterator {
const txhash = action.payload;
@ -34,6 +55,67 @@ export function* fetchTxData(action: FetchTransactionDataAction): SagaIterator {
yield put(setTransactionData({ txhash, data, receipt, error }));
}
export function* saveBroadcastedTx(action: BroadcastTransactionQueuedAction) {
const { serializedTransaction: txBuffer, indexingHash: txIdx } = action.payload;
const res: BroadcastTransactionSucceededAction | BroadcastTransactionFailedAction = yield take([
TxTypeKeys.BROADCAST_TRANSACTION_SUCCEEDED,
TxTypeKeys.BROADCAST_TRASACTION_FAILED
]);
// If our TX succeeded, save it and update the store.
if (
res.type === TxTypeKeys.BROADCAST_TRANSACTION_SUCCEEDED &&
res.payload.indexingHash === txIdx
) {
const tx = new EthTx(txBuffer);
const savableTx: SavedTransaction = yield call(
getSaveableTransaction,
tx,
res.payload.broadcastedHash
);
yield put(addRecentTransaction(savableTx));
}
}
// Given a serialized transaction, return a transaction we could save in LS
export function* getSaveableTransaction(tx: EthTx, hash: string): SagaIterator {
const fields = getTransactionFields(tx);
let from: string = '';
let chainId: number = 0;
try {
// Signed transactions have these fields
from = hexEncodeData(tx.getSenderAddress());
chainId = fields.chainId;
} catch (err) {
// Unsigned transactions (e.g. web3) don't, so grab them from current state
const wallet: AppState['wallet']['inst'] = yield select(getWalletInst);
const network: NetworkConfig = yield select(getNetworkConfig);
chainId = network.chainId;
if (wallet) {
from = wallet.getAddressString();
}
}
const savableTx: SavedTransaction = {
hash,
from,
chainId,
to: toChecksumAddress(fields.to),
value: fields.value,
time: Date.now()
};
return savableTx;
}
export function* resetTxData() {
yield put(resetTransactionData());
}
export default function* transactions(): SagaIterator {
yield takeEvery(TypeKeys.TRANSACTIONS_FETCH_TRANSACTION_DATA, fetchTxData);
yield takeEvery(TxTypeKeys.BROADCAST_TRANSACTION_QUEUED, saveBroadcastedTx);
yield takeEvery(ConfigTypeKeys.CONFIG_NODE_CHANGE, resetTxData);
}

View File

@ -92,4 +92,12 @@
position: relative;
height: inherit;
}
// Identicons need to fit into the select
.Identicon {
display: inline-block;
width: 22px !important;
height: 22px !important;
top: -1px;
}
}

View File

@ -1,5 +1,30 @@
import { AppState } from 'reducers';
import { SavedTransaction } from 'types/transactions';
import { getNetworkConfig } from './config';
import { getWalletInst } from './wallet';
export function getTransactionDatas(state: AppState) {
return state.transactions.txData;
}
export function getRecentTransactions(state: AppState): SavedTransaction[] {
return state.transactions.recent;
}
export function getRecentNetworkTransactions(state: AppState): SavedTransaction[] {
const txs = getRecentTransactions(state);
const network = getNetworkConfig(state);
return txs.filter(tx => tx.chainId === network.chainId);
}
export function getRecentWalletTransactions(state: AppState): SavedTransaction[] {
const networkTxs = getRecentNetworkTransactions(state);
const wallet = getWalletInst(state);
if (wallet) {
const addr = wallet.getAddressString().toLowerCase();
return networkTxs.filter(tx => tx.from.toLowerCase() === addr);
} else {
return [];
}
}

View File

@ -4,6 +4,10 @@ import {
INITIAL_STATE as transactionInitialState,
State as TransactionState
} from 'reducers/transaction';
import {
INITIAL_STATE as initialTransactionsState,
State as TransactionsState
} from 'reducers/transactions';
import { State as SwapState, INITIAL_STATE as swapInitialState } from 'reducers/swap';
import { applyMiddleware, createStore, Store } from 'redux';
import { composeWithDevTools } from 'redux-devtools-extension';
@ -44,6 +48,7 @@ const configureStore = () => {
: { ...swapInitialState };
const savedTransactionState = loadStatePropertyOrEmptyObject<TransactionState>('transaction');
const savedTransactionsState = loadStatePropertyOrEmptyObject<TransactionsState>('transactions');
const persistedInitialState: Partial<AppState> = {
transaction: {
@ -62,6 +67,10 @@ const configureStore = () => {
// ONLY LOAD SWAP STATE FROM LOCAL STORAGE IF STEP WAS 3
swap: swapState,
transactions: {
...initialTransactionsState,
...savedTransactionsState
},
...rehydrateConfigAndCustomTokenState()
};
@ -75,6 +84,7 @@ const configureStore = () => {
store.subscribe(
throttle(() => {
const state: AppState = store.getState();
saveState({
transaction: {
fields: {
@ -96,6 +106,9 @@ const configureStore = () => {
allIds: []
}
},
transactions: {
recent: state.transactions.recent
},
...getConfigAndCustomTokensStateToSubscribe(state)
});
}, 50)

View File

@ -1,9 +1,10 @@
export const REDUX_STATE = 'REDUX_STATE';
import { sha256 } from 'ethereumjs-util';
import { State as SwapState } from 'reducers/swap';
import { IWallet, WalletConfig } from 'libs/wallet';
import { sha256 } from 'ethereumjs-util';
import { AppState } from 'reducers';
export const REDUX_STATE = 'REDUX_STATE';
export function loadState<T>(): T | undefined {
try {
const serializedState = localStorage.getItem(REDUX_STATE);

44
shared/types/transactions.d.ts vendored Normal file
View File

@ -0,0 +1,44 @@
import { Wei } from 'libs/units';
export interface SavedTransaction {
hash: string;
to: string;
from: string;
value: string;
chainId: number;
time: number;
}
export interface TransactionData {
hash: string;
nonce: number;
blockHash: string | null;
blockNumber: number | null;
transactionIndex: number | null;
from: string;
to: string;
value: Wei;
gasPrice: Wei;
gas: Wei;
input: string;
}
export interface TransactionReceipt {
transactionHash: string;
transactionIndex: number;
blockHash: string;
blockNumber: number;
cumulativeGasUsed: Wei;
gasUsed: Wei;
contractAddress: string | null;
logs: string[];
logsBloom: string;
status: number;
}
export interface TransactionState {
data: TransactionData | null;
receipt: TransactionReceipt | null;
error: string | null;
isLoading: boolean;
}