diff --git a/.gitignore b/.gitignore index 70fed4934..e812b86f4 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,5 @@ node_modules/ /server/mediasoup_valgrind_* /.vscode/ + +/app/dist/ diff --git a/README.md b/README.md index edf36cf50..22b541ecc 100644 --- a/README.md +++ b/README.md @@ -70,10 +70,7 @@ $ cp config.example.js config.js ```bash $ cd app -# For node 16 $ npm install -# For node 18 or higher, use legacy peer dependencies -$ npm install --legacy-peer-deps ``` @@ -100,17 +97,13 @@ If you configured a self-signed tls certificate, and receive wss: connection err ## Deploy it in a server -* Globally install `gulp-cli` NPM module (may need `sudo`): - -```bash -$ npm install -g gulp-cli -``` - * Build the production ready browser application: ```bash $ cd app -$ gulp dist +$ npm run build +$ rm -rf ../server/public +$ mv dist ../server/public ``` * Upload the entire `server` folder to your server and make your web server (Apache, Nginx, etc) expose the `server/public` folder. diff --git a/app/.babelrc b/app/.babelrc deleted file mode 100644 index 20714a435..000000000 --- a/app/.babelrc +++ /dev/null @@ -1,13 +0,0 @@ -{ - "plugins": - [ - "@babel/plugin-transform-runtime", - "@babel/plugin-proposal-object-rest-spread", - "jsx-control-statements" - ], - "presets": - [ - "@babel/env", - "@babel/react" - ] -} diff --git a/app/.eslintrc.cjs b/app/.eslintrc.cjs new file mode 100644 index 000000000..674cc66cd --- /dev/null +++ b/app/.eslintrc.cjs @@ -0,0 +1,227 @@ +const disabled = 0 +const warning = 1 +const error = 2 +const default_level = warning + +module.exports = { + env: { + browser: true, + es6: true, + node: true, + }, + plugins: ['import', 'react'], + extends: ['eslint:recommended', 'plugin:react/recommended'], + settings: { + react: { + pragma: 'React', + version: '18', + }, + }, + parserOptions: { + ecmaVersion: 2018, + sourceType: 'module', + ecmaFeatures: { + impliedStrict: true, + jsx: true, + }, + }, + rules: { + 'array-bracket-spacing': [ + default_level, + 'always', + { + objectsInArrays: true, + arraysInArrays: true, + }, + ], + 'arrow-parens': [default_level, 'always'], + 'arrow-spacing': default_level, + 'block-spacing': [default_level, 'always'], + 'brace-style': [default_level, 'allman', { allowSingleLine: true }], + camelcase: default_level, + 'comma-dangle': default_level, + 'comma-spacing': [default_level, { before: false, after: true }], + 'comma-style': default_level, + 'computed-property-spacing': default_level, + 'constructor-super': default_level, + 'func-call-spacing': default_level, + 'generator-star-spacing': default_level, + 'guard-for-in': default_level, + 'key-spacing': [ + default_level, + { + singleLine: { + beforeColon: false, + afterColon: true, + }, + multiLine: { + beforeColon: true, + afterColon: true, + align: 'colon', + }, + }, + ], + 'keyword-spacing': default_level, + 'linebreak-style': [default_level, 'unix'], + 'lines-around-comment': [ + default_level, + { + allowBlockStart: true, + allowObjectStart: true, + beforeBlockComment: true, + beforeLineComment: false, + }, + ], + 'newline-after-var': default_level, + 'newline-before-return': default_level, + 'newline-per-chained-call': default_level, + 'no-alert': default_level, + 'no-caller': default_level, + 'no-case-declarations': default_level, + 'no-catch-shadow': default_level, + 'no-class-assign': default_level, + 'no-confusing-arrow': default_level, + 'no-console': default_level, + 'no-const-assign': default_level, + 'no-debugger': default_level, + 'no-dupe-args': default_level, + 'no-dupe-keys': default_level, + 'no-duplicate-case': default_level, + 'no-div-regex': default_level, + 'no-empty': [default_level, { allowEmptyCatch: true }], + 'no-empty-pattern': default_level, + 'no-else-return': disabled, + 'no-eval': default_level, + 'no-extend-native': default_level, + 'no-ex-assign': default_level, + 'no-extra-bind': default_level, + 'no-extra-boolean-cast': default_level, + 'no-extra-label': default_level, + 'no-extra-semi': default_level, + 'no-fallthrough': default_level, + 'no-func-assign': default_level, + 'no-global-assign': default_level, + 'no-implicit-coercion': default_level, + 'no-implicit-globals': default_level, + 'no-inner-declarations': default_level, + 'no-invalid-regexp': default_level, + 'no-invalid-this': default_level, + 'no-irregular-whitespace': default_level, + 'no-lonely-if': default_level, + 'no-mixed-operators': default_level, + 'no-mixed-spaces-and-tabs': disabled, + 'no-multi-spaces': default_level, + 'no-multi-str': default_level, + 'no-multiple-empty-lines': [ + default_level, + { max: 1, maxEOF: 0, maxBOF: 0 }, + ], + 'no-native-reassign': default_level, + 'no-negated-in-lhs': default_level, + 'no-new': default_level, + 'no-new-func': default_level, + 'no-new-wrappers': default_level, + 'no-obj-calls': default_level, + 'no-proto': default_level, + 'no-prototype-builtins': disabled, + 'no-redeclare': default_level, + 'no-regex-spaces': default_level, + 'no-restricted-imports': default_level, + 'no-return-assign': default_level, + 'no-self-assign': default_level, + 'no-self-compare': default_level, + 'no-sequences': default_level, + 'no-shadow': default_level, + 'no-shadow-restricted-names': default_level, + 'no-spaced-func': default_level, + 'no-sparse-arrays': default_level, + 'no-this-before-super': default_level, + 'no-throw-literal': default_level, + 'no-undef': default_level, + 'no-unexpected-multiline': default_level, + 'no-unmodified-loop-condition': default_level, + 'no-unreachable': default_level, + 'no-unused-vars': [default_level, { vars: 'all', args: 'after-used' }], + 'no-use-before-define': [default_level, { functions: false }], + 'no-useless-call': default_level, + 'no-useless-computed-key': default_level, + 'no-useless-concat': default_level, + 'no-useless-rename': default_level, + 'no-var': default_level, + 'no-whitespace-before-property': default_level, + 'object-curly-newline': disabled, + 'object-curly-spacing': [default_level, 'always'], + 'object-property-newline': [ + default_level, + { allowMultiplePropertiesPerLine: true }, + ], + 'prefer-const': default_level, + 'prefer-rest-params': default_level, + 'prefer-spread': default_level, + 'prefer-template': default_level, + quotes: [default_level, 'single', { avoidEscape: true }], + semi: [default_level, 'always'], + 'semi-spacing': default_level, + 'space-before-blocks': default_level, + 'space-before-function-paren': [ + default_level, + { + anonymous: 'never', + named: 'never', + asyncArrow: 'always', + }, + ], + 'space-in-parens': [default_level, 'never'], + 'spaced-comment': [default_level, 'always'], + strict: default_level, + 'valid-typeof': default_level, + 'eol-last': default_level, + yoda: default_level, + // eslint-plugin-import options. + 'import/extensions': default_level, + 'import/no-duplicates': default_level, + // eslint-plugin-react options. + 'jsx-quotes': [default_level, 'prefer-single'], + 'react/display-name': [default_level, { ignoreTranspilerName: false }], + 'react/forbid-prop-types': disabled, + 'react/jsx-boolean-value': default_level, + 'react/jsx-closing-bracket-location': default_level, + 'react/jsx-curly-spacing': default_level, + 'react/jsx-equals-spacing': default_level, + 'react/jsx-handler-names': default_level, + 'react/jsx-key': default_level, + 'react/jsx-max-props-per-line': disabled, + 'react/jsx-no-bind': disabled, + 'react/jsx-no-duplicate-props': default_level, + 'react/jsx-no-literals': disabled, + 'react/jsx-no-undef': disabled, + 'react/jsx-pascal-case': default_level, + 'react/jsx-sort-prop-types': disabled, + 'react/jsx-sort-props': disabled, + 'react/jsx-uses-react': default_level, + 'react/jsx-uses-vars': default_level, + 'react/no-danger': default_level, + 'react/no-deprecated': default_level, + 'react/no-did-mount-set-state': default_level, + 'react/no-did-update-set-state': default_level, + 'react/no-direct-mutation-state': default_level, + 'react/no-is-mounted': default_level, + 'react/no-multi-comp': disabled, + 'react/no-set-state': disabled, + 'react/no-string-refs': disabled, + 'react/no-unknown-property': default_level, + 'react/prefer-es6-class': default_level, + 'react/prop-types': [default_level, { skipUndeclared: true }], + 'react/react-in-jsx-scope': default_level, + 'react/self-closing-comp': default_level, + 'react/sort-comp': disabled, + 'react/jsx-wrap-multilines': [ + default_level, + { + declaration: false, + assignment: false, + return: true, + }, + ], + }, +} diff --git a/app/.eslintrc.js b/app/.eslintrc.js deleted file mode 100644 index d24d9bf35..000000000 --- a/app/.eslintrc.js +++ /dev/null @@ -1,236 +0,0 @@ -module.exports = -{ - env: - { - browser: true, - es6: true, - node: true - }, - plugins: - [ - 'import', - 'react', - 'jsx-control-statements' - ], - extends: - [ - 'eslint:recommended', - 'plugin:react/recommended', - 'plugin:jsx-control-statements/recommended' - ], - settings: - { - react: - { - pragma: 'React', - version: '16' - } - }, - parserOptions: - { - ecmaVersion: 2018, - sourceType: 'module', - ecmaFeatures: - { - impliedStrict: true, - jsx: true - } - }, - rules: - { - 'array-bracket-spacing': [ 2, 'always', - { - objectsInArrays: true, - arraysInArrays: true - }], - 'arrow-parens': [ 2, 'always' ], - 'arrow-spacing': 2, - 'block-spacing': [ 2, 'always' ], - 'brace-style': [ 2, 'allman', { allowSingleLine: true } ], - 'camelcase': 2, - 'comma-dangle': 2, - 'comma-spacing': [ 2, { before: false, after: true } ], - 'comma-style': 2, - 'computed-property-spacing': 2, - 'constructor-super': 2, - 'func-call-spacing': 2, - 'generator-star-spacing': 2, - 'guard-for-in': 2, - 'indent': [ 2, 'tab', { 'SwitchCase': 1 } ], - 'key-spacing': [ 2, - { - singleLine: - { - beforeColon: false, - afterColon: true - }, - multiLine: - { - beforeColon: true, - afterColon: true, - align: 'colon' - } - }], - 'keyword-spacing': 2, - 'linebreak-style': [ 2, 'unix' ], - 'lines-around-comment': [ 2, - { - allowBlockStart: true, - allowObjectStart: true, - beforeBlockComment: true, - beforeLineComment: false - }], - 'max-len': [ 2, 94, - { - tabWidth: 2, - comments: 110, - ignoreUrls: true, - ignoreStrings: true, - ignoreTemplateLiterals: true, - ignoreRegExpLiterals: true - }], - 'newline-after-var': 2, - 'newline-before-return': 2, - 'newline-per-chained-call': 2, - 'no-alert': 2, - 'no-caller': 2, - 'no-case-declarations': 2, - 'no-catch-shadow': 2, - 'no-class-assign': 2, - 'no-confusing-arrow': 2, - 'no-console': 2, - 'no-const-assign': 2, - 'no-debugger': 2, - 'no-dupe-args': 2, - 'no-dupe-keys': 2, - 'no-duplicate-case': 2, - 'no-div-regex': 2, - 'no-empty': [ 2, { allowEmptyCatch: true } ], - 'no-empty-pattern': 2, - 'no-else-return': 0, - 'no-eval': 2, - 'no-extend-native': 2, - 'no-ex-assign': 2, - 'no-extra-bind': 2, - 'no-extra-boolean-cast': 2, - 'no-extra-label': 2, - 'no-extra-semi': 2, - 'no-fallthrough': 2, - 'no-func-assign': 2, - 'no-global-assign': 2, - 'no-implicit-coercion': 2, - 'no-implicit-globals': 2, - 'no-inner-declarations': 2, - 'no-invalid-regexp': 2, - 'no-invalid-this': 2, - 'no-irregular-whitespace': 2, - 'no-lonely-if': 2, - 'no-mixed-operators': 2, - 'no-mixed-spaces-and-tabs': 2, - 'no-multi-spaces': 2, - 'no-multi-str': 2, - 'no-multiple-empty-lines': [ 2, { max: 1, maxEOF: 0, maxBOF: 0 } ], - 'no-native-reassign': 2, - 'no-negated-in-lhs': 2, - 'no-new': 2, - 'no-new-func': 2, - 'no-new-wrappers': 2, - 'no-obj-calls': 2, - 'no-proto': 2, - 'no-prototype-builtins': 0, - 'no-redeclare': 2, - 'no-regex-spaces': 2, - 'no-restricted-imports': 2, - 'no-return-assign': 2, - 'no-self-assign': 2, - 'no-self-compare': 2, - 'no-sequences': 2, - 'no-shadow': 2, - 'no-shadow-restricted-names': 2, - 'no-spaced-func': 2, - 'no-sparse-arrays': 2, - 'no-this-before-super': 2, - 'no-throw-literal': 2, - 'no-undef': 2, - 'no-unexpected-multiline': 2, - 'no-unmodified-loop-condition': 2, - 'no-unreachable': 2, - 'no-unused-vars': [ 1, { vars: 'all', args: 'after-used' }], - 'no-use-before-define': [ 2, { functions: false } ], - 'no-useless-call': 2, - 'no-useless-computed-key': 2, - 'no-useless-concat': 2, - 'no-useless-rename': 2, - 'no-var': 2, - 'no-whitespace-before-property': 2, - 'object-curly-newline': 0, - 'object-curly-spacing': [ 2, 'always' ], - 'object-property-newline': [ 2, { allowMultiplePropertiesPerLine: true } ], - 'prefer-const': 2, - 'prefer-rest-params': 2, - 'prefer-spread': 2, - 'prefer-template': 2, - 'quotes': [ 2, 'single', { avoidEscape: true } ], - 'semi': [ 2, 'always' ], - 'semi-spacing': 2, - 'space-before-blocks': 2, - 'space-before-function-paren': [ 2, - { - anonymous : 'never', - named : 'never', - asyncArrow : 'always' - }], - 'space-in-parens': [ 2, 'never' ], - 'spaced-comment': [ 2, 'always' ], - 'strict': 2, - 'valid-typeof': 2, - 'eol-last': 2, - 'yoda': 2, - // eslint-plugin-import options. - 'import/extensions': 2, - 'import/no-duplicates': 2, - // eslint-plugin-react options. - 'jsx-quotes': [ 2, 'prefer-single' ], - 'react/display-name': [ 2, { ignoreTranspilerName: false } ], - 'react/forbid-prop-types': 0, - 'react/jsx-boolean-value': 2, - 'react/jsx-closing-bracket-location': 2, - 'react/jsx-curly-spacing': 2, - 'react/jsx-equals-spacing': 2, - 'react/jsx-handler-names': 2, - 'react/jsx-indent-props': [ 2, 'tab' ], - 'react/jsx-indent': [ 2, 'tab' ], - 'react/jsx-key': 2, - 'react/jsx-max-props-per-line': 0, - 'react/jsx-no-bind': 0, - 'react/jsx-no-duplicate-props': 2, - 'react/jsx-no-literals': 0, - 'react/jsx-no-undef': 0, - 'react/jsx-pascal-case': 2, - 'react/jsx-sort-prop-types': 0, - 'react/jsx-sort-props': 0, - 'react/jsx-uses-react': 2, - 'react/jsx-uses-vars': 2, - 'react/no-danger': 2, - 'react/no-deprecated': 2, - 'react/no-did-mount-set-state': 2, - 'react/no-did-update-set-state': 2, - 'react/no-direct-mutation-state': 2, - 'react/no-is-mounted': 2, - 'react/no-multi-comp': 0, - 'react/no-set-state': 0, - 'react/no-string-refs': 0, - 'react/no-unknown-property': 2, - 'react/prefer-es6-class': 2, - 'react/prop-types': [ 2, { skipUndeclared: true } ], - 'react/react-in-jsx-scope': 2, - 'react/self-closing-comp': 2, - 'react/sort-comp': 0, - 'react/jsx-wrap-multilines': [ 2, - { - declaration: false, - assignment: false, - return: true - }] - } -}; diff --git a/app/.npmrc b/app/.npmrc index 43c97e719..4fd15ed11 100644 --- a/app/.npmrc +++ b/app/.npmrc @@ -1 +1,2 @@ package-lock=false +legacy-peer-deps=true diff --git a/app/.postcssrc.cjs b/app/.postcssrc.cjs new file mode 100644 index 000000000..90d9fffcb --- /dev/null +++ b/app/.postcssrc.cjs @@ -0,0 +1,5 @@ +module.exports = { + plugins: { + autoprefixer: {}, + }, +} diff --git a/app/.prettierrc.cjs b/app/.prettierrc.cjs new file mode 100644 index 000000000..e93ca4b46 --- /dev/null +++ b/app/.prettierrc.cjs @@ -0,0 +1,17 @@ +module.exports = { + printWidth: 80, + tabWidth: 2, + useTabs: false, + semi: false, + singleQuote: true, + quoteProps: 'as-needed', + jsxSingleQuote: true, + trailingComma: 'all', + bracketSpacing: true, + bracketSameLine: false, + arrowParens: 'avoid', + requirePragma: false, + insertPragma: false, + endOfLine: 'lf', + htmlWhitespaceSensitivity: 'ignore', +} diff --git a/app/gulpfile.js b/app/gulpfile.js deleted file mode 100644 index bcfc45829..000000000 --- a/app/gulpfile.js +++ /dev/null @@ -1,454 +0,0 @@ -/** - * Tasks: - * - * gulp dist - * Generates the browser app in development mode (unless NODE_ENV is set - * to 'production'). - * - * gulp live - * Generates the browser app in development mode (unless NODE_ENV is set - * to 'production'), opens it and watches for changes in the source code. - * - * gulp devel - * Generates the browser app in development mode (unless NODE_ENV is set - * to 'production'), opens two browsers and watches for changes in the source - * code. - * - * gulp devel:tcp - * Same as gulp devel, but forcing media over TCP. - * - * gulp devel:vp9 - * Generates the browser app in development mode (unless NODE_ENV is set - * to 'production'), opens two browsers forcing VP9 and watches for changes in - * the source code. - * - * gulp devel:h264 - * Generates the browser app in development mode (unless NODE_ENV is set - * to 'production'), opens two browsers forcing H264 and watches for changes in - * the source code. - - * gulp - * Alias for `gulp dist`. - */ - -const fs = require('fs'); -const path = require('path'); -const gulp = require('gulp'); -const gulpif = require('gulp-if'); -const gutil = require('gulp-util'); -const plumber = require('gulp-plumber'); -const rename = require('gulp-rename'); -const header = require('gulp-header'); -const touch = require('gulp-touch-cmd'); -const browserify = require('browserify'); -const watchify = require('watchify'); -const envify = require('envify/custom'); -const uglify = require('gulp-uglify-es').default; -const source = require('vinyl-source-stream'); -const buffer = require('vinyl-buffer'); -const del = require('del'); -const mkdirp = require('mkdirp'); -const ncp = require('ncp'); -const eslint = require('gulp-eslint'); -const stylus = require('gulp-stylus'); -const cssBase64 = require('gulp-css-base64'); -const nib = require('nib'); -const browserSync = require('browser-sync'); - -const PKG = require('./package'); -const BANNER = fs.readFileSync('banner.txt').toString(); -const BANNER_OPTIONS = -{ - pkg : PKG, - currentYear : (new Date()).getFullYear() -}; -const OUTPUT_DIR = '../server/public'; - -// Set Node 'development' environment (unless externally set). -process.env.NODE_ENV = process.env.NODE_ENV || 'development'; - -gutil.log(`NODE_ENV: ${process.env.NODE_ENV}`); - -function logError(error) -{ - gutil.log(gutil.colors.red(error.stack)); -} - -function bundle(options) -{ - options = options || {}; - - const watch = Boolean(options.watch); - - let bundler = browserify( - { - entries : PKG.main, - extensions : [ '.js', '.jsx', 'mjs' ], - // required for sourcemaps (must be false otherwise). - debug : process.env.NODE_ENV === 'development', - // required for watchify. - cache : {}, - // required for watchify. - packageCache : {}, - // required to be true only for watchify. - fullPaths : watch - }) - .transform('babelify', { presets: [ '@babel/preset-env' ] }) - .transform(envify( - { - NODE_ENV : process.env.NODE_ENV, - _ : 'purge' - })); - - if (watch) - { - bundler = watchify(bundler); - - bundler.on('update', () => - { - const start = Date.now(); - - gutil.log('bundling...'); - rebundle(); - gutil.log('bundle took %sms', (Date.now() - start)); - }); - } - - function rebundle() - { - return bundler.bundle() - .on('error', logError) - .pipe(plumber()) - .pipe(source(`${PKG.name}.js`)) - .pipe(buffer()) - .pipe(rename(`${PKG.name}.js`)) - .pipe(gulpif(process.env.NODE_ENV === 'production', - uglify() - )) - .pipe(header(BANNER, BANNER_OPTIONS)) - .pipe(gulp.dest(OUTPUT_DIR)); - } - - return rebundle(); -} - -gulp.task('clean', () => del(OUTPUT_DIR, { force: true })); - -gulp.task('lint', () => -{ - const src = - [ - 'gulpfile.js', - 'lib/**/*.js', - 'lib/**/*.jsx' - ]; - - return gulp.src(src) - .pipe(plumber()) - .pipe(eslint()) - .pipe(eslint.format()); -}); - -gulp.task('css', () => -{ - return gulp.src('stylus/index.styl') - .pipe(plumber()) - .pipe(stylus( - { - use : nib(), - compress : process.env.NODE_ENV === 'production' - })) - .on('error', logError) - .pipe(cssBase64( - { - baseDir : '.', - maxWeightResource : 50000 // So big ttf fonts are not included, nice. - })) - .pipe(rename(`${PKG.name}.css`)) - .pipe(gulp.dest(OUTPUT_DIR)) - .pipe(touch()); -}); - -gulp.task('html', () => -{ - return gulp.src('index.html') - .pipe(gulp.dest(OUTPUT_DIR)); -}); - -gulp.task('resources', (done) => -{ - const dst = path.join(OUTPUT_DIR, 'resources'); - - mkdirp.sync(dst); - ncp('resources', dst, { stopOnErr: true }, (error) => - { - if (error && error[0].code !== 'ENOENT') - throw new Error(`resources copy failed: ${error}`); - - done(); - }); -}); - -gulp.task('bundle', () => -{ - return bundle({ watch: false }); -}); - -gulp.task('bundle:watch', () => -{ - return bundle({ watch: true }); -}); - -gulp.task('dist', gulp.series( - 'clean', - 'lint', - 'bundle', - 'html', - 'css', - 'resources' -)); - -gulp.task('watch', (done) => -{ - // Watch changes in HTML. - gulp.watch([ 'index.html' ], gulp.series( - 'html' - )); - - // Watch changes in Stylus files. - gulp.watch([ 'stylus/**/*.styl' ], gulp.series( - 'css' - )); - - // Watch changes in resources. - gulp.watch([ 'resources/**/*' ], gulp.series( - 'resources', 'css' - )); - - // Watch changes in JS files. - gulp.watch([ 'gulpfile.js', 'lib/**/*.js', 'lib/**/*.jsx' ], gulp.series( - 'lint' - )); - - done(); -}); - -gulp.task('browser:base', gulp.series( - 'clean', - 'lint', - 'bundle:watch', - 'html', - 'css', - 'resources', - 'watch' -)); - -gulp.task('live', gulp.series( - 'browser:base', - (done) => - { - const config = require('../server/config'); - - browserSync( - { - open : 'external', - host : config.domain, - startPath : '/?info=true', - server : - { - baseDir : OUTPUT_DIR - }, - https : config.https.tls, - ghostMode : false, - files : path.join(OUTPUT_DIR, '**', '*') - }); - - done(); - } -)); - -gulp.task('devel', gulp.series( - 'browser:base', - async (done) => - { - const config = require('../server/config'); - - await new Promise((resolve) => - { - browserSync.create('producer1').init( - { - open : 'external', - host : config.domain, - startPath : '/?roomId=devel&info=true&_throttleSecret=foo&consume=false', - server : - { - baseDir : OUTPUT_DIR - }, - https : config.https.tls, - ghostMode : false, - files : path.join(OUTPUT_DIR, '**', '*') - }, - resolve); - }); - - await new Promise((resolve) => - { - browserSync.create('consumer1').init( - { - open : 'external', - host : config.domain, - startPath : '/?roomId=devel&info=true&_throttleSecret=foo&produce=false', - server : - { - baseDir : OUTPUT_DIR - }, - https : config.https.tls, - ghostMode : false, - files : path.join(OUTPUT_DIR, '**', '*') - }, - resolve); - }); - - done(); - } -)); - -gulp.task('devel:tcp', gulp.series( - 'browser:base', - async (done) => - { - const config = require('../server/config'); - - await new Promise((resolve) => - { - browserSync.create('producer1').init( - { - open : 'external', - host : config.domain, - startPath : '/?roomId=devel:tcp&info=true&_throttleSecret=foo&forceTcp=true&consume=false', - server : - { - baseDir : OUTPUT_DIR - }, - https : config.https.tls, - ghostMode : false, - files : path.join(OUTPUT_DIR, '**', '*') - }, - resolve); - }); - - await new Promise((resolve) => - { - browserSync.create('consumer1').init( - { - open : 'external', - host : config.domain, - startPath : '/?roomId=devel:tcp&info=true&_throttleSecret=foo&forceTcp=true&produce=false', - server : - { - baseDir : OUTPUT_DIR - }, - https : config.https.tls, - ghostMode : false, - files : path.join(OUTPUT_DIR, '**', '*') - }, - resolve); - }); - - done(); - } -)); - -gulp.task('devel:vp9', gulp.series( - 'browser:base', - async (done) => - { - const config = require('../server/config'); - - await new Promise((resolve) => - { - browserSync.create('producer1').init( - { - open : 'external', - host : config.domain, - startPath : '/?roomId=devel:vp9&info=true&_throttleSecret=foo&forceVP9=true&numSimulcastStreams=3&webcamScalabilityMode=L1T3&consume=false', - server : - { - baseDir : OUTPUT_DIR - }, - https : config.https.tls, - ghostMode : false, - files : path.join(OUTPUT_DIR, '**', '*') - }, - resolve); - }); - - await new Promise((resolve) => - { - browserSync.create('consumer1').init( - { - open : 'external', - host : config.domain, - startPath : '/?roomId=devel:vp9&info=true&_throttleSecret=foo&forceVP9=true&produce=false', - server : - { - baseDir : OUTPUT_DIR - }, - https : config.https.tls, - ghostMode : false, - files : path.join(OUTPUT_DIR, '**', '*') - }, - resolve); - }); - - done(); - } -)); - -gulp.task('devel:h264', gulp.series( - 'browser:base', - async (done) => - { - const config = require('../server/config'); - - await new Promise((resolve) => - { - browserSync.create('producer1').init( - { - open : 'external', - host : config.domain, - startPath : '/?roomId=devel:h264&info=true&_throttleSecret=foo&forceH264=true&consume=false', - server : - { - baseDir : OUTPUT_DIR - }, - https : config.https.tls, - ghostMode : false, - files : path.join(OUTPUT_DIR, '**', '*') - }, - resolve); - }); - - await new Promise((resolve) => - { - browserSync.create('consumer1').init( - { - open : 'external', - host : config.domain, - startPath : '/?roomId=devel:h264&info=true&_throttleSecret=foo&forceH264=true&produce=false', - server : - { - baseDir : OUTPUT_DIR - }, - https : config.https.tls, - ghostMode : false, - files : path.join(OUTPUT_DIR, '**', '*') - }, - resolve); - }); - - done(); - } -)); - -gulp.task('default', gulp.series('dist')); diff --git a/app/index.html b/app/index.html index a65435268..78e6248f1 100644 --- a/app/index.html +++ b/app/index.html @@ -7,19 +7,10 @@ - - - - +
diff --git a/app/lib/Logger.js b/app/lib/Logger.js deleted file mode 100644 index 7d1baa10e..000000000 --- a/app/lib/Logger.js +++ /dev/null @@ -1,43 +0,0 @@ -import debug from 'debug'; - -const APP_NAME = 'mediasoup-demo'; - -export default class Logger -{ - constructor(prefix) - { - if (prefix) - { - this._debug = debug(`${APP_NAME}:${prefix}`); - this._warn = debug(`${APP_NAME}:WARN:${prefix}`); - this._error = debug(`${APP_NAME}:ERROR:${prefix}`); - } - else - { - this._debug = debug(APP_NAME); - this._warn = debug(`${APP_NAME}:WARN`); - this._error = debug(`${APP_NAME}:ERROR`); - } - - /* eslint-disable no-console */ - this._debug.log = console.info.bind(console); - this._warn.log = console.warn.bind(console); - this._error.log = console.error.bind(console); - /* eslint-enable no-console */ - } - - get debug() - { - return this._debug; - } - - get warn() - { - return this._warn; - } - - get error() - { - return this._error; - } -} diff --git a/app/lib/RoomClient.js b/app/lib/RoomClient.js deleted file mode 100644 index 85794c07b..000000000 --- a/app/lib/RoomClient.js +++ /dev/null @@ -1,2669 +0,0 @@ -import protooClient from 'protoo-client'; -import * as mediasoupClient from 'mediasoup-client'; -import Logger from './Logger'; -import { getProtooUrl } from './urlFactory'; -import * as cookiesManager from './cookiesManager'; -import * as requestActions from './redux/requestActions'; -import * as stateActions from './redux/stateActions'; -import * as e2e from './e2e'; - -const VIDEO_CONSTRAINS = -{ - qvga : { width: { ideal: 320 }, height: { ideal: 240 } }, - vga : { width: { ideal: 640 }, height: { ideal: 480 } }, - hd : { width: { ideal: 1280 }, height: { ideal: 720 } } -}; - -const PC_PROPRIETARY_CONSTRAINTS = -{ - // optional : [ { googDscp: true } ] -}; - -const EXTERNAL_VIDEO_SRC = '/resources/videos/video-audio-stereo.mp4'; - -const logger = new Logger('RoomClient'); - -let store; - -export default class RoomClient -{ - /** - * @param {Object} data - * @param {Object} data.store - The Redux store. - */ - static init(data) - { - store = data.store; - } - - constructor( - { - roomId, - peerId, - displayName, - device, - handlerName, - forceTcp, - produce, - consume, - datachannel, - enableWebcamLayers, - enableSharingLayers, - webcamScalabilityMode, - sharingScalabilityMode, - numSimulcastStreams, - forceVP8, - forceH264, - forceVP9, - externalVideo, - e2eKey, - consumerReplicas - } - ) - { - logger.debug( - 'constructor() [roomId:"%s", peerId:"%s", displayName:"%s", device:%s]', - roomId, peerId, displayName, device.flag); - - // Closed flag. - // @type {Boolean} - this._closed = false; - - // Display name. - // @type {String} - this._displayName = displayName; - - // Device info. - // @type {Object} - this._device = device; - - // Custom mediasoup-client handler name (to override default browser - // detection if desired). - // @type {String} - this._handlerName = handlerName; - - // Whether we want to force RTC over TCP. - // @type {Boolean} - this._forceTcp = forceTcp; - - // Whether we want to produce audio/video. - // @type {Boolean} - this._produce = produce; - - // Whether we should consume. - // @type {Boolean} - this._consume = consume; - - // Whether we want DataChannels. - // @type {Boolean} - this._useDataChannel = Boolean(datachannel); - - // Force VP8 codec for sending. - // @type {Boolean} - this._forceVP8 = Boolean(forceVP8); - - // Force H264 codec for sending. - // @type {Boolean} - this._forceH264 = Boolean(forceH264); - - // Force VP9 codec for sending. - // @type {Boolean} - this._forceVP9 = Boolean(forceVP9); - - // Whether simulcast or SVC should be used for webcam. - // @type {Boolean} - this._enableWebcamLayers = Boolean(enableWebcamLayers); - - // Whether simulcast or SVC should be used in desktop sharing. - // @type {Boolean} - this._enableSharingLayers = Boolean(enableSharingLayers); - - // Scalability mode for webcam. - // @type {String} - this._webcamScalabilityMode = webcamScalabilityMode; - - // Scalability mode for sharing. - // @type {String} - this._sharingScalabilityMode = sharingScalabilityMode; - - // Number of simuclast streams for webcam and sharing. - // @type {Number} - this._numSimulcastStreams = numSimulcastStreams; - - // External video. - // @type {HTMLVideoElement} - this._externalVideo = null; - - // Enabled end-to-end encryption. - this._e2eKey = e2eKey; - - // MediaStream of the external video. - // @type {MediaStream} - this._externalVideoStream = null; - - // Next expected dataChannel test number. - // @type {Number} - this._nextDataChannelTestNumber = 0; - - if (externalVideo) - { - this._externalVideo = document.createElement('video'); - - this._externalVideo.controls = true; - this._externalVideo.muted = true; - this._externalVideo.loop = true; - this._externalVideo.setAttribute('playsinline', ''); - this._externalVideo.src = EXTERNAL_VIDEO_SRC; - - this._externalVideo.play() - .catch((error) => logger.warn('externalVideo.play() failed:%o', error)); - } - - // Protoo URL. - // @type {String} - this._protooUrl = getProtooUrl({ roomId, peerId, consumerReplicas }); - - // protoo-client Peer instance. - // @type {protooClient.Peer} - this._protoo = null; - - // mediasoup-client Device instance. - // @type {mediasoupClient.Device} - this._mediasoupDevice = null; - - // mediasoup Transport for sending. - // @type {mediasoupClient.Transport} - this._sendTransport = null; - - // mediasoup Transport for receiving. - // @type {mediasoupClient.Transport} - this._recvTransport = null; - - // Local mic mediasoup Producer. - // @type {mediasoupClient.Producer} - this._micProducer = null; - - // Local webcam mediasoup Producer. - // @type {mediasoupClient.Producer} - this._webcamProducer = null; - - // Local share mediasoup Producer. - // @type {mediasoupClient.Producer} - this._shareProducer = null; - - // Local chat DataProducer. - // @type {mediasoupClient.DataProducer} - this._chatDataProducer = null; - - // Local bot DataProducer. - // @type {mediasoupClient.DataProducer} - this._botDataProducer = null; - - // mediasoup Consumers. - // @type {Map{notification.title}
-{notification.text}
-- {'id: '} - clipboardCopy(`"${audioProducerId}"`)} - > - {audioProducerId} - -
- -- {'id: '} - clipboardCopy(`"${audioConsumerId}"`)} - > - {audioConsumerId} - -
- -codec: {audioCodec}
-- {'id: '} - clipboardCopy(`"${videoProducerId}"`)} - > - {videoProducerId} - -
- -- {'id: '} - clipboardCopy(`"${videoConsumerId}"`)} - > - {videoConsumerId} - -
- -codec: {videoCodec}
-resolution: {videoResolutionWidth}x{videoResolutionHeight}
-- max spatial layer: {maxSpatialLayer > -1 ? maxSpatialLayer : 'none'} - {' '} - -1 - })} - onClick={(event) => - { - event.stopPropagation(); - - const newMaxSpatialLayer = maxSpatialLayer -1; - - onChangeMaxSendingSpatialLayer(newMaxSpatialLayer); - this.setState({ maxSpatialLayer: newMaxSpatialLayer }); - }} - > - {'[ down ]'} - - {' '} - - { - event.stopPropagation(); - - const newMaxSpatialLayer = maxSpatialLayer + 1; - - onChangeMaxSendingSpatialLayer(newMaxSpatialLayer); - this.setState({ maxSpatialLayer: newMaxSpatialLayer }); - }} - > - {'[ up ]'} - -
-- {`current spatial-temporal layers: ${consumerCurrentSpatialLayer} ${consumerCurrentTemporalLayer}`} -
-- {`preferred spatial-temporal layers: ${consumerPreferredSpatialLayer} ${consumerPreferredTemporalLayer}`} - {' '} - - { - event.stopPropagation(); - - let newPreferredSpatialLayer = consumerPreferredSpatialLayer; - let newPreferredTemporalLayer; - - if (consumerPreferredTemporalLayer > 0) - { - newPreferredTemporalLayer = consumerPreferredTemporalLayer - 1; - } - else - { - if (consumerPreferredSpatialLayer > 0) - newPreferredSpatialLayer = consumerPreferredSpatialLayer - 1; - else - newPreferredSpatialLayer = consumerSpatialLayers - 1; - - newPreferredTemporalLayer = consumerTemporalLayers - 1; - } - - onChangeVideoPreferredLayers( - newPreferredSpatialLayer, newPreferredTemporalLayer); - }} - > - {'[ down ]'} - - {' '} - - { - event.stopPropagation(); - - let newPreferredSpatialLayer = consumerPreferredSpatialLayer; - let newPreferredTemporalLayer; - - if (consumerPreferredTemporalLayer < consumerTemporalLayers - 1) - { - newPreferredTemporalLayer = consumerPreferredTemporalLayer + 1; - } - else - { - if (consumerPreferredSpatialLayer < consumerSpatialLayers - 1) - newPreferredSpatialLayer = consumerPreferredSpatialLayer + 1; - else - newPreferredSpatialLayer = 0; - - newPreferredTemporalLayer = 0; - } - - onChangeVideoPreferredLayers( - newPreferredSpatialLayer, newPreferredTemporalLayer); - }} - > - {'[ up ]'} - -
-- {`priority: ${consumerPriority}`} - {' '} - 1 - })} - onClick={(event) => - { - event.stopPropagation(); - - onChangeVideoPriority(consumerPriority - 1); - }} - > - {'[ down ]'} - - {' '} - - { - event.stopPropagation(); - - onChangeVideoPriority(consumerPriority + 1); - }} - > - {'[ up ]'} - -
-- - { - event.stopPropagation(); - - if (!onRequestKeyFrame) - return; - - onRequestKeyFrame(); - }} - > - {'[ request keyframe ]'} - -
-streams:
- - { - scores - .sort((a, b) => - { - if (a.rid) - return (a.rid > b.rid ? 1 : -1); - else - return (a.ssrc > b.ssrc ? 1 : -1); - }) - .map(({ ssrc, rid, score }, idx) => ( // eslint-disable-line no-shadow -
-
- {`score:${score.score}, producerScore:${score.producerScore}, producerScores:[${score.producerScores}]`} -
- ); - } -} - -PeerView.propTypes = -{ - isMe : PropTypes.bool, - peer : PropTypes.oneOfType( - [ appPropTypes.Me, appPropTypes.Peer ]).isRequired, - audioProducerId : PropTypes.string, - videoProducerId : PropTypes.string, - audioConsumerId : PropTypes.string, - videoConsumerId : PropTypes.string, - audioRtpParameters : PropTypes.object, - videoRtpParameters : PropTypes.object, - consumerSpatialLayers : PropTypes.number, - consumerTemporalLayers : PropTypes.number, - consumerCurrentSpatialLayer : PropTypes.number, - consumerCurrentTemporalLayer : PropTypes.number, - consumerPreferredSpatialLayer : PropTypes.number, - consumerPreferredTemporalLayer : PropTypes.number, - consumerPriority : PropTypes.number, - audioTrack : PropTypes.any, - videoTrack : PropTypes.any, - audioMuted : PropTypes.bool, - videoVisible : PropTypes.bool.isRequired, - videoMultiLayer : PropTypes.bool, - audioCodec : PropTypes.string, - videoCodec : PropTypes.string, - audioScore : PropTypes.any, - videoScore : PropTypes.any, - faceDetection : PropTypes.bool.isRequired, - onChangeDisplayName : PropTypes.func, - onChangeMaxSendingSpatialLayer : PropTypes.func, - onChangeVideoPreferredLayers : PropTypes.func, - onChangeVideoPriority : PropTypes.func, - onRequestKeyFrame : PropTypes.func, - onStatsClick : PropTypes.func.isRequired -}; diff --git a/app/lib/components/Peers.jsx b/app/lib/components/Peers.jsx deleted file mode 100644 index 6d440e52a..000000000 --- a/app/lib/components/Peers.jsx +++ /dev/null @@ -1,64 +0,0 @@ -import React from 'react'; -import { connect } from 'react-redux'; -import PropTypes from 'prop-types'; -import classnames from 'classnames'; -import * as appPropTypes from './appPropTypes'; -import { Appear } from './transitions'; -import Peer from './Peer'; - -const Peers = ({ peers, activeSpeakerId }) => -{ - return ( -{room.state}
-server: {room.mediasoupVersion}
-client: {mediasoupClientVersion}
-handler: {room.mediasoupClientHandler}
-- {'chat dataproducer stats: '} - [remote] - {' '} - [local] -
-- {'bot dataproducer stats: '} - [remote] - {' '} - [local] -
-- {'chat dataconsumer stats: '} - [remote] - {' '} - [local] -
-- {'bot dataconsumer stats: '} - [remote] - {' '} - [local] -
-{key}
-{JSON.stringify(item[key], null, ' ')}-
{notification.title}
+ )} + +{notification.text}
++ {'id: '} + clipboardCopy(`"${audioProducerId}"`)} + > + {audioProducerId} + +
+ ++ {'id: '} + clipboardCopy(`"${audioConsumerId}"`)} + > + {audioConsumerId} + +
+ +codec: {audioCodec}
} + + {audioProducerId && + audioScore && + this._printProducerScore(audioProducerId, audioScore)} + + {audioConsumerId && + audioScore && + this._printConsumerScore(audioConsumerId, audioScore)} + > + )} + + {(videoProducerId || videoConsumerId) && ( + <> ++ {'id: '} + clipboardCopy(`"${videoProducerId}"`)} + > + {videoProducerId} + +
+ ++ {'id: '} + clipboardCopy(`"${videoConsumerId}"`)} + > + {videoConsumerId} + +
+ +codec: {videoCodec}
} + + {videoVisible && videoResolutionWidth !== null && ( ++ resolution: {videoResolutionWidth}x{videoResolutionHeight} +
+ )} + + {videoVisible && + videoProducerId && + videoRtpParameters.encodings.length > 1 && ( ++ max spatial layer:{' '} + {maxSpatialLayer > -1 ? maxSpatialLayer : 'none'} + + -1, + })} + onClick={event => { + event.stopPropagation() + + const newMaxSpatialLayer = maxSpatialLayer - 1 + + onChangeMaxSendingSpatialLayer(newMaxSpatialLayer) + this.setState({ maxSpatialLayer: newMaxSpatialLayer }) + }} + > + {'[ down ]'} + + + { + event.stopPropagation() + + const newMaxSpatialLayer = maxSpatialLayer + 1 + + onChangeMaxSendingSpatialLayer(newMaxSpatialLayer) + this.setState({ maxSpatialLayer: newMaxSpatialLayer }) + }} + > + {'[ up ]'} + +
+ )} + + {!isMe && videoMultiLayer && ( + <> ++ {`current spatial-temporal layers: ${consumerCurrentSpatialLayer} ${consumerCurrentTemporalLayer}`} +
++ {`preferred spatial-temporal layers: ${consumerPreferredSpatialLayer} ${consumerPreferredTemporalLayer}`} + + { + event.stopPropagation() + + let newPreferredSpatialLayer = + consumerPreferredSpatialLayer + let newPreferredTemporalLayer + + if (consumerPreferredTemporalLayer > 0) { + newPreferredTemporalLayer = + consumerPreferredTemporalLayer - 1 + } else { + if (consumerPreferredSpatialLayer > 0) + newPreferredSpatialLayer = + consumerPreferredSpatialLayer - 1 + else + newPreferredSpatialLayer = + consumerSpatialLayers - 1 + + newPreferredTemporalLayer = + consumerTemporalLayers - 1 + } + + onChangeVideoPreferredLayers( + newPreferredSpatialLayer, + newPreferredTemporalLayer, + ) + }} + > + {'[ down ]'} + + + { + event.stopPropagation() + + let newPreferredSpatialLayer = + consumerPreferredSpatialLayer + let newPreferredTemporalLayer + + if ( + consumerPreferredTemporalLayer < + consumerTemporalLayers - 1 + ) { + newPreferredTemporalLayer = + consumerPreferredTemporalLayer + 1 + } else { + if ( + consumerPreferredSpatialLayer < + consumerSpatialLayers - 1 + ) + newPreferredSpatialLayer = + consumerPreferredSpatialLayer + 1 + else newPreferredSpatialLayer = 0 + + newPreferredTemporalLayer = 0 + } + + onChangeVideoPreferredLayers( + newPreferredSpatialLayer, + newPreferredTemporalLayer, + ) + }} + > + {'[ up ]'} + +
+ > + )} + + {!isMe && videoCodec && consumerPriority > 0 && ( ++ {`priority: ${consumerPriority}`} + + 1, + })} + onClick={event => { + event.stopPropagation() + + onChangeVideoPriority(consumerPriority - 1) + }} + > + {'[ down ]'} + + + { + event.stopPropagation() + + onChangeVideoPriority(consumerPriority + 1) + }} + > + {'[ up ]'} + +
+ )} + + {!isMe && videoCodec && ( ++ { + event.stopPropagation() + + if (!onRequestKeyFrame) return + + onRequestKeyFrame() + }} + > + {'[ request keyframe ]'} + +
+ )} + + {videoProducerId && + videoScore && + this._printProducerScore(videoProducerId, videoScore)} + + {videoConsumerId && + videoScore && + this._printConsumerScore(videoConsumerId, videoScore)} + > + )} +streams:
+ + {scores + .filter(v => v) + .sort((a, b) => { + if (a.rid) return a.rid > b.rid ? 1 : -1 + else return a.ssrc > b.ssrc ? 1 : -1 + }) + .map( + ( + { ssrc, rid, score }, + idx, // eslint-disable-line no-shadow + ) => ( ++ {rid !== undefined + ? `rid:${rid}, ssrc:${ssrc}, score:${score}` + : `ssrc:${ssrc}, score:${score}`} +
+ ), + )} ++ {`score:${score.score}, producerScore:${score.producerScore}, producerScores:[${score.producerScores}]`} +
+ ) + } +} + +PeerView.propTypes = { + isMe: PropTypes.bool, + peer: PropTypes.oneOfType([appPropTypes.Me, appPropTypes.Peer]).isRequired, + audioProducerId: PropTypes.string, + videoProducerId: PropTypes.string, + audioConsumerId: PropTypes.string, + videoConsumerId: PropTypes.string, + audioRtpParameters: PropTypes.object, + videoRtpParameters: PropTypes.object, + consumerSpatialLayers: PropTypes.number, + consumerTemporalLayers: PropTypes.number, + consumerCurrentSpatialLayer: PropTypes.number, + consumerCurrentTemporalLayer: PropTypes.number, + consumerPreferredSpatialLayer: PropTypes.number, + consumerPreferredTemporalLayer: PropTypes.number, + consumerPriority: PropTypes.number, + audioTrack: PropTypes.any, + videoTrack: PropTypes.any, + audioMuted: PropTypes.bool, + videoVisible: PropTypes.bool.isRequired, + videoMultiLayer: PropTypes.bool, + audioCodec: PropTypes.string, + videoCodec: PropTypes.string, + audioScore: PropTypes.any, + videoScore: PropTypes.any, + faceDetection: PropTypes.bool.isRequired, + onChangeDisplayName: PropTypes.func, + onChangeMaxSendingSpatialLayer: PropTypes.func, + onChangeVideoPreferredLayers: PropTypes.func, + onChangeVideoPriority: PropTypes.func, + onRequestKeyFrame: PropTypes.func, + onStatsClick: PropTypes.func.isRequired, +} diff --git a/app/src/components/Peers.jsx b/app/src/components/Peers.jsx new file mode 100644 index 000000000..e3aada006 --- /dev/null +++ b/app/src/components/Peers.jsx @@ -0,0 +1,52 @@ +import React from 'react' +import { connect } from 'react-redux' +import PropTypes from 'prop-types' +import classnames from 'classnames' +import * as appPropTypes from './appPropTypes' +import { Appear } from './transitions' +import Peer from './Peer' + +const Peers = ({ peers, activeSpeakerId }) => { + return ( +{room.state}
++ server: + {room.mediasoupVersion} +
++ client: + {mediasoupClientVersion} +
++ handler: + {room.mediasoupClientHandler} +
++ {'send transport stats: '} + [remote] + + [local] +
+ )} + + {(recvTransportRemoteStats || recvTransportLocalStats) && ( ++ {'recv transport stats: '} + [remote] + + [local] +
+ )} + + {(audioProducerRemoteStats || audioProducerLocalStats) && ( ++ {'audio producer stats: '} + [remote] + + [local] +
+ )} + + {(videoProducerRemoteStats || videoProducerLocalStats) && ( ++ {'video producer stats: '} + [remote] + + [local] +
+ )} + + {chatDataProducerRemoteStats && ( ++ {'chat dataproducer stats: '} + [remote] + + [local] +
+ )} + + {botDataProducerRemoteStats && ( ++ {'bot dataproducer stats: '} + [remote] + + [local] +
+ )} + + {(audioConsumerRemoteStats || audioConsumerLocalStats) && ( ++ {'audio consumer stats: '} + [remote] + + [local] +
+ )} + + {(videoConsumerRemoteStats || videoConsumerLocalStats) && ( ++ {'video consumer stats: '} + [remote] + + [local] +
+ )} + + {chatDataConsumerRemoteStats && ( + + )} + + {botDataConsumerRemoteStats && ( + + )} +{key}
++ {typeof item[key] === 'number' + ? JSON.stringify( + Math.round(item[key] * 100) / 100, + null, + ' ', + ) + : JSON.stringify(item[key], null, ' ')} ++