From b9d23accf01d54585d1d6da9204ab6cc685bac3a Mon Sep 17 00:00:00 2001 From: SirStendec Date: Mon, 13 Nov 2023 20:47:45 -0500 Subject: [PATCH] Initial commit for converting FrankerFaceZ to TypeScript. --- .babelrc | 9 - .editorconfig | 21 + .gitignore | 1 + bin/build_types.js | 82 + bin/update_fonts.js | 14 +- package.json | 190 +-- pnpm-lock.yaml | 174 ++- src/clips.js | 2 +- src/entry.js | 2 +- src/i18n.js | 8 +- src/load_tracker.jsx | 79 - src/load_tracker.ts | 155 ++ src/{main.js => main.ts} | 134 +- src/modules/chat/components/chat-rich.vue | 2 +- src/modules/chat/emotes.js | 4 +- src/modules/chat/index.js | 6 +- .../main_menu/components/filter-editor.vue | 8 +- .../main_menu/components/graphql-inspect.vue | 4 +- src/modules/main_menu/index.js | 6 +- src/modules/{metadata.jsx => metadata.tsx} | 525 +++++-- src/modules/{tooltips.js => tooltips.ts} | 189 ++- src/player.js | 4 +- src/raven.js | 4 +- src/settings/{clearables.js => clearables.ts} | 17 +- src/settings/context.js | 4 +- src/settings/{filters.js => filters.ts} | 233 ++- src/settings/{index.js => index.ts} | 461 +++--- src/settings/migration.js | 16 - src/settings/processors.js | 49 - src/settings/processors.ts | 66 + src/settings/{profile.js => profile.ts} | 296 +++- src/settings/{providers.js => providers.ts} | 1126 +++++++------- src/settings/{types.js => typehandlers.js} | 2 +- src/settings/types.ts | 168 +++ src/settings/validators.js | 45 - src/settings/validators.ts | 54 + src/sites/clips/line.jsx | 2 +- src/sites/twitch-twilight/index.js | 2 +- .../twitch-twilight/modules/chat/index.js | 16 +- .../twitch-twilight/modules/chat/input.jsx | 8 +- .../twitch-twilight/modules/chat/line.js | 23 +- .../modules/css_tweaks/index.js | 4 +- .../modules/directory/game.jsx | 5 +- .../modules/directory/index.jsx | 5 +- .../modules/following_link.off | 5 +- .../twitch-twilight/modules/menu_button.jsx | 5 +- src/socket.js | 2 +- src/utilities/addon.js | 56 - src/utilities/addon.ts | 82 + src/utilities/blobs.js | 138 -- src/utilities/blobs.ts | 104 ++ src/utilities/color.js | 13 + src/utilities/compat/fine.js | 15 - .../compat/{subpump.js => subpump.ts} | 133 +- src/utilities/{constants.js => constants.ts} | 139 +- src/utilities/css-tweaks.js | 104 -- src/utilities/css-tweaks.ts | 218 +++ src/utilities/{dialog.js => dialog.ts} | 60 +- src/utilities/dom.js | 377 ----- src/utilities/dom.ts | 529 +++++++ src/utilities/events.js | 484 ------ src/utilities/events.ts | 1009 +++++++++++++ src/utilities/{ffz-icons.js => ffz-icons.ts} | 12 +- src/utilities/{filtering.js => filtering.ts} | 49 +- .../{font-awesome.js => font-awesome.ts} | 27 +- src/utilities/{fonts.js => fonts.ts} | 36 +- src/utilities/logging.js | 243 --- src/utilities/logging.ts | 320 ++++ src/utilities/module.js | 873 ----------- src/utilities/module.ts | 1188 +++++++++++++++ src/utilities/object.js | 933 ------------ src/utilities/object.ts | 1339 +++++++++++++++++ .../{path-parser.js => path-parser.ts} | 41 +- src/utilities/{time.js => time.ts} | 23 +- src/utilities/timing.js | 4 +- src/utilities/{tooltip.js => tooltip.ts} | 283 +++- ...ranslation-core.js => translation-core.ts} | 242 ++- src/utilities/types.ts | 181 +++ tsconfig.json | 37 + typedoc.json | 25 + types/ffz_icu-msgparser.d.ts | 55 + types/getScreenDetails.d.ts | 31 + types/global.d.ts | 11 + types/import-types.d.ts | 4 + types/jsx-global.d.ts | 9 + webpack.config.js | 14 +- 86 files changed, 8673 insertions(+), 5005 deletions(-) delete mode 100644 .babelrc create mode 100644 .editorconfig create mode 100644 bin/build_types.js delete mode 100644 src/load_tracker.jsx create mode 100644 src/load_tracker.ts rename src/{main.js => main.ts} (58%) rename src/modules/{metadata.jsx => metadata.tsx} (62%) rename src/modules/{tooltips.js => tooltips.ts} (59%) rename src/settings/{clearables.js => clearables.ts} (71%) rename src/settings/{filters.js => filters.ts} (60%) rename src/settings/{index.js => index.ts} (71%) delete mode 100644 src/settings/migration.js delete mode 100644 src/settings/processors.js create mode 100644 src/settings/processors.ts rename src/settings/{profile.js => profile.ts} (50%) rename src/settings/{providers.js => providers.ts} (58%) rename src/settings/{types.js => typehandlers.js} (99%) create mode 100644 src/settings/types.ts delete mode 100644 src/settings/validators.js create mode 100644 src/settings/validators.ts delete mode 100644 src/utilities/addon.js create mode 100644 src/utilities/addon.ts delete mode 100644 src/utilities/blobs.js create mode 100644 src/utilities/blobs.ts rename src/utilities/compat/{subpump.js => subpump.ts} (59%) rename src/utilities/{constants.js => constants.ts} (67%) delete mode 100644 src/utilities/css-tweaks.js create mode 100644 src/utilities/css-tweaks.ts rename src/utilities/{dialog.js => dialog.ts} (81%) delete mode 100644 src/utilities/dom.js create mode 100644 src/utilities/dom.ts delete mode 100644 src/utilities/events.js create mode 100644 src/utilities/events.ts rename src/utilities/{ffz-icons.js => ffz-icons.ts} (78%) rename src/utilities/{filtering.js => filtering.ts} (53%) rename src/utilities/{font-awesome.js => font-awesome.ts} (94%) rename src/utilities/{fonts.js => fonts.ts} (82%) delete mode 100644 src/utilities/logging.js create mode 100644 src/utilities/logging.ts delete mode 100644 src/utilities/module.js create mode 100644 src/utilities/module.ts delete mode 100644 src/utilities/object.js create mode 100644 src/utilities/object.ts rename src/utilities/{path-parser.js => path-parser.ts} (77%) rename src/utilities/{time.js => time.ts} (74%) rename src/utilities/{tooltip.js => tooltip.ts} (63%) rename src/utilities/{translation-core.js => translation-core.ts} (70%) create mode 100644 src/utilities/types.ts create mode 100644 tsconfig.json create mode 100644 typedoc.json create mode 100644 types/ffz_icu-msgparser.d.ts create mode 100644 types/getScreenDetails.d.ts create mode 100644 types/global.d.ts create mode 100644 types/import-types.d.ts create mode 100644 types/jsx-global.d.ts 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 cda56e00..14e44c92 100755 --- a/package.json +++ b/package.json @@ -1,91 +1,103 @@ { - "name": "frankerfacez", - "author": "Dan Salvato LLC", - "version": "4.60.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", - "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.60.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/safe-regex": "^1.1.6", + "@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": "^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" + } + } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0951ca20..a543a568 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -15,8 +15,8 @@ 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 @@ -97,6 +97,12 @@ devDependencies: '@ffz/fontello-cli': specifier: ^1.0.4 version: 1.0.4 + '@types/safe-regex': + specifier: ^1.1.6 + version: 1.1.6 + '@types/webpack-env': + specifier: ^1.18.4 + version: 1.18.4 browserslist: specifier: ^4.21.10 version: 4.21.10 @@ -130,6 +136,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 +160,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 +562,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: @@ -643,6 +670,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 +701,10 @@ packages: '@types/node': 20.5.7 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 +1024,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 +1809,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 @@ -3056,13 +3100,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 +3197,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 +3683,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 @@ -3710,6 +3767,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 +3898,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 +3917,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 +4750,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 +4970,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 +5367,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 +5497,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: @@ -5789,6 +5939,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/clips.js b/src/clips.js index 60a9992a..a6e9443c 100644 --- a/src/clips.js +++ b/src/clips.js @@ -12,7 +12,7 @@ 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'; 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/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..ffe27445 --- /dev/null +++ b/src/load_tracker.ts @@ -0,0 +1,155 @@ +'use strict'; + +// ============================================================================ +// Loading Tracker +// ============================================================================ + +import Module, { GenericModule } from 'utilities/module'; +import type SettingsManager from './settings'; + +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 58% rename from src/main.js rename to src/main.ts index f0c54798..efa5df72 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 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); // ======================================================================== @@ -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/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/main_menu/components/filter-editor.vue b/src/modules/main_menu/components/filter-editor.vue index 88a25688..0a95f2d7 100644 --- a/src/modules/main_menu/components/filter-editor.vue +++ b/src/modules/main_menu/components/filter-editor.vue @@ -55,8 +55,8 @@ \ No newline at end of file + diff --git a/src/modules/main_menu/components/graphql-inspect.vue b/src/modules/main_menu/components/graphql-inspect.vue index 1f0ea8e4..c0f9fa72 100644 --- a/src/modules/main_menu/components/graphql-inspect.vue +++ b/src/modules/main_menu/components/graphql-inspect.vue @@ -77,7 +77,7 @@ export default { this.client = this.ffz.resolve('site.apollo')?.client; this.has_client = !! this.client; - this.printer = this.ffz.resolve('site.web_munch')?.getModule?.('gql-printer'); + this.printer = this.ffz.resolve('site.web_munch')?.getModule('gql-printer'); this.has_printer = !! this.printer; }, @@ -157,4 +157,4 @@ function guessNameFromDocument(doc) { return keys[0]; } - \ No newline at end of file + diff --git a/src/modules/main_menu/index.js b/src/modules/main_menu/index.js index e7869029..35f4cdb4 100644 --- a/src/modules/main_menu/index.js +++ b/src/modules/main_menu/index.js @@ -492,7 +492,7 @@ export default class MainMenu extends Module { current = tree.keys[state.ffzcc]; if ( ! current ) { const params = new URL(window.location).searchParams, - key = params?.get?.('ffz-settings'); + key = params?.get('ffz-settings'); current = key && tree.keys[key]; } if ( ! current ) @@ -1161,7 +1161,7 @@ export default class MainMenu extends Module { restored = false; } if ( ! current ) { const params = new URL(window.location).searchParams, - key = params?.get?.('ffz-settings'); + key = params?.get('ffz-settings'); current = key && settings.keys[key]; if ( ! current ) restored = false; @@ -1241,4 +1241,4 @@ export default class MainMenu extends Module { return out; } -} \ No newline at end of file +} diff --git a/src/modules/metadata.jsx b/src/modules/metadata.tsx similarity index 62% rename from src/modules/metadata.jsx rename to src/modules/metadata.tsx index 4be0c984..55b487af 100644 --- a/src/modules/metadata.jsx +++ b/src/modules/metadata.tsx @@ -1,23 +1,216 @@ -'use strict'; // ============================================================================ // Channel Metadata // ============================================================================ +import { DEBUG } from 'utilities/constants'; + import {createElement, ClickOutside, setChildren} from 'utilities/dom'; import {maybe_call} from 'utilities/object'; +import Module, { buildAddonProxy, GenericModule } from 'utilities/module'; import {duration_to_string, durationForURL} from 'utilities/time'; +import Tooltip, { TooltipInstance } from 'utilities/tooltip'; +import type { AddonInfo, DomFragment, OptionallyThisCallable, OptionalPromise } from 'utilities/types'; -import Tooltip from 'utilities/tooltip'; -import Module from 'utilities/module'; -import { DEBUG } from 'src/utilities/constants'; +import type SettingsManager from '../settings'; +import type TranslationManager from '../i18n'; +import type TooltipProvider from './tooltips'; +import type SocketClient from '../socket'; const CLIP_URL = /^https:\/\/[^/]+\.(?:twitch\.tv|twitchcdn\.net)\/.+?\.mp4(?:\?.*)?$/; +declare global { + interface Element { + _ffz_stat?: HTMLElement | null; + _ffz_data?: any; + _ffz_order?: number | null; + + _ffz_destroy?: (() => void) | null; + _ffz_outside?: ClickOutside | null; + _ffz_popup?: Tooltip | null; + tip?: TooltipInstance | null; + tip_content?: any; + } +} + + +export type MetadataState = { + /** Whether or not the metadata is being rendered onto the player directly. */ + is_player: boolean; + + /** The current channel. */ + channel: { + /** The channel's user ID. */ + id: string; + /** The channel's login name. */ + login: string; + /** The channel's display name. */ + display_name: string; + /** Whether or not the channel is currently displaying a video. */ + video: boolean; + /** Whether or not the channel is currently live. */ + live: boolean; + /** When the channel went live, if it is currently live. */ + live_since: string | Date; + }; + + /** Get the current number of viewers watching the current channel. */ + getViewerCount: () => number; + + /** Get the broadcast ID of the current live broadcast, assuming the current channel is live. */ + getBroadcastID: () => string | null; + + /** Get the currently logged in user's relationship with the current channel. */ + // TODO: Types + getUserSelf: () => Promise; + + /** + * Get the currently logged in user's relationship with the current + * channel, immediately. When data loads, if it is not already available + * at the time of the call, and a callback method is provided, the + * callback method will be called with the data. + */ + // TODO: Types + getUserSelfImmediate: (callback?: (data: any) => void) => any | null; + + /** A method that, when called, will trigger the metadata element to be refreshed. */ + refresh: () => void; + +} + + +type OptionallyCallable = OptionallyThisCallable; + + +/** + * A metadata definition contains all the information that FrankerFaceZ + * needs in order to render a player metadata element. This includes special + * data processing, how often to refresh, behavior when interacted with, + * and various appearance options. + */ +export type MetadataDefinition = { + + // Targets + modview?: boolean; + player?: boolean; + + // Behavior + + /** + * Optional. If present, this setup method will be called whenever + * processing this metadata element in order to transform its data + * into a prefered format. + */ + setup?: (this: Metadata, data: MetadataState) => OptionalPromise; + + /** + * Optional. Whether or not this metadata element should refresh itself + * periodically. This can be a specific amount of time, in milliseconds, + * after which the element should be refreshed or `true` to refresh + * after 1 second. + * + * Note: Your metadata might not refresh after the exact length, as + * the metadata manager will attempt to optimize rendering performance + * by using animation frames and batching. + */ + refresh?: OptionallyCallable; + + /** + * Optional. A click handler for the metadata element. + * @param data Your state, as returned from {@link setup} + * @param event The {@link MouseEvent} being handled. + * @param refresh A method that, when called, manually refreshes + * your metadata. + */ + click?: (this: Metadata, data: TData, event: MouseEvent, refresh: () => void) => void; + + /** + * Optional. If this returns true, interactions with your metadata + * element will be disabled and the element may appear with a visual + * disabled state. + */ + disabled?: OptionallyCallable; + + // Appearance + + /** + * The label for this metadata element. If no label is returned, the + * metadata element will not be displayed. This should be a + * human-readable string. + */ + label: OptionallyCallable; + + tooltip?: OptionallyCallable; + + /** + * Optional. What order this metadata element should be displayed in. + * This uses CSS's flexbox's order property to adjust the visible + * position of each metadata element. + */ + order?: OptionallyCallable; + + /** + * Optional. The color that the metadata element's label should be. If + * this is not set, the default text color will be used. + */ + color?: OptionallyCallable; + + /** + * Optional. An icon to be displayed + */ + icon?: OptionallyCallable; + + // Button Appearance + + /** + * Optional. Whether or not this metadata element should be displayed + * with a button style. By default, elements are displayed with a button + * style if they have a {@link popup} or {@link click} behavior defined. + * + * You can override the appearance using this value. + */ + button?: boolean; + + border?: OptionallyCallable; + + inherit?: OptionallyCallable; + + // Popup Appearance and Behavior + + /** + * Optional. When this is true, an arrow element will not be created + * when building a popup for this metadata element. + */ + no_arrow?: boolean; + + popup?: (this: Metadata, data: TData, tip: TooltipInstance, refresh: () => void, addCloseListener: (callback: () => void) => void) => void; + + + /** + * The source that added this metadata definition. This will be unset + * if the metadata was added by FrankerFaceZ, or contain the add-on ID + * of an add-on. + */ + __source?: string; + +} + +/** + * @noInheritDoc + */ export default class Metadata extends Module { - constructor(...args) { - super(...args); + + definitions: Record | null | undefined>; + + // Dependencies + settings: SettingsManager = null as any; + i18n: TranslationManager = null as any; + tooltips: TooltipProvider = null as any; + + /** @internal */ + constructor(name?: string, parent?: GenericModule) { + super(name, parent); this.inject('settings'); this.inject('i18n'); @@ -105,7 +298,7 @@ export default class Metadata extends Module { }); - this.definitions.viewers = { + this.define('viewers', { refresh() { return this.settings.get('metadata.viewers') }, @@ -131,10 +324,11 @@ export default class Metadata extends Module { }, color: 'var(--color-text-live)' - }; + + }); - this.definitions.uptime = { + this.define('uptime', { inherit: true, no_arrow: true, player: true, @@ -142,20 +336,15 @@ export default class Metadata extends Module { refresh() { return this.settings.get('metadata.uptime') > 0 }, setup(data) { - const socket = this.resolve('socket'); let created = data?.channel?.live_since; - if ( ! created ) { - const created_at = data?.meta?.createdAt; - if ( ! created_at ) - return {}; - - created = created_at; - } + if ( ! created ) + return null; if ( !(created instanceof Date) ) created = new Date(created); - const now = Date.now() - socket._time_drift; + const socket = this.resolve('socket'); + const now = Date.now() - (socket?._time_drift ?? 0); return { created, @@ -169,16 +358,14 @@ export default class Metadata extends Module { label(data) { const setting = this.settings.get('metadata.uptime'); - if ( ! setting || ! data.created ) + if ( ! setting || ! data?.created ) return null; return duration_to_string(data.uptime, false, false, false, setting !== 2); }, - subtitle: () => this.i18n.t('metadata.uptime.subtitle', 'Uptime'), - tooltip(data) { - if ( ! data.created ) + if ( ! data?.created ) return null; return [ @@ -197,8 +384,13 @@ export default class Metadata extends Module { }, async popup(data, tip) { + if ( ! data ) + return; + const [permission, broadcast_id] = await Promise.all([ - navigator?.permissions?.query?.({name: 'clipboard-write'}).then(perm => perm?.state).catch(() => null), + // We need the as any here because TypeScript's devs don't + // live with the rest of us in the real world. + navigator?.permissions?.query?.({name: 'clipboard-write' as PermissionName}).then(perm => perm?.state).catch(() => null), data.getBroadcastID() ]); if ( ! broadcast_id ) @@ -209,13 +401,13 @@ export default class Metadata extends Module { const url = `https://www.twitch.tv/videos/${broadcast_id}${data.uptime > 0 ? `?t=${durationForURL(data.uptime)}` : ''}`, can_copy = permission === 'granted' || permission === 'prompt'; - const copy = can_copy ? e => { + const copy = can_copy ? (event: MouseEvent) => { navigator.clipboard.writeText(url); - e.preventDefault(); + event.preventDefault(); return false; } : null; - tip.element.classList.add('ffz-balloon--lg'); + tip.element?.classList.add('ffz-balloon--lg'); return (
@@ -228,7 +420,7 @@ export default class Metadata extends Module { class="tw-border-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 ffz-input tw-full-width" type="text" value={url} - onFocus={e => e.target.select()} + onFocus={(e: FocusEvent) => (e.target as HTMLInputElement)?.select()} /> {can_copy &&
); } - } + }); - this.definitions['clip-download'] = { + this.define('clip-download', { button: true, inherit: true, @@ -259,7 +451,8 @@ export default class Metadata extends Module { if ( ! this.settings.get('metadata.clip-download') ) return; - const Player = this.resolve('site.player'), + // TODO: Types + const Player = this.resolve('site.player') as any, player = Player.current; if ( ! player ) return; @@ -271,13 +464,14 @@ export default class Metadata extends Module { return; if ( this.settings.get('metadata.clip-download.force') ) - return src; + return src as string; - const user = this.resolve('site').getUser?.(), + // TODO: Types + const user = (this.resolve('site') as any).getUser?.(), is_self = user?.id == data.channel.id; if ( is_self || data.getUserSelfImmediate(data.refresh)?.isEditor ) - return src; + return src as string; }, label(src) { @@ -297,9 +491,9 @@ export default class Metadata extends Module { link.click(); link.remove(); } - } + }); - this.definitions['player-stats'] = { + this.define('player-stats', { button: true, inherit: true, modview: true, @@ -309,9 +503,9 @@ export default class Metadata extends Module { return this.settings.get('metadata.player-stats') }, - setup() { - const Player = this.resolve('site.player'), - socket = this.resolve('socket'), + setup(data) { + const Player = this.resolve('site.player') as any, + socket = this.resolve('socket') as SocketClient, player = Player.current; let stats; @@ -374,13 +568,13 @@ export default class Metadata extends Module { try { const url = player.core.state.path; if ( url.includes('/api/channel/hls/') ) { - const data = JSON.parse(new URL(url).searchParams.get('token')); + const data = JSON.parse(new URL(url).searchParams.get('token') as string); tampered = data && data.player_type && data.player_type !== 'site' ? data.player_type : false; } } catch(err) { /* no op */ } if ( ! stats || stats.hlsLatencyBroadcaster < -100 ) - return {stats}; + return null; let drift = 0; @@ -388,6 +582,7 @@ export default class Metadata extends Module { drift = socket._time_drift; return { + is_player: data.is_player, stats, drift, rate: stats.rate == null ? 1 : stats.rate, @@ -400,16 +595,14 @@ export default class Metadata extends Module { order: 3, icon(data) { - if ( data.rate > 1 ) + if ( data?.rate > 1 ) return 'ffz-i-fast-fw'; return 'ffz-i-gauge' }, - subtitle: () => this.i18n.t('metadata.player-stats.subtitle', 'Latency'), - label(data) { - if ( ! this.settings.get('metadata.player-stats') || ! data.delay ) + if ( ! this.settings.get('metadata.player-stats') || ! data?.delay ) return null; if ( data.old ) @@ -424,10 +617,10 @@ export default class Metadata extends Module { }, click() { - const Player = this.resolve('site.player'), - fine = this.resolve('site.fine'), + const Player = this.resolve('site.player') as any, + fine = this.resolve('site.fine') as any, player = Player.Player?.first, - inst = fine && player && fine.searchTree(player, n => n.props?.setStatsOverlay, 200), + inst = fine && player && fine.searchTree(player, (n: any) => n.props?.setStatsOverlay, 200), cont = inst && fine.getChildNode(player), el = cont && cont.querySelector('[data-a-target="player-overlay-video-stats"]'); @@ -449,7 +642,7 @@ export default class Metadata extends Module { color(data) { const setting = this.settings.get('metadata.stream-delay-warning'); - if ( setting === 0 || ! data.delay || data.old ) + if ( setting === 0 || ! data?.delay || data.old ) return; if ( data.delay > (setting * 2) ) @@ -460,6 +653,9 @@ export default class Metadata extends Module { }, tooltip(data) { + if ( ! data ) + return null; + const tampered = data.tampered ? (
{this.i18n.t( 'metadata.player-stats.tampered', @@ -470,21 +666,21 @@ export default class Metadata extends Module { )}
) : null; - const delayed = data.drift > 5000 && (
+ const delayed = data.drift > 5000 ? (
{this.i18n.t( 'metadata.player-stats.delay-warning', 'Your local clock seems to be off by roughly {count,number} seconds, which could make this inaccurate.', Math.round(data.drift / 10) / 100 )} -
); +
) : null; - const ff = data.rate > 1 && (
+ const ff = data.rate > 1 ? (
{this.i18n.t( 'metadata.player-stats.rate-warning', 'Playing at {rate,number}x speed to reduce delay.', {rate: data.rate.toFixed(2)} )} -
); +
) : null; if ( ! data.stats || ! data.delay ) return [ @@ -555,41 +751,32 @@ export default class Metadata extends Module { tampered ]; } - } + }); } - - getAddonProxy(addon_id, addon, module) { + /** @internal */ + getAddonProxy(addon_id: string, addon: AddonInfo, module: GenericModule): GenericModule { if ( ! addon_id ) return this; - const overrides = {}, + const overrides: Record = {}, is_dev = DEBUG || addon?.dev; - overrides.define = (key, definition) => { + overrides.define = (key: string, definition: MetadataDefinition) => { if ( definition ) definition.__source = addon_id; return this.define(key, definition); }; - return new Proxy(this, { - get(obj, prop) { - const thing = overrides[prop]; - if ( thing ) - return thing; - if ( prop === 'definitions' && is_dev ) - module.log.warn('[DEV-CHECK] Accessed metadata.definitions directly. Please use define()'); - - return Reflect.get(...arguments); - } - }); + return buildAddonProxy(module, this, 'metadata', overrides); } + /** @internal */ onEnable() { - const md = this.tooltips.types.metadata = target => { - let el = target; + const md: any = (this.tooltips.types as any).metadata = (target: HTMLElement) => { + let el: HTMLElement | null = target; if ( el._ffz_stat ) el = el._ffz_stat; else if ( ! el.classList.contains('ffz-stat') ) { @@ -601,31 +788,31 @@ export default class Metadata extends Module { return; const key = el.dataset.key, - def = this.definitions[key]; + def = key?.length ? this.definitions[key] : null; - return maybe_call(def.tooltip, this, el._ffz_data) + return maybe_call(def?.tooltip, this, el._ffz_data) }; - md.onShow = (target, tip) => { + md.onShow = (target: HTMLElement, tip: TooltipInstance) => { const el = target._ffz_stat || target; el.tip = tip; }; - md.onHide = target => { + md.onHide = (target: HTMLElement) => { const el = target._ffz_stat || target; el.tip = null; el.tip_content = null; } - md.popperConfig = (target, tip, opts) => { + md.popperConfig = (target: HTMLElement, tip: TooltipInstance, opts: any) => { opts.placement = 'bottom'; opts.modifiers.flip = {behavior: ['bottom','top']}; return opts; } this.on('addon:fully-unload', addon_id => { - const removed = new Set; - for(const [key,def] of Object.entries(this.definitions)) { + const removed = new Set; + for(const [key, def] of Object.entries(this.definitions)) { if ( def?.__source === addon_id ) { removed.add(key); this.definitions[key] = undefined; @@ -640,51 +827,99 @@ export default class Metadata extends Module { } + /** + * Return an array of all metadata definition keys. + */ get keys() { return Object.keys(this.definitions); } - define(key, definition) { + /** + * Add or update a metadata definition. This method updates the entry + * in {@link definitions}, and then it updates every live metadata + * display to reflect the updated definition. + * + * @example Adding a simple metadata definition that displays when the channel went live. + * ```typescript + * metadata.define('when-live', { + * setup(data) { + * return data.channel?.live && data.channel.live_since; + * }, + * + * label(live_since) { + * return live_since; + * } + * }); + * ``` + * + * @param key A unique key for the metadata. + * @param definition Your metadata's definition, or `null` to remove it. + */ + define(key: string, definition?: MetadataDefinition | null) { this.definitions[key] = definition; this.updateMetadata(key); } - updateMetadata(keys) { - const channel = this.resolve('site.channel'); + /** + * Update the rendered metadata elements for a key or keys. If keys + * is not provided, this will update every metadata element. + * + * @param keys Optional. The key or keys that should be updated. + */ + updateMetadata(keys?: string | string[]) { + // TODO: Types + + const channel = this.resolve('site.channel') as any; if ( channel ) for(const el of channel.InfoBar.instances) channel.updateMetadata(el, keys); - const player = this.resolve('site.player'); + const player = this.resolve('site.player') as any; if ( player ) for(const inst of player.Player.instances) player.updateMetadata(inst, keys); } - async renderLegacy(key, data, container, timers, refresh_fn) { + /** + * Render a metadata definition into a container. This is used + * internally to render metadata. + * + * @param key The metadata's unique key. + * @param data The initial state + * @param container The container to render into + * @param timers An object to store timers for re-rendering + * @param refresh_fn A method to call when the metadata should be re-rendered. + */ + async renderLegacy( + key: string, + data: MetadataState, + container: HTMLElement, + timers: Record>, + refresh_fn: (key: string) => void + ) { if ( timers[key] ) clearTimeout(timers[key]); - let el = container.querySelector(`.ffz-stat[data-key="${key}"]`); + let el = container.querySelector(`.ffz-stat[data-key="${key}"]`); const def = this.definitions[key], destroy = () => { if ( el ) { - if ( el.tooltip ) + /*if ( el.tooltip ) el.tooltip.destroy(); if ( el.popper ) - el.popper.destroy(); + el.popper.destroy();*/ if ( el._ffz_destroy ) el._ffz_destroy(); - el._ffz_destroy = el.tooltip = el.popper = null; + el._ffz_destroy = /*el.tooltip = el.popper =*/ null; el.remove(); } }; - if ( ! def || (data._mt || 'channel') !== (def.type || 'channel') ) + if ( ! def /* || (data._mt || 'channel') !== (def.type || 'channel') */ ) return destroy(); try { @@ -709,9 +944,10 @@ export default class Metadata extends Module { // Grab the element again in case it changed, somehow. - el = container.querySelector(`.ffz-stat[data-key="${key}"]`); + el = container.querySelector(`.ffz-stat[data-key="${key}"]`); - let stat, old_color, old_icon; + let stat: HTMLElement | null, + old_color, old_icon; const label = maybe_call(def.label, this, data); @@ -728,7 +964,9 @@ export default class Metadata extends Module { if ( def.button !== false && (def.popup || def.click) ) { button = true; - let btn, popup; + let btn: HTMLButtonElement | undefined, + popup: HTMLButtonElement | undefined; + const border = maybe_call(def.border, this, data), inherit = maybe_call(def.inherit, this, data); @@ -741,6 +979,8 @@ export default class Metadata extends Module { el = (
{btn = ()} + ) as HTMLButtonElement} {popup = (
- )} + ) as HTMLButtonElement}
); } else @@ -769,64 +1009,74 @@ export default class Metadata extends Module { class={`ffz-stat tw-align-items-center tw-align-middle tw-border-bottom-left-radius-medium tw-border-top-left-radius-medium tw-border-bottom-right-radius-medium tw-border-top-right-radius-medium ffz-core-button ffz-core-button--text ${inherit ? 'ffz-c-text-inherit' : 'tw-c-text-base'} tw-inline-flex tw-interactive tw-justify-content-center tw-overflow-hidden tw-relative tw-pd-x-05 ffz-stat--fix-padding ${border ? 'tw-border tw-mg-r-1' : 'tw-font-size-5 tw-regular tw-mg-r-05 ffz-mg-l--05'}${def.tooltip ? ' ffz-tooltip ffz-tooltip--no-mouse' : ''}`} data-tooltip-type="metadata" data-key={key} + // createElement will properly assign this to the + // created element. Shut up TypeScript. tip_content={null} >
- {icon} + {icon as any} {stat = ()} {def.popup && ! def.no_arrow &&
}
- ); + ) as any as HTMLButtonElement; if ( def.click ) - btn.addEventListener('click', e => { - if ( el._ffz_fading || btn.disabled || btn.classList.contains('disabled') || el.disabled || el.classList.contains('disabled') ) + btn.addEventListener('click', (event: MouseEvent) => { + if ( ! el || ! btn || btn.disabled || btn.classList.contains('disabled') || (el as any).disabled || el.classList.contains('disabled') ) return false; - def.click.call(this, el._ffz_data, e, () => refresh_fn(key)); + return def.click?.call?.(this, el._ffz_data, event, () => { refresh_fn(key); }); }); if ( def.popup ) popup.addEventListener('click', () => { - if ( popup.disabled || popup.classList.contains('disabled') || el.disabled || el.classList.contains('disabled') ) + if ( ! el || ! popup || popup.disabled || popup.classList.contains('disabled') || (el as any).disabled || el.classList.contains('disabled') ) return false; - if ( el._ffz_popup ) + if ( el._ffz_popup && el._ffz_destroy ) return el._ffz_destroy(); - const listeners = [], - add_close_listener = cb => listeners.push(cb); + const listeners: (() => void)[] = [], + add_close_listener = (cb: () => void) => { + listeners.push(cb); + }; const destroy = el._ffz_destroy = () => { for(const cb of listeners) { try { cb(); } catch(err) { - this.log.capture(err, { - tags: { - metadata: key - } - }); + if ( err instanceof Error ) + this.log.capture(err, { + tags: { + metadata: key + } + }); this.log.error('Error when running a callback for pop-up destruction for metadata:', key, err); } } - if ( el._ffz_outside ) - el._ffz_outside.destroy(); + // el is not going to be null + // TypeScript is on drugs + // whatever though + if ( el ) { + if ( el._ffz_outside ) + el._ffz_outside.destroy(); - if ( el._ffz_popup ) { - const fp = el._ffz_popup; - el._ffz_popup = null; - fp.destroy(); + if ( el._ffz_popup ) { + const fp = el._ffz_popup; + el._ffz_popup = null; + fp.destroy(); + } + + el._ffz_destroy = el._ffz_outside = null; } - - el._ffz_destroy = el._ffz_outside = null; }; - const parent = document.fullscreenElement || document.body.querySelector('#root>div') || document.body, - tt = el._ffz_popup = new Tooltip(parent, el, { + const parent = document.fullscreenElement || document.body.querySelector('#root>div') || document.body, + tt = el._ffz_popup = new Tooltip(parent as HTMLElement, el, { logger: this.log, i18n: this.i18n, manual: true, @@ -850,10 +1100,11 @@ export default class Metadata extends Module { } } }, - content: (t, tip) => def.popup.call(this, el._ffz_data, tip, () => refresh_fn(key), add_close_listener), + content: (t, tip) => def.popup?.call(this, el?._ffz_data, tip, () => refresh_fn(key), add_close_listener), onShow: (t, tip) => setTimeout(() => { - el._ffz_outside = new ClickOutside(tip.outer, destroy); + if ( el && tip.outer ) + el._ffz_outside = new ClickOutside(tip.outer, destroy); }), onHide: destroy }); @@ -871,23 +1122,23 @@ export default class Metadata extends Module { data-key={key} tip_content={null} > - {icon} + {icon as any} {stat = }
); if ( def.click ) - el.addEventListener('click', e => { - if ( el._ffz_fading || el.disabled || el.classList.contains('disabled') ) + el.addEventListener('click', (event: MouseEvent) => { + if ( ! el || (el as any).disabled || el.classList.contains('disabled') ) return false; - def.click.call(this, el._ffz_data, e, () => refresh_fn(key)); + def.click?.call?.(this, el._ffz_data, event, () => refresh_fn(key)); }); } el._ffz_order = order; if ( order != null ) - el.style.order = order; + el.style.order = `${order}`; container.appendChild(el); @@ -900,14 +1151,16 @@ export default class Metadata extends Module { old_color = el.dataset.color || ''; if ( el._ffz_order !== order ) - el.style.order = el._ffz_order = order; + el.style.order = `${el._ffz_order = order}`; if ( el.tip ) { const tooltip = maybe_call(def.tooltip, this, data); if ( el.tip_content !== tooltip ) { el.tip_content = tooltip; - el.tip.element.innerHTML = ''; - setChildren(el.tip.element, tooltip); + if ( el.tip?.element ) { + el.tip.element.innerHTML = ''; + setChildren(el.tip.element, tooltip); + } } } } @@ -928,19 +1181,21 @@ export default class Metadata extends Module { } el._ffz_data = data; - stat.innerHTML = label; + stat.innerHTML = ''; + setChildren(stat, label); if ( def.disabled !== undefined ) - el.disabled = maybe_call(def.disabled, this, data); + (el as any).disabled = maybe_call(def.disabled, this, data); } catch(err) { - this.log.capture(err, { - tags: { - metadata: key - } - }); + if ( err instanceof Error ) + this.log.capture(err, { + tags: { + metadata: key + } + }); this.log.error(`Error rendering metadata for ${key}`, err); return destroy(); } } -} \ No newline at end of file +} diff --git a/src/modules/tooltips.js b/src/modules/tooltips.ts similarity index 59% rename from src/modules/tooltips.js rename to src/modules/tooltips.ts index ba3fb98b..4994f072 100644 --- a/src/modules/tooltips.js +++ b/src/modules/tooltips.ts @@ -5,16 +5,71 @@ // ============================================================================ import {createElement, sanitize} from 'utilities/dom'; -import {has, maybe_call, once} from 'utilities/object'; +import {has, maybe_call} from 'utilities/object'; -import Tooltip from 'utilities/tooltip'; -import Module from 'utilities/module'; +import Tooltip, { TooltipInstance } from 'utilities/tooltip'; +import Module, { GenericModule, buildAddonProxy } from 'utilities/module'; import awaitMD, {getMD} from 'utilities/markdown'; import { DEBUG } from 'src/utilities/constants'; +import type { AddonInfo, DomFragment, OptionallyCallable } from '../utilities/types'; +import type TranslationManager from '../i18n'; + +declare global { + interface HTMLElement { + _ffz_child: Element | null; + } +} + +export type TooltipEvents = { + /** + * When this event is emitted, the tooltip provider will attempt to remove + * old, invalid tool-tips. + */ + ':cleanup': [], + + ':hover': [target: HTMLElement, tip: TooltipInstance, event: MouseEvent]; + ':leave': [target: HTMLElement, tip: TooltipInstance, event: MouseEvent]; +}; + +type TooltipOptional = OptionallyCallable<[target: HTMLElement, tip: TooltipInstance], TReturn>; + +type TooltipExtra = { + __source?: string; + + popperConfig(target: HTMLElement, tip: TooltipInstance, options: any): any; + + delayShow: TooltipOptional; + delayHide: TooltipOptional; + + interactive: TooltipOptional; + hover_events: TooltipOptional; + + onShow(target: HTMLElement, tip: TooltipInstance): void; + onHide(target: HTMLElement, tip: TooltipInstance): void; + +}; + +export type TooltipDefinition = Partial & + ((target: HTMLElement, tip: TooltipInstance) => DomFragment); + + +export default class TooltipProvider extends Module<'tooltips', TooltipEvents> { + + // Storage + types: Record; + + // Dependencies + i18n: TranslationManager = null as any; + + // State + container?: HTMLElement | null; + tip_element?: HTMLElement | null; + tips?: Tooltip | null; + + + constructor(name?: string, parent?: GenericModule) { + super(name, parent); -export default class TooltipProvider extends Module { - constructor(...args) { - super(...args); this.types = {}; this.inject('i18n'); @@ -69,44 +124,46 @@ export default class TooltipProvider extends Module { return md.render(target.dataset.title); }; - this.types.text = target => sanitize(target.dataset.title); + this.types.text = target => sanitize(target.dataset.title ?? ''); this.types.html = target => target.dataset.title; this.onFSChange = this.onFSChange.bind(this); } - getAddonProxy(addon_id, addon, module) { + getAddonProxy(addon_id: string, addon: AddonInfo, module: GenericModule) { if ( ! addon_id ) return this; - const overrides = {}, + const overrides: Record = {}, is_dev = DEBUG || addon?.dev; + let warnings: Record | undefined; - overrides.define = (key, handler) => { + overrides.define = (key: string, handler: TooltipDefinition) => { if ( handler ) handler.__source = addon_id; return this.define(key, handler); }; - if ( is_dev ) + if ( is_dev ) { overrides.cleanup = () => { module.log.warn('[DEV-CHECK] Instead of calling tooltips.cleanup(), you can emit the event "tooltips:cleanup"'); return this.cleanup(); }; - return new Proxy(this, { - get(obj, prop) { - const thing = overrides[prop]; - if ( thing ) - return thing; - if ( prop === 'types' && is_dev ) - module.log.warn('[DEV-CHECK] Accessed tooltips.types directly. Please use tooltips.define()'); + warnings = { + types: 'Please use tooltips.define()' + }; + } - return Reflect.get(...arguments); - } - }); + return buildAddonProxy( + module, + this, + 'tooltips', + overrides, + warnings + ); } @@ -140,20 +197,22 @@ export default class TooltipProvider extends Module { } - define(key, handler) { + define(key: string, handler: TooltipDefinition) { + // TODO: Determine if any tooltips are already open. + // If so, we need to close them / maybe re-open them? this.types[key] = handler; } getRoot() { // eslint-disable-line class-methods-use-this - return document.querySelector('.sunlight-root') || + return document.querySelector('.sunlight-root') || //document.querySelector('#root>div') || document.querySelector('#root') || document.querySelector('.clips-root') || document.body; } - _createInstance(container, klass = 'ffz-tooltip', default_type = 'text', tip_container) { + _createInstance(container: HTMLElement, klass = 'ffz-tooltip', default_type = 'text', tip_container?: HTMLElement) { return new Tooltip(container, klass, { html: true, i18n: this.i18n, @@ -190,34 +249,52 @@ export default class TooltipProvider extends Module { onFSChange() { - const tip_element = document.fullscreenElement || this.container; + if ( ! this.container ) + this.container = this.getRoot(); + + let tip_element = this.container; + if ( document.fullscreenElement instanceof HTMLElement ) + tip_element = document.fullscreenElement; + if ( tip_element !== this.tip_element ) { - this.tips.destroy(); this.tip_element = tip_element; - this.tips = this._createInstance(tip_element); + if ( this.tips ) { + this.tips.destroy(); + this.tips = this._createInstance(tip_element); + } } } cleanup() { - this.tips.cleanup(); + if ( this.tips ) + this.tips.cleanup(); } - delegatePopperConfig(default_type, target, tip, pop_opts) { + delegatePopperConfig( + default_type: string, + target: HTMLElement, + tip: TooltipInstance, + options: any + ) { const type = target.dataset.tooltipType || default_type, handler = this.types[type]; if ( target.dataset.tooltipSide ) - pop_opts.placement = target.dataset.tooltipSide; + options.placement = target.dataset.tooltipSide; if ( handler && handler.popperConfig ) - return handler.popperConfig(target, tip, pop_opts); + return handler.popperConfig(target, tip, options); - return pop_opts; + return options; } - delegateOnShow(default_type, target, tip) { + delegateOnShow( + default_type: string, + target: HTMLElement, + tip: TooltipInstance + ) { const type = target.dataset.tooltipType || default_type, handler = this.types[type]; @@ -225,7 +302,11 @@ export default class TooltipProvider extends Module { handler.onShow(target, tip); } - delegateOnHide(default_type, target, tip) { + delegateOnHide( + default_type: string, + target: HTMLElement, + tip: TooltipInstance + ) { const type = target.dataset.tooltipType || default_type, handler = this.types[type]; @@ -233,47 +314,67 @@ export default class TooltipProvider extends Module { handler.onHide(target, tip); } - checkDelayShow(default_type, target, tip) { + checkDelayShow( + default_type: string, + target: HTMLElement, + tip: TooltipInstance + ) { const type = target.dataset.tooltipType || default_type, handler = this.types[type]; - if ( has(handler, 'delayShow') ) + if ( handler?.delayShow != null ) return maybe_call(handler.delayShow, null, target, tip); return 0; } - checkDelayHide(default_type, target, tip) { + checkDelayHide( + default_type: string, + target: HTMLElement, + tip: TooltipInstance + ) { const type = target.dataset.tooltipType || default_type, handler = this.types[type]; - if ( has(handler, 'delayHide') ) + if ( handler?.delayHide != null ) return maybe_call(handler.delayHide, null, target, tip); return 0; } - checkInteractive(default_type, target, tip) { + checkInteractive( + default_type: string, + target: HTMLElement, + tip: TooltipInstance + ) { const type = target.dataset.tooltipType || default_type, handler = this.types[type]; - if ( has(handler, 'interactive') ) + if ( handler?.interactive != null ) return maybe_call(handler.interactive, null, target, tip); return false; } - checkHoverEvents(default_type, target, tip) { + checkHoverEvents( + default_type: string, + target: HTMLElement, + tip: TooltipInstance + ) { const type = target.dataset.tooltipType || default_type, handler = this.types[type]; - if ( has(handler, 'hover_events') ) + if ( handler?.hover_events != null ) return maybe_call(handler.hover_events, null, target, tip); return false; } - process(default_type, target, tip) { + process( + default_type: string, + target: HTMLElement, + tip: TooltipInstance + ) { const type = target.dataset.tooltipType || default_type || 'text', align = target.dataset.tooltipAlign, handler = this.types[type]; @@ -295,4 +396,4 @@ export default class TooltipProvider extends Module { return handler(target, tip); } -} \ No newline at end of file +} diff --git a/src/player.js b/src/player.js index 868288e6..c44c484c 100644 --- a/src/player.js +++ b/src/player.js @@ -12,7 +12,7 @@ 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 StagingSelector from './staging'; import PubSubClient from './pubsub'; import LoadTracker from './load_tracker'; @@ -156,4 +156,4 @@ FrankerFaceZ.utilities = { window.FrankerFaceZ = FrankerFaceZ; -window.ffz = new FrankerFaceZ(); \ No newline at end of file +window.ffz = new FrankerFaceZ(); diff --git a/src/raven.js b/src/raven.js index e11f00b4..c9a76cdc 100644 --- a/src/raven.js +++ b/src/raven.js @@ -216,7 +216,7 @@ export default class RavenLogger extends Module { return false; if ( this.settings && this.settings.get('reports.error.include-user') ) { - const user = this.resolve('site')?.getUser(); + const user = this.resolve('site')?.getUser?.(); if ( user ) data.user = {id: user.id, username: user.login} } @@ -401,4 +401,4 @@ export default class RavenLogger extends Module { captureException(exc, opts) { return this.raven.captureException(exc, opts) } captureMessage(msg, opts) { return this.raven.captureMessage(msg, opts) } captureBreadcrumb(...args) { return this.raven.captureBreadcrumb(...args) } -} \ No newline at end of file +} diff --git a/src/settings/clearables.js b/src/settings/clearables.ts similarity index 71% rename from src/settings/clearables.js rename to src/settings/clearables.ts index 929c2d5f..dfd3ea26 100644 --- a/src/settings/clearables.js +++ b/src/settings/clearables.ts @@ -1,10 +1,13 @@ 'use strict'; +import { AdvancedSettingsProvider } from "./providers"; +import type { SettingsClearable } from "./types"; + // ============================================================================ // Clearable Settings // ============================================================================ -export const Experiments = { +export const Experiments: SettingsClearable = { label: 'Experiment Overrides', keys: [ 'exp-lock', @@ -12,7 +15,7 @@ export const Experiments = { ] }; -export const HiddenEmotes = { +export const HiddenEmotes: SettingsClearable = { label: 'Hidden Emotes', keys(provider) { const keys = ['emote-menu.hidden-sets']; @@ -24,7 +27,7 @@ export const HiddenEmotes = { } }; -export const FavoriteEmotes = { +export const FavoriteEmotes: SettingsClearable = { label: 'Favorited Emotes', keys(provider) { const keys = []; @@ -36,7 +39,7 @@ export const FavoriteEmotes = { } }; -export const Overrides = { +export const Overrides: SettingsClearable = { label: 'Name and Color Overrides', keys: [ 'overrides.colors', @@ -44,7 +47,7 @@ export const Overrides = { ] }; -export const Profiles = { +export const Profiles: SettingsClearable = { label: 'Profiles', clear(provider, settings) { const keys = ['profiles']; @@ -59,11 +62,11 @@ export const Profiles = { } }; -export const Everything = { +export const Everything: SettingsClearable = { label: 'Absolutely Everything', async clear(provider, settings) { provider.clear(); - if ( provider.supportsBlobs ) + if ( provider.supportsBlobs && provider instanceof AdvancedSettingsProvider ) await provider.clearBlobs(); settings.loadProfiles(); diff --git a/src/settings/context.js b/src/settings/context.js index 4c224628..2700319a 100644 --- a/src/settings/context.js +++ b/src/settings/context.js @@ -7,7 +7,7 @@ import {EventEmitter} from 'utilities/events'; import {has, get as getter, array_equals, set_equals, map_equals, deep_equals} from 'utilities/object'; -import * as DEFINITIONS from './types'; +import * as DEFINITIONS from './typehandlers'; /** * Perform a basic check of a setting's requirements to see if they changed. @@ -506,4 +506,4 @@ export default class SettingsContext extends EventEmitter { return this.__meta.get(key).uses; } -} \ No newline at end of file +} diff --git a/src/settings/filters.js b/src/settings/filters.ts similarity index 60% rename from src/settings/filters.js rename to src/settings/filters.ts index 33b3a8e7..e4196241 100644 --- a/src/settings/filters.js +++ b/src/settings/filters.ts @@ -4,24 +4,29 @@ // Profile Filters for Settings // ============================================================================ -import {glob_to_regex, escape_regex, matchScreen} from 'utilities/object'; -import {createTester} from 'utilities/filtering'; +import {glob_to_regex, escape_regex, matchScreen, ScreenOptions} from 'utilities/object'; +import {FilterData, FilterType, createTester} from 'utilities/filtering'; import { DEBUG } from 'utilities/constants'; +import type { ContextData } from './types'; +import type { ScreenDetails } from 'root/types/getScreenDetails'; +import SettingsManager from '.'; -let safety = null; +let safety: ((input: string | RegExp) => boolean) | null = null; -function loadSafety(cb) { +function loadSafety(callback?: () => void) { import(/* webpackChunkName: 'regex' */ 'safe-regex').then(thing => { safety = thing.default; - if ( cb ) - cb(); + if ( callback ) + callback(); }) } +const NeverMatch = () => false; + // Logical Components -export const Invert = { +export const Invert: FilterType = { createTest(config, rule_types, rebuild) { return createTester(config, rule_types, true, false, rebuild) }, @@ -37,7 +42,7 @@ export const Invert = { editor: () => import(/* webpackChunkName: 'main-menu' */ './components/nested.vue') }; -export const And = { +export const And: FilterType = { createTest(config, rule_types, rebuild) { return createTester(config, rule_types, false, false, rebuild); }, @@ -52,7 +57,7 @@ export const And = { editor: () => import(/* webpackChunkName: 'main-menu' */ './components/nested.vue') }; -export const Or = { +export const Or: FilterType = { createTest(config, rule_types, rebuild) { return createTester(config, rule_types, false, true, rebuild); }, @@ -67,11 +72,17 @@ export const Or = { editor: () => import(/* webpackChunkName: 'main-menu' */ './components/nested.vue') }; -export const If = { +type IfData = [ + condition: FilterData[], + if_true: FilterData[], + if_else: FilterData[] +]; + +export const If: FilterType = { createTest(config, rule_types, rebuild) { - const cond = createTester(config[0], rule_types, false, false, rebuild), - if_true = createTester(config[1], rule_types, false, false, rebuild), - if_false = createTester(config[2], rule_types, false, false, rebuild); + const cond = createTester(config[0], rule_types as any, false, false, rebuild), + if_true = createTester(config[1], rule_types as any, false, false, rebuild), + if_false = createTester(config[2], rule_types as any, false, false, rebuild); return ctx => cond(ctx) ? if_true(ctx) : if_false(ctx) }, @@ -85,11 +96,11 @@ export const If = { editor: () => import(/* webpackChunkName: 'main-menu' */ './components/if.vue') }; -export const Constant = { +export const Constant: FilterType = { createTest(config) { if ( config ) return () => true; - return () => false; + return NeverMatch; }, title: 'True or False', @@ -103,7 +114,7 @@ export const Constant = { // Context Stuff -function parseTime(time) { +function parseTime(time: string) { if ( typeof time !== 'string' || ! time.length ) return null; @@ -123,7 +134,12 @@ function parseTime(time) { return hours * 60 + minutes; } -export const Time = { +type TimeFilter = FilterType<[start: string, end: string], ContextData> & { + _captured: Set; + captured: () => number[]; +}; + +export const Time: TimeFilter = { _captured: new Set, createTest(config) { @@ -131,7 +147,7 @@ export const Time = { end = parseTime(config[1]); if ( start == null || end == null ) - return () => false; + return NeverMatch; if ( start <= end ) return () => { @@ -170,12 +186,12 @@ export const Time = { editor: () => import(/* webpackChunkName: 'main-menu' */ './components/time.vue') } -export const TheaterMode = { +export const TheaterMode: FilterType = { createTest(config) { return ctx => { if ( ctx.fullscreen ) return config === false; - return ctx.ui && ctx.ui.theatreModeEnabled === config; + return ctx.ui?.theatreModeEnabled === config; } }, @@ -187,7 +203,7 @@ export const TheaterMode = { editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue') }; -export const Fullscreen = { +export const Fullscreen: FilterType = { createTest(config) { return ctx => ctx.fullscreen === config; }, @@ -200,7 +216,7 @@ export const Fullscreen = { editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue') }; -export const Moderator = { +export const Moderator: FilterType = { createTest(config) { return ctx => ctx.moderator === config; }, @@ -212,7 +228,7 @@ export const Moderator = { editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue') }; -export const Debug = { +export const Debug: FilterType = { createTest(config) { return () => DEBUG === config; }, @@ -224,7 +240,7 @@ export const Debug = { editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue') }; -export const AddonDebug = { +export const AddonDebug: FilterType = { createTest(config) { return ctx => ctx.addonDev == config }, @@ -236,9 +252,9 @@ export const AddonDebug = { editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue') } -export const SquadMode = { +export const SquadMode: FilterType = { createTest(config) { - return ctx => ctx.ui && ctx.ui.squadModeEnabled === config; + return ctx => ctx.ui?.squadModeEnabled === config; }, title: 'Squad Mode', @@ -248,10 +264,10 @@ export const SquadMode = { editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue') }; -export const NativeDarkTheme = { +export const NativeDarkTheme: FilterType = { createTest(config) { const val = config ? 1 : 0; - return ctx => ctx.ui && ctx.ui.theme === val; + return ctx => ctx.ui?.theme === val; }, title: 'Dark Theme', @@ -261,33 +277,41 @@ export const NativeDarkTheme = { editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue') }; -export const Page = { - createTest(config = {}) { +// TODO: Add typing. +type PageData = { + route: string; + values: Record; +}; + +export const Page: FilterType = { + createTest(config) { + if ( ! config ) + return NeverMatch; + const name = config.route, - parts = []; + parts: [index: number, value: string][] = []; if ( Object.keys(config.values).length ) { const ffz = window.FrankerFaceZ?.get(), - router = ffz && ffz.resolve('site.router'); + router = ffz && ffz.resolve('site.router') as any; - if ( router ) { - const route = router.getRoute(name); - if ( ! route || ! route.parts ) - return () => false; + if ( ! router ) + return NeverMatch; - let i = 1; - for(const part of route.parts) { - if ( typeof part === 'object' ) { - const val = config.values[part.name]; - if ( val && val.length ) - parts.push([i, val.toLowerCase()]); + const route = router.getRoute(name); + if ( ! route || ! route.parts ) + return NeverMatch; - i++; - } + let i = 1; + for(const part of route.parts) { + if ( typeof part === 'object' ) { + const val = config.values[part.name]; + if ( val && val.length ) + parts.push([i, val.toLowerCase()]); + + i++; } - - } else - return () => false; + } } return ctx => { @@ -318,12 +342,28 @@ export const Page = { editor: () => import(/* webpackChunkName: 'main-menu' */ './components/page.vue') }; -export const Channel = { - createTest(config = {}) { - const login = config.login, - id = config.id; +type ChannelData = { + login: string | null; + id: string | null; +}; - return ctx => ctx.channelID === id || (ctx.channelID == null && ctx.channelLogin === login); +export const Channel: FilterType = { + createTest(config) { + const login = config?.login, + id = config?.id; + + if ( ! id && ! login ) + return NeverMatch; + + else if ( ! id ) + return ctx => ctx.channel === login; + + else if ( ! login ) + return ctx => ctx.channelID === id; + + return ctx => + ctx.channelID === id || + (ctx.channelID == null && ctx.channel === login); }, title: 'Current Channel', @@ -336,15 +376,28 @@ export const Channel = { editor: () => import(/* webpackChunkName: 'main-menu' */ './components/channel.vue') }; -export const Category = { - createTest(config = {}) { - const name = config.name, - id = config.id; +type CategoryData = { + name: string | null; + id: string | null; +} - if ( ! id || ! name ) - return () => false; +export const Category: FilterType = { + createTest(config) { + const name = config?.name, + id = config?.id; - return ctx => ctx.categoryID === id || (ctx.categoryID == null && ctx.category === name); + if ( ! id && ! name ) + return NeverMatch; + + else if ( ! id ) + return ctx => ctx.category === name; + + else if ( ! name ) + return ctx => ctx.categoryID === id; + + return ctx => + ctx.categoryID === id || + (ctx.categoryID == null && ctx.category === name); }, title: 'Current Category', @@ -358,14 +411,20 @@ export const Category = { editor: () => import(/* webpackChunkName: 'main-menu' */ './components/category.vue') } -export const Title = { - createTest(config = {}, _, reload) { - const mode = config.mode; - let title = config.title, +type TitleData = { + title: string; + mode: 'text' | 'glob' | 'raw' | 'regex'; + sensitive: boolean; +}; + +export const Title: FilterType = { + createTest(config, _, reload) { + const mode = config?.mode; + let title = config?.title, need_safety = true; if ( ! title || ! mode ) - return () => false; + return NeverMatch; if ( mode === 'text' ) { title = escape_regex(title); @@ -373,26 +432,26 @@ export const Title = { } else if ( mode === 'glob' ) title = glob_to_regex(title); else if ( mode !== 'raw' ) - return () => false; + return NeverMatch; if ( need_safety ) { if ( ! safety ) loadSafety(reload); if ( ! safety || ! safety(title) ) - return () => false; + return NeverMatch; } - let regex; + let regex: RegExp; try { regex = new RegExp(title, `g${config.sensitive ? '' : 'i'}`); } catch(err) { - return () => false; + return NeverMatch; } return ctx => { regex.lastIndex = 0; - return ctx.title && regex.test(ctx.title); + return ctx.title ? regex.test(ctx.title): false; } }, @@ -410,7 +469,15 @@ export const Title = { // Monitor Stuff -export let Monitor = null; +type MonitorType = FilterType & { + _used: boolean; + details?: ScreenDetails | null | false; + + used: () => boolean; + +}; + +export let Monitor: MonitorType = null as any; if ( window.getScreenDetails ) { @@ -424,31 +491,31 @@ if ( window.getScreenDetails ) { return out; }, - createTest(config = {}, _, reload) { - if ( ! config.label ) - return () => false; + createTest(config, _, reload) { + if ( ! config?.label ) + return NeverMatch; Monitor._used = true; - if ( Monitor.details === undefined ) { - const FFZ = window.FrankerFaceZ ?? window.FFZBridge; - if ( FFZ ) - FFZ.get().resolve('settings').createMonitorUpdate().then(() => { + if ( reload && Monitor.details === undefined ) { + const FFZ = window.FrankerFaceZ ?? ((window as any).FFZBridge as any), + ffz = FFZ?.get(), + settings = ffz?.resolve('settings'); + if ( settings ) + settings.createMonitorUpdate().then(() => { reload(); }); } return () => { Monitor._used = true; - const details = Monitor.details, - screen = details?.currentScreen; - - if ( ! screen ) + const details = Monitor.details; + if ( ! details ) return false; - const sorted = details.screens, // sortScreens(Array.from(details.screens)), + const sorted = details.screens, matched = matchScreen(sorted, config); - return matched === screen; + return matched === details.currentScreen; }; }, diff --git a/src/settings/index.js b/src/settings/index.ts similarity index 71% rename from src/settings/index.js rename to src/settings/index.ts index 0f3cd5c0..08172b4b 100644 --- a/src/settings/index.js +++ b/src/settings/index.ts @@ -4,22 +4,26 @@ // Settings System // ============================================================================ -import Module from 'utilities/module'; +import Module, { GenericModule } from 'utilities/module'; import {deep_equals, has, debounce, deep_copy} from 'utilities/object'; -import {parse as new_parse} from 'utilities/path-parser'; +import {parse as parse_path} from 'utilities/path-parser'; import SettingsProfile from './profile'; import SettingsContext from './context'; -import MigrationManager from './migration'; +//import MigrationManager from './migration'; import * as PROCESSORS from './processors'; import * as VALIDATORS from './validators'; -import * as PROVIDERS from './providers'; import * as FILTERS from './filters'; import * as CLEARABLES from './clearables'; +import type { SettingsProfileMetadata, ContextData, ExportedFullDump, SettingsClearable, SettingsDefinition, SettingsProcessor, SettingsUiDefinition, SettingsValidator } from './types'; +import type { FilterType } from '../utilities/filtering'; +import { AdvancedSettingsProvider, IndexedDBProvider, LocalStorageProvider, Providers, type SettingsProvider } from './providers'; + +export {parse as parse_path} from 'utilities/path-parser'; -function postMessage(target, msg) { +function postMessage(target: Window, msg) { try { target.postMessage(msg, '*'); return true; @@ -31,6 +35,28 @@ function postMessage(target, msg) { export const NO_SYNC_KEYS = ['session']; +// TODO: Check settings keys for better typing on events. + +export type SettingsEvents = { + [key: `:changed:${string}`]: [value: any, old_value: any]; + [key: `:uses_changed:${string}`]: [uses: number[], old_uses: number[]]; + + ':added-definition': [key: string, definition: SettingsDefinition]; + ':removed-definition': [key: string, definition: SettingsDefinition]; + + ':quota-exceeded': []; + ':change-provider': []; + + ':ls-update': [key: string, value: any]; + + ':profile-created': [profile: SettingsProfile]; + ':profile-changed': [profile: SettingsProfile]; + ':profile-deleted': [profile: SettingsProfile]; + ':profile-toggled': [profile: SettingsProfile, enabled: boolean]; + ':profiles-reordered': []; +}; + + // ============================================================================ // SettingsManager // ============================================================================ @@ -39,22 +65,62 @@ export const NO_SYNC_KEYS = ['session']; * The SettingsManager module creates all the necessary class instances * required for the settings system to operate, facilitates communication * and discovery, and emits events for other modules to react to. - * @extends Module */ -export default class SettingsManager extends Module { +export default class SettingsManager extends Module<'settings', SettingsEvents> { + + _start_time: number; + + // localStorage Hooks + private __ls_hooked: boolean; + private __ls_scheduled: Set; + private __ls_cache: Map; + private __ls_timer?: ReturnType | null; + + + // Storage of Things + clearables: Record; + filters: Record>; + processors: Record>; + providers: Record; + validators: Record>; + + // Storage of Settings + ui_structures: Map>; + definitions: Map | string[]>; + + // Storage of State + provider: SettingsProvider | null = null; + main_context: SettingsContext; + + private _update_timer?: ReturnType | null; + private _time_timer?: ReturnType | null; + + private _active_provider: string = 'local'; + private _idb: IndexedDBProvider | null = null; + + private _provider_waiter?: Promise | null; + private _provider_resolve?: ((input: SettingsProvider) => void) | null; + + private __contexts: SettingsContext[]; + private __profiles: SettingsProfile[]; + private __profile_ids: Record; + /** - * Create a SettingsManager module. + * Whether or not profiles have been disabled for this session */ - constructor(...args) { - super(...args); + disable_profiles: boolean = false; + + updateSoon: () => void; + + /** @internal */ + constructor(name?: string, parent?: GenericModule) { + super(name, parent); this.providers = {}; - for(const key in PROVIDERS) - if ( has(PROVIDERS, key) ) { - const provider = PROVIDERS[key]; - if ( provider.key && provider.supported(this) ) - this.providers[provider.key] = provider; - } + for(const [key, provider] of Object.entries(Providers)) { + if ( provider.supported() ) + this.providers[key] = provider; + } // This cannot be modified at a future time, as providers NEED // to be ready very early in FFZ intitialization. Seal it. @@ -64,7 +130,7 @@ export default class SettingsManager extends Module { // Do we want to not enable any profiles? try { - const params = new URL(window.location).searchParams; + const params = new URL(window.location as any).searchParams; if ( params ) { if ( params.has('ffz-no-settings') ) this.disable_profiles = true; @@ -115,24 +181,22 @@ export default class SettingsManager extends Module { // Create our provider as early as possible. - this._provider_waiters = []; - this._createProvider().then(provider => { this.provider = provider; this.log.info(`Using Provider: ${provider.constructor.name}`); provider.on('changed', this._onProviderChange, this); - provider.on('quota-exceeded', err => { - this.emit(':quota-exceeded', err); + provider.on('quota-exceeded', (err) => { + this.emit(':quota-exceeded'); }); provider.on('change-provider', () => { this.emit(':change-provider'); }); - for(const waiter of this._provider_waiters) - waiter(provider); + if ( this._provider_resolve ) + this._provider_resolve(provider); }); - this.migrations = new MigrationManager(this); + //this.migrations = new MigrationManager(this); // Also create the main context as early as possible. this.main_context = new SettingsContext(this); @@ -184,7 +248,7 @@ export default class SettingsManager extends Module { } - addFilter(key, data) { + addFilter(key: string, data: FilterType) { if ( this.filters[key] ) return this.log.warn('Tried to add already existing filter', key); @@ -209,8 +273,14 @@ export default class SettingsManager extends Module { if ( this.provider ) return Promise.resolve(this.provider); - return new Promise(s => { - this._provider_waiters.push(s); + if ( this._provider_waiter ) + return this._provider_waiter; + + return this._provider_waiter = new Promise((resolve, reject) => { + this._provider_resolve = resolve; + }).finally(() => { + this._provider_waiter = null; + this._provider_resolve = null; }); } @@ -221,6 +291,9 @@ export default class SettingsManager extends Module { async onEnable() { // Before we do anything else, make sure the provider is ready. await this.awaitProvider(); + if ( ! this.provider ) + throw new Error('did not get provider'); + await this.provider.awaitReady(); // When the router updates we additional routes, make sure to @@ -253,11 +326,14 @@ export default class SettingsManager extends Module { Monitor.details = null; try { - Monitor.details = await window.getScreenDetails(); - Monitor.details.addEventListener('currentscreenchange', () => { - for(const context of this.__contexts) - context.selectProfiles(); - }); + if ( window.getScreenDetails ) { + Monitor.details = await window.getScreenDetails(); + Monitor.details.addEventListener('currentscreenchange', () => { + for(const context of this.__contexts) + context.selectProfiles(); + }); + } else + Monitor.details = false; } catch(err) { this.log.error('Unable to get monitor details', err); @@ -305,7 +381,7 @@ export default class SettingsManager extends Module { // LocalStorage Management // ======================================================================== - _updateLSKey(key) { + private _updateLSKey(key: string) { if ( this.__ls_cache.has(key) || this.__ls_cache.has(`raw.${key}`) ) { this.__ls_scheduled.add(key); if ( ! this.__ls_timer ) @@ -313,7 +389,7 @@ export default class SettingsManager extends Module { } } - _hookLS() { + private _hookLS() { if ( this.__ls_hooked ) return; @@ -336,13 +412,13 @@ export default class SettingsManager extends Module { window.addEventListener('storage', this._handleLSEvent); } - _handleLSEvent(event) { - if ( event.storageArea === localStorage ) + private _handleLSEvent(event: StorageEvent) { + if ( event.key && event.storageArea === localStorage ) this._updateLSKey(event.key); } - _updateLS() { - clearTimeout(this.__ls_timer); + private _updateLS() { + clearTimeout(this.__ls_timer as ReturnType); this.__ls_timer = null; const keys = this.__ls_scheduled; this.__ls_scheduled = new Set; @@ -377,9 +453,9 @@ export default class SettingsManager extends Module { } } - getLS(key) { + getLS(key: string): T | null { if ( this.__ls_cache.has(key) ) - return this.__ls_cache.get(key); + return this.__ls_cache.get(key) as T; if ( ! this.__ls_hooked ) this._hookLS(); @@ -392,7 +468,7 @@ export default class SettingsManager extends Module { value = raw; else try { - value = JSON.parse(raw); + value = raw ? JSON.parse(raw) : null; } catch(err) { this.log.warn(`Unable to parse localStorage value as JSON for "${key}"`, err); } @@ -420,12 +496,15 @@ export default class SettingsManager extends Module { } - async _needsZipBackup() { + private async _needsZipBackup() { // Before we do anything else, make sure the provider is ready. await this.awaitProvider(); + if ( ! this.provider ) + return false; + await this.provider.awaitReady(); - if ( ! this.provider.supportsBlobs ) + if ( !(this.provider instanceof AdvancedSettingsProvider) || ! this.provider.supportsBlobs ) return false; const keys = await this.provider.blobKeys(); @@ -433,9 +512,12 @@ export default class SettingsManager extends Module { } - async _getZipBackup() { + private async _getZipBackup() { // Before we do anything else, make sure the provider is ready. await this.awaitProvider(); + if ( ! this.provider ) + throw new Error('provider not available'); + await this.provider.awaitReady(); // Create our ZIP file. @@ -449,7 +531,7 @@ export default class SettingsManager extends Module { // Blob Settings const metadata = {}; - if ( this.provider.supportsBlobs ) { + if ( this.provider instanceof AdvancedSettingsProvider && this.provider.supportsBlobs ) { const keys = await this.provider.blobKeys(); for(const key of keys) { const safe_key = encodeURIComponent(key), @@ -489,16 +571,19 @@ export default class SettingsManager extends Module { async getSettingsDump() { // Before we do anything else, make sure the provider is ready. await this.awaitProvider(); + if ( ! this.provider ) + return null; + await this.provider.awaitReady(); - const out = { + const out: ExportedFullDump = { version: 2, type: 'full', values: {} }; - for(const [k, v] of this.provider.entries()) - out.values[k] = v; + for(const [key, value] of this.provider.entries()) + out.values[key] = value; return out; } @@ -514,9 +599,9 @@ export default class SettingsManager extends Module { async checkUpdates() { await this.awaitProvider(); - await this.provider.awaitReady(); + await this.provider?.awaitReady(); - if ( ! this.provider.shouldUpdate ) + if ( ! this.provider?.shouldUpdate ) return; const promises = []; @@ -575,9 +660,9 @@ export default class SettingsManager extends Module { wanted = localStorage.ffzProviderv2 = await this.sniffProvider(); if ( this.providers[wanted] ) { - const provider = new this.providers[wanted](this); + const provider = new (this.providers[wanted] as any)(this) as SettingsProvider; if ( wanted === 'idb' ) - this._idb = provider; + this._idb = provider as IndexedDBProvider; this._active_provider = wanted; return provider; @@ -585,7 +670,7 @@ export default class SettingsManager extends Module { // Fallback to localStorage if nothing else was wanted and available. this._active_provider = 'local'; - return new this.providers.local(this); + return new LocalStorageProvider(this); } @@ -599,12 +684,15 @@ export default class SettingsManager extends Module { * @returns {String} The key for which provider we should use. */ async sniffProvider() { - const providers = Object.values(this.providers); - providers.sort((a,b) => b.priority - a.priority); + const providers = Array.from(Object.entries(this.providers)); + providers.sort((a, b) => + ((b[1] as any).priority ?? 0) - + ((a[1] as any).priority ?? 0) + ); - for(const provider of providers) { - if ( provider.supported(this) && provider.hasContent && await provider.hasContent(this) ) // eslint-disable-line no-await-in-loop - return provider.key; + for(const [key, provider] of providers) { + if ( provider.supported() && await provider.hasContent() ) // eslint-disable-line no-await-in-loop + return key; } // Fallback to local if no provider indicated present settings. @@ -620,13 +708,13 @@ export default class SettingsManager extends Module { * @param {Boolean} transfer Whether or not settings should be transferred * from the current provider. */ - async changeProvider(key, transfer) { - if ( ! this.providers[key] || ! this.providers[key].supported(this) ) + async changeProvider(key: string, transfer: boolean) { + if ( ! this.providers[key] || ! this.providers[key].supported() ) throw new Error(`Invalid provider: ${key}`); // If we're changing to the current provider... well, that doesn't make // a lot of sense, does it? Abort! - if ( key === this._active_provider ) + if ( key === this._active_provider || ! this.provider ) return; const old_provider = this.provider; @@ -637,7 +725,7 @@ export default class SettingsManager extends Module { // Are we transfering settings? if ( transfer ) { - const new_provider = new this.providers[key](this); + const new_provider = new (this.providers[key] as any)(this) as SettingsProvider; await new_provider.awaitReady(); if ( new_provider.allowTransfer && old_provider.allowTransfer ) { @@ -645,13 +733,13 @@ export default class SettingsManager extends Module { // When transfering, we clear all existing settings. await new_provider.clear(); - if ( new_provider.supportsBlobs ) + if ( new_provider instanceof AdvancedSettingsProvider && new_provider.supportsBlobs ) await new_provider.clearBlobs(); for(const [key,val] of old_provider.entries()) new_provider.set(key, val); - if ( old_provider.supportsBlobs && new_provider.supportsBlobs ) { + if ( old_provider instanceof AdvancedSettingsProvider && old_provider.supportsBlobs && new_provider instanceof AdvancedSettingsProvider && new_provider.supportsBlobs ) { for(const key of await old_provider.blobKeys() ) { const blob = await old_provider.getBlob(key); // eslint-disable-line no-await-in-loop if ( blob ) @@ -679,7 +767,7 @@ export default class SettingsManager extends Module { * the result of a setting being changed in another tab or, when cloud * settings are enabled, on another computer. */ - _onProviderChange(key, new_value, deleted) { + _onProviderChange(key: string, new_value: any, deleted: boolean) { // If profiles have changed, reload our profiles. if ( key === 'profiles' ) return this.loadProfiles(); @@ -690,17 +778,17 @@ export default class SettingsManager extends Module { // If we're still here, it means an individual setting was changed. // Look up the profile it belongs to and emit a changed event from // that profile, thus notifying any contexts or UI instances. - key = key.substr(2); + key = key.slice(2); // Is it a value? const idx = key.indexOf(':'); if ( idx === -1 ) return; - const profile = this.__profile_ids[key.slice(0, idx)], + const profile = this.__profile_ids[key.slice(0, idx) as any], s_key = key.slice(idx + 1); - if ( profile ) { + if ( profile && ! profile.ephemeral ) { if ( s_key === ':enabled' ) profile.emit('toggled', profile, deleted ? true : new_value); else @@ -716,7 +804,7 @@ export default class SettingsManager extends Module { updateRoutes() { // Clear the existing matchers. for(const profile of this.__profiles) - profile.matcher = null; + profile.clearMatcher(); // And then re-select the active profiles. for(const context of this.__contexts) @@ -726,12 +814,12 @@ export default class SettingsManager extends Module { } - _onProfileToggled(profile, val) { + _onProfileToggled(profile: SettingsProfile, enabled: boolean) { for(const context of this.__contexts) context.selectProfiles(); this.updateClock(); - this.emit(':profile-toggled', profile, val); + this.emit(':profile-toggled', profile, enabled); } @@ -739,8 +827,8 @@ export default class SettingsManager extends Module { * Get an existing {@link SettingsProfile} instance. * @param {number} id - The id of the profile. */ - profile(id) { - return this.__profile_ids[id] || null; + profile(id: number): SettingsProfile | null { + return this.__profile_ids[id] ?? null; } @@ -748,12 +836,12 @@ export default class SettingsManager extends Module { * Build {@link SettingsProfile} instances for all of the profiles * defined in storage, re-using existing instances when possible. */ - loadProfiles(suppress_events) { + loadProfiles(suppress_events: boolean = false) { const old_profile_ids = this.__profile_ids, old_profiles = this.__profiles, - profile_ids = this.__profile_ids = {}, - profiles = this.__profiles = [], + profile_ids: Record = this.__profile_ids = {}, + profiles: SettingsProfile[] = this.__profiles = [], // Create a set of actual IDs with a map from the profiles // list rather than just getting the keys from the ID map @@ -761,17 +849,17 @@ export default class SettingsManager extends Module { // to keys. old_ids = new Set(old_profiles.map(x => x.id)), - new_ids = new Set, - changed_ids = new Set; + new_ids = new Set, + changed_ids = new Set; - let raw_profiles = this.provider.get('profiles', [ - SettingsProfile.Moderation, - SettingsProfile.Default - ]); + let raw_profiles = this.provider?.get('profiles') ?? [ + SettingsProfile.Moderation, + SettingsProfile.Default + ]; // Sanity check. If we have no profiles, delete the old data. if ( ! raw_profiles?.length ) { - this.provider.delete('profiles'); + this.provider?.delete('profiles'); raw_profiles = [ SettingsProfile.Moderation, SettingsProfile.Default @@ -787,7 +875,7 @@ export default class SettingsManager extends Module { } for(const profile_data of raw_profiles) { - const id = profile_data.id, + const id = profile_data.id as number, slot_id = profiles.length, old_profile = old_profile_ids[id], old_slot_id = old_profile ? old_profiles.indexOf(old_profile) : -1; @@ -798,12 +886,15 @@ export default class SettingsManager extends Module { reordered = true; // Monkey patch to the new profile format... + // Update: Probably safe to remove this, at this point. + /* if ( profile_data.context && ! Array.isArray(profile_data.context) ) { if ( profile_data.context.moderator ) profile_data.context = SettingsProfile.Moderation.context; else profile_data.context = null; } + */ if ( old_profile && deep_equals(old_profile.data, profile_data, true) ) { // Did the order change? @@ -816,10 +907,7 @@ export default class SettingsManager extends Module { const new_profile = profile_ids[id] = new SettingsProfile(this, profile_data); if ( old_profile ) { - // Move all the listeners over. - new_profile.__listeners = old_profile.__listeners; - old_profile.__listeners = {}; - + old_profile.transferListeners(new_profile); changed_ids.add(id); } else @@ -856,29 +944,37 @@ export default class SettingsManager extends Module { /** * Create a new profile and return the {@link SettingsProfile} instance * representing it. - * @returns {SettingsProfile} */ - createProfile(options) { + createProfile(options: Partial = {}) { if ( ! this.enabled ) throw new Error('Unable to create profile before settings have initialized. Please await enable()'); - let i = 0; - while( this.__profile_ids[i] ) - i++; + if ( options.id !== undefined ) + throw new Error('You cannot specify an ID when creating a profile.'); - options = options || {}; - options.id = i; + let id = 0; + + // Find the next available profile ID. + while ( this.__profile_ids[id] ) { + // Ephemeral profiles have negative IDs. + options.ephemeral ? id-- : id++; + } + + options.id = id; if ( ! options.name ) - options.name = `Unnamed Profile ${i}`; + options.name = `Unnamed Profile ${this.__profiles.length + 1}`; - const profile = this.__profile_ids[i] = new SettingsProfile(this, options); + const profile = this.__profile_ids[id] = new SettingsProfile(this, options); this.__profiles.unshift(profile); profile.on('toggled', this._onProfileToggled, this); profile.hotkey_enabled = true; - this._saveProfiles(); + // Don't bother saving if it's ephemeral. + if ( ! profile.ephemeral ) + this._saveProfiles(); + this.emit(':profile-created', profile); return profile; } @@ -886,14 +982,17 @@ export default class SettingsManager extends Module { /** * Delete a profile. - * @param {number|SettingsProfile} id - The profile to delete + * + * @param id - The ID of the profile to delete, or just the profile itself. */ - deleteProfile(id) { + deleteProfile(id: number | SettingsProfile) { if ( ! this.enabled ) throw new Error('Unable to delete profile before settings have initialized. Please await enable()'); - if ( typeof id === 'object' && id.id != null ) + if ( typeof id === 'object' && typeof id.id === 'number' ) id = id.id; + else if ( typeof id !== 'number' ) + throw new Error('Invalid profile'); const profile = this.__profile_ids[id]; if ( ! profile ) @@ -913,17 +1012,22 @@ export default class SettingsManager extends Module { if ( idx !== -1 ) this.__profiles.splice(idx, 1); - this._saveProfiles(); + // If it wasn't an ephemeral profile, go ahead and update. + if ( ! profile.ephemeral ) + this._saveProfiles(); + this.emit(':profile-deleted', profile); } - moveProfile(id, index) { + moveProfile(id: number | SettingsProfile, index: number) { if ( ! this.enabled ) throw new Error('Unable to move profiles before settings have initialized. Please await enable()'); - if ( typeof id === 'object' && id.id ) + if ( typeof id === 'object' && typeof id.id === 'number' ) id = id.id; + else if ( typeof id !== 'number' ) + throw new Error('Invalid profile'); const profile = this.__profile_ids[id]; if ( ! profile ) @@ -936,29 +1040,39 @@ export default class SettingsManager extends Module { profiles.splice(index, 0, ...profiles.splice(idx, 1)); - this._saveProfiles(); + // If it wasn't an ephemeral profile, go ahead and update. + if ( ! profile.ephemeral ) + this._saveProfiles(); + this.emit(':profiles-reordered'); } - saveProfile(id) { + saveProfile(id: number | SettingsProfile) { if ( ! this.enabled ) throw new Error('Unable to save profile before settings have initialized. Please await enable()'); - if ( typeof id === 'object' && id.id ) + if ( typeof id === 'object' && typeof id.id === 'number' ) id = id.id; + else if ( typeof id !== 'number' ) + throw new Error('Invalid profile'); const profile = this.__profile_ids[id]; if ( ! profile ) return; - this._saveProfiles(); + // If it wasn't an ephemeral profile, go ahead and update. + if ( ! profile.ephemeral ) + this._saveProfiles(); + this.emit(':profile-changed', profile); } _saveProfiles() { - const out = this.__profiles.filter(prof => ! prof.ephemeral).map(prof => prof.data); + const out = this.__profiles + .filter(prof => ! prof.ephemeral) + .map(prof => prof.data); // Ensure that we always have a non-ephemeral profile. if ( ! out ) { @@ -967,10 +1081,12 @@ export default class SettingsManager extends Module { i18n_key: 'setting.profiles.default', description: 'Settings that apply everywhere on Twitch.' }); + + // Just return. Creating the profile will call this method again. return; } - this.provider.set('profiles', out); + this.provider?.set('profiles', out); for(const context of this.__contexts) context.selectProfiles(); @@ -986,18 +1102,18 @@ export default class SettingsManager extends Module { get(key) { return this.main_context.get(key); } getChanges(key, fn, ctx) { return this.main_context.getChanges(key, fn, ctx); } onChange(key, fn, ctx) { return this.main_context.onChange(key, fn, ctx); } - uses(key) { return this.main_context.uses(key) } - update(key) { return this.main_context.update(key) } + uses(key: string) { return this.main_context.uses(key) } + update(key: string) { return this.main_context.update(key) } - updateContext(context) { return this.main_context.updateContext(context) } - setContext(context) { return this.main_context.setContext(context) } + updateContext(context: Partial) { return this.main_context.updateContext(context) } + setContext(context: Partial) { return this.main_context.setContext(context) } // ======================================================================== // Add-On Proxy // ======================================================================== - getAddonProxy(addon_id) { + getAddonProxy(addon_id: string) { if ( ! addon_id ) return this; @@ -1030,31 +1146,26 @@ export default class SettingsManager extends Module { // Definitions // ======================================================================== - add(key, definition, source) { - if ( typeof key === 'object' ) { - for(const k in key) - if ( has(key, k) ) - this.add(k, key[k]); - return; - } + add(key: string, definition: SettingsDefinition, source?: string) { const old_definition = this.definitions.get(key), - required_by = old_definition ? - (Array.isArray(old_definition) ? old_definition : old_definition.required_by) : []; + required_by = (Array.isArray(old_definition) + ? old_definition + : old_definition?.required_by) ?? []; definition.required_by = required_by; - definition.requires = definition.requires || []; + definition.requires = definition.requires ?? []; definition.__source = source; for(const req_key of definition.requires) { const req = this.definitions.get(req_key); - if ( ! req ) - this.definitions.set(req_key, [key]); - else if ( Array.isArray(req) ) + if ( Array.isArray(req) ) req.push(key); + else if ( req ) + req.required_by?.push(key); else - req.required_by.push(key); + this.definitions.set(req_key, [key]); } @@ -1094,7 +1205,7 @@ export default class SettingsManager extends Module { } - remove(key) { + remove(key: string) { const definition = this.definitions.get(key); if ( ! definition ) return; @@ -1162,17 +1273,18 @@ export default class SettingsManager extends Module { } - addClearable(key, definition, source) { + addClearable(key: string | Record, definition?: SettingsClearable, source?: string) { if ( typeof key === 'object' ) { - for(const k in key) - if ( has(key, k) ) - this.addClearable(k, key[k], source); + for(const [k, value] of Object.entries(key)) + this.addClearable(k, value, source); return; + } else if ( typeof key !== 'string' ) + throw new Error('invalid key'); + + if ( definition ) { + definition.__source = source; + this.clearables[key] = definition; } - - definition.__source = source; - - this.clearables[key] = definition; } getClearables() { @@ -1180,39 +1292,40 @@ export default class SettingsManager extends Module { } - addProcessor(key, fn) { + addProcessor(key: string | Record>, processor?: SettingsProcessor) { if ( typeof key === 'object' ) { - for(const k in key) - if ( has(key, k) ) - this.addProcessor(k, key[k]); + for(const [k, value] of Object.entries(key)) + this.addProcessor(k, value); return; - } + } else if ( typeof key !== 'string' ) + throw new Error('invalid key'); - this.processors[key] = fn; + if ( processor ) + this.processors[key] = processor; } - getProcessor(key) { - return this.processors[key]; + getProcessor(key: string): SettingsProcessor | null { + return this.processors[key] ?? null; } getProcessors() { return deep_copy(this.processors); } - - addValidator(key, fn) { + addValidator(key: string | Record>, validator?: SettingsValidator) { if ( typeof key === 'object' ) { - for(const k in key) - if ( has(key, k) ) - this.addValidator(k, key[k]); + for(const [k, value] of Object.entries(key)) + this.addValidator(k, value); return; - } + } else if ( typeof key !== 'string' ) + throw new Error('invalid key'); - this.validators[key] = fn; + if ( validator ) + this.validators[key] = validator; } - getValidator(key) { - return this.validators[key]; + getValidator(key: string): SettingsValidator | null { + return this.validators[key] ?? null; } getValidators() { @@ -1221,42 +1334,6 @@ export default class SettingsManager extends Module { } -export function parse_path(path) { - return new_parse(path); -} - - -/*const PATH_SPLITTER = /(?:^|\s*([~>]+))\s*([^~>@]+)\s*(?:@([^~>]+))?/g; - -export function old_parse_path(path) { - const tokens = []; - let match; - - while((match = PATH_SPLITTER.exec(path))) { - const page = match[1] === '>>', - tab = match[1] === '~>', - title = match[2].trim(), - key = title.toSnakeCase(), - options = match[3], - - opts = { key, title, page, tab }; - - if ( options ) { - try { - Object.assign(opts, JSON.parse(options)); - } catch(err) { - console.warn('Matched segment:', options); - throw err; - } - } - - tokens.push(opts); - } - - return tokens; -}*/ - - export function format_path_tokens(tokens) { for(let i=0, l = tokens.length; i < l; i++) { const token = tokens[i]; @@ -1274,4 +1351,4 @@ export function format_path_tokens(tokens) { } return tokens; -} \ No newline at end of file +} diff --git a/src/settings/migration.js b/src/settings/migration.js deleted file mode 100644 index de997ccf..00000000 --- a/src/settings/migration.js +++ /dev/null @@ -1,16 +0,0 @@ -'use strict'; - -// ============================================================================ -// Settings Migrations -// ============================================================================ - -export default class MigrationManager { - constructor(manager) { - this.manager = manager; - this.provider = manager.provider; - } - - process() { // eslint-disable-line class-methods-use-this - throw new Error('Not Implemented'); - } -} \ No newline at end of file diff --git a/src/settings/processors.js b/src/settings/processors.js deleted file mode 100644 index 66c87025..00000000 --- a/src/settings/processors.js +++ /dev/null @@ -1,49 +0,0 @@ -'use strict'; - -const BAD = Symbol('BAD'); - -const do_number = (val, default_value, def) => { - if ( typeof val !== 'number' || isNaN(val) || ! isFinite(val) ) - val = BAD; - - if ( val !== BAD ) { - const bounds = def.bounds; - if ( Array.isArray(bounds) ) { - if ( bounds.length >= 3 ) { - // [low, inclusive, high, inclusive] - if ( (bounds[1] ? (val < bounds[0]) : (val <= bounds[0])) || - (bounds[3] ? (val > bounds[2]) : (val >= bounds[2])) ) - val = BAD; - - } else if ( bounds.length === 2 ) { - // [low, inclusive] or [low, high] ? - if ( typeof bounds[1] === 'boolean' ) { - if ( bounds[1] ? val < bounds[0] : val <= bounds[0] ) - val = BAD; - } else if ( val < bounds[0] || val > bounds[1] ) - val = BAD; - } else if ( bounds.length === 1 && val < bounds[0] ) - val = BAD; - } - } - - return val === BAD ? default_value : val; -} - -export const to_int = (val, default_value, def) => { - if ( typeof val === 'string' && ! /^-?\d+$/.test(val) ) - val = BAD; - else - val = parseInt(val, 10); - - return do_number(val, default_value, def); -} - -export const to_float = (val, default_value, def) => { - if ( typeof val === 'string' && ! /^-?[\d.]+$/.test(val) ) - val = BAD; - else - val = parseFloat(val); - - return do_number(val, default_value, def); -} \ No newline at end of file diff --git a/src/settings/processors.ts b/src/settings/processors.ts new file mode 100644 index 00000000..5ec7bf0f --- /dev/null +++ b/src/settings/processors.ts @@ -0,0 +1,66 @@ +'use strict'; + +import type { SettingsDefinition, SettingsProcessor, SettingsUiDefinition } from "./types"; + +const BAD = Symbol('BAD'); +type BadType = typeof BAD; + +function do_number( + input: number | BadType, + default_value: number, + definition: SettingsUiDefinition +) { + if ( typeof input !== 'number' || isNaN(input) || ! isFinite(input) ) + input = BAD; + + if ( input !== BAD ) { + const bounds = definition.bounds; + if ( Array.isArray(bounds) ) { + if ( bounds.length >= 3 ) { + // [low, inclusive, high, inclusive] + if ( (bounds[1] ? (input < bounds[0]) : (input <= bounds[0])) || + // TODO: Figure out why it doesn't like bounds[2] but bounds[3] is okay + (bounds[3] ? (input > (bounds as any)[2]) : (input >= (bounds as any)[2])) ) + input = BAD; + + } else if ( bounds.length === 2 ) { + // [low, inclusive] or [low, high] ? + if ( typeof bounds[1] === 'boolean' ) { + if ( bounds[1] ? input < bounds[0] : input <= bounds[0] ) + input = BAD; + } else if ( input < bounds[0] || input > bounds[1] ) + input = BAD; + } else if ( bounds.length === 1 && input < bounds[0] ) + input = BAD; + } + } + + return input === BAD ? default_value : input; +} + +export const to_int: SettingsProcessor = ( + value, + default_value, + definition +) => { + if ( typeof value === 'string' && /^-?\d+$/.test(value) ) + value = parseInt(value, 10); + else if ( typeof value !== 'number' ) + value = BAD; + + return do_number(value as number, default_value, definition); +} + +export const to_float: SettingsProcessor = ( + value: unknown, + default_value, + definition +) => { + if ( typeof value === 'string' && /^-?[\d.]+$/.test(value) ) + value = parseFloat(value); + else if ( typeof value !== 'number' ) + value = BAD; + + return do_number(value as number, default_value, definition); +} + diff --git a/src/settings/profile.js b/src/settings/profile.ts similarity index 50% rename from src/settings/profile.js rename to src/settings/profile.ts index 0009dae2..31919d52 100644 --- a/src/settings/profile.js +++ b/src/settings/profile.ts @@ -5,18 +5,151 @@ // ============================================================================ import {EventEmitter} from 'utilities/events'; -import {isValidShortcut, has} from 'utilities/object'; -import {createTester} from 'utilities/filtering'; +import {isValidShortcut, fetchJSON} from 'utilities/object'; +import {FilterData, createTester} from 'utilities/filtering'; +import type SettingsManager from '.'; +import type { SettingsProvider } from './providers'; +import type { ContextData, ExportedSettingsProfile, SettingsProfileMetadata } from './types'; +import type { Mousetrap } from '../utilities/types'; + +declare global { + interface Window { + Mousetrap?: Mousetrap; + } +} + + +export type ProfileEvents = { + 'toggled': [profile: SettingsProfile, enabled: boolean]; + 'changed': [key: string, value: unknown, deleted: boolean]; +} -const fetchJSON = (url, options) => fetch(url, options).then(r => r.ok ? r.json() : null).catch(() => null); /** * Instances of SettingsProfile are used for getting and setting raw settings * values, enumeration, and emit events when the raw settings are changed. * @extends EventEmitter */ -export default class SettingsProfile extends EventEmitter { - constructor(manager, data) { +export default class SettingsProfile extends EventEmitter { + + static Default: Partial = { + id: 0, + name: 'Default Profile', + i18n_key: 'setting.profiles.default', + + description: 'Settings that apply everywhere on Twitch.' + }; + + + static Moderation: Partial = { + id: 1, + name: 'Moderation', + i18n_key: 'setting.profiles.moderation', + + description: 'Settings that apply when you are a moderator of the current channel.', + + context: [ + { + type: 'Moderator', + data: true + } + ] + }; + + + private manager: SettingsManager; + private provider: SettingsProvider; + + private prefix: string; + private enabled_key: string; + + private _enabled: boolean = false; + private _storage?: Map; + + /** + * If this is true, the profile will not be persisted and the user will + * not be able to edit it. + */ + ephemeral: boolean = false; + + /** + * The ID number for this profile. ID numbers may be recycled as profiles + * are deleted and created. + */ + id: number = -1; + + // Metadata + + /** + * The name of this profile. A human-readable string that may be edited + * by the user. + */ + name: string = null as any; + + /** + * The localization key for the name of this profile. If this is set, + * the name will be localized. If this is not set, the name will be + * displayed as-is. This value is cleared if the user edits the name. + */ + i18n_key?: string | null; + + /** + * The description of this profile. A human-readable string that may + * be edited by the user. + */ + description?: string | null; + + /** + * The localization key for the description of this profile. If this + * is set, the description will be localized. If this is not set, the + * description will be displayed as-is. This value is cleared if + * the user edits the description. + */ + desc_i18n_key?: string | null; + + /** + * A URL for this profile. If this is set, the profile will potentially + * be automatically updated from the URL. + */ + url?: string | null; + + /** + * Whether or not automatic updates should be processed. If this is + * set to true, the profile will not be automatically updated. + */ + pause_updates: boolean = false; + + // TODO: Document, check default value + show_toggle: boolean = false; + + + // Profile Rules + + context?: FilterData[]; + + + // Hotkey Stuff + + /** + * A user-set hotkey for toggling this profile on or off. + * @see {@link hotkey} + */ + private _hotkey?: string | null; + + /** + * Whether or not the hotkey is currently enabled. + * @see {@link hotkey_enabled} + */ + private _hotkey_enabled?: boolean = false; + + private _bound_key?: string | null; + private Mousetrap?: Mousetrap; + + + private matcher?: ((ctx: ContextData) => boolean) | null; + + + constructor(manager: SettingsManager, data: Partial) { super(); this.onShortcut = this.onShortcut.bind(this); @@ -35,10 +168,10 @@ export default class SettingsProfile extends EventEmitter { } } - get data() { + get data(): Partial { return { id: this.id, - parent: this.parent, + //parent: this.parent, name: this.name, i18n_key: this.i18n_key, @@ -61,20 +194,32 @@ export default class SettingsProfile extends EventEmitter { if ( typeof val !== 'object' ) throw new TypeError('data must be an object'); - this.matcher = null; + this.clearMatcher(); // Make sure ephemeral is set first. if ( val.ephemeral ) this.ephemeral = true; - for(const key in val) - if ( has(val, key) ) - this[key] = val[key]; + // Copy the values to this profile. + for(const [key, value] of Object.entries(val)) + (this as any)[key] = value; } - matches(context) { + + clearMatcher() { + this.matcher = null; + } + + + matches(context: ContextData) { if ( ! this.matcher ) - this.matcher = createTester(this.context, this.manager.filters, false, false, () => this.manager.updateSoon()); + this.matcher = createTester( + this.context, + this.manager.filters, + false, + false, + () => this.manager.updateSoon() + ); return this.matcher(context); } @@ -86,8 +231,8 @@ export default class SettingsProfile extends EventEmitter { } - getBackup() { - const out = { + getBackup(): ExportedSettingsProfile { + const out: ExportedSettingsProfile = { version: 2, type: 'profile', profile: this.data, @@ -97,8 +242,8 @@ export default class SettingsProfile extends EventEmitter { delete out.profile.ephemeral; - for(const [k,v] of this.entries()) - out.values[k] = v; + for(const [key, value] of this.entries()) + out.values[key] = value; return out; } @@ -108,8 +253,8 @@ export default class SettingsProfile extends EventEmitter { if ( ! this.url || this.pause_updates ) return false; - const data = await fetchJSON(this.url); - if ( ! data || ! data.type === 'profile' || ! data.profile || ! data.values ) + const data = await fetchJSON(this.url); + if ( ! data || data.type !== 'profile' || ! data.profile || ! data.values ) return false; // We don't want to override general settings. @@ -186,12 +331,12 @@ export default class SettingsProfile extends EventEmitter { } } - onShortcut(e) { + onShortcut(event: KeyboardEvent) { this.toggled = ! this.toggled; - if ( e ) { - e.stopPropagation(); - e.preventDefault(); + if ( event ) { + event.stopPropagation(); + event.preventDefault(); } } @@ -223,22 +368,24 @@ export default class SettingsProfile extends EventEmitter { // Context // ======================================================================== + // wtf is this method context is an array yo + /* updateContext(context) { if ( this.id === 0 ) throw new Error('cannot set context of default profile'); this.context = Object.assign(this.context || {}, context); this.matcher = null; - this.manager._saveProfiles(); - } + this.save(); + }*/ - setContext(context) { + setContext(context?: FilterData[]) { if ( this.id === 0 ) throw new Error('cannot set context of default profile'); this.context = context; - this.matcher = null; - this.manager._saveProfiles(); + this.clearMatcher(); + this.save(); } @@ -246,37 +393,48 @@ export default class SettingsProfile extends EventEmitter { // Setting Access // ======================================================================== - get(key, default_value) { - if ( this.ephemeral ) - return this._storage.get(key, default_value); - return this.provider.get(this.prefix + key, default_value); + get(key: string, default_value: T): T; + get(key: string): T | null; + + get(key: string, default_value?: T): T | null { + if ( this.ephemeral ) { + if ( this._storage && this._storage.has(key) ) + return this._storage.get(key) as T; + + return default_value ?? null; + } + + return this.provider.get(this.prefix + key, default_value as T); } - set(key, value) { - if ( this.ephemeral ) - this._storage.set(key, value); - else + set(key: string, value: unknown) { + if ( this.ephemeral ) { + if ( this._storage ) + this._storage.set(key, value); + } else this.provider.set(this.prefix + key, value); - this.emit('changed', key, value); + + this.emit('changed', key, value, false); } - delete(key) { - if ( this.ephemeral ) - this._storage.delete(key); - else + delete(key: string) { + if ( this.ephemeral ) { + if ( this._storage ) + this._storage.delete(key); + } else this.provider.delete(this.prefix + key); this.emit('changed', key, undefined, true); } - has(key) { + has(key: string) { if ( this.ephemeral ) - return this._storage.has(key); + return this._storage ? this._storage.has(key): false; return this.provider.has(this.prefix + key); } keys() { if ( this.ephemeral ) - return Array.from(this._storage.keys()); + return this._storage ? Array.from(this._storage.keys()) : []; const out = [], p = this.prefix, @@ -291,11 +449,14 @@ export default class SettingsProfile extends EventEmitter { clear() { if ( this.ephemeral ) { - const keys = this.keys(); - this._storage.clear(); - for(const key of keys) { - this.emit('changed', key, undefined, true); + if ( this._storage ) { + const keys = this.keys(); + this._storage.clear(); + for(const key of keys) { + this.emit('changed', key, undefined, true); + } } + return; } @@ -310,22 +471,26 @@ export default class SettingsProfile extends EventEmitter { *entries() { if ( this.ephemeral ) { - for(const entry of this._storage.entries()) - yield entry; + if ( this._storage ) { + for(const entry of this._storage.entries()) + yield entry; + } } else { const p = this.prefix, len = p.length; for(const key of this.provider.keys()) - if ( key.startsWith(p) && key !== this.enabled_key ) - yield [key.slice(len), this.provider.get(key)]; + if ( key.startsWith(p) && key !== this.enabled_key ) { + const out: [string, unknown] = [key.slice(len), this.provider.get(key)]; + yield out; + } } } get size() { if ( this.ephemeral ) - return this._storage.size; + return this._storage ? this._storage.size : 0; const p = this.prefix; let count = 0; @@ -337,28 +502,3 @@ export default class SettingsProfile extends EventEmitter { return count; } } - - -SettingsProfile.Default = { - id: 0, - name: 'Default Profile', - i18n_key: 'setting.profiles.default', - - description: 'Settings that apply everywhere on Twitch.' -} - - -SettingsProfile.Moderation = { - id: 1, - name: 'Moderation', - i18n_key: 'setting.profiles.moderation', - - description: 'Settings that apply when you are a moderator of the current channel.', - - context: [ - { - type: 'Moderator', - data: true - } - ] -} \ No newline at end of file diff --git a/src/settings/providers.js b/src/settings/providers.ts similarity index 58% rename from src/settings/providers.js rename to src/settings/providers.ts index 3a644aa8..d3fd74c6 100644 --- a/src/settings/providers.js +++ b/src/settings/providers.ts @@ -1,20 +1,38 @@ 'use strict'; -import { isValidBlob, deserializeBlob, serializeBlob } from 'utilities/blobs'; -import { EXTENSION } from 'utilities/constants'; +import { isValidBlob, deserializeBlob, serializeBlob, BlobLike, SerializedBlobLike } from 'utilities/blobs'; + // ============================================================================ // Settings Providers // ============================================================================ import {EventEmitter} from 'utilities/events'; -import {has} from 'utilities/object'; +import {has, once} from 'utilities/object'; +import type SettingsManager from '.'; +import type { OptionalArray, OptionalPromise } from '../utilities/types'; const DB_VERSION = 1, NOT_WWW_TWITCH = window.location.host !== 'www.twitch.tv', NOT_WWW_YT = window.location.host !== 'www.youtube.com'; +// ============================================================================ +// Types +// ============================================================================ + +type ProviderEvents = { + 'change-provider': []; + 'set': [key: string, value: any, deleted: boolean]; + 'changed': [key: string, value: any, deleted: boolean]; + 'quota-exceeded': [error: any]; + + 'set-blob': [key: string, value: BlobLike | undefined, deleted: boolean]; + 'changed-blob': [key: string, deleted: boolean]; + 'clear-blobs': []; +} + + // ============================================================================ // SettingsProvider // ============================================================================ @@ -23,15 +41,28 @@ const DB_VERSION = 1, * Base class for providers for the settings system. A provider is in charge * of reading and writing values from storage as well as sending events to * the {@link SettingsManager} when a value is changed remotely. - * - * @extends EventEmitter */ -export class SettingsProvider extends EventEmitter { +export abstract class SettingsProvider extends EventEmitter { + + // Static Stuff + + static priority: number = 0; + static title: string; + static description: string; + + static hasContent: () => OptionalPromise; + + + manager: SettingsManager; + disabled: boolean; + + protected ready: boolean = false; + /** * Create a new SettingsProvider * @param {SettingsManager} manager - The manager that owns this provider. */ - constructor(manager) { + constructor(manager: SettingsManager) { super(); this.manager = manager; @@ -42,7 +73,6 @@ export class SettingsProvider extends EventEmitter { return false; } - static supportsBlobs = false; static allowTransfer = true; static shouldUpdate = true; @@ -53,45 +83,93 @@ export class SettingsProvider extends EventEmitter { return Promise.reject(new Error('Not Implemented')); } - get allowTransfer() { return this.constructor.allowTransfer; } - get shouldUpdate() { return this.constructor.shouldUpdate; } + get allowTransfer() { + return (this.constructor as typeof SettingsProvider).allowTransfer; + } - broadcastTransfer() { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this - disableEvents() { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this + get shouldUpdate() { + return (this.constructor as typeof SettingsProvider).shouldUpdate; + } - async flush() { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this, require-await + abstract broadcastTransfer(): void; + abstract disableEvents(): void; - get(key, default_value) { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this, no-unused-vars - set(key, value) { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this, no-unused-vars - delete(key) { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this, no-unused-vars - clear() { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this + abstract flush(): OptionalPromise; - has(key) { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this, no-unused-vars + abstract get(key: string, default_value: T): T; + abstract get(key: string): T | null; - keys() { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this - entries() { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this - get size() { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this + abstract set(key: string, value: any): void; + abstract delete(key: string): void; + abstract clear(): void; - get supportsBlobs() { return this.constructor.supportsBlobs; } // eslint-disable-line class-methods-use-this + abstract has(key: string): boolean; - isValidBlob(blob) { return this.supportsBlobs && isValidBlob(blob) } + abstract keys(): Iterable; + abstract entries(): Iterable<[string, any]>; - async getBlob(key) { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this, no-unused-vars, require-await - async setBlob(key, value) { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this, no-unused-vars, require-await - async deleteBlob(key) { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this, no-unused-vars, require-await - async hasBlob(key) { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this, no-unused-vars, require-await - async clearBlobs() { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this, no-unused-vars, require-await - async blobKeys() { throw new Error('Not Implemented') } // eslint-disable-line class-methods-use-this, no-unused-vars, require-await + abstract get size(): number; } +export abstract class AdvancedSettingsProvider extends SettingsProvider { + + get supportsBlobs() { return true; } + + isValidBlob(blob: any): blob is BlobLike { + return this.supportsBlobs && isValidBlob(blob); + } + + abstract getBlob(key: string): Promise; + abstract setBlob(key: string, value: BlobLike): Promise; + abstract deleteBlob(key: string): Promise; + abstract hasBlob(key: string): Promise; + abstract clearBlobs(): Promise; + abstract blobKeys(): Promise>; + +} + + + + // ============================================================================ // LocalStorage // ============================================================================ export class LocalStorageProvider extends SettingsProvider { - constructor(manager, prefix) { + + // Static Stuff + static priority = -1000; + static title = 'Local Storage'; + static description = '[Local Storage](https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API) is available on all platforms and fast to access, but has poorly defined capacity limits and may be cleared unexpectedly. Particularly, clearing cookies and cache in your browser will likely clear Local Storage as well.'; + + // All environments that support FFZ support LocalStorage. + static supported() { + return true; + } + + static hasContent(prefix?: string) { + if ( ! prefix ) + prefix = 'FFZ:setting:'; + + for(const key in localStorage) + if ( key.startsWith(prefix) && has(localStorage, key) ) + return true; + + return false; + } + + // Config and Storage + readonly prefix: string; + private _cached: Map; + + // Event Handling + private _broadcaster?: BroadcastChannel | null; + private _boundHandleMessage?: ((event: MessageEvent) => void) | null; + private _boundHandleStorage?: ((event: StorageEvent) => void) | null; + + constructor(manager: SettingsManager, prefix?: string) { super(manager); this.prefix = prefix = prefix == null ? 'FFZ:setting:' : prefix; @@ -101,11 +179,12 @@ export class LocalStorageProvider extends SettingsProvider { for(const key in localStorage) if ( has(localStorage, key) && key.startsWith(prefix) ) { const val = localStorage.getItem(key); - try { - cache.set(key.slice(len), JSON.parse(val)); - } catch(err) { - this.manager.log.warn(`unable to parse value for ${key}`, val); - } + if ( val ) + try { + cache.set(key.slice(len), JSON.parse(val)); + } catch(err) { + this.manager.log.warn(`unable to parse value for ${key}`, val); + } } this.ready = true; @@ -125,9 +204,10 @@ export class LocalStorageProvider extends SettingsProvider { this.broadcast({type: 'change-provider'}); } - disableEvents() { + private removeListeners() { if ( this._broadcaster ) { - this._broadcaster.removeEventListener('message', this._boundHandleMessage); + if ( this._boundHandleMessage ) + this._broadcaster.removeEventListener('message', this._boundHandleMessage); this._broadcaster.close(); this._boundHandleMessage = this._broadcaster = null; } @@ -136,62 +216,36 @@ export class LocalStorageProvider extends SettingsProvider { window.removeEventListener('storage', this._boundHandleStorage); this._boundHandleStorage = null; } + } + + disableEvents() { + this.removeListeners(); this.broadcast = () => {}; this.emit = () => {}; } - - static key = 'local'; - static priority = -1000; - static title = 'Local Storage'; - static description = '[Local Storage](https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API) is available on all platforms and fast to access, but has poorly defined capacity limits and may be cleared unexpectedly. Particularly, clearing cookies and cache in your browser will likely clear Local Storage as well.'; - - // All environments that support FFZ support LocalStorage. - static supported() { - return true; - } - - static hasContent(prefix) { - if ( ! prefix ) - prefix = 'FFZ:setting:'; - - for(const key in localStorage) - if ( key.startsWith(prefix) && has(localStorage, key) ) - return true; - } - destroy() { this.disable(); this._cached.clear(); } disable() { + this.removeListeners(); this.disabled = true; - - if ( this._broadcaster ) { - this._broadcaster.removeEventListener('message', this._boundHandleMessage); - this._broadcaster.close(); - this._boundHandleMessage = this._broadcaster = null; - } - - if ( this._boundHandleStorage ) { - window.removeEventListener('storage', this._boundHandleStorage); - this._boundHandleStorage = null; - } } flush() { /* no-op */ } // eslint-disable-line class-methods-use-this - broadcast(msg) { + broadcast(msg: any) { if ( this._broadcaster ) this._broadcaster.postMessage(msg); } - handleMessage(event) { + handleMessage(event: MessageEvent) { if ( this.disabled || ! event.isTrusted || ! event.data ) return; @@ -205,7 +259,7 @@ export class LocalStorageProvider extends SettingsProvider { this.disableEvents(); } else if ( type === 'set' ) { - const val = JSON.parse(localStorage.getItem(this.prefix + key)); + const val = JSON.parse(localStorage.getItem(this.prefix + key) ?? 'null'); this._cached.set(key, val); this.emit('changed', key, val, false); @@ -222,7 +276,7 @@ export class LocalStorageProvider extends SettingsProvider { } - handleStorage(event) { + handleStorage(event: StorageEvent) { if ( this.disabled ) return; @@ -230,7 +284,7 @@ export class LocalStorageProvider extends SettingsProvider { if ( event.storageArea !== localStorage ) return; - if ( event.key.startsWith(this.prefix) ) { + if ( event.key && event.key.startsWith(this.prefix) ) { // If value is null, the key was deleted. const key = event.key.slice(this.prefix.length); let val = event.newValue; @@ -248,13 +302,13 @@ export class LocalStorageProvider extends SettingsProvider { } - get(key, default_value) { - return this._cached.has(key) ? - this._cached.get(key) : - default_value; + get(key: string, default_value?: T): T { + return this._cached.has(key) + ? this._cached.get(key) + : default_value; } - set(key, value) { + set(key: string, value: any) { if ( this.disabled ) return; @@ -271,7 +325,7 @@ export class LocalStorageProvider extends SettingsProvider { if ( this.manager ) this.manager.log.error(`An error occurred while trying to save a value to localStorage for key "${this.prefix + key}"`); - if ( /quota/i.test(err.toString()) ) + if ( err && /quota/i.test(err.toString()) ) this.emit('quota-exceeded', err); throw err; @@ -281,7 +335,7 @@ export class LocalStorageProvider extends SettingsProvider { this.emit('set', key, value, false); } - delete(key) { + delete(key: string) { if ( this.disabled ) return; @@ -291,7 +345,7 @@ export class LocalStorageProvider extends SettingsProvider { this.emit('set', key, undefined, true); } - has(key) { + has(key: string) { return this._cached.has(key); } @@ -324,61 +378,29 @@ export class LocalStorageProvider extends SettingsProvider { } -export class IndexedDBProvider extends SettingsProvider { - constructor(manager, start = true) { - super(manager); +export class IndexedDBProvider extends AdvancedSettingsProvider { - this._start_time = performance.now(); - - this._pending = new Set; - this._flush_wait = null; - - this._cached = new Map; - this.ready = false; - this._ready_wait = null; - - if ( start ) { - if ( window.BroadcastChannel ) { - const bc = this._broadcaster = new BroadcastChannel('ffz-settings'); - bc.addEventListener('message', - this._boundHandleMessage = this.handleMessage.bind(this)); - - } else { - window.addEventListener('storage', - this._boundHandleStorage = this.handleStorage.bind(this)); - } - - this.loadSettings() - .then(() => this._resolveReady(true)) - .catch(err => this._resolveReady(false, err)); - } - } - - _resolveReady(success, data) { - if ( this.manager ) - this.manager.log.info(`IDB ready in ${(performance.now() - this._start_time).toFixed(5)}ms`); - this.ready = success; - const waiters = this._ready_wait; - this._ready_wait = null; - if ( waiters ) - for(const pair of waiters) - pair[success ? 0 : 1](data); - } + // Static Stuff + static priority = 10; + static title = 'IndexedDB'; + static description = '[IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) is available on most platforms, and has a slightly slower initialization time than Local Storage. IndexedDB has a higher storage capacity and is less likely to be cleared unexpectedly.'; static supported() { return window.indexedDB != null; } static hasContent() { - return new Promise((s) => { + return new Promise((resolve) => { const request = window.indexedDB.open('FFZ', DB_VERSION); - request.onerror = () => s(false); + request.onerror = () => resolve(false); request.onupgradeneeded = e => { // TODO: Logic to detect that the version updated. // Can wait to implement till we actually increment version. - e.target.transaction.abort(); - s(false); + if ( e.target instanceof IDBRequest ) + e.target.transaction?.abort(); + + resolve(false); } request.onsuccess = () => { @@ -391,33 +413,80 @@ export class IndexedDBProvider extends SettingsProvider { store = trx.objectStore('settings'); } catch(err) { // This indicates a bad database. - s(false); + return resolve(false); } const r2 = store.getAllKeys(); r2.onerror = () => { db.close(); - s(false); + resolve(false); } r2.onsuccess = () => { const success = Array.isArray(r2.result) && r2.result.length > 0; db.close(); - return s(success); + return resolve(success); } } }); } - static key = 'idb'; - static priority = 10; - static title = 'IndexedDB'; - static description = '[IndexedDB](https://developer.mozilla.org/en-US/docs/Web/API/IndexedDB_API) is available on most platforms, and has a slightly slower initialization time than Local Storage. IndexedDB has a higher storage capacity and is less likely to be cleared unexpectedly.'; - static supportsBlobs = true; + // State and Storage + private _start_time: number; + private _cached: Map; + private _pending: Set | null; + private _flush_wait?: Promise | null; + private _flush_wait_resolve?: (() => void) | null; + private _ready_wait?: Promise | null; - //get supportsBlobs() { return true; } // eslint-disable-line class-methods-use-this + private _db_wait?: Promise | null; + + private db?: IDBDatabase | null; + + // Event Handling + private _broadcaster?: BroadcastChannel | null; + private _boundHandleMessage?: ((event: MessageEvent) => void) | null; + + constructor(manager: SettingsManager, start: boolean = true) { + super(manager); + + this.getDB = once(this.getDB); + + this._start_time = performance.now(); + + this._pending = new Set; + this._flush_wait = null; + + this._cached = new Map; + this.ready = false; + this._ready_wait = null; + + if ( start ) { + if ( window.BroadcastChannel ) { + const bc = this._broadcaster = new BroadcastChannel('ffz-settings'); + bc.addEventListener('message', + this._boundHandleMessage = this.handleMessage.bind(this)); + + } + + this._ready_wait = this.loadSettings() + .then(() => { + if ( this.manager ) + this.manager.log.info(`IDB ready in ${(performance.now() - this._start_time).toFixed(5)}ms`); + this.ready = true; + }) + .catch(err => { + if ( this.manager ) + this.manager.log.error(`IDB failed after ${(performance.now() - this._start_time).toFixed(5)}ms:`, err); + this.ready = false; + }) + .finally(() => { + this._ready_wait = null; + }); + } + } destroy() { this.disable(); @@ -426,14 +495,18 @@ export class IndexedDBProvider extends SettingsProvider { disable() { this.disabled = true; + this.removeListeners(); if ( this.db ) { this.db.close(); this.db = null; } + } + private removeListeners() { if ( this._broadcaster ) { - this._broadcaster.removeEventListener('message', this._boundHandleMessage); + if ( this._boundHandleMessage ) + this._broadcaster.removeEventListener('message', this._boundHandleMessage); this._broadcaster.close(); this._boundHandleMessage = this._broadcaster = null; } @@ -444,25 +517,20 @@ export class IndexedDBProvider extends SettingsProvider { } disableEvents() { - if ( this._broadcaster ) { - this._broadcaster.removeEventListener('message', this._boundHandleMessage); - this._broadcaster.close(); - this._boundHandleMessage = this._broadcaster = null; - } - + this.removeListeners(); this.broadcast = () => {}; this.emit = () => {}; } - _onStart(obj) { + _onStart(obj: unknown) { if ( ! this._pending ) - this._pending = new Set; + this._pending = new Set; this._pending.add(obj); } - _onFinish(obj) { + _onFinish(obj: unknown) { if ( this._pending ) { this._pending.delete(obj); @@ -470,35 +538,33 @@ export class IndexedDBProvider extends SettingsProvider { return; } - if ( this._flush_wait ) { - const waiters = this._flush_wait; - this._flush_wait = null; - - for(const waiter of waiters) - waiter(); - } + if ( this._flush_wait_resolve ) + this._flush_wait_resolve(); } flush() { + if ( this._flush_wait ) + return this._flush_wait; + if ( ! this._pending || ! this._pending.size ) return Promise.resolve(); - return new Promise(s => { - if ( ! this._flush_wait ) - this._flush_wait = []; - - this._flush_wait.push(s); + return this._flush_wait = new Promise(resolve => { + this._flush_wait_resolve = resolve + }).finally(() => { + this._flush_wait_resolve = null; + this._flush_wait = null; }); } - broadcast(msg) { + broadcast(msg: any) { if ( this._broadcaster ) this._broadcaster.postMessage(msg); } - handleMessage(event) { + handleMessage(event: MessageEvent) { if ( this.disabled || ! event.isTrusted || ! event.data ) return; @@ -539,23 +605,25 @@ export class IndexedDBProvider extends SettingsProvider { awaitReady() { + if ( this._ready_wait ) + return this._ready_wait; + if ( this.ready ) return Promise.resolve(); - return new Promise((resolve, reject) => { - const waiters = this._ready_wait = this._ready_wait || []; - waiters.push([resolve, reject]); - }) + return Promise.reject(); } // Synchronous Methods - get(key, default_value) { - return this._cached.has(key) ? this._cached.get(key) : default_value; + get(key: string, default_value?: T): T { + return this._cached.has(key) + ? this._cached.get(key) + : default_value; } - set(key, value) { + set(key: string, value: any) { if ( this.disabled ) return; @@ -573,7 +641,7 @@ export class IndexedDBProvider extends SettingsProvider { this.emit('set', key, value, false); } - delete(key) { + delete(key: string) { if ( this.disabled ) return; @@ -585,7 +653,7 @@ export class IndexedDBProvider extends SettingsProvider { this.emit('set', key, undefined, true); } - has(key) { + has(key: string) { return this._cached.has(key); } @@ -623,32 +691,26 @@ export class IndexedDBProvider extends SettingsProvider { if ( this.db ) return Promise.resolve(this.db); - if ( this._listeners ) - return new Promise((s,f) => this._listeners.push([s,f])); + if ( this._db_wait ) + return this._db_wait; - return new Promise((s,f) => { - const listeners = this._listeners = [[s,f]], - done = (success, data) => { - if ( this._listeners === listeners ) { - this._listeners = null; - for(const pair of listeners) - pair[success ? 0 : 1](data); - } - } + let this_wait: Promise; + return this._db_wait = this_wait = new Promise((resolve, reject) => { const request = window.indexedDB.open('FFZ', DB_VERSION); this._onStart(request); - request.onerror = e => { + request.onerror = event => { if ( this.manager ) - this.manager.log.error('Error opening database.', e); - done(false, e); + this.manager.log.error('Error opening database.', event); + + reject(event); this._onFinish(request); } - request.onupgradeneeded = e => { + request.onupgradeneeded = event => { if ( this.manager ) - this.manager.log.info(`Upgrading database from version ${e.oldVersion} to ${DB_VERSION}`); + this.manager.log.info(`Upgrading database from version ${event.oldVersion} to ${DB_VERSION}`); const db = request.result; @@ -659,6 +721,7 @@ export class IndexedDBProvider extends SettingsProvider { request.onsuccess = () => { if ( this.manager ) this.manager.log.info(`Database opened. (After: ${(performance.now() - this._start_time).toFixed(5)}ms)`); + this.db = request.result; try { @@ -678,17 +741,17 @@ export class IndexedDBProvider extends SettingsProvider { this.db = null; if ( second ) - done(false, err); + reject(err); else { // Try deleting the database and making a new one. const delreq = window.indexedDB.deleteDatabase('FFZ'); this._onStart(delreq); - delreq.onerror = e => { + delreq.onerror = event => { if ( this.manager ) - this.manager.log.error('Error deleting invalid database.', e); - done(false, e); + this.manager.log.error('Error deleting invalid database.', event); + reject(event); this._onFinish(delreq); } @@ -697,23 +760,22 @@ export class IndexedDBProvider extends SettingsProvider { this.manager.log.info('Deleted invalid database.'); this._onFinish(delreq); - this._listeners = null; - this.getDB(true).then(result => { - for(const pair of listeners) - pair[0](result); - }).catch(err => { - for(const pair of listeners) - pair[1](err); - }); + this._db_wait = null; + this.getDB(true) + .then(resolve) + .catch(reject); } } return; } - done(true, this.db); + resolve(this.db); this._onFinish(request); } + }).finally(() => { + if ( this_wait === this._db_wait ) + this._db_wait = null; }); } @@ -723,7 +785,7 @@ export class IndexedDBProvider extends SettingsProvider { trx = db.transaction(['settings'], 'readonly'), store = trx.objectStore('settings'); - return new Promise((s,f) => { + return new Promise((resolve, fail) => { const request = store.getAll(); this._onStart(request); @@ -731,14 +793,14 @@ export class IndexedDBProvider extends SettingsProvider { for(const entry of request.result) this._cached.set(entry.k, entry.v); - s(); + resolve(); this._onFinish(request); } request.onerror = err => { if ( this.manager ) this.manager.log.error('Error reading settings from database.', err); - f(); + fail(); this._onFinish(request); } }); @@ -750,48 +812,49 @@ export class IndexedDBProvider extends SettingsProvider { trx = db.transaction(['settings'], 'readonly'), store = trx.objectStore('settings'); - return new Promise((s,f) => { + return new Promise((resolve,fail) => { const request = store.getAllKeys(); this._onStart(request); request.onsuccess = () => { - s(request.result); + resolve(request.result); this._onFinish(request); } - request.onerror = () => { - f(); + request.onerror = err => { + if ( this.manager ) + this.manager.log.error('Error reading keys from database.', err); + fail(); this._onFinish(request); } }); } - async _get(key) { + async _get(key: string) { const db = await this.getDB(), trx = db.transaction(['settings'], 'readonly'), store = trx.objectStore('settings'); - return new Promise((s,f) => { - store.onerror = f; - + return new Promise((resolve, fail) => { + //store.onerror = fail; const req = store.get(key); this._onStart(req); - req.onerror = () => { - f(); + req.onerror = err => { + fail(); this._onFinish(req); } req.onsuccess = () => { - s(req.result.v); + resolve(req.result.v); this._onFinish(req); } }); } - async _set(key, value) { + async _set(key: string, value: any) { if ( this.disabled ) return; @@ -799,25 +862,24 @@ export class IndexedDBProvider extends SettingsProvider { trx = db.transaction(['settings'], 'readwrite'), store = trx.objectStore('settings'); - return new Promise((s,f) => { - store.onerror = f; - + return new Promise((resolve, fail) => { + //store.onerror = f; const req = store.put({k: key, v: value}); this._onStart(req); req.onerror = () => { - f(); + fail(); this._onFinish(req); } req.onsuccess = () => { - s(); + resolve(); this._onFinish(req); } }); } - async _delete(key) { + async _delete(key: string) { if ( this.disabled ) return; @@ -825,18 +887,17 @@ export class IndexedDBProvider extends SettingsProvider { trx = db.transaction(['settings'], 'readwrite'), store = trx.objectStore('settings'); - return new Promise((s,f) => { - store.onerror = f; - + return new Promise((resolve, fail) => { + //store.onerror = f; const req = store.delete(key); this._onStart(req); req.onerror = () => { - f(); + fail(); this._onFinish(req); } req.onsuccess = () => { - s(); + resolve(); this._onFinish(req); } }); @@ -851,18 +912,17 @@ export class IndexedDBProvider extends SettingsProvider { trx = db.transaction(['settings'], 'readwrite'), store = trx.objectStore('settings'); - return new Promise((s,f) => { - store.onerror = f; - + return new Promise((resolve, fail) => { + //store.onerror = f; const req = store.clear(); this._onStart(req); req.onerror = () => { - f(); + fail(); this._onFinish(req); } req.onsuccess = () => { - s(); + resolve(); this._onFinish(req); } }); @@ -870,29 +930,31 @@ export class IndexedDBProvider extends SettingsProvider { /* Blobs */ - async getBlob(key) { + async getBlob(key: string) { const db = await this.getDB(), trx = db.transaction(['blobs'], 'readonly'), store = trx.objectStore('blobs'); - return new Promise((s, f) => { - store.onerror = f; - + return new Promise((resolve, fail) => { + //store.onerror = f; const req = store.get(key); this._onStart(req); req.onerror = () => { - f(); + fail(); this._onFinish(req); } req.onsuccess = e => { - s(e.target.result); + if ( isValidBlob(req.result) ) + resolve(req.result); + else + fail(); this._onFinish(req); } }); } - async setBlob(key, value) { + async setBlob(key: string, value: BlobLike) { if ( this.disabled ) return; @@ -903,18 +965,17 @@ export class IndexedDBProvider extends SettingsProvider { trx = db.transaction(['blobs'], 'readwrite'), store = trx.objectStore('blobs'); - return new Promise((s, f) => { - store.onerror = f; - + return new Promise((resolve, fail) => { + //store.onerror = f; const req = store.put(value, key); this._onStart(req); req.onerror = () => { - f(); + fail(); this._onFinish(req); } req.onsuccess = () => { - s(); + resolve(); this.broadcast({type: 'set-blob', key}); this.emit('set-blob', key, value, false); @@ -923,7 +984,7 @@ export class IndexedDBProvider extends SettingsProvider { }); } - async deleteBlob(key) { + async deleteBlob(key: string) { if ( this.disabled ) return; @@ -931,18 +992,18 @@ export class IndexedDBProvider extends SettingsProvider { trx = db.transaction(['blobs'], 'readwrite'), store = trx.objectStore('blobs'); - return new Promise((s, f) => { - store.onerror = f; - + return new Promise((resolve, fail) => { + //store.onerror = f; const req = store.delete(key); this._onStart(req); req.onerror = () => { - f(); + fail(); this._onFinish(req); } req.onsuccess = () => { - s(); + resolve(); + this.broadcast({type: 'delete-blob', key}); this.emit('set-blob', key, undefined, true); this._onFinish(req); @@ -950,7 +1011,7 @@ export class IndexedDBProvider extends SettingsProvider { }); } - async hasBlob(key) { + async hasBlob(key: string) { const keys = await this.blobKeys(); return keys.includes(key); } @@ -963,18 +1024,18 @@ export class IndexedDBProvider extends SettingsProvider { trx = db.transaction(['blobs'], 'readwrite'), store = trx.objectStore('blobs'); - return new Promise((s, f) => { - store.onerror = f; - + return new Promise((resolve, fail) => { + //store.onerror = fail; const req = store.clear(); this._onStart(req); req.onerror = () => { - f(); + fail(); this._onFinish(req); } req.onsuccess = () => { - s(); + resolve(); + this.broadcast({type: 'clear-blobs'}); this._onFinish(req); } @@ -986,19 +1047,19 @@ export class IndexedDBProvider extends SettingsProvider { trx = db.transaction(['blobs'], 'readonly'), store = trx.objectStore('blobs'); - return new Promise((s, f) => { + return new Promise((resolve, fail) => { const req = store.getAllKeys(); this._onStart(req); req.onerror = () => { - f(); + fail(); this._onFinish(req); } req.onsuccess = () => { if ( Array.isArray(req.result) ) - s(req.result); + resolve(req.result as string[]); else - f(); + fail(); this._onFinish(req); } @@ -1012,8 +1073,39 @@ export class IndexedDBProvider extends SettingsProvider { // CrossOriginStorageBridge // ============================================================================ -export class CrossOriginStorageBridge extends SettingsProvider { - constructor(manager) { +export class CrossOriginStorageBridge extends AdvancedSettingsProvider { + + // Static Stuff + + static supported() { return NOT_WWW_TWITCH && NOT_WWW_YT; } + static hasContent() { + return CrossOriginStorageBridge.supported(); + } + + static priority = 100; + static title = 'Cross-Origin Storage Bridge'; + static description = 'This provider uses an `