Merge pull request #666 from gnosis/development
Merge development into master
This commit is contained in:
commit
f4bdf2f3c5
|
@ -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'>;
|
||||
}
|
|
@ -46,11 +46,13 @@
|
|||
"@material-ui/core": "4.9.5",
|
||||
"@material-ui/icons": "4.9.1",
|
||||
"@material-ui/lab": "4.0.0-alpha.39",
|
||||
"@openzeppelin/contracts": "^2.5.0",
|
||||
"@testing-library/jest-dom": "5.1.1",
|
||||
"@welldone-software/why-did-you-render": "4.0.5",
|
||||
"async-sema": "^3.1.0",
|
||||
"axios": "0.19.2",
|
||||
"bignumber.js": "9.0.0",
|
||||
"bnc-onboard": "1.3.3",
|
||||
"bnc-onboard": "1.3.5",
|
||||
"connected-react-router": "6.7.0",
|
||||
"currency-flags": "^2.1.1",
|
||||
"date-fns": "2.10.0",
|
||||
|
@ -164,4 +166,4 @@
|
|||
"webpack-dev-server": "3.10.3",
|
||||
"webpack-manifest-plugin": "2.2.0"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -2,7 +2,7 @@
|
|||
import IconButton from '@material-ui/core/IconButton'
|
||||
import { createStyles, makeStyles } from '@material-ui/core/styles'
|
||||
import Close from '@material-ui/icons/Close'
|
||||
import React from 'react'
|
||||
import React, { type Node } from 'react'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import Modal from '~/components/Modal'
|
||||
|
@ -40,8 +40,8 @@ const useStyles = makeStyles(() =>
|
|||
|
||||
type Props = {
|
||||
title: string,
|
||||
body: React.Node,
|
||||
footer: React.Node,
|
||||
body: Node,
|
||||
footer: Node,
|
||||
onClose: () => void,
|
||||
}
|
||||
|
||||
|
|
|
@ -105,8 +105,12 @@ const ConnectButton = (props: Props) => (
|
|||
color="primary"
|
||||
minWidth={140}
|
||||
onClick={async () => {
|
||||
await onboard.walletSelect()
|
||||
await onboard.walletCheck()
|
||||
const walletSelected = await onboard.walletSelect()
|
||||
|
||||
// perform wallet checks only if user selected a wallet
|
||||
if (walletSelected) {
|
||||
await onboard.walletCheck()
|
||||
}
|
||||
}}
|
||||
variant="contained"
|
||||
{...props}
|
||||
|
|
|
@ -30,6 +30,7 @@ const useStyles = makeStyles({
|
|||
padding: '27px 15px',
|
||||
position: 'fixed',
|
||||
width: '100%',
|
||||
zIndex: '5',
|
||||
},
|
||||
content: {
|
||||
maxWidth: '100%',
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
@ -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,
|
||||
}))
|
|
@ -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
|
|
@ -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
|
||||
},
|
||||
},
|
||||
[],
|
||||
)
|
|
@ -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]
|
|
@ -3,7 +3,7 @@ import { List } from 'immutable'
|
|||
|
||||
import { type Confirmation } from '~/routes/safe/store/models/confirmation'
|
||||
|
||||
// https://gnosis-safe.readthedocs.io/en/latest/contracts/signatures.html#pre-validated-signatures
|
||||
// https://docs.gnosis.io/safe/docs/docs5/#pre-validated-signatures
|
||||
// https://github.com/gnosis/safe-contracts/blob/master/test/gnosisSafeTeamEdition.js#L26
|
||||
export const generateSignaturesFromTxConfirmations = (
|
||||
confirmations: List<Confirmation>,
|
||||
|
@ -28,7 +28,7 @@ export const generateSignaturesFromTxConfirmations = (
|
|||
if (conf.signature) {
|
||||
sigs += conf.signature.slice(2)
|
||||
} else {
|
||||
// https://gnosis-safe.readthedocs.io/en/latest/contracts/signatures.html#pre-validated-signatures
|
||||
// https://docs.gnosis.io/safe/docs/docs5/#pre-validated-signatures
|
||||
sigs += `000000000000000000000000${addr.replace(
|
||||
'0x',
|
||||
'',
|
||||
|
|
|
@ -21,7 +21,7 @@ const estimateDataGasCosts = data => {
|
|||
return data.match(/.{2}/g).reduce(reducer, 0)
|
||||
}
|
||||
|
||||
// https://gnosis-safe.readthedocs.io/en/latest/contracts/transactions.html#safe-transaction-data-gas-estimation
|
||||
// https://docs.gnosis.io/safe/docs/docs4/#safe-transaction-data-gas-estimation
|
||||
// https://github.com/gnosis/safe-contracts/blob/a97c6fd24f79c0b159ddd25a10a2ebd3ea2ef926/test/utils/execution.js
|
||||
export const estimateDataGas = (
|
||||
safe: any,
|
||||
|
@ -43,7 +43,7 @@ export const estimateDataGas = (
|
|||
const gasPrice = 0 // no need to get refund when we submit txs to metamask
|
||||
const signatureCost = signatureCount * (68 + 2176 + 2176 + 6000) // array count (3 -> r, s, v) * signature count
|
||||
|
||||
// https://gnosis-safe.readthedocs.io/en/latest/contracts/signatures.html#pre-validated-signatures
|
||||
// https://docs.gnosis.io/safe/docs/docs5/#pre-validated-signatures
|
||||
const sigs = `0x000000000000000000000000${from.replace(
|
||||
'0x',
|
||||
'',
|
||||
|
@ -103,7 +103,7 @@ export const calculateTxFee = async (
|
|||
safeInstance = await getGnosisSafeInstanceAt(safeAddress)
|
||||
}
|
||||
|
||||
// https://gnosis-safe.readthedocs.io/en/latest/contracts/signatures.html#pre-validated-signatures
|
||||
// https://docs.gnosis.io/safe/docs/docs5/#pre-validated-signatures
|
||||
const sigs = `0x000000000000000000000000${from.replace(
|
||||
'0x',
|
||||
'',
|
||||
|
|
|
@ -27,7 +27,7 @@ export const estimateTxGasCosts = async (
|
|||
|
||||
let txData
|
||||
if (isExecution) {
|
||||
// https://gnosis-safe.readthedocs.io/en/latest/contracts/signatures.html#pre-validated-signatures
|
||||
// https://docs.gnosis.io/safe/docs/docs5/#pre-validated-signatures
|
||||
const signatures =
|
||||
tx && tx.confirmations
|
||||
? generateSignaturesFromTxConfirmations(tx.confirmations, preApprovingOwner)
|
||||
|
|
|
@ -1,22 +1,50 @@
|
|||
// @flow
|
||||
import semverLessThan from 'semver/functions/lt'
|
||||
import semverSatisfies from 'semver/functions/satisfies'
|
||||
import semverValid from 'semver/functions/valid'
|
||||
|
||||
import { getSafeLastVersion } from '~/config'
|
||||
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) => {
|
||||
if (!gnosisSafeInstance || !lastSafeVersion) {
|
||||
return null
|
||||
}
|
||||
const safeMasterVersion = await gnosisSafeInstance.VERSION()
|
||||
const safeMasterVersion = await getCurrentSafeVersion(gnosisSafeInstance)
|
||||
const current = semverValid(safeMasterVersion)
|
||||
const latest = semverValid(lastSafeVersion)
|
||||
const needUpdate = latest ? semverLessThan(current, latest) : false
|
||||
const needUpdate = safeNeedsUpdate(safeMasterVersion, lastSafeVersion)
|
||||
|
||||
return { current, latest, needUpdate }
|
||||
}
|
||||
|
||||
const getCurrentMasterContractLastVersion = async () => {
|
||||
export const getCurrentMasterContractLastVersion = async () => {
|
||||
const safeMaster = await getSafeMasterContract()
|
||||
let safeMasterVersion
|
||||
try {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
// @flow
|
||||
import StandardToken from '@gnosis.pm/util-contracts/build/contracts/GnosisStandardToken.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 type { Dispatch as ReduxDispatch } from 'redux'
|
||||
import contract from 'truffle-contract'
|
||||
|
@ -28,10 +29,27 @@ const createHumanFriendlyTokenContract = async () => {
|
|||
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 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>) => {
|
||||
try {
|
||||
const {
|
||||
|
|
|
@ -222,7 +222,7 @@ const AddressBookTable = ({ classes }: Props) => {
|
|||
onClose={() => setDeleteEntryModalOpen(false)}
|
||||
/>
|
||||
<SendModal
|
||||
activeScreenType="sendFunds"
|
||||
activeScreenType="chooseTxType"
|
||||
ethBalance={ethBalance}
|
||||
isOpen={sendFundsModalOpen}
|
||||
onClose={() => setSendFundsModalOpen(false)}
|
||||
|
|
|
@ -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 |
|
@ -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
|
|
@ -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>;
|
||||
}
|
|
@ -1,35 +1,46 @@
|
|||
// @flow
|
||||
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 { List } from 'immutable'
|
||||
import React, { Suspense, useEffect, useState } from 'react'
|
||||
|
||||
import Modal from '~/components/Modal'
|
||||
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 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 SendCustomTx = React.lazy(() => import('./screens/SendCustomTx'))
|
||||
|
||||
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 = {
|
||||
onClose: () => void,
|
||||
classes: Object,
|
||||
isOpen: boolean,
|
||||
safeAddress: string,
|
||||
safeName: string,
|
||||
ethBalance: string,
|
||||
tokens: List<Token>,
|
||||
selectedToken: string,
|
||||
createTransaction: Function,
|
||||
selectedToken?: string | NFTToken | {},
|
||||
createTransaction?: Function,
|
||||
activeScreenType: ActiveScreen,
|
||||
recipientAddress?: string,
|
||||
}
|
||||
|
@ -43,7 +54,7 @@ type TxStateType =
|
|||
}
|
||||
| Object
|
||||
|
||||
const styles = () => ({
|
||||
const useStyles = makeStyles({
|
||||
scalableModalWindow: {
|
||||
height: 'auto',
|
||||
},
|
||||
|
@ -51,19 +62,17 @@ const styles = () => ({
|
|||
height: 'auto',
|
||||
position: 'static',
|
||||
},
|
||||
loaderStyle: {
|
||||
height: '500px',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
})
|
||||
|
||||
const loaderStyle = {
|
||||
height: '500px',
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
}
|
||||
|
||||
const Send = ({
|
||||
const SendModal = ({
|
||||
activeScreenType,
|
||||
classes,
|
||||
createTransaction,
|
||||
ethBalance,
|
||||
isOpen,
|
||||
|
@ -74,6 +83,7 @@ const Send = ({
|
|||
selectedToken,
|
||||
tokens,
|
||||
}: Props) => {
|
||||
const classes = useStyles()
|
||||
const [activeScreen, setActiveScreen] = useState<ActiveScreen>(activeScreenType || 'chooseTxType')
|
||||
const [tx, setTx] = useState<TxStateType>({})
|
||||
|
||||
|
@ -94,6 +104,11 @@ const Send = ({
|
|||
setTx(customTxInfo)
|
||||
}
|
||||
|
||||
const handleSendCollectible = txInfo => {
|
||||
setActiveScreen('reviewCollectible')
|
||||
setTx(txInfo)
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
description="Send Tokens Form"
|
||||
|
@ -104,12 +119,14 @@ const Send = ({
|
|||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div style={loaderStyle}>
|
||||
<div className={classes.loaderStyle}>
|
||||
<CircularProgress size={40} />
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{activeScreen === 'chooseTxType' && <ChooseTxType onClose={onClose} setActiveScreen={setActiveScreen} />}
|
||||
{activeScreen === 'chooseTxType' && (
|
||||
<ChooseTxType onClose={onClose} recipientAddress={recipientAddress} setActiveScreen={setActiveScreen} />
|
||||
)}
|
||||
{activeScreen === 'sendFunds' && (
|
||||
<SendFunds
|
||||
ethBalance={ethBalance}
|
||||
|
@ -141,6 +158,7 @@ const Send = ({
|
|||
initialValues={tx}
|
||||
onClose={onClose}
|
||||
onSubmit={handleCustomTxCreation}
|
||||
recipientAddress={recipientAddress}
|
||||
safeAddress={safeAddress}
|
||||
safeName={safeName}
|
||||
/>
|
||||
|
@ -156,9 +174,21 @@ const Send = ({
|
|||
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>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default withStyles(styles)(Send)
|
||||
export default SendModal
|
||||
|
|
|
@ -1,22 +1,26 @@
|
|||
// @flow
|
||||
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 classNames from 'classnames/bind'
|
||||
import * as React from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
|
||||
import Code from '../assets/code.svg'
|
||||
import Collectible from '../assets/collectibles.svg'
|
||||
import Token from '../assets/token.svg'
|
||||
|
||||
import { mustBeEthereumContractAddress } from '~/components/forms/validator'
|
||||
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 { safeSelector } from '~/routes/safe/store/selectors'
|
||||
import { lg, md, sm } from '~/theme/variables'
|
||||
|
||||
const styles = () => ({
|
||||
const useStyles = makeStyles({
|
||||
heading: {
|
||||
padding: `${md} ${lg}`,
|
||||
justifyContent: 'space-between',
|
||||
|
@ -26,6 +30,14 @@ const styles = () => ({
|
|||
manage: {
|
||||
fontSize: lg,
|
||||
},
|
||||
disclaimer: {
|
||||
marginBottom: `-${md}`,
|
||||
paddingTop: md,
|
||||
textAlign: 'center',
|
||||
},
|
||||
disclaimerText: {
|
||||
fontSize: md,
|
||||
},
|
||||
closeIcon: {
|
||||
height: '35px',
|
||||
width: '35px',
|
||||
|
@ -51,47 +63,96 @@ const styles = () => ({
|
|||
|
||||
type Props = {
|
||||
onClose: () => void,
|
||||
classes: Object,
|
||||
recipientAddress?: string,
|
||||
setActiveScreen: Function,
|
||||
}
|
||||
|
||||
const ChooseTxType = ({ classes, onClose, setActiveScreen }: Props) => (
|
||||
<>
|
||||
<Row align="center" className={classes.heading} grow>
|
||||
<Paragraph className={classes.manage} noMargin weight="bolder">
|
||||
Send
|
||||
</Paragraph>
|
||||
<IconButton disableRipple onClick={onClose}>
|
||||
<Close className={classes.closeIcon} />
|
||||
</IconButton>
|
||||
</Row>
|
||||
<Hairline />
|
||||
<Row align="center">
|
||||
<Col className={classes.buttonColumn} layout="column" middle="xs">
|
||||
<Button
|
||||
className={classes.firstButton}
|
||||
color="primary"
|
||||
minHeight={52}
|
||||
minWidth={260}
|
||||
onClick={() => setActiveScreen('sendFunds')}
|
||||
variant="contained"
|
||||
>
|
||||
<Img alt="Send funds" className={classNames(classes.leftIcon, classes.iconSmall)} src={Token} />
|
||||
Send funds
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
minHeight={52}
|
||||
minWidth={260}
|
||||
onClick={() => setActiveScreen('sendCustomTx')}
|
||||
variant="outlined"
|
||||
>
|
||||
<Img alt="Send custom transaction" className={classNames(classes.leftIcon, classes.iconSmall)} src={Code} />
|
||||
Send custom transaction
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
)
|
||||
const ChooseTxType = ({ onClose, recipientAddress, setActiveScreen }: Props) => {
|
||||
const classes = useStyles()
|
||||
const { featuresEnabled } = useSelector(safeSelector)
|
||||
const erc721Enabled = featuresEnabled.includes('ERC721')
|
||||
const [disableCustomTx, setDisableCustomTx] = React.useState(!!recipientAddress)
|
||||
|
||||
export default withStyles(styles)(ChooseTxType)
|
||||
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>
|
||||
<Paragraph className={classes.manage} noMargin weight="bolder">
|
||||
Send
|
||||
</Paragraph>
|
||||
<IconButton disableRipple onClick={onClose}>
|
||||
<Close className={classes.closeIcon} />
|
||||
</IconButton>
|
||||
</Row>
|
||||
<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">
|
||||
<Col className={classes.buttonColumn} layout="column" middle="xs">
|
||||
<Button
|
||||
className={classes.firstButton}
|
||||
color="primary"
|
||||
minHeight={52}
|
||||
minWidth={260}
|
||||
onClick={() => setActiveScreen('sendFunds')}
|
||||
variant="contained"
|
||||
>
|
||||
<Img alt="Send funds" className={classNames(classes.leftIcon, classes.iconSmall)} src={Token} />
|
||||
Send funds
|
||||
</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
|
||||
color="primary"
|
||||
disabled={disableCustomTx}
|
||||
minHeight={52}
|
||||
minWidth={260}
|
||||
onClick={() => setActiveScreen('sendCustomTx')}
|
||||
variant="outlined"
|
||||
>
|
||||
<Img alt="Send custom transaction" className={classNames(classes.leftIcon, classes.iconSmall)} src={Code} />
|
||||
Send custom transaction
|
||||
</Button>
|
||||
</Col>
|
||||
</Row>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default ChooseTxType
|
||||
|
|
|
@ -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)
|
|
@ -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',
|
||||
},
|
||||
})
|
|
@ -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
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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
|
|
@ -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',
|
||||
},
|
||||
})
|
|
@ -34,6 +34,7 @@ import { sm } from '~/theme/variables'
|
|||
type Props = {
|
||||
onClose: () => void,
|
||||
classes: Object,
|
||||
recipientAddress: string,
|
||||
safeAddress: string,
|
||||
safeName: string,
|
||||
ethBalance: string,
|
||||
|
@ -41,10 +42,19 @@ type Props = {
|
|||
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 [selectedEntry, setSelectedEntry] = useState<Object | null>({
|
||||
address: '',
|
||||
address: recipientAddress || initialValues.recipientAddress,
|
||||
name: '',
|
||||
})
|
||||
const [pristine, setPristine] = useState<boolean>(true)
|
||||
|
|
|
@ -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 |
|
@ -11,7 +11,6 @@ import * as React from 'react'
|
|||
|
||||
import AssetTableCell from './AssetTableCell'
|
||||
import Receive from './Receive'
|
||||
import SendModal from './SendModal'
|
||||
import Tokens from './Tokens'
|
||||
import { BALANCE_TABLE_ASSET_ID, type BalanceRow, generateColumns, getBalanceData } from './dataFetcher'
|
||||
import { styles } from './style'
|
||||
|
@ -22,36 +21,47 @@ import { type Column, cellWidth } from '~/components/Table/TableHead'
|
|||
import Button from '~/components/layout/Button'
|
||||
import ButtonLink from '~/components/layout/ButtonLink'
|
||||
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 type { BalanceCurrencyType } from '~/logic/currencyValues/store/model/currencyValues'
|
||||
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 DropdownCurrency from '~/routes/safe/components/DropdownCurrency'
|
||||
import { history } from '~/store'
|
||||
|
||||
export const MANAGE_TOKENS_BUTTON_TEST_ID = 'manage-tokens-btn'
|
||||
export const BALANCE_ROW_TEST_ID = 'balance-row'
|
||||
|
||||
type State = {
|
||||
showToken: boolean,
|
||||
showReceive: boolean,
|
||||
erc721Enabled: boolean,
|
||||
subMenuOptions: string[],
|
||||
sendFunds: Object,
|
||||
showCoins: boolean,
|
||||
showCollectibles: boolean,
|
||||
showReceive: boolean,
|
||||
showToken: boolean,
|
||||
}
|
||||
|
||||
type Props = {
|
||||
classes: Object,
|
||||
granted: boolean,
|
||||
tokens: List<Token>,
|
||||
activateTokensByBalance: Function,
|
||||
activeTokens: List<Token>,
|
||||
blacklistedTokens: List<Token>,
|
||||
activateTokensByBalance: Function,
|
||||
fetchTokens: Function,
|
||||
safeAddress: string,
|
||||
safeName: string,
|
||||
ethBalance: string,
|
||||
classes: Object,
|
||||
createTransaction: Function,
|
||||
currencySelected: string,
|
||||
fetchCurrencyValues: Function,
|
||||
currencyValues: BalanceCurrencyType[],
|
||||
ethBalance: string,
|
||||
featuresEnabled: string[],
|
||||
fetchCurrencyValues: Function,
|
||||
fetchTokens: Function,
|
||||
granted: boolean,
|
||||
safeAddress: string,
|
||||
safeName: string,
|
||||
tokens: List<Token>,
|
||||
}
|
||||
|
||||
type Action = 'Token' | 'Send' | 'Receive'
|
||||
|
@ -60,20 +70,58 @@ class Balances extends React.Component<Props, State> {
|
|||
constructor(props) {
|
||||
super(props)
|
||||
this.state = {
|
||||
erc721Enabled: false,
|
||||
subMenuOptions: [],
|
||||
showToken: false,
|
||||
sendFunds: {
|
||||
isOpen: false,
|
||||
selectedToken: undefined,
|
||||
},
|
||||
showCoins: true,
|
||||
showCollectibles: false,
|
||||
showReceive: false,
|
||||
}
|
||||
props.fetchTokens()
|
||||
}
|
||||
|
||||
static isCoinsLocation = /\/balances\/?$/
|
||||
static isCollectiblesLocation = /\/balances\/collectibles$/
|
||||
|
||||
componentDidMount(): void {
|
||||
const { activateTokensByBalance, fetchCurrencyValues, safeAddress } = this.props
|
||||
fetchCurrencyValues(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) => () => {
|
||||
|
@ -103,7 +151,7 @@ class Balances extends React.Component<Props, State> {
|
|||
}
|
||||
|
||||
render() {
|
||||
const { sendFunds, showReceive, showToken } = this.state
|
||||
const { erc721Enabled, sendFunds, showCoins, showCollectibles, showReceive, showToken, subMenuOptions } = this.state
|
||||
const {
|
||||
activeTokens,
|
||||
blacklistedTokens,
|
||||
|
@ -125,106 +173,135 @@ class Balances extends React.Component<Props, State> {
|
|||
|
||||
return (
|
||||
<>
|
||||
<Row align="center" className={classes.message}>
|
||||
<Col end="sm" xs={12}>
|
||||
<DropdownCurrency />
|
||||
<ButtonLink onClick={this.onShow('Token')} size="lg" testId="manage-tokens-btn">
|
||||
Manage List
|
||||
</ButtonLink>
|
||||
<Modal
|
||||
description="Enable and disable tokens to be listed"
|
||||
handleClose={this.onHide('Token')}
|
||||
open={showToken}
|
||||
title="Manage List"
|
||||
>
|
||||
<Tokens
|
||||
activeTokens={activeTokens}
|
||||
blacklistedTokens={blacklistedTokens}
|
||||
onClose={this.onHide('Token')}
|
||||
safeAddress={safeAddress}
|
||||
tokens={tokens}
|
||||
/>
|
||||
</Modal>
|
||||
<Row align="center" className={classes.controls}>
|
||||
<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 />
|
||||
<ButtonLink
|
||||
className={classes.manageTokensButton}
|
||||
onClick={this.onShow('Token')}
|
||||
size="lg"
|
||||
testId="manage-tokens-btn"
|
||||
>
|
||||
Manage List
|
||||
</ButtonLink>
|
||||
<Modal
|
||||
description="Enable and disable tokens to be listed"
|
||||
handleClose={this.onHide('Token')}
|
||||
open={showToken}
|
||||
title="Manage List"
|
||||
>
|
||||
<Tokens
|
||||
activeTokens={activeTokens}
|
||||
blacklistedTokens={blacklistedTokens}
|
||||
onClose={this.onHide('Token')}
|
||||
safeAddress={safeAddress}
|
||||
tokens={tokens}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
<TableContainer>
|
||||
<Table
|
||||
columns={columns}
|
||||
data={filteredData}
|
||||
defaultFixed
|
||||
defaultOrderBy={BALANCE_TABLE_ASSET_ID}
|
||||
defaultRowsPerPage={10}
|
||||
label="Balances"
|
||||
size={filteredData.size}
|
||||
>
|
||||
{(sortedData: Array<BalanceRow>) =>
|
||||
sortedData.map((row: any, index: number) => (
|
||||
<TableRow className={classes.hide} data-testid={BALANCE_ROW_TEST_ID} key={index} tabIndex={-1}>
|
||||
{autoColumns.map((column: Column) => {
|
||||
const { align, id, width } = column
|
||||
let cellItem
|
||||
switch (id) {
|
||||
case BALANCE_TABLE_ASSET_ID: {
|
||||
cellItem = <AssetTableCell asset={row[id]} />
|
||||
break
|
||||
{showCoins && (
|
||||
<TableContainer>
|
||||
<Table
|
||||
columns={columns}
|
||||
data={filteredData}
|
||||
defaultFixed
|
||||
defaultOrderBy={BALANCE_TABLE_ASSET_ID}
|
||||
defaultRowsPerPage={10}
|
||||
label="Balances"
|
||||
size={filteredData.size}
|
||||
>
|
||||
{(sortedData: Array<BalanceRow>) =>
|
||||
sortedData.map((row: any, index: number) => (
|
||||
<TableRow className={classes.hide} data-testid={BALANCE_ROW_TEST_ID} key={index} tabIndex={-1}>
|
||||
{autoColumns.map((column: Column) => {
|
||||
const { align, id, width } = column
|
||||
let cellItem
|
||||
switch (id) {
|
||||
case BALANCE_TABLE_ASSET_ID: {
|
||||
cellItem = <AssetTableCell asset={row[id]} />
|
||||
break
|
||||
}
|
||||
case BALANCE_TABLE_BALANCE_ID: {
|
||||
cellItem = <div>{row[id]}</div>
|
||||
break
|
||||
}
|
||||
case BALANCE_TABLE_VALUE_ID: {
|
||||
cellItem = <div className={classes.currencyValueRow}>{row[id]}</div>
|
||||
break
|
||||
}
|
||||
default: {
|
||||
cellItem = null
|
||||
break
|
||||
}
|
||||
}
|
||||
case BALANCE_TABLE_BALANCE_ID: {
|
||||
cellItem = <div>{row[id]}</div>
|
||||
break
|
||||
}
|
||||
case BALANCE_TABLE_VALUE_ID: {
|
||||
cellItem = <div className={classes.currencyValueRow}>{row[id]}</div>
|
||||
break
|
||||
}
|
||||
default: {
|
||||
cellItem = null
|
||||
break
|
||||
}
|
||||
}
|
||||
return (
|
||||
<TableCell align={align} component="td" key={id} style={cellWidth(width)}>
|
||||
{cellItem}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
<TableCell component="td">
|
||||
<Row align="end" className={classes.actions}>
|
||||
{granted && (
|
||||
return (
|
||||
<TableCell align={align} component="td" key={id} style={cellWidth(width)}>
|
||||
{cellItem}
|
||||
</TableCell>
|
||||
)
|
||||
})}
|
||||
<TableCell component="td">
|
||||
<Row align="end" className={classes.actions}>
|
||||
{granted && (
|
||||
<Button
|
||||
className={classes.send}
|
||||
color="primary"
|
||||
onClick={() => this.showSendFunds(row.asset.address)}
|
||||
size="small"
|
||||
testId="balance-send-btn"
|
||||
variant="contained"
|
||||
>
|
||||
<CallMade
|
||||
alt="Send Transaction"
|
||||
className={classNames(classes.leftIcon, classes.iconSmall)}
|
||||
/>
|
||||
Send
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className={classes.send}
|
||||
className={classes.receive}
|
||||
color="primary"
|
||||
onClick={() => this.showSendFunds(row.asset.address)}
|
||||
onClick={this.onShow('Receive')}
|
||||
size="small"
|
||||
testId="balance-send-btn"
|
||||
variant="contained"
|
||||
>
|
||||
<CallMade
|
||||
alt="Send Transaction"
|
||||
<CallReceived
|
||||
alt="Receive Transaction"
|
||||
className={classNames(classes.leftIcon, classes.iconSmall)}
|
||||
/>
|
||||
Send
|
||||
Receive
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
className={classes.receive}
|
||||
color="primary"
|
||||
onClick={this.onShow('Receive')}
|
||||
size="small"
|
||||
variant="contained"
|
||||
>
|
||||
<CallReceived
|
||||
alt="Receive Transaction"
|
||||
className={classNames(classes.leftIcon, classes.iconSmall)}
|
||||
/>
|
||||
Receive
|
||||
</Button>
|
||||
</Row>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
}
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Row>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
}
|
||||
</Table>
|
||||
</TableContainer>
|
||||
)}
|
||||
{erc721Enabled && showCollectibles && <Collectibles />}
|
||||
<SendModal
|
||||
activeScreenType="sendFunds"
|
||||
createTransaction={createTransaction}
|
||||
|
|
|
@ -1,17 +1,66 @@
|
|||
// @flow
|
||||
import { md, sm, xs } from '~/theme/variables'
|
||||
import { md, screenSm, secondary, sm, xs } from '~/theme/variables'
|
||||
|
||||
export const styles = (theme: Object) => ({
|
||||
root: {
|
||||
width: '20px',
|
||||
marginRight: sm,
|
||||
width: '20px',
|
||||
},
|
||||
message: {
|
||||
margin: `${sm} 0`,
|
||||
padding: `${md} 0`,
|
||||
maxHeight: '54px',
|
||||
controls: {
|
||||
alignItems: 'center',
|
||||
boxSizing: 'border-box',
|
||||
justifyContent: 'flex-end',
|
||||
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',
|
||||
order: '2',
|
||||
padding: '0',
|
||||
},
|
||||
},
|
||||
manageTokensButton: {
|
||||
marginLeft: 'auto',
|
||||
|
||||
[`@media (min-width: ${screenSm}px)`]: {
|
||||
marginLeft: '0',
|
||||
},
|
||||
},
|
||||
actionIcon: {
|
||||
marginRight: theme.spacing(1),
|
||||
|
|
|
@ -13,7 +13,6 @@ import { type Actions } from '../container/actions'
|
|||
|
||||
import Balances from './Balances'
|
||||
import Receive from './Balances/Receive'
|
||||
import SendModal from './Balances/SendModal'
|
||||
import Settings from './Settings'
|
||||
import Transactions from './Transactions'
|
||||
import { AddressBookIcon } from './assets/AddressBookIcon'
|
||||
|
@ -35,9 +34,9 @@ import Hairline from '~/components/layout/Hairline'
|
|||
import Heading from '~/components/layout/Heading'
|
||||
import Paragraph from '~/components/layout/Paragraph'
|
||||
import Row from '~/components/layout/Row'
|
||||
import { getSafeVersion } from '~/logic/safe/utils/safeVersion'
|
||||
import { getEtherScanLink, getWeb3 } from '~/logic/wallets/getWeb3'
|
||||
import AddressBookTable from '~/routes/safe/components/AddressBook'
|
||||
import SendModal from '~/routes/safe/components/Balances/SendModal'
|
||||
import { type SelectorProps } from '~/routes/safe/container/selector'
|
||||
import { border } from '~/theme/variables'
|
||||
|
||||
|
@ -107,23 +106,6 @@ const Layout = (props: Props) => {
|
|||
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 { history } = props
|
||||
|
||||
|
@ -134,7 +116,7 @@ const Layout = (props: Props) => {
|
|||
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 web3Instance = getWeb3()
|
||||
|
||||
|
@ -176,7 +158,7 @@ const Layout = (props: Props) => {
|
|||
<Badge
|
||||
badgeContent=""
|
||||
color="error"
|
||||
invisible={!needUpdate || !granted}
|
||||
invisible={!safe.needsUpdate || !granted}
|
||||
style={{ paddingRight: '10px' }}
|
||||
variant="dot"
|
||||
>
|
||||
|
@ -187,7 +169,7 @@ const Layout = (props: Props) => {
|
|||
const labelBalances = (
|
||||
<>
|
||||
<BalancesIcon />
|
||||
Balances
|
||||
Assets
|
||||
</>
|
||||
)
|
||||
const labelTransactions = (
|
||||
|
@ -212,6 +194,18 @@ const Layout = (props: Props) => {
|
|||
</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 (
|
||||
<>
|
||||
<Block className={classes.container} margin="xl">
|
||||
|
@ -261,7 +255,7 @@ const Layout = (props: Props) => {
|
|||
indicatorColor="secondary"
|
||||
onChange={handleCallToRouter}
|
||||
textColor="secondary"
|
||||
value={location.pathname}
|
||||
value={tabsValue()}
|
||||
variant="scrollable"
|
||||
>
|
||||
<Tab
|
||||
|
@ -316,7 +310,7 @@ const Layout = (props: Props) => {
|
|||
<Switch>
|
||||
<Route
|
||||
exact
|
||||
path={`${match.path}/balances`}
|
||||
path={`${match.path}/balances/:assetType?`}
|
||||
render={() => (
|
||||
<Balances
|
||||
activateTokensByBalance={activateTokensByBalance}
|
||||
|
@ -326,6 +320,7 @@ const Layout = (props: Props) => {
|
|||
currencySelected={currencySelected}
|
||||
currencyValues={currencyValues}
|
||||
ethBalance={ethBalance}
|
||||
featuresEnabled={featuresEnabled}
|
||||
fetchCurrencyValues={fetchCurrencyValues}
|
||||
fetchTokens={fetchTokens}
|
||||
granted={granted}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
// @flow
|
||||
import { makeStyles } from '@material-ui/core/styles'
|
||||
import { withSnackbar } from 'notistack'
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
|
||||
import { styles } from './style'
|
||||
|
@ -19,9 +19,9 @@ import Paragraph from '~/components/layout/Paragraph'
|
|||
import Row from '~/components/layout/Row'
|
||||
import { getNotificationsFromTxType, showSnackbar } from '~/logic/notifications'
|
||||
import { TX_NOTIFICATION_TYPES } from '~/logic/safe/transactions'
|
||||
import { getSafeVersion } from '~/logic/safe/utils/safeVersion'
|
||||
import UpdateSafeModal from '~/routes/safe/components/Settings/UpdateSafeModal'
|
||||
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_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 = {
|
||||
safeAddress: string,
|
||||
safeCurrentVersion: string,
|
||||
safeName: string,
|
||||
safeNeedsUpdate: boolean,
|
||||
updateSafe: Function,
|
||||
enqueueSnackbar: Function,
|
||||
createTransaction: Function,
|
||||
|
@ -40,11 +42,19 @@ const useStyles = makeStyles(styles)
|
|||
|
||||
const SafeDetails = (props: Props) => {
|
||||
const classes = useStyles()
|
||||
const [safeVersions, setSafeVersions] = React.useState({ current: null, latest: null, needUpdate: false })
|
||||
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 = () => {
|
||||
setModalOpen(prevOpen => !prevOpen)
|
||||
|
@ -61,19 +71,6 @@ const SafeDetails = (props: Props) => {
|
|||
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 (
|
||||
<>
|
||||
<GnoForm onSubmit={handleSubmit}>
|
||||
|
@ -83,11 +80,11 @@ const SafeDetails = (props: Props) => {
|
|||
<Heading tag="h2">Safe Version</Heading>
|
||||
<Row align="end" grow>
|
||||
<Paragraph className={classes.versionNumber}>
|
||||
{safeVersions.current}
|
||||
{safeVersions.needUpdate && ` (there's a newer version: ${safeVersions.latest})`}
|
||||
{safeCurrentVersion}
|
||||
{safeNeedsUpdate && ` (there's a newer version: ${latestMasterContractVersion})`}
|
||||
</Paragraph>
|
||||
</Row>
|
||||
{safeVersions.needUpdate && isUserOwner ? (
|
||||
{safeNeedsUpdate && isUserOwner ? (
|
||||
<Row align="end" grow>
|
||||
<Paragraph>
|
||||
<Button
|
||||
|
@ -138,7 +135,7 @@ const SafeDetails = (props: Props) => {
|
|||
</Col>
|
||||
</Row>
|
||||
<Modal description="Update Safe" handleClose={toggleModal} open={isModalOpen} title="Update Safe">
|
||||
<UpdateSafeModal createTransaction={createTransaction} onClose={toggleModal} safeAddress={safeAddress} />
|
||||
<UpdateSafeModal onClose={toggleModal} safeAddress={safeAddress} />
|
||||
</Modal>
|
||||
</>
|
||||
)}
|
||||
|
|
|
@ -3,6 +3,8 @@ import IconButton from '@material-ui/core/IconButton'
|
|||
import Close from '@material-ui/icons/Close'
|
||||
import { withStyles } from '@material-ui/styles'
|
||||
import React from 'react'
|
||||
import { useDispatch } from 'react-redux'
|
||||
import { bindActionCreators } from 'redux'
|
||||
|
||||
import { styles } from './style'
|
||||
|
||||
|
@ -13,18 +15,19 @@ import Hairline from '~/components/layout/Hairline'
|
|||
import Paragraph from '~/components/layout/Paragraph'
|
||||
import Row from '~/components/layout/Row'
|
||||
import { upgradeSafeToLatestVersion } from '~/logic/safe/utils/upgradeSafe'
|
||||
import createTransaction from '~/routes/safe/store/actions/createTransaction'
|
||||
|
||||
type Props = {
|
||||
onClose: Function,
|
||||
createTransaction: Function,
|
||||
classes: Object,
|
||||
safeAddress: string,
|
||||
}
|
||||
|
||||
const UpdateSafeModal = ({ classes, createTransaction, onClose, safeAddress }: Props) => {
|
||||
const UpdateSafeModal = ({ classes, onClose, safeAddress }: Props) => {
|
||||
const dispatch = useDispatch()
|
||||
const handleSubmit = async () => {
|
||||
// Call the update safe method
|
||||
await upgradeSafeToLatestVersion(safeAddress, createTransaction)
|
||||
await upgradeSafeToLatestVersion(safeAddress, bindActionCreators(createTransaction, dispatch))
|
||||
onClose()
|
||||
}
|
||||
|
||||
|
|
|
@ -26,7 +26,6 @@ import Paragraph from '~/components/layout/Paragraph'
|
|||
import Row from '~/components/layout/Row'
|
||||
import Span from '~/components/layout/Span'
|
||||
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 { Safe } from '~/routes/safe/store/models/safe'
|
||||
|
||||
|
@ -35,7 +34,6 @@ export const OWNERS_SETTINGS_TAB_TEST_ID = 'owner-settings-tab'
|
|||
type State = {
|
||||
showRemoveSafe: boolean,
|
||||
menuOptionIndex: number,
|
||||
needUpdate: boolean,
|
||||
}
|
||||
|
||||
type Props = Actions & {
|
||||
|
@ -68,25 +66,9 @@ class Settings extends React.Component<Props, State> {
|
|||
this.state = {
|
||||
showRemoveSafe: false,
|
||||
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 => () => {
|
||||
this.setState({ menuOptionIndex })
|
||||
}
|
||||
|
@ -148,7 +130,7 @@ class Settings extends React.Component<Props, State> {
|
|||
<Badge
|
||||
badgeContent=" "
|
||||
color="error"
|
||||
invisible={!this.state.needUpdate || !granted}
|
||||
invisible={!safe.needsUpdate || !granted}
|
||||
style={{ paddingRight: '10px' }}
|
||||
variant="dot"
|
||||
>
|
||||
|
@ -184,7 +166,9 @@ class Settings extends React.Component<Props, State> {
|
|||
<SafeDetails
|
||||
createTransaction={createTransaction}
|
||||
safeAddress={safeAddress}
|
||||
safeCurrentVersion={safe.currentVersion}
|
||||
safeName={safeName}
|
||||
safeNeedsUpdate={safe.needsUpdate}
|
||||
updateSafe={updateSafe}
|
||||
/>
|
||||
)}
|
||||
|
|
|
@ -21,6 +21,7 @@ type Props = {
|
|||
const statusToIcon = {
|
||||
success: OkIcon,
|
||||
cancelled: ErrorIcon,
|
||||
failed: ErrorIcon,
|
||||
awaiting_your_confirmation: AwaitingIcon,
|
||||
awaiting_confirmations: AwaitingIcon,
|
||||
awaiting_execution: AwaitingIcon,
|
||||
|
@ -30,6 +31,7 @@ const statusToIcon = {
|
|||
const statusToLabel = {
|
||||
success: 'Success',
|
||||
cancelled: 'Cancelled',
|
||||
failed: 'Failed',
|
||||
awaiting_your_confirmation: 'Awaiting your confirmation',
|
||||
awaiting_confirmations: 'Awaiting confirmations',
|
||||
awaiting_execution: 'Awaiting execution',
|
||||
|
|
|
@ -23,6 +23,11 @@ export const styles = () => ({
|
|||
color: error,
|
||||
border: `1px solid ${error}`,
|
||||
},
|
||||
failed: {
|
||||
backgroundColor: 'transparent',
|
||||
color: error,
|
||||
border: `1px solid ${error}`,
|
||||
},
|
||||
awaiting_your_confirmation: {
|
||||
backgroundColor: '#d4d5d3',
|
||||
color: disabled,
|
||||
|
|
|
@ -125,7 +125,7 @@ const TxsTable = ({
|
|||
{autoColumns.map((column: Column) => (
|
||||
<TableCell
|
||||
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"
|
||||
key={column.id}
|
||||
style={cellWidth(column.width)}
|
||||
|
|
|
@ -1,12 +1,14 @@
|
|||
// @flow
|
||||
import loadAddressBookFromStorage from '~/logic/addressBook/store/actions/loadAddressBookFromStorage'
|
||||
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 addViewedSafe from '~/logic/currentSession/store/actions/addViewedSafe'
|
||||
import activateTokensByBalance from '~/logic/tokens/store/actions/activateTokensByBalance'
|
||||
import fetchTokens from '~/logic/tokens/store/actions/fetchTokens'
|
||||
import createTransaction from '~/routes/safe/store/actions/createTransaction'
|
||||
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 fetchTokenBalances from '~/routes/safe/store/actions/fetchTokenBalances'
|
||||
import fetchTransactions from '~/routes/safe/store/actions/fetchTransactions'
|
||||
|
@ -19,9 +21,11 @@ export type Actions = {
|
|||
createTransaction: typeof createTransaction,
|
||||
fetchTransactions: typeof fetchTransactions,
|
||||
updateSafe: typeof updateSafe,
|
||||
fetchCollectibles: typeof fetchCollectibles,
|
||||
fetchTokens: typeof fetchTokens,
|
||||
processTransaction: typeof processTransaction,
|
||||
fetchEtherBalance: typeof fetchEtherBalance,
|
||||
fetchLatestMasterContractVersion: typeof fetchLatestMasterContractVersion,
|
||||
activateTokensByBalance: typeof activateTokensByBalance,
|
||||
checkAndUpdateSafeOwners: typeof checkAndUpdateSafe,
|
||||
fetchCurrencyValues: typeof fetchCurrencyValues,
|
||||
|
@ -35,11 +39,13 @@ export default {
|
|||
fetchTokenBalances,
|
||||
createTransaction,
|
||||
processTransaction,
|
||||
fetchCollectibles,
|
||||
fetchTokens,
|
||||
fetchTransactions,
|
||||
activateTokensByBalance,
|
||||
updateSafe,
|
||||
fetchEtherBalance,
|
||||
fetchLatestMasterContractVersion,
|
||||
fetchCurrencyValues,
|
||||
checkAndUpdateSafeOwners: checkAndUpdateSafe,
|
||||
loadAddressBook: loadAddressBookFromStorage,
|
||||
|
|
|
@ -34,11 +34,15 @@ class SafeView extends React.Component<Props, State> {
|
|||
|
||||
intervalId: IntervalID
|
||||
|
||||
longIntervalId: IntervalID
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
activeTokens,
|
||||
addViewedSafe,
|
||||
fetchCollectibles,
|
||||
fetchCurrencyValues,
|
||||
fetchLatestMasterContractVersion,
|
||||
fetchSafe,
|
||||
fetchTokenBalances,
|
||||
fetchTokens,
|
||||
|
@ -47,11 +51,14 @@ class SafeView extends React.Component<Props, State> {
|
|||
safeUrl,
|
||||
} = this.props
|
||||
|
||||
fetchSafe(safeUrl).then(() => {
|
||||
// The safe needs to be loaded before fetching the transactions
|
||||
fetchTransactions(safeUrl)
|
||||
addViewedSafe(safeUrl)
|
||||
})
|
||||
fetchLatestMasterContractVersion()
|
||||
.then(() => fetchSafe(safeUrl))
|
||||
.then(() => {
|
||||
// The safe needs to be loaded before fetching the transactions
|
||||
fetchTransactions(safeUrl)
|
||||
addViewedSafe(safeUrl)
|
||||
fetchCollectibles()
|
||||
})
|
||||
fetchTokenBalances(safeUrl, activeTokens)
|
||||
// fetch tokens there to get symbols for tokens in TXs list
|
||||
fetchTokens()
|
||||
|
@ -61,6 +68,10 @@ class SafeView extends React.Component<Props, State> {
|
|||
this.intervalId = setInterval(() => {
|
||||
this.checkForUpdates()
|
||||
}, TIMEOUT)
|
||||
|
||||
this.longIntervalId = setInterval(() => {
|
||||
fetchCollectibles()
|
||||
}, TIMEOUT * 3)
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
|
@ -78,6 +89,7 @@ class SafeView extends React.Component<Props, State> {
|
|||
|
||||
componentWillUnmount() {
|
||||
clearInterval(this.intervalId)
|
||||
clearInterval(this.longIntervalId)
|
||||
}
|
||||
|
||||
onShow = (action: Action) => () => {
|
||||
|
|
|
@ -63,6 +63,10 @@ const getTxStatus = (tx: Transaction, userAddress: string, safe: Safe): Transact
|
|||
txStatus = !userConfirmed && userIsSafeOwner ? 'awaiting_your_confirmation' : 'awaiting_confirmations'
|
||||
}
|
||||
|
||||
if (tx.isSuccessful === false) {
|
||||
txStatus = 'failed'
|
||||
}
|
||||
|
||||
return txStatus
|
||||
}
|
||||
|
||||
|
@ -98,12 +102,12 @@ export const extendedSafeTokensSelector: Selector<GlobalState, RouterProps, List
|
|||
map.set(tokenAddress, baseToken.set('balance', tokenBalance || '0'))
|
||||
}
|
||||
})
|
||||
|
||||
if (ethAsToken) {
|
||||
map.set(ethAsToken.address, ethAsToken)
|
||||
}
|
||||
})
|
||||
|
||||
if (ethAsToken) {
|
||||
return extendedTokens.set(ethAsToken.address, ethAsToken).toList()
|
||||
}
|
||||
|
||||
return extendedTokens.toList()
|
||||
},
|
||||
)
|
||||
|
|
|
@ -28,7 +28,7 @@ type CreateTransactionArgs = {
|
|||
to: string,
|
||||
valueInWei: string,
|
||||
txData: string,
|
||||
notifiedTransaction: NotifiedTransaction,
|
||||
notifiedTransaction: $Values<NotifiedTransaction>,
|
||||
enqueueSnackbar: Function,
|
||||
closeSnackbar: Function,
|
||||
txNonce?: number,
|
||||
|
@ -65,7 +65,7 @@ const createTransaction = ({
|
|||
const nonce = await getNewTxNonce(txNonce, lastTx, safeInstance)
|
||||
const isExecution = await shouldExecuteTransaction(safeInstance, nonce, lastTx)
|
||||
|
||||
// https://gnosis-safe.readthedocs.io/en/latest/contracts/signatures.html#pre-validated-signatures
|
||||
// https://docs.gnosis.io/safe/docs/docs5/#pre-validated-signatures
|
||||
const sigs = `0x000000000000000000000000${from.replace(
|
||||
'0x',
|
||||
'',
|
||||
|
|
|
@ -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
|
|
@ -4,6 +4,7 @@ import type { Dispatch as ReduxDispatch } from 'redux'
|
|||
|
||||
import { getGnosisSafeInstanceAt } from '~/logic/contracts/safeContracts'
|
||||
import { getLocalSafe, getSafeName } from '~/logic/safe/utils'
|
||||
import { enabledFeatures, safeNeedsUpdate } from '~/logic/safe/utils/safeVersion'
|
||||
import { sameAddress } from '~/logic/wallets/ethAddresses'
|
||||
import { getBalanceInEtherOf, getWeb3 } from '~/logic/wallets/getWeb3'
|
||||
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 gnosisSafe = await getGnosisSafeInstanceAt(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 nonce = Number(await gnosisSafe.nonce())
|
||||
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 = {
|
||||
address: safeAddress,
|
||||
|
@ -50,6 +54,9 @@ export const buildSafe = async (safeAdd: string, safeName: string) => {
|
|||
owners,
|
||||
ethBalance,
|
||||
nonce,
|
||||
currentVersion,
|
||||
needsUpdate,
|
||||
featuresEnabled,
|
||||
}
|
||||
|
||||
return safe
|
||||
|
@ -93,11 +100,12 @@ export const checkAndUpdateSafe = (safeAdd: string) => async (dispatch: ReduxDis
|
|||
}
|
||||
|
||||
// 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 {
|
||||
const safeAddress = getWeb3().utils.toChecksumAddress(safeAdd)
|
||||
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))
|
||||
} catch (err) {
|
||||
|
|
|
@ -52,6 +52,7 @@ type TxServiceModel = {
|
|||
executionDate: ?string,
|
||||
confirmations: ConfirmationServiceModel[],
|
||||
isExecuted: boolean,
|
||||
isSuccessful: boolean,
|
||||
transactionHash: ?string,
|
||||
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 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 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
|
||||
if (tx.gasPrice > 0) {
|
||||
|
@ -163,6 +167,7 @@ export const buildTransactionFrom = async (safeAddress: string, tx: TxServiceMod
|
|||
refundReceiver: tx.refundReceiver,
|
||||
refundParams,
|
||||
isExecuted: tx.isExecuted,
|
||||
isSuccessful: tx.isSuccessful,
|
||||
submissionDate: tx.submissionDate,
|
||||
executor: tx.executor,
|
||||
executionDate: tx.executionDate,
|
||||
|
|
|
@ -11,10 +11,10 @@ import { loadFromStorage } from '~/utils/storage'
|
|||
|
||||
const loadSafesFromStorage = () => async (dispatch: ReduxDispatch<GlobalState>) => {
|
||||
try {
|
||||
const safes: ?{ [string]: SafeProps } = await loadFromStorage(SAFES_KEY)
|
||||
const safes: ?{ [key: string]: SafeProps } = await loadFromStorage(SAFES_KEY)
|
||||
|
||||
if (safes) {
|
||||
Object.values(safes).forEach((safeProps: SafeProps) => {
|
||||
Object.values(safes).forEach(safeProps => {
|
||||
dispatch(addSafe(buildSafe(safeProps)))
|
||||
})
|
||||
}
|
||||
|
|
|
@ -48,7 +48,7 @@ const processTransaction = ({
|
|||
const isExecution = approveAndExecute || (await shouldExecuteTransaction(safeInstance, nonce, lastTx))
|
||||
|
||||
let sigs = generateSignaturesFromTxConfirmations(tx.confirmations, approveAndExecute && userAddress)
|
||||
// https://gnosis-safe.readthedocs.io/en/latest/contracts/signatures.html#pre-validated-signatures
|
||||
// https://docs.gnosis.io/safe/docs/docs5/#pre-validated-signatures
|
||||
if (!sigs) {
|
||||
sigs = `0x000000000000000000000000${from.replace(
|
||||
'0x',
|
||||
|
@ -82,6 +82,12 @@ const processTransaction = ({
|
|||
transaction = isExecution ? await getExecutionTransaction(txArgs) : await getApprovalTransaction(txArgs)
|
||||
|
||||
const sendParams = { from, value: 0 }
|
||||
|
||||
// TODO find a better solution for this in dev and production.
|
||||
if (process.env.REACT_APP_ENV !== 'production') {
|
||||
sendParams.gasLimit = 1000000
|
||||
}
|
||||
|
||||
// if not set owner management tests will fail on ganache
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
sendParams.gas = '7000000'
|
||||
|
|
|
@ -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
|
|
@ -16,6 +16,9 @@ export type SafeProps = {
|
|||
nonce: number,
|
||||
latestIncomingTxBlock?: number,
|
||||
recurringUser?: boolean,
|
||||
currentVersion: string,
|
||||
needsUpdate: boolean,
|
||||
featuresEnabled: string[],
|
||||
}
|
||||
|
||||
const SafeRecord: RecordFactory<SafeProps> = Record({
|
||||
|
@ -30,6 +33,9 @@ const SafeRecord: RecordFactory<SafeProps> = Record({
|
|||
nonce: 0,
|
||||
latestIncomingTxBlock: 0,
|
||||
recurringUser: undefined,
|
||||
currentVersion: '',
|
||||
needsUpdate: false,
|
||||
featuresEnabled: [],
|
||||
})
|
||||
|
||||
export type Safe = RecordOf<SafeProps>
|
||||
|
|
|
@ -20,6 +20,7 @@ export type TransactionStatus =
|
|||
| 'awaiting_your_confirmation'
|
||||
| 'awaiting_confirmations'
|
||||
| 'success'
|
||||
| 'failed'
|
||||
| 'cancelled'
|
||||
| 'awaiting_execution'
|
||||
| 'pending'
|
||||
|
@ -39,6 +40,7 @@ export type TransactionProps = {
|
|||
gasToken: string,
|
||||
refundReceiver: string,
|
||||
isExecuted: boolean,
|
||||
isSuccessful: boolean,
|
||||
submissionDate: ?string,
|
||||
executionDate: ?string,
|
||||
symbol: string,
|
||||
|
@ -75,6 +77,7 @@ export const makeTransaction: RecordFactory<TransactionProps> = Record({
|
|||
gasToken: ZERO_ADDRESS,
|
||||
refundReceiver: ZERO_ADDRESS,
|
||||
isExecuted: false,
|
||||
isSuccessful: true,
|
||||
submissionDate: '',
|
||||
executor: '',
|
||||
executionDate: '',
|
||||
|
|
|
@ -11,9 +11,10 @@ import { REMOVE_SAFE } from '~/routes/safe/store/actions/removeSafe'
|
|||
import { REMOVE_SAFE_OWNER } from '~/routes/safe/store/actions/removeSafeOwner'
|
||||
import { REPLACE_SAFE_OWNER } from '~/routes/safe/store/actions/replaceSafeOwner'
|
||||
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_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'
|
||||
|
||||
export const SAFE_REDUCER_ID = 'safes'
|
||||
|
@ -21,11 +22,8 @@ export const SAFE_REDUCER_ID = 'safes'
|
|||
export type SafeReducerState = Map<string, *>
|
||||
|
||||
export const buildSafe = (storedSafe: SafeProps) => {
|
||||
const names = storedSafe.owners.map((owner: OwnerProps) => owner.name)
|
||||
const addresses = storedSafe.owners.map((owner: OwnerProps) => {
|
||||
const checksumed = getWeb3().utils.toChecksumAddress(owner.address)
|
||||
return checksumed
|
||||
})
|
||||
const names = storedSafe.owners.map(owner => owner.name)
|
||||
const addresses = storedSafe.owners.map(owner => getWeb3().utils.toChecksumAddress(owner.address))
|
||||
const owners = buildOwnersFrom(Array.from(names), Array.from(addresses))
|
||||
const activeTokens = Set(storedSafe.activeTokens)
|
||||
const blacklistedTokens = Set(storedSafe.blacklistedTokens)
|
||||
|
@ -53,7 +51,7 @@ export default handleActions<SafeReducerState, *>(
|
|||
[ACTIVATE_TOKEN_FOR_ALL_SAFES]: (state: SafeReducerState, action: ActionType<Function>): SafeReducerState => {
|
||||
const tokenAddress = action.payload
|
||||
|
||||
const newState = state.withMutations(map => {
|
||||
return state.withMutations(map => {
|
||||
map
|
||||
.get('safes')
|
||||
.keySeq()
|
||||
|
@ -64,8 +62,6 @@ export default handleActions<SafeReducerState, *>(
|
|||
map.updateIn(['safes', safeAddress], prevSafe => prevSafe.merge({ activeTokens }))
|
||||
})
|
||||
})
|
||||
|
||||
return newState
|
||||
},
|
||||
[ADD_SAFE]: (state: SafeReducerState, action: ActionType<Function>): SafeReducerState => {
|
||||
const { safe }: { safe: SafeProps } = action.payload
|
||||
|
@ -132,10 +128,14 @@ export default handleActions<SafeReducerState, *>(
|
|||
},
|
||||
[SET_DEFAULT_SAFE]: (state: SafeReducerState, action: ActionType<Function>): SafeReducerState =>
|
||||
state.set('defaultSafe', action.payload),
|
||||
[SET_LATEST_MASTER_CONTRACT_VERSION]: (state: SafeReducerState, action: ActionType<Function>): SafeReducerState =>
|
||||
state.set('latestMasterContractVersion', action.payload),
|
||||
},
|
||||
Map({
|
||||
// $FlowFixMe
|
||||
defaultSafe: undefined,
|
||||
safes: Map(),
|
||||
// $FlowFixMe
|
||||
latestMasterContractVersion: '',
|
||||
}),
|
||||
)
|
||||
|
|
|
@ -52,6 +52,14 @@ export const defaultSafeSelector: OutputSelector<GlobalState, {}, string> = crea
|
|||
(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 cancellationTransactionsSelector = (state: GlobalState): CancelTransactionsState =>
|
||||
|
|
|
@ -6,6 +6,14 @@ import thunk from 'redux-thunk'
|
|||
|
||||
import addressBookMiddleware from '~/logic/addressBook/store/middleware/addressBookMiddleware'
|
||||
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 currencyValues, { CURRENCY_VALUES_KEY } from '~/logic/currencyValues/store/reducer/currencyValues'
|
||||
import currentSession, {
|
||||
|
@ -54,6 +62,8 @@ export type GlobalState = {
|
|||
providers: ProviderState,
|
||||
router: RouterHistory,
|
||||
safes: SafeState,
|
||||
nftAssets: NFTAssetsState,
|
||||
nftTokens: NFTTokensState,
|
||||
tokens: TokensState,
|
||||
transactions: TransactionsState,
|
||||
cancellationTransactions: CancelTransactionsState,
|
||||
|
@ -68,6 +78,8 @@ const reducers: CombinedReducer<GlobalState, *> = combineReducers({
|
|||
router: connectRouter(history),
|
||||
[PROVIDER_REDUCER_ID]: provider,
|
||||
[SAFE_REDUCER_ID]: safe,
|
||||
[NFT_ASSETS_REDUCER_ID]: nftAssetReducer,
|
||||
[NFT_TOKENS_REDUCER_ID]: nftTokensReducer,
|
||||
[TOKEN_REDUCER_ID]: tokens,
|
||||
[TRANSACTIONS_REDUCER_ID]: transactions,
|
||||
[CANCELLATION_TRANSACTIONS_REDUCER_ID]: cancellationTransactions,
|
||||
|
|
|
@ -309,6 +309,16 @@ const theme = createMuiTheme({
|
|||
},
|
||||
},
|
||||
},
|
||||
MuiTableContainer: {
|
||||
root: {
|
||||
marginLeft: '-10px',
|
||||
marginRight: '-10px',
|
||||
marginTop: '-10px',
|
||||
paddingLeft: '10px',
|
||||
paddingRight: '10px',
|
||||
paddingTop: '10px',
|
||||
},
|
||||
},
|
||||
MuiTablePagination: {
|
||||
toolbar: {
|
||||
paddingRight: '15px',
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
})
|
|
@ -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'
|
|
@ -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}`
|
||||
}
|
Loading…
Reference in New Issue