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

Merge pull request #1430 from FrankerFaceZ/types

The Grand TypeScript Update
This commit is contained in:
Mike 2023-12-14 17:53:19 -05:00 committed by GitHub
commit 40865adba7
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
118 changed files with 13451 additions and 7821 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
npm-debug.log
dist
typedist
Extension Building
badges
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 icons = config.glyphs.map(x => x.css);
fs.writeFileSync('src/utilities/ffz-icons.js', `'use strict';
// This is a generated file. To update it, please run: npm run font:update
fs.writeFileSync('src/utilities/ffz-icons.ts', `'use strict';
// This is a generated file. To update it, please run: pnpm font:update
/* eslint quotes: 0 */
export default ${JSON.stringify(icons, null, '\t')};`);
/**
* A list of all valid icon names in the FrankerFaceZ icon font. These
* icons can be used by adding a class to a DOM element with the name
* \`ffz-i-$\{name}\` where \`$\{name}\` is a name from this list.
*
* For example, to use the \`threads\` icon, you'd add the class
* \`ffz-i-threads\` to your element.
*/
export default ${JSON.stringify(icons, null, '\t')} as const;`);

View file

@ -1,7 +1,7 @@
{
"name": "frankerfacez",
"author": "Dan Salvato LLC",
"version": "4.60.1",
"version": "4.61.0",
"description": "FrankerFaceZ is a Twitch enhancement suite.",
"private": true,
"license": "Apache-2.0",
@ -15,6 +15,9 @@
"build:stats": "cross-env NODE_ENV=production webpack build --json > stats.json",
"build:prod": "cross-env NODE_ENV=production webpack build",
"build:dev": "cross-env NODE_ENV=development webpack build",
"build:types": "cross-env tsc --declaration --emitDeclarationOnly --outDir typedist && node bin/build_types",
"abuild:types": "node bin/build_types",
"build:docs": "cross-env typedoc --options typedoc.json",
"font": "pnpm font:edit",
"font:edit": "fontello-cli --cli-config fontello.client.json edit",
"font:save": "fontello-cli --cli-config fontello.client.json save && pnpm font:update",
@ -22,6 +25,11 @@
},
"devDependencies": {
"@ffz/fontello-cli": "^1.0.4",
"@types/crypto-js": "^4.2.1",
"@types/js-cookie": "^3.0.6",
"@types/safe-regex": "^1.1.6",
"@types/vue-clickaway": "^2.2.4",
"@types/webpack-env": "^1.18.4",
"browserslist": "^4.21.10",
"copy-webpack-plugin": "^11.0.0",
"cross-env": "^7.0.3",
@ -33,6 +41,7 @@
"eslint-plugin-vue": "^9.17.0",
"extract-loader": "^5.1.0",
"file-loader": "^6.2.0",
"glob": "^10.3.10",
"json-loader": "^0.5.7",
"minify-graphql-loader": "^1.0.2",
"raw-loader": "^4.0.2",
@ -40,6 +49,12 @@
"sass": "^1.66.1",
"sass-loader": "^13.3.2",
"semver": "^7.5.4",
"typedoc": "^0.25.3",
"typedoc-plugin-markdown": "^3.17.1",
"typedoc-plugin-mdn-links": "^3.1.0",
"typedoc-plugin-no-inherit": "^1.4.0",
"typedoc-plugin-rename-defaults": "^0.7.0",
"typescript": "^5.2.2",
"vue-loader": "^15.10.2",
"vue-template-compiler": "^2.6.14",
"webpack": "^5.88.2",
@ -53,8 +68,8 @@
},
"dependencies": {
"@ffz/icu-msgparser": "^2.0.0",
"@popperjs/core": "^2.10.2",
"crypto-js": "^3.3.0",
"@popperjs/core": "^2.11.8",
"crypto-js": "^4.2.0",
"dayjs": "^1.10.7",
"denoflare-mqtt": "^0.0.2",
"displacejs": "^1.4.1",
@ -62,7 +77,7 @@
"file-saver": "^2.0.5",
"graphql": "^16.0.1",
"graphql-tag": "^2.12.6",
"js-cookie": "^2.2.1",
"js-cookie": "^3.0.5",
"jszip": "^3.7.1",
"markdown-it": "^12.2.0",
"markdown-it-link-attributes": "^3.0.0",

215
pnpm-lock.yaml generated
View file

@ -15,11 +15,11 @@ dependencies:
specifier: ^2.0.0
version: 2.0.0
'@popperjs/core':
specifier: ^2.10.2
version: 2.10.2
specifier: ^2.11.8
version: 2.11.8
crypto-js:
specifier: ^3.3.0
version: 3.3.0
specifier: ^4.2.0
version: 4.2.0
dayjs:
specifier: ^1.10.7
version: 1.10.7
@ -42,8 +42,8 @@ dependencies:
specifier: ^2.12.6
version: 2.12.6(graphql@16.0.1)
js-cookie:
specifier: ^2.2.1
version: 2.2.1
specifier: ^3.0.5
version: 3.0.5
jszip:
specifier: ^3.7.1
version: 3.7.1
@ -97,6 +97,21 @@ devDependencies:
'@ffz/fontello-cli':
specifier: ^1.0.4
version: 1.0.4
'@types/crypto-js':
specifier: ^4.2.1
version: 4.2.1
'@types/js-cookie':
specifier: ^3.0.6
version: 3.0.6
'@types/safe-regex':
specifier: ^1.1.6
version: 1.1.6
'@types/vue-clickaway':
specifier: ^2.2.4
version: 2.2.4
'@types/webpack-env':
specifier: ^1.18.4
version: 1.18.4
browserslist:
specifier: ^4.21.10
version: 4.21.10
@ -130,6 +145,9 @@ devDependencies:
file-loader:
specifier: ^6.2.0
version: 6.2.0(webpack@5.88.2)
glob:
specifier: ^10.3.10
version: 10.3.10
json-loader:
specifier: ^0.5.7
version: 0.5.7
@ -151,6 +169,24 @@ devDependencies:
semver:
specifier: ^7.5.4
version: 7.5.4
typedoc:
specifier: ^0.25.3
version: 0.25.3(typescript@5.2.2)
typedoc-plugin-markdown:
specifier: ^3.17.1
version: 3.17.1(typedoc@0.25.3)
typedoc-plugin-mdn-links:
specifier: ^3.1.0
version: 3.1.0(typedoc@0.25.3)
typedoc-plugin-no-inherit:
specifier: ^1.4.0
version: 1.4.0(typedoc@0.25.3)
typedoc-plugin-rename-defaults:
specifier: ^0.7.0
version: 0.7.0(typedoc@0.25.3)
typescript:
specifier: ^5.2.2
version: 5.2.2
vue-loader:
specifier: ^15.10.2
version: 15.10.2(css-loader@6.8.1)(react@17.0.2)(vue-template-compiler@2.6.14)(webpack@5.88.2)
@ -535,8 +571,8 @@ packages:
dev: true
optional: true
/@popperjs/core@2.10.2:
resolution: {integrity: sha512-IXf3XA7+XyN7CP9gGh/XB0UxVMlvARGEgGXLubFICsUMGz6Q+DU+i4gGlpOxTjKvXjkJDJC8YdqdKkDj9qZHEQ==}
/@popperjs/core@2.11.8:
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
dev: false
/@types/body-parser@1.19.2:
@ -565,6 +601,10 @@ packages:
'@types/node': 20.5.7
dev: true
/@types/crypto-js@4.2.1:
resolution: {integrity: sha512-FSPGd9+OcSok3RsM0UZ/9fcvMOXJ1ENE/ZbLfOPlBWj7BgXtEAM8VYfTtT760GiLbQIMoVozwVuisjvsVwqYWw==}
dev: true
/@types/eslint-scope@3.7.4:
resolution: {integrity: sha512-9K4zoImiZc3HlIp6AVUDE4CWYx22a+lhSZMYNpbjW04+YF0KWj4pJXnEMjdnFTiQibFFmElcsasJXDbdI/EPhA==}
dependencies:
@ -611,6 +651,10 @@ packages:
'@types/node': 20.5.7
dev: true
/@types/js-cookie@3.0.6:
resolution: {integrity: sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==}
dev: true
/@types/json-schema@7.0.9:
resolution: {integrity: sha512-qcUXuemtEu+E5wZSJHNxUXeCZhAfXKQ41D+duX+VYPde7xyEVZci+/oXKJL13tnRs9lR2pr4fod59GT6/X1/yQ==}
dev: true
@ -643,6 +687,10 @@ packages:
resolution: {integrity: sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==}
dev: true
/@types/safe-regex@1.1.6:
resolution: {integrity: sha512-CQ/uPB9fLOPKwDsrTeVbNIkwfUthTWOx0l6uIGwVFjZxv7e68pCW5gtTYFzdJi3EBJp8h8zYhJbTasAbX7gEMQ==}
dev: true
/@types/send@0.17.1:
resolution: {integrity: sha512-Cwo8LE/0rnvX7kIIa3QHCkcuF21c05Ayb0ZfxPiv0W8VRiZiNW/WuRupHKpqqGVGf7SUA44QSOUKaEd9lIrd/Q==}
dependencies:
@ -670,6 +718,16 @@ packages:
'@types/node': 20.5.7
dev: true
/@types/vue-clickaway@2.2.4:
resolution: {integrity: sha512-Jy0dGNUrm/Fya1hY8bHM5lXJvZvlyU/rvgLEFVcjQkwNp2Z2IGNnRKS6ZH9orMDkUI7Qj0oyWp0b89VTErAS9Q==}
dependencies:
vue: 2.6.14
dev: true
/@types/webpack-env@1.18.4:
resolution: {integrity: sha512-I6e+9+HtWADAWeeJWDFQtdk4EVSAbj6Rtz4q8fJ7mSr1M0jzlFcs8/HZ+Xb5SHzVm1dxH7aUiI+A8kA8Gcrm0A==}
dev: true
/@types/ws@8.5.5:
resolution: {integrity: sha512-lwhs8hktwxSjf9UaZ9tG5M03PGogvFaH8gUgLNbN9HKIg0dvv6q+gkSuJ8HN4/VbyxkuLzCjlN7GquQ0gUJfIg==}
dependencies:
@ -989,6 +1047,10 @@ packages:
engines: {node: '>=12'}
dev: true
/ansi-sequence-parser@1.1.1:
resolution: {integrity: sha512-vJXt3yiaUL4UU546s3rPXlsry/RnM730G1+HkpKE012AN0sx1eOrxSu95oKDIonskeLTijMgqWZ3uDEe3NFvyg==}
dev: true
/ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
@ -1770,6 +1832,11 @@ packages:
engines: {node: '>=6'}
dev: true
/camelcase@8.0.0:
resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==}
engines: {node: '>=16'}
dev: true
/caniuse-lite@1.0.30001524:
resolution: {integrity: sha512-Jj917pJtYg9HSJBF95HVX3Cdr89JUyLT4IZ8SvM5aDRni95swKgYi3TgYLH5hnGfPE/U1dg6IfZ50UsIlLkwSA==}
dev: true
@ -2129,8 +2196,8 @@ packages:
which: 2.0.2
dev: true
/crypto-js@3.3.0:
resolution: {integrity: sha512-DIT51nX0dCfKltpRiXV+/TVZq+Qq2NgF4644+K7Ttnla7zEzqc+kjJyiB96BHNyUTBxyjzRcZYpUdZa+QAqi6Q==}
/crypto-js@4.2.0:
resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
dev: false
/css-loader@6.8.1(webpack@5.88.2):
@ -3056,13 +3123,13 @@ packages:
resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==}
dev: true
/glob@10.3.3:
resolution: {integrity: sha512-92vPiMb/iqpmEgsOoIDvTjc50wf9CCCvMzsi6W0JLPeUKE8TWP1a73PgqSrqy7iAZxaSD1YdzU7QZR5LF51MJw==}
/glob@10.3.10:
resolution: {integrity: sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==}
engines: {node: '>=16 || 14 >=14.17'}
hasBin: true
dependencies:
foreground-child: 3.1.1
jackspeak: 2.3.0
jackspeak: 2.3.6
minimatch: 9.0.3
minipass: 7.0.3
path-scurry: 1.10.1
@ -3153,6 +3220,19 @@ packages:
resolution: {integrity: sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg==}
dev: true
/handlebars@4.7.8:
resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==}
engines: {node: '>=0.4.7'}
hasBin: true
dependencies:
minimist: 1.2.8
neo-async: 2.6.2
source-map: 0.6.1
wordwrap: 1.0.0
optionalDependencies:
uglify-js: 3.17.4
dev: true
/has-bigints@1.0.2:
resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==}
dev: true
@ -3626,8 +3706,8 @@ packages:
reflect.getprototypeof: 1.0.3
dev: true
/jackspeak@2.3.0:
resolution: {integrity: sha512-uKmsITSsF4rUWQHzqaRUuyAir3fZfW3f202Ee34lz/gZCi970CPZwyQXLGNgWJvvZbvFyzeyGq0+4fcG/mBKZg==}
/jackspeak@2.3.6:
resolution: {integrity: sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==}
engines: {node: '>=14'}
dependencies:
'@isaacs/cliui': 8.0.2
@ -3644,8 +3724,9 @@ packages:
supports-color: 8.1.1
dev: true
/js-cookie@2.2.1:
resolution: {integrity: sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==}
/js-cookie@3.0.5:
resolution: {integrity: sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==}
engines: {node: '>=14'}
dev: false
/js-tokens@3.0.2:
@ -3710,6 +3791,10 @@ packages:
hasBin: true
dev: true
/jsonc-parser@3.2.0:
resolution: {integrity: sha512-gfFQZrcTc8CnKXp6Y4/CBT3fTc0OVuDofpre4aEeEpSBPV5X5v4+Vmx+8snU7RLPrNHPKSgLxGo9YuQzz20o+w==}
dev: true
/jsx-ast-utils@3.2.1:
resolution: {integrity: sha512-uP5vu8xfy2F9A6LGC22KO7e2/vGTS1MhP+18f++ZNlf0Ohaxbc9nIEwHAsejlJKyzfZzU5UIhe5ItYkitcZnZA==}
engines: {node: '>=4.0'}
@ -3837,6 +3922,10 @@ packages:
yallist: 4.0.0
dev: true
/lunr@2.3.9:
resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==}
dev: true
/markdown-it-link-attributes@3.0.0:
resolution: {integrity: sha512-B34ySxVeo6MuEGSPCWyIYryuXINOvngNZL87Mp7YYfKIf6DcD837+lXA8mo6EBbauKsnGz22ZH0zsbOiQRWTNg==}
dev: false
@ -3852,6 +3941,12 @@ packages:
uc.micro: 1.0.6
dev: false
/marked@4.3.0:
resolution: {integrity: sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A==}
engines: {node: '>= 12'}
hasBin: true
dev: true
/material-colors@1.2.6:
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
dev: false
@ -4679,7 +4774,7 @@ packages:
engines: {node: '>=14'}
hasBin: true
dependencies:
glob: 10.3.3
glob: 10.3.10
dev: true
/run-parallel@1.2.0:
@ -4899,6 +4994,15 @@ packages:
resolution: {integrity: sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==}
dev: true
/shiki@0.14.5:
resolution: {integrity: sha512-1gCAYOcmCFONmErGTrS1fjzJLA7MGZmKzrBNX7apqSwhyITJg2O102uFzXUeBxNnEkDA9vHIKLyeKq0V083vIw==}
dependencies:
ansi-sequence-parser: 1.1.1
jsonc-parser: 3.2.0
vscode-oniguruma: 1.7.0
vscode-textmate: 8.0.0
dev: true
/side-channel@1.0.4:
resolution: {integrity: sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw==}
dependencies:
@ -5287,10 +5391,72 @@ packages:
is-typed-array: 1.1.12
dev: true
/typedoc-plugin-markdown@3.17.1(typedoc@0.25.3):
resolution: {integrity: sha512-QzdU3fj0Kzw2XSdoL15ExLASt2WPqD7FbLeaqwT70+XjKyTshBnUlQA5nNREO1C2P8Uen0CDjsBLMsCQ+zd0lw==}
peerDependencies:
typedoc: '>=0.24.0'
dependencies:
handlebars: 4.7.8
typedoc: 0.25.3(typescript@5.2.2)
dev: true
/typedoc-plugin-mdn-links@3.1.0(typedoc@0.25.3):
resolution: {integrity: sha512-4uwnkvywPFV3UVx7WXpIWTHJdXH1rlE2e4a1WsSwCFYKqJxgTmyapv3ZxJtbSl1dvnb6jmuMNSqKEPz77Gs2OA==}
peerDependencies:
typedoc: '>= 0.23.14 || 0.24.x || 0.25.x'
dependencies:
typedoc: 0.25.3(typescript@5.2.2)
dev: true
/typedoc-plugin-no-inherit@1.4.0(typedoc@0.25.3):
resolution: {integrity: sha512-cAvqQ8X9xh1xztVoDKtF4nYRSBx9XwttN3OBbNNpA0YaJSRM8XvpVVhugq8FoO1HdWjF3aizS0JzdUOMDt0y9g==}
peerDependencies:
typedoc: '>=0.23.0'
dependencies:
typedoc: 0.25.3(typescript@5.2.2)
dev: true
/typedoc-plugin-rename-defaults@0.7.0(typedoc@0.25.3):
resolution: {integrity: sha512-NudSQ1o/XLHNF9c4y7LzIZxfE9ltz09yCDklBPJpP5VMRvuBpYGIbQ0ZgmPz+EIV8vPx9Z/OyKwzp4HT2vDtfg==}
peerDependencies:
typedoc: 0.22.x || 0.23.x || 0.24.x || 0.25.x
dependencies:
camelcase: 8.0.0
typedoc: 0.25.3(typescript@5.2.2)
dev: true
/typedoc@0.25.3(typescript@5.2.2):
resolution: {integrity: sha512-Ow8Bo7uY1Lwy7GTmphRIMEo6IOZ+yYUyrc8n5KXIZg1svpqhZSWgni2ZrDhe+wLosFS8yswowUzljTAV/3jmWw==}
engines: {node: '>= 16'}
hasBin: true
peerDependencies:
typescript: 4.6.x || 4.7.x || 4.8.x || 4.9.x || 5.0.x || 5.1.x || 5.2.x
dependencies:
lunr: 2.3.9
marked: 4.3.0
minimatch: 9.0.3
shiki: 0.14.5
typescript: 5.2.2
dev: true
/typescript@5.2.2:
resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==}
engines: {node: '>=14.17'}
hasBin: true
dev: true
/uc.micro@1.0.6:
resolution: {integrity: sha512-8Y75pvTYkLJW2hWQHXxoqRgV7qb9B+9vFEtidML+7koHUFapnVJAZ6cKs+Qjz5Aw3aZWHMC6u0wJE3At+nSGwA==}
dev: false
/uglify-js@3.17.4:
resolution: {integrity: sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==}
engines: {node: '>=0.8.0'}
hasBin: true
requiresBuild: true
dev: true
optional: true
/unbox-primitive@1.0.2:
resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==}
dependencies:
@ -5355,6 +5521,14 @@ packages:
engines: {node: '>= 0.8'}
dev: true
/vscode-oniguruma@1.7.0:
resolution: {integrity: sha512-L9WMGRfrjOhgHSdOYgCt/yRMsXzLDJSL7BPrOZt73gU0iWO4mpqzqQzOz5srxqTvMBaR0XZTSrVWo4j55Rc6cA==}
dev: true
/vscode-textmate@8.0.0:
resolution: {integrity: sha512-AFbieoL7a5LMqcnOF04ji+rpXadgOXnZsxQr//r83kLPr7biP7am3g9zbaZIaBGwBRWeSvoMD4mgPdX3e4NWBg==}
dev: true
/vue-clickaway@2.2.2(vue@2.6.14):
resolution: {integrity: sha512-25SpjXKetL06GLYoLoC8pqAV6Cur9cQ//2g35GRFBV4FgoljbZZjTINR8g2NuVXXDMLSUXaKx5dutgO4PaDE7A==}
peerDependencies:
@ -5502,7 +5676,6 @@ packages:
/vue@2.6.14:
resolution: {integrity: sha512-x2284lgYvjOMj3Za7kqzRcUSxBboHqtgRE2zlos1qWaOye5yUmHn42LB1250NJBLRwEcdrB0JRwyPTEPhfQjiQ==}
dev: false
/vuedraggable@2.24.3:
resolution: {integrity: sha512-6/HDXi92GzB+Hcs9fC6PAAozK1RLt1ewPTLjK0anTYguXLAeySDmcnqE8IC0xa7shvSzRjQXq3/+dsZ7ETGF3g==}
@ -5789,6 +5962,10 @@ packages:
resolution: {integrity: sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==}
dev: true
/wordwrap@1.0.0:
resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==}
dev: true
/wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}

View file

@ -4,21 +4,77 @@
// Add-On System
// ============================================================================
import Module from 'utilities/module';
import Module, { GenericModule } from 'utilities/module';
import { EXTENSION, SERVER_OR_EXT } from 'utilities/constants';
import { createElement } from 'utilities/dom';
import { timeout, has, deep_copy } from 'utilities/object';
import { timeout, has, deep_copy, fetchJSON } from 'utilities/object';
import { getBuster } from 'utilities/time';
import type SettingsManager from './settings';
import type TranslationManager from './i18n';
import type LoadTracker from './load_tracker';
import type FrankerFaceZ from './main';
import type { AddonInfo } from 'utilities/types';
declare global {
interface Window {
ffzAddonsWebpackJsonp: unknown;
}
}
declare module 'utilities/types' {
interface ModuleMap {
addons: AddonManager;
}
interface ModuleEventMap {
addons: AddonManagerEvents;
}
interface SettingsTypeMap {
'addons.dev.server': boolean;
}
};
type AddonManagerEvents = {
':ready': [];
':data-loaded': [];
':reload-required': [];
':added': [id: string, info: AddonInfo];
':addon-loaded': [id: string];
':addon-enabled': [id: string];
':addon-disabled': [id: string];
':fully-unload': [id: string];
};
type FullAddonInfo = AddonInfo & {
_search?: string | null;
src: string;
};
const fetchJSON = (url, options) => fetch(url, options).then(r => r.ok ? r.json() : null).catch(() => null);
// ============================================================================
// AddonManager
// ============================================================================
export default class AddonManager extends Module {
constructor(...args) {
super(...args);
export default class AddonManager extends Module<'addons'> {
// Dependencies
i18n: TranslationManager = null as any;
load_tracker: LoadTracker = null as any;
settings: SettingsManager = null as any;
// State
has_dev: boolean;
reload_required: boolean;
target: string;
addons: Record<string, FullAddonInfo | string[]>;
enabled_addons: string[];
private _loader?: Promise<void>;
constructor(name?: string, parent?: GenericModule) {
super(name, parent);
this.should_enable = true;
@ -28,7 +84,7 @@ export default class AddonManager extends Module {
this.load_requires = ['settings'];
this.target = this.parent.flavor || 'unknown';
this.target = (this.parent as unknown as FrankerFaceZ).flavor || 'unknown';
this.has_dev = false;
this.reload_required = false;
@ -39,6 +95,7 @@ export default class AddonManager extends Module {
}
onLoad() {
// We don't actually *wait* for this, we just start it.
this._loader = this.loadAddonData();
}
@ -54,20 +111,20 @@ export default class AddonManager extends Module {
getFFZ: () => this,
isReady: () => this.enabled,
getAddons: () => Object.values(this.addons),
hasAddon: id => this.hasAddon(id),
getVersion: id => this.getVersion(id),
doesAddonTarget: id => this.doesAddonTarget(id),
isAddonEnabled: id => this.isAddonEnabled(id),
isAddonExternal: id => this.isAddonExternal(id),
enableAddon: id => this.enableAddon(id),
disableAddon: id => this.disableAddon(id),
reloadAddon: id => this.reloadAddon(id),
canReloadAddon: id => this.canReloadAddon(id),
hasAddon: (id: string) => this.hasAddon(id),
getVersion: (id: string) => this.getVersion(id),
doesAddonTarget: (id: string) => this.doesAddonTarget(id),
isAddonEnabled: (id: string) => this.isAddonEnabled(id),
isAddonExternal: (id: string) => this.isAddonExternal(id),
enableAddon: (id: string) => this.enableAddon(id),
disableAddon: (id: string) => this.disableAddon(id),
reloadAddon: (id: string) => this.reloadAddon(id),
canReloadAddon: (id: string) => this.canReloadAddon(id),
isReloadRequired: () => this.reload_required,
refresh: () => window.location.reload(),
on: (...args) => this.on(...args),
off: (...args) => this.off(...args)
on: (...args: Parameters<typeof this.on>) => this.on(...args),
off: (...args: Parameters<typeof this.off>) => this.off(...args)
});
if ( ! EXTENSION )
@ -85,7 +142,7 @@ export default class AddonManager extends Module {
this.settings.provider.on('changed', this.onProviderChange, this);
this._loader.then(() => {
this._loader?.then(() => {
this.enabled_addons = this.settings.provider.get('addons.enabled', []);
// We do not await enabling add-ons because that would delay the
@ -103,8 +160,8 @@ export default class AddonManager extends Module {
}
doesAddonTarget(id) {
const data = this.addons[id];
doesAddonTarget(id: string) {
const data = this.getAddon(id);
if ( ! data )
return false;
@ -118,12 +175,15 @@ export default class AddonManager extends Module {
generateLog() {
const out = ['Known'];
for(const [id, addon] of Object.entries(this.addons))
for(const [id, addon] of Object.entries(this.addons)) {
if ( Array.isArray(addon) )
continue;
out.push(`${id} | ${this.isAddonEnabled(id) ? 'enabled' : 'disabled'} | ${addon.dev ? 'dev | ' : ''}${this.isAddonExternal(id) ? 'external | ' : ''}${addon.short_name} v${addon.version}`);
}
out.push('');
out.push('Modules');
for(const [key, module] of Object.entries(this.__modules)) {
for(const [key, module] of Object.entries((this as any).__modules as Record<string, GenericModule>)) {
if ( module )
out.push(`${module.loaded ? 'loaded ' : module.loading ? 'loading ' : 'unloaded'} | ${module.enabled ? 'enabled ' : module.enabling ? 'enabling' : 'disabled'} | ${key}`)
}
@ -131,22 +191,20 @@ export default class AddonManager extends Module {
return out.join('\n');
}
onProviderChange(key, value) {
onProviderChange(key: string, value: unknown) {
if ( key != 'addons.enabled' )
return;
if ( ! value )
value = [];
const old_enabled = [...this.enabled_addons];
const val: string[] = Array.isArray(value) ? value : [],
old_enabled = [...this.enabled_addons];
// Add-ons to disable
for(const id of old_enabled)
if ( ! value.includes(id) )
if ( ! val.includes(id) )
this.disableAddon(id, false);
// Add-ons to enable
for(const id of value)
for(const id of val)
if ( ! old_enabled.includes(id) )
this.enableAddon(id, false);
}
@ -187,7 +245,9 @@ export default class AddonManager extends Module {
this.emit(':data-loaded');
}
addAddon(addon, is_dev = false) {
addAddon(input: AddonInfo, is_dev: boolean = false) {
let addon = input as FullAddonInfo;
const old = this.addons[addon.id];
this.addons[addon.id] = addon;
@ -217,7 +277,7 @@ export default class AddonManager extends Module {
this.addons[id] = [addon.id];
}
if ( ! old )
if ( ! old || Array.isArray(old) )
this.settings.addUI(`addon-changelog.${addon.id}`, {
path: `Add-Ons > Changelog > ${addon.name}`,
component: 'changelog',
@ -227,11 +287,14 @@ export default class AddonManager extends Module {
getFFZ: () => this
});
this.emit(':added');
this.emit(':added', addon.id, addon);
}
rebuildAddonSearch() {
for(const addon of Object.values(this.addons)) {
if ( Array.isArray(addon) )
continue;
const terms = new Set([
addon._search,
addon.name,
@ -250,47 +313,51 @@ export default class AddonManager extends Module {
if ( addon.author_i18n )
terms.add(this.i18n.t(addon.author_i18n, addon.author));
if ( addon.maintainer_i18n )
terms.add(this.i18n.t(addon.maintainer_i18n, addon.maintainer));
if ( addon.description_i18n )
terms.add(this.i18n.t(addon.description_i18n, addon.description));
}
addon.search_terms = [...terms].map(term => term ? term.toLocaleLowerCase() : '').join('\n');
addon.search_terms = [...terms]
.map(term => term ? term.toLocaleLowerCase() : '').join('\n');
}
}
isAddonEnabled(id) {
isAddonEnabled(id: string) {
if ( this.isAddonExternal(id) )
return true;
return this.enabled_addons.includes(id);
}
getAddon(id) {
getAddon(id: string) {
const addon = this.addons[id];
return Array.isArray(addon) ? null : addon;
}
hasAddon(id) {
hasAddon(id: string) {
return this.getAddon(id) != null;
}
getVersion(id) {
getVersion(id: string) {
const addon = this.getAddon(id);
if ( ! addon )
throw new Error(`Unknown add-on id: ${id}`);
const module = this.resolve(`addon.${id}`);
if ( module ) {
if ( has(module, 'version') )
if ( 'version' in module ) // has(module, 'version') )
return module.version;
else if ( module.constructor && has(module.constructor, 'version') )
else if ( module.constructor && 'version' in module.constructor ) // has(module.constructor, 'version') )
return module.constructor.version;
}
return addon.version;
}
isAddonExternal(id) {
isAddonExternal(id: string) {
if ( ! this.hasAddon(id) )
throw new Error(`Unknown add-on id: ${id}`);
@ -306,10 +373,10 @@ export default class AddonManager extends Module {
return true;
// Finally, let the module flag itself as external.
return module.external || (module.constructor && module.constructor.external);
return (module as any).external || (module.constructor as any)?.external;
}
canReloadAddon(id) {
canReloadAddon(id: string) {
// Obviously we can't reload it if we don't have it.
if ( ! this.hasAddon(id) )
throw new Error(`Unknown add-on id: ${id}`);
@ -334,8 +401,8 @@ export default class AddonManager extends Module {
return true;
}
async fullyUnloadModule(module) {
if ( ! module )
async fullyUnloadModule(module: GenericModule) {
if ( ! module || ! module.addon_id )
return;
if ( module.children )
@ -346,47 +413,47 @@ export default class AddonManager extends Module {
await module.unload();
// Clean up parent references.
if ( module.parent && module.parent.children[module.name] === module )
if ( module.parent instanceof Module && module.parent.children[module.name] === module )
delete module.parent.children[module.name];
// Clean up all individual references.
for(const entry of module.references) {
const other = this.resolve(entry[0]),
name = entry[1];
if ( other && other[name] === module )
other[name] = null;
if ( (other as any)[name] === module )
(other as any)[name] = null;
}
// Send off a signal for other modules to unload related data.
this.emit('addon:fully-unload', module.addon_id);
this.emit(':fully-unload', module.addon_id);
// Clean up the global reference.
if ( this.__modules[module.__path] === module )
delete this.__modules[module.__path]; /* = [
if ( (this as any).__modules[(module as any).__path] === module )
delete (this as any).__modules[(module as any).__path]; /* = [
module.dependents,
module.load_dependents,
module.references
];*/
// Remove any events we didn't unregister.
this.offContext(null, module);
this.off(undefined, undefined, module);
// Do the same for settings.
for(const ctx of this.settings.__contexts)
ctx.offContext(null, module);
ctx.off(undefined, undefined, module);
// Clean up all settings.
for(const [key, def] of Array.from(this.settings.definitions.entries())) {
if ( def && def.__source === module.addon_id ) {
if ( ! Array.isArray(def) && def?.__source === module.addon_id ) {
this.settings.remove(key);
}
}
// Clean up the logger too.
module.__log = null;
(module as any).__log = null;
}
async reloadAddon(id) {
async reloadAddon(id: string) {
const addon = this.getAddon(id),
button = this.resolve('site.menu_button');
if ( ! addon )
@ -456,7 +523,7 @@ export default class AddonManager extends Module {
});
}
async _enableAddon(id) {
private async _enableAddon(id: string) {
const addon = this.getAddon(id);
if ( ! addon )
throw new Error(`Unknown add-on id: ${id}`);
@ -476,7 +543,7 @@ export default class AddonManager extends Module {
this.load_tracker.notify(event, `addon.${id}`, false);
}
async loadAddon(id) {
async loadAddon(id: string) {
const addon = this.getAddon(id);
if ( ! addon )
throw new Error(`Unknown add-on id: ${id}`);
@ -500,7 +567,7 @@ export default class AddonManager extends Module {
}));
// Error if this takes more than 5 seconds.
await timeout(this.waitFor(`addon.${id}:registered`), 60000);
await timeout(this.waitFor(`addon.${id}:registered` as any), 60000);
module = this.resolve(`addon.${id}`);
if ( module && ! module.loaded )
@ -509,13 +576,13 @@ export default class AddonManager extends Module {
this.emit(':addon-loaded', id);
}
unloadAddon(id) {
unloadAddon(id: string) {
const module = this.resolve(`addon.${id}`);
if ( module )
return module.unload();
}
enableAddon(id, save = true) {
enableAddon(id: string, save: boolean = true) {
const addon = this.getAddon(id);
if( ! addon )
throw new Error(`Unknown add-on id: ${id}`);
@ -546,7 +613,7 @@ export default class AddonManager extends Module {
});
}
async disableAddon(id, save = true) {
async disableAddon(id: string, save: boolean = true) {
const addon = this.getAddon(id);
if ( ! addon )
throw new Error(`Unknown add-on id: ${id}`);

View file

@ -12,13 +12,13 @@ import {timeout} from 'utilities/object';
import SettingsManager from './settings/index';
import AddonManager from './addons';
import ExperimentManager from './experiments';
import {TranslationManager} from './i18n';
import TranslationManager from './i18n';
import PubSubClient from './pubsub';
import StagingSelector from './staging';
import LoadTracker from './load_tracker';
import Site from './sites/clips';
import Vue from 'utilities/vue';
import VueModule from 'utilities/vue';
import Tooltips from 'src/modules/tooltips';
import Chat from 'src/modules/chat';
@ -64,7 +64,7 @@ class FrankerFaceZ extends Module {
this.inject('site', Site);
this.inject('addons', AddonManager);
this.register('vue', Vue);
this.register('vue', VueModule);
// ========================================================================
// Startup

View file

@ -1,479 +0,0 @@
'use strict';
// ============================================================================
// Experiments
// ============================================================================
import {DEBUG, SERVER} from 'utilities/constants';
import Module from 'utilities/module';
import {has, deep_copy} from 'utilities/object';
import { getBuster } from 'utilities/time';
import Cookie from 'js-cookie';
import SHA1 from 'crypto-js/sha1';
const OVERRIDE_COOKIE = 'experiment_overrides',
COOKIE_OPTIONS = {
expires: 7,
domain: '.twitch.tv'
};
// We want to import this so that the file is included in the output.
// We don't load using this because we might want a newer file from the
// server.
import EXPERIMENTS from './experiments.json'; // eslint-disable-line no-unused-vars
function sortExperimentLog(a,b) {
if ( a.rarity < b.rarity )
return -1;
else if ( a.rarity > b.rarity )
return 1;
if ( a.name < b.name )
return -1;
else if ( a.name > b.name )
return 1;
return 0;
}
// ============================================================================
// Experiment Manager
// ============================================================================
export default class ExperimentManager extends Module {
constructor(...args) {
super(...args);
this.get = this.getAssignment;
this.inject('settings');
this.settings.addUI('experiments', {
path: 'Debugging > Experiments',
component: 'experiments',
no_filter: true,
getExtraTerms: () => {
const values = [];
for(const exps of [this.experiments, this.getTwitchExperiments()]) {
if ( ! exps )
continue;
for(const [key, val] of Object.entries(exps)) {
values.push(key);
if ( val.name )
values.push(val.name);
if ( val.description )
values.push(val.description);
}
}
return values;
},
is_locked: () => this.getControlsLocked(),
unlock: () => this.unlockControls(),
unique_id: () => this.unique_id,
ffz_data: () => deep_copy(this.experiments),
twitch_data: () => deep_copy(this.getTwitchExperiments()),
usingTwitchExperiment: key => this.usingTwitchExperiment(key),
getTwitchAssignment: key => this.getTwitchAssignment(key),
getTwitchType: type => this.getTwitchType(type),
hasTwitchOverride: key => this.hasTwitchOverride(key),
setTwitchOverride: (key, val) => this.setTwitchOverride(key, val),
deleteTwitchOverride: key => this.deleteTwitchOverride(key),
getAssignment: key => this.getAssignment(key),
hasOverride: key => this.hasOverride(key),
setOverride: (key, val) => this.setOverride(key, val),
deleteOverride: key => this.deleteOverride(key),
on: (...args) => this.on(...args),
off: (...args) => this.off(...args)
});
this.unique_id = Cookie.get('unique_id');
this.Cookie = Cookie;
this.experiments = {};
this.cache = new Map;
}
getControlsLocked() {
if ( DEBUG )
return false;
const ts = this.settings.provider.get('exp-lock', 0);
if ( isNaN(ts) || ! isFinite(ts) )
return true;
return Date.now() - ts >= 86400000;
}
unlockControls() {
this.settings.provider.set('exp-lock', Date.now());
}
async onLoad() {
await this.loadExperiments();
}
async loadExperiments() {
let data;
try {
data = await fetch(DEBUG ? EXPERIMENTS : `${SERVER}/script/experiments.json?_=${getBuster()}`).then(r =>
r.ok ? r.json() : null);
} catch(err) {
this.log.warn('Unable to load experiment data.', err);
}
if ( ! data )
return;
this.experiments = data;
const old_cache = this.cache;
this.cache = new Map;
let changed = 0;
for(const [key, old_val] of old_cache.entries()) {
const new_val = this.getAssignment(key);
if ( old_val !== new_val ) {
changed++;
this.emit(':changed', key, new_val);
this.emit(`:changed:${key}`, new_val);
}
}
this.log.info(`Loaded information on ${Object.keys(data).length} experiments.${changed > 0 ? ` ${changed} values updated.` : ''}`);
//this.emit(':loaded');
}
onEnable() {
this.on('pubsub:command:reload_experiments', this.loadExperiments, this);
this.on('pubsub:command:update_experiment', this.updateExperiment, this);
}
updateExperiment(key, data) {
this.log.info(`Received updated data for experiment "${key}" via WebSocket.`, data);
if ( data.groups )
this.experiments[key] = data;
else
this.experiments[key].groups = data;
this._rebuildKey(key);
}
generateLog() {
const out = [
`Unique ID: ${this.unique_id}`,
''
];
const ffz_assignments = [];
for(const [key, value] of Object.entries(this.experiments)) {
const assignment = this.getAssignment(key),
override = this.hasOverride(key);
let weight = 0, total = 0;
for(const group of value.groups) {
if ( group.value === assignment )
weight = group.weight;
total += group.weight;
}
if ( ! override && weight === total )
continue;
ffz_assignments.push({
key,
name: value.name,
value: assignment,
override,
rarity: weight / total
});
//out.push(`FFZ | ${value.name}: ${this.getAssignment(key)}${this.hasOverride(key) ? ' (Overriden)' : ''}`);
}
ffz_assignments.sort(sortExperimentLog);
for(const entry of ffz_assignments)
out.push(`FFZ | ${entry.name}: ${entry.value}${entry.override ? ' (Override)' : ''} (r:${entry.rarity})`);
const twitch_assignments = [],
channel = this.settings.get('context.channel');
for(const [key, value] of Object.entries(this.getTwitchExperiments())) {
if ( ! this.usingTwitchExperiment(key) )
continue;
const assignment = this.getTwitchAssignment(key),
override = this.hasTwitchOverride(key);
let weight = 0, total = 0;
for(const group of value.groups) {
if ( group.value === assignment )
weight = group.weight;
total += group.weight;
}
if ( ! override && weight === total )
continue;
twitch_assignments.push({
key,
name: value.name,
value: assignment,
override,
type: this.getTwitchTypeByKey(key),
rarity: weight / total
});
//out.push(`TWITCH | ${value.name}: ${this.getTwitchAssignment(key)}${this.hasTwitchOverride(key) ? ' (Overriden)' : ''}`)
}
twitch_assignments.sort(sortExperimentLog);
for(const entry of twitch_assignments)
out.push(`Twitch | ${entry.name}: ${entry.value}${entry.override ? ' (Override)' : ''} (r:${entry.rarity}, t:${entry.type}${entry.type === 'channel_id' ? `, c:${channel}`: ''})`);
return out.join('\n');
}
// Twitch Experiments
getTwitchType(type) {
const core = this.resolve('site')?.getCore?.();
if ( core?.experiments?.getExperimentType )
return core.experiments.getExperimentType(type);
if ( type === 1 )
return 'device_id';
else if ( type === 2 )
return 'user_id';
else if ( type === 3 )
return 'channel_id';
return type;
}
getTwitchTypeByKey(key) {
const core = this.resolve('site')?.getCore?.(),
exps = core && core.experiments,
exp = exps?.experiments?.[key];
if ( exp?.t )
return this.getTwitchType(exp.t);
return null;
}
getTwitchExperiments() {
if ( window.__twilightSettings )
return window.__twilightSettings.experiments;
const core = this.resolve('site')?.getCore?.();
return core && core.experiments.experiments;
}
usingTwitchExperiment(key) {
const core = this.resolve('site')?.getCore?.();
return core && has(core.experiments.assignments, key)
}
setTwitchOverride(key, value = null) {
const overrides = Cookie.getJSON(OVERRIDE_COOKIE) || {};
const experiments = overrides.experiments = overrides.experiments || {};
const disabled = overrides.disabled = overrides.disabled || [];
experiments[key] = value;
const idx = disabled.indexOf(key);
if (idx != -1)
disabled.remove(idx);
Cookie.set(OVERRIDE_COOKIE, overrides, COOKIE_OPTIONS);
const core = this.resolve('site')?.getCore?.();
if ( core )
core.experiments.overrides[key] = value;
this._rebuildTwitchKey(key, true, value);
}
deleteTwitchOverride(key) {
const overrides = Cookie.getJSON(OVERRIDE_COOKIE),
experiments = overrides?.experiments;
if ( ! experiments || ! has(experiments, key) )
return;
const old_val = experiments[key];
delete experiments[key];
Cookie.set(OVERRIDE_COOKIE, overrides, COOKIE_OPTIONS);
const core = this.resolve('site')?.getCore?.();
if ( core )
delete core.experiments.overrides[key];
this._rebuildTwitchKey(key, false, old_val);
}
hasTwitchOverride(key) { // eslint-disable-line class-methods-use-this
const overrides = Cookie.getJSON(OVERRIDE_COOKIE),
experiments = overrides?.experiments;
return experiments && has(experiments, key);
}
getTwitchAssignment(key, channel = null) {
const core = this.resolve('site')?.getCore?.(),
exps = core && core.experiments;
if ( ! exps )
return null;
if ( ! exps.hasInitialized && exps.initialize )
try {
exps.initialize();
} catch(err) {
this.log.warn('Error attempting to initialize Twitch experiments tracker.', err);
}
if ( channel || this.getTwitchType(exps.experiments[key]?.t) === 'channel_id' )
return exps.getAssignmentById(key, {channel: channel ?? this.settings.get('context.channel')});
if ( exps.overrides && exps.overrides[key] )
return exps.overrides[key];
else if ( exps.assignments && exps.assignments[key] )
return exps.assignments[key];
return null;
}
getTwitchKeyFromName(name) {
const experiments = this.getTwitchExperiments();
if ( ! experiments )
return undefined;
name = name.toLowerCase();
for(const key in experiments)
if ( has(experiments, key) ) {
const data = experiments[key];
if ( data && data.name && data.name.toLowerCase() === name )
return key;
}
}
getTwitchAssignmentByName(name, channel = null) {
return this.getTwitchAssignment(this.getTwitchKeyFromName(name), channel);
}
_rebuildTwitchKey(key, is_set, new_val) {
const core = this.resolve('site')?.getCore?.(),
exps = core.experiments,
old_val = has(exps.assignments, key) ?
exps.assignments[key] :
undefined;
if ( old_val !== new_val ) {
const value = is_set ? new_val : old_val;
this.emit(':twitch-changed', key, value);
this.emit(`:twitch-changed:${key}`, value);
}
}
// FFZ Experiments
setOverride(key, value = null) {
const overrides = this.settings.provider.get('experiment-overrides') || {};
overrides[key] = value;
this.settings.provider.set('experiment-overrides', overrides);
this._rebuildKey(key);
}
deleteOverride(key) {
const overrides = this.settings.provider.get('experiment-overrides');
if ( ! overrides || ! has(overrides, key) )
return;
delete overrides[key];
this.settings.provider.set('experiment-overrides', overrides);
this._rebuildKey(key);
}
hasOverride(key) {
const overrides = this.settings.provider.get('experiment-overrides');
return overrides && has(overrides, key);
}
getAssignment(key) {
if ( this.cache.has(key) )
return this.cache.get(key);
const experiment = this.experiments[key];
if ( ! experiment ) {
this.log.warn(`Tried to get assignment for experiment "${key}" which is not known.`);
return null;
}
const overrides = this.settings.provider.get('experiment-overrides'),
out = overrides && has(overrides, key) ?
overrides[key] :
ExperimentManager.selectGroup(key, experiment, this.unique_id);
this.cache.set(key, out);
return out;
}
_rebuildKey(key) {
if ( ! this.cache.has(key) )
return;
const old_val = this.cache.get(key);
this.cache.delete(key);
const new_val = this.getAssignment(key);
if ( new_val !== old_val ) {
this.emit(':changed', key, new_val);
this.emit(`:changed:${key}`, new_val);
}
}
static selectGroup(key, experiment, unique_id) {
const seed = key + unique_id + (experiment.seed || ''),
total = experiment.groups.reduce((a,b) => a + b.weight, 0);
let value = (SHA1(seed).words[0] >>> 0) / Math.pow(2, 32);
for(const group of experiment.groups) {
value -= group.weight / total;
if ( value <= 0 )
return group.value;
}
return null;
}
}

696
src/experiments.ts Normal file
View file

@ -0,0 +1,696 @@
'use strict';
// ============================================================================
// Experiments
// ============================================================================
import {DEBUG, SERVER} from 'utilities/constants';
import Module, { GenericModule } from 'utilities/module';
import {has, deep_copy, fetchJSON} from 'utilities/object';
import { getBuster } from 'utilities/time';
import Cookie from 'js-cookie';
import SHA1 from 'crypto-js/sha1';
import type SettingsManager from './settings';
import type { ExperimentTypeMap } from 'utilities/types';
declare module 'utilities/types' {
interface ModuleMap {
experiments: ExperimentManager;
}
interface ModuleEventMap {
experiments: ExperimentEvents;
}
interface ProviderTypeMap {
'experiment-overrides': {
[K in keyof ExperimentTypeMap]?: ExperimentTypeMap[K];
}
}
interface PubSubCommands {
reload_experiments: [];
update_experiment: {
key: keyof ExperimentTypeMap,
data: FFZExperimentData | ExperimentGroup[]
};
}
}
declare global {
interface Window {
__twilightSettings?: {
experiments?: Record<string, TwitchExperimentData>;
}
}
}
const OVERRIDE_COOKIE = 'experiment_overrides',
COOKIE_OPTIONS = {
expires: 7,
domain: '.twitch.tv'
};
// We want to import this so that the file is included in the output.
// We don't load using this because we might want a newer file from the
// server. Because of our webpack settings, this is imported as a URL
// and not an object.
const EXPERIMENTS: string = require('./experiments.json');
// ============================================================================
// Data Types
// ============================================================================
export enum TwitchExperimentType {
Unknown = 0,
Device = 1,
User = 2,
Channel = 3
};
export type ExperimentGroup = {
value: unknown;
weight: number;
};
export type FFZExperimentData = {
name: string;
seed?: number;
description: string;
groups: ExperimentGroup[];
}
export type TwitchExperimentData = {
name: string;
t: TwitchExperimentType;
v: number;
groups: ExperimentGroup[];
};
export type ExperimentData = FFZExperimentData | TwitchExperimentData;
export type OverrideCookie = {
experiments: Record<string, string>;
disabled: string[];
};
type ExperimentEvents = {
':changed': [key: string, new_value: any, old_value: any];
':twitch-changed': [key: string, new_value: string | null, old_value: string | null];
[key: `:twitch-changed:${string}`]: [new_value: string | null, old_value: string | null];
} & {
[K in keyof ExperimentTypeMap as `:changed:${K}`]: [new_value: ExperimentTypeMap[K], old_value: ExperimentTypeMap[K] | null];
};
type ExperimentLogEntry = {
key: string;
name: string;
value: any;
override: boolean;
rarity: number;
type?: string;
}
// ============================================================================
// Helper Methods
// ============================================================================
export function isTwitchExperiment(exp: ExperimentData): exp is TwitchExperimentData {
return 't' in exp;
}
export function isFFZExperiment(exp: ExperimentData): exp is FFZExperimentData {
return 'description' in exp;
}
function sortExperimentLog(a: ExperimentLogEntry, b: ExperimentLogEntry) {
if ( a.rarity < b.rarity )
return -1;
else if ( a.rarity > b.rarity )
return 1;
if ( a.name < b.name )
return -1;
else if ( a.name > b.name )
return 1;
return 0;
}
// ============================================================================
// Experiment Manager
// ============================================================================
export default class ExperimentManager extends Module<'experiments', ExperimentEvents> {
// Dependencies
settings: SettingsManager = null as any;
// State
unique_id?: string;
experiments: Partial<{
[K in keyof ExperimentTypeMap]: FFZExperimentData;
}>;
private cache: Map<keyof ExperimentTypeMap, unknown>;
// Helpers
Cookie: typeof Cookie;
constructor(name?: string, parent?: GenericModule) {
super(name, parent);
this.get = this.getAssignment;
this.inject('settings');
this.settings.addUI('experiments', {
path: 'Debugging > Experiments',
component: 'experiments',
no_filter: true,
getExtraTerms: () => {
const values: string[] = [];
for(const [key, val] of Object.entries(this.experiments)) {
values.push(key);
if ( val.name )
values.push(val.name);
if ( val.description )
values.push(val.description);
}
for(const [key, val] of Object.entries(this.getTwitchExperiments())) {
values.push(key);
if ( val.name )
values.push(val.name);
}
return values;
},
is_locked: () => this.getControlsLocked(),
unlock: () => this.unlockControls(),
unique_id: () => this.unique_id,
ffz_data: () => deep_copy(this.experiments),
twitch_data: () => deep_copy(this.getTwitchExperiments()),
usingTwitchExperiment: (key: string) => this.usingTwitchExperiment(key),
getTwitchAssignment: (key: string) => this.getTwitchAssignment(key),
getTwitchType: (type: TwitchExperimentType) => this.getTwitchType(type),
hasTwitchOverride: (key: string) => this.hasTwitchOverride(key),
setTwitchOverride: (key: string, val: string) => this.setTwitchOverride(key, val),
deleteTwitchOverride: (key: string) => this.deleteTwitchOverride(key),
getAssignment: <K extends keyof ExperimentTypeMap>(key: K) => this.getAssignment(key),
hasOverride: (key: keyof ExperimentTypeMap) => this.hasOverride(key),
setOverride: <K extends keyof ExperimentTypeMap>(key: K, val: ExperimentTypeMap[K]) => this.setOverride(key, val),
deleteOverride: (key: keyof ExperimentTypeMap) => this.deleteOverride(key),
on: (...args: Parameters<typeof this.on>) => this.on(...args),
off: (...args: Parameters<typeof this.off>) => this.off(...args)
});
this.unique_id = Cookie.get('unique_id');
this.Cookie = Cookie;
this.experiments = {};
this.cache = new Map;
}
getControlsLocked() {
if ( DEBUG )
return false;
const ts = this.settings.provider.get<number>('exp-lock', 0);
if ( isNaN(ts) || ! isFinite(ts) )
return true;
return Date.now() - ts >= 86400000;
}
unlockControls() {
this.settings.provider.set('exp-lock', Date.now());
}
async onLoad() {
await this.loadExperiments();
}
async loadExperiments() {
let data: Record<keyof ExperimentTypeMap, FFZExperimentData> | null;
try {
data = await fetchJSON(DEBUG
? EXPERIMENTS
: `${SERVER}/script/experiments.json?_=${getBuster()}`
);
} catch(err) {
this.log.warn('Unable to load experiment data.', err);
return;
}
if ( ! data )
return;
this.experiments = data;
const old_cache = this.cache;
this.cache = new Map;
let changed = 0;
for(const [key, old_val] of old_cache.entries()) {
const new_val = this.getAssignment(key);
if ( old_val !== new_val ) {
changed++;
this.emit(':changed', key, new_val, old_val);
this.emit(`:changed:${key as keyof ExperimentTypeMap}`, new_val as any, old_val as any);
}
}
this.log.info(`Loaded information on ${Object.keys(data).length} experiments.${changed > 0 ? ` ${changed} values updated.` : ''}`);
//this.emit(':loaded');
}
/** @internal */
onEnable() {
this.on('pubsub:command:reload_experiments', this.loadExperiments, this);
this.on('pubsub:command:update_experiment', data => {
this.updateExperiment(data.key, data.data);
}, this);
}
updateExperiment(key: keyof ExperimentTypeMap, data: FFZExperimentData | ExperimentGroup[]) {
this.log.info(`Received updated data for experiment "${key}" via PubSub.`, data);
if ( Array.isArray(data) ) {
const existing = this.experiments[key];
if ( ! existing )
return;
existing.groups = data;
} else if ( data?.groups )
this.experiments[key] = data;
this._rebuildKey(key);
}
generateLog() {
const out = [
`Unique ID: ${this.unique_id}`,
''
];
const ffz_assignments: ExperimentLogEntry[] = [];
for(const [key, value] of Object.entries(this.experiments) as [keyof ExperimentTypeMap, FFZExperimentData][]) {
const assignment = this.getAssignment(key),
override = this.hasOverride(key);
let weight = 0, total = 0;
for(const group of value.groups) {
if ( group.value === assignment )
weight = group.weight;
total += group.weight;
}
if ( ! override && weight === total )
continue;
ffz_assignments.push({
key,
name: value.name,
value: assignment,
override,
rarity: weight / total
});
//out.push(`FFZ | ${value.name}: ${this.getAssignment(key)}${this.hasOverride(key) ? ' (Overriden)' : ''}`);
}
ffz_assignments.sort(sortExperimentLog);
for(const entry of ffz_assignments)
out.push(`FFZ | ${entry.name}: ${entry.value}${entry.override ? ' (Override)' : ''} (r:${entry.rarity})`);
const twitch_assignments: ExperimentLogEntry[] = [],
channel = this.settings.get('context.channel');
for(const [key, value] of Object.entries(this.getTwitchExperiments())) {
if ( ! this.usingTwitchExperiment(key) )
continue;
const assignment = this.getTwitchAssignment(key),
override = this.hasTwitchOverride(key);
let weight = 0, total = 0;
for(const group of value.groups) {
if ( group.value === assignment )
weight = group.weight;
total += group.weight;
}
if ( ! override && weight === total )
continue;
twitch_assignments.push({
key,
name: value.name,
value: assignment,
override,
type: this.getTwitchTypeByKey(key),
rarity: weight / total
});
//out.push(`TWITCH | ${value.name}: ${this.getTwitchAssignment(key)}${this.hasTwitchOverride(key) ? ' (Overriden)' : ''}`)
}
twitch_assignments.sort(sortExperimentLog);
for(const entry of twitch_assignments)
out.push(`Twitch | ${entry.name}: ${entry.value}${entry.override ? ' (Override)' : ''} (r:${entry.rarity}, t:${entry.type}${entry.type === 'channel_id' ? `, c:${channel}`: ''})`);
return out.join('\n');
}
// Twitch Experiments
getTwitchType(type: number) {
const core = this.resolve('site')?.getCore?.();
if ( core?.experiments?.getExperimentType )
return core.experiments.getExperimentType(type);
if ( type === 1 )
return 'device_id';
else if ( type === 2 )
return 'user_id';
else if ( type === 3 )
return 'channel_id';
return type;
}
getTwitchTypeByKey(key: string) {
const exps = this.getTwitchExperiments(),
exp = exps?.[key];
if ( exp?.t )
return this.getTwitchType(exp.t);
return null;
}
getTwitchExperiments(): Record<string, TwitchExperimentData> {
if ( window.__twilightSettings )
return window.__twilightSettings.experiments ?? {};
const core = this.resolve('site')?.getCore?.();
return core && core.experiments.experiments || {};
}
usingTwitchExperiment(key: string) {
const core = this.resolve('site')?.getCore?.();
return core && has(core.experiments.assignments, key)
}
private _getOverrideCookie() {
const raw = Cookie.get(OVERRIDE_COOKIE);
let out: OverrideCookie;
try {
out = raw ? JSON.parse(raw) : {};
} catch(err) {
out = {} as OverrideCookie;
}
if ( ! out.experiments )
out.experiments = {};
if ( ! out.disabled )
out.disabled = [];
return out;
}
private _saveOverrideCookie(value?: OverrideCookie) {
if ( value ) {
if ((! value.experiments || ! Object.keys(value.experiments).length) &&
(! value.disabled || ! value.disabled.length)
)
value = undefined;
}
if ( value )
Cookie.set(OVERRIDE_COOKIE, JSON.stringify(value), COOKIE_OPTIONS);
else
Cookie.remove(OVERRIDE_COOKIE, COOKIE_OPTIONS);
}
setTwitchOverride(key: string, value: string) {
const overrides = this._getOverrideCookie(),
experiments = overrides.experiments,
disabled = overrides.disabled;
experiments[key] = value;
const idx = disabled.indexOf(key);
if (idx != -1)
disabled.splice(idx, 1);
this._saveOverrideCookie(overrides);
const core = this.resolve('site')?.getCore?.();
if ( core )
core.experiments.overrides[key] = value;
this._rebuildTwitchKey(key, true, value);
}
deleteTwitchOverride(key: string) {
const overrides = this._getOverrideCookie(),
experiments = overrides.experiments;
if ( ! has(experiments, key) )
return;
const old_val = experiments[key];
delete experiments[key];
this._saveOverrideCookie(overrides);
const core = this.resolve('site')?.getCore?.();
if ( core )
delete core.experiments.overrides[key];
this._rebuildTwitchKey(key, false, old_val);
}
hasTwitchOverride(key: string) { // eslint-disable-line class-methods-use-this
const overrides = this._getOverrideCookie(),
experiments = overrides.experiments;
return has(experiments, key);
}
getTwitchAssignment(key: string, channel: string | null = null) {
const core = this.resolve('site')?.getCore?.(),
exps = core && core.experiments;
if ( ! exps )
return null;
if ( ! exps.hasInitialized && exps.initialize )
try {
exps.initialize();
} catch(err) {
this.log.warn('Error attempting to initialize Twitch experiments tracker.', err);
}
if ( exps.overrides && exps.overrides[key] )
return exps.overrides[key];
const exp_data = exps.experiments[key],
type = this.getTwitchType(exp_data?.t ?? 0);
// channel_id experiments always use getAssignmentById
if ( type === 'channel_id' ) {
return exps.getAssignmentById(key, {
bucketing: {
type: 1,
value: channel ?? this.settings.get('context.channelID')
}
});
}
// Otherwise, just use the default assignment?
if ( exps.assignments?.[key] )
return exps.assignments[key];
// If there is no default assignment, we should try to figure out
// what assignment they *would* get.
if ( type === 'device_id' )
return exps.selectTreatment(key, exp_data, this.unique_id);
else if ( type === 'user_id' )
// Technically, some experiments are expecting to get the user's
// login rather than user ID. But we don't care that much if an
// inactive legacy experiment is shown wrong. Meh.
return exps.selectTreatment(key, exp_data, this.resolve('site')?.getUser?.()?.id);
// We don't know what kind of experiment this is.
// Give up!
return null;
}
getTwitchKeyFromName(name: string) {
const experiments = this.getTwitchExperiments();
if ( ! experiments )
return;
name = name.toLowerCase();
for(const key in experiments)
if ( has(experiments, key) ) {
const data = experiments[key];
if ( data && data.name && data.name.toLowerCase() === name )
return key;
}
}
getTwitchAssignmentByName(name: string, channel: string | null = null) {
const key = this.getTwitchKeyFromName(name);
if ( ! key )
return null;
return this.getTwitchAssignment(key, channel);
}
private _rebuildTwitchKey(
key: string,
is_set: boolean,
new_val: string | null
) {
const core = this.resolve('site')?.getCore?.(),
exps = core.experiments,
old_val = has(exps.assignments, key) ?
exps.assignments[key] as string :
null;
if ( old_val !== new_val ) {
const value = is_set ? new_val : old_val;
this.emit(':twitch-changed', key, value, old_val);
this.emit(`:twitch-changed:${key}`, value, old_val);
}
}
// FFZ Experiments
setOverride<
K extends keyof ExperimentTypeMap
>(key: K, value: ExperimentTypeMap[K]) {
const overrides = this.settings.provider.get('experiment-overrides', {});
overrides[key] = value;
this.settings.provider.set('experiment-overrides', overrides);
this._rebuildKey(key);
}
deleteOverride(key: keyof ExperimentTypeMap) {
const overrides = this.settings.provider.get('experiment-overrides');
if ( ! overrides || ! has(overrides, key) )
return;
delete overrides[key];
if ( Object.keys(overrides).length )
this.settings.provider.set('experiment-overrides', overrides);
else
this.settings.provider.delete('experiment-overrides');
this._rebuildKey(key);
}
hasOverride(key: keyof ExperimentTypeMap) {
const overrides = this.settings.provider.get('experiment-overrides');
return overrides ? has(overrides, key): false;
}
get: <K extends keyof ExperimentTypeMap>(
key: K
) => ExperimentTypeMap[K];
getAssignment<K extends keyof ExperimentTypeMap>(
key: K
): ExperimentTypeMap[K] {
if ( this.cache.has(key) )
return this.cache.get(key) as ExperimentTypeMap[K];
const experiment = this.experiments[key];
if ( ! experiment ) {
this.log.warn(`Tried to get assignment for experiment "${key}" which is not known.`);
return null as ExperimentTypeMap[K];
}
const overrides = this.settings.provider.get('experiment-overrides'),
out = overrides && has(overrides, key) ?
overrides[key] :
ExperimentManager.selectGroup<ExperimentTypeMap[K]>(key, experiment, this.unique_id ?? '');
this.cache.set(key, out);
return out as ExperimentTypeMap[K];
}
private _rebuildKey(key: keyof ExperimentTypeMap) {
if ( ! this.cache.has(key) )
return;
const old_val = this.cache.get(key);
this.cache.delete(key);
const new_val = this.getAssignment(key);
if ( new_val !== old_val ) {
this.emit(':changed', key, new_val, old_val);
this.emit(`:changed:${key}`, new_val, old_val);
}
}
static selectGroup<T>(
key: string,
experiment: FFZExperimentData,
unique_id: string
): T | null {
const seed = key + unique_id + (experiment.seed || ''),
total = experiment.groups.reduce((a,b) => a + b.weight, 0);
let value = (SHA1(seed).words[0] >>> 0) / Math.pow(2, 32);
for(const group of experiment.groups) {
value -= group.weight / total;
if ( value <= 0 )
return group.value as T;
}
return null;
}
}

View file

@ -4,8 +4,6 @@
// Localization
// ============================================================================
import Parser from '@ffz/icu-msgparser';
import {DEBUG, SERVER} from 'utilities/constants';
import {get, pick_random, shallow_copy, deep_copy} from 'utilities/object';
import { getBuster } from 'utilities/time';
@ -69,13 +67,11 @@ const FACES = ['(・`ω´・)', ';;w;;', 'owo', 'ono', 'oAo', 'oxo', 'ovo;', 'Uw
// TranslationManager
// ============================================================================
export class TranslationManager extends Module {
export default class TranslationManager extends Module {
constructor(...args) {
super(...args);
this.inject('settings');
this.parser = new Parser;
this._seen = new Set;
this.availableLocales = ['en'];

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

169
src/load_tracker.ts Normal file
View file

@ -0,0 +1,169 @@
'use strict';
// ============================================================================
// Loading Tracker
// ============================================================================
import Module, { GenericModule } from 'utilities/module';
import type SettingsManager from './settings';
declare module 'utilities/types' {
interface ModuleEventMap {
load_tracker: LoadEvents;
}
interface ModuleMap {
load_tracker: LoadTracker;
}
interface SettingsTypeMap {
'chat.update-when-loaded': boolean;
}
}
type PendingLoadData = {
pending: Set<string>;
timers: Record<string, ReturnType<typeof setTimeout> | null>;
success: boolean
};
export type LoadEvents = {
':schedule': [type: string, key: string],
[key: `:complete:${string}`]: [keys: string[]]
};
/**
* LoadTracker is a module used for coordinating loading events between
* the core of FrankerFaceZ and any present add-ons. This allows for
* enhanced performance by, for example, only refreshing chat messages
* once emote data has been loaded by all of a user's add-ons.
*
* @example How to use load tracker if you're loading emotes.
* ```typescript
* // Inform the load tracker that we're trying to load data.
* this.load_tracker.schedule('chat-data', 'my-addon--emotes-global');
*
* // Load our data.
* let emotes;
* try {
* emotes = await loadEmotesFromSomewhere();
* } catch(err) {
* // Notify that we failed to load, so it stops waiting.
* this.load_tracker.notify('chat-data', 'my-addon--emotes-global', false);
* return;
* }
*
* // Load the emote data.
* this.emotes.addDefaultSet('my-addon', 'my-addon--global-emotes', emotes);
*
* // Notify that we succeeded.
* this.load_tracker.notify('chat-data', 'my-addon--emotes-global', true);
* ```
*
* @noInheritDoc
*/
export default class LoadTracker extends Module<'load_tracker', LoadEvents> {
/** A map for storing information about pending loadables. */
private pending_loads: Map<string, PendingLoadData> = new Map();
// Dependencies.
settings: SettingsManager = null as any;
/** @internal */
constructor(name?: string, parent?: GenericModule) {
super(name, parent);
this.should_enable = true;
this.inject('settings');
this.settings.add('chat.update-when-loaded', {
default: true,
ui: {
path: 'Chat > Behavior >> General',
title: 'Update existing chat messages when loading new data.',
component: 'setting-check-box',
description: 'This may cause elements in chat to move, so you may wish to disable this when performing moderation.'
}
});
}
/** @internal */
onEnable() {
this.emit('load_tracker:schedule', 'test', 'fish');
this.on(':schedule', this.schedule);
}
/**
* Register our intent to perform a load. This lets the system know that
* a load of {@link type} is pending, and it starts a wait of 15 seconds
* for the load to complete.
*
* You must, after using this, call {@link notify} when your load
* completes or fails. That ensures that the system does not wait
* needlessly after your load process has finished.
*
* @param type The load type.
* @param key A unique key for your load, on this load type. If you are
* loading multiple times (for example, global emotes and channel-specific
* emotes), you should use two distinct keys.
*/
schedule(type: string, key: string) {
let data = this.pending_loads.get(type);
if ( ! data || ! data.pending || ! data.timers ) {
data = {
pending: new Set,
timers: {},
success: false
};
this.pending_loads.set(type, data);
}
if ( data.pending.has(key) )
return;
data.pending.add(key);
data.timers[key] = setTimeout(() => this.notify(type, key, false), 15000);
}
/**
* Notify the load tracker that your load has completed. If all loads
* for the given type have been completed, and any of the loads were
* a success, then a `:complete:${type}` event will be fired.
* @param type The load type.
* @param key A unique key for your load. The same that you use
* with {@link schedule}.
* @param success Whether or not your load was a success.
*/
notify(type: string, key: string, success = true) {
const data = this.pending_loads.get(type);
if ( ! data || ! data.pending || ! data.timers )
return;
if ( data.timers[key] ) {
clearTimeout(data.timers[key] as any);
data.timers[key] = null;
}
if ( ! data.pending.has(key) )
return;
data.pending.delete(key);
if ( success )
data.success = true;
if ( ! data.pending.size ) {
const keys = Object.keys(data.timers);
this.log.debug('complete', type, keys);
if ( data.success )
this.emit(`:complete:${type}`, keys);
this.pending_loads.delete(type);
}
}
}

View file

@ -4,7 +4,7 @@ import dayjs from 'dayjs';
//import RavenLogger from './raven';
import Logger from 'utilities/logging';
import Module from 'utilities/module';
import Module, { State } from 'utilities/module';
import { timeout } from 'utilities/object';
import {DEBUG} from 'utilities/constants';
@ -12,16 +12,87 @@ import {DEBUG} from 'utilities/constants';
import SettingsManager from './settings/index';
import AddonManager from './addons';
import ExperimentManager from './experiments';
import {TranslationManager} from './i18n';
import TranslationManager from './i18n';
import SocketClient from './socket';
import PubSubClient from './pubsub';
import Site from 'site';
import Vue from 'utilities/vue';
import VueModule from 'utilities/vue';
import StagingSelector from './staging';
import LoadTracker from './load_tracker';
//import Timing from 'utilities/timing';
import type { ClientVersion } from 'utilities/types';
import * as Utility_Addons from 'utilities/addon';
import * as Utility_Blobs from 'utilities/blobs';
import * as Utility_Color from 'utilities/color';
import * as Utility_Constants from 'utilities/constants';
import * as Utility_Dialog from 'utilities/dialog';
import * as Utility_DOM from 'utilities/dom';
import * as Utility_Events from 'utilities/events';
import * as Utility_FontAwesome from 'utilities/font-awesome';
import * as Utility_GraphQL from 'utilities/graphql';
import * as Utility_Logging from 'utilities/logging';
import * as Utility_Module from 'utilities/module';
import * as Utility_Object from 'utilities/object';
import * as Utility_Time from 'utilities/time';
import * as Utility_Tooltip from 'utilities/tooltip';
import * as Utility_I18n from 'utilities/translation-core';
import * as Utility_Filtering from 'utilities/filtering';
class FrankerFaceZ extends Module {
static instance: FrankerFaceZ = null as any;
static version_info: ClientVersion = null as any;
static Logger = Logger;
static utilities = {
addon: Utility_Addons,
blobs: Utility_Blobs,
color: Utility_Color,
constants: Utility_Constants,
dialog: Utility_Dialog,
dom: Utility_DOM,
events: Utility_Events,
fontAwesome: Utility_FontAwesome,
graphql: Utility_GraphQL,
logging: Utility_Logging,
module: Utility_Module,
object: Utility_Object,
time: Utility_Time,
tooltip: Utility_Tooltip,
i18n: Utility_I18n,
filtering: Utility_Filtering
};
/*
static utilities = {
addon: require('utilities/addon'),
blobs: require('utilities/blobs'),
color: require('utilities/color'),
constants: require('utilities/constants'),
dialog: require('utilities/dialog'),
dom: require('utilities/dom'),
events: require('utilities/events'),
fontAwesome: require('utilities/font-awesome'),
graphql: require('utilities/graphql'),
logging: require('utilities/logging'),
module: require('utilities/module'),
object: require('utilities/object'),
time: require('utilities/time'),
tooltip: require('utilities/tooltip'),
i18n: require('utilities/translation-core'),
dayjs: require('dayjs'),
filtering: require('utilities/filtering'),
popper: require('@popperjs/core')
};
*/
core_log: Logger;
host: string;
flavor: string;
constructor() {
super();
const start_time = performance.now();
@ -31,12 +102,14 @@ class FrankerFaceZ extends Module {
this.host = 'twitch';
this.flavor = 'main';
this.name = 'frankerfacez';
this.__state = 0;
this.__modules.core = this;
// Evil private member access.
(this as any).__state = State.Disabled;
(this as any).__modules.core = this;
// Timing
//this.inject('timing', Timing);
this.__time('instance');
this._time('instance');
// ========================================================================
// Error Reporting and Logging
@ -48,7 +121,7 @@ class FrankerFaceZ extends Module {
this.log.init = true;
this.core_log = this.log.get('core');
this.log.hi(this);
this.log.hi(this, FrankerFaceZ.version_info);
// ========================================================================
@ -65,7 +138,7 @@ class FrankerFaceZ extends Module {
this.inject('site', Site);
this.inject('addons', AddonManager);
this.register('vue', Vue);
this.register('vue', VueModule);
// ========================================================================
@ -96,14 +169,13 @@ class FrankerFaceZ extends Module {
async generateLog() {
const promises = [];
for(const key in this.__modules) {
const module = this.__modules[key];
if ( module instanceof Module && module.generateLog && module != this )
for(const [key, module] of Object.entries((this as any).__modules)) {
if ( module instanceof Module && module.generateLog && (module as any) != this )
promises.push((async () => {
try {
return [
key,
await timeout(Promise.resolve(module.generateLog()), 5000)
await timeout(Promise.resolve((module as any).generateLog()), 5000)
];
} catch(err) {
return [
@ -141,11 +213,11 @@ ${typeof x[1] === 'string' ? x[1] : JSON.stringify(x[1], null, 4)}`).join('\n\n'
const ctx = await require.context(
'src/modules',
true,
/(?:^(?:\.\/)?[^/]+|index)\.jsx?$/
/(?:^(?:\.\/)?[^/]+|index)\.[jt]sx?$/
/*, 'lazy-once' */
);
const modules = this.populate(ctx, this.core_log);
const modules = this.loadFromContext(ctx, this.core_log);
this.core_log.info(`Loaded descriptions of ${Object.keys(modules).length} modules.`);
}
@ -153,20 +225,17 @@ ${typeof x[1] === 'string' ? x[1] : JSON.stringify(x[1], null, 4)}`).join('\n\n'
async enableInitialModules() {
const promises = [];
/* eslint guard-for-in: off */
for(const key in this.__modules) {
const module = this.__modules[key];
for(const module of Object.values((this as any).__modules)) {
if ( module instanceof Module && module.should_enable )
promises.push(module.enable());
}
await Promise.all(promises);
return Promise.all(promises);
}
}
FrankerFaceZ.Logger = Logger;
const VER = FrankerFaceZ.version_info = Object.freeze({
const VER: ClientVersion = FrankerFaceZ.version_info = Object.freeze({
major: __version_major__,
minor: __version_minor__,
revision: __version_patch__,
@ -179,27 +248,14 @@ const VER = FrankerFaceZ.version_info = Object.freeze({
});
FrankerFaceZ.utilities = {
addon: require('utilities/addon'),
blobs: require('utilities/blobs'),
color: require('utilities/color'),
constants: require('utilities/constants'),
dialog: require('utilities/dialog'),
dom: require('utilities/dom'),
events: require('utilities/events'),
fontAwesome: require('utilities/font-awesome'),
graphql: require('utilities/graphql'),
logging: require('utilities/logging'),
module: require('utilities/module'),
object: require('utilities/object'),
time: require('utilities/time'),
tooltip: require('utilities/tooltip'),
i18n: require('utilities/translation-core'),
dayjs: require('dayjs'),
filtering: require('utilities/filtering'),
popper: require('@popperjs/core')
}
export default FrankerFaceZ;
declare global {
interface Window {
FrankerFaceZ: typeof FrankerFaceZ;
ffz: FrankerFaceZ;
}
}
window.FrankerFaceZ = FrankerFaceZ;
window.ffz = new FrankerFaceZ();

View file

@ -614,7 +614,7 @@ export default class Actions extends Module {
},
onMove: (target, tip, event) => {
this.emit('tooltips:mousemove', target, tip, event)
this.emit('tooltips:hover', target, tip, event)
},
onLeave: (target, tip, event) => {

View file

@ -80,7 +80,7 @@ export default {
if ( ! ds )
return;
const evt = new FFZEvent({
const evt = FFZEvent.makeEvent({
url: ds.url ?? target.href,
source: event
});

View file

@ -6,7 +6,7 @@
import Module, { buildAddonProxy } from 'utilities/module';
import {ManagedStyle} from 'utilities/dom';
import { FFZEvent } from 'utilities/events';
import {get, has, timeout, SourcedSet, make_enum_flags, makeAddonIdChecker} from 'utilities/object';
import {NEW_API, IS_OSX, EmoteTypes, TWITCH_GLOBAL_SETS, TWITCH_POINTS_SETS, TWITCH_PRIME_SETS, DEBUG} from 'utilities/constants';
@ -1315,7 +1315,7 @@ export default class Emotes extends Module {
/* no-op */
}
const evt = new FFZEvent({
const evt = this.makeEvent({
provider,
id: ds.id,
set: ds.set,

View file

@ -10,7 +10,6 @@ import { DEBUG, LINK_DATA_HOSTS } from 'utilities/constants';
import Module, { buildAddonProxy } from 'utilities/module';
import {Color} from 'utilities/color';
import {createElement, ManagedStyle} from 'utilities/dom';
import {FFZEvent} from 'utilities/events';
import {getFontsList} from 'utilities/fonts';
import {timeout, has, addWordSeparators, glob_to_regex, escape_regex, split_chars, makeAddonIdChecker} from 'utilities/object';
@ -1800,7 +1799,7 @@ export default class Chat extends Module {
if ( ! ds )
return;
const evt = new FFZEvent({
const evt = this.makeEvent({
url: ds.url ?? target.href,
source: event
});
@ -1811,7 +1810,6 @@ export default class Chat extends Module {
event.stopPropagation();
return true;
}
}

View file

@ -4,14 +4,43 @@
// Name and Color Overrides
// ============================================================================
import Module from 'utilities/module';
import Module, { GenericModule } from 'utilities/module';
import { createElement, ClickOutside } from 'utilities/dom';
import Tooltip from 'utilities/tooltip';
import type SettingsManager from 'root/src/settings';
export default class Overrides extends Module {
constructor(...args) {
super(...args);
declare module 'utilities/types' {
interface ModuleMap {
'chat.overrides': Overrides;
}
interface ModuleEventMap {
'chat.overrides': OverrideEvents;
}
interface ProviderTypeMap {
'overrides.colors': Record<string, string | undefined>;
'overrides.names': Record<string, string | undefined>;
}
}
export type OverrideEvents = {
':changed': [id: string, type: 'name' | 'color', value: string | undefined];
}
export default class Overrides extends Module<'chat.overrides'> {
// Dependencies
settings: SettingsManager = null as any;
// State and Caching
color_cache: Record<string, string | undefined> | null;
name_cache: Record<string, string | undefined> | null;
constructor(name?: string, parent?: GenericModule) {
super(name, parent);
this.inject('settings');
@ -35,12 +64,15 @@ export default class Overrides extends Module {
});*/
}
/** @internal */
onEnable() {
this.settings.provider.on('changed', this.onProviderChange, this);
}
renderUserEditor(user, target) {
let outside, popup, ve;
renderUserEditor(user: any, target: HTMLElement) {
let outside: ClickOutside | null,
popup: Tooltip | null,
ve: any;
const destroy = () => {
const o = outside, p = popup, v = ve;
@ -56,7 +88,10 @@ export default class Overrides extends Module {
v.$destroy();
}
const parent = document.fullscreenElement || document.body.querySelector('#root>div') || document.body;
const parent =
document.fullscreenElement as HTMLElement
?? document.body.querySelector<HTMLElement>('#root>div')
?? document.body;
popup = new Tooltip(parent, [], {
logger: this.log,
@ -88,6 +123,9 @@ export default class Overrides extends Module {
const vue = this.resolve('vue'),
_editor = import(/* webpackChunkName: "overrides" */ './override-editor.vue');
if ( ! vue )
throw new Error('unable to load vue');
const [, editor] = await Promise.all([vue.enable(), _editor]);
vue.component('override-editor', editor.default);
@ -118,12 +156,13 @@ export default class Overrides extends Module {
onShow: async (t, tip) => {
await tip.waitForDom();
requestAnimationFrame(() => {
if ( tip.outer )
outside = new ClickOutside(tip.outer, destroy)
});
},
onMove: (target, tip, event) => {
this.emit('tooltips:mousemove', target, tip, event)
this.emit('tooltips:hover', target, tip, event)
},
onLeave: (target, tip, event) => {
@ -137,30 +176,25 @@ export default class Overrides extends Module {
}
onProviderChange(key) {
if ( key === 'overrides.colors' )
onProviderChange(key: string) {
if ( key === 'overrides.colors' && this.color_cache )
this.loadColors();
else if ( key === 'overrides.names' )
else if ( key === 'overrides.names' && this.name_cache )
this.loadNames();
}
get colors() {
if ( ! this.color_cache )
this.loadColors();
return this.color_cache;
return this.color_cache ?? this.loadColors();
}
get names() {
if ( ! this.name_cache )
this.loadNames();
return this.name_cache;
return this.name_cache ?? this.loadNames();
}
loadColors() {
let old_keys,
let old_keys: Set<string>,
loaded = true;
if ( ! this.color_cache ) {
loaded = false;
this.color_cache = {};
@ -168,7 +202,9 @@ export default class Overrides extends Module {
} else
old_keys = new Set(Object.keys(this.color_cache));
for(const [key, val] of Object.entries(this.settings.provider.get('overrides.colors', {}))) {
const entries = this.settings.provider.get('overrides.colors');
if ( entries )
for(const [key, val] of Object.entries(entries)) {
old_keys.delete(key);
if ( this.color_cache[key] !== val ) {
this.color_cache[key] = val;
@ -182,10 +218,12 @@ export default class Overrides extends Module {
if ( loaded )
this.emit(':changed', key, 'color', undefined);
}
return this.color_cache;
}
loadNames() {
let old_keys,
let old_keys: Set<string>,
loaded = true;
if ( ! this.name_cache ) {
loaded = false;
@ -194,7 +232,9 @@ export default class Overrides extends Module {
} else
old_keys = new Set(Object.keys(this.name_cache));
for(const [key, val] of Object.entries(this.settings.provider.get('overrides.names', {}))) {
const entries = this.settings.provider.get('overrides.names');
if ( entries )
for(const [key, val] of Object.entries(entries)) {
old_keys.delete(key);
if ( this.name_cache[key] !== val ) {
this.name_cache[key] = val;
@ -208,23 +248,19 @@ export default class Overrides extends Module {
if ( loaded )
this.emit(':changed', key, 'name', undefined);
}
return this.name_cache;
}
getColor(id) {
if ( this.colors[id] != null )
return this.colors[id];
return null;
getColor(id: string): string | null {
return this.colors[id] ?? null;
}
getName(id) {
if ( this.names[id] != null )
return this.names[id];
return null;
getName(id: string) {
return this.names[id] ?? null;
}
setColor(id, color) {
setColor(id: string, color?: string) {
if ( this.colors[id] !== color ) {
this.colors[id] = color;
this.settings.provider.set('overrides.colors', this.colors);
@ -232,7 +268,7 @@ export default class Overrides extends Module {
}
}
setName(id, name) {
setName(id: string, name?: string) {
if ( this.names[id] !== name ) {
this.names[id] = name;
this.settings.provider.set('overrides.names', this.names);
@ -240,11 +276,11 @@ export default class Overrides extends Module {
}
}
deleteColor(id) {
deleteColor(id: string) {
this.setColor(id, undefined);
}
deleteName(id) {
deleteName(id: string) {
this.setName(id, undefined);
}
}

View file

@ -0,0 +1,8 @@
// ============================================================================
// Badges
// ============================================================================
export type BadgeAssignment = {
id: string;
};

View file

@ -5,20 +5,39 @@
// ============================================================================
import {SourcedSet} from 'utilities/object';
import type Chat from '.';
import type Room from './room';
import type { BadgeAssignment } from './types';
export default class User {
constructor(manager, room, id, login) {
// Parent
manager: Chat;
room: Room | null;
// State
destroyed: boolean = false;
_id: string | null;
_login: string | null = null;
// Storage
emote_sets: SourcedSet<string> | null;
badges: SourcedSet<BadgeAssignment> | null;
constructor(manager: Chat, room: Room | null, id: string | null, login: string | null) {
this.manager = manager;
this.room = room;
this.emote_sets = null; //new SourcedSet;
this.badges = null; // new SourcedSet;
this.emote_sets = null;
this.badges = null;
this._id = id;
this.login = login;
if ( id )
(room || manager).user_ids[id] = this;
(room ?? manager).user_ids[id] = this;
}
destroy() {
@ -31,6 +50,7 @@ export default class User {
this.emote_sets = null;
}
// Badges are not referenced, so we can just dump them all.
if ( this.badges )
this.badges = null;
@ -45,26 +65,24 @@ export default class User {
}
}
merge(other) {
merge(other: User) {
if ( ! this.login && other.login )
this.login = other.login;
if ( other.emote_sets && other.emote_sets._sources ) {
for(const [provider, sets] of other.emote_sets._sources.entries()) {
if ( other.emote_sets )
for(const [provider, sets] of other.emote_sets.iterateSources()) {
for(const set_id of sets)
this.addSet(provider, set_id);
}
}
if ( other.badges && other.badges._sources ) {
for(const [provider, badges] of other.badges._sources.entries()) {
if ( other.badges )
for(const [provider, badges] of other.badges.iterateSources()) {
for(const badge of badges)
this.addBadge(provider, badge.id, badge);
}
}
}
_unloadAddon(addon_id) {
_unloadAddon(addon_id: string) {
// TODO: This
return 0;
}
@ -107,9 +125,9 @@ export default class User {
// Add Badges
// ========================================================================
addBadge(provider, badge_id, data) {
addBadge(provider: string, badge_id: string, data?: BadgeAssignment) {
if ( this.destroyed )
return;
return false;
if ( typeof badge_id === 'number' )
badge_id = `${badge_id}`;
@ -122,8 +140,9 @@ export default class User {
if ( ! this.badges )
this.badges = new SourcedSet;
if ( this.badges.has(provider) )
for(const old_b of this.badges.get(provider))
const existing = this.badges.get(provider);
if ( existing )
for(const old_b of existing)
if ( old_b.id == badge_id ) {
Object.assign(old_b, data);
return false;
@ -135,31 +154,35 @@ export default class User {
}
getBadge(badge_id) {
if ( ! this.badges )
return null;
getBadge(badge_id: string) {
if ( this.badges )
for(const badge of this.badges._cache)
if ( badge.id == badge_id )
return badge;
return null;
}
removeBadge(provider, badge_id) {
if ( ! this.badges || ! this.badges.has(provider) )
removeBadge(provider: string, badge_id: string) {
if ( ! this.badges )
return false;
for(const old_b of this.badges.get(provider))
const existing = this.badges.get(provider);
if ( existing )
for(const old_b of existing)
if ( old_b.id == badge_id ) {
this.badges.remove(provider, old_b);
//this.manager.badges.unrefBadge(badge_id);
return true;
}
return false;
}
removeAllBadges(provider) {
if ( this.destroyed || ! this.badges )
removeAllBadges(provider: string) {
if ( ! this.badges )
return false;
if ( ! this.badges.has(provider) )
@ -175,7 +198,7 @@ export default class User {
// Emote Sets
// ========================================================================
addSet(provider, set_id, data) {
addSet(provider: string, set_id: string, data?: unknown) {
if ( this.destroyed )
return;
@ -203,8 +226,8 @@ export default class User {
return added;
}
removeAllSets(provider) {
if ( this.destroyed || ! this.emote_sets )
removeAllSets(provider: string) {
if ( ! this.emote_sets )
return false;
const sets = this.emote_sets.get(provider);
@ -217,8 +240,8 @@ export default class User {
return true;
}
removeSet(provider, set_id) {
if ( this.destroyed || ! this.emote_sets )
removeSet(provider: string, set_id: string) {
if ( ! this.emote_sets )
return;
if ( typeof set_id === 'number' )

View file

@ -633,7 +633,7 @@ export default {
// TODO: Update timestamps for pinned chat?
}
this.chat.resolve('site.subpump').inject(item.topic, item.data);
this.chat.resolve('site.subpump').simulateMessage(item.topic, item.data);
}
if ( item.chat ) {

View file

@ -173,7 +173,7 @@
@change="onTwitchChange($event)"
>
<option
v-if="exp.in_use === false"
v-if="exp.value === null"
:selected="exp.default"
>
{{ t('setting.experiments.unset', 'unset') }}

View file

@ -55,8 +55,8 @@
<script>
import Sortable from 'sortablejs';
import {deep_copy, maybe_call, generateUUID} from 'utilities/object';
import {findSharedParent} from 'utilities/dom';
import { deep_copy, maybe_call, generateUUID } from 'utilities/object';
import { hasSharedParent } from 'utilities/dom';
export default {
props: {
@ -131,7 +131,7 @@ export default {
// Check to see if we have a common ancester for the two
// draggables.
if ( ! findSharedParent(to.el, from.el, '.ffz--rule-list') )
if ( ! hasSharedParent(to.el, from.el, '.ffz--rule-list') )
return false;
return true;

View file

@ -77,7 +77,7 @@ export default {
this.client = this.ffz.resolve('site.apollo')?.client;
this.has_client = !! this.client;
this.printer = this.ffz.resolve('site.web_munch')?.getModule?.('gql-printer');
this.printer = this.ffz.resolve('site.web_munch')?.getModule('gql-printer');
this.has_printer = !! this.printer;
},
@ -119,8 +119,8 @@ export default {
result: null
});
this.queryMap[name].variables = deep_copy(query.observableQuery?.variables);
this.queryMap[name].result = deep_copy(query.observableQuery?.lastResult?.data ?? null);
this.queryMap[name].variables = deep_copy(query.observableQuery?.last?.variables ?? query.observableQuery?.variables);
this.queryMap[name].result = deep_copy(query.observableQuery?.lastResult?.data ?? query.observableQuery?.last?.result?.data ?? null);
}
if ( ! this.current )

View file

@ -165,7 +165,7 @@
<figure class="ffz-i-discord tw-font-size-3" />
</span>
</a>
<a
<!--a
:data-title="t('home.twitter', 'Twitter')"
class="tw-flex-grow-1 tw-button ffz-tooltip ffz--twitter-button tw-mg-r-1"
href="https://twitter.com/frankerfacez"
@ -175,7 +175,7 @@
<span class="tw-button__icon tw-pd-05">
<figure class="ffz-i-twitter tw-font-size-3" />
</span>
</a>
</a-->
<a
:data-title="t('home.github', 'GitHub')"
class="tw-flex-grow-1 tw-button ffz-tooltip ffz--github-button"
@ -189,7 +189,12 @@
</a>
</div>
<template v-if="not_extension">
<rich-feed
url="https://bsky-feed.special.frankerfacez.com/user::frankerfacez.com"
:context="context"
/>
<!--template v-if="not_extension">
<a
:data-theme="theme"
class="twitter-timeline"
@ -198,7 +203,7 @@
>
{{ t('home.tweets', 'Tweets by FrankerFaceZ') }}
</a>
</template>
</template-->
</div>
</div>
</template>
@ -221,7 +226,7 @@ export default {
addons: null,
new_addons: null,
unseen: this.item.getUnseen(),
not_extension: ! EXTENSION
//not_extension: ! EXTENSION
}
},
@ -243,7 +248,7 @@ export default {
ffz.off('addons:data-loaded', this.updateAddons, this);
},
mounted() {
/*mounted() {
let el;
if ( this.not_extension )
document.head.appendChild(el = e('script', {
@ -253,7 +258,7 @@ export default {
src: 'https://platform.twitter.com/widgets.js',
onLoad: () => el.remove()
}));
},
},*/
methods: {
updateUnseen() {

View file

@ -2,10 +2,47 @@
<div class="ffz--menu-page">
<header class="tw-mg-b-1">
<span v-for="i in breadcrumbs" :key="i.full_key">
<a v-if="i !== item" href="#" @click="$emit('change-item', i, false)">{{ t(i.i18n_key, i.title) }}</a>
<a v-if="i !== item" href="#" @click.prevent="$emit('change-item', i, false)">{{ t(i.i18n_key, i.title) }}</a>
<strong v-if="i === item">{{ t(i.i18n_key, i.title) }}</strong>
<template v-if="i !== item">&raquo; </template>
</span>
<span v-if="item.header_links" class="ffz--menu-page__header-links">
<span class="tw-mg-x-05"></span>
<template v-for="i in item.header_links">
<a
v-if="i.href && i.href.startsWith('~')"
class="tw-mg-r-05"
href="#"
@click.prevent="$emit('navigate', i.href.slice(1))"
>{{
t(i.i18n_key, i.title)
}}</a>
<react-link
v-else-if="i.href"
class="tw-mg-r-05"
:href="i.href"
:state="i.state"
>{{
t(i.i18n_key, i.title)
}}</react-link>
<a
v-else-if="i.navigate"
class="tw-mg-r-05"
href="#"
@click.prevent="navigate(...i.navigate)"
>{{
t(i.i18n_key, i.title)
}}</a>
<a
v-else-if="i.target"
class="tw-mg-r-05"
href="#"
@click.prevent="$emit('change-item', i.target, false)"
>{{
t(i.i18n_key, i.title)
}}</a>
</template>
</span>
</header>
<section v-if="context.currentProfile.ephemeral && item.profile_warning !== false" class="tw-border-t tw-pd-t-1 tw-pd-b-2">
<div class="tw-c-background-accent tw-c-text-overlay tw-pd-1">

View file

@ -0,0 +1,89 @@
<template>
<div v-if="feed">
<chat-rich
v-for="(entry, idx) in feed"
:key="idx"
:data="entry"
class="tw-mg-b-1"
/>
</div>
</template>
<script>
import { maybe_call } from 'utilities/object';
export default {
components: {
'chat-rich': async () => {
const stuff = await import(/* webpackChunkName: "chat" */ 'src/modules/chat/components');
return stuff.default('./chat-rich.vue').default;
}
},
props: ['context', 'url'],
data() {
return {
loading: false,
error: null,
feed: null
}
},
created() {
this.loadFromURL();
},
methods: {
async loadFromURL() {
if ( this.loading )
return;
this.loading = true;
this.error = null;
this.feed = null;
const chat = this.context.getFFZ().resolve('chat'),
url = this.url;
if ( ! url ) {
this.loading = false;
this.error = null;
this.feed = [];
return;
}
let data;
try {
data = await chat.get_link_info(url, false, false);
} catch(err) {
this.loading = false;
this.error = err;
return;
}
if ( ! data?.v ) {
this.error = 'Invalid response.';
this.loading = false;
return;
}
if ( ! data.feed )
data = {feed: [data]};
this.feed = data.feed.map(entry => {
entry.allow_media = true;
entry.allow_unsafe = false;
return {
getData: () => entry
}
});
this.loading = false;
}
}
}
</script>

View file

@ -492,7 +492,7 @@ export default class MainMenu extends Module {
current = tree.keys[state.ffzcc];
if ( ! current ) {
const params = new URL(window.location).searchParams,
key = params?.get?.('ffz-settings');
key = params?.get('ffz-settings');
current = key && tree.keys[key];
}
if ( ! current )
@ -1161,7 +1161,7 @@ export default class MainMenu extends Module {
restored = false;
} if ( ! current ) {
const params = new URL(window.location).searchParams,
key = params?.get?.('ffz-settings');
key = params?.get('ffz-settings');
current = key && settings.keys[key];
if ( ! current )
restored = false;

View file

@ -1,23 +1,230 @@
'use strict';
// ============================================================================
// Channel Metadata
// ============================================================================
import { DEBUG } from 'utilities/constants';
import {createElement, ClickOutside, setChildren} from 'utilities/dom';
import {maybe_call} from 'utilities/object';
import Module, { buildAddonProxy, GenericModule } from 'utilities/module';
import {duration_to_string, durationForURL} from 'utilities/time';
import Tooltip, { TooltipInstance } from 'utilities/tooltip';
import type { AddonInfo, DomFragment, OptionallyThisCallable, OptionalPromise } from 'utilities/types';
import Tooltip from 'utilities/tooltip';
import Module from 'utilities/module';
import { DEBUG } from 'src/utilities/constants';
import type SettingsManager from '../settings';
import type TranslationManager from '../i18n';
import type TooltipProvider from './tooltips';
import type SocketClient from '../socket';
const CLIP_URL = /^https:\/\/[^/]+\.(?:twitch\.tv|twitchcdn\.net)\/.+?\.mp4(?:\?.*)?$/;
declare global {
interface Element {
_ffz_stat?: HTMLElement | null;
_ffz_data?: any;
_ffz_order?: number | null;
_ffz_destroy?: (() => void) | null;
_ffz_outside?: ClickOutside<any> | null;
_ffz_popup?: Tooltip | null;
tip?: TooltipInstance | null;
tip_content?: any;
}
}
declare module 'utilities/types' {
interface ModuleMap {
metadata: Metadata
}
interface SettingsTypeMap {
'metadata.clip-download': boolean;
'metadata.clip-download.force': boolean;
'metadata.player-stats': boolean;
'metadata.uptime': number;
'metadata.stream-delay-warning': number;
'metadata.viewers': boolean;
}
}
export type MetadataState = {
/** Whether or not the metadata is being rendered onto the player directly. */
is_player: boolean;
/** The current channel. */
channel: {
/** The channel's user ID. */
id: string;
/** The channel's login name. */
login: string;
/** The channel's display name. */
display_name: string;
/** Whether or not the channel is currently displaying a video. */
video: boolean;
/** Whether or not the channel is currently live. */
live: boolean;
/** When the channel went live, if it is currently live. */
live_since: string | Date;
};
/** Get the current number of viewers watching the current channel. */
getViewerCount: () => number;
/** Get the broadcast ID of the current live broadcast, assuming the current channel is live. */
getBroadcastID: () => string | null;
/** Get the currently logged in user's relationship with the current channel. */
// TODO: Types
getUserSelf: () => Promise<any>;
/**
* Get the currently logged in user's relationship with the current
* channel, immediately. When data loads, if it is not already available
* at the time of the call, and a callback method is provided, the
* callback method will be called with the data.
*/
// TODO: Types
getUserSelfImmediate: (callback?: (data: any) => void) => any | null;
/** A method that, when called, will trigger the metadata element to be refreshed. */
refresh: () => void;
}
type OptionallyCallable<TData, TReturn> = OptionallyThisCallable<Metadata, [data: TData], TReturn>;
/**
* A metadata definition contains all the information that FrankerFaceZ
* needs in order to render a player metadata element. This includes special
* data processing, how often to refresh, behavior when interacted with,
* and various appearance options.
*/
export type MetadataDefinition<TData = MetadataState> = {
// Targets
modview?: boolean;
player?: boolean;
// Behavior
/**
* Optional. If present, this setup method will be called whenever
* processing this metadata element in order to transform its data
* into a prefered format.
*/
setup?: (this: Metadata, data: MetadataState) => OptionalPromise<TData>;
/**
* Optional. Whether or not this metadata element should refresh itself
* periodically. This can be a specific amount of time, in milliseconds,
* after which the element should be refreshed or `true` to refresh
* after 1 second.
*
* Note: Your metadata might not refresh after the exact length, as
* the metadata manager will attempt to optimize rendering performance
* by using animation frames and batching.
*/
refresh?: OptionallyCallable<TData, boolean | number>;
/**
* Optional. A click handler for the metadata element.
* @param data Your state, as returned from {@link setup}
* @param event The {@link MouseEvent} being handled.
* @param refresh A method that, when called, manually refreshes
* your metadata.
*/
click?: (this: Metadata, data: TData, event: MouseEvent, refresh: () => void) => void;
/**
* Optional. If this returns true, interactions with your metadata
* element will be disabled and the element may appear with a visual
* disabled state.
*/
disabled?: OptionallyCallable<TData, boolean>;
// Appearance
/**
* The label for this metadata element. If no label is returned, the
* metadata element will not be displayed. This should be a
* human-readable string.
*/
label: OptionallyCallable<TData, DomFragment>;
tooltip?: OptionallyCallable<TData, DomFragment>;
/**
* Optional. What order this metadata element should be displayed in.
* This uses CSS's flexbox's order property to adjust the visible
* position of each metadata element.
*/
order?: OptionallyCallable<TData, number>;
/**
* Optional. The color that the metadata element's label should be. If
* this is not set, the default text color will be used.
*/
color?: OptionallyCallable<TData, string | null | undefined>;
/**
* Optional. An icon to be displayed
*/
icon?: OptionallyCallable<TData, DomFragment>;
// Button Appearance
/**
* Optional. Whether or not this metadata element should be displayed
* with a button style. By default, elements are displayed with a button
* style if they have a {@link popup} or {@link click} behavior defined.
*
* You can override the appearance using this value.
*/
button?: boolean;
border?: OptionallyCallable<TData, boolean>;
inherit?: OptionallyCallable<TData, boolean>;
// Popup Appearance and Behavior
/**
* Optional. When this is true, an arrow element will not be created
* when building a popup for this metadata element.
*/
no_arrow?: boolean;
popup?: (this: Metadata, data: TData, tip: TooltipInstance, refresh: () => void, addCloseListener: (callback: () => void) => void) => void;
/**
* The source that added this metadata definition. This will be unset
* if the metadata was added by FrankerFaceZ, or contain the add-on ID
* of an add-on.
*/
__source?: string;
}
/**
* @noInheritDoc
*/
export default class Metadata extends Module {
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('i18n');
@ -105,7 +312,7 @@ export default class Metadata extends Module {
});
this.definitions.viewers = {
this.define('viewers', {
refresh() { return this.settings.get('metadata.viewers') },
@ -131,10 +338,11 @@ export default class Metadata extends Module {
},
color: 'var(--color-text-live)'
};
});
this.definitions.uptime = {
this.define('uptime', {
inherit: true,
no_arrow: true,
player: true,
@ -142,20 +350,15 @@ export default class Metadata extends Module {
refresh() { return this.settings.get('metadata.uptime') > 0 },
setup(data) {
const socket = this.resolve('socket');
let created = data?.channel?.live_since;
if ( ! created ) {
const created_at = data?.meta?.createdAt;
if ( ! created_at )
return {};
created = created_at;
}
if ( ! created )
return null;
if ( !(created instanceof Date) )
created = new Date(created);
const now = Date.now() - socket._time_drift;
const socket = this.resolve('socket');
const now = Date.now() - (socket?._time_drift ?? 0);
return {
created,
@ -169,16 +372,14 @@ export default class Metadata extends Module {
label(data) {
const setting = this.settings.get('metadata.uptime');
if ( ! setting || ! data.created )
if ( ! setting || ! data?.created )
return null;
return duration_to_string(data.uptime, false, false, false, setting !== 2);
},
subtitle: () => this.i18n.t('metadata.uptime.subtitle', 'Uptime'),
tooltip(data) {
if ( ! data.created )
if ( ! data?.created )
return null;
return [
@ -197,8 +398,13 @@ export default class Metadata extends Module {
},
async popup(data, tip) {
if ( ! data )
return;
const [permission, broadcast_id] = await Promise.all([
navigator?.permissions?.query?.({name: 'clipboard-write'}).then(perm => perm?.state).catch(() => null),
// We need the as any here because TypeScript's devs don't
// live with the rest of us in the real world.
navigator?.permissions?.query?.({name: 'clipboard-write' as PermissionName}).then(perm => perm?.state).catch(() => null),
data.getBroadcastID()
]);
if ( ! broadcast_id )
@ -209,13 +415,13 @@ export default class Metadata extends Module {
const url = `https://www.twitch.tv/videos/${broadcast_id}${data.uptime > 0 ? `?t=${durationForURL(data.uptime)}` : ''}`,
can_copy = permission === 'granted' || permission === 'prompt';
const copy = can_copy ? e => {
const copy = can_copy ? (event: MouseEvent) => {
navigator.clipboard.writeText(url);
e.preventDefault();
event.preventDefault();
return false;
} : null;
tip.element.classList.add('ffz-balloon--lg');
tip.element?.classList.add('ffz-balloon--lg');
return (<div>
<div class="tw-pd-b-1 tw-mg-b-1 tw-border-b tw-semibold">
@ -228,7 +434,7 @@ export default class Metadata extends Module {
class="tw-border-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 ffz-input tw-full-width"
type="text"
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">
<button
@ -249,9 +455,9 @@ export default class Metadata extends Module {
</div>
</div>);
}
}
});
this.definitions['clip-download'] = {
this.define('clip-download', {
button: true,
inherit: true,
@ -259,7 +465,8 @@ export default class Metadata extends Module {
if ( ! this.settings.get('metadata.clip-download') )
return;
const Player = this.resolve('site.player'),
// TODO: Types
const Player = this.resolve('site.player') as any,
player = Player.current;
if ( ! player )
return;
@ -271,13 +478,14 @@ export default class Metadata extends Module {
return;
if ( this.settings.get('metadata.clip-download.force') )
return src;
return src as string;
const user = this.resolve('site').getUser?.(),
// TODO: Types
const user = (this.resolve('site') as any).getUser?.(),
is_self = user?.id == data.channel.id;
if ( is_self || data.getUserSelfImmediate(data.refresh)?.isEditor )
return src;
return src as string;
},
label(src) {
@ -288,18 +496,25 @@ export default class Metadata extends Module {
icon: 'ffz-i-download',
click(src) {
const title = this.settings.get('context.title');
const title = this.settings.get('context.title') || 'Untitled';
const name = title.replace(/[\\/:"*?<>|]+/, '_') + '.mp4';
const link = createElement('a', {target: '_blank', download: name, href: src, style: {display: 'none'}});
const link = createElement('a', {
target: '_blank',
download: name,
href: src,
style: {
display: 'none'
}
});
document.body.appendChild(link);
link.click();
link.remove();
}
}
});
this.definitions['player-stats'] = {
this.define('player-stats', {
button: true,
inherit: true,
modview: true,
@ -309,9 +524,9 @@ export default class Metadata extends Module {
return this.settings.get('metadata.player-stats')
},
setup() {
const Player = this.resolve('site.player'),
socket = this.resolve('socket'),
setup(data) {
const Player = this.resolve('site.player') as any,
socket = this.resolve('socket') as SocketClient,
player = Player.current;
let stats;
@ -374,13 +589,13 @@ export default class Metadata extends Module {
try {
const url = player.core.state.path;
if ( url.includes('/api/channel/hls/') ) {
const data = JSON.parse(new URL(url).searchParams.get('token'));
const data = JSON.parse(new URL(url).searchParams.get('token') as string);
tampered = data && data.player_type && data.player_type !== 'site' ? data.player_type : false;
}
} catch(err) { /* no op */ }
if ( ! stats || stats.hlsLatencyBroadcaster < -100 )
return {stats};
return null;
let drift = 0;
@ -388,6 +603,7 @@ export default class Metadata extends Module {
drift = socket._time_drift;
return {
is_player: data.is_player,
stats,
drift,
rate: stats.rate == null ? 1 : stats.rate,
@ -400,16 +616,14 @@ export default class Metadata extends Module {
order: 3,
icon(data) {
if ( data.rate > 1 )
if ( data?.rate > 1 )
return 'ffz-i-fast-fw';
return 'ffz-i-gauge'
},
subtitle: () => this.i18n.t('metadata.player-stats.subtitle', 'Latency'),
label(data) {
if ( ! this.settings.get('metadata.player-stats') || ! data.delay )
if ( ! this.settings.get('metadata.player-stats') || ! data?.delay )
return null;
if ( data.old )
@ -424,10 +638,10 @@ export default class Metadata extends Module {
},
click() {
const Player = this.resolve('site.player'),
fine = this.resolve('site.fine'),
const Player = this.resolve('site.player') as any,
fine = this.resolve('site.fine') as any,
player = Player.Player?.first,
inst = fine && player && fine.searchTree(player, n => n.props?.setStatsOverlay, 200),
inst = fine && player && fine.searchTree(player, (n: any) => n.props?.setStatsOverlay, 200),
cont = inst && fine.getChildNode(player),
el = cont && cont.querySelector('[data-a-target="player-overlay-video-stats"]');
@ -449,7 +663,7 @@ export default class Metadata extends Module {
color(data) {
const setting = this.settings.get('metadata.stream-delay-warning');
if ( setting === 0 || ! data.delay || data.old )
if ( setting === 0 || ! data?.delay || data.old )
return;
if ( data.delay > (setting * 2) )
@ -460,6 +674,9 @@ export default class Metadata extends Module {
},
tooltip(data) {
if ( ! data )
return null;
const tampered = data.tampered ? (<div class="tw-border-t tw-mg-t-05 tw-pd-t-05">
{this.i18n.t(
'metadata.player-stats.tampered',
@ -470,21 +687,21 @@ export default class Metadata extends Module {
)}
</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(
'metadata.player-stats.delay-warning',
'Your local clock seems to be off by roughly {count,number} seconds, which could make this inaccurate.',
Math.round(data.drift / 10) / 100
)}
</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(
'metadata.player-stats.rate-warning',
'Playing at {rate,number}x speed to reduce delay.',
{rate: data.rate.toFixed(2)}
)}
</div>);
</div>) : null;
if ( ! data.stats || ! data.delay )
return [
@ -555,41 +772,32 @@ export default class Metadata extends Module {
tampered
];
}
}
});
}
getAddonProxy(addon_id, addon, module) {
/** @internal */
getAddonProxy(addon_id: string, addon: AddonInfo, module: GenericModule) {
if ( ! addon_id )
return this;
const overrides = {},
const overrides: Record<string, any> = {},
is_dev = DEBUG || addon?.dev;
overrides.define = (key, definition) => {
overrides.define = <TData,>(key: string, definition: MetadataDefinition<TData>) => {
if ( definition )
definition.__source = addon_id;
return this.define(key, definition);
};
return new Proxy(this, {
get(obj, prop) {
const thing = overrides[prop];
if ( thing )
return thing;
if ( prop === 'definitions' && is_dev )
module.log.warn('[DEV-CHECK] Accessed metadata.definitions directly. Please use define()');
return Reflect.get(...arguments);
}
});
return buildAddonProxy(module, this, 'metadata', overrides);
}
/** @internal */
onEnable() {
const md = this.tooltips.types.metadata = target => {
let el = target;
const md: any = (this.tooltips.types as any).metadata = (target: HTMLElement) => {
let el: HTMLElement | null = target;
if ( el._ffz_stat )
el = el._ffz_stat;
else if ( ! el.classList.contains('ffz-stat') ) {
@ -601,31 +809,31 @@ export default class Metadata extends Module {
return;
const key = el.dataset.key,
def = this.definitions[key];
def = key?.length ? this.definitions[key] : null;
return maybe_call(def.tooltip, this, el._ffz_data)
return maybe_call(def?.tooltip, this, el._ffz_data)
};
md.onShow = (target, tip) => {
md.onShow = (target: HTMLElement, tip: TooltipInstance) => {
const el = target._ffz_stat || target;
el.tip = tip;
};
md.onHide = target => {
md.onHide = (target: HTMLElement) => {
const el = target._ffz_stat || target;
el.tip = null;
el.tip_content = null;
}
md.popperConfig = (target, tip, opts) => {
md.popperConfig = (target: HTMLElement, tip: TooltipInstance, opts: any) => {
opts.placement = 'bottom';
opts.modifiers.flip = {behavior: ['bottom','top']};
return opts;
}
this.on('addon:fully-unload', addon_id => {
const removed = new Set;
for(const [key,def] of Object.entries(this.definitions)) {
const removed = new Set<string>;
for(const [key, def] of Object.entries(this.definitions)) {
if ( def?.__source === addon_id ) {
removed.add(key);
this.definitions[key] = undefined;
@ -640,51 +848,99 @@ export default class Metadata extends Module {
}
/**
* Return an array of all metadata definition keys.
*/
get keys() {
return Object.keys(this.definitions);
}
define(key, definition) {
/**
* Add or update a metadata definition. This method updates the entry
* in {@link definitions}, and then it updates every live metadata
* display to reflect the updated definition.
*
* @example Adding a simple metadata definition that displays when the channel went live.
* ```typescript
* metadata.define('when-live', {
* setup(data) {
* return data.channel?.live && data.channel.live_since;
* },
*
* label(live_since) {
* return live_since;
* }
* });
* ```
*
* @param key A unique key for the metadata.
* @param definition Your metadata's definition, or `null` to remove it.
*/
define<TData>(key: string, definition?: MetadataDefinition<TData> | null) {
this.definitions[key] = definition;
this.updateMetadata(key);
}
updateMetadata(keys) {
const channel = this.resolve('site.channel');
/**
* Update the rendered metadata elements for a key or keys. If keys
* is not provided, this will update every metadata element.
*
* @param keys Optional. The key or keys that should be updated.
*/
updateMetadata(keys?: string | string[]) {
// TODO: Types
const channel = this.resolve('site.channel') as any;
if ( channel )
for(const el of channel.InfoBar.instances)
channel.updateMetadata(el, keys);
const player = this.resolve('site.player');
const player = this.resolve('site.player') as any;
if ( player )
for(const inst of player.Player.instances)
player.updateMetadata(inst, keys);
}
async renderLegacy(key, data, container, timers, refresh_fn) {
/**
* Render a metadata definition into a container. This is used
* internally to render metadata.
*
* @param key The metadata's unique key.
* @param data The initial state
* @param container The container to render into
* @param timers An object to store timers for re-rendering
* @param refresh_fn A method to call when the metadata should be re-rendered.
*/
async renderLegacy(
key: string,
data: MetadataState,
container: HTMLElement,
timers: Record<string, ReturnType<typeof setTimeout>>,
refresh_fn: (key: string) => void
) {
if ( 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],
destroy = () => {
if ( el ) {
if ( el.tooltip )
/*if ( el.tooltip )
el.tooltip.destroy();
if ( el.popper )
el.popper.destroy();
el.popper.destroy();*/
if ( el._ffz_destroy )
el._ffz_destroy();
el._ffz_destroy = el.tooltip = el.popper = null;
el._ffz_destroy = /*el.tooltip = el.popper =*/ null;
el.remove();
}
};
if ( ! def || (data._mt || 'channel') !== (def.type || 'channel') )
if ( ! def /* || (data._mt || 'channel') !== (def.type || 'channel') */ )
return destroy();
try {
@ -709,9 +965,10 @@ export default class Metadata extends Module {
// Grab the element again in case it changed, somehow.
el = container.querySelector(`.ffz-stat[data-key="${key}"]`);
el = container.querySelector<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);
@ -728,7 +985,9 @@ export default class Metadata extends Module {
if ( def.button !== false && (def.popup || def.click) ) {
button = true;
let btn, popup;
let btn: HTMLButtonElement | undefined,
popup: HTMLButtonElement | undefined;
const border = maybe_call(def.border, this, data),
inherit = maybe_call(def.inherit, this, data);
@ -741,6 +1000,8 @@ export default class Metadata extends Module {
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'}`}
data-key={key}
// createElement will properly assign this to the
// created element. Shut up TypeScript.
tip_content={null}
>
{btn = (<button
@ -748,10 +1009,10 @@ export default class Metadata extends Module {
data-tooltip-type="metadata"
>
<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" />)}
</div>
</button>)}
</button>) as HTMLButtonElement}
{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' : ''}`}
data-tooltip-type="metadata"
@ -761,7 +1022,7 @@ export default class Metadata extends Module {
<figure class="ffz-i-down-dir" />
</span>
</div>
</button>)}
</button>) as HTMLButtonElement}
</div>);
} else
@ -769,41 +1030,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' : ''}`}
data-tooltip-type="metadata"
data-key={key}
// createElement will properly assign this to the
// created element. Shut up TypeScript.
tip_content={null}
>
<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" />)}
{def.popup && ! def.no_arrow && <span class="tw-mg-l-05">
<figure class="ffz-i-down-dir" />
</span>}
</div>
</button>);
</button>) as any as HTMLButtonElement;
if ( def.click )
btn.addEventListener('click', e => {
if ( el._ffz_fading || btn.disabled || btn.classList.contains('disabled') || el.disabled || el.classList.contains('disabled') )
btn.addEventListener('click', (event: MouseEvent) => {
if ( ! el || ! btn || btn.disabled || btn.classList.contains('disabled') || (el as any).disabled || el.classList.contains('disabled') )
return false;
def.click.call(this, el._ffz_data, e, () => refresh_fn(key));
return def.click?.call?.(this, el._ffz_data, event, () => { refresh_fn(key); });
});
if ( def.popup )
popup.addEventListener('click', () => {
if ( popup.disabled || popup.classList.contains('disabled') || el.disabled || el.classList.contains('disabled') )
if ( ! el || ! popup || popup.disabled || popup.classList.contains('disabled') || (el as any).disabled || el.classList.contains('disabled') )
return false;
if ( el._ffz_popup )
if ( el._ffz_popup && el._ffz_destroy )
return el._ffz_destroy();
const listeners = [],
add_close_listener = cb => listeners.push(cb);
const listeners: (() => void)[] = [],
add_close_listener = (cb: () => void) => {
listeners.push(cb);
};
const destroy = el._ffz_destroy = () => {
for(const cb of listeners) {
try {
cb();
} catch(err) {
if ( err instanceof Error )
this.log.capture(err, {
tags: {
metadata: key
@ -813,6 +1079,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 )
el._ffz_outside.destroy();
@ -823,10 +1093,11 @@ export default class Metadata extends Module {
}
el._ffz_destroy = el._ffz_outside = null;
}
};
const parent = document.fullscreenElement || document.body.querySelector('#root>div') || document.body,
tt = el._ffz_popup = new Tooltip(parent, el, {
const parent = document.fullscreenElement || document.body.querySelector<HTMLElement>('#root>div') || document.body,
tt = el._ffz_popup = new Tooltip(parent as HTMLElement, el, {
logger: this.log,
i18n: this.i18n,
manual: true,
@ -850,9 +1121,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) =>
setTimeout(() => {
if ( el && tip.outer )
el._ffz_outside = new ClickOutside(tip.outer, destroy);
}),
onHide: destroy
@ -871,23 +1143,23 @@ export default class Metadata extends Module {
data-key={key}
tip_content={null}
>
{icon}
{icon as any}
{stat = <span class={`${icon ? 'tw-mg-l-05 ' : ''}ffz-stat-text tw-stat__value`} />}
</div>);
if ( def.click )
el.addEventListener('click', e => {
if ( el._ffz_fading || el.disabled || el.classList.contains('disabled') )
el.addEventListener('click', (event: MouseEvent) => {
if ( ! el || (el as any).disabled || el.classList.contains('disabled') )
return false;
def.click.call(this, el._ffz_data, e, () => refresh_fn(key));
def.click?.call?.(this, el._ffz_data, event, () => refresh_fn(key));
});
}
el._ffz_order = order;
if ( order != null )
el.style.order = order;
el.style.order = `${order}`;
container.appendChild(el);
@ -900,17 +1172,19 @@ export default class Metadata extends Module {
old_color = el.dataset.color || '';
if ( el._ffz_order !== order )
el.style.order = el._ffz_order = order;
el.style.order = `${el._ffz_order = order}`;
if ( el.tip ) {
const tooltip = maybe_call(def.tooltip, this, data);
if ( el.tip_content !== tooltip ) {
el.tip_content = tooltip;
if ( el.tip?.element ) {
el.tip.element.innerHTML = '';
setChildren(el.tip.element, tooltip);
}
}
}
}
if ( typeof def.icon === 'function' ) {
const icon = maybe_call(def.icon, this, data);
@ -928,12 +1202,14 @@ export default class Metadata extends Module {
}
el._ffz_data = data;
stat.innerHTML = label;
stat.innerHTML = '';
setChildren(stat, label);
if ( def.disabled !== undefined )
el.disabled = maybe_call(def.disabled, this, data);
(el as any).disabled = maybe_call(def.disabled, this, data);
} catch(err) {
if ( err instanceof Error )
this.log.capture(err, {
tags: {
metadata: key

View file

@ -5,16 +5,81 @@
// ============================================================================
import {createElement, sanitize} from 'utilities/dom';
import {has, maybe_call, once} from 'utilities/object';
import {has, maybe_call} from 'utilities/object';
import Tooltip from 'utilities/tooltip';
import Module from 'utilities/module';
import Tooltip, { TooltipInstance } from 'utilities/tooltip';
import Module, { GenericModule, buildAddonProxy } from 'utilities/module';
import awaitMD, {getMD} from 'utilities/markdown';
import { DEBUG } from 'src/utilities/constants';
import type { AddonInfo, DomFragment, OptionallyCallable } from '../utilities/types';
import type TranslationManager from '../i18n';
declare global {
interface HTMLElement {
_ffz_child: Element | null;
}
}
declare module 'utilities/types' {
interface ModuleEventMap {
tooltips: TooltipEvents;
}
interface ModuleMap {
tooltips: TooltipProvider;
}
}
export type TooltipEvents = {
/**
* When this event is emitted, the tooltip provider will attempt to remove
* old, invalid tool-tips.
*/
':cleanup': [],
':hover': [target: HTMLElement, tip: TooltipInstance, event: MouseEvent];
':leave': [target: HTMLElement, tip: TooltipInstance, event: MouseEvent];
};
type TooltipOptional<TReturn> = OptionallyCallable<[target: HTMLElement, tip: TooltipInstance], TReturn>;
type TooltipExtra = {
__source?: string;
popperConfig(target: HTMLElement, tip: TooltipInstance, options: any): any;
delayShow: TooltipOptional<number>;
delayHide: TooltipOptional<number>;
interactive: TooltipOptional<boolean>;
hover_events: TooltipOptional<boolean>;
onShow(target: HTMLElement, tip: TooltipInstance): void;
onHide(target: HTMLElement, tip: TooltipInstance): void;
};
export type TooltipDefinition = Partial<TooltipExtra> &
((target: HTMLElement, tip: TooltipInstance) => DomFragment);
export default class TooltipProvider extends Module<'tooltips', TooltipEvents> {
// Storage
types: Record<string, TooltipDefinition | undefined>;
// Dependencies
i18n: TranslationManager = null as any;
// State
container?: HTMLElement | null;
tip_element?: HTMLElement | null;
tips?: Tooltip | null;
constructor(name?: string, parent?: GenericModule) {
super(name, parent);
export default class TooltipProvider extends Module {
constructor(...args) {
super(...args);
this.types = {};
this.inject('i18n');
@ -69,44 +134,46 @@ export default class TooltipProvider extends Module {
return md.render(target.dataset.title);
};
this.types.text = target => sanitize(target.dataset.title);
this.types.text = target => sanitize(target.dataset.title ?? '');
this.types.html = target => target.dataset.title;
this.onFSChange = this.onFSChange.bind(this);
}
getAddonProxy(addon_id, addon, module) {
getAddonProxy(addon_id: string, addon: AddonInfo, module: GenericModule) {
if ( ! addon_id )
return this;
const overrides = {},
const overrides: Record<string, any> = {},
is_dev = DEBUG || addon?.dev;
let warnings: Record<string, boolean | string> | undefined;
overrides.define = (key, handler) => {
overrides.define = (key: string, handler: TooltipDefinition) => {
if ( handler )
handler.__source = addon_id;
return this.define(key, handler);
};
if ( is_dev )
if ( is_dev ) {
overrides.cleanup = () => {
module.log.warn('[DEV-CHECK] Instead of calling tooltips.cleanup(), you can emit the event "tooltips:cleanup"');
return this.cleanup();
};
return new Proxy(this, {
get(obj, prop) {
const thing = overrides[prop];
if ( thing )
return thing;
if ( prop === 'types' && is_dev )
module.log.warn('[DEV-CHECK] Accessed tooltips.types directly. Please use tooltips.define()');
return Reflect.get(...arguments);
warnings = {
types: 'Please use tooltips.define()'
};
}
});
return buildAddonProxy(
module,
this,
'tooltips',
overrides,
warnings
);
}
@ -140,20 +207,22 @@ export default class TooltipProvider extends Module {
}
define(key, handler) {
define(key: string, handler: TooltipDefinition) {
// TODO: Determine if any tooltips are already open.
// If so, we need to close them / maybe re-open them?
this.types[key] = handler;
}
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') ||
document.querySelector('.clips-root') ||
document.body;
}
_createInstance(container, klass = 'ffz-tooltip', default_type = 'text', tip_container) {
_createInstance(container: HTMLElement, klass = 'ffz-tooltip', default_type = 'text', tip_container?: HTMLElement) {
return new Tooltip(container, klass, {
html: true,
i18n: this.i18n,
@ -190,34 +259,52 @@ export default class TooltipProvider extends Module {
onFSChange() {
const tip_element = document.fullscreenElement || this.container;
if ( ! this.container )
this.container = this.getRoot();
let tip_element = this.container;
if ( document.fullscreenElement instanceof HTMLElement )
tip_element = document.fullscreenElement;
if ( tip_element !== this.tip_element ) {
this.tips.destroy();
this.tip_element = tip_element;
if ( this.tips ) {
this.tips.destroy();
this.tips = this._createInstance(tip_element);
}
}
}
cleanup() {
if ( this.tips )
this.tips.cleanup();
}
delegatePopperConfig(default_type, target, tip, pop_opts) {
delegatePopperConfig(
default_type: string,
target: HTMLElement,
tip: TooltipInstance,
options: any
) {
const type = target.dataset.tooltipType || default_type,
handler = this.types[type];
if ( target.dataset.tooltipSide )
pop_opts.placement = target.dataset.tooltipSide;
options.placement = target.dataset.tooltipSide;
if ( handler && handler.popperConfig )
return handler.popperConfig(target, tip, pop_opts);
return handler.popperConfig(target, tip, options);
return pop_opts;
return options;
}
delegateOnShow(default_type, target, tip) {
delegateOnShow(
default_type: string,
target: HTMLElement,
tip: TooltipInstance
) {
const type = target.dataset.tooltipType || default_type,
handler = this.types[type];
@ -225,7 +312,11 @@ export default class TooltipProvider extends Module {
handler.onShow(target, tip);
}
delegateOnHide(default_type, target, tip) {
delegateOnHide(
default_type: string,
target: HTMLElement,
tip: TooltipInstance
) {
const type = target.dataset.tooltipType || default_type,
handler = this.types[type];
@ -233,47 +324,67 @@ export default class TooltipProvider extends Module {
handler.onHide(target, tip);
}
checkDelayShow(default_type, target, tip) {
checkDelayShow(
default_type: string,
target: HTMLElement,
tip: TooltipInstance
) {
const type = target.dataset.tooltipType || default_type,
handler = this.types[type];
if ( has(handler, 'delayShow') )
if ( handler?.delayShow != null )
return maybe_call(handler.delayShow, null, target, tip);
return 0;
}
checkDelayHide(default_type, target, tip) {
checkDelayHide(
default_type: string,
target: HTMLElement,
tip: TooltipInstance
) {
const type = target.dataset.tooltipType || default_type,
handler = this.types[type];
if ( has(handler, 'delayHide') )
if ( handler?.delayHide != null )
return maybe_call(handler.delayHide, null, target, tip);
return 0;
}
checkInteractive(default_type, target, tip) {
checkInteractive(
default_type: string,
target: HTMLElement,
tip: TooltipInstance
) {
const type = target.dataset.tooltipType || default_type,
handler = this.types[type];
if ( has(handler, 'interactive') )
if ( handler?.interactive != null )
return maybe_call(handler.interactive, null, target, tip);
return false;
}
checkHoverEvents(default_type, target, tip) {
checkHoverEvents(
default_type: string,
target: HTMLElement,
tip: TooltipInstance
) {
const type = target.dataset.tooltipType || default_type,
handler = this.types[type];
if ( has(handler, 'hover_events') )
if ( handler?.hover_events != null )
return maybe_call(handler.hover_events, null, target, tip);
return false;
}
process(default_type, target, tip) {
process(
default_type: string,
target: HTMLElement,
tip: TooltipInstance
) {
const type = target.dataset.tooltipType || default_type || 'text',
align = target.dataset.tooltipAlign,
handler = this.types[type];

View file

@ -12,7 +12,7 @@ import {timeout} from 'utilities/object';
import SettingsManager from './settings/index';
import AddonManager from './addons';
import ExperimentManager from './experiments';
import {TranslationManager} from './i18n';
import TranslationManager from './i18n';
import StagingSelector from './staging';
import PubSubClient from './pubsub';
import LoadTracker from './load_tracker';

View file

@ -4,19 +4,67 @@
// PubSub Client
// ============================================================================
import Module from 'utilities/module';
import Module, { GenericModule } from 'utilities/module';
import { PUBSUB_CLUSTERS } from 'utilities/constants';
import type ExperimentManager from '../experiments';
import type SettingsManager from '../settings';
import type PubSubClient from 'utilities/pubsub';
import type { PubSubCommands } from 'utilities/types';
import type { SettingUi_Select_Entry } from '../settings/types';
declare module 'utilities/types' {
interface ModuleMap {
pubsub: PubSub;
}
interface ModuleEventMap {
pubsub: PubSubEvents;
}
interface SettingsTypeMap {
'pubsub.use-cluster': keyof typeof PUBSUB_CLUSTERS | null;
}
interface ExperimentTypeMap {
cf_pubsub: boolean;
}
}
export default class PubSub extends Module {
constructor(...args) {
super(...args);
type PubSubCommandData<K extends keyof PubSubCommands> = {
topic: string;
cmd: K;
data: PubSubCommands[K];
};
type PubSubCommandKey = `:command:${keyof PubSubCommands}`;
type PubSubEvents = {
':sub-change': [];
':message': [topic: string, data: unknown];
} & {
[K in keyof PubSubCommands as `:command:${K}`]: [data: PubSubCommands[K], meta: PubSubCommandData<K>];
}
export default class PubSub extends Module<'pubsub', PubSubEvents> {
// Dependencies
experiments: ExperimentManager = null as any;
settings: SettingsManager = null as any;
// State
_topics: Map<string, Set<unknown>>;
_client: PubSubClient | null;
_mqtt?: typeof PubSubClient | null;
_mqtt_loader?: Promise<typeof PubSubClient> | null;
constructor(name?: string, parent?: GenericModule) {
super(name, parent);
this.inject('settings');
this.inject('experiments');
this.settings.add('pubsub.use-cluster', {
default: ctx => {
default: () => {
if ( this.experiments.getAssignment('cf_pubsub') )
return 'Staging';
return null;
@ -33,7 +81,7 @@ export default class PubSub extends Module {
data: [{
value: null,
title: 'Disabled'
}].concat(Object.keys(PUBSUB_CLUSTERS).map(x => ({
} as SettingUi_Select_Entry<string | null>].concat(Object.keys(PUBSUB_CLUSTERS).map(x => ({
value: x,
title: x
})))
@ -161,18 +209,18 @@ export default class PubSub extends Module {
client.on('message', event => {
const topic = event.topic,
data = event.data;
data = event.data as PubSubCommandData<any>;
if ( ! data?.cmd ) {
this.log.debug(`Received message on topic "${topic}":`, data);
this.emit(`pubsub:message`, topic, data);
this.emit(`:message`, topic, data);
return;
}
data.topic = topic;
this.log.debug(`Received command on topic "${topic}" for command "${data.cmd}":`, data.data);
this.emit(`pubsub:command:${data.cmd}`, data.data, data);
this.emit(`:command:${data.cmd}` as PubSubCommandKey, data.data, data);
});
// Subscribe to topics.
@ -196,20 +244,23 @@ export default class PubSub extends Module {
// Topics
// ========================================================================
subscribe(referrer, ...topics) {
const t = this._topics;
subscribe(referrer: unknown, ...topics: string[]) {
const topic_map = this._topics;
let changed = false;
for(const topic of topics) {
if ( ! t.has(topic) ) {
let refs = topic_map.get(topic);
if ( refs )
refs.add(referrer);
else {
if ( this._client )
this._client.subscribe(topic);
t.set(topic, new Set);
refs = new Set;
refs.add(referrer);
topic_map.set(topic, refs);
changed = true;
}
const tp = t.get(topic);
tp.add(referrer);
}
if ( changed )
@ -217,19 +268,19 @@ export default class PubSub extends Module {
}
unsubscribe(referrer, ...topics) {
const t = this._topics;
unsubscribe(referrer: unknown, ...topics: string[]) {
const topic_map = this._topics;
let changed = false;
for(const topic of topics) {
if ( ! t.has(topic) )
const refs = topic_map.get(topic);
if ( ! refs )
continue;
const tp = t.get(topic);
tp.delete(referrer);
refs.delete(referrer);
if ( ! tp.size ) {
if ( ! refs.size ) {
changed = true;
t.delete(topic);
topic_map.delete(topic);
if ( this._client )
this._client.unsubscribe(topic);
}

View file

@ -216,7 +216,7 @@ export default class RavenLogger extends Module {
return false;
if ( this.settings && this.settings.get('reports.error.include-user') ) {
const user = this.resolve('site')?.getUser();
const user = this.resolve('site')?.getUser?.();
if ( user )
data.user = {id: user.id, username: user.login}
}

View file

@ -1,10 +1,13 @@
'use strict';
import { AdvancedSettingsProvider } from "./providers";
import type { SettingsClearable } from "./types";
// ============================================================================
// Clearable Settings
// ============================================================================
export const Experiments = {
export const Experiments: SettingsClearable = {
label: 'Experiment Overrides',
keys: [
'exp-lock',
@ -12,7 +15,7 @@ export const Experiments = {
]
};
export const HiddenEmotes = {
export const HiddenEmotes: SettingsClearable = {
label: 'Hidden Emotes',
keys(provider) {
const keys = ['emote-menu.hidden-sets'];
@ -24,7 +27,7 @@ export const HiddenEmotes = {
}
};
export const FavoriteEmotes = {
export const FavoriteEmotes: SettingsClearable = {
label: 'Favorited Emotes',
keys(provider) {
const keys = [];
@ -36,7 +39,7 @@ export const FavoriteEmotes = {
}
};
export const Overrides = {
export const Overrides: SettingsClearable = {
label: 'Name and Color Overrides',
keys: [
'overrides.colors',
@ -44,7 +47,7 @@ export const Overrides = {
]
};
export const Profiles = {
export const Profiles: SettingsClearable = {
label: 'Profiles',
clear(provider, settings) {
const keys = ['profiles'];
@ -59,11 +62,11 @@ export const Profiles = {
}
};
export const Everything = {
export const Everything: SettingsClearable = {
label: 'Absolutely Everything',
async clear(provider, settings) {
provider.clear();
if ( provider.supportsBlobs )
if ( provider.supportsBlobs && provider instanceof AdvancedSettingsProvider )
await provider.clearBlobs();
settings.loadProfiles();

View file

@ -7,7 +7,11 @@
import {EventEmitter} from 'utilities/events';
import {has, get as getter, array_equals, set_equals, map_equals, deep_equals} from 'utilities/object';
import * as DEFINITIONS from './types';
import DEFINITIONS from './typehandlers';
import type { AllSettingsKeys, ContextData, SettingMetadata, SettingType, SettingDefinition, SettingsKeys } from './types';
import type SettingsManager from '.';
import type SettingsProfile from './profile';
import type { SettingsTypeMap } from 'utilities/types';
/**
* Perform a basic check of a setting's requirements to see if they changed.
@ -16,7 +20,11 @@ import * as DEFINITIONS from './types';
* @param {Map} old_cache
* @returns Whether or not they changed.
*/
function compare_requirements(definition, cache, old_cache) {
function compare_requirements(
definition: SettingDefinition<any>,
cache: Map<string, unknown>,
old_cache: Map<string, unknown>
) {
if ( ! definition || ! Array.isArray(definition.requires) )
return false;
@ -47,14 +55,44 @@ function compare_requirements(definition, cache, old_cache) {
}
export type SettingsContextEvents = {
[K in keyof SettingsTypeMap as `changed:${K}`]: [value: SettingsTypeMap[K], old_value: SettingsTypeMap[K]];
} & {
[K in keyof SettingsTypeMap as `uses_changed:${K}`]: [uses: number[] | null, old_uses: number[] | null];
} & {
changed: [key: SettingsKeys, value: any, old_value: any];
uses_changed: [key: SettingsKeys, uses: number[] | null, old_uses: number[] | null];
context_changed: [];
profiles_changed: [];
}
/**
* The SettingsContext class provides a context through which to read
* settings values in addition to emitting events when settings values
* are changed.
* @extends EventEmitter
*/
export default class SettingsContext extends EventEmitter {
constructor(manager, context) {
export default class SettingsContext extends EventEmitter<SettingsContextEvents> {
parent: SettingsContext | null;
manager: SettingsManager;
order: number[];
/** @internal */
_context: ContextData;
private __context: ContextData = null as any;
private __profiles: SettingsProfile[];
private __cache: Map<SettingsKeys, unknown>;
private __meta: Map<SettingsKeys, SettingMetadata>;
private __ls_listening: boolean;
private __ls_wanted: Map<string, Set<string>>;
constructor(manager: SettingsContext | SettingsManager, context?: ContextData) {
super();
if ( manager instanceof SettingsContext ) {
@ -68,7 +106,7 @@ export default class SettingsContext extends EventEmitter {
this.manager = manager;
}
this.manager.__contexts.push(this);
(this.manager as any).__contexts.push(this);
this._context = context || {};
/*this._context_objects = new Set;
@ -93,7 +131,7 @@ export default class SettingsContext extends EventEmitter {
for(const profile of this.__profiles)
profile.off('changed', this._onChanged, this);
const contexts = this.manager.__contexts,
const contexts = (this.manager as any).__contexts,
idx = contexts.indexOf(this);
if ( idx !== -1 )
@ -106,26 +144,26 @@ export default class SettingsContext extends EventEmitter {
// ========================================================================
_watchLS() {
if ( this.__ls_watched )
if ( this.__ls_listening )
return;
this.__ls_watched = true;
this.__ls_listening = true;
this.manager.on(':ls-update', this._onLSUpdate, this);
}
_unwatchLS() {
if ( ! this.__ls_watched )
if ( ! this.__ls_listening )
return;
this.__ls_watched = false;
this.__ls_listening = false;
this.manager.off(':ls-update', this._onLSUpdate, this);
}
_onLSUpdate(key) {
_onLSUpdate(key: string) {
const keys = this.__ls_wanted.get(`ls.${key}`);
if ( keys )
for(const key of keys)
this._update(key, key, []);
this._update(key as SettingsKeys, key as SettingsKeys, []);
}
@ -147,8 +185,8 @@ export default class SettingsContext extends EventEmitter {
selectProfiles() {
const new_profiles = [],
order = this.order = [];
const new_profiles: SettingsProfile[] = [],
order: number[] = this.order = [];
if ( ! this.manager.disable_profiles ) {
for(const profile of this.manager.__profiles)
@ -171,13 +209,13 @@ export default class SettingsContext extends EventEmitter {
for(const profile of new_profiles)
if ( ! this.__profiles.includes(profile) ) {
profile.on('changed', this._onChanged, this);
profile.on('changed', this._onChanged as any, this);
changed_ids.add(profile.id);
}
this.__profiles = new_profiles;
this.emit('profiles_changed');
this.rebuildCache(changed_ids);
this.rebuildCache(/*changed_ids*/);
return true;
}
@ -203,7 +241,7 @@ export default class SettingsContext extends EventEmitter {
const definition = this.manager.definitions.get(key);
let changed = false;
if ( definition && definition.equals ) {
if ( ! Array.isArray(definition) && definition?.equals ) {
if ( definition.equals === 'requirements' )
changed = compare_requirements(definition, this.__cache, old_cache);
else if ( typeof definition.equals === 'function' )
@ -224,7 +262,7 @@ export default class SettingsContext extends EventEmitter {
if ( changed ) {
this.emit('changed', key, new_value, old_value);
this.emit(`changed:${key}`, new_value, old_value);
this.emit(`changed:${key}`, new_value, old_value as any);
}
if ( ! array_equals(new_uses, old_uses) ) {
@ -239,12 +277,12 @@ export default class SettingsContext extends EventEmitter {
// Context Control
// ========================================================================
context(context) {
context(context: ContextData) {
return new SettingsContext(this, context);
}
updateContext(context) {
updateContext(context: ContextData) {
let changed = false;
for(const key in context)
@ -258,7 +296,7 @@ export default class SettingsContext extends EventEmitter {
// This can catch a recursive structure error.
}
this._context[key] = val;
this._context[key] = val as any;
changed = true;
}
@ -325,8 +363,8 @@ export default class SettingsContext extends EventEmitter {
}*/
setContext(context) {
this._context_objects = new Set;
setContext(context: ContextData) {
//this._context_objects = new Set;
this._context = {};
this.updateContext(context);
}
@ -336,11 +374,14 @@ export default class SettingsContext extends EventEmitter {
// Data Access
// ========================================================================
_onChanged(key) {
_onChanged(key: SettingsKeys) {
this._update(key, key, []);
}
_update(key, initial, visited) {
_update<
K extends SettingsKeys,
TValue = SettingType<K>
>(key: K, initial: SettingsKeys, visited: SettingsKeys[]) {
if ( ! this.__cache.has(key) )
return;
@ -349,7 +390,7 @@ export default class SettingsContext extends EventEmitter {
visited.push(key);
const old_value = this.__cache.get(key),
const old_value = this.__cache.get(key) as TValue | undefined,
old_meta = this.__meta.get(key),
new_value = this._get(key, key, []),
new_meta = this.__meta.get(key),
@ -359,38 +400,41 @@ export default class SettingsContext extends EventEmitter {
if ( ! array_equals(new_uses, old_uses) ) {
this.emit('uses_changed', key, new_uses, old_uses);
this.emit(`uses_changed:${key}`, new_uses, old_uses);
this.emit(`uses_changed:${key}` as any, new_uses, old_uses);
}
if ( old_value === new_value )
return;
this.emit('changed', key, new_value, old_value);
this.emit(`changed:${key}`, new_value, old_value);
this.emit(`changed:${key}` as any, new_value, old_value);
const definition = this.manager.definitions.get(key);
if ( definition && definition.required_by )
if ( ! Array.isArray(definition) && definition?.required_by )
for(const req_key of definition.required_by)
if ( ! req_key.startsWith('context.') && ! req_key.startsWith('ls.') )
this._update(req_key, initial, Array.from(visited));
this._update(req_key as SettingsKeys, initial, Array.from(visited));
}
_get(key, initial, visited) {
_get<
K extends SettingsKeys,
TValue = SettingType<K>
>(key: K, initial: SettingsKeys, visited: SettingsKeys[]): TValue {
if ( visited.includes(key) )
throw new Error(`cyclic dependency when resolving setting "${initial}"`);
visited.push(key);
const definition = this.manager.definitions.get(key),
raw_type = definition && definition.type,
const definition = this.manager.definitions.get(key);
const raw_type = ! Array.isArray(definition) && definition?.type,
type = raw_type ? DEFINITIONS[raw_type] : DEFINITIONS.basic;
if ( ! type )
throw new Error(`non-existent setting type "${raw_type}"`);
const raw_value = this._getRaw(key, type),
meta = {
meta: SettingMetadata = {
uses: raw_value ? raw_value[1] : null
};
@ -421,8 +465,8 @@ export default class SettingsContext extends EventEmitter {
keys.add(key);
} else if ( ! req_key.startsWith('context.') && ! this.__cache.has(req_key) )
this._get(req_key, initial, Array.from(visited));
} else if ( ! req_key.startsWith('context.') && ! this.__cache.has(req_key as SettingsKeys) )
this._get(req_key as SettingsKeys, initial, Array.from(visited));
if ( definition.process )
value = definition.process(this, value, meta);
@ -440,70 +484,84 @@ export default class SettingsContext extends EventEmitter {
}
hasProfile(profile) {
if ( typeof profile === 'number' )
hasProfile(profile: number | SettingsProfile) {
if ( typeof profile === 'number' ) {
for(const prof of this.__profiles)
if ( prof.id === profile )
return true;
return false;
}
return this.__profiles.includes(profile);
}
_getRaw(key, type) {
_getRaw(key: SettingsKeys, type) {
if ( ! type )
throw new Error(`non-existent type for ${key}`)
return type.get(key, this.profiles(), this.manager.definitions.get(key), this.manager.log, this);
return type.get(
key,
this.profiles(),
this.manager.definitions.get(key),
this.manager.log,
this
);
}
/* for(const profile of this.__profiles)
if ( profile.has(key) )
return [profile.get(key), profile]
}*/
// ========================================================================
// Data Access
// ========================================================================
update(key) {
update(key: SettingsKeys) {
this._update(key, key, []);
}
get(key) {
get<
K extends AllSettingsKeys,
TValue = SettingType<K>
>(key: K): TValue {
if ( key.startsWith('ls.') )
return this.manager.getLS(key.slice(3));
return this.manager.getLS(key.slice(3)) as TValue;
if ( key.startsWith('context.') )
//return this.__context[key.slice(8)];
return getter(key.slice(8), this.__context);
if ( this.__cache.has(key) )
return this.__cache.get(key);
if ( this.__cache.has(key as SettingsKeys) )
return this.__cache.get(key as SettingsKeys) as TValue;
return this._get(key, key, []);
return this._get(key as SettingsKeys, key as SettingsKeys, []);
}
getChanges(key, fn, ctx) {
getChanges<
K extends SettingsKeys,
TValue = SettingsTypeMap[K]
>(key: K, fn: (value: TValue, old_value: TValue | undefined) => void, ctx?: any) {
this.onChange(key, fn, ctx);
fn.call(ctx, this.get(key));
fn.call(ctx, this.get(key), undefined as TValue);
}
onChange(key, fn, ctx) {
this.on(`changed:${key}`, fn, ctx);
onChange<
K extends SettingsKeys,
TValue = SettingsTypeMap[K]
>(key: K, fn: (value: TValue, old_value: TValue) => void, ctx?: any) {
this.on(`changed:${key}`, fn as any, ctx);
}
uses(key) {
uses(key: AllSettingsKeys) {
if ( key.startsWith('ls.') )
return null;
if ( key.startsWith('context.') )
return null;
if ( ! this.__meta.has(key) )
this._get(key, key, []);
if ( ! this.__meta.has(key as SettingsKeys) )
this._get(key as SettingsKeys, key as SettingsKeys, []);
return this.__meta.get(key).uses;
return this.__meta.get(key as SettingsKeys)?.uses ?? null;
}
}

View file

@ -4,24 +4,29 @@
// Profile Filters for Settings
// ============================================================================
import {glob_to_regex, escape_regex, matchScreen} from 'utilities/object';
import {createTester} from 'utilities/filtering';
import {glob_to_regex, escape_regex, matchScreen, ScreenOptions} from 'utilities/object';
import {FilterData, FilterType, createTester} from 'utilities/filtering';
import { DEBUG } from 'utilities/constants';
import type { ContextData } from './types';
import type { ScreenDetails } from 'root/types/getScreenDetails';
import SettingsManager from '.';
let safety = null;
let safety: ((input: string | RegExp) => boolean) | null = null;
function loadSafety(cb) {
function loadSafety(callback?: () => void) {
import(/* webpackChunkName: 'regex' */ 'safe-regex').then(thing => {
safety = thing.default;
if ( cb )
cb();
if ( callback )
callback();
})
}
const NeverMatch = () => false;
// Logical Components
export const Invert = {
export const Invert: FilterType<FilterData[], ContextData> = {
createTest(config, rule_types, rebuild) {
return createTester(config, rule_types, true, false, rebuild)
},
@ -37,7 +42,7 @@ export const Invert = {
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/nested.vue')
};
export const And = {
export const And: FilterType<FilterData[], ContextData> = {
createTest(config, rule_types, rebuild) {
return createTester(config, rule_types, false, false, rebuild);
},
@ -52,7 +57,7 @@ export const And = {
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/nested.vue')
};
export const Or = {
export const Or: FilterType<FilterData[], ContextData> = {
createTest(config, rule_types, rebuild) {
return createTester(config, rule_types, false, true, rebuild);
},
@ -67,11 +72,17 @@ export const Or = {
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/nested.vue')
};
export const If = {
type IfData = [
condition: FilterData[],
if_true: FilterData[],
if_else: FilterData[]
];
export const If: FilterType<IfData, ContextData> = {
createTest(config, rule_types, rebuild) {
const cond = createTester(config[0], rule_types, false, false, rebuild),
if_true = createTester(config[1], rule_types, false, false, rebuild),
if_false = createTester(config[2], rule_types, false, false, rebuild);
const cond = createTester(config[0], rule_types as any, false, false, rebuild),
if_true = createTester(config[1], rule_types as any, false, false, rebuild),
if_false = createTester(config[2], rule_types as any, false, false, rebuild);
return ctx => cond(ctx) ? if_true(ctx) : if_false(ctx)
},
@ -85,11 +96,11 @@ export const If = {
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/if.vue')
};
export const Constant = {
export const Constant: FilterType<boolean, ContextData> = {
createTest(config) {
if ( config )
return () => true;
return () => false;
return NeverMatch;
},
title: 'True or False',
@ -103,7 +114,7 @@ export const Constant = {
// Context Stuff
function parseTime(time) {
function parseTime(time: string) {
if ( typeof time !== 'string' || ! time.length )
return null;
@ -123,7 +134,12 @@ function parseTime(time) {
return hours * 60 + minutes;
}
export const Time = {
type TimeFilter = FilterType<[start: string, end: string], ContextData> & {
_captured: Set<number>;
captured: () => number[];
};
export const Time: TimeFilter = {
_captured: new Set,
createTest(config) {
@ -131,7 +147,7 @@ export const Time = {
end = parseTime(config[1]);
if ( start == null || end == null )
return () => false;
return NeverMatch;
if ( start <= end )
return () => {
@ -170,12 +186,12 @@ export const Time = {
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/time.vue')
}
export const TheaterMode = {
export const TheaterMode: FilterType<boolean, ContextData> = {
createTest(config) {
return ctx => {
if ( ctx.fullscreen )
return config === false;
return ctx.ui && ctx.ui.theatreModeEnabled === config;
return ctx.ui?.theatreModeEnabled === config;
}
},
@ -187,7 +203,7 @@ export const TheaterMode = {
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
};
export const Fullscreen = {
export const Fullscreen: FilterType<boolean, ContextData> = {
createTest(config) {
return ctx => ctx.fullscreen === config;
},
@ -200,7 +216,7 @@ export const Fullscreen = {
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
};
export const Moderator = {
export const Moderator: FilterType<boolean, ContextData> = {
createTest(config) {
return ctx => ctx.moderator === config;
},
@ -212,7 +228,7 @@ export const Moderator = {
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
};
export const Debug = {
export const Debug: FilterType<boolean, ContextData> = {
createTest(config) {
return () => DEBUG === config;
},
@ -224,7 +240,7 @@ export const Debug = {
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
};
export const AddonDebug = {
export const AddonDebug: FilterType<boolean, ContextData> = {
createTest(config) {
return ctx => ctx.addonDev == config
},
@ -236,9 +252,9 @@ export const AddonDebug = {
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
}
export const SquadMode = {
export const SquadMode: FilterType<boolean, ContextData> = {
createTest(config) {
return ctx => ctx.ui && ctx.ui.squadModeEnabled === config;
return ctx => ctx.ui?.squadModeEnabled === config;
},
title: 'Squad Mode',
@ -248,10 +264,10 @@ export const SquadMode = {
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
};
export const NativeDarkTheme = {
export const NativeDarkTheme: FilterType<boolean, ContextData> = {
createTest(config) {
const val = config ? 1 : 0;
return ctx => ctx.ui && ctx.ui.theme === val;
return ctx => ctx.ui?.theme === val;
},
title: 'Dark Theme',
@ -261,19 +277,30 @@ export const NativeDarkTheme = {
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
};
export const Page = {
createTest(config = {}) {
// TODO: Add typing.
type PageData = {
route: string;
values: Record<string, string>;
};
export const Page: FilterType<PageData, ContextData> = {
createTest(config) {
if ( ! config )
return NeverMatch;
const name = config.route,
parts = [];
parts: [index: number, value: string][] = [];
if ( Object.keys(config.values).length ) {
const ffz = window.FrankerFaceZ?.get(),
router = ffz && ffz.resolve('site.router');
router = ffz && ffz.resolve('site.router') as any;
if ( ! router )
return NeverMatch;
if ( router ) {
const route = router.getRoute(name);
if ( ! route || ! route.parts )
return () => false;
return NeverMatch;
let i = 1;
for(const part of route.parts) {
@ -285,9 +312,6 @@ export const Page = {
i++;
}
}
} else
return () => false;
}
return ctx => {
@ -318,12 +342,28 @@ export const Page = {
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/page.vue')
};
export const Channel = {
createTest(config = {}) {
const login = config.login,
id = config.id;
type ChannelData = {
login: string | null;
id: string | null;
};
return ctx => ctx.channelID === id || (ctx.channelID == null && ctx.channelLogin === login);
export const Channel: FilterType<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',
@ -336,15 +376,28 @@ export const Channel = {
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/channel.vue')
};
export const Category = {
createTest(config = {}) {
const name = config.name,
id = config.id;
type CategoryData = {
name: string | null;
id: string | null;
}
if ( ! id || ! name )
return () => false;
export const Category: FilterType<CategoryData, ContextData> = {
createTest(config) {
const name = config?.name,
id = config?.id;
return ctx => ctx.categoryID === id || (ctx.categoryID == null && ctx.category === name);
if ( ! id && ! name )
return NeverMatch;
else if ( ! id )
return ctx => ctx.category === name;
else if ( ! name )
return ctx => ctx.categoryID === id;
return ctx =>
ctx.categoryID === id ||
(ctx.categoryID == null && ctx.category === name);
},
title: 'Current Category',
@ -358,14 +411,20 @@ export const Category = {
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/category.vue')
}
export const Title = {
createTest(config = {}, _, reload) {
const mode = config.mode;
let title = config.title,
type TitleData = {
title: string;
mode: 'text' | 'glob' | 'raw' | 'regex';
sensitive: boolean;
};
export const Title: FilterType<TitleData, ContextData> = {
createTest(config, _, reload) {
const mode = config?.mode;
let title = config?.title,
need_safety = true;
if ( ! title || ! mode )
return () => false;
return NeverMatch;
if ( mode === 'text' ) {
title = escape_regex(title);
@ -373,26 +432,26 @@ export const Title = {
} else if ( mode === 'glob' )
title = glob_to_regex(title);
else if ( mode !== 'raw' )
return () => false;
return NeverMatch;
if ( need_safety ) {
if ( ! safety )
loadSafety(reload);
if ( ! safety || ! safety(title) )
return () => false;
return NeverMatch;
}
let regex;
let regex: RegExp;
try {
regex = new RegExp(title, `g${config.sensitive ? '' : 'i'}`);
} catch(err) {
return () => false;
return NeverMatch;
}
return ctx => {
regex.lastIndex = 0;
return ctx.title && regex.test(ctx.title);
return ctx.title ? regex.test(ctx.title): false;
}
},
@ -410,7 +469,15 @@ export const Title = {
// Monitor Stuff
export let Monitor = null;
type MonitorType = FilterType<ScreenOptions, ContextData> & {
_used: boolean;
details?: ScreenDetails | null | false;
used: () => boolean;
};
export let Monitor: MonitorType = null as any;
if ( window.getScreenDetails ) {
@ -424,31 +491,31 @@ if ( window.getScreenDetails ) {
return out;
},
createTest(config = {}, _, reload) {
if ( ! config.label )
return () => false;
createTest(config, _, reload) {
if ( ! config?.label )
return NeverMatch;
Monitor._used = true;
if ( Monitor.details === undefined ) {
const FFZ = window.FrankerFaceZ ?? window.FFZBridge;
if ( FFZ )
FFZ.get().resolve('settings').createMonitorUpdate().then(() => {
if ( reload && Monitor.details === undefined ) {
const FFZ = window.FrankerFaceZ ?? ((window as any).FFZBridge as any),
ffz = FFZ?.get(),
settings = ffz?.resolve('settings');
if ( settings )
settings.createMonitorUpdate().then(() => {
reload();
});
}
return () => {
Monitor._used = true;
const details = Monitor.details,
screen = details?.currentScreen;
if ( ! screen )
const details = Monitor.details;
if ( ! details )
return false;
const sorted = details.screens, // sortScreens(Array.from(details.screens)),
const sorted = details.screens,
matched = matchScreen(sorted, config);
return matched === screen;
return matched === details.currentScreen;
};
},

File diff suppressed because it is too large Load diff

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 { SettingDefinition, SettingProcessor, SettingUiDefinition } from "./types";
const BAD = Symbol('BAD');
type BadType = typeof BAD;
function do_number(
input: number | BadType,
default_value: number,
definition: SettingUiDefinition<number>
) {
if ( typeof input !== 'number' || isNaN(input) || ! isFinite(input) )
input = BAD;
if ( input !== BAD ) {
const bounds = definition.bounds;
if ( Array.isArray(bounds) ) {
if ( bounds.length >= 3 ) {
// [low, inclusive, high, inclusive]
if ( (bounds[1] ? (input < bounds[0]) : (input <= bounds[0])) ||
// TODO: Figure out why it doesn't like bounds[2] but bounds[3] is okay
(bounds[3] ? (input > (bounds as any)[2]) : (input >= (bounds as any)[2])) )
input = BAD;
} else if ( bounds.length === 2 ) {
// [low, inclusive] or [low, high] ?
if ( typeof bounds[1] === 'boolean' ) {
if ( bounds[1] ? input < bounds[0] : input <= bounds[0] )
input = BAD;
} else if ( input < bounds[0] || input > bounds[1] )
input = BAD;
} else if ( bounds.length === 1 && input < bounds[0] )
input = BAD;
}
}
return input === BAD ? default_value : input;
}
export const to_int: SettingProcessor<number> = (
value,
default_value,
definition
) => {
if ( typeof value === 'string' && /^-?\d+$/.test(value) )
value = parseInt(value, 10);
else if ( typeof value !== 'number' )
value = BAD;
return do_number(value as number, default_value, definition);
}
export const to_float: SettingProcessor<number> = (
value: unknown,
default_value,
definition
) => {
if ( typeof value === 'string' && /^-?[\d.]+$/.test(value) )
value = parseFloat(value);
else if ( typeof value !== 'number' )
value = BAD;
return do_number(value as number, default_value, definition);
}

View file

@ -5,18 +5,151 @@
// ============================================================================
import {EventEmitter} from 'utilities/events';
import {isValidShortcut, has} from 'utilities/object';
import {createTester} from 'utilities/filtering';
import {isValidShortcut, fetchJSON} from 'utilities/object';
import {FilterData, createTester} from 'utilities/filtering';
import type SettingsManager from '.';
import type { SettingsProvider } from './providers';
import type { ContextData, ExportedSettingsProfile, SettingsProfileMetadata } from './types';
import type { Mousetrap } from '../utilities/types';
declare global {
interface Window {
Mousetrap?: Mousetrap;
}
}
export type ProfileEvents = {
'toggled': [profile: SettingsProfile, enabled: boolean];
'changed': [key: string, value: unknown, deleted: boolean];
}
const fetchJSON = (url, options) => fetch(url, options).then(r => r.ok ? r.json() : null).catch(() => null);
/**
* Instances of SettingsProfile are used for getting and setting raw settings
* values, enumeration, and emit events when the raw settings are changed.
* @extends EventEmitter
*/
export default class SettingsProfile extends EventEmitter {
constructor(manager, data) {
export default class SettingsProfile extends EventEmitter<ProfileEvents> {
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();
this.onShortcut = this.onShortcut.bind(this);
@ -35,10 +168,10 @@ export default class SettingsProfile extends EventEmitter {
}
}
get data() {
get data(): Partial<SettingsProfileMetadata> {
return {
id: this.id,
parent: this.parent,
//parent: this.parent,
name: this.name,
i18n_key: this.i18n_key,
@ -61,20 +194,32 @@ export default class SettingsProfile extends EventEmitter {
if ( typeof val !== 'object' )
throw new TypeError('data must be an object');
this.matcher = null;
this.clearMatcher();
// Make sure ephemeral is set first.
if ( val.ephemeral )
this.ephemeral = true;
for(const key in val)
if ( has(val, key) )
this[key] = val[key];
// Copy the values to this profile.
for(const [key, value] of Object.entries(val))
(this as any)[key] = value;
}
matches(context) {
clearMatcher() {
this.matcher = null;
}
matches(context: ContextData) {
if ( ! this.matcher )
this.matcher = createTester(this.context, this.manager.filters, false, false, () => this.manager.updateSoon());
this.matcher = createTester(
this.context,
this.manager.filters,
false,
false,
() => this.manager.updateSoon()
);
return this.matcher(context);
}
@ -86,8 +231,8 @@ export default class SettingsProfile extends EventEmitter {
}
getBackup() {
const out = {
getBackup(): ExportedSettingsProfile {
const out: ExportedSettingsProfile = {
version: 2,
type: 'profile',
profile: this.data,
@ -97,8 +242,8 @@ export default class SettingsProfile extends EventEmitter {
delete out.profile.ephemeral;
for(const [k,v] of this.entries())
out.values[k] = v;
for(const [key, value] of this.entries())
out.values[key] = value;
return out;
}
@ -108,8 +253,8 @@ export default class SettingsProfile extends EventEmitter {
if ( ! this.url || this.pause_updates )
return false;
const data = await fetchJSON(this.url);
if ( ! data || ! data.type === 'profile' || ! data.profile || ! data.values )
const data = await fetchJSON<ExportedSettingsProfile>(this.url);
if ( ! data || data.type !== 'profile' || ! data.profile || ! data.values )
return false;
// We don't want to override general settings.
@ -186,12 +331,12 @@ export default class SettingsProfile extends EventEmitter {
}
}
onShortcut(e) {
onShortcut(event: KeyboardEvent) {
this.toggled = ! this.toggled;
if ( e ) {
e.stopPropagation();
e.preventDefault();
if ( event ) {
event.stopPropagation();
event.preventDefault();
}
}
@ -223,22 +368,24 @@ export default class SettingsProfile extends EventEmitter {
// Context
// ========================================================================
// wtf is this method context is an array yo
/*
updateContext(context) {
if ( this.id === 0 )
throw new Error('cannot set context of default profile');
this.context = Object.assign(this.context || {}, context);
this.matcher = null;
this.manager._saveProfiles();
}
this.save();
}*/
setContext(context) {
setContext(context?: FilterData[]) {
if ( this.id === 0 )
throw new Error('cannot set context of default profile');
this.context = context;
this.matcher = null;
this.manager._saveProfiles();
this.clearMatcher();
this.save();
}
@ -246,37 +393,48 @@ export default class SettingsProfile extends EventEmitter {
// Setting Access
// ========================================================================
get(key, default_value) {
if ( this.ephemeral )
return this._storage.get(key, default_value);
return this.provider.get(this.prefix + key, default_value);
get<T>(key: string, default_value: T): T;
get<T>(key: string): T | null;
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) {
if ( this.ephemeral )
return this.provider.get<T>(this.prefix + key, default_value as T);
}
set(key: string, value: unknown) {
if ( this.ephemeral ) {
if ( this._storage )
this._storage.set(key, value);
else
} else
this.provider.set(this.prefix + key, value);
this.emit('changed', key, value);
this.emit('changed', key, value, false);
}
delete(key) {
if ( this.ephemeral )
delete(key: string) {
if ( this.ephemeral ) {
if ( this._storage )
this._storage.delete(key);
else
} else
this.provider.delete(this.prefix + key);
this.emit('changed', key, undefined, true);
}
has(key) {
has(key: string) {
if ( this.ephemeral )
return this._storage.has(key);
return this._storage ? this._storage.has(key): false;
return this.provider.has(this.prefix + key);
}
keys() {
if ( this.ephemeral )
return Array.from(this._storage.keys());
return this._storage ? Array.from(this._storage.keys()) : [];
const out = [],
p = this.prefix,
@ -291,11 +449,14 @@ export default class SettingsProfile extends EventEmitter {
clear() {
if ( this.ephemeral ) {
if ( this._storage ) {
const keys = this.keys();
this._storage.clear();
for(const key of keys) {
this.emit('changed', key, undefined, true);
}
}
return;
}
@ -310,22 +471,26 @@ export default class SettingsProfile extends EventEmitter {
*entries() {
if ( this.ephemeral ) {
if ( this._storage ) {
for(const entry of this._storage.entries())
yield entry;
}
} else {
const p = this.prefix,
len = p.length;
for(const key of this.provider.keys())
if ( key.startsWith(p) && key !== this.enabled_key )
yield [key.slice(len), this.provider.get(key)];
if ( key.startsWith(p) && key !== this.enabled_key ) {
const out: [string, unknown] = [key.slice(len), this.provider.get(key)];
yield out;
}
}
}
get size() {
if ( this.ephemeral )
return this._storage.size;
return this._storage ? this._storage.size : 0;
const p = this.prefix;
let count = 0;
@ -337,28 +502,3 @@ export default class SettingsProfile extends EventEmitter {
return count;
}
}
SettingsProfile.Default = {
id: 0,
name: 'Default Profile',
i18n_key: 'setting.profiles.default',
description: 'Settings that apply everywhere on Twitch.'
}
SettingsProfile.Moderation = {
id: 1,
name: 'Moderation',
i18n_key: 'setting.profiles.moderation',
description: 'Settings that apply when you are a moderator of the current channel.',
context: [
{
type: 'Moderator',
data: true
}
]
}

File diff suppressed because it is too large Load diff

View file

@ -1,32 +1,38 @@
'use strict';
import type Logger from "utilities/logging";
import type SettingsProfile from "./profile";
import type { SettingDefinition, SettingsTypeHandler } from "./types";
import type SettingsContext from "./context";
// ============================================================================
// Settings Types
// ============================================================================
const DEFAULT = Symbol('default');
export const basic = {
get(key, profiles) {
export const basic: SettingsTypeHandler = {
get<T>(key: string, profiles: SettingsProfile[]) {
for(const profile of profiles)
if ( profile.has(key) )
return [
profile.get(key),
profile.get(key) as T,
[profile.id]
]
}
}
export const object_merge = {
get(key, profiles, log) {
const values = [],
sources = [];
export const object_merge: SettingsTypeHandler = {
get<T>(key: string, profiles: SettingsProfile[], definition: SettingDefinition<any>, log: Logger) {
const values: T[] = [],
sources: number[] = [];
for(const profile of profiles)
if ( profile.has(key) ) {
const val = profile.get(key);
if ( typeof val !== 'object' ) {
const val = profile.get<T>(key);
if ( ! val || typeof val !== 'object' ) {
log.warn(`Profile #${profile.id} has an invalid value for "${key}" of type ${typeof val}. Skipping.`);
continue;
}
@ -44,14 +50,16 @@ export const object_merge = {
}
export const basic_array_merge = {
get(key, profiles, log) {
const values = [],
sources = [];
type UnwrapArray<T> = T extends Array<infer U> ? U : T;
export const basic_array_merge: SettingsTypeHandler = {
get<T>(key: string, profiles: SettingsProfile[], definition: SettingDefinition<any>, log: Logger) {
const values: UnwrapArray<T>[] = [],
sources: number[] = [];
for(const profile of profiles)
if ( profile.has(key) ) {
const val = profile.get(key);
const val = profile.get<UnwrapArray<T>>(key);
if ( ! Array.isArray(val) ) {
log.warn(`Profile #${profile.id} has an invalid value for "${key}"`);
continue;
@ -71,7 +79,7 @@ export const basic_array_merge = {
}
export const array_merge = {
export const array_merge: SettingsTypeHandler = {
default(val) {
const values = [];
for(const v of val)
@ -81,13 +89,20 @@ export const array_merge = {
return values;
},
get(key, profiles, definition, log, ctx) {
const values = [],
sources = [];
let trailing = [];
get<T>(
key: string,
profiles: SettingsProfile[],
definition: SettingDefinition<any>,
log: Logger,
ctx: SettingsContext
) {
const values: UnwrapArray<T>[] = [],
sources: number[] = [];
let trailing: UnwrapArray<T>[] = [];
let had_value = false;
let profs = profiles;
let profs: (SettingsProfile | typeof DEFAULT)[] = profiles;
if ( definition.inherit_default )
profs = [...profiles, DEFAULT];
@ -109,7 +124,7 @@ export const array_merge = {
continue;
}
const trail = [];
const trail: UnwrapArray<T>[] = [];
if ( profile !== DEFAULT )
sources.push(profile.id);
@ -141,3 +156,12 @@ export const array_merge = {
]
}
}
export default {
basic,
object_merge,
basic_array_merge,
array_merge
} as Record<string, SettingsTypeHandler>;

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

@ -0,0 +1,333 @@
import type SettingsManager from ".";
import type { FilterData } from "../utilities/filtering";
import type Logger from "../utilities/logging";
import type { PathNode } from "../utilities/path-parser";
import type { ExtractKey, ExtractSegments, ExtractType, JoinKeyPaths, ObjectKeyPaths, OptionalPromise, OptionallyCallable, PartialPartial, RecursivePartial, SettingsTypeMap } from "../utilities/types";
import type SettingsContext from "./context";
import type SettingsProfile from "./profile";
import type { SettingsProvider } from "./providers";
// Clearables
type SettingsClearableKeys = {
keys: OptionallyCallable<[provider: SettingsProvider, manager: SettingsManager], OptionalPromise<string[]>>;
}
type SettingsClearableClear = {
clear(provider: SettingsProvider, manager: SettingsManager): OptionalPromise<void>;
}
export type SettingsClearable = {
label: string;
__source?: string | null;
} & (SettingsClearableKeys | SettingsClearableClear);
// Context
export interface ConcreteContextData {
addonDev: boolean;
category: string;
categoryID: string;
chat: {
};
title: string;
channel: string;
channelColor: string;
channelID: string;
chatHidden: boolean;
fullscreen: boolean;
isWatchParty: boolean;
moderator: boolean;
route: {
domain: string | null;
name: string | null;
};
route_data: string[];
size: {
width: number;
height: number
};
ui: {
theatreModeEnabled: boolean;
squadModeEnabled: boolean;
theme: number;
};
};
export type ContextData = RecursivePartial<ConcreteContextData>;
export interface ConcreteLocalStorageData {
test: number;
}
export type LocalStorageData = Partial<ConcreteLocalStorageData>;
export type SettingsContextKeys = JoinKeyPaths<'context', ObjectKeyPaths<ConcreteContextData>>;
export type SettingsLocalStorageKeys = JoinKeyPaths<'ls', ObjectKeyPaths<ConcreteLocalStorageData>> | JoinKeyPaths<'ls.raw', ObjectKeyPaths<ConcreteLocalStorageData>>;
export type SettingsKeys = keyof SettingsTypeMap;
export type AllSettingsKeys = SettingsKeys | SettingsContextKeys | SettingsLocalStorageKeys;
export type SettingType<K extends AllSettingsKeys> =
K extends `context.${infer Rest}`
? ExtractType<ConcreteContextData, ExtractSegments<Rest>> | undefined
:
K extends `ls.raw.${infer _}`
? string | undefined
:
K extends `ls.${infer Rest}`
? Rest extends keyof LocalStorageData
? LocalStorageData[Rest]
: unknown
:
K extends keyof SettingsTypeMap
? SettingsTypeMap[K]
:
unknown;
export type SettingMetadata = {
uses: number[];
};
// Usable Definitions
export type OptionalSettingDefinitionKeys = 'type';
export type ForbiddenSettingDefinitionKeys = '__source' | 'ui';
export type SettingDefinition<T> = Omit<
PartialPartial<FullSettingDefinition<T>, OptionalSettingDefinitionKeys>,
ForbiddenSettingDefinitionKeys
> & {
ui: SettingUiDefinition<T>;
};
export type OptionalSettingUiDefinitionKeys = 'key' | 'path_tokens' | 'i18n_key';
export type ForbiddenSettingUiDefinitionKeys = never;
export type SettingUiDefinition<T> = PartialPartial<FullSettingUiDefinition<T>, OptionalSettingUiDefinitionKeys>;
// Definitions
export type FullSettingDefinition<T> = {
default: ((ctx: SettingsContext) => T) | T,
type?: string;
equals?: 'requirements' | ((new_value: T, old_value: T | undefined, cache: Map<SettingsKeys, unknown>, old_cache: Map<SettingsKeys, unknown>) => boolean);
process?(ctx: SettingsContext, val: T, meta: SettingMetadata): T;
// Dependencies
required_by?: string[];
requires?: string[];
always_inherit?: boolean;
inherit_default?: boolean;
// Tracking
__source?: string | null;
// UI Stuff
ui?: SettingUiDefinition<T>;
// Reactivity
changed?: (value: T) => void;
};
// UI Definitions
export type SettingUi_Basic = {
key: string;
path: string;
path_tokens: PathNode[];
no_filter?: boolean;
force_seen?: boolean;
title: string;
i18n_key: string;
description?: string;
desc_i18n_key?: string;
/**
* Optional. If present, this method will be used to retrieve an array of
* additional search terms that can be used to search for this setting.
*/
getExtraTerms?: () => string[];
};
// ============================================================================
// Each built-in settings component has a type with extra data definitions.
// ============================================================================
// Text Box
// ============================================================================
export type SettingUi_TextBox = SettingUi_Basic & {
component: 'setting-text-box';
} & (SettingUi_TextBox_Process_Number | SettingUi_TextBox_Process_Other);
// Processing
export type SettingUi_TextBox_Process_Other = {
process?: Exclude<string, 'to_int' | 'to_float'>;
}
export type SettingUi_TextBox_Process_Number = {
process: 'to_int' | 'to_float';
/**
* Bounds represents a minimum and maximum numeric value. These values
* are used by number processing and validation if the processor is set
* to `to_int` or `to_float`.
*/
bounds?:
[low: number, low_inclusive: boolean, high: number, high_inclusive: boolean] |
[low: number, low_inclusive: boolean, high: number] |
[low: number, low_inclusive: boolean] |
[low: number, high: number] |
[low: number];
}
// Check Box
// ============================================================================
export type SettingUi_CheckBox = SettingUi_Basic & {
component: 'setting-check-box';
};
// Select Box
// ============================================================================
export type SettingUi_Select<T> = SettingUi_Basic & {
component: 'setting-select-box';
data: OptionallyCallable<[profile: SettingsProfile, current: T], SettingUi_Select_Entry<T>[]>;
}
export type SettingUi_Select_Entry<T> = {
value: T;
title: string;
};
// ============================================================================
// Combined Definitions
// ============================================================================
export type SettingTypeUiDefinition<T> = SettingUi_TextBox | SettingUi_CheckBox | SettingUi_Select<T>;
// We also support other components, if the component doesn't match.
export type SettingOtherUiDefinition = SettingUi_Basic & {
component: Exclude<string, ExtractKey<SettingTypeUiDefinition<any>, 'component'>>;
}
// The final combined definition.
export type FullSettingUiDefinition<T> = SettingTypeUiDefinition<T> | SettingOtherUiDefinition;
// Exports
export type ExportedSettingsProfile = {
version: 2;
type: 'profile';
profile: Partial<SettingsProfileMetadata>;
toggled?: boolean;
values: Record<string, any>;
};
export type ExportedFullDump = {
version: 2;
type: 'full';
values: Record<string, any>;
};
export type ExportedBlobMetadata = {
key: string;
type?: string;
name?: string;
modified?: number;
mime?: string;
};
// Profiles
export type SettingsProfileMetadata = {
id: number;
name: string;
i18n_key?: string | null;
hotkey?: string | null;
pause_updates: boolean;
ephemeral?: boolean;
description?: string | null;
desc_i18n_key?: string | null;
url?: string | null;
show_toggle: boolean;
context?: FilterData[] | null;
};
// Type Handlers
export type SettingsTypeHandler = {
default?(input: any, definition: SettingDefinition<any>, log: Logger): any;
get(
key: string,
profiles: SettingsProfile[],
definition: SettingDefinition<any>,
log: Logger,
ctx: SettingsContext
): [unknown, number[]] | null | undefined;
}
// Processors
export type SettingProcessor<T> = (
input: unknown,
default_value: T,
definition: SettingUiDefinition<T>
) => T;
// Validators
export type SettingValidator<T> = (
value: T,
definition: SettingUiDefinition<T>
) => boolean;

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 { SettingUiDefinition, SettingValidator } from "./types";
function do_number(value: any, definition: SettingUiDefinition<number>) {
if ( typeof value !== 'number' || isNaN(value) || ! isFinite(value) )
return false;
const bounds = definition.bounds;
if ( Array.isArray(bounds) ) {
if ( bounds.length >= 3 ) {
// [low, inclusive, high, inclusive]
if ( (bounds[1] ? (value < bounds[0]) : (value <= bounds[0])) ||
(bounds[3] ? (value > (bounds as any)[2]) : (value >= (bounds as any)[2])) )
return false;
} else if ( bounds.length === 2 ) {
// [low, inclusive] or [low, high] ?
if ( typeof bounds[1] === 'boolean' ) {
if ( bounds[1] ? value < bounds[0] : value <= bounds[0] )
return false;
} else if ( value < bounds[0] || value > bounds[1] )
return false;
} else if ( bounds.length === 1 && value < bounds[0] )
return false;
}
return true;
}
export const process_to_int: SettingValidator<number> = (
value,
definition
) => {
if ( typeof value === 'string' && /^-?\d+$/.test(value) )
value = parseInt(value, 10);
else if ( typeof value !== 'number' )
return false;
return do_number(value, definition);
}
export const process_to_float: SettingValidator<number> = (
value,
definition
) => {
if ( typeof value === 'string' && /^-?[\d.]+$/.test(value) )
value = parseFloat(value);
else if ( typeof value !== 'number' )
return false;
return do_number(value, definition);
}

View file

@ -44,7 +44,7 @@ export default class Twilight extends BaseSite {
async populateModules() {
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.`);
}

View file

@ -257,51 +257,6 @@ export default class Channel extends Module {
}
}
/*setHost(channel_id, channel_login, target_id, target_login) {
const topic = `stream-chat-room-v1.${channel_id}`;
this.subpump.inject(topic, {
type: 'host_target_change',
data: {
channel_id,
channel_login,
target_channel_id: target_id || null,
target_channel_login: target_login || null,
previous_target_channel_id: null,
num_viewers: 0
}
});
this.subpump.inject(topic, {
type: 'host_target_change_v2',
data: {
channel_id,
channel_login,
target_channel_id: target_id || null,
target_channel_login: target_login || null,
previous_target_channel_id: null,
num_viewers: 0
}
});
}
onPubSub(event) {
if ( event.prefix !== 'stream-chat-room-v1' || this.settings.get('channel.hosting.enable') )
return;
const type = event.message.type;
if ( type === 'host_target_change' || type === 'host_target_change_v2' ) {
this.log.info('Nulling Host Target Change', type);
event.message.data.target_channel_id = null;
event.message.data.target_channel_login = null;
event.message.data.previous_target_channel_id = null;
event.message.data.num_viewers = 0;
event.markChanged();
}
}*/
updateSubscription(id, login) {
if ( this._subbed_login === login && this._subbed_id === id )
return;
@ -431,10 +386,14 @@ export default class Channel extends Module {
this.fine.searchNode(react, node => {
let state = node?.memoizedState, i = 0;
while(state != null && channel == null && i < 50 ) {
state = state?.next;
channel = state?.memoizedState?.current?.previous?.result?.data?.user;
if (!channel?.lastBroadcast?.game)
channel = state?.memoizedState?.current?.result?.data?.user ??
state?.memoizedState?.current?.previousData?.user;
if ( !channel?.lastBroadcast?.game )
channel = null;
if ( ! channel )
state = state?.next;
i++;
}
return channel != null;
@ -583,10 +542,11 @@ export default class Channel extends Module {
let state = node?.memoizedState;
i=0;
while(state != null && channel == null && i < 50) {
state = state?.next;
channel = state?.memoizedState?.current?.currentObservable?.lastResult?.data?.userOrError;
channel = state?.memoizedState?.current?.result?.data?.userOrError ??
state?.memoizedState?.current?.previousData?.userOrError;
if ( ! channel )
channel = state?.memoizedState?.current?.previous?.result?.previousData?.userOrError;
state = state?.next;
i++;
}
node = node?.return;

View file

@ -7,7 +7,7 @@
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 {WEBKIT_CSS as WEBKIT} from 'utilities/constants';
import {FFZEvent} from 'utilities/events';
import {useFont} from 'utilities/fonts';
import Module from 'utilities/module';
@ -1910,7 +1910,7 @@ export default class ChatHook extends Module {
return;
if ( t.hasListeners('chat:receive-message') ) {
const event = new FFZEvent({
const event = t.makeEvent({
message: m,
inst,
channel: room,
@ -2277,12 +2277,16 @@ export default class ChatHook extends Module {
if ( want_event ) {
if ( ! event ) {
event = new FFZEvent();
event = t.makeEvent({
inst: this,
channel: undefined,
channelID: undefined,
message: undefined
});
const cont = this._ffz_connector ?? this.ffzGetConnector(),
room_id = cont && cont.props.channelID;
event.inst = this;
event.channelID = room_id;
if ( room_id ) {
@ -2447,7 +2451,7 @@ export default class ChatHook extends Module {
msg = msg.slice(idx + 1).trimStart();
}
const event = new FFZEvent({
const event = t.makeEvent({
command: subcmd,
message: msg,
extra,
@ -2472,7 +2476,7 @@ export default class ChatHook extends Module {
return false;
}
const event = new FFZEvent({
const event = t.makeEvent({
message: msg,
extra,
context: t.chat.context,

View file

@ -6,8 +6,8 @@
import Module from 'utilities/module';
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 Twilight from 'site';
@ -523,7 +523,7 @@ export default class Input extends Module {
}
previewClick(id, set, name, evt) {
const fe = new FFZEvent({
const fe = this.makeEvent({
provider: 'ffz',
id,
set,
@ -779,7 +779,7 @@ export default class Input extends Module {
isEditor: inst.props.isCurrentUserEditor
});
const event = new FFZEvent({
const event = t.makeEvent({
input,
permissionLevel: inst.props.permissionLevel,
isEditor: inst.props.isCurrentUserEditor,

View file

@ -8,11 +8,11 @@ import Twilight from 'site';
import Module from 'utilities/module';
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 { print_duration } from 'utilities/time';
import { FFZEvent } from 'utilities/events';
import { getRewardTitle, getRewardCost, isHighlightedReward } from './points';
import { getRewardTitle, getRewardCost } from './points';
const SUB_TIERS = {
1000: 1,
@ -431,21 +431,6 @@ export default class ChatLine extends Module {
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)
this.chat.context.on(`changed:${setting}`, this.rerenderLines, this);
@ -839,7 +824,7 @@ other {# messages were deleted by a moderator.}
} catch(err) { /* nothing~! */ }
}
const fe = new FFZEvent({
const fe = t.makeEvent({
inst: this,
event,
message: msg,

View file

@ -247,7 +247,7 @@ export default class Scroller extends Module {
inst.ffz_outside = true;
inst._ffz_accessor = `_ffz_contains_${last_id++}`;
t.on('tooltips:mousemove', this.ffzTooltipHover, this);
t.on('tooltips:hover', this.ffzTooltipHover, this);
t.on('tooltips:leave', this.ffzTooltipLeave, this);
inst.scrollToBottom = function() {
@ -682,7 +682,7 @@ export default class Scroller extends Module {
}
onUnmount(inst) { // eslint-disable-line class-methods-use-this
this.off('tooltips:mousemove', inst.ffzTooltipHover, inst);
this.off('tooltips:hover', inst.ffzTooltipHover, inst);
this.off('tooltips:leave', inst.ffzTooltipLeave, inst);
if ( inst._ffz_hover_timer ) {

View file

@ -597,7 +597,7 @@ export default class CSSTweaks extends Module {
return;
if ( ! this.chunks_loaded )
return this.populate().then(() => this._apply(key));
return this.loadFromContext().then(() => this._apply(key));
if ( ! has(this.chunks, key) ) {
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}`) }
populate() {
loadFromContext() {
if ( this.chunks_loaded )
return;

View file

@ -4,15 +4,16 @@
// Directory (Following, for now)
// ============================================================================
import {SiteModule} from 'utilities/module';
import Module from 'utilities/module';
import {createElement} from 'utilities/dom';
import { get } from 'utilities/object';
export default class Game extends SiteModule {
export default class Game extends Module {
constructor(...args) {
super(...args);
this.inject('site');
this.inject('site.fine');
this.inject('site.apollo');

View file

@ -4,7 +4,7 @@
// Directory
// ============================================================================
import {SiteModule} from 'utilities/module';
import Module from 'utilities/module';
import {duration_to_string} from 'utilities/time';
import {createElement} from 'utilities/dom';
import {get, glob_to_regex, escape_regex, addWordSeparators} from 'utilities/object';
@ -18,6 +18,15 @@ export const CARD_CONTEXTS = ((e ={}) => {
return e;
})();
export const CONTENT_FLAGS = [
'DrugsIntoxication',
'Gambling',
'MatureGame',
'ProfanityVulgarity',
'SexualThemes',
'ViolentGrpahic'
];
function formatTerms(data, flags) {
if ( data[0].length )
data[1].push(addWordSeparators(data[0].join('|')));
@ -33,12 +42,13 @@ function formatTerms(data, flags) {
const DIR_ROUTES = ['front-page', 'dir', 'dir-community', 'dir-community-index', 'dir-creative', 'dir-following', 'dir-game-index', 'dir-game-clips', 'dir-game-videos', 'dir-all', 'dir-category', 'user-videos', 'user-clips'];
export default class Directory extends SiteModule {
export default class Directory extends Module {
constructor(...args) {
super(...args);
this.should_enable = true;
this.inject('site');
this.inject('site.elemental');
this.inject('site.fine');
this.inject('site.router');
@ -252,6 +262,115 @@ export default class Directory extends SiteModule {
changed: () => this.updateCards()
});
this.settings.add('directory.blur-titles', {
default: [],
type: 'array_merge',
always_inherit: true,
ui: {
path: 'Directory > Channels >> Hide Thumbnails by Title',
component: 'basic-terms'
}
});
this.settings.add('__filter:directory.blur-titles', {
requires: ['directory.blur-titles'],
equals: 'requirements',
process(ctx) {
const val = ctx.get('directory.blur-titles');
if ( ! val || ! val.length )
return null;
const out = [
[ // sensitive
[], [] // word
],
[
[], []
]
];
for(const item of val) {
const t = item.t;
let v = item.v;
if ( t === 'glob' )
v = glob_to_regex(v);
else if ( t !== 'raw' )
v = escape_regex(v);
if ( ! v || ! v.length )
continue;
out[item.s ? 0 : 1][item.w ? 0 : 1].push(v);
}
return [
formatTerms(out[0], 'g'),
formatTerms(out[1], 'gi')
];
},
changed: () => this.updateCards()
});
this.settings.add('directory.blur-tags', {
default: [],
type: 'basic_array_merge',
always_inherit: true,
ui: {
path: 'Directory > Channels >> Hide Thumbnails by Tag',
component: 'tag-list-editor'
},
changed: () => this.updateCards()
});
this.settings.add('directory.block-flags', {
default: [],
type: 'array_merge',
always_inherit: true,
process(ctx, val) {
const out = new Set;
for(const v of val)
if ( v?.v )
out.add(v.v);
return out;
},
ui: {
path: 'Directory > Channels >> Block by Flag',
component: 'blocked-types',
data: () => [...CONTENT_FLAGS]
.sort()
},
changed: () => this.updateCards()
});
this.settings.add('directory.blur-flags', {
default: [],
type: 'array_merge',
always_inherit: true,
process(ctx, val) {
const out = new Set;
for(const v of val)
if ( v?.v )
out.add(v.v);
return out;
},
ui: {
path: 'Directory > Channels >> Hide Thumbnails by Flag',
component: 'blocked-types',
data: () => [...CONTENT_FLAGS]
.sort()
},
changed: () => this.updateCards()
});
/*this.settings.add('directory.hide-viewing-history', {
default: false,
ui: {
@ -457,22 +576,60 @@ export default class Directory extends SiteModule {
const game = props.gameTitle || props.trackingProps?.categoryName || props.trackingProps?.category || props.contextualCardActionProps?.props?.categoryName,
tags = props.tagListProps?.freeformTags;
let bad_tag = false;
const blur_flags = this.settings.get('directory.blur-flags', []),
block_flags = this.settings.get('directory.block-flags', []);
el.classList.toggle('ffz-hide-thumbnail', this.settings.provider.get('directory.game.hidden-thumbnails', []).includes(game));
el.dataset.ffzType = props.streamType;
if ( el._ffz_flags === undefined && (blur_flags.size || block_flags.size) ) {
el._ffz_flags = null;
this.twitch_data.getStreamFlags(null, props.channelLogin).then(data => {
el._ffz_flags = data;
this.updateCard(el);
});
}
let bad_tag = false,
blur_tag = false;
if ( Array.isArray(tags) ) {
const bad_tags = this.settings.get('directory.blocked-tags', []);
if ( bad_tags.length ) {
const bad_tags = this.settings.get('directory.blocked-tags', []),
blur_tags = this.settings.get('directory.blur-tags', []);
if ( bad_tags.length || blur_tags.length ) {
for(const tag of tags) {
if ( tag?.name && bad_tags.includes(tag.name.toLowerCase()) ) {
if ( tag?.name ) {
const lname = tag.name.toLowerCase();
if ( bad_tags.includes(lname) )
bad_tag = true;
if ( blur_tags.includes(lname) )
blur_tag = true;
}
if ( (bad_tag || ! bad_tags.length) && (blur_tag || ! blur_tags.length) )
break;
}
}
}
let should_blur = blur_tag;
if ( ! should_blur )
should_blur = this.settings.provider.get('directory.game.hidden-thumbnails', []).includes(game);
if ( ! should_blur && blur_flags.size && el._ffz_flags ) {
for(const flag of el._ffz_flags)
if ( flag?.id && blur_flags.has(flag.id) ) {
should_blur = true;
break;
}
}
if ( ! should_blur ) {
const regexes = this.settings.get('__filter:directory.blur-titles');
if ( regexes &&
(( regexes[0] && regexes[0].test(props.title) ) ||
( regexes[1] && regexes[1].test(props.title) ))
)
should_blur = true;
}
el.classList.toggle('ffz-hide-thumbnail', should_blur);
el.dataset.ffzType = props.streamType;
let should_hide = false;
if ( bad_tag )
@ -484,6 +641,15 @@ export default class Directory extends SiteModule {
else if ( (props.isPromotion || props.sourceType === 'COMMUNITY_BOOST' || props.sourceType === 'PROMOTION' || props.sourceType === 'SPONSORED') && this.settings.get('directory.hide-promoted') )
should_hide = true;
else {
if ( block_flags.size && el._ffz_flags ) {
for(const flag of el._ffz_flags)
if ( flag?.id && block_flags.has(flag.id) ) {
should_hide = true;
break;
}
}
if ( ! should_hide ) {
const regexes = this.settings.get('__filter:directory.block-titles');
if ( regexes &&
(( regexes[0] && regexes[0].test(props.title) ) ||
@ -491,6 +657,7 @@ export default class Directory extends SiteModule {
)
should_hide = true;
}
}
let hide_container = el.closest('.tw-tower > div');
if ( ! hide_container )

View file

@ -4,19 +4,20 @@
// Following Button Modification
// ============================================================================
import {SiteModule} from 'utilities/module';
import Module from 'utilities/module';
import {createElement as e} from 'utilities/dom';
import {duration_to_string} from 'utilities/time';
import Tooltip from 'utilities/tooltip';
export default class FollowingText extends SiteModule {
export default class FollowingText extends Module {
constructor(...args) {
super(...args);
this.should_enable = true;
this.inject('settings');
this.inject('site');
this.inject('site.router');
this.inject('site.apollo');
this.inject('i18n');

View file

@ -1,15 +1,64 @@
'use strict';
import type SettingsManager from 'root/src/settings';
import type { FineWrapper } from 'root/src/utilities/compat/fine';
import type Fine from 'root/src/utilities/compat/fine';
import type { ReactStateNode } from 'root/src/utilities/compat/react-types';
// ============================================================================
// Loadable Stuff
// ============================================================================
import Module from 'utilities/module';
import Module, { GenericModule } from 'utilities/module';
import type { AnyFunction } from 'utilities/types';
import type Twilight from '..';
declare module 'utilities/types' {
interface ModuleEventMap {
}
interface ModuleMap {
'site.loadable': Loadable
}
interface SettingsTypeMap {
'chat.hype.show-pinned': boolean;
'layout.turbo-cta': boolean;
}
}
type LoadableNode = ReactStateNode<{
component: string;
loader: any;
}, {
Component?: AnyFunction;
}>;
type ErrorBoundaryNode = ReactStateNode<{
name: string;
onError: any;
children: any;
}> & {
onErrorBoundaryTestEmit: any
}
export default class Loadable extends Module {
constructor(...args) {
super(...args);
// Dependencies
settings: SettingsManager = null as any;
site: Twilight = null as any;
fine: Fine = null as any;
// State
overrides: Map<string, boolean>;
// Fine
ErrorBoundaryComponent: FineWrapper<ErrorBoundaryNode>;
LoadableComponent: FineWrapper<LoadableNode>;
constructor(name?: string, parent?: GenericModule) {
super(name, parent);
this.should_enable = true;
@ -19,12 +68,18 @@ export default class Loadable extends Module {
this.LoadableComponent = this.fine.define(
'loadable-component',
n => n.props?.component && n.props.loader
n =>
(n as LoadableNode).props?.component &&
(n as LoadableNode).props.loader
);
this.ErrorBoundaryComponent = this.fine.define(
'error-boundary-component',
n => n.props?.name && n.props?.onError && n.props?.children && n.onErrorBoundaryTestEmit
n =>
(n as ErrorBoundaryNode).props?.name &&
(n as ErrorBoundaryNode).props?.onError &&
(n as ErrorBoundaryNode).props?.children &&
(n as ErrorBoundaryNode).onErrorBoundaryTestEmit
);
this.overrides = new Map();
@ -44,9 +99,10 @@ export default class Loadable extends Module {
this.log.debug('Found Error Boundary component wrapper.');
const t = this,
old_render = cls.prototype.render;
proto = cls.prototype as ErrorBoundaryNode,
old_render = proto.render;
cls.prototype.render = function() {
proto.render = function() {
try {
const type = this.props.name;
if ( t.overrides.has(type) && ! t.shouldRender(type) )
@ -66,32 +122,33 @@ export default class Loadable extends Module {
this.log.debug('Found Loadable component wrapper.');
const t = this,
old_render = cls.prototype.render;
proto = cls.prototype,
old_render = proto.render;
cls.prototype.render = function() {
proto.render = function() {
try {
const type = this.props.component;
if ( t.overrides.has(type) ) {
if ( t.overrides.has(type) && this.state ) {
let cmp = this.state.Component;
if ( typeof cmp === 'function' && ! cmp.ffzWrapped ) {
if ( typeof cmp === 'function' && ! (cmp as any).ffzWrapped ) {
const React = t.site.getReact(),
createElement = React && React.createElement;
if ( createElement ) {
if ( ! cmp.ffzWrapper ) {
if ( ! (cmp as any).ffzWrapper ) {
const th = this;
function FFZWrapper(props, state) {
if ( t.shouldRender(th.props.component, props, state) )
function FFZWrapper(props: any) {
if ( t.shouldRender(th.props.component) )
return createElement(cmp, props);
return null;
}
FFZWrapper.ffzWrapped = true;
FFZWrapper.displayName = `FFZWrapper(${this.props.component})`;
cmp.ffzWrapper = FFZWrapper;
(cmp as any).ffzWrapper = FFZWrapper;
}
this.state.Component = cmp.ffzWrapper;
this.state.Component = (cmp as any).ffzWrapper;
}
}
}
@ -107,7 +164,7 @@ export default class Loadable extends Module {
});
}
toggle(cmp, state = null) {
toggle(cmp: string, state: boolean | null = null) {
const existing = this.overrides.get(cmp) ?? true;
if ( state == null )
@ -121,21 +178,21 @@ export default class Loadable extends Module {
}
}
update(cmp) {
update(cmp: string) {
for(const inst of this.LoadableComponent.instances) {
const type = inst?.props?.component;
const type = inst.props?.component;
if ( type && type === cmp )
inst.forceUpdate();
}
for(const inst of this.ErrorBoundaryComponent.instances) {
const name = inst?.props?.name;
const name = inst.props?.name;
if ( name && name === cmp )
inst.forceUpdate();
}
}
shouldRender(cmp, props) {
shouldRender(cmp: string) {
return this.overrides.get(cmp) ?? true;
}

View file

@ -5,19 +5,20 @@
// ============================================================================
import {DEBUG} from 'utilities/constants';
import {SiteModule} from 'utilities/module';
import Module from 'utilities/module';
import {createElement, ClickOutside, setChildren} from 'utilities/dom';
import Twilight from 'site';
import awaitMD, {getMD} from 'utilities/markdown';
export default class MenuButton extends SiteModule {
export default class MenuButton extends Module {
constructor(...args) {
super(...args);
this.inject('i18n');
this.inject('settings');
this.inject('site');
this.inject('site.fine');
this.inject('site.elemental');
//this.inject('addons');

View file

@ -109,11 +109,10 @@ export default class ModView extends Module {
let state = node.memoizedState;
i = 0;
while(state != null && channel == null && i < 50) {
channel = state?.memoizedState?.current?.result?.data?.user ??
state?.memoizedState?.current?.previousData?.user;
state = state?.next;
//channel = state?.memoizedState?.current?.previousData?.result?.data?.user;
channel = state?.memoizedState?.current?.currentObservable?.lastResult?.data?.user;
if ( ! channel )
channel = state?.memoizedState?.current?.previous?.result?.previousData?.user;
i++;
}
node = node?.child;
@ -226,8 +225,9 @@ export default class ModView extends Module {
let channel = null, state = root?.return?.memoizedState, i = 0;
while(state != null && channel == null && i < 50 ) {
channel = state?.memoizedState?.current?.result?.data?.channel ??
state?.memoizedState?.current?.previousData?.channel;
state = state?.next;
channel = state?.memoizedState?.current?.previous?.result?.data?.channel;
i++;
}

View file

@ -4,12 +4,50 @@
// Sub Button
// ============================================================================
import Module from 'utilities/module';
import Module, { GenericModule } from 'utilities/module';
import {createElement} from 'utilities/dom';
import type SettingsManager from 'src/settings';
import type TranslationManager from 'src/i18n';
import type Fine from 'utilities/compat/fine';
import type { FineWrapper } from 'utilities/compat/fine';
import type { ReactStateNode } from 'root/src/utilities/compat/react-types';
declare module 'utilities/types' {
interface ModuleMap {
'site.sub_button': SubButton;
}
interface SettingsTypeMap {
'layout.swap-sidebars': unknown;
'sub-button.prime-notice': boolean;
}
}
type SubButtonNode = ReactStateNode<{
data?: {
user?: {
self?: {
canPrimeSubscribe: boolean;
subscriptionBenefit: unknown;
}
}
}
}> & {
handleSubMenuAction: any;
openSubModal: any;
};
export default class SubButton extends Module {
constructor(...args) {
super(...args);
// Dependencies
i18n: TranslationManager = null as any;
fine: Fine = null as any;
settings: SettingsManager = null as any;
// Stuff
SubButton: FineWrapper<SubButtonNode>;
constructor(name?: string, parent?: GenericModule) {
super(name, parent);
this.should_enable = true;
@ -32,39 +70,20 @@ export default class SubButton extends Module {
this.SubButton = this.fine.define(
'sub-button',
n => n.handleSubMenuAction && n.openSubModal,
n =>
(n as SubButtonNode).handleSubMenuAction &&
(n as SubButtonNode).openSubModal,
['user', 'user-home', 'user-video', 'user-clip', 'video', 'user-videos', 'user-clips', 'user-collections', 'user-events', 'user-followers', 'user-following']
);
}
onEnable() {
this.settings.on(':changed:layout.swap-sidebars', () => this.SubButton.forceUpdate())
this.settings.on(':changed:layout.swap-sidebars', () =>
this.SubButton.forceUpdate());
this.SubButton.ready((cls, instances) => {
const t = this,
old_render = cls.prototype.render;
cls.prototype.render = function() {
try {
const old_direction = this.props.balloonDirection;
if ( old_direction !== undefined ) {
const should_be_left = t.settings.get('layout.swap-sidebars'),
is_left = old_direction.includes('--left');
if ( should_be_left && ! is_left )
this.props.balloonDirection = old_direction.replace('--right', '--left');
else if ( ! should_be_left && is_left )
this.props.balloonDirection = old_direction.replace('--left', '--right');
}
} catch(err) { /* no-op */ }
return old_render.call(this);
}
for(const inst of instances)
this.updateSubButton(inst);
this.SubButton.forceUpdate();
});
this.SubButton.on('mount', this.updateSubButton, this);
@ -72,9 +91,9 @@ export default class SubButton extends Module {
}
updateSubButton(inst) {
const container = this.fine.getChildNode(inst),
btn = container && container.querySelector('button[data-a-target="subscribe-button"]');
updateSubButton(inst: SubButtonNode) {
const container = this.fine.getChildNode<HTMLElement>(inst),
btn = container?.querySelector('button[data-a-target="subscribe-button"]');
if ( ! btn )
return;

View file

@ -4,12 +4,38 @@
// Staging Selector
// ============================================================================
import Module from 'utilities/module';
import Module, { GenericModule } from 'utilities/module';
import { API_SERVER, SERVER, STAGING_API, STAGING_CDN } from './utilities/constants';
import type SettingsManager from './settings';
export default class StagingSelector extends Module {
constructor(...args) {
super(...args);
declare module 'utilities/types' {
interface ModuleMap {
staging: StagingSelector;
}
interface ModuleEventMap {
staging: StagingEvents;
}
interface SettingsTypeMap {
'data.use-staging': boolean;
}
}
type StagingEvents = {
':updated': [api: string, cdn: string];
}
export default class StagingSelector extends Module<'staging', StagingEvents> {
// Dependencies
settings: SettingsManager = null as any;
// State
api: string = API_SERVER;
cdn: string = SERVER;
active: boolean = false;
constructor(name?: string, parent?: GenericModule) {
super(name, parent);
this.inject('settings');
@ -26,11 +52,12 @@ export default class StagingSelector extends Module {
this.updateStaging(false);
}
/** @internal */
onEnable() {
this.settings.getChanges('data.use-staging', this.updateStaging, this);
}
updateStaging(val) {
private updateStaging(val: boolean) {
this.active = val;
this.api = val

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

@ -1,669 +0,0 @@
'use strict';
export function hue2rgb(p, q, t) {
if ( t < 0 ) t += 1;
if ( t > 1 ) t -= 1;
if ( t < 1/6 )
return p + (q-p) * 6 * t;
if ( t < 1/2 )
return q;
if ( t < 2/3 )
return p + (q-p) * (2/3 - t) * 6;
return p;
}
export function bit2linear(channel) {
// http://www.brucelindbloom.com/Eqn_RGB_to_XYZ.html
// This converts rgb 8bit to rgb linear, lazy because the other algorithm is really really dumb
//return Math.pow(channel, 2.2);
// CSS Colors Level 4 says 0.03928, Bruce Lindbloom who cared to write all algos says 0.04045, used bruce because whynawt
return (channel <= 0.04045) ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4);
}
export function linear2bit(channel) {
// Using lazy conversion in the other direction as well
//return Math.pow(channel, 1/2.2);
// I'm honestly not sure about 0.0031308, I've only seen it referenced on Bruce Lindbloom's site
return (channel <= 0.0031308) ? channel * 12.92 : Math.pow(1.055 * channel, 1/2.4) - 0.055;
}
export const Color = {};
Color._canvas = null;
Color._context = null;
Color.CVDMatrix = {
protanope: [ // reds are greatly reduced (1% men)
0.0, 2.02344, -2.52581,
0.0, 1.0, 0.0,
0.0, 0.0, 1.0
],
deuteranope: [ // greens are greatly reduced (1% men)
1.0, 0.0, 0.0,
0.494207, 0.0, 1.24827,
0.0, 0.0, 1.0
],
tritanope: [ // blues are greatly reduced (0.003% population)
1.0, 0.0, 0.0,
0.0, 1.0, 0.0,
-0.395913, 0.801109, 0.0
]
}
const RGBAColor = Color.RGBA = function(r, g, b, a) {
this.r = r||0; this.g = g||0; this.b = b||0; this.a = a||0;
};
const HSVAColor = Color.HSVA = function(h, s, v, a) {
this.h = h||0; this.s = s||0; this.v = v||0; this.a = a||0;
};
const HSLAColor = Color.HSLA = function(h, s, l, a) {
this.h = h||0; this.s = s||0; this.l = l||0; this.a = a||0;
};
const XYZAColor = Color.XYZA = function(x, y, z, a) {
this.x = x||0; this.y = y||0; this.z = z||0; this.a = a||0;
};
const LUVAColor = Color.LUVA = function(l, u, v, a) {
this.l = l||0; this.u = u||0; this.v = v||0; this.a = a||0;
};
// RGBA Colors
RGBAColor.prototype.eq = function(rgb) {
return rgb.r === this.r && rgb.g === this.g && rgb.b === this.b && rgb.a === this.a;
}
RGBAColor.fromName = function(name) {
let context = Color._context;
if ( ! context ) {
const canvas = Color._canvas = document.createElement('canvas');
context = Color._context = canvas.getContext('2d');
}
context.clearRect(0,0,1,1);
context.fillStyle = name;
context.fillRect(0,0,1,1);
const data = context.getImageData(0,0,1,1);
if ( ! data || ! data.data || data.data.length !== 4 )
return null;
return new RGBAColor(data.data[0], data.data[1], data.data[2], data.data[3] / 255);
}
RGBAColor.fromCSS = function(rgb) {
if ( ! rgb )
return null;
rgb = rgb.trim();
if ( rgb.charAt(0) === '#' )
return RGBAColor.fromHex(rgb);
const match = /rgba?\( *(\d+%?) *, *(\d+%?) *, *(\d+%?) *(?:, *([\d.]+))?\)/i.exec(rgb);
if ( match ) {
let r = match[1],
g = match[2],
b = match[3],
a = match[4];
if ( r.charAt(r.length-1) === '%' )
r = 255 * (parseInt(r,10) / 100);
else
r = parseInt(r,10);
if ( g.charAt(g.length-1) === '%' )
g = 255 * (parseInt(g,10) / 100);
else
g = parseInt(g,10);
if ( b.charAt(b.length-1) === '%' )
b = 255 * (parseInt(b,10) / 100);
else
b = parseInt(b,10);
if ( a )
if ( a.charAt(a.length-1) === '%' )
a = parseInt(a,10) / 100;
else
a = parseFloat(a);
else
a = 1;
return new RGBAColor(
Math.min(Math.max(0, r), 255),
Math.min(Math.max(0, g), 255),
Math.min(Math.max(0, b), 255),
Math.min(Math.max(0, a), 1)
);
}
return RGBAColor.fromName(rgb);
}
RGBAColor.fromHex = function(code, alpha = 1) {
if ( code.charAt(0) === '#' )
code = code.slice(1);
if ( code.length === 3 )
code = `${code[0]}${code[0]}${code[1]}${code[1]}${code[2]}${code[2]}`;
else if ( code.length === 4 )
code = `${code[0]}${code[0]}${code[1]}${code[1]}${code[2]}${code[2]}${code[3]}${code[3]}`;
if ( code.length === 8 ) {
alpha = parseInt(code.slice(6), 16) / 255;
code = code.slice(0, 6);
} else if ( code.length !== 6 )
throw new Error('invalid hex code');
const raw = parseInt(code, 16);
return new RGBAColor(
(raw >> 16), // Red
(raw >> 8 & 0x00FF), // Green
(raw & 0x0000FF), // Blue,
alpha // Alpha
);
}
RGBAColor.fromHSVA = function(h, s, v, a) {
let r, g, b;
const i = Math.floor(h * 6),
f = h * 6 - i,
p = v * (1 - s),
q = v * (1 - f * s),
t = v * (1 - (1 - f) * s);
switch(i % 6) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
case 5: r = v; g = p; b = q;
}
return new RGBAColor(
Math.round(Math.min(Math.max(0, r*255), 255)),
Math.round(Math.min(Math.max(0, g*255), 255)),
Math.round(Math.min(Math.max(0, b*255), 255)),
a === undefined ? 1 : a
);
}
RGBAColor.fromXYZA = function(x, y, z, a) {
const R = 3.240479 * x - 1.537150 * y - 0.498535 * z,
G = -0.969256 * x + 1.875992 * y + 0.041556 * z,
B = 0.055648 * x - 0.204043 * y + 1.057311 * z;
// Make sure we end up in a real color space
return new RGBAColor(
Math.max(0, Math.min(255, 255 * linear2bit(R))),
Math.max(0, Math.min(255, 255 * linear2bit(G))),
Math.max(0, Math.min(255, 255 * linear2bit(B))),
a === undefined ? 1 : a
);
}
RGBAColor.fromHSLA = function(h, s, l, a) {
if ( s === 0 ) {
const v = Math.round(Math.min(Math.max(0, 255*l), 255));
return new RGBAColor(v, v, v, a === undefined ? 1 : a);
}
const q = l < 0.5 ? l * (1 + s) : l + s - l * s,
p = 2 * l - q;
return new RGBAColor(
Math.round(Math.min(Math.max(0, 255 * hue2rgb(p, q, h + 1/3)), 255)),
Math.round(Math.min(Math.max(0, 255 * hue2rgb(p, q, h)), 255)),
Math.round(Math.min(Math.max(0, 255 * hue2rgb(p, q, h - 1/3)), 255)),
a === undefined ? 1 : a
);
}
RGBAColor.prototype.toRGBA = function() { return this; }
RGBAColor.prototype.toHSVA = function() { return HSVAColor.fromRGBA(this.r, this.g, this.b, this.a); }
RGBAColor.prototype.toHSLA = function() { return HSLAColor.fromRGBA(this.r, this.g, this.b, this.a); }
RGBAColor.prototype.toCSS = function() { return `rgb${this.a !== 1 ? 'a' : ''}(${Math.round(this.r)},${Math.round(this.g)},${Math.round(this.b)}${this.a !== 1 ? `,${this.a}` : ''})`; }
RGBAColor.prototype.toXYZA = function() { return XYZAColor.fromRGBA(this.r, this.g, this.b, this.a); }
RGBAColor.prototype.toLUVA = function() { return this.toXYZA().toLUVA(); }
RGBAColor.prototype.toHex = function() {
const rgb = this.b | (this.g << 8) | (this.r << 16);
return `#${(0x1000000 + rgb).toString(16).slice(1)}`;
}
RGBAColor.prototype.get_Y = function() {
return ((0.299 * this.r) + ( 0.587 * this.g) + ( 0.114 * this.b)) / 255;
}
RGBAColor.prototype.luminance = function() {
const r = bit2linear(this.r / 255),
g = bit2linear(this.g / 255),
b = bit2linear(this.b / 255);
return (0.2126 * r) + (0.7152 * g) + (0.0722 * b);
}
RGBAColor.prototype.brighten = function(amount) {
amount = typeof amount === `number` ? amount : 1;
amount = Math.round(255 * (amount / 100));
return new RGBAColor(
Math.max(0, Math.min(255, this.r + amount)),
Math.max(0, Math.min(255, this.g + amount)),
Math.max(0, Math.min(255, this.b + amount)),
this.a
);
}
RGBAColor.prototype.daltonize = function(type) {
let cvd;
if ( typeof type === 'string' ) {
if ( Color.CVDMatrix.hasOwnProperty(type) )
cvd = Color.CVDMatrix[type];
else
throw new Error('Invalid CVD matrix');
} else
cvd = type;
const cvd_a = cvd[0], cvd_b = cvd[1], cvd_c = cvd[2],
cvd_d = cvd[3], cvd_e = cvd[4], cvd_f = cvd[5],
cvd_g = cvd[6], cvd_h = cvd[7], cvd_i = cvd[8];
//let L, M, S, l, m, s, R, G, B, RR, GG, BB;
// RGB to LMS matrix conversion
const L = (17.8824 * this.r) + (43.5161 * this.g) + (4.11935 * this.b),
M = (3.45565 * this.r) + (27.1554 * this.g) + (3.86714 * this.b),
S = (0.0299566 * this.r) + (0.184309 * this.g) + (1.46709 * this.b);
// Simulate color blindness
const l = (cvd_a * L) + (cvd_b * M) + (cvd_c * S),
m = (cvd_d * L) + (cvd_e * M) + (cvd_f * S),
s = (cvd_g * L) + (cvd_h * M) + (cvd_i * S);
// LMS to RGB matrix conversion
let R = (0.0809444479 * l) + (-0.130504409 * m) + (0.116721066 * s),
G = (-0.0102485335 * l) + (0.0540193266 * m) + (-0.113614708 * s),
B = (-0.000365296938 * l) + (-0.00412161469 * m) + (0.693511405 * s);
// Isolate invisible colors to color vision deficiency (calculate error matrix)
R = this.r - R;
G = this.g - G;
B = this.b - B;
// Shift colors towards visible spectrum (apply error modifications)
const RR = (0.0 * R) + (0.0 * G) + (0.0 * B),
GG = (0.7 * R) + (1.0 * G) + (0.0 * B),
BB = (0.7 * R) + (0.0 * G) + (1.0 * B);
// Add compensation to original values
R = Math.min(Math.max(0, RR + this.r), 255);
G = Math.min(Math.max(0, GG + this.g), 255);
B = Math.min(Math.max(0, BB + this.b), 255);
return new RGBAColor(R, G, B, this.a);
}
RGBAColor.prototype._r = function(r) { return new RGBAColor(r, this.g, this.b, this.a); }
RGBAColor.prototype._g = function(g) { return new RGBAColor(this.r, g, this.b, this.a); }
RGBAColor.prototype._b = function(b) { return new RGBAColor(this.r, this.g, b, this.a); }
RGBAColor.prototype._a = function(a) { return new RGBAColor(this.r, this.g, this.b, a); }
// HSL Colors
HSLAColor.prototype.eq = function(hsl) {
return hsl.h === this.h && hsl.s === this.s && hsl.l === this.l && hsl.a === this.a;
}
HSLAColor.fromRGBA = function(r, g, b, a) {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r,g,b),
min = Math.min(r,g,b),
l = Math.min(Math.max(0, (max+min) / 2), 1),
d = Math.min(Math.max(0, max - min), 1);
let h, s;
if ( d === 0 )
h = s = 0;
else {
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch(max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
}
h /= 6;
}
return new HSLAColor(h, s, l, a === undefined ? 1 : a);
}
HSLAColor.prototype.targetLuminance = function (target) {
let s = this.s,
min = 0,
max = 1;
s *= Math.pow(this.l > 0.5 ? -this.l : this.l - 1, 7) + 1;
let d = (max - min) / 2,
mid = min + d;
for (; d > 1/65536; d /= 2, mid = min + d) {
const luminance = RGBAColor.fromHSLA(this.h, s, mid, 1).luminance()
if (luminance > target) {
max = mid;
} else {
min = mid;
}
}
return new HSLAColor(this.h, s, mid, this.a);
}
HSLAColor.prototype.toRGBA = function() { return RGBAColor.fromHSLA(this.h, this.s, this.l, this.a); }
HSLAColor.prototype.toCSS = function() { return `hsl${this.a !== 1 ? 'a' : ''}(${Math.round(this.h*360)},${Math.round(this.s*100)}%,${Math.round(this.l*100)}%${this.a !== 1 ? `,${this.a}` : ''})`; }
HSLAColor.prototype.toHSLA = function() { return this; }
HSLAColor.prototype.toHSVA = function() { return this.toRGBA().toHSVA(); }
HSLAColor.prototype.toXYZA = function() { return this.toRGBA().toXYZA(); }
HSLAColor.prototype.toLUVA = function() { return this.toRGBA().toLUVA(); }
HSLAColor.prototype._h = function(h) { return new HSLAColor(h, this.s, this.l, this.a); }
HSLAColor.prototype._s = function(s) { return new HSLAColor(this.h, s, this.l, this.a); }
HSLAColor.prototype._l = function(l) { return new HSLAColor(this.h, this.s, l, this.a); }
HSLAColor.prototype._a = function(a) { return new HSLAColor(this.h, this.s, this.l, a); }
// HSV Colors
HSVAColor.prototype.eq = function(hsv) { return hsv.h === this.h && hsv.s === this.s && hsv.v === this.v && hsv.a === this.a; }
HSVAColor.fromRGBA = function(r, g, b, a) {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b),
min = Math.min(r, g, b),
d = Math.min(Math.max(0, max - min), 1),
s = max === 0 ? 0 : d / max,
v = max;
let h;
if ( d === 0 )
h = 0;
else {
switch(max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
case b:
h = (r - g) / d + 4;
}
h /= 6;
}
return new HSVAColor(h, s, v, a === undefined ? 1 : a);
}
HSVAColor.prototype.toRGBA = function() { return RGBAColor.fromHSVA(this.h, this.s, this.v, this.a); }
HSVAColor.prototype.toHSVA = function() { return this; }
HSVAColor.prototype.toHSLA = function() { return this.toRGBA().toHSLA(); }
HSVAColor.prototype.toXYZA = function() { return this.toRGBA().toXYZA(); }
HSVAColor.prototype.toLUVA = function() { return this.toRGBA().toLUVA(); }
HSVAColor.prototype._h = function(h) { return new HSVAColor(h, this.s, this.v, this.a); }
HSVAColor.prototype._s = function(s) { return new HSVAColor(this.h, s, this.v, this.a); }
HSVAColor.prototype._v = function(v) { return new HSVAColor(this.h, this.s, v, this.a); }
HSVAColor.prototype._a = function(a) { return new HSVAColor(this.h, this.s, this.v, a); }
// XYZ Colors
XYZAColor.prototype.eq = function(xyz) { return xyz.x === this.x && xyz.y === this.y && xyz.z === this.z; }
XYZAColor.fromRGBA = function(r, g, b, a) {
const R = bit2linear(r / 255),
G = bit2linear(g / 255),
B = bit2linear(b / 255);
return new XYZAColor(
0.412453 * R + 0.357580 * G + 0.180423 * B,
0.212671 * R + 0.715160 * G + 0.072169 * B,
0.019334 * R + 0.119193 * G + 0.950227 * B,
a === undefined ? 1 : a
);
}
XYZAColor.fromLUVA = function(l, u, v, alpha) {
const deltaGammaFactor = 1 / (XYZAColor.WHITE.x + 15 * XYZAColor.WHITE.y + 3 * XYZAColor.WHITE.z),
uDeltaGamma = 4 * XYZAColor.WHITE.x * deltaGammaFactor,
vDeltagamma = 9 * XYZAColor.WHITE.y * deltaGammaFactor;
// XYZAColor.EPSILON * XYZAColor.KAPPA = 8
const Y = (l > 8) ? Math.pow((l + 16) / 116, 3) : l / XYZAColor.KAPPA,
a = 1/3 * (((52 * l) / (u + 13 * l * uDeltaGamma)) - 1),
b = -5 * Y,
c = -1/3,
d = Y * (((39 * l) / (v + 13 * l * vDeltagamma)) - 5),
X = (d - b) / (a - c),
Z = X * a + b;
return new XYZAColor(X, Y, Z, alpha === undefined ? 1 : alpha);
}
XYZAColor.prototype.toRGBA = function() { return RGBAColor.fromXYZA(this.x, this.y, this.z, this.a); }
XYZAColor.prototype.toLUVA = function() { return LUVAColor.fromXYZA(this.x, this.y, this.z, this.a); }
XYZAColor.prototype.toHSLA = function() { return this.toRGBA().toHSLA(); }
XYZAColor.prototype.toHSVA = function() { return this.toRGBA().toHSVA(); }
XYZAColor.prototype.toXYZA = function() { return this; }
XYZAColor.prototype._x = function(x) { return new XYZAColor(x, this.y, this.z, this.a); }
XYZAColor.prototype._y = function(y) { return new XYZAColor(this.x, y, this.z, this.a); }
XYZAColor.prototype._z = function(z) { return new XYZAColor(this.x, this.y, z, this.a); }
XYZAColor.prototype._a = function(a) { return new XYZAColor(this.x, this.y, this.z, a); }
// LUV Colors
XYZAColor.EPSILON = Math.pow(6 / 29, 3);
XYZAColor.KAPPA = Math.pow(29 / 3, 3);
XYZAColor.WHITE = (new RGBAColor(255, 255, 255, 1)).toXYZA();
LUVAColor.prototype.eq = function(luv) { return luv.l === this.l && luv.u === this.u && luv.v === this.v; }
LUVAColor.fromXYZA = function(X, Y, Z, a) {
const deltaGammaFactor = 1 / (XYZAColor.WHITE.x + 15 * XYZAColor.WHITE.y + 3 * XYZAColor.WHITE.z),
uDeltaGamma = 4 * XYZAColor.WHITE.x * deltaGammaFactor,
vDeltagamma = 9 * XYZAColor.WHITE.y * deltaGammaFactor,
yGamma = Y / XYZAColor.WHITE.y;
let deltaDivider = (X + 15 * Y + 3 * Z);
if (deltaDivider === 0) {
deltaDivider = 1;
}
const deltaFactor = 1 / deltaDivider,
uDelta = 4 * X * deltaFactor,
vDelta = 9 * Y * deltaFactor,
L = (yGamma > XYZAColor.EPSILON) ? 116 * Math.pow(yGamma, 1/3) - 16 : XYZAColor.KAPPA * yGamma,
u = 13 * L * (uDelta - uDeltaGamma),
v = 13 * L * (vDelta - vDeltagamma);
return new LUVAColor(L, u, v, a === undefined ? 1 : a);
}
LUVAColor.prototype.toXYZA = function() { return XYZAColor.fromLUVA(this.l, this.u, this.v, this.a); }
LUVAColor.prototype.toRGBA = function() { return this.toXYZA().toRGBA(); }
LUVAColor.prototype.toHSLA = function() { return this.toXYZA().toHSLA(); }
LUVAColor.prototype.toHSVA = function() { return this.toXYZA().toHSVA(); }
LUVAColor.prototype.toLUVA = function() { return this; }
LUVAColor.prototype._l = function(l) { return new LUVAColor(l, this.u, this.v, this.a); }
LUVAColor.prototype._u = function(u) { return new LUVAColor(this.l, u, this.v, this.a); }
LUVAColor.prototype._v = function(v) { return new LUVAColor(this.l, this.u, v, this.a); }
LUVAColor.prototype._a = function(a) { return new LUVAColor(this.l, this.u, this.v, a); }
export class ColorAdjuster {
constructor(base = '#232323', mode = 0, contrast = 4.5) {
this._contrast = contrast;
this._base = base;
this._mode = mode;
this.rebuildContrast();
}
get contrast() { return this._contrast }
set contrast(val) { this._contrast = val; this.rebuildContrast() }
get base() { return this._base }
set base(val) { this._base = val; this.rebuildContrast() }
get dark() { return this._dark }
get mode() { return this._mode }
set mode(val) { this._mode = val; this.rebuildContrast() }
rebuildContrast() {
this._cache = new Map;
const base = RGBAColor.fromCSS(this._base),
lum = base.luminance();
const dark = this._dark = lum < 0.5;
if ( dark ) {
this._luv = new XYZAColor(
0,
(this._contrast * (base.toXYZA().y + 0.05) - 0.05),
0,
1
).toLUVA().l;
this._luma = this._contrast * (base.luminance() + 0.05) - 0.05;
} else {
this._luv = new XYZAColor(
0,
(base.toXYZA().y + 0.05) / this._contrast - 0.05,
0,
1
).toLUVA().l;
this._luma = (base.luminance() + 0.05) / this._contrast - 0.05;
}
}
process(color, throw_errors = false) {
if ( this._mode === -1 )
return '';
else if ( this._mode === 0 )
return color;
if ( color instanceof RGBAColor )
color = color.toCSS();
if ( ! color )
return null;
if ( this._cache.has(color) )
return this._cache.get(color);
let rgb;
try {
rgb = RGBAColor.fromCSS(color);
} catch(err) {
if ( throw_errors )
throw err;
return null;
}
if ( this._mode === 1 ) {
// HSL Luma
const luma = rgb.luminance();
if ( this._dark ? luma < this._luma : luma > this._luma )
rgb = rgb.toHSLA().targetLuminance(this._luma).toRGBA();
} else if ( this._mode === 2 ) {
// LUV
const luv = rgb.toLUVA();
if ( this._dark ? luv.l < this._luv : luv.l > this._luv )
rgb = luv._l(this._luv).toRGBA();
} else if ( this._mode === 3 ) {
// HSL Loop (aka BTTV Style)
if ( this._dark )
while ( rgb.get_Y() < 0.5 ) {
const hsl = rgb.toHSLA();
rgb = hsl._l(Math.min(Math.max(0, 0.1 + 0.9 * hsl.l), 1)).toRGBA();
}
else
while ( rgb.get_Y() >= 0.5 ) {
const hsl = rgb.toHSLA();
rgb = hsl._l(Math.min(Math.max(0, 0.9 * hsl.l), 1)).toRGBA();
}
} else if ( this._mode === 4 ) {
// RGB Loop
let i = 0;
if ( this._dark )
while ( rgb.luminance() < 0.15 && i++ < 127 )
rgb = rgb.brighten();
else
while ( rgb.luminance() > 0.3 && i++ < 127 )
rgb = rgb.brighten(-1);
}
const out = rgb.toCSS();
this._cache.set(color, out);
return out;
}
}

895
src/utilities/color.ts Normal file
View file

@ -0,0 +1,895 @@
'use strict';
export type CVDMatrix = [number, number, number, number, number, number, number, number, number];
export function hue2rgb(p: number, q: number, t: number) {
if ( t < 0 ) t += 1;
if ( t > 1 ) t -= 1;
if ( t < 1/6 )
return p + (q-p) * 6 * t;
if ( t < 1/2 )
return q;
if ( t < 2/3 )
return p + (q-p) * (2/3 - t) * 6;
return p;
}
export function bit2linear(channel: number) {
// http://www.brucelindbloom.com/Eqn_RGB_to_XYZ.html
// This converts rgb 8bit to rgb linear, lazy because the other algorithm is really really dumb
//return Math.pow(channel, 2.2);
// CSS Colors Level 4 says 0.03928, Bruce Lindbloom who cared to write all algos says 0.04045, used bruce because whynawt
return (channel <= 0.04045) ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4);
}
export function linear2bit(channel: number) {
// Using lazy conversion in the other direction as well
//return Math.pow(channel, 1/2.2);
// I'm honestly not sure about 0.0031308, I've only seen it referenced on Bruce Lindbloom's site
return (channel <= 0.0031308) ? channel * 12.92 : Math.pow(1.055 * channel, 1/2.4) - 0.055;
}
export interface BaseColor {
eq(other: BaseColor | null | undefined, ignoreAlpha: boolean): boolean;
toCSS(): string;
toHex(): string;
toRGBA(): RGBAColor;
toHSVA(): HSVAColor;
toHSLA(): HSLAColor;
toXYZA(): XYZAColor;
toLUVA(): LUVAColor;
}
class RGBAColor implements BaseColor {
readonly r: number;
readonly g: number;
readonly b: number;
readonly a: number;
constructor(r: number, g: number, b: number, a?: number) {
this.r = r || 0;
this.g = g || 0;
this.b = b || 0;
this.a = a || 0;
}
eq(other?: BaseColor | null, ignoreAlpha = false): boolean {
if ( other instanceof RGBAColor )
return this.r === other.r && this.g === other.g && this.b === other.b && (ignoreAlpha || this.a === other.a);
return other ? this.eq(other.toRGBA(), ignoreAlpha) : false;
}
// ========================================================================
// Updates
// ========================================================================
_r(r: number) { return new RGBAColor(r, this.g, this.b, this.a); }
_g(g: number) { return new RGBAColor(this.r, g, this.b, this.a); }
_b(b: number) { return new RGBAColor(this.r, this.g, b, this.a); }
_a(a: number) { return new RGBAColor(this.r, this.g, this.b, a); }
// ========================================================================
// Conversion: to RGBA
// ========================================================================
static fromName(name: string) {
const ctx = Color.getContext();
ctx.clearRect(0, 0, 1, 1);
ctx.fillStyle = name;
ctx.fillRect(0, 0, 1, 1);
const data = ctx.getImageData(0, 0, 1, 1);
if ( data?.data?.length !== 4 )
return null;
return new RGBAColor(data.data[0], data.data[1], data.data[2], data.data[3]);
}
static fromCSS(input: string) {
input = input && input.trim();
if ( ! input?.length )
return null;
if ( input.charAt(0) === '#' )
return RGBAColor.fromHex(input);
// fillStyle can handle rgba() inputs
/*const match = /rgba?\( *(\d+%?) *, *(\d+%?) *, *(\d+%?) *(?:, *([\d.]+))?\)/i.exec(input);
if ( match ) {
let r: number, g: number, b: number, a: number;
let rS = match[1],
gS = match[2],
bS = match[3],
aS = match[4];
if ( rS.charAt(rS.length-1) === '%' )
r = 255 * (parseInt(rS,10) / 100);
else
r = parseInt(rS,10);
if ( gS.charAt(gS.length-1) === '%' )
g = 255 * (parseInt(gS,10) / 100);
else
g = parseInt(gS,10);
if ( bS.charAt(bS.length-1) === '%' )
b = 255 * (parseInt(bS,10) / 100);
else
b = parseInt(bS,10);
if ( aS )
if ( aS.charAt(aS.length-1) === '%' )
a = parseInt(aS,10) / 100;
else
a = parseFloat(aS);
else
a = 1;
return new RGBAColorA(
Math.min(Math.max(0, r), 255),
Math.min(Math.max(0, g), 255),
Math.min(Math.max(0, b), 255),
Math.min(Math.max(0, a), 1)
);
}*/
return RGBAColor.fromName(input);
}
static fromHex(input: string) {
if ( input.charAt(0) === '#' )
input = input.slice(1);
let raw: number;
let alpha: number = 255;
if ( input.length === 4 ) {
alpha = parseInt(input[3], 16) * 17;
input = input.slice(0, 3);
} else if ( input.length === 8 ) {
alpha = parseInt(input.slice(6), 16);
input = input.slice(0, 6);
}
if ( input.length === 3 )
raw =
((parseInt(input[0], 16) * 17) << 16) +
((parseInt(input[1], 16) * 17) << 8) +
parseInt(input[2], 16) * 17;
else
raw = parseInt(input, 16);
return new RGBAColor(
(raw >> 16), // Red
(raw >> 8 & 0x00FF), // Green
(raw & 0xFF), // Blue
alpha / 255 // Alpha (scaled from 0 to 1)
);
}
static fromHSVA(h: number, s: number, v: number, a?: number) {
let r: number, g: number, b: number;
const i = Math.floor(h * 6),
f = h * 6 - i,
p = v * (1 - s),
q = v * (1 - f * s),
t = v * (1 - (1 - f) * s);
switch(i % 6) {
case 0: r = v; g = t; b = p; break;
case 1: r = q; g = v; b = p; break;
case 2: r = p; g = v; b = t; break;
case 3: r = p; g = q; b = v; break;
case 4: r = t; g = p; b = v; break;
default: // case 5:
r = v; g = p; b = q;
}
return new RGBAColor(
Math.round(Math.min(Math.max(0, r*255), 255)),
Math.round(Math.min(Math.max(0, g*255), 255)),
Math.round(Math.min(Math.max(0, b*255), 255)),
a === undefined ? 1 : a
);
}
static fromHSLA(h: number, s: number, l: number, a?: number) {
if ( s === 0 ) {
const v = Math.round(Math.min(Math.max(0, 255*l), 255));
return new RGBAColor(v, v, v, a === undefined ? 1 : a);
}
const q = l < 0.5 ? l * (1 + s) : l + s - l * s,
p = 2 * l - q;
return new RGBAColor(
Math.round(Math.min(Math.max(0, 255 * hue2rgb(p, q, h + 1/3)), 255)),
Math.round(Math.min(Math.max(0, 255 * hue2rgb(p, q, h)), 255)),
Math.round(Math.min(Math.max(0, 255 * hue2rgb(p, q, h - 1/3)), 255)),
a === undefined ? 1 : a
);
}
static fromXYZA(x: number, y: number, z: number, a?: number) {
const R = 3.240479 * x - 1.537150 * y - 0.498535 * z,
G = -0.969256 * x + 1.875992 * y + 0.041556 * z,
B = 0.055648 * x - 0.204043 * y + 1.057311 * z;
// Make sure we end up in a real color space
return new RGBAColor(
Math.max(0, Math.min(255, 255 * linear2bit(R))),
Math.max(0, Math.min(255, 255 * linear2bit(G))),
Math.max(0, Math.min(255, 255 * linear2bit(B))),
a === undefined ? 1 : a
);
}
// ========================================================================
// Conversion: from RGBA
// ========================================================================
// CSS
toCSS() {
if ( this.a !== 1 )
return `rgba(${this.r},${this.g},${this.b},${this.a})`;
return this.toHex();
}
toHex() {
const value = (this.r << 16) + (this.g << 8) + this.b;
return `#${value.toString(16).padStart(6, '0')}`;
}
// Color Spaces
toRGBA() { return this; }
toHSVA() { return HSVAColor.fromRGBA(this.r, this.g, this.b, this.a); }
toHSLA() { return HSLAColor.fromRGBA(this.r, this.g, this.b, this.a); }
toXYZA() { return XYZAColor.fromRGBA(this.r, this.g, this.b, this.a); }
toLUVA() { return this.toXYZA().toLUVA(); }
// ========================================================================
// Processing
// ========================================================================
get_Y() {
return ((0.299 * this.r) + ( 0.587 * this.g) + ( 0.114 * this.b)) / 255;
}
luminance() {
const r = bit2linear(this.r / 255),
g = bit2linear(this.g / 255),
b = bit2linear(this.b / 255);
return (0.2126 * r) + (0.7152 * g) + (0.0722 * b);
}
/** @deprecated This is a horrible function. */
brighten(amount?: number) {
amount = typeof amount === `number` ? amount : 1;
amount = Math.round(255 * (amount / 100));
return new RGBAColor(
Math.max(0, Math.min(255, this.r + amount)),
Math.max(0, Math.min(255, this.g + amount)),
Math.max(0, Math.min(255, this.b + amount)),
this.a
);
}
daltonize(type: string | CVDMatrix) {
let cvd: CVDMatrix;
if ( typeof type === 'string' ) {
if ( Color.CVDMatrix.hasOwnProperty(type) )
cvd = Color.CVDMatrix[type];
else
throw new Error('Invalid CVD matrix');
} else
cvd = type;
const cvd_a = cvd[0], cvd_b = cvd[1], cvd_c = cvd[2],
cvd_d = cvd[3], cvd_e = cvd[4], cvd_f = cvd[5],
cvd_g = cvd[6], cvd_h = cvd[7], cvd_i = cvd[8];
//let L, M, S, l, m, s, R, G, B, RR, GG, BB;
// RGB to LMS matrix conversion
const L = (17.8824 * this.r) + (43.5161 * this.g) + (4.11935 * this.b),
M = (3.45565 * this.r) + (27.1554 * this.g) + (3.86714 * this.b),
S = (0.0299566 * this.r) + (0.184309 * this.g) + (1.46709 * this.b);
// Simulate color blindness
const l = (cvd_a * L) + (cvd_b * M) + (cvd_c * S),
m = (cvd_d * L) + (cvd_e * M) + (cvd_f * S),
s = (cvd_g * L) + (cvd_h * M) + (cvd_i * S);
// LMS to RGB matrix conversion
let R = (0.0809444479 * l) + (-0.130504409 * m) + (0.116721066 * s),
G = (-0.0102485335 * l) + (0.0540193266 * m) + (-0.113614708 * s),
B = (-0.000365296938 * l) + (-0.00412161469 * m) + (0.693511405 * s);
// Isolate invisible colors to color vision deficiency (calculate error matrix)
R = this.r - R;
G = this.g - G;
B = this.b - B;
// Shift colors towards visible spectrum (apply error modifications)
const RR = (0.0 * R) + (0.0 * G) + (0.0 * B),
GG = (0.7 * R) + (1.0 * G) + (0.0 * B),
BB = (0.7 * R) + (0.0 * G) + (1.0 * B);
// Add compensation to original values
R = Math.min(Math.max(0, RR + this.r), 255);
G = Math.min(Math.max(0, GG + this.g), 255);
B = Math.min(Math.max(0, BB + this.b), 255);
return new RGBAColor(R, G, B, this.a);
}
}
class HSVAColor implements BaseColor {
readonly h: number;
readonly s: number;
readonly v: number;
readonly a: number;
constructor(h: number, s: number, v: number, a?: number) {
this.h = h || 0;
this.s = s || 0;
this.v = v || 0;
this.a = a || 0;
}
eq(other?: BaseColor | null, ignoreAlpha = false): boolean {
if ( other instanceof HSVAColor )
return this.h === other.h && this.s === other.s && this.v === other.v && (ignoreAlpha || this.a === other.a);
return other ? this.eq(other.toHSVA(), ignoreAlpha) : false;
}
// ========================================================================
// Updates
// ========================================================================
_h(h: number) { return new HSVAColor(h, this.s, this.v, this.a); }
_s(s: number) { return new HSVAColor(this.h, s, this.v, this.a); }
_v(v: number) { return new HSVAColor(this.h, this.s, v, this.a); }
_a(a: number) { return new HSVAColor(this.h, this.s, this.v, a); }
// ========================================================================
// Conversion: to HSVA
// ========================================================================
static fromRGBA(r: number, g: number, b: number, a?: number) {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r, g, b),
min = Math.min(r, g, b),
d = Math.min(Math.max(0, max - min), 1),
s = max === 0 ? 0 : d / max,
v = max;
let h;
if ( d === 0 )
h = 0;
else {
switch(max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
default: // case b:
h = (r - g) / d + 4;
}
h /= 6;
}
return new HSVAColor(
h,
s,
v,
a === undefined ? 1 : a
);
}
// ========================================================================
// Conversion: from HSVA
// ========================================================================
toCSS() { return this.toRGBA().toCSS(); }
toHex() { return this.toRGBA().toHex(); }
toRGBA() { return RGBAColor.fromHSVA(this.h, this.s, this.v, this.a); }
toHSVA() { return this; }
toHSLA() { return this.toRGBA().toHSLA(); }
toXYZA() { return this.toRGBA().toXYZA(); }
toLUVA() { return this.toRGBA().toLUVA(); }
}
class HSLAColor implements BaseColor {
readonly h: number;
readonly s: number;
readonly l: number;
readonly a: number;
constructor(h: number, s: number, l: number, a?: number) {
this.h = h || 0;
this.s = s || 0;
this.l = l || 0;
this.a = a || 0;
}
eq(other?: BaseColor | null, ignoreAlpha = false): boolean {
if ( other instanceof HSLAColor )
return this.h === other.h && this.s === other.s && this.l === other.l && (ignoreAlpha || this.a === other.a);
return other ? this.eq(other.toHSLA(), ignoreAlpha) : false;
}
// ========================================================================
// Updates
// ========================================================================
_h(h: number) { return new HSLAColor(h, this.s, this.l, this.a); }
_s(s: number) { return new HSLAColor(this.h, s, this.l, this.a); }
_l(l: number) { return new HSLAColor(this.h, this.s, l, this.a); }
_a(a: number) { return new HSLAColor(this.h, this.s, this.l, a); }
// ========================================================================
// Conversion: to HSLA
// ========================================================================
static fromRGBA(r: number, g: number, b: number, a?: number) {
r /= 255; g /= 255; b /= 255;
const max = Math.max(r,g,b),
min = Math.min(r,g,b),
l = Math.min(Math.max(0, (max+min) / 2), 1),
d = Math.min(Math.max(0, max - min), 1);
let h, s;
if ( d === 0 )
h = s = 0;
else {
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch(max) {
case r:
h = (g - b) / d + (g < b ? 6 : 0);
break;
case g:
h = (b - r) / d + 2;
break;
default: //case b:
h = (r - g) / d + 4;
}
h /= 6;
}
return new HSLAColor(h, s, l, a === undefined ? 1 : a);
}
// ========================================================================
// Conversion: from HSLA
// ========================================================================
toCSS() {
const a = this.a;
return `hsl${a !== 1 ? 'a' : ''}(${Math.round(this.h*360)},${Math.round(this.s*100)}%,${Math.round(this.l*100)}%${a !== 1 ? `,${this.a}` : ''})`;
}
toHex() { return this.toRGBA().toHex(); }
toRGBA() { return RGBAColor.fromHSLA(this.h, this.s, this.l, this.a); }
toHSLA() { return this; }
toHSVA() { return this.toRGBA().toHSVA(); }
toXYZA() { return this.toRGBA().toXYZA(); }
toLUVA() { return this.toRGBA().toLUVA(); }
// ========================================================================
// Processing
// ========================================================================
targetLuminance(target: number) {
let s = this.s,
min = 0,
max = 1;
s *= Math.pow(this.l > 0.5 ? -this.l : this.l - 1, 7) + 1;
let d = (max - min) / 2,
mid = min + d;
for (; d > 1/65536; d /= 2, mid = min + d) {
const luminance = RGBAColor.fromHSLA(this.h, s, mid, 1).luminance();
if (luminance > target) {
max = mid;
} else {
min = mid;
}
}
return new HSLAColor(this.h, s, mid, this.a);
}
}
class XYZAColor implements BaseColor {
readonly x: number;
readonly y: number;
readonly z: number;
readonly a: number;
constructor(x: number, y: number, z: number, a?: number) {
this.x = x || 0;
this.y = y || 0;
this.z = z || 0;
this.a = a || 0;
}
eq(other?: BaseColor, ignoreAlpha = false): boolean {
if ( other instanceof XYZAColor )
return this.x === other.x && this.y === other.y && this.z === other.z && (ignoreAlpha || this.a === other.a);
return other ? this.eq(other.toXYZA(), ignoreAlpha) : false;
}
// ========================================================================
// Updates
// ========================================================================
_x(x: number) { return new XYZAColor(x, this.y, this.z, this.a); }
_y(y: number) { return new XYZAColor(this.x, y, this.z, this.a); }
_z(z: number) { return new XYZAColor(this.x, this.y, z, this.a); }
_a(a: number) { return new XYZAColor(this.x, this.y, this.z, a); }
// ========================================================================
// Conversion: to XYZA
// ========================================================================
static EPSILON = Math.pow(6 / 29, 3);
static KAPPA = Math.pow(29 / 3, 3);
static WHITE = null as any; // Gotta do this late to avoid an error.
static fromRGBA(r: number, g: number, b: number, a?: number) {
const R = bit2linear(r / 255),
G = bit2linear(g / 255),
B = bit2linear(b / 255);
return new XYZAColor(
0.412453 * R + 0.357580 * G + 0.180423 * B,
0.212671 * R + 0.715160 * G + 0.072169 * B,
0.019334 * R + 0.119193 * G + 0.950227 * B,
a === undefined ? 1 : a
);
}
static fromLUVA(l: number, u: number, v: number, alpha?: number) {
const deltaGammaFactor = 1 / (XYZAColor.WHITE.x + 15 * XYZAColor.WHITE.y + 3 * XYZAColor.WHITE.z),
uDeltaGamma = 4 * XYZAColor.WHITE.x * deltaGammaFactor,
vDeltagamma = 9 * XYZAColor.WHITE.y * deltaGammaFactor;
// XYZAColor.EPSILON * XYZAColor.KAPPA = 8
const Y = (l > 8) ? Math.pow((l + 16) / 116, 3) : l / XYZAColor.KAPPA,
a = 1/3 * (((52 * l) / (u + 13 * l * uDeltaGamma)) - 1),
b = -5 * Y,
c = -1/3,
d = Y * (((39 * l) / (v + 13 * l * vDeltagamma)) - 5),
X = (d - b) / (a - c),
Z = X * a + b;
return new XYZAColor(X, Y, Z, alpha === undefined ? 1 : alpha);
}
// ========================================================================
// Conversion: from XYZA
// ========================================================================
toCSS() { return this.toRGBA().toCSS(); }
toHex() { return this.toRGBA().toHex(); }
toRGBA() { return RGBAColor.fromXYZA(this.x, this.y, this.z, this.a); }
toHSLA() { return this.toRGBA().toHSLA(); }
toHSVA() { return this.toRGBA().toHSVA(); }
toXYZA() { return this; }
toLUVA() { return LUVAColor.fromXYZA(this.x, this.y, this.z, this.a); }
}
// Assign this now that XYZAColor exists.
XYZAColor.WHITE = new RGBAColor(255,255,255,1).toXYZA();
class LUVAColor implements BaseColor {
readonly l: number;
readonly u: number;
readonly v: number;
readonly a: number;
constructor(l: number, u: number, v: number, a?: number) {
this.l = l || 0;
this.u = u || 0;
this.v = v || 0;
this.a = a || 0;
}
eq(other?: BaseColor | null, ignoreAlpha = false): boolean {
if ( other instanceof LUVAColor )
return this.l === other.l && this.u === other.u && this.v === other.v && (ignoreAlpha || this.a === other.a);
return other ? this.eq(other.toLUVA(), ignoreAlpha) : false;
}
// ========================================================================
// Updates
// ========================================================================
_l(l: number) { return new LUVAColor(l, this.u, this.v, this.a); }
_u(u: number) { return new LUVAColor(this.l, u, this.v, this.a); }
_v(v: number) { return new LUVAColor(this.l, this.u, v, this.a); }
_a(a: number) { return new LUVAColor(this.l, this.u, this.v, a); }
// ========================================================================
// Conversion: to LUVA
// ========================================================================
static fromXYZA(X: number, Y: number, Z: number, a?: number) {
const deltaGammaFactor = 1 / (XYZAColor.WHITE.x + 15 * XYZAColor.WHITE.y + 3 * XYZAColor.WHITE.z),
uDeltaGamma = 4 * XYZAColor.WHITE.x * deltaGammaFactor,
vDeltagamma = 9 * XYZAColor.WHITE.y * deltaGammaFactor,
yGamma = Y / XYZAColor.WHITE.y;
let deltaDivider = (X + 15 * Y + 3 * Z);
if (deltaDivider === 0) {
deltaDivider = 1;
}
const deltaFactor = 1 / deltaDivider,
uDelta = 4 * X * deltaFactor,
vDelta = 9 * Y * deltaFactor,
L = (yGamma > XYZAColor.EPSILON) ? 116 * Math.pow(yGamma, 1/3) - 16 : XYZAColor.KAPPA * yGamma,
u = 13 * L * (uDelta - uDeltaGamma),
v = 13 * L * (vDelta - vDeltagamma);
return new LUVAColor(L, u, v, a === undefined ? 1 : a);
}
// ========================================================================
// Conversion: from LUVA
// ========================================================================
toCSS() { return this.toRGBA().toCSS(); }
toHex() { return this.toRGBA().toHex(); }
toRGBA() { return this.toXYZA().toRGBA(); }
toHSLA() { return this.toRGBA().toHSLA(); }
toHSVA() { return this.toRGBA().toHSVA(); }
toXYZA() { return XYZAColor.fromLUVA(this.l, this.u, this.v, this.a); }
toLUVA() { return this;}
}
type ColorType = {
_canvas?: HTMLCanvasElement;
_context?: CanvasRenderingContext2D;
getCanvas(): HTMLCanvasElement;
getContext(): CanvasRenderingContext2D
CVDMatrix: Record<string, CVDMatrix>;
RGBA: typeof RGBAColor;
HSVA: typeof HSVAColor;
HSLA: typeof HSLAColor;
XYZA: typeof XYZAColor;
LUVA: typeof LUVAColor;
fromCSS(input: string): RGBAColor | null;
}
export const Color: ColorType = {
CVDMatrix: {
protanope: [ // reds are greatly reduced (1% men)
0.0, 2.02344, -2.52581,
0.0, 1.0, 0.0,
0.0, 0.0, 1.0
],
deuteranope: [ // greens are greatly reduced (1% men)
1.0, 0.0, 0.0,
0.494207, 0.0, 1.24827,
0.0, 0.0, 1.0
],
tritanope: [ // blues are greatly reduced (0.003% population)
1.0, 0.0, 0.0,
0.0, 1.0, 0.0,
-0.395913, 0.801109, 0.0
]
},
getCanvas() {
if ( ! Color._canvas )
Color._canvas = document.createElement('canvas');
return Color._canvas;
},
getContext: () => {
if ( ! Color._context )
Color._context = Color.getCanvas().getContext('2d') as CanvasRenderingContext2D;
return Color._context;
},
RGBA: RGBAColor,
HSVA: HSVAColor,
HSLA: HSLAColor,
XYZA: XYZAColor,
LUVA: LUVAColor,
fromCSS(input: string) {
return RGBAColor.fromCSS(input);
},
};
export class ColorAdjuster {
private _base: string;
private _contrast: number;
private _mode: number;
private _dark: boolean = false;
private _cache: Map<string, string> = new Map;
private _luv: number = 0;
private _luma: number = 0;
constructor(base = '#232323', mode = 0, contrast = 4.5) {
this._contrast = contrast;
this._base = base;
this._mode = mode;
this.rebuildContrast();
}
get contrast() { return this._contrast }
set contrast(val) { this._contrast = val; this.rebuildContrast() }
get base() { return this._base }
set base(val) { this._base = val; this.rebuildContrast() }
get dark() { return this._dark }
get mode() { return this._mode }
set mode(val) { this._mode = val; this.rebuildContrast() }
rebuildContrast() {
this._cache = new Map;
const base = RGBAColor.fromCSS(this._base);
if ( ! base )
throw new Error('Invalid base color');
const lum = base.luminance(),
dark = this._dark = lum < 0.5;
if ( dark ) {
this._luv = new XYZAColor(
0,
(this._contrast * (base.toXYZA().y + 0.05) - 0.05),
0,
1
).toLUVA().l;
this._luma = this._contrast * (base.luminance() + 0.05) - 0.05;
} else {
this._luv = new XYZAColor(
0,
(base.toXYZA().y + 0.05) / this._contrast - 0.05,
0,
1
).toLUVA().l;
this._luma = (base.luminance() + 0.05) / this._contrast - 0.05;
}
}
process(color: BaseColor | string, throw_errors = false) {
if ( this._mode === -1 )
return '';
else if ( this._mode === 0 )
return color;
if ( typeof color !== 'string' )
color = color.toCSS();
if ( ! color )
return null;
if ( this._cache.has(color) )
return this._cache.get(color);
let rgb;
try {
rgb = RGBAColor.fromCSS(color);
} catch(err) {
if ( throw_errors )
throw err;
return null;
}
if ( ! rgb )
return null;
if ( this._mode === 1 ) {
// HSL Luma
const luma = rgb.luminance();
if ( this._dark ? luma < this._luma : luma > this._luma )
rgb = rgb.toHSLA().targetLuminance(this._luma).toRGBA();
} else if ( this._mode === 2 ) {
// LUV
const luv = rgb.toLUVA();
if ( this._dark ? luv.l < this._luv : luv.l > this._luv )
rgb = luv._l(this._luv).toRGBA();
} else if ( this._mode === 3 ) {
// HSL Loop (aka BTTV Style)
if ( this._dark )
while ( rgb.get_Y() < 0.5 ) {
const hsl = rgb.toHSLA();
rgb = hsl._l(Math.min(Math.max(0, 0.1 + 0.9 * hsl.l), 1)).toRGBA();
}
else
while ( rgb.get_Y() >= 0.5 ) {
const hsl = rgb.toHSLA();
rgb = hsl._l(Math.min(Math.max(0, 0.9 * hsl.l), 1)).toRGBA();
}
} else if ( this._mode === 4 ) {
// RGB Loop
let i = 0;
if ( this._dark )
while ( rgb.luminance() < 0.15 && i++ < 127 )
rgb = rgb.brighten();
else
while ( rgb.luminance() > 0.3 && i++ < 127 )
rgb = rgb.brighten(-1);
}
const out = rgb.toCSS();
this._cache.set(color, out);
return out;
}
}

View file

@ -6,11 +6,28 @@
// ============================================================================
import {EventEmitter} from 'utilities/events';
import Module from 'utilities/module';
import Module, { GenericModule } from 'utilities/module';
export default class Elemental extends Module {
constructor(...args) {
super(...args);
declare module 'utilities/types' {
interface ModuleMap {
'site.elemental': Elemental;
}
}
export default class Elemental extends Module<'site.elemental'> {
private _wrappers: Map<string, ElementalWrapper<any>>;
private _observer: MutationObserver | null;
private _watching: Set<ElementalWrapper<any>>;
private _live_watching: ElementalWrapper<any>[] | null;
private _route?: string | null;
private _timer?: number | null;
private _timeout?: ReturnType<typeof setTimeout> | null;
private _clean_all?: ReturnType<typeof requestAnimationFrame> | null;
constructor(name?: string, parent?: GenericModule) {
super(name, parent);
this._pruneLive = this._pruneLive.bind(this);
@ -21,27 +38,35 @@ export default class Elemental extends Module {
this._live_watching = null;
}
/** @internal */
onDisable() {
this._stopWatching();
}
define(key, selector, routes, opts = null, limit = 0, timeout = 5000, remove = true) {
define<TElement extends HTMLElement = HTMLElement>(
key: string,
selector: string,
routes?: string[] | false | null,
opts: MutationObserverInit | null = null,
limit = 0,
timeout = 5000,
remove = true
) {
if ( this._wrappers.has(key) )
return this._wrappers.get(key);
return this._wrappers.get(key) as ElementalWrapper<TElement>;
if ( ! selector || typeof selector !== 'string' || ! selector.length )
throw new Error('cannot find definition and no selector provided');
const wrapper = new ElementalWrapper(key, selector, routes, opts, limit, timeout, remove, this);
const wrapper = new ElementalWrapper<TElement>(key, selector, routes, opts, limit, timeout, remove, this);
this._wrappers.set(key, wrapper);
return wrapper;
}
route(route) {
route(route: string | null) {
this._route = route;
this._timer = Date.now();
this._updateLiveWatching();
@ -76,24 +101,27 @@ export default class Elemental extends Module {
}
_isActive(watcher, now) {
private _isActive(watcher: ElementalWrapper<any>, now: number) {
if ( watcher.routes === false )
return false;
if ( this._route && watcher.routes.length && ! watcher.routes.includes(this._route) )
return false;
if ( watcher.timeout > 0 && (now - this._timer) > watcher.timeout )
if ( watcher.timeout > 0 && (now - (this._timer as number)) > watcher.timeout )
return false;
return true;
}
_updateLiveWatching() {
private _updateLiveWatching() {
if ( this._timeout ) {
clearTimeout(this._timeout);
this._timeout = null;
}
const lw = this._live_watching = [],
const lw: ElementalWrapper<any>[] = this._live_watching = [],
now = Date.now();
let min_timeout = Number.POSITIVE_INFINITY;
@ -115,16 +143,17 @@ export default class Elemental extends Module {
this._startWatching();
}
_pruneLive() {
private _pruneLive() {
this._updateLiveWatching();
}
_checkWatchers(muts) {
private _checkWatchers(muts: Node[]) {
if ( this._live_watching )
for(const watcher of this._live_watching)
watcher.checkElements(muts);
watcher.checkElements(muts as Element[]);
}
_startWatching() {
private _startWatching() {
if ( ! this._observer && this._live_watching && this._live_watching.length ) {
this.log.info('Installing MutationObserver.');
@ -136,7 +165,7 @@ export default class Elemental extends Module {
}
}
_stopWatching() {
private _stopWatching() {
if ( this._observer ) {
this.log.info('Stopping MutationObserver.');
this._observer.disconnect();
@ -152,7 +181,7 @@ export default class Elemental extends Module {
}
listen(inst, ensure_live = true) {
listen(inst: ElementalWrapper<any>, ensure_live = true) {
if ( this._watching.has(inst) )
return;
@ -163,7 +192,7 @@ export default class Elemental extends Module {
this._updateLiveWatching();
}
unlisten(inst) {
unlisten(inst: ElementalWrapper<any>) {
if ( ! this._watching.has(inst) )
return;
@ -175,20 +204,64 @@ export default class Elemental extends Module {
let elemental_id = 0;
export class ElementalWrapper extends EventEmitter {
constructor(name, selector, routes, opts, limit, timeout, remove, elemental) {
type ElementalParam = `_ffz$elemental$${number}`;
type ElementalRemoveParam = `_ffz$elemental_remove$${number}`;
declare global {
interface HTMLElement {
[key: ElementalParam]: MutationObserver | null;
[key: ElementalRemoveParam]: MutationObserver | null;
}
}
type ElementalWrapperEvents<TElement extends HTMLElement> = {
mount: [element: TElement];
unmount: [element: TElement];
mutate: [element: TElement, mutations: MutationRecord[]];
}
export class ElementalWrapper<
TElement extends HTMLElement = HTMLElement
> extends EventEmitter<ElementalWrapperEvents<TElement>> {
readonly id: number;
readonly name: string;
readonly selector: string;
readonly routes: string[] | false;
readonly opts: MutationObserverInit | null;
readonly limit: number;
readonly timeout: number;
readonly check_removal: boolean;
count: number;
readonly instances: Set<TElement>;
readonly elemental: Elemental;
readonly param: ElementalParam;
readonly remove_param: ElementalRemoveParam;
private _stimer?: ReturnType<typeof setTimeout> | null;
constructor(
name: string,
selector: string,
routes: string[] | false | undefined | null,
opts: MutationObserverInit | null,
limit: number,
timeout: number,
remove: boolean,
elemental: Elemental
) {
super();
this.id = elemental_id++;
this.param = `_ffz$elemental$${this.id}`;
this.remove_param = `_ffz$elemental_remove$${this.id}`;
this.mut_param = `_ffz$elemental_mutating${this.id}`;
this._schedule = this._schedule.bind(this);
this.name = name;
this.selector = selector;
this.routes = routes || [];
this.routes = routes ?? [];
this.opts = opts;
this.limit = limit;
this.timeout = timeout;
@ -199,7 +272,6 @@ export class ElementalWrapper extends EventEmitter {
this.count = 0;
this.instances = new Set;
this.observers = new Map;
this.elemental = elemental;
this.check();
@ -224,6 +296,7 @@ export class ElementalWrapper extends EventEmitter {
}
_schedule() {
if ( this._stimer )
clearTimeout(this._stimer);
this._stimer = null;
@ -234,18 +307,19 @@ export class ElementalWrapper extends EventEmitter {
}
check() {
const matches = document.querySelectorAll(this.selector);
for(const el of matches)
const matches = document.querySelectorAll<TElement>(this.selector);
// TypeScript is stupid and thinks NodeListOf<Element> doesn't have an iterator
for(const el of matches as unknown as Iterable<TElement>)
this.add(el);
}
checkElements(els) {
checkElements(els: Iterable<Element>) {
if ( this.atLimit )
return this.schedule();
for(const el of els) {
const matches = el.querySelectorAll(this.selector);
for(const match of matches)
const matches = el.querySelectorAll<TElement>(this.selector);
for(const match of matches as unknown as Iterable<TElement>)
this.add(match);
if ( this.atLimit )
@ -264,66 +338,66 @@ export class ElementalWrapper extends EventEmitter {
return Array.from(this.instances);
}
each(fn) {
each(fn: (element: TElement) => void) {
for(const el of this.instances)
fn(el);
}
add(el) {
if ( this.instances.has(el) )
add(element: TElement) {
if ( this.instances.has(element) )
return;
this.instances.add(el);
this.instances.add(element);
this.count++;
if ( this.check_removal ) {
if ( this.check_removal && element.parentNode ) {
const remove_check = new MutationObserver(() => {
requestAnimationFrame(() => {
if ( ! document.contains(el) )
this.remove(el);
if ( ! document.contains(element) )
this.remove(element);
});
});
remove_check.observe(el.parentNode, {childList: true});
el[this.remove_param] = remove_check;
remove_check.observe(element.parentNode, {childList: true});
(element as HTMLElement)[this.remove_param] = remove_check;
}
if ( this.opts ) {
const observer = new MutationObserver(muts => {
if ( ! document.contains(el) ) {
this.remove(el);
if ( ! document.contains(element) ) {
this.remove(element);
} else if ( ! this.__running.size )
this.emit('mutate', el, muts);
this.emit('mutate', element, muts);
});
observer.observe(el, this.opts);
el[this.param] = observer;
observer.observe(element, this.opts);
(element as HTMLElement)[this.param] = observer;
}
this.schedule();
this.emit('mount', el);
this.emit('mount', element);
}
remove(el) {
const observer = el[this.param];
remove(element: TElement) {
const observer = element[this.param];
if ( observer ) {
observer.disconnect();
el[this.param] = null;
(element as HTMLElement)[this.param] = null;
}
const remove_check = el[this.remove_param];
const remove_check = element[this.remove_param];
if ( remove_check ) {
remove_check.disconnect();
el[this.remove_param] = null;
(element as HTMLElement)[this.remove_param] = null;
}
if ( ! this.instances.has(el) )
if ( ! this.instances.has(element) )
return;
this.instances.delete(el);
this.instances.delete(element);
this.count--;
this.schedule();
this.emit('unmount', el);
this.emit('unmount', element);
}
}

View file

@ -5,13 +5,56 @@
// ============================================================================
import {parse, tokensToRegExp, tokensToFunction} from 'path-to-regexp';
import Module from 'utilities/module';
import {has, deep_equals} from 'utilities/object';
import Module, { GenericModule } from 'utilities/module';
import {has, deep_equals, sleep} from 'utilities/object';
import type Fine from './fine';
import type { OptionalPromise } from 'utilities/types';
declare module 'utilities/types' {
interface ModuleEventMap {
'site.router': FineRouterEvents;
}
interface ModuleMap {
'site.router': FineRouter;
}
}
export default class FineRouter extends Module {
constructor(...args) {
super(...args);
type FineRouterEvents = {
':updated-route-names': [];
':updated-routes': [];
':route': [route: RouteInfo | null, match: unknown];
};
export type RouteInfo = {
name: string;
domain: string | null;
};
export default class FineRouter extends Module<'site.router', FineRouterEvents> {
// Dependencies
fine: Fine = null as any;
// Storage
routes: Record<string, RouteInfo>;
route_names: Record<string, string>;
private __routes: RouteInfo[];
// State
current: RouteInfo | null;
current_name: string | null;
current_state: unknown | null;
match: unknown | null;
location: unknown | null;
constructor(name?: string, parent?: GenericModule) {
super(name, parent);
this.inject('..fine');
this.__routes = [];
@ -20,16 +63,18 @@ export default class FineRouter extends Module {
this.route_names = {};
this.current = null;
this.current_name = null;
this.current_state = null;
this.match = null;
this.location = null;
}
onEnable() {
/** @internal */
onEnable(): OptionalPromise<void> {
const thing = this.fine.searchTree(null, n => n.props && n.props.history),
history = this.history = thing && thing.props && thing.props.history;
if ( ! history )
return new Promise(r => setTimeout(r, 50)).then(() => this.onEnable());
return sleep(50).then(() => this.onEnable());
history.listen(location => {
if ( this.enabled )
@ -43,7 +88,7 @@ export default class FineRouter extends Module {
this.history.push(this.getURL(route, data, opts), state);
}
_navigateTo(location) {
private _navigateTo(location) {
this.log.debug('New Location', location);
const host = window.location.host,
path = location.pathname,
@ -66,7 +111,7 @@ export default class FineRouter extends Module {
this._pickRoute();
}
_pickRoute() {
private _pickRoute() {
const path = this.location,
host = this.domain;
@ -85,7 +130,6 @@ export default class FineRouter extends Module {
this.current_name = route.name;
this.match = match;
this.emit(':route', route, match);
this.emit(`:route:${route.name}`, ...match);
return;
}
}
@ -117,7 +161,7 @@ export default class FineRouter extends Module {
return r.url(data, opts);
}
getRoute(name) {
getRoute(name: string) {
return this.routes[name];
}
@ -132,7 +176,7 @@ export default class FineRouter extends Module {
return this.route_names;
}
getRouteName(route) {
getRouteName(route: string) {
if ( ! this.route_names[route] )
this.route_names[route] = route
.replace(/^dash-([a-z])/, (_, letter) =>
@ -143,7 +187,7 @@ export default class FineRouter extends Module {
return this.route_names[route];
}
routeName(route, name, process = true) {
routeName(route: string | Record<string, string>, name?: string, process: boolean = true) {
if ( typeof route === 'object' ) {
for(const key in route)
if ( has(route, key) )
@ -154,11 +198,13 @@ export default class FineRouter extends Module {
return;
}
if ( name ) {
this.route_names[route] = name;
if ( process )
this.emit(':updated-route-names');
}
}
route(name, path, domain = null, state_fn = null, process = true) {
if ( typeof name === 'object' ) {
@ -173,6 +219,7 @@ export default class FineRouter extends Module {
this._pickRoute();
this.emit(':updated-routes');
}
return;
}

View file

@ -1,813 +0,0 @@
'use strict';
// ============================================================================
// Fine Lib
// It controls React.
// ============================================================================
import {EventEmitter} from 'utilities/events';
import Module from 'utilities/module';
export default class Fine extends Module {
constructor(...args) {
super(...args);
this._wrappers = new Map;
this._known_classes = new Map;
this._observer = null;
this._waiting = [];
this._live_waiting = null;
}
async onEnable(tries=0) {
// TODO: Move awaitElement to utilities/dom
if ( ! this.root_element )
this.root_element = await this.parent.awaitElement(this.selector || 'body #root');
if ( ! this.root_element || ! this.root_element._reactRootContainer ) {
if ( tries > 500 )
throw new Error('Unable to find React after 25 seconds');
this.root_element = null;
return new Promise(r => setTimeout(r, 50)).then(() => this.onEnable(tries+1));
}
this.react_root = this.root_element._reactRootContainer;
if ( this.react_root._internalRoot && this.react_root._internalRoot.current )
this.react_root = this.react_root._internalRoot;
this.react = this.react_root.current.child;
}
onDisable() {
this.react_root = this.root_element = this.react = this.accessor = null;
}
static findAccessor(element) {
for(const key in element)
if ( key.startsWith('__reactInternalInstance$') )
return key;
}
// ========================================================================
// Low Level Accessors
// ========================================================================
getReactInstance(element) {
if ( ! this.accessor )
this.accessor = Fine.findAccessor(element);
if ( ! this.accessor )
return;
return element[this.accessor] || (element._reactRootContainer && element._reactRootContainer._internalRoot && element._reactRootContainer._internalRoot.current) || (element._reactRootContainer && element._reactRootContainer.current);
}
getOwner(instance) {
if ( instance._reactInternalFiber )
instance = instance._reactInternalFiber;
else if ( instance instanceof Node )
instance = this.getReactInstance(instance);
if ( ! instance )
return null;
return instance.return;
}
getParentNode(instance, max_depth = 100, traverse_roots = false) {
/*if ( instance._reactInternalFiber )
instance = instance._reactInternalFiber;
else if ( instance instanceof Node )
instance = this.getReactInstance(instance);
while( instance )
if ( instance.stateNode instanceof Node )
return instance.stateNode
else
instance = instance.parent;*/
return this.searchParent(instance, n => n instanceof Node, max_depth, 0, traverse_roots);
}
getChildNode(instance, max_depth = 100, traverse_roots = false) {
/*if ( instance._reactInternalFiber )
instance = instance._reactInternalFiber;
else if ( instance instanceof Node )
instance = this.getReactInstance(instance);
while( instance )
if ( instance.stateNode instanceof Node )
return instance.stateNode
else {
max_depth--;
if ( max_depth < 0 )
return null;
instance = instance.child;
}*/
return this.searchTree(instance, n => n instanceof Node, max_depth, 0, traverse_roots);
}
getHostNode(instance, max_depth = 100) {
return this.getChildNode(instance, max_depth);
}
getParent(instance) {
return this.getOwner(instance);
}
getFirstChild(node) {
if ( node._reactInternalFiber )
node = node._reactInternalFiber;
else if ( node instanceof Node )
node = this.getReactInstance(node);
if ( ! node )
return null;
return node.child;
}
getChildren(node) {
if ( node._reactInternalFiber )
node = node._reactInternalFiber;
else if ( node instanceof Node )
node = this.getReactInstance(node);
if ( ! node )
return null;
const children = [];
let child = node.child;
while(child) {
children.push(child);
child = child.sibling;
}
return children;
}
searchParent(node, criteria, max_depth=15, depth=0, traverse_roots = true) {
if ( node._reactInternalFiber )
node = node._reactInternalFiber;
else if ( node instanceof Node )
node = this.getReactInstance(node);
if ( ! node || node._ffz_no_scan || depth > max_depth )
return null;
if ( typeof criteria === 'string' ) {
const wrapper = this._wrappers.get(criteria);
if ( ! wrapper )
throw new Error('invalid critera');
if ( ! wrapper._class )
return null;
criteria = n => n && n.constructor === wrapper._class;
}
const inst = node.stateNode;
if ( inst && criteria(inst) )
return inst;
if ( node.return ) {
const result = this.searchParent(node.return, criteria, max_depth, depth+1, traverse_roots);
if ( result )
return result;
}
// Stupid code for traversing up into another React root.
if ( traverse_roots && inst && inst.containerInfo ) {
const parent = inst.containerInfo.parentElement,
parent_node = parent && this.getReactInstance(parent);
if ( parent_node ) {
const result = this.searchParent(parent_node, criteria, max_depth, depth+1, traverse_roots);
if ( result )
return result;
}
}
return null;
}
searchNode(node, criteria, max_depth=15, depth=0, traverse_roots = true) {
if ( ! node )
node = this.react;
else if ( node._reactInternalFiber )
node = node._reactInternalFiber;
else if ( node instanceof Node )
node = this.getReactInstance(node);
if ( ! node || node._ffz_no_scan || depth > max_depth )
return null;
if ( typeof criteria === 'string' ) {
const wrapper = this._wrappers.get(criteria);
if ( ! wrapper )
throw new Error('invalid critera');
if ( ! wrapper._class )
return null;
criteria = n => n && n.constructor === wrapper._class;
}
if ( node && criteria(node) )
return node;
if ( node.child ) {
let child = node.child;
while(child) {
const result = this.searchNode(child, criteria, max_depth, depth+1, traverse_roots);
if ( result )
return result;
child = child.sibling;
}
}
const inst = node.stateNode;
if ( traverse_roots && inst && inst.props && inst.props.root ) {
const root = inst.props.root._reactRootContainer;
if ( root ) {
let child = root._internalRoot && root._internalRoot.current || root.current;
while(child) {
const result = this.searchNode(child, criteria, max_depth, depth+1, traverse_roots);
if ( result )
return result;
child = child.sibling;
}
}
}
}
searchTree(node, criteria, max_depth=15, depth=0, traverse_roots = true, multi = false) {
if ( ! node )
node = this.react;
else if ( node._reactInternalFiber )
node = node._reactInternalFiber;
else if ( node instanceof Node )
node = this.getReactInstance(node);
if ( multi ) {
if ( !(multi instanceof Set) )
multi = new Set;
}
if ( multi && ! (multi instanceof Set) )
multi = new Set;
if ( ! node || node._ffz_no_scan || depth > max_depth )
return multi ? multi : null;
if ( typeof criteria === 'string' ) {
const wrapper = this._wrappers.get(criteria);
if ( ! wrapper )
throw new Error('invalid critera');
if ( ! wrapper._class )
return multi ? multi : null;
criteria = n => n && n.constructor === wrapper._class;
}
const inst = node.stateNode;
if ( inst && criteria(inst, node) ) {
if ( multi )
multi.add(inst);
else
return inst;
}
if ( node.child ) {
let child = node.child;
while(child) {
const result = this.searchTree(child, criteria, max_depth, depth+1, traverse_roots, multi);
if ( result && ! multi )
return result;
child = child.sibling;
}
}
if ( traverse_roots && inst && inst.props && inst.props.root ) {
const root = inst.props.root._reactRootContainer;
if ( root ) {
let child = root._internalRoot && root._internalRoot.current || root.current;
while(child) {
const result = this.searchTree(child, criteria, max_depth, depth+1, traverse_roots, multi);
if ( result && ! multi )
return result;
child = child.sibling;
}
}
}
if ( multi )
return multi;
}
findAllMatching(node, criteria, max_depth=15, single_class = true, parents=false, depth=0, traverse_roots=true) {
const matches = new Set;
let crit = n => ! matches.has(n) && criteria(n);
while(true) {
const match = parents ?
this.searchParent(node, crit, max_depth, depth, traverse_roots) :
this.searchTree(node, crit, max_depth, depth, traverse_roots);
if ( ! match )
break;
if ( single_class && ! matches.size ) {
const klass = match.constructor;
crit = n => ! matches.has(n) && (n instanceof klass) && criteria(n);
}
matches.add(match);
}
return matches;
}
searchAll(node, criterias, max_depth=15, depth=0, data, traverse_roots = true) {
if ( ! node )
node = this.react;
else if ( node._reactInternalFiber )
node = node._reactInternalFiber;
else if ( node instanceof Node )
node = this.getReactInstance(node);
if ( ! data )
data = {
seen: new Set,
classes: criterias.map(() => null),
out: criterias.map(() => ({
cls: null, instances: new Set, depth: null
})),
max_depth: depth
};
if ( ! node || node._ffz_no_scan || depth > max_depth )
return data.out;
if ( depth > data.max_depth )
data.max_depth = depth;
const inst = node.stateNode;
if ( inst ) {
const cls = inst.constructor,
idx = data.classes.indexOf(cls);
if ( idx !== -1 )
data.out[idx].instances.add(inst);
else if ( ! data.seen.has(cls) ) {
let i = criterias.length;
while(i-- > 0)
if ( criterias[i](inst) ) {
data.classes[i] = data.out[i].cls = cls;
data.out[i].instances.add(inst);
data.out[i].depth = depth;
break;
}
data.seen.add(cls);
}
}
let child = node.child;
while(child) {
this.searchAll(child, criterias, max_depth, depth+1, data, traverse_roots);
child = child.sibling;
}
if ( traverse_roots && inst && inst.props && inst.props.root ) {
const root = inst.props.root._reactRootContainer;
if ( root ) {
let child = root._internalRoot && root._internalRoot.current || root.current;
while(child) {
this.searchAll(child, criterias, max_depth, depth+1, data, traverse_roots);
child = child.sibling;
}
}
}
return data.out;
}
// ========================================================================
// Class Wrapping
// ========================================================================
route(route) {
this._route = route;
this._updateLiveWaiting();
}
_updateLiveWaiting() {
const lw = this._live_waiting = [],
crt = this._waiting_crit = [],
route = this._route;
if ( this._waiting )
for(const waiter of this._waiting)
if ( ! route || ! waiter.routes.length || waiter.routes.includes(route) ) {
lw.push(waiter);
crt.push(waiter.criteria);
}
if ( ! this._live_waiting.length )
this._stopWaiting();
else if ( ! this._waiting_timer )
this._startWaiting();
}
define(key, criteria, routes) {
if ( this._wrappers.has(key) )
return this._wrappers.get(key);
if ( ! criteria )
throw new Error('cannot find definition and no criteria provided');
const wrapper = new FineWrapper(key, criteria, routes, this);
this._wrappers.set(key, wrapper);
const data = this.searchAll(this.react, [criteria], 1000)[0];
if ( data.cls ) {
wrapper._set(data.cls, data.instances);
this._known_classes.set(data.cls, wrapper);
} else if ( routes !== false ) {
this._waiting.push(wrapper);
this._updateLiveWaiting();
}
return wrapper;
}
wrap(key, cls) {
let wrapper;
if ( this._wrappers.has(key) )
wrapper = this._wrappers.get(key);
else {
wrapper = new FineWrapper(key, null, undefined, this);
this._wrappers.set(key, wrapper);
}
if ( cls ) {
if ( wrapper._class || wrapper.criteria )
throw new Error('tried setting a class on an already initialized FineWrapper');
wrapper._set(cls, new Set);
this._known_classes.set(cls, wrapper);
}
return wrapper;
}
_checkWaiters(nodes) {
if ( ! this._live_waiting )
return;
if ( ! Array.isArray(nodes) )
nodes = [nodes];
for(let node of nodes) {
if ( ! node )
node = this.react;
else if ( node._reactInternalFiber )
node = node._reactInternalFiber;
else if ( node instanceof Node )
node = this.getReactInstance(node);
if ( ! node || node._ffz_no_scan || ! this._live_waiting.length )
continue;
const data = this.searchAll(node, this._waiting_crit, 1000);
let i = data.length;
while(i-- > 0) {
if ( data[i].cls ) {
const d = data[i],
w = this._live_waiting.splice(i, 1)[0];
this._waiting_crit.splice(i, 1);
const idx = this._waiting.indexOf(w);
if ( idx !== -1 )
this._waiting.splice(idx, 1);
this.log.debug(`Found class for "${w.name}" at depth ${d.depth}`);
w._set(d.cls, d.instances);
}
}
}
if ( ! this._live_waiting.length )
this._stopWaiting();
}
_startWaiting() {
this.log.info('Installing MutationObserver.');
this._waiting_timer = setInterval(() => this._checkWaiters(), 500);
if ( ! this._observer )
this._observer = new MutationObserver(mutations =>
this._checkWaiters(mutations.map(x => x.target))
);
this._observer.observe(document.body, {
childList: true,
subtree: true
});
}
_stopWaiting() {
this.log.info('Stopping MutationObserver.');
if ( this._observer )
this._observer.disconnect();
if ( this._waiting_timer )
clearInterval(this._waiting_timer);
this._live_waiting = null;
this._waiting_crit = null;
this._waiting_timer = null;
}
}
const EVENTS = {
'will-mount': 'UNSAFE_componentWillMount',
mount: 'componentDidMount',
render: 'render',
'receive-props': 'UNSAFE_componentWillReceiveProps',
'should-update': 'shouldComponentUpdate',
'will-update': 'UNSAFE_componentWillUpdate',
update: 'componentDidUpdate',
unmount: 'componentWillUnmount'
}
export class FineWrapper extends EventEmitter {
constructor(name, criteria, routes, fine) {
super();
this.name = name;
this.criteria = criteria;
this.fine = fine;
this.instances = new Set;
this.routes = routes || [];
this._wrapped = new Set;
this._class = null;
}
get first() {
return this.toArray()[0];
}
toArray() {
return Array.from(this.instances);
}
check(node = null, max_depth = 1000) {
if ( this._class )
return;
const instances = this.fine.findAllMatching(node, this.criteria, max_depth);
if ( instances.size ) {
const insts = Array.from(instances);
this._set(insts[0].constructor, insts);
}
}
ready(fn) {
if ( this._class )
fn(this._class, this.instances);
else
this.once('set', fn);
}
each(fn) {
for(const inst of this.instances)
fn(inst);
}
updateInstances(node = null, max_depth = 1000) {
if ( ! this._class )
return;
const instances = this.fine.findAllMatching(node, n => n.constructor === this._class, max_depth);
for(const inst of instances) {
inst._ffz_mounted = true;
this.instances.add(inst);
}
}
_set(cls, instances) {
if ( this._class )
throw new Error('already have a class');
this._class = cls;
this._wrapped.add('UNSAFE_componentWillMount');
this._wrapped.add('componentWillUnmount');
cls._ffz_wrapper = this;
const t = this,
_instances = this.instances,
proto = cls.prototype,
o_mount = proto.UNSAFE_componentWillMount,
o_unmount = proto.componentWillUnmount,
mount = proto.UNSAFE_componentWillMount = o_mount ?
function(...args) {
this._ffz_mounted = true;
_instances.add(this);
t.emit('will-mount', this, ...args);
return o_mount.apply(this, args);
} :
function(...args) {
this._ffz_mounted = true;
_instances.add(this);
t.emit('will-mount', this, ...args);
},
unmount = proto.componentWillUnmount = o_unmount ?
function(...args) {
t.emit('unmount', this, ...args);
_instances.delete(this);
this._ffz_mounted = false;
return o_unmount.apply(this, args);
} :
function(...args) {
t.emit('unmount', this, ...args);
_instances.delete(this);
this._ffz_mounted = false;
};
this.__UNSAFE_componentWillMount = [mount, o_mount];
this.__componentWillUnmount = [unmount, o_unmount];
for(const event of this.events())
this._maybeWrap(event);
if ( instances )
for(const inst of instances) {
// How do we check mounted state for fibers?
// Just assume they're mounted for now I guess.
inst._ffz_mounted = true;
_instances.add(inst);
}
this.emit('set', cls, _instances);
}
_add(instances) {
for(const inst of instances)
this.instances.add(inst);
}
_maybeWrap(event) {
const key = EVENTS[event];
if ( ! this._class || ! key || this._wrapped.has(key) )
return;
this._wrap(event, key);
}
_wrap(event, key) {
if ( this._wrapped.has(key) )
return;
const t = this,
proto = this._class.prototype,
original = proto[key],
fn = proto[key] = original ?
function(...args) {
if ( ! this._ffz_mounted ) {
this._ffz_mounted = true;
t.instances.add(this);
t.emit('late-mount', this);
}
t.emit(event, this, ...args);
return original.apply(this, args);
} :
key === 'shouldComponentUpdate' ?
function(...args) {
if ( ! this._ffz_mounted ) {
this._ffz_mounted = true;
t.instances.add(this);
t.emit('late-mount', this);
}
t.emit(event, this, ...args);
return true;
}
:
function(...args) {
if ( ! this._ffz_mounted ) {
this._ffz_mounted = true;
t.instances.add(this);
t.emit('late-mount', this);
}
t.emit(event, this, ...args);
};
this[`__${key}`] = [fn, original];
}
_unwrap(key) {
if ( ! this._wrapped.has(key) )
return;
const k = `__${key}`,
proto = this._class.prototype,
[fn, original] = this[k];
if ( proto[key] !== fn )
throw new Error('unable to unwrap -- prototype modified');
proto[key] = original;
this[k] = undefined;
this._wrapped.delete(key);
}
forceUpdate() {
for(const inst of this.instances)
try {
inst.forceUpdate();
this.fine.emit('site:dom-update', this.name, inst);
} catch(err) {
this.fine.log.capture(err, {
tags: {
fine_wrapper: this.name
}
});
this.fine.log.error(`An error occurred when calling forceUpdate on an instance of ${this.name}`, err);
}
}
on(event, fn, ctx) {
this._maybeWrap(event);
return super.on(event, fn, ctx);
}
prependOn(event, fn, ctx) {
this._maybeWrap(event);
return super.prependOn(event, fn, ctx);
}
once(event, fn, ctx) {
this._maybeWrap(event);
return super.once(event, fn, ctx);
}
prependOnce(event, fn, ctx) {
this._maybeWrap(event);
return super.prependOnce(event, fn, ctx);
}
many(event, ttl, fn, ctx) {
this._maybeWrap(event);
return super.many(event, ttl, fn, ctx);
}
prependMany(event, ttl, fn, ctx) {
this._maybeWrap(event);
return super.prependMany(event, ttl, fn, ctx);
}
waitFor(event) {
this._maybeWrap(event);
return super.waitFor(event);
}
}

1032
src/utilities/compat/fine.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,75 @@
// You might be wondering why we're homebrewing React types when we could just
// reply on @types/react.
//
// It's simple. TypeScript is obtuse and refuses to NOT use @types/react if
// the package is installed. That breaks our own JSX use, and so we can't use
// those types.
declare global {
interface Node {
[key: ReactAccessor]: ReactNode | undefined;
_reactRootContainer?: ReactRoot;
_ffz_no_scan?: boolean;
}
}
export type ReactAccessor = `__reactInternalInstance$${string}`;
export type ReactRoot = {
_internalRoot?: ReactRoot;
current: ReactNode | null;
};
export type ReactNode = {
alternate: ReactNode | null;
child: ReactNode | null;
return: ReactNode | null;
sibling: ReactNode | null;
stateNode: ReactStateNode | Node | null;
};
export type ReactStateNode<
TProps extends {} = {},
TState extends {} = {},
TSnapshot extends {} = {}
> = {
// FFZ Helpers
_ffz_no_scan?: boolean;
_ffz_mounted?: boolean;
// Access to the internal node.
_reactInternalFiber: ReactNode | null;
// Stuff
props: TProps;
state: TState | null;
// Lifecycle Methods
componentDidMount?(): void;
componentDidUpdate?(prevProps: TProps, prevState: TState, snapshot: TSnapshot | null): void;
componentWillUnmount?(): void;
shouldComponentUpdate?(nextProps: TProps, nextState: TState): boolean;
getSnapshotBeforeUpdate?(prevProps: TProps, prevState: TState): TSnapshot | null;
componentDidCatch?(error: any, info: any): void;
/** @deprecated Will be removed in React 17 */
UNSAFE_componentWillMount?(): void;
/** @deprecated Will be removed in React 17 */
UNSAFE_componentWillReceiveProps?(nextProps: TProps): void;
/** @deprecated Will be removed in React 17 */
UNSAFE_componentWillUpdate?(nextProps: TProps, nextState: TState): void;
setState(
updater: Partial<TState> | ((state: TState, props: TProps) => Partial<TState>),
callback?: () => void
): void;
// TODO: Implement proper return type.
render(): any;
forceUpdate(callback?: () => void): void;
};

View file

@ -5,11 +5,88 @@
// It controls Twitch PubSub.
// ============================================================================
import Module from 'utilities/module';
import Module, { GenericModule } from 'utilities/module';
import { FFZEvent } from 'utilities/events';
export class PubSubEvent extends FFZEvent {
constructor(data) {
declare global {
interface Window {
__twitch_pubsub_client: TwitchPubSubClient | null | undefined;
//__Twitch__pubsubInstances: any;
}
}
declare module 'utilities/types' {
interface ModuleEventMap {
'site.subpump': SubpumpEvents;
}
interface ModuleMap {
'site.subpump': Subpump;
}
}
/**
* This is a rough map of the parts of Twitch's PubSub client that we
* care about for our purposes.
*/
type TwitchPubSubClient = {
connection: {
removeAllListeners(topic: string): void;
addListener(topic: string, listener: (event: any) => void): void;
}
topicListeners?: {
_events?: Record<string, any>;
}
onMessage(event: TwitchPubSubMessageEvent): any;
listen(opts: { topic: string }, listener: (event: any) => void, ...args: any[]): void;
unlisten(topic: string, listener: (event: any) => void, ...args: any[]): void;
ffz_original_listen?: (opts: { topic: string }, listener: (event: any) => void, ...args: any[]) => void;
ffz_original_unlisten?: (topic: string, listener: (event: any) => void, ...args: any[]) => void;
simulateMessage(topic: string, message: string): void;
}
type TwitchPubSubMessageEvent = {
type: string;
data?: {
topic: string;
message: string;
}
}
export type RawPubSubEventData = {
prefix: string;
trail: string;
event: {
topic: string;
message: string;
}
}
export class PubSubEvent<TMessage = any> extends FFZEvent<RawPubSubEventData> {
_obj?: TMessage;
_changed: boolean;
// This is assigned in super()
prefix: string = null as any;
trail: string = null as any;
event: {
topic: string;
message: string;
} = null as any;
constructor(data: RawPubSubEventData) {
super(data);
this._obj = undefined;
@ -24,31 +101,45 @@ export class PubSubEvent extends FFZEvent {
return this.event.topic;
}
get message() {
get message(): TMessage {
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) {
this._obj = val;
this._changed = true;
}
}
export default class Subpump extends Module {
constructor(...args) {
super(...args);
export type SubpumpEvents = {
/** 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;
}
onEnable(tries = 0) {
const instance = window.__twitch_pubsub_client,
instances = window.__Twitch__pubsubInstances;
const instance = window.__twitch_pubsub_client;
//instances = window.__Twitch__pubsubInstances;
if ( ! instance && ! instances ) {
if ( ! instance ) { //} && ! instances ) {
if ( tries > 10 )
this.log.warn('Unable to find PubSub.');
else
@ -62,6 +153,7 @@ export default class Subpump extends Module {
this.hookClient(instance);
}
/*
else if ( instances ) {
for(const val of Object.values(instances))
if ( val?._client ) {
@ -74,12 +166,13 @@ export default class Subpump extends Module {
this.hookOldClient(val._client);
}
}
*/
if ( ! this.instance )
this.log.warn('Unable to find a PubSub instance.');
}
handleMessage(msg) {
handleMessage(msg: TwitchPubSubMessageEvent) {
try {
if ( msg.type === 'MESSAGE' && msg.data?.topic ) {
const raw_topic = msg.data.topic,
@ -108,11 +201,11 @@ export default class Subpump extends Module {
return false;
}
hookClient(client) {
hookClient(client: TwitchPubSubClient) {
const t = this,
orig_message = client.onMessage;
this.is_old = false;
//this.is_old = false;
client.connection.removeAllListeners('message');
@ -153,66 +246,24 @@ export default class Subpump extends Module {
}
}
hookOldClient(client) {
const t = this,
orig_message = client._onMessage;
this.is_old = true;
client._unbindPrimary(client._primarySocket);
client._onMessage = function(e) {
if ( t.handleMessage(e) )
return;
return orig_message.call(this, e);
};
client._bindPrimary(client._primarySocket);
const listener = client._listens,
orig_on = listener.on,
orig_off = listener.off;
listener.on = function(topic, fn, ctx) {
const has_topic = !! listener._events?.[topic],
out = orig_on.call(this, topic, fn, ctx);
if ( ! has_topic )
t.emit(':add-topic', topic)
return out;
}
listener.off = function(topic, fn) {
const has_topic = !! listener._events?.[topic],
out = orig_off.call(this, topic, fn);
if ( has_topic && ! listener._events?.[topic] )
t.emit(':remove-topic', topic);
return out;
}
}
inject(topic, message) {
simulateMessage(topic: string, message: any) {
if ( ! this.instance )
throw new Error('No PubSub instance available');
if ( this.is_old ) {
/*if ( this.is_old ) {
const listens = this.instance._client?._listens;
listens._trigger(topic, JSON.stringify(message));
} else {
} else {*/
this.instance.simulateMessage(topic, JSON.stringify(message));
}
//}
}
get topics() {
let events;
if ( this.is_old )
const events = this.instance?.topicListeners?._events;
/*if ( this.is_old )
events = this.instance?._client?._listens._events;
else
events = this.instance?.topicListeners?._events;
events = this.instance?.topicListeners?._events;*/
if ( ! events )
return [];

View file

@ -1,10 +1,14 @@
'use strict';
import {make_enum} from 'utilities/object';
declare global {
let __extension__: string | undefined;
}
/** Whether or not FrankerFaceZ was loaded from a development server. */
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__;
/** The base URL of the FrankerFaceZ CDN. */
export const SERVER = DEBUG ? 'https://localhost:8000' : 'https://cdn.frankerfacez.com';
let path = `${SERVER}/script`;
@ -15,21 +19,31 @@ if ( EXTENSION ) {
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 CLIENT_ID = 'a3bc9znoz6vi8ozsoca0inlcr4fcvkl';
/** The base URL of the FrankerFaceZ API. */
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';
/** The base URL of the FrankerFaceZ staging CDN. */
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 SENTRY_ID = 'https://1c3b56f127254d3ba1bd1d6ad8805eee@sentry.io/1186960';
//export const SENTRY_ID = 'https://07ded545d3224ca59825daee02dc7745@catbag.frankerfacez.com:444/2';
/** The base URL provided to Sentry integrations for automatic error reporting. */
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 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],
16: [22,27],
17: [20,27],
@ -90,6 +104,7 @@ export const WEIRD_EMOTE_SIZES = {
1906: [24,30]
};
/** A list of hotkey combinations that are not valid for one reason or another. */
export const BAD_HOTKEYS = [
'f',
'space',
@ -103,7 +118,7 @@ export const BAD_HOTKEYS = [
'alt+x'
];
/** A list of setting keys that, when changed, cause chat messages to re-render. */
export const RERENDER_SETTINGS = [
'chat.name-format',
'chat.me-style',
@ -120,15 +135,23 @@ export const RERENDER_SETTINGS = [
'chat.bits.cheer-notice',
'chat.filtering.hidden-tokens',
'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 = [
'chat.badges.style',
'chat.badges.hidden',
'chat.badges.custom-mod',
'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 = [
'chat.emotes.enabled',
'chat.emotes.2x',
@ -151,9 +174,12 @@ export const UPDATE_TOKEN_SETTINGS = [
'__filter:block-terms',
'__filter:block-users',
'__filter:block-badges'
];
] as const;
/**
* A list of keycodes for specific keys, for use with
* {@link KeyboardEvent} events.
*/
export const KEYS = {
Tab: 9,
Enter: 13,
@ -172,13 +198,16 @@ export const KEYS = {
ArrowDown: 40,
Meta: 91,
Context: 93
};
} as const;
export const TWITCH_EMOTE_BASE = '//static-cdn.jtvnw.net/emoticons/v1/';
/** The base URL for Twitch emote images. */
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',
'\\&lt\\;\\]': '<]',
@ -203,9 +232,11 @@ export const KNOWN_CODES = {
'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 REPLACEMENTS = {
/** A map of specific Twitch emotes that should use replacement images. */
export const REPLACEMENTS: Record<string, string> = {
15: '15-JKanStyle.png',
16: '16-OptimizePrime.png',
17: '17-StoneLightning.png',
@ -221,7 +252,10 @@ export const REPLACEMENTS = {
36: '36-PJSalt.png'
};
/**
* A map of WebSocket servers for the original FrankerFaceZ socket
* system. @deprecated
*/
export const WS_CLUSTERS = {
Production: [
['wss://catbag.frankerfacez.com/', 0.25],
@ -275,29 +309,56 @@ export const PUBSUB_CLUSTERS = {
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_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-' : '';
/** 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];
export const TWITCH_POINTS_SETS = [300238151];
export const TWITCH_PRIME_SETS = [457, 793, 19151, 19194];
/** A list of Twitch emote sets that are for emotes unlocked with channel points. */
export const TWITCH_POINTS_SETS = [300238151] as const;
export const EmoteTypes = make_enum(
'Unknown',
'Prime',
'Turbo',
'LimitedTime',
'ChannelPoints',
'Unavailable',
'Subscription',
'BitsTier',
'Global',
'TwoFactor',
'Follower'
);
/** A list of Twitch emote sets that are for Twitch Prime subscribers. */
export const TWITCH_PRIME_SETS = [457, 793, 19151, 19194] as const;
/** An enum of all possible Twitch emote types. */
export enum EmoteTypes {
/** What kind of weird emote are you dragging in here */
Unknown,
/** Emotes unlocked via Twitch Prime */
Prime,
/** Emotes unlocked via Twitch Turbo */
Turbo,
/** 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;
}
}

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

@ -0,0 +1,224 @@
'use strict';
// ============================================================================
// CSS Tweaks
// Tweak some CSS
// ============================================================================
import Module, { GenericModule } from 'utilities/module';
import {ManagedStyle} from 'utilities/dom';
import {has, once} from 'utilities/object';
declare module "utilities/types" {
interface ModuleMap {
'site.css_tweaks': CSSTweaks;
}
}
/**
* CSS Tweaks is a somewhat generic module for handling FrankerFaceZ's CSS
* injection. It can load and unload specific blocks of CSS, as well as
* automatically generate rules to hide specific elements based on their
* selectors.
*
* Generally, this module is loaded by the current site module and is
* available as `site.css_tweaks`.
*
* @noInheritDoc
*/
export default class CSSTweaks<TPath extends string = 'site.css_tweaks'> extends Module<TPath> {
/** Stores CSS rules used with the {@link toggleHide} method. */
rules: Record<string, string> = {};
/** Stores CSS chunks loaded by the provided loader, and used with the {@link toggle} method. */
chunks: Record<string, string> = {};
private _toggle_state: Record<string, boolean> = {};
private _chunk_loader?: __WebpackModuleApi.RequireContext | null;
private _chunks_loaded: boolean = false;
private _style?: ManagedStyle;
/** @internal */
constructor(name?: string, parent?: GenericModule) {
super(name, parent);
this._loadChunks = once(this._loadChunks);
}
/** Whether or not chunks have been loaded using the {@link loader}. */
get chunks_loaded() {
return this._chunks_loaded;
}
/** An optional require context that can be used for loading arbitrary, named CSS chunks. */
get loader() {
return this._chunk_loader;
}
set loader(value: __WebpackModuleApi.RequireContext | null | undefined) {
if ( value === this._chunk_loader )
return;
this._chunks_loaded = false;
this._chunk_loader = value;
}
/** The {@link ManagedStyle} instance used internally by this {@link CSSTweaks} instance. */
get style() {
if ( ! this._style )
this._style = new ManagedStyle;
return this._style;
}
/**
* If {@link force} is not set, this toggles a specific element hiding rule,
* enabling it if it was not previously enabled and vice versa. If force is
* provided, it will either enable or disable the specific element hiding
* rule based on the boolean value of {@link force}.
*
* @param key The key for the element hiding rule in {@link rules}.
* @param force Optional. The desired state.
* @throws If the provided {@link key} is not within {@link rules}.
*/
toggleHide(key: string, force?: boolean) {
const k = `hide--${key}`;
force = force != null ? !! force : ! this._toggle_state[k];
if ( this._toggle_state[k] === force )
return;
this._toggle_state[k] = force;
if ( ! force ) {
if ( this._style )
this._style.delete(k);
return;
}
if ( ! has(this.rules, key) )
throw new Error(`unknown rule "${key}" for toggleHide`);
this.style.set(k, `${this.rules[key]}{display:none !important}`);
}
/**
* If {@link force} is not set, this toggles a specific CSS chunk,
* enabling it if it was not previously enabled and vice versa. If force is
* provide, it will either enable or disable the specific CSS chunk based
* on the boolean value of {@link force}.
*
* @param key The key for the CSS block in {@link chunks}.
* @param force Optional. The desired state.
*/
toggle(key: string, force?: boolean) {
force = force != null ? !! force : ! this._toggle_state[key];
if ( this._toggle_state[key] == force )
return;
this._toggle_state[key] = force;
this._apply(key);
}
/**
* Actually perform the update for {@link toggle}. This method may
* have to wait and then call itself again if the chunks have not yet
* been loaded.
*
* @param key The key for the CSS block to toggle.
*/
private _apply(key: string): void {
const val = this._toggle_state[key];
if ( ! val ) {
if ( this._style )
this._style.delete(key);
return;
}
if ( this.style.has(key) )
return;
else if ( ! this._chunks_loaded ) {
this._loadChunks().then(() => this._apply(key));
} else if ( ! has(this.chunks, key) ) {
this.log.warn(`Unknown chunk name "${key}" for toggle()`);
} else
this.style.set(key, this.chunks[key]);
}
/**
* Include an arbitrary string of CSS using this CSSTweak instance's
* {@link ManagedStyle} instance. This will override any existing
* CSS block using the same key.
*
* @see {@link ManagedStyle.set}
* @param key The key for the CSS block.
* @param value The text content of the CSS block.
*/
set(key: string, value: string) { return this.style.set(key, value); }
/**
* Delete a CSS block from this CSSTweak instance's {@link ManagedStyle}
* instance. This can be used to delete managed blocks including
* those set by {@link toggle}, {@link toggleHide}, and
* {@link setVariable} to please use caution.
*
* @see {@link ManagedStyle.delete}
* @param key The key to be deleted.
*/
delete(key: string) { this._style && this._style.delete(key) }
/**
* Set a CSS variable. The variable's name will be prefixed with `ffz-`
* so, for example, if {@link key} is `"link-color"` then the resulting
* CSS variable will be `--ffz-link-color` and can be used with
* `var(--ffz-link-color)`.
*
* @param key The key for the variable.
* @param value The value of the variable.
* @param scope The scope this variable should be set on. Defaults
* to `"body"`.
*/
setVariable(key: string, value: string, scope: string = 'body') {
this.style.set(`var--${key}`, `${scope}{--ffz-${key}:${value};}`);
}
/**
* Delete a CSS variable.
* @param key The key for the variable
*/
deleteVariable(key: string) {
if ( this._style )
this._style.delete(`var--${key}`);
}
/**
* This method is used internally to load CSS chunks from the
* provided {@link loader} instance.
*/
private async _loadChunks() {
if ( this._chunks_loaded )
return;
if ( ! this._chunk_loader ) {
this._chunks_loaded = true;
return;
}
const promises = [];
for(const key of this._chunk_loader.keys()) {
const k = key.slice(2, key.length - (key.endsWith('.scss') ? 5 : 4));
promises.push(this._chunk_loader(key).then((data: any) => {
if ( typeof data?.default === 'string' )
this.chunks[k] = data.default;
}));
}
await Promise.all(promises);
this._chunks_loaded = true;
}
}

View file

@ -0,0 +1,13 @@
query FFZ_StreamFlags($ids: [ID!], $logins: [String!]) {
users(ids: $ids, logins: $logins) {
id
login
stream {
id
contentClassificationLabels {
id
localizedName
}
}
}
}

View file

@ -16,8 +16,47 @@ export function getDialogNextZ() {
}
export class Dialog extends EventEmitter {
constructor(element, options = {}) {
export type DialogSelectors = {
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();
this.selectors = {
@ -128,7 +167,7 @@ export class Dialog extends EventEmitter {
);
}
toggleVisible(event) {
toggleVisible(event?: MouseEvent) {
if ( event && event.button !== 0 )
return;
@ -174,14 +213,18 @@ export class Dialog extends EventEmitter {
this._element = el;
}
if ( ! this._element )
return;
if ( this.prepend )
container.insertBefore(this._element, container.firstChild);
else
container.appendChild(this._element);
this.emit('show');
}
toggleSize(event) {
toggleSize(event?: MouseEvent) {
if ( ! this._visible || event && event.button !== 0 || ! this._element )
return;
@ -212,10 +255,5 @@ export class Dialog extends EventEmitter {
}
}
Dialog.EXCLUSIVE = Site.DIALOG_EXCLUSIVE;
Dialog.MAXIMIZED = Site.DIALOG_MAXIMIZED;
Dialog.SELECTOR = Site.DIALOG_SELECTOR;
// This is necessary for add-ons for now.
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 {
el: HTMLElement | null;
cb: ((event: MouseEvent) => void) | null;
_fn: ((event: MouseEvent) => void) | null;
constructor(element: HTMLElement, callback: ((event: MouseEvent) => void)) {
this.el = element;
this.cb = callback;
this._fn = this.handleClick.bind(this);
document.documentElement.addEventListener('click', this.handleClick);
}
destroy() {
if ( this._fn )
document.documentElement.removeEventListener('click', this._fn);
this.cb = this.el = this._fn = null;
}
handleClick(event: MouseEvent) {
if ( this.cb && this.el && ! this.el.contains(event.target as Node) )
this.cb(event);
}
}
/**
* Take an object that can be expressed as JSON and return a string of HTML
* that can be used to display the object with highlighting and formatting.
*
* TODO: Rewrite this method to not use raw HTML.
*
* @deprecated You should not depend on this method, as its signature is expected to change.
*
* @param object The object to be formatted
* @param pretty Whether or not to use indentation when rendering the object
* @param depth The current rendering depth
* @param max_depth The maximum depth to render, defaults to 30.
* @returns A string of HTML.
*/
export function highlightJson(object: any, pretty = false, depth = 1, max_depth = 30): string {
let indent = '', indent_inner = '';
if ( pretty ) {
indent = ' '.repeat(depth - 1);
indent_inner = ' '.repeat(depth);
}
if ( depth > max_depth )
return `<span class="ffz-ct--obj-literal">&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;

1019
src/utilities/events.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,7 +1,15 @@
'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 */
/**
* 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 [
"window-minimize",
"window-maximize",
@ -114,4 +122,4 @@ export default [
"doc-text",
"fx",
"artist"
];
] as const;

View file

@ -1,14 +1,55 @@
'use strict';
import type { OptionalPromise, OptionallyCallable } from "./types";
// ============================================================================
// 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 )
return inverted ? () => false : () => true;
const tests = [],
const tests: FilterMethod<TContext>[] = [],
names = [];
let i = 0;
@ -42,7 +83,7 @@ export function createTester(rules, filter_types, inverted = false, or = false,
return inverted ? () => false : () => true;
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 ${inverted ? `!(` : ''}${names.map(name => `${name}(ctx)`).join(or ? ' || ' : ' && ')}${inverted ? ')' : ''};`

View file

@ -6,6 +6,11 @@ import {createElement} from 'utilities/dom';
// 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 = {
'ban': ['ban', 'block'],
'ok': ['ok', 'unban', 'untimeout'],
@ -102,8 +107,12 @@ export const ALIASES = {
'bath': ['bathtub','s15','bath'],
'window-close': ['times-rectangle','window-close'],
'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 = [
'glass','music','search','envelope-o','heart','star','star-o','user',
'film','th-large','th','th-list','check','times','search-plus',
@ -217,12 +226,17 @@ export const ICONS = [
'thermometer-quarter','thermometer-empty','shower','bath','podcast',
'window-maximize','window-minimize','window-restore','window-close',
'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;
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 = () => {
if ( loaded )
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') )
return;

View file

@ -66,10 +66,10 @@ const GOOGLE_FONTS = [
'Karla'
];
const LOADED_GOOGLE = new Map();
const LOADED_GOOGLE_LINKS = new Map();
const LOADED_GOOGLE = new Map<string, number>();
const LOADED_GOOGLE_LINKS = new Map<string, HTMLLinkElement>();
function loadGoogleFont(font) {
function loadGoogleFont(font: string) {
if ( LOADED_GOOGLE_LINKS.has(font) )
return;
@ -85,7 +85,7 @@ function loadGoogleFont(font) {
document.head.appendChild(link);
}
function unloadGoogleFont(font) {
function unloadGoogleFont(font: string) {
const link = LOADED_GOOGLE_LINKS.get(font);
if ( ! link )
return;
@ -106,7 +106,7 @@ const OD_FONTS = [
import OD_URL from 'styles/opendyslexic.scss';
let od_count = 0;
let od_link = null;
let od_link: HTMLLinkElement | null = null;
function loadOpenDyslexic() {
if ( od_link )
@ -134,8 +134,28 @@ function unloadOpenDyslexic() {
/* Using and Listing Fonts */
export function getFontsList() {
const out = [
// TODO: Move this type somewhere more generic.
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'},
{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 )
return [font, null];

View file

@ -1,20 +1,23 @@
'use strict';
import type { DefinitionNode, DocumentNode, FieldNode, FragmentDefinitionNode, OperationDefinitionNode, SelectionNode, SelectionSetNode } from 'graphql';
// ============================================================================
// GraphQL Document Manipulation
// ============================================================================
export const MERGE_METHODS = {
Document: (a, b) => {
export const MERGE_METHODS: Record<string, (a: any, b: any) => any> = {
Document: (a: DocumentNode, b: DocumentNode) => {
if ( a.definitions && b.definitions )
a.definitions = mergeList(a.definitions, b.definitions);
(a as any).definitions = mergeList(a.definitions as DefinitionNode[], b.definitions as any);
else if ( b.definitions )
a.definitions = b.definitions;
(a as any).definitions = b.definitions;
return a;
},
Field: (a, b) => {
Field: (a: FieldNode, b: FieldNode) => {
if ( a.name && (! b.name || b.name.value !== a.name.value) )
return a;
@ -22,14 +25,14 @@ export const MERGE_METHODS = {
// TODO: directives
if ( a.selectionSet && b.selectionSet )
a.selectionSet = merge(a.selectionSet, b.selectionSet);
(a as any).selectionSet = merge(a.selectionSet, b.selectionSet);
else if ( b.selectionSet )
a.selectionSet = b.selectionSet;
(a as any).selectionSet = b.selectionSet;
return a;
},
OperationDefinition: (a, b) => {
OperationDefinition: (a: OperationDefinitionNode, b: OperationDefinitionNode) => {
if ( a.operation !== b.operation )
return a;
@ -37,14 +40,14 @@ export const MERGE_METHODS = {
// TODO: directives
if ( a.selectionSet && b.selectionSet )
a.selectionSet = merge(a.selectionSet, b.selectionSet);
(a as any).selectionSet = merge(a.selectionSet, b.selectionSet);
else if ( b.selectionSet )
a.selectionSet = b.selectionSet;
(a as any).selectionSet = b.selectionSet;
return a;
},
FragmentDefinition: (a, b) => {
FragmentDefinition: (a: FragmentDefinitionNode, b: FragmentDefinitionNode) => {
if ( a.typeCondition && b.typeCondition ) {
if ( a.typeCondition.kind !== b.typeCondition.kind )
return a;
@ -56,16 +59,16 @@ export const MERGE_METHODS = {
// TODO: directives
if ( a.selectionSet && b.selectionSet )
a.selectionSet = merge(a.selectionSet, b.selectionSet);
(a as any).selectionSet = merge(a.selectionSet, b.selectionSet);
else if ( b.selectionSet )
a.selectionSet = b.selectionSet;
(a as any).selectionSet = b.selectionSet;
return a;
},
SelectionSet: (a, b) => {
SelectionSet: (a: SelectionSetNode, b: SelectionSetNode) => {
if ( a.selections && b.selections )
a.selections = mergeList(a.selections, b.selections);
a.selections = mergeList(a.selections as SelectionNode[], b.selections as any);
else if ( b.selections )
a.selections = b.selections;
@ -73,10 +76,10 @@ export const MERGE_METHODS = {
}
}
export function mergeList(a, b) {
// TODO: Type safe this
export function mergeList(a: any[], b: any[]) {
let has_operation = false;
const a_names = {};
const a_names: Record<string, any> = {};
for(const item of a) {
if ( ! item || ! item.name || item.name.kind !== 'Name' )
continue;
@ -114,7 +117,7 @@ export function mergeList(a, b) {
}
export default function merge(a, b) {
export default function merge(a: any, b: any) {
if ( a.kind !== b.kind )
return a;

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

1187
src/utilities/module.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,952 +0,0 @@
'use strict';
import {BAD_HOTKEYS, TWITCH_EMOTE_V2, WORD_SEPARATORS} from 'utilities/constants';
const HOP = Object.prototype.hasOwnProperty;
export function getTwitchEmoteURL(id, scale, animated = false, dark = true) {
return `${TWITCH_EMOTE_V2}/${id}/${animated ? 'default' : 'static'}/${dark ? 'dark' : 'light'}/${scale == 4 ? 3 : scale}.0`
}
export function getTwitchEmoteSrcSet(id, animated = false, dark = true, big = false) {
if ( big )
return `${getTwitchEmoteURL(id, 2, animated, dark)} 1x, ${getTwitchEmoteURL(id, 4, animated, dark)} 2x`;
return `${getTwitchEmoteURL(id, 1, animated, dark)} 1x, ${getTwitchEmoteURL(id, 2, animated, dark)} 2x, ${getTwitchEmoteURL(id, 4, animated, dark)} 4x`;
}
export function isValidShortcut(key) {
if ( ! key )
return false;
key = key.toLowerCase().trim();
return ! BAD_HOTKEYS.includes(key);
}
// Source: https://gist.github.com/jed/982883 (WTFPL)
export function generateUUID(input) {
return input // if the placeholder was passed, return
? ( // a random number from 0 to 15
input ^ // unless b is 8,
Math.random() // in which case
* 16 // a random number from
>> input/4 // 8 to 11
).toString(16) // in hexadecimal
: ( // or otherwise a concatenated string:
[1e7] + // 10000000 +
-1e3 + // -1000 +
-4e3 + // -4000 +
-8e3 + // -80000000 +
-1e11 // -100000000000,
).replace( // replacing
/[018]/g, // zeroes, ones, and eights with
generateUUID // random hex digits
);
}
export class TranslatableError extends Error {
constructor(message/*:string*/, key/*:string*/, data/*:object*/) {
super(message);
this.i18n_key = key;
this.data = data;
}
toString() {
const ffz = window.FrankerFaceZ?.get?.(),
i18n = ffz?.resolve?.('i18n');
if ( i18n && this.i18n_key )
return i18n.t(this.i18n_key, this.message, this.data);
return this.message;
}
}
export async function sha256(message) {
// encode as UTF-8
const msgBuffer = new TextEncoder().encode(message);
// hash the message
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer);
// convert ArrayBuffer to Array
const hashArray = Array.from(new Uint8Array(hashBuffer));
// convert bytes to hex string
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return hashHex;
}
/*export function sortScreens(screens) {
screens.sort((a,b) => {
if ( a.left < b.left ) return -1;
if ( a.left > b.left ) return 1;
if ( a.top < b.top ) return -1;
if ( a.top > b.top ) return 1;
return 0;
});
return screens;
}*/
export function matchScreen(screens, options) {
let match = undefined;
let mscore = 0;
for(let i = 0; i < screens.length; i++) {
const mon = screens[i];
if ( mon.label !== options.label )
continue;
let score = 1;
if ( options.left && options.left === mon.left )
score += 15;
if ( options.top && options.top === mon.top )
score += 15;
if ( options.width && options.width === mon.width )
score += 10;
if ( options.height && options.height === mon.height )
score += 10;
if ( options.index )
score -= Math.abs(options.index - i);
if ( score > mscore ) {
match = mon;
mscore = score;
}
}
return match;
}
export function has(object, key) {
return object ? HOP.call(object, key) : false;
}
export function sleep(delay) {
return new Promise(s => setTimeout(s, delay));
}
export function make_enum(...array) {
const out = {};
for(let i=0; i < array.length; i++) {
const word = array[i];
out[word] = i;
out[i] = word;
}
return out;
}
export function make_enum_flags(...array) {
const out = {};
out.None = 0;
out[0] = 'None';
for(let i = 0; i < array.length; i++) {
const word = array[i],
value = Math.pow(2, i);
out[word] = value;
out[value] = word;
}
return out;
}
export function timeout(promise, delay) {
return new Promise((resolve, reject) => {
let resolved = false;
const timer = setTimeout(() => {
if ( ! resolved ) {
resolved = true;
reject(new Error('timeout'));
}
}, delay);
promise.then(result => {
if ( ! resolved ) {
resolved = true;
clearTimeout(timer);
resolve(result);
}
}).catch(err => {
if ( ! resolved ) {
resolved = true;
clearTimeout(timer);
reject(err);
}
});
});
}
export class Mutex {
constructor(limit = 1) {
this.limit = limit;
this._active = 0;
this._waiting = [];
this._done = this._done.bind(this);
}
get available() { return this._active < this.limit }
_done() {
this._active--;
while(this._active < this.limit && this._waiting.length > 0) {
this._active++;
const waiter = this._waiting.shift();
waiter(this._done);
}
}
wait() {
if ( this._active < this.limit) {
this._active++;
return Promise.resolve(this._done);
}
return new Promise(s => this._waiting.push(s));
}
}
/**
* Return a wrapper for a function that will only execute the function
* a period of time after it has stopped being called.
* @param {Function} fn The function to wrap.
* @param {Integer} delay The time to wait, in milliseconds
* @param {Boolean} immediate If immediate is true, trigger the function immediately rather than eventually.
* @returns {Function} wrapped function
*/
export function debounce(fn, delay, immediate) {
let timer;
if ( immediate ) {
const later = () => timer = null;
if ( immediate === 2 )
// Special Mode! Run immediately OR later.
return function(...args) {
if ( timer ) {
clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
fn.apply(this, args); // eslint-disable-line no-invalid-this
}, delay);
} else {
fn.apply(this, args); // eslint-disable-line no-invalid-this
timer = setTimeout(later, delay);
}
}
return function(...args) {
if ( ! timer )
fn.apply(this, args); // eslint-disable-line no-invalid-this
else
clearTimeout(timer);
timer = setTimeout(later, delay);
}
}
return function(...args) {
if ( timer )
clearTimeout(timer);
timer = setTimeout(fn.bind(this, ...args), delay); // eslint-disable-line no-invalid-this
}
}
/**
* Make sure that a given asynchronous function is only called once
* at a time.
*/
export function once(fn) {
let waiters;
return function(...args) {
return new Promise(async (s,f) => {
if ( waiters )
return waiters.push([s,f]);
waiters = [[s,f]];
let result;
try {
result = await fn.call(this, ...args); // eslint-disable-line no-invalid-this
} catch(err) {
for(const w of waiters)
w[1](err);
waiters = null;
return;
}
for(const w of waiters)
w[0](result);
waiters = null;
})
}
}
/**
* Check that two arrays are the same length and that each array has the same
* items in the same indices.
* @param {Array} a The first array
* @param {Array} b The second array
* @returns {boolean} Whether or not they match
*/
export function array_equals(a, b) {
if ( ! Array.isArray(a) || ! Array.isArray(b) || a.length !== b.length )
return false;
let i = a.length;
while(i--)
if ( a[i] !== b[i] )
return false;
return true;
}
export function deep_equals(object, other, ignore_undefined = false, seen, other_seen) {
if ( object === other )
return true;
if ( typeof object !== typeof other )
return false;
if ( typeof object !== 'object' )
return false;
if ( (object === null) !== (other === null) )
return false;
if ( ! seen )
seen = new Set;
if ( ! other_seen )
other_seen = new Set;
if ( seen.has(object) || other_seen.has(other) )
throw new Error('recursive structure detected');
seen.add(object);
other_seen.add(other);
const source_keys = Object.keys(object),
dest_keys = Object.keys(other);
if ( ! ignore_undefined && ! set_equals(new Set(source_keys), new Set(dest_keys)) )
return false;
for(const key of source_keys)
if ( ! deep_equals(object[key], other[key], ignore_undefined, new Set(seen), new Set(other_seen)) )
return false;
if ( ignore_undefined )
for(const key of dest_keys)
if ( ! source_keys.includes(key) ) {
if ( ! deep_equals(object[key], other[key], ignore_undefined, new Set(seen), new Set(other_seen)) )
return false;
}
return true;
}
export function shallow_object_equals(a, b) {
if ( typeof a !== 'object' || typeof b !== 'object' )
return false;
const keys = Object.keys(a);
if ( ! set_equals(new Set(keys), new Set(Object.keys(b))) )
return false;
for(const key of keys)
if ( a[key] !== b[key] )
return false;
return true;
}
export function map_equals(a, b) {
if ( !(a instanceof Map) || !(b instanceof Map) || a.size !== b.size )
return false;
for(const [key, val] of a)
if ( ! b.has(key) || b.get(key) !== val )
return false;
return true;
}
export function set_equals(a,b) {
if ( !(a instanceof Set) || !(b instanceof Set) || a.size !== b.size )
return false;
for(const v of a)
if ( ! b.has(v) )
return false;
return true;
}
/**
* Special logic to ensure that a target object is matched by a filter.
* @param {object} filter The filter object
* @param {object} target The object to check it against
* @returns {boolean} Whether or not it matches
*/
export function filter_match(filter, target) {
for(const key in filter) {
if ( HOP.call(filter, key) ) {
const filter_value = filter[key],
target_value = target[key],
type = typeof filter_value;
if ( type === 'function' ) {
if ( ! filter_value(target_value) )
return false;
} else if ( Array.isArray(filter_value) ) {
if ( Array.isArray(target_value) ) {
for(const val of filter_value)
if ( ! target_value.includes(val) )
return false;
} else if ( ! filter_value.include(target_value) )
return false;
} else if ( typeof target_value !== type )
return false;
else if ( type === 'object' ) {
if ( ! filter_match(filter_value, target_value) )
return false;
} else if ( filter_value !== target_value )
return false;
}
}
return true;
}
export function substr_count(str, needle) {
let i = 0, idx = 0;
while( idx < str.length ) {
const x = str.indexOf(needle, idx);
if ( x === -1 )
break;
i++;
idx = x + 1;
}
return i;
}
/**
* Get a value from an object at a path.
* @param {string|Array} path The path to follow, using periods to go down a level.
* @param {object|Array} object The starting object.
* @returns {*} The value at that point in the path, or undefined if part of the path doesn't exist.
*/
export function get(path, object) {
if ( HOP.call(object, path) )
return object[path];
if ( typeof path === 'string' )
path = path.split('.');
for(let i=0, l = path.length; i < l; i++) {
const part = path[i];
if ( part === '@each' ) {
const p = path.slice(i + 1);
if ( p.length ) {
if ( Array.isArray )
object = object.map(x => get(p, x));
else {
const new_object = {};
for(const key in object)
if ( HOP.call(object, key) )
new_object[key] = get(p, object[key]);
object = new_object;
}
}
break;
} else if ( part === '@last' )
object = object[object.length - 1];
else
object = object[path[i]];
if ( ! object )
break;
}
return object;
}
/**
* Copy an object so that it can be safely serialized. If an object
* is not serializable, such as a promise, returns null.
*
* @export
* @param {*} object The thing to copy.
* @param {Number} [depth=2] The maximum depth to explore the object.
* @param {Set} [seen=null] A Set of seen objects. Internal use only.
* @returns {Object} The copy to safely store or use.
*/
export function shallow_copy(object, depth = 2, seen = null) {
if ( object == null )
return object;
if ( object instanceof Promise || typeof object === 'function' )
return null;
if ( typeof object !== 'object' )
return object;
if ( depth === 0 )
return null;
if ( ! seen )
seen = new Set;
seen.add(object);
if ( Array.isArray(object) ) {
const out = [];
for(const val of object) {
if ( seen.has(val) )
continue;
out.push(shallow_copy(val, depth - 1, new Set(seen)));
}
return out;
}
const out = {};
for(const [key, val] of Object.entries(object) ) {
if ( seen.has(val) )
continue;
out[key] = shallow_copy(val, depth - 1, new Set(seen));
}
return out;
}
export function deep_copy(object, seen) {
if ( object === null )
return null;
else if ( object === undefined )
return undefined;
if ( object instanceof Promise )
return new Promise((s,f) => object.then(s).catch(f));
if ( typeof object === 'function' )
return function(...args) { return object.apply(this, args); } // eslint-disable-line no-invalid-this
if ( typeof object !== 'object' )
return object;
if ( ! seen )
seen = new Set;
if ( seen.has(object) )
throw new Error('recursive structure detected');
seen.add(object);
if ( Array.isArray(object) )
return object.map(x => deep_copy(x, new Set(seen)));
const out = {};
for(const key in object)
if ( HOP.call(object, key) ) {
const val = object[key];
if ( typeof val === 'object' )
out[key] = deep_copy(val, new Set(seen));
else
out[key] = val;
}
return out;
}
export function normalizeAddonIdForComparison(input) {
return input.toLowerCase().replace(/[\.\_\-]+/, '-');
}
export function makeAddonIdChecker(input) {
input = escape_regex(normalizeAddonIdForComparison(input));
input = input.replace(/-+/g, '[\.\_\-]+');
// Special: ffzap-bttv
input = input.replace(/\bbttv\b/g, '(?:bttv|betterttv)');
// Special: which seven tho
input = input.replace(/\b7tv\b/g, '(?:7tv|seventv)');
// Special: pronouns (badges)
input = input.replace(/\bpronouns\b/g, '(?:pronouns|addon-pn)');
return new RegExp('\\b' + input + '\\b', 'i');
}
export function maybe_call(fn, ctx, ...args) {
if ( typeof fn === 'function' ) {
if ( ctx )
return fn.call(ctx, ...args);
return fn(...args);
}
return fn;
}
const SPLIT_REGEX = /[^\uD800-\uDFFF]|[\uD800-\uDBFF][\uDC00-\uDFFF]|[\uD800-\uDFFF]/g;
export function split_chars(str) {
if ( str === '' )
return [];
return str.match(SPLIT_REGEX);
}
export function pick_random(obj) {
if ( ! obj )
return null;
if ( ! Array.isArray(obj) )
return obj[pick_random(Object.keys(obj))]
return obj[Math.floor(Math.random() * obj.length)];
}
export const escape_regex = RegExp.escape || function escape_regex(str) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
export function addWordSeparators(str) {
return `(^|.*?${WORD_SEPARATORS})(?:${str})(?=$|${WORD_SEPARATORS})`
}
const CONTROL_CHARS = '/$^+.()=!|';
export function glob_to_regex(input) {
if ( typeof input !== 'string' )
throw new TypeError('input must be a string');
let output = '',
groups = 0;
for(let i=0, l=input.length; i<l; i++) {
const char = input[i];
if ( CONTROL_CHARS.includes(char) )
output += `\\${char}`;
else if ( char === '\\' ) {
i++;
const next = input[i];
if ( next ) {
if ( CONTROL_CHARS.includes(next) )
output += `\\${next}`;
else
output += next;
}
} else if ( char === '?' )
output += '.';
else if ( char === '[' ) {
output += char;
const next = input[i + 1];
if ( next === '!' ) {
i++;
output += '^';
}
} else if ( char === ']' )
output += char;
else if ( char === '{' ) {
output += '(?:';
groups++;
} else if ( char === '}' ) {
if ( groups > 0 ) {
output += ')';
groups--;
}
} else if ( char === ',' && groups > 0 )
output += '|';
else if ( char === '*' ) {
let count = 1;
while(input[i+1] === '*') {
count++;
i++;
}
if ( count > 1 )
output += '.*?';
else
output += '[^\\s]*?';
} else
output += char;
}
/*while(groups > 0) {
output += ')';
groups--;
}*/
return output;
}
/**
* Truncate a string. Tries to intelligently break the string in white-space
* if possible, without back-tracking. The returned string can be up to
* `ellipsis.length + target + overage` characters long.
* @param {String} str The string to truncate.
* @param {Number} target The target length for the result
* @param {Number} overage Accept up to this many additional characters for a better result
* @param {String} [ellipsis='…'] The string to append when truncating
* @param {Boolean} [break_line=true] If true, attempt to break at the first LF
* @param {Boolean} [trim=true] If true, runs trim() on the string before truncating
* @returns {String} The truncated string
*/
export function truncate(str, target = 100, overage = 15, ellipsis = '…', break_line = true, trim = true) {
if ( ! str || ! str.length )
return str;
if ( trim )
str = str.trim();
let idx = break_line ? str.indexOf('\n') : -1;
if ( idx === -1 || idx > target )
idx = target;
if ( str.length <= idx )
return str;
let out = str.slice(0, idx).trimRight();
if ( overage > 0 && out.length >= idx ) {
let next_space = str.slice(idx).search(/\s+/);
if ( next_space === -1 && overage + idx > str.length )
next_space = str.length - idx;
if ( next_space !== -1 && next_space <= overage ) {
if ( str.length <= (idx + next_space) )
return str;
out = str.slice(0, idx + next_space);
}
}
return out + ellipsis;
}
function decimalToHex(number) {
return number.toString(16).padStart(2, '0')
}
export function generateHex(length = 40) {
const arr = new Uint8Array(length / 2);
window.crypto.getRandomValues(arr);
return Array.from(arr, decimalToHex).join('')
}
export class SourcedSet {
constructor(use_set = false) {
this._use_set = use_set;
this._cache = use_set ? new Set : [];
}
_rebuild() {
if ( ! this._sources )
return;
const use_set = this._use_set,
cache = this._cache = use_set ? new Set : [];
for(const items of this._sources.values())
for(const i of items)
if ( use_set )
cache.add(i);
else if ( ! cache.includes(i) )
this._cache.push(i);
}
get(key) { return this._sources && this._sources.get(key) }
has(key) { return this._sources ? this._sources.has(key) : false }
sourceIncludes(key, val) {
const src = this._sources && this._sources.get(key);
return src && src.includes(val);
}
includes(val) {
return this._use_set ? this._cache.has(val) : this._cache.includes(val);
}
delete(key) {
if ( this._sources && this._sources.has(key) ) {
this._sources.delete(key);
this._rebuild();
}
}
extend(key, ...items) {
if ( ! this._sources )
this._sources = new Map;
const had = this.has(key);
if ( had )
items = [...this._sources.get(key), ...items];
this._sources.set(key, items);
if ( had )
this._rebuild();
else
for(const i of items)
if ( this._use_set )
this._cache.add(i);
else if ( ! this._cache.includes(i) )
this._cache.push(i);
}
set(key, val) {
if ( ! this._sources )
this._sources = new Map;
const had = this.has(key);
if ( ! Array.isArray(val) )
val = [val];
this._sources.set(key, val);
if ( had )
this._rebuild();
else
for(const i of val)
if ( this._use_set )
this._cache.add(i);
else if ( ! this._cache.includes(i) )
this._cache.push(i);
}
push(key, val) {
if ( ! this._sources )
return this.set(key, val);
const old_val = this._sources.get(key);
if ( old_val === undefined )
return this.set(key, val);
else if ( old_val.includes(val) )
return;
old_val.push(val);
if ( this._use_set )
this._cache.add(val);
else if ( ! this._cache.includes(val) )
this._cache.push(val);
}
remove(key, val) {
if ( ! this.has(key) )
return;
const old_val = this._sources.get(key),
idx = old_val.indexOf(val);
if ( idx === -1 )
return;
old_val.splice(idx, 1);
this._rebuild();
}
}
export function b64ToArrayBuffer(input) {
const bin = atob(input),
len = bin.length,
buffer = new ArrayBuffer(len),
view = new Uint8Array(buffer);
for(let i = 0, len = bin.length; i < len; i++)
view[i] = bin.charCodeAt(i);
return buffer;
}
const PEM_HEADER = /-----BEGIN (.+?) KEY-----/,
PEM_FOOTER = /-----END (.+?) KEY-----/;
export function importRsaKey(pem, uses = ['verify']) {
const start_match = PEM_HEADER.exec(pem),
end_match = PEM_FOOTER.exec(pem);
if ( ! start_match || ! end_match || start_match[1] !== end_match[1] )
throw new Error('invalid key');
const is_private = /\bPRIVATE\b/i.test(start_match[1]),
start = start_match.index + start_match[0].length,
end = end_match.index;
const content = pem.slice(start, end).replace(/\n/g, '').trim();
//console.debug('content', JSON.stringify(content));
const buffer = b64ToArrayBuffer(content);
return crypto.subtle.importKey(
is_private ? 'pkcs8' : 'spki',
buffer,
{
name: "RSA-PSS",
hash: "SHA-256"
},
true,
uses
);
}

1339
src/utilities/object.ts Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,18 +1,32 @@
'use strict';
export function parse(path) {
export function parse(path: string) {
return parseAST({
path,
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,
length = path.length,
out = [];
let token, raw;
let token: PathNode | null = null,
raw: string | null = null;
let old_tab = false,
old_page = false;
@ -21,10 +35,11 @@ function parseAST(ctx) {
char = path[start],
next = path[start + 1];
if ( ! token ) {
raw = [];
token = {};
}
if ( ! token )
token = {} as PathNode;
if ( ! raw )
raw = '';
// JSON
if ( char === '@' && next === '{') {
@ -42,7 +57,7 @@ function parseAST(ctx) {
segment = ! page && char === '>';
if ( ! segment && ! page && ! tab ) {
raw.push(char);
raw += char;
ctx.i++;
continue;
}
@ -52,7 +67,7 @@ function parseAST(ctx) {
if ( tab || page )
ctx.i++;
token.title = raw.join('').trim();
token.title = raw.trim();
token.key = token.title.toSnakeCase();
token.page = old_page;
@ -66,8 +81,8 @@ function parseAST(ctx) {
ctx.i++;
}
if ( token ) {
token.title = raw.join('').trim();
if ( token && raw ) {
token.title = raw.trim();
token.key = token.title.toSnakeCase();
token.page = old_page;
token.tab = old_tab;
@ -77,7 +92,7 @@ function parseAST(ctx) {
return out;
}
function parseJSON(ctx) {
function parseJSON(ctx: ParseContext) {
const path = ctx.path,
length = path.length,
@ -86,7 +101,7 @@ function parseJSON(ctx) {
ctx.i++;
const stack = ['{'];
let string = false;
let string: string | boolean = false;
while ( ctx.i < length && stack.length ) {
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);
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;
let minutes = Math.floor(elapsed / 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;
if ( separate_days ) {
days = Math.floor(hours / 24);
const day_count = Math.floor(hours / 24);
hours = hours % 24;
if ( days_only && days > 0 )
if ( days_only && day_count > 0 )
return `${days} days`;
days = days > 0 ? `${days} days, ` : '';
days = day_count > 0 ? `${day_count} days, ` : '';
}
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);
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;
let minutes = Math.floor(elapsed / 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;
let minutes = Math.floor(elapsed / 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) {
event.ts = performance.now();

View file

@ -11,7 +11,20 @@
import {createElement, setChildren} from 'utilities/dom';
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;
@ -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
// ============================================================================
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');
this.options = Object.assign({}, DefaultOptions, options);
this.options = Object.assign({}, DefaultOptions, options) as TooltipOptions;
this.live = this.options.live;
this.check_modifiers = this.options.check_modifiers;
this.parent = parent;
this.container = this.options.container || this.parent;
this.cls = cls;
if ( this.check_modifiers )
if ( this.options.check_modifiers )
this.installModifiers();
if ( ! this.live ) {
let elements;
if ( typeof cls === 'string' )
this.elements = parent.querySelectorAll(cls);
elements = Array.from(parent.querySelectorAll(cls)) as HTMLElement[];
else if ( Array.isArray(cls) )
this.elements = cls;
else if ( cls instanceof Node )
this.elements = [cls];
elements = cls;
else if ( cls instanceof HTMLElement )
elements = [cls];
else
throw new TypeError('invalid elements');
this.elements = new Set(this.elements);
this.elements = new Set(elements);
} else {
this.cls = cls;
@ -72,7 +191,11 @@ export class Tooltip {
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 ) {
// Do nothing~!
@ -80,8 +203,8 @@ export class Tooltip {
} else if ( this.live ) {
this._onMouseOver = e => {
this.updateShift(e.shiftKey, e.ctrlKey);
const target = e.target;
if ( target && target.classList && target.classList.contains(this.cls) && target.dataset.forceOpen !== 'true' ) {
const target = e.target as HTMLElement;
if ( target && target.classList && target.classList.contains(this.cls as string) && target.dataset?.forceOpen !== 'true' ) {
this._enter(target);
}
};
@ -92,10 +215,9 @@ export class Tooltip {
} else {
this._onMouseOver = e => {
this.updateShift(e.shiftKey, e.ctrlKey);
const target = e.target;
if ( this.elements.has(target) && target.dataset.forceOpen !== 'true' ) {
this._enter(e.target);
}
const target = e.target as HTMLElement;
if ( this.elements.has(target) && target.dataset.forceOpen !== 'true' )
this._enter(target);
}
if ( this.elements.size <= 5 )
@ -118,27 +240,30 @@ export class Tooltip {
if ( this.options.manual ) {
// Do nothing~!
} else if ( this.live || this.elements.size > 5 ) {
if ( this._onMouseOver )
this.parent.removeEventListener('mouseover', this._onMouseOver);
this.parent.removeEventListener('mouseout', this._onMouseOut);
} else
for(const el of this.elements) {
if ( this._onMouseOver )
el.removeEventListener('mouseenter', this._onMouseOver);
el.removeEventListener('mouseleave', this._onMouseOut);
}
for(const el of this.elements) {
const tip = el[this._accessor];
const tip = (el as any)[this._accessor] as TooltipInstance;
if ( tip && tip.visible )
this.hide(tip);
el[this._accessor] = null;
(el as any)[this._accessor] = null;
el._ffz_tooltip = null;
}
this.elements = null;
this._onMouseOut = this._onMouseOver = null;
this.container = null;
this.parent = null;
// Lazy types. We don't care.
this.elements = null as any;
this._onMouseOut = this._onMouseOver = null as any;
this.container = null as any;
this.parent = null as any;
}
@ -160,7 +285,7 @@ export class Tooltip {
this._keyUpdate = null;
}
updateShift(state, ctrl_state) {
updateShift(state: boolean, ctrl_state: boolean) {
if ( state === this.shift_state && ctrl_state === this.ctrl_state )
return;
@ -172,12 +297,11 @@ export class Tooltip {
this._shift_af = null;
if ( 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 ) {
tip.outer.dataset.shift = this.shift_state;
tip.outer.dataset.ctrl = this.ctrl_state;
tip.outer.dataset.shift = `${this.shift_state}`;
tip.outer.dataset.ctrl = `${this.ctrl_state}`;
tip.update();
//tip.updateVideo();
}
}
});
@ -189,7 +313,7 @@ export class Tooltip {
return;
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) )
continue;
@ -199,10 +323,10 @@ export class Tooltip {
}
_enter(target) {
let tip = target[this._accessor];
_enter(target: HTMLElement) {
let tip = (target as any)[this._accessor] as TooltipInstance;
if ( ! tip )
tip = target[this._accessor] = {target};
tip = (target as any)[this._accessor] = {target} as TooltipInstance;
tip.state = true;
@ -227,8 +351,8 @@ export class Tooltip {
}, delay);
}
_exit(target) {
const tip = target[this._accessor];
_exit(target: HTMLElement) {
const tip = (target as any)[this._accessor] as TooltipInstance;
if ( ! tip )
return;
@ -257,7 +381,7 @@ export class Tooltip {
}
show(tip) {
show(tip: TooltipInstance) {
const opts = this.options,
target = tip.target;
@ -265,26 +389,21 @@ export class Tooltip {
target._ffz_tooltip = tip;
// Set this early in case content uses it early.
tip._promises = [];
tip.waitForDom = () => tip.element ? Promise.resolve() : new Promise(s => {tip._promises.push(s)});
tip._wait_promise = null;
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.show = () => {
let tip = target[this._accessor];
let tip = (target as any)[this._accessor] as TooltipInstance;
if ( ! tip )
tip = target[this._accessor] = {target};
tip = (target as any)[this._accessor] = {target} as TooltipInstance;
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.rerender = () => {
if ( tip.visible ) {
@ -301,7 +420,10 @@ export class Tooltip {
return;
// 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),
el = tip.outer = createElement('div', {
@ -310,8 +432,6 @@ export class Tooltip {
'data-ctrl': this.ctrl_state
}, [inner, arrow]);
arrow.setAttribute('x-arrow', true);
if ( opts.arrowInner )
arrow.appendChild(createElement('div', opts.arrowInner));
@ -386,12 +506,12 @@ export class Tooltip {
if ( opts.popperConfig )
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) )
this.hide(tip);
}, 250);
let popper_target = target;
let popper_target: any = target;
if ( opts.no_update )
popper_target = makeReference(target);
@ -401,12 +521,13 @@ export class Tooltip {
}
}
for(const fn of tip._promises)
fn();
if ( tip._waiter )
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>';
content.then(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;
if ( opts.onHide )
opts.onHide(tip.target, tip);
@ -482,7 +603,7 @@ export class Tooltip {
if ( tip.target._ffz_tooltip === tip )
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.element = null;
tip.visible = false;
@ -492,17 +613,20 @@ export class Tooltip {
export default Tooltip;
export function normalizeModifiers(input) {
const output = [];
// Is this gross? Yes.
// 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)) {
const thing = {
const thing: any = {
name: key
};
if (val && typeof val === 'object' && ! Array.isArray(val)) {
if (has(val, 'enabled'))
thing.enabled = val.enabled;
thing.enabled = (val as any).enabled;
const keys = Object.keys(val);
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) {
if ( x instanceof Node ) {
export function makeReference(x: HTMLElement | number, y?: number, height: number = 0, width: number = 0) {
let _x: number;
if ( x instanceof HTMLElement ) {
const rect = x.getBoundingClientRect();
x = rect.x;
_x = rect.x;
y = rect.y;
height = rect.height;
width = rect.width;
}
} else
_x = x;
const out = {
getBoundingClientRect: () => ({
top: y,
bottom: y + height,
bottom: (y as number) + height,
y,
left: x,
right: x + width,
left: _x,
right: _x + width,
x,
height,
width

View file

@ -7,25 +7,26 @@
import dayjs from 'dayjs';
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);
import Parser from '@ffz/icu-msgparser';
const DEFAULT_PARSER_OPTIONS = {
allowTags: 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
// ============================================================================
export const DEFAULT_TYPES = {
export const DEFAULT_TYPES: Record<string, TypeFormatter> = {
tostring(val) {
return `${val}`
},
@ -65,7 +66,7 @@ export const DEFAULT_TYPES = {
val = new_val;
}
return this.formatNumber(val, node.f);
return this.formatNumber(val, node.f as string);
},
currency(val, node) {
@ -79,19 +80,19 @@ export const DEFAULT_TYPES = {
val = new_val;
}
return this.formatCurrency(val, node.f);
return this.formatCurrency(val, node.f as string);
},
date(val, node) {
return this.formatDate(val, node.f);
return this.formatDate(val, node.f as string);
},
time(val, node) {
return this.formatTime(val, node.f);
return this.formatTime(val, node.f as string);
},
datetime(val, node) {
return this.formatDateTime(val, node.f);
return this.formatDateTime(val, node.f as string);
},
duration(val) {
@ -103,17 +104,19 @@ export const DEFAULT_TYPES = {
},
relativetime(val, node) {
return this.formatRelativeTime(val, node.f);
return this.formatRelativeTime(val, node.f as string);
},
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 = {
number: {
currency: {
@ -209,15 +212,83 @@ export const DEFAULT_FORMATS = {
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
// ============================================================================
export default class TranslationCore {
constructor(options) {
export class TranslationCore {
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 || {};
this.warn = options.warn;
@ -226,9 +297,9 @@ export default class TranslationCore {
this.defaultLocale = options.defaultLocale || this._locale;
this.transformation = null;
this.defaultDateFormat = options.defaultDateFormat;
this.defaultTimeFormat = options.defaultTimeFormat;
this.defaultDateTimeFormat = options.defaultDateTimeFormat;
this.defaultDateFormat = options.defaultDateFormat ?? 'default';
this.defaultTimeFormat = options.defaultTimeFormat ?? 'short';
this.defaultDateTimeFormat = options.defaultDateTimeFormat ?? 'medium';
this.phrases = new Map;
this.cache = new Map;
@ -237,9 +308,12 @@ export default class TranslationCore {
this.currencyFormats = new Map;
this.formats = Object.assign({}, DEFAULT_FORMATS);
if ( options.formats )
for(const key of Object.keys(options.formats))
this.formats[key] = Object.assign({}, this.formats[key], options.formats[key]);
if ( options.formats ) {
// I have no idea why the types are so picky here.
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.parser = new Parser(Object.assign({}, DEFAULT_PARSER_OPTIONS, options.parserOptions));
@ -260,15 +334,17 @@ export default class TranslationCore {
}
}
toLocaleString(thing) {
if ( thing && thing.toLocaleString )
return thing.toLocaleString(this._locale);
toLocaleString(thing: any) {
if ( thing?.toLocaleString )
return thing.toLocaleString(this._locale) as string;
else if ( typeof thing !== 'string' )
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),
without_suffix = f === 'plain';
without_suffix = format === 'plain';
try {
return d.locale(this._dayjs_locale).fromNow(without_suffix);
@ -277,10 +353,10 @@ export default class TranslationCore {
}
}
formatCurrency(value, currency) {
formatCurrency(value: number | bigint, currency: string) {
let formatter = this.currencyFormats.get(currency);
if ( ! formatter ) {
formatter = new Intl.NumberFormat(navigator.languages, {
formatter = new Intl.NumberFormat(navigator.languages as string[], {
style: 'currency',
currency
});
@ -291,7 +367,7 @@ export default class TranslationCore {
return formatter.format(value);
}
formatNumber(value, format) {
formatNumber(value: number | bigint, format: string) {
let formatter = this.numberFormats.get(format);
if ( ! formatter ) {
if ( this.formats.number[format] )
@ -310,11 +386,11 @@ export default class TranslationCore {
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);
}
formatDate(value, format) {
formatDate(value: string | number | Date, format?: string) {
if ( ! format )
format = this.defaultDateFormat;
@ -333,7 +409,7 @@ export default class TranslationCore {
return value.toLocaleDateString(this._locale, this.formats.date[format] || {});
}
formatTime(value, format) {
formatTime(value: string | number | Date, format?: string) {
if ( ! format )
format = this.defaultTimeFormat;
@ -352,7 +428,7 @@ export default class TranslationCore {
return value.toLocaleTimeString(this._locale, this.formats.time[format] || {});
}
formatDateTime(value, format) {
formatDateTime(value: string | number | Date, format?: string) {
if ( ! format )
format = this.defaultDateTimeFormat;
@ -371,8 +447,8 @@ export default class TranslationCore {
return value.toLocaleString(this._locale, this.formats.datetime[format] || {});
}
extend(phrases, prefix) {
const added = [];
extend(phrases: RecursivePhraseMap, prefix?: string) {
const added: string[] = [];
if ( ! phrases || typeof phrases !== 'object' )
return added;
@ -402,14 +478,14 @@ export default class TranslationCore {
return added;
}
unset(phrases, prefix) {
unset(phrases: string | string[], prefix: string) {
if ( typeof phrases === 'string' )
phrases = [phrases];
const keys = Array.isArray(phrases) ? phrases : Object.keys(phrases);
for(const key of keys) {
const full_key = prefix ? key === '_' ? prefix : `${prefix}.${key}` : key,
phrase = phrases[key];
phrase = (phrases as any)[key];
if ( typeof phrase === 'object' )
this.unset(phrases, full_key);
@ -420,11 +496,11 @@ export default class TranslationCore {
}
}
has(key) {
has(key: string) {
return this.phrases.has(key);
}
set(key, phrase) {
set(key: string, phrase: string) {
const parsed = this.parser.parse(phrase);
this.phrases.set(key, phrase);
this.cache.set(key, parsed);
@ -435,26 +511,34 @@ export default class TranslationCore {
this.cache.clear();
}
replace(phrases) {
replace(phrases: RecursivePhraseMap) {
this.clear();
this.extend(phrases);
}
_preTransform(key, phrase, options, settings = {}) {
let ast, locale, data = options == null ? {} : options;
_preTransform(
key: string,
phrase: string,
options: any,
settings: ParseTranslationSettings = {}
): [MessageAST, any, string] {
let ast: MessageAST,
locale: string,
data = options == null ? {} : options;
if ( typeof data === 'number' )
data = {count: data};
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;
} else if ( ! settings.noCache && this.cache.has(key) ) {
ast = this.cache.get(key);
ast = this.cache.get(key) ?? [];
locale = this.defaultLocale;
} else {
let parsed = null;
let parsed: MessageAST | null = null;
try {
parsed = this.parser.parse(phrase);
} catch(err) {
@ -478,6 +562,10 @@ export default class TranslationCore {
this.cache.set(key, parsed);
}
} else {
// This should never happen unless bad data is supplied.
ast = [];
locale = this.defaultLocale;
}
}
@ -487,15 +575,21 @@ export default class TranslationCore {
return [ast, data, locale];
}
t(key, phrase, options, settings) {
return listToString(this.tList(key, phrase, options, settings));
t(key: string, phrase: string, data: any, settings?: ParseTranslationSettings) {
return listToString(this.tList(key, phrase, data, settings));
}
tList(key, phrase, options, settings) {
return this._processAST(...this._preTransform(key, phrase, options, settings));
tList(key: string, phrase: string, data: any, settings?: ParseTranslationSettings) {
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' )
return node;
@ -507,16 +601,17 @@ export default class TranslationCore {
return null;
if ( node.t ) {
if ( this.types[node.t] )
return this.types[node.t].call(this, val, node, locale, out, ast, data);
const handler = this.types[node.t];
if ( handler )
return handler.call(this, val, node as MessageVariable, locale, out, ast, data);
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;
}
_processAST(ast, data, locale) {
_processAST(ast: MessageAST, data: any, locale: string) {
const out = [];
for(const node of ast) {
@ -530,10 +625,12 @@ export default class TranslationCore {
}
export default TranslationCore;
function listToString(list) {
function listToString(list: any[]): string {
if ( ! Array.isArray(list) )
return String(list);
return `${list}`;
return list.map(listToString).join('');
}
@ -543,188 +640,30 @@ function listToString(list) {
// Plural Handling
// ============================================================================
const CARDINAL_TO_LANG = {
arabic: ['ar'],
czech: ['cs'],
danish: ['da'],
german: ['de', 'el', 'en', 'es', 'fi', 'hu', 'it', 'nl', 'no', 'nb', 'tr', 'sv'],
hebrew: ['he'],
persian: ['fa'],
polish: ['pl'],
serbian: ['sr'],
french: ['fr', 'pt'],
russian: ['ru','uk'],
slov: ['sl']
}
let cardinal_i18n: Intl.PluralRules | null = null,
cardinal_locale: string | null = null;
const CARDINAL_TYPES = {
other: () => 5,
arabic(n) {
if ( n === 0 ) return 0;
if ( n === 1 ) return 1;
if ( n === 2 ) return 2;
const n1 = n % 1000;
if ( n1 >= 3 && n1 <= 10 ) return 3;
return n1 >= 11 ? 4 : 5;
},
czech: (n,i,v) => {
if ( v !== 0 ) return 4;
if ( i === 1 ) return 1;
if ( i >= 2 && i <= 4 ) return 3;
return 5;
},
danish: (n,i,v,t) => (n === 1 || (t !== 0 && (i === 0 || i === 1))) ? 1 : 5,
french: (n, i) => (i === 0 || i === 1) ? 1 : 5,
german: n => n === 1 ? 1 : 5,
hebrew(n) {
if ( n === 1 ) return 1;
if ( n === 2 ) return 2;
return (n > 10 && n % 10 === 0) ? 4 : 5;
},
persian: (n, i) => (i === 0 || n === 1) ? 1 : 5,
slov(n, i, v) {
if ( v !== 0 ) return 3;
const n1 = n % 100;
if ( n1 === 1 ) return 1;
if ( n1 === 2 ) return 2;
if ( n1 === 3 || n1 === 4 ) return 3;
return 5;
},
serbian(n, i, v, t) {
if ( v !== 0 ) return 5;
const i1 = i % 10, i2 = i % 100;
const t1 = t % 10, t2 = t % 100;
if ( i1 === 1 && i2 !== 11 ) return 1;
if ( t1 === 1 && t2 !== 11 ) return 1;
if ( i1 >= 2 && i1 <= 4 && !(i2 >= 12 && i2 <= 14) ) return 3;
if ( t1 >= 2 && t1 <= 4 && !(t2 >= 12 && t2 <= 14) ) return 3;
return 5;
},
polish(n, i, v) {
if ( v !== 0 ) return 5;
if ( n === 1 ) return 1;
const n1 = n % 10, n2 = n % 100;
if ( n1 >= 2 && n1 <= 4 && !(n2 >= 12 && n2 <= 14) ) return 3;
if ( i !== 1 && (n1 === 0 || n1 === 1) ) return 4;
if ( n1 >= 5 && n1 <= 9 ) return 4;
if ( n2 >= 12 && n2 <= 14 ) return 4;
return 5;
},
russian(n,i,v) {
const n1 = n % 10, n2 = n % 100;
if ( n1 === 1 && n2 !== 11 ) return 1;
if ( v === 0 && (n1 >= 2 && n1 <= 4) && (n2 < 12 || n2 > 14) ) return 3;
return ( v === 0 && (n1 === 0 || (n1 >= 5 && n1 <= 9) || (n2 >= 11 || n2 <= 14)) ) ? 4 : 5
}
}
const ORDINAL_TO_LANG = {
english: ['en'],
hungarian: ['hu'],
italian: ['it'],
one: ['fr', 'lo', 'ms'],
swedish: ['sv'],
ukranian: ['uk']
};
const ORDINAL_TYPES = {
other: () => 5,
one: n => n === 1 ? 1 : 5,
english(n) {
const n1 = n % 10, n2 = n % 100;
if ( n1 === 1 && n2 !== 11 ) return 1;
if ( n1 === 2 && n2 !== 12 ) return 2;
if ( n1 === 3 && n2 !== 13 ) return 3;
return 5;
},
ukranian(n) {
const n1 = n % 10, n2 = n % 100;
if ( n1 === 3 && n2 !== 13 ) return 3;
return 5;
},
hungarian: n => (n === 1 || n === 5) ? 1 : 5,
italian: n => (n === 11 || n === 8 || n === 80 || n === 800) ? 4 : 5,
swedish(n) {
const n1 = n % 10, n2 = n % 100;
return ((n1 === 1 || n1 === 2) && (n2 !== 11 && n2 !== 12)) ? 1 : 5;
}
}
const PLURAL_TO_NAME = [
'zero', // 0
'one', // 1
'two', // 2
'few', // 3
'many', // 4
'other' // 5
];
const CARDINAL_LANG_TO_TYPE = {},
ORDINAL_LANG_TO_TYPE = {};
for(const type of Object.keys(CARDINAL_TO_LANG))
for(const lang of CARDINAL_TO_LANG[type])
CARDINAL_LANG_TO_TYPE[lang] = type;
for(const type of Object.keys(ORDINAL_TO_LANG))
for(const lang of ORDINAL_TO_LANG[type])
ORDINAL_LANG_TO_TYPE[lang] = type;
function executePlural(fn, input) {
input = Math.abs(Number(input));
const i = Math.floor(input);
let v, t;
if ( i === input ) {
v = 0;
t = 0;
} else {
t = `${input}`.split('.')[1]
v = t ? t.length : 0;
t = t ? Number(t) : 0;
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 PLURAL_TO_NAME[fn(
input,
i,
v,
t
)]
return cardinal_i18n.select(input);
}
let ordinal_i18n: Intl.PluralRules | null = null,
ordinal_locale: string | null = null;
export function getCardinalName(locale, input) {
let type = CARDINAL_LANG_TO_TYPE[locale];
if ( ! type ) {
const idx = locale.indexOf('-');
type = (idx !== -1 && CARDINAL_LANG_TO_TYPE[locale.slice(0, idx)]) || 'other';
CARDINAL_LANG_TO_TYPE[locale] = type;
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 executePlural(CARDINAL_TYPES[type], input);
}
export function getOrdinalName(locale, input) {
let type = ORDINAL_LANG_TO_TYPE[locale];
if ( ! type ) {
const idx = locale.indexOf('-');
type = (idx !== -1 && ORDINAL_LANG_TO_TYPE[locale.slice(0, idx)]) || 'other';
ORDINAL_LANG_TO_TYPE[locale] = type;
}
return executePlural(ORDINAL_TYPES[type], input);
return ordinal_i18n.select(input);
}

Some files were not shown because too many files have changed in this diff Show more