diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml index 28d7613e6..81552fc6f 100644 --- a/.github/workflows/audit.yml +++ b/.github/workflows/audit.yml @@ -6,11 +6,11 @@ jobs: audit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@master - - name: Use Node.js 20.x + - uses: actions/checkout@v2 + - name: Use Node.js 22.x uses: actions/setup-node@v4.0.1 with: - node-version: 20.x + node-version: 22.x cache: 'npm' - name: Upgrade npm run: npm i -g npm diff --git a/.github/workflows/build-release.yaml b/.github/workflows/build-release.yaml index 33f594704..d1a0c0415 100644 --- a/.github/workflows/build-release.yaml +++ b/.github/workflows/build-release.yaml @@ -14,11 +14,11 @@ jobs: id-token: write runs-on: ubuntu-latest steps: - - uses: actions/checkout@master - - name: Use Node.js 20.x + - uses: actions/checkout@v2 + - name: Use Node.js 22.x uses: actions/setup-node@v4.0.1 with: - node-version: 20.x + node-version: 22.x cache: 'npm' - name: Set package.json version uses: decentraland/oddish-action@master diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 14e3b95ed..5fdc21e11 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -6,11 +6,11 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@master - - name: Use Node.js 20.x - uses: actions/setup-node@v1 + - uses: actions/checkout@v2 + - name: Use Node.js 22.x + uses: actions/setup-node@v4.0.1 with: - node-version: 20.x + node-version: 22.x - name: Install run: npm ci --legacy-peer-deps - name: Test diff --git a/package-lock.json b/package-lock.json index 15a10d4ec..dec881e0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,7 +17,7 @@ "@dcl/crypto": "^3.4.5", "@dcl/hashing": "^3.0.4", "@dcl/mini-rpc": "^1.0.7", - "@dcl/schemas": "^19.6.0", + "@dcl/schemas": "^19.7.0", "@dcl/sdk": "7.5.5", "@dcl/single-sign-on-client": "^0.1.0", "@dcl/ui-env": "^1.5.0", @@ -49,8 +49,9 @@ "decentraland-experiments": "^1.0.2", "decentraland-transactions": "^2.24.0", "decentraland-ui": "^6.23.1", - "decentraland-ui2": "^0.27.0", + "decentraland-ui2": "^0.40.0", "ethers": "^5.6.8", + "fast-equals": "^5.3.0", "file-saver": "^2.0.1", "graphql": "^15.8.0", "interface-datastore": "^0.7.0", @@ -556,81 +557,19 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.23.5", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.23.5.tgz", - "integrity": "sha512-CgH3s1a96LipHCmSUmYFPwY7MNx8C3avkq7i4Wl3cfa662ldtUe4VM1TPXX70pfmrlWTb6jLqTYrZyT2ZTJBgA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", + "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", + "license": "MIT", "dependencies": { - "@babel/highlight": "^7.23.4", - "chalk": "^2.4.2" + "@babel/helper-validator-identifier": "^7.27.1", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/code-frame/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/code-frame/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" - }, - "node_modules/@babel/code-frame/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/code-frame/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/code-frame/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", - "dependencies": { - "has-flag": "^3.0.0" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/compat-data": { "version": "7.23.5", "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.23.5.tgz", @@ -686,40 +625,42 @@ } }, "node_modules/@babel/generator": { - "version": "7.23.6", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.23.6.tgz", - "integrity": "sha512-qrSfCYxYQB5owCmGLbl8XRpX1ytXlpueOb0N0UmQwA073KZxejgQTzAmJezxvpwQD9uGtK2shHdi55QT+MbjIw==", - "dev": true, + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.3.tgz", + "integrity": "sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw==", + "license": "MIT", "dependencies": { - "@babel/types": "^7.23.6", - "@jridgewell/gen-mapping": "^0.3.2", - "@jridgewell/trace-mapping": "^0.3.17", - "jsesc": "^2.5.1" + "@babel/parser": "^7.28.3", + "@babel/types": "^7.28.2", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/generator/node_modules/jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", - "dev": true, + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "license": "MIT", "bin": { "jsesc": "bin/jsesc" }, "engines": { - "node": ">=4" + "node": ">=6" } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.22.5.tgz", - "integrity": "sha512-LvBTxu8bQSQkcyKOU+a1btnNFQ1dMAd0R6PyW3arXes06F6QLWLIrd681bxRPIXlrMGR3XYnW9JyML7dP3qgxg==", + "version": "7.27.3", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz", + "integrity": "sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { - "@babel/types": "^7.22.5" + "@babel/types": "^7.27.3" }, "engines": { "node": ">=6.9.0" @@ -759,37 +700,23 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-function-name": { - "version": "7.23.0", - "resolved": "https://registry.npmjs.org/@babel/helper-function-name/-/helper-function-name-7.23.0.tgz", - "integrity": "sha512-OErEqsrxjZTJciZ4Oo+eoZqeW9UIiOcuYKRJA4ZAgV9myA+pOXhhmpfNCKjEH/auVfEYVFJ6y1Tc4r0eIApqiw==", - "dev": true, - "dependencies": { - "@babel/template": "^7.22.15", - "@babel/types": "^7.23.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-hoist-variables": { - "version": "7.22.5", - "resolved": "https://registry.npmjs.org/@babel/helper-hoist-variables/-/helper-hoist-variables-7.22.5.tgz", - "integrity": "sha512-wGjk9QZVzvknA6yKIUURb8zY3grXCcOZt+/7Wcy8O2uctxhplmUPkOdlgoNhmdVee2c92JXbf1xpMtVNbfoxRw==", - "dev": true, - "dependencies": { - "@babel/types": "^7.22.5" - }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.22.15", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.22.15.tgz", - "integrity": "sha512-0pYVBnDKZO2fnSPCrgM/6WMc7eS20Fbok+0r88fp+YtWVLZrp4CkafFGIp+W0VKw4a22sgebPT99y+FDNMdP4w==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", + "integrity": "sha512-0gSFWUPNXNopqtIPQvlD5WgXYI5GY2kP2cCvoT8kczjbfcfuIljTbcWrulD1CIPIX2gt1wghbDy08yE1p+/r3w==", + "license": "MIT", "dependencies": { - "@babel/types": "^7.22.15" + "@babel/traverse": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -815,10 +742,11 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.24.0", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.0.tgz", - "integrity": "sha512-9cUznXMG0+FxRuJfvL82QlTqIzhVW9sL0KjMPHhAOOvpQGL8QtdxnBKILjBqxlHyliz0yCa1G903ZXI/FuHy2w==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.27.1.tgz", + "integrity": "sha512-1gn1Up5YXka3YYAHGKpbideQ5Yjf1tDa9qYcgysz+cNCXukyLl6DjPXhD3VRwSb8c0J9tA4b2+rHEZtc6R0tlw==", "dev": true, + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -848,17 +776,19 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.23.4.tgz", - "integrity": "sha512-803gmbQdqwdf4olxrX4AJyFBV/RTr3rSmOj0rKwesmzlfhYNDEs+/iOcznzpNWlJlIlTJC2QfPFcHB6DlzdVLQ==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.22.20", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.22.20.tgz", - "integrity": "sha512-Y4OZ+ytlatR8AI+8KZfKuL5urKp7qey08ha31L8b3BwewJAoJamTzyvxPR/5D+KkdJCGPq/+8TukHBlY10FX9A==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", + "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -886,88 +816,14 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/highlight": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.23.4.tgz", - "integrity": "sha512-acGdbYSfp2WheJoJm/EBBBLh/ID8KDc64ISZ9DYtBmC8/Q204PZJLHyzeB5qMzJ5trcOkybd78M4x2KWsUq++A==", - "dependencies": { - "@babel/helper-validator-identifier": "^7.22.20", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight/node_modules/ansi-styles": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", - "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", - "dependencies": { - "color-convert": "^1.9.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/chalk": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", - "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", - "dependencies": { - "ansi-styles": "^3.2.1", - "escape-string-regexp": "^1.0.5", - "supports-color": "^5.3.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/color-convert": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", - "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", - "dependencies": { - "color-name": "1.1.3" - } - }, - "node_modules/@babel/highlight/node_modules/color-name": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", - "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" - }, - "node_modules/@babel/highlight/node_modules/escape-string-regexp": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", - "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/@babel/highlight/node_modules/has-flag": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", - "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/highlight/node_modules/supports-color": { - "version": "5.5.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", - "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "node_modules/@babel/parser": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", + "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", + "license": "MIT", "dependencies": { - "has-flag": "^3.0.0" + "@babel/types": "^7.28.4" }, - "engines": { - "node": ">=4" - } - }, - "node_modules/@babel/parser": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.9.tgz", - "integrity": "sha512-9tcKgqKbs3xGJ+NtKF2ndOBBLVwPjl1SHxPQkd36r3Dlirw3xWUeGaTbqr7uGZcTaxkVNwc+03SVP7aCdWrTlA==", - "dev": true, "bin": { "parser": "bin/babel-parser.js" }, @@ -1012,13 +868,14 @@ } }, "node_modules/@babel/plugin-syntax-flow": { - "version": "7.24.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.24.1.tgz", - "integrity": "sha512-sxi2kLTI5DeW5vDtMUsk4mTPwvlUDbjOnoWayhynCwrw4QXRld4QEYwqzY8JmQXaJUtgUuCIurtSRH5sn4c7mA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-flow/-/plugin-syntax-flow-7.27.1.tgz", + "integrity": "sha512-p9OkPbZ5G7UT1MofwYFigGebnrzGJacoBSQM0/6bi/PUMVE+qlWDD/OalvQKbwgQzU6dl0xAv6r4X7Jme0RYxA==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.0" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1052,12 +909,13 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.23.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.23.3.tgz", - "integrity": "sha512-EB2MELswq55OHUoRZLGg/zC7QWUKfNLpE57m/S2yr1uEneIgsTgrSzXP3NXEsMkVn76OlaVVnzN+ugObuYGwhg==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.27.1.tgz", + "integrity": "sha512-y8YTNIeKoyhGd9O0Jiyzyyqk8gdjnumGTQPsz0xOZOQ2RmkVJeZ1vmmfIvFEKqucBG6axJGBZDE/7iI5suUI/w==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.22.5" + "@babel/helper-plugin-utils": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1169,17 +1027,18 @@ } }, "node_modules/@babel/plugin-transform-react-jsx": { - "version": "7.23.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.23.4.tgz", - "integrity": "sha512-5xOpoPguCZCRbo/JeHlloSkTA8Bld1J/E1/kLfD1nsuiW1m8tduTA1ERCgIZokDflX/IBzKcqR3l7VlRgiIfHA==", + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.27.1.tgz", + "integrity": "sha512-2KH4LWGSrJIkVf5tSiBFYuXDAoWRq2MMwgivCf+93dd0GQi8RXLjKA/0EvRnVV5G0hrHczsquXuD01L8s6dmBw==", "dev": true, + "license": "MIT", "peer": true, "dependencies": { - "@babel/helper-annotate-as-pure": "^7.22.5", - "@babel/helper-module-imports": "^7.22.15", - "@babel/helper-plugin-utils": "^7.22.5", - "@babel/plugin-syntax-jsx": "^7.23.3", - "@babel/types": "^7.23.4" + "@babel/helper-annotate-as-pure": "^7.27.1", + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-plugin-utils": "^7.27.1", + "@babel/plugin-syntax-jsx": "^7.27.1", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" @@ -1200,70 +1059,50 @@ } }, "node_modules/@babel/template": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.23.9.tgz", - "integrity": "sha512-+xrD2BWLpvHKNmX2QbpdpsBaWnRxahMwJjO+KZk2JOElj5nSmKezyS1B4u+QbHMTX69t4ukm6hh9lsYQ7GHCKA==", - "dev": true, + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", + "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.23.5", - "@babel/parser": "^7.23.9", - "@babel/types": "^7.23.9" + "@babel/code-frame": "^7.27.1", + "@babel/parser": "^7.27.2", + "@babel/types": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.23.9.tgz", - "integrity": "sha512-I/4UJ9vs90OkBtY6iiiTORVMyIhJ4kAVmsKo9KFc8UOxMeUfi2hvtIBsET5u9GizXE6/GFSuKCTNfgCswuEjRg==", - "dev": true, + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.4.tgz", + "integrity": "sha512-YEzuboP2qvQavAcjgQNVgsvHIDv6ZpwXvcvjmyySP2DIMuByS/6ioU5G9pYrWHM6T2YDfc7xga9iNzYOs12CFQ==", + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.23.5", - "@babel/generator": "^7.23.6", - "@babel/helper-environment-visitor": "^7.22.20", - "@babel/helper-function-name": "^7.23.0", - "@babel/helper-hoist-variables": "^7.22.5", - "@babel/helper-split-export-declaration": "^7.22.6", - "@babel/parser": "^7.23.9", - "@babel/types": "^7.23.9", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.3", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.4", + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/types": { - "version": "7.23.9", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.23.9.tgz", - "integrity": "sha512-dQjSq/7HaSjRM43FFGnv5keM2HsxpmyV1PfaSVm0nzzjwwTmjOe6J4bC8e3+pTEIgHaHj+1ZlLThRJ2auc/w1Q==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", + "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", + "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.23.4", - "@babel/helper-validator-identifier": "^7.22.20", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/types/node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "engines": { - "node": ">=4" - } - }, "node_modules/@babylonjs/core": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@babylonjs/core/-/core-4.2.2.tgz", @@ -2351,6 +2190,40 @@ "npm": ">=3.0.0" } }, + "node_modules/@dcl/asset-packs/node_modules/dcl-catalyst-client": { + "version": "21.8.1", + "resolved": "https://registry.npmjs.org/dcl-catalyst-client/-/dcl-catalyst-client-21.8.1.tgz", + "integrity": "sha512-VtYSBDmiHOFdAl3qzi2CtEUJ8VlJqQ6uTZXYTZ6pCgT/lQnxYkdB0VCVfSfqNo9OD+S7dYeayQ2n+Uap32K0AA==", + "license": "Apache-2.0", + "dependencies": { + "@dcl/catalyst-contracts": "^4.4.0", + "@dcl/crypto": "^3.4.0", + "@dcl/hashing": "^3.0.0", + "@dcl/schemas": "^11.5.0", + "@well-known-components/fetch-component": "^2.0.0", + "cookie": "^0.5.0", + "cross-fetch": "^3.1.5", + "form-data": "^4.0.0" + } + }, + "node_modules/@dcl/asset-packs/node_modules/dcl-catalyst-client/node_modules/@dcl/hashing": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@dcl/hashing/-/hashing-3.0.4.tgz", + "integrity": "sha512-Cg+MoIOn+BYmQV2q8zSFnNYY+GldlnUazwBnfgrq3i66ZxOaZ65h01btd8OUtSAlfWG4VTNIOHDjtKqmuwJNBg==", + "license": "Apache-2.0" + }, + "node_modules/@dcl/asset-packs/node_modules/dcl-catalyst-client/node_modules/@dcl/schemas": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/@dcl/schemas/-/schemas-11.12.0.tgz", + "integrity": "sha512-L04KTucvxSnrHDAl3/rnkzhjfZ785dSSPeKarBVfzyuw41uyQ0Mh4HVFWjX9hC+f/nMpM5Adg5udlT5efmepcA==", + "license": "Apache-2.0", + "dependencies": { + "ajv": "^8.11.0", + "ajv-errors": "^3.0.0", + "ajv-keywords": "^5.1.0", + "mitt": "^3.0.1" + } + }, "node_modules/@dcl/asset-packs/node_modules/err-code": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/err-code/-/err-code-3.0.1.tgz", @@ -2884,9 +2757,10 @@ "integrity": "sha512-n7BTTOyeFJbZicGcLGzhFlVx7I+kVYOgJlFDaFYecdR2iGBLa1Y2sFLrTpMbSHJpt9dIPGdNbdmpEhDgQpWI+w==" }, "node_modules/@dcl/catalyst-contracts": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@dcl/catalyst-contracts/-/catalyst-contracts-4.3.1.tgz", - "integrity": "sha512-wOaIG/RwsKniQu1wDhigiQHDbfYMSu9Ifk7PToLMA01ellPrF0CZK06vGJLtXlahaXfUbv94N03teS7kYRORLA==" + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@dcl/catalyst-contracts/-/catalyst-contracts-4.4.2.tgz", + "integrity": "sha512-gJZo3IB8U+jhBJWR0DgoLS+zaUDIb/u79e7Rp+MEM78uH3bvC19S3Dd8lxWEbPXNKlCB0saUptfK/Buw0c1y2Q==", + "license": "Apache-2.0" }, "node_modules/@dcl/content-hash-tree": { "version": "1.1.4", @@ -3157,9 +3031,9 @@ } }, "node_modules/@dcl/schemas": { - "version": "19.6.0", - "resolved": "https://registry.npmjs.org/@dcl/schemas/-/schemas-19.6.0.tgz", - "integrity": "sha512-bsApu1HK4+GkUiUVJkeXPes+EvdaFrwlW/TKCQkvafvA+Fgw7EPWrQR4nHEdMzRrijocgSJeN+pE2u5tRgfAWQ==", + "version": "19.7.0", + "resolved": "https://registry.npmjs.org/@dcl/schemas/-/schemas-19.7.0.tgz", + "integrity": "sha512-me/ii1uJ6WlU/D9L4tp997VWx8rb4DZKODCS2KNQPV2PkhAdjEOP/F0f3m5sZ7OLAxgRksHjTyVFjUP/9GZPBA==", "license": "Apache-2.0", "dependencies": { "ajv": "^8.11.0", @@ -3278,6 +3152,40 @@ "npm": ">=3.0.0" } }, + "node_modules/@dcl/sdk-commands/node_modules/dcl-catalyst-client": { + "version": "21.8.1", + "resolved": "https://registry.npmjs.org/dcl-catalyst-client/-/dcl-catalyst-client-21.8.1.tgz", + "integrity": "sha512-VtYSBDmiHOFdAl3qzi2CtEUJ8VlJqQ6uTZXYTZ6pCgT/lQnxYkdB0VCVfSfqNo9OD+S7dYeayQ2n+Uap32K0AA==", + "license": "Apache-2.0", + "dependencies": { + "@dcl/catalyst-contracts": "^4.4.0", + "@dcl/crypto": "^3.4.0", + "@dcl/hashing": "^3.0.0", + "@dcl/schemas": "^11.5.0", + "@well-known-components/fetch-component": "^2.0.0", + "cookie": "^0.5.0", + "cross-fetch": "^3.1.5", + "form-data": "^4.0.0" + } + }, + "node_modules/@dcl/sdk-commands/node_modules/dcl-catalyst-client/node_modules/@dcl/hashing": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@dcl/hashing/-/hashing-3.0.4.tgz", + "integrity": "sha512-Cg+MoIOn+BYmQV2q8zSFnNYY+GldlnUazwBnfgrq3i66ZxOaZ65h01btd8OUtSAlfWG4VTNIOHDjtKqmuwJNBg==", + "license": "Apache-2.0" + }, + "node_modules/@dcl/sdk-commands/node_modules/dcl-catalyst-client/node_modules/@dcl/schemas": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/@dcl/schemas/-/schemas-11.12.0.tgz", + "integrity": "sha512-L04KTucvxSnrHDAl3/rnkzhjfZ785dSSPeKarBVfzyuw41uyQ0Mh4HVFWjX9hC+f/nMpM5Adg5udlT5efmepcA==", + "license": "Apache-2.0", + "dependencies": { + "ajv": "^8.11.0", + "ajv-errors": "^3.0.0", + "ajv-keywords": "^5.1.0", + "mitt": "^3.0.1" + } + }, "node_modules/@dcl/sdk-commands/node_modules/err-code": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/err-code/-/err-code-3.0.1.tgz", @@ -5619,16 +5527,13 @@ } }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.3.tgz", - "integrity": "sha512-HLhSWOLRi875zjjMG/r+Nv0oCW8umGb0BgEhyX3dDX3egwZtB8PqLnjz3yedt8R5StBrzcg4aBpnh8UA9D1BoQ==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.0.1", - "@jridgewell/sourcemap-codec": "^1.4.10", - "@jridgewell/trace-mapping": "^0.3.9" - }, - "engines": { - "node": ">=6.0.0" + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" } }, "node_modules/@jridgewell/resolve-uri": { @@ -5639,14 +5544,6 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.1.2.tgz", - "integrity": "sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw==", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/source-map": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.5.tgz", @@ -5657,14 +5554,16 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.4.15", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.15.tgz", - "integrity": "sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==" + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.22", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.22.tgz", - "integrity": "sha512-Wf963MzWtA2sjrNt+g18IAln9lKnlRp+K2eH4jjIoF1wYeq3aMREpG09xhlhdzS0EjwU7qmUJYangWa+151vZw==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" @@ -12474,9 +12373,10 @@ } }, "node_modules/dcl-catalyst-client": { - "version": "21.6.1", - "resolved": "https://registry.npmjs.org/dcl-catalyst-client/-/dcl-catalyst-client-21.6.1.tgz", - "integrity": "sha512-OsGvcu3rKn03tWfvfWnk91th4Le/bEUA1aUxYnE53w4ZsW8HHbaJjcBNFXeaZ+9kaQKTWrs8iuwi3B48d2CczA==", + "version": "21.5.5", + "resolved": "https://registry.npmjs.org/dcl-catalyst-client/-/dcl-catalyst-client-21.5.5.tgz", + "integrity": "sha512-VXIypnUl4czyo+vTH0L082YgTqA5fKu/1Y5yusZ7bhuUs/uxgNsGSc35blqjUY67GaG4R7XaSOwn6jA3dzxPfg==", + "license": "Apache-2.0", "dependencies": { "@dcl/catalyst-contracts": "^4.0.2", "@dcl/crypto": "^3.4.0", @@ -12492,6 +12392,7 @@ "version": "9.15.0", "resolved": "https://registry.npmjs.org/@dcl/schemas/-/schemas-9.15.0.tgz", "integrity": "sha512-nip5rsOcJplNfBWeImwezuHLprM0gLW03kEeqGIvT9J6HnEBTtvIwkk9+NSt7hzFKEvWGI+C23vyNWbG3nU+SQ==", + "license": "Apache-2.0", "dependencies": { "ajv": "^8.11.0", "ajv-errors": "^3.0.0", @@ -12667,6 +12568,84 @@ "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz", "integrity": "sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==" }, + "node_modules/decentraland-dapps/node_modules/dcl-catalyst-client": { + "version": "21.8.1", + "resolved": "https://registry.npmjs.org/dcl-catalyst-client/-/dcl-catalyst-client-21.8.1.tgz", + "integrity": "sha512-VtYSBDmiHOFdAl3qzi2CtEUJ8VlJqQ6uTZXYTZ6pCgT/lQnxYkdB0VCVfSfqNo9OD+S7dYeayQ2n+Uap32K0AA==", + "license": "Apache-2.0", + "dependencies": { + "@dcl/catalyst-contracts": "^4.4.0", + "@dcl/crypto": "^3.4.0", + "@dcl/hashing": "^3.0.0", + "@dcl/schemas": "^11.5.0", + "@well-known-components/fetch-component": "^2.0.0", + "cookie": "^0.5.0", + "cross-fetch": "^3.1.5", + "form-data": "^4.0.0" + } + }, + "node_modules/decentraland-dapps/node_modules/dcl-catalyst-client/node_modules/@dcl/schemas": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/@dcl/schemas/-/schemas-11.12.0.tgz", + "integrity": "sha512-L04KTucvxSnrHDAl3/rnkzhjfZ785dSSPeKarBVfzyuw41uyQ0Mh4HVFWjX9hC+f/nMpM5Adg5udlT5efmepcA==", + "license": "Apache-2.0", + "dependencies": { + "ajv": "^8.11.0", + "ajv-errors": "^3.0.0", + "ajv-keywords": "^5.1.0", + "mitt": "^3.0.1" + } + }, + "node_modules/decentraland-dapps/node_modules/decentraland-ui2": { + "version": "0.34.1", + "resolved": "https://registry.npmjs.org/decentraland-ui2/-/decentraland-ui2-0.34.1.tgz", + "integrity": "sha512-TvQ7M8Fj0z8DjkO/mAchfWkqMAZxhOQVDw0n2qOMf//7NpOFp0/2sITQ1zLKxS+M3ZLIlBG3l0c0krwRMGhJjA==", + "license": "Apache-2.0", + "dependencies": { + "@contentful/rich-text-react-renderer": "^16.0.1", + "@dcl/hooks": "^0.0.4", + "@dcl/schemas": "^19.4.0", + "@dcl/ui-env": "^1.5.1", + "@emotion/react": "^11.11.4", + "@emotion/styled": "^11.11.5", + "@mui/icons-material": "^5.16.0", + "@mui/material": "^5.16.0", + "autoprefixer": "^10.4.19", + "date-fns": "^3.6.0", + "deep-equal": "^2.2.3", + "ethereum-blockies": "^0.1.1", + "fp-future": "^1.0.1", + "mitt": "^3.0.1", + "radash": "^11.0.0", + "react": "^17.0.2", + "react-dom": "^17.0.2", + "react-tile-map": "^0.4.1", + "uuid": "^11.1.0" + } + }, + "node_modules/decentraland-dapps/node_modules/decentraland-ui2/node_modules/date-fns": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", + "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/decentraland-dapps/node_modules/decentraland-ui2/node_modules/uuid": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/esm/bin/uuid" + } + }, "node_modules/decentraland-dapps/node_modules/react-intl": { "version": "5.25.1", "resolved": "https://registry.npmjs.org/react-intl/-/react-intl-5.25.1.tgz", @@ -12705,6 +12684,7 @@ "version": "4.9.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", + "license": "Apache-2.0", "optional": true, "peer": true, "bin": { @@ -12986,14 +12966,14 @@ } }, "node_modules/decentraland-ui2": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/decentraland-ui2/-/decentraland-ui2-0.27.0.tgz", - "integrity": "sha512-gI22wP+25w82wM6QGmrL/T3lhKtarMmysm6qYTOaWmc6EzSPr/lKwopYzekCwz+wxlM1M77uWswFwzjPiVwwZw==", + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/decentraland-ui2/-/decentraland-ui2-0.40.0.tgz", + "integrity": "sha512-kRYd9GyIfG2e3TLHvUmjNPhkwuUhH1FJARISQL53E8UIs0XLDqx5iAGd9ttheW7bjZQRT4irSEdUO8L/SbnBEQ==", "license": "Apache-2.0", "dependencies": { "@contentful/rich-text-react-renderer": "^16.0.1", "@dcl/hooks": "^0.0.4", - "@dcl/schemas": "^18.1.0", + "@dcl/schemas": "^19.7.0", "@dcl/ui-env": "^1.5.1", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", @@ -13012,18 +12992,6 @@ "uuid": "^11.1.0" } }, - "node_modules/decentraland-ui2/node_modules/@dcl/schemas": { - "version": "18.8.0", - "resolved": "https://registry.npmjs.org/@dcl/schemas/-/schemas-18.8.0.tgz", - "integrity": "sha512-1HxbL0azB7N+kMwYyU4/Uqltc+7F8Lv8UcS3dxDNVTPn71gldIDImj58OM9Y3gIyQG5Tauacrlk5cDkXhjMkOA==", - "license": "Apache-2.0", - "dependencies": { - "ajv": "^8.11.0", - "ajv-errors": "^3.0.0", - "ajv-keywords": "^5.1.0", - "mitt": "^3.0.1" - } - }, "node_modules/decentraland-ui2/node_modules/date-fns": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", @@ -14650,9 +14618,9 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-equals": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.2.2.tgz", - "integrity": "sha512-V7/RktU11J3I36Nwq2JnZEM7tNm17eBJz+u25qdxBZeCKiX6BkVSZQjwWIr+IobgnZy+ag73tTZgZi7tr0LrBw==", + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.3.0.tgz", + "integrity": "sha512-xwP+dG/in/nJelMOUEQBiIYeOoHKihWPB2sNZ8ZeDbZFoGb1OwTGMggGRgg6CRitNx7kmHgtIz2dOHDQ8Ap7Bw==", "license": "MIT", "engines": { "node": ">=6.0.0" diff --git a/package.json b/package.json index 751d8b4f3..5e8829944 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,7 @@ "@dcl/crypto": "^3.4.5", "@dcl/hashing": "^3.0.4", "@dcl/mini-rpc": "^1.0.7", - "@dcl/schemas": "^19.6.0", + "@dcl/schemas": "^19.7.0", "@dcl/sdk": "7.5.5", "@dcl/single-sign-on-client": "^0.1.0", "@dcl/ui-env": "^1.5.0", @@ -43,8 +43,9 @@ "decentraland-experiments": "^1.0.2", "decentraland-transactions": "^2.24.0", "decentraland-ui": "^6.23.1", - "decentraland-ui2": "^0.27.0", + "decentraland-ui2": "^0.40.0", "ethers": "^5.6.8", + "fast-equals": "^5.3.0", "file-saver": "^2.0.1", "graphql": "^15.8.0", "interface-datastore": "^0.7.0", @@ -158,7 +159,6 @@ "homepage": "", "overrides": { "decentraland-ecs": "6.12.4-7784644013.commit-f770b3e", - "decentraland": "3.3.0", - "decentraland-ui2": "^0.27.0" + "decentraland": "3.3.0" } } diff --git a/src/components/ItemEditorPage/CenterPanel/CenterPanel.css b/src/components/ItemEditorPage/CenterPanel/CenterPanel.css index e50caf77c..4310f46d0 100644 --- a/src/components/ItemEditorPage/CenterPanel/CenterPanel.css +++ b/src/components/ItemEditorPage/CenterPanel/CenterPanel.css @@ -172,6 +172,24 @@ transform: none; } +.CenterPanel .animation-controls { + margin-left: 16px; + & .MuiButtonBase-root { + background-color: #726e7c !important; + } +} + +.CenterPanel .animation-controls__popper { + & .MuiPaper-root { + background-color: #726e7c; + & .MuiList-root { + & .MuiButtonBase-root.MuiMenuItem-root.Mui-selected { + background-color: #24212933; + } + } + } +} + .CenterPanel.import-files-modal-is-open { pointer-events: none; } @@ -181,28 +199,14 @@ flex-direction: column; position: absolute; top: 12px; + left: auto; right: 12px; -} - -.CenterPanel .zoom-controls > .ui.button.zoom-control { - width: 32px; - height: 32px; - padding: 0; - min-width: unset; - background-color: rgba(0, 0, 0, 0.64) !important; - border-radius: 0; - display: flex; - justify-content: center; - align-items: center; -} - -.CenterPanel .zoom-controls > .ui.button.zoom-control i.icon { - margin: 0 !important; -} - -.CenterPanel .zoom-controls > .ui.button.zoom-in-control { - width: 32px; - height: 32px; - padding: 0; - min-width: unset; + & .MuiButtonBase-root.MuiButton-root { + & .MuiSvgIcon-root { + fill: white; + } + & .MuiTouchRipple-root { + color: white; + } + } } diff --git a/src/components/ItemEditorPage/CenterPanel/CenterPanel.tsx b/src/components/ItemEditorPage/CenterPanel/CenterPanel.tsx index 451dc75bb..a0a43027e 100644 --- a/src/components/ItemEditorPage/CenterPanel/CenterPanel.tsx +++ b/src/components/ItemEditorPage/CenterPanel/CenterPanel.tsx @@ -1,30 +1,20 @@ import * as React from 'react' import type { Wearable } from 'decentraland-ecs' import { BodyShape, PreviewEmote, WearableCategory } from '@dcl/schemas' -import { - Dropdown, - DropdownProps, - Popup, - Icon, - Loader, - Center, - EmoteControls, - DropdownItemProps, - Button, - WearablePreview -} from 'decentraland-ui' +import { Dropdown, DropdownProps, Popup, Icon, Loader, Center, EmoteControls, DropdownItemProps, Button } from 'decentraland-ui' +import { AnimationControls, WearablePreview, ZoomControls } from 'decentraland-ui2' +import { SocialEmoteAnimation } from '@dcl/schemas/dist/dapps/preview/social-emote-animation' import { getAnalytics } from 'decentraland-dapps/dist/modules/analytics/utils' import { t } from 'decentraland-dapps/dist/modules/translation/utils' import { Color4 } from 'lib/colors' import { isDevelopment } from 'lib/environment' import { extractThirdPartyTokenId, extractTokenId, isThirdParty } from 'lib/urn' import { isTPCollection } from 'modules/collection/utils' -import { ItemType } from 'modules/item/types' +import { EmoteData, ItemType } from 'modules/item/types' import { isEmote } from 'modules/item/utils' import { toBase64, toHex } from 'modules/editor/utils' import { getSkinColors, getEyeColors, getHairColors } from 'modules/editor/avatar' import BuilderIcon from 'components/Icon' -import { ControlOptionAction } from 'components/Modals/CreateSingleItemModal/EditThumbnailStep/EditThumbnailStep.types' import AvatarColorDropdown from './AvatarColorDropdown' import AvatarWearableDropdown from './AvatarWearableDropdown' import { Props, State } from './CenterPanel.types' @@ -34,7 +24,8 @@ export default class CenterPanel extends React.PureComponent { state = { showSceneBoundaries: false, isShowingAvatarAttributes: false, - isLoading: false + isLoading: false, + socialEmote: undefined } analytics = getAnalytics() @@ -163,27 +154,6 @@ export default class CenterPanel extends React.PureComponent { this.setState({ isLoading: false }) } - handleControlActionChange = async (action: ControlOptionAction) => { - const { wearableController } = this.props - const ZOOM_DELTA = 0.1 - - if (wearableController) { - await wearableController?.emote.pause() - switch (action) { - case ControlOptionAction.ZOOM_IN: { - await wearableController?.scene.changeZoom(ZOOM_DELTA) - break - } - case ControlOptionAction.ZOOM_OUT: { - await wearableController?.scene.changeZoom(-ZOOM_DELTA) - break - } - default: - break - } - } - } - handlePlayEmote = () => { const { wearableController, isPlayingEmote, visibleItems, onSetAvatarAnimation, onSetItems } = this.props const newVisibleItems = visibleItems.filter(item => item.type !== ItemType.EMOTE) @@ -196,6 +166,10 @@ export default class CenterPanel extends React.PureComponent { } } + handleSocialEmoteSelect = (animation: SocialEmoteAnimation) => { + this.setState({ socialEmote: animation }) + } + renderEmotePlayButton = () => { const { isPlayingEmote } = this.props const icon = isPlayingEmote ? 'stop' : 'play' @@ -272,13 +246,19 @@ export default class CenterPanel extends React.PureComponent { isImportFilesModalOpen, wearableController } = this.props - const { isShowingAvatarAttributes, showSceneBoundaries, isLoading } = this.state + const { isShowingAvatarAttributes, showSceneBoundaries, isLoading, socialEmote } = this.state const isRenderingAnEmote = visibleItems.some(isEmote) && selectedItem?.type === ItemType.EMOTE const zoom = emote === PreviewEmote.JUMP ? 1 : undefined + let _socialEmote = undefined + + if (!socialEmote && selectedItem?.type === ItemType.EMOTE && (selectedItem.data as unknown as EmoteData).startAnimation) { + _socialEmote = { title: 'Start Animation', ...(selectedItem.data as unknown as EmoteData).startAnimation, loop: true } + } return (
{ onLoad={this.handleWearablePreviewLoad} disableDefaultEmotes={isRenderingAnEmote} showSceneBoundaries={showSceneBoundaries} + socialEmote={socialEmote || _socialEmote} /> - {isRenderingAnEmote ? ( -
- - -
+ {isRenderingAnEmote && !isLoading && wearableController ? ( + ) : null} {isLoading && (
@@ -334,7 +312,19 @@ export default class CenterPanel extends React.PureComponent {
- {isRenderingAnEmote ? null : this.renderEmoteSelector()} + {isRenderingAnEmote ? ( + !isLoading && wearableController ? ( + + ) : null + ) : ( + this.renderEmoteSelector() + )}
= ({ value, disabled = false, placeholder = '', editable = false, className = '', maxLength, onChange }) => { + const [isEditing, setIsEditing] = useState(false) + const inputRef = useRef(null) + const mirrorRef = useRef(null) + const typingTimerRef = useRef(null) + const isComposingRef = useRef(false) + + const applyNoTransitionBriefly = useCallback(() => { + const el = inputRef.current + if (!el) return + el.classList.add(styles.noTransition) + if (typingTimerRef.current) window.clearTimeout(typingTimerRef.current) + typingTimerRef.current = window.setTimeout(() => { + el.classList.remove(styles.noTransition) + typingTimerRef.current = null + }, 120) as unknown as number + }, []) + + const measureAndApplyWidth = useCallback(() => { + const mirror = mirrorRef.current + const input = inputRef.current + if (!mirror || !input) return + + if (isComposingRef.current) return + + mirror.textContent = value && value.length > 0 ? value : placeholder || '\u200B' + const rect = mirror.getBoundingClientRect() + const width = rect.width + EXTRA_CURSOR_SPACE + input.style.width = `${width}px` + }, [value, placeholder]) + + useLayoutEffect(() => { + measureAndApplyWidth() + }, [measureAndApplyWidth]) + + const handleChange = useCallback( + (event: ChangeEvent) => { + const newValue = event.target.value + if (value !== newValue && onChange) { + onChange(maxLength ? newValue.slice(0, maxLength) : newValue) + } + applyNoTransitionBriefly() + requestAnimationFrame(measureAndApplyWidth) + }, + [value, maxLength, onChange, applyNoTransitionBriefly, measureAndApplyWidth] + ) + + const handleCompositionStart = useCallback((_: CompositionEvent) => { + isComposingRef.current = true + }, []) + + const handleCompositionEnd = useCallback( + (_: CompositionEvent) => { + isComposingRef.current = false + requestAnimationFrame(measureAndApplyWidth) + }, + [measureAndApplyWidth] + ) + + const handleKeyDown = useCallback((event: KeyboardEvent) => { + if (event.key === 'Enter') setIsEditing(false) + }, []) + + const handleBlur = useCallback(() => setIsEditing(false), []) + const handleEdit = useCallback(() => { + setIsEditing(true) + setTimeout(() => inputRef.current?.focus(), 0) + }, []) + + const canEdit = editable && !disabled + + return ( +
+ + +
+ ) +} + +export default memo(DynamicInput) diff --git a/src/components/ItemEditorPage/RightPanel/DynamicInput/DynamicInput.types.ts b/src/components/ItemEditorPage/RightPanel/DynamicInput/DynamicInput.types.ts new file mode 100644 index 000000000..d7ee18d2d --- /dev/null +++ b/src/components/ItemEditorPage/RightPanel/DynamicInput/DynamicInput.types.ts @@ -0,0 +1,9 @@ +export type Props = { + value: string + disabled?: boolean + maxLength?: number + placeholder?: string + editable?: boolean + onChange?: (newValue: string) => void + className?: string +} diff --git a/src/components/ItemEditorPage/RightPanel/DynamicInput/index.ts b/src/components/ItemEditorPage/RightPanel/DynamicInput/index.ts new file mode 100644 index 000000000..682ed6836 --- /dev/null +++ b/src/components/ItemEditorPage/RightPanel/DynamicInput/index.ts @@ -0,0 +1 @@ +export { default } from './DynamicInput' diff --git a/src/components/ItemEditorPage/RightPanel/RightPanel.css b/src/components/ItemEditorPage/RightPanel/RightPanel.css index c6277710e..3f656ad2f 100644 --- a/src/components/ItemEditorPage/RightPanel/RightPanel.css +++ b/src/components/ItemEditorPage/RightPanel/RightPanel.css @@ -227,3 +227,88 @@ align-items: center; color: var(--secondary-text); } + +.dcl.box.emote-request-state-box, +.dcl.box.outcome-box { + padding: 10px 10px 0 10px; + width: 100%; + border-width: 1px; + border-color: var(--text-area-border); + margin-bottom: 10px; +} + +.dcl.box.emote-request-state-box .ui.dropdown.Select, +.dcl.box.outcome-box .ui.dropdown.Select { + border: none; + padding: 0; +} + +.dcl.box.emote-request-state-box .box-header, +.dcl.box.outcome-box .box-header { + display: flex; + flex-direction: column; + align-items: flex-start; + margin-bottom: 0; +} + +.dcl.box.emote-request-state-box .box-header::after, +.dcl.box.outcome-box .box-header::after { + content: ''; + margin-left: -10px; + display: block; + width: calc(100% + 20px); + height: 1px; + background-color: var(--gray); + margin-top: 10px; +} + +.dcl.box.outcome-box .box-header .dcl.box-header-text { + width: 100%; +} + +.dcl.box.emote-request-state-box .emote-start-header, +.dcl.box.outcome-box .outcome-header { + font-size: 1rem; + font-weight: 700; +} + +.dcl.box.outcome-box .outcome-header { + display: flex; + align-items: center; +} + +.dcl.box.outcome-box .outcome-header .outcome-edit-button .icon { + color: var(--secondary-text); +} + +.dcl.box.outcome-box .outcome-header .outcome-remove-button { + margin-left: auto; +} + +.dcl.box.outcome-box .outcome-header .outcome-remove-button .icon { + color: var(--secondary-text); +} + +.dcl.box.outcome-box .outcome-header .Input { + border: none; + padding: 0; + margin: 0; + height: unset; +} + +.dcl.box.outcome-box .outcome-header .Input input { + text-align: left; + flex: 0; +} + +.dcl.box.outcome-box .outcome-header .outcome-title-input > input { + font-weight: 700; + color: var(--text); + &:disabled { + opacity: 1; + } +} + +.dcl.box.outcome-box .outcome-header .outcome-title-input .ui.button.basic { + color: var(--secondary-text) !important; +} diff --git a/src/components/ItemEditorPage/RightPanel/RightPanel.tsx b/src/components/ItemEditorPage/RightPanel/RightPanel.tsx index 1223cd69b..3b15cc4d3 100644 --- a/src/components/ItemEditorPage/RightPanel/RightPanel.tsx +++ b/src/components/ItemEditorPage/RightPanel/RightPanel.tsx @@ -1,7 +1,18 @@ +import { AnimationClip } from 'three' import * as React from 'react' import equal from 'fast-deep-equal' -import { Loader, Dropdown, Button, Checkbox, CheckboxProps, TextAreaField, TextAreaProps } from 'decentraland-ui' -import { BodyPartCategory, EmoteCategory, Rarity, EmoteDataADR74, HideableWearableCategory, Network, WearableCategory } from '@dcl/schemas' +import { Loader, Dropdown, Button, Checkbox, CheckboxProps, TextAreaField, TextAreaProps, Row, Box, Header } from 'decentraland-ui' +import { + BodyPartCategory, + EmoteCategory, + Rarity, + EmoteDataADR74, + HideableWearableCategory, + Network, + WearableCategory, + ArmatureId, + StartAnimation +} from '@dcl/schemas' import { NetworkButton } from 'decentraland-dapps/dist/containers' import { t } from 'decentraland-dapps/dist/modules/translation/utils' import { getAnalytics } from 'decentraland-dapps/dist/modules/analytics/utils' @@ -17,7 +28,9 @@ import { getHideableWearableCategories, isSmart, hasVideo, - isWearable + isWearable, + isAudioFile, + itemHasAudio } from 'modules/item/utils' import { isLocked } from 'modules/collection/utils' import { computeHashes } from 'modules/deployment/contentUtils' @@ -28,12 +41,15 @@ import { ItemType, ITEM_DESCRIPTION_MAX_LENGTH, ITEM_NAME_MAX_LENGTH, + OUTCOME_TITLE_MAX_LENGTH, THUMBNAIL_PATH, WearableData, VIDEO_PATH, SyncStatus, - ITEM_UTILITY_MAX_LENGTH + ITEM_UTILITY_MAX_LENGTH, + EmoteData } from 'modules/item/types' +import { isSocialEmoteMetrics } from 'modules/models/types' import { dataURLToBlob } from 'modules/media/utils' import Collapsable from 'components/Collapsable' import ConfirmDelete from 'components/ConfirmDelete' @@ -41,10 +57,12 @@ import Icon from 'components/Icon' import Info from 'components/Info' import ItemImage from 'components/ItemImage' import ItemProvider from 'components/ItemProvider' +import { AnimationData } from 'components/ItemProvider/ItemProvider.types' import ItemVideo from 'components/ItemVideo' import ItemProperties from 'components/ItemProperties' import ItemRequiredPermission from 'components/ItemRequiredPermission' import { EditVideoModalMetadata } from 'components/Modals/EditVideoModal/EditVideoModal.types' +import DynamicInput from './DynamicInput/DynamicInput' import Input from './Input' import Select from './Select' import MultiSelect from './MultiSelect' @@ -52,6 +70,8 @@ import Tags from './Tags' import { Props, State } from './RightPanel.types' import './RightPanel.css' +const MAX_OUTCOMES = 3 + export default class RightPanel extends React.PureComponent { analytics = getAnalytics() state: State = this.getInitialState() @@ -177,11 +197,11 @@ export default class RightPanel extends React.PureComponent { handleChangeCategory = (category: HideableWearableCategory | EmoteCategory) => { let data - if (isEmoteData(this.state.data!)) { + if (isEmoteData(this.state.data)) { data = { ...this.state.data, category - } as EmoteDataADR74 + } as EmoteData } else { data = { ...this.state.data, @@ -204,6 +224,142 @@ export default class RightPanel extends React.PureComponent { this.setState({ data, isDirty: this.isDirty({ data }) }) } + handleStartAnimationArmatureAnimationChange = (prop: ArmatureId.Armature | ArmatureId.Armature_Prop, value: string) => { + const data = this.state.data as EmoteData + const startAnimation = { ...(data.startAnimation || {}) } + + // For optional fields (Armature_Prop), remove the entire prop if value is empty + if (value === '' && prop === ArmatureId.Armature_Prop) { + delete startAnimation[prop] + } else { + startAnimation[prop] = { animation: value } + } + + this.setState({ + data: { ...data, startAnimation: startAnimation as StartAnimation }, + isDirty: this.isDirty({ data: { ...data, startAnimation: startAnimation as StartAnimation } }) + }) + } + + handleStartAnimationAudioClipChange = (audio: string) => { + const data = this.state.data as EmoteData + const startAnimation = { ...(data.startAnimation || {}) } + startAnimation.audio = audio || undefined + this.setState({ + data: { ...data, startAnimation: startAnimation as StartAnimation }, + isDirty: this.isDirty({ data: { ...data, startAnimation: startAnimation as StartAnimation } }) + }) + } + + handleStartAnimationPlayModeChange = (loop: boolean) => { + const data = this.state.data as EmoteData + const startAnimation = { ...(data.startAnimation || {}) } + startAnimation.loop = loop + this.setState({ + data: { ...data, startAnimation: startAnimation as StartAnimation }, + isDirty: this.isDirty({ data: { ...data, startAnimation: startAnimation as StartAnimation } }) + }) + } + + handleRandomizeOutcomesChange = (randomize: boolean) => { + const data = { + ...this.state.data, + randomizeOutcomes: randomize + } as EmoteData + this.setState({ data, isDirty: this.isDirty({ data }) }) + } + + handleOutcomeChange = (outcomeIndex: number, field: string, value: string) => { + const data = this.state.data as any + const outcomes = [...data.outcomes] + outcomes[outcomeIndex] = { + ...outcomes[outcomeIndex], + [field]: value + } + this.setState({ + data: { ...data, outcomes }, + isDirty: this.isDirty({ data: { ...data, outcomes } }) + }) + } + + handleOutcomeClipChange = (outcomeIndex: number, armatureId: ArmatureId, field: string, value: string) => { + const data = this.state.data as EmoteData + const outcomes = [...(data.outcomes || [])] + const clips = { ...outcomes[outcomeIndex].clips } + + if (value === '') { + // Remove the entire armatureId if value is empty + delete clips[armatureId] + } else { + if (!clips[armatureId]) { + clips[armatureId] = { + animation: '' + } + } + clips[armatureId] = { + ...clips[armatureId], + [field]: value + } + } + + outcomes[outcomeIndex] = { + ...outcomes[outcomeIndex], + clips + } + this.setState({ + data: { ...data, outcomes }, + isDirty: this.isDirty({ data: { ...data, outcomes } }) + }) + } + + handleOutcomeAudioClipChange = (outcomeIndex: number, audio: string) => { + const data = this.state.data as EmoteData + const outcomes = [...(data.outcomes || [])] + outcomes[outcomeIndex].audio = audio || undefined + this.setState({ data: { ...data, outcomes }, isDirty: this.isDirty({ data: { ...data, outcomes } }) }) + } + + handleOutcomePlayModeChange = (outcomeIndex: number, loop: boolean) => { + const data = this.state.data as EmoteData + const outcomes = [...(data.outcomes || [])] + outcomes[outcomeIndex].loop = loop + this.setState({ + data: { ...data, outcomes }, + isDirty: this.isDirty({ data: { ...data, outcomes } }) + }) + } + + handleAddOutcome = () => { + const data = this.state.data as EmoteData + if (!data || !data.outcomes) { + data.outcomes = [] + } + const newOutcome = { + title: `Outcome ${data.outcomes.length + 1}`, + clips: {}, + loop: false + } + const outcomes = [...data.outcomes, newOutcome] + this.setState({ + data: { ...data, outcomes }, + isDirty: this.isDirty({ data: { ...data, outcomes } }) + }) + } + + handleRemoveOutcome = (outcomeIndex: number) => { + const data = this.state.data as any + const outcomes = data.outcomes.filter((_: any, index: number) => index !== outcomeIndex) + this.setState({ + data: { ...data, outcomes }, + isDirty: this.isDirty({ data: { ...data, outcomes } }) + }) + } + + handleEditOutcomeTitle = (outcomeIndex: number, value: string) => { + // Update the outcome title + this.handleOutcomeChange(outcomeIndex, 'title', value) + } + setReplaces(data: WearableData, replaces: HideableWearableCategory[]) { return { ...data, @@ -398,6 +554,49 @@ export default class RightPanel extends React.PureComponent { })) } + getAnimationOptions(animationData: AnimationData, optional = false): Array<{ value: string; text: string }> { + if (!animationData.isLoaded) { + return [] + } + + if (animationData.error) { + console.warn('Animation data error:', animationData.error) + return [] + } + + const options = animationData.animations.map((animation: AnimationClip) => ({ + value: animation.name, + text: animation.name + })) + + return optional ? [{ value: '', text: '--' }, ...options] : options + } + + getAudioOptions(values: Item['contents']): Array<{ value: string; text: string }> { + const audioFiles = new Set() + + const options = Object.keys(values) + .map(key => { + if (isAudioFile(key)) { + // Extract filename without male/ or female/ prefix + const fileName = key.replace(/^(male|female)\//, '') + + // Only add if we haven't seen this filename before + if (!audioFiles.has(fileName)) { + audioFiles.add(fileName) + return { + value: fileName, + text: fileName + } + } + } + return null + }) + .filter(Boolean) as { value: string; text: string }[] + + return [{ value: '', text: '--' }, ...options] + } + renderOverrides(item: Item) { const canEditItemMetadata = this.canEditItemMetadata(item) const data = this.state.data as WearableData @@ -539,7 +738,7 @@ export default class RightPanel extends React.PureComponent {
{isConnected ? ( - {(item, collection, isLoading) => { + {(item, collection, isLoading, animationData) => { const isItemLocked = collection && isLocked(collection) const canEditItemMetadata = this.canEditItemMetadata(item) @@ -647,7 +846,165 @@ export default class RightPanel extends React.PureComponent { {this.renderOverrides(item)} )} - {item?.type === ItemType.EMOTE && ( + {item?.type === ItemType.EMOTE && + item?.metrics && + isSocialEmoteMetrics(item.metrics) && + !!item.metrics.additionalArmatures ? ( + +
+ Emote Start}> + + itemId={item.id} + label={`${t('item_editor.right_panel.social_emote.avatar')} 1`} + value={(data as unknown as EmoteData)?.startAnimation?.[ArmatureId.Armature]?.animation} + options={this.getAnimationOptions(animationData)} + disabled={isLoading} + onChange={value => this.handleStartAnimationArmatureAnimationChange(ArmatureId.Armature, value)} + /> + + + itemId={item.id} + label={`${t('item_editor.right_panel.social_emote.props')} (${t( + 'item_editor.right_panel.social_emote.optional' + )})`} + value={(data as unknown as EmoteData)?.startAnimation?.[ArmatureId.Armature_Prop]?.animation} + options={this.getAnimationOptions(animationData, true)} + disabled={isLoading} + onChange={value => this.handleStartAnimationArmatureAnimationChange(ArmatureId.Armature_Prop, value)} + /> + + {Object.values(item.contents).length > 0 && itemHasAudio(item) ? ( + + itemId={item.id} + label={`${t('item_editor.right_panel.social_emote.audio')} (${t( + 'item_editor.right_panel.social_emote.optional' + )})`} + value={(data as unknown as EmoteData)?.startAnimation?.audio} + options={this.getAudioOptions(item.contents)} + onChange={value => this.handleStartAnimationAudioClipChange(value)} + /> + ) : null} + + itemId={item.id} + label={t('create_single_item_modal.play_mode_label')} + value={(data as unknown as EmoteData)?.startAnimation?.loop ? EmotePlayMode.LOOP : EmotePlayMode.SIMPLE} + options={this.asPlayModeSelect(playModes)} + onChange={value => this.handleStartAnimationPlayModeChange(value === EmotePlayMode.LOOP)} + disabled // For now play mode for start animation is always loop + /> + + + {(data as unknown as EmoteData)?.outcomes && (data as unknown as EmoteData).outcomes!.length > 1 ? ( + + itemId={item.id} + label={t('item_editor.right_panel.social_emote.randomize_outcomes')} + value={(data as unknown as EmoteData)?.randomizeOutcomes ? 'true' : 'false'} + options={[ + { value: 'true', text: 'True' }, + { value: 'false', text: 'False' } + ]} + disabled={isLoading} + onChange={value => this.handleRandomizeOutcomesChange(value === 'true')} + /> + ) : null} + + {(data as unknown as EmoteData)?.outcomes && + (data as unknown as EmoteData).outcomes!.length > 0 && + (data as unknown as EmoteData).outcomes!.map((outcome, outcomeIndex) => ( + + this.handleEditOutcomeTitle(outcomeIndex, value)} + /> + {(data as unknown as EmoteData).outcomes!.length > 1 ? ( +
+ } + > + + itemId={item.id} + label={`${t('item_editor.right_panel.social_emote.avatar')} 1`} + value={outcome.clips[ArmatureId.Armature]?.animation} + options={this.getAnimationOptions(animationData)} + disabled={isLoading} + onChange={value => this.handleOutcomeClipChange(outcomeIndex, ArmatureId.Armature, 'animation', value)} + /> + + + itemId={item.id} + label={`${t('item_editor.right_panel.social_emote.avatar')} 2 (${t( + 'item_editor.right_panel.social_emote.optional' + )})`} + value={outcome.clips[ArmatureId.Armature_Other]?.animation} + options={this.getAnimationOptions(animationData, true)} + disabled={isLoading} + onChange={value => + this.handleOutcomeClipChange(outcomeIndex, ArmatureId.Armature_Other, 'animation', value) + } + /> + + + itemId={item.id} + label={`${t('item_editor.right_panel.social_emote.props')} (${t( + 'item_editor.right_panel.social_emote.optional' + )})`} + value={outcome.clips[ArmatureId.Armature_Prop]?.animation} + options={this.getAnimationOptions(animationData, true)} + disabled={isLoading} + onChange={value => + this.handleOutcomeClipChange(outcomeIndex, ArmatureId.Armature_Prop, 'animation', value) + } + /> + + {Object.values(item.contents).length > 0 && itemHasAudio(item) ? ( + + itemId={item.id} + label={`${t('item_editor.right_panel.social_emote.audio')} (${t( + 'item_editor.right_panel.social_emote.optional' + )})`} + value={outcome.audio} + options={this.getAudioOptions(item.contents)} + onChange={value => this.handleOutcomeAudioClipChange(outcomeIndex, value)} + /> + ) : null} + + + itemId={item.id} + label={t('create_single_item_modal.play_mode_label')} + value={outcome?.loop ? EmotePlayMode.LOOP : EmotePlayMode.SIMPLE} + options={this.asPlayModeSelect(playModes)} + disabled={isLoading} + onChange={value => this.handleOutcomePlayModeChange(outcomeIndex, value === EmotePlayMode.LOOP)} + /> + + ))} + + {((data as unknown as EmoteData)?.outcomes ?? []).length < MAX_OUTCOMES ? ( + + + + ) : null} +
+ + ) : ( {item ? ( diff --git a/src/components/ItemEditorPage/RightPanel/RightPanel.types.ts b/src/components/ItemEditorPage/RightPanel/RightPanel.types.ts index f245c4cb1..0e3247ead 100644 --- a/src/components/ItemEditorPage/RightPanel/RightPanel.types.ts +++ b/src/components/ItemEditorPage/RightPanel/RightPanel.types.ts @@ -1,5 +1,5 @@ import { Dispatch } from 'redux' -import { EmoteDataADR74, Rarity } from '@dcl/schemas' +import { Rarity } from '@dcl/schemas' import { deleteItemRequest, DeleteItemRequestAction, @@ -8,7 +8,7 @@ import { downloadItemRequest, DownloadItemRequestAction } from 'modules/item/actions' -import { Item, SyncStatus, WearableData } from 'modules/item/types' +import { Item, SyncStatus, WearableData, EmoteData } from 'modules/item/types' import { Collection } from 'modules/collection/types' import { openModal, OpenModalAction } from 'decentraland-dapps/dist/modules/modal/actions' @@ -42,7 +42,7 @@ export type State = { video: string rarity?: Rarity contents: Record - data?: WearableData | EmoteDataADR74 + data?: WearableData | EmoteData hasItem: boolean isDirty: boolean } diff --git a/src/components/ItemProvider/ItemProvider.tsx b/src/components/ItemProvider/ItemProvider.tsx index dbb7c7133..40818215f 100644 --- a/src/components/ItemProvider/ItemProvider.tsx +++ b/src/components/ItemProvider/ItemProvider.tsx @@ -1,9 +1,16 @@ import * as React from 'react' +import { getEmoteData } from 'lib/getModelData' import { Props, State } from './ItemProvider.types' +import { Item } from 'modules/item/types' export default class ItemProvider extends React.PureComponent { state: State = { - loadedItemId: undefined + loadedItemId: undefined, + animationData: { + animations: [], + armatures: [], + isLoaded: false + } } componentDidMount() { @@ -15,9 +22,14 @@ export default class ItemProvider extends React.PureComponent { if (isConnected && id && item?.collectionId && !collection) { onFetchCollection(item.collectionId) } + + // Load animation data if item is available + if (isConnected && id && item) { + void this.loadAnimationData(item) + } } - componentDidUpdate() { + componentDidUpdate(prevProps: Props) { const { id, item, collection, onFetchItem, onFetchCollection, isConnected } = this.props const { loadedItemId } = this.state @@ -27,10 +39,92 @@ export default class ItemProvider extends React.PureComponent { if (isConnected && id && item?.collectionId && !collection) { onFetchCollection(item.collectionId) } + + // Load animation data when item changes + if (isConnected && id && item && item.id !== prevProps.item?.id) { + void this.loadAnimationData(item) + } + } + + private findGlbFile(contents: Record): { path: string; hash: string } | null { + // Look for GLB/GLTF files in the contents + for (const [path, hash] of Object.entries(contents)) { + if (path.endsWith('.glb') || path.endsWith('.gltf')) { + return { path, hash } + } + } + return null + } + + private async fetchGlbBlob(hash: string): Promise { + const { getContentsStorageUrl } = await import('lib/api/builder') + const url = getContentsStorageUrl(hash) + const response = await fetch(url) + if (!response.ok) { + throw new Error(`Failed to fetch GLB file: ${response.statusText}`) + } + return response.blob() + } + + private async loadAnimationData(item: Item) { + if (item.type !== 'emote') { + return + } + + this.setState(prev => ({ + ...prev, + animationData: { + animations: [], + armatures: [], + isLoaded: false + } + })) + + try { + const glbFile = this.findGlbFile(item.contents) + if (!glbFile) { + this.setState(prev => ({ + ...prev, + animationData: { + animations: [], + armatures: [], + isLoaded: true, + error: 'No GLB/GLTF file found' + } + })) + return + } + + // Fetch the blob and get emote data + const blob = await this.fetchGlbBlob(glbFile.hash) + const data = await getEmoteData(URL.createObjectURL(blob)) + + console.log('Loaded animation data:', data) + + this.setState(prev => ({ + ...prev, + animationData: { + animations: data.animations || [], + armatures: data.armatures || [], + isLoaded: true + } + })) + } catch (error) { + this.setState(prev => ({ + ...prev, + animationData: { + animations: [], + armatures: [], + isLoaded: true, + error: error instanceof Error ? error.message : 'Unknown error' + } + })) + } } render() { const { item, collection, isLoading, children } = this.props - return <>{children(item, collection, isLoading)} + const { animationData } = this.state + return <>{children(item, collection, isLoading, animationData)} } } diff --git a/src/components/ItemProvider/ItemProvider.types.ts b/src/components/ItemProvider/ItemProvider.types.ts index 91222660b..29478d7b9 100644 --- a/src/components/ItemProvider/ItemProvider.types.ts +++ b/src/components/ItemProvider/ItemProvider.types.ts @@ -1,6 +1,14 @@ +import { AnimationClip, Object3D } from 'three' import { Item } from 'modules/item/types' import { Collection } from 'modules/collection/types' +export type AnimationData = { + animations: AnimationClip[] + armatures: Object3D[] + isLoaded: boolean + error?: string +} + export type Props = { item: Item | null collection: Collection | null @@ -9,10 +17,11 @@ export type Props = { id: string | null onFetchItem: (id: string) => void onFetchCollection: (id: string) => void - children: (item: Item | null, collection: Collection | null, isLoading: boolean) => React.ReactNode + children: (item: Item | null, collection: Collection | null, isLoading: boolean, animationData: AnimationData) => React.ReactNode } export type State = { loadedItemId: string | undefined + animationData: AnimationData } export type ContainerProps = Pick diff --git a/src/components/Modals/CreateSingleItemModal/CommonFields.tsx b/src/components/Modals/CreateSingleItemModal/CommonFields.tsx new file mode 100644 index 000000000..87265cd13 --- /dev/null +++ b/src/components/Modals/CreateSingleItemModal/CommonFields.tsx @@ -0,0 +1,172 @@ +import React, { useCallback } from 'react' +import { Field, SelectField } from 'decentraland-ui' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import { Mapping, MappingType, Rarity, WearableCategory } from '@dcl/schemas' +import { ItemType, ITEM_NAME_MAX_LENGTH } from 'modules/item/types' +import { getWearableCategories, getEmoteCategories, isSmart, buildItemMappings, isImageFile } from 'modules/item/utils' +import { MappingEditor } from 'components/MappingEditor' +import { THUMBNAIL_PATH } from 'modules/item/types' +import { createItemActions } from './CreateSingleItemModal.reducer' +import { useCreateSingleItemModal } from './CreateSingleItemModal.context' +import { getDefaultMappings, getLinkedContract, getThumbnailType } from './utils' +import { convertImageIntoWearableThumbnail } from 'modules/media/utils' +import { getModelData, EngineType } from 'lib/getModelData' +import { getExtension } from 'lib/file' +import { isThirdParty } from 'lib/urn' + +const RARITIES_LINK = 'https://docs.decentraland.org/creator/wearables-and-emotes/manage-collections' + +const defaultMapping: Mapping = { type: MappingType.ANY } + +export const CommonFields = () => { + const { state, collection, isThirdPartyV2Enabled, isLoading, dispatch } = useCreateSingleItemModal() + + // Field handlers - moved from main modal to here since they're only used in CommonFields + const handleNameChange = useCallback( + (_event: React.ChangeEvent, props: any) => + dispatch(createItemActions.setName(props.value.slice(0, ITEM_NAME_MAX_LENGTH))), + [dispatch] + ) + + const handleRarityChange = useCallback( + (_event: React.SyntheticEvent, data: any) => { + dispatch(createItemActions.setRarity(data.value)) + }, + [dispatch] + ) + + const handleCategoryChange = useCallback( + (_event: React.SyntheticEvent, data: any) => { + const category = data.value as WearableCategory + const hasChangedThumbnailType = + (state.category && getThumbnailType(category) !== getThumbnailType(state.category as WearableCategory)) || !state.category + + if (state.category !== category) { + dispatch(createItemActions.setCategory(category)) + if (state.type === ItemType.WEARABLE && hasChangedThumbnailType) { + void updateThumbnailByCategory(category) + } + } + }, + [state, dispatch] + ) + + const handleMappingChange = useCallback( + (mapping: any) => { + const contract = getLinkedContract(collection) + if (!contract) { + return + } + dispatch(createItemActions.setMappings(buildItemMappings(mapping, contract))) + }, + [collection, dispatch] + ) + + const getMapping = useCallback((): Mapping => { + const { mappings } = state + const contract = getLinkedContract(collection) + if (!contract) { + return defaultMapping + } + + let mapping: Mapping | undefined + if (mappings) { + mapping = mappings[contract.network]?.[contract.address][0] + } else { + mapping = getDefaultMappings(contract, isThirdPartyV2Enabled)?.[contract.network]?.[contract.address][0] + } + + return mapping ?? defaultMapping + }, [collection, state, isThirdPartyV2Enabled]) + + const updateThumbnailByCategory = useCallback( + async (category: WearableCategory) => { + const { model, contents } = state + + const isCustom = !!contents && THUMBNAIL_PATH in contents + if (!isCustom) { + dispatch(createItemActions.setLoading(true)) + let thumbnail + if (contents && isImageFile(model!)) { + thumbnail = await convertImageIntoWearableThumbnail(contents[THUMBNAIL_PATH] || contents[model!], category) + } else { + const url = URL.createObjectURL(contents![model!]) + const { image } = await getModelData(url, { + width: 1024, + height: 1024, + thumbnailType: getThumbnailType(category), + extension: (model && getExtension(model)) || undefined, + engine: EngineType.BABYLON + }) + thumbnail = image + URL.revokeObjectURL(url) + } + dispatch(createItemActions.setThumbnail(thumbnail)) + dispatch(createItemActions.setLoading(false)) + } + }, + [state, dispatch] + ) + const { name, category, rarity, contents, item, type } = state + + const belongsToAThirdPartyCollection = collection?.urn && isThirdParty(collection.urn) + const rarities = Rarity.getRarities() + const categories: string[] = type === ItemType.WEARABLE ? getWearableCategories(contents) : getEmoteCategories() + const linkedContract = getLinkedContract(collection) + + const raritiesLink = + RARITIES_LINK + + (type === ItemType.EMOTE + ? '/uploading-emotes/#rarity' + : isSmart({ type, contents }) + ? '/uploading-smart-wearables/#rarity' + : '/uploading-wearables/#rarity') + + return ( + <> + + {(!item || !item.isPublished) && !belongsToAThirdPartyCollection ? ( + + {t('create_single_item_modal.rarity_label')} + + {t('global.learn_more')} + +
+ } + placeholder={t('create_single_item_modal.rarity_placeholder')} + value={rarity} + options={rarities.map(value => ({ + value, + label: t('wearable.supply', { + count: Rarity.getMaxSupply(value), + formatted: Rarity.getMaxSupply(value).toLocaleString() + }), + text: t(`wearable.rarity.${value}`) + }))} + disabled={isLoading} + onChange={handleRarityChange} + /> + ) : null} + ({ value, text: t(`${type!}.category.${value}`) }))} + onChange={handleCategoryChange} + /> + {isThirdPartyV2Enabled && linkedContract && } + + ) +} + +export default React.memo(CommonFields) diff --git a/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.container.ts b/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.container.ts index eb7c28616..cad210223 100644 --- a/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.container.ts +++ b/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.container.ts @@ -4,7 +4,7 @@ import { isLoadingType } from 'decentraland-dapps/dist/modules/loading/selectors import { RootState } from 'modules/common/types' import { getCollection } from 'modules/collection/selectors' import { Collection } from 'modules/collection/types' -import { getIsLinkedWearablesV2Enabled, getIsOffchainPublicItemOrdersEnabled } from 'modules/features/selectors' +import { getIsLinkedWearablesV2Enabled } from 'modules/features/selectors' import { saveItemRequest, SAVE_ITEM_REQUEST } from 'modules/item/actions' import { getLoading, getError, getStatusByItemId } from 'modules/item/selectors' import { MapStateProps, MapDispatchProps, MapDispatch, OwnProps } from './CreateSingleItemModal.types' @@ -18,12 +18,11 @@ const mapState = (state: RootState, ownProps: OwnProps): MapStateProps => { return { collection, + itemStatus, address: getAddress(state), error: getError(state), - isThirdPartyV2Enabled: getIsLinkedWearablesV2Enabled(state), - isOffchainPublicItemOrdersEnabled: getIsOffchainPublicItemOrdersEnabled(state), - itemStatus, - isLoading: isLoadingType(getLoading(state), SAVE_ITEM_REQUEST) + isLoading: isLoadingType(getLoading(state), SAVE_ITEM_REQUEST), + isThirdPartyV2Enabled: getIsLinkedWearablesV2Enabled(state) } } diff --git a/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.context.ts b/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.context.ts new file mode 100644 index 000000000..01499f531 --- /dev/null +++ b/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.context.ts @@ -0,0 +1,53 @@ +import React, { createContext, useContext } from 'react' +import { Item, SyncStatus } from 'modules/item/types' +import { State, CreateSingleItemModalMetadata } from './CreateSingleItemModal.types' +import { CreateItemAction } from './CreateSingleItemModal.reducer' +import { Collection } from 'modules/collection/types' + +export interface CreateSingleItemModalContextValue { + // State + state: State + collection: Collection | null + error: string | null + itemStatus: SyncStatus | null + metadata: CreateSingleItemModalMetadata + dispatch: (action: CreateItemAction) => void + isLoading: boolean + + // Thumbnail handlers + handleOpenThumbnailDialog: () => void + handleThumbnailChange: (event: React.ChangeEvent) => void + thumbnailInput: React.RefObject + + // Wearable-specific handlers + filterItemsByBodyShape: (item: Item) => boolean + handleItemChange: (item: Item) => void + + // File handling + handleDropAccepted: (acceptedFileProps: any) => void + handleOnScreenshotTaken: (screenshot: string) => void + + // Modal handlers + onClose: () => void + handleSubmit: () => void + isDisabled: () => boolean + + // Render functions + renderMetrics: () => React.ReactNode + renderModalTitle: () => string + renderWearablePreview: () => React.ReactNode + + // Flags + isThirdPartyV2Enabled: boolean + isAddingRepresentation: boolean +} + +export const CreateSingleItemModalContext = createContext(undefined) + +export const useCreateSingleItemModal = (): CreateSingleItemModalContextValue => { + const context = useContext(CreateSingleItemModalContext) + if (context === undefined) { + throw new Error('useCreateSingleItemModal must be used within a CreateSingleItemModalProvider') + } + return context +} diff --git a/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.css b/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.css index 3b2619363..dd7cf0812 100644 --- a/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.css +++ b/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.css @@ -43,6 +43,7 @@ display: flex; flex-direction: column; padding: 24px; + align-self: flex-start; } .CreateSingleItemModal .preview .thumbnail-container { @@ -226,7 +227,8 @@ background-color: var(--secondary-text); } -.CreateSingleItemModal .importer-thumbnail-container .WearablePreview { +.CreateSingleItemModal .importer-thumbnail-container .WearablePreview, +.CreateSingleItemModal .importer-thumbnail-container #thumbnail-picker { display: none; } @@ -280,7 +282,7 @@ font-weight: 500; } -.CreateSingleItemModal .field-header { +.CreateSingleItemModal .fields-header { display: flex; align-items: center; justify-content: space-between; @@ -289,3 +291,20 @@ .CreateSingleItemModal .field-header .learn-more { text-decoration: underline; } + +.CreateSingleItemModal .outcomes-section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.CreateSingleItemModal .outcome-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.CreateSingleItemModal .dcl.box.outcome-box { + padding: 0; + padding-bottom: 8px; +} diff --git a/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.reducer.ts b/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.reducer.ts new file mode 100644 index 000000000..e23339f55 --- /dev/null +++ b/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.reducer.ts @@ -0,0 +1,449 @@ +import { AnimationClip, Object3D } from 'three' +import { + EmotePlayMode, + Rarity, + WearableCategory, + Mapping, + ContractNetwork, + ContractAddress, + OutcomeGroup, + StartAnimation +} from '@dcl/schemas' +import { Item, BodyShapeType, ItemType } from 'modules/item/types' +import { Metrics } from 'modules/models/types' +import { CreateItemView, State, AcceptedFileProps, CreateSingleItemModalMetadata } from './CreateSingleItemModal.types' +import { Collection } from 'modules/collection/types' +import { getBodyShapeType, getMissingBodyShapeType } from 'modules/item/utils' +import { getDefaultMappings, getLinkedContract } from './utils' + +// Initial State Creation +export const createInitialState = ( + metadata: CreateSingleItemModalMetadata | null, + collection: Collection | null, + isThirdPartyV2Enabled: boolean +): State => { + const state: State = { + view: CreateItemView.IMPORT, + playMode: EmotePlayMode.SIMPLE, + weareblePreviewUpdated: false, + hasScreenshotTaken: false + } + + if (!metadata) { + return state + } + + const { collectionId, item, addRepresentation } = metadata + state.collectionId = collectionId + const contract = collection ? getLinkedContract(collection) : undefined + + if (item) { + state.id = item.id + state.name = item.name + state.description = item.description + state.item = item + state.type = item.type + state.collectionId = item.collectionId + state.bodyShape = getBodyShapeType(item) + state.category = item.data.category + state.rarity = item.rarity + state.isRepresentation = false + state.mappings = item.mappings ?? getDefaultMappings(contract, isThirdPartyV2Enabled) + + if (addRepresentation) { + const missingBodyShape = getMissingBodyShapeType(item) + if (missingBodyShape) { + state.bodyShape = missingBodyShape + state.isRepresentation = true + } + } + } else { + state.mappings = getDefaultMappings(contract, isThirdPartyV2Enabled) + } + + return state +} + +// Action Types +export const CREATE_ITEM_ACTIONS = { + SET_VIEW: 'SET_VIEW', + SET_LOADING: 'SET_LOADING', + SET_ERROR: 'SET_ERROR', + SET_THUMBNAIL: 'SET_THUMBNAIL', + SET_VIDEO: 'SET_VIDEO', + SET_CONTENTS: 'SET_CONTENTS', + SET_METRICS: 'SET_METRICS', + SET_MODEL_SIZE: 'SET_MODEL_SIZE', + // SET_PREVIEW_CONTROLLER: 'SET_PREVIEW_CONTROLLER', + SET_ITEM: 'SET_ITEM', + SET_COLLECTION_ID: 'SET_COLLECTION_ID', + SET_BODY_SHAPE: 'SET_BODY_SHAPE', + SET_CATEGORY: 'SET_CATEGORY', + SET_RARITY: 'SET_RARITY', + SET_PLAY_MODE: 'SET_PLAY_MODE', + SET_TYPE: 'SET_TYPE', + SET_NAME: 'SET_NAME', + SET_DESCRIPTION: 'SET_DESCRIPTION', + SET_IS_REPRESENTATION: 'SET_IS_REPRESENTATION', + SET_MAPPINGS: 'SET_MAPPINGS', + SET_REQUIRED_PERMISSIONS: 'SET_REQUIRED_PERMISSIONS', + SET_START_ANIMATION: 'SET_START_ANIMATION', + SET_OUTCOMES: 'SET_OUTCOMES', + SET_EMOTE_DATA: 'SET_EMOTE_DATA', + SET_TAGS: 'SET_TAGS', + SET_BLOCK_VRM_EXPORT: 'SET_BLOCK_VRM_EXPORT', + SET_HAS_SCREENSHOT_TAKEN: 'SET_HAS_SCREENSHOT_TAKEN', + SET_WEARABLE_PREVIEW_UPDATED: 'SET_WEARABLE_PREVIEW_UPDATED', + SET_ITEM_SORTED_CONTENTS: 'SET_ITEM_SORTED_CONTENTS', + SET_FROM_VIEW: 'SET_FROM_VIEW', + SET_MODEL: 'SET_MODEL', + SET_VIDEO_PREVIEW: 'SET_VIDEO_PREVIEW', + SET_ACCEPTED_PROPS: 'SET_ACCEPTED_PROPS', + RESET_STATE: 'RESET_STATE', + UPDATE_THUMBNAIL_BY_CATEGORY: 'UPDATE_THUMBNAIL_BY_CATEGORY', + CLEAR_ERROR: 'CLEAR_ERROR' +} as const + +// Action Type Union +export type CreateItemAction = + | { type: typeof CREATE_ITEM_ACTIONS.SET_VIEW; payload: CreateItemView } + | { type: typeof CREATE_ITEM_ACTIONS.SET_LOADING; payload: boolean } + | { type: typeof CREATE_ITEM_ACTIONS.SET_ERROR; payload: string | undefined } + | { type: typeof CREATE_ITEM_ACTIONS.SET_THUMBNAIL; payload: string } + | { type: typeof CREATE_ITEM_ACTIONS.SET_VIDEO; payload: string | undefined } + | { type: typeof CREATE_ITEM_ACTIONS.SET_CONTENTS; payload: Record } + | { type: typeof CREATE_ITEM_ACTIONS.SET_METRICS; payload: Metrics } + | { type: typeof CREATE_ITEM_ACTIONS.SET_MODEL_SIZE; payload: number } + // | { type: typeof CREATE_ITEM_ACTIONS.SET_PREVIEW_CONTROLLER; payload: any } + | { type: typeof CREATE_ITEM_ACTIONS.SET_ITEM; payload: Item | undefined } + | { type: typeof CREATE_ITEM_ACTIONS.SET_COLLECTION_ID; payload: string | undefined } + | { type: typeof CREATE_ITEM_ACTIONS.SET_BODY_SHAPE; payload: BodyShapeType | undefined } + | { type: typeof CREATE_ITEM_ACTIONS.SET_CATEGORY; payload: WearableCategory | string } + | { type: typeof CREATE_ITEM_ACTIONS.SET_RARITY; payload: Rarity } + | { type: typeof CREATE_ITEM_ACTIONS.SET_PLAY_MODE; payload: EmotePlayMode } + | { type: typeof CREATE_ITEM_ACTIONS.SET_TYPE; payload: ItemType } + | { type: typeof CREATE_ITEM_ACTIONS.SET_NAME; payload: string } + | { type: typeof CREATE_ITEM_ACTIONS.SET_DESCRIPTION; payload: string } + | { type: typeof CREATE_ITEM_ACTIONS.SET_IS_REPRESENTATION; payload: boolean | undefined } + | { + type: typeof CREATE_ITEM_ACTIONS.SET_MAPPINGS + payload: Partial>> | undefined + } + | { type: typeof CREATE_ITEM_ACTIONS.SET_REQUIRED_PERMISSIONS; payload: string[] } + | { type: typeof CREATE_ITEM_ACTIONS.SET_START_ANIMATION; payload: Partial } + | { type: typeof CREATE_ITEM_ACTIONS.SET_OUTCOMES; payload: OutcomeGroup[] | ((prevOutcomes: OutcomeGroup[]) => OutcomeGroup[]) } + | { type: typeof CREATE_ITEM_ACTIONS.SET_EMOTE_DATA; payload: { animations: AnimationClip[]; armatures: Object3D[] } } + | { type: typeof CREATE_ITEM_ACTIONS.SET_TAGS; payload: string[] } + | { type: typeof CREATE_ITEM_ACTIONS.SET_BLOCK_VRM_EXPORT; payload: boolean } + | { type: typeof CREATE_ITEM_ACTIONS.SET_HAS_SCREENSHOT_TAKEN; payload: boolean } + | { type: typeof CREATE_ITEM_ACTIONS.SET_WEARABLE_PREVIEW_UPDATED; payload: boolean } + | { type: typeof CREATE_ITEM_ACTIONS.SET_ITEM_SORTED_CONTENTS; payload: Record } + | { type: typeof CREATE_ITEM_ACTIONS.SET_FROM_VIEW; payload: CreateItemView | undefined } + | { type: typeof CREATE_ITEM_ACTIONS.SET_MODEL; payload: string } + | { type: typeof CREATE_ITEM_ACTIONS.SET_VIDEO_PREVIEW; payload: string } + | { type: typeof CREATE_ITEM_ACTIONS.SET_ACCEPTED_PROPS; payload: Partial } + | { type: typeof CREATE_ITEM_ACTIONS.RESET_STATE; payload: Partial } + | { type: typeof CREATE_ITEM_ACTIONS.UPDATE_THUMBNAIL_BY_CATEGORY; payload: { thumbnail: string; isLoading: boolean } } + | { type: typeof CREATE_ITEM_ACTIONS.CLEAR_ERROR } + +// Action Creators +export const createItemActions = { + setView: (view: CreateItemView): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_VIEW, + payload: view + }), + + setLoading: (isLoading: boolean): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_LOADING, + payload: isLoading + }), + + setError: (error: string | undefined): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_ERROR, + payload: error + }), + + setThumbnail: (thumbnail: string): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_THUMBNAIL, + payload: thumbnail + }), + + setVideo: (video: string | undefined): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_VIDEO, + payload: video + }), + + setContents: (contents: Record): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_CONTENTS, + payload: contents + }), + + setMetrics: (metrics: Metrics): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_METRICS, + payload: metrics + }), + + setModelSize: (modelSize: number): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_MODEL_SIZE, + payload: modelSize + }), + + // setPreviewController: (controller: any): CreateItemAction => ({ + // type: CREATE_ITEM_ACTIONS.SET_PREVIEW_CONTROLLER, + // payload: controller + // }), + + setItem: (item: Item | undefined): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_ITEM, + payload: item + }), + + setCollectionId: (collectionId: string | undefined): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_COLLECTION_ID, + payload: collectionId + }), + + setBodyShape: (bodyShape?: BodyShapeType): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_BODY_SHAPE, + payload: bodyShape + }), + + setCategory: (category: WearableCategory | string): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_CATEGORY, + payload: category + }), + + setRarity: (rarity: Rarity): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_RARITY, + payload: rarity + }), + + setPlayMode: (playMode: EmotePlayMode): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_PLAY_MODE, + payload: playMode + }), + + setType: (type: ItemType): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_TYPE, + payload: type + }), + + setName: (name: string): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_NAME, + payload: name + }), + + setDescription: (description: string): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_DESCRIPTION, + payload: description + }), + + setIsRepresentation: (isRepresentation: boolean | undefined): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_IS_REPRESENTATION, + payload: isRepresentation + }), + + setMappings: (mappings: Partial>> | undefined): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_MAPPINGS, + payload: mappings + }), + + setRequiredPermissions: (requiredPermissions: string[]): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_REQUIRED_PERMISSIONS, + payload: requiredPermissions + }), + + setStartAnimation: (startAnimation: Partial): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_START_ANIMATION, + payload: startAnimation + }), + + setOutcomes: (outcomes: OutcomeGroup[] | ((prevOutcomes: OutcomeGroup[]) => OutcomeGroup[])): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_OUTCOMES, + payload: outcomes + }), + + setEmoteData: (emoteData: { animations: AnimationClip[]; armatures: Object3D[] }): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_EMOTE_DATA, + payload: emoteData + }), + + setTags: (tags: string[]): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_TAGS, + payload: tags + }), + + setBlockVrmExport: (blockVrmExport: boolean): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_BLOCK_VRM_EXPORT, + payload: blockVrmExport + }), + + setHasScreenshotTaken: (hasScreenshotTaken: boolean): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_HAS_SCREENSHOT_TAKEN, + payload: hasScreenshotTaken + }), + + setWearablePreviewUpdated: (weareblePreviewUpdated: boolean): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_WEARABLE_PREVIEW_UPDATED, + payload: weareblePreviewUpdated + }), + + setItemSortedContents: (itemSortedContents: Record): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_ITEM_SORTED_CONTENTS, + payload: itemSortedContents + }), + + setFromView: (fromView: CreateItemView | undefined): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_FROM_VIEW, + payload: fromView + }), + + setModel: (model: string): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_MODEL, + payload: model + }), + + setVideoPreview: (videoPreview: string): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_VIDEO_PREVIEW, + payload: videoPreview + }), + + setAcceptedProps: (acceptedProps: Partial): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.SET_ACCEPTED_PROPS, + payload: acceptedProps + }), + + resetState: (resetState: Partial): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.RESET_STATE, + payload: resetState + }), + + updateThumbnailByCategory: (thumbnail: string, isLoading: boolean): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.UPDATE_THUMBNAIL_BY_CATEGORY, + payload: { thumbnail, isLoading } + }), + + clearError: (): CreateItemAction => ({ + type: CREATE_ITEM_ACTIONS.CLEAR_ERROR + }) +} + +// Reducer function +export const createItemReducer = (state: State, action: CreateItemAction | null): State => { + switch (action?.type) { + case CREATE_ITEM_ACTIONS.SET_VIEW: + return { ...state, view: action.payload } + + case CREATE_ITEM_ACTIONS.SET_LOADING: + return { ...state, isLoading: action.payload } + + case CREATE_ITEM_ACTIONS.SET_ERROR: + return { ...state, error: action.payload } + + case CREATE_ITEM_ACTIONS.SET_THUMBNAIL: + return { ...state, thumbnail: action.payload } + + case CREATE_ITEM_ACTIONS.SET_VIDEO: + return { ...state, video: action.payload } + + case CREATE_ITEM_ACTIONS.SET_CONTENTS: + return { ...state, contents: action.payload } + + case CREATE_ITEM_ACTIONS.SET_METRICS: + return { ...state, metrics: action.payload } + + case CREATE_ITEM_ACTIONS.SET_MODEL_SIZE: + return { ...state, modelSize: action.payload } + + case CREATE_ITEM_ACTIONS.SET_ITEM: + return { ...state, item: action.payload } + + case CREATE_ITEM_ACTIONS.SET_COLLECTION_ID: + return { ...state, collectionId: action.payload } + + case CREATE_ITEM_ACTIONS.SET_BODY_SHAPE: + return { ...state, bodyShape: action.payload } + + case CREATE_ITEM_ACTIONS.SET_CATEGORY: + return { ...state, category: action.payload } + + case CREATE_ITEM_ACTIONS.SET_RARITY: + return { ...state, rarity: action.payload } + + case CREATE_ITEM_ACTIONS.SET_PLAY_MODE: + return { ...state, playMode: action.payload } + + case CREATE_ITEM_ACTIONS.SET_TYPE: + return { ...state, type: action.payload } + + case CREATE_ITEM_ACTIONS.SET_NAME: + return { ...state, name: action.payload } + + case CREATE_ITEM_ACTIONS.SET_DESCRIPTION: + return { ...state, description: action.payload } + + case CREATE_ITEM_ACTIONS.SET_IS_REPRESENTATION: + return { ...state, isRepresentation: action.payload } + + case CREATE_ITEM_ACTIONS.SET_MAPPINGS: + return { ...state, mappings: action.payload } + + case CREATE_ITEM_ACTIONS.SET_REQUIRED_PERMISSIONS: + return { ...state, requiredPermissions: action.payload } + + case CREATE_ITEM_ACTIONS.SET_START_ANIMATION: + return { ...state, startAnimation: action.payload as StartAnimation } + + case CREATE_ITEM_ACTIONS.SET_OUTCOMES: + return { + ...state, + outcomes: typeof action.payload === 'function' ? action.payload(state.outcomes || []) : action.payload + } + + case CREATE_ITEM_ACTIONS.SET_EMOTE_DATA: + return { ...state, emoteData: action.payload } + + case CREATE_ITEM_ACTIONS.SET_TAGS: + return { ...state, tags: action.payload } + + case CREATE_ITEM_ACTIONS.SET_BLOCK_VRM_EXPORT: + return { ...state, blockVrmExport: action.payload } + + case CREATE_ITEM_ACTIONS.SET_HAS_SCREENSHOT_TAKEN: + return { ...state, hasScreenshotTaken: action.payload } + + case CREATE_ITEM_ACTIONS.SET_WEARABLE_PREVIEW_UPDATED: + return { ...state, weareblePreviewUpdated: action.payload } + + case CREATE_ITEM_ACTIONS.SET_ITEM_SORTED_CONTENTS: + return { ...state, itemSortedContents: action.payload } + + case CREATE_ITEM_ACTIONS.SET_FROM_VIEW: + return { ...state, fromView: action.payload } + + case CREATE_ITEM_ACTIONS.SET_MODEL: + return { ...state, model: action.payload } + + case CREATE_ITEM_ACTIONS.SET_VIDEO_PREVIEW: + return { ...state, video: action.payload } + + case CREATE_ITEM_ACTIONS.SET_ACCEPTED_PROPS: + return { ...state, ...action.payload } + + case CREATE_ITEM_ACTIONS.UPDATE_THUMBNAIL_BY_CATEGORY: + return { + ...state, + thumbnail: action.payload.thumbnail, + isLoading: action.payload.isLoading + } + + case CREATE_ITEM_ACTIONS.CLEAR_ERROR: + return { ...state, error: undefined } + + case CREATE_ITEM_ACTIONS.RESET_STATE: + return { ...state, ...action.payload } + + default: + return state + } +} diff --git a/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.tsx b/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.tsx index 9a2472a36..14fd08070 100644 --- a/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.tsx +++ b/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.tsx @@ -1,17 +1,14 @@ -import * as React from 'react' +import React, { useReducer, useRef, useCallback, useMemo } from 'react' import { ethers } from 'ethers' import { BodyPartCategory, BodyShape, EmoteCategory, - EmoteDataADR74, Rarity, PreviewProjection, WearableCategory, - Mapping, - MappingType, - ContractNetwork, - ContractAddress + IPreviewController, + StartAnimation } from '@dcl/schemas' import { MAX_EMOTE_FILE_SIZE, @@ -20,61 +17,41 @@ import { MAX_WEARABLE_FILE_SIZE, MAX_SMART_WEARABLE_FILE_SIZE } from '@dcl/builder-client/dist/files/constants' -import { - ModalNavigation, - Row, - Column, - Button, - Form, - Field, - Icon as DCLIcon, - Section, - Header, - InputOnChangeData, - SelectField, - DropdownProps, - WearablePreview, - Message -} from 'decentraland-ui' +import { WearablePreview } from 'decentraland-ui2' import { t } from 'decentraland-dapps/dist/modules/translation/utils' import Modal from 'decentraland-dapps/dist/containers/Modal' import { isErrorWithMessage } from 'decentraland-dapps/dist/lib/error' -import { getImageType, dataURLToBlob, convertImageIntoWearableThumbnail } from 'modules/media/utils' +import { getImageType, dataURLToBlob } from 'modules/media/utils' import { ImageType } from 'modules/media/types' import { THUMBNAIL_PATH, Item, BodyShapeType, - ITEM_NAME_MAX_LENGTH, WearableRepresentation, ItemType, EmotePlayMode, VIDEO_PATH, WearableData, - SyncStatus + SyncStatus, + EmoteData } from 'modules/item/types' -import { Metrics } from 'modules/models/types' +import { areEmoteMetrics, Metrics } from 'modules/models/types' import { computeHashes } from 'modules/deployment/contentUtils' import { getBodyShapeType, getMissingBodyShapeType, - getWearableCategories, - getBackgroundStyle, isImageFile, resizeImage, - getEmoteCategories, - getEmotePlayModes, getBodyShapeTypeFromContents, isSmart, isWearable, - buildItemMappings, isEmoteFileSizeValid, isSkinFileSizeValid, isSmartWearableFileSizeValid, isWearableFileSizeValid } from 'modules/item/utils' -import { EngineType, getItemData, getModelData } from 'lib/getModelData' -import { getExtension, toMB } from 'lib/file' +import { getItemData } from 'lib/getModelData' +import { toMB } from 'lib/file' import { getDefaultThirdPartyUrnSuffix, buildThirdPartyURN, @@ -83,395 +60,497 @@ import { isThirdParty, isThirdPartyCollectionDecodedUrn } from 'lib/urn' -import ItemDropdown from 'components/ItemDropdown' -import Icon from 'components/Icon' -import ItemVideo from 'components/ItemVideo' -import ItemRequiredPermission from 'components/ItemRequiredPermission' import ItemProperties from 'components/ItemProperties' -import { Collection } from 'modules/collection/types' -import { LinkedContract } from 'modules/thirdParty/types' import { calculateFileSize, calculateModelFinalSize } from 'modules/item/export' import { MAX_THUMBNAIL_SIZE } from 'modules/assetPack/utils' import { areMappingsValid } from 'modules/thirdParty/utils' import { Authorization } from 'lib/api/auth' -import { MappingEditor } from 'components/MappingEditor' import { BUILDER_SERVER_URL, BuilderAPI } from 'lib/api/builder' -import EditPriceAndBeneficiaryModal from '../EditPriceAndBeneficiaryModal' -import ImportStep from './ImportStep/ImportStep' -import EditThumbnailStep from './EditThumbnailStep/EditThumbnailStep' -import UploadVideoStep from './UploadVideoStep/UploadVideoStep' -import { getThumbnailType, toEmoteWithBlobs, toWearableWithBlobs } from './utils' +import { + autocompleteSocialEmoteData, + buildRepresentations, + buildRepresentationsZipBothBodyshape, + getLinkedContract, + sortContent, + sortContentZipBothBodyShape, + toEmoteWithBlobs, + toWearableWithBlobs +} from './utils' +import { createItemReducer, createItemActions, createInitialState } from './CreateSingleItemModal.reducer' import { Props, State, CreateItemView, - CreateSingleItemModalMetadata, StateData, SortedContent, AcceptedFileProps, - ITEM_LOADED_CHECK_DELAY + CreateSingleItemModalMetadata } from './CreateSingleItemModal.types' +import { Steps } from './Steps' +import { CreateSingleItemModalProvider } from './CreateSingleItemModalProvider' import './CreateSingleItemModal.css' -const defaultMapping: Mapping = { type: MappingType.ANY } -export default class CreateSingleItemModal extends React.PureComponent { - state: State = this.getInitialState() - thumbnailInput = React.createRef() - videoInput = React.createRef() - modalContainer = React.createRef() - timer: ReturnType | undefined - - getDefaultMappings( - contract: LinkedContract | undefined, - isThirdPartyV2Enabled: boolean - ): Partial>> | undefined { - if (!isThirdPartyV2Enabled || !contract) { - return undefined - } +export const CreateSingleItemModal: React.FC = props => { + const { address, collection, error, itemStatus, metadata, name, onClose, onSave, isLoading, isThirdPartyV2Enabled } = props + const thumbnailInput = useRef(null) + const modalContainer = useRef(null) - return { - [contract.network]: { - [contract.address]: [defaultMapping] - } - } - } + const getInitialState = useCallback((): State => { + return createInitialState(metadata, collection, isThirdPartyV2Enabled) + }, [collection, metadata, isThirdPartyV2Enabled]) - getInitialState() { - const { metadata, collection, isThirdPartyV2Enabled } = this.props + const [state, dispatch] = useReducer(createItemReducer, getInitialState()) - const state: State = { - view: CreateItemView.IMPORT, - playMode: EmotePlayMode.SIMPLE, - weareblePreviewUpdated: false, - hasScreenshotTaken: false - } + const isAddingRepresentation = useMemo(() => { + return !!(metadata && metadata.item && !metadata.changeItemFile) + }, [metadata]) + + const createItem = useCallback( + async (sortedContents: SortedContent, representations: WearableRepresentation[]) => { + const { + id, + name, + description, + type, + metrics, + collectionId, + category, + playMode, + rarity, + hasScreenshotTaken, + requiredPermissions, + tags, + blockVrmExport, + mappings, + emoteData + } = state as StateData + + const belongsToAThirdPartyCollection = collection?.urn && isThirdParty(collection?.urn) + // If it's a third party item, we need to automatically create an URN for it by generating a random uuid different from the id + const decodedCollectionUrn: DecodedURN | null = collection?.urn ? decodeURN(collection.urn) : null + let urn: string | undefined + if (decodedCollectionUrn && isThirdPartyCollectionDecodedUrn(decodedCollectionUrn)) { + urn = buildThirdPartyURN( + decodedCollectionUrn.thirdPartyName, + decodedCollectionUrn.thirdPartyCollectionId, + getDefaultThirdPartyUrnSuffix(name) + ) + } - if (!metadata) { - return state - } + // create item to save + let data: WearableData | EmoteData + + if (type === ItemType.WEARABLE) { + const removesDefaultHiding = category === WearableCategory.UPPER_BODY ? [BodyPartCategory.HANDS] : [] + data = { + category: category as WearableCategory, + replaces: [], + hides: [], + removesDefaultHiding, + tags: tags || [], + representations: [...representations], + requiredPermissions: requiredPermissions || [], + blockVrmExport: blockVrmExport ?? false, + outlineCompatible: true // it's going to be true for all the items. It can be deactivated later in the editor view + } as WearableData + } else { + data = { + category: category as EmoteCategory, + loop: playMode === EmotePlayMode.LOOP, + tags: tags || [], + representations: [...representations] + } as EmoteData + + // Use autocompleteSocialEmoteData to generate startAnimation and outcomes from available animations + if (emoteData?.animations && emoteData.animations.length > 0) { + const animationNames = emoteData.animations.map(clip => clip.name) + const autocompletedData = autocompleteSocialEmoteData(animationNames) + + if (autocompletedData.startAnimation || autocompletedData.outcomes) { + // Transform startAnimation if available + if (autocompletedData.startAnimation) { + data.startAnimation = autocompletedData.startAnimation as StartAnimation + } - const { collectionId, item, addRepresentation } = metadata as CreateSingleItemModalMetadata - state.collectionId = collectionId - const contract = collection ? this.getLinkedContract(collection) : undefined - - if (item) { - state.id = item.id - state.name = item.name - state.description = item.description - state.item = item - state.type = item.type - state.collectionId = item.collectionId - state.bodyShape = getBodyShapeType(item) - state.category = item.data.category - state.rarity = item.rarity - state.isRepresentation = false - state.mappings = item.mappings ?? this.getDefaultMappings(contract, isThirdPartyV2Enabled) - - if (addRepresentation) { - const missingBodyShape = getMissingBodyShapeType(item) - if (missingBodyShape) { - state.bodyShape = missingBodyShape - state.isRepresentation = true + // Transform outcomes if available + if (autocompletedData.outcomes) { + data.outcomes = autocompletedData.outcomes + } + + // Add randomizeOutcomes flag + data.randomizeOutcomes = false + } } } - } else { - state.mappings = this.getDefaultMappings(contract, isThirdPartyV2Enabled) - } - return state - } + const contents = await computeHashes(sortedContents.all) - /** - * Prefixes the content name by adding the adding the body shape name to it. - * - * @param bodyShape - The body shaped used to prefix the content name. - * @param contentKey - The content key or name to be prefixed. - */ - prefixContentName(bodyShape: BodyShapeType, contentKey: string): string { - return `${bodyShape}/${contentKey}` - } + const item: Item = { + id, + name, + urn, + description: description || '', + thumbnail: THUMBNAIL_PATH, + video: contents[VIDEO_PATH], + type, + collectionId, + totalSupply: 0, + isPublished: false, + isApproved: false, + inCatalyst: false, + blockchainContentHash: null, + currentContentHash: null, + catalystContentHash: null, + rarity: belongsToAThirdPartyCollection ? Rarity.UNIQUE : rarity, + data, + owner: address!, + metrics, + contents, + mappings: belongsToAThirdPartyCollection && mappings ? mappings : null, + createdAt: +new Date(), + updatedAt: +new Date() + } - /** - * Creates a new contents record with the names of the contents blobs record prefixed. - * The names need to be prefixed so they won't collide with other - * pre-uploaded models. The name of the content is the name of the uploaded file. - * - * @param bodyShape - The body shaped used to prefix the content names. - * @param contents - The contents which keys are going to be prefixed. - */ - prefixContents(bodyShape: BodyShapeType, contents: Record): Record { - return Object.keys(contents).reduce((newContents: Record, key: string) => { - // Do not include the thumbnail, scenes, and video in each of the body shapes - if ([THUMBNAIL_PATH, VIDEO_PATH].includes(key)) { - return newContents + // If it's a Third Party Item, don't prompt the user with the SET PRICE view + if ((hasScreenshotTaken || type !== ItemType.EMOTE) && belongsToAThirdPartyCollection) { + item.price = '0' + item.beneficiary = ethers.constants.AddressZero + return onSave(item as Item, sortedContents.all) } - newContents[this.prefixContentName(bodyShape, key)] = contents[key] - return newContents - }, {}) - } - /** - * Sorts the content into "male", "female" and "all" taking into consideration the body shape. - * All contains the item thumbnail and both male and female representations according to the shape. - * If the body representation is male, "female" will be an empty object and viceversa. - * - * @param bodyShape - The body shaped used to sort the content. - * @param contents - The contents to be sorted. - */ - sortContent = (bodyShape: BodyShapeType, contents: Record): SortedContent => { - const male = - bodyShape === BodyShapeType.BOTH || bodyShape === BodyShapeType.MALE ? this.prefixContents(BodyShapeType.MALE, contents) : {} - const female = - bodyShape === BodyShapeType.BOTH || bodyShape === BodyShapeType.FEMALE ? this.prefixContents(BodyShapeType.FEMALE, contents) : {} - - const all: Record = { - [THUMBNAIL_PATH]: contents[THUMBNAIL_PATH], - ...male, - ...female - } + if (hasScreenshotTaken || type !== ItemType.EMOTE) { + item.price = ethers.constants.MaxUint256.toString() + item.beneficiary = item.beneficiary || address + return onSave(item as Item, sortedContents.all) + } - if (contents[VIDEO_PATH]) { - all[VIDEO_PATH] = contents[VIDEO_PATH] - } + dispatch(createItemActions.setItem(item as Item)) + dispatch(createItemActions.setItemSortedContents(sortedContents.all)) + dispatch(createItemActions.setView(CreateItemView.THUMBNAIL)) + dispatch(createItemActions.setFromView(CreateItemView.THUMBNAIL)) + }, + [address, collection, state, onSave] + ) + + const addItemRepresentation = useCallback( + async (sortedContents: SortedContent, representations: WearableRepresentation[]) => { + const { bodyShape, item: editedItem, requiredPermissions } = state as StateData + const hashedContents = await computeHashes(bodyShape === BodyShapeType.MALE ? sortedContents.male : sortedContents.female) + if (isWearable(editedItem)) { + const removesDefaultHiding = + editedItem.data.category === WearableCategory.UPPER_BODY || editedItem.data.hides.includes(WearableCategory.UPPER_BODY) + ? [BodyPartCategory.HANDS] + : [] + const item = { + ...editedItem, + data: { + ...editedItem.data, + representations: [ + ...editedItem.data.representations, + // add new representation + ...representations + ], + replaces: [...editedItem.data.replaces], + hides: [...editedItem.data.hides], + removesDefaultHiding: removesDefaultHiding, + tags: [...editedItem.data.tags], + requiredPermissions: requiredPermissions || [], + blockVrmExport: editedItem.data.blockVrmExport, + outlineCompatible: editedItem.data.outlineCompatible || true // it's going to be true for all the items. It can be deactivated later in the editor view + }, + contents: { + ...editedItem.contents, + ...hashedContents + }, + updatedAt: +new Date() + } - return { male, female, all } - } + // Do not change the thumbnail when adding a new representation + delete sortedContents.all[THUMBNAIL_PATH] + onSave(item, sortedContents.all) + } + }, + [state, onSave] + ) + + const modifyItem = useCallback( + async (pristineItem: Item, sortedContents: SortedContent, representations: WearableRepresentation[]) => { + const { name, bodyShape, type, mappings, metrics, category, playMode, requiredPermissions, outcomes } = state as StateData + + let data: WearableData | EmoteData + + if (type === ItemType.WEARABLE) { + const removesDefaultHiding = category === WearableCategory.UPPER_BODY ? [BodyPartCategory.HANDS] : [] + data = { + ...pristineItem.data, + removesDefaultHiding, + category: category as WearableCategory, + requiredPermissions: requiredPermissions || [] + } as WearableData + } else { + data = { + ...pristineItem.data, + loop: playMode === EmotePlayMode.LOOP, + category: category as EmoteCategory + } as EmoteData + + // TODO: Autocomplete animations when modifying an emote + if (outcomes && outcomes.length > 0) { + data.outcomes = outcomes + } + } - sortContentZipBothBodyShape = (bodyShape: BodyShapeType, contents: Record): SortedContent => { - let male: Record = {} - let female: Record = {} - const both: Record = {} + const contents = await computeHashes(sortedContents.all) - for (const [key, value] of Object.entries(contents)) { - if (key.startsWith('male/') && (bodyShape === BodyShapeType.BOTH || bodyShape === BodyShapeType.MALE)) { - male[key] = value - } else if (key.startsWith('female/') && (bodyShape === BodyShapeType.BOTH || bodyShape === BodyShapeType.FEMALE)) { - female[key] = value + const item = { + ...pristineItem, + data, + name, + metrics, + contents, + mappings, + updatedAt: +new Date() + } + + const wearableBodyShape = bodyShape === BodyShapeType.MALE ? BodyShape.MALE : BodyShape.FEMALE + const representationIndex = pristineItem.data.representations.findIndex( + (representation: WearableRepresentation) => representation.bodyShapes[0] === wearableBodyShape + ) + const pristineBodyShape = getBodyShapeType(pristineItem) + if (representations.length === 2 || representationIndex === -1 || pristineBodyShape === BodyShapeType.BOTH) { + // Unisex or Representation changed + item.data.representations = representations } else { - both[key] = value + // Edited representation + item.data.representations[representationIndex] = representations[0] + } + + if (itemStatus && [SyncStatus.UNPUBLISHED, SyncStatus.UNDER_REVIEW].includes(itemStatus) && isSmart(item) && VIDEO_PATH in contents) { + item.video = contents[VIDEO_PATH] } + + onSave(item as Item, sortedContents.all) + }, + [itemStatus, state, onSave] + ) + + // Thumbnail handling + const handleOpenThumbnailDialog = useCallback(() => { + const { type } = state + if (type === ItemType.EMOTE) { + dispatch(createItemActions.setView(CreateItemView.THUMBNAIL)) + } else if (thumbnailInput.current) { + thumbnailInput.current.click() + } + }, [state, thumbnailInput]) + + const handleThumbnailChange = useCallback( + async (event: React.ChangeEvent) => { + const { contents } = state + const { files } = event.target + if (files && files.length > 0) { + const file = files[0] + const imageType = await getImageType(file) + if (imageType !== ImageType.PNG) { + dispatch(createItemActions.setError(t('create_single_item_modal.wrong_thumbnail_format'))) + return + } + dispatch(createItemActions.clearError()) + + const smallThumbnailBlob = await resizeImage(file) + const bigThumbnailBlob = await resizeImage(file, 1024, 1024) + + const thumbnail = URL.createObjectURL(smallThumbnailBlob) + + dispatch(createItemActions.setThumbnail(thumbnail)) + dispatch( + createItemActions.setContents({ + ...contents, + [THUMBNAIL_PATH]: bigThumbnailBlob + }) + ) + } + }, + [state] + ) + + const handleItemChange = useCallback((item: Item) => { + dispatch(createItemActions.setItem(item)) + dispatch(createItemActions.setCategory(item.data.category as WearableCategory)) + dispatch(createItemActions.setRarity(item.rarity as Rarity)) + }, []) + + const filterItemsByBodyShape = useCallback( + (item: Item) => { + const { bodyShape } = state + return getMissingBodyShapeType(item) === bodyShape && metadata.collectionId === item.collectionId + }, + [metadata, state] + ) + + // Common render functions + const renderMetrics = useCallback(() => { + const { metrics, contents } = state + if (metrics) { + return + } else { + return null } + }, [state]) - male = { - ...male, - ...(bodyShape === BodyShapeType.BOTH || bodyShape === BodyShapeType.MALE ? this.prefixContents(BodyShapeType.MALE, both) : {}) + /** + * Gets the modal title based on state and metadata + */ + const getModalTitle = (state: State, metadata: CreateSingleItemModalMetadata, isAddingRepresentation: boolean): string => { + const { type, view, contents } = state + + if (isAddingRepresentation) { + return t('create_single_item_modal.add_representation') } - female = { - ...female, - ...(bodyShape === BodyShapeType.BOTH || bodyShape === BodyShapeType.FEMALE ? this.prefixContents(BodyShapeType.FEMALE, both) : {}) + if (metadata && metadata.changeItemFile) { + return t('create_single_item_modal.change_item_file') } - const all = { - [THUMBNAIL_PATH]: contents[THUMBNAIL_PATH], - ...male, - ...female + if (type === ItemType.EMOTE) { + return view === CreateItemView.THUMBNAIL + ? t('create_single_item_modal.thumbnail_step_title') + : t('create_single_item_modal.title_emote') } - return { male, female, all } + if (type === ItemType.WEARABLE && contents && isSmart({ type, contents }) && view === CreateItemView.DETAILS) { + return t('create_single_item_modal.smart_wearable_details_title') + } + + switch (view) { + case CreateItemView.THUMBNAIL: + return t('create_single_item_modal.thumbnail_step_title') + case CreateItemView.UPLOAD_VIDEO: + return t('create_single_item_modal.upload_video_step_title') + default: + return t('create_single_item_modal.title') + } } - createItem = async (sortedContents: SortedContent, representations: WearableRepresentation[]) => { - const { address, collection, onSave } = this.props + const renderModalTitle = useCallback(() => { + return getModalTitle(state, metadata, isAddingRepresentation) + }, [state, metadata, isAddingRepresentation]) + + // Validation + const isValid = useCallback((): boolean => { const { - id, name, - description, - type, + thumbnail, metrics, - collectionId, + bodyShape, category, playMode, rarity, - hasScreenshotTaken, - requiredPermissions, - tags, - blockVrmExport, - mappings - } = this.state as StateData - - const belongsToAThirdPartyCollection = collection?.urn && isThirdParty(collection?.urn) - // If it's a third party item, we need to automatically create an URN for it by generating a random uuid different from the id - const decodedCollectionUrn: DecodedURN | null = collection?.urn ? decodeURN(collection.urn) : null - let urn: string | undefined - if (decodedCollectionUrn && isThirdPartyCollectionDecodedUrn(decodedCollectionUrn)) { - urn = buildThirdPartyURN( - decodedCollectionUrn.thirdPartyName, - decodedCollectionUrn.thirdPartyCollectionId, - getDefaultThirdPartyUrnSuffix(name) - ) - } - - // create item to save - let data: WearableData | EmoteDataADR74 - - if (type === ItemType.WEARABLE) { - const removesDefaultHiding = category === WearableCategory.UPPER_BODY ? [BodyPartCategory.HANDS] : [] - data = { - category: category as WearableCategory, - replaces: [], - hides: [], - removesDefaultHiding, - tags: tags || [], - representations: [...representations], - requiredPermissions: requiredPermissions || [], - blockVrmExport: blockVrmExport ?? false, - outlineCompatible: true // it's going to be true for all the items. It can be deactivated later in the editor view - } as WearableData - } else { - data = { - category: category as EmoteCategory, - loop: playMode === EmotePlayMode.LOOP, - tags: tags || [], - representations: [...representations] - } as EmoteDataADR74 - } - - const contents = await computeHashes(sortedContents.all) - - const item: Item = { - id, - name, - urn, - description: description || '', - thumbnail: THUMBNAIL_PATH, - video: contents[VIDEO_PATH], + item, + isRepresentation, type, - collectionId, - totalSupply: 0, - isPublished: false, - isApproved: false, - inCatalyst: false, - blockchainContentHash: null, - currentContentHash: null, - catalystContentHash: null, - rarity: belongsToAThirdPartyCollection ? Rarity.UNIQUE : rarity, - data, - owner: address!, - metrics, - contents, - mappings: belongsToAThirdPartyCollection && mappings ? mappings : null, - createdAt: +new Date(), - updatedAt: +new Date() - } + modelSize, + mappings, + error: stateError + } = state + const belongsToAThirdPartyCollection = collection?.urn && isThirdParty(collection.urn) + const linkedContract = collection ? getLinkedContract(collection) : undefined - // If it's a Third Party Item, don't prompt the user with the SET PRICE view - if ((hasScreenshotTaken || type !== ItemType.EMOTE) && belongsToAThirdPartyCollection) { - item.price = '0' - item.beneficiary = ethers.constants.AddressZero - return onSave(item as Item, sortedContents.all) + if (stateError) { + dispatch(createItemActions.clearError()) } - if (hasScreenshotTaken || type !== ItemType.EMOTE) { - item.price = ethers.constants.MaxUint256.toString() - item.beneficiary = item.beneficiary || address - return onSave(item as Item, sortedContents.all) + let required: (string | Metrics | Item | undefined)[] + if (isRepresentation) { + required = [item as Item] + } else if (belongsToAThirdPartyCollection) { + required = [name, thumbnail, metrics, bodyShape, category] + } else if (type === ItemType.EMOTE) { + required = [name, thumbnail, metrics, category, playMode, rarity, type] + } else { + required = [name, thumbnail, metrics, bodyShape, category, rarity, type] } - this.setState({ - item, - itemSortedContents: sortedContents.all, - view: CreateItemView.THUMBNAIL, - fromView: CreateItemView.THUMBNAIL - }) - } - - addItemRepresentation = async (sortedContents: SortedContent, representations: WearableRepresentation[]) => { - const { onSave } = this.props - const { bodyShape, item: editedItem, requiredPermissions } = this.state as StateData - const hashedContents = await computeHashes(bodyShape === BodyShapeType.MALE ? sortedContents.male : sortedContents.female) - if (isWearable(editedItem)) { - const removesDefaultHiding = - editedItem.data.category === WearableCategory.UPPER_BODY || editedItem.data.hides.includes(WearableCategory.UPPER_BODY) - ? [BodyPartCategory.HANDS] - : [] - const item = { - ...editedItem, - data: { - ...editedItem.data, - representations: [ - ...editedItem.data.representations, - // add new representation - ...representations - ], - replaces: [...editedItem.data.replaces], - hides: [...editedItem.data.hides], - removesDefaultHiding: removesDefaultHiding, - tags: [...editedItem.data.tags], - requiredPermissions: requiredPermissions || [], - blockVrmExport: editedItem.data.blockVrmExport, - outlineCompatible: editedItem.data.outlineCompatible || true // it's going to be true for all the items. It can be deactivated later in the editor view - }, - contents: { - ...editedItem.contents, - ...hashedContents - }, - updatedAt: +new Date() - } + const thumbnailBlob = thumbnail ? dataURLToBlob(thumbnail) : undefined + const thumbnailSize = thumbnailBlob ? calculateFileSize(thumbnailBlob) : 0 - // Do not change the thumbnail when adding a new representation - delete sortedContents.all[THUMBNAIL_PATH] - onSave(item, sortedContents.all) + if (thumbnailSize && thumbnailSize > MAX_THUMBNAIL_SIZE) { + dispatch( + createItemActions.setError( + t('create_single_item_modal.error.thumbnail_file_too_big', { maxSize: `${toMB(MAX_THUMBNAIL_FILE_SIZE)}MB` }) + ) + ) + return false } - } - - modifyItem = async (pristineItem: Item, sortedContents: SortedContent, representations: WearableRepresentation[]) => { - const { itemStatus, onSave } = this.props - const { name, bodyShape, type, mappings, metrics, category, playMode, requiredPermissions } = this.state as StateData - - let data: WearableData | EmoteDataADR74 + const isSkin = category === WearableCategory.SKIN + const isEmote = type === ItemType.EMOTE + const isSmartWearable = isSmart({ type, contents: state.contents }) + const isRequirementMet = required.every(prop => prop !== undefined) + const finalSize = modelSize ? modelSize + thumbnailSize : undefined - if (type === ItemType.WEARABLE) { - const removesDefaultHiding = category === WearableCategory.UPPER_BODY ? [BodyPartCategory.HANDS] : [] - data = { - ...pristineItem.data, - removesDefaultHiding, - category: category as WearableCategory, - requiredPermissions: requiredPermissions || [] - } as WearableData - } else { - data = { - ...pristineItem.data, - loop: playMode === EmotePlayMode.LOOP, - category: category as EmoteCategory - } as EmoteDataADR74 + if (isThirdPartyV2Enabled && ((!mappings && linkedContract) || (mappings && !areMappingsValid(mappings)))) { + return false } - const contents = await computeHashes(sortedContents.all) + if (isRequirementMet && isEmote && finalSize && !isEmoteFileSizeValid(finalSize)) { + dispatch( + createItemActions.setError( + t('create_single_item_modal.error.item_too_big', { + size: `${toMB(MAX_EMOTE_FILE_SIZE)}MB`, + type: `emote` + }) + ) + ) + return false + } - const item = { - ...pristineItem, - data, - name, - metrics, - contents, - mappings, - updatedAt: +new Date() + if (isRequirementMet && isSkin && finalSize && !isSkinFileSizeValid(finalSize)) { + dispatch( + createItemActions.setError( + t('create_single_item_modal.error.item_too_big', { + size: `${toMB(MAX_SKIN_FILE_SIZE)}MB`, + type: `skin` + }) + ) + ) + return false } - const wearableBodyShape = bodyShape === BodyShapeType.MALE ? BodyShape.MALE : BodyShape.FEMALE - const representationIndex = pristineItem.data.representations.findIndex( - (representation: WearableRepresentation) => representation.bodyShapes[0] === wearableBodyShape - ) - const pristineBodyShape = getBodyShapeType(pristineItem) - if (representations.length === 2 || representationIndex === -1 || pristineBodyShape === BodyShapeType.BOTH) { - // Unisex or Representation changed - item.data.representations = representations - } else { - // Edited representation - item.data.representations[representationIndex] = representations[0] + if (isRequirementMet && !isSkin && isSmartWearable && finalSize && !isSmartWearableFileSizeValid(finalSize)) { + console.log('isSmartWearableFileSizeValid', isSmartWearableFileSizeValid(finalSize), finalSize, toMB(MAX_SMART_WEARABLE_FILE_SIZE)) + dispatch( + createItemActions.setError( + t('create_single_item_modal.error.item_too_big', { + size: `${toMB(MAX_SMART_WEARABLE_FILE_SIZE)}MB`, + type: `smart wearable` + }) + ) + ) + return false } - if (itemStatus && [SyncStatus.UNPUBLISHED, SyncStatus.UNDER_REVIEW].includes(itemStatus) && isSmart(item) && VIDEO_PATH in contents) { - item.video = contents[VIDEO_PATH] + if (isRequirementMet && !isSkin && !isSmartWearable && finalSize && !isWearableFileSizeValid(finalSize)) { + dispatch( + createItemActions.setError( + t('create_single_item_modal.error.item_too_big', { + size: `${toMB(MAX_WEARABLE_FILE_SIZE)}MB`, + type: `wearable` + }) + ) + ) + return false } + return isRequirementMet + }, [collection, state, isThirdPartyV2Enabled]) - onSave(item as Item, sortedContents.all) - } + const isDisabled = useCallback((): boolean => { + const { isLoading: isStateLoading } = state + return !isValid() || isLoading || Boolean(isStateLoading) + }, [state, isLoading, isValid]) - handleSubmit = async () => { - const { metadata } = this.props - const { id } = this.state + // TODO: Refactor this logic to accept all the required parameters instead of using the reduer state as we need to wait for each render before properly call the submit + const handleSubmit = useCallback(async () => { + const { id } = state let changeItemFile = false let addRepresentation = false @@ -483,9 +562,25 @@ export default class CreateSingleItemModal extends React.PureComponent { - const { type, previewController, model, contents, category, thumbnail } = this.state - if (type && model && contents) { - const data = await getItemData({ - wearablePreviewController: previewController, - type, - model, - contents, - category - }) - this.setState({ metrics: data.info, thumbnail: thumbnail ?? data.image, isLoading: false }, () => { - if (isSmart({ type, contents })) { - this.timer = setTimeout(() => this.setState({ view: CreateItemView.UPLOAD_VIDEO }), ITEM_LOADED_CHECK_DELAY) - return - } - - this.setState({ view: CreateItemView.DETAILS }) - }) - } - } - - handleDropAccepted = (acceptedFileProps: AcceptedFileProps) => { - const { bodyShape, ...acceptedProps } = acceptedFileProps - this.setState(prevState => ({ - isLoading: true, - bodyShape: bodyShape || prevState.bodyShape, - ...acceptedProps - })) - } - - handleVideoDropAccepted = (acceptedFileProps: AcceptedFileProps) => { - this.setState({ - isLoading: true, - ...acceptedFileProps - }) - } - - handleSaveVideo = () => { - this.setState({ - fromView: undefined, - isLoading: false, - view: CreateItemView.DETAILS - }) - } - - getLinkedContract(collection: Collection | undefined | null): LinkedContract | undefined { - if (!collection?.linkedContractAddress || !collection?.linkedContractNetwork) { - return undefined - } - - return { - address: collection.linkedContractAddress, - network: collection.linkedContractNetwork - } - } - - getMapping = (): Mapping => { - const { isThirdPartyV2Enabled, collection } = this.props - const { mappings } = this.state - const contract = this.getLinkedContract(collection) - if (!contract) { - return defaultMapping - } - - // eslint-disable-next-line @typescript-eslint/no-redundant-type-constituents - let mapping: Mapping | undefined - if (mappings) { - mapping = mappings[contract.network]?.[contract.address][0] - } else { - mapping = this.getDefaultMappings(contract, isThirdPartyV2Enabled)?.[contract.network]?.[contract.address][0] - } - - return mapping ?? defaultMapping - } + }, [metadata, state, addItemRepresentation, createItem, modifyItem, isValid]) - handleMappingChange = (mapping: Mapping) => { - const { collection } = this.props - const contract = this.getLinkedContract(collection) - if (!contract) { - return - } + const handleDropAccepted = useCallback( + (acceptedFileProps: AcceptedFileProps) => { + const { bodyShape, ...acceptedProps } = acceptedFileProps + dispatch(createItemActions.setLoading(true)) + dispatch(createItemActions.setBodyShape(bodyShape || state.bodyShape)) + dispatch(createItemActions.setAcceptedProps(acceptedProps)) + }, + [state] + ) - this.setState({ - mappings: buildItemMappings(mapping, contract) - }) - } + const handleOnScreenshotTaken = useCallback( + async (screenshot: string) => { + const { itemSortedContents, item } = state - handleOpenDocs = () => window.open('https://docs.decentraland.org/3d-modeling/3d-models/', '_blank') + if (item && itemSortedContents) { + const blob = dataURLToBlob(screenshot) - handleNameChange = (_event: React.ChangeEvent, props: InputOnChangeData) => - this.setState({ name: props.value.slice(0, ITEM_NAME_MAX_LENGTH) }) + itemSortedContents[THUMBNAIL_PATH] = blob! + item.contents = await computeHashes(itemSortedContents) - handleItemChange = (item: Item) => { - this.setState({ item: item, category: item.data.category, rarity: item.rarity }) - } - - handleCategoryChange = (_event: React.SyntheticEvent, { value }: DropdownProps) => { - const category = value as WearableCategory - const hasChangedThumbnailType = - (this.state.category && getThumbnailType(category) !== getThumbnailType(this.state.category as WearableCategory)) || - !this.state.category - - if (this.state.category !== category) { - this.setState({ category }) - if (this.state.type === ItemType.WEARABLE && hasChangedThumbnailType) { - // As it's not required to wait for the promise, use the void operator to return undefined - void this.updateThumbnailByCategory(category) - } - } - } - - handleRarityChange = (_event: React.SyntheticEvent, { value }: DropdownProps) => { - const rarity = value as Rarity - this.setState({ rarity }) - } - - handlePlayModeChange = (_event: React.SyntheticEvent, { value }: DropdownProps) => { - const playMode = value as EmotePlayMode - this.setState({ playMode }) - } - - handleOpenThumbnailDialog = () => { - const { type } = this.state - if (type === ItemType.EMOTE) { - this.setState({ fromView: CreateItemView.DETAILS, view: CreateItemView.THUMBNAIL }) - } else if (this.thumbnailInput.current) { - this.thumbnailInput.current.click() - } - } - - handleThumbnailChange = async (event: React.ChangeEvent) => { - const { contents } = this.state - const { files } = event.target - if (files && files.length > 0) { - const file = files[0] - const imageType = await getImageType(file) - if (imageType !== ImageType.PNG) { - this.setState({ error: t('create_single_item_modal.wrong_thumbnail_format') }) - return + // Update state with individual actions for clarity + dispatch(createItemActions.setItemSortedContents(itemSortedContents)) + dispatch(createItemActions.setItem(item as Item)) + } else { + // Update state with individual actions + dispatch(createItemActions.setThumbnail(screenshot)) } - this.setState({ error: undefined }) - - const smallThumbnailBlob = await resizeImage(file) - const bigThumbnailBlob = await resizeImage(file, 1024, 1024) - - const thumbnail = URL.createObjectURL(smallThumbnailBlob) + dispatch(createItemActions.setHasScreenshotTaken(true)) + }, + [state] + ) + + const getMetricsAndScreenshot = useCallback( + async (controller?: IPreviewController) => { + const { type, model, contents, category, thumbnail } = state + if (type && model && contents && controller) { + const data = await getItemData({ + wearablePreviewController: controller, + type, + model, + contents, + category + }) - this.setState({ - thumbnail, - contents: { - ...contents, - [THUMBNAIL_PATH]: bigThumbnailBlob + let view = CreateItemView.DETAILS + if (isSmart({ type, contents })) { + // TODO: await setTimeout(() => {}, ITEM_LOADED_CHECK_DELAY) + view = CreateItemView.UPLOAD_VIDEO } - }) - } - } - - handleOpenVideoDialog = () => { - this.setState({ view: CreateItemView.UPLOAD_VIDEO, fromView: CreateItemView.DETAILS }) - } - handleYes = () => this.setState({ isRepresentation: true }) + dispatch(createItemActions.setMetrics(data.metrics)) + dispatch(createItemActions.setThumbnail(thumbnail ?? data.image)) + dispatch(createItemActions.setView(view)) + if (areEmoteMetrics(data.metrics) && data.metrics.additionalArmatures) { + // required? + dispatch(createItemActions.setEmoteData({ animations: data.animations ?? [], armatures: data.armatures! })) - handleNo = () => this.setState({ isRepresentation: false }) + // Extract animation names from AnimationClip objects + const animationNames = (data.animations ?? []).map(clip => clip.name) - isAddingRepresentation = () => { - const { metadata } = this.props - return !!(metadata && metadata.item && !metadata.changeItemFile) - } - - filterItemsByBodyShape = (item: Item) => { - const { bodyShape } = this.state - const { metadata } = this.props - return getMissingBodyShapeType(item) === bodyShape && metadata.collectionId === item.collectionId - } + // Autocomplete emote data based on animation naming conventions + const autocompletedData = autocompleteSocialEmoteData(animationNames) - /** - * Updates the item's thumbnail if the user changes the category of the item. - * - * @param category - The category of the wearable. - */ - async updateThumbnailByCategory(category: WearableCategory) { - const { model, contents } = this.state - - const isCustom = !!contents && THUMBNAIL_PATH in contents - if (!isCustom) { - this.setState({ isLoading: true }) - let thumbnail - if (contents && isImageFile(model!)) { - thumbnail = await convertImageIntoWearableThumbnail(contents[THUMBNAIL_PATH] || contents[model!], category) - } else { - const url = URL.createObjectURL(contents![model!]) - const { image } = await getModelData(url, { - width: 1024, - height: 1024, - thumbnailType: getThumbnailType(category), - extension: (model && getExtension(model)) || undefined, - engine: EngineType.BABYLON - }) - thumbnail = image - URL.revokeObjectURL(url) + if (autocompletedData.startAnimation) { + dispatch(createItemActions.setStartAnimation(autocompletedData.startAnimation)) + } + if (autocompletedData.outcomes) { + dispatch(createItemActions.setOutcomes(autocompletedData.outcomes)) + } + } + dispatch(createItemActions.setLoading(false)) } - this.setState({ thumbnail, isLoading: false }) - } - } - - buildRepresentations(bodyShape: BodyShapeType, model: string, contents: SortedContent): WearableRepresentation[] { - const representations: WearableRepresentation[] = [] - - // add male representation - if (bodyShape === BodyShapeType.MALE || bodyShape === BodyShapeType.BOTH) { - representations.push({ - bodyShapes: [BodyShape.MALE], - mainFile: this.prefixContentName(BodyShapeType.MALE, model), - contents: Object.keys(contents.male), - overrideHides: [], - overrideReplaces: [] - }) - } - - // add female representation - if (bodyShape === BodyShapeType.FEMALE || bodyShape === BodyShapeType.BOTH) { - representations.push({ - bodyShapes: [BodyShape.FEMALE], - mainFile: this.prefixContentName(BodyShapeType.FEMALE, model), - contents: Object.keys(contents.female), - overrideHides: [], - overrideReplaces: [] - }) - } + }, + [state] + ) - return representations - } - - buildRepresentationsZipBothBodyshape(bodyShape: BodyShapeType, contents: SortedContent): WearableRepresentation[] { - const representations: WearableRepresentation[] = [] - - // add male representation - if (bodyShape === BodyShapeType.MALE || bodyShape === BodyShapeType.BOTH) { - representations.push({ - bodyShapes: [BodyShape.MALE], - mainFile: Object.keys(contents.male).find(content => content.includes('glb'))!, - contents: Object.keys(contents.male), - overrideHides: [], - overrideReplaces: [] - }) - } - - // add female representation - if (bodyShape === BodyShapeType.FEMALE || bodyShape === BodyShapeType.BOTH) { - representations.push({ - bodyShapes: [BodyShape.FEMALE], - mainFile: Object.keys(contents.female).find(content => content.includes('glb'))!, - contents: Object.keys(contents.female), - overrideHides: [], - overrideReplaces: [] - }) - } - - return representations - } - - renderModalTitle = () => { - const isAddingRepresentation = this.isAddingRepresentation() - const { bodyShape, type, view, contents } = this.state - const { metadata } = this.props - - if (isAddingRepresentation) { - return t('create_single_item_modal.add_representation', { bodyShape: t(`body_shapes.${bodyShape!}`) }) - } - - if (metadata && metadata.changeItemFile) { - return t('create_single_item_modal.change_item_file') - } - - if (type === ItemType.EMOTE) { - return view === CreateItemView.THUMBNAIL - ? t('create_single_item_modal.thumbnail_step_title') - : t('create_single_item_modal.title_emote') - } - - if (isSmart({ type, contents }) && view === CreateItemView.DETAILS) { - return t('create_single_item_modal.smart_wearable_details_title') - } - - switch (view) { - case CreateItemView.THUMBNAIL: - return t('create_single_item_modal.thumbnail_step_title') - case CreateItemView.UPLOAD_VIDEO: - return t('create_single_item_modal.upload_video_step_title') - default: - return t('create_single_item_modal.title') - } - } - - handleFileLoad = async () => { - const { weareblePreviewUpdated, type, model, item, contents } = this.state + const handleFileLoad = useCallback(async () => { + const { weareblePreviewUpdated, type, model, item, contents } = state const modelSize = await calculateModelFinalSize( item?.contents ?? {}, contents ?? {}, type ?? ItemType.WEARABLE, - new BuilderAPI(BUILDER_SERVER_URL, new Authorization(() => this.props.address)) + new BuilderAPI(BUILDER_SERVER_URL, new Authorization(() => props.address)) ) - this.setState({ modelSize }) + dispatch(createItemActions.setModelSize(modelSize)) // if model is an image, the wearable preview won't be needed if (model && isImageFile(model)) { - return this.getMetricsAndScreenshot() + return getMetricsAndScreenshot() } const controller = WearablePreview.createController('thumbnail-picker') - this.setState({ previewController: controller }) if (weareblePreviewUpdated) { if (type === ItemType.EMOTE) { const length = await controller.emote.getLength() await controller.emote.goTo(Math.floor(Math.random() * length)) } - return this.getMetricsAndScreenshot() + return getMetricsAndScreenshot(controller) } - } + }, [state, getMetricsAndScreenshot]) - renderWearablePreview = () => { - const { type, contents } = this.state + const renderWearablePreview = useCallback(() => { + const { type, contents } = state const isEmote = type === ItemType.EMOTE const blob = contents ? (isEmote ? toEmoteWithBlobs({ contents }) : toWearableWithBlobs({ contents })) : undefined @@ -878,583 +773,66 @@ export default class CreateSingleItemModal extends React.PureComponent this.setState({ weareblePreviewUpdated: true })} - onLoad={this.handleFileLoad} + onUpdate={() => dispatch(createItemActions.setWearablePreviewUpdated(true))} + onLoad={handleFileLoad} /> ) - } - - handleUploadVideoGoBack = () => { - const keys = Object.keys(this.state) - const { fromView } = this.state - - if (fromView) { - this.setState({ view: fromView }) - return - } - - const stateReset = keys.reduce((acc, v) => ({ ...acc, [v]: undefined }), {}) - this.setState({ ...stateReset, ...this.getInitialState() }) - } - - renderImportView() { - const { collection, metadata, onClose } = this.props - const { category, isLoading, isRepresentation } = this.state - const title = this.renderModalTitle() - - return ( - {this.renderWearablePreview()}
} - isLoading={!!isLoading} - isRepresentation={!!isRepresentation} - onDropAccepted={this.handleDropAccepted} - onClose={onClose} - /> - ) - } - - renderUploadVideoView() { - const { itemStatus, onClose } = this.props - const { contents } = this.state - const title = this.renderModalTitle() - - return ( - - ) - } - - renderFields() { - const { collection, isThirdPartyV2Enabled } = this.props - const { name, category, rarity, contents, item, type, isLoading } = this.state - - const belongsToAThirdPartyCollection = collection?.urn && isThirdParty(collection.urn) - const rarities = Rarity.getRarities() - const categories: string[] = type === ItemType.WEARABLE ? getWearableCategories(contents) : getEmoteCategories() - const linkedContract = this.getLinkedContract(collection) - - const raritiesLink = - 'https://docs.decentraland.org/creator/wearables-and-emotes/manage-collections' + - (type === ItemType.EMOTE - ? '/uploading-emotes/#rarity' - : isSmart({ type, contents }) - ? '/uploading-smart-wearables/#rarity' - : '/uploading-wearables/#rarity') - - return ( - <> - - {(!item || !item.isPublished) && !belongsToAThirdPartyCollection ? ( - <> - - {t('create_single_item_modal.rarity_label')} - - {t('global.learn_more')} - - - } - placeholder={t('create_single_item_modal.rarity_placeholder')} - value={rarity} - options={rarities.map(value => ({ - value, - label: t('wearable.supply', { - count: Rarity.getMaxSupply(value), - formatted: Rarity.getMaxSupply(value).toLocaleString() - }), - text: t(`wearable.rarity.${value}`) - }))} - disabled={isLoading} - onChange={this.handleRarityChange} - /> - - ) : null} - ({ value, text: t(`${type!}.category.${value}`) }))} - onChange={this.handleCategoryChange} - /> - {isThirdPartyV2Enabled && linkedContract && } - - ) - } - - getPlayModeOptions() { - const playModes: string[] = getEmotePlayModes() - - return playModes.map(value => ({ - value, - text: t(`emote.play_mode.${value}.text`), - description: t(`emote.play_mode.${value}.description`) - })) - } - - renderMetrics() { - const { metrics, contents } = this.state - if (metrics) { - return - } else { - return null - } - } - - isDisabled(): boolean { - const { isLoading } = this.props - const { isLoading: isStateLoading } = this.state - - return !this.isValid() || isLoading || Boolean(isStateLoading) - } - - isValid(): boolean { - const { name, thumbnail, metrics, bodyShape, category, playMode, rarity, item, isRepresentation, type, modelSize, mappings } = - this.state - const { collection, isThirdPartyV2Enabled } = this.props - const belongsToAThirdPartyCollection = collection?.urn && isThirdParty(collection.urn) - const linkedContract = collection ? this.getLinkedContract(collection) : undefined - - if (this.state.error) { - this.setState({ error: undefined }) - } - - let required: (string | Metrics | Item | undefined)[] - if (isRepresentation) { - required = [item as Item] - } else if (belongsToAThirdPartyCollection) { - required = [name, thumbnail, metrics, bodyShape, category] - } else if (type === ItemType.EMOTE) { - required = [name, thumbnail, metrics, category, playMode, rarity, type] - } else { - required = [name, thumbnail, metrics, bodyShape, category, rarity, type] - } - - const thumbnailBlob = thumbnail ? dataURLToBlob(thumbnail) : undefined - const thumbnailSize = thumbnailBlob ? calculateFileSize(thumbnailBlob) : 0 - - if (thumbnailSize && thumbnailSize > MAX_THUMBNAIL_SIZE) { - this.setState({ - error: t('create_single_item_modal.error.thumbnail_file_too_big', { maxSize: `${toMB(MAX_THUMBNAIL_FILE_SIZE)}MB` }) - }) - return false - } - const isSkin = category === WearableCategory.SKIN - const isEmote = type === ItemType.EMOTE - const isSmartWearable = isSmart({ type, contents: this.state.contents }) - const isRequirementMet = required.every(prop => prop !== undefined) - const finalSize = modelSize ? modelSize + thumbnailSize : undefined - - if (isThirdPartyV2Enabled && ((!mappings && linkedContract) || (mappings && !areMappingsValid(mappings)))) { - return false - } - - if (isRequirementMet && isEmote && finalSize && !isEmoteFileSizeValid(finalSize)) { - this.setState({ - error: t('create_single_item_modal.error.item_too_big', { - size: `${toMB(MAX_EMOTE_FILE_SIZE)}MB`, - type: `emote` - }) - }) - return false - } - - if (isRequirementMet && isSkin && finalSize && !isSkinFileSizeValid(finalSize)) { - this.setState({ - error: t('create_single_item_modal.error.item_too_big', { - size: `${toMB(MAX_SKIN_FILE_SIZE)}MB`, - type: `skin` - }) - }) - return false - } - - if (isRequirementMet && !isSkin && isSmartWearable && finalSize && !isSmartWearableFileSizeValid(finalSize)) { - this.setState({ - error: t('create_single_item_modal.error.item_too_big', { - size: `${toMB(MAX_SMART_WEARABLE_FILE_SIZE)}MB`, - type: `smart wearable` - }) - }) - return false - } - - if (isRequirementMet && !isSkin && !isSmartWearable && finalSize && !isWearableFileSizeValid(finalSize)) { - this.setState({ - error: t('create_single_item_modal.error.item_too_big', { - size: `${toMB(MAX_WEARABLE_FILE_SIZE)}MB`, - type: `wearable` - }) - }) - return false - } - return isRequirementMet - } - - renderWearableDetails() { - const { metadata } = this.props - const { bodyShape, thumbnail, isRepresentation, rarity, item } = this.state - const title = this.renderModalTitle() - const thumbnailStyle = getBackgroundStyle(rarity) - const isAddingRepresentation = this.isAddingRepresentation() - - return ( - <> -
-
- {title} - {isRepresentation ? null : ( - <> - - - - )} -
- {this.renderMetrics()} -
- - {isAddingRepresentation ? null : ( -
-
{t('create_single_item_modal.representation_label')}
- - {this.renderRepresentation(BodyShapeType.BOTH)} - {this.renderRepresentation(BodyShapeType.MALE)} - {this.renderRepresentation(BodyShapeType.FEMALE)} - -
- )} - {bodyShape && (!metadata || !metadata.changeItemFile) ? ( - <> - {bodyShape === BodyShapeType.BOTH ? ( - this.renderFields() - ) : ( - <> - {isAddingRepresentation ? null : ( -
-
{t('create_single_item_modal.existing_item')}
- -
- {t('global.yes')} -
-
- {t('global.no')} -
-
-
- )} - {isRepresentation === undefined ? null : isRepresentation ? ( -
-
- {isAddingRepresentation - ? t('create_single_item_modal.adding_representation', { bodyShape: t(`body_shapes.${bodyShape}`) }) - : t('create_single_item_modal.pick_item', { bodyShape: t(`body_shapes.${bodyShape}`) })} -
- } - filter={this.filterItemsByBodyShape} - onChange={this.handleItemChange} - isDisabled={isAddingRepresentation} - /> -
- ) : ( - this.renderFields() - )} - - )} - - ) : ( - this.renderFields() - )} -
- - ) - } - - renderEmoteDetails() { - const { thumbnail, rarity, playMode = '' } = this.state - const title = this.renderModalTitle() - const thumbnailStyle = getBackgroundStyle(rarity) - - return ( - - - -
- {title} - - -
- {this.renderMetrics()} -
- - {this.renderFields()} - - -
-
- } /> -
-
- ) - } - - renderSmartWearableDetails() { - const { thumbnail, rarity, requiredPermissions, video } = this.state - const title = this.renderModalTitle() - const thumbnailStyle = getBackgroundStyle(rarity) - - return ( -
- {this.renderFields()} - {requiredPermissions?.length ? ( -
-
- {t('create_single_item_modal.smart_wearable_permissions_label')} - - {t('global.learn_more')} - -
- -
- ) : null} - -
-
{t('create_single_item_modal.thumbnail_preview_title')}
-
-
- {title} - - -
-
{this.renderMetrics()}
-
-
- -
-
{t('create_single_item_modal.video_preview_title')}
-
- } - onClick={this.handleOpenVideoDialog} - /> -
-
-
-
- } /> -
-
- ) - } - - renderItemDetails() { - const { type, contents } = this.state - - if (type === ItemType.EMOTE) { - return this.renderEmoteDetails() - } else if (isSmart({ type, contents })) { - return this.renderSmartWearableDetails() - } else { - return this.renderWearableDetails() - } - } - - handleGoBack = () => { - this.setState({ view: CreateItemView.UPLOAD_VIDEO }) - } - - renderDetailsView() { - const { onClose, metadata, error, isLoading, collection } = this.props - const { isRepresentation, error: stateError, type, contents, isLoading: isStateLoading, hasScreenshotTaken } = this.state - const belongsToAThirdPartyCollection = collection?.urn && isThirdParty(collection.urn) - const isDisabled = this.isDisabled() - const title = this.renderModalTitle() - const hasFinishSteps = (type === ItemType.EMOTE && hasScreenshotTaken) || type === ItemType.WEARABLE - - return ( - <> - - -
- - {this.renderItemDetails()} - - {isSmart({ type, contents }) ? ( - - - - ) : null} - - - - - {stateError ? ( - -

{stateError}

-
- ) : null} - {error ? ( - -

{error}

-
- ) : null} -
-
-
- - ) - } - - handleOnScreenshotTaken = async (screenshot: string) => { - const { fromView, itemSortedContents, item } = this.state - - if (item && itemSortedContents) { - const blob = dataURLToBlob(screenshot) - - itemSortedContents[THUMBNAIL_PATH] = blob! - item.contents = await computeHashes(itemSortedContents) - this.setState({ itemSortedContents, item, hasScreenshotTaken: true }, () => { - if (fromView === CreateItemView.DETAILS) { - this.setState({ view: CreateItemView.DETAILS }) - } else { - void this.handleSubmit() - } - }) - } else { - this.setState({ thumbnail: screenshot, hasScreenshotTaken: true }, () => { - if (fromView === CreateItemView.DETAILS) { - this.setState({ view: CreateItemView.DETAILS }) - } - }) - } - } - - renderThumbnailView() { - const { onClose } = this.props - const { isLoading, contents } = this.state - - return ( - this.setState({ view: CreateItemView.DETAILS })} - onSave={this.handleOnScreenshotTaken} - onClose={onClose} - /> - ) - } - - renderRepresentation(type: BodyShapeType) { - const { bodyShape } = this.state - const { metadata } = this.props - return ( -
- this.setState({ bodyShape: type, isRepresentation: metadata && metadata.changeItemFile ? false : undefined, item: undefined }) - } - > - {t('body_shapes.' + type)} -
- ) - } - - renderSetPrice() { - const { onClose } = this.props - const { item, itemSortedContents } = this.state - return ( - - ) - } - - renderView() { - switch (this.state.view) { - case CreateItemView.IMPORT: - return this.renderImportView() - case CreateItemView.UPLOAD_VIDEO: - return this.renderUploadVideoView() - case CreateItemView.DETAILS: - return this.renderDetailsView() - case CreateItemView.THUMBNAIL: - return this.renderThumbnailView() - case CreateItemView.SET_PRICE: - return this.renderSetPrice() - default: - return null - } - } - - componentWillUnmount() { - if (this.timer) { - clearTimeout(this.timer) - } - } - - render() { - const { name, onClose } = this.props - return ( -
- - {this.renderView()} - -
- ) - } + }, [state, handleFileLoad]) + + const contextValue = { + // State + state, + dispatch, + metadata, + collection, + isLoading, + error, + itemStatus, + + // Thumbnail handlers + handleOpenThumbnailDialog, + handleThumbnailChange, + thumbnailInput, + + // Wearable-specific handlers + filterItemsByBodyShape, + handleItemChange, + + // File handling + handleDropAccepted, + handleOnScreenshotTaken, + + // Modal handlers + onClose, + handleSubmit, + isDisabled, + + // Render functions + renderMetrics, + renderModalTitle, + renderWearablePreview, + + // Flags + isThirdPartyV2Enabled, + isAddingRepresentation + } + + return ( +
+ + + + + +
+ ) } + +export default React.memo(CreateSingleItemModal) diff --git a/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.types.ts b/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.types.ts index afa561206..985d55e02 100644 --- a/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.types.ts +++ b/src/components/Modals/CreateSingleItemModal/CreateSingleItemModal.types.ts @@ -1,6 +1,7 @@ +import { AnimationClip, Object3D } from 'three' import { Dispatch } from 'redux' import { ModalProps } from 'decentraland-dapps/dist/providers/ModalProvider/ModalProvider.types' -import { IPreviewController, Mappings, Rarity } from '@dcl/schemas' +import { IPreviewController, Mappings, OutcomeGroup, Rarity, StartAnimation } from '@dcl/schemas' import { Metrics } from 'modules/models/types' import { Collection } from 'modules/collection/types' import { saveItemRequest, SaveItemRequestAction } from 'modules/item/actions' @@ -18,13 +19,12 @@ export const ITEM_LOADED_CHECK_DELAY = 2000 export type Props = ModalProps & { address?: string - metadata: CreateSingleItemModalMetadata + collection: Collection | null error: string | null + itemStatus: SyncStatus | null + metadata: CreateSingleItemModalMetadata isThirdPartyV2Enabled: boolean - isOffchainPublicItemOrdersEnabled: boolean isLoading: boolean - collection: Collection | null - itemStatus: SyncStatus | null onSave: typeof saveItemRequest } @@ -56,7 +56,14 @@ export type StateData = { modelSize?: number mappings: Mappings blockVrmExport?: boolean + startAnimation?: StartAnimation + outcomes?: OutcomeGroup[] + emoteData?: { + animations: AnimationClip[] + armatures: Object3D[] + } } + export type State = { view: CreateItemView fromView?: CreateItemView @@ -105,9 +112,6 @@ export type AcceptedFileProps = Pick< | 'blockVrmExport' > export type OwnProps = Pick & { metadata: CreateSingleItemModalMetadata } -export type MapStateProps = Pick< - Props, - 'address' | 'error' | 'isLoading' | 'collection' | 'itemStatus' | 'isThirdPartyV2Enabled' | 'isOffchainPublicItemOrdersEnabled' -> +export type MapStateProps = Pick export type MapDispatchProps = Pick export type MapDispatch = Dispatch diff --git a/src/components/Modals/CreateSingleItemModal/CreateSingleItemModalProvider.tsx b/src/components/Modals/CreateSingleItemModal/CreateSingleItemModalProvider.tsx new file mode 100644 index 000000000..c55fa2702 --- /dev/null +++ b/src/components/Modals/CreateSingleItemModal/CreateSingleItemModalProvider.tsx @@ -0,0 +1,11 @@ +import { type ReactNode } from 'react' +import { CreateSingleItemModalContext, type CreateSingleItemModalContextValue } from './CreateSingleItemModal.context' + +interface CreateSingleItemModalProviderProps { + children: ReactNode + value: CreateSingleItemModalContextValue +} + +export const CreateSingleItemModalProvider = ({ children, value }: CreateSingleItemModalProviderProps) => { + return {children} +} diff --git a/src/components/Modals/CreateSingleItemModal/EditThumbnailStep/EditThumbnailStep.css b/src/components/Modals/CreateSingleItemModal/EditThumbnailStep/EditThumbnailStep.css index b9516cebd..4a7a10f09 100644 --- a/src/components/Modals/CreateSingleItemModal/EditThumbnailStep/EditThumbnailStep.css +++ b/src/components/Modals/CreateSingleItemModal/EditThumbnailStep/EditThumbnailStep.css @@ -1,192 +1,57 @@ -.EditThumbnailStep .zoom-controls { - display: flex; - flex-direction: column; - position: absolute; - top: 12px; - left: 12px; -} - -.EditThumbnailStep .ui.button.zoom-control { - width: 32px; - height: 32px; - padding: 0; - min-width: unset; - background-color: rgba(0, 0, 0, 0.64) !important; - border-radius: 0; - display: flex; - justify-content: center; - align-items: center; -} - -.EditThumbnailStep .ui.button.zoom-control i.icon { - margin: 0 !important; -} - -.ui.modal .EditThumbnailStep .ui.button.sound-control { - background: transparent; -} - -.EditThumbnailStep .ui.button.zoom-in-control { - width: 32px; - height: 32px; - padding: 0; - min-width: unset; -} - -.EditThumbnailStep .ui.button.play-control { - width: 56px; - height: 44px; - padding: 0; - min-width: unset; - margin-right: 16px; -} - -.EditThumbnailStep .EmoteControls .ui.button.play-control .play.icon { - padding-bottom: 18px; - padding-left: 3px; - margin: 0; -} - -.EditThumbnailStep .EmoteControls .ui.button.play-control .pause.icon { - padding-left: 0; - padding-bottom: 16px; - margin: 0; -} - -.EditThumbnailStep .Preview canvas { - background: rgba(22, 20, 26, 0.48); -} - -.EditThumbnailStep .dcl.sliderfield p { - display: none; -} - -.EditThumbnailStep .play-controls { - display: flex; - margin: 16px 0; -} - -.EditThumbnailStep .rotate-control { - position: absolute; - bottom: 40px; - left: 7px; -} - -.EditThumbnailStep .dcl.sliderfield-wrapper .dcl.sliderfield-track, -.EditThumbnailStep .dcl.sliderfield-wrapper .dcl.sliderfield-mark { - background-color: #736e7d; -} - -.EditThumbnailStep input[type='range'] { - background: none; -} - -.EditThumbnailStep .play-controls input[type='range'] { - width: 100%; -} - -.EditThumbnailStep input[type='range'] { - -webkit-appearance: none; -} - -.EditThumbnailStep input[type='range']:focus { - outline: none; -} - -.EditThumbnailStep .play-controls input[type='range']::-webkit-slider-thumb { - -webkit-appearance: none; - width: 24px; - height: 24px; - border-radius: 10px; - background-color: #736e7d; - overflow: visible; - cursor: pointer; - padding-bottom: 5px; - margin-top: -4px; -} - -.EditThumbnailStep input[type='range']::-webkit-slider-thumb { - -webkit-appearance: none; - width: 32px; - height: 8px; - border-radius: 10px; - background: #736e7d; - box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.16), inset 0px 1px 0px rgba(255, 255, 255, 0.16); - border-radius: 4px; - overflow: visible; - cursor: pointer; - padding-bottom: 5px; - margin-top: -3px; -} - -.EditThumbnailStep input[type='range']::-moz-range-thumb { - width: 24px; - height: 24px; - border-radius: 20px; - background-color: #736e7d; - overflow: visible; - cursor: pointer; -} - -.EditThumbnailStep input[type='range']::-ms-thumb { - width: 24px; - height: 24px; - border-radius: 20px; - background-color: #736e7d; - overflow: visible; - cursor: pointer; -} - -.EditThumbnailStep .play-controls input[type='range']::-webkit-slider-runnable-track { - width: 300px; - height: 15px; - background: rgba(115, 110, 125, 0.32); - border: none; - border-radius: 10px; -} - -.EditThumbnailStep input[type='range']::-webkit-slider-runnable-track { - width: 300px; - height: 2px; - background: #000000; - border: none; - border-radius: 10px; -} - -.EditThumbnailStep input[type='range']::-moz-range-track { - width: 100%; - height: 15px; - border-radius: 10px; - cursor: pointer; - background: rgba(115, 110, 125, 0.32); -} - -.EditThumbnailStep .WearablePreview { +.EditThumbnailStep .WearablePreview, +.EditThumbnailStep #preview { background: #43404a; height: 364px; border-radius: 8px; } -.EditThumbnailStep .y-slider-container { - position: absolute; - transform: rotate(-90deg); - right: -105px; - top: 181px; - width: 280px; - display: flex; - flex-direction: row-reverse; -} - -.EditThumbnailStep .y-slider { - width: 229px; +.EditThumbnailStep .Preview canvas { + background: rgba(22, 20, 26, 0.48); } -.EditThumbnailStep .y-slider-container i.icon { - margin-top: -6px; - margin-left: 12px; +.EditThumbnailStep .zoom-controls { + & .MuiButtonBase-root.MuiButton-root { + & .MuiSvgIcon-root { + fill: white; + } + & .MuiTouchRipple-root { + color: white; + } + } } -.EditThumbnailStep .EmoteControls { +.EditThumbnailStep .emote-controls { padding: 0; bottom: 0; + & .MuiButtonBase-root.MuiButton-root { + background-color: transparent; + margin: 0; + & .MuiSvgIcon-root { + fill: white; + } + & .MuiTouchRipple-root { + color: white; + } + } +} + +.EditThumbnailStep .translation-controls { + align-items: flex-start; +} + +.EditThumbnailStep .animation-controls { + & .MuiButtonBase-root { + background-color: #726e7c !important; + } +} + +.EditThumbnailStep .animation-controls__popper { + & .MuiPaper-root { + background-color: #726e7c; + & .MuiList-root { + & .MuiButtonBase-root.MuiMenuItem-root.Mui-selected { + background-color: #24212933; + } + } + } } diff --git a/src/components/Modals/CreateSingleItemModal/EditThumbnailStep/EditThumbnailStep.tsx b/src/components/Modals/CreateSingleItemModal/EditThumbnailStep/EditThumbnailStep.tsx index 19ecdfcea..76777c61d 100644 --- a/src/components/Modals/CreateSingleItemModal/EditThumbnailStep/EditThumbnailStep.tsx +++ b/src/components/Modals/CreateSingleItemModal/EditThumbnailStep/EditThumbnailStep.tsx @@ -1,27 +1,18 @@ import * as React from 'react' -import { ModalNavigation, Row, Button, Icon, Loader, EmoteControls, WearablePreview } from 'decentraland-ui' +import { SocialEmoteAnimation } from '@dcl/schemas/dist/dapps/preview/social-emote-animation' +import { ModalNavigation, Row, Button, Loader } from 'decentraland-ui' +import { AnimationControls, EmoteControls, TranslationControls, WearablePreview, ZoomControls } from 'decentraland-ui2' import { t } from 'decentraland-dapps/dist/modules/translation/utils' import Modal from 'decentraland-dapps/dist/containers/Modal' -import { ControlOptionAction, Props, State } from './EditThumbnailStep.types' +import { Props, State } from './EditThumbnailStep.types' import './EditThumbnailStep.css' -const DEFAULT_ZOOM = 2 -const ZOOM_DELTA = 0.1 - export default class EditThumbnailStep extends React.PureComponent { - previewRef = React.createRef() state: State = { - zoom: DEFAULT_ZOOM, blob: this.props.blob, previewController: this.props.wearablePreviewController, - hasBeenUpdated: false - } - - componentWillUnmount() { - const { playingIntervalId } = this.state - if (playingIntervalId) { - clearInterval(playingIntervalId) - } + hasBeenUpdated: false, + socialEmote: undefined } handleFileLoad = async () => { @@ -41,38 +32,25 @@ export default class EditThumbnailStep extends React.PureComponent await previewController?.scene.getScreenshot(1024, 1024).then(screenshot => onSave(screenshot)) } - handleControlActionChange = async (action: ControlOptionAction, value?: number) => { - const { previewController } = this.state - const iframeContentWindow = this.previewRef.current?.iframe?.contentWindow - if (iframeContentWindow) { - await previewController?.emote.pause() - switch (action) { - case ControlOptionAction.PAN_CAMERA_Y: { - this.setState({ offsetY: value }) - await previewController?.scene.panCamera({ y: value! * -1 }) - break - } - case ControlOptionAction.ZOOM_IN: { - await previewController?.scene.changeZoom(ZOOM_DELTA) - break - } - case ControlOptionAction.ZOOM_OUT: { - await previewController?.scene.changeZoom(-ZOOM_DELTA) - break - } - default: - break - } - } - } - - handleZoomOut = () => { - this.setState(prevState => ({ zoom: (prevState.zoom || DEFAULT_ZOOM) - 1 })) + handleSocialEmoteSelect = (animation: SocialEmoteAnimation) => { + this.setState({ socialEmote: animation }) } render() { const { onClose, onBack, title, isLoading, base64s } = this.props - const { blob, hasBeenUpdated } = this.state + const { blob, hasBeenUpdated, socialEmote, previewController } = this.state + + let emoteData = undefined + if (base64s && base64s.length > 0) { + emoteData = JSON.parse(atob(base64s[0]))?.emoteDataADR74 + } else if (blob?.emoteDataADR74) { + emoteData = blob?.emoteDataADR74 + } + + let _socialEmote = undefined + if (!socialEmote && emoteData?.startAnimation) { + _socialEmote = { title: 'Start Animation', ...emoteData.startAnimation } + } return ( <> @@ -80,7 +58,7 @@ export default class EditThumbnailStep extends React.PureComponent
skin="000000" zoom={100} wheelZoom={2} + socialEmote={socialEmote || _socialEmote} onLoad={this.handleFileLoad} onUpdate={() => this.setState({ hasBeenUpdated: true })} /> - {hasBeenUpdated ? ( + {hasBeenUpdated && previewController ? ( <> -
- - -
-
- - this.handleControlActionChange(ControlOptionAction.PAN_CAMERA_Y, Number(e.target.value))} - > -
+ + + -
- -
+ ) : ( )}
- - diff --git a/src/components/Modals/CreateSingleItemModal/EditThumbnailStep/EditThumbnailStep.types.ts b/src/components/Modals/CreateSingleItemModal/EditThumbnailStep/EditThumbnailStep.types.ts index 1157ba2cc..623a720be 100644 --- a/src/components/Modals/CreateSingleItemModal/EditThumbnailStep/EditThumbnailStep.types.ts +++ b/src/components/Modals/CreateSingleItemModal/EditThumbnailStep/EditThumbnailStep.types.ts @@ -1,19 +1,7 @@ +import React from 'react' import { EmoteWithBlobs, IPreviewController } from '@dcl/schemas' +import { SocialEmoteAnimation } from '@dcl/schemas/dist/dapps/preview/social-emote-animation' import { Item } from 'modules/item/types' -import React from 'react' - -export enum CreateItemView { - IMPORT = 'import', - DETAILS = 'details', - THUMBNAIL = 'thumbnail' -} - -export enum ControlOptionAction { - ZOOM_IN, - ZOOM_OUT, - PAN_CAMERA_Y, - CHANGE_CAMERA_ALPHA -} export type Props = { title: string @@ -29,11 +17,9 @@ export type Props = { export type State = { hasBeenUpdated: boolean - playingIntervalId?: ReturnType previewController?: IPreviewController blob?: EmoteWithBlobs - zoom: number - offsetY?: number + socialEmote?: SocialEmoteAnimation } export type CreateSingleItemModalMetadata = { diff --git a/src/components/Modals/CreateSingleItemModal/EditThumbnailStep/index.ts b/src/components/Modals/CreateSingleItemModal/EditThumbnailStep/index.ts new file mode 100644 index 000000000..4b511ed40 --- /dev/null +++ b/src/components/Modals/CreateSingleItemModal/EditThumbnailStep/index.ts @@ -0,0 +1,3 @@ +import EditThumbnailStep from './EditThumbnailStep' + +export { EditThumbnailStep } diff --git a/src/components/Modals/CreateSingleItemModal/ImportStep/ImportStep.tsx b/src/components/Modals/CreateSingleItemModal/ImportStep/ImportStep.tsx index bb10f54c9..79c1194e5 100644 --- a/src/components/Modals/CreateSingleItemModal/ImportStep/ImportStep.tsx +++ b/src/components/Modals/CreateSingleItemModal/ImportStep/ImportStep.tsx @@ -21,7 +21,7 @@ import Modal from 'decentraland-dapps/dist/containers/Modal' import { t } from 'decentraland-dapps/dist/modules/translation/utils' import { getExtension } from 'lib/file' import { isThirdParty } from 'lib/urn' -import { EngineType, getEmoteMetrics, getIsEmote } from 'lib/getModelData' +import { EngineType, getEmoteData, getIsEmote } from 'lib/getModelData' import { cleanAssetName, rawMappingsToObjectURL } from 'modules/asset/utils' import { FileTooBigError, @@ -145,8 +145,8 @@ export default class ImportStep extends React.PureComponent { const { model, contents: proccessedContent, type } = await this.processModel(modelPath, contents) if (type === ItemType.EMOTE) { - const info: AnimationMetrics = await getEmoteMetrics(contents[model]) - if (info.duration > MAX_EMOTE_DURATION) { + const { metrics }: { metrics: AnimationMetrics } = await getEmoteData(URL.createObjectURL(contents[model])) + if (metrics.duration > MAX_EMOTE_DURATION) { throw new EmoteDurationTooLongError() } } diff --git a/src/components/Modals/CreateSingleItemModal/ItemDetailsStep/EmoteDetails.tsx b/src/components/Modals/CreateSingleItemModal/ItemDetailsStep/EmoteDetails.tsx new file mode 100644 index 000000000..08367007b --- /dev/null +++ b/src/components/Modals/CreateSingleItemModal/ItemDetailsStep/EmoteDetails.tsx @@ -0,0 +1,115 @@ +import React, { useCallback } from 'react' +import { Row, Column, SelectField, Message, DropdownProps, Button } from 'decentraland-ui' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import { EmotePlayMode } from '@dcl/schemas' +import { getBackgroundStyle } from 'modules/item/utils' +import { Item } from 'modules/item/types' +import Icon from 'components/Icon' +import ItemProperties from 'components/ItemProperties' +import { useCreateSingleItemModal } from '../CreateSingleItemModal.context' +import { createItemActions } from '../CreateSingleItemModal.reducer' +import CommonFields from '../CommonFields' +import { CreateItemView } from '../CreateSingleItemModal.types' +import { areEmoteMetrics } from 'modules/models/types' + +/** + * Gets play mode options for emotes + */ +const getPlayModeOptions = () => { + const playModes: string[] = [EmotePlayMode.SIMPLE, EmotePlayMode.LOOP] + + return playModes.map(value => ({ + value, + text: t(`emote.play_mode.${value}.text`), + description: t(`emote.play_mode.${value}.description`) + })) +} + +const PlayModeSelectField: React.FC<{ + value: string + onChange: DropdownProps['onChange'] +}> = ({ value, onChange }) => { + return ( + + ) +} + +export const EmoteDetails: React.FC = () => { + const { + state, + renderModalTitle, + handleOpenThumbnailDialog, + handleThumbnailChange, + thumbnailInput, + dispatch, + isLoading, + handleSubmit, + isDisabled + } = useCreateSingleItemModal() + const { contents, metrics, thumbnail, rarity, playMode = '', hasScreenshotTaken } = state + const title = renderModalTitle() + const thumbnailStyle = getBackgroundStyle(rarity) + + const handlePlayModeChange = useCallback( + (_event: React.SyntheticEvent, data: DropdownProps) => { + const value = data.value as EmotePlayMode + dispatch(createItemActions.setPlayMode(value)) + }, + [dispatch] + ) + + const handleClickSave = useCallback(() => { + if (hasScreenshotTaken) { + handleSubmit() + } else { + dispatch(createItemActions.setFromView(CreateItemView.DETAILS)) + handleOpenThumbnailDialog() + } + }, [hasScreenshotTaken, handleSubmit, handleOpenThumbnailDialog]) + + return ( + <> + + + + +
+ {title} + + +
+ {metrics ? : null} +
+ + + {metrics && areEmoteMetrics(metrics) && !metrics.additionalArmatures ? ( + + ) : null} + +
+
+ } /> +
+
+
+ + + + + + + ) +} + +export default React.memo(EmoteDetails) diff --git a/src/components/Modals/CreateSingleItemModal/ItemDetailsStep/ItemDetailsStep.tsx b/src/components/Modals/CreateSingleItemModal/ItemDetailsStep/ItemDetailsStep.tsx new file mode 100644 index 000000000..b6f9d0ee6 --- /dev/null +++ b/src/components/Modals/CreateSingleItemModal/ItemDetailsStep/ItemDetailsStep.tsx @@ -0,0 +1,47 @@ +import React from 'react' +import { ModalNavigation, Row, Column, Form } from 'decentraland-ui' +import Modal from 'decentraland-dapps/dist/containers/Modal' +import { ItemType } from 'modules/item/types' +import { isSmart } from 'modules/item/utils' +import { useCreateSingleItemModal } from '../CreateSingleItemModal.context' +import { WearableDetails, EmoteDetails, SmartWearableDetails } from './index' + +export const ItemDetailsStep: React.FC = () => { + const { state, error, isDisabled, renderModalTitle, onClose } = useCreateSingleItemModal() + const { type, contents } = state + + const renderDetailsContent = () => { + if (type === ItemType.EMOTE) { + return + } else if (isSmart({ type, contents })) { + return + } else { + return + } + } + + return ( + <> + + +
+ + {renderDetailsContent()} + {state.error ? ( + +

{state.error}

+
+ ) : null} + {error ? ( + +

{error}

+
+ ) : null} +
+
+
+ + ) +} + +export default React.memo(ItemDetailsStep) diff --git a/src/components/Modals/CreateSingleItemModal/ItemDetailsStep/SmartWearableDetails.tsx b/src/components/Modals/CreateSingleItemModal/ItemDetailsStep/SmartWearableDetails.tsx new file mode 100644 index 000000000..b2fa8e59a --- /dev/null +++ b/src/components/Modals/CreateSingleItemModal/ItemDetailsStep/SmartWearableDetails.tsx @@ -0,0 +1,146 @@ +import React, { useCallback } from 'react' +import { Row, Header, Message, Column, Button } from 'decentraland-ui' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import { getBackgroundStyle } from 'modules/item/utils' +import Icon from 'components/Icon' +import ItemVideo from 'components/ItemVideo' +import ItemRequiredPermission from 'components/ItemRequiredPermission' +import { useCreateSingleItemModal } from '../CreateSingleItemModal.context' +import { AcceptedFileProps, CreateItemView } from '../CreateSingleItemModal.types' +import CommonFields from '../CommonFields' +import { UploadVideoStep } from '../UploadVideoStep' +import { createItemActions, createInitialState } from '../CreateSingleItemModal.reducer' + +export const SmartWearableDetails: React.FC = () => { + const { + state, + dispatch, + onClose, + renderMetrics, + renderModalTitle, + handleOpenThumbnailDialog, + handleThumbnailChange, + thumbnailInput, + itemStatus, + metadata, + collection, + isThirdPartyV2Enabled, + handleSubmit, + isDisabled, + isLoading + } = useCreateSingleItemModal() + const { contents, thumbnail, rarity, requiredPermissions, video, view } = state + const title = renderModalTitle() + const thumbnailStyle = getBackgroundStyle(rarity) + + const handleUploadVideoGoBack = useCallback(() => { + const { fromView } = state + + if (fromView) { + dispatch(createItemActions.setView(fromView)) + return + } + + // Reset to initial state using the reducer's createInitialState function + const initialState = createInitialState(metadata, collection, isThirdPartyV2Enabled) + dispatch(createItemActions.resetState(initialState)) + }, [state, metadata, collection, isThirdPartyV2Enabled]) + + const handleSaveVideo = useCallback(() => { + dispatch(createItemActions.setFromView(undefined)) + dispatch(createItemActions.setLoading(false)) + dispatch(createItemActions.setView(CreateItemView.DETAILS)) + }, []) + + const handleOpenVideoDialog = useCallback(() => { + dispatch(createItemActions.setView(CreateItemView.UPLOAD_VIDEO)) + dispatch(createItemActions.setFromView(CreateItemView.DETAILS)) + }, []) + + const handleVideoDropAccepted = useCallback((acceptedFileProps: AcceptedFileProps) => { + dispatch(createItemActions.setLoading(true)) + dispatch(createItemActions.setAcceptedProps(acceptedFileProps)) + }, []) + + if (view === CreateItemView.UPLOAD_VIDEO) { + return ( + + ) + } + + return ( + <> + +
+ + {requiredPermissions?.length ? ( +
+
+ {t('create_single_item_modal.smart_wearable_permissions_label')} + + {t('global.learn_more')} + +
+ +
+ ) : null} + +
+
{t('create_single_item_modal.thumbnail_preview_title')}
+
+
+ {title} + + +
+
{renderMetrics()}
+
+
+ +
+
{t('create_single_item_modal.video_preview_title')}
+
+ } + onClick={handleOpenVideoDialog} + /> +
+
+
+
+ } /> +
+
+
+ + + + + + + + + + ) +} + +export default React.memo(SmartWearableDetails) diff --git a/src/components/Modals/CreateSingleItemModal/ItemDetailsStep/WearableDetails.tsx b/src/components/Modals/CreateSingleItemModal/ItemDetailsStep/WearableDetails.tsx new file mode 100644 index 000000000..9b3054913 --- /dev/null +++ b/src/components/Modals/CreateSingleItemModal/ItemDetailsStep/WearableDetails.tsx @@ -0,0 +1,139 @@ +import React, { useCallback } from 'react' +import { Row, Column, Section, Header, Icon, Button } from 'decentraland-ui' +import { t } from 'decentraland-dapps/dist/modules/translation/utils' +import { BodyShapeType, ItemType, Item } from 'modules/item/types' +import { getBackgroundStyle } from 'modules/item/utils' +import ItemDropdown from 'components/ItemDropdown' +import { createItemActions } from '../CreateSingleItemModal.reducer' +import { useCreateSingleItemModal } from '../CreateSingleItemModal.context' +import CommonFields from '../CommonFields' + +export const WearableDetails: React.FC = () => { + const { + state, + metadata, + dispatch, + renderMetrics, + renderModalTitle, + handleOpenThumbnailDialog, + handleThumbnailChange, + thumbnailInput, + filterItemsByBodyShape, + handleItemChange, + isAddingRepresentation, + handleSubmit, + isDisabled, + isLoading + } = useCreateSingleItemModal() + const { bodyShape, thumbnail, isRepresentation, rarity, item } = state + const title = renderModalTitle() + const thumbnailStyle = getBackgroundStyle(rarity) + + const handleYes = useCallback(() => dispatch(createItemActions.setIsRepresentation(true)), []) + + const handleNo = useCallback(() => dispatch(createItemActions.setIsRepresentation(false)), []) + + const renderCommonFields = () => + + const renderExistingItemQuestion = () => ( +
+
{t('create_single_item_modal.existing_item')}
+ +
+ {t('global.yes')} +
+
+ {t('global.no')} +
+
+
+ ) + + const renderItemDropdown = () => ( +
+
+ {isAddingRepresentation + ? t('create_single_item_modal.adding_representation', { bodyShape: t(`body_shapes.${bodyShape}`) }) + : t('create_single_item_modal.pick_item', { bodyShape: t(`body_shapes.${bodyShape}`) })} +
+ } + filter={filterItemsByBodyShape} + onChange={handleItemChange} + isDisabled={isAddingRepresentation} + /> +
+ ) + + const renderRepresentation = useCallback( + (type: BodyShapeType) => { + return ( +
{ + dispatch(createItemActions.setBodyShape(type)) + dispatch(createItemActions.setIsRepresentation(metadata && metadata.changeItemFile ? false : undefined)) + dispatch(createItemActions.setItem(undefined)) + }} + > + {t('body_shapes.' + type)} +
+ ) + }, + [bodyShape, metadata, dispatch] + ) + + const renderWearableContent = () => { + if (!bodyShape || bodyShape === BodyShapeType.BOTH || (metadata && metadata.changeItemFile)) { + return renderCommonFields() + } + + return ( + <> + {!isAddingRepresentation && renderExistingItemQuestion()} + {isRepresentation === undefined ? null : isRepresentation ? renderItemDropdown() : renderCommonFields()} + + ) + } + + return ( + <> + +
+
+ {title} + {isRepresentation ? null : ( + <> + + + + )} +
+ {renderMetrics()} +
+ + {isAddingRepresentation ? null : ( +
+
{t('create_single_item_modal.representation_label')}
+ + {renderRepresentation(BodyShapeType.BOTH)} + {renderRepresentation(BodyShapeType.MALE)} + {renderRepresentation(BodyShapeType.FEMALE)} + +
+ )} + {renderWearableContent()} +
+
+ + + + + + + ) +} + +export default React.memo(WearableDetails) diff --git a/src/components/Modals/CreateSingleItemModal/ItemDetailsStep/index.ts b/src/components/Modals/CreateSingleItemModal/ItemDetailsStep/index.ts new file mode 100644 index 000000000..e27f6db82 --- /dev/null +++ b/src/components/Modals/CreateSingleItemModal/ItemDetailsStep/index.ts @@ -0,0 +1,4 @@ +export { default as ItemDetailsStep } from './ItemDetailsStep' +export { default as WearableDetails } from './WearableDetails' +export { default as EmoteDetails } from './EmoteDetails' +export { default as SmartWearableDetails } from './SmartWearableDetails' diff --git a/src/components/Modals/CreateSingleItemModal/Steps/Steps.tsx b/src/components/Modals/CreateSingleItemModal/Steps/Steps.tsx new file mode 100644 index 000000000..75631dc34 --- /dev/null +++ b/src/components/Modals/CreateSingleItemModal/Steps/Steps.tsx @@ -0,0 +1,115 @@ +import React, { useCallback, useEffect } from 'react' +import EditPriceAndBeneficiaryModal from '../../EditPriceAndBeneficiaryModal' +import ImportStep from '../ImportStep/ImportStep' +import EditThumbnailStep from '../EditThumbnailStep/EditThumbnailStep' +import { ItemDetailsStep } from '../ItemDetailsStep' +import { toEmoteWithBlobs } from '../utils' +import { createItemActions } from '../CreateSingleItemModal.reducer' +import { CreateItemView } from '../CreateSingleItemModal.types' +import { useCreateSingleItemModal } from '../CreateSingleItemModal.context' + +interface StepsProps { + modalContainer: React.RefObject +} + +export const Steps: React.FC = ({ modalContainer }) => { + const { + state, + metadata, + collection, + onClose, + dispatch, + isLoading, + handleSubmit, + handleDropAccepted, + handleOnScreenshotTaken, + renderWearablePreview, + renderModalTitle + } = useCreateSingleItemModal() + + const { view } = state + + // TODO: Refactor this logic to a callback that calls the handleSubmit function + // and pass to the handleSubmit the required parameters + useEffect(() => { + if (state.fromView === CreateItemView.DETAILS && state.hasScreenshotTaken) { + handleSubmit() + } else if (!state.fromView && state.view === CreateItemView.THUMBNAIL && state.hasScreenshotTaken) { + dispatch(createItemActions.setView(CreateItemView.DETAILS)) + } + }, [state, handleSubmit]) + + // Thumbnail editing handlers + const handleThumbnailGoBack = useCallback(() => { + dispatch(createItemActions.setView(CreateItemView.DETAILS)) + dispatch(createItemActions.setFromView(undefined)) + }, [dispatch]) + + const handleSaveThumbnail = useCallback( + (screenshot: string) => { + handleOnScreenshotTaken(screenshot) + }, + [state, dispatch] + ) + + const renderView = useCallback(() => { + switch (view) { + case CreateItemView.IMPORT: + return ( + {renderWearablePreview()}} + isLoading={!!state.isLoading} + isRepresentation={!!state.isRepresentation} + onDropAccepted={handleDropAccepted} + onClose={onClose} + /> + ) + + case CreateItemView.DETAILS: + return + + case CreateItemView.THUMBNAIL: + return ( + + ) + + case CreateItemView.SET_PRICE: + return ( + { + onClose() + return { type: 'Close modal', payload: { name: 'EditPriceAndBeneficiaryModal' } } + }} + mountNode={modalContainer.current ?? undefined} + onSkip={handleSubmit} + /> + ) + + default: + return null + } + }, [view, state, modalContainer, isLoading, handleDropAccepted, handleOnScreenshotTaken]) + + return renderView() +} + +export default Steps diff --git a/src/components/Modals/CreateSingleItemModal/Steps/index.ts b/src/components/Modals/CreateSingleItemModal/Steps/index.ts new file mode 100644 index 000000000..10cf87eaf --- /dev/null +++ b/src/components/Modals/CreateSingleItemModal/Steps/index.ts @@ -0,0 +1,2 @@ +export { Steps } from './Steps' +export { default } from './Steps' diff --git a/src/components/Modals/CreateSingleItemModal/UploadVideoStep/index.ts b/src/components/Modals/CreateSingleItemModal/UploadVideoStep/index.ts new file mode 100644 index 000000000..f834680e8 --- /dev/null +++ b/src/components/Modals/CreateSingleItemModal/UploadVideoStep/index.ts @@ -0,0 +1,3 @@ +import UploadVideoStep from './UploadVideoStep' + +export { UploadVideoStep } diff --git a/src/components/Modals/CreateSingleItemModal/index.ts b/src/components/Modals/CreateSingleItemModal/index.ts index a26fe2613..b041922f2 100644 --- a/src/components/Modals/CreateSingleItemModal/index.ts +++ b/src/components/Modals/CreateSingleItemModal/index.ts @@ -1,2 +1,9 @@ import CreateSingleItemModal from './CreateSingleItemModal.container' export default CreateSingleItemModal + +// Export new refactored components +export * from './CreateSingleItemModal.types' +export * from './CreateSingleItemModal.reducer' +export * from './ItemDetailsStep' +export { default as CommonFields } from './CommonFields' +export * from './Steps' diff --git a/src/components/Modals/CreateSingleItemModal/utils.spec.ts b/src/components/Modals/CreateSingleItemModal/utils.spec.ts new file mode 100644 index 000000000..1d63bf5b5 --- /dev/null +++ b/src/components/Modals/CreateSingleItemModal/utils.spec.ts @@ -0,0 +1,166 @@ +import { ArmatureId } from '@dcl/schemas' +import { autocompleteSocialEmoteData } from './utils' + +describe('autocompleteSocialEmoteData', () => { + describe('when processing animations with _Start suffix', () => { + it('should create startAnimation for animations ending with _Start', () => { + const animations = ['HighFive_Start', 'Wave_Start_Prop'] + + const result = autocompleteSocialEmoteData(animations) + + expect(result.startAnimation).toBeDefined() + expect(result.startAnimation?.[ArmatureId.Armature]).toEqual({ + animation: 'HighFive_Start' + }) + expect(result.startAnimation?.loop).toEqual(true) + }) + + it('should handle prop start animations', () => { + const animations = ['HighFive_Start_Prop'] + + const result = autocompleteSocialEmoteData(animations) + + expect(result.startAnimation).toBeDefined() + expect(result.startAnimation?.[ArmatureId.Armature_Prop]).toEqual({ + animation: 'HighFive_Start_Prop' + }) + expect(result.startAnimation?.loop).toEqual(true) + }) + }) + + describe('when processing animations without _Start suffix', () => { + it('should create outcomes for non-start animations', () => { + const animations = ['HighFive_Avatar', 'Wave_Avatar'] + + const result = autocompleteSocialEmoteData(animations) + + expect(result.outcomes).toBeDefined() + expect(result.outcomes).toHaveLength(2) + expect(result.outcomes?.[0]).toEqual({ + title: 'High Five', + clips: { + Armature: { + animation: 'HighFive_Avatar' + } + }, + loop: false + }) + expect(result.outcomes?.[1]).toEqual({ + title: 'Wave', + clips: { + [ArmatureId.Armature]: { + animation: 'Wave_Avatar' + } + }, + loop: false + }) + }) + + it('should handle AvatarOther animations', () => { + const animations = ['HighFive_AvatarOther'] + + const result = autocompleteSocialEmoteData(animations) + + expect(result.outcomes).toBeDefined() + expect(result.outcomes?.[0]).toEqual({ + title: 'High Five', + clips: { + [ArmatureId.Armature_Other]: { + animation: 'HighFive_AvatarOther' + } + }, + loop: false + }) + }) + + it('should handle Prop animations', () => { + const animations = ['HighFive_Prop'] + + const result = autocompleteSocialEmoteData(animations) + + expect(result.outcomes).toBeDefined() + expect(result.outcomes?.[0]).toEqual({ + title: 'High Five', + clips: { + [ArmatureId.Armature_Prop]: { + animation: 'HighFive_Prop' + } + }, + loop: false + }) + }) + }) + + describe('when processing mixed animations', () => { + it('should handle both start animations and outcomes', () => { + const animations = ['HighFive_Start', 'HighFive_Avatar', 'HighFive_Prop', 'Wave_Start_Prop', 'Wave_Avatar'] + + const result = autocompleteSocialEmoteData(animations) + + expect(result.startAnimation).toBeDefined() + expect(result.startAnimation?.[ArmatureId.Armature]).toEqual({ + animation: 'HighFive_Start' + }) + + expect(result.startAnimation?.[ArmatureId.Armature_Prop]).toEqual({ + animation: 'Wave_Start_Prop' + }) + + expect(result.startAnimation?.loop).toEqual(true) + + expect(result.outcomes).toBeDefined() + expect(result.outcomes).toHaveLength(2) + + // HighFive group + const highFiveOutcome = result.outcomes?.find(o => o.title === 'High Five') + expect(highFiveOutcome).toBeDefined() + expect(highFiveOutcome?.clips).toEqual({ + [ArmatureId.Armature]: { + animation: 'HighFive_Avatar' + }, + [ArmatureId.Armature_Prop]: { + animation: 'HighFive_Prop' + } + }) + + // Wave group + const waveOutcome = result.outcomes?.find(o => o.title === 'Wave') + expect(waveOutcome).toBeDefined() + expect(waveOutcome?.clips).toEqual({ + [ArmatureId.Armature]: { + animation: 'Wave_Avatar' + } + }) + }) + }) + + describe('when processing animations with complex names', () => { + it('should format camelCase names correctly', () => { + const animations = ['SuperJump_Avatar', 'CamelCaseTest_Avatar'] + + const result = autocompleteSocialEmoteData(animations) + + expect(result.outcomes).toBeDefined() + expect(result.outcomes?.[0].title).toBe('Super Jump') + expect(result.outcomes?.[1].title).toBe('Camel Case Test') + }) + }) + + describe('when processing empty or invalid animations', () => { + it('should handle empty array', () => { + const result = autocompleteSocialEmoteData([]) + + expect(result.startAnimation).toBeUndefined() + expect(result.outcomes).toBeUndefined() + }) + + it('should handle animations without recognized suffixes', () => { + const animations = ['UnknownAnimation', 'AnotherUnknown'] + + const result = autocompleteSocialEmoteData(animations) + + expect(result.outcomes).toBeDefined() + expect(result.outcomes?.[0].clips[ArmatureId.Armature]).toBeDefined() // Default to Armature + }) + }) +}) diff --git a/src/components/Modals/CreateSingleItemModal/utils.ts b/src/components/Modals/CreateSingleItemModal/utils.ts index 9e27d7a40..e5a87f69c 100644 --- a/src/components/Modals/CreateSingleItemModal/utils.ts +++ b/src/components/Modals/CreateSingleItemModal/utils.ts @@ -1,6 +1,24 @@ -import { BodyShape, EmoteCategory, EmoteWithBlobs, WearableCategory, WearableWithBlobs } from '@dcl/schemas' +import { + ArmatureId, + BodyShape, + ContractAddress, + ContractNetwork, + EmoteCategory, + EmoteClip, + EmoteWithBlobs, + Mapping, + MappingType, + OutcomeGroup, + StartAnimation, + WearableCategory, + WearableWithBlobs +} from '@dcl/schemas' import { ThumbnailType } from 'lib/getModelData' +import { LinkedContract } from 'modules/thirdParty/types' import { isImageFile, isModelFile } from 'modules/item/utils' +import { Collection } from 'modules/collection/types' +import { BodyShapeType, THUMBNAIL_PATH, VIDEO_PATH, WearableRepresentation } from 'modules/item/types' +import { SortedContent } from './CreateSingleItemModal.types' export const THUMBNAIL_WIDTH = 1024 export const THUMBNAIL_HEIGHT = 1024 @@ -62,7 +80,17 @@ export function toWearableWithBlobs({ contents, file }: { contents?: Record; file?: File }): EmoteWithBlobs { +export function toEmoteWithBlobs({ + contents, + file, + startAnimation, + outcomes +}: { + contents?: Record + file?: File + startAnimation?: StartAnimation + outcomes?: OutcomeGroup[] +}): EmoteWithBlobs { const mainFile = contents && Object.keys(contents).find(content => isModelFile(content)) if (contents && !mainFile) { throw Error('Not valid main content') @@ -93,7 +121,295 @@ export function toEmoteWithBlobs({ contents, file }: { contents?: Record { + return `${bodyShape}/${contentKey}` +} + +/** + * Creates a new contents record with the names of the contents blobs record prefixed. + * The names need to be prefixed so they won't collide with other + * pre-uploaded models. The name of the content is the name of the uploaded file. + * + * @param bodyShape - The body shaped used to prefix the content names. + * @param contents - The contents which keys are going to be prefixed. + */ +const prefixContents = (bodyShape: BodyShapeType, contents: Record): Record => { + return Object.keys(contents).reduce((newContents: Record, key: string) => { + // Do not include the thumbnail, scenes, and video in each of the body shapes + if ([THUMBNAIL_PATH, VIDEO_PATH].includes(key)) { + return newContents + } + newContents[prefixContentName(bodyShape, key)] = contents[key] + return newContents + }, {}) +} + +/** + * Sorts the content into "male", "female" and "all" taking into consideration the body shape. + * All contains the item thumbnail and both male and female representations according to the shape. + * If the body representation is male, "female" will be an empty object and viceversa. + * + * @param bodyShape - The body shaped used to sort the content. + * @param contents - The contents to be sorted. + */ +export const sortContent = (bodyShape: BodyShapeType, contents: Record): SortedContent => { + const male = bodyShape === BodyShapeType.BOTH || bodyShape === BodyShapeType.MALE ? prefixContents(BodyShapeType.MALE, contents) : {} + const female = + bodyShape === BodyShapeType.BOTH || bodyShape === BodyShapeType.FEMALE ? prefixContents(BodyShapeType.FEMALE, contents) : {} + + const all: Record = { + [THUMBNAIL_PATH]: contents[THUMBNAIL_PATH], + ...male, + ...female + } + + if (contents[VIDEO_PATH]) { + all[VIDEO_PATH] = contents[VIDEO_PATH] + } + + return { male, female, all } +} + +export const sortContentZipBothBodyShape = (bodyShape: BodyShapeType, contents: Record): SortedContent => { + let male: Record = {} + let female: Record = {} + const both: Record = {} + + for (const [key, value] of Object.entries(contents)) { + if (key.startsWith('male/') && (bodyShape === BodyShapeType.BOTH || bodyShape === BodyShapeType.MALE)) { + male[key] = value + } else if (key.startsWith('female/') && (bodyShape === BodyShapeType.BOTH || bodyShape === BodyShapeType.FEMALE)) { + female[key] = value + } else { + both[key] = value + } + } + + male = { + ...male, + ...(bodyShape === BodyShapeType.BOTH || bodyShape === BodyShapeType.MALE ? prefixContents(BodyShapeType.MALE, both) : {}) + } + + female = { + ...female, + ...(bodyShape === BodyShapeType.BOTH || bodyShape === BodyShapeType.FEMALE ? prefixContents(BodyShapeType.FEMALE, both) : {}) + } + + const all = { + [THUMBNAIL_PATH]: contents[THUMBNAIL_PATH], + ...male, + ...female + } + + return { male, female, all } +} + +export const buildRepresentations = (bodyShape: BodyShapeType, model: string, contents: SortedContent): WearableRepresentation[] => { + const representations: WearableRepresentation[] = [] + + // add male representation + if (bodyShape === BodyShapeType.MALE || bodyShape === BodyShapeType.BOTH) { + representations.push({ + bodyShapes: [BodyShape.MALE], + mainFile: prefixContentName(BodyShapeType.MALE, model), + contents: Object.keys(contents.male), + overrideHides: [], + overrideReplaces: [] + }) + } + + // add female representation + if (bodyShape === BodyShapeType.FEMALE || bodyShape === BodyShapeType.BOTH) { + representations.push({ + bodyShapes: [BodyShape.FEMALE], + mainFile: prefixContentName(BodyShapeType.FEMALE, model), + contents: Object.keys(contents.female), + overrideHides: [], + overrideReplaces: [] + }) + } + + return representations +} + +export const buildRepresentationsZipBothBodyshape = (bodyShape: BodyShapeType, contents: SortedContent): WearableRepresentation[] => { + const representations: WearableRepresentation[] = [] + + // add male representation + if (bodyShape === BodyShapeType.MALE || bodyShape === BodyShapeType.BOTH) { + representations.push({ + bodyShapes: [BodyShape.MALE], + mainFile: Object.keys(contents.male).find(content => content.includes('glb'))!, + contents: Object.keys(contents.male), + overrideHides: [], + overrideReplaces: [] + }) + } + + // add female representation + if (bodyShape === BodyShapeType.FEMALE || bodyShape === BodyShapeType.BOTH) { + representations.push({ + bodyShapes: [BodyShape.FEMALE], + mainFile: Object.keys(contents.female).find(content => content.includes('glb'))!, + contents: Object.keys(contents.female), + overrideHides: [], + overrideReplaces: [] + }) + } + + return representations +} + +/** + * Gets the default mappings for a third party contract + */ +export const getDefaultMappings = ( + contract: LinkedContract | undefined, + isThirdPartyV2Enabled: boolean +): Partial>> | undefined => { + if (!isThirdPartyV2Enabled || !contract) { + return undefined + } + + return { + [contract.network]: { + [contract.address]: [{ type: MappingType.ANY }] + } + } +} + +/** + * Gets the linked contract from a collection + */ +export const getLinkedContract = (collection: Collection | undefined | null): LinkedContract | undefined => { + if (!collection?.linkedContractAddress || !collection?.linkedContractNetwork) { + return undefined + } + + return { + address: collection.linkedContractAddress, + network: collection.linkedContractNetwork + } +} + +/** + * Maps animation suffixes to their corresponding armature names + */ +const ANIMATION_TO_ARMATURE_MAP = { + Avatar: ArmatureId.Armature, + AvatarOther: ArmatureId.Armature_Other, + Prop: ArmatureId.Armature_Prop +} as const + +/** + * Extracts the base name from an animation name by removing the suffix + */ +const getBaseAnimationName = (animationName: string): string => { + // Remove common suffixes to get the base name + const suffixes = ['_Start', '_Avatar', '_AvatarOther', '_Prop', '_Start_Prop'] + + for (const suffix of suffixes) { + if (animationName.endsWith(suffix)) { + return animationName.slice(0, -suffix.length) + } + } + + return animationName +} + +/** + * Gets the armature name based on the animation name suffix + */ +const getArmatureFromAnimation = (animationName: string): ArmatureId => { + if (animationName.endsWith('_AvatarOther')) { + return ANIMATION_TO_ARMATURE_MAP.AvatarOther + } + if (animationName.endsWith('_Prop') || animationName.endsWith('_Start_Prop')) { + return ANIMATION_TO_ARMATURE_MAP.Prop + } + // Default to Avatar for _Avatar, _Start, or no suffix + return ANIMATION_TO_ARMATURE_MAP.Avatar +} + +/** + * Formats the base animation name into a title (e.g., "HighFive" -> "High Five") + */ +const formatAnimationTitle = (baseName: string): string => { + // Convert camelCase/PascalCase to title case + return baseName + .replace(/([A-Z])/g, ' $1') // Add space before capital letters + .replace(/^./, str => str.toUpperCase()) // Capitalize first letter + .trim() +} + +/** + * Checks if an animation is a start animation + */ +const isStartAnimation = (animationName: string): boolean => { + return animationName.endsWith('_Start') || animationName.endsWith('_Start_Prop') +} + +/** + * Autocompletes emote data based on animation naming conventions + */ +export const autocompleteSocialEmoteData = (animations: string[]) => { + const startAnimation: Partial = {} + const outcomes: OutcomeGroup[] = [] + + // Group animations by base name + const animationGroups = new Map() + + animations.forEach(animation => { + const baseName = getBaseAnimationName(animation) + const group = animationGroups.get(baseName) ?? [] + group.push(animation) + animationGroups.set(baseName, group) + }) + + // Process each group + animationGroups.forEach((groupAnimations, baseName) => { + const title = formatAnimationTitle(baseName) + + // Find start animations + const startAnimations = groupAnimations.filter(isStartAnimation) + startAnimations.forEach(animation => { + const armature = getArmatureFromAnimation(animation) as ArmatureId.Armature | ArmatureId.Armature_Prop + startAnimation[armature] = { animation } + }) + + // Find outcome animations (non-start animations) + const outcomeAnimations = groupAnimations.filter(anim => !isStartAnimation(anim)) + if (outcomeAnimations.length > 0) { + const clips: Partial> = {} + outcomeAnimations.forEach(animation => { + const armature = getArmatureFromAnimation(animation) + clips[armature] = { animation } + }) + + outcomes.push({ + title, + clips, + loop: false + }) + } + }) + + return { + startAnimation: Object.keys(startAnimation).length > 0 ? { ...startAnimation, loop: true } : undefined, + outcomes: outcomes.length > 0 ? outcomes : undefined + } +} diff --git a/src/components/Modals/EditThumbnailModal/EditThumbnailModal.tsx b/src/components/Modals/EditThumbnailModal/EditThumbnailModal.tsx index bbee64171..0df916e65 100644 --- a/src/components/Modals/EditThumbnailModal/EditThumbnailModal.tsx +++ b/src/components/Modals/EditThumbnailModal/EditThumbnailModal.tsx @@ -2,6 +2,7 @@ import React from 'react' import Modal from 'decentraland-dapps/dist/containers/Modal' import { t } from 'decentraland-dapps/dist/modules/translation/utils' import { toBase64 } from 'modules/editor/utils' +import { EmoteData } from 'modules/item/types' import ImportStep from 'components/Modals/CreateSingleItemModal/ImportStep/ImportStep' import EditThumbnailStep from 'components/Modals/CreateSingleItemModal/EditThumbnailStep/EditThumbnailStep' import { AcceptedFileProps, CreateItemView } from 'components/Modals/CreateSingleItemModal/CreateSingleItemModal.types' @@ -61,7 +62,13 @@ export default class EditThumbnailModal extends React.PureComponent = {}) { const Three = await import('three') const { width, height, mappings } = { @@ -210,36 +217,50 @@ export async function getIsEmote(url: string, options: Partial = {}) { return gltf.animations.length > 0 } -export async function getEmoteMetrics(blob: Blob) { - const { gltf, renderer } = await loadGltf(URL.createObjectURL(blob)) +export async function getEmoteData(url: string, options: Partial = {}) { + const { + gltf: { scene, animations }, + renderer + } = await loadGltf(url, options) document.body.removeChild(renderer.domElement) - const animation = gltf.animations[0] - const propsAnimation = gltf.animations.length > 1 ? gltf.animations[1] : null - let frames = 0 - - for (let i = 0; i < animation.tracks.length; i++) { - const track = animation.tracks[i] - frames = Math.max(frames, track.times.length) - } + const armatures = scene.children.filter(({ name }) => name.startsWith(ARMATURE_PREFIX)) + const animation = animations[0] + const propsAnimation = animations.length > 1 ? animations[1] : null if ( - gltf.scene.children.some(sceneItem => + scene.children.some(sceneItem => sceneItem.children.some(item => item.name.toLowerCase().includes('basemesh') || item.name.toLowerCase().includes('avatar_mesh')) ) ) { throw new EmoteWithMeshError() } - if (propsAnimation && propsAnimation.duration !== animation.duration) { + // For social emotes, we need to count the number of additional armatures, currently only one additional armature is supported + const additionalArmatures = armatures.some(({ name }) => name === ARMATURES.OTHER) ? 1 : 0 + + // For social emotes, we don't need to check the duration of the props animation + if (!additionalArmatures && propsAnimation && propsAnimation.duration !== animation.duration) { throw new EmoteAnimationsSyncError() } + let frames = 0 + + for (let i = 0; i < animation.tracks.length; i++) { + const track = animation.tracks[i] + frames = Math.max(frames, track.times.length) + } + return { - sequences: gltf.animations.length, - duration: animation.duration, - frames, - fps: frames / animation.duration, - props: gltf.scene.children.some(({ name }) => name === 'Armature_Prop') ? 1 : 0 + animations, + armatures, + metrics: { + sequences: animations.length, + duration: animation.duration, + frames, + fps: frames / animation.duration, + props: armatures.some(({ name }) => name === ARMATURES.PROP) ? 1 : 0, + additionalArmatures + } } } @@ -256,10 +277,15 @@ export async function getItemData({ contents: Record category?: string }) { - let info: Metrics - let image + const data: { metrics: Metrics; image: string; armatures?: Object3D[]; animations?: AnimationClip[] } = { + metrics: {} as Metrics, + image: '', + armatures: [], + animations: [] + } + if (isImageFile(model)) { - info = { + data.metrics = { triangles: 100, materials: 1, textures: 1, @@ -267,18 +293,21 @@ export async function getItemData({ bodies: 1, entities: 1 } - image = await convertImageIntoWearableThumbnail(contents[THUMBNAIL_PATH] || contents[model], category as WearableCategory) + data.image = await convertImageIntoWearableThumbnail(contents[THUMBNAIL_PATH] || contents[model], category as WearableCategory) } else { if (!wearablePreviewController) { throw Error('WearablePreview controller needed') } if (type === ItemType.EMOTE) { - info = await getEmoteMetrics(contents[model]) + const emoteData = await getEmoteData(URL.createObjectURL(contents[model])) + data.metrics = emoteData.metrics + data.armatures = emoteData.armatures + data.animations = emoteData.animations } else { - info = await wearablePreviewController.scene.getMetrics() + data.metrics = await wearablePreviewController.scene.getMetrics() } - image = await wearablePreviewController.scene.getScreenshot(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT) + data.image = await wearablePreviewController.scene.getScreenshot(THUMBNAIL_WIDTH, THUMBNAIL_HEIGHT) } - return { info, image } + return data } diff --git a/src/modules/editor/utils.ts b/src/modules/editor/utils.ts index 03ae7bc45..05c09b998 100644 --- a/src/modules/editor/utils.ts +++ b/src/modules/editor/utils.ts @@ -198,7 +198,7 @@ export const getName = (wearable: Wearable) => { return !isNumeric || part <= 0 ? strPart : null }) .filter(part => part != null) // Filter out ignored parts - .map(part => capitalize(part!)) + .map(part => capitalize(part)) .join(' ') } @@ -331,7 +331,10 @@ export function toEmote(item: Item): EmoteDefinition { ...representation, contents: representation.contents.map(path => ({ key: path, url: getContentsStorageUrl(item.contents[path]) })) })), - loop: item.data.loop + loop: item.data.loop, + startAnimation: item.data.startAnimation, + randomizeOutcomes: item.data.randomizeOutcomes, + outcomes: item.data.outcomes } } } diff --git a/src/modules/item/export.ts b/src/modules/item/export.ts index 1f52846c9..524342f97 100644 --- a/src/modules/item/export.ts +++ b/src/modules/item/export.ts @@ -6,7 +6,7 @@ import { BuilderAPI } from 'lib/api/builder' import { buildCatalystItemURN } from 'lib/urn' import { makeContentFiles, computeHashes } from 'modules/deployment/contentUtils' import { Collection } from 'modules/collection/types' -import { Item, IMAGE_PATH, THUMBNAIL_PATH, ItemType, EntityHashingType, isEmoteItemType, VIDEO_PATH } from './types' +import { Item, IMAGE_PATH, THUMBNAIL_PATH, ItemType, EntityHashingType, isEmoteItemType, VIDEO_PATH, isSocialEmote } from './types' import { EMPTY_ITEM_METRICS, generateCatalystImage, generateImage } from './utils' /** @@ -212,7 +212,10 @@ function buildADR74EmoteEntityMetadata(collection: Collection, item: Item): any { + if (!collection.contractAddress || !item.tokenId) { + throw new Error('You need the collection and item to be published') + } + + // The order of the metadata properties can't be changed. Changing it will result in a different content hash. + const catalystItem: any = { + id: buildCatalystItemURN(collection.contractAddress, item.tokenId), + name: item.name, + description: item.description, + collectionAddress: collection.contractAddress, + rarity: item.rarity! as unknown as Rarity, + i18n: [{ code: Locale.EN, text: item.name }], + // TODO: ADR287 + emoteDataADR287: { + category: item.data.category, + representations: item.data.representations, + tags: item.data.tags, + loop: item.data.loop, + startAnimation: item.data.startAnimation, + randomizeOutcomes: item.data.randomizeOutcomes, + outcomes: item.data.outcomes + }, + image: IMAGE_PATH, + thumbnail: THUMBNAIL_PATH, + metrics: EMPTY_ITEM_METRICS + } + return catalystItem +} + async function buildItemEntityContent(item: Item): Promise> { const contents = { ...item.contents } if (!item.contents[IMAGE_PATH]) { @@ -260,7 +293,7 @@ export async function buildItemEntity( let metadata const isEmote = isEmoteItemType(item) //TODO: @Emotes remove this FF once launched if (isEmote) { - metadata = buildADR74EmoteEntityMetadata(collection, item) + metadata = isSocialEmote(item.data) ? buildADR287EmoteEntityMetadata(collection, item) : buildADR74EmoteEntityMetadata(collection, item) } else if (tree && itemHash) { metadata = buildTPItemEntityMetadata(item, itemHash, tree) } else { @@ -301,7 +334,11 @@ export async function buildStandardWearableContentHash( ): Promise { const hashes = await buildItemEntityContent(item) const content = Object.keys(hashes).map(file => ({ file, hash: hashes[file] })) - const metadata = isEmoteItemType(item) ? buildADR74EmoteEntityMetadata(collection, item) : buildWearableEntityMetadata(collection, item) + const metadata = isEmoteItemType(item) + ? isSocialEmote(item.data) + ? buildADR287EmoteEntityMetadata(collection, item) + : buildADR74EmoteEntityMetadata(collection, item) + : buildWearableEntityMetadata(collection, item) if (hashingType === EntityHashingType.V0) { return (await calculateMultipleHashesADR32LegacyQmHash(content, metadata)).hash } else { diff --git a/src/modules/item/types.ts b/src/modules/item/types.ts index 63f07fb82..1757afae5 100644 --- a/src/modules/item/types.ts +++ b/src/modules/item/types.ts @@ -44,6 +44,12 @@ export enum ItemMetadataType { EMOTE = 'e' } +export enum EmoteOutcomeMetadataType { + SIMPLE_OUTCOME = 'so', + MULTIPLE_OUTCOME = 'mo', + RANDOM_OUTCOME = 'ro' +} + export const BODY_SHAPE_CATEGORY = 'body_shape' export enum BodyShapeType { @@ -83,6 +89,8 @@ export type WearableData = { outlineCompatible?: boolean } +export type EmoteData = EmoteDataADR74 + type BaseItem = { id: string // uuid name: string @@ -121,7 +129,7 @@ export type Item = Omit & { blockchainContentHash: string | null currentContentHash: string | null catalystContentHash: string | null - data: T extends ItemType.WEARABLE ? WearableData : EmoteDataADR74 + data: T extends ItemType.WEARABLE ? WearableData : EmoteData metrics: T extends ItemType.WEARABLE ? ModelMetrics : AnimationMetrics mappings: Partial>> | null isMappingComplete?: boolean @@ -132,7 +140,12 @@ export type Item = Omit & { export const isEmoteItemType = (item: Item | Item): item is Item => (item as Item).type === ItemType.EMOTE -export const isEmoteData = (data: WearableData | EmoteDataADR74): data is EmoteDataADR74 => (data as EmoteDataADR74).loop !== undefined +export const isSocialEmote = (data: WearableData | EmoteData | undefined): boolean => { + return !!data && (data as unknown as EmoteData).startAnimation !== undefined && (data as unknown as EmoteData).outcomes !== undefined +} + +export const isEmoteData = (data: WearableData | EmoteData | undefined): data is EmoteData => + !!data && (data as unknown as EmoteData).loop !== undefined export enum Currency { MANA = 'MANA', @@ -160,6 +173,7 @@ export const SCENE_LOGIC_PATH = 'bin/game.js' export const ITEM_NAME_MAX_LENGTH = 32 export const ITEM_DESCRIPTION_MAX_LENGTH = 64 export const ITEM_UTILITY_MAX_LENGTH = 64 +export const OUTCOME_TITLE_MAX_LENGTH = 24 export const MODEL_EXTENSIONS = ['.zip', '.gltf', '.glb'] export const IMAGE_EXTENSIONS = ['.zip', '.png'] export const VIDEO_EXTENSIONS = ['.mp4'] diff --git a/src/modules/item/utils.ts b/src/modules/item/utils.ts index 79cba41f2..81a71ba94 100644 --- a/src/modules/item/utils.ts +++ b/src/modules/item/utils.ts @@ -1,4 +1,5 @@ import { constants } from 'ethers' +import { deepEqual } from 'fast-equals' import { LocalItem, MAX_EMOTE_FILE_SIZE, @@ -10,7 +11,6 @@ import { BodyPartCategory, BodyShape, EmoteCategory, - EmoteDataADR74, Wearable, Rarity, WearableCategory, @@ -50,7 +50,10 @@ import { WearableRepresentation, GenerateImageOptions, EmotePlayMode, - VIDEO_PATH + VIDEO_PATH, + EmoteOutcomeMetadataType, + EmoteData, + isSocialEmote } from './types' import { getChainIdByNetwork, getSigner } from 'decentraland-dapps/dist/lib' import { getOffChainMarketplaceContract, getTradeSignature } from 'decentraland-dapps/dist/lib/trades' @@ -129,6 +132,22 @@ export function getEmoteAdditionalProperties(item: Item): string return additionalProperties } +export function getEmoteOutcomeType(item: Item): string { + if (!isSocialEmote(item.data)) { + return '' + } + + if ((item.data as unknown as EmoteData).outcomes!.length === 1) { + return EmoteOutcomeMetadataType.SIMPLE_OUTCOME + } + + if ((item.data as unknown as EmoteData).outcomes!.length > 1 && (item.data as unknown as EmoteData).randomizeOutcomes) { + return EmoteOutcomeMetadataType.RANDOM_OUTCOME + } + + return EmoteOutcomeMetadataType.MULTIPLE_OUTCOME +} + export function getMissingBodyShapeType(item: Item) { const existingBodyShapeType = getBodyShapeType(item) if (existingBodyShapeType === BodyShapeType.MALE) { @@ -224,11 +243,12 @@ export function buildEmoteMetada( category: string, bodyShapeTypes: string, loop: number, - additionalProperties?: string + additionalProperties?: string, + outcomeType?: string ): string { return `${version}:${type}:${name}:${description}:${category}:${bodyShapeTypes}:${loop}${ additionalProperties ? `:${additionalProperties}` : '' - }` + }${outcomeType ? `:${outcomeType}` : ''}` } // Metadata looks like this: @@ -246,12 +266,13 @@ export function getMetadata(item: Item) { return buildItemMetadata(1, getItemMetadataType(item), item.name, item.description, data.category, bodyShapeTypes) } case ItemType.EMOTE: { - const data = item.data as unknown as EmoteDataADR74 + const data = item.data as unknown as EmoteData const bodyShapeTypes = getBodyShapes(item).map(toWearableBodyShapeType).join(',') if (!data.category) { throw new Error(`Unknown item category "${JSON.stringify(item.data)}"`) } const additionalProperties = getEmoteAdditionalProperties(item as unknown as Item) + const outcomeType = getEmoteOutcomeType(item as unknown as Item) return buildEmoteMetada( 1, getItemMetadataType(item), @@ -260,7 +281,8 @@ export function getMetadata(item: Item) { data.category, bodyShapeTypes, data.loop ? 1 : 0, - additionalProperties + additionalProperties, + outcomeType ) } default: @@ -478,6 +500,11 @@ export function isModelFile(fileName: string) { return fileName.endsWith('.gltf') || fileName.endsWith('.glb') } +export function isAudioFile(fileName: string) { + fileName = fileName.toLowerCase() + return fileName.endsWith('.mp3') || fileName.endsWith('.ogg') +} + export function isModelPath(fileName: string) { fileName = fileName.toLowerCase() // we ignore PNG files that end with "_mask", since those are auxiliary @@ -586,7 +613,7 @@ export function isEmoteSynced(item: Item | Item, entity: Entity) throw new Error('Item must be EMOTE') } - // check if metadata has the new schema from ADR 74 + // check if metadata has the new schema from ADR74 const isADR74 = 'emoteDataADR74' in entity.metadata if (!isADR74) { return false @@ -595,15 +622,23 @@ export function isEmoteSynced(item: Item | Item, entity: Entity) // check if metadata is synced const catalystItem = entity.metadata const catalystItemMetadataData = isADR74 ? entity.metadata.emoteDataADR74 : entity.metadata.data - const data = item.data as EmoteDataADR74 + const data = item.data as unknown as EmoteData - const hasMetadataChanged = + let hasMetadataChanged = item.name !== catalystItem.name || item.description !== catalystItem.description || data.category !== catalystItemMetadataData.category || data.loop !== catalystItemMetadataData.loop || data.tags.toString() !== catalystItemMetadataData.tags.toString() + if (isSocialEmote(data)) { + hasMetadataChanged = + hasMetadataChanged || + !deepEqual(data.startAnimation, catalystItemMetadataData.startAnimation) || + data.randomizeOutcomes !== catalystItemMetadataData.randomizeOutcomes || + !deepEqual(data.outcomes, catalystItemMetadataData.outcomes) + } + if (hasMetadataChanged) { return false } @@ -844,3 +879,7 @@ export async function createItemOrderTrade( return { ...tradeToSign, signature: await getTradeSignature(tradeToSign) } } + +export const itemHasAudio = (item: Item): boolean => { + return Object.keys(item.contents).some(content => isAudioFile(content)) +} diff --git a/src/modules/models/types.ts b/src/modules/models/types.ts index 3cf638cbe..d2f1e0f87 100644 --- a/src/modules/models/types.ts +++ b/src/modules/models/types.ts @@ -13,11 +13,13 @@ export type AnimationMetrics = { frames: number fps: number props: number + additionalArmatures: number } export type Metrics = ModelMetrics | AnimationMetrics export const areEmoteMetrics = (metrics: Metrics): metrics is AnimationMetrics => !!(metrics as AnimationMetrics).fps +export const isSocialEmoteMetrics = (metrics: Metrics): metrics is AnimationMetrics => !!(metrics as AnimationMetrics).additionalArmatures export type Vector3 = { x: number; y: number; z: number } diff --git a/src/modules/translation/languages/en.json b/src/modules/translation/languages/en.json index 7b2c142c7..eba055e32 100644 --- a/src/modules/translation/languages/en.json +++ b/src/modules/translation/languages/en.json @@ -2109,6 +2109,7 @@ "wearables": "Wearables", "wearables_info": "The elements you choose from the wearables dropdown menu will be hidden.", "animation": "Animation", + "animations": "Animations", "tags": "Tags", "event_tag": "Use the {event_tag} tag if you want to include this item in the {event_name} event.", "select_placeholder": "Select an option", @@ -2128,6 +2129,14 @@ "enabled": "Enabled", "disabled": "Disabled", "info": "Nice Wearable! Allow owners of your item to include it in VRM Avatar Exports so they can show it off outside of Decentraland." + }, + "social_emote": { + "avatar": "Avatar", + "props": "Props", + "audio": "Audio", + "optional": "optional", + "randomize_outcomes": "Randomize outcomes", + "add_outcome": "Add an outcome" } } }, diff --git a/src/modules/translation/languages/es.json b/src/modules/translation/languages/es.json index e9c5c2bcd..825bbb1b4 100644 --- a/src/modules/translation/languages/es.json +++ b/src/modules/translation/languages/es.json @@ -2127,6 +2127,7 @@ "wearables": "Wearables", "wearables_info": "Los elementos que elija del menú desplegable estarán ocultos.", "animation": "Animación", + "animations": "Animationes", "tags": "Etiquetas", "event_tag": "Usa la etiqueta {event_tag} si quieres incluir este item en el {event_name}. {learn_more}", "select_placeholder": "Selecciona una opción", @@ -2146,6 +2147,14 @@ "enabled": "Activada", "disabled": "Desactivada", "info": "¡Bonito wearable! Permitir que los propietarios de su artículo lo incluyan en las exportaciones de avatar de VRM para que puedan mostrarlo fuera de Decentraland." + }, + "social_emote": { + "avatar": "Avatar", + "props": "Props", + "audio": "Audio", + "optional": "opcional", + "randomize_outcomes": "Aleatorizar opciones", + "add_outcome": "Agregar una opción" } } }, diff --git a/src/modules/translation/languages/zh.json b/src/modules/translation/languages/zh.json index aaa50332a..b5d9e19fe 100644 --- a/src/modules/translation/languages/zh.json +++ b/src/modules/translation/languages/zh.json @@ -2108,6 +2108,7 @@ "wearables": "可穿戴设备", "wearables_info": "您从可穿戴设备下拉菜单中选择的元素将被隐藏。", "animation": "动画", + "animations": "动画", "tags": "标签", "event_tag": "如果您想将此项目包含在 {event_tag} 中,请使用 {event_name} 标签。{learn_more}", "select_placeholder": "选择一个选项", @@ -2127,6 +2128,14 @@ "enabled": "启用", "disabled": "禁用", "info": "漂亮的可穿戴!允许您的物品的所有者将其包含在VRM Avatar出口中,以便他们可以在分散的外部出现。" + }, + "social_emote": { + "avatar": "阿凡达", + "props": "道具", + "audio": "音频", + "optional": "可选", + "randomize_outcomes": "随机化结果", + "add_outcome": "添加结果" } } },