Skip to content

Commit

Permalink
feat: Integrate JSX into snap notifications (#27407)
Browse files Browse the repository at this point in the history
## **Description**

Adds an expanded view for snaps notifications, using JSX content
returned from the snap to populate the expanded view.


Write a short description of the changes included in this pull request,
also include relevant motivation and context. Have in mind the following
questions:
1. What is the reason for the change? Allow for richer snaps
notifications
2. What is the improvement/solution? Add expanded view for snaps,
allowing a snap to return jsx content in the expanded view.

## **Screenshots/Recordings**


### **After**




https://github.com/user-attachments/assets/74c89bb7-7510-4d1c-acb6-a585978ecec8

## **Manual testing steps**
1. Pull down https://github.com/hmalik88/swiss-knife-snap and run `yarn
start` to serve the snap and site.
2. Build this branch using `yarn start:flask`
3. Install the snap from the local host site.
4. Trigger a notification with the "trigger a JSX notification" button
and observe the results.

## **Pre-merge author checklist**

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

## **Pre-merge reviewer checklist**

- [x] I've manually tested the PR (e.g. pull and build branch, run the
app, test code being changed).
- [x] 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.

---------

Co-authored-by: Maarten Zuidhoorn <[email protected]>
Co-authored-by: Guillaume Roux <[email protected]>
Co-authored-by: Frederik Bolding <[email protected]>
  • Loading branch information
4 people authored Dec 6, 2024
1 parent 9e70c73 commit 8d7f003
Show file tree
Hide file tree
Showing 34 changed files with 451 additions and 216 deletions.
25 changes: 20 additions & 5 deletions app/scripts/metamask-controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -1481,12 +1481,19 @@ export default class MetamaskController extends EventEmitter {
},
showInAppNotification: {
method: (origin, args) => {
const { message } = args;
const { message, title, footerLink, interfaceId } = args;

const detailedView = {
title,
...(footerLink ? { footerLink } : {}),
interfaceId,
};

const notification = {
data: {
message,
origin,
...(interfaceId ? { detailedView } : {}),
},
type: TRIGGER_TYPES.SNAP,
readDate: null,
Expand Down Expand Up @@ -2923,14 +2930,22 @@ export default class MetamaskController extends EventEmitter {
origin,
args.message,
),
showInAppNotification: (origin, args) =>
this.controllerMessenger.call(
showInAppNotification: (origin, args) => {
const { message, title, footerLink } = args;
const notificationArgs = {
interfaceId: args.content,
message,
title,
footerLink,
};
return this.controllerMessenger.call(
'RateLimitController:call',
origin,
'showInAppNotification',
origin,
args,
),
notificationArgs,
);
},
updateSnapState: this.controllerMessenger.call.bind(
this.controllerMessenger,
'SnapController:updateSnapState',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
TextVariant,
} from '../../../helpers/constants/design-system';
import Tooltip from '../../ui/tooltip';
import { AvatarGroup } from '../../multichain';
import { AvatarGroup } from '../../multichain/avatar-group';
import { AvatarType } from '../../multichain/avatar-group/avatar-group.types';
import { useI18nContext } from '../../../hooks/useI18nContext';
import { formatDate } from '../../../helpers/utils/util';
Expand Down
65 changes: 48 additions & 17 deletions ui/components/app/snaps/snap-ui-markdown/snap-ui-markdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
Text,
} from '../../../component-library';
import SnapLinkWarning from '../snap-link-warning';
import useSnapNavigation from '../../../../hooks/snaps/useSnapNavigation';

const Paragraph = (props) => (
<Text
Expand All @@ -27,28 +28,42 @@ const Paragraph = (props) => (
/>
);

const Link = ({ onClick, children, ...rest }) => (
<ButtonLink
{...rest}
as="a"
onClick={onClick}
externalLink
size={ButtonLinkSize.Inherit}
display={Display.Inline}
className="snap-ui-markdown__link"
>
{children}
<Icon name={IconName.Export} size={IconSize.Inherit} marginLeft={1} />
</ButtonLink>
);
const Link = ({ onClick, children, isMetaMaskUrl, ...rest }) => {
return (
<ButtonLink
{...rest}
as="a"
onClick={onClick}
externalLink={!isMetaMaskUrl}
size={ButtonLinkSize.Inherit}
display={Display.Inline}
className="snap-ui-markdown__link"
>
{children}
{!isMetaMaskUrl && (
<Icon name={IconName.Export} size={IconSize.Inherit} marginLeft={1} />
)}
</ButtonLink>
);
};

const isMetaMaskUrl = (href) => href.startsWith('metamask:');

export const SnapUIMarkdown = ({ children, markdown }) => {
const [redirectUrl, setRedirectUrl] = useState(undefined);
const { navigate } = useSnapNavigation();

if (markdown === false) {
return <Paragraph>{children}</Paragraph>;
}

const linkTransformer = (href) => {
if (isMetaMaskUrl(href)) {
return href;
}
return ReactMarkdown.uriTransformer(href);
};

const handleLinkClick = (url) => {
setRedirectUrl(url);
};
Expand All @@ -66,11 +81,26 @@ export const SnapUIMarkdown = ({ children, markdown }) => {
/>
<ReactMarkdown
allowedElements={['p', 'strong', 'em', 'a']}
transformLinkUri={linkTransformer}
components={{
p: Paragraph,
a: ({ children: value, href }) => (
<Link onClick={() => handleLinkClick(href)}>{value ?? href}</Link>
),
a: ({ children: value, href }) => {
return (
<Link
onClick={(e) => {
e.stopPropagation();
if (isMetaMaskUrl(href)) {
navigate(href);
} else {
handleLinkClick(href);
}
}}
isMetaMaskUrl={isMetaMaskUrl(href)}
>
{value ?? href}
</Link>
);
},
}}
>
{children}
Expand All @@ -87,4 +117,5 @@ SnapUIMarkdown.propTypes = {
Link.propTypes = {
onClick: PropTypes.func,
children: PropTypes.node,
isMetaMaskUrl: PropTypes.bool,
};
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ import {
} from '../../../store/actions';
import { TextVariant } from '../../../helpers/constants/design-system';
import { formatAccountType } from '../../../helpers/utils/metrics';
import { AccountDetailsMenuItem, ViewExplorerMenuItem } from '..';
import { AccountDetailsMenuItem, ViewExplorerMenuItem } from '../menu-items';

const METRICS_LOCATION = 'Account Options';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ import { useSelector } from 'react-redux';
import { useI18nContext } from '../../../hooks/useI18nContext';
import { shortenAddress } from '../../../helpers/utils/util';

import { AccountListItemMenu, AvatarGroup } from '..';
import { AccountListItemMenu } from '../account-list-item-menu';
import { AvatarGroup } from '../avatar-group';
import { ConnectedAccountsMenu } from '../connected-accounts-menu';
import {
AvatarAccount,
Expand Down
11 changes: 5 additions & 6 deletions ui/components/multichain/account-list-menu/account-list-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,12 +43,9 @@ import {
import { ModalContent } from '../../component-library/modal-content/deprecated';
import { ModalHeader } from '../../component-library/modal-header';
import { TextFieldSearch } from '../../component-library/text-field-search/deprecated';
import {
AccountListItem,
AccountListItemMenuTypes,
CreateEthAccount,
ImportAccount,
} from '..';
import { AccountListItem } from '../account-list-item';
import { AccountListItemMenuTypes } from '../account-list-item/account-list-item.types';

import {
AlignItems,
BlockSize,
Expand Down Expand Up @@ -130,6 +127,8 @@ import {
ACCOUNT_OVERVIEW_TAB_KEY_TO_TRACE_NAME_MAP,
AccountOverviewTabKey,
} from '../../../../shared/constants/app-state';
import { CreateEthAccount } from '../create-eth-account';
import { ImportAccount } from '../import-account';
///: BEGIN:ONLY_INCLUDE_IF(solana)
import {
SOLANA_WALLET_NAME,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ import {
IconSize,
Text,
} from '../../component-library';
import { AccountListItem } from '../account-list-item';
import { AccountListItemMenuTypes } from '..';
import {
AccountListItem,
AccountListItemMenuTypes,
} from '../account-list-item';
import { mergeAccounts } from './account-list-menu';

export const HiddenAccountList = ({ onClose }) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
setAccountLabel,
getNextAvailableAccountName as getNextAvailableAccountNameFromController,
} from '../../../store/actions';
import { CreateAccount } from '..';
import { CreateAccount } from '../create-account';

export const CreateEthAccount = ({ onActionComplete }) => {
const dispatch = useDispatch();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,16 @@ const mockNotification = {
type: 'ERC20_RECEIVED',
};

jest.mock('react-router-dom', () => {
const original = jest.requireActual('react-router-dom');
return {
...original,
useHistory: () => ({
push: jest.fn(),
}),
};
});

describe('NotificationDetailBlockExplorerButton', () => {
it('renders correctly with a valid block explorer URL', () => {
render(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@ const { TRIGGER_TYPES } = NotificationServicesController.Constants;

type Notification = NotificationServicesController.Types.INotification;

jest.mock('react-router-dom', () => {
const original = jest.requireActual('react-router-dom');
return {
...original,
useHistory: () => ({
push: jest.fn(),
}),
};
});

describe('NotificationDetailButton', () => {
const defaultProps = {
variant: ButtonVariant.Primary,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import React, { useContext } from 'react';
import { NotificationServicesController } from '@metamask/notification-services-controller';
import React, { useContext, useState } from 'react';
import { MetaMetricsContext } from '../../../contexts/metametrics';
import {
MetaMetricsEventCategory,
Expand All @@ -12,8 +11,10 @@ import {
IconName,
} from '../../component-library';
import { BlockSize } from '../../../helpers/constants/design-system';

type Notification = NotificationServicesController.Types.INotification;
import { TRIGGER_TYPES } from '../../../pages/notifications/notification-components';
import useSnapNavigation from '../../../hooks/snaps/useSnapNavigation';
import { type Notification } from '../../../pages/notifications/notification-components/types/notifications/notifications';
import SnapLinkWarning from '../../app/snaps/snap-link-warning';

type NotificationDetailButtonProps = {
notification: Notification;
Expand All @@ -35,6 +36,23 @@ export const NotificationDetailButton = ({
endIconName = true,
}: NotificationDetailButtonProps) => {
const trackEvent = useContext(MetaMetricsContext);
const { navigate } = useSnapNavigation();
const isMetaMaskUrl = href.startsWith('metamask:');
const [isOpen, setIsOpen] = useState(false);

const isSnapNotification = notification.type === TRIGGER_TYPES.SNAP;

const handleModalClose = () => {
setIsOpen(false);
};

// this logic can be expanded once this detail button is used outside of the current use cases
const getClickedItem = () => {
if (notification.type === TRIGGER_TYPES.FEATURES_ANNOUNCEMENT) {
return 'block_explorer';
}
return isExternal ? 'external_link' : 'internal_link';
};

const onClick = () => {
trackEvent({
Expand All @@ -46,23 +64,40 @@ export const NotificationDetailButton = ({
...('chain_id' in notification && {
chain_id: notification.chain_id,
}),
clicked_item: 'block_explorer',
clicked_item: getClickedItem(),
},
});

if (isSnapNotification) {
if (isMetaMaskUrl) {
navigate(href);
} else {
setIsOpen(true);
}
}
};

return (
<Button
key={id}
href={href}
variant={variant}
externalLink={isExternal}
size={ButtonSize.Lg}
width={BlockSize.Full}
endIconName={endIconName ? IconName.Arrow2UpRight : undefined}
onClick={onClick}
>
{text}
</Button>
<>
{isSnapNotification && (
<SnapLinkWarning
isOpen={isOpen}
onClose={handleModalClose}
url={href}
/>
)}
<Button
key={id}
href={!isSnapNotification && href ? href : undefined}
variant={variant}
externalLink={isExternal || !isMetaMaskUrl}
size={ButtonSize.Lg}
width={BlockSize.Full}
endIconName={endIconName ? IconName.Arrow2UpRight : undefined}
onClick={onClick}
>
{text}
</Button>
</>
);
};
Original file line number Diff line number Diff line change
@@ -1,14 +1,12 @@
import React, { createContext, useContext, useEffect, useMemo } from 'react';
import { useSelector } from 'react-redux';
import type { NotificationServicesController } from '@metamask/notification-services-controller';
import { useListNotifications } from '../../hooks/metamask-notifications/useNotifications';
import { useAccountSyncingEffect } from '../../hooks/identity/useProfileSyncing';
import { selectIsProfileSyncingEnabled } from '../../selectors/identity/profile-syncing';
import { selectIsMetamaskNotificationsEnabled } from '../../selectors/metamask-notifications/metamask-notifications';
import { getUseExternalServices } from '../../selectors';
import { getIsUnlocked } from '../../ducks/metamask/metamask';

type Notification = NotificationServicesController.Types.INotification;
import { type Notification } from '../../pages/notifications/notification-components/types/notifications/notifications';

type MetamaskNotificationsContextType = {
listNotifications: () => void;
Expand Down
7 changes: 2 additions & 5 deletions ui/hooks/metamask-notifications/useNotifications.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
import { useState, useCallback } from 'react';
import { useDispatch } from 'react-redux';
import type { InternalAccount } from '@metamask/keyring-api';
import type { NotificationServicesController } from '@metamask/notification-services-controller';
import log from 'loglevel';

import { type MarkAsReadNotificationsParam } from '@metamask/notification-services-controller/notification-services';
import {
createOnChainTriggers,
fetchAndUpdateMetamaskNotifications,
markMetamaskNotificationsAsRead,
enableMetamaskNotifications,
disableMetamaskNotifications,
} from '../../store/actions';

type Notification = NotificationServicesController.Types.INotification;
type MarkAsReadNotificationsParam =
NotificationServicesController.Types.MarkAsReadNotificationsParam;
import { type Notification } from '../../pages/notifications/notification-components/types/notifications/notifications';

// Define KeyringType interface
type KeyringType = {
Expand Down
Loading

0 comments on commit 8d7f003

Please sign in to comment.