diff --git a/shared/constants/snaps/permissions.ts b/shared/constants/snaps/permissions.ts index 837beea4d9ff..e08afdf46421 100644 --- a/shared/constants/snaps/permissions.ts +++ b/shared/constants/snaps/permissions.ts @@ -7,6 +7,7 @@ export const EndowmentPermissions = Object.freeze({ 'endowment:webassembly': 'endowment:webassembly', 'endowment:lifecycle-hooks': 'endowment:lifecycle-hooks', 'endowment:page-home': 'endowment:page-home', + 'endowment:page-settings': 'endowment:page-settings', 'endowment:signature-insight': 'endowment:signature-insight', 'endowment:name-lookup': 'endowment:name-lookup', ///: BEGIN:ONLY_INCLUDE_IF(keyring-snaps) diff --git a/ui/components/app/snaps/snap-settings-page/index.ts b/ui/components/app/snaps/snap-settings-page/index.ts new file mode 100644 index 000000000000..91a594fcdeb0 --- /dev/null +++ b/ui/components/app/snaps/snap-settings-page/index.ts @@ -0,0 +1 @@ +export * from './snap-settings-renderer'; diff --git a/ui/components/app/snaps/snap-settings-page/snap-settings-renderer.tsx b/ui/components/app/snaps/snap-settings-page/snap-settings-renderer.tsx new file mode 100644 index 000000000000..fa5636ebb77f --- /dev/null +++ b/ui/components/app/snaps/snap-settings-page/snap-settings-renderer.tsx @@ -0,0 +1,81 @@ +import React, { FunctionComponent, useEffect, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useLocation } from 'react-router-dom'; +import { useI18nContext } from '../../../../hooks/useI18nContext'; + +import { deleteInterface } from '../../../../store/actions'; +import { Box, Text } from '../../../component-library'; +import { + BackgroundColor, + BlockSize, + TextVariant, +} from '../../../../helpers/constants/design-system'; +import { SnapDelineator } from '../snap-delineator'; +import { getSnapMetadata } from '../../../../selectors'; +import { DelineatorType } from '../../../../helpers/constants/snaps'; +import { Copyable } from '../copyable'; +import { SnapUIRenderer } from '../snap-ui-renderer'; +import { useSnapSettings } from '../../../../hooks/snaps/useSnapSettings'; +import { decodeSnapIdFromPathname } from '../../../../helpers/utils/snaps'; + +type SnapSettingsRendererProps = { + snapId: string; +}; + +export const SnapSettingsRenderer: FunctionComponent< + SnapSettingsRendererProps +> = () => { + const { pathname } = useLocation(); + const dispatch = useDispatch(); + const t = useI18nContext(); + + const snapId = useMemo(() => decodeSnapIdFromPathname(pathname), [pathname]); + + const { name: snapName } = useSelector((state) => + getSnapMetadata(state, snapId), + ); + + const { data, error, loading } = useSnapSettings({ + snapId, + }); + + const interfaceId = !loading && !error ? data?.id : undefined; + + useEffect(() => { + return () => { + interfaceId && dispatch(deleteInterface(interfaceId)); + }; + }, [interfaceId]); + + if (!snapId) { + return null; + } + + return ( + + {error && ( + + + + {t('snapsUIError', [{snapName}])} + + + + + )} + {(interfaceId || loading) && ( + + )} + + ); +}; diff --git a/ui/components/app/tab-bar/index.scss b/ui/components/app/tab-bar/index.scss index ce149f922879..6efdbab6c51e 100644 --- a/ui/components/app/tab-bar/index.scss +++ b/ui/components/app/tab-bar/index.scss @@ -38,11 +38,16 @@ display: flex; align-items: center; position: relative; + overflow: hidden; width: 100%; &__title { @include design-system.H4; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + @include design-system.screen-sm-min { @include design-system.H6; } diff --git a/ui/helpers/constants/routes.ts b/ui/helpers/constants/routes.ts index da12a52be812..ac21c32a2b1b 100644 --- a/ui/helpers/constants/routes.ts +++ b/ui/helpers/constants/routes.ts @@ -62,6 +62,9 @@ PATH_NAME_MAP[CONTACT_ADD_ROUTE] = 'Add Contact Settings Page'; export const CONTACT_VIEW_ROUTE = '/settings/contact-list/view-contact'; PATH_NAME_MAP[`${CONTACT_VIEW_ROUTE}/:address`] = 'View Contact Settings Page'; +export const SNAP_SETTINGS_ROUTE = '/settings/snap'; +PATH_NAME_MAP[`${SNAP_SETTINGS_ROUTE}/:snapId`] = 'Snap Settings Page'; + export const REVEAL_SEED_ROUTE = '/seed'; PATH_NAME_MAP[REVEAL_SEED_ROUTE] = 'Reveal Secret Recovery Phrase Page'; diff --git a/ui/helpers/utils/snaps.js b/ui/helpers/utils/snaps.js deleted file mode 100644 index 8a57ce516cf2..000000000000 --- a/ui/helpers/utils/snaps.js +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Check if the given value is a valid snap ID. - * - * NOTE: This function is a duplicate oF a yet to be released version in @metamask/snaps-utils. - * - * @param value - The value to check. - * @returns `true` if the value is a valid snap ID, and `false` otherwise. - */ -export function isSnapId(value) { - return ( - (typeof value === 'string' || value instanceof String) && - (value.startsWith('local:') || value.startsWith('npm:')) - ); -} diff --git a/ui/helpers/utils/snaps.ts b/ui/helpers/utils/snaps.ts new file mode 100644 index 000000000000..c788393e83ab --- /dev/null +++ b/ui/helpers/utils/snaps.ts @@ -0,0 +1,40 @@ +import { SnapId } from '@metamask/snaps-sdk'; +import { isProduction } from '../../../shared/modules/environment'; + +/** + * Check if the given value is a valid snap ID. + * + * NOTE: This function is a duplicate oF a yet to be released version in @metamask/snaps-utils. + * + * @param value - The value to check. + * @returns `true` if the value is a valid snap ID, and `false` otherwise. + */ +export function isSnapId(value: unknown): value is SnapId { + return ( + (typeof value === 'string' || value instanceof String) && + (value.startsWith('local:') || value.startsWith('npm:')) + ); +} + +/** + * Decode a snap ID fron a pathname. + * + * @param pathname - The pathname to decode the snap ID from. + * @returns The decoded snap ID, or `undefined` if the snap ID could not be decoded. + */ +export const decodeSnapIdFromPathname = (pathname: string) => { + const snapIdURI = pathname?.match(/[^/]+$/u)?.[0]; + return snapIdURI && decodeURIComponent(snapIdURI); +}; + +const IGNORED_EXAMPLE_SNAPS = ['npm:@metamask/preinstalled-example-snap']; + +/** + * Check if the given snap ID is ignored in production. + * + * @param snapId - The snap ID to check. + * @returns `true` if the snap ID is ignored in production, and `false` otherwise. + */ +export const isSnapIgnoredInProd = (snapId: string) => { + return isProduction() ? IGNORED_EXAMPLE_SNAPS.includes(snapId) : false; +}; diff --git a/ui/hooks/snaps/useSnapSettings.ts b/ui/hooks/snaps/useSnapSettings.ts new file mode 100644 index 000000000000..ad2debc8cac2 --- /dev/null +++ b/ui/hooks/snaps/useSnapSettings.ts @@ -0,0 +1,55 @@ +import { useEffect, useState } from 'react'; +import { useDispatch } from 'react-redux'; +import { + forceUpdateMetamaskState, + handleSnapRequest, +} from '../../store/actions'; + +export function useSnapSettings({ snapId }: { snapId?: string }) { + const dispatch = useDispatch(); + const [loading, setLoading] = useState(true); + const [data, setData] = useState<{ id: string } | undefined>(undefined); + const [error, setError] = useState(undefined); + + useEffect(() => { + let cancelled = false; + async function fetchPage(id: string) { + try { + setError(undefined); + setLoading(true); + + const newData = (await handleSnapRequest({ + snapId: id, + origin: '', + handler: 'onSettingsPage', + request: { + jsonrpc: '2.0', + method: ' ', + }, + })) as { id: string }; + if (!cancelled) { + setData(newData); + forceUpdateMetamaskState(dispatch); + } + } catch (err) { + if (!cancelled) { + setError(err as Error); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + } + + if (snapId) { + fetchPage(snapId); + } + + return () => { + cancelled = true; + }; + }, [snapId]); + + return { data, error, loading }; +} diff --git a/ui/pages/confirmations/components/confirm/info/personal-sign/personal-sign.test.tsx b/ui/pages/confirmations/components/confirm/info/personal-sign/personal-sign.test.tsx index e105493485ec..59b6619a21b4 100644 --- a/ui/pages/confirmations/components/confirm/info/personal-sign/personal-sign.test.tsx +++ b/ui/pages/confirmations/components/confirm/info/personal-sign/personal-sign.test.tsx @@ -145,7 +145,7 @@ describe('PersonalSignInfo', () => { getMockPersonalSignConfirmStateForRequest(signatureRequestSIWE); (utils.isSIWESignatureRequest as jest.Mock).mockReturnValue(false); - (snapUtils.isSnapId as jest.Mock).mockReturnValue(true); + (snapUtils.isSnapId as unknown as jest.Mock).mockReturnValue(true); const mockStore = configureMockStore([])(state); const { queryByText, getByText } = renderWithConfirmContextProvider( @@ -167,7 +167,7 @@ describe('PersonalSignInfo', () => { const state = getMockPersonalSignConfirmStateForRequest(signatureRequestSIWE); (utils.isSIWESignatureRequest as jest.Mock).mockReturnValue(false); - (snapUtils.isSnapId as jest.Mock).mockReturnValue(true); + (snapUtils.isSnapId as unknown as jest.Mock).mockReturnValue(true); const mockStore = configureMockStore([])(state); const { getByText, queryByText } = renderWithConfirmContextProvider( diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign-v1/typed-sign-v1.test.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign-v1/typed-sign-v1.test.tsx index 2b1e6969ddd5..83696b69ac64 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign-v1/typed-sign-v1.test.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign-v1/typed-sign-v1.test.tsx @@ -65,7 +65,7 @@ describe('TypedSignInfo', () => { type: TransactionType.signTypedData, chainId: '0x5', }); - (snapUtils.isSnapId as jest.Mock).mockReturnValue(true); + (snapUtils.isSnapId as unknown as jest.Mock).mockReturnValue(true); const mockStore = configureMockStore([])(mockState); const { queryByText } = renderWithConfirmContextProvider( , @@ -88,7 +88,7 @@ describe('TypedSignInfo', () => { type: TransactionType.signTypedData, chainId: '0x5', }); - (snapUtils.isSnapId as jest.Mock).mockReturnValue(false); + (snapUtils.isSnapId as unknown as jest.Mock).mockReturnValue(false); const mockStore = configureMockStore([])(mockState); const { queryByText } = renderWithConfirmContextProvider( , diff --git a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign.test.tsx b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign.test.tsx index 56421561ccd2..640b663e5cc1 100644 --- a/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign.test.tsx +++ b/ui/pages/confirmations/components/confirm/info/typed-sign/typed-sign.test.tsx @@ -153,7 +153,7 @@ describe('TypedSignInfo', () => { type: TransactionType.signTypedData, chainId: '0x5', }); - (snapUtils.isSnapId as jest.Mock).mockReturnValue(true); + (snapUtils.isSnapId as unknown as jest.Mock).mockReturnValue(true); const mockStore = configureMockStore([])(mockState); const { queryByText } = renderWithConfirmContextProvider( , @@ -177,7 +177,7 @@ describe('TypedSignInfo', () => { type: TransactionType.signTypedData, chainId: '0x5', }); - (snapUtils.isSnapId as jest.Mock).mockReturnValue(false); + (snapUtils.isSnapId as unknown as jest.Mock).mockReturnValue(false); const mockStore = configureMockStore([])(mockState); const { queryByText } = renderWithConfirmContextProvider( , diff --git a/ui/pages/settings/index.scss b/ui/pages/settings/index.scss index f57e1c310998..f9b08529ee65 100644 --- a/ui/pages/settings/index.scss +++ b/ui/pages/settings/index.scss @@ -56,7 +56,7 @@ &__title { @include design-system.screen-sm-min { - width: 197px; + margin-right: 16px; } @include design-system.screen-sm-max { @@ -230,6 +230,7 @@ display: flex; flex-direction: column; flex: 1 1 auto; + max-width: 100vw; @include design-system.screen-sm-min { flex: 0 0 40%; diff --git a/ui/pages/settings/settings.component.js b/ui/pages/settings/settings.component.js index 724c661c9aeb..37257e2c8fcb 100644 --- a/ui/pages/settings/settings.component.js +++ b/ui/pages/settings/settings.component.js @@ -21,6 +21,7 @@ import { ADD_POPULAR_CUSTOM_NETWORK, DEFAULT_ROUTE, NOTIFICATIONS_SETTINGS_ROUTE, + SNAP_SETTINGS_ROUTE, } from '../../helpers/constants/routes'; import { getSettingsRoutes } from '../../helpers/utils/settings-search'; @@ -31,6 +32,7 @@ import { IconName, Box, Text, + IconSize, } from '../../components/component-library'; import { AlignItems, @@ -44,6 +46,8 @@ import MetafoxLogo from '../../components/ui/metafox-logo'; // eslint-disable-next-line import/no-restricted-paths import { getEnvironmentType } from '../../../app/scripts/lib/util'; import { ENVIRONMENT_TYPE_POPUP } from '../../../shared/constants/app'; +import { SnapIcon } from '../../components/app/snaps/snap-icon'; +import { SnapSettingsRenderer } from '../../components/app/snaps/snap-settings-page'; import SettingsTab from './settings-tab'; import AdvancedTab from './advanced-tab'; import InfoTab from './info-tab'; @@ -70,6 +74,8 @@ class SettingsPage extends PureComponent { mostRecentOverviewPage: PropTypes.string.isRequired, pathnameI18nKey: PropTypes.string, remoteFeatureFlags: PropTypes.object.isRequired, + settingsPageSnaps: PropTypes.array, + snapSettingsTitle: PropTypes.string, toggleNetworkMenu: PropTypes.func.isRequired, useExternalServices: PropTypes.bool, }; @@ -210,19 +216,24 @@ class SettingsPage extends PureComponent { renderTitle() { const { t } = this.context; - const { isPopup, pathnameI18nKey, addressName } = this.props; + const { isPopup, pathnameI18nKey, addressName, snapSettingsTitle } = + this.props; let titleText; if (isPopup && addressName) { titleText = t('details'); } else if (pathnameI18nKey && isPopup) { titleText = t(pathnameI18nKey); + } else if (snapSettingsTitle) { + titleText = snapSettingsTitle; } else { titleText = t('settings'); } return (
- {titleText} + + {titleText} +
); } @@ -293,15 +304,31 @@ class SettingsPage extends PureComponent { } renderTabs() { - const { history, currentPath, useExternalServices } = this.props; + const { history, currentPath, useExternalServices, settingsPageSnaps } = + this.props; const { t } = this.context; + const snapsSettings = settingsPageSnaps.map(({ id, name }) => { + return { + content: name, + icon: ( + + ), + key: `${SNAP_SETTINGS_ROUTE}/${encodeURIComponent(id)}`, + }; + }); + const tabs = [ { content: t('general'), icon: , key: GENERAL_ROUTE, }, + ...snapsSettings, { content: t('advanced'), icon: , @@ -390,6 +417,10 @@ class SettingsPage extends PureComponent { )} /> + { metamask: { currencyRates }, } = state; const remoteFeatureFlags = getRemoteFeatureFlags(state); + + const settingsPageSnapsIds = getSettingsPageSnapsIds(state); + const snapsMetadata = getSnapsMetadata(state); const conversionDate = currencyRates[ticker]?.conversionDate; const pathNameTail = pathname.match(/[^/]+$/u)[0]; @@ -75,6 +83,7 @@ const mapStateToProps = (state, ownProps) => { const isAddPopularCustomNetwork = Boolean( pathname.match(ADD_POPULAR_CUSTOM_NETWORK), ); + const isSnapSettingsRoute = Boolean(pathname.match(SNAP_SETTINGS_ROUTE)); const isPopup = getEnvironmentType() === ENVIRONMENT_TYPE_POPUP; const pathnameI18nKey = ROUTES_TO_I18N_KEYS[pathname]; @@ -102,6 +111,16 @@ const mapStateToProps = (state, ownProps) => { ); const useExternalServices = getUseExternalServices(state); + const snapNameGetter = getSnapName(snapsMetadata); + + const settingsPageSnaps = settingsPageSnapsIds.map((snapId) => ({ + id: snapId, + name: snapNameGetter(snapId), + })); + + const snapSettingsTitle = + isSnapSettingsRoute && snapNameGetter(decodeSnapIdFromPathname(pathname)); + return { addNewNetwork, addressName, @@ -115,6 +134,8 @@ const mapStateToProps = (state, ownProps) => { mostRecentOverviewPage: getMostRecentOverviewPage(state), pathnameI18nKey, remoteFeatureFlags, + settingsPageSnaps, + snapSettingsTitle, useExternalServices, }; }; diff --git a/ui/pages/settings/settings.stories.js b/ui/pages/settings/settings.stories.js index 53437f4175db..b6b695a89cb1 100644 --- a/ui/pages/settings/settings.stories.js +++ b/ui/pages/settings/settings.stories.js @@ -62,6 +62,7 @@ const Settings = ({ history }) => { pathnameI18nKey={pathnameI18nKey} backRoute={SETTINGS_ROUTE} remoteFeatureFlags={{}} + settingsPageSnaps={[]} /> ); diff --git a/ui/selectors/selectors.js b/ui/selectors/selectors.js index 02adf1241e30..caa20245bb7d 100644 --- a/ui/selectors/selectors.js +++ b/ui/selectors/selectors.js @@ -12,6 +12,7 @@ import { NameType } from '@metamask/name-controller'; import { TransactionStatus } from '@metamask/transaction-controller'; import { isEvmAccountType } from '@metamask/keyring-api'; import { RpcEndpointType } from '@metamask/network-controller'; +import { SnapEndowments } from '@metamask/snaps-rpc-methods'; import { getCurrentChainId, getProviderConfig, @@ -112,6 +113,7 @@ import { BridgeFeatureFlagsKey } from '../../app/scripts/controllers/bridge/type import { hasTransactionData } from '../../shared/modules/transaction.utils'; import { toChecksumHexAddress } from '../../shared/modules/hexstring-utils'; import { createDeepEqualSelector } from '../../shared/modules/selectors/util'; +import { isSnapIgnoredInProd } from '../helpers/utils/snaps'; import { getAllUnapprovedTransactions, getCurrentNetworkTransactions, @@ -1920,6 +1922,19 @@ export const getInsightSnaps = createDeepEqualSelector( }, ); +export const getSettingsPageSnaps = createDeepEqualSelector( + getEnabledSnaps, + getPermissionSubjects, + (snaps, subjects) => { + return Object.values(snaps).filter( + ({ id, preinstalled }) => + subjects[id]?.permissions[SnapEndowments.SettingsPage] && + preinstalled && + !isSnapIgnoredInProd(id), + ); + }, +); + export const getSignatureInsightSnaps = createDeepEqualSelector( getEnabledSnaps, getPermissionSubjects, @@ -1950,6 +1965,11 @@ export const getNameLookupSnapsIds = createDeepEqualSelector( }, ); +export const getSettingsPageSnapsIds = createDeepEqualSelector( + getSettingsPageSnaps, + (snaps) => snaps.map((snap) => snap.id), +); + export const getNotifySnaps = createDeepEqualSelector( getEnabledSnaps, getPermissionSubjects,