diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index f015a4b23ab8..02e2e5f2c12d 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -3235,6 +3235,12 @@ "snaps": { "message": "Snaps" }, + "snapsInsightLoading": { + "message": "Loading transaction insight..." + }, + "snapsNoInsight": { + "message": "The snap didn't return any Insight" + }, "snapsSettingsDescription": { "message": "Manage your Snaps" }, diff --git a/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js b/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js index b09b534ed9c5..be48015a56fc 100644 --- a/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js +++ b/ui/components/app/confirm-page-container/confirm-page-container-content/confirm-page-container-content.component.js @@ -23,6 +23,9 @@ export default class ConfirmPageContainerContent extends Component { dataComponent: PropTypes.node, dataHexComponent: PropTypes.node, detailsComponent: PropTypes.node, + ///: BEGIN:ONLY_INCLUDE_IN(flask) + insightComponent: PropTypes.node, + ///: END:ONLY_INCLUDE_IN errorKey: PropTypes.string, errorMessage: PropTypes.string, hideSubtitle: PropTypes.bool, @@ -59,15 +62,37 @@ export default class ConfirmPageContainerContent extends Component { renderContent() { const { detailsComponent, dataComponent } = this.props; + ///: BEGIN:ONLY_INCLUDE_IN(flask) + const { insightComponent } = this.props; + + if (insightComponent && (detailsComponent || dataComponent)) { + return this.renderTabs(); + } + ///: END:ONLY_INCLUDE_IN + if (detailsComponent && dataComponent) { return this.renderTabs(); } - return detailsComponent || dataComponent; + + return ( + detailsComponent || + ///: BEGIN:ONLY_INCLUDE_IN(flask) + insightComponent || + ///: END:ONLY_INCLUDE_IN + dataComponent + ); } renderTabs() { const { t } = this.context; - const { detailsComponent, dataComponent, dataHexComponent } = this.props; + const { + detailsComponent, + dataComponent, + dataHexComponent, + ///: BEGIN:ONLY_INCLUDE_IN(flask) + insightComponent, + ///: END:ONLY_INCLUDE_IN + } = this.props; return ( @@ -88,6 +113,12 @@ export default class ConfirmPageContainerContent extends Component { {dataHexComponent} )} + + { + ///: BEGIN:ONLY_INCLUDE_IN(flask) + insightComponent + ///: END:ONLY_INCLUDE_IN + } ); } diff --git a/ui/components/app/confirm-page-container/confirm-page-container-content/index.scss b/ui/components/app/confirm-page-container/confirm-page-container-content/index.scss index 37d104c808fc..f9ebf4be115f 100644 --- a/ui/components/app/confirm-page-container/confirm-page-container-content/index.scss +++ b/ui/components/app/confirm-page-container/confirm-page-container-content/index.scss @@ -82,13 +82,20 @@ color: var(--color-text-alternative); text-transform: uppercase; - margin: 0 8px; & button { font-size: unset; color: var(--color-text-alternative); text-transform: uppercase; } + + & .dropdown__select { + color: var(--color-text-alternative); + } + + & .dropdown__icon-caret-down { + top: 40%; + } } .page-container__footer { diff --git a/ui/components/app/confirm-page-container/confirm-page-container.component.js b/ui/components/app/confirm-page-container/confirm-page-container.component.js index 79ac85996153..ed0962b0750e 100644 --- a/ui/components/app/confirm-page-container/confirm-page-container.component.js +++ b/ui/components/app/confirm-page-container/confirm-page-container.component.js @@ -64,6 +64,9 @@ export default class ConfirmPageContainer extends Component { dataComponent: PropTypes.node, dataHexComponent: PropTypes.node, detailsComponent: PropTypes.node, + ///: BEGIN:ONLY_INCLUDE_IN(flask) + insightComponent: PropTypes.node, + ///: END:ONLY_INCLUDE_IN tokenAddress: PropTypes.string, nonce: PropTypes.string, warning: PropTypes.string, @@ -153,6 +156,9 @@ export default class ConfirmPageContainer extends Component { showBuyModal, isBuyableChain, networkIdentifier, + ///: BEGIN:ONLY_INCLUDE_IN(flask) + insightComponent, + ///: END:ONLY_INCLUDE_IN } = this.props; const showAddToAddressDialog = @@ -240,6 +246,9 @@ export default class ConfirmPageContainer extends Component { detailsComponent={detailsComponent} dataComponent={dataComponent} dataHexComponent={dataHexComponent} + ///: BEGIN:ONLY_INCLUDE_IN(flask) + insightComponent={insightComponent} + ///: END:ONLY_INCLUDE_IN errorMessage={errorMessage} errorKey={errorKey} tokenAddress={tokenAddress} diff --git a/ui/components/app/confirm-page-container/flask/index.scss b/ui/components/app/confirm-page-container/flask/index.scss new file mode 100644 index 000000000000..818e91330767 --- /dev/null +++ b/ui/components/app/confirm-page-container/flask/index.scss @@ -0,0 +1,32 @@ +.snap-insight { + display: flex; + flex-direction: column; + height: 100%; + word-wrap: break-word; + + + &--no-data { + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + height: 170px; + } + + &__container { + display: flex; + height: 100%; + flex-direction: column; + + + &__data { + display: flex; + flex-direction: column; + padding: 0 4px 12px 24px; + + .typography { + font-size: 14px; + } + } + } +} diff --git a/ui/components/app/confirm-page-container/flask/snap-insight.js b/ui/components/app/confirm-page-container/flask/snap-insight.js new file mode 100644 index 000000000000..0a2bb94a3b32 --- /dev/null +++ b/ui/components/app/confirm-page-container/flask/snap-insight.js @@ -0,0 +1,70 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import classnames from 'classnames'; + +import Preloader from '../../../ui/icon/preloader/preloader-icon.component'; +import Typography from '../../../ui/typography/typography'; +import { COLORS } from '../../../../helpers/constants/design-system'; +import { useI18nContext } from '../../../../hooks/useI18nContext'; +import { useTransactionInsightSnap } from '../../../../hooks/flask/useTransactionInsightSnap'; +import SnapContentFooter from '../../flask/snap-content-footer/snap-content-footer'; + +export const SnapInsight = ({ transaction, chainId, selectedSnap }) => { + const t = useI18nContext(); + const response = useTransactionInsightSnap({ + transaction, + chainId, + snapId: selectedSnap.id, + }); + + const data = response?.insights; + + return ( +
+ {data ? ( +
+ {Object.keys(data).length ? ( + <> +
+ {Object.keys(data).map((key, i) => ( +
+ + {key} + + {data[key]} +
+ ))} +
+ + + ) : ( + + {t('snapsNoInsight')} + + )} +
+ ) : ( + <> + + + {t('snapsInsightLoading')} + + + )} +
+ ); +}; + +SnapInsight.propTypes = { + transaction: PropTypes.object, + chainId: PropTypes.string, + selectedSnap: PropTypes.object, +}; diff --git a/ui/components/app/confirm-page-container/index.js b/ui/components/app/confirm-page-container/index.js index 955ef1bb8b49..b216aba0561b 100644 --- a/ui/components/app/confirm-page-container/index.js +++ b/ui/components/app/confirm-page-container/index.js @@ -7,3 +7,6 @@ export { default as ConfirmPageContainerContent, ConfirmPageContainerSummary, } from './confirm-page-container-content'; +///: BEGIN:ONLY_INCLUDE_IN(flask) +export { SnapInsight } from './flask/snap-insight'; +///: END:ONLY_INCLUDE_IN diff --git a/ui/components/app/confirm-page-container/index.scss b/ui/components/app/confirm-page-container/index.scss index ca3e12aa7a76..ca1ac9fb150d 100644 --- a/ui/components/app/confirm-page-container/index.scss +++ b/ui/components/app/confirm-page-container/index.scss @@ -2,6 +2,9 @@ @import 'confirm-page-container-header/index'; @import 'confirm-detail-row/index'; @import 'confirm-page-container-navigation/index'; +///: BEGIN:ONLY_INCLUDE_IN(flask) +@import 'flask/index'; +///: END:ONLY_INCLUDE_IN .confirm-page-container { &__dialog { diff --git a/ui/components/app/flask/snap-content-footer/index.scss b/ui/components/app/flask/snap-content-footer/index.scss index ea253480406c..9d451153a73a 100644 --- a/ui/components/app/flask/snap-content-footer/index.scss +++ b/ui/components/app/flask/snap-content-footer/index.scss @@ -1,7 +1,12 @@ .snap-content-footer { display: flex; justify-content: center; - padding: 24px 0 16px; + align-items: center; + padding: 16px 0; + + .typography { + font-size: 12px; + } i { color: var(--color-icon-muted); diff --git a/ui/components/app/flask/snap-content-footer/snap-content-footer.js b/ui/components/app/flask/snap-content-footer/snap-content-footer.js index b6b20f83be63..ec75f9b89737 100644 --- a/ui/components/app/flask/snap-content-footer/snap-content-footer.js +++ b/ui/components/app/flask/snap-content-footer/snap-content-footer.js @@ -1,19 +1,35 @@ import React from 'react'; import PropTypes from 'prop-types'; + +import { useHistory } from 'react-router-dom'; + import Typography from '../../../ui/typography/typography'; import { useI18nContext } from '../../../../hooks/useI18nContext'; -import { SNAPS_VIEW_ROUTE } from 'ui/helpers/constants/routes'; -import { COLORS, TYPOGRAPHY } from 'ui/helpers/constants/design-system'; +import { SNAPS_VIEW_ROUTE } from '../../../../helpers/constants/routes'; +import { + COLORS, + TYPOGRAPHY, +} from '../../../../helpers/constants/design-system'; +import Button from '../../../ui/button'; export default function SnapContentFooter({ snapName, snapId }) { const t = useI18nContext(); - const route = `${SNAPS_VIEW_ROUTE}/${encodeURIComponent(snapId)}`; + const history = useHistory(); + + const handleNameClick = (e) => { + e.stopPropagation(); + history.push(`${SNAPS_VIEW_ROUTE}/${encodeURIComponent(snapId)}`); + }; // TODO: add truncation to the snap name, need to pick a character length at which to cut off return (
- + - {t('snapContent', {snapName})} + {t('snapContent', [ + , + ])}
); diff --git a/ui/components/ui/tabs/dropdown-tab/dropdown-tab.js b/ui/components/ui/tabs/dropdown-tab/dropdown-tab.js new file mode 100644 index 000000000000..4deccf85045d --- /dev/null +++ b/ui/components/ui/tabs/dropdown-tab/dropdown-tab.js @@ -0,0 +1,63 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import Dropdown from '../../dropdown'; + +export const DropdownTab = (props) => { + const { + activeClassName, + className, + 'data-testid': dataTestId, + isActive, + onClick, + onChange, + tabIndex, + options, + selectedOption, + } = props; + + return ( +
  • { + event.preventDefault(); + onClick(tabIndex); + }} + > + +
  • + ); +}; + +DropdownTab.propTypes = { + activeClassName: PropTypes.string, + className: PropTypes.string, + 'data-testid': PropTypes.string, + isActive: PropTypes.bool, // required, but added using React.cloneElement + options: PropTypes.arrayOf( + PropTypes.exact({ + name: PropTypes.string, + value: PropTypes.string.isRequired, + }), + ).isRequired, + selectedOption: PropTypes.string, + onChange: PropTypes.func, + onClick: PropTypes.func, + tabIndex: PropTypes.number, // required, but added using React.cloneElement +}; + +DropdownTab.defaultProps = { + activeClassName: undefined, + className: undefined, + onChange: undefined, + onClick: undefined, + selectedOption: undefined, +}; diff --git a/ui/components/ui/tabs/dropdown-tab/index.js b/ui/components/ui/tabs/dropdown-tab/index.js new file mode 100644 index 000000000000..82d1738acee7 --- /dev/null +++ b/ui/components/ui/tabs/dropdown-tab/index.js @@ -0,0 +1,3 @@ +import { DropdownTab } from './dropdown-tab'; + +export default DropdownTab; diff --git a/ui/components/ui/tabs/dropdown-tab/index.scss b/ui/components/ui/tabs/dropdown-tab/index.scss new file mode 100644 index 000000000000..11461d56b0d4 --- /dev/null +++ b/ui/components/ui/tabs/dropdown-tab/index.scss @@ -0,0 +1,25 @@ +.tab { + .dropdown__select { + border: none; + font-size: unset; + width: 100%; + background-color: unset; + padding-left: 8px; + padding-right: 20px; + line-height: unset; + text-transform: uppercase; + + option { + background-color: var(--color-background-default); + text-transform: none; + } + + &:focus-visible { + outline: none; + } + } + + .dropdown__icon-caret-down { + right: 0; + } +} diff --git a/ui/components/ui/tabs/index.js b/ui/components/ui/tabs/index.js index 43366ec6f7e8..c20ebbfc1854 100644 --- a/ui/components/ui/tabs/index.js +++ b/ui/components/ui/tabs/index.js @@ -1,4 +1,5 @@ import Tabs from './tabs.component'; import Tab from './tab'; +import DropdownTab from './dropdown-tab'; -export { Tabs, Tab }; +export { Tabs, Tab, DropdownTab }; diff --git a/ui/components/ui/tabs/index.scss b/ui/components/ui/tabs/index.scss index 96ccf695c2af..b3f230d98664 100644 --- a/ui/components/ui/tabs/index.scss +++ b/ui/components/ui/tabs/index.scss @@ -1,4 +1,5 @@ @import 'tab/index'; +@import 'dropdown-tab/index'; .tabs { flex-grow: 1; diff --git a/ui/components/ui/tabs/tabs.stories.js b/ui/components/ui/tabs/tabs.stories.js index 0266eec6c5d7..f3047e4915e6 100644 --- a/ui/components/ui/tabs/tabs.stories.js +++ b/ui/components/ui/tabs/tabs.stories.js @@ -1,4 +1,5 @@ import React from 'react'; +import DropdownTab from './dropdown-tab'; import Tab from './tab/tab.component'; import Tabs from './tabs.component'; @@ -41,6 +42,14 @@ export const DefaultStory = (args) => { onTabClick={args.onTabClick} > {args.tabs.map((tabProps, i) => renderTab(tabProps, i))} + + This is a dropdown Tab + ); }; diff --git a/ui/hooks/flask/useTransactionInsightSnap.js b/ui/hooks/flask/useTransactionInsightSnap.js index cf1db24df994..6206bc30da90 100644 --- a/ui/hooks/flask/useTransactionInsightSnap.js +++ b/ui/hooks/flask/useTransactionInsightSnap.js @@ -3,28 +3,35 @@ import { useSelector } from 'react-redux'; import { handleSnapRequest } from '../../store/actions'; import { getPermissionSubjects } from '../../selectors'; -const INSIGHT_PERMISSION = 'endowment:tx-insight'; +const INSIGHT_PERMISSION = 'endowment:transaction-insight'; -export function useTransactionInsightSnap(transaction, snapId) { +export function useTransactionInsightSnap({ transaction, chainId, snapId }) { const subjects = useSelector(getPermissionSubjects); if (!subjects[snapId]?.permissions[INSIGHT_PERMISSION]) { throw new Error( 'This snap does not have the transaction insight endowment.', ); } - const [data, setData] = useState(null); + const [data, setData] = useState(undefined); useEffect(() => { async function fetchInsight() { - const d = await handleSnapRequest(snapId, undefined, 'onTransaction', { - params: [transaction], + const d = await handleSnapRequest({ + snapId, + origin: 'test', + handler: 'onTransaction', + request: { + jsonrpc: '2.0', + method: ' ', + params: { transaction, chainId }, + }, }); setData(d); } if (transaction) { fetchInsight(); } - }, [snapId, transaction]); + }, [snapId, transaction, chainId]); return data; } diff --git a/ui/pages/confirm-transaction-base/confirm-transaction-base.component.js b/ui/pages/confirm-transaction-base/confirm-transaction-base.component.js index acc2c0b549d9..a9023d65b719 100644 --- a/ui/pages/confirm-transaction-base/confirm-transaction-base.component.js +++ b/ui/pages/confirm-transaction-base/confirm-transaction-base.component.js @@ -1,5 +1,8 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; +///: BEGIN:ONLY_INCLUDE_IN(flask) +import { stripHexPrefix } from 'ethereumjs-util'; +///: END:ONLY_INCLUDE_IN import ConfirmPageContainer from '../../components/app/confirm-page-container'; import TransactionDecoding from '../../components/app/transaction-decoding'; import { isBalanceSufficient } from '../send/send.utils'; @@ -46,7 +49,7 @@ import GasDetailsItem from '../../components/app/gas-details-item'; import GasTiming from '../../components/app/gas-timing/gas-timing.component'; import LedgerInstructionField from '../../components/app/ledger-instruction-field'; import MultiLayerFeeMessage from '../../components/app/multilayer-fee-message'; - +import Typography from '../../components/ui/typography/typography'; import { COLORS, FONT_STYLE, @@ -59,10 +62,19 @@ import { removePollingTokenFromAppState, } from '../../store/actions'; -import Typography from '../../components/ui/typography/typography'; import { MIN_GAS_LIMIT_DEC } from '../send/send.constants'; -import { NETWORK_TO_NAME_MAP } from '../../../shared/constants/network'; +///: BEGIN:ONLY_INCLUDE_IN(flask) +import { SnapInsight } from '../../components/app/confirm-page-container/flask/snap-insight'; +import { DropdownTab, Tab } from '../../components/ui/tabs'; +///: END:ONLY_INCLUDE_IN + +import { + NETWORK_TO_NAME_MAP, + ///: BEGIN:ONLY_INCLUDE_IN(flask) + CHAIN_ID_TO_NETWORK_ID_MAP, + ///: END:ONLY_INCLUDE_IN +} from '../../../shared/constants/network'; import TransactionAlerts from './transaction-alerts'; const renderHeartBeatIfNotInTest = () => @@ -151,6 +163,9 @@ export default class ConfirmTransactionBase extends Component { eip1559V2Enabled: PropTypes.bool, showBuyModal: PropTypes.func, isBuyableChain: PropTypes.bool, + ///: BEGIN:ONLY_INCLUDE_IN(flask) + insightSnaps: PropTypes.arrayOf(PropTypes.object), + ///: END:ONLY_INCLUDE_IN }; state = { @@ -160,6 +175,9 @@ export default class ConfirmTransactionBase extends Component { ethGasPriceWarning: '', editingGas: false, userAcknowledgedGasMissing: false, + ///: BEGIN:ONLY_INCLUDE_IN(flask) + selectedInsightSnapId: this.props.insightSnaps[0]?.id, + ///: END:ONLY_INCLUDE_IN }; componentDidUpdate(prevProps) { @@ -303,6 +321,12 @@ export default class ConfirmTransactionBase extends Component { this.setState({ editingGas: false }); } + ///: BEGIN:ONLY_INCLUDE_IN(flask) + handleSnapSelected(snapId) { + this.setState({ selectedInsightSnapId: snapId }); + } + ///: END:ONLY_INCLUDE_IN + setUserAcknowledgedGasMissing() { this.setState({ userAcknowledgedGasMissing: true }); } @@ -730,6 +754,62 @@ export default class ConfirmTransactionBase extends Component { ); } + ///: BEGIN:ONLY_INCLUDE_IN(flask) + renderInsight() { + const { txData, insightSnaps } = this.props; + const { selectedInsightSnapId } = this.state; + const { txParams, chainId } = txData; + + const selectedSnap = insightSnaps.find( + ({ id }) => id === selectedInsightSnapId, + ); + + const networkId = CHAIN_ID_TO_NETWORK_ID_MAP[chainId]; + const caip2ChainId = `eip155:${networkId ?? stripHexPrefix(chainId)}`; + + if ( + txData.type !== TRANSACTION_TYPES.CONTRACT_INTERACTION || + !insightSnaps + ) { + return null; + } + + const dropdownOptions = insightSnaps.reduce( + (prev, acc) => [ + ...prev, + { value: acc.id, name: acc.manifest.proposedName }, + ], + [], + ); + + return insightSnaps.length > 1 ? ( + this.handleSnapSelected(snapId)} + > + + + ) : ( + + + + ); + } + ///: END:ONLY_INCLUDE_IN + handleEdit() { const { txData, @@ -1111,6 +1191,9 @@ export default class ConfirmTransactionBase extends Component { detailsComponent={this.renderDetails()} dataComponent={this.renderData(functionType)} dataHexComponent={this.renderDataHex(functionType)} + ///: BEGIN:ONLY_INCLUDE_IN(flask) + insightComponent={this.renderInsight()} + ///: END:ONLY_INCLUDE_IN contentComponent={contentComponent} nonce={customNonceValue || nonce} unapprovedTxCount={unapprovedTxCount} diff --git a/ui/pages/confirm-transaction-base/confirm-transaction-base.container.js b/ui/pages/confirm-transaction-base/confirm-transaction-base.container.js index 78770daa2068..555b370774f6 100644 --- a/ui/pages/confirm-transaction-base/confirm-transaction-base.container.js +++ b/ui/pages/confirm-transaction-base/confirm-transaction-base.container.js @@ -35,6 +35,9 @@ import { getEIP1559V2Enabled, getIsBuyableChain, getEnsResolutionByAddress, + ///: BEGIN:ONLY_INCLUDE_IN(flask) + getInsightSnaps, + ///: END:ONLY_INCLUDE_IN } from '../../selectors'; import { getMostRecentOverviewPage } from '../../ducks/history/history'; import { @@ -197,6 +200,10 @@ const mapStateToProps = (state, ownProps) => { const isMultiLayerFeeNetwork = getIsMultiLayerFeeNetwork(state); const eip1559V2Enabled = getEIP1559V2Enabled(state); + ///: BEGIN:ONLY_INCLUDE_IN(flask) + const insightSnaps = getInsightSnaps(state); + ///: END:ONLY_INCLUDE_IN + return { balance, fromAddress, @@ -249,6 +256,9 @@ const mapStateToProps = (state, ownProps) => { chainId, eip1559V2Enabled, isBuyableChain, + ///: BEGIN:ONLY_INCLUDE_IN(flask) + insightSnaps, + ///: END:ONLY_INCLUDE_IN }; }; diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index 59e4dd70dd75..72dde3ec9448 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -72,6 +72,7 @@ import { import { isEqualCaseInsensitive } from '../../shared/modules/string-utils'; ///: BEGIN:ONLY_INCLUDE_IN(flask) import { SNAPS_VIEW_ROUTE } from '../helpers/constants/routes'; +import { getPermissionSubjects } from './permissions'; ///: END:ONLY_INCLUDE_IN /** @@ -747,6 +748,17 @@ export function getSnaps(state) { return state.metamask.snaps; } +export function getInsightSnaps(state) { + const snaps = Object.values(state.metamask.snaps); + const subjects = getPermissionSubjects(state); + + const insightSnaps = snaps.filter( + ({ id }) => subjects[id]?.permissions['endowment:transaction-insight'], + ); + + return insightSnaps; +} + export const getSnapsRouteObjects = createSelector(getSnaps, (snaps) => { return Object.values(snaps).map((snap) => { return { diff --git a/ui/store/actions.js b/ui/store/actions.js index 84b944615a0f..c977918f6ad7 100644 --- a/ui/store/actions.js +++ b/ui/store/actions.js @@ -1050,13 +1050,8 @@ export async function removeSnapError(msgData) { return submitRequestToBackground('removeSnapError', [msgData]); } -export async function handleSnapRequest(snapId, origin, handler, request) { - return submitRequestToBackground('handleSnapRequest', [ - snapId, - origin, - handler, - request, - ]); +export async function handleSnapRequest(args) { + return submitRequestToBackground('handleSnapRequest', [args]); } export function dismissNotifications(ids) {