1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-06-27 21:05:53 +00:00

Initial commit for converting FrankerFaceZ to TypeScript.

This commit is contained in:
SirStendec 2023-11-13 20:47:45 -05:00
parent ba72969c51
commit b9d23accf0
86 changed files with 8673 additions and 5005 deletions

View file

@ -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
View 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
View file

@ -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
View 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);
}

View file

@ -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;`);

View file

@ -15,6 +15,9 @@
"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",
"build:types": "cross-env tsc --declaration --emitDeclarationOnly --outDir typedist && node bin/build_types",
"abuild:types": "node bin/build_types",
"build:docs": "cross-env typedoc --options typedoc.json",
"font": "pnpm font:edit", "font": "pnpm font:edit",
"font:edit": "fontello-cli --cli-config fontello.client.json edit", "font:edit": "fontello-cli --cli-config fontello.client.json edit",
"font:save": "fontello-cli --cli-config fontello.client.json save && pnpm font:update", "font:save": "fontello-cli --cli-config fontello.client.json save && pnpm font:update",
@ -22,6 +25,8 @@
}, },
"devDependencies": { "devDependencies": {
"@ffz/fontello-cli": "^1.0.4", "@ffz/fontello-cli": "^1.0.4",
"@types/safe-regex": "^1.1.6",
"@types/webpack-env": "^1.18.4",
"browserslist": "^4.21.10", "browserslist": "^4.21.10",
"copy-webpack-plugin": "^11.0.0", "copy-webpack-plugin": "^11.0.0",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
@ -33,6 +38,7 @@
"eslint-plugin-vue": "^9.17.0", "eslint-plugin-vue": "^9.17.0",
"extract-loader": "^5.1.0", "extract-loader": "^5.1.0",
"file-loader": "^6.2.0", "file-loader": "^6.2.0",
"glob": "^10.3.10",
"json-loader": "^0.5.7", "json-loader": "^0.5.7",
"minify-graphql-loader": "^1.0.2", "minify-graphql-loader": "^1.0.2",
"raw-loader": "^4.0.2", "raw-loader": "^4.0.2",
@ -40,6 +46,12 @@
"sass": "^1.66.1", "sass": "^1.66.1",
"sass-loader": "^13.3.2", "sass-loader": "^13.3.2",
"semver": "^7.5.4", "semver": "^7.5.4",
"typedoc": "^0.25.3",
"typedoc-plugin-markdown": "^3.17.1",
"typedoc-plugin-mdn-links": "^3.1.0",
"typedoc-plugin-no-inherit": "^1.4.0",
"typedoc-plugin-rename-defaults": "^0.7.0",
"typescript": "^5.2.2",
"vue-loader": "^15.10.2", "vue-loader": "^15.10.2",
"vue-template-compiler": "^2.6.14", "vue-template-compiler": "^2.6.14",
"webpack": "^5.88.2", "webpack": "^5.88.2",
@ -53,7 +65,7 @@
}, },
"dependencies": { "dependencies": {
"@ffz/icu-msgparser": "^2.0.0", "@ffz/icu-msgparser": "^2.0.0",
"@popperjs/core": "^2.10.2", "@popperjs/core": "^2.11.8",
"crypto-js": "^3.3.0", "crypto-js": "^3.3.0",
"dayjs": "^1.10.7", "dayjs": "^1.10.7",
"denoflare-mqtt": "^0.0.2", "denoflare-mqtt": "^0.0.2",

174
pnpm-lock.yaml generated
View file

@ -15,8 +15,8 @@ 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: ^3.3.0
version: 3.3.0 version: 3.3.0
@ -97,6 +97,12 @@ devDependencies:
'@ffz/fontello-cli': '@ffz/fontello-cli':
specifier: ^1.0.4 specifier: ^1.0.4
version: 1.0.4 version: 1.0.4
'@types/safe-regex':
specifier: ^1.1.6
version: 1.1.6
'@types/webpack-env':
specifier: ^1.18.4
version: 1.18.4
browserslist: browserslist:
specifier: ^4.21.10 specifier: ^4.21.10
version: 4.21.10 version: 4.21.10
@ -130,6 +136,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 +160,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 +562,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:
@ -643,6 +670,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 +701,10 @@ packages:
'@types/node': 20.5.7 '@types/node': 20.5.7
dev: true 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 +1024,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 +1809,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
@ -3056,13 +3100,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 +3197,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 +3683,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
@ -3710,6 +3767,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 +3898,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 +3917,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 +4750,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 +4970,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 +5367,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 +5497,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:
@ -5789,6 +5939,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'}

View file

@ -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 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';

View file

@ -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'];

View file

@ -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);
}
}
}

155
src/load_tracker.ts Normal file
View file

@ -0,0 +1,155 @@
'use strict';
// ============================================================================
// Loading Tracker
// ============================================================================
import Module, { GenericModule } from 'utilities/module';
import type SettingsManager from './settings';
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);
}
}
}

View file

@ -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 Vue 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);
// ======================================================================== // ========================================================================
@ -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();

View file

@ -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
}); });

View file

@ -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,

View file

@ -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;
} }
} }

View file

@ -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;

View file

@ -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;
}, },

View file

@ -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;

View file

@ -1,23 +1,216 @@
'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;
}
}
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 +298,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 +324,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 +336,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 +358,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 +384,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 +401,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 +420,7 @@ export default class Metadata extends Module {
class="tw-border-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 ffz-input tw-full-width" 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 +441,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 +451,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 +464,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) {
@ -297,9 +491,9 @@ export default class Metadata extends Module {
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 +503,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 +568,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 +582,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 +595,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 +617,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 +642,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 +653,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 +666,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 +751,32 @@ export default class Metadata extends Module {
tampered tampered
]; ];
} }
} });
} }
/** @internal */
getAddonProxy(addon_id, addon, module) { getAddonProxy(addon_id: string, addon: AddonInfo, module: GenericModule): 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 +788,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 +827,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 +944,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 +964,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 +979,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 +988,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 +1001,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,41 +1009,46 @@ 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) {
if ( err instanceof Error )
this.log.capture(err, { this.log.capture(err, {
tags: { tags: {
metadata: key metadata: key
@ -813,6 +1058,10 @@ export default class Metadata extends Module {
} }
} }
// el is not going to be null
// TypeScript is on drugs
// whatever though
if ( el ) {
if ( el._ffz_outside ) if ( el._ffz_outside )
el._ffz_outside.destroy(); el._ffz_outside.destroy();
@ -823,10 +1072,11 @@ export default class Metadata extends Module {
} }
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,9 +1100,10 @@ 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(() => {
if ( el && tip.outer )
el._ffz_outside = new ClickOutside(tip.outer, destroy); el._ffz_outside = new ClickOutside(tip.outer, destroy);
}), }),
onHide: destroy onHide: destroy
@ -871,23 +1122,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,17 +1151,19 @@ 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;
if ( el.tip?.element ) {
el.tip.element.innerHTML = ''; el.tip.element.innerHTML = '';
setChildren(el.tip.element, tooltip); setChildren(el.tip.element, tooltip);
} }
} }
} }
}
if ( typeof def.icon === 'function' ) { if ( typeof def.icon === 'function' ) {
const icon = maybe_call(def.icon, this, data); const icon = maybe_call(def.icon, this, data);
@ -928,12 +1181,14 @@ 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) {
if ( err instanceof Error )
this.log.capture(err, { this.log.capture(err, {
tags: { tags: {
metadata: key metadata: key

View file

@ -5,16 +5,71 @@
// ============================================================================ // ============================================================================
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;
}
}
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 +124,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 +197,22 @@ export default class TooltipProvider extends Module {
} }
define(key, handler) { define(key: string, handler: TooltipDefinition) {
// TODO: Determine if any tooltips are already open.
// If so, we need to close them / maybe re-open them?
this.types[key] = handler; 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 +249,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;
if ( this.tips ) {
this.tips.destroy();
this.tips = this._createInstance(tip_element); this.tips = this._createInstance(tip_element);
} }
} }
}
cleanup() { cleanup() {
if ( this.tips )
this.tips.cleanup(); 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 +302,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 +314,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];

View file

@ -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';

View file

@ -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}
} }

View file

@ -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();

View file

@ -7,7 +7,7 @@
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 * as DEFINITIONS from './typehandlers';
/** /**
* 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.

View file

@ -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,19 +277,30 @@ 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 )
return NeverMatch;
if ( router ) {
const route = router.getRoute(name); const route = router.getRoute(name);
if ( ! route || ! route.parts ) if ( ! route || ! route.parts )
return () => false; return NeverMatch;
let i = 1; let i = 1;
for(const part of route.parts) { for(const part of route.parts) {
@ -285,9 +312,6 @@ export const Page = {
i++; 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;
}; };
}, },

View file

@ -4,22 +4,26 @@
// Settings System // Settings System
// ============================================================================ // ============================================================================
import Module from 'utilities/module'; import Module, { GenericModule } from 'utilities/module';
import {deep_equals, has, debounce, deep_copy} from 'utilities/object'; import {deep_equals, has, debounce, deep_copy} from 'utilities/object';
import {parse as new_parse} from 'utilities/path-parser'; import {parse as parse_path} from 'utilities/path-parser';
import SettingsProfile from './profile'; import SettingsProfile from './profile';
import SettingsContext from './context'; import SettingsContext from './context';
import MigrationManager from './migration'; //import MigrationManager from './migration';
import * as PROCESSORS from './processors'; import * as PROCESSORS from './processors';
import * as VALIDATORS from './validators'; import * as VALIDATORS from './validators';
import * as PROVIDERS from './providers';
import * as FILTERS from './filters'; import * as FILTERS from './filters';
import * as CLEARABLES from './clearables'; import * as CLEARABLES from './clearables';
import type { SettingsProfileMetadata, ContextData, ExportedFullDump, SettingsClearable, SettingsDefinition, SettingsProcessor, SettingsUiDefinition, SettingsValidator } from './types';
import type { FilterType } from '../utilities/filtering';
import { AdvancedSettingsProvider, IndexedDBProvider, LocalStorageProvider, Providers, type SettingsProvider } from './providers';
export {parse as parse_path} from 'utilities/path-parser';
function postMessage(target, msg) { function postMessage(target: Window, msg) {
try { try {
target.postMessage(msg, '*'); target.postMessage(msg, '*');
return true; return true;
@ -31,6 +35,28 @@ function postMessage(target, msg) {
export const NO_SYNC_KEYS = ['session']; export const NO_SYNC_KEYS = ['session'];
// TODO: Check settings keys for better typing on events.
export type SettingsEvents = {
[key: `:changed:${string}`]: [value: any, old_value: any];
[key: `:uses_changed:${string}`]: [uses: number[], old_uses: number[]];
':added-definition': [key: string, definition: SettingsDefinition<any>];
':removed-definition': [key: string, definition: SettingsDefinition<any>];
':quota-exceeded': [];
':change-provider': [];
':ls-update': [key: string, value: any];
':profile-created': [profile: SettingsProfile];
':profile-changed': [profile: SettingsProfile];
':profile-deleted': [profile: SettingsProfile];
':profile-toggled': [profile: SettingsProfile, enabled: boolean];
':profiles-reordered': [];
};
// ============================================================================ // ============================================================================
// SettingsManager // SettingsManager
// ============================================================================ // ============================================================================
@ -39,21 +65,61 @@ export const NO_SYNC_KEYS = ['session'];
* The SettingsManager module creates all the necessary class instances * The SettingsManager module creates all the necessary class instances
* required for the settings system to operate, facilitates communication * required for the settings system to operate, facilitates communication
* and discovery, and emits events for other modules to react to. * and discovery, and emits events for other modules to react to.
* @extends Module
*/ */
export default class SettingsManager extends Module { export default class SettingsManager extends Module<'settings', SettingsEvents> {
_start_time: number;
// localStorage Hooks
private __ls_hooked: boolean;
private __ls_scheduled: Set<string>;
private __ls_cache: Map<string, unknown>;
private __ls_timer?: ReturnType<typeof setTimeout> | null;
// Storage of Things
clearables: Record<string, SettingsClearable>;
filters: Record<string, FilterType<any, ContextData>>;
processors: Record<string, SettingsProcessor<any>>;
providers: Record<string, typeof SettingsProvider>;
validators: Record<string, SettingsValidator<any>>;
// Storage of Settings
ui_structures: Map<string, SettingsUiDefinition<any>>;
definitions: Map<string, SettingsDefinition<any> | string[]>;
// Storage of State
provider: SettingsProvider | null = null;
main_context: SettingsContext;
private _update_timer?: ReturnType<typeof setTimeout> | null;
private _time_timer?: ReturnType<typeof setTimeout> | null;
private _active_provider: string = 'local';
private _idb: IndexedDBProvider | null = null;
private _provider_waiter?: Promise<SettingsProvider> | null;
private _provider_resolve?: ((input: SettingsProvider) => void) | null;
private __contexts: SettingsContext[];
private __profiles: SettingsProfile[];
private __profile_ids: Record<number, SettingsProfile | null>;
/** /**
* Create a SettingsManager module. * Whether or not profiles have been disabled for this session
*/ */
constructor(...args) { disable_profiles: boolean = false;
super(...args);
updateSoon: () => void;
/** @internal */
constructor(name?: string, parent?: GenericModule) {
super(name, parent);
this.providers = {}; this.providers = {};
for(const key in PROVIDERS) for(const [key, provider] of Object.entries(Providers)) {
if ( has(PROVIDERS, key) ) { if ( provider.supported() )
const provider = PROVIDERS[key]; this.providers[key] = provider;
if ( provider.key && provider.supported(this) )
this.providers[provider.key] = provider;
} }
// This cannot be modified at a future time, as providers NEED // This cannot be modified at a future time, as providers NEED
@ -64,7 +130,7 @@ export default class SettingsManager extends Module {
// Do we want to not enable any profiles? // Do we want to not enable any profiles?
try { try {
const params = new URL(window.location).searchParams; const params = new URL(window.location as any).searchParams;
if ( params ) { if ( params ) {
if ( params.has('ffz-no-settings') ) if ( params.has('ffz-no-settings') )
this.disable_profiles = true; this.disable_profiles = true;
@ -115,24 +181,22 @@ export default class SettingsManager extends Module {
// Create our provider as early as possible. // Create our provider as early as possible.
this._provider_waiters = [];
this._createProvider().then(provider => { this._createProvider().then(provider => {
this.provider = provider; this.provider = provider;
this.log.info(`Using Provider: ${provider.constructor.name}`); this.log.info(`Using Provider: ${provider.constructor.name}`);
provider.on('changed', this._onProviderChange, this); provider.on('changed', this._onProviderChange, this);
provider.on('quota-exceeded', err => { provider.on('quota-exceeded', (err) => {
this.emit(':quota-exceeded', err); this.emit(':quota-exceeded');
}); });
provider.on('change-provider', () => { provider.on('change-provider', () => {
this.emit(':change-provider'); this.emit(':change-provider');
}); });
for(const waiter of this._provider_waiters) if ( this._provider_resolve )
waiter(provider); this._provider_resolve(provider);
}); });
this.migrations = new MigrationManager(this); //this.migrations = new MigrationManager(this);
// Also create the main context as early as possible. // Also create the main context as early as possible.
this.main_context = new SettingsContext(this); this.main_context = new SettingsContext(this);
@ -184,7 +248,7 @@ export default class SettingsManager extends Module {
} }
addFilter(key, data) { addFilter<T>(key: string, data: FilterType<T, ContextData>) {
if ( this.filters[key] ) if ( this.filters[key] )
return this.log.warn('Tried to add already existing filter', key); return this.log.warn('Tried to add already existing filter', key);
@ -209,8 +273,14 @@ export default class SettingsManager extends Module {
if ( this.provider ) if ( this.provider )
return Promise.resolve(this.provider); return Promise.resolve(this.provider);
return new Promise(s => { if ( this._provider_waiter )
this._provider_waiters.push(s); return this._provider_waiter;
return this._provider_waiter = new Promise<SettingsProvider>((resolve, reject) => {
this._provider_resolve = resolve;
}).finally(() => {
this._provider_waiter = null;
this._provider_resolve = null;
}); });
} }
@ -221,6 +291,9 @@ export default class SettingsManager extends Module {
async onEnable() { async onEnable() {
// Before we do anything else, make sure the provider is ready. // Before we do anything else, make sure the provider is ready.
await this.awaitProvider(); await this.awaitProvider();
if ( ! this.provider )
throw new Error('did not get provider');
await this.provider.awaitReady(); await this.provider.awaitReady();
// When the router updates we additional routes, make sure to // When the router updates we additional routes, make sure to
@ -253,11 +326,14 @@ export default class SettingsManager extends Module {
Monitor.details = null; Monitor.details = null;
try { try {
if ( window.getScreenDetails ) {
Monitor.details = await window.getScreenDetails(); Monitor.details = await window.getScreenDetails();
Monitor.details.addEventListener('currentscreenchange', () => { Monitor.details.addEventListener('currentscreenchange', () => {
for(const context of this.__contexts) for(const context of this.__contexts)
context.selectProfiles(); context.selectProfiles();
}); });
} else
Monitor.details = false;
} catch(err) { } catch(err) {
this.log.error('Unable to get monitor details', err); this.log.error('Unable to get monitor details', err);
@ -305,7 +381,7 @@ export default class SettingsManager extends Module {
// LocalStorage Management // LocalStorage Management
// ======================================================================== // ========================================================================
_updateLSKey(key) { private _updateLSKey(key: string) {
if ( this.__ls_cache.has(key) || this.__ls_cache.has(`raw.${key}`) ) { if ( this.__ls_cache.has(key) || this.__ls_cache.has(`raw.${key}`) ) {
this.__ls_scheduled.add(key); this.__ls_scheduled.add(key);
if ( ! this.__ls_timer ) if ( ! this.__ls_timer )
@ -313,7 +389,7 @@ export default class SettingsManager extends Module {
} }
} }
_hookLS() { private _hookLS() {
if ( this.__ls_hooked ) if ( this.__ls_hooked )
return; return;
@ -336,13 +412,13 @@ export default class SettingsManager extends Module {
window.addEventListener('storage', this._handleLSEvent); window.addEventListener('storage', this._handleLSEvent);
} }
_handleLSEvent(event) { private _handleLSEvent(event: StorageEvent) {
if ( event.storageArea === localStorage ) if ( event.key && event.storageArea === localStorage )
this._updateLSKey(event.key); this._updateLSKey(event.key);
} }
_updateLS() { private _updateLS() {
clearTimeout(this.__ls_timer); clearTimeout(this.__ls_timer as ReturnType<typeof setTimeout>);
this.__ls_timer = null; this.__ls_timer = null;
const keys = this.__ls_scheduled; const keys = this.__ls_scheduled;
this.__ls_scheduled = new Set; this.__ls_scheduled = new Set;
@ -377,9 +453,9 @@ export default class SettingsManager extends Module {
} }
} }
getLS(key) { getLS<T>(key: string): T | null {
if ( this.__ls_cache.has(key) ) if ( this.__ls_cache.has(key) )
return this.__ls_cache.get(key); return this.__ls_cache.get(key) as T;
if ( ! this.__ls_hooked ) if ( ! this.__ls_hooked )
this._hookLS(); this._hookLS();
@ -392,7 +468,7 @@ export default class SettingsManager extends Module {
value = raw; value = raw;
else else
try { try {
value = JSON.parse(raw); value = raw ? JSON.parse(raw) : null;
} catch(err) { } catch(err) {
this.log.warn(`Unable to parse localStorage value as JSON for "${key}"`, err); this.log.warn(`Unable to parse localStorage value as JSON for "${key}"`, err);
} }
@ -420,12 +496,15 @@ export default class SettingsManager extends Module {
} }
async _needsZipBackup() { private async _needsZipBackup() {
// Before we do anything else, make sure the provider is ready. // Before we do anything else, make sure the provider is ready.
await this.awaitProvider(); await this.awaitProvider();
if ( ! this.provider )
return false;
await this.provider.awaitReady(); await this.provider.awaitReady();
if ( ! this.provider.supportsBlobs ) if ( !(this.provider instanceof AdvancedSettingsProvider) || ! this.provider.supportsBlobs )
return false; return false;
const keys = await this.provider.blobKeys(); const keys = await this.provider.blobKeys();
@ -433,9 +512,12 @@ export default class SettingsManager extends Module {
} }
async _getZipBackup() { private async _getZipBackup() {
// Before we do anything else, make sure the provider is ready. // Before we do anything else, make sure the provider is ready.
await this.awaitProvider(); await this.awaitProvider();
if ( ! this.provider )
throw new Error('provider not available');
await this.provider.awaitReady(); await this.provider.awaitReady();
// Create our ZIP file. // Create our ZIP file.
@ -449,7 +531,7 @@ export default class SettingsManager extends Module {
// Blob Settings // Blob Settings
const metadata = {}; const metadata = {};
if ( this.provider.supportsBlobs ) { if ( this.provider instanceof AdvancedSettingsProvider && this.provider.supportsBlobs ) {
const keys = await this.provider.blobKeys(); const keys = await this.provider.blobKeys();
for(const key of keys) { for(const key of keys) {
const safe_key = encodeURIComponent(key), const safe_key = encodeURIComponent(key),
@ -489,16 +571,19 @@ export default class SettingsManager extends Module {
async getSettingsDump() { async getSettingsDump() {
// Before we do anything else, make sure the provider is ready. // Before we do anything else, make sure the provider is ready.
await this.awaitProvider(); await this.awaitProvider();
if ( ! this.provider )
return null;
await this.provider.awaitReady(); await this.provider.awaitReady();
const out = { const out: ExportedFullDump = {
version: 2, version: 2,
type: 'full', type: 'full',
values: {} values: {}
}; };
for(const [k, v] of this.provider.entries()) for(const [key, value] of this.provider.entries())
out.values[k] = v; out.values[key] = value;
return out; return out;
} }
@ -514,9 +599,9 @@ export default class SettingsManager extends Module {
async checkUpdates() { async checkUpdates() {
await this.awaitProvider(); await this.awaitProvider();
await this.provider.awaitReady(); await this.provider?.awaitReady();
if ( ! this.provider.shouldUpdate ) if ( ! this.provider?.shouldUpdate )
return; return;
const promises = []; const promises = [];
@ -575,9 +660,9 @@ export default class SettingsManager extends Module {
wanted = localStorage.ffzProviderv2 = await this.sniffProvider(); wanted = localStorage.ffzProviderv2 = await this.sniffProvider();
if ( this.providers[wanted] ) { if ( this.providers[wanted] ) {
const provider = new this.providers[wanted](this); const provider = new (this.providers[wanted] as any)(this) as SettingsProvider;
if ( wanted === 'idb' ) if ( wanted === 'idb' )
this._idb = provider; this._idb = provider as IndexedDBProvider;
this._active_provider = wanted; this._active_provider = wanted;
return provider; return provider;
@ -585,7 +670,7 @@ export default class SettingsManager extends Module {
// Fallback to localStorage if nothing else was wanted and available. // Fallback to localStorage if nothing else was wanted and available.
this._active_provider = 'local'; this._active_provider = 'local';
return new this.providers.local(this); return new LocalStorageProvider(this);
} }
@ -599,12 +684,15 @@ export default class SettingsManager extends Module {
* @returns {String} The key for which provider we should use. * @returns {String} The key for which provider we should use.
*/ */
async sniffProvider() { async sniffProvider() {
const providers = Object.values(this.providers); const providers = Array.from(Object.entries(this.providers));
providers.sort((a,b) => b.priority - a.priority); providers.sort((a, b) =>
((b[1] as any).priority ?? 0) -
((a[1] as any).priority ?? 0)
);
for(const provider of providers) { for(const [key, provider] of providers) {
if ( provider.supported(this) && provider.hasContent && await provider.hasContent(this) ) // eslint-disable-line no-await-in-loop if ( provider.supported() && await provider.hasContent() ) // eslint-disable-line no-await-in-loop
return provider.key; return key;
} }
// Fallback to local if no provider indicated present settings. // Fallback to local if no provider indicated present settings.
@ -620,13 +708,13 @@ export default class SettingsManager extends Module {
* @param {Boolean} transfer Whether or not settings should be transferred * @param {Boolean} transfer Whether or not settings should be transferred
* from the current provider. * from the current provider.
*/ */
async changeProvider(key, transfer) { async changeProvider(key: string, transfer: boolean) {
if ( ! this.providers[key] || ! this.providers[key].supported(this) ) if ( ! this.providers[key] || ! this.providers[key].supported() )
throw new Error(`Invalid provider: ${key}`); throw new Error(`Invalid provider: ${key}`);
// If we're changing to the current provider... well, that doesn't make // If we're changing to the current provider... well, that doesn't make
// a lot of sense, does it? Abort! // a lot of sense, does it? Abort!
if ( key === this._active_provider ) if ( key === this._active_provider || ! this.provider )
return; return;
const old_provider = this.provider; const old_provider = this.provider;
@ -637,7 +725,7 @@ export default class SettingsManager extends Module {
// Are we transfering settings? // Are we transfering settings?
if ( transfer ) { if ( transfer ) {
const new_provider = new this.providers[key](this); const new_provider = new (this.providers[key] as any)(this) as SettingsProvider;
await new_provider.awaitReady(); await new_provider.awaitReady();
if ( new_provider.allowTransfer && old_provider.allowTransfer ) { if ( new_provider.allowTransfer && old_provider.allowTransfer ) {
@ -645,13 +733,13 @@ export default class SettingsManager extends Module {
// When transfering, we clear all existing settings. // When transfering, we clear all existing settings.
await new_provider.clear(); await new_provider.clear();
if ( new_provider.supportsBlobs ) if ( new_provider instanceof AdvancedSettingsProvider && new_provider.supportsBlobs )
await new_provider.clearBlobs(); await new_provider.clearBlobs();
for(const [key,val] of old_provider.entries()) for(const [key,val] of old_provider.entries())
new_provider.set(key, val); new_provider.set(key, val);
if ( old_provider.supportsBlobs && new_provider.supportsBlobs ) { if ( old_provider instanceof AdvancedSettingsProvider && old_provider.supportsBlobs && new_provider instanceof AdvancedSettingsProvider && new_provider.supportsBlobs ) {
for(const key of await old_provider.blobKeys() ) { for(const key of await old_provider.blobKeys() ) {
const blob = await old_provider.getBlob(key); // eslint-disable-line no-await-in-loop const blob = await old_provider.getBlob(key); // eslint-disable-line no-await-in-loop
if ( blob ) if ( blob )
@ -679,7 +767,7 @@ export default class SettingsManager extends Module {
* the result of a setting being changed in another tab or, when cloud * the result of a setting being changed in another tab or, when cloud
* settings are enabled, on another computer. * settings are enabled, on another computer.
*/ */
_onProviderChange(key, new_value, deleted) { _onProviderChange(key: string, new_value: any, deleted: boolean) {
// If profiles have changed, reload our profiles. // If profiles have changed, reload our profiles.
if ( key === 'profiles' ) if ( key === 'profiles' )
return this.loadProfiles(); return this.loadProfiles();
@ -690,17 +778,17 @@ export default class SettingsManager extends Module {
// If we're still here, it means an individual setting was changed. // If we're still here, it means an individual setting was changed.
// Look up the profile it belongs to and emit a changed event from // Look up the profile it belongs to and emit a changed event from
// that profile, thus notifying any contexts or UI instances. // that profile, thus notifying any contexts or UI instances.
key = key.substr(2); key = key.slice(2);
// Is it a value? // Is it a value?
const idx = key.indexOf(':'); const idx = key.indexOf(':');
if ( idx === -1 ) if ( idx === -1 )
return; return;
const profile = this.__profile_ids[key.slice(0, idx)], const profile = this.__profile_ids[key.slice(0, idx) as any],
s_key = key.slice(idx + 1); s_key = key.slice(idx + 1);
if ( profile ) { if ( profile && ! profile.ephemeral ) {
if ( s_key === ':enabled' ) if ( s_key === ':enabled' )
profile.emit('toggled', profile, deleted ? true : new_value); profile.emit('toggled', profile, deleted ? true : new_value);
else else
@ -716,7 +804,7 @@ export default class SettingsManager extends Module {
updateRoutes() { updateRoutes() {
// Clear the existing matchers. // Clear the existing matchers.
for(const profile of this.__profiles) for(const profile of this.__profiles)
profile.matcher = null; profile.clearMatcher();
// And then re-select the active profiles. // And then re-select the active profiles.
for(const context of this.__contexts) for(const context of this.__contexts)
@ -726,12 +814,12 @@ export default class SettingsManager extends Module {
} }
_onProfileToggled(profile, val) { _onProfileToggled(profile: SettingsProfile, enabled: boolean) {
for(const context of this.__contexts) for(const context of this.__contexts)
context.selectProfiles(); context.selectProfiles();
this.updateClock(); this.updateClock();
this.emit(':profile-toggled', profile, val); this.emit(':profile-toggled', profile, enabled);
} }
@ -739,8 +827,8 @@ export default class SettingsManager extends Module {
* Get an existing {@link SettingsProfile} instance. * Get an existing {@link SettingsProfile} instance.
* @param {number} id - The id of the profile. * @param {number} id - The id of the profile.
*/ */
profile(id) { profile(id: number): SettingsProfile | null {
return this.__profile_ids[id] || null; return this.__profile_ids[id] ?? null;
} }
@ -748,12 +836,12 @@ export default class SettingsManager extends Module {
* Build {@link SettingsProfile} instances for all of the profiles * Build {@link SettingsProfile} instances for all of the profiles
* defined in storage, re-using existing instances when possible. * defined in storage, re-using existing instances when possible.
*/ */
loadProfiles(suppress_events) { loadProfiles(suppress_events: boolean = false) {
const old_profile_ids = this.__profile_ids, const old_profile_ids = this.__profile_ids,
old_profiles = this.__profiles, old_profiles = this.__profiles,
profile_ids = this.__profile_ids = {}, profile_ids: Record<number, SettingsProfile> = this.__profile_ids = {},
profiles = this.__profiles = [], profiles: SettingsProfile[] = this.__profiles = [],
// Create a set of actual IDs with a map from the profiles // Create a set of actual IDs with a map from the profiles
// list rather than just getting the keys from the ID map // list rather than just getting the keys from the ID map
@ -761,17 +849,17 @@ export default class SettingsManager extends Module {
// to keys. // to keys.
old_ids = new Set(old_profiles.map(x => x.id)), old_ids = new Set(old_profiles.map(x => x.id)),
new_ids = new Set, new_ids = new Set<number>,
changed_ids = new Set; changed_ids = new Set<number>;
let raw_profiles = this.provider.get('profiles', [ let raw_profiles = this.provider?.get<SettingsProfileMetadata[]>('profiles') ?? [
SettingsProfile.Moderation, SettingsProfile.Moderation,
SettingsProfile.Default SettingsProfile.Default
]); ];
// Sanity check. If we have no profiles, delete the old data. // Sanity check. If we have no profiles, delete the old data.
if ( ! raw_profiles?.length ) { if ( ! raw_profiles?.length ) {
this.provider.delete('profiles'); this.provider?.delete('profiles');
raw_profiles = [ raw_profiles = [
SettingsProfile.Moderation, SettingsProfile.Moderation,
SettingsProfile.Default SettingsProfile.Default
@ -787,7 +875,7 @@ export default class SettingsManager extends Module {
} }
for(const profile_data of raw_profiles) { for(const profile_data of raw_profiles) {
const id = profile_data.id, const id = profile_data.id as number,
slot_id = profiles.length, slot_id = profiles.length,
old_profile = old_profile_ids[id], old_profile = old_profile_ids[id],
old_slot_id = old_profile ? old_profiles.indexOf(old_profile) : -1; old_slot_id = old_profile ? old_profiles.indexOf(old_profile) : -1;
@ -798,12 +886,15 @@ export default class SettingsManager extends Module {
reordered = true; reordered = true;
// Monkey patch to the new profile format... // Monkey patch to the new profile format...
// Update: Probably safe to remove this, at this point.
/*
if ( profile_data.context && ! Array.isArray(profile_data.context) ) { if ( profile_data.context && ! Array.isArray(profile_data.context) ) {
if ( profile_data.context.moderator ) if ( profile_data.context.moderator )
profile_data.context = SettingsProfile.Moderation.context; profile_data.context = SettingsProfile.Moderation.context;
else else
profile_data.context = null; profile_data.context = null;
} }
*/
if ( old_profile && deep_equals(old_profile.data, profile_data, true) ) { if ( old_profile && deep_equals(old_profile.data, profile_data, true) ) {
// Did the order change? // Did the order change?
@ -816,10 +907,7 @@ export default class SettingsManager extends Module {
const new_profile = profile_ids[id] = new SettingsProfile(this, profile_data); const new_profile = profile_ids[id] = new SettingsProfile(this, profile_data);
if ( old_profile ) { if ( old_profile ) {
// Move all the listeners over. old_profile.transferListeners(new_profile);
new_profile.__listeners = old_profile.__listeners;
old_profile.__listeners = {};
changed_ids.add(id); changed_ids.add(id);
} else } else
@ -856,29 +944,37 @@ export default class SettingsManager extends Module {
/** /**
* Create a new profile and return the {@link SettingsProfile} instance * Create a new profile and return the {@link SettingsProfile} instance
* representing it. * representing it.
* @returns {SettingsProfile}
*/ */
createProfile(options) { createProfile(options: Partial<SettingsProfileMetadata> = {}) {
if ( ! this.enabled ) if ( ! this.enabled )
throw new Error('Unable to create profile before settings have initialized. Please await enable()'); throw new Error('Unable to create profile before settings have initialized. Please await enable()');
let i = 0; if ( options.id !== undefined )
while( this.__profile_ids[i] ) throw new Error('You cannot specify an ID when creating a profile.');
i++;
options = options || {}; let id = 0;
options.id = i;
// Find the next available profile ID.
while ( this.__profile_ids[id] ) {
// Ephemeral profiles have negative IDs.
options.ephemeral ? id-- : id++;
}
options.id = id;
if ( ! options.name ) if ( ! options.name )
options.name = `Unnamed Profile ${i}`; options.name = `Unnamed Profile ${this.__profiles.length + 1}`;
const profile = this.__profile_ids[i] = new SettingsProfile(this, options); const profile = this.__profile_ids[id] = new SettingsProfile(this, options);
this.__profiles.unshift(profile); this.__profiles.unshift(profile);
profile.on('toggled', this._onProfileToggled, this); profile.on('toggled', this._onProfileToggled, this);
profile.hotkey_enabled = true; profile.hotkey_enabled = true;
// Don't bother saving if it's ephemeral.
if ( ! profile.ephemeral )
this._saveProfiles(); this._saveProfiles();
this.emit(':profile-created', profile); this.emit(':profile-created', profile);
return profile; return profile;
} }
@ -886,14 +982,17 @@ export default class SettingsManager extends Module {
/** /**
* Delete a profile. * Delete a profile.
* @param {number|SettingsProfile} id - The profile to delete *
* @param id - The ID of the profile to delete, or just the profile itself.
*/ */
deleteProfile(id) { deleteProfile(id: number | SettingsProfile) {
if ( ! this.enabled ) if ( ! this.enabled )
throw new Error('Unable to delete profile before settings have initialized. Please await enable()'); throw new Error('Unable to delete profile before settings have initialized. Please await enable()');
if ( typeof id === 'object' && id.id != null ) if ( typeof id === 'object' && typeof id.id === 'number' )
id = id.id; id = id.id;
else if ( typeof id !== 'number' )
throw new Error('Invalid profile');
const profile = this.__profile_ids[id]; const profile = this.__profile_ids[id];
if ( ! profile ) if ( ! profile )
@ -913,17 +1012,22 @@ export default class SettingsManager extends Module {
if ( idx !== -1 ) if ( idx !== -1 )
this.__profiles.splice(idx, 1); this.__profiles.splice(idx, 1);
// If it wasn't an ephemeral profile, go ahead and update.
if ( ! profile.ephemeral )
this._saveProfiles(); this._saveProfiles();
this.emit(':profile-deleted', profile); this.emit(':profile-deleted', profile);
} }
moveProfile(id, index) { moveProfile(id: number | SettingsProfile, index: number) {
if ( ! this.enabled ) if ( ! this.enabled )
throw new Error('Unable to move profiles before settings have initialized. Please await enable()'); throw new Error('Unable to move profiles before settings have initialized. Please await enable()');
if ( typeof id === 'object' && id.id ) if ( typeof id === 'object' && typeof id.id === 'number' )
id = id.id; id = id.id;
else if ( typeof id !== 'number' )
throw new Error('Invalid profile');
const profile = this.__profile_ids[id]; const profile = this.__profile_ids[id];
if ( ! profile ) if ( ! profile )
@ -936,29 +1040,39 @@ export default class SettingsManager extends Module {
profiles.splice(index, 0, ...profiles.splice(idx, 1)); profiles.splice(index, 0, ...profiles.splice(idx, 1));
// If it wasn't an ephemeral profile, go ahead and update.
if ( ! profile.ephemeral )
this._saveProfiles(); this._saveProfiles();
this.emit(':profiles-reordered'); this.emit(':profiles-reordered');
} }
saveProfile(id) { saveProfile(id: number | SettingsProfile) {
if ( ! this.enabled ) if ( ! this.enabled )
throw new Error('Unable to save profile before settings have initialized. Please await enable()'); throw new Error('Unable to save profile before settings have initialized. Please await enable()');
if ( typeof id === 'object' && id.id ) if ( typeof id === 'object' && typeof id.id === 'number' )
id = id.id; id = id.id;
else if ( typeof id !== 'number' )
throw new Error('Invalid profile');
const profile = this.__profile_ids[id]; const profile = this.__profile_ids[id];
if ( ! profile ) if ( ! profile )
return; return;
// If it wasn't an ephemeral profile, go ahead and update.
if ( ! profile.ephemeral )
this._saveProfiles(); this._saveProfiles();
this.emit(':profile-changed', profile); this.emit(':profile-changed', profile);
} }
_saveProfiles() { _saveProfiles() {
const out = this.__profiles.filter(prof => ! prof.ephemeral).map(prof => prof.data); const out = this.__profiles
.filter(prof => ! prof.ephemeral)
.map(prof => prof.data);
// Ensure that we always have a non-ephemeral profile. // Ensure that we always have a non-ephemeral profile.
if ( ! out ) { if ( ! out ) {
@ -967,10 +1081,12 @@ export default class SettingsManager extends Module {
i18n_key: 'setting.profiles.default', i18n_key: 'setting.profiles.default',
description: 'Settings that apply everywhere on Twitch.' description: 'Settings that apply everywhere on Twitch.'
}); });
// Just return. Creating the profile will call this method again.
return; return;
} }
this.provider.set('profiles', out); this.provider?.set('profiles', out);
for(const context of this.__contexts) for(const context of this.__contexts)
context.selectProfiles(); context.selectProfiles();
@ -986,18 +1102,18 @@ export default class SettingsManager extends Module {
get(key) { return this.main_context.get(key); } get(key) { return this.main_context.get(key); }
getChanges(key, fn, ctx) { return this.main_context.getChanges(key, fn, ctx); } getChanges(key, fn, ctx) { return this.main_context.getChanges(key, fn, ctx); }
onChange(key, fn, ctx) { return this.main_context.onChange(key, fn, ctx); } onChange(key, fn, ctx) { return this.main_context.onChange(key, fn, ctx); }
uses(key) { return this.main_context.uses(key) } uses(key: string) { return this.main_context.uses(key) }
update(key) { return this.main_context.update(key) } update(key: string) { return this.main_context.update(key) }
updateContext(context) { return this.main_context.updateContext(context) } updateContext(context: Partial<ContextData>) { return this.main_context.updateContext(context) }
setContext(context) { return this.main_context.setContext(context) } setContext(context: Partial<ContextData>) { return this.main_context.setContext(context) }
// ======================================================================== // ========================================================================
// Add-On Proxy // Add-On Proxy
// ======================================================================== // ========================================================================
getAddonProxy(addon_id) { getAddonProxy(addon_id: string) {
if ( ! addon_id ) if ( ! addon_id )
return this; return this;
@ -1030,31 +1146,26 @@ export default class SettingsManager extends Module {
// Definitions // Definitions
// ======================================================================== // ========================================================================
add(key, definition, source) { add<T>(key: string, definition: SettingsDefinition<T>, source?: string) {
if ( typeof key === 'object' ) {
for(const k in key)
if ( has(key, k) )
this.add(k, key[k]);
return;
}
const old_definition = this.definitions.get(key), const old_definition = this.definitions.get(key),
required_by = old_definition ? required_by = (Array.isArray(old_definition)
(Array.isArray(old_definition) ? old_definition : old_definition.required_by) : []; ? old_definition
: old_definition?.required_by) ?? [];
definition.required_by = required_by; definition.required_by = required_by;
definition.requires = definition.requires || []; definition.requires = definition.requires ?? [];
definition.__source = source; definition.__source = source;
for(const req_key of definition.requires) { for(const req_key of definition.requires) {
const req = this.definitions.get(req_key); const req = this.definitions.get(req_key);
if ( ! req ) if ( Array.isArray(req) )
this.definitions.set(req_key, [key]);
else if ( Array.isArray(req) )
req.push(key); req.push(key);
else if ( req )
req.required_by?.push(key);
else else
req.required_by.push(key); this.definitions.set(req_key, [key]);
} }
@ -1094,7 +1205,7 @@ export default class SettingsManager extends Module {
} }
remove(key) { remove(key: string) {
const definition = this.definitions.get(key); const definition = this.definitions.get(key);
if ( ! definition ) if ( ! definition )
return; return;
@ -1162,57 +1273,59 @@ export default class SettingsManager extends Module {
} }
addClearable(key, definition, source) { addClearable(key: string | Record<string, SettingsClearable>, definition?: SettingsClearable, source?: string) {
if ( typeof key === 'object' ) { if ( typeof key === 'object' ) {
for(const k in key) for(const [k, value] of Object.entries(key))
if ( has(key, k) ) this.addClearable(k, value, source);
this.addClearable(k, key[k], source);
return; return;
} } else if ( typeof key !== 'string' )
throw new Error('invalid key');
if ( definition ) {
definition.__source = source; definition.__source = source;
this.clearables[key] = definition; this.clearables[key] = definition;
} }
}
getClearables() { getClearables() {
return deep_copy(this.clearables); return deep_copy(this.clearables);
} }
addProcessor(key, fn) { addProcessor(key: string | Record<string, SettingsProcessor<any>>, processor?: SettingsProcessor<any>) {
if ( typeof key === 'object' ) { if ( typeof key === 'object' ) {
for(const k in key) for(const [k, value] of Object.entries(key))
if ( has(key, k) ) this.addProcessor(k, value);
this.addProcessor(k, key[k]);
return; return;
} else if ( typeof key !== 'string' )
throw new Error('invalid key');
if ( processor )
this.processors[key] = processor;
} }
this.processors[key] = fn; getProcessor<T>(key: string): SettingsProcessor<T> | null {
} return this.processors[key] ?? null;
getProcessor(key) {
return this.processors[key];
} }
getProcessors() { getProcessors() {
return deep_copy(this.processors); return deep_copy(this.processors);
} }
addValidator(key: string | Record<string, SettingsValidator<any>>, validator?: SettingsValidator<any>) {
addValidator(key, fn) {
if ( typeof key === 'object' ) { if ( typeof key === 'object' ) {
for(const k in key) for(const [k, value] of Object.entries(key))
if ( has(key, k) ) this.addValidator(k, value);
this.addValidator(k, key[k]);
return; return;
} else if ( typeof key !== 'string' )
throw new Error('invalid key');
if ( validator )
this.validators[key] = validator;
} }
this.validators[key] = fn; getValidator<T>(key: string): SettingsValidator<T> | null {
} return this.validators[key] ?? null;
getValidator(key) {
return this.validators[key];
} }
getValidators() { getValidators() {
@ -1221,42 +1334,6 @@ export default class SettingsManager extends Module {
} }
export function parse_path(path) {
return new_parse(path);
}
/*const PATH_SPLITTER = /(?:^|\s*([~>]+))\s*([^~>@]+)\s*(?:@([^~>]+))?/g;
export function old_parse_path(path) {
const tokens = [];
let match;
while((match = PATH_SPLITTER.exec(path))) {
const page = match[1] === '>>',
tab = match[1] === '~>',
title = match[2].trim(),
key = title.toSnakeCase(),
options = match[3],
opts = { key, title, page, tab };
if ( options ) {
try {
Object.assign(opts, JSON.parse(options));
} catch(err) {
console.warn('Matched segment:', options);
throw err;
}
}
tokens.push(opts);
}
return tokens;
}*/
export function format_path_tokens(tokens) { export function format_path_tokens(tokens) {
for(let i=0, l = tokens.length; i < l; i++) { for(let i=0, l = tokens.length; i < l; i++) {
const token = tokens[i]; const token = tokens[i];

View file

@ -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');
}
}

View file

@ -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);
}

View file

@ -0,0 +1,66 @@
'use strict';
import type { SettingsDefinition, SettingsProcessor, SettingsUiDefinition } from "./types";
const BAD = Symbol('BAD');
type BadType = typeof BAD;
function do_number(
input: number | BadType,
default_value: number,
definition: SettingsUiDefinition<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: SettingsProcessor<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: SettingsProcessor<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);
}

View file

@ -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;
} }
set(key, value) { return this.provider.get<T>(this.prefix + key, default_value as T);
if ( this.ephemeral ) }
set(key: string, value: unknown) {
if ( this.ephemeral ) {
if ( this._storage )
this._storage.set(key, value); this._storage.set(key, value);
else } 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 ) {
if ( this._storage )
this._storage.delete(key); this._storage.delete(key);
else } 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 ) {
if ( this._storage ) {
const keys = this.keys(); const keys = this.keys();
this._storage.clear(); this._storage.clear();
for(const key of keys) { for(const key of keys) {
this.emit('changed', key, undefined, true); 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 ) {
if ( this._storage ) {
for(const entry of this._storage.entries()) for(const entry of this._storage.entries())
yield entry; 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

168
src/settings/types.ts Normal file
View file

@ -0,0 +1,168 @@
import type SettingsManager from ".";
import type { FilterData } from "../utilities/filtering";
import type { OptionalPromise, OptionallyCallable, RecursivePartial } from "../utilities/types";
import type SettingsContext from "./context";
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 type ContextData = RecursivePartial<{
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;
};
}>;
// Definitions
export type SettingsDefinition<T> = {
default: T,
type?: string;
process?(this: SettingsManager, ctx: SettingsContext, val: T): T;
// Dependencies
required_by?: string[];
requires?: string[];
// Tracking
__source?: string | null;
// UI Stuff
ui?: SettingsUiDefinition<T>;
// Reactivity
changed?: () => void;
};
export type SettingsUiDefinition<T> = {
path: string;
component: string;
process?: string;
/**
* 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];
title: string;
description?: string;
}
// 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>;
};
// 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;
};
// Processors
export type SettingsProcessor<T> = (
input: unknown,
default_value: T,
definition: SettingsUiDefinition<T>
) => T;
// Validators
export type SettingsValidator<T> = (
value: T,
definition: SettingsUiDefinition<T>
) => boolean;

View file

@ -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);
}

View file

@ -0,0 +1,54 @@
'use strict';
import type { SettingsUiDefinition, SettingsValidator } from "./types";
function do_number(value: any, definition: SettingsUiDefinition<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: SettingsValidator<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: SettingsValidator<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);
}

View file

@ -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.`);
} }

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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;

