Improved the server avatar upload modal

master
WolverinDEV 2021-03-24 16:56:09 +01:00
parent f0903db925
commit c68c217eaf
19 changed files with 1576 additions and 111 deletions

View File

@ -1,2 +0,0 @@
plugins:
- removeViewBox: false

View File

@ -1,4 +1,7 @@
# Changelog:
* **24.03.21**
- Improved the avatar upload modal (now way more intuitive)
* **23.03.21**
- Made the permission editor popoutable
- Now using SVG flags for higher quality.

346
package-lock.json generated
View File

@ -3426,6 +3426,11 @@
"integrity": "sha512-rr+OQyAjxze7GgWrSaJwydHStIhHq2lvY3BOC2Mj7KnzI7XK0Uw1TOOdI9lDoajEbSWLiYgoo4f1R51erQfhPQ==",
"dev": true
},
"@types/crypto-js": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@types/crypto-js/-/crypto-js-4.0.1.tgz",
"integrity": "sha512-6+OPzqhKX/cx5xh+yO8Cqg3u3alrkhoxhE5ZOdSEv0DOzJ13lwJ6laqGU0Kv6+XDMFmlnGId04LtY22PsFLQUw=="
},
"@types/dompurify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-2.0.1.tgz",
@ -4412,6 +4417,17 @@
"safer-buffer": "~2.1.0"
}
},
"asn1.js": {
"version": "5.4.1",
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
"requires": {
"bn.js": "^4.0.0",
"inherits": "^2.0.1",
"minimalistic-assert": "^1.0.0",
"safer-buffer": "^2.1.0"
}
},
"asn1js": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/asn1js/-/asn1js-2.1.0.tgz",
@ -5597,8 +5613,7 @@
"base64-js": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz",
"integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==",
"dev": true
"integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g=="
},
"batch": {
"version": "0.6.1",
@ -5873,6 +5888,94 @@
"resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz",
"integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8="
},
"browserify-aes": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz",
"integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==",
"requires": {
"buffer-xor": "^1.0.3",
"cipher-base": "^1.0.0",
"create-hash": "^1.1.0",
"evp_bytestokey": "^1.0.3",
"inherits": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"browserify-cipher": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz",
"integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==",
"requires": {
"browserify-aes": "^1.0.4",
"browserify-des": "^1.0.0",
"evp_bytestokey": "^1.0.0"
}
},
"browserify-des": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz",
"integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==",
"requires": {
"cipher-base": "^1.0.1",
"des.js": "^1.0.0",
"inherits": "^2.0.1",
"safe-buffer": "^5.1.2"
}
},
"browserify-rsa": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.1.0.tgz",
"integrity": "sha512-AdEER0Hkspgno2aR97SAf6vi0y0k8NuOpGnVH3O99rcA5Q6sh8QxcngtHuJ6uXwnfAXNM4Gn1Gb7/MV1+Ymbog==",
"requires": {
"bn.js": "^5.0.0",
"randombytes": "^2.0.1"
},
"dependencies": {
"bn.js": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.0.tgz",
"integrity": "sha512-D7iWRBvnZE8ecXiLj/9wbxH7Tk79fAh8IHaTNq1RWRixsS02W+5qS+iE9yq6RYl0asXx5tw0bLhmT5pIfbSquw=="
}
}
},
"browserify-sign": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.2.1.tgz",
"integrity": "sha512-/vrA5fguVAKKAVTNJjgSm1tRQDHUU6DbwO9IROu/0WAzC8PKhucDSh18J0RMvVeHAn5puMd+QHC2erPRNf8lmg==",
"requires": {
"bn.js": "^5.1.1",
"browserify-rsa": "^4.0.1",
"create-hash": "^1.2.0",
"create-hmac": "^1.1.7",
"elliptic": "^6.5.3",
"inherits": "^2.0.4",
"parse-asn1": "^5.1.5",
"readable-stream": "^3.6.0",
"safe-buffer": "^5.2.0"
},
"dependencies": {
"bn.js": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-5.2.0.tgz",
"integrity": "sha512-D7iWRBvnZE8ecXiLj/9wbxH7Tk79fAh8IHaTNq1RWRixsS02W+5qS+iE9yq6RYl0asXx5tw0bLhmT5pIfbSquw=="
},
"readable-stream": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
"integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
"requires": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
}
},
"safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
}
}
},
"browserslist": {
"version": "4.13.0",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.13.0.tgz",
@ -5885,6 +5988,15 @@
"node-releases": "^1.1.58"
}
},
"buffer": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
"requires": {
"base64-js": "^1.3.1",
"ieee754": "^1.2.1"
}
},
"buffer-crc32": {
"version": "0.2.13",
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz",
@ -5915,6 +6027,11 @@
"integrity": "sha512-4/rOEg86jivtPTeOUUT61jJO1Ya1TrR/OkqCSZDyq84WJh3LuuiphBYJN+fm5xufIk4XAFcEwte/8WzC8If/1g==",
"dev": true
},
"buffer-xor": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz",
"integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk="
},
"bytes": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.0.tgz",
@ -6203,6 +6320,15 @@
"integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==",
"dev": true
},
"cipher-base": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz",
"integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==",
"requires": {
"inherits": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"circular-dependency-plugin": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/circular-dependency-plugin/-/circular-dependency-plugin-5.2.0.tgz",
@ -6796,6 +6922,40 @@
}
}
},
"create-ecdh": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.4.tgz",
"integrity": "sha512-mf+TCx8wWc9VpuxfP2ht0iSISLZnt0JgWlrOKZiNqyUZWnjIaCIVNQArMHnCZKfEYRg6IM7A+NeJoN8gf/Ws0A==",
"requires": {
"bn.js": "^4.1.0",
"elliptic": "^6.5.3"
}
},
"create-hash": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz",
"integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==",
"requires": {
"cipher-base": "^1.0.1",
"inherits": "^2.0.1",
"md5.js": "^1.3.4",
"ripemd160": "^2.0.1",
"sha.js": "^2.4.0"
}
},
"create-hmac": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz",
"integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==",
"requires": {
"cipher-base": "^1.0.3",
"create-hash": "^1.1.0",
"inherits": "^2.0.1",
"ripemd160": "^2.0.0",
"safe-buffer": "^5.0.1",
"sha.js": "^2.4.8"
}
},
"cross-spawn": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-3.0.1.tgz",
@ -6806,6 +6966,29 @@
"which": "^1.2.9"
}
},
"crypto-browserify": {
"version": "3.12.0",
"resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz",
"integrity": "sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==",
"requires": {
"browserify-cipher": "^1.0.0",
"browserify-sign": "^4.0.0",
"create-ecdh": "^4.0.0",
"create-hash": "^1.1.0",
"create-hmac": "^1.1.0",
"diffie-hellman": "^5.0.0",
"inherits": "^2.0.1",
"pbkdf2": "^3.0.3",
"public-encrypt": "^4.0.0",
"randombytes": "^2.0.0",
"randomfill": "^1.0.3"
}
},
"crypto-js": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.0.0.tgz",
"integrity": "sha512-bzHZN8Pn+gS7DQA6n+iUmBfl0hO5DJq++QP3U6uTucDtk/0iGpXd/Gg7CGR0p8tJhofJyaKoWBuJI4eAO00BBg=="
},
"crypto-random-string": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-2.0.0.tgz",
@ -7707,6 +7890,16 @@
"integrity": "sha512-qi86tE6hRcFHy8jI1m2VG+LaPUR1LhqDa5G8tVjuUXmOrpuAgqsA1pN0+ldgr3aKUH+QLI9hCY/OcRYisERejw==",
"dev": true
},
"diffie-hellman": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz",
"integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==",
"requires": {
"bn.js": "^4.1.0",
"miller-rabin": "^4.0.0",
"randombytes": "^2.0.0"
}
},
"dir-glob": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz",
@ -8311,6 +8504,15 @@
"original": "^1.0.0"
}
},
"evp_bytestokey": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz",
"integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==",
"requires": {
"md5.js": "^1.3.4",
"safe-buffer": "^5.1.1"
}
},
"execa": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/execa/-/execa-5.0.0.tgz",
@ -10850,6 +11052,33 @@
"integrity": "sha512-UqBRqi4ju7T+TqGNdqAO0PaSVGsDGJUBQvk9eUWNGRY1CFGDzYhLWoM7JQEemnlvVcv/YEmc2wNW8BC24EnUsw==",
"dev": true
},
"hash-base": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.1.0.tgz",
"integrity": "sha512-1nmYp/rhMDiE7AYkDw+lLwlAzz0AntGIe51F3RfFfEqyQ3feY2eI/NcwC6umIQVOASPMsWJLJScWKSSvzL9IVA==",
"requires": {
"inherits": "^2.0.4",
"readable-stream": "^3.6.0",
"safe-buffer": "^5.2.0"
},
"dependencies": {
"readable-stream": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
"integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
"requires": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
}
},
"safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
}
}
},
"hash.js": {
"version": "1.1.7",
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
@ -11336,6 +11565,11 @@
}
}
},
"ieee754": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
},
"ignore": {
"version": "5.1.8",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.1.8.tgz",
@ -12889,6 +13123,16 @@
"integrity": "sha512-rUxjysqif/BZQH2yhd5Aaq7vXMSx9NdEsQcyA07uEzIvxgI7zIr33gGsh+RU0/XjmQpCW7RsVof1vlkvQVCK5A==",
"optional": true
},
"md5.js": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.5.tgz",
"integrity": "sha512-xitP+WxNPcTTOgnTJcrhM0xvdPepipPSf3I8EIpGKeFLjt3PlJLIDG3u8EX53ZIubkb+5U2+3rELYpEhHhzdkg==",
"requires": {
"hash-base": "^3.0.0",
"inherits": "^2.0.1",
"safe-buffer": "^5.1.2"
}
},
"mdn-data": {
"version": "2.0.6",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.6.tgz",
@ -13059,6 +13303,15 @@
}
}
},
"miller-rabin": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz",
"integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==",
"requires": {
"bn.js": "^4.0.0",
"brorand": "^1.0.1"
}
},
"mime": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
@ -13979,6 +14232,18 @@
}
}
},
"parse-asn1": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.6.tgz",
"integrity": "sha512-RnZRo1EPU6JBnra2vGHj0yhp6ebyjBZpmUCLHWiFhxlzvBCCpAuZ7elsBp1PVAbQN0/04VD/19rfzlBSwLstMw==",
"requires": {
"asn1.js": "^5.2.0",
"browserify-aes": "^1.0.0",
"evp_bytestokey": "^1.0.0",
"pbkdf2": "^3.0.3",
"safe-buffer": "^5.1.1"
}
},
"parse-filepath": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz",
@ -14137,6 +14402,18 @@
"pinkie-promise": "^2.0.0"
}
},
"pbkdf2": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.1.1.tgz",
"integrity": "sha512-4Ejy1OPxi9f2tt1rRV7Go7zmfDQ+ZectEQz3VGUQhgq62HtIRPDyG/JtnwIxs6x3uNMwo2V7q1fMvKjb+Tnpqg==",
"requires": {
"create-hash": "^1.1.2",
"create-hmac": "^1.1.4",
"ripemd160": "^2.0.1",
"safe-buffer": "^5.0.1",
"sha.js": "^2.4.8"
}
},
"performance-now": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
@ -16572,6 +16849,19 @@
"integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==",
"dev": true
},
"public-encrypt": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.3.tgz",
"integrity": "sha512-zVpa8oKZSz5bTMTFClc1fQOnyyEzpl5ozpi1B5YcvBrdohMjH2rfsBtyXcuNuwjsDIXmBYlF2N5FlJYhR29t8Q==",
"requires": {
"bn.js": "^4.1.0",
"browserify-rsa": "^4.0.0",
"create-hash": "^1.1.0",
"parse-asn1": "^5.0.0",
"randombytes": "^2.0.1",
"safe-buffer": "^5.1.2"
}
},
"pump": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz",
@ -16687,11 +16977,19 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
"integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
"dev": true,
"requires": {
"safe-buffer": "^5.1.0"
}
},
"randomfill": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz",
"integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==",
"requires": {
"randombytes": "^2.0.5",
"safe-buffer": "^5.1.0"
}
},
"range-parser": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
@ -17565,6 +17863,15 @@
"glob": "^7.1.3"
}
},
"ripemd160": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz",
"integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==",
"requires": {
"hash-base": "^3.0.0",
"inherits": "^2.0.1"
}
},
"rtcpeerconnection-shim": {
"version": "1.2.15",
"resolved": "https://registry.npmjs.org/rtcpeerconnection-shim/-/rtcpeerconnection-shim-1.2.15.tgz",
@ -17595,8 +17902,7 @@
"safer-buffer": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"sass": {
"version": "1.22.10",
@ -18137,6 +18443,15 @@
"integrity": "sha512-JvdAWfbXeIGaZ9cILp38HntZSFSo3mWg6xGcJJsd+d4aRMOqauag1C63dJfDw7OaMYwEbHMOxEZ1lqVRYP2OAw==",
"dev": true
},
"sha.js": {
"version": "2.4.11",
"resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz",
"integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==",
"requires": {
"inherits": "^2.0.1",
"safe-buffer": "^5.0.1"
}
},
"sha256": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/sha256/-/sha256-0.2.0.tgz",
@ -18599,6 +18914,27 @@
"readable-stream": "^2.0.1"
}
},
"stream-browserify": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-3.0.0.tgz",
"integrity": "sha512-H73RAHsVBapbim0tU2JwwOiXUj+fikfiaoYAKHF3VJfA0pe2BCzkhAHBlLG6REzE+2WNZcxOXjK7lkso+9euLA==",
"requires": {
"inherits": "~2.0.4",
"readable-stream": "^3.5.0"
},
"dependencies": {
"readable-stream": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.0.tgz",
"integrity": "sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==",
"requires": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
}
}
}
},
"stream-events": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/stream-events/-/stream-events-1.0.5.tgz",

