Skip to content

Commit

Permalink
feat(snaps): Settings Page (#29234)
Browse files Browse the repository at this point in the history
## **Description**

This PR adds a settings section for each preinstalled snaps that exposes
a `onSettingsPage` handler

[![Open in GitHub
Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/MetaMask/metamask-extension/pull/29234?quickstart=1)

## **Related issues**

Fixes: MetaMask/snaps#2874

## **Manual testing steps**

1. Open MetaMask's settings
2. You should see the preinstalled snaps settings

## **Screenshots/Recordings**

<!-- If applicable, add screenshots and/or recordings to visualize the
before and after of your change. -->

### **Before**


### **After**


![image](https://github.com/user-attachments/assets/55f4027c-258e-4039-9006-95228cfdca3f)

![image](https://github.com/user-attachments/assets/e05e6964-ac61-49a1-bb28-e8d7d077dadc)

![image](https://github.com/user-attachments/assets/b2db7088-069a-4b83-9740-11f23f36e879)


## **Pre-merge author checklist**

- [ ] I've followed [MetaMask Contributor
Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask
Extension Coding
Standards](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/CODING_GUIDELINES.md).
- [ ] I've completed the PR template to the best of my ability
- [ ] I’ve included tests if applicable
- [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format
if applicable
- [ ] I’ve applied the right labels on the PR (see [labeling
guidelines](https://github.com/MetaMask/metamask-extension/blob/main/.github/guidelines/LABELING_GUIDELINES.md)).
Not required for external contributors.

## **Pre-merge reviewer checklist**

- [ ] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [ ] I confirm that this PR addresses all acceptance criteria described
in the ticket it closes and includes the necessary testing evidence such
as recordings and or screenshots.
  • Loading branch information
GuillaumeRx authored Dec 17, 2024
1 parent 70c7e35 commit c80f17a
Show file tree
Hide file tree
Showing 16 changed files with 270 additions and 24 deletions.
1 change: 1 addition & 0 deletions shared/constants/snaps/permissions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions ui/components/app/snaps/snap-settings-page/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './snap-settings-renderer';
Original file line number Diff line number Diff line change
@@ -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 (
<Box
height={BlockSize.Full}
width={BlockSize.Full}
backgroundColor={BackgroundColor.backgroundDefault}
>
{error && (
<Box height={BlockSize.Full} padding={4}>
<SnapDelineator snapName={snapName} type={DelineatorType.Error}>
<Text variant={TextVariant.bodySm} marginBottom={4}>
{t('snapsUIError', [<b key="0">{snapName}</b>])}
</Text>
<Copyable text={error.message} />
</SnapDelineator>
</Box>
)}
{(interfaceId || loading) && (
<SnapUIRenderer
snapId={snapId}
interfaceId={interfaceId}
isLoading={loading}
useDelineator={false}
contentBackgroundColor={BackgroundColor.backgroundDefault}
/>
)}
</Box>
);
};
5 changes: 5 additions & 0 deletions ui/components/app/tab-bar/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
3 changes: 3 additions & 0 deletions ui/helpers/constants/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
14 changes: 0 additions & 14 deletions ui/helpers/utils/snaps.js

This file was deleted.

40 changes: 40 additions & 0 deletions ui/helpers/utils/snaps.ts
Original file line number Diff line number Diff line change
@@ -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;
};
55 changes: 55 additions & 0 deletions ui/hooks/snaps/useSnapSettings.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>(true);
const [data, setData] = useState<{ id: string } | undefined>(undefined);
const [error, setError] = useState<Error | undefined>(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 };
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<TypedSignInfoV1 />,
Expand All @@ -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(
<TypedSignInfoV1 />,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<TypedSignInfo />,
Expand All @@ -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(
<TypedSignInfo />,
Expand Down
3 changes: 2 additions & 1 deletion ui/pages/settings/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@

&__title {
@include design-system.screen-sm-min {
width: 197px;
margin-right: 16px;
}

@include design-system.screen-sm-max {
Expand Down Expand Up @@ -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%;
Expand Down
Loading

0 comments on commit c80f17a

Please sign in to comment.