diff --git a/app/_locales/en/messages.json b/app/_locales/en/messages.json index b99c3976b8c9..b2754b85e4f2 100644 --- a/app/_locales/en/messages.json +++ b/app/_locales/en/messages.json @@ -405,6 +405,9 @@ "allow": { "message": "Allow" }, + "allowMetaMaskToDetectTokens": { + "message": "Allow MetaMask to detect and display your tokens with autodetection. You’ll be able to:" + }, "allowMmiToConnectToCustodian": { "message": "This will allow MMI to connect to $1 to import your accounts." }, @@ -1529,6 +1532,9 @@ "displayNftMediaDescription": { "message": "Displaying NFT media and data exposes your IP address to OpenSea or other third parties. This can allow attackers to associate your IP address with your Ethereum address. NFT autodetection relies on this setting, and won't be available when this is turned off." }, + "diveStraightIntoUsingYourTokens": { + "message": "Dive straight into using your tokens" + }, "doNotShare": { "message": "Do not share this with anyone" }, @@ -1657,6 +1663,9 @@ "editSpeedUpEditGasFeeModalTitle": { "message": "Edit speed up gas fee" }, + "effortlesslyNavigateYourDigitalAssets": { + "message": "Effortlessly navigate your digital assets" + }, "enable": { "message": "Enable" }, @@ -1673,6 +1682,9 @@ "message": "enable $1", "description": "$1 is a token symbol, e.g. ETH" }, + "enableTokenAutoDetection": { + "message": "Enable token autodetection" + }, "enabled": { "message": "Enabled" }, @@ -2161,6 +2173,9 @@ "imToken": { "message": "imToken" }, + "immediateAccessToYourTokens": { + "message": "Immediate access to your tokens" + }, "import": { "message": "Import", "description": "Button to import an account from a selected file" @@ -3094,6 +3109,9 @@ "notEnoughGas": { "message": "Not enough gas" }, + "notRightNow": { + "message": "Not right now" + }, "note": { "message": "Note" }, diff --git a/app/images/wallet-alpha.png b/app/images/wallet-alpha.png new file mode 100644 index 000000000000..bc88bcfb5f2d Binary files /dev/null and b/app/images/wallet-alpha.png differ diff --git a/app/scripts/controllers/app-metadata.ts b/app/scripts/controllers/app-metadata.ts index 0d745730d0c0..79d346c8412d 100644 --- a/app/scripts/controllers/app-metadata.ts +++ b/app/scripts/controllers/app-metadata.ts @@ -9,6 +9,7 @@ export type AppMetadataControllerState = { previousAppVersion: string; previousMigrationVersion: number; currentMigrationVersion: number; + showTokenAutodetectModalOnUpgrade: boolean | null; }; /** @@ -25,6 +26,7 @@ const defaultState: AppMetadataControllerState = { previousAppVersion: '', previousMigrationVersion: 0, currentMigrationVersion: 0, + showTokenAutodetectModalOnUpgrade: false, }; /** @@ -76,6 +78,7 @@ export default class AppMetadataController extends EventEmitter { this.store.updateState({ currentAppVersion: maybeNewAppVersion, previousAppVersion: oldCurrentAppVersion, + showTokenAutodetectModalOnUpgrade: null, }); } } @@ -96,4 +99,13 @@ export default class AppMetadataController extends EventEmitter { }); } } + + /** + * Setter for the `showTokenAutodetectModalOnUpgrade` property + * + * @param val - Indicates the value of showTokenAutodetectModalOnUpgrade + */ + setShowTokenAutodetectModalOnUpgrade(val: boolean): void { + this.store.updateState({ showTokenAutodetectModalOnUpgrade: val }); + } } diff --git a/app/scripts/controllers/preferences.js b/app/scripts/controllers/preferences.js index 635eb5d4fc77..a8b6b7f1a755 100644 --- a/app/scripts/controllers/preferences.js +++ b/app/scripts/controllers/preferences.js @@ -97,6 +97,7 @@ export default class PreferencesController { petnamesEnabled: true, redesignedConfirmationsEnabled: true, featureNotificationsEnabled: false, + showTokenAutodetectModal: null, }, // ENS decentralized website resolution ipfsGateway: IPFS_DEFAULT_GATEWAY_URL, diff --git a/app/scripts/lib/backup.test.js b/app/scripts/lib/backup.test.js index 81a7cd8c0093..b9f209a9a408 100644 --- a/app/scripts/lib/backup.test.js +++ b/app/scripts/lib/backup.test.js @@ -163,6 +163,7 @@ const jsonData = JSON.stringify({ showTestNetworks: true, smartTransactionsOptInStatus: false, useNativeCurrencyAsPrimaryCurrency: true, + showTokenAutodetectModal: false, }, ipfsGateway: 'dweb.link', ledgerTransportType: 'webhid', diff --git a/app/scripts/lib/setupSentry.js b/app/scripts/lib/setupSentry.js index d0cdef3d6480..8cc5881d08d1 100644 --- a/app/scripts/lib/setupSentry.js +++ b/app/scripts/lib/setupSentry.js @@ -79,6 +79,7 @@ export const SENTRY_BACKGROUND_STATE = { currentMigrationVersion: true, previousAppVersion: true, previousMigrationVersion: true, + showTokenAutodetectModalOnUpgrade: false, }, ApprovalController: { approvalFlows: false, @@ -240,6 +241,7 @@ export const SENTRY_BACKGROUND_STATE = { smartTransactionsOptInStatus: true, useNativeCurrencyAsPrimaryCurrency: true, petnamesEnabled: true, + showTokenAutodetectModal: false, }, useExternalServices: false, selectedAddress: false, diff --git a/app/scripts/metamask-controller.js b/app/scripts/metamask-controller.js index 5d91f0f03ae1..52289a11182c 100644 --- a/app/scripts/metamask-controller.js +++ b/app/scripts/metamask-controller.js @@ -2961,6 +2961,7 @@ export default class MetamaskController extends EventEmitter { networkController, announcementController, onboardingController, + appMetadataController, permissionController, preferencesController, swapsController, @@ -3394,6 +3395,12 @@ export default class MetamaskController extends EventEmitter { this.encryptionPublicKeyController, ), + // AppMetadataController + setShowTokenAutodetectModalOnUpgrade: + appMetadataController.setShowTokenAutodetectModalOnUpgrade.bind( + appMetadataController, + ), + // onboarding controller setSeedPhraseBackedUp: onboardingController.setSeedPhraseBackedUp.bind(onboardingController), diff --git a/shared/constants/metametrics.ts b/shared/constants/metametrics.ts index 9a9f8e7b9b13..e3f5dd805145 100644 --- a/shared/constants/metametrics.ts +++ b/shared/constants/metametrics.ts @@ -712,6 +712,8 @@ export enum MetaMetricsEventName { SnapAccountTransactionFinalizeRedirectGoToSiteClicked = 'Snap Account Transaction Finalize Redirect "Go To Site" Clicked', SnapAccountTransactionFinalizeRedirectSnapUrlClicked = 'Snap Account Transaction Finalize Redirect "Snap URL" Clicked', SnapAccountTransactionFinalizeClosed = 'Snap Account Transaction Finalize Closed', + TokenAutoDetectionEnableModal = 'Token Autodetection Enabled from modal', + TokenAutoDetectionDisableModal = 'Token Autodetection Disabled from modal', ///: END:ONLY_INCLUDE_IF TurnOffProfileSyncing = 'Turn Off Profile Syncing', TurnOnProfileSyncing = 'Turn On Profile Syncing', diff --git a/shared/modules/selectors/index.test.ts b/shared/modules/selectors/index.test.ts index b2174eadcc8a..6f1298952d76 100644 --- a/shared/modules/selectors/index.test.ts +++ b/shared/modules/selectors/index.test.ts @@ -13,6 +13,7 @@ describe('Selectors', () => { metamask: { preferences: { smartTransactionsOptInStatus: true, + showTokenAutodetectModal: true, }, internalAccounts: { selectedAccount: 'account1', @@ -68,6 +69,14 @@ describe('Selectors', () => { }); }); + describe('getShowTokenAutodetectModal', () => { + it('should return show autodetection token modal status', () => { + const state = createMockState(); + const result = getSmartTransactionsOptInStatus(state); + expect(result).toBe(true); + }); + }); + describe('getCurrentChainSupportsSmartTransactions', () => { it('should return true if the chain ID is allowed for smart transactions', () => { const state = createMockState(); diff --git a/shared/modules/selectors/index.ts b/shared/modules/selectors/index.ts index 79362360877f..51c2d67f930c 100644 --- a/shared/modules/selectors/index.ts +++ b/shared/modules/selectors/index.ts @@ -1,2 +1,3 @@ export * from './smart-transactions'; export * from './feature-flags'; +export * from './token-auto-detect'; diff --git a/shared/modules/selectors/token-auto-detect.ts b/shared/modules/selectors/token-auto-detect.ts new file mode 100644 index 000000000000..a6680a47af8d --- /dev/null +++ b/shared/modules/selectors/token-auto-detect.ts @@ -0,0 +1,32 @@ +import { getUseTokenDetection } from '../../../ui/selectors/selectors'; + +type TokenAutoDetectionMetaMaskState = { + metamask: { + preferences: { + showTokenAutodetectModal: boolean | null; + }; + showTokenAutodetectModalOnUpgrade: boolean | null; + }; +}; + +export const getShowTokenAutodetectModal = ( + state: TokenAutoDetectionMetaMaskState, +): boolean | null => { + return state.metamask.preferences?.showTokenAutodetectModal; +}; + +export const getIsShowTokenAutodetectModal = ( + state: TokenAutoDetectionMetaMaskState, +) => { + // Upgrade case + if (state.metamask.showTokenAutodetectModalOnUpgrade === null) { + return ( + !getUseTokenDetection(state) && + state.metamask.showTokenAutodetectModalOnUpgrade === null + ); + } + + return ( + !getUseTokenDetection(state) && getShowTokenAutodetectModal(state) === null + ); +}; diff --git a/test/e2e/default-fixture.js b/test/e2e/default-fixture.js index eb6c6ea7f4a4..74b526bb532c 100644 --- a/test/e2e/default-fixture.js +++ b/test/e2e/default-fixture.js @@ -190,6 +190,7 @@ function defaultFixture(inputChainId = CHAIN_IDS.LOCALHOST) { smartTransactionsOptInStatus: false, useNativeCurrencyAsPrimaryCurrency: true, petnamesEnabled: true, + showTokenAutodetectModal: false, }, selectedAddress: '0x5cfe73b6021e818b776b421b1c4db2474086a7e1', theme: 'light', diff --git a/test/e2e/fixture-builder.js b/test/e2e/fixture-builder.js index e52fed2cfae4..04f5dd5e06b5 100644 --- a/test/e2e/fixture-builder.js +++ b/test/e2e/fixture-builder.js @@ -87,6 +87,7 @@ function onboardingFixture() { smartTransactionsOptInStatus: false, useNativeCurrencyAsPrimaryCurrency: true, petnamesEnabled: true, + showTokenAutodetectModal: false, }, useExternalServices: true, theme: 'light', diff --git a/test/e2e/restore/MetaMaskUserData.json b/test/e2e/restore/MetaMaskUserData.json index 0f400e8a34e7..380efd5c5205 100644 --- a/test/e2e/restore/MetaMaskUserData.json +++ b/test/e2e/restore/MetaMaskUserData.json @@ -37,7 +37,8 @@ "showFiatInTestnets": false, "showTestNetworks": false, "smartTransactionsOptInStatus": false, - "useNativeCurrencyAsPrimaryCurrency": true + "useNativeCurrencyAsPrimaryCurrency": true, + "showTokenAutodetectModal": "boolean" }, "theme": "light", "useBlockie": false, diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json index f76c758dc98d..5570ea4b92ba 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-background-state.json @@ -18,7 +18,8 @@ "currentAppVersion": "string", "previousAppVersion": "", "previousMigrationVersion": 0, - "currentMigrationVersion": "number" + "currentMigrationVersion": "number", + "showTokenAutodetectModalOnUpgrade": "object" }, "AppStateController": { "connectedStatusPopoverHasBeenShown": true, @@ -187,7 +188,8 @@ "showTestNetworks": false, "smartTransactionsOptInStatus": false, "useNativeCurrencyAsPrimaryCurrency": true, - "petnamesEnabled": true + "petnamesEnabled": true, + "showTokenAutodetectModal": "boolean" }, "ipfsGateway": "string", "isIpfsGatewayEnabled": "boolean", diff --git a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json index 416bc7fc6fc8..ac852521854d 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-after-init-opt-in-ui-state.json @@ -33,7 +33,8 @@ "showTestNetworks": false, "smartTransactionsOptInStatus": false, "useNativeCurrencyAsPrimaryCurrency": true, - "petnamesEnabled": true + "petnamesEnabled": true, + "showTokenAutodetectModal": "boolean" }, "firstTimeFlowType": "import", "completedOnboarding": true, @@ -91,6 +92,7 @@ "previousMigrationVersion": 0, "currentMigrationVersion": "number", "selectedNetworkClientId": "string", + "showTokenAutodetectModalOnUpgrade": "object", "networksMetadata": { "networkConfigurationId": { "EIPS": { "1559": false }, diff --git a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json index f8f209648ccd..707752ddef05 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-background-state.json @@ -112,7 +112,8 @@ "showTestNetworks": false, "smartTransactionsOptInStatus": false, "useNativeCurrencyAsPrimaryCurrency": true, - "petnamesEnabled": true + "petnamesEnabled": true, + "showTokenAutodetectModal": "boolean" }, "selectedAddress": "string", "theme": "light", diff --git a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json index 472e9ea517fb..cccbca085552 100644 --- a/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json +++ b/test/e2e/tests/metrics/state-snapshots/errors-before-init-opt-in-ui-state.json @@ -112,7 +112,8 @@ "showTestNetworks": false, "smartTransactionsOptInStatus": false, "useNativeCurrencyAsPrimaryCurrency": true, - "petnamesEnabled": true + "petnamesEnabled": true, + "showTokenAutodetectModal": "boolean" }, "selectedAddress": "string", "theme": "light", diff --git a/test/jest/mock-store.js b/test/jest/mock-store.js index 0719c6f1eea8..234a1316b94c 100644 --- a/test/jest/mock-store.js +++ b/test/jest/mock-store.js @@ -151,6 +151,7 @@ export const createSwapsMockStore = () => { preferences: { showFiatInTestnets: true, smartTransactionsOptInStatus: true, + showTokenAutodetectModal: false, }, transactions: [ { diff --git a/ui/components/app/app-components.scss b/ui/components/app/app-components.scss index 4623b27cfd91..e516ff1fd8f6 100644 --- a/ui/components/app/app-components.scss +++ b/ui/components/app/app-components.scss @@ -45,6 +45,7 @@ @import 'recovery-phrase-reminder/index'; @import 'step-progress-bar/index.scss'; @import 'selected-account/index'; +@import 'auto-detect-token/index'; @import 'smart-transactions/index'; @import 'srp-input/srp-input'; @import 'snaps/snap-privacy-warning/index'; diff --git a/ui/components/app/auto-detect-token/auto-detect-token-modal.test.stories.js b/ui/components/app/auto-detect-token/auto-detect-token-modal.test.stories.js new file mode 100644 index 000000000000..f9164697d402 --- /dev/null +++ b/ui/components/app/auto-detect-token/auto-detect-token-modal.test.stories.js @@ -0,0 +1,48 @@ +import React from 'react'; +import { Provider } from 'react-redux'; +import testData from '../../../../.storybook/test-data'; +import configureStore from '../../../store/store'; +import AutoDetectTokenModal from './auto-detect-token-modal'; + +const customData = { + ...testData, + metamask: { + ...testData.metamask, + currentCurrency: 'USD', + intlLocale: 'en-US', + }, +}; +const customStore = configureStore(customData); + +export default { + title: 'Components/App/AutoDetectTokenModal', + component: AutoDetectTokenModal, + decorators: [ + (Story) => ( + + + + ), + ], + argTypes: { + isOpen: { + control: 'boolean', + }, + onClose: { action: 'onClose' }, + }, + args: { + isOpen: true, + }, +}; + +const Template = (args) => ; + +export const ModalOpen = Template.bind({}); +ModalOpen.args = { + isOpen: true, +}; + +export const ModalClosed = Template.bind({}); +ModalClosed.args = { + isOpen: false, +}; diff --git a/ui/components/app/auto-detect-token/auto-detect-token-modal.test.tsx b/ui/components/app/auto-detect-token/auto-detect-token-modal.test.tsx new file mode 100644 index 000000000000..95f2dc8127a5 --- /dev/null +++ b/ui/components/app/auto-detect-token/auto-detect-token-modal.test.tsx @@ -0,0 +1,75 @@ +import React from 'react'; +import { screen, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { useDispatch } from 'react-redux'; +import configureMockStore from 'redux-mock-store'; +import mockState from '../../../../test/data/mock-state.json'; +import { renderWithProvider } from '../../../../test/lib/render-helpers'; +import AutoDetectTokenModal from './auto-detect-token-modal'; + +// Mock store setup +const mockStore = configureMockStore([])(mockState); + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useDispatch: jest.fn(), +})); + +describe('AutoDetectTokenModal', () => { + const useDispatchMock = jest.mocked(useDispatch); + + beforeEach(() => { + jest.resetAllMocks(); + useDispatchMock.mockReturnValue(jest.fn()); + }); + + it('renders the modal when isOpen is true', () => { + renderWithProvider( + ({})} + setShowTokenAutodetectModalOnUpgrade={() => ({})} + />, + mockStore, + ); + + expect(screen.getByText('Enable token autodetection')).toBeInTheDocument(); + expect(screen.getByText('Allow')).toBeInTheDocument(); + expect(screen.getByText('Not right now')).toBeInTheDocument(); + }); + + it('calls onClose with true when Allow button is clicked', () => { + useDispatchMock.mockReturnValue(jest.fn().mockResolvedValue({})); + const handleClose = jest.fn(); + renderWithProvider( + ({})} + />, + mockStore, + ); + + fireEvent.click(screen.getByText('Allow')); + expect(handleClose).toHaveBeenCalledWith(true); + }); + + it('calls onClose with false when Not right now button is clicked', () => { + useDispatchMock.mockReturnValue(jest.fn().mockResolvedValue({})); + const handleClose = jest.fn(); + const handleSetShowTokenAutodetectModalOnUpgrade = jest.fn(); + renderWithProvider( + , + mockStore, + ); + + fireEvent.click(screen.getByText('Not right now')); + expect(handleClose).toHaveBeenCalledWith(false); + }); +}); diff --git a/ui/components/app/auto-detect-token/auto-detect-token-modal.tsx b/ui/components/app/auto-detect-token/auto-detect-token-modal.tsx new file mode 100644 index 000000000000..27ea2bfcaf70 --- /dev/null +++ b/ui/components/app/auto-detect-token/auto-detect-token-modal.tsx @@ -0,0 +1,138 @@ +import React, { useCallback, useContext } from 'react'; + +import { useDispatch, useSelector } from 'react-redux'; +import { + Modal, + ModalContent, + ModalOverlay, + ModalHeader, + Box, + Text, + ModalBody, + ModalFooter, +} from '../../component-library'; +import { useI18nContext } from '../../../hooks/useI18nContext'; +import { + AlignItems, + BorderRadius, + Display, + FlexDirection, + JustifyContent, + TextAlign, + TextVariant, +} from '../../../helpers/constants/design-system'; +import { setUseTokenDetection } from '../../../store/actions'; +import { MetaMetricsContext } from '../../../contexts/metametrics'; +import { + MetaMetricsEventCategory, + MetaMetricsEventName, +} from '../../../../shared/constants/metametrics'; +import { getProviderConfig } from '../../../ducks/metamask/metamask'; +import { getCurrentLocale } from '../../../ducks/locale/locale'; +import { ORIGIN_METAMASK } from '../../../../shared/constants/app'; + +type AutoDetectTokenModalProps = { + isOpen: boolean; + onClose: (arg: boolean) => void; + setShowTokenAutodetectModalOnUpgrade: (arg: boolean) => void; +}; +function AutoDetectTokenModal({ + isOpen, + onClose, + setShowTokenAutodetectModalOnUpgrade, +}: AutoDetectTokenModalProps) { + const t = useI18nContext(); + const dispatch = useDispatch(); + const trackEvent = useContext(MetaMetricsContext); + const { chainId } = useSelector(getProviderConfig); + + const handleTokenAutoDetection = useCallback( + (val) => { + trackEvent({ + event: val + ? MetaMetricsEventName.TokenAutoDetectionEnableModal + : MetaMetricsEventName.TokenAutoDetectionDisableModal, + category: MetaMetricsEventCategory.Navigation, + properties: { + chain_id: chainId, + locale: getCurrentLocale, + referrer: ORIGIN_METAMASK, + }, + }); + dispatch(setUseTokenDetection(val)); + onClose(val); + setShowTokenAutodetectModalOnUpgrade(val); + }, + [dispatch], + ); + + return ( + onClose(true)} + isClosedOnOutsideClick={false} + isClosedOnEscapeKey={false} + className="mm-modal__custom-scrollbar auto-detect-in-modal" + autoFocus={false} + > + + + + {t('enableTokenAutoDetection')} + + + + + + + {t('allowMetaMaskToDetectTokens')} + + + {t('immediateAccessToYourTokens')} + + + {t('effortlesslyNavigateYourDigitalAssets')} + + + {t('diveStraightIntoUsingYourTokens')} + + + + + handleTokenAutoDetection(true)} + submitButtonProps={{ + children: t('allow'), + block: true, + }} + onCancel={() => handleTokenAutoDetection(false)} + cancelButtonProps={{ + children: t('notRightNow'), + block: true, + }} + /> + + + ); +} + +export default AutoDetectTokenModal; diff --git a/ui/components/app/auto-detect-token/index.scss b/ui/components/app/auto-detect-token/index.scss new file mode 100644 index 000000000000..5714d957f0f9 --- /dev/null +++ b/ui/components/app/auto-detect-token/index.scss @@ -0,0 +1,10 @@ +.auto-detect-in-modal { + &__benefit { + flex: 1; + } + + &__dialog { + background-position: -80px 16px; + background-repeat: no-repeat; + } +} diff --git a/ui/components/multichain/asset-picker-amount/asset-picker/asset-picker.tsx b/ui/components/multichain/asset-picker-amount/asset-picker/asset-picker.tsx index 1d39f37b2bd6..4c7be3c9db44 100644 --- a/ui/components/multichain/asset-picker-amount/asset-picker/asset-picker.tsx +++ b/ui/components/multichain/asset-picker-amount/asset-picker/asset-picker.tsx @@ -111,6 +111,7 @@ export function AssetPicker({ sendingAsset?.details?.symbol || nativeCurrencySymbol } /> +