From 5827b5969a1d7599acc32ce626ad19ad99a3d8e3 Mon Sep 17 00:00:00 2001 From: Frederik Bolding Date: Mon, 1 Jul 2024 12:40:33 +0200 Subject: [PATCH] Revamp Snap connection screen --- .../InstallSnapApproval.constants.ts | 2 +- .../InstallSnapApproval.styles.ts | 6 +- .../InstallSnapApproval.tsx | 69 +++++++++++++----- .../InstallSnapApproval.types.ts | 4 +- .../InstallSnapConnectionRequest.tsx | 32 +++----- .../InstallSnapConnectionRequest/index.ts | 2 +- .../InstallSnapConnectionRequest.test.tsx | 2 +- .../InstallSnapApproval/components/index.ts | 9 ++- .../Approvals/InstallSnapApproval/index.ts | 2 +- app/components/Nav/Main/RootRPCMethodsUI.js | 4 +- .../UI/Snaps/SnapAvatar/SnapAvatar.styles.ts | 39 ++++++++++ .../UI/Snaps/SnapAvatar/SnapAvatar.tsx | 73 +++++++++++++++++++ app/core/EngineService/EngineService.ts | 2 +- app/selectors/snaps/permissionController.ts | 30 ++++++++ app/selectors/snaps/snapController.ts | 37 ++++++++++ locales/languages/en.json | 2 +- 16 files changed, 264 insertions(+), 51 deletions(-) create mode 100644 app/components/UI/Snaps/SnapAvatar/SnapAvatar.styles.ts create mode 100644 app/components/UI/Snaps/SnapAvatar/SnapAvatar.tsx create mode 100644 app/selectors/snaps/permissionController.ts create mode 100644 app/selectors/snaps/snapController.ts diff --git a/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.constants.ts b/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.constants.ts index 7cca5eabcff..f5f3353dd06 100644 --- a/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.constants.ts +++ b/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.constants.ts @@ -1,4 +1,4 @@ -///: BEGIN:ONLY_INCLUDE_IF(external-snaps) +///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) export const SNAP_INSTALL_FLOW = 'snap-install-flow'; export const SNAP_INSTALL_OK = 'snap-install-ok'; ///: END:ONLY_INCLUDE_IF diff --git a/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.styles.ts b/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.styles.ts index 0788c1ba427..54af3331d92 100644 --- a/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.styles.ts +++ b/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.styles.ts @@ -1,4 +1,4 @@ -///: BEGIN:ONLY_INCLUDE_IF(external-snaps) +///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) import { StyleSheet } from 'react-native'; import { Theme } from '../../../util/theme/models'; import Device from '../../../util/device'; @@ -37,6 +37,10 @@ const styleSheet = (params: { theme: Theme }) => { snapCell: { marginVertical: 16, }, + snapAvatar: { + alignSelf: 'center', + marginTop: 16, + }, snapPermissionContainer: { maxHeight: 300, borderWidth: 1, diff --git a/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.tsx b/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.tsx index f30f44189e5..ffd254867a9 100644 --- a/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.tsx +++ b/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.tsx @@ -1,32 +1,50 @@ -///: BEGIN:ONLY_INCLUDE_IF(external-snaps) +///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) import React, { useEffect, useState } from 'react'; import ApprovalModal from '../ApprovalModal'; import useApprovalRequest from '../../Views/confirmations/hooks/useApprovalRequest'; import { ApprovalTypes } from '../../../core/RPCMethods/RPCMethodMiddleware'; -import Logger from '../../../util/Logger'; import { SnapInstallState } from './InstallSnapApproval.types'; import { InstallSnapConnectionRequest, + ///: END:ONLY_INCLUDE_IF + ///: BEGIN:ONLY_INCLUDE_IF(external-snaps) InstallSnapError, InstallSnapPermissionsRequest, InstallSnapSuccess, + ///: END:ONLY_INCLUDE_IF + ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) } from './components'; -import { SNAP_INSTALL_FLOW } from './InstallSnapApproval.constants'; import { ApprovalRequest } from '@metamask/approval-controller'; +import { useSelector } from 'react-redux'; +import { selectSnapsMetadata } from '../../../selectors/snaps/snapController'; +import { + WALLET_SNAP_PERMISSION_KEY, + stripSnapPrefix, +} from '@metamask/snaps-utils'; const InstallSnapApproval = () => { + const snapsMetadata = useSelector(selectSnapsMetadata); + const [installState, setInstallState] = useState< SnapInstallState | undefined >(undefined); - const [isFinished, setIsFinished] = useState(false); + ///: END:ONLY_INCLUDE_IF + ///: BEGIN:ONLY_INCLUDE_IF(external-snaps) const [installError, setInstallError] = useState( undefined, ); + ///: END:ONLY_INCLUDE_IF + ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) const { approvalRequest, onConfirm, onReject } = useApprovalRequest(); useEffect(() => { if (approvalRequest) { - if (approvalRequest.type === ApprovalTypes.REQUEST_PERMISSIONS) { + if ( + approvalRequest.type === ApprovalTypes.REQUEST_PERMISSIONS && + Object.keys(approvalRequest?.requestData?.permissions).includes( + WALLET_SNAP_PERMISSION_KEY, + ) + ) { setInstallState(SnapInstallState.Confirm); } else if ( approvalRequest.type === ApprovalTypes.INSTALL_SNAP && @@ -41,14 +59,11 @@ const InstallSnapApproval = () => { // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any - const getSnapName = (request: ApprovalRequest): string => { + const getSnapId = (request: ApprovalRequest): string => { // We first look for the name inside the snapId approvalRequest data const snapId = request?.requestData?.snapId; if (typeof snapId === 'string') { - const colonIndex = snapId.indexOf(':'); - if (colonIndex !== -1) { - return snapId.substring(colonIndex + 1); - } + return snapId; } // If there is no snapId present in the approvalRequest data, we look for the name inside the snapIds caveat const snapIdsCaveat = @@ -57,14 +72,19 @@ const InstallSnapApproval = () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any (c: any) => c.type === 'snapIds', ); - // return an empty string if we can't find the snap name in the approvalRequest data - return snapIdsCaveat?.value ? Object.keys(snapIdsCaveat.value)[0] : ''; + return Object.keys(snapIdsCaveat.value)[0]; }; + const getSnapMetadata = (snapId: string) => + snapsMetadata[snapId] ?? { name: stripSnapPrefix(snapId) }; + if (!approvalRequest) return null; + ///: END:ONLY_INCLUDE_IF + ///: BEGIN:ONLY_INCLUDE_IF(external-snaps) + const onInstallSnapFinished = () => { - setIsFinished(true); + setInstallState(SnapInstallState.SnapInstallFinished); }; const onPermissionsConfirm = async () => { @@ -75,34 +95,38 @@ const InstallSnapApproval = () => { }); setInstallState(SnapInstallState.SnapInstalled); } catch (error) { - Logger.error( - error as Error, - `${SNAP_INSTALL_FLOW} Failed to install snap`, - ); setInstallError(error as Error); setInstallState(SnapInstallState.SnapInstallError); } }; + ///: END:ONLY_INCLUDE_IF + ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) - if (!approvalRequest) return null; + if (!approvalRequest || installState === undefined) return null; - const snapName = getSnapName(approvalRequest); + const snapId = getSnapId(approvalRequest); + const snapName = getSnapMetadata(snapId).name; + // TODO: This component should support connecting to multiple Snaps at once. const renderModalContent = () => { switch (installState) { case SnapInstallState.Confirm: return ( ); + ///: END:ONLY_INCLUDE_IF + ///: BEGIN:ONLY_INCLUDE_IF(external-snaps) case SnapInstallState.AcceptPermissions: return ( { error={installError} /> ); + ///: END:ONLY_INCLUDE_IF + ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) default: return null; } @@ -132,7 +158,10 @@ const InstallSnapApproval = () => { return content ? ( {content} diff --git a/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.types.ts b/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.types.ts index 00315095b33..8a954c5c47f 100644 --- a/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.types.ts +++ b/app/components/Approvals/InstallSnapApproval/InstallSnapApproval.types.ts @@ -1,8 +1,9 @@ -///: BEGIN:ONLY_INCLUDE_IF(external-snaps) +///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) interface InstallSnapFlowProps { // TODO: Replace "any" with type // eslint-disable-next-line @typescript-eslint/no-explicit-any approvalRequest: any; + snapId: string; snapName: string; onConfirm: () => void; onCancel: () => void; @@ -15,6 +16,7 @@ export enum SnapInstallState { AcceptPermissions = 'AcceptPermissions', SnapInstalled = 'SnapInstalled', SnapInstallError = 'SnapInstallError', + SnapInstallFinished = 'SnapInstallFinished', } // eslint-disable-next-line import/prefer-default-export diff --git a/app/components/Approvals/InstallSnapApproval/components/InstallSnapConnectionRequest/InstallSnapConnectionRequest.tsx b/app/components/Approvals/InstallSnapApproval/components/InstallSnapConnectionRequest/InstallSnapConnectionRequest.tsx index ad646e4b063..d7675b83a25 100644 --- a/app/components/Approvals/InstallSnapApproval/components/InstallSnapConnectionRequest/InstallSnapConnectionRequest.tsx +++ b/app/components/Approvals/InstallSnapApproval/components/InstallSnapConnectionRequest/InstallSnapConnectionRequest.tsx @@ -1,6 +1,6 @@ -///: BEGIN:ONLY_INCLUDE_IF(external-snaps) +///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) import React, { useMemo } from 'react'; -import { ImageSourcePropType, View } from 'react-native'; +import { View } from 'react-native'; import { InstallSnapFlowProps } from '../../InstallSnapApproval.types'; import styleSheet from '../../InstallSnapApproval.styles'; import { strings } from '../../../../../../locales/i18n'; @@ -11,10 +11,6 @@ import Text, { import TagUrl from '../../../../../component-library/components/Tags/TagUrl'; import { getUrlObj, prefixUrlWithProtocol } from '../../../../../util/browser'; import { IconName } from '../../../../../component-library/components/Icons/Icon'; -import Cell, { - CellVariant, -} from '../../../../../component-library/components/Cells/Cell'; -import { AvatarVariant } from '../../../../../component-library/components/Avatars/Avatar'; import { ButtonSize, ButtonVariants, @@ -29,15 +25,18 @@ import { SNAP_INSTALL_CONNECT, SNAP_INSTALL_CONNECTION_REQUEST, } from './InstallSnapConnectionRequest.constants'; +import { useFavicon } from '../../../../hooks/useFavicon'; +import { SnapAvatar } from '../../../../UI/Snaps/SnapAvatar/SnapAvatar'; const InstallSnapConnectionRequest = ({ approvalRequest, + snapId, snapName, onConfirm, onCancel, }: Pick< InstallSnapFlowProps, - 'approvalRequest' | 'onConfirm' | 'onCancel' | 'snapName' + 'approvalRequest' | 'onConfirm' | 'onCancel' | 'snapId' | 'snapName' >) => { const { styles } = useStyles(styleSheet, {}); @@ -46,10 +45,7 @@ const InstallSnapConnectionRequest = ({ [approvalRequest.origin], ); - const favicon: ImageSourcePropType = useMemo(() => { - const iconUrl = `https://api.faviconkit.com/${origin}/50`; - return { uri: iconUrl }; - }, [origin]); + const favicon = useFavicon(origin); const urlWithProtocol = prefixUrlWithProtocol(origin); @@ -85,6 +81,11 @@ const InstallSnapConnectionRequest = ({ label={urlWithProtocol} iconName={secureIcon} /> + {strings('install_snap.description', { @@ -92,15 +93,6 @@ const InstallSnapConnectionRequest = ({ snap: snapName, })} - { { - ///: BEGIN:ONLY_INCLUDE_IF(external-snaps) + ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) } { diff --git a/app/components/UI/Snaps/SnapAvatar/SnapAvatar.styles.ts b/app/components/UI/Snaps/SnapAvatar/SnapAvatar.styles.ts new file mode 100644 index 00000000000..04571d4ea74 --- /dev/null +++ b/app/components/UI/Snaps/SnapAvatar/SnapAvatar.styles.ts @@ -0,0 +1,39 @@ +///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) +import { StyleSheet } from 'react-native'; +import { Theme } from '../../../../util/theme/models'; + +/** + * + * @param params Style sheet params. + * @param params.theme App theme from ThemeContext. + * @param params.vars Inputs that the style sheet depends on. + * @returns StyleSheet object. + */ +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + const { colors } = theme; + return StyleSheet.create({ + avatar: { + backgroundColor: colors.background.alternativeHover, + }, + fallbackAvatar: { + backgroundColor: colors.background.alternativeHover, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + color: colors.text.alternative, + }, + fallbackAvatarText: { + textTransform: 'uppercase', + }, + badge: { + backgroundColor: colors.info.default, + color: colors.info.inverse, + borderColor: colors.background.alternative, + borderWidth: 2, + }, + }); +}; + +export default styleSheet; +///: END:ONLY_INCLUDE_IF diff --git a/app/components/UI/Snaps/SnapAvatar/SnapAvatar.tsx b/app/components/UI/Snaps/SnapAvatar/SnapAvatar.tsx new file mode 100644 index 00000000000..2ad33542ef6 --- /dev/null +++ b/app/components/UI/Snaps/SnapAvatar/SnapAvatar.tsx @@ -0,0 +1,73 @@ +/* eslint-disable react/prop-types */ +///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) +import React from 'react'; +import { useSelector } from 'react-redux'; +import AvatarFavicon from '../../../../component-library/components/Avatars/Avatar/variants/AvatarFavicon'; +import { selectTargetSubjectMetadata } from '../../../../selectors/snaps/permissionController'; +import BadgeWrapper from '../../../../component-library/components/Badges/BadgeWrapper'; +import { BadgePosition } from '../../../../component-library/components/Badges/BadgeWrapper/BadgeWrapper.types'; +import AvatarIcon from '../../../../component-library/components/Avatars/Avatar/variants/AvatarIcon'; +import { + IconColor, + IconName, +} from '../../../..//component-library/components/Icons/Icon'; +import AvatarBase from '../../../../component-library/components/Avatars/Avatar/foundation/AvatarBase'; +import Text from '../../../../component-library/components/Texts/Text'; +import { useStyles } from '../../../../component-library/hooks'; +import styleSheet from './SnapAvatar.styles'; +import { RootState } from '../../../../reducers'; +import { AvatarSize } from '../../../../component-library/components/Avatars/Avatar'; +import { ViewStyle } from 'react-native'; + +const getAvatarFallbackLetter = (subjectName: string) => + subjectName?.match(/[a-z0-9]/iu)?.[0] ?? '?'; + +export interface SnapAvatarProps { + snapId: string; + snapName: string; // TODO: Don't pass this in, derive it in the component instead. + style?: ViewStyle; +} + +export const SnapAvatar: React.FunctionComponent = ({ + snapId, + snapName, + style, +}) => { + const { styles } = useStyles(styleSheet, {}); + + const subjectMetadata = useSelector((state: RootState) => + selectTargetSubjectMetadata(state, snapId), + ); + + const iconUrl = subjectMetadata?.iconUrl; + + const fallbackAvatar = getAvatarFallbackLetter(snapName); + + return ( + + } + badgePosition={BadgePosition.BottomRight} + > + {iconUrl ? ( + + ) : ( + + {fallbackAvatar} + + )} + + ); +}; +///: END:ONLY_INCLUDE_IF diff --git a/app/core/EngineService/EngineService.ts b/app/core/EngineService/EngineService.ts index 5698e99ab44..ecec1d552cf 100644 --- a/app/core/EngineService/EngineService.ts +++ b/app/core/EngineService/EngineService.ts @@ -99,7 +99,7 @@ class EngineService { key: `${engine.context.SnapController.name}:stateChange`, }, { - name: 'subjectMetadataController', + name: 'SubjectMetadataController', key: `${engine.context.SubjectMetadataController.name}:stateChange`, }, ///: END:ONLY_INCLUDE_IF diff --git a/app/selectors/snaps/permissionController.ts b/app/selectors/snaps/permissionController.ts new file mode 100644 index 00000000000..08ae3291e28 --- /dev/null +++ b/app/selectors/snaps/permissionController.ts @@ -0,0 +1,30 @@ +import { RootState } from '../../reducers'; +import { memoize } from 'lodash'; +import { SubjectType } from '@metamask/permission-controller'; + +export const selectPermissionControllerState = (state: RootState) => + state.engine.backgroundState.PermissionController; + +export const selectSubjectMetadataControllerState = (state: RootState) => + state.engine.backgroundState.SubjectMetadataController; + +const getEmbeddableSvg = memoize( + (svgString) => `data:image/svg+xml;utf8,${encodeURIComponent(svgString)}`, +); + +function selectSubjectMetadata(state: RootState) { + return selectSubjectMetadataControllerState(state).subjectMetadata; +} + +export function selectTargetSubjectMetadata(state: RootState, origin: string) { + const metadata = selectSubjectMetadata(state)[origin]; + + if (metadata?.subjectType === SubjectType.Snap) { + return { + ...metadata, + iconUrl: metadata.svgIcon ? getEmbeddableSvg(metadata.svgIcon) : null, + }; + } + + return metadata; +} diff --git a/app/selectors/snaps/snapController.ts b/app/selectors/snaps/snapController.ts new file mode 100644 index 00000000000..25e8053ada7 --- /dev/null +++ b/app/selectors/snaps/snapController.ts @@ -0,0 +1,37 @@ +import { createSelector } from 'reselect'; +import { RootState } from '../../reducers'; +import { createDeepEqualSelector } from '../util'; +import { getLocalizedSnapManifest } from '@metamask/snaps-utils'; + +// TODO: Filter out huge values +export const selectSnapControllerState = (state: RootState) => + state.engine.backgroundState.SnapController; + +export const selectSnaps = createSelector( + selectSnapControllerState, + (controller) => controller.snaps, +); + +export const selectSnapsMetadata = createDeepEqualSelector( + selectSnaps, + (snaps) => + Object.values(snaps).reduce< + Record + >((snapsMetadata, snap) => { + const snapId = snap.id; + const manifest = snap.localizationFiles + ? getLocalizedSnapManifest( + snap.manifest, + // TODO: Use actual locale here. + 'en', + snap.localizationFiles, + ) + : snap.manifest; + + snapsMetadata[snapId] = { + name: manifest.proposedName, + description: manifest.description, + }; + return snapsMetadata; + }, {}), +); diff --git a/locales/languages/en.json b/locales/languages/en.json index 4a6875a0bc8..c78ad3a84ca 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -3075,7 +3075,7 @@ }, "install_snap": { "title": "Connection request", - "description": "{{origin}} wants to download and connect with {{snap}}. Make sure you trust the authors before you proceed.", + "description": "{{origin}} wants to use {{snap}}.", "permissions_request_title": "Permissions request", "permissions_request_description": "{{origin}} wants to install {{snap}}, which is requesting the following permissions.", "approve_permissions": "Approve",