Skip to content

Commit 0c2befd

Browse files
feat: New nft details page (#10277)
## **Description** This PR modifies the design of the NFT details page. Designs: https://www.figma.com/design/TfVzSMJA8KwpWX8TTWQ2iO/Asset-list-and-details?node-id=1242-143952&m=dev ## **Related issues** Fixes: Related: MetaMask/core#4522 Related: MetaMask/core#4443 ## **Manual testing steps** 1. Go to this NFT tab 2. Click and browse through your NFTs 3. You should be able to see basic things like tokenId, contract address, description. Other fields will be displayed if they exist. 4. Click on the contract address and you should be redirected to etherscan. 5. Click on the image in the NFT details page and you should see the image in a new page without any of the details. 6. Try clicking on the navbar and go to "See on opensea" 7. Removing NFT flow and Sending NFT flow should not be affected ## **Screenshots/Recordings** <!-- If applicable, add screenshots and/or recordings to visualize the before and after of your change. --> ### **Before** <!-- [screenshots/recordings] --> https://github.com/user-attachments/assets/97679ba6-c8bb-483d-adb1-f3ebfdfb4337 ### **After** <!-- [screenshots/recordings] --> https://github.com/user-attachments/assets/024f389a-9feb-4fef-8b20-81fa59f232fd Also we added a new bottom sheet when token ID is too long https://github.com/user-attachments/assets/1434b08b-ab1a-4e56-acde-189b303b88e9 ## **Pre-merge author checklist** - [ ] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [ ] I've completed the PR template to the best of my ability - [ ] I’ve included tests if applicable - [ ] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [ ] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [ ] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [ ] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --------- Co-authored-by: Curtis <[email protected]>
1 parent 5835f66 commit 0c2befd

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+4094
-60
lines changed
Lines changed: 1 addition & 1 deletion
Loading
Lines changed: 1 addition & 1 deletion
Loading
Lines changed: 1 addition & 1 deletion
Loading
Lines changed: 1 addition & 1 deletion
Loading
Lines changed: 1 addition & 1 deletion
Loading
Lines changed: 1 addition & 1 deletion
Loading
Lines changed: 1 addition & 1 deletion
Loading

app/components/Base/RemoteImage/index.js

Lines changed: 157 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1-
import React, { useMemo, useState } from 'react';
1+
import React, { useEffect, useMemo, useState } from 'react';
22
import PropTypes from 'prop-types';
3-
import { Image, ViewPropTypes, View, StyleSheet } from 'react-native';
3+
import {
4+
Image,
5+
ViewPropTypes,
6+
View,
7+
StyleSheet,
8+
Dimensions,
9+
} from 'react-native';
410
import FadeIn from 'react-native-fade-in-image';
511
// eslint-disable-next-line import/default
612
import resolveAssetSource from 'react-native/Libraries/Image/resolveAssetSource';
@@ -10,13 +16,44 @@ import ComponentErrorBoundary from '../../UI/ComponentErrorBoundary';
1016
import useIpfsGateway from '../../hooks/useIpfsGateway';
1117
import { getFormattedIpfsUrl } from '@metamask/assets-controllers';
1218
import Identicon from '../../UI/Identicon';
19+
import BadgeWrapper from '../../../component-library/components/Badges/BadgeWrapper';
20+
import Badge, {
21+
BadgeVariant,
22+
} from '../../../component-library/components/Badges/Badge';
23+
import { useSelector } from 'react-redux';
24+
import {
25+
selectChainId,
26+
selectTicker,
27+
} from '../../../selectors/networkController';
28+
import {
29+
getTestNetImageByChainId,
30+
isLineaMainnet,
31+
isMainNet,
32+
isTestNet,
33+
} from '../../../util/networks';
34+
import images from 'images/image-icons';
35+
import { selectNetworkName } from '../../../selectors/networkInfos';
36+
37+
import { BadgeAnchorElementShape } from '../../../component-library/components/Badges/BadgeWrapper/BadgeWrapper.types';
1338
import useSvgUriViewBox from '../../hooks/useSvgUriViewBox';
39+
import { AvatarSize } from '../../../component-library/components/Avatars/Avatar';
1440

1541
const createStyles = () =>
1642
StyleSheet.create({
1743
svgContainer: {
1844
overflow: 'hidden',
1945
},
46+
badgeWrapper: {
47+
flex: 1,
48+
},
49+
imageStyle: {
50+
width: '100%',
51+
height: '100%',
52+
borderRadius: 8,
53+
},
54+
detailedImageStyle: {
55+
borderRadius: 8,
56+
},
2057
});
2158

2259
const RemoteImage = (props) => {
@@ -26,6 +63,9 @@ const RemoteImage = (props) => {
2663
const isImageUrl = isUrl(props?.source?.uri);
2764
const ipfsGateway = useIpfsGateway();
2865
const styles = createStyles();
66+
const chainId = useSelector(selectChainId);
67+
const ticker = useSelector(selectTicker);
68+
const networkName = useSelector(selectNetworkName);
2969
const resolvedIpfsUrl = useMemo(() => {
3070
try {
3171
const url = new URL(props.source.uri);
@@ -41,6 +81,51 @@ const RemoteImage = (props) => {
4181

4282
const onError = ({ nativeEvent: { error } }) => setError(error);
4383

84+
const [dimensions, setDimensions] = useState(null);
85+
86+
useEffect(() => {
87+
const calculateImageDimensions = (imageWidth, imageHeight) => {
88+
const deviceWidth = Dimensions.get('window').width;
89+
const maxWidth = deviceWidth - 32;
90+
const maxHeight = 0.75 * maxWidth;
91+
92+
if (imageWidth > imageHeight) {
93+
// Horizontal image
94+
const width = maxWidth;
95+
const height = (imageHeight / imageWidth) * maxWidth;
96+
return { width, height };
97+
} else if (imageHeight > imageWidth) {
98+
// Vertical image
99+
const height = maxHeight;
100+
const width = (imageWidth / imageHeight) * maxHeight;
101+
return { width, height };
102+
}
103+
// Square image
104+
return { width: maxHeight, height: maxHeight };
105+
};
106+
107+
Image.getSize(
108+
uri,
109+
(width, height) => {
110+
const { width: calculatedWidth, height: calculatedHeight } =
111+
calculateImageDimensions(width, height);
112+
setDimensions({ width: calculatedWidth, height: calculatedHeight });
113+
},
114+
() => {
115+
console.error('Failed to get image dimensions');
116+
},
117+
);
118+
}, [uri]);
119+
120+
const NetworkBadgeSource = () => {
121+
if (isTestNet(chainId)) return getTestNetImageByChainId(chainId);
122+
123+
if (isMainNet(chainId)) return images.ETHEREUM;
124+
125+
if (isLineaMainnet(chainId)) return images['LINEA-MAINNET'];
126+
127+
return ticker ? images[ticker] : undefined;
128+
};
44129
const isSVG =
45130
source &&
46131
source.uri &&
@@ -83,12 +168,75 @@ const RemoteImage = (props) => {
83168
}
84169

85170
if (props.fadeIn) {
171+
const { style, ...restProps } = props;
172+
const badge = {
173+
top: -4,
174+
right: -4,
175+
};
86176
return (
87-
<FadeIn placeholderStyle={props.placeholderStyle}>
88-
<Image {...props} source={{ uri }} onError={onError} />
89-
</FadeIn>
177+
<>
178+
{props.isTokenImage ? (
179+
<FadeIn placeholderStyle={props.placeholderStyle}>
180+
<View>
181+
{props.isFullRatio && dimensions ? (
182+
<BadgeWrapper
183+
badgePosition={badge}
184+
anchorElementShape={BadgeAnchorElementShape.Rectangular}
185+
badgeElement={
186+
<Badge
187+
variant={BadgeVariant.Network}
188+
imageSource={NetworkBadgeSource()}
189+
name={networkName}
190+
isScaled={false}
191+
size={AvatarSize.Md}
192+
/>
193+
}
194+
>
195+
<Image
196+
source={{ uri }}
197+
style={{
198+
width: dimensions.width,
199+
height: dimensions.height,
200+
...styles.detailedImageStyle,
201+
}}
202+
/>
203+
</BadgeWrapper>
204+
) : (
205+
<BadgeWrapper
206+
badgePosition={badge}
207+
anchorElementShape={BadgeAnchorElementShape.Rectangular}
208+
badgeElement={
209+
<Badge
210+
variant={BadgeVariant.Network}
211+
imageSource={NetworkBadgeSource()}
212+
name={networkName}
213+
isScaled={false}
214+
size={AvatarSize.Xs}
215+
/>
216+
}
217+
>
218+
<View style={style}>
219+
<Image
220+
style={styles.imageStyle}
221+
{...restProps}
222+
source={{ uri }}
223+
onError={onError}
224+
resizeMode={'cover'}
225+
/>
226+
</View>
227+
</BadgeWrapper>
228+
)}
229+
</View>
230+
</FadeIn>
231+
) : (
232+
<FadeIn placeholderStyle={props.placeholderStyle}>
233+
<Image {...props} source={{ uri }} onError={onError} />
234+
</FadeIn>
235+
)}
236+
</>
90237
);
91238
}
239+
92240
return <Image {...props} source={{ uri }} onError={onError} />;
93241
};
94242

@@ -121,6 +269,10 @@ RemoteImage.propTypes = {
121269
* Token address
122270
*/
123271
address: PropTypes.string,
272+
273+
isTokenImage: PropTypes.bool,
274+
275+
isFullRatio: PropTypes.bool,
124276
};
125277

126278
export default RemoteImage;

app/components/Nav/App/index.js

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ import BasicFunctionalityModal from '../../UI/BasicFunctionality/BasicFunctional
116116
import SmartTransactionsOptInModal from '../../Views/SmartTransactionsOptInModal/SmartTranactionsOptInModal';
117117
import ProfileSyncingModal from '../../UI/ProfileSyncing/ProfileSyncingModal/ProfileSyncingModal';
118118
import NFTAutoDetectionModal from '../../../../app/components/Views/NFTAutoDetectionModal/NFTAutoDetectionModal';
119+
import NftOptions from '../../../components/Views/NftOptions';
120+
import ShowTokenIdSheet from '../../../components/Views/ShowTokenIdSheet';
119121
import OriginSpamModal from '../../Views/OriginSpamModal/OriginSpamModal';
120122
///: BEGIN:ONLY_INCLUDE_IF(preinstalled-snaps,external-snaps)
121123
import { SnapsExecutionWebView } from '../../../lib/snaps';
@@ -682,6 +684,7 @@ const App = ({ userLoggedIn }) => {
682684
/>
683685
<Stack.Screen name={'DetectedTokens'} component={DetectedTokensFlow} />
684686
<Stack.Screen name={'AssetOptions'} component={AssetOptions} />
687+
<Stack.Screen name={'NftOptions'} component={NftOptions} />
685688
<Stack.Screen
686689
name={Routes.MODAL.UPDATE_NEEDED}
687690
component={UpdateNeeded}
@@ -715,6 +718,11 @@ const App = ({ userLoggedIn }) => {
715718
name={Routes.MODAL.NFT_AUTO_DETECTION_MODAL}
716719
component={NFTAutoDetectionModal}
717720
/>
721+
<Stack.Screen
722+
name={Routes.SHEET.SHOW_TOKEN_ID}
723+
component={ShowTokenIdSheet}
724+
/>
725+
718726
<Stack.Screen
719727
name={Routes.SHEET.ORIGIN_SPAM_MODAL}
720728
component={OriginSpamModal}

app/components/Nav/Main/MainNavigator.js

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,9 @@ import { AesCryptoTestForm } from '../../Views/AesCryptoTestForm';
8383
import { isTest } from '../../../util/test/utils';
8484
import { selectPermissionControllerState } from '../../../selectors/snaps/permissionController';
8585

86+
import NftDetails from '../../Views/NftDetails';
87+
import NftDetailsFullImage from '../../Views/NftDetails/NFtDetailsFullImage';
88+
8689
const Stack = createStackNavigator();
8790
const Tab = createBottomTabNavigator();
8891

@@ -541,6 +544,32 @@ const SendView = () => (
541544
</Stack.Navigator>
542545
);
543546

547+
/* eslint-disable react/prop-types */
548+
const NftDetailsModeView = (props) => (
549+
<Stack.Navigator>
550+
<Stack.Screen
551+
name=" " // No name here because this title will be displayed in the header of the page
552+
component={NftDetails}
553+
initialParams={{
554+
collectible: props.route.params?.collectible,
555+
}}
556+
/>
557+
</Stack.Navigator>
558+
);
559+
560+
/* eslint-disable react/prop-types */
561+
const NftDetailsFullImageModeView = (props) => (
562+
<Stack.Navigator>
563+
<Stack.Screen
564+
name=" " // No name here because this title will be displayed in the header of the page
565+
component={NftDetailsFullImage}
566+
initialParams={{
567+
collectible: props.route.params?.collectible,
568+
}}
569+
/>
570+
</Stack.Navigator>
571+
);
572+
544573
const SendFlowView = () => (
545574
<Stack.Navigator>
546575
<Stack.Screen
@@ -728,6 +757,11 @@ const MainNavigator = () => (
728757
name={Routes.NOTIFICATIONS.VIEW}
729758
component={NotificationsModeView}
730759
/>
760+
<Stack.Screen name="NftDetails" component={NftDetailsModeView} />
761+
<Stack.Screen
762+
name="NftDetailsFullImage"
763+
component={NftDetailsFullImageModeView}
764+
/>
731765
<Stack.Screen name={Routes.QR_SCANNER} component={QrScanner} />
732766
<Stack.Screen name="PaymentRequestView" component={PaymentRequestView} />
733767
<Stack.Screen name={Routes.RAMP.BUY}>

0 commit comments

Comments
 (0)