diff --git a/assets/package-lock.json b/assets/package-lock.json index f5cc3716..16392e94 100644 --- a/assets/package-lock.json +++ b/assets/package-lock.json @@ -6,7 +6,7 @@ "": { "license": "MIT", "dependencies": { - "@jellyfish-dev/membrane-webrtc-js": "^0.4.5", + "@jellyfish-dev/membrane-webrtc-js": "^0.4.6", "chartist": "^1.3.0", "clsx": "^1.2.1", "date-fns": "^2.29.3", @@ -17,9 +17,11 @@ "ramda": "^0.29.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-modal": "^3.16.1", "react-page-visibility": "^7.0.0", "react-resize-detector": "^8.0.4", "react-router-dom": "^6.4.2", + "react-select": "^5.7.0", "uuid": "^8.3.2" }, "devDependencies": { @@ -28,12 +30,13 @@ "@types/ramda": "^0.29.0", "@types/react": "^18.0.21", "@types/react-dom": "^18.0.6", + "@types/react-modal": "^3.16.0", "@types/react-page-visibility": "^6.4.1", "@types/uuid": "^8.3.4", - "@typescript-eslint/eslint-plugin": "^5.40.1", - "@typescript-eslint/parser": "^5.40.1", + "@typescript-eslint/eslint-plugin": "^5.59.1", + "@typescript-eslint/parser": "^5.59.1", "autoprefixer": "^10.4.13", - "eslint": "^8.25.0", + "eslint": "^8.39.0", "eslint-plugin-react": "^7.31.10", "eslint-plugin-react-hooks": "^4.6.0", "playwright": "1.17", @@ -41,7 +44,8 @@ "postcss-import": "^14.0.2", "prettier": "^2.8.3", "prettier-plugin-tailwindcss": "^0.2.1", - "tailwindcss": "^3.2.7" + "tailwindcss": "^3.2.7", + "typescript": "^5.0.4" } }, "../deps/phoenix": { @@ -51,6 +55,180 @@ "../deps/phoenix_html": { "version": "3.3.1" }, + "node_modules/@babel/code-frame": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.18.6.tgz", + "integrity": "sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==", + "dependencies": { + "@babel/highlight": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz", + "integrity": "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==", + "dependencies": { + "@babel/types": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.19.4", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz", + "integrity": "sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.19.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz", + "integrity": "sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/highlight": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.18.6.tgz", + "integrity": "sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g==", + "dependencies": { + "@babel/helper-validator-identifier": "^7.18.6", + "chalk": "^2.0.0", + "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==", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/@babel/runtime": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.21.0.tgz", + "integrity": "sha512-xwII0//EObnq89Ji5AKYQaRYiW/nZ3llSv29d49IuxPhKbtJoLP+9QUUZ4nVragQVtaVGeZrpB+ZtG/Pdy/POw==", + "dependencies": { + "regenerator-runtime": "^0.13.11" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.21.3", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.21.3.tgz", + "integrity": "sha512-sBGdETxC+/M4o/zKC0sl6sjWv62WFR/uzxrJ6uYyMLZOUlPnwzw0tKgVHOXxaAd5l2g8pEDM5RZ495GPQI77kg==", + "dependencies": { + "@babel/helper-string-parser": "^7.19.4", + "@babel/helper-validator-identifier": "^7.19.1", + "to-fast-properties": "^2.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@emotion/babel-plugin": { + "version": "11.10.6", + "resolved": "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.10.6.tgz", + "integrity": "sha512-p2dAqtVrkhSa7xz1u/m9eHYdLi+en8NowrmXeF/dKtJpU8lCWli8RUAati7NcSl0afsBott48pdnANuD0wh9QQ==", + "dependencies": { + "@babel/helper-module-imports": "^7.16.7", + "@babel/runtime": "^7.18.3", + "@emotion/hash": "^0.9.0", + "@emotion/memoize": "^0.8.0", + "@emotion/serialize": "^1.1.1", + "babel-plugin-macros": "^3.1.0", + "convert-source-map": "^1.5.0", + "escape-string-regexp": "^4.0.0", + "find-root": "^1.1.0", + "source-map": "^0.5.7", + "stylis": "4.1.3" + } + }, + "node_modules/@emotion/cache": { + "version": "11.10.5", + "resolved": "https://registry.npmjs.org/@emotion/cache/-/cache-11.10.5.tgz", + "integrity": "sha512-dGYHWyzTdmK+f2+EnIGBpkz1lKc4Zbj2KHd4cX3Wi8/OWr5pKslNjc3yABKH4adRGCvSX4VDC0i04mrrq0aiRA==", + "dependencies": { + "@emotion/memoize": "^0.8.0", + "@emotion/sheet": "^1.2.1", + "@emotion/utils": "^1.2.0", + "@emotion/weak-memoize": "^0.3.0", + "stylis": "4.1.3" + } + }, + "node_modules/@emotion/hash": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.9.0.tgz", + "integrity": "sha512-14FtKiHhy2QoPIzdTcvh//8OyBlknNs2nXRwIhG904opCby3l+9Xaf/wuPvICBF0rc1ZCNBd3nKe9cd2mecVkQ==" + }, "node_modules/@emotion/is-prop-valid": { "version": "0.8.8", "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz", @@ -60,20 +238,113 @@ "@emotion/memoize": "0.7.4" } }, - "node_modules/@emotion/memoize": { + "node_modules/@emotion/is-prop-valid/node_modules/@emotion/memoize": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz", "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==", "optional": true }, + "node_modules/@emotion/memoize": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.8.0.tgz", + "integrity": "sha512-G/YwXTkv7Den9mXDO7AhLWkE3q+I92B+VqAE+dYG4NGPaHZGvt3G8Q0p9vmE+sq7rTGphUbAvmQ9YpbfMQGGlA==" + }, + "node_modules/@emotion/react": { + "version": "11.10.6", + "resolved": "https://registry.npmjs.org/@emotion/react/-/react-11.10.6.tgz", + "integrity": "sha512-6HT8jBmcSkfzO7mc+N1L9uwvOnlcGoix8Zn7srt+9ga0MjREo6lRpuVX0kzo6Jp6oTqDhREOFsygN6Ew4fEQbw==", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@emotion/babel-plugin": "^11.10.6", + "@emotion/cache": "^11.10.5", + "@emotion/serialize": "^1.1.1", + "@emotion/use-insertion-effect-with-fallbacks": "^1.0.0", + "@emotion/utils": "^1.2.0", + "@emotion/weak-memoize": "^0.3.0", + "hoist-non-react-statics": "^3.3.1" + }, + "peerDependencies": { + "react": ">=16.8.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@emotion/serialize": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@emotion/serialize/-/serialize-1.1.1.tgz", + "integrity": "sha512-Zl/0LFggN7+L1liljxXdsVSVlg6E/Z/olVWpfxUTxOAmi8NU7YoeWeLfi1RmnB2TATHoaWwIBRoL+FvAJiTUQA==", + "dependencies": { + "@emotion/hash": "^0.9.0", + "@emotion/memoize": "^0.8.0", + "@emotion/unitless": "^0.8.0", + "@emotion/utils": "^1.2.0", + "csstype": "^3.0.2" + } + }, + "node_modules/@emotion/sheet": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.2.1.tgz", + "integrity": "sha512-zxRBwl93sHMsOj4zs+OslQKg/uhF38MB+OMKoCrVuS0nyTkqnau+BM3WGEoOptg9Oz45T/aIGs1qbVAsEFo3nA==" + }, + "node_modules/@emotion/unitless": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.8.0.tgz", + "integrity": "sha512-VINS5vEYAscRl2ZUDiT3uMPlrFQupiKgHz5AA4bCH1miKBg4qtwkim1qPmJj/4WG6TreYMY111rEFsjupcOKHw==" + }, + "node_modules/@emotion/use-insertion-effect-with-fallbacks": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.0.tgz", + "integrity": "sha512-1eEgUGmkaljiBnRMTdksDV1W4kUnmwgp7X9G8B++9GYwl1lUdqSndSriIrTJ0N7LQaoauY9JJ2yhiOYK5+NI4A==", + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/@emotion/utils": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@emotion/utils/-/utils-1.2.0.tgz", + "integrity": "sha512-sn3WH53Kzpw8oQ5mgMmIzzyAaH2ZqFEbozVVBSYp538E06OSE6ytOp7pRAjNQR+Q/orwqdQYJSe2m3hCOeznkw==" + }, + "node_modules/@emotion/weak-memoize": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.3.0.tgz", + "integrity": "sha512-AHPmaAx+RYfZz0eYu6Gviiagpmiyw98ySSlQvCUhVGDRtDFe4DBS0x1bSjdF3gqUDYOczB+yYvBTtEylYSdRhg==" + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", + "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", + "dev": true, + "dependencies": { + "eslint-visitor-keys": "^3.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.5.0.tgz", + "integrity": "sha512-vITaYzIcNmjn5tF5uxcZ/ft7/RXGrMUIS9HalWckEOF6ESiwXKoMzAQf2UW0aVd6rnOeExTJVd5hmWXucBKGXQ==", + "dev": true, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, "node_modules/@eslint/eslintrc": { - "version": "1.4.1", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.0.2.tgz", + "integrity": "sha512-3W4f5tDUra+pA+FzgugqL2pRimUTDJWKr7BINqOpkZrC0uYI0NIc0/JFgBROCU07HR6GieA5m3/rsPIhDmCXTQ==", "dev": true, - "license": "MIT", "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", - "espree": "^9.4.0", + "espree": "^9.5.1", "globals": "^13.19.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", @@ -88,6 +359,28 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/js": { + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.39.0.tgz", + "integrity": "sha512-kf9RB0Fg7NZfap83B3QOqOGg9QmD9yBudqQXzzOtn3i4y7ZUXe5ONeW34Gwi+TxhH4mvj72R1Zc300KUMa9Bng==", + "dev": true, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, + "node_modules/@floating-ui/core": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.2.4.tgz", + "integrity": "sha512-SQOeVbMwb1di+mVWWJLpsUTToKfqVNioXys011beCAhyOIFtS+GQoW4EQSneuxzmQKddExDwQ+X0hLl4lJJaSQ==" + }, + "node_modules/@floating-ui/dom": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.2.4.tgz", + "integrity": "sha512-4+k+BLhtWj+peCU60gp0+rHeR8+Ohqx6kjJf/lHMnJ8JD5Qj6jytcq1+SZzRwD7rvHKRhR7TDiWWddrNrfwQLg==", + "dependencies": { + "@floating-ui/core": "^1.2.3" + } + }, "node_modules/@humanwhocodes/config-array": { "version": "0.11.8", "dev": true, @@ -119,9 +412,11 @@ "license": "BSD-3-Clause" }, "node_modules/@jellyfish-dev/membrane-webrtc-js": { - "version": "0.4.5", - "license": "Apache-2.0", + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/@jellyfish-dev/membrane-webrtc-js/-/membrane-webrtc-js-0.4.6.tgz", + "integrity": "sha512-CHGzJBDPQ3Y+ezA1MsrTkt96UG9JjYkVsDCtq0xbQ/yBRK3jZxM3J/ilpSs2/GGiI8UO6ju9XjCeNNBOi2hImQ==", "dependencies": { + "events": "^3.3.0", "uuid": "^8.3.2" } }, @@ -171,8 +466,9 @@ }, "node_modules/@types/json-schema": { "version": "7.0.11", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.11.tgz", + "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", + "dev": true }, "node_modules/@types/node": { "version": "18.11.18", @@ -180,6 +476,11 @@ "license": "MIT", "optional": true }, + "node_modules/@types/parse-json": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.0.tgz", + "integrity": "sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==" + }, "node_modules/@types/phoenix": { "version": "1.5.4", "dev": true, @@ -187,21 +488,19 @@ }, "node_modules/@types/prop-types": { "version": "15.7.5", - "dev": true, "license": "MIT" }, "node_modules/@types/ramda": { - "version": "0.29.0", - "resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.29.0.tgz", - "integrity": "sha512-TY9eKsklU43CmAbFJPKDUyBjleZ4EFAkbJeQRF4e8byGkOw1CjDcwg5EGa0Bgf0Kgs9BE9OU4UzQWnQDHnvMtA==", + "version": "0.29.1", + "resolved": "https://registry.npmjs.org/@types/ramda/-/ramda-0.29.1.tgz", + "integrity": "sha512-Ff5RRG9YRqMgWOqZVVavSjGEvYHUnXnGF0YPGbzIWhB3o8qiccSJZlFX2z8qm3G1H/IC5w0ozHmlezUeQCtGfQ==", "dev": true, "dependencies": { - "types-ramda": "^0.29.1" + "types-ramda": "^0.29.2" } }, "node_modules/@types/react": { "version": "18.0.27", - "dev": true, "license": "MIT", "dependencies": { "@types/prop-types": "*", @@ -217,6 +516,15 @@ "@types/react": "*" } }, + "node_modules/@types/react-modal": { + "version": "3.16.0", + "resolved": "https://registry.npmjs.org/@types/react-modal/-/react-modal-3.16.0.tgz", + "integrity": "sha512-iphdqXAyUfByLbxJn5j6d+yh93dbMgshqGP0IuBeaKbZXx0aO+OXsvEkt6QctRdxjeM9/bR+Gp3h9F9djVWTQQ==", + "dev": true, + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/react-page-visibility": { "version": "6.4.1", "dev": true, @@ -225,15 +533,23 @@ "@types/react": "*" } }, + "node_modules/@types/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-juKD/eiSM3/xZYzjuzH6ZwpP+/lejltmiS3QEzV/vmb/Q8+HfDmxu+Baga8UEMGBqV88Nbg4l2hY/K2DkyaLLA==", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/scheduler": { "version": "0.16.2", - "dev": true, "license": "MIT" }, "node_modules/@types/semver": { "version": "7.3.13", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.3.13.tgz", + "integrity": "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw==", + "dev": true }, "node_modules/@types/uuid": { "version": "8.3.4", @@ -250,18 +566,19 @@ } }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "5.50.0", + "version": "5.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.59.1.tgz", + "integrity": "sha512-AVi0uazY5quFB9hlp2Xv+ogpfpk77xzsgsIEWyVS7uK/c7MZ5tw7ZPbapa0SbfkqE0fsAMkz5UwtgMLVk2BQAg==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "5.50.0", - "@typescript-eslint/type-utils": "5.50.0", - "@typescript-eslint/utils": "5.50.0", + "@eslint-community/regexpp": "^4.4.0", + "@typescript-eslint/scope-manager": "5.59.1", + "@typescript-eslint/type-utils": "5.59.1", + "@typescript-eslint/utils": "5.59.1", "debug": "^4.3.4", "grapheme-splitter": "^1.0.4", "ignore": "^5.2.0", "natural-compare-lite": "^1.4.0", - "regexpp": "^3.2.0", "semver": "^7.3.7", "tsutils": "^3.21.0" }, @@ -283,13 +600,14 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "5.50.0", + "version": "5.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.59.1.tgz", + "integrity": "sha512-nzjFAN8WEu6yPRDizIFyzAfgK7nybPodMNFGNH0M9tei2gYnYszRDqVA0xlnRjkl7Hkx2vYrEdb6fP2a21cG1g==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/scope-manager": "5.50.0", - "@typescript-eslint/types": "5.50.0", - "@typescript-eslint/typescript-estree": "5.50.0", + "@typescript-eslint/scope-manager": "5.59.1", + "@typescript-eslint/types": "5.59.1", + "@typescript-eslint/typescript-estree": "5.59.1", "debug": "^4.3.4" }, "engines": { @@ -309,12 +627,13 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "5.50.0", + "version": "5.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.59.1.tgz", + "integrity": "sha512-mau0waO5frJctPuAzcxiNWqJR5Z8V0190FTSqRw1Q4Euop6+zTwHAf8YIXNwDOT29tyUDrQ65jSg9aTU/H0omA==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/types": "5.50.0", - "@typescript-eslint/visitor-keys": "5.50.0" + "@typescript-eslint/types": "5.59.1", + "@typescript-eslint/visitor-keys": "5.59.1" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -325,12 +644,13 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "5.50.0", + "version": "5.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.59.1.tgz", + "integrity": "sha512-ZMWQ+Oh82jWqWzvM3xU+9y5U7MEMVv6GLioM3R5NJk6uvP47kZ7YvlgSHJ7ERD6bOY7Q4uxWm25c76HKEwIjZw==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "5.50.0", - "@typescript-eslint/utils": "5.50.0", + "@typescript-eslint/typescript-estree": "5.59.1", + "@typescript-eslint/utils": "5.59.1", "debug": "^4.3.4", "tsutils": "^3.21.0" }, @@ -351,9 +671,10 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "5.50.0", + "version": "5.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.59.1.tgz", + "integrity": "sha512-dg0ICB+RZwHlysIy/Dh1SP+gnXNzwd/KS0JprD3Lmgmdq+dJAJnUPe1gNG34p0U19HvRlGX733d/KqscrGC1Pg==", "dev": true, - "license": "MIT", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -363,12 +684,13 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "5.50.0", + "version": "5.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.59.1.tgz", + "integrity": "sha512-lYLBBOCsFltFy7XVqzX0Ju+Lh3WPIAWxYpmH/Q7ZoqzbscLiCW00LeYCdsUnnfnj29/s1WovXKh2gwCoinHNGA==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { - "@typescript-eslint/types": "5.50.0", - "@typescript-eslint/visitor-keys": "5.50.0", + "@typescript-eslint/types": "5.59.1", + "@typescript-eslint/visitor-keys": "5.59.1", "debug": "^4.3.4", "globby": "^11.1.0", "is-glob": "^4.0.3", @@ -389,17 +711,18 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "5.50.0", + "version": "5.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.59.1.tgz", + "integrity": "sha512-MkTe7FE+K1/GxZkP5gRj3rCztg45bEhsd8HYjczBuYm+qFHP5vtZmjx3B0yUCDotceQ4sHgTyz60Ycl225njmA==", "dev": true, - "license": "MIT", "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", "@types/json-schema": "^7.0.9", "@types/semver": "^7.3.12", - "@typescript-eslint/scope-manager": "5.50.0", - "@typescript-eslint/types": "5.50.0", - "@typescript-eslint/typescript-estree": "5.50.0", + "@typescript-eslint/scope-manager": "5.59.1", + "@typescript-eslint/types": "5.59.1", + "@typescript-eslint/typescript-estree": "5.59.1", "eslint-scope": "^5.1.1", - "eslint-utils": "^3.0.0", "semver": "^7.3.7" }, "engines": { @@ -414,11 +737,12 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "5.50.0", + "version": "5.59.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.59.1.tgz", + "integrity": "sha512-6waEYwBTCWryx0VJmP7JaM4FpipLsFl9CvYf2foAE8Qh/Y0s+bxWysciwOs0LTBED4JCaNxTZ5rGadB14M6dwA==", "dev": true, - "license": "MIT", "dependencies": { - "@typescript-eslint/types": "5.50.0", + "@typescript-eslint/types": "5.59.1", "eslint-visitor-keys": "^3.3.0" }, "engines": { @@ -431,8 +755,9 @@ }, "node_modules/acorn": { "version": "8.8.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", + "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", "dev": true, - "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -442,8 +767,9 @@ }, "node_modules/acorn-jsx": { "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, - "license": "MIT", "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } @@ -490,8 +816,9 @@ }, "node_modules/ajv": { "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, - "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -544,8 +871,9 @@ }, "node_modules/argparse": { "version": "2.0.1", - "dev": true, - "license": "Python-2.0" + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true }, "node_modules/array-includes": { "version": "3.1.6", @@ -645,6 +973,36 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/babel-plugin-macros": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz", + "integrity": "sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "cosmiconfig": "^7.0.0", + "resolve": "^1.19.0" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + } + }, + "node_modules/babel-plugin-macros/node_modules/resolve": { + "version": "1.22.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.1.tgz", + "integrity": "sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw==", + "dependencies": { + "is-core-module": "^2.9.0", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "dev": true, @@ -727,7 +1085,6 @@ }, "node_modules/callsites": { "version": "3.1.0", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -852,6 +1209,26 @@ "dev": true, "license": "MIT" }, + "node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==" + }, + "node_modules/cosmiconfig": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-7.1.0.tgz", + "integrity": "sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==", + "dependencies": { + "@types/parse-json": "^4.0.0", + "import-fresh": "^3.2.1", + "parse-json": "^5.0.0", + "path-type": "^4.0.0", + "yaml": "^1.10.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "dev": true, @@ -878,13 +1255,15 @@ }, "node_modules/csstype": { "version": "3.1.1", - "dev": true, "license": "MIT" }, "node_modules/date-fns": { - "version": "2.29.3", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.29.3.tgz", - "integrity": "sha512-dDCnyH2WnnKusqvZZ6+jA1O51Ibt8ZMRNkDZdyAyK4YfbDwa/cEmuztzG5pk6hqlp9aSBPYcjOlktquahGwGeA==", + "version": "2.30.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", + "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", + "dependencies": { + "@babel/runtime": "^7.21.0" + }, "engines": { "node": ">=0.11" }, @@ -992,6 +1371,15 @@ "node": ">=6.0.0" } }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, "node_modules/electron-to-chromium": { "version": "1.4.284", "dev": true, @@ -1005,6 +1393,14 @@ "once": "^1.4.0" } }, + "node_modules/error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, "node_modules/es-abstract": { "version": "1.21.1", "dev": true, @@ -1098,7 +1494,6 @@ }, "node_modules/escape-string-regexp": { "version": "4.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -1108,11 +1503,15 @@ } }, "node_modules/eslint": { - "version": "8.33.0", + "version": "8.39.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.39.0.tgz", + "integrity": "sha512-mwiok6cy7KTW7rBpo05k6+p4YVZByLNjAZ/ACB9DRCu4YDRwjXI01tWHp6KAUWelsBetTxKK/2sHB0vdS8Z2Og==", "dev": true, - "license": "MIT", "dependencies": { - "@eslint/eslintrc": "^1.4.1", + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.4.0", + "@eslint/eslintrc": "^2.0.2", + "@eslint/js": "8.39.0", "@humanwhocodes/config-array": "^0.11.8", "@humanwhocodes/module-importer": "^1.0.1", "@nodelib/fs.walk": "^1.2.8", @@ -1122,11 +1521,10 @@ "debug": "^4.3.2", "doctrine": "^3.0.0", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.1.1", - "eslint-utils": "^3.0.0", - "eslint-visitor-keys": "^3.3.0", - "espree": "^9.4.0", - "esquery": "^1.4.0", + "eslint-scope": "^7.2.0", + "eslint-visitor-keys": "^3.4.0", + "espree": "^9.5.1", + "esquery": "^1.4.2", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^6.0.1", @@ -1147,7 +1545,6 @@ "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.1", - "regexpp": "^3.2.0", "strip-ansi": "^6.0.1", "strip-json-comments": "^3.1.0", "text-table": "^0.2.0" @@ -1222,8 +1619,9 @@ }, "node_modules/eslint-scope": { "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" @@ -1234,65 +1632,50 @@ }, "node_modules/eslint-scope/node_modules/estraverse": { "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", "dev": true, - "license": "BSD-2-Clause", "engines": { "node": ">=4.0" } }, - "node_modules/eslint-utils": { - "version": "3.0.0", - "dev": true, - "license": "MIT", - "dependencies": { - "eslint-visitor-keys": "^2.0.0" - }, - "engines": { - "node": "^10.0.0 || ^12.0.0 || >= 14.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - }, - "peerDependencies": { - "eslint": ">=5" - } - }, - "node_modules/eslint-utils/node_modules/eslint-visitor-keys": { - "version": "2.1.0", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=10" - } - }, "node_modules/eslint-visitor-keys": { - "version": "3.3.0", + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.0.tgz", + "integrity": "sha512-HPpKPUBQcAsZOsHAFwTtIKcYlCje62XB7SEAcxjtmW6TD1WVpkS6i6/hOVtTZIl4zGj/mBqpFVGvaDneik+VoQ==", "dev": true, - "license": "Apache-2.0", "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/eslint/node_modules/eslint-scope": { - "version": "7.1.1", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.0.tgz", + "integrity": "sha512-DYj5deGlHBfMt15J7rdtyKNq/Nqlv5KfU4iodrQ019XESsRnwXH9KAE0y3cwtUHDo2ob7CypAnCqefh6vioWRw==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/espree": { - "version": "9.4.1", + "version": "9.5.1", + "resolved": "https://registry.npmjs.org/espree/-/espree-9.5.1.tgz", + "integrity": "sha512-5yxtHSZXRSW5pvv3hAlXM5+/Oswi1AUFqBmbibKb5s6bp3rGIDkyXU6xCoyuuLhijr4SFwPrXRoZjz0AZDN9tg==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "acorn": "^8.8.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.3.0" + "eslint-visitor-keys": "^3.4.0" }, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" @@ -1302,9 +1685,10 @@ } }, "node_modules/esquery": { - "version": "1.4.0", + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", + "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", "dev": true, - "license": "BSD-3-Clause", "dependencies": { "estraverse": "^5.1.0" }, @@ -1339,6 +1723,19 @@ "node": ">=0.10.0" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/exenv": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/exenv/-/exenv-1.2.2.tgz", + "integrity": "sha512-Z+ktTxTwv9ILfgKCk32OX3n/doe+OcLTRtqK9pcL+JsP3J1/VW8Uvl4ZjLlKqeW4rzK4oesDOGMEMRIZqtP4Iw==" + }, "node_modules/extract-zip": { "version": "2.0.1", "dev": true, @@ -1360,8 +1757,9 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true }, "node_modules/fast-glob": { "version": "3.2.12", @@ -1391,8 +1789,9 @@ }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true }, "node_modules/fast-levenshtein": { "version": "2.0.6", @@ -1444,6 +1843,11 @@ "node": ">=0.10.0" } }, + "node_modules/find-root": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", + "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==" + }, "node_modules/find-up": { "version": "5.0.0", "dev": true, @@ -1543,7 +1947,6 @@ }, "node_modules/function-bind": { "version": "1.1.1", - "dev": true, "license": "MIT" }, "node_modules/function.prototype.name": { @@ -1645,8 +2048,9 @@ }, "node_modules/globals": { "version": "13.20.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", + "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", "dev": true, - "license": "MIT", "dependencies": { "type-fest": "^0.20.2" }, @@ -1713,7 +2117,6 @@ }, "node_modules/has": { "version": "1.0.3", - "dev": true, "license": "MIT", "dependencies": { "function-bind": "^1.1.1" @@ -1785,6 +2188,14 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/hoist-non-react-statics": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", + "integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==", + "dependencies": { + "react-is": "^16.7.0" + } + }, "node_modules/https-proxy-agent": { "version": "5.0.1", "dev": true, @@ -1807,7 +2218,6 @@ }, "node_modules/import-fresh": { "version": "3.3.0", - "dev": true, "license": "MIT", "dependencies": { "parent-module": "^1.0.0", @@ -1873,6 +2283,11 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==" + }, "node_modules/is-bigint": { "version": "1.0.4", "dev": true, @@ -1923,7 +2338,6 @@ }, "node_modules/is-core-module": { "version": "2.11.0", - "dev": true, "license": "MIT", "dependencies": { "has": "^1.0.3" @@ -2114,8 +2528,9 @@ }, "node_modules/js-yaml": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, - "license": "MIT", "dependencies": { "argparse": "^2.0.1" }, @@ -2123,10 +2538,16 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", - "dev": true, - "license": "MIT" + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", @@ -2165,6 +2586,11 @@ "node": ">=10" } }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==" + }, "node_modules/locate-path": { "version": "6.0.0", "dev": true, @@ -2209,6 +2635,11 @@ "node": ">=10" } }, + "node_modules/memoize-one": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-6.0.0.tgz", + "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==" + }, "node_modules/merge2": { "version": "1.4.1", "dev": true, @@ -2465,7 +2896,6 @@ }, "node_modules/parent-module": { "version": "1.0.1", - "dev": true, "license": "MIT", "dependencies": { "callsites": "^3.0.0" @@ -2474,6 +2904,23 @@ "node": ">=6" } }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/path-exists": { "version": "4.0.0", "dev": true, @@ -2500,12 +2947,10 @@ }, "node_modules/path-parse": { "version": "1.0.7", - "dev": true, "license": "MIT" }, "node_modules/path-type": { "version": "4.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -2870,8 +3315,9 @@ }, "node_modules/punycode": { "version": "2.3.0", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", + "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", "dev": true, - "license": "MIT", "engines": { "node": ">=6" } @@ -2956,6 +3402,29 @@ "version": "16.13.1", "license": "MIT" }, + "node_modules/react-lifecycles-compat": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz", + "integrity": "sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA==" + }, + "node_modules/react-modal": { + "version": "3.16.1", + "resolved": "https://registry.npmjs.org/react-modal/-/react-modal-3.16.1.tgz", + "integrity": "sha512-VStHgI3BVcGo7OXczvnJN7yT2TWHJPDXZWyI/a0ssFNhGZWsPmB8cF0z33ewDXq4VfYMO1vXgiv/g8Nj9NDyWg==", + "dependencies": { + "exenv": "^1.2.0", + "prop-types": "^15.7.2", + "react-lifecycles-compat": "^3.0.0", + "warning": "^4.0.3" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18", + "react-dom": "^0.14.0 || ^15.0.0 || ^16 || ^17 || ^18" + } + }, "node_modules/react-page-visibility": { "version": "7.0.0", "license": "MIT", @@ -3008,6 +3477,41 @@ "react-dom": ">=16.8" } }, + "node_modules/react-select": { + "version": "5.7.0", + "resolved": "https://registry.npmjs.org/react-select/-/react-select-5.7.0.tgz", + "integrity": "sha512-lJGiMxCa3cqnUr2Jjtg9YHsaytiZqeNOKeibv6WF5zbK/fPegZ1hg3y/9P1RZVLhqBTs0PfqQLKuAACednYGhQ==", + "dependencies": { + "@babel/runtime": "^7.12.0", + "@emotion/cache": "^11.4.0", + "@emotion/react": "^11.8.1", + "@floating-ui/dom": "^1.0.1", + "@types/react-transition-group": "^4.4.0", + "memoize-one": "^6.0.0", + "prop-types": "^15.6.0", + "react-transition-group": "^4.3.0", + "use-isomorphic-layout-effect": "^1.1.2" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, "node_modules/read-cache": { "version": "1.0.0", "dev": true, @@ -3027,6 +3531,11 @@ "node": ">=8.10.0" } }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + }, "node_modules/regexp.prototype.flags": { "version": "1.4.3", "dev": true, @@ -3043,17 +3552,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/regexpp": { - "version": "3.2.0", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/mysticatea" - } - }, "node_modules/resolve": { "version": "2.0.0-next.4", "dev": true, @@ -3072,7 +3570,6 @@ }, "node_modules/resolve-from": { "version": "4.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">=4" @@ -3245,6 +3742,14 @@ "node": ">= 10" } }, + "node_modules/source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/source-map-js": { "version": "1.0.2", "dev": true, @@ -3343,8 +3848,9 @@ }, "node_modules/strip-json-comments": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", "dev": true, - "license": "MIT", "engines": { "node": ">=8" }, @@ -3352,6 +3858,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/stylis": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.1.3.tgz", + "integrity": "sha512-GP6WDNWf+o403jrEp9c5jibKavrtLW+/qYGhFxFrG8maXhwTBI7gLLhiBb0o7uFccWN+EOS9aMO6cGHWAO07OA==" + }, "node_modules/supports-color": { "version": "7.2.0", "dev": true, @@ -3365,7 +3876,6 @@ }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.4" @@ -3435,6 +3945,14 @@ "dev": true, "license": "MIT" }, + "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/to-regex-range": { "version": "5.0.1", "dev": true, @@ -3484,8 +4002,9 @@ }, "node_modules/type-fest": { "version": "0.20.2", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", + "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", "dev": true, - "license": "(MIT OR CC0-1.0)", "engines": { "node": ">=10" }, @@ -3507,25 +4026,25 @@ } }, "node_modules/types-ramda": { - "version": "0.29.1", - "resolved": "https://registry.npmjs.org/types-ramda/-/types-ramda-0.29.1.tgz", - "integrity": "sha512-pdEF8VXcBTSu3fPupZahieG6Lh8eBWPtcaH/OB5QPCoN2hz4vBbspoMLB3X9kwzXRD3lwD4/j0hwj3Z/PAzzcA==", + "version": "0.29.2", + "resolved": "https://registry.npmjs.org/types-ramda/-/types-ramda-0.29.2.tgz", + "integrity": "sha512-HpLcR0ly2EfXQwG8VSI5ov6ml7PvtT+u+cp+7lZLu7q4nhnPDVW+rUTC1uy/SNs4aAyTUXri5M/LyhgvjEXJDg==", "dev": true, "dependencies": { "ts-toolbelt": "^9.6.0" } }, "node_modules/typescript": { - "version": "4.9.5", + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.0.4.tgz", + "integrity": "sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==", "dev": true, - "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=4.2.0" + "node": ">=12.20" } }, "node_modules/unbox-primitive": { @@ -3569,12 +4088,26 @@ }, "node_modules/uri-js": { "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", "dev": true, - "license": "BSD-2-Clause", "dependencies": { "punycode": "^2.1.0" } }, + "node_modules/use-isomorphic-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.1.2.tgz", + "integrity": "sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "dev": true, @@ -3587,6 +4120,14 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "dev": true, @@ -3683,7 +4224,6 @@ }, "node_modules/yaml": { "version": "1.10.2", - "dev": true, "license": "ISC", "engines": { "node": ">= 6" diff --git a/assets/package.json b/assets/package.json index 842cc009..dc6dcd7e 100644 --- a/assets/package.json +++ b/assets/package.json @@ -8,7 +8,7 @@ "typing:check": "tsc --noEmit --skipLibCheck" }, "dependencies": { - "@jellyfish-dev/membrane-webrtc-js": "^0.4.5", + "@jellyfish-dev/membrane-webrtc-js": "^0.4.6", "chartist": "^1.3.0", "clsx": "^1.2.1", "date-fns": "^2.29.3", @@ -19,9 +19,11 @@ "ramda": "^0.29.0", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-modal": "^3.16.1", "react-page-visibility": "^7.0.0", "react-resize-detector": "^8.0.4", "react-router-dom": "^6.4.2", + "react-select": "^5.7.0", "uuid": "^8.3.2" }, "devDependencies": { @@ -30,12 +32,13 @@ "@types/ramda": "^0.29.0", "@types/react": "^18.0.21", "@types/react-dom": "^18.0.6", + "@types/react-modal": "^3.16.0", "@types/react-page-visibility": "^6.4.1", "@types/uuid": "^8.3.4", - "@typescript-eslint/eslint-plugin": "^5.40.1", - "@typescript-eslint/parser": "^5.40.1", + "@typescript-eslint/eslint-plugin": "^5.59.1", + "@typescript-eslint/parser": "^5.59.1", "autoprefixer": "^10.4.13", - "eslint": "^8.25.0", + "eslint": "^8.39.0", "eslint-plugin-react": "^7.31.10", "eslint-plugin-react-hooks": "^4.6.0", "playwright": "1.17", @@ -43,6 +46,7 @@ "postcss-import": "^14.0.2", "prettier": "^2.8.3", "prettier-plugin-tailwindcss": "^0.2.1", - "tailwindcss": "^3.2.7" + "tailwindcss": "^3.2.7", + "typescript": "^5.0.4" } } diff --git a/assets/src/App.tsx b/assets/src/App.tsx index 529e1696..a1144c9e 100644 --- a/assets/src/App.tsx +++ b/assets/src/App.tsx @@ -4,18 +4,32 @@ import { DeveloperInfoProvider } from "./contexts/DeveloperInfoContext"; import { router } from "./Routes"; import { UserProvider } from "./contexts/UserContext"; import { ToastProvider } from "./features/shared/context/ToastContext"; -import { PreviewSettingsProvider } from "./features/home-page/context/PreviewSettingsContext"; +import { ModalProvider } from "./contexts/ModalContext"; +import { DeviceErrorBoundary } from "./features/devices/DeviceErrorBoundary"; +import { LocalPeerMediaProvider } from "./features/devices/LocalPeerMediaContext"; +import { MediaSettingsModal } from "./features/devices/MediaSettingsModal"; +import { disableSafariCache } from "./features/devices/disableSafariCache"; + +// When returning to the videoroom page from another domain using the 'Back' button on the Safari browser, +// the page is served from the cache, which prevents lifecycle events from being triggered. +// As a result, the camera and microphone do not start. To resolve this issue, one simple solution is to disable the cache. +disableSafariCache(); const App: FC = () => { return ( - + - + + + + + + - + diff --git a/assets/src/Routes.tsx b/assets/src/Routes.tsx index 3aa4a06e..c3666553 100644 --- a/assets/src/Routes.tsx +++ b/assets/src/Routes.tsx @@ -5,7 +5,6 @@ import { useDeveloperInfo } from "./contexts/DeveloperInfoContext"; import { useUser } from "./contexts/UserContext"; import VideoroomHomePage from "./features/home-page/components/VideoroomHomePage"; import LeavingRoomScreen from "./features/home-page/components/LeavingRoomScreen"; -import { usePreviewSettings } from "./features/home-page/hooks/usePreviewSettings"; import Page404 from "./features/shared/components/Page404"; import { WebrtcInternalsPage } from "./pages/webrtcInternals/WebrtcInternalsPage"; @@ -16,21 +15,13 @@ const RoomPageWrapper: React.FC = () => { const isLeavingRoom = !!state?.isLeavingRoom; const { username } = useUser(); const { simulcast, manualMode } = useDeveloperInfo(); - const { cameraAutostart, audioAutostart } = usePreviewSettings(); if (isLeavingRoom && roomId) { return ; } return username && roomId ? ( - + ) : ( ); diff --git a/assets/src/contexts/ModalContext.tsx b/assets/src/contexts/ModalContext.tsx new file mode 100644 index 00000000..7f4ba4e6 --- /dev/null +++ b/assets/src/contexts/ModalContext.tsx @@ -0,0 +1,26 @@ +import React, { useContext, useState } from "react"; + +export type ModalContextType = { + setOpen: (value: boolean) => void; + isOpen: boolean; +}; + +const ModelContext = React.createContext(undefined); + +type Props = { + children: React.ReactNode; +}; + +export const ModalProvider = ({ children }: Props) => { + const [isOpen, setIsOpen] = useState(false); + + return ( + setIsOpen(value), isOpen }}>{children} + ); +}; + +export const useModal = (): ModalContextType => { + const context = useContext(ModelContext); + if (!context) throw new Error("useModal must be used within a ModalProvider"); + return context; +}; diff --git a/assets/src/features/devices/DeviceErrorBoundary.tsx b/assets/src/features/devices/DeviceErrorBoundary.tsx new file mode 100644 index 00000000..8f9614d7 --- /dev/null +++ b/assets/src/features/devices/DeviceErrorBoundary.tsx @@ -0,0 +1,38 @@ +import React, { FC, PropsWithChildren } from "react"; +import useToast from "../shared/hooks/useToast"; +import useEffectOnChange from "../shared/hooks/useEffectOnChange"; +import { useLocalPeer } from "./LocalPeerMediaContext"; + +const prepareErrorMessage = (videoDeviceError: string | null, audioDeviceError: string | null): null | string => { + if (videoDeviceError && audioDeviceError) { + return "Access to camera and microphone is blocked"; + } else if (videoDeviceError) { + return "Access to camera is blocked"; + } else if (audioDeviceError) { + return "Access to microphone is blocked"; + } else return null; +}; + +export const DeviceErrorBoundary: FC = ({ children }) => { + const { addToast } = useToast(); + const { video, audio } = useLocalPeer(); + + useEffectOnChange( + [video.error, audio.error], + () => { + const message = prepareErrorMessage(video.error, audio.error); + + if (message) { + addToast({ + id: "device-not-allowed-error", + message: message, + timeout: "INFINITY", + type: "error", + }); + } + }, + (next, prev) => prev?.[0] === next[0] && prev?.[1] === next[1] + ); + + return <>{children}; +}; diff --git a/assets/src/features/devices/DeviceSelector.tsx b/assets/src/features/devices/DeviceSelector.tsx new file mode 100644 index 00000000..88b6d7a1 --- /dev/null +++ b/assets/src/features/devices/DeviceSelector.tsx @@ -0,0 +1,30 @@ +import React from "react"; +import { SelectOption } from "../shared/components/Select"; +import Input from "../shared/components/Input"; + +type Props = { + name: string; + devices: MediaDeviceInfo[] | null; + setInput: (value: string | null) => void; + inputValue: string | null; +}; + +export const DeviceSelector = ({ name, devices, setInput, inputValue }: Props) => { + const options: SelectOption[] = (devices || []).map(({ deviceId, label }) => ({ + value: deviceId, + label, + })); + + return ( + { + setInput(option.value); + }} + value={options.find(({ value }) => value === inputValue)} + /> + ); +}; diff --git a/assets/src/features/devices/LocalPeerMediaContext.tsx b/assets/src/features/devices/LocalPeerMediaContext.tsx new file mode 100644 index 00000000..30b4ba61 --- /dev/null +++ b/assets/src/features/devices/LocalPeerMediaContext.tsx @@ -0,0 +1,145 @@ +import React, { useContext, useMemo, useState } from "react"; +import { AUDIO_TRACK_CONSTRAINTS, VIDEO_TRACK_CONSTRAINTS } from "../../pages/room/consts"; +import { loadObject, saveObject } from "../shared/utils/localStorage"; +import { useMedia } from "./useMedia"; +import { DeviceState, Type, UseUserMediaConfig, UseUserMediaStartConfig } from "./use-user-media/type"; +import { useUserMedia } from "./use-user-media/useUserMedia"; + +export type Device = { + stream: MediaStream | null; + start: () => void; + stop: () => void; + isEnabled: boolean; + disable: () => void; + enable: () => void; +}; + +export type UserMedia = { + id: string | null; + setId: (id: string) => void; + device: Device; + error: string | null; + devices: MediaDeviceInfo[] | null; +}; + +export type DisplayMedia = { + setConfig: (constraints: MediaStreamConstraints | null) => void; + config: MediaStreamConstraints | null; + device: Device; +}; + +export type LocalPeerContextType = { + video: UserMedia; + audio: UserMedia; + screenShare: DisplayMedia; + start: (config: UseUserMediaStartConfig) => void; +}; + +const LocalPeerMediaContext = React.createContext(undefined); + +type Props = { + children: React.ReactNode; +}; + +const LOCAL_STORAGE_VIDEO_DEVICE_KEY = "last-selected-video-device"; +const LOCAL_STORAGE_AUDIO_DEVICE_KEY = "last-selected-audio-device"; + +const useDisplayMedia = (screenSharingConfig: MediaStreamConstraints | null) => + useMedia( + useMemo( + () => (screenSharingConfig ? () => navigator.mediaDevices.getDisplayMedia(screenSharingConfig) : null), + [screenSharingConfig] + ) + ); + +const USE_USER_MEDIA_CONFIG: UseUserMediaConfig = { + getLastAudioDevice: () => loadObject(LOCAL_STORAGE_AUDIO_DEVICE_KEY, null), + saveLastAudioDevice: (info: MediaDeviceInfo) => saveObject(LOCAL_STORAGE_AUDIO_DEVICE_KEY, info), + getLastVideoDevice: () => loadObject(LOCAL_STORAGE_VIDEO_DEVICE_KEY, null), + saveLastVideoDevice: (info: MediaDeviceInfo) => saveObject(LOCAL_STORAGE_VIDEO_DEVICE_KEY, info), + videoTrackConstraints: VIDEO_TRACK_CONSTRAINTS, + audioTrackConstraints: AUDIO_TRACK_CONSTRAINTS, + refetchOnMount: true, +}; + +const useMediaData = ( + data: DeviceState | null, + type: Type, + localStorageKey: string, + start: (config: UseUserMediaStartConfig) => void, + stop: (type: Type) => void, + setEnable: (type: Type, value: boolean) => void +) => { + const deviceIdKey: keyof UseUserMediaStartConfig = type === "video" ? "videoDeviceId" : "audioDeviceId"; + + return useMemo( + (): UserMedia => ({ + id: data?.media?.deviceInfo?.deviceId || null, + setId: (value: string) => start({ [deviceIdKey]: value }), + device: { + stream: data?.media?.stream || null, + stop: () => stop(type), + start: () => start({ [deviceIdKey]: loadObject(localStorageKey, null)?.deviceId }), + disable: () => setEnable(type, false), + enable: () => setEnable(type, true), + isEnabled: !!data?.media?.enabled, + }, + devices: data?.devices || null, + error: data?.error?.name || null, + }), + [data, stop, start, setEnable, type, localStorageKey, deviceIdKey] + ); +}; + +export const LocalPeerMediaProvider = ({ children }: Props) => { + const { data, stop, start, setEnable } = useUserMedia(USE_USER_MEDIA_CONFIG); + + const [screenSharingConfig, setScreenSharingConfig] = useState(null); + const screenSharingDevice: Device = useDisplayMedia(screenSharingConfig); + + const video: UserMedia = useMediaData( + data?.video || null, + "video", + LOCAL_STORAGE_VIDEO_DEVICE_KEY, + start, + stop, + setEnable + ); + + const audio: UserMedia = useMediaData( + data?.audio || null, + "audio", + LOCAL_STORAGE_AUDIO_DEVICE_KEY, + start, + stop, + setEnable + ); + + const screenShare: DisplayMedia = useMemo( + () => ({ + config: screenSharingConfig, + setConfig: setScreenSharingConfig, + device: screenSharingDevice, + }), + [screenSharingConfig, screenSharingDevice] + ); + + return ( + + {children} + + ); +}; + +export const useLocalPeer = (): LocalPeerContextType => { + const context = useContext(LocalPeerMediaContext); + if (!context) throw new Error("useLocalPeer must be used within a LocalPeerMediaContext"); + return context; +}; diff --git a/assets/src/features/devices/MediaSettingsModal.tsx b/assets/src/features/devices/MediaSettingsModal.tsx new file mode 100644 index 00000000..80cf84bb --- /dev/null +++ b/assets/src/features/devices/MediaSettingsModal.tsx @@ -0,0 +1,58 @@ +import React, { useEffect, useState } from "react"; +import { DeviceSelector } from "./DeviceSelector"; +import { useModal } from "../../contexts/ModalContext"; +import { useLocalPeer } from "./LocalPeerMediaContext"; +import { Modal } from "../shared/components/modal/Modal"; + +export const MediaSettingsModal: React.FC = () => { + const { setOpen, isOpen } = useModal(); + const { video, audio, start } = useLocalPeer(); + const [videoInput, setVideoInput] = useState(null); + const [audioInput, setAudioInput] = useState(null); + + useEffect(() => { + if (video.devices && video.id) { + setVideoInput(video.id); + } + }, [video.devices, video.id]); + + useEffect(() => { + if (audio.devices && audio.id) { + setAudioInput(audio.id); + } + }, [audio.devices, audio.id]); + + const handleClose = () => { + setOpen(false); + setAudioInput(audio.id); + setVideoInput(video.id); + }; + + return ( + { + start({ + audioDeviceId: audioInput || undefined, + videoDeviceId: videoInput || undefined, + }); + setOpen(false); + }} + onCancel={handleClose} + maxWidth="max-w-md" + isOpen={isOpen} + > + + + + ); +}; diff --git a/assets/src/features/devices/disableSafariCache.ts b/assets/src/features/devices/disableSafariCache.ts new file mode 100644 index 00000000..9b24bac2 --- /dev/null +++ b/assets/src/features/devices/disableSafariCache.ts @@ -0,0 +1,8 @@ +export const disableSafariCache = () => { + // https://stackoverflow.com/questions/8788802/prevent-safari-loading-from-cache-when-back-button-is-clicked + window.onpageshow = (event) => { + if (event.persisted) { + window.location.reload(); + } + }; +}; diff --git a/assets/src/features/devices/use-user-media/constants.ts b/assets/src/features/devices/use-user-media/constants.ts new file mode 100644 index 00000000..4f0db4df --- /dev/null +++ b/assets/src/features/devices/use-user-media/constants.ts @@ -0,0 +1,6 @@ +import { DeviceError } from "./type"; + +export const REQUESTING = { type: "Requesting" } as const; +export const NOT_REQUESTED = { type: "Not requested" } as const; +export const PERMISSION_DENIED: DeviceError = { name: "NotAllowedError" }; +export const OVERCONSTRAINED_ERROR: DeviceError = { name: "OverconstrainedError" }; diff --git a/assets/src/features/devices/use-user-media/constraints.ts b/assets/src/features/devices/use-user-media/constraints.ts new file mode 100644 index 00000000..06936b49 --- /dev/null +++ b/assets/src/features/devices/use-user-media/constraints.ts @@ -0,0 +1,45 @@ +export const toMediaTrackConstraints = ( + constraint?: boolean | MediaTrackConstraints +): MediaTrackConstraints | undefined => { + if (typeof constraint === "boolean") { + return constraint ? {} : undefined; + } + return constraint; +}; + +export const prepareMediaTrackConstraints = ( + deviceId: string | undefined, + constraints: MediaTrackConstraints | undefined +): MediaTrackConstraints | boolean => { + if (!deviceId) return false; + const exactId: Pick = deviceId ? { deviceId: { exact: deviceId } } : {}; + return { ...constraints, ...exactId }; +}; + +export const getExactDeviceConstraint = ( + videoConstraints: MediaTrackConstraints | undefined, + deviceId: string | undefined +) => ({ + ...videoConstraints, + deviceId: { exact: deviceId }, +}); + +export const prepareConstraints = ( + shouldAskForDevice: boolean, + deviceIdToStart: string | undefined, + constraints: MediaTrackConstraints | undefined +): MediaTrackConstraints | undefined | boolean => { + if (!shouldAskForDevice) return false; + + return deviceIdToStart ? getExactDeviceConstraint(constraints, deviceIdToStart) : constraints; +}; + +export const removeExact = ( + trackConstraints: boolean | MediaTrackConstraints | undefined +): boolean | MediaTrackConstraints | undefined => { + if (trackConstraints === undefined || trackConstraints === true || trackConstraints === false) + return trackConstraints; + const copy: MediaTrackConstraints = { ...trackConstraints }; + delete copy["deviceId"]; + return copy; +}; diff --git a/assets/src/features/devices/use-user-media/debug.ts b/assets/src/features/devices/use-user-media/debug.ts new file mode 100644 index 00000000..4c2f103b --- /dev/null +++ b/assets/src/features/devices/use-user-media/debug.ts @@ -0,0 +1,14 @@ +// eslint-disable-next-line +export const log = (message?: any, ...optionalParams: any[]) => { + const logDeviceManager = localStorage.getItem("log-device-manager"); + if (logDeviceManager === "true") { + console.log(message, ...optionalParams); + } +}; +// eslint-disable-next-line +export const warn = (message?: any, ...optionalParams: any[]) => { + const logDeviceManager = localStorage.getItem("log-device-manager"); + if (logDeviceManager === "true") { + console.warn(message, optionalParams); + } +}; diff --git a/assets/src/features/devices/use-user-media/device-utils.ts b/assets/src/features/devices/use-user-media/device-utils.ts new file mode 100644 index 00000000..05d71516 --- /dev/null +++ b/assets/src/features/devices/use-user-media/device-utils.ts @@ -0,0 +1,65 @@ +import { CurrentDevices, DeviceError } from "./type"; +import { OVERCONSTRAINED_ERROR, PERMISSION_DENIED } from "./constants"; + +export const deviceUtils = (mediaDeviceInfo: MediaDeviceInfo) => + mediaDeviceInfo.label !== "" && mediaDeviceInfo.deviceId !== ""; + +export const isVideo = (device: MediaDeviceInfo) => device.kind === "videoinput"; + +export const isAudio = (device: MediaDeviceInfo) => device.kind === "audioinput"; + +export const getDeviceInfo = (trackDeviceId: string | null, devices: MediaDeviceInfo[]): MediaDeviceInfo | null => + trackDeviceId ? devices.find(({ deviceId }) => trackDeviceId === deviceId) || null : null; + +export const getCurrentDevicesSettings = ( + requestedDevices: MediaStream, + mediaDeviceInfos: MediaDeviceInfo[] +): CurrentDevices => { + const currentDevices: CurrentDevices = { videoinput: null, audioinput: null }; + + requestedDevices.getTracks().forEach((track) => { + const settings = track.getSettings(); + if (settings.deviceId) { + const currentDevice = mediaDeviceInfos.find((device) => device.deviceId == settings.deviceId); + const kind = currentDevice?.kind || null; + if ((currentDevice && kind && kind === "videoinput") || kind === "audioinput") { + currentDevices[kind] = currentDevice || null; + } + } + }); + return currentDevices; +}; +// todo remove export +export const isDeviceDifferentFromLastSession = ( + lastDevice: MediaDeviceInfo | null, + currentDevice: MediaDeviceInfo | null +) => lastDevice && (currentDevice?.deviceId !== lastDevice.deviceId || currentDevice?.label !== lastDevice?.label); + +export const isAnyDeviceDifferentFromLastSession = ( + lastVideoDevice: MediaDeviceInfo | null, + lastAudioDevice: MediaDeviceInfo | null, + currentDevices: CurrentDevices | null +): boolean => + !!( + (currentDevices?.videoinput && + isDeviceDifferentFromLastSession(lastVideoDevice, currentDevices?.videoinput || null)) || + (currentDevices?.audioinput && + isDeviceDifferentFromLastSession(lastAudioDevice, currentDevices?.audioinput || null)) + ); + +// https://developer.mozilla.org/en-US/docs/Web/API/MediaDevices/getUserMedia#exceptions +// OverconstrainedError has higher priority than NotAllowedError +export const parseError = (error: unknown): DeviceError | null => { + if (error && typeof error === "object" && "name" in error) { + if (error.name === "NotAllowedError") { + return PERMISSION_DENIED; + } else if (error.name === "OverconstrainedError") { + return OVERCONSTRAINED_ERROR; + } + } + // todo handle unknown error + return null; +}; + +export const enumerateDevices: () => Promise = async () => + await navigator.mediaDevices.enumerateDevices(); diff --git a/assets/src/features/devices/use-user-media/type.ts b/assets/src/features/devices/use-user-media/type.ts new file mode 100644 index 00000000..147352fa --- /dev/null +++ b/assets/src/features/devices/use-user-media/type.ts @@ -0,0 +1,63 @@ +export type Type = "audio" | "video"; + +export type DeviceReturnType = + | { type: "OK" } + | { type: "Error"; error: DeviceError | null } + | { type: "Not requested" } + | { type: "Requesting" }; + +export type Media = { + stream: MediaStream | null; + track: MediaStreamTrack | null; + enabled: boolean; + deviceInfo: MediaDeviceInfo | null; +}; + +export type DeviceState = { + status: DeviceReturnType; + media: Media | null; + error: DeviceError | null; + devices: MediaDeviceInfo[] | null; +}; + +export type UseUserMediaState = { + video: DeviceState; + audio: DeviceState; + devices: MediaDeviceInfo[] | null; +}; + +export type UseUserMediaConfig = { + getLastAudioDevice: () => MediaDeviceInfo | null; + saveLastAudioDevice: (info: MediaDeviceInfo) => void; + getLastVideoDevice: () => MediaDeviceInfo | null; + saveLastVideoDevice: (info: MediaDeviceInfo) => void; + videoTrackConstraints: boolean | MediaTrackConstraints; + audioTrackConstraints: boolean | MediaTrackConstraints; + refetchOnMount: boolean; +}; + +export type UseUserMediaStartConfig = { + audioDeviceId?: string; + videoDeviceId?: string; +}; + +export type UseUserMedia = { + data: UseUserMediaState | null; + start: (config: UseUserMediaStartConfig) => void; + stop: (type: Type) => void; + setEnable: (type: Type, value: boolean) => void; + init: (videoParam: boolean | MediaTrackConstraints, audioParam: boolean | MediaTrackConstraints) => void; +}; + +export type DeviceError = { name: "OverconstrainedError" } | { name: "NotAllowedError" }; + +export type Errors = { + audio?: DeviceError | null; + video?: DeviceError | null; +}; + +export type GetMedia = + | { stream: MediaStream; type: "OK"; constraints: MediaStreamConstraints; previousErrors: Errors } + | { error: DeviceError | null; type: "Error"; constraints: MediaStreamConstraints }; + +export type CurrentDevices = { videoinput: MediaDeviceInfo | null; audioinput: MediaDeviceInfo | null }; diff --git a/assets/src/features/devices/use-user-media/useUserMedia.tsx b/assets/src/features/devices/use-user-media/useUserMedia.tsx new file mode 100644 index 00000000..36158b69 --- /dev/null +++ b/assets/src/features/devices/use-user-media/useUserMedia.tsx @@ -0,0 +1,449 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + DeviceError, + DeviceReturnType, + DeviceState, + Errors, + GetMedia, + Media, + Type, + UseUserMedia, + UseUserMediaConfig, + UseUserMediaStartConfig, + UseUserMediaState, +} from "./type"; +import { + getExactDeviceConstraint, + prepareConstraints, + prepareMediaTrackConstraints, + removeExact, + toMediaTrackConstraints, +} from "./constraints"; +import { log, warn } from "./debug"; +import { NOT_REQUESTED, PERMISSION_DENIED, REQUESTING } from "./constants"; +import { + enumerateDevices, + getCurrentDevicesSettings, + getDeviceInfo, + isAnyDeviceDifferentFromLastSession, + isAudio, + isVideo, + parseError, +} from "./device-utils"; + +// TODO After some testing this hook should be prepareDeviceState to browser-media-utils + +const stopTracks = (requestedDevices: MediaStream) => { + requestedDevices.getTracks().forEach((track) => { + track.stop(); + }); +}; + +const getMedia = async ( + constraints: MediaStreamConstraints, + previousErrors: Errors, + logIdentifier?: string +): Promise => { + try { + log({ name: `${logIdentifier} invoked`, constraints, errors: previousErrors }); + + const mediaStream = await navigator.mediaDevices.getUserMedia(constraints); + log(`%c${logIdentifier} succeeded`, "color: green"); + return { stream: mediaStream, type: "OK", constraints, previousErrors }; + } catch (error: unknown) { + warn({ error }); + const parsedError: DeviceError | null = parseError(error); + return { error: parsedError, type: "Error", constraints }; + } +}; + +const handleOverconstrainedError = async (constraints: MediaStreamConstraints): Promise => { + const videoResult = await getMedia( + { video: removeExact(constraints.video), audio: constraints.audio }, + {}, + "handleOverconstrainedError loosen video constraints" + ); + if (videoResult.type === "OK" || videoResult.error?.name === "NotAllowedError") { + return videoResult; + } + + const audioResult = await getMedia( + { video: constraints.video, audio: removeExact(constraints.audio) }, + {}, + "handleOverconstrainedError loosen audio constraints" + ); + if (audioResult.type === "OK" || audioResult.error?.name === "NotAllowedError") { + return audioResult; + } + + return await getMedia( + { video: removeExact(constraints.video), audio: removeExact(constraints.audio) }, + {}, + "handleOverconstrainedError loosen both constraints" + ); +}; + +const handleNotAllowedError = async (constraints: MediaStreamConstraints): Promise => { + const videoResult = await getMedia( + { video: false, audio: constraints.audio }, + { video: PERMISSION_DENIED }, + "handleNotAllowedError disable video constraints" + ); + if (videoResult.type === "OK") { + return videoResult; + } + + const audioResult = await getMedia( + { video: constraints.video, audio: false }, + { audio: PERMISSION_DENIED }, + "handleNotAllowedError disable audio constraints" + ); + if (audioResult.type === "OK") { + return audioResult; + } + + return await getMedia( + { video: false, audio: false }, + { video: PERMISSION_DENIED, audio: PERMISSION_DENIED }, + "handleNotAllowedError disable both constraints" + ); +}; + +const getError = (result: GetMedia, type: "audio" | "video"): DeviceError | null => { + if (result.type === "OK") { + return result.previousErrors[type] || null; + } + return PERMISSION_DENIED; +}; + +const prepareStatus = ( + requested: boolean, + track: MediaStreamTrack | null, + deviceError: DeviceError | null +): DeviceReturnType => { + if (!requested) return { type: "Not requested" }; + if (track) return { type: "OK" }; + if (deviceError) return { type: "Error", error: deviceError }; + return { type: "Error", error: null }; +}; + +const prepareDeviceState = ( + stream: MediaStream | null, + track: MediaStreamTrack | null, + devices: MediaDeviceInfo[], + error: DeviceError | null, + shouldAskForVideo: boolean +) => { + const deviceInfo = getDeviceInfo(track?.getSettings()?.deviceId || null, devices); + + return { + devices: devices, + status: prepareStatus(shouldAskForVideo, track, error), + media: { + stream: track ? stream : null, + track: track, + deviceInfo, + enabled: !!track, + }, + error: error, + }; +}; + +const INITIAL_STATE: UseUserMediaState = { + video: { + status: { type: "Not requested" }, + media: null, + devices: null, + error: null, + }, + audio: { + status: { type: "Not requested" }, + media: null, + devices: null, + error: null, + }, + devices: null, +}; + +/** + * This hook is responsible for managing Media Devices and Media Streams from those devices. + * + * It stores all available devices and devices that are currently in use. + * + * It can also store previously selected devices, so it can retrieve them after a page reload. + * + * The inner algorithm should only open one prompt for both audio and video. + * + * If it's not possible to get the previous device (e.g. because the device doesn't exist), + * it tries to recover by loosening constraints on each device one by one to overcome OverconstrainedError. + * + * If one device is not available (e.g. if the user closed the prompt or permanently blocked the device, + * resulting in NotAllowedError), it tries to identify which device is not available and turns on the remaining one. + * + * Logs in this hook are visible on console when setting "log-device-manager" to `"true" in your LocalStorage. + */ +export const useUserMedia = ({ + getLastAudioDevice, + saveLastAudioDevice, + getLastVideoDevice, + saveLastVideoDevice, + videoTrackConstraints, + audioTrackConstraints, + refetchOnMount, +}: UseUserMediaConfig): UseUserMedia => { + const [state, setState] = useState(INITIAL_STATE); + + useEffect(() => { + log({ name: "stateChange", data: state }); + }, [state]); + + const audioConstraints = useMemo(() => toMediaTrackConstraints(audioTrackConstraints), [audioTrackConstraints]); + const videoConstraints = useMemo(() => toMediaTrackConstraints(videoTrackConstraints), [videoTrackConstraints]); + + const skip = useRef(false); + + const init = useCallback(async () => { + if (!navigator?.mediaDevices) throw Error("Navigator is available only in secure contexts"); + if (skip.current) return; + skip.current = true; + + const previousVideoDevice: MediaDeviceInfo | null = getLastVideoDevice(); + const previousAudioDevice: MediaDeviceInfo | null = getLastAudioDevice(); + + const shouldAskForVideo = !!videoTrackConstraints; + const shouldAskForAudio = !!audioTrackConstraints; + + setState((prevState) => ({ + ...prevState, + video: { + ...prevState.video, + status: shouldAskForVideo && videoConstraints ? REQUESTING : prevState.video.status ?? NOT_REQUESTED, + }, + audio: { + ...prevState.audio, + status: shouldAskForAudio && audioConstraints ? REQUESTING : prevState.audio.status ?? NOT_REQUESTED, + }, + })); + + let requestedDevices: MediaStream | null = null; + + const constraints = { + video: shouldAskForVideo && getExactDeviceConstraint(videoConstraints, previousVideoDevice?.deviceId), + audio: shouldAskForAudio && getExactDeviceConstraint(audioConstraints, previousAudioDevice?.deviceId), + }; + + let result: GetMedia = await getMedia(constraints, {}, "Exact match"); + + if (result.type === "Error" && result.error?.name === "OverconstrainedError") { + result = await handleOverconstrainedError(constraints); + } + + if (result.type === "Error" && result.error?.name === "NotAllowedError") { + result = await handleNotAllowedError(result.constraints); + } + + const mediaDeviceInfos: MediaDeviceInfo[] = await enumerateDevices(); + + if (result.type === "OK") { + requestedDevices = result.stream; + // Safari changes deviceId between sessions, therefore we cannot rely on deviceId for identification purposes. + // We can switch a random device that comes from safari to one that has the same label as the one used in the previous session. + const currentDevices = getCurrentDevicesSettings(requestedDevices, mediaDeviceInfos); + const shouldCorrectDevices = isAnyDeviceDifferentFromLastSession( + previousVideoDevice, + previousAudioDevice, + currentDevices + ); + if (shouldCorrectDevices) { + const videoIdToStart = mediaDeviceInfos.find((info) => info.label === previousVideoDevice?.label)?.deviceId; + const audioIdToStart = mediaDeviceInfos.find((info) => info.label === previousAudioDevice?.label)?.deviceId; + + if (videoIdToStart || audioIdToStart) { + stopTracks(requestedDevices); + + const exactConstraints: MediaStreamConstraints = { + video: prepareConstraints(!!result.constraints.video, videoIdToStart, videoConstraints), + audio: prepareConstraints(!!result.constraints.audio, audioIdToStart, audioConstraints), + }; + + const correctedResult = await getMedia( + exactConstraints, + { + video: result.previousErrors.video, + audio: result.previousErrors.audio, + }, + "Correct both device" + ); + + if (correctedResult.type === "OK") { + requestedDevices = correctedResult.stream; + } else { + console.error("Device Manager unexpected error"); + } + } + } + } + + log("%cResult", "color: orange"); + + log({ result }); + + const video: DeviceState = prepareDeviceState( + requestedDevices, + requestedDevices?.getVideoTracks()[0] || null, + mediaDeviceInfos.filter(isVideo), + getError(result, "video"), + shouldAskForVideo + ); + + const audio: DeviceState = prepareDeviceState( + requestedDevices, + requestedDevices?.getAudioTracks()[0] || null, + mediaDeviceInfos.filter(isAudio), + getError(result, "audio"), + shouldAskForAudio + ); + + setState({ + devices: mediaDeviceInfos, + video, + audio, + }); + + if (video.media?.deviceInfo) { + log({ name: "saveLastVideoDevice", info: video.media.deviceInfo }); + saveLastVideoDevice(video.media.deviceInfo); + } + + if (audio.media?.deviceInfo) { + saveLastAudioDevice(audio.media?.deviceInfo); + } + }, [ + getLastVideoDevice, + getLastAudioDevice, + videoTrackConstraints, + audioTrackConstraints, + videoConstraints, + audioConstraints, + saveLastVideoDevice, + saveLastAudioDevice, + ]); + + const start = useCallback( + async ({ audioDeviceId, videoDeviceId }: UseUserMediaStartConfig) => { + const shouldRestartVideo = !!videoDeviceId && videoDeviceId !== state.video.media?.deviceInfo?.deviceId; + const shouldRestartAudio = !!audioDeviceId && audioDeviceId !== state.audio.media?.deviceInfo?.deviceId; + + const exactConstraints: MediaStreamConstraints = { + video: shouldRestartVideo && prepareMediaTrackConstraints(videoDeviceId, videoConstraints), + audio: shouldRestartAudio && prepareMediaTrackConstraints(audioDeviceId, audioConstraints), + }; + + if (!exactConstraints.video && !exactConstraints.audio) return; + + const result = await getMedia(exactConstraints, {}, "Restart"); + + if (result.type == "OK") { + const stream = result.stream; + + if (shouldRestartVideo) { + state?.video.media?.track?.stop(); + } + + const videoInfo = videoDeviceId ? getDeviceInfo(videoDeviceId, state.video.devices ?? []) : null; + if (videoInfo) { + saveLastVideoDevice(videoInfo); + } + + if (shouldRestartAudio) { + state?.audio.media?.track?.stop(); + } + + const audioInfo = audioDeviceId ? getDeviceInfo(audioDeviceId, state.audio.devices ?? []) : null; + + if (audioInfo) { + saveLastAudioDevice(audioInfo); + } + + setState((prevState): UseUserMediaState => { + const videoMedia: Media | null = shouldRestartVideo + ? { + stream, + track: stream.getVideoTracks()[0] || null, + deviceInfo: videoInfo, + enabled: true, + } + : prevState.video.media; + + const audioMedia: Media | null = shouldRestartAudio + ? { + stream, + track: stream.getAudioTracks()[0] || null, + deviceInfo: audioInfo, + enabled: true, + } + : prevState.audio.media; + + return { + ...prevState, + video: { ...prevState.video, media: videoMedia }, + audio: { ...prevState.audio, media: audioMedia }, + }; + }); + } else { + const parsedError = result.error; + + setState((prevState) => { + const videoError = exactConstraints.video ? parsedError : prevState.video.error; + const audioError = exactConstraints.audio ? parsedError : prevState.audio.error; + + return { + ...prevState, + video: { ...prevState.video, error: videoError }, + audio: { ...prevState.audio, error: audioError }, + }; + }); + } + }, + [state, audioConstraints, saveLastAudioDevice, videoConstraints, saveLastVideoDevice] + ); + + const stop = useCallback(async (type: Type) => { + setState((prevState) => { + prevState?.[type]?.media?.track?.stop(); + + return { ...prevState, [type]: { ...prevState[type], media: null } }; + }); + }, []); + + const setEnable = useCallback((type: Type, value: boolean) => { + setState((prevState) => { + const media = prevState[type].media; + if (!media || !media.track) { + return prevState; + } + + media.track.enabled = value; + + return { ...prevState, [type]: { ...prevState[type], media: { ...media, enabled: value } } }; + }); + }, []); + + useEffect(() => { + if (refetchOnMount) { + init(); + } + // eslint-disable-next-line + }, []); + + return useMemo( + () => ({ + data: state, + start, + stop, + init, + setEnable, + }), + [start, state, stop, init, setEnable] + ); +}; diff --git a/assets/src/features/devices/useMedia.tsx b/assets/src/features/devices/useMedia.tsx new file mode 100644 index 00000000..3044b9b1 --- /dev/null +++ b/assets/src/features/devices/useMedia.tsx @@ -0,0 +1,121 @@ +import { useCallback, useEffect, useState } from "react"; +import noop from "../shared/utils/noop"; + +// TODO After some testing this hook should be extracted to browser-media-utils + +export type UseMedia = { + isError: boolean; + stream: MediaStream | null; + isLoading: boolean; + start: () => void; + stop: () => void; + isEnabled: boolean; + disable: () => void; + enable: () => void; +}; + +const defaultState: UseMedia = { + isError: false, + stream: null, + isLoading: false, + start: noop, + stop: noop, + isEnabled: true, + disable: noop, + enable: noop, +}; + +const stopTracks = (stream: MediaStream) => { + stream.getTracks().forEach((track) => { + track.stop(); + }); +}; + +/** + * Hook that returns media stream and methods to control it, depending on the passed method. + * + * @param getMedia - Promise that returns a promise with MediaStream + * @returns object containing information about the media stream and methods to control it + */ +export const useMedia = (getMedia: (() => Promise) | null): UseMedia => { + const [state, setState] = useState(defaultState); + + const start: (getMedia: () => Promise) => Promise = useCallback( + (getMedia: () => Promise) => { + const setEnable = (stream: MediaStream, status: boolean) => { + stream?.getTracks().forEach((track: MediaStreamTrack) => { + track.enabled = status; + }); + setState( + (prevState: UseMedia): UseMedia => ({ + ...prevState, + isEnabled: status, + }) + ); + }; + + setState((prevState) => ({ ...prevState, isLoading: true })); + + return getMedia() + .then((mediasStream) => { + const stop = () => { + stopTracks(mediasStream); + setState((prevState) => ({ + ...prevState, + stop: noop, + start: () => start(getMedia), + stream: null, + isEnabled: false, + disable: noop, + enable: noop, + })); + }; + + setState((prevState: UseMedia) => { + return { + ...prevState, + isLoading: false, + stream: mediasStream, + start: noop, + stop: stop, + disable: () => setEnable(mediasStream, false), + enable: () => setEnable(mediasStream, true), + isEnabled: true, + }; + }); + return mediasStream; + }) + .catch((e) => { + setState((prevState) => ({ + ...prevState, + isLoading: false, + isError: true, + isEnabled: false, + disable: noop, + enable: noop, + })); + return Promise.reject(e); + }); + }, + [] + ); + + useEffect(() => { + if (!getMedia) return; + const result: Promise = start(getMedia); + + return () => { + result.then((mediaStream) => { + stopTracks(mediaStream); + setState((prevState) => ({ + ...prevState, + stop: noop, + start: () => start(getMedia), + stream: null, + })); + }); + }; + }, [getMedia, start]); + + return state; +}; diff --git a/assets/src/features/home-page/components/HomePageVideoTile.tsx b/assets/src/features/home-page/components/HomePageVideoTile.tsx index e952be4d..b7ee7bc7 100644 --- a/assets/src/features/home-page/components/HomePageVideoTile.tsx +++ b/assets/src/features/home-page/components/HomePageVideoTile.tsx @@ -1,108 +1,98 @@ import React from "react"; import MediaControlButton from "../../../pages/room/components/MediaControlButton"; import MediaPlayerTile from "../../../pages/room/components/StreamPlayer/MediaPlayerTile"; -import { AUDIO_TRACKS_CONFIG, VIDEO_TRACKS_CONFIG } from "../../../pages/room/consts"; -import { useMedia } from "../../../pages/room/hooks/useMedia"; -import { usePeersState } from "../../../pages/room/hooks/usePeerState"; -import { useSetLocalUserTrack } from "../../../pages/room/hooks/useSetLocalUserTrack"; -import { TrackWithId } from "../../../pages/types"; import InitialsImage, { computeInitials } from "../../room-page/components/InitialsImage"; import { activeButtonStyle, neutralButtonStyle } from "../../room-page/consts"; import Camera from "../../room-page/icons/Camera"; import CameraOff from "../../room-page/icons/CameraOff"; import Microphone from "../../room-page/icons/Microphone"; import MicrophoneOff from "../../room-page/icons/MicrophoneOff"; -import { remoteTrackToLocalTrack } from "../../room-page/utils/remoteTrackToLocalTrack"; -import { usePreviewSettings } from "../hooks/usePreviewSettings"; +import Settings from "../../room-page/icons/Settings"; +import { useModal } from "../../../contexts/ModalContext"; +import { useLocalPeer } from "../../devices/LocalPeerMediaContext"; type HomePageVideoTileProps = { displayName: string; }; const HomePageVideoTile: React.FC = ({ displayName }) => { - const { cameraAutostart, audioAutostart } = usePreviewSettings(); - const { state: peerState, api: peerApi } = usePeersState(); + const { audio, video } = useLocalPeer(); - const localPeer = peerState.local; - - const videoTrack: TrackWithId | null = remoteTrackToLocalTrack(localPeer?.tracks["camera"]); const initials = computeInitials(displayName); - - const localCamera = useMedia(VIDEO_TRACKS_CONFIG, cameraAutostart.status); - useSetLocalUserTrack("camera", peerApi, localCamera.stream, localCamera.isEnabled); - const localAudio = useMedia(AUDIO_TRACKS_CONFIG, audioAutostart.status); - useSetLocalUserTrack("audio", peerApi, localAudio.stream, localAudio.isEnabled); + const { setOpen } = useModal(); return ( - - {!cameraAutostart.status || !localCamera.isEnabled ? : null} -
- {localCamera.isEnabled ? ( - { - localCamera.disable(); - cameraAutostart.setCameraAutostart(false); - }} - /> - ) : ( - { - if (localCamera?.stream) { - localCamera.enable(); - } else { - localCamera.start(); - } - cameraAutostart.setCameraAutostart(true); - }} - /> - )} - {localAudio.isEnabled ? ( - { - localAudio.disable(); - audioAutostart.setAudioAutostart(false); - }} - /> - ) : ( +
+ + {!video.device.isEnabled ? : null} +
+ {video.device.isEnabled ? ( + { + video.device.stop(); + }} + /> + ) : ( + { + if (video.device.stream) { + video.device.enable(); + } else { + video.device.start(); + } + }} + /> + )} + {audio.device.isEnabled ? ( + { + audio.device.stop(); + }} + /> + ) : ( + { + if (audio.device.stream) { + audio.device.enable(); + } else { + audio.device.start(); + } + }} + /> + )} { - if (localAudio.stream) { - localAudio.enable(); - } else { - localAudio.start(); - } - audioAutostart.setAudioAutostart(true); + setOpen(true); }} /> - )} -
- - } - /> +
+ + } + /> +
); }; diff --git a/assets/src/features/home-page/components/VideoroomHomePage.tsx b/assets/src/features/home-page/components/VideoroomHomePage.tsx index 51ad9a1f..d723d7af 100644 --- a/assets/src/features/home-page/components/VideoroomHomePage.tsx +++ b/assets/src/features/home-page/components/VideoroomHomePage.tsx @@ -2,15 +2,11 @@ import React, { useCallback, useMemo, useState } from "react"; import { useParams, useSearchParams } from "react-router-dom"; import { useDeveloperInfo } from "../../../contexts/DeveloperInfoContext"; import { useUser } from "../../../contexts/UserContext"; -import { messageComparator } from "../../../pages/room/errorMessage"; import { DEFAULT_MANUAL_MODE_CHECKBOX_VALUE, DEFAULT_SMART_LAYER_SWITCHING_VALUE } from "../../../pages/room/consts"; -import { useMediaDeviceManager } from "../../../pages/room/hooks/useMediaDeviceManager"; import { useToggle } from "../../../pages/room/hooks/useToggle"; import Button from "../../shared/components/Button"; import { Checkbox, CheckboxProps } from "../../shared/components/Checkbox"; import Input from "../../shared/components/Input"; -import useEffectOnChange from "../../shared/hooks/useEffectOnChange"; -import useToast from "../../shared/hooks/useToast"; import { MobileLoginStep, MobileLoginStepType } from "../types"; import HomePageLayout from "./HomePageLayout"; @@ -27,7 +23,6 @@ const VideoroomHomePage: React.FC = () => { const [roomIdInput, setRoomIdInput] = useState(roomId); const buttonDisabled = !displayNameInput || !roomIdInput; - const deviceManager = useMediaDeviceManager({ askOnMount: true }); const { simulcast, manualMode, smartLayerSwitching } = useDeveloperInfo(); const [searchParams] = useSearchParams(); @@ -76,22 +71,6 @@ const VideoroomHomePage: React.FC = () => { smartLayerSwitchingInput, ]); - const { addToast } = useToast(); - useEffectOnChange( - deviceManager.errorMessage, - () => { - if (deviceManager.errorMessage) { - addToast({ - id: deviceManager.errorMessage.id || crypto.randomUUID(), - message: deviceManager.errorMessage.message, - timeout: "INFINITY", - type: "error", - }); - } - }, - messageComparator - ); - const inputs = useMemo(() => { return ( <> @@ -207,7 +186,7 @@ const VideoroomHomePage: React.FC = () => { {/* desktop view */} <> -
+
diff --git a/assets/src/features/home-page/context/PreviewSettingsContext.tsx b/assets/src/features/home-page/context/PreviewSettingsContext.tsx deleted file mode 100644 index e7fabf21..00000000 --- a/assets/src/features/home-page/context/PreviewSettingsContext.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React, { createContext, useState } from "react"; -import { DEFAULT_AUTOSTART_CAMERA_VALUE, DEFAULT_AUTOSTART_MICROPHONE_VALUE } from "../../../pages/room/consts"; - -export type PreviewSettings = { - cameraAutostart: { status: boolean; setCameraAutostart: (status: boolean) => void }; - audioAutostart: { status: boolean; setAudioAutostart: (status: boolean) => void }; -}; - -export const PreviewSettingsContext = createContext({ - cameraAutostart: { status: false, setCameraAutostart: () => console.log("Error while creating context") }, - audioAutostart: { status: false, setAudioAutostart: () => console.log("Error while creating context") }, -}); - -type Props = { - children: React.ReactNode; -}; - -export const PreviewSettingsProvider = ({ children }: Props) => { - const [cameraAutostart, setCameraAutostart] = useState(DEFAULT_AUTOSTART_CAMERA_VALUE); - const [audioAutostart, setAudioAutostart] = useState(DEFAULT_AUTOSTART_MICROPHONE_VALUE); - - return ( - - {children} - - ); -}; diff --git a/assets/src/features/home-page/hooks/usePreviewSettings.tsx b/assets/src/features/home-page/hooks/usePreviewSettings.tsx deleted file mode 100644 index be608f97..00000000 --- a/assets/src/features/home-page/hooks/usePreviewSettings.tsx +++ /dev/null @@ -1,4 +0,0 @@ -import { useContext } from "react"; -import { PreviewSettingsContext } from "../context/PreviewSettingsContext"; - -export const usePreviewSettings = () => useContext(PreviewSettingsContext); diff --git a/assets/src/features/room-page/components/DisabledTrackIcon.tsx b/assets/src/features/room-page/components/DisabledTrackIcon.tsx index 858b1a61..6e1b6ec1 100644 --- a/assets/src/features/room-page/components/DisabledTrackIcon.tsx +++ b/assets/src/features/room-page/components/DisabledTrackIcon.tsx @@ -18,4 +18,4 @@ export const DisabledMicIcon = ({ isLoading }: DisabledMicIconProps) => { export const isLoading = (track: TrackWithId | null) => track !== null && track?.stream === undefined && track?.metadata?.active === true; export const showDisabledIcon = (track: TrackWithId | null) => - (track !== null && track?.stream === undefined) || track?.metadata?.active === false; + track === null || !track?.stream || track?.metadata?.active === false; diff --git a/assets/src/features/room-page/components/Navbar.tsx b/assets/src/features/room-page/components/Navbar.tsx index 285c391a..acc40c01 100644 --- a/assets/src/features/room-page/components/Navbar.tsx +++ b/assets/src/features/room-page/components/Navbar.tsx @@ -6,36 +6,63 @@ import MembraneVideoroomLogo from "../../shared/components/MembraneVideoroomLogo import PlainLink from "../../shared/components/PlainLink"; import useToast from "../../shared/hooks/useToast"; import ShareSquare from "../icons/ShareSquare"; +import Settings from "../icons/Settings"; +import MediaControlButton from "../../../pages/room/components/MediaControlButton"; +import { useModal } from "../../../contexts/ModalContext"; +import useSmartphoneViewport from "../../shared/hooks/useSmartphoneViewport"; const Navbar: React.FC = () => { const match = useParams(); const currentUrl = window.location.href; const roomId: string = match?.roomId || ""; const { addToast } = useToast(); + const { setOpen } = useModal(); const onLinkCopy = useCallback(async () => { await navigator.clipboard.writeText(currentUrl); addToast({ id: "toast-link-copied", message: "Link copied to clipboard" }); }, [addToast, currentUrl]); + const isSmartphone = useSmartphoneViewport().isSmartphone; + return ( -
- - - -
- Invite link - + + )} + { + setOpen(true); + }} variant="light" - className={clsx( - "!rounded-3xl !border !border-brand-dark-blue-200 !px-5 !py-1", - "flex items-center gap-x-2", - "!text-base" - )} - > - {roomId} - + position="bottom" + hoverClassName="-ml-10" + />
); diff --git a/assets/src/features/room-page/components/PageLayout.tsx b/assets/src/features/room-page/components/PageLayout.tsx index 602ac72a..b7205689 100644 --- a/assets/src/features/room-page/components/PageLayout.tsx +++ b/assets/src/features/room-page/components/PageLayout.tsx @@ -15,7 +15,7 @@ const PageLayout: React.FC = ({ children }) => { className={clsx( "h-screen w-full bg-auto bg-center bg-no-repeat sm:bg-videoroom-background", "bg-brand-sea-blue-100 font-rocGrotesk text-brand-dark-blue-500", - "flex flex-col items-center gap-y-4 p-4", + "flex flex-col items-center gap-y-4 overflow-x-hidden p-4", shouldBlockScreen && "invisible" )} > diff --git a/assets/src/features/room-page/icons/Settings.tsx b/assets/src/features/room-page/icons/Settings.tsx new file mode 100644 index 00000000..85091cb3 --- /dev/null +++ b/assets/src/features/room-page/icons/Settings.tsx @@ -0,0 +1,24 @@ +import clsx from "clsx"; +import React from "react"; + +const Settings: React.FC> = (props) => { + return ( + + + + ); +}; + +export default Settings; diff --git a/assets/src/features/room-page/utils/getVideoGridConfig.tsx b/assets/src/features/room-page/utils/getVideoGridConfig.tsx index d616e803..05ece38d 100644 --- a/assets/src/features/room-page/utils/getVideoGridConfig.tsx +++ b/assets/src/features/room-page/utils/getVideoGridConfig.tsx @@ -46,6 +46,7 @@ export function getGridConfig(peers: number): GridConfigType { if (peers < 13) return "gap-3"; return "gap-2"; } + const grid = "grid place-content-center grid-flow-row"; const gap = getGridGap(); const padding = peers >= 10 && peers < 13 ? "xl:px-[140px]" : ""; @@ -63,7 +64,8 @@ export const getUnpinnedTilesGridStyle = ( isAnyTilePinned: boolean, horizontalRow: boolean, videoInVideo: boolean, - fixedRatio: boolean + fixedRatio: boolean, + isMobile: boolean ): string => { if (!isAnyTilePinned) return clsx( @@ -77,10 +79,8 @@ export const getUnpinnedTilesGridStyle = ( if (fixedRatio) return clsx( - "w-[400px]", - videoInVideo - ? "h-[220px] absolute bottom-4 right-4 z-10" - : "h-full flex flex-wrap flex-col content-center justify-center" + videoInVideo ? "absolute bottom-4 right-4 z-10" : "h-full flex flex-wrap flex-col content-center justify-center", + isMobile ? "w-[200px] h-[110px]" : "w-[400px] h-[220px]" ); const horizontal = horizontalRow ? "sm:flex-row" : "sm:flex-col"; diff --git a/assets/src/features/shared/components/Button.tsx b/assets/src/features/shared/components/Button.tsx index a295282c..379f598a 100644 --- a/assets/src/features/shared/components/Button.tsx +++ b/assets/src/features/shared/components/Button.tsx @@ -2,15 +2,16 @@ import clsx from "clsx"; import React from "react"; import PlainLink, { PlainLinkProps } from "./PlainLink"; -type ButtonVariant = "normal" | "light" | "transparent" | "transparent-light"; +export type ButtonVariant = "normal" | "light" | "transparent" | "transparent-light"; -type ButtonProps = PlainLinkProps & { variant?: ButtonVariant }; +type ButtonProps = PlainLinkProps & { variant?: ButtonVariant; removeDefaultPadding?: boolean }; const Button: React.FC = (props) => { return ( ) => void; +type BaseInputProps = { name?: string; type?: InputType; placeholder?: string; label?: string; additionalText?: string; - required?: boolean; disabled?: boolean; error?: boolean; disableAutocomplete?: boolean; className?: string; + wrapperClassName?: string; +}; + +type SelectInputProps = BaseInputProps & + SelectProps & { + type: "select"; + value?: SelectOption; + }; + +type TextInputProps = BaseInputProps & { + type: "text"; + value?: string; + required?: boolean; + onChange?: (e: React.ChangeEvent) => void; }; -const Input: React.FC = ({ - value, - onChange, - type, - placeholder, - label, - name, - additionalText, - required, - disabled, - error, - disableAutocomplete = true, - className, -}) => { +type InputProps = SelectInputProps | TextInputProps; + +const Input: React.FC = (props) => { + const { + value, + onChange, + type, + placeholder, + label, + name, + additionalText, + disabled, + error, + disableAutocomplete = true, + className, + wrapperClassName, + ...otherProps + } = props; + + const inputClassName = clsx( + "w-full px-3.5 py-3", + "rounded-[40px] border-2 text-brand-dark-blue-500 focus:outline-none ", + error + ? "border-brand-red" + : disabled + ? "border-brand-grey-60 bg-white text-brand-grey-80" + : "border-brand-dark-blue-500 focus:border-brand-sea-blue-400", + "appearance-none", + className + ); + + const getComponent = () => { + switch (type) { + case "select": + return ( + + ); + } + }; + return ( -
+
{label && ( )} - + + {getComponent()} + {additionalText && ( , "onChange"> & { + onChange?: (value: SelectOption) => void; + controlClassName?: string; +}; + +export type SelectOption = { + value: string; + label: string; +}; + +export const Select: FC = ({ onChange, controlClassName, ...otherProps }) => { + return ( + { + return { + ...base, + top: "calc(100% + 11px)", + }; + }, + }} + classNames={{ + control: (state) => clsx(controlClassName, state.isFocused && "border-brand-sea-blue-400"), + option: (state) => + clsx( + "px-4 py-3.5 hover:bg-brand-dark-blue-100 focus-within:bg-brand-dark-blue-100", + state.isFocused && "bg-brand-dark-blue-100" + ), + menu: () => + "max-h-40 rounded-lg border-brand-dark-blue-200 border-2 overflow-y-auto bg-brand-white flex flex-col", + }} + onChange={(v) => onChange?.(v as SelectOption)} + {...otherProps} + /> + ); +}; diff --git a/assets/src/features/shared/components/Toast.tsx b/assets/src/features/shared/components/Toast.tsx index ec2b86f1..c42d5a2b 100644 --- a/assets/src/features/shared/components/Toast.tsx +++ b/assets/src/features/shared/components/Toast.tsx @@ -19,7 +19,7 @@ const Toast: React.FC = ({ id, message, onClose, type = "information )} >
{message}
-
diff --git a/assets/src/features/shared/components/modal/Modal.tsx b/assets/src/features/shared/components/modal/Modal.tsx new file mode 100644 index 00000000..b5e1df68 --- /dev/null +++ b/assets/src/features/shared/components/modal/Modal.tsx @@ -0,0 +1,62 @@ +import clsx from "clsx"; +import React from "react"; +import ReactModal from "react-modal"; +import Close from "../../../room-page/icons/Close"; +import Button from "../Button"; + +interface ModalProps extends ReactModal.Props { + title: string; + onConfirm?: () => void; + onCancel?: () => void; + onRequestClose?: () => void; + confirmText?: string; + confirmClassName?: string; + cancelText?: string; + cancelClassName?: string; + closable?: boolean; + maxWidth?: string; +} + +export const Modal: React.FC = ({ + maxWidth, + title, + onRequestClose, + closable, + children, + onConfirm, + confirmClassName, + confirmText, + onCancel, + cancelClassName, + cancelText, + ...otherProps +}) => { + return ( + +
+
+ {title} +
+ +
+
{children ?? "Modal message"}
+
+ + +
+
+ ); +}; diff --git a/assets/src/features/shared/hooks/useEffectOnChange.tsx b/assets/src/features/shared/hooks/useEffectOnChange.tsx index 5c877ef2..c7f6eaf3 100644 --- a/assets/src/features/shared/hooks/useEffectOnChange.tsx +++ b/assets/src/features/shared/hooks/useEffectOnChange.tsx @@ -1,5 +1,13 @@ import { useEffect, useRef } from "react"; +/** + * This hook only triggers an effect function if the value passed as the first parameter changes. + * + * @param value - Effect will only activate if the value changes. + * @param effect - Imperative function that can return a cleanup function. + * @param comparator - If the value is not a primitive type, such as a numerical or string value, + * you must provide a comparator function to prevent unnecessary invocations. + */ const useEffectOnChange = ( value: T, effect: (prevValue?: T) => void | (() => void), diff --git a/assets/src/features/shared/utils/localStorage.ts b/assets/src/features/shared/utils/localStorage.ts new file mode 100644 index 00000000..19710a80 --- /dev/null +++ b/assets/src/features/shared/utils/localStorage.ts @@ -0,0 +1,21 @@ +export const loadObject = (key: string, defaultValue: T): T => { + const stringValue = loadString(key, ""); + if (stringValue === "") { + return defaultValue; + } + return JSON.parse(stringValue) as T; +}; +export const loadString = (key: string, defaultValue = "") => { + const value = localStorage.getItem(key); + if (value === null || value === undefined) { + return defaultValue; + } + return value; +}; +export const saveObject = (key: string, value: T) => { + const stringValue = JSON.stringify(value); + saveString(key, stringValue); +}; +export const saveString = (key: string, value: string) => { + localStorage.setItem(key, value); +}; diff --git a/assets/src/index.tsx b/assets/src/index.tsx index aa466405..4e02061c 100644 --- a/assets/src/index.tsx +++ b/assets/src/index.tsx @@ -1,5 +1,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; +import ReactModal from "react-modal"; import App from "./App"; +ReactModal.setAppElement("#root"); ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(); diff --git a/assets/src/pages/room/RoomPage.tsx b/assets/src/pages/room/RoomPage.tsx index f2572970..d814bdfa 100644 --- a/assets/src/pages/room/RoomPage.tsx +++ b/assets/src/pages/room/RoomPage.tsx @@ -1,5 +1,4 @@ import React, { FC, useState } from "react"; -import { AUDIO_TRACKS_CONFIG, SCREEN_SHARING_TRACKS_CONFIG, VIDEO_TRACKS_CONFIG } from "./consts"; import { useMembraneClient } from "./hooks/useMembraneClient"; import MediaControlButtons from "./components/MediaControlButtons"; import { PeerMetadata, usePeersState } from "./hooks/usePeerState"; @@ -14,6 +13,7 @@ import useEffectOnChange from "../../features/shared/hooks/useEffectOnChange"; import { ErrorMessage, messageComparator } from "./errorMessage"; import { useAcquireWakeLockAutomatically } from "./hooks/useAcquireWakeLockAutomatically"; import clsx from "clsx"; +import { useLocalPeer } from "../../features/devices/LocalPeerMediaContext"; import RoomSidebar from "./RoomSidebar"; type Props = { @@ -21,18 +21,9 @@ type Props = { roomId: string; isSimulcastOn: boolean; manualMode: boolean; - cameraAutostartStreaming?: boolean; - audioAutostartStreaming?: boolean; }; -const RoomPage: FC = ({ - roomId, - displayName, - isSimulcastOn, - manualMode, - cameraAutostartStreaming, - audioAutostartStreaming, -}: Props) => { +const RoomPage: FC = ({ roomId, displayName, isSimulcastOn, manualMode }: Props) => { useAcquireWakeLockAutomatically(); const mode: StreamingMode = manualMode ? "manual" : "automatic"; @@ -46,41 +37,32 @@ const RoomPage: FC = ({ const isConnected = !!peerState?.local?.id; + const { video: localVideo, audio: localAudio, screenShare } = useLocalPeer(); + const camera = useStreamManager( "camera", mode, isConnected, isSimulcastOn, - webrtc, - VIDEO_TRACKS_CONFIG, - peerApi, - cameraAutostartStreaming - ); - const audio = useStreamManager( - "audio", - mode, - isConnected, - isSimulcastOn, - webrtc, - AUDIO_TRACKS_CONFIG, + webrtc || null, peerApi, - audioAutostartStreaming + localVideo.device ); + const audio = useStreamManager("audio", mode, isConnected, isSimulcastOn, webrtc || null, peerApi, localAudio.device); const screenSharing = useStreamManager( "screensharing", mode, isConnected, isSimulcastOn, - webrtc, - SCREEN_SHARING_TRACKS_CONFIG, + webrtc || null, peerApi, - false + screenShare.device ); const { addToast } = useToast(); - useEffectOnChange(screenSharing.local.isEnabled, () => { - if (screenSharing.local.isEnabled) { + useEffectOnChange(screenSharing.local.stream, () => { + if (screenSharing.local.stream) { addToast({ id: "screen-sharing", message: "You are sharing the screen now", timeout: 4000 }); } }); diff --git a/assets/src/pages/room/components/MediaControlButton.tsx b/assets/src/pages/room/components/MediaControlButton.tsx index 803e322e..d241b5e9 100644 --- a/assets/src/pages/room/components/MediaControlButton.tsx +++ b/assets/src/pages/room/components/MediaControlButton.tsx @@ -1,44 +1,66 @@ import clsx from "clsx"; import React, { FC, SVGAttributes } from "react"; -import Button from "../../../features/shared/components/Button"; +import Button, { ButtonVariant } from "../../../features/shared/components/Button"; export type MediaControlButtonProps = { onClick: () => void; hover?: string; - className?: string; + buttonClassName?: string; + hoverClassName?: string; hideOnMobile?: boolean; icon: React.FC>; + variant?: ButtonVariant; + position?: "top" | "bottom"; }; -const MediaControlButton: FC = ({ - hover, - icon: Icon, - onClick, - hideOnMobile, - className, -}: MediaControlButtonProps) => { +const MediaControlButton: FC = (props: MediaControlButtonProps) => { + const { + hover, + icon: Icon, + onClick, + hideOnMobile, + buttonClassName, + hoverClassName, + position = "top", + variant, + } = props; + return (
{hover && ( -
- +
+ {hover}
diff --git a/assets/src/pages/room/components/MediaControlButtons.tsx b/assets/src/pages/room/components/MediaControlButtons.tsx index 19b24223..a1d85b50 100644 --- a/assets/src/pages/room/components/MediaControlButtons.tsx +++ b/assets/src/pages/room/components/MediaControlButtons.tsx @@ -1,6 +1,5 @@ import React, { FC } from "react"; -import { UseMediaResult } from "../hooks/useMedia"; import MediaControlButton, { MediaControlButtonProps } from "./MediaControlButton"; import { NavigateFunction, useNavigate, useParams } from "react-router-dom"; import { MembraneStreaming, StreamingMode } from "../hooks/useMembraneMediaStreaming"; @@ -15,32 +14,29 @@ import Chat from "../../../features/room-page/icons/Chat"; import useSmartphoneViewport from "../../../features/shared/hooks/useSmartphoneViewport"; import MenuDots from "../../../features/room-page/icons/MenuDots"; import { activeButtonStyle, neutralButtonStyle, redButtonStyle } from "../../../features/room-page/consts"; +import { SCREENSHARING_MEDIA_CONSTRAINTS } from "../consts"; +import { Device, useLocalPeer } from "../../../features/devices/LocalPeerMediaContext"; type ControlButton = MediaControlButtonProps & { id: string }; const getAutomaticControls = ( - { - userMediaAudio, - audioStreaming, - userMediaVideo, - cameraStreaming, - displayMedia, - screenSharingStreaming, - isSidebarOpen, - openSidebar, - }: LocalUserMediaControls, + { audioStreaming, cameraStreaming, screenSharingStreaming, isSidebarOpen, openSidebar }: LocalUserMediaControls, navigate: NavigateFunction, - isMobileViewport?: boolean, - roomId?: string + roomId: string | null, + videoDevice: Device, + audioDevice: Device, + screenSharingDevice: Device, + setScreenSharingConfig: (constraints: MediaStreamConstraints | null) => void, + isMobileViewport?: boolean ): ControlButton[] => [ - userMediaVideo.isEnabled + videoDevice.isEnabled ? { id: "cam-off", icon: Camera, hover: "Turn off the camera", - className: neutralButtonStyle, + buttonClassName: neutralButtonStyle, onClick: () => { - userMediaVideo.disable(); + videoDevice.disable(); cameraStreaming.setActive(false); }, } @@ -48,24 +44,24 @@ const getAutomaticControls = ( id: "cam-on", hover: "Turn on the camera", icon: CameraOff, - className: activeButtonStyle, + buttonClassName: activeButtonStyle, onClick: () => { - if (userMediaVideo.stream) { - userMediaVideo.enable(); + if (videoDevice.stream) { + videoDevice.enable(); } else { - userMediaVideo.start(); + videoDevice.start(); } cameraStreaming.setActive(true); }, }, - userMediaAudio.isEnabled + audioDevice.isEnabled ? { id: "mic-mute", icon: Microphone, hover: "Turn off the microphone", - className: neutralButtonStyle, + buttonClassName: neutralButtonStyle, onClick: () => { - userMediaAudio.disable(); + audioDevice.disable(); audioStreaming.setActive(false); }, } @@ -73,25 +69,25 @@ const getAutomaticControls = ( id: "mic-unmute", icon: MicrophoneOff, hover: "Turn on the microphone", - className: activeButtonStyle, + buttonClassName: activeButtonStyle, onClick: () => { - if (userMediaAudio.stream) { - userMediaAudio.enable(); + if (audioDevice.stream) { + audioDevice.enable(); } else { - userMediaAudio.start(); + audioDevice.start(); } audioStreaming.setActive(true); }, }, - displayMedia.stream + screenSharingDevice.stream ? { id: "screenshare-stop", icon: Screenshare, hover: "Stop sharing your screen", - className: neutralButtonStyle, + buttonClassName: neutralButtonStyle, hideOnMobile: true, onClick: () => { - displayMedia.stop(); + setScreenSharingConfig(null); screenSharingStreaming.setActive(false); }, } @@ -99,10 +95,10 @@ const getAutomaticControls = ( id: "screenshare-start", icon: Screenshare, hover: "Share your screen", - className: neutralButtonStyle, + buttonClassName: neutralButtonStyle, hideOnMobile: true, onClick: () => { - displayMedia.start(); + setScreenSharingConfig(SCREENSHARING_MEDIA_CONSTRAINTS); screenSharingStreaming.setActive(true); }, }, @@ -110,14 +106,14 @@ const getAutomaticControls = ( id: "chat", icon: isMobileViewport ? MenuDots : Chat, hover: isMobileViewport ? undefined : isSidebarOpen ? "Close the sidebar" : "Open the sidebar", - className: isSidebarOpen ? activeButtonStyle : neutralButtonStyle, + buttonClassName: isSidebarOpen ? activeButtonStyle : neutralButtonStyle, onClick: openSidebar, }, { id: "leave-room", icon: HangUp, hover: "Leave the room", - className: redButtonStyle, + buttonClassName: redButtonStyle, onClick: () => { navigate(`/room/${roomId}`, { state: { isLeavingRoom: true } }); }, @@ -142,14 +138,14 @@ const getManualControls = ( ? { id: "mic-stop", icon: Microphone, - className: neutralButtonStyle, + buttonClassName: neutralButtonStyle, hover: "Start the microphone", onClick: () => userMediaAudio.stop(), } : { id: "mic-start", icon: MicrophoneOff, - className: activeButtonStyle, + buttonClassName: activeButtonStyle, hover: "Stop the microphone", onClick: () => userMediaAudio.start(), }, @@ -157,14 +153,14 @@ const getManualControls = ( ? { id: "mic-disable", icon: Microphone, - className: neutralButtonStyle, + buttonClassName: neutralButtonStyle, hover: "Disable microphone stream", onClick: () => userMediaAudio.disable(), } : { id: "mic-enable", icon: MicrophoneOff, - className: activeButtonStyle, + buttonClassName: activeButtonStyle, hover: "Enable microphone stream", onClick: () => userMediaAudio.enable(), }, @@ -172,14 +168,14 @@ const getManualControls = ( ? { id: "mic-remove", icon: Microphone, - className: neutralButtonStyle, + buttonClassName: neutralButtonStyle, hover: "Remove microphone track", onClick: () => audioStreaming.removeTracks(), } : { id: "mic-add", icon: MicrophoneOff, - className: activeButtonStyle, + buttonClassName: activeButtonStyle, hover: "Add microphone track", onClick: () => userMediaAudio?.stream && audioStreaming.addTracks(userMediaAudio?.stream), }, @@ -187,14 +183,14 @@ const getManualControls = ( ? { id: "mic-metadata-false", icon: Microphone, - className: neutralButtonStyle, + buttonClassName: neutralButtonStyle, hover: "Set 'active' metadata to 'false'", onClick: () => audioStreaming.setActive(false), } : { id: "mic-metadata-true", icon: MicrophoneOff, - className: activeButtonStyle, + buttonClassName: activeButtonStyle, hover: "Set 'active' metadata to 'true'", onClick: () => audioStreaming.setActive(true), }, @@ -204,7 +200,7 @@ const getManualControls = ( ? { id: "cam-stop", icon: Camera, - className: neutralButtonStyle, + buttonClassName: neutralButtonStyle, hover: "Turn off the camera", onClick: () => userMediaVideo.stop(), } @@ -212,14 +208,14 @@ const getManualControls = ( id: "cam-start", hover: "Turn on the camera", icon: CameraOff, - className: activeButtonStyle, + buttonClassName: activeButtonStyle, onClick: () => userMediaVideo.start(), }, userMediaVideo.isEnabled ? { id: "cam-disable", icon: Camera, - className: neutralButtonStyle, + buttonClassName: neutralButtonStyle, hover: "Disable the camera stream", onClick: () => userMediaVideo.disable(), } @@ -227,21 +223,21 @@ const getManualControls = ( id: "cam-enable", hover: "Enable the the camera stream", icon: CameraOff, - className: activeButtonStyle, + buttonClassName: activeButtonStyle, onClick: () => userMediaVideo.enable(), }, cameraStreaming.trackId ? { id: "cam-remove", icon: Camera, - className: neutralButtonStyle, + buttonClassName: neutralButtonStyle, hover: "Remove camera track", onClick: () => cameraStreaming.removeTracks(), } : { id: "cam-add", icon: CameraOff, - className: activeButtonStyle, + buttonClassName: activeButtonStyle, hover: "Add camera track", onClick: () => userMediaVideo?.stream && cameraStreaming.addTracks(userMediaVideo?.stream), }, @@ -249,14 +245,14 @@ const getManualControls = ( ? { id: "cam-metadata-false", icon: Camera, - className: neutralButtonStyle, + buttonClassName: neutralButtonStyle, hover: "Set 'active' metadata to 'false'", onClick: () => cameraStreaming.setActive(false), } : { id: "cam-metadata-true", icon: CameraOff, - className: activeButtonStyle, + buttonClassName: activeButtonStyle, hover: "Set 'active' metadata to 'true'", onClick: () => cameraStreaming.setActive(true), }, @@ -266,7 +262,7 @@ const getManualControls = ( ? { id: "screen-stop", icon: Screenshare, - className: neutralButtonStyle, + buttonClassName: neutralButtonStyle, hover: "Stop the screensharing", hideOnMobile: true, onClick: () => displayMedia.stop(), @@ -274,7 +270,7 @@ const getManualControls = ( : { id: "screen-start", icon: Screenshare, - className: neutralButtonStyle, + buttonClassName: neutralButtonStyle, hover: "Start the screensharing", hideOnMobile: true, onClick: () => displayMedia.start(), @@ -283,7 +279,7 @@ const getManualControls = ( ? { id: "screen-disable", icon: Screenshare, - className: neutralButtonStyle, + buttonClassName: neutralButtonStyle, hover: "Disable screensharing stream", hideOnMobile: true, onClick: () => displayMedia.disable(), @@ -291,7 +287,7 @@ const getManualControls = ( : { id: "screen-enable", icon: Screenshare, - className: neutralButtonStyle, + buttonClassName: neutralButtonStyle, hover: "Enable screensharing stream", hideOnMobile: true, onClick: () => displayMedia.enable(), @@ -300,7 +296,7 @@ const getManualControls = ( ? { id: "screen-remove", icon: Screenshare, - className: neutralButtonStyle, + buttonClassName: neutralButtonStyle, hover: "Remove screensharing track", hideOnMobile: true, onClick: () => screenSharingStreaming.removeTracks(), @@ -308,7 +304,7 @@ const getManualControls = ( : { id: "screen-add", icon: Screenshare, - className: neutralButtonStyle, + buttonClassName: neutralButtonStyle, hover: "Add screensharing track", hideOnMobile: true, onClick: () => displayMedia?.stream && screenSharingStreaming.addTracks(displayMedia?.stream), @@ -317,7 +313,7 @@ const getManualControls = ( ? { id: "screen-metadata-false", icon: Screenshare, - className: neutralButtonStyle, + buttonClassName: neutralButtonStyle, hover: "Set 'active' metadata to 'false'", hideOnMobile: true, onClick: () => screenSharingStreaming.setActive(false), @@ -325,7 +321,7 @@ const getManualControls = ( : { id: "screen-metadata-true", icon: Screenshare, - className: neutralButtonStyle, + buttonClassName: neutralButtonStyle, hover: "Set 'active' metadata to 'true'", hideOnMobile: true, onClick: () => screenSharingStreaming.setActive(true), @@ -336,7 +332,7 @@ const getManualControls = ( id: "leave-room", icon: HangUp, hover: "Leave the room", - className: redButtonStyle, + buttonClassName: redButtonStyle, onClick: () => { navigate(`/room/${roomId}`, { state: { isLeavingRoom: true } }); }, @@ -349,11 +345,11 @@ type Props = { } & LocalUserMediaControls; type LocalUserMediaControls = { - userMediaVideo: UseMediaResult; + userMediaVideo: Device; cameraStreaming: MembraneStreaming; - userMediaAudio: UseMediaResult; + userMediaAudio: Device; audioStreaming: MembraneStreaming; - displayMedia: UseMediaResult; + displayMedia: Device; screenSharingStreaming: MembraneStreaming; isSidebarOpen?: boolean; openSidebar: () => void; @@ -366,10 +362,23 @@ const MediaControlButtons: FC = (props: Props) => { const navigate = useNavigate(); + const { audio, video, screenShare } = useLocalPeer(); + const controls: ControlButton[][] = props.mode === "manual" - ? getManualControls(props, navigate) - : [getAutomaticControls(props, navigate, isSmartphone, roomId)]; + ? getManualControls(props, navigate, roomId) + : [ + getAutomaticControls( + props, + navigate, + roomId || null, + video.device, + audio.device, + screenShare.device, + screenShare.setConfig, + isSmartphone + ), + ]; return (
= (props: Props) => {
{controls.map((group, index) => (
- {group.map(({ hover, onClick, className, id, icon, hideOnMobile }) => ( + {group.map(({ hover, onClick, buttonClassName, id, icon, hideOnMobile }) => ( diff --git a/assets/src/pages/room/components/StreamPlayer/UnpinnedTilesSection.tsx b/assets/src/pages/room/components/StreamPlayer/UnpinnedTilesSection.tsx index eaa58f3e..29b77abf 100644 --- a/assets/src/pages/room/components/StreamPlayer/UnpinnedTilesSection.tsx +++ b/assets/src/pages/room/components/StreamPlayer/UnpinnedTilesSection.tsx @@ -14,6 +14,7 @@ import { showDisabledIcon, } from "../../../../features/room-page/components/DisabledTrackIcon"; import SoundIcon from "../../../../features/room-page/components/SoundIcon"; +import useSmartphoneViewport from "../../../../features/shared/hooks/useSmartphoneViewport"; type Props = { tileConfigs: MediaPlayerTileConfig[]; @@ -40,9 +41,18 @@ const UnpinnedTilesSection: FC = ({ horizontal, }: Props) => { const gridConfig = getGridConfig(tileConfigs.length); + const isSmartphone = useSmartphoneViewport().isSmartphone || false; const videoGridStyle = useMemo( - () => getUnpinnedTilesGridStyle(gridConfig, isAnyTilePinned, horizontal, videoInVideo, tileConfigs.length === 1), - [gridConfig, isAnyTilePinned, horizontal, videoInVideo, tileConfigs.length] + () => + getUnpinnedTilesGridStyle( + gridConfig, + isAnyTilePinned, + horizontal, + videoInVideo, + tileConfigs.length === 1, + isSmartphone + ), + [gridConfig, isAnyTilePinned, horizontal, videoInVideo, tileConfigs.length, isSmartphone] ); const tileStyle = !isAnyTilePinned diff --git a/assets/src/pages/room/components/StreamPlayer/simulcast/LayerButton.tsx b/assets/src/pages/room/components/StreamPlayer/simulcast/LayerButton.tsx index e27abcca..115f2c19 100644 --- a/assets/src/pages/room/components/StreamPlayer/simulcast/LayerButton.tsx +++ b/assets/src/pages/room/components/StreamPlayer/simulcast/LayerButton.tsx @@ -15,13 +15,12 @@ export type LayerButtonProps = { export const LayerButton = ({ onClick, text, tooltipText, disabled, selected, tooltipCss = "" }: LayerButtonProps) => (