From 90bd9d59cd7060108e7043e93affab5c1f3715d9 Mon Sep 17 00:00:00 2001 From: Paul Ccari Date: Wed, 20 Oct 2021 13:07:32 -0500 Subject: [PATCH] add revamp components for transaction page --- .../wallet/summary/WalletSummaryRevamp.js | 201 ++-- .../wallet/transactions/TransactionRevamp.js | 931 ++++++++++++++++++ .../WalletNoTransactionsRevamp.js | 47 + .../WalletTransactionsListRevamp.js | 218 ++++ .../containers/wallet/WalletSummaryPage.js | 50 +- 5 files changed, 1355 insertions(+), 92 deletions(-) create mode 100644 packages/yoroi-extension/app/components/wallet/transactions/TransactionRevamp.js create mode 100644 packages/yoroi-extension/app/components/wallet/transactions/WalletNoTransactionsRevamp.js create mode 100644 packages/yoroi-extension/app/components/wallet/transactions/WalletTransactionsListRevamp.js diff --git a/packages/yoroi-extension/app/components/wallet/summary/WalletSummaryRevamp.js b/packages/yoroi-extension/app/components/wallet/summary/WalletSummaryRevamp.js index e75fb95a4f..280b0d98eb 100644 --- a/packages/yoroi-extension/app/components/wallet/summary/WalletSummaryRevamp.js +++ b/packages/yoroi-extension/app/components/wallet/summary/WalletSummaryRevamp.js @@ -2,10 +2,8 @@ import { Component } from 'react'; import type { Node } from 'react'; import { observer } from 'mobx-react'; -import classnames from 'classnames'; import { defineMessages, intlShape } from 'react-intl'; -import ExportTxToFileSvg from '../../../assets/images/transaction/export-tx-to-file.inline.svg'; -import BorderedBox from '../../widgets/BorderedBox'; +import ExportTxToFileSvg from '../../../assets/images/transaction/export.inline.svg'; import type { UnconfirmedAmount } from '../../../types/unconfirmedAmountType'; import globalMessages from '../../../i18n/global-messages'; import styles from './WalletSummary.scss'; @@ -18,6 +16,8 @@ import { hiddenAmount } from '../../../utils/strings'; import type { TokenLookupKey } from '../../../api/common/lib/MultiToken'; import { getTokenName } from '../../../stores/stateless/tokenHelpers'; import type { TokenRow } from '../../../api/ada/lib/storage/database/primitives/tables'; +import { Button, Typography } from '@mui/material'; +import { Box, styled } from '@mui/system'; const messages = defineMessages({ pendingOutgoingConfirmationLabel: { @@ -109,101 +109,126 @@ export default class WalletSummaryRevamp extends Component { const { intl } = this.context; const content = ( -
-
-
- - {!isLoadingTransactions && ( - <> -
- {intl.formatMessage(messages.numOfTxsLabel)}: {numberOfTransactions} -
- {(!pendingAmount.incoming.isEmpty() || !pendingAmount.outgoing.isEmpty()) && ( -
- {!pendingAmount.incoming.isEmpty() && ( -
- {`${intl.formatMessage(messages.pendingIncomingConfirmationLabel)}`} - :  - {pendingAmount.incomingInSelectedCurrency && - unitOfAccountSetting.enabled ? ( - - {formatValue(pendingAmount.incomingInSelectedCurrency)} - {' ' + unitOfAccountSetting.currency} - - ) : ( - <> - - {this.renderAmountDisplay({ - shouldHideBalance: this.props.shouldHideBalance, - amount: pendingAmount.incoming, - })} - - - )} -
+ + {!isLoadingTransactions && ( + <> + + + {intl.formatMessage(messages.numOfTxsLabel)}:{' '} + + {numberOfTransactions} + + + + + {(!pendingAmount.incoming.isEmpty() || !pendingAmount.outgoing.isEmpty()) && ( + + {!pendingAmount.incoming.isEmpty() && ( +
+ {`${intl.formatMessage(messages.pendingIncomingConfirmationLabel)}`} + :  + {pendingAmount.incomingInSelectedCurrency && unitOfAccountSetting.enabled ? ( + + {formatValue(pendingAmount.incomingInSelectedCurrency)} + {' ' + unitOfAccountSetting.currency} + + ) : ( + <> + + {this.renderAmountDisplay({ + shouldHideBalance: this.props.shouldHideBalance, + amount: pendingAmount.incoming, + })} + + )} - {!pendingAmount.outgoing.isEmpty() && ( -
- {`${intl.formatMessage(messages.pendingOutgoingConfirmationLabel)}`} - :  - {pendingAmount.outgoingInSelectedCurrency && - unitOfAccountSetting.enabled ? ( - - {formatValue(pendingAmount.outgoingInSelectedCurrency)} - {' ' + unitOfAccountSetting.currency} - - ) : ( - <> - - {this.renderAmountDisplay({ - shouldHideBalance: this.props.shouldHideBalance, - amount: pendingAmount.outgoing, - })} - - - )} -
+
+ )} + {!pendingAmount.outgoing.isEmpty() && ( +
+ {`${intl.formatMessage(messages.pendingOutgoingConfirmationLabel)}`} + :  + {pendingAmount.outgoingInSelectedCurrency && unitOfAccountSetting.enabled ? ( + + {formatValue(pendingAmount.outgoingInSelectedCurrency)} + {' ' + unitOfAccountSetting.currency} + + ) : ( + <> + + {this.renderAmountDisplay({ + shouldHideBalance: this.props.shouldHideBalance, + amount: pendingAmount.outgoing, + })} + + )}
)} - +
)} - -
-
- {!isLoadingTransactions ? ( - - - - ) : null} -
-
-
- {intl.formatMessage(messages.dateSection)} -
-
+ + )} + +
-
+ +
-
+ +
-
+ +
-
-
+ + + ); - return
{content}
; + return content; } } + +const Label = styled(Typography)({ + color: 'var(--yoroi-palette-gray-600)', +}); diff --git a/packages/yoroi-extension/app/components/wallet/transactions/TransactionRevamp.js b/packages/yoroi-extension/app/components/wallet/transactions/TransactionRevamp.js new file mode 100644 index 0000000000..ec634071d1 --- /dev/null +++ b/packages/yoroi-extension/app/components/wallet/transactions/TransactionRevamp.js @@ -0,0 +1,931 @@ +// @flow +import React, { Component } from 'react'; +import type { Node } from 'react'; +import { observer } from 'mobx-react'; +import { defineMessages, intlShape } from 'react-intl'; +import type { $npm$ReactIntl$IntlFormat } from 'react-intl'; +import moment from 'moment'; +import classnames from 'classnames'; +import styles from './Transaction.scss'; +import AddMemoSvg from '../../../assets/images/add-memo.inline.svg'; +import EditSvg from '../../../assets/images/edit.inline.svg'; +import WalletTransaction from '../../../domain/WalletTransaction'; +import JormungandrTransaction from '../../../domain/JormungandrTransaction'; +import CardanoShelleyTransaction from '../../../domain/CardanoShelleyTransaction'; +import globalMessages, { memoMessages } from '../../../i18n/global-messages'; +import type { TransactionDirectionType } from '../../../api/ada/transactions/types'; +import { transactionTypes } from '../../../api/ada/transactions/types'; +import type { AssuranceLevel } from '../../../types/transactionAssuranceTypes'; +import { Logger } from '../../../utils/logging'; +import ExpandArrow from '../../../assets/images/expand-arrow-grey.inline.svg'; +import ExplorableHashContainer from '../../../containers/widgets/ExplorableHashContainer'; +import { SelectedExplorer } from '../../../domain/SelectedExplorer'; +import { calculateAndFormatValue } from '../../../utils/unit-of-account'; +import { TxStatusCodes } from '../../../api/ada/lib/storage/database/primitives/enums'; +import type { TxStatusCodesType } from '../../../api/ada/lib/storage/database/primitives/enums'; +import type { + CertificateRow, + TokenRow, +} from '../../../api/ada/lib/storage/database/primitives/tables'; +import { RustModule } from '../../../api/ada/lib/cardanoCrypto/rustLoader'; +import { splitAmount, truncateAddressShort, truncateToken } from '../../../utils/formatters'; +import type { TxMemoTableRow } from '../../../api/ada/lib/storage/database/memos/tables'; +import CopyableAddress from '../../widgets/CopyableAddress'; +import type { Notification } from '../../../types/notificationType'; +import { genAddressLookup } from '../../../stores/stateless/addressStores'; +import { MultiToken } from '../../../api/common/lib/MultiToken'; +import { hiddenAmount } from '../../../utils/strings'; +import type { TokenLookupKey, TokenEntry } from '../../../api/common/lib/MultiToken'; +import { getTokenName, getTokenIdentifierIfExists } from '../../../stores/stateless/tokenHelpers'; +import type { UnitOfAccountSettingType } from '../../../types/unitOfAccountType'; +import { + parseMetadata, + parseMetadataDetailed, +} from '../../../api/ada/lib/storage/bridge/metadataUtils'; +import CodeBlock from '../../widgets/CodeBlock'; +import BigNumber from 'bignumber.js'; +import { ComplexityLevels } from '../../../types/complexityLevelType'; +import type { ComplexityLevelType } from '../../../types/complexityLevelType'; +import { Typography } from '@mui/material'; +import { Box } from '@mui/system'; + +const messages = defineMessages({ + type: { + id: 'wallet.transaction.type', + defaultMessage: '!!!{currency} transaction', + }, + exchange: { + id: 'wallet.transaction.type.exchange', + defaultMessage: '!!!Exchange', + }, + assuranceLevel: { + id: 'wallet.transaction.assuranceLevel', + defaultMessage: '!!!Transaction assurance level', + }, + confirmations: { + id: 'wallet.transaction.confirmations', + defaultMessage: '!!!confirmations', + }, + conversionRate: { + id: 'wallet.transaction.conversion.rate', + defaultMessage: '!!!Conversion rate', + }, + sent: { + id: 'wallet.transaction.sent', + defaultMessage: '!!!{currency} sent', + }, + received: { + id: 'wallet.transaction.received', + defaultMessage: '!!!{currency} received', + }, + intrawallet: { + id: 'wallet.transaction.type.intrawallet', + defaultMessage: '!!!{currency} intrawallet transaction', + }, + multiparty: { + id: 'wallet.transaction.type.multiparty', + defaultMessage: '!!!{currency} multiparty transaction', + }, + rewardWithdrawn: { + id: 'wallet.transaction.type.rewardWithdrawn', + defaultMessage: '!!!Reward withdrawn', + }, + catalystVotingRegistered: { + id: 'wallet.transaction.type.catalystVotingRegistered', + defaultMessage: '!!!Catalyst voting registered', + }, + stakeDelegated: { + id: 'wallet.transaction.type.stakeDelegated', + defaultMessage: '!!!Stake delegated', + }, + stakeKeyRegistered: { + id: 'wallet.transaction.type.stakeKeyRegistered', + defaultMessage: '!!!Staking key registered', + }, + fromAddress: { + id: 'wallet.transaction.address.from', + defaultMessage: '!!!From address', + }, + toAddress: { + id: 'wallet.transaction.address.to', + defaultMessage: '!!!To address', + }, + addressType: { + id: 'wallet.transaction.address.type', + defaultMessage: '!!!Address Type', + }, + certificateLabel: { + id: 'wallet.transaction.certificateLabel', + defaultMessage: '!!!Certificate', + }, + certificatesLabel: { + id: 'wallet.transaction.certificatesLabel', + defaultMessage: '!!!Certificates', + }, + transactionAmount: { + id: 'wallet.transaction.transactionAmount', + defaultMessage: '!!!Transaction amount', + }, + transactionMetadata: { + id: 'wallet.transaction.transactionMetadata', + defaultMessage: '!!!Transaction Metadata', + }, +}); + +const jormungandrCertificateKinds = defineMessages({ + PoolRegistration: { + id: 'wallet.transaction.certificate.PoolRegistration', + defaultMessage: '!!!Pool registration', + }, + PoolUpdate: { + id: 'wallet.transaction.certificate.PoolUpdate', + defaultMessage: '!!!Pool update', + }, + PoolRetirement: { + id: 'wallet.transaction.certificate.PoolRetirement', + defaultMessage: '!!!Pool retirement', + }, + StakeDelegation: { + id: 'wallet.transaction.certificate.StakeDelegation', + defaultMessage: '!!!Stake delegation', + }, + OwnerStakeDelegation: { + id: 'wallet.transaction.certificate.OwnerStakeDelegation', + defaultMessage: '!!!Owner stake delegation', + }, +}); + +const shelleyCertificateKinds = { + PoolRegistration: jormungandrCertificateKinds.PoolRegistration, + PoolRetirement: jormungandrCertificateKinds.PoolRetirement, + StakeDelegation: jormungandrCertificateKinds.StakeDelegation, + StakeDeregistration: globalMessages.StakeDeregistration, + ...defineMessages({ + StakeRegistration: { + id: 'wallet.transaction.certificate.StakeRegistration', + defaultMessage: '!!!Staking key registration', + }, + GenesisKeyDelegation: { + id: 'wallet.transaction.certificate.GenesisKeyDelegation', + defaultMessage: '!!!Genesis key delegation', + }, + MoveInstantaneousRewardsCert: { + id: 'wallet.transaction.certificate.MoveInstantaneousRewardsCert', + defaultMessage: '!!!Manually-initiated reward payout', + }, + }), +}; + +const assuranceLevelTranslations = defineMessages({ + low: { + id: 'wallet.transaction.assuranceLevel.low', + defaultMessage: '!!!low', + }, + medium: { + id: 'wallet.transaction.assuranceLevel.medium', + defaultMessage: '!!!medium', + }, + high: { + id: 'wallet.transaction.assuranceLevel.high', + defaultMessage: '!!!high', + }, +}); + +const stateTranslations = defineMessages({ + pending: { + id: 'wallet.transaction.state.pending', + defaultMessage: '!!!pending', + }, + failed: { + id: 'wallet.transaction.state.failed', + defaultMessage: '!!!failed', + }, +}); + +type Props = {| + +data: WalletTransaction, + +numberOfConfirmations: ?number, + +memo: void | $ReadOnly, + +state: TxStatusCodesType, + +selectedExplorer: SelectedExplorer, + +assuranceLevel: AssuranceLevel, + isLastInList: boolean, + +shouldHideBalance: boolean, + +onAddMemo: WalletTransaction => void, + +onEditMemo: WalletTransaction => void, + +unitOfAccountSetting: UnitOfAccountSettingType, + +getCurrentPrice: (from: string, to: string) => ?number, + +addressLookup: ReturnType, + +onCopyAddressTooltip: (string, string) => void, + +notification: ?Notification, + +addressToDisplayString: string => string, + +getTokenInfo: ($ReadOnly>) => $ReadOnly, + +complexityLevel: ?ComplexityLevelType, +|}; + +type State = {| + isExpanded: boolean, +|}; + +@observer +export default class TransactionRevamp extends Component { + static contextTypes: {| intl: $npm$ReactIntl$IntlFormat |} = { + intl: intlShape.isRequired, + }; + + state: State = { + isExpanded: false, + }; + + toggleDetails: void => void = () => { + this.setState(prevState => ({ isExpanded: !prevState.isExpanded })); + }; + + getTxTypeMsg(intl: $npm$ReactIntl$IntlFormat, currency: string, data: WalletTransaction): string { + const { type } = data; + if (type === transactionTypes.EXPEND) { + return intl.formatMessage(messages.sent, { currency }); + } + if (type === transactionTypes.INCOME) { + return intl.formatMessage(messages.received, { currency }); + } + if (type === transactionTypes.SELF) { + if (data instanceof CardanoShelleyTransaction) { + const features = data.getFeatures(); + if ( + (features.includes('Withdrawal') && features.length === 1) || + (features.includes('Withdrawal') && + features.includes('StakeDeregistration') && + features.length === 2) + ) { + return intl.formatMessage(messages.rewardWithdrawn); + } + if (features.includes('CatalystVotingRegistration') && features.length === 1) { + return intl.formatMessage(messages.catalystVotingRegistered); + } + if ( + (features.includes('StakeDelegation') && features.length === 1) || + (features.includes('StakeDelegation') && + features.includes('StakeRegistration') && + features.length === 2) + ) { + return intl.formatMessage(messages.stakeDelegated); + } + if (features.includes('StakeRegistration') && features.length === 1) { + return intl.formatMessage(messages.stakeKeyRegistered); + } + } + return intl.formatMessage(messages.intrawallet, { currency }); + } + if (type === transactionTypes.MULTI) { + // can happen for example in Cardano + // if you claim a reward from an account doesn't belong to you + // you have an input to pay the tx fee + // there is an input you don't own (the withdrawal) + // you have an output to receive change + withdrawal amount + return intl.formatMessage(messages.multiparty, { currency }); + } + // unused + if (type === transactionTypes.EXCHANGE) { + Logger.error('EXCHANGE type transactions not supported'); + return '???'; + } + Logger.error('Unknown transaction type'); + return '???'; + } + + getStatusString( + intl: $npm$ReactIntl$IntlFormat, + state: number, + assuranceLevel: AssuranceLevel + ): string { + if (state === TxStatusCodes.IN_BLOCK) { + return intl.formatMessage(assuranceLevelTranslations[assuranceLevel]); + } + if (state === TxStatusCodes.PENDING) { + return intl.formatMessage(stateTranslations.pending); + } + if (state < 0) { + return intl.formatMessage(stateTranslations.failed); + } + throw new Error(`${nameof(this.getStatusString)} unexpected state ` + state); + } + + renderAmountDisplay: ({| + entry: TokenEntry, + |}) => Node = request => { + if (this.props.shouldHideBalance) { + return {hiddenAmount}; + } + const tokenInfo = this.props.getTokenInfo(request.entry); + const shiftedAmount = request.entry.amount.shiftedBy(-tokenInfo.Metadata.numberOfDecimals); + + if (this.props.unitOfAccountSetting.enabled === true) { + const { currency } = this.props.unitOfAccountSetting; + const price = this.props.getCurrentPrice(request.entry.identifier, currency); + if (price != null) { + return ( + <> + {calculateAndFormatValue(shiftedAmount, price) + ' ' + currency} + + {shiftedAmount.toString()} {getTokenName(tokenInfo)} + + + ); + } + } + const [beforeDecimalRewards, afterDecimalRewards] = splitAmount( + shiftedAmount, + tokenInfo.Metadata.numberOfDecimals + ); + + // we may need to explicitly add + for positive values + const adjustedBefore = beforeDecimalRewards.startsWith('-') + ? beforeDecimalRewards + : '+' + beforeDecimalRewards; + + return ( + <> + {adjustedBefore} + + {afterDecimalRewards} + + + ); + }; + + renderFeeDisplay: ({| + amount: MultiToken, + type: TransactionDirectionType, + |}) => Node = request => { + if (this.props.shouldHideBalance) { + return {hiddenAmount}; + } + const defaultEntry = request.amount.getDefaultEntry(); + const tokenInfo = this.props.getTokenInfo(defaultEntry); + const shiftedAmount = defaultEntry.amount.shiftedBy(-tokenInfo.Metadata.numberOfDecimals); + + if (this.props.unitOfAccountSetting.enabled === true) { + const { currency } = this.props.unitOfAccountSetting; + const price = this.props.getCurrentPrice(defaultEntry.identifier, currency); + if (price != null) { + return ( + <> + {calculateAndFormatValue(shiftedAmount.abs(), price) + ' ' + currency} + + {shiftedAmount.abs().toString()} {getTokenName(tokenInfo)} + + + ); + } + } + if (request.type === transactionTypes.INCOME) { + return ( + + - + + ); + } + const [beforeDecimalRewards, afterDecimalRewards] = splitAmount( + shiftedAmount.abs(), + tokenInfo.Metadata.numberOfDecimals + ); + + return ( + <> + {beforeDecimalRewards} + + {afterDecimalRewards} + + + ); + }; + + getTicker: TokenEntry => string = tokenEntry => { + if (this.props.unitOfAccountSetting.enabled === true) { + return this.props.unitOfAccountSetting.currency; + } + const tokenInfo = this.props.getTokenInfo(tokenEntry); + return truncateToken(getTokenName(tokenInfo)); + }; + + getFingerprint: TokenEntry => string | void = tokenEntry => { + const tokenInfo = this.props.getTokenInfo(tokenEntry); + if (tokenInfo.Metadata.type === 'Cardano') { + return getTokenIdentifierIfExists(tokenInfo); + } + return undefined; + }; + + renderAssets: ({| + assets: Array, + |}) => Node = request => { + if (request.assets.length === 0) { + return null; + } + if (request.assets.length === 1) { + const entry = request.assets[0]; + return ( +
+ {this.renderAmountDisplay({ entry })} {this.getTicker(entry)} +
+ ); + } + // request.assets.length > 1 + + // display sign only if all amounts are either the same sign or zero + let sign = undefined; + for (const entry of request.assets) { + if (entry.amount.isPositive()) { + if (sign === '-') { + sign = null; + break; + } + sign = '+'; + } else if (entry.amount.isNegative()) { + if (sign === '+') { + sign = null; + break; + } + sign = '-'; + } + } + + return ( +
+ {sign} + {request.assets.length} {this.context.intl.formatMessage(globalMessages.assets)} +
+ ); + }; + + renderRow: ({| + kind: string, + data: WalletTransaction, + address: {| address: string, value: MultiToken |}, + addressIndex: number, + transform?: BigNumber => BigNumber, + |}) => Node = request => { + const notificationElementId = `${request.kind}-address-${request.addressIndex}-${request.data.txid}-copyNotification`; + const divKey = identifier => + `${request.data.txid}-${request.kind}-${request.address.address}-${request.addressIndex}-${identifier}`; + const renderAmount = entry => { + const fingerprint = this.getFingerprint(entry); + return ( +
+ {this.renderAmountDisplay({ + entry: { + ...entry, + amount: request.transform ? request.transform(entry.amount) : entry.amount, + }, + })}{' '} + {fingerprint !== undefined ? ( + + {this.getTicker(entry)} + + ) : ( + this.getTicker(entry) + )} +
+ ); + }; + + return ( + // eslint-disable-next-line react/no-array-index-key +
+ + this.props.onCopyAddressTooltip(request.address.address, notificationElementId) + } + notification={this.props.notification} + placementTooltip="bottom-start" + > + + + {truncateAddressShort(this.props.addressToDisplayString(request.address.address))} + + + + {this.generateAddressButton(request.address.address)} + + {renderAmount(request.address.value.getDefaultEntry())} + + {request.address.value.nonDefaultEntries().map(entry => ( + +
+
+ {renderAmount(entry)} + + ))} +
+ ); + }; + + render(): Node { + const data = this.props.data; + const { state, assuranceLevel, onAddMemo, onEditMemo } = this.props; + const { isExpanded } = this.state; + const { intl } = this.context; + const isFailedTransaction = state < 0; + const isPendingTransaction = state === TxStatusCodes.PENDING; + + const componentStyles = classnames([ + styles.component, + isFailedTransaction ? styles.failed : null, + isPendingTransaction ? styles.pending : null, + ]); + + const contentStyles = classnames([styles.content, isExpanded ? styles.shadow : null]); + + const detailsStyles = classnames([ + styles.details, + isExpanded ? styles.expanded : styles.closed, + ]); + + const labelOkClasses = classnames([styles.status, styles[assuranceLevel]]); + + const labelClasses = classnames([ + styles.status, + isFailedTransaction ? styles.failedLabel : '', + isPendingTransaction ? styles.pendingLabel : '', + ]); + + const arrowClasses = isExpanded ? styles.collapseArrow : styles.expandArrow; + + const status = this.getStatusString(intl, state, assuranceLevel); + + return ( + + {/* ==== Clickable Header -> toggles details ==== */} + + +
+ + + {this.getTxTypeMsg(intl, this.getTicker(data.amount.getDefaultEntry()), data)} + + + {moment(data.date).format('hh:mm A')} + + + {state === TxStatusCodes.IN_BLOCK ? ( +
{status}
+ ) : ( +
{status}
+ )} + + {this.renderFeeDisplay({ + amount: data.fee, + type: data.type, + })} + +
+ + {this.renderAmountDisplay({ + entry: data.amount.getDefaultEntry(), + })}{' '} + {this.getTicker(data.amount.getDefaultEntry())} + + {this.renderAssets({ assets: data.amount.nonDefaultEntries() })} +
+
+
+ + + +
+
+
+ + {/* ==== Toggleable Transaction Details ==== */} + +
+ {/* converting assets is not implemented but we may use it in the future for tokens */} + {data.type === transactionTypes.EXCHANGE && ( +
+
+

{intl.formatMessage(messages.exchange)}

+
+
+

{intl.formatMessage(messages.conversionRate)}

+
+
+ )} +
+
+
+
+

+ {intl.formatMessage(globalMessages.fromAddresses)}: + {data.addresses.from.length} +

+

{intl.formatMessage(messages.addressType)}

+

{intl.formatMessage(globalMessages.amountLabel)}

+
+
+ {data.addresses.from.map((address, addressIndex) => { + return this.renderRow({ + kind: 'in', + data, + address, + addressIndex, + transform: amount => amount.abs().negated(), // ensure it shows as negative + }); + })} +
+
+
+
+

+ {intl.formatMessage(globalMessages.toAddresses)}: + {data.addresses.to.length} +

+

{intl.formatMessage(messages.addressType)}

+

{intl.formatMessage(globalMessages.amountLabel)}

+
+
+ {data.addresses.to.map((address, addressIndex) => { + return this.renderRow({ + kind: 'out', + data, + address, + addressIndex, + }); + })} +
+
+
+ {this.getWithdrawals(data)} + {this.getCertificate(data)} + + {state === TxStatusCodes.IN_BLOCK && this.props.numberOfConfirmations != null && ( +
+

{intl.formatMessage(messages.assuranceLevel)}

+ + {status}.{' '} + {this.props.numberOfConfirmations}{' '} + {intl.formatMessage(messages.confirmations)}. + +
+ )} + +

{intl.formatMessage(globalMessages.transactionId)}

+ + + {data.txid} + + + + {this.getMetadata(data)} + {this.props.memo != null ? ( +
+

+ {intl.formatMessage(memoMessages.memoLabel)} + + +

+ + {this.props.memo?.Content} + +
+ ) : ( +
+
+ +
+
+ )} +
+
+
+
+ ); + } + + generateAddressButton: string => ?Node = address => { + const { intl } = this.context; + const addressInfo = this.props.addressLookup(address); + if (addressInfo == null) { + return ( +
+ {intl.formatMessage(globalMessages.processingLabel)} +
+ ); + } + return ( + + ); + }; + + jormungandrCertificateToText: ($ReadOnly) => string = certificate => { + const { intl } = this.context; + const kind = certificate.Kind; + switch (kind) { + case RustModule.WalletV3.CertificateKind.PoolRegistration: + return intl.formatMessage(jormungandrCertificateKinds.PoolRegistration); + case RustModule.WalletV3.CertificateKind.PoolUpdate: + return intl.formatMessage(jormungandrCertificateKinds.PoolUpdate); + case RustModule.WalletV3.CertificateKind.PoolRetirement: + return intl.formatMessage(jormungandrCertificateKinds.PoolRetirement); + case RustModule.WalletV3.CertificateKind.StakeDelegation: + return intl.formatMessage(jormungandrCertificateKinds.StakeDelegation); + case RustModule.WalletV3.CertificateKind.OwnerStakeDelegation: + return intl.formatMessage(jormungandrCertificateKinds.OwnerStakeDelegation); + default: { + throw new Error(`${nameof(this.jormungandrCertificateToText)} unexpected kind ${kind}`); + } + } + }; + + shelleyCertificateToText: ($ReadOnly) => string = certificate => { + const { intl } = this.context; + const kind = certificate.Kind; + switch (kind) { + case RustModule.WalletV4.CertificateKind.StakeRegistration: + return intl.formatMessage(shelleyCertificateKinds.StakeRegistration); + case RustModule.WalletV4.CertificateKind.StakeDeregistration: + return intl.formatMessage(shelleyCertificateKinds.StakeDeregistration); + case RustModule.WalletV4.CertificateKind.StakeDelegation: + return intl.formatMessage(shelleyCertificateKinds.StakeDelegation); + case RustModule.WalletV4.CertificateKind.PoolRegistration: + return intl.formatMessage(shelleyCertificateKinds.PoolRegistration); + case RustModule.WalletV4.CertificateKind.PoolRetirement: + return intl.formatMessage(shelleyCertificateKinds.PoolRetirement); + case RustModule.WalletV4.CertificateKind.GenesisKeyDelegation: + return intl.formatMessage(shelleyCertificateKinds.GenesisKeyDelegation); + case RustModule.WalletV4.CertificateKind.MoveInstantaneousRewardsCert: + return intl.formatMessage(shelleyCertificateKinds.MoveInstantaneousRewardsCert); + default: { + throw new Error(`${nameof(this.shelleyCertificateToText)} unexpected kind ${kind}`); + } + } + }; + + getWithdrawals: WalletTransaction => ?Node = data => { + const { intl } = this.context; + if (!(data instanceof CardanoShelleyTransaction)) { + return null; + } + if (data.withdrawals.length === 0) { + return null; + } + return ( +
+
+
+

+ {intl.formatMessage(globalMessages.withdrawalsLabel)}: + {data.withdrawals.length} +

+

{intl.formatMessage(messages.addressType)}

+

{intl.formatMessage(globalMessages.amountLabel)}

+
+
+ {data.withdrawals.map((address, addressIndex) => { + return this.renderRow({ + kind: 'withdrawal', + data, + address, + addressIndex, + }); + })} +
+
+
+
+ ); + }; + + getCertificate: WalletTransaction => ?Node = data => { + const { intl } = this.context; + + const wrapCertificateText = (node, manyCerts) => ( + <> +

+ {manyCerts + ? intl.formatMessage(messages.certificatesLabel) + : intl.formatMessage(messages.certificateLabel)} +

+ {node} + + ); + if (data instanceof JormungandrTransaction) { + if (data.certificates.length === 0) { + return null; + } + return wrapCertificateText( + this.jormungandrCertificateToText(data.certificates[0].certificate), + data.certificates.length > 1 + ); + } + if (data instanceof CardanoShelleyTransaction) { + if (data.certificates.length === 0) { + return null; + } + const certBlock = data.certificates.reduce((acc, curr, idx) => { + const newElem = ( + // eslint-disable-next-line react/no-array-index-key + + {acc.length !== 0 ?
: undefined} + {this.shelleyCertificateToText(curr.certificate)} +
+ ); + acc.push(newElem); + return acc; + }, ([]: Array)); + return wrapCertificateText(certBlock, data.certificates.length > 1); + } + }; + + getMetadata: WalletTransaction => ?Node = data => { + const { intl } = this.context; + + if (data instanceof CardanoShelleyTransaction && data.metadata !== null) { + let jsonData = null; + + try { + jsonData = parseMetadata(data.metadata); + } catch (error) { + // try to parse schema using detailed conversion if advanced user + if (this.props.complexityLevel === ComplexityLevels.Advanced) { + try { + jsonData = parseMetadataDetailed(data.metadata); + } catch (errDetailed) { + // discard error + // can not parse metadata as json + // show the metadata hex as is + } + } + // do nothing for simple user + } + + return ( +
+

{intl.formatMessage(messages.transactionMetadata)}

+ + {jsonData !== null ? : 0x{data.metadata}} + +
+ ); + } + return null; + }; +} diff --git a/packages/yoroi-extension/app/components/wallet/transactions/WalletNoTransactionsRevamp.js b/packages/yoroi-extension/app/components/wallet/transactions/WalletNoTransactionsRevamp.js new file mode 100644 index 0000000000..4352ae57f4 --- /dev/null +++ b/packages/yoroi-extension/app/components/wallet/transactions/WalletNoTransactionsRevamp.js @@ -0,0 +1,47 @@ +// @flow +import { Component } from 'react'; +import type { Node } from 'react'; +import { observer } from 'mobx-react'; +import NoTransactionClassicSvg from '../../../assets/images/transaction/no-transactions-yet.classic.inline.svg'; +import NoTransactionModernSvg from '../../../assets/images/transaction/no-transactions-yet.modern.inline.svg'; +import { Box, Typography } from '@mui/material'; + +type Props = {| + +label: string, + +classicTheme: boolean, +|}; + +@observer +export default class WalletNoTransactionsRevamp extends Component { + render(): Node { + const { classicTheme } = this.props; + const NoTransactionSvg = classicTheme ? NoTransactionClassicSvg : NoTransactionModernSvg; + return ( + + + + + + {this.props.label} + + + ); + } +} diff --git a/packages/yoroi-extension/app/components/wallet/transactions/WalletTransactionsListRevamp.js b/packages/yoroi-extension/app/components/wallet/transactions/WalletTransactionsListRevamp.js new file mode 100644 index 0000000000..82b7a96fec --- /dev/null +++ b/packages/yoroi-extension/app/components/wallet/transactions/WalletTransactionsListRevamp.js @@ -0,0 +1,218 @@ +// @flow +import { Component } from 'react'; +import type { Node } from 'react'; +import { observer } from 'mobx-react'; +import { defineMessages, intlShape } from 'react-intl'; +import { Button, Typography } from '@mui/material'; +import moment from 'moment'; +import styles from './WalletTransactionsList.scss'; +import WalletTransaction from '../../../domain/WalletTransaction'; +import LoadingSpinner from '../../widgets/LoadingSpinner'; +import type { AssuranceMode } from '../../../types/transactionAssuranceTypes'; +import { Logger } from '../../../utils/logging'; +import { SelectedExplorer } from '../../../domain/SelectedExplorer'; +import type { UnitOfAccountSettingType } from '../../../types/unitOfAccountType'; +import globalMessages from '../../../i18n/global-messages'; +import type { TxMemoTableRow } from '../../../api/ada/lib/storage/database/memos/tables'; +import type { $npm$ReactIntl$IntlFormat } from 'react-intl'; +import type { Notification } from '../../../types/notificationType'; +import { genAddressLookup } from '../../../stores/stateless/addressStores'; +import type { TokenLookupKey } from '../../../api/common/lib/MultiToken'; +import type { TokenRow } from '../../../api/ada/lib/storage/database/primitives/tables'; +import type { ComplexityLevelType } from '../../../types/complexityLevelType'; +import TransactionRevamp from './TransactionRevamp'; +import { Box } from '@mui/system'; + +const messages = defineMessages({ + showMoreTransactionsButtonLabel: { + id: 'wallet.summary.page.showMoreTransactionsButtonLabel', + defaultMessage: '!!!Show more transactions', + }, +}); + +const dateFormat = 'YYYY-MM-DD'; + +type Props = {| + +transactions: Array, + +lastSyncBlock: number, + +memoMap: Map>, + +isLoadingTransactions: boolean, + +hasMoreToLoad: boolean, + +selectedExplorer: SelectedExplorer, + +assuranceMode: AssuranceMode, + +onLoadMore: void => PossiblyAsync, + +shouldHideBalance: boolean, + +onAddMemo: WalletTransaction => void, + +onEditMemo: WalletTransaction => void, + +unitOfAccountSetting: UnitOfAccountSettingType, + +getCurrentPrice: (from: string, to: string) => ?number, + +addressLookup: ReturnType, + +onCopyAddressTooltip: (string, string) => void, + +notification: ?Notification, + +addressToDisplayString: string => string, + +getTokenInfo: ($ReadOnly>) => $ReadOnly, + +complexityLevel: ?ComplexityLevelType, +|}; + +@observer +export default class WalletTransactionsListRevamp extends Component { + static contextTypes: {| intl: $npm$ReactIntl$IntlFormat |} = { + intl: intlShape.isRequired, + }; + + // eslint-disable-next-line camelcase + UNSAFE_componentWillMount(): void { + this.localizedDateFormat = moment.localeData().longDateFormat('LL'); + // Localized dateFormat: + // English - MM/DD/YYYY + // Japanese - YYYY/MM/DD + } + + list: HTMLElement; + loadingSpinner: ?LoadingSpinner; + localizedDateFormat: 'MM/DD/YYYY'; + + groupTransactionsByDay( + transactions: Array + ): Array<{| + date: string, + transactions: Array, + |}> { + const groups: Array<{| + date: string, + transactions: Array, + |}> = []; + for (const transaction of transactions) { + const date: string = moment(transaction.date).format(dateFormat); + // find the group this transaction belongs in + let group = groups.find(g => g.date === date); + // if first transaction in this group, create the group + if (!group) { + group = { date, transactions: [] }; + groups.push(group); + } + group.transactions.push(transaction); + } + return groups.sort( + (a, b) => b.transactions[0].date.getTime() - a.transactions[0].date.getTime() + ); + } + + localizedDate(date: string): string { + const { intl } = this.context; + const today = moment().format(dateFormat); + if (date === today) return intl.formatMessage(globalMessages.dateToday); + const yesterday = moment().subtract(1, 'days').format(dateFormat); + if (date === yesterday) return intl.formatMessage(globalMessages.dateYesterday); + return moment(date).format(this.localizedDateFormat); + } + + getTransactionKey(transactions: Array): string { + if (transactions.length) { + const firstTransaction = transactions[0]; + return firstTransaction.uniqueKey; + } + // this branch should not happen + Logger.error( + '[WalletTransactionsList::getTransactionKey] tried to render empty transaction group' + ); + return ''; + } + + render(): Node { + const { intl } = this.context; + const { + transactions, + isLoadingTransactions, + hasMoreToLoad, + assuranceMode, + onLoadMore, + onAddMemo, + onEditMemo, + notification, + onCopyAddressTooltip, + } = this.props; + + const transactionsGroups = this.groupTransactionsByDay(transactions); + + const loadingSpinner = isLoadingTransactions ? ( +
+ { + this.loadingSpinner = component; + }} + /> +
+ ) : null; + + return ( + + {transactionsGroups.map(group => ( + + + {this.localizedDate(group.date)} + + + {group.transactions.map((transaction, transactionIndex) => ( + + ))} + + + ))} + {loadingSpinner} + {!isLoadingTransactions && hasMoreToLoad && ( + + )} + + ); + } +} diff --git a/packages/yoroi-extension/app/containers/wallet/WalletSummaryPage.js b/packages/yoroi-extension/app/containers/wallet/WalletSummaryPage.js index ab8d67de16..06c815b890 100644 --- a/packages/yoroi-extension/app/containers/wallet/WalletSummaryPage.js +++ b/packages/yoroi-extension/app/containers/wallet/WalletSummaryPage.js @@ -13,8 +13,10 @@ import globalMessages from '../../i18n/global-messages'; import successIcon from '../../assets/images/success-small.inline.svg'; import type { InjectedOrGenerated } from '../../types/injectedPropsType'; import WalletTransactionsList from '../../components/wallet/transactions/WalletTransactionsList'; +import WalletTransactionsListRevamp from '../../components/wallet/transactions/WalletTransactionsListRevamp'; import WalletSummary from '../../components/wallet/summary/WalletSummary'; import WalletNoTransactions from '../../components/wallet/transactions/WalletNoTransactions'; +import WalletNoTransactionsRevamp from '../../components/wallet/transactions/WalletNoTransactionsRevamp'; import VerticalFlexContainer from '../../components/layout/VerticalFlexContainer'; import ExportTransactionDialog from '../../components/wallet/export/ExportTransactionDialog'; import AddMemoDialog from '../../components/wallet/memos/AddMemoDialog'; @@ -50,11 +52,14 @@ import type { ComplexityLevelType } from '../../types/complexityLevelType'; import { withLayout } from '../../styles/context/layout'; import type { LayoutComponentMap } from '../../styles/context/layout'; import WalletSummaryRevamp from '../../components/wallet/summary/WalletSummaryRevamp'; +import BuySellDialog from '../../components/buySell/BuySellDialog'; +import WalletEmptyBanner from './WalletEmptyBanner'; export type GeneratedData = typeof WalletSummaryPage.prototype.generated; type Props = InjectedOrGenerated; type InjectedProps = {| +renderLayoutComponent: LayoutComponentMap => Node, + +selectedLayout: string, |}; type AllProps = {| ...Props, ...InjectedProps |}; @@ -127,12 +132,25 @@ class WalletSummaryPage extends Component { if (searchOptions) { const { limit } = searchOptions; const noTransactionsFoundLabel = intl.formatMessage(globalMessages.noTransactionsFound); + + const mapWalletTransactionLayout = { + CLASSIC: WalletTransactionsList, + REVAMP: WalletTransactionsListRevamp, + }; + const mapWalletNoTransactionsLayout = { + CLASSIC: WalletNoTransactions, + REVAMP: WalletNoTransactionsRevamp, + }; + const WalletTransactionsListComp = mapWalletTransactionLayout[this.props.selectedLayout]; + const WalletNoTransactionsComp = mapWalletNoTransactionsLayout[this.props.selectedLayout]; + if (!recentTransactionsRequest.wasExecuted || hasAny) { const { assuranceMode, } = this.generated.stores.walletSettings.getPublicDeriverSettingsCache(publicDeriver); + walletTransactions = ( - { ); } else { walletTransactions = ( - @@ -238,7 +256,6 @@ class WalletSummaryPage extends Component { /> )} - { /> )} - + {!recentTransactionsRequest.wasExecuted || hasAny ? null : ( + + this.generated.actions.dialogs.open.trigger({ dialog: BuySellDialog }) + } + /> + )} { params?: any, |}) => void, |}, + open: {| + trigger: (params: {| + dialog: any, + params?: any, + |}) => void, + |}, |}, memos: {| closeMemoDialog: {| @@ -541,6 +572,9 @@ class WalletSummaryPage extends Component { |}, |}, transactions: {| + closeWalletEmptyBanner: {| + trigger: (params: void) => void, + |}, closeExportTransactionDialog: {| trigger: (params: void) => void, |}, @@ -583,6 +617,7 @@ class WalletSummaryPage extends Component { |}, transactions: {| exportError: ?LocalizableError, + showWalletEmptyBanner: boolean, hasAny: boolean, isExporting: boolean, lastSyncInfo: IGetLastSyncInfoResponse, @@ -657,6 +692,7 @@ class WalletSummaryPage extends Component { txMemoMap: stores.memos.txMemoMap, }, transactions: { + showWalletEmptyBanner: stores.transactions.showWalletEmptyBanner, hasAny: stores.transactions.hasAny, totalAvailable: stores.transactions.totalAvailable, recent: stores.transactions.recent, @@ -690,6 +726,9 @@ class WalletSummaryPage extends Component { push: { trigger: actions.dialogs.push.trigger, }, + open: { + trigger: actions.dialogs.open.trigger, + }, }, router: { goToRoute: { trigger: actions.router.goToRoute.trigger }, @@ -704,6 +743,9 @@ class WalletSummaryPage extends Component { selectTransaction: { trigger: actions.memos.selectTransaction.trigger }, }, transactions: { + closeWalletEmptyBanner: { + trigger: actions.transactions.closeWalletEmptyBanner.trigger, + }, exportTransactionsToFile: { trigger: actions.transactions.exportTransactionsToFile.trigger, },