diff --git a/src/components/controls/Text.js b/src/components/controls/Text.js index a9cdd1c..e09e6db 100644 --- a/src/components/controls/Text.js +++ b/src/components/controls/Text.js @@ -37,6 +37,9 @@ export default class C extends Component { case 'regular': globalStyle = { ...GlobalStyles.text.regular }; break; + case 'regular-compact': + globalStyle = { ...GlobalStyles.text.regularCompact }; + break; case 'title-small': globalStyle = { ...GlobalStyles.text.titleSmall }; break; diff --git a/src/components/organisms/AggregateTransactionDetails.js b/src/components/organisms/AggregateTransactionDetails.js index 04b7a5e..98a2086 100644 --- a/src/components/organisms/AggregateTransactionDetails.js +++ b/src/components/organisms/AggregateTransactionDetails.js @@ -1,17 +1,14 @@ import React, { Component } from 'react'; import { connect } from 'react-redux'; -import { BackHandler, Dimensions, FlatList, StyleSheet, TouchableOpacity, View } from 'react-native'; +import { BackHandler, FlatList, StyleSheet, TouchableOpacity, View } from 'react-native'; import { Button, Checkbox, FadeView, - Icon, - Input, LinkExplorer, ListContainer, ListItem, LoadingAnimationFlexible, - RandomImage, Row, Section, SwipeablePanel, @@ -22,15 +19,13 @@ import { } from '@src/components'; import store from '@src/store'; import TransactionService from '@src/services/TransactionService'; -import type { AggregateTransactionModel } from '@src/storage/models/TransactionModel'; +import { Router } from '@src/Router'; import { showPasscode } from '@src/utils/passcode'; import { transactionAwaitingSignatureByAccount } from '@src/utils/transaction'; import translate from '@src/locales/i18n'; import GlobalStyles from '@src/styles/GlobalStyles'; import _ from 'lodash'; -const FULL_HEIGHT = Dimensions.get('window').height; - const styles = StyleSheet.create({ panel: { backgroundColor: GlobalStyles.color.DARKWHITE, @@ -53,10 +48,7 @@ const styles = StyleSheet.create({ paddingTop: 16, paddingBottom: 16, }, - table: { - marginTop: 20, - }, - contentBottom: { + graphicItemLast: { marginBottom: 18, }, graphicItem: { @@ -72,164 +64,145 @@ const styles = StyleSheet.create({ shadowRadius: 1, elevation: 1, }, - acceptanceForm: { + signFormContainer: { + flex: null, + }, + signFormRegular: { backgroundColor: GlobalStyles.color.SECONDARY, paddingTop: 17, flex: null, }, - signFormContainer: { + signFormWarning: { backgroundColor: GlobalStyles.color.ORANGE, + paddingTop: 17, + flex: null, }, - randomImage: { - width: '100%', - height: FULL_HEIGHT / 4, - resizeMode: 'cover', - flexDirection: 'column', - justifyContent: 'center', + linkText: { + textAlign: 'center', + textDecorationLine: 'underline', + opacity: 0.7, }, - textCaution: { - textShadowColor: GlobalStyles.color.SECONDARY, - textShadowOffset: { width: 0, height: 1 }, - textShadowRadius: 15, + loadingView: { + position: 'relative', + height: '80%', }, - signForm: { - backgroundColor: GlobalStyles.color.ORANGE, - paddingTop: 17, - flex: null, + warningIcon: { + fontSize: 8, + lineHeight: 8, + borderRadius: 7, + backgroundColor: GlobalStyles.color.RED, + color: GlobalStyles.color.WHITE, + paddingTop: 3, + paddingBottom: 1, + paddingHorizontal: 5, + marginLeft: 5, }, }); -type Props = { - transaction: AggregateTransactionModel, -}; - -class AggregateTransactionDetails extends Component { +class AggregateTransactionDetails extends Component { state = { - fullTransaction: null, + transactionDetails: null, isActive: false, isLoading: false, - expandGraphic: false, - showLastWarning: false, - userUnderstand: false, - showBlacklistForm: false, - needsSignature: false, - blacklistAccountName: '', + isGraphicExpanded: false, + isSignFormShown: false, + isBlackListedSigner: false, + signerAddress: '', + signFormView: '', selectedTab: 'innerTransactions', }; backHandler = BackHandler.addEventListener('hardwareBackPress', () => { this.backHandler.remove(); - this.onClose(); + this.close(); return true; }); componentDidMount() { - const { selectedNode, transaction } = this.props; + const { + addressBook, + cosignatoryOf, + isMultisig, + multisigTreeAccounts, + onError, + selectedAccount, + selectedNode, + transaction, + } = this.props; this.setState({ isActive: true, isLoading: true, - fullTransaction: null, + transactionDetails: null, }); TransactionService.getTransactionDetails(transaction.hash, selectedNode) - .then(async tx => { - const needsSignature = this.needsSignature(); + .then(async transactionDetails => { + const { signerAddress } = transactionDetails; + const isAwaitingSignature = + !isMultisig && transactionAwaitingSignatureByAccount(transaction, selectedAccount, cosignatoryOf); + const signerContact = addressBook.getContactByAddress(signerAddress); + const isBlackListedSigner = signerContact && signerContact.isBlackListed; + const isWhiteListedSigner = signerContact && !isBlackListedSigner; + const isKnownSigner = + !isBlackListedSigner && !!multisigTreeAccounts.find(account => account.accountAddress.plain() === signerAddress); + let signFormView; + + if (isBlackListedSigner) { + signFormView = 'blocked_signer_initial'; + } else if (isWhiteListedSigner) { + signFormView = 'trusted_signer_initial'; + } else if (isKnownSigner) { + signFormView = 'known_signer_initial'; + } else { + signFormView = 'unknown_signer_initial'; + } this.setState({ - needsSignature, - expandGraphic: needsSignature, - fullTransaction: tx, + isSignFormShown: isAwaitingSignature, + isGraphicExpanded: isAwaitingSignature, + isBlackListedSigner, + signerAddress, + signFormView, + transactionDetails, isLoading: false, }); }) - .catch(e => { - console.error(e); - this.setState({ - isActive: false, - isLoading: false, + .catch(error => { + Router.showMessage({ + message: error.message, + type: 'danger', }); + this.close(); + onError(error); }); } - sign() { - showPasscode(this.props.componentId, () => { - const { transaction } = this.props; - store - .dispatchAction({ - type: 'transfer/signAggregateBonded', - payload: transaction, - }) - .then(() => { - store.dispatchAction({ - type: 'transaction/changeFilters', - payload: {}, - }); - }); - }); - } - - needsSignature = () => { - const { transaction, selectedAccount, isMultisig, cosignatoryOf } = this.props; - return !isMultisig && transactionAwaitingSignatureByAccount(transaction, selectedAccount, cosignatoryOf); - }; - - onClose() { + close() { const { onClose } = this.props; - this.setState({ - isActive: false, - }); - setTimeout(() => { - onClose(); - }, 200); - } - - renderTransactionItem({ index, item }) { - return ( - - - - ); + this.backHandler.remove(); + this.setState({ isActive: false }); + setTimeout(onClose, 200); } - renderGraphicItem = (expand, count) => ({ index, item }) => { - return ( -
- - - -
- ); - }; - renderTabInnerTransactions() { - const { isLoading, fullTransaction } = this.state; - - return ( - - - {fullTransaction && ( - '' + index + 'details'} - contentContainerStyle={{ flexGrow: 1 }} - /> - )} - - - ); - } + const { isGraphicExpanded, transactionDetails } = this.state; + const transactionCount = transactionDetails.innerTransactions.length; - renderTabGraphic() { - const { fullTransaction, expandGraphic } = this.state; + const getItemStyle = index => (index === transactionCount - 1 ? styles.graphicItemLast : {}); return ( - {fullTransaction && ( + {transactionDetails && ( ( +
+ + + +
+ )} keyExtractor={(item, index) => '' + index + 'details'} contentContainerStyle={{ flexGrow: 1 }} /> @@ -239,19 +212,19 @@ class AggregateTransactionDetails extends Component { } renderTabInfo() { - const { isLoading, fullTransaction } = this.state; - const tabledata = _.omit(fullTransaction, ['innerTransactions', 'signTransactionObject', 'cosignaturePublicKeys', 'fee']); + const { transactionDetails, isLoading } = this.state; + const tabledata = _.omit(transactionDetails, ['innerTransactions', 'signTransactionObject', 'cosignaturePublicKeys', 'fee']); return ( - - {fullTransaction && ( + + {transactionDetails && ( <> - + )} @@ -260,128 +233,194 @@ class AggregateTransactionDetails extends Component { ); } - renderSign() { - const { showLastWarning, userUnderstand, showBlacklistForm, blacklistAccountName } = this.state; + renderSignForm() { + const { addressBook, componentId, transaction } = this.props; + const { signerAddress, signFormView, isRiskAccepted } = this.state; - if (!showLastWarning && !showBlacklistForm) { - return ( -
-
- {translate('history.cosignFormTitleRequireSignature')} + const goToUnknownSignerOptions = () => + this.setState({ + signFormView: 'unknown_signer_options', + }); + const goToKnownSignerInitial = () => + this.setState({ + signFormView: 'known_signer_initial', + isGraphicExpanded: true, + }); + const goToKnownSignerConfirm = () => + this.setState({ + signFormView: 'known_signer_confirm', + isRiskAccepted: false, + }); + const goToContactProfile = async () => { + this.close(); + const contact = addressBook.getContactByAddress(signerAddress); + await store.dispatchAction({ type: 'addressBook/selectContact', payload: contact }); + Router.goToContactProfile({}, componentId); + }; + const blockSigner = async () => { + this.close(); + await store.dispatchAction({ type: 'addressBook/selectContact', payload: null }); + Router.goToAddContact( + { + address: signerAddress, + isBlackListed: true, + }, + componentId + ); + }; + const signTransaction = () => { + showPasscode(componentId, async () => { + await store.dispatchAction({ type: 'transfer/signAggregateBonded', payload: transaction }); + store.dispatchAction({ type: 'transaction/changeFilters', payload: {} }); + this.close(); + }); + }; + + switch (signFormView) { + case 'unknown_signer_initial': + return ( +
+
+ + {translate('history.cosignFormTitleRequireSignatureUnknown')} + + ! + + +
+
+
-
-
*/} -
- ); - } else if (showBlacklistForm) { - return ( -
-
- this.setState({ showBlacklistForm: false })}> + ); + + case 'known_signer_initial': + return ( +
+
- - {translate('history.cosignFormTitleBlacklist')} + {translate('history.cosignFormTitleRequireSignatureUnknown')} + + ! + +
+
+
+ + + {translate('history.cosignFormButtonBlacklist')} +
-
- this.setState({ blacklistAccountName: value })} - /> + ); + + case 'known_signer_confirm': + return ( + // +
+
+ {translate('history.cosignFormTitleLastWarning')} +
+
+ this.setState({ isRiskAccepted: value })} + /> +
+
+
+ + + {translate('history.cosignFormButtonBack')} + +
-
-
-
- ); - } else { - return ( - - - - {translate('history.caution')} - - -
-
-
- {translate('history.cosignFormTitleLastWarning')} -
-
- this.setState({ userUnderstand: value })} - /> -
-
-
-
-
+ ); + + case 'blocked_signer_initial': + return ( +
+
+ {translate('history.cosignFormTitleRequireSignature')} +
+
+ + {translate('history.cosignFormBlockedSignerExplanation')} + +
+
+ + + {translate('history.cosignFormButtonViewContact')} + +
- - ); + ); + + default: + return null; } } render() { - const { isActive, showLastWarning, isLoading, selectedTab, fullTransaction, needsSignature } = this.state; - let Content = null; - - switch (selectedTab) { - default: - case 'innerTransactions': - Content = this.renderTabGraphic(); - break; - case 'info': - Content = this.renderTabInfo(); - break; - } + const { isActive, isLoading, isSignFormShown, selectedTab, showLastWarning, transactionDetails } = this.state; + const isContentLoaded = !isLoading && transactionDetails; + const isTabInnerTransactionsShown = isContentLoaded && selectedTab === 'innerTransactions'; + const isTabInfoShown = isContentLoaded && selectedTab === 'info'; return ( { fullWidth openLarge onlyLarge - onClose={() => this.onClose()} - onPressCloseButton={() => this.onClose()} - style={{ backgroundColor: '#f3f4f8' }} + onClose={() => this.close()} + onPressCloseButton={() => this.close()} + style={styles.panel} > - this.onClose()} /> + this.close()} /> {!showLastWarning && ( { )} - {(isLoading || !fullTransaction) && ( - - )} - {!showLastWarning && !isLoading && fullTransaction && Content} - {needsSignature && !isLoading && this.renderSign()} + {!isContentLoaded && } + {isTabInnerTransactionsShown && this.renderTabInnerTransactions()} + {isTabInfoShown && this.renderTabInfo()} + {isSignFormShown && this.renderSignForm()} ); } } export default connect(state => ({ - selectedNode: state.network.selectedNetwork, + address: state.account.selectedAccountAddress, + addressBook: state.addressBook.addressBook, + cosignatoryOf: state.account.cosignatoryOf, isLoading: state.transfer.isLoading, - selectedAccount: state.wallet.selectedAccount, isMultisig: state.account.isMultisig, - cosignatoryOf: state.account.cosignatoryOf, + multisigTreeAccounts: state.account.multisigTreeAccounts, network: state.network.selectedNetwork.type, - address: state.account.selectedAccountAddress, + selectedAccount: state.wallet.selectedAccount, + selectedNode: state.network.selectedNetwork, }))(AggregateTransactionDetails); diff --git a/src/locales/translations/en.json b/src/locales/translations/en.json index 20df431..f35a4b6 100644 --- a/src/locales/translations/en.json +++ b/src/locales/translations/en.json @@ -625,19 +625,20 @@ "innerTransactionTab": "Inner Transactions", "graphicTab": "Graphic", "infoTransactionTab": "About Transaction", - "cosignFormTitleRequireSignatureTrust": "Transaction requires signature. Do you trust the signer address of this transaction?", - "cosignFormTitleRequireSignature": "Transaction requires signature", - "cosignFormButtonTrust": "Trust", - "cosignFormButtonContinue": "Proceed to sign", - "cosignFormButtonMarkSpam": "Mark as spam", - "cosignFormTitleBlacklist": "Blacklist this address?", - "cosignFormInputNote": "Note", - "cosignFormButtonBlacklist": "Blacklist", - "cosignFormButtonArchive": "Archive", - "cosignFormTitleLastWarning": "You are about to sign this transaction. Please review it carefully and sign it ONLY if you understand it. Otherwise, it can lead to the loss of all of your funds.", - "cosignFormCheckbox": "I acknowledge that I have reviewed and understand the transaction's details and its inner transactions", - "cosignFormButtonReviewAgain": "Review transaction again", - "caution": "CAUTION" + "cosignFormTitleRequireSignature": "Transaction awaiting signature.", + "cosignFormTitleRequireSignatureUnknown": "Unknown transaction awaiting signature.", + "cosignFormUnknownSignerCaution": "Caution: Malicious transactions can result in a total loss of funds.", + "cosignFormUnknownSignerExplanation": "This transaction was created by an unknown address and is awaiting signature. Please carefully review all amounts and recipient addresses as transactions are not reversible. If you understand the risks and wish to continue, please add the sender (signer) to the address book whitelist. If the transaction looks suspicious, please block the sender.", + "cosignFormTitleLastWarning": "You are about to sign a transaction that was not created by you. Malicious transactions can result in a total loss of funds. Please carefully review all amounts and recipient addresses, as transactions are not reversible.", + "cosignFormBlockedSignerExplanation": "Sender (signer) of this thransaction is on your blacklist. If you wish to accept this transaction, please remove this contact from your blacklist.", + "cosignFormCheckbox": "I understand the risks and wish to continue.", + "cosignFormButtonWhitelist": "Add to whitelist", + "cosignFormButtonBlacklist": "Block sender", + "cosignFormButtonViewContact": "View contact", + "cosignFormButtonContinue": "Continue", + "cosignFormButtonGoToSign": "Proceed to sign", + "cosignFormButtonBack": "Back" + }, "harvest": { "title": "Harvesting", diff --git a/src/screens/History.js b/src/screens/History.js index 60153fb..fc52b63 100644 --- a/src/screens/History.js +++ b/src/screens/History.js @@ -180,7 +180,12 @@ class History extends Component { /> {currentTransaction && this.isAggregate(currentTransaction) && ( - this.showDetails(-1)} {...this.props} /> + this.showDetails(-1)} + onError={this.onRefresh} + {...this.props} + /> )} ); diff --git a/src/store/account.js b/src/store/account.js index 55711d0..1a2e0f9 100644 --- a/src/store/account.js +++ b/src/store/account.js @@ -1,9 +1,8 @@ import AccountService from '@src/services/AccountService'; import NamespaceService from '@src/services/NamespaceService'; -import { of } from 'rxjs'; import { GlobalListener } from '@src/store/index'; +import { getMultisigInfoListFromMultisigGraphInfo } from '@src/utils/account'; import { Address, RepositoryFactoryHttp } from 'symbol-sdk'; -import { catchError, map } from 'rxjs/operators'; import _ from 'lodash'; export default { @@ -19,6 +18,8 @@ export default { accounts: [], cosignatoryOf: [], pendingSignature: false, + multisigTreeAccounts: [], + multisigTree: null, multisigGraphInfo: [], names: [], }, @@ -51,6 +52,14 @@ export default { state.account.cosignatoryOf = payload; return state; }, + setMultisigTreeAccounts(state, payload) { + state.account.multisigTreeAccounts = payload; + return state; + }, + setMultisigTree(state, payload) { + state.account.multisigTree = payload; + return state; + }, setMultisigGraphInfo(state, payload) { state.account.multisigGraphInfo = payload; return state; @@ -139,27 +148,43 @@ export default { // load multisig tree data loadMultisigTree: async ({ commit, state }) => { const address = AccountService.getAddressByAccountModelAndNetwork(state.wallet.selectedAccount, state.network.network); - const repositoryFactory = new RepositoryFactoryHttp(state.network.selectedNode); - const multisigRepo = repositoryFactory.createMultisigRepository(); - await multisigRepo - .getMultisigAccountGraphInfo(Address.createFromRawAddress(address)) - .pipe( - map(g => { - commit({ - type: 'account/setMultisigGraphInfo', - payload: g.multisigEntries, - }); - return of(g); - }), - catchError(() => { - commit({ - type: 'account/setMultisigGraphInfo', - payload: undefined, - }); - return of([]); - }) - ) - .toPromise(); + const multisigRepository = new RepositoryFactoryHttp(state.network.selectedNode).createMultisigRepository(); + + try { + const multisigAccountGraphInfo = await multisigRepository + .getMultisigAccountGraphInfo(Address.createFromRawAddress(address)) + .toPromise(); + + const multisigTree = new Map(multisigAccountGraphInfo.multisigEntries); + + for (const [currentLevel, multisigAccountInfos] of multisigAccountGraphInfo.multisigEntries) { + if (currentLevel < 0) { + for (const multisigAccountInfo of multisigAccountInfos) { + const fetchedMultisigAccountGraphInfo = await multisigRepository + .getMultisigAccountGraphInfo(multisigAccountInfo.accountAddress) + .toPromise(); + + fetchedMultisigAccountGraphInfo.multisigEntries.forEach((fetchedMultisigAccountInfos, fetchedLevel) => { + const currentMultisigAccountInfos = multisigTree.get(currentLevel + fetchedLevel) || []; + const newMultisigAccountInfos = [...currentMultisigAccountInfos, ...fetchedMultisigAccountInfos]; + const filteredMultisigAccountInfos = _.uniqBy(newMultisigAccountInfos, item => item.accountAddress.plain()); + + multisigTree.set(currentLevel + fetchedLevel, filteredMultisigAccountInfos); + }); + } + } + } + const multisigTreeAccounts = getMultisigInfoListFromMultisigGraphInfo({ + multisigEntries: multisigTree, + }); + commit({ type: 'account/setMultisigTreeAccounts', payload: multisigTreeAccounts }); + commit({ type: 'account/setMultisigTree', payload: multisigTree }); + commit({ type: 'account/setMultisigGraphInfo', payload: multisigAccountGraphInfo.multisigEntries }); + } catch { + commit({ type: 'account/setMultisigTreeAccounts', payload: [] }); + commit({ type: 'account/setMultisigTree', payload: null }); + commit({ type: 'account/setMultisigGraphInfo', payload: undefined }); + } }, loadAccountNames: async ({ commit, state }) => { const address = Address.createFromRawAddress( diff --git a/src/styles/GlobalStyles.js b/src/styles/GlobalStyles.js index d449299..54511bf 100644 --- a/src/styles/GlobalStyles.js +++ b/src/styles/GlobalStyles.js @@ -91,6 +91,14 @@ export default { opacity: 0.6, }, + regularCompact: { + color: COLORS.DARKWHITE, + fontFamily: 'NotoSans-Regular', + fontSize: 12, + lineHeight: 16, + opacity: 0.6, + }, + bold: { color: COLORS.WHITE, fontFamily: 'NotoSans-SemiBold', diff --git a/src/utils/account.js b/src/utils/account.js index 79186f0..70ce064 100644 --- a/src/utils/account.js +++ b/src/utils/account.js @@ -12,3 +12,13 @@ export const isPrivateKeyValid = (privateKey: string): boolean => { return false; } }; + +export const getMultisigInfoListFromMultisigGraphInfo = multisigAccountGraphInfo => { + const { multisigEntries } = multisigAccountGraphInfo; + const levels = [...multisigEntries.keys()].sort((a, b) => b - a); + + return levels + .map(level => multisigEntries.get(level) || []) + .filter(multisigInfoList => multisigInfoList.length > 0) + .flat(); +};