1
0
mirror of https://github.com/kremalicious/metamask-extension.git synced 2024-11-22 01:47:00 +01:00

Feat/collectibles display (#12873)

* Wiring up Collectibles lists/items

* wip

* more wip

* more more wip

* yet more wip

* wippp

* more wipppp

* closer

* wroking

* more wip

* cleanup

* cleanup

* add-collectible form validation

* update default ipfs-gateway

* update refresh button

* fix proptypes issue + add more padding to asset background

* css tweaking

* more cleanup

* more cleanup

* more cleanup

* add migration

* address feedback

* fix migration + cleanup

* bumping controllers version + adapting new collectiblesController shape

* fix yarn dedupe
This commit is contained in:
Alex Donesky 2021-12-01 10:10:17 -06:00 committed by GitHub
parent 39d5afb3c1
commit 81ea24f08a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 774 additions and 86 deletions

View File

@ -139,9 +139,6 @@
"addNFT": {
"message": "Add NFT"
},
"addNFTLowerCase": {
"message": "add NFT"
},
"addNetwork": {
"message": "Add Network"
},
@ -569,6 +566,9 @@
"contract": {
"message": "Contract"
},
"contractAddress": {
"message": "Contract address"
},
"contractAddressError": {
"message": "You are sending tokens to the token's contract address. This may result in the loss of these tokens."
},
@ -707,6 +707,9 @@
"depositEther": {
"message": "Deposit Ether"
},
"description": {
"message": "Description"
},
"details": {
"message": "Details"
},
@ -870,6 +873,9 @@
"editPermission": {
"message": "Edit Permission"
},
"enableAutoDetect": {
"message": " Enable Autodetect"
},
"enableFromSettings": {
"message": " Enable it from Settings."
},
@ -1268,6 +1274,9 @@
"importMyWallet": {
"message": "Import My Wallet"
},
"importNFTs": {
"message": "Import NFTs"
},
"importTokenQuestion": {
"message": "Import token?"
},
@ -1460,6 +1469,9 @@
"likeToImportTokens": {
"message": "Would you like to import these tokens?"
},
"link": {
"message": "Link"
},
"links": {
"message": "Links"
},
@ -2142,6 +2154,9 @@
"removeAccountDescription": {
"message": "This account will be removed from your wallet. Please make sure you have the original Secret Recovery Phrase or private key for this imported account before continuing. You can import or create accounts again from the account drop-down. "
},
"removeNFT": {
"message": "Remove NFT"
},
"requestsAwaitingAcknowledgement": {
"message": "requests waiting to be acknowledged"
},
@ -2460,6 +2475,9 @@
"somethingWentWrong": {
"message": "Oops! Something went wrong."
},
"source": {
"message": "Source"
},
"speedUp": {
"message": "Speed Up"
},
@ -3240,6 +3258,9 @@
"message": "View $1 on Etherscan",
"description": "$1 is the action type. e.g (Account, Transaction, Swap)"
},
"viewOnOpensea": {
"message": "View on Opensea"
},
"viewinExplorer": {
"message": "View $1 in Explorer",
"description": "$1 is the action type. e.g (Account, Transaction, Swap)"

View File

@ -61,7 +61,7 @@ export default class PreferencesController {
hideZeroBalanceTokens: false,
},
// ENS decentralized website resolution
ipfsGateway: 'dweb.link',
ipfsGateway: 'https://cloudflare-ipfs.com/ipfs/',
infuraBlocked: null,
ledgerTransportType: window.navigator.hid
? LEDGER_TRANSPORT_TYPES.WEBHID

View File

@ -50,6 +50,8 @@ export default function setupEnsIpfsResolver({
async function attemptResolve({ tabId, name, pathname, search, fragment }) {
const ipfsGateway = getIpfsGateway();
const ipfsGatewayHost = new URL(ipfsGateway)?.host;
extension.tabs.update(tabId, { url: `loading.html` });
let url = `https://app.ens.domains/name/${name}`;
try {
@ -61,7 +63,7 @@ export default function setupEnsIpfsResolver({
const resolvedUrl = `https://${hash}.${type.slice(
0,
4,
)}.${ipfsGateway}${pathname}${search || ''}${fragment || ''}`;
)}.${ipfsGatewayHost}${pathname}${search || ''}${fragment || ''}`;
try {
// check if ipfs gateway has result
const response = await fetchWithTimeout(resolvedUrl, {

View File

@ -41,7 +41,10 @@ import {
GAS_DEV_API_BASE_URL,
SWAPS_CLIENT_ID,
} from '../../shared/constants/swaps';
import { MAINNET_CHAIN_ID } from '../../shared/constants/network';
import {
IPFS_DEFAULT_GATEWAY_URL,
MAINNET_CHAIN_ID,
} from '../../shared/constants/network';
import {
DEVICE_NAMES,
KEYRING_TYPES,
@ -187,32 +190,44 @@ export default class MetamaskController extends EventEmitter {
provider: this.provider,
});
this.collectiblesController = new CollectiblesController({
onPreferencesStateChange: this.preferencesController.store.subscribe.bind(
this.preferencesController.store,
),
onNetworkStateChange: this.networkController.store.subscribe.bind(
this.networkController.store,
),
getAssetName: this.assetsContractController.getAssetName.bind(
this.assetsContractController,
),
getAssetSymbol: this.assetsContractController.getAssetSymbol.bind(
this.assetsContractController,
),
getCollectibleTokenURI: this.assetsContractController.getCollectibleTokenURI.bind(
this.assetsContractController,
),
getOwnerOf: this.assetsContractController.getOwnerOf.bind(
this.assetsContractController,
),
balanceOfERC1155Collectible: this.assetsContractController.balanceOfERC1155Collectible.bind(
this.assetsContractController,
),
uriERC1155Collectible: this.assetsContractController.uriERC1155Collectible.bind(
this.assetsContractController,
),
});
this.collectiblesController = new CollectiblesController(
{
onPreferencesStateChange: (cb) =>
this.preferencesController.store.subscribe((preferencesState) => {
const { ipfsGateway } = this.preferencesController.store.getState();
const modifiedPreferencesState = {
...preferencesState,
ipfsGateway: ipfsGateway.endsWith('/ipfs/')
? ipfsGateway
: `${ipfsGateway}/ipfs/`,
};
return cb(modifiedPreferencesState);
}),
onNetworkStateChange: this.networkController.store.subscribe.bind(
this.networkController.store,
),
getAssetName: this.assetsContractController.getAssetName.bind(
this.assetsContractController,
),
getAssetSymbol: this.assetsContractController.getAssetSymbol.bind(
this.assetsContractController,
),
getCollectibleTokenURI: this.assetsContractController.getCollectibleTokenURI.bind(
this.assetsContractController,
),
getOwnerOf: this.assetsContractController.getOwnerOf.bind(
this.assetsContractController,
),
balanceOfERC1155Collectible: this.assetsContractController.balanceOfERC1155Collectible.bind(
this.assetsContractController,
),
uriERC1155Collectible: this.assetsContractController.uriERC1155Collectible.bind(
this.assetsContractController,
),
},
{ ipfsGateway: `${IPFS_DEFAULT_GATEWAY_URL}/ipfs/` },
initState.CollectiblesController,
);
process.env.COLLECTIBLES_V1 &&
(this.collectibleDetectionController = new CollectibleDetectionController(

View File

@ -0,0 +1,61 @@
import { cloneDeep } from 'lodash';
import { IPFS_DEFAULT_GATEWAY_URL } from '../../../shared/constants/network';
const version = 68;
function addUrlProtocolPrefix(urlString) {
if (!urlString.match(/(^http:\/\/)|(^https:\/\/)/u)) {
return `https://${urlString}`;
}
return urlString;
}
export default {
version,
async migrate(originalVersionedData) {
const versionedData = cloneDeep(originalVersionedData);
versionedData.meta.version = version;
const state = versionedData.data;
const newState = transformState(state);
versionedData.data = newState;
return versionedData;
},
};
function transformState(state) {
const PreferencesController = state?.PreferencesController || {};
const preferences = PreferencesController.preferences || {};
const oldIpfsGateWay = preferences.ipfsGateway;
let newState;
if (oldIpfsGateWay && oldIpfsGateWay !== 'dweb.link') {
const newIpfsGateway = new URL(
addUrlProtocolPrefix(oldIpfsGateWay),
).toString();
newState = {
...state,
PreferencesController: {
...PreferencesController,
preferences: {
...preferences,
ipfsGateway: newIpfsGateway,
},
},
};
} else {
newState = {
...state,
PreferencesController: {
...PreferencesController,
preferences: {
...preferences,
ipfsGateway: IPFS_DEFAULT_GATEWAY_URL,
},
},
};
}
return newState;
}

View File

@ -0,0 +1,56 @@
import { IPFS_DEFAULT_GATEWAY_URL } from '../../../shared/constants/network';
import migration68 from './068';
describe('migration #68', () => {
it('should update the version metadata', async () => {
const oldStorage = {
meta: {
version: 67,
},
data: {},
};
const newStorage = await migration68.migrate(oldStorage);
expect(newStorage.meta).toStrictEqual({
version: 68,
});
});
it('should set preference ipfsGateway to "https://cloudflare-ipfs.com" if ipfsGateway is old default dweb.link', async () => {
const expectedValue = IPFS_DEFAULT_GATEWAY_URL; // = https://cloudflare-ipfs.com
const oldStorage = {
meta: {},
data: {
PreferencesController: {
preferences: {
ipfsGateway: 'dweb.link',
},
},
},
};
const newStorage = await migration68.migrate(oldStorage);
expect(newStorage.data.PreferencesController.preferences.ipfsGateway).toBe(
expectedValue,
);
});
it('should update preference ipfsGateway to a full url version of user set ipfsGateway if ipfsGateway is not old default dweb.link', async () => {
const expectedValue = 'https://random.ipfs/';
const oldStorage = {
meta: {},
data: {
PreferencesController: {
preferences: {
ipfsGateway: 'random.ipfs',
},
},
},
};
const newStorage = await migration68.migrate(oldStorage);
expect(newStorage.data.PreferencesController.preferences.ipfsGateway).toBe(
expectedValue,
);
});
});

View File

@ -71,6 +71,7 @@ import m064 from './064';
import m065 from './065';
import m066 from './066';
import m067 from './067';
import m068 from './068';
const migrations = [
m002,
@ -139,6 +140,7 @@ const migrations = [
m065,
m066,
m067,
m068,
];
export default migrations;

View File

@ -111,7 +111,7 @@
"@keystonehq/metamask-airgapped-keyring": "0.2.1",
"@material-ui/core": "^4.11.0",
"@metamask/contract-metadata": "^1.28.0",
"@metamask/controllers": "^20.1.0",
"@metamask/controllers": "^21.0.1",
"@metamask/eth-ledger-bridge-keyring": "^0.10.0",
"@metamask/eth-token-tracker": "^3.0.1",
"@metamask/etherscan-link": "^2.1.0",

View File

@ -171,3 +171,5 @@ export const UNSUPPORTED_RPC_METHODS = new Set([
// eth-json-rpc-middleware but our UI does not support it.
'eth_signTransaction',
]);
export const IPFS_DEFAULT_GATEWAY_URL = 'https://cloudflare-ipfs.com';

View File

@ -7,6 +7,8 @@
@import 'app-header/index';
@import 'asset-list-item/asset-list-item';
@import 'confirm-page-container/index';
@import 'collectibles-items/index';
@import 'collectible-details/index';
@import 'connected-accounts-list/index';
@import 'connected-accounts-permissions/index';
@import 'connected-sites-list/index';

View File

@ -0,0 +1,212 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import { getTokenTrackerLink } from '@metamask/etherscan-link';
import Box from '../../ui/box';
import Typography from '../../ui/typography/typography';
import {
COLORS,
TYPOGRAPHY,
BLOCK_SIZES,
FONT_WEIGHT,
JUSTIFY_CONTENT,
FLEX_DIRECTION,
OVERFLOW_WRAP,
DISPLAY,
} from '../../../helpers/constants/design-system';
import { useI18nContext } from '../../../hooks/useI18nContext';
import {
getAssetImageURL,
isEqualCaseInsensitive,
shortenAddress,
} from '../../../helpers/utils/util';
import {
getCurrentChainId,
getIpfsGateway,
getRpcPrefsForCurrentProvider,
getSelectedIdentity,
} from '../../../selectors';
import AssetNavigation from '../../../pages/asset/components/asset-navigation';
import { getCollectibleContracts } from '../../../ducks/metamask/metamask';
import { DEFAULT_ROUTE } from '../../../helpers/constants/routes';
import { removeAndIgnoreCollectible } from '../../../store/actions';
import {
GOERLI_CHAIN_ID,
KOVAN_CHAIN_ID,
MAINNET_CHAIN_ID,
POLYGON_CHAIN_ID,
RINKEBY_CHAIN_ID,
ROPSTEN_CHAIN_ID,
} from '../../../../shared/constants/network';
import { getEnvironmentType } from '../../../../app/scripts/lib/util';
import { ENVIRONMENT_TYPE_POPUP } from '../../../../shared/constants/app';
import CollectibleOptions from './collectible-options';
export default function CollectibleDetails({ collectible }) {
const { image, name, description, address, tokenId } = collectible;
const t = useI18nContext();
const history = useHistory();
const rpcPrefs = useSelector(getRpcPrefsForCurrentProvider);
const ipfsGateway = useSelector(getIpfsGateway);
const collectibleContracts = useSelector(getCollectibleContracts);
const currentNetwork = useSelector(getCurrentChainId);
const collectibleContractName = collectibleContracts.find(
({ address: contractAddress }) =>
isEqualCaseInsensitive(contractAddress, address),
)?.name;
const selectedAccountName = useSelector(
(state) => getSelectedIdentity(state).name,
);
const collectibleImageURL = getAssetImageURL(image, ipfsGateway);
const dispatch = useDispatch();
const onRemove = () => {
dispatch(removeAndIgnoreCollectible(address, tokenId));
history.push(DEFAULT_ROUTE);
};
const getOpenSeaLink = () => {
switch (currentNetwork) {
case MAINNET_CHAIN_ID:
return `https://opensea.io/assets/${address}/${tokenId}`;
case POLYGON_CHAIN_ID:
return `https://opensea.io/assets/matic/${address}/${tokenId}`;
case GOERLI_CHAIN_ID:
case KOVAN_CHAIN_ID:
case ROPSTEN_CHAIN_ID:
case RINKEBY_CHAIN_ID:
return `https://testnets.opensea.io/assets/${address}/${tokenId}`;
default:
return null;
}
};
const openSeaLink = getOpenSeaLink();
return (
<>
<AssetNavigation
accountName={selectedAccountName}
assetName={collectibleContractName}
onBack={() => history.push(DEFAULT_ROUTE)}
optionsButton={
<CollectibleOptions
onViewOnOpensea={
openSeaLink
? () => global.platform.openTab({ url: openSeaLink })
: null
}
onRemove={onRemove}
/>
}
/>
<Box padding={[4, 2, 4, 2]}>
<div className="collectible-details">
<Box margin={3} padding={2} justifyContent={JUSTIFY_CONTENT.CENTER}>
<img style={{ width: '14rem' }} src={collectibleImageURL} />
</Box>
<Box
margin={3}
flexDirection={FLEX_DIRECTION.COLUMN}
width={
getEnvironmentType() === ENVIRONMENT_TYPE_POPUP
? BLOCK_SIZES.THREE_FOURTHS
: BLOCK_SIZES.HALF
}
>
<Typography
color={COLORS.BLACK}
variant={TYPOGRAPHY.H4}
fontWeight={FONT_WEIGHT.BOLD}
>
{name}
</Typography>
<Typography
color={COLORS.UI3}
variant={TYPOGRAPHY.H5}
boxProps={{ marginTop: 2, marginBottom: 3 }}
overflowWrap={OVERFLOW_WRAP.BREAK_WORD}
>
{`#${tokenId}`}
</Typography>
<Typography
color={COLORS.BLACK}
variant={TYPOGRAPHY.H6}
fontWeight={FONT_WEIGHT.BOLD}
className="collectible-details__description"
>
{t('description')}
</Typography>
<Typography color={COLORS.UI3} variant={TYPOGRAPHY.H6}>
{description}
</Typography>
</Box>
</div>
<Box margin={4}>
<Box display={DISPLAY.FLEX} flexDirection={FLEX_DIRECTION.ROW}>
<Typography
color={COLORS.BLACK}
variant={TYPOGRAPHY.H6}
fontWeight={FONT_WEIGHT.BOLD}
boxProps={{ marginBottom: 3, width: BLOCK_SIZES.ONE_FOURTH }}
>
{t('source')}
</Typography>
<Typography
color={COLORS.PRIMARY1}
variant={TYPOGRAPHY.H6}
boxProps={{ marginBottom: 3, width: BLOCK_SIZES.THREE_FOURTHS }}
overflowWrap={OVERFLOW_WRAP.BREAK_WORD}
>
<a
target="_blank"
href={collectibleImageURL}
rel="noopener noreferrer"
style={{ overflow: 'hidden', textOverflow: 'ellipsis' }}
>
{image}
</a>
</Typography>
</Box>
<Box display={DISPLAY.FLEX} flexDirection={FLEX_DIRECTION.ROW}>
<Typography
color={COLORS.BLACK}
variant={TYPOGRAPHY.H6}
fontWeight={FONT_WEIGHT.BOLD}
boxProps={{ width: BLOCK_SIZES.ONE_FOURTH }}
>
{t('contractAddress')}
</Typography>
<Typography
color={COLORS.UI3}
variant={TYPOGRAPHY.H6}
overflowWrap={OVERFLOW_WRAP.BREAK_WORD}
boxProps={{ width: BLOCK_SIZES.THREE_FOURTHS }}
>
<a
target="_blank"
href={getTokenTrackerLink(
address,
currentNetwork,
null,
null,
rpcPrefs,
)}
rel="noopener noreferrer"
>
{getEnvironmentType() === ENVIRONMENT_TYPE_POPUP
? shortenAddress(address)
: address}
</a>
</Typography>
</Box>
</Box>
</Box>
</>
);
}
CollectibleDetails.propTypes = {
collectible: PropTypes.object,
};

View File

@ -0,0 +1,23 @@
import React from 'react';
import CollectibleDetails from './collectible-details';
export default {
title: 'Collectibles Detail',
id: __filename,
};
export const basic = () => {
const collectible = {
name: 'Catnip Spicywright',
tokenId: '1124157',
address: '0x06012c8cf97bead5deae237070f9587f8e7a266d',
image: './images/catnip-spicywright.png',
description:
"Good day. My name is Catnip Spicywight, which got me teased a lot in high school. If I want to put low fat mayo all over my hamburgers, I shouldn't have to answer to anyone about it, am I right? One time I beat Arlene in an arm wrestle.",
};
return (
<div style={{ width: '420px' }}>
<CollectibleDetails collectible={collectible} />
</div>
);
};

View File

@ -0,0 +1,61 @@
import React, { useContext, useState } from 'react';
import PropTypes from 'prop-types';
import { I18nContext } from '../../../contexts/i18n';
import { Menu, MenuItem } from '../../ui/menu';
const CollectibleOptions = ({ onRemove, onViewOnOpensea }) => {
const t = useContext(I18nContext);
const [
collectibleOptionsButtonElement,
setCollectibleOptionsButtonElement,
] = useState(null);
const [collectibleOptionsOpen, setCollectibleOptionsOpen] = useState(false);
return (
<>
<button
className="fas fa-ellipsis-v collectible-options__button"
data-testid="collectible-options__button"
onClick={() => setCollectibleOptionsOpen(true)}
ref={setCollectibleOptionsButtonElement}
/>
{collectibleOptionsOpen ? (
<Menu
anchorElement={collectibleOptionsButtonElement}
onHide={() => setCollectibleOptionsOpen(false)}
>
{onViewOnOpensea ? (
<MenuItem
iconClassName="fas fa-qrcode"
data-testid="collectible-options__view-on-opensea"
onClick={() => {
setCollectibleOptionsOpen(false);
onViewOnOpensea();
}}
>
{t('viewOnOpensea')}
</MenuItem>
) : null}
<MenuItem
iconClassName="fas fa-trash-alt collectible-options__icon"
data-testid="collectible-options__hide"
onClick={() => {
setCollectibleOptionsOpen(false);
onRemove();
}}
>
{t('removeNFT')}
</MenuItem>
</Menu>
) : null}
</>
);
};
CollectibleOptions.propTypes = {
onRemove: PropTypes.func.isRequired,
onViewOnOpensea: PropTypes.func.isRequired,
};
export default CollectibleOptions;

View File

@ -0,0 +1,26 @@
.collectible-details {
display: flex;
flex-direction: column;
@media screen and (min-width: $break-large) {
display: flex;
flex-direction: row;
}
&__address {
overflow-wrap: break-word;
}
}
.collectible-options {
&__button {
font-size: $font-size-paragraph;
color: $Black-100;
background-color: inherit;
padding: 2px 0 2px 8px;
}
&__icon {
font-weight: 900;
}
}

View File

@ -1,5 +1,7 @@
import React, { useState } from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import Box from '../../ui/box';
import Button from '../../ui/button';
import Typography from '../../ui/typography/typography';
@ -18,29 +20,43 @@ import {
import { ENVIRONMENT_TYPE_POPUP } from '../../../../shared/constants/app';
import { useI18nContext } from '../../../hooks/useI18nContext';
import { getEnvironmentType } from '../../../../app/scripts/lib/util';
import { getIpfsGateway } from '../../../selectors';
import { ASSET_ROUTE } from '../../../helpers/constants/routes';
import { getAssetImageURL } from '../../../helpers/utils/util';
export default function CollectiblesItems({ onAddNFT, onRefreshList }) {
const width =
getEnvironmentType() === ENVIRONMENT_TYPE_POPUP
? BLOCK_SIZES.ONE_THIRD
: BLOCK_SIZES.ONE_SIXTH;
export default function CollectiblesItems({
onAddNFT,
onRefreshList,
collections,
useCollectibleDetection,
onEnableAutoDetect,
}) {
const t = useI18nContext();
const collections = {};
const defaultDropdownState = {};
const ipfsGateway = useSelector(getIpfsGateway);
Object.keys(collections).forEach((key) => {
defaultDropdownState[key] = true;
});
const history = useHistory();
const [dropdownState, setDropdownState] = useState(defaultDropdownState);
const width =
getEnvironmentType() === ENVIRONMENT_TYPE_POPUP
? BLOCK_SIZES.ONE_THIRD
: BLOCK_SIZES.ONE_SIXTH;
return (
<div className="collectibles-items">
<Box padding={[4, 6, 4, 6]} flexDirection={FLEX_DIRECTION.COLUMN}>
<>
{Object.keys(collections).map((key, index) => {
const { icon, collectibles } = collections[key];
const isExpanded = dropdownState[key];
const {
collectibles,
collectionName,
collectionImage,
} = collections[key];
const isExpanded = dropdownState[key];
return (
<div key={`collection-${index}`}>
<Box
@ -51,13 +67,20 @@ export default function CollectiblesItems({ onAddNFT, onRefreshList }) {
justifyContent={JUSTIFY_CONTENT.SPACE_BETWEEN}
>
<Box alignItems={ALIGN_ITEMS.CENTER}>
<img width="28" src={icon} />
{collectionImage ? (
<img
style={{ width: '1.5rem', borderRadius: '50%' }}
src={collectionImage}
/>
) : (
<div className="collection-icon">{collectionName[0]}</div>
)}
<Typography
color={COLORS.BLACK}
variant={TYPOGRAPHY.H4}
margin={[0, 0, 0, 2]}
>
{`${key} (${collectibles.length})`}
{`${collectionName} (${collectibles.length})`}
</Typography>
</Box>
<Box alignItems={ALIGN_ITEMS.FLEX_END}>
@ -77,13 +100,30 @@ export default function CollectiblesItems({ onAddNFT, onRefreshList }) {
{isExpanded ? (
<Box display={DISPLAY.FLEX} flexWrap={FLEX_WRAP.WRAP}>
{collectibles.map((collectible, i) => {
const { image, address, tokenId } = collectible;
const collectibleImage = getAssetImageURL(
image,
ipfsGateway,
);
return (
<Box width={width} padding={2} key={`collectible-${i}`}>
<Box width={width} margin={1} key={`collectible-${i}`}>
<Box
borderRadius={SIZES.MD}
backgroundColor={collectible.backgroundColor}
display={DISPLAY.FLEX}
justifyContent={JUSTIFY_CONTENT.CENTER}
padding={2}
width={BLOCK_SIZES.FULL}
>
<img src={collectible.icon} />
<img
onClick={() =>
history.push(
`${ASSET_ROUTE}/${address}/${tokenId}`,
)
}
className="collectibles-items__image"
src={collectibleImage}
/>
</Box>
</Box>
);
@ -109,15 +149,28 @@ export default function CollectiblesItems({ onAddNFT, onRefreshList }) {
alignItems={ALIGN_ITEMS.CENTER}
justifyContent={JUSTIFY_CONTENT.CENTER}
>
<Box justifyContent={JUSTIFY_CONTENT.FLEX_END}>
<Button
type="link"
onClick={onRefreshList}
style={{ padding: '4px' }}
>
{t('refreshList')}
</Button>
</Box>
{' '}
{useCollectibleDetection ? (
<Box justifyContent={JUSTIFY_CONTENT.FLEX_END}>
<Button
type="link"
onClick={onRefreshList}
style={{ padding: '4px', fontSize: '16px' }}
>
{t('refreshList')}
</Button>
</Box>
) : (
<Box justifyContent={JUSTIFY_CONTENT.FLEX_END}>
<Button
type="link"
onClick={onEnableAutoDetect}
style={{ padding: '4px', fontSize: '16px' }}
>
{t('enableAutoDetect')}
</Button>
</Box>
)}
<Typography
color={COLORS.UI3}
variant={TYPOGRAPHY.H4}
@ -129,9 +182,9 @@ export default function CollectiblesItems({ onAddNFT, onRefreshList }) {
<Button
type="link"
onClick={onAddNFT}
style={{ padding: '4px' }}
style={{ padding: '4px', fontSize: '16px' }}
>
{t('addNFTLowerCase')}
{t('importNFTs')}
</Button>
</Box>
</Box>
@ -145,4 +198,7 @@ export default function CollectiblesItems({ onAddNFT, onRefreshList }) {
CollectiblesItems.propTypes = {
onAddNFT: PropTypes.func.isRequired,
onRefreshList: PropTypes.func.isRequired,
collections: PropTypes.array,
useCollectibleDetection: PropTypes.bool.isRequired,
onEnableAutoDetect: PropTypes.func.isRequired,
};

View File

@ -0,0 +1,18 @@
.collectibles-items {
&__image {
border-radius: 0.625rem;
width: 100%;
height: 100%;
}
}
.collection-icon {
border-radius: 50%;
width: 2rem;
height: 2rem;
padding: 0.5rem;
background: $ui-4;
color: $ui-white;
text-align: center;
line-height: 1;
}

View File

@ -1,5 +1,7 @@
import React from 'react';
import PropTypes from 'prop-types';
import { useDispatch, useSelector } from 'react-redux';
import { useHistory } from 'react-router-dom';
import Box from '../../ui/box';
import Button from '../../ui/button';
import Typography from '../../ui/typography/typography';
@ -14,20 +16,49 @@ import {
FONT_WEIGHT,
} from '../../../helpers/constants/design-system';
import { useI18nContext } from '../../../hooks/useI18nContext';
import {
getCollectibles,
getCollectibleContracts,
} from '../../../ducks/metamask/metamask';
import { getUseCollectibleDetection } from '../../../selectors';
import { EXPERIMENTAL_ROUTE } from '../../../helpers/constants/routes';
import { detectCollectibles } from '../../../store/actions';
export default function CollectiblesTab({ onAddNFT }) {
const collectibles = [];
const collectibles = useSelector(getCollectibles);
const collectibleContracts = useSelector(getCollectibleContracts);
const useCollectibleDetection = useSelector(getUseCollectibleDetection);
const history = useHistory();
const newNFTsDetected = false;
const t = useI18nContext();
const collections = {};
const dispatch = useDispatch();
collectibles.forEach((collectible) => {
if (collections[collectible.address]) {
collections[collectible.address].collectibles.push(collectible);
} else {
const collectionContract = collectibleContracts.find(
({ address }) => address === collectible.address,
);
collections[collectible.address] = {
collectionName: collectionContract?.name || collectible.name,
collectionImage:
collectionContract?.logo || collectible.collectionImage,
collectibles: [collectible],
};
}
});
return (
<div className="collectibles-tab">
{collectibles.length > 0 ? (
<CollectiblesItems
collections={collections}
onAddNFT={onAddNFT}
onRefreshList={() => {
console.log('refreshing collectibles');
}}
useCollectibleDetection={useCollectibleDetection}
onRefreshList={() => dispatch(detectCollectibles())}
onEnableAutoDetect={() => history.push(EXPERIMENTAL_ROUTE)}
/>
) : (
<Box padding={[6, 12, 6, 12]}>

View File

@ -169,6 +169,6 @@ Box.propTypes = {
display: PropTypes.oneOf(Object.values(DISPLAY)),
width: PropTypes.oneOf(Object.values(BLOCK_SIZES)),
height: PropTypes.oneOf(Object.values(BLOCK_SIZES)),
backgroundColor: PropTypes.oneOf(Object.values(COLORS)),
backgroundColor: PropTypes.string,
className: PropTypes.string,
};

View File

@ -7,6 +7,7 @@ import {
FONT_STYLE,
TEXT_ALIGN,
TYPOGRAPHY,
OVERFLOW_WRAP,
} from '../../../helpers/constants/design-system';
import Box, { MultipleSizes } from '../box';
@ -22,6 +23,7 @@ export default function Typography({
fontStyle = 'normal',
fontSize,
align,
overflowWrap,
boxProps = {},
margin = [1, 0],
}) {
@ -35,6 +37,7 @@ export default function Typography({
[`typography--align-${align}`]: Boolean(align),
[`typography--color-${color}`]: Boolean(color),
[`typography--size-${fontSize}`]: Boolean(fontSize),
[`typography--overflowwrap-${overflowWrap}`]: Boolean(overflowWrap),
},
);
@ -69,6 +72,7 @@ Typography.propTypes = {
margin: MultipleSizes,
fontWeight: PropTypes.oneOf(Object.values(FONT_WEIGHT)),
fontStyle: PropTypes.oneOf(Object.values(FONT_STYLE)),
overflowWrap: PropTypes.oneOf(Object.values(OVERFLOW_WRAP)),
fontSize: PropTypes.string,
tag: PropTypes.oneOf([
'p',

View File

@ -45,6 +45,12 @@
}
}
@each $overflow in design-system.$overflow-wrap {
&--overflowwrap-#{$overflow} {
overflow-wrap: $overflow;
}
}
@for $i from 1 through 8 {
&--spacing-#{$i} {
margin: #{$i * 4}px auto;

View File

@ -80,6 +80,7 @@ $border-style: solid, double, none, dashed, dotted;
$directions: top, right, bottom, left;
$display: block, grid, flex, inline-block, inline-grid, inline-flex, list-item;
$text-align: left, right, center, justify, end;
$overflow-wrap: normal, break-word;
$font-weight: bold, normal, 100, 200, 300, 400, 500, 600, 700, 800, 900;
$font-style: normal, italic, oblique;
$font-size: 10px, 12px;

View File

@ -256,6 +256,45 @@ export const getUnconnectedAccountAlertShown = (state) =>
export const getTokens = (state) => state.metamask.tokens;
export const getCollectibles = (state) => {
const {
metamask: {
allCollectibles,
provider: { chainId },
selectedAddress,
},
} = state;
let decFormattedChainId;
if (typeof chainId === 'string' && isHexString(chainId)) {
decFormattedChainId = `${parseInt(chainId, 16)}`;
} else if (typeof chainId === 'number') {
decFormattedChainId = `${chainId}`;
}
return allCollectibles?.[selectedAddress]?.[decFormattedChainId] || [];
};
export const getCollectibleContracts = (state) => {
const {
metamask: {
allCollectibleContracts,
provider: { chainId },
selectedAddress,
},
} = state;
let decFormattedChainId;
if (typeof chainId === 'string' && isHexString(chainId)) {
decFormattedChainId = `${parseInt(chainId, 16)}`;
} else if (typeof chainId === 'number') {
decFormattedChainId = `${chainId}`;
}
return (
allCollectibleContracts?.[selectedAddress]?.[decFormattedChainId] || []
);
};
export function getBlockGasLimit(state) {
return state.metamask.currentBlockGasLimit;
}

View File

@ -171,6 +171,11 @@ export const FONT_WEIGHT = {
900: 900,
};
export const OVERFLOW_WRAP = {
BREAK_WORD: 'break-word',
NORMAL: 'normal',
};
export const FONT_STYLE = {
ITALIC: 'italic',
NORMAL: 'normal',

View File

@ -88,7 +88,7 @@ const PATH_NAME_MAP = {
[DEFAULT_ROUTE]: 'Home',
[UNLOCK_ROUTE]: 'Unlock Page',
[LOCK_ROUTE]: 'Lock Page',
[`${ASSET_ROUTE}/:asset`]: `Asset Page`,
[`${ASSET_ROUTE}/:asset/:id`]: `Asset Page`,
[SETTINGS_ROUTE]: 'Settings Page',
[GENERAL_ROUTE]: 'General Settings Page',
[ADVANCED_ROUTE]: 'Advanced Settings Page',

View File

@ -3,6 +3,7 @@ import abi from 'human-standard-token-abi';
import BigNumber from 'bignumber.js';
import * as ethUtil from 'ethereumjs-util';
import { DateTime } from 'luxon';
import { util } from '@metamask/controllers';
import { addHexPrefix } from '../../../app/scripts/lib/util';
import {
GOERLI_CHAIN_ID,
@ -428,3 +429,18 @@ export const toHumanReadableTime = (t, milliseconds) => {
export function clearClipboard() {
window.navigator.clipboard.writeText('');
}
export function getAssetImageURL(image, ipfsGateway) {
let result = image;
if (!image || !ipfsGateway || typeof image !== 'string') {
return '';
}
if (image.startsWith('ipfs://')) {
const contentIdentifier = util.getIpfsUrlContentIdentifier(image);
result = ipfsGateway.endsWith('/')
? ipfsGateway + contentIdentifier
: `${ipfsGateway}/${contentIdentifier}`;
}
return result;
}

View File

@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { useHistory } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { isValidHexAddress } from '@metamask/controllers/dist/util';
import { useI18nContext } from '../../hooks/useI18nContext';
import { DEFAULT_ROUTE } from '../../helpers/constants/routes';
@ -19,6 +20,7 @@ export default function AddCollectible() {
const [address, setAddress] = useState('');
const [tokenId, setTokenId] = useState('');
const [disabled, setDisabled] = useState(true);
const handleAddCollectible = async () => {
try {
@ -33,6 +35,16 @@ export default function AddCollectible() {
history.push(DEFAULT_ROUTE);
};
const validateAndSetAddress = (val) => {
setDisabled(!isValidHexAddress(val) || !tokenId);
setAddress(val);
};
const validateAndSetTokenId = (val) => {
setDisabled(!isValidHexAddress(address) || !val);
setTokenId(val);
};
return (
<PageContainer
title={t('addNFT')}
@ -46,7 +58,7 @@ export default function AddCollectible() {
onClose={() => {
history.push(DEFAULT_ROUTE);
}}
disabled={false}
disabled={disabled}
contentComponent={
<Box padding={4}>
<Box>
@ -56,7 +68,7 @@ export default function AddCollectible() {
placeholder="0x..."
type="text"
value={address}
onChange={(e) => setAddress(e.target.value)}
onChange={(e) => validateAndSetAddress(e.target.value)}
fullWidth
autoFocus
margin="normal"
@ -69,7 +81,7 @@ export default function AddCollectible() {
placeholder={t('nftTokenIdPlaceholder')}
type="number"
value={tokenId}
onChange={(e) => setTokenId(e.target.value)}
onChange={(e) => validateAndSetTokenId(e.target.value)}
fullWidth
margin="normal"
/>

View File

@ -1,7 +1,8 @@
import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { Redirect, useParams } from 'react-router-dom';
import { getTokens } from '../../ducks/metamask/metamask';
import CollectibleDetails from '../../components/app/collectible-details/collectible-details';
import { getCollectibles, getTokens } from '../../ducks/metamask/metamask';
import { DEFAULT_ROUTE } from '../../helpers/constants/routes';
import { isEqualCaseInsensitive } from '../../helpers/utils/util';
@ -11,12 +12,18 @@ import TokenAsset from './components/token-asset';
const Asset = () => {
const nativeCurrency = useSelector((state) => state.metamask.nativeCurrency);
const tokens = useSelector(getTokens);
const { asset } = useParams();
const collectibles = useSelector(getCollectibles);
const { asset, id } = useParams();
const token = tokens.find(({ address }) =>
isEqualCaseInsensitive(address, asset),
);
const collectible = collectibles.find(
({ address, tokenId }) =>
isEqualCaseInsensitive(address, asset) && id === tokenId,
);
useEffect(() => {
const el = document.querySelector('.app');
el.scroll(0, 0);
@ -25,6 +32,8 @@ const Asset = () => {
let content;
if (token) {
content = <TokenAsset token={token} />;
} else if (collectible) {
content = <CollectibleDetails collectible={collectible} />;
} else if (asset === nativeCurrency) {
content = <NativeAsset nativeCurrency={nativeCurrency} />;
} else {

View File

@ -184,7 +184,8 @@ export default class Routes extends Component {
path={`${CONNECT_ROUTE}/:id`}
component={PermissionsConnect}
/>
<Authenticated path={`${ASSET_ROUTE}/:asset`} component={Asset} />
<Authenticated path={`${ASSET_ROUTE}/:asset/:id`} component={Asset} />
<Authenticated path={`${ASSET_ROUTE}/:asset/`} component={Asset} />
<Authenticated path={DEFAULT_ROUTE} component={Home} />
</Switch>
);

View File

@ -566,10 +566,7 @@ export default class AdvancedTab extends PureComponent {
}
handleIpfsGatewaySave() {
const url = new URL(addUrlProtocolPrefix(this.state.ipfsGateway));
const { host } = url;
this.props.setIpfsGateway(host);
this.props.setIpfsGateway(this.state.ipfsGateway);
}
renderIpfsGatewayControl() {

View File

@ -2203,6 +2203,16 @@ export function setOpenSeaEnabled(val) {
};
}
export function detectCollectibles() {
return async (dispatch) => {
dispatch(showLoadingIndication());
log.debug(`background.detectCollectibles`);
await promisifiedBackground.detectCollectibles();
dispatch(hideLoadingIndication());
await forceUpdateMetamaskState(dispatch);
};
}
export function setAdvancedGasFee(val) {
return (dispatch) => {
dispatch(showLoadingIndication());

View File

@ -2601,19 +2601,19 @@
semver "^7.3.5"
yargs "^17.0.1"
"@metamask/contract-metadata@^1.19.0", "@metamask/contract-metadata@^1.28.0", "@metamask/contract-metadata@^1.30.0":
version "1.30.0"
resolved "https://registry.yarnpkg.com/@metamask/contract-metadata/-/contract-metadata-1.30.0.tgz#fa8e1b0c3e7aaa963986088f691fb553ffbe3904"
integrity sha512-b2usYW/ptQYnE6zhUmr4T+nvOAQJK5ABcpKudyQANpy4K099elpv4aN0WcrcOcwV99NHOdMzFP3ZuG0HoAyOBQ==
"@metamask/contract-metadata@^1.19.0", "@metamask/contract-metadata@^1.28.0", "@metamask/contract-metadata@^1.31.0":
version "1.31.0"
resolved "https://registry.yarnpkg.com/@metamask/contract-metadata/-/contract-metadata-1.31.0.tgz#9e3e46de7a955ea1ca61f7db20d9a17b5e91d3d0"
integrity sha512-4FBJkg/vDiYp/thIiZknxrJ0lfsj2eWIPenwlNZmoqOhoL4VqhK5eKWxi+EuGMvv9taP+QBRk6Key7wC1uL78A==
"@metamask/controllers@^20.1.0":
version "20.1.0"
resolved "https://registry.yarnpkg.com/@metamask/controllers/-/controllers-20.1.0.tgz#1d8386dc22d14f9fd9feb8b3cc8314d663587550"
integrity sha512-Z/7uLGXZWbCBbtCybR3jo1bx3mcvZRUSm1i43od4dnJoQo2+Veq4ePrFVgPKS3WtLIM/hzZuI7UTAQ9HNX9aew==
"@metamask/controllers@^21.0.1":
version "21.0.1"
resolved "https://registry.yarnpkg.com/@metamask/controllers/-/controllers-21.0.1.tgz#f7776a448afd3869dce76ecb34549e867fa78ec4"
integrity sha512-E8JLRlTC7jyUgJSaXgFYa3g5pt5NCtA87hyarZFoolNKL6yrQgLHxqRyfGV28TsNsawl1i++rxb/8rgAJSRPHw==
dependencies:
"@ethereumjs/common" "^2.3.1"
"@ethereumjs/tx" "^3.2.1"
"@metamask/contract-metadata" "^1.30.0"
"@metamask/contract-metadata" "^1.31.0"
"@types/uuid" "^8.3.0"
abort-controller "^3.0.0"
async-mutex "^0.2.6"
@ -2632,7 +2632,7 @@
ethjs-unit "^0.1.6"
ethjs-util "^0.1.6"
human-standard-collectible-abi "^1.0.2"
human-standard-multi-collectible-abi "^1.0.2"
human-standard-multi-collectible-abi "^1.0.4"
human-standard-token-abi "^2.0.0"
immer "^9.0.6"
isomorphic-fetch "^3.0.0"
@ -14501,10 +14501,10 @@ human-standard-collectible-abi@^1.0.2:
resolved "https://registry.yarnpkg.com/human-standard-collectible-abi/-/human-standard-collectible-abi-1.0.2.tgz#077bae9ed1b0b0b82bc46932104b4b499c941aa0"
integrity sha512-nD3ITUuSAIBgkaCm9J2BGwlHL8iEzFjJfTleDAC5Wi8RBJEXXhxV0JeJjd95o+rTwf98uTE5MW+VoBKOIYQh0g==
human-standard-multi-collectible-abi@^1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/human-standard-multi-collectible-abi/-/human-standard-multi-collectible-abi-1.0.3.tgz#be5896b13f8622289cff70040e478366931bf3d7"
integrity sha512-1VXqats7JQqDZozLKhpmFG0S33hVePrkLNRJNKfJTxewR0heYKjSoz72kqs+6O/Tywi0zW4fWe7dfTaPX4j7gQ==
human-standard-multi-collectible-abi@^1.0.4:
version "1.0.4"
resolved "https://registry.yarnpkg.com/human-standard-multi-collectible-abi/-/human-standard-multi-collectible-abi-1.0.4.tgz#981625bc1a6bea5fef90567f9e12c11581fac497"
integrity sha512-ylR9JDXClDJAxWD/QJxsjXJJdLTUmhipTquMAgrfybXL3qX3x3P/vmKg92A7qFu7SqVOf2hyv5dA8vX0j+0Thg==
human-standard-token-abi@^1.0.2:
version "1.0.2"