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