diff --git a/app/actions/security/index.ts b/app/actions/security/index.ts index 1b6ce9ae390..e0e87a0ef55 100644 --- a/app/actions/security/index.ts +++ b/app/actions/security/index.ts @@ -7,6 +7,7 @@ export enum ActionType { USER_SELECTED_AUTOMATIC_SECURITY_CHECKS_OPTION = 'USER_SELECTED_AUTOMATIC_SECURITY_CHECKS_OPTION', SET_AUTOMATIC_SECURITY_CHECKS_MODAL_OPEN = 'SET_AUTOMATIC_SECURITY_CHECKS_MODAL_OPEN', SET_DATA_COLLECTION_FOR_MARKETING = 'SET_DATA_COLLECTION_FOR_MARKETING', + SET_NFT_AUTO_DETECTION_MODAL_OPEN = 'SET_NFT_AUTO_DETECTION_MODAL_OPEN', } export interface AllowLoginWithRememberMeUpdated @@ -29,6 +30,11 @@ export interface SetAutomaticSecurityChecksModalOpen open: boolean; } +export interface SetNftAutoDetectionModalOpen + extends ReduxAction { + open: boolean; +} + export interface SetDataCollectionForMarketing extends ReduxAction { enabled: boolean; @@ -39,7 +45,8 @@ export type Action = | AutomaticSecurityChecks | UserSelectedAutomaticSecurityChecksOptions | SetAutomaticSecurityChecksModalOpen - | SetDataCollectionForMarketing; + | SetDataCollectionForMarketing + | SetNftAutoDetectionModalOpen; export const setAllowLoginWithRememberMe = ( enabled: boolean, @@ -68,6 +75,13 @@ export const setAutomaticSecurityChecksModalOpen = ( open, }); +export const setNftAutoDetectionModalOpen = ( + open: boolean, +): SetNftAutoDetectionModalOpen => ({ + type: ActionType.SET_NFT_AUTO_DETECTION_MODAL_OPEN, + open, +}); + export const setDataCollectionForMarketing = (enabled: boolean) => ({ type: ActionType.SET_DATA_COLLECTION_FOR_MARKETING, enabled, diff --git a/app/actions/security/state.ts b/app/actions/security/state.ts index 8c393555156..00c41ca3f8d 100644 --- a/app/actions/security/state.ts +++ b/app/actions/security/state.ts @@ -3,6 +3,7 @@ export interface SecuritySettingsState { automaticSecurityChecksEnabled: boolean; hasUserSelectedAutomaticSecurityCheckOption: boolean; isAutomaticSecurityChecksModalOpen: boolean; + isNFTAutoDetectionModalViewed: boolean; // 'null' represents the user not having set his preference over dataCollectionForMarketing yet dataCollectionForMarketing: boolean | null; } diff --git a/app/component-library/components/Toast/Toast.tsx b/app/component-library/components/Toast/Toast.tsx index b14c5cbd230..e4a37893279 100644 --- a/app/component-library/components/Toast/Toast.tsx +++ b/app/component-library/components/Toast/Toast.tsx @@ -187,6 +187,18 @@ const Toast = forwardRef((_, ref: React.ForwardedRef) => { /> ); } + case ToastVariants.Icon: { + const { iconName, iconColor, backgroundColor } = toastOptions; + return ( + + ); + } } }; diff --git a/app/component-library/components/Toast/Toast.types.ts b/app/component-library/components/Toast/Toast.types.ts index 9b0d3b0413f..998d3a17056 100644 --- a/app/component-library/components/Toast/Toast.types.ts +++ b/app/component-library/components/Toast/Toast.types.ts @@ -12,6 +12,7 @@ export enum ToastVariants { Plain = 'Plain', Account = 'Account', Network = 'Network', + Icon = 'Icon', } /** @@ -65,13 +66,21 @@ interface NetworkToastOption extends BaseToastVariants { networkImageSource: ImageSourcePropType; } +interface IconToastOption extends BaseToastVariants { + variant: ToastVariants.Icon; + iconName?: string; + iconColor?: string; + backgroundColor?: string; +} + /** * Different toast options combined in a union type. */ export type ToastOptions = | PlainToastOption | AccountToastOption - | NetworkToastOption; + | NetworkToastOption + | IconToastOption; /** * Toast component reference. diff --git a/app/components/Nav/App/index.js b/app/components/Nav/App/index.js index 6021355e2ac..f0316226cd3 100644 --- a/app/components/Nav/App/index.js +++ b/app/components/Nav/App/index.js @@ -110,6 +110,7 @@ import OnboardingSuccess from '../../Views/OnboardingSuccess'; import DefaultSettings from '../../Views/OnboardingSuccess/DefaultSettings'; import BasicFunctionalityModal from '../../UI/BasicFunctionality/BasicFunctionalityModal/BasicFunctionalityModal'; import SmartTransactionsOptInModal from '../../Views/SmartTransactionsOptInModal/SmartTranactionsOptInModal'; +import NFTAutoDetectionModal from '../../../../app/components/Views/NFTAutoDetectionModal/NFTAutoDetectionModal'; const clearStackNavigatorOptions = { headerShown: false, @@ -693,6 +694,10 @@ const App = ({ userLoggedIn }) => { name={Routes.SHEET.SHOW_NFT_DISPLAY_MEDIA} component={ShowDisplayNftMediaSheet} /> + ); diff --git a/app/components/UI/CollectibleContracts/constants.ts b/app/components/UI/CollectibleContracts/constants.ts index 8d33e4b7a69..2682130644b 100644 --- a/app/components/UI/CollectibleContracts/constants.ts +++ b/app/components/UI/CollectibleContracts/constants.ts @@ -1,3 +1,2 @@ -const RefreshTestId = 'refreshControl'; - -export default RefreshTestId; +export const RefreshTestId = 'refreshControl'; +export const SpinnerTestId = 'spinner'; diff --git a/app/components/UI/CollectibleContracts/index.js b/app/components/UI/CollectibleContracts/index.js index 5d76737516c..866d8b96d7b 100644 --- a/app/components/UI/CollectibleContracts/index.js +++ b/app/components/UI/CollectibleContracts/index.js @@ -8,6 +8,7 @@ import { Platform, FlatList, RefreshControl, + ActivityIndicator, } from 'react-native'; import { connect } from 'react-redux'; import { fontStyles } from '../../../styles/common'; @@ -19,6 +20,7 @@ import { collectibleContractsSelector, collectiblesSelector, favoritesCollectiblesSelector, + isNftFetchingProgressSelector, } from '../../../reducers/collectibles'; import { removeFavoriteCollectible } from '../../../actions/collectibles'; import Text from '../../Base/Text'; @@ -44,7 +46,7 @@ import { NFT_TAB_CONTAINER_ID, } from '../../../../wdio/screen-objects/testIDs/Screens/WalletView.testIds'; import { useMetrics } from '../../../components/hooks/useMetrics'; -import RefreshTestId from './constants'; +import { RefreshTestId, SpinnerTestId } from './constants'; const createStyles = (colors) => StyleSheet.create({ @@ -87,6 +89,9 @@ const createStyles = (colors) => marginBottom: 8, fontSize: 14, }, + spinner: { + marginBottom: 8, + }, }); /** @@ -100,6 +105,7 @@ const CollectibleContracts = ({ navigation, collectibleContracts, collectibles: allCollectibles, + isNftFetchingProgress, favoriteCollectibles, removeFavoriteCollectible, useNftDetection, @@ -219,6 +225,14 @@ const CollectibleContracts = ({ const renderFooter = useCallback( () => ( + {isNftFetchingProgress ? ( + + ) : null} + {strings('wallet.no_collectibles')} @@ -233,7 +247,7 @@ const CollectibleContracts = ({ ), - [goToAddCollectible, isAddNFTEnabled, styles], + [goToAddCollectible, isAddNFTEnabled, styles, isNftFetchingProgress], ); const renderCollectibleContract = useCallback( @@ -282,7 +296,7 @@ const CollectibleContracts = ({ NftDetectionController.detectNfts(), NftController.checkAndUpdateAllNftsOwnershipStatus(), ]; - await Promise.all(actions); + await Promise.allSettled(actions); setRefreshing(false); }); }, [setRefreshing]); @@ -322,7 +336,7 @@ const CollectibleContracts = ({ <> {isCollectionDetectionBannerVisible && ( - + )} {renderFavoriteCollectibles()} @@ -355,7 +369,6 @@ const CollectibleContracts = ({ renderFooter, renderEmpty, isCollectionDetectionBannerVisible, - navigation, styles.emptyView, ], ); @@ -391,7 +404,11 @@ CollectibleContracts.propTypes = { * Array of collectibles objects */ collectibles: PropTypes.array, - + /** + * boolean indicating if fetching status is + * still in progress + */ + isNftFetchingProgress: PropTypes.bool, /** * Navigation object required to push * the Asset detail view @@ -426,6 +443,7 @@ const mapStateToProps = (state) => ({ useNftDetection: selectUseNftDetection(state), collectibleContracts: collectibleContractsSelector(state), collectibles: collectiblesSelector(state), + isNftFetchingProgress: isNftFetchingProgressSelector(state), favoriteCollectibles: favoritesCollectiblesSelector(state), isIpfsGatewayEnabled: selectIsIpfsGatewayEnabled(state), displayNftMedia: selectDisplayNftMedia(state), diff --git a/app/components/UI/CollectibleContracts/index.test.tsx b/app/components/UI/CollectibleContracts/index.test.tsx index b3ea91bbf42..946a654ba62 100644 --- a/app/components/UI/CollectibleContracts/index.test.tsx +++ b/app/components/UI/CollectibleContracts/index.test.tsx @@ -495,4 +495,124 @@ describe('CollectibleContracts', () => { expect(spyOnUpdateNftMetadata).toHaveBeenCalledTimes(0); expect(spyOnDetectNfts).toHaveBeenCalledTimes(1); }); + + it('shows spinner if nfts are still being fetched', async () => { + const CURRENT_ACCOUNT = '0x1a'; + const mockState = { + collectibles: { + favorites: {}, + isNftFetchingProgress: true, + }, + engine: { + backgroundState: { + ...initialBackgroundState, + NetworkController: { + network: '1', + providerConfig: { + ticker: 'ETH', + type: 'mainnet', + chainId: '1', + }, + }, + AccountTrackerController: { + accounts: { [CURRENT_ACCOUNT]: { balance: '0' } }, + }, + PreferencesController: { + useNftDetection: true, + displayNftMedia: true, + selectedAddress: CURRENT_ACCOUNT, + identities: { + [CURRENT_ACCOUNT]: { + address: CURRENT_ACCOUNT, + name: 'Account 1', + }, + }, + }, + NftController: { + addNft: jest.fn(), + updateNftMetadata: jest.fn(), + allNfts: { + [CURRENT_ACCOUNT]: { + '1': [], + }, + }, + allNftContracts: { + [CURRENT_ACCOUNT]: { + '1': [], + }, + }, + }, + NftDetectionController: { + detectNfts: jest.fn(), + }, + }, + }, + }; + const { queryByTestId } = renderWithProvider(, { + state: mockState, + }); + + const spinner = queryByTestId('spinner'); + expect(spinner).not.toBeNull(); + }); + + it('Does not show spinner if nfts are not still being fetched', async () => { + const CURRENT_ACCOUNT = '0x1a'; + const mockState = { + collectibles: { + favorites: {}, + }, + engine: { + backgroundState: { + ...initialBackgroundState, + NetworkController: { + network: '1', + providerConfig: { + ticker: 'ETH', + type: 'mainnet', + chainId: '1', + }, + }, + AccountTrackerController: { + accounts: { [CURRENT_ACCOUNT]: { balance: '0' } }, + }, + PreferencesController: { + useNftDetection: true, + displayNftMedia: true, + selectedAddress: CURRENT_ACCOUNT, + identities: { + [CURRENT_ACCOUNT]: { + address: CURRENT_ACCOUNT, + name: 'Account 1', + }, + }, + }, + NftController: { + addNft: jest.fn(), + updateNftMetadata: jest.fn(), + allNfts: { + [CURRENT_ACCOUNT]: { + '1': [], + }, + }, + allNftContracts: { + [CURRENT_ACCOUNT]: { + '1': [], + }, + }, + }, + NftDetectionController: { + detectNfts: jest.fn(), + }, + }, + }, + }; + + const { queryByTestId } = renderWithProvider(, { + state: mockState, + }); + + const spinner = queryByTestId('spinner'); + expect(spinner).toBeNull(); + }); }); diff --git a/app/components/UI/CollectibleDetectionModal/index.tsx b/app/components/UI/CollectibleDetectionModal/index.tsx index 50d2061585d..b66303f25b2 100644 --- a/app/components/UI/CollectibleDetectionModal/index.tsx +++ b/app/components/UI/CollectibleDetectionModal/index.tsx @@ -1,10 +1,24 @@ -import React from 'react'; +import React, { useCallback, useContext } from 'react'; import { StyleSheet, View } from 'react-native'; import { strings } from '../../../../locales/i18n'; import Banner from '../../../component-library/components/Banners/Banner/Banner'; import { BannerVariant } from '../../../component-library/components/Banners/Banner'; import { ButtonVariants } from '../../../component-library/components/Buttons/Button'; import { TextVariant } from '../../../component-library/components/Texts/Text'; +import { + ToastContext, + ToastVariants, +} from '../../../component-library/components/Toast'; +import { + IconColor, + IconName, +} from '../../../component-library/components/Icons/Icon'; +import { useTheme } from '../../../util/theme'; +import Engine from '../../../core/Engine'; +import { + hideNftFetchingLoadingIndicator, + showNftFetchingLoadingIndicator, +} from '../../../reducers/collectibles'; const styles = StyleSheet.create({ alertBar: { @@ -13,24 +27,33 @@ const styles = StyleSheet.create({ }, }); -interface Props { - /** - * Navigation object needed to link to settings - */ - // TODO: Replace "any" with type - // eslint-disable-next-line @typescript-eslint/no-explicit-any - navigation: any; -} +const CollectibleDetectionModal = () => { + const { colors } = useTheme(); + const { toastRef } = useContext(ToastContext); -const CollectibleDetectionModal = ({ navigation }: Props) => { - const goToSecuritySettings = () => { - navigation.navigate('SettingsView', { - screen: 'SecuritySettings', - params: { - scrollToDetectNFTs: true, - }, + const showToastAndEnableNFtDetection = useCallback(async () => { + // show toast + toastRef?.current?.showToast({ + variant: ToastVariants.Icon, + labelOptions: [{ label: strings('toast.nft_detection_enabled') }], + iconName: IconName.CheckBold, + iconColor: IconColor.Default, + backgroundColor: colors.primary.inverse, + hasNoTimeout: false, }); - }; + // set nft autodetection + const { PreferencesController, NftDetectionController } = Engine.context; + PreferencesController.setDisplayNftMedia(true); + PreferencesController.setUseNftDetection(true); + // Call detect nfts + showNftFetchingLoadingIndicator(); + try { + await NftDetectionController.detectNfts(); + } finally { + hideNftFetchingLoadingIndicator(); + } + }, [colors.primary.inverse, toastRef]); + return ( { actionButtonProps={{ variant: ButtonVariants.Link, label: strings('wallet.nfts_autodetect_cta'), - onPress: goToSecuritySettings, + onPress: showToastAndEnableNFtDetection, textVariant: TextVariant.BodyMD, }} /> diff --git a/app/components/Views/Collectible/index.js b/app/components/Views/Collectible/index.js index 4bbd42ae402..0af7da45279 100644 --- a/app/components/Views/Collectible/index.js +++ b/app/components/Views/Collectible/index.js @@ -78,8 +78,11 @@ class Collectible extends PureComponent { onRefresh = async () => { this.setState({ refreshing: true }); const { NftDetectionController } = Engine.context; - await NftDetectionController.detectNfts(); - this.setState({ refreshing: false }); + try { + await NftDetectionController.detectNfts(); + } finally { + this.setState({ refreshing: false }); + } }; hideCollectibleContractModal = () => { diff --git a/app/components/Views/NFTAutoDetectionModal/NFTAutoDetectionModal.styles.ts b/app/components/Views/NFTAutoDetectionModal/NFTAutoDetectionModal.styles.ts new file mode 100644 index 00000000000..87f6afbc2bc --- /dev/null +++ b/app/components/Views/NFTAutoDetectionModal/NFTAutoDetectionModal.styles.ts @@ -0,0 +1,29 @@ +import { StyleSheet } from 'react-native'; + +/** + * Style sheet function for NFT auto detection modal component. + * + * @returns StyleSheet object. + */ +const styleSheet = () => + StyleSheet.create({ + container: { + alignItems: 'center', + }, + image: { + width: 219, + height: 219, + }, + description: { + marginLeft: 32, + marginRight: 32, + }, + buttonsContainer: { + paddingTop: 24, + marginLeft: 16, + marginRight: 16, + }, + spacer: { height: 8 }, + }); + +export default styleSheet; diff --git a/app/components/Views/NFTAutoDetectionModal/NFTAutoDetectionModal.test.tsx b/app/components/Views/NFTAutoDetectionModal/NFTAutoDetectionModal.test.tsx new file mode 100644 index 00000000000..f21fa098833 --- /dev/null +++ b/app/components/Views/NFTAutoDetectionModal/NFTAutoDetectionModal.test.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import NFTAutoDetectionModal from './NFTAutoDetectionModal'; +import renderWithProvider from '../../../util/test/renderWithProvider'; +import { createStackNavigator } from '@react-navigation/stack'; +import Routes from '../../../constants/navigation/Routes'; +import Engine from '../../../core/Engine'; +import { fireEvent } from '@testing-library/react-native'; +import { RootState } from 'app/reducers'; + +const mockEngine = Engine; + +const setUseNftDetectionSpy = jest.spyOn( + Engine.context.PreferencesController, + 'setUseNftDetection', +); + +const setDisplayNftMediaSpy = jest.spyOn( + Engine.context.PreferencesController, + 'setDisplayNftMedia', +); +jest.mock('../../../core/Engine', () => ({ + init: () => mockEngine.init({}), + context: { + PreferencesController: { + setUseNftDetection: jest.fn(), + setDisplayNftMedia: jest.fn(), + }, + }, +})); + +type PartialDeepState = { + [P in keyof T]?: PartialDeepState; +}; + +const initialState = { + engine: { + backgroundState: { + PreferencesController: { + displayNftMedia: true, + }, + }, + }, +}; + +const Stack = createStackNavigator(); + +const renderComponent = (state: PartialDeepState = {}) => + renderWithProvider( + + + {() => } + + , + { state }, + ); +describe('NFT Auto detection modal', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + it('render matches snapshot', () => { + const { toJSON } = renderComponent(initialState); + expect(toJSON()).toMatchSnapshot(); + }); + + it('calls setUseNftDetection and setDisplayNftMedia when clicking on allow button with nftDisplayMedia initially off', () => { + const { getByTestId } = renderComponent({ + engine: { + backgroundState: { + PreferencesController: { + displayNftMedia: false, + }, + }, + }, + }); + const allowButton = getByTestId('allow'); + + fireEvent.press(allowButton); + expect(setUseNftDetectionSpy).toHaveBeenCalled(); + expect(setDisplayNftMediaSpy).toHaveBeenCalled(); + }); + + it('calls setDisplayNftMedia when clicking on allow button if displayNftMedia if on', () => { + const { getByTestId } = renderComponent(initialState); + const allowButton = getByTestId('allow'); + + fireEvent.press(allowButton); + expect(setUseNftDetectionSpy).toHaveBeenCalled(); + expect(setDisplayNftMediaSpy).not.toHaveBeenCalled(); + }); + + it('Does not call setUseNftDetection nor setDisplayNftMedia when clicking on not right now button', () => { + const { getByTestId } = renderComponent(initialState); + const cancelButton = getByTestId('cancel'); + + fireEvent.press(cancelButton); + expect(setUseNftDetectionSpy).not.toHaveBeenCalled(); + expect(setDisplayNftMediaSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/app/components/Views/NFTAutoDetectionModal/NFTAutoDetectionModal.tsx b/app/components/Views/NFTAutoDetectionModal/NFTAutoDetectionModal.tsx new file mode 100644 index 00000000000..bf787491664 --- /dev/null +++ b/app/components/Views/NFTAutoDetectionModal/NFTAutoDetectionModal.tsx @@ -0,0 +1,103 @@ +/* eslint-disable @typescript-eslint/no-require-imports */ +/* eslint-disable import/no-commonjs */ +/* eslint-disable @typescript-eslint/no-var-requires */ +import React, { useRef, useCallback } from 'react'; +import BottomSheet, { + BottomSheetRef, +} from '../../../component-library/components/BottomSheets/BottomSheet'; +import { strings } from '../../../../locales/i18n'; +import { useStyles } from '../../../component-library/hooks'; +import styleSheet from './NFTAutoDetectionModal.styles'; +import SheetHeader from '../../../component-library/components/Sheet/SheetHeader'; +import Text from '../../../component-library/components/Texts/Text'; +import { View, Image } from 'react-native'; +import { NftDetectionModalSelectorsIDs } from '../../../../e2e/selectors/Modals/NftDetectionModal.selectors'; + +import Button, { + ButtonSize, + ButtonVariants, + ButtonWidthTypes, +} from '../../../component-library/components/Buttons/Button'; +import { useNavigation } from '@react-navigation/native'; +import Engine from '../../../core/Engine'; +import { useMetrics } from '../../../components/hooks/useMetrics'; +import { MetaMetricsEvents } from '../../../core/Analytics'; +import { selectChainId } from '../../../selectors/networkController'; +import { useSelector } from 'react-redux'; +import { selectDisplayNftMedia } from '../../../selectors/preferencesController'; + +const walletImage = require('../../../images/wallet-alpha.png'); + +const NFTAutoDetectionModal = () => { + const { styles } = useStyles(styleSheet, {}); + const sheetRef = useRef(null); + const navigation = useNavigation(); + const chainId = useSelector(selectChainId); + const displayNftMedia = useSelector(selectDisplayNftMedia); + const { trackEvent } = useMetrics(); + const enableNftDetectionAndDismissModal = useCallback( + (value: boolean) => { + if (value) { + const { PreferencesController } = Engine.context; + if (!displayNftMedia) { + PreferencesController.setDisplayNftMedia(true); + } + PreferencesController.setUseNftDetection(true); + trackEvent(MetaMetricsEvents.NFT_AUTO_DETECTION_MODAL_ENABLE, { + chainId, + }); + } else { + trackEvent(MetaMetricsEvents.NFT_AUTO_DETECTION_MODAL_DISABLE, { + chainId, + }); + } + + if (sheetRef?.current) { + sheetRef.current.onCloseBottomSheet(); + } else { + navigation.goBack(); + } + }, + [displayNftMedia, trackEvent, chainId, navigation], + ); + + return ( + + + + + + + + {strings('enable_nft-auto-detection.description')} + + • {strings('enable_nft-auto-detection.immediateAccess')} + • {strings('enable_nft-auto-detection.navigate')} + • {strings('enable_nft-auto-detection.dive')} + + +