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