diff --git a/web/cashtab/package-lock.json b/web/cashtab/package-lock.json --- a/web/cashtab/package-lock.json +++ b/web/cashtab/package-lock.json @@ -5,6 +5,7 @@ "requires": true, "packages": { "": { + "name": "cashtab", "version": "1.0.0", "dependencies": { "@ant-design/icons": "^4.3.0", @@ -27,6 +28,7 @@ "react-dev-utils": "^11.0.4", "react-device-detect": "^1.15.0", "react-dom": "^17.0.1", + "react-easy-crop": "^3.5.3", "react-ga": "^3.3.0", "react-image": "^4.0.3", "react-router": "^5.2.0", @@ -16391,6 +16393,11 @@ "node": ">=6" } }, + "node_modules/normalize-wheel": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz", + "integrity": "sha1-rsiGr/2wRQcNhWRH32Ls+GFG7EU=" + }, "node_modules/npm-run-path": { "version": "4.0.1", "dev": true, @@ -22024,6 +22031,24 @@ "react": "17.0.1" } }, + "node_modules/react-easy-crop": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-3.5.3.tgz", + "integrity": "sha512-ApTbh+lzKAvKqYW81ihd5J6ZTNN3vPDwi6ncFuUrHPI4bko2DlYOESkRm+0NYoW0H8YLaD7bxox+Z3EvIzAbUA==", + "dependencies": { + "normalize-wheel": "^1.0.1", + "tslib": "2.0.1" + }, + "peerDependencies": { + "react": ">=16.4.0", + "react-dom": ">=16.4.0" + } + }, + "node_modules/react-easy-crop/node_modules/tslib": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.1.tgz", + "integrity": "sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ==" + }, "node_modules/react-error-overlay": { "version": "6.0.9", "license": "MIT" @@ -39784,6 +39809,11 @@ "version": "3.3.0", "dev": true }, + "normalize-wheel": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/normalize-wheel/-/normalize-wheel-1.0.1.tgz", + "integrity": "sha1-rsiGr/2wRQcNhWRH32Ls+GFG7EU=" + }, "npm-run-path": { "version": "4.0.1", "dev": true, @@ -43390,6 +43420,22 @@ "scheduler": "^0.20.1" } }, + "react-easy-crop": { + "version": "3.5.3", + "resolved": "https://registry.npmjs.org/react-easy-crop/-/react-easy-crop-3.5.3.tgz", + "integrity": "sha512-ApTbh+lzKAvKqYW81ihd5J6ZTNN3vPDwi6ncFuUrHPI4bko2DlYOESkRm+0NYoW0H8YLaD7bxox+Z3EvIzAbUA==", + "requires": { + "normalize-wheel": "^1.0.1", + "tslib": "2.0.1" + }, + "dependencies": { + "tslib": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.0.1.tgz", + "integrity": "sha512-SgIkNheinmEBgx1IUNirK0TUD4X9yjjBRTqqjggWCU3pUEqIk3/Uwl3yRixYKT6WjQuGiwDv4NomL3wqRCj+CQ==" + } + } + }, "react-error-overlay": { "version": "6.0.9" }, diff --git a/web/cashtab/package.json b/web/cashtab/package.json --- a/web/cashtab/package.json +++ b/web/cashtab/package.json @@ -24,6 +24,7 @@ "react-dev-utils": "^11.0.4", "react-device-detect": "^1.15.0", "react-dom": "^17.0.1", + "react-easy-crop": "^3.5.3", "react-ga": "^3.3.0", "react-image": "^4.0.3", "react-router": "^5.2.0", diff --git a/web/cashtab/src/components/App.js b/web/cashtab/src/components/App.js --- a/web/cashtab/src/components/App.js +++ b/web/cashtab/src/components/App.js @@ -36,14 +36,14 @@ import { currency } from './Common/Ticker'; const GlobalStyle = createGlobalStyle` - .ant-modal-wrap > div > div.ant-modal-content > div > div > div.ant-modal-confirm-btns > button, .ant-modal > button, .ant-modal-confirm-btns > button, .ant-modal-footer > button { + .ant-modal-wrap > div > div.ant-modal-content > div > div > div.ant-modal-confirm-btns > button, .ant-modal > button, .ant-modal-confirm-btns > button, .ant-modal-footer > button, #cropControlsConfirm { border-radius: 8px; background-color: ${props => props.theme.modals.buttons.background}; color: ${props => props.theme.wallet.text.secondary}; font-weight: bold; } - .ant-modal-wrap > div > div.ant-modal-content > div > div > div.ant-modal-confirm-btns > button:hover,.ant-modal-confirm-btns > button:hover, .ant-modal-footer > button:hover { + .ant-modal-wrap > div > div.ant-modal-content > div > div > div.ant-modal-confirm-btns > button:hover,.ant-modal-confirm-btns > button:hover, .ant-modal-footer > button:hover, #cropControlsConfirm:hover { color: ${props => props.theme.primary}; transition: color 0.3s; background-color: ${props => props.theme.modals.buttons.background}; @@ -61,15 +61,22 @@ color: ${props => props.theme.contrast} !important; background-color: ${props => props.theme.primary} !important; } - #addrSwitch { + #addrSwitch, #cropSwitch { .ant-switch-checked { background-color: white !important; } } - #addrSwitch.ant-switch-checked { + #addrSwitch.ant-switch-checked, #cropSwitch.ant-switch-checked { background-image: ${props => props.theme.buttons.primary.backgroundImage} !important; } + + .ant-slider-rail { + background-color: ${props => props.theme.forms.border} !important; + } + .ant-slider-track { + background-color: ${props => props.theme.primary} !important; + } `; const CustomApp = styled.div` diff --git a/web/cashtab/src/components/Common/CropControlModal.js b/web/cashtab/src/components/Common/CropControlModal.js new file mode 100644 --- /dev/null +++ b/web/cashtab/src/components/Common/CropControlModal.js @@ -0,0 +1,53 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styled from 'styled-components'; +import { Card, Modal } from 'antd'; + +export const CropperContainer = styled.div` + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 175px; +`; +export const ControlsContainer = styled.div` + position: absolute; + padding: 12px; + bottom: 0; + left: 50%; + width: 50%; + transform: translateX(-50%); + height: 175px; + display: block; + align-items: center; +`; + +export const CropControlModal = ({ + expand, + renderExpanded = () => null, + onClose, + style, + ...otherProps +}) => { + return ( + + + {renderExpanded()} + + + ); +}; +CropControlModal.propTypes = { + expand: PropTypes.bool, + renderExpanded: PropTypes.func, + onClose: PropTypes.func, + style: PropTypes.object, +}; diff --git a/web/cashtab/src/components/Common/Notifications.js b/web/cashtab/src/components/Common/Notifications.js --- a/web/cashtab/src/components/Common/Notifications.js +++ b/web/cashtab/src/components/Common/Notifications.js @@ -40,6 +40,17 @@ }); }; +const tokenIconSubmitSuccess = () => { + notification.success({ + message: 'Success', + description: ( + Your eToken icon was successfully submitted. + ), + icon: , + style: { width: '100%' }, + }); +}; + const sendTokenNotification = link => { notification.success({ message: 'Success', @@ -136,6 +147,7 @@ export { sendXecNotification, createTokenNotification, + tokenIconSubmitSuccess, sendTokenNotification, xecReceivedNotification, eTokenReceivedNotification, diff --git a/web/cashtab/src/components/Common/Ticker.js b/web/cashtab/src/components/Common/Ticker.js --- a/web/cashtab/src/components/Common/Ticker.js +++ b/web/cashtab/src/components/Common/Ticker.js @@ -20,9 +20,10 @@ blockExplorerUrlTestnet: 'https://texplorer.bitcoinabc.org', tokenName: 'eToken', tokenTicker: 'eToken', + tokenIconSubmitApi: 'https://icons.etokens.cash/new', tokenLogo: tokenLogo, tokenPrefixes: ['etoken'], - tokenIconsUrl: '', //https://tokens.bitcoin.com/32 for BCH SLP + tokenIconsUrl: 'https://etoken-icons.s3.us-west-2.amazonaws.com/32', txHistoryCount: 5, hydrateUtxoBatchSize: 20, defaultSettings: { fiatCurrency: 'usd' }, diff --git a/web/cashtab/src/components/Tokens/CreateTokenForm.js b/web/cashtab/src/components/Tokens/CreateTokenForm.js --- a/web/cashtab/src/components/Tokens/CreateTokenForm.js +++ b/web/cashtab/src/components/Tokens/CreateTokenForm.js @@ -1,8 +1,13 @@ -import React, { useState } from 'react'; +import React, { useState, useCallback } from 'react'; import PropTypes from 'prop-types'; import { AntdFormWrapper } from '@components/Common/EnhancedInputs'; import { TokenCollapse } from '@components/Common/StyledCollapse'; import { currency } from '@components/Common/Ticker.js'; +import { + CropControlModal, + CropperContainer, + ControlsContainer, +} from '../Common/CropControlModal'; import { WalletContext } from '@utils/context'; import { isValidTokenName, @@ -11,15 +16,37 @@ isValidTokenInitialQty, isValidTokenDocumentUrl, } from '@utils/validation'; -import { PlusSquareOutlined } from '@ant-design/icons'; +import { + PlusSquareOutlined, + UploadOutlined, + PaperClipOutlined, +} from '@ant-design/icons'; import { SmartButton } from '@components/Common/PrimaryButton'; -import { Collapse, Form, Input, Modal } from 'antd'; +import { + notification, + Collapse, + Form, + Input, + Modal, + Button, + Slider, + Tooltip, + Upload, + Typography, + Switch, +} from 'antd'; const { Panel } = Collapse; import { TokenParamLabel } 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; const CreateTokenForm = ({ BCH, @@ -30,6 +57,196 @@ }) => { 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 { + const croppedResult = await getCroppedImg( + rawImageUrl, + croppedAreaPixels, + rotation, + fileName, + ); + + if (roundSelection) { + const roundResult = await getRoundImg( + croppedResult.url, + fileName, + ); + + await getResizedImage( + roundResult.url, + resizedResult => { + setTokenIcon(resizedResult.file); + setImageUrl(resizedResult.url); + }, + fileName, + ); + } else { + setTokenIcon(croppedResult.file); + setImageUrl(croppedResult.url); + } + } 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) => { + reject(); + // setLoading(false); + }); + }; + + const beforeTokenIconUpload = file => { + try { + if (file.type.split('/')[0] !== 'image') { + throw new Error('You can only upload image files!'); + } else { + setLoading(true); + handleTokenIconImage(file, imageUrl => setImageUrl(imageUrl)); + } + } catch (e) { + console.error('error', e); + notification.error({ + message: 'Error', + description: e.message || e.error || JSON.stringify(e), + duration: 0, + }); + 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); @@ -110,6 +327,66 @@ // 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 @@ -139,6 +416,11 @@ 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); @@ -329,6 +611,160 @@ } /> + + false} + fileList={tokenIconFileList} + name="tokenIcon" + style={{ + backgroundColor: '#f4f4f4', + }} + > + {imageUrl ? ( + avatar + ) : ( + <> + {' '} + +

+ Click, or drag file to this + area to upload +

+

+ Must be an image +

+ + )} +
+ + {!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} + /> +
+ new Promise((resolve, reject) => { + const image = new Image(); + image.addEventListener('load', () => resolve(image)); + image.addEventListener('error', error => reject(error)); + image.setAttribute('crossOrigin', 'anonymous'); + image.src = url; + }); + +function getRadianAngle(degreeValue) { + return (degreeValue * Math.PI) / 180; +} + +export default async function getCroppedImg( + imageSrc, + pixelCrop, + rotation = 0, + fileName, +) { + const image = await createImage(imageSrc); + console.log('image :', image); + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + const maxSize = Math.max(image.width, image.height); + const safeArea = 2 * ((maxSize / 2) * Math.sqrt(2)); + + canvas.width = safeArea; + canvas.height = safeArea; + + ctx.translate(safeArea / 2, safeArea / 2); + ctx.rotate(getRadianAngle(rotation)); + ctx.translate(-safeArea / 2, -safeArea / 2); + + ctx.drawImage( + image, + safeArea / 2 - image.width * 0.5, + safeArea / 2 - image.height * 0.5, + ); + const data = ctx.getImageData(0, 0, safeArea, safeArea); + + canvas.width = pixelCrop.width; + canvas.height = pixelCrop.height; + + ctx.putImageData( + data, + 0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x, + 0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y, + ); + + 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' })); + }); + }, + }); + } + return new Promise(resolve => { + ctx.canvas.toBlob( + blob => { + const file = new File([blob], fileName, { + type: 'image/png', + }); + const resultReader = new FileReader(); + + resultReader.readAsDataURL(file); + + resultReader.addEventListener('load', () => + resolve({ file, url: resultReader.result }), + ); + }, + 'image/png', + 1, + ); + }); +} diff --git a/web/cashtab/src/utils/icons/resizeImage.js b/web/cashtab/src/utils/icons/resizeImage.js new file mode 100644 --- /dev/null +++ b/web/cashtab/src/utils/icons/resizeImage.js @@ -0,0 +1,57 @@ +const createImage = url => + new Promise((resolve, reject) => { + const image = new Image(); + image.addEventListener('load', () => resolve(image)); + image.addEventListener('error', error => reject(error)); + image.setAttribute('crossOrigin', 'anonymous'); + image.src = url; + }); + +export default async function getResizedImg(imageSrc, callback, fileName) { + const image = await createImage(imageSrc); + + const width = 128; + const height = 128; + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d'); + + ctx.drawImage(image, 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' })); + }); + }, + }); + } + + return new Promise(resolve => { + ctx.canvas.toBlob( + blob => { + const file = new File([blob], fileName, { + type: 'image/png', + }); + const resultReader = new FileReader(); + + resultReader.readAsDataURL(file); + + resultReader.addEventListener('load', () => + callback({ file, url: resultReader.result }), + ); + resolve(); + }, + 'image/png', + 1, + ); + }); +} diff --git a/web/cashtab/src/utils/icons/roundImage.js b/web/cashtab/src/utils/icons/roundImage.js new file mode 100644 --- /dev/null +++ b/web/cashtab/src/utils/icons/roundImage.js @@ -0,0 +1,64 @@ +const createImage = url => + new Promise((resolve, reject) => { + const image = new Image(); + image.addEventListener('load', () => resolve(image)); + image.addEventListener('error', error => reject(error)); + image.setAttribute('crossOrigin', 'anonymous'); + image.src = url; + }); + +export default async function getRoundImg(imageSrc, fileName) { + const image = await createImage(imageSrc); + console.log('image :', image); + const canvas = document.createElement('canvas'); + canvas.width = image.width; + canvas.height = image.height; + const ctx = canvas.getContext('2d'); + + ctx.drawImage(image, 0, 0); + ctx.globalCompositeOperation = 'destination-in'; + ctx.beginPath(); + ctx.arc( + image.width / 2, + image.height / 2, + image.height / 2, + 0, + Math.PI * 2, + ); + ctx.closePath(); + ctx.fill(); + 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' })); + }); + }, + }); + } + return new Promise(resolve => { + ctx.canvas.toBlob( + blob => { + const file = new File([blob], fileName, { + type: 'image/png', + }); + const resultReader = new FileReader(); + + resultReader.readAsDataURL(file); + + resultReader.addEventListener('load', () => + resolve({ file, url: resultReader.result }), + ); + }, + 'image/png', + 1, + ); + }); +}