View file

@ -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');

View file

@ -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';
@ -33,12 +33,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');

View file

@ -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');

View file

@ -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');

View file

@ -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
View 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;
}
}
}

View file

@ -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
View 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');
}

View file

@ -547,6 +547,19 @@ LUVAColor.prototype._a = function(a) { return new LUVAColor(this.l, this.u, this
export class ColorAdjuster { 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) { constructor(base = '#232323', mode = 0, contrast = 4.5) {
this._contrast = contrast; this._contrast = contrast;
this._base = base; this._base = base;

View file

@ -781,31 +781,16 @@ export class FineWrapper extends EventEmitter {
return super.on(event, fn, ctx); return super.on(event, fn, ctx);
} }
prependOn(event, fn, ctx) {
this._maybeWrap(event);
return super.prependOn(event, fn, ctx);
}
once(event, fn, ctx) { once(event, fn, ctx) {
this._maybeWrap(event); this._maybeWrap(event);
return super.once(event, fn, ctx); return super.once(event, fn, ctx);
} }
prependOnce(event, fn, ctx) {
this._maybeWrap(event);
return super.prependOnce(event, fn, ctx);
}
many(event, ttl, fn, ctx) { many(event, ttl, fn, ctx) {
this._maybeWrap(event); this._maybeWrap(event);
return super.many(event, ttl, fn, ctx); return super.many(event, ttl, fn, ctx);
} }
prependMany(event, ttl, fn, ctx) {
this._maybeWrap(event);
return super.prependMany(event, ttl, fn, ctx);
}
waitFor(event) { waitFor(event) {
this._maybeWrap(event); this._maybeWrap(event);
return super.waitFor(event); return super.waitFor(event);

View file

@ -5,11 +5,78 @@
// It controls Twitch PubSub. // It controls Twitch PubSub.
// ============================================================================ // ============================================================================
import Module from 'utilities/module'; import Module, { GenericModule, ModuleEvents } 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;
}
}
/**
* 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 +91,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 +143,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 +156,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 +191,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,6 +236,7 @@ export default class Subpump extends Module {
} }
} }
/*
hookOldClient(client) { hookOldClient(client) {
const t = this, const t = this,
orig_message = client._onMessage; orig_message = client._onMessage;
@ -194,25 +278,26 @@ export default class Subpump extends Module {
return out; return out;
} }
} }
*/
inject(topic, message) { inject(topic: string, message: any) {
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 [];

View file

@ -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',
'\\&lt\\;\\]': '<]', '\\&lt\\;\\]': '<]',
@ -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
};

View file

@ -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;
}
}

218
src/utilities/css-tweaks.ts Normal file
View file

@ -0,0 +1,218 @@
'use strict';
// ============================================================================
// CSS Tweaks
// Tweak some CSS
// ============================================================================
import Module, { GenericModule } from 'utilities/module';
import {ManagedStyle} from 'utilities/dom';
import {has, once} from 'utilities/object';
/**
* 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;
}
}

View file

@ -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;
Dialog.MAXIMIZED = Site.DIALOG_MAXIMIZED;
Dialog.SELECTOR = Site.DIALOG_SELECTOR;
export default Dialog; export default Dialog;

View file

@ -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">&lt;nested&gt;</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
View 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<TFunc extends (event: MouseEvent) => void> {
el: HTMLElement | null;
cb: TFunc | null;
_fn: ((event: MouseEvent) => void) | null;
constructor(element: HTMLElement, callback: TFunc) {
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">&lt;nested&gt;</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>`;
}

View file

@ -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;

1009
src/utilities/events.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -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;

View file

@ -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,7 +83,7 @@ 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 ? ')' : ''};`

View file

@ -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,7 +251,12 @@ 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;

View file

@ -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];

View file

@ -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
View 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;

View file

@ -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;
}
}

1188
src/utilities/module.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,933 +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 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

File diff suppressed because it is too large Load diff

View file

@ -1,18 +1,32 @@
'use strict'; 'use strict';
export function parse(path) { export function parse(path: string) {
return parseAST({ return parseAST({
path, path,
i: 0 i: 0
}); });
} }
function parseAST(ctx) { type ParseContext = {
path: string;
i: number;
}
export type PathNode = {
title: string;
key: string;
page: boolean;
tab: boolean;
}
function parseAST(ctx: ParseContext) {
const path = ctx.path, const path = ctx.path,
length = path.length, length = path.length,
out = []; out = [];
let token, raw; let token: PathNode | null = null,
raw: string | null = null;
let old_tab = false, let old_tab = false,
old_page = false; old_page = false;
@ -21,10 +35,11 @@ function parseAST(ctx) {
char = path[start], char = path[start],
next = path[start + 1]; next = path[start + 1];
if ( ! token ) { if ( ! token )
raw = []; token = {} as PathNode;
token = {};
} if ( ! raw )
raw = '';
// JSON // JSON
if ( char === '@' && next === '{') { if ( char === '@' && next === '{') {
@ -42,7 +57,7 @@ function parseAST(ctx) {
segment = ! page && char === '>'; segment = ! page && char === '>';
if ( ! segment && ! page && ! tab ) { if ( ! segment && ! page && ! tab ) {
raw.push(char); raw += char;
ctx.i++; ctx.i++;
continue; continue;
} }
@ -52,7 +67,7 @@ function parseAST(ctx) {
if ( tab || page ) if ( tab || page )
ctx.i++; ctx.i++;
token.title = raw.join('').trim(); token.title = raw.trim();
token.key = token.title.toSnakeCase(); token.key = token.title.toSnakeCase();
token.page = old_page; token.page = old_page;
@ -66,8 +81,8 @@ function parseAST(ctx) {
ctx.i++; ctx.i++;
} }
if ( token ) { if ( token && raw ) {
token.title = raw.join('').trim(); token.title = raw.trim();
token.key = token.title.toSnakeCase(); token.key = token.title.toSnakeCase();
token.page = old_page; token.page = old_page;
token.tab = old_tab; token.tab = old_tab;
@ -77,7 +92,7 @@ function parseAST(ctx) {
return out; return out;
} }
function parseJSON(ctx) { function parseJSON(ctx: ParseContext) {
const path = ctx.path, const path = ctx.path,
length = path.length, length = path.length,
@ -86,7 +101,7 @@ function parseJSON(ctx) {
ctx.i++; ctx.i++;
const stack = ['{']; const stack = ['{'];
let string = false; let string: string | boolean = false;
while ( ctx.i < length && stack.length ) { while ( ctx.i < length && stack.length ) {
const start = ctx.i, const start = ctx.i,

View file

@ -1,11 +1,16 @@
'use strict';
export function getBuster(resolution = 5) { export function getBuster(resolution: number = 5) {
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
return now - (now % resolution); return now - (now % resolution);
} }
export function duration_to_string(elapsed, separate_days, days_only, no_hours, no_seconds) { export function duration_to_string(
elapsed: number,
separate_days?: boolean,
days_only?: boolean,
no_hours?: boolean,
no_seconds?: boolean
) {
const seconds = elapsed % 60; const seconds = elapsed % 60;
let minutes = Math.floor(elapsed / 60), let minutes = Math.floor(elapsed / 60),
hours = Math.floor(minutes / 60), hours = Math.floor(minutes / 60),
@ -14,12 +19,12 @@ export function duration_to_string(elapsed, separate_days, days_only, no_hours,
minutes = minutes % 60; minutes = minutes % 60;
if ( separate_days ) { if ( separate_days ) {
days = Math.floor(hours / 24); const day_count = Math.floor(hours / 24);
hours = hours % 24; hours = hours % 24;
if ( days_only && days > 0 ) if ( days_only && day_count > 0 )
return `${days} days`; return `${days} days`;
days = days > 0 ? `${days} days, ` : ''; days = day_count > 0 ? `${day_count} days, ` : '';
} }
const show_hours = (!no_hours || days || hours); const show_hours = (!no_hours || days || hours);
@ -31,7 +36,7 @@ export function duration_to_string(elapsed, separate_days, days_only, no_hours,
} }
export function print_duration(seconds) { export function print_duration(seconds: number) {
let minutes = Math.floor(seconds / 60); let minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);
@ -42,7 +47,7 @@ export function print_duration(seconds) {
} }
export function durationForChat(elapsed) { export function durationForChat(elapsed: number) {
const seconds = elapsed % 60; const seconds = elapsed % 60;
let minutes = Math.floor(elapsed / 60); let minutes = Math.floor(elapsed / 60);
let hours = Math.floor(minutes / 60); let hours = Math.floor(minutes / 60);
@ -55,7 +60,7 @@ export function durationForChat(elapsed) {
} }
export function durationForURL(elapsed) { export function durationForURL(elapsed: number) {
const seconds = elapsed % 60; const seconds = elapsed % 60;
let minutes = Math.floor(elapsed / 60); let minutes = Math.floor(elapsed / 60);
const hours = Math.floor(minutes / 60); const hours = Math.floor(minutes / 60);

View file

@ -29,7 +29,7 @@ export default class Timing extends Module {
}); });
} }
__time() { /* no-op */ } // eslint-disable-line class-methods-use-this _time() { /* no-op */ } // eslint-disable-line class-methods-use-this
addEvent(event) { addEvent(event) {
event.ts = performance.now(); event.ts = performance.now();

View file

@ -11,7 +11,20 @@
import {createElement, setChildren} from 'utilities/dom'; import {createElement, setChildren} from 'utilities/dom';
import {maybe_call, debounce, has} from 'utilities/object'; import {maybe_call, debounce, has} from 'utilities/object';
import {createPopper} from '@popperjs/core'; import {Instance, createPopper} from '@popperjs/core';
import type Logger from './logging';
import type { OptionallyCallable } from './types';
// Extensions to things.
declare global {
interface HTMLElement {
_ffz_tooltip?: TooltipInstance | null;
_ffz_move_handler?: MouseEventHandler | null;
_ffz_over_handler?: MouseEventHandler | null;
_ffz_out_handler?: MouseEventHandler | null;
}
}
let last_id = 0; let last_id = 0;
@ -30,40 +43,146 @@ export const DefaultOptions = {
} }
type TooltipOptional<TReturn> = OptionallyCallable<[target: HTMLElement, tip: TooltipInstance], TReturn>;
export type TooltipOptions = {
html: boolean;
delayShow: TooltipOptional<number>;
delayHide: TooltipOptional<number>;
live: boolean;
manual: boolean;
check_modifiers: boolean;
logger?: Logger;
// TODO: Replace this with proper i18n.
i18n?: {
t: (key: string, phrase: string, data: any) => string;
};
container?: HTMLElement;
tooltipClass: string;
innerClass: string;
arrowClass: string;
arrowInner?: string;
sanitizeChildren: boolean;
popper?: any;
popperConfig?: TipFunction<any, [any]>;
no_update?: boolean;
no_auto_remove?: boolean;
content: ContentFunction;
interactive: TooltipOptional<boolean>;
hover_events: TooltipOptional<boolean>;
onShow: TipFunction<void>;
onHide: TipFunction<void>;
onMove: TipFunction<void, [MouseEvent]>;
onHover: TipFunction<void, [MouseEvent]>;
onLeave: TipFunction<void, [MouseEvent]>;
}
export interface TooltipInstance {
target: HTMLElement;
visible: boolean;
element: HTMLElement | null;
outer: HTMLElement | null;
align?: string;
add_class?: string | string[];
state: boolean;
_show_timer?: ReturnType<typeof setTimeout> | null;
popper?: Instance | null;
_update: () => void;
_waiter: (() => void) | null;
_wait_promise: Promise<void> | null;
waitForDom: () => Promise<void>;
update: () => void;
show: () => void;
hide: () => void;
rerender: () => void;
}
// TODO: Narrow the return type. Need better types for createElement / setChildren
type ContentFunction = TipFunction<any>;
type TipFunction<TReturn = unknown, TArgs extends any[] = []> = (target: HTMLElement, tip: TooltipInstance, ...extra: TArgs) => TReturn;
type MouseEventHandler = (event: MouseEvent) => void;
type KeyboardEventHandler = (event: KeyboardEvent) => void;
// ============================================================================ // ============================================================================
// Tooltip Class // Tooltip Class
// ============================================================================ // ============================================================================
export class Tooltip { export class Tooltip {
constructor(parent, cls, options) {
if ( typeof parent === 'string' )
parent = document.querySelector(parent);
if (!( parent instanceof Node )) cls: Element | Element[] | string;
options: TooltipOptions;
live: boolean;
parent: HTMLElement;
container: HTMLElement;
elements: Set<HTMLElement>;
private _accessor: string;
private _onMouseOut: MouseEventHandler;
private _onMouseOver?: MouseEventHandler;
private _keyUpdate?: KeyboardEventHandler | null;
shift_state: boolean = false;
ctrl_state: boolean = false;
private _shift_af?: ReturnType<typeof requestAnimationFrame> | null;
constructor(parent: HTMLElement | string | null, cls: HTMLElement | HTMLElement[] | string, options: Partial<TooltipOptions>) {
if ( typeof parent === 'string' )
parent = document.querySelector(parent) as HTMLElement;
if (!( parent instanceof HTMLElement ))
throw new TypeError('invalid parent'); throw new TypeError('invalid parent');
this.options = Object.assign({}, DefaultOptions, options); this.options = Object.assign({}, DefaultOptions, options) as TooltipOptions;
this.live = this.options.live; this.live = this.options.live;
this.check_modifiers = this.options.check_modifiers;
this.parent = parent; this.parent = parent;
this.container = this.options.container || this.parent; this.container = this.options.container || this.parent;
this.cls = cls; this.cls = cls;
if ( this.check_modifiers ) if ( this.options.check_modifiers )
this.installModifiers(); this.installModifiers();
if ( ! this.live ) { if ( ! this.live ) {
let elements;
if ( typeof cls === 'string' ) if ( typeof cls === 'string' )
this.elements = parent.querySelectorAll(cls); elements = Array.from(parent.querySelectorAll(cls)) as HTMLElement[];
else if ( Array.isArray(cls) ) else if ( Array.isArray(cls) )
this.elements = cls; elements = cls;
else if ( cls instanceof Node ) else if ( cls instanceof HTMLElement )
this.elements = [cls]; elements = [cls];
else else
throw new TypeError('invalid elements'); throw new TypeError('invalid elements');
this.elements = new Set(this.elements); this.elements = new Set(elements);
} else { } else {
this.cls = cls; this.cls = cls;
@ -72,7 +191,11 @@ export class Tooltip {
this._accessor = `_ffz_tooltip$${last_id++}`; this._accessor = `_ffz_tooltip$${last_id++}`;
this._onMouseOut = e => e.target && e.target?.dataset?.forceOpen !== 'true' && this._exit(e.target); this._onMouseOut = e => {
const target = e.target as HTMLElement;
if ( target && target.dataset?.forceOpen !== 'true' )
return this._exit(target);
};
if ( this.options.manual ) { if ( this.options.manual ) {
// Do nothing~! // Do nothing~!
@ -80,8 +203,8 @@ export class Tooltip {
} else if ( this.live ) { } else if ( this.live ) {
this._onMouseOver = e => { this._onMouseOver = e => {
this.updateShift(e.shiftKey, e.ctrlKey); this.updateShift(e.shiftKey, e.ctrlKey);
const target = e.target; const target = e.target as HTMLElement;
if ( target && target.classList && target.classList.contains(this.cls) && target.dataset.forceOpen !== 'true' ) { if ( target && target.classList && target.classList.contains(this.cls as string) && target.dataset?.forceOpen !== 'true' ) {
this._enter(target); this._enter(target);
} }
}; };
@ -92,10 +215,9 @@ export class Tooltip {
} else { } else {
this._onMouseOver = e => { this._onMouseOver = e => {
this.updateShift(e.shiftKey, e.ctrlKey); this.updateShift(e.shiftKey, e.ctrlKey);
const target = e.target; const target = e.target as HTMLElement;
if ( this.elements.has(target) && target.dataset.forceOpen !== 'true' ) { if ( this.elements.has(target) && target.dataset.forceOpen !== 'true' )
this._enter(e.target); this._enter(target);
}
} }
if ( this.elements.size <= 5 ) if ( this.elements.size <= 5 )
@ -118,27 +240,30 @@ export class Tooltip {
if ( this.options.manual ) { if ( this.options.manual ) {
// Do nothing~! // Do nothing~!
} else if ( this.live || this.elements.size > 5 ) { } else if ( this.live || this.elements.size > 5 ) {
if ( this._onMouseOver )
this.parent.removeEventListener('mouseover', this._onMouseOver); this.parent.removeEventListener('mouseover', this._onMouseOver);
this.parent.removeEventListener('mouseout', this._onMouseOut); this.parent.removeEventListener('mouseout', this._onMouseOut);
} else } else
for(const el of this.elements) { for(const el of this.elements) {
if ( this._onMouseOver )
el.removeEventListener('mouseenter', this._onMouseOver); el.removeEventListener('mouseenter', this._onMouseOver);
el.removeEventListener('mouseleave', this._onMouseOut); el.removeEventListener('mouseleave', this._onMouseOut);
} }
for(const el of this.elements) { for(const el of this.elements) {
const tip = el[this._accessor]; const tip = (el as any)[this._accessor] as TooltipInstance;
if ( tip && tip.visible ) if ( tip && tip.visible )
this.hide(tip); this.hide(tip);
el[this._accessor] = null; (el as any)[this._accessor] = null;
el._ffz_tooltip = null; el._ffz_tooltip = null;
} }
this.elements = null; // Lazy types. We don't care.
this._onMouseOut = this._onMouseOver = null; this.elements = null as any;
this.container = null; this._onMouseOut = this._onMouseOver = null as any;
this.parent = null; this.container = null as any;
this.parent = null as any;
} }
@ -160,7 +285,7 @@ export class Tooltip {
this._keyUpdate = null; this._keyUpdate = null;
} }
updateShift(state, ctrl_state) { updateShift(state: boolean, ctrl_state: boolean) {
if ( state === this.shift_state && ctrl_state === this.ctrl_state ) if ( state === this.shift_state && ctrl_state === this.ctrl_state )
return; return;
@ -172,12 +297,11 @@ export class Tooltip {
this._shift_af = null; this._shift_af = null;
if ( this.elements ) if ( this.elements )
for(const el of this.elements) { for(const el of this.elements) {
const tip = el[this._accessor]; const tip = (el as any)[this._accessor] as TooltipInstance;
if ( tip && tip.outer ) { if ( tip && tip.outer ) {
tip.outer.dataset.shift = this.shift_state; tip.outer.dataset.shift = `${this.shift_state}`;
tip.outer.dataset.ctrl = this.ctrl_state; tip.outer.dataset.ctrl = `${this.ctrl_state}`;
tip.update(); tip.update();
//tip.updateVideo();
} }
} }
}); });
@ -189,7 +313,7 @@ export class Tooltip {
return; return;
for(const el of this.elements) { for(const el of this.elements) {
const tip = el[this._accessor]; const tip = (el as any)[this._accessor] as TooltipInstance;
if ( document.body.contains(el) ) if ( document.body.contains(el) )
continue; continue;
@ -199,10 +323,10 @@ export class Tooltip {
} }
_enter(target) { _enter(target: HTMLElement) {
let tip = target[this._accessor]; let tip = (target as any)[this._accessor] as TooltipInstance;
if ( ! tip ) if ( ! tip )
tip = target[this._accessor] = {target}; tip = (target as any)[this._accessor] = {target} as TooltipInstance;
tip.state = true; tip.state = true;
@ -227,8 +351,8 @@ export class Tooltip {
}, delay); }, delay);
} }
_exit(target) { _exit(target: HTMLElement) {
const tip = target[this._accessor]; const tip = (target as any)[this._accessor] as TooltipInstance;
if ( ! tip ) if ( ! tip )
return; return;
@ -257,7 +381,7 @@ export class Tooltip {
} }
show(tip) { show(tip: TooltipInstance) {
const opts = this.options, const opts = this.options,
target = tip.target; target = tip.target;
@ -265,26 +389,21 @@ export class Tooltip {
target._ffz_tooltip = tip; target._ffz_tooltip = tip;
// Set this early in case content uses it early. // Set this early in case content uses it early.
tip._promises = []; tip._wait_promise = null;
tip.waitForDom = () => tip.element ? Promise.resolve() : new Promise(s => {tip._promises.push(s)}); tip.waitForDom = () => {
if ( tip.element )
return Promise.resolve();
if ( ! tip._wait_promise )
tip._wait_promise = new Promise(resolve => tip._waiter = resolve);
return tip._wait_promise;
};
tip.update = () => tip._update(); // tip.popper && tip.popper.scheduleUpdate(); tip.update = () => tip._update(); // tip.popper && tip.popper.scheduleUpdate();
tip.show = () => { tip.show = () => {
let tip = target[this._accessor]; let tip = (target as any)[this._accessor] as TooltipInstance;
if ( ! tip ) if ( ! tip )
tip = target[this._accessor] = {target}; tip = (target as any)[this._accessor] = {target} as TooltipInstance;
this.show(tip); this.show(tip);
}; };
tip.updateVideo = () => {
if ( ! tip.element )
return;
const videos = tip.element.querySelectorAll('video');
for(const video of videos) {
if ( this.ctrl_state )
video.play();
else
video.pause();
}
};
tip.hide = () => this.hide(tip); tip.hide = () => this.hide(tip);
tip.rerender = () => { tip.rerender = () => {
if ( tip.visible ) { if ( tip.visible ) {
@ -301,7 +420,10 @@ export class Tooltip {
return; return;
// Build the DOM. // Build the DOM.
const arrow = createElement('div', opts.arrowClass), const arrow = createElement('div', {
className: opts.arrowClass,
'x-arrow': true
}),
inner = tip.element = createElement('div', opts.innerClass), inner = tip.element = createElement('div', opts.innerClass),
el = tip.outer = createElement('div', { el = tip.outer = createElement('div', {
@ -310,8 +432,6 @@ export class Tooltip {
'data-ctrl': this.ctrl_state 'data-ctrl': this.ctrl_state
}, [inner, arrow]); }, [inner, arrow]);
arrow.setAttribute('x-arrow', true);
if ( opts.arrowInner ) if ( opts.arrowInner )
arrow.appendChild(createElement('div', opts.arrowInner)); arrow.appendChild(createElement('div', opts.arrowInner));
@ -386,12 +506,12 @@ export class Tooltip {
if ( opts.popperConfig ) if ( opts.popperConfig )
pop_opts = opts.popperConfig(target, tip, pop_opts) ?? pop_opts; pop_opts = opts.popperConfig(target, tip, pop_opts) ?? pop_opts;
pop_opts.onUpdate = tip._on_update = debounce(() => { pop_opts.onUpdate = debounce(() => {
if ( ! opts.no_auto_remove && ! document.contains(tip.target) ) if ( ! opts.no_auto_remove && ! document.contains(tip.target) )
this.hide(tip); this.hide(tip);
}, 250); }, 250);
let popper_target = target; let popper_target: any = target;
if ( opts.no_update ) if ( opts.no_update )
popper_target = makeReference(target); popper_target = makeReference(target);
@ -401,12 +521,13 @@ export class Tooltip {
} }
} }
for(const fn of tip._promises) if ( tip._waiter )
fn(); tip._waiter();
tip._promises = null; tip._waiter = null;
tip._wait_promise = null;
if ( content instanceof Promise || (content?.then && content.toString() === '[object Promise]') ) { if ( content instanceof Promise ) { //} || (content?.then && content.toString() === '[object Promise]') ) {
inner.innerHTML = '<div class="ffz-i-zreknarf loader"></div>'; inner.innerHTML = '<div class="ffz-i-zreknarf loader"></div>';
content.then(content => { content.then(content => {
if ( ! content ) if ( ! content )
@ -451,7 +572,7 @@ export class Tooltip {
} }
hide(tip) { // eslint-disable-line class-methods-use-this hide(tip: TooltipInstance) {
const opts = this.options; const opts = this.options;
if ( opts.onHide ) if ( opts.onHide )
opts.onHide(tip.target, tip); opts.onHide(tip.target, tip);
@ -482,7 +603,7 @@ export class Tooltip {
if ( tip.target._ffz_tooltip === tip ) if ( tip.target._ffz_tooltip === tip )
tip.target._ffz_tooltip = null; tip.target._ffz_tooltip = null;
tip.target[this._accessor] = null; (tip.target as any)[this._accessor] = null;
tip._update = tip.rerender = tip.update = noop; tip._update = tip.rerender = tip.update = noop;
tip.element = null; tip.element = null;
tip.visible = false; tip.visible = false;
@ -492,17 +613,20 @@ export class Tooltip {
export default Tooltip; export default Tooltip;
export function normalizeModifiers(input) { // Is this gross? Yes.
const output = []; // You know what else is gross?
// Popper's type definitions.
export function normalizeModifiers(input: any) {
const output: any[] = [];
for(const [key, val] of Object.entries(input)) { for(const [key, val] of Object.entries(input)) {
const thing = { const thing: any = {
name: key name: key
}; };
if (val && typeof val === 'object' && ! Array.isArray(val)) { if (val && typeof val === 'object' && ! Array.isArray(val)) {
if (has(val, 'enabled')) if (has(val, 'enabled'))
thing.enabled = val.enabled; thing.enabled = (val as any).enabled;
const keys = Object.keys(val); const keys = Object.keys(val);
if (keys.length > 1 || (keys.length === 1 && keys[0] !== 'enabled')) if (keys.length > 1 || (keys.length === 1 && keys[0] !== 'enabled'))
@ -516,22 +640,25 @@ export function normalizeModifiers(input) {
} }
export function makeReference(x, y, height=0, width=0) { export function makeReference(x: HTMLElement | number, y?: number, height: number = 0, width: number = 0) {
if ( x instanceof Node ) { let _x: number;
if ( x instanceof HTMLElement ) {
const rect = x.getBoundingClientRect(); const rect = x.getBoundingClientRect();
x = rect.x; _x = rect.x;
y = rect.y; y = rect.y;
height = rect.height; height = rect.height;
width = rect.width; width = rect.width;
} } else
_x = x;
const out = { const out = {
getBoundingClientRect: () => ({ getBoundingClientRect: () => ({
top: y, top: y,
bottom: y + height, bottom: (y as number) + height,
y, y,
left: x, left: _x,
right: x + width, right: _x + width,
x, x,
height, height,
width width

View file

@ -7,25 +7,26 @@
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import RelativeTime from 'dayjs/plugin/relativeTime'; import RelativeTime from 'dayjs/plugin/relativeTime';
import {get} from 'utilities/object';
import {duration_to_string} from 'utilities/time';
import Parser, { MessageAST, MessageNode, MessageVariable, ParserOptions } from '@ffz/icu-msgparser';
dayjs.extend(RelativeTime); dayjs.extend(RelativeTime);
import Parser from '@ffz/icu-msgparser';
const DEFAULT_PARSER_OPTIONS = { const DEFAULT_PARSER_OPTIONS = {
allowTags: false, allowTags: false,
requireOther: false requireOther: false
}; } as Partial<ParserOptions>;
import {get} from 'utilities/object';
import {duration_to_string} from 'utilities/time';
export type TypeFormatter = (this: TranslationCore, val: any, node: MessageVariable, locale: string, out: any[], ast: MessageAST, data: any) => any;
// ============================================================================ // ============================================================================
// Types // Types
// ============================================================================ // ============================================================================
export const DEFAULT_TYPES = { export const DEFAULT_TYPES: Record<string, TypeFormatter> = {
tostring(val) { tostring(val) {
return `${val}` return `${val}`
}, },
@ -65,7 +66,7 @@ export const DEFAULT_TYPES = {
val = new_val; val = new_val;
} }
return this.formatNumber(val, node.f); return this.formatNumber(val, node.f as string);
}, },
currency(val, node) { currency(val, node) {
@ -79,19 +80,19 @@ export const DEFAULT_TYPES = {
val = new_val; val = new_val;
} }
return this.formatCurrency(val, node.f); return this.formatCurrency(val, node.f as string);
}, },
date(val, node) { date(val, node) {
return this.formatDate(val, node.f); return this.formatDate(val, node.f as string);
}, },
time(val, node) { time(val, node) {
return this.formatTime(val, node.f); return this.formatTime(val, node.f as string);
}, },
datetime(val, node) { datetime(val, node) {
return this.formatDateTime(val, node.f); return this.formatDateTime(val, node.f as string);
}, },
duration(val) { duration(val) {
@ -103,17 +104,19 @@ export const DEFAULT_TYPES = {
}, },
relativetime(val, node) { relativetime(val, node) {
return this.formatRelativeTime(val, node.f); return this.formatRelativeTime(val, node.f as string);
}, },
humantime(val, node) { humantime(val, node) {
return this.formatRelativeTime(val, node.f); return this.formatRelativeTime(val, node.f as string);
}, },
en_plural: v => v !== 1 ? 's' : '' en_plural: (v: number) => v !== 1 ? 's' : ''
} }
export const DEFAULT_FORMATS = { export const DEFAULT_FORMATS = {
number: { number: {
currency: { currency: {
@ -209,15 +212,83 @@ export const DEFAULT_FORMATS = {
timeZoneName: 'short' timeZoneName: 'short'
} }
} }
} as FormattingOptions;
// ============================================================================
// Options
// ============================================================================
type WarningMethod = typeof console.warn;
type RecursivePhraseMap = {
[key: string]: RecursivePhraseMap | string
};
export type FormattingOptions = {
number: Record<string, Intl.NumberFormatOptions>,
date: Record<string, Intl.DateTimeFormatOptions>,
time: Record<string, Intl.DateTimeFormatOptions>,
datetime: Record<string, Intl.DateTimeFormatOptions>
};
export type TranslationOptions = {
warn?: WarningMethod;
locale?: string;
dayjsLocale?: string;
defaultLocale?: string;
types?: Record<string, TypeFormatter>;
formats?: Partial<FormattingOptions>;
phrases?: RecursivePhraseMap;
parserOptions?: Partial<ParserOptions>,
defaultDateFormat: string;
defaultTimeFormat: string;
defaultDateTimeFormat: string;
} }
export type ParseTranslationSettings = {
noCache?: boolean;
throwParse?: boolean;
noWarn?: boolean;
}
// ============================================================================ // ============================================================================
// TranslationCore // TranslationCore
// ============================================================================ // ============================================================================
export default class TranslationCore { export class TranslationCore {
constructor(options) {
warn?: WarningMethod;
parser: Parser;
phrases: Map<string, string>;
cache: Map<string, MessageAST>;
transformation: ((key: string, ast: MessageAST) => MessageAST) | null;
types: Record<string, TypeFormatter>;
formats: FormattingOptions;
private _locale: string;
private _dayjs_locale: string;
defaultLocale: string;
defaultDateFormat: string;
defaultTimeFormat: string;
defaultDateTimeFormat: string;
numberFormats: Map<string, Intl.NumberFormat>;
currencyFormats: Map<string, Intl.NumberFormat>;
constructor(options?: Partial<TranslationOptions>) {
options = options || {}; options = options || {};
this.warn = options.warn; this.warn = options.warn;
@ -226,9 +297,9 @@ export default class TranslationCore {
this.defaultLocale = options.defaultLocale || this._locale; this.defaultLocale = options.defaultLocale || this._locale;
this.transformation = null; this.transformation = null;
this.defaultDateFormat = options.defaultDateFormat; this.defaultDateFormat = options.defaultDateFormat ?? 'default';
this.defaultTimeFormat = options.defaultTimeFormat; this.defaultTimeFormat = options.defaultTimeFormat ?? 'short';
this.defaultDateTimeFormat = options.defaultDateTimeFormat; this.defaultDateTimeFormat = options.defaultDateTimeFormat ?? 'medium';
this.phrases = new Map; this.phrases = new Map;
this.cache = new Map; this.cache = new Map;
@ -237,9 +308,12 @@ export default class TranslationCore {
this.currencyFormats = new Map; this.currencyFormats = new Map;
this.formats = Object.assign({}, DEFAULT_FORMATS); this.formats = Object.assign({}, DEFAULT_FORMATS);
if ( options.formats ) if ( options.formats ) {
for(const key of Object.keys(options.formats)) // I have no idea why the types are so picky here.
this.formats[key] = Object.assign({}, this.formats[key], options.formats[key]); const keys = Object.keys(options.formats) as (keyof FormattingOptions)[];
for(const key of keys)
(this.formats as any)[key] = Object.assign({}, this.formats[key], options.formats[key]);
}
this.types = Object.assign({}, DEFAULT_TYPES, options.types || {}); this.types = Object.assign({}, DEFAULT_TYPES, options.types || {});
this.parser = new Parser(Object.assign({}, DEFAULT_PARSER_OPTIONS, options.parserOptions)); this.parser = new Parser(Object.assign({}, DEFAULT_PARSER_OPTIONS, options.parserOptions));
@ -260,15 +334,15 @@ export default class TranslationCore {
} }
} }
toLocaleString(thing) { toLocaleString(thing: any) {
if ( thing && thing.toLocaleString ) if ( thing && thing.toLocaleString )
return thing.toLocaleString(this._locale); return thing.toLocaleString(this._locale);
return thing; return thing;
} }
formatRelativeTime(value, f) { // eslint-disable-line class-methods-use-this formatRelativeTime(value: string | number | Date, format?: string) { // eslint-disable-line class-methods-use-this
const d = dayjs(value), const d = dayjs(value),
without_suffix = f === 'plain'; without_suffix = format === 'plain';
try { try {
return d.locale(this._dayjs_locale).fromNow(without_suffix); return d.locale(this._dayjs_locale).fromNow(without_suffix);
@ -277,10 +351,10 @@ export default class TranslationCore {
} }
} }
formatCurrency(value, currency) { formatCurrency(value: number | bigint, currency: string) {
let formatter = this.currencyFormats.get(currency); let formatter = this.currencyFormats.get(currency);
if ( ! formatter ) { if ( ! formatter ) {
formatter = new Intl.NumberFormat(navigator.languages, { formatter = new Intl.NumberFormat(navigator.languages as string[], {
style: 'currency', style: 'currency',
currency currency
}); });
@ -291,7 +365,7 @@ export default class TranslationCore {
return formatter.format(value); return formatter.format(value);
} }
formatNumber(value, format) { formatNumber(value: number | bigint, format: string) {
let formatter = this.numberFormats.get(format); let formatter = this.numberFormats.get(format);
if ( ! formatter ) { if ( ! formatter ) {
if ( this.formats.number[format] ) if ( this.formats.number[format] )
@ -310,11 +384,11 @@ export default class TranslationCore {
return formatter.format(value); return formatter.format(value);
} }
formatDuration(value) { // eslint-disable-line class-methods-use-this formatDuration(value: number) { // eslint-disable-line class-methods-use-this
return duration_to_string(value); return duration_to_string(value);
} }
formatDate(value, format) { formatDate(value: string | number | Date, format?: string) {
if ( ! format ) if ( ! format )
format = this.defaultDateFormat; format = this.defaultDateFormat;
@ -333,7 +407,7 @@ export default class TranslationCore {
return value.toLocaleDateString(this._locale, this.formats.date[format] || {}); return value.toLocaleDateString(this._locale, this.formats.date[format] || {});
} }
formatTime(value, format) { formatTime(value: string | number | Date, format?: string) {
if ( ! format ) if ( ! format )
format = this.defaultTimeFormat; format = this.defaultTimeFormat;
@ -352,7 +426,7 @@ export default class TranslationCore {
return value.toLocaleTimeString(this._locale, this.formats.time[format] || {}); return value.toLocaleTimeString(this._locale, this.formats.time[format] || {});
} }
formatDateTime(value, format) { formatDateTime(value: string | number | Date, format?: string) {
if ( ! format ) if ( ! format )
format = this.defaultDateTimeFormat; format = this.defaultDateTimeFormat;
@ -371,8 +445,8 @@ export default class TranslationCore {
return value.toLocaleString(this._locale, this.formats.datetime[format] || {}); return value.toLocaleString(this._locale, this.formats.datetime[format] || {});
} }
extend(phrases, prefix) { extend(phrases: RecursivePhraseMap, prefix?: string) {
const added = []; const added: string[] = [];
if ( ! phrases || typeof phrases !== 'object' ) if ( ! phrases || typeof phrases !== 'object' )
return added; return added;
@ -402,14 +476,14 @@ export default class TranslationCore {
return added; return added;
} }
unset(phrases, prefix) { unset(phrases: string | string[], prefix: string) {
if ( typeof phrases === 'string' ) if ( typeof phrases === 'string' )
phrases = [phrases]; phrases = [phrases];
const keys = Array.isArray(phrases) ? phrases : Object.keys(phrases); const keys = Array.isArray(phrases) ? phrases : Object.keys(phrases);
for(const key of keys) { for(const key of keys) {
const full_key = prefix ? key === '_' ? prefix : `${prefix}.${key}` : key, const full_key = prefix ? key === '_' ? prefix : `${prefix}.${key}` : key,
phrase = phrases[key]; phrase = (phrases as any)[key];
if ( typeof phrase === 'object' ) if ( typeof phrase === 'object' )
this.unset(phrases, full_key); this.unset(phrases, full_key);
@ -420,11 +494,11 @@ export default class TranslationCore {
} }
} }
has(key) { has(key: string) {
return this.phrases.has(key); return this.phrases.has(key);
} }
set(key, phrase) { set(key: string, phrase: string) {
const parsed = this.parser.parse(phrase); const parsed = this.parser.parse(phrase);
this.phrases.set(key, phrase); this.phrases.set(key, phrase);
this.cache.set(key, parsed); this.cache.set(key, parsed);
@ -435,26 +509,34 @@ export default class TranslationCore {
this.cache.clear(); this.cache.clear();
} }
replace(phrases) { replace(phrases: RecursivePhraseMap) {
this.clear(); this.clear();
this.extend(phrases); this.extend(phrases);
} }
_preTransform(key, phrase, options, settings = {}) { _preTransform(
let ast, locale, data = options == null ? {} : options; key: string,
phrase: string,
options: any,
settings: ParseTranslationSettings = {}
): [MessageAST, any, string] {
let ast: MessageAST,
locale: string,
data = options == null ? {} : options;
if ( typeof data === 'number' ) if ( typeof data === 'number' )
data = {count: data}; data = {count: data};
if ( ! settings.noCache && this.phrases.has(key) ) { if ( ! settings.noCache && this.phrases.has(key) ) {
ast = this.cache.get(key); // TODO: Remind myself why this exists.
ast = this.cache.get(key) ?? [];
locale = this.locale; locale = this.locale;
} else if ( ! settings.noCache && this.cache.has(key) ) { } else if ( ! settings.noCache && this.cache.has(key) ) {
ast = this.cache.get(key); ast = this.cache.get(key) ?? [];
locale = this.defaultLocale; locale = this.defaultLocale;
} else { } else {
let parsed = null; let parsed: MessageAST | null = null;
try { try {
parsed = this.parser.parse(phrase); parsed = this.parser.parse(phrase);
} catch(err) { } catch(err) {
@ -478,6 +560,10 @@ export default class TranslationCore {
this.cache.set(key, parsed); this.cache.set(key, parsed);
} }
} else {
// This should never happen unless bad data is supplied.
ast = [];
locale = this.defaultLocale;
} }
} }
@ -487,15 +573,21 @@ export default class TranslationCore {
return [ast, data, locale]; return [ast, data, locale];
} }
t(key, phrase, options, settings) { t(key: string, phrase: string, data: any, settings?: ParseTranslationSettings) {
return listToString(this.tList(key, phrase, options, settings)); return listToString(this.tList(key, phrase, data, settings));
} }
tList(key, phrase, options, settings) { tList(key: string, phrase: string, data: any, settings?: ParseTranslationSettings) {
return this._processAST(...this._preTransform(key, phrase, options, settings)); return this._processAST(...this._preTransform(key, phrase, data, settings));
} }
formatNode(node, data, locale = null, out = null, ast = null) { formatNode(
node: MessageNode,
data: any,
locale: string | null,
out: any[],
ast: MessageAST
) {
if ( ! node || typeof node !== 'object' ) if ( ! node || typeof node !== 'object' )
return node; return node;
@ -507,16 +599,17 @@ export default class TranslationCore {
return null; return null;
if ( node.t ) { if ( node.t ) {
if ( this.types[node.t] ) const handler = this.types[node.t];
return this.types[node.t].call(this, val, node, locale, out, ast, data); if ( handler )
return handler.call(this, val, node as MessageVariable, locale, out, ast, data);
else if ( this.warn ) else if ( this.warn )
this.warn(`Encountered unknown type "${node.t}" when formatting node.`); this.warn(`Encountered unknown type "${(node as MessageVariable).t}" when formatting node.`);
} }
return val; return val;
} }
_processAST(ast, data, locale) { _processAST(ast: MessageAST, data: any, locale: string) {
const out = []; const out = [];
for(const node of ast) { for(const node of ast) {
@ -530,10 +623,12 @@ export default class TranslationCore {
} }
export default TranslationCore;
function listToString(list) {
function listToString(list: any[]): string {
if ( ! Array.isArray(list) ) if ( ! Array.isArray(list) )
return String(list); return `${list}`;
return list.map(listToString).join(''); return list.map(listToString).join('');
} }
@ -543,6 +638,7 @@ function listToString(list) {
// Plural Handling // Plural Handling
// ============================================================================ // ============================================================================
/*
const CARDINAL_TO_LANG = { const CARDINAL_TO_LANG = {
arabic: ['ar'], arabic: ['ar'],
czech: ['cs'], czech: ['cs'],
@ -705,9 +801,39 @@ function executePlural(fn, input) {
t t
)] )]
} }
*/
let cardinal_i18n: Intl.PluralRules | null = null,
cardinal_locale: string | null = null;
export function getCardinalName(locale: string, input: number) {
if ( ! cardinal_i18n || locale !== cardinal_locale ) {
cardinal_i18n = new Intl.PluralRules(locale, {
type: 'cardinal'
});
cardinal_locale = locale;
}
return cardinal_i18n.select(input);
}
let ordinal_i18n: Intl.PluralRules | null = null,
ordinal_locale: string | null = null;
export function getOrdinalName(locale: string, input: number) {
if ( ! ordinal_i18n || locale !== ordinal_locale ) {
ordinal_i18n = new Intl.PluralRules(locale, {
type: 'ordinal'
});
ordinal_locale = locale;
}
return ordinal_i18n.select(input);
}
export function getCardinalName(locale, input) { /*
export function getCardinalName(locale: string, input: number) {
let type = CARDINAL_LANG_TO_TYPE[locale]; let type = CARDINAL_LANG_TO_TYPE[locale];
if ( ! type ) { if ( ! type ) {
const idx = locale.indexOf('-'); const idx = locale.indexOf('-');
@ -727,4 +853,4 @@ export function getOrdinalName(locale, input) {
} }
return executePlural(ORDINAL_TYPES[type], input); return executePlural(ORDINAL_TYPES[type], input);
} }*/

181
src/utilities/types.ts Normal file
View file

@ -0,0 +1,181 @@
import type ExperimentManager from "../experiments";
import type TranslationManager from "../i18n";
import type LoadTracker from "../load_tracker";
import type { LoadEvents } from "../load_tracker";
import type Chat from "../modules/chat";
import type Actions from "../modules/chat/actions/actions";
import type Badges from "../modules/chat/badges";
import type Emoji from "../modules/chat/emoji";
import type Emotes from "../modules/chat/emotes";
import type Overrides from "../modules/chat/overrides";
import type EmoteCard from "../modules/emote_card";
import type LinkCard from "../modules/link_card";
import type MainMenu from "../modules/main_menu";
import type Metadata from "../modules/metadata";
import type TooltipProvider from "../modules/tooltips";
import type { TooltipEvents } from "../modules/tooltips";
import type TranslationUI from "../modules/translation_ui";
import type PubSub from "../pubsub";
import type { SettingsEvents } from "../settings";
import type SettingsManager from "../settings";
import type SocketClient from "../socket";
import type StagingSelector from "../staging";
import type Apollo from "./compat/apollo";
import type Elemental from "./compat/elemental";
import type Fine from "./compat/fine";
import type Subpump from "./compat/subpump";
import type { SubpumpEvents } from "./compat/subpump";
import type WebMunch from "./compat/webmunch";
import type CSSTweaks from "./css-tweaks";
import type { NamespacedEvents } from "./events";
import type TwitchData from "./twitch-data";
import type Vue from "./vue";
/**
* AddonInfo represents the data contained in an add-on's manifest.
*/
export type AddonInfo = {
// ========================================================================
// System Data
// ========================================================================
/** The add-on's ID. This is used to identify content, including settings, modules, emotes, etc. that are associated with the add-on. */
id: string;
/** The add-on's version number. This should be a semantic version, but this is not enforced. */
version: string;
// ========================================================================
// Metadata
// ========================================================================
/** The human-readable name of the add-on, in English. */
name: string;
/** Optional. A human-readable shortened name for the add-on, in English. */
short_name?: string;
/** The name of the add-on's author. */
author: string;
/** The name of the person or persons maintaining the add-on, if different than the author. */
maintainer?: string;
/** A description of the add-on. This can be multiple lines and supports Markdown. */
description: string;
/** Optional. A settings UI key. If set, a Settings button will be displayed for this add-on that takes the user to this add-on's settings. */
settings?: string;
/** Optional. This add-on's website. If set, a Website button will be displayed that functions as a link. */
website?: string;
/** The date when the add-on was first created. */
created: Date;
/** The date when the add-on was last updated. */
updated?: Date;
// ========================================================================
// Runtime Requirements / State
// ========================================================================
/** Whether or not the add-on has been loaded from a development center. */
dev: boolean;
/** Whether or not the add-on has been loaded externally (outside of FFZ's control). */
external: boolean;
/** A list of add-ons, by ID, that require this add-on to be enabled to function. */
required_by: string[];
/** A list of add-ons, by ID, that this add-on requires to be enabled to function. */
requires: string[];
/** List of FrankerFaceZ flavors ("main", "clips", "player") that this add-on supports. */
targets: string[];
};
export type OptionallyThisCallable<TThis, TArgs extends any[], TReturn> = TReturn | ((this: TThis, ...args: TArgs) => TReturn);
export type OptionallyCallable<TArgs extends any[], TReturn> = TReturn | ((...args: TArgs) => TReturn);
export type OptionalPromise<T> = T | Promise<T>;
export type OptionalArray<T> = T | T[];
export type RecursivePartial<T> = {
[K in keyof T]?: T[K] extends object
? RecursivePartial<T[K]>
: T[K];
};
export type ClientVersion = {
major: number;
minor: number;
revision: number;
extra: number;
commit: string | null;
build: string;
hash: string;
};
export type Mousetrap = {
bind(
keys: string | string[],
callback: (event: KeyboardEvent, combo: string) => boolean | void
): void;
unbind(keys: string | string[], action?: string): void;
};
export type DomFragment = Node | string | null | undefined | DomFragment[];
// TODO: Move this event into addons.
type AddonEvent = {
'addon:fully-unload': [addon_id: string]
};
export type KnownEvents =
AddonEvent &
NamespacedEvents<'load_tracker', LoadEvents> &
NamespacedEvents<'settings', SettingsEvents> &
NamespacedEvents<'site.subpump', SubpumpEvents> &
NamespacedEvents<'tooltips', TooltipEvents>;
export type ModuleMap = {
'chat': Chat;
'chat.actions': Actions;
'chat.badges': Badges;
'chat.emoji': Emoji;
'chat.emotes': Emotes;
'chat.overrides': Overrides;
'emote_card': EmoteCard;
'experiments': ExperimentManager;
'i18n': TranslationManager;
'link_card': LinkCard;
'load_tracker': LoadTracker;
'main_menu': MainMenu;
'metadata': Metadata;
'pubsub': PubSub;
'settings': SettingsManager;
'site.apollo': Apollo;
'site.css_tweaks': CSSTweaks;
'site.elemental': Elemental;
'site.fine': Fine;
'site.subpump': Subpump;
'site.twitch_data': TwitchData;
'site.web_munch': WebMunch;
'socket': SocketClient;
'staging': StagingSelector;
'tooltips': TooltipProvider;
'translation_ui': TranslationUI;
'vue': Vue;
};

37
tsconfig.json Normal file
View file

@ -0,0 +1,37 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"lib": [
"ES2020",
"DOM"
],
"allowJs": true,
"jsx": "preserve",
"alwaysStrict": true,
"strict": true,
"strictNullChecks": true,
"strictFunctionTypes": true,
"strictBindCallApply": true,
"strictPropertyInitialization": true,
"noImplicitThis": true,
"noImplicitReturns": false,
"moduleResolution": "Bundler",
"importsNotUsedAsValues": "error",
"allowSyntheticDefaultImports": true,
"noImplicitAny": true,
"paths": {
"res/*": ["./res/*"],
"styles/*": ["./styles/*"],
"root/*": ["./*"],
"src/*": ["./src/*"],
"utilities/*": ["./src/utilities/*"],
"site": ["./src/sites/twitch-twilight/index.js"],
"site/*": ["./src/sites/twitch-twilight/*"]
}
},
"include": [
"types/*",
"src/**/*"
]
}

25
typedoc.json Normal file
View file

@ -0,0 +1,25 @@
{
"$schema": "https://typedoc.org/schema.json",
"plugin": [
"typedoc-plugin-no-inherit",
"typedoc-plugin-mdn-links",
"typedoc-plugin-rename-defaults",
"typedoc-plugin-markdown"
],
//"out": "./distdocs",
"out": "../ffz-docs/docs/dev/client",
"hideBreadcrumbs": true,
"hideInPageTOC": true,
// Vite has a bug surrounding "." characters in names.
"filenameSeparator": "_",
"excludePrivate": true,
"excludeInternal": true,
"excludeNotDocumented": true,
"excludeNotDocumentedKinds": ["Property", "Interface", "TypeAlias"],
"disableGit": true,
"sourceLinkTemplate": "https://github.com/FrankerFaceZ/FrankerFaceZ/blob/master/{path}#L{line}",
"entryPoints": [
"./src/**/*.ts",
"./src/**/*.tsx"
],
}

55
types/ffz_icu-msgparser.d.ts vendored Normal file
View file

@ -0,0 +1,55 @@
declare module '@ffz/icu-msgparser' {
export type MessageAST = MessageNode[];
export type MessageNode = string | MessagePlaceholder;
export type MessagePlaceholder = MessageTag | MessageVariable;
export type MessageTag = {
n: string;
v: never;
t: never;
c?: MessageAST;
};
export type MessageVariable = {
n: never;
v: string;
t?: string;
f?: string | number;
o?: MessageSubmessages;
};
export type MessageSubmessages = {
[rule: string]: MessageAST;
};
export type ParserOptions = {
OPEN: string;
CLOSE: string;
SEP: string;
ESCAPE: string;
SUB_VAR: string;
TAG_OPEN: string;
TAG_CLOSE: string;
TAG_CLOSING: string;
OFFSET: string;
subnumeric_types: string[];
submessage_types: string[];
allowTags: boolean;
requireOther: boolean | string[];
}
export default class Parser {
constructor(options?: Partial<ParserOptions>);
parse(input: string): MessageAST;
}
}

31
types/getScreenDetails.d.ts vendored Normal file
View file

@ -0,0 +1,31 @@
export interface ScreenDetailed extends Screen {
readonly availLeft: number;
readonly availTop: number;
readonly devicePixelRatio: number;
readonly isInternal: boolean;
readonly isPrimary: boolean;
readonly label: string;
readonly left: number;
readonly top: number;
}
export interface ScreenDetails extends EventTarget {
readonly currentScreen: ScreenDetailed;
readonly screens: ScreenDetailed[];
}
declare global {
interface Window {
getScreenDetails: (() => Promise<ScreenDetails>) | undefined;
}
}
export {}

11
types/global.d.ts vendored Normal file
View file

@ -0,0 +1,11 @@
declare global {
const __version_major__: number;
const __version_minor__: number;
const __version_patch__: number;
const __version_prerelease__: number[];
const __git_commit__: string | null;
const __version_build__: string;
}
export {}

4
types/import-types.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module "*.scss" {
const content: string;
export default content;
}

9
types/jsx-global.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
declare namespace JSX {
interface Element extends HTMLElement {}
interface IntrinsicElements {
[elemName: string]: any;
}
}

View file

@ -48,7 +48,7 @@ const ENTRY_POINTS = {
bridge: './src/bridge.js', bridge: './src/bridge.js',
esbridge: './src/esbridge.js', esbridge: './src/esbridge.js',
player: './src/player.js', player: './src/player.js',
avalon: './src/main.js', avalon: './src/main.ts',
clips: './src/clips.js' clips: './src/clips.js'
}; };
@ -66,7 +66,7 @@ const config = {
target: ['web', TARGET], target: ['web', TARGET],
resolve: { resolve: {
extensions: ['.js', '.jsx'], extensions: ['.js', '.jsx', '.ts', '.tsx'],
alias: { alias: {
res: path.resolve(__dirname, 'res/'), res: path.resolve(__dirname, 'res/'),
styles: path.resolve(__dirname, 'styles/'), styles: path.resolve(__dirname, 'styles/'),
@ -163,6 +163,16 @@ const config = {
target: TARGET target: TARGET
} }
}, },
{
test: /\.tsx?$/,
exclude: /node_modules/,
loader: 'esbuild-loader',
options: {
loader: 'tsx',
jsxFactory: 'createElement',
target: TARGET
}
},
{ {
test: /\.(graphql|gql)$/, test: /\.(graphql|gql)$/,
exclude: /node_modules/, exclude: /node_modules/,