View File

@ -8,14 +8,11 @@
"trgen": "node tools/trgen/index.js",
"tsc": "tsc",
"compile-scss": "sass loader/css/index.scss:loader/css/index.css",
"start": "npm run compile-project-base && node file.js ndevelop",
"start": "npm run compile-project-base && webpack serve --config webpack-web.config.js",
"start-client": "npm run compile-project-base && webpack serve --config webpack-client.config.js",
"build-web": "webpack --config webpack-web.config.js",
"build-client": "webpack --config webpack-client.config.js",
"webpack-web": "webpack --config webpack-web.config.js",
"webpack-client": "webpack --config webpack-client.config.js",
"generate-i18n-gtranslate": "node shared/generate_i18n_gtranslate.js",
"dev-server": "npm run compile-project-base && webpack serve --config webpack-web.config.js",
"dev-server-client": "npm run compile-project-base && webpack serve --config webpack-client.config.js"
"generate-i18n-gtranslate": "node shared/generate_i18n_gtranslate.js"
},
"author": "TeaSpeak (WolverinDEV)",
"license": "ISC",
@ -100,7 +97,11 @@
},
"homepage": "https://www.teaspeak.de",
"dependencies": {
"@types/crypto-js": "^4.0.1",
"broadcastchannel-polyfill": "^1.0.1",
"buffer": "^6.0.3",
"crypto-browserify": "^3.12.0",
"crypto-js": "^4.0.0",
"detect-browser": "^5.2.0",
"dompurify": "^2.0.8",
"emoji-mart": "git+https://github.com/WolverinDEV/emoji-mart.git",
@ -118,6 +119,7 @@
"resize-observer-polyfill": "^1.5.1",
"sdp-transform": "^2.14.0",
"simple-jsonp-promise": "^1.1.0",
"stream-browserify": "^3.0.0",
"twemoji": "^13.0.0",
"url-knife": "^3.1.3",
"webcrypto-liner": "^1.2.4",

View File

@ -1009,87 +1009,6 @@ export class ConnectionHandler {
}
}
update_avatar() {
spawnAvatarUpload(data => {
if(typeof(data) === "undefined") {
return;
}
if(!data) {
logInfo(LogCategory.CLIENT, tr("Deleting existing avatar"));
this.serverConnection.send_command('ftdeletefile', {
name: "/avatar_", /* delete own avatar */
path: "",
cid: 0
}).then(() => {
createInfoModal(tr("Avatar deleted"), tr("Avatar successfully deleted")).open();
}).catch(error => {
logError(LogCategory.GENERAL, tr("Failed to reset avatar flag: %o"), error);
let message;
if(error instanceof CommandResult) {
message = formatMessage(tr("Failed to delete avatar.{:br:}Error: {0}"), error.extra_message || error.message);
}
if(!message) {
message = formatMessage(tr("Failed to delete avatar.{:br:}Lookup the console for more details"));
}
createErrorModal(tr("Failed to delete avatar"), message).open();
return;
});
} else {
logInfo(LogCategory.CLIENT, tr("Uploading new avatar"));
(async () => {
const transfer = this.fileManager.initializeFileUpload({
name: "/avatar",
path: "",
channel: 0,
channelPassword: undefined,
source: async () => await TransferProvider.provider().createBufferSource(data)
});
await transfer.awaitFinished();
if(transfer.transferState() !== FileTransferState.FINISHED) {
if(transfer.transferState() === FileTransferState.ERRORED) {
logWarn(LogCategory.FILE_TRANSFER, tr("Failed to upload clients avatar: %o"), transfer.currentError());
createErrorModal(tr("Failed to upload avatar"), traj("Failed to upload avatar:{:br:}{0}", transfer.currentErrorMessage())).open();
return;
} else if(transfer.transferState() === FileTransferState.CANCELED) {
createErrorModal(tr("Failed to upload avatar"), tr("Your avatar upload has been canceled.")).open();
return;
} else {
createErrorModal(tr("Failed to upload avatar"), tr("Avatar upload finished with an unknown finished state.")).open();
return;
}
}
try {
await this.serverConnection.send_command('clientupdate', {
client_flag_avatar: md5(new Uint8Array(data))
});
} catch(error) {
logError(LogCategory.GENERAL, tr("Failed to update avatar flag: %o"), error);
let message;
if(error instanceof CommandResult)
message = formatMessage(tr("Failed to update avatar flag.{:br:}Error: {0}"), error.extra_message || error.message);
if(!message)
message = formatMessage(tr("Failed to update avatar flag.{:br:}Lookup the console for more details"));
createErrorModal(tr("Failed to set avatar"), message).open();
return;
}
createInfoModal(tr("Avatar successfully uploaded"), tr("Your avatar has been uploaded successfully!")).open();
})();
}
});
}
private async initializeWhisperSession(session: WhisperSession) : Promise<WhisperSessionInitializeData> {
/* TODO: Try to load the clients unique via a clientgetuidfromclid */
if(!session.getClientUniqueId())

View File

@ -2,11 +2,21 @@ export class Mutex<T> {
private value: T;
private taskExecuting = false;
private taskQueue = [];
private freeListener = [];
constructor(value: T) {
this.value = value;
}
isFree() : boolean {
return !this.taskExecuting && this.taskQueue.length === 0;
}
awaitFree() : Promise<void> {
return new Promise<void>(resolve => this.freeListener.push(resolve));
}
execute<R>(callback: (value: T, setValue: (newValue: T) => void) => R | Promise<R>) : Promise<R> {
return new Promise<R>((resolve, reject) => {
this.taskQueue.push(() => new Promise(taskResolve => {
@ -53,10 +63,26 @@ export class Mutex<T> {
const task = this.taskQueue.pop_front();
if(typeof task === "undefined") {
this.taskExecuting = false;
this.triggerFinished();
return;
}
this.taskExecuting = true;
task().then(() => this.executeNextTask());
}
private triggerFinished() {
while(this.isFree()) {
const listener = this.freeListener.pop_front();
if(!listener) {
break;
}
try {
listener();
} catch (error) {
console.error(error);
}
}
}
}

View File

@ -57,8 +57,9 @@ export abstract class ClientAvatar {
}
public getTypedStateData<T extends AvatarState>(state: T) : AvatarStateData[T] {
if(this.state !== state)
if(this.state !== state) {
throw "invalid avatar state";
}
return this.stateData as any;
}
@ -79,11 +80,34 @@ export abstract class ClientAvatar {
this.setState("errored", data);
}
async awaitLoaded() {
if(this.state !== "loading")
return;
async awaitLoaded() : Promise<true>;
async awaitLoaded(timeout: number) : Promise<boolean>;
async awaitLoaded(timeout?: number) : Promise<boolean> {
if(this.state !== "loading") {
return true;
}
await new Promise(resolve => this.events.on("avatar_state_changed", event => event.newState !== "loading" && resolve()));
await new Promise(resolve => {
let timeoutId;
const callback = success => {
if(typeof timeoutId !== "undefined") {
clearTimeout(timeoutId);
}
unregister();
resolve(success);
};
const unregister = this.events.on("avatar_state_changed", event => {
if(event.newState !== "loading") {
callback(true);
}
});
if(typeof timeout === "number") {
timeoutId = setTimeout(() => callback(false), timeout);
}
});
}
getState() : AvatarState {
@ -99,8 +123,10 @@ export abstract class ClientAvatar {
}
getAvatarUrl() {
if(this.state === "loaded")
if(this.state === "loaded") {
return this.getTypedStateData("loaded").url || kDefaultAvatarImage;
}
return kDefaultAvatarImage;
}

View File

@ -147,12 +147,17 @@ export class ImageCache {
if(!cacheInstance) { return; }
const new_headers = new Headers();
for(const key of value.headers.keys())
for(const key of value.headers.keys()) {
new_headers.set(key, value.headers.get(key));
if(type)
}
if(type) {
new_headers.set("Content-type", type);
for(const key of Object.keys(headers || {}))
}
for(const key of Object.keys(headers || {})) {
new_headers.set(key, headers[key]);
}
await cacheInstance.put("https://_local_cache/cache_request_" + key, new Response(value.body, {
headers: new_headers

View File

@ -0,0 +1,256 @@
/* Note: This will be included into the controller and renderer process */
import {LogCategory, logError, logWarn} from "tc-shared/log";
import * as loader from "tc-loader";
import {Stage} from "tc-loader";
import * as crypto from "crypto-js";
export type LocalAvatarInfo = {
fileName: string,
fileSize: number,
fileHashMD5: string,
fileUploaded: number,
fileModified: number,
contentType: string,
resourceUrl: string | undefined,
}
export type LocalAvatarUpdateResult = {
status: "success"
} | {
status: "error",
reason: string
} | {
status: "cache-unavailable"
};
export type LocalAvatarLoadResult<T> = {
status: "success",
result: T,
} | {
status: "error",
reason: string
} | {
status: "cache-unavailable" | "empty-result"
};
const kMaxAvatarSize = 16 * 1024 * 1024;
export type OwnAvatarMode = "uploading" | "server";
export class OwnAvatarStorage {
private openedCache: Cache | undefined;
private static generateRequestUrl(serverUniqueId: string, mode: OwnAvatarMode) : string {
return "https://_local_avatar/" + serverUniqueId + "/" + mode;
}
async initialize() {
if(!("caches" in window)) {
/* Not available (may unsecure context?) */
this.openedCache = undefined;
return;
}
try {
this.openedCache = await caches.open("local-avatars");
} catch (error) {
logError(LogCategory.GENERAL, tr("Failed to open local avatar cache: %o"), error);
return;
}
}
private async loadAvatarRequest(serverUniqueId: string, mode: OwnAvatarMode) : Promise<LocalAvatarLoadResult<Response>> {
if(!this.openedCache) {
return { status: "cache-unavailable" };
}
try {
const response = await this.openedCache.match(OwnAvatarStorage.generateRequestUrl(serverUniqueId, mode), {
ignoreMethod: true,
ignoreSearch: true,
});
if(!response) {
return { status: "empty-result" };
}
return { status: "success", result: response };
} catch (error) {
logError(LogCategory.GENERAL, tr("Failed to query local avatar cache: %o"), error);
return { status: "error", reason: tr("failed to query cache") };
}
}
async loadAvatarImage(serverUniqueId: string, mode: OwnAvatarMode) : Promise<LocalAvatarLoadResult<ArrayBuffer>> {
const loadResult = await this.loadAvatarRequest(serverUniqueId, mode);
if(loadResult.status !== "success") {
return loadResult;
}
try {
return { status: "success", result: await loadResult.result.arrayBuffer() };
} catch (error) {
logError(LogCategory.GENERAL, tr("Failed to load avatar into a buffer: %o"), error);
return { status: "error", reason: tr("failed to load avatar into a buffer") };
}
}
async loadAvatar(serverUniqueId: string, mode: OwnAvatarMode, createResourceUrl: boolean) : Promise<LocalAvatarLoadResult<LocalAvatarInfo>> {
const loadResult = await this.loadAvatarRequest(serverUniqueId, mode);
if(loadResult.status !== "success") {
return loadResult;
}
const headers = loadResult.result.headers;
const contentType = headers.get("Content-Type");
const avatarSize = parseInt(headers.get("Content-Length"));
const avatarHash = headers.get("X-Avatar-Hash");
const avatarName = headers.get("X-File-Name");
const avatarDateModified = parseInt(headers.get("X-File-Date-Modified"));
const avatarDateUploaded = parseInt(headers.get("X-File-Uploaded"));
if(!avatarHash) {
return { status: "error", reason: tr("missing response header file hash") };
}
if(!avatarName) {
return { status: "error", reason: tr("missing response header file name") };
}
if(isNaN(avatarSize)) {
return { status: "error", reason: tr("missing/invalid response header file size") };
}
if(isNaN(avatarDateModified)) {
return { status: "error", reason: tr("missing/invalid response header file modify date") };
}
if(isNaN(avatarDateUploaded)) {
return { status: "error", reason: tr("missing/invalid response header file upload date") };
}
let resourceUrl;
if(createResourceUrl) {
try {
resourceUrl = URL.createObjectURL(await loadResult.result.blob());
} catch (error) {
logError(LogCategory.GENERAL, tr("Failed to create avatar resource url: %o"), error);
return { status: "error", reason: tr("failed to generate resource url") };
}
}
return {
status: "success",
result: {
contentType: contentType,
fileName: avatarName,
fileSize: avatarSize,
fileHashMD5: avatarHash,
fileModified: avatarDateModified,
fileUploaded: avatarDateUploaded,
resourceUrl: resourceUrl,
}
};
}
async updateAvatar(serverUniqueId: string, mode: OwnAvatarMode, target: File) : Promise<LocalAvatarUpdateResult> {
if(!this.openedCache) {
return { status: "cache-unavailable" };
}
if(target.size > kMaxAvatarSize) {
return { status: "error", reason: tra("Image exceeds maximum software size of {} bytes", kMaxAvatarSize) };
}
let md5Hash: string;
try {
const hasher = crypto.algo.MD5.create();
await target.stream().pipeTo(new WritableStream({
write(data) {
hasher.update(crypto.lib.WordArray.create(data));
}
}));
md5Hash = hasher.finalize().toString(crypto.enc.Hex);
} catch (error) {
logError(LogCategory.GENERAL, tr("Failed to create avatar md5 hash: %o"), error);
return { status: "error", reason: tr("failed to create md5 hash") };
}
const headers = new Headers();
headers.set("X-Avatar-Hash", md5Hash);
headers.set("X-File-Name", target.name);
headers.set("X-File-Date-Modified", target.lastModified.toString());
headers.set("X-File-Uploaded", Date.now().toString());
headers.set("Content-Type", target.type);
headers.set("Content-Length", target.size.toString());
try {
const response = new Response(target, { headers: headers });
await this.openedCache.put("https://_local_avatar/" + serverUniqueId + "/" + mode, response);
} catch (error) {
logError(LogCategory.GENERAL, tr("Failed to save local avatar: %o"), error);
return { status: "error", reason: tr("failed to save avatar to disk") };
}
return { status: "success" };
}
async removeAvatar(serverUniqueId: string, mode: OwnAvatarMode) {
if(!this.openedCache) {
return;
}
try {
await this.openedCache.delete(OwnAvatarStorage.generateRequestUrl(serverUniqueId, mode));
} catch (error) {
logWarn(LogCategory.GENERAL, tr("Failed to delete avatar request: %o"), error);
}
}
/**
* Move the avatar file which is currently in "uploading" state to server
* @param serverUniqueId
*/
async avatarUploadSucceeded(serverUniqueId: string) {
if(!this.openedCache) {
return;
}
const request = await this.loadAvatarRequest(serverUniqueId, "uploading");
if(request.status !== "success") {
if(request.status !== "empty-result") {
logError(LogCategory.GENERAL, tr("Failed to save uploaded avatar. Request failed to load: %o"), request);
}
return;
}
try {
await this.openedCache.put(OwnAvatarStorage.generateRequestUrl(serverUniqueId, "server"), request.result);
} catch (error) {
logError(LogCategory.GENERAL, tr("Failed to save uploaded avatar. Failed to store request: %o"), request);
return;
}
await this.removeAvatar(serverUniqueId, "uploading");
}
}
let instance: OwnAvatarStorage;
export function getOwnAvatarStorage(): OwnAvatarStorage {
return instance;
}
loader.register_task(Stage.JAVASCRIPT_INITIALIZING, {
priority: 10,
name: "own avatar storage",
function: async () => {
instance = new OwnAvatarStorage();
await instance.initialize();
}
});

View File

@ -17,21 +17,43 @@ export const downloadTextAsFile = (text: string, name: string) => {
}, 0);
};
export const requestFileAsText = async (): Promise<string> => {
export const requestFile = async (options: {
accept?: string,
multiple?: boolean
}): Promise<File[]> => {
const element = document.createElement("input");
element.style.display = "none";
element.type = "file";
if(typeof options.accept === "string") {
element.accept = options.accept;
}
if(typeof options.multiple === "string") {
element.multiple = options.multiple;
}
document.body.appendChild(element);
element.click();
await new Promise(resolve => {
element.onchange = resolve;
});
if (element.files.length !== 1)
return undefined;
const file = element.files[0];
element.remove();
const result = [];
for(let index = 0; index < element.files.length; index++) {
result.push(element.files.item(index));
}
return await file.text();
element.remove();
return result;
}
export const requestFileAsText = async (): Promise<string> => {
const files = await requestFile({ multiple: false });
if(files.length !== 1) {
return undefined;
}
/* FIXME: text() might not be available in Safari */
return await files[0].text();
};

View File

@ -29,6 +29,7 @@ import {ActionResult} from "tc-services";
import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration";
import {ErrorCode} from "tc-shared/connection/ErrorCode";
import {bookmarks} from "tc-shared/Bookmarks";
import {getAudioBackend} from "tc-shared/audio/Player";
/* required import for init */
import "svg-sprites/client-icons";
@ -47,12 +48,12 @@ import "./ui/AppController";
import "./ui/frames/menu-bar/MainMenu";
import "./ui/modal/connect/Controller";
import "./ui/modal/video-viewer/Controller";
import "./ui/modal/avatar-upload/Controller";
import "./ui/elements/ContextDivider";
import "./ui/elements/Tab";
import "./clientservice";
import "./text/bbcode/InviteController";
import "./text/bbcode/YoutubeController";
import {getAudioBackend} from "tc-shared/audio/Player";
assertMainApplication();

View File

@ -3,6 +3,7 @@ import {ClientGroupInfo, ClientInfoEvents,} from "tc-shared/ui/frames/side/Clien
import {Registry} from "tc-shared/events";
import {openClientInfo} from "tc-shared/ui/modal/ModalClientInfo";
import {spawnAvatarUpload} from "tc-shared/ui/modal/avatar-upload/Controller";
export class ClientInfoController {
private readonly uiEvents: Registry<ClientInfoEvents>;
@ -27,7 +28,13 @@ export class ClientInfoController {
this.uiEvents.on("query_version", () => this.sendVersion());
this.uiEvents.on("query_forum", () => this.sendForum());
this.uiEvents.on("action_edit_avatar", () => this.connection?.update_avatar());
this.uiEvents.on("action_edit_avatar", () => {
if(!this.connection?.connected) {
return;
}
spawnAvatarUpload(this.connection);
});
this.uiEvents.on("action_show_full_info", () => {
const client = this.connection?.getSelectedClientInfo().getClient();
if(client) {

View File

@ -0,0 +1,346 @@
import {ConnectionHandler, ConnectionState} from "tc-shared/ConnectionHandler";
import {Registry} from "tc-events";
import {ModalAvatarUploadEvents, ModalAvatarUploadVariables} from "tc-shared/ui/modal/avatar-upload/Definitions";
import {IpcUiVariableProvider} from "tc-shared/ui/utils/IpcVariable";
import {spawnModal} from "tc-shared/ui/react-elements/modal";
import {server_connections} from "tc-shared/ConnectionManager";
import PermissionType from "tc-shared/permission/PermissionType";
import {getOwnAvatarStorage, LocalAvatarInfo} from "tc-shared/file/OwnAvatarStorage";
import {LogCategory, logError, logInfo, logWarn} from "tc-shared/log";
import {Mutex} from "tc-shared/Mutex";
import {tr, traj} from "tc-shared/i18n/localize";
import {createErrorModal, createInfoModal} from "tc-shared/ui/elements/Modal";
import {CommandResult} from "tc-shared/connection/ServerConnectionDeclaration";
import {formatMessage} from "tc-shared/ui/frames/chat";
import {FileTransferState, TransferProvider} from "tc-shared/file/Transfer";
class Controller {
readonly connection: ConnectionHandler;
readonly serverUniqueId: string;
readonly events: Registry<ModalAvatarUploadEvents>;
readonly variables: IpcUiVariableProvider<ModalAvatarUploadVariables>;
private registeredListener: (() => void)[];
private rendererUploading = false;
private controllerLoading = false;
private serverAvatarLoading = false;
private avatarInfo: LocalAvatarInfo | undefined;
/* TODO: Update the UI so the client can't upload multiple avatars at the same time (maybe even server wide?) */
private serverAvatarMutex: Mutex<void>;
private serverAvatarUrl: string;
constructor(connection: ConnectionHandler) {
this.connection = connection;
this.serverUniqueId = connection.getCurrentServerUniqueId();
this.registeredListener = [];
this.serverAvatarMutex = new Mutex<void>(void 0);
this.events = new Registry<ModalAvatarUploadEvents>();
this.variables = new IpcUiVariableProvider<ModalAvatarUploadVariables>();
this.variables.setVariableProvider("maxAvatarSize", () => {
const permission = this.connection.permissions.neededPermission(PermissionType.I_CLIENT_MAX_AVATAR_FILESIZE);
return permission.valueOr(-1);
});
this.variables.setVariableProvider("currentAvatar", () => {
if(this.rendererUploading || this.controllerLoading || this.serverAvatarLoading) {
return { status: "loading" };
}
if(this.avatarInfo) {
const maxSize = this.variables.getVariableSync("maxAvatarSize");
return {
status: maxSize >= 0 && maxSize < this.avatarInfo.fileSize ? "exceeds-max-size" : "available",
fileHashMD5: this.avatarInfo.fileHashMD5,
fileName: this.avatarInfo.fileName,
fileSize: this.avatarInfo.fileSize,
resourceUrl: this.avatarInfo.resourceUrl,
serverHasAvatar: this.serverAvatarUrl !== undefined
};
} else if(this.serverAvatarUrl) {
return { status: "server", resourceUrl: this.serverAvatarUrl };
} else {
return { status: "unset" };
}
});
this.registeredListener.push(this.connection.permissions.register_needed_permission(PermissionType.I_CLIENT_MAX_AVATAR_FILESIZE, () => {
this.variables.sendVariable("maxAvatarSize");
this.variables.sendVariable("currentAvatar");
}));
this.events.on("action_file_cache_loading", () => {
this.rendererUploading = true;
this.variables.sendVariable("currentAvatar");
});
this.events.on("action_file_cache_loading_finished", event => {
this.rendererUploading = false;
if(!event.success) {
/* Failed to update local avatar. Send the last one. */
this.variables.sendVariable("currentAvatar");
return;
}
this.loadLocalAvatar();
});
this.loadServerAvatar();
}
destroy() {
this.registeredListener.forEach(callback => callback());
this.registeredListener = [];
this.events.destroy();
this.variables.destroy();
this.setAvatarInfo(undefined);
this.serverAvatarMutex.execute(async () => {
/* Cleanup the cache so the next upload will be fresh */
await getOwnAvatarStorage().removeAvatar(this.serverUniqueId, "uploading");
}).then(undefined);
}
private loadLocalAvatar() {
if(this.controllerLoading) {
return;
}
this.loadLocalAvatar0().catch(error => {
logError(LogCategory.GENERAL, tr("Failed to load local cached avatar: %o"), error);
this.events.fire("notify_avatar_load_error", { error: tr("Failed to load local cached avatar") });
}).then(() => {
this.controllerLoading = false;
this.variables.sendVariable("currentAvatar");
});
}
private async loadLocalAvatar0() {
const result = await getOwnAvatarStorage().loadAvatar(this.serverUniqueId, "uploading", true);
let info: LocalAvatarInfo;
switch (result.status) {
case "success":
info = result.result;
break;
case "error":
this.events.fire("notify_avatar_load_error", {error: result.reason});
return;
case "cache-unavailable":
this.events.fire("notify_avatar_load_error", {error: tr("Avatar cache unavailable")});
return;
case "empty-result":
this.setAvatarInfo(undefined);
return;
default:
throw tr("invalid state");
}
this.setAvatarInfo(info);
}
private loadServerAvatar() {
if(this.serverAvatarLoading) {
return;
}
this.serverAvatarUrl = undefined;
this.loadServerAvatar0().catch(error => {
logError(LogCategory.GENERAL, tr("Failed to load server avatar: %o"), error);
}).then(() => {
this.serverAvatarLoading = false;
this.variables.sendVariable("currentAvatar");
})
}
private async loadServerAvatar0() {
const ownClientAvatar = this.connection.fileManager.avatars.resolveAvatar(this.connection.getClient().avatarId());
await ownClientAvatar.awaitLoaded(5000);
if(ownClientAvatar.getState() !== "loaded") {
return;
}
this.serverAvatarUrl = ownClientAvatar.getTypedStateData("loaded").url;
}
/**
* Note: This will not trigger the "currentAvatar" variable resend!
* @param newInfo
* @private
*/
private setAvatarInfo(newInfo: LocalAvatarInfo) {
if(this.avatarInfo?.resourceUrl) {
URL.revokeObjectURL(this.avatarInfo.resourceUrl);
}
this.avatarInfo = newInfo;
}
resetAvatar() {
this.serverAvatarMutex.execute(async () => {
this.setAvatarInfo(undefined);
await getOwnAvatarStorage().removeAvatar(this.serverUniqueId, "uploading");
const serverConnection = this.connection.serverConnection;
if(!serverConnection.connected()) {
return;
}
try {
await serverConnection.send_command('ftdeletefile', {
name: "/avatar_", /* delete own avatar */
path: "",
cid: 0
});
createInfoModal(tr("Avatar deleted"), tr("Avatar successfully deleted")).open();
} catch (error) {
logError(LogCategory.GENERAL, tr("Failed to reset avatar flag: %o"), error);
let message;
if(error instanceof CommandResult) {
message = formatMessage(tr("Failed to delete avatar.{:br:}Error: {0}"), error.formattedMessage());
}
if(!message) {
message = formatMessage(tr("Failed to delete avatar.{:br:}Lookup the console for more details"));
}
createErrorModal(tr("Failed to delete avatar"), message).open();
return;
}
this.loadServerAvatar();
});
}
uploadAvatar() {
/* copy the avatar info */
const avatarInfo = this.avatarInfo;
this.serverAvatarMutex.execute(async() => {
const serverConnection = this.connection.serverConnection;
if(!serverConnection.connected()) {
return;
}
if(!avatarInfo) {
return;
}
try {
logInfo(LogCategory.CLIENT, tr("Uploading new avatar"));
const loadResult = await getOwnAvatarStorage().loadAvatarImage(this.serverUniqueId, "uploading");
if (loadResult.status !== "success") {
logError(LogCategory.GENERAL, tr("Failed to load cached avatar image: %o"), loadResult);
throw tr("failed to load avatar image");
}
const transfer = this.connection.fileManager.initializeFileUpload({
name: "/avatar",
path: "",
channel: 0,
channelPassword: undefined,
source: async () => await TransferProvider.provider().createBufferSource(loadResult.result)
});
await transfer.awaitFinished();
if (transfer.transferState() !== FileTransferState.FINISHED) {
if (transfer.transferState() === FileTransferState.ERRORED) {
logWarn(LogCategory.FILE_TRANSFER, tr("Failed to upload clients avatar: %o"), transfer.currentError());
createErrorModal(tr("Failed to upload avatar"), traj("Failed to upload avatar:{:br:}{0}", transfer.currentErrorMessage())).open();
return;
} else if (transfer.transferState() === FileTransferState.CANCELED) {
createErrorModal(tr("Failed to upload avatar"), tr("Your avatar upload has been canceled.")).open();
return;
} else {
createErrorModal(tr("Failed to upload avatar"), tr("Avatar upload finished with an unknown finished state.")).open();
return;
}
}
} catch (error) {
logError(LogCategory.GENERAL, tr("Failed to upload avatar: %o"), error);
createErrorModal(tr("Failed to upload avatar"), tr("Avatar upload failed. Lookup the console for more details.")).open();
return;
}
try {
await this.connection.serverConnection.send_command('clientupdate', {
client_flag_avatar: avatarInfo.fileHashMD5
});
} catch(error) {
logError(LogCategory.GENERAL, tr("Failed to update avatar flag: %o"), error);
let message;
if(error instanceof CommandResult) {
message = formatMessage(tr("Failed to update avatar flag.{:br:}Error: {0}"), error.formattedMessage());
}
if(!message) {
message = formatMessage(tr("Failed to update avatar flag.{:br:}Lookup the console for more details"));
}
createErrorModal(tr("Failed to set avatar"), message).open();
return;
}
createInfoModal(tr("Avatar successfully uploaded"), tr("Your avatar has been uploaded successfully!")).open();
this.loadServerAvatar();
});
}
}
export function spawnAvatarUpload(connection: ConnectionHandler) {
const controller = new Controller(connection);
const modal = spawnModal("modal-avatar-upload", [
controller.events.generateIpcDescription(),
controller.variables.generateConsumerDescription(),
connection.getCurrentServerUniqueId()
], {
popoutable: true
});
controller.events.on("action_avatar_upload", event => {
controller.uploadAvatar();
if(event.closeWindow) {
modal.destroy();
}
});
controller.events.on("action_avatar_delete", event => {
controller.resetAvatar();
if(event.closeWindow) {
modal.destroy();
}
});
modal.getEvents().on("destroy", () => controller.destroy());
modal.getEvents().on("destroy", connection.events().on("notify_connection_state_changed", event => {
if(event.newState !== ConnectionState.CONNECTED) {
modal.destroy();
}
}));
modal.show().then(undefined);
/* Trying to prompt the user */
controller.events.fire("action_open_select");
}
(window as any).test = () => spawnAvatarUpload(server_connections.getActiveConnectionHandler());
setTimeout(() => (window as any).test(), 1500);

View File

@ -0,0 +1,33 @@
export type CurrentAvatarState = {
status: "unset" | "loading"
} | {
status: "available" | "exceeds-max-size",
fileName: string,
fileSize: number,
fileHashMD5: string,
resourceUrl: string | undefined,
serverHasAvatar: boolean
} | {
status: "server",
resourceUrl: string
};
export interface ModalAvatarUploadVariables {
readonly maxAvatarSize: number,
readonly currentAvatar: CurrentAvatarState
}
export interface ModalAvatarUploadEvents {
action_open_select: {},
action_file_cache_loading: {},
action_file_cache_loading_finished: { success: boolean },
action_avatar_upload: { closeWindow: boolean },
action_avatar_delete: { closeWindow: boolean }
notify_avatar_load_error: { error: string }
}

View File

@ -0,0 +1,132 @@
@import "../../../../css/static/mixin";
@import "../../../../css/static/properties";
.container {
display: flex;
flex-direction: column;
padding: 1em;
width: 30em;
&.windowed {
height: 100%;
width: 100%;
}
}
.containerSelect {
display: flex;
flex-direction: column;
.containerFile {
display: flex;
flex-direction: row;
justify-content: flex-start;
.button {
margin-right: .5em;
}
.name {
align-self: center;
@include text-dotdotdot();
}
}
.containerLimit {
margin-top: .1em;
margin-left: .25em;
font-size: .9em;
color: #666;
&.error {
color: #a32929;
}
}
}
.containerPreview {
display: flex;
flex-direction: column;
margin-top: 1em;
.title {
color: #557edc;
text-transform: uppercase;
}
.previews {
margin-top: .5em;
display: flex;
flex-direction: row;
justify-content: space-evenly;
}
}
.preview {
display: flex;
flex-direction: column;
justify-content: flex-end;
width: 10em;
.imageContainer {
width: 1em;
height: 1em;
border-radius: 50%;
display: flex;
flex-direction: column;
justify-content: center;
overflow: hidden;
align-self: center;
//background: #353535;
box-shadow: inset 0 0 5px #00000040;
.image {
align-self: center;
max-height: 100%;
max-width: 100%;
&.heightBound {
height: 100%;
}
&.widthBound {
width: 100%;
}
}
&.sizeChatInfo {
height: 10em;
width: 10em;
}
&.sizeChat {
height: 2.5em;
width: 2.5em;
}
}
.name {
margin-top: 1em;
text-align: center;
}
}
.buttons {
display: flex;
flex-direction: row;
justify-content: space-between;
padding-top: 1em;
margin-top: auto;
}

View File

@ -0,0 +1,336 @@
import {AbstractModal} from "tc-shared/ui/react-elements/modal/Definitions";
import React, {useContext} from "react";
import {Translatable, VariadicTranslatable} from "tc-shared/ui/react-elements/i18n";
import {IpcRegistryDescription, Registry} from "tc-events";
import {
CurrentAvatarState,
ModalAvatarUploadEvents,
ModalAvatarUploadVariables
} from "tc-shared/ui/modal/avatar-upload/Definitions";
import {UiVariableConsumer} from "tc-shared/ui/utils/Variable";
import {createIpcUiVariableConsumer, IpcVariableDescriptor} from "tc-shared/ui/utils/IpcVariable";
import {Button} from "tc-shared/ui/react-elements/Button";
import {requestFile} from "tc-shared/file/Utils";
import {network} from "tc-shared/ui/frames/chat";
import {LoadingDots} from "tc-shared/ui/react-elements/LoadingDots";
import {getOwnAvatarStorage} from "tc-shared/file/OwnAvatarStorage";
import {createErrorModal} from "tc-shared/ui/elements/Modal";
import {joinClassList, useDependentState} from "tc-shared/ui/react-elements/Helper";
import kDefaultAvatarUrl from "../../../../img/style/avatar.png";
import byteSizeToString = network.byteSizeToString;
const ServerUniqueIdContext = React.createContext<string>(undefined);
const EventContext = React.createContext<Registry<ModalAvatarUploadEvents>>(undefined);
const VariablesContext = React.createContext<UiVariableConsumer<ModalAvatarUploadVariables>>(undefined);
const CurrentAvatarContext = React.createContext<CurrentAvatarState>(undefined);
const cssStyle = require("./Renderer.scss");
const AvatarFileName = React.memo(() => {
const currentAvatar = useContext(CurrentAvatarContext);
switch (currentAvatar.status) {
case "loading":
return (
<div className={cssStyle.name} key={"loading"}>
<Translatable>Loading avatar</Translatable>
</div>
);
case "server":
case "unset":
return (
<div className={cssStyle.name} key={"unset"}>
<Translatable>No avatar selected</Translatable>
</div>
);
case "available":
case "exceeds-max-size":
return (
<div className={cssStyle.name} key={"available"}>
{currentAvatar.fileName}
</div>
);
default:
throw "invalid avatar state";
}
});
const SizeLimitRenderer = React.memo((props: { byteSize: number }) => {
if(props.byteSize === -1) {
return <Translatable key={"unlimited"}>unlimited</Translatable>;
} else {
return <React.Fragment key={"limited"}>{byteSizeToString(props.byteSize)}</React.Fragment>;
}
});
const MaxAvatarSize = React.memo(() => {
const variables = useContext(VariablesContext);
const currentAvatar = useContext(CurrentAvatarContext);
const maxSize = variables.useReadOnly("maxAvatarSize", undefined);
if(currentAvatar.status === "loading" || maxSize.status === "loading") {
return (
<div className={cssStyle.containerLimit} key={"loading"}>
<Translatable>Maximal avatar size:</Translatable> <Translatable>loading</Translatable> <LoadingDots />
</div>
)
} else if(currentAvatar.status === "unset" || currentAvatar.status === "server") {
return (
<div className={cssStyle.containerLimit} key={"unset"}>
<VariadicTranslatable text={"Maximal avatar size: {}"}>
<SizeLimitRenderer byteSize={maxSize.value} />
</VariadicTranslatable>
</div>
);
} else if(currentAvatar.status === "available") {
return (
<div className={cssStyle.containerLimit} key={"size-ok"}>
<VariadicTranslatable text={"Avatar size: {} / {}"}>
{byteSizeToString(currentAvatar.fileSize)}
<SizeLimitRenderer byteSize={maxSize.value} />
</VariadicTranslatable>
</div>
);
} else if(currentAvatar.status === "exceeds-max-size") {
return (
<div className={cssStyle.containerLimit + " " + cssStyle.error} key={"unset"}>
<VariadicTranslatable text={"Avatar {} exceeds allowed size of {}"}>
{byteSizeToString(currentAvatar.fileSize)}
{maxSize.value === -1 ? <Translatable key={"unlimited"}>unlimited</Translatable> : byteSizeToString(maxSize.value)}
</VariadicTranslatable>
</div>
);
} else {
throw "invalid avatar state";
}
})
const AvatarSelect = React.memo(() => {
const currentAvatar = useContext(CurrentAvatarContext);
const events = useContext(EventContext);
return (
<div className={cssStyle.containerSelect}>
<div className={cssStyle.containerFile}>
<Button
onClick={() => events.fire("action_open_select")}
className={cssStyle.button}
disabled={currentAvatar.status === "loading"}
>
<Translatable>Select avatar</Translatable>
</Button>
<AvatarFileName />
</div>
<MaxAvatarSize />
</div>
)
});
const AvatarPreview = React.memo((props: { size: "client-info" | "chat" }) => {
const currentAvatar = useContext(CurrentAvatarContext);
let sizeClass;
let name;
switch (props.size) {
case "client-info":
sizeClass = cssStyle.sizeChatInfo;
name = <Translatable key={"client-info"}>Client info</Translatable>;
break;
case "chat":
sizeClass = cssStyle.sizeChat;
name = <Translatable key={"chat-avatar"}>Chat Avatar</Translatable>;
break;
}
let imageUrl;
if(currentAvatar.status === "available" || currentAvatar.status === "exceeds-max-size") {
imageUrl = currentAvatar.resourceUrl;
} else if(currentAvatar.status === "server") {
imageUrl = currentAvatar.resourceUrl;
} else {
imageUrl = kDefaultAvatarUrl;
}
const [ boundClass, setBoundClass ] = useDependentState(() => undefined, [ imageUrl ]);
return (
<div className={cssStyle.preview}>
<div className={cssStyle.imageContainer + " " + sizeClass}>
<img
className={joinClassList(cssStyle.image, boundClass)}
src={imageUrl}
alt={""}
onLoad={event => {
const imageElement = event.currentTarget;
if(imageElement.naturalHeight > imageElement.naturalWidth) {
setBoundClass(cssStyle.heightBound);
} else {
setBoundClass(cssStyle.widthBound);
}
}}
/>
</div>
<div className={cssStyle.name}>
{name}
</div>
</div>
)
});
const PreviewTitle = React.memo(() => {
const currentAvatar = useContext(CurrentAvatarContext);
if(currentAvatar.status === "server") {
return <Translatable key={"server"}>Current Avatar</Translatable>;
} else {
return <Translatable key={"general"}>Preview</Translatable>;
}
});
const AvatarPreviewContainer = React.memo(() => (
<div className={cssStyle.containerPreview}>
<div className={cssStyle.title}><PreviewTitle /></div>
<div className={cssStyle.previews}>
<AvatarPreview size={"client-info"} />
<AvatarPreview size={"chat"} />
</div>
</div>
));
const Buttons = React.memo(() => {
const events = useContext(EventContext);
const currentAvatar = useContext(CurrentAvatarContext);
let enableReset, enableUpload;
switch (currentAvatar.status) {
case "exceeds-max-size":
enableReset = currentAvatar.serverHasAvatar;
enableUpload = false;
break;
case "loading":
case "unset":
enableReset = false;
enableUpload = false;
break;
case "available":
enableReset = true;
enableUpload = true;
break;
case "server":
enableReset = true;
enableUpload = false;
break;
}
return (
<div className={cssStyle.buttons}>
<Button
color={"red"}
onClick={event => events.fire("action_avatar_delete", { closeWindow: !event.shiftKey })}
disabled={!enableReset}
>
<Translatable>Delete Avatar</Translatable>
</Button>
<Button
color={"green"}
onClick={event => events.fire("action_avatar_upload", { closeWindow: !event.shiftKey })}
disabled={!enableUpload}
>
<Translatable>Update avatar</Translatable>
</Button>
</div>
)
});
const CurrentAvatarProvider = React.memo((props: { children }) => {
const variables = useContext(VariablesContext);
const avatar = variables.useReadOnly("currentAvatar", undefined, { status: "loading" });
return (
<CurrentAvatarContext.Provider value={avatar}>
{props.children}
</CurrentAvatarContext.Provider>
);
});
class ModalAvatarUpload extends AbstractModal {
private readonly serverUniqueId: string;
private readonly events: Registry<ModalAvatarUploadEvents>;
private readonly variables: UiVariableConsumer<ModalAvatarUploadVariables>;
constructor(events: IpcRegistryDescription<ModalAvatarUploadEvents>, variables: IpcVariableDescriptor<ModalAvatarUploadVariables>, serverUniqueId: string) {
super();
this.serverUniqueId = serverUniqueId;
this.events = Registry.fromIpcDescription(events);
this.variables = createIpcUiVariableConsumer(variables);
this.events.on("notify_avatar_load_error", event => {
createErrorModal(tr("Failed to load avatar"), event.error).open();
});
this.events.on("action_open_select", async () => {
this.events.fire("action_file_cache_loading");
const files = await requestFile({
multiple: false,
accept: ".svg, .png, .jpg, .jpeg, gif"
});
if(files.length !== 1) {
this.events.fire("action_file_cache_loading_finished", { success: false });
return;
}
const result = await getOwnAvatarStorage().updateAvatar(serverUniqueId, "uploading", files[0]);
let succeeded;
if(result.status === "success") {
succeeded = true;
} else if(result.status === "error") {
createErrorModal(tr("Failed to load avatar"), tra("Failed to load avatar: {}", result.reason)).open();
succeeded = false;
} else if(result.status === "cache-unavailable") {
createErrorModal(tr("Failed to load avatar"), tra("Failed to load avatar:{:br:}Own avatar cach unavailable.")).open();
succeeded = false;
} else {
succeeded = false;
}
this.events.fire("action_file_cache_loading_finished", { success: succeeded });
});
}
protected onDestroy() {
super.onDestroy();
this.events.destroy();
this.variables.destroy();
}
renderBody(): React.ReactElement {
return (
<EventContext.Provider value={this.events}>
<VariablesContext.Provider value={this.variables}>
<ServerUniqueIdContext.Provider value={this.serverUniqueId}>
<CurrentAvatarProvider>
<div className={cssStyle.container + " " + (this.properties.windowed ? cssStyle.windowed : "")}>
<AvatarSelect />
<AvatarPreviewContainer />
<Buttons />
</div>
</CurrentAvatarProvider>
</ServerUniqueIdContext.Provider>
</VariablesContext.Provider>
</EventContext.Provider>
);
}
renderTitle(): string | React.ReactElement {
return <Translatable>Avatar upload</Translatable>;
}
}
export default ModalAvatarUpload;

View File

@ -19,6 +19,7 @@ import {VideoViewerEvents} from "tc-shared/ui/modal/video-viewer/Definitions";
import {PermissionModalEvents} from "tc-shared/ui/modal/permission/ModalDefinitions";
import {PermissionEditorEvents} from "tc-shared/ui/modal/permission/EditorDefinitions";
import {PermissionEditorServerInfo} from "tc-shared/ui/modal/permission/ModalRenderer";
import {ModalAvatarUploadEvents, ModalAvatarUploadVariables} from "tc-shared/ui/modal/avatar-upload/Definitions";
export type ModalType = "error" | "warning" | "info" | "none";
export type ModalRenderType = "page" | "dialog";
@ -204,5 +205,10 @@ export interface ModalConstructorArguments {
/* serverInfo */ PermissionEditorServerInfo,
/* modalEvents */ IpcRegistryDescription<PermissionModalEvents>,
/* editorEvents */ IpcRegistryDescription<PermissionEditorEvents>
],
"modal-avatar-upload": [
/* events */ IpcRegistryDescription<ModalAvatarUploadEvents>,
/* variables */ IpcVariableDescriptor<ModalAvatarUploadVariables>,
/* serverUniqueId */ string
]
}

View File

@ -103,4 +103,10 @@ registerModal({
popoutSupported: true
});
registerModal({
modalId: "modal-avatar-upload",
classLoader: async () => await import("tc-shared/ui/modal/avatar-upload/Renderer"),
popoutSupported: true
});

View File

@ -330,6 +330,11 @@ export const config = async (env: any, target: "web" | "client"): Promise<Config
"tc-events": path.resolve(__dirname, "vendor/TeaEventBus/src/index.ts"),
"tc-services": path.resolve(__dirname, "vendor/TeaClientServices/src/index.ts"),
},
fallback: {
stream: "stream-browserify",
crypto: "crypto-browserify",
buffer: "buffer"
}
},
externals: [
{"tc-loader": "window loader"}