From 0c2befd2c404968424df69ed52e1a21e608f40fe Mon Sep 17 00:00:00 2001 From: sahar-fehri Date: Thu, 1 Aug 2024 18:00:36 +0200 Subject: [PATCH] feat: New nft details page (#10277) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** This PR modifies the design of the NFT details page. Designs: https://www.figma.com/design/TfVzSMJA8KwpWX8TTWQ2iO/Asset-list-and-details?node-id=1242-143952&m=dev ## **Related issues** Fixes: Related: https://github.com/MetaMask/core/pull/4522 Related: https://github.com/MetaMask/core/pull/4443 ## **Manual testing steps** 1. Go to this NFT tab 2. Click and browse through your NFTs 3. You should be able to see basic things like tokenId, contract address, description. Other fields will be displayed if they exist. 4. Click on the contract address and you should be redirected to etherscan. 5. Click on the image in the NFT details page and you should see the image in a new page without any of the details. 6. Try clicking on the navbar and go to "See on opensea" 7. Removing NFT flow and Sending NFT flow should not be affected ## **Screenshots/Recordings** ### **Before** https://github.com/user-attachments/assets/97679ba6-c8bb-483d-adb1-f3ebfdfb4337 ### **After** https://github.com/user-attachments/assets/024f389a-9feb-4fef-8b20-81fa59f232fd Also we added a new bottom sheet when token ID is too long https://github.com/user-attachments/assets/1434b08b-ab1a-4e56-acde-189b303b88e9 ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/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-mobile/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. --------- Co-authored-by: Curtis --- .../Icons/Icon/assets/arrow-down.svg | 2 +- .../Icons/Icon/assets/arrow-left.svg | 2 +- .../Icons/Icon/assets/arrow-right.svg | 2 +- .../components/Icons/Icon/assets/arrow-up.svg | 2 +- .../components/Icons/Icon/assets/close.svg | 2 +- .../Icons/Icon/assets/more-horizontal.svg | 2 +- .../Icons/Icon/assets/more-vertical.svg | 2 +- app/components/Base/RemoteImage/index.js | 162 +- app/components/Nav/App/index.js | 8 + app/components/Nav/Main/MainNavigator.js | 34 + .../AboutAsset/ContentDisplay.tsx | 5 +- .../UI/CollectibleContractElement/index.js | 1 + .../UI/CollectibleContracts/index.js | 9 +- .../UI/CollectibleMedia/CollectibleMedia.tsx | 30 +- .../CollectibleMedia.types.ts | 8 +- app/components/UI/Navbar/index.js | 117 +- .../Views/NftDetails/NFtDetailsFullImage.tsx | 37 + .../Views/NftDetails/NftDetails.styles.ts | 153 ++ .../Views/NftDetails/NftDetails.test.ts | 160 ++ .../Views/NftDetails/NftDetails.tsx | 656 ++++++++ .../Views/NftDetails/NftDetails.types.ts | 27 + .../Views/NftDetails/NftDetailsBox.tsx | 70 + .../NftDetails/NftDetailsInformationRow.tsx | 56 + .../__snapshots__/NftDetails.test.ts.snap | 1436 +++++++++++++++++ app/components/Views/NftDetails/index.ts | 1 + .../Views/NftDetails/nftDetails.utils.ts | 3 + .../Views/NftOptions/NftOptions.styles.ts | 39 + .../Views/NftOptions/NftOptions.tsx | 140 ++ app/components/Views/NftOptions/index.ts | 1 + .../ShowTokenIdSheet.styles.ts | 22 + .../ShowTokenIdSheet.test.tsx | 32 + .../ShowTokenIdSheet/ShowTokenIdSheet.tsx | 36 + .../ShowTokenIdSheet.types.ts | 3 + .../ShowTokenIdSheet.test.tsx.snap | 497 ++++++ .../Views/ShowTokenIdSheet/index.ts | 1 + app/constants/navigation/Routes.ts | 1 + app/util/date/index.js | 13 + app/util/date/index.test.ts | 15 +- e2e/pages/wallet/WalletView.js | 10 + e2e/selectors/wallet/WalletView.selectors.js | 2 + e2e/specs/assets/nft-details.spec.js | 69 + e2e/utils/Matchers.js | 5 +- locales/languages/en.json | 23 + .../@metamask+assets-controllers+30.0.0.patch | 258 ++- 44 files changed, 4094 insertions(+), 60 deletions(-) create mode 100644 app/components/Views/NftDetails/NFtDetailsFullImage.tsx create mode 100644 app/components/Views/NftDetails/NftDetails.styles.ts create mode 100644 app/components/Views/NftDetails/NftDetails.test.ts create mode 100644 app/components/Views/NftDetails/NftDetails.tsx create mode 100644 app/components/Views/NftDetails/NftDetails.types.ts create mode 100644 app/components/Views/NftDetails/NftDetailsBox.tsx create mode 100644 app/components/Views/NftDetails/NftDetailsInformationRow.tsx create mode 100644 app/components/Views/NftDetails/__snapshots__/NftDetails.test.ts.snap create mode 100644 app/components/Views/NftDetails/index.ts create mode 100644 app/components/Views/NftDetails/nftDetails.utils.ts create mode 100644 app/components/Views/NftOptions/NftOptions.styles.ts create mode 100644 app/components/Views/NftOptions/NftOptions.tsx create mode 100644 app/components/Views/NftOptions/index.ts create mode 100644 app/components/Views/ShowTokenIdSheet/ShowTokenIdSheet.styles.ts create mode 100644 app/components/Views/ShowTokenIdSheet/ShowTokenIdSheet.test.tsx create mode 100644 app/components/Views/ShowTokenIdSheet/ShowTokenIdSheet.tsx create mode 100644 app/components/Views/ShowTokenIdSheet/ShowTokenIdSheet.types.ts create mode 100644 app/components/Views/ShowTokenIdSheet/__snapshots__/ShowTokenIdSheet.test.tsx.snap create mode 100644 app/components/Views/ShowTokenIdSheet/index.ts create mode 100644 e2e/specs/assets/nft-details.spec.js diff --git a/app/component-library/components/Icons/Icon/assets/arrow-down.svg b/app/component-library/components/Icons/Icon/assets/arrow-down.svg index 3f85f6f7092..53a2504ec86 100644 --- a/app/component-library/components/Icons/Icon/assets/arrow-down.svg +++ b/app/component-library/components/Icons/Icon/assets/arrow-down.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/component-library/components/Icons/Icon/assets/arrow-left.svg b/app/component-library/components/Icons/Icon/assets/arrow-left.svg index 1c234045437..c5a0b3b72a0 100644 --- a/app/component-library/components/Icons/Icon/assets/arrow-left.svg +++ b/app/component-library/components/Icons/Icon/assets/arrow-left.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/component-library/components/Icons/Icon/assets/arrow-right.svg b/app/component-library/components/Icons/Icon/assets/arrow-right.svg index e4b516d4aa9..c8f00386182 100644 --- a/app/component-library/components/Icons/Icon/assets/arrow-right.svg +++ b/app/component-library/components/Icons/Icon/assets/arrow-right.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/component-library/components/Icons/Icon/assets/arrow-up.svg b/app/component-library/components/Icons/Icon/assets/arrow-up.svg index ae046fd52bc..f4ee67a926e 100644 --- a/app/component-library/components/Icons/Icon/assets/arrow-up.svg +++ b/app/component-library/components/Icons/Icon/assets/arrow-up.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/component-library/components/Icons/Icon/assets/close.svg b/app/component-library/components/Icons/Icon/assets/close.svg index 6f6b12c407b..0a1bcfda123 100644 --- a/app/component-library/components/Icons/Icon/assets/close.svg +++ b/app/component-library/components/Icons/Icon/assets/close.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/component-library/components/Icons/Icon/assets/more-horizontal.svg b/app/component-library/components/Icons/Icon/assets/more-horizontal.svg index 8567f3cbda8..e0f3e437f6f 100644 --- a/app/component-library/components/Icons/Icon/assets/more-horizontal.svg +++ b/app/component-library/components/Icons/Icon/assets/more-horizontal.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/component-library/components/Icons/Icon/assets/more-vertical.svg b/app/component-library/components/Icons/Icon/assets/more-vertical.svg index c34f71cd880..bf325e2105d 100644 --- a/app/component-library/components/Icons/Icon/assets/more-vertical.svg +++ b/app/component-library/components/Icons/Icon/assets/more-vertical.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/app/components/Base/RemoteImage/index.js b/app/components/Base/RemoteImage/index.js index f47269f60ce..16707964806 100644 --- a/app/components/Base/RemoteImage/index.js +++ b/app/components/Base/RemoteImage/index.js @@ -1,6 +1,12 @@ -import React, { useMemo, useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import PropTypes from 'prop-types'; -import { Image, ViewPropTypes, View, StyleSheet } from 'react-native'; +import { + Image, + ViewPropTypes, + View, + StyleSheet, + Dimensions, +} from 'react-native'; import FadeIn from 'react-native-fade-in-image'; // eslint-disable-next-line import/default import resolveAssetSource from 'react-native/Libraries/Image/resolveAssetSource'; @@ -10,13 +16,44 @@ import ComponentErrorBoundary from '../../UI/ComponentErrorBoundary'; import useIpfsGateway from '../../hooks/useIpfsGateway'; import { getFormattedIpfsUrl } from '@metamask/assets-controllers'; import Identicon from '../../UI/Identicon'; +import BadgeWrapper from '../../../component-library/components/Badges/BadgeWrapper'; +import Badge, { + BadgeVariant, +} from '../../../component-library/components/Badges/Badge'; +import { useSelector } from 'react-redux'; +import { + selectChainId, + selectTicker, +} from '../../../selectors/networkController'; +import { + getTestNetImageByChainId, + isLineaMainnet, + isMainNet, + isTestNet, +} from '../../../util/networks'; +import images from 'images/image-icons'; +import { selectNetworkName } from '../../../selectors/networkInfos'; + +import { BadgeAnchorElementShape } from '../../../component-library/components/Badges/BadgeWrapper/BadgeWrapper.types'; import useSvgUriViewBox from '../../hooks/useSvgUriViewBox'; +import { AvatarSize } from '../../../component-library/components/Avatars/Avatar'; const createStyles = () => StyleSheet.create({ svgContainer: { overflow: 'hidden', }, + badgeWrapper: { + flex: 1, + }, + imageStyle: { + width: '100%', + height: '100%', + borderRadius: 8, + }, + detailedImageStyle: { + borderRadius: 8, + }, }); const RemoteImage = (props) => { @@ -26,6 +63,9 @@ const RemoteImage = (props) => { const isImageUrl = isUrl(props?.source?.uri); const ipfsGateway = useIpfsGateway(); const styles = createStyles(); + const chainId = useSelector(selectChainId); + const ticker = useSelector(selectTicker); + const networkName = useSelector(selectNetworkName); const resolvedIpfsUrl = useMemo(() => { try { const url = new URL(props.source.uri); @@ -41,6 +81,51 @@ const RemoteImage = (props) => { const onError = ({ nativeEvent: { error } }) => setError(error); + const [dimensions, setDimensions] = useState(null); + + useEffect(() => { + const calculateImageDimensions = (imageWidth, imageHeight) => { + const deviceWidth = Dimensions.get('window').width; + const maxWidth = deviceWidth - 32; + const maxHeight = 0.75 * maxWidth; + + if (imageWidth > imageHeight) { + // Horizontal image + const width = maxWidth; + const height = (imageHeight / imageWidth) * maxWidth; + return { width, height }; + } else if (imageHeight > imageWidth) { + // Vertical image + const height = maxHeight; + const width = (imageWidth / imageHeight) * maxHeight; + return { width, height }; + } + // Square image + return { width: maxHeight, height: maxHeight }; + }; + + Image.getSize( + uri, + (width, height) => { + const { width: calculatedWidth, height: calculatedHeight } = + calculateImageDimensions(width, height); + setDimensions({ width: calculatedWidth, height: calculatedHeight }); + }, + () => { + console.error('Failed to get image dimensions'); + }, + ); + }, [uri]); + + const NetworkBadgeSource = () => { + if (isTestNet(chainId)) return getTestNetImageByChainId(chainId); + + if (isMainNet(chainId)) return images.ETHEREUM; + + if (isLineaMainnet(chainId)) return images['LINEA-MAINNET']; + + return ticker ? images[ticker] : undefined; + }; const isSVG = source && source.uri && @@ -83,12 +168,75 @@ const RemoteImage = (props) => { } if (props.fadeIn) { + const { style, ...restProps } = props; + const badge = { + top: -4, + right: -4, + }; return ( - - - + <> + {props.isTokenImage ? ( + + + {props.isFullRatio && dimensions ? ( + + } + > + + + ) : ( + + } + > + + + + + )} + + + ) : ( + + + + )} + ); } + return ; }; @@ -121,6 +269,10 @@ RemoteImage.propTypes = { * Token address */ address: PropTypes.string, + + isTokenImage: PropTypes.bool, + + isFullRatio: PropTypes.bool, }; export default RemoteImage; diff --git a/app/components/Nav/App/index.js b/app/components/Nav/App/index.js index 9f7b2f1b17e..2ca02dd680e 100644 --- a/app/components/Nav/App/index.js +++ b/app/components/Nav/App/index.js @@ -116,6 +116,8 @@ import BasicFunctionalityModal from '../../UI/BasicFunctionality/BasicFunctional import SmartTransactionsOptInModal from '../../Views/SmartTransactionsOptInModal/SmartTranactionsOptInModal'; import ProfileSyncingModal from '../../UI/ProfileSyncing/ProfileSyncingModal/ProfileSyncingModal'; import NFTAutoDetectionModal from '../../../../app/components/Views/NFTAutoDetectionModal/NFTAutoDetectionModal'; +import NftOptions from '../../../components/Views/NftOptions'; +import ShowTokenIdSheet from '../../../components/Views/ShowTokenIdSheet'; import OriginSpamModal from '../../Views/OriginSpamModal/OriginSpamModal'; ///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps) import { SnapsExecutionWebView } from '../../../lib/snaps'; @@ -682,6 +684,7 @@ const App = ({ userLoggedIn }) => { /> + { name={Routes.MODAL.NFT_AUTO_DETECTION_MODAL} component={NFTAutoDetectionModal} /> + + ( ); +/* eslint-disable react/prop-types */ +const NftDetailsModeView = (props) => ( + + + +); + +/* eslint-disable react/prop-types */ +const NftDetailsFullImageModeView = (props) => ( + + + +); + const SendFlowView = () => ( ( name={Routes.NOTIFICATIONS.VIEW} component={NotificationsModeView} /> + + diff --git a/app/components/UI/AssetOverview/AboutAsset/ContentDisplay.tsx b/app/components/UI/AssetOverview/AboutAsset/ContentDisplay.tsx index 10e8ca9fcf2..b32d1e3cc85 100644 --- a/app/components/UI/AssetOverview/AboutAsset/ContentDisplay.tsx +++ b/app/components/UI/AssetOverview/AboutAsset/ContentDisplay.tsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { View } from 'react-native'; +import { TextStyle, View } from 'react-native'; import ButtonLink from '../../../../component-library/components/Buttons/Button/variants/ButtonLink'; import { useStyles } from '../../../../component-library/hooks'; import Text, { @@ -13,12 +13,14 @@ interface ContentDisplayProps { content: string; numberOfLines?: number; disclaimer?: string; + textStyle?: TextStyle; } const ContentDisplay = ({ content, numberOfLines = 3, disclaimer, + textStyle, }: ContentDisplayProps) => { const { styles } = useStyles(styleSheet, {}); @@ -33,6 +35,7 @@ const ContentDisplay = ({ {content} diff --git a/app/components/UI/CollectibleContractElement/index.js b/app/components/UI/CollectibleContractElement/index.js index 6f05e932ff0..742d69dcdcd 100644 --- a/app/components/UI/CollectibleContractElement/index.js +++ b/app/components/UI/CollectibleContractElement/index.js @@ -189,6 +189,7 @@ function CollectibleContractElement({ style={styles.collectibleIcon} collectible={{ ...collectible, name }} onPressColectible={onPress} + isTokenImage /> diff --git a/app/components/UI/CollectibleContracts/index.js b/app/components/UI/CollectibleContracts/index.js index d03c8bce4d3..52ccc725c63 100644 --- a/app/components/UI/CollectibleContracts/index.js +++ b/app/components/UI/CollectibleContracts/index.js @@ -42,6 +42,7 @@ import { selectSelectedInternalAccountChecksummedAddress } from '../../../select import { WalletViewSelectorsIDs } from '../../../../e2e/selectors/wallet/WalletView.selectors'; import { useMetrics } from '../../../components/hooks/useMetrics'; import { RefreshTestId, SpinnerTestId } from './constants'; +import { debounce } from 'lodash'; const createStyles = (colors) => StyleSheet.create({ @@ -89,6 +90,10 @@ const createStyles = (colors) => }, }); +const debouncedNavigation = debounce((navigation, collectible) => { + navigation.navigate('NftDetails', { collectible }); +}, 200); + /** * View that renders a list of CollectibleContract * ERC-721 and ERC-1155 @@ -120,8 +125,8 @@ const CollectibleContracts = ({ networkType === MAINNET && !useNftDetection; const onItemPress = useCallback( - (collectible, contractName) => { - navigation.navigate('CollectiblesDetails', { collectible, contractName }); + (collectible) => { + debouncedNavigation(navigation, collectible); }, [navigation], ); diff --git a/app/components/UI/CollectibleMedia/CollectibleMedia.tsx b/app/components/UI/CollectibleMedia/CollectibleMedia.tsx index a538aea540c..7dc94f9b0d4 100644 --- a/app/components/UI/CollectibleMedia/CollectibleMedia.tsx +++ b/app/components/UI/CollectibleMedia/CollectibleMedia.tsx @@ -33,6 +33,8 @@ const CollectibleMedia: React.FC = ({ cover, onClose, onPressColectible, + isTokenImage, + isFullRatio, }) => { const [sourceUri, setSourceUri] = useState(null); const { colors } = useTheme(); @@ -99,7 +101,7 @@ const CollectibleMedia: React.FC = ({ {collectible.tokenId - ? ` #${formatTokenId(collectible.tokenId)}` + ? ` #${formatTokenId(parseInt(collectible.tokenId, 10))}` : ''} @@ -139,7 +141,7 @@ const CollectibleMedia: React.FC = ({ style={tiny ? styles.textWrapperIcon : styles.textWrapper} > {collectible.tokenId - ? ` #${formatTokenId(collectible.tokenId)}` + ? ` #${formatTokenId(parseInt(collectible.tokenId, 10))}` : ''} @@ -194,6 +196,8 @@ const CollectibleMedia: React.FC = ({ ]} onError={fallback} testID="nft-image" + isTokenImage={isTokenImage} + isFullRatio={isFullRatio} /> ); } @@ -208,20 +212,28 @@ const CollectibleMedia: React.FC = ({ return renderFallback(false); }, [ - collectible, + displayNftMedia, + isIpfsGatewayEnabled, sourceUri, - onClose, + collectible.error, + collectible.animation, + renderFallback, renderAnimation, + onClose, + styles.mediaPlayer, + styles.cover, + styles.image, + styles.tinyImage, + styles.smallImage, + styles.bigImage, + cover, style, tiny, small, big, - cover, - styles, - isIpfsGatewayEnabled, - renderFallback, fallback, - displayNftMedia, + isTokenImage, + isFullRatio, ]); return ( diff --git a/app/components/UI/CollectibleMedia/CollectibleMedia.types.ts b/app/components/UI/CollectibleMedia/CollectibleMedia.types.ts index f3c28b11045..ce88e73fb84 100644 --- a/app/components/UI/CollectibleMedia/CollectibleMedia.types.ts +++ b/app/components/UI/CollectibleMedia/CollectibleMedia.types.ts @@ -1,3 +1,4 @@ +import { Nft } from '@metamask/assets-controllers'; import { ViewStyle } from 'react-native'; export interface Collectible { @@ -13,10 +14,13 @@ export interface Collectible { standard: string; imageOriginal?: string; error: string | undefined; + description?: string; + rarityRank?: number; + isCurrentlyOwned?: boolean; } export interface CollectibleMediaProps { - collectible: Collectible; + collectible: Nft; tiny?: boolean; small?: boolean; big?: boolean; @@ -25,4 +29,6 @@ export interface CollectibleMediaProps { style?: ViewStyle; onClose?: () => void; onPressColectible?: () => void; + isTokenImage?: boolean; + isFullRatio?: boolean; } diff --git a/app/components/UI/Navbar/index.js b/app/components/UI/Navbar/index.js index d56aec0c3db..f7cb21ebe62 100644 --- a/app/components/UI/Navbar/index.js +++ b/app/components/UI/Navbar/index.js @@ -36,11 +36,7 @@ import Routes from '../../../constants/navigation/Routes'; import ButtonIcon, { ButtonIconSizes, } from '../../../component-library/components/Buttons/ButtonIcon'; -import { - IconName, - IconSize, - IconColor, -} from '../../../component-library/components/Icons/Icon'; + import { default as MorphText, TextVariant, @@ -51,6 +47,11 @@ import { NetworksViewSelectorsIDs } from '../../../../e2e/selectors/Settings/Net import { SendLinkViewSelectorsIDs } from '../../../../e2e/selectors/SendLinkView.selectors'; import { SendViewSelectorsIDs } from '../../../../e2e/selectors/SendView.selectors'; import { getBlockaidTransactionMetricsParams } from '../../../util/blockaid'; +import Icon, { + IconName, + IconSize, + IconColor, +} from '../../../component-library/components/Icons/Icon'; import { AddContactViewSelectorsIDs } from '../../../../e2e/selectors/Settings/Contacts/AddContactView.selectors'; const trackEvent = (event, params = {}) => { @@ -1105,6 +1106,110 @@ export function getImportTokenNavbarOptions( }; } +export function getNftDetailsNavbarOptions( + navigation, + themeColors, + onRightPress, + contentOffset = 0, +) { + const innerStyles = StyleSheet.create({ + headerStyle: { + backgroundColor: themeColors.background.default, + shadowColor: importedColors.transparent, + elevation: 0, + }, + headerShadow: { + elevation: 2, + shadowColor: themeColors.background.primary, + shadowOpacity: contentOffset < 20 ? contentOffset / 100 : 0.2, + shadowOffset: { height: 4, width: 0 }, + shadowRadius: 8, + }, + headerIcon: { + color: themeColors.primary.default, + }, + headerBackIcon: { + color: themeColors.icon.default, + }, + }); + return { + headerLeft: () => ( + navigation.pop()} + style={styles.backButton} + {...generateTestId(Platform, ASSET_BACK_BUTTON)} + > + + + ), + headerRight: onRightPress + ? () => ( + + + + ) + : () => , + headerStyle: [ + innerStyles.headerStyle, + contentOffset && innerStyles.headerShadow, + ], + }; +} + +export function getNftFullImageNavbarOptions( + navigation, + themeColors, + contentOffset = 0, +) { + const innerStyles = StyleSheet.create({ + headerStyle: { + backgroundColor: themeColors.background.default, + shadowColor: importedColors.transparent, + elevation: 0, + }, + headerShadow: { + elevation: 2, + shadowColor: themeColors.background.primary, + shadowOpacity: contentOffset < 20 ? contentOffset / 100 : 0.2, + shadowOffset: { height: 4, width: 0 }, + shadowRadius: 8, + }, + headerIcon: { + color: themeColors.primary.default, + }, + headerBackIcon: { + color: themeColors.icon.default, + }, + }); + return { + headerRight: () => ( + navigation.pop()} + > + + + ), + headerLeft: () => , + headerStyle: [ + innerStyles.headerStyle, + contentOffset && innerStyles.headerShadow, + ], + }; +} + /** * Function that returns the navigation options containing title and network indicator * @@ -1203,7 +1308,7 @@ export function getWebviewNavbar(navigation, route, themeColors) { elevation: 0, }, headerIcon: { - color: themeColors.primary.default, + color: themeColors.default, }, }); diff --git a/app/components/Views/NftDetails/NFtDetailsFullImage.tsx b/app/components/Views/NftDetails/NFtDetailsFullImage.tsx new file mode 100644 index 00000000000..58d7e3f2878 --- /dev/null +++ b/app/components/Views/NftDetails/NFtDetailsFullImage.tsx @@ -0,0 +1,37 @@ +import React, { useCallback, useEffect } from 'react'; +import { SafeAreaView, View } from 'react-native'; +import { getNftFullImageNavbarOptions } from '../../UI/Navbar'; +import { useNavigation } from '@react-navigation/native'; +import { useParams } from '../../../util/navigation/navUtils'; +import { useStyles } from '../../../component-library/hooks'; +import styleSheet from './NftDetails.styles'; +import { NftDetailsParams } from './NftDetails.types'; +import CollectibleMedia from '../../../components/UI/CollectibleMedia'; + +const NftDetailsFullImage = () => { + const navigation = useNavigation(); + const { collectible } = useParams(); + + const { + styles, + theme: { colors }, + } = useStyles(styleSheet, {}); + + const updateNavBar = useCallback(() => { + navigation.setOptions(getNftFullImageNavbarOptions(navigation, colors)); + }, [colors, navigation]); + + useEffect(() => { + updateNavBar(); + }, [updateNavBar]); + + return ( + + + + + + ); +}; + +export default NftDetailsFullImage; diff --git a/app/components/Views/NftDetails/NftDetails.styles.ts b/app/components/Views/NftDetails/NftDetails.styles.ts new file mode 100644 index 00000000000..652aadabbae --- /dev/null +++ b/app/components/Views/NftDetails/NftDetails.styles.ts @@ -0,0 +1,153 @@ +import { StyleSheet } from 'react-native'; +import { Theme } from '../../../util/theme/models'; +import { fontStyles } from '../../../styles/common'; + +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + const { colors } = theme; + return StyleSheet.create({ + infoContainer: { + padding: 16, + }, + nameWrapper: { + flexDirection: 'row', + alignItems: 'baseline', + }, + collectibleMediaWrapper: { + paddingTop: 16, + paddingBottom: 40, + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + collectibleMediaStyle: { + alignItems: 'center', + flexGrow: 0, + flexShrink: 0, + flexBasis: 97, + width: 180, + height: 180, + }, + iconVerified: { + color: colors.primary.default, + paddingTop: 18, + marginLeft: 4, + }, + generalInfoFrame: { + display: 'flex', + gap: 16, + flexWrap: 'wrap', + flexDirection: 'row', + justifyContent: 'center', + paddingTop: 10, + paddingBottom: 6, + }, + heading: { + color: colors.text.default, + ...fontStyles.bold, + fontSize: 20, + lineHeight: 24, + paddingTop: 16, + }, + generalInfoValueStyle: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + disclaimerText: { + color: colors.text.alternative, + }, + disclaimer: { + paddingTop: 16, + }, + description: { + ...fontStyles.normal, + color: colors.text.alternative, + fontSize: 12, + fontWeight: '400', + lineHeight: 20, + }, + wrapper: { + flex: 1, + backgroundColor: colors.background.default, + padding: 16, + }, + buttonSendWrapper: { + flexDirection: 'row', + paddingTop: 16, + paddingRight: 16, + paddingLeft: 16, + }, + generalInfoTitleStyle: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + generalInfoTitleTextStyle: { + color: colors.text.alternative, + ...fontStyles.normal, + fontWeight: '500', + lineHeight: 16, + fontSize: 10, + }, + informationRowTitleStyle: { + color: colors.text.alternative, + ...fontStyles.normal, + fontWeight: '500', + lineHeight: 22, + fontSize: 14, + }, + informationRowValueStyle: { + color: colors.text.default, + ...fontStyles.normal, + fontWeight: '400', + lineHeight: 22, + fontSize: 14, + }, + informationRowValueAddressStyle: { + color: colors.primary.default, + ...fontStyles.normal, + fontWeight: '500', + lineHeight: 20, + fontSize: 12, + }, + iconExport: { + color: colors.text.alternative, + paddingLeft: 16, + }, + generalInfoValueTextStyle: { + color: colors.text.default, + ...fontStyles.normal, + fontWeight: '700', + lineHeight: 24, + fontSize: 16, + }, + generalInfoValueTextAddressStyle: { + color: colors.primary.default, + ...fontStyles.normal, + fontWeight: '500', + lineHeight: 20, + fontSize: 12, + }, + buttonSend: { + flexGrow: 1, + }, + fullImageContainer: { + position: 'relative', + flex: 1, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }, + fullImageItem: { + position: 'absolute', + left: 0, + right: 0, + padding: 16, + }, + iconPadding: { + paddingLeft: 8, + }, + }); +}; +export default styleSheet; diff --git a/app/components/Views/NftDetails/NftDetails.test.ts b/app/components/Views/NftDetails/NftDetails.test.ts new file mode 100644 index 00000000000..183ad5d5dcb --- /dev/null +++ b/app/components/Views/NftDetails/NftDetails.test.ts @@ -0,0 +1,160 @@ +import { renderScreen } from '../../../util/test/renderWithProvider'; +import QrScanner from './'; +import { backgroundState } from '../../../util/test/initial-root-state'; +import { Collectible } from '../../../components/UI/CollectibleMedia/CollectibleMedia.types'; + +const initialState = { + engine: { + backgroundState, + }, +}; + +const mockSetOptions = jest.fn(); +const mockNavigate = jest.fn(); + +jest.mock('@react-navigation/native', () => { + const actualReactNavigation = jest.requireActual('@react-navigation/native'); + return { + ...actualReactNavigation, + useNavigation: () => ({ + navigate: mockNavigate, + setOptions: mockSetOptions, + }), + useFocusEffect: jest.fn(), + }; +}); + +const TEST_COLLECTIBLE = { + address: '0x7c3Ea2b7B3beFA1115aB51c09F0C9f245C500B18', + tokenId: 23000044, + favorite: false, + isCurrentlyOwned: true, + name: 'Aura #44', + description: + 'Aura is a collection of 100 high resolution AI-generated portraits, exploring the boundaries of realism versus imagination... how far a portrait image can be deconstructed so that a sense of humanity and emotion would still remain? Aura plays with our appreciation of realistic details in photography.', + image: + 'https://img.reservoir.tools/images/v2/mainnet/m8Rol%2FE80oMmjzi7K7IQ0u6HzXVyHUh6MaSEPbYQy1GRP1ztTkhG1VSzAwMMXv97QfX8ZgwGwpR8nf9yb12HQqI%2BXfaLY%2BhMdAJk7UThICr6sEjrTDK%2BhfdaXGiZnjM%2BawmNp3vHAw1Ev5N5b97XEQ%3D%3D.png?width=512', + imageThumbnail: + 'https://img.reservoir.tools/images/v2/mainnet/m8Rol%2FE80oMmjzi7K7IQ0u6HzXVyHUh6MaSEPbYQy1GRP1ztTkhG1VSzAwMMXv97QfX8ZgwGwpR8nf9yb12HQqI%2BXfaLY%2BhMdAJk7UThICr6sEjrTDK%2BhfdaXGiZnjM%2BawmNp3vHAw1Ev5N5b97XEQ%3D%3D.png?width=250', + imageOriginal: + 'https://media-proxy.artblocks.io/0x7c3ea2b7b3befa1115ab51c09f0c9f245c500b18/23000044.png', + standard: 'ERC721', + attributes: [ + { + key: 'Title', + kind: 'string', + value: 'You Came To See Me', + tokenCount: 1, + onSaleCount: 0, + floorAskPrice: null, + topBidValue: null, + createdAt: '2024-02-22T11:03:01.829Z', + }, + ], + topBid: { + id: '0x853dc9bf7cdf966f2b59768b08dfd75816ae7adb7cf9ec12014bf8884ea4c71f', + price: { + currency: { + contract: '0xc02aaa39b223fe8d0a0e5c4f27ead9083c756cc2', + name: 'Wrapped Ether', + symbol: 'WETH', + decimals: 18, + }, + amount: { + raw: '62600000000000000', + decimal: 0.0626, + usd: 188.98127, + native: 0.0626, + }, + netAmount: { + raw: '62287000000000000', + decimal: 0.06229, + usd: 188.03636, + native: 0.06229, + }, + }, + source: { + id: '0x5b3256965e7c3cf26e11fcaf296dfc8807c01073', + domain: 'opensea.io', + name: 'OpenSea', + icon: 'https://raw.githubusercontent.com/reservoirprotocol/assets/main/sources/opensea-logo.svg', + url: 'https://opensea.io/assets/ethereum/0x7c3ea2b7b3befa1115ab51c09f0c9f245c500b18/23000044', + }, + }, + rarityRank: 1, + rarityScore: 4, + collection: { + id: '0x7c3ea2b7b3befa1115ab51c09f0c9f245c500b18:23000000:23999999', + name: 'Aura by Roope Rainisto', + slug: 'aura-by-roope-rainisto', + symbol: 'MOMENT-FLEX', + contractDeployedAt: '2023-05-05T08:24:59.000Z', + imageUrl: + 'https://img.reservoir.tools/images/v2/mainnet/m8Rol%2FE80oMmjzi7K7IQ0u6HzXVyHUh6MaSEPbYQy1GRP1ztTkhG1VSzAwMMXv97QfX8ZgwGwpR8nf9yb12HQqI%2BXfaLY%2BhMdAJk7UThICq3VpXqP8R9a7UJJWaudViqrlaZXcB%2B9WiV9avzgRprPEfJ1chTNYa3%2B36V9Areb6V%2BqwbskYYLZjPXCrV525seJSJnfQqrVwl64p9PV9sCkw%3D%3D?width=250', + isSpam: false, + isNsfw: false, + metadataDisabled: false, + openseaVerificationStatus: 'verified', + tokenCount: '100', + floorAsk: { + id: '0x8d9ac3875c6939f9085346e9ff869af643a4d3085da1c6d4d1c47ce49360ca3f', + price: { + currency: { + contract: '0x0000000000000000000000000000000000000000', + name: 'Ether', + symbol: 'ETH', + decimals: 18, + }, + amount: { + raw: '400000000000000000', + decimal: 0.4, + usd: 1206.43334, + native: 0.4, + }, + }, + maker: '0x02e1821ad27d690cf2ee2af83aaf957dacfd5966', + validFrom: 1718582323, + validUntil: 1721174204, + source: { + id: '0x5b3256965e7c3cf26e11fcaf296dfc8807c01073', + domain: 'opensea.io', + name: 'OpenSea', + icon: 'https://raw.githubusercontent.com/reservoirprotocol/assets/main/sources/opensea-logo.svg', + url: 'https://opensea.io/assets/ethereum/0x7c3ea2b7b3befa1115ab51c09f0c9f245c500b18/23000002', + }, + }, + royaltiesBps: 1000, + royalties: [ + { bps: 250, recipient: '0x43a7d26a271f5801b8092d94dfd5b36ea5d01f5f' }, + { bps: 750, recipient: '0x5f19463dda395e08b78b99a99c52413ed941edf7' }, + ], + }, + logo: 'https://img.reservoir.tools/images/v2/mainnet/m8Rol%2FE80oMmjzi7K7IQ0u6HzXVyHUh6MaSEPbYQy1GRP1ztTkhG1VSzAwMMXv97QfX8ZgwGwpR8nf9yb12HQqI%2BXfaLY%2BhMdAJk7UThICq3VpXqP8R9a7UJJWaudViqrlaZXcB%2B9WiV9avzgRprPEfJ1chTNYa3%2B36V9Areb6V%2BqwbskYYLZjPXCrV525seJSJnfQqrVwl64p9PV9sCkw%3D%3D?width=250', +}; + +let mockUseParamsValues: { + collectible: Collectible; +} = { + collectible: TEST_COLLECTIBLE, +}; + +jest.mock('../../../util/navigation/navUtils', () => ({ + ...jest.requireActual('../../../util/navigation/navUtils'), + useParams: jest.fn(() => mockUseParamsValues), +})); + +describe('NftDetails', () => { + beforeEach(() => { + mockUseParamsValues = { + collectible: TEST_COLLECTIBLE, + }; + }); + it('should render correctly', () => { + const { toJSON } = renderScreen( + QrScanner, + { name: 'NftDetails' }, + { state: initialState }, + ); + expect(toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/components/Views/NftDetails/NftDetails.tsx b/app/components/Views/NftDetails/NftDetails.tsx new file mode 100644 index 00000000000..452a61c5ab0 --- /dev/null +++ b/app/components/Views/NftDetails/NftDetails.tsx @@ -0,0 +1,656 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + NativeSyntheticEvent, + SafeAreaView, + TextLayoutEventData, + View, +} from 'react-native'; +import { getNftDetailsNavbarOptions } from '../../UI/Navbar'; +import Text from '../../../component-library/components/Texts/Text/Text'; +import { useNavigation } from '@react-navigation/native'; +import { useParams } from '../../../util/navigation/navUtils'; +import { useStyles } from '../../../component-library/hooks'; +import styleSheet from './NftDetails.styles'; +import Routes from '../../../constants/navigation/Routes'; +import { NftDetailsParams } from './NftDetails.types'; +import { ScrollView, TouchableOpacity } from 'react-native-gesture-handler'; +import StyledButton from '../../../components/UI/StyledButton'; +import NftDetailsBox from './NftDetailsBox'; +import NftDetailsInformationRow from './NftDetailsInformationRow'; +import { renderShortAddress } from '../../../util/address'; +import Icon, { + IconName, + IconSize, +} from '../../../component-library/components/Icons/Icon'; +import ClipboardManager from '../../../core/ClipboardManager'; +import { useDispatch, useSelector } from 'react-redux'; +import { showAlert } from '../../../actions/alert'; +import { strings } from '../../../../locales/i18n'; +import { + selectChainId, + selectTicker, +} from '../../../selectors/networkController'; +import etherscanLink from '@metamask/etherscan-link'; +import { + selectConversionRate, + selectCurrentCurrency, +} from '../../../selectors/currencyRateController'; +import { formatCurrency } from '../../../util/confirm-tx'; +import { newAssetTransaction } from '../../../actions/transaction'; +import CollectibleMedia from '../../../components/UI/CollectibleMedia'; +import ContentDisplay from '../../../components/UI/AssetOverview/AboutAsset/ContentDisplay'; +import BigNumber from 'bignumber.js'; +import { getDecimalChainId } from '../../../util/networks'; +import { MetaMetricsEvents } from '../../../core/Analytics'; +import { useMetrics } from '../../../components/hooks/useMetrics'; +import { renderShortText } from '../../../util/general'; +import { prefixUrlWithProtocol } from '../../../util/browser'; +import { formatTimestampToYYYYMMDD } from '../../../util/date'; +import MAX_TOKEN_ID_LENGTH from './nftDetails.utils'; + +const NftDetails = () => { + const navigation = useNavigation(); + const { collectible } = useParams(); + const chainId = useSelector(selectChainId); + const dispatch = useDispatch(); + const currentCurrency = useSelector(selectCurrentCurrency); + const ticker = useSelector(selectTicker); + const { trackEvent } = useMetrics(); + const selectedNativeConversionRate = useSelector(selectConversionRate); + const hasLastSalePrice = Boolean( + collectible.lastSale?.price?.amount?.usd && + collectible.lastSale?.price?.amount?.native, + ); + const hasFloorAskPrice = Boolean( + collectible.collection?.floorAsk?.price?.amount?.usd && + collectible.collection?.floorAsk?.price?.amount?.native, + ); + const hasOnlyContractAddress = + !hasLastSalePrice && !hasFloorAskPrice && !collectible?.rarityRank; + + const { + styles, + theme: { colors }, + } = useStyles(styleSheet, {}); + + const updateNavBar = useCallback(() => { + navigation.setOptions( + getNftDetailsNavbarOptions( + navigation, + colors, + () => + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: 'NftOptions', + params: { + collectible, + }, + }), + undefined, + ), + ); + }, [collectible, colors, navigation]); + + useEffect(() => { + updateNavBar(); + }, [updateNavBar]); + + useEffect(() => { + trackEvent(MetaMetricsEvents.COLLECTIBLE_DETAILS_OPENED, { + chain_id: getDecimalChainId(chainId), + }); + // The linter wants `trackEvent` to be added as a dependency, + // But the event fires twice if I do that. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [chainId]); + + const viewHighestFloorPriceSource = () => { + const url = + hasFloorAskPrice && + Boolean(collectible?.collection?.floorAsk?.source?.url) + ? collectible?.collection?.floorAsk?.source?.url + : undefined; + + navigation.navigate('Webview', { + screen: 'SimpleWebview', + params: { url }, + }); + }; + + const viewLastSalePriceSource = () => { + const source = collectible?.lastSale?.orderSource; + if (source) { + const url = prefixUrlWithProtocol(source); + navigation.navigate('Webview', { + screen: 'SimpleWebview', + params: { url }, + }); + } + }; + + const copyAddressToClipboard = async (address?: string) => { + if (!address) { + return; + } + await ClipboardManager.setString(address); + dispatch( + showAlert({ + isVisible: true, + autodismiss: 1500, + content: 'clipboard-alert', + data: { msg: strings('detected_tokens.address_copied_to_clipboard') }, + }), + ); + }; + + const blockExplorerTokenLink = () => + etherscanLink.createTokenTrackerLink(collectible?.address, chainId); + + const blockExplorerAccountLink = () => { + if (collectible.collection?.creator) { + return etherscanLink.createAccountLink( + collectible?.collection?.creator, + chainId, + ); + } + }; + + const getDateCreatedTimestamp = (dateString: string) => { + const date = new Date(dateString); + return Math.floor(date.getTime() / 1000); + }; + + const onSend = useCallback(async () => { + dispatch( + newAssetTransaction({ contractName: collectible.name, ...collectible }), + ); + navigation.navigate('SendFlowView'); + }, [collectible, navigation, dispatch]); + + const isTradable = useCallback( + () => + collectible.standard === 'ERC721' && + collectible.isCurrentlyOwned === true, + [collectible], + ); + + const getCurrentHighestBidValue = () => { + if ( + collectible?.topBid?.price?.amount?.native && + collectible.collection?.topBid?.price?.amount?.native + ) { + // return the max between collection top Bid and token topBid + const topBidValue = Math.max( + collectible?.topBid?.price?.amount?.native, + collectible.collection?.topBid?.price?.amount?.native, + ); + return `${topBidValue}${ticker}`; + } + // return the one that is available + const topBidValue = + collectible.topBid?.price?.amount?.native || + collectible.collection?.topBid?.price?.amount?.native; + if (!topBidValue) { + return null; + } + return `${topBidValue}${ticker}`; + }; + + const getTopBidSourceDomain = () => + collectible?.topBid?.source?.url || + (collectible?.collection?.topBid?.sourceDomain + ? `https://${collectible?.collection.topBid?.sourceDomain}` + : undefined); + + const [numberOfLines, setNumberOfLines] = useState(0); + + const handleTextLayout = ( + event: NativeSyntheticEvent, + ) => { + setNumberOfLines(event.nativeEvent.lines.length); + }; + + const renderDescription = () => { + if (!collectible.description) { + return null; + } else if (numberOfLines <= 2) { + // Render Text component if lines are less than or equal to 2 + return ( + + {collectible.description} + + ); + } + return ( + + ); + }; + + const applyConversionRate = (value: BigNumber, rate?: number) => { + if (typeof rate === 'undefined') { + return value; + } + + const conversionRate = new BigNumber(rate, 10); + return value.times(conversionRate); + }; + + const getValueInFormattedCurrency = ( + nativeValue: number, + usdValue: number, + ) => { + const numericVal = new BigNumber(nativeValue, 10); + // if current currency is usd or if fetching conversion rate failed then always return USD value + if (!selectedNativeConversionRate || currentCurrency === 'usd') { + const usdValueFormatted = formatCurrency(usdValue.toString(), 'usd'); + return usdValueFormatted; + } + const value = applyConversionRate( + numericVal, + selectedNativeConversionRate, + ).toNumber(); + return formatCurrency(new BigNumber(value, 10).toString(), currentCurrency); + }; + + const getFormattedDate = (dateString: number) => { + const date = new Date(dateString * 1000).getTime(); + return formatTimestampToYYYYMMDD(date); + }; + + const onMediaPress = useCallback(() => { + // Navigate to new NFT details page + navigation.navigate('NftDetailsFullImage', { + collectible, + }); + }, [collectible, navigation]); + + const goToTokenIdSheet = (tokenId: string) => { + navigation.navigate(Routes.MODAL.ROOT_MODAL_FLOW, { + screen: Routes.SHEET.SHOW_TOKEN_ID, + params: { + tokenId, + }, + }); + }; + + const shouldShowTokenIdBottomSheet = (tokenId: string) => + tokenId.length > MAX_TOKEN_ID_LENGTH; + + const hasPriceSection = + getCurrentHighestBidValue() || collectible?.lastSale?.timestamp; + const hasCollectionSection = + collectible?.collection?.name || + collectible?.collection?.tokenCount || + collectible?.collection?.creator; + const hasAttributesSection = + collectible?.attributes && collectible?.attributes?.length !== 0; + + return ( + + + + + + + + + {collectible.name} + {collectible.collection?.openseaVerificationStatus === + 'verified' ? ( + + ) : null} + + {renderDescription()} + + + {hasLastSalePrice || hasFloorAskPrice ? ( + <> + viewLastSalePriceSource()} + > + + + ) : null + } + /> + viewHighestFloorPriceSource()} + > + + + ) : null + } + /> + + ) : null} + + {collectible.rarityRank ? ( + + ) : null} + {hasLastSalePrice || hasFloorAskPrice || collectible?.rarityRank ? ( + copyAddressToClipboard(collectible.address)} + > + + + } + onValuePress={() => { + navigation.navigate('Webview', { + screen: 'SimpleWebview', + params: { + url: blockExplorerTokenLink(), + }, + }); + }} + /> + ) : null} + + {hasOnlyContractAddress ? ( + copyAddressToClipboard(collectible.address)} + style={styles.iconPadding} + > + + + } + onValuePress={() => { + if (collectible.collection?.creator) { + navigation.navigate('Webview', { + screen: 'SimpleWebview', + params: { + url: blockExplorerTokenLink(), + }, + }); + } + }} + /> + ) : null} + + goToTokenIdSheet(collectible.tokenId)} + style={styles.iconPadding} + > + + + ) : null + } + /> + + + + {hasCollectionSection ? ( + + {strings('collectible.collection')} + + ) : null} + + + + + copyAddressToClipboard(collectible?.collection?.creator) + } + style={styles.iconPadding} + > + + + } + onValuePress={() => { + if (collectible.collection?.creator) { + navigation.navigate('Webview', { + screen: 'SimpleWebview', + params: { + url: blockExplorerAccountLink(), + }, + }); + } + }} + /> + + {hasPriceSection ? Price : null} + + + { + navigation.navigate('Webview', { + screen: 'SimpleWebview', + params: { url: getTopBidSourceDomain() }, + }); + }} + > + + + ) : null + } + /> + + {hasAttributesSection ? ( + + {strings('nft_details.attributes')} + + ) : null} + + {collectible?.attributes?.length !== 0 ? ( + + {collectible.attributes?.map((elm, idx) => { + const { key, value } = elm; + return ( + + ); + })} + + ) : null} + + + {strings('nft_details.disclaimer')} + + + + + + {isTradable() ? ( + + + {strings('transaction.send')} + + + ) : null} + + ); +}; + +export default NftDetails; diff --git a/app/components/Views/NftDetails/NftDetails.types.ts b/app/components/Views/NftDetails/NftDetails.types.ts new file mode 100644 index 00000000000..82321fc7b6f --- /dev/null +++ b/app/components/Views/NftDetails/NftDetails.types.ts @@ -0,0 +1,27 @@ +import { Nft } from '@metamask/assets-controllers'; +import { StyleProp, ViewProps, ViewStyle } from 'react-native'; + +export interface NftDetailsParams { + collectible: Nft; +} + +export interface NftDetailsInformationRowProps extends ViewProps { + title: string; + value?: string | null; + titleStyle?: StyleProp; + valueStyle?: StyleProp; + icon?: React.ReactNode; + onValuePress?: () => void; +} + +export interface NftDetailsBoxProps extends ViewProps { + title?: string; + value: string | null; + titleStyle?: StyleProp; + valueStyle?: StyleProp; + icon?: React.ReactNode; + onValuePress?: () => void; + + titleTextStyle?: StyleProp; + valueTextStyle?: StyleProp; +} diff --git a/app/components/Views/NftDetails/NftDetailsBox.tsx b/app/components/Views/NftDetails/NftDetailsBox.tsx new file mode 100644 index 00000000000..2aa3f99be9c --- /dev/null +++ b/app/components/Views/NftDetails/NftDetailsBox.tsx @@ -0,0 +1,70 @@ +import React from 'react'; +import { StyleSheet, View, TouchableOpacity } from 'react-native'; +import { useTheme } from '../../../util/theme'; +import Text from '../../../component-library/components/Texts/Text'; +import { ThemeColors } from '@metamask/design-tokens/dist/types/js/themes/types'; +import { NftDetailsBoxProps } from './NftDetails.types'; + +const createStyles = (colors: ThemeColors) => + StyleSheet.create({ + inputWrapper: { + paddingTop: 12, + paddingBottom: 12, + paddingLeft: 16, + paddingRight: 16, + borderWidth: 1, + borderRadius: 8, + borderColor: colors.border.default, + flexGrow: 1, + width: '33%', + }, + valueWithIcon: { + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + }, + }); + +const NftDetailsBox = (props: NftDetailsBoxProps) => { + const { + title, + titleStyle, + titleTextStyle, + value, + valueStyle, + valueTextStyle, + icon, + onValuePress, + } = props; + const { colors } = useTheme(); + const styles = createStyles(colors); + + if (!value) { + return null; + } + + return ( + + + {title} + + {icon ? ( + + {onValuePress ? ( + + {value} + + ) : ( + {value} + )} + {icon} + + ) : ( + + {value} + + )} + + ); +}; +export default NftDetailsBox; diff --git a/app/components/Views/NftDetails/NftDetailsInformationRow.tsx b/app/components/Views/NftDetails/NftDetailsInformationRow.tsx new file mode 100644 index 00000000000..3986ea95e76 --- /dev/null +++ b/app/components/Views/NftDetails/NftDetailsInformationRow.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { StyleSheet, View } from 'react-native'; +import Text from '../../../component-library/components/Texts/Text'; +import { NftDetailsInformationRowProps } from './NftDetails.types'; +import { TouchableOpacity } from 'react-native-gesture-handler'; + +const createStyles = () => + StyleSheet.create({ + inputWrapper: { + display: 'flex', + justifyContent: 'space-between', + marginTop: 4, + flexDirection: 'row', + }, + valueWithIcon: { + alignItems: 'center', + justifyContent: 'center', + flexDirection: 'row', + }, + }); + +const NftDetailsInformationRow = ({ + title, + titleStyle, + value, + valueStyle, + icon, + onValuePress, +}: NftDetailsInformationRowProps) => { + const styles = createStyles(); + + if (!value) { + return null; + } + + return ( + + {title} + {icon ? ( + + {onValuePress ? ( + + {value} + + ) : ( + {value} + )} + {icon} + + ) : ( + {value} + )} + + ); +}; +export default NftDetailsInformationRow; diff --git a/app/components/Views/NftDetails/__snapshots__/NftDetails.test.ts.snap b/app/components/Views/NftDetails/__snapshots__/NftDetails.test.ts.snap new file mode 100644 index 00000000000..ffb477093b7 --- /dev/null +++ b/app/components/Views/NftDetails/__snapshots__/NftDetails.test.ts.snap @@ -0,0 +1,1436 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NftDetails should render correctly 1`] = ` + + + + + + + + + + + + + NftDetails + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Aura #44 + + + + + Aura is a collection of 100 high resolution AI-generated portraits, exploring the boundaries of realism versus imagination... how far a portrait image can be deconstructed so that a sense of humanity and emotion would still remain? Aura plays with our appreciation of realistic details in photography. + + + + + + + Bought for + + + + + data unavailable + + + + + + + Highest floor price + + + + + $1,206.43 + + + + + + + + + + + + Rank + + + + + #1 + + + + + + + Contract address + + + + + + 0x7c3E...0B18 + + + + + + + + + + + + + Token ID + + + 23000044 + + + + + Token symbol + + + MOMENT-FLEX + + + + + Token standard + + + ERC721 + + + + + Date created + + + 2023-05-05 + + + + Collection + + + + Tokens in collection + + + 100 + + + + Price + + + + Highest current bid + + + + 0.0626ETH + + + + + + + + + + Attributes + + + + + + Title + + + + + You Came To See Me + + + + + + + Disclaimer: MetaMask pulls the media file from the source URL. This URL is sometimes changed by the marketplace the NFT was minted on. + + + + + + + + + Send + + + + + + + + + + + + + + +`; diff --git a/app/components/Views/NftDetails/index.ts b/app/components/Views/NftDetails/index.ts new file mode 100644 index 00000000000..4704dffbab4 --- /dev/null +++ b/app/components/Views/NftDetails/index.ts @@ -0,0 +1 @@ +export { default } from './NftDetails'; diff --git a/app/components/Views/NftDetails/nftDetails.utils.ts b/app/components/Views/NftDetails/nftDetails.utils.ts new file mode 100644 index 00000000000..14ffdaf2eae --- /dev/null +++ b/app/components/Views/NftDetails/nftDetails.utils.ts @@ -0,0 +1,3 @@ +const MAX_TOKEN_ID_LENGTH = 15; + +export default MAX_TOKEN_ID_LENGTH; diff --git a/app/components/Views/NftOptions/NftOptions.styles.ts b/app/components/Views/NftOptions/NftOptions.styles.ts new file mode 100644 index 00000000000..bfae995349a --- /dev/null +++ b/app/components/Views/NftOptions/NftOptions.styles.ts @@ -0,0 +1,39 @@ +import type { Theme } from '@metamask/design-tokens'; +import { StyleSheet } from 'react-native'; + +const styleSheet = (params: { theme: Theme }) => { + const { theme } = params; + const { colors } = theme; + return StyleSheet.create({ + screen: { justifyContent: 'flex-end' }, + sheet: { + backgroundColor: colors.background.default, + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + }, + notch: { + width: 48, + height: 5, + borderRadius: 4, + backgroundColor: colors.border.default, + marginTop: 12, + alignSelf: 'center', + marginBottom: 16, + }, + optionButton: { + alignItems: 'center', + flexDirection: 'row', + padding: 16, + }, + iconOs: { + marginRight: 16, + color: colors.text.default, + }, + iconTrash: { + marginRight: 16, + color: colors.error.default, + }, + }); +}; + +export default styleSheet; diff --git a/app/components/Views/NftOptions/NftOptions.tsx b/app/components/Views/NftOptions/NftOptions.tsx new file mode 100644 index 00000000000..15852b81f10 --- /dev/null +++ b/app/components/Views/NftOptions/NftOptions.tsx @@ -0,0 +1,140 @@ +import { useNavigation } from '@react-navigation/native'; +import React, { useRef } from 'react'; +import { Alert, TouchableOpacity, View } from 'react-native'; +import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { useSelector } from 'react-redux'; +import { useStyles } from '../../../component-library/hooks'; +import { strings } from '../../../../locales/i18n'; +import Icon, { + IconName, +} from '../../../component-library/components/Icons/Icon'; +import { selectChainId } from '../../../selectors/networkController'; +import ReusableModal, { ReusableModalRef } from '../../UI/ReusableModal'; +import styleSheet from './NftOptions.styles'; +import Text, { + TextColor, + TextVariant, +} from '../../../component-library/components/Texts/Text'; +import { CHAIN_IDS } from '@metamask/transaction-controller'; +import Engine from '../../../core/Engine'; +import { removeFavoriteCollectible } from '../../../actions/collectibles'; +import { selectSelectedInternalAccountChecksummedAddress } from '../../../selectors/accountsController'; +import { Collectible } from '../../../components/UI/CollectibleMedia/CollectibleMedia.types'; +import Routes from '../../../constants/navigation/Routes'; +import { + useMetrics, + MetaMetricsEvents, +} from '../../../components/hooks/useMetrics'; +import { getDecimalChainId } from '../../../util/networks'; + +interface Props { + route: { + params: { + collectible: Collectible; + }; + }; +} + +const NftOptions = (props: Props) => { + const { collectible } = props.route.params; + const { styles } = useStyles(styleSheet, {}); + const safeAreaInsets = useSafeAreaInsets(); + const navigation = useNavigation(); + const modalRef = useRef(null); + const chainId = useSelector(selectChainId); + const { trackEvent } = useMetrics(); + const selectedAddress = useSelector( + selectSelectedInternalAccountChecksummedAddress, + ); + + const goToWalletPage = () => { + navigation.navigate(Routes.WALLET.HOME, { + screen: Routes.WALLET.TAB_STACK_FLOW, + params: { + screen: Routes.WALLET_VIEW, + }, + }); + }; + + const goToBrowserUrl = (url: string) => { + modalRef.current?.dismissModal(() => { + navigation.navigate('Webview', { + screen: 'SimpleWebview', + params: { + url, + }, + }); + }); + }; + + const getOpenSeaLink = () => { + switch (chainId) { + case CHAIN_IDS.MAINNET: + return `https://opensea.io/assets/ethereum/${collectible.address}/${collectible.tokenId}`; + case CHAIN_IDS.POLYGON: + return `https://opensea.io/assets/matic/${collectible.address}/${collectible.tokenId}`; + case CHAIN_IDS.GOERLI: + return `https://testnets.opensea.io/assets/goerli/${collectible.address}/${collectible.tokenId}`; + case CHAIN_IDS.SEPOLIA: + return `https://testnets.opensea.io/assets/sepolia/${collectible.address}/${collectible.tokenId}`; + default: + return null; + } + }; + + const gotToOpensea = () => { + const url = getOpenSeaLink(); + if (url) { + goToBrowserUrl(url); + } + }; + + const removeNft = () => { + const { NftController } = Engine.context; + removeFavoriteCollectible(selectedAddress, chainId, collectible); + NftController.removeAndIgnoreNft( + collectible.address, + collectible.tokenId.toString(), + ); + trackEvent(MetaMetricsEvents.COLLECTIBLE_REMOVED, { + chain_id: getDecimalChainId(chainId), + }); + Alert.alert( + strings('wallet.collectible_removed_title'), + strings('wallet.collectible_removed_desc'), + ); + // Redirect to home after removing NFT + goToWalletPage(); + }; + + return ( + + + + + {getOpenSeaLink() !== null ? ( + + + + {strings('nft_details.options.view_on_os')} + + + ) : null} + + + + + + {strings('nft_details.options.remove_nft')} + + + + + + ); +}; + +export default NftOptions; diff --git a/app/components/Views/NftOptions/index.ts b/app/components/Views/NftOptions/index.ts new file mode 100644 index 00000000000..8d1f25a8209 --- /dev/null +++ b/app/components/Views/NftOptions/index.ts @@ -0,0 +1 @@ +export { default } from './NftOptions'; diff --git a/app/components/Views/ShowTokenIdSheet/ShowTokenIdSheet.styles.ts b/app/components/Views/ShowTokenIdSheet/ShowTokenIdSheet.styles.ts new file mode 100644 index 00000000000..d2b4e7dd5cb --- /dev/null +++ b/app/components/Views/ShowTokenIdSheet/ShowTokenIdSheet.styles.ts @@ -0,0 +1,22 @@ +import { StyleSheet } from 'react-native'; + +const createStyles = () => + StyleSheet.create({ + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + marginTop: 16, + + marginBottom: 8, + + height: 32, + }, + textContent: { + paddingHorizontal: 16, + + alignItems: 'center', + }, + }); + +export default createStyles; diff --git a/app/components/Views/ShowTokenIdSheet/ShowTokenIdSheet.test.tsx b/app/components/Views/ShowTokenIdSheet/ShowTokenIdSheet.test.tsx new file mode 100644 index 00000000000..041e252dd6e --- /dev/null +++ b/app/components/Views/ShowTokenIdSheet/ShowTokenIdSheet.test.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { createStackNavigator } from '@react-navigation/stack'; +import renderWithProvider from '../../../util/test/renderWithProvider'; +import { backgroundState } from '../../../util/test/initial-root-state'; + +import Routes from '../../../constants/navigation/Routes'; +import ShowTokenIdSheet from './ShowTokenIdSheet'; + +const initialState = { + engine: { + backgroundState, + }, +}; + +const Stack = createStackNavigator(); + +describe('ShowTokenId', () => { + it('should render correctly', () => { + const { toJSON } = renderWithProvider( + + + {() => } + + , + { + state: initialState, + }, + ); + + expect(toJSON()).toMatchSnapshot(); + }); +}); diff --git a/app/components/Views/ShowTokenIdSheet/ShowTokenIdSheet.tsx b/app/components/Views/ShowTokenIdSheet/ShowTokenIdSheet.tsx new file mode 100644 index 00000000000..a8dbf3bce88 --- /dev/null +++ b/app/components/Views/ShowTokenIdSheet/ShowTokenIdSheet.tsx @@ -0,0 +1,36 @@ +// Third party dependencies +import React, { useRef } from 'react'; + +// External dependencies +import BottomSheet, { + BottomSheetRef, +} from '../../../component-library/components/BottomSheets/BottomSheet'; +import SheetHeader from '../../../component-library/components/Sheet/SheetHeader/SheetHeader'; +import Text from '../../../component-library/components/Texts/Text/Text'; +import { strings } from '../../../../locales/i18n'; + +// Internal dependencies +import createStyles from './ShowTokenIdSheet.styles'; +import { useParams } from '../../../util/navigation/navUtils'; +import { ShowTokenIdSheetParams } from './ShowTokenIdSheet.types'; +import { View } from 'react-native'; + +const ShowTokenIdSheet = () => { + const styles = createStyles(); + const sheetRef = useRef(null); + const { tokenId } = useParams(); + + return ( + + + + {tokenId} + + + ); +}; + +export default ShowTokenIdSheet; diff --git a/app/components/Views/ShowTokenIdSheet/ShowTokenIdSheet.types.ts b/app/components/Views/ShowTokenIdSheet/ShowTokenIdSheet.types.ts new file mode 100644 index 00000000000..e817ad4226f --- /dev/null +++ b/app/components/Views/ShowTokenIdSheet/ShowTokenIdSheet.types.ts @@ -0,0 +1,3 @@ +export interface ShowTokenIdSheetParams { + tokenId: string; +} diff --git a/app/components/Views/ShowTokenIdSheet/__snapshots__/ShowTokenIdSheet.test.tsx.snap b/app/components/Views/ShowTokenIdSheet/__snapshots__/ShowTokenIdSheet.test.tsx.snap new file mode 100644 index 00000000000..7ddba006ba8 --- /dev/null +++ b/app/components/Views/ShowTokenIdSheet/__snapshots__/ShowTokenIdSheet.test.tsx.snap @@ -0,0 +1,497 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ShowTokenId should render correctly 1`] = ` + + + + + + + + + + + + + ShowTokenId + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Token ID + + + + + + + + + + + + + + + + + + + +`; diff --git a/app/components/Views/ShowTokenIdSheet/index.ts b/app/components/Views/ShowTokenIdSheet/index.ts new file mode 100644 index 00000000000..d4cd06a783d --- /dev/null +++ b/app/components/Views/ShowTokenIdSheet/index.ts @@ -0,0 +1 @@ +export { default } from './ShowTokenIdSheet'; diff --git a/app/constants/navigation/Routes.ts b/app/constants/navigation/Routes.ts index b9067e19103..628cf28496e 100644 --- a/app/constants/navigation/Routes.ts +++ b/app/constants/navigation/Routes.ts @@ -101,6 +101,7 @@ const Routes = { FIAT_ON_TESTNETS_FRICTION: 'SettingsAdvancedFiatOnTestnetsFriction', SHOW_IPFS: 'ShowIpfs', SHOW_NFT_DISPLAY_MEDIA: 'ShowNftDisplayMedia', + SHOW_TOKEN_ID: 'ShowTokenId', ORIGIN_SPAM_MODAL: 'OriginSpamModal', }, BROWSER: { diff --git a/app/util/date/index.js b/app/util/date/index.js index 7bbddc56c02..0035c6a2a9b 100644 --- a/app/util/date/index.js +++ b/app/util/date/index.js @@ -48,3 +48,16 @@ export function msBetweenDates(date) { export function msToHours(milliseconds) { return milliseconds / (60 * 60 * 1000); } + +/** + * this function will convert a timestamp to the 'yyyy-MM-dd' format + * @param {*} timestamp timestamp you wish to convert in milliseconds + * @returns formatted date yyyy-MM-dd + */ +export const formatTimestampToYYYYMMDD = (timestamp) => { + const date = new Date(timestamp); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); // Months are 0-indexed + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; diff --git a/app/util/date/index.test.ts b/app/util/date/index.test.ts index 8cca7a9fc08..80e8cf1f27b 100644 --- a/app/util/date/index.test.ts +++ b/app/util/date/index.test.ts @@ -1,4 +1,9 @@ -import { msBetweenDates, msToHours, toDateFormat } from '.'; +import { + msBetweenDates, + msToHours, + toDateFormat, + formatTimestampToYYYYMMDD, +} from '.'; const TZ = 'America/Toronto'; @@ -61,3 +66,11 @@ describe('Date util :: msToHours', () => { expect(msToHours(1000 * 60 * 60)).toEqual(1); }); }); + +describe('Date util :: formatTimestampToYYYYMMDD', () => { + it('should format timestamp', () => { + const testTimestamp = 1722432060; + const date = new Date(testTimestamp * 1000).getTime(); + expect(formatTimestampToYYYYMMDD(date)).toEqual('2024-07-31'); + }); +}); diff --git a/e2e/pages/wallet/WalletView.js b/e2e/pages/wallet/WalletView.js index 0afd755a695..97534960ef5 100644 --- a/e2e/pages/wallet/WalletView.js +++ b/e2e/pages/wallet/WalletView.js @@ -118,6 +118,16 @@ class WalletView { await Gestures.waitAndTap(this.importNFTButton); } + get testCollectible() { + return device.getPlatform() === 'android' + ? Matchers.getElementByID(WalletViewSelectorsIDs.COLLECTIBLE_FALLBACK, 1) + : Matchers.getElementByID(WalletViewSelectorsIDs.TEST_COLLECTIBLE); + } + + async tapOnNftName() { + await Gestures.waitAndTap(this.testCollectible); + } + async tapImportTokensButton() { await Gestures.waitAndTap(this.importTokensButton); } diff --git a/e2e/selectors/wallet/WalletView.selectors.js b/e2e/selectors/wallet/WalletView.selectors.js index add1b222bdc..a8951763ee5 100644 --- a/e2e/selectors/wallet/WalletView.selectors.js +++ b/e2e/selectors/wallet/WalletView.selectors.js @@ -23,6 +23,8 @@ export const WalletViewSelectorsIDs = { ACCOUNT_ACTIONS: 'main-wallet-account-actions', ACCOUNT_COPY_BUTTON: 'wallet-account-copy-button', ACCOUNT_ADDRESS: 'wallet-account-address', + TEST_COLLECTIBLE: 'collectible-Test Dapp NFTs #1-1', + COLLECTIBLE_FALLBACK: 'fallback-nft-with-token-id', }; export const WalletViewSelectorsText = { diff --git a/e2e/specs/assets/nft-details.spec.js b/e2e/specs/assets/nft-details.spec.js new file mode 100644 index 00000000000..b61fa8d7868 --- /dev/null +++ b/e2e/specs/assets/nft-details.spec.js @@ -0,0 +1,69 @@ +'use strict'; + +import { SmokeAssets } from '../../tags'; +import TestHelpers from '../../helpers'; +import { loginToApp } from '../../viewHelper'; +import FixtureBuilder from '../../fixtures/fixture-builder'; +import { + withFixtures, + defaultGanacheOptions, +} from '../../fixtures/fixture-helper'; +import { SMART_CONTRACTS } from '../../../app/util/test/smart-contracts'; +import WalletView from '../../pages/wallet/WalletView'; +import AddCustomTokenView from '../../pages/AddCustomTokenView'; +import Assertions from '../../utils/Assertions'; +import enContent from '../../../locales/languages/en.json'; + +describe(SmokeAssets('NFT Details page'), () => { + const NFT_CONTRACT = SMART_CONTRACTS.NFTS; + const TEST_DAPP_CONTRACT = 'TestDappNFTs'; + beforeAll(async () => { + jest.setTimeout(170000); + await TestHelpers.reverseServerPort(); + }); + + it('show nft details', async () => { + await withFixtures( + { + dapp: true, + fixture: new FixtureBuilder() + .withGanacheNetwork() + .withPermissionControllerConnectedToTestDapp() + .build(), + restartDevice: true, + ganacheOptions: defaultGanacheOptions, + smartContract: NFT_CONTRACT, + }, + async ({ contractRegistry }) => { + const nftsAddress = await contractRegistry.getContractAddress( + NFT_CONTRACT, + ); + + await loginToApp(); + + await WalletView.tapNftTab(); + await WalletView.scrollDownOnNFTsTab(); + // Tap on the add collectibles button + await WalletView.tapImportNFTButton(); + await AddCustomTokenView.isVisible(); + await AddCustomTokenView.typeInNFTAddress(nftsAddress); + await AddCustomTokenView.typeInNFTIdentifier('1'); + + await Assertions.checkIfVisible(WalletView.container); + // Wait for asset to load + await Assertions.checkIfVisible( + WalletView.nftInWallet(TEST_DAPP_CONTRACT), + ); + await WalletView.tapOnNftName(); + + await Assertions.checkIfTextIsDisplayed(enContent.nft_details.token_id); + await Assertions.checkIfTextIsDisplayed( + enContent.nft_details.contract_address, + ); + await Assertions.checkIfTextIsDisplayed( + enContent.nft_details.token_standard, + ); + }, + ); + }); +}); diff --git a/e2e/utils/Matchers.js b/e2e/utils/Matchers.js index e9522b6d2a1..9890197dc65 100644 --- a/e2e/utils/Matchers.js +++ b/e2e/utils/Matchers.js @@ -10,7 +10,10 @@ class Matchers { * @param {string} elementId - Match elements with the specified testID * @return {Promise} - Resolves to the located element */ - static async getElementByID(elementId) { + static async getElementByID(elementId, index) { + if (index) { + return element(by.id(elementId)).atIndex(index); + } return element(by.id(elementId)); } diff --git a/locales/languages/en.json b/locales/languages/en.json index 69662ff5f24..ae6cd7b846b 100644 --- a/locales/languages/en.json +++ b/locales/languages/en.json @@ -375,6 +375,29 @@ "accept": "I agree", "cancel": "No thanks" }, + "nft_details": { + "bought_for": "Bought for", + "highest_floor_price": "Highest floor price", + "data_unavailable": "data unavailable", + "price_unavailable": "price unavailable", + "rank": "Rank", + "contract_address": "Contract address", + "token_id": "Token ID", + "token_symbol": "Token symbol", + "token_standard": "Token standard", + "date_created": "Date created", + "unique_token_holders": "Unique token holders", + "tokens_in_collection": "Tokens in collection", + "creator_address": "Creator address", + "last_sold": "Last sold", + "highest_current_bid": "Highest current bid", + "options" :{ + "view_on_os": "View on OpenSea", + "remove_nft": "Remove NFT" + }, + "attributes": "Attributes", + "disclaimer": "Disclaimer: MetaMask pulls the media file from the source URL. This URL is sometimes changed by the marketplace the NFT was minted on." + }, "qr_tab_switcher": { "scanner_tab": "Scan QR code", "receive_tab": "Your QR code" diff --git a/patches/@metamask+assets-controllers+30.0.0.patch b/patches/@metamask+assets-controllers+30.0.0.patch index 90cad62e460..bc3dcfd0a44 100644 --- a/patches/@metamask+assets-controllers+30.0.0.patch +++ b/patches/@metamask+assets-controllers+30.0.0.patch @@ -24,15 +24,17 @@ index 0dc70ec..461a210 100644 }; return acc; diff --git a/node_modules/@metamask/assets-controllers/dist/chunk-FMZML3V5.js b/node_modules/@metamask/assets-controllers/dist/chunk-FMZML3V5.js -index ee6155c..addfe1e 100644 +index ee6155c..06a3a04 100644 --- a/node_modules/@metamask/assets-controllers/dist/chunk-FMZML3V5.js +++ b/node_modules/@metamask/assets-controllers/dist/chunk-FMZML3V5.js -@@ -1,12 +1,14 @@ +@@ -1,12 +1,16 @@ "use strict";Object.defineProperty(exports, "__esModule", {value: true});// src/NftDetectionController.ts -- +var utils_1 = require('@metamask/utils'); +- ++var _chunkNEXY7SE2js = require('./chunk-NEXY7SE2.js'); ++var MAX_GET_COLLECTION_BATCH_SIZE = 20; var _controllerutils = require('@metamask/controller-utils'); @@ -43,7 +45,7 @@ index ee6155c..addfe1e 100644 var BlockaidResultType = /* @__PURE__ */ ((BlockaidResultType2) => { BlockaidResultType2["Benign"] = "Benign"; BlockaidResultType2["Spam"] = "Spam"; -@@ -50,6 +52,7 @@ var NftDetectionController = class extends _pollingcontroller.StaticIntervalPoll +@@ -50,6 +54,7 @@ var NftDetectionController = class extends _pollingcontroller.StaticIntervalPoll * Name of this controller used during composition */ this.name = "NftDetectionController"; @@ -51,7 +53,7 @@ index ee6155c..addfe1e 100644 /** * Checks whether network is mainnet or not. * -@@ -72,11 +75,6 @@ var NftDetectionController = class extends _pollingcontroller.StaticIntervalPoll +@@ -72,11 +77,6 @@ var NftDetectionController = class extends _pollingcontroller.StaticIntervalPoll const { selectedAddress: previouslySelectedAddress, disabled } = this.config; if (selectedAddress !== previouslySelectedAddress || !useNftDetection !== disabled) { this.configure({ selectedAddress, disabled: !useNftDetection }); @@ -63,7 +65,7 @@ index ee6155c..addfe1e 100644 } }); onNetworkStateChange(({ selectedNetworkClientId }) => { -@@ -92,34 +90,21 @@ var NftDetectionController = class extends _pollingcontroller.StaticIntervalPoll +@@ -92,34 +92,21 @@ var NftDetectionController = class extends _pollingcontroller.StaticIntervalPoll this.setIntervalLength(this.config.interval); } getOwnerNftApi({ @@ -108,7 +110,7 @@ index ee6155c..addfe1e 100644 } async _executePoll(networkClientId, options) { await this.detectNfts({ networkClientId, userAddress: options.address }); -@@ -169,62 +154,96 @@ var NftDetectionController = class extends _pollingcontroller.StaticIntervalPoll +@@ -169,62 +156,150 @@ var NftDetectionController = class extends _pollingcontroller.StaticIntervalPoll networkClientId, userAddress } = { userAddress: this.config.selectedAddress }) { @@ -191,6 +193,60 @@ index ee6155c..addfe1e 100644 - userAddress, - source: "detected" /* Detected */, - networkClientId ++ const collections = apiNfts.reduce((acc, currValue) => { ++ if (!acc.includes(currValue.token.contract) && currValue.token.contract === currValue?.token?.collection?.id) { ++ acc.push(currValue.token.contract); ++ } ++ return acc; ++ }, []); ++ if (collections.length !== 0) { ++ const collectionResponse = await _chunkNEXY7SE2js.reduceInBatchesSerially.call(void 0, { ++ values: collections, ++ batchSize: MAX_GET_COLLECTION_BATCH_SIZE, ++ eachBatch: async (allResponses, batch) => { ++ const params = new URLSearchParams( ++ batch.map((s) => ["contract", s]) ++ ); ++ params.append("chainId", "1"); ++ const collectionResponseForBatch = await _controllerutils.fetchWithErrorHandling.call(void 0, ++ { ++ url: `${_controllerutils.NFT_API_BASE_URL}/collections?${params.toString()}`, ++ options: { ++ headers: { ++ Version: '1' ++ } ++ }, ++ timeout: 15000 ++ } ++ ); ++ return { ++ ...allResponses, ++ ...collectionResponseForBatch ++ }; ++ }, ++ initialResult: {} ++ }); ++ if (collectionResponse.collections?.length) { ++ apiNfts.forEach((singleNFT) => { ++ const found = collectionResponse.collections.find( ++ (elm) => elm.id?.toLowerCase() === singleNFT.token.contract.toLowerCase() ++ ); ++ if (found) { ++ singleNFT.token = { ++ ...singleNFT.token, ++ collection: { ++ ...singleNFT.token.collection ? singleNFT.token.collection : {}, ++ creator: found?.creator, ++ openseaVerificationStatus: found?.openseaVerificationStatus, ++ contractDeployedAt: found.contractDeployedAt, ++ ownerCount: found.ownerCount, ++ topBid: found.topBid ++ } ++ }; ++ } ++ }); ++ } ++ } + const addNftPromises = apiNfts.map(async (nft) => { + const { + tokenId: token_id, @@ -309,7 +365,7 @@ index 76e3362..5ab79a4 100644 allTokens, allDetectedTokens, diff --git a/node_modules/@metamask/assets-controllers/dist/chunk-LAU6ZDZR.js b/node_modules/@metamask/assets-controllers/dist/chunk-LAU6ZDZR.js -index d429be1..f5c1e2e 100644 +index d429be1..6fef22b 100644 --- a/node_modules/@metamask/assets-controllers/dist/chunk-LAU6ZDZR.js +++ b/node_modules/@metamask/assets-controllers/dist/chunk-LAU6ZDZR.js @@ -33,6 +33,11 @@ var getDefaultNftState = () => { @@ -353,7 +409,24 @@ index d429be1..f5c1e2e 100644 if (needsUpdateNftMetadata) { const { chainId } = this.config; const nfts = this.state.allNfts[selectedAddress]?.[chainId] ?? []; -@@ -189,7 +194,8 @@ var NftController = class extends _basecontroller.BaseControllerV1 { +@@ -184,12 +189,25 @@ var NftController = class extends _basecontroller.BaseControllerV1 { + } + } + }); ++ const getCollectionParams = new URLSearchParams({ ++ chainId: "1", ++ id: `${nftInformation?.tokens[0]?.token?.collection?.id}` ++ }).toString(); ++ const collectionInformation = await _controllerutils.fetchWithErrorHandling.call(void 0, { ++ url: `${_controllerutils.NFT_API_BASE_URL}/collections?${getCollectionParams}`, ++ options: { ++ headers: { ++ Version: "1" ++ } ++ } ++ }); + if (!nftInformation?.tokens?.[0]?.token) { + return { name: null, description: null, image: null, @@ -363,7 +436,25 @@ index d429be1..f5c1e2e 100644 }; } const { -@@ -234,7 +240,7 @@ var NftController = class extends _basecontroller.BaseControllerV1 { +@@ -221,7 +239,16 @@ var NftController = class extends _basecontroller.BaseControllerV1 { + }, + rarityRank && { rarityRank }, + rarity && { rarity }, +- collection && { collection } ++ (collection || collectionInformation) && { ++ collection: { ++ ...collection || {}, ++ creator: collection?.creator || collectionInformation?.collections[0].creator, ++ openseaVerificationStatus: collectionInformation?.collections[0].openseaVerificationStatus, ++ contractDeployedAt: collectionInformation?.collections[0].contractDeployedAt, ++ ownerCount: collectionInformation?.collections[0].ownerCount, ++ topBid: collectionInformation?.collections[0].topBid ++ } ++ } + ); + return nftMetadata; + } +@@ -234,7 +261,7 @@ var NftController = class extends _basecontroller.BaseControllerV1 { * @returns Promise resolving to the current NFT name and image. */ async getNftInformationFromTokenURI(contractAddress, tokenId, networkClientId) { @@ -372,7 +463,7 @@ index d429be1..f5c1e2e 100644 const result = await this.getNftURIAndStandard( contractAddress, tokenId, -@@ -242,6 +248,18 @@ var NftController = class extends _basecontroller.BaseControllerV1 { +@@ -242,6 +269,18 @@ var NftController = class extends _basecontroller.BaseControllerV1 { ); let tokenURI = result[0]; const standard = result[1]; @@ -391,7 +482,7 @@ index d429be1..f5c1e2e 100644 const hasIpfsTokenURI = tokenURI.startsWith("ipfs://"); if (hasIpfsTokenURI && !isIpfsGatewayEnabled) { return { -@@ -253,15 +271,15 @@ var NftController = class extends _basecontroller.BaseControllerV1 { +@@ -253,15 +292,15 @@ var NftController = class extends _basecontroller.BaseControllerV1 { tokenURI: tokenURI ?? null }; } @@ -410,7 +501,7 @@ index d429be1..f5c1e2e 100644 }; } if (hasIpfsTokenURI) { -@@ -288,7 +306,8 @@ var NftController = class extends _basecontroller.BaseControllerV1 { +@@ -288,7 +327,8 @@ var NftController = class extends _basecontroller.BaseControllerV1 { description: null, standard: standard || null, favorite: false, @@ -420,7 +511,7 @@ index d429be1..f5c1e2e 100644 }; } } -@@ -345,15 +364,28 @@ var NftController = class extends _basecontroller.BaseControllerV1 { +@@ -345,15 +385,28 @@ var NftController = class extends _basecontroller.BaseControllerV1 { networkClientId ) ), @@ -451,7 +542,17 @@ index d429be1..f5c1e2e 100644 standard: blockchainMetadata?.standard ?? nftApiMetadata?.standard ?? null, tokenURI: blockchainMetadata?.tokenURI ?? null }; -@@ -472,7 +504,8 @@ var NftController = class extends _basecontroller.BaseControllerV1 { +@@ -443,7 +496,8 @@ var NftController = class extends _basecontroller.BaseControllerV1 { + nftMetadata, + existingEntry + ); +- if (differentMetadata || !existingEntry.isCurrentlyOwned) { ++ const hasNewFields = _chunkNEXY7SE2js.hasNewCollectionFields(nftMetadata, existingEntry); ++ if (differentMetadata || hasNewFields || !existingEntry.isCurrentlyOwned) { + const indexToRemove = nfts.findIndex( + (nft) => nft.address.toLowerCase() === tokenAddress.toLowerCase() && nft.tokenId === tokenId + ); +@@ -472,7 +526,8 @@ var NftController = class extends _basecontroller.BaseControllerV1 { symbol: nftContract.symbol, tokenId: tokenId.toString(), standard: nftMetadata.standard, @@ -461,7 +562,7 @@ index d429be1..f5c1e2e 100644 }); } return newNfts; -@@ -850,7 +883,7 @@ var NftController = class extends _basecontroller.BaseControllerV1 { +@@ -850,7 +905,7 @@ var NftController = class extends _basecontroller.BaseControllerV1 { ); } } @@ -470,7 +571,7 @@ index d429be1..f5c1e2e 100644 * Refetches NFT metadata and updates the state * * @param options - Options for refetching NFT metadata -@@ -858,11 +891,13 @@ var NftController = class extends _basecontroller.BaseControllerV1 { +@@ -858,11 +913,13 @@ var NftController = class extends _basecontroller.BaseControllerV1 { * @param options.userAddress - The current user address * @param options.networkClientId - The networkClientId that can be used to identify the network client to use for this request. */ @@ -489,7 +590,7 @@ index d429be1..f5c1e2e 100644 const chainId = this.getCorrectChainId({ networkClientId }); const nftsWithChecksumAdr = nfts.map((nft) => { return { -@@ -870,7 +905,7 @@ var NftController = class extends _basecontroller.BaseControllerV1 { +@@ -870,7 +927,7 @@ var NftController = class extends _basecontroller.BaseControllerV1 { address: _controllerutils.toChecksumHexAddress.call(void 0, nft.address) }; }); @@ -498,7 +599,7 @@ index d429be1..f5c1e2e 100644 nftsWithChecksumAdr.map(async (nft) => { const resMetadata = await this.getNftInformation( nft.address, -@@ -883,19 +918,16 @@ var NftController = class extends _basecontroller.BaseControllerV1 { +@@ -883,19 +940,16 @@ var NftController = class extends _basecontroller.BaseControllerV1 { }; }) ); @@ -521,7 +622,7 @@ index d429be1..f5c1e2e 100644 existingEntry ); if (differentMetadata) { -@@ -905,15 +937,13 @@ var NftController = class extends _basecontroller.BaseControllerV1 { +@@ -905,15 +959,13 @@ var NftController = class extends _basecontroller.BaseControllerV1 { }); if (nftsWithDifferentMetadata.length !== 0) { nftsWithDifferentMetadata.forEach( @@ -655,10 +756,28 @@ index cd8f792..b20db8a 100644 diff --git a/node_modules/@metamask/assets-controllers/dist/chunk-NEXY7SE2.js b/node_modules/@metamask/assets-controllers/dist/chunk-NEXY7SE2.js -index 8c506d9..d1ec2d2 100644 +index 8c506d9..bd798ec 100644 --- a/node_modules/@metamask/assets-controllers/dist/chunk-NEXY7SE2.js +++ b/node_modules/@metamask/assets-controllers/dist/chunk-NEXY7SE2.js -@@ -182,7 +182,7 @@ async function fetchTokenContractExchangeRates({ +@@ -27,6 +27,17 @@ function compareNftMetadata(newNftMetadata, nft) { + }, 0); + return differentValues > 0; + } ++ ++function hasNewCollectionFields( ++ newNftMetadata, ++ nft, ++) { ++ const keysNewNftMetadata = Object.keys(newNftMetadata.collection || {}); ++ const keysExistingNft = new Set(Object.keys(nft.collection || {})); ++ ++ return keysNewNftMetadata.some((key) => !keysExistingNft.has(key)); ++} ++ + var aggregatorNameByKey = { + aave: "Aave", + bancor: "Bancor", +@@ -182,7 +193,7 @@ async function fetchTokenContractExchangeRates({ (obj, [tokenAddress, tokenPrice]) => { return { ...obj, @@ -667,6 +786,14 @@ index 8c506d9..d1ec2d2 100644 }; }, {} +@@ -205,5 +216,5 @@ async function fetchTokenContractExchangeRates({ + + + +-exports.TOKEN_PRICES_BATCH_SIZE = TOKEN_PRICES_BATCH_SIZE; exports.compareNftMetadata = compareNftMetadata; exports.formatAggregatorNames = formatAggregatorNames; exports.formatIconUrlWithProxy = formatIconUrlWithProxy; exports.SupportedTokenDetectionNetworks = SupportedTokenDetectionNetworks; exports.isTokenDetectionSupportedForNetwork = isTokenDetectionSupportedForNetwork; exports.isTokenListSupportedForNetwork = isTokenListSupportedForNetwork; exports.removeIpfsProtocolPrefix = removeIpfsProtocolPrefix; exports.getIpfsCIDv1AndPath = getIpfsCIDv1AndPath; exports.getFormattedIpfsUrl = getFormattedIpfsUrl; exports.addUrlProtocolPrefix = addUrlProtocolPrefix; exports.ethersBigNumberToBN = ethersBigNumberToBN; exports.divideIntoBatches = divideIntoBatches; exports.reduceInBatchesSerially = reduceInBatchesSerially; exports.fetchTokenContractExchangeRates = fetchTokenContractExchangeRates; ++exports.TOKEN_PRICES_BATCH_SIZE = TOKEN_PRICES_BATCH_SIZE; exports.compareNftMetadata = compareNftMetadata; exports.hasNewCollectionFields = hasNewCollectionFields; exports.formatAggregatorNames = formatAggregatorNames; exports.formatIconUrlWithProxy = formatIconUrlWithProxy; exports.SupportedTokenDetectionNetworks = SupportedTokenDetectionNetworks; exports.isTokenDetectionSupportedForNetwork = isTokenDetectionSupportedForNetwork; exports.isTokenListSupportedForNetwork = isTokenListSupportedForNetwork; exports.removeIpfsProtocolPrefix = removeIpfsProtocolPrefix; exports.getIpfsCIDv1AndPath = getIpfsCIDv1AndPath; exports.getFormattedIpfsUrl = getFormattedIpfsUrl; exports.addUrlProtocolPrefix = addUrlProtocolPrefix; exports.ethersBigNumberToBN = ethersBigNumberToBN; exports.divideIntoBatches = divideIntoBatches; exports.reduceInBatchesSerially = reduceInBatchesSerially; exports.fetchTokenContractExchangeRates = fetchTokenContractExchangeRates; + //# sourceMappingURL=chunk-NEXY7SE2.js.map +\ No newline at end of file diff --git a/node_modules/@metamask/assets-controllers/dist/chunk-V4ZO3F2S.js b/node_modules/@metamask/assets-controllers/dist/chunk-V4ZO3F2S.js index 0430e5c..038398c 100644 --- a/node_modules/@metamask/assets-controllers/dist/chunk-V4ZO3F2S.js @@ -715,18 +842,34 @@ index 2f1b66f..60cbc0f 100644 ...marketData }; diff --git a/node_modules/@metamask/assets-controllers/dist/types/NftController.d.ts b/node_modules/@metamask/assets-controllers/dist/types/NftController.d.ts -index 42a321a..1393ca3 100644 +index 42a321a..b4554f5 100644 --- a/node_modules/@metamask/assets-controllers/dist/types/NftController.d.ts +++ b/node_modules/@metamask/assets-controllers/dist/types/NftController.d.ts -@@ -109,6 +109,7 @@ export interface NftMetadata { +@@ -8,7 +8,7 @@ import type { Hex } from '@metamask/utils'; + import { EventEmitter } from 'events'; + import type { AssetsContractController } from './AssetsContractController'; + import { Source } from './constants'; +-import type { Collection, Attributes, LastSale } from './NftDetectionController'; ++import type { Collection, Attributes, LastSale, TopBid } from './NftDetectionController'; + type NFTStandardType = 'ERC721' | 'ERC1155'; + type SuggestedNftMeta = { + asset: { +@@ -109,11 +109,13 @@ export interface NftMetadata { creator?: string; transactionId?: string; tokenURI?: string | null; + error?: string; collection?: Collection; address?: string; - attributes?: Attributes; -@@ -125,7 +126,7 @@ export interface NftConfig extends BaseConfig { +- attributes?: Attributes; ++ attributes?: Attributes[]; + lastSale?: LastSale; + rarityRank?: string; ++ topBid?: TopBid; + } + /** + * @type NftConfig +@@ -125,7 +127,7 @@ export interface NftConfig extends BaseConfig { selectedAddress: string; chainId: Hex; ipfsGateway: string; @@ -735,7 +878,7 @@ index 42a321a..1393ca3 100644 useIPFSSubdomains: boolean; isIpfsGatewayEnabled: boolean; } -@@ -350,7 +351,7 @@ export declare class NftController extends BaseControllerV1 +@@ -350,7 +352,7 @@ export declare class NftController extends BaseControllerV1 source: string; }) => void; messenger: NftControllerMessenger; @@ -744,6 +887,67 @@ index 42a321a..1393ca3 100644 private validateWatchNft; private getCorrectChainId; /** +diff --git a/node_modules/@metamask/assets-controllers/dist/types/NftDetectionController.d.ts b/node_modules/@metamask/assets-controllers/dist/types/NftDetectionController.d.ts +index 9016c5f..a24805e 100644 +--- a/node_modules/@metamask/assets-controllers/dist/types/NftDetectionController.d.ts ++++ b/node_modules/@metamask/assets-controllers/dist/types/NftDetectionController.d.ts +@@ -234,7 +234,42 @@ export type Attributes = { + topBidValue?: number | null; + createdAt?: string; + }; +-export type Collection = { ++export type GetCollectionsResponse = { ++ collections: CollectionResponse[]; ++ }; ++ ++export type CollectionResponse = { ++ id?: string; ++ openseaVerificationStatus?: string; ++ contractDeployedAt?: string; ++ creator?: string; ++ ownerCount?: string; ++ topBid?: TopBid & { ++ sourceDomain?: string; ++ }; ++}; ++ ++export type FloorAskCollection = { ++ id?: string; ++ price?: Price; ++ maker?: string; ++ kind?: string; ++ validFrom?: number; ++ validUntil?: number; ++ source?: SourceCollection; ++ rawData?: Metadata; ++ isNativeOffChainCancellable?: boolean; ++}; ++ ++export type SourceCollection = { ++ id: string; ++ domain: string; ++ name: string; ++ icon: string; ++ url: string; ++}; ++ ++export type TokenCollection = { + id?: string; + name?: string; + slug?: string; +@@ -250,7 +285,11 @@ export type Collection = { + floorAskPrice?: Price; + royaltiesBps?: number; + royalties?: Royalties[]; +-}; ++ floorAsk?: FloorAskCollection; ++ }; ++ ++export type Collection = TokenCollection & CollectionResponse; ++ + export type Royalties = { + bps?: number; + recipient?: string; diff --git a/node_modules/@metamask/assets-controllers/dist/types/TokenBalancesController.d.ts b/node_modules/@metamask/assets-controllers/dist/types/TokenBalancesController.d.ts index 52bb3ac..1f4d15d 100644 --- a/node_modules/@metamask/assets-controllers/dist/types/TokenBalancesController.d.ts