diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json
index 7748b2764..c351b97af 100644
--- a/app/_locales/en/messages.json
+++ b/app/_locales/en/messages.json
@@ -4933,6 +4933,9 @@
"viewOnOpensea": {
"message": "View on Opensea"
},
+ "viewinCustodianApp": {
+ "message": "View in custodian app"
+ },
"viewinExplorer": {
"message": "View $1 in explorer",
"description": "$1 is the action type. e.g (Account, Transaction, Swap)"
diff --git a/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.js b/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.js
index 493b461b8..7393cd6f4 100644
--- a/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.js
+++ b/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.js
@@ -11,6 +11,11 @@ import Button from '../../ui/button';
import Tooltip from '../../ui/tooltip';
import CancelButton from '../cancel-button';
import Popover from '../../ui/popover';
+///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
+import Box from '../../ui/box/box';
+import { Icon, IconName, Text } from '../../component-library';
+import { IconColor } from '../../../helpers/constants/design-system';
+///: END:ONLY_INCLUDE_IN
import { SECOND } from '../../../../shared/constants/time';
import { MetaMetricsEventCategory } from '../../../../shared/constants/metametrics';
import { TransactionType } from '../../../../shared/constants/transaction';
@@ -52,10 +57,18 @@ export default class TransactionListItemDetails extends PureComponent {
isCustomNetwork: PropTypes.bool,
history: PropTypes.object,
blockExplorerLinkText: PropTypes.object,
+ ///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
+ getCustodianTransactionDeepLink: PropTypes.func,
+ selectedIdentity: PropTypes.object,
+ transactionNote: PropTypes.string,
+ ///: END:ONLY_INCLUDE_IN
};
state = {
justCopied: false,
+ ///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
+ custodyTransactionDeepLink: null,
+ ///: END:ONLY_INCLUDE_IN
};
handleBlockExplorerClick = () => {
@@ -124,16 +137,57 @@ export default class TransactionListItemDetails extends PureComponent {
};
componentDidMount() {
- const { recipientAddress, tryReverseResolveAddress } = this.props;
+ const {
+ recipientAddress,
+ tryReverseResolveAddress,
+ ///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
+ selectedIdentity,
+ transactionGroup,
+ ///: END:ONLY_INCLUDE_IN
+ } = this.props;
+
+ ///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
+ this._mounted = true;
+ const address = selectedIdentity?.address;
+ const custodyId = transactionGroup?.primaryTransaction?.custodyId;
+
+ if (this._mounted && address && custodyId) {
+ this.getCustodianTransactionDeepLink(address, custodyId);
+ }
+ ///: END:ONLY_INCLUDE_IN
if (recipientAddress) {
tryReverseResolveAddress(recipientAddress);
}
}
+ ///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
+ getCustodianTransactionDeepLink = async (address, custodyId) => {
+ const { getCustodianTransactionDeepLink } = this.props;
+
+ const custodyTransactionDeepLink = await getCustodianTransactionDeepLink(
+ address,
+ custodyId,
+ );
+
+ if (custodyTransactionDeepLink && this._mounted) {
+ this.setState({ custodyTransactionDeepLink });
+ }
+ };
+
+ componentWillUnmount() {
+ this._mounted = false;
+ }
+ ///: END:ONLY_INCLUDE_IN
+
render() {
const { t } = this.context;
- const { justCopied } = this.state;
+ const {
+ justCopied,
+ ///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
+ custodyTransactionDeepLink,
+ ///: END:ONLY_INCLUDE_IN
+ } = this.state;
const {
transactionGroup,
primaryCurrency,
@@ -152,6 +206,9 @@ export default class TransactionListItemDetails extends PureComponent {
showCancel,
transactionStatus: TransactionStatus,
blockExplorerLinkText,
+ ///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
+ transactionNote,
+ ///: END:ONLY_INCLUDE_IN
} = this.props;
const {
primaryTransaction: transaction,
@@ -229,6 +286,30 @@ export default class TransactionListItemDetails extends PureComponent {
+ {
+ ///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
+ custodyTransactionDeepLink &&
+ custodyTransactionDeepLink.url && (
+
+
+
+ )
+ ///: END:ONLY_INCLUDE_IN
+ }
@@ -281,6 +362,20 @@ export default class TransactionListItemDetails extends PureComponent {
primaryCurrency={primaryCurrency}
className="transaction-list-item-details__transaction-breakdown"
/>
+ {
+ ///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
+ transactionNote && transactionNote.length !== 0 && (
+
+
+ {t('transactionNote')}
+
+
+ {transactionNote}
+
+
+ )
+ ///: END:ONLY_INCLUDE_IN
+ }
{transactionGroup.initialTransaction.type !==
TransactionType.incoming && (
diff --git a/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.test.js b/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.test.js
index c2781102f..db0f05e27 100644
--- a/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.test.js
+++ b/ui/components/app/transaction-list-item-details/transaction-list-item-details.component.test.js
@@ -1,6 +1,7 @@
import React from 'react';
import configureMockStore from 'redux-mock-store';
import thunk from 'redux-thunk';
+import { waitFor } from '@testing-library/react';
import { TransactionStatus } from '../../../../shared/constants/transaction';
import { GAS_LIMITS } from '../../../../shared/constants/gas';
import { renderWithProvider } from '../../../../test/lib/render-helpers';
@@ -13,6 +14,14 @@ jest.mock('../../../store/actions.ts', () => ({
addPollingTokenToAppState: jest.fn(),
}));
+let mockGetCustodianTransactionDeepLink = jest.fn();
+
+jest.mock('../../../store/institutional/institution-background', () => ({
+ mmiActionsFactory: () => ({
+ getCustodianTransactionDeepLink: () => mockGetCustodianTransactionDeepLink,
+ }),
+}));
+
describe('TransactionListItemDetails Component', () => {
const transaction = {
history: [],
@@ -26,6 +35,10 @@ describe('TransactionListItemDetails Component', () => {
to: '0x2',
value: '0x2386f26fc10000',
},
+ metadata: {
+ note: 'some note',
+ },
+ custodyId: '1',
};
const transactionGroup = {
@@ -58,7 +71,7 @@ describe('TransactionListItemDetails Component', () => {
rpcPrefs,
};
- it('should render title with title prop', () => {
+ it('should render title with title prop', async () => {
const mockStore = configureMockStore([thunk])(mockState);
const { queryByText } = renderWithProvider(
@@ -66,7 +79,9 @@ describe('TransactionListItemDetails Component', () => {
mockStore,
);
- expect(queryByText(props.title)).toBeInTheDocument();
+ await waitFor(() => {
+ expect(queryByText(props.title)).toBeInTheDocument();
+ });
});
describe('Retry button', () => {
@@ -122,4 +137,55 @@ describe('TransactionListItemDetails Component', () => {
expect(queryByTestId('speedup-button')).toBeInTheDocument();
});
});
+
+ describe('Institutional', () => {
+ it('should render correctly if custodyTransactionDeepLink has a url', async () => {
+ mockGetCustodianTransactionDeepLink = jest
+ .fn()
+ .mockReturnValue({ url: 'https://url.com' });
+
+ const mockStore = configureMockStore([thunk])(mockState);
+
+ renderWithProvider(, mockStore);
+
+ await waitFor(() => {
+ const custodianViewButton = document.querySelector(
+ '[data-original-title="View in custodian app"]',
+ );
+
+ // Assert that the custodian view button is rendered
+ expect(custodianViewButton).toBeInTheDocument();
+ });
+ });
+
+ it('should render correctly if transactionNote is provided', async () => {
+ const newTransaction = {
+ ...transaction,
+ metadata: {
+ note: 'some note',
+ },
+ custodyId: '1',
+ };
+
+ const newTransactionGroup = {
+ ...transactionGroup,
+ transactions: [newTransaction],
+ primaryTransaction: newTransaction,
+ initialTransaction: newTransaction,
+ };
+ const mockStore = configureMockStore([thunk])(mockState);
+
+ const { queryByText } = renderWithProvider(
+ ,
+ mockStore,
+ );
+
+ await waitFor(() => {
+ expect(queryByText('some note')).toBeInTheDocument();
+ });
+ });
+ });
});
diff --git a/ui/components/app/transaction-list-item-details/transaction-list-item-details.container.js b/ui/components/app/transaction-list-item-details/transaction-list-item-details.container.js
index 86b341533..0f4d5d254 100644
--- a/ui/components/app/transaction-list-item-details/transaction-list-item-details.container.js
+++ b/ui/components/app/transaction-list-item-details/transaction-list-item-details.container.js
@@ -2,6 +2,9 @@ import { connect } from 'react-redux';
import { compose } from 'redux';
import { withRouter } from 'react-router-dom';
import { tryReverseResolveAddress } from '../../../store/actions';
+///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
+import { mmiActionsFactory } from '../../../store/institutional/institution-background';
+///: END:ONLY_INCLUDE_IN
import {
getAddressBook,
getBlockExplorerLinkText,
@@ -11,6 +14,10 @@ import {
getAccountName,
getMetadataContractName,
getMetaMaskIdentities,
+ ///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
+ getSelectedIdentity,
+ getKnownMethodData,
+ ///: END:ONLY_INCLUDE_IN
} from '../../../selectors';
import { toChecksumHexAddress } from '../../../../shared/modules/hexstring-utils';
import TransactionListItemDetails from './transaction-list-item-details.component';
@@ -40,6 +47,13 @@ const mapStateToProps = (state, ownProps) => {
const isCustomNetwork = getIsCustomNetwork(state);
+ ///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
+ const data = ownProps.transactionGroup?.primaryTransaction?.txParams?.data;
+ const methodData = getKnownMethodData(state, data) || {};
+ const transactionNote =
+ ownProps.transactionGroup?.primaryTransaction?.metadata?.note;
+ ///: END:ONLY_INCLUDE_IN
+
return {
rpcPrefs,
recipientEns,
@@ -49,14 +63,29 @@ const mapStateToProps = (state, ownProps) => {
blockExplorerLinkText: getBlockExplorerLinkText(state),
recipientName,
recipientMetadataName,
+ ///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
+ methodData,
+ transactionNote,
+ selectedIdentity: getSelectedIdentity(state),
+ ///: END:ONLY_INCLUDE_IN
};
};
const mapDispatchToProps = (dispatch) => {
+ ///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
+ const mmiActions = mmiActionsFactory();
+ ///: END:ONLY_INCLUDE_IN
return {
tryReverseResolveAddress: (address) => {
return dispatch(tryReverseResolveAddress(address));
},
+ ///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
+ getCustodianTransactionDeepLink: (address, txId) => {
+ return dispatch(
+ mmiActions.getCustodianTransactionDeepLink(address, txId),
+ );
+ },
+ ///: END:ONLY_INCLUDE_IN
};
};
diff --git a/ui/components/app/transaction-list-item/index.scss b/ui/components/app/transaction-list-item/index.scss
index ae3e28039..069510062 100644
--- a/ui/components/app/transaction-list-item/index.scss
+++ b/ui/components/app/transaction-list-item/index.scss
@@ -67,4 +67,13 @@
text-overflow: ellipsis;
white-space: nowrap;
}
+
+ ///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
+ &__icon-badge {
+ position: absolute;
+ top: 18px;
+ left: 18px;
+ transform: scale(0.8);
+ }
+ ///: END:ONLY_INCLUDE_IN
}
diff --git a/ui/components/app/transaction-list-item/transaction-list-item.component.js b/ui/components/app/transaction-list-item/transaction-list-item.component.js
index f412e7fbc..77867fff8 100644
--- a/ui/components/app/transaction-list-item/transaction-list-item.component.js
+++ b/ui/components/app/transaction-list-item/transaction-list-item.component.js
@@ -13,6 +13,10 @@ import { CONFIRM_TRANSACTION_ROUTE } from '../../../helpers/constants/routes';
import { useShouldShowSpeedUp } from '../../../hooks/useShouldShowSpeedUp';
import TransactionStatusLabel from '../transaction-status-label/transaction-status-label';
import TransactionIcon from '../transaction-icon';
+///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
+import { IconColor } from '../../../helpers/constants/design-system';
+import { Icon, IconName, IconSize } from '../../component-library';
+///: END:ONLY_INCLUDE_IN
import { MetaMetricsEventCategory } from '../../../../shared/constants/metametrics';
import {
TransactionGroupCategory,
@@ -125,6 +129,9 @@ function TransactionListItemInner({
const isApproval = category === TransactionGroupCategory.approval;
const isUnapproved = status === TransactionStatus.unapproved;
const isSwap = category === TransactionGroupCategory.swap;
+ ///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
+ const isCustodian = Boolean(transactionGroup.primaryTransaction.custodyId);
+ ///: END:ONLY_INCLUDE_IN
const className = classnames('transaction-list-item', {
'transaction-list-item--unconfirmed':
@@ -144,10 +151,29 @@ function TransactionListItemInner({
setShowDetails((prev) => !prev);
}, [isUnapproved, history, id]);
+ ///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
+ const debugTransactionMeta = {
+ 'data-hash': transactionGroup.primaryTransaction.hash,
+ ...(isCustodian
+ ? {
+ 'data-custodiantransactionid':
+ transactionGroup.primaryTransaction.custodyId,
+ }
+ : {}),
+ };
+ ///: END:ONLY_INCLUDE_IN
+
const speedUpButton = useMemo(() => {
+ ///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
+ if (isCustodian) {
+ return null;
+ }
+ ///: END:ONLY_INCLUDE_IN
+
if (!shouldShowSpeedUp || !isPending || isUnapproved) {
return null;
}
+
return (
+ {
+ ///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
+
+ ///: END:ONLY_INCLUDE_IN
+ }
{showDetails && (
(
{
};
});
+jest.mock('../../../store/actions.ts', () => ({
+ tryReverseResolveAddress: jest.fn().mockReturnValue({ type: 'TYPE' }),
+}));
+
+jest.mock('../../../store/institutional/institution-background', () => ({
+ mmiActionsFactory: () => ({
+ getCustodianTransactionDeepLink: jest
+ .fn()
+ .mockReturnValue({ type: 'TYPE' }),
+ }),
+}));
+
+const mockStore = configureStore();
+
const generateUseSelectorRouter = (opts) => (selector) => {
if (selector === getConversionRate) {
return 1;
@@ -146,5 +162,49 @@ describe('TransactionListItem', () => {
fireEvent.click(cancelButton);
expect(getByText('Cancel transaction')).toBeInTheDocument();
});
+
+ it('should have a custodian Tx and show the custody icon', () => {
+ useSelector.mockImplementation(
+ generateUseSelectorRouter({
+ balance: '2AA1EFB94E0000',
+ }),
+ );
+
+ const newTransactionGroup = {
+ ...transactionGroup,
+ ...(transactionGroup.primaryTransaction.custodyId = '1'),
+ };
+
+ const { queryByTestId } = renderWithProvider(
+ ,
+ );
+ expect(queryByTestId('custody-icon')).toBeInTheDocument();
+ });
+
+ it('should click the custody list item and view the send screen', () => {
+ const store = mockStore(mockState);
+
+ useSelector.mockImplementation(
+ generateUseSelectorRouter({
+ balance: '2AA1EFB94E0000',
+ }),
+ );
+
+ const newTransactionGroup = {
+ ...transactionGroup,
+ ...(transactionGroup.primaryTransaction.custodyId = '1'),
+ };
+
+ const { queryByTestId } = renderWithProvider(
+ ,
+ store,
+ );
+
+ const custodyListItem = queryByTestId('custody-icon');
+ fireEvent.click(custodyListItem);
+
+ const sendTextExists = screen.queryAllByText('Send');
+ expect(sendTextExists).toBeTruthy();
+ });
});
});
diff --git a/ui/components/app/transaction-list/transaction-list.component.js b/ui/components/app/transaction-list/transaction-list.component.js
index a86df8c5f..be50d01f9 100644
--- a/ui/components/app/transaction-list/transaction-list.component.js
+++ b/ui/components/app/transaction-list/transaction-list.component.js
@@ -123,23 +123,36 @@ export default function TransactionList({
{`${t('queue')} (${pendingTransactions.length})`}
- {pendingTransactions.map((transactionGroup, index) =>
- transactionGroup.initialTransaction.transactionType ===
- TransactionType.smart ? (
-
- ) : (
-
- ),
- )}
+ {pendingTransactions
+ ///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
+ .sort(
+ (a, b) => b.primaryTransaction.time - a.primaryTransaction.time,
+ )
+ ///: END:ONLY_INCLUDE_IN
+ .map((transactionGroup, index) => {
+ ///: BEGIN:ONLY_INCLUDE_IN(build-main,build-beta,build-flask)
+ if (
+ transactionGroup.initialTransaction.transactionType ===
+ TransactionType.smart
+ ) {
+ return (
+
+ );
+ }
+ ///: END:ONLY_INCLUDE_IN
+ return (
+
+ );
+ })}
)}
@@ -148,6 +161,11 @@ export default function TransactionList({
) : null}
{completedTransactions.length > 0 ? (
completedTransactions
+ ///: BEGIN:ONLY_INCLUDE_IN(build-mmi)
+ .sort(
+ (a, b) => b.primaryTransaction.time - a.primaryTransaction.time,
+ )
+ ///: END:ONLY_INCLUDE_IN
.slice(0, limit)
.map((transactionGroup, index) =>
transactionGroup.initialTransaction?.transactionType ===