mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-06-27 21:05:53 +00:00
Merge pull request #1430 from FrankerFaceZ/types
The Grand TypeScript Update
This commit is contained in:
commit
40865adba7
118 changed files with 13451 additions and 7821 deletions
9
.babelrc
9
.babelrc
|
@ -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}]
|
|
||||||
]
|
|
||||||
}
|
|
21
.editorconfig
Normal file
21
.editorconfig
Normal file
|
@ -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
|
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,6 +1,7 @@
|
||||||
node_modules
|
node_modules
|
||||||
npm-debug.log
|
npm-debug.log
|
||||||
dist
|
dist
|
||||||
|
typedist
|
||||||
Extension Building
|
Extension Building
|
||||||
badges
|
badges
|
||||||
cdn
|
cdn
|
||||||
|
|
82
bin/build_types.js
Normal file
82
bin/build_types.js
Normal file
|
@ -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);
|
||||||
|
|
||||||
|
}
|
|
@ -17,8 +17,16 @@ for(const file of fs.readdirSync(dir)) {
|
||||||
const config = JSON.parse(fs.readFileSync('fontello.config.json', 'utf8'));
|
const config = JSON.parse(fs.readFileSync('fontello.config.json', 'utf8'));
|
||||||
const icons = config.glyphs.map(x => x.css);
|
const icons = config.glyphs.map(x => x.css);
|
||||||
|
|
||||||
fs.writeFileSync('src/utilities/ffz-icons.js', `'use strict';
|
fs.writeFileSync('src/utilities/ffz-icons.ts', `'use strict';
|
||||||
// This is a generated file. To update it, please run: npm run font:update
|
// This is a generated file. To update it, please run: pnpm font:update
|
||||||
/* eslint quotes: 0 */
|
/* 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;`);
|
||||||
|
|
193
package.json
193
package.json
|
@ -1,91 +1,106 @@
|
||||||
{
|
{
|
||||||
"name": "frankerfacez",
|
"name": "frankerfacez",
|
||||||
"author": "Dan Salvato LLC",
|
"author": "Dan Salvato LLC",
|
||||||
"version": "4.60.1",
|
"version": "4.61.0",
|
||||||
"description": "FrankerFaceZ is a Twitch enhancement suite.",
|
"description": "FrankerFaceZ is a Twitch enhancement suite.",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "pnpm dev",
|
"start": "pnpm dev",
|
||||||
"eslint": "eslint \"src/**/*.{js,jsx,vue}\"",
|
"eslint": "eslint \"src/**/*.{js,jsx,vue}\"",
|
||||||
"clean": "rimraf dist",
|
"clean": "rimraf dist",
|
||||||
"dev": "cross-env NODE_ENV=development webpack serve",
|
"dev": "cross-env NODE_ENV=development webpack serve",
|
||||||
"dev:prod": "cross-env NODE_ENV=production webpack serve",
|
"dev:prod": "cross-env NODE_ENV=production webpack serve",
|
||||||
"build": "pnpm build:prod",
|
"build": "pnpm build:prod",
|
||||||
"build:stats": "cross-env NODE_ENV=production webpack build --json > stats.json",
|
"build:stats": "cross-env NODE_ENV=production webpack build --json > stats.json",
|
||||||
"build:prod": "cross-env NODE_ENV=production webpack build",
|
"build:prod": "cross-env NODE_ENV=production webpack build",
|
||||||
"build:dev": "cross-env NODE_ENV=development webpack build",
|
"build:dev": "cross-env NODE_ENV=development webpack build",
|
||||||
"font": "pnpm font:edit",
|
"build:types": "cross-env tsc --declaration --emitDeclarationOnly --outDir typedist && node bin/build_types",
|
||||||
"font:edit": "fontello-cli --cli-config fontello.client.json edit",
|
"abuild:types": "node bin/build_types",
|
||||||
"font:save": "fontello-cli --cli-config fontello.client.json save && pnpm font:update",
|
"build:docs": "cross-env typedoc --options typedoc.json",
|
||||||
"font:update": "node bin/update_fonts"
|
"font": "pnpm font:edit",
|
||||||
},
|
"font:edit": "fontello-cli --cli-config fontello.client.json edit",
|
||||||
"devDependencies": {
|
"font:save": "fontello-cli --cli-config fontello.client.json save && pnpm font:update",
|
||||||
"@ffz/fontello-cli": "^1.0.4",
|
"font:update": "node bin/update_fonts"
|
||||||
"browserslist": "^4.21.10",
|
},
|
||||||
"copy-webpack-plugin": "^11.0.0",
|
"devDependencies": {
|
||||||
"cross-env": "^7.0.3",
|
"@ffz/fontello-cli": "^1.0.4",
|
||||||
"css-loader": "^6.8.1",
|
"@types/crypto-js": "^4.2.1",
|
||||||
"esbuild-loader": "^4.0.2",
|
"@types/js-cookie": "^3.0.6",
|
||||||
"eslint": "^8.48.0",
|
"@types/safe-regex": "^1.1.6",
|
||||||
"eslint-plugin-import": "^2.28.1",
|
"@types/vue-clickaway": "^2.2.4",
|
||||||
"eslint-plugin-react": "^7.33.2",
|
"@types/webpack-env": "^1.18.4",
|
||||||
"eslint-plugin-vue": "^9.17.0",
|
"browserslist": "^4.21.10",
|
||||||
"extract-loader": "^5.1.0",
|
"copy-webpack-plugin": "^11.0.0",
|
||||||
"file-loader": "^6.2.0",
|
"cross-env": "^7.0.3",
|
||||||
"json-loader": "^0.5.7",
|
"css-loader": "^6.8.1",
|
||||||
"minify-graphql-loader": "^1.0.2",
|
"esbuild-loader": "^4.0.2",
|
||||||
"raw-loader": "^4.0.2",
|
"eslint": "^8.48.0",
|
||||||
"rimraf": "^5.0.1",
|
"eslint-plugin-import": "^2.28.1",
|
||||||
"sass": "^1.66.1",
|
"eslint-plugin-react": "^7.33.2",
|
||||||
"sass-loader": "^13.3.2",
|
"eslint-plugin-vue": "^9.17.0",
|
||||||
"semver": "^7.5.4",
|
"extract-loader": "^5.1.0",
|
||||||
"vue-loader": "^15.10.2",
|
"file-loader": "^6.2.0",
|
||||||
"vue-template-compiler": "^2.6.14",
|
"glob": "^10.3.10",
|
||||||
"webpack": "^5.88.2",
|
"json-loader": "^0.5.7",
|
||||||
"webpack-cli": "^5.1.4",
|
"minify-graphql-loader": "^1.0.2",
|
||||||
"webpack-dev-server": "^4.15.1",
|
"raw-loader": "^4.0.2",
|
||||||
"webpack-manifest-plugin": "^5.0.0"
|
"rimraf": "^5.0.1",
|
||||||
},
|
"sass": "^1.66.1",
|
||||||
"repository": {
|
"sass-loader": "^13.3.2",
|
||||||
"type": "git",
|
"semver": "^7.5.4",
|
||||||
"url": "https://github.com/FrankerFaceZ/FrankerFaceZ.git"
|
"typedoc": "^0.25.3",
|
||||||
},
|
"typedoc-plugin-markdown": "^3.17.1",
|
||||||
"dependencies": {
|
"typedoc-plugin-mdn-links": "^3.1.0",
|
||||||
"@ffz/icu-msgparser": "^2.0.0",
|
"typedoc-plugin-no-inherit": "^1.4.0",
|
||||||
"@popperjs/core": "^2.10.2",
|
"typedoc-plugin-rename-defaults": "^0.7.0",
|
||||||
"crypto-js": "^3.3.0",
|
"typescript": "^5.2.2",
|
||||||
"dayjs": "^1.10.7",
|
"vue-loader": "^15.10.2",
|
||||||
"denoflare-mqtt": "^0.0.2",
|
"vue-template-compiler": "^2.6.14",
|
||||||
"displacejs": "^1.4.1",
|
"webpack": "^5.88.2",
|
||||||
"emoji-regex": "^9.2.2",
|
"webpack-cli": "^5.1.4",
|
||||||
"file-saver": "^2.0.5",
|
"webpack-dev-server": "^4.15.1",
|
||||||
"graphql": "^16.0.1",
|
"webpack-manifest-plugin": "^5.0.0"
|
||||||
"graphql-tag": "^2.12.6",
|
},
|
||||||
"js-cookie": "^2.2.1",
|
"repository": {
|
||||||
"jszip": "^3.7.1",
|
"type": "git",
|
||||||
"markdown-it": "^12.2.0",
|
"url": "https://github.com/FrankerFaceZ/FrankerFaceZ.git"
|
||||||
"markdown-it-link-attributes": "^3.0.0",
|
},
|
||||||
"mnemonist": "^0.38.5",
|
"dependencies": {
|
||||||
"path-to-regexp": "^3.2.0",
|
"@ffz/icu-msgparser": "^2.0.0",
|
||||||
"raven-js": "^3.27.2",
|
"@popperjs/core": "^2.11.8",
|
||||||
"react": "^17.0.2",
|
"crypto-js": "^4.2.0",
|
||||||
"safe-regex": "^2.1.1",
|
"dayjs": "^1.10.7",
|
||||||
"sortablejs": "^1.14.0",
|
"denoflare-mqtt": "^0.0.2",
|
||||||
"sourcemapped-stacktrace": "^1.1.11",
|
"displacejs": "^1.4.1",
|
||||||
"text-diff": "^1.0.1",
|
"emoji-regex": "^9.2.2",
|
||||||
"vue": "^2.6.14",
|
"file-saver": "^2.0.5",
|
||||||
"vue-clickaway": "^2.2.2",
|
"graphql": "^16.0.1",
|
||||||
"vue-color": "^2.8.1",
|
"graphql-tag": "^2.12.6",
|
||||||
"vue-observe-visibility": "^1.0.0",
|
"js-cookie": "^3.0.5",
|
||||||
"vuedraggable": "^2.24.3"
|
"jszip": "^3.7.1",
|
||||||
},
|
"markdown-it": "^12.2.0",
|
||||||
"pnpm": {
|
"markdown-it-link-attributes": "^3.0.0",
|
||||||
"overrides": {
|
"mnemonist": "^0.38.5",
|
||||||
"ansi-regex@>2.1.1 <5.0.1": ">=5.0.1",
|
"path-to-regexp": "^3.2.0",
|
||||||
"chalk@<4": ">=4 <5",
|
"raven-js": "^3.27.2",
|
||||||
"set-value@<4.0.1": ">=4.0.1",
|
"react": "^17.0.2",
|
||||||
"glob-parent@<5.1.2": ">=5.1.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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
215
pnpm-lock.yaml
generated
215
pnpm-lock.yaml
generated
|
@ -15,11 +15,11 @@ dependencies:
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
'@popperjs/core':
|
'@popperjs/core':
|
||||||
specifier: ^2.10.2
|
specifier: ^2.11.8
|
||||||
version: 2.10.2
|
version: 2.11.8
|
||||||
crypto-js:
|
crypto-js:
|
||||||
specifier: ^3.3.0
|
specifier: ^4.2.0
|
||||||
version: 3.3.0
|
version: 4.2.0
|
||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.10.7
|
specifier: ^1.10.7
|
||||||
version: 1.10.7
|
version: 1.10.7
|
||||||
|
@ -42,8 +42,8 @@ dependencies:
|
||||||
specifier: ^2.12.6
|
specifier: ^2.12.6
|
||||||
version: 2.12.6(graphql@16.0.1)
|
version: 2.12.6(graphql@16.0.1)
|
||||||
js-cookie:
|
js-cookie:
|
||||||
specifier: ^2.2.1
|
specifier: ^3.0.5
|
||||||
version: 2.2.1
|
version: 3.0.5
|
||||||
jszip:
|
jszip:
|
||||||
specifier: ^3.7.1
|
specifier: ^3.7.1
|
||||||
version: 3.7.1
|
version: 3.7.1
|
||||||
|
@ -97,6 +97,21 @@ devDependencies:
|
||||||
'@ffz/fontello-cli':
|
'@ffz/fontello-cli':
|
||||||
specifier: ^1.0.4
|
specifier: ^1.0.4
|
||||||
version: 1.0.4
|
version: 1.0.4
|
||||||
|
'@types/crypto-js':
|
||||||
|
specifier: ^4.2.1
|
||||||
|
version: 4.2.1
|
||||||
|
'@types/js-cookie':
|
||||||
|
specifier: ^3.0.6
|
||||||
|
version: 3.0.6
|
||||||
|
'@types/safe-regex':
|
||||||
|
specifier: ^1.1.6
|
||||||
|
version: 1.1.6
|
||||||
|
'@types/vue-clickaway':
|
||||||
|
specifier: ^2.2.4
|
||||||
|
version: 2.2.4
|
||||||
|
'@types/webpack-env':
|
||||||
|
specifier: ^1.18.4
|
||||||
|
version: 1.18.4
|
||||||
browserslist:
|
browserslist:
|
||||||
specifier: ^4.21.10
|
specifier: ^4.21.10
|
||||||
version: 4.21.10
|
version: 4.21.10
|
||||||
|
@ -130,6 +145,9 @@ devDependencies:
|
||||||
file-loader:
|
file-loader:
|
||||||
specifier: ^6.2.0
|
specifier: ^6.2.0
|
||||||
version: 6.2.0(webpack@5.88.2)
|
version: 6.2.0(webpack@5.88.2)
|
||||||
|
glob:
|
||||||
|
specifier: ^10.3.10
|
||||||
|
version: 10.3.10
|
||||||
json-loader:
|
json-loader:
|
||||||
specifier: ^0.5.7
|
specifier: ^0.5.7
|
||||||
version: 0.5.7
|
version: 0.5.7
|
||||||
|
@ -151,6 +169,24 @@ devDependencies:
|
||||||
semver:
|
semver:
|
||||||
specifier: ^7.5.4
|
specifier: ^7.5.4
|
||||||
version: 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:
|
vue-loader:
|
||||||
specifier: ^15.10.2
|
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)
|
version: 15.10.2(css-loader@6.8.1)(react@17.0.2)(vue-template-compiler@2.6.14)(webpack@5.88.2)
|
||||||
|
@ -535,8 +571,8 @@ packages:
|
||||||
dev: true
|
dev: true
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
/@popperjs/core@2.10.2:
|
/@popperjs/core@2.11.8:
|
||||||
resolution: {integrity: sha512-IXf3XA7+XyN7CP9gGh/XB0UxVMlvARGEgGXLubFICsUMGz6Q+DU+i4gGlpOxTjKvXjkJDJC8YdqdKkDj9qZHEQ==}
|
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/@types/body-parser@1.19.2:
|
/@types/body-parser@1.19.2:
|
||||||
|
@ -565,6 +601,10 @@ packages:
|
||||||
'@types/node': 20.5.7
|
'@types/node': 20.5.7
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@types/crypto-js@4.2.1:
|
||||||
|
resolution: {integrity: sha512-FSPGd9+OcSok3RsM0UZ/9fcvMOXJ1ENE/ZbLfOPlBWj7BgXtEAM8VYfTtT760GiLbQIMoVozwVuisjvsVwqYWw==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@types/eslint-scope@3.7.4:
|
/@types/eslint-scope@3.7.4:
|
||||||
resolution: {integrity: sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==}
|
resolution: {integrity: sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -611,6 +651,10 @@ packages:
|
||||||
'@types/node': 20.5.7
|
'@types/node': 20.5.7
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@types/js-cookie@3.0.6:
|
||||||
|
resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@types/json-schema@7.0.9:
|
/@types/json-schema@7.0.9:
|
||||||
resolution: {integrity: sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==}
|
resolution: {integrity: sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==}
|
||||||
dev: true
|
dev: true
|
||||||
|
@ -643,6 +687,10 @@ packages:
|
||||||
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
|
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@types/safe-regex@1.1.6:
|
||||||
|
resolution: {integrity: sha512-CQ/uPB9fLOPKwDsrTeVbNIkwfUthTWOx0l6uIGwVFjZxv7e68pCW5gtTYFzdJi3EBJp8h8zYhJbTasAbX7gEMQ==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@types/send@0.17.1:
|
/@types/send@0.17.1:
|
||||||
resolution: {integrity: sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==}
|
resolution: {integrity: sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -670,6 +718,16 @@ packages:
|
||||||
'@types/node': 20.5.7
|
'@types/node': 20.5.7
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/@types/vue-clickaway@2.2.4:
|
||||||
|
resolution: {integrity: sha512-Jy0dGNUrm/Fya1hY8bHM5lXJvZvlyU/rvgLEFVcjQkwNp2Z2IGNnRKS6ZH9orMDkUI7Qj0oyWp0b89VTErAS9Q==}
|
||||||
|
dependencies:
|
||||||
|
vue: 2.6.14
|
||||||
|
dev: true
|
||||||
|
|
||||||
|
/@types/webpack-env@1.18.4:
|
||||||
|
resolution: {integrity: sha512-I6e+9+HtWADAWeeJWDFQtdk4EVSAbj6Rtz4q8fJ7mSr1M0jzlFcs8/HZ+Xb5SHzVm1dxH7aUiI+A8kA8Gcrm0A==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/@types/ws@8.5.5:
|
/@types/ws@8.5.5:
|
||||||
resolution: {integrity: sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg==}
|
resolution: {integrity: sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -989,6 +1047,10 @@ packages:
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/ansi-sequence-parser@1.1.1:
|
||||||
|
resolution: {integrity: sha512-vJXt3yiaUL4UU546s3rPXlsry/RnM730G1+HkpKE012AN0sx1eOrxSu95oKDIonskeLTijMgqWZ3uDEe3NFvyg==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/ansi-styles@4.3.0:
|
/ansi-styles@4.3.0:
|
||||||
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
@ -1770,6 +1832,11 @@ packages:
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/camelcase@8.0.0:
|
||||||
|
resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/caniuse-lite@1.0.30001524:
|
/caniuse-lite@1.0.30001524:
|
||||||
resolution: {integrity: sha512-Jj917pJtYg9HSJBF95HVX3Cdr89JUyLT4IZ8SvM5aDRni95swKgYi3TgYLH5hnGfPE/U1dg6IfZ50UsIlLkwSA==}
|
resolution: {integrity: sha512-Jj917pJtYg9HSJBF95HVX3Cdr89JUyLT4IZ8SvM5aDRni95swKgYi3TgYLH5hnGfPE/U1dg6IfZ50UsIlLkwSA==}
|
||||||
dev: true
|
dev: true
|
||||||
|
@ -2129,8 +2196,8 @@ packages:
|
||||||
which: 2.0.2
|
which: 2.0.2
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/crypto-js@3.3.0:
|
/crypto-js@4.2.0:
|
||||||
resolution: {integrity: sha512-DIT51nX0dCfKltpRiXV+/TVZq+Qq2NgF4644+K7Ttnla7zEzqc+kjJyiB96BHNyUTBxyjzRcZYpUdZa+QAqi6Q==}
|
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/css-loader@6.8.1(webpack@5.88.2):
|
/css-loader@6.8.1(webpack@5.88.2):
|
||||||
|
@ -3056,13 +3123,13 @@ packages:
|
||||||
resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==}
|
resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/glob@10.3.3:
|
/glob@10.3.10:
|
||||||
resolution: {integrity: sha512-92vPiMb/iqpmEgsOoIDvTjc50wf9CCCvMzsi6W0JLPeUKE8TWP1a73PgqSrqy7iAZxaSD1YdzU7QZR5LF51MJw==}
|
resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==}
|
||||||
engines: {node: '>=16 || 14 >=14.17'}
|
engines: {node: '>=16 || 14 >=14.17'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
dependencies:
|
dependencies:
|
||||||
foreground-child: 3.1.1
|
foreground-child: 3.1.1
|
||||||
jackspeak: 2.3.0
|
jackspeak: 2.3.6
|
||||||
minimatch: 9.0.3
|
minimatch: 9.0.3
|
||||||
minipass: 7.0.3
|
minipass: 7.0.3
|
||||||
path-scurry: 1.10.1
|
path-scurry: 1.10.1
|
||||||
|
@ -3153,6 +3220,19 @@ packages:
|
||||||
resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==}
|
resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==}
|
||||||
dev: true
|
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:
|
/has-bigints@1.0.2:
|
||||||
resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==}
|
resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==}
|
||||||
dev: true
|
dev: true
|
||||||
|
@ -3626,8 +3706,8 @@ packages:
|
||||||
reflect.getprototypeof: 1.0.3
|
reflect.getprototypeof: 1.0.3
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/jackspeak@2.3.0:
|
/jackspeak@2.3.6:
|
||||||
resolution: {integrity: sha512-uKmsITSsF4rUWQHzqaRUuyAir3fZfW3f202Ee34lz/gZCi970CPZwyQXLGNgWJvvZbvFyzeyGq0+4fcG/mBKZg==}
|
resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
dependencies:
|
dependencies:
|
||||||
'@isaacs/cliui': 8.0.2
|
'@isaacs/cliui': 8.0.2
|
||||||
|
@ -3644,8 +3724,9 @@ packages:
|
||||||
supports-color: 8.1.1
|
supports-color: 8.1.1
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/js-cookie@2.2.1:
|
/js-cookie@3.0.5:
|
||||||
resolution: {integrity: sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==}
|
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
|
||||||
|
engines: {node: '>=14'}
|
||||||
dev: false
|
dev: false
|
||||||
|
|
||||||
/js-tokens@3.0.2:
|
/js-tokens@3.0.2:
|
||||||
|
@ -3710,6 +3791,10 @@ packages:
|
||||||
hasBin: true
|
hasBin: true
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/jsonc-parser@3.2.0:
|
||||||
|
resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/jsx-ast-utils@3.2.1:
|
/jsx-ast-utils@3.2.1:
|
||||||
resolution: {integrity: sha512-uP5vu8xfy2F9A6LGC22KO7e2/vGTS1MhP+18f++ZNlf0Ohaxbc9nIEwHAsejlJKyzfZzU5UIhe5ItYkitcZnZA==}
|
resolution: {integrity: sha512-uP5vu8xfy2F9A6LGC22KO7e2/vGTS1MhP+18f++ZNlf0Ohaxbc9nIEwHAsejlJKyzfZzU5UIhe5ItYkitcZnZA==}
|
||||||
engines: {node: '>=4.0'}
|
engines: {node: '>=4.0'}
|
||||||
|
@ -3837,6 +3922,10 @@ packages:
|
||||||
yallist: 4.0.0
|
yallist: 4.0.0
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/lunr@2.3.9:
|
||||||
|
resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/markdown-it-link-attributes@3.0.0:
|
/markdown-it-link-attributes@3.0.0:
|
||||||
resolution: {integrity: sha512-B34ySxVeo6MuEGSPCWyIYryuXINOvngNZL87Mp7YYfKIf6DcD837+lXA8mo6EBbauKsnGz22ZH0zsbOiQRWTNg==}
|
resolution: {integrity: sha512-B34ySxVeo6MuEGSPCWyIYryuXINOvngNZL87Mp7YYfKIf6DcD837+lXA8mo6EBbauKsnGz22ZH0zsbOiQRWTNg==}
|
||||||
dev: false
|
dev: false
|
||||||
|
@ -3852,6 +3941,12 @@ packages:
|
||||||
uc.micro: 1.0.6
|
uc.micro: 1.0.6
|
||||||
dev: false
|
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:
|
/material-colors@1.2.6:
|
||||||
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
|
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
|
||||||
dev: false
|
dev: false
|
||||||
|
@ -4679,7 +4774,7 @@ packages:
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
dependencies:
|
dependencies:
|
||||||
glob: 10.3.3
|
glob: 10.3.10
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/run-parallel@1.2.0:
|
/run-parallel@1.2.0:
|
||||||
|
@ -4899,6 +4994,15 @@ packages:
|
||||||
resolution: {integrity: sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==}
|
resolution: {integrity: sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==}
|
||||||
dev: true
|
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:
|
/side-channel@1.0.4:
|
||||||
resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==}
|
resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -5287,10 +5391,72 @@ packages:
|
||||||
is-typed-array: 1.1.12
|
is-typed-array: 1.1.12
|
||||||
dev: true
|
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:
|
/uc.micro@1.0.6:
|
||||||
resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==}
|
resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==}
|
||||||
dev: false
|
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:
|
/unbox-primitive@1.0.2:
|
||||||
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
|
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
|
||||||
dependencies:
|
dependencies:
|
||||||
|
@ -5355,6 +5521,14 @@ packages:
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
dev: true
|
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):
|
/vue-clickaway@2.2.2(vue@2.6.14):
|
||||||
resolution: {integrity: sha512-25SpjXKetL06GLYoLoC8pqAV6Cur9cQ//2g35GRFBV4FgoljbZZjTINR8g2NuVXXDMLSUXaKx5dutgO4PaDE7A==}
|
resolution: {integrity: sha512-25SpjXKetL06GLYoLoC8pqAV6Cur9cQ//2g35GRFBV4FgoljbZZjTINR8g2NuVXXDMLSUXaKx5dutgO4PaDE7A==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
|
@ -5502,7 +5676,6 @@ packages:
|
||||||
|
|
||||||
/vue@2.6.14:
|
/vue@2.6.14:
|
||||||
resolution: {integrity: sha512-x2284lgYvjOMj3Za7kqzRcUSxBboHqtgRE2zlos1qWaOye5yUmHn42LB1250NJBLRwEcdrB0JRwyPTEPhfQjiQ==}
|
resolution: {integrity: sha512-x2284lgYvjOMj3Za7kqzRcUSxBboHqtgRE2zlos1qWaOye5yUmHn42LB1250NJBLRwEcdrB0JRwyPTEPhfQjiQ==}
|
||||||
dev: false
|
|
||||||
|
|
||||||
/vuedraggable@2.24.3:
|
/vuedraggable@2.24.3:
|
||||||
resolution: {integrity: sha512-6/HDXi92GzB+Hcs9fC6PAAozK1RLt1ewPTLjK0anTYguXLAeySDmcnqE8IC0xa7shvSzRjQXq3/+dsZ7ETGF3g==}
|
resolution: {integrity: sha512-6/HDXi92GzB+Hcs9fC6PAAozK1RLt1ewPTLjK0anTYguXLAeySDmcnqE8IC0xa7shvSzRjQXq3/+dsZ7ETGF3g==}
|
||||||
|
@ -5789,6 +5962,10 @@ packages:
|
||||||
resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==}
|
resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
|
/wordwrap@1.0.0:
|
||||||
|
resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
|
||||||
|
dev: true
|
||||||
|
|
||||||
/wrap-ansi@7.0.0:
|
/wrap-ansi@7.0.0:
|
||||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
|
@ -4,21 +4,77 @@
|
||||||
// Add-On System
|
// Add-On System
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import Module from 'utilities/module';
|
import Module, { GenericModule } from 'utilities/module';
|
||||||
import { EXTENSION, SERVER_OR_EXT } from 'utilities/constants';
|
import { EXTENSION, SERVER_OR_EXT } from 'utilities/constants';
|
||||||
import { createElement } from 'utilities/dom';
|
import { createElement } from 'utilities/dom';
|
||||||
import { timeout, has, deep_copy } from 'utilities/object';
|
import { timeout, has, deep_copy, fetchJSON } from 'utilities/object';
|
||||||
import { getBuster } from 'utilities/time';
|
import { getBuster } from 'utilities/time';
|
||||||
|
import type SettingsManager from './settings';
|
||||||
|
import type TranslationManager from './i18n';
|
||||||
|
import type LoadTracker from './load_tracker';
|
||||||
|
import type FrankerFaceZ from './main';
|
||||||
|
import type { AddonInfo } from 'utilities/types';
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
ffzAddonsWebpackJsonp: unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'utilities/types' {
|
||||||
|
interface ModuleMap {
|
||||||
|
addons: AddonManager;
|
||||||
|
}
|
||||||
|
interface ModuleEventMap {
|
||||||
|
addons: AddonManagerEvents;
|
||||||
|
}
|
||||||
|
interface SettingsTypeMap {
|
||||||
|
'addons.dev.server': boolean;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
type AddonManagerEvents = {
|
||||||
|
':ready': [];
|
||||||
|
':data-loaded': [];
|
||||||
|
':reload-required': [];
|
||||||
|
|
||||||
|
':added': [id: string, info: AddonInfo];
|
||||||
|
':addon-loaded': [id: string];
|
||||||
|
':addon-enabled': [id: string];
|
||||||
|
':addon-disabled': [id: string];
|
||||||
|
':fully-unload': [id: string];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
type FullAddonInfo = AddonInfo & {
|
||||||
|
_search?: string | null;
|
||||||
|
src: string;
|
||||||
|
};
|
||||||
|
|
||||||
const fetchJSON = (url, options) => fetch(url, options).then(r => r.ok ? r.json() : null).catch(() => null);
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// AddonManager
|
// AddonManager
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export default class AddonManager extends Module {
|
export default class AddonManager extends Module<'addons'> {
|
||||||
constructor(...args) {
|
|
||||||
super(...args);
|
// Dependencies
|
||||||
|
i18n: TranslationManager = null as any;
|
||||||
|
load_tracker: LoadTracker = null as any;
|
||||||
|
settings: SettingsManager = null as any;
|
||||||
|
|
||||||
|
// State
|
||||||
|
has_dev: boolean;
|
||||||
|
reload_required: boolean;
|
||||||
|
target: string;
|
||||||
|
|
||||||
|
addons: Record<string, FullAddonInfo | string[]>;
|
||||||
|
enabled_addons: string[];
|
||||||
|
|
||||||
|
private _loader?: Promise<void>;
|
||||||
|
|
||||||
|
constructor(name?: string, parent?: GenericModule) {
|
||||||
|
super(name, parent);
|
||||||
|
|
||||||
this.should_enable = true;
|
this.should_enable = true;
|
||||||
|
|
||||||
|
@ -28,7 +84,7 @@ export default class AddonManager extends Module {
|
||||||
|
|
||||||
this.load_requires = ['settings'];
|
this.load_requires = ['settings'];
|
||||||
|
|
||||||
this.target = this.parent.flavor || 'unknown';
|
this.target = (this.parent as unknown as FrankerFaceZ).flavor || 'unknown';
|
||||||
|
|
||||||
this.has_dev = false;
|
this.has_dev = false;
|
||||||
this.reload_required = false;
|
this.reload_required = false;
|
||||||
|
@ -39,6 +95,7 @@ export default class AddonManager extends Module {
|
||||||
}
|
}
|
||||||
|
|
||||||
onLoad() {
|
onLoad() {
|
||||||
|
// We don't actually *wait* for this, we just start it.
|
||||||
this._loader = this.loadAddonData();
|
this._loader = this.loadAddonData();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -54,20 +111,20 @@ export default class AddonManager extends Module {
|
||||||
getFFZ: () => this,
|
getFFZ: () => this,
|
||||||
isReady: () => this.enabled,
|
isReady: () => this.enabled,
|
||||||
getAddons: () => Object.values(this.addons),
|
getAddons: () => Object.values(this.addons),
|
||||||
hasAddon: id => this.hasAddon(id),
|
hasAddon: (id: string) => this.hasAddon(id),
|
||||||
getVersion: id => this.getVersion(id),
|
getVersion: (id: string) => this.getVersion(id),
|
||||||
doesAddonTarget: id => this.doesAddonTarget(id),
|
doesAddonTarget: (id: string) => this.doesAddonTarget(id),
|
||||||
isAddonEnabled: id => this.isAddonEnabled(id),
|
isAddonEnabled: (id: string) => this.isAddonEnabled(id),
|
||||||
isAddonExternal: id => this.isAddonExternal(id),
|
isAddonExternal: (id: string) => this.isAddonExternal(id),
|
||||||
enableAddon: id => this.enableAddon(id),
|
enableAddon: (id: string) => this.enableAddon(id),
|
||||||
disableAddon: id => this.disableAddon(id),
|
disableAddon: (id: string) => this.disableAddon(id),
|
||||||
reloadAddon: id => this.reloadAddon(id),
|
reloadAddon: (id: string) => this.reloadAddon(id),
|
||||||
canReloadAddon: id => this.canReloadAddon(id),
|
canReloadAddon: (id: string) => this.canReloadAddon(id),
|
||||||
isReloadRequired: () => this.reload_required,
|
isReloadRequired: () => this.reload_required,
|
||||||
refresh: () => window.location.reload(),
|
refresh: () => window.location.reload(),
|
||||||
|
|
||||||
on: (...args) => this.on(...args),
|
on: (...args: Parameters<typeof this.on>) => this.on(...args),
|
||||||
off: (...args) => this.off(...args)
|
off: (...args: Parameters<typeof this.off>) => this.off(...args)
|
||||||
});
|
});
|
||||||
|
|
||||||
if ( ! EXTENSION )
|
if ( ! EXTENSION )
|
||||||
|
@ -85,7 +142,7 @@ export default class AddonManager extends Module {
|
||||||
|
|
||||||
this.settings.provider.on('changed', this.onProviderChange, this);
|
this.settings.provider.on('changed', this.onProviderChange, this);
|
||||||
|
|
||||||
this._loader.then(() => {
|
this._loader?.then(() => {
|
||||||
this.enabled_addons = this.settings.provider.get('addons.enabled', []);
|
this.enabled_addons = this.settings.provider.get('addons.enabled', []);
|
||||||
|
|
||||||
// We do not await enabling add-ons because that would delay the
|
// We do not await enabling add-ons because that would delay the
|
||||||
|
@ -103,8 +160,8 @@ export default class AddonManager extends Module {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
doesAddonTarget(id) {
|
doesAddonTarget(id: string) {
|
||||||
const data = this.addons[id];
|
const data = this.getAddon(id);
|
||||||
if ( ! data )
|
if ( ! data )
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
@ -118,12 +175,15 @@ export default class AddonManager extends Module {
|
||||||
|
|
||||||
generateLog() {
|
generateLog() {
|
||||||
const out = ['Known'];
|
const out = ['Known'];
|
||||||
for(const [id, addon] of Object.entries(this.addons))
|
for(const [id, addon] of Object.entries(this.addons)) {
|
||||||
|
if ( Array.isArray(addon) )
|
||||||
|
continue;
|
||||||
out.push(`${id} | ${this.isAddonEnabled(id) ? 'enabled' : 'disabled'} | ${addon.dev ? 'dev | ' : ''}${this.isAddonExternal(id) ? 'external | ' : ''}${addon.short_name} v${addon.version}`);
|
out.push(`${id} | ${this.isAddonEnabled(id) ? 'enabled' : 'disabled'} | ${addon.dev ? 'dev | ' : ''}${this.isAddonExternal(id) ? 'external | ' : ''}${addon.short_name} v${addon.version}`);
|
||||||
|
}
|
||||||
|
|
||||||
out.push('');
|
out.push('');
|
||||||
out.push('Modules');
|
out.push('Modules');
|
||||||
for(const [key, module] of Object.entries(this.__modules)) {
|
for(const [key, module] of Object.entries((this as any).__modules as Record<string, GenericModule>)) {
|
||||||
if ( module )
|
if ( module )
|
||||||
out.push(`${module.loaded ? 'loaded ' : module.loading ? 'loading ' : 'unloaded'} | ${module.enabled ? 'enabled ' : module.enabling ? 'enabling' : 'disabled'} | ${key}`)
|
out.push(`${module.loaded ? 'loaded ' : module.loading ? 'loading ' : 'unloaded'} | ${module.enabled ? 'enabled ' : module.enabling ? 'enabling' : 'disabled'} | ${key}`)
|
||||||
}
|
}
|
||||||
|
@ -131,22 +191,20 @@ export default class AddonManager extends Module {
|
||||||
return out.join('\n');
|
return out.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
onProviderChange(key, value) {
|
onProviderChange(key: string, value: unknown) {
|
||||||
if ( key != 'addons.enabled' )
|
if ( key != 'addons.enabled' )
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if ( ! value )
|
const val: string[] = Array.isArray(value) ? value : [],
|
||||||
value = [];
|
old_enabled = [...this.enabled_addons];
|
||||||
|
|
||||||
const old_enabled = [...this.enabled_addons];
|
|
||||||
|
|
||||||
// Add-ons to disable
|
// Add-ons to disable
|
||||||
for(const id of old_enabled)
|
for(const id of old_enabled)
|
||||||
if ( ! value.includes(id) )
|
if ( ! val.includes(id) )
|
||||||
this.disableAddon(id, false);
|
this.disableAddon(id, false);
|
||||||
|
|
||||||
// Add-ons to enable
|
// Add-ons to enable
|
||||||
for(const id of value)
|
for(const id of val)
|
||||||
if ( ! old_enabled.includes(id) )
|
if ( ! old_enabled.includes(id) )
|
||||||
this.enableAddon(id, false);
|
this.enableAddon(id, false);
|
||||||
}
|
}
|
||||||
|
@ -187,7 +245,9 @@ export default class AddonManager extends Module {
|
||||||
this.emit(':data-loaded');
|
this.emit(':data-loaded');
|
||||||
}
|
}
|
||||||
|
|
||||||
addAddon(addon, is_dev = false) {
|
addAddon(input: AddonInfo, is_dev: boolean = false) {
|
||||||
|
let addon = input as FullAddonInfo;
|
||||||
|
|
||||||
const old = this.addons[addon.id];
|
const old = this.addons[addon.id];
|
||||||
this.addons[addon.id] = addon;
|
this.addons[addon.id] = addon;
|
||||||
|
|
||||||
|
@ -217,7 +277,7 @@ export default class AddonManager extends Module {
|
||||||
this.addons[id] = [addon.id];
|
this.addons[id] = [addon.id];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( ! old )
|
if ( ! old || Array.isArray(old) )
|
||||||
this.settings.addUI(`addon-changelog.${addon.id}`, {
|
this.settings.addUI(`addon-changelog.${addon.id}`, {
|
||||||
path: `Add-Ons > Changelog > ${addon.name}`,
|
path: `Add-Ons > Changelog > ${addon.name}`,
|
||||||
component: 'changelog',
|
component: 'changelog',
|
||||||
|
@ -227,11 +287,14 @@ export default class AddonManager extends Module {
|
||||||
getFFZ: () => this
|
getFFZ: () => this
|
||||||
});
|
});
|
||||||
|
|
||||||
this.emit(':added');
|
this.emit(':added', addon.id, addon);
|
||||||
}
|
}
|
||||||
|
|
||||||
rebuildAddonSearch() {
|
rebuildAddonSearch() {
|
||||||
for(const addon of Object.values(this.addons)) {
|
for(const addon of Object.values(this.addons)) {
|
||||||
|
if ( Array.isArray(addon) )
|
||||||
|
continue;
|
||||||
|
|
||||||
const terms = new Set([
|
const terms = new Set([
|
||||||
addon._search,
|
addon._search,
|
||||||
addon.name,
|
addon.name,
|
||||||
|
@ -250,47 +313,51 @@ export default class AddonManager extends Module {
|
||||||
if ( addon.author_i18n )
|
if ( addon.author_i18n )
|
||||||
terms.add(this.i18n.t(addon.author_i18n, addon.author));
|
terms.add(this.i18n.t(addon.author_i18n, addon.author));
|
||||||
|
|
||||||
|
if ( addon.maintainer_i18n )
|
||||||
|
terms.add(this.i18n.t(addon.maintainer_i18n, addon.maintainer));
|
||||||
|
|
||||||
if ( addon.description_i18n )
|
if ( addon.description_i18n )
|
||||||
terms.add(this.i18n.t(addon.description_i18n, addon.description));
|
terms.add(this.i18n.t(addon.description_i18n, addon.description));
|
||||||
}
|
}
|
||||||
|
|
||||||
addon.search_terms = [...terms].map(term => term ? term.toLocaleLowerCase() : '').join('\n');
|
addon.search_terms = [...terms]
|
||||||
|
.map(term => term ? term.toLocaleLowerCase() : '').join('\n');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
isAddonEnabled(id) {
|
isAddonEnabled(id: string) {
|
||||||
if ( this.isAddonExternal(id) )
|
if ( this.isAddonExternal(id) )
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
return this.enabled_addons.includes(id);
|
return this.enabled_addons.includes(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
getAddon(id) {
|
getAddon(id: string) {
|
||||||
const addon = this.addons[id];
|
const addon = this.addons[id];
|
||||||
return Array.isArray(addon) ? null : addon;
|
return Array.isArray(addon) ? null : addon;
|
||||||
}
|
}
|
||||||
|
|
||||||
hasAddon(id) {
|
hasAddon(id: string) {
|
||||||
return this.getAddon(id) != null;
|
return this.getAddon(id) != null;
|
||||||
}
|
}
|
||||||
|
|
||||||
getVersion(id) {
|
getVersion(id: string) {
|
||||||
const addon = this.getAddon(id);
|
const addon = this.getAddon(id);
|
||||||
if ( ! addon )
|
if ( ! addon )
|
||||||
throw new Error(`Unknown add-on id: ${id}`);
|
throw new Error(`Unknown add-on id: ${id}`);
|
||||||
|
|
||||||
const module = this.resolve(`addon.${id}`);
|
const module = this.resolve(`addon.${id}`);
|
||||||
if ( module ) {
|
if ( module ) {
|
||||||
if ( has(module, 'version') )
|
if ( 'version' in module ) // has(module, 'version') )
|
||||||
return module.version;
|
return module.version;
|
||||||
else if ( module.constructor && has(module.constructor, 'version') )
|
else if ( module.constructor && 'version' in module.constructor ) // has(module.constructor, 'version') )
|
||||||
return module.constructor.version;
|
return module.constructor.version;
|
||||||
}
|
}
|
||||||
|
|
||||||
return addon.version;
|
return addon.version;
|
||||||
}
|
}
|
||||||
|
|
||||||
isAddonExternal(id) {
|
isAddonExternal(id: string) {
|
||||||
if ( ! this.hasAddon(id) )
|
if ( ! this.hasAddon(id) )
|
||||||
throw new Error(`Unknown add-on id: ${id}`);
|
throw new Error(`Unknown add-on id: ${id}`);
|
||||||
|
|
||||||
|
@ -306,10 +373,10 @@ export default class AddonManager extends Module {
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
// Finally, let the module flag itself as external.
|
// Finally, let the module flag itself as external.
|
||||||
return module.external || (module.constructor && module.constructor.external);
|
return (module as any).external || (module.constructor as any)?.external;
|
||||||
}
|
}
|
||||||
|
|
||||||
canReloadAddon(id) {
|
canReloadAddon(id: string) {
|
||||||
// Obviously we can't reload it if we don't have it.
|
// Obviously we can't reload it if we don't have it.
|
||||||
if ( ! this.hasAddon(id) )
|
if ( ! this.hasAddon(id) )
|
||||||
throw new Error(`Unknown add-on id: ${id}`);
|
throw new Error(`Unknown add-on id: ${id}`);
|
||||||
|
@ -334,8 +401,8 @@ export default class AddonManager extends Module {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
async fullyUnloadModule(module) {
|
async fullyUnloadModule(module: GenericModule) {
|
||||||
if ( ! module )
|
if ( ! module || ! module.addon_id )
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if ( module.children )
|
if ( module.children )
|
||||||
|
@ -346,47 +413,47 @@ export default class AddonManager extends Module {
|
||||||
await module.unload();
|
await module.unload();
|
||||||
|
|
||||||
// Clean up parent references.
|
// Clean up parent references.
|
||||||
if ( module.parent && module.parent.children[module.name] === module )
|
if ( module.parent instanceof Module && module.parent.children[module.name] === module )
|
||||||
delete module.parent.children[module.name];
|
delete module.parent.children[module.name];
|
||||||
|
|
||||||
// Clean up all individual references.
|
// Clean up all individual references.
|
||||||
for(const entry of module.references) {
|
for(const entry of module.references) {
|
||||||
const other = this.resolve(entry[0]),
|
const other = this.resolve(entry[0]),
|
||||||
name = entry[1];
|
name = entry[1];
|
||||||
if ( other && other[name] === module )
|
if ( (other as any)[name] === module )
|
||||||
other[name] = null;
|
(other as any)[name] = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send off a signal for other modules to unload related data.
|
// Send off a signal for other modules to unload related data.
|
||||||
this.emit('addon:fully-unload', module.addon_id);
|
this.emit(':fully-unload', module.addon_id);
|
||||||
|
|
||||||
// Clean up the global reference.
|
// Clean up the global reference.
|
||||||
if ( this.__modules[module.__path] === module )
|
if ( (this as any).__modules[(module as any).__path] === module )
|
||||||
delete this.__modules[module.__path]; /* = [
|
delete (this as any).__modules[(module as any).__path]; /* = [
|
||||||
module.dependents,
|
module.dependents,
|
||||||
module.load_dependents,
|
module.load_dependents,
|
||||||
module.references
|
module.references
|
||||||
];*/
|
];*/
|
||||||
|
|
||||||
// Remove any events we didn't unregister.
|
// Remove any events we didn't unregister.
|
||||||
this.offContext(null, module);
|
this.off(undefined, undefined, module);
|
||||||
|
|
||||||
// Do the same for settings.
|
// Do the same for settings.
|
||||||
for(const ctx of this.settings.__contexts)
|
for(const ctx of this.settings.__contexts)
|
||||||
ctx.offContext(null, module);
|
ctx.off(undefined, undefined, module);
|
||||||
|
|
||||||
// Clean up all settings.
|
// Clean up all settings.
|
||||||
for(const [key, def] of Array.from(this.settings.definitions.entries())) {
|
for(const [key, def] of Array.from(this.settings.definitions.entries())) {
|
||||||
if ( def && def.__source === module.addon_id ) {
|
if ( ! Array.isArray(def) && def?.__source === module.addon_id ) {
|
||||||
this.settings.remove(key);
|
this.settings.remove(key);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clean up the logger too.
|
// Clean up the logger too.
|
||||||
module.__log = null;
|
(module as any).__log = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async reloadAddon(id) {
|
async reloadAddon(id: string) {
|
||||||
const addon = this.getAddon(id),
|
const addon = this.getAddon(id),
|
||||||
button = this.resolve('site.menu_button');
|
button = this.resolve('site.menu_button');
|
||||||
if ( ! addon )
|
if ( ! addon )
|
||||||
|
@ -456,7 +523,7 @@ export default class AddonManager extends Module {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async _enableAddon(id) {
|
private async _enableAddon(id: string) {
|
||||||
const addon = this.getAddon(id);
|
const addon = this.getAddon(id);
|
||||||
if ( ! addon )
|
if ( ! addon )
|
||||||
throw new Error(`Unknown add-on id: ${id}`);
|
throw new Error(`Unknown add-on id: ${id}`);
|
||||||
|
@ -476,7 +543,7 @@ export default class AddonManager extends Module {
|
||||||
this.load_tracker.notify(event, `addon.${id}`, false);
|
this.load_tracker.notify(event, `addon.${id}`, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadAddon(id) {
|
async loadAddon(id: string) {
|
||||||
const addon = this.getAddon(id);
|
const addon = this.getAddon(id);
|
||||||
if ( ! addon )
|
if ( ! addon )
|
||||||
throw new Error(`Unknown add-on id: ${id}`);
|
throw new Error(`Unknown add-on id: ${id}`);
|
||||||
|
@ -500,7 +567,7 @@ export default class AddonManager extends Module {
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Error if this takes more than 5 seconds.
|
// Error if this takes more than 5 seconds.
|
||||||
await timeout(this.waitFor(`addon.${id}:registered`), 60000);
|
await timeout(this.waitFor(`addon.${id}:registered` as any), 60000);
|
||||||
|
|
||||||
module = this.resolve(`addon.${id}`);
|
module = this.resolve(`addon.${id}`);
|
||||||
if ( module && ! module.loaded )
|
if ( module && ! module.loaded )
|
||||||
|
@ -509,13 +576,13 @@ export default class AddonManager extends Module {
|
||||||
this.emit(':addon-loaded', id);
|
this.emit(':addon-loaded', id);
|
||||||
}
|
}
|
||||||
|
|
||||||
unloadAddon(id) {
|
unloadAddon(id: string) {
|
||||||
const module = this.resolve(`addon.${id}`);
|
const module = this.resolve(`addon.${id}`);
|
||||||
if ( module )
|
if ( module )
|
||||||
return module.unload();
|
return module.unload();
|
||||||
}
|
}
|
||||||
|
|
||||||
enableAddon(id, save = true) {
|
enableAddon(id: string, save: boolean = true) {
|
||||||
const addon = this.getAddon(id);
|
const addon = this.getAddon(id);
|
||||||
if( ! addon )
|
if( ! addon )
|
||||||
throw new Error(`Unknown add-on id: ${id}`);
|
throw new Error(`Unknown add-on id: ${id}`);
|
||||||
|
@ -546,7 +613,7 @@ export default class AddonManager extends Module {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async disableAddon(id, save = true) {
|
async disableAddon(id: string, save: boolean = true) {
|
||||||
const addon = this.getAddon(id);
|
const addon = this.getAddon(id);
|
||||||
if ( ! addon )
|
if ( ! addon )
|
||||||
throw new Error(`Unknown add-on id: ${id}`);
|
throw new Error(`Unknown add-on id: ${id}`);
|
|
@ -12,13 +12,13 @@ import {timeout} from 'utilities/object';
|
||||||
import SettingsManager from './settings/index';
|
import SettingsManager from './settings/index';
|
||||||
import AddonManager from './addons';
|
import AddonManager from './addons';
|
||||||
import ExperimentManager from './experiments';
|
import ExperimentManager from './experiments';
|
||||||
import {TranslationManager} from './i18n';
|
import TranslationManager from './i18n';
|
||||||
import PubSubClient from './pubsub';
|
import PubSubClient from './pubsub';
|
||||||
import StagingSelector from './staging';
|
import StagingSelector from './staging';
|
||||||
import LoadTracker from './load_tracker';
|
import LoadTracker from './load_tracker';
|
||||||
|
|
||||||
import Site from './sites/clips';
|
import Site from './sites/clips';
|
||||||
import Vue from 'utilities/vue';
|
import VueModule from 'utilities/vue';
|
||||||
|
|
||||||
import Tooltips from 'src/modules/tooltips';
|
import Tooltips from 'src/modules/tooltips';
|
||||||
import Chat from 'src/modules/chat';
|
import Chat from 'src/modules/chat';
|
||||||
|
@ -64,7 +64,7 @@ class FrankerFaceZ extends Module {
|
||||||
this.inject('site', Site);
|
this.inject('site', Site);
|
||||||
this.inject('addons', AddonManager);
|
this.inject('addons', AddonManager);
|
||||||
|
|
||||||
this.register('vue', Vue);
|
this.register('vue', VueModule);
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// Startup
|
// Startup
|
||||||
|
|
|
@ -26,4 +26,4 @@
|
||||||
script.crossOrigin = 'anonymous';
|
script.crossOrigin = 'anonymous';
|
||||||
script.src = `${SERVER}/script/${FLAVOR}.js?_=${Date.now()}`;
|
script.src = `${SERVER}/script/${FLAVOR}.js?_=${Date.now()}`;
|
||||||
document.head.appendChild(script);
|
document.head.appendChild(script);
|
||||||
})();
|
})();
|
||||||
|
|
|
@ -1,479 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Experiments
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import {DEBUG, SERVER} from 'utilities/constants';
|
|
||||||
import Module from 'utilities/module';
|
|
||||||
import {has, deep_copy} from 'utilities/object';
|
|
||||||
import { getBuster } from 'utilities/time';
|
|
||||||
|
|
||||||
import Cookie from 'js-cookie';
|
|
||||||
import SHA1 from 'crypto-js/sha1';
|
|
||||||
|
|
||||||
const OVERRIDE_COOKIE = 'experiment_overrides',
|
|
||||||
COOKIE_OPTIONS = {
|
|
||||||
expires: 7,
|
|
||||||
domain: '.twitch.tv'
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// We want to import this so that the file is included in the output.
|
|
||||||
// We don't load using this because we might want a newer file from the
|
|
||||||
// server.
|
|
||||||
import EXPERIMENTS from './experiments.json'; // eslint-disable-line no-unused-vars
|
|
||||||
|
|
||||||
|
|
||||||
function sortExperimentLog(a,b) {
|
|
||||||
if ( a.rarity < b.rarity )
|
|
||||||
return -1;
|
|
||||||
else if ( a.rarity > b.rarity )
|
|
||||||
return 1;
|
|
||||||
|
|
||||||
if ( a.name < b.name )
|
|
||||||
return -1;
|
|
||||||
else if ( a.name > b.name )
|
|
||||||
return 1;
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Experiment Manager
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export default class ExperimentManager extends Module {
|
|
||||||
constructor(...args) {
|
|
||||||
super(...args);
|
|
||||||
|
|
||||||
this.get = this.getAssignment;
|
|
||||||
|
|
||||||
this.inject('settings');
|
|
||||||
|
|
||||||
this.settings.addUI('experiments', {
|
|
||||||
path: 'Debugging > Experiments',
|
|
||||||
component: 'experiments',
|
|
||||||
no_filter: true,
|
|
||||||
|
|
||||||
getExtraTerms: () => {
|
|
||||||
const values = [];
|
|
||||||
|
|
||||||
for(const exps of [this.experiments, this.getTwitchExperiments()]) {
|
|
||||||
if ( ! exps )
|
|
||||||
continue;
|
|
||||||
|
|
||||||
for(const [key, val] of Object.entries(exps)) {
|
|
||||||
values.push(key);
|
|
||||||
if ( val.name )
|
|
||||||
values.push(val.name);
|
|
||||||
if ( val.description )
|
|
||||||
values.push(val.description);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return values;
|
|
||||||
},
|
|
||||||
|
|
||||||
is_locked: () => this.getControlsLocked(),
|
|
||||||
unlock: () => this.unlockControls(),
|
|
||||||
|
|
||||||
unique_id: () => this.unique_id,
|
|
||||||
|
|
||||||
ffz_data: () => deep_copy(this.experiments),
|
|
||||||
twitch_data: () => deep_copy(this.getTwitchExperiments()),
|
|
||||||
|
|
||||||
usingTwitchExperiment: key => this.usingTwitchExperiment(key),
|
|
||||||
getTwitchAssignment: key => this.getTwitchAssignment(key),
|
|
||||||
getTwitchType: type => this.getTwitchType(type),
|
|
||||||
hasTwitchOverride: key => this.hasTwitchOverride(key),
|
|
||||||
setTwitchOverride: (key, val) => this.setTwitchOverride(key, val),
|
|
||||||
deleteTwitchOverride: key => this.deleteTwitchOverride(key),
|
|
||||||
|
|
||||||
getAssignment: key => this.getAssignment(key),
|
|
||||||
hasOverride: key => this.hasOverride(key),
|
|
||||||
setOverride: (key, val) => this.setOverride(key, val),
|
|
||||||
deleteOverride: key => this.deleteOverride(key),
|
|
||||||
|
|
||||||
on: (...args) => this.on(...args),
|
|
||||||
off: (...args) => this.off(...args)
|
|
||||||
});
|
|
||||||
|
|
||||||
this.unique_id = Cookie.get('unique_id');
|
|
||||||
|
|
||||||
this.Cookie = Cookie;
|
|
||||||
|
|
||||||
this.experiments = {};
|
|
||||||
this.cache = new Map;
|
|
||||||
}
|
|
||||||
|
|
||||||
getControlsLocked() {
|
|
||||||
if ( DEBUG )
|
|
||||||
return false;
|
|
||||||
|
|
||||||
const ts = this.settings.provider.get('exp-lock', 0);
|
|
||||||
if ( isNaN(ts) || ! isFinite(ts) )
|
|
||||||
return true;
|
|
||||||
|
|
||||||
return Date.now() - ts >= 86400000;
|
|
||||||
}
|
|
||||||
|
|
||||||
unlockControls() {
|
|
||||||
this.settings.provider.set('exp-lock', Date.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
async onLoad() {
|
|
||||||
await this.loadExperiments();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async loadExperiments() {
|
|
||||||
let data;
|
|
||||||
|
|
||||||
try {
|
|
||||||
data = await fetch(DEBUG ? EXPERIMENTS : `${SERVER}/script/experiments.json?_=${getBuster()}`).then(r =>
|
|
||||||
r.ok ? r.json() : null);
|
|
||||||
|
|
||||||
} catch(err) {
|
|
||||||
this.log.warn('Unable to load experiment data.', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( ! data )
|
|
||||||
return;
|
|
||||||
|
|
||||||
this.experiments = data;
|
|
||||||
|
|
||||||
const old_cache = this.cache;
|
|
||||||
this.cache = new Map;
|
|
||||||
|
|
||||||
let changed = 0;
|
|
||||||
|
|
||||||
for(const [key, old_val] of old_cache.entries()) {
|
|
||||||
const new_val = this.getAssignment(key);
|
|
||||||
if ( old_val !== new_val ) {
|
|
||||||
changed++;
|
|
||||||
this.emit(':changed', key, new_val);
|
|
||||||
this.emit(`:changed:${key}`, new_val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.log.info(`Loaded information on ${Object.keys(data).length} experiments.${changed > 0 ? ` ${changed} values updated.` : ''}`);
|
|
||||||
//this.emit(':loaded');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
onEnable() {
|
|
||||||
this.on('pubsub:command:reload_experiments', this.loadExperiments, this);
|
|
||||||
this.on('pubsub:command:update_experiment', this.updateExperiment, this);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
updateExperiment(key, data) {
|
|
||||||
this.log.info(`Received updated data for experiment "${key}" via WebSocket.`, data);
|
|
||||||
|
|
||||||
if ( data.groups )
|
|
||||||
this.experiments[key] = data;
|
|
||||||
else
|
|
||||||
this.experiments[key].groups = data;
|
|
||||||
|
|
||||||
this._rebuildKey(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
generateLog() {
|
|
||||||
const out = [
|
|
||||||
`Unique ID: ${this.unique_id}`,
|
|
||||||
''
|
|
||||||
];
|
|
||||||
|
|
||||||
const ffz_assignments = [];
|
|
||||||
for(const [key, value] of Object.entries(this.experiments)) {
|
|
||||||
const assignment = this.getAssignment(key),
|
|
||||||
override = this.hasOverride(key);
|
|
||||||
|
|
||||||
let weight = 0, total = 0;
|
|
||||||
for(const group of value.groups) {
|
|
||||||
if ( group.value === assignment )
|
|
||||||
weight = group.weight;
|
|
||||||
total += group.weight;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( ! override && weight === total )
|
|
||||||
continue;
|
|
||||||
|
|
||||||
ffz_assignments.push({
|
|
||||||
key,
|
|
||||||
name: value.name,
|
|
||||||
value: assignment,
|
|
||||||
override,
|
|
||||||
rarity: weight / total
|
|
||||||
});
|
|
||||||
|
|
||||||
//out.push(`FFZ | ${value.name}: ${this.getAssignment(key)}${this.hasOverride(key) ? ' (Overriden)' : ''}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
ffz_assignments.sort(sortExperimentLog);
|
|
||||||
|
|
||||||
for(const entry of ffz_assignments)
|
|
||||||
out.push(`FFZ | ${entry.name}: ${entry.value}${entry.override ? ' (Override)' : ''} (r:${entry.rarity})`);
|
|
||||||
|
|
||||||
const twitch_assignments = [],
|
|
||||||
channel = this.settings.get('context.channel');
|
|
||||||
|
|
||||||
for(const [key, value] of Object.entries(this.getTwitchExperiments())) {
|
|
||||||
if ( ! this.usingTwitchExperiment(key) )
|
|
||||||
continue;
|
|
||||||
|
|
||||||
const assignment = this.getTwitchAssignment(key),
|
|
||||||
override = this.hasTwitchOverride(key);
|
|
||||||
|
|
||||||
let weight = 0, total = 0;
|
|
||||||
for(const group of value.groups) {
|
|
||||||
if ( group.value === assignment )
|
|
||||||
weight = group.weight;
|
|
||||||
total += group.weight;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( ! override && weight === total )
|
|
||||||
continue;
|
|
||||||
|
|
||||||
twitch_assignments.push({
|
|
||||||
key,
|
|
||||||
name: value.name,
|
|
||||||
value: assignment,
|
|
||||||
override,
|
|
||||||
type: this.getTwitchTypeByKey(key),
|
|
||||||
rarity: weight / total
|
|
||||||
});
|
|
||||||
|
|
||||||
//out.push(`TWITCH | ${value.name}: ${this.getTwitchAssignment(key)}${this.hasTwitchOverride(key) ? ' (Overriden)' : ''}`)
|
|
||||||
}
|
|
||||||
|
|
||||||
twitch_assignments.sort(sortExperimentLog);
|
|
||||||
|
|
||||||
for(const entry of twitch_assignments)
|
|
||||||
out.push(`Twitch | ${entry.name}: ${entry.value}${entry.override ? ' (Override)' : ''} (r:${entry.rarity}, t:${entry.type}${entry.type === 'channel_id' ? `, c:${channel}`: ''})`);
|
|
||||||
|
|
||||||
return out.join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// Twitch Experiments
|
|
||||||
|
|
||||||
getTwitchType(type) {
|
|
||||||
const core = this.resolve('site')?.getCore?.();
|
|
||||||
if ( core?.experiments?.getExperimentType )
|
|
||||||
return core.experiments.getExperimentType(type);
|
|
||||||
|
|
||||||
if ( type === 1 )
|
|
||||||
return 'device_id';
|
|
||||||
else if ( type === 2 )
|
|
||||||
return 'user_id';
|
|
||||||
else if ( type === 3 )
|
|
||||||
return 'channel_id';
|
|
||||||
return type;
|
|
||||||
}
|
|
||||||
|
|
||||||
getTwitchTypeByKey(key) {
|
|
||||||
const core = this.resolve('site')?.getCore?.(),
|
|
||||||
exps = core && core.experiments,
|
|
||||||
exp = exps?.experiments?.[key];
|
|
||||||
|
|
||||||
if ( exp?.t )
|
|
||||||
return this.getTwitchType(exp.t);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
getTwitchExperiments() {
|
|
||||||
if ( window.__twilightSettings )
|
|
||||||
return window.__twilightSettings.experiments;
|
|
||||||
|
|
||||||
const core = this.resolve('site')?.getCore?.();
|
|
||||||
return core && core.experiments.experiments;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
usingTwitchExperiment(key) {
|
|
||||||
const core = this.resolve('site')?.getCore?.();
|
|
||||||
return core && has(core.experiments.assignments, key)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
setTwitchOverride(key, value = null) {
|
|
||||||
const overrides = Cookie.getJSON(OVERRIDE_COOKIE) || {};
|
|
||||||
const experiments = overrides.experiments = overrides.experiments || {};
|
|
||||||
const disabled = overrides.disabled = overrides.disabled || [];
|
|
||||||
experiments[key] = value;
|
|
||||||
const idx = disabled.indexOf(key);
|
|
||||||
if (idx != -1)
|
|
||||||
disabled.remove(idx);
|
|
||||||
Cookie.set(OVERRIDE_COOKIE, overrides, COOKIE_OPTIONS);
|
|
||||||
|
|
||||||
const core = this.resolve('site')?.getCore?.();
|
|
||||||
if ( core )
|
|
||||||
core.experiments.overrides[key] = value;
|
|
||||||
|
|
||||||
this._rebuildTwitchKey(key, true, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteTwitchOverride(key) {
|
|
||||||
const overrides = Cookie.getJSON(OVERRIDE_COOKIE),
|
|
||||||
experiments = overrides?.experiments;
|
|
||||||
if ( ! experiments || ! has(experiments, key) )
|
|
||||||
return;
|
|
||||||
|
|
||||||
const old_val = experiments[key];
|
|
||||||
delete experiments[key];
|
|
||||||
Cookie.set(OVERRIDE_COOKIE, overrides, COOKIE_OPTIONS);
|
|
||||||
|
|
||||||
const core = this.resolve('site')?.getCore?.();
|
|
||||||
if ( core )
|
|
||||||
delete core.experiments.overrides[key];
|
|
||||||
|
|
||||||
this._rebuildTwitchKey(key, false, old_val);
|
|
||||||
}
|
|
||||||
|
|
||||||
hasTwitchOverride(key) { // eslint-disable-line class-methods-use-this
|
|
||||||
const overrides = Cookie.getJSON(OVERRIDE_COOKIE),
|
|
||||||
experiments = overrides?.experiments;
|
|
||||||
return experiments && has(experiments, key);
|
|
||||||
}
|
|
||||||
|
|
||||||
getTwitchAssignment(key, channel = null) {
|
|
||||||
const core = this.resolve('site')?.getCore?.(),
|
|
||||||
exps = core && core.experiments;
|
|
||||||
|
|
||||||
if ( ! exps )
|
|
||||||
return null;
|
|
||||||
|
|
||||||
if ( ! exps.hasInitialized && exps.initialize )
|
|
||||||
try {
|
|
||||||
exps.initialize();
|
|
||||||
} catch(err) {
|
|
||||||
this.log.warn('Error attempting to initialize Twitch experiments tracker.', err);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( channel || this.getTwitchType(exps.experiments[key]?.t) === 'channel_id' )
|
|
||||||
return exps.getAssignmentById(key, {channel: channel ?? this.settings.get('context.channel')});
|
|
||||||
|
|
||||||
if ( exps.overrides && exps.overrides[key] )
|
|
||||||
return exps.overrides[key];
|
|
||||||
|
|
||||||
else if ( exps.assignments && exps.assignments[key] )
|
|
||||||
return exps.assignments[key];
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
getTwitchKeyFromName(name) {
|
|
||||||
const experiments = this.getTwitchExperiments();
|
|
||||||
if ( ! experiments )
|
|
||||||
return undefined;
|
|
||||||
|
|
||||||
name = name.toLowerCase();
|
|
||||||
for(const key in experiments)
|
|
||||||
if ( has(experiments, key) ) {
|
|
||||||
const data = experiments[key];
|
|
||||||
if ( data && data.name && data.name.toLowerCase() === name )
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getTwitchAssignmentByName(name, channel = null) {
|
|
||||||
return this.getTwitchAssignment(this.getTwitchKeyFromName(name), channel);
|
|
||||||
}
|
|
||||||
|
|
||||||
_rebuildTwitchKey(key, is_set, new_val) {
|
|
||||||
const core = this.resolve('site')?.getCore?.(),
|
|
||||||
exps = core.experiments,
|
|
||||||
|
|
||||||
old_val = has(exps.assignments, key) ?
|
|
||||||
exps.assignments[key] :
|
|
||||||
undefined;
|
|
||||||
|
|
||||||
if ( old_val !== new_val ) {
|
|
||||||
const value = is_set ? new_val : old_val;
|
|
||||||
this.emit(':twitch-changed', key, value);
|
|
||||||
this.emit(`:twitch-changed:${key}`, value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// FFZ Experiments
|
|
||||||
|
|
||||||
setOverride(key, value = null) {
|
|
||||||
const overrides = this.settings.provider.get('experiment-overrides') || {};
|
|
||||||
overrides[key] = value;
|
|
||||||
|
|
||||||
this.settings.provider.set('experiment-overrides', overrides);
|
|
||||||
|
|
||||||
this._rebuildKey(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteOverride(key) {
|
|
||||||
const overrides = this.settings.provider.get('experiment-overrides');
|
|
||||||
if ( ! overrides || ! has(overrides, key) )
|
|
||||||
return;
|
|
||||||
|
|
||||||
delete overrides[key];
|
|
||||||
this.settings.provider.set('experiment-overrides', overrides);
|
|
||||||
|
|
||||||
this._rebuildKey(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
hasOverride(key) {
|
|
||||||
const overrides = this.settings.provider.get('experiment-overrides');
|
|
||||||
return overrides && has(overrides, key);
|
|
||||||
}
|
|
||||||
|
|
||||||
getAssignment(key) {
|
|
||||||
if ( this.cache.has(key) )
|
|
||||||
return this.cache.get(key);
|
|
||||||
|
|
||||||
const experiment = this.experiments[key];
|
|
||||||
if ( ! experiment ) {
|
|
||||||
this.log.warn(`Tried to get assignment for experiment "${key}" which is not known.`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const overrides = this.settings.provider.get('experiment-overrides'),
|
|
||||||
out = overrides && has(overrides, key) ?
|
|
||||||
overrides[key] :
|
|
||||||
ExperimentManager.selectGroup(key, experiment, this.unique_id);
|
|
||||||
|
|
||||||
this.cache.set(key, out);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
_rebuildKey(key) {
|
|
||||||
if ( ! this.cache.has(key) )
|
|
||||||
return;
|
|
||||||
|
|
||||||
const old_val = this.cache.get(key);
|
|
||||||
this.cache.delete(key);
|
|
||||||
const new_val = this.getAssignment(key);
|
|
||||||
|
|
||||||
if ( new_val !== old_val ) {
|
|
||||||
this.emit(':changed', key, new_val);
|
|
||||||
this.emit(`:changed:${key}`, new_val);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static selectGroup(key, experiment, unique_id) {
|
|
||||||
const seed = key + unique_id + (experiment.seed || ''),
|
|
||||||
total = experiment.groups.reduce((a,b) => a + b.weight, 0);
|
|
||||||
|
|
||||||
let value = (SHA1(seed).words[0] >>> 0) / Math.pow(2, 32);
|
|
||||||
|
|
||||||
for(const group of experiment.groups) {
|
|
||||||
value -= group.weight / total;
|
|
||||||
if ( value <= 0 )
|
|
||||||
return group.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
696
src/experiments.ts
Normal file
696
src/experiments.ts
Normal file
|
@ -0,0 +1,696 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Experiments
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import {DEBUG, SERVER} from 'utilities/constants';
|
||||||
|
import Module, { GenericModule } from 'utilities/module';
|
||||||
|
import {has, deep_copy, fetchJSON} from 'utilities/object';
|
||||||
|
import { getBuster } from 'utilities/time';
|
||||||
|
|
||||||
|
import Cookie from 'js-cookie';
|
||||||
|
import SHA1 from 'crypto-js/sha1';
|
||||||
|
|
||||||
|
import type SettingsManager from './settings';
|
||||||
|
import type { ExperimentTypeMap } from 'utilities/types';
|
||||||
|
|
||||||
|
declare module 'utilities/types' {
|
||||||
|
interface ModuleMap {
|
||||||
|
experiments: ExperimentManager;
|
||||||
|
}
|
||||||
|
interface ModuleEventMap {
|
||||||
|
experiments: ExperimentEvents;
|
||||||
|
}
|
||||||
|
interface ProviderTypeMap {
|
||||||
|
'experiment-overrides': {
|
||||||
|
[K in keyof ExperimentTypeMap]?: ExperimentTypeMap[K];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
interface PubSubCommands {
|
||||||
|
reload_experiments: [];
|
||||||
|
update_experiment: {
|
||||||
|
key: keyof ExperimentTypeMap,
|
||||||
|
data: FFZExperimentData | ExperimentGroup[]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
__twilightSettings?: {
|
||||||
|
experiments?: Record<string, TwitchExperimentData>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const OVERRIDE_COOKIE = 'experiment_overrides',
|
||||||
|
COOKIE_OPTIONS = {
|
||||||
|
expires: 7,
|
||||||
|
domain: '.twitch.tv'
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// We want to import this so that the file is included in the output.
|
||||||
|
// We don't load using this because we might want a newer file from the
|
||||||
|
// server. Because of our webpack settings, this is imported as a URL
|
||||||
|
// and not an object.
|
||||||
|
const EXPERIMENTS: string = require('./experiments.json');
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Data Types
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export enum TwitchExperimentType {
|
||||||
|
Unknown = 0,
|
||||||
|
Device = 1,
|
||||||
|
User = 2,
|
||||||
|
Channel = 3
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExperimentGroup = {
|
||||||
|
value: unknown;
|
||||||
|
weight: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FFZExperimentData = {
|
||||||
|
name: string;
|
||||||
|
seed?: number;
|
||||||
|
description: string;
|
||||||
|
groups: ExperimentGroup[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type TwitchExperimentData = {
|
||||||
|
name: string;
|
||||||
|
t: TwitchExperimentType;
|
||||||
|
v: number;
|
||||||
|
groups: ExperimentGroup[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExperimentData = FFZExperimentData | TwitchExperimentData;
|
||||||
|
|
||||||
|
|
||||||
|
export type OverrideCookie = {
|
||||||
|
experiments: Record<string, string>;
|
||||||
|
disabled: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
type ExperimentEvents = {
|
||||||
|
':changed': [key: string, new_value: any, old_value: any];
|
||||||
|
':twitch-changed': [key: string, new_value: string | null, old_value: string | null];
|
||||||
|
[key: `:twitch-changed:${string}`]: [new_value: string | null, old_value: string | null];
|
||||||
|
} & {
|
||||||
|
[K in keyof ExperimentTypeMap as `:changed:${K}`]: [new_value: ExperimentTypeMap[K], old_value: ExperimentTypeMap[K] | null];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
type ExperimentLogEntry = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
value: any;
|
||||||
|
override: boolean;
|
||||||
|
rarity: number;
|
||||||
|
type?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Helper Methods
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export function isTwitchExperiment(exp: ExperimentData): exp is TwitchExperimentData {
|
||||||
|
return 't' in exp;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFFZExperiment(exp: ExperimentData): exp is FFZExperimentData {
|
||||||
|
return 'description' in exp;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortExperimentLog(a: ExperimentLogEntry, b: ExperimentLogEntry) {
|
||||||
|
if ( a.rarity < b.rarity )
|
||||||
|
return -1;
|
||||||
|
else if ( a.rarity > b.rarity )
|
||||||
|
return 1;
|
||||||
|
|
||||||
|
if ( a.name < b.name )
|
||||||
|
return -1;
|
||||||
|
else if ( a.name > b.name )
|
||||||
|
return 1;
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Experiment Manager
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export default class ExperimentManager extends Module<'experiments', ExperimentEvents> {
|
||||||
|
|
||||||
|
// Dependencies
|
||||||
|
settings: SettingsManager = null as any;
|
||||||
|
|
||||||
|
// State
|
||||||
|
unique_id?: string;
|
||||||
|
experiments: Partial<{
|
||||||
|
[K in keyof ExperimentTypeMap]: FFZExperimentData;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
private cache: Map<keyof ExperimentTypeMap, unknown>;
|
||||||
|
|
||||||
|
|
||||||
|
// Helpers
|
||||||
|
Cookie: typeof Cookie;
|
||||||
|
|
||||||
|
|
||||||
|
constructor(name?: string, parent?: GenericModule) {
|
||||||
|
super(name, parent);
|
||||||
|
|
||||||
|
this.get = this.getAssignment;
|
||||||
|
|
||||||
|
this.inject('settings');
|
||||||
|
|
||||||
|
this.settings.addUI('experiments', {
|
||||||
|
path: 'Debugging > Experiments',
|
||||||
|
component: 'experiments',
|
||||||
|
no_filter: true,
|
||||||
|
|
||||||
|
getExtraTerms: () => {
|
||||||
|
const values: string[] = [];
|
||||||
|
|
||||||
|
for(const [key, val] of Object.entries(this.experiments)) {
|
||||||
|
values.push(key);
|
||||||
|
if ( val.name )
|
||||||
|
values.push(val.name);
|
||||||
|
if ( val.description )
|
||||||
|
values.push(val.description);
|
||||||
|
}
|
||||||
|
|
||||||
|
for(const [key, val] of Object.entries(this.getTwitchExperiments())) {
|
||||||
|
values.push(key);
|
||||||
|
if ( val.name )
|
||||||
|
values.push(val.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return values;
|
||||||
|
},
|
||||||
|
|
||||||
|
is_locked: () => this.getControlsLocked(),
|
||||||
|
unlock: () => this.unlockControls(),
|
||||||
|
|
||||||
|
unique_id: () => this.unique_id,
|
||||||
|
|
||||||
|
ffz_data: () => deep_copy(this.experiments),
|
||||||
|
twitch_data: () => deep_copy(this.getTwitchExperiments()),
|
||||||
|
|
||||||
|
usingTwitchExperiment: (key: string) => this.usingTwitchExperiment(key),
|
||||||
|
getTwitchAssignment: (key: string) => this.getTwitchAssignment(key),
|
||||||
|
getTwitchType: (type: TwitchExperimentType) => this.getTwitchType(type),
|
||||||
|
hasTwitchOverride: (key: string) => this.hasTwitchOverride(key),
|
||||||
|
setTwitchOverride: (key: string, val: string) => this.setTwitchOverride(key, val),
|
||||||
|
deleteTwitchOverride: (key: string) => this.deleteTwitchOverride(key),
|
||||||
|
|
||||||
|
getAssignment: <K extends keyof ExperimentTypeMap>(key: K) => this.getAssignment(key),
|
||||||
|
hasOverride: (key: keyof ExperimentTypeMap) => this.hasOverride(key),
|
||||||
|
setOverride: <K extends keyof ExperimentTypeMap>(key: K, val: ExperimentTypeMap[K]) => this.setOverride(key, val),
|
||||||
|
deleteOverride: (key: keyof ExperimentTypeMap) => this.deleteOverride(key),
|
||||||
|
|
||||||
|
on: (...args: Parameters<typeof this.on>) => this.on(...args),
|
||||||
|
off: (...args: Parameters<typeof this.off>) => this.off(...args)
|
||||||
|
});
|
||||||
|
|
||||||
|
this.unique_id = Cookie.get('unique_id');
|
||||||
|
|
||||||
|
this.Cookie = Cookie;
|
||||||
|
|
||||||
|
this.experiments = {};
|
||||||
|
this.cache = new Map;
|
||||||
|
}
|
||||||
|
|
||||||
|
getControlsLocked() {
|
||||||
|
if ( DEBUG )
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const ts = this.settings.provider.get<number>('exp-lock', 0);
|
||||||
|
if ( isNaN(ts) || ! isFinite(ts) )
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return Date.now() - ts >= 86400000;
|
||||||
|
}
|
||||||
|
|
||||||
|
unlockControls() {
|
||||||
|
this.settings.provider.set('exp-lock', Date.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
async onLoad() {
|
||||||
|
await this.loadExperiments();
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async loadExperiments() {
|
||||||
|
let data: Record<keyof ExperimentTypeMap, FFZExperimentData> | null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
data = await fetchJSON(DEBUG
|
||||||
|
? EXPERIMENTS
|
||||||
|
: `${SERVER}/script/experiments.json?_=${getBuster()}`
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch(err) {
|
||||||
|
this.log.warn('Unable to load experiment data.', err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! data )
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.experiments = data;
|
||||||
|
|
||||||
|
const old_cache = this.cache;
|
||||||
|
this.cache = new Map;
|
||||||
|
|
||||||
|
let changed = 0;
|
||||||
|
|
||||||
|
for(const [key, old_val] of old_cache.entries()) {
|
||||||
|
const new_val = this.getAssignment(key);
|
||||||
|
if ( old_val !== new_val ) {
|
||||||
|
changed++;
|
||||||
|
this.emit(':changed', key, new_val, old_val);
|
||||||
|
this.emit(`:changed:${key as keyof ExperimentTypeMap}`, new_val as any, old_val as any);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.log.info(`Loaded information on ${Object.keys(data).length} experiments.${changed > 0 ? ` ${changed} values updated.` : ''}`);
|
||||||
|
//this.emit(':loaded');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
onEnable() {
|
||||||
|
this.on('pubsub:command:reload_experiments', this.loadExperiments, this);
|
||||||
|
this.on('pubsub:command:update_experiment', data => {
|
||||||
|
this.updateExperiment(data.key, data.data);
|
||||||
|
}, this);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
updateExperiment(key: keyof ExperimentTypeMap, data: FFZExperimentData | ExperimentGroup[]) {
|
||||||
|
this.log.info(`Received updated data for experiment "${key}" via PubSub.`, data);
|
||||||
|
|
||||||
|
if ( Array.isArray(data) ) {
|
||||||
|
const existing = this.experiments[key];
|
||||||
|
if ( ! existing )
|
||||||
|
return;
|
||||||
|
|
||||||
|
existing.groups = data;
|
||||||
|
|
||||||
|
} else if ( data?.groups )
|
||||||
|
this.experiments[key] = data;
|
||||||
|
|
||||||
|
this._rebuildKey(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
generateLog() {
|
||||||
|
const out = [
|
||||||
|
`Unique ID: ${this.unique_id}`,
|
||||||
|
''
|
||||||
|
];
|
||||||
|
|
||||||
|
const ffz_assignments: ExperimentLogEntry[] = [];
|
||||||
|
for(const [key, value] of Object.entries(this.experiments) as [keyof ExperimentTypeMap, FFZExperimentData][]) {
|
||||||
|
const assignment = this.getAssignment(key),
|
||||||
|
override = this.hasOverride(key);
|
||||||
|
|
||||||
|
let weight = 0, total = 0;
|
||||||
|
for(const group of value.groups) {
|
||||||
|
if ( group.value === assignment )
|
||||||
|
weight = group.weight;
|
||||||
|
total += group.weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! override && weight === total )
|
||||||
|
continue;
|
||||||
|
|
||||||
|
ffz_assignments.push({
|
||||||
|
key,
|
||||||
|
name: value.name,
|
||||||
|
value: assignment,
|
||||||
|
override,
|
||||||
|
rarity: weight / total
|
||||||
|
});
|
||||||
|
|
||||||
|
//out.push(`FFZ | ${value.name}: ${this.getAssignment(key)}${this.hasOverride(key) ? ' (Overriden)' : ''}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
ffz_assignments.sort(sortExperimentLog);
|
||||||
|
|
||||||
|
for(const entry of ffz_assignments)
|
||||||
|
out.push(`FFZ | ${entry.name}: ${entry.value}${entry.override ? ' (Override)' : ''} (r:${entry.rarity})`);
|
||||||
|
|
||||||
|
const twitch_assignments: ExperimentLogEntry[] = [],
|
||||||
|
channel = this.settings.get('context.channel');
|
||||||
|
|
||||||
|
for(const [key, value] of Object.entries(this.getTwitchExperiments())) {
|
||||||
|
if ( ! this.usingTwitchExperiment(key) )
|
||||||
|
continue;
|
||||||
|
|
||||||
|
const assignment = this.getTwitchAssignment(key),
|
||||||
|
override = this.hasTwitchOverride(key);
|
||||||
|
|
||||||
|
let weight = 0, total = 0;
|
||||||
|
for(const group of value.groups) {
|
||||||
|
if ( group.value === assignment )
|
||||||
|
weight = group.weight;
|
||||||
|
total += group.weight;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! override && weight === total )
|
||||||
|
continue;
|
||||||
|
|
||||||
|
twitch_assignments.push({
|
||||||
|
key,
|
||||||
|
name: value.name,
|
||||||
|
value: assignment,
|
||||||
|
override,
|
||||||
|
type: this.getTwitchTypeByKey(key),
|
||||||
|
rarity: weight / total
|
||||||
|
});
|
||||||
|
|
||||||
|
//out.push(`TWITCH | ${value.name}: ${this.getTwitchAssignment(key)}${this.hasTwitchOverride(key) ? ' (Overriden)' : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
twitch_assignments.sort(sortExperimentLog);
|
||||||
|
|
||||||
|
for(const entry of twitch_assignments)
|
||||||
|
out.push(`Twitch | ${entry.name}: ${entry.value}${entry.override ? ' (Override)' : ''} (r:${entry.rarity}, t:${entry.type}${entry.type === 'channel_id' ? `, c:${channel}`: ''})`);
|
||||||
|
|
||||||
|
return out.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Twitch Experiments
|
||||||
|
|
||||||
|
getTwitchType(type: number) {
|
||||||
|
const core = this.resolve('site')?.getCore?.();
|
||||||
|
if ( core?.experiments?.getExperimentType )
|
||||||
|
return core.experiments.getExperimentType(type);
|
||||||
|
|
||||||
|
if ( type === 1 )
|
||||||
|
return 'device_id';
|
||||||
|
else if ( type === 2 )
|
||||||
|
return 'user_id';
|
||||||
|
else if ( type === 3 )
|
||||||
|
return 'channel_id';
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTwitchTypeByKey(key: string) {
|
||||||
|
const exps = this.getTwitchExperiments(),
|
||||||
|
exp = exps?.[key];
|
||||||
|
|
||||||
|
if ( exp?.t )
|
||||||
|
return this.getTwitchType(exp.t);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTwitchExperiments(): Record<string, TwitchExperimentData> {
|
||||||
|
if ( window.__twilightSettings )
|
||||||
|
return window.__twilightSettings.experiments ?? {};
|
||||||
|
|
||||||
|
const core = this.resolve('site')?.getCore?.();
|
||||||
|
return core && core.experiments.experiments || {};
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
usingTwitchExperiment(key: string) {
|
||||||
|
const core = this.resolve('site')?.getCore?.();
|
||||||
|
return core && has(core.experiments.assignments, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
private _getOverrideCookie() {
|
||||||
|
const raw = Cookie.get(OVERRIDE_COOKIE);
|
||||||
|
let out: OverrideCookie;
|
||||||
|
|
||||||
|
try {
|
||||||
|
out = raw ? JSON.parse(raw) : {};
|
||||||
|
} catch(err) {
|
||||||
|
out = {} as OverrideCookie;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! out.experiments )
|
||||||
|
out.experiments = {};
|
||||||
|
|
||||||
|
if ( ! out.disabled )
|
||||||
|
out.disabled = [];
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
private _saveOverrideCookie(value?: OverrideCookie) {
|
||||||
|
if ( value ) {
|
||||||
|
if ((! value.experiments || ! Object.keys(value.experiments).length) &&
|
||||||
|
(! value.disabled || ! value.disabled.length)
|
||||||
|
)
|
||||||
|
value = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( value )
|
||||||
|
Cookie.set(OVERRIDE_COOKIE, JSON.stringify(value), COOKIE_OPTIONS);
|
||||||
|
else
|
||||||
|
Cookie.remove(OVERRIDE_COOKIE, COOKIE_OPTIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
setTwitchOverride(key: string, value: string) {
|
||||||
|
const overrides = this._getOverrideCookie(),
|
||||||
|
experiments = overrides.experiments,
|
||||||
|
disabled = overrides.disabled;
|
||||||
|
|
||||||
|
experiments[key] = value;
|
||||||
|
|
||||||
|
const idx = disabled.indexOf(key);
|
||||||
|
if (idx != -1)
|
||||||
|
disabled.splice(idx, 1);
|
||||||
|
|
||||||
|
this._saveOverrideCookie(overrides);
|
||||||
|
|
||||||
|
const core = this.resolve('site')?.getCore?.();
|
||||||
|
if ( core )
|
||||||
|
core.experiments.overrides[key] = value;
|
||||||
|
|
||||||
|
this._rebuildTwitchKey(key, true, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteTwitchOverride(key: string) {
|
||||||
|
const overrides = this._getOverrideCookie(),
|
||||||
|
experiments = overrides.experiments;
|
||||||
|
|
||||||
|
if ( ! has(experiments, key) )
|
||||||
|
return;
|
||||||
|
|
||||||
|
const old_val = experiments[key];
|
||||||
|
delete experiments[key];
|
||||||
|
|
||||||
|
this._saveOverrideCookie(overrides);
|
||||||
|
|
||||||
|
const core = this.resolve('site')?.getCore?.();
|
||||||
|
if ( core )
|
||||||
|
delete core.experiments.overrides[key];
|
||||||
|
|
||||||
|
this._rebuildTwitchKey(key, false, old_val);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasTwitchOverride(key: string) { // eslint-disable-line class-methods-use-this
|
||||||
|
const overrides = this._getOverrideCookie(),
|
||||||
|
experiments = overrides.experiments;
|
||||||
|
|
||||||
|
return has(experiments, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
getTwitchAssignment(key: string, channel: string | null = null) {
|
||||||
|
const core = this.resolve('site')?.getCore?.(),
|
||||||
|
exps = core && core.experiments;
|
||||||
|
|
||||||
|
if ( ! exps )
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if ( ! exps.hasInitialized && exps.initialize )
|
||||||
|
try {
|
||||||
|
exps.initialize();
|
||||||
|
} catch(err) {
|
||||||
|
this.log.warn('Error attempting to initialize Twitch experiments tracker.', err);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( exps.overrides && exps.overrides[key] )
|
||||||
|
return exps.overrides[key];
|
||||||
|
|
||||||
|
const exp_data = exps.experiments[key],
|
||||||
|
type = this.getTwitchType(exp_data?.t ?? 0);
|
||||||
|
|
||||||
|
// channel_id experiments always use getAssignmentById
|
||||||
|
if ( type === 'channel_id' ) {
|
||||||
|
return exps.getAssignmentById(key, {
|
||||||
|
bucketing: {
|
||||||
|
type: 1,
|
||||||
|
value: channel ?? this.settings.get('context.channelID')
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, just use the default assignment?
|
||||||
|
if ( exps.assignments?.[key] )
|
||||||
|
return exps.assignments[key];
|
||||||
|
|
||||||
|
// If there is no default assignment, we should try to figure out
|
||||||
|
// what assignment they *would* get.
|
||||||
|
|
||||||
|
if ( type === 'device_id' )
|
||||||
|
return exps.selectTreatment(key, exp_data, this.unique_id);
|
||||||
|
|
||||||
|
else if ( type === 'user_id' )
|
||||||
|
// Technically, some experiments are expecting to get the user's
|
||||||
|
// login rather than user ID. But we don't care that much if an
|
||||||
|
// inactive legacy experiment is shown wrong. Meh.
|
||||||
|
return exps.selectTreatment(key, exp_data, this.resolve('site')?.getUser?.()?.id);
|
||||||
|
|
||||||
|
// We don't know what kind of experiment this is.
|
||||||
|
// Give up!
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getTwitchKeyFromName(name: string) {
|
||||||
|
const experiments = this.getTwitchExperiments();
|
||||||
|
if ( ! experiments )
|
||||||
|
return;
|
||||||
|
|
||||||
|
name = name.toLowerCase();
|
||||||
|
for(const key in experiments)
|
||||||
|
if ( has(experiments, key) ) {
|
||||||
|
const data = experiments[key];
|
||||||
|
if ( data && data.name && data.name.toLowerCase() === name )
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getTwitchAssignmentByName(name: string, channel: string | null = null) {
|
||||||
|
const key = this.getTwitchKeyFromName(name);
|
||||||
|
if ( ! key )
|
||||||
|
return null;
|
||||||
|
return this.getTwitchAssignment(key, channel);
|
||||||
|
}
|
||||||
|
|
||||||
|
private _rebuildTwitchKey(
|
||||||
|
key: string,
|
||||||
|
is_set: boolean,
|
||||||
|
new_val: string | null
|
||||||
|
) {
|
||||||
|
const core = this.resolve('site')?.getCore?.(),
|
||||||
|
exps = core.experiments,
|
||||||
|
|
||||||
|
old_val = has(exps.assignments, key) ?
|
||||||
|
exps.assignments[key] as string :
|
||||||
|
null;
|
||||||
|
|
||||||
|
if ( old_val !== new_val ) {
|
||||||
|
const value = is_set ? new_val : old_val;
|
||||||
|
this.emit(':twitch-changed', key, value, old_val);
|
||||||
|
this.emit(`:twitch-changed:${key}`, value, old_val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// FFZ Experiments
|
||||||
|
|
||||||
|
setOverride<
|
||||||
|
K extends keyof ExperimentTypeMap
|
||||||
|
>(key: K, value: ExperimentTypeMap[K]) {
|
||||||
|
const overrides = this.settings.provider.get('experiment-overrides', {});
|
||||||
|
overrides[key] = value;
|
||||||
|
|
||||||
|
this.settings.provider.set('experiment-overrides', overrides);
|
||||||
|
|
||||||
|
this._rebuildKey(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteOverride(key: keyof ExperimentTypeMap) {
|
||||||
|
const overrides = this.settings.provider.get('experiment-overrides');
|
||||||
|
if ( ! overrides || ! has(overrides, key) )
|
||||||
|
return;
|
||||||
|
|
||||||
|
delete overrides[key];
|
||||||
|
if ( Object.keys(overrides).length )
|
||||||
|
this.settings.provider.set('experiment-overrides', overrides);
|
||||||
|
else
|
||||||
|
this.settings.provider.delete('experiment-overrides');
|
||||||
|
|
||||||
|
this._rebuildKey(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasOverride(key: keyof ExperimentTypeMap) {
|
||||||
|
const overrides = this.settings.provider.get('experiment-overrides');
|
||||||
|
return overrides ? has(overrides, key): false;
|
||||||
|
}
|
||||||
|
|
||||||
|
get: <K extends keyof ExperimentTypeMap>(
|
||||||
|
key: K
|
||||||
|
) => ExperimentTypeMap[K];
|
||||||
|
|
||||||
|
getAssignment<K extends keyof ExperimentTypeMap>(
|
||||||
|
key: K
|
||||||
|
): ExperimentTypeMap[K] {
|
||||||
|
if ( this.cache.has(key) )
|
||||||
|
return this.cache.get(key) as ExperimentTypeMap[K];
|
||||||
|
|
||||||
|
const experiment = this.experiments[key];
|
||||||
|
if ( ! experiment ) {
|
||||||
|
this.log.warn(`Tried to get assignment for experiment "${key}" which is not known.`);
|
||||||
|
return null as ExperimentTypeMap[K];
|
||||||
|
}
|
||||||
|
|
||||||
|
const overrides = this.settings.provider.get('experiment-overrides'),
|
||||||
|
out = overrides && has(overrides, key) ?
|
||||||
|
overrides[key] :
|
||||||
|
ExperimentManager.selectGroup<ExperimentTypeMap[K]>(key, experiment, this.unique_id ?? '');
|
||||||
|
|
||||||
|
this.cache.set(key, out);
|
||||||
|
return out as ExperimentTypeMap[K];
|
||||||
|
}
|
||||||
|
|
||||||
|
private _rebuildKey(key: keyof ExperimentTypeMap) {
|
||||||
|
if ( ! this.cache.has(key) )
|
||||||
|
return;
|
||||||
|
|
||||||
|
const old_val = this.cache.get(key);
|
||||||
|
this.cache.delete(key);
|
||||||
|
const new_val = this.getAssignment(key);
|
||||||
|
|
||||||
|
if ( new_val !== old_val ) {
|
||||||
|
this.emit(':changed', key, new_val, old_val);
|
||||||
|
this.emit(`:changed:${key}`, new_val, old_val);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
static selectGroup<T>(
|
||||||
|
key: string,
|
||||||
|
experiment: FFZExperimentData,
|
||||||
|
unique_id: string
|
||||||
|
): T | null {
|
||||||
|
const seed = key + unique_id + (experiment.seed || ''),
|
||||||
|
total = experiment.groups.reduce((a,b) => a + b.weight, 0);
|
||||||
|
|
||||||
|
let value = (SHA1(seed).words[0] >>> 0) / Math.pow(2, 32);
|
||||||
|
|
||||||
|
for(const group of experiment.groups) {
|
||||||
|
value -= group.weight / total;
|
||||||
|
if ( value <= 0 )
|
||||||
|
return group.value as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,8 +4,6 @@
|
||||||
// Localization
|
// Localization
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import Parser from '@ffz/icu-msgparser';
|
|
||||||
|
|
||||||
import {DEBUG, SERVER} from 'utilities/constants';
|
import {DEBUG, SERVER} from 'utilities/constants';
|
||||||
import {get, pick_random, shallow_copy, deep_copy} from 'utilities/object';
|
import {get, pick_random, shallow_copy, deep_copy} from 'utilities/object';
|
||||||
import { getBuster } from 'utilities/time';
|
import { getBuster } from 'utilities/time';
|
||||||
|
@ -69,13 +67,11 @@ const FACES = ['(・`ω´・)', ';;w;;', 'owo', 'ono', 'oAo', 'oxo', 'ovo;', 'Uw
|
||||||
// TranslationManager
|
// TranslationManager
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export class TranslationManager extends Module {
|
export default class TranslationManager extends Module {
|
||||||
constructor(...args) {
|
constructor(...args) {
|
||||||
super(...args);
|
super(...args);
|
||||||
this.inject('settings');
|
this.inject('settings');
|
||||||
|
|
||||||
this.parser = new Parser;
|
|
||||||
|
|
||||||
this._seen = new Set;
|
this._seen = new Set;
|
||||||
|
|
||||||
this.availableLocales = ['en'];
|
this.availableLocales = ['en'];
|
||||||
|
@ -897,4 +893,4 @@ export function transformPhrase(phrase, substitutions, locale, token_regex, form
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
169
src/load_tracker.ts
Normal file
169
src/load_tracker.ts
Normal file
|
@ -0,0 +1,169 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Loading Tracker
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import Module, { GenericModule } from 'utilities/module';
|
||||||
|
import type SettingsManager from './settings';
|
||||||
|
|
||||||
|
|
||||||
|
declare module 'utilities/types' {
|
||||||
|
interface ModuleEventMap {
|
||||||
|
load_tracker: LoadEvents;
|
||||||
|
}
|
||||||
|
interface ModuleMap {
|
||||||
|
load_tracker: LoadTracker;
|
||||||
|
}
|
||||||
|
interface SettingsTypeMap {
|
||||||
|
'chat.update-when-loaded': boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type PendingLoadData = {
|
||||||
|
pending: Set<string>;
|
||||||
|
timers: Record<string, ReturnType<typeof setTimeout> | 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<string, PendingLoadData> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -4,7 +4,7 @@ import dayjs from 'dayjs';
|
||||||
//import RavenLogger from './raven';
|
//import RavenLogger from './raven';
|
||||||
|
|
||||||
import Logger from 'utilities/logging';
|
import Logger from 'utilities/logging';
|
||||||
import Module from 'utilities/module';
|
import Module, { State } from 'utilities/module';
|
||||||
import { timeout } from 'utilities/object';
|
import { timeout } from 'utilities/object';
|
||||||
|
|
||||||
import {DEBUG} from 'utilities/constants';
|
import {DEBUG} from 'utilities/constants';
|
||||||
|
@ -12,16 +12,87 @@ import {DEBUG} from 'utilities/constants';
|
||||||
import SettingsManager from './settings/index';
|
import SettingsManager from './settings/index';
|
||||||
import AddonManager from './addons';
|
import AddonManager from './addons';
|
||||||
import ExperimentManager from './experiments';
|
import ExperimentManager from './experiments';
|
||||||
import {TranslationManager} from './i18n';
|
import TranslationManager from './i18n';
|
||||||
import SocketClient from './socket';
|
import SocketClient from './socket';
|
||||||
import PubSubClient from './pubsub';
|
import PubSubClient from './pubsub';
|
||||||
import Site from 'site';
|
import Site from 'site';
|
||||||
import Vue from 'utilities/vue';
|
import VueModule from 'utilities/vue';
|
||||||
import StagingSelector from './staging';
|
import StagingSelector from './staging';
|
||||||
import LoadTracker from './load_tracker';
|
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 {
|
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() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
const start_time = performance.now();
|
const start_time = performance.now();
|
||||||
|
@ -31,12 +102,14 @@ class FrankerFaceZ extends Module {
|
||||||
this.host = 'twitch';
|
this.host = 'twitch';
|
||||||
this.flavor = 'main';
|
this.flavor = 'main';
|
||||||
this.name = 'frankerfacez';
|
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
|
// Timing
|
||||||
//this.inject('timing', Timing);
|
//this.inject('timing', Timing);
|
||||||
this.__time('instance');
|
this._time('instance');
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// Error Reporting and Logging
|
// Error Reporting and Logging
|
||||||
|
@ -48,7 +121,7 @@ class FrankerFaceZ extends Module {
|
||||||
this.log.init = true;
|
this.log.init = true;
|
||||||
|
|
||||||
this.core_log = this.log.get('core');
|
this.core_log = this.log.get('core');
|
||||||
this.log.hi(this);
|
this.log.hi(this, FrankerFaceZ.version_info);
|
||||||
|
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
@ -65,7 +138,7 @@ class FrankerFaceZ extends Module {
|
||||||
this.inject('site', Site);
|
this.inject('site', Site);
|
||||||
this.inject('addons', AddonManager);
|
this.inject('addons', AddonManager);
|
||||||
|
|
||||||
this.register('vue', Vue);
|
this.register('vue', VueModule);
|
||||||
|
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
@ -96,14 +169,13 @@ class FrankerFaceZ extends Module {
|
||||||
|
|
||||||
async generateLog() {
|
async generateLog() {
|
||||||
const promises = [];
|
const promises = [];
|
||||||
for(const key in this.__modules) {
|
for(const [key, module] of Object.entries((this as any).__modules)) {
|
||||||
const module = this.__modules[key];
|
if ( module instanceof Module && module.generateLog && (module as any) != this )
|
||||||
if ( module instanceof Module && module.generateLog && module != this )
|
|
||||||
promises.push((async () => {
|
promises.push((async () => {
|
||||||
try {
|
try {
|
||||||
return [
|
return [
|
||||||
key,
|
key,
|
||||||
await timeout(Promise.resolve(module.generateLog()), 5000)
|
await timeout(Promise.resolve((module as any).generateLog()), 5000)
|
||||||
];
|
];
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
return [
|
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(
|
const ctx = await require.context(
|
||||||
'src/modules',
|
'src/modules',
|
||||||
true,
|
true,
|
||||||
/(?:^(?:\.\/)?[^/]+|index)\.jsx?$/
|
/(?:^(?:\.\/)?[^/]+|index)\.[jt]sx?$/
|
||||||
/*, 'lazy-once' */
|
/*, '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.`);
|
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() {
|
async enableInitialModules() {
|
||||||
const promises = [];
|
const promises = [];
|
||||||
/* eslint guard-for-in: off */
|
for(const module of Object.values((this as any).__modules)) {
|
||||||
for(const key in this.__modules) {
|
|
||||||
const module = this.__modules[key];
|
|
||||||
if ( module instanceof Module && module.should_enable )
|
if ( module instanceof Module && module.should_enable )
|
||||||
promises.push(module.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__,
|
major: __version_major__,
|
||||||
minor: __version_minor__,
|
minor: __version_minor__,
|
||||||
revision: __version_patch__,
|
revision: __version_patch__,
|
||||||
|
@ -179,27 +248,14 @@ const VER = FrankerFaceZ.version_info = Object.freeze({
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
FrankerFaceZ.utilities = {
|
export default FrankerFaceZ;
|
||||||
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')
|
|
||||||
}
|
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
FrankerFaceZ: typeof FrankerFaceZ;
|
||||||
|
ffz: FrankerFaceZ;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
window.FrankerFaceZ = FrankerFaceZ;
|
window.FrankerFaceZ = FrankerFaceZ;
|
||||||
window.ffz = new FrankerFaceZ();
|
window.ffz = new FrankerFaceZ();
|
|
@ -614,7 +614,7 @@ export default class Actions extends Module {
|
||||||
},
|
},
|
||||||
|
|
||||||
onMove: (target, tip, event) => {
|
onMove: (target, tip, event) => {
|
||||||
this.emit('tooltips:mousemove', target, tip, event)
|
this.emit('tooltips:hover', target, tip, event)
|
||||||
},
|
},
|
||||||
|
|
||||||
onLeave: (target, tip, event) => {
|
onLeave: (target, tip, event) => {
|
||||||
|
@ -1276,4 +1276,4 @@ export default class Actions extends Module {
|
||||||
sendMessage(room, message) {
|
sendMessage(room, message) {
|
||||||
return this.resolve('site.chat').sendMessage(room, message);
|
return this.resolve('site.chat').sendMessage(room, message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1554,4 +1554,4 @@ export function fixBadgeData(badge) {
|
||||||
}
|
}
|
||||||
|
|
||||||
return badge;
|
return badge;
|
||||||
}
|
}
|
||||||
|
|
|
@ -80,7 +80,7 @@ export default {
|
||||||
if ( ! ds )
|
if ( ! ds )
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const evt = new FFZEvent({
|
const evt = FFZEvent.makeEvent({
|
||||||
url: ds.url ?? target.href,
|
url: ds.url ?? target.href,
|
||||||
source: event
|
source: event
|
||||||
});
|
});
|
||||||
|
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
import Module, { buildAddonProxy } from 'utilities/module';
|
import Module, { buildAddonProxy } from 'utilities/module';
|
||||||
import {ManagedStyle} from 'utilities/dom';
|
import {ManagedStyle} from 'utilities/dom';
|
||||||
import { FFZEvent } from 'utilities/events';
|
|
||||||
import {get, has, timeout, SourcedSet, make_enum_flags, makeAddonIdChecker} from 'utilities/object';
|
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';
|
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 */
|
/* no-op */
|
||||||
}
|
}
|
||||||
|
|
||||||
const evt = new FFZEvent({
|
const evt = this.makeEvent({
|
||||||
provider,
|
provider,
|
||||||
id: ds.id,
|
id: ds.id,
|
||||||
set: ds.set,
|
set: ds.set,
|
||||||
|
|
|
@ -10,7 +10,6 @@ import { DEBUG, LINK_DATA_HOSTS } from 'utilities/constants';
|
||||||
import Module, { buildAddonProxy } from 'utilities/module';
|
import Module, { buildAddonProxy } from 'utilities/module';
|
||||||
import {Color} from 'utilities/color';
|
import {Color} from 'utilities/color';
|
||||||
import {createElement, ManagedStyle} from 'utilities/dom';
|
import {createElement, ManagedStyle} from 'utilities/dom';
|
||||||
import {FFZEvent} from 'utilities/events';
|
|
||||||
import {getFontsList} from 'utilities/fonts';
|
import {getFontsList} from 'utilities/fonts';
|
||||||
import {timeout, has, addWordSeparators, glob_to_regex, escape_regex, split_chars, makeAddonIdChecker} from 'utilities/object';
|
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 )
|
if ( ! ds )
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const evt = new FFZEvent({
|
const evt = this.makeEvent({
|
||||||
url: ds.url ?? target.href,
|
url: ds.url ?? target.href,
|
||||||
source: event
|
source: event
|
||||||
});
|
});
|
||||||
|
@ -1811,7 +1810,6 @@ export default class Chat extends Module {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -2657,4 +2655,4 @@ export default class Chat extends Module {
|
||||||
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,14 +4,43 @@
|
||||||
// Name and Color Overrides
|
// Name and Color Overrides
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import Module from 'utilities/module';
|
import Module, { GenericModule } from 'utilities/module';
|
||||||
import { createElement, ClickOutside } from 'utilities/dom';
|
import { createElement, ClickOutside } from 'utilities/dom';
|
||||||
import Tooltip from 'utilities/tooltip';
|
import Tooltip from 'utilities/tooltip';
|
||||||
|
import type SettingsManager from 'root/src/settings';
|
||||||
|
|
||||||
|
|
||||||
export default class Overrides extends Module {
|
declare module 'utilities/types' {
|
||||||
constructor(...args) {
|
interface ModuleMap {
|
||||||
super(...args);
|
'chat.overrides': Overrides;
|
||||||
|
}
|
||||||
|
interface ModuleEventMap {
|
||||||
|
'chat.overrides': OverrideEvents;
|
||||||
|
}
|
||||||
|
interface ProviderTypeMap {
|
||||||
|
'overrides.colors': Record<string, string | undefined>;
|
||||||
|
'overrides.names': Record<string, string | undefined>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export type OverrideEvents = {
|
||||||
|
':changed': [id: string, type: 'name' | 'color', value: string | undefined];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default class Overrides extends Module<'chat.overrides'> {
|
||||||
|
|
||||||
|
// Dependencies
|
||||||
|
settings: SettingsManager = null as any;
|
||||||
|
|
||||||
|
// State and Caching
|
||||||
|
color_cache: Record<string, string | undefined> | null;
|
||||||
|
name_cache: Record<string, string | undefined> | null;
|
||||||
|
|
||||||
|
|
||||||
|
constructor(name?: string, parent?: GenericModule) {
|
||||||
|
super(name, parent);
|
||||||
|
|
||||||
this.inject('settings');
|
this.inject('settings');
|
||||||
|
|
||||||
|
@ -35,12 +64,15 @@ export default class Overrides extends Module {
|
||||||
});*/
|
});*/
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
onEnable() {
|
onEnable() {
|
||||||
this.settings.provider.on('changed', this.onProviderChange, this);
|
this.settings.provider.on('changed', this.onProviderChange, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderUserEditor(user, target) {
|
renderUserEditor(user: any, target: HTMLElement) {
|
||||||
let outside, popup, ve;
|
let outside: ClickOutside | null,
|
||||||
|
popup: Tooltip | null,
|
||||||
|
ve: any;
|
||||||
|
|
||||||
const destroy = () => {
|
const destroy = () => {
|
||||||
const o = outside, p = popup, v = ve;
|
const o = outside, p = popup, v = ve;
|
||||||
|
@ -56,7 +88,10 @@ export default class Overrides extends Module {
|
||||||
v.$destroy();
|
v.$destroy();
|
||||||
}
|
}
|
||||||
|
|
||||||
const parent = document.fullscreenElement || document.body.querySelector('#root>div') || document.body;
|
const parent =
|
||||||
|
document.fullscreenElement as HTMLElement
|
||||||
|
?? document.body.querySelector<HTMLElement>('#root>div')
|
||||||
|
?? document.body;
|
||||||
|
|
||||||
popup = new Tooltip(parent, [], {
|
popup = new Tooltip(parent, [], {
|
||||||
logger: this.log,
|
logger: this.log,
|
||||||
|
@ -88,6 +123,9 @@ export default class Overrides extends Module {
|
||||||
const vue = this.resolve('vue'),
|
const vue = this.resolve('vue'),
|
||||||
_editor = import(/* webpackChunkName: "overrides" */ './override-editor.vue');
|
_editor = import(/* webpackChunkName: "overrides" */ './override-editor.vue');
|
||||||
|
|
||||||
|
if ( ! vue )
|
||||||
|
throw new Error('unable to load vue');
|
||||||
|
|
||||||
const [, editor] = await Promise.all([vue.enable(), _editor]);
|
const [, editor] = await Promise.all([vue.enable(), _editor]);
|
||||||
vue.component('override-editor', editor.default);
|
vue.component('override-editor', editor.default);
|
||||||
|
|
||||||
|
@ -118,12 +156,13 @@ export default class Overrides extends Module {
|
||||||
onShow: async (t, tip) => {
|
onShow: async (t, tip) => {
|
||||||
await tip.waitForDom();
|
await tip.waitForDom();
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
outside = new ClickOutside(tip.outer, destroy)
|
if ( tip.outer )
|
||||||
|
outside = new ClickOutside(tip.outer, destroy)
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
onMove: (target, tip, event) => {
|
onMove: (target, tip, event) => {
|
||||||
this.emit('tooltips:mousemove', target, tip, event)
|
this.emit('tooltips:hover', target, tip, event)
|
||||||
},
|
},
|
||||||
|
|
||||||
onLeave: (target, tip, event) => {
|
onLeave: (target, tip, event) => {
|
||||||
|
@ -137,30 +176,25 @@ export default class Overrides extends Module {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
onProviderChange(key) {
|
onProviderChange(key: string) {
|
||||||
if ( key === 'overrides.colors' )
|
if ( key === 'overrides.colors' && this.color_cache )
|
||||||
this.loadColors();
|
this.loadColors();
|
||||||
else if ( key === 'overrides.names' )
|
else if ( key === 'overrides.names' && this.name_cache )
|
||||||
this.loadNames();
|
this.loadNames();
|
||||||
}
|
}
|
||||||
|
|
||||||
get colors() {
|
get colors() {
|
||||||
if ( ! this.color_cache )
|
return this.color_cache ?? this.loadColors();
|
||||||
this.loadColors();
|
|
||||||
|
|
||||||
return this.color_cache;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get names() {
|
get names() {
|
||||||
if ( ! this.name_cache )
|
return this.name_cache ?? this.loadNames();
|
||||||
this.loadNames();
|
|
||||||
|
|
||||||
return this.name_cache;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
loadColors() {
|
loadColors() {
|
||||||
let old_keys,
|
let old_keys: Set<string>,
|
||||||
loaded = true;
|
loaded = true;
|
||||||
|
|
||||||
if ( ! this.color_cache ) {
|
if ( ! this.color_cache ) {
|
||||||
loaded = false;
|
loaded = false;
|
||||||
this.color_cache = {};
|
this.color_cache = {};
|
||||||
|
@ -168,24 +202,28 @@ export default class Overrides extends Module {
|
||||||
} else
|
} else
|
||||||
old_keys = new Set(Object.keys(this.color_cache));
|
old_keys = new Set(Object.keys(this.color_cache));
|
||||||
|
|
||||||
for(const [key, val] of Object.entries(this.settings.provider.get('overrides.colors', {}))) {
|
const entries = this.settings.provider.get('overrides.colors');
|
||||||
old_keys.delete(key);
|
if ( entries )
|
||||||
if ( this.color_cache[key] !== val ) {
|
for(const [key, val] of Object.entries(entries)) {
|
||||||
this.color_cache[key] = val;
|
old_keys.delete(key);
|
||||||
if ( loaded )
|
if ( this.color_cache[key] !== val ) {
|
||||||
this.emit(':changed', key, 'color', val);
|
this.color_cache[key] = val;
|
||||||
|
if ( loaded )
|
||||||
|
this.emit(':changed', key, 'color', val);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for(const key of old_keys) {
|
for(const key of old_keys) {
|
||||||
this.color_cache[key] = undefined;
|
this.color_cache[key] = undefined;
|
||||||
if ( loaded )
|
if ( loaded )
|
||||||
this.emit(':changed', key, 'color', undefined);
|
this.emit(':changed', key, 'color', undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return this.color_cache;
|
||||||
}
|
}
|
||||||
|
|
||||||
loadNames() {
|
loadNames() {
|
||||||
let old_keys,
|
let old_keys: Set<string>,
|
||||||
loaded = true;
|
loaded = true;
|
||||||
if ( ! this.name_cache ) {
|
if ( ! this.name_cache ) {
|
||||||
loaded = false;
|
loaded = false;
|
||||||
|
@ -194,37 +232,35 @@ export default class Overrides extends Module {
|
||||||
} else
|
} else
|
||||||
old_keys = new Set(Object.keys(this.name_cache));
|
old_keys = new Set(Object.keys(this.name_cache));
|
||||||
|
|
||||||
for(const [key, val] of Object.entries(this.settings.provider.get('overrides.names', {}))) {
|
const entries = this.settings.provider.get('overrides.names');
|
||||||
old_keys.delete(key);
|
if ( entries )
|
||||||
if ( this.name_cache[key] !== val ) {
|
for(const [key, val] of Object.entries(entries)) {
|
||||||
this.name_cache[key] = val;
|
old_keys.delete(key);
|
||||||
if ( loaded )
|
if ( this.name_cache[key] !== val ) {
|
||||||
this.emit(':changed', key, 'name', val);
|
this.name_cache[key] = val;
|
||||||
|
if ( loaded )
|
||||||
|
this.emit(':changed', key, 'name', val);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
for(const key of old_keys) {
|
for(const key of old_keys) {
|
||||||
this.name_cache[key] = undefined;
|
this.name_cache[key] = undefined;
|
||||||
if ( loaded )
|
if ( loaded )
|
||||||
this.emit(':changed', key, 'name', undefined);
|
this.emit(':changed', key, 'name', undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return this.name_cache;
|
||||||
}
|
}
|
||||||
|
|
||||||
getColor(id) {
|
getColor(id: string): string | null {
|
||||||
if ( this.colors[id] != null )
|
return this.colors[id] ?? null;
|
||||||
return this.colors[id];
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getName(id) {
|
getName(id: string) {
|
||||||
if ( this.names[id] != null )
|
return this.names[id] ?? null;
|
||||||
return this.names[id];
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setColor(id, color) {
|
setColor(id: string, color?: string) {
|
||||||
if ( this.colors[id] !== color ) {
|
if ( this.colors[id] !== color ) {
|
||||||
this.colors[id] = color;
|
this.colors[id] = color;
|
||||||
this.settings.provider.set('overrides.colors', this.colors);
|
this.settings.provider.set('overrides.colors', this.colors);
|
||||||
|
@ -232,7 +268,7 @@ export default class Overrides extends Module {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setName(id, name) {
|
setName(id: string, name?: string) {
|
||||||
if ( this.names[id] !== name ) {
|
if ( this.names[id] !== name ) {
|
||||||
this.names[id] = name;
|
this.names[id] = name;
|
||||||
this.settings.provider.set('overrides.names', this.names);
|
this.settings.provider.set('overrides.names', this.names);
|
||||||
|
@ -240,11 +276,11 @@ export default class Overrides extends Module {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteColor(id) {
|
deleteColor(id: string) {
|
||||||
this.setColor(id, undefined);
|
this.setColor(id, undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
deleteName(id) {
|
deleteName(id: string) {
|
||||||
this.setName(id, undefined);
|
this.setName(id, undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
8
src/modules/chat/types.ts
Normal file
8
src/modules/chat/types.ts
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Badges
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type BadgeAssignment = {
|
||||||
|
id: string;
|
||||||
|
};
|
|
@ -5,20 +5,39 @@
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import {SourcedSet} from 'utilities/object';
|
import {SourcedSet} from 'utilities/object';
|
||||||
|
import type Chat from '.';
|
||||||
|
import type Room from './room';
|
||||||
|
import type { BadgeAssignment } from './types';
|
||||||
|
|
||||||
export default class User {
|
export default class User {
|
||||||
constructor(manager, room, id, login) {
|
|
||||||
|
// Parent
|
||||||
|
manager: Chat;
|
||||||
|
room: Room | null;
|
||||||
|
|
||||||
|
// State
|
||||||
|
destroyed: boolean = false;
|
||||||
|
|
||||||
|
_id: string | null;
|
||||||
|
_login: string | null = null;
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
emote_sets: SourcedSet<string> | null;
|
||||||
|
badges: SourcedSet<BadgeAssignment> | null;
|
||||||
|
|
||||||
|
|
||||||
|
constructor(manager: Chat, room: Room | null, id: string | null, login: string | null) {
|
||||||
this.manager = manager;
|
this.manager = manager;
|
||||||
this.room = room;
|
this.room = room;
|
||||||
|
|
||||||
this.emote_sets = null; //new SourcedSet;
|
this.emote_sets = null;
|
||||||
this.badges = null; // new SourcedSet;
|
this.badges = null;
|
||||||
|
|
||||||
this._id = id;
|
this._id = id;
|
||||||
this.login = login;
|
this.login = login;
|
||||||
|
|
||||||
if ( id )
|
if ( id )
|
||||||
(room || manager).user_ids[id] = this;
|
(room ?? manager).user_ids[id] = this;
|
||||||
}
|
}
|
||||||
|
|
||||||
destroy() {
|
destroy() {
|
||||||
|
@ -31,6 +50,7 @@ export default class User {
|
||||||
this.emote_sets = null;
|
this.emote_sets = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Badges are not referenced, so we can just dump them all.
|
||||||
if ( this.badges )
|
if ( this.badges )
|
||||||
this.badges = null;
|
this.badges = null;
|
||||||
|
|
||||||
|
@ -45,26 +65,24 @@ export default class User {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
merge(other) {
|
merge(other: User) {
|
||||||
if ( ! this.login && other.login )
|
if ( ! this.login && other.login )
|
||||||
this.login = other.login;
|
this.login = other.login;
|
||||||
|
|
||||||
if ( other.emote_sets && other.emote_sets._sources ) {
|
if ( other.emote_sets )
|
||||||
for(const [provider, sets] of other.emote_sets._sources.entries()) {
|
for(const [provider, sets] of other.emote_sets.iterateSources()) {
|
||||||
for(const set_id of sets)
|
for(const set_id of sets)
|
||||||
this.addSet(provider, set_id);
|
this.addSet(provider, set_id);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if ( other.badges && other.badges._sources ) {
|
if ( other.badges )
|
||||||
for(const [provider, badges] of other.badges._sources.entries()) {
|
for(const [provider, badges] of other.badges.iterateSources()) {
|
||||||
for(const badge of badges)
|
for(const badge of badges)
|
||||||
this.addBadge(provider, badge.id, badge);
|
this.addBadge(provider, badge.id, badge);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_unloadAddon(addon_id) {
|
_unloadAddon(addon_id: string) {
|
||||||
// TODO: This
|
// TODO: This
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
@ -107,9 +125,9 @@ export default class User {
|
||||||
// Add Badges
|
// Add Badges
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
addBadge(provider, badge_id, data) {
|
addBadge(provider: string, badge_id: string, data?: BadgeAssignment) {
|
||||||
if ( this.destroyed )
|
if ( this.destroyed )
|
||||||
return;
|
return false;
|
||||||
|
|
||||||
if ( typeof badge_id === 'number' )
|
if ( typeof badge_id === 'number' )
|
||||||
badge_id = `${badge_id}`;
|
badge_id = `${badge_id}`;
|
||||||
|
@ -122,8 +140,9 @@ export default class User {
|
||||||
if ( ! this.badges )
|
if ( ! this.badges )
|
||||||
this.badges = new SourcedSet;
|
this.badges = new SourcedSet;
|
||||||
|
|
||||||
if ( this.badges.has(provider) )
|
const existing = this.badges.get(provider);
|
||||||
for(const old_b of this.badges.get(provider))
|
if ( existing )
|
||||||
|
for(const old_b of existing)
|
||||||
if ( old_b.id == badge_id ) {
|
if ( old_b.id == badge_id ) {
|
||||||
Object.assign(old_b, data);
|
Object.assign(old_b, data);
|
||||||
return false;
|
return false;
|
||||||
|
@ -135,31 +154,35 @@ export default class User {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
getBadge(badge_id) {
|
getBadge(badge_id: string) {
|
||||||
if ( ! this.badges )
|
if ( this.badges )
|
||||||
return null;
|
for(const badge of this.badges._cache)
|
||||||
|
if ( badge.id == badge_id )
|
||||||
|
return badge;
|
||||||
|
|
||||||
for(const badge of this.badges._cache)
|
return null;
|
||||||
if ( badge.id == badge_id )
|
|
||||||
return badge;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
removeBadge(provider, badge_id) {
|
removeBadge(provider: string, badge_id: string) {
|
||||||
if ( ! this.badges || ! this.badges.has(provider) )
|
if ( ! this.badges )
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
for(const old_b of this.badges.get(provider))
|
const existing = this.badges.get(provider);
|
||||||
if ( old_b.id == badge_id ) {
|
if ( existing )
|
||||||
this.badges.remove(provider, old_b);
|
for(const old_b of existing)
|
||||||
//this.manager.badges.unrefBadge(badge_id);
|
if ( old_b.id == badge_id ) {
|
||||||
return true;
|
this.badges.remove(provider, old_b);
|
||||||
}
|
//this.manager.badges.unrefBadge(badge_id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
removeAllBadges(provider) {
|
removeAllBadges(provider: string) {
|
||||||
if ( this.destroyed || ! this.badges )
|
if ( ! this.badges )
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
if ( ! this.badges.has(provider) )
|
if ( ! this.badges.has(provider) )
|
||||||
|
@ -175,7 +198,7 @@ export default class User {
|
||||||
// Emote Sets
|
// Emote Sets
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
addSet(provider, set_id, data) {
|
addSet(provider: string, set_id: string, data?: unknown) {
|
||||||
if ( this.destroyed )
|
if ( this.destroyed )
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
@ -203,8 +226,8 @@ export default class User {
|
||||||
return added;
|
return added;
|
||||||
}
|
}
|
||||||
|
|
||||||
removeAllSets(provider) {
|
removeAllSets(provider: string) {
|
||||||
if ( this.destroyed || ! this.emote_sets )
|
if ( ! this.emote_sets )
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
const sets = this.emote_sets.get(provider);
|
const sets = this.emote_sets.get(provider);
|
||||||
|
@ -217,8 +240,8 @@ export default class User {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
removeSet(provider, set_id) {
|
removeSet(provider: string, set_id: string) {
|
||||||
if ( this.destroyed || ! this.emote_sets )
|
if ( ! this.emote_sets )
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if ( typeof set_id === 'number' )
|
if ( typeof set_id === 'number' )
|
||||||
|
@ -235,4 +258,4 @@ export default class User {
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -633,7 +633,7 @@ export default {
|
||||||
// TODO: Update timestamps for pinned chat?
|
// TODO: Update timestamps for pinned chat?
|
||||||
}
|
}
|
||||||
|
|
||||||
this.chat.resolve('site.subpump').inject(item.topic, item.data);
|
this.chat.resolve('site.subpump').simulateMessage(item.topic, item.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( item.chat ) {
|
if ( item.chat ) {
|
||||||
|
@ -731,4 +731,4 @@ export default {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -173,7 +173,7 @@
|
||||||
@change="onTwitchChange($event)"
|
@change="onTwitchChange($event)"
|
||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
v-if="exp.in_use === false"
|
v-if="exp.value === null"
|
||||||
:selected="exp.default"
|
:selected="exp.default"
|
||||||
>
|
>
|
||||||
{{ t('setting.experiments.unset', 'unset') }}
|
{{ t('setting.experiments.unset', 'unset') }}
|
||||||
|
@ -436,4 +436,4 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -55,8 +55,8 @@
|
||||||
<script>
|
<script>
|
||||||
|
|
||||||
import Sortable from 'sortablejs';
|
import Sortable from 'sortablejs';
|
||||||
import {deep_copy, maybe_call, generateUUID} from 'utilities/object';
|
import { deep_copy, maybe_call, generateUUID } from 'utilities/object';
|
||||||
import {findSharedParent} from 'utilities/dom';
|
import { hasSharedParent } from 'utilities/dom';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
|
@ -131,7 +131,7 @@ export default {
|
||||||
|
|
||||||
// Check to see if we have a common ancester for the two
|
// Check to see if we have a common ancester for the two
|
||||||
// draggables.
|
// draggables.
|
||||||
if ( ! findSharedParent(to.el, from.el, '.ffz--rule-list') )
|
if ( ! hasSharedParent(to.el, from.el, '.ffz--rule-list') )
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
@ -288,4 +288,4 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -77,7 +77,7 @@ export default {
|
||||||
this.client = this.ffz.resolve('site.apollo')?.client;
|
this.client = this.ffz.resolve('site.apollo')?.client;
|
||||||
this.has_client = !! this.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;
|
this.has_printer = !! this.printer;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -119,8 +119,8 @@ export default {
|
||||||
result: null
|
result: null
|
||||||
});
|
});
|
||||||
|
|
||||||
this.queryMap[name].variables = deep_copy(query.observableQuery?.variables);
|
this.queryMap[name].variables = deep_copy(query.observableQuery?.last?.variables ?? query.observableQuery?.variables);
|
||||||
this.queryMap[name].result = deep_copy(query.observableQuery?.lastResult?.data ?? null);
|
this.queryMap[name].result = deep_copy(query.observableQuery?.lastResult?.data ?? query.observableQuery?.last?.result?.data ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( ! this.current )
|
if ( ! this.current )
|
||||||
|
@ -157,4 +157,4 @@ function guessNameFromDocument(doc) {
|
||||||
return keys[0];
|
return keys[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -165,7 +165,7 @@
|
||||||
<figure class="ffz-i-discord tw-font-size-3" />
|
<figure class="ffz-i-discord tw-font-size-3" />
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<a
|
<!--a
|
||||||
:data-title="t('home.twitter', 'Twitter')"
|
:data-title="t('home.twitter', 'Twitter')"
|
||||||
class="tw-flex-grow-1 tw-button ffz-tooltip ffz--twitter-button tw-mg-r-1"
|
class="tw-flex-grow-1 tw-button ffz-tooltip ffz--twitter-button tw-mg-r-1"
|
||||||
href="https://twitter.com/frankerfacez"
|
href="https://twitter.com/frankerfacez"
|
||||||
|
@ -175,7 +175,7 @@
|
||||||
<span class="tw-button__icon tw-pd-05">
|
<span class="tw-button__icon tw-pd-05">
|
||||||
<figure class="ffz-i-twitter tw-font-size-3" />
|
<figure class="ffz-i-twitter tw-font-size-3" />
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a-->
|
||||||
<a
|
<a
|
||||||
:data-title="t('home.github', 'GitHub')"
|
:data-title="t('home.github', 'GitHub')"
|
||||||
class="tw-flex-grow-1 tw-button ffz-tooltip ffz--github-button"
|
class="tw-flex-grow-1 tw-button ffz-tooltip ffz--github-button"
|
||||||
|
@ -189,7 +189,12 @@
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template v-if="not_extension">
|
<rich-feed
|
||||||
|
url="https://bsky-feed.special.frankerfacez.com/user::frankerfacez.com"
|
||||||
|
:context="context"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!--template v-if="not_extension">
|
||||||
<a
|
<a
|
||||||
:data-theme="theme"
|
:data-theme="theme"
|
||||||
class="twitter-timeline"
|
class="twitter-timeline"
|
||||||
|
@ -198,7 +203,7 @@
|
||||||
>
|
>
|
||||||
{{ t('home.tweets', 'Tweets by FrankerFaceZ') }}
|
{{ t('home.tweets', 'Tweets by FrankerFaceZ') }}
|
||||||
</a>
|
</a>
|
||||||
</template>
|
</template-->
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
@ -221,7 +226,7 @@ export default {
|
||||||
addons: null,
|
addons: null,
|
||||||
new_addons: null,
|
new_addons: null,
|
||||||
unseen: this.item.getUnseen(),
|
unseen: this.item.getUnseen(),
|
||||||
not_extension: ! EXTENSION
|
//not_extension: ! EXTENSION
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -243,7 +248,7 @@ export default {
|
||||||
ffz.off('addons:data-loaded', this.updateAddons, this);
|
ffz.off('addons:data-loaded', this.updateAddons, this);
|
||||||
},
|
},
|
||||||
|
|
||||||
mounted() {
|
/*mounted() {
|
||||||
let el;
|
let el;
|
||||||
if ( this.not_extension )
|
if ( this.not_extension )
|
||||||
document.head.appendChild(el = e('script', {
|
document.head.appendChild(el = e('script', {
|
||||||
|
@ -253,7 +258,7 @@ export default {
|
||||||
src: 'https://platform.twitter.com/widgets.js',
|
src: 'https://platform.twitter.com/widgets.js',
|
||||||
onLoad: () => el.remove()
|
onLoad: () => el.remove()
|
||||||
}));
|
}));
|
||||||
},
|
},*/
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
updateUnseen() {
|
updateUnseen() {
|
||||||
|
@ -308,4 +313,4 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -2,10 +2,47 @@
|
||||||
<div class="ffz--menu-page">
|
<div class="ffz--menu-page">
|
||||||
<header class="tw-mg-b-1">
|
<header class="tw-mg-b-1">
|
||||||
<span v-for="i in breadcrumbs" :key="i.full_key">
|
<span v-for="i in breadcrumbs" :key="i.full_key">
|
||||||
<a v-if="i !== item" href="#" @click="$emit('change-item', i, false)">{{ t(i.i18n_key, i.title) }}</a>
|
<a v-if="i !== item" href="#" @click.prevent="$emit('change-item', i, false)">{{ t(i.i18n_key, i.title) }}</a>
|
||||||
<strong v-if="i === item">{{ t(i.i18n_key, i.title) }}</strong>
|
<strong v-if="i === item">{{ t(i.i18n_key, i.title) }}</strong>
|
||||||
<template v-if="i !== item">» </template>
|
<template v-if="i !== item">» </template>
|
||||||
</span>
|
</span>
|
||||||
|
<span v-if="item.header_links" class="ffz--menu-page__header-links">
|
||||||
|
<span class="tw-mg-x-05">•</span>
|
||||||
|
<template v-for="i in item.header_links">
|
||||||
|
<a
|
||||||
|
v-if="i.href && i.href.startsWith('~')"
|
||||||
|
class="tw-mg-r-05"
|
||||||
|
href="#"
|
||||||
|
@click.prevent="$emit('navigate', i.href.slice(1))"
|
||||||
|
>{{
|
||||||
|
t(i.i18n_key, i.title)
|
||||||
|
}}</a>
|
||||||
|
<react-link
|
||||||
|
v-else-if="i.href"
|
||||||
|
class="tw-mg-r-05"
|
||||||
|
:href="i.href"
|
||||||
|
:state="i.state"
|
||||||
|
>{{
|
||||||
|
t(i.i18n_key, i.title)
|
||||||
|
}}</react-link>
|
||||||
|
<a
|
||||||
|
v-else-if="i.navigate"
|
||||||
|
class="tw-mg-r-05"
|
||||||
|
href="#"
|
||||||
|
@click.prevent="navigate(...i.navigate)"
|
||||||
|
>{{
|
||||||
|
t(i.i18n_key, i.title)
|
||||||
|
}}</a>
|
||||||
|
<a
|
||||||
|
v-else-if="i.target"
|
||||||
|
class="tw-mg-r-05"
|
||||||
|
href="#"
|
||||||
|
@click.prevent="$emit('change-item', i.target, false)"
|
||||||
|
>{{
|
||||||
|
t(i.i18n_key, i.title)
|
||||||
|
}}</a>
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
</header>
|
</header>
|
||||||
<section v-if="context.currentProfile.ephemeral && item.profile_warning !== false" class="tw-border-t tw-pd-t-1 tw-pd-b-2">
|
<section v-if="context.currentProfile.ephemeral && item.profile_warning !== false" class="tw-border-t tw-pd-t-1 tw-pd-b-2">
|
||||||
<div class="tw-c-background-accent tw-c-text-overlay tw-pd-1">
|
<div class="tw-c-background-accent tw-c-text-overlay tw-pd-1">
|
||||||
|
@ -226,4 +263,4 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
89
src/modules/main_menu/components/rich-feed.vue
Normal file
89
src/modules/main_menu/components/rich-feed.vue
Normal file
|
@ -0,0 +1,89 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="feed">
|
||||||
|
<chat-rich
|
||||||
|
v-for="(entry, idx) in feed"
|
||||||
|
:key="idx"
|
||||||
|
:data="entry"
|
||||||
|
class="tw-mg-b-1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
|
||||||
|
import { maybe_call } from 'utilities/object';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
'chat-rich': async () => {
|
||||||
|
const stuff = await import(/* webpackChunkName: "chat" */ 'src/modules/chat/components');
|
||||||
|
return stuff.default('./chat-rich.vue').default;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
props: ['context', 'url'],
|
||||||
|
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
feed: null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
created() {
|
||||||
|
this.loadFromURL();
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
async loadFromURL() {
|
||||||
|
if ( this.loading )
|
||||||
|
return;
|
||||||
|
|
||||||
|
this.loading = true;
|
||||||
|
this.error = null;
|
||||||
|
this.feed = null;
|
||||||
|
|
||||||
|
const chat = this.context.getFFZ().resolve('chat'),
|
||||||
|
url = this.url;
|
||||||
|
|
||||||
|
if ( ! url ) {
|
||||||
|
this.loading = false;
|
||||||
|
this.error = null;
|
||||||
|
this.feed = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = await chat.get_link_info(url, false, false);
|
||||||
|
} catch(err) {
|
||||||
|
this.loading = false;
|
||||||
|
this.error = err;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! data?.v ) {
|
||||||
|
this.error = 'Invalid response.';
|
||||||
|
this.loading = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! data.feed )
|
||||||
|
data = {feed: [data]};
|
||||||
|
|
||||||
|
this.feed = data.feed.map(entry => {
|
||||||
|
entry.allow_media = true;
|
||||||
|
entry.allow_unsafe = false;
|
||||||
|
|
||||||
|
return {
|
||||||
|
getData: () => entry
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
</script>
|
|
@ -492,7 +492,7 @@ export default class MainMenu extends Module {
|
||||||
current = tree.keys[state.ffzcc];
|
current = tree.keys[state.ffzcc];
|
||||||
if ( ! current ) {
|
if ( ! current ) {
|
||||||
const params = new URL(window.location).searchParams,
|
const params = new URL(window.location).searchParams,
|
||||||
key = params?.get?.('ffz-settings');
|
key = params?.get('ffz-settings');
|
||||||
current = key && tree.keys[key];
|
current = key && tree.keys[key];
|
||||||
}
|
}
|
||||||
if ( ! current )
|
if ( ! current )
|
||||||
|
@ -1161,7 +1161,7 @@ export default class MainMenu extends Module {
|
||||||
restored = false;
|
restored = false;
|
||||||
} if ( ! current ) {
|
} if ( ! current ) {
|
||||||
const params = new URL(window.location).searchParams,
|
const params = new URL(window.location).searchParams,
|
||||||
key = params?.get?.('ffz-settings');
|
key = params?.get('ffz-settings');
|
||||||
current = key && settings.keys[key];
|
current = key && settings.keys[key];
|
||||||
if ( ! current )
|
if ( ! current )
|
||||||
restored = false;
|
restored = false;
|
||||||
|
@ -1241,4 +1241,4 @@ export default class MainMenu extends Module {
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,23 +1,230 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Channel Metadata
|
// Channel Metadata
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
import { DEBUG } from 'utilities/constants';
|
||||||
|
|
||||||
import {createElement, ClickOutside, setChildren} from 'utilities/dom';
|
import {createElement, ClickOutside, setChildren} from 'utilities/dom';
|
||||||
import {maybe_call} from 'utilities/object';
|
import {maybe_call} from 'utilities/object';
|
||||||
|
|
||||||
|
import Module, { buildAddonProxy, GenericModule } from 'utilities/module';
|
||||||
import {duration_to_string, durationForURL} from 'utilities/time';
|
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 type SettingsManager from '../settings';
|
||||||
import Module from 'utilities/module';
|
import type TranslationManager from '../i18n';
|
||||||
import { DEBUG } from 'src/utilities/constants';
|
import type TooltipProvider from './tooltips';
|
||||||
|
import type SocketClient from '../socket';
|
||||||
|
|
||||||
const CLIP_URL = /^https:\/\/[^/]+\.(?:twitch\.tv|twitchcdn\.net)\/.+?\.mp4(?:\?.*)?$/;
|
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<any> | null;
|
||||||
|
_ffz_popup?: Tooltip | null;
|
||||||
|
tip?: TooltipInstance | null;
|
||||||
|
tip_content?: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'utilities/types' {
|
||||||
|
interface ModuleMap {
|
||||||
|
metadata: Metadata
|
||||||
|
}
|
||||||
|
interface SettingsTypeMap {
|
||||||
|
'metadata.clip-download': boolean;
|
||||||
|
'metadata.clip-download.force': boolean;
|
||||||
|
'metadata.player-stats': boolean;
|
||||||
|
'metadata.uptime': number;
|
||||||
|
'metadata.stream-delay-warning': number;
|
||||||
|
'metadata.viewers': boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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<any>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<TData, TReturn> = OptionallyThisCallable<Metadata, [data: TData], TReturn>;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<TData = MetadataState> = {
|
||||||
|
|
||||||
|
// 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<TData>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<TData, boolean | number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<TData, boolean>;
|
||||||
|
|
||||||
|
// 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<TData, DomFragment>;
|
||||||
|
|
||||||
|
tooltip?: OptionallyCallable<TData, DomFragment>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<TData, number>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<TData, string | null | undefined>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional. An icon to be displayed
|
||||||
|
*/
|
||||||
|
icon?: OptionallyCallable<TData, DomFragment>;
|
||||||
|
|
||||||
|
// 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<TData, boolean>;
|
||||||
|
|
||||||
|
inherit?: OptionallyCallable<TData, boolean>;
|
||||||
|
|
||||||
|
// 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 {
|
export default class Metadata extends Module {
|
||||||
constructor(...args) {
|
|
||||||
super(...args);
|
definitions: Record<string, MetadataDefinition<any> | 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('settings');
|
||||||
this.inject('i18n');
|
this.inject('i18n');
|
||||||
|
@ -105,7 +312,7 @@ export default class Metadata extends Module {
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
this.definitions.viewers = {
|
this.define('viewers', {
|
||||||
|
|
||||||
refresh() { return this.settings.get('metadata.viewers') },
|
refresh() { return this.settings.get('metadata.viewers') },
|
||||||
|
|
||||||
|
@ -131,10 +338,11 @@ export default class Metadata extends Module {
|
||||||
},
|
},
|
||||||
|
|
||||||
color: 'var(--color-text-live)'
|
color: 'var(--color-text-live)'
|
||||||
};
|
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
this.definitions.uptime = {
|
this.define('uptime', {
|
||||||
inherit: true,
|
inherit: true,
|
||||||
no_arrow: true,
|
no_arrow: true,
|
||||||
player: true,
|
player: true,
|
||||||
|
@ -142,20 +350,15 @@ export default class Metadata extends Module {
|
||||||
refresh() { return this.settings.get('metadata.uptime') > 0 },
|
refresh() { return this.settings.get('metadata.uptime') > 0 },
|
||||||
|
|
||||||
setup(data) {
|
setup(data) {
|
||||||
const socket = this.resolve('socket');
|
|
||||||
let created = data?.channel?.live_since;
|
let created = data?.channel?.live_since;
|
||||||
if ( ! created ) {
|
if ( ! created )
|
||||||
const created_at = data?.meta?.createdAt;
|
return null;
|
||||||
if ( ! created_at )
|
|
||||||
return {};
|
|
||||||
|
|
||||||
created = created_at;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( !(created instanceof Date) )
|
if ( !(created instanceof Date) )
|
||||||
created = new Date(created);
|
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 {
|
return {
|
||||||
created,
|
created,
|
||||||
|
@ -169,16 +372,14 @@ export default class Metadata extends Module {
|
||||||
|
|
||||||
label(data) {
|
label(data) {
|
||||||
const setting = this.settings.get('metadata.uptime');
|
const setting = this.settings.get('metadata.uptime');
|
||||||
if ( ! setting || ! data.created )
|
if ( ! setting || ! data?.created )
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return duration_to_string(data.uptime, false, false, false, setting !== 2);
|
return duration_to_string(data.uptime, false, false, false, setting !== 2);
|
||||||
},
|
},
|
||||||
|
|
||||||
subtitle: () => this.i18n.t('metadata.uptime.subtitle', 'Uptime'),
|
|
||||||
|
|
||||||
tooltip(data) {
|
tooltip(data) {
|
||||||
if ( ! data.created )
|
if ( ! data?.created )
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
return [
|
return [
|
||||||
|
@ -197,8 +398,13 @@ export default class Metadata extends Module {
|
||||||
},
|
},
|
||||||
|
|
||||||
async popup(data, tip) {
|
async popup(data, tip) {
|
||||||
|
if ( ! data )
|
||||||
|
return;
|
||||||
|
|
||||||
const [permission, broadcast_id] = await Promise.all([
|
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()
|
data.getBroadcastID()
|
||||||
]);
|
]);
|
||||||
if ( ! broadcast_id )
|
if ( ! broadcast_id )
|
||||||
|
@ -209,13 +415,13 @@ export default class Metadata extends Module {
|
||||||
const url = `https://www.twitch.tv/videos/${broadcast_id}${data.uptime > 0 ? `?t=${durationForURL(data.uptime)}` : ''}`,
|
const url = `https://www.twitch.tv/videos/${broadcast_id}${data.uptime > 0 ? `?t=${durationForURL(data.uptime)}` : ''}`,
|
||||||
can_copy = permission === 'granted' || permission === 'prompt';
|
can_copy = permission === 'granted' || permission === 'prompt';
|
||||||
|
|
||||||
const copy = can_copy ? e => {
|
const copy = can_copy ? (event: MouseEvent) => {
|
||||||
navigator.clipboard.writeText(url);
|
navigator.clipboard.writeText(url);
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
return false;
|
return false;
|
||||||
} : null;
|
} : null;
|
||||||
|
|
||||||
tip.element.classList.add('ffz-balloon--lg');
|
tip.element?.classList.add('ffz-balloon--lg');
|
||||||
|
|
||||||
return (<div>
|
return (<div>
|
||||||
<div class="tw-pd-b-1 tw-mg-b-1 tw-border-b tw-semibold">
|
<div class="tw-pd-b-1 tw-mg-b-1 tw-border-b tw-semibold">
|
||||||
|
@ -228,7 +434,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"
|
class="tw-border-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 ffz-input tw-full-width"
|
||||||
type="text"
|
type="text"
|
||||||
value={url}
|
value={url}
|
||||||
onFocus={e => e.target.select()}
|
onFocus={(e: FocusEvent) => (e.target as HTMLInputElement)?.select()}
|
||||||
/>
|
/>
|
||||||
{can_copy && <div class="tw-relative ffz-il-tooltip__container tw-mg-l-1">
|
{can_copy && <div class="tw-relative ffz-il-tooltip__container tw-mg-l-1">
|
||||||
<button
|
<button
|
||||||
|
@ -249,9 +455,9 @@ export default class Metadata extends Module {
|
||||||
</div>
|
</div>
|
||||||
</div>);
|
</div>);
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
this.definitions['clip-download'] = {
|
this.define('clip-download', {
|
||||||
button: true,
|
button: true,
|
||||||
inherit: true,
|
inherit: true,
|
||||||
|
|
||||||
|
@ -259,7 +465,8 @@ export default class Metadata extends Module {
|
||||||
if ( ! this.settings.get('metadata.clip-download') )
|
if ( ! this.settings.get('metadata.clip-download') )
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const Player = this.resolve('site.player'),
|
// TODO: Types
|
||||||
|
const Player = this.resolve('site.player') as any,
|
||||||
player = Player.current;
|
player = Player.current;
|
||||||
if ( ! player )
|
if ( ! player )
|
||||||
return;
|
return;
|
||||||
|
@ -271,13 +478,14 @@ export default class Metadata extends Module {
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if ( this.settings.get('metadata.clip-download.force') )
|
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;
|
is_self = user?.id == data.channel.id;
|
||||||
|
|
||||||
if ( is_self || data.getUserSelfImmediate(data.refresh)?.isEditor )
|
if ( is_self || data.getUserSelfImmediate(data.refresh)?.isEditor )
|
||||||
return src;
|
return src as string;
|
||||||
},
|
},
|
||||||
|
|
||||||
label(src) {
|
label(src) {
|
||||||
|
@ -288,18 +496,25 @@ export default class Metadata extends Module {
|
||||||
icon: 'ffz-i-download',
|
icon: 'ffz-i-download',
|
||||||
|
|
||||||
click(src) {
|
click(src) {
|
||||||
const title = this.settings.get('context.title');
|
const title = this.settings.get('context.title') || 'Untitled';
|
||||||
const name = title.replace(/[\\/:"*?<>|]+/, '_') + '.mp4';
|
const name = title.replace(/[\\/:"*?<>|]+/, '_') + '.mp4';
|
||||||
|
|
||||||
const link = createElement('a', {target: '_blank', download: name, href: src, style: {display: 'none'}});
|
const link = createElement('a', {
|
||||||
|
target: '_blank',
|
||||||
|
download: name,
|
||||||
|
href: src,
|
||||||
|
style: {
|
||||||
|
display: 'none'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
document.body.appendChild(link);
|
document.body.appendChild(link);
|
||||||
link.click();
|
link.click();
|
||||||
link.remove();
|
link.remove();
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
this.definitions['player-stats'] = {
|
this.define('player-stats', {
|
||||||
button: true,
|
button: true,
|
||||||
inherit: true,
|
inherit: true,
|
||||||
modview: true,
|
modview: true,
|
||||||
|
@ -309,9 +524,9 @@ export default class Metadata extends Module {
|
||||||
return this.settings.get('metadata.player-stats')
|
return this.settings.get('metadata.player-stats')
|
||||||
},
|
},
|
||||||
|
|
||||||
setup() {
|
setup(data) {
|
||||||
const Player = this.resolve('site.player'),
|
const Player = this.resolve('site.player') as any,
|
||||||
socket = this.resolve('socket'),
|
socket = this.resolve('socket') as SocketClient,
|
||||||
player = Player.current;
|
player = Player.current;
|
||||||
|
|
||||||
let stats;
|
let stats;
|
||||||
|
@ -374,13 +589,13 @@ export default class Metadata extends Module {
|
||||||
try {
|
try {
|
||||||
const url = player.core.state.path;
|
const url = player.core.state.path;
|
||||||
if ( url.includes('/api/channel/hls/') ) {
|
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;
|
tampered = data && data.player_type && data.player_type !== 'site' ? data.player_type : false;
|
||||||
}
|
}
|
||||||
} catch(err) { /* no op */ }
|
} catch(err) { /* no op */ }
|
||||||
|
|
||||||
if ( ! stats || stats.hlsLatencyBroadcaster < -100 )
|
if ( ! stats || stats.hlsLatencyBroadcaster < -100 )
|
||||||
return {stats};
|
return null;
|
||||||
|
|
||||||
let drift = 0;
|
let drift = 0;
|
||||||
|
|
||||||
|
@ -388,6 +603,7 @@ export default class Metadata extends Module {
|
||||||
drift = socket._time_drift;
|
drift = socket._time_drift;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
is_player: data.is_player,
|
||||||
stats,
|
stats,
|
||||||
drift,
|
drift,
|
||||||
rate: stats.rate == null ? 1 : stats.rate,
|
rate: stats.rate == null ? 1 : stats.rate,
|
||||||
|
@ -400,16 +616,14 @@ export default class Metadata extends Module {
|
||||||
order: 3,
|
order: 3,
|
||||||
|
|
||||||
icon(data) {
|
icon(data) {
|
||||||
if ( data.rate > 1 )
|
if ( data?.rate > 1 )
|
||||||
return 'ffz-i-fast-fw';
|
return 'ffz-i-fast-fw';
|
||||||
|
|
||||||
return 'ffz-i-gauge'
|
return 'ffz-i-gauge'
|
||||||
},
|
},
|
||||||
|
|
||||||
subtitle: () => this.i18n.t('metadata.player-stats.subtitle', 'Latency'),
|
|
||||||
|
|
||||||
label(data) {
|
label(data) {
|
||||||
if ( ! this.settings.get('metadata.player-stats') || ! data.delay )
|
if ( ! this.settings.get('metadata.player-stats') || ! data?.delay )
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
if ( data.old )
|
if ( data.old )
|
||||||
|
@ -424,10 +638,10 @@ export default class Metadata extends Module {
|
||||||
},
|
},
|
||||||
|
|
||||||
click() {
|
click() {
|
||||||
const Player = this.resolve('site.player'),
|
const Player = this.resolve('site.player') as any,
|
||||||
fine = this.resolve('site.fine'),
|
fine = this.resolve('site.fine') as any,
|
||||||
player = Player.Player?.first,
|
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),
|
cont = inst && fine.getChildNode(player),
|
||||||
el = cont && cont.querySelector('[data-a-target="player-overlay-video-stats"]');
|
el = cont && cont.querySelector('[data-a-target="player-overlay-video-stats"]');
|
||||||
|
|
||||||
|
@ -449,7 +663,7 @@ export default class Metadata extends Module {
|
||||||
|
|
||||||
color(data) {
|
color(data) {
|
||||||
const setting = this.settings.get('metadata.stream-delay-warning');
|
const setting = this.settings.get('metadata.stream-delay-warning');
|
||||||
if ( setting === 0 || ! data.delay || data.old )
|
if ( setting === 0 || ! data?.delay || data.old )
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if ( data.delay > (setting * 2) )
|
if ( data.delay > (setting * 2) )
|
||||||
|
@ -460,6 +674,9 @@ export default class Metadata extends Module {
|
||||||
},
|
},
|
||||||
|
|
||||||
tooltip(data) {
|
tooltip(data) {
|
||||||
|
if ( ! data )
|
||||||
|
return null;
|
||||||
|
|
||||||
const tampered = data.tampered ? (<div class="tw-border-t tw-mg-t-05 tw-pd-t-05">
|
const tampered = data.tampered ? (<div class="tw-border-t tw-mg-t-05 tw-pd-t-05">
|
||||||
{this.i18n.t(
|
{this.i18n.t(
|
||||||
'metadata.player-stats.tampered',
|
'metadata.player-stats.tampered',
|
||||||
|
@ -470,21 +687,21 @@ export default class Metadata extends Module {
|
||||||
)}
|
)}
|
||||||
</div>) : null;
|
</div>) : null;
|
||||||
|
|
||||||
const delayed = data.drift > 5000 && (<div class="tw-border-b tw-mg-b-05 tw-pd-b-05">
|
const delayed = data.drift > 5000 ? (<div class="tw-border-b tw-mg-b-05 tw-pd-b-05">
|
||||||
{this.i18n.t(
|
{this.i18n.t(
|
||||||
'metadata.player-stats.delay-warning',
|
'metadata.player-stats.delay-warning',
|
||||||
'Your local clock seems to be off by roughly {count,number} seconds, which could make this inaccurate.',
|
'Your local clock seems to be off by roughly {count,number} seconds, which could make this inaccurate.',
|
||||||
Math.round(data.drift / 10) / 100
|
Math.round(data.drift / 10) / 100
|
||||||
)}
|
)}
|
||||||
</div>);
|
</div>) : null;
|
||||||
|
|
||||||
const ff = data.rate > 1 && (<div class="tw-border-b tw-mg-b-05 tw-pd-b-05">
|
const ff = data.rate > 1 ? (<div class="tw-border-b tw-mg-b-05 tw-pd-b-05">
|
||||||
{this.i18n.t(
|
{this.i18n.t(
|
||||||
'metadata.player-stats.rate-warning',
|
'metadata.player-stats.rate-warning',
|
||||||
'Playing at {rate,number}x speed to reduce delay.',
|
'Playing at {rate,number}x speed to reduce delay.',
|
||||||
{rate: data.rate.toFixed(2)}
|
{rate: data.rate.toFixed(2)}
|
||||||
)}
|
)}
|
||||||
</div>);
|
</div>) : null;
|
||||||
|
|
||||||
if ( ! data.stats || ! data.delay )
|
if ( ! data.stats || ! data.delay )
|
||||||
return [
|
return [
|
||||||
|
@ -555,41 +772,32 @@ export default class Metadata extends Module {
|
||||||
tampered
|
tampered
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
getAddonProxy(addon_id, addon, module) {
|
getAddonProxy(addon_id: string, addon: AddonInfo, module: GenericModule) {
|
||||||
if ( ! addon_id )
|
if ( ! addon_id )
|
||||||
return this;
|
return this;
|
||||||
|
|
||||||
const overrides = {},
|
const overrides: Record<string, any> = {},
|
||||||
is_dev = DEBUG || addon?.dev;
|
is_dev = DEBUG || addon?.dev;
|
||||||
|
|
||||||
overrides.define = (key, definition) => {
|
overrides.define = <TData,>(key: string, definition: MetadataDefinition<TData>) => {
|
||||||
if ( definition )
|
if ( definition )
|
||||||
definition.__source = addon_id;
|
definition.__source = addon_id;
|
||||||
|
|
||||||
return this.define(key, definition);
|
return this.define(key, definition);
|
||||||
};
|
};
|
||||||
|
|
||||||
return new Proxy(this, {
|
return buildAddonProxy(module, this, 'metadata', overrides);
|
||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
onEnable() {
|
onEnable() {
|
||||||
const md = this.tooltips.types.metadata = target => {
|
const md: any = (this.tooltips.types as any).metadata = (target: HTMLElement) => {
|
||||||
let el = target;
|
let el: HTMLElement | null = target;
|
||||||
if ( el._ffz_stat )
|
if ( el._ffz_stat )
|
||||||
el = el._ffz_stat;
|
el = el._ffz_stat;
|
||||||
else if ( ! el.classList.contains('ffz-stat') ) {
|
else if ( ! el.classList.contains('ffz-stat') ) {
|
||||||
|
@ -601,31 +809,31 @@ export default class Metadata extends Module {
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const key = el.dataset.key,
|
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;
|
const el = target._ffz_stat || target;
|
||||||
el.tip = tip;
|
el.tip = tip;
|
||||||
};
|
};
|
||||||
|
|
||||||
md.onHide = target => {
|
md.onHide = (target: HTMLElement) => {
|
||||||
const el = target._ffz_stat || target;
|
const el = target._ffz_stat || target;
|
||||||
el.tip = null;
|
el.tip = null;
|
||||||
el.tip_content = null;
|
el.tip_content = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
md.popperConfig = (target, tip, opts) => {
|
md.popperConfig = (target: HTMLElement, tip: TooltipInstance, opts: any) => {
|
||||||
opts.placement = 'bottom';
|
opts.placement = 'bottom';
|
||||||
opts.modifiers.flip = {behavior: ['bottom','top']};
|
opts.modifiers.flip = {behavior: ['bottom','top']};
|
||||||
return opts;
|
return opts;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.on('addon:fully-unload', addon_id => {
|
this.on('addon:fully-unload', addon_id => {
|
||||||
const removed = new Set;
|
const removed = new Set<string>;
|
||||||
for(const [key,def] of Object.entries(this.definitions)) {
|
for(const [key, def] of Object.entries(this.definitions)) {
|
||||||
if ( def?.__source === addon_id ) {
|
if ( def?.__source === addon_id ) {
|
||||||
removed.add(key);
|
removed.add(key);
|
||||||
this.definitions[key] = undefined;
|
this.definitions[key] = undefined;
|
||||||
|
@ -640,51 +848,99 @@ export default class Metadata extends Module {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return an array of all metadata definition keys.
|
||||||
|
*/
|
||||||
get keys() {
|
get keys() {
|
||||||
return Object.keys(this.definitions);
|
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<TData>(key: string, definition?: MetadataDefinition<TData> | null) {
|
||||||
this.definitions[key] = definition;
|
this.definitions[key] = definition;
|
||||||
this.updateMetadata(key);
|
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 )
|
if ( channel )
|
||||||
for(const el of channel.InfoBar.instances)
|
for(const el of channel.InfoBar.instances)
|
||||||
channel.updateMetadata(el, keys);
|
channel.updateMetadata(el, keys);
|
||||||
|
|
||||||
const player = this.resolve('site.player');
|
const player = this.resolve('site.player') as any;
|
||||||
if ( player )
|
if ( player )
|
||||||
for(const inst of player.Player.instances)
|
for(const inst of player.Player.instances)
|
||||||
player.updateMetadata(inst, keys);
|
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<string, ReturnType<typeof setTimeout>>,
|
||||||
|
refresh_fn: (key: string) => void
|
||||||
|
) {
|
||||||
if ( timers[key] )
|
if ( timers[key] )
|
||||||
clearTimeout(timers[key]);
|
clearTimeout(timers[key]);
|
||||||
|
|
||||||
let el = container.querySelector(`.ffz-stat[data-key="${key}"]`);
|
let el = container.querySelector<HTMLElement>(`.ffz-stat[data-key="${key}"]`);
|
||||||
|
|
||||||
const def = this.definitions[key],
|
const def = this.definitions[key],
|
||||||
destroy = () => {
|
destroy = () => {
|
||||||
if ( el ) {
|
if ( el ) {
|
||||||
if ( el.tooltip )
|
/*if ( el.tooltip )
|
||||||
el.tooltip.destroy();
|
el.tooltip.destroy();
|
||||||
|
|
||||||
if ( el.popper )
|
if ( el.popper )
|
||||||
el.popper.destroy();
|
el.popper.destroy();*/
|
||||||
|
|
||||||
if ( el._ffz_destroy )
|
if ( el._ffz_destroy )
|
||||||
el._ffz_destroy();
|
el._ffz_destroy();
|
||||||
|
|
||||||
el._ffz_destroy = el.tooltip = el.popper = null;
|
el._ffz_destroy = /*el.tooltip = el.popper =*/ null;
|
||||||
el.remove();
|
el.remove();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if ( ! def || (data._mt || 'channel') !== (def.type || 'channel') )
|
if ( ! def /* || (data._mt || 'channel') !== (def.type || 'channel') */ )
|
||||||
return destroy();
|
return destroy();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -709,9 +965,10 @@ export default class Metadata extends Module {
|
||||||
|
|
||||||
|
|
||||||
// Grab the element again in case it changed, somehow.
|
// Grab the element again in case it changed, somehow.
|
||||||
el = container.querySelector(`.ffz-stat[data-key="${key}"]`);
|
el = container.querySelector<HTMLElement>(`.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);
|
const label = maybe_call(def.label, this, data);
|
||||||
|
|
||||||
|
@ -728,7 +985,9 @@ export default class Metadata extends Module {
|
||||||
if ( def.button !== false && (def.popup || def.click) ) {
|
if ( def.button !== false && (def.popup || def.click) ) {
|
||||||
button = true;
|
button = true;
|
||||||
|
|
||||||
let btn, popup;
|
let btn: HTMLButtonElement | undefined,
|
||||||
|
popup: HTMLButtonElement | undefined;
|
||||||
|
|
||||||
const border = maybe_call(def.border, this, data),
|
const border = maybe_call(def.border, this, data),
|
||||||
inherit = maybe_call(def.inherit, this, data);
|
inherit = maybe_call(def.inherit, this, data);
|
||||||
|
|
||||||
|
@ -741,6 +1000,8 @@ export default class Metadata extends Module {
|
||||||
el = (<div
|
el = (<div
|
||||||
class={`tw-align-items-center tw-inline-flex tw-relative ffz-il-tooltip__container ffz-stat tw-stat ffz-stat--fix-padding ${border ? 'tw-mg-r-1' : 'tw-mg-r-05 ffz-mg-l--05'}`}
|
class={`tw-align-items-center tw-inline-flex tw-relative ffz-il-tooltip__container ffz-stat tw-stat ffz-stat--fix-padding ${border ? 'tw-mg-r-1' : 'tw-mg-r-05 ffz-mg-l--05'}`}
|
||||||
data-key={key}
|
data-key={key}
|
||||||
|
// createElement will properly assign this to the
|
||||||
|
// created element. Shut up TypeScript.
|
||||||
tip_content={null}
|
tip_content={null}
|
||||||
>
|
>
|
||||||
{btn = (<button
|
{btn = (<button
|
||||||
|
@ -748,10 +1009,10 @@ export default class Metadata extends Module {
|
||||||
data-tooltip-type="metadata"
|
data-tooltip-type="metadata"
|
||||||
>
|
>
|
||||||
<div class="tw-align-items-center tw-flex tw-flex-grow-0 tw-justify-center tw-pd-x-1">
|
<div class="tw-align-items-center tw-flex tw-flex-grow-0 tw-justify-center tw-pd-x-1">
|
||||||
{icon}
|
{icon as any}
|
||||||
{stat = (<span class="ffz-stat-text" />)}
|
{stat = (<span class="ffz-stat-text" />)}
|
||||||
</div>
|
</div>
|
||||||
</button>)}
|
</button>) as HTMLButtonElement}
|
||||||
{popup = (<button
|
{popup = (<button
|
||||||
class={`tw-align-items-center tw-align-middle 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 ${border ? 'tw-border' : 'tw-font-size-5 tw-regular'}${def.tooltip ? ' ffz-tooltip ffz-tooltip--no-mouse' : ''}`}
|
class={`tw-align-items-center tw-align-middle 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 ${border ? 'tw-border' : 'tw-font-size-5 tw-regular'}${def.tooltip ? ' ffz-tooltip ffz-tooltip--no-mouse' : ''}`}
|
||||||
data-tooltip-type="metadata"
|
data-tooltip-type="metadata"
|
||||||
|
@ -761,7 +1022,7 @@ export default class Metadata extends Module {
|
||||||
<figure class="ffz-i-down-dir" />
|
<figure class="ffz-i-down-dir" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</button>)}
|
</button>) as HTMLButtonElement}
|
||||||
</div>);
|
</div>);
|
||||||
|
|
||||||
} else
|
} else
|
||||||
|
@ -769,64 +1030,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' : ''}`}
|
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-tooltip-type="metadata"
|
||||||
data-key={key}
|
data-key={key}
|
||||||
|
// createElement will properly assign this to the
|
||||||
|
// created element. Shut up TypeScript.
|
||||||
tip_content={null}
|
tip_content={null}
|
||||||
>
|
>
|
||||||
<div class="tw-align-items-center tw-flex tw-flex-grow-0 tw-justify-center">
|
<div class="tw-align-items-center tw-flex tw-flex-grow-0 tw-justify-center">
|
||||||
{icon}
|
{icon as any}
|
||||||
{stat = (<span class="ffz-stat-text" />)}
|
{stat = (<span class="ffz-stat-text" />)}
|
||||||
{def.popup && ! def.no_arrow && <span class="tw-mg-l-05">
|
{def.popup && ! def.no_arrow && <span class="tw-mg-l-05">
|
||||||
<figure class="ffz-i-down-dir" />
|
<figure class="ffz-i-down-dir" />
|
||||||
</span>}
|
</span>}
|
||||||
</div>
|
</div>
|
||||||
</button>);
|
</button>) as any as HTMLButtonElement;
|
||||||
|
|
||||||
if ( def.click )
|
if ( def.click )
|
||||||
btn.addEventListener('click', e => {
|
btn.addEventListener('click', (event: MouseEvent) => {
|
||||||
if ( el._ffz_fading || btn.disabled || btn.classList.contains('disabled') || el.disabled || el.classList.contains('disabled') )
|
if ( ! el || ! btn || btn.disabled || btn.classList.contains('disabled') || (el as any).disabled || el.classList.contains('disabled') )
|
||||||
return false;
|
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 )
|
if ( def.popup )
|
||||||
popup.addEventListener('click', () => {
|
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;
|
return false;
|
||||||
|
|
||||||
if ( el._ffz_popup )
|
if ( el._ffz_popup && el._ffz_destroy )
|
||||||
return el._ffz_destroy();
|
return el._ffz_destroy();
|
||||||
|
|
||||||
const listeners = [],
|
const listeners: (() => void)[] = [],
|
||||||
add_close_listener = cb => listeners.push(cb);
|
add_close_listener = (cb: () => void) => {
|
||||||
|
listeners.push(cb);
|
||||||
|
};
|
||||||
|
|
||||||
const destroy = el._ffz_destroy = () => {
|
const destroy = el._ffz_destroy = () => {
|
||||||
for(const cb of listeners) {
|
for(const cb of listeners) {
|
||||||
try {
|
try {
|
||||||
cb();
|
cb();
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
this.log.capture(err, {
|
if ( err instanceof Error )
|
||||||
tags: {
|
this.log.capture(err, {
|
||||||
metadata: key
|
tags: {
|
||||||
}
|
metadata: key
|
||||||
});
|
}
|
||||||
|
});
|
||||||
this.log.error('Error when running a callback for pop-up destruction for metadata:', key, err);
|
this.log.error('Error when running a callback for pop-up destruction for metadata:', key, err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( el._ffz_outside )
|
// el is not going to be null
|
||||||
el._ffz_outside.destroy();
|
// TypeScript is on drugs
|
||||||
|
// whatever though
|
||||||
|
if ( el ) {
|
||||||
|
if ( el._ffz_outside )
|
||||||
|
el._ffz_outside.destroy();
|
||||||
|
|
||||||
if ( el._ffz_popup ) {
|
if ( el._ffz_popup ) {
|
||||||
const fp = el._ffz_popup;
|
const fp = el._ffz_popup;
|
||||||
el._ffz_popup = null;
|
el._ffz_popup = null;
|
||||||
fp.destroy();
|
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,
|
const parent = document.fullscreenElement || document.body.querySelector<HTMLElement>('#root>div') || document.body,
|
||||||
tt = el._ffz_popup = new Tooltip(parent, el, {
|
tt = el._ffz_popup = new Tooltip(parent as HTMLElement, el, {
|
||||||
logger: this.log,
|
logger: this.log,
|
||||||
i18n: this.i18n,
|
i18n: this.i18n,
|
||||||
manual: true,
|
manual: true,
|
||||||
|
@ -850,10 +1121,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) =>
|
onShow: (t, tip) =>
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
el._ffz_outside = new ClickOutside(tip.outer, destroy);
|
if ( el && tip.outer )
|
||||||
|
el._ffz_outside = new ClickOutside(tip.outer, destroy);
|
||||||
}),
|
}),
|
||||||
onHide: destroy
|
onHide: destroy
|
||||||
});
|
});
|
||||||
|
@ -871,23 +1143,23 @@ export default class Metadata extends Module {
|
||||||
data-key={key}
|
data-key={key}
|
||||||
tip_content={null}
|
tip_content={null}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon as any}
|
||||||
{stat = <span class={`${icon ? 'tw-mg-l-05 ' : ''}ffz-stat-text tw-stat__value`} />}
|
{stat = <span class={`${icon ? 'tw-mg-l-05 ' : ''}ffz-stat-text tw-stat__value`} />}
|
||||||
</div>);
|
</div>);
|
||||||
|
|
||||||
if ( def.click )
|
if ( def.click )
|
||||||
el.addEventListener('click', e => {
|
el.addEventListener('click', (event: MouseEvent) => {
|
||||||
if ( el._ffz_fading || el.disabled || el.classList.contains('disabled') )
|
if ( ! el || (el as any).disabled || el.classList.contains('disabled') )
|
||||||
return false;
|
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;
|
el._ffz_order = order;
|
||||||
|
|
||||||
if ( order != null )
|
if ( order != null )
|
||||||
el.style.order = order;
|
el.style.order = `${order}`;
|
||||||
|
|
||||||
container.appendChild(el);
|
container.appendChild(el);
|
||||||
|
|
||||||
|
@ -900,14 +1172,16 @@ export default class Metadata extends Module {
|
||||||
old_color = el.dataset.color || '';
|
old_color = el.dataset.color || '';
|
||||||
|
|
||||||
if ( el._ffz_order !== order )
|
if ( el._ffz_order !== order )
|
||||||
el.style.order = el._ffz_order = order;
|
el.style.order = `${el._ffz_order = order}`;
|
||||||
|
|
||||||
if ( el.tip ) {
|
if ( el.tip ) {
|
||||||
const tooltip = maybe_call(def.tooltip, this, data);
|
const tooltip = maybe_call(def.tooltip, this, data);
|
||||||
if ( el.tip_content !== tooltip ) {
|
if ( el.tip_content !== tooltip ) {
|
||||||
el.tip_content = tooltip;
|
el.tip_content = tooltip;
|
||||||
el.tip.element.innerHTML = '';
|
if ( el.tip?.element ) {
|
||||||
setChildren(el.tip.element, tooltip);
|
el.tip.element.innerHTML = '';
|
||||||
|
setChildren(el.tip.element, tooltip);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -928,19 +1202,21 @@ export default class Metadata extends Module {
|
||||||
}
|
}
|
||||||
|
|
||||||
el._ffz_data = data;
|
el._ffz_data = data;
|
||||||
stat.innerHTML = label;
|
stat.innerHTML = '';
|
||||||
|
setChildren(stat, label);
|
||||||
|
|
||||||
if ( def.disabled !== undefined )
|
if ( def.disabled !== undefined )
|
||||||
el.disabled = maybe_call(def.disabled, this, data);
|
(el as any).disabled = maybe_call(def.disabled, this, data);
|
||||||
|
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
this.log.capture(err, {
|
if ( err instanceof Error )
|
||||||
tags: {
|
this.log.capture(err, {
|
||||||
metadata: key
|
tags: {
|
||||||
}
|
metadata: key
|
||||||
});
|
}
|
||||||
|
});
|
||||||
this.log.error(`Error rendering metadata for ${key}`, err);
|
this.log.error(`Error rendering metadata for ${key}`, err);
|
||||||
return destroy();
|
return destroy();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -5,16 +5,81 @@
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import {createElement, sanitize} from 'utilities/dom';
|
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 Tooltip, { TooltipInstance } from 'utilities/tooltip';
|
||||||
import Module from 'utilities/module';
|
import Module, { GenericModule, buildAddonProxy } from 'utilities/module';
|
||||||
import awaitMD, {getMD} from 'utilities/markdown';
|
import awaitMD, {getMD} from 'utilities/markdown';
|
||||||
import { DEBUG } from 'src/utilities/constants';
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'utilities/types' {
|
||||||
|
interface ModuleEventMap {
|
||||||
|
tooltips: TooltipEvents;
|
||||||
|
}
|
||||||
|
interface ModuleMap {
|
||||||
|
tooltips: TooltipProvider;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
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<TReturn> = OptionallyCallable<[target: HTMLElement, tip: TooltipInstance], TReturn>;
|
||||||
|
|
||||||
|
type TooltipExtra = {
|
||||||
|
__source?: string;
|
||||||
|
|
||||||
|
popperConfig(target: HTMLElement, tip: TooltipInstance, options: any): any;
|
||||||
|
|
||||||
|
delayShow: TooltipOptional<number>;
|
||||||
|
delayHide: TooltipOptional<number>;
|
||||||
|
|
||||||
|
interactive: TooltipOptional<boolean>;
|
||||||
|
hover_events: TooltipOptional<boolean>;
|
||||||
|
|
||||||
|
onShow(target: HTMLElement, tip: TooltipInstance): void;
|
||||||
|
onHide(target: HTMLElement, tip: TooltipInstance): void;
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TooltipDefinition = Partial<TooltipExtra> &
|
||||||
|
((target: HTMLElement, tip: TooltipInstance) => DomFragment);
|
||||||
|
|
||||||
|
|
||||||
|
export default class TooltipProvider extends Module<'tooltips', TooltipEvents> {
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
types: Record<string, TooltipDefinition | undefined>;
|
||||||
|
|
||||||
|
// 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.types = {};
|
||||||
|
|
||||||
this.inject('i18n');
|
this.inject('i18n');
|
||||||
|
@ -69,44 +134,46 @@ export default class TooltipProvider extends Module {
|
||||||
return md.render(target.dataset.title);
|
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.types.html = target => target.dataset.title;
|
||||||
|
|
||||||
this.onFSChange = this.onFSChange.bind(this);
|
this.onFSChange = this.onFSChange.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
getAddonProxy(addon_id, addon, module) {
|
getAddonProxy(addon_id: string, addon: AddonInfo, module: GenericModule) {
|
||||||
if ( ! addon_id )
|
if ( ! addon_id )
|
||||||
return this;
|
return this;
|
||||||
|
|
||||||
const overrides = {},
|
const overrides: Record<string, any> = {},
|
||||||
is_dev = DEBUG || addon?.dev;
|
is_dev = DEBUG || addon?.dev;
|
||||||
|
let warnings: Record<string, boolean | string> | undefined;
|
||||||
|
|
||||||
overrides.define = (key, handler) => {
|
overrides.define = (key: string, handler: TooltipDefinition) => {
|
||||||
if ( handler )
|
if ( handler )
|
||||||
handler.__source = addon_id;
|
handler.__source = addon_id;
|
||||||
|
|
||||||
return this.define(key, handler);
|
return this.define(key, handler);
|
||||||
};
|
};
|
||||||
|
|
||||||
if ( is_dev )
|
if ( is_dev ) {
|
||||||
overrides.cleanup = () => {
|
overrides.cleanup = () => {
|
||||||
module.log.warn('[DEV-CHECK] Instead of calling tooltips.cleanup(), you can emit the event "tooltips:cleanup"');
|
module.log.warn('[DEV-CHECK] Instead of calling tooltips.cleanup(), you can emit the event "tooltips:cleanup"');
|
||||||
return this.cleanup();
|
return this.cleanup();
|
||||||
};
|
};
|
||||||
|
|
||||||
return new Proxy(this, {
|
warnings = {
|
||||||
get(obj, prop) {
|
types: 'Please use tooltips.define()'
|
||||||
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()');
|
|
||||||
|
|
||||||
return Reflect.get(...arguments);
|
return buildAddonProxy(
|
||||||
}
|
module,
|
||||||
});
|
this,
|
||||||
|
'tooltips',
|
||||||
|
overrides,
|
||||||
|
warnings
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -140,20 +207,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;
|
this.types[key] = handler;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
getRoot() { // eslint-disable-line class-methods-use-this
|
getRoot() { // eslint-disable-line class-methods-use-this
|
||||||
return document.querySelector('.sunlight-root') ||
|
return document.querySelector<HTMLElement>('.sunlight-root') ||
|
||||||
//document.querySelector('#root>div') ||
|
//document.querySelector('#root>div') ||
|
||||||
document.querySelector('#root') ||
|
document.querySelector('#root') ||
|
||||||
document.querySelector('.clips-root') ||
|
document.querySelector('.clips-root') ||
|
||||||
document.body;
|
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, {
|
return new Tooltip(container, klass, {
|
||||||
html: true,
|
html: true,
|
||||||
i18n: this.i18n,
|
i18n: this.i18n,
|
||||||
|
@ -190,34 +259,52 @@ export default class TooltipProvider extends Module {
|
||||||
|
|
||||||
|
|
||||||
onFSChange() {
|
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 ) {
|
if ( tip_element !== this.tip_element ) {
|
||||||
this.tips.destroy();
|
|
||||||
this.tip_element = tip_element;
|
this.tip_element = tip_element;
|
||||||
this.tips = this._createInstance(tip_element);
|
if ( this.tips ) {
|
||||||
|
this.tips.destroy();
|
||||||
|
this.tips = this._createInstance(tip_element);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
cleanup() {
|
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,
|
const type = target.dataset.tooltipType || default_type,
|
||||||
handler = this.types[type];
|
handler = this.types[type];
|
||||||
|
|
||||||
if ( target.dataset.tooltipSide )
|
if ( target.dataset.tooltipSide )
|
||||||
pop_opts.placement = target.dataset.tooltipSide;
|
options.placement = target.dataset.tooltipSide;
|
||||||
|
|
||||||
if ( handler && handler.popperConfig )
|
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,
|
const type = target.dataset.tooltipType || default_type,
|
||||||
handler = this.types[type];
|
handler = this.types[type];
|
||||||
|
|
||||||
|
@ -225,7 +312,11 @@ export default class TooltipProvider extends Module {
|
||||||
handler.onShow(target, tip);
|
handler.onShow(target, tip);
|
||||||
}
|
}
|
||||||
|
|
||||||
delegateOnHide(default_type, target, tip) {
|
delegateOnHide(
|
||||||
|
default_type: string,
|
||||||
|
target: HTMLElement,
|
||||||
|
tip: TooltipInstance
|
||||||
|
) {
|
||||||
const type = target.dataset.tooltipType || default_type,
|
const type = target.dataset.tooltipType || default_type,
|
||||||
handler = this.types[type];
|
handler = this.types[type];
|
||||||
|
|
||||||
|
@ -233,47 +324,67 @@ export default class TooltipProvider extends Module {
|
||||||
handler.onHide(target, tip);
|
handler.onHide(target, tip);
|
||||||
}
|
}
|
||||||
|
|
||||||
checkDelayShow(default_type, target, tip) {
|
checkDelayShow(
|
||||||
|
default_type: string,
|
||||||
|
target: HTMLElement,
|
||||||
|
tip: TooltipInstance
|
||||||
|
) {
|
||||||
const type = target.dataset.tooltipType || default_type,
|
const type = target.dataset.tooltipType || default_type,
|
||||||
handler = this.types[type];
|
handler = this.types[type];
|
||||||
|
|
||||||
if ( has(handler, 'delayShow') )
|
if ( handler?.delayShow != null )
|
||||||
return maybe_call(handler.delayShow, null, target, tip);
|
return maybe_call(handler.delayShow, null, target, tip);
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
checkDelayHide(default_type, target, tip) {
|
checkDelayHide(
|
||||||
|
default_type: string,
|
||||||
|
target: HTMLElement,
|
||||||
|
tip: TooltipInstance
|
||||||
|
) {
|
||||||
const type = target.dataset.tooltipType || default_type,
|
const type = target.dataset.tooltipType || default_type,
|
||||||
handler = this.types[type];
|
handler = this.types[type];
|
||||||
|
|
||||||
if ( has(handler, 'delayHide') )
|
if ( handler?.delayHide != null )
|
||||||
return maybe_call(handler.delayHide, null, target, tip);
|
return maybe_call(handler.delayHide, null, target, tip);
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
checkInteractive(default_type, target, tip) {
|
checkInteractive(
|
||||||
|
default_type: string,
|
||||||
|
target: HTMLElement,
|
||||||
|
tip: TooltipInstance
|
||||||
|
) {
|
||||||
const type = target.dataset.tooltipType || default_type,
|
const type = target.dataset.tooltipType || default_type,
|
||||||
handler = this.types[type];
|
handler = this.types[type];
|
||||||
|
|
||||||
if ( has(handler, 'interactive') )
|
if ( handler?.interactive != null )
|
||||||
return maybe_call(handler.interactive, null, target, tip);
|
return maybe_call(handler.interactive, null, target, tip);
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
checkHoverEvents(default_type, target, tip) {
|
checkHoverEvents(
|
||||||
|
default_type: string,
|
||||||
|
target: HTMLElement,
|
||||||
|
tip: TooltipInstance
|
||||||
|
) {
|
||||||
const type = target.dataset.tooltipType || default_type,
|
const type = target.dataset.tooltipType || default_type,
|
||||||
handler = this.types[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 maybe_call(handler.hover_events, null, target, tip);
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
process(default_type, target, tip) {
|
process(
|
||||||
|
default_type: string,
|
||||||
|
target: HTMLElement,
|
||||||
|
tip: TooltipInstance
|
||||||
|
) {
|
||||||
const type = target.dataset.tooltipType || default_type || 'text',
|
const type = target.dataset.tooltipType || default_type || 'text',
|
||||||
align = target.dataset.tooltipAlign,
|
align = target.dataset.tooltipAlign,
|
||||||
handler = this.types[type];
|
handler = this.types[type];
|
||||||
|
@ -295,4 +406,4 @@ export default class TooltipProvider extends Module {
|
||||||
|
|
||||||
return handler(target, tip);
|
return handler(target, tip);
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -12,7 +12,7 @@ import {timeout} from 'utilities/object';
|
||||||
import SettingsManager from './settings/index';
|
import SettingsManager from './settings/index';
|
||||||
import AddonManager from './addons';
|
import AddonManager from './addons';
|
||||||
import ExperimentManager from './experiments';
|
import ExperimentManager from './experiments';
|
||||||
import {TranslationManager} from './i18n';
|
import TranslationManager from './i18n';
|
||||||
import StagingSelector from './staging';
|
import StagingSelector from './staging';
|
||||||
import PubSubClient from './pubsub';
|
import PubSubClient from './pubsub';
|
||||||
import LoadTracker from './load_tracker';
|
import LoadTracker from './load_tracker';
|
||||||
|
@ -156,4 +156,4 @@ FrankerFaceZ.utilities = {
|
||||||
|
|
||||||
|
|
||||||
window.FrankerFaceZ = FrankerFaceZ;
|
window.FrankerFaceZ = FrankerFaceZ;
|
||||||
window.ffz = new FrankerFaceZ();
|
window.ffz = new FrankerFaceZ();
|
||||||
|
|
|
@ -4,19 +4,67 @@
|
||||||
// PubSub Client
|
// PubSub Client
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import Module from 'utilities/module';
|
import Module, { GenericModule } from 'utilities/module';
|
||||||
import { PUBSUB_CLUSTERS } from 'utilities/constants';
|
import { PUBSUB_CLUSTERS } from 'utilities/constants';
|
||||||
|
import type ExperimentManager from '../experiments';
|
||||||
|
import type SettingsManager from '../settings';
|
||||||
|
import type PubSubClient from 'utilities/pubsub';
|
||||||
|
import type { PubSubCommands } from 'utilities/types';
|
||||||
|
import type { SettingUi_Select_Entry } from '../settings/types';
|
||||||
|
|
||||||
|
declare module 'utilities/types' {
|
||||||
|
interface ModuleMap {
|
||||||
|
pubsub: PubSub;
|
||||||
|
}
|
||||||
|
interface ModuleEventMap {
|
||||||
|
pubsub: PubSubEvents;
|
||||||
|
}
|
||||||
|
interface SettingsTypeMap {
|
||||||
|
'pubsub.use-cluster': keyof typeof PUBSUB_CLUSTERS | null;
|
||||||
|
}
|
||||||
|
interface ExperimentTypeMap {
|
||||||
|
cf_pubsub: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export default class PubSub extends Module {
|
type PubSubCommandData<K extends keyof PubSubCommands> = {
|
||||||
constructor(...args) {
|
topic: string;
|
||||||
super(...args);
|
cmd: K;
|
||||||
|
data: PubSubCommands[K];
|
||||||
|
};
|
||||||
|
|
||||||
|
type PubSubCommandKey = `:command:${keyof PubSubCommands}`;
|
||||||
|
|
||||||
|
type PubSubEvents = {
|
||||||
|
':sub-change': [];
|
||||||
|
':message': [topic: string, data: unknown];
|
||||||
|
} & {
|
||||||
|
[K in keyof PubSubCommands as `:command:${K}`]: [data: PubSubCommands[K], meta: PubSubCommandData<K>];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default class PubSub extends Module<'pubsub', PubSubEvents> {
|
||||||
|
|
||||||
|
// Dependencies
|
||||||
|
experiments: ExperimentManager = null as any;
|
||||||
|
settings: SettingsManager = null as any;
|
||||||
|
|
||||||
|
// State
|
||||||
|
_topics: Map<string, Set<unknown>>;
|
||||||
|
_client: PubSubClient | null;
|
||||||
|
|
||||||
|
_mqtt?: typeof PubSubClient | null;
|
||||||
|
_mqtt_loader?: Promise<typeof PubSubClient> | null;
|
||||||
|
|
||||||
|
constructor(name?: string, parent?: GenericModule) {
|
||||||
|
super(name, parent);
|
||||||
|
|
||||||
this.inject('settings');
|
this.inject('settings');
|
||||||
this.inject('experiments');
|
this.inject('experiments');
|
||||||
|
|
||||||
this.settings.add('pubsub.use-cluster', {
|
this.settings.add('pubsub.use-cluster', {
|
||||||
default: ctx => {
|
default: () => {
|
||||||
if ( this.experiments.getAssignment('cf_pubsub') )
|
if ( this.experiments.getAssignment('cf_pubsub') )
|
||||||
return 'Staging';
|
return 'Staging';
|
||||||
return null;
|
return null;
|
||||||
|
@ -33,7 +81,7 @@ export default class PubSub extends Module {
|
||||||
data: [{
|
data: [{
|
||||||
value: null,
|
value: null,
|
||||||
title: 'Disabled'
|
title: 'Disabled'
|
||||||
}].concat(Object.keys(PUBSUB_CLUSTERS).map(x => ({
|
} as SettingUi_Select_Entry<string | null>].concat(Object.keys(PUBSUB_CLUSTERS).map(x => ({
|
||||||
value: x,
|
value: x,
|
||||||
title: x
|
title: x
|
||||||
})))
|
})))
|
||||||
|
@ -161,18 +209,18 @@ export default class PubSub extends Module {
|
||||||
|
|
||||||
client.on('message', event => {
|
client.on('message', event => {
|
||||||
const topic = event.topic,
|
const topic = event.topic,
|
||||||
data = event.data;
|
data = event.data as PubSubCommandData<any>;
|
||||||
|
|
||||||
if ( ! data?.cmd ) {
|
if ( ! data?.cmd ) {
|
||||||
this.log.debug(`Received message on topic "${topic}":`, data);
|
this.log.debug(`Received message on topic "${topic}":`, data);
|
||||||
this.emit(`pubsub:message`, topic, data);
|
this.emit(`:message`, topic, data);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
data.topic = topic;
|
data.topic = topic;
|
||||||
|
|
||||||
this.log.debug(`Received command on topic "${topic}" for command "${data.cmd}":`, data.data);
|
this.log.debug(`Received command on topic "${topic}" for command "${data.cmd}":`, data.data);
|
||||||
this.emit(`pubsub:command:${data.cmd}`, data.data, data);
|
this.emit(`:command:${data.cmd}` as PubSubCommandKey, data.data, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Subscribe to topics.
|
// Subscribe to topics.
|
||||||
|
@ -196,20 +244,23 @@ export default class PubSub extends Module {
|
||||||
// Topics
|
// Topics
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
subscribe(referrer, ...topics) {
|
subscribe(referrer: unknown, ...topics: string[]) {
|
||||||
const t = this._topics;
|
const topic_map = this._topics;
|
||||||
let changed = false;
|
let changed = false;
|
||||||
for(const topic of topics) {
|
for(const topic of topics) {
|
||||||
if ( ! t.has(topic) ) {
|
let refs = topic_map.get(topic);
|
||||||
|
if ( refs )
|
||||||
|
refs.add(referrer);
|
||||||
|
else {
|
||||||
if ( this._client )
|
if ( this._client )
|
||||||
this._client.subscribe(topic);
|
this._client.subscribe(topic);
|
||||||
|
|
||||||
t.set(topic, new Set);
|
refs = new Set;
|
||||||
|
refs.add(referrer);
|
||||||
|
|
||||||
|
topic_map.set(topic, refs);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tp = t.get(topic);
|
|
||||||
tp.add(referrer);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( changed )
|
if ( changed )
|
||||||
|
@ -217,19 +268,19 @@ export default class PubSub extends Module {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
unsubscribe(referrer, ...topics) {
|
unsubscribe(referrer: unknown, ...topics: string[]) {
|
||||||
const t = this._topics;
|
const topic_map = this._topics;
|
||||||
let changed = false;
|
let changed = false;
|
||||||
for(const topic of topics) {
|
for(const topic of topics) {
|
||||||
if ( ! t.has(topic) )
|
const refs = topic_map.get(topic);
|
||||||
|
if ( ! refs )
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
const tp = t.get(topic);
|
refs.delete(referrer);
|
||||||
tp.delete(referrer);
|
|
||||||
|
|
||||||
if ( ! tp.size ) {
|
if ( ! refs.size ) {
|
||||||
changed = true;
|
changed = true;
|
||||||
t.delete(topic);
|
topic_map.delete(topic);
|
||||||
if ( this._client )
|
if ( this._client )
|
||||||
this._client.unsubscribe(topic);
|
this._client.unsubscribe(topic);
|
||||||
}
|
}
|
|
@ -216,7 +216,7 @@ export default class RavenLogger extends Module {
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
if ( this.settings && this.settings.get('reports.error.include-user') ) {
|
if ( this.settings && this.settings.get('reports.error.include-user') ) {
|
||||||
const user = this.resolve('site')?.getUser();
|
const user = this.resolve('site')?.getUser?.();
|
||||||
if ( user )
|
if ( user )
|
||||||
data.user = {id: user.id, username: user.login}
|
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) }
|
captureException(exc, opts) { return this.raven.captureException(exc, opts) }
|
||||||
captureMessage(msg, opts) { return this.raven.captureMessage(msg, opts) }
|
captureMessage(msg, opts) { return this.raven.captureMessage(msg, opts) }
|
||||||
captureBreadcrumb(...args) { return this.raven.captureBreadcrumb(...args) }
|
captureBreadcrumb(...args) { return this.raven.captureBreadcrumb(...args) }
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,13 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
import { AdvancedSettingsProvider } from "./providers";
|
||||||
|
import type { SettingsClearable } from "./types";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Clearable Settings
|
// Clearable Settings
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export const Experiments = {
|
export const Experiments: SettingsClearable = {
|
||||||
label: 'Experiment Overrides',
|
label: 'Experiment Overrides',
|
||||||
keys: [
|
keys: [
|
||||||
'exp-lock',
|
'exp-lock',
|
||||||
|
@ -12,7 +15,7 @@ export const Experiments = {
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
export const HiddenEmotes = {
|
export const HiddenEmotes: SettingsClearable = {
|
||||||
label: 'Hidden Emotes',
|
label: 'Hidden Emotes',
|
||||||
keys(provider) {
|
keys(provider) {
|
||||||
const keys = ['emote-menu.hidden-sets'];
|
const keys = ['emote-menu.hidden-sets'];
|
||||||
|
@ -24,7 +27,7 @@ export const HiddenEmotes = {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const FavoriteEmotes = {
|
export const FavoriteEmotes: SettingsClearable = {
|
||||||
label: 'Favorited Emotes',
|
label: 'Favorited Emotes',
|
||||||
keys(provider) {
|
keys(provider) {
|
||||||
const keys = [];
|
const keys = [];
|
||||||
|
@ -36,7 +39,7 @@ export const FavoriteEmotes = {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Overrides = {
|
export const Overrides: SettingsClearable = {
|
||||||
label: 'Name and Color Overrides',
|
label: 'Name and Color Overrides',
|
||||||
keys: [
|
keys: [
|
||||||
'overrides.colors',
|
'overrides.colors',
|
||||||
|
@ -44,7 +47,7 @@ export const Overrides = {
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Profiles = {
|
export const Profiles: SettingsClearable = {
|
||||||
label: 'Profiles',
|
label: 'Profiles',
|
||||||
clear(provider, settings) {
|
clear(provider, settings) {
|
||||||
const keys = ['profiles'];
|
const keys = ['profiles'];
|
||||||
|
@ -59,11 +62,11 @@ export const Profiles = {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Everything = {
|
export const Everything: SettingsClearable = {
|
||||||
label: 'Absolutely Everything',
|
label: 'Absolutely Everything',
|
||||||
async clear(provider, settings) {
|
async clear(provider, settings) {
|
||||||
provider.clear();
|
provider.clear();
|
||||||
if ( provider.supportsBlobs )
|
if ( provider.supportsBlobs && provider instanceof AdvancedSettingsProvider )
|
||||||
await provider.clearBlobs();
|
await provider.clearBlobs();
|
||||||
|
|
||||||
settings.loadProfiles();
|
settings.loadProfiles();
|
|
@ -30,4 +30,4 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -7,7 +7,11 @@
|
||||||
import {EventEmitter} from 'utilities/events';
|
import {EventEmitter} from 'utilities/events';
|
||||||
import {has, get as getter, array_equals, set_equals, map_equals, deep_equals} from 'utilities/object';
|
import {has, get as getter, array_equals, set_equals, map_equals, deep_equals} from 'utilities/object';
|
||||||
|
|
||||||
import * as DEFINITIONS from './types';
|
import DEFINITIONS from './typehandlers';
|
||||||
|
import type { AllSettingsKeys, ContextData, SettingMetadata, SettingType, SettingDefinition, SettingsKeys } from './types';
|
||||||
|
import type SettingsManager from '.';
|
||||||
|
import type SettingsProfile from './profile';
|
||||||
|
import type { SettingsTypeMap } from 'utilities/types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Perform a basic check of a setting's requirements to see if they changed.
|
* Perform a basic check of a setting's requirements to see if they changed.
|
||||||
|
@ -16,7 +20,11 @@ import * as DEFINITIONS from './types';
|
||||||
* @param {Map} old_cache
|
* @param {Map} old_cache
|
||||||
* @returns Whether or not they changed.
|
* @returns Whether or not they changed.
|
||||||
*/
|
*/
|
||||||
function compare_requirements(definition, cache, old_cache) {
|
function compare_requirements(
|
||||||
|
definition: SettingDefinition<any>,
|
||||||
|
cache: Map<string, unknown>,
|
||||||
|
old_cache: Map<string, unknown>
|
||||||
|
) {
|
||||||
if ( ! definition || ! Array.isArray(definition.requires) )
|
if ( ! definition || ! Array.isArray(definition.requires) )
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
|
@ -47,14 +55,44 @@ function compare_requirements(definition, cache, old_cache) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export type SettingsContextEvents = {
|
||||||
|
[K in keyof SettingsTypeMap as `changed:${K}`]: [value: SettingsTypeMap[K], old_value: SettingsTypeMap[K]];
|
||||||
|
} & {
|
||||||
|
[K in keyof SettingsTypeMap as `uses_changed:${K}`]: [uses: number[] | null, old_uses: number[] | null];
|
||||||
|
} & {
|
||||||
|
changed: [key: SettingsKeys, value: any, old_value: any];
|
||||||
|
uses_changed: [key: SettingsKeys, uses: number[] | null, old_uses: number[] | null];
|
||||||
|
|
||||||
|
context_changed: [];
|
||||||
|
profiles_changed: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The SettingsContext class provides a context through which to read
|
* The SettingsContext class provides a context through which to read
|
||||||
* settings values in addition to emitting events when settings values
|
* settings values in addition to emitting events when settings values
|
||||||
* are changed.
|
* are changed.
|
||||||
* @extends EventEmitter
|
|
||||||
*/
|
*/
|
||||||
export default class SettingsContext extends EventEmitter {
|
export default class SettingsContext extends EventEmitter<SettingsContextEvents> {
|
||||||
constructor(manager, context) {
|
|
||||||
|
parent: SettingsContext | null;
|
||||||
|
manager: SettingsManager;
|
||||||
|
|
||||||
|
order: number[];
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
_context: ContextData;
|
||||||
|
private __context: ContextData = null as any;
|
||||||
|
|
||||||
|
private __profiles: SettingsProfile[];
|
||||||
|
|
||||||
|
private __cache: Map<SettingsKeys, unknown>;
|
||||||
|
private __meta: Map<SettingsKeys, SettingMetadata>;
|
||||||
|
|
||||||
|
private __ls_listening: boolean;
|
||||||
|
private __ls_wanted: Map<string, Set<string>>;
|
||||||
|
|
||||||
|
constructor(manager: SettingsContext | SettingsManager, context?: ContextData) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
if ( manager instanceof SettingsContext ) {
|
if ( manager instanceof SettingsContext ) {
|
||||||
|
@ -68,7 +106,7 @@ export default class SettingsContext extends EventEmitter {
|
||||||
this.manager = manager;
|
this.manager = manager;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.manager.__contexts.push(this);
|
(this.manager as any).__contexts.push(this);
|
||||||
this._context = context || {};
|
this._context = context || {};
|
||||||
|
|
||||||
/*this._context_objects = new Set;
|
/*this._context_objects = new Set;
|
||||||
|
@ -93,7 +131,7 @@ export default class SettingsContext extends EventEmitter {
|
||||||
for(const profile of this.__profiles)
|
for(const profile of this.__profiles)
|
||||||
profile.off('changed', this._onChanged, this);
|
profile.off('changed', this._onChanged, this);
|
||||||
|
|
||||||
const contexts = this.manager.__contexts,
|
const contexts = (this.manager as any).__contexts,
|
||||||
idx = contexts.indexOf(this);
|
idx = contexts.indexOf(this);
|
||||||
|
|
||||||
if ( idx !== -1 )
|
if ( idx !== -1 )
|
||||||
|
@ -106,26 +144,26 @@ export default class SettingsContext extends EventEmitter {
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
_watchLS() {
|
_watchLS() {
|
||||||
if ( this.__ls_watched )
|
if ( this.__ls_listening )
|
||||||
return;
|
return;
|
||||||
|
|
||||||
this.__ls_watched = true;
|
this.__ls_listening = true;
|
||||||
this.manager.on(':ls-update', this._onLSUpdate, this);
|
this.manager.on(':ls-update', this._onLSUpdate, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
_unwatchLS() {
|
_unwatchLS() {
|
||||||
if ( ! this.__ls_watched )
|
if ( ! this.__ls_listening )
|
||||||
return;
|
return;
|
||||||
|
|
||||||
this.__ls_watched = false;
|
this.__ls_listening = false;
|
||||||
this.manager.off(':ls-update', this._onLSUpdate, this);
|
this.manager.off(':ls-update', this._onLSUpdate, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
_onLSUpdate(key) {
|
_onLSUpdate(key: string) {
|
||||||
const keys = this.__ls_wanted.get(`ls.${key}`);
|
const keys = this.__ls_wanted.get(`ls.${key}`);
|
||||||
if ( keys )
|
if ( keys )
|
||||||
for(const key of keys)
|
for(const key of keys)
|
||||||
this._update(key, key, []);
|
this._update(key as SettingsKeys, key as SettingsKeys, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -147,8 +185,8 @@ export default class SettingsContext extends EventEmitter {
|
||||||
|
|
||||||
|
|
||||||
selectProfiles() {
|
selectProfiles() {
|
||||||
const new_profiles = [],
|
const new_profiles: SettingsProfile[] = [],
|
||||||
order = this.order = [];
|
order: number[] = this.order = [];
|
||||||
|
|
||||||
if ( ! this.manager.disable_profiles ) {
|
if ( ! this.manager.disable_profiles ) {
|
||||||
for(const profile of this.manager.__profiles)
|
for(const profile of this.manager.__profiles)
|
||||||
|
@ -171,13 +209,13 @@ export default class SettingsContext extends EventEmitter {
|
||||||
|
|
||||||
for(const profile of new_profiles)
|
for(const profile of new_profiles)
|
||||||
if ( ! this.__profiles.includes(profile) ) {
|
if ( ! this.__profiles.includes(profile) ) {
|
||||||
profile.on('changed', this._onChanged, this);
|
profile.on('changed', this._onChanged as any, this);
|
||||||
changed_ids.add(profile.id);
|
changed_ids.add(profile.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.__profiles = new_profiles;
|
this.__profiles = new_profiles;
|
||||||
this.emit('profiles_changed');
|
this.emit('profiles_changed');
|
||||||
this.rebuildCache(changed_ids);
|
this.rebuildCache(/*changed_ids*/);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -203,7 +241,7 @@ export default class SettingsContext extends EventEmitter {
|
||||||
const definition = this.manager.definitions.get(key);
|
const definition = this.manager.definitions.get(key);
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
|
||||||
if ( definition && definition.equals ) {
|
if ( ! Array.isArray(definition) && definition?.equals ) {
|
||||||
if ( definition.equals === 'requirements' )
|
if ( definition.equals === 'requirements' )
|
||||||
changed = compare_requirements(definition, this.__cache, old_cache);
|
changed = compare_requirements(definition, this.__cache, old_cache);
|
||||||
else if ( typeof definition.equals === 'function' )
|
else if ( typeof definition.equals === 'function' )
|
||||||
|
@ -224,7 +262,7 @@ export default class SettingsContext extends EventEmitter {
|
||||||
|
|
||||||
if ( changed ) {
|
if ( changed ) {
|
||||||
this.emit('changed', key, new_value, old_value);
|
this.emit('changed', key, new_value, old_value);
|
||||||
this.emit(`changed:${key}`, new_value, old_value);
|
this.emit(`changed:${key}`, new_value, old_value as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( ! array_equals(new_uses, old_uses) ) {
|
if ( ! array_equals(new_uses, old_uses) ) {
|
||||||
|
@ -239,12 +277,12 @@ export default class SettingsContext extends EventEmitter {
|
||||||
// Context Control
|
// Context Control
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
context(context) {
|
context(context: ContextData) {
|
||||||
return new SettingsContext(this, context);
|
return new SettingsContext(this, context);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
updateContext(context) {
|
updateContext(context: ContextData) {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
|
||||||
for(const key in context)
|
for(const key in context)
|
||||||
|
@ -258,7 +296,7 @@ export default class SettingsContext extends EventEmitter {
|
||||||
// This can catch a recursive structure error.
|
// This can catch a recursive structure error.
|
||||||
}
|
}
|
||||||
|
|
||||||
this._context[key] = val;
|
this._context[key] = val as any;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -325,8 +363,8 @@ export default class SettingsContext extends EventEmitter {
|
||||||
}*/
|
}*/
|
||||||
|
|
||||||
|
|
||||||
setContext(context) {
|
setContext(context: ContextData) {
|
||||||
this._context_objects = new Set;
|
//this._context_objects = new Set;
|
||||||
this._context = {};
|
this._context = {};
|
||||||
this.updateContext(context);
|
this.updateContext(context);
|
||||||
}
|
}
|
||||||
|
@ -336,11 +374,14 @@ export default class SettingsContext extends EventEmitter {
|
||||||
// Data Access
|
// Data Access
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
_onChanged(key) {
|
_onChanged(key: SettingsKeys) {
|
||||||
this._update(key, key, []);
|
this._update(key, key, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
_update(key, initial, visited) {
|
_update<
|
||||||
|
K extends SettingsKeys,
|
||||||
|
TValue = SettingType<K>
|
||||||
|
>(key: K, initial: SettingsKeys, visited: SettingsKeys[]) {
|
||||||
if ( ! this.__cache.has(key) )
|
if ( ! this.__cache.has(key) )
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
@ -349,7 +390,7 @@ export default class SettingsContext extends EventEmitter {
|
||||||
|
|
||||||
visited.push(key);
|
visited.push(key);
|
||||||
|
|
||||||
const old_value = this.__cache.get(key),
|
const old_value = this.__cache.get(key) as TValue | undefined,
|
||||||
old_meta = this.__meta.get(key),
|
old_meta = this.__meta.get(key),
|
||||||
new_value = this._get(key, key, []),
|
new_value = this._get(key, key, []),
|
||||||
new_meta = this.__meta.get(key),
|
new_meta = this.__meta.get(key),
|
||||||
|
@ -359,38 +400,41 @@ export default class SettingsContext extends EventEmitter {
|
||||||
|
|
||||||
if ( ! array_equals(new_uses, old_uses) ) {
|
if ( ! array_equals(new_uses, old_uses) ) {
|
||||||
this.emit('uses_changed', key, new_uses, old_uses);
|
this.emit('uses_changed', key, new_uses, old_uses);
|
||||||
this.emit(`uses_changed:${key}`, new_uses, old_uses);
|
this.emit(`uses_changed:${key}` as any, new_uses, old_uses);
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( old_value === new_value )
|
if ( old_value === new_value )
|
||||||
return;
|
return;
|
||||||
|
|
||||||
this.emit('changed', key, new_value, old_value);
|
this.emit('changed', key, new_value, old_value);
|
||||||
this.emit(`changed:${key}`, new_value, old_value);
|
this.emit(`changed:${key}` as any, new_value, old_value);
|
||||||
|
|
||||||
const definition = this.manager.definitions.get(key);
|
const definition = this.manager.definitions.get(key);
|
||||||
if ( definition && definition.required_by )
|
if ( ! Array.isArray(definition) && definition?.required_by )
|
||||||
for(const req_key of definition.required_by)
|
for(const req_key of definition.required_by)
|
||||||
if ( ! req_key.startsWith('context.') && ! req_key.startsWith('ls.') )
|
if ( ! req_key.startsWith('context.') && ! req_key.startsWith('ls.') )
|
||||||
this._update(req_key, initial, Array.from(visited));
|
this._update(req_key as SettingsKeys, initial, Array.from(visited));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
_get(key, initial, visited) {
|
_get<
|
||||||
|
K extends SettingsKeys,
|
||||||
|
TValue = SettingType<K>
|
||||||
|
>(key: K, initial: SettingsKeys, visited: SettingsKeys[]): TValue {
|
||||||
if ( visited.includes(key) )
|
if ( visited.includes(key) )
|
||||||
throw new Error(`cyclic dependency when resolving setting "${initial}"`);
|
throw new Error(`cyclic dependency when resolving setting "${initial}"`);
|
||||||
|
|
||||||
visited.push(key);
|
visited.push(key);
|
||||||
|
|
||||||
const definition = this.manager.definitions.get(key),
|
const definition = this.manager.definitions.get(key);
|
||||||
raw_type = definition && definition.type,
|
const raw_type = ! Array.isArray(definition) && definition?.type,
|
||||||
type = raw_type ? DEFINITIONS[raw_type] : DEFINITIONS.basic;
|
type = raw_type ? DEFINITIONS[raw_type] : DEFINITIONS.basic;
|
||||||
|
|
||||||
if ( ! type )
|
if ( ! type )
|
||||||
throw new Error(`non-existent setting type "${raw_type}"`);
|
throw new Error(`non-existent setting type "${raw_type}"`);
|
||||||
|
|
||||||
const raw_value = this._getRaw(key, type),
|
const raw_value = this._getRaw(key, type),
|
||||||
meta = {
|
meta: SettingMetadata = {
|
||||||
uses: raw_value ? raw_value[1] : null
|
uses: raw_value ? raw_value[1] : null
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -421,8 +465,8 @@ export default class SettingsContext extends EventEmitter {
|
||||||
|
|
||||||
keys.add(key);
|
keys.add(key);
|
||||||
|
|
||||||
} else if ( ! req_key.startsWith('context.') && ! this.__cache.has(req_key) )
|
} else if ( ! req_key.startsWith('context.') && ! this.__cache.has(req_key as SettingsKeys) )
|
||||||
this._get(req_key, initial, Array.from(visited));
|
this._get(req_key as SettingsKeys, initial, Array.from(visited));
|
||||||
|
|
||||||
if ( definition.process )
|
if ( definition.process )
|
||||||
value = definition.process(this, value, meta);
|
value = definition.process(this, value, meta);
|
||||||
|
@ -440,70 +484,84 @@ export default class SettingsContext extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
hasProfile(profile) {
|
hasProfile(profile: number | SettingsProfile) {
|
||||||
if ( typeof profile === 'number' )
|
if ( typeof profile === 'number' ) {
|
||||||
for(const prof of this.__profiles)
|
for(const prof of this.__profiles)
|
||||||
if ( prof.id === profile )
|
if ( prof.id === profile )
|
||||||
return true;
|
return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
return this.__profiles.includes(profile);
|
return this.__profiles.includes(profile);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
_getRaw(key, type) {
|
_getRaw(key: SettingsKeys, type) {
|
||||||
if ( ! type )
|
if ( ! type )
|
||||||
throw new Error(`non-existent type for ${key}`)
|
throw new Error(`non-existent type for ${key}`)
|
||||||
|
|
||||||
return type.get(key, this.profiles(), this.manager.definitions.get(key), this.manager.log, this);
|
return type.get(
|
||||||
|
key,
|
||||||
|
this.profiles(),
|
||||||
|
this.manager.definitions.get(key),
|
||||||
|
this.manager.log,
|
||||||
|
this
|
||||||
|
);
|
||||||
}
|
}
|
||||||
/* for(const profile of this.__profiles)
|
|
||||||
if ( profile.has(key) )
|
|
||||||
return [profile.get(key), profile]
|
|
||||||
}*/
|
|
||||||
|
|
||||||
|
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
// Data Access
|
// Data Access
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
update(key) {
|
update(key: SettingsKeys) {
|
||||||
this._update(key, key, []);
|
this._update(key, key, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
get(key) {
|
get<
|
||||||
|
K extends AllSettingsKeys,
|
||||||
|
TValue = SettingType<K>
|
||||||
|
>(key: K): TValue {
|
||||||
if ( key.startsWith('ls.') )
|
if ( key.startsWith('ls.') )
|
||||||
return this.manager.getLS(key.slice(3));
|
return this.manager.getLS(key.slice(3)) as TValue;
|
||||||
|
|
||||||
if ( key.startsWith('context.') )
|
if ( key.startsWith('context.') )
|
||||||
//return this.__context[key.slice(8)];
|
//return this.__context[key.slice(8)];
|
||||||
return getter(key.slice(8), this.__context);
|
return getter(key.slice(8), this.__context);
|
||||||
|
|
||||||
if ( this.__cache.has(key) )
|
if ( this.__cache.has(key as SettingsKeys) )
|
||||||
return this.__cache.get(key);
|
return this.__cache.get(key as SettingsKeys) as TValue;
|
||||||
|
|
||||||
return this._get(key, key, []);
|
return this._get(key as SettingsKeys, key as SettingsKeys, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
getChanges(key, fn, ctx) {
|
getChanges<
|
||||||
|
K extends SettingsKeys,
|
||||||
|
TValue = SettingsTypeMap[K]
|
||||||
|
>(key: K, fn: (value: TValue, old_value: TValue | undefined) => void, ctx?: any) {
|
||||||
this.onChange(key, fn, ctx);
|
this.onChange(key, fn, ctx);
|
||||||
fn.call(ctx, this.get(key));
|
fn.call(ctx, this.get(key), undefined as TValue);
|
||||||
}
|
}
|
||||||
|
|
||||||
onChange(key, fn, ctx) {
|
onChange<
|
||||||
this.on(`changed:${key}`, fn, ctx);
|
K extends SettingsKeys,
|
||||||
|
TValue = SettingsTypeMap[K]
|
||||||
|
>(key: K, fn: (value: TValue, old_value: TValue) => void, ctx?: any) {
|
||||||
|
this.on(`changed:${key}`, fn as any, ctx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
uses(key) {
|
uses(key: AllSettingsKeys) {
|
||||||
if ( key.startsWith('ls.') )
|
if ( key.startsWith('ls.') )
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
if ( key.startsWith('context.') )
|
if ( key.startsWith('context.') )
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
if ( ! this.__meta.has(key) )
|
if ( ! this.__meta.has(key as SettingsKeys) )
|
||||||
this._get(key, key, []);
|
this._get(key as SettingsKeys, key as SettingsKeys, []);
|
||||||
|
|
||||||
return this.__meta.get(key).uses;
|
return this.__meta.get(key as SettingsKeys)?.uses ?? null;
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -4,24 +4,29 @@
|
||||||
// Profile Filters for Settings
|
// Profile Filters for Settings
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import {glob_to_regex, escape_regex, matchScreen} from 'utilities/object';
|
import {glob_to_regex, escape_regex, matchScreen, ScreenOptions} from 'utilities/object';
|
||||||
import {createTester} from 'utilities/filtering';
|
import {FilterData, FilterType, createTester} from 'utilities/filtering';
|
||||||
import { DEBUG } from 'utilities/constants';
|
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 => {
|
import(/* webpackChunkName: 'regex' */ 'safe-regex').then(thing => {
|
||||||
safety = thing.default;
|
safety = thing.default;
|
||||||
if ( cb )
|
if ( callback )
|
||||||
cb();
|
callback();
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const NeverMatch = () => false;
|
||||||
|
|
||||||
|
|
||||||
// Logical Components
|
// Logical Components
|
||||||
|
|
||||||
export const Invert = {
|
export const Invert: FilterType<FilterData[], ContextData> = {
|
||||||
createTest(config, rule_types, rebuild) {
|
createTest(config, rule_types, rebuild) {
|
||||||
return createTester(config, rule_types, true, false, rebuild)
|
return createTester(config, rule_types, true, false, rebuild)
|
||||||
},
|
},
|
||||||
|
@ -37,7 +42,7 @@ export const Invert = {
|
||||||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/nested.vue')
|
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/nested.vue')
|
||||||
};
|
};
|
||||||
|
|
||||||
export const And = {
|
export const And: FilterType<FilterData[], ContextData> = {
|
||||||
createTest(config, rule_types, rebuild) {
|
createTest(config, rule_types, rebuild) {
|
||||||
return createTester(config, rule_types, false, false, rebuild);
|
return createTester(config, rule_types, false, false, rebuild);
|
||||||
},
|
},
|
||||||
|
@ -52,7 +57,7 @@ export const And = {
|
||||||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/nested.vue')
|
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/nested.vue')
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Or = {
|
export const Or: FilterType<FilterData[], ContextData> = {
|
||||||
createTest(config, rule_types, rebuild) {
|
createTest(config, rule_types, rebuild) {
|
||||||
return createTester(config, rule_types, false, true, rebuild);
|
return createTester(config, rule_types, false, true, rebuild);
|
||||||
},
|
},
|
||||||
|
@ -67,11 +72,17 @@ export const Or = {
|
||||||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/nested.vue')
|
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<IfData, ContextData> = {
|
||||||
createTest(config, rule_types, rebuild) {
|
createTest(config, rule_types, rebuild) {
|
||||||
const cond = createTester(config[0], rule_types, false, false, rebuild),
|
const cond = createTester(config[0], rule_types as any, false, false, rebuild),
|
||||||
if_true = createTester(config[1], rule_types, false, false, rebuild),
|
if_true = createTester(config[1], rule_types as any, false, false, rebuild),
|
||||||
if_false = createTester(config[2], rule_types, 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)
|
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')
|
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/if.vue')
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Constant = {
|
export const Constant: FilterType<boolean, ContextData> = {
|
||||||
createTest(config) {
|
createTest(config) {
|
||||||
if ( config )
|
if ( config )
|
||||||
return () => true;
|
return () => true;
|
||||||
return () => false;
|
return NeverMatch;
|
||||||
},
|
},
|
||||||
|
|
||||||
title: 'True or False',
|
title: 'True or False',
|
||||||
|
@ -103,7 +114,7 @@ export const Constant = {
|
||||||
|
|
||||||
// Context Stuff
|
// Context Stuff
|
||||||
|
|
||||||
function parseTime(time) {
|
function parseTime(time: string) {
|
||||||
if ( typeof time !== 'string' || ! time.length )
|
if ( typeof time !== 'string' || ! time.length )
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
|
@ -123,7 +134,12 @@ function parseTime(time) {
|
||||||
return hours * 60 + minutes;
|
return hours * 60 + minutes;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Time = {
|
type TimeFilter = FilterType<[start: string, end: string], ContextData> & {
|
||||||
|
_captured: Set<number>;
|
||||||
|
captured: () => number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Time: TimeFilter = {
|
||||||
_captured: new Set,
|
_captured: new Set,
|
||||||
|
|
||||||
createTest(config) {
|
createTest(config) {
|
||||||
|
@ -131,7 +147,7 @@ export const Time = {
|
||||||
end = parseTime(config[1]);
|
end = parseTime(config[1]);
|
||||||
|
|
||||||
if ( start == null || end == null )
|
if ( start == null || end == null )
|
||||||
return () => false;
|
return NeverMatch;
|
||||||
|
|
||||||
if ( start <= end )
|
if ( start <= end )
|
||||||
return () => {
|
return () => {
|
||||||
|
@ -170,12 +186,12 @@ export const Time = {
|
||||||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/time.vue')
|
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/time.vue')
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TheaterMode = {
|
export const TheaterMode: FilterType<boolean, ContextData> = {
|
||||||
createTest(config) {
|
createTest(config) {
|
||||||
return ctx => {
|
return ctx => {
|
||||||
if ( ctx.fullscreen )
|
if ( ctx.fullscreen )
|
||||||
return config === false;
|
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')
|
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Fullscreen = {
|
export const Fullscreen: FilterType<boolean, ContextData> = {
|
||||||
createTest(config) {
|
createTest(config) {
|
||||||
return ctx => ctx.fullscreen === config;
|
return ctx => ctx.fullscreen === config;
|
||||||
},
|
},
|
||||||
|
@ -200,7 +216,7 @@ export const Fullscreen = {
|
||||||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
|
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Moderator = {
|
export const Moderator: FilterType<boolean, ContextData> = {
|
||||||
createTest(config) {
|
createTest(config) {
|
||||||
return ctx => ctx.moderator === config;
|
return ctx => ctx.moderator === config;
|
||||||
},
|
},
|
||||||
|
@ -212,7 +228,7 @@ export const Moderator = {
|
||||||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
|
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Debug = {
|
export const Debug: FilterType<boolean, ContextData> = {
|
||||||
createTest(config) {
|
createTest(config) {
|
||||||
return () => DEBUG === config;
|
return () => DEBUG === config;
|
||||||
},
|
},
|
||||||
|
@ -224,7 +240,7 @@ export const Debug = {
|
||||||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
|
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AddonDebug = {
|
export const AddonDebug: FilterType<boolean, ContextData> = {
|
||||||
createTest(config) {
|
createTest(config) {
|
||||||
return ctx => ctx.addonDev == config
|
return ctx => ctx.addonDev == config
|
||||||
},
|
},
|
||||||
|
@ -236,9 +252,9 @@ export const AddonDebug = {
|
||||||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
|
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SquadMode = {
|
export const SquadMode: FilterType<boolean, ContextData> = {
|
||||||
createTest(config) {
|
createTest(config) {
|
||||||
return ctx => ctx.ui && ctx.ui.squadModeEnabled === config;
|
return ctx => ctx.ui?.squadModeEnabled === config;
|
||||||
},
|
},
|
||||||
|
|
||||||
title: 'Squad Mode',
|
title: 'Squad Mode',
|
||||||
|
@ -248,10 +264,10 @@ export const SquadMode = {
|
||||||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
|
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
|
||||||
};
|
};
|
||||||
|
|
||||||
export const NativeDarkTheme = {
|
export const NativeDarkTheme: FilterType<boolean, ContextData> = {
|
||||||
createTest(config) {
|
createTest(config) {
|
||||||
const val = config ? 1 : 0;
|
const val = config ? 1 : 0;
|
||||||
return ctx => ctx.ui && ctx.ui.theme === val;
|
return ctx => ctx.ui?.theme === val;
|
||||||
},
|
},
|
||||||
|
|
||||||
title: 'Dark Theme',
|
title: 'Dark Theme',
|
||||||
|
@ -261,33 +277,41 @@ export const NativeDarkTheme = {
|
||||||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
|
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Page = {
|
// TODO: Add typing.
|
||||||
createTest(config = {}) {
|
type PageData = {
|
||||||
|
route: string;
|
||||||
|
values: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Page: FilterType<PageData, ContextData> = {
|
||||||
|
createTest(config) {
|
||||||
|
if ( ! config )
|
||||||
|
return NeverMatch;
|
||||||
|
|
||||||
const name = config.route,
|
const name = config.route,
|
||||||
parts = [];
|
parts: [index: number, value: string][] = [];
|
||||||
|
|
||||||
if ( Object.keys(config.values).length ) {
|
if ( Object.keys(config.values).length ) {
|
||||||
const ffz = window.FrankerFaceZ?.get(),
|
const ffz = window.FrankerFaceZ?.get(),
|
||||||
router = ffz && ffz.resolve('site.router');
|
router = ffz && ffz.resolve('site.router') as any;
|
||||||
|
|
||||||
if ( router ) {
|
if ( ! router )
|
||||||
const route = router.getRoute(name);
|
return NeverMatch;
|
||||||
if ( ! route || ! route.parts )
|
|
||||||
return () => false;
|
|
||||||
|
|
||||||
let i = 1;
|
const route = router.getRoute(name);
|
||||||
for(const part of route.parts) {
|
if ( ! route || ! route.parts )
|
||||||
if ( typeof part === 'object' ) {
|
return NeverMatch;
|
||||||
const val = config.values[part.name];
|
|
||||||
if ( val && val.length )
|
|
||||||
parts.push([i, val.toLowerCase()]);
|
|
||||||
|
|
||||||
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 => {
|
return ctx => {
|
||||||
|
@ -318,12 +342,28 @@ export const Page = {
|
||||||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/page.vue')
|
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/page.vue')
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Channel = {
|
type ChannelData = {
|
||||||
createTest(config = {}) {
|
login: string | null;
|
||||||
const login = config.login,
|
id: string | null;
|
||||||
id = config.id;
|
};
|
||||||
|
|
||||||
return ctx => ctx.channelID === id || (ctx.channelID == null && ctx.channelLogin === login);
|
export const Channel: FilterType<ChannelData, ContextData> = {
|
||||||
|
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',
|
title: 'Current Channel',
|
||||||
|
@ -336,15 +376,28 @@ export const Channel = {
|
||||||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/channel.vue')
|
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/channel.vue')
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Category = {
|
type CategoryData = {
|
||||||
createTest(config = {}) {
|
name: string | null;
|
||||||
const name = config.name,
|
id: string | null;
|
||||||
id = config.id;
|
}
|
||||||
|
|
||||||
if ( ! id || ! name )
|
export const Category: FilterType<CategoryData, ContextData> = {
|
||||||
return () => false;
|
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',
|
title: 'Current Category',
|
||||||
|
@ -358,14 +411,20 @@ export const Category = {
|
||||||
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/category.vue')
|
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/category.vue')
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Title = {
|
type TitleData = {
|
||||||
createTest(config = {}, _, reload) {
|
title: string;
|
||||||
const mode = config.mode;
|
mode: 'text' | 'glob' | 'raw' | 'regex';
|
||||||
let title = config.title,
|
sensitive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Title: FilterType<TitleData, ContextData> = {
|
||||||
|
createTest(config, _, reload) {
|
||||||
|
const mode = config?.mode;
|
||||||
|
let title = config?.title,
|
||||||
need_safety = true;
|
need_safety = true;
|
||||||
|
|
||||||
if ( ! title || ! mode )
|
if ( ! title || ! mode )
|
||||||
return () => false;
|
return NeverMatch;
|
||||||
|
|
||||||
if ( mode === 'text' ) {
|
if ( mode === 'text' ) {
|
||||||
title = escape_regex(title);
|
title = escape_regex(title);
|
||||||
|
@ -373,26 +432,26 @@ export const Title = {
|
||||||
} else if ( mode === 'glob' )
|
} else if ( mode === 'glob' )
|
||||||
title = glob_to_regex(title);
|
title = glob_to_regex(title);
|
||||||
else if ( mode !== 'raw' )
|
else if ( mode !== 'raw' )
|
||||||
return () => false;
|
return NeverMatch;
|
||||||
|
|
||||||
if ( need_safety ) {
|
if ( need_safety ) {
|
||||||
if ( ! safety )
|
if ( ! safety )
|
||||||
loadSafety(reload);
|
loadSafety(reload);
|
||||||
|
|
||||||
if ( ! safety || ! safety(title) )
|
if ( ! safety || ! safety(title) )
|
||||||
return () => false;
|
return NeverMatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
let regex;
|
let regex: RegExp;
|
||||||
try {
|
try {
|
||||||
regex = new RegExp(title, `g${config.sensitive ? '' : 'i'}`);
|
regex = new RegExp(title, `g${config.sensitive ? '' : 'i'}`);
|
||||||
} catch(err) {
|
} catch(err) {
|
||||||
return () => false;
|
return NeverMatch;
|
||||||
}
|
}
|
||||||
|
|
||||||
return ctx => {
|
return ctx => {
|
||||||
regex.lastIndex = 0;
|
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
|
// Monitor Stuff
|
||||||
|
|
||||||
export let Monitor = null;
|
type MonitorType = FilterType<ScreenOptions, ContextData> & {
|
||||||
|
_used: boolean;
|
||||||
|
details?: ScreenDetails | null | false;
|
||||||
|
|
||||||
|
used: () => boolean;
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
export let Monitor: MonitorType = null as any;
|
||||||
|
|
||||||
if ( window.getScreenDetails ) {
|
if ( window.getScreenDetails ) {
|
||||||
|
|
||||||
|
@ -424,31 +491,31 @@ if ( window.getScreenDetails ) {
|
||||||
return out;
|
return out;
|
||||||
},
|
},
|
||||||
|
|
||||||
createTest(config = {}, _, reload) {
|
createTest(config, _, reload) {
|
||||||
if ( ! config.label )
|
if ( ! config?.label )
|
||||||
return () => false;
|
return NeverMatch;
|
||||||
|
|
||||||
Monitor._used = true;
|
Monitor._used = true;
|
||||||
if ( Monitor.details === undefined ) {
|
if ( reload && Monitor.details === undefined ) {
|
||||||
const FFZ = window.FrankerFaceZ ?? window.FFZBridge;
|
const FFZ = window.FrankerFaceZ ?? ((window as any).FFZBridge as any),
|
||||||
if ( FFZ )
|
ffz = FFZ?.get(),
|
||||||
FFZ.get().resolve('settings').createMonitorUpdate().then(() => {
|
settings = ffz?.resolve('settings');
|
||||||
|
if ( settings )
|
||||||
|
settings.createMonitorUpdate().then(() => {
|
||||||
reload();
|
reload();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
Monitor._used = true;
|
Monitor._used = true;
|
||||||
const details = Monitor.details,
|
const details = Monitor.details;
|
||||||
screen = details?.currentScreen;
|
if ( ! details )
|
||||||
|
|
||||||
if ( ! screen )
|
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
const sorted = details.screens, // sortScreens(Array.from(details.screens)),
|
const sorted = details.screens,
|
||||||
matched = matchScreen(sorted, config);
|
matched = matchScreen(sorted, config);
|
||||||
|
|
||||||
return matched === screen;
|
return matched === details.currentScreen;
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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');
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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);
|
|
||||||
}
|
|
66
src/settings/processors.ts
Normal file
66
src/settings/processors.ts
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
import type { SettingDefinition, SettingProcessor, SettingUiDefinition } from "./types";
|
||||||
|
|
||||||
|
const BAD = Symbol('BAD');
|
||||||
|
type BadType = typeof BAD;
|
||||||
|
|
||||||
|
function do_number(
|
||||||
|
input: number | BadType,
|
||||||
|
default_value: number,
|
||||||
|
definition: SettingUiDefinition<number>
|
||||||
|
) {
|
||||||
|
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: SettingProcessor<number> = (
|
||||||
|
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: SettingProcessor<number> = (
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
|
@ -5,18 +5,151 @@
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import {EventEmitter} from 'utilities/events';
|
import {EventEmitter} from 'utilities/events';
|
||||||
import {isValidShortcut, has} from 'utilities/object';
|
import {isValidShortcut, fetchJSON} from 'utilities/object';
|
||||||
import {createTester} from 'utilities/filtering';
|
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
|
* Instances of SettingsProfile are used for getting and setting raw settings
|
||||||
* values, enumeration, and emit events when the raw settings are changed.
|
* values, enumeration, and emit events when the raw settings are changed.
|
||||||
* @extends EventEmitter
|
* @extends EventEmitter
|
||||||
*/
|
*/
|
||||||
export default class SettingsProfile extends EventEmitter {
|
export default class SettingsProfile extends EventEmitter<ProfileEvents> {
|
||||||
constructor(manager, data) {
|
|
||||||
|
static Default: Partial<SettingsProfileMetadata> = {
|
||||||
|
id: 0,
|
||||||
|
name: 'Default Profile',
|
||||||
|
i18n_key: 'setting.profiles.default',
|
||||||
|
|
||||||
|
description: 'Settings that apply everywhere on Twitch.'
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
static Moderation: Partial<SettingsProfileMetadata> = {
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<SettingsProfileMetadata>) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.onShortcut = this.onShortcut.bind(this);
|
this.onShortcut = this.onShortcut.bind(this);
|
||||||
|
@ -35,10 +168,10 @@ export default class SettingsProfile extends EventEmitter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get data() {
|
get data(): Partial<SettingsProfileMetadata> {
|
||||||
return {
|
return {
|
||||||
id: this.id,
|
id: this.id,
|
||||||
parent: this.parent,
|
//parent: this.parent,
|
||||||
|
|
||||||
name: this.name,
|
name: this.name,
|
||||||
i18n_key: this.i18n_key,
|
i18n_key: this.i18n_key,
|
||||||
|
@ -61,20 +194,32 @@ export default class SettingsProfile extends EventEmitter {
|
||||||
if ( typeof val !== 'object' )
|
if ( typeof val !== 'object' )
|
||||||
throw new TypeError('data must be an object');
|
throw new TypeError('data must be an object');
|
||||||
|
|
||||||
this.matcher = null;
|
this.clearMatcher();
|
||||||
|
|
||||||
// Make sure ephemeral is set first.
|
// Make sure ephemeral is set first.
|
||||||
if ( val.ephemeral )
|
if ( val.ephemeral )
|
||||||
this.ephemeral = true;
|
this.ephemeral = true;
|
||||||
|
|
||||||
for(const key in val)
|
// Copy the values to this profile.
|
||||||
if ( has(val, key) )
|
for(const [key, value] of Object.entries(val))
|
||||||
this[key] = val[key];
|
(this as any)[key] = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
matches(context) {
|
|
||||||
|
clearMatcher() {
|
||||||
|
this.matcher = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
matches(context: ContextData) {
|
||||||
if ( ! this.matcher )
|
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);
|
return this.matcher(context);
|
||||||
}
|
}
|
||||||
|
@ -86,8 +231,8 @@ export default class SettingsProfile extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
getBackup() {
|
getBackup(): ExportedSettingsProfile {
|
||||||
const out = {
|
const out: ExportedSettingsProfile = {
|
||||||
version: 2,
|
version: 2,
|
||||||
type: 'profile',
|
type: 'profile',
|
||||||
profile: this.data,
|
profile: this.data,
|
||||||
|
@ -97,8 +242,8 @@ export default class SettingsProfile extends EventEmitter {
|
||||||
|
|
||||||
delete out.profile.ephemeral;
|
delete out.profile.ephemeral;
|
||||||
|
|
||||||
for(const [k,v] of this.entries())
|
for(const [key, value] of this.entries())
|
||||||
out.values[k] = v;
|
out.values[key] = value;
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
@ -108,8 +253,8 @@ export default class SettingsProfile extends EventEmitter {
|
||||||
if ( ! this.url || this.pause_updates )
|
if ( ! this.url || this.pause_updates )
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
const data = await fetchJSON(this.url);
|
const data = await fetchJSON<ExportedSettingsProfile>(this.url);
|
||||||
if ( ! data || ! data.type === 'profile' || ! data.profile || ! data.values )
|
if ( ! data || data.type !== 'profile' || ! data.profile || ! data.values )
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
// We don't want to override general settings.
|
// 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;
|
this.toggled = ! this.toggled;
|
||||||
|
|
||||||
if ( e ) {
|
if ( event ) {
|
||||||
e.stopPropagation();
|
event.stopPropagation();
|
||||||
e.preventDefault();
|
event.preventDefault();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -223,22 +368,24 @@ export default class SettingsProfile extends EventEmitter {
|
||||||
// Context
|
// Context
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
|
// wtf is this method context is an array yo
|
||||||
|
/*
|
||||||
updateContext(context) {
|
updateContext(context) {
|
||||||
if ( this.id === 0 )
|
if ( this.id === 0 )
|
||||||
throw new Error('cannot set context of default profile');
|
throw new Error('cannot set context of default profile');
|
||||||
|
|
||||||
this.context = Object.assign(this.context || {}, context);
|
this.context = Object.assign(this.context || {}, context);
|
||||||
this.matcher = null;
|
this.matcher = null;
|
||||||
this.manager._saveProfiles();
|
this.save();
|
||||||
}
|
}*/
|
||||||
|
|
||||||
setContext(context) {
|
setContext(context?: FilterData[]) {
|
||||||
if ( this.id === 0 )
|
if ( this.id === 0 )
|
||||||
throw new Error('cannot set context of default profile');
|
throw new Error('cannot set context of default profile');
|
||||||
|
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.matcher = null;
|
this.clearMatcher();
|
||||||
this.manager._saveProfiles();
|
this.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -246,37 +393,48 @@ export default class SettingsProfile extends EventEmitter {
|
||||||
// Setting Access
|
// Setting Access
|
||||||
// ========================================================================
|
// ========================================================================
|
||||||
|
|
||||||
get(key, default_value) {
|
get<T>(key: string, default_value: T): T;
|
||||||
if ( this.ephemeral )
|
get<T>(key: string): T | null;
|
||||||
return this._storage.get(key, default_value);
|
|
||||||
return this.provider.get(this.prefix + key, default_value);
|
get<T>(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<T>(this.prefix + key, default_value as T);
|
||||||
}
|
}
|
||||||
|
|
||||||
set(key, value) {
|
set(key: string, value: unknown) {
|
||||||
if ( this.ephemeral )
|
if ( this.ephemeral ) {
|
||||||
this._storage.set(key, value);
|
if ( this._storage )
|
||||||
else
|
this._storage.set(key, value);
|
||||||
|
} else
|
||||||
this.provider.set(this.prefix + key, value);
|
this.provider.set(this.prefix + key, value);
|
||||||
this.emit('changed', key, value);
|
|
||||||
|
this.emit('changed', key, value, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
delete(key) {
|
delete(key: string) {
|
||||||
if ( this.ephemeral )
|
if ( this.ephemeral ) {
|
||||||
this._storage.delete(key);
|
if ( this._storage )
|
||||||
else
|
this._storage.delete(key);
|
||||||
|
} else
|
||||||
this.provider.delete(this.prefix + key);
|
this.provider.delete(this.prefix + key);
|
||||||
this.emit('changed', key, undefined, true);
|
this.emit('changed', key, undefined, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
has(key) {
|
has(key: string) {
|
||||||
if ( this.ephemeral )
|
if ( this.ephemeral )
|
||||||
return this._storage.has(key);
|
return this._storage ? this._storage.has(key): false;
|
||||||
return this.provider.has(this.prefix + key);
|
return this.provider.has(this.prefix + key);
|
||||||
}
|
}
|
||||||
|
|
||||||
keys() {
|
keys() {
|
||||||
if ( this.ephemeral )
|
if ( this.ephemeral )
|
||||||
return Array.from(this._storage.keys());
|
return this._storage ? Array.from(this._storage.keys()) : [];
|
||||||
|
|
||||||
const out = [],
|
const out = [],
|
||||||
p = this.prefix,
|
p = this.prefix,
|
||||||
|
@ -291,11 +449,14 @@ export default class SettingsProfile extends EventEmitter {
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
if ( this.ephemeral ) {
|
if ( this.ephemeral ) {
|
||||||
const keys = this.keys();
|
if ( this._storage ) {
|
||||||
this._storage.clear();
|
const keys = this.keys();
|
||||||
for(const key of keys) {
|
this._storage.clear();
|
||||||
this.emit('changed', key, undefined, true);
|
for(const key of keys) {
|
||||||
|
this.emit('changed', key, undefined, true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -310,22 +471,26 @@ export default class SettingsProfile extends EventEmitter {
|
||||||
|
|
||||||
*entries() {
|
*entries() {
|
||||||
if ( this.ephemeral ) {
|
if ( this.ephemeral ) {
|
||||||
for(const entry of this._storage.entries())
|
if ( this._storage ) {
|
||||||
yield entry;
|
for(const entry of this._storage.entries())
|
||||||
|
yield entry;
|
||||||
|
}
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
const p = this.prefix,
|
const p = this.prefix,
|
||||||
len = p.length;
|
len = p.length;
|
||||||
|
|
||||||
for(const key of this.provider.keys())
|
for(const key of this.provider.keys())
|
||||||
if ( key.startsWith(p) && key !== this.enabled_key )
|
if ( key.startsWith(p) && key !== this.enabled_key ) {
|
||||||
yield [key.slice(len), this.provider.get(key)];
|
const out: [string, unknown] = [key.slice(len), this.provider.get(key)];
|
||||||
|
yield out;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get size() {
|
get size() {
|
||||||
if ( this.ephemeral )
|
if ( this.ephemeral )
|
||||||
return this._storage.size;
|
return this._storage ? this._storage.size : 0;
|
||||||
|
|
||||||
const p = this.prefix;
|
const p = this.prefix;
|
||||||
let count = 0;
|
let count = 0;
|
||||||
|
@ -337,28 +502,3 @@ export default class SettingsProfile extends EventEmitter {
|
||||||
return count;
|
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
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
File diff suppressed because it is too large
Load diff
|
@ -1,32 +1,38 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
import type Logger from "utilities/logging";
|
||||||
|
import type SettingsProfile from "./profile";
|
||||||
|
import type { SettingDefinition, SettingsTypeHandler } from "./types";
|
||||||
|
import type SettingsContext from "./context";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Settings Types
|
// Settings Types
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const DEFAULT = Symbol('default');
|
const DEFAULT = Symbol('default');
|
||||||
|
|
||||||
export const basic = {
|
|
||||||
get(key, profiles) {
|
export const basic: SettingsTypeHandler = {
|
||||||
|
get<T>(key: string, profiles: SettingsProfile[]) {
|
||||||
for(const profile of profiles)
|
for(const profile of profiles)
|
||||||
if ( profile.has(key) )
|
if ( profile.has(key) )
|
||||||
return [
|
return [
|
||||||
profile.get(key),
|
profile.get(key) as T,
|
||||||
[profile.id]
|
[profile.id]
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const object_merge = {
|
export const object_merge: SettingsTypeHandler = {
|
||||||
get(key, profiles, log) {
|
get<T>(key: string, profiles: SettingsProfile[], definition: SettingDefinition<any>, log: Logger) {
|
||||||
const values = [],
|
const values: T[] = [],
|
||||||
sources = [];
|
sources: number[] = [];
|
||||||
|
|
||||||
for(const profile of profiles)
|
for(const profile of profiles)
|
||||||
if ( profile.has(key) ) {
|
if ( profile.has(key) ) {
|
||||||
const val = profile.get(key);
|
const val = profile.get<T>(key);
|
||||||
if ( typeof val !== 'object' ) {
|
if ( ! val || typeof val !== 'object' ) {
|
||||||
log.warn(`Profile #${profile.id} has an invalid value for "${key}" of type ${typeof val}. Skipping.`);
|
log.warn(`Profile #${profile.id} has an invalid value for "${key}" of type ${typeof val}. Skipping.`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
@ -44,14 +50,16 @@ export const object_merge = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const basic_array_merge = {
|
type UnwrapArray<T> = T extends Array<infer U> ? U : T;
|
||||||
get(key, profiles, log) {
|
|
||||||
const values = [],
|
export const basic_array_merge: SettingsTypeHandler = {
|
||||||
sources = [];
|
get<T>(key: string, profiles: SettingsProfile[], definition: SettingDefinition<any>, log: Logger) {
|
||||||
|
const values: UnwrapArray<T>[] = [],
|
||||||
|
sources: number[] = [];
|
||||||
|
|
||||||
for(const profile of profiles)
|
for(const profile of profiles)
|
||||||
if ( profile.has(key) ) {
|
if ( profile.has(key) ) {
|
||||||
const val = profile.get(key);
|
const val = profile.get<UnwrapArray<T>>(key);
|
||||||
if ( ! Array.isArray(val) ) {
|
if ( ! Array.isArray(val) ) {
|
||||||
log.warn(`Profile #${profile.id} has an invalid value for "${key}"`);
|
log.warn(`Profile #${profile.id} has an invalid value for "${key}"`);
|
||||||
continue;
|
continue;
|
||||||
|
@ -71,7 +79,7 @@ export const basic_array_merge = {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export const array_merge = {
|
export const array_merge: SettingsTypeHandler = {
|
||||||
default(val) {
|
default(val) {
|
||||||
const values = [];
|
const values = [];
|
||||||
for(const v of val)
|
for(const v of val)
|
||||||
|
@ -81,13 +89,20 @@ export const array_merge = {
|
||||||
return values;
|
return values;
|
||||||
},
|
},
|
||||||
|
|
||||||
get(key, profiles, definition, log, ctx) {
|
get<T>(
|
||||||
const values = [],
|
key: string,
|
||||||
sources = [];
|
profiles: SettingsProfile[],
|
||||||
let trailing = [];
|
definition: SettingDefinition<any>,
|
||||||
|
log: Logger,
|
||||||
|
ctx: SettingsContext
|
||||||
|
) {
|
||||||
|
|
||||||
|
const values: UnwrapArray<T>[] = [],
|
||||||
|
sources: number[] = [];
|
||||||
|
let trailing: UnwrapArray<T>[] = [];
|
||||||
let had_value = false;
|
let had_value = false;
|
||||||
|
|
||||||
let profs = profiles;
|
let profs: (SettingsProfile | typeof DEFAULT)[] = profiles;
|
||||||
if ( definition.inherit_default )
|
if ( definition.inherit_default )
|
||||||
profs = [...profiles, DEFAULT];
|
profs = [...profiles, DEFAULT];
|
||||||
|
|
||||||
|
@ -109,7 +124,7 @@ export const array_merge = {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const trail = [];
|
const trail: UnwrapArray<T>[] = [];
|
||||||
|
|
||||||
if ( profile !== DEFAULT )
|
if ( profile !== DEFAULT )
|
||||||
sources.push(profile.id);
|
sources.push(profile.id);
|
||||||
|
@ -140,4 +155,13 @@ export const array_merge = {
|
||||||
sources
|
sources
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export default {
|
||||||
|
basic,
|
||||||
|
object_merge,
|
||||||
|
basic_array_merge,
|
||||||
|
array_merge
|
||||||
|
} as Record<string, SettingsTypeHandler>;
|
333
src/settings/types.ts
Normal file
333
src/settings/types.ts
Normal file
|
@ -0,0 +1,333 @@
|
||||||
|
import type SettingsManager from ".";
|
||||||
|
import type { FilterData } from "../utilities/filtering";
|
||||||
|
import type Logger from "../utilities/logging";
|
||||||
|
import type { PathNode } from "../utilities/path-parser";
|
||||||
|
import type { ExtractKey, ExtractSegments, ExtractType, JoinKeyPaths, ObjectKeyPaths, OptionalPromise, OptionallyCallable, PartialPartial, RecursivePartial, SettingsTypeMap } from "../utilities/types";
|
||||||
|
import type SettingsContext from "./context";
|
||||||
|
import type SettingsProfile from "./profile";
|
||||||
|
import type { SettingsProvider } from "./providers";
|
||||||
|
|
||||||
|
|
||||||
|
// Clearables
|
||||||
|
|
||||||
|
type SettingsClearableKeys = {
|
||||||
|
keys: OptionallyCallable<[provider: SettingsProvider, manager: SettingsManager], OptionalPromise<string[]>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SettingsClearableClear = {
|
||||||
|
clear(provider: SettingsProvider, manager: SettingsManager): OptionalPromise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SettingsClearable = {
|
||||||
|
label: string;
|
||||||
|
__source?: string | null;
|
||||||
|
|
||||||
|
} & (SettingsClearableKeys | SettingsClearableClear);
|
||||||
|
|
||||||
|
|
||||||
|
// Context
|
||||||
|
|
||||||
|
export interface ConcreteContextData {
|
||||||
|
addonDev: boolean;
|
||||||
|
|
||||||
|
category: string;
|
||||||
|
categoryID: string;
|
||||||
|
|
||||||
|
chat: {
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
title: string;
|
||||||
|
channel: string;
|
||||||
|
channelColor: string;
|
||||||
|
channelID: string;
|
||||||
|
|
||||||
|
chatHidden: boolean;
|
||||||
|
fullscreen: boolean;
|
||||||
|
isWatchParty: boolean;
|
||||||
|
moderator: boolean;
|
||||||
|
|
||||||
|
route: {
|
||||||
|
domain: string | null;
|
||||||
|
name: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
route_data: string[];
|
||||||
|
|
||||||
|
size: {
|
||||||
|
width: number;
|
||||||
|
height: number
|
||||||
|
};
|
||||||
|
|
||||||
|
ui: {
|
||||||
|
theatreModeEnabled: boolean;
|
||||||
|
squadModeEnabled: boolean;
|
||||||
|
theme: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ContextData = RecursivePartial<ConcreteContextData>;
|
||||||
|
|
||||||
|
export interface ConcreteLocalStorageData {
|
||||||
|
test: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LocalStorageData = Partial<ConcreteLocalStorageData>;
|
||||||
|
|
||||||
|
export type SettingsContextKeys = JoinKeyPaths<'context', ObjectKeyPaths<ConcreteContextData>>;
|
||||||
|
export type SettingsLocalStorageKeys = JoinKeyPaths<'ls', ObjectKeyPaths<ConcreteLocalStorageData>> | JoinKeyPaths<'ls.raw', ObjectKeyPaths<ConcreteLocalStorageData>>;
|
||||||
|
export type SettingsKeys = keyof SettingsTypeMap;
|
||||||
|
export type AllSettingsKeys = SettingsKeys | SettingsContextKeys | SettingsLocalStorageKeys;
|
||||||
|
|
||||||
|
export type SettingType<K extends AllSettingsKeys> =
|
||||||
|
K extends `context.${infer Rest}`
|
||||||
|
? ExtractType<ConcreteContextData, ExtractSegments<Rest>> | undefined
|
||||||
|
:
|
||||||
|
K extends `ls.raw.${infer _}`
|
||||||
|
? string | undefined
|
||||||
|
:
|
||||||
|
K extends `ls.${infer Rest}`
|
||||||
|
? Rest extends keyof LocalStorageData
|
||||||
|
? LocalStorageData[Rest]
|
||||||
|
: unknown
|
||||||
|
:
|
||||||
|
K extends keyof SettingsTypeMap
|
||||||
|
? SettingsTypeMap[K]
|
||||||
|
:
|
||||||
|
unknown;
|
||||||
|
|
||||||
|
export type SettingMetadata = {
|
||||||
|
uses: number[];
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Usable Definitions
|
||||||
|
|
||||||
|
export type OptionalSettingDefinitionKeys = 'type';
|
||||||
|
export type ForbiddenSettingDefinitionKeys = '__source' | 'ui';
|
||||||
|
|
||||||
|
export type SettingDefinition<T> = Omit<
|
||||||
|
PartialPartial<FullSettingDefinition<T>, OptionalSettingDefinitionKeys>,
|
||||||
|
ForbiddenSettingDefinitionKeys
|
||||||
|
> & {
|
||||||
|
ui: SettingUiDefinition<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type OptionalSettingUiDefinitionKeys = 'key' | 'path_tokens' | 'i18n_key';
|
||||||
|
export type ForbiddenSettingUiDefinitionKeys = never;
|
||||||
|
|
||||||
|
export type SettingUiDefinition<T> = PartialPartial<FullSettingUiDefinition<T>, OptionalSettingUiDefinitionKeys>;
|
||||||
|
|
||||||
|
|
||||||
|
// Definitions
|
||||||
|
|
||||||
|
export type FullSettingDefinition<T> = {
|
||||||
|
|
||||||
|
default: ((ctx: SettingsContext) => T) | T,
|
||||||
|
type?: string;
|
||||||
|
|
||||||
|
equals?: 'requirements' | ((new_value: T, old_value: T | undefined, cache: Map<SettingsKeys, unknown>, old_cache: Map<SettingsKeys, unknown>) => boolean);
|
||||||
|
|
||||||
|
process?(ctx: SettingsContext, val: T, meta: SettingMetadata): T;
|
||||||
|
|
||||||
|
// Dependencies
|
||||||
|
required_by?: string[];
|
||||||
|
requires?: string[];
|
||||||
|
|
||||||
|
always_inherit?: boolean;
|
||||||
|
inherit_default?: boolean;
|
||||||
|
|
||||||
|
// Tracking
|
||||||
|
__source?: string | null;
|
||||||
|
|
||||||
|
// UI Stuff
|
||||||
|
ui?: SettingUiDefinition<T>;
|
||||||
|
|
||||||
|
// Reactivity
|
||||||
|
changed?: (value: T) => void;
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// UI Definitions
|
||||||
|
|
||||||
|
export type SettingUi_Basic = {
|
||||||
|
key: string;
|
||||||
|
path: string;
|
||||||
|
path_tokens: PathNode[];
|
||||||
|
|
||||||
|
no_filter?: boolean;
|
||||||
|
force_seen?: boolean;
|
||||||
|
|
||||||
|
title: string;
|
||||||
|
i18n_key: string;
|
||||||
|
|
||||||
|
description?: string;
|
||||||
|
desc_i18n_key?: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional. If present, this method will be used to retrieve an array of
|
||||||
|
* additional search terms that can be used to search for this setting.
|
||||||
|
*/
|
||||||
|
getExtraTerms?: () => string[];
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Each built-in settings component has a type with extra data definitions.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
// Text Box
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type SettingUi_TextBox = SettingUi_Basic & {
|
||||||
|
component: 'setting-text-box';
|
||||||
|
} & (SettingUi_TextBox_Process_Number | SettingUi_TextBox_Process_Other);
|
||||||
|
|
||||||
|
|
||||||
|
// Processing
|
||||||
|
|
||||||
|
export type SettingUi_TextBox_Process_Other = {
|
||||||
|
process?: Exclude<string, 'to_int' | 'to_float'>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SettingUi_TextBox_Process_Number = {
|
||||||
|
process: 'to_int' | 'to_float';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bounds represents a minimum and maximum numeric value. These values
|
||||||
|
* are used by number processing and validation if the processor is set
|
||||||
|
* to `to_int` or `to_float`.
|
||||||
|
*/
|
||||||
|
bounds?:
|
||||||
|
[low: number, low_inclusive: boolean, high: number, high_inclusive: boolean] |
|
||||||
|
[low: number, low_inclusive: boolean, high: number] |
|
||||||
|
[low: number, low_inclusive: boolean] |
|
||||||
|
[low: number, high: number] |
|
||||||
|
[low: number];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Check Box
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type SettingUi_CheckBox = SettingUi_Basic & {
|
||||||
|
component: 'setting-check-box';
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Select Box
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type SettingUi_Select<T> = SettingUi_Basic & {
|
||||||
|
component: 'setting-select-box';
|
||||||
|
|
||||||
|
data: OptionallyCallable<[profile: SettingsProfile, current: T], SettingUi_Select_Entry<T>[]>;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SettingUi_Select_Entry<T> = {
|
||||||
|
value: T;
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Combined Definitions
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export type SettingTypeUiDefinition<T> = SettingUi_TextBox | SettingUi_CheckBox | SettingUi_Select<T>;
|
||||||
|
|
||||||
|
|
||||||
|
// We also support other components, if the component doesn't match.
|
||||||
|
export type SettingOtherUiDefinition = SettingUi_Basic & {
|
||||||
|
component: Exclude<string, ExtractKey<SettingTypeUiDefinition<any>, 'component'>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The final combined definition.
|
||||||
|
export type FullSettingUiDefinition<T> = SettingTypeUiDefinition<T> | SettingOtherUiDefinition;
|
||||||
|
|
||||||
|
|
||||||
|
// Exports
|
||||||
|
|
||||||
|
export type ExportedSettingsProfile = {
|
||||||
|
version: 2;
|
||||||
|
type: 'profile';
|
||||||
|
profile: Partial<SettingsProfileMetadata>;
|
||||||
|
toggled?: boolean;
|
||||||
|
values: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ExportedFullDump = {
|
||||||
|
version: 2;
|
||||||
|
type: 'full';
|
||||||
|
values: Record<string, any>;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type ExportedBlobMetadata = {
|
||||||
|
key: string;
|
||||||
|
type?: string;
|
||||||
|
name?: string;
|
||||||
|
modified?: number;
|
||||||
|
mime?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Profiles
|
||||||
|
|
||||||
|
export type SettingsProfileMetadata = {
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
name: string;
|
||||||
|
i18n_key?: string | null;
|
||||||
|
hotkey?: string | null;
|
||||||
|
pause_updates: boolean;
|
||||||
|
|
||||||
|
ephemeral?: boolean;
|
||||||
|
|
||||||
|
description?: string | null;
|
||||||
|
desc_i18n_key?: string | null;
|
||||||
|
|
||||||
|
url?: string | null;
|
||||||
|
show_toggle: boolean;
|
||||||
|
|
||||||
|
context?: FilterData[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Type Handlers
|
||||||
|
|
||||||
|
export type SettingsTypeHandler = {
|
||||||
|
|
||||||
|
default?(input: any, definition: SettingDefinition<any>, log: Logger): any;
|
||||||
|
|
||||||
|
get(
|
||||||
|
key: string,
|
||||||
|
profiles: SettingsProfile[],
|
||||||
|
definition: SettingDefinition<any>,
|
||||||
|
log: Logger,
|
||||||
|
ctx: SettingsContext
|
||||||
|
): [unknown, number[]] | null | undefined;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Processors
|
||||||
|
|
||||||
|
export type SettingProcessor<T> = (
|
||||||
|
input: unknown,
|
||||||
|
default_value: T,
|
||||||
|
definition: SettingUiDefinition<T>
|
||||||
|
) => T;
|
||||||
|
|
||||||
|
|
||||||
|
// Validators
|
||||||
|
|
||||||
|
export type SettingValidator<T> = (
|
||||||
|
value: T,
|
||||||
|
definition: SettingUiDefinition<T>
|
||||||
|
) => boolean;
|
|
@ -1,45 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const do_number = (val, def) => {
|
|
||||||
if ( typeof val !== 'number' || isNaN(val) || ! isFinite(val) )
|
|
||||||
return false;
|
|
||||||
|
|
||||||
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])) )
|
|
||||||
return false;
|
|
||||||
|
|
||||||
} else if ( bounds.length === 2 ) {
|
|
||||||
// [low, inclusive] or [low, high] ?
|
|
||||||
if ( typeof bounds[1] === 'boolean' ) {
|
|
||||||
if ( bounds[1] ? val < bounds[0] : val <= bounds[0] )
|
|
||||||
return false;
|
|
||||||
} else if ( val < bounds[0] || val > bounds[1] )
|
|
||||||
return false;
|
|
||||||
} else if ( bounds.length === 1 && val < bounds[0] )
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const process_to_int = (val, def) => {
|
|
||||||
if ( typeof val === 'string' && ! /^-?\d+$/.test(val) )
|
|
||||||
return false;
|
|
||||||
else
|
|
||||||
val = parseInt(val, 10);
|
|
||||||
|
|
||||||
return do_number(val, def);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const process_to_float = (val, def) => {
|
|
||||||
if ( typeof val === 'string' && ! /^-?[\d.]+$/.test(val) )
|
|
||||||
return false;
|
|
||||||
else
|
|
||||||
val = parseFloat(val);
|
|
||||||
|
|
||||||
return do_number(val, def);
|
|
||||||
}
|
|
54
src/settings/validators.ts
Normal file
54
src/settings/validators.ts
Normal file
|
@ -0,0 +1,54 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
import type { SettingUiDefinition, SettingValidator } from "./types";
|
||||||
|
|
||||||
|
|
||||||
|
function do_number(value: any, definition: SettingUiDefinition<number>) {
|
||||||
|
if ( typeof value !== 'number' || isNaN(value) || ! isFinite(value) )
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const bounds = definition.bounds;
|
||||||
|
if ( Array.isArray(bounds) ) {
|
||||||
|
if ( bounds.length >= 3 ) {
|
||||||
|
// [low, inclusive, high, inclusive]
|
||||||
|
if ( (bounds[1] ? (value < bounds[0]) : (value <= bounds[0])) ||
|
||||||
|
(bounds[3] ? (value > (bounds as any)[2]) : (value >= (bounds as any)[2])) )
|
||||||
|
return false;
|
||||||
|
|
||||||
|
} else if ( bounds.length === 2 ) {
|
||||||
|
// [low, inclusive] or [low, high] ?
|
||||||
|
if ( typeof bounds[1] === 'boolean' ) {
|
||||||
|
if ( bounds[1] ? value < bounds[0] : value <= bounds[0] )
|
||||||
|
return false;
|
||||||
|
} else if ( value < bounds[0] || value > bounds[1] )
|
||||||
|
return false;
|
||||||
|
} else if ( bounds.length === 1 && value < bounds[0] )
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const process_to_int: SettingValidator<number> = (
|
||||||
|
value,
|
||||||
|
definition
|
||||||
|
) => {
|
||||||
|
if ( typeof value === 'string' && /^-?\d+$/.test(value) )
|
||||||
|
value = parseInt(value, 10);
|
||||||
|
else if ( typeof value !== 'number' )
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return do_number(value, definition);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const process_to_float: SettingValidator<number> = (
|
||||||
|
value,
|
||||||
|
definition
|
||||||
|
) => {
|
||||||
|
if ( typeof value === 'string' && /^-?[\d.]+$/.test(value) )
|
||||||
|
value = parseFloat(value);
|
||||||
|
else if ( typeof value !== 'number' )
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return do_number(value, definition);
|
||||||
|
}
|
|
@ -279,4 +279,4 @@ export default class Line extends Module {
|
||||||
|
|
||||||
return msg;
|
return msg;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -44,7 +44,7 @@ export default class Twilight extends BaseSite {
|
||||||
|
|
||||||
async populateModules() {
|
async populateModules() {
|
||||||
const ctx = await require.context('site/modules', true, /(?:^(?:\.\/)?[^/]+|index)\.jsx?$/);
|
const ctx = await require.context('site/modules', true, /(?:^(?:\.\/)?[^/]+|index)\.jsx?$/);
|
||||||
const modules = await this.populate(ctx, this.log);
|
const modules = await this.loadFromContext(ctx, this.log);
|
||||||
this.log.info(`Loaded descriptions of ${Object.keys(modules).length} modules.`);
|
this.log.info(`Loaded descriptions of ${Object.keys(modules).length} modules.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -257,51 +257,6 @@ export default class Channel extends Module {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/*setHost(channel_id, channel_login, target_id, target_login) {
|
|
||||||
const topic = `stream-chat-room-v1.${channel_id}`;
|
|
||||||
|
|
||||||
this.subpump.inject(topic, {
|
|
||||||
type: 'host_target_change',
|
|
||||||
data: {
|
|
||||||
channel_id,
|
|
||||||
channel_login,
|
|
||||||
target_channel_id: target_id || null,
|
|
||||||
target_channel_login: target_login || null,
|
|
||||||
previous_target_channel_id: null,
|
|
||||||
num_viewers: 0
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.subpump.inject(topic, {
|
|
||||||
type: 'host_target_change_v2',
|
|
||||||
data: {
|
|
||||||
channel_id,
|
|
||||||
channel_login,
|
|
||||||
target_channel_id: target_id || null,
|
|
||||||
target_channel_login: target_login || null,
|
|
||||||
previous_target_channel_id: null,
|
|
||||||
num_viewers: 0
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
onPubSub(event) {
|
|
||||||
if ( event.prefix !== 'stream-chat-room-v1' || this.settings.get('channel.hosting.enable') )
|
|
||||||
return;
|
|
||||||
|
|
||||||
const type = event.message.type;
|
|
||||||
if ( type === 'host_target_change' || type === 'host_target_change_v2' ) {
|
|
||||||
this.log.info('Nulling Host Target Change', type);
|
|
||||||
event.message.data.target_channel_id = null;
|
|
||||||
event.message.data.target_channel_login = null;
|
|
||||||
event.message.data.previous_target_channel_id = null;
|
|
||||||
event.message.data.num_viewers = 0;
|
|
||||||
event.markChanged();
|
|
||||||
}
|
|
||||||
}*/
|
|
||||||
|
|
||||||
|
|
||||||
updateSubscription(id, login) {
|
updateSubscription(id, login) {
|
||||||
if ( this._subbed_login === login && this._subbed_id === id )
|
if ( this._subbed_login === login && this._subbed_id === id )
|
||||||
return;
|
return;
|
||||||
|
@ -431,10 +386,14 @@ export default class Channel extends Module {
|
||||||
this.fine.searchNode(react, node => {
|
this.fine.searchNode(react, node => {
|
||||||
let state = node?.memoizedState, i = 0;
|
let state = node?.memoizedState, i = 0;
|
||||||
while(state != null && channel == null && i < 50 ) {
|
while(state != null && channel == null && i < 50 ) {
|
||||||
state = state?.next;
|
channel = state?.memoizedState?.current?.result?.data?.user ??
|
||||||
channel = state?.memoizedState?.current?.previous?.result?.data?.user;
|
state?.memoizedState?.current?.previousData?.user;
|
||||||
if (!channel?.lastBroadcast?.game)
|
|
||||||
|
if ( !channel?.lastBroadcast?.game )
|
||||||
channel = null;
|
channel = null;
|
||||||
|
|
||||||
|
if ( ! channel )
|
||||||
|
state = state?.next;
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
return channel != null;
|
return channel != null;
|
||||||
|
@ -583,10 +542,11 @@ export default class Channel extends Module {
|
||||||
let state = node?.memoizedState;
|
let state = node?.memoizedState;
|
||||||
i=0;
|
i=0;
|
||||||
while(state != null && channel == null && i < 50) {
|
while(state != null && channel == null && i < 50) {
|
||||||
state = state?.next;
|
channel = state?.memoizedState?.current?.result?.data?.userOrError ??
|
||||||
channel = state?.memoizedState?.current?.currentObservable?.lastResult?.data?.userOrError;
|
state?.memoizedState?.current?.previousData?.userOrError;
|
||||||
|
|
||||||
if ( ! channel )
|
if ( ! channel )
|
||||||
channel = state?.memoizedState?.current?.previous?.result?.previousData?.userOrError;
|
state = state?.next;
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
node = node?.return;
|
node = node?.return;
|
||||||
|
@ -725,4 +685,4 @@ export default class Channel extends Module {
|
||||||
err ? pair[1](err) : pair[0](id);
|
err ? pair[1](err) : pair[0](id);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,7 +7,7 @@
|
||||||
import {Color, ColorAdjuster} from 'utilities/color';
|
import {Color, ColorAdjuster} from 'utilities/color';
|
||||||
import {get, has, make_enum, shallow_object_equals, set_equals, deep_equals, glob_to_regex, escape_regex} from 'utilities/object';
|
import {get, has, make_enum, shallow_object_equals, set_equals, deep_equals, glob_to_regex, escape_regex} from 'utilities/object';
|
||||||
import {WEBKIT_CSS as WEBKIT} from 'utilities/constants';
|
import {WEBKIT_CSS as WEBKIT} from 'utilities/constants';
|
||||||
import {FFZEvent} from 'utilities/events';
|
|
||||||
import {useFont} from 'utilities/fonts';
|
import {useFont} from 'utilities/fonts';
|
||||||
|
|
||||||
import Module from 'utilities/module';
|
import Module from 'utilities/module';
|
||||||
|
@ -1910,7 +1910,7 @@ export default class ChatHook extends Module {
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if ( t.hasListeners('chat:receive-message') ) {
|
if ( t.hasListeners('chat:receive-message') ) {
|
||||||
const event = new FFZEvent({
|
const event = t.makeEvent({
|
||||||
message: m,
|
message: m,
|
||||||
inst,
|
inst,
|
||||||
channel: room,
|
channel: room,
|
||||||
|
@ -2277,12 +2277,16 @@ export default class ChatHook extends Module {
|
||||||
|
|
||||||
if ( want_event ) {
|
if ( want_event ) {
|
||||||
if ( ! event ) {
|
if ( ! event ) {
|
||||||
event = new FFZEvent();
|
event = t.makeEvent({
|
||||||
|
inst: this,
|
||||||
|
channel: undefined,
|
||||||
|
channelID: undefined,
|
||||||
|
message: undefined
|
||||||
|
});
|
||||||
|
|
||||||
const cont = this._ffz_connector ?? this.ffzGetConnector(),
|
const cont = this._ffz_connector ?? this.ffzGetConnector(),
|
||||||
room_id = cont && cont.props.channelID;
|
room_id = cont && cont.props.channelID;
|
||||||
|
|
||||||
event.inst = this;
|
|
||||||
event.channelID = room_id;
|
event.channelID = room_id;
|
||||||
|
|
||||||
if ( room_id ) {
|
if ( room_id ) {
|
||||||
|
@ -2447,7 +2451,7 @@ export default class ChatHook extends Module {
|
||||||
msg = msg.slice(idx + 1).trimStart();
|
msg = msg.slice(idx + 1).trimStart();
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = new FFZEvent({
|
const event = t.makeEvent({
|
||||||
command: subcmd,
|
command: subcmd,
|
||||||
message: msg,
|
message: msg,
|
||||||
extra,
|
extra,
|
||||||
|
@ -2472,7 +2476,7 @@ export default class ChatHook extends Module {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const event = new FFZEvent({
|
const event = t.makeEvent({
|
||||||
message: msg,
|
message: msg,
|
||||||
extra,
|
extra,
|
||||||
context: t.chat.context,
|
context: t.chat.context,
|
||||||
|
|
|
@ -6,8 +6,8 @@
|
||||||
|
|
||||||
import Module from 'utilities/module';
|
import Module from 'utilities/module';
|
||||||
import { findReactFragment } from 'utilities/dom';
|
import { findReactFragment } from 'utilities/dom';
|
||||||
import { FFZEvent } from 'utilities/events';
|
|
||||||
import { getTwitchEmoteSrcSet, has, getTwitchEmoteURL } from 'utilities/object';
|
import { getTwitchEmoteSrcSet } from 'utilities/object';
|
||||||
import { TWITCH_POINTS_SETS, TWITCH_GLOBAL_SETS, TWITCH_PRIME_SETS, KNOWN_CODES, REPLACEMENTS, REPLACEMENT_BASE, KEYS } from 'utilities/constants';
|
import { TWITCH_POINTS_SETS, TWITCH_GLOBAL_SETS, TWITCH_PRIME_SETS, KNOWN_CODES, REPLACEMENTS, REPLACEMENT_BASE, KEYS } from 'utilities/constants';
|
||||||
|
|
||||||
import Twilight from 'site';
|
import Twilight from 'site';
|
||||||
|
@ -523,7 +523,7 @@ export default class Input extends Module {
|
||||||
}
|
}
|
||||||
|
|
||||||
previewClick(id, set, name, evt) {
|
previewClick(id, set, name, evt) {
|
||||||
const fe = new FFZEvent({
|
const fe = this.makeEvent({
|
||||||
provider: 'ffz',
|
provider: 'ffz',
|
||||||
id,
|
id,
|
||||||
set,
|
set,
|
||||||
|
@ -779,7 +779,7 @@ export default class Input extends Module {
|
||||||
isEditor: inst.props.isCurrentUserEditor
|
isEditor: inst.props.isCurrentUserEditor
|
||||||
});
|
});
|
||||||
|
|
||||||
const event = new FFZEvent({
|
const event = t.makeEvent({
|
||||||
input,
|
input,
|
||||||
permissionLevel: inst.props.permissionLevel,
|
permissionLevel: inst.props.permissionLevel,
|
||||||
isEditor: inst.props.isCurrentUserEditor,
|
isEditor: inst.props.isCurrentUserEditor,
|
||||||
|
|
|
@ -8,11 +8,11 @@ import Twilight from 'site';
|
||||||
import Module from 'utilities/module';
|
import Module from 'utilities/module';
|
||||||
|
|
||||||
import RichContent from './rich_content';
|
import RichContent from './rich_content';
|
||||||
import { has, maybe_call } from 'utilities/object';
|
import { has } from 'utilities/object';
|
||||||
import { KEYS, RERENDER_SETTINGS, UPDATE_BADGE_SETTINGS, UPDATE_TOKEN_SETTINGS } from 'utilities/constants';
|
import { KEYS, RERENDER_SETTINGS, UPDATE_BADGE_SETTINGS, UPDATE_TOKEN_SETTINGS } from 'utilities/constants';
|
||||||
import { print_duration } from 'utilities/time';
|
import { print_duration } from 'utilities/time';
|
||||||
import { FFZEvent } from 'utilities/events';
|
|
||||||
import { getRewardTitle, getRewardCost, isHighlightedReward } from './points';
|
import { getRewardTitle, getRewardCost } from './points';
|
||||||
|
|
||||||
const SUB_TIERS = {
|
const SUB_TIERS = {
|
||||||
1000: 1,
|
1000: 1,
|
||||||
|
@ -431,21 +431,6 @@ export default class ChatLine extends Module {
|
||||||
this.updateLines();
|
this.updateLines();
|
||||||
});
|
});
|
||||||
|
|
||||||
/*this.on('experiments:changed:line_renderer', () => {
|
|
||||||
const value = this.experiments.get('line_renderer'),
|
|
||||||
cls = this.ChatLine._class;
|
|
||||||
|
|
||||||
this.log.debug('Changing line renderer:', value ? 'new' : 'old');
|
|
||||||
|
|
||||||
if (cls) {
|
|
||||||
cls.prototype.render = this.experiments.get('line_renderer')
|
|
||||||
? cls.prototype.ffzNewRender
|
|
||||||
: cls.prototype.ffzOldRender;
|
|
||||||
|
|
||||||
this.rerenderLines();
|
|
||||||
}
|
|
||||||
});*/
|
|
||||||
|
|
||||||
for(const setting of RERENDER_SETTINGS)
|
for(const setting of RERENDER_SETTINGS)
|
||||||
this.chat.context.on(`changed:${setting}`, this.rerenderLines, this);
|
this.chat.context.on(`changed:${setting}`, this.rerenderLines, this);
|
||||||
|
|
||||||
|
@ -839,7 +824,7 @@ other {# messages were deleted by a moderator.}
|
||||||
} catch(err) { /* nothing~! */ }
|
} catch(err) { /* nothing~! */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
const fe = new FFZEvent({
|
const fe = t.makeEvent({
|
||||||
inst: this,
|
inst: this,
|
||||||
event,
|
event,
|
||||||
message: msg,
|
message: msg,
|
||||||
|
|
|
@ -247,7 +247,7 @@ export default class Scroller extends Module {
|
||||||
inst.ffz_outside = true;
|
inst.ffz_outside = true;
|
||||||
inst._ffz_accessor = `_ffz_contains_${last_id++}`;
|
inst._ffz_accessor = `_ffz_contains_${last_id++}`;
|
||||||
|
|
||||||
t.on('tooltips:mousemove', this.ffzTooltipHover, this);
|
t.on('tooltips:hover', this.ffzTooltipHover, this);
|
||||||
t.on('tooltips:leave', this.ffzTooltipLeave, this);
|
t.on('tooltips:leave', this.ffzTooltipLeave, this);
|
||||||
|
|
||||||
inst.scrollToBottom = function() {
|
inst.scrollToBottom = function() {
|
||||||
|
@ -682,7 +682,7 @@ export default class Scroller extends Module {
|
||||||
}
|
}
|
||||||
|
|
||||||
onUnmount(inst) { // eslint-disable-line class-methods-use-this
|
onUnmount(inst) { // eslint-disable-line class-methods-use-this
|
||||||
this.off('tooltips:mousemove', inst.ffzTooltipHover, inst);
|
this.off('tooltips:hover', inst.ffzTooltipHover, inst);
|
||||||
this.off('tooltips:leave', inst.ffzTooltipLeave, inst);
|
this.off('tooltips:leave', inst.ffzTooltipLeave, inst);
|
||||||
|
|
||||||
if ( inst._ffz_hover_timer ) {
|
if ( inst._ffz_hover_timer ) {
|
||||||
|
@ -698,4 +698,4 @@ export default class Scroller extends Module {
|
||||||
window.removeEventListener('keydown', inst.ffzHandleKey);
|
window.removeEventListener('keydown', inst.ffzHandleKey);
|
||||||
window.removeEventListener('keyup', inst.ffzHandleKey);
|
window.removeEventListener('keyup', inst.ffzHandleKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -597,7 +597,7 @@ export default class CSSTweaks extends Module {
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if ( ! this.chunks_loaded )
|
if ( ! this.chunks_loaded )
|
||||||
return this.populate().then(() => this._apply(key));
|
return this.loadFromContext().then(() => this._apply(key));
|
||||||
|
|
||||||
if ( ! has(this.chunks, key) ) {
|
if ( ! has(this.chunks, key) ) {
|
||||||
this.log.warn(`Unknown chunk name "${key}" for toggle()`);
|
this.log.warn(`Unknown chunk name "${key}" for toggle()`);
|
||||||
|
@ -618,7 +618,7 @@ export default class CSSTweaks extends Module {
|
||||||
deleteVariable(key) { this.style.delete(`var--${key}`) }
|
deleteVariable(key) { this.style.delete(`var--${key}`) }
|
||||||
|
|
||||||
|
|
||||||
populate() {
|
loadFromContext() {
|
||||||
if ( this.chunks_loaded )
|
if ( this.chunks_loaded )
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
|
|
@ -4,15 +4,16 @@
|
||||||
// Directory (Following, for now)
|
// Directory (Following, for now)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import {SiteModule} from 'utilities/module';
|
import Module from 'utilities/module';
|
||||||
import {createElement} from 'utilities/dom';
|
import {createElement} from 'utilities/dom';
|
||||||
import { get } from 'utilities/object';
|
import { get } from 'utilities/object';
|
||||||
|
|
||||||
|
|
||||||
export default class Game extends SiteModule {
|
export default class Game extends Module {
|
||||||
constructor(...args) {
|
constructor(...args) {
|
||||||
super(...args);
|
super(...args);
|
||||||
|
|
||||||
|
this.inject('site');
|
||||||
this.inject('site.fine');
|
this.inject('site.fine');
|
||||||
this.inject('site.apollo');
|
this.inject('site.apollo');
|
||||||
|
|
||||||
|
|
|
@ -4,7 +4,7 @@
|
||||||
// Directory
|
// Directory
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import {SiteModule} from 'utilities/module';
|
import Module from 'utilities/module';
|
||||||
import {duration_to_string} from 'utilities/time';
|
import {duration_to_string} from 'utilities/time';
|
||||||
import {createElement} from 'utilities/dom';
|
import {createElement} from 'utilities/dom';
|
||||||
import {get, glob_to_regex, escape_regex, addWordSeparators} from 'utilities/object';
|
import {get, glob_to_regex, escape_regex, addWordSeparators} from 'utilities/object';
|
||||||
|
@ -18,6 +18,15 @@ export const CARD_CONTEXTS = ((e ={}) => {
|
||||||
return e;
|
return e;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
export const CONTENT_FLAGS = [
|
||||||
|
'DrugsIntoxication',
|
||||||
|
'Gambling',
|
||||||
|
'MatureGame',
|
||||||
|
'ProfanityVulgarity',
|
||||||
|
'SexualThemes',
|
||||||
|
'ViolentGrpahic'
|
||||||
|
];
|
||||||
|
|
||||||
function formatTerms(data, flags) {
|
function formatTerms(data, flags) {
|
||||||
if ( data[0].length )
|
if ( data[0].length )
|
||||||
data[1].push(addWordSeparators(data[0].join('|')));
|
data[1].push(addWordSeparators(data[0].join('|')));
|
||||||
|
@ -33,12 +42,13 @@ function formatTerms(data, flags) {
|
||||||
const DIR_ROUTES = ['front-page', 'dir', 'dir-community', 'dir-community-index', 'dir-creative', 'dir-following', 'dir-game-index', 'dir-game-clips', 'dir-game-videos', 'dir-all', 'dir-category', 'user-videos', 'user-clips'];
|
const DIR_ROUTES = ['front-page', 'dir', 'dir-community', 'dir-community-index', 'dir-creative', 'dir-following', 'dir-game-index', 'dir-game-clips', 'dir-game-videos', 'dir-all', 'dir-category', 'user-videos', 'user-clips'];
|
||||||
|
|
||||||
|
|
||||||
export default class Directory extends SiteModule {
|
export default class Directory extends Module {
|
||||||
constructor(...args) {
|
constructor(...args) {
|
||||||
super(...args);
|
super(...args);
|
||||||
|
|
||||||
this.should_enable = true;
|
this.should_enable = true;
|
||||||
|
|
||||||
|
this.inject('site');
|
||||||
this.inject('site.elemental');
|
this.inject('site.elemental');
|
||||||
this.inject('site.fine');
|
this.inject('site.fine');
|
||||||
this.inject('site.router');
|
this.inject('site.router');
|
||||||
|
@ -252,6 +262,115 @@ export default class Directory extends SiteModule {
|
||||||
changed: () => this.updateCards()
|
changed: () => this.updateCards()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
this.settings.add('directory.blur-titles', {
|
||||||
|
default: [],
|
||||||
|
type: 'array_merge',
|
||||||
|
always_inherit: true,
|
||||||
|
ui: {
|
||||||
|
path: 'Directory > Channels >> Hide Thumbnails by Title',
|
||||||
|
component: 'basic-terms'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
this.settings.add('__filter:directory.blur-titles', {
|
||||||
|
requires: ['directory.blur-titles'],
|
||||||
|
equals: 'requirements',
|
||||||
|
process(ctx) {
|
||||||
|
const val = ctx.get('directory.blur-titles');
|
||||||
|
if ( ! val || ! val.length )
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const out = [
|
||||||
|
[ // sensitive
|
||||||
|
[], [] // word
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[], []
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
for(const item of val) {
|
||||||
|
const t = item.t;
|
||||||
|
let v = item.v;
|
||||||
|
|
||||||
|
if ( t === 'glob' )
|
||||||
|
v = glob_to_regex(v);
|
||||||
|
|
||||||
|
else if ( t !== 'raw' )
|
||||||
|
v = escape_regex(v);
|
||||||
|
|
||||||
|
if ( ! v || ! v.length )
|
||||||
|
continue;
|
||||||
|
|
||||||
|
out[item.s ? 0 : 1][item.w ? 0 : 1].push(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
formatTerms(out[0], 'g'),
|
||||||
|
formatTerms(out[1], 'gi')
|
||||||
|
];
|
||||||
|
},
|
||||||
|
|
||||||
|
changed: () => this.updateCards()
|
||||||
|
});
|
||||||
|
|
||||||
|
this.settings.add('directory.blur-tags', {
|
||||||
|
default: [],
|
||||||
|
type: 'basic_array_merge',
|
||||||
|
always_inherit: true,
|
||||||
|
ui: {
|
||||||
|
path: 'Directory > Channels >> Hide Thumbnails by Tag',
|
||||||
|
component: 'tag-list-editor'
|
||||||
|
},
|
||||||
|
changed: () => this.updateCards()
|
||||||
|
});
|
||||||
|
|
||||||
|
this.settings.add('directory.block-flags', {
|
||||||
|
default: [],
|
||||||
|
type: 'array_merge',
|
||||||
|
always_inherit: true,
|
||||||
|
process(ctx, val) {
|
||||||
|
const out = new Set;
|
||||||
|
for(const v of val)
|
||||||
|
if ( v?.v )
|
||||||
|
out.add(v.v);
|
||||||
|
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
|
||||||
|
ui: {
|
||||||
|
path: 'Directory > Channels >> Block by Flag',
|
||||||
|
component: 'blocked-types',
|
||||||
|
data: () => [...CONTENT_FLAGS]
|
||||||
|
.sort()
|
||||||
|
},
|
||||||
|
|
||||||
|
changed: () => this.updateCards()
|
||||||
|
});
|
||||||
|
|
||||||
|
this.settings.add('directory.blur-flags', {
|
||||||
|
default: [],
|
||||||
|
type: 'array_merge',
|
||||||
|
always_inherit: true,
|
||||||
|
process(ctx, val) {
|
||||||
|
const out = new Set;
|
||||||
|
for(const v of val)
|
||||||
|
if ( v?.v )
|
||||||
|
out.add(v.v);
|
||||||
|
|
||||||
|
return out;
|
||||||
|
},
|
||||||
|
|
||||||
|
ui: {
|
||||||
|
path: 'Directory > Channels >> Hide Thumbnails by Flag',
|
||||||
|
component: 'blocked-types',
|
||||||
|
data: () => [...CONTENT_FLAGS]
|
||||||
|
.sort()
|
||||||
|
},
|
||||||
|
changed: () => this.updateCards()
|
||||||
|
});
|
||||||
|
|
||||||
/*this.settings.add('directory.hide-viewing-history', {
|
/*this.settings.add('directory.hide-viewing-history', {
|
||||||
default: false,
|
default: false,
|
||||||
ui: {
|
ui: {
|
||||||
|
@ -457,23 +576,61 @@ export default class Directory extends SiteModule {
|
||||||
const game = props.gameTitle || props.trackingProps?.categoryName || props.trackingProps?.category || props.contextualCardActionProps?.props?.categoryName,
|
const game = props.gameTitle || props.trackingProps?.categoryName || props.trackingProps?.category || props.contextualCardActionProps?.props?.categoryName,
|
||||||
tags = props.tagListProps?.freeformTags;
|
tags = props.tagListProps?.freeformTags;
|
||||||
|
|
||||||
let bad_tag = false;
|
const blur_flags = this.settings.get('directory.blur-flags', []),
|
||||||
|
block_flags = this.settings.get('directory.block-flags', []);
|
||||||
|
|
||||||
el.classList.toggle('ffz-hide-thumbnail', this.settings.provider.get('directory.game.hidden-thumbnails', []).includes(game));
|
if ( el._ffz_flags === undefined && (blur_flags.size || block_flags.size) ) {
|
||||||
el.dataset.ffzType = props.streamType;
|
el._ffz_flags = null;
|
||||||
|
this.twitch_data.getStreamFlags(null, props.channelLogin).then(data => {
|
||||||
|
el._ffz_flags = data;
|
||||||
|
this.updateCard(el);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let bad_tag = false,
|
||||||
|
blur_tag = false;
|
||||||
|
|
||||||
if ( Array.isArray(tags) ) {
|
if ( Array.isArray(tags) ) {
|
||||||
const bad_tags = this.settings.get('directory.blocked-tags', []);
|
const bad_tags = this.settings.get('directory.blocked-tags', []),
|
||||||
if ( bad_tags.length ) {
|
blur_tags = this.settings.get('directory.blur-tags', []);
|
||||||
|
|
||||||
|
if ( bad_tags.length || blur_tags.length ) {
|
||||||
for(const tag of tags) {
|
for(const tag of tags) {
|
||||||
if ( tag?.name && bad_tags.includes(tag.name.toLowerCase()) ) {
|
if ( tag?.name ) {
|
||||||
bad_tag = true;
|
const lname = tag.name.toLowerCase();
|
||||||
break;
|
if ( bad_tags.includes(lname) )
|
||||||
|
bad_tag = true;
|
||||||
|
if ( blur_tags.includes(lname) )
|
||||||
|
blur_tag = true;
|
||||||
}
|
}
|
||||||
|
if ( (bad_tag || ! bad_tags.length) && (blur_tag || ! blur_tags.length) )
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let should_blur = blur_tag;
|
||||||
|
if ( ! should_blur )
|
||||||
|
should_blur = this.settings.provider.get('directory.game.hidden-thumbnails', []).includes(game);
|
||||||
|
if ( ! should_blur && blur_flags.size && el._ffz_flags ) {
|
||||||
|
for(const flag of el._ffz_flags)
|
||||||
|
if ( flag?.id && blur_flags.has(flag.id) ) {
|
||||||
|
should_blur = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ( ! should_blur ) {
|
||||||
|
const regexes = this.settings.get('__filter:directory.blur-titles');
|
||||||
|
if ( regexes &&
|
||||||
|
(( regexes[0] && regexes[0].test(props.title) ) ||
|
||||||
|
( regexes[1] && regexes[1].test(props.title) ))
|
||||||
|
)
|
||||||
|
should_blur = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
el.classList.toggle('ffz-hide-thumbnail', should_blur);
|
||||||
|
el.dataset.ffzType = props.streamType;
|
||||||
|
|
||||||
let should_hide = false;
|
let should_hide = false;
|
||||||
if ( bad_tag )
|
if ( bad_tag )
|
||||||
should_hide = true;
|
should_hide = true;
|
||||||
|
@ -484,12 +641,22 @@ export default class Directory extends SiteModule {
|
||||||
else if ( (props.isPromotion || props.sourceType === 'COMMUNITY_BOOST' || props.sourceType === 'PROMOTION' || props.sourceType === 'SPONSORED') && this.settings.get('directory.hide-promoted') )
|
else if ( (props.isPromotion || props.sourceType === 'COMMUNITY_BOOST' || props.sourceType === 'PROMOTION' || props.sourceType === 'SPONSORED') && this.settings.get('directory.hide-promoted') )
|
||||||
should_hide = true;
|
should_hide = true;
|
||||||
else {
|
else {
|
||||||
const regexes = this.settings.get('__filter:directory.block-titles');
|
if ( block_flags.size && el._ffz_flags ) {
|
||||||
if ( regexes &&
|
for(const flag of el._ffz_flags)
|
||||||
(( regexes[0] && regexes[0].test(props.title) ) ||
|
if ( flag?.id && block_flags.has(flag.id) ) {
|
||||||
( regexes[1] && regexes[1].test(props.title) ))
|
should_hide = true;
|
||||||
)
|
break;
|
||||||
should_hide = true;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! should_hide ) {
|
||||||
|
const regexes = this.settings.get('__filter:directory.block-titles');
|
||||||
|
if ( regexes &&
|
||||||
|
(( regexes[0] && regexes[0].test(props.title) ) ||
|
||||||
|
( regexes[1] && regexes[1].test(props.title) ))
|
||||||
|
)
|
||||||
|
should_hide = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let hide_container = el.closest('.tw-tower > div');
|
let hide_container = el.closest('.tw-tower > div');
|
||||||
|
@ -641,4 +808,4 @@ export default class Directory extends SiteModule {
|
||||||
|
|
||||||
this.router.navigate('user', { userName: user });
|
this.router.navigate('user', { userName: user });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,19 +4,20 @@
|
||||||
// Following Button Modification
|
// Following Button Modification
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import {SiteModule} from 'utilities/module';
|
import Module from 'utilities/module';
|
||||||
import {createElement as e} from 'utilities/dom';
|
import {createElement as e} from 'utilities/dom';
|
||||||
import {duration_to_string} from 'utilities/time';
|
import {duration_to_string} from 'utilities/time';
|
||||||
|
|
||||||
import Tooltip from 'utilities/tooltip';
|
import Tooltip from 'utilities/tooltip';
|
||||||
|
|
||||||
export default class FollowingText extends SiteModule {
|
export default class FollowingText extends Module {
|
||||||
constructor(...args) {
|
constructor(...args) {
|
||||||
super(...args);
|
super(...args);
|
||||||
|
|
||||||
this.should_enable = true;
|
this.should_enable = true;
|
||||||
|
|
||||||
this.inject('settings');
|
this.inject('settings');
|
||||||
|
this.inject('site');
|
||||||
this.inject('site.router');
|
this.inject('site.router');
|
||||||
this.inject('site.apollo');
|
this.inject('site.apollo');
|
||||||
this.inject('i18n');
|
this.inject('i18n');
|
||||||
|
|
|
@ -1,15 +1,64 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
import type SettingsManager from 'root/src/settings';
|
||||||
|
import type { FineWrapper } from 'root/src/utilities/compat/fine';
|
||||||
|
import type Fine from 'root/src/utilities/compat/fine';
|
||||||
|
import type { ReactStateNode } from 'root/src/utilities/compat/react-types';
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Loadable Stuff
|
// Loadable Stuff
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import Module from 'utilities/module';
|
import Module, { GenericModule } from 'utilities/module';
|
||||||
|
import type { AnyFunction } from 'utilities/types';
|
||||||
|
import type Twilight from '..';
|
||||||
|
|
||||||
|
declare module 'utilities/types' {
|
||||||
|
interface ModuleEventMap {
|
||||||
|
|
||||||
|
}
|
||||||
|
interface ModuleMap {
|
||||||
|
'site.loadable': Loadable
|
||||||
|
}
|
||||||
|
interface SettingsTypeMap {
|
||||||
|
'chat.hype.show-pinned': boolean;
|
||||||
|
'layout.turbo-cta': boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
type LoadableNode = ReactStateNode<{
|
||||||
|
component: string;
|
||||||
|
loader: any;
|
||||||
|
}, {
|
||||||
|
Component?: AnyFunction;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
type ErrorBoundaryNode = ReactStateNode<{
|
||||||
|
name: string;
|
||||||
|
onError: any;
|
||||||
|
children: any;
|
||||||
|
}> & {
|
||||||
|
onErrorBoundaryTestEmit: any
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export default class Loadable extends Module {
|
export default class Loadable extends Module {
|
||||||
constructor(...args) {
|
|
||||||
super(...args);
|
// Dependencies
|
||||||
|
settings: SettingsManager = null as any;
|
||||||
|
site: Twilight = null as any;
|
||||||
|
fine: Fine = null as any;
|
||||||
|
|
||||||
|
// State
|
||||||
|
overrides: Map<string, boolean>;
|
||||||
|
|
||||||
|
// Fine
|
||||||
|
ErrorBoundaryComponent: FineWrapper<ErrorBoundaryNode>;
|
||||||
|
LoadableComponent: FineWrapper<LoadableNode>;
|
||||||
|
|
||||||
|
constructor(name?: string, parent?: GenericModule) {
|
||||||
|
super(name, parent);
|
||||||
|
|
||||||
this.should_enable = true;
|
this.should_enable = true;
|
||||||
|
|
||||||
|
@ -19,12 +68,18 @@ export default class Loadable extends Module {
|
||||||
|
|
||||||
this.LoadableComponent = this.fine.define(
|
this.LoadableComponent = this.fine.define(
|
||||||
'loadable-component',
|
'loadable-component',
|
||||||
n => n.props?.component && n.props.loader
|
n =>
|
||||||
|
(n as LoadableNode).props?.component &&
|
||||||
|
(n as LoadableNode).props.loader
|
||||||
);
|
);
|
||||||
|
|
||||||
this.ErrorBoundaryComponent = this.fine.define(
|
this.ErrorBoundaryComponent = this.fine.define(
|
||||||
'error-boundary-component',
|
'error-boundary-component',
|
||||||
n => n.props?.name && n.props?.onError && n.props?.children && n.onErrorBoundaryTestEmit
|
n =>
|
||||||
|
(n as ErrorBoundaryNode).props?.name &&
|
||||||
|
(n as ErrorBoundaryNode).props?.onError &&
|
||||||
|
(n as ErrorBoundaryNode).props?.children &&
|
||||||
|
(n as ErrorBoundaryNode).onErrorBoundaryTestEmit
|
||||||
);
|
);
|
||||||
|
|
||||||
this.overrides = new Map();
|
this.overrides = new Map();
|
||||||
|
@ -44,9 +99,10 @@ export default class Loadable extends Module {
|
||||||
this.log.debug('Found Error Boundary component wrapper.');
|
this.log.debug('Found Error Boundary component wrapper.');
|
||||||
|
|
||||||
const t = this,
|
const t = this,
|
||||||
old_render = cls.prototype.render;
|
proto = cls.prototype as ErrorBoundaryNode,
|
||||||
|
old_render = proto.render;
|
||||||
|
|
||||||
cls.prototype.render = function() {
|
proto.render = function() {
|
||||||
try {
|
try {
|
||||||
const type = this.props.name;
|
const type = this.props.name;
|
||||||
if ( t.overrides.has(type) && ! t.shouldRender(type) )
|
if ( t.overrides.has(type) && ! t.shouldRender(type) )
|
||||||
|
@ -66,32 +122,33 @@ export default class Loadable extends Module {
|
||||||
this.log.debug('Found Loadable component wrapper.');
|
this.log.debug('Found Loadable component wrapper.');
|
||||||
|
|
||||||
const t = this,
|
const t = this,
|
||||||
old_render = cls.prototype.render;
|
proto = cls.prototype,
|
||||||
|
old_render = proto.render;
|
||||||
|
|
||||||
cls.prototype.render = function() {
|
proto.render = function() {
|
||||||
try {
|
try {
|
||||||
const type = this.props.component;
|
const type = this.props.component;
|
||||||
if ( t.overrides.has(type) ) {
|
if ( t.overrides.has(type) && this.state ) {
|
||||||
let cmp = this.state.Component;
|
let cmp = this.state.Component;
|
||||||
if ( typeof cmp === 'function' && ! cmp.ffzWrapped ) {
|
if ( typeof cmp === 'function' && ! (cmp as any).ffzWrapped ) {
|
||||||
const React = t.site.getReact(),
|
const React = t.site.getReact(),
|
||||||
createElement = React && React.createElement;
|
createElement = React && React.createElement;
|
||||||
|
|
||||||
if ( createElement ) {
|
if ( createElement ) {
|
||||||
if ( ! cmp.ffzWrapper ) {
|
if ( ! (cmp as any).ffzWrapper ) {
|
||||||
const th = this;
|
const th = this;
|
||||||
function FFZWrapper(props, state) {
|
function FFZWrapper(props: any) {
|
||||||
if ( t.shouldRender(th.props.component, props, state) )
|
if ( t.shouldRender(th.props.component) )
|
||||||
return createElement(cmp, props);
|
return createElement(cmp, props);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
FFZWrapper.ffzWrapped = true;
|
FFZWrapper.ffzWrapped = true;
|
||||||
FFZWrapper.displayName = `FFZWrapper(${this.props.component})`;
|
FFZWrapper.displayName = `FFZWrapper(${this.props.component})`;
|
||||||
cmp.ffzWrapper = FFZWrapper;
|
(cmp as any).ffzWrapper = FFZWrapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.state.Component = cmp.ffzWrapper;
|
this.state.Component = (cmp as any).ffzWrapper;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -107,7 +164,7 @@ export default class Loadable extends Module {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle(cmp, state = null) {
|
toggle(cmp: string, state: boolean | null = null) {
|
||||||
const existing = this.overrides.get(cmp) ?? true;
|
const existing = this.overrides.get(cmp) ?? true;
|
||||||
|
|
||||||
if ( state == null )
|
if ( state == null )
|
||||||
|
@ -121,22 +178,22 @@ export default class Loadable extends Module {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
update(cmp) {
|
update(cmp: string) {
|
||||||
for(const inst of this.LoadableComponent.instances) {
|
for(const inst of this.LoadableComponent.instances) {
|
||||||
const type = inst?.props?.component;
|
const type = inst.props?.component;
|
||||||
if ( type && type === cmp )
|
if ( type && type === cmp )
|
||||||
inst.forceUpdate();
|
inst.forceUpdate();
|
||||||
}
|
}
|
||||||
|
|
||||||
for(const inst of this.ErrorBoundaryComponent.instances) {
|
for(const inst of this.ErrorBoundaryComponent.instances) {
|
||||||
const name = inst?.props?.name;
|
const name = inst.props?.name;
|
||||||
if ( name && name === cmp )
|
if ( name && name === cmp )
|
||||||
inst.forceUpdate();
|
inst.forceUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
shouldRender(cmp, props) {
|
shouldRender(cmp: string) {
|
||||||
return this.overrides.get(cmp) ?? true;
|
return this.overrides.get(cmp) ?? true;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
|
@ -5,19 +5,20 @@
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import {DEBUG} from 'utilities/constants';
|
import {DEBUG} from 'utilities/constants';
|
||||||
import {SiteModule} from 'utilities/module';
|
import Module from 'utilities/module';
|
||||||
import {createElement, ClickOutside, setChildren} from 'utilities/dom';
|
import {createElement, ClickOutside, setChildren} from 'utilities/dom';
|
||||||
|
|
||||||
import Twilight from 'site';
|
import Twilight from 'site';
|
||||||
import awaitMD, {getMD} from 'utilities/markdown';
|
import awaitMD, {getMD} from 'utilities/markdown';
|
||||||
|
|
||||||
|
|
||||||
export default class MenuButton extends SiteModule {
|
export default class MenuButton extends Module {
|
||||||
constructor(...args) {
|
constructor(...args) {
|
||||||
super(...args);
|
super(...args);
|
||||||
|
|
||||||
this.inject('i18n');
|
this.inject('i18n');
|
||||||
this.inject('settings');
|
this.inject('settings');
|
||||||
|
this.inject('site');
|
||||||
this.inject('site.fine');
|
this.inject('site.fine');
|
||||||
this.inject('site.elemental');
|
this.inject('site.elemental');
|
||||||
//this.inject('addons');
|
//this.inject('addons');
|
||||||
|
|
|
@ -109,11 +109,10 @@ export default class ModView extends Module {
|
||||||
let state = node.memoizedState;
|
let state = node.memoizedState;
|
||||||
i = 0;
|
i = 0;
|
||||||
while(state != null && channel == null && i < 50) {
|
while(state != null && channel == null && i < 50) {
|
||||||
|
channel = state?.memoizedState?.current?.result?.data?.user ??
|
||||||
|
state?.memoizedState?.current?.previousData?.user;
|
||||||
|
|
||||||
state = state?.next;
|
state = state?.next;
|
||||||
//channel = state?.memoizedState?.current?.previousData?.result?.data?.user;
|
|
||||||
channel = state?.memoizedState?.current?.currentObservable?.lastResult?.data?.user;
|
|
||||||
if ( ! channel )
|
|
||||||
channel = state?.memoizedState?.current?.previous?.result?.previousData?.user;
|
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
node = node?.child;
|
node = node?.child;
|
||||||
|
@ -226,8 +225,9 @@ export default class ModView extends Module {
|
||||||
|
|
||||||
let channel = null, state = root?.return?.memoizedState, i = 0;
|
let channel = null, state = root?.return?.memoizedState, i = 0;
|
||||||
while(state != null && channel == null && i < 50 ) {
|
while(state != null && channel == null && i < 50 ) {
|
||||||
|
channel = state?.memoizedState?.current?.result?.data?.channel ??
|
||||||
|
state?.memoizedState?.current?.previousData?.channel;
|
||||||
state = state?.next;
|
state = state?.next;
|
||||||
channel = state?.memoizedState?.current?.previous?.result?.data?.channel;
|
|
||||||
i++;
|
i++;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -337,4 +337,4 @@ export default class ModView extends Module {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,12 +4,50 @@
|
||||||
// Sub Button
|
// Sub Button
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import Module from 'utilities/module';
|
import Module, { GenericModule } from 'utilities/module';
|
||||||
import {createElement} from 'utilities/dom';
|
import {createElement} from 'utilities/dom';
|
||||||
|
import type SettingsManager from 'src/settings';
|
||||||
|
import type TranslationManager from 'src/i18n';
|
||||||
|
import type Fine from 'utilities/compat/fine';
|
||||||
|
import type { FineWrapper } from 'utilities/compat/fine';
|
||||||
|
import type { ReactStateNode } from 'root/src/utilities/compat/react-types';
|
||||||
|
|
||||||
|
declare module 'utilities/types' {
|
||||||
|
interface ModuleMap {
|
||||||
|
'site.sub_button': SubButton;
|
||||||
|
}
|
||||||
|
interface SettingsTypeMap {
|
||||||
|
'layout.swap-sidebars': unknown;
|
||||||
|
'sub-button.prime-notice': boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type SubButtonNode = ReactStateNode<{
|
||||||
|
data?: {
|
||||||
|
user?: {
|
||||||
|
self?: {
|
||||||
|
canPrimeSubscribe: boolean;
|
||||||
|
subscriptionBenefit: unknown;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}> & {
|
||||||
|
handleSubMenuAction: any;
|
||||||
|
openSubModal: any;
|
||||||
|
};
|
||||||
|
|
||||||
export default class SubButton extends Module {
|
export default class SubButton extends Module {
|
||||||
constructor(...args) {
|
|
||||||
super(...args);
|
// Dependencies
|
||||||
|
i18n: TranslationManager = null as any;
|
||||||
|
fine: Fine = null as any;
|
||||||
|
settings: SettingsManager = null as any;
|
||||||
|
|
||||||
|
// Stuff
|
||||||
|
SubButton: FineWrapper<SubButtonNode>;
|
||||||
|
|
||||||
|
constructor(name?: string, parent?: GenericModule) {
|
||||||
|
super(name, parent);
|
||||||
|
|
||||||
this.should_enable = true;
|
this.should_enable = true;
|
||||||
|
|
||||||
|
@ -32,39 +70,20 @@ export default class SubButton extends Module {
|
||||||
|
|
||||||
this.SubButton = this.fine.define(
|
this.SubButton = this.fine.define(
|
||||||
'sub-button',
|
'sub-button',
|
||||||
n => n.handleSubMenuAction && n.openSubModal,
|
n =>
|
||||||
|
(n as SubButtonNode).handleSubMenuAction &&
|
||||||
|
(n as SubButtonNode).openSubModal,
|
||||||
['user', 'user-home', 'user-video', 'user-clip', 'video', 'user-videos', 'user-clips', 'user-collections', 'user-events', 'user-followers', 'user-following']
|
['user', 'user-home', 'user-video', 'user-clip', 'video', 'user-videos', 'user-clips', 'user-collections', 'user-events', 'user-followers', 'user-following']
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
onEnable() {
|
onEnable() {
|
||||||
this.settings.on(':changed:layout.swap-sidebars', () => this.SubButton.forceUpdate())
|
this.settings.on(':changed:layout.swap-sidebars', () =>
|
||||||
|
this.SubButton.forceUpdate());
|
||||||
|
|
||||||
this.SubButton.ready((cls, instances) => {
|
this.SubButton.ready((cls, instances) => {
|
||||||
const t = this,
|
|
||||||
old_render = cls.prototype.render;
|
|
||||||
|
|
||||||
cls.prototype.render = function() {
|
|
||||||
try {
|
|
||||||
const old_direction = this.props.balloonDirection;
|
|
||||||
if ( old_direction !== undefined ) {
|
|
||||||
const should_be_left = t.settings.get('layout.swap-sidebars'),
|
|
||||||
is_left = old_direction.includes('--left');
|
|
||||||
|
|
||||||
if ( should_be_left && ! is_left )
|
|
||||||
this.props.balloonDirection = old_direction.replace('--right', '--left');
|
|
||||||
else if ( ! should_be_left && is_left )
|
|
||||||
this.props.balloonDirection = old_direction.replace('--left', '--right');
|
|
||||||
}
|
|
||||||
} catch(err) { /* no-op */ }
|
|
||||||
|
|
||||||
return old_render.call(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
for(const inst of instances)
|
for(const inst of instances)
|
||||||
this.updateSubButton(inst);
|
this.updateSubButton(inst);
|
||||||
|
|
||||||
this.SubButton.forceUpdate();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
this.SubButton.on('mount', this.updateSubButton, this);
|
this.SubButton.on('mount', this.updateSubButton, this);
|
||||||
|
@ -72,9 +91,9 @@ export default class SubButton extends Module {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
updateSubButton(inst) {
|
updateSubButton(inst: SubButtonNode) {
|
||||||
const container = this.fine.getChildNode(inst),
|
const container = this.fine.getChildNode<HTMLElement>(inst),
|
||||||
btn = container && container.querySelector('button[data-a-target="subscribe-button"]');
|
btn = container?.querySelector('button[data-a-target="subscribe-button"]');
|
||||||
if ( ! btn )
|
if ( ! btn )
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
@ -102,4 +121,4 @@ export default class SubButton extends Module {
|
||||||
post.remove();
|
post.remove();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -683,4 +683,4 @@ export default class SocketClient extends Module {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
SocketClient.State = State;
|
SocketClient.State = State;
|
||||||
|
|
|
@ -4,12 +4,38 @@
|
||||||
// Staging Selector
|
// Staging Selector
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import Module from 'utilities/module';
|
import Module, { GenericModule } from 'utilities/module';
|
||||||
import { API_SERVER, SERVER, STAGING_API, STAGING_CDN } from './utilities/constants';
|
import { API_SERVER, SERVER, STAGING_API, STAGING_CDN } from './utilities/constants';
|
||||||
|
import type SettingsManager from './settings';
|
||||||
|
|
||||||
export default class StagingSelector extends Module {
|
declare module 'utilities/types' {
|
||||||
constructor(...args) {
|
interface ModuleMap {
|
||||||
super(...args);
|
staging: StagingSelector;
|
||||||
|
}
|
||||||
|
interface ModuleEventMap {
|
||||||
|
staging: StagingEvents;
|
||||||
|
}
|
||||||
|
interface SettingsTypeMap {
|
||||||
|
'data.use-staging': boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type StagingEvents = {
|
||||||
|
':updated': [api: string, cdn: string];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class StagingSelector extends Module<'staging', StagingEvents> {
|
||||||
|
|
||||||
|
// Dependencies
|
||||||
|
settings: SettingsManager = null as any;
|
||||||
|
|
||||||
|
// State
|
||||||
|
api: string = API_SERVER;
|
||||||
|
cdn: string = SERVER;
|
||||||
|
active: boolean = false;
|
||||||
|
|
||||||
|
constructor(name?: string, parent?: GenericModule) {
|
||||||
|
super(name, parent);
|
||||||
|
|
||||||
this.inject('settings');
|
this.inject('settings');
|
||||||
|
|
||||||
|
@ -26,11 +52,12 @@ export default class StagingSelector extends Module {
|
||||||
this.updateStaging(false);
|
this.updateStaging(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
onEnable() {
|
onEnable() {
|
||||||
this.settings.getChanges('data.use-staging', this.updateStaging, this);
|
this.settings.getChanges('data.use-staging', this.updateStaging, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
updateStaging(val) {
|
private updateStaging(val: boolean) {
|
||||||
this.active = val;
|
this.active = val;
|
||||||
|
|
||||||
this.api = val
|
this.api = val
|
||||||
|
@ -43,4 +70,4 @@ export default class StagingSelector extends Module {
|
||||||
|
|
||||||
this.emit(':updated', this.api, this.cdn);
|
this.emit(':updated', this.api, this.cdn);
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,56 +0,0 @@
|
||||||
import Module from 'utilities/module';
|
|
||||||
|
|
||||||
const EXTRACTOR = /^addon\.([^.]+)(?:\.|$)/i;
|
|
||||||
|
|
||||||
function extractAddonId(path) {
|
|
||||||
const match = EXTRACTOR.exec(path);
|
|
||||||
if ( match )
|
|
||||||
return match[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class Addon extends Module {
|
|
||||||
constructor(...args) {
|
|
||||||
super(...args);
|
|
||||||
|
|
||||||
this.addon_id = extractAddonId(this.__path);
|
|
||||||
this.addon_root = this;
|
|
||||||
|
|
||||||
this.inject('i18n');
|
|
||||||
this.inject('settings');
|
|
||||||
}
|
|
||||||
|
|
||||||
static register(id, info) {
|
|
||||||
if ( typeof id === 'object' ) {
|
|
||||||
info = id;
|
|
||||||
id = info.id || undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( ! id ) {
|
|
||||||
if ( this.name )
|
|
||||||
id = this.name.toSnakeCase();
|
|
||||||
else
|
|
||||||
throw new Error(`Unable to register module without ID.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( ! info && this.info )
|
|
||||||
info = this.info;
|
|
||||||
|
|
||||||
const ffz = FrankerFaceZ.get();
|
|
||||||
if ( info ) {
|
|
||||||
info.id = id;
|
|
||||||
ffz.addons.addAddon(info);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
ffz.register(`addon.${id}`, this);
|
|
||||||
} catch(err) {
|
|
||||||
if ( err.message && err.message.includes('Name Collision for Module') ) {
|
|
||||||
const module = ffz.resolve(`addon.${id}`);
|
|
||||||
if ( module )
|
|
||||||
module.external = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
82
src/utilities/addon.ts
Normal file
82
src/utilities/addon.ts
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
import Module, { GenericModule, ModuleEvents } from 'utilities/module';
|
||||||
|
import type { AddonInfo } from './types';
|
||||||
|
import type Logger from './logging';
|
||||||
|
import type TranslationManager from '../i18n';
|
||||||
|
import type SettingsManager from '../settings';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A special sub-class of {@link Module} used for the root module of an add-on.
|
||||||
|
*
|
||||||
|
* This sub-class has a static {@link register} method that add-ons should call
|
||||||
|
* to properly inject themselves into FrankerFaceZ once their scripts have
|
||||||
|
* loaded. {@link register} is called automatically by add-ons build from the
|
||||||
|
* official add-ons repository.
|
||||||
|
*/
|
||||||
|
export class Addon<TPath extends string = '', TEventMap extends ModuleEvents = ModuleEvents> extends Module<TPath, TEventMap> {
|
||||||
|
|
||||||
|
static info?: AddonInfo;
|
||||||
|
|
||||||
|
// Dependencies
|
||||||
|
i18n: TranslationManager = null as any;
|
||||||
|
settings: SettingsManager = null as any;
|
||||||
|
|
||||||
|
constructor(name?: string, parent?: GenericModule) {
|
||||||
|
super(name, parent, true);
|
||||||
|
|
||||||
|
this.inject('i18n');
|
||||||
|
this.inject('settings');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated
|
||||||
|
* @see {@link loadFromContext}
|
||||||
|
*/
|
||||||
|
populate(ctx: __WebpackModuleApi.RequireContext, log?: Logger) {
|
||||||
|
this.log.warn('[DEV-CHECK] populate() has been renamed to loadFromContext(). The populate() name is deprecated.');
|
||||||
|
return this.loadFromContext(ctx, log);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register this add-on with the FrankerFaceZ module system. This
|
||||||
|
* should be called as soon as your add-on class is available and
|
||||||
|
* ready to be enabled. The {@link AddonManager} class will then
|
||||||
|
* call {@link enable} on this module (assuming the user wants
|
||||||
|
* the add-on to be enabled.)
|
||||||
|
* @param id This add-on's ID, or an {@link AddonInfo} object.
|
||||||
|
* @param info An optional AddonInfo object if {@link id} was not set to an AddonInfo object.
|
||||||
|
*/
|
||||||
|
static register(id?: string | AddonInfo, info?: AddonInfo) {
|
||||||
|
if ( typeof id === 'object' ) {
|
||||||
|
info = id;
|
||||||
|
id = info.id || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! id ) {
|
||||||
|
if ( this.name )
|
||||||
|
id = this.name.toSnakeCase();
|
||||||
|
else
|
||||||
|
throw new Error(`Unable to register module without ID.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! info && this.info )
|
||||||
|
info = this.info;
|
||||||
|
|
||||||
|
const ffz = window.FrankerFaceZ.get();
|
||||||
|
if ( info ) {
|
||||||
|
info.id = id;
|
||||||
|
(ffz as any).addons.addAddon(info);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
ffz.register(`addon.${id}`, this);
|
||||||
|
} catch(err) {
|
||||||
|
if ( (err instanceof Error) && err.message && err.message.includes('Name Collision for Module') ) {
|
||||||
|
const module = ffz.resolve(`addon.${id}`);
|
||||||
|
if ( module )
|
||||||
|
(module as any).external = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,138 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
export function isValidBlob(blob) {
|
|
||||||
return blob instanceof Blob || blob instanceof File || blob instanceof ArrayBuffer || blob instanceof Uint8Array;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function serializeBlob(blob) {
|
|
||||||
if ( ! blob )
|
|
||||||
return null;
|
|
||||||
|
|
||||||
if ( blob instanceof Blob )
|
|
||||||
return {
|
|
||||||
type: 'blob',
|
|
||||||
mime: blob.type,
|
|
||||||
buffer: await blob.arrayBuffer(),
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( blob instanceof File )
|
|
||||||
return {
|
|
||||||
type: 'file',
|
|
||||||
mime: blob.type,
|
|
||||||
name: blob.name,
|
|
||||||
modified: blob.lastModified,
|
|
||||||
buffer: await blob.arrayBuffer()
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( blob instanceof ArrayBuffer )
|
|
||||||
return {
|
|
||||||
type: 'ab',
|
|
||||||
buffer: blob
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( blob instanceof Uint8Array )
|
|
||||||
return {
|
|
||||||
type: 'u8',
|
|
||||||
buffer: blob.buffer
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new TypeError('Invalid type');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deserializeBlob(data) {
|
|
||||||
if ( ! data || ! data.type )
|
|
||||||
return null;
|
|
||||||
|
|
||||||
if ( data.type === 'blob' )
|
|
||||||
return new Blob([data.buffer], {type: data.mime});
|
|
||||||
|
|
||||||
if ( data.type === 'file' )
|
|
||||||
return new File([data.buffer], data.name, {type: data.mime, lastModified: data.modified});
|
|
||||||
|
|
||||||
if ( data.type === 'ab' )
|
|
||||||
return data.buffer;
|
|
||||||
|
|
||||||
if ( data.type === 'u8' )
|
|
||||||
return new Uint8Array(data.buffer);
|
|
||||||
|
|
||||||
throw new TypeError('Invalid type');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function serializeBlobUrl(blob) {
|
|
||||||
return new Promise((s,f) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.onabort = f;
|
|
||||||
reader.onerror = f;
|
|
||||||
reader.onload = e => {
|
|
||||||
s(e.target.result);
|
|
||||||
}
|
|
||||||
reader.readAsDataURL(blob);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deserializeBlobUrl(url) {
|
|
||||||
return fetch(blob).then(res => res.blob())
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deserializeABUrl(url) {
|
|
||||||
return fetch(blob).then(res => res.arrayBuffer())
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function serializeBlobForExt(blob) {
|
|
||||||
if ( ! blob )
|
|
||||||
return null;
|
|
||||||
|
|
||||||
if ( blob instanceof Blob )
|
|
||||||
return {
|
|
||||||
type: 'blob',
|
|
||||||
mime: blob.type,
|
|
||||||
url: await serializeBlobUrl(blob)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( blob instanceof File )
|
|
||||||
return {
|
|
||||||
type: 'file',
|
|
||||||
mime: blob.type,
|
|
||||||
name: blob.name,
|
|
||||||
modified: blob.lastModified,
|
|
||||||
url: await serializeBlobUrl(blob)
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( blob instanceof ArrayBuffer )
|
|
||||||
return {
|
|
||||||
type: 'ab',
|
|
||||||
url: await serializeBlobUrl(new Blob([blob]))
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( blob instanceof Uint8Array )
|
|
||||||
return {
|
|
||||||
type: 'u8',
|
|
||||||
url: await serializeBlobUrl(new Blob([blob]))
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new TypeError('Invalid type');
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deserializeBlobForExt(data) {
|
|
||||||
if ( ! data || ! data.type )
|
|
||||||
return null;
|
|
||||||
|
|
||||||
if ( data.type === 'blob' )
|
|
||||||
return await deserializeBlobUrl(data.url);
|
|
||||||
|
|
||||||
if ( data.type === 'file' )
|
|
||||||
return new File(
|
|
||||||
[await deserializeBlobUrl(data.url)],
|
|
||||||
data.name,
|
|
||||||
{type: data.mime, lastModified: data.modified}
|
|
||||||
);
|
|
||||||
|
|
||||||
if ( data.type === 'ab' )
|
|
||||||
return await deserializeABUrl(data.url);
|
|
||||||
|
|
||||||
if ( data.type === 'u8' )
|
|
||||||
return new Uint8Array(await deserializeABUrl(data.url));
|
|
||||||
|
|
||||||
throw new TypeError('Invalid type');
|
|
||||||
}
|
|
104
src/utilities/blobs.ts
Normal file
104
src/utilities/blobs.ts
Normal file
|
@ -0,0 +1,104 @@
|
||||||
|
|
||||||
|
/** A union of the various Blob types that are supported. */
|
||||||
|
export type BlobLike = Blob | File | ArrayBuffer | Uint8Array;
|
||||||
|
|
||||||
|
/** A union of the various serialized blob types. */
|
||||||
|
export type SerializedBlobLike = SerializedBlob | SerializedFile | SerializedArrayBuffer | SerializedUint8Array;
|
||||||
|
|
||||||
|
/** A serialized {@link Blob} representation. */
|
||||||
|
export type SerializedBlob = {
|
||||||
|
type: 'blob';
|
||||||
|
mime: string;
|
||||||
|
buffer: ArrayBuffer
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A serialized {@link File} representation. */
|
||||||
|
export type SerializedFile = {
|
||||||
|
type: 'file';
|
||||||
|
mime: string;
|
||||||
|
name: string;
|
||||||
|
modified: number;
|
||||||
|
buffer: ArrayBuffer
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A serialized {@link ArrayBuffer} representation. */
|
||||||
|
export type SerializedArrayBuffer = {
|
||||||
|
type: 'ab';
|
||||||
|
buffer: ArrayBuffer;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A serialized {@link Uint8Array} representation. */
|
||||||
|
export type SerializedUint8Array = {
|
||||||
|
type: 'u8',
|
||||||
|
buffer: ArrayBuffer
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the provided object is a valid Blob that can be serialized
|
||||||
|
* for transmission via a messaging API.
|
||||||
|
*/
|
||||||
|
export function isValidBlob(blob: any): blob is BlobLike {
|
||||||
|
return blob instanceof Blob || blob instanceof File || blob instanceof ArrayBuffer || blob instanceof Uint8Array;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serialize the provided {@link BlobLike} object into a format that can be
|
||||||
|
* transmitted via a messaging API.
|
||||||
|
*/
|
||||||
|
export async function serializeBlob(blob?: BlobLike): Promise<SerializedBlobLike | null> {
|
||||||
|
if ( ! blob )
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if ( blob instanceof Blob )
|
||||||
|
return {
|
||||||
|
type: 'blob',
|
||||||
|
mime: blob.type,
|
||||||
|
buffer: await blob.arrayBuffer(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( blob instanceof File )
|
||||||
|
return {
|
||||||
|
type: 'file',
|
||||||
|
mime: blob.type,
|
||||||
|
name: blob.name,
|
||||||
|
modified: blob.lastModified,
|
||||||
|
buffer: await blob.arrayBuffer()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( blob instanceof ArrayBuffer )
|
||||||
|
return {
|
||||||
|
type: 'ab',
|
||||||
|
buffer: blob
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( blob instanceof Uint8Array )
|
||||||
|
return {
|
||||||
|
type: 'u8',
|
||||||
|
buffer: blob.buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new TypeError('Invalid type');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deserialize the provided {@link SerializedBlobLike} object into a copy of
|
||||||
|
* the original {@link BlobLike}.
|
||||||
|
*/
|
||||||
|
export function deserializeBlob(data: SerializedBlobLike): BlobLike | null {
|
||||||
|
if ( ! data || ! data.type )
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if ( data.type === 'blob' )
|
||||||
|
return new Blob([data.buffer], {type: data.mime});
|
||||||
|
|
||||||
|
if ( data.type === 'file' )
|
||||||
|
return new File([data.buffer], data.name, {type: data.mime, lastModified: data.modified});
|
||||||
|
|
||||||
|
if ( data.type === 'ab' )
|
||||||
|
return data.buffer;
|
||||||
|
|
||||||
|
if ( data.type === 'u8' )
|
||||||
|
return new Uint8Array(data.buffer);
|
||||||
|
|
||||||
|
throw new TypeError('Invalid type');
|
||||||
|
}
|
|
@ -1,669 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
export function hue2rgb(p, q, t) {
|
|
||||||
if ( t < 0 ) t += 1;
|
|
||||||
if ( t > 1 ) t -= 1;
|
|
||||||
if ( t < 1/6 )
|
|
||||||
return p + (q-p) * 6 * t;
|
|
||||||
if ( t < 1/2 )
|
|
||||||
return q;
|
|
||||||
if ( t < 2/3 )
|
|
||||||
return p + (q-p) * (2/3 - t) * 6;
|
|
||||||
return p;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function bit2linear(channel) {
|
|
||||||
// http://www.brucelindbloom.com/Eqn_RGB_to_XYZ.html
|
|
||||||
// This converts rgb 8bit to rgb linear, lazy because the other algorithm is really really dumb
|
|
||||||
//return Math.pow(channel, 2.2);
|
|
||||||
|
|
||||||
// CSS Colors Level 4 says 0.03928, Bruce Lindbloom who cared to write all algos says 0.04045, used bruce because whynawt
|
|
||||||
return (channel <= 0.04045) ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function linear2bit(channel) {
|
|
||||||
// Using lazy conversion in the other direction as well
|
|
||||||
//return Math.pow(channel, 1/2.2);
|
|
||||||
|
|
||||||
// I'm honestly not sure about 0.0031308, I've only seen it referenced on Bruce Lindbloom's site
|
|
||||||
return (channel <= 0.0031308) ? channel * 12.92 : Math.pow(1.055 * channel, 1/2.4) - 0.055;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const Color = {};
|
|
||||||
|
|
||||||
Color._canvas = null;
|
|
||||||
Color._context = null;
|
|
||||||
|
|
||||||
Color.CVDMatrix = {
|
|
||||||
protanope: [ // reds are greatly reduced (1% men)
|
|
||||||
0.0, 2.02344, -2.52581,
|
|
||||||
0.0, 1.0, 0.0,
|
|
||||||
0.0, 0.0, 1.0
|
|
||||||
],
|
|
||||||
deuteranope: [ // greens are greatly reduced (1% men)
|
|
||||||
1.0, 0.0, 0.0,
|
|
||||||
0.494207, 0.0, 1.24827,
|
|
||||||
0.0, 0.0, 1.0
|
|
||||||
],
|
|
||||||
tritanope: [ // blues are greatly reduced (0.003% population)
|
|
||||||
1.0, 0.0, 0.0,
|
|
||||||
0.0, 1.0, 0.0,
|
|
||||||
-0.395913, 0.801109, 0.0
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const RGBAColor = Color.RGBA = function(r, g, b, a) {
|
|
||||||
this.r = r||0; this.g = g||0; this.b = b||0; this.a = a||0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const HSVAColor = Color.HSVA = function(h, s, v, a) {
|
|
||||||
this.h = h||0; this.s = s||0; this.v = v||0; this.a = a||0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const HSLAColor = Color.HSLA = function(h, s, l, a) {
|
|
||||||
this.h = h||0; this.s = s||0; this.l = l||0; this.a = a||0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const XYZAColor = Color.XYZA = function(x, y, z, a) {
|
|
||||||
this.x = x||0; this.y = y||0; this.z = z||0; this.a = a||0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const LUVAColor = Color.LUVA = function(l, u, v, a) {
|
|
||||||
this.l = l||0; this.u = u||0; this.v = v||0; this.a = a||0;
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
// RGBA Colors
|
|
||||||
|
|
||||||
RGBAColor.prototype.eq = function(rgb) {
|
|
||||||
return rgb.r === this.r && rgb.g === this.g && rgb.b === this.b && rgb.a === this.a;
|
|
||||||
}
|
|
||||||
|
|
||||||
RGBAColor.fromName = function(name) {
|
|
||||||
let context = Color._context;
|
|
||||||
if ( ! context ) {
|
|
||||||
const canvas = Color._canvas = document.createElement('canvas');
|
|
||||||
context = Color._context = canvas.getContext('2d');
|
|
||||||
}
|
|
||||||
|
|
||||||
context.clearRect(0,0,1,1);
|
|
||||||
context.fillStyle = name;
|
|
||||||
context.fillRect(0,0,1,1);
|
|
||||||
const data = context.getImageData(0,0,1,1);
|
|
||||||
|
|
||||||
if ( ! data || ! data.data || data.data.length !== 4 )
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return new RGBAColor(data.data[0], data.data[1], data.data[2], data.data[3] / 255);
|
|
||||||
}
|
|
||||||
|
|
||||||
RGBAColor.fromCSS = function(rgb) {
|
|
||||||
if ( ! rgb )
|
|
||||||
return null;
|
|
||||||
|
|
||||||
rgb = rgb.trim();
|
|
||||||
|
|
||||||
if ( rgb.charAt(0) === '#' )
|
|
||||||
return RGBAColor.fromHex(rgb);
|
|
||||||
|
|
||||||
const match = /rgba?\( *(\d+%?) *, *(\d+%?) *, *(\d+%?) *(?:, *([\d.]+))?\)/i.exec(rgb);
|
|
||||||
if ( match ) {
|
|
||||||
let r = match[1],
|
|
||||||
g = match[2],
|
|
||||||
b = match[3],
|
|
||||||
a = match[4];
|
|
||||||
|
|
||||||
if ( r.charAt(r.length-1) === '%' )
|
|
||||||
r = 255 * (parseInt(r,10) / 100);
|
|
||||||
else
|
|
||||||
r = parseInt(r,10);
|
|
||||||
|
|
||||||
if ( g.charAt(g.length-1) === '%' )
|
|
||||||
g = 255 * (parseInt(g,10) / 100);
|
|
||||||
else
|
|
||||||
g = parseInt(g,10);
|
|
||||||
|
|
||||||
if ( b.charAt(b.length-1) === '%' )
|
|
||||||
b = 255 * (parseInt(b,10) / 100);
|
|
||||||
else
|
|
||||||
b = parseInt(b,10);
|
|
||||||
|
|
||||||
if ( a )
|
|
||||||
if ( a.charAt(a.length-1) === '%' )
|
|
||||||
a = parseInt(a,10) / 100;
|
|
||||||
else
|
|
||||||
a = parseFloat(a);
|
|
||||||
else
|
|
||||||
a = 1;
|
|
||||||
|
|
||||||
return new RGBAColor(
|
|
||||||
Math.min(Math.max(0, r), 255),
|
|
||||||
Math.min(Math.max(0, g), 255),
|
|
||||||
Math.min(Math.max(0, b), 255),
|
|
||||||
Math.min(Math.max(0, a), 1)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return RGBAColor.fromName(rgb);
|
|
||||||
}
|
|
||||||
|
|
||||||
RGBAColor.fromHex = function(code, alpha = 1) {
|
|
||||||
if ( code.charAt(0) === '#' )
|
|
||||||
code = code.slice(1);
|
|
||||||
|
|
||||||
if ( code.length === 3 )
|
|
||||||
code = `${code[0]}${code[0]}${code[1]}${code[1]}${code[2]}${code[2]}`;
|
|
||||||
|
|
||||||
else if ( code.length === 4 )
|
|
||||||
code = `${code[0]}${code[0]}${code[1]}${code[1]}${code[2]}${code[2]}${code[3]}${code[3]}`;
|
|
||||||
|
|
||||||
if ( code.length === 8 ) {
|
|
||||||
alpha = parseInt(code.slice(6), 16) / 255;
|
|
||||||
code = code.slice(0, 6);
|
|
||||||
|
|
||||||
} else if ( code.length !== 6 )
|
|
||||||
throw new Error('invalid hex code');
|
|
||||||
|
|
||||||
const raw = parseInt(code, 16);
|
|
||||||
return new RGBAColor(
|
|
||||||
(raw >> 16), // Red
|
|
||||||
(raw >> 8 & 0x00FF), // Green
|
|
||||||
(raw & 0x0000FF), // Blue,
|
|
||||||
alpha // Alpha
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
RGBAColor.fromHSVA = function(h, s, v, a) {
|
|
||||||
let r, g, b;
|
|
||||||
|
|
||||||
const i = Math.floor(h * 6),
|
|
||||||
f = h * 6 - i,
|
|
||||||
p = v * (1 - s),
|
|
||||||
q = v * (1 - f * s),
|
|
||||||
t = v * (1 - (1 - f) * s);
|
|
||||||
|
|
||||||
switch(i % 6) {
|
|
||||||
case 0: r = v; g = t; b = p; break;
|
|
||||||
case 1: r = q; g = v; b = p; break;
|
|
||||||
case 2: r = p; g = v; b = t; break;
|
|
||||||
case 3: r = p; g = q; b = v; break;
|
|
||||||
case 4: r = t; g = p; b = v; break;
|
|
||||||
case 5: r = v; g = p; b = q;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new RGBAColor(
|
|
||||||
Math.round(Math.min(Math.max(0, r*255), 255)),
|
|
||||||
Math.round(Math.min(Math.max(0, g*255), 255)),
|
|
||||||
Math.round(Math.min(Math.max(0, b*255), 255)),
|
|
||||||
a === undefined ? 1 : a
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
RGBAColor.fromXYZA = function(x, y, z, a) {
|
|
||||||
const R = 3.240479 * x - 1.537150 * y - 0.498535 * z,
|
|
||||||
G = -0.969256 * x + 1.875992 * y + 0.041556 * z,
|
|
||||||
B = 0.055648 * x - 0.204043 * y + 1.057311 * z;
|
|
||||||
|
|
||||||
// Make sure we end up in a real color space
|
|
||||||
return new RGBAColor(
|
|
||||||
Math.max(0, Math.min(255, 255 * linear2bit(R))),
|
|
||||||
Math.max(0, Math.min(255, 255 * linear2bit(G))),
|
|
||||||
Math.max(0, Math.min(255, 255 * linear2bit(B))),
|
|
||||||
a === undefined ? 1 : a
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
RGBAColor.fromHSLA = function(h, s, l, a) {
|
|
||||||
if ( s === 0 ) {
|
|
||||||
const v = Math.round(Math.min(Math.max(0, 255*l), 255));
|
|
||||||
return new RGBAColor(v, v, v, a === undefined ? 1 : a);
|
|
||||||
}
|
|
||||||
|
|
||||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s,
|
|
||||||
p = 2 * l - q;
|
|
||||||
|
|
||||||
return new RGBAColor(
|
|
||||||
Math.round(Math.min(Math.max(0, 255 * hue2rgb(p, q, h + 1/3)), 255)),
|
|
||||||
Math.round(Math.min(Math.max(0, 255 * hue2rgb(p, q, h)), 255)),
|
|
||||||
Math.round(Math.min(Math.max(0, 255 * hue2rgb(p, q, h - 1/3)), 255)),
|
|
||||||
a === undefined ? 1 : a
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
RGBAColor.prototype.toRGBA = function() { return this; }
|
|
||||||
RGBAColor.prototype.toHSVA = function() { return HSVAColor.fromRGBA(this.r, this.g, this.b, this.a); }
|
|
||||||
RGBAColor.prototype.toHSLA = function() { return HSLAColor.fromRGBA(this.r, this.g, this.b, this.a); }
|
|
||||||
RGBAColor.prototype.toCSS = function() { return `rgb${this.a !== 1 ? 'a' : ''}(${Math.round(this.r)},${Math.round(this.g)},${Math.round(this.b)}${this.a !== 1 ? `,${this.a}` : ''})`; }
|
|
||||||
RGBAColor.prototype.toXYZA = function() { return XYZAColor.fromRGBA(this.r, this.g, this.b, this.a); }
|
|
||||||
RGBAColor.prototype.toLUVA = function() { return this.toXYZA().toLUVA(); }
|
|
||||||
|
|
||||||
RGBAColor.prototype.toHex = function() {
|
|
||||||
const rgb = this.b | (this.g << 8) | (this.r << 16);
|
|
||||||
return `#${(0x1000000 + rgb).toString(16).slice(1)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
RGBAColor.prototype.get_Y = function() {
|
|
||||||
return ((0.299 * this.r) + ( 0.587 * this.g) + ( 0.114 * this.b)) / 255;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
RGBAColor.prototype.luminance = function() {
|
|
||||||
const r = bit2linear(this.r / 255),
|
|
||||||
g = bit2linear(this.g / 255),
|
|
||||||
b = bit2linear(this.b / 255);
|
|
||||||
|
|
||||||
return (0.2126 * r) + (0.7152 * g) + (0.0722 * b);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
RGBAColor.prototype.brighten = function(amount) {
|
|
||||||
amount = typeof amount === `number` ? amount : 1;
|
|
||||||
amount = Math.round(255 * (amount / 100));
|
|
||||||
|
|
||||||
return new RGBAColor(
|
|
||||||
Math.max(0, Math.min(255, this.r + amount)),
|
|
||||||
Math.max(0, Math.min(255, this.g + amount)),
|
|
||||||
Math.max(0, Math.min(255, this.b + amount)),
|
|
||||||
this.a
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
RGBAColor.prototype.daltonize = function(type) {
|
|
||||||
let cvd;
|
|
||||||
if ( typeof type === 'string' ) {
|
|
||||||
if ( Color.CVDMatrix.hasOwnProperty(type) )
|
|
||||||
cvd = Color.CVDMatrix[type];
|
|
||||||
else
|
|
||||||
throw new Error('Invalid CVD matrix');
|
|
||||||
} else
|
|
||||||
cvd = type;
|
|
||||||
|
|
||||||
const cvd_a = cvd[0], cvd_b = cvd[1], cvd_c = cvd[2],
|
|
||||||
cvd_d = cvd[3], cvd_e = cvd[4], cvd_f = cvd[5],
|
|
||||||
cvd_g = cvd[6], cvd_h = cvd[7], cvd_i = cvd[8];
|
|
||||||
|
|
||||||
//let L, M, S, l, m, s, R, G, B, RR, GG, BB;
|
|
||||||
|
|
||||||
// RGB to LMS matrix conversion
|
|
||||||
const L = (17.8824 * this.r) + (43.5161 * this.g) + (4.11935 * this.b),
|
|
||||||
M = (3.45565 * this.r) + (27.1554 * this.g) + (3.86714 * this.b),
|
|
||||||
S = (0.0299566 * this.r) + (0.184309 * this.g) + (1.46709 * this.b);
|
|
||||||
|
|
||||||
// Simulate color blindness
|
|
||||||
const l = (cvd_a * L) + (cvd_b * M) + (cvd_c * S),
|
|
||||||
m = (cvd_d * L) + (cvd_e * M) + (cvd_f * S),
|
|
||||||
s = (cvd_g * L) + (cvd_h * M) + (cvd_i * S);
|
|
||||||
|
|
||||||
// LMS to RGB matrix conversion
|
|
||||||
let R = (0.0809444479 * l) + (-0.130504409 * m) + (0.116721066 * s),
|
|
||||||
G = (-0.0102485335 * l) + (0.0540193266 * m) + (-0.113614708 * s),
|
|
||||||
B = (-0.000365296938 * l) + (-0.00412161469 * m) + (0.693511405 * s);
|
|
||||||
|
|
||||||
// Isolate invisible colors to color vision deficiency (calculate error matrix)
|
|
||||||
R = this.r - R;
|
|
||||||
G = this.g - G;
|
|
||||||
B = this.b - B;
|
|
||||||
|
|
||||||
// Shift colors towards visible spectrum (apply error modifications)
|
|
||||||
const RR = (0.0 * R) + (0.0 * G) + (0.0 * B),
|
|
||||||
GG = (0.7 * R) + (1.0 * G) + (0.0 * B),
|
|
||||||
BB = (0.7 * R) + (0.0 * G) + (1.0 * B);
|
|
||||||
|
|
||||||
// Add compensation to original values
|
|
||||||
R = Math.min(Math.max(0, RR + this.r), 255);
|
|
||||||
G = Math.min(Math.max(0, GG + this.g), 255);
|
|
||||||
B = Math.min(Math.max(0, BB + this.b), 255);
|
|
||||||
|
|
||||||
return new RGBAColor(R, G, B, this.a);
|
|
||||||
}
|
|
||||||
|
|
||||||
RGBAColor.prototype._r = function(r) { return new RGBAColor(r, this.g, this.b, this.a); }
|
|
||||||
RGBAColor.prototype._g = function(g) { return new RGBAColor(this.r, g, this.b, this.a); }
|
|
||||||
RGBAColor.prototype._b = function(b) { return new RGBAColor(this.r, this.g, b, this.a); }
|
|
||||||
RGBAColor.prototype._a = function(a) { return new RGBAColor(this.r, this.g, this.b, a); }
|
|
||||||
|
|
||||||
|
|
||||||
// HSL Colors
|
|
||||||
|
|
||||||
HSLAColor.prototype.eq = function(hsl) {
|
|
||||||
return hsl.h === this.h && hsl.s === this.s && hsl.l === this.l && hsl.a === this.a;
|
|
||||||
}
|
|
||||||
|
|
||||||
HSLAColor.fromRGBA = function(r, g, b, a) {
|
|
||||||
r /= 255; g /= 255; b /= 255;
|
|
||||||
|
|
||||||
const max = Math.max(r,g,b),
|
|
||||||
min = Math.min(r,g,b),
|
|
||||||
|
|
||||||
l = Math.min(Math.max(0, (max+min) / 2), 1),
|
|
||||||
d = Math.min(Math.max(0, max - min), 1);
|
|
||||||
|
|
||||||
let h, s;
|
|
||||||
|
|
||||||
if ( d === 0 )
|
|
||||||
h = s = 0;
|
|
||||||
else {
|
|
||||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
|
||||||
switch(max) {
|
|
||||||
case r:
|
|
||||||
h = (g - b) / d + (g < b ? 6 : 0);
|
|
||||||
break;
|
|
||||||
case g:
|
|
||||||
h = (b - r) / d + 2;
|
|
||||||
break;
|
|
||||||
case b:
|
|
||||||
h = (r - g) / d + 4;
|
|
||||||
}
|
|
||||||
h /= 6;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new HSLAColor(h, s, l, a === undefined ? 1 : a);
|
|
||||||
}
|
|
||||||
|
|
||||||
HSLAColor.prototype.targetLuminance = function (target) {
|
|
||||||
let s = this.s,
|
|
||||||
min = 0,
|
|
||||||
max = 1;
|
|
||||||
|
|
||||||
s *= Math.pow(this.l > 0.5 ? -this.l : this.l - 1, 7) + 1;
|
|
||||||
|
|
||||||
let d = (max - min) / 2,
|
|
||||||
mid = min + d;
|
|
||||||
|
|
||||||
for (; d > 1/65536; d /= 2, mid = min + d) {
|
|
||||||
const luminance = RGBAColor.fromHSLA(this.h, s, mid, 1).luminance()
|
|
||||||
if (luminance > target) {
|
|
||||||
max = mid;
|
|
||||||
} else {
|
|
||||||
min = mid;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new HSLAColor(this.h, s, mid, this.a);
|
|
||||||
}
|
|
||||||
|
|
||||||
HSLAColor.prototype.toRGBA = function() { return RGBAColor.fromHSLA(this.h, this.s, this.l, this.a); }
|
|
||||||
HSLAColor.prototype.toCSS = function() { return `hsl${this.a !== 1 ? 'a' : ''}(${Math.round(this.h*360)},${Math.round(this.s*100)}%,${Math.round(this.l*100)}%${this.a !== 1 ? `,${this.a}` : ''})`; }
|
|
||||||
HSLAColor.prototype.toHSLA = function() { return this; }
|
|
||||||
HSLAColor.prototype.toHSVA = function() { return this.toRGBA().toHSVA(); }
|
|
||||||
HSLAColor.prototype.toXYZA = function() { return this.toRGBA().toXYZA(); }
|
|
||||||
HSLAColor.prototype.toLUVA = function() { return this.toRGBA().toLUVA(); }
|
|
||||||
|
|
||||||
|
|
||||||
HSLAColor.prototype._h = function(h) { return new HSLAColor(h, this.s, this.l, this.a); }
|
|
||||||
HSLAColor.prototype._s = function(s) { return new HSLAColor(this.h, s, this.l, this.a); }
|
|
||||||
HSLAColor.prototype._l = function(l) { return new HSLAColor(this.h, this.s, l, this.a); }
|
|
||||||
HSLAColor.prototype._a = function(a) { return new HSLAColor(this.h, this.s, this.l, a); }
|
|
||||||
|
|
||||||
|
|
||||||
// HSV Colors
|
|
||||||
|
|
||||||
HSVAColor.prototype.eq = function(hsv) { return hsv.h === this.h && hsv.s === this.s && hsv.v === this.v && hsv.a === this.a; }
|
|
||||||
|
|
||||||
HSVAColor.fromRGBA = function(r, g, b, a) {
|
|
||||||
r /= 255; g /= 255; b /= 255;
|
|
||||||
|
|
||||||
const max = Math.max(r, g, b),
|
|
||||||
min = Math.min(r, g, b),
|
|
||||||
d = Math.min(Math.max(0, max - min), 1),
|
|
||||||
|
|
||||||
s = max === 0 ? 0 : d / max,
|
|
||||||
v = max;
|
|
||||||
|
|
||||||
let h;
|
|
||||||
|
|
||||||
if ( d === 0 )
|
|
||||||
h = 0;
|
|
||||||
else {
|
|
||||||
switch(max) {
|
|
||||||
case r:
|
|
||||||
h = (g - b) / d + (g < b ? 6 : 0);
|
|
||||||
break;
|
|
||||||
case g:
|
|
||||||
h = (b - r) / d + 2;
|
|
||||||
break;
|
|
||||||
case b:
|
|
||||||
h = (r - g) / d + 4;
|
|
||||||
}
|
|
||||||
h /= 6;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new HSVAColor(h, s, v, a === undefined ? 1 : a);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
HSVAColor.prototype.toRGBA = function() { return RGBAColor.fromHSVA(this.h, this.s, this.v, this.a); }
|
|
||||||
HSVAColor.prototype.toHSVA = function() { return this; }
|
|
||||||
HSVAColor.prototype.toHSLA = function() { return this.toRGBA().toHSLA(); }
|
|
||||||
HSVAColor.prototype.toXYZA = function() { return this.toRGBA().toXYZA(); }
|
|
||||||
HSVAColor.prototype.toLUVA = function() { return this.toRGBA().toLUVA(); }
|
|
||||||
|
|
||||||
|
|
||||||
HSVAColor.prototype._h = function(h) { return new HSVAColor(h, this.s, this.v, this.a); }
|
|
||||||
HSVAColor.prototype._s = function(s) { return new HSVAColor(this.h, s, this.v, this.a); }
|
|
||||||
HSVAColor.prototype._v = function(v) { return new HSVAColor(this.h, this.s, v, this.a); }
|
|
||||||
HSVAColor.prototype._a = function(a) { return new HSVAColor(this.h, this.s, this.v, a); }
|
|
||||||
|
|
||||||
|
|
||||||
// XYZ Colors
|
|
||||||
|
|
||||||
XYZAColor.prototype.eq = function(xyz) { return xyz.x === this.x && xyz.y === this.y && xyz.z === this.z; }
|
|
||||||
|
|
||||||
XYZAColor.fromRGBA = function(r, g, b, a) {
|
|
||||||
const R = bit2linear(r / 255),
|
|
||||||
G = bit2linear(g / 255),
|
|
||||||
B = bit2linear(b / 255);
|
|
||||||
|
|
||||||
return new XYZAColor(
|
|
||||||
0.412453 * R + 0.357580 * G + 0.180423 * B,
|
|
||||||
0.212671 * R + 0.715160 * G + 0.072169 * B,
|
|
||||||
0.019334 * R + 0.119193 * G + 0.950227 * B,
|
|
||||||
a === undefined ? 1 : a
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
XYZAColor.fromLUVA = function(l, u, v, alpha) {
|
|
||||||
const deltaGammaFactor = 1 / (XYZAColor.WHITE.x + 15 * XYZAColor.WHITE.y + 3 * XYZAColor.WHITE.z),
|
|
||||||
uDeltaGamma = 4 * XYZAColor.WHITE.x * deltaGammaFactor,
|
|
||||||
vDeltagamma = 9 * XYZAColor.WHITE.y * deltaGammaFactor;
|
|
||||||
|
|
||||||
// XYZAColor.EPSILON * XYZAColor.KAPPA = 8
|
|
||||||
const Y = (l > 8) ? Math.pow((l + 16) / 116, 3) : l / XYZAColor.KAPPA,
|
|
||||||
a = 1/3 * (((52 * l) / (u + 13 * l * uDeltaGamma)) - 1),
|
|
||||||
b = -5 * Y,
|
|
||||||
c = -1/3,
|
|
||||||
d = Y * (((39 * l) / (v + 13 * l * vDeltagamma)) - 5),
|
|
||||||
|
|
||||||
X = (d - b) / (a - c),
|
|
||||||
Z = X * a + b;
|
|
||||||
|
|
||||||
return new XYZAColor(X, Y, Z, alpha === undefined ? 1 : alpha);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
XYZAColor.prototype.toRGBA = function() { return RGBAColor.fromXYZA(this.x, this.y, this.z, this.a); }
|
|
||||||
XYZAColor.prototype.toLUVA = function() { return LUVAColor.fromXYZA(this.x, this.y, this.z, this.a); }
|
|
||||||
XYZAColor.prototype.toHSLA = function() { return this.toRGBA().toHSLA(); }
|
|
||||||
XYZAColor.prototype.toHSVA = function() { return this.toRGBA().toHSVA(); }
|
|
||||||
XYZAColor.prototype.toXYZA = function() { return this; }
|
|
||||||
|
|
||||||
|
|
||||||
XYZAColor.prototype._x = function(x) { return new XYZAColor(x, this.y, this.z, this.a); }
|
|
||||||
XYZAColor.prototype._y = function(y) { return new XYZAColor(this.x, y, this.z, this.a); }
|
|
||||||
XYZAColor.prototype._z = function(z) { return new XYZAColor(this.x, this.y, z, this.a); }
|
|
||||||
XYZAColor.prototype._a = function(a) { return new XYZAColor(this.x, this.y, this.z, a); }
|
|
||||||
|
|
||||||
|
|
||||||
// LUV Colors
|
|
||||||
|
|
||||||
XYZAColor.EPSILON = Math.pow(6 / 29, 3);
|
|
||||||
XYZAColor.KAPPA = Math.pow(29 / 3, 3);
|
|
||||||
XYZAColor.WHITE = (new RGBAColor(255, 255, 255, 1)).toXYZA();
|
|
||||||
|
|
||||||
|
|
||||||
LUVAColor.prototype.eq = function(luv) { return luv.l === this.l && luv.u === this.u && luv.v === this.v; }
|
|
||||||
|
|
||||||
LUVAColor.fromXYZA = function(X, Y, Z, a) {
|
|
||||||
const deltaGammaFactor = 1 / (XYZAColor.WHITE.x + 15 * XYZAColor.WHITE.y + 3 * XYZAColor.WHITE.z),
|
|
||||||
uDeltaGamma = 4 * XYZAColor.WHITE.x * deltaGammaFactor,
|
|
||||||
vDeltagamma = 9 * XYZAColor.WHITE.y * deltaGammaFactor,
|
|
||||||
|
|
||||||
yGamma = Y / XYZAColor.WHITE.y;
|
|
||||||
|
|
||||||
let deltaDivider = (X + 15 * Y + 3 * Z);
|
|
||||||
if (deltaDivider === 0) {
|
|
||||||
deltaDivider = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const deltaFactor = 1 / deltaDivider,
|
|
||||||
|
|
||||||
uDelta = 4 * X * deltaFactor,
|
|
||||||
vDelta = 9 * Y * deltaFactor,
|
|
||||||
|
|
||||||
L = (yGamma > XYZAColor.EPSILON) ? 116 * Math.pow(yGamma, 1/3) - 16 : XYZAColor.KAPPA * yGamma,
|
|
||||||
u = 13 * L * (uDelta - uDeltaGamma),
|
|
||||||
v = 13 * L * (vDelta - vDeltagamma);
|
|
||||||
|
|
||||||
return new LUVAColor(L, u, v, a === undefined ? 1 : a);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
LUVAColor.prototype.toXYZA = function() { return XYZAColor.fromLUVA(this.l, this.u, this.v, this.a); }
|
|
||||||
LUVAColor.prototype.toRGBA = function() { return this.toXYZA().toRGBA(); }
|
|
||||||
LUVAColor.prototype.toHSLA = function() { return this.toXYZA().toHSLA(); }
|
|
||||||
LUVAColor.prototype.toHSVA = function() { return this.toXYZA().toHSVA(); }
|
|
||||||
LUVAColor.prototype.toLUVA = function() { return this; }
|
|
||||||
|
|
||||||
|
|
||||||
LUVAColor.prototype._l = function(l) { return new LUVAColor(l, this.u, this.v, this.a); }
|
|
||||||
LUVAColor.prototype._u = function(u) { return new LUVAColor(this.l, u, this.v, this.a); }
|
|
||||||
LUVAColor.prototype._v = function(v) { return new LUVAColor(this.l, this.u, v, this.a); }
|
|
||||||
LUVAColor.prototype._a = function(a) { return new LUVAColor(this.l, this.u, this.v, a); }
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export class ColorAdjuster {
|
|
||||||
constructor(base = '#232323', mode = 0, contrast = 4.5) {
|
|
||||||
this._contrast = contrast;
|
|
||||||
this._base = base;
|
|
||||||
this._mode = mode;
|
|
||||||
|
|
||||||
this.rebuildContrast();
|
|
||||||
}
|
|
||||||
|
|
||||||
get contrast() { return this._contrast }
|
|
||||||
set contrast(val) { this._contrast = val; this.rebuildContrast() }
|
|
||||||
|
|
||||||
get base() { return this._base }
|
|
||||||
set base(val) { this._base = val; this.rebuildContrast() }
|
|
||||||
|
|
||||||
get dark() { return this._dark }
|
|
||||||
|
|
||||||
get mode() { return this._mode }
|
|
||||||
set mode(val) { this._mode = val; this.rebuildContrast() }
|
|
||||||
|
|
||||||
|
|
||||||
rebuildContrast() {
|
|
||||||
this._cache = new Map;
|
|
||||||
|
|
||||||
const base = RGBAColor.fromCSS(this._base),
|
|
||||||
lum = base.luminance();
|
|
||||||
|
|
||||||
const dark = this._dark = lum < 0.5;
|
|
||||||
|
|
||||||
if ( dark ) {
|
|
||||||
this._luv = new XYZAColor(
|
|
||||||
0,
|
|
||||||
(this._contrast * (base.toXYZA().y + 0.05) - 0.05),
|
|
||||||
0,
|
|
||||||
1
|
|
||||||
).toLUVA().l;
|
|
||||||
|
|
||||||
this._luma = this._contrast * (base.luminance() + 0.05) - 0.05;
|
|
||||||
|
|
||||||
} else {
|
|
||||||
this._luv = new XYZAColor(
|
|
||||||
0,
|
|
||||||
(base.toXYZA().y + 0.05) / this._contrast - 0.05,
|
|
||||||
0,
|
|
||||||
1
|
|
||||||
).toLUVA().l;
|
|
||||||
|
|
||||||
this._luma = (base.luminance() + 0.05) / this._contrast - 0.05;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
process(color, throw_errors = false) {
|
|
||||||
if ( this._mode === -1 )
|
|
||||||
return '';
|
|
||||||
else if ( this._mode === 0 )
|
|
||||||
return color;
|
|
||||||
|
|
||||||
if ( color instanceof RGBAColor )
|
|
||||||
color = color.toCSS();
|
|
||||||
|
|
||||||
if ( ! color )
|
|
||||||
return null;
|
|
||||||
|
|
||||||
if ( this._cache.has(color) )
|
|
||||||
return this._cache.get(color);
|
|
||||||
|
|
||||||
let rgb;
|
|
||||||
|
|
||||||
try {
|
|
||||||
rgb = RGBAColor.fromCSS(color);
|
|
||||||
} catch(err) {
|
|
||||||
if ( throw_errors )
|
|
||||||
throw err;
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( this._mode === 1 ) {
|
|
||||||
// HSL Luma
|
|
||||||
const luma = rgb.luminance();
|
|
||||||
|
|
||||||
if ( this._dark ? luma < this._luma : luma > this._luma )
|
|
||||||
rgb = rgb.toHSLA().targetLuminance(this._luma).toRGBA();
|
|
||||||
|
|
||||||
} else if ( this._mode === 2 ) {
|
|
||||||
// LUV
|
|
||||||
const luv = rgb.toLUVA();
|
|
||||||
if ( this._dark ? luv.l < this._luv : luv.l > this._luv )
|
|
||||||
rgb = luv._l(this._luv).toRGBA();
|
|
||||||
|
|
||||||
} else if ( this._mode === 3 ) {
|
|
||||||
// HSL Loop (aka BTTV Style)
|
|
||||||
if ( this._dark )
|
|
||||||
while ( rgb.get_Y() < 0.5 ) {
|
|
||||||
const hsl = rgb.toHSLA();
|
|
||||||
rgb = hsl._l(Math.min(Math.max(0, 0.1 + 0.9 * hsl.l), 1)).toRGBA();
|
|
||||||
}
|
|
||||||
|
|
||||||
else
|
|
||||||
while ( rgb.get_Y() >= 0.5 ) {
|
|
||||||
const hsl = rgb.toHSLA();
|
|
||||||
rgb = hsl._l(Math.min(Math.max(0, 0.9 * hsl.l), 1)).toRGBA();
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if ( this._mode === 4 ) {
|
|
||||||
// RGB Loop
|
|
||||||
let i = 0;
|
|
||||||
if ( this._dark )
|
|
||||||
while ( rgb.luminance() < 0.15 && i++ < 127 )
|
|
||||||
rgb = rgb.brighten();
|
|
||||||
|
|
||||||
else
|
|
||||||
while ( rgb.luminance() > 0.3 && i++ < 127 )
|
|
||||||
rgb = rgb.brighten(-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const out = rgb.toCSS();
|
|
||||||
this._cache.set(color, out);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
}
|
|
895
src/utilities/color.ts
Normal file
895
src/utilities/color.ts
Normal file
|
@ -0,0 +1,895 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
export type CVDMatrix = [number, number, number, number, number, number, number, number, number];
|
||||||
|
|
||||||
|
export function hue2rgb(p: number, q: number, t: number) {
|
||||||
|
if ( t < 0 ) t += 1;
|
||||||
|
if ( t > 1 ) t -= 1;
|
||||||
|
if ( t < 1/6 )
|
||||||
|
return p + (q-p) * 6 * t;
|
||||||
|
if ( t < 1/2 )
|
||||||
|
return q;
|
||||||
|
if ( t < 2/3 )
|
||||||
|
return p + (q-p) * (2/3 - t) * 6;
|
||||||
|
return p;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bit2linear(channel: number) {
|
||||||
|
// http://www.brucelindbloom.com/Eqn_RGB_to_XYZ.html
|
||||||
|
// This converts rgb 8bit to rgb linear, lazy because the other algorithm is really really dumb
|
||||||
|
//return Math.pow(channel, 2.2);
|
||||||
|
|
||||||
|
// CSS Colors Level 4 says 0.03928, Bruce Lindbloom who cared to write all algos says 0.04045, used bruce because whynawt
|
||||||
|
return (channel <= 0.04045) ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function linear2bit(channel: number) {
|
||||||
|
// Using lazy conversion in the other direction as well
|
||||||
|
//return Math.pow(channel, 1/2.2);
|
||||||
|
|
||||||
|
// I'm honestly not sure about 0.0031308, I've only seen it referenced on Bruce Lindbloom's site
|
||||||
|
return (channel <= 0.0031308) ? channel * 12.92 : Math.pow(1.055 * channel, 1/2.4) - 0.055;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export interface BaseColor {
|
||||||
|
eq(other: BaseColor | null | undefined, ignoreAlpha: boolean): boolean;
|
||||||
|
|
||||||
|
toCSS(): string;
|
||||||
|
toHex(): string;
|
||||||
|
|
||||||
|
toRGBA(): RGBAColor;
|
||||||
|
toHSVA(): HSVAColor;
|
||||||
|
toHSLA(): HSLAColor;
|
||||||
|
toXYZA(): XYZAColor;
|
||||||
|
toLUVA(): LUVAColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RGBAColor implements BaseColor {
|
||||||
|
|
||||||
|
readonly r: number;
|
||||||
|
readonly g: number;
|
||||||
|
readonly b: number;
|
||||||
|
readonly a: number;
|
||||||
|
|
||||||
|
constructor(r: number, g: number, b: number, a?: number) {
|
||||||
|
this.r = r || 0;
|
||||||
|
this.g = g || 0;
|
||||||
|
this.b = b || 0;
|
||||||
|
this.a = a || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other?: BaseColor | null, ignoreAlpha = false): boolean {
|
||||||
|
if ( other instanceof RGBAColor )
|
||||||
|
return this.r === other.r && this.g === other.g && this.b === other.b && (ignoreAlpha || this.a === other.a);
|
||||||
|
return other ? this.eq(other.toRGBA(), ignoreAlpha) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Updates
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
_r(r: number) { return new RGBAColor(r, this.g, this.b, this.a); }
|
||||||
|
_g(g: number) { return new RGBAColor(this.r, g, this.b, this.a); }
|
||||||
|
_b(b: number) { return new RGBAColor(this.r, this.g, b, this.a); }
|
||||||
|
_a(a: number) { return new RGBAColor(this.r, this.g, this.b, a); }
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Conversion: to RGBA
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
static fromName(name: string) {
|
||||||
|
const ctx = Color.getContext();
|
||||||
|
ctx.clearRect(0, 0, 1, 1);
|
||||||
|
ctx.fillStyle = name;
|
||||||
|
ctx.fillRect(0, 0, 1, 1);
|
||||||
|
const data = ctx.getImageData(0, 0, 1, 1);
|
||||||
|
if ( data?.data?.length !== 4 )
|
||||||
|
return null;
|
||||||
|
|
||||||
|
return new RGBAColor(data.data[0], data.data[1], data.data[2], data.data[3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromCSS(input: string) {
|
||||||
|
input = input && input.trim();
|
||||||
|
if ( ! input?.length )
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if ( input.charAt(0) === '#' )
|
||||||
|
return RGBAColor.fromHex(input);
|
||||||
|
|
||||||
|
// fillStyle can handle rgba() inputs
|
||||||
|
/*const match = /rgba?\( *(\d+%?) *, *(\d+%?) *, *(\d+%?) *(?:, *([\d.]+))?\)/i.exec(input);
|
||||||
|
if ( match ) {
|
||||||
|
let r: number, g: number, b: number, a: number;
|
||||||
|
let rS = match[1],
|
||||||
|
gS = match[2],
|
||||||
|
bS = match[3],
|
||||||
|
aS = match[4];
|
||||||
|
|
||||||
|
if ( rS.charAt(rS.length-1) === '%' )
|
||||||
|
r = 255 * (parseInt(rS,10) / 100);
|
||||||
|
else
|
||||||
|
r = parseInt(rS,10);
|
||||||
|
|
||||||
|
if ( gS.charAt(gS.length-1) === '%' )
|
||||||
|
g = 255 * (parseInt(gS,10) / 100);
|
||||||
|
else
|
||||||
|
g = parseInt(gS,10);
|
||||||
|
|
||||||
|
if ( bS.charAt(bS.length-1) === '%' )
|
||||||
|
b = 255 * (parseInt(bS,10) / 100);
|
||||||
|
else
|
||||||
|
b = parseInt(bS,10);
|
||||||
|
|
||||||
|
if ( aS )
|
||||||
|
if ( aS.charAt(aS.length-1) === '%' )
|
||||||
|
a = parseInt(aS,10) / 100;
|
||||||
|
else
|
||||||
|
a = parseFloat(aS);
|
||||||
|
else
|
||||||
|
a = 1;
|
||||||
|
|
||||||
|
return new RGBAColorA(
|
||||||
|
Math.min(Math.max(0, r), 255),
|
||||||
|
Math.min(Math.max(0, g), 255),
|
||||||
|
Math.min(Math.max(0, b), 255),
|
||||||
|
Math.min(Math.max(0, a), 1)
|
||||||
|
);
|
||||||
|
}*/
|
||||||
|
|
||||||
|
return RGBAColor.fromName(input);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromHex(input: string) {
|
||||||
|
if ( input.charAt(0) === '#' )
|
||||||
|
input = input.slice(1);
|
||||||
|
|
||||||
|
let raw: number;
|
||||||
|
let alpha: number = 255;
|
||||||
|
|
||||||
|
if ( input.length === 4 ) {
|
||||||
|
alpha = parseInt(input[3], 16) * 17;
|
||||||
|
input = input.slice(0, 3);
|
||||||
|
} else if ( input.length === 8 ) {
|
||||||
|
alpha = parseInt(input.slice(6), 16);
|
||||||
|
input = input.slice(0, 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( input.length === 3 )
|
||||||
|
raw =
|
||||||
|
((parseInt(input[0], 16) * 17) << 16) +
|
||||||
|
((parseInt(input[1], 16) * 17) << 8) +
|
||||||
|
parseInt(input[2], 16) * 17;
|
||||||
|
|
||||||
|
else
|
||||||
|
raw = parseInt(input, 16);
|
||||||
|
|
||||||
|
return new RGBAColor(
|
||||||
|
(raw >> 16), // Red
|
||||||
|
(raw >> 8 & 0x00FF), // Green
|
||||||
|
(raw & 0xFF), // Blue
|
||||||
|
alpha / 255 // Alpha (scaled from 0 to 1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromHSVA(h: number, s: number, v: number, a?: number) {
|
||||||
|
let r: number, g: number, b: number;
|
||||||
|
|
||||||
|
const i = Math.floor(h * 6),
|
||||||
|
f = h * 6 - i,
|
||||||
|
p = v * (1 - s),
|
||||||
|
q = v * (1 - f * s),
|
||||||
|
t = v * (1 - (1 - f) * s);
|
||||||
|
|
||||||
|
switch(i % 6) {
|
||||||
|
case 0: r = v; g = t; b = p; break;
|
||||||
|
case 1: r = q; g = v; b = p; break;
|
||||||
|
case 2: r = p; g = v; b = t; break;
|
||||||
|
case 3: r = p; g = q; b = v; break;
|
||||||
|
case 4: r = t; g = p; b = v; break;
|
||||||
|
default: // case 5:
|
||||||
|
r = v; g = p; b = q;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new RGBAColor(
|
||||||
|
Math.round(Math.min(Math.max(0, r*255), 255)),
|
||||||
|
Math.round(Math.min(Math.max(0, g*255), 255)),
|
||||||
|
Math.round(Math.min(Math.max(0, b*255), 255)),
|
||||||
|
a === undefined ? 1 : a
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromHSLA(h: number, s: number, l: number, a?: number) {
|
||||||
|
if ( s === 0 ) {
|
||||||
|
const v = Math.round(Math.min(Math.max(0, 255*l), 255));
|
||||||
|
return new RGBAColor(v, v, v, a === undefined ? 1 : a);
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = l < 0.5 ? l * (1 + s) : l + s - l * s,
|
||||||
|
p = 2 * l - q;
|
||||||
|
|
||||||
|
return new RGBAColor(
|
||||||
|
Math.round(Math.min(Math.max(0, 255 * hue2rgb(p, q, h + 1/3)), 255)),
|
||||||
|
Math.round(Math.min(Math.max(0, 255 * hue2rgb(p, q, h)), 255)),
|
||||||
|
Math.round(Math.min(Math.max(0, 255 * hue2rgb(p, q, h - 1/3)), 255)),
|
||||||
|
a === undefined ? 1 : a
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromXYZA(x: number, y: number, z: number, a?: number) {
|
||||||
|
const R = 3.240479 * x - 1.537150 * y - 0.498535 * z,
|
||||||
|
G = -0.969256 * x + 1.875992 * y + 0.041556 * z,
|
||||||
|
B = 0.055648 * x - 0.204043 * y + 1.057311 * z;
|
||||||
|
|
||||||
|
// Make sure we end up in a real color space
|
||||||
|
return new RGBAColor(
|
||||||
|
Math.max(0, Math.min(255, 255 * linear2bit(R))),
|
||||||
|
Math.max(0, Math.min(255, 255 * linear2bit(G))),
|
||||||
|
Math.max(0, Math.min(255, 255 * linear2bit(B))),
|
||||||
|
a === undefined ? 1 : a
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Conversion: from RGBA
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
// CSS
|
||||||
|
toCSS() {
|
||||||
|
if ( this.a !== 1 )
|
||||||
|
return `rgba(${this.r},${this.g},${this.b},${this.a})`;
|
||||||
|
return this.toHex();
|
||||||
|
}
|
||||||
|
|
||||||
|
toHex() {
|
||||||
|
const value = (this.r << 16) + (this.g << 8) + this.b;
|
||||||
|
return `#${value.toString(16).padStart(6, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Color Spaces
|
||||||
|
toRGBA() { return this; }
|
||||||
|
toHSVA() { return HSVAColor.fromRGBA(this.r, this.g, this.b, this.a); }
|
||||||
|
toHSLA() { return HSLAColor.fromRGBA(this.r, this.g, this.b, this.a); }
|
||||||
|
toXYZA() { return XYZAColor.fromRGBA(this.r, this.g, this.b, this.a); }
|
||||||
|
toLUVA() { return this.toXYZA().toLUVA(); }
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Processing
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
get_Y() {
|
||||||
|
return ((0.299 * this.r) + ( 0.587 * this.g) + ( 0.114 * this.b)) / 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
luminance() {
|
||||||
|
const r = bit2linear(this.r / 255),
|
||||||
|
g = bit2linear(this.g / 255),
|
||||||
|
b = bit2linear(this.b / 255);
|
||||||
|
|
||||||
|
return (0.2126 * r) + (0.7152 * g) + (0.0722 * b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated This is a horrible function. */
|
||||||
|
brighten(amount?: number) {
|
||||||
|
amount = typeof amount === `number` ? amount : 1;
|
||||||
|
amount = Math.round(255 * (amount / 100));
|
||||||
|
|
||||||
|
return new RGBAColor(
|
||||||
|
Math.max(0, Math.min(255, this.r + amount)),
|
||||||
|
Math.max(0, Math.min(255, this.g + amount)),
|
||||||
|
Math.max(0, Math.min(255, this.b + amount)),
|
||||||
|
this.a
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
daltonize(type: string | CVDMatrix) {
|
||||||
|
let cvd: CVDMatrix;
|
||||||
|
if ( typeof type === 'string' ) {
|
||||||
|
if ( Color.CVDMatrix.hasOwnProperty(type) )
|
||||||
|
cvd = Color.CVDMatrix[type];
|
||||||
|
else
|
||||||
|
throw new Error('Invalid CVD matrix');
|
||||||
|
} else
|
||||||
|
cvd = type;
|
||||||
|
|
||||||
|
const cvd_a = cvd[0], cvd_b = cvd[1], cvd_c = cvd[2],
|
||||||
|
cvd_d = cvd[3], cvd_e = cvd[4], cvd_f = cvd[5],
|
||||||
|
cvd_g = cvd[6], cvd_h = cvd[7], cvd_i = cvd[8];
|
||||||
|
|
||||||
|
//let L, M, S, l, m, s, R, G, B, RR, GG, BB;
|
||||||
|
|
||||||
|
// RGB to LMS matrix conversion
|
||||||
|
const L = (17.8824 * this.r) + (43.5161 * this.g) + (4.11935 * this.b),
|
||||||
|
M = (3.45565 * this.r) + (27.1554 * this.g) + (3.86714 * this.b),
|
||||||
|
S = (0.0299566 * this.r) + (0.184309 * this.g) + (1.46709 * this.b);
|
||||||
|
|
||||||
|
// Simulate color blindness
|
||||||
|
const l = (cvd_a * L) + (cvd_b * M) + (cvd_c * S),
|
||||||
|
m = (cvd_d * L) + (cvd_e * M) + (cvd_f * S),
|
||||||
|
s = (cvd_g * L) + (cvd_h * M) + (cvd_i * S);
|
||||||
|
|
||||||
|
// LMS to RGB matrix conversion
|
||||||
|
let R = (0.0809444479 * l) + (-0.130504409 * m) + (0.116721066 * s),
|
||||||
|
G = (-0.0102485335 * l) + (0.0540193266 * m) + (-0.113614708 * s),
|
||||||
|
B = (-0.000365296938 * l) + (-0.00412161469 * m) + (0.693511405 * s);
|
||||||
|
|
||||||
|
// Isolate invisible colors to color vision deficiency (calculate error matrix)
|
||||||
|
R = this.r - R;
|
||||||
|
G = this.g - G;
|
||||||
|
B = this.b - B;
|
||||||
|
|
||||||
|
// Shift colors towards visible spectrum (apply error modifications)
|
||||||
|
const RR = (0.0 * R) + (0.0 * G) + (0.0 * B),
|
||||||
|
GG = (0.7 * R) + (1.0 * G) + (0.0 * B),
|
||||||
|
BB = (0.7 * R) + (0.0 * G) + (1.0 * B);
|
||||||
|
|
||||||
|
// Add compensation to original values
|
||||||
|
R = Math.min(Math.max(0, RR + this.r), 255);
|
||||||
|
G = Math.min(Math.max(0, GG + this.g), 255);
|
||||||
|
B = Math.min(Math.max(0, BB + this.b), 255);
|
||||||
|
|
||||||
|
return new RGBAColor(R, G, B, this.a);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HSVAColor implements BaseColor {
|
||||||
|
|
||||||
|
readonly h: number;
|
||||||
|
readonly s: number;
|
||||||
|
readonly v: number;
|
||||||
|
readonly a: number;
|
||||||
|
|
||||||
|
constructor(h: number, s: number, v: number, a?: number) {
|
||||||
|
this.h = h || 0;
|
||||||
|
this.s = s || 0;
|
||||||
|
this.v = v || 0;
|
||||||
|
this.a = a || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other?: BaseColor | null, ignoreAlpha = false): boolean {
|
||||||
|
if ( other instanceof HSVAColor )
|
||||||
|
return this.h === other.h && this.s === other.s && this.v === other.v && (ignoreAlpha || this.a === other.a);
|
||||||
|
return other ? this.eq(other.toHSVA(), ignoreAlpha) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Updates
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
_h(h: number) { return new HSVAColor(h, this.s, this.v, this.a); }
|
||||||
|
_s(s: number) { return new HSVAColor(this.h, s, this.v, this.a); }
|
||||||
|
_v(v: number) { return new HSVAColor(this.h, this.s, v, this.a); }
|
||||||
|
_a(a: number) { return new HSVAColor(this.h, this.s, this.v, a); }
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Conversion: to HSVA
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
static fromRGBA(r: number, g: number, b: number, a?: number) {
|
||||||
|
r /= 255; g /= 255; b /= 255;
|
||||||
|
|
||||||
|
const max = Math.max(r, g, b),
|
||||||
|
min = Math.min(r, g, b),
|
||||||
|
d = Math.min(Math.max(0, max - min), 1),
|
||||||
|
|
||||||
|
s = max === 0 ? 0 : d / max,
|
||||||
|
v = max;
|
||||||
|
|
||||||
|
let h;
|
||||||
|
|
||||||
|
if ( d === 0 )
|
||||||
|
h = 0;
|
||||||
|
else {
|
||||||
|
switch(max) {
|
||||||
|
case r:
|
||||||
|
h = (g - b) / d + (g < b ? 6 : 0);
|
||||||
|
break;
|
||||||
|
case g:
|
||||||
|
h = (b - r) / d + 2;
|
||||||
|
break;
|
||||||
|
default: // case b:
|
||||||
|
h = (r - g) / d + 4;
|
||||||
|
}
|
||||||
|
h /= 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HSVAColor(
|
||||||
|
h,
|
||||||
|
s,
|
||||||
|
v,
|
||||||
|
a === undefined ? 1 : a
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Conversion: from HSVA
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
toCSS() { return this.toRGBA().toCSS(); }
|
||||||
|
toHex() { return this.toRGBA().toHex(); }
|
||||||
|
|
||||||
|
toRGBA() { return RGBAColor.fromHSVA(this.h, this.s, this.v, this.a); }
|
||||||
|
toHSVA() { return this; }
|
||||||
|
toHSLA() { return this.toRGBA().toHSLA(); }
|
||||||
|
toXYZA() { return this.toRGBA().toXYZA(); }
|
||||||
|
toLUVA() { return this.toRGBA().toLUVA(); }
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HSLAColor implements BaseColor {
|
||||||
|
|
||||||
|
readonly h: number;
|
||||||
|
readonly s: number;
|
||||||
|
readonly l: number;
|
||||||
|
readonly a: number;
|
||||||
|
|
||||||
|
constructor(h: number, s: number, l: number, a?: number) {
|
||||||
|
this.h = h || 0;
|
||||||
|
this.s = s || 0;
|
||||||
|
this.l = l || 0;
|
||||||
|
this.a = a || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other?: BaseColor | null, ignoreAlpha = false): boolean {
|
||||||
|
if ( other instanceof HSLAColor )
|
||||||
|
return this.h === other.h && this.s === other.s && this.l === other.l && (ignoreAlpha || this.a === other.a);
|
||||||
|
return other ? this.eq(other.toHSLA(), ignoreAlpha) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Updates
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
_h(h: number) { return new HSLAColor(h, this.s, this.l, this.a); }
|
||||||
|
_s(s: number) { return new HSLAColor(this.h, s, this.l, this.a); }
|
||||||
|
_l(l: number) { return new HSLAColor(this.h, this.s, l, this.a); }
|
||||||
|
_a(a: number) { return new HSLAColor(this.h, this.s, this.l, a); }
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Conversion: to HSLA
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
static fromRGBA(r: number, g: number, b: number, a?: number) {
|
||||||
|
r /= 255; g /= 255; b /= 255;
|
||||||
|
|
||||||
|
const max = Math.max(r,g,b),
|
||||||
|
min = Math.min(r,g,b),
|
||||||
|
|
||||||
|
l = Math.min(Math.max(0, (max+min) / 2), 1),
|
||||||
|
d = Math.min(Math.max(0, max - min), 1);
|
||||||
|
|
||||||
|
let h, s;
|
||||||
|
|
||||||
|
if ( d === 0 )
|
||||||
|
h = s = 0;
|
||||||
|
else {
|
||||||
|
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||||
|
switch(max) {
|
||||||
|
case r:
|
||||||
|
h = (g - b) / d + (g < b ? 6 : 0);
|
||||||
|
break;
|
||||||
|
case g:
|
||||||
|
h = (b - r) / d + 2;
|
||||||
|
break;
|
||||||
|
default: //case b:
|
||||||
|
h = (r - g) / d + 4;
|
||||||
|
}
|
||||||
|
h /= 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HSLAColor(h, s, l, a === undefined ? 1 : a);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Conversion: from HSLA
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
toCSS() {
|
||||||
|
const a = this.a;
|
||||||
|
return `hsl${a !== 1 ? 'a' : ''}(${Math.round(this.h*360)},${Math.round(this.s*100)}%,${Math.round(this.l*100)}%${a !== 1 ? `,${this.a}` : ''})`;
|
||||||
|
}
|
||||||
|
|
||||||
|
toHex() { return this.toRGBA().toHex(); }
|
||||||
|
|
||||||
|
toRGBA() { return RGBAColor.fromHSLA(this.h, this.s, this.l, this.a); }
|
||||||
|
toHSLA() { return this; }
|
||||||
|
toHSVA() { return this.toRGBA().toHSVA(); }
|
||||||
|
toXYZA() { return this.toRGBA().toXYZA(); }
|
||||||
|
toLUVA() { return this.toRGBA().toLUVA(); }
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Processing
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
targetLuminance(target: number) {
|
||||||
|
let s = this.s,
|
||||||
|
min = 0,
|
||||||
|
max = 1;
|
||||||
|
|
||||||
|
s *= Math.pow(this.l > 0.5 ? -this.l : this.l - 1, 7) + 1;
|
||||||
|
|
||||||
|
let d = (max - min) / 2,
|
||||||
|
mid = min + d;
|
||||||
|
|
||||||
|
for (; d > 1/65536; d /= 2, mid = min + d) {
|
||||||
|
const luminance = RGBAColor.fromHSLA(this.h, s, mid, 1).luminance();
|
||||||
|
if (luminance > target) {
|
||||||
|
max = mid;
|
||||||
|
} else {
|
||||||
|
min = mid;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HSLAColor(this.h, s, mid, this.a);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class XYZAColor implements BaseColor {
|
||||||
|
|
||||||
|
readonly x: number;
|
||||||
|
readonly y: number;
|
||||||
|
readonly z: number;
|
||||||
|
readonly a: number;
|
||||||
|
|
||||||
|
constructor(x: number, y: number, z: number, a?: number) {
|
||||||
|
this.x = x || 0;
|
||||||
|
this.y = y || 0;
|
||||||
|
this.z = z || 0;
|
||||||
|
this.a = a || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other?: BaseColor, ignoreAlpha = false): boolean {
|
||||||
|
if ( other instanceof XYZAColor )
|
||||||
|
return this.x === other.x && this.y === other.y && this.z === other.z && (ignoreAlpha || this.a === other.a);
|
||||||
|
return other ? this.eq(other.toXYZA(), ignoreAlpha) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Updates
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
_x(x: number) { return new XYZAColor(x, this.y, this.z, this.a); }
|
||||||
|
_y(y: number) { return new XYZAColor(this.x, y, this.z, this.a); }
|
||||||
|
_z(z: number) { return new XYZAColor(this.x, this.y, z, this.a); }
|
||||||
|
_a(a: number) { return new XYZAColor(this.x, this.y, this.z, a); }
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Conversion: to XYZA
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
static EPSILON = Math.pow(6 / 29, 3);
|
||||||
|
static KAPPA = Math.pow(29 / 3, 3);
|
||||||
|
static WHITE = null as any; // Gotta do this late to avoid an error.
|
||||||
|
|
||||||
|
static fromRGBA(r: number, g: number, b: number, a?: number) {
|
||||||
|
const R = bit2linear(r / 255),
|
||||||
|
G = bit2linear(g / 255),
|
||||||
|
B = bit2linear(b / 255);
|
||||||
|
|
||||||
|
return new XYZAColor(
|
||||||
|
0.412453 * R + 0.357580 * G + 0.180423 * B,
|
||||||
|
0.212671 * R + 0.715160 * G + 0.072169 * B,
|
||||||
|
0.019334 * R + 0.119193 * G + 0.950227 * B,
|
||||||
|
a === undefined ? 1 : a
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static fromLUVA(l: number, u: number, v: number, alpha?: number) {
|
||||||
|
const deltaGammaFactor = 1 / (XYZAColor.WHITE.x + 15 * XYZAColor.WHITE.y + 3 * XYZAColor.WHITE.z),
|
||||||
|
uDeltaGamma = 4 * XYZAColor.WHITE.x * deltaGammaFactor,
|
||||||
|
vDeltagamma = 9 * XYZAColor.WHITE.y * deltaGammaFactor;
|
||||||
|
|
||||||
|
// XYZAColor.EPSILON * XYZAColor.KAPPA = 8
|
||||||
|
const Y = (l > 8) ? Math.pow((l + 16) / 116, 3) : l / XYZAColor.KAPPA,
|
||||||
|
a = 1/3 * (((52 * l) / (u + 13 * l * uDeltaGamma)) - 1),
|
||||||
|
b = -5 * Y,
|
||||||
|
c = -1/3,
|
||||||
|
d = Y * (((39 * l) / (v + 13 * l * vDeltagamma)) - 5),
|
||||||
|
|
||||||
|
X = (d - b) / (a - c),
|
||||||
|
Z = X * a + b;
|
||||||
|
|
||||||
|
return new XYZAColor(X, Y, Z, alpha === undefined ? 1 : alpha);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Conversion: from XYZA
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
toCSS() { return this.toRGBA().toCSS(); }
|
||||||
|
toHex() { return this.toRGBA().toHex(); }
|
||||||
|
|
||||||
|
toRGBA() { return RGBAColor.fromXYZA(this.x, this.y, this.z, this.a); }
|
||||||
|
toHSLA() { return this.toRGBA().toHSLA(); }
|
||||||
|
toHSVA() { return this.toRGBA().toHSVA(); }
|
||||||
|
toXYZA() { return this; }
|
||||||
|
toLUVA() { return LUVAColor.fromXYZA(this.x, this.y, this.z, this.a); }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign this now that XYZAColor exists.
|
||||||
|
XYZAColor.WHITE = new RGBAColor(255,255,255,1).toXYZA();
|
||||||
|
|
||||||
|
|
||||||
|
class LUVAColor implements BaseColor {
|
||||||
|
|
||||||
|
readonly l: number;
|
||||||
|
readonly u: number;
|
||||||
|
readonly v: number;
|
||||||
|
readonly a: number;
|
||||||
|
|
||||||
|
constructor(l: number, u: number, v: number, a?: number) {
|
||||||
|
this.l = l || 0;
|
||||||
|
this.u = u || 0;
|
||||||
|
this.v = v || 0;
|
||||||
|
this.a = a || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other?: BaseColor | null, ignoreAlpha = false): boolean {
|
||||||
|
if ( other instanceof LUVAColor )
|
||||||
|
return this.l === other.l && this.u === other.u && this.v === other.v && (ignoreAlpha || this.a === other.a);
|
||||||
|
return other ? this.eq(other.toLUVA(), ignoreAlpha) : false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Updates
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
_l(l: number) { return new LUVAColor(l, this.u, this.v, this.a); }
|
||||||
|
_u(u: number) { return new LUVAColor(this.l, u, this.v, this.a); }
|
||||||
|
_v(v: number) { return new LUVAColor(this.l, this.u, v, this.a); }
|
||||||
|
_a(a: number) { return new LUVAColor(this.l, this.u, this.v, a); }
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Conversion: to LUVA
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
static fromXYZA(X: number, Y: number, Z: number, a?: number) {
|
||||||
|
const deltaGammaFactor = 1 / (XYZAColor.WHITE.x + 15 * XYZAColor.WHITE.y + 3 * XYZAColor.WHITE.z),
|
||||||
|
uDeltaGamma = 4 * XYZAColor.WHITE.x * deltaGammaFactor,
|
||||||
|
vDeltagamma = 9 * XYZAColor.WHITE.y * deltaGammaFactor,
|
||||||
|
|
||||||
|
yGamma = Y / XYZAColor.WHITE.y;
|
||||||
|
|
||||||
|
let deltaDivider = (X + 15 * Y + 3 * Z);
|
||||||
|
if (deltaDivider === 0) {
|
||||||
|
deltaDivider = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deltaFactor = 1 / deltaDivider,
|
||||||
|
|
||||||
|
uDelta = 4 * X * deltaFactor,
|
||||||
|
vDelta = 9 * Y * deltaFactor,
|
||||||
|
|
||||||
|
L = (yGamma > XYZAColor.EPSILON) ? 116 * Math.pow(yGamma, 1/3) - 16 : XYZAColor.KAPPA * yGamma,
|
||||||
|
u = 13 * L * (uDelta - uDeltaGamma),
|
||||||
|
v = 13 * L * (vDelta - vDeltagamma);
|
||||||
|
|
||||||
|
return new LUVAColor(L, u, v, a === undefined ? 1 : a);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================================================================
|
||||||
|
// Conversion: from LUVA
|
||||||
|
// ========================================================================
|
||||||
|
|
||||||
|
toCSS() { return this.toRGBA().toCSS(); }
|
||||||
|
toHex() { return this.toRGBA().toHex(); }
|
||||||
|
|
||||||
|
toRGBA() { return this.toXYZA().toRGBA(); }
|
||||||
|
toHSLA() { return this.toRGBA().toHSLA(); }
|
||||||
|
toHSVA() { return this.toRGBA().toHSVA(); }
|
||||||
|
toXYZA() { return XYZAColor.fromLUVA(this.l, this.u, this.v, this.a); }
|
||||||
|
toLUVA() { return this;}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
type ColorType = {
|
||||||
|
_canvas?: HTMLCanvasElement;
|
||||||
|
_context?: CanvasRenderingContext2D;
|
||||||
|
|
||||||
|
getCanvas(): HTMLCanvasElement;
|
||||||
|
getContext(): CanvasRenderingContext2D
|
||||||
|
|
||||||
|
CVDMatrix: Record<string, CVDMatrix>;
|
||||||
|
|
||||||
|
RGBA: typeof RGBAColor;
|
||||||
|
HSVA: typeof HSVAColor;
|
||||||
|
HSLA: typeof HSLAColor;
|
||||||
|
XYZA: typeof XYZAColor;
|
||||||
|
LUVA: typeof LUVAColor;
|
||||||
|
|
||||||
|
fromCSS(input: string): RGBAColor | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export const Color: ColorType = {
|
||||||
|
CVDMatrix: {
|
||||||
|
protanope: [ // reds are greatly reduced (1% men)
|
||||||
|
0.0, 2.02344, -2.52581,
|
||||||
|
0.0, 1.0, 0.0,
|
||||||
|
0.0, 0.0, 1.0
|
||||||
|
],
|
||||||
|
deuteranope: [ // greens are greatly reduced (1% men)
|
||||||
|
1.0, 0.0, 0.0,
|
||||||
|
0.494207, 0.0, 1.24827,
|
||||||
|
0.0, 0.0, 1.0
|
||||||
|
],
|
||||||
|
tritanope: [ // blues are greatly reduced (0.003% population)
|
||||||
|
1.0, 0.0, 0.0,
|
||||||
|
0.0, 1.0, 0.0,
|
||||||
|
-0.395913, 0.801109, 0.0
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
getCanvas() {
|
||||||
|
if ( ! Color._canvas )
|
||||||
|
Color._canvas = document.createElement('canvas');
|
||||||
|
return Color._canvas;
|
||||||
|
},
|
||||||
|
getContext: () => {
|
||||||
|
if ( ! Color._context )
|
||||||
|
Color._context = Color.getCanvas().getContext('2d') as CanvasRenderingContext2D;
|
||||||
|
return Color._context;
|
||||||
|
},
|
||||||
|
|
||||||
|
RGBA: RGBAColor,
|
||||||
|
HSVA: HSVAColor,
|
||||||
|
HSLA: HSLAColor,
|
||||||
|
XYZA: XYZAColor,
|
||||||
|
LUVA: LUVAColor,
|
||||||
|
|
||||||
|
fromCSS(input: string) {
|
||||||
|
return RGBAColor.fromCSS(input);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export class ColorAdjuster {
|
||||||
|
|
||||||
|
private _base: string;
|
||||||
|
private _contrast: number;
|
||||||
|
private _mode: number;
|
||||||
|
|
||||||
|
private _dark: boolean = false;
|
||||||
|
private _cache: Map<string, string> = new Map;
|
||||||
|
|
||||||
|
private _luv: number = 0;
|
||||||
|
private _luma: number = 0;
|
||||||
|
|
||||||
|
constructor(base = '#232323', mode = 0, contrast = 4.5) {
|
||||||
|
this._contrast = contrast;
|
||||||
|
this._base = base;
|
||||||
|
this._mode = mode;
|
||||||
|
|
||||||
|
this.rebuildContrast();
|
||||||
|
}
|
||||||
|
|
||||||
|
get contrast() { return this._contrast }
|
||||||
|
set contrast(val) { this._contrast = val; this.rebuildContrast() }
|
||||||
|
|
||||||
|
get base() { return this._base }
|
||||||
|
set base(val) { this._base = val; this.rebuildContrast() }
|
||||||
|
|
||||||
|
get dark() { return this._dark }
|
||||||
|
|
||||||
|
get mode() { return this._mode }
|
||||||
|
set mode(val) { this._mode = val; this.rebuildContrast() }
|
||||||
|
|
||||||
|
|
||||||
|
rebuildContrast() {
|
||||||
|
this._cache = new Map;
|
||||||
|
|
||||||
|
const base = RGBAColor.fromCSS(this._base);
|
||||||
|
if ( ! base )
|
||||||
|
throw new Error('Invalid base color');
|
||||||
|
|
||||||
|
const lum = base.luminance(),
|
||||||
|
dark = this._dark = lum < 0.5;
|
||||||
|
|
||||||
|
if ( dark ) {
|
||||||
|
this._luv = new XYZAColor(
|
||||||
|
0,
|
||||||
|
(this._contrast * (base.toXYZA().y + 0.05) - 0.05),
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
).toLUVA().l;
|
||||||
|
|
||||||
|
this._luma = this._contrast * (base.luminance() + 0.05) - 0.05;
|
||||||
|
|
||||||
|
} else {
|
||||||
|
this._luv = new XYZAColor(
|
||||||
|
0,
|
||||||
|
(base.toXYZA().y + 0.05) / this._contrast - 0.05,
|
||||||
|
0,
|
||||||
|
1
|
||||||
|
).toLUVA().l;
|
||||||
|
|
||||||
|
this._luma = (base.luminance() + 0.05) / this._contrast - 0.05;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
process(color: BaseColor | string, throw_errors = false) {
|
||||||
|
if ( this._mode === -1 )
|
||||||
|
return '';
|
||||||
|
|
||||||
|
else if ( this._mode === 0 )
|
||||||
|
return color;
|
||||||
|
|
||||||
|
if ( typeof color !== 'string' )
|
||||||
|
color = color.toCSS();
|
||||||
|
|
||||||
|
if ( ! color )
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if ( this._cache.has(color) )
|
||||||
|
return this._cache.get(color);
|
||||||
|
|
||||||
|
let rgb;
|
||||||
|
|
||||||
|
try {
|
||||||
|
rgb = RGBAColor.fromCSS(color);
|
||||||
|
} catch(err) {
|
||||||
|
if ( throw_errors )
|
||||||
|
throw err;
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! rgb )
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if ( this._mode === 1 ) {
|
||||||
|
// HSL Luma
|
||||||
|
const luma = rgb.luminance();
|
||||||
|
|
||||||
|
if ( this._dark ? luma < this._luma : luma > this._luma )
|
||||||
|
rgb = rgb.toHSLA().targetLuminance(this._luma).toRGBA();
|
||||||
|
|
||||||
|
} else if ( this._mode === 2 ) {
|
||||||
|
// LUV
|
||||||
|
const luv = rgb.toLUVA();
|
||||||
|
if ( this._dark ? luv.l < this._luv : luv.l > this._luv )
|
||||||
|
rgb = luv._l(this._luv).toRGBA();
|
||||||
|
|
||||||
|
} else if ( this._mode === 3 ) {
|
||||||
|
// HSL Loop (aka BTTV Style)
|
||||||
|
if ( this._dark )
|
||||||
|
while ( rgb.get_Y() < 0.5 ) {
|
||||||
|
const hsl = rgb.toHSLA();
|
||||||
|
rgb = hsl._l(Math.min(Math.max(0, 0.1 + 0.9 * hsl.l), 1)).toRGBA();
|
||||||
|
}
|
||||||
|
|
||||||
|
else
|
||||||
|
while ( rgb.get_Y() >= 0.5 ) {
|
||||||
|
const hsl = rgb.toHSLA();
|
||||||
|
rgb = hsl._l(Math.min(Math.max(0, 0.9 * hsl.l), 1)).toRGBA();
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if ( this._mode === 4 ) {
|
||||||
|
// RGB Loop
|
||||||
|
let i = 0;
|
||||||
|
if ( this._dark )
|
||||||
|
while ( rgb.luminance() < 0.15 && i++ < 127 )
|
||||||
|
rgb = rgb.brighten();
|
||||||
|
|
||||||
|
else
|
||||||
|
while ( rgb.luminance() > 0.3 && i++ < 127 )
|
||||||
|
rgb = rgb.brighten(-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = rgb.toCSS();
|
||||||
|
this._cache.set(color, out);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
|
@ -6,11 +6,28 @@
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import {EventEmitter} from 'utilities/events';
|
import {EventEmitter} from 'utilities/events';
|
||||||
import Module from 'utilities/module';
|
import Module, { GenericModule } from 'utilities/module';
|
||||||
|
|
||||||
export default class Elemental extends Module {
|
declare module 'utilities/types' {
|
||||||
constructor(...args) {
|
interface ModuleMap {
|
||||||
super(...args);
|
'site.elemental': Elemental;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Elemental extends Module<'site.elemental'> {
|
||||||
|
|
||||||
|
private _wrappers: Map<string, ElementalWrapper<any>>;
|
||||||
|
private _observer: MutationObserver | null;
|
||||||
|
private _watching: Set<ElementalWrapper<any>>;
|
||||||
|
private _live_watching: ElementalWrapper<any>[] | null;
|
||||||
|
private _route?: string | null;
|
||||||
|
private _timer?: number | null;
|
||||||
|
|
||||||
|
private _timeout?: ReturnType<typeof setTimeout> | null;
|
||||||
|
private _clean_all?: ReturnType<typeof requestAnimationFrame> | null;
|
||||||
|
|
||||||
|
constructor(name?: string, parent?: GenericModule) {
|
||||||
|
super(name, parent);
|
||||||
|
|
||||||
this._pruneLive = this._pruneLive.bind(this);
|
this._pruneLive = this._pruneLive.bind(this);
|
||||||
|
|
||||||
|
@ -21,27 +38,35 @@ export default class Elemental extends Module {
|
||||||
this._live_watching = null;
|
this._live_watching = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
onDisable() {
|
onDisable() {
|
||||||
this._stopWatching();
|
this._stopWatching();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
define(key, selector, routes, opts = null, limit = 0, timeout = 5000, remove = true) {
|
define<TElement extends HTMLElement = HTMLElement>(
|
||||||
|
key: string,
|
||||||
|
selector: string,
|
||||||
|
routes?: string[] | false | null,
|
||||||
|
opts: MutationObserverInit | null = null,
|
||||||
|
limit = 0,
|
||||||
|
timeout = 5000,
|
||||||
|
remove = true
|
||||||
|
) {
|
||||||
if ( this._wrappers.has(key) )
|
if ( this._wrappers.has(key) )
|
||||||
return this._wrappers.get(key);
|
return this._wrappers.get(key) as ElementalWrapper<TElement>;
|
||||||
|
|
||||||
if ( ! selector || typeof selector !== 'string' || ! selector.length )
|
if ( ! selector || typeof selector !== 'string' || ! selector.length )
|
||||||
throw new Error('cannot find definition and no selector provided');
|
throw new Error('cannot find definition and no selector provided');
|
||||||
|
|
||||||
const wrapper = new ElementalWrapper(key, selector, routes, opts, limit, timeout, remove, this);
|
const wrapper = new ElementalWrapper<TElement>(key, selector, routes, opts, limit, timeout, remove, this);
|
||||||
this._wrappers.set(key, wrapper);
|
this._wrappers.set(key, wrapper);
|
||||||
|
|
||||||
return wrapper;
|
return wrapper;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
route(route) {
|
route(route: string | null) {
|
||||||
this._route = route;
|
this._route = route;
|
||||||
this._timer = Date.now();
|
this._timer = Date.now();
|
||||||
this._updateLiveWatching();
|
this._updateLiveWatching();
|
||||||
|
@ -76,24 +101,27 @@ export default class Elemental extends Module {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
_isActive(watcher, now) {
|
private _isActive(watcher: ElementalWrapper<any>, now: number) {
|
||||||
|
if ( watcher.routes === false )
|
||||||
|
return false;
|
||||||
|
|
||||||
if ( this._route && watcher.routes.length && ! watcher.routes.includes(this._route) )
|
if ( this._route && watcher.routes.length && ! watcher.routes.includes(this._route) )
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
if ( watcher.timeout > 0 && (now - this._timer) > watcher.timeout )
|
if ( watcher.timeout > 0 && (now - (this._timer as number)) > watcher.timeout )
|
||||||
return false;
|
return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
_updateLiveWatching() {
|
private _updateLiveWatching() {
|
||||||
if ( this._timeout ) {
|
if ( this._timeout ) {
|
||||||
clearTimeout(this._timeout);
|
clearTimeout(this._timeout);
|
||||||
this._timeout = null;
|
this._timeout = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const lw = this._live_watching = [],
|
const lw: ElementalWrapper<any>[] = this._live_watching = [],
|
||||||
now = Date.now();
|
now = Date.now();
|
||||||
let min_timeout = Number.POSITIVE_INFINITY;
|
let min_timeout = Number.POSITIVE_INFINITY;
|
||||||
|
|
||||||
|
@ -115,16 +143,17 @@ export default class Elemental extends Module {
|
||||||
this._startWatching();
|
this._startWatching();
|
||||||
}
|
}
|
||||||
|
|
||||||
_pruneLive() {
|
private _pruneLive() {
|
||||||
this._updateLiveWatching();
|
this._updateLiveWatching();
|
||||||
}
|
}
|
||||||
|
|
||||||
_checkWatchers(muts) {
|
private _checkWatchers(muts: Node[]) {
|
||||||
for(const watcher of this._live_watching)
|
if ( this._live_watching )
|
||||||
watcher.checkElements(muts);
|
for(const watcher of this._live_watching)
|
||||||
|
watcher.checkElements(muts as Element[]);
|
||||||
}
|
}
|
||||||
|
|
||||||
_startWatching() {
|
private _startWatching() {
|
||||||
if ( ! this._observer && this._live_watching && this._live_watching.length ) {
|
if ( ! this._observer && this._live_watching && this._live_watching.length ) {
|
||||||
this.log.info('Installing MutationObserver.');
|
this.log.info('Installing MutationObserver.');
|
||||||
|
|
||||||
|
@ -136,7 +165,7 @@ export default class Elemental extends Module {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_stopWatching() {
|
private _stopWatching() {
|
||||||
if ( this._observer ) {
|
if ( this._observer ) {
|
||||||
this.log.info('Stopping MutationObserver.');
|
this.log.info('Stopping MutationObserver.');
|
||||||
this._observer.disconnect();
|
this._observer.disconnect();
|
||||||
|
@ -152,7 +181,7 @@ export default class Elemental extends Module {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
listen(inst, ensure_live = true) {
|
listen(inst: ElementalWrapper<any>, ensure_live = true) {
|
||||||
if ( this._watching.has(inst) )
|
if ( this._watching.has(inst) )
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
@ -163,7 +192,7 @@ export default class Elemental extends Module {
|
||||||
this._updateLiveWatching();
|
this._updateLiveWatching();
|
||||||
}
|
}
|
||||||
|
|
||||||
unlisten(inst) {
|
unlisten(inst: ElementalWrapper<any>) {
|
||||||
if ( ! this._watching.has(inst) )
|
if ( ! this._watching.has(inst) )
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
@ -175,20 +204,64 @@ export default class Elemental extends Module {
|
||||||
|
|
||||||
let elemental_id = 0;
|
let elemental_id = 0;
|
||||||
|
|
||||||
export class ElementalWrapper extends EventEmitter {
|
type ElementalParam = `_ffz$elemental$${number}`;
|
||||||
constructor(name, selector, routes, opts, limit, timeout, remove, elemental) {
|
type ElementalRemoveParam = `_ffz$elemental_remove$${number}`;
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface HTMLElement {
|
||||||
|
[key: ElementalParam]: MutationObserver | null;
|
||||||
|
[key: ElementalRemoveParam]: MutationObserver | null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ElementalWrapperEvents<TElement extends HTMLElement> = {
|
||||||
|
mount: [element: TElement];
|
||||||
|
unmount: [element: TElement];
|
||||||
|
mutate: [element: TElement, mutations: MutationRecord[]];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ElementalWrapper<
|
||||||
|
TElement extends HTMLElement = HTMLElement
|
||||||
|
> extends EventEmitter<ElementalWrapperEvents<TElement>> {
|
||||||
|
|
||||||
|
readonly id: number;
|
||||||
|
readonly name: string;
|
||||||
|
readonly selector: string;
|
||||||
|
readonly routes: string[] | false;
|
||||||
|
readonly opts: MutationObserverInit | null;
|
||||||
|
readonly limit: number;
|
||||||
|
readonly timeout: number;
|
||||||
|
readonly check_removal: boolean;
|
||||||
|
count: number;
|
||||||
|
readonly instances: Set<TElement>;
|
||||||
|
readonly elemental: Elemental;
|
||||||
|
|
||||||
|
readonly param: ElementalParam;
|
||||||
|
readonly remove_param: ElementalRemoveParam;
|
||||||
|
|
||||||
|
private _stimer?: ReturnType<typeof setTimeout> | null;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
name: string,
|
||||||
|
selector: string,
|
||||||
|
routes: string[] | false | undefined | null,
|
||||||
|
opts: MutationObserverInit | null,
|
||||||
|
limit: number,
|
||||||
|
timeout: number,
|
||||||
|
remove: boolean,
|
||||||
|
elemental: Elemental
|
||||||
|
) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.id = elemental_id++;
|
this.id = elemental_id++;
|
||||||
this.param = `_ffz$elemental$${this.id}`;
|
this.param = `_ffz$elemental$${this.id}`;
|
||||||
this.remove_param = `_ffz$elemental_remove$${this.id}`;
|
this.remove_param = `_ffz$elemental_remove$${this.id}`;
|
||||||
this.mut_param = `_ffz$elemental_mutating${this.id}`;
|
|
||||||
|
|
||||||
this._schedule = this._schedule.bind(this);
|
this._schedule = this._schedule.bind(this);
|
||||||
|
|
||||||
this.name = name;
|
this.name = name;
|
||||||
this.selector = selector;
|
this.selector = selector;
|
||||||
this.routes = routes || [];
|
this.routes = routes ?? [];
|
||||||
this.opts = opts;
|
this.opts = opts;
|
||||||
this.limit = limit;
|
this.limit = limit;
|
||||||
this.timeout = timeout;
|
this.timeout = timeout;
|
||||||
|
@ -199,7 +272,6 @@ export class ElementalWrapper extends EventEmitter {
|
||||||
|
|
||||||
this.count = 0;
|
this.count = 0;
|
||||||
this.instances = new Set;
|
this.instances = new Set;
|
||||||
this.observers = new Map;
|
|
||||||
this.elemental = elemental;
|
this.elemental = elemental;
|
||||||
|
|
||||||
this.check();
|
this.check();
|
||||||
|
@ -224,7 +296,8 @@ export class ElementalWrapper extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
_schedule() {
|
_schedule() {
|
||||||
clearTimeout(this._stimer);
|
if ( this._stimer )
|
||||||
|
clearTimeout(this._stimer);
|
||||||
this._stimer = null;
|
this._stimer = null;
|
||||||
|
|
||||||
if ( this.limit === 0 || this.count < this.limit )
|
if ( this.limit === 0 || this.count < this.limit )
|
||||||
|
@ -234,18 +307,19 @@ export class ElementalWrapper extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
check() {
|
check() {
|
||||||
const matches = document.querySelectorAll(this.selector);
|
const matches = document.querySelectorAll<TElement>(this.selector);
|
||||||
for(const el of matches)
|
// TypeScript is stupid and thinks NodeListOf<Element> doesn't have an iterator
|
||||||
|
for(const el of matches as unknown as Iterable<TElement>)
|
||||||
this.add(el);
|
this.add(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
checkElements(els) {
|
checkElements(els: Iterable<Element>) {
|
||||||
if ( this.atLimit )
|
if ( this.atLimit )
|
||||||
return this.schedule();
|
return this.schedule();
|
||||||
|
|
||||||
for(const el of els) {
|
for(const el of els) {
|
||||||
const matches = el.querySelectorAll(this.selector);
|
const matches = el.querySelectorAll<TElement>(this.selector);
|
||||||
for(const match of matches)
|
for(const match of matches as unknown as Iterable<TElement>)
|
||||||
this.add(match);
|
this.add(match);
|
||||||
|
|
||||||
if ( this.atLimit )
|
if ( this.atLimit )
|
||||||
|
@ -264,66 +338,66 @@ export class ElementalWrapper extends EventEmitter {
|
||||||
return Array.from(this.instances);
|
return Array.from(this.instances);
|
||||||
}
|
}
|
||||||
|
|
||||||
each(fn) {
|
each(fn: (element: TElement) => void) {
|
||||||
for(const el of this.instances)
|
for(const el of this.instances)
|
||||||
fn(el);
|
fn(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
add(el) {
|
add(element: TElement) {
|
||||||
if ( this.instances.has(el) )
|
if ( this.instances.has(element) )
|
||||||
return;
|
return;
|
||||||
|
|
||||||
this.instances.add(el);
|
this.instances.add(element);
|
||||||
this.count++;
|
this.count++;
|
||||||
|
|
||||||
if ( this.check_removal ) {
|
if ( this.check_removal && element.parentNode ) {
|
||||||
const remove_check = new MutationObserver(() => {
|
const remove_check = new MutationObserver(() => {
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
if ( ! document.contains(el) )
|
if ( ! document.contains(element) )
|
||||||
this.remove(el);
|
this.remove(element);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
remove_check.observe(el.parentNode, {childList: true});
|
remove_check.observe(element.parentNode, {childList: true});
|
||||||
el[this.remove_param] = remove_check;
|
(element as HTMLElement)[this.remove_param] = remove_check;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( this.opts ) {
|
if ( this.opts ) {
|
||||||
const observer = new MutationObserver(muts => {
|
const observer = new MutationObserver(muts => {
|
||||||
if ( ! document.contains(el) ) {
|
if ( ! document.contains(element) ) {
|
||||||
this.remove(el);
|
this.remove(element);
|
||||||
} else if ( ! this.__running.size )
|
} else if ( ! this.__running.size )
|
||||||
this.emit('mutate', el, muts);
|
this.emit('mutate', element, muts);
|
||||||
});
|
});
|
||||||
|
|
||||||
observer.observe(el, this.opts);
|
observer.observe(element, this.opts);
|
||||||
el[this.param] = observer;
|
(element as HTMLElement)[this.param] = observer;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.schedule();
|
this.schedule();
|
||||||
this.emit('mount', el);
|
this.emit('mount', element);
|
||||||
}
|
}
|
||||||
|
|
||||||
remove(el) {
|
remove(element: TElement) {
|
||||||
const observer = el[this.param];
|
const observer = element[this.param];
|
||||||
if ( observer ) {
|
if ( observer ) {
|
||||||
observer.disconnect();
|
observer.disconnect();
|
||||||
el[this.param] = null;
|
(element as HTMLElement)[this.param] = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const remove_check = el[this.remove_param];
|
const remove_check = element[this.remove_param];
|
||||||
if ( remove_check ) {
|
if ( remove_check ) {
|
||||||
remove_check.disconnect();
|
remove_check.disconnect();
|
||||||
el[this.remove_param] = null;
|
(element as HTMLElement)[this.remove_param] = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ( ! this.instances.has(el) )
|
if ( ! this.instances.has(element) )
|
||||||
return;
|
return;
|
||||||
|
|
||||||
this.instances.delete(el);
|
this.instances.delete(element);
|
||||||
this.count--;
|
this.count--;
|
||||||
|
|
||||||
this.schedule();
|
this.schedule();
|
||||||
this.emit('unmount', el);
|
this.emit('unmount', element);
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -5,13 +5,56 @@
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import {parse, tokensToRegExp, tokensToFunction} from 'path-to-regexp';
|
import {parse, tokensToRegExp, tokensToFunction} from 'path-to-regexp';
|
||||||
import Module from 'utilities/module';
|
import Module, { GenericModule } from 'utilities/module';
|
||||||
import {has, deep_equals} from 'utilities/object';
|
import {has, deep_equals, sleep} from 'utilities/object';
|
||||||
|
import type Fine from './fine';
|
||||||
|
import type { OptionalPromise } from 'utilities/types';
|
||||||
|
|
||||||
|
declare module 'utilities/types' {
|
||||||
|
interface ModuleEventMap {
|
||||||
|
'site.router': FineRouterEvents;
|
||||||
|
}
|
||||||
|
interface ModuleMap {
|
||||||
|
'site.router': FineRouter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export default class FineRouter extends Module {
|
type FineRouterEvents = {
|
||||||
constructor(...args) {
|
':updated-route-names': [];
|
||||||
super(...args);
|
':updated-routes': [];
|
||||||
|
|
||||||
|
':route': [route: RouteInfo | null, match: unknown];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RouteInfo = {
|
||||||
|
name: string;
|
||||||
|
domain: string | null;
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default class FineRouter extends Module<'site.router', FineRouterEvents> {
|
||||||
|
|
||||||
|
// Dependencies
|
||||||
|
fine: Fine = null as any;
|
||||||
|
|
||||||
|
// Storage
|
||||||
|
routes: Record<string, RouteInfo>;
|
||||||
|
route_names: Record<string, string>;
|
||||||
|
private __routes: RouteInfo[];
|
||||||
|
|
||||||
|
// State
|
||||||
|
current: RouteInfo | null;
|
||||||
|
current_name: string | null;
|
||||||
|
current_state: unknown | null;
|
||||||
|
match: unknown | null;
|
||||||
|
location: unknown | null;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
constructor(name?: string, parent?: GenericModule) {
|
||||||
|
super(name, parent);
|
||||||
this.inject('..fine');
|
this.inject('..fine');
|
||||||
|
|
||||||
this.__routes = [];
|
this.__routes = [];
|
||||||
|
@ -20,16 +63,18 @@ export default class FineRouter extends Module {
|
||||||
this.route_names = {};
|
this.route_names = {};
|
||||||
this.current = null;
|
this.current = null;
|
||||||
this.current_name = null;
|
this.current_name = null;
|
||||||
|
this.current_state = null;
|
||||||
this.match = null;
|
this.match = null;
|
||||||
this.location = null;
|
this.location = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
onEnable() {
|
/** @internal */
|
||||||
|
onEnable(): OptionalPromise<void> {
|
||||||
const thing = this.fine.searchTree(null, n => n.props && n.props.history),
|
const thing = this.fine.searchTree(null, n => n.props && n.props.history),
|
||||||
history = this.history = thing && thing.props && thing.props.history;
|
history = this.history = thing && thing.props && thing.props.history;
|
||||||
|
|
||||||
if ( ! history )
|
if ( ! history )
|
||||||
return new Promise(r => setTimeout(r, 50)).then(() => this.onEnable());
|
return sleep(50).then(() => this.onEnable());
|
||||||
|
|
||||||
history.listen(location => {
|
history.listen(location => {
|
||||||
if ( this.enabled )
|
if ( this.enabled )
|
||||||
|
@ -43,7 +88,7 @@ export default class FineRouter extends Module {
|
||||||
this.history.push(this.getURL(route, data, opts), state);
|
this.history.push(this.getURL(route, data, opts), state);
|
||||||
}
|
}
|
||||||
|
|
||||||
_navigateTo(location) {
|
private _navigateTo(location) {
|
||||||
this.log.debug('New Location', location);
|
this.log.debug('New Location', location);
|
||||||
const host = window.location.host,
|
const host = window.location.host,
|
||||||
path = location.pathname,
|
path = location.pathname,
|
||||||
|
@ -66,7 +111,7 @@ export default class FineRouter extends Module {
|
||||||
this._pickRoute();
|
this._pickRoute();
|
||||||
}
|
}
|
||||||
|
|
||||||
_pickRoute() {
|
private _pickRoute() {
|
||||||
const path = this.location,
|
const path = this.location,
|
||||||
host = this.domain;
|
host = this.domain;
|
||||||
|
|
||||||
|
@ -85,7 +130,6 @@ export default class FineRouter extends Module {
|
||||||
this.current_name = route.name;
|
this.current_name = route.name;
|
||||||
this.match = match;
|
this.match = match;
|
||||||
this.emit(':route', route, match);
|
this.emit(':route', route, match);
|
||||||
this.emit(`:route:${route.name}`, ...match);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -117,7 +161,7 @@ export default class FineRouter extends Module {
|
||||||
return r.url(data, opts);
|
return r.url(data, opts);
|
||||||
}
|
}
|
||||||
|
|
||||||
getRoute(name) {
|
getRoute(name: string) {
|
||||||
return this.routes[name];
|
return this.routes[name];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -132,7 +176,7 @@ export default class FineRouter extends Module {
|
||||||
return this.route_names;
|
return this.route_names;
|
||||||
}
|
}
|
||||||
|
|
||||||
getRouteName(route) {
|
getRouteName(route: string) {
|
||||||
if ( ! this.route_names[route] )
|
if ( ! this.route_names[route] )
|
||||||
this.route_names[route] = route
|
this.route_names[route] = route
|
||||||
.replace(/^dash-([a-z])/, (_, letter) =>
|
.replace(/^dash-([a-z])/, (_, letter) =>
|
||||||
|
@ -143,7 +187,7 @@ export default class FineRouter extends Module {
|
||||||
return this.route_names[route];
|
return this.route_names[route];
|
||||||
}
|
}
|
||||||
|
|
||||||
routeName(route, name, process = true) {
|
routeName(route: string | Record<string, string>, name?: string, process: boolean = true) {
|
||||||
if ( typeof route === 'object' ) {
|
if ( typeof route === 'object' ) {
|
||||||
for(const key in route)
|
for(const key in route)
|
||||||
if ( has(route, key) )
|
if ( has(route, key) )
|
||||||
|
@ -154,10 +198,12 @@ export default class FineRouter extends Module {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.route_names[route] = name;
|
if ( name ) {
|
||||||
|
this.route_names[route] = name;
|
||||||
|
|
||||||
if ( process )
|
if ( process )
|
||||||
this.emit(':updated-route-names');
|
this.emit(':updated-route-names');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
route(name, path, domain = null, state_fn = null, process = true) {
|
route(name, path, domain = null, state_fn = null, process = true) {
|
||||||
|
@ -173,6 +219,7 @@ export default class FineRouter extends Module {
|
||||||
this._pickRoute();
|
this._pickRoute();
|
||||||
this.emit(':updated-routes');
|
this.emit(':updated-routes');
|
||||||
}
|
}
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -200,4 +247,4 @@ export default class FineRouter extends Module {
|
||||||
this.emit(':updated-routes');
|
this.emit(':updated-routes');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -1,813 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Fine Lib
|
|
||||||
// It controls React.
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import {EventEmitter} from 'utilities/events';
|
|
||||||
import Module from 'utilities/module';
|
|
||||||
|
|
||||||
|
|
||||||
export default class Fine extends Module {
|
|
||||||
constructor(...args) {
|
|
||||||
super(...args);
|
|
||||||
|
|
||||||
this._wrappers = new Map;
|
|
||||||
this._known_classes = new Map;
|
|
||||||
this._observer = null;
|
|
||||||
this._waiting = [];
|
|
||||||
this._live_waiting = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async onEnable(tries=0) {
|
|
||||||
// TODO: Move awaitElement to utilities/dom
|
|
||||||
if ( ! this.root_element )
|
|
||||||
this.root_element = await this.parent.awaitElement(this.selector || 'body #root');
|
|
||||||
|
|
||||||
if ( ! this.root_element || ! this.root_element._reactRootContainer ) {
|
|
||||||
if ( tries > 500 )
|
|
||||||
throw new Error('Unable to find React after 25 seconds');
|
|
||||||
this.root_element = null;
|
|
||||||
return new Promise(r => setTimeout(r, 50)).then(() => this.onEnable(tries+1));
|
|
||||||
}
|
|
||||||
|
|
||||||
this.react_root = this.root_element._reactRootContainer;
|
|
||||||
if ( this.react_root._internalRoot && this.react_root._internalRoot.current )
|
|
||||||
this.react_root = this.react_root._internalRoot;
|
|
||||||
|
|
||||||
this.react = this.react_root.current.child;
|
|
||||||
}
|
|
||||||
|
|
||||||
onDisable() {
|
|
||||||
this.react_root = this.root_element = this.react = this.accessor = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
static findAccessor(element) {
|
|
||||||
for(const key in element)
|
|
||||||
if ( key.startsWith('__reactInternalInstance$') )
|
|
||||||
return key;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// Low Level Accessors
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
getReactInstance(element) {
|
|
||||||
if ( ! this.accessor )
|
|
||||||
this.accessor = Fine.findAccessor(element);
|
|
||||||
if ( ! this.accessor )
|
|
||||||
return;
|
|
||||||
|
|
||||||
return element[this.accessor] || (element._reactRootContainer && element._reactRootContainer._internalRoot && element._reactRootContainer._internalRoot.current) || (element._reactRootContainer && element._reactRootContainer.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
getOwner(instance) {
|
|
||||||
if ( instance._reactInternalFiber )
|
|
||||||
instance = instance._reactInternalFiber;
|
|
||||||
else if ( instance instanceof Node )
|
|
||||||
instance = this.getReactInstance(instance);
|
|
||||||
|
|
||||||
if ( ! instance )
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return instance.return;
|
|
||||||
}
|
|
||||||
|
|
||||||
getParentNode(instance, max_depth = 100, traverse_roots = false) {
|
|
||||||
/*if ( instance._reactInternalFiber )
|
|
||||||
instance = instance._reactInternalFiber;
|
|
||||||
else if ( instance instanceof Node )
|
|
||||||
instance = this.getReactInstance(instance);
|
|
||||||
|
|
||||||
while( instance )
|
|
||||||
if ( instance.stateNode instanceof Node )
|
|
||||||
return instance.stateNode
|
|
||||||
else
|
|
||||||
instance = instance.parent;*/
|
|
||||||
|
|
||||||
return this.searchParent(instance, n => n instanceof Node, max_depth, 0, traverse_roots);
|
|
||||||
}
|
|
||||||
|
|
||||||
getChildNode(instance, max_depth = 100, traverse_roots = false) {
|
|
||||||
/*if ( instance._reactInternalFiber )
|
|
||||||
instance = instance._reactInternalFiber;
|
|
||||||
else if ( instance instanceof Node )
|
|
||||||
instance = this.getReactInstance(instance);
|
|
||||||
|
|
||||||
while( instance )
|
|
||||||
if ( instance.stateNode instanceof Node )
|
|
||||||
return instance.stateNode
|
|
||||||
else {
|
|
||||||
max_depth--;
|
|
||||||
if ( max_depth < 0 )
|
|
||||||
return null;
|
|
||||||
instance = instance.child;
|
|
||||||
}*/
|
|
||||||
|
|
||||||
return this.searchTree(instance, n => n instanceof Node, max_depth, 0, traverse_roots);
|
|
||||||
}
|
|
||||||
|
|
||||||
getHostNode(instance, max_depth = 100) {
|
|
||||||
return this.getChildNode(instance, max_depth);
|
|
||||||
}
|
|
||||||
|
|
||||||
getParent(instance) {
|
|
||||||
return this.getOwner(instance);
|
|
||||||
}
|
|
||||||
|
|
||||||
getFirstChild(node) {
|
|
||||||
if ( node._reactInternalFiber )
|
|
||||||
node = node._reactInternalFiber;
|
|
||||||
else if ( node instanceof Node )
|
|
||||||
node = this.getReactInstance(node);
|
|
||||||
|
|
||||||
if ( ! node )
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return node.child;
|
|
||||||
}
|
|
||||||
|
|
||||||
getChildren(node) {
|
|
||||||
if ( node._reactInternalFiber )
|
|
||||||
node = node._reactInternalFiber;
|
|
||||||
else if ( node instanceof Node )
|
|
||||||
node = this.getReactInstance(node);
|
|
||||||
|
|
||||||
if ( ! node )
|
|
||||||
return null;
|
|
||||||
|
|
||||||
const children = [];
|
|
||||||
let child = node.child;
|
|
||||||
while(child) {
|
|
||||||
children.push(child);
|
|
||||||
child = child.sibling;
|
|
||||||
}
|
|
||||||
|
|
||||||
return children;
|
|
||||||
}
|
|
||||||
|
|
||||||
searchParent(node, criteria, max_depth=15, depth=0, traverse_roots = true) {
|
|
||||||
if ( node._reactInternalFiber )
|
|
||||||
node = node._reactInternalFiber;
|
|
||||||
else if ( node instanceof Node )
|
|
||||||
node = this.getReactInstance(node);
|
|
||||||
|
|
||||||
if ( ! node || node._ffz_no_scan || depth > max_depth )
|
|
||||||
return null;
|
|
||||||
|
|
||||||
if ( typeof criteria === 'string' ) {
|
|
||||||
const wrapper = this._wrappers.get(criteria);
|
|
||||||
if ( ! wrapper )
|
|
||||||
throw new Error('invalid critera');
|
|
||||||
|
|
||||||
if ( ! wrapper._class )
|
|
||||||
return null;
|
|
||||||
|
|
||||||
criteria = n => n && n.constructor === wrapper._class;
|
|
||||||
}
|
|
||||||
|
|
||||||
const inst = node.stateNode;
|
|
||||||
if ( inst && criteria(inst) )
|
|
||||||
return inst;
|
|
||||||
|
|
||||||
if ( node.return ) {
|
|
||||||
const result = this.searchParent(node.return, criteria, max_depth, depth+1, traverse_roots);
|
|
||||||
if ( result )
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stupid code for traversing up into another React root.
|
|
||||||
if ( traverse_roots && inst && inst.containerInfo ) {
|
|
||||||
const parent = inst.containerInfo.parentElement,
|
|
||||||
parent_node = parent && this.getReactInstance(parent);
|
|
||||||
|
|
||||||
if ( parent_node ) {
|
|
||||||
const result = this.searchParent(parent_node, criteria, max_depth, depth+1, traverse_roots);
|
|
||||||
if ( result )
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
searchNode(node, criteria, max_depth=15, depth=0, traverse_roots = true) {
|
|
||||||
if ( ! node )
|
|
||||||
node = this.react;
|
|
||||||
else if ( node._reactInternalFiber )
|
|
||||||
node = node._reactInternalFiber;
|
|
||||||
else if ( node instanceof Node )
|
|
||||||
node = this.getReactInstance(node);
|
|
||||||
|
|
||||||
if ( ! node || node._ffz_no_scan || depth > max_depth )
|
|
||||||
return null;
|
|
||||||
|
|
||||||
if ( typeof criteria === 'string' ) {
|
|
||||||
const wrapper = this._wrappers.get(criteria);
|
|
||||||
if ( ! wrapper )
|
|
||||||
throw new Error('invalid critera');
|
|
||||||
|
|
||||||
if ( ! wrapper._class )
|
|
||||||
return null;
|
|
||||||
|
|
||||||
criteria = n => n && n.constructor === wrapper._class;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( node && criteria(node) )
|
|
||||||
return node;
|
|
||||||
|
|
||||||
if ( node.child ) {
|
|
||||||
let child = node.child;
|
|
||||||
while(child) {
|
|
||||||
const result = this.searchNode(child, criteria, max_depth, depth+1, traverse_roots);
|
|
||||||
if ( result )
|
|
||||||
return result;
|
|
||||||
child = child.sibling;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const inst = node.stateNode;
|
|
||||||
if ( traverse_roots && inst && inst.props && inst.props.root ) {
|
|
||||||
const root = inst.props.root._reactRootContainer;
|
|
||||||
if ( root ) {
|
|
||||||
let child = root._internalRoot && root._internalRoot.current || root.current;
|
|
||||||
while(child) {
|
|
||||||
const result = this.searchNode(child, criteria, max_depth, depth+1, traverse_roots);
|
|
||||||
if ( result )
|
|
||||||
return result;
|
|
||||||
|
|
||||||
child = child.sibling;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
searchTree(node, criteria, max_depth=15, depth=0, traverse_roots = true, multi = false) {
|
|
||||||
if ( ! node )
|
|
||||||
node = this.react;
|
|
||||||
else if ( node._reactInternalFiber )
|
|
||||||
node = node._reactInternalFiber;
|
|
||||||
else if ( node instanceof Node )
|
|
||||||
node = this.getReactInstance(node);
|
|
||||||
|
|
||||||
if ( multi ) {
|
|
||||||
if ( !(multi instanceof Set) )
|
|
||||||
multi = new Set;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( multi && ! (multi instanceof Set) )
|
|
||||||
multi = new Set;
|
|
||||||
|
|
||||||
if ( ! node || node._ffz_no_scan || depth > max_depth )
|
|
||||||
return multi ? multi : null;
|
|
||||||
|
|
||||||
if ( typeof criteria === 'string' ) {
|
|
||||||
const wrapper = this._wrappers.get(criteria);
|
|
||||||
if ( ! wrapper )
|
|
||||||
throw new Error('invalid critera');
|
|
||||||
|
|
||||||
if ( ! wrapper._class )
|
|
||||||
return multi ? multi : null;
|
|
||||||
|
|
||||||
criteria = n => n && n.constructor === wrapper._class;
|
|
||||||
}
|
|
||||||
|
|
||||||
const inst = node.stateNode;
|
|
||||||
if ( inst && criteria(inst, node) ) {
|
|
||||||
if ( multi )
|
|
||||||
multi.add(inst);
|
|
||||||
else
|
|
||||||
return inst;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( node.child ) {
|
|
||||||
let child = node.child;
|
|
||||||
while(child) {
|
|
||||||
const result = this.searchTree(child, criteria, max_depth, depth+1, traverse_roots, multi);
|
|
||||||
if ( result && ! multi )
|
|
||||||
return result;
|
|
||||||
child = child.sibling;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( traverse_roots && inst && inst.props && inst.props.root ) {
|
|
||||||
const root = inst.props.root._reactRootContainer;
|
|
||||||
if ( root ) {
|
|
||||||
let child = root._internalRoot && root._internalRoot.current || root.current;
|
|
||||||
while(child) {
|
|
||||||
const result = this.searchTree(child, criteria, max_depth, depth+1, traverse_roots, multi);
|
|
||||||
if ( result && ! multi )
|
|
||||||
return result;
|
|
||||||
|
|
||||||
child = child.sibling;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( multi )
|
|
||||||
return multi;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
findAllMatching(node, criteria, max_depth=15, single_class = true, parents=false, depth=0, traverse_roots=true) {
|
|
||||||
const matches = new Set;
|
|
||||||
let crit = n => ! matches.has(n) && criteria(n);
|
|
||||||
|
|
||||||
while(true) {
|
|
||||||
const match = parents ?
|
|
||||||
this.searchParent(node, crit, max_depth, depth, traverse_roots) :
|
|
||||||
this.searchTree(node, crit, max_depth, depth, traverse_roots);
|
|
||||||
|
|
||||||
if ( ! match )
|
|
||||||
break;
|
|
||||||
|
|
||||||
if ( single_class && ! matches.size ) {
|
|
||||||
const klass = match.constructor;
|
|
||||||
crit = n => ! matches.has(n) && (n instanceof klass) && criteria(n);
|
|
||||||
}
|
|
||||||
|
|
||||||
matches.add(match);
|
|
||||||
}
|
|
||||||
|
|
||||||
return matches;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
searchAll(node, criterias, max_depth=15, depth=0, data, traverse_roots = true) {
|
|
||||||
if ( ! node )
|
|
||||||
node = this.react;
|
|
||||||
else if ( node._reactInternalFiber )
|
|
||||||
node = node._reactInternalFiber;
|
|
||||||
else if ( node instanceof Node )
|
|
||||||
node = this.getReactInstance(node);
|
|
||||||
|
|
||||||
if ( ! data )
|
|
||||||
data = {
|
|
||||||
seen: new Set,
|
|
||||||
classes: criterias.map(() => null),
|
|
||||||
out: criterias.map(() => ({
|
|
||||||
cls: null, instances: new Set, depth: null
|
|
||||||
})),
|
|
||||||
max_depth: depth
|
|
||||||
};
|
|
||||||
|
|
||||||
if ( ! node || node._ffz_no_scan || depth > max_depth )
|
|
||||||
return data.out;
|
|
||||||
|
|
||||||
if ( depth > data.max_depth )
|
|
||||||
data.max_depth = depth;
|
|
||||||
|
|
||||||
const inst = node.stateNode;
|
|
||||||
if ( inst ) {
|
|
||||||
const cls = inst.constructor,
|
|
||||||
idx = data.classes.indexOf(cls);
|
|
||||||
|
|
||||||
if ( idx !== -1 )
|
|
||||||
data.out[idx].instances.add(inst);
|
|
||||||
|
|
||||||
else if ( ! data.seen.has(cls) ) {
|
|
||||||
let i = criterias.length;
|
|
||||||
while(i-- > 0)
|
|
||||||
if ( criterias[i](inst) ) {
|
|
||||||
data.classes[i] = data.out[i].cls = cls;
|
|
||||||
data.out[i].instances.add(inst);
|
|
||||||
data.out[i].depth = depth;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
data.seen.add(cls);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let child = node.child;
|
|
||||||
while(child) {
|
|
||||||
this.searchAll(child, criterias, max_depth, depth+1, data, traverse_roots);
|
|
||||||
child = child.sibling;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( traverse_roots && inst && inst.props && inst.props.root ) {
|
|
||||||
const root = inst.props.root._reactRootContainer;
|
|
||||||
if ( root ) {
|
|
||||||
let child = root._internalRoot && root._internalRoot.current || root.current;
|
|
||||||
while(child) {
|
|
||||||
this.searchAll(child, criterias, max_depth, depth+1, data, traverse_roots);
|
|
||||||
child = child.sibling;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return data.out;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// Class Wrapping
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
route(route) {
|
|
||||||
this._route = route;
|
|
||||||
this._updateLiveWaiting();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
_updateLiveWaiting() {
|
|
||||||
const lw = this._live_waiting = [],
|
|
||||||
crt = this._waiting_crit = [],
|
|
||||||
route = this._route;
|
|
||||||
|
|
||||||
if ( this._waiting )
|
|
||||||
for(const waiter of this._waiting)
|
|
||||||
if ( ! route || ! waiter.routes.length || waiter.routes.includes(route) ) {
|
|
||||||
lw.push(waiter);
|
|
||||||
crt.push(waiter.criteria);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( ! this._live_waiting.length )
|
|
||||||
this._stopWaiting();
|
|
||||||
else if ( ! this._waiting_timer )
|
|
||||||
this._startWaiting();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
define(key, criteria, routes) {
|
|
||||||
if ( this._wrappers.has(key) )
|
|
||||||
return this._wrappers.get(key);
|
|
||||||
|
|
||||||
if ( ! criteria )
|
|
||||||
throw new Error('cannot find definition and no criteria provided');
|
|
||||||
|
|
||||||
const wrapper = new FineWrapper(key, criteria, routes, this);
|
|
||||||
this._wrappers.set(key, wrapper);
|
|
||||||
|
|
||||||
const data = this.searchAll(this.react, [criteria], 1000)[0];
|
|
||||||
if ( data.cls ) {
|
|
||||||
wrapper._set(data.cls, data.instances);
|
|
||||||
this._known_classes.set(data.cls, wrapper);
|
|
||||||
|
|
||||||
} else if ( routes !== false ) {
|
|
||||||
this._waiting.push(wrapper);
|
|
||||||
this._updateLiveWaiting();
|
|
||||||
}
|
|
||||||
|
|
||||||
return wrapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
wrap(key, cls) {
|
|
||||||
let wrapper;
|
|
||||||
if ( this._wrappers.has(key) )
|
|
||||||
wrapper = this._wrappers.get(key);
|
|
||||||
else {
|
|
||||||
wrapper = new FineWrapper(key, null, undefined, this);
|
|
||||||
this._wrappers.set(key, wrapper);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( cls ) {
|
|
||||||
if ( wrapper._class || wrapper.criteria )
|
|
||||||
throw new Error('tried setting a class on an already initialized FineWrapper');
|
|
||||||
|
|
||||||
wrapper._set(cls, new Set);
|
|
||||||
this._known_classes.set(cls, wrapper);
|
|
||||||
}
|
|
||||||
|
|
||||||
return wrapper;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
_checkWaiters(nodes) {
|
|
||||||
if ( ! this._live_waiting )
|
|
||||||
return;
|
|
||||||
|
|
||||||
if ( ! Array.isArray(nodes) )
|
|
||||||
nodes = [nodes];
|
|
||||||
|
|
||||||
for(let node of nodes) {
|
|
||||||
if ( ! node )
|
|
||||||
node = this.react;
|
|
||||||
else if ( node._reactInternalFiber )
|
|
||||||
node = node._reactInternalFiber;
|
|
||||||
else if ( node instanceof Node )
|
|
||||||
node = this.getReactInstance(node);
|
|
||||||
|
|
||||||
if ( ! node || node._ffz_no_scan || ! this._live_waiting.length )
|
|
||||||
continue;
|
|
||||||
|
|
||||||
const data = this.searchAll(node, this._waiting_crit, 1000);
|
|
||||||
let i = data.length;
|
|
||||||
while(i-- > 0) {
|
|
||||||
if ( data[i].cls ) {
|
|
||||||
const d = data[i],
|
|
||||||
w = this._live_waiting.splice(i, 1)[0];
|
|
||||||
|
|
||||||
this._waiting_crit.splice(i, 1);
|
|
||||||
|
|
||||||
const idx = this._waiting.indexOf(w);
|
|
||||||
if ( idx !== -1 )
|
|
||||||
this._waiting.splice(idx, 1);
|
|
||||||
|
|
||||||
this.log.debug(`Found class for "${w.name}" at depth ${d.depth}`);
|
|
||||||
w._set(d.cls, d.instances);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( ! this._live_waiting.length )
|
|
||||||
this._stopWaiting();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
_startWaiting() {
|
|
||||||
this.log.info('Installing MutationObserver.');
|
|
||||||
this._waiting_timer = setInterval(() => this._checkWaiters(), 500);
|
|
||||||
|
|
||||||
if ( ! this._observer )
|
|
||||||
this._observer = new MutationObserver(mutations =>
|
|
||||||
this._checkWaiters(mutations.map(x => x.target))
|
|
||||||
);
|
|
||||||
|
|
||||||
this._observer.observe(document.body, {
|
|
||||||
childList: true,
|
|
||||||
subtree: true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
_stopWaiting() {
|
|
||||||
this.log.info('Stopping MutationObserver.');
|
|
||||||
|
|
||||||
if ( this._observer )
|
|
||||||
this._observer.disconnect();
|
|
||||||
|
|
||||||
if ( this._waiting_timer )
|
|
||||||
clearInterval(this._waiting_timer);
|
|
||||||
|
|
||||||
this._live_waiting = null;
|
|
||||||
this._waiting_crit = null;
|
|
||||||
this._waiting_timer = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const EVENTS = {
|
|
||||||
'will-mount': 'UNSAFE_componentWillMount',
|
|
||||||
mount: 'componentDidMount',
|
|
||||||
render: 'render',
|
|
||||||
'receive-props': 'UNSAFE_componentWillReceiveProps',
|
|
||||||
'should-update': 'shouldComponentUpdate',
|
|
||||||
'will-update': 'UNSAFE_componentWillUpdate',
|
|
||||||
update: 'componentDidUpdate',
|
|
||||||
unmount: 'componentWillUnmount'
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export class FineWrapper extends EventEmitter {
|
|
||||||
constructor(name, criteria, routes, fine) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.name = name;
|
|
||||||
this.criteria = criteria;
|
|
||||||
this.fine = fine;
|
|
||||||
|
|
||||||
this.instances = new Set;
|
|
||||||
this.routes = routes || [];
|
|
||||||
|
|
||||||
this._wrapped = new Set;
|
|
||||||
this._class = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
get first() {
|
|
||||||
return this.toArray()[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
toArray() {
|
|
||||||
return Array.from(this.instances);
|
|
||||||
}
|
|
||||||
|
|
||||||
check(node = null, max_depth = 1000) {
|
|
||||||
if ( this._class )
|
|
||||||
return;
|
|
||||||
|
|
||||||
const instances = this.fine.findAllMatching(node, this.criteria, max_depth);
|
|
||||||
if ( instances.size ) {
|
|
||||||
const insts = Array.from(instances);
|
|
||||||
this._set(insts[0].constructor, insts);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ready(fn) {
|
|
||||||
if ( this._class )
|
|
||||||
fn(this._class, this.instances);
|
|
||||||
else
|
|
||||||
this.once('set', fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
each(fn) {
|
|
||||||
for(const inst of this.instances)
|
|
||||||
fn(inst);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateInstances(node = null, max_depth = 1000) {
|
|
||||||
if ( ! this._class )
|
|
||||||
return;
|
|
||||||
|
|
||||||
const instances = this.fine.findAllMatching(node, n => n.constructor === this._class, max_depth);
|
|
||||||
|
|
||||||
for(const inst of instances) {
|
|
||||||
inst._ffz_mounted = true;
|
|
||||||
this.instances.add(inst);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_set(cls, instances) {
|
|
||||||
if ( this._class )
|
|
||||||
throw new Error('already have a class');
|
|
||||||
|
|
||||||
this._class = cls;
|
|
||||||
this._wrapped.add('UNSAFE_componentWillMount');
|
|
||||||
this._wrapped.add('componentWillUnmount');
|
|
||||||
|
|
||||||
cls._ffz_wrapper = this;
|
|
||||||
|
|
||||||
const t = this,
|
|
||||||
_instances = this.instances,
|
|
||||||
proto = cls.prototype,
|
|
||||||
o_mount = proto.UNSAFE_componentWillMount,
|
|
||||||
o_unmount = proto.componentWillUnmount,
|
|
||||||
|
|
||||||
mount = proto.UNSAFE_componentWillMount = o_mount ?
|
|
||||||
function(...args) {
|
|
||||||
this._ffz_mounted = true;
|
|
||||||
_instances.add(this);
|
|
||||||
t.emit('will-mount', this, ...args);
|
|
||||||
return o_mount.apply(this, args);
|
|
||||||
} :
|
|
||||||
function(...args) {
|
|
||||||
this._ffz_mounted = true;
|
|
||||||
_instances.add(this);
|
|
||||||
t.emit('will-mount', this, ...args);
|
|
||||||
},
|
|
||||||
|
|
||||||
unmount = proto.componentWillUnmount = o_unmount ?
|
|
||||||
function(...args) {
|
|
||||||
t.emit('unmount', this, ...args);
|
|
||||||
_instances.delete(this);
|
|
||||||
this._ffz_mounted = false;
|
|
||||||
return o_unmount.apply(this, args);
|
|
||||||
} :
|
|
||||||
function(...args) {
|
|
||||||
t.emit('unmount', this, ...args);
|
|
||||||
_instances.delete(this);
|
|
||||||
this._ffz_mounted = false;
|
|
||||||
};
|
|
||||||
|
|
||||||
this.__UNSAFE_componentWillMount = [mount, o_mount];
|
|
||||||
this.__componentWillUnmount = [unmount, o_unmount];
|
|
||||||
|
|
||||||
for(const event of this.events())
|
|
||||||
this._maybeWrap(event);
|
|
||||||
|
|
||||||
if ( instances )
|
|
||||||
for(const inst of instances) {
|
|
||||||
// How do we check mounted state for fibers?
|
|
||||||
// Just assume they're mounted for now I guess.
|
|
||||||
inst._ffz_mounted = true;
|
|
||||||
_instances.add(inst);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.emit('set', cls, _instances);
|
|
||||||
}
|
|
||||||
|
|
||||||
_add(instances) {
|
|
||||||
for(const inst of instances)
|
|
||||||
this.instances.add(inst);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
_maybeWrap(event) {
|
|
||||||
const key = EVENTS[event];
|
|
||||||
if ( ! this._class || ! key || this._wrapped.has(key) )
|
|
||||||
return;
|
|
||||||
|
|
||||||
this._wrap(event, key);
|
|
||||||
}
|
|
||||||
|
|
||||||
_wrap(event, key) {
|
|
||||||
if ( this._wrapped.has(key) )
|
|
||||||
return;
|
|
||||||
|
|
||||||
const t = this,
|
|
||||||
proto = this._class.prototype,
|
|
||||||
original = proto[key],
|
|
||||||
|
|
||||||
fn = proto[key] = original ?
|
|
||||||
function(...args) {
|
|
||||||
if ( ! this._ffz_mounted ) {
|
|
||||||
this._ffz_mounted = true;
|
|
||||||
t.instances.add(this);
|
|
||||||
t.emit('late-mount', this);
|
|
||||||
}
|
|
||||||
|
|
||||||
t.emit(event, this, ...args);
|
|
||||||
return original.apply(this, args);
|
|
||||||
} :
|
|
||||||
|
|
||||||
key === 'shouldComponentUpdate' ?
|
|
||||||
function(...args) {
|
|
||||||
if ( ! this._ffz_mounted ) {
|
|
||||||
this._ffz_mounted = true;
|
|
||||||
t.instances.add(this);
|
|
||||||
t.emit('late-mount', this);
|
|
||||||
}
|
|
||||||
|
|
||||||
t.emit(event, this, ...args);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
:
|
|
||||||
function(...args) {
|
|
||||||
if ( ! this._ffz_mounted ) {
|
|
||||||
this._ffz_mounted = true;
|
|
||||||
t.instances.add(this);
|
|
||||||
t.emit('late-mount', this);
|
|
||||||
}
|
|
||||||
|
|
||||||
t.emit(event, this, ...args);
|
|
||||||
};
|
|
||||||
|
|
||||||
this[`__${key}`] = [fn, original];
|
|
||||||
}
|
|
||||||
|
|
||||||
_unwrap(key) {
|
|
||||||
if ( ! this._wrapped.has(key) )
|
|
||||||
return;
|
|
||||||
|
|
||||||
const k = `__${key}`,
|
|
||||||
proto = this._class.prototype,
|
|
||||||
[fn, original] = this[k];
|
|
||||||
|
|
||||||
if ( proto[key] !== fn )
|
|
||||||
throw new Error('unable to unwrap -- prototype modified');
|
|
||||||
|
|
||||||
proto[key] = original;
|
|
||||||
this[k] = undefined;
|
|
||||||
this._wrapped.delete(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
forceUpdate() {
|
|
||||||
for(const inst of this.instances)
|
|
||||||
try {
|
|
||||||
inst.forceUpdate();
|
|
||||||
this.fine.emit('site:dom-update', this.name, inst);
|
|
||||||
|
|
||||||
} catch(err) {
|
|
||||||
this.fine.log.capture(err, {
|
|
||||||
tags: {
|
|
||||||
fine_wrapper: this.name
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
this.fine.log.error(`An error occurred when calling forceUpdate on an instance of ${this.name}`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
on(event, fn, ctx) {
|
|
||||||
this._maybeWrap(event);
|
|
||||||
return super.on(event, fn, ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
prependOn(event, fn, ctx) {
|
|
||||||
this._maybeWrap(event);
|
|
||||||
return super.prependOn(event, fn, ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
once(event, fn, ctx) {
|
|
||||||
this._maybeWrap(event);
|
|
||||||
return super.once(event, fn, ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
prependOnce(event, fn, ctx) {
|
|
||||||
this._maybeWrap(event);
|
|
||||||
return super.prependOnce(event, fn, ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
many(event, ttl, fn, ctx) {
|
|
||||||
this._maybeWrap(event);
|
|
||||||
return super.many(event, ttl, fn, ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
prependMany(event, ttl, fn, ctx) {
|
|
||||||
this._maybeWrap(event);
|
|
||||||
return super.prependMany(event, ttl, fn, ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
waitFor(event) {
|
|
||||||
this._maybeWrap(event);
|
|
||||||
return super.waitFor(event);
|
|
||||||
}
|
|
||||||
}
|
|
1032
src/utilities/compat/fine.ts
Normal file
1032
src/utilities/compat/fine.ts
Normal file
File diff suppressed because it is too large
Load diff
75
src/utilities/compat/react-types.ts
Normal file
75
src/utilities/compat/react-types.ts
Normal file
|
@ -0,0 +1,75 @@
|
||||||
|
|
||||||
|
// You might be wondering why we're homebrewing React types when we could just
|
||||||
|
// reply on @types/react.
|
||||||
|
//
|
||||||
|
// It's simple. TypeScript is obtuse and refuses to NOT use @types/react if
|
||||||
|
// the package is installed. That breaks our own JSX use, and so we can't use
|
||||||
|
// those types.
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Node {
|
||||||
|
[key: ReactAccessor]: ReactNode | undefined;
|
||||||
|
_reactRootContainer?: ReactRoot;
|
||||||
|
_ffz_no_scan?: boolean;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ReactAccessor = `__reactInternalInstance$${string}`;
|
||||||
|
|
||||||
|
export type ReactRoot = {
|
||||||
|
_internalRoot?: ReactRoot;
|
||||||
|
current: ReactNode | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ReactNode = {
|
||||||
|
alternate: ReactNode | null;
|
||||||
|
child: ReactNode | null;
|
||||||
|
return: ReactNode | null;
|
||||||
|
sibling: ReactNode | null;
|
||||||
|
stateNode: ReactStateNode | Node | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type ReactStateNode<
|
||||||
|
TProps extends {} = {},
|
||||||
|
TState extends {} = {},
|
||||||
|
TSnapshot extends {} = {}
|
||||||
|
> = {
|
||||||
|
|
||||||
|
// FFZ Helpers
|
||||||
|
_ffz_no_scan?: boolean;
|
||||||
|
_ffz_mounted?: boolean;
|
||||||
|
|
||||||
|
// Access to the internal node.
|
||||||
|
_reactInternalFiber: ReactNode | null;
|
||||||
|
|
||||||
|
// Stuff
|
||||||
|
props: TProps;
|
||||||
|
state: TState | null;
|
||||||
|
|
||||||
|
// Lifecycle Methods
|
||||||
|
componentDidMount?(): void;
|
||||||
|
componentDidUpdate?(prevProps: TProps, prevState: TState, snapshot: TSnapshot | null): void;
|
||||||
|
componentWillUnmount?(): void;
|
||||||
|
shouldComponentUpdate?(nextProps: TProps, nextState: TState): boolean;
|
||||||
|
getSnapshotBeforeUpdate?(prevProps: TProps, prevState: TState): TSnapshot | null;
|
||||||
|
componentDidCatch?(error: any, info: any): void;
|
||||||
|
|
||||||
|
/** @deprecated Will be removed in React 17 */
|
||||||
|
UNSAFE_componentWillMount?(): void;
|
||||||
|
/** @deprecated Will be removed in React 17 */
|
||||||
|
UNSAFE_componentWillReceiveProps?(nextProps: TProps): void;
|
||||||
|
/** @deprecated Will be removed in React 17 */
|
||||||
|
UNSAFE_componentWillUpdate?(nextProps: TProps, nextState: TState): void;
|
||||||
|
|
||||||
|
setState(
|
||||||
|
updater: Partial<TState> | ((state: TState, props: TProps) => Partial<TState>),
|
||||||
|
callback?: () => void
|
||||||
|
): void;
|
||||||
|
|
||||||
|
// TODO: Implement proper return type.
|
||||||
|
render(): any;
|
||||||
|
|
||||||
|
forceUpdate(callback?: () => void): void;
|
||||||
|
|
||||||
|
};
|
|
@ -5,11 +5,88 @@
|
||||||
// It controls Twitch PubSub.
|
// It controls Twitch PubSub.
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
import Module from 'utilities/module';
|
import Module, { GenericModule } from 'utilities/module';
|
||||||
import { FFZEvent } from 'utilities/events';
|
import { FFZEvent } from 'utilities/events';
|
||||||
|
|
||||||
export class PubSubEvent extends FFZEvent {
|
declare global {
|
||||||
constructor(data) {
|
interface Window {
|
||||||
|
__twitch_pubsub_client: TwitchPubSubClient | null | undefined;
|
||||||
|
//__Twitch__pubsubInstances: any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
declare module 'utilities/types' {
|
||||||
|
interface ModuleEventMap {
|
||||||
|
'site.subpump': SubpumpEvents;
|
||||||
|
}
|
||||||
|
interface ModuleMap {
|
||||||
|
'site.subpump': Subpump;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This is a rough map of the parts of Twitch's PubSub client that we
|
||||||
|
* care about for our purposes.
|
||||||
|
*/
|
||||||
|
type TwitchPubSubClient = {
|
||||||
|
|
||||||
|
connection: {
|
||||||
|
removeAllListeners(topic: string): void;
|
||||||
|
addListener(topic: string, listener: (event: any) => void): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
topicListeners?: {
|
||||||
|
_events?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMessage(event: TwitchPubSubMessageEvent): any;
|
||||||
|
|
||||||
|
listen(opts: { topic: string }, listener: (event: any) => void, ...args: any[]): void;
|
||||||
|
unlisten(topic: string, listener: (event: any) => void, ...args: any[]): void;
|
||||||
|
|
||||||
|
ffz_original_listen?: (opts: { topic: string }, listener: (event: any) => void, ...args: any[]) => void;
|
||||||
|
ffz_original_unlisten?: (topic: string, listener: (event: any) => void, ...args: any[]) => void;
|
||||||
|
|
||||||
|
simulateMessage(topic: string, message: string): void;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
type TwitchPubSubMessageEvent = {
|
||||||
|
type: string;
|
||||||
|
data?: {
|
||||||
|
topic: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
export type RawPubSubEventData = {
|
||||||
|
prefix: string;
|
||||||
|
trail: string;
|
||||||
|
event: {
|
||||||
|
topic: string;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class PubSubEvent<TMessage = any> extends FFZEvent<RawPubSubEventData> {
|
||||||
|
|
||||||
|
_obj?: TMessage;
|
||||||
|
_changed: boolean;
|
||||||
|
|
||||||
|
// This is assigned in super()
|
||||||
|
prefix: string = null as any;
|
||||||
|
trail: string = null as any;
|
||||||
|
event: {
|
||||||
|
topic: string;
|
||||||
|
message: string;
|
||||||
|
} = null as any;
|
||||||
|
|
||||||
|
constructor(data: RawPubSubEventData) {
|
||||||
super(data);
|
super(data);
|
||||||
|
|
||||||
this._obj = undefined;
|
this._obj = undefined;
|
||||||
|
@ -24,31 +101,45 @@ export class PubSubEvent extends FFZEvent {
|
||||||
return this.event.topic;
|
return this.event.topic;
|
||||||
}
|
}
|
||||||
|
|
||||||
get message() {
|
get message(): TMessage {
|
||||||
if ( this._obj === undefined )
|
if ( this._obj === undefined )
|
||||||
this._obj = JSON.parse(this.event.message);
|
this._obj = JSON.parse(this.event.message) ?? null;
|
||||||
|
|
||||||
return this._obj;
|
return this._obj as TMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
set message(val) {
|
set message(val) {
|
||||||
this._obj = val;
|
this._obj = val;
|
||||||
this._changed = true;
|
this._changed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class Subpump extends Module {
|
|
||||||
|
|
||||||
constructor(...args) {
|
export type SubpumpEvents = {
|
||||||
super(...args);
|
/** A message was received via Twitch's PubSub connection. */
|
||||||
|
':pubsub-message': [event: PubSubEvent];
|
||||||
|
/** Twitch subscribed to a new topic. */
|
||||||
|
':add-topic': [topic: string];
|
||||||
|
/** Twitch unsubscribed from a topic. */
|
||||||
|
':remove-topic': [topic: string];
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export default class Subpump extends Module<'site.subpump', SubpumpEvents> {
|
||||||
|
|
||||||
|
instance?: TwitchPubSubClient | null;
|
||||||
|
|
||||||
|
constructor(name?: string, parent?: GenericModule) {
|
||||||
|
super(name, parent);
|
||||||
this.instance = null;
|
this.instance = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
onEnable(tries = 0) {
|
onEnable(tries = 0) {
|
||||||
const instance = window.__twitch_pubsub_client,
|
const instance = window.__twitch_pubsub_client;
|
||||||
instances = window.__Twitch__pubsubInstances;
|
//instances = window.__Twitch__pubsubInstances;
|
||||||
|
|
||||||
if ( ! instance && ! instances ) {
|
if ( ! instance ) { //} && ! instances ) {
|
||||||
if ( tries > 10 )
|
if ( tries > 10 )
|
||||||
this.log.warn('Unable to find PubSub.');
|
this.log.warn('Unable to find PubSub.');
|
||||||
else
|
else
|
||||||
|
@ -62,6 +153,7 @@ export default class Subpump extends Module {
|
||||||
this.hookClient(instance);
|
this.hookClient(instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
else if ( instances ) {
|
else if ( instances ) {
|
||||||
for(const val of Object.values(instances))
|
for(const val of Object.values(instances))
|
||||||
if ( val?._client ) {
|
if ( val?._client ) {
|
||||||
|
@ -74,12 +166,13 @@ export default class Subpump extends Module {
|
||||||
this.hookOldClient(val._client);
|
this.hookOldClient(val._client);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
if ( ! this.instance )
|
if ( ! this.instance )
|
||||||
this.log.warn('Unable to find a PubSub instance.');
|
this.log.warn('Unable to find a PubSub instance.');
|
||||||
}
|
}
|
||||||
|
|
||||||
handleMessage(msg) {
|
handleMessage(msg: TwitchPubSubMessageEvent) {
|
||||||
try {
|
try {
|
||||||
if ( msg.type === 'MESSAGE' && msg.data?.topic ) {
|
if ( msg.type === 'MESSAGE' && msg.data?.topic ) {
|
||||||
const raw_topic = msg.data.topic,
|
const raw_topic = msg.data.topic,
|
||||||
|
@ -108,11 +201,11 @@ export default class Subpump extends Module {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
hookClient(client) {
|
hookClient(client: TwitchPubSubClient) {
|
||||||
const t = this,
|
const t = this,
|
||||||
orig_message = client.onMessage;
|
orig_message = client.onMessage;
|
||||||
|
|
||||||
this.is_old = false;
|
//this.is_old = false;
|
||||||
|
|
||||||
client.connection.removeAllListeners('message');
|
client.connection.removeAllListeners('message');
|
||||||
|
|
||||||
|
@ -153,66 +246,24 @@ export default class Subpump extends Module {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
hookOldClient(client) {
|
simulateMessage(topic: string, message: any) {
|
||||||
const t = this,
|
|
||||||
orig_message = client._onMessage;
|
|
||||||
|
|
||||||
this.is_old = true;
|
|
||||||
|
|
||||||
client._unbindPrimary(client._primarySocket);
|
|
||||||
|
|
||||||
client._onMessage = function(e) {
|
|
||||||
if ( t.handleMessage(e) )
|
|
||||||
return;
|
|
||||||
|
|
||||||
return orig_message.call(this, e);
|
|
||||||
};
|
|
||||||
|
|
||||||
client._bindPrimary(client._primarySocket);
|
|
||||||
|
|
||||||
const listener = client._listens,
|
|
||||||
orig_on = listener.on,
|
|
||||||
orig_off = listener.off;
|
|
||||||
|
|
||||||
listener.on = function(topic, fn, ctx) {
|
|
||||||
const has_topic = !! listener._events?.[topic],
|
|
||||||
out = orig_on.call(this, topic, fn, ctx);
|
|
||||||
|
|
||||||
if ( ! has_topic )
|
|
||||||
t.emit(':add-topic', topic)
|
|
||||||
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
listener.off = function(topic, fn) {
|
|
||||||
const has_topic = !! listener._events?.[topic],
|
|
||||||
out = orig_off.call(this, topic, fn);
|
|
||||||
|
|
||||||
if ( has_topic && ! listener._events?.[topic] )
|
|
||||||
t.emit(':remove-topic', topic);
|
|
||||||
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
inject(topic, message) {
|
|
||||||
if ( ! this.instance )
|
if ( ! this.instance )
|
||||||
throw new Error('No PubSub instance available');
|
throw new Error('No PubSub instance available');
|
||||||
|
|
||||||
if ( this.is_old ) {
|
/*if ( this.is_old ) {
|
||||||
const listens = this.instance._client?._listens;
|
const listens = this.instance._client?._listens;
|
||||||
listens._trigger(topic, JSON.stringify(message));
|
listens._trigger(topic, JSON.stringify(message));
|
||||||
} else {
|
} else {*/
|
||||||
this.instance.simulateMessage(topic, JSON.stringify(message));
|
this.instance.simulateMessage(topic, JSON.stringify(message));
|
||||||
}
|
//}
|
||||||
}
|
}
|
||||||
|
|
||||||
get topics() {
|
get topics() {
|
||||||
let events;
|
const events = this.instance?.topicListeners?._events;
|
||||||
if ( this.is_old )
|
/*if ( this.is_old )
|
||||||
events = this.instance?._client?._listens._events;
|
events = this.instance?._client?._listens._events;
|
||||||
else
|
else
|
||||||
events = this.instance?.topicListeners?._events;
|
events = this.instance?.topicListeners?._events;*/
|
||||||
|
|
||||||
if ( ! events )
|
if ( ! events )
|
||||||
return [];
|
return [];
|
||||||
|
@ -220,4 +271,4 @@ export default class Subpump extends Module {
|
||||||
return Object.keys(events);
|
return Object.keys(events);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
|
@ -1,10 +1,14 @@
|
||||||
'use strict';
|
declare global {
|
||||||
|
let __extension__: string | undefined;
|
||||||
import {make_enum} from 'utilities/object';
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/** Whether or not FrankerFaceZ was loaded from a development server. */
|
||||||
export const DEBUG = localStorage.ffzDebugMode === 'true' && document.body.classList.contains('ffz-dev');
|
export const DEBUG = localStorage.ffzDebugMode === 'true' && document.body.classList.contains('ffz-dev');
|
||||||
|
|
||||||
|
/** Whether or not FrankerFaceZ was loaded as a packed web extension. */
|
||||||
export const EXTENSION = !!__extension__;
|
export const EXTENSION = !!__extension__;
|
||||||
|
|
||||||
|
/** The base URL of the FrankerFaceZ CDN. */
|
||||||
export const SERVER = DEBUG ? 'https://localhost:8000' : 'https://cdn.frankerfacez.com';
|
export const SERVER = DEBUG ? 'https://localhost:8000' : 'https://cdn.frankerfacez.com';
|
||||||
|
|
||||||
let path = `${SERVER}/script`;
|
let path = `${SERVER}/script`;
|
||||||
|
@ -15,21 +19,31 @@ if ( EXTENSION ) {
|
||||||
path = path.slice(0, path.length - 1);
|
path = path.slice(0, path.length - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Either the base URL of the FrankerFaceZ CDN or, if FFZ was loaded as a packed web extension, the base URL of the web extension's web accessible files. */
|
||||||
export const SERVER_OR_EXT = path;
|
export const SERVER_OR_EXT = path;
|
||||||
|
|
||||||
export const CLIENT_ID = 'a3bc9znoz6vi8ozsoca0inlcr4fcvkl';
|
/** The base URL of the FrankerFaceZ API. */
|
||||||
export const API_SERVER = 'https://api.frankerfacez.com';
|
export const API_SERVER = 'https://api.frankerfacez.com';
|
||||||
|
|
||||||
|
/** The base URL of the FrankerFaceZ staging API. */
|
||||||
export const STAGING_API = 'https://api-staging.frankerfacez.com';
|
export const STAGING_API = 'https://api-staging.frankerfacez.com';
|
||||||
|
|
||||||
|
/** The base URL of the FrankerFaceZ staging CDN. */
|
||||||
export const STAGING_CDN = 'https://cdn-staging.frankerfacez.com';
|
export const STAGING_CDN = 'https://cdn-staging.frankerfacez.com';
|
||||||
|
|
||||||
|
/** The base URL of the FrankerFaceZ testing API used for load testing. */
|
||||||
export const NEW_API = 'https://api2.frankerfacez.com';
|
export const NEW_API = 'https://api2.frankerfacez.com';
|
||||||
|
|
||||||
//export const SENTRY_ID = 'https://1c3b56f127254d3ba1bd1d6ad8805eee@sentry.io/1186960';
|
/** The base URL provided to Sentry integrations for automatic error reporting. */
|
||||||
//export const SENTRY_ID = 'https://07ded545d3224ca59825daee02dc7745@catbag.frankerfacez.com:444/2';
|
|
||||||
export const SENTRY_ID = 'https://74b46b3894114f399d51949c6d237489@sentry.frankerfacez.com/2';
|
export const SENTRY_ID = 'https://74b46b3894114f399d51949c6d237489@sentry.frankerfacez.com/2';
|
||||||
|
|
||||||
export const WORD_SEPARATORS = '[\\s`~<>!-#%-\\x2A,-/:;\\x3F@\\x5B-\\x5D_\\x7B}\\u00A1\\u00A7\\u00AB\\u00B6\\u00B7\\u00BB\\u00BF\\u037E\\u0387\\u055A-\\u055F\\u0589\\u058A\\u05BE\\u05C0\\u05C3\\u05C6\\u05F3\\u05F4\\u0609\\u060A\\u060C\\u060D\\u061B\\u061E\\u061F\\u066A-\\u066D\\u06D4\\u0700-\\u070D\\u07F7-\\u07F9\\u0830-\\u083E\\u085E\\u0964\\u0965\\u0970\\u0AF0\\u0DF4\\u0E4F\\u0E5A\\u0E5B\\u0F04-\\u0F12\\u0F14\\u0F3A-\\u0F3D\\u0F85\\u0FD0-\\u0FD4\\u0FD9\\u0FDA\\u104A-\\u104F\\u10FB\\u1360-\\u1368\\u1400\\u166D\\u166E\\u169B\\u169C\\u16EB-\\u16ED\\u1735\\u1736\\u17D4-\\u17D6\\u17D8-\\u17DA\\u1800-\\u180A\\u1944\\u1945\\u1A1E\\u1A1F\\u1AA0-\\u1AA6\\u1AA8-\\u1AAD\\u1B5A-\\u1B60\\u1BFC-\\u1BFF\\u1C3B-\\u1C3F\\u1C7E\\u1C7F\\u1CC0-\\u1CC7\\u1CD3\\u2010-\\u2027\\u2030-\\u2043\\u2045-\\u2051\\u2053-\\u205E\\u207D\\u207E\\u208D\\u208E\\u2329\\u232A\\u2768-\\u2775\\u27C5\\u27C6\\u27E6-\\u27EF\\u2983-\\u2998\\u29D8-\\u29DB\\u29FC\\u29FD\\u2CF9-\\u2CFC\\u2CFE\\u2CFF\\u2D70\\u2E00-\\u2E2E\\u2E30-\\u2E3B\\u3001-\\u3003\\u3008-\\u3011\\u3014-\\u301F\\u3030\\u303D\\u30A0\\u30FB\\uA4FE\\uA4FF\\uA60D-\\uA60F\\uA673\\uA67E\\uA6F2-\\uA6F7\\uA874-\\uA877\\uA8CE\\uA8CF\\uA8F8-\\uA8FA\\uA92E\\uA92F\\uA95F\\uA9C1-\\uA9CD\\uA9DE\\uA9DF\\uAA5C-\\uAA5F\\uAADE\\uAADF\\uAAF0\\uAAF1\\uABEB\\uFD3E\\uFD3F\\uFE10-\\uFE19\\uFE30-\\uFE52\\uFE54-\\uFE61\\uFE63\\uFE68\\uFE6A\\uFE6B\\uFF01-\\uFF03\\uFF05-\\uFF0A\\uFF0C-\\uFF0F\\uFF1A\\uFF1B\\uFF1F\\uFF20\\uFF3B-\\uFF3D\\uFF3F\\uFF5B\\uFF5D\\uFF5F-\\uFF65]';
|
export const WORD_SEPARATORS = '[\\s`~<>!-#%-\\x2A,-/:;\\x3F@\\x5B-\\x5D_\\x7B}\\u00A1\\u00A7\\u00AB\\u00B6\\u00B7\\u00BB\\u00BF\\u037E\\u0387\\u055A-\\u055F\\u0589\\u058A\\u05BE\\u05C0\\u05C3\\u05C6\\u05F3\\u05F4\\u0609\\u060A\\u060C\\u060D\\u061B\\u061E\\u061F\\u066A-\\u066D\\u06D4\\u0700-\\u070D\\u07F7-\\u07F9\\u0830-\\u083E\\u085E\\u0964\\u0965\\u0970\\u0AF0\\u0DF4\\u0E4F\\u0E5A\\u0E5B\\u0F04-\\u0F12\\u0F14\\u0F3A-\\u0F3D\\u0F85\\u0FD0-\\u0FD4\\u0FD9\\u0FDA\\u104A-\\u104F\\u10FB\\u1360-\\u1368\\u1400\\u166D\\u166E\\u169B\\u169C\\u16EB-\\u16ED\\u1735\\u1736\\u17D4-\\u17D6\\u17D8-\\u17DA\\u1800-\\u180A\\u1944\\u1945\\u1A1E\\u1A1F\\u1AA0-\\u1AA6\\u1AA8-\\u1AAD\\u1B5A-\\u1B60\\u1BFC-\\u1BFF\\u1C3B-\\u1C3F\\u1C7E\\u1C7F\\u1CC0-\\u1CC7\\u1CD3\\u2010-\\u2027\\u2030-\\u2043\\u2045-\\u2051\\u2053-\\u205E\\u207D\\u207E\\u208D\\u208E\\u2329\\u232A\\u2768-\\u2775\\u27C5\\u27C6\\u27E6-\\u27EF\\u2983-\\u2998\\u29D8-\\u29DB\\u29FC\\u29FD\\u2CF9-\\u2CFC\\u2CFE\\u2CFF\\u2D70\\u2E00-\\u2E2E\\u2E30-\\u2E3B\\u3001-\\u3003\\u3008-\\u3011\\u3014-\\u301F\\u3030\\u303D\\u30A0\\u30FB\\uA4FE\\uA4FF\\uA60D-\\uA60F\\uA673\\uA67E\\uA6F2-\\uA6F7\\uA874-\\uA877\\uA8CE\\uA8CF\\uA8F8-\\uA8FA\\uA92E\\uA92F\\uA95F\\uA9C1-\\uA9CD\\uA9DE\\uA9DF\\uAA5C-\\uAA5F\\uAADE\\uAADF\\uAAF0\\uAAF1\\uABEB\\uFD3E\\uFD3F\\uFE10-\\uFE19\\uFE30-\\uFE52\\uFE54-\\uFE61\\uFE63\\uFE68\\uFE6A\\uFE6B\\uFF01-\\uFF03\\uFF05-\\uFF0A\\uFF0C-\\uFF0F\\uFF1A\\uFF1B\\uFF1F\\uFF20\\uFF3B-\\uFF3D\\uFF3F\\uFF5B\\uFF5D\\uFF5F-\\uFF65]';
|
||||||
|
|
||||||
export const WEIRD_EMOTE_SIZES = {
|
/**
|
||||||
|
* A map of default Twitch emotes with non-standard sizes, so they can be displayed
|
||||||
|
* more accurately in certain situations.
|
||||||
|
*/
|
||||||
|
export const WEIRD_EMOTE_SIZES: Record<string, [width: number, height: number]> = {
|
||||||
15: [21,27],
|
15: [21,27],
|
||||||
16: [22,27],
|
16: [22,27],
|
||||||
17: [20,27],
|
17: [20,27],
|
||||||
|
@ -90,6 +104,7 @@ export const WEIRD_EMOTE_SIZES = {
|
||||||
1906: [24,30]
|
1906: [24,30]
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** A list of hotkey combinations that are not valid for one reason or another. */
|
||||||
export const BAD_HOTKEYS = [
|
export const BAD_HOTKEYS = [
|
||||||
'f',
|
'f',
|
||||||
'space',
|
'space',
|
||||||
|
@ -103,7 +118,7 @@ export const BAD_HOTKEYS = [
|
||||||
'alt+x'
|
'alt+x'
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** A list of setting keys that, when changed, cause chat messages to re-render. */
|
||||||
export const RERENDER_SETTINGS = [
|
export const RERENDER_SETTINGS = [
|
||||||
'chat.name-format',
|
'chat.name-format',
|
||||||
'chat.me-style',
|
'chat.me-style',
|
||||||
|
@ -120,15 +135,23 @@ export const RERENDER_SETTINGS = [
|
||||||
'chat.bits.cheer-notice',
|
'chat.bits.cheer-notice',
|
||||||
'chat.filtering.hidden-tokens',
|
'chat.filtering.hidden-tokens',
|
||||||
'chat.hype.message-style'
|
'chat.hype.message-style'
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of setting keys that, when changed, cause chat messages to first clear
|
||||||
|
* their badge caches and then re-render.
|
||||||
|
*/
|
||||||
export const UPDATE_BADGE_SETTINGS = [
|
export const UPDATE_BADGE_SETTINGS = [
|
||||||
'chat.badges.style',
|
'chat.badges.style',
|
||||||
'chat.badges.hidden',
|
'chat.badges.hidden',
|
||||||
'chat.badges.custom-mod',
|
'chat.badges.custom-mod',
|
||||||
'chat.badges.custom-vip',
|
'chat.badges.custom-vip',
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of setting keys that, when changed, cause chat messages to first clear
|
||||||
|
* their cached token lists and then re-render.
|
||||||
|
*/
|
||||||
export const UPDATE_TOKEN_SETTINGS = [
|
export const UPDATE_TOKEN_SETTINGS = [
|
||||||
'chat.emotes.enabled',
|
'chat.emotes.enabled',
|
||||||
'chat.emotes.2x',
|
'chat.emotes.2x',
|
||||||
|
@ -151,9 +174,12 @@ export const UPDATE_TOKEN_SETTINGS = [
|
||||||
'__filter:block-terms',
|
'__filter:block-terms',
|
||||||
'__filter:block-users',
|
'__filter:block-users',
|
||||||
'__filter:block-badges'
|
'__filter:block-badges'
|
||||||
];
|
] as const;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of keycodes for specific keys, for use with
|
||||||
|
* {@link KeyboardEvent} events.
|
||||||
|
*/
|
||||||
export const KEYS = {
|
export const KEYS = {
|
||||||
Tab: 9,
|
Tab: 9,
|
||||||
Enter: 13,
|
Enter: 13,
|
||||||
|
@ -172,13 +198,16 @@ export const KEYS = {
|
||||||
ArrowDown: 40,
|
ArrowDown: 40,
|
||||||
Meta: 91,
|
Meta: 91,
|
||||||
Context: 93
|
Context: 93
|
||||||
};
|
} as const;
|
||||||
|
|
||||||
|
/** The base URL for Twitch emote images. */
|
||||||
export const TWITCH_EMOTE_BASE = '//static-cdn.jtvnw.net/emoticons/v1/';
|
|
||||||
export const TWITCH_EMOTE_V2 = '//static-cdn.jtvnw.net/emoticons/v2';
|
export const TWITCH_EMOTE_V2 = '//static-cdn.jtvnw.net/emoticons/v2';
|
||||||
|
|
||||||
export const KNOWN_CODES = {
|
/**
|
||||||
|
* A map of regex-style Twitch emote codes into normal,
|
||||||
|
* human-readable strings for display in UI.
|
||||||
|
*/
|
||||||
|
export const KNOWN_CODES: Record<string, string> = {
|
||||||
'#-?[\\\\/]': '#-/',
|
'#-?[\\\\/]': '#-/',
|
||||||
':-?(?:7|L)': ':-7',
|
':-?(?:7|L)': ':-7',
|
||||||
'\\<\\;\\]': '<]',
|
'\\<\\;\\]': '<]',
|
||||||
|
@ -203,9 +232,11 @@ export const KNOWN_CODES = {
|
||||||
'Gr(a|e)yFace': 'GrayFace'
|
'Gr(a|e)yFace': 'GrayFace'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** The base URL for replacement images used for specific Twitch emotes. */
|
||||||
export const REPLACEMENT_BASE = `${SERVER}/static/replacements/`;
|
export const REPLACEMENT_BASE = `${SERVER}/static/replacements/`;
|
||||||
|
|
||||||
export const REPLACEMENTS = {
|
/** A map of specific Twitch emotes that should use replacement images. */
|
||||||
|
export const REPLACEMENTS: Record<string, string> = {
|
||||||
15: '15-JKanStyle.png',
|
15: '15-JKanStyle.png',
|
||||||
16: '16-OptimizePrime.png',
|
16: '16-OptimizePrime.png',
|
||||||
17: '17-StoneLightning.png',
|
17: '17-StoneLightning.png',
|
||||||
|
@ -221,7 +252,10 @@ export const REPLACEMENTS = {
|
||||||
36: '36-PJSalt.png'
|
36: '36-PJSalt.png'
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A map of WebSocket servers for the original FrankerFaceZ socket
|
||||||
|
* system. @deprecated
|
||||||
|
*/
|
||||||
export const WS_CLUSTERS = {
|
export const WS_CLUSTERS = {
|
||||||
Production: [
|
Production: [
|
||||||
['wss://catbag.frankerfacez.com/', 0.25],
|
['wss://catbag.frankerfacez.com/', 0.25],
|
||||||
|
@ -275,29 +309,56 @@ export const PUBSUB_CLUSTERS = {
|
||||||
Development: `https://stendec.dev/ps/`
|
Development: `https://stendec.dev/ps/`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether or not we're running on macOS */
|
||||||
export const IS_OSX = navigator.platform ? navigator.platform.indexOf('Mac') !== -1 : /OS X/.test(navigator.userAgent);
|
export const IS_OSX = navigator.platform ? navigator.platform.indexOf('Mac') !== -1 : /OS X/.test(navigator.userAgent);
|
||||||
export const IS_WIN = navigator.platform ? navigator.platform.indexOf('Win') !== -1 : /Windows/.test(navigator.userAgent);
|
|
||||||
export const IS_WEBKIT = navigator.userAgent.indexOf('AppleWebKit/') !== -1 && navigator.userAgent.indexOf('Edge/') === -1;
|
|
||||||
export const IS_FIREFOX = (navigator.userAgent.indexOf('Firefox/') !== -1) || (window.InstallTrigger !== undefined);
|
|
||||||
|
|
||||||
|
/** Whether or not we're running on Windows */
|
||||||
|
export const IS_WIN = navigator.platform ? navigator.platform.indexOf('Win') !== -1 : /Windows/.test(navigator.userAgent);
|
||||||
|
|
||||||
|
/** Whether or not we're running on a Webkit-based browser. */
|
||||||
|
export const IS_WEBKIT = navigator.userAgent.indexOf('AppleWebKit/') !== -1 && navigator.userAgent.indexOf('Edge/') === -1;
|
||||||
|
|
||||||
|
/** Whether or not we're running on a Firefox-based browser. */
|
||||||
|
export const IS_FIREFOX = (navigator.userAgent.indexOf('Firefox/') !== -1);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A -webkit- CSS prefix, if we're running on a Webkit-based browser.
|
||||||
|
* Hopefully we don't need this anymore.
|
||||||
|
* @deprecated
|
||||||
|
*/
|
||||||
export const WEBKIT_CSS = IS_WEBKIT ? '-webkit-' : '';
|
export const WEBKIT_CSS = IS_WEBKIT ? '-webkit-' : '';
|
||||||
|
|
||||||
|
/** A list of Twitch emote sets that are globally available. */
|
||||||
|
export const TWITCH_GLOBAL_SETS = [0, 33, 42] as const;
|
||||||
|
|
||||||
export const TWITCH_GLOBAL_SETS = [0, 33, 42];
|
/** A list of Twitch emote sets that are for emotes unlocked with channel points. */
|
||||||
export const TWITCH_POINTS_SETS = [300238151];
|
export const TWITCH_POINTS_SETS = [300238151] as const;
|
||||||
export const TWITCH_PRIME_SETS = [457, 793, 19151, 19194];
|
|
||||||
|
|
||||||
export const EmoteTypes = make_enum(
|
/** A list of Twitch emote sets that are for Twitch Prime subscribers. */
|
||||||
'Unknown',
|
export const TWITCH_PRIME_SETS = [457, 793, 19151, 19194] as const;
|
||||||
'Prime',
|
|
||||||
'Turbo',
|
/** An enum of all possible Twitch emote types. */
|
||||||
'LimitedTime',
|
export enum EmoteTypes {
|
||||||
'ChannelPoints',
|
/** What kind of weird emote are you dragging in here */
|
||||||
'Unavailable',
|
Unknown,
|
||||||
'Subscription',
|
/** Emotes unlocked via Twitch Prime */
|
||||||
'BitsTier',
|
Prime,
|
||||||
'Global',
|
/** Emotes unlocked via Twitch Turbo */
|
||||||
'TwoFactor',
|
Turbo,
|
||||||
'Follower'
|
/** Emotes unlocked via arbitrary condition, permanently available. */
|
||||||
);
|
LimitedTime,
|
||||||
|
/** Emotes unlocked via channel points. */
|
||||||
|
ChannelPoints,
|
||||||
|
/** Emote no longer available. */
|
||||||
|
Unavailable,
|
||||||
|
/** Emote unlocked via subscription to channel. */
|
||||||
|
Subscription,
|
||||||
|
/** Emote permanently unlocked via cheering in channel. */
|
||||||
|
BitsTier,
|
||||||
|
/** Globally available emote. */
|
||||||
|
Global,
|
||||||
|
/** Emote unlocked via enabling two-factor authentication. */
|
||||||
|
TwoFactor,
|
||||||
|
/** Emote unlocked via following a channel. */
|
||||||
|
Follower
|
||||||
|
};
|
|
@ -1,104 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// CSS Tweaks
|
|
||||||
// Tweak some CSS
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import Module from 'utilities/module';
|
|
||||||
import {ManagedStyle} from 'utilities/dom';
|
|
||||||
import {has, once} from 'utilities/object';
|
|
||||||
|
|
||||||
export default class CSSTweaks extends Module {
|
|
||||||
constructor(...args) {
|
|
||||||
super(...args);
|
|
||||||
|
|
||||||
this.rules = {};
|
|
||||||
|
|
||||||
this.loader = null;
|
|
||||||
this.chunks = {};
|
|
||||||
this.chunks_loaded = false;
|
|
||||||
|
|
||||||
this._state = {};
|
|
||||||
|
|
||||||
this.populate = once(this.populate);
|
|
||||||
}
|
|
||||||
|
|
||||||
get style() {
|
|
||||||
if ( ! this._style )
|
|
||||||
this._style = new ManagedStyle;
|
|
||||||
|
|
||||||
return this._style;
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleHide(key, val) {
|
|
||||||
const k = `hide--${key}`;
|
|
||||||
if ( ! val ) {
|
|
||||||
if ( this._style )
|
|
||||||
this._style.delete(k);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( ! has(this.rules, key) )
|
|
||||||
throw new Error(`unknown rule "${key}" for toggleHide`);
|
|
||||||
|
|
||||||
this.style.set(k, `${this.rules[key]}{display:none !important}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
toggle(key, val) {
|
|
||||||
if ( this._state[key] == val )
|
|
||||||
return;
|
|
||||||
|
|
||||||
this._state[key] = val;
|
|
||||||
this._apply(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
_apply(key) {
|
|
||||||
const val = this._state[key];
|
|
||||||
if ( ! val ) {
|
|
||||||
if ( this._style )
|
|
||||||
this._style.delete(key);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( this.style.has(key) )
|
|
||||||
return;
|
|
||||||
|
|
||||||
if ( ! this.chunks_loaded )
|
|
||||||
return this.populate().then(() => this._apply(key));
|
|
||||||
|
|
||||||
if ( ! has(this.chunks, key) ) {
|
|
||||||
this.log.warn(`Unknown chunk name "${key}" for toggle()`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.style.set(key, this.chunks[key]);
|
|
||||||
}
|
|
||||||
|
|
||||||
set(key, val) { return this.style.set(key, val); }
|
|
||||||
delete(key) { this._style && this._style.delete(key) }
|
|
||||||
|
|
||||||
setVariable(key, val, scope = 'body') {
|
|
||||||
this.style.set(`var--${key}`, `${scope}{--ffz-${key}:${val};}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
deleteVariable(key) {
|
|
||||||
if ( this._style )
|
|
||||||
this._style.delete(`var--${key}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async populate() {
|
|
||||||
if ( this.chunks_loaded || ! this.loader )
|
|
||||||
return;
|
|
||||||
|
|
||||||
const promises = [];
|
|
||||||
for(const key of this.loader.keys()) {
|
|
||||||
const k = key.slice(2, key.length - (key.endsWith('.scss') ? 5 : 4));
|
|
||||||
promises.push(this.loader(key).then(data => this.chunks[k] = data.default));
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(promises);
|
|
||||||
this.chunks_loaded = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
224
src/utilities/css-tweaks.ts
Normal file
224
src/utilities/css-tweaks.ts
Normal file
|
@ -0,0 +1,224 @@
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// CSS Tweaks
|
||||||
|
// Tweak some CSS
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
import Module, { GenericModule } from 'utilities/module';
|
||||||
|
import {ManagedStyle} from 'utilities/dom';
|
||||||
|
import {has, once} from 'utilities/object';
|
||||||
|
|
||||||
|
declare module "utilities/types" {
|
||||||
|
interface ModuleMap {
|
||||||
|
'site.css_tweaks': CSSTweaks;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CSS Tweaks is a somewhat generic module for handling FrankerFaceZ's CSS
|
||||||
|
* injection. It can load and unload specific blocks of CSS, as well as
|
||||||
|
* automatically generate rules to hide specific elements based on their
|
||||||
|
* selectors.
|
||||||
|
*
|
||||||
|
* Generally, this module is loaded by the current site module and is
|
||||||
|
* available as `site.css_tweaks`.
|
||||||
|
*
|
||||||
|
* @noInheritDoc
|
||||||
|
*/
|
||||||
|
export default class CSSTweaks<TPath extends string = 'site.css_tweaks'> extends Module<TPath> {
|
||||||
|
|
||||||
|
/** Stores CSS rules used with the {@link toggleHide} method. */
|
||||||
|
rules: Record<string, string> = {};
|
||||||
|
|
||||||
|
/** Stores CSS chunks loaded by the provided loader, and used with the {@link toggle} method. */
|
||||||
|
chunks: Record<string, string> = {};
|
||||||
|
|
||||||
|
private _toggle_state: Record<string, boolean> = {};
|
||||||
|
private _chunk_loader?: __WebpackModuleApi.RequireContext | null;
|
||||||
|
private _chunks_loaded: boolean = false;
|
||||||
|
private _style?: ManagedStyle;
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
constructor(name?: string, parent?: GenericModule) {
|
||||||
|
super(name, parent);
|
||||||
|
|
||||||
|
this._loadChunks = once(this._loadChunks);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Whether or not chunks have been loaded using the {@link loader}. */
|
||||||
|
get chunks_loaded() {
|
||||||
|
return this._chunks_loaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An optional require context that can be used for loading arbitrary, named CSS chunks. */
|
||||||
|
get loader() {
|
||||||
|
return this._chunk_loader;
|
||||||
|
}
|
||||||
|
|
||||||
|
set loader(value: __WebpackModuleApi.RequireContext | null | undefined) {
|
||||||
|
if ( value === this._chunk_loader )
|
||||||
|
return;
|
||||||
|
|
||||||
|
this._chunks_loaded = false;
|
||||||
|
this._chunk_loader = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The {@link ManagedStyle} instance used internally by this {@link CSSTweaks} instance. */
|
||||||
|
get style() {
|
||||||
|
if ( ! this._style )
|
||||||
|
this._style = new ManagedStyle;
|
||||||
|
|
||||||
|
return this._style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If {@link force} is not set, this toggles a specific element hiding rule,
|
||||||
|
* enabling it if it was not previously enabled and vice versa. If force is
|
||||||
|
* provided, it will either enable or disable the specific element hiding
|
||||||
|
* rule based on the boolean value of {@link force}.
|
||||||
|
*
|
||||||
|
* @param key The key for the element hiding rule in {@link rules}.
|
||||||
|
* @param force Optional. The desired state.
|
||||||
|
* @throws If the provided {@link key} is not within {@link rules}.
|
||||||
|
*/
|
||||||
|
toggleHide(key: string, force?: boolean) {
|
||||||
|
const k = `hide--${key}`;
|
||||||
|
force = force != null ? !! force : ! this._toggle_state[k];
|
||||||
|
if ( this._toggle_state[k] === force )
|
||||||
|
return;
|
||||||
|
|
||||||
|
this._toggle_state[k] = force;
|
||||||
|
|
||||||
|
if ( ! force ) {
|
||||||
|
if ( this._style )
|
||||||
|
this._style.delete(k);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( ! has(this.rules, key) )
|
||||||
|
throw new Error(`unknown rule "${key}" for toggleHide`);
|
||||||
|
|
||||||
|
this.style.set(k, `${this.rules[key]}{display:none !important}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If {@link force} is not set, this toggles a specific CSS chunk,
|
||||||
|
* enabling it if it was not previously enabled and vice versa. If force is
|
||||||
|
* provide, it will either enable or disable the specific CSS chunk based
|
||||||
|
* on the boolean value of {@link force}.
|
||||||
|
*
|
||||||
|
* @param key The key for the CSS block in {@link chunks}.
|
||||||
|
* @param force Optional. The desired state.
|
||||||
|
*/
|
||||||
|
toggle(key: string, force?: boolean) {
|
||||||
|
force = force != null ? !! force : ! this._toggle_state[key];
|
||||||
|
|
||||||
|
if ( this._toggle_state[key] == force )
|
||||||
|
return;
|
||||||
|
|
||||||
|
this._toggle_state[key] = force;
|
||||||
|
this._apply(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actually perform the update for {@link toggle}. This method may
|
||||||
|
* have to wait and then call itself again if the chunks have not yet
|
||||||
|
* been loaded.
|
||||||
|
*
|
||||||
|
* @param key The key for the CSS block to toggle.
|
||||||
|
*/
|
||||||
|
private _apply(key: string): void {
|
||||||
|
const val = this._toggle_state[key];
|
||||||
|
if ( ! val ) {
|
||||||
|
if ( this._style )
|
||||||
|
this._style.delete(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( this.style.has(key) )
|
||||||
|
return;
|
||||||
|
|
||||||
|
else if ( ! this._chunks_loaded ) {
|
||||||
|
this._loadChunks().then(() => this._apply(key));
|
||||||
|
|
||||||
|
} else if ( ! has(this.chunks, key) ) {
|
||||||
|
this.log.warn(`Unknown chunk name "${key}" for toggle()`);
|
||||||
|
|
||||||
|
} else
|
||||||
|
this.style.set(key, this.chunks[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Include an arbitrary string of CSS using this CSSTweak instance's
|
||||||
|
* {@link ManagedStyle} instance. This will override any existing
|
||||||
|
* CSS block using the same key.
|
||||||
|
*
|
||||||
|
* @see {@link ManagedStyle.set}
|
||||||
|
* @param key The key for the CSS block.
|
||||||
|
* @param value The text content of the CSS block.
|
||||||
|
*/
|
||||||
|
set(key: string, value: string) { return this.style.set(key, value); }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a CSS block from this CSSTweak instance's {@link ManagedStyle}
|
||||||
|
* instance. This can be used to delete managed blocks including
|
||||||
|
* those set by {@link toggle}, {@link toggleHide}, and
|
||||||
|
* {@link setVariable} to please use caution.
|
||||||
|
*
|
||||||
|
* @see {@link ManagedStyle.delete}
|
||||||
|
* @param key The key to be deleted.
|
||||||
|
*/
|
||||||
|
delete(key: string) { this._style && this._style.delete(key) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a CSS variable. The variable's name will be prefixed with `ffz-`
|
||||||
|
* so, for example, if {@link key} is `"link-color"` then the resulting
|
||||||
|
* CSS variable will be `--ffz-link-color` and can be used with
|
||||||
|
* `var(--ffz-link-color)`.
|
||||||
|
*
|
||||||
|
* @param key The key for the variable.
|
||||||
|
* @param value The value of the variable.
|
||||||
|
* @param scope The scope this variable should be set on. Defaults
|
||||||
|
* to `"body"`.
|
||||||
|
*/
|
||||||
|
setVariable(key: string, value: string, scope: string = 'body') {
|
||||||
|
this.style.set(`var--${key}`, `${scope}{--ffz-${key}:${value};}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a CSS variable.
|
||||||
|
* @param key The key for the variable
|
||||||
|
*/
|
||||||
|
deleteVariable(key: string) {
|
||||||
|
if ( this._style )
|
||||||
|
this._style.delete(`var--${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method is used internally to load CSS chunks from the
|
||||||
|
* provided {@link loader} instance.
|
||||||
|
*/
|
||||||
|
private async _loadChunks() {
|
||||||
|
if ( this._chunks_loaded )
|
||||||
|
return;
|
||||||
|
|
||||||
|
if ( ! this._chunk_loader ) {
|
||||||
|
this._chunks_loaded = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const promises = [];
|
||||||
|
for(const key of this._chunk_loader.keys()) {
|
||||||
|
const k = key.slice(2, key.length - (key.endsWith('.scss') ? 5 : 4));
|
||||||
|
promises.push(this._chunk_loader(key).then((data: any) => {
|
||||||
|
if ( typeof data?.default === 'string' )
|
||||||
|
this.chunks[k] = data.default;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
await Promise.all(promises);
|
||||||
|
this._chunks_loaded = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
13
src/utilities/data/stream-flags.gql
Normal file
13
src/utilities/data/stream-flags.gql
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
query FFZ_StreamFlags($ids: [ID!], $logins: [String!]) {
|
||||||
|
users(ids: $ids, logins: $logins) {
|
||||||
|
id
|
||||||
|
login
|
||||||
|
stream {
|
||||||
|
id
|
||||||
|
contentClassificationLabels {
|
||||||
|
id
|
||||||
|
localizedName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -16,8 +16,47 @@ export function getDialogNextZ() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export class Dialog extends EventEmitter {
|
export type DialogSelectors = {
|
||||||
constructor(element, options = {}) {
|
exclusive: string;
|
||||||
|
maximized: string;
|
||||||
|
normal: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export type DialogOptions = {
|
||||||
|
selectors: DialogSelectors;
|
||||||
|
maximized?: boolean;
|
||||||
|
exclusive?: boolean;
|
||||||
|
prepend?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export type DialogEvents = {
|
||||||
|
hide: [],
|
||||||
|
show: [],
|
||||||
|
error: [error: any],
|
||||||
|
resize: []
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export class Dialog extends EventEmitter<DialogEvents> {
|
||||||
|
|
||||||
|
static EXCLUSIVE = Site.DIALOG_EXCLUSIVE;
|
||||||
|
static MAXIMIZED = Site.DIALOG_MAXIMIZED;
|
||||||
|
static SELECTOR = Site.DIALOG_SELECTOR;
|
||||||
|
|
||||||
|
selectors: DialogSelectors;
|
||||||
|
|
||||||
|
factory?: () => Element;
|
||||||
|
_element?: Element | null;
|
||||||
|
|
||||||
|
_visible: boolean;
|
||||||
|
_maximized: boolean;
|
||||||
|
_exclusive: boolean;
|
||||||
|
prepend: boolean;
|
||||||
|
|
||||||
|
|
||||||
|
constructor(element: Element | (() => Element), options: Partial<DialogOptions> = {}) {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.selectors = {
|
this.selectors = {
|
||||||
|
@ -128,7 +167,7 @@ export class Dialog extends EventEmitter {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleVisible(event) {
|
toggleVisible(event?: MouseEvent) {
|
||||||
if ( event && event.button !== 0 )
|
if ( event && event.button !== 0 )
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
@ -174,14 +213,18 @@ export class Dialog extends EventEmitter {
|
||||||
this._element = el;
|
this._element = el;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ( ! this._element )
|
||||||
|
return;
|
||||||
|
|
||||||
if ( this.prepend )
|
if ( this.prepend )
|
||||||
container.insertBefore(this._element, container.firstChild);
|
container.insertBefore(this._element, container.firstChild);
|
||||||
else
|
else
|
||||||
container.appendChild(this._element);
|
container.appendChild(this._element);
|
||||||
|
|
||||||
this.emit('show');
|
this.emit('show');
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSize(event) {
|
toggleSize(event?: MouseEvent) {
|
||||||
if ( ! this._visible || event && event.button !== 0 || ! this._element )
|
if ( ! this._visible || event && event.button !== 0 || ! this._element )
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
@ -212,10 +255,5 @@ export class Dialog extends EventEmitter {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This is necessary for add-ons for now.
|
||||||
Dialog.EXCLUSIVE = Site.DIALOG_EXCLUSIVE;
|
export default Dialog;
|
||||||
Dialog.MAXIMIZED = Site.DIALOG_MAXIMIZED;
|
|
||||||
Dialog.SELECTOR = Site.DIALOG_SELECTOR;
|
|
||||||
|
|
||||||
|
|
||||||
export default Dialog;
|
|
|
@ -1,377 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
import {has} from 'utilities/object';
|
|
||||||
|
|
||||||
const ATTRS = [
|
|
||||||
'accept', 'accept-charset', 'accesskey', 'action', 'align', 'alt', 'async',
|
|
||||||
'autocomplete', 'autofocus', 'autoplay', 'bgcolor', 'border', 'buffered',
|
|
||||||
'challenge', 'charset', 'checked', 'cite', 'class', 'code', 'codebase',
|
|
||||||
'color', 'cols', 'colspan', 'content', 'contenteditable', 'contextmenu',
|
|
||||||
'controls', 'coords', 'crossorigin', 'data', 'data-*', 'datetime',
|
|
||||||
'default', 'defer', 'dir', 'dirname', 'download', 'draggable',
|
|
||||||
'dropzone', 'enctype', 'for', 'form', 'formaction', 'headers', 'height',
|
|
||||||
'hidden', 'high', 'href', 'hreflang', 'http-equiv', 'icon', 'id',
|
|
||||||
'integrity', 'ismap', 'itemprop', 'keytype', 'kind', 'label', 'lang',
|
|
||||||
'language', 'list', 'loop', 'low', 'manifest', 'max', 'maxlength',
|
|
||||||
'minlength', 'media', 'method', 'min', 'multiple', 'name',
|
|
||||||
'novalidate', 'open', 'optimum', 'pattern', 'ping', 'placeholder', 'poster',
|
|
||||||
'preload', 'radiogroup', 'readonly', 'rel', 'required', 'reversed', 'rows',
|
|
||||||
'rowspan', 'sandbox', 'scope', 'scoped', 'seamless', 'selected', 'shape',
|
|
||||||
'size', 'sizes', 'slot', 'span', 'spellcheck', 'src', 'srcdoc', 'srclang',
|
|
||||||
'srcset', 'start', 'step', 'style', 'summary', 'tabindex', 'target',
|
|
||||||
'title', 'type', 'usemap', 'value', 'width', 'wrap'
|
|
||||||
];
|
|
||||||
|
|
||||||
const BOOLEAN_ATTRS = [
|
|
||||||
'controls', 'autoplay', 'loop'
|
|
||||||
];
|
|
||||||
|
|
||||||
|
|
||||||
const range = document.createRange();
|
|
||||||
|
|
||||||
function camelCase(name) {
|
|
||||||
return name.replace(/[-_]\w/g, m => m[1].toUpperCase());
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function on(obj, ...args) {
|
|
||||||
return obj.addEventListener(...args);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function off(obj, ...args) {
|
|
||||||
return obj.removeEventListener(...args);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function findReactFragment(frag, criteria, depth = 25, current = 0, visited = null) {
|
|
||||||
if ( ! visited )
|
|
||||||
visited = new Set;
|
|
||||||
else if ( visited.has(frag) )
|
|
||||||
return null;
|
|
||||||
|
|
||||||
if ( criteria(frag) )
|
|
||||||
return frag;
|
|
||||||
|
|
||||||
if ( current >= depth )
|
|
||||||
return null;
|
|
||||||
|
|
||||||
visited.add(frag);
|
|
||||||
|
|
||||||
if ( frag && frag.props && frag.props.children ) {
|
|
||||||
if ( Array.isArray(frag.props.children) ) {
|
|
||||||
for(const child of frag.props.children) {
|
|
||||||
if ( ! child )
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if ( Array.isArray(child) ) {
|
|
||||||
for(const f of child) {
|
|
||||||
const out = findReactFragment(f, criteria, depth, current + 1, visited);
|
|
||||||
if ( out )
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const out = findReactFragment(child, criteria, depth, current + 1, visited);
|
|
||||||
if ( out )
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const out = findReactFragment(frag.props.children, criteria, depth, current + 1, visited);
|
|
||||||
if ( out )
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function createElement(tag, props, ...children) {
|
|
||||||
const el = document.createElement(tag);
|
|
||||||
|
|
||||||
if ( children.length === 0)
|
|
||||||
children = null;
|
|
||||||
else if ( children.length === 1)
|
|
||||||
children = children[0];
|
|
||||||
|
|
||||||
if ( typeof props === 'string' )
|
|
||||||
el.className = props;
|
|
||||||
else if ( props )
|
|
||||||
for(const key in props)
|
|
||||||
if ( has(props, key) ) {
|
|
||||||
const lk = key.toLowerCase(),
|
|
||||||
prop = props[key];
|
|
||||||
|
|
||||||
if ( lk === 'style' ) {
|
|
||||||
if ( typeof prop === 'string' )
|
|
||||||
el.style.cssText = prop;
|
|
||||||
else
|
|
||||||
for(const k in prop)
|
|
||||||
if ( has(prop, k) ) {
|
|
||||||
if ( has(el.style, k) || has(Object.getPrototypeOf(el.style), k) )
|
|
||||||
el.style[k] = prop[k];
|
|
||||||
else
|
|
||||||
el.style.setProperty(k, prop[k]);
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if ( lk === 'dataset' ) {
|
|
||||||
for(const k in prop)
|
|
||||||
if ( has(prop, k) )
|
|
||||||
el.dataset[camelCase(k)] = prop[k];
|
|
||||||
|
|
||||||
} else if ( key === 'dangerouslySetInnerHTML' ) {
|
|
||||||
// React compatibility is cool. SeemsGood
|
|
||||||
if ( prop && prop.__html )
|
|
||||||
el.innerHTML = prop.__html;
|
|
||||||
|
|
||||||
} else if ( lk.startsWith('on') )
|
|
||||||
el.addEventListener(lk.slice(2), prop);
|
|
||||||
|
|
||||||
else if ( lk.startsWith('data-') )
|
|
||||||
el.dataset[camelCase(lk.slice(5))] = prop;
|
|
||||||
|
|
||||||
else if ( BOOLEAN_ATTRS.includes(lk) ) {
|
|
||||||
if ( prop && prop !== 'false' )
|
|
||||||
el.setAttribute(key, prop);
|
|
||||||
|
|
||||||
} else if ( lk.startsWith('aria-') || ATTRS.includes(lk) )
|
|
||||||
el.setAttribute(key, prop);
|
|
||||||
|
|
||||||
else
|
|
||||||
el[key] = prop;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( children )
|
|
||||||
setChildren(el, children);
|
|
||||||
|
|
||||||
return el;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setChildren(el, children, no_sanitize, no_empty) {
|
|
||||||
if (children instanceof Node ) {
|
|
||||||
if (! no_empty )
|
|
||||||
el.innerHTML = '';
|
|
||||||
|
|
||||||
el.appendChild(children);
|
|
||||||
|
|
||||||
} else if ( Array.isArray(children) ) {
|
|
||||||
if (! no_empty)
|
|
||||||
el.innerHTML = '';
|
|
||||||
|
|
||||||
for(const child of children)
|
|
||||||
if (child instanceof Node)
|
|
||||||
el.appendChild(child);
|
|
||||||
else if (Array.isArray(child))
|
|
||||||
setChildren(el, child, no_sanitize, true);
|
|
||||||
else if (child) {
|
|
||||||
const val = typeof child === 'string' ? child : String(child);
|
|
||||||
|
|
||||||
el.appendChild(no_sanitize ?
|
|
||||||
range.createContextualFragment(val) : document.createTextNode(val));
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if (children) {
|
|
||||||
const val = typeof children === 'string' ? children : String(children);
|
|
||||||
|
|
||||||
el.appendChild(no_sanitize ?
|
|
||||||
range.createContextualFragment(val) : document.createTextNode(val));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function findSharedParent(element, other, selector) {
|
|
||||||
while(element) {
|
|
||||||
if ( element.contains(other) )
|
|
||||||
return true;
|
|
||||||
|
|
||||||
element = element.parentElement;
|
|
||||||
if ( selector )
|
|
||||||
element = element && element.closest(selector);
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function openFile(contentType, multiple) {
|
|
||||||
return new Promise(resolve => {
|
|
||||||
const input = document.createElement('input');
|
|
||||||
input.type = 'file';
|
|
||||||
input.accept = contentType;
|
|
||||||
input.multiple = multiple;
|
|
||||||
|
|
||||||
let resolved = false;
|
|
||||||
|
|
||||||
// TODO: Investigate this causing issues
|
|
||||||
// for some users.
|
|
||||||
/*const focuser = () => {
|
|
||||||
off(window, 'focus', focuser);
|
|
||||||
setTimeout(() => {
|
|
||||||
if ( ! resolved ) {
|
|
||||||
resolved = true;
|
|
||||||
resolve(multiple ? [] : null);
|
|
||||||
}
|
|
||||||
}, 5000);
|
|
||||||
};
|
|
||||||
|
|
||||||
on(window, 'focus', focuser);*/
|
|
||||||
|
|
||||||
input.onchange = () => {
|
|
||||||
//off(window, 'focus', focuser);
|
|
||||||
if ( ! resolved ) {
|
|
||||||
resolved = true;
|
|
||||||
const files = Array.from(input.files);
|
|
||||||
resolve(multiple ? files : files[0])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input.click();
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function readFile(file, encoding = 'utf-8') {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const reader = new FileReader();
|
|
||||||
reader.readAsText(file, encoding);
|
|
||||||
reader.onload = () => resolve(reader.result);
|
|
||||||
reader.onerror = e => reject(e);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const el = createElement('span');
|
|
||||||
|
|
||||||
export function sanitize(text) {
|
|
||||||
el.textContent = text;
|
|
||||||
return el.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
let last_id = 0;
|
|
||||||
|
|
||||||
export class ManagedStyle {
|
|
||||||
constructor(id) {
|
|
||||||
this.id = id || last_id++;
|
|
||||||
|
|
||||||
this._blocks = {};
|
|
||||||
|
|
||||||
this._style = createElement('style', {
|
|
||||||
type: 'text/css',
|
|
||||||
id: `ffz--managed-style--${this.id}`
|
|
||||||
});
|
|
||||||
|
|
||||||
document.head.appendChild(this._style);
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
this._style.remove();
|
|
||||||
this._blocks = null;
|
|
||||||
this._style = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
clear() {
|
|
||||||
this._blocks = {};
|
|
||||||
this._style.innerHTML = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
get(key) {
|
|
||||||
const block = this._blocks[key];
|
|
||||||
if ( block )
|
|
||||||
return block.textContent;
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
has(key) {
|
|
||||||
return !! this._blocks[key];
|
|
||||||
}
|
|
||||||
|
|
||||||
set(key, value, force) {
|
|
||||||
const block = this._blocks[key];
|
|
||||||
if ( block ) {
|
|
||||||
if ( ! force && block.textContent === value )
|
|
||||||
return;
|
|
||||||
|
|
||||||
block.textContent = value;
|
|
||||||
} else
|
|
||||||
this._style.appendChild(this._blocks[key] = document.createTextNode(value));
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(key) {
|
|
||||||
const block = this._blocks[key];
|
|
||||||
if ( block ) {
|
|
||||||
if ( this._style.contains(block) )
|
|
||||||
this._style.removeChild(block);
|
|
||||||
|
|
||||||
this._blocks[key] = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export class ClickOutside {
|
|
||||||
constructor(element, callback) {
|
|
||||||
this.el = element;
|
|
||||||
this.cb = callback;
|
|
||||||
this._fn = this.handleClick.bind(this);
|
|
||||||
document.documentElement.addEventListener('click', this._fn);
|
|
||||||
}
|
|
||||||
|
|
||||||
destroy() {
|
|
||||||
if ( this._fn )
|
|
||||||
document.documentElement.removeEventListener('click', this._fn);
|
|
||||||
|
|
||||||
this.cb = this.el = this._fn = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleClick(e) {
|
|
||||||
if ( this.el && ! this.el.contains(e.target) )
|
|
||||||
this.cb(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// TODO: Rewrite this method to not use raw HTML.
|
|
||||||
|
|
||||||
export function highlightJson(object, pretty = false, depth = 1, max_depth = 30) {
|
|
||||||
let indent = '', indent_inner = '';
|
|
||||||
if ( pretty ) {
|
|
||||||
indent = ' '.repeat(depth - 1);
|
|
||||||
indent_inner = ' '.repeat(depth);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( depth > max_depth )
|
|
||||||
return `<span class="ffz-ct--obj-literal"><nested></span>`;
|
|
||||||
|
|
||||||
if (object == null)
|
|
||||||
return `<span class="ffz-ct--literal" depth="${depth}">null</span>`;
|
|
||||||
|
|
||||||
if ( typeof object === 'number' || typeof object === 'boolean' )
|
|
||||||
return `<span class="ffz-ct--literal" depth="${depth}">${object}</span>`;
|
|
||||||
|
|
||||||
if ( typeof object === 'string' )
|
|
||||||
return `<span class=ffz-ct--string depth="${depth}">"${sanitize(object)}"</span>`;
|
|
||||||
|
|
||||||
if ( Array.isArray(object) )
|
|
||||||
return `<span class="ffz-ct--obj-open" depth="${depth}">[</span>`
|
|
||||||
+ (object.length > 0 ? (
|
|
||||||
object.map(x => (pretty ? `\n${indent_inner}` : '') + highlightJson(x, pretty, depth + 1, max_depth)).join(`<span class="ffz-ct--obj-sep" depth="${depth}">, </span>`)
|
|
||||||
+ (pretty ? `\n${indent}` : '')
|
|
||||||
) : '')
|
|
||||||
+ `<span class="ffz-ct--obj-close" depth="${depth}">]</span>`;
|
|
||||||
|
|
||||||
const out = [];
|
|
||||||
|
|
||||||
for(const [key, val] of Object.entries(object)) {
|
|
||||||
if ( out.length > 0 )
|
|
||||||
out.push(`<span class="ffz-ct--obj-sep" depth="${depth}">, </span>`);
|
|
||||||
|
|
||||||
if ( pretty )
|
|
||||||
out.push(`\n${indent_inner}`);
|
|
||||||
out.push(`<span class="ffz-ct--obj-key" depth="${depth}">"${sanitize(key)}"</span><span class="ffz-ct--obj-key-sep" depth="${depth}">: </span>`);
|
|
||||||
out.push(highlightJson(val, pretty, depth + 1, max_depth));
|
|
||||||
}
|
|
||||||
|
|
||||||
return `<span class="ffz-ct--obj-open" depth="${depth}">{</span>${out.join('')}${out.length && pretty ? `\n${indent}` : ''}<span class="ffz-ct--obj-close" depth="${depth}">}</span>`;
|
|
||||||
}
|
|
529
src/utilities/dom.ts
Normal file
529
src/utilities/dom.ts
Normal file
|
@ -0,0 +1,529 @@
|
||||||
|
|
||||||
|
import {has} from 'utilities/object';
|
||||||
|
import type { DomFragment, OptionalArray } from './types';
|
||||||
|
|
||||||
|
const ATTRS = [
|
||||||
|
'accept', 'accept-charset', 'accesskey', 'action', 'align', 'alt', 'async',
|
||||||
|
'autocomplete', 'autofocus', 'autoplay', 'bgcolor', 'border', 'buffered',
|
||||||
|
'challenge', 'charset', 'checked', 'cite', 'class', 'code', 'codebase',
|
||||||
|
'color', 'cols', 'colspan', 'content', 'contenteditable', 'contextmenu',
|
||||||
|
'controls', 'coords', 'crossorigin', 'data', 'data-*', 'datetime',
|
||||||
|
'default', 'defer', 'dir', 'dirname', 'download', 'draggable',
|
||||||
|
'dropzone', 'enctype', 'for', 'form', 'formaction', 'headers', 'height',
|
||||||
|
'hidden', 'high', 'href', 'hreflang', 'http-equiv', 'icon', 'id',
|
||||||
|
'integrity', 'ismap', 'itemprop', 'keytype', 'kind', 'label', 'lang',
|
||||||
|
'language', 'list', 'loop', 'low', 'manifest', 'max', 'maxlength',
|
||||||
|
'minlength', 'media', 'method', 'min', 'multiple', 'name',
|
||||||
|
'novalidate', 'open', 'optimum', 'pattern', 'ping', 'placeholder', 'poster',
|
||||||
|
'preload', 'radiogroup', 'readonly', 'rel', 'required', 'reversed', 'rows',
|
||||||
|
'rowspan', 'sandbox', 'scope', 'scoped', 'seamless', 'selected', 'shape',
|
||||||
|
'size', 'sizes', 'slot', 'span', 'spellcheck', 'src', 'srcdoc', 'srclang',
|
||||||
|
'srcset', 'start', 'step', 'style', 'summary', 'tabindex', 'target',
|
||||||
|
'title', 'type', 'usemap', 'value', 'width', 'wrap'
|
||||||
|
];
|
||||||
|
|
||||||
|
const BOOLEAN_ATTRS = [
|
||||||
|
'controls', 'autoplay', 'loop'
|
||||||
|
];
|
||||||
|
|
||||||
|
|
||||||
|
const range = document.createRange();
|
||||||
|
|
||||||
|
function camelCase(name: string) {
|
||||||
|
return name.replace(/[-_]\w/g, m => m[1].toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A simple helper method for calling {@link EventTarget.addEventListener}
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function on(obj: EventTarget, type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions) {
|
||||||
|
return obj.addEventListener(type, listener, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A simple helper method for calling {@link EventTarget.removeEventListener}
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
|
export function off(obj: EventTarget, type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | EventListenerOptions) {
|
||||||
|
return obj.removeEventListener(type, listener, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// TODO: Better fake React types.
|
||||||
|
|
||||||
|
type SimpleNodeLike = {
|
||||||
|
props?: {
|
||||||
|
children?: SimpleNodeLike | SimpleNodeLike[]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scan a React render tree, attempting to find a matching fragment.
|
||||||
|
*
|
||||||
|
* @param frag The initial point to start scanning the tree.
|
||||||
|
* @param criteria A function that returns true if a fragment matches what
|
||||||
|
* we want.
|
||||||
|
* @param depth The maximum scanning depth, defaults to 25.
|
||||||
|
* @param current For Internal Use. The current scanning depth.
|
||||||
|
* @param visited For Internal Use. A Set of all visited fragments, to prevent
|
||||||
|
* redundant checks.
|
||||||
|
* @returns The matching fragment, or null if one was not found.
|
||||||
|
*/
|
||||||
|
export function findReactFragment<TNode extends SimpleNodeLike>(
|
||||||
|
frag: TNode,
|
||||||
|
criteria: (node: TNode) => boolean,
|
||||||
|
depth: number = 25,
|
||||||
|
current: number = 0,
|
||||||
|
visited?: Set<any>
|
||||||
|
): TNode | null {
|
||||||
|
if ( ! visited )
|
||||||
|
visited = new Set;
|
||||||
|
else if ( visited.has(frag) )
|
||||||
|
return null;
|
||||||
|
|
||||||
|
if ( criteria(frag) )
|
||||||
|
return frag;
|
||||||
|
|
||||||
|
if ( current >= depth )
|
||||||
|
return null;
|
||||||
|
|
||||||
|
visited.add(frag);
|
||||||
|
|
||||||
|
if ( frag?.props?.children ) {
|
||||||
|
if ( Array.isArray(frag.props.children) ) {
|
||||||
|
for(const child of frag.props.children) {
|
||||||
|
if ( ! child )
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if ( Array.isArray(child) ) {
|
||||||
|
for(const f of child) {
|
||||||
|
const out = findReactFragment(f, criteria, depth, current + 1, visited);
|
||||||
|
if ( out )
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const out = findReactFragment(child as TNode, criteria, depth, current + 1, visited);
|
||||||
|
if ( out )
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const out = findReactFragment(frag.props.children as TNode, criteria, depth, current + 1, visited);
|
||||||
|
if ( out )
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// TODO: Stronger types.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This method allows you to create native DOM fragments using the same calling
|
||||||
|
* syntax as React's `React.createElement` method. Because of this, we can use
|
||||||
|
* JSX for creating native DOM fragments, as well as rendering functions that are
|
||||||
|
* interchangable inside of and outside of a React context.
|
||||||
|
*
|
||||||
|
* @example Create a span containing a figure to render an icon.
|
||||||
|
* ```typescript
|
||||||
|
* return createElement('span', {
|
||||||
|
* className: 'ffz--icon-holder tw-mg-r-05'
|
||||||
|
* }, createElement('figure', {
|
||||||
|
* className: 'ffz-i-zreknarf'
|
||||||
|
* }));
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @example Doing the same, but with JSX
|
||||||
|
* ```typescript
|
||||||
|
* // When using JSX, we still need to make sure createElement is available in
|
||||||
|
* // the current context. It can be provided as an argument to a function, or
|
||||||
|
* // imported at the top level of the module.
|
||||||
|
*
|
||||||
|
* import { createElement } from 'utilities/dom';
|
||||||
|
* // or... if you're working with add-ons...
|
||||||
|
* const { createElement } = FrankerFaceZ.utilities.dom;
|
||||||
|
*
|
||||||
|
* return (<span class="ffz--icon-holder tw-mg-r-05">
|
||||||
|
* <figure class="ffz-i-zreknarf" />
|
||||||
|
* </span>);
|
||||||
|
* ```
|
||||||
|
*
|
||||||
|
* @param tag The name of the tag to be created. Functions are not supported.
|
||||||
|
* @param props The properties object.
|
||||||
|
* @param children A child or list of children. These should be strings, `null`s,
|
||||||
|
* or {@link Node}s that can be assigned as children of a {@link HTMLElement}.
|
||||||
|
*/
|
||||||
|
export function createElement<K extends keyof HTMLElementTagNameMap>(tag: K, props?: any, ...children: DomFragment[]): HTMLElementTagNameMap[K];
|
||||||
|
export function createElement<K extends keyof HTMLElementDeprecatedTagNameMap>(tag: K, props?: any, ...children: DomFragment[]): HTMLElementDeprecatedTagNameMap[K];
|
||||||
|
export function createElement(tag: string, props?: any, ...children: DomFragment[]): HTMLElement {
|
||||||
|
const el = document.createElement(tag);
|
||||||
|
|
||||||
|
if ( children.length === 0)
|
||||||
|
children = null as any;
|
||||||
|
else if ( children.length === 1 )
|
||||||
|
children = children[0] as any;
|
||||||
|
|
||||||
|
if ( typeof props === 'string' )
|
||||||
|
el.className = props;
|
||||||
|
else if ( props )
|
||||||
|
for(const key in props)
|
||||||
|
if ( has(props, key) ) {
|
||||||
|
const lk = key.toLowerCase(),
|
||||||
|
prop = props[key];
|
||||||
|
|
||||||
|
if ( lk === 'style' ) {
|
||||||
|
if ( typeof prop === 'string' )
|
||||||
|
el.style.cssText = prop;
|
||||||
|
else
|
||||||
|
for(const [key, val] of Object.entries(prop)) {
|
||||||
|
if ( has(el.style, key) || has(Object.getPrototypeOf(el.style), key) )
|
||||||
|
(el.style as any)[key] = val;
|
||||||
|
else
|
||||||
|
el.style.setProperty(key, prop[key]);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if ( lk === 'dataset' ) {
|
||||||
|
for(const k in prop)
|
||||||
|
if ( has(prop, k) )
|
||||||
|
el.dataset[camelCase(k)] = prop[k];
|
||||||
|
|
||||||
|
} else if ( key === 'dangerouslySetInnerHTML' ) {
|
||||||
|
// React compatibility is cool. SeemsGood
|
||||||
|
if ( prop && prop.__html )
|
||||||
|
el.innerHTML = prop.__html;
|
||||||
|
|
||||||
|
} else if ( lk.startsWith('on') )
|
||||||
|
el.addEventListener(lk.slice(2), prop);
|
||||||
|
|
||||||
|
else if ( lk.startsWith('data-') )
|
||||||
|
el.dataset[camelCase(lk.slice(5))] = prop;
|
||||||
|
|
||||||
|
else if ( BOOLEAN_ATTRS.includes(lk) ) {
|
||||||
|
if ( prop && prop !== 'false' )
|
||||||
|
el.setAttribute(key, prop);
|
||||||
|
|
||||||
|
} else if ( lk.startsWith('aria-') || ATTRS.includes(lk) )
|
||||||
|
el.setAttribute(key, prop);
|
||||||
|
|
||||||
|
else
|
||||||
|
(el as any)[key] = prop;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( children )
|
||||||
|
setChildren(el, children);
|
||||||
|
|
||||||
|
return el;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the children of a {@link HTMLElement}. This is also used internally by
|
||||||
|
* the {@link createElement} method.
|
||||||
|
*
|
||||||
|
* @param element The element to set the children of.
|
||||||
|
* @param children The children to add to the element.
|
||||||
|
* @param no_sanitize If this is set to true, any provided string values will
|
||||||
|
* be treated as HTML rather than text and will not be sanitized. This is
|
||||||
|
* NOT recommended.
|
||||||
|
* @param no_empty If this is set to true, the element's previous contents
|
||||||
|
* will not be discarded before setting the new children.
|
||||||
|
*/
|
||||||
|
export function setChildren(
|
||||||
|
element: HTMLElement,
|
||||||
|
children: DomFragment,
|
||||||
|
no_sanitize: boolean = false,
|
||||||
|
no_empty: boolean = false
|
||||||
|
) {
|
||||||
|
if (children instanceof Node ) {
|
||||||
|
if (! no_empty )
|
||||||
|
element.innerHTML = '';
|
||||||
|
|
||||||
|
element.appendChild(children);
|
||||||
|
|
||||||
|
} else if ( Array.isArray(children) ) {
|
||||||
|
if (! no_empty)
|
||||||
|
element.innerHTML = '';
|
||||||
|
|
||||||
|
for(const child of children)
|
||||||
|
if (child instanceof Node)
|
||||||
|
element.appendChild(child);
|
||||||
|
else if (Array.isArray(child))
|
||||||
|
setChildren(element, child, no_sanitize, true);
|
||||||
|
else if (child) {
|
||||||
|
const val = typeof child === 'string' ? child : String(child);
|
||||||
|
|
||||||
|
element.appendChild(no_sanitize ?
|
||||||
|
range.createContextualFragment(val) : document.createTextNode(val));
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (children) {
|
||||||
|
const val = typeof children === 'string' ? children : String(children);
|
||||||
|
|
||||||
|
element.appendChild(no_sanitize ?
|
||||||
|
range.createContextualFragment(val) : document.createTextNode(val));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if the two provided Nodes share a parent.
|
||||||
|
*
|
||||||
|
* @param element The first node.
|
||||||
|
* @param other The second node.
|
||||||
|
* @param selector A CSS selector to use. If this is set, only consider parents
|
||||||
|
* that match the selector.
|
||||||
|
*/
|
||||||
|
export function hasSharedParent(element: Node | null, other: Node, selector?: string) {
|
||||||
|
while(element) {
|
||||||
|
if ( element.contains(other) )
|
||||||
|
return true;
|
||||||
|
|
||||||
|
element = element.parentElement;
|
||||||
|
if ( selector )
|
||||||
|
element = element instanceof Element
|
||||||
|
? element.closest(selector)
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Display an Open File dialog to the user and return the selected
|
||||||
|
* value. This may never return depending on the user agent's
|
||||||
|
* behavior and should be used sparingly and never in a heavy
|
||||||
|
* context to avoid excess memory usage.
|
||||||
|
*
|
||||||
|
* @param contentType The content type to filter by when selecting files.
|
||||||
|
* @param multiple Whether or not multiple files should be returned.
|
||||||
|
* @returns A file or list of files.
|
||||||
|
*/
|
||||||
|
export function openFile(contentType: string, multiple: boolean) {
|
||||||
|
return new Promise<File | File[] | null>(resolve => {
|
||||||
|
const input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = contentType;
|
||||||
|
input.multiple = multiple;
|
||||||
|
|
||||||
|
let resolved = false;
|
||||||
|
|
||||||
|
// TODO: Investigate this causing issues
|
||||||
|
// for some users.
|
||||||
|
/*const focuser = () => {
|
||||||
|
off(window, 'focus', focuser);
|
||||||
|
setTimeout(() => {
|
||||||
|
if ( ! resolved ) {
|
||||||
|
resolved = true;
|
||||||
|
resolve(multiple ? [] : null);
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
};
|
||||||
|
|
||||||
|
on(window, 'focus', focuser);*/
|
||||||
|
|
||||||
|
input.onchange = () => {
|
||||||
|
//off(window, 'focus', focuser);
|
||||||
|
if ( ! resolved ) {
|
||||||
|
resolved = true;
|
||||||
|
const files = Array.from(input.files ?? []);
|
||||||
|
resolve(multiple ? files : files[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input.click();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the contents of a {@link File} asynchronously.
|
||||||
|
*
|
||||||
|
* @param file The file to read
|
||||||
|
* @param encoding The character encoding to use. Defaults to UTF-8.
|
||||||
|
*/
|
||||||
|
export function readFile(file: Blob, encoding = 'utf-8') {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.readAsText(file, encoding);
|
||||||
|
reader.onload = () => resolve(reader.result);
|
||||||
|
reader.onerror = e => reject(e);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const el = document.createElement('span');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitize a string, replacing all special HTML characters
|
||||||
|
* with entities.
|
||||||
|
*
|
||||||
|
* Internally, this uses the browser's native DOM library
|
||||||
|
* by setting `textContent` on an Element and returning its
|
||||||
|
* `innerHTML`.
|
||||||
|
*
|
||||||
|
* @param text The text to sanitize.
|
||||||
|
*/
|
||||||
|
export function sanitize(text: string) {
|
||||||
|
el.textContent = text;
|
||||||
|
const out = el.innerHTML;
|
||||||
|
// Ensure we're not keeping large strings in memory.
|
||||||
|
el.textContent = '';
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
let last_id = 0;
|
||||||
|
|
||||||
|
export class ManagedStyle {
|
||||||
|
id: number;
|
||||||
|
|
||||||
|
private _blocks: Record<string, Text | null>;
|
||||||
|
private _style: HTMLStyleElement;
|
||||||
|
|
||||||
|
constructor(id?: number) {
|
||||||
|
this.id = id || last_id++;
|
||||||
|
|
||||||
|
this._blocks = {};
|
||||||
|
|
||||||
|
this._style = createElement('style', {
|
||||||
|
type: 'text/css',
|
||||||
|
id: `ffz--managed-style--${this.id}`
|
||||||
|
});
|
||||||
|
|
||||||
|
document.head.appendChild(this._style);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if ( this._style )
|
||||||
|
this._style.remove();
|
||||||
|
|
||||||
|
// This is lazy typing, but I don't really care.
|
||||||
|
// Rather do this than put checks in every other bit of code.
|
||||||
|
this._blocks = null as any;
|
||||||
|
this._style = null as any;
|
||||||
|
}
|
||||||
|
|
||||||
|
clear() {
|
||||||
|
this._blocks = {};
|
||||||
|
this._style.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
get(key: string) {
|
||||||
|
const block = this._blocks[key];
|
||||||
|
if ( block )
|
||||||
|
return block.textContent;
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
has(key: string) {
|
||||||
|
return !! this._blocks[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
set(key: string, value: string, force: boolean = false) {
|
||||||
|
const block = this._blocks[key];
|
||||||
|
if ( block ) {
|
||||||
|
if ( ! force && block.textContent === value )
|
||||||
|
return;
|
||||||
|
|
||||||
|
block.textContent = value;
|
||||||
|
} else
|
||||||
|
this._style.appendChild(this._blocks[key] = document.createTextNode(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(key: string) {
|
||||||
|
const block = this._blocks[key];
|
||||||
|
if ( block ) {
|
||||||
|
if ( this._style.contains(block) )
|
||||||
|
this._style.removeChild(block);
|
||||||
|
|
||||||
|
this._blocks[key] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class ClickOutside {
|
||||||
|
|
||||||
|
el: HTMLElement | null;
|
||||||
|
cb: ((event: MouseEvent) => void) | null;
|
||||||
|
_fn: ((event: MouseEvent) => void) | null;
|
||||||
|
|
||||||
|
constructor(element: HTMLElement, callback: ((event: MouseEvent) => void)) {
|
||||||
|
this.el = element;
|
||||||
|
this.cb = callback;
|
||||||
|
|
||||||
|
this._fn = this.handleClick.bind(this);
|
||||||
|
document.documentElement.addEventListener('click', this.handleClick);
|
||||||
|
}
|
||||||
|
|
||||||
|
destroy() {
|
||||||
|
if ( this._fn )
|
||||||
|
document.documentElement.removeEventListener('click', this._fn);
|
||||||
|
|
||||||
|
this.cb = this.el = this._fn = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleClick(event: MouseEvent) {
|
||||||
|
if ( this.cb && this.el && ! this.el.contains(event.target as Node) )
|
||||||
|
this.cb(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Take an object that can be expressed as JSON and return a string of HTML
|
||||||
|
* that can be used to display the object with highlighting and formatting.
|
||||||
|
*
|
||||||
|
* TODO: Rewrite this method to not use raw HTML.
|
||||||
|
*
|
||||||
|
* @deprecated You should not depend on this method, as its signature is expected to change.
|
||||||
|
*
|
||||||
|
* @param object The object to be formatted
|
||||||
|
* @param pretty Whether or not to use indentation when rendering the object
|
||||||
|
* @param depth The current rendering depth
|
||||||
|
* @param max_depth The maximum depth to render, defaults to 30.
|
||||||
|
* @returns A string of HTML.
|
||||||
|
*/
|
||||||
|
export function highlightJson(object: any, pretty = false, depth = 1, max_depth = 30): string {
|
||||||
|
let indent = '', indent_inner = '';
|
||||||
|
if ( pretty ) {
|
||||||
|
indent = ' '.repeat(depth - 1);
|
||||||
|
indent_inner = ' '.repeat(depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( depth > max_depth )
|
||||||
|
return `<span class="ffz-ct--obj-literal"><nested></span>`;
|
||||||
|
|
||||||
|
if (object == null)
|
||||||
|
return `<span class="ffz-ct--literal" depth="${depth}">null</span>`;
|
||||||
|
|
||||||
|
if ( typeof object === 'number' || typeof object === 'boolean' )
|
||||||
|
return `<span class="ffz-ct--literal" depth="${depth}">${object}</span>`;
|
||||||
|
|
||||||
|
if ( typeof object === 'string' )
|
||||||
|
return `<span class=ffz-ct--string depth="${depth}">"${sanitize(object)}"</span>`;
|
||||||
|
|
||||||
|
if ( Array.isArray(object) )
|
||||||
|
return `<span class="ffz-ct--obj-open" depth="${depth}">[</span>`
|
||||||
|
+ (object.length > 0 ? (
|
||||||
|
object.map(x => (pretty ? `\n${indent_inner}` : '') + highlightJson(x, pretty, depth + 1, max_depth)).join(`<span class="ffz-ct--obj-sep" depth="${depth}">, </span>`)
|
||||||
|
+ (pretty ? `\n${indent}` : '')
|
||||||
|
) : '')
|
||||||
|
+ `<span class="ffz-ct--obj-close" depth="${depth}">]</span>`;
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
|
||||||
|
for(const [key, val] of Object.entries(object)) {
|
||||||
|
if ( out.length > 0 )
|
||||||
|
out.push(`<span class="ffz-ct--obj-sep" depth="${depth}">, </span>`);
|
||||||
|
|
||||||
|
if ( pretty )
|
||||||
|
out.push(`\n${indent_inner}`);
|
||||||
|
out.push(`<span class="ffz-ct--obj-key" depth="${depth}">"${sanitize(key)}"</span><span class="ffz-ct--obj-key-sep" depth="${depth}">: </span>`);
|
||||||
|
out.push(highlightJson(val, pretty, depth + 1, max_depth));
|
||||||
|
}
|
||||||
|
|
||||||
|
return `<span class="ffz-ct--obj-open" depth="${depth}">{</span>${out.join('')}${out.length && pretty ? `\n${indent}` : ''}<span class="ffz-ct--obj-close" depth="${depth}">}</span>`;
|
||||||
|
}
|
|
@ -1,484 +0,0 @@
|
||||||
// ============================================================================
|
|
||||||
// EventEmitter
|
|
||||||
// Homegrown for that lean feeling.
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import {has} from 'utilities/object';
|
|
||||||
|
|
||||||
const Detach = Symbol('Detach');
|
|
||||||
const StopPropagation = Symbol('StopPropagation');
|
|
||||||
|
|
||||||
const SNAKE_CAPS = /([a-z])([A-Z])/g,
|
|
||||||
SNAKE_SPACE = /[ \t\W]/g,
|
|
||||||
SNAKE_TRIM = /^_+|_+$/g;
|
|
||||||
|
|
||||||
String.prototype.toSlug = function(separator = '-') {
|
|
||||||
let result = this;
|
|
||||||
if (result.normalize)
|
|
||||||
result = result.normalize('NFD');
|
|
||||||
|
|
||||||
return result
|
|
||||||
.replace(/[\u0300-\u036f]/g, '')
|
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/[^a-z0-9 ]/g, '')
|
|
||||||
.replace(/\s+/g, separator);
|
|
||||||
}
|
|
||||||
|
|
||||||
String.prototype.toSnakeCase = function() {
|
|
||||||
let result = this;
|
|
||||||
if (result.normalize)
|
|
||||||
result = result.normalize('NFD');
|
|
||||||
|
|
||||||
return result
|
|
||||||
.replace(/[\u0300-\u036f]/g, '')
|
|
||||||
.trim()
|
|
||||||
.replace(SNAKE_CAPS, '$1_$2')
|
|
||||||
.replace(SNAKE_SPACE, '_')
|
|
||||||
.replace(SNAKE_TRIM, '')
|
|
||||||
.toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export class EventEmitter {
|
|
||||||
constructor() {
|
|
||||||
this.__listeners = {};
|
|
||||||
this.__running = new Set;
|
|
||||||
this.__dead_events = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
__cleanListeners() {
|
|
||||||
if ( ! this.__dead_events )
|
|
||||||
return;
|
|
||||||
|
|
||||||
const nl = {}, ol = this.__listeners;
|
|
||||||
for(const key in ol)
|
|
||||||
if ( has(ol, key) ) {
|
|
||||||
const val = ol[key];
|
|
||||||
if ( val )
|
|
||||||
nl[key] = val;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.__listeners = nl;
|
|
||||||
this.__dead_events = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// Public Methods
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
on(event, fn, ctx) {
|
|
||||||
if ( typeof fn !== 'function' )
|
|
||||||
throw new TypeError('fn must be a function');
|
|
||||||
|
|
||||||
(this.__listeners[event] = this.__listeners[event] || []).push([fn, ctx, false])
|
|
||||||
}
|
|
||||||
|
|
||||||
prependOn(event, fn, ctx) {
|
|
||||||
if ( typeof fn !== 'function' )
|
|
||||||
throw new TypeError('fn must be a function');
|
|
||||||
|
|
||||||
(this.__listeners[event] = this.__listeners[event] || []).unshift([fn, ctx, false])
|
|
||||||
}
|
|
||||||
|
|
||||||
once(event, fn, ctx) { return this.many(event, 1, fn, ctx) }
|
|
||||||
prependOnce(event, fn, ctx) { return this.prependMany(event, 1, fn, ctx) }
|
|
||||||
|
|
||||||
many(event, ttl, fn, ctx) {
|
|
||||||
if ( typeof fn !== 'function' )
|
|
||||||
throw new TypeError('fn must be a function');
|
|
||||||
|
|
||||||
if ( typeof ttl !== 'number' || isNaN(ttl) || ! isFinite(ttl) || ttl < 1 )
|
|
||||||
throw new TypeError('ttl must be a positive, finite number');
|
|
||||||
|
|
||||||
(this.__listeners[event] = this.__listeners[event] || []).push([fn, ctx, ttl]);
|
|
||||||
}
|
|
||||||
|
|
||||||
prependMany(event, ttl, fn, ctx) {
|
|
||||||
if ( typeof fn !== 'function' )
|
|
||||||
throw new TypeError('fn must be a function');
|
|
||||||
|
|
||||||
if ( typeof ttl !== 'number' || isNaN(ttl) || ! isFinite(ttl) || ttl < 1 )
|
|
||||||
throw new TypeError('ttl must be a positive, finite number');
|
|
||||||
|
|
||||||
(this.__listeners[event] = this.__listeners[event] || []).unshift([fn, ctx, ttl]);
|
|
||||||
}
|
|
||||||
|
|
||||||
waitFor(event) {
|
|
||||||
return new Promise(resolve => {
|
|
||||||
(this.__listeners[event] = this.__listeners[event] || []).push([resolve, null, 1]);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
off(event, fn, ctx) {
|
|
||||||
if ( this.__running.has(event) )
|
|
||||||
throw new Error(`concurrent modification: tried removing event listener while event is running`);
|
|
||||||
|
|
||||||
let list = this.__listeners[event];
|
|
||||||
if ( ! list )
|
|
||||||
return;
|
|
||||||
|
|
||||||
if ( ! fn )
|
|
||||||
list = null;
|
|
||||||
else {
|
|
||||||
list = list.filter(([f, c]) => !(f === fn && (!ctx || ctx === c)));
|
|
||||||
if ( ! list.length )
|
|
||||||
list = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.__listeners[event] = list;
|
|
||||||
if ( ! list )
|
|
||||||
this.__dead_events++;
|
|
||||||
}
|
|
||||||
|
|
||||||
offContext(event, ctx) {
|
|
||||||
if ( event == null ) {
|
|
||||||
for(const evt in Object.keys(this.__listeners)) {
|
|
||||||
if ( ! this.__running.has(evt) )
|
|
||||||
this.offContext(evt, ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( this.__running.has(event) )
|
|
||||||
throw new Error(`concurrent modification: tried removing event listener while event is running`);
|
|
||||||
|
|
||||||
let list = this.__listeners[event];
|
|
||||||
if ( ! list )
|
|
||||||
return;
|
|
||||||
|
|
||||||
if ( ! fn )
|
|
||||||
list = null;
|
|
||||||
else {
|
|
||||||
list = list.filter(x => x && x[1] !== ctx);
|
|
||||||
if ( ! list.length )
|
|
||||||
list = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.__listeners[event] = list;
|
|
||||||
if ( ! list )
|
|
||||||
this.__dead_events++;
|
|
||||||
}
|
|
||||||
|
|
||||||
events() {
|
|
||||||
this.__cleanListeners();
|
|
||||||
return Object.keys(this.__listeners);
|
|
||||||
}
|
|
||||||
|
|
||||||
listeners(event) {
|
|
||||||
const list = this.__listeners[event];
|
|
||||||
return list ? Array.from(list) : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
hasListeners(event) {
|
|
||||||
return !! this.__listeners[event]
|
|
||||||
}
|
|
||||||
|
|
||||||
emitUnsafe(event, ...args) {
|
|
||||||
let list = this.__listeners[event];
|
|
||||||
if ( ! list )
|
|
||||||
return;
|
|
||||||
|
|
||||||
if ( this.__running.has(event) )
|
|
||||||
throw new Error(`concurrent access: tried to emit event while event is running`);
|
|
||||||
|
|
||||||
// Track removals separately to make iteration over the event list
|
|
||||||
// much, much simpler.
|
|
||||||
const removed = new Set;
|
|
||||||
|
|
||||||
// Set the current list of listeners to null because we don't want
|
|
||||||
// to enter some kind of loop if a new listener is added as the result
|
|
||||||
// of an existing listener.
|
|
||||||
this.__listeners[event] = null;
|
|
||||||
this.__running.add(event);
|
|
||||||
|
|
||||||
for(const item of list) {
|
|
||||||
const [fn, ctx, ttl] = item,
|
|
||||||
ret = fn.apply(ctx, args);
|
|
||||||
|
|
||||||
if ( ret === Detach )
|
|
||||||
removed.add(item);
|
|
||||||
else if ( ttl !== false ) {
|
|
||||||
if ( ttl <= 1 )
|
|
||||||
removed.add(item);
|
|
||||||
else
|
|
||||||
item[2] = ttl - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( (args[0] instanceof FFZEvent && args[0].propagationStopped) || ret === StopPropagation )
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove any dead listeners from the list.
|
|
||||||
if ( removed.size ) {
|
|
||||||
for(const item of removed) {
|
|
||||||
const idx = list.indexOf(item);
|
|
||||||
if ( idx !== -1 )
|
|
||||||
list.splice(idx, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Were more listeners added while we were running? Just combine
|
|
||||||
// the two lists if so.
|
|
||||||
if ( this.__listeners[event] )
|
|
||||||
list = list.concat(this.__listeners[event]);
|
|
||||||
|
|
||||||
// If we have items, store the list back. Otherwise, mark that we
|
|
||||||
// have a dead listener.
|
|
||||||
if ( list.length )
|
|
||||||
this.__listeners[event] = list;
|
|
||||||
else {
|
|
||||||
this.__listeners[event] = null;
|
|
||||||
this.__dead_events++;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.__running.delete(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
emit(event, ...args) {
|
|
||||||
let list = this.__listeners[event];
|
|
||||||
if ( ! list )
|
|
||||||
return;
|
|
||||||
|
|
||||||
if ( this.__running.has(event) )
|
|
||||||
throw new Error(`concurrent access: tried to emit event while event is running`);
|
|
||||||
|
|
||||||
// Track removals separately to make iteration over the event list
|
|
||||||
// much, much simpler.
|
|
||||||
const removed = new Set;
|
|
||||||
|
|
||||||
// Set the current list of listeners to null because we don't want
|
|
||||||
// to enter some kind of loop if a new listener is added as the result
|
|
||||||
// of an existing listener.
|
|
||||||
this.__listeners[event] = null;
|
|
||||||
this.__running.add(event);
|
|
||||||
|
|
||||||
for(const item of list) {
|
|
||||||
const [fn, ctx, ttl] = item;
|
|
||||||
let ret;
|
|
||||||
try {
|
|
||||||
ret = fn.apply(ctx, args);
|
|
||||||
} catch(err) {
|
|
||||||
if ( this.log ) {
|
|
||||||
this.log.capture(err, {tags: {event}, extra:{args}});
|
|
||||||
this.log.error(err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( ret === Detach )
|
|
||||||
removed.add(item);
|
|
||||||
else if ( ttl !== false ) {
|
|
||||||
if ( ttl <= 1 )
|
|
||||||
removed.add(item);
|
|
||||||
else
|
|
||||||
item[2] = ttl - 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Automatically wait for a promise, if the return value is a promise
|
|
||||||
// and we're dealing with a waitable event.
|
|
||||||
if ( ret instanceof Promise ) {
|
|
||||||
if ( (args[0] instanceof FFZWaitableEvent) )
|
|
||||||
args[0].waitFor(ret);
|
|
||||||
/*else if ( this.log )
|
|
||||||
this.log.debug(`handler for event "${event}" returned a Promise but the event is not an FFZWaitableEvent`);*/
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( (args[0] instanceof FFZEvent && args[0].propagationStopped) || ret === StopPropagation )
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove any dead listeners from the list.
|
|
||||||
if ( removed.size ) {
|
|
||||||
for(const item of removed) {
|
|
||||||
const idx = list.indexOf(item);
|
|
||||||
if ( idx !== -1 )
|
|
||||||
list.splice(idx, 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Were more listeners added while we were running? Just combine
|
|
||||||
// the two lists if so.
|
|
||||||
if ( this.__listeners[event] )
|
|
||||||
list = list.concat(this.__listeners[event]);
|
|
||||||
|
|
||||||
// If we have items, store the list back. Otherwise, mark that we
|
|
||||||
// have a dead listener.
|
|
||||||
if ( list.length )
|
|
||||||
this.__listeners[event] = list;
|
|
||||||
else {
|
|
||||||
this.__listeners[event] = null;
|
|
||||||
this.__dead_events++;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.__running.delete(event);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
EventEmitter.Detach = Detach;
|
|
||||||
EventEmitter.StopPropagation = StopPropagation;
|
|
||||||
|
|
||||||
|
|
||||||
export class FFZEvent {
|
|
||||||
constructor(data) {
|
|
||||||
this.defaultPrevented = false;
|
|
||||||
this.propagationStopped = false;
|
|
||||||
|
|
||||||
Object.assign(this, data);
|
|
||||||
}
|
|
||||||
|
|
||||||
_reset() {
|
|
||||||
this.defaultPrevented = false;
|
|
||||||
this.propagationStopped = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
stopPropagation() {
|
|
||||||
this.propagationStopped = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
preventDefault() {
|
|
||||||
this.defaultPrevented = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export class FFZWaitableEvent extends FFZEvent {
|
|
||||||
|
|
||||||
_wait() {
|
|
||||||
if ( this.__waiter )
|
|
||||||
return this.__waiter;
|
|
||||||
|
|
||||||
if ( ! this.__promises )
|
|
||||||
return;
|
|
||||||
|
|
||||||
const promises = this.__promises;
|
|
||||||
this.__promises = null;
|
|
||||||
|
|
||||||
return this.__waiter = Promise.all(promises).finally(() => {
|
|
||||||
this.__waiter = null;
|
|
||||||
return this._wait();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
_reset() {
|
|
||||||
super._reset();
|
|
||||||
this.__waiter = null;
|
|
||||||
this.__promises = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
waitFor(promise) {
|
|
||||||
if ( ! this.__promises )
|
|
||||||
this.__promises = [promise];
|
|
||||||
else
|
|
||||||
this.__promises.push(promise);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export class HierarchicalEventEmitter extends EventEmitter {
|
|
||||||
constructor(name, parent) {
|
|
||||||
super();
|
|
||||||
|
|
||||||
this.name = name || (this.constructor.name || '').toSnakeCase();
|
|
||||||
this.parent = parent;
|
|
||||||
|
|
||||||
if ( parent ) {
|
|
||||||
this.root = parent.root;
|
|
||||||
this.__listeners = parent.__listeners;
|
|
||||||
this.__path = name && parent.__path ? `${parent.__path}.${name}` : name;
|
|
||||||
|
|
||||||
} else {
|
|
||||||
this.root = this;
|
|
||||||
this.__path = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.__path_parts = this.__path ? this.__path.split('.') : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// Public Properties
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
get path() {
|
|
||||||
return this.__path;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// Public Methods
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
abs_path(path) {
|
|
||||||
if ( typeof path !== 'string' || ! path.length )
|
|
||||||
throw new TypeError('path must be a non-empty string');
|
|
||||||
|
|
||||||
let i = 0, chr;
|
|
||||||
const parts = this.__path_parts,
|
|
||||||
depth = parts.length;
|
|
||||||
|
|
||||||
do {
|
|
||||||
chr = path.charAt(i);
|
|
||||||
if ( path.charAt(i) === '.' ) {
|
|
||||||
if ( i > depth )
|
|
||||||
throw new Error('invalid path: reached top of stack');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
} while ( ++i < path.length );
|
|
||||||
|
|
||||||
const event = chr === ':';
|
|
||||||
if ( i === 0 )
|
|
||||||
return event && this.__path ? `${this.__path}${path}` : path;
|
|
||||||
|
|
||||||
const prefix = parts.slice(0, depth - (i-1)).join('.'),
|
|
||||||
remain = path.slice(i);
|
|
||||||
|
|
||||||
if ( ! prefix.length )
|
|
||||||
return remain;
|
|
||||||
|
|
||||||
else if ( ! remain.length )
|
|
||||||
return prefix;
|
|
||||||
|
|
||||||
else if ( event )
|
|
||||||
return prefix + remain;
|
|
||||||
|
|
||||||
return `${prefix}.${remain}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
on(event, fn, ctx) { return super.on(this.abs_path(event), fn, ctx) }
|
|
||||||
prependOn(event, fn, ctx) { return super.prependOn(this.abs_path(event), fn, ctx) }
|
|
||||||
|
|
||||||
once(event, fn, ctx) { return super.once(this.abs_path(event), fn, ctx) }
|
|
||||||
prependOnce(event, fn, ctx) { return super.prependOnce(this.abs_path(event), fn, ctx) }
|
|
||||||
|
|
||||||
many(event, ttl, fn, ctx) { return super.many(this.abs_path(event), ttl, fn, ctx) }
|
|
||||||
prependMany(event, ttl, fn, ctx) { return super.prependMany(this.abs_path(event), ttl, fn, ctx) }
|
|
||||||
|
|
||||||
waitFor(event) { return super.waitFor(this.abs_path(event)) }
|
|
||||||
off(event, fn, ctx) { return super.off(this.abs_path(event), fn, ctx) }
|
|
||||||
listeners(event) { return super.listeners(this.abs_path(event)) }
|
|
||||||
hasListeners(event) { return super.hasListeners(this.abs_path(event)) }
|
|
||||||
|
|
||||||
emit(event, ...args) { return super.emit(this.abs_path(event), ...args) }
|
|
||||||
emitUnsafe(event, ...args) { return super.emitUnsafe(this.abs_path(event), ...args) }
|
|
||||||
|
|
||||||
events(include_children) {
|
|
||||||
this.__cleanListeners();
|
|
||||||
const keys = Object.keys(this.__listeners),
|
|
||||||
path = this.__path || '',
|
|
||||||
len = path.length;
|
|
||||||
|
|
||||||
return keys.filter(x => {
|
|
||||||
const y = x.charAt(len);
|
|
||||||
return x.startsWith(path) && (y === '' || (include_children && y === '.') || y === ':');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default HierarchicalEventEmitter;
|
|
1019
src/utilities/events.ts
Normal file
1019
src/utilities/events.ts
Normal file
File diff suppressed because it is too large
Load diff
|
@ -1,7 +1,15 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
// This is a generated file. To update it, please run: npm run font:update
|
// This is a generated file. To update it, please run: pnpm font:update
|
||||||
/* eslint quotes: 0 */
|
/* eslint quotes: 0 */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 [
|
export default [
|
||||||
"window-minimize",
|
"window-minimize",
|
||||||
"window-maximize",
|
"window-maximize",
|
||||||
|
@ -114,4 +122,4 @@ export default [
|
||||||
"doc-text",
|
"doc-text",
|
||||||
"fx",
|
"fx",
|
||||||
"artist"
|
"artist"
|
||||||
];
|
] as const;
|
|
@ -1,14 +1,55 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
import type { OptionalPromise, OptionallyCallable } from "./types";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Advanced Filter System
|
// Advanced Filter System
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export function createTester(rules, filter_types, inverted = false, or = false, rebuild) {
|
type FilterMethod<TContext = unknown> = (ctx: TContext) => boolean;
|
||||||
|
|
||||||
|
export type FilterType<TConfig, TContext> = {
|
||||||
|
createTest: (config: TConfig, filter_types: FilterTypeMap<TContext>, rebuild?: () => void) => FilterMethod<TContext>
|
||||||
|
|
||||||
|
default: OptionallyCallable<[], TConfig>;
|
||||||
|
|
||||||
|
// Editor Configuration
|
||||||
|
editor?: OptionallyCallable<[], OptionalPromise<any>>;
|
||||||
|
|
||||||
|
title: string;
|
||||||
|
i18n?: string | null;
|
||||||
|
tall?: boolean;
|
||||||
|
|
||||||
|
maxRules?: number;
|
||||||
|
childRules?: boolean;
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
type FilterTypeMap<TContext> = {
|
||||||
|
[key: string]: FilterType<any, TContext>;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export type FilterData = {
|
||||||
|
id?: string;
|
||||||
|
type: string;
|
||||||
|
data: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function createTester<TContext, Types extends FilterTypeMap<TContext>>(
|
||||||
|
rules: FilterData[] | null | undefined,
|
||||||
|
filter_types: Types,
|
||||||
|
inverted: boolean = false,
|
||||||
|
or: boolean = false,
|
||||||
|
rebuild?: () => void
|
||||||
|
): (ctx: TContext) => boolean {
|
||||||
|
|
||||||
if ( ! Array.isArray(rules) || ! filter_types )
|
if ( ! Array.isArray(rules) || ! filter_types )
|
||||||
return inverted ? () => false : () => true;
|
return inverted ? () => false : () => true;
|
||||||
|
|
||||||
const tests = [],
|
const tests: FilterMethod<TContext>[] = [],
|
||||||
names = [];
|
names = [];
|
||||||
|
|
||||||
let i = 0;
|
let i = 0;
|
||||||
|
@ -42,9 +83,9 @@ export function createTester(rules, filter_types, inverted = false, or = false,
|
||||||
return inverted ? () => false : () => true;
|
return inverted ? () => false : () => true;
|
||||||
|
|
||||||
if ( tests.length === 1 )
|
if ( tests.length === 1 )
|
||||||
return inverted ? ctx => ! tests[0](ctx) : tests[0];
|
return inverted ? (ctx: TContext) => ! tests[0](ctx) : tests[0];
|
||||||
|
|
||||||
return new Function(...names, 'ctx',
|
return new Function(...names, 'ctx',
|
||||||
`return ${inverted ? `!(` : ''}${names.map(name => `${name}(ctx)`).join(or ? ' || ' : ' && ')}${inverted ? ')' : ''};`
|
`return ${inverted ? `!(` : ''}${names.map(name => `${name}(ctx)`).join(or ? ' || ' : ' && ')}${inverted ? ')' : ''};`
|
||||||
).bind(null, ...tests);
|
).bind(null, ...tests);
|
||||||
}
|
}
|
|
@ -6,6 +6,11 @@ import {createElement} from 'utilities/dom';
|
||||||
// Font Awesome Data
|
// Font Awesome Data
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A map of aliases for FontAwesome icons. This is used to allow for slightly
|
||||||
|
* less annoying search behavior in UIs. These names are all raw icon names
|
||||||
|
* and not suitable for direct use.
|
||||||
|
*/
|
||||||
export const ALIASES = {
|
export const ALIASES = {
|
||||||
'ban': ['ban', 'block'],
|
'ban': ['ban', 'block'],
|
||||||
'ok': ['ok', 'unban', 'untimeout'],
|
'ok': ['ok', 'unban', 'untimeout'],
|
||||||
|
@ -102,8 +107,12 @@ export const ALIASES = {
|
||||||
'bath': ['bathtub','s15','bath'],
|
'bath': ['bathtub','s15','bath'],
|
||||||
'window-close': ['times-rectangle','window-close'],
|
'window-close': ['times-rectangle','window-close'],
|
||||||
'window-close-o': ['times-rectangle-o','window-close-o']
|
'window-close-o': ['times-rectangle-o','window-close-o']
|
||||||
};
|
} as Record<string, string[]>; // const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A list of all available FontAwesome icon names, for use in populating UIs.
|
||||||
|
* These are raw names, and not suitable for direct use.
|
||||||
|
*/
|
||||||
export const ICONS = [
|
export const ICONS = [
|
||||||
'glass','music','search','envelope-o','heart','star','star-o','user',
|
'glass','music','search','envelope-o','heart','star','star-o','user',
|
||||||
'film','th-large','th','th-list','check','times','search-plus',
|
'film','th-large','th','th-list','check','times','search-plus',
|
||||||
|
@ -217,12 +226,17 @@ export const ICONS = [
|
||||||
'thermometer-quarter','thermometer-empty','shower','bath','podcast',
|
'thermometer-quarter','thermometer-empty','shower','bath','podcast',
|
||||||
'window-maximize','window-minimize','window-restore','window-close',
|
'window-maximize','window-minimize','window-restore','window-close',
|
||||||
'window-close-o','bandcamp','grav','etsy','imdb','ravelry','eercast',
|
'window-close-o','bandcamp','grav','etsy','imdb','ravelry','eercast',
|
||||||
'microchip','snowflake-o','superpowers','wpexplorer','meetup'];
|
'microchip','snowflake-o','superpowers','wpexplorer','meetup'
|
||||||
|
] as string[]; // const;
|
||||||
|
|
||||||
let loaded = false;
|
let loaded = false;
|
||||||
|
|
||||||
import FA_URL from 'styles/font-awesome.scss';
|
import FA_URL from 'styles/font-awesome.scss';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load the FontAwesome stylesheet and font files if they have not already
|
||||||
|
* been loaded.
|
||||||
|
*/
|
||||||
export const load = () => {
|
export const load = () => {
|
||||||
if ( loaded )
|
if ( loaded )
|
||||||
return;
|
return;
|
||||||
|
@ -237,9 +251,14 @@ export const load = () => {
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export const maybeLoad = icon => {
|
/**
|
||||||
|
* Potentially load the FontAwesome stylesheet and font files, if the
|
||||||
|
* provided icon name requires them. If it does not, do nothing.
|
||||||
|
* @param icon An icon's name.
|
||||||
|
*/
|
||||||
|
export const maybeLoad = (icon: string) => {
|
||||||
if ( loaded || ! String(icon).startsWith('fa-') && ! String(icon).startsWith('ffz-fa') )
|
if ( loaded || ! String(icon).startsWith('fa-') && ! String(icon).startsWith('ffz-fa') )
|
||||||
return;
|
return;
|
||||||
|
|
||||||
load();
|
load();
|
||||||
}
|
}
|
|
@ -66,10 +66,10 @@ const GOOGLE_FONTS = [
|
||||||
'Karla'
|
'Karla'
|
||||||
];
|
];
|
||||||
|
|
||||||
const LOADED_GOOGLE = new Map();
|
const LOADED_GOOGLE = new Map<string, number>();
|
||||||
const LOADED_GOOGLE_LINKS = new Map();
|
const LOADED_GOOGLE_LINKS = new Map<string, HTMLLinkElement>();
|
||||||
|
|
||||||
function loadGoogleFont(font) {
|
function loadGoogleFont(font: string) {
|
||||||
if ( LOADED_GOOGLE_LINKS.has(font) )
|
if ( LOADED_GOOGLE_LINKS.has(font) )
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
@ -85,7 +85,7 @@ function loadGoogleFont(font) {
|
||||||
document.head.appendChild(link);
|
document.head.appendChild(link);
|
||||||
}
|
}
|
||||||
|
|
||||||
function unloadGoogleFont(font) {
|
function unloadGoogleFont(font: string) {
|
||||||
const link = LOADED_GOOGLE_LINKS.get(font);
|
const link = LOADED_GOOGLE_LINKS.get(font);
|
||||||
if ( ! link )
|
if ( ! link )
|
||||||
return;
|
return;
|
||||||
|
@ -106,7 +106,7 @@ const OD_FONTS = [
|
||||||
import OD_URL from 'styles/opendyslexic.scss';
|
import OD_URL from 'styles/opendyslexic.scss';
|
||||||
|
|
||||||
let od_count = 0;
|
let od_count = 0;
|
||||||
let od_link = null;
|
let od_link: HTMLLinkElement | null = null;
|
||||||
|
|
||||||
function loadOpenDyslexic() {
|
function loadOpenDyslexic() {
|
||||||
if ( od_link )
|
if ( od_link )
|
||||||
|
@ -134,8 +134,28 @@ function unloadOpenDyslexic() {
|
||||||
|
|
||||||
/* Using and Listing Fonts */
|
/* Using and Listing Fonts */
|
||||||
|
|
||||||
export function getFontsList() {
|
// TODO: Move this type somewhere more generic.
|
||||||
const out = [
|
type SettingSelectOption<TValue> = {
|
||||||
|
value: TValue;
|
||||||
|
|
||||||
|
title: string;
|
||||||
|
i18n_key?: string;
|
||||||
|
|
||||||
|
separator?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SettingSelectSeparator = {
|
||||||
|
separator: true;
|
||||||
|
|
||||||
|
title: string;
|
||||||
|
i18n_key?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SettingSelectEntry<TValue> = SettingSelectOption<TValue> | SettingSelectSeparator;
|
||||||
|
|
||||||
|
|
||||||
|
export function getFontsList(): SettingSelectEntry<string>[] {
|
||||||
|
const out: SettingSelectEntry<string>[] = [
|
||||||
{value: '', i18n_key: 'setting.font.default', title: 'Default'},
|
{value: '', i18n_key: 'setting.font.default', title: 'Default'},
|
||||||
{separator: true, i18n_key: 'setting.font.builtin', title: 'Built-in Fonts'},
|
{separator: true, i18n_key: 'setting.font.builtin', title: 'Built-in Fonts'},
|
||||||
];
|
];
|
||||||
|
@ -161,7 +181,7 @@ export function getFontsList() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function useFont(font) {
|
export function useFont(font: string): [string, (() => void) | null] {
|
||||||
if ( ! font )
|
if ( ! font )
|
||||||
return [font, null];
|
return [font, null];
|
||||||
|
|
|
@ -1,20 +1,23 @@
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
import type { DefinitionNode, DocumentNode, FieldNode, FragmentDefinitionNode, OperationDefinitionNode, SelectionNode, SelectionSetNode } from 'graphql';
|
||||||
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// GraphQL Document Manipulation
|
// GraphQL Document Manipulation
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
export const MERGE_METHODS = {
|
export const MERGE_METHODS: Record<string, (a: any, b: any) => any> = {
|
||||||
Document: (a, b) => {
|
Document: (a: DocumentNode, b: DocumentNode) => {
|
||||||
if ( a.definitions && b.definitions )
|
if ( a.definitions && b.definitions )
|
||||||
a.definitions = mergeList(a.definitions, b.definitions);
|
(a as any).definitions = mergeList(a.definitions as DefinitionNode[], b.definitions as any);
|
||||||
else if ( b.definitions )
|
else if ( b.definitions )
|
||||||
a.definitions = b.definitions;
|
(a as any).definitions = b.definitions;
|
||||||
|
|
||||||
return a;
|
return a;
|
||||||
},
|
},
|
||||||
|
|
||||||
Field: (a, b) => {
|
Field: (a: FieldNode, b: FieldNode) => {
|
||||||
if ( a.name && (! b.name || b.name.value !== a.name.value) )
|
if ( a.name && (! b.name || b.name.value !== a.name.value) )
|
||||||
return a;
|
return a;
|
||||||
|
|
||||||
|
@ -22,14 +25,14 @@ export const MERGE_METHODS = {
|
||||||
// TODO: directives
|
// TODO: directives
|
||||||
|
|
||||||
if ( a.selectionSet && b.selectionSet )
|
if ( a.selectionSet && b.selectionSet )
|
||||||
a.selectionSet = merge(a.selectionSet, b.selectionSet);
|
(a as any).selectionSet = merge(a.selectionSet, b.selectionSet);
|
||||||
else if ( b.selectionSet )
|
else if ( b.selectionSet )
|
||||||
a.selectionSet = b.selectionSet;
|
(a as any).selectionSet = b.selectionSet;
|
||||||
|
|
||||||
return a;
|
return a;
|
||||||
},
|
},
|
||||||
|
|
||||||
OperationDefinition: (a, b) => {
|
OperationDefinition: (a: OperationDefinitionNode, b: OperationDefinitionNode) => {
|
||||||
if ( a.operation !== b.operation )
|
if ( a.operation !== b.operation )
|
||||||
return a;
|
return a;
|
||||||
|
|
||||||
|
@ -37,14 +40,14 @@ export const MERGE_METHODS = {
|
||||||
// TODO: directives
|
// TODO: directives
|
||||||
|
|
||||||
if ( a.selectionSet && b.selectionSet )
|
if ( a.selectionSet && b.selectionSet )
|
||||||
a.selectionSet = merge(a.selectionSet, b.selectionSet);
|
(a as any).selectionSet = merge(a.selectionSet, b.selectionSet);
|
||||||
else if ( b.selectionSet )
|
else if ( b.selectionSet )
|
||||||
a.selectionSet = b.selectionSet;
|
(a as any).selectionSet = b.selectionSet;
|
||||||
|
|
||||||
return a;
|
return a;
|
||||||
},
|
},
|
||||||
|
|
||||||
FragmentDefinition: (a, b) => {
|
FragmentDefinition: (a: FragmentDefinitionNode, b: FragmentDefinitionNode) => {
|
||||||
if ( a.typeCondition && b.typeCondition ) {
|
if ( a.typeCondition && b.typeCondition ) {
|
||||||
if ( a.typeCondition.kind !== b.typeCondition.kind )
|
if ( a.typeCondition.kind !== b.typeCondition.kind )
|
||||||
return a;
|
return a;
|
||||||
|
@ -56,16 +59,16 @@ export const MERGE_METHODS = {
|
||||||
// TODO: directives
|
// TODO: directives
|
||||||
|
|
||||||
if ( a.selectionSet && b.selectionSet )
|
if ( a.selectionSet && b.selectionSet )
|
||||||
a.selectionSet = merge(a.selectionSet, b.selectionSet);
|
(a as any).selectionSet = merge(a.selectionSet, b.selectionSet);
|
||||||
else if ( b.selectionSet )
|
else if ( b.selectionSet )
|
||||||
a.selectionSet = b.selectionSet;
|
(a as any).selectionSet = b.selectionSet;
|
||||||
|
|
||||||
return a;
|
return a;
|
||||||
},
|
},
|
||||||
|
|
||||||
SelectionSet: (a, b) => {
|
SelectionSet: (a: SelectionSetNode, b: SelectionSetNode) => {
|
||||||
if ( a.selections && b.selections )
|
if ( a.selections && b.selections )
|
||||||
a.selections = mergeList(a.selections, b.selections);
|
a.selections = mergeList(a.selections as SelectionNode[], b.selections as any);
|
||||||
else if ( b.selections )
|
else if ( b.selections )
|
||||||
a.selections = b.selections;
|
a.selections = b.selections;
|
||||||
|
|
||||||
|
@ -73,10 +76,10 @@ export const MERGE_METHODS = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Type safe this
|
||||||
export function mergeList(a, b) {
|
export function mergeList(a: any[], b: any[]) {
|
||||||
let has_operation = false;
|
let has_operation = false;
|
||||||
const a_names = {};
|
const a_names: Record<string, any> = {};
|
||||||
for(const item of a) {
|
for(const item of a) {
|
||||||
if ( ! item || ! item.name || item.name.kind !== 'Name' )
|
if ( ! item || ! item.name || item.name.kind !== 'Name' )
|
||||||
continue;
|
continue;
|
||||||
|
@ -114,7 +117,7 @@ export function mergeList(a, b) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export default function merge(a, b) {
|
export default function merge(a: any, b: any) {
|
||||||
if ( a.kind !== b.kind )
|
if ( a.kind !== b.kind )
|
||||||
return a;
|
return a;
|
||||||
|
|
||||||
|
@ -122,4 +125,4 @@ export default function merge(a, b) {
|
||||||
return MERGE_METHODS[a.kind](a, b);
|
return MERGE_METHODS[a.kind](a, b);
|
||||||
|
|
||||||
return a;
|
return a;
|
||||||
}
|
}
|
|
@ -1,243 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
const RAVEN_LEVELS = {
|
|
||||||
1: 'debug',
|
|
||||||
2: 'info',
|
|
||||||
4: 'warn',
|
|
||||||
8: 'error'
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
function readLSLevel() {
|
|
||||||
const level = localStorage.ffzLogLevel;
|
|
||||||
if ( ! level )
|
|
||||||
return null;
|
|
||||||
|
|
||||||
const upper = level.toUpperCase();
|
|
||||||
if ( Logger.hasOwnProperty(upper) )
|
|
||||||
return Logger[upper];
|
|
||||||
|
|
||||||
if ( /^\d+$/.test(level) )
|
|
||||||
return parseInt(level, 10);
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export class Logger {
|
|
||||||
constructor(parent, name, level, raven) {
|
|
||||||
this.root = parent ? parent.root : this;
|
|
||||||
this.parent = parent;
|
|
||||||
this.name = name;
|
|
||||||
|
|
||||||
if ( this.root == this ) {
|
|
||||||
this.captured_init = [];
|
|
||||||
this.label = 'FFZ';
|
|
||||||
}
|
|
||||||
|
|
||||||
this.init = false;
|
|
||||||
this.enabled = true;
|
|
||||||
this.level = level ?? (parent && parent.level) ?? readLSLevel() ?? Logger.DEFAULT_LEVEL;
|
|
||||||
this.raven = raven || (parent && parent.raven);
|
|
||||||
|
|
||||||
this.children = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
hi(core) {
|
|
||||||
const VER = core.constructor.version_info;
|
|
||||||
this.info(`FrankerFaceZ v${VER} (s:${core.host} f:${core.flavor} b:${VER.build} c:${VER.commit || 'null'})`);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const loc = new URL(location);
|
|
||||||
loc.search = '';
|
|
||||||
this.info(`Initial URL: ${loc}`);
|
|
||||||
} catch(err) {
|
|
||||||
this.warn(`Unable to read location.`, err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get(name, level) {
|
|
||||||
if ( ! this.children[name] )
|
|
||||||
this.children[name] = new Logger(this, (this.name ? `${this.name}.${name}` : name), level);
|
|
||||||
|
|
||||||
return this.children[name];
|
|
||||||
}
|
|
||||||
|
|
||||||
verbose(...args) {
|
|
||||||
return this.invoke(Logger.VERBOSE, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
verboseColor(msg, colors, ...args) {
|
|
||||||
return this.invokeColor(Logger.VERBOSE, msg, colors, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
debug(...args) {
|
|
||||||
return this.invoke(Logger.DEBUG, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
debugColor(msg, colors, ...args) {
|
|
||||||
return this.invokeColor(Logger.DEBUG, msg, colors, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
info(...args) {
|
|
||||||
return this.invoke(Logger.INFO, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
infoColor(msg, colors, ...args) {
|
|
||||||
return this.invokeColor(Logger.INFO, msg, colors, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
warn(...args) {
|
|
||||||
return this.invoke(Logger.WARN, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
warnColor(msg, colors, ...args) {
|
|
||||||
return this.invokeColor(Logger.WARN, msg, colors, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
warning(...args) {
|
|
||||||
return this.invoke(Logger.WARN, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
warningColor(msg, colors, ...args) {
|
|
||||||
return this.invokeColor(Logger.WARN, msg, colors, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
error(...args) {
|
|
||||||
return this.invoke(Logger.ERROR, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
errorColor(msg, colors, ...args) {
|
|
||||||
return this.invokeColor(Logger.ERROR, msg, colors, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
crumb(...args) {
|
|
||||||
if ( this.raven )
|
|
||||||
return this.raven.captureBreadcrumb(...args);
|
|
||||||
}
|
|
||||||
|
|
||||||
capture(exc, opts, ...args) {
|
|
||||||
if ( this.raven ) {
|
|
||||||
opts = opts || {};
|
|
||||||
if ( ! opts.logger )
|
|
||||||
opts.logger = this.name;
|
|
||||||
|
|
||||||
this.raven.captureException(exc, opts);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( args.length )
|
|
||||||
return this.error(...args);
|
|
||||||
}
|
|
||||||
|
|
||||||
invokeColor(level, msg, colors, args) {
|
|
||||||
if ( ! this.enabled || level < this.level )
|
|
||||||
return;
|
|
||||||
|
|
||||||
if ( ! Array.isArray(colors) )
|
|
||||||
colors = [colors];
|
|
||||||
|
|
||||||
const message = args ? Array.prototype.slice.call(args) : [];
|
|
||||||
|
|
||||||
if ( level !== Logger.VERBOSE ) {
|
|
||||||
const out = msg.replace(/%c/g, '') + ' ' + message.join(' ');
|
|
||||||
|
|
||||||
if ( this.root.init )
|
|
||||||
this.root.captured_init.push({
|
|
||||||
time: Date.now(),
|
|
||||||
category: this.name,
|
|
||||||
message: out,
|
|
||||||
level: RAVEN_LEVELS[level] || level
|
|
||||||
});
|
|
||||||
|
|
||||||
this.crumb({
|
|
||||||
message: out,
|
|
||||||
category: this.name,
|
|
||||||
level: RAVEN_LEVELS[level] || level
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
message.unshift(msg);
|
|
||||||
|
|
||||||
if ( this.name ) {
|
|
||||||
message[0] = `%c${this.root.label} [%c${this.name}%c]:%c ${message[0]}`;
|
|
||||||
colors.unshift('color:#755000; font-weight:bold', '', 'color:#755000; font-weight:bold', '');
|
|
||||||
|
|
||||||
} else {
|
|
||||||
message[0] = `%c${this.root.label}:%c ${message[0]}`;
|
|
||||||
colors.unshift('color:#755000; font-weight:bold', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
message.splice(1, 0, ...colors);
|
|
||||||
|
|
||||||
if ( level === Logger.DEBUG || level === Logger.VERBOSE )
|
|
||||||
console.debug(...message);
|
|
||||||
|
|
||||||
else if ( level === Logger.INFO )
|
|
||||||
console.info(...message);
|
|
||||||
|
|
||||||
else if ( level === Logger.WARN )
|
|
||||||
console.warn(...message);
|
|
||||||
|
|
||||||
else if ( level === Logger.ERROR )
|
|
||||||
console.error(...message);
|
|
||||||
|
|
||||||
else
|
|
||||||
console.log(...message);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* eslint no-console: "off" */
|
|
||||||
invoke(level, args) {
|
|
||||||
if ( ! this.enabled || level < this.level )
|
|
||||||
return;
|
|
||||||
|
|
||||||
const message = Array.prototype.slice.call(args);
|
|
||||||
|
|
||||||
if ( level !== Logger.VERBOSE ) {
|
|
||||||
if ( this.root.init )
|
|
||||||
this.root.captured_init.push({
|
|
||||||
time: Date.now(),
|
|
||||||
category: this.name,
|
|
||||||
message: message.join(' '),
|
|
||||||
level: RAVEN_LEVELS[level] || level
|
|
||||||
});
|
|
||||||
|
|
||||||
this.crumb({
|
|
||||||
message: message.join(' '),
|
|
||||||
category: this.name,
|
|
||||||
level: RAVEN_LEVELS[level] || level
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( this.name )
|
|
||||||
message.unshift(`%c${this.root.label} [%c${this.name}%c]:%c`, 'color:#755000; font-weight:bold', '', 'color:#755000; font-weight:bold', '');
|
|
||||||
else
|
|
||||||
message.unshift(`%c${this.root.label}:%c`, 'color:#755000; font-weight:bold', '');
|
|
||||||
|
|
||||||
if ( level === Logger.DEBUG || level === Logger.VERBOSE )
|
|
||||||
console.debug(...message);
|
|
||||||
|
|
||||||
else if ( level === Logger.INFO )
|
|
||||||
console.info(...message);
|
|
||||||
|
|
||||||
else if ( level === Logger.WARN )
|
|
||||||
console.warn(...message);
|
|
||||||
|
|
||||||
else if ( level === Logger.ERROR )
|
|
||||||
console.error(...message);
|
|
||||||
|
|
||||||
else
|
|
||||||
console.log(...message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.VERBOSE = 0;
|
|
||||||
Logger.DEBUG = 1;
|
|
||||||
Logger.INFO = 2;
|
|
||||||
Logger.WARN = 4;
|
|
||||||
Logger.WARNING = 4;
|
|
||||||
Logger.ERROR = 8;
|
|
||||||
Logger.OFF = 99;
|
|
||||||
|
|
||||||
Logger.DEFAULT_LEVEL = Logger.INFO;
|
|
||||||
|
|
||||||
export default Logger;
|
|
320
src/utilities/logging.ts
Normal file
320
src/utilities/logging.ts
Normal file
|
@ -0,0 +1,320 @@
|
||||||
|
import type { ClientVersion } from "./types";
|
||||||
|
|
||||||
|
const RAVEN_LEVELS: Record<number, string> = {
|
||||||
|
1: 'debug',
|
||||||
|
2: 'info',
|
||||||
|
4: 'warn',
|
||||||
|
8: 'error'
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export enum LogLevel {
|
||||||
|
Verbose = 0,
|
||||||
|
Debug = 1,
|
||||||
|
Info = 2,
|
||||||
|
Warning = 4,
|
||||||
|
Error = 8,
|
||||||
|
Off = 99
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLSLevel(): number | null {
|
||||||
|
const level = localStorage.ffzLogLevel;
|
||||||
|
if ( ! level )
|
||||||
|
return null;
|
||||||
|
|
||||||
|
const upper = level.toUpperCase(),
|
||||||
|
value = (Logger as any)[upper];
|
||||||
|
|
||||||
|
if ( typeof value === 'number' )
|
||||||
|
return value;
|
||||||
|
|
||||||
|
if ( /^\d+$/.test(level) )
|
||||||
|
return parseInt(level, 10);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
interface Core {
|
||||||
|
|
||||||
|
host: string;
|
||||||
|
flavor: string;
|
||||||
|
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
type InitItem = {
|
||||||
|
time: number;
|
||||||
|
category: string | null;
|
||||||
|
message: string;
|
||||||
|
level: string | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export class Logger {
|
||||||
|
public static readonly Levels = LogLevel;
|
||||||
|
public static readonly VERBOSE = LogLevel.Verbose;
|
||||||
|
public static readonly DEBUG = LogLevel.Debug;
|
||||||
|
public static readonly INFO = LogLevel.Info;
|
||||||
|
public static readonly WARN = LogLevel.Warning;
|
||||||
|
public static readonly WARNING = LogLevel.Warning;
|
||||||
|
public static readonly ERROR = LogLevel.Error;
|
||||||
|
public static readonly OFF = LogLevel.Off;
|
||||||
|
|
||||||
|
public static readonly DEFAULT_LEVEL = LogLevel.Info;
|
||||||
|
|
||||||
|
name: string | null;
|
||||||
|
enabled: boolean;
|
||||||
|
level: LogLevel;
|
||||||
|
|
||||||
|
label?: string;
|
||||||
|
|
||||||
|
init?: boolean;
|
||||||
|
captured_init?: InitItem[];
|
||||||
|
|
||||||
|
root: Logger;
|
||||||
|
parent: Logger | null;
|
||||||
|
children: Record<string, Logger>;
|
||||||
|
|
||||||
|
raven: any;
|
||||||
|
|
||||||
|
constructor(parent: Logger | null, name: string | null, level?: LogLevel | null, raven?: any) {
|
||||||
|
this.root = parent ? parent.root : this;
|
||||||
|
this.parent = parent;
|
||||||
|
this.name = name;
|
||||||
|
|
||||||
|
if ( this.root == this ) {
|
||||||
|
this.init = false;
|
||||||
|
this.captured_init = [];
|
||||||
|
this.label = 'FFZ';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.enabled = true;
|
||||||
|
this.level = level ?? (parent && parent.level) ?? readLSLevel() ?? Logger.DEFAULT_LEVEL;
|
||||||
|
this.raven = raven || (parent && parent.raven);
|
||||||
|
|
||||||
|
this.children = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
hi(core: Core, version?: ClientVersion) {
|
||||||
|
const VER = version ?? (core.constructor as any)?.version_info;
|
||||||
|
this.info(`FrankerFaceZ v${VER} (s:${core.host} f:${core.flavor} b:${VER?.build} c:${VER?.commit || 'null'})`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const loc = new URL(location.toString());
|
||||||
|
loc.search = '';
|
||||||
|
this.info(`Initial URL: ${loc}`);
|
||||||
|
} catch(err) {
|
||||||
|
this.warn(`Unable to read location.`, err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get(name: string, level?: LogLevel) {
|
||||||
|
if ( ! this.children[name] )
|
||||||
|
this.children[name] = new Logger(this, (this.name ? `${this.name}.${name}` : name), level);
|
||||||
|
|
||||||
|
return this.children[name];
|
||||||
|
}
|
||||||
|
|
||||||
|
verbose(message: any, ...optionalParams: any[]) {
|
||||||
|
return this.invoke(LogLevel.Verbose, message, optionalParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
verboseColor(message: any, colors: string[], ...optionalParams: any[]) {
|
||||||
|
return this.invokeColor(Logger.VERBOSE, message, colors, optionalParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
debug(message: any, ...optionalParams: any[]) {
|
||||||
|
return this.invoke(Logger.DEBUG, message, optionalParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
debugColor(message: any, colors: string[], ...optionalParams: any[]) {
|
||||||
|
return this.invokeColor(Logger.DEBUG, message, colors, optionalParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
info(message: any, ...optionalParams: any[]) {
|
||||||
|
return this.invoke(Logger.INFO, message, optionalParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
infoColor(message: any, colors: string[], ...optionalParams: any[]) {
|
||||||
|
return this.invokeColor(Logger.INFO, message, colors, optionalParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
warn(message: any, ...optionalParams: any[]) {
|
||||||
|
return this.invoke(Logger.WARN, message, optionalParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
warnColor(message: any, colors: string[], ...optionalParams: any[]) {
|
||||||
|
return this.invokeColor(Logger.WARN, message, colors, optionalParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
warning(message: any, ...optionalParams: any[]) {
|
||||||
|
return this.invoke(Logger.WARN, message, optionalParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
warningColor(message: any, colors: string[], ...optionalParams: any[]) {
|
||||||
|
return this.invokeColor(Logger.WARN, message, colors, optionalParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
error(message: any, ...optionalParams: any[]) {
|
||||||
|
return this.invoke(Logger.ERROR, message, optionalParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
errorColor(message: any, colors: string[], ...optionalParams: any[]) {
|
||||||
|
return this.invokeColor(Logger.ERROR, message, colors, optionalParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
crumb(...args: any[]) {
|
||||||
|
if ( this.raven )
|
||||||
|
return this.raven.captureBreadcrumb(...args);
|
||||||
|
}
|
||||||
|
|
||||||
|
capture(exc: Error, opts?: any, ...args: any[]) {
|
||||||
|
if ( this.raven ) {
|
||||||
|
opts = opts || {};
|
||||||
|
if ( ! opts.logger )
|
||||||
|
opts.logger = this.name;
|
||||||
|
|
||||||
|
this.raven.captureException(exc, opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( args.length ) {
|
||||||
|
const msg = args.shift();
|
||||||
|
return this.error(msg, ...args);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
invokeColor(level: number, message: any, colors: string | string[], ...optionalParams: any[]) {
|
||||||
|
if ( ! this.enabled || level < this.level )
|
||||||
|
return;
|
||||||
|
|
||||||
|
if ( ! Array.isArray(colors) )
|
||||||
|
colors = [colors];
|
||||||
|
|
||||||
|
//const message = args ? Array.prototype.slice.call(args) : [];
|
||||||
|
|
||||||
|
if ( level > LogLevel.Verbose ) {
|
||||||
|
let out = message;
|
||||||
|
if ( typeof out === 'string' )
|
||||||
|
out = out.replace(/%c/g, '');
|
||||||
|
|
||||||
|
if ( optionalParams.length )
|
||||||
|
out = `${out} ${optionalParams.join(' ')}`;
|
||||||
|
|
||||||
|
if ( this.root.init && this.root.captured_init )
|
||||||
|
this.root.captured_init.push({
|
||||||
|
time: Date.now(),
|
||||||
|
category: this.name,
|
||||||
|
message: out,
|
||||||
|
level: RAVEN_LEVELS[level] || level
|
||||||
|
});
|
||||||
|
|
||||||
|
this.crumb({
|
||||||
|
message: out,
|
||||||
|
category: this.name,
|
||||||
|
level: RAVEN_LEVELS[level] || level
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const default_style = level < LogLevel.Info
|
||||||
|
? 'color:#999999'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if ( this.name ) {
|
||||||
|
if ( typeof message === 'string' )
|
||||||
|
message = `%c${this.root.label} [%c${this.name}%c]:%c ${message}`;
|
||||||
|
else {
|
||||||
|
optionalParams.unshift(message);
|
||||||
|
message = `%c${this.root.label} [%c${this.name}%c]:%c`;
|
||||||
|
}
|
||||||
|
|
||||||
|
colors.unshift('color:#755000; font-weight:bold', default_style, 'color:#755000; font-weight:bold', default_style);
|
||||||
|
|
||||||
|
} else {
|
||||||
|
if ( typeof message === 'string' )
|
||||||
|
message = `%c${this.root.label}:%c ${message}`;
|
||||||
|
else {
|
||||||
|
optionalParams.unshift(message);
|
||||||
|
message = `%c${this.root.label}:%c`;
|
||||||
|
}
|
||||||
|
|
||||||
|
colors.unshift('color:#755000; font-weight:bold', default_style);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( level < LogLevel.Info )
|
||||||
|
console.debug(message, ...colors, ...optionalParams);
|
||||||
|
|
||||||
|
else if ( level < LogLevel.Warning )
|
||||||
|
console.info(message, ...colors, ...optionalParams);
|
||||||
|
|
||||||
|
else if ( level < LogLevel.Error )
|
||||||
|
console.warn(message, ...colors, ...optionalParams);
|
||||||
|
|
||||||
|
else if ( level < LogLevel.Off )
|
||||||
|
console.error(message, ...colors, ...optionalParams);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* eslint no-console: "off" */
|
||||||
|
invoke(level: number, message: string, optionalParams?: any[]) {
|
||||||
|
if ( ! this.enabled || level < this.level || level >= LogLevel.Off )
|
||||||
|
return;
|
||||||
|
|
||||||
|
const result = optionalParams ? [
|
||||||
|
message,
|
||||||
|
...optionalParams
|
||||||
|
] : [message];
|
||||||
|
|
||||||
|
if ( level > LogLevel.Verbose ) {
|
||||||
|
const out = result.join(' ');
|
||||||
|
|
||||||
|
if ( this.root.init && this.root.captured_init )
|
||||||
|
this.root.captured_init.push({
|
||||||
|
time: Date.now(),
|
||||||
|
category: this.name,
|
||||||
|
message: out,
|
||||||
|
level: RAVEN_LEVELS[level] || level
|
||||||
|
});
|
||||||
|
|
||||||
|
this.crumb({
|
||||||
|
message: out,
|
||||||
|
category: this.name,
|
||||||
|
level: RAVEN_LEVELS[level] || level
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chrome removed any sort of special styling from debug
|
||||||
|
// logging, so let's add our own to make them visually distinct.
|
||||||
|
const default_style = level < LogLevel.Info
|
||||||
|
? 'color:#999999'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// If we're adding our own style, we need to grab as many of
|
||||||
|
// the strings as we can.
|
||||||
|
let strings = '';
|
||||||
|
if ( default_style !== '' ) {
|
||||||
|
while(result.length > 0 && typeof result[0] === 'string') {
|
||||||
|
strings += ' ' + result.shift();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ( this.name ) {
|
||||||
|
result.unshift(`%c${this.root.label} [%c${this.name}%c]:%c${strings}`, 'color:#755000; font-weight:bold', default_style, 'color:#755000; font-weight:bold', default_style);
|
||||||
|
} else
|
||||||
|
result.unshift(`%c${this.root.label}:%c${strings}`, 'color:#755000; font-weight:bold', default_style);
|
||||||
|
|
||||||
|
if ( level < LogLevel.Info )
|
||||||
|
console.debug(...result);
|
||||||
|
|
||||||
|
else if ( level < LogLevel.Warning )
|
||||||
|
console.info(...result);
|
||||||
|
|
||||||
|
else if ( level < LogLevel.Error )
|
||||||
|
console.warn(...result);
|
||||||
|
|
||||||
|
else if ( level < LogLevel.Off )
|
||||||
|
console.error(...result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Logger;
|
|
@ -1,873 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Module System
|
|
||||||
// Modules are cool.
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import EventEmitter from 'utilities/events';
|
|
||||||
import {has} from 'utilities/object';
|
|
||||||
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Module
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export const State = {
|
|
||||||
UNLOADED: 0,
|
|
||||||
LOADING: 1,
|
|
||||||
LOADED: 2,
|
|
||||||
UNLOADING: 3,
|
|
||||||
|
|
||||||
DISABLED: 0,
|
|
||||||
ENABLING: 1,
|
|
||||||
ENABLED: 2,
|
|
||||||
DISABLING: 3
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export class Module extends EventEmitter {
|
|
||||||
constructor(name, parent) {
|
|
||||||
if ( ! parent && name instanceof Module ) {
|
|
||||||
parent = name;
|
|
||||||
name = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
super(name, parent);
|
|
||||||
this.__modules = parent ? parent.__modules : {};
|
|
||||||
this.children = {};
|
|
||||||
|
|
||||||
if ( parent?.addon_id ) {
|
|
||||||
this.addon_id = parent.addon_id;
|
|
||||||
this.addon_root = parent.addon_root;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( parent && ! parent.children[this.name] )
|
|
||||||
parent.children[this.name] = this;
|
|
||||||
|
|
||||||
if ( this.root === this )
|
|
||||||
this.__modules[this.__path || ''] = this;
|
|
||||||
|
|
||||||
this.__load_state = this.onLoad ? State.UNLOADED : State.LOADED;
|
|
||||||
this.__state = this.onLoad || this.onEnable ?
|
|
||||||
State.DISABLED : State.ENABLED;
|
|
||||||
|
|
||||||
this.__time('instance');
|
|
||||||
this.emit(':registered');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// Public Properties
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
get state() { return this.__state }
|
|
||||||
get load_state() { return this.__load_state }
|
|
||||||
|
|
||||||
get loaded() { return this.__load_state === State.LOADED }
|
|
||||||
get loading() { return this.__load_state === State.LOADING }
|
|
||||||
|
|
||||||
get enabled() { return this.__state === State.ENABLED }
|
|
||||||
get enabling() { return this.__state === State.ENABLING }
|
|
||||||
|
|
||||||
|
|
||||||
get log() {
|
|
||||||
if ( ! this.__log )
|
|
||||||
this.__log = this.parent && this.parent.log.get(this.name);
|
|
||||||
return this.__log
|
|
||||||
}
|
|
||||||
|
|
||||||
set log(log) {
|
|
||||||
this.__log = log;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// Timing
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
__time(event) {
|
|
||||||
if ( this.root.timing ) {
|
|
||||||
if ( typeof event !== 'object' )
|
|
||||||
event = {event};
|
|
||||||
event.module = this.__path || 'core';
|
|
||||||
this.root.timing.addEvent(event);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// State! Glorious State
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
load(...args) {
|
|
||||||
return this.__load(args, this.__path, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
unload(...args) {
|
|
||||||
return this.__unload(args, this.__path, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
enable(...args) {
|
|
||||||
return this.__enable(args, this.__path, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
disable(...args) {
|
|
||||||
return this.__disable(args, this.__path, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
canUnload() {
|
|
||||||
return this.__canUnload(this.__path, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
canDisable() {
|
|
||||||
return this.__canDisable(this.__path, []);
|
|
||||||
}
|
|
||||||
|
|
||||||
__load(args, initial, chain) {
|
|
||||||
const path = this.__path || this.name,
|
|
||||||
state = this.__load_state;
|
|
||||||
|
|
||||||
if ( chain.includes(this) )
|
|
||||||
return Promise.reject(new CyclicDependencyError(`cyclic load requirements when loading ${initial}`, [...chain, this]));
|
|
||||||
else if ( this.load_requires )
|
|
||||||
for(const name of this.load_requires) {
|
|
||||||
const module = this.__resolve(name);
|
|
||||||
if ( module && chain.includes(module) )
|
|
||||||
return Promise.reject(new CyclicDependencyError(`cyclic load requirements when loading ${initial}`, [...chain, this, module]));
|
|
||||||
}
|
|
||||||
|
|
||||||
chain.push(this);
|
|
||||||
|
|
||||||
if ( state === State.LOADING )
|
|
||||||
return this.__load_promise;
|
|
||||||
|
|
||||||
else if ( state === State.LOADED )
|
|
||||||
return Promise.resolve();
|
|
||||||
|
|
||||||
else if ( state === State.UNLOADING )
|
|
||||||
return Promise.reject(new ModuleError(`attempted to load module ${path} while module is being unloaded`));
|
|
||||||
|
|
||||||
this.__time('load-start');
|
|
||||||
this.__load_state = State.LOADING;
|
|
||||||
return this.__load_promise = (async () => {
|
|
||||||
if ( this.load_requires ) {
|
|
||||||
const promises = [];
|
|
||||||
for(const name of this.load_requires) {
|
|
||||||
const module = this.__resolve(name);
|
|
||||||
if ( ! module || !(module instanceof Module) )
|
|
||||||
throw new ModuleError(`cannot find required module ${name} when loading ${path}`);
|
|
||||||
|
|
||||||
promises.push(module.__enable([], initial, Array.from(chain)));
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(promises);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( this.onLoad ) {
|
|
||||||
this.__time('load-self');
|
|
||||||
return this.onLoad(...args);
|
|
||||||
}
|
|
||||||
|
|
||||||
})().then(ret => {
|
|
||||||
this.__load_state = State.LOADED;
|
|
||||||
this.__load_promise = null;
|
|
||||||
this.__time('load-end');
|
|
||||||
this.emit(':loaded', this);
|
|
||||||
return ret;
|
|
||||||
}).catch(err => {
|
|
||||||
this.__load_state = State.UNLOADED;
|
|
||||||
this.__load_promise = null;
|
|
||||||
this.__time('load-end');
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
__canUnload(initial, chain) {
|
|
||||||
const path = this.__path || this.name,
|
|
||||||
state = this.__load_state;
|
|
||||||
|
|
||||||
if ( chain.includes(this) )
|
|
||||||
throw new CyclicDependencyError(`cyclic load requirements when checking if can unload ${initial}`, [...chain, this]);
|
|
||||||
else if ( this.load_dependents ) {
|
|
||||||
chain.push(this);
|
|
||||||
|
|
||||||
for(const dep of this.load_dependents) {
|
|
||||||
const module = this.__resolve(dep);
|
|
||||||
if ( module ) {
|
|
||||||
if ( chain.includes(module) )
|
|
||||||
throw new CyclicDependencyError(`cyclic load requirements when checking if can unload ${initial}`, [...chain, this, module]);
|
|
||||||
|
|
||||||
if ( ! module.__canUnload(initial, Array.from(chain)) )
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( state === State.UNLOADING )
|
|
||||||
return true;
|
|
||||||
|
|
||||||
else if ( state === State.UNLOADED )
|
|
||||||
return true;
|
|
||||||
|
|
||||||
else if ( this.onLoad && ! this.onUnload )
|
|
||||||
return false;
|
|
||||||
|
|
||||||
else if ( state === State.LOADING )
|
|
||||||
return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
__unload(args, initial, chain) {
|
|
||||||
const path = this.__path || this.name,
|
|
||||||
state = this.__load_state;
|
|
||||||
|
|
||||||
if ( chain.includes(this) )
|
|
||||||
return Promise.reject(new CyclicDependencyError(`cyclic load requirements when unloading ${initial}`, [...chain, this]));
|
|
||||||
else if ( this.load_dependents )
|
|
||||||
for(const dep of this.load_dependents) {
|
|
||||||
const module = this.__resolve(dep);
|
|
||||||
if ( module && chain.includes(module) )
|
|
||||||
return Promise.reject(new CyclicDependencyError(`cyclic load requirements when unloading ${initial}`, [...chain, this, module]));
|
|
||||||
}
|
|
||||||
|
|
||||||
chain.push(this);
|
|
||||||
|
|
||||||
if ( state === State.UNLOADING )
|
|
||||||
return this.__load_promise;
|
|
||||||
|
|
||||||
else if ( state === State.UNLOADED )
|
|
||||||
return Promise.resolve();
|
|
||||||
|
|
||||||
else if ( this.onLoad && ! this.onUnload )
|
|
||||||
return Promise.reject(new ModuleError(`attempted to unload module ${path} but module cannot be unloaded`));
|
|
||||||
|
|
||||||
else if ( state === State.LOADING )
|
|
||||||
return Promise.reject(new ModuleError(`attempted to unload module ${path} while module is being loaded`));
|
|
||||||
|
|
||||||
this.__time('unload-start');
|
|
||||||
this.__load_state = State.UNLOADING;
|
|
||||||
return this.__load_promise = (async () => {
|
|
||||||
if ( this.__state !== State.DISABLED )
|
|
||||||
await this.disable();
|
|
||||||
|
|
||||||
if ( this.load_dependents ) {
|
|
||||||
const promises = [];
|
|
||||||
for(const name of this.load_dependents) {
|
|
||||||
const module = this.__resolve(name);
|
|
||||||
if ( ! module || !(module instanceof Module) )
|
|
||||||
//throw new ModuleError(`cannot find depending module ${name} when unloading ${path}`);
|
|
||||||
continue;
|
|
||||||
|
|
||||||
promises.push(module.__unload([], initial, Array.from(chain)));
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(promises);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.__time('unload-self');
|
|
||||||
if ( this.onUnload )
|
|
||||||
return this.onUnload(...args);
|
|
||||||
return null;
|
|
||||||
|
|
||||||
})().then(ret => {
|
|
||||||
this.__load_state = State.UNLOADED;
|
|
||||||
this.__load_promise = null;
|
|
||||||
this.__time('unload-end');
|
|
||||||
this.emit(':unloaded', this);
|
|
||||||
return ret;
|
|
||||||
}).catch(err => {
|
|
||||||
this.__load_state = State.LOADED;
|
|
||||||
this.__load_promise = null;
|
|
||||||
this.__time('unload-end');
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
__enable(args, initial, chain) {
|
|
||||||
const path = this.__path || this.name,
|
|
||||||
state = this.__state;
|
|
||||||
|
|
||||||
if ( chain.includes(this) )
|
|
||||||
return Promise.reject(new CyclicDependencyError(`cyclic requirements when enabling ${initial}`, [...chain, this]));
|
|
||||||
else if ( this.requires )
|
|
||||||
for(const name of this.requires) {
|
|
||||||
const module = this.__resolve(name);
|
|
||||||
if ( module && chain.includes(module) )
|
|
||||||
return Promise.reject(new CyclicDependencyError(`cyclic requirements when enabling ${initial}`, [...chain, this, module]));
|
|
||||||
}
|
|
||||||
|
|
||||||
chain.push(this);
|
|
||||||
|
|
||||||
if ( state === State.ENABLING )
|
|
||||||
return this.__state_promise;
|
|
||||||
|
|
||||||
else if ( state === State.ENABLED )
|
|
||||||
return Promise.resolve();
|
|
||||||
|
|
||||||
else if ( state === State.DISABLING )
|
|
||||||
return Promise.reject(new ModuleError(`attempted to enable module ${path} while module is being disabled`));
|
|
||||||
|
|
||||||
this.__time('enable-start');
|
|
||||||
this.__state = State.ENABLING;
|
|
||||||
return this.__state_promise = (async () => {
|
|
||||||
const promises = [],
|
|
||||||
requires = this.requires,
|
|
||||||
load_state = this.__load_state;
|
|
||||||
|
|
||||||
if ( load_state === State.UNLOADING )
|
|
||||||
// We'd abort for this later to, but kill it now before we start
|
|
||||||
// any unnecessary work.
|
|
||||||
throw new ModuleError(`attempted to load module ${path} while module is being unloaded`);
|
|
||||||
|
|
||||||
else if ( load_state === State.LOADING || load_state === State.UNLOADED )
|
|
||||||
promises.push(this.load());
|
|
||||||
|
|
||||||
if ( requires )
|
|
||||||
for(const name of requires) {
|
|
||||||
const module = this.__resolve(name);
|
|
||||||
if ( ! module || !(module instanceof Module) )
|
|
||||||
throw new ModuleError(`cannot find required module ${name} when enabling ${path}`);
|
|
||||||
|
|
||||||
promises.push(module.__enable([], initial, Array.from(chain)));
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(promises);
|
|
||||||
if ( this.onEnable ) {
|
|
||||||
this.__time('enable-self');
|
|
||||||
return this.onEnable(...args);
|
|
||||||
}
|
|
||||||
|
|
||||||
})().then(ret => {
|
|
||||||
this.__state = State.ENABLED;
|
|
||||||
this.__state_promise = null;
|
|
||||||
this.__time('enable-end');
|
|
||||||
this.emit(':enabled', this);
|
|
||||||
return ret;
|
|
||||||
|
|
||||||
}).catch(err => {
|
|
||||||
this.__state = State.DISABLED;
|
|
||||||
this.__state_promise = null;
|
|
||||||
this.__time('enable-end');
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
__canDisable(initial, chain) {
|
|
||||||
const path = this.__path || this.name,
|
|
||||||
state = this.__state;
|
|
||||||
|
|
||||||
if ( chain.includes(this) )
|
|
||||||
throw new CyclicDependencyError(`cyclic load requirements when checking if can disable ${initial}`, [...chain, this]);
|
|
||||||
else if ( this.dependents ) {
|
|
||||||
chain.push(this);
|
|
||||||
|
|
||||||
for(const dep of this.dependents) {
|
|
||||||
const module = this.__resolve(dep);
|
|
||||||
if ( module && (module instanceof Module) ) {
|
|
||||||
if ( chain.includes(module) )
|
|
||||||
throw new CyclicDependencyError(`cyclic load requirements when checking if can disable ${initial}`, [...chain, this, module]);
|
|
||||||
|
|
||||||
if ( ! module.__canDisable(initial, Array.from(chain)) )
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( state === State.DISABLING || state === State.DISABLED )
|
|
||||||
return true;
|
|
||||||
|
|
||||||
else if ( ! this.onDisable )
|
|
||||||
return false;
|
|
||||||
|
|
||||||
else if ( state === State.ENABLING )
|
|
||||||
return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
__disable(args, initial, chain) {
|
|
||||||
const path = this.__path || this.name,
|
|
||||||
state = this.__state;
|
|
||||||
|
|
||||||
if ( chain.includes(this) )
|
|
||||||
return Promise.reject(new CyclicDependencyError(`cyclic requirements when disabling ${initial}`, [...chain, this]));
|
|
||||||
else if ( this.dependents )
|
|
||||||
for(const dep of this.dependents) {
|
|
||||||
const module = this.__resolve(dep);
|
|
||||||
if ( module && chain.includes(module) )
|
|
||||||
return Promise.reject(new CyclicDependencyError(`cyclic requirements when disabling ${initial}`, [...chain, this, dep]));
|
|
||||||
}
|
|
||||||
|
|
||||||
chain.push(this);
|
|
||||||
|
|
||||||
if ( state === State.DISABLING )
|
|
||||||
return this.__state_promise;
|
|
||||||
|
|
||||||
else if ( state === State.DISABLED )
|
|
||||||
return Promise.resolve();
|
|
||||||
|
|
||||||
else if ( ! this.onDisable )
|
|
||||||
return Promise.reject(new ModuleError(`attempted to disable module ${path} but module cannot be disabled`));
|
|
||||||
|
|
||||||
else if ( state === State.ENABLING )
|
|
||||||
return Promise.reject(new ModuleError(`attempted to disable module ${path} but module is being enabled`));
|
|
||||||
|
|
||||||
this.__time('disable-start');
|
|
||||||
this.__state = State.DISABLING;
|
|
||||||
return this.__state_promise = (async () => {
|
|
||||||
if ( this.__load_state !== State.LOADED )
|
|
||||||
// We'd abort for this later to, but kill it now before we start
|
|
||||||
// any unnecessary work.
|
|
||||||
throw new ModuleError(`attempted to disable module ${path} but module is unloaded -- weird state`);
|
|
||||||
|
|
||||||
if ( this.dependents ) {
|
|
||||||
const promises = [];
|
|
||||||
for(const name of this.dependents) {
|
|
||||||
const module = this.__resolve(name);
|
|
||||||
if ( ! module || !(module instanceof Module) )
|
|
||||||
// Assume a non-existent module isn't enabled.
|
|
||||||
//throw new ModuleError(`cannot find depending module ${name} when disabling ${path}`);
|
|
||||||
continue;
|
|
||||||
|
|
||||||
promises.push(module.__disable([], initial, Array.from(chain)));
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(promises);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.__time('disable-self');
|
|
||||||
return this.onDisable(...args);
|
|
||||||
|
|
||||||
})().then(ret => {
|
|
||||||
this.__state = State.DISABLED;
|
|
||||||
this.__state_promise = null;
|
|
||||||
this.__time('disable-end');
|
|
||||||
this.emit(':disabled', this);
|
|
||||||
return ret;
|
|
||||||
|
|
||||||
}).catch(err => {
|
|
||||||
this.__state = State.ENABLED;
|
|
||||||
this.__state_promise = null;
|
|
||||||
this.__time('disable-end');
|
|
||||||
throw err;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// Slightly Easier Events
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
on(event, fn, ctx) {
|
|
||||||
return super.on(event, fn, ctx === undefined ? this : ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
prependOn(event, fn, ctx) {
|
|
||||||
return super.prependOn(event, fn, ctx === undefined ? this : ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
many(event, ttl, fn, ctx) {
|
|
||||||
return super.many(event, ttl, fn, ctx === undefined ? this : ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
prependMany(event, ttl, fn, ctx) {
|
|
||||||
return super.prependMany(event, ttl, fn, ctx === undefined ? this : ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
once(event, fn, ctx) {
|
|
||||||
return super.once(event, fn, ctx === undefined ? this : ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
prependOnce(event, fn, ctx) {
|
|
||||||
return super.prependOnce(event, fn, ctx === undefined ? this : ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
off(event, fn, ctx) {
|
|
||||||
return super.off(event, fn, ctx === undefined ? this : ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// Child Control
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
loadModules(...names) {
|
|
||||||
return Promise.all(names.map(n => this.__resolve(n).load()))
|
|
||||||
}
|
|
||||||
|
|
||||||
unloadModules(...names) {
|
|
||||||
return Promise.all(names.map(n => this.__resolve(n).unload()))
|
|
||||||
}
|
|
||||||
|
|
||||||
enableModules(...names) {
|
|
||||||
return Promise.all(names.map(n => this.__resolve(n).enable()))
|
|
||||||
}
|
|
||||||
|
|
||||||
disableModules(...names) {
|
|
||||||
return Promise.all(names.map(n => this.__resolve(n).disable()))
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// ========================================================================
|
|
||||||
// Module Management
|
|
||||||
// ========================================================================
|
|
||||||
|
|
||||||
__resolve(name) {
|
|
||||||
if ( name instanceof Module )
|
|
||||||
return name;
|
|
||||||
|
|
||||||
return this.__modules[this.abs_path(name)];
|
|
||||||
}
|
|
||||||
|
|
||||||
resolve(name) {
|
|
||||||
let module = this.__resolve(name);
|
|
||||||
if ( !(module instanceof Module) )
|
|
||||||
return null;
|
|
||||||
|
|
||||||
if ( this.__processModule )
|
|
||||||
module = this.__processModule(module);
|
|
||||||
|
|
||||||
return module;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
hasModule(name) {
|
|
||||||
const module = this.__modules[this.abs_path(name)];
|
|
||||||
return module instanceof Module;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
__get_requires() {
|
|
||||||
if ( has(this, 'requires') )
|
|
||||||
return this.requires;
|
|
||||||
if ( has(this.constructor, 'requires') )
|
|
||||||
return this.constructor.requires;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
__get_load_requires() {
|
|
||||||
if ( has(this, 'load_requires') )
|
|
||||||
return this.load_requires;
|
|
||||||
if ( has(this.constructor, 'load_requires') )
|
|
||||||
return this.constructor.load_requires;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
__processModule(module) {
|
|
||||||
if ( this.addon_root && module.getAddonProxy ) {
|
|
||||||
const addon_id = this.addon_id;
|
|
||||||
if ( ! module.__proxies )
|
|
||||||
module.__proxies = {};
|
|
||||||
|
|
||||||
if ( module.__proxies[addon_id] )
|
|
||||||
return module.__proxies[addon_id];
|
|
||||||
|
|
||||||
const addon = this.__resolve('addons')?.getAddon?.(addon_id),
|
|
||||||
out = module.getAddonProxy(addon_id, addon, this.addon_root, this);
|
|
||||||
|
|
||||||
if ( out !== module )
|
|
||||||
module.__proxies[addon_id] = out;
|
|
||||||
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
return module;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
inject(name, module, require = true) {
|
|
||||||
if ( name instanceof Module || name.prototype instanceof Module ) {
|
|
||||||
require = module != null ? module : true;
|
|
||||||
module = name;
|
|
||||||
name = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const requires = this.requires = this.__get_requires() || [];
|
|
||||||
|
|
||||||
if ( module instanceof Module ) {
|
|
||||||
// Existing Instance
|
|
||||||
if ( ! name )
|
|
||||||
name = module.constructor.name.toSnakeCase();
|
|
||||||
|
|
||||||
} else if ( module && module.prototype instanceof Module ) {
|
|
||||||
// New Instance
|
|
||||||
if ( ! name )
|
|
||||||
name = module.name.toSnakeCase();
|
|
||||||
|
|
||||||
module = this.register(name, module);
|
|
||||||
|
|
||||||
} else if ( name ) {
|
|
||||||
// Just a Name
|
|
||||||
const full_name = name;
|
|
||||||
name = name.replace(/^(?:[^.]*\.)+/, '');
|
|
||||||
module = this.__resolve(full_name);
|
|
||||||
|
|
||||||
// Allow injecting a module that doesn't exist yet?
|
|
||||||
|
|
||||||
if ( ! module || !(module instanceof Module) ) {
|
|
||||||
if ( module )
|
|
||||||
module[2].push([this.__path, name]);
|
|
||||||
else
|
|
||||||
this.__modules[this.abs_path(full_name)] = [[], [], [[this.__path, name]]]
|
|
||||||
|
|
||||||
requires.push(this.abs_path(full_name));
|
|
||||||
|
|
||||||
return this[name] = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
} else
|
|
||||||
throw new TypeError(`must provide a valid module name or class`);
|
|
||||||
|
|
||||||
if ( ! module )
|
|
||||||
throw new Error(`cannot find module ${name} or no module provided`);
|
|
||||||
|
|
||||||
if ( require )
|
|
||||||
requires.push(module.abs_path('.'));
|
|
||||||
|
|
||||||
if ( this.enabled && ! module.enabled )
|
|
||||||
module.enable();
|
|
||||||
|
|
||||||
module.references.push([this.__path, name]);
|
|
||||||
|
|
||||||
if ( (module instanceof Module) && this.__processModule )
|
|
||||||
module = this.__processModule(module);
|
|
||||||
|
|
||||||
return this[name] = module;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
injectAs(variable, name, module, require = true) {
|
|
||||||
if ( name instanceof Module || name.prototype instanceof Module ) {
|
|
||||||
require = module != null ? module : true;
|
|
||||||
module = name;
|
|
||||||
name = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const requires = this.requires = this.__get_requires() || [];
|
|
||||||
|
|
||||||
if ( module instanceof Module ) {
|
|
||||||
// Existing Instance
|
|
||||||
if ( ! name )
|
|
||||||
name = module.constructor.name.toSnakeCase();
|
|
||||||
|
|
||||||
} else if ( module && module.prototype instanceof Module ) {
|
|
||||||
// New Instance
|
|
||||||
if ( ! name )
|
|
||||||
name = module.name.toSnakeCase();
|
|
||||||
|
|
||||||
module = this.register(name, module);
|
|
||||||
|
|
||||||
} else if ( name ) {
|
|
||||||
// Just a Name
|
|
||||||
const full_name = name;
|
|
||||||
name = name.replace(/^(?:[^.]*\.)+/, '');
|
|
||||||
module = this.__resolve(full_name);
|
|
||||||
|
|
||||||
// Allow injecting a module that doesn't exist yet?
|
|
||||||
|
|
||||||
if ( ! module || !(module instanceof Module) ) {
|
|
||||||
if ( module )
|
|
||||||
module[2].push([this.__path, variable]);
|
|
||||||
else
|
|
||||||
this.__modules[this.abs_path(full_name)] = [[], [], [[this.__path, variable]]]
|
|
||||||
|
|
||||||
requires.push(this.abs_path(full_name));
|
|
||||||
|
|
||||||
return this[variable] = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
} else
|
|
||||||
throw new TypeError(`must provide a valid module name or class`);
|
|
||||||
|
|
||||||
if ( ! module )
|
|
||||||
throw new Error(`cannot find module ${name} or no module provided`);
|
|
||||||
|
|
||||||
if ( require )
|
|
||||||
requires.push(module.abs_path('.'));
|
|
||||||
|
|
||||||
|
|
||||||
if ( this.enabled && ! module.enabled )
|
|
||||||
module.enable();
|
|
||||||
|
|
||||||
module.references.push([this.__path, variable]);
|
|
||||||
|
|
||||||
if ( (module instanceof Module) && this.__processModule )
|
|
||||||
module = this.__processModule(module, name);
|
|
||||||
|
|
||||||
return this[variable] = module;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
register(name, module, inject_reference) {
|
|
||||||
if ( name.prototype instanceof Module ) {
|
|
||||||
inject_reference = module;
|
|
||||||
module = name;
|
|
||||||
name = module.name.toSnakeCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
const path = this.abs_path(`.${name}`),
|
|
||||||
proto = module.prototype,
|
|
||||||
old_val = this.__modules[path];
|
|
||||||
|
|
||||||
if ( !(proto instanceof Module) )
|
|
||||||
throw new TypeError(`Module ${name} is not subclass of Module.`);
|
|
||||||
|
|
||||||
if ( old_val instanceof Module )
|
|
||||||
throw new ModuleError(`Name Collision for Module ${path}`);
|
|
||||||
|
|
||||||
const dependents = old_val || [[], [], []];
|
|
||||||
let inst = this.__modules[path] = new module(name, this);
|
|
||||||
const requires = inst.requires = inst.__get_requires() || [],
|
|
||||||
load_requires = inst.load_requires = inst.__get_load_requires() || [];
|
|
||||||
|
|
||||||
inst.dependents = dependents[0];
|
|
||||||
inst.load_dependents = dependents[1];
|
|
||||||
inst.references = dependents[2];
|
|
||||||
|
|
||||||
if ( inst instanceof SiteModule && ! requires.includes('site') )
|
|
||||||
requires.push('site');
|
|
||||||
|
|
||||||
for(const req_name of requires) {
|
|
||||||
const req_path = inst.abs_path(req_name),
|
|
||||||
req_mod = this.__modules[req_path];
|
|
||||||
|
|
||||||
if ( ! req_mod )
|
|
||||||
this.__modules[req_path] = [[path],[],[]];
|
|
||||||
else if ( Array.isArray(req_mod) )
|
|
||||||
req_mod[0].push(path);
|
|
||||||
else
|
|
||||||
req_mod.dependents.push(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
for(const req_name of load_requires) {
|
|
||||||
const req_path = inst.abs_path(req_name),
|
|
||||||
req_mod = this.__modules[req_path];
|
|
||||||
|
|
||||||
if ( ! req_mod )
|
|
||||||
this.__modules[req_path] = [[], [path], []];
|
|
||||||
else if ( Array.isArray(req_mod) )
|
|
||||||
req_mod[1].push(path);
|
|
||||||
else
|
|
||||||
req_mod.load_dependents.push(path);
|
|
||||||
}
|
|
||||||
|
|
||||||
for(const [in_path, in_name] of dependents[2]) {
|
|
||||||
const in_mod = this.__resolve(in_path);
|
|
||||||
if ( in_mod )
|
|
||||||
in_mod[in_name] = inst;
|
|
||||||
else
|
|
||||||
this.log.warn(`Unable to find module "${in_path}" that wanted "${in_name}".`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( (inst instanceof Module) && this.__processModule )
|
|
||||||
inst = this.__processModule(inst, name);
|
|
||||||
|
|
||||||
if ( inject_reference )
|
|
||||||
this[name] = inst;
|
|
||||||
|
|
||||||
return inst;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async populate(ctx, log) {
|
|
||||||
log = log || this.log;
|
|
||||||
const added = {};
|
|
||||||
for(const raw_path of ctx.keys()) {
|
|
||||||
const raw_module = await ctx(raw_path), // eslint-disable-line no-await-in-loop
|
|
||||||
module = raw_module.module || raw_module.default,
|
|
||||||
lix = raw_path.lastIndexOf('.'),
|
|
||||||
trimmed = lix > 2 ? raw_path.slice(2, lix) : raw_path,
|
|
||||||
name = trimmed.endsWith('/index') ? trimmed.slice(0, -6) : trimmed;
|
|
||||||
|
|
||||||
try {
|
|
||||||
added[name] = this.register(name, module);
|
|
||||||
} catch(err) {
|
|
||||||
log && log.capture(err, {
|
|
||||||
extra: {
|
|
||||||
module: name,
|
|
||||||
path: raw_path
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
log && log.warn(err, `Skipping ${raw_path}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return added;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Module.State = State;
|
|
||||||
Module.prototype.State = State;
|
|
||||||
|
|
||||||
|
|
||||||
export class SiteModule extends Module {
|
|
||||||
constructor(name, parent) {
|
|
||||||
super(name, parent);
|
|
||||||
this.site = this.resolve('site');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default Module;
|
|
||||||
|
|
||||||
|
|
||||||
export function buildAddonProxy(accessor, thing, name, overrides, access_warnings, no_proxy = false) {
|
|
||||||
|
|
||||||
const handler = {
|
|
||||||
get(obj, prop) {
|
|
||||||
// First, handle basic overrides behavior.
|
|
||||||
let value = overrides[prop];
|
|
||||||
if ( value !== undefined ) {
|
|
||||||
// Check for functions, and bind their this.
|
|
||||||
if ( typeof value === 'function' )
|
|
||||||
return value.bind(obj);
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Next, handle access warnings.
|
|
||||||
const warning = access_warnings && access_warnings[prop];
|
|
||||||
if ( accessor?.log && warning )
|
|
||||||
accessor.log.warn(`[DEV-CHECK] Accessed ${name}.${prop} directly. ${typeof warning === 'string' ? warning : ''}`)
|
|
||||||
|
|
||||||
// Check for functions, and bind their this.
|
|
||||||
value = obj[prop];
|
|
||||||
if ( typeof value === 'function' )
|
|
||||||
return value.bind(obj);
|
|
||||||
|
|
||||||
// Make sure all module access is proxied.
|
|
||||||
if ( accessor && (value instanceof Module) )
|
|
||||||
return accessor.resolve(value);
|
|
||||||
|
|
||||||
// Return whatever it would be normally.
|
|
||||||
return Reflect.get(...arguments);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return no_proxy ? handler : new Proxy(thing, handler);
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
Module.buildAddonProxy = buildAddonProxy;
|
|
||||||
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Errors
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export class ModuleError extends Error { }
|
|
||||||
|
|
||||||
export class CyclicDependencyError extends ModuleError {
|
|
||||||
constructor(message, modules) {
|
|
||||||
super(`${message} (${modules.map(x => x.path).join(' => ')})`);
|
|
||||||
this.modules = modules;
|
|
||||||
}
|
|
||||||
}
|
|
1187
src/utilities/module.ts
Normal file
1187
src/utilities/module.ts
Normal file
File diff suppressed because it is too large
Load diff
|
@ -1,952 +0,0 @@
|
||||||
'use strict';
|
|
||||||
|
|
||||||
import {BAD_HOTKEYS, TWITCH_EMOTE_V2, WORD_SEPARATORS} from 'utilities/constants';
|
|
||||||
|
|
||||||
const HOP = Object.prototype.hasOwnProperty;
|
|
||||||
|
|
||||||
export function getTwitchEmoteURL(id, scale, animated = false, dark = true) {
|
|
||||||
return `${TWITCH_EMOTE_V2}/${id}/${animated ? 'default' : 'static'}/${dark ? 'dark' : 'light'}/${scale == 4 ? 3 : scale}.0`
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTwitchEmoteSrcSet(id, animated = false, dark = true, big = false) {
|
|
||||||
if ( big )
|
|
||||||
return `${getTwitchEmoteURL(id, 2, animated, dark)} 1x, ${getTwitchEmoteURL(id, 4, animated, dark)} 2x`;
|
|
||||||
|
|
||||||
return `${getTwitchEmoteURL(id, 1, animated, dark)} 1x, ${getTwitchEmoteURL(id, 2, animated, dark)} 2x, ${getTwitchEmoteURL(id, 4, animated, dark)} 4x`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isValidShortcut(key) {
|
|
||||||
if ( ! key )
|
|
||||||
return false;
|
|
||||||
|
|
||||||
key = key.toLowerCase().trim();
|
|
||||||
return ! BAD_HOTKEYS.includes(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Source: https://gist.github.com/jed/982883 (WTFPL)
|
|
||||||
export function generateUUID(input) {
|
|
||||||
return input // if the placeholder was passed, return
|
|
||||||
? ( // a random number from 0 to 15
|
|
||||||
input ^ // unless b is 8,
|
|
||||||
Math.random() // in which case
|
|
||||||
* 16 // a random number from
|
|
||||||
>> input/4 // 8 to 11
|
|
||||||
).toString(16) // in hexadecimal
|
|
||||||
: ( // or otherwise a concatenated string:
|
|
||||||
[1e7] + // 10000000 +
|
|
||||||
-1e3 + // -1000 +
|
|
||||||
-4e3 + // -4000 +
|
|
||||||
-8e3 + // -80000000 +
|
|
||||||
-1e11 // -100000000000,
|
|
||||||
).replace( // replacing
|
|
||||||
/[018]/g, // zeroes, ones, and eights with
|
|
||||||
generateUUID // random hex digits
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export class TranslatableError extends Error {
|
|
||||||
constructor(message/*:string*/, key/*:string*/, data/*:object*/) {
|
|
||||||
super(message);
|
|
||||||
this.i18n_key = key;
|
|
||||||
this.data = data;
|
|
||||||
}
|
|
||||||
|
|
||||||
toString() {
|
|
||||||
const ffz = window.FrankerFaceZ?.get?.(),
|
|
||||||
i18n = ffz?.resolve?.('i18n');
|
|
||||||
|
|
||||||
if ( i18n && this.i18n_key )
|
|
||||||
return i18n.t(this.i18n_key, this.message, this.data);
|
|
||||||
|
|
||||||
return this.message;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export async function sha256(message) {
|
|
||||||
// encode as UTF-8
|
|
||||||
const msgBuffer = new TextEncoder().encode(message);
|
|
||||||
|
|
||||||
// hash the message
|
|
||||||
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
|
|
||||||
|
|
||||||
// convert ArrayBuffer to Array
|
|
||||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
|
||||||
|
|
||||||
// convert bytes to hex string
|
|
||||||
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
|
||||||
return hashHex;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/*export function sortScreens(screens) {
|
|
||||||
screens.sort((a,b) => {
|
|
||||||
if ( a.left < b.left ) return -1;
|
|
||||||
if ( a.left > b.left ) return 1;
|
|
||||||
if ( a.top < b.top ) return -1;
|
|
||||||
if ( a.top > b.top ) return 1;
|
|
||||||
return 0;
|
|
||||||
});
|
|
||||||
return screens;
|
|
||||||
}*/
|
|
||||||
|
|
||||||
|
|
||||||
export function matchScreen(screens, options) {
|
|
||||||
let match = undefined;
|
|
||||||
let mscore = 0;
|
|
||||||
|
|
||||||
for(let i = 0; i < screens.length; i++) {
|
|
||||||
const mon = screens[i];
|
|
||||||
if ( mon.label !== options.label )
|
|
||||||
continue;
|
|
||||||
|
|
||||||
let score = 1;
|
|
||||||
if ( options.left && options.left === mon.left )
|
|
||||||
score += 15;
|
|
||||||
if ( options.top && options.top === mon.top )
|
|
||||||
score += 15;
|
|
||||||
|
|
||||||
if ( options.width && options.width === mon.width )
|
|
||||||
score += 10;
|
|
||||||
|
|
||||||
if ( options.height && options.height === mon.height )
|
|
||||||
score += 10;
|
|
||||||
|
|
||||||
if ( options.index )
|
|
||||||
score -= Math.abs(options.index - i);
|
|
||||||
|
|
||||||
if ( score > mscore ) {
|
|
||||||
match = mon;
|
|
||||||
mscore = score;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return match;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function has(object, key) {
|
|
||||||
return object ? HOP.call(object, key) : false;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function sleep(delay) {
|
|
||||||
return new Promise(s => setTimeout(s, delay));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function make_enum(...array) {
|
|
||||||
const out = {};
|
|
||||||
|
|
||||||
for(let i=0; i < array.length; i++) {
|
|
||||||
const word = array[i];
|
|
||||||
out[word] = i;
|
|
||||||
out[i] = word;
|
|
||||||
}
|
|
||||||
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function make_enum_flags(...array) {
|
|
||||||
const out = {};
|
|
||||||
|
|
||||||
out.None = 0;
|
|
||||||
out[0] = 'None';
|
|
||||||
|
|
||||||
for(let i = 0; i < array.length; i++) {
|
|
||||||
const word = array[i],
|
|
||||||
value = Math.pow(2, i);
|
|
||||||
out[word] = value;
|
|
||||||
out[value] = word;
|
|
||||||
}
|
|
||||||
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function timeout(promise, delay) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
let resolved = false;
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
if ( ! resolved ) {
|
|
||||||
resolved = true;
|
|
||||||
reject(new Error('timeout'));
|
|
||||||
}
|
|
||||||
}, delay);
|
|
||||||
|
|
||||||
promise.then(result => {
|
|
||||||
if ( ! resolved ) {
|
|
||||||
resolved = true;
|
|
||||||
clearTimeout(timer);
|
|
||||||
resolve(result);
|
|
||||||
}
|
|
||||||
}).catch(err => {
|
|
||||||
if ( ! resolved ) {
|
|
||||||
resolved = true;
|
|
||||||
clearTimeout(timer);
|
|
||||||
reject(err);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export class Mutex {
|
|
||||||
constructor(limit = 1) {
|
|
||||||
this.limit = limit;
|
|
||||||
this._active = 0;
|
|
||||||
this._waiting = [];
|
|
||||||
|
|
||||||
this._done = this._done.bind(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
get available() { return this._active < this.limit }
|
|
||||||
|
|
||||||
_done() {
|
|
||||||
this._active--;
|
|
||||||
|
|
||||||
while(this._active < this.limit && this._waiting.length > 0) {
|
|
||||||
this._active++;
|
|
||||||
const waiter = this._waiting.shift();
|
|
||||||
waiter(this._done);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
wait() {
|
|
||||||
if ( this._active < this.limit) {
|
|
||||||
this._active++;
|
|
||||||
return Promise.resolve(this._done);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Promise(s => this._waiting.push(s));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return a wrapper for a function that will only execute the function
|
|
||||||
* a period of time after it has stopped being called.
|
|
||||||
* @param {Function} fn The function to wrap.
|
|
||||||
* @param {Integer} delay The time to wait, in milliseconds
|
|
||||||
* @param {Boolean} immediate If immediate is true, trigger the function immediately rather than eventually.
|
|
||||||
* @returns {Function} wrapped function
|
|
||||||
*/
|
|
||||||
export function debounce(fn, delay, immediate) {
|
|
||||||
let timer;
|
|
||||||
if ( immediate ) {
|
|
||||||
const later = () => timer = null;
|
|
||||||
if ( immediate === 2 )
|
|
||||||
// Special Mode! Run immediately OR later.
|
|
||||||
return function(...args) {
|
|
||||||
if ( timer ) {
|
|
||||||
clearTimeout(timer);
|
|
||||||
timer = setTimeout(() => {
|
|
||||||
timer = null;
|
|
||||||
fn.apply(this, args); // eslint-disable-line no-invalid-this
|
|
||||||
}, delay);
|
|
||||||
} else {
|
|
||||||
fn.apply(this, args); // eslint-disable-line no-invalid-this
|
|
||||||
timer = setTimeout(later, delay);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return function(...args) {
|
|
||||||
if ( ! timer )
|
|
||||||
fn.apply(this, args); // eslint-disable-line no-invalid-this
|
|
||||||
else
|
|
||||||
clearTimeout(timer);
|
|
||||||
|
|
||||||
timer = setTimeout(later, delay);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return function(...args) {
|
|
||||||
if ( timer )
|
|
||||||
clearTimeout(timer);
|
|
||||||
|
|
||||||
timer = setTimeout(fn.bind(this, ...args), delay); // eslint-disable-line no-invalid-this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Make sure that a given asynchronous function is only called once
|
|
||||||
* at a time.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export function once(fn) {
|
|
||||||
let waiters;
|
|
||||||
|
|
||||||
return function(...args) {
|
|
||||||
return new Promise(async (s,f) => {
|
|
||||||
if ( waiters )
|
|
||||||
return waiters.push([s,f]);
|
|
||||||
|
|
||||||
waiters = [[s,f]];
|
|
||||||
let result;
|
|
||||||
try {
|
|
||||||
result = await fn.call(this, ...args); // eslint-disable-line no-invalid-this
|
|
||||||
} catch(err) {
|
|
||||||
for(const w of waiters)
|
|
||||||
w[1](err);
|
|
||||||
waiters = null;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for(const w of waiters)
|
|
||||||
w[0](result);
|
|
||||||
|
|
||||||
waiters = null;
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check that two arrays are the same length and that each array has the same
|
|
||||||
* items in the same indices.
|
|
||||||
* @param {Array} a The first array
|
|
||||||
* @param {Array} b The second array
|
|
||||||
* @returns {boolean} Whether or not they match
|
|
||||||
*/
|
|
||||||
export function array_equals(a, b) {
|
|
||||||
if ( ! Array.isArray(a) || ! Array.isArray(b) || a.length !== b.length )
|
|
||||||
return false;
|
|
||||||
|
|
||||||
let i = a.length;
|
|
||||||
while(i--)
|
|
||||||
if ( a[i] !== b[i] )
|
|
||||||
return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function deep_equals(object, other, ignore_undefined = false, seen, other_seen) {
|
|
||||||
if ( object === other )
|
|
||||||
return true;
|
|
||||||
if ( typeof object !== typeof other )
|
|
||||||
return false;
|
|
||||||
if ( typeof object !== 'object' )
|
|
||||||
return false;
|
|
||||||
if ( (object === null) !== (other === null) )
|
|
||||||
return false;
|
|
||||||
|
|
||||||
if ( ! seen )
|
|
||||||
seen = new Set;
|
|
||||||
|
|
||||||
if ( ! other_seen )
|
|
||||||
other_seen = new Set;
|
|
||||||
|
|
||||||
if ( seen.has(object) || other_seen.has(other) )
|
|
||||||
throw new Error('recursive structure detected');
|
|
||||||
|
|
||||||
seen.add(object);
|
|
||||||
other_seen.add(other);
|
|
||||||
|
|
||||||
const source_keys = Object.keys(object),
|
|
||||||
dest_keys = Object.keys(other);
|
|
||||||
|
|
||||||
if ( ! ignore_undefined && ! set_equals(new Set(source_keys), new Set(dest_keys)) )
|
|
||||||
return false;
|
|
||||||
|
|
||||||
for(const key of source_keys)
|
|
||||||
if ( ! deep_equals(object[key], other[key], ignore_undefined, new Set(seen), new Set(other_seen)) )
|
|
||||||
return false;
|
|
||||||
|
|
||||||
if ( ignore_undefined )
|
|
||||||
for(const key of dest_keys)
|
|
||||||
if ( ! source_keys.includes(key) ) {
|
|
||||||
if ( ! deep_equals(object[key], other[key], ignore_undefined, new Set(seen), new Set(other_seen)) )
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function shallow_object_equals(a, b) {
|
|
||||||
if ( typeof a !== 'object' || typeof b !== 'object' )
|
|
||||||
return false;
|
|
||||||
|
|
||||||
const keys = Object.keys(a);
|
|
||||||
if ( ! set_equals(new Set(keys), new Set(Object.keys(b))) )
|
|
||||||
return false;
|
|
||||||
|
|
||||||
for(const key of keys)
|
|
||||||
if ( a[key] !== b[key] )
|
|
||||||
return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function map_equals(a, b) {
|
|
||||||
if ( !(a instanceof Map) || !(b instanceof Map) || a.size !== b.size )
|
|
||||||
return false;
|
|
||||||
|
|
||||||
for(const [key, val] of a)
|
|
||||||
if ( ! b.has(key) || b.get(key) !== val )
|
|
||||||
return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function set_equals(a,b) {
|
|
||||||
if ( !(a instanceof Set) || !(b instanceof Set) || a.size !== b.size )
|
|
||||||
return false;
|
|
||||||
|
|
||||||
for(const v of a)
|
|
||||||
if ( ! b.has(v) )
|
|
||||||
return false;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Special logic to ensure that a target object is matched by a filter.
|
|
||||||
* @param {object} filter The filter object
|
|
||||||
* @param {object} target The object to check it against
|
|
||||||
* @returns {boolean} Whether or not it matches
|
|
||||||
*/
|
|
||||||
export function filter_match(filter, target) {
|
|
||||||
for(const key in filter) {
|
|
||||||
if ( HOP.call(filter, key) ) {
|
|
||||||
const filter_value = filter[key],
|
|
||||||
target_value = target[key],
|
|
||||||
type = typeof filter_value;
|
|
||||||
|
|
||||||
if ( type === 'function' ) {
|
|
||||||
if ( ! filter_value(target_value) )
|
|
||||||
return false;
|
|
||||||
|
|
||||||
} else if ( Array.isArray(filter_value) ) {
|
|
||||||
if ( Array.isArray(target_value) ) {
|
|
||||||
for(const val of filter_value)
|
|
||||||
if ( ! target_value.includes(val) )
|
|
||||||
return false;
|
|
||||||
|
|
||||||
} else if ( ! filter_value.include(target_value) )
|
|
||||||
return false;
|
|
||||||
|
|
||||||
} else if ( typeof target_value !== type )
|
|
||||||
return false;
|
|
||||||
|
|
||||||
else if ( type === 'object' ) {
|
|
||||||
if ( ! filter_match(filter_value, target_value) )
|
|
||||||
return false;
|
|
||||||
|
|
||||||
} else if ( filter_value !== target_value )
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function substr_count(str, needle) {
|
|
||||||
let i = 0, idx = 0;
|
|
||||||
while( idx < str.length ) {
|
|
||||||
const x = str.indexOf(needle, idx);
|
|
||||||
if ( x === -1 )
|
|
||||||
break;
|
|
||||||
|
|
||||||
i++;
|
|
||||||
idx = x + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
return i;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get a value from an object at a path.
|
|
||||||
* @param {string|Array} path The path to follow, using periods to go down a level.
|
|
||||||
* @param {object|Array} object The starting object.
|
|
||||||
* @returns {*} The value at that point in the path, or undefined if part of the path doesn't exist.
|
|
||||||
*/
|
|
||||||
export function get(path, object) {
|
|
||||||
if ( HOP.call(object, path) )
|
|
||||||
return object[path];
|
|
||||||
|
|
||||||
if ( typeof path === 'string' )
|
|
||||||
path = path.split('.');
|
|
||||||
|
|
||||||
for(let i=0, l = path.length; i < l; i++) {
|
|
||||||
const part = path[i];
|
|
||||||
if ( part === '@each' ) {
|
|
||||||
const p = path.slice(i + 1);
|
|
||||||
if ( p.length ) {
|
|
||||||
if ( Array.isArray )
|
|
||||||
object = object.map(x => get(p, x));
|
|
||||||
else {
|
|
||||||
const new_object = {};
|
|
||||||
for(const key in object)
|
|
||||||
if ( HOP.call(object, key) )
|
|
||||||
new_object[key] = get(p, object[key]);
|
|
||||||
object = new_object;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
|
|
||||||
} else if ( part === '@last' )
|
|
||||||
object = object[object.length - 1];
|
|
||||||
else
|
|
||||||
object = object[path[i]];
|
|
||||||
|
|
||||||
if ( ! object )
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return object;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Copy an object so that it can be safely serialized. If an object
|
|
||||||
* is not serializable, such as a promise, returns null.
|
|
||||||
*
|
|
||||||
* @export
|
|
||||||
* @param {*} object The thing to copy.
|
|
||||||
* @param {Number} [depth=2] The maximum depth to explore the object.
|
|
||||||
* @param {Set} [seen=null] A Set of seen objects. Internal use only.
|
|
||||||
* @returns {Object} The copy to safely store or use.
|
|
||||||
*/
|
|
||||||
export function shallow_copy(object, depth = 2, seen = null) {
|
|
||||||
if ( object == null )
|
|
||||||
return object;
|
|
||||||
|
|
||||||
if ( object instanceof Promise || typeof object === 'function' )
|
|
||||||
return null;
|
|
||||||
|
|
||||||
if ( typeof object !== 'object' )
|
|
||||||
return object;
|
|
||||||
|
|
||||||
if ( depth === 0 )
|
|
||||||
return null;
|
|
||||||
|
|
||||||
if ( ! seen )
|
|
||||||
seen = new Set;
|
|
||||||
|
|
||||||
seen.add(object);
|
|
||||||
|
|
||||||
if ( Array.isArray(object) ) {
|
|
||||||
const out = [];
|
|
||||||
for(const val of object) {
|
|
||||||
if ( seen.has(val) )
|
|
||||||
continue;
|
|
||||||
|
|
||||||
out.push(shallow_copy(val, depth - 1, new Set(seen)));
|
|
||||||
}
|
|
||||||
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
const out = {};
|
|
||||||
for(const [key, val] of Object.entries(object) ) {
|
|
||||||
if ( seen.has(val) )
|
|
||||||
continue;
|
|
||||||
|
|
||||||
out[key] = shallow_copy(val, depth - 1, new Set(seen));
|
|
||||||
}
|
|
||||||
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function deep_copy(object, seen) {
|
|
||||||
if ( object === null )
|
|
||||||
return null;
|
|
||||||
else if ( object === undefined )
|
|
||||||
return undefined;
|
|
||||||
|
|
||||||
if ( object instanceof Promise )
|
|
||||||
return new Promise((s,f) => object.then(s).catch(f));
|
|
||||||
|
|
||||||
if ( typeof object === 'function' )
|
|
||||||
return function(...args) { return object.apply(this, args); } // eslint-disable-line no-invalid-this
|
|
||||||
|
|
||||||
if ( typeof object !== 'object' )
|
|
||||||
return object;
|
|
||||||
|
|
||||||
if ( ! seen )
|
|
||||||
seen = new Set;
|
|
||||||
|
|
||||||
if ( seen.has(object) )
|
|
||||||
throw new Error('recursive structure detected');
|
|
||||||
|
|
||||||
seen.add(object);
|
|
||||||
|
|
||||||
if ( Array.isArray(object) )
|
|
||||||
return object.map(x => deep_copy(x, new Set(seen)));
|
|
||||||
|
|
||||||
const out = {};
|
|
||||||
for(const key in object)
|
|
||||||
if ( HOP.call(object, key) ) {
|
|
||||||
const val = object[key];
|
|
||||||
if ( typeof val === 'object' )
|
|
||||||
out[key] = deep_copy(val, new Set(seen));
|
|
||||||
else
|
|
||||||
out[key] = val;
|
|
||||||
}
|
|
||||||
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function normalizeAddonIdForComparison(input) {
|
|
||||||
return input.toLowerCase().replace(/[\.\_\-]+/, '-');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function makeAddonIdChecker(input) {
|
|
||||||
input = escape_regex(normalizeAddonIdForComparison(input));
|
|
||||||
input = input.replace(/-+/g, '[\.\_\-]+');
|
|
||||||
|
|
||||||
// Special: ffzap-bttv
|
|
||||||
input = input.replace(/\bbttv\b/g, '(?:bttv|betterttv)');
|
|
||||||
|
|
||||||
// Special: which seven tho
|
|
||||||
input = input.replace(/\b7tv\b/g, '(?:7tv|seventv)');
|
|
||||||
|
|
||||||
// Special: pronouns (badges)
|
|
||||||
input = input.replace(/\bpronouns\b/g, '(?:pronouns|addon-pn)');
|
|
||||||
|
|
||||||
return new RegExp('\\b' + input + '\\b', 'i');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function maybe_call(fn, ctx, ...args) {
|
|
||||||
if ( typeof fn === 'function' ) {
|
|
||||||
if ( ctx )
|
|
||||||
return fn.call(ctx, ...args);
|
|
||||||
return fn(...args);
|
|
||||||
}
|
|
||||||
|
|
||||||
return fn;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const SPLIT_REGEX = /[^\uD800-\uDFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDFFF]/g;
|
|
||||||
|
|
||||||
export function split_chars(str) {
|
|
||||||
if ( str === '' )
|
|
||||||
return [];
|
|
||||||
|
|
||||||
return str.match(SPLIT_REGEX);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function pick_random(obj) {
|
|
||||||
if ( ! obj )
|
|
||||||
return null;
|
|
||||||
|
|
||||||
if ( ! Array.isArray(obj) )
|
|
||||||
return obj[pick_random(Object.keys(obj))]
|
|
||||||
|
|
||||||
return obj[Math.floor(Math.random() * obj.length)];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export const escape_regex = RegExp.escape || function escape_regex(str) {
|
|
||||||
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function addWordSeparators(str) {
|
|
||||||
return `(^|.*?${WORD_SEPARATORS})(?:${str})(?=$|${WORD_SEPARATORS})`
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const CONTROL_CHARS = '/$^+.()=!|';
|
|
||||||
|
|
||||||
export function glob_to_regex(input) {
|
|
||||||
if ( typeof input !== 'string' )
|
|
||||||
throw new TypeError('input must be a string');
|
|
||||||
|
|
||||||
let output = '',
|
|
||||||
groups = 0;
|
|
||||||
|
|
||||||
for(let i=0, l=input.length; i<l; i++) {
|
|
||||||
const char = input[i];
|
|
||||||
|
|
||||||
if ( CONTROL_CHARS.includes(char) )
|
|
||||||
output += `\\${char}`;
|
|
||||||
|
|
||||||
else if ( char === '\\' ) {
|
|
||||||
i++;
|
|
||||||
const next = input[i];
|
|
||||||
if ( next ) {
|
|
||||||
if ( CONTROL_CHARS.includes(next) )
|
|
||||||
output += `\\${next}`;
|
|
||||||
else
|
|
||||||
output += next;
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if ( char === '?' )
|
|
||||||
output += '.';
|
|
||||||
|
|
||||||
else if ( char === '[' ) {
|
|
||||||
output += char;
|
|
||||||
const next = input[i + 1];
|
|
||||||
if ( next === '!' ) {
|
|
||||||
i++;
|
|
||||||
output += '^';
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if ( char === ']' )
|
|
||||||
output += char;
|
|
||||||
|
|
||||||
else if ( char === '{' ) {
|
|
||||||
output += '(?:';
|
|
||||||
groups++;
|
|
||||||
|
|
||||||
} else if ( char === '}' ) {
|
|
||||||
if ( groups > 0 ) {
|
|
||||||
output += ')';
|
|
||||||
groups--;
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if ( char === ',' && groups > 0 )
|
|
||||||
output += '|';
|
|
||||||
|
|
||||||
else if ( char === '*' ) {
|
|
||||||
let count = 1;
|
|
||||||
while(input[i+1] === '*') {
|
|
||||||
count++;
|
|
||||||
i++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ( count > 1 )
|
|
||||||
output += '.*?';
|
|
||||||
else
|
|
||||||
output += '[^\\s]*?';
|
|
||||||
|
|
||||||
} else
|
|
||||||
output += char;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*while(groups > 0) {
|
|
||||||
output += ')';
|
|
||||||
groups--;
|
|
||||||
}*/
|
|
||||||
|
|
||||||
return output;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Truncate a string. Tries to intelligently break the string in white-space
|
|
||||||
* if possible, without back-tracking. The returned string can be up to
|
|
||||||
* `ellipsis.length + target + overage` characters long.
|
|
||||||
* @param {String} str The string to truncate.
|
|
||||||
* @param {Number} target The target length for the result
|
|
||||||
* @param {Number} overage Accept up to this many additional characters for a better result
|
|
||||||
* @param {String} [ellipsis='…'] The string to append when truncating
|
|
||||||
* @param {Boolean} [break_line=true] If true, attempt to break at the first LF
|
|
||||||
* @param {Boolean} [trim=true] If true, runs trim() on the string before truncating
|
|
||||||
* @returns {String} The truncated string
|
|
||||||
*/
|
|
||||||
export function truncate(str, target = 100, overage = 15, ellipsis = '…', break_line = true, trim = true) {
|
|
||||||
if ( ! str || ! str.length )
|
|
||||||
return str;
|
|
||||||
|
|
||||||
if ( trim )
|
|
||||||
str = str.trim();
|
|
||||||
|
|
||||||
let idx = break_line ? str.indexOf('\n') : -1;
|
|
||||||
if ( idx === -1 || idx > target )
|
|
||||||
idx = target;
|
|
||||||
|
|
||||||
if ( str.length <= idx )
|
|
||||||
return str;
|
|
||||||
|
|
||||||
let out = str.slice(0, idx).trimRight();
|
|
||||||
if ( overage > 0 && out.length >= idx ) {
|
|
||||||
let next_space = str.slice(idx).search(/\s+/);
|
|
||||||
if ( next_space === -1 && overage + idx > str.length )
|
|
||||||
next_space = str.length - idx;
|
|
||||||
|
|
||||||
if ( next_space !== -1 && next_space <= overage ) {
|
|
||||||
if ( str.length <= (idx + next_space) )
|
|
||||||
return str;
|
|
||||||
|
|
||||||
out = str.slice(0, idx + next_space);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return out + ellipsis;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
function decimalToHex(number) {
|
|
||||||
return number.toString(16).padStart(2, '0')
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function generateHex(length = 40) {
|
|
||||||
const arr = new Uint8Array(length / 2);
|
|
||||||
window.crypto.getRandomValues(arr);
|
|
||||||
return Array.from(arr, decimalToHex).join('')
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export class SourcedSet {
|
|
||||||
constructor(use_set = false) {
|
|
||||||
this._use_set = use_set;
|
|
||||||
this._cache = use_set ? new Set : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
_rebuild() {
|
|
||||||
if ( ! this._sources )
|
|
||||||
return;
|
|
||||||
|
|
||||||
const use_set = this._use_set,
|
|
||||||
cache = this._cache = use_set ? new Set : [];
|
|
||||||
for(const items of this._sources.values())
|
|
||||||
for(const i of items)
|
|
||||||
if ( use_set )
|
|
||||||
cache.add(i);
|
|
||||||
else if ( ! cache.includes(i) )
|
|
||||||
this._cache.push(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
get(key) { return this._sources && this._sources.get(key) }
|
|
||||||
has(key) { return this._sources ? this._sources.has(key) : false }
|
|
||||||
|
|
||||||
sourceIncludes(key, val) {
|
|
||||||
const src = this._sources && this._sources.get(key);
|
|
||||||
return src && src.includes(val);
|
|
||||||
}
|
|
||||||
|
|
||||||
includes(val) {
|
|
||||||
return this._use_set ? this._cache.has(val) : this._cache.includes(val);
|
|
||||||
}
|
|
||||||
|
|
||||||
delete(key) {
|
|
||||||
if ( this._sources && this._sources.has(key) ) {
|
|
||||||
this._sources.delete(key);
|
|
||||||
this._rebuild();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
extend(key, ...items) {
|
|
||||||
if ( ! this._sources )
|
|
||||||
this._sources = new Map;
|
|
||||||
|
|
||||||
const had = this.has(key);
|
|
||||||
if ( had )
|
|
||||||
items = [...this._sources.get(key), ...items];
|
|
||||||
|
|
||||||
this._sources.set(key, items);
|
|
||||||
if ( had )
|
|
||||||
this._rebuild();
|
|
||||||
else
|
|
||||||
for(const i of items)
|
|
||||||
if ( this._use_set )
|
|
||||||
this._cache.add(i);
|
|
||||||
else if ( ! this._cache.includes(i) )
|
|
||||||
this._cache.push(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
set(key, val) {
|
|
||||||
if ( ! this._sources )
|
|
||||||
this._sources = new Map;
|
|
||||||
|
|
||||||
const had = this.has(key);
|
|
||||||
if ( ! Array.isArray(val) )
|
|
||||||
val = [val];
|
|
||||||
|
|
||||||
this._sources.set(key, val);
|
|
||||||
if ( had )
|
|
||||||
this._rebuild();
|
|
||||||
else
|
|
||||||
for(const i of val)
|
|
||||||
if ( this._use_set )
|
|
||||||
this._cache.add(i);
|
|
||||||
else if ( ! this._cache.includes(i) )
|
|
||||||
this._cache.push(i);
|
|
||||||
}
|
|
||||||
|
|
||||||
push(key, val) {
|
|
||||||
if ( ! this._sources )
|
|
||||||
return this.set(key, val);
|
|
||||||
|
|
||||||
const old_val = this._sources.get(key);
|
|
||||||
if ( old_val === undefined )
|
|
||||||
return this.set(key, val);
|
|
||||||
|
|
||||||
else if ( old_val.includes(val) )
|
|
||||||
return;
|
|
||||||
|
|
||||||
old_val.push(val);
|
|
||||||
if ( this._use_set )
|
|
||||||
this._cache.add(val);
|
|
||||||
else if ( ! this._cache.includes(val) )
|
|
||||||
this._cache.push(val);
|
|
||||||
}
|
|
||||||
|
|
||||||
remove(key, val) {
|
|
||||||
if ( ! this.has(key) )
|
|
||||||
return;
|
|
||||||
|
|
||||||
const old_val = this._sources.get(key),
|
|
||||||
idx = old_val.indexOf(val);
|
|
||||||
|
|
||||||
if ( idx === -1 )
|
|
||||||
return;
|
|
||||||
|
|
||||||
old_val.splice(idx, 1);
|
|
||||||
this._rebuild();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function b64ToArrayBuffer(input) {
|
|
||||||
const bin = atob(input),
|
|
||||||
len = bin.length,
|
|
||||||
buffer = new ArrayBuffer(len),
|
|
||||||
view = new Uint8Array(buffer);
|
|
||||||
|
|
||||||
for(let i = 0, len = bin.length; i < len; i++)
|
|
||||||
view[i] = bin.charCodeAt(i);
|
|
||||||
|
|
||||||
return buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const PEM_HEADER = /-----BEGIN (.+?) KEY-----/,
|
|
||||||
PEM_FOOTER = /-----END (.+?) KEY-----/;
|
|
||||||
|
|
||||||
export function importRsaKey(pem, uses = ['verify']) {
|
|
||||||
const start_match = PEM_HEADER.exec(pem),
|
|
||||||
end_match = PEM_FOOTER.exec(pem);
|
|
||||||
|
|
||||||
if ( ! start_match || ! end_match || start_match[1] !== end_match[1] )
|
|
||||||
throw new Error('invalid key');
|
|
||||||
|
|
||||||
const is_private = /\bPRIVATE\b/i.test(start_match[1]),
|
|
||||||
start = start_match.index + start_match[0].length,
|
|
||||||
end = end_match.index;
|
|
||||||
|
|
||||||
const content = pem.slice(start, end).replace(/\n/g, '').trim();
|
|
||||||
//console.debug('content', JSON.stringify(content));
|
|
||||||
|
|
||||||
const buffer = b64ToArrayBuffer(content);
|
|
||||||
|
|
||||||
return crypto.subtle.importKey(
|
|
||||||
is_private ? 'pkcs8' : 'spki',
|
|
||||||
buffer,
|
|
||||||
{
|
|
||||||
name: "RSA-PSS",
|
|
||||||
hash: "SHA-256"
|
|
||||||
},
|
|
||||||
true,
|
|
||||||
uses
|
|
||||||
);
|
|
||||||
}
|
|
1339
src/utilities/object.ts
Normal file
1339
src/utilities/object.ts
Normal file
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue