diff --git a/web/cashtab/src/components/Tokens/CreateTokenForm.js b/web/cashtab/src/components/Tokens/CreateTokenForm.js index e9064deb7..f90f8fcb5 100644 --- a/web/cashtab/src/components/Tokens/CreateTokenForm.js +++ b/web/cashtab/src/components/Tokens/CreateTokenForm.js @@ -1,821 +1,816 @@ import React, { useState, useCallback } from 'react'; import styled from 'styled-components'; import PropTypes from 'prop-types'; import { AntdFormWrapper } from 'components/Common/EnhancedInputs'; import { currency } from 'components/Common/Ticker.js'; import { CropControlModal, CropperContainer, ControlsContainer, } from '../Common/CropControlModal'; import { WalletContext } from 'utils/context'; import { isValidTokenName, isValidTokenTicker, isValidTokenDecimals, isValidTokenInitialQty, isValidTokenDocumentUrl, } from 'utils/validation'; import { PlusSquareOutlined, UploadOutlined, PaperClipOutlined, } from '@ant-design/icons'; import { SmartButton } from 'components/Common/PrimaryButton'; import { Form, Input, Modal, Button, Slider, Tooltip, Upload, Typography, Switch, } from 'antd'; import { TokenParamLabel, FormLabel } from 'components/Common/Atoms'; import { createTokenNotification, tokenIconSubmitSuccess, errorNotification, } from 'components/Common/Notifications'; import Cropper from 'react-easy-crop'; import getCroppedImg from 'utils/icons/cropImage'; import getRoundImg from 'utils/icons/roundImage'; import getResizedImage from 'utils/icons/resizeImage'; const { Dragger } = Upload; export const CreateTokenCtn = styled.div` margin-top: 20px; h3 { color: ${props => props.theme.contrast}; } .ant-form-item { margin-bottom: 0px; } .ant-typography { color: ${props => props.theme.lightGrey}; } div.ant-upload-list.ant-upload-list-text { color: ${props => props.theme.lightGrey}; } svg { color: ${props => props.theme.lightGrey}; } `; const CreateTokenForm = ({ BCH, getRestUrl, createToken, disabled, passLoadingStatus, }) => { const { wallet } = React.useContext(WalletContext); // eToken icon adds const [tokenIcon, setTokenIcon] = useState(''); const [loading, setLoading] = useState(false); const [fileName, setFileName] = useState(''); const [tokenIconFileList, setTokenIconFileList] = useState(); const [rawImageUrl, setRawImageUrl] = useState(''); const [imageUrl, setImageUrl] = useState(''); const [showCropModal, setShowCropModal] = useState(false); const [roundSelection, setRoundSelection] = useState(true); const [crop, setCrop] = useState({ x: 0, y: 0 }); const [rotation, setRotation] = useState(0); const [zoom, setZoom] = useState(1); const [croppedAreaPixels, setCroppedAreaPixels] = useState(null); const onCropComplete = useCallback((croppedArea, croppedAreaPixels) => { setCroppedAreaPixels(croppedAreaPixels); }, []); const showCroppedImage = useCallback(async () => { setLoading(true); try { let imageToResize; const croppedResult = await getCroppedImg( rawImageUrl, croppedAreaPixels, rotation, fileName, ); if (roundSelection) { imageToResize = await getRoundImg(croppedResult.url, fileName); } else { imageToResize = croppedResult; } await getResizedImage( imageToResize.url, resizedResult => { setTokenIcon(resizedResult.file); setImageUrl(resizedResult.url); }, fileName, ); } catch (e) { console.error(e); } finally { setLoading(false); } }, [croppedAreaPixels, fileName, rawImageUrl, rotation, roundSelection]); const onClose = useCallback(() => { setShowCropModal(false); }, []); const handleTokenIconImage = (imgFile, callback) => new Promise((resolve, reject) => { setLoading(true); try { const reader = new FileReader(); const width = 128; const height = 128; reader.readAsDataURL(imgFile); reader.addEventListener('load', () => setRawImageUrl(reader.result), ); reader.onload = event => { const img = new Image(); img.src = event.target.result; img.onload = () => { const elem = document.createElement('canvas'); //console.log(`Canvas created`); elem.width = width; elem.height = height; const ctx = elem.getContext('2d'); // img.width and img.height will contain the original dimensions ctx.drawImage(img, 0, 0, width, height); if (!HTMLCanvasElement.prototype.toBlob) { Object.defineProperty( HTMLCanvasElement.prototype, 'toBlob', { value: function (callback, type, quality) { var dataURL = this.toDataURL( type, quality, ).split(',')[1]; setTimeout(function () { var binStr = atob(dataURL), len = binStr.length, arr = new Uint8Array(len); for (var i = 0; i < len; i++) { arr[i] = binStr.charCodeAt(i); } callback( new Blob([arr], { type: type || 'image/png', }), ); }); }, }, ); } ctx.canvas.toBlob( blob => { console.log(imgFile.name); let fileNameParts = imgFile.name.split('.'); fileNameParts.pop(); let fileNamePng = fileNameParts.join('.') + '.png'; const file = new File([blob], fileNamePng, { type: 'image/png', }); setFileName(fileNamePng); const resultReader = new FileReader(); resultReader.readAsDataURL(file); setTokenIcon(file); resultReader.addEventListener('load', () => callback(resultReader.result), ); setLoading(false); setShowCropModal(true); resolve(); }, 'image/png', 1, ); }; }; } catch (err) { console.log(`Error in handleTokenIconImage()`); console.log(err); reject(err); } }); - const transformTokenIconFile = file => { - return new Promise((resolve, reject) => { - // Dragger requires this function to work properly with file as an input; - // linter requires file param to be used in the function. - console.log(file); - reject(); - }); - }; - const beforeTokenIconUpload = file => { const approvedFileTypes = ['image/png', 'image/jpg', 'image/jpeg']; try { if (!approvedFileTypes.includes(file.type)) { throw new Error('Only jpg or png image files are accepted'); } else { setLoading(true); handleTokenIconImage(file, imageUrl => setImageUrl(imageUrl)); } } catch (e) { console.error('error', e); Modal.error({ title: 'Icon Upload Error', content: e.message || e.error || JSON.stringify(e), }); setTokenIconFileList(undefined); setTokenIcon(undefined); setImageUrl(''); return false; } }; const handleChangeTokenIconUpload = info => { let list = [...info.fileList]; if (info.file.type.split('/')[0] !== 'image') { setTokenIconFileList(undefined); setImageUrl(''); } else { setTokenIconFileList(list.slice(-1)); } }; //end eToken icon adds // New Token Name const [newTokenName, setNewTokenName] = useState(''); const [newTokenNameIsValid, setNewTokenNameIsValid] = useState(null); const handleNewTokenNameInput = e => { const { value } = e.target; // validation setNewTokenNameIsValid(isValidTokenName(value)); setNewTokenName(value); }; // New Token Ticker const [newTokenTicker, setNewTokenTicker] = useState(''); const [newTokenTickerIsValid, setNewTokenTickerIsValid] = useState(null); const handleNewTokenTickerInput = e => { const { value } = e.target; // validation setNewTokenTickerIsValid(isValidTokenTicker(value)); setNewTokenTicker(value); }; // New Token Decimals const [newTokenDecimals, setNewTokenDecimals] = useState(0); const [newTokenDecimalsIsValid, setNewTokenDecimalsIsValid] = useState(true); const handleNewTokenDecimalsInput = e => { const { value } = e.target; // validation setNewTokenDecimalsIsValid(isValidTokenDecimals(value)); // Also validate the supply here if it has not yet been set if (newTokenInitialQtyIsValid !== null) { setNewTokenInitialQtyIsValid( isValidTokenInitialQty(value, newTokenDecimals), ); } setNewTokenDecimals(value); }; // New Token Initial Quantity const [newTokenInitialQty, setNewTokenInitialQty] = useState(''); const [newTokenInitialQtyIsValid, setNewTokenInitialQtyIsValid] = useState(null); const handleNewTokenInitialQtyInput = e => { const { value } = e.target; // validation setNewTokenInitialQtyIsValid( isValidTokenInitialQty(value, newTokenDecimals), ); setNewTokenInitialQty(value); }; // New Token document URL const [newTokenDocumentUrl, setNewTokenDocumentUrl] = useState(''); // Start with this as true, field is not required const [newTokenDocumentUrlIsValid, setNewTokenDocumentUrlIsValid] = useState(true); const handleNewTokenDocumentUrlInput = e => { const { value } = e.target; // validation setNewTokenDocumentUrlIsValid(isValidTokenDocumentUrl(value)); setNewTokenDocumentUrl(value); }; // New Token fixed supply // Only allow creation of fixed supply tokens until Minting support is added // New Token document hash // Do not include this; questionable value to casual users and requires significant complication // Only enable CreateToken button if all form entries are valid let tokenGenesisDataIsValid = newTokenNameIsValid && newTokenTickerIsValid && newTokenDecimalsIsValid && newTokenInitialQtyIsValid && newTokenDocumentUrlIsValid; // Modal settings const [showConfirmCreateToken, setShowConfirmCreateToken] = useState(false); const submitTokenIcon = async link => { // Get the tokenId from link const newlyMintedTokenId = link.substr(link.length - 64); let formData = new FormData(); const data = { newTokenName, newTokenTicker, newTokenDecimals, newTokenDocumentUrl, newTokenInitialQty, tokenIcon, }; for (let key in data) { formData.append(key, data[key]); } // Would get tokenId here //formData.append('tokenId', link.substr(link.length - 64)); // for now, hard code it formData.append('tokenId', newlyMintedTokenId); console.log(formData); try { const tokenIconApprovalResponse = await fetch( currency.tokenIconSubmitApi, { method: 'POST', //Note: fetch automatically assigns correct header for multipart form based on formData obj headers: { Accept: 'application/json', }, body: formData, }, ); const tokenIconApprovalResponseJson = await tokenIconApprovalResponse.json(); if (!tokenIconApprovalResponseJson.approvalRequested) { // If the backend returns a specific error msg along with "approvalRequested = false", throw that error // You may want to customize how the app reacts to different cases if (tokenIconApprovalResponseJson.msg) { throw new Error(tokenIconApprovalResponseJson.msg); } else { throw new Error('Error in uploading token icon'); } } tokenIconSubmitSuccess(); } catch (err) { console.error(err.message); errorNotification( err, err.message, 'Submitting icon for approval while creating a new eToken', ); } }; const createPreviewedToken = async () => { passLoadingStatus(true); // If data is for some reason not valid here, bail out if (!tokenGenesisDataIsValid) { return; } // data must be valid and user reviewed to get here const configObj = { name: newTokenName, ticker: newTokenTicker, documentUrl: newTokenDocumentUrl === '' ? currency.newTokenDefaultUrl : newTokenDocumentUrl, decimals: newTokenDecimals, initialQty: newTokenInitialQty, documentHash: '', }; // create token with data in state fields try { const link = await createToken( BCH, wallet, currency.defaultFee, configObj, ); createTokenNotification(link); // If this eToken has an icon, upload to server if (tokenIcon !== '') { submitTokenIcon(link); } } catch (e) { // Set loading to false here as well, as balance may not change depending on where error occured in try loop passLoadingStatus(false); let message; if (!e.error && !e.message) { message = `Transaction failed: no response from ${getRestUrl()}.`; } else if ( /Could not communicate with full node or other external service/.test( e.error, ) ) { message = 'Could not communicate with API. Please try again.'; } else if ( e.error && e.error.includes( 'too-long-mempool-chain, too many unconfirmed ancestors [limit: 50] (code 64)', ) ) { message = `The ${currency.ticker} you are trying to send has too many unconfirmed ancestors to send (limit 50). Sending will be possible after a block confirmation. Try again in about 10 minutes.`; } else { message = e.message || e.error || JSON.stringify(e); } errorNotification(e, message, 'Creating eToken'); } // Hide the modal setShowConfirmCreateToken(false); // Stop spinner passLoadingStatus(false); }; return ( <> setShowConfirmCreateToken(false)} > Name: {newTokenName}
Ticker: {newTokenTicker}
Decimals: {newTokenDecimals}
Supply: {newTokenInitialQty}
Document URL:{' '} {newTokenDocumentUrl === '' ? currency.newTokenDefaultUrl : newTokenDocumentUrl}

Create a Token

{!disabled && ( <>
Token Name handleNewTokenNameInput(e) } /> Ticker handleNewTokenTickerInput(e) } /> Decimals handleNewTokenDecimalsInput(e) } /> Supply handleNewTokenInitialQtyInput(e) } /> Document URL handleNewTokenDocumentUrlInput(e) } /> Add Image + setTimeout(() => { + onSuccess('ok', null); + }, 0) + } onChange={handleChangeTokenIconUpload} onRemove={() => false} fileList={tokenIconFileList} name="tokenIcon" style={{ backgroundColor: '#f4f4f4', }} > {imageUrl ? ( avatar ) : ( <> {' '}

Click, or drag file to this area to upload

Only jpg or png accepted

)}
{!loading && tokenIcon && ( <> setShowCropModal(true) } > {tokenIcon.name} setShowCropModal(true) } > Click here to crop or zoom your icon {' '} )} null} renderExpanded={() => ( <> {' '} setRoundSelection( !checked, ) } />{' '}
{'Zoom:'} setZoom(zoom) } min={1} max={10} step={0.1} /> {'Rotation:'} setRotation( rotation, ) } min={0} max={360} step={1} />
)} onClose={onClose} />
setShowConfirmCreateToken(true)} disabled={!tokenGenesisDataIsValid} style={{ marginTop: '30px' }} >  Create eToken )}
); }; /* passLoadingStatus must receive a default prop that is a function in order to pass the rendering unit test in CreateTokenForm.test.js status => {console.log(status)} is an arbitrary stub function */ CreateTokenForm.defaultProps = { passLoadingStatus: status => { console.log(status); }, }; CreateTokenForm.propTypes = { BCH: PropTypes.object, getRestUrl: PropTypes.func, createToken: PropTypes.func, disabled: PropTypes.bool, passLoadingStatus: PropTypes.func, }; export default CreateTokenForm;