(Feature) erc721 feature implementation (#570)

* Add Assets sections

* (add) collectibles tab

* (add) criptokitty items

* (add) collectible items, definitive edition

* (fix) collectibles were overlapping with bottom banner

* (fix) wording

* (fix) responsive issues

* Install `async-sema` dependency

* Create collectible source classes

- source from mocked data and opensea, it's extendable to import information from other sources

* Update `Collectible` implementation to use new data source

* Create constants file to better handle env variables and default values

* Add description to item's cards

- also added a mocked class with real data

* Fix `saveTxToHistory`, remove hardcoded `CALL`

* Fix after merge development

* Set background color for collectible based on data info

- Changed `withStyles` in favor of a hook-like approach with `makeStyles`

* Enhance collectible card info and group title

* Use current safeAddress to query for collectibles information

- also migrated from `withStyles` to `makeStyles`

* Use proper key values for lists and set more significant names

* update yarn.lock after merge

* Fix linting error

* Move ethAsToken verification outside loop

* Use absolute route for `SendModal` import

* Move Collectibles into redux store

* Update yarn.lock file

* Selectable NFTs

* Divide the `collectible` store into `nftAssets` and `nftTokens`

- Also updated components to retain functionality
- Created a `textShortener` function for better presentation

* Update `yarn.lock`

* Update `yarn.lock`

* Fix item background color

* Clears the tokenID select field when the collectible selected changes

* Open Send modal from the assets section

* Use token name for the token selection dropdown

* Add openZeppelin contracts dependency

* Create ERC721 getter

* Fix types, default values and clean code

* Fix: properly refresh list of collectibles when switching safes

* Add ReviewCollectible step in send NFT

* Change items shadow

* Give option to choose what to send by clicking 'Send' button in AddressBook

* Disable [Send] button for Collectibles if not owner

* Set Coins as default option in assets tab

- also fixed styles for `Coins` option

* Use collectible icon in send modal

* Set default message when no assets available

- removed pagination feature

* Create SafeVersionProvider to better handle version-related tasks

Provides:
- current and latest versions,
- a boolean indicating a need for update,
- an upgradeSafe callback to trigger upgrade from any place,
- a list of enabled features, depending on the current version
  - the latter needs a refactor like extract features outside the provider
   and define constants for the features.

* Force build

* Update `yarn.lock`

* Disable Manage list for NFTs

* Fix container shadow

- Also fixes tables shadow, thanks to @gabitoesmiapodo

* Enable nested routes for balances (assets) tab

* Default to `/balance` if invalid nested path

* Disable [Send Collectible] button, if not supported by safe

* Change sub-menu buttons to clickable text

* Replace Paragraph with Link

* Fix invalid props errors for Link component

* Fallback to `transferFrom` if `safeTransferFrom` is not implemented

* Use `transfer` as fallback to ERC-721's `safeTransferFrom`

- need to identify ERC721 token using `transfer` and `name` methods

* Display failed transactions

* Use react.lazy for collectibles' modals

* Identify ERC-721 token transaction

* Fix Send Collectibles modal layout/behavior

- disable dropdown list if there's no item to pick
- fix placeholder for tokens list
- fix dropdown list styles

* Set default `isSuccessful` flag to `true`

* Save version related values into store

- each safe has its `currentVersion`, `needsUpdate` and `featuresEnabled`
- and safes store has the `latestMasterContractVersion`

* Migrate Balance to use store-provided values

* Migrate Settings to use store-provided values

* Migrate ChooseTxType to use store-provided values

* Remove SafeVersionProvider

Co-authored-by: Gabriel Rodriguez Alsina <gabriel.rodriguez@altoros.com>
Co-authored-by: apane <agustin.pane@gmail.com>
This commit is contained in:
Fernando 2020-03-18 17:24:24 -03:00 committed by GitHub
parent 497b672633
commit 5359794e21
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
62 changed files with 3496 additions and 436 deletions

35
flow-typed/npm/async-sema_vx.x.x.js vendored Normal file
View File

@ -0,0 +1,35 @@
// flow-typed signature: 8bfc0eb0795000bed1976434e91400ae
// flow-typed version: <<STUB>>/async-sema_v3.1.0/flow_v0.114.0
/**
* This is an autogenerated libdef stub for:
*
* 'async-sema'
*
* Fill this stub out by replacing all the `any` types.
*
* Once filled out, we encourage you to share your work with the
* community by sending a pull request to:
* https://github.com/flowtype/flow-typed
*/
declare module 'async-sema' {
declare module.exports: any;
}
/**
* We include stubs for each file inside this npm package in case you need to
* require those files directly. Feel free to delete any files that aren't
* needed.
*/
declare module 'async-sema/lib' {
declare module.exports: any;
}
// Filename aliases
declare module 'async-sema/lib/index' {
declare module.exports: $Exports<'async-sema/lib'>;
}
declare module 'async-sema/lib/index.js' {
declare module.exports: $Exports<'async-sema/lib'>;
}

View File

@ -46,8 +46,10 @@
"@material-ui/core": "4.9.5", "@material-ui/core": "4.9.5",
"@material-ui/icons": "4.9.1", "@material-ui/icons": "4.9.1",
"@material-ui/lab": "4.0.0-alpha.39", "@material-ui/lab": "4.0.0-alpha.39",
"@openzeppelin/contracts": "^2.5.0",
"@testing-library/jest-dom": "5.1.1", "@testing-library/jest-dom": "5.1.1",
"@welldone-software/why-did-you-render": "4.0.5", "@welldone-software/why-did-you-render": "4.0.5",
"async-sema": "^3.1.0",
"axios": "0.19.2", "axios": "0.19.2",
"bignumber.js": "9.0.0", "bignumber.js": "9.0.0",
"bnc-onboard": "1.3.5", "bnc-onboard": "1.3.5",

View File

@ -2,7 +2,7 @@
import IconButton from '@material-ui/core/IconButton' import IconButton from '@material-ui/core/IconButton'
import { createStyles, makeStyles } from '@material-ui/core/styles' import { createStyles, makeStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close' import Close from '@material-ui/icons/Close'
import React from 'react' import React, { type Node } from 'react'
import styled from 'styled-components' import styled from 'styled-components'
import Modal from '~/components/Modal' import Modal from '~/components/Modal'
@ -40,8 +40,8 @@ const useStyles = makeStyles(() =>
type Props = { type Props = {
title: string, title: string,
body: React.Node, body: Node,
footer: React.Node, footer: Node,
onClose: () => void, onClose: () => void,
} }

View File

@ -30,6 +30,7 @@ const useStyles = makeStyles({
padding: '27px 15px', padding: '27px 15px',
position: 'fixed', position: 'fixed',
width: '100%', width: '100%',
zIndex: '5',
}, },
content: { content: {
maxWidth: '100%', maxWidth: '100%',

View File

@ -0,0 +1,26 @@
// @flow
import React from 'react'
import { OnChange } from 'react-final-form-listeners'
import GnoField from '~/components/forms/Field'
type Props = {
field: string,
set: string,
to: string | number | null,
}
const WhenFieldChanges = ({ field, set, to }: Props) => (
<GnoField name={set} subscription={{}}>
{(
// No subscription. We only use Field to get to the change function
{ input: { onChange } },
) => (
<OnChange name={field}>
{() => {
onChange(to)
}}
</OnChange>
)}
</GnoField>
)
export default WhenFieldChanges

View File

@ -0,0 +1,12 @@
// @flow
import OpenSea from '~/logic/collectibles/sources/OpenSea'
import mockedOpenSea from '~/logic/collectibles/sources/mocked_opensea'
class MockedOpenSea extends OpenSea {
_fetch = async () => {
await this._rateLimit()
return { json: () => mockedOpenSea }
}
}
export default MockedOpenSea

View File

@ -0,0 +1,108 @@
// @flow
import { RateLimit } from 'async-sema'
import type {
CollectibleMetadataSource,
CollectiblesInfo,
NFTAsset,
NFTAssets,
NFTToken,
OpenSeaAsset,
} from '~/routes/safe/components/Balances/Collectibles/types'
import NFTIcon from '~/routes/safe/components/Balances/assets/nft_icon.png'
import { OPENSEA_API_KEY } from '~/utils/constants'
class OpenSea implements CollectibleMetadataSource {
_rateLimit = async () => {}
_endpointsUrls: { [key: number]: string } = {
// $FlowFixMe
1: 'https://api.opensea.io/api/v1',
// $FlowFixMe
4: 'https://rinkeby-api.opensea.io/api/v1',
}
_fetch = async (url: string) => {
// eslint-disable-next-line no-underscore-dangle
return fetch(url, {
headers: { 'X-API-KEY': OPENSEA_API_KEY || '' },
})
}
/**
* OpenSea class constructor
* @param {object} options
* @param {number} options.rps - requests per second
*/
constructor(options: { rps: number }) {
// eslint-disable-next-line no-underscore-dangle
this._rateLimit = RateLimit(options.rps, { timeUnit: 60 * 1000, uniformDistribution: true })
}
static extractAssets(assets: OpenSeaAsset[]): NFTAssets {
const extractNFTAsset = (asset): NFTAsset => ({
address: asset.asset_contract.address,
assetContract: asset.asset_contract,
collection: asset.collection,
description: asset.asset_contract.name,
image: asset.asset_contract.image_url || NFTIcon,
name: asset.collection.name,
numberOfTokens: 1,
slug: asset.collection.slug,
symbol: asset.asset_contract.symbol,
})
return assets.reduce((acc: NFTAssets, asset: OpenSeaAsset) => {
const address = asset.asset_contract.address
if (acc[address] === undefined) {
acc[address] = extractNFTAsset(asset)
} else {
// By default, extractNFTAsset sets `numberOfTokens` to 1,
// counting the asset recently processed.
// If it happens to already exist the asset in the map,
// then we just increment the `numberOfTokens` value by 1.
acc[address].numberOfTokens = acc[address].numberOfTokens + 1
}
return acc
}, {})
}
static extractTokens(assets: OpenSeaAsset[]): NFTToken[] {
return assets.map((asset: OpenSeaAsset): NFTToken => ({
assetAddress: asset.asset_contract.address,
color: asset.background_color,
description: asset.description,
image: asset.image_thumbnail_url || NFTIcon,
name: asset.name || `${asset.asset_contract.name} - #${asset.token_id}`,
tokenId: asset.token_id,
}))
}
static extractCollectiblesInfo(assetResponseJson: { assets: OpenSeaAsset[] }): CollectiblesInfo {
return {
nftAssets: OpenSea.extractAssets(assetResponseJson.assets),
nftTokens: OpenSea.extractTokens(assetResponseJson.assets),
}
}
/**
* Fetches from OpenSea the list of collectibles, grouped by category,
* for the provided Safe Address in the specified Network
* @param {string} safeAddress
* @param {number} networkId
* @returns {Promise<{ nftAssets: Map<string, NFTAsset>, nftTokens: Array<NFTToken> }>}
*/
async fetchAllUserCollectiblesByCategoryAsync(safeAddress: string, networkId: number) {
// eslint-disable-next-line no-underscore-dangle
const metadataSourceUrl = this._endpointsUrls[networkId]
const url = `${metadataSourceUrl}/assets/?owner=${safeAddress}`
// eslint-disable-next-line no-underscore-dangle
const assetsResponse = await this._fetch(url)
const assetsResponseJson = await assetsResponse.json()
return OpenSea.extractCollectiblesInfo(assetsResponseJson)
}
}
export default OpenSea

View File

@ -0,0 +1,12 @@
// @flow
import MockedOpenSea from '~/logic/collectibles/sources/MockedOpenSea'
import OpenSea from '~/logic/collectibles/sources/OpenSea'
import type { CollectibleMetadataSource } from '~/routes/safe/components/Balances/Collectibles/types'
import { COLLECTIBLES_SOURCE } from '~/utils/constants'
const sources: { [key: string]: CollectibleMetadataSource } = {
opensea: new OpenSea({ rps: 4 }),
mockedopensea: new MockedOpenSea({ rps: 4 }),
}
export const getConfiguredSource = () => sources[COLLECTIBLES_SOURCE.toLowerCase()]

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,15 @@
// @flow
import { createAction } from 'redux-actions'
import type { NFTAssets, NFTToken } from '~/routes/safe/components/Balances/Collectibles/types'
export const ADD_NFT_ASSETS = 'ADD_NFT_ASSETS'
export const ADD_NFT_TOKENS = 'ADD_NFT_TOKENS'
export const addNftAssets = createAction<string, *, *>(ADD_NFT_ASSETS, (nftAssets: NFTAssets) => ({
nftAssets,
}))
export const addNftTokens = createAction<string, *, *>(ADD_NFT_TOKENS, (nftTokens: NFTToken[]) => ({
nftTokens,
}))

View File

@ -0,0 +1,21 @@
// @flow
import type { Dispatch } from 'redux'
import { getConfiguredSource } from '~/logic/collectibles/sources'
import { addNftAssets, addNftTokens } from '~/logic/collectibles/store/actions/addCollectibles'
import { PROVIDER_REDUCER_ID } from '~/logic/wallets/store/reducer/provider'
import { safeParamAddressFromStateSelector } from '~/routes/safe/store/selectors'
import type { GlobalState } from '~/store'
const fetchCollectibles = () => async (dispatch: Dispatch<GlobalState>, getState) => {
const state = getState()
const { network } = state[PROVIDER_REDUCER_ID]
const safeAddress = safeParamAddressFromStateSelector(state)
const source = getConfiguredSource()
const collectibles = await source.fetchAllUserCollectiblesByCategoryAsync(safeAddress, network)
dispatch(addNftAssets(collectibles.nftAssets))
dispatch(addNftTokens(collectibles.nftTokens))
}
export default fetchCollectibles

View File

@ -0,0 +1,33 @@
// @flow
import { type ActionType, handleActions } from 'redux-actions'
import { ADD_NFT_ASSETS, ADD_NFT_TOKENS } from '~/logic/collectibles/store/actions/addCollectibles'
import type { NFTAssets, NFTToken } from '~/routes/safe/components/Balances/Collectibles/types'
export const NFT_ASSETS_REDUCER_ID = 'nftAssets'
export const NFT_TOKENS_REDUCER_ID = 'nftTokens'
export type NFTAssetsState = NFTAssets | {}
export type NFTTokensState = NFTToken[]
export const nftAssetReducer = handleActions<NFTAssetsState, *>(
{
[ADD_NFT_ASSETS]: (state: NFTAssetsState, action: ActionType<Function>): NFTAssetsState => {
const { nftAssets } = action.payload
return nftAssets
},
},
{},
)
export const nftTokensReducer = handleActions<NFTTokensState, *>(
{
[ADD_NFT_TOKENS]: (state: NFTTokensState, action: ActionType<Function>): NFTTokensState => {
const { nftTokens } = action.payload
return nftTokens
},
},
[],
)

View File

@ -0,0 +1,6 @@
// @flow
import { NFT_ASSETS_REDUCER_ID, NFT_TOKENS_REDUCER_ID } from '~/logic/collectibles/store/reducer/collectibles'
import type { GlobalState } from '~/store'
export const nftAssetsSelector = (state: GlobalState) => state[NFT_ASSETS_REDUCER_ID]
export const nftTokensSelector = (state: GlobalState) => state[NFT_TOKENS_REDUCER_ID]

View File

@ -1,22 +1,50 @@
// @flow // @flow
import semverLessThan from 'semver/functions/lt' import semverLessThan from 'semver/functions/lt'
import semverSatisfies from 'semver/functions/satisfies'
import semverValid from 'semver/functions/valid' import semverValid from 'semver/functions/valid'
import { getSafeLastVersion } from '~/config' import { getSafeLastVersion } from '~/config'
import { getGnosisSafeInstanceAt, getSafeMasterContract } from '~/logic/contracts/safeContracts' import { getGnosisSafeInstanceAt, getSafeMasterContract } from '~/logic/contracts/safeContracts'
export const FEATURES = [
{ name: 'ERC721', validVersion: '>=1.1.1' },
{ name: 'ERC1155', validVersion: '>=1.1.1' },
]
export const safeNeedsUpdate = (currentVersion: string, latestVersion: string) => {
if (!currentVersion || !latestVersion) {
return false
}
const current = semverValid(currentVersion)
const latest = semverValid(latestVersion)
return latest ? semverLessThan(current, latest) : false
}
export const getCurrentSafeVersion = gnosisSafeInstance => gnosisSafeInstance.VERSION()
export const enabledFeatures = (version: string) =>
FEATURES.reduce((acc, feature) => {
if (semverSatisfies(version, feature.validVersion)) {
acc.push(feature.name)
}
return acc
}, [])
export const checkIfSafeNeedsUpdate = async (gnosisSafeInstance, lastSafeVersion) => { export const checkIfSafeNeedsUpdate = async (gnosisSafeInstance, lastSafeVersion) => {
if (!gnosisSafeInstance || !lastSafeVersion) { if (!gnosisSafeInstance || !lastSafeVersion) {
return null return null
} }
const safeMasterVersion = await gnosisSafeInstance.VERSION() const safeMasterVersion = await getCurrentSafeVersion(gnosisSafeInstance)
const current = semverValid(safeMasterVersion) const current = semverValid(safeMasterVersion)
const latest = semverValid(lastSafeVersion) const latest = semverValid(lastSafeVersion)
const needUpdate = latest ? semverLessThan(current, latest) : false const needUpdate = safeNeedsUpdate(safeMasterVersion, lastSafeVersion)
return { current, latest, needUpdate } return { current, latest, needUpdate }
} }
const getCurrentMasterContractLastVersion = async () => { export const getCurrentMasterContractLastVersion = async () => {
const safeMaster = await getSafeMasterContract() const safeMaster = await getSafeMasterContract()
let safeMasterVersion let safeMasterVersion
try { try {

View File

@ -1,6 +1,7 @@
// @flow // @flow
import StandardToken from '@gnosis.pm/util-contracts/build/contracts/GnosisStandardToken.json' import StandardToken from '@gnosis.pm/util-contracts/build/contracts/GnosisStandardToken.json'
import HumanFriendlyToken from '@gnosis.pm/util-contracts/build/contracts/HumanFriendlyToken.json' import HumanFriendlyToken from '@gnosis.pm/util-contracts/build/contracts/HumanFriendlyToken.json'
import ERC721 from '@openzeppelin/contracts/build/contracts/ERC721'
import { List } from 'immutable' import { List } from 'immutable'
import type { Dispatch as ReduxDispatch } from 'redux' import type { Dispatch as ReduxDispatch } from 'redux'
import contract from 'truffle-contract' import contract from 'truffle-contract'
@ -28,10 +29,27 @@ const createHumanFriendlyTokenContract = async () => {
return humanErc20Token return humanErc20Token
} }
const createERC721TokenContract = async () => {
const web3 = getWeb3()
const erc721Token = await contract(ERC721)
erc721Token.setProvider(web3.currentProvider)
return erc721Token
}
export const getHumanFriendlyToken = ensureOnce(createHumanFriendlyTokenContract) export const getHumanFriendlyToken = ensureOnce(createHumanFriendlyTokenContract)
export const getStandardTokenContract = ensureOnce(createStandardTokenContract) export const getStandardTokenContract = ensureOnce(createStandardTokenContract)
export const getERC721TokenContract = ensureOnce(createERC721TokenContract)
export const containsMethodByHash = async (contractAddress: string, methodHash: string) => {
const web3 = getWeb3()
const byteCode = await web3.eth.getCode(contractAddress)
return byteCode.indexOf(methodHash.replace('0x', '')) !== -1
}
export const fetchTokens = () => async (dispatch: ReduxDispatch<GlobalState>) => { export const fetchTokens = () => async (dispatch: ReduxDispatch<GlobalState>) => {
try { try {
const { const {

View File

@ -222,7 +222,7 @@ const AddressBookTable = ({ classes }: Props) => {
onClose={() => setDeleteEntryModalOpen(false)} onClose={() => setDeleteEntryModalOpen(false)}
/> />
<SendModal <SendModal
activeScreenType="sendFunds" activeScreenType="chooseTxType"
ethBalance={ethBalance} ethBalance={ethBalance}
isOpen={sendFundsModalOpen} isOpen={sendFundsModalOpen}
onClose={() => setSendFundsModalOpen(false)} onClose={() => setSendFundsModalOpen(false)}

View File

@ -0,0 +1,140 @@
// @flow
import { makeStyles } from '@material-ui/core/styles'
import CallMade from '@material-ui/icons/CallMade'
import cn from 'classnames'
import * as React from 'react'
import { useSelector } from 'react-redux'
import Button from '~/components/layout/Button'
import type { NFTToken } from '~/routes/safe/components/Balances/Collectibles/types'
import { grantedSelector } from '~/routes/safe/container/selector'
import { fontColor, sm, xs } from '~/theme/variables'
const useStyles = makeStyles({
item: {
backgroundColor: '#fff',
borderRadius: '8px',
boxShadow: '0 0 10px 0 rgba(33, 48, 77, 0.10)',
boxSizing: 'border-box',
cursor: props => (props.granted ? 'pointer' : 'default'),
display: 'flex',
flexDirection: 'column',
flexGrow: '1',
minHeight: '250px',
minWidth: '0',
position: 'relative',
'&:hover .showOnHover': {
opacity: '1',
},
'&:active .showOnHover': {
opacity: '1',
},
},
mainContent: {
display: 'flex',
flexDirection: 'column',
flexGrow: '1',
position: 'relative',
zIndex: '1',
},
extraContent: {
alignItems: 'center',
backgroundColor: 'rgba(255, 243, 226, 0.6)',
bottom: '0',
cursor: 'pointer',
display: 'flex',
justifyContent: 'center',
left: '0',
opacity: '0',
position: 'absolute',
right: '0',
top: '0',
transition: 'opacity 0.15s ease-out',
zIndex: '5',
},
image: {
backgroundColor: props => `#${props.backgroundColor}` || '#f0efee',
backgroundPosition: '50% 50%',
backgroundRepeat: 'no-repeat',
backgroundSize: 'contain',
borderRadius: '8px',
height: '178px',
flexGrow: '1',
width: '100%',
},
textContainer: {
boxSizing: 'border-box',
color: fontColor,
flexShrink: '0',
fontSize: '12px',
lineHeight: '1.4',
padding: '15px 22px 20px',
},
title: {
fontWeight: 'bold',
margin: '0',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
text: {
margin: '0',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
},
buttonIcon: {
fontSize: 16,
marginRight: sm,
},
sendButton: {
borderRadius: xs,
minWidth: '85px',
'& > span': {
fontSize: '14px',
},
},
})
type Props = {
data: NFTToken,
onSend: Function,
}
const Item = ({ data, onSend }: Props) => {
const granted = useSelector(grantedSelector)
const classes = useStyles({ backgroundColor: data.color, granted })
return (
<div className={classes.item}>
<div className={classes.mainContent}>
<div className={classes.image} style={{ backgroundImage: `url(${data.image})` }} />
<div className={classes.textContainer}>
{data.name && (
<h3 className={classes.title} title={data.name}>
{data.name}
</h3>
)}
{data.description && (
<p className={classes.text} title={data.description}>
{data.description}
</p>
)}
</div>
</div>
{granted && (
<div className={cn(classes.extraContent, 'showOnHover')}>
<Button className={classes.sendButton} color="primary" onClick={onSend} size="small" variant="contained">
<CallMade alt="Send" className={classes.buttonIcon} /> Send
</Button>
</div>
)}
</div>
)
}
export default Item

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -0,0 +1,138 @@
// @flow
import Card from '@material-ui/core/Card'
import { makeStyles } from '@material-ui/core/styles'
import React from 'react'
import { useSelector } from 'react-redux'
import Item from './components/Item'
import Paragraph from '~/components/layout/Paragraph'
import type { NFTAssetsState, NFTTokensState } from '~/logic/collectibles/store/reducer/collectibles'
import { nftAssetsSelector, nftTokensSelector } from '~/logic/collectibles/store/selectors'
import SendModal from '~/routes/safe/components/Balances/SendModal'
import { safeSelector } from '~/routes/safe/store/selectors'
import { fontColor, lg, screenSm, screenXs } from '~/theme/variables'
const useStyles = makeStyles({
cardInner: {
boxSizing: 'border-box',
maxWidth: '100%',
padding: '52px 54px',
},
cardOuter: {
boxShadow: '1px 2px 10px 0 rgba(212, 212, 211, 0.59)',
},
gridRow: {
boxSizing: 'border-box',
columnGap: '30px',
display: 'grid',
gridTemplateColumns: '1fr',
marginBottom: '45px',
maxWidth: '100%',
rowGap: '45px',
'&:last-child': {
marginBottom: '0',
},
[`@media (min-width: ${screenXs}px)`]: {
gridTemplateColumns: '1fr 1fr',
},
[`@media (min-width: ${screenSm}px)`]: {
gridTemplateColumns: '1fr 1fr 1fr 1fr',
},
},
title: {
alignItems: 'center',
display: 'flex',
margin: '0 0 18px',
},
titleImg: {
backgroundPosition: '50% 50%',
backgroundRepeat: 'no-repeat',
backgroundSize: 'contain',
borderRadius: '50%',
height: '45px',
margin: '0 10px 0 0',
width: '45px',
},
titleText: {
color: fontColor,
fontSize: '18px',
fontWeight: 'normal',
lineHeight: '1.2',
margin: '0',
},
titleFiller: {
backgroundColor: '#e8e7e6',
flexGrow: '1',
height: '2px',
marginLeft: '40px',
},
noData: {
fontSize: lg,
textAlign: 'center',
},
})
const Collectibles = () => {
const classes = useStyles()
const [selectedToken, setSelectedToken] = React.useState({})
const [sendNFTsModalOpen, setSendNFTsModalOpen] = React.useState(false)
const { address, ethBalance, name } = useSelector(safeSelector)
const nftAssets: NFTAssetsState = useSelector(nftAssetsSelector)
const nftTokens: NFTTokensState = useSelector(nftTokensSelector)
const nftAssetsKeys = Object.keys(nftAssets)
const handleItemSend = nftToken => {
setSelectedToken(nftToken)
setSendNFTsModalOpen(true)
}
return (
<Card className={classes.cardOuter}>
<div className={classes.cardInner}>
{nftAssetsKeys.length ? (
nftAssetsKeys.map(assetAddress => {
const nftAsset = nftAssets[assetAddress]
return (
<React.Fragment key={nftAsset.slug}>
<div className={classes.title}>
<div className={classes.titleImg} style={{ backgroundImage: `url(${nftAsset.image || ''})` }} />
<h2 className={classes.titleText}>{nftAsset.name}</h2>
<div className={classes.titleFiller} />
</div>
<div className={classes.gridRow}>
{nftTokens
.filter(({ assetAddress }) => nftAsset.address === assetAddress)
.map(nftToken => (
<Item
data={nftToken}
key={`${nftAsset.slug}_${nftToken.tokenId}`}
onSend={() => handleItemSend(nftToken)}
/>
))}
</div>
</React.Fragment>
)
})
) : (
<Paragraph className={classes.noData}>No collectibles available</Paragraph>
)}
</div>
<SendModal
activeScreenType="sendCollectible"
ethBalance={ethBalance}
isOpen={sendNFTsModalOpen}
onClose={() => setSendNFTsModalOpen(false)}
safeAddress={address}
safeName={name}
selectedToken={selectedToken}
/>
</Card>
)
}
export default Collectibles

View File

@ -0,0 +1,209 @@
// @flow
export type AssetContractType = 'fungible' | 'non-fungible' | 'semi-fungible' | 'unknown'
export type CollectibleContract = {
address: string,
asset_contract_type: AssetContractType,
created_date: string,
name: string,
nft_version: string,
opensea_version: ?string,
owner: ?number,
schema_name: string,
symbol: string,
total_supply: ?string,
description: ?string,
external_link: ?string,
image_url: ?string,
default_to_fiat: boolean,
dev_buyer_fee_basis_points: number,
dev_seller_fee_basis_points: number,
only_proxied_transfers: boolean,
opensea_buyer_fee_basis_points: number,
opensea_seller_fee_basis_points: number,
buyer_fee_basis_points: number,
seller_fee_basis_points: number,
payout_address: ?string,
}
export type Range = {
min: number,
max: number,
}
export type Traits = {
cooldown_index: Range,
generation: Range,
fancy_ranking: Range,
}
export type Stats = {
seven_day_volume: number,
seven_day_change: number,
total_volume: number,
count: number,
num_owners: number,
market_cap: number,
average_price: number,
items_sold: number,
}
export type DisplayData = {
card_display_style?: ?string,
images: ?(string[]),
}
export type OpenSeaCollection = {
primary_asset_contracts?: CollectibleContract[],
traits?: Traits | {},
stats?: Stats,
banner_image_url: ?string,
chat_url: ?string,
created_date: string,
default_to_fiat: boolean,
description: ?string,
dev_buyer_fee_basis_points: string,
dev_seller_fee_basis_points: string,
display_data: DisplayData,
external_url: ?string,
featured: boolean,
featured_image_url: ?string,
hidden: boolean,
image_url: ?string,
is_subject_to_whitelist: boolean,
large_image_url: ?string,
name: string,
only_proxied_transfers: boolean,
opensea_buyer_fee_basis_points: string,
opensea_seller_fee_basis_points: string,
payout_address: ?string,
require_email: boolean,
short_description: ?string,
slug: string,
wiki_url: ?string,
owned_asset_count?: number,
}
export type OpenSeaAccount = {
user: ?number | string,
profile_img_url: string,
address: string,
config: string,
discord_id: string,
}
export type OpenSeaTransaction = {
id: number,
from_account: OpenSeaAccount,
to_account: OpenSeaAccount,
created_date: string,
modified_date: string,
transaction_hash: string,
transaction_index: string,
block_number: string,
block_hash: string,
timestamp: string,
}
export type OpenSeaToken = {
symbol: string,
address: string,
image_url: ?string,
name: string,
decimals: number,
eth_price: string,
usd_price: string,
}
export type OpenSeaSale = {
event_type: string,
auction_type: ?string,
total_price: string,
transaction: OpenSeaTransaction,
payment_token: OpenSeaToken,
}
export type OpenSeaAsset = {
token_id: string,
num_sales: number,
background_color: string,
image_url: string,
image_preview_url: string,
image_thumbnail_url: string,
image_original_url: string,
animation_url: ?string,
animation_original_url: ?string,
name: string,
description: string,
external_link: string,
asset_contract: CollectibleContract,
owner: OpenSeaAccount,
permalink: string,
collection: OpenSeaCollection,
decimals: ?(number | string),
auctions: ?(number | string),
sell_orders: ?(string[]),
traits: {}[],
last_sale: OpenSeaSale,
top_bid: ?(number | string),
current_price: ?(number | string),
current_escrow_price: ?(number | string),
listing_date: ?string,
is_presale: boolean,
transfer_fee_payment_token: ?(number | string),
transfer_fee: ?(number | string),
}
export type AssetCollectible = {
tokenId: string,
title: string,
text: ?string,
color: string,
image: string,
assetUrl: string,
description: string,
order?: ?string,
assetAddress: string,
asset: CollectibleContract,
collection: OpenSeaCollection,
}
export type CollectibleData = {
image: ?string,
slug: string,
title: string,
data: AssetCollectible[],
}
export type NFTAsset = {
address: string,
assetContract: CollectibleContract,
collection: OpenSeaCollection,
description: ?string,
image: ?string,
name: string,
numberOfTokens: number,
slug: string,
symbol: string,
}
export type NFTToken = {
assetAddress: string,
color: ?string,
description: string,
image: string,
name: string,
tokenId: string,
}
export type NFTAssets = { [key: string]: NFTAsset }
export type CollectiblesInfo = {
nftAssets: NFTAssets,
nftTokens: NFTToken[],
}
export interface CollectibleMetadataSource {
constructor(options: { rps: number }): void;
fetchAllUserCollectiblesByCategoryAsync(safeAddress: string, networkId: number): Promise<CollectiblesInfo>;
}

View File

@ -1,35 +1,46 @@
// @flow // @flow
import CircularProgress from '@material-ui/core/CircularProgress' import CircularProgress from '@material-ui/core/CircularProgress'
import { withStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import cn from 'classnames' import cn from 'classnames'
import { List } from 'immutable' import { List } from 'immutable'
import React, { Suspense, useEffect, useState } from 'react' import React, { Suspense, useEffect, useState } from 'react'
import Modal from '~/components/Modal' import Modal from '~/components/Modal'
import { type Token } from '~/logic/tokens/store/model/token' import { type Token } from '~/logic/tokens/store/model/token'
import type { NFTToken } from '~/routes/safe/components/Balances/Collectibles/types'
const ChooseTxType = React.lazy(() => import('./screens/ChooseTxType')) const ChooseTxType = React.lazy(() => import('./screens/ChooseTxType'))
const SendFunds = React.lazy(() => import('./screens/SendFunds')) const SendFunds = React.lazy(() => import('./screens/SendFunds'))
const SendCollectible = React.lazy(() => import('./screens/SendCollectible'))
const ReviewCollectible = React.lazy(() => import('./screens/ReviewCollectible'))
const ReviewTx = React.lazy(() => import('./screens/ReviewTx')) const ReviewTx = React.lazy(() => import('./screens/ReviewTx'))
const SendCustomTx = React.lazy(() => import('./screens/SendCustomTx')) const SendCustomTx = React.lazy(() => import('./screens/SendCustomTx'))
const ReviewCustomTx = React.lazy(() => import('./screens/ReviewCustomTx')) const ReviewCustomTx = React.lazy(() => import('./screens/ReviewCustomTx'))
type ActiveScreen = 'chooseTxType' | 'sendFunds' | 'reviewTx' | 'sendCustomTx' | 'reviewCustomTx' type ActiveScreen =
| 'chooseTxType'
| 'sendFunds'
| 'reviewTx'
| 'sendCustomTx'
| 'reviewCustomTx'
| 'sendCollectible'
| 'reviewCollectible'
type Props = { type Props = {
onClose: () => void, onClose: () => void,
classes: Object,
isOpen: boolean, isOpen: boolean,
safeAddress: string, safeAddress: string,
safeName: string, safeName: string,
ethBalance: string, ethBalance: string,
tokens: List<Token>, tokens: List<Token>,
selectedToken: string, selectedToken?: string | NFTToken | {},
createTransaction: Function, createTransaction?: Function,
activeScreenType: ActiveScreen, activeScreenType: ActiveScreen,
recipientAddress?: string, recipientAddress?: string,
} }
@ -43,7 +54,7 @@ type TxStateType =
} }
| Object | Object
const styles = () => ({ const useStyles = makeStyles({
scalableModalWindow: { scalableModalWindow: {
height: 'auto', height: 'auto',
}, },
@ -51,19 +62,17 @@ const styles = () => ({
height: 'auto', height: 'auto',
position: 'static', position: 'static',
}, },
}) loaderStyle: {
const loaderStyle = {
height: '500px', height: '500px',
width: '100%', width: '100%',
display: 'flex', display: 'flex',
justifyContent: 'center', justifyContent: 'center',
alignItems: 'center', alignItems: 'center',
} },
})
const Send = ({ const SendModal = ({
activeScreenType, activeScreenType,
classes,
createTransaction, createTransaction,
ethBalance, ethBalance,
isOpen, isOpen,
@ -74,6 +83,7 @@ const Send = ({
selectedToken, selectedToken,
tokens, tokens,
}: Props) => { }: Props) => {
const classes = useStyles()
const [activeScreen, setActiveScreen] = useState<ActiveScreen>(activeScreenType || 'chooseTxType') const [activeScreen, setActiveScreen] = useState<ActiveScreen>(activeScreenType || 'chooseTxType')
const [tx, setTx] = useState<TxStateType>({}) const [tx, setTx] = useState<TxStateType>({})
@ -94,6 +104,11 @@ const Send = ({
setTx(customTxInfo) setTx(customTxInfo)
} }
const handleSendCollectible = txInfo => {
setActiveScreen('reviewCollectible')
setTx(txInfo)
}
return ( return (
<Modal <Modal
description="Send Tokens Form" description="Send Tokens Form"
@ -104,12 +119,14 @@ const Send = ({
> >
<Suspense <Suspense
fallback={ fallback={
<div style={loaderStyle}> <div className={classes.loaderStyle}>
<CircularProgress size={40} /> <CircularProgress size={40} />
</div> </div>
} }
> >
{activeScreen === 'chooseTxType' && <ChooseTxType onClose={onClose} setActiveScreen={setActiveScreen} />} {activeScreen === 'chooseTxType' && (
<ChooseTxType onClose={onClose} recipientAddress={recipientAddress} setActiveScreen={setActiveScreen} />
)}
{activeScreen === 'sendFunds' && ( {activeScreen === 'sendFunds' && (
<SendFunds <SendFunds
ethBalance={ethBalance} ethBalance={ethBalance}
@ -141,6 +158,7 @@ const Send = ({
initialValues={tx} initialValues={tx}
onClose={onClose} onClose={onClose}
onSubmit={handleCustomTxCreation} onSubmit={handleCustomTxCreation}
recipientAddress={recipientAddress}
safeAddress={safeAddress} safeAddress={safeAddress}
safeName={safeName} safeName={safeName}
/> />
@ -156,9 +174,21 @@ const Send = ({
tx={tx} tx={tx}
/> />
)} )}
{activeScreen === 'sendCollectible' && (
<SendCollectible
initialValues={tx}
onClose={onClose}
onNext={handleSendCollectible}
recipientAddress={recipientAddress}
selectedToken={selectedToken}
/>
)}
{activeScreen === 'reviewCollectible' && (
<ReviewCollectible onClose={onClose} onPrev={() => setActiveScreen('sendCollectible')} tx={tx} />
)}
</Suspense> </Suspense>
</Modal> </Modal>
) )
} }
export default withStyles(styles)(Send) export default SendModal

View File

@ -1,22 +1,26 @@
// @flow // @flow
import IconButton from '@material-ui/core/IconButton' import IconButton from '@material-ui/core/IconButton'
import { withStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close' import Close from '@material-ui/icons/Close'
import classNames from 'classnames/bind' import classNames from 'classnames/bind'
import * as React from 'react' import * as React from 'react'
import { useSelector } from 'react-redux'
import Code from '../assets/code.svg' import Code from '../assets/code.svg'
import Collectible from '../assets/collectibles.svg'
import Token from '../assets/token.svg' import Token from '../assets/token.svg'
import { mustBeEthereumContractAddress } from '~/components/forms/validator'
import Button from '~/components/layout/Button' import Button from '~/components/layout/Button'
import Col from '~/components/layout/Col' import Col from '~/components/layout/Col'
import Hairline from '~/components/layout/Hairline' import Hairline from '~/components/layout/Hairline'
import Img from '~/components/layout/Img' import Img from '~/components/layout/Img'
import Paragraph from '~/components/layout/Paragraph' import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row' import Row from '~/components/layout/Row'
import { safeSelector } from '~/routes/safe/store/selectors'
import { lg, md, sm } from '~/theme/variables' import { lg, md, sm } from '~/theme/variables'
const styles = () => ({ const useStyles = makeStyles({
heading: { heading: {
padding: `${md} ${lg}`, padding: `${md} ${lg}`,
justifyContent: 'space-between', justifyContent: 'space-between',
@ -26,6 +30,14 @@ const styles = () => ({
manage: { manage: {
fontSize: lg, fontSize: lg,
}, },
disclaimer: {
marginBottom: `-${md}`,
paddingTop: md,
textAlign: 'center',
},
disclaimerText: {
fontSize: md,
},
closeIcon: { closeIcon: {
height: '35px', height: '35px',
width: '35px', width: '35px',
@ -51,11 +63,32 @@ const styles = () => ({
type Props = { type Props = {
onClose: () => void, onClose: () => void,
classes: Object, recipientAddress?: string,
setActiveScreen: Function, setActiveScreen: Function,
} }
const ChooseTxType = ({ classes, onClose, setActiveScreen }: Props) => ( const ChooseTxType = ({ onClose, recipientAddress, setActiveScreen }: Props) => {
const classes = useStyles()
const { featuresEnabled } = useSelector(safeSelector)
const erc721Enabled = featuresEnabled.includes('ERC721')
const [disableCustomTx, setDisableCustomTx] = React.useState(!!recipientAddress)
React.useEffect(() => {
let isCurrent = true
const isContract = async () => {
if (recipientAddress && isCurrent) {
setDisableCustomTx(!!(await mustBeEthereumContractAddress(recipientAddress)))
}
}
isContract()
return () => {
isCurrent = false
}
}, [recipientAddress])
return (
<> <>
<Row align="center" className={classes.heading} grow> <Row align="center" className={classes.heading} grow>
<Paragraph className={classes.manage} noMargin weight="bolder"> <Paragraph className={classes.manage} noMargin weight="bolder">
@ -66,6 +99,15 @@ const ChooseTxType = ({ classes, onClose, setActiveScreen }: Props) => (
</IconButton> </IconButton>
</Row> </Row>
<Hairline /> <Hairline />
{!!recipientAddress && (
<Row align="center">
<Col className={classes.disclaimer} layout="column" middle="xs">
<Paragraph className={classes.disclaimerText} noMargin>
Please select what you will send to {recipientAddress}
</Paragraph>
</Col>
</Row>
)}
<Row align="center"> <Row align="center">
<Col className={classes.buttonColumn} layout="column" middle="xs"> <Col className={classes.buttonColumn} layout="column" middle="xs">
<Button <Button
@ -79,8 +121,26 @@ const ChooseTxType = ({ classes, onClose, setActiveScreen }: Props) => (
<Img alt="Send funds" className={classNames(classes.leftIcon, classes.iconSmall)} src={Token} /> <Img alt="Send funds" className={classNames(classes.leftIcon, classes.iconSmall)} src={Token} />
Send funds Send funds
</Button> </Button>
{erc721Enabled && (
<Button
className={classes.firstButton}
color="primary"
minHeight={52}
minWidth={260}
onClick={() => setActiveScreen('sendCollectible')}
variant="contained"
>
<Img
alt="Send collectible"
className={classNames(classes.leftIcon, classes.iconSmall)}
src={Collectible}
/>
Send collectible
</Button>
)}
<Button <Button
color="primary" color="primary"
disabled={disableCustomTx}
minHeight={52} minHeight={52}
minWidth={260} minWidth={260}
onClick={() => setActiveScreen('sendCustomTx')} onClick={() => setActiveScreen('sendCustomTx')}
@ -93,5 +153,6 @@ const ChooseTxType = ({ classes, onClose, setActiveScreen }: Props) => (
</Row> </Row>
</> </>
) )
}
export default withStyles(styles)(ChooseTxType) export default ChooseTxType

View File

@ -0,0 +1,199 @@
// @flow
import IconButton from '@material-ui/core/IconButton'
import { makeStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import { List } from 'immutable'
import { withSnackbar } from 'notistack'
import React, { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import ArrowDown from '../assets/arrow-down.svg'
import { styles } from './style'
import CopyBtn from '~/components/CopyBtn'
import EtherscanBtn from '~/components/EtherscanBtn'
import Identicon from '~/components/Identicon'
import Block from '~/components/layout/Block'
import Button from '~/components/layout/Button'
import Col from '~/components/layout/Col'
import Hairline from '~/components/layout/Hairline'
import Img from '~/components/layout/Img'
import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row'
import type { NFTTokensState } from '~/logic/collectibles/store/reducer/collectibles'
import { nftTokensSelector } from '~/logic/collectibles/store/selectors'
import { TX_NOTIFICATION_TYPES } from '~/logic/safe/transactions'
import { estimateTxGasCosts } from '~/logic/safe/transactions/gasNew'
import {
containsMethodByHash,
getERC721TokenContract,
getHumanFriendlyToken,
} from '~/logic/tokens/store/actions/fetchTokens'
import { type Token } from '~/logic/tokens/store/model/token'
import { formatAmount } from '~/logic/tokens/utils/formatAmount'
import { getWeb3 } from '~/logic/wallets/getWeb3'
import SafeInfo from '~/routes/safe/components/Balances/SendModal/SafeInfo'
import { setImageToPlaceholder } from '~/routes/safe/components/Balances/utils'
import createTransaction from '~/routes/safe/store/actions/createTransaction'
import { safeSelector } from '~/routes/safe/store/selectors'
import { sm } from '~/theme/variables'
import { textShortener } from '~/utils/strings'
const useStyles = makeStyles(styles)
const SAFE_TRANSFER_FROM_WITHOUT_DATA_HASH = '0x42842e0e'
type Props = {
onClose: () => void,
onPrev: () => void,
classes: Object,
tx: Object,
tokens: List<Token>,
createTransaction: Function,
enqueueSnackbar: Function,
closeSnackbar: Function,
}
const ReviewCollectible = ({ closeSnackbar, enqueueSnackbar, onClose, onPrev, tx }: Props) => {
const classes = useStyles()
const shortener = textShortener()
const dispatch = useDispatch()
const { address: safeAddress, ethBalance, name: safeName } = useSelector(safeSelector)
const nftTokens: NFTTokensState = useSelector(nftTokensSelector)
const [gasCosts, setGasCosts] = useState<string>('< 0.001')
const txToken = nftTokens.find(
({ assetAddress, tokenId }) => assetAddress === tx.assetAddress && tokenId === tx.nftTokenId,
)
const [data, setData] = useState('')
useEffect(() => {
let isCurrent = true
const estimateGas = async () => {
const { fromWei, toBN } = getWeb3().utils
const supportsSafeTransfer = await containsMethodByHash(tx.assetAddress, SAFE_TRANSFER_FROM_WITHOUT_DATA_HASH)
const methodToCall = supportsSafeTransfer ? SAFE_TRANSFER_FROM_WITHOUT_DATA_HASH : 'transfer'
const transferParams = [tx.recipientAddress, tx.nftTokenId]
const params = methodToCall === 'transfer' ? transferParams : [safeAddress, ...transferParams]
const ERC721Token = methodToCall === 'transfer' ? await getHumanFriendlyToken() : await getERC721TokenContract()
const tokenInstance = await ERC721Token.at(tx.assetAddress)
const txData = tokenInstance.contract.methods[methodToCall](...params).encodeABI()
const estimatedGasCosts = await estimateTxGasCosts(safeAddress, tx.recipientAddress, txData)
const gasCostsAsEth = fromWei(toBN(estimatedGasCosts), 'ether')
const formattedGasCosts = formatAmount(gasCostsAsEth)
if (isCurrent) {
setGasCosts(formattedGasCosts)
setData(txData)
}
}
estimateGas()
return () => {
isCurrent = false
}
}, [])
const submitTx = async () => {
dispatch(
createTransaction({
safeAddress,
to: tx.assetAddress,
valueInWei: '0',
txData: data,
notifiedTransaction: TX_NOTIFICATION_TYPES.STANDARD_TX,
enqueueSnackbar,
closeSnackbar,
}),
)
onClose()
}
return (
<>
<Row align="center" className={classes.heading} grow>
<Paragraph className={classes.headingText} noMargin weight="bolder">
Send Funds
</Paragraph>
<Paragraph className={classes.annotation}>2 of 2</Paragraph>
<IconButton disableRipple onClick={onClose}>
<Close className={classes.closeIcon} />
</IconButton>
</Row>
<Hairline />
<Block className={classes.container}>
<SafeInfo ethBalance={ethBalance} safeAddress={safeAddress} safeName={safeName} />
<Row margin="md">
<Col xs={1}>
<img alt="Arrow Down" src={ArrowDown} style={{ marginLeft: sm }} />
</Col>
<Col center="xs" layout="column" xs={11}>
<Hairline />
</Col>
</Row>
<Row margin="xs">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Recipient
</Paragraph>
</Row>
<Row align="center" margin="md">
<Col xs={1}>
<Identicon address={tx.recipientAddress} diameter={32} />
</Col>
<Col layout="column" xs={11}>
<Block justify="left">
<Paragraph className={classes.address} noMargin weight="bolder">
{tx.recipientAddress}
</Paragraph>
<CopyBtn content={tx.recipientAddress} />
<EtherscanBtn type="address" value={tx.recipientAddress} />
</Block>
</Col>
</Row>
<Row margin="xs">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
{textShortener({ charsStart: 40, charsEnd: 0 })(tx.assetName)}
</Paragraph>
</Row>
{txToken && (
<Row align="center" margin="md">
<Img alt={txToken.name} height={28} onError={setImageToPlaceholder} src={txToken.image} />
<Paragraph className={classes.amount} noMargin size="md">
{shortener(txToken.name)} (Token ID: {shortener(txToken.tokenId)})
</Paragraph>
</Row>
)}
<Row>
<Paragraph>
{`You're about to create a transaction and will have to confirm it with your currently connected wallet. Make sure you have ${gasCosts} (fee price) ETH in this wallet to fund this confirmation.`}
</Paragraph>
</Row>
</Block>
<Hairline style={{ position: 'absolute', bottom: 85 }} />
<Row align="center" className={classes.buttonRow}>
<Button minWidth={140} onClick={onPrev}>
Back
</Button>
<Button
className={classes.submitButton}
color="primary"
data-testid="submit-tx-btn"
disabled={!data}
minWidth={140}
onClick={submitTx}
type="submit"
variant="contained"
>
Submit
</Button>
</Row>
</>
)
}
export default withSnackbar(ReviewCollectible)

View File

@ -0,0 +1,45 @@
// @flow
import { lg, md, secondaryText, sm } from '~/theme/variables'
export const styles = () => ({
heading: {
padding: `${md} ${lg}`,
justifyContent: 'flex-start',
boxSizing: 'border-box',
maxHeight: '75px',
},
annotation: {
letterSpacing: '-1px',
color: secondaryText,
marginRight: 'auto',
marginLeft: '20px',
},
headingText: {
fontSize: lg,
},
closeIcon: {
height: '35px',
width: '35px',
},
container: {
padding: `${md} ${lg}`,
},
amount: {
marginLeft: sm,
},
address: {
marginRight: sm,
},
buttonRow: {
height: '84px',
justifyContent: 'center',
'& > button': {
fontFamily: 'Averta',
fontSize: md,
},
},
submitButton: {
boxShadow: '1px 2px 10px 0 rgba(212, 212, 211, 0.59)',
marginLeft: '15px',
},
})

View File

@ -0,0 +1,85 @@
// @flow
import ListItemIcon from '@material-ui/core/ListItemIcon'
import ListItemText from '@material-ui/core/ListItemText'
import MenuItem from '@material-ui/core/MenuItem'
import { makeStyles } from '@material-ui/core/styles'
import React from 'react'
import { selectStyles, selectedTokenStyles } from './style'
import Field from '~/components/forms/Field'
import SelectField from '~/components/forms/SelectField'
import { required } from '~/components/forms/validator'
import Img from '~/components/layout/Img'
import Paragraph from '~/components/layout/Paragraph'
import type { NFTTokensState } from '~/logic/collectibles/store/reducer/collectibles'
import { setImageToPlaceholder } from '~/routes/safe/components/Balances/utils'
import { textShortener } from '~/utils/strings'
type SelectedCollectibleProps = {
tokens: NFTTokensState,
tokenId: string | number,
}
const useSelectedCollectibleStyles = makeStyles(selectedTokenStyles)
const SelectedCollectible = ({ tokenId, tokens }: SelectedCollectibleProps) => {
const classes = useSelectedCollectibleStyles()
const token = tokenId && tokens ? tokens.find(({ tokenId: id }) => tokenId === id) : null
const shortener = textShortener({ charsStart: 40, charsEnd: 0 })
return (
<MenuItem className={classes.container}>
{token ? (
<>
<ListItemIcon className={classes.tokenImage}>
<Img alt={token.description} height={28} onError={setImageToPlaceholder} src={token.image} />
</ListItemIcon>
<ListItemText
className={classes.tokenData}
primary={shortener(token.name)}
secondary={`token ID: ${shortener(token.tokenId)}`}
/>
</>
) : (
<Paragraph color="disabled" size="md" style={{ opacity: 0.5 }} weight="light">
Select a token*
</Paragraph>
)}
</MenuItem>
)
}
type SelectFieldProps = {
initialValue: string,
tokens: NFTTokensState,
}
const useCollectibleSelectFieldStyles = makeStyles(selectStyles)
const CollectibleSelectField = ({ initialValue, tokens }: SelectFieldProps) => {
const classes = useCollectibleSelectFieldStyles()
return (
<Field
className={classes.selectMenu}
component={SelectField}
disabled={!tokens.length}
initialValue={initialValue}
name="nftTokenId"
renderValue={nftTokenId => <SelectedCollectible tokenId={nftTokenId} tokens={tokens} />}
validate={required}
>
{tokens.map(token => (
<MenuItem key={`${token.assetAddress}-${token.tokenId}`} value={token.tokenId}>
<ListItemIcon className={classes.tokenImage}>
<Img alt={token.name} height={28} onError={setImageToPlaceholder} src={token.image} />
</ListItemIcon>
<ListItemText primary={token.name} secondary={`token ID: ${token.tokenId}`} />
</MenuItem>
))}
</Field>
)
}
export default CollectibleSelectField

View File

@ -0,0 +1,27 @@
// @flow
import { sm } from '~/theme/variables'
export const selectedTokenStyles = () => ({
container: {
minHeight: '55px',
padding: 0,
width: '100%',
},
tokenData: {
padding: 0,
margin: 0,
lineHeight: '14px',
},
tokenImage: {
marginRight: sm,
},
})
export const selectStyles = () => ({
selectMenu: {
paddingRight: 0,
},
tokenImage: {
marginRight: sm,
},
})

View File

@ -0,0 +1,94 @@
// @flow
import ListItemIcon from '@material-ui/core/ListItemIcon'
import ListItemText from '@material-ui/core/ListItemText'
import MenuItem from '@material-ui/core/MenuItem'
import { makeStyles } from '@material-ui/core/styles'
import React from 'react'
import { selectStyles, selectedTokenStyles } from './style'
import Field from '~/components/forms/Field'
import SelectField from '~/components/forms/SelectField'
import { required } from '~/components/forms/validator'
import Img from '~/components/layout/Img'
import Paragraph from '~/components/layout/Paragraph'
import type { NFTAssetsState } from '~/logic/collectibles/store/reducer/collectibles'
import { formatAmount } from '~/logic/tokens/utils/formatAmount'
import { setImageToPlaceholder } from '~/routes/safe/components/Balances/utils'
import { textShortener } from '~/utils/strings'
type SelectedTokenProps = {
assetAddress?: string,
assets: NFTAssetsState,
}
const useSelectedTokenStyles = makeStyles(selectedTokenStyles)
const SelectedToken = ({ assetAddress, assets }: SelectedTokenProps) => {
const classes = useSelectedTokenStyles()
const asset = assetAddress ? assets[assetAddress] : null
const shortener = textShortener({ charsStart: 40, charsEnd: 0 })
return (
<MenuItem className={classes.container}>
{asset && asset.numberOfTokens ? (
<>
<ListItemIcon className={classes.tokenImage}>
<Img alt={asset.name} height={28} onError={setImageToPlaceholder} src={asset.image} />
</ListItemIcon>
<ListItemText
className={classes.tokenData}
primary={shortener(asset.name)}
secondary={`${formatAmount(asset.numberOfTokens)} ${asset.symbol}`}
/>
</>
) : (
<Paragraph color="disabled" size="md" style={{ opacity: 0.5 }} weight="light">
Select an asset*
</Paragraph>
)}
</MenuItem>
)
}
type SelectFieldProps = {
assets: NFTAssetsState,
initialValue: ?string,
}
const useTokenSelectFieldStyles = makeStyles(selectStyles)
const TokenSelectField = ({ assets, initialValue }: SelectFieldProps) => {
const classes = useTokenSelectFieldStyles()
const assetsAddresses = Object.keys(assets)
return (
<Field
className={classes.selectMenu}
component={SelectField}
disabled={!assetsAddresses.length}
initialValue={initialValue}
name="assetAddress"
renderValue={assetAddress => <SelectedToken assetAddress={assetAddress} assets={assets} />}
validate={required}
>
{assetsAddresses.map(assetAddress => {
const asset = assets[assetAddress]
return (
<MenuItem key={asset.slug} value={assetAddress}>
<ListItemIcon className={classes.tokenImage}>
<Img alt={asset.name} height={28} onError={setImageToPlaceholder} src={asset.image} />
</ListItemIcon>
<ListItemText
primary={asset.name}
secondary={`Count: ${formatAmount(asset.numberOfTokens)} ${asset.symbol}`}
/>
</MenuItem>
)
})}
</Field>
)
}
export default TokenSelectField

View File

@ -0,0 +1,27 @@
// @flow
import { sm } from '~/theme/variables'
export const selectedTokenStyles = () => ({
container: {
minHeight: '55px',
padding: 0,
width: '100%',
},
tokenData: {
padding: 0,
margin: 0,
lineHeight: '14px',
},
tokenImage: {
marginRight: sm,
},
})
export const selectStyles = () => ({
selectMenu: {
paddingRight: 0,
},
tokenImage: {
marginRight: sm,
},
})

View File

@ -0,0 +1,268 @@
// @flow
import IconButton from '@material-ui/core/IconButton'
import { makeStyles } from '@material-ui/core/styles'
import Close from '@material-ui/icons/Close'
import React, { useState } from 'react'
import { useSelector } from 'react-redux'
import ArrowDown from '../assets/arrow-down.svg'
import { styles } from './style'
import QRIcon from '~/assets/icons/qrcode.svg'
import CopyBtn from '~/components/CopyBtn'
import EtherscanBtn from '~/components/EtherscanBtn'
import Identicon from '~/components/Identicon'
import ScanQRModal from '~/components/ScanQRModal'
import WhenFieldChanges from '~/components/WhenFieldChanges'
import GnoForm from '~/components/forms/GnoForm'
import Block from '~/components/layout/Block'
import Button from '~/components/layout/Button'
import Col from '~/components/layout/Col'
import Hairline from '~/components/layout/Hairline'
import Img from '~/components/layout/Img'
import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row'
import type { NFTAssetsState, NFTTokensState } from '~/logic/collectibles/store/reducer/collectibles'
import { nftAssetsSelector, nftTokensSelector } from '~/logic/collectibles/store/selectors'
import type { NFTToken } from '~/routes/safe/components/Balances/Collectibles/types'
import SafeInfo from '~/routes/safe/components/Balances/SendModal/SafeInfo'
import AddressBookInput from '~/routes/safe/components/Balances/SendModal/screens/AddressBookInput'
import CollectibleSelectField from '~/routes/safe/components/Balances/SendModal/screens/SendCollectible/CollectibleSelectField'
import TokenSelectField from '~/routes/safe/components/Balances/SendModal/screens/SendCollectible/TokenSelectField'
import { safeSelector } from '~/routes/safe/store/selectors'
import { sm } from '~/theme/variables'
type Props = {
initialValues: Object,
onClose: () => void,
onNext: any => void,
recipientAddress?: string,
selectedToken?: NFTToken | {},
}
const formMutators = {
setMax: (args, state, utils) => {
utils.changeValue(state, 'amount', () => args[0])
},
onTokenChange: (args, state, utils) => {
utils.changeValue(state, 'amount', () => '')
},
setRecipient: (args, state, utils) => {
utils.changeValue(state, 'recipientAddress', () => args[0])
},
}
const useStyles = makeStyles(styles)
const SendCollectible = ({ initialValues, onClose, onNext, recipientAddress, selectedToken = {} }: Props) => {
const classes = useStyles()
const { address: safeAddress, ethBalance, name: safeName } = useSelector(safeSelector)
const nftAssets: NFTAssetsState = useSelector(nftAssetsSelector)
const nftTokens: NFTTokensState = useSelector(nftTokensSelector)
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false)
const [selectedEntry, setSelectedEntry] = useState<Object | null>({
address: recipientAddress || initialValues.recipientAddress,
name: '',
})
const [pristine, setPristine] = useState<boolean>(true)
const [isValidAddress, setIsValidAddress] = useState<boolean>(true)
React.useMemo(() => {
if (selectedEntry === null && pristine) {
setPristine(false)
}
}, [selectedEntry, pristine])
const handleSubmit = values => {
// If the input wasn't modified, there was no mutation of the recipientAddress
if (!values.recipientAddress) {
values.recipientAddress = selectedEntry.address
}
values.assetName = nftAssets[values.assetAddress].name
onNext(values)
}
const openQrModal = () => {
setQrModalOpen(true)
}
const closeQrModal = () => {
setQrModalOpen(false)
}
return (
<>
<Row align="center" className={classes.heading} grow>
<Paragraph className={classes.manage} noMargin weight="bolder">
Send Collectible
</Paragraph>
<Paragraph className={classes.annotation}>1 of 2</Paragraph>
<IconButton disableRipple onClick={onClose}>
<Close className={classes.closeIcon} />
</IconButton>
</Row>
<Hairline />
<GnoForm formMutators={formMutators} initialValues={initialValues} onSubmit={handleSubmit}>
{(...args) => {
const formState = args[2]
const mutators = args[3]
const { assetAddress } = formState.values
const selectedNFTTokens = nftTokens.filter(nftToken => nftToken.assetAddress === assetAddress)
const handleScan = value => {
let scannedAddress = value
if (scannedAddress.startsWith('ethereum:')) {
scannedAddress = scannedAddress.replace('ethereum:', '')
}
mutators.setRecipient(scannedAddress)
closeQrModal()
}
let shouldDisableSubmitButton = !isValidAddress
if (selectedEntry) {
shouldDisableSubmitButton = !selectedEntry.address
}
return (
<>
<WhenFieldChanges field="assetAddress" set="nftTokenId" to={''} />
<Block className={classes.formContainer}>
<SafeInfo ethBalance={ethBalance} safeAddress={safeAddress} safeName={safeName} />
<Row margin="md">
<Col xs={1}>
<img alt="Arrow Down" src={ArrowDown} style={{ marginLeft: sm }} />
</Col>
<Col center="xs" layout="column" xs={11}>
<Hairline />
</Col>
</Row>
{selectedEntry && selectedEntry.address ? (
<div
onKeyDown={e => {
if (e.keyCode !== 9) {
setSelectedEntry(null)
}
}}
role="listbox"
tabIndex="0"
>
<Row margin="xs">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Recipient
</Paragraph>
</Row>
<Row align="center" margin="md">
<Col xs={1}>
<Identicon address={selectedEntry.address} diameter={32} />
</Col>
<Col layout="column" xs={11}>
<Block justify="left">
<Block>
<Paragraph
className={classes.selectAddress}
noMargin
onClick={() => setSelectedEntry(null)}
weight="bolder"
>
{selectedEntry.name}
</Paragraph>
<Paragraph
className={classes.selectAddress}
noMargin
onClick={() => setSelectedEntry(null)}
weight="bolder"
>
{selectedEntry.address}
</Paragraph>
</Block>
<CopyBtn content={selectedEntry.address} />
<EtherscanBtn type="address" value={selectedEntry.address} />
</Block>
</Col>
</Row>
</div>
) : (
<>
<Row margin="md">
<Col xs={11}>
<AddressBookInput
fieldMutator={mutators.setRecipient}
pristine={pristine}
recipientAddress={recipientAddress}
setIsValidAddress={setIsValidAddress}
setSelectedEntry={setSelectedEntry}
/>
</Col>
<Col center="xs" className={classes} middle="xs" xs={1}>
<Img
alt="Scan QR"
className={classes.qrCodeBtn}
height={20}
onClick={() => {
openQrModal()
}}
role="button"
src={QRIcon}
/>
</Col>
</Row>
</>
)}
<Row margin="xs">
<Col between="lg">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Collectible
</Paragraph>
</Col>
</Row>
<Row margin="sm">
<Col>
<TokenSelectField assets={nftAssets} initialValue={selectedToken.assetAddress} />
</Col>
</Row>
<Row margin="xs">
<Col between="lg">
<Paragraph color="disabled" noMargin size="md" style={{ letterSpacing: '-0.5px' }}>
Token ID
</Paragraph>
</Col>
</Row>
<Row margin="md">
<Col>
<CollectibleSelectField initialValue={selectedToken.tokenId} tokens={selectedNFTTokens} />
</Col>
</Row>
</Block>
<Hairline />
<Row align="center" className={classes.buttonRow}>
<Button minWidth={140} onClick={onClose}>
Cancel
</Button>
<Button
className={classes.submitButton}
color="primary"
data-testid="review-tx-btn"
disabled={shouldDisableSubmitButton}
minWidth={140}
type="submit"
variant="contained"
>
Review
</Button>
</Row>
{qrModalOpen && <ScanQRModal isOpen={qrModalOpen} onClose={closeQrModal} onScan={handleScan} />}
</>
)
}}
</GnoForm>
</>
)
}
export default SendCollectible

View File

@ -0,0 +1,45 @@
// @flow
import { lg, md, secondaryText } from '~/theme/variables'
export const styles = () => ({
heading: {
padding: `${md} ${lg}`,
justifyContent: 'flex-start',
boxSizing: 'border-box',
maxHeight: '75px',
},
annotation: {
letterSpacing: '-1px',
color: secondaryText,
marginRight: 'auto',
marginLeft: '20px',
},
manage: {
fontSize: lg,
},
closeIcon: {
height: '35px',
width: '35px',
},
qrCodeBtn: {
cursor: 'pointer',
},
formContainer: {
padding: `${md} ${lg}`,
},
buttonRow: {
height: '84px',
justifyContent: 'center',
'& > button': {
fontFamily: 'Averta',
fontSize: md,
},
},
submitButton: {
boxShadow: '1px 2px 10px 0 rgba(212, 212, 211, 0.59)',
marginLeft: '15px',
},
selectAddress: {
cursor: 'pointer',
},
})

View File

@ -34,6 +34,7 @@ import { sm } from '~/theme/variables'
type Props = { type Props = {
onClose: () => void, onClose: () => void,
classes: Object, classes: Object,
recipientAddress: string,
safeAddress: string, safeAddress: string,
safeName: string, safeName: string,
ethBalance: string, ethBalance: string,
@ -41,10 +42,19 @@ type Props = {
initialValues: Object, initialValues: Object,
} }
const SendCustomTx = ({ classes, ethBalance, initialValues, onClose, onSubmit, safeAddress, safeName }: Props) => { const SendCustomTx = ({
classes,
ethBalance,
initialValues,
onClose,
onSubmit,
recipientAddress,
safeAddress,
safeName,
}: Props) => {
const [qrModalOpen, setQrModalOpen] = useState<boolean>(false) const [qrModalOpen, setQrModalOpen] = useState<boolean>(false)
const [selectedEntry, setSelectedEntry] = useState<Object | null>({ const [selectedEntry, setSelectedEntry] = useState<Object | null>({
address: '', address: recipientAddress || initialValues.recipientAddress,
name: '', name: '',
}) })
const [pristine, setPristine] = useState<boolean>(true) const [pristine, setPristine] = useState<boolean>(true)

View File

@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="24" height="24" viewBox="0 0 24 24">
<defs>
<path id="prefix__a" d="M0 0L23.984 0 23.984 22.002 0 22.002z"/>
</defs>
<g fill="none" fill-rule="evenodd">
<path d="M0 0H24V24H0z"/>
<g transform="translate(0 1)">
<mask id="prefix__b" fill="#fff">
<use xlink:href="#prefix__a"/>
</mask>
<path fill="#FFF" d="M11.992 19.384L3.05 8.003h17.884l-8.942 11.38zM5.999 2h12.4l2.709 4.003H2.993L5.999 2zm17.974 5.16c.007-.042.009-.085.01-.128.002-.047.002-.093-.004-.141-.005-.043-.013-.084-.024-.127-.011-.046-.025-.092-.044-.137-.008-.02-.01-.042-.02-.061-.01-.022-.027-.038-.04-.06-.011-.02-.017-.043-.031-.063L19.759.44c-.186-.275-.496-.44-.83-.44H5.5c-.315 0-.611.149-.8.4L.192 6.403c-.02.026-.03.056-.047.085-.016.027-.04.048-.052.078-.006.013-.008.028-.014.042-.02.045-.031.09-.045.138-.011.042-.023.084-.028.127-.007.047-.005.094-.005.142 0 .044 0 .088.006.13.007.048.02.092.035.137.012.044.023.087.043.128.005.014.006.028.012.04.017.033.04.058.06.088.018.028.028.058.049.083l11 13.999c.189.242.479.382.786.382.307 0 .597-.14.786-.382l11-14c.019-.022.027-.05.044-.075.017-.025.038-.046.053-.073.009-.016.01-.033.018-.05.019-.041.034-.083.046-.126.015-.045.027-.09.034-.136z" mask="url(#prefix__b)"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

View File

@ -11,7 +11,6 @@ import * as React from 'react'
import AssetTableCell from './AssetTableCell' import AssetTableCell from './AssetTableCell'
import Receive from './Receive' import Receive from './Receive'
import SendModal from './SendModal'
import Tokens from './Tokens' import Tokens from './Tokens'
import { BALANCE_TABLE_ASSET_ID, type BalanceRow, generateColumns, getBalanceData } from './dataFetcher' import { BALANCE_TABLE_ASSET_ID, type BalanceRow, generateColumns, getBalanceData } from './dataFetcher'
import { styles } from './style' import { styles } from './style'
@ -22,36 +21,47 @@ import { type Column, cellWidth } from '~/components/Table/TableHead'
import Button from '~/components/layout/Button' import Button from '~/components/layout/Button'
import ButtonLink from '~/components/layout/ButtonLink' import ButtonLink from '~/components/layout/ButtonLink'
import Col from '~/components/layout/Col' import Col from '~/components/layout/Col'
import Divider from '~/components/layout/Divider'
import Link from '~/components/layout/Link'
import Row from '~/components/layout/Row' import Row from '~/components/layout/Row'
import type { BalanceCurrencyType } from '~/logic/currencyValues/store/model/currencyValues' import type { BalanceCurrencyType } from '~/logic/currencyValues/store/model/currencyValues'
import { type Token } from '~/logic/tokens/store/model/token' import { type Token } from '~/logic/tokens/store/model/token'
import { SAFELIST_ADDRESS } from '~/routes/routes'
import Collectibles from '~/routes/safe/components/Balances/Collectibles'
import SendModal from '~/routes/safe/components/Balances/SendModal'
import { BALANCE_TABLE_BALANCE_ID, BALANCE_TABLE_VALUE_ID } from '~/routes/safe/components/Balances/dataFetcher' import { BALANCE_TABLE_BALANCE_ID, BALANCE_TABLE_VALUE_ID } from '~/routes/safe/components/Balances/dataFetcher'
import DropdownCurrency from '~/routes/safe/components/DropdownCurrency' import DropdownCurrency from '~/routes/safe/components/DropdownCurrency'
import { history } from '~/store'
export const MANAGE_TOKENS_BUTTON_TEST_ID = 'manage-tokens-btn' export const MANAGE_TOKENS_BUTTON_TEST_ID = 'manage-tokens-btn'
export const BALANCE_ROW_TEST_ID = 'balance-row' export const BALANCE_ROW_TEST_ID = 'balance-row'
type State = { type State = {
showToken: boolean, erc721Enabled: boolean,
showReceive: boolean, subMenuOptions: string[],
sendFunds: Object, sendFunds: Object,
showCoins: boolean,
showCollectibles: boolean,
showReceive: boolean,
showToken: boolean,
} }
type Props = { type Props = {
classes: Object, activateTokensByBalance: Function,
granted: boolean,
tokens: List<Token>,
activeTokens: List<Token>, activeTokens: List<Token>,
blacklistedTokens: List<Token>, blacklistedTokens: List<Token>,
activateTokensByBalance: Function, classes: Object,
fetchTokens: Function,
safeAddress: string,
safeName: string,
ethBalance: string,
createTransaction: Function, createTransaction: Function,
currencySelected: string, currencySelected: string,
fetchCurrencyValues: Function,
currencyValues: BalanceCurrencyType[], currencyValues: BalanceCurrencyType[],
ethBalance: string,
featuresEnabled: string[],
fetchCurrencyValues: Function,
fetchTokens: Function,
granted: boolean,
safeAddress: string,
safeName: string,
tokens: List<Token>,
} }
type Action = 'Token' | 'Send' | 'Receive' type Action = 'Token' | 'Send' | 'Receive'
@ -60,20 +70,58 @@ class Balances extends React.Component<Props, State> {
constructor(props) { constructor(props) {
super(props) super(props)
this.state = { this.state = {
erc721Enabled: false,
subMenuOptions: [],
showToken: false, showToken: false,
sendFunds: { sendFunds: {
isOpen: false, isOpen: false,
selectedToken: undefined, selectedToken: undefined,
}, },
showCoins: true,
showCollectibles: false,
showReceive: false, showReceive: false,
} }
props.fetchTokens() props.fetchTokens()
} }
static isCoinsLocation = /\/balances\/?$/
static isCollectiblesLocation = /\/balances\/collectibles$/
componentDidMount(): void { componentDidMount(): void {
const { activateTokensByBalance, fetchCurrencyValues, safeAddress } = this.props const { activateTokensByBalance, fetchCurrencyValues, safeAddress } = this.props
fetchCurrencyValues(safeAddress) fetchCurrencyValues(safeAddress)
activateTokensByBalance(safeAddress) activateTokensByBalance(safeAddress)
const showCollectibles = Balances.isCollectiblesLocation.test(history.location.pathname)
const showCoins = Balances.isCoinsLocation.test(history.location.pathname)
if (!showCollectibles && !showCoins) {
history.replace(`${SAFELIST_ADDRESS}/${this.props.safeAddress}/balances`)
}
const subMenuOptions = [
{ enabled: showCoins, legend: 'Coins', url: `${SAFELIST_ADDRESS}/${this.props.safeAddress}/balances` },
]
const erc721Enabled = this.props.featuresEnabled.includes('ERC721')
if (erc721Enabled) {
subMenuOptions.push({
enabled: showCollectibles,
legend: 'Collectibles',
url: `${SAFELIST_ADDRESS}/${this.props.safeAddress}/balances/collectibles`,
})
} else {
if (showCollectibles) {
history.replace(subMenuOptions[0].url)
}
}
this.setState({
showCoins,
showCollectibles,
erc721Enabled,
subMenuOptions,
})
} }
onShow = (action: Action) => () => { onShow = (action: Action) => () => {
@ -103,7 +151,7 @@ class Balances extends React.Component<Props, State> {
} }
render() { render() {
const { sendFunds, showReceive, showToken } = this.state const { erc721Enabled, sendFunds, showCoins, showCollectibles, showReceive, showToken, subMenuOptions } = this.state
const { const {
activeTokens, activeTokens,
blacklistedTokens, blacklistedTokens,
@ -125,10 +173,34 @@ class Balances extends React.Component<Props, State> {
return ( return (
<> <>
<Row align="center" className={classes.message}> <Row align="center" className={classes.controls}>
<Col end="sm" xs={12}> <Col className={classes.assetTabs} sm={6} start="sm" xs={12}>
{subMenuOptions.length > 1 &&
subMenuOptions.map(({ enabled, legend, url }, index) => (
<React.Fragment key={`legend-${index}`}>
{index > 0 && <Divider className={classes.assetDivider} />}
<Link
className={enabled ? classes.assetTabActive : classes.assetTab}
data-testid={`${legend.toLowerCase()}'-assets-btn'`}
size="md"
to={url}
weight={enabled ? 'bold' : 'regular'}
>
{legend}
</Link>
</React.Fragment>
))}
</Col>
<Col className={classes.tokenControls} end="sm" sm={6} xs={12}>
{showCoins && (
<>
<DropdownCurrency /> <DropdownCurrency />
<ButtonLink onClick={this.onShow('Token')} size="lg" testId="manage-tokens-btn"> <ButtonLink
className={classes.manageTokensButton}
onClick={this.onShow('Token')}
size="lg"
testId="manage-tokens-btn"
>
Manage List Manage List
</ButtonLink> </ButtonLink>
<Modal <Modal
@ -145,8 +217,11 @@ class Balances extends React.Component<Props, State> {
tokens={tokens} tokens={tokens}
/> />
</Modal> </Modal>
</>
)}
</Col> </Col>
</Row> </Row>
{showCoins && (
<TableContainer> <TableContainer>
<Table <Table
columns={columns} columns={columns}
@ -225,6 +300,8 @@ class Balances extends React.Component<Props, State> {
} }
</Table> </Table>
</TableContainer> </TableContainer>
)}
{erc721Enabled && showCollectibles && <Collectibles />}
<SendModal <SendModal
activeScreenType="sendFunds" activeScreenType="sendFunds"
createTransaction={createTransaction} createTransaction={createTransaction}

View File

@ -1,17 +1,66 @@
// @flow // @flow
import { md, sm, xs } from '~/theme/variables' import { md, screenSm, secondary, sm, xs } from '~/theme/variables'
export const styles = (theme: Object) => ({ export const styles = (theme: Object) => ({
root: { root: {
width: '20px',
marginRight: sm, marginRight: sm,
width: '20px',
}, },
message: { controls: {
margin: `${sm} 0`, alignItems: 'center',
padding: `${md} 0`,
maxHeight: '54px',
boxSizing: 'border-box', boxSizing: 'border-box',
justifyContent: 'space-between',
padding: `${md} 0`,
},
assetTabs: {
alignItems: 'center',
display: 'flex',
order: '2',
[`@media (min-width: ${screenSm}px)`]: {
order: '1',
},
},
assetDivider: {
borderRightColor: `${secondary} !important`,
height: '18px !important',
},
assetTab: {
color: '#686868',
margin: '2px 0',
padding: '0 10px',
textDecoration: 'underline',
'&:hover': {
cursor: 'pointer',
},
},
assetTabActive: {
color: secondary,
fontWeight: 'bold',
margin: '2px 0',
padding: '0 10px',
textDecoration: 'none',
},
tokenControls: {
alignItems: 'center',
display: 'flex',
justifyContent: 'space-between',
order: '1',
padding: '0 0 10px',
[`@media (min-width: ${screenSm}px)`]: {
justifyContent: 'flex-end', justifyContent: 'flex-end',
order: '2',
padding: '0',
},
},
manageTokensButton: {
marginLeft: 'auto',
[`@media (min-width: ${screenSm}px)`]: {
marginLeft: '0',
},
}, },
actionIcon: { actionIcon: {
marginRight: theme.spacing(1), marginRight: theme.spacing(1),

View File

@ -13,7 +13,6 @@ import { type Actions } from '../container/actions'
import Balances from './Balances' import Balances from './Balances'
import Receive from './Balances/Receive' import Receive from './Balances/Receive'
import SendModal from './Balances/SendModal'
import Settings from './Settings' import Settings from './Settings'
import Transactions from './Transactions' import Transactions from './Transactions'
import { AddressBookIcon } from './assets/AddressBookIcon' import { AddressBookIcon } from './assets/AddressBookIcon'
@ -35,9 +34,9 @@ import Hairline from '~/components/layout/Hairline'
import Heading from '~/components/layout/Heading' import Heading from '~/components/layout/Heading'
import Paragraph from '~/components/layout/Paragraph' import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row' import Row from '~/components/layout/Row'
import { getSafeVersion } from '~/logic/safe/utils/safeVersion'
import { getEtherScanLink, getWeb3 } from '~/logic/wallets/getWeb3' import { getEtherScanLink, getWeb3 } from '~/logic/wallets/getWeb3'
import AddressBookTable from '~/routes/safe/components/AddressBook' import AddressBookTable from '~/routes/safe/components/AddressBook'
import SendModal from '~/routes/safe/components/Balances/SendModal'
import { type SelectorProps } from '~/routes/safe/container/selector' import { type SelectorProps } from '~/routes/safe/container/selector'
import { border } from '~/theme/variables' import { border } from '~/theme/variables'
@ -107,23 +106,6 @@ const Layout = (props: Props) => {
onClose: null, onClose: null,
}) })
const [needUpdate, setNeedUpdate] = useState(false)
React.useEffect(() => {
const checkUpdateRequirement = async () => {
let safeVersion = {}
try {
safeVersion = await getSafeVersion(safe.address)
} catch (e) {
console.error('failed to check version', e)
}
setNeedUpdate(safeVersion.needUpdate)
}
checkUpdateRequirement()
}, [safe && safe.address])
const handleCallToRouter = (_, value) => { const handleCallToRouter = (_, value) => {
const { history } = props const { history } = props
@ -134,7 +116,7 @@ const Layout = (props: Props) => {
return <NoSafe provider={provider} text="Safe not found" /> return <NoSafe provider={provider} text="Safe not found" />
} }
const { address, ethBalance, name } = safe const { address, ethBalance, featuresEnabled, name } = safe
const etherScanLink = getEtherScanLink('address', address) const etherScanLink = getEtherScanLink('address', address)
const web3Instance = getWeb3() const web3Instance = getWeb3()
@ -176,7 +158,7 @@ const Layout = (props: Props) => {
<Badge <Badge
badgeContent="" badgeContent=""
color="error" color="error"
invisible={!needUpdate || !granted} invisible={!safe.needsUpdate || !granted}
style={{ paddingRight: '10px' }} style={{ paddingRight: '10px' }}
variant="dot" variant="dot"
> >
@ -187,7 +169,7 @@ const Layout = (props: Props) => {
const labelBalances = ( const labelBalances = (
<> <>
<BalancesIcon /> <BalancesIcon />
Balances Assets
</> </>
) )
const labelTransactions = ( const labelTransactions = (
@ -212,6 +194,18 @@ const Layout = (props: Props) => {
</React.Suspense> </React.Suspense>
) )
const tabsValue = () => {
const balanceLocation = `${match.url}/balances`
const isInBalance = new RegExp(`^${balanceLocation}.*$`)
const { pathname } = location
if (isInBalance.test(pathname)) {
return balanceLocation
}
return pathname
}
return ( return (
<> <>
<Block className={classes.container} margin="xl"> <Block className={classes.container} margin="xl">
@ -261,7 +255,7 @@ const Layout = (props: Props) => {
indicatorColor="secondary" indicatorColor="secondary"
onChange={handleCallToRouter} onChange={handleCallToRouter}
textColor="secondary" textColor="secondary"
value={location.pathname} value={tabsValue()}
variant="scrollable" variant="scrollable"
> >
<Tab <Tab
@ -316,7 +310,7 @@ const Layout = (props: Props) => {
<Switch> <Switch>
<Route <Route
exact exact
path={`${match.path}/balances`} path={`${match.path}/balances/:assetType?`}
render={() => ( render={() => (
<Balances <Balances
activateTokensByBalance={activateTokensByBalance} activateTokensByBalance={activateTokensByBalance}
@ -326,6 +320,7 @@ const Layout = (props: Props) => {
currencySelected={currencySelected} currencySelected={currencySelected}
currencyValues={currencyValues} currencyValues={currencyValues}
ethBalance={ethBalance} ethBalance={ethBalance}
featuresEnabled={featuresEnabled}
fetchCurrencyValues={fetchCurrencyValues} fetchCurrencyValues={fetchCurrencyValues}
fetchTokens={fetchTokens} fetchTokens={fetchTokens}
granted={granted} granted={granted}

View File

@ -1,7 +1,7 @@
// @flow // @flow
import { makeStyles } from '@material-ui/core/styles' import { makeStyles } from '@material-ui/core/styles'
import { withSnackbar } from 'notistack' import { withSnackbar } from 'notistack'
import React, { useEffect, useState } from 'react' import React from 'react'
import { useSelector } from 'react-redux' import { useSelector } from 'react-redux'
import { styles } from './style' import { styles } from './style'
@ -19,9 +19,9 @@ import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row' import Row from '~/components/layout/Row'
import { getNotificationsFromTxType, showSnackbar } from '~/logic/notifications' import { getNotificationsFromTxType, showSnackbar } from '~/logic/notifications'
import { TX_NOTIFICATION_TYPES } from '~/logic/safe/transactions' import { TX_NOTIFICATION_TYPES } from '~/logic/safe/transactions'
import { getSafeVersion } from '~/logic/safe/utils/safeVersion'
import UpdateSafeModal from '~/routes/safe/components/Settings/UpdateSafeModal' import UpdateSafeModal from '~/routes/safe/components/Settings/UpdateSafeModal'
import { grantedSelector } from '~/routes/safe/container/selector' import { grantedSelector } from '~/routes/safe/container/selector'
import { latestMasterContractVersionSelector } from '~/routes/safe/store/selectors'
export const SAFE_NAME_INPUT_TEST_ID = 'safe-name-input' export const SAFE_NAME_INPUT_TEST_ID = 'safe-name-input'
export const SAFE_NAME_SUBMIT_BTN_TEST_ID = 'change-safe-name-btn' export const SAFE_NAME_SUBMIT_BTN_TEST_ID = 'change-safe-name-btn'
@ -29,7 +29,9 @@ export const SAFE_NAME_UPDATE_SAFE_BTN_TEST_ID = 'update-safe-name-btn'
type Props = { type Props = {
safeAddress: string, safeAddress: string,
safeCurrentVersion: string,
safeName: string, safeName: string,
safeNeedsUpdate: boolean,
updateSafe: Function, updateSafe: Function,
enqueueSnackbar: Function, enqueueSnackbar: Function,
createTransaction: Function, createTransaction: Function,
@ -40,11 +42,19 @@ const useStyles = makeStyles(styles)
const SafeDetails = (props: Props) => { const SafeDetails = (props: Props) => {
const classes = useStyles() const classes = useStyles()
const [safeVersions, setSafeVersions] = React.useState({ current: null, latest: null, needUpdate: false })
const isUserOwner = useSelector(grantedSelector) const isUserOwner = useSelector(grantedSelector)
const { closeSnackbar, createTransaction, enqueueSnackbar, safeAddress, safeName, updateSafe } = props const latestMasterContractVersion = useSelector(latestMasterContractVersionSelector)
const {
closeSnackbar,
enqueueSnackbar,
safeAddress,
safeCurrentVersion,
safeName,
safeNeedsUpdate,
updateSafe,
} = props
const [isModalOpen, setModalOpen] = useState(false) const [isModalOpen, setModalOpen] = React.useState(false)
const toggleModal = () => { const toggleModal = () => {
setModalOpen(prevOpen => !prevOpen) setModalOpen(prevOpen => !prevOpen)
@ -61,19 +71,6 @@ const SafeDetails = (props: Props) => {
setModalOpen(true) setModalOpen(true)
} }
useEffect(() => {
const getVersion = async () => {
try {
const { current, latest, needUpdate } = await getSafeVersion(safeAddress)
setSafeVersions({ current, latest, needUpdate })
} catch (err) {
setSafeVersions({ current: 'Version not defined' })
console.error(err)
}
}
getVersion()
}, [])
return ( return (
<> <>
<GnoForm onSubmit={handleSubmit}> <GnoForm onSubmit={handleSubmit}>
@ -83,11 +80,11 @@ const SafeDetails = (props: Props) => {
<Heading tag="h2">Safe Version</Heading> <Heading tag="h2">Safe Version</Heading>
<Row align="end" grow> <Row align="end" grow>
<Paragraph className={classes.versionNumber}> <Paragraph className={classes.versionNumber}>
{safeVersions.current} {safeCurrentVersion}
{safeVersions.needUpdate && ` (there's a newer version: ${safeVersions.latest})`} {safeNeedsUpdate && ` (there's a newer version: ${latestMasterContractVersion})`}
</Paragraph> </Paragraph>
</Row> </Row>
{safeVersions.needUpdate && isUserOwner ? ( {safeNeedsUpdate && isUserOwner ? (
<Row align="end" grow> <Row align="end" grow>
<Paragraph> <Paragraph>
<Button <Button
@ -138,7 +135,7 @@ const SafeDetails = (props: Props) => {
</Col> </Col>
</Row> </Row>
<Modal description="Update Safe" handleClose={toggleModal} open={isModalOpen} title="Update Safe"> <Modal description="Update Safe" handleClose={toggleModal} open={isModalOpen} title="Update Safe">
<UpdateSafeModal createTransaction={createTransaction} onClose={toggleModal} safeAddress={safeAddress} /> <UpdateSafeModal onClose={toggleModal} safeAddress={safeAddress} />
</Modal> </Modal>
</> </>
)} )}

View File

@ -3,6 +3,8 @@ import IconButton from '@material-ui/core/IconButton'
import Close from '@material-ui/icons/Close' import Close from '@material-ui/icons/Close'
import { withStyles } from '@material-ui/styles' import { withStyles } from '@material-ui/styles'
import React from 'react' import React from 'react'
import { useDispatch } from 'react-redux'
import { bindActionCreators } from 'redux'
import { styles } from './style' import { styles } from './style'
@ -13,18 +15,19 @@ import Hairline from '~/components/layout/Hairline'
import Paragraph from '~/components/layout/Paragraph' import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row' import Row from '~/components/layout/Row'
import { upgradeSafeToLatestVersion } from '~/logic/safe/utils/upgradeSafe' import { upgradeSafeToLatestVersion } from '~/logic/safe/utils/upgradeSafe'
import createTransaction from '~/routes/safe/store/actions/createTransaction'
type Props = { type Props = {
onClose: Function, onClose: Function,
createTransaction: Function,
classes: Object, classes: Object,
safeAddress: string, safeAddress: string,
} }
const UpdateSafeModal = ({ classes, createTransaction, onClose, safeAddress }: Props) => { const UpdateSafeModal = ({ classes, onClose, safeAddress }: Props) => {
const dispatch = useDispatch()
const handleSubmit = async () => { const handleSubmit = async () => {
// Call the update safe method // Call the update safe method
await upgradeSafeToLatestVersion(safeAddress, createTransaction) await upgradeSafeToLatestVersion(safeAddress, bindActionCreators(createTransaction, dispatch))
onClose() onClose()
} }

View File

@ -26,7 +26,6 @@ import Paragraph from '~/components/layout/Paragraph'
import Row from '~/components/layout/Row' import Row from '~/components/layout/Row'
import Span from '~/components/layout/Span' import Span from '~/components/layout/Span'
import type { AddressBook } from '~/logic/addressBook/model/addressBook' import type { AddressBook } from '~/logic/addressBook/model/addressBook'
import { getSafeVersion } from '~/logic/safe/utils/safeVersion'
import { type Owner } from '~/routes/safe/store/models/owner' import { type Owner } from '~/routes/safe/store/models/owner'
import type { Safe } from '~/routes/safe/store/models/safe' import type { Safe } from '~/routes/safe/store/models/safe'
@ -35,7 +34,6 @@ export const OWNERS_SETTINGS_TAB_TEST_ID = 'owner-settings-tab'
type State = { type State = {
showRemoveSafe: boolean, showRemoveSafe: boolean,
menuOptionIndex: number, menuOptionIndex: number,
needUpdate: boolean,
} }
type Props = Actions & { type Props = Actions & {
@ -68,25 +66,9 @@ class Settings extends React.Component<Props, State> {
this.state = { this.state = {
showRemoveSafe: false, showRemoveSafe: false,
menuOptionIndex: 1, menuOptionIndex: 1,
needUpdate: false,
} }
} }
componentDidMount(): void {
const checkUpdateRequirement = async () => {
let safeVersion = {}
try {
safeVersion = await getSafeVersion(this.props.safe.address)
} catch (e) {
console.error('failed to check version', e)
}
this.setState({ needUpdate: safeVersion.needUpdate })
}
checkUpdateRequirement()
}
handleChange = menuOptionIndex => () => { handleChange = menuOptionIndex => () => {
this.setState({ menuOptionIndex }) this.setState({ menuOptionIndex })
} }
@ -148,7 +130,7 @@ class Settings extends React.Component<Props, State> {
<Badge <Badge
badgeContent=" " badgeContent=" "
color="error" color="error"
invisible={!this.state.needUpdate || !granted} invisible={!safe.needsUpdate || !granted}
style={{ paddingRight: '10px' }} style={{ paddingRight: '10px' }}
variant="dot" variant="dot"
> >
@ -184,7 +166,9 @@ class Settings extends React.Component<Props, State> {
<SafeDetails <SafeDetails
createTransaction={createTransaction} createTransaction={createTransaction}
safeAddress={safeAddress} safeAddress={safeAddress}
safeCurrentVersion={safe.currentVersion}
safeName={safeName} safeName={safeName}
safeNeedsUpdate={safe.needsUpdate}
updateSafe={updateSafe} updateSafe={updateSafe}
/> />
)} )}

View File

@ -21,6 +21,7 @@ type Props = {
const statusToIcon = { const statusToIcon = {
success: OkIcon, success: OkIcon,
cancelled: ErrorIcon, cancelled: ErrorIcon,
failed: ErrorIcon,
awaiting_your_confirmation: AwaitingIcon, awaiting_your_confirmation: AwaitingIcon,
awaiting_confirmations: AwaitingIcon, awaiting_confirmations: AwaitingIcon,
awaiting_execution: AwaitingIcon, awaiting_execution: AwaitingIcon,
@ -30,6 +31,7 @@ const statusToIcon = {
const statusToLabel = { const statusToLabel = {
success: 'Success', success: 'Success',
cancelled: 'Cancelled', cancelled: 'Cancelled',
failed: 'Failed',
awaiting_your_confirmation: 'Awaiting your confirmation', awaiting_your_confirmation: 'Awaiting your confirmation',
awaiting_confirmations: 'Awaiting confirmations', awaiting_confirmations: 'Awaiting confirmations',
awaiting_execution: 'Awaiting execution', awaiting_execution: 'Awaiting execution',

View File

@ -23,6 +23,11 @@ export const styles = () => ({
color: error, color: error,
border: `1px solid ${error}`, border: `1px solid ${error}`,
}, },
failed: {
backgroundColor: 'transparent',
color: error,
border: `1px solid ${error}`,
},
awaiting_your_confirmation: { awaiting_your_confirmation: {
backgroundColor: '#d4d5d3', backgroundColor: '#d4d5d3',
color: disabled, color: disabled,

View File

@ -125,7 +125,7 @@ const TxsTable = ({
{autoColumns.map((column: Column) => ( {autoColumns.map((column: Column) => (
<TableCell <TableCell
align={column.align} align={column.align}
className={cn(classes.cell, row.status === 'cancelled' && classes.cancelledRow)} className={cn(classes.cell, ['cancelled', 'failed'].includes(row.status) && classes.cancelledRow)}
component="td" component="td"
key={column.id} key={column.id}
style={cellWidth(column.width)} style={cellWidth(column.width)}

View File

@ -1,12 +1,14 @@
// @flow // @flow
import loadAddressBookFromStorage from '~/logic/addressBook/store/actions/loadAddressBookFromStorage' import loadAddressBookFromStorage from '~/logic/addressBook/store/actions/loadAddressBookFromStorage'
import { updateAddressBookEntry } from '~/logic/addressBook/store/actions/updateAddressBookEntry' import { updateAddressBookEntry } from '~/logic/addressBook/store/actions/updateAddressBookEntry'
import fetchCollectibles from '~/logic/collectibles/store/actions/fetchCollectibles'
import fetchCurrencyValues from '~/logic/currencyValues/store/actions/fetchCurrencyValues' import fetchCurrencyValues from '~/logic/currencyValues/store/actions/fetchCurrencyValues'
import addViewedSafe from '~/logic/currentSession/store/actions/addViewedSafe' import addViewedSafe from '~/logic/currentSession/store/actions/addViewedSafe'
import activateTokensByBalance from '~/logic/tokens/store/actions/activateTokensByBalance' import activateTokensByBalance from '~/logic/tokens/store/actions/activateTokensByBalance'
import fetchTokens from '~/logic/tokens/store/actions/fetchTokens' import fetchTokens from '~/logic/tokens/store/actions/fetchTokens'
import createTransaction from '~/routes/safe/store/actions/createTransaction' import createTransaction from '~/routes/safe/store/actions/createTransaction'
import fetchEtherBalance from '~/routes/safe/store/actions/fetchEtherBalance' import fetchEtherBalance from '~/routes/safe/store/actions/fetchEtherBalance'
import fetchLatestMasterContractVersion from '~/routes/safe/store/actions/fetchLatestMasterContractVersion'
import fetchSafe, { checkAndUpdateSafe } from '~/routes/safe/store/actions/fetchSafe' import fetchSafe, { checkAndUpdateSafe } from '~/routes/safe/store/actions/fetchSafe'
import fetchTokenBalances from '~/routes/safe/store/actions/fetchTokenBalances' import fetchTokenBalances from '~/routes/safe/store/actions/fetchTokenBalances'
import fetchTransactions from '~/routes/safe/store/actions/fetchTransactions' import fetchTransactions from '~/routes/safe/store/actions/fetchTransactions'
@ -19,9 +21,11 @@ export type Actions = {
createTransaction: typeof createTransaction, createTransaction: typeof createTransaction,
fetchTransactions: typeof fetchTransactions, fetchTransactions: typeof fetchTransactions,
updateSafe: typeof updateSafe, updateSafe: typeof updateSafe,
fetchCollectibles: typeof fetchCollectibles,
fetchTokens: typeof fetchTokens, fetchTokens: typeof fetchTokens,
processTransaction: typeof processTransaction, processTransaction: typeof processTransaction,
fetchEtherBalance: typeof fetchEtherBalance, fetchEtherBalance: typeof fetchEtherBalance,
fetchLatestMasterContractVersion: typeof fetchLatestMasterContractVersion,
activateTokensByBalance: typeof activateTokensByBalance, activateTokensByBalance: typeof activateTokensByBalance,
checkAndUpdateSafeOwners: typeof checkAndUpdateSafe, checkAndUpdateSafeOwners: typeof checkAndUpdateSafe,
fetchCurrencyValues: typeof fetchCurrencyValues, fetchCurrencyValues: typeof fetchCurrencyValues,
@ -35,11 +39,13 @@ export default {
fetchTokenBalances, fetchTokenBalances,
createTransaction, createTransaction,
processTransaction, processTransaction,
fetchCollectibles,
fetchTokens, fetchTokens,
fetchTransactions, fetchTransactions,
activateTokensByBalance, activateTokensByBalance,
updateSafe, updateSafe,
fetchEtherBalance, fetchEtherBalance,
fetchLatestMasterContractVersion,
fetchCurrencyValues, fetchCurrencyValues,
checkAndUpdateSafeOwners: checkAndUpdateSafe, checkAndUpdateSafeOwners: checkAndUpdateSafe,
loadAddressBook: loadAddressBookFromStorage, loadAddressBook: loadAddressBookFromStorage,

View File

@ -34,11 +34,15 @@ class SafeView extends React.Component<Props, State> {
intervalId: IntervalID intervalId: IntervalID
longIntervalId: IntervalID
componentDidMount() { componentDidMount() {
const { const {
activeTokens, activeTokens,
addViewedSafe, addViewedSafe,
fetchCollectibles,
fetchCurrencyValues, fetchCurrencyValues,
fetchLatestMasterContractVersion,
fetchSafe, fetchSafe,
fetchTokenBalances, fetchTokenBalances,
fetchTokens, fetchTokens,
@ -47,10 +51,13 @@ class SafeView extends React.Component<Props, State> {
safeUrl, safeUrl,
} = this.props } = this.props
fetchSafe(safeUrl).then(() => { fetchLatestMasterContractVersion()
.then(() => fetchSafe(safeUrl))
.then(() => {
// The safe needs to be loaded before fetching the transactions // The safe needs to be loaded before fetching the transactions
fetchTransactions(safeUrl) fetchTransactions(safeUrl)
addViewedSafe(safeUrl) addViewedSafe(safeUrl)
fetchCollectibles()
}) })
fetchTokenBalances(safeUrl, activeTokens) fetchTokenBalances(safeUrl, activeTokens)
// fetch tokens there to get symbols for tokens in TXs list // fetch tokens there to get symbols for tokens in TXs list
@ -61,6 +68,10 @@ class SafeView extends React.Component<Props, State> {
this.intervalId = setInterval(() => { this.intervalId = setInterval(() => {
this.checkForUpdates() this.checkForUpdates()
}, TIMEOUT) }, TIMEOUT)
this.longIntervalId = setInterval(() => {
fetchCollectibles()
}, TIMEOUT * 3)
} }
componentDidUpdate(prevProps) { componentDidUpdate(prevProps) {
@ -78,6 +89,7 @@ class SafeView extends React.Component<Props, State> {
componentWillUnmount() { componentWillUnmount() {
clearInterval(this.intervalId) clearInterval(this.intervalId)
clearInterval(this.longIntervalId)
} }
onShow = (action: Action) => () => { onShow = (action: Action) => () => {

View File

@ -63,6 +63,10 @@ const getTxStatus = (tx: Transaction, userAddress: string, safe: Safe): Transact
txStatus = !userConfirmed && userIsSafeOwner ? 'awaiting_your_confirmation' : 'awaiting_confirmations' txStatus = !userConfirmed && userIsSafeOwner ? 'awaiting_your_confirmation' : 'awaiting_confirmations'
} }
if (tx.isSuccessful === false) {
txStatus = 'failed'
}
return txStatus return txStatus
} }
@ -98,11 +102,11 @@ export const extendedSafeTokensSelector: Selector<GlobalState, RouterProps, List
map.set(tokenAddress, baseToken.set('balance', tokenBalance || '0')) map.set(tokenAddress, baseToken.set('balance', tokenBalance || '0'))
} }
}) })
})
if (ethAsToken) { if (ethAsToken) {
map.set(ethAsToken.address, ethAsToken) return extendedTokens.set(ethAsToken.address, ethAsToken).toList()
} }
})
return extendedTokens.toList() return extendedTokens.toList()
}, },

View File

@ -28,7 +28,7 @@ type CreateTransactionArgs = {
to: string, to: string,
valueInWei: string, valueInWei: string,
txData: string, txData: string,
notifiedTransaction: NotifiedTransaction, notifiedTransaction: $Values<NotifiedTransaction>,
enqueueSnackbar: Function, enqueueSnackbar: Function,
closeSnackbar: Function, closeSnackbar: Function,
txNonce?: number, txNonce?: number,

View File

@ -0,0 +1,14 @@
// @flow
import type { Dispatch as ReduxDispatch } from 'redux'
import { getCurrentMasterContractLastVersion } from '~/logic/safe/utils/safeVersion'
import setLatestMasterContractVersion from '~/routes/safe/store/actions/setLatestMasterContractVersion'
import { type GlobalState } from '~/store'
const fetchLatestMasterContractVersion = () => async (dispatch: ReduxDispatch<GlobalState>) => {
const latestVersion = await getCurrentMasterContractLastVersion()
dispatch(setLatestMasterContractVersion(latestVersion))
}
export default fetchLatestMasterContractVersion

View File

@ -4,6 +4,7 @@ import type { Dispatch as ReduxDispatch } from 'redux'
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts' import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
import { getLocalSafe, getSafeName } from '~/logic/safe/utils' import { getLocalSafe, getSafeName } from '~/logic/safe/utils'
import { enabledFeatures, safeNeedsUpdate } from '~/logic/safe/utils/safeVersion'
import { sameAddress } from '~/logic/wallets/ethAddresses' import { sameAddress } from '~/logic/wallets/ethAddresses'
import { getBalanceInEtherOf, getWeb3 } from '~/logic/wallets/getWeb3' import { getBalanceInEtherOf, getWeb3 } from '~/logic/wallets/getWeb3'
import addSafe from '~/routes/safe/store/actions/addSafe' import addSafe from '~/routes/safe/store/actions/addSafe'
@ -34,7 +35,7 @@ const buildOwnersFrom = (
}) })
}) })
export const buildSafe = async (safeAdd: string, safeName: string) => { export const buildSafe = async (safeAdd: string, safeName: string, latestMasterContractVersion: string) => {
const safeAddress = getWeb3().utils.toChecksumAddress(safeAdd) const safeAddress = getWeb3().utils.toChecksumAddress(safeAdd)
const gnosisSafe = await getGnosisSafeInstanceAt(safeAddress) const gnosisSafe = await getGnosisSafeInstanceAt(safeAddress)
const ethBalance = await getBalanceInEtherOf(safeAddress) const ethBalance = await getBalanceInEtherOf(safeAddress)
@ -42,6 +43,9 @@ export const buildSafe = async (safeAdd: string, safeName: string) => {
const threshold = Number(await gnosisSafe.getThreshold()) const threshold = Number(await gnosisSafe.getThreshold())
const nonce = Number(await gnosisSafe.nonce()) const nonce = Number(await gnosisSafe.nonce())
const owners = List(buildOwnersFrom(await gnosisSafe.getOwners(), await getLocalSafe(safeAddress))) const owners = List(buildOwnersFrom(await gnosisSafe.getOwners(), await getLocalSafe(safeAddress)))
const currentVersion = await gnosisSafe.VERSION()
const needsUpdate = await safeNeedsUpdate(currentVersion, latestMasterContractVersion)
const featuresEnabled = enabledFeatures(currentVersion)
const safe: SafeProps = { const safe: SafeProps = {
address: safeAddress, address: safeAddress,
@ -50,6 +54,9 @@ export const buildSafe = async (safeAdd: string, safeName: string) => {
owners, owners,
ethBalance, ethBalance,
nonce, nonce,
currentVersion,
needsUpdate,
featuresEnabled,
} }
return safe return safe
@ -93,11 +100,12 @@ export const checkAndUpdateSafe = (safeAdd: string) => async (dispatch: ReduxDis
} }
// eslint-disable-next-line consistent-return // eslint-disable-next-line consistent-return
export default (safeAdd: string) => async (dispatch: ReduxDispatch<GlobalState>) => { export default (safeAdd: string) => async (dispatch: ReduxDispatch<GlobalState>, getState: () => GlobalState) => {
try { try {
const safeAddress = getWeb3().utils.toChecksumAddress(safeAdd) const safeAddress = getWeb3().utils.toChecksumAddress(safeAdd)
const safeName = (await getSafeName(safeAddress)) || 'LOADED SAFE' const safeName = (await getSafeName(safeAddress)) || 'LOADED SAFE'
const safeProps: SafeProps = await buildSafe(safeAddress, safeName) const latestMasterContractVersion = getState().safes.get('latestMasterContractVersion')
const safeProps: SafeProps = await buildSafe(safeAddress, safeName, latestMasterContractVersion)
dispatch(addSafe(safeProps)) dispatch(addSafe(safeProps))
} catch (err) { } catch (err) {

View File

@ -52,6 +52,7 @@ type TxServiceModel = {
executionDate: ?string, executionDate: ?string,
confirmations: ConfirmationServiceModel[], confirmations: ConfirmationServiceModel[],
isExecuted: boolean, isExecuted: boolean,
isSuccessful: boolean,
transactionHash: ?string, transactionHash: ?string,
creationTx?: boolean, creationTx?: boolean,
} }
@ -90,10 +91,13 @@ export const buildTransactionFrom = async (safeAddress: string, tx: TxServiceMod
) )
const modifySettingsTx = sameAddress(tx.to, safeAddress) && Number(tx.value) === 0 && !!tx.data const modifySettingsTx = sameAddress(tx.to, safeAddress) && Number(tx.value) === 0 && !!tx.data
const cancellationTx = sameAddress(tx.to, safeAddress) && Number(tx.value) === 0 && !tx.data const cancellationTx = sameAddress(tx.to, safeAddress) && Number(tx.value) === 0 && !tx.data
const isSendTokenTx = isTokenTransfer(tx.data, Number(tx.value)) const code = tx.to ? await web3.eth.getCode(tx.to) : ''
const isERC721Token =
code.includes('42842e0e') || (isTokenTransfer(tx.data, Number(tx.value)) && code.includes('06fdde03'))
const isSendTokenTx = !isERC721Token && isTokenTransfer(tx.data, Number(tx.value))
const isMultiSendTx = isMultisendTransaction(tx.data, Number(tx.value)) const isMultiSendTx = isMultisendTransaction(tx.data, Number(tx.value))
const isUpgradeTx = isMultiSendTx && isUpgradeTransaction(tx.data) const isUpgradeTx = isMultiSendTx && isUpgradeTransaction(tx.data)
const customTx = !sameAddress(tx.to, safeAddress) && !!tx.data && !isSendTokenTx && !isUpgradeTx const customTx = !sameAddress(tx.to, safeAddress) && !!tx.data && !isSendTokenTx && !isUpgradeTx && !isERC721Token
let refundParams = null let refundParams = null
if (tx.gasPrice > 0) { if (tx.gasPrice > 0) {
@ -163,6 +167,7 @@ export const buildTransactionFrom = async (safeAddress: string, tx: TxServiceMod
refundReceiver: tx.refundReceiver, refundReceiver: tx.refundReceiver,
refundParams, refundParams,
isExecuted: tx.isExecuted, isExecuted: tx.isExecuted,
isSuccessful: tx.isSuccessful,
submissionDate: tx.submissionDate, submissionDate: tx.submissionDate,
executor: tx.executor, executor: tx.executor,
executionDate: tx.executionDate, executionDate: tx.executionDate,

View File

@ -11,10 +11,10 @@ import { loadFromStorage } from '~/utils/storage'
const loadSafesFromStorage = () => async (dispatch: ReduxDispatch<GlobalState>) => { const loadSafesFromStorage = () => async (dispatch: ReduxDispatch<GlobalState>) => {
try { try {
const safes: ?{ [string]: SafeProps } = await loadFromStorage(SAFES_KEY) const safes: ?{ [key: string]: SafeProps } = await loadFromStorage(SAFES_KEY)
if (safes) { if (safes) {
Object.values(safes).forEach((safeProps: SafeProps) => { Object.values(safes).forEach(safeProps => {
dispatch(addSafe(buildSafe(safeProps))) dispatch(addSafe(buildSafe(safeProps)))
}) })
} }

View File

@ -0,0 +1,8 @@
// @flow
import { createAction } from 'redux-actions'
export const SET_LATEST_MASTER_CONTRACT_VERSION = 'SET_LATEST_MASTER_CONTRACT_VERSION'
const setLatestMasterContractVersion = createAction<string, *>(SET_LATEST_MASTER_CONTRACT_VERSION)
export default setLatestMasterContractVersion

View File

@ -16,6 +16,9 @@ export type SafeProps = {
nonce: number, nonce: number,
latestIncomingTxBlock?: number, latestIncomingTxBlock?: number,
recurringUser?: boolean, recurringUser?: boolean,
currentVersion: string,
needsUpdate: boolean,
featuresEnabled: string[],
} }
const SafeRecord: RecordFactory<SafeProps> = Record({ const SafeRecord: RecordFactory<SafeProps> = Record({
@ -30,6 +33,9 @@ const SafeRecord: RecordFactory<SafeProps> = Record({
nonce: 0, nonce: 0,
latestIncomingTxBlock: 0, latestIncomingTxBlock: 0,
recurringUser: undefined, recurringUser: undefined,
currentVersion: '',
needsUpdate: false,
featuresEnabled: [],
}) })
export type Safe = RecordOf<SafeProps> export type Safe = RecordOf<SafeProps>

View File

@ -20,6 +20,7 @@ export type TransactionStatus =
| 'awaiting_your_confirmation' | 'awaiting_your_confirmation'
| 'awaiting_confirmations' | 'awaiting_confirmations'
| 'success' | 'success'
| 'failed'
| 'cancelled' | 'cancelled'
| 'awaiting_execution' | 'awaiting_execution'
| 'pending' | 'pending'
@ -39,6 +40,7 @@ export type TransactionProps = {
gasToken: string, gasToken: string,
refundReceiver: string, refundReceiver: string,
isExecuted: boolean, isExecuted: boolean,
isSuccessful: boolean,
submissionDate: ?string, submissionDate: ?string,
executionDate: ?string, executionDate: ?string,
symbol: string, symbol: string,
@ -75,6 +77,7 @@ export const makeTransaction: RecordFactory<TransactionProps> = Record({
gasToken: ZERO_ADDRESS, gasToken: ZERO_ADDRESS,
refundReceiver: ZERO_ADDRESS, refundReceiver: ZERO_ADDRESS,
isExecuted: false, isExecuted: false,
isSuccessful: true,
submissionDate: '', submissionDate: '',
executor: '', executor: '',
executionDate: '', executionDate: '',

View File

@ -11,9 +11,10 @@ import { REMOVE_SAFE } from '~/routes/safe/store/actions/removeSafe'
import { REMOVE_SAFE_OWNER } from '~/routes/safe/store/actions/removeSafeOwner' import { REMOVE_SAFE_OWNER } from '~/routes/safe/store/actions/removeSafeOwner'
import { REPLACE_SAFE_OWNER } from '~/routes/safe/store/actions/replaceSafeOwner' import { REPLACE_SAFE_OWNER } from '~/routes/safe/store/actions/replaceSafeOwner'
import { SET_DEFAULT_SAFE } from '~/routes/safe/store/actions/setDefaultSafe' import { SET_DEFAULT_SAFE } from '~/routes/safe/store/actions/setDefaultSafe'
import { SET_LATEST_MASTER_CONTRACT_VERSION } from '~/routes/safe/store/actions/setLatestMasterContractVersion'
import { UPDATE_SAFE } from '~/routes/safe/store/actions/updateSafe' import { UPDATE_SAFE } from '~/routes/safe/store/actions/updateSafe'
import { UPDATE_SAFE_THRESHOLD } from '~/routes/safe/store/actions/updateSafeThreshold' import { UPDATE_SAFE_THRESHOLD } from '~/routes/safe/store/actions/updateSafeThreshold'
import { type OwnerProps, makeOwner } from '~/routes/safe/store/models/owner' import { makeOwner } from '~/routes/safe/store/models/owner'
import SafeRecord, { type SafeProps } from '~/routes/safe/store/models/safe' import SafeRecord, { type SafeProps } from '~/routes/safe/store/models/safe'
export const SAFE_REDUCER_ID = 'safes' export const SAFE_REDUCER_ID = 'safes'
@ -21,11 +22,8 @@ export const SAFE_REDUCER_ID = 'safes'
export type SafeReducerState = Map<string, *> export type SafeReducerState = Map<string, *>
export const buildSafe = (storedSafe: SafeProps) => { export const buildSafe = (storedSafe: SafeProps) => {
const names = storedSafe.owners.map((owner: OwnerProps) => owner.name) const names = storedSafe.owners.map(owner => owner.name)
const addresses = storedSafe.owners.map((owner: OwnerProps) => { const addresses = storedSafe.owners.map(owner => getWeb3().utils.toChecksumAddress(owner.address))
const checksumed = getWeb3().utils.toChecksumAddress(owner.address)
return checksumed
})
const owners = buildOwnersFrom(Array.from(names), Array.from(addresses)) const owners = buildOwnersFrom(Array.from(names), Array.from(addresses))
const activeTokens = Set(storedSafe.activeTokens) const activeTokens = Set(storedSafe.activeTokens)
const blacklistedTokens = Set(storedSafe.blacklistedTokens) const blacklistedTokens = Set(storedSafe.blacklistedTokens)
@ -53,7 +51,7 @@ export default handleActions<SafeReducerState, *>(
[ACTIVATE_TOKEN_FOR_ALL_SAFES]: (state: SafeReducerState, action: ActionType<Function>): SafeReducerState => { [ACTIVATE_TOKEN_FOR_ALL_SAFES]: (state: SafeReducerState, action: ActionType<Function>): SafeReducerState => {
const tokenAddress = action.payload const tokenAddress = action.payload
const newState = state.withMutations(map => { return state.withMutations(map => {
map map
.get('safes') .get('safes')
.keySeq() .keySeq()
@ -64,8 +62,6 @@ export default handleActions<SafeReducerState, *>(
map.updateIn(['safes', safeAddress], prevSafe => prevSafe.merge({ activeTokens })) map.updateIn(['safes', safeAddress], prevSafe => prevSafe.merge({ activeTokens }))
}) })
}) })
return newState
}, },
[ADD_SAFE]: (state: SafeReducerState, action: ActionType<Function>): SafeReducerState => { [ADD_SAFE]: (state: SafeReducerState, action: ActionType<Function>): SafeReducerState => {
const { safe }: { safe: SafeProps } = action.payload const { safe }: { safe: SafeProps } = action.payload
@ -132,10 +128,14 @@ export default handleActions<SafeReducerState, *>(
}, },
[SET_DEFAULT_SAFE]: (state: SafeReducerState, action: ActionType<Function>): SafeReducerState => [SET_DEFAULT_SAFE]: (state: SafeReducerState, action: ActionType<Function>): SafeReducerState =>
state.set('defaultSafe', action.payload), state.set('defaultSafe', action.payload),
[SET_LATEST_MASTER_CONTRACT_VERSION]: (state: SafeReducerState, action: ActionType<Function>): SafeReducerState =>
state.set('latestMasterContractVersion', action.payload),
}, },
Map({ Map({
// $FlowFixMe // $FlowFixMe
defaultSafe: undefined, defaultSafe: undefined,
safes: Map(), safes: Map(),
// $FlowFixMe
latestMasterContractVersion: '',
}), }),
) )

View File

@ -52,6 +52,14 @@ export const defaultSafeSelector: OutputSelector<GlobalState, {}, string> = crea
(safeState: Map<string, *>): string => safeState.get('defaultSafe'), (safeState: Map<string, *>): string => safeState.get('defaultSafe'),
) )
export const latestMasterContractVersionSelector: OutputSelector<
GlobalState,
{},
string,
> = createSelector(safesStateSelector, (safeState: Map<string, *>): string =>
safeState.get('latestMasterContractVersion'),
)
const transactionsSelector = (state: GlobalState): TransactionsState => state[TRANSACTIONS_REDUCER_ID] const transactionsSelector = (state: GlobalState): TransactionsState => state[TRANSACTIONS_REDUCER_ID]
const cancellationTransactionsSelector = (state: GlobalState): CancelTransactionsState => const cancellationTransactionsSelector = (state: GlobalState): CancelTransactionsState =>

View File

@ -6,6 +6,14 @@ import thunk from 'redux-thunk'
import addressBookMiddleware from '~/logic/addressBook/store/middleware/addressBookMiddleware' import addressBookMiddleware from '~/logic/addressBook/store/middleware/addressBookMiddleware'
import addressBook, { ADDRESS_BOOK_REDUCER_ID } from '~/logic/addressBook/store/reducer/addressBook' import addressBook, { ADDRESS_BOOK_REDUCER_ID } from '~/logic/addressBook/store/reducer/addressBook'
import {
type NFTAssetsState,
type NFTTokensState,
NFT_ASSETS_REDUCER_ID,
NFT_TOKENS_REDUCER_ID,
nftAssetReducer,
nftTokensReducer,
} from '~/logic/collectibles/store/reducer/collectibles'
import cookies, { COOKIES_REDUCER_ID } from '~/logic/cookies/store/reducer/cookies' import cookies, { COOKIES_REDUCER_ID } from '~/logic/cookies/store/reducer/cookies'
import currencyValues, { CURRENCY_VALUES_KEY } from '~/logic/currencyValues/store/reducer/currencyValues' import currencyValues, { CURRENCY_VALUES_KEY } from '~/logic/currencyValues/store/reducer/currencyValues'
import currentSession, { import currentSession, {
@ -54,6 +62,8 @@ export type GlobalState = {
providers: ProviderState, providers: ProviderState,
router: RouterHistory, router: RouterHistory,
safes: SafeState, safes: SafeState,
nftAssets: NFTAssetsState,
nftTokens: NFTTokensState,
tokens: TokensState, tokens: TokensState,
transactions: TransactionsState, transactions: TransactionsState,
cancellationTransactions: CancelTransactionsState, cancellationTransactions: CancelTransactionsState,
@ -68,6 +78,8 @@ const reducers: CombinedReducer<GlobalState, *> = combineReducers({
router: connectRouter(history), router: connectRouter(history),
[PROVIDER_REDUCER_ID]: provider, [PROVIDER_REDUCER_ID]: provider,
[SAFE_REDUCER_ID]: safe, [SAFE_REDUCER_ID]: safe,
[NFT_ASSETS_REDUCER_ID]: nftAssetReducer,
[NFT_TOKENS_REDUCER_ID]: nftTokensReducer,
[TOKEN_REDUCER_ID]: tokens, [TOKEN_REDUCER_ID]: tokens,
[TRANSACTIONS_REDUCER_ID]: transactions, [TRANSACTIONS_REDUCER_ID]: transactions,
[CANCELLATION_TRANSACTIONS_REDUCER_ID]: cancellationTransactions, [CANCELLATION_TRANSACTIONS_REDUCER_ID]: cancellationTransactions,

View File

@ -309,6 +309,16 @@ const theme = createMuiTheme({
}, },
}, },
}, },
MuiTableContainer: {
root: {
marginLeft: '-10px',
marginRight: '-10px',
marginTop: '-10px',
paddingLeft: '10px',
paddingRight: '10px',
paddingTop: '10px',
},
},
MuiTablePagination: { MuiTablePagination: {
toolbar: { toolbar: {
paddingRight: '15px', paddingRight: '15px',

View File

@ -0,0 +1,63 @@
import { textShortener } from '~/utils/strings'
describe('Utils > strings > textShortener', () => {
it(`should return the original string if there's no room to shorten`, () => {
// Given
const text = "I'm a short string"
const shortener = textShortener() // default values
// When
const shortenText = shortener(text)
// Then
expect(shortenText).toEqual(text)
})
it(`should return a shorter version with the default ellipsis`, () => {
// Given
const text = "I'm a short string" // 18 chars long
const shortener = textShortener({ charsStart: 2, charsEnd: 2 })
// When
const shortenText = shortener(text)
// Then
expect(shortenText).toEqual("I'...ng")
})
it(`should return a shorter version with the '---' ellipsis`, () => {
// Given
const text = "I'm a short string" // 18 chars long
const shortener = textShortener({ charsStart: 2, charsEnd: 2, ellipsis: '---' })
// When
const shortenText = shortener(text)
// Then
expect(shortenText).toEqual("I'---ng")
})
it(`should return a shorter version with only the start of the string`, () => {
// Given
const text = "I'm a short string" // 18 chars long
const shortener = textShortener({ charsStart: 2, charsEnd: 0 })
// When
const shortenText = shortener(text)
// Then
expect(shortenText).toEqual("I'...")
})
it(`should return a shorter version with only the end of the string`, () => {
// Given
const text = "I'm a short string" // 18 chars long
const shortener = textShortener({ charsStart: 0, charsEnd: 2 })
// When
const shortenText = shortener(text)
// Then
expect(shortenText).toEqual('...ng')
})
})

13
src/utils/constants.js Normal file
View File

@ -0,0 +1,13 @@
// @flow
export const NETWORK = process.env.REACT_APP_NETWORK
export const GOOGLE_ANALYTICS_ID_RINKEBY = process.env.REACT_APP_GOOGLE_ANALYTICS_ID_RINKEBY
export const GOOGLE_ANALYTICS_ID_MAINNET = process.env.REACT_APP_GOOGLE_ANALYTICS_ID_MAINNET
export const INTERCOM_ID = process.env.REACT_APP_INTERCOM_ID
export const PORTIS_ID = process.env.REACT_APP_PORTIS_ID
export const SQUARELINK_ID = process.env.REACT_APP_SQUARELINK_ID
export const FORTMATIC_KEY = process.env.REACT_APP_FORTMATIC_KEY
export const INFURA_TOKEN = process.env.REACT_APP_INFURA_TOKEN || ''
export const LATEST_SAFE_VERSION = process.env.REACT_APP_LATEST_SAFE_VERSION || 'not-defined'
export const APP_VERSION = process.env.REACT_APP_APP_VERSION || 'not-defined'
export const OPENSEA_API_KEY = process.env.REACT_APP_OPENSEA_API_KEY || ''
export const COLLECTIBLES_SOURCE = process.env.REACT_APP_COLLECTIBLES_SOURCE || 'OpenSea'

54
src/utils/strings.js Normal file
View File

@ -0,0 +1,54 @@
// @flow
type ShortenTextOptionsProps = {
charsStart?: number,
charsEnd?: number,
ellipsis?: string,
}
/**
* Setups `shortenText` options
* @param {object} opts
* @param {number} opts.charsStart=10 - characters to preserve from the beginning
* @param {number} opts.charsEnd=10 - characters to preserve at the end
* @param {string} opts.ellipsis='...' - ellipsis characters
* @returns {function} shortener
*/
export const textShortener = ({ charsEnd = 10, charsStart = 10, ellipsis = '...' }: ShortenTextOptionsProps = {}) =>
/**
* @function
* @name shortener
*
* Shortens a text string based on options
* @param {string} text=null - String to shorten
* @param text
* @returns {string|?string}
*/
(text: ?string = null) => {
if (typeof text !== 'string') {
throw new TypeError(` A string is required. ${typeof text} was provided instead.`)
}
if (!text) {
return ''
}
const amountOfCharsToKeep = charsEnd + charsStart
const finalStringLength = amountOfCharsToKeep + ellipsis.length
if (finalStringLength >= text.length || !amountOfCharsToKeep) {
// no need to shorten
return text
}
const r = new RegExp(`^(.{${charsStart}}).+(.{${charsEnd}})$`)
const matchResult = r.exec(text)
if (!matchResult) {
// if for any reason the exec returns null, the text remains untouched
return text
}
const [, textStart, textEnd] = matchResult
return `${textStart}${ellipsis}${textEnd}`
}

186
yarn.lock
View File

@ -1499,6 +1499,11 @@
resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz#2b5a3ab3f918cca48a8c754c08168e3f03eba61b" resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz#2b5a3ab3f918cca48a8c754c08168e3f03eba61b"
integrity sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw== integrity sha512-shAmDyaQC4H92APFoIaVDHCx5bStIocgvbwQyxPRrbUY20V1EYTbSDchWbuwlMG3V17cprZhA6+78JfB+3DTPw==
"@openzeppelin/contracts@^2.5.0":
version "2.5.0"
resolved "https://registry.yarnpkg.com/@openzeppelin/contracts/-/contracts-2.5.0.tgz#e327a98ba1d26b7756ff62885a0aa0967a375449"
integrity sha512-t3jm8FrhL9tkkJTofkznTqo/XXdHi21w5yXwalEnaMOp22ZwZ0f/mmKdlgMMLPFa6bSVHbY88mKESwJT/7m5Lg==
"@portis/eth-json-rpc-middleware@^4.1.2": "@portis/eth-json-rpc-middleware@^4.1.2":
version "4.1.2" version "4.1.2"
resolved "https://registry.yarnpkg.com/@portis/eth-json-rpc-middleware/-/eth-json-rpc-middleware-4.1.2.tgz#391e392da03dea348c8111a8111ce4550aa24a02" resolved "https://registry.yarnpkg.com/@portis/eth-json-rpc-middleware/-/eth-json-rpc-middleware-4.1.2.tgz#391e392da03dea348c8111a8111ce4550aa24a02"
@ -2410,11 +2415,6 @@ abab@^2.0.0:
resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.3.tgz#623e2075e02eb2d3f2475e49f99c91846467907a" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.3.tgz#623e2075e02eb2d3f2475e49f99c91846467907a"
integrity sha512-tsFzPpcttalNjFBCFMqsKYQcWxxen1pgJR56by//QwvJc4/OUS3kPOOttx2tSIfjsylB0pYu7f5D3K1RCxUnUg== integrity sha512-tsFzPpcttalNjFBCFMqsKYQcWxxen1pgJR56by//QwvJc4/OUS3kPOOttx2tSIfjsylB0pYu7f5D3K1RCxUnUg==
abbrev@1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-1.1.1.tgz#f8f2c887ad10bf67f634f005b6987fed3179aac8"
integrity sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==
abi-decoder@^1.2.0: abi-decoder@^1.2.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/abi-decoder/-/abi-decoder-1.2.0.tgz#c42882dbb91b444805f0cd203a87a5cc3c22f4a8" resolved "https://registry.yarnpkg.com/abi-decoder/-/abi-decoder-1.2.0.tgz#c42882dbb91b444805f0cd203a87a5cc3c22f4a8"
@ -2659,19 +2659,11 @@ app-module-path@^2.2.0:
resolved "https://registry.yarnpkg.com/app-module-path/-/app-module-path-2.2.0.tgz#641aa55dfb7d6a6f0a8141c4b9c0aa50b6c24dd5" resolved "https://registry.yarnpkg.com/app-module-path/-/app-module-path-2.2.0.tgz#641aa55dfb7d6a6f0a8141c4b9c0aa50b6c24dd5"
integrity sha1-ZBqlXft9am8KgUHEucCqULbCTdU= integrity sha1-ZBqlXft9am8KgUHEucCqULbCTdU=
aproba@^1.0.3, aproba@^1.1.1: aproba@^1.1.1:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a"
integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw== integrity sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==
are-we-there-yet@~1.1.2:
version "1.1.5"
resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21"
integrity sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==
dependencies:
delegates "^1.0.0"
readable-stream "^2.0.6"
argparse@^1.0.7: argparse@^1.0.7:
version "1.0.10" version "1.0.10"
resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911" resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911"
@ -2857,6 +2849,11 @@ async-limiter@^1.0.0, async-limiter@~1.0.0:
resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd" resolved "https://registry.yarnpkg.com/async-limiter/-/async-limiter-1.0.1.tgz#dd379e94f0db8310b08291f9d64c3209766617fd"
integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ== integrity sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==
async-sema@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/async-sema/-/async-sema-3.1.0.tgz#3a813beb261e4cc58b19213916a48e931e21d21e"
integrity sha512-+JpRq3r0zjpRLDruS6q/nC4V5tzsaiu07521677Mdi5i+AkaU/aNJH38rYHJVQ4zvz+SSkjgc8FUI7qIZrR+3g==
async@2.6.1: async@2.6.1:
version "2.6.1" version "2.6.1"
resolved "https://registry.yarnpkg.com/async/-/async-2.6.1.tgz#b245a23ca71930044ec53fa46aa00a3e87c6a610" resolved "https://registry.yarnpkg.com/async/-/async-2.6.1.tgz#b245a23ca71930044ec53fa46aa00a3e87c6a610"
@ -4821,11 +4818,6 @@ console-browserify@^1.1.0:
resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336" resolved "https://registry.yarnpkg.com/console-browserify/-/console-browserify-1.2.0.tgz#67063cef57ceb6cf4993a2ab3a55840ae8c49336"
integrity sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA== integrity sha512-ZMkYO/LkF17QvCPqM0gxw8yUzigAOZOSWSHg91FH6orS7vcEj5dVZTidN2fQ14yBSdg97RqhSNwLUXInd52OTA==
console-control-strings@^1.0.0, console-control-strings@~1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/console-control-strings/-/console-control-strings-1.1.0.tgz#3d7cf4464db6446ea644bf4b39507f9851008e8e"
integrity sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=
constants-browserify@^1.0.0: constants-browserify@^1.0.0:
version "1.0.0" version "1.0.0"
resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75" resolved "https://registry.yarnpkg.com/constants-browserify/-/constants-browserify-1.0.0.tgz#c20b96d8c617748aaf1c16021760cd27fcb8cb75"
@ -5348,7 +5340,7 @@ debug@3.1.0, debug@=3.1.0:
dependencies: dependencies:
ms "2.0.0" ms "2.0.0"
debug@3.2.6, debug@^3.0.0, debug@^3.1.0, debug@^3.1.1, debug@^3.2.5, debug@^3.2.6: debug@3.2.6, debug@^3.0.0, debug@^3.1.0, debug@^3.1.1, debug@^3.2.5:
version "3.2.6" version "3.2.6"
resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.6.tgz#e83d17de16d8a7efb7717edbe5fb10135eee629b"
integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ== integrity sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==
@ -5464,11 +5456,6 @@ deep-equal@^1.0.1, deep-equal@~1.1.1:
object-keys "^1.1.1" object-keys "^1.1.1"
regexp.prototype.flags "^1.2.0" regexp.prototype.flags "^1.2.0"
deep-extend@^0.6.0:
version "0.6.0"
resolved "https://registry.yarnpkg.com/deep-extend/-/deep-extend-0.6.0.tgz#c4fa7c95404a17a9c3e8ca7e1537312b736330ac"
integrity sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==
deep-is@~0.1.3: deep-is@~0.1.3:
version "0.1.3" version "0.1.3"
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34" resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.3.tgz#b369d6fb5dbc13eecf524f91b070feedc357cf34"
@ -5574,11 +5561,6 @@ delayed-stream@~1.0.0:
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk= integrity sha1-3zrhmayt+31ECqrgsp4icrJOxhk=
delegates@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/delegates/-/delegates-1.0.0.tgz#84c6e159b81904fdca59a0ef44cd870d31250f9a"
integrity sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=
depd@~1.1.2: depd@~1.1.2:
version "1.1.2" version "1.1.2"
resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9" resolved "https://registry.yarnpkg.com/depd/-/depd-1.1.2.tgz#9bcd52e14c097763e749b274c4346ed2e560b5a9"
@ -5616,11 +5598,6 @@ detect-installed@^2.0.4:
dependencies: dependencies:
get-installed-path "^2.0.3" get-installed-path "^2.0.3"
detect-libc@^1.0.2:
version "1.0.3"
resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
integrity sha1-+hN8S9aY7fVc1c0CrFWfkaTEups=
detect-newline@2.X: detect-newline@2.X:
version "2.1.0" version "2.1.0"
resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2" resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2"
@ -7903,20 +7880,6 @@ ganache-core@2.7.0:
ethereumjs-wallet "0.6.3" ethereumjs-wallet "0.6.3"
web3 "1.2.1" web3 "1.2.1"
gauge@~2.7.3:
version "2.7.4"
resolved "https://registry.yarnpkg.com/gauge/-/gauge-2.7.4.tgz#2c03405c7538c39d7eb37b317022e325fb018bf7"
integrity sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=
dependencies:
aproba "^1.0.3"
console-control-strings "^1.0.0"
has-unicode "^2.0.0"
object-assign "^4.1.0"
signal-exit "^3.0.0"
string-width "^1.0.1"
strip-ansi "^3.0.1"
wide-align "^1.1.0"
gensync@^1.0.0-beta.1: gensync@^1.0.0-beta.1:
version "1.0.0-beta.1" version "1.0.0-beta.1"
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.1.tgz#58f4361ff987e5ff6e1e7a210827aa371eaac269" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.1.tgz#58f4361ff987e5ff6e1e7a210827aa371eaac269"
@ -8343,11 +8306,6 @@ has-to-string-tag-x@^1.2.0:
dependencies: dependencies:
has-symbol-support-x "^1.4.1" has-symbol-support-x "^1.4.1"
has-unicode@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9"
integrity sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=
has-value@^0.3.1: has-value@^0.3.1:
version "0.3.1" version "0.3.1"
resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f" resolved "https://registry.yarnpkg.com/has-value/-/has-value-0.3.1.tgz#7b1f58bada62ca827ec0a2078025654845995e1f"
@ -8726,7 +8684,7 @@ ice-cap@0.0.4:
cheerio "0.20.0" cheerio "0.20.0"
color-logger "0.0.3" color-logger "0.0.3"
iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@^0.4.4, iconv-lite@~0.4.13: iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@~0.4.13:
version "0.4.24" version "0.4.24"
resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b"
integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==
@ -8762,13 +8720,6 @@ iferr@^0.1.5:
resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501" resolved "https://registry.yarnpkg.com/iferr/-/iferr-0.1.5.tgz#c60eed69e6d8fdb6b3104a1fcbca1c192dc5b501"
integrity sha1-xg7taebY/bazEEofy8ocGS3FtQE= integrity sha1-xg7taebY/bazEEofy8ocGS3FtQE=
ignore-walk@^3.0.1:
version "3.0.3"
resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-3.0.3.tgz#017e2447184bfeade7c238e4aefdd1e8f95b1e37"
integrity sha512-m7o6xuOaT1aqheYHKf8W6J5pYH85ZI9w077erOzLje3JsB1gkafkAhHHY19dqjulgIZHFm32Cp5uNZgcQqdJKw==
dependencies:
minimatch "^3.0.4"
ignore@^3.3.5: ignore@^3.3.5:
version "3.3.10" version "3.3.10"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.10.tgz#0a97fb876986e8081c631160f8f9f389157f0043" resolved "https://registry.yarnpkg.com/ignore/-/ignore-3.3.10.tgz#0a97fb876986e8081c631160f8f9f389157f0043"
@ -8896,7 +8847,7 @@ inherits@2.0.3:
resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de"
integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4= integrity sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=
ini@^1.3.4, ini@^1.3.5, ini@~1.3.0: ini@^1.3.4, ini@^1.3.5:
version "1.3.5" version "1.3.5"
resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927" resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.5.tgz#eee25f56db1c9ec6085e0c22778083f596abf927"
integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw== integrity sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==
@ -11567,15 +11518,6 @@ natural-compare@^1.4.0:
resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7" resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc= integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
needle@^2.2.1:
version "2.3.3"
resolved "https://registry.yarnpkg.com/needle/-/needle-2.3.3.tgz#a041ad1d04a871b0ebb666f40baaf1fb47867117"
integrity sha512-EkY0GeSq87rWp1hoq/sH/wnTWgFVhYlnIkbJ0YJFfRgEFlz2RraCjBpFQ+vrEgEdp0ThfyHADmkChEhcb7PKyw==
dependencies:
debug "^3.2.6"
iconv-lite "^0.4.4"
sax "^1.2.4"
negotiator@0.6.2: negotiator@0.6.2:
version "0.6.2" version "0.6.2"
resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.2.tgz#feacf7ccf525a77ae9634436a64883ffeca346fb"
@ -11694,22 +11636,6 @@ node-notifier@^6.0.0:
shellwords "^0.1.1" shellwords "^0.1.1"
which "^1.3.1" which "^1.3.1"
node-pre-gyp@*:
version "0.14.0"
resolved "https://registry.yarnpkg.com/node-pre-gyp/-/node-pre-gyp-0.14.0.tgz#9a0596533b877289bcad4e143982ca3d904ddc83"
integrity sha512-+CvDC7ZttU/sSt9rFjix/P05iS43qHCOOGzcr3Ry99bXG7VX953+vFyEuph/tfqoYu8dttBkE86JSKBO2OzcxA==
dependencies:
detect-libc "^1.0.2"
mkdirp "^0.5.1"
needle "^2.2.1"
nopt "^4.0.1"
npm-packlist "^1.1.6"
npmlog "^4.0.2"
rc "^1.2.7"
rimraf "^2.6.1"
semver "^5.3.0"
tar "^4.4.2"
node-releases@^1.1.47, node-releases@^1.1.50: node-releases@^1.1.47, node-releases@^1.1.50:
version "1.1.51" version "1.1.51"
resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.51.tgz#70d0e054221343d2966006bfbd4d98622cc00bd0" resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-1.1.51.tgz#70d0e054221343d2966006bfbd4d98622cc00bd0"
@ -11717,14 +11643,6 @@ node-releases@^1.1.47, node-releases@^1.1.50:
dependencies: dependencies:
semver "^6.3.0" semver "^6.3.0"
nopt@^4.0.1:
version "4.0.3"
resolved "https://registry.yarnpkg.com/nopt/-/nopt-4.0.3.tgz#a375cad9d02fd921278d954c2254d5aa57e15e48"
integrity sha512-CvaGwVMztSMJLOeXPrez7fyfObdZqNUK1cPAEzLHrTybIua9pMdmmPR5YwtfNftIOMv3DPUhFaxsZMNTQO20Kg==
dependencies:
abbrev "1"
osenv "^0.1.4"
normalize-hex@0.0.2: normalize-hex@0.0.2:
version "0.0.2" version "0.0.2"
resolved "https://registry.yarnpkg.com/normalize-hex/-/normalize-hex-0.0.2.tgz#5491c43759db2f06b7168d8419f4925c271ab27e" resolved "https://registry.yarnpkg.com/normalize-hex/-/normalize-hex-0.0.2.tgz#5491c43759db2f06b7168d8419f4925c271ab27e"
@ -11788,27 +11706,6 @@ normalize-url@^4.1.0:
prop-types "^15.7.2" prop-types "^15.7.2"
react-is "^16.9.0" react-is "^16.9.0"
npm-bundled@^1.0.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-1.1.1.tgz#1edd570865a94cdb1bc8220775e29466c9fb234b"
integrity sha512-gqkfgGePhTpAEgUsGEgcq1rqPXA+tv/aVBlgEzfXwA1yiUJF7xtEt3CtVwOjNYQOVknDk0F20w58Fnm3EtG0fA==
dependencies:
npm-normalize-package-bin "^1.0.1"
npm-normalize-package-bin@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/npm-normalize-package-bin/-/npm-normalize-package-bin-1.0.1.tgz#6e79a41f23fd235c0623218228da7d9c23b8f6e2"
integrity sha512-EPfafl6JL5/rU+ot6P3gRSCpPDW5VmIzX959Ob1+ySFUuuYHWHekXpwdUZcKP5C+DS4GEtdJluwBjnsNDl+fSA==
npm-packlist@^1.1.6:
version "1.4.8"
resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-1.4.8.tgz#56ee6cc135b9f98ad3d51c1c95da22bbb9b2ef3e"
integrity sha512-5+AZgwru5IevF5ZdnFglB5wNlHG1AOOuw28WhUq8/8emhBmLv6jX5by4WJCh7lW0uSYZYS6DXqIsyZVIXRZU9A==
dependencies:
ignore-walk "^3.0.1"
npm-bundled "^1.0.1"
npm-normalize-package-bin "^1.0.1"
npm-programmatic@0.0.6: npm-programmatic@0.0.6:
version "0.0.6" version "0.0.6"
resolved "https://registry.yarnpkg.com/npm-programmatic/-/npm-programmatic-0.0.6.tgz#3c8f4dbb210efd65b99ee6a5ac76f27b4d5d6b78" resolved "https://registry.yarnpkg.com/npm-programmatic/-/npm-programmatic-0.0.6.tgz#3c8f4dbb210efd65b99ee6a5ac76f27b4d5d6b78"
@ -11830,16 +11727,6 @@ npm-run-path@^4.0.0:
dependencies: dependencies:
path-key "^3.0.0" path-key "^3.0.0"
npmlog@^4.0.2:
version "4.1.2"
resolved "https://registry.yarnpkg.com/npmlog/-/npmlog-4.1.2.tgz#08a7f2a8bf734604779a9efa4ad5cc717abb954b"
integrity sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==
dependencies:
are-we-there-yet "~1.1.2"
console-control-strings "~1.1.0"
gauge "~2.7.3"
set-blocking "~2.0.0"
nth-check@^1.0.2, nth-check@~1.0.1: nth-check@^1.0.2, nth-check@~1.0.1:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c" resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-1.0.2.tgz#b2bd295c37e3dd58a3bf0700376663ba4d9cf05c"
@ -12170,19 +12057,11 @@ os-locale@^3.0.0, os-locale@^3.1.0:
lcid "^2.0.0" lcid "^2.0.0"
mem "^4.0.0" mem "^4.0.0"
os-tmpdir@^1.0.0, os-tmpdir@^1.0.1, os-tmpdir@~1.0.2: os-tmpdir@^1.0.1, os-tmpdir@~1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274"
integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ= integrity sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=
osenv@^0.1.4:
version "0.1.5"
resolved "https://registry.yarnpkg.com/osenv/-/osenv-0.1.5.tgz#85cdfafaeb28e8677f416e287592b5f3f49ea410"
integrity sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==
dependencies:
os-homedir "^1.0.0"
os-tmpdir "^1.0.0"
p-cancelable@^0.3.0: p-cancelable@^0.3.0:
version "0.3.0" version "0.3.0"
resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-0.3.0.tgz#b9e123800bcebb7ac13a479be195b507b98d30fa" resolved "https://registry.yarnpkg.com/p-cancelable/-/p-cancelable-0.3.0.tgz#b9e123800bcebb7ac13a479be195b507b98d30fa"
@ -13408,16 +13287,6 @@ raw-body@2.4.0:
iconv-lite "0.4.24" iconv-lite "0.4.24"
unpipe "1.0.0" unpipe "1.0.0"
rc@^1.2.7:
version "1.2.8"
resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"
integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==
dependencies:
deep-extend "^0.6.0"
ini "~1.3.0"
minimist "^1.2.0"
strip-json-comments "~2.0.1"
react-dev-utils@^10.0.0: react-dev-utils@^10.0.0:
version "10.2.0" version "10.2.0"
resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-10.2.0.tgz#b11cc48aa2be2502fb3c27a50d1dfa95cfa9dfe0" resolved "https://registry.yarnpkg.com/react-dev-utils/-/react-dev-utils-10.2.0.tgz#b11cc48aa2be2502fb3c27a50d1dfa95cfa9dfe0"
@ -13634,7 +13503,7 @@ read-pkg@^3.0.0:
normalize-package-data "^2.3.2" normalize-package-data "^2.3.2"
path-type "^3.0.0" path-type "^3.0.0"
"readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.0.6, readable-stream@^2.1.4, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.2.9, readable-stream@^2.3.0, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6: "readable-stream@1 || 2", readable-stream@^2.0.0, readable-stream@^2.0.1, readable-stream@^2.0.2, readable-stream@^2.0.4, readable-stream@^2.0.5, readable-stream@^2.1.4, readable-stream@^2.1.5, readable-stream@^2.2.2, readable-stream@^2.2.9, readable-stream@^2.3.0, readable-stream@^2.3.3, readable-stream@^2.3.5, readable-stream@^2.3.6, readable-stream@~2.3.6:
version "2.3.7" version "2.3.7"
resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.7.tgz#1eca1cf711aef814c04f62252a36a62f6cb23b57"
integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw== integrity sha512-Ebho8K4jIbHAxnuxi7o42OrZgF/ZTNcsZj6nRKyUmkhLFq8CHItp/fy6hQZuZmP/n3yZ9VBUbp4zz/mX8hmYPw==
@ -14243,7 +14112,7 @@ rimraf@2.6.3, rimraf@~2.6.2:
dependencies: dependencies:
glob "^7.1.3" glob "^7.1.3"
rimraf@^2.2.8, rimraf@^2.5.4, rimraf@^2.6.1, rimraf@^2.6.3, rimraf@^2.7.1: rimraf@^2.2.8, rimraf@^2.5.4, rimraf@^2.6.3, rimraf@^2.7.1:
version "2.7.1" version "2.7.1"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==
@ -14390,7 +14259,7 @@ sane@^4.0.2, sane@^4.0.3:
minimist "^1.1.1" minimist "^1.1.1"
walker "~1.0.5" walker "~1.0.5"
sax@^1.1.4, sax@^1.2.4, sax@~1.2.4: sax@^1.1.4, sax@~1.2.4:
version "1.2.4" version "1.2.4"
resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9"
integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==
@ -14658,7 +14527,7 @@ servify@^0.1.12:
request "^2.79.0" request "^2.79.0"
xhr "^2.3.3" xhr "^2.3.3"
set-blocking@^2.0.0, set-blocking@~2.0.0: set-blocking@^2.0.0:
version "2.0.0" version "2.0.0"
resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7"
integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc= integrity sha1-BF+XgtARrppoA93TgrJDkrPYkPc=
@ -15241,7 +15110,7 @@ string-width@^1.0.1:
is-fullwidth-code-point "^1.0.0" is-fullwidth-code-point "^1.0.0"
strip-ansi "^3.0.0" strip-ansi "^3.0.0"
"string-width@^1.0.2 || 2", string-width@^2.0.0, string-width@^2.1.0, string-width@^2.1.1: string-width@^2.0.0, string-width@^2.1.0, string-width@^2.1.1:
version "2.1.1" version "2.1.1"
resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e" resolved "https://registry.yarnpkg.com/string-width/-/string-width-2.1.1.tgz#ab93f27a8dc13d28cac815c462143a6d9012ae9e"
integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw== integrity sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==
@ -15421,7 +15290,7 @@ strip-indent@^3.0.0:
dependencies: dependencies:
min-indent "^1.0.0" min-indent "^1.0.0"
strip-json-comments@^2.0.1, strip-json-comments@~2.0.1: strip-json-comments@^2.0.1:
version "2.0.1" version "2.0.1"
resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a" resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-2.0.1.tgz#3c531942e908c2697c0ec344858c286c7ca0a60a"
integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo= integrity sha1-PFMZQukIwml8DsNEhYwobHygpgo=
@ -15625,7 +15494,7 @@ tar-stream@^1.5.2:
to-buffer "^1.1.1" to-buffer "^1.1.1"
xtend "^4.0.0" xtend "^4.0.0"
tar@^4.0.2, tar@^4.4.2: tar@^4.0.2:
version "4.4.13" version "4.4.13"
resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.13.tgz#43b364bc52888d555298637b10d60790254ab525" resolved "https://registry.yarnpkg.com/tar/-/tar-4.4.13.tgz#43b364bc52888d555298637b10d60790254ab525"
integrity sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA== integrity sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA==
@ -17347,9 +17216,9 @@ web3-provider-engine@^15.0.4:
xhr "^2.2.0" xhr "^2.2.0"
xtend "^4.0.1" xtend "^4.0.1"
"web3-provider-engine@git+https://github.com/trufflesuite/provider-engine.git#web3-one": "web3-provider-engine@https://github.com/trufflesuite/provider-engine#web3-one":
version "14.0.6" version "14.0.6"
resolved "git+https://github.com/trufflesuite/provider-engine.git#3538c60bc4836b73ccae1ac3f64c8fed8ef19c1a" resolved "https://github.com/trufflesuite/provider-engine#3538c60bc4836b73ccae1ac3f64c8fed8ef19c1a"
dependencies: dependencies:
async "^2.5.0" async "^2.5.0"
backoff "^2.5.0" backoff "^2.5.0"
@ -17789,13 +17658,6 @@ which@^2.0.1:
dependencies: dependencies:
isexe "^2.0.0" isexe "^2.0.0"
wide-align@^1.1.0:
version "1.1.3"
resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457"
integrity sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==
dependencies:
string-width "^1.0.2 || 2"
window-size@^0.2.0: window-size@^0.2.0:
version "0.2.0" version "0.2.0"
resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.2.0.tgz#b4315bb4214a3d7058ebeee892e13fa24d98b075" resolved "https://registry.yarnpkg.com/window-size/-/window-size-0.2.0.tgz#b4315bb4214a3d7058ebeee892e13fa24d98b075"