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,59 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import styled from 'styled-components'; +import { Card, Modal } from 'antd'; + +const CropModal = styled(Modal)` + .ant-modal-close-x { + font-size: 2px; + } +`; + +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/Send/__tests__/__snapshots__/SendToken.test.js.snap b/web/cashtab/src/components/Send/__tests__/__snapshots__/SendToken.test.js.snap --- a/web/cashtab/src/components/Send/__tests__/__snapshots__/SendToken.test.js.snap +++ b/web/cashtab/src/components/Send/__tests__/__snapshots__/SendToken.test.js.snap @@ -170,19 +170,7 @@ > - identicon of tokenId bd1acc4c986de57af8d6d2a64aecad8c30ee80f37ae9d066d758923732ddc9ba - + /> { 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) => { + reject(); + // setLoading(false); + }); + }; + + const beforeTokenIconUpload = file => { + const approvedFileTypes = ['image/png', 'image/jpg']; + 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); @@ -110,6 +326,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 +415,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 +610,160 @@ } /> + + 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} + /> +
You need some XEC in your wallet to create tokens. ,
0 @@ -60,7 +60,7 @@
,

You need at least @@ -83,14 +83,14 @@ exports[`Wallet with BCH balances and tokens 1`] = ` Array [

You need some XEC in your wallet to create tokens.
,
0 @@ -140,7 +140,7 @@
,

You need at least @@ -163,14 +163,14 @@ exports[`Wallet with BCH balances and tokens and state field 1`] = ` Array [

0.06 XEC
,
$ NaN @@ -226,25 +226,13 @@ onClick={[Function]} >
- identicon of tokenId bd1acc4c986de57af8d6d2a64aecad8c30ee80f37ae9d066d758923732ddc9ba -
+ className="sc-hMqMXs kTHajp" + />
6.001 @@ -261,14 +249,14 @@ exports[`Wallet without BCH balance 1`] = ` Array [
You need some XEC in your wallet to create tokens.
,
0 @@ -318,7 +306,7 @@
,

You need at least @@ -341,14 +329,14 @@ exports[`Without wallet defined 1`] = ` Array [

You need some XEC in your wallet to create tokens.
,
0 @@ -398,7 +386,7 @@
,

You need at least diff --git a/web/cashtab/src/utils/icons/cropImage.js b/web/cashtab/src/utils/icons/cropImage.js new file mode 100644 --- /dev/null +++ b/web/cashtab/src/utils/icons/cropImage.js @@ -0,0 +1,85 @@ +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; + }); + +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 = 512; + const height = 512; + 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, + ); + }); +}