diff --git a/.babelrc b/.babelrc deleted file mode 100644 index 5343b6a4..00000000 --- a/.babelrc +++ /dev/null @@ -1,9 +0,0 @@ -{ - "plugins": [ - "@babel/plugin-syntax-dynamic-import", - "@babel/plugin-proposal-optional-chaining", - "@babel/plugin-proposal-nullish-coalescing-operator", - ["@babel/plugin-proposal-object-rest-spread", {"loose": true, "useBuiltIns": true}], - ["@babel/plugin-proposal-class-properties", {"loose": true}] - ] -} \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..f9647354 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +root = true + +[*] +indent_style = tab +indent_size = tab +tab_width = 4 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.yaml] +indent_style = space +indent_size = 2 + +[*.yml] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false +insert_final_newline = false diff --git a/.gitignore b/.gitignore index 455bda1f..95199d8d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules npm-debug.log dist +typedist Extension Building badges cdn diff --git a/bin/build_types.js b/bin/build_types.js new file mode 100644 index 00000000..f7ae67db --- /dev/null +++ b/bin/build_types.js @@ -0,0 +1,82 @@ +const fs = require('fs'); +const glob = require('glob'); +const nativepath = require('path'); +const pospath = nativepath.posix; + +const PACKAGE = require('../package.json'); + +const manifest = { + private: true, + name: '@ffz/client-types', + description: "TypeScript definitions for FrankerFaceZ", + version: PACKAGE.version, + types: "main", + projects: [ + 'https://www.frankerfacez.com' + ], + repository: PACKAGE.repository, + dependencies: { + '@types/webpack-env': '^1.18.4' + } +}; + +fs.writeFileSync('typedist/package.json', JSON.stringify(manifest, null, 4)); + +// Now, fix all the import paths. + +const MATCHER = /from '([^']+)';$/gm, + MATCH_TWO = /\bimport\("([^"]+)"\)/gm; + +function shouldReplace(module) { + return module.startsWith('utilities/'); +} + +for(const filename of glob.sync('typedist/**/*.d.ts')) { + const folder = pospath.dirname(filename.split(nativepath.sep).join(pospath.sep)); + console.log('thing', filename, '-->', folder); + + let content = fs.readFileSync(filename, 'utf8'); + let changed = false; + + content = content.replace(MATCHER, (match, package, index) => { + if ( shouldReplace(package) ) { + //const modpath = pospath.dirname(`typedist/${package}`); + let relative = pospath.relative(folder, 'typedist'); + + if ( relative === '' ) + relative = '.'; + + if ( ! relative.endsWith('/') ) + relative += '/'; + + console.log(' to', package, '->', JSON.stringify(relative)); + + changed = true; + return `from '${relative}${package}';`; + } + + return match; + }); + + content = content.replace(MATCH_TWO, (match, package, index) => { + if ( shouldReplace(package) ) { + //const modpath = pospath.dirname(`typedist/${package}`); + let relative = pospath.relative(folder, 'typedist'); + + if ( relative === '' ) + relative = '.'; + + if ( ! relative.endsWith('/') ) + relative += '/'; + + changed = true; + return `import("${relative}${package}")`; + } + + return match; + }); + + if ( changed ) + fs.writeFileSync(filename, content); + +} diff --git a/bin/update_fonts.js b/bin/update_fonts.js index e3c9788d..e70cbaa0 100644 --- a/bin/update_fonts.js +++ b/bin/update_fonts.js @@ -17,8 +17,16 @@ for(const file of fs.readdirSync(dir)) { const config = JSON.parse(fs.readFileSync('fontello.config.json', 'utf8')); const icons = config.glyphs.map(x => x.css); -fs.writeFileSync('src/utilities/ffz-icons.js', `'use strict'; -// This is a generated file. To update it, please run: npm run font:update +fs.writeFileSync('src/utilities/ffz-icons.ts', `'use strict'; +// This is a generated file. To update it, please run: pnpm font:update /* eslint quotes: 0 */ -export default ${JSON.stringify(icons, null, '\t')};`); +/** + * A list of all valid icon names in the FrankerFaceZ icon font. These + * icons can be used by adding a class to a DOM element with the name + * \`ffz-i-$\{name}\` where \`$\{name}\` is a name from this list. + * + * For example, to use the \`threads\` icon, you'd add the class + * \`ffz-i-threads\` to your element. + */ +export default ${JSON.stringify(icons, null, '\t')} as const;`); diff --git a/package.json b/package.json index f2acbe84..57f18d20 100755 --- a/package.json +++ b/package.json @@ -1,91 +1,106 @@ { - "name": "frankerfacez", - "author": "Dan Salvato LLC", - "version": "4.60.1", - "description": "FrankerFaceZ is a Twitch enhancement suite.", - "private": true, - "license": "Apache-2.0", - "scripts": { - "start": "pnpm dev", - "eslint": "eslint \"src/**/*.{js,jsx,vue}\"", - "clean": "rimraf dist", - "dev": "cross-env NODE_ENV=development webpack serve", - "dev:prod": "cross-env NODE_ENV=production webpack serve", - "build": "pnpm build:prod", - "build:stats": "cross-env NODE_ENV=production webpack build --json > stats.json", - "build:prod": "cross-env NODE_ENV=production webpack build", - "build:dev": "cross-env NODE_ENV=development webpack build", - "font": "pnpm font:edit", - "font:edit": "fontello-cli --cli-config fontello.client.json edit", - "font:save": "fontello-cli --cli-config fontello.client.json save && pnpm font:update", - "font:update": "node bin/update_fonts" - }, - "devDependencies": { - "@ffz/fontello-cli": "^1.0.4", - "browserslist": "^4.21.10", - "copy-webpack-plugin": "^11.0.0", - "cross-env": "^7.0.3", - "css-loader": "^6.8.1", - "esbuild-loader": "^4.0.2", - "eslint": "^8.48.0", - "eslint-plugin-import": "^2.28.1", - "eslint-plugin-react": "^7.33.2", - "eslint-plugin-vue": "^9.17.0", - "extract-loader": "^5.1.0", - "file-loader": "^6.2.0", - "json-loader": "^0.5.7", - "minify-graphql-loader": "^1.0.2", - "raw-loader": "^4.0.2", - "rimraf": "^5.0.1", - "sass": "^1.66.1", - "sass-loader": "^13.3.2", - "semver": "^7.5.4", - "vue-loader": "^15.10.2", - "vue-template-compiler": "^2.6.14", - "webpack": "^5.88.2", - "webpack-cli": "^5.1.4", - "webpack-dev-server": "^4.15.1", - "webpack-manifest-plugin": "^5.0.0" - }, - "repository": { - "type": "git", - "url": "https://github.com/FrankerFaceZ/FrankerFaceZ.git" - }, - "dependencies": { - "@ffz/icu-msgparser": "^2.0.0", - "@popperjs/core": "^2.10.2", - "crypto-js": "^3.3.0", - "dayjs": "^1.10.7", - "denoflare-mqtt": "^0.0.2", - "displacejs": "^1.4.1", - "emoji-regex": "^9.2.2", - "file-saver": "^2.0.5", - "graphql": "^16.0.1", - "graphql-tag": "^2.12.6", - "js-cookie": "^2.2.1", - "jszip": "^3.7.1", - "markdown-it": "^12.2.0", - "markdown-it-link-attributes": "^3.0.0", - "mnemonist": "^0.38.5", - "path-to-regexp": "^3.2.0", - "raven-js": "^3.27.2", - "react": "^17.0.2", - "safe-regex": "^2.1.1", - "sortablejs": "^1.14.0", - "sourcemapped-stacktrace": "^1.1.11", - "text-diff": "^1.0.1", - "vue": "^2.6.14", - "vue-clickaway": "^2.2.2", - "vue-color": "^2.8.1", - "vue-observe-visibility": "^1.0.0", - "vuedraggable": "^2.24.3" - }, - "pnpm": { - "overrides": { - "ansi-regex@>2.1.1 <5.0.1": ">=5.0.1", - "chalk@<4": ">=4 <5", - "set-value@<4.0.1": ">=4.0.1", - "glob-parent@<5.1.2": ">=5.1.2" - } - } + "name": "frankerfacez", + "author": "Dan Salvato LLC", + "version": "4.61.0", + "description": "FrankerFaceZ is a Twitch enhancement suite.", + "private": true, + "license": "Apache-2.0", + "scripts": { + "start": "pnpm dev", + "eslint": "eslint \"src/**/*.{js,jsx,vue}\"", + "clean": "rimraf dist", + "dev": "cross-env NODE_ENV=development webpack serve", + "dev:prod": "cross-env NODE_ENV=production webpack serve", + "build": "pnpm build:prod", + "build:stats": "cross-env NODE_ENV=production webpack build --json > stats.json", + "build:prod": "cross-env NODE_ENV=production webpack build", + "build:dev": "cross-env NODE_ENV=development webpack build", + "build:types": "cross-env tsc --declaration --emitDeclarationOnly --outDir typedist && node bin/build_types", + "abuild:types": "node bin/build_types", + "build:docs": "cross-env typedoc --options typedoc.json", + "font": "pnpm font:edit", + "font:edit": "fontello-cli --cli-config fontello.client.json edit", + "font:save": "fontello-cli --cli-config fontello.client.json save && pnpm font:update", + "font:update": "node bin/update_fonts" + }, + "devDependencies": { + "@ffz/fontello-cli": "^1.0.4", + "@types/crypto-js": "^4.2.1", + "@types/js-cookie": "^3.0.6", + "@types/safe-regex": "^1.1.6", + "@types/vue-clickaway": "^2.2.4", + "@types/webpack-env": "^1.18.4", + "browserslist": "^4.21.10", + "copy-webpack-plugin": "^11.0.0", + "cross-env": "^7.0.3", + "css-loader": "^6.8.1", + "esbuild-loader": "^4.0.2", + "eslint": "^8.48.0", + "eslint-plugin-import": "^2.28.1", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-vue": "^9.17.0", + "extract-loader": "^5.1.0", + "file-loader": "^6.2.0", + "glob": "^10.3.10", + "json-loader": "^0.5.7", + "minify-graphql-loader": "^1.0.2", + "raw-loader": "^4.0.2", + "rimraf": "^5.0.1", + "sass": "^1.66.1", + "sass-loader": "^13.3.2", + "semver": "^7.5.4", + "typedoc": "^0.25.3", + "typedoc-plugin-markdown": "^3.17.1", + "typedoc-plugin-mdn-links": "^3.1.0", + "typedoc-plugin-no-inherit": "^1.4.0", + "typedoc-plugin-rename-defaults": "^0.7.0", + "typescript": "^5.2.2", + "vue-loader": "^15.10.2", + "vue-template-compiler": "^2.6.14", + "webpack": "^5.88.2", + "webpack-cli": "^5.1.4", + "webpack-dev-server": "^4.15.1", + "webpack-manifest-plugin": "^5.0.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/FrankerFaceZ/FrankerFaceZ.git" + }, + "dependencies": { + "@ffz/icu-msgparser": "^2.0.0", + "@popperjs/core": "^2.11.8", + "crypto-js": "^4.2.0", + "dayjs": "^1.10.7", + "denoflare-mqtt": "^0.0.2", + "displacejs": "^1.4.1", + "emoji-regex": "^9.2.2", + "file-saver": "^2.0.5", + "graphql": "^16.0.1", + "graphql-tag": "^2.12.6", + "js-cookie": "^3.0.5", + "jszip": "^3.7.1", + "markdown-it": "^12.2.0", + "markdown-it-link-attributes": "^3.0.0", + "mnemonist": "^0.38.5", + "path-to-regexp": "^3.2.0", + "raven-js": "^3.27.2", + "react": "^17.0.2", + "safe-regex": "^2.1.1", + "sortablejs": "^1.14.0", + "sourcemapped-stacktrace": "^1.1.11", + "text-diff": "^1.0.1", + "vue": "^2.6.14", + "vue-clickaway": "^2.2.2", + "vue-color": "^2.8.1", + "vue-observe-visibility": "^1.0.0", + "vuedraggable": "^2.24.3" + }, + "pnpm": { + "overrides": { + "ansi-regex@>2.1.1 <5.0.1": ">=5.0.1", + "chalk@<4": ">=4 <5", + "set-value@<4.0.1": ">=4.0.1", + "glob-parent@<5.1.2": ">=5.1.2" + } + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0951ca20..ef82d740 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,11 +15,11 @@ dependencies: specifier: ^2.0.0 version: 2.0.0 '@popperjs/core': - specifier: ^2.10.2 - version: 2.10.2 + specifier: ^2.11.8 + version: 2.11.8 crypto-js: - specifier: ^3.3.0 - version: 3.3.0 + specifier: ^4.2.0 + version: 4.2.0 dayjs: specifier: ^1.10.7 version: 1.10.7 @@ -42,8 +42,8 @@ dependencies: specifier: ^2.12.6 version: 2.12.6(graphql@16.0.1) js-cookie: - specifier: ^2.2.1 - version: 2.2.1 + specifier: ^3.0.5 + version: 3.0.5 jszip: specifier: ^3.7.1 version: 3.7.1 @@ -97,6 +97,21 @@ devDependencies: '@ffz/fontello-cli': specifier: ^1.0.4 version: 1.0.4 + '@types/crypto-js': + specifier: ^4.2.1 + version: 4.2.1 + '@types/js-cookie': + specifier: ^3.0.6 + version: 3.0.6 + '@types/safe-regex': + specifier: ^1.1.6 + version: 1.1.6 + '@types/vue-clickaway': + specifier: ^2.2.4 + version: 2.2.4 + '@types/webpack-env': + specifier: ^1.18.4 + version: 1.18.4 browserslist: specifier: ^4.21.10 version: 4.21.10 @@ -130,6 +145,9 @@ devDependencies: file-loader: specifier: ^6.2.0 version: 6.2.0(webpack@5.88.2) + glob: + specifier: ^10.3.10 + version: 10.3.10 json-loader: specifier: ^0.5.7 version: 0.5.7 @@ -151,6 +169,24 @@ devDependencies: semver: specifier: ^7.5.4 version: 7.5.4 + typedoc: + specifier: ^0.25.3 + version: 0.25.3(typescript@5.2.2) + typedoc-plugin-markdown: + specifier: ^3.17.1 + version: 3.17.1(typedoc@0.25.3) + typedoc-plugin-mdn-links: + specifier: ^3.1.0 + version: 3.1.0(typedoc@0.25.3) + typedoc-plugin-no-inherit: + specifier: ^1.4.0 + version: 1.4.0(typedoc@0.25.3) + typedoc-plugin-rename-defaults: + specifier: ^0.7.0 + version: 0.7.0(typedoc@0.25.3) + typescript: + specifier: ^5.2.2 + version: 5.2.2 vue-loader: specifier: ^15.10.2 version: 15.10.2(css-loader@6.8.1)(react@17.0.2)(vue-template-compiler@2.6.14)(webpack@5.88.2) @@ -535,8 +571,8 @@ packages: dev: true optional: true - /@popperjs/core@2.10.2: - resolution: {integrity: sha512-IXf3XA7+XyN7CP9gGh/XB0UxVMlvARGEgGXLubFICsUMGz6Q+DU+i4gGlpOxTjKvXjkJDJC8YdqdKkDj9qZHEQ==} + /@popperjs/core@2.11.8: + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} dev: false /@types/body-parser@1.19.2: @@ -565,6 +601,10 @@ packages: '@types/node': 20.5.7 dev: true + /@types/crypto-js@4.2.1: + resolution: {integrity: sha512-FSPGd9+OcSok3RsM0UZ/9fcvMOXJ1ENE/ZbLfOPlBWj7BgXtEAM8VYfTtT760GiLbQIMoVozwVuisjvsVwqYWw==} + dev: true + /@types/eslint-scope@3.7.4: resolution: {integrity: sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==} dependencies: @@ -611,6 +651,10 @@ packages: '@types/node': 20.5.7 dev: true + /@types/js-cookie@3.0.6: + resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==} + dev: true + /@types/json-schema@7.0.9: resolution: {integrity: sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==} dev: true @@ -643,6 +687,10 @@ packages: resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==} dev: true + /@types/safe-regex@1.1.6: + resolution: {integrity: sha512-CQ/uPB9fLOPKwDsrTeVbNIkwfUthTWOx0l6uIGwVFjZxv7e68pCW5gtTYFzdJi3EBJp8h8zYhJbTasAbX7gEMQ==} + dev: true + /@types/send@0.17.1: resolution: {integrity: sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==} dependencies: @@ -670,6 +718,16 @@ packages: '@types/node': 20.5.7 dev: true + /@types/vue-clickaway@2.2.4: + resolution: {integrity: sha512-Jy0dGNUrm/Fya1hY8bHM5lXJvZvlyU/rvgLEFVcjQkwNp2Z2IGNnRKS6ZH9orMDkUI7Qj0oyWp0b89VTErAS9Q==} + dependencies: + vue: 2.6.14 + dev: true + + /@types/webpack-env@1.18.4: + resolution: {integrity: sha512-I6e+9+HtWADAWeeJWDFQtdk4EVSAbj6Rtz4q8fJ7mSr1M0jzlFcs8/HZ+Xb5SHzVm1dxH7aUiI+A8kA8Gcrm0A==} + dev: true + /@types/ws@8.5.5: resolution: {integrity: sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg==} dependencies: @@ -989,6 +1047,10 @@ packages: engines: {node: '>=12'} dev: true + /ansi-sequence-parser@1.1.1: + resolution: {integrity: sha512-vJXt3yiaUL4UU546s3rPXlsry/RnM730G1+HkpKE012AN0sx1eOrxSu95oKDIonskeLTijMgqWZ3uDEe3NFvyg==} + dev: true + /ansi-styles@4.3.0: resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} engines: {node: '>=8'} @@ -1770,6 +1832,11 @@ packages: engines: {node: '>=6'} dev: true + /camelcase@8.0.0: + resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} + engines: {node: '>=16'} + dev: true + /caniuse-lite@1.0.30001524: resolution: {integrity: sha512-Jj917pJtYg9HSJBF95HVX3Cdr89JUyLT4IZ8SvM5aDRni95swKgYi3TgYLH5hnGfPE/U1dg6IfZ50UsIlLkwSA==} dev: true @@ -2129,8 +2196,8 @@ packages: which: 2.0.2 dev: true - /crypto-js@3.3.0: - resolution: {integrity: sha512-DIT51nX0dCfKltpRiXV+/TVZq+Qq2NgF4644+K7Ttnla7zEzqc+kjJyiB96BHNyUTBxyjzRcZYpUdZa+QAqi6Q==} + /crypto-js@4.2.0: + resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} dev: false /css-loader@6.8.1(webpack@5.88.2): @@ -3056,13 +3123,13 @@ packages: resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} dev: true - /glob@10.3.3: - resolution: {integrity: sha512-92vPiMb/iqpmEgsOoIDvTjc50wf9CCCvMzsi6W0JLPeUKE8TWP1a73PgqSrqy7iAZxaSD1YdzU7QZR5LF51MJw==} + /glob@10.3.10: + resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==} engines: {node: '>=16 || 14 >=14.17'} hasBin: true dependencies: foreground-child: 3.1.1 - jackspeak: 2.3.0 + jackspeak: 2.3.6 minimatch: 9.0.3 minipass: 7.0.3 path-scurry: 1.10.1 @@ -3153,6 +3220,19 @@ packages: resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==} dev: true + /handlebars@4.7.8: + resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + engines: {node: '>=0.4.7'} + hasBin: true + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.17.4 + dev: true + /has-bigints@1.0.2: resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} dev: true @@ -3626,8 +3706,8 @@ packages: reflect.getprototypeof: 1.0.3 dev: true - /jackspeak@2.3.0: - resolution: {integrity: sha512-uKmsITSsF4rUWQHzqaRUuyAir3fZfW3f202Ee34lz/gZCi970CPZwyQXLGNgWJvvZbvFyzeyGq0+4fcG/mBKZg==} + /jackspeak@2.3.6: + resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==} engines: {node: '>=14'} dependencies: '@isaacs/cliui': 8.0.2 @@ -3644,8 +3724,9 @@ packages: supports-color: 8.1.1 dev: true - /js-cookie@2.2.1: - resolution: {integrity: sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==} + /js-cookie@3.0.5: + resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==} + engines: {node: '>=14'} dev: false /js-tokens@3.0.2: @@ -3710,6 +3791,10 @@ packages: hasBin: true dev: true + /jsonc-parser@3.2.0: + resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==} + dev: true + /jsx-ast-utils@3.2.1: resolution: {integrity: sha512-uP5vu8xfy2F9A6LGC22KO7e2/vGTS1MhP+18f++ZNlf0Ohaxbc9nIEwHAsejlJKyzfZzU5UIhe5ItYkitcZnZA==} engines: {node: '>=4.0'} @@ -3837,6 +3922,10 @@ packages: yallist: 4.0.0 dev: true + /lunr@2.3.9: + resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} + dev: true + /markdown-it-link-attributes@3.0.0: resolution: {integrity: sha512-B34ySxVeo6MuEGSPCWyIYryuXINOvngNZL87Mp7YYfKIf6DcD837+lXA8mo6EBbauKsnGz22ZH0zsbOiQRWTNg==} dev: false @@ -3852,6 +3941,12 @@ packages: uc.micro: 1.0.6 dev: false + /marked@4.3.0: + resolution: {integrity: sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==} + engines: {node: '>= 12'} + hasBin: true + dev: true + /material-colors@1.2.6: resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==} dev: false @@ -4679,7 +4774,7 @@ packages: engines: {node: '>=14'} hasBin: true dependencies: - glob: 10.3.3 + glob: 10.3.10 dev: true /run-parallel@1.2.0: @@ -4899,6 +4994,15 @@ packages: resolution: {integrity: sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==} dev: true + /shiki@0.14.5: + resolution: {integrity: sha512-1gCAYOcmCFONmErGTrS1fjzJLA7MGZmKzrBNX7apqSwhyITJg2O102uFzXUeBxNnEkDA9vHIKLyeKq0V083vIw==} + dependencies: + ansi-sequence-parser: 1.1.1 + jsonc-parser: 3.2.0 + vscode-oniguruma: 1.7.0 + vscode-textmate: 8.0.0 + dev: true + /side-channel@1.0.4: resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==} dependencies: @@ -5287,10 +5391,72 @@ packages: is-typed-array: 1.1.12 dev: true + /typedoc-plugin-markdown@3.17.1(typedoc@0.25.3): + resolution: {integrity: sha512-QzdU3fj0Kzw2XSdoL15ExLASt2WPqD7FbLeaqwT70+XjKyTshBnUlQA5nNREO1C2P8Uen0CDjsBLMsCQ+zd0lw==} + peerDependencies: + typedoc: '>=0.24.0' + dependencies: + handlebars: 4.7.8 + typedoc: 0.25.3(typescript@5.2.2) + dev: true + + /typedoc-plugin-mdn-links@3.1.0(typedoc@0.25.3): + resolution: {integrity: sha512-4uwnkvywPFV3UVx7WXpIWTHJdXH1rlE2e4a1WsSwCFYKqJxgTmyapv3ZxJtbSl1dvnb6jmuMNSqKEPz77Gs2OA==} + peerDependencies: + typedoc: '>= 0.23.14 || 0.24.x || 0.25.x' + dependencies: + typedoc: 0.25.3(typescript@5.2.2) + dev: true + + /typedoc-plugin-no-inherit@1.4.0(typedoc@0.25.3): + resolution: {integrity: sha512-cAvqQ8X9xh1xztVoDKtF4nYRSBx9XwttN3OBbNNpA0YaJSRM8XvpVVhugq8FoO1HdWjF3aizS0JzdUOMDt0y9g==} + peerDependencies: + typedoc: '>=0.23.0' + dependencies: + typedoc: 0.25.3(typescript@5.2.2) + dev: true + + /typedoc-plugin-rename-defaults@0.7.0(typedoc@0.25.3): + resolution: {integrity: sha512-NudSQ1o/XLHNF9c4y7LzIZxfE9ltz09yCDklBPJpP5VMRvuBpYGIbQ0ZgmPz+EIV8vPx9Z/OyKwzp4HT2vDtfg==} + peerDependencies: + typedoc: 0.22.x || 0.23.x || 0.24.x || 0.25.x + dependencies: + camelcase: 8.0.0 + typedoc: 0.25.3(typescript@5.2.2) + dev: true + + /typedoc@0.25.3(typescript@5.2.2): + resolution: {integrity: sha512-Ow8Bo7uY1Lwy7GTmphRIMEo6IOZ+yYUyrc8n5KXIZg1svpqhZSWgni2ZrDhe+wLosFS8yswowUzljTAV/3jmWw==} + engines: {node: '>= 16'} + hasBin: true + peerDependencies: + typescript: 4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x + dependencies: + lunr: 2.3.9 + marked: 4.3.0 + minimatch: 9.0.3 + shiki: 0.14.5 + typescript: 5.2.2 + dev: true + + /typescript@5.2.2: + resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==} + engines: {node: '>=14.17'} + hasBin: true + dev: true + /uc.micro@1.0.6: resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==} dev: false + /uglify-js@3.17.4: + resolution: {integrity: sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==} + engines: {node: '>=0.8.0'} + hasBin: true + requiresBuild: true + dev: true + optional: true + /unbox-primitive@1.0.2: resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} dependencies: @@ -5355,6 +5521,14 @@ packages: engines: {node: '>= 0.8'} dev: true + /vscode-oniguruma@1.7.0: + resolution: {integrity: sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==} + dev: true + + /vscode-textmate@8.0.0: + resolution: {integrity: sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==} + dev: true + /vue-clickaway@2.2.2(vue@2.6.14): resolution: {integrity: sha512-25SpjXKetL06GLYoLoC8pqAV6Cur9cQ//2g35GRFBV4FgoljbZZjTINR8g2NuVXXDMLSUXaKx5dutgO4PaDE7A==} peerDependencies: @@ -5502,7 +5676,6 @@ packages: /vue@2.6.14: resolution: {integrity: sha512-x2284lgYvjOMj3Za7kqzRcUSxBboHqtgRE2zlos1qWaOye5yUmHn42LB1250NJBLRwEcdrB0JRwyPTEPhfQjiQ==} - dev: false /vuedraggable@2.24.3: resolution: {integrity: sha512-6/HDXi92GzB+Hcs9fC6PAAozK1RLt1ewPTLjK0anTYguXLAeySDmcnqE8IC0xa7shvSzRjQXq3/+dsZ7ETGF3g==} @@ -5789,6 +5962,10 @@ packages: resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==} dev: true + /wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + dev: true + /wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} diff --git a/src/addons.js b/src/addons.ts similarity index 74% rename from src/addons.js rename to src/addons.ts index 4be77b01..ecefcc95 100644 --- a/src/addons.js +++ b/src/addons.ts @@ -4,21 +4,77 @@ // Add-On System // ============================================================================ -import Module from 'utilities/module'; +import Module, { GenericModule } from 'utilities/module'; import { EXTENSION, SERVER_OR_EXT } from 'utilities/constants'; import { createElement } from 'utilities/dom'; -import { timeout, has, deep_copy } from 'utilities/object'; +import { timeout, has, deep_copy, fetchJSON } from 'utilities/object'; import { getBuster } from 'utilities/time'; +import type SettingsManager from './settings'; +import type TranslationManager from './i18n'; +import type LoadTracker from './load_tracker'; +import type FrankerFaceZ from './main'; +import type { AddonInfo } from 'utilities/types'; + +declare global { + interface Window { + ffzAddonsWebpackJsonp: unknown; + } +} + +declare module 'utilities/types' { + interface ModuleMap { + addons: AddonManager; + } + interface ModuleEventMap { + addons: AddonManagerEvents; + } + interface SettingsTypeMap { + 'addons.dev.server': boolean; + } +}; + +type AddonManagerEvents = { + ':ready': []; + ':data-loaded': []; + ':reload-required': []; + + ':added': [id: string, info: AddonInfo]; + ':addon-loaded': [id: string]; + ':addon-enabled': [id: string]; + ':addon-disabled': [id: string]; + ':fully-unload': [id: string]; +}; + + +type FullAddonInfo = AddonInfo & { + _search?: string | null; + src: string; +}; -const fetchJSON = (url, options) => fetch(url, options).then(r => r.ok ? r.json() : null).catch(() => null); // ============================================================================ // AddonManager // ============================================================================ -export default class AddonManager extends Module { - constructor(...args) { - super(...args); +export default class AddonManager extends Module<'addons'> { + + // Dependencies + i18n: TranslationManager = null as any; + load_tracker: LoadTracker = null as any; + settings: SettingsManager = null as any; + + // State + has_dev: boolean; + reload_required: boolean; + target: string; + + addons: Record; + enabled_addons: string[]; + + private _loader?: Promise; + + constructor(name?: string, parent?: GenericModule) { + super(name, parent); this.should_enable = true; @@ -28,7 +84,7 @@ export default class AddonManager extends Module { this.load_requires = ['settings']; - this.target = this.parent.flavor || 'unknown'; + this.target = (this.parent as unknown as FrankerFaceZ).flavor || 'unknown'; this.has_dev = false; this.reload_required = false; @@ -39,6 +95,7 @@ export default class AddonManager extends Module { } onLoad() { + // We don't actually *wait* for this, we just start it. this._loader = this.loadAddonData(); } @@ -54,20 +111,20 @@ export default class AddonManager extends Module { getFFZ: () => this, isReady: () => this.enabled, getAddons: () => Object.values(this.addons), - hasAddon: id => this.hasAddon(id), - getVersion: id => this.getVersion(id), - doesAddonTarget: id => this.doesAddonTarget(id), - isAddonEnabled: id => this.isAddonEnabled(id), - isAddonExternal: id => this.isAddonExternal(id), - enableAddon: id => this.enableAddon(id), - disableAddon: id => this.disableAddon(id), - reloadAddon: id => this.reloadAddon(id), - canReloadAddon: id => this.canReloadAddon(id), + hasAddon: (id: string) => this.hasAddon(id), + getVersion: (id: string) => this.getVersion(id), + doesAddonTarget: (id: string) => this.doesAddonTarget(id), + isAddonEnabled: (id: string) => this.isAddonEnabled(id), + isAddonExternal: (id: string) => this.isAddonExternal(id), + enableAddon: (id: string) => this.enableAddon(id), + disableAddon: (id: string) => this.disableAddon(id), + reloadAddon: (id: string) => this.reloadAddon(id), + canReloadAddon: (id: string) => this.canReloadAddon(id), isReloadRequired: () => this.reload_required, refresh: () => window.location.reload(), - on: (...args) => this.on(...args), - off: (...args) => this.off(...args) + on: (...args: Parameters) => this.on(...args), + off: (...args: Parameters) => this.off(...args) }); if ( ! EXTENSION ) @@ -85,7 +142,7 @@ export default class AddonManager extends Module { this.settings.provider.on('changed', this.onProviderChange, this); - this._loader.then(() => { + this._loader?.then(() => { this.enabled_addons = this.settings.provider.get('addons.enabled', []); // We do not await enabling add-ons because that would delay the @@ -103,8 +160,8 @@ export default class AddonManager extends Module { } - doesAddonTarget(id) { - const data = this.addons[id]; + doesAddonTarget(id: string) { + const data = this.getAddon(id); if ( ! data ) return false; @@ -118,12 +175,15 @@ export default class AddonManager extends Module { generateLog() { const out = ['Known']; - for(const [id, addon] of Object.entries(this.addons)) + for(const [id, addon] of Object.entries(this.addons)) { + if ( Array.isArray(addon) ) + continue; out.push(`${id} | ${this.isAddonEnabled(id) ? 'enabled' : 'disabled'} | ${addon.dev ? 'dev | ' : ''}${this.isAddonExternal(id) ? 'external | ' : ''}${addon.short_name} v${addon.version}`); + } out.push(''); out.push('Modules'); - for(const [key, module] of Object.entries(this.__modules)) { + for(const [key, module] of Object.entries((this as any).__modules as Record)) { if ( module ) out.push(`${module.loaded ? 'loaded ' : module.loading ? 'loading ' : 'unloaded'} | ${module.enabled ? 'enabled ' : module.enabling ? 'enabling' : 'disabled'} | ${key}`) } @@ -131,22 +191,20 @@ export default class AddonManager extends Module { return out.join('\n'); } - onProviderChange(key, value) { + onProviderChange(key: string, value: unknown) { if ( key != 'addons.enabled' ) return; - if ( ! value ) - value = []; - - const old_enabled = [...this.enabled_addons]; + const val: string[] = Array.isArray(value) ? value : [], + old_enabled = [...this.enabled_addons]; // Add-ons to disable for(const id of old_enabled) - if ( ! value.includes(id) ) + if ( ! val.includes(id) ) this.disableAddon(id, false); // Add-ons to enable - for(const id of value) + for(const id of val) if ( ! old_enabled.includes(id) ) this.enableAddon(id, false); } @@ -187,7 +245,9 @@ export default class AddonManager extends Module { this.emit(':data-loaded'); } - addAddon(addon, is_dev = false) { + addAddon(input: AddonInfo, is_dev: boolean = false) { + let addon = input as FullAddonInfo; + const old = this.addons[addon.id]; this.addons[addon.id] = addon; @@ -217,7 +277,7 @@ export default class AddonManager extends Module { this.addons[id] = [addon.id]; } - if ( ! old ) + if ( ! old || Array.isArray(old) ) this.settings.addUI(`addon-changelog.${addon.id}`, { path: `Add-Ons > Changelog > ${addon.name}`, component: 'changelog', @@ -227,11 +287,14 @@ export default class AddonManager extends Module { getFFZ: () => this }); - this.emit(':added'); + this.emit(':added', addon.id, addon); } rebuildAddonSearch() { for(const addon of Object.values(this.addons)) { + if ( Array.isArray(addon) ) + continue; + const terms = new Set([ addon._search, addon.name, @@ -250,47 +313,51 @@ export default class AddonManager extends Module { if ( addon.author_i18n ) terms.add(this.i18n.t(addon.author_i18n, addon.author)); + if ( addon.maintainer_i18n ) + terms.add(this.i18n.t(addon.maintainer_i18n, addon.maintainer)); + if ( addon.description_i18n ) terms.add(this.i18n.t(addon.description_i18n, addon.description)); } - addon.search_terms = [...terms].map(term => term ? term.toLocaleLowerCase() : '').join('\n'); + addon.search_terms = [...terms] + .map(term => term ? term.toLocaleLowerCase() : '').join('\n'); } } - isAddonEnabled(id) { + isAddonEnabled(id: string) { if ( this.isAddonExternal(id) ) return true; return this.enabled_addons.includes(id); } - getAddon(id) { + getAddon(id: string) { const addon = this.addons[id]; return Array.isArray(addon) ? null : addon; } - hasAddon(id) { + hasAddon(id: string) { return this.getAddon(id) != null; } - getVersion(id) { + getVersion(id: string) { const addon = this.getAddon(id); if ( ! addon ) throw new Error(`Unknown add-on id: ${id}`); const module = this.resolve(`addon.${id}`); if ( module ) { - if ( has(module, 'version') ) + if ( 'version' in module ) // has(module, 'version') ) return module.version; - else if ( module.constructor && has(module.constructor, 'version') ) + else if ( module.constructor && 'version' in module.constructor ) // has(module.constructor, 'version') ) return module.constructor.version; } return addon.version; } - isAddonExternal(id) { + isAddonExternal(id: string) { if ( ! this.hasAddon(id) ) throw new Error(`Unknown add-on id: ${id}`); @@ -306,10 +373,10 @@ export default class AddonManager extends Module { return true; // Finally, let the module flag itself as external. - return module.external || (module.constructor && module.constructor.external); + return (module as any).external || (module.constructor as any)?.external; } - canReloadAddon(id) { + canReloadAddon(id: string) { // Obviously we can't reload it if we don't have it. if ( ! this.hasAddon(id) ) throw new Error(`Unknown add-on id: ${id}`); @@ -334,8 +401,8 @@ export default class AddonManager extends Module { return true; } - async fullyUnloadModule(module) { - if ( ! module ) + async fullyUnloadModule(module: GenericModule) { + if ( ! module || ! module.addon_id ) return; if ( module.children ) @@ -346,47 +413,47 @@ export default class AddonManager extends Module { await module.unload(); // Clean up parent references. - if ( module.parent && module.parent.children[module.name] === module ) + if ( module.parent instanceof Module && module.parent.children[module.name] === module ) delete module.parent.children[module.name]; // Clean up all individual references. for(const entry of module.references) { const other = this.resolve(entry[0]), name = entry[1]; - if ( other && other[name] === module ) - other[name] = null; + if ( (other as any)[name] === module ) + (other as any)[name] = null; } // Send off a signal for other modules to unload related data. - this.emit('addon:fully-unload', module.addon_id); + this.emit(':fully-unload', module.addon_id); // Clean up the global reference. - if ( this.__modules[module.__path] === module ) - delete this.__modules[module.__path]; /* = [ + if ( (this as any).__modules[(module as any).__path] === module ) + delete (this as any).__modules[(module as any).__path]; /* = [ module.dependents, module.load_dependents, module.references ];*/ // Remove any events we didn't unregister. - this.offContext(null, module); + this.off(undefined, undefined, module); // Do the same for settings. for(const ctx of this.settings.__contexts) - ctx.offContext(null, module); + ctx.off(undefined, undefined, module); // Clean up all settings. for(const [key, def] of Array.from(this.settings.definitions.entries())) { - if ( def && def.__source === module.addon_id ) { + if ( ! Array.isArray(def) && def?.__source === module.addon_id ) { this.settings.remove(key); } } // Clean up the logger too. - module.__log = null; + (module as any).__log = null; } - async reloadAddon(id) { + async reloadAddon(id: string) { const addon = this.getAddon(id), button = this.resolve('site.menu_button'); if ( ! addon ) @@ -456,7 +523,7 @@ export default class AddonManager extends Module { }); } - async _enableAddon(id) { + private async _enableAddon(id: string) { const addon = this.getAddon(id); if ( ! addon ) throw new Error(`Unknown add-on id: ${id}`); @@ -476,7 +543,7 @@ export default class AddonManager extends Module { this.load_tracker.notify(event, `addon.${id}`, false); } - async loadAddon(id) { + async loadAddon(id: string) { const addon = this.getAddon(id); if ( ! addon ) throw new Error(`Unknown add-on id: ${id}`); @@ -500,7 +567,7 @@ export default class AddonManager extends Module { })); // Error if this takes more than 5 seconds. - await timeout(this.waitFor(`addon.${id}:registered`), 60000); + await timeout(this.waitFor(`addon.${id}:registered` as any), 60000); module = this.resolve(`addon.${id}`); if ( module && ! module.loaded ) @@ -509,13 +576,13 @@ export default class AddonManager extends Module { this.emit(':addon-loaded', id); } - unloadAddon(id) { + unloadAddon(id: string) { const module = this.resolve(`addon.${id}`); if ( module ) return module.unload(); } - enableAddon(id, save = true) { + enableAddon(id: string, save: boolean = true) { const addon = this.getAddon(id); if( ! addon ) throw new Error(`Unknown add-on id: ${id}`); @@ -546,7 +613,7 @@ export default class AddonManager extends Module { }); } - async disableAddon(id, save = true) { + async disableAddon(id: string, save: boolean = true) { const addon = this.getAddon(id); if ( ! addon ) throw new Error(`Unknown add-on id: ${id}`); diff --git a/src/clips.js b/src/clips.js index 60a9992a..ce6d98b6 100644 --- a/src/clips.js +++ b/src/clips.js @@ -12,13 +12,13 @@ import {timeout} from 'utilities/object'; import SettingsManager from './settings/index'; import AddonManager from './addons'; import ExperimentManager from './experiments'; -import {TranslationManager} from './i18n'; +import TranslationManager from './i18n'; import PubSubClient from './pubsub'; import StagingSelector from './staging'; import LoadTracker from './load_tracker'; import Site from './sites/clips'; -import Vue from 'utilities/vue'; +import VueModule from 'utilities/vue'; import Tooltips from 'src/modules/tooltips'; import Chat from 'src/modules/chat'; @@ -64,7 +64,7 @@ class FrankerFaceZ extends Module { this.inject('site', Site); this.inject('addons', AddonManager); - this.register('vue', Vue); + this.register('vue', VueModule); // ======================================================================== // Startup diff --git a/src/entry.js b/src/entry.js index 19f74228..54455c03 100644 --- a/src/entry.js +++ b/src/entry.js @@ -26,4 +26,4 @@ script.crossOrigin = 'anonymous'; script.src = `${SERVER}/script/${FLAVOR}.js?_=${Date.now()}`; document.head.appendChild(script); -})(); \ No newline at end of file +})(); diff --git a/src/experiments.js b/src/experiments.js deleted file mode 100644 index 26686f6a..00000000 --- a/src/experiments.js +++ /dev/null @@ -1,479 +0,0 @@ -'use strict'; - -// ============================================================================ -// Experiments -// ============================================================================ - -import {DEBUG, SERVER} from 'utilities/constants'; -import Module from 'utilities/module'; -import {has, deep_copy} from 'utilities/object'; -import { getBuster } from 'utilities/time'; - -import Cookie from 'js-cookie'; -import SHA1 from 'crypto-js/sha1'; - -const OVERRIDE_COOKIE = 'experiment_overrides', - COOKIE_OPTIONS = { - expires: 7, - domain: '.twitch.tv' - }; - - -// We want to import this so that the file is included in the output. -// We don't load using this because we might want a newer file from the -// server. -import EXPERIMENTS from './experiments.json'; // eslint-disable-line no-unused-vars - - -function sortExperimentLog(a,b) { - if ( a.rarity < b.rarity ) - return -1; - else if ( a.rarity > b.rarity ) - return 1; - - if ( a.name < b.name ) - return -1; - else if ( a.name > b.name ) - return 1; - - return 0; -} - - -// ============================================================================ -// Experiment Manager -// ============================================================================ - -export default class ExperimentManager extends Module { - constructor(...args) { - super(...args); - - this.get = this.getAssignment; - - this.inject('settings'); - - this.settings.addUI('experiments', { - path: 'Debugging > Experiments', - component: 'experiments', - no_filter: true, - - getExtraTerms: () => { - const values = []; - - for(const exps of [this.experiments, this.getTwitchExperiments()]) { - if ( ! exps ) - continue; - - for(const [key, val] of Object.entries(exps)) { - values.push(key); - if ( val.name ) - values.push(val.name); - if ( val.description ) - values.push(val.description); - } - } - - return values; - }, - - is_locked: () => this.getControlsLocked(), - unlock: () => this.unlockControls(), - - unique_id: () => this.unique_id, - - ffz_data: () => deep_copy(this.experiments), - twitch_data: () => deep_copy(this.getTwitchExperiments()), - - usingTwitchExperiment: key => this.usingTwitchExperiment(key), - getTwitchAssignment: key => this.getTwitchAssignment(key), - getTwitchType: type => this.getTwitchType(type), - hasTwitchOverride: key => this.hasTwitchOverride(key), - setTwitchOverride: (key, val) => this.setTwitchOverride(key, val), - deleteTwitchOverride: key => this.deleteTwitchOverride(key), - - getAssignment: key => this.getAssignment(key), - hasOverride: key => this.hasOverride(key), - setOverride: (key, val) => this.setOverride(key, val), - deleteOverride: key => this.deleteOverride(key), - - on: (...args) => this.on(...args), - off: (...args) => this.off(...args) - }); - - this.unique_id = Cookie.get('unique_id'); - - this.Cookie = Cookie; - - this.experiments = {}; - this.cache = new Map; - } - - getControlsLocked() { - if ( DEBUG ) - return false; - - const ts = this.settings.provider.get('exp-lock', 0); - if ( isNaN(ts) || ! isFinite(ts) ) - return true; - - return Date.now() - ts >= 86400000; - } - - unlockControls() { - this.settings.provider.set('exp-lock', Date.now()); - } - - async onLoad() { - await this.loadExperiments(); - } - - - async loadExperiments() { - let data; - - try { - data = await fetch(DEBUG ? EXPERIMENTS : `${SERVER}/script/experiments.json?_=${getBuster()}`).then(r => - r.ok ? r.json() : null); - - } catch(err) { - this.log.warn('Unable to load experiment data.', err); - } - - if ( ! data ) - return; - - this.experiments = data; - - const old_cache = this.cache; - this.cache = new Map; - - let changed = 0; - - for(const [key, old_val] of old_cache.entries()) { - const new_val = this.getAssignment(key); - if ( old_val !== new_val ) { - changed++; - this.emit(':changed', key, new_val); - this.emit(`:changed:${key}`, new_val); - } - } - - this.log.info(`Loaded information on ${Object.keys(data).length} experiments.${changed > 0 ? ` ${changed} values updated.` : ''}`); - //this.emit(':loaded'); - } - - - onEnable() { - this.on('pubsub:command:reload_experiments', this.loadExperiments, this); - this.on('pubsub:command:update_experiment', this.updateExperiment, this); - } - - - updateExperiment(key, data) { - this.log.info(`Received updated data for experiment "${key}" via WebSocket.`, data); - - if ( data.groups ) - this.experiments[key] = data; - else - this.experiments[key].groups = data; - - this._rebuildKey(key); - } - - - generateLog() { - const out = [ - `Unique ID: ${this.unique_id}`, - '' - ]; - - const ffz_assignments = []; - for(const [key, value] of Object.entries(this.experiments)) { - const assignment = this.getAssignment(key), - override = this.hasOverride(key); - - let weight = 0, total = 0; - for(const group of value.groups) { - if ( group.value === assignment ) - weight = group.weight; - total += group.weight; - } - - if ( ! override && weight === total ) - continue; - - ffz_assignments.push({ - key, - name: value.name, - value: assignment, - override, - rarity: weight / total - }); - - //out.push(`FFZ | ${value.name}: ${this.getAssignment(key)}${this.hasOverride(key) ? ' (Overriden)' : ''}`); - } - - ffz_assignments.sort(sortExperimentLog); - - for(const entry of ffz_assignments) - out.push(`FFZ | ${entry.name}: ${entry.value}${entry.override ? ' (Override)' : ''} (r:${entry.rarity})`); - - const twitch_assignments = [], - channel = this.settings.get('context.channel'); - - for(const [key, value] of Object.entries(this.getTwitchExperiments())) { - if ( ! this.usingTwitchExperiment(key) ) - continue; - - const assignment = this.getTwitchAssignment(key), - override = this.hasTwitchOverride(key); - - let weight = 0, total = 0; - for(const group of value.groups) { - if ( group.value === assignment ) - weight = group.weight; - total += group.weight; - } - - if ( ! override && weight === total ) - continue; - - twitch_assignments.push({ - key, - name: value.name, - value: assignment, - override, - type: this.getTwitchTypeByKey(key), - rarity: weight / total - }); - - //out.push(`TWITCH | ${value.name}: ${this.getTwitchAssignment(key)}${this.hasTwitchOverride(key) ? ' (Overriden)' : ''}`) - } - - twitch_assignments.sort(sortExperimentLog); - - for(const entry of twitch_assignments) - out.push(`Twitch | ${entry.name}: ${entry.value}${entry.override ? ' (Override)' : ''} (r:${entry.rarity}, t:${entry.type}${entry.type === 'channel_id' ? `, c:${channel}`: ''})`); - - return out.join('\n'); - } - - - // Twitch Experiments - - getTwitchType(type) { - const core = this.resolve('site')?.getCore?.(); - if ( core?.experiments?.getExperimentType ) - return core.experiments.getExperimentType(type); - - if ( type === 1 ) - return 'device_id'; - else if ( type === 2 ) - return 'user_id'; - else if ( type === 3 ) - return 'channel_id'; - return type; - } - - getTwitchTypeByKey(key) { - const core = this.resolve('site')?.getCore?.(), - exps = core && core.experiments, - exp = exps?.experiments?.[key]; - - if ( exp?.t ) - return this.getTwitchType(exp.t); - - return null; - } - - getTwitchExperiments() { - if ( window.__twilightSettings ) - return window.__twilightSettings.experiments; - - const core = this.resolve('site')?.getCore?.(); - return core && core.experiments.experiments; - } - - - usingTwitchExperiment(key) { - const core = this.resolve('site')?.getCore?.(); - return core && has(core.experiments.assignments, key) - } - - - setTwitchOverride(key, value = null) { - const overrides = Cookie.getJSON(OVERRIDE_COOKIE) || {}; - const experiments = overrides.experiments = overrides.experiments || {}; - const disabled = overrides.disabled = overrides.disabled || []; - experiments[key] = value; - const idx = disabled.indexOf(key); - if (idx != -1) - disabled.remove(idx); - Cookie.set(OVERRIDE_COOKIE, overrides, COOKIE_OPTIONS); - - const core = this.resolve('site')?.getCore?.(); - if ( core ) - core.experiments.overrides[key] = value; - - this._rebuildTwitchKey(key, true, value); - } - - deleteTwitchOverride(key) { - const overrides = Cookie.getJSON(OVERRIDE_COOKIE), - experiments = overrides?.experiments; - if ( ! experiments || ! has(experiments, key) ) - return; - - const old_val = experiments[key]; - delete experiments[key]; - Cookie.set(OVERRIDE_COOKIE, overrides, COOKIE_OPTIONS); - - const core = this.resolve('site')?.getCore?.(); - if ( core ) - delete core.experiments.overrides[key]; - - this._rebuildTwitchKey(key, false, old_val); - } - - hasTwitchOverride(key) { // eslint-disable-line class-methods-use-this - const overrides = Cookie.getJSON(OVERRIDE_COOKIE), - experiments = overrides?.experiments; - return experiments && has(experiments, key); - } - - getTwitchAssignment(key, channel = null) { - const core = this.resolve('site')?.getCore?.(), - exps = core && core.experiments; - - if ( ! exps ) - return null; - - if ( ! exps.hasInitialized && exps.initialize ) - try { - exps.initialize(); - } catch(err) { - this.log.warn('Error attempting to initialize Twitch experiments tracker.', err); - } - - if ( channel || this.getTwitchType(exps.experiments[key]?.t) === 'channel_id' ) - return exps.getAssignmentById(key, {channel: channel ?? this.settings.get('context.channel')}); - - if ( exps.overrides && exps.overrides[key] ) - return exps.overrides[key]; - - else if ( exps.assignments && exps.assignments[key] ) - return exps.assignments[key]; - - return null; - } - - getTwitchKeyFromName(name) { - const experiments = this.getTwitchExperiments(); - if ( ! experiments ) - return undefined; - - name = name.toLowerCase(); - for(const key in experiments) - if ( has(experiments, key) ) { - const data = experiments[key]; - if ( data && data.name && data.name.toLowerCase() === name ) - return key; - } - } - - getTwitchAssignmentByName(name, channel = null) { - return this.getTwitchAssignment(this.getTwitchKeyFromName(name), channel); - } - - _rebuildTwitchKey(key, is_set, new_val) { - const core = this.resolve('site')?.getCore?.(), - exps = core.experiments, - - old_val = has(exps.assignments, key) ? - exps.assignments[key] : - undefined; - - if ( old_val !== new_val ) { - const value = is_set ? new_val : old_val; - this.emit(':twitch-changed', key, value); - this.emit(`:twitch-changed:${key}`, value); - } - } - - - // FFZ Experiments - - setOverride(key, value = null) { - const overrides = this.settings.provider.get('experiment-overrides') || {}; - overrides[key] = value; - - this.settings.provider.set('experiment-overrides', overrides); - - this._rebuildKey(key); - } - - deleteOverride(key) { - const overrides = this.settings.provider.get('experiment-overrides'); - if ( ! overrides || ! has(overrides, key) ) - return; - - delete overrides[key]; - this.settings.provider.set('experiment-overrides', overrides); - - this._rebuildKey(key); - } - - hasOverride(key) { - const overrides = this.settings.provider.get('experiment-overrides'); - return overrides && has(overrides, key); - } - - getAssignment(key) { - if ( this.cache.has(key) ) - return this.cache.get(key); - - const experiment = this.experiments[key]; - if ( ! experiment ) { - this.log.warn(`Tried to get assignment for experiment "${key}" which is not known.`); - return null; - } - - const overrides = this.settings.provider.get('experiment-overrides'), - out = overrides && has(overrides, key) ? - overrides[key] : - ExperimentManager.selectGroup(key, experiment, this.unique_id); - - this.cache.set(key, out); - return out; - } - - _rebuildKey(key) { - if ( ! this.cache.has(key) ) - return; - - const old_val = this.cache.get(key); - this.cache.delete(key); - const new_val = this.getAssignment(key); - - if ( new_val !== old_val ) { - this.emit(':changed', key, new_val); - this.emit(`:changed:${key}`, new_val); - } - } - - - static selectGroup(key, experiment, unique_id) { - const seed = key + unique_id + (experiment.seed || ''), - total = experiment.groups.reduce((a,b) => a + b.weight, 0); - - let value = (SHA1(seed).words[0] >>> 0) / Math.pow(2, 32); - - for(const group of experiment.groups) { - value -= group.weight / total; - if ( value <= 0 ) - return group.value; - } - - return null; - } -} \ No newline at end of file diff --git a/src/experiments.ts b/src/experiments.ts new file mode 100644 index 00000000..e1f36069 --- /dev/null +++ b/src/experiments.ts @@ -0,0 +1,696 @@ +'use strict'; + +// ============================================================================ +// Experiments +// ============================================================================ + +import {DEBUG, SERVER} from 'utilities/constants'; +import Module, { GenericModule } from 'utilities/module'; +import {has, deep_copy, fetchJSON} from 'utilities/object'; +import { getBuster } from 'utilities/time'; + +import Cookie from 'js-cookie'; +import SHA1 from 'crypto-js/sha1'; + +import type SettingsManager from './settings'; +import type { ExperimentTypeMap } from 'utilities/types'; + +declare module 'utilities/types' { + interface ModuleMap { + experiments: ExperimentManager; + } + interface ModuleEventMap { + experiments: ExperimentEvents; + } + interface ProviderTypeMap { + 'experiment-overrides': { + [K in keyof ExperimentTypeMap]?: ExperimentTypeMap[K]; + } + } + interface PubSubCommands { + reload_experiments: []; + update_experiment: { + key: keyof ExperimentTypeMap, + data: FFZExperimentData | ExperimentGroup[] + }; + } +} + +declare global { + interface Window { + __twilightSettings?: { + experiments?: Record; + } + } +} + + +const OVERRIDE_COOKIE = 'experiment_overrides', + COOKIE_OPTIONS = { + expires: 7, + domain: '.twitch.tv' + }; + + +// We want to import this so that the file is included in the output. +// We don't load using this because we might want a newer file from the +// server. Because of our webpack settings, this is imported as a URL +// and not an object. +const EXPERIMENTS: string = require('./experiments.json'); + +// ============================================================================ +// Data Types +// ============================================================================ + +export enum TwitchExperimentType { + Unknown = 0, + Device = 1, + User = 2, + Channel = 3 +}; + +export type ExperimentGroup = { + value: unknown; + weight: number; +}; + +export type FFZExperimentData = { + name: string; + seed?: number; + description: string; + groups: ExperimentGroup[]; +} + +export type TwitchExperimentData = { + name: string; + t: TwitchExperimentType; + v: number; + groups: ExperimentGroup[]; +}; + +export type ExperimentData = FFZExperimentData | TwitchExperimentData; + + +export type OverrideCookie = { + experiments: Record; + disabled: string[]; +}; + + +type ExperimentEvents = { + ':changed': [key: string, new_value: any, old_value: any]; + ':twitch-changed': [key: string, new_value: string | null, old_value: string | null]; + [key: `:twitch-changed:${string}`]: [new_value: string | null, old_value: string | null]; +} & { + [K in keyof ExperimentTypeMap as `:changed:${K}`]: [new_value: ExperimentTypeMap[K], old_value: ExperimentTypeMap[K] | null]; +}; + + +type ExperimentLogEntry = { + key: string; + name: string; + value: any; + override: boolean; + rarity: number; + type?: string; +} + + +// ============================================================================ +// Helper Methods +// ============================================================================ + +export function isTwitchExperiment(exp: ExperimentData): exp is TwitchExperimentData { + return 't' in exp; +} + +export function isFFZExperiment(exp: ExperimentData): exp is FFZExperimentData { + return 'description' in exp; +} + +function sortExperimentLog(a: ExperimentLogEntry, b: ExperimentLogEntry) { + if ( a.rarity < b.rarity ) + return -1; + else if ( a.rarity > b.rarity ) + return 1; + + if ( a.name < b.name ) + return -1; + else if ( a.name > b.name ) + return 1; + + return 0; +} + + +// ============================================================================ +// Experiment Manager +// ============================================================================ + +export default class ExperimentManager extends Module<'experiments', ExperimentEvents> { + + // Dependencies + settings: SettingsManager = null as any; + + // State + unique_id?: string; + experiments: Partial<{ + [K in keyof ExperimentTypeMap]: FFZExperimentData; + }>; + + private cache: Map; + + + // Helpers + Cookie: typeof Cookie; + + + constructor(name?: string, parent?: GenericModule) { + super(name, parent); + + this.get = this.getAssignment; + + this.inject('settings'); + + this.settings.addUI('experiments', { + path: 'Debugging > Experiments', + component: 'experiments', + no_filter: true, + + getExtraTerms: () => { + const values: string[] = []; + + for(const [key, val] of Object.entries(this.experiments)) { + values.push(key); + if ( val.name ) + values.push(val.name); + if ( val.description ) + values.push(val.description); + } + + for(const [key, val] of Object.entries(this.getTwitchExperiments())) { + values.push(key); + if ( val.name ) + values.push(val.name); + } + + return values; + }, + + is_locked: () => this.getControlsLocked(), + unlock: () => this.unlockControls(), + + unique_id: () => this.unique_id, + + ffz_data: () => deep_copy(this.experiments), + twitch_data: () => deep_copy(this.getTwitchExperiments()), + + usingTwitchExperiment: (key: string) => this.usingTwitchExperiment(key), + getTwitchAssignment: (key: string) => this.getTwitchAssignment(key), + getTwitchType: (type: TwitchExperimentType) => this.getTwitchType(type), + hasTwitchOverride: (key: string) => this.hasTwitchOverride(key), + setTwitchOverride: (key: string, val: string) => this.setTwitchOverride(key, val), + deleteTwitchOverride: (key: string) => this.deleteTwitchOverride(key), + + getAssignment: (key: K) => this.getAssignment(key), + hasOverride: (key: keyof ExperimentTypeMap) => this.hasOverride(key), + setOverride: (key: K, val: ExperimentTypeMap[K]) => this.setOverride(key, val), + deleteOverride: (key: keyof ExperimentTypeMap) => this.deleteOverride(key), + + on: (...args: Parameters) => this.on(...args), + off: (...args: Parameters) => this.off(...args) + }); + + this.unique_id = Cookie.get('unique_id'); + + this.Cookie = Cookie; + + this.experiments = {}; + this.cache = new Map; + } + + getControlsLocked() { + if ( DEBUG ) + return false; + + const ts = this.settings.provider.get('exp-lock', 0); + if ( isNaN(ts) || ! isFinite(ts) ) + return true; + + return Date.now() - ts >= 86400000; + } + + unlockControls() { + this.settings.provider.set('exp-lock', Date.now()); + } + + async onLoad() { + await this.loadExperiments(); + } + + + async loadExperiments() { + let data: Record | null; + + try { + data = await fetchJSON(DEBUG + ? EXPERIMENTS + : `${SERVER}/script/experiments.json?_=${getBuster()}` + ); + + } catch(err) { + this.log.warn('Unable to load experiment data.', err); + return; + } + + if ( ! data ) + return; + + this.experiments = data; + + const old_cache = this.cache; + this.cache = new Map; + + let changed = 0; + + for(const [key, old_val] of old_cache.entries()) { + const new_val = this.getAssignment(key); + if ( old_val !== new_val ) { + changed++; + this.emit(':changed', key, new_val, old_val); + this.emit(`:changed:${key as keyof ExperimentTypeMap}`, new_val as any, old_val as any); + } + } + + this.log.info(`Loaded information on ${Object.keys(data).length} experiments.${changed > 0 ? ` ${changed} values updated.` : ''}`); + //this.emit(':loaded'); + } + + /** @internal */ + onEnable() { + this.on('pubsub:command:reload_experiments', this.loadExperiments, this); + this.on('pubsub:command:update_experiment', data => { + this.updateExperiment(data.key, data.data); + }, this); + } + + + updateExperiment(key: keyof ExperimentTypeMap, data: FFZExperimentData | ExperimentGroup[]) { + this.log.info(`Received updated data for experiment "${key}" via PubSub.`, data); + + if ( Array.isArray(data) ) { + const existing = this.experiments[key]; + if ( ! existing ) + return; + + existing.groups = data; + + } else if ( data?.groups ) + this.experiments[key] = data; + + this._rebuildKey(key); + } + + + generateLog() { + const out = [ + `Unique ID: ${this.unique_id}`, + '' + ]; + + const ffz_assignments: ExperimentLogEntry[] = []; + for(const [key, value] of Object.entries(this.experiments) as [keyof ExperimentTypeMap, FFZExperimentData][]) { + const assignment = this.getAssignment(key), + override = this.hasOverride(key); + + let weight = 0, total = 0; + for(const group of value.groups) { + if ( group.value === assignment ) + weight = group.weight; + total += group.weight; + } + + if ( ! override && weight === total ) + continue; + + ffz_assignments.push({ + key, + name: value.name, + value: assignment, + override, + rarity: weight / total + }); + + //out.push(`FFZ | ${value.name}: ${this.getAssignment(key)}${this.hasOverride(key) ? ' (Overriden)' : ''}`); + } + + ffz_assignments.sort(sortExperimentLog); + + for(const entry of ffz_assignments) + out.push(`FFZ | ${entry.name}: ${entry.value}${entry.override ? ' (Override)' : ''} (r:${entry.rarity})`); + + const twitch_assignments: ExperimentLogEntry[] = [], + channel = this.settings.get('context.channel'); + + for(const [key, value] of Object.entries(this.getTwitchExperiments())) { + if ( ! this.usingTwitchExperiment(key) ) + continue; + + const assignment = this.getTwitchAssignment(key), + override = this.hasTwitchOverride(key); + + let weight = 0, total = 0; + for(const group of value.groups) { + if ( group.value === assignment ) + weight = group.weight; + total += group.weight; + } + + if ( ! override && weight === total ) + continue; + + twitch_assignments.push({ + key, + name: value.name, + value: assignment, + override, + type: this.getTwitchTypeByKey(key), + rarity: weight / total + }); + + //out.push(`TWITCH | ${value.name}: ${this.getTwitchAssignment(key)}${this.hasTwitchOverride(key) ? ' (Overriden)' : ''}`) + } + + twitch_assignments.sort(sortExperimentLog); + + for(const entry of twitch_assignments) + out.push(`Twitch | ${entry.name}: ${entry.value}${entry.override ? ' (Override)' : ''} (r:${entry.rarity}, t:${entry.type}${entry.type === 'channel_id' ? `, c:${channel}`: ''})`); + + return out.join('\n'); + } + + + // Twitch Experiments + + getTwitchType(type: number) { + const core = this.resolve('site')?.getCore?.(); + if ( core?.experiments?.getExperimentType ) + return core.experiments.getExperimentType(type); + + if ( type === 1 ) + return 'device_id'; + else if ( type === 2 ) + return 'user_id'; + else if ( type === 3 ) + return 'channel_id'; + return type; + } + + getTwitchTypeByKey(key: string) { + const exps = this.getTwitchExperiments(), + exp = exps?.[key]; + + if ( exp?.t ) + return this.getTwitchType(exp.t); + + return null; + } + + getTwitchExperiments(): Record { + if ( window.__twilightSettings ) + return window.__twilightSettings.experiments ?? {}; + + const core = this.resolve('site')?.getCore?.(); + return core && core.experiments.experiments || {}; + } + + + usingTwitchExperiment(key: string) { + const core = this.resolve('site')?.getCore?.(); + return core && has(core.experiments.assignments, key) + } + + + private _getOverrideCookie() { + const raw = Cookie.get(OVERRIDE_COOKIE); + let out: OverrideCookie; + + try { + out = raw ? JSON.parse(raw) : {}; + } catch(err) { + out = {} as OverrideCookie; + } + + if ( ! out.experiments ) + out.experiments = {}; + + if ( ! out.disabled ) + out.disabled = []; + + return out; + } + + private _saveOverrideCookie(value?: OverrideCookie) { + if ( value ) { + if ((! value.experiments || ! Object.keys(value.experiments).length) && + (! value.disabled || ! value.disabled.length) + ) + value = undefined; + } + + if ( value ) + Cookie.set(OVERRIDE_COOKIE, JSON.stringify(value), COOKIE_OPTIONS); + else + Cookie.remove(OVERRIDE_COOKIE, COOKIE_OPTIONS); + } + + + setTwitchOverride(key: string, value: string) { + const overrides = this._getOverrideCookie(), + experiments = overrides.experiments, + disabled = overrides.disabled; + + experiments[key] = value; + + const idx = disabled.indexOf(key); + if (idx != -1) + disabled.splice(idx, 1); + + this._saveOverrideCookie(overrides); + + const core = this.resolve('site')?.getCore?.(); + if ( core ) + core.experiments.overrides[key] = value; + + this._rebuildTwitchKey(key, true, value); + } + + deleteTwitchOverride(key: string) { + const overrides = this._getOverrideCookie(), + experiments = overrides.experiments; + + if ( ! has(experiments, key) ) + return; + + const old_val = experiments[key]; + delete experiments[key]; + + this._saveOverrideCookie(overrides); + + const core = this.resolve('site')?.getCore?.(); + if ( core ) + delete core.experiments.overrides[key]; + + this._rebuildTwitchKey(key, false, old_val); + } + + hasTwitchOverride(key: string) { // eslint-disable-line class-methods-use-this + const overrides = this._getOverrideCookie(), + experiments = overrides.experiments; + + return has(experiments, key); + } + + getTwitchAssignment(key: string, channel: string | null = null) { + const core = this.resolve('site')?.getCore?.(), + exps = core && core.experiments; + + if ( ! exps ) + return null; + + if ( ! exps.hasInitialized && exps.initialize ) + try { + exps.initialize(); + } catch(err) { + this.log.warn('Error attempting to initialize Twitch experiments tracker.', err); + } + + if ( exps.overrides && exps.overrides[key] ) + return exps.overrides[key]; + + const exp_data = exps.experiments[key], + type = this.getTwitchType(exp_data?.t ?? 0); + + // channel_id experiments always use getAssignmentById + if ( type === 'channel_id' ) { + return exps.getAssignmentById(key, { + bucketing: { + type: 1, + value: channel ?? this.settings.get('context.channelID') + } + }); + } + + // Otherwise, just use the default assignment? + if ( exps.assignments?.[key] ) + return exps.assignments[key]; + + // If there is no default assignment, we should try to figure out + // what assignment they *would* get. + + if ( type === 'device_id' ) + return exps.selectTreatment(key, exp_data, this.unique_id); + + else if ( type === 'user_id' ) + // Technically, some experiments are expecting to get the user's + // login rather than user ID. But we don't care that much if an + // inactive legacy experiment is shown wrong. Meh. + return exps.selectTreatment(key, exp_data, this.resolve('site')?.getUser?.()?.id); + + // We don't know what kind of experiment this is. + // Give up! + return null; + } + + getTwitchKeyFromName(name: string) { + const experiments = this.getTwitchExperiments(); + if ( ! experiments ) + return; + + name = name.toLowerCase(); + for(const key in experiments) + if ( has(experiments, key) ) { + const data = experiments[key]; + if ( data && data.name && data.name.toLowerCase() === name ) + return key; + } + } + + getTwitchAssignmentByName(name: string, channel: string | null = null) { + const key = this.getTwitchKeyFromName(name); + if ( ! key ) + return null; + return this.getTwitchAssignment(key, channel); + } + + private _rebuildTwitchKey( + key: string, + is_set: boolean, + new_val: string | null + ) { + const core = this.resolve('site')?.getCore?.(), + exps = core.experiments, + + old_val = has(exps.assignments, key) ? + exps.assignments[key] as string : + null; + + if ( old_val !== new_val ) { + const value = is_set ? new_val : old_val; + this.emit(':twitch-changed', key, value, old_val); + this.emit(`:twitch-changed:${key}`, value, old_val); + } + } + + + // FFZ Experiments + + setOverride< + K extends keyof ExperimentTypeMap + >(key: K, value: ExperimentTypeMap[K]) { + const overrides = this.settings.provider.get('experiment-overrides', {}); + overrides[key] = value; + + this.settings.provider.set('experiment-overrides', overrides); + + this._rebuildKey(key); + } + + deleteOverride(key: keyof ExperimentTypeMap) { + const overrides = this.settings.provider.get('experiment-overrides'); + if ( ! overrides || ! has(overrides, key) ) + return; + + delete overrides[key]; + if ( Object.keys(overrides).length ) + this.settings.provider.set('experiment-overrides', overrides); + else + this.settings.provider.delete('experiment-overrides'); + + this._rebuildKey(key); + } + + hasOverride(key: keyof ExperimentTypeMap) { + const overrides = this.settings.provider.get('experiment-overrides'); + return overrides ? has(overrides, key): false; + } + + get: ( + key: K + ) => ExperimentTypeMap[K]; + + getAssignment( + key: K + ): ExperimentTypeMap[K] { + if ( this.cache.has(key) ) + return this.cache.get(key) as ExperimentTypeMap[K]; + + const experiment = this.experiments[key]; + if ( ! experiment ) { + this.log.warn(`Tried to get assignment for experiment "${key}" which is not known.`); + return null as ExperimentTypeMap[K]; + } + + const overrides = this.settings.provider.get('experiment-overrides'), + out = overrides && has(overrides, key) ? + overrides[key] : + ExperimentManager.selectGroup(key, experiment, this.unique_id ?? ''); + + this.cache.set(key, out); + return out as ExperimentTypeMap[K]; + } + + private _rebuildKey(key: keyof ExperimentTypeMap) { + if ( ! this.cache.has(key) ) + return; + + const old_val = this.cache.get(key); + this.cache.delete(key); + const new_val = this.getAssignment(key); + + if ( new_val !== old_val ) { + this.emit(':changed', key, new_val, old_val); + this.emit(`:changed:${key}`, new_val, old_val); + } + } + + + static selectGroup( + key: string, + experiment: FFZExperimentData, + unique_id: string + ): T | null { + const seed = key + unique_id + (experiment.seed || ''), + total = experiment.groups.reduce((a,b) => a + b.weight, 0); + + let value = (SHA1(seed).words[0] >>> 0) / Math.pow(2, 32); + + for(const group of experiment.groups) { + value -= group.weight / total; + if ( value <= 0 ) + return group.value as T; + } + + return null; + } +} diff --git a/src/i18n.js b/src/i18n.js index 6680b851..7df36a00 100644 --- a/src/i18n.js +++ b/src/i18n.js @@ -4,8 +4,6 @@ // Localization // ============================================================================ -import Parser from '@ffz/icu-msgparser'; - import {DEBUG, SERVER} from 'utilities/constants'; import {get, pick_random, shallow_copy, deep_copy} from 'utilities/object'; import { getBuster } from 'utilities/time'; @@ -69,13 +67,11 @@ const FACES = ['(・`ω´・)', ';;w;;', 'owo', 'ono', 'oAo', 'oxo', 'ovo;', 'Uw // TranslationManager // ============================================================================ -export class TranslationManager extends Module { +export default class TranslationManager extends Module { constructor(...args) { super(...args); this.inject('settings'); - this.parser = new Parser; - this._seen = new Set; this.availableLocales = ['en']; @@ -897,4 +893,4 @@ export function transformPhrase(phrase, substitutions, locale, token_regex, form }); return result; -} \ No newline at end of file +} diff --git a/src/load_tracker.jsx b/src/load_tracker.jsx deleted file mode 100644 index cd68089f..00000000 --- a/src/load_tracker.jsx +++ /dev/null @@ -1,79 +0,0 @@ -'use strict'; - -// ============================================================================ -// Loading Tracker -// ============================================================================ - -import Module from 'utilities/module'; - -export default class LoadTracker extends Module { - - constructor(...args) { - super(...args); - - this.should_enable = true; - - this.inject('settings'); - - this.settings.add('chat.update-when-loaded', { - default: true, - ui: { - path: 'Chat > Behavior >> General', - title: 'Update existing chat messages when loading new data.', - component: 'setting-check-box', - description: 'This may cause elements in chat to move, so you may wish to disable this when performing moderation.' - } - }); - - this.pending_loads = new Map; - - this.on(':schedule', this.schedule, this); - - } - - schedule(type, key) { - let data = this.pending_loads.get(type); - if ( ! data || ! data.pending || ! data.timers ) { - data = { - pending: new Set, - timers: {}, - success: false - }; - this.pending_loads.set(type, data); - } - - if ( data.pending.has(key) ) - return; - - data.pending.add(key); - data.timers[key] = setTimeout(() => this.notify(type, key, false), 15000); - } - - notify(type, key, success = true) { - const data = this.pending_loads.get(type); - if ( ! data || ! data.pending || ! data.timers ) - return; - - if ( data.timers[key] ) { - clearTimeout(data.timers[key]); - data.timers[key] = null; - } - - if ( ! data.pending.has(key) ) - return; - - data.pending.delete(key); - if ( success ) - data.success = true; - - if ( ! data.pending.size ) { - const keys = Object.keys(data.timers); - - this.log.debug('complete', type, keys); - if ( data.success ) - this.emit(`:complete:${type}`, keys); - this.pending_loads.delete(type); - } - } - -} \ No newline at end of file diff --git a/src/load_tracker.ts b/src/load_tracker.ts new file mode 100644 index 00000000..1edd73ed --- /dev/null +++ b/src/load_tracker.ts @@ -0,0 +1,169 @@ +'use strict'; + +// ============================================================================ +// Loading Tracker +// ============================================================================ + +import Module, { GenericModule } from 'utilities/module'; +import type SettingsManager from './settings'; + + +declare module 'utilities/types' { + interface ModuleEventMap { + load_tracker: LoadEvents; + } + interface ModuleMap { + load_tracker: LoadTracker; + } + interface SettingsTypeMap { + 'chat.update-when-loaded': boolean; + } +} + + +type PendingLoadData = { + pending: Set; + timers: Record | null>; + success: boolean +}; + + +export type LoadEvents = { + ':schedule': [type: string, key: string], + [key: `:complete:${string}`]: [keys: string[]] +}; + + +/** + * LoadTracker is a module used for coordinating loading events between + * the core of FrankerFaceZ and any present add-ons. This allows for + * enhanced performance by, for example, only refreshing chat messages + * once emote data has been loaded by all of a user's add-ons. + * + * @example How to use load tracker if you're loading emotes. + * ```typescript + * // Inform the load tracker that we're trying to load data. + * this.load_tracker.schedule('chat-data', 'my-addon--emotes-global'); + * + * // Load our data. + * let emotes; + * try { + * emotes = await loadEmotesFromSomewhere(); + * } catch(err) { + * // Notify that we failed to load, so it stops waiting. + * this.load_tracker.notify('chat-data', 'my-addon--emotes-global', false); + * return; + * } + * + * // Load the emote data. + * this.emotes.addDefaultSet('my-addon', 'my-addon--global-emotes', emotes); + * + * // Notify that we succeeded. + * this.load_tracker.notify('chat-data', 'my-addon--emotes-global', true); + * ``` + * + * @noInheritDoc + */ +export default class LoadTracker extends Module<'load_tracker', LoadEvents> { + + /** A map for storing information about pending loadables. */ + private pending_loads: Map = new Map(); + + // Dependencies. + settings: SettingsManager = null as any; + + /** @internal */ + constructor(name?: string, parent?: GenericModule) { + super(name, parent); + + this.should_enable = true; + + this.inject('settings'); + + this.settings.add('chat.update-when-loaded', { + default: true, + ui: { + path: 'Chat > Behavior >> General', + title: 'Update existing chat messages when loading new data.', + component: 'setting-check-box', + description: 'This may cause elements in chat to move, so you may wish to disable this when performing moderation.' + } + }); + } + + /** @internal */ + onEnable() { + this.emit('load_tracker:schedule', 'test', 'fish'); + + this.on(':schedule', this.schedule); + } + + /** + * Register our intent to perform a load. This lets the system know that + * a load of {@link type} is pending, and it starts a wait of 15 seconds + * for the load to complete. + * + * You must, after using this, call {@link notify} when your load + * completes or fails. That ensures that the system does not wait + * needlessly after your load process has finished. + * + * @param type The load type. + * @param key A unique key for your load, on this load type. If you are + * loading multiple times (for example, global emotes and channel-specific + * emotes), you should use two distinct keys. + */ + schedule(type: string, key: string) { + let data = this.pending_loads.get(type); + if ( ! data || ! data.pending || ! data.timers ) { + data = { + pending: new Set, + timers: {}, + success: false + }; + this.pending_loads.set(type, data); + } + + if ( data.pending.has(key) ) + return; + + data.pending.add(key); + data.timers[key] = setTimeout(() => this.notify(type, key, false), 15000); + } + + /** + * Notify the load tracker that your load has completed. If all loads + * for the given type have been completed, and any of the loads were + * a success, then a `:complete:${type}` event will be fired. + * @param type The load type. + * @param key A unique key for your load. The same that you use + * with {@link schedule}. + * @param success Whether or not your load was a success. + */ + notify(type: string, key: string, success = true) { + const data = this.pending_loads.get(type); + if ( ! data || ! data.pending || ! data.timers ) + return; + + if ( data.timers[key] ) { + clearTimeout(data.timers[key] as any); + data.timers[key] = null; + } + + if ( ! data.pending.has(key) ) + return; + + data.pending.delete(key); + if ( success ) + data.success = true; + + if ( ! data.pending.size ) { + const keys = Object.keys(data.timers); + + this.log.debug('complete', type, keys); + if ( data.success ) + this.emit(`:complete:${type}`, keys); + this.pending_loads.delete(type); + } + } + +} diff --git a/src/main.js b/src/main.ts similarity index 57% rename from src/main.js rename to src/main.ts index f0c54798..029437a3 100644 --- a/src/main.js +++ b/src/main.ts @@ -4,7 +4,7 @@ import dayjs from 'dayjs'; //import RavenLogger from './raven'; import Logger from 'utilities/logging'; -import Module from 'utilities/module'; +import Module, { State } from 'utilities/module'; import { timeout } from 'utilities/object'; import {DEBUG} from 'utilities/constants'; @@ -12,16 +12,87 @@ import {DEBUG} from 'utilities/constants'; import SettingsManager from './settings/index'; import AddonManager from './addons'; import ExperimentManager from './experiments'; -import {TranslationManager} from './i18n'; +import TranslationManager from './i18n'; import SocketClient from './socket'; import PubSubClient from './pubsub'; import Site from 'site'; -import Vue from 'utilities/vue'; +import VueModule from 'utilities/vue'; import StagingSelector from './staging'; import LoadTracker from './load_tracker'; -//import Timing from 'utilities/timing'; + +import type { ClientVersion } from 'utilities/types'; + +import * as Utility_Addons from 'utilities/addon'; +import * as Utility_Blobs from 'utilities/blobs'; +import * as Utility_Color from 'utilities/color'; +import * as Utility_Constants from 'utilities/constants'; +import * as Utility_Dialog from 'utilities/dialog'; +import * as Utility_DOM from 'utilities/dom'; +import * as Utility_Events from 'utilities/events'; +import * as Utility_FontAwesome from 'utilities/font-awesome'; +import * as Utility_GraphQL from 'utilities/graphql'; +import * as Utility_Logging from 'utilities/logging'; +import * as Utility_Module from 'utilities/module'; +import * as Utility_Object from 'utilities/object'; +import * as Utility_Time from 'utilities/time'; +import * as Utility_Tooltip from 'utilities/tooltip'; +import * as Utility_I18n from 'utilities/translation-core'; +import * as Utility_Filtering from 'utilities/filtering'; class FrankerFaceZ extends Module { + + static instance: FrankerFaceZ = null as any; + static version_info: ClientVersion = null as any; + static Logger = Logger; + + static utilities = { + addon: Utility_Addons, + blobs: Utility_Blobs, + color: Utility_Color, + constants: Utility_Constants, + dialog: Utility_Dialog, + dom: Utility_DOM, + events: Utility_Events, + fontAwesome: Utility_FontAwesome, + graphql: Utility_GraphQL, + logging: Utility_Logging, + module: Utility_Module, + object: Utility_Object, + time: Utility_Time, + tooltip: Utility_Tooltip, + i18n: Utility_I18n, + filtering: Utility_Filtering + }; + + /* + static utilities = { + addon: require('utilities/addon'), + blobs: require('utilities/blobs'), + color: require('utilities/color'), + constants: require('utilities/constants'), + dialog: require('utilities/dialog'), + dom: require('utilities/dom'), + events: require('utilities/events'), + fontAwesome: require('utilities/font-awesome'), + graphql: require('utilities/graphql'), + logging: require('utilities/logging'), + module: require('utilities/module'), + object: require('utilities/object'), + time: require('utilities/time'), + tooltip: require('utilities/tooltip'), + i18n: require('utilities/translation-core'), + dayjs: require('dayjs'), + filtering: require('utilities/filtering'), + popper: require('@popperjs/core') + }; + */ + + + core_log: Logger; + + host: string; + flavor: string; + constructor() { super(); const start_time = performance.now(); @@ -31,12 +102,14 @@ class FrankerFaceZ extends Module { this.host = 'twitch'; this.flavor = 'main'; this.name = 'frankerfacez'; - this.__state = 0; - this.__modules.core = this; + + // Evil private member access. + (this as any).__state = State.Disabled; + (this as any).__modules.core = this; // Timing //this.inject('timing', Timing); - this.__time('instance'); + this._time('instance'); // ======================================================================== // Error Reporting and Logging @@ -48,7 +121,7 @@ class FrankerFaceZ extends Module { this.log.init = true; this.core_log = this.log.get('core'); - this.log.hi(this); + this.log.hi(this, FrankerFaceZ.version_info); // ======================================================================== @@ -65,7 +138,7 @@ class FrankerFaceZ extends Module { this.inject('site', Site); this.inject('addons', AddonManager); - this.register('vue', Vue); + this.register('vue', VueModule); // ======================================================================== @@ -96,14 +169,13 @@ class FrankerFaceZ extends Module { async generateLog() { const promises = []; - for(const key in this.__modules) { - const module = this.__modules[key]; - if ( module instanceof Module && module.generateLog && module != this ) + for(const [key, module] of Object.entries((this as any).__modules)) { + if ( module instanceof Module && module.generateLog && (module as any) != this ) promises.push((async () => { try { return [ key, - await timeout(Promise.resolve(module.generateLog()), 5000) + await timeout(Promise.resolve((module as any).generateLog()), 5000) ]; } catch(err) { return [ @@ -141,11 +213,11 @@ ${typeof x[1] === 'string' ? x[1] : JSON.stringify(x[1], null, 4)}`).join('\n\n' const ctx = await require.context( 'src/modules', true, - /(?:^(?:\.\/)?[^/]+|index)\.jsx?$/ + /(?:^(?:\.\/)?[^/]+|index)\.[jt]sx?$/ /*, 'lazy-once' */ ); - const modules = this.populate(ctx, this.core_log); + const modules = this.loadFromContext(ctx, this.core_log); this.core_log.info(`Loaded descriptions of ${Object.keys(modules).length} modules.`); } @@ -153,20 +225,17 @@ ${typeof x[1] === 'string' ? x[1] : JSON.stringify(x[1], null, 4)}`).join('\n\n' async enableInitialModules() { const promises = []; - /* eslint guard-for-in: off */ - for(const key in this.__modules) { - const module = this.__modules[key]; + for(const module of Object.values((this as any).__modules)) { if ( module instanceof Module && module.should_enable ) promises.push(module.enable()); } - await Promise.all(promises); + return Promise.all(promises); } } -FrankerFaceZ.Logger = Logger; -const VER = FrankerFaceZ.version_info = Object.freeze({ +const VER: ClientVersion = FrankerFaceZ.version_info = Object.freeze({ major: __version_major__, minor: __version_minor__, revision: __version_patch__, @@ -179,27 +248,14 @@ const VER = FrankerFaceZ.version_info = Object.freeze({ }); -FrankerFaceZ.utilities = { - addon: require('utilities/addon'), - blobs: require('utilities/blobs'), - color: require('utilities/color'), - constants: require('utilities/constants'), - dialog: require('utilities/dialog'), - dom: require('utilities/dom'), - events: require('utilities/events'), - fontAwesome: require('utilities/font-awesome'), - graphql: require('utilities/graphql'), - logging: require('utilities/logging'), - module: require('utilities/module'), - object: require('utilities/object'), - time: require('utilities/time'), - tooltip: require('utilities/tooltip'), - i18n: require('utilities/translation-core'), - dayjs: require('dayjs'), - filtering: require('utilities/filtering'), - popper: require('@popperjs/core') -} +export default FrankerFaceZ; +declare global { + interface Window { + FrankerFaceZ: typeof FrankerFaceZ; + ffz: FrankerFaceZ; + } +} window.FrankerFaceZ = FrankerFaceZ; window.ffz = new FrankerFaceZ(); diff --git a/src/modules/chat/actions/actions.jsx b/src/modules/chat/actions/actions.jsx index 6dd2c40d..50f4f3af 100644 --- a/src/modules/chat/actions/actions.jsx +++ b/src/modules/chat/actions/actions.jsx @@ -614,7 +614,7 @@ export default class Actions extends Module { }, onMove: (target, tip, event) => { - this.emit('tooltips:mousemove', target, tip, event) + this.emit('tooltips:hover', target, tip, event) }, onLeave: (target, tip, event) => { @@ -1276,4 +1276,4 @@ export default class Actions extends Module { sendMessage(room, message) { return this.resolve('site.chat').sendMessage(room, message); } -} \ No newline at end of file +} diff --git a/src/modules/chat/badges.jsx b/src/modules/chat/badges.jsx index 71274db1..551cf1a4 100644 --- a/src/modules/chat/badges.jsx +++ b/src/modules/chat/badges.jsx @@ -1554,4 +1554,4 @@ export function fixBadgeData(badge) { } return badge; -} \ No newline at end of file +} diff --git a/src/modules/chat/components/chat-rich.vue b/src/modules/chat/components/chat-rich.vue index 2cbc3222..a9cd75e0 100644 --- a/src/modules/chat/components/chat-rich.vue +++ b/src/modules/chat/components/chat-rich.vue @@ -80,7 +80,7 @@ export default { if ( ! ds ) return; - const evt = new FFZEvent({ + const evt = FFZEvent.makeEvent({ url: ds.url ?? target.href, source: event }); diff --git a/src/modules/chat/emotes.js b/src/modules/chat/emotes.js index 84dfdb09..7fde7518 100644 --- a/src/modules/chat/emotes.js +++ b/src/modules/chat/emotes.js @@ -6,7 +6,7 @@ import Module, { buildAddonProxy } from 'utilities/module'; import {ManagedStyle} from 'utilities/dom'; -import { FFZEvent } from 'utilities/events'; + import {get, has, timeout, SourcedSet, make_enum_flags, makeAddonIdChecker} from 'utilities/object'; import {NEW_API, IS_OSX, EmoteTypes, TWITCH_GLOBAL_SETS, TWITCH_POINTS_SETS, TWITCH_PRIME_SETS, DEBUG} from 'utilities/constants'; @@ -1315,7 +1315,7 @@ export default class Emotes extends Module { /* no-op */ } - const evt = new FFZEvent({ + const evt = this.makeEvent({ provider, id: ds.id, set: ds.set, diff --git a/src/modules/chat/index.js b/src/modules/chat/index.js index 987e8010..8a6d4ffb 100644 --- a/src/modules/chat/index.js +++ b/src/modules/chat/index.js @@ -10,7 +10,6 @@ import { DEBUG, LINK_DATA_HOSTS } from 'utilities/constants'; import Module, { buildAddonProxy } from 'utilities/module'; import {Color} from 'utilities/color'; import {createElement, ManagedStyle} from 'utilities/dom'; -import {FFZEvent} from 'utilities/events'; import {getFontsList} from 'utilities/fonts'; import {timeout, has, addWordSeparators, glob_to_regex, escape_regex, split_chars, makeAddonIdChecker} from 'utilities/object'; @@ -1800,7 +1799,7 @@ export default class Chat extends Module { if ( ! ds ) return; - const evt = new FFZEvent({ + const evt = this.makeEvent({ url: ds.url ?? target.href, source: event }); @@ -1811,7 +1810,6 @@ export default class Chat extends Module { event.stopPropagation(); return true; } - } @@ -2657,4 +2655,4 @@ export default class Chat extends Module { return data; } -} \ No newline at end of file +} diff --git a/src/modules/chat/overrides.js b/src/modules/chat/overrides.ts similarity index 63% rename from src/modules/chat/overrides.js rename to src/modules/chat/overrides.ts index 88bd35de..c925b195 100644 --- a/src/modules/chat/overrides.js +++ b/src/modules/chat/overrides.ts @@ -4,14 +4,43 @@ // Name and Color Overrides // ============================================================================ -import Module from 'utilities/module'; +import Module, { GenericModule } from 'utilities/module'; import { createElement, ClickOutside } from 'utilities/dom'; import Tooltip from 'utilities/tooltip'; +import type SettingsManager from 'root/src/settings'; -export default class Overrides extends Module { - constructor(...args) { - super(...args); +declare module 'utilities/types' { + interface ModuleMap { + 'chat.overrides': Overrides; + } + interface ModuleEventMap { + 'chat.overrides': OverrideEvents; + } + interface ProviderTypeMap { + 'overrides.colors': Record; + 'overrides.names': Record; + } +} + + +export type OverrideEvents = { + ':changed': [id: string, type: 'name' | 'color', value: string | undefined]; +} + + +export default class Overrides extends Module<'chat.overrides'> { + + // Dependencies + settings: SettingsManager = null as any; + + // State and Caching + color_cache: Record | null; + name_cache: Record | null; + + + constructor(name?: string, parent?: GenericModule) { + super(name, parent); this.inject('settings'); @@ -35,12 +64,15 @@ export default class Overrides extends Module { });*/ } + /** @internal */ onEnable() { this.settings.provider.on('changed', this.onProviderChange, this); } - renderUserEditor(user, target) { - let outside, popup, ve; + renderUserEditor(user: any, target: HTMLElement) { + let outside: ClickOutside | null, + popup: Tooltip | null, + ve: any; const destroy = () => { const o = outside, p = popup, v = ve; @@ -56,7 +88,10 @@ export default class Overrides extends Module { v.$destroy(); } - const parent = document.fullscreenElement || document.body.querySelector('#root>div') || document.body; + const parent = + document.fullscreenElement as HTMLElement + ?? document.body.querySelector('#root>div') + ?? document.body; popup = new Tooltip(parent, [], { logger: this.log, @@ -88,6 +123,9 @@ export default class Overrides extends Module { const vue = this.resolve('vue'), _editor = import(/* webpackChunkName: "overrides" */ './override-editor.vue'); + if ( ! vue ) + throw new Error('unable to load vue'); + const [, editor] = await Promise.all([vue.enable(), _editor]); vue.component('override-editor', editor.default); @@ -118,12 +156,13 @@ export default class Overrides extends Module { onShow: async (t, tip) => { await tip.waitForDom(); requestAnimationFrame(() => { - outside = new ClickOutside(tip.outer, destroy) + if ( tip.outer ) + outside = new ClickOutside(tip.outer, destroy) }); }, onMove: (target, tip, event) => { - this.emit('tooltips:mousemove', target, tip, event) + this.emit('tooltips:hover', target, tip, event) }, onLeave: (target, tip, event) => { @@ -137,30 +176,25 @@ export default class Overrides extends Module { } - onProviderChange(key) { - if ( key === 'overrides.colors' ) + onProviderChange(key: string) { + if ( key === 'overrides.colors' && this.color_cache ) this.loadColors(); - else if ( key === 'overrides.names' ) + else if ( key === 'overrides.names' && this.name_cache ) this.loadNames(); } get colors() { - if ( ! this.color_cache ) - this.loadColors(); - - return this.color_cache; + return this.color_cache ?? this.loadColors(); } get names() { - if ( ! this.name_cache ) - this.loadNames(); - - return this.name_cache; + return this.name_cache ?? this.loadNames(); } loadColors() { - let old_keys, + let old_keys: Set, loaded = true; + if ( ! this.color_cache ) { loaded = false; this.color_cache = {}; @@ -168,24 +202,28 @@ export default class Overrides extends Module { } else old_keys = new Set(Object.keys(this.color_cache)); - for(const [key, val] of Object.entries(this.settings.provider.get('overrides.colors', {}))) { - old_keys.delete(key); - if ( this.color_cache[key] !== val ) { - this.color_cache[key] = val; - if ( loaded ) - this.emit(':changed', key, 'color', val); + const entries = this.settings.provider.get('overrides.colors'); + if ( entries ) + for(const [key, val] of Object.entries(entries)) { + old_keys.delete(key); + if ( this.color_cache[key] !== val ) { + this.color_cache[key] = val; + if ( loaded ) + this.emit(':changed', key, 'color', val); + } } - } for(const key of old_keys) { this.color_cache[key] = undefined; if ( loaded ) this.emit(':changed', key, 'color', undefined); } + + return this.color_cache; } loadNames() { - let old_keys, + let old_keys: Set, loaded = true; if ( ! this.name_cache ) { loaded = false; @@ -194,37 +232,35 @@ export default class Overrides extends Module { } else old_keys = new Set(Object.keys(this.name_cache)); - for(const [key, val] of Object.entries(this.settings.provider.get('overrides.names', {}))) { - old_keys.delete(key); - if ( this.name_cache[key] !== val ) { - this.name_cache[key] = val; - if ( loaded ) - this.emit(':changed', key, 'name', val); + const entries = this.settings.provider.get('overrides.names'); + if ( entries ) + for(const [key, val] of Object.entries(entries)) { + old_keys.delete(key); + if ( this.name_cache[key] !== val ) { + this.name_cache[key] = val; + if ( loaded ) + this.emit(':changed', key, 'name', val); + } } - } for(const key of old_keys) { this.name_cache[key] = undefined; if ( loaded ) this.emit(':changed', key, 'name', undefined); } + + return this.name_cache; } - getColor(id) { - if ( this.colors[id] != null ) - return this.colors[id]; - - return null; + getColor(id: string): string | null { + return this.colors[id] ?? null; } - getName(id) { - if ( this.names[id] != null ) - return this.names[id]; - - return null; + getName(id: string) { + return this.names[id] ?? null; } - setColor(id, color) { + setColor(id: string, color?: string) { if ( this.colors[id] !== color ) { this.colors[id] = color; this.settings.provider.set('overrides.colors', this.colors); @@ -232,7 +268,7 @@ export default class Overrides extends Module { } } - setName(id, name) { + setName(id: string, name?: string) { if ( this.names[id] !== name ) { this.names[id] = name; this.settings.provider.set('overrides.names', this.names); @@ -240,11 +276,11 @@ export default class Overrides extends Module { } } - deleteColor(id) { + deleteColor(id: string) { this.setColor(id, undefined); } - deleteName(id) { + deleteName(id: string) { this.setName(id, undefined); } -} \ No newline at end of file +} diff --git a/src/modules/chat/types.ts b/src/modules/chat/types.ts new file mode 100644 index 00000000..8414127c --- /dev/null +++ b/src/modules/chat/types.ts @@ -0,0 +1,8 @@ + +// ============================================================================ +// Badges +// ============================================================================ + +export type BadgeAssignment = { + id: string; +}; diff --git a/src/modules/chat/user.js b/src/modules/chat/user.ts similarity index 69% rename from src/modules/chat/user.js rename to src/modules/chat/user.ts index 7c699d87..3d42d2cb 100644 --- a/src/modules/chat/user.js +++ b/src/modules/chat/user.ts @@ -5,20 +5,39 @@ // ============================================================================ import {SourcedSet} from 'utilities/object'; +import type Chat from '.'; +import type Room from './room'; +import type { BadgeAssignment } from './types'; export default class User { - constructor(manager, room, id, login) { + + // Parent + manager: Chat; + room: Room | null; + + // State + destroyed: boolean = false; + + _id: string | null; + _login: string | null = null; + + // Storage + emote_sets: SourcedSet | null; + badges: SourcedSet | null; + + + constructor(manager: Chat, room: Room | null, id: string | null, login: string | null) { this.manager = manager; this.room = room; - this.emote_sets = null; //new SourcedSet; - this.badges = null; // new SourcedSet; + this.emote_sets = null; + this.badges = null; this._id = id; this.login = login; if ( id ) - (room || manager).user_ids[id] = this; + (room ?? manager).user_ids[id] = this; } destroy() { @@ -31,6 +50,7 @@ export default class User { this.emote_sets = null; } + // Badges are not referenced, so we can just dump them all. if ( this.badges ) this.badges = null; @@ -45,26 +65,24 @@ export default class User { } } - merge(other) { + merge(other: User) { if ( ! this.login && other.login ) this.login = other.login; - if ( other.emote_sets && other.emote_sets._sources ) { - for(const [provider, sets] of other.emote_sets._sources.entries()) { + if ( other.emote_sets ) + for(const [provider, sets] of other.emote_sets.iterateSources()) { for(const set_id of sets) this.addSet(provider, set_id); } - } - if ( other.badges && other.badges._sources ) { - for(const [provider, badges] of other.badges._sources.entries()) { + if ( other.badges ) + for(const [provider, badges] of other.badges.iterateSources()) { for(const badge of badges) this.addBadge(provider, badge.id, badge); } - } } - _unloadAddon(addon_id) { + _unloadAddon(addon_id: string) { // TODO: This return 0; } @@ -107,9 +125,9 @@ export default class User { // Add Badges // ======================================================================== - addBadge(provider, badge_id, data) { + addBadge(provider: string, badge_id: string, data?: BadgeAssignment) { if ( this.destroyed ) - return; + return false; if ( typeof badge_id === 'number' ) badge_id = `${badge_id}`; @@ -122,8 +140,9 @@ export default class User { if ( ! this.badges ) this.badges = new SourcedSet; - if ( this.badges.has(provider) ) - for(const old_b of this.badges.get(provider)) + const existing = this.badges.get(provider); + if ( existing ) + for(const old_b of existing) if ( old_b.id == badge_id ) { Object.assign(old_b, data); return false; @@ -135,31 +154,35 @@ export default class User { } - getBadge(badge_id) { - if ( ! this.badges ) - return null; + getBadge(badge_id: string) { + if ( this.badges ) + for(const badge of this.badges._cache) + if ( badge.id == badge_id ) + return badge; - for(const badge of this.badges._cache) - if ( badge.id == badge_id ) - return badge; + return null; } - removeBadge(provider, badge_id) { - if ( ! this.badges || ! this.badges.has(provider) ) + removeBadge(provider: string, badge_id: string) { + if ( ! this.badges ) return false; - for(const old_b of this.badges.get(provider)) - if ( old_b.id == badge_id ) { - this.badges.remove(provider, old_b); - //this.manager.badges.unrefBadge(badge_id); - return true; - } + const existing = this.badges.get(provider); + if ( existing ) + for(const old_b of existing) + if ( old_b.id == badge_id ) { + this.badges.remove(provider, old_b); + //this.manager.badges.unrefBadge(badge_id); + return true; + } + + return false; } - removeAllBadges(provider) { - if ( this.destroyed || ! this.badges ) + removeAllBadges(provider: string) { + if ( ! this.badges ) return false; if ( ! this.badges.has(provider) ) @@ -175,7 +198,7 @@ export default class User { // Emote Sets // ======================================================================== - addSet(provider, set_id, data) { + addSet(provider: string, set_id: string, data?: unknown) { if ( this.destroyed ) return; @@ -203,8 +226,8 @@ export default class User { return added; } - removeAllSets(provider) { - if ( this.destroyed || ! this.emote_sets ) + removeAllSets(provider: string) { + if ( ! this.emote_sets ) return false; const sets = this.emote_sets.get(provider); @@ -217,8 +240,8 @@ export default class User { return true; } - removeSet(provider, set_id) { - if ( this.destroyed || ! this.emote_sets ) + removeSet(provider: string, set_id: string) { + if ( ! this.emote_sets ) return; if ( typeof set_id === 'number' ) @@ -235,4 +258,4 @@ export default class User { return false; } -} \ No newline at end of file +} diff --git a/src/modules/main_menu/components/chat-tester.vue b/src/modules/main_menu/components/chat-tester.vue index 521f949b..0d1e321f 100644 --- a/src/modules/main_menu/components/chat-tester.vue +++ b/src/modules/main_menu/components/chat-tester.vue @@ -633,7 +633,7 @@ export default { // TODO: Update timestamps for pinned chat? } - this.chat.resolve('site.subpump').inject(item.topic, item.data); + this.chat.resolve('site.subpump').simulateMessage(item.topic, item.data); } if ( item.chat ) { @@ -731,4 +731,4 @@ export default { } - \ No newline at end of file + diff --git a/src/modules/main_menu/components/experiments.vue b/src/modules/main_menu/components/experiments.vue index bb455f8c..24c34a03 100644 --- a/src/modules/main_menu/components/experiments.vue +++ b/src/modules/main_menu/components/experiments.vue @@ -173,7 +173,7 @@ @change="onTwitchChange($event)" >