1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-11-22 09:57:02 +01:00

[FLASK] Improve snaps connect flow (#19461)

* add todo comments

* add snaps-connect component

* added new messages

* added component scss files to main scss files

* remove dead code and add snap-connect-cell

* update snaps connect

* updated messages and styling

* update messages and css

* update css

* moved snaps privacy warning into snaps connect, moved snaps connect error into snap install

* added story and removed unused import

* fix style linting and move snaps connect error css

* removed unused message

* ran lavamoat policy generation

* fix fencing

* some more css changes

* Fix scrolling and box shadow

* added comment, fixed quote

* Align more with Figma

* Regen LavaMoat policies

* bring back privacy logic to permission page container

* Revert scrolling changes + fix snaps icon

* fix linting, reintroduced dedupe logic and additionally addressed a corner case

* made some fixes

* Fix scrolling with multiple snaps

* add dedupe logic to snaps connect and fix spacing issue

* policy regen

* lint fix

* fix fencing

* replaced with new icon design, trimmed origin urls in certain places

* remove unused imports

* badge icon size

* Revert LM policy changes

* Use SnapAvatar for snaps-connect

* Use InstallError for connection failed

* Delete unused CSS file

* Remove unused CSS

* Use useOriginMetadata

* addressed PR comments

* fix linting errors

* add explicit condition

* fix fencing

* fix some more fencing

* fix util fencing issue

* fix storybook file, prevent null destructuring

* Fix storybook origin URLs

* Fix wrong prop name

---------

Co-authored-by: Frederik Bolding <frederik.bolding@gmail.com>
Co-authored-by: Guillaume Roux <guillaumeroux123@gmail.com>
Co-authored-by: Erik Nilsson <eriks@mail.se>
This commit is contained in:
Hassan Malik 2023-06-09 10:36:38 -04:00 committed by GitHub
parent 95d57b254f
commit ff36e32fb0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 639 additions and 41 deletions

View File

@ -739,6 +739,10 @@
"connectManually": { "connectManually": {
"message": "Manually connect to current site" "message": "Manually connect to current site"
}, },
"connectSnap": {
"message": "Connect $1",
"description": "$1 is the snap for which a connection is being requested."
},
"connectTo": { "connectTo": {
"message": "Connect to $1", "message": "Connect to $1",
"description": "$1 is the name/origin of a web3 site/application that the user can connect to metamask" "description": "$1 is the name/origin of a web3 site/application that the user can connect to metamask"
@ -804,6 +808,16 @@
"connectionError": { "connectionError": {
"message": "Connection error" "message": "Connection error"
}, },
"connectionFailed": {
"message": "Connection failed"
},
"connectionFailedDescription": {
"message": "Fetching of $1 failed, check your network and try again.",
"description": "$1 is the name of the snap being fetched."
},
"connectionRequest": {
"message": "Connection request"
},
"contactUs": { "contactUs": {
"message": "Contact us" "message": "Contact us"
}, },
@ -2265,6 +2279,10 @@
"moreComingSoon": { "moreComingSoon": {
"message": "More coming soon..." "message": "More coming soon..."
}, },
"multipleSnapConnectionWarning": {
"message": "$1 wants to connect with $2 snaps. Only proceed if you trust this website.",
"description": "$1 is the dapp and $2 is the number of snaps it wants to connect to."
},
"mustSelectOne": { "mustSelectOne": {
"message": "Must select at least 1 token." "message": "Must select at least 1 token."
}, },
@ -3764,6 +3782,10 @@
"smartTransaction": { "smartTransaction": {
"message": "Smart transaction" "message": "Smart transaction"
}, },
"snapConnectionWarning": {
"message": "$1 wants to connect to $2. Only continue if you trust this website.",
"description": "$2 is the snap and $1 is the dapp requesting connection to the snap."
},
"snapContent": { "snapContent": {
"message": "This content is coming from $1", "message": "This content is coming from $1",
"description": "This is shown when a snap shows transaction insight information in the confirmation UI. $1 is a link to the snap's settings page with the link text being the name of the snap." "description": "This is shown when a snap shows transaction insight information in the confirmation UI. $1 is a link to the snap's settings page with the link text being the name of the snap."

View File

@ -2,7 +2,6 @@ import PropTypes from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { isEqual } from 'lodash'; import { isEqual } from 'lodash';
///: BEGIN:ONLY_INCLUDE_IN(snaps) ///: BEGIN:ONLY_INCLUDE_IN(snaps)
import { isObject } from '@metamask/utils';
import { import {
SnapCaveatType, SnapCaveatType,
WALLET_SNAP_PERMISSION_KEY, WALLET_SNAP_PERMISSION_KEY,
@ -14,6 +13,7 @@ import PermissionsConnectFooter from '../permissions-connect-footer';
///: BEGIN:ONLY_INCLUDE_IN(snaps) ///: BEGIN:ONLY_INCLUDE_IN(snaps)
import { RestrictedMethods } from '../../../../shared/constants/permissions'; import { RestrictedMethods } from '../../../../shared/constants/permissions';
import SnapPrivacyWarning from '../snaps/snap-privacy-warning'; import SnapPrivacyWarning from '../snaps/snap-privacy-warning';
import { getDedupedSnaps } from '../../../helpers/utils/util';
///: END:ONLY_INCLUDE_IN ///: END:ONLY_INCLUDE_IN
import { PermissionPageContainerContent } from '.'; import { PermissionPageContainerContent } from '.';
@ -86,29 +86,20 @@ export default class PermissionPageContainer extends Component {
///: BEGIN:ONLY_INCLUDE_IN(snaps) ///: BEGIN:ONLY_INCLUDE_IN(snaps)
getDedupedSnapPermissions() { getDedupedSnapPermissions() {
const permission = const { request, currentPermissions } = this.props;
this.props.request.permissions[WALLET_SNAP_PERMISSION_KEY]; const snapKeys = getDedupedSnaps(request, currentPermissions);
const requestedSnaps = permission?.caveats[0].value; const permission = request?.permissions?.[WALLET_SNAP_PERMISSION_KEY] || {};
const currentSnaps =
this.props.currentPermissions[WALLET_SNAP_PERMISSION_KEY]?.caveats[0]
.value;
if (!isObject(currentSnaps)) {
return permission;
}
const requestedSnapKeys = requestedSnaps ? Object.keys(requestedSnaps) : [];
const currentSnapKeys = currentSnaps ? Object.keys(currentSnaps) : [];
const dedupedCaveats = requestedSnapKeys.reduce((acc, snapId) => {
if (!currentSnapKeys.includes(snapId)) {
acc[snapId] = {};
}
return acc;
}, {});
return { return {
...permission, ...permission,
caveats: [{ type: SnapCaveatType.SnapIds, value: dedupedCaveats }], caveats: [
{
type: SnapCaveatType.SnapIds,
value: snapKeys.reduce((caveatValue, snapId) => {
caveatValue[snapId] = {};
return caveatValue;
}, {}),
},
],
}; };
} }

View File

@ -40,16 +40,18 @@ const InstallError = ({ title, error, description, iconName }) => {
{title} {title}
</Text> </Text>
{description && <Text textAlign={TextAlign.Center}>{description}</Text>} {description && <Text textAlign={TextAlign.Center}>{description}</Text>}
{error && (
<Box padding={2}> <Box padding={2}>
<ActionableMessage type="danger" message={error} /> <ActionableMessage type="danger" message={error} />
</Box> </Box>
)}
</Box> </Box>
); );
}; };
InstallError.propTypes = { InstallError.propTypes = {
title: PropTypes.node.isRequired, title: PropTypes.node.isRequired,
error: PropTypes.string.isRequired, error: PropTypes.string,
description: PropTypes.string, description: PropTypes.string,
iconName: PropTypes.string, iconName: PropTypes.string,
}; };

View File

@ -8,7 +8,6 @@ import {
AlignItems, AlignItems,
DISPLAY, DISPLAY,
JustifyContent, JustifyContent,
Size,
BackgroundColor, BackgroundColor,
} from '../../../../helpers/constants/design-system'; } from '../../../../helpers/constants/design-system';
import { getSnapName } from '../../../../helpers/utils/util'; import { getSnapName } from '../../../../helpers/utils/util';
@ -23,7 +22,13 @@ import {
} from '../../../component-library'; } from '../../../component-library';
import { getTargetSubjectMetadata } from '../../../../selectors'; import { getTargetSubjectMetadata } from '../../../../selectors';
const SnapAvatar = ({ snapId, className }) => { const SnapAvatar = ({
snapId,
badgeSize = IconSize.Sm,
avatarSize = IconSize.Lg,
borderWidth = 2,
className,
}) => {
const subjectMetadata = useSelector((state) => const subjectMetadata = useSelector((state) =>
getTargetSubjectMetadata(state, snapId), getTargetSubjectMetadata(state, snapId),
); );
@ -40,12 +45,12 @@ const SnapAvatar = ({ snapId, className }) => {
badge={ badge={
<AvatarIcon <AvatarIcon
iconName={IconName.Snaps} iconName={IconName.Snaps}
size={IconSize.Sm} size={badgeSize}
backgroundColor={IconColor.infoDefault} backgroundColor={IconColor.infoDefault}
borderColor={BackgroundColor.backgroundDefault} borderColor={BackgroundColor.backgroundDefault}
borderWidth={2} borderWidth={borderWidth}
iconProps={{ iconProps={{
size: IconSize.Sm, size: badgeSize,
color: IconColor.infoInverse, color: IconColor.infoInverse,
}} }}
/> />
@ -53,10 +58,10 @@ const SnapAvatar = ({ snapId, className }) => {
position={BadgeWrapperPosition.bottomRight} position={BadgeWrapperPosition.bottomRight}
> >
{iconUrl ? ( {iconUrl ? (
<AvatarFavicon size={Size.LG} src={iconUrl} name={friendlyName} /> <AvatarFavicon size={avatarSize} src={iconUrl} name={friendlyName} />
) : ( ) : (
<AvatarBase <AvatarBase
size={Size.LG} size={avatarSize}
display={DISPLAY.FLEX} display={DISPLAY.FLEX}
alignItems={AlignItems.center} alignItems={AlignItems.center}
justifyContent={JustifyContent.center} justifyContent={JustifyContent.center}
@ -75,6 +80,9 @@ SnapAvatar.propTypes = {
* The id of the snap * The id of the snap
*/ */
snapId: PropTypes.string, snapId: PropTypes.string,
badgeSize: PropTypes.string,
avatarSize: PropTypes.string,
borderWidth: PropTypes.number,
/** /**
* The className of the SnapAvatar * The className of the SnapAvatar
*/ */

View File

@ -0,0 +1 @@
export { default } from './snap-connect-cell';

View File

@ -0,0 +1,74 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import Box from '../../../ui/box';
import {
IconColor,
AlignItems,
Display,
FontWeight,
} from '../../../../helpers/constants/design-system';
import { getSnapName } from '../../../../helpers/utils/util';
import {
Icon,
IconName,
IconSize,
Text,
ValidTag,
} from '../../../component-library';
import Tooltip from '../../../ui/tooltip/tooltip';
import { useI18nContext } from '../../../../hooks/useI18nContext';
import SnapAvatar from '../snap-avatar/snap-avatar';
import { getTargetSubjectMetadata } from '../../../../selectors';
export default function SnapConnectCell({ origin, snapId }) {
const t = useI18nContext();
const snapMetadata = useSelector((state) =>
getTargetSubjectMetadata(state, snapId),
);
const friendlyName = getSnapName(snapId, snapMetadata);
return (
<Box
alignItems={AlignItems.center}
paddingTop={2}
paddingBottom={2}
display={Display.Flex}
>
<SnapAvatar snapId={snapId} />
<Box width="full" marginLeft={4} marginRight={4}>
<Text>
{t('connectSnap', [
<Text as={ValidTag.Span} key="1" fontWeight={FontWeight.Bold}>
{friendlyName}
</Text>,
])}
</Text>
</Box>
<Box>
<Tooltip
html={
<div>
{t('snapConnectionWarning', [
<b key="0">{origin}</b>,
<b key="1">{friendlyName}</b>,
])}
</div>
}
position="bottom"
>
<Icon
color={IconColor.iconMuted}
name={IconName.Info}
size={IconSize.Sm}
/>
</Tooltip>
</Box>
</Box>
);
}
SnapConnectCell.propTypes = {
origin: PropTypes.string.isRequired,
snapId: PropTypes.string.isRequired,
};

View File

@ -0,0 +1,16 @@
import React from 'react';
import SnapConnectCell from '.';
export default {
title: 'Components/App/Snaps/SnapConnectCell',
component: SnapConnectCell,
};
export const DefaultStory = (args) => <SnapConnectCell {...args} />;
DefaultStory.storyName = 'Default';
DefaultStory.args = {
origin: 'aave.com',
snapId: 'npm:@metamask/example-snap',
};

View File

@ -44,6 +44,7 @@ const TOKEN_DETAILS = '/token-details';
const CONNECT_ROUTE = '/connect'; const CONNECT_ROUTE = '/connect';
const CONNECT_CONFIRM_PERMISSIONS_ROUTE = '/confirm-permissions'; const CONNECT_CONFIRM_PERMISSIONS_ROUTE = '/confirm-permissions';
///: BEGIN:ONLY_INCLUDE_IN(snaps) ///: BEGIN:ONLY_INCLUDE_IN(snaps)
const CONNECT_SNAPS_CONNECT_ROUTE = '/snaps-connect';
const CONNECT_SNAP_INSTALL_ROUTE = '/snap-install'; const CONNECT_SNAP_INSTALL_ROUTE = '/snap-install';
const CONNECT_SNAP_UPDATE_ROUTE = '/snap-update'; const CONNECT_SNAP_UPDATE_ROUTE = '/snap-update';
const CONNECT_SNAP_RESULT_ROUTE = '/snap-install-result'; const CONNECT_SNAP_RESULT_ROUTE = '/snap-install-result';
@ -238,6 +239,7 @@ export {
CONNECT_ROUTE, CONNECT_ROUTE,
CONNECT_CONFIRM_PERMISSIONS_ROUTE, CONNECT_CONFIRM_PERMISSIONS_ROUTE,
///: BEGIN:ONLY_INCLUDE_IN(snaps) ///: BEGIN:ONLY_INCLUDE_IN(snaps)
CONNECT_SNAPS_CONNECT_ROUTE,
CONNECT_SNAP_INSTALL_ROUTE, CONNECT_SNAP_INSTALL_ROUTE,
CONNECT_SNAP_UPDATE_ROUTE, CONNECT_SNAP_UPDATE_ROUTE,
CONNECT_SNAP_RESULT_ROUTE, CONNECT_SNAP_RESULT_ROUTE,

View File

@ -9,6 +9,8 @@ import * as lodash from 'lodash';
import bowser from 'bowser'; import bowser from 'bowser';
///: BEGIN:ONLY_INCLUDE_IN(snaps) ///: BEGIN:ONLY_INCLUDE_IN(snaps)
import { getSnapPrefix } from '@metamask/snaps-utils'; import { getSnapPrefix } from '@metamask/snaps-utils';
import { WALLET_SNAP_PERMISSION_KEY } from '@metamask/rpc-methods';
import { isObject } from '@metamask/utils';
///: END:ONLY_INCLUDE_IN ///: END:ONLY_INCLUDE_IN
import { CHAIN_IDS, NETWORK_TYPES } from '../../../shared/constants/network'; import { CHAIN_IDS, NETWORK_TYPES } from '../../../shared/constants/network';
import { import {
@ -566,6 +568,25 @@ export const getSnapName = (snapId, subjectMetadata) => {
return subjectMetadata?.name ?? removeSnapIdPrefix(snapId); return subjectMetadata?.name ?? removeSnapIdPrefix(snapId);
}; };
export const getDedupedSnaps = (request, permissions) => {
const permission = request?.permissions?.[WALLET_SNAP_PERMISSION_KEY];
const requestedSnaps = permission?.caveats[0].value;
const currentSnaps =
permissions?.[WALLET_SNAP_PERMISSION_KEY]?.caveats[0].value;
if (!isObject(currentSnaps) && requestedSnaps) {
return Object.keys(requestedSnaps);
}
const requestedSnapKeys = requestedSnaps ? Object.keys(requestedSnaps) : [];
const currentSnapKeys = currentSnaps ? Object.keys(currentSnaps) : [];
const dedupedSnaps = requestedSnapKeys.filter(
(snapId) => !currentSnapKeys.includes(snapId),
);
return dedupedSnaps.length > 0 ? dedupedSnaps : requestedSnapKeys;
};
///: END:ONLY_INCLUDE_IN ///: END:ONLY_INCLUDE_IN
/** /**

View File

@ -2,6 +2,7 @@
@import 'snaps/snap-install/index'; @import 'snaps/snap-install/index';
@import 'snaps/snap-update/index'; @import 'snaps/snap-update/index';
@import 'snaps/snap-result/index'; @import 'snaps/snap-result/index';
@import 'snaps/snaps-connect/index';
@import 'redirect/index'; @import 'redirect/index';
.permissions-connect { .permissions-connect {

View File

@ -13,6 +13,7 @@ import { Icon, IconName, IconSize } from '../../components/component-library';
import ChooseAccount from './choose-account'; import ChooseAccount from './choose-account';
import PermissionsRedirect from './redirect'; import PermissionsRedirect from './redirect';
///: BEGIN:ONLY_INCLUDE_IN(snaps) ///: BEGIN:ONLY_INCLUDE_IN(snaps)
import SnapsConnect from './snaps/snaps-connect';
import SnapInstall from './snaps/snap-install'; import SnapInstall from './snaps/snap-install';
import SnapUpdate from './snaps/snap-update'; import SnapUpdate from './snaps/snap-update';
import SnapResult from './snaps/snap-result'; import SnapResult from './snaps/snap-result';
@ -40,6 +41,7 @@ export default class PermissionConnect extends Component {
confirmPermissionPath: PropTypes.string.isRequired, confirmPermissionPath: PropTypes.string.isRequired,
requestType: PropTypes.string.isRequired, requestType: PropTypes.string.isRequired,
///: BEGIN:ONLY_INCLUDE_IN(snaps) ///: BEGIN:ONLY_INCLUDE_IN(snaps)
snapsConnectPath: PropTypes.string.isRequired,
snapInstallPath: PropTypes.string.isRequired, snapInstallPath: PropTypes.string.isRequired,
snapUpdatePath: PropTypes.string.isRequired, snapUpdatePath: PropTypes.string.isRequired,
snapResultPath: PropTypes.string.isRequired, snapResultPath: PropTypes.string.isRequired,
@ -105,6 +107,7 @@ export default class PermissionConnect extends Component {
connectPath, connectPath,
confirmPermissionPath, confirmPermissionPath,
///: BEGIN:ONLY_INCLUDE_IN(snaps) ///: BEGIN:ONLY_INCLUDE_IN(snaps)
snapsConnectPath,
snapInstallPath, snapInstallPath,
snapUpdatePath, snapUpdatePath,
snapResultPath, snapResultPath,
@ -140,6 +143,9 @@ export default class PermissionConnect extends Component {
case 'wallet_installSnapResult': case 'wallet_installSnapResult':
history.replace(snapResultPath); history.replace(snapResultPath);
break; break;
case 'wallet_connectSnaps':
history.replace(snapsConnectPath);
break;
default: default:
///: END:ONLY_INCLUDE_IN ///: END:ONLY_INCLUDE_IN
history.replace(confirmPermissionPath); history.replace(confirmPermissionPath);
@ -183,6 +189,7 @@ export default class PermissionConnect extends Component {
confirmPermissionPath, confirmPermissionPath,
requestType, requestType,
///: BEGIN:ONLY_INCLUDE_IN(snaps) ///: BEGIN:ONLY_INCLUDE_IN(snaps)
snapsConnectPath,
snapInstallPath, snapInstallPath,
snapUpdatePath, snapUpdatePath,
snapResultPath, snapResultPath,
@ -204,6 +211,9 @@ export default class PermissionConnect extends Component {
case 'wallet_installSnapResult': case 'wallet_installSnapResult':
this.props.history.push(snapResultPath); this.props.history.push(snapResultPath);
break; break;
case 'wallet_connectSnaps':
this.props.history.replace(snapsConnectPath);
break;
///: END:ONLY_INCLUDE_IN ///: END:ONLY_INCLUDE_IN
default: default:
this.props.history.push(confirmPermissionPath); this.props.history.push(confirmPermissionPath);
@ -299,6 +309,7 @@ export default class PermissionConnect extends Component {
confirmPermissionPath, confirmPermissionPath,
hideTopBar, hideTopBar,
///: BEGIN:ONLY_INCLUDE_IN(snaps) ///: BEGIN:ONLY_INCLUDE_IN(snaps)
snapsConnectPath,
snapInstallPath, snapInstallPath,
snapUpdatePath, snapUpdatePath,
snapResultPath, snapResultPath,
@ -381,6 +392,35 @@ export default class PermissionConnect extends Component {
{ {
///: BEGIN:ONLY_INCLUDE_IN(snaps) ///: BEGIN:ONLY_INCLUDE_IN(snaps)
} }
<Route
path={snapsConnectPath}
exact
render={() => (
<SnapsConnect
request={permissionsRequest || {}}
approveConnection={(...args) => {
approvePermissionsRequest(...args);
this.redirect(true);
}}
rejectConnection={(requestId) =>
this.cancelPermissionsRequest(requestId)
}
targetSubjectMetadata={targetSubjectMetadata}
snapsInstallPrivacyWarningShown={
snapsInstallPrivacyWarningShown
}
setSnapsInstallPrivacyWarningShownStatus={
setSnapsInstallPrivacyWarningShownStatus
}
/>
)}
/>
{
///: END:ONLY_INCLUDE_IN
}
{
///: BEGIN:ONLY_INCLUDE_IN(snaps)
}
<Route <Route
path={snapInstallPath} path={snapInstallPath}
exact exact

View File

@ -1,4 +1,7 @@
import { SubjectType } from '@metamask/subject-metadata-controller'; import { SubjectType } from '@metamask/subject-metadata-controller';
///: BEGIN:ONLY_INCLUDE_IN(snaps)
import { WALLET_SNAP_PERMISSION_KEY } from '@metamask/rpc-methods';
///: END:ONLY_INCLUDE_IN
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import { import {
@ -32,6 +35,7 @@ import {
CONNECT_ROUTE, CONNECT_ROUTE,
CONNECT_CONFIRM_PERMISSIONS_ROUTE, CONNECT_CONFIRM_PERMISSIONS_ROUTE,
///: BEGIN:ONLY_INCLUDE_IN(snaps) ///: BEGIN:ONLY_INCLUDE_IN(snaps)
CONNECT_SNAPS_CONNECT_ROUTE,
CONNECT_SNAP_INSTALL_ROUTE, CONNECT_SNAP_INSTALL_ROUTE,
CONNECT_SNAP_UPDATE_ROUTE, CONNECT_SNAP_UPDATE_ROUTE,
CONNECT_SNAP_RESULT_ROUTE, CONNECT_SNAP_RESULT_ROUTE,
@ -75,10 +79,21 @@ const mapStateToProps = (state, ownProps) => {
subjectType: SubjectType.Unknown, subjectType: SubjectType.Unknown,
}; };
const requestType = getRequestType(state, permissionsRequestId); let requestType = getRequestType(state, permissionsRequestId);
///: BEGIN:ONLY_INCLUDE_IN(snaps) ///: BEGIN:ONLY_INCLUDE_IN(snaps)
const requestState = getRequestState(state, permissionsRequestId); // We want to only assign the wallet_connectSnaps request type (i.e. only show
// SnapsConnect) if and only if we get a singular wallet_snap permission request.
// Any other request gets pushed to the normal permission connect flow.
if (
permissionsRequest &&
Object.keys(permissionsRequest.permissions || {}).length === 1 &&
permissionsRequest.permissions?.[WALLET_SNAP_PERMISSION_KEY]
) {
requestType = 'wallet_connectSnaps';
}
const requestState = getRequestState(state, permissionsRequestId) || {};
///: END:ONLY_INCLUDE_IN ///: END:ONLY_INCLUDE_IN
const accountsWithLabels = getAccountsWithLabels(state); const accountsWithLabels = getAccountsWithLabels(state);
@ -96,6 +111,7 @@ const mapStateToProps = (state, ownProps) => {
const connectPath = `${CONNECT_ROUTE}/${permissionsRequestId}`; const connectPath = `${CONNECT_ROUTE}/${permissionsRequestId}`;
const confirmPermissionPath = `${CONNECT_ROUTE}/${permissionsRequestId}${CONNECT_CONFIRM_PERMISSIONS_ROUTE}`; const confirmPermissionPath = `${CONNECT_ROUTE}/${permissionsRequestId}${CONNECT_CONFIRM_PERMISSIONS_ROUTE}`;
///: BEGIN:ONLY_INCLUDE_IN(snaps) ///: BEGIN:ONLY_INCLUDE_IN(snaps)
const snapsConnectPath = `${CONNECT_ROUTE}/${permissionsRequestId}${CONNECT_SNAPS_CONNECT_ROUTE}`;
const snapInstallPath = `${CONNECT_ROUTE}/${permissionsRequestId}${CONNECT_SNAP_INSTALL_ROUTE}`; const snapInstallPath = `${CONNECT_ROUTE}/${permissionsRequestId}${CONNECT_SNAP_INSTALL_ROUTE}`;
const snapUpdatePath = `${CONNECT_ROUTE}/${permissionsRequestId}${CONNECT_SNAP_UPDATE_ROUTE}`; const snapUpdatePath = `${CONNECT_ROUTE}/${permissionsRequestId}${CONNECT_SNAP_UPDATE_ROUTE}`;
const snapResultPath = `${CONNECT_ROUTE}/${permissionsRequestId}${CONNECT_SNAP_RESULT_ROUTE}`; const snapResultPath = `${CONNECT_ROUTE}/${permissionsRequestId}${CONNECT_SNAP_RESULT_ROUTE}`;
@ -119,6 +135,8 @@ const mapStateToProps = (state, ownProps) => {
///: BEGIN:ONLY_INCLUDE_IN(snaps) ///: BEGIN:ONLY_INCLUDE_IN(snaps)
} else if (isSnapInstallOrUpdateOrResult) { } else if (isSnapInstallOrUpdateOrResult) {
page = isRequestingAccounts ? '3' : '2'; page = isRequestingAccounts ? '3' : '2';
} else if (pathname === snapsConnectPath) {
page = 1;
///: END:ONLY_INCLUDE_IN ///: END:ONLY_INCLUDE_IN
} else { } else {
throw new Error('Incorrect path for permissions-connect component'); throw new Error('Incorrect path for permissions-connect component');
@ -128,6 +146,7 @@ const mapStateToProps = (state, ownProps) => {
isRequestingAccounts, isRequestingAccounts,
requestType, requestType,
///: BEGIN:ONLY_INCLUDE_IN(snaps) ///: BEGIN:ONLY_INCLUDE_IN(snaps)
snapsConnectPath,
snapInstallPath, snapInstallPath,
snapUpdatePath, snapUpdatePath,
snapResultPath, snapResultPath,

View File

@ -18,7 +18,6 @@ import {
} from '../../../../helpers/constants/design-system'; } from '../../../../helpers/constants/design-system';
import { getSnapInstallWarnings } from '../util'; import { getSnapInstallWarnings } from '../util';
import PulseLoader from '../../../../components/ui/pulse-loader/pulse-loader'; import PulseLoader from '../../../../components/ui/pulse-loader/pulse-loader';
import InstallError from '../../../../components/app/snaps/install-error/install-error';
import SnapAuthorshipHeader from '../../../../components/app/snaps/snap-authorship-header'; import SnapAuthorshipHeader from '../../../../components/app/snaps/snap-authorship-header';
import { import {
AvatarIcon, AvatarIcon,
@ -29,6 +28,9 @@ import {
import { getSnapName } from '../../../../helpers/utils/util'; import { getSnapName } from '../../../../helpers/utils/util';
import SnapPermissionsList from '../../../../components/app/snaps/snap-permissions-list'; import SnapPermissionsList from '../../../../components/app/snaps/snap-permissions-list';
import { useScrollRequired } from '../../../../hooks/useScrollRequired'; import { useScrollRequired } from '../../../../hooks/useScrollRequired';
import SiteOrigin from '../../../../components/ui/site-origin/site-origin';
import InstallError from '../../../../components/app/snaps/install-error/install-error';
import { useOriginMetadata } from '../../../../hooks/useOriginMetadata';
export default function SnapInstall({ export default function SnapInstall({
request, request,
@ -38,7 +40,8 @@ export default function SnapInstall({
targetSubjectMetadata, targetSubjectMetadata,
}) { }) {
const t = useI18nContext(); const t = useI18nContext();
const siteMetadata = useOriginMetadata(request?.metadata?.dappOrigin) || {};
const { origin, iconUrl, name } = siteMetadata;
const [isShowingWarning, setIsShowingWarning] = useState(false); const [isShowingWarning, setIsShowingWarning] = useState(false);
const { isScrollable, isScrolledToBottom, scrollToBottom, ref, onScroll } = const { isScrollable, isScrolledToBottom, scrollToBottom, ref, onScroll } =
@ -78,6 +81,15 @@ export default function SnapInstall({
} }
}; };
const getFooterMessage = () => {
if (hasError) {
return 'ok';
} else if (isLoading) {
return 'connect';
}
return 'install';
};
return ( return (
<Box <Box
className="page-container snap-install" className="page-container snap-install"
@ -86,14 +98,31 @@ export default function SnapInstall({
borderStyle={BorderStyle.none} borderStyle={BorderStyle.none}
flexDirection={FLEX_DIRECTION.COLUMN} flexDirection={FLEX_DIRECTION.COLUMN}
> >
{isLoading || hasError ? (
<Box
width="full"
alignItems={AlignItems.center}
justifyContent={JustifyContent.center}
paddingTop={4}
>
<SiteOrigin
chip
siteOrigin={origin}
title={origin}
iconSrc={iconUrl}
iconName={name}
/>
</Box>
) : (
<SnapAuthorshipHeader snapId={targetSubjectMetadata.origin} /> <SnapAuthorshipHeader snapId={targetSubjectMetadata.origin} />
)}
<Box <Box
ref={ref} ref={ref}
onScroll={onScroll} onScroll={onScroll}
className="snap-install__content" className="snap-install__content"
style={{ style={{
overflowY: 'auto', overflowY: 'auto',
flex: !isLoading && '1', flex: !isLoading && !hasError && '1',
}} }}
> >
{isLoading && ( {isLoading && (
@ -107,7 +136,16 @@ export default function SnapInstall({
</Box> </Box>
)} )}
{hasError && ( {hasError && (
<InstallError error={requestState.error} title={t('requestFailed')} /> <InstallError
iconName={IconName.Warning}
title={t('connectionFailed')}
description={t('connectionFailedDescription', [
<Text as={ValidTag.Span} key="1" fontWeight={FontWeight.Medium}>
{snapName}
</Text>,
])}
error={requestState.error}
/>
)} )}
{!hasError && !isLoading && ( {!hasError && !isLoading && (
<> <>
@ -172,7 +210,7 @@ export default function SnapInstall({
onCancel={onCancel} onCancel={onCancel}
cancelText={t('cancel')} cancelText={t('cancel')}
onSubmit={handleSubmit} onSubmit={handleSubmit}
submitText={t(hasError ? 'ok' : 'install')} submitText={t(getFooterMessage())}
/> />
</Box> </Box>
{isShowingWarning && ( {isShowingWarning && (

View File

@ -0,0 +1 @@
export { default } from './snaps-connect';

View File

@ -0,0 +1,9 @@
.snaps-connect {
overflow-y: hidden;
.page-container__footer {
border-top: 0;
margin-top: auto;
box-shadow: var(--shadow-size-lg) var(--color-shadow-default);
}
}

View File

@ -0,0 +1,251 @@
import React, { useCallback, useState } from 'react';
import { useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import { useI18nContext } from '../../../../hooks/useI18nContext';
import Box from '../../../../components/ui/box';
import SiteOrigin from '../../../../components/ui/site-origin';
import {
IconSize,
Text,
ValidTag,
} from '../../../../components/component-library';
import {
FlexDirection,
TextVariant,
JustifyContent,
AlignItems,
TextAlign,
Display,
FontWeight,
BlockSize,
} from '../../../../helpers/constants/design-system';
import { PageContainerFooter } from '../../../../components/ui/page-container';
import SnapConnectCell from '../../../../components/app/snaps/snap-connect-cell/snap-connect-cell';
import { getDedupedSnaps, getSnapName } from '../../../../helpers/utils/util';
import PulseLoader from '../../../../components/ui/pulse-loader/pulse-loader';
import SnapPrivacyWarning from '../../../../components/app/snaps/snap-privacy-warning/snap-privacy-warning';
import {
getPermissions,
getTargetSubjectMetadata,
} from '../../../../selectors';
import SnapAvatar from '../../../../components/app/snaps/snap-avatar/snap-avatar';
import { useOriginMetadata } from '../../../../hooks/useOriginMetadata';
export default function SnapsConnect({
request,
approveConnection,
rejectConnection,
targetSubjectMetadata,
snapsInstallPrivacyWarningShown,
setSnapsInstallPrivacyWarningShownStatus,
}) {
const t = useI18nContext();
const { origin, iconUrl, name } = targetSubjectMetadata;
const [isLoading, setIsLoading] = useState(false);
const [isShowingSnapsPrivacyWarning, setIsShowingSnapsPrivacyWarning] =
useState(!snapsInstallPrivacyWarningShown);
const currentPermissions = useSelector((state) =>
getPermissions(state, request?.metadata?.origin),
);
const onCancel = useCallback(() => {
rejectConnection(request.metadata.id);
}, [request, rejectConnection]);
const onConnect = useCallback(() => {
try {
setIsLoading(true);
approveConnection(request);
} finally {
setIsLoading(false);
}
}, [request, approveConnection]);
const snaps = getDedupedSnaps(request, currentPermissions);
const singularConnectSnapMetadata = useSelector((state) =>
getTargetSubjectMetadata(state, snaps?.[0]),
);
const SnapsConnectContent = () => {
const { hostname: trimmedOrigin } = useOriginMetadata(origin) || {};
if (isLoading) {
return (
<Box
className="snap-connect__loader-container"
flexDirection={FlexDirection.Column}
alignItems={AlignItems.center}
justifyContent={JustifyContent.center}
>
<PulseLoader />
</Box>
);
}
if (snaps?.length > 1) {
return (
<Box
className="snaps-connect__content"
flexDirection={FlexDirection.Column}
justifyContent={JustifyContent.center}
alignItems={AlignItems.center}
paddingLeft={4}
paddingRight={4}
paddingTop={8}
width={BlockSize.Full}
style={{ overflowY: 'hidden' }}
>
<Text paddingBottom={2} variant={TextVariant.headingLg}>
{t('connectionRequest')}
</Text>
<Text variant={TextVariant.bodyMd} textAlign={TextAlign.Center}>
{t('multipleSnapConnectionWarning', [
<Text
as={ValidTag.Span}
key="1"
variant={TextVariant.bodyMd}
fontWeight={FontWeight.Medium}
>
{trimmedOrigin}
</Text>,
<Text
as={ValidTag.Span}
key="2"
variant={TextVariant.bodyMd}
fontWeight={FontWeight.Medium}
>
{snaps?.length}
</Text>,
])}
</Text>
<Box
className="snaps-connect__content__snaps-list"
flexDirection={FlexDirection.Column}
display={Display.Flex}
marginTop={4}
width={BlockSize.Full}
style={{ overflowY: 'auto', flex: 1 }}
>
{snaps.map((snap) => (
// TODO(hbmalik88): add in the iconUrl prop when we have access to a snap's icons pre-installation
<SnapConnectCell
key={`snaps-connect-${snap}`}
snapId={snap}
origin={trimmedOrigin}
/>
))}
</Box>
</Box>
);
} else if (snaps?.length === 1) {
const snapId = snaps[0];
const snapName = getSnapName(snapId, singularConnectSnapMetadata);
return (
<Box
className="snaps-connect__content"
flexDirection={FlexDirection.Column}
justifyContent={JustifyContent.center}
alignItems={AlignItems.center}
height={BlockSize.Full}
paddingLeft={4}
paddingRight={4}
>
<Box paddingBottom={2}>
<SnapAvatar
snapId={snaps[0]}
badgeSize={IconSize.Md}
avatarSize={IconSize.Xl}
borderWidth={3}
/>
</Box>
<Text paddingBottom={2} variant={TextVariant.headingLg}>
{t('connectionRequest')}
</Text>
<Text
variant={TextVariant.bodyMd}
textAlign={TextAlign.Center}
padding={[0, 4]}
>
{t('snapConnectionWarning', [
<Text
as={ValidTag.Span}
key="1"
variant={TextVariant.bodyMd}
fontWeight={FontWeight.Medium}
>
{trimmedOrigin}
</Text>,
<Text
as={ValidTag.Span}
key="2"
variant={TextVariant.bodyMd}
fontWeight={FontWeight.Medium}
>
{snapName}
</Text>,
])}
</Text>
</Box>
);
}
return null;
};
return (
<Box
className="page-container snaps-connect"
flexDirection={FlexDirection.Column}
alignItems={AlignItems.center}
>
{isShowingSnapsPrivacyWarning && (
<SnapPrivacyWarning
onAccepted={() => {
setIsShowingSnapsPrivacyWarning(false);
setSnapsInstallPrivacyWarningShownStatus(true);
}}
onCanceled={onCancel}
/>
)}
<Box
className="snaps-connect__header"
flexDirection={FlexDirection.Column}
alignItems={AlignItems.center}
paddingLeft={4}
paddingRight={4}
>
<SiteOrigin
chip
siteOrigin={origin}
title={origin}
iconSrc={iconUrl}
iconName={name}
/>
</Box>
<SnapsConnectContent />
<PageContainerFooter
footerClassName="snaps-connect__footer"
cancelButtonType="default"
hideCancel={false}
disabled={isLoading}
onCancel={onCancel}
cancelText={t('cancel')}
onSubmit={onConnect}
submitText={t('connect')}
/>
</Box>
);
}
SnapsConnect.propTypes = {
request: PropTypes.object.isRequired,
approveConnection: PropTypes.func.isRequired,
rejectConnection: PropTypes.func.isRequired,
targetSubjectMetadata: PropTypes.shape({
extensionId: PropTypes.string,
iconUrl: PropTypes.string,
name: PropTypes.string,
origin: PropTypes.string,
subjectType: PropTypes.string,
}),
snapsInstallPrivacyWarningShown: PropTypes.bool.isRequired,
setSnapsInstallPrivacyWarningShownStatus: PropTypes.func,
};

View File

@ -0,0 +1,102 @@
import React from 'react';
import { Provider } from 'react-redux';
import configureStore from '../../../../store/store';
import mockState from '../../../../../test/data/mock-state.json';
import SnapsConnect from '.';
const store = configureStore(mockState);
export default {
title: 'Pages/Snaps/SnapConnect',
component: SnapsConnect,
argTypes: {},
decorators: [(story) => <Provider store={store}>{story()}</Provider>],
};
export const DefaultStory = (args) => <SnapsConnect {...args} />;
DefaultStory.storyName = 'Default';
DefaultStory.args = {
request: {
metadata: {
id: 'foo',
},
permissions: {
wallet_snap: {
caveats: [
{
value: {
'npm:@metamask/test-snap-bip44': {},
},
},
],
},
},
},
targetSubjectMetadata: {
origin: 'https://metamask.io',
},
};
export const MultiStory = (args) => <SnapsConnect {...args} />;
MultiStory.storyName = 'Multi';
MultiStory.args = {
request: {
metadata: {
id: 'foo',
},
permissions: {
wallet_snap: {
caveats: [
{
value: {
'npm:@metamask/test-snap-bip44': {},
'npm:@metamask/test-snap-bip32': {},
'npm:@metamask/test-snap-getEntropy': {},
},
},
],
},
},
},
targetSubjectMetadata: {
origin: 'https://metamask.io',
},
};
export const ScrollingStory = (args) => <SnapsConnect {...args} />;
ScrollingStory.storyName = 'Scrolling';
ScrollingStory.args = {
request: {
metadata: {
id: 'foo',
},
permissions: {
wallet_snap: {
caveats: [
{
value: {
'npm:@metamask/test-snap-bip44': {},
'npm:@metamask/test-snap-bip32': {},
'npm:@metamask/test-snap-getEntropy': {},
'npm:@metamask/test-snap-networkAccess': {},
'npm:@metamask/test-snap-wasm': {},
'npm:@metamask/test-snap-notify': {},
'npm:@metamask/test-snap-dialog': {},
},
},
],
},
},
},
targetSubjectMetadata: {
origin: 'https://metamask.io',
},
};