1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-09-17 10:16:57 +00:00

Merge branch 'master' into rm-deprecated-substr

This commit is contained in:
Tobias Speicher 2023-09-29 12:50:58 +02:00
commit e20ee44015
No known key found for this signature in database
GPG key ID: 2CF824BD810C3BDB
203 changed files with 18692 additions and 6687 deletions

View file

@ -13,8 +13,8 @@ module.exports = {
'react' 'react'
], ],
'parserOptions': { 'parserOptions': {
'parser': '@babel/eslint-parser', //'parser': '@babel/eslint-parser',
'ecmaVersion': 8, 'ecmaVersion': 'latest',
'sourceType': 'module', 'sourceType': 'module',
'ecmaFeatures': { 'ecmaFeatures': {
'jsx': true 'jsx': true
@ -34,6 +34,7 @@ module.exports = {
'__version_minor__': false, '__version_minor__': false,
'__version_patch__': false, '__version_patch__': false,
'__version_prerelease__': false, '__version_prerelease__': false,
'__extension__': false,
'FrankerFaceZ': false 'FrankerFaceZ': false
}, },
'rules': { 'rules': {

2
.github/FUNDING.yml vendored
View file

@ -1 +1 @@
custom: ["https://www.frankerfacez.com/donate"] custom: ["https://www.frankerfacez.com/subscribe"]

View file

@ -785,6 +785,116 @@
"css": "list-bullet", "css": "list-bullet",
"code": 61642, "code": 61642,
"src": "fontawesome" "src": "fontawesome"
},
{
"uid": "7655b7161cf9660beeb1af4036db1198",
"css": "mastodon",
"code": 59463,
"src": "custom_icons",
"selected": true,
"svg": {
"path": "M932.9 220.7C918.5 114.6 825 31 714.2 14.8 695.5 12 624.7 2.1 460.6 2.1H459.4C295.3 2.1 260.1 12 241.4 14.8 133.7 30.5 35.3 105.6 11.4 213 0 265.9-1.3 324.5 0.9 378.2 3.9 455.3 4.5 532.3 11.6 609.1 16.5 660.1 25 710.7 37.1 760.5 59.8 852.5 151.6 929 241.6 960.3 337.9 992.8 441.5 998.2 540.8 975.9 551.7 973.4 562.5 970.4 573.2 967.1 597.3 959.5 625.6 951.1 646.3 936.2 646.6 935.9 646.9 935.7 647 935.4 647.2 935.1 647.3 934.7 647.3 934.4V860C647.3 859.6 647.2 859.3 647.1 859 646.9 858.7 646.7 858.5 646.4 858.3 646.2 858.1 645.9 857.9 645.6 857.8 645.2 857.8 644.9 857.8 644.6 857.8 581 872.9 515.8 880.4 450.4 880.3 337.9 880.3 307.6 827.5 299 805.5 292 786.5 287.6 766.7 285.8 746.5 285.8 746.2 285.9 745.8 286 745.5 286.1 745.2 286.3 744.9 286.6 744.7 286.9 744.5 287.2 744.4 287.5 744.3 287.9 744.2 288.2 744.2 288.5 744.3 351.1 759.2 415.2 766.8 479.5 766.8 495 766.8 510.4 766.8 525.9 766.3 590.6 764.6 658.8 761.3 722.4 749 724 748.7 725.6 748.4 727 748 827.4 728.9 922.9 669.1 932.7 517.5 933 511.5 933.9 455 933.9 448.8 934 427.7 940.8 299.5 932.9 220.7ZM778.4 598.9H672.8V343.1C672.8 289.3 650.1 261.8 604 261.8 553.2 261.8 527.8 294.3 527.8 358.5V498.5H422.9V358.5C422.9 294.3 397.4 261.8 346.7 261.8 300.8 261.8 277.9 289.3 277.9 343.1V598.9H172.4V335.4C172.4 281.5 186.3 238.7 214.1 207 242.7 175.4 280.4 159.1 327.1 159.1 381.1 159.1 421.9 179.7 449.2 220.8L475.4 264.4 501.7 220.8C529 179.7 569.8 159.1 623.8 159.1 670.4 159.1 708 175.4 736.8 207 764.6 238.7 778.5 281.5 778.5 335.4L778.4 598.9Z",
"width": 937
},
"search": [
"logo-black"
]
},
{
"uid": "012ff5762ccb18c16bdfdd6baf187406",
"css": "volume-up",
"code": 59464,
"src": "elusive"
},
{
"uid": "1fc437d46c5ef828375b6b3de577918d",
"css": "unmod",
"code": 59465,
"src": "custom_icons",
"selected": true,
"svg": {
"path": "M350 100A200 200 0 0 0 299.3 493.5C294.4 525.5 266.7 550 233.3 550A133.3 133.3 0 0 0 100 683.4V900H200V683.4C200 664.9 214.9 650 233.3 650 278.8 650 319.9 631.9 350 602.4 380.1 631.9 421.3 650 466.7 650 485.1 650 500 664.9 500 683.4V900H600V683.4A133.3 133.3 0 0 0 466.7 550C433.3 550 405.6 525.5 400.8 493.5A200.1 200.1 0 0 0 350 100ZM250 300A100 100 0 1 0 450 300 100 100 0 0 0 250 300ZM600 420.7L670.7 350 751.8 431.1 833 350 903.7 420.7 822.6 501.8 903.7 583 833 653.7 751.9 572.5 670.7 653.7 600 583 681.1 501.9 600 420.7Z",
"width": 1000
},
"search": [
"unmod"
]
},
{
"uid": "2c1f4d302aa8281c3ed4882568669043",
"css": "mod",
"code": 59466,
"src": "custom_icons",
"selected": true,
"svg": {
"path": "M350 100A200 200 0 0 0 299.3 493.5C294.4 525.5 266.7 550 233.3 550A133.3 133.3 0 0 0 100 683.4V900H200V683.4C200 664.9 214.9 650 233.3 650 278.8 650 319.9 631.9 350 602.4 380.1 631.9 421.3 650 466.7 650 485.1 650 500 664.9 500 683.4V900H600V683.4A133.3 133.3 0 0 0 466.7 550C433.3 550 405.6 525.5 400.8 493.5A200.1 200.1 0 0 0 350 100ZM250 300A100 100 0 1 0 450 300 100 100 0 0 0 250 300ZM750 350L900 500 750 650V550H600V450H750V350Z",
"width": 1000
},
"search": [
"mod"
]
},
{
"uid": "e436d990b8c910352dba1fe3e88d9ca3",
"css": "flag",
"code": 59467,
"src": "custom_icons",
"selected": true,
"svg": {
"path": "M900 100L700 400 900 700H200V900H100V100H900ZM200 600H713.1L579.8 400 713.1 200H200V600Z",
"width": 1000
},
"search": [
"flag"
]
},
{
"uid": "c56ae110cddeae77e2e904e33f9b9718",
"css": "mange-suspicious",
"code": 59468,
"src": "custom_icons",
"selected": true,
"svg": {
"path": "M450 550V650H550V550H450ZM250.2 197.3A500 500 0 0 0 475.9 116L500 100 524 116A500 500 0 0 0 801.5 200H850L825.3 482.1A450 450 0 0 1 623.1 819.6L500 900 376.9 819.6A450 450 0 0 1 174.7 482.1L150 200H198.6C215.9 200 233.1 199.1 250.2 197.3ZM258.9 296.9L274.3 473.4A350 350 0 0 0 431.6 735.9L500 780.6 568.4 735.9A350 350 0 0 0 725.7 473.4L741.2 296.9A600 600 0 0 1 550 244.8V450H450V244.8A600 600 0 0 1 258.8 296.9Z",
"width": 1000
},
"search": [
"mange-suspicious"
]
},
{
"uid": "5408be43f7c42bccee419c6be53fdef5",
"css": "doc-text",
"code": 61686,
"src": "fontawesome"
},
{
"uid": "090b7864c67408ce29c67a49429b17a7",
"css": "fx",
"code": 59469,
"src": "custom_icons",
"selected": true,
"svg": {
"path": "M435.1 30C411.8 34 382.3 46.4 357.4 62.8 339.8 74.4 313.5 100.9 301.3 119.5 284.3 145.2 268.2 183 257.7 222.2L252.8 240.2 212.5 240.5 172.4 241 163.9 273.9C159.2 292.1 155.4 307.8 155.4 308.6 155.3 310.1 164.1 310.5 194.8 310.5 221 310.5 234.4 311 234.4 312 234.4 315.4 169.5 572.6 165.4 585.8 158.6 606.7 151.8 620.2 146.2 623.7 135.2 630.3 118.7 631.2 79.5 627.5 49.1 624.6 38.7 625.2 26.7 630.6-5.7 645.1-8.9 689.1 20.9 709.6 32.8 717.8 43.7 721 62.3 721.7 112.1 723.9 160.4 702.7 202.9 660.1 224.1 638.8 237 621.1 250.8 594 272 552.2 272.8 549.5 321.5 350.2L331.3 310.3 396.1 311.1C431.5 311.6 468.6 312.5 478.4 312.9L496.1 313.8 504.3 349.8C509 369.7 512.7 387 512.7 388.2 512.7 390.5 484.9 432.3 471.7 449.7 460.5 464.2 438.3 489.3 436.2 489.3 435.4 489.3 432.1 486.9 428.9 484 411.3 468.2 389.1 467.7 372.2 482.8 361.5 492.5 356.8 502.9 356.8 517.1 356.8 527.1 357.6 529.8 361.5 538 369.1 553.3 381.9 562.5 400.6 566.5 414.6 569.2 428.6 567.5 444.7 561 468.3 551.7 492.5 529.8 520.3 493.2 528.1 483 534.8 475 535.3 475.8 535.5 476.4 537.6 481.3 539.6 486.8 550.3 515.3 573.8 553.3 591.8 571.4 611 590.8 636.3 600.7 666.5 600.7 684.1 600.7 695.5 597.4 710.4 587.7 732.3 573.5 743.6 560.4 747.8 544.2 752.5 526 749.3 513.3 736.8 501 727.9 491.9 721.4 489.3 708.7 489.3 702.7 489.3 698.4 490.4 691.7 493.8 680.7 499.2 676.3 504.2 669.1 519.6 666.1 526.3 663.1 531.7 662.5 531.7 656.8 531.7 643.8 516.2 635.2 499.5 626.8 483.4 625.8 479.9 613.8 423.5L603.5 375.9 608.8 367.8C625.8 342.2 649.2 317.3 661.4 311.9 665.6 310 675.3 308.1 686.7 306.7 697 305.6 709.1 303.7 713.8 302.5 733.4 297.5 746.9 281.4 747.5 262.2 748.4 229.1 718.8 208.4 683.3 217.5 655.7 224.7 616.6 252.5 593.3 281.8 589.6 286.4 586.4 290 585.9 290 585.5 290 583.4 284.2 581.2 277.1 573.9 253.9 563.1 240.7 548.3 237.5 543.3 236.4 515 236.7 452.8 238.2 404 239.4 360.8 240.2 356.7 239.9L349.2 239.5 355.4 213.1C367.1 163.2 377.9 136.2 392.1 121.9 399 115 400.3 114.3 406.3 114.3 409.9 114.3 422 115.9 433.2 118.1 481.8 127 494.5 125.5 510.6 109 528.1 91 526.8 61.8 507.6 44.1 491.7 29.6 466.6 24.6 435.1 30Z",
"width": 1000
},
"search": [
"fx"
]
},
{
"uid": "3c736f432d2e3ec0d6b5d2f193b93345",
"css": "artist",
"code": 59470,
"src": "custom_icons",
"selected": true,
"svg": {
"path": "M200 500V535C200 685 320 800 465 800H535A95 95 0 0 0 630 705V690A150 150 0 0 1 680 585L770 500C790 480 800 455 800 430V400L880 320C895 355 900 390 900 430A200 200 0 0 1 835 575L750 660A50 50 0 0 0 735 690V705A195 195 0 0 1 535 900H470C270 900 105 735 105 535V500A400 400 0 0 1 505 100H575C675 100 760 140 825 210L805 225 705 325 605 405V450A150 150 0 0 1 455 600H300L335 565C350 550 360 525 360 500A150 150 0 0 1 510 350H545L625 250 650 225 660 215C630 205 605 200 570 200H500A300 300 0 0 0 200 500Z",
"width": 1000
},
"search": [
"artist"
]
} }
] ]
} }

View file

@ -8,7 +8,7 @@
"es6", "es6",
"es2017" "es2017"
], ],
"target": "es2017", "target": "esnext",
"module": "es6", "module": "es6",
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {

View file

@ -1,60 +1,51 @@
{ {
"name": "frankerfacez", "name": "frankerfacez",
"author": "Dan Salvato LLC", "author": "Dan Salvato LLC",
"version": "4.32.1", "version": "4.55.1",
"description": "FrankerFaceZ is a Twitch enhancement suite.", "description": "FrankerFaceZ is a Twitch enhancement suite.",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"scripts": { "scripts": {
"start": "webpack-dev-server --config webpack.web.dev.js", "start": "pnpm dev",
"eslint": "eslint \"src/**/*.{js,jsx,vue}\"", "eslint": "eslint \"src/**/*.{js,jsx,vue}\"",
"clean": "rimraf dist", "clean": "rimraf dist",
"dev": "webpack-dev-server --config webpack.web.dev.js", "dev": "cross-env NODE_ENV=development webpack serve",
"dev:prod": "webpack-dev-server --config webpack.web.dev.prod.js", "dev:prod": "cross-env NODE_ENV=production webpack serve",
"build": "pnpm build:prod", "build": "pnpm build:prod",
"build:stats": "cross-env NODE_ENV=production webpack --config webpack.web.prod.js --json > stats.json", "build:stats": "cross-env NODE_ENV=production webpack build --json > stats.json",
"build:prod": "cross-env NODE_ENV=production webpack --config webpack.web.prod.js", "build:prod": "cross-env NODE_ENV=production webpack build",
"build:dev": "pnpm clean && webpack --config webpack.web.dev.js", "build:dev": "cross-env NODE_ENV=development webpack build",
"font": "pnpm font:edit", "font": "pnpm font:edit",
"font:edit": "fontello-cli --cli-config fontello.client.json edit", "font:edit": "fontello-cli --cli-config fontello.client.json edit",
"font:save": "fontello-cli --cli-config fontello.client.json save && pnpm font:update", "font:save": "fontello-cli --cli-config fontello.client.json save && pnpm font:update",
"font:update": "node bin/update_fonts" "font:update": "node bin/update_fonts"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.16.0",
"@babel/eslint-parser": "^7.16.0",
"@babel/plugin-proposal-class-properties": "^7.16.0",
"@babel/plugin-proposal-nullish-coalescing-operator": "^7.16.0",
"@babel/plugin-proposal-object-rest-spread": "^7.16.0",
"@babel/plugin-proposal-optional-chaining": "^7.16.0",
"@babel/plugin-syntax-dynamic-import": "^7.8.3",
"@babel/plugin-transform-react-jsx": "^7.16.0",
"@ffz/fontello-cli": "^1.0.4", "@ffz/fontello-cli": "^1.0.4",
"@webpack-cli/serve": "^1.6.0", "browserslist": "^4.21.10",
"babel-loader": "^8.2.3", "copy-webpack-plugin": "^11.0.0",
"clean-webpack-plugin": "^3.0.0",
"copy-webpack-plugin": "^5.1.2",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"css-loader": "^3.6.0", "css-loader": "^6.8.1",
"eslint": "^7.32.0", "esbuild-loader": "^4.0.2",
"eslint-plugin-react": "^7.26.1", "eslint": "^8.48.0",
"eslint-plugin-vue": "^6.2.2", "eslint-plugin-import": "^2.28.1",
"extract-loader": "^2.0.1", "eslint-plugin-react": "^7.33.2",
"file-loader": "^4.3.0", "eslint-plugin-vue": "^9.17.0",
"extract-loader": "^5.1.0",
"file-loader": "^6.2.0",
"json-loader": "^0.5.7", "json-loader": "^0.5.7",
"raw-loader": "^3.1.0", "minify-graphql-loader": "^1.0.2",
"rimraf": "^3.0.2", "raw-loader": "^4.0.2",
"sass": "^1.43.4", "rimraf": "^5.0.1",
"sass-loader": "^7.3.1", "sass": "^1.66.1",
"semver": "^7.3.5", "sass-loader": "^13.3.2",
"terser-webpack-plugin": "4", "semver": "^7.5.4",
"vue-loader": "^15.9.8", "vue-loader": "^15.10.2",
"vue-template-compiler": "^2.6.14", "vue-template-compiler": "^2.6.14",
"webpack": "^4.46.0", "webpack": "^5.88.2",
"webpack-cli": "^4.9.1", "webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.4.0", "webpack-dev-server": "^4.15.1",
"webpack-manifest-plugin": "^4.0.2", "webpack-manifest-plugin": "^5.0.0"
"webpack-merge": "^4.2.2"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@ -70,8 +61,8 @@
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"graphql": "^16.0.1", "graphql": "^16.0.1",
"graphql-tag": "^2.12.6", "graphql-tag": "^2.12.6",
"jszip": "^3.7.1",
"js-cookie": "^2.2.1", "js-cookie": "^2.2.1",
"jszip": "^3.7.1",
"markdown-it": "^12.2.0", "markdown-it": "^12.2.0",
"markdown-it-link-attributes": "^3.0.0", "markdown-it-link-attributes": "^3.0.0",
"mnemonist": "^0.38.5", "mnemonist": "^0.38.5",
@ -82,6 +73,7 @@
"sortablejs": "^1.14.0", "sortablejs": "^1.14.0",
"sourcemapped-stacktrace": "^1.1.11", "sourcemapped-stacktrace": "^1.1.11",
"text-diff": "^1.0.1", "text-diff": "^1.0.1",
"u8-mqtt": "^0.5.3",
"vue": "^2.6.14", "vue": "^2.6.14",
"vue-clickaway": "^2.2.2", "vue-clickaway": "^2.2.2",
"vue-color": "^2.8.1", "vue-color": "^2.8.1",

7155
pnpm-lock.yaml generated

File diff suppressed because it is too large Load diff

Binary file not shown.

View file

@ -1,7 +1,7 @@
<?xml version="1.0" standalone="no"?> <?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"> <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<svg xmlns="http://www.w3.org/2000/svg"> <svg xmlns="http://www.w3.org/2000/svg">
<metadata>Copyright (C) 2021 by original authors @ fontello.com</metadata> <metadata>Copyright (C) 2023 by original authors @ fontello.com</metadata>
<defs> <defs>
<font id="ffz-fontello" horiz-adv-x="1000" > <font id="ffz-fontello" horiz-adv-x="1000" >
<font-face font-family="ffz-fontello" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" /> <font-face font-family="ffz-fontello" font-weight="400" font-stretch="normal" units-per-em="1000" ascent="850" descent="-150" />
@ -148,6 +148,22 @@
<glyph glyph-name="right-open" unicode="&#xe846;" d="M618 361l-414-415q-11-10-25-10t-25 10l-93 93q-11 11-11 25t11 25l296 297-296 296q-11 11-11 25t11 25l93 93q10 11 25 11t25-11l414-414q10-11 10-25t-10-25z" horiz-adv-x="714.3" /> <glyph glyph-name="right-open" unicode="&#xe846;" d="M618 361l-414-415q-11-10-25-10t-25 10l-93 93q-11 11-11 25t11 25l296 297-296 296q-11 11-11 25t11 25l93 93q10 11 25 11t25-11l414-414q10-11 10-25t-10-25z" horiz-adv-x="714.3" />
<glyph glyph-name="mastodon" unicode="&#xe847;" d="M933 629c-14 106-108 190-219 206-18 3-89 13-253 13h-2c-164 0-199-10-218-13-107-15-206-91-230-198-11-53-12-111-10-165 3-77 4-154 11-231 5-51 13-102 25-151 23-93 115-169 205-200 96-33 200-38 299-16 11 3 22 6 32 9 24 7 53 16 73 31 1 0 1 0 1 1 0 0 0 0 0 1v74c0 0 0 1 0 1 0 0 0 0-1 1 0 0 0 0 0 0-1 0-1 0-1 0-64-15-129-22-195-22-112 0-142 53-151 75-7 19-11 38-13 59 0 0 0 0 0 1 0 0 0 0 1 0 0 1 0 1 1 1 0 0 0 0 1 0 62-15 126-23 191-23 15 0 30 0 46 1 65 1 133 5 196 17 2 0 4 1 5 1 100 19 196 79 206 231 0 6 1 62 1 68 0 21 7 150-1 228z m-155-378h-105v256c0 54-23 81-69 81-51 0-76-32-76-96v-140h-105v140c0 64-26 96-76 96-46 0-69-27-69-81v-256h-106v264c0 54 14 96 42 128 29 32 66 48 113 48 54 0 95-21 122-62l26-43 27 43c27 41 68 62 122 62 46 0 84-16 113-48 28-32 42-74 42-128l-1-264z" horiz-adv-x="937" />
<glyph glyph-name="volume-up" unicode="&#xe848;" d="M0 169l0 360 203 0 307 250 0-858-307 248-203 0z m563 33q62 63 62 151t-62 152l60 65q90-90 92-219 0-125-92-213z m101-105q106 105 106 256t-106 258l66 62q131-133 131-319t-131-321z m100-98q146 147 146 354t-146 353l62 65q82-82 128-190t46-227-46-229-128-190z" horiz-adv-x="1000" />
<glyph glyph-name="unmod" unicode="&#xe849;" d="M350 750a200 200 0 0 1-51-393c-5-32-32-57-66-57a133 133 0 0 1-133-133v-217h100v217c0 18 15 33 33 33 46 0 87 18 117 48 30-30 71-48 117-48 18 0 33-15 33-33v-217h100v217a133 133 0 0 1-133 133c-34 0-61 25-66 57a200 200 0 0 1-51 393z m-100-200a100 100 0 1 1 200 0 100 100 0 0 1-200 0z m350-121l71 71 81-81 81 81 71-71-81-81 81-81-71-71-81 82-81-82-71 71 81 81-81 81z" horiz-adv-x="1000" />
<glyph glyph-name="mod" unicode="&#xe84a;" d="M350 750a200 200 0 0 1-51-393c-5-32-32-57-66-57a133 133 0 0 1-133-133v-217h100v217c0 18 15 33 33 33 46 0 87 18 117 48 30-30 71-48 117-48 18 0 33-15 33-33v-217h100v217a133 133 0 0 1-133 133c-34 0-61 25-66 57a200 200 0 0 1-51 393z m-100-200a100 100 0 1 1 200 0 100 100 0 0 1-200 0z m500-50l150-150-150-150v100h-150v100h150v100z" horiz-adv-x="1000" />
<glyph glyph-name="flag" unicode="&#xe84b;" d="M900 750l-200-300 200-300h-700v-200h-100v800h800z m-700-500h513l-133 200 133 200h-513v-400z" horiz-adv-x="1000" />
<glyph glyph-name="mange-suspicious" unicode="&#xe84c;" d="M450 300v-100h100v100h-100z m-200 353a500 500 0 0 1 226 81l24 16 24-16a500 500 0 0 1 278-84h48l-25-282a450 450 0 0 0-202-338l-123-80-123 80a450 450 0 0 0-202 338l-25 282h49c17 0 34 1 51 3z m9-100l15-176a350 350 0 0 1 158-263l68-45 68 45a350 350 0 0 1 158 263l15 176a600 600 0 0 0-191 52v-205h-100v205a600 600 0 0 0-191-52z" horiz-adv-x="1000" />
<glyph glyph-name="fx" unicode="&#xe84d;" d="M435 820c-23-4-53-16-78-33-17-11-43-38-56-56-17-26-33-64-43-103l-5-18-40 0-41-1-8-33c-5-18-9-34-9-35 0-1 9-1 40-1 26 0 39-1 39-2 0-3-64-261-69-274-6-21-13-34-19-38-11-6-27-7-66-3-31 2-41 2-53-4-33-14-36-58-6-79 12-8 23-11 41-12 50-2 98 19 141 62 21 21 34 39 48 66 21 42 22 45 71 244l9 40 65-1c36-1 73-1 82-2l18-1 8-36c5-20 9-37 9-38 0-2-28-44-41-62-11-14-34-39-36-39-1 0-4 2-7 5-18 16-40 16-57 1-10-9-15-20-15-34 0-10 1-13 5-21 7-15 20-24 39-28 14-3 28-1 44 5 23 9 48 31 75 68 8 10 15 18 15 17 1 0 3-5 5-11 10-28 34-66 52-84 19-20 44-30 75-30 17 0 29 4 43 13 22 15 34 28 38 44 5 18 1 31-11 43-9 9-16 12-28 12-6 0-11-1-17-5-11-5-16-10-23-26-3-6-6-12-6-12-6 0-19 16-28 33-8 16-9 19-21 76l-10 47 5 8c17 26 40 51 52 56 5 2 14 4 26 5 10 1 22 3 27 5 19 5 33 21 34 40 0 33-29 54-65 45-27-8-66-35-90-65-3-4-7-8-7-8 0 0-3 6-5 13-7 23-18 36-33 40-5 1-33 0-95-1-49-1-92-2-96-2l-8 1 6 26c12 50 23 77 37 91 7 7 8 8 14 8 4 0 16-2 27-4 49-9 62-7 78 9 17 18 16 47-3 65-16 14-41 19-73 14z" horiz-adv-x="1000" />
<glyph glyph-name="artist" unicode="&#xe84e;" d="M200 350v-35c0-150 120-265 265-265h70a95 95 0 0 1 95 95v15a150 150 0 0 0 50 105l90 85c20 20 30 45 30 70v30l80 80c15-35 20-70 20-110a200 200 0 0 0-65-145l-85-85a50 50 0 0 1-15-30v-15a195 195 0 0 0-200-195h-65c-200 0-365 165-365 365v35a400 400 0 0 0 400 400h70c100 0 185-40 250-110l-20-15-100-100-100-80v-45a150 150 0 0 0-150-150h-155l35 35c15 15 25 40 25 65a150 150 0 0 0 150 150h35l80 100 25 25 10 10c-30 10-55 15-90 15h-70a300 300 0 0 1-300-300z" horiz-adv-x="1000" />
<glyph glyph-name="move" unicode="&#xf047;" d="M1000 350q0-14-11-25l-142-143q-11-11-26-11t-25 11-10 25v72h-215v-215h72q14 0 25-10t11-25-11-25l-143-143q-10-11-25-11t-25 11l-143 143q-11 10-11 25t11 25 25 10h72v215h-215v-72q0-14-10-25t-25-11-25 11l-143 143q-11 11-11 25t11 25l143 143q10 11 25 11t25-11 10-25v-72h215v215h-72q-14 0-25 10t-11 25 11 26l143 142q11 11 25 11t25-11l143-142q11-11 11-26t-11-25-25-10h-72v-215h215v72q0 14 10 25t25 11 26-11l142-143q11-10 11-25z" horiz-adv-x="1000" /> <glyph glyph-name="move" unicode="&#xf047;" d="M1000 350q0-14-11-25l-142-143q-11-11-26-11t-25 11-10 25v72h-215v-215h72q14 0 25-10t11-25-11-25l-143-143q-10-11-25-11t-25 11l-143 143q-11 10-11 25t11 25 25 10h72v215h-215v-72q0-14-10-25t-25-11-25 11l-143 143q-11 11-11 25t11 25l143 143q10 11 25 11t25-11 10-25v-72h215v215h-72q-14 0-25 10t-11 25 11 26l143 142q11 11 25 11t25-11l143-142q11-11 11-26t-11-25-25-10h-72v-215h215v72q0 14 10 25t25 11 26-11l142-143q11-10 11-25z" horiz-adv-x="1000" />
<glyph glyph-name="link-ext" unicode="&#xf08e;" d="M786 332v-178q0-67-47-114t-114-47h-464q-67 0-114 47t-47 114v464q0 66 47 113t114 48h393q7 0 12-5t5-13v-36q0-8-5-13t-12-5h-393q-37 0-63-26t-27-63v-464q0-37 27-63t63-27h464q37 0 63 27t26 63v178q0 8 5 13t13 5h36q8 0 13-5t5-13z m214 482v-285q0-15-11-25t-25-11-25 11l-98 98-364-364q-5-6-13-6t-12 6l-64 64q-6 5-6 12t6 13l364 364-98 98q-11 11-11 25t11 25 25 11h285q15 0 25-11t11-25z" horiz-adv-x="1000" /> <glyph glyph-name="link-ext" unicode="&#xf08e;" d="M786 332v-178q0-67-47-114t-114-47h-464q-67 0-114 47t-47 114v464q0 66 47 113t114 48h393q7 0 12-5t5-13v-36q0-8-5-13t-12-5h-393q-37 0-63-26t-27-63v-464q0-37 27-63t63-27h464q37 0 63 27t26 63v178q0 8 5 13t13 5h36q8 0 13-5t5-13z m214 482v-285q0-15-11-25t-25-11-25 11l-98 98-364-364q-5-6-13-6t-12 6l-64 64q-6 5-6 12t6 13l364 364-98 98q-11 11-11 25t11 25 25 11h285q15 0 25-11t11-25z" horiz-adv-x="1000" />
@ -172,6 +188,8 @@
<glyph glyph-name="upload-cloud" unicode="&#xf0ee;" d="M714 368q0 8-5 13l-196 196q-5 5-13 5t-13-5l-196-196q-5-6-5-13 0-8 5-13t13-5h125v-196q0-8 5-13t12-5h108q7 0 12 5t5 13v196h125q8 0 13 5t5 13z m357-161q0-89-62-151t-152-63h-607q-103 0-177 73t-73 177q0 72 39 134t105 92q-1 17-1 24 0 118 84 202t202 84q87 0 159-49t105-129q40 35 93 35 59 0 101-42t42-101q0-43-23-77 72-17 119-76t46-133z" horiz-adv-x="1071.4" /> <glyph glyph-name="upload-cloud" unicode="&#xf0ee;" d="M714 368q0 8-5 13l-196 196q-5 5-13 5t-13-5l-196-196q-5-6-5-13 0-8 5-13t13-5h125v-196q0-8 5-13t12-5h108q7 0 12 5t5 13v196h125q8 0 13 5t5 13z m357-161q0-89-62-151t-152-63h-607q-103 0-177 73t-73 177q0 72 39 134t105 92q-1 17-1 24 0 118 84 202t202 84q87 0 159-49t105-129q40 35 93 35 59 0 101-42t42-101q0-43-23-77 72-17 119-76t46-133z" horiz-adv-x="1071.4" />
<glyph glyph-name="doc-text" unicode="&#xf0f6;" d="M819 638q16-16 27-42t11-50v-642q0-23-15-38t-38-16h-750q-23 0-38 16t-16 38v892q0 23 16 38t38 16h500q22 0 49-11t42-27z m-248 136v-210h210q-5 17-12 23l-175 175q-6 7-23 12z m215-853v572h-232q-23 0-38 16t-16 37v233h-429v-858h715z m-572 483q0 7 5 12t13 5h393q8 0 13-5t5-12v-36q0-8-5-13t-13-5h-393q-8 0-13 5t-5 13v36z m411-125q8 0 13-5t5-13v-36q0-8-5-13t-13-5h-393q-8 0-13 5t-5 13v36q0 8 5 13t13 5h393z m0-143q8 0 13-5t5-13v-36q0-8-5-13t-13-5h-393q-8 0-13 5t-5 13v36q0 8 5 13t13 5h393z" horiz-adv-x="857.1" />
<glyph glyph-name="reply" unicode="&#xf112;" d="M1000 225q0-93-71-252-1-4-6-13t-7-17-7-12q-7-10-16-10-8 0-13 6t-5 14q0 5 1 15t2 13q3 38 3 69 0 56-10 101t-27 77-45 56-59 39-74 24-86 12-98 3h-125v-143q0-14-10-25t-26-11-25 11l-285 286q-11 10-11 25t11 25l285 286q11 10 25 10t26-10 10-25v-143h125q398 0 488-225 30-75 30-186z" horiz-adv-x="1000" /> <glyph glyph-name="reply" unicode="&#xf112;" d="M1000 225q0-93-71-252-1-4-6-13t-7-17-7-12q-7-10-16-10-8 0-13 6t-5 14q0 5 1 15t2 13q3 38 3 69 0 56-10 101t-27 77-45 56-59 39-74 24-86 12-98 3h-125v-143q0-14-10-25t-26-11-25 11l-285 286q-11 10-11 25t11 25l285 286q11 10 25 10t26-10 10-25v-143h125q398 0 488-225 30-75 30-186z" horiz-adv-x="1000" />
<glyph glyph-name="smile" unicode="&#xf118;" d="M633 250q-21-67-77-109t-127-41-128 41-77 109q-4 14 3 27t21 18q14 4 27-2t17-22q14-44 52-72t85-28 84 28 52 72q4 15 18 22t27 2 21-18 2-27z m-276 243q0-30-21-51t-50-21-51 21-21 51 21 50 51 21 50-21 21-50z m286 0q0-30-21-51t-51-21-50 21-21 51 21 50 50 21 51-21 21-50z m143-143q0 73-29 139t-76 114-114 76-138 28-139-28-114-76-76-114-29-139 29-139 76-113 114-77 139-28 138 28 114 77 76 113 29 139z m71 0q0-117-57-215t-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58 215-58 156-156 57-215z" horiz-adv-x="857.1" /> <glyph glyph-name="smile" unicode="&#xf118;" d="M633 250q-21-67-77-109t-127-41-128 41-77 109q-4 14 3 27t21 18q14 4 27-2t17-22q14-44 52-72t85-28 84 28 52 72q4 15 18 22t27 2 21-18 2-27z m-276 243q0-30-21-51t-50-21-51 21-21 51 21 50 51 21 50-21 21-50z m286 0q0-30-21-51t-51-21-50 21-21 51 21 50 50 21 51-21 21-50z m143-143q0 73-29 139t-76 114-114 76-138 28-139-28-114-76-76-114-29-139 29-139 76-113 114-77 139-28 138 28 114 77 76 113 29 139z m71 0q0-117-57-215t-156-156-215-58-216 58-155 156-58 215 58 215 155 156 216 58 215-58 156-156 57-215z" horiz-adv-x="857.1" />

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Before After
Before After

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -5,7 +5,7 @@
// ============================================================================ // ============================================================================
import Module from 'utilities/module'; import Module from 'utilities/module';
import { SERVER } from 'utilities/constants'; import { EXTENSION, SERVER_OR_EXT } from 'utilities/constants';
import { createElement } from 'utilities/dom'; import { createElement } from 'utilities/dom';
import { timeout, has } from 'utilities/object'; import { timeout, has } from 'utilities/object';
import { getBuster } from 'utilities/time'; import { getBuster } from 'utilities/time';
@ -24,6 +24,7 @@ export default class AddonManager extends Module {
this.inject('settings'); this.inject('settings');
this.inject('i18n'); this.inject('i18n');
this.inject('load_tracker');
this.load_requires = ['settings']; this.load_requires = ['settings'];
@ -33,6 +34,8 @@ export default class AddonManager extends Module {
this.reload_required = false; this.reload_required = false;
this.addons = {}; this.addons = {};
this.enabled_addons = []; this.enabled_addons = [];
this.load_tracker.schedule('chat-data', 'addon-initial');
} }
onLoad() { onLoad() {
@ -57,6 +60,8 @@ export default class AddonManager extends Module {
isAddonExternal: id => this.isAddonExternal(id), isAddonExternal: id => this.isAddonExternal(id),
enableAddon: id => this.enableAddon(id), enableAddon: id => this.enableAddon(id),
disableAddon: id => this.disableAddon(id), disableAddon: id => this.disableAddon(id),
reloadAddon: id => this.reloadAddon(id),
canReloadAddon: id => this.canReloadAddon(id),
isReloadRequired: () => this.reload_required, isReloadRequired: () => this.reload_required,
refresh: () => window.location.reload(), refresh: () => window.location.reload(),
@ -64,15 +69,16 @@ export default class AddonManager extends Module {
off: (...args) => this.off(...args) off: (...args) => this.off(...args)
}); });
this.settings.add('addons.dev.server', { if ( ! EXTENSION )
default: false, this.settings.add('addons.dev.server', {
ui: { default: false,
path: 'Add-Ons >> Development', ui: {
title: 'Use Local Development Server', path: 'Add-Ons >> Development',
description: 'Attempt to load add-ons from local development server on port 8001.', title: 'Use Local Development Server',
component: 'setting-check-box' description: 'Attempt to load add-ons from local development server on port 8001.',
} component: 'setting-check-box'
}); }
});
this.on('i18n:update', this.rebuildAddonSearch, this); this.on('i18n:update', this.rebuildAddonSearch, this);
@ -90,6 +96,7 @@ export default class AddonManager extends Module {
this.log.capture(err); this.log.capture(err);
}); });
this.load_tracker.notify('chat-data', 'addon-initial');
this.emit(':ready'); this.emit(':ready');
}); });
} }
@ -145,14 +152,25 @@ export default class AddonManager extends Module {
async loadAddonData() { async loadAddonData() {
const [cdn_data, local_data] = await Promise.all([ const [cdn_data, local_data] = await Promise.all([
fetchJSON(`${SERVER}/script/addons.json?_=${getBuster(30)}`), fetchJSON(`${SERVER_OR_EXT}/addons.json?_=${getBuster(30)}`),
this.settings.get('addons.dev.server') ?
fetchJSON(`https://localhost:8001/script/addons.json?_=${getBuster()}`) : null // Do not attempt to load local add-ons if using the extension, as
// loading external code is against the policy of basically everyone.
(! EXTENSION && this.settings.get('addons.dev.server'))
? fetchJSON(`https://localhost:8001/script/addons.json?_=${getBuster()}`)
: null
]); ]);
if ( Array.isArray(cdn_data) ) if ( Array.isArray(cdn_data) ) {
for(const addon of cdn_data ) // We need to handle relative URLs for addon logos.
const base_path = `${SERVER_OR_EXT}/addons/`;
for(const addon of cdn_data ) {
if ( addon.icon )
addon.icon = (new URL(addon.icon, base_path)).toString();
this.addAddon(addon, false); this.addAddon(addon, false);
}
}
if ( Array.isArray(local_data) ) { if ( Array.isArray(local_data) ) {
this.has_dev = true; this.has_dev = true;
@ -280,16 +298,168 @@ export default class AddonManager extends Module {
return module.external || (module.constructor && module.constructor.external); return module.external || (module.constructor && module.constructor.external);
} }
canReloadAddon(id) {
// Obviously we can't reload it if we don't have it.
if ( ! this.hasAddon(id) )
throw new Error(`Unknown add-on id: ${id}`);
// If the module isn't available, we can't reload it.
let module = this.resolve(`addon.${id}`);
if ( ! module )
return false;
// If the module cannot be disabled, or it cannot be unloaded, then
// we can't reload it.
if ( ! module.canDisable() || ! module.canUnload() )
return false;
// Check each child.
if ( module.children )
for(const child of Object.values(module.children))
if ( ! child.canDisable() || ! child.canUnload() )
return false;
// If we got here, we might be able to reload it.
return true;
}
async fullyUnloadModule(module) {
if ( ! module )
return;
if ( module.children )
for(const child of Object.values(module.children))
await this.fullyUnloadModule(child);
await module.disable();
await module.unload();
// Clean up parent references.
if ( module.parent && 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;
}
// Clean up the global reference.
if ( this.__modules[module.__path] === module )
delete this.__modules[module.__path]; /* = [
module.dependents,
module.load_dependents,
module.references
];*/
// Remove any events we didn't unregister.
this.offContext(null, module);
// Do the same for settings.
for(const ctx of this.settings.__contexts)
ctx.offContext(null, module);
// Clean up all settings.
for(const [key, def] of Array.from(this.settings.definitions.entries())) {
if ( def && def.__source === module.__path ) {
this.settings.remove(key);
}
}
// Clean up the logger too.
module.__log = null;
}
async reloadAddon(id) {
const addon = this.getAddon(id),
button = this.resolve('site.menu_button');
if ( ! addon )
throw new Error(`Unknown add-on id: ${id}`);
const start = performance.now();
// Yeet the module into the abyss.
// This will also yeet all children.
let module = this.resolve(`addon.${id}`);
if ( module )
try {
await this.fullyUnloadModule(module);
} catch(err) {
if ( button )
button.addToast({
title_i18n: 'addons.reload.toast-error',
title: 'Error Reloading Add-On',
text_i18n: 'addons.reload.toast-error.unload',
text: 'Unable to unload existing modules for add-on "{addon_id}":\n\n{error}',
icon: 'ffz-i-attention',
addon_id: id,
error: String(err)
});
throw err;
}
// Is there a script tab?
let el = document.querySelector(`script#ffz-loaded-addon-${addon.id}`);
if ( el )
el.remove();
// Do unnatural things to webpack.
if ( window.ffzAddonsWebpackJsonp )
window.ffzAddonsWebpackJsonp = undefined;
// Now, reload it all~
try {
await this._enableAddon(id);
} catch(err) {
if ( button )
button.addToast({
title_i18n: 'addons.reload.toast-error',
title: 'Error Reloading Add-On',
text_i18n: 'addons.reload.toast-error.reload',
text: 'Unable to load new module for add-on "{addon_id}":\n\n{error}',
error: String(err),
icon: 'ffz-i-attention',
addon_id: id
});
throw err;
}
const end = performance.now();
if ( button )
button.addToast({
title_i18n: 'addons.reload.toast',
title: 'Reloaded Add-On',
text_i18n: 'addons.reload.toast.text',
text: 'Successfully reloaded add-on "{addon_id}" in {duration}ms.',
icon: 'ffz-i-info',
addon_id: id,
timeout: 5000,
duration: Math.round(100 * (end - start)) / 100
});
}
async _enableAddon(id) { async _enableAddon(id) {
const addon = this.getAddon(id); const addon = this.getAddon(id);
if ( ! addon ) if ( ! addon )
throw new Error(`Unknown add-on id: ${id}`); throw new Error(`Unknown add-on id: ${id}`);
if ( Array.isArray(addon.load_events) )
for(const event of addon.load_events)
this.load_tracker.schedule(event, `addon.${id}`);
await this.loadAddon(id); await this.loadAddon(id);
const module = this.resolve(`addon.${id}`); const module = this.resolve(`addon.${id}`);
if ( module && ! module.enabled ) if ( module && ! module.enabled )
await module.enable(); await module.enable();
if ( Array.isArray(addon.load_events) )
for(const event of addon.load_events)
this.load_tracker.notify(event, `addon.${id}`, false);
} }
async loadAddon(id) { async loadAddon(id) {
@ -311,7 +481,7 @@ export default class AddonManager extends Module {
document.head.appendChild(createElement('script', { document.head.appendChild(createElement('script', {
id: `ffz-loaded-addon-${addon.id}`, id: `ffz-loaded-addon-${addon.id}`,
type: 'text/javascript', type: 'text/javascript',
src: addon.src || `${addon.dev ? 'https://localhost:8001' : SERVER}/script/addons/${addon.id}/script.js?_=${getBuster(30)}`, src: addon.src || `${addon.dev ? 'https://localhost:8001/script' : SERVER_OR_EXT}/addons/${addon.id}/script.js?_=${getBuster(30)}`,
crossorigin: 'anonymous' crossorigin: 'anonymous'
})); }));

View file

@ -208,16 +208,17 @@ class FFZBridge extends Module {
FFZBridge.Logger = Logger; FFZBridge.Logger = Logger;
const VER = FFZBridge.version_info = { const VER = FFZBridge.version_info = Object.freeze({
major: __version_major__, major: __version_major__,
minor: __version_minor__, minor: __version_minor__,
revision: __version_patch__, revision: __version_patch__,
extra: __version_prerelease__?.length && __version_prerelease__[0], extra: __version_prerelease__?.length && __version_prerelease__[0],
commit: __git_commit__, commit: __git_commit__,
build: __webpack_hash__, build: __version_build__,
hash: __webpack_hash__,
toString: () => toString: () =>
`${VER.major}.${VER.minor}.${VER.revision}${VER.extra || ''}${DEBUG ? '-dev' : ''}` `${VER.major}.${VER.minor}.${VER.revision}${VER.build ? `.${VER.build}` : ''}${VER.extra || ''}${DEBUG ? '-dev' : ''}`
} });
window.FFZBridge = FFZBridge; window.FFZBridge = FFZBridge;
window.ffz_bridge = new FFZBridge(); window.ffz_bridge = new FFZBridge();

View file

@ -13,10 +13,16 @@ import SettingsManager from './settings/index';
import AddonManager from './addons'; import AddonManager from './addons';
import ExperimentManager from './experiments'; import ExperimentManager from './experiments';
import {TranslationManager} from './i18n'; import {TranslationManager} from './i18n';
import PubSubClient from './pubsub';
import StagingSelector from './staging';
import LoadTracker from './load_tracker';
import Site from './sites/clips'; import Site from './sites/clips';
import Vue from 'utilities/vue';
import Tooltips from 'src/modules/tooltips'; import Tooltips from 'src/modules/tooltips';
import Chat from 'src/modules/chat'; import Chat from 'src/modules/chat';
import EmoteCard from 'src/modules/emote_card';
class FrankerFaceZ extends Module { class FrankerFaceZ extends Module {
constructor() { constructor() {
@ -52,30 +58,52 @@ class FrankerFaceZ extends Module {
this.inject('settings', SettingsManager); this.inject('settings', SettingsManager);
this.inject('experiments', ExperimentManager); this.inject('experiments', ExperimentManager);
this.inject('i18n', TranslationManager); this.inject('i18n', TranslationManager);
this.inject('staging', StagingSelector);
this.inject('load_tracker', LoadTracker);
this.inject('pubsub', PubSubClient);
this.inject('site', Site); this.inject('site', Site);
this.inject('addons', AddonManager); this.inject('addons', AddonManager);
this.register('vue', Vue);
// ======================================================================== // ========================================================================
// Startup // Startup
// ======================================================================== // ========================================================================
this.inject('tooltips', Tooltips); this.inject('tooltips', Tooltips);
this.register('chat', Chat);
this.enable().then(() => { this.register('chat', Chat);
const duration = performance.now() - start_time; this.register('emote_card', EmoteCard);
this.core_log.info(`Initialization complete in ${duration.toFixed(5)}ms.`);
this.log.init = false; this.enable()
}).catch(err => { .then(() => this.enableInitialModules())
this.core_log.error(`An error occurred during initialization.`, err); .then(() => {
this.log.init = false; const duration = performance.now() - start_time;
}); this.core_log.info(`Initialization complete in ${duration.toFixed(5)}ms.`);
this.log.init = false;
}).catch(err => {
this.core_log.error(`An error occurred during initialization.`, err);
this.log.init = false;
});
} }
static get() { static get() {
return FrankerFaceZ.instance; return FrankerFaceZ.instance;
} }
async enableInitialModules() {
const promises = [];
/* eslint guard-for-in: off */
for(const key in this.__modules) {
const module = this.__modules[key];
if ( module instanceof Module && module.should_enable )
promises.push(module.enable());
}
await Promise.all(promises);
}
// ======================================================================== // ========================================================================
// Generate Log // Generate Log
// ======================================================================== // ========================================================================
@ -121,20 +149,22 @@ ${typeof x[1] === 'string' ? x[1] : JSON.stringify(x[1], null, 4)}`).join('\n\n'
FrankerFaceZ.Logger = Logger; FrankerFaceZ.Logger = Logger;
const VER = FrankerFaceZ.version_info = { const VER = FrankerFaceZ.version_info = Object.freeze({
major: __version_major__, major: __version_major__,
minor: __version_minor__, minor: __version_minor__,
revision: __version_patch__, revision: __version_patch__,
extra: __version_prerelease__?.length && __version_prerelease__[0], extra: __version_prerelease__?.length && __version_prerelease__[0],
commit: __git_commit__, commit: __git_commit__,
build: __webpack_hash__, build: __version_build__,
hash: __webpack_hash__,
toString: () => toString: () =>
`${VER.major}.${VER.minor}.${VER.revision}${VER.extra || ''}${DEBUG ? '-dev' : ''}` `${VER.major}.${VER.minor}.${VER.revision}${VER.build ? `.${VER.build}` : ''}${VER.extra || ''}${DEBUG ? '-dev' : ''}`
} });
// We don't support addons in the player right now, so // We don't support addons in the player right now, so
FrankerFaceZ.utilities = { FrankerFaceZ.utilities = {
addon: require('utilities/addon'), addon: require('utilities/addon'),
blobs: require('utilities/blobs'),
color: require('utilities/color'), color: require('utilities/color'),
constants: require('utilities/constants'), constants: require('utilities/constants'),
dom: require('utilities/dom'), dom: require('utilities/dom'),

View file

@ -2,19 +2,21 @@
'use strict'; 'use strict';
(() => { (() => {
// Don't run on certain sub-domains. // Don't run on certain sub-domains.
if ( /^(?:localhost\.rig|blog|im|chatdepot|tmi|api|brand|dev)\./.test(location.hostname) ) if ( /^(?:localhost\.rig|blog|im|chatdepot|tmi|api|brand|dev|gql|passport)\./.test(location.hostname) )
return; return;
const DEBUG = localStorage.ffzDebugMode == 'true' && document.body.classList.contains('ffz-dev'), const DEBUG = localStorage.ffzDebugMode == 'true' && document.body.classList.contains('ffz-dev'),
HOST = location.hostname, HOST = location.hostname,
FLAVOR = SERVER = DEBUG ? '//localhost:8000' : '//cdn.frankerfacez.com',
script = document.createElement('script');
let FLAVOR =
HOST.includes('player') ? 'player' : HOST.includes('player') ? 'player' :
HOST.includes('clips') ? 'clips' : HOST.includes('clips') ? 'clips' :
(location.pathname === '/p/ffz_bridge/' ? 'bridge' : 'avalon'), (location.pathname === '/p/ffz_bridge/' ? 'bridge' : 'avalon');
SERVER = DEBUG ? '//localhost:8000' : '//cdn.frankerfacez.com',
//CLIPS = /clips\.twitch\.tv/.test(location.hostname) ? 'clips/' : '',
script = document.createElement('script'); if (FLAVOR === 'clips' && location.pathname === '/embed')
FLAVOR = 'player';
script.id = 'ffz-script'; script.id = 'ffz-script';
script.async = true; script.async = true;

29
src/entry_ext.js Normal file
View file

@ -0,0 +1,29 @@
/* eslint strict: off */
'use strict';
(() => {
// Don't run on certain sub-domains.
if ( /^(?:localhost\.rig|blog|im|chatdepot|tmi|api|brand|dev|gql|passport)\./.test(location.hostname) )
return;
const browser = globalThis.browser ?? globalThis.chrome,
HOST = location.hostname,
SERVER = browser.runtime.getURL("web"),
script = document.createElement('script');
let FLAVOR =
HOST.includes('player') ? 'player' :
HOST.includes('clips') ? 'clips' :
(location.pathname === '/p/ffz_bridge/' ? 'bridge' : 'avalon');
if (FLAVOR === 'clips' && location.pathname === '/embed')
FLAVOR = 'player';
script.id = 'ffz-script';
script.async = true;
script.crossOrigin = 'anonymous';
script.src = `${SERVER}/${FLAVOR}.js?_=${Date.now()}`;
script.dataset.path = SERVER;
document.head.appendChild(script);
})();

158
src/esbridge.js Normal file
View file

@ -0,0 +1,158 @@
'use strict';
import Logger from 'utilities/logging';
class FFZESBridge {
constructor() {
FFZESBridge.instance = this;
this.host = 'null';
this.flavor = 'esbridge';
// ========================================================================
// Logging
// ========================================================================
this.log = new Logger(null, null, null);
this.log.label = 'FFZESBridge';
this.core_log = this.log.get('core');
this.log.hi(this);
// ========================================================================
// Startup
// ========================================================================
this.onWindowMessage = this.onWindowMessage.bind(this);
this.onRuntimeDisconnect = this.onRuntimeDisconnect.bind(this);
this.onRuntimeMessage = this.onRuntimeMessage.bind(this);
window.addEventListener('message', this.onWindowMessage);
document.addEventListener('readystatechange', event => {
if ( document.documentElement )
document.documentElement.dataset.ffzEsbridge = true;
});
}
static get() {
return FFZESBridge.instance;
}
// ========================================================================
// Window Communication
// ========================================================================
windowSend(msg, transfer) {
if ( typeof msg === 'string' )
msg = {ffz_esb_type: msg};
try {
window.postMessage(
msg,
location.origin,
transfer ? (Array.isArray(transfer) ? transfer : [transfer]) : undefined
);
} catch(err) {
this.log.error('Error sending message to window.', err, msg, transfer);
}
}
onWindowMessage(event) {
if ( event.origin !== location.origin )
return;
const msg = event.data,
id = msg?.id,
type = msg?.ffz_esb_type;
if ( ! type )
return;
this.log.info('Received Message from Page', type, id, msg);
if ( type === 'init' ) {
this.received_init = true;
if ( this.active )
this.runtimeHeartbeat();
}
this.runtimeSend(msg);
}
// ========================================================================
// Runtime Communication
// ========================================================================
runtimeOpen() {
if ( this.active )
return Promise.resolve();
this.log.info('Connecting to worker.');
this.port = (window.browser ?? window.chrome).runtime.connect({name: 'esbridge'});
this.port.onMessage.addListener(this.onRuntimeMessage);
this.port.onDisconnect.addListener(this.onRuntimeDisconnect);
if ( this.received_init )
this.runtimeHeartbeat();
}
onRuntimeMessage(msg) {
this.windowSend(msg);
}
onRuntimeDisconnect(...args) {
this.log.info('Disconnected from worker.', args);
this.active = false;
this.port = null;
if ( this._heartbeat ) {
clearInterval(this._heartbeat);
this._heartbeat = null;
}
}
runtimeHeartbeat() {
if ( this._heartbeat )
return;
this._heartbeat = setInterval(() => {
if ( this.active )
this.runtimeSend('heartbeat');
}, 30000);
}
runtimeSend(msg) {
if ( typeof msg === 'string' )
msg = {ffz_esb_type: msg};
if ( ! this.active )
// We need to create our port.
this.runtimeOpen();
// Send the message, knowing we have an open port.
this.port.postMessage(msg);
}
};
FFZESBridge.Logger = Logger;
const VER = FFZESBridge.version_info = Object.freeze({
major: __version_major__,
minor: __version_minor__,
revision: __version_patch__,
extra: __version_prerelease__?.length && __version_prerelease__[0],
commit: __git_commit__,
build: __version_build__,
hash: __webpack_hash__,
toString: () =>
`${VER.major}.${VER.minor}.${VER.revision}${VER.extra || ''}${VER.build ? `+${VER.build}` : ''}`
});
window.FFZESBridge = FFZESBridge;
window.ffz_esbridge = new FFZESBridge();

View file

@ -48,6 +48,8 @@ export default class ExperimentManager extends Module {
constructor(...args) { constructor(...args) {
super(...args); super(...args);
this.get = this.getAssignment;
this.inject('settings'); this.inject('settings');
this.settings.addUI('experiments', { this.settings.addUI('experiments', {
@ -301,7 +303,12 @@ export default class ExperimentManager extends Module {
setTwitchOverride(key, value = null) { setTwitchOverride(key, value = null) {
const overrides = Cookie.getJSON(OVERRIDE_COOKIE) || {}; const overrides = Cookie.getJSON(OVERRIDE_COOKIE) || {};
overrides[key] = value; 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); Cookie.set(OVERRIDE_COOKIE, overrides, COOKIE_OPTIONS);
const core = this.resolve('site')?.getCore?.(); const core = this.resolve('site')?.getCore?.();
@ -312,12 +319,13 @@ export default class ExperimentManager extends Module {
} }
deleteTwitchOverride(key) { deleteTwitchOverride(key) {
const overrides = Cookie.getJSON(OVERRIDE_COOKIE); const overrides = Cookie.getJSON(OVERRIDE_COOKIE),
if ( ! overrides || ! has(overrides, key) ) experiments = overrides?.experiments;
if ( ! experiments || ! has(experiments, key) )
return; return;
const old_val = overrides[key]; const old_val = experiments[key];
delete overrides[key]; delete experiments[key];
Cookie.set(OVERRIDE_COOKIE, overrides, COOKIE_OPTIONS); Cookie.set(OVERRIDE_COOKIE, overrides, COOKIE_OPTIONS);
const core = this.resolve('site')?.getCore?.(); const core = this.resolve('site')?.getCore?.();
@ -328,8 +336,9 @@ export default class ExperimentManager extends Module {
} }
hasTwitchOverride(key) { // eslint-disable-line class-methods-use-this hasTwitchOverride(key) { // eslint-disable-line class-methods-use-this
const overrides = Cookie.getJSON(OVERRIDE_COOKIE); const overrides = Cookie.getJSON(OVERRIDE_COOKIE),
return overrides && has(overrides, key); experiments = overrides?.experiments;
return experiments && has(experiments, key);
} }
getTwitchAssignment(key, channel = null) { getTwitchAssignment(key, channel = null) {

View file

@ -14,5 +14,13 @@
{"value": true, "weight": 30}, {"value": true, "weight": 30},
{"value": false, "weight": 70} {"value": false, "weight": 70}
] ]
},
"pubsub": {
"name": "MQTT-Based PubSub",
"description": "An experimental new pubsub system that should be more reliable than the existing socket cluster.",
"groups": [
{"value": true, "weight": 25},
{"value": false, "weight": 75}
]
} }
} }

View file

@ -191,7 +191,7 @@ export class TranslationManager extends Module {
}, },
data: () => { data: () => {
const out = [], now = new Date; const out = [], now = new Date;
for (const [key,fmt] of Object.entries(this._.formats.date)) { for (const [key, fmt] of Object.entries(this._.formats.date)) {
out.push({ out.push({
value: key, title: `${this.formatDate(now, key)} (${key})` value: key, title: `${this.formatDate(now, key)} (${key})`
}) })
@ -451,6 +451,10 @@ export class TranslationManager extends Module {
} }
get dayjsLocale() {
return this._?._dayjs_locale;
}
get locale() { get locale() {
return this._ && this._.locale; return this._ && this._.locale;
} }
@ -661,6 +665,9 @@ export class TranslationManager extends Module {
async loadLocale(locale, chunk = null) { async loadLocale(locale, chunk = null) {
// Normalize the locale.
locale = locale.toLowerCase();
if ( locale === 'en' ) if ( locale === 'en' )
return {}; return {};
@ -710,12 +717,13 @@ export class TranslationManager extends Module {
} }
async setLocale(new_locale) { async setLocale(new_locale) {
// Normalize the locale.
new_locale = new_locale.toLowerCase();
const old_locale = this._.locale; const old_locale = this._.locale;
if ( new_locale === old_locale ) if ( new_locale === old_locale )
return []; return [];
await this.loadDayjsLocale(new_locale);
this._.locale = new_locale; this._.locale = new_locale;
this._.clear(); this._.clear();
this.log.info(`Changed Locale: ${new_locale} -- Old: ${old_locale}`); this.log.info(`Changed Locale: ${new_locale} -- Old: ${old_locale}`);
@ -726,15 +734,27 @@ export class TranslationManager extends Module {
// All the built-in messages are English. We don't need special // All the built-in messages are English. We don't need special
// logic to load the translations. // logic to load the translations.
this.emit(':loaded', []); this.emit(':loaded', []);
this._._dayjs_locale = 'en';
return []; return [];
} }
const data = this.localeData[new_locale]; const data = this.localeData[new_locale];
const phrases = await this.loadLocale(data?.id || new_locale); const phrases = await this.loadLocale(data?.id || new_locale);
let djs;
try {
djs = data?.dayjs_override || new_locale;
await this.loadDayjsLocale(djs);
} catch (err) {
this.log.warn(`Unable to load DayJS locale for ${new_locale}`);
djs = 'en';
}
if ( this._.locale !== new_locale ) if ( this._.locale !== new_locale )
throw new Error('locale has changed since we started loading'); throw new Error('locale has changed since we started loading');
this._._dayjs_locale = djs;
const added = this._.extend(phrases); const added = this._.extend(phrases);
if ( added.length ) { if ( added.length ) {
this.log.info(`Loaded Locale: ${new_locale} -- Phrases: ${added.length}`); this.log.info(`Loaded Locale: ${new_locale} -- Phrases: ${added.length}`);
@ -780,6 +800,10 @@ export class TranslationManager extends Module {
return this._.formatNumber(...args); return this._.formatNumber(...args);
} }
formatCurrency(...args) {
return this._.formatCurrency(...args);
}
formatDuration(...args) { formatDuration(...args) {
return this._.formatDuration(...args); return this._.formatDuration(...args);
} }
@ -815,6 +839,28 @@ export class TranslationManager extends Module {
const DOLLAR_REGEX = /\$/g; const DOLLAR_REGEX = /\$/g;
const REPLACE = String.prototype.replace; const REPLACE = String.prototype.replace;
const FORMAT_REGEX = /^\s*([^(]+?)\s*(?:\(\s*([^)]+?)\s*\))?\s*$/;
export function parseFormatters(fmt) {
if (!fmt || ! fmt.length)
return;
const result = [];
for(const token of fmt.split(/\|/g)) {
const match = FORMAT_REGEX.exec(token);
if (!match)
continue;
result.push({
fmt: match[1],
extra: match[2]
});
}
return result;
}
export function transformPhrase(phrase, substitutions, locale, token_regex, formatters) { export function transformPhrase(phrase, substitutions, locale, token_regex, formatters) {
const is_array = Array.isArray(phrase); const is_array = Array.isArray(phrase);
if ( substitutions == null ) if ( substitutions == null )
@ -828,14 +874,23 @@ export function transformPhrase(phrase, substitutions, locale, token_regex, form
if ( typeof result === 'string' ) if ( typeof result === 'string' )
result = REPLACE.call(result, token_regex, (expr, arg, fmt) => { result = REPLACE.call(result, token_regex, (expr, arg, fmt) => {
let val = get(arg, options); let val = get(arg.trim(), options);
if ( val == null ) if ( val == null )
return ''; return '';
const formatter = formatters[fmt]; const fmts = parseFormatters(fmt);
if ( typeof formatter === 'function' ) let formatted = false;
val = formatter(val, locale, options); if (fmts) {
else if ( typeof val === 'string' ) for(const format of fmts) {
const formatter = formatters[format.fmt];
if (typeof formatter === 'function') {
val = formatter(val, locale, options, format.extra);
formatted = true;
}
}
}
if (! formatted && typeof val === 'string' )
val = REPLACE.call(val, DOLLAR_REGEX, '$$'); val = REPLACE.call(val, DOLLAR_REGEX, '$$');
return val; return val;

79
src/load_tracker.jsx Normal file
View file

@ -0,0 +1,79 @@
'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);
}
}
}

View file

@ -14,9 +14,11 @@ import AddonManager from './addons';
import ExperimentManager from './experiments'; import ExperimentManager from './experiments';
import {TranslationManager} from './i18n'; import {TranslationManager} from './i18n';
import SocketClient from './socket'; import SocketClient from './socket';
//import PubSubClient from './pubsub'; import PubSubClient from './pubsub';
import Site from 'site'; import Site from 'site';
import Vue from 'utilities/vue'; import Vue from 'utilities/vue';
import StagingSelector from './staging';
import LoadTracker from './load_tracker';
//import Timing from 'utilities/timing'; //import Timing from 'utilities/timing';
class FrankerFaceZ extends Module { class FrankerFaceZ extends Module {
@ -56,8 +58,10 @@ class FrankerFaceZ extends Module {
this.inject('settings', SettingsManager); this.inject('settings', SettingsManager);
this.inject('experiments', ExperimentManager); this.inject('experiments', ExperimentManager);
this.inject('i18n', TranslationManager); this.inject('i18n', TranslationManager);
this.inject('staging', StagingSelector);
this.inject('load_tracker', LoadTracker);
this.inject('socket', SocketClient); this.inject('socket', SocketClient);
//this.inject('pubsub', PubSubClient); this.inject('pubsub', PubSubClient);
this.inject('site', Site); this.inject('site', Site);
this.inject('addons', AddonManager); this.inject('addons', AddonManager);
@ -134,7 +138,13 @@ ${typeof x[1] === 'string' ? x[1] : JSON.stringify(x[1], null, 4)}`).join('\n\n'
async discoverModules() { async discoverModules() {
// TODO: Actually do async modules. // TODO: Actually do async modules.
const ctx = await require.context('src/modules', true, /(?:^(?:\.\/)?[^/]+|index)\.jsx?$/ /*, 'lazy-once' */); const ctx = await require.context(
'src/modules',
true,
/(?:^(?:\.\/)?[^/]+|index)\.jsx?$/
/*, 'lazy-once' */
);
const modules = this.populate(ctx, this.core_log); const modules = this.populate(ctx, this.core_log);
this.core_log.info(`Loaded descriptions of ${Object.keys(modules).length} modules.`); this.core_log.info(`Loaded descriptions of ${Object.keys(modules).length} modules.`);
@ -156,20 +166,22 @@ ${typeof x[1] === 'string' ? x[1] : JSON.stringify(x[1], null, 4)}`).join('\n\n'
FrankerFaceZ.Logger = Logger; FrankerFaceZ.Logger = Logger;
const VER = FrankerFaceZ.version_info = { const VER = FrankerFaceZ.version_info = Object.freeze({
major: __version_major__, major: __version_major__,
minor: __version_minor__, minor: __version_minor__,
revision: __version_patch__, revision: __version_patch__,
extra: __version_prerelease__?.length && __version_prerelease__[0], extra: __version_prerelease__?.length && __version_prerelease__[0],
commit: __git_commit__, commit: __git_commit__,
build: __webpack_hash__, build: __version_build__,
hash: __webpack_hash__,
toString: () => toString: () =>
`${VER.major}.${VER.minor}.${VER.revision}${VER.extra || ''}${DEBUG ? '-dev' : ''}` `${VER.major}.${VER.minor}.${VER.revision}${VER.build ? `.${VER.build}` : ''}${VER.extra || ''}${DEBUG ? '-dev' : ''}`
} });
FrankerFaceZ.utilities = { FrankerFaceZ.utilities = {
addon: require('utilities/addon'), addon: require('utilities/addon'),
blobs: require('utilities/blobs'),
color: require('utilities/color'), color: require('utilities/color'),
constants: require('utilities/constants'), constants: require('utilities/constants'),
dialog: require('utilities/dialog'), dialog: require('utilities/dialog'),

View file

@ -38,6 +38,18 @@ export default class Actions extends Module {
} }
}); });
this.settings.add('chat.actions.hover-size', {
default: 30,
ui: {
path: 'Chat > Actions > Message Hover >> Appearance',
title: 'Action Size',
description: "How tall hover actions should be, in pixels. This may be affected by your browser's zoom and font size settings.",
component: 'setting-text-box',
process: 'to_int',
bounds: [1]
}
});
this.settings.add('chat.actions.reasons', { this.settings.add('chat.actions.reasons', {
default: [ default: [
{v: {text: 'One-Man Spam', i18n: 'chat.reasons.spam'}}, {v: {text: 'One-Man Spam', i18n: 'chat.reasons.spam'}},
@ -67,6 +79,42 @@ export default class Actions extends Module {
} }
}); });
this.settings.add('chat.actions.hover', {
process: (ctx, val) =>
val.filter(x => x.appearance &&
this.renderers[x.appearance.type] &&
(! this.renderers[x.appearance.type].load || this.renderers[x.appearance.type].load(x.appearance)) &&
(! x.action || this.actions[x.action])
),
default: [
{v: {action: 'pin', appearance: {type: 'icon', icon: 'ffz-i-pin'}, options: {}, display: {mod_icons: true}}},
{v: {action: 'reply', appearance: {type: 'dynamic'}, options: {}, display: {}}}
],
type: 'array_merge',
inherit_default: true,
ui: {
path: 'Chat > Actions > Message Hover @{"description": "Here, you can define custom actions that will appear on top of messages in chat when you hover over them. If you aren\'t seeing an action you\'ve defined here in chat, please make sure that you have enabled Mod Icons in the chat settings menu."}',
component: 'chat-actions',
context: ['user', 'room', 'message'],
inline: true,
modifiers: true,
hover_modifier: false,
data: () => {
const chat = this.resolve('site.chat');
return {
color: val => chat && chat.colors ? chat.colors.process(val) : val,
actions: deep_copy(this.actions),
renderers: deep_copy(this.renderers)
}
}
}
});
this.settings.add('chat.actions.inline', { this.settings.add('chat.actions.inline', {
// Filter out actions // Filter out actions
process: (ctx, val) => process: (ctx, val) =>
@ -80,8 +128,7 @@ export default class Actions extends Module {
{v: {action: 'ban', appearance: {type: 'icon', icon: 'ffz-i-block'}, options: {}, display: {mod: true, mod_icons: true, deleted: false}}}, {v: {action: 'ban', appearance: {type: 'icon', icon: 'ffz-i-block'}, options: {}, display: {mod: true, mod_icons: true, deleted: false}}},
{v: {action: 'unban', appearance: {type: 'icon', icon: 'ffz-i-ok'}, options: {}, display: {mod: true, mod_icons: true, deleted: true}}}, {v: {action: 'unban', appearance: {type: 'icon', icon: 'ffz-i-ok'}, options: {}, display: {mod: true, mod_icons: true, deleted: true}}},
{v: {action: 'timeout', appearance: {type: 'icon', icon: 'ffz-i-clock'}, display: {mod: true, mod_icons: true}}}, {v: {action: 'timeout', appearance: {type: 'icon', icon: 'ffz-i-clock'}, display: {mod: true, mod_icons: true}}},
{v: {action: 'msg_delete', appearance: {type: 'icon', icon: 'ffz-i-trash'}, options: {}, display: {mod: true, mod_icons: true}}}, {v: {action: 'msg_delete', appearance: {type: 'icon', icon: 'ffz-i-trash'}, options: {}, display: {mod: true, mod_icons: true}}}
{v: {action: 'reply', appearance: {type: 'icon', icon: 'ffz-i-reply'}, options: {}, display: {}}}
], ],
type: 'array_merge', type: 'array_merge',
@ -223,8 +270,12 @@ export default class Actions extends Module {
this.actions[key] = data; this.actions[key] = data;
for(const ctx of this.settings.__contexts) for(const ctx of this.settings.__contexts) {
ctx.update('chat.actions.inline'); ctx.update('chat.actions.inline');
ctx.update('chat.actions.hover');
ctx.update('chat.actions.user-context');
ctx.update('chat.actions.room');
}
} }
@ -234,8 +285,13 @@ export default class Actions extends Module {
this.renderers[key] = data; this.renderers[key] = data;
for(const ctx of this.settings.__contexts) for(const ctx of this.settings.__contexts) {
ctx.update('chat.actions.inline'); ctx.update('chat.actions.inline');
ctx.update('chat.actions.inline');
ctx.update('chat.actions.hover');
ctx.update('chat.actions.user-context');
ctx.update('chat.actions.room');
}
} }
@ -245,7 +301,82 @@ export default class Actions extends Module {
data, data,
this.i18n.locale, this.i18n.locale,
VAR_REPLACE, VAR_REPLACE,
{} {
upper(val) {
return val.toString().toUpperCase();
},
uppercase(val) {
return val.toString().toUpperCase();
},
lower(val) {
return val.toString().toLowerCase();
},
lowercase(val) {
return val.toString().toLowerCase();
},
snakecase(val) {
return val.toString().toSnakeCase();
},
slugify(val, locale, options, extra) {
return val.toString().toSlug(extra && extra.length ? extra : '-');
},
urlencode(val) {
return encodeURIComponent(val);
},
word(val, locale, options, extra) {
if (! extra || ! extra.length)
return val;
let start, end;
const bits = extra.split(',');
try {
start = parseInt(bits[0], 10);
if (isNaN(start) || !isFinite(start))
return val;
} catch (err) {
this.log.warn('Invalid value for word(start)', bits[0]);
return val;
}
if (bits.length > 1) {
const bit = bits[1].trim();
if (! bit.length )
end = -1;
else
try {
end = parseInt(bits[1], 10);
if (isNaN(end) || !isFinite(end))
return val;
} catch(err) {
this.log.warn('Invalid value for word(end)', bits[1]);
return val;
}
}
const words = val.split(/\s+/);
if (start < 0)
start = words.length + start;
if (start < 0)
start = 0;
if (start >= words.length)
start = words.length - 1;
if (end != null) {
if (end < 0)
end = words.length + end;
if (end < start)
end = start;
if (end > words.length)
end = words.length;
return words.slice(start, end + 1).join(' ');
}
return words[start];
}
}
); );
} }
@ -429,6 +560,8 @@ export default class Actions extends Module {
if ( ! data ) if ( ! data )
continue; continue;
data.ctx = 'room';
const type = data.type; const type = data.type;
if ( type ) { if ( type ) {
if ( type === 'new-line' ) { if ( type === 'new-line' ) {
@ -473,6 +606,12 @@ export default class Actions extends Module {
if ( maybe_call(act.hidden, this, data, null, current_room, current_user, mod_icons) ) if ( maybe_call(act.hidden, this, data, null, current_room, current_user, mod_icons) )
continue; continue;
if ( ap.type === 'dynamic' ) {
const out = act.dynamicAppearance && act.dynamicAppearance.call(this, Object.assign({}, ap), data, null, current_room, current_user, mod_icons);
if ( out )
ap = out;
}
if ( act.override_appearance ) { if ( act.override_appearance ) {
const out = act.override_appearance.call(this, Object.assign({}, ap), data, null, current_room, current_user, mod_icons); const out = act.override_appearance.call(this, Object.assign({}, ap), data, null, current_room, current_user, mod_icons);
if ( out ) if ( out )
@ -543,16 +682,17 @@ export default class Actions extends Module {
const u = site.getUser(), const u = site.getUser(),
r = {id: line.props.channelID, login: room}; r = {id: line.props.channelID, login: room};
const has_replies = line.chatRepliesTreatment ? line.chatRepliesTreatment !== 'control' : false, const has_replies = !!(line.props.hasReply || line.props.reply || ! line.props.replyRestrictedReason),
can_replies = has_replies && ! msg.deleted && ! line.props.disableReplyClick, can_replies = has_replies && msg.message && ! msg.deleted && ! line.props.disableReplyClick,
can_reply = can_replies && u.login !== msg.user?.login && ! msg.reply; can_reply = can_replies && (has_replies || (u && u.login !== msg.user?.login));
msg.roomId = r.id; msg.roomId = r.id;
if ( u ) { if ( u ) {
u.moderator = line.props.isCurrentUserModerator; u.moderator = line.props.isCurrentUserModerator;
u.staff = line.props.isCurrentUserStaff; u.staff = line.props.isCurrentUserStaff;
u.can_reply = this.parent.context.get('chat.replies.style') === 2 && can_reply; u.reply_mode = this.parent.context.get('chat.replies.style'),
u.can_reply = can_reply;
} }
const current_level = this.getUserLevel(r, u), const current_level = this.getUserLevel(r, u),
@ -577,6 +717,8 @@ export default class Actions extends Module {
if ( ! data ) if ( ! data )
continue; continue;
data.ctx = 'user_context';
if ( data.type === 'new-line' ) { if ( data.type === 'new-line' ) {
line = null; line = null;
continue; continue;
@ -609,11 +751,17 @@ export default class Actions extends Module {
(disp.deleted != null && disp.deleted !== !!msg.deleted) ) (disp.deleted != null && disp.deleted !== !!msg.deleted) )
continue; continue;
if ( maybe_call(act.hidden, this, data, msg, r, u, mod_icons) ) if ( maybe_call(act.hidden, this, data, msg, r, u, mod_icons, chat_line) )
continue; continue;
if ( ap.type === 'dynamic' ) {
const out = act.dynamicAppearance && act.dynamicAppearance.call(this, Object.assign({}, ap), data, msg, r, u, mod_icons, chat_line);
if ( out )
ap = out;
}
if ( act.override_appearance ) { if ( act.override_appearance ) {
const out = act.override_appearance.call(this, Object.assign({}, ap), data, msg, r, u, mod_icons); const out = act.override_appearance.call(this, Object.assign({}, ap), data, msg, r, u, mod_icons, chat_line);
if ( out ) if ( out )
ap = out; ap = out;
} }
@ -623,7 +771,7 @@ export default class Actions extends Module {
continue; continue;
const has_color = def.colored && ap.color, const has_color = def.colored && ap.color,
disabled = maybe_call(act.disabled, this, data, msg, r, u, mod_icons) || false, disabled = maybe_call(act.disabled, this, data, msg, r, u, mod_icons, chat_line) || false,
color = has_color && (chat && chat.colors ? chat.colors.process(ap.color) : ap.color), color = has_color && (chat && chat.colors ? chat.colors.process(ap.color) : ap.color),
contents = def.render.call(this, ap, createElement, color); contents = def.render.call(this, ap, createElement, color);
@ -632,7 +780,7 @@ export default class Actions extends Module {
const btn = (<button const btn = (<button
class={`ffz-tooltip ffz-tooltip--no-mouse tw-button tw-button--text${disabled ? ' tw-button--disabled disabled' : ''}`} class={`ffz-tooltip ffz-tooltip--no-mouse tw-button tw-button--text${disabled ? ' tw-button--disabled disabled' : ''}`}
disabled={disabled} //disabled={disabled}
data-tooltip-type="action" data-tooltip-type="action"
data-action={data.action} data-action={data.action}
data-options={data.options ? JSON.stringify(data.options) : null} data-options={data.options ? JSON.stringify(data.options) : null}
@ -671,7 +819,95 @@ export default class Actions extends Module {
} }
renderInline(msg, mod_icons, current_user, current_room, createElement) { renderHover(msg, mod_icons, current_user, current_room, createElement, instance = null) {
const actions = [];
const current_level = this.getUserLevel(current_room, current_user),
msg_level = this.getUserLevel(current_room, msg.user),
is_self = msg.user && current_user && current_user.login === msg.user.login;
if ( current_level < 3 )
mod_icons = false;
const chat = this.resolve('site.chat');
let had_action = false;
for(const data of this.parent.context.get('chat.actions.hover')) {
if ( ! data.action || ! data.appearance )
continue;
data.ctx = 'hover';
let ap = data.appearance || {};
const disp = data.display || {},
keys = disp.keys,
act = this.actions[data.action];
if ( ! act || disp.disabled ||
(disp.mod_icons != null && disp.mod_icons !== !!mod_icons) ||
(disp.mod != null && disp.mod !== (current_level > msg_level)) ||
(disp.staff != null && disp.staff !== (current_user ? !!current_user.staff : false)) ||
(disp.deleted != null && disp.deleted !== !!msg.deleted) )
continue;
if ( is_self && ! act.can_self )
continue;
if ( maybe_call(act.hidden, this, data, msg, current_room, current_user, mod_icons, instance) )
continue;
if ( ap.type === 'dynamic' ) {
const out = act.dynamicAppearance && act.dynamicAppearance.call(this, Object.assign({}, ap), data, msg, current_room, current_user, mod_icons, instance);
if ( out )
ap = out;
}
if ( act.override_appearance ) {
const out = act.override_appearance.call(this, Object.assign({}, ap), data, msg, current_room, current_user, mod_icons, instance);
if ( out )
ap = out;
}
const def = this.renderers[ap.type];
if ( ! def )
continue;
const has_color = def.colored && ap.color,
disabled = maybe_call(act.disabled, this, data, msg, current_room, current_user, mod_icons, instance) || false,
color = has_color && (chat && chat.colors ? chat.colors.process(ap.color) : ap.color),
contents = def.render.call(this, ap, createElement, color);
had_action = true;
actions.push(<div class={`ffz-hover-action${keys ? ` ffz-has-modifier ffz-modifier-${keys}` : ''}`}>
<button
class={`ffz-tooltip ffz-mod-icon tw-c-text-alt-2${disabled ? ' disabled' : ''}${has_color ? ' colored' : ''}`}
//disabled={disabled}
data-tooltip-type="action"
data-action={data.action}
data-options={data.options ? JSON.stringify(data.options) : null}
data-tip={ap.tooltip}
onClick={this.handleClick}
onContextMenu={this.handleContext}
>
{contents}
</button>
</div>);
}
if ( ! had_action )
return null;
return (<div
class={`ffz--hover-actions ffz-action-data tw-mg-r-05`}
data-source="line"
>
{actions}
</div>);
}
renderInline(msg, mod_icons, current_user, current_room, createElement, instance = null) {
const actions = []; const actions = [];
const current_level = this.getUserLevel(current_room, current_user), const current_level = this.getUserLevel(current_room, current_user),
@ -690,6 +926,8 @@ export default class Actions extends Module {
if ( ! data.action || ! data.appearance ) if ( ! data.action || ! data.appearance )
continue; continue;
data.ctx = 'inline';
let ap = data.appearance || {}; let ap = data.appearance || {};
const disp = data.display || {}, const disp = data.display || {},
keys = disp.keys, keys = disp.keys,
@ -706,11 +944,17 @@ export default class Actions extends Module {
if ( is_self && ! act.can_self ) if ( is_self && ! act.can_self )
continue; continue;
if ( maybe_call(act.hidden, this, data, msg, current_room, current_user, mod_icons) ) if ( maybe_call(act.hidden, this, data, msg, current_room, current_user, mod_icons, instance) )
continue; continue;
if ( ap.type === 'dynamic' ) {
const out = act.dynamicAppearance && act.dynamicAppearance.call(this, Object.assign({}, ap), data, msg, current_room, current_user, mod_icons, instance);
if ( out )
ap = out;
}
if ( act.override_appearance ) { if ( act.override_appearance ) {
const out = act.override_appearance.call(this, Object.assign({}, ap), data, msg, current_room, current_user, mod_icons); const out = act.override_appearance.call(this, Object.assign({}, ap), data, msg, current_room, current_user, mod_icons, instance);
if ( out ) if ( out )
ap = out; ap = out;
} }
@ -720,7 +964,7 @@ export default class Actions extends Module {
continue; continue;
const has_color = def.colored && ap.color, const has_color = def.colored && ap.color,
disabled = maybe_call(act.disabled, this, data, msg, current_room, current_user, mod_icons) || false, disabled = maybe_call(act.disabled, this, data, msg, current_room, current_user, mod_icons, instance) || false,
color = has_color && (chat && chat.colors ? chat.colors.process(ap.color) : ap.color), color = has_color && (chat && chat.colors ? chat.colors.process(ap.color) : ap.color),
contents = def.render.call(this, ap, createElement, color); contents = def.render.call(this, ap, createElement, color);
@ -732,7 +976,7 @@ export default class Actions extends Module {
had_action = true; had_action = true;
list.push(<button list.push(<button
class={`ffz-tooltip mod-icon ffz-mod-icon tw-c-text-alt-2${disabled ? ' disabled' : ''}${has_color ? ' colored' : ''}${keys ? ` ffz-modifier-${keys}` : ''}${hover ? ' ffz-hover' : ''}`} class={`ffz-tooltip mod-icon ffz-mod-icon tw-c-text-alt-2${disabled ? ' disabled' : ''}${has_color ? ' colored' : ''}${keys ? ` ffz-modifier-${keys}` : ''}${hover ? ' ffz-hover' : ''}`}
disabled={disabled} //disabled={disabled}
data-tooltip-type="action" data-tooltip-type="action"
data-action={data.action} data-action={data.action}
data-options={data.options ? JSON.stringify(data.options) : null} data-options={data.options ? JSON.stringify(data.options) : null}
@ -747,14 +991,6 @@ export default class Actions extends Module {
if ( ! had_action ) if ( ! had_action )
return null; return null;
/*const room = current_room && JSON.stringify(current_room),
user = msg.user && JSON.stringify({
login: msg.user.login,
displayName: msg.user.displayName,
id: msg.user.id,
type: msg.user.type
});*/
let out = null; let out = null;
if ( actions.length ) if ( actions.length )
out = (<div out = (<div
@ -891,6 +1127,8 @@ export default class Actions extends Module {
if ( target._ffz_tooltip ) if ( target._ffz_tooltip )
target._ffz_tooltip.hide(); target._ffz_tooltip.hide();
return data.definition.click.call(this, event, data); return data.definition.click.call(this, event, data);
} }

View file

@ -1,38 +1,68 @@
<template lang="html"> <template lang="html">
<div class="tw-flex tw-align-items-start"> <div>
<label :for="'edit_chat$' + id" class="tw-mg-y-05"> <div class="tw-flex tw-align-items-start">
{{ t('setting.actions.chat', 'Chat Command') }} <label :for="'edit_chat$' + id" class="tw-mg-y-05">
</label> {{ t('setting.actions.chat', 'Chat Command') }}
</label>
<div class="tw-full-width"> <div class="tw-full-width">
<input
:id="'edit_chat$' + id"
v-model="value.command"
:placeholder="defaults.command"
class="tw-border-radius-medium tw-font-size-6 tw-full-width ffz-input tw-pd-x-1 tw-pd-y-05 tw-mg-y-05"
@input="$emit('input', value)"
>
<div class="tw-c-text-alt-2 tw-mg-b-1">
{{ t('setting.actions.variables', 'Available Variables: {vars}', {vars}) }}
</div>
<div class="ffz-checkbox">
<input <input
:id="'chat-paste$' + id" :id="'edit_chat$' + id"
v-model="value.paste" v-model="value.command"
type="checkbox" :placeholder="defaults.command"
class="ffz-checkbox__input" class="tw-border-radius-medium tw-font-size-6 tw-full-width ffz-input tw-pd-x-1 tw-pd-y-05 tw-mg-y-05"
@change="$emit('input', value)" @input="$emit('input', value)"
> >
<label :for="'chat-paste$' + id" class="ffz-checkbox__label"> <div class="tw-c-text-alt-2 tw-mg-b-1">
<span class="tw-mg-l-1"> {{ t('setting.actions.variables', 'Available Variables: {vars}', {vars}) }}
{{ t('setting.actions.set-chat', 'Paste this message into chat rather than sending it directly.') }} </div>
</span>
</label> <div class="tw-c-text-alt-2 tw-mg-b-1">
{{ t('setting.actions.formats', 'Available Formatters: {fmts}', {fmts}) }}
</div>
<div class="ffz-checkbox">
<input
:id="'chat-paste$' + id"
v-model="value.paste"
type="checkbox"
class="ffz-checkbox__input"
@change="$emit('input', value)"
>
<label :for="'chat-paste$' + id" class="ffz-checkbox__label">
<span class="tw-mg-l-1">
{{ t('setting.actions.set-chat', 'Paste this message into chat rather than sending it directly.') }}
</span>
</label>
</div>
</div> </div>
</div> </div>
<div class="tw-flex tw-align-items-start">
<label :for="'edit_chat_target$' + id" class="tw-mg-y-05">
{{ t('setting.actions.chat.target', 'Target Channel') }}
</label>
<div class="tw-full-width">
<input
:id="'edit_chat_target$' + id"
v-model="value.target"
:placeholder="defaults.target || ''"
class="tw-border-radius-medium tw-font-size-6 tw-full-width ffz-input tw-pd-x-1 tw-pd-y-05 tw-mg-y-05"
@input="$emit('input', value)"
>
<div class="tw-c-text-alt-2 tw-mg-b-1">
{{ t('setting.actions.chat.target-notice', 'Please note that when sending a message into another chat, you will not receive feedback that your message was sent.') }}
</div>
<div class="tw-c-text-alt-2">
{{ t('setting.actions.chat.target-incompatible', 'Note: This is not compatible with pasting a message into chat, and will not function if that is enabled.') }}
</div>
</div>
</div>
</div> </div>
</template> </template>
@ -41,7 +71,7 @@
let last_id = 0; let last_id = 0;
export default { export default {
props: ['value', 'defaults', 'vars'], props: ['value', 'defaults', 'vars', 'fmts'],
data() { data() {
return { return {

View file

@ -0,0 +1,33 @@
<template lang="html">
<div class="tw-flex tw-align-items-start">
<label for="edit_format" class="tw-mg-y-05">
{{ t('setting.actions.format', 'Format') }}
</label>
<div class="tw-full-width">
<input
id="edit_format"
v-model="value.format"
:placeholder="defaults.format"
class="tw-border-radius-medium tw-font-size-6 tw-full-width ffz-input tw-pd-x-1 tw-pd-y-05 tw-mg-y-05"
@input="$emit('input', value)"
>
<div class="tw-c-text-alt-2 tw-mg-b-1">
{{ t('setting.actions.variables', 'Available Variables: {vars}', {vars}) }}
</div>
<div class="tw-c-text-alt-2 tw-mg-b-1">
{{ t('setting.actions.formats', 'Available Formatters: {fmts}', {fmts}) }}
</div>
</div>
</div>
</template>
<script>
export default {
props: ['value', 'defaults', 'vars', 'fmts'],
}
</script>

View file

@ -0,0 +1,40 @@
<template lang="html">
<div class="tw-flex tw-align-items-start">
<label class="tw-mg-y-05">
{{ t('setting.actions.emote', 'Emote') }}
</label>
<emote-picker
:value="val"
class="tw-full-width"
@input="change"
/>
</div>
</template>
<script>
export default {
props: ['value'],
data() {
return {
val: this.value
};
},
watch: {
value() {
this.val = this.value;
}
},
methods: {
change(val) {
this.val = val;
this.$emit('input', this.val);
}
}
}
</script>

View file

@ -16,6 +16,10 @@
<div class="tw-c-text-alt-2 tw-mg-b-1"> <div class="tw-c-text-alt-2 tw-mg-b-1">
{{ t('setting.actions.variables', 'Available Variables: {vars}', {vars}) }} {{ t('setting.actions.variables', 'Available Variables: {vars}', {vars}) }}
</div> </div>
<div class="tw-c-text-alt-2 tw-mg-b-1">
{{ t('setting.actions.formats', 'Available Formatters: {fmts}', {fmts}) }}
</div>
</div> </div>
</div> </div>
</template> </template>
@ -23,7 +27,7 @@
<script> <script>
export default { export default {
props: ['value', 'defaults', 'vars'], props: ['value', 'defaults', 'vars', 'fmts'],
} }
</script> </script>

View file

@ -0,0 +1,5 @@
<template functional>
<figure class="mod-icon__image">
<img :src="props.data.src">
</figure>
</template>

View file

@ -3,6 +3,22 @@
import {load as loadFontAwesome} from 'utilities/font-awesome'; import {load as loadFontAwesome} from 'utilities/font-awesome';
// ============================================================================
// Dynamic
// ============================================================================
export const dynamic = {
title: 'Dynamic',
title_i18n: 'setting.actions.appearance.dynamic',
colored: true,
render(data, createElement, color) {
return <figure style={{color}} class={`${data.icon||'ffz-i-zreknarf'}`} />;
}
}
// ============================================================================ // ============================================================================
// Text // Text
// ============================================================================ // ============================================================================
@ -21,6 +37,21 @@ export const text = {
} }
} }
// ============================================================================
// Emote
// ============================================================================
export const emote = {
title: 'Emote',
title_i18n: 'setting.actions.appearance.emote',
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/edit-emote.vue'),
component: () => import(/* webpackChunkName: 'main-menu' */ './components/preview-emote.vue'),
render(data, createElement) {
return <figure class="mod-icon__image"><img src={data.src} /></figure>;
}
}
// ============================================================================ // ============================================================================
// Icon // Icon

View file

@ -3,6 +3,73 @@
import {createElement} from 'utilities/dom'; import {createElement} from 'utilities/dom';
// ============================================================================
// Pin Message
// ============================================================================
export const pin = {
presets: [{
appearance: {
type: 'icon',
icon: 'ffz-i-pin'
}
}],
required_context: ['message'],
title: 'Pin This Message',
description: "Allows you to pin a chat message if you're a moderator.",
can_self: true,
tooltip(data) {
const pinned = data.line?.props?.pinnedMessage?.message?.id === data.message_id;
if (pinned)
return this.i18n.t('chat.actions.pin.already', 'This message is already pinned.');
return this.i18n.t('chat.actions.pin', 'Pin This Message')
},
disabled(data, message, current_room, current_user, mod_icons, instance) {
const line = instance ?? data.line,
props = line?.props,
pinned = props?.pinnedMessage?.message?.id === message.id && message.id != null;
return pinned;
},
hidden(data, message, current_room, current_user, mod_icons, instance) {
let line = instance;
if ( ! line )
return true;
if ( ! line.props.isPinnable || ! line.onPinMessageClick )
return true;
// If the message is empty or deleted, we can't pin it.
if ( ! message.message || ! message.message.length || message.deleted )
return true;
},
click(event, data) {
let line = data.line;
if ( ! line ) {
const fine = this.resolve('site.fine');
line = fine ? fine.searchParent(event.target, n => n.setMessageTray && n.props && n.props.message) : null;
}
if ( ! line || ! line.props.isPinnable || ! line.onPinMessageClick )
return;
if ( line.props.pinnedMessage?.message?.id === data.message_id )
return;
line.onPinMessageClick();
}
}
// ============================================================================ // ============================================================================
// Send Reply // Send Reply
// ============================================================================ // ============================================================================
@ -10,19 +77,37 @@ import {createElement} from 'utilities/dom';
export const reply = { export const reply = {
presets: [{ presets: [{
appearance: { appearance: {
type: 'icon', type: 'dynamic'
icon: 'ffz-i-reply'
} }
}], }],
required_context: ['message'], required_context: ['message'],
supports_dynamic: true,
title: 'Reply to Message', title: 'Reply to Message',
description: 'Allows you to directly reply to another user\'s message. Only functions when the Chat Replies Style is "FrankerFaceZ".', description: "Allows you to directly reply to another user's message.",
can_self: true, can_self: true,
tooltip() { dynamicAppearance(ap, data, message, current_room, current_user, mod_icons, instance) {
const line = instance ?? data.line,
props = line?.props,
has_reply = props?.hasReply || props?.reply;
return {
type: 'icon',
icon: has_reply ? 'ffz-i-threads' : 'ffz-i-reply',
color: ap.color
}
},
tooltip(data) {
const props = data.line?.props,
has_reply = props?.hasReply || props?.reply;
if (has_reply)
return this.i18n.t('chat.actions.reply.thread', 'Open Thread');
return this.i18n.t('chat.actions.reply', 'Reply to Message') return this.i18n.t('chat.actions.reply', 'Reply to Message')
}, },
@ -31,10 +116,16 @@ export const reply = {
if ( typeof id !== 'string' || ! /^[0-9a-f]+-[0-9a-f]+/.test(id) ) if ( typeof id !== 'string' || ! /^[0-9a-f]+-[0-9a-f]+/.test(id) )
return true; return true;
if ( ! message.message || message.deleted || (current_user && current_user.login === message.user?.login) || ! current_user?.can_reply ) // Users must be able to reply.
if ( ! current_user?.can_reply )
return true; return true;
if ( message?.reply ) // If reply mode is set to 0 (Disabled), don't show the action.
if ( current_user?.reply_mode === 0 )
return true;
// If the message is empty or deleted, don't show the action.
if ( ! message.message || message.deleted )
return true; return true;
}, },
@ -83,6 +174,51 @@ export const edit_overrides = {
} }
// ============================================================================
// Copy to Clipboard
// ============================================================================
export const copy_message = {
presets: [{
appearance: {
type: 'icon',
icon: 'ffz-i-docs'
}
}],
defaults: {
format: '{{user.displayName}}: {{message.text}}'
},
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/edit-copy.vue'),
required_context: ['user', 'message'],
title: 'Copy Message',
description: 'Allows you to quickly copy a chat message to your clipboard.',
can_self: true,
tooltip(data) {
const msg = this.replaceVariables(data.options.format, data);
return [
(<div class="tw-border-b tw-mg-b-05">{ // eslint-disable-line react/jsx-key
this.i18n.t('chat.actions.copy_message', 'Copy Message')
}</div>),
(<div class="tw-align-left">{ // eslint-disable-line react/jsx-key
msg
}</div>)
];
},
click(event, data) {
const msg = this.replaceVariables(data.options.format, data);
navigator.clipboard.writeText(msg);
}
}
// ============================================================================ // ============================================================================
// Open URL // Open URL
// ============================================================================ // ============================================================================
@ -102,7 +238,10 @@ export const open_url = {
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/edit-url.vue'), editor: () => import(/* webpackChunkName: 'main-menu' */ './components/edit-url.vue'),
title: 'Open URL', title: 'Open URL',
description: '{options.url}', description(data) {
return data.options.url;
},
description_i18n: null,
can_self: true, can_self: true,
@ -151,7 +290,20 @@ export const chat = {
}, },
title: 'Chat Command', title: 'Chat Command',
description: '{options.command}', description(data) {
if ( data.options.paste )
return this.t('chat.actions.chat.desc.paste', 'Paste into chat: {cmd}', {cmd: data.options.command})
const target = data.options.target ?? '';
return this.t('chat.actions.chat.desc.target', 'Send in {target}: {cmd}', {
cmd: data.options.command,
target: /^\s*$/.test(target)
? this.t('chat.actions.chat.desc.current', 'current channel')
: target
});
},
description_i18n: null,
can_self: true, can_self: true,
@ -159,10 +311,15 @@ export const chat = {
tooltip(data) { tooltip(data) {
const msg = this.replaceVariables(data.options.command, data); const msg = this.replaceVariables(data.options.command, data);
let target = this.replaceVariables(data.options.target ?? '', data);
if ( /^\s*$/.test(target) )
target = null;
return [ return [
(<div class="tw-border-b tw-mg-b-05">{ // eslint-disable-line react/jsx-key (<div class="tw-border-b tw-mg-b-05">{ // eslint-disable-line react/jsx-key
this.i18n.t('chat.actions.chat', 'Chat Command') target
? this.i18n.t('chat.actions.chat.with-target', 'Chat Command in Channel: {target}', {target})
: this.i18n.t('chat.actions.chat', 'Chat Command')
}</div>), }</div>),
(<div class="tw-align-left">{ // eslint-disable-line react/jsx-key (<div class="tw-align-left">{ // eslint-disable-line react/jsx-key
msg msg
@ -172,10 +329,14 @@ export const chat = {
click(event, data) { click(event, data) {
const msg = this.replaceVariables(data.options.command, data); const msg = this.replaceVariables(data.options.command, data);
let target = this.replaceVariables(data.options.target ?? '', data);
if ( data.options.paste || /^\s*$/.test(target) )
target = data.room.login;
if ( data.options.paste ) if ( data.options.paste )
this.pasteMessage(data.room.login, msg); this.pasteMessage(target, msg);
else else
this.sendMessage(data.room.login, msg); this.sendMessage(target, msg);
} }
} }
@ -363,6 +524,84 @@ export const untimeout = {
} }
// ============================================================================
// Mod and Unmod User
// ============================================================================
export const mod = {
presets: [{
appearance: {
type: 'icon',
icon: 'ffz-i-mod'
}
}],
required_context: ['room', 'user'],
title: 'Mod User',
tooltip(data) {
return this.i18n.t('chat.actions.mod.tooltip', 'Mod {user.login}', {user: data.user});
},
hidden(data, message, current_room, current_user, mod_icons, instance) {
// You cannot mod mods.
if ( message.user.type === 'mod' )
return true;
// You cannot mod the broadcaster.
if ( message.user.id === current_room.id )
return true;
// Only the broadcaster can mod, otherwise.
return current_room.id !== current_user.id;
},
click(event, data) {
this.sendMessage(data.room.login, `/mod ${data.user.login}`);
}
};
export const unmod = {
presets: [{
appearance: {
type: 'icon',
icon: 'ffz-i-unmod'
}
}],
required_context: ['room', 'user'],
title: 'Un-Mod User',
tooltip(data) {
return this.i18n.t('chat.actions.unmod.tooltip', 'Un-Mod {user.login}', {user: data.user});
},
hidden(data, message, current_room, current_user, mod_icons, instance) {
// You can only un-mod mods.
if ( message.user.type !== 'mod' )
return true;
// You can unmod yourself.
if ( message.user.id === current_user.id )
return false;
// You cannot unmod the broadcaster.
if ( message.user.id === current_room.id )
return false;
// Only the broadcaster can unmod, otherwise.
return current_room.id !== current_user.id;
},
click(event, data) {
this.sendMessage(data.room.login, `/unmod ${data.user.login}`);
}
};
// ============================================================================ // ============================================================================
// Whisper // Whisper
// ============================================================================ // ============================================================================

View file

@ -4,7 +4,7 @@
// Badge Handling // Badge Handling
// ============================================================================ // ============================================================================
import {NEW_API, SERVER, API_SERVER, IS_WEBKIT, IS_FIREFOX, WEBKIT_CSS as WEBKIT} from 'utilities/constants'; import {NEW_API, SERVER, IS_WEBKIT, IS_FIREFOX, WEBKIT_CSS as WEBKIT} from 'utilities/constants';
import {createElement, ManagedStyle} from 'utilities/dom'; import {createElement, ManagedStyle} from 'utilities/dom';
import {has, maybe_call, SourcedSet} from 'utilities/object'; import {has, maybe_call, SourcedSet} from 'utilities/object';
@ -44,7 +44,10 @@ const CSS_BADGES = {
turbo: { 1: { color: '#59399A' } }, turbo: { 1: { color: '#59399A' } },
premium: { 1: { color: '#00A0D6' } }, premium: { 1: { color: '#00A0D6' } },
'anonymous-cheerer': { 1: { color: '#4B367C' } }, 'anonymous-cheerer': { 1: { color: '#4B367C' } },
'clip-champ': { 1: { color: '#9146FF' } } 'clip-champ': { 1: { color: '#9146FF' } },
'artist-badge': { 1: { color: '#1e69ff' } },
'no_audio': { 1: { color: '#323239' } },
'no_video': { 1: { color: '#323239' } }
} }
} }
@ -181,6 +184,8 @@ export default class Badges extends Module {
this.inject('settings'); this.inject('settings');
this.inject('tooltips'); this.inject('tooltips');
this.inject('experiments'); this.inject('experiments');
this.inject('staging');
this.inject('load_tracker');
this.style = new ManagedStyle('badges'); this.style = new ManagedStyle('badges');
@ -223,12 +228,24 @@ export default class Badges extends Module {
}); });
this.settings.add('chat.badges.clickable', { this.settings.add('chat.badges.clickable', {
default: true, default: 2,
process(ctx, val) {
if (val === true)
return 2;
else if (val === false)
return 0;
return val;
},
ui: { ui: {
path: 'Chat > Badges >> Behavior', path: 'Chat > Badges >> Behavior',
title: 'Allow clicking badges.', title: 'Allow clicking badges.',
description: 'Certain badges, such as Prime Gaming, act as links when this is enabled.', description: 'Certain badges, such as Prime Gaming, act as links when this is enabled.',
component: 'setting-check-box' component: 'setting-select-box',
data: [
{value: 0, title: 'Disabled'},
{value: 1, title: 'Legacy (Open URLs)'},
{value: 2, title: 'Open Badge Card'}
]
} }
}); });
@ -408,6 +425,11 @@ export default class Badges extends Module {
this.rebuildAllCSS(); this.rebuildAllCSS();
this.loadGlobalBadges(); this.loadGlobalBadges();
this.on('chat:reload-data', flags => {
if ( ! flags || flags.badges )
this.loadGlobalBadges();
});
this.tooltips.types.badge = (target, tip) => { this.tooltips.types.badge = (target, tip) => {
tip.add_class = 'ffz__tooltip--badges'; tip.add_class = 'ffz__tooltip--badges';
@ -534,7 +556,8 @@ export default class Badges extends Module {
handleClick(event) { handleClick(event) {
if ( ! this.parent.context.get('chat.badges.clickable') ) const mode = this.parent.context.get('chat.badges.clickable');
if ( ! mode )
return; return;
const target = event.target; const target = event.target;
@ -544,6 +567,7 @@ export default class Badges extends Module {
return; return;
let url = null; let url = null;
let click_badge = null;
for(const d of ds.data) { for(const d of ds.data) {
const p = d.provider; const p = d.provider;
@ -553,14 +577,14 @@ export default class Badges extends Module {
if ( ! bd ) if ( ! bd )
continue; continue;
if ( bd.click_url ) if ( mode == 1 && bd.click_url )
url = bd.click_url; url = bd.click_url;
else if ( global_badge.click_url ) else if ( mode == 1 && global_badge.click_url )
url = global_badge.click_url; url = global_badge.click_url;
else if ( (bd.click_action === 'sub' || global_badge.click_action === 'sub') && ds.room_login ) else if ( mode == 1 && (bd.click_action === 'sub' || global_badge.click_action === 'sub') && ds.room_login )
url = `https://www.twitch.tv/subs/${ds.room_login}`; url = `https://www.twitch.tv/subs/${ds.room_login}`;
else else
continue; click_badge = bd;
break; break;
@ -578,6 +602,17 @@ export default class Badges extends Module {
} }
} }
if (click_badge) {
const fine = this.resolve('site.fine');
if (fine) {
const line = fine.searchParent(target, n => n.openBadgeDetails && n.props?.message);
if (line) {
line.openBadgeDetails(click_badge, event);
return;
}
}
}
if ( url ) { if ( url ) {
const link = createElement('a', { const link = createElement('a', {
target: '_blank', target: '_blank',
@ -630,7 +665,7 @@ export default class Badges extends Module {
bdata = tb && tb[badge_id], bdata = tb && tb[badge_id],
cat = bdata && bdata.__cat || 'm-twitch'; cat = bdata && bdata.__cat || 'm-twitch';
if ( is_hidden || (is_hidden == null && hidden_badges[cat]) ) if ( ! badge_id || is_hidden || (is_hidden == null && hidden_badges[cat]) )
continue; continue;
if ( has(BADGE_POSITIONS, badge_id) ) if ( has(BADGE_POSITIONS, badge_id) )
@ -922,6 +957,8 @@ export default class Badges extends Module {
async loadGlobalBadges(tries = 0) { async loadGlobalBadges(tries = 0) {
this.load_tracker.schedule('chat-data', 'ffz-global-badges');
let response, data; let response, data;
if ( this.experiments.getAssignment('api_load') && tries < 1 ) if ( this.experiments.getAssignment('api_load') && tries < 1 )
@ -930,23 +967,27 @@ export default class Badges extends Module {
} catch(err) { /* do nothing */ } } catch(err) { /* do nothing */ }
try { try {
response = await fetch(`${API_SERVER}/v1/badges/ids`); response = await fetch(`${this.staging.api}/v1/badges/ids`);
} catch(err) { } catch(err) {
tries++; tries++;
if ( tries < 10 ) if ( tries < 10 )
return setTimeout(() => this.loadGlobalBadges(tries), 500 * tries); return setTimeout(() => this.loadGlobalBadges(tries), 500 * tries);
this.log.error('Error loading global badge data.', err); this.log.error('Error loading global badge data.', err);
this.load_tracker.notify('chat-data', 'ffz-global-badges', false);
return false; return false;
} }
if ( ! response.ok ) if ( ! response.ok ) {
this.load_tracker.notify('chat-data', 'ffz-global-badges', false);
return false; return false;
}
try { try {
data = await response.json(); data = await response.json();
} catch(err) { } catch(err) {
this.log.error('Error parsing global badge data.', err); this.log.error('Error parsing global badge data.', err);
this.load_tracker.notify('chat-data', 'ffz-global-badges', false);
return false; return false;
} }
@ -966,7 +1007,7 @@ export default class Badges extends Module {
name = badge?.name; name = badge?.name;
let c = 0; let c = 0;
if ( name === 'supporter' || name === 'bot' ) { if ( name === 'supporter' || name === 'subwoofer' || name === 'bot' ) {
this.setBulk('ffz-global', badge_id, data.users[badge_id].map(x => String(x))); this.setBulk('ffz-global', badge_id, data.users[badge_id].map(x => String(x)));
/*this.supporter_id = badge_id; /*this.supporter_id = badge_id;
for(const user_id of data.users[badge_id]) for(const user_id of data.users[badge_id])
@ -988,6 +1029,7 @@ export default class Badges extends Module {
this.log.info(`Loaded ${badges} badges and assigned them to ${users} users.`); this.log.info(`Loaded ${badges} badges and assigned them to ${users} users.`);
this.buildBadgeCSS(); this.buildBadgeCSS();
this.load_tracker.notify('chat-data', 'ffz-global-badges');
} }
@ -1003,8 +1045,8 @@ export default class Badges extends Module {
data.replaces = true; data.replaces = true;
} }
if ( ! data.addon && (data.name === 'developer' || data.name === 'supporter') ) if ( ! data.addon && (data.name === 'developer' || data.name === 'subwoofer' || data.name === 'supporter') )
data.click_url = 'https://www.frankerfacez.com/donate'; data.click_url = 'https://www.frankerfacez.com/subscribe';
} }
if ( generate_css ) if ( generate_css )

View file

@ -1,5 +1,6 @@
<script> <script>
import {FFZEvent} from 'utilities/events';
import {has, timeout} from 'utilities/object'; import {has, timeout} from 'utilities/object';
const ERROR_IMAGE = 'https://static-cdn.jtvnw.net/emoticons/v1/58765/2.0'; const ERROR_IMAGE = 'https://static-cdn.jtvnw.net/emoticons/v1/58765/2.0';
@ -8,13 +9,14 @@ let tokenizer;
export default { export default {
props: ['data', 'url', 'events', 'forceFull', 'forceUnsafe', 'forceMedia', 'forceMid'], props: ['data', 'url', 'events', 'forceFull', 'forceUnsafe', 'forceMedia', 'forceMid', 'noLink', 'noTooltip', 'noElevation', 'noUnsafe'],
data() { data() {
return { return {
has_tokenizer: false, has_tokenizer: false,
loaded: false, loaded: false,
version: null, version: null,
player_state: {},
fragments: {}, fragments: {},
error: null, error: null,
accent: null, accent: null,
@ -23,6 +25,7 @@ export default {
full: null, full: null,
unsafe: false, unsafe: false,
urls: null, urls: null,
i18n_prefix: null,
allow_media: false, allow_media: false,
allow_unsafe: false allow_unsafe: false
} }
@ -55,14 +58,40 @@ export default {
this.listen(); this.listen();
this.load(); this.load();
this.handle_click = this.handleClick.bind(this);
}, },
beforeDestroy() { beforeDestroy() {
this.unlisten(); this.unlisten();
this.clearRefresh(); this.clearRefresh();
this.handle_click = null;
}, },
methods: { methods: {
handleClick(event) {
if ( ! this.events.emit || event.ctrlKey || event.shiftKey )
return;
const target = event.currentTarget,
ds = target?.dataset;
if ( ! ds )
return;
const evt = new FFZEvent({
url: ds.url ?? target.href,
source: event
});
this.events.emit('chat:click-link', evt);
if ( evt.defaultPrevented ) {
event.preventDefault();
return true;
}
},
async loadTokenizer() { async loadTokenizer() {
if ( tokenizer ) if ( tokenizer )
this.has_tokenizer = true; this.has_tokenizer = true;
@ -104,6 +133,7 @@ export default {
reset(refresh = false) { reset(refresh = false) {
this.clearRefresh(); this.clearRefresh();
this.player_state = {};
this.loaded = false; this.loaded = false;
this.error = null; this.error = null;
this.version = null; this.version = null;
@ -114,6 +144,7 @@ export default {
this.fragments = {}; this.fragments = {};
this.unsafe = false; this.unsafe = false;
this.urls = null; this.urls = null;
this.i18n_prefix = null;
this.allow_media = false; this.allow_media = false;
this.allow_unsafe = false; this.allow_unsafe = false;
this.load(refresh); this.load(refresh);
@ -179,6 +210,7 @@ export default {
this.fragments = data.fragments ?? {}; this.fragments = data.fragments ?? {};
this.unsafe = data.unsafe; this.unsafe = data.unsafe;
this.urls = data.urls; this.urls = data.urls;
this.i18n_prefix = data.i18n_prefix;
this.allow_media = data.allow_media; this.allow_media = data.allow_media;
this.allow_unsafe = data.allow_unsafe; this.allow_unsafe = data.allow_unsafe;
}, },
@ -199,7 +231,7 @@ export default {
}, },
renderUnsafe(h) { renderUnsafe(h) {
if ( ! this.unsafe ) if ( ! this.unsafe || this.noUnsafe )
return null; return null;
const reasons = Array.from(new Set(this.urls.map(url => url.flags).flat())).join(', '); const reasons = Array.from(new Set(this.urls.map(url => url.flags).flat())).join(', ');
@ -223,8 +255,13 @@ export default {
}, },
renderBody(h) { renderBody(h) {
let body = this.forceFull ? this.full : let body;
this.forceMid ? this.mid : this.short; if ( this.forceFull === true || (this.forceFull !== false && this.full) )
body = this.full;
else if ( this.forceMid === true || (this.forceMid !== false && this.mid) )
body = this.mid;
else
body = this.short;
if ( this.has_tokenizer && this.version && this.version > tokenizer.VERSION ) if ( this.has_tokenizer && this.version && this.version > tokenizer.VERSION )
body = null; body = null;
@ -237,7 +274,17 @@ export default {
tList: (...args) => this.tList(...args), tList: (...args) => this.tList(...args),
i18n: this.getI18n(), i18n: this.getI18n(),
last_player: 0,
player_state: this.player_state,
togglePlayer: id => {
this.$set(this.player_state, id, ! this.player_state[id]);
},
link_click_handler: this.handle_click,
fragments: this.fragments, fragments: this.fragments,
i18n_prefix: this.i18n_prefix,
allow_media: this.forceMedia ?? this.allow_media, allow_media: this.forceMedia ?? this.allow_media,
allow_unsafe: this.forceUnsafe ?? this.allow_unsafe allow_unsafe: this.forceUnsafe ?? this.allow_unsafe
@ -291,12 +338,12 @@ export default {
render(h) { render(h) {
let content = h('div', { let content = h('div', {
class: 'tw-flex tw-flex-nowrap tw-pd-05' class: 'tw-flex tw-flex-nowrap tw-full-width tw-pd-05'
}, this.renderCard(h)); }, this.renderCard(h));
const tooltip = this.has_full && ! this.forceFull; const tooltip = ! this.noTooltip && this.has_full && ! this.forceFull;
if ( this.url ) if ( this.url && ! this.noLink )
content = h('a', { content = h('a', {
class: [ class: [
tooltip && 'ffz-tooltip', tooltip && 'ffz-tooltip',
@ -304,6 +351,9 @@ export default {
!this.error && 'ffz-interactable--hover-enabled', !this.error && 'ffz-interactable--hover-enabled',
'tw-block tw-border-radius-medium tw-full-width ffz-interactable ffz-interactable--default tw-interactive' 'tw-block tw-border-radius-medium tw-full-width ffz-interactable ffz-interactable--default tw-interactive'
], ],
on: {
click: this.handleClick
},
attrs: { attrs: {
'data-tooltip-type': 'link', 'data-tooltip-type': 'link',
'data-url': this.url, 'data-url': this.url,
@ -325,7 +375,8 @@ export default {
return h('div', { return h('div', {
class: [ class: [
'tw-border-radius-medium tw-elevation-1 ffz--chat-card tw-relative', 'tw-border-radius-medium ffz--chat-card tw-relative',
this.noElevation ? '' : 'tw-elevation-1',
this.unsafe ? 'ffz--unsafe' : '' this.unsafe ? 'ffz--unsafe' : ''
], ],
style: { style: {

View file

@ -92,7 +92,12 @@ export default class Emoji extends Module {
if (enable_replace_joiner) if (enable_replace_joiner)
this.settings.add('chat.emoji.replace-joiner', { this.settings.add('chat.emoji.replace-joiner', {
default: 2, default: 1,
process(ctx, val) {
if ( val === 2 )
return 1;
return val;
},
ui: { ui: {
path: 'Chat > Behavior >> Emoji', path: 'Chat > Behavior >> Emoji',
title: 'Emoji Joiner Workaround', title: 'Emoji Joiner Workaround',
@ -101,7 +106,7 @@ export default class Emoji extends Module {
data: [ data: [
{value: 0, title: 'Disabled'}, {value: 0, title: 'Disabled'},
{value: 1, title: 'Display Only'}, {value: 1, title: 'Display Only'},
{value: 2, title: 'Display and Send'} {value: 3, title: 'Display and Send'}
] ]
} }
}); });

View file

@ -1,6 +1,11 @@
query FFZ_GetEmoteInfo($id: ID!) { query FFZ_GetEmoteInfo($id: ID!) {
emote(id: $id) { emote(id: $id) {
id id
artist {
id
login
displayName
}
owner { owner {
id id
login login

File diff suppressed because it is too large Load diff

View file

@ -7,9 +7,11 @@
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import Module from 'utilities/module'; import Module from 'utilities/module';
import {createElement, ManagedStyle} from 'utilities/dom';
import {timeout, has, glob_to_regex, escape_regex, split_chars} from 'utilities/object';
import {Color} from 'utilities/color'; 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} from 'utilities/object';
import Badges from './badges'; import Badges from './badges';
import Emotes from './emotes'; import Emotes from './emotes';
@ -20,11 +22,10 @@ import Room from './room';
import User from './user'; import User from './user';
import * as TOKENIZERS from './tokenizers'; import * as TOKENIZERS from './tokenizers';
import * as RICH_PROVIDERS from './rich_providers'; import * as RICH_PROVIDERS from './rich_providers';
import * as LINK_PROVIDERS from './link_providers';
import Actions from './actions'; import Actions from './actions/actions';
import { getFontsList } from 'src/utilities/fonts';
export const 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]';
function sortPriorityColorTerms(list) { function sortPriorityColorTerms(list) {
list.sort((a,b) => { list.sort((a,b) => {
@ -37,19 +38,19 @@ function sortPriorityColorTerms(list) {
return list; return list;
} }
function addSeparators(str) {
return `(^|.*?${SEPARATORS})(?:${str})(?=$|${SEPARATORS})`
}
const TERM_FLAGS = ['g', 'gi']; const TERM_FLAGS = ['g', 'gi'];
const UNBLOCKABLE_TOKENS = [
'filter_test'
];
function formatTerms(data) { function formatTerms(data) {
const out = []; const out = [];
for(let i=0; i < data.length; i++) { for(let i=0; i < data.length; i++) {
const list = data[i]; const list = data[i];
if ( list[0].length ) if ( list[0].length )
list[1].push(addSeparators(list[0].join('|'))); list[1].push(addWordSeparators(list[0].join('|')));
out.push(list[1].length ? new RegExp(list[1].join('|'), TERM_FLAGS[i] || 'gi') : null); out.push(list[1].length ? new RegExp(list[1].join('|'), TERM_FLAGS[i] || 'gi') : null);
} }
@ -71,17 +72,20 @@ export default class Chat extends Module {
this.inject('i18n'); this.inject('i18n');
this.inject('tooltips'); this.inject('tooltips');
this.inject('experiments'); this.inject('experiments');
this.inject('staging');
this.inject('load_tracker');
this.inject(Badges); this.inject(Badges);
this.inject(Emotes); this.inject(Emotes);
this.inject(Emoji); this.inject(Emoji);
this.inject(Actions); this.inject(Actions);
this.inject(Overrides); this.inject('overrides', Overrides);
this._link_info = {}; this._link_info = {};
// Bind for JSX stuff // Bind for JSX stuff
this.clickToReveal = this.clickToReveal.bind(this); this.clickToReveal = this.clickToReveal.bind(this);
this.handleLinkClick = this.handleLinkClick.bind(this);
this.handleMentionClick = this.handleMentionClick.bind(this); this.handleMentionClick = this.handleMentionClick.bind(this);
this.handleReplyClick = this.handleReplyClick.bind(this); this.handleReplyClick = this.handleReplyClick.bind(this);
@ -101,6 +105,9 @@ export default class Chat extends Module {
this.rich_providers = {}; this.rich_providers = {};
this.__rich_providers = []; this.__rich_providers = [];
this.link_providers = {};
this.__link_providers = [];
this._hl_reasons = {}; this._hl_reasons = {};
this.addHighlightReason('mention', 'Mentioned'); this.addHighlightReason('mention', 'Mentioned');
this.addHighlightReason('user', 'Highlight User'); this.addHighlightReason('user', 'Highlight User');
@ -447,6 +454,29 @@ export default class Chat extends Module {
} }
}); });
this.settings.add('chat.filtering.hidden-tokens', {
default: [],
type: 'array_merge',
always_inherit: true,
process(ctx, val) {
const out = new Set;
for(const v of val)
if ( v?.v || ! UNBLOCKABLE_TOKENS.includes(v.v) )
out.add(v.v);
return out;
},
ui: {
path: 'Chat > Appearance >> Hidden Token Types @{"description":"This filter allows you to prevent specific content token types from appearing chat messages, such as hiding all cheers or emotes."}',
component: 'blocked-types',
data: () => Object
.keys(this.tokenizers)
.filter(key => ! UNBLOCKABLE_TOKENS.includes(key) && this.tokenizers[key]?.render)
.sort()
}
});
this.settings.add('chat.filtering.highlight-basic-users', { this.settings.add('chat.filtering.highlight-basic-users', {
default: [], default: [],
type: 'array_merge', type: 'array_merge',
@ -873,6 +903,16 @@ export default class Chat extends Module {
} }
}); });
this.settings.add('chat.filtering.all-mentions', {
default: false,
ui: {
component: 'setting-check-box',
path: 'Chat > Filtering > General >> Appearance',
title: 'Display mentions for all users without requiring an at sign (@).',
description: '**Note**: This setting can increase memory usage and impact chat performance.'
}
});
this.settings.add('chat.filtering.color-mentions', { this.settings.add('chat.filtering.color-mentions', {
default: false, default: false,
ui: { ui: {
@ -883,6 +923,13 @@ export default class Chat extends Module {
} }
}); });
this.settings.add('chat.filtering.need-colors', {
requires: ['chat.filtering.all-mentions' ,'chat.filtering.color-mentions'],
process(ctx) {
return ctx.get('chat.filtering.all-mentions') || ctx.get('chat.filtering.color-mentions')
}
});
this.settings.add('chat.filtering.bold-mentions', { this.settings.add('chat.filtering.bold-mentions', {
default: true, default: true,
ui: { ui: {
@ -1191,7 +1238,7 @@ export default class Chat extends Module {
room.buildBitsCSS(); room.buildBitsCSS();
}); });
this.context.on('changed:chat.filtering.color-mentions', async val => { this.context.on('changed:chat.filtering.need-colors', async val => {
if ( val ) if ( val )
await this.createColorCache(); await this.createColorCache();
else else
@ -1199,6 +1246,9 @@ export default class Chat extends Module {
this.emit(':update-line-tokens'); this.emit(':update-line-tokens');
}); });
this.context.on('changed:chat.filtering.all-mentions', () => this.emit(':update-line-tokens'));
this.context.on('changed:chat.filtering.color-mentions', () => this.emit(':update-line-tokens'));
} }
@ -1219,8 +1269,11 @@ export default class Chat extends Module {
onEnable() { onEnable() {
this.socket = this.resolve('socket'); this.socket = this.resolve('socket');
this.pubsub = this.resolve('pubsub');
if ( this.context.get('chat.filtering.color-mentions') ) this.on('site.subpump:pubsub-message', this.onPubSub, this);
if ( this.context.get('chat.filtering.need-colors') )
this.createColorCache().then(() => this.emit(':update-line-tokens')); this.createColorCache().then(() => this.emit(':update-line-tokens'));
for(const key in TOKENIZERS) for(const key in TOKENIZERS)
@ -1230,6 +1283,85 @@ export default class Chat extends Module {
for(const key in RICH_PROVIDERS) for(const key in RICH_PROVIDERS)
if ( has(RICH_PROVIDERS, key) ) if ( has(RICH_PROVIDERS, key) )
this.addRichProvider(RICH_PROVIDERS[key]); this.addRichProvider(RICH_PROVIDERS[key]);
for(const key in LINK_PROVIDERS)
if ( has(LINK_PROVIDERS, key) )
this.addLinkProvider(LINK_PROVIDERS[key]);
this.on('chat:reload-data', flags => {
for(const room of this.iterateRooms())
room.load_data();
});
this.on('chat:get-tab-commands', event => {
event.commands.push({
name: 'ffz:reload',
description: this.i18n.t('chat.command.reload', 'Reload FFZ and add-on chat data (emotes, badges, etc.)'),
permissionLevel: 0,
ffz_group: 'FrankerFaceZ'
});
});
this.triggered_reload = false;
this.on('chat:ffz-command:reload', event => {
if ( this.triggered_reload )
return;
const sc = this.resolve('site.chat');
if ( sc?.addNotice )
sc.addNotice('*', this.i18n.t('chat.command.reload.starting', 'FFZ is reloading data...'));
this.triggered_reload = true;
this.emit('chat:reload-data');
});
this.on('load_tracker:complete:chat-data', (list) => {
if ( this.triggered_reload ) {
const sc = this.resolve('site.chat');
if ( sc?.addNotice )
sc.addNotice('*', this.i18n.t('chat.command.reload.done', 'FFZ has finished reloading data. (Sources: {list})', {list: list.join(', ')}));
}
this.triggered_reload = false;
});
}
onPubSub(event) {
if ( event.prefix === 'stream-chat-room-v1' && event.message.type === 'chat_rich_embed' ) {
const data = event.message.data,
url = data.request_url,
providers = this.__link_providers;
// Don't re-cache.
if ( this._link_info[url] )
return;
for(const provider of providers) {
const match = provider.test.call(this, url);
if ( match ) {
const processed = provider.receive ? provider.receive.call(this, match, data) : data;
let result = provider.process.call(this, match, processed);
if ( !(result instanceof Promise) )
result = Promise.resolve(result);
result.then(value => {
// If something is already running, don't override it.
let info = this._link_info[url];
if ( info )
return;
// Save the value.
this._link_info[url] = [true, Date.now() + 120000, value];
});
return;
}
}
}
} }
@ -1375,6 +1507,31 @@ export default class Chat extends Module {
} }
handleLinkClick(event) {
if ( event.ctrlKey || event.shiftKey )
return;
const target = event.currentTarget,
ds = target?.dataset;
if ( ! ds )
return;
const evt = new FFZEvent({
url: ds.url ?? target.href,
source: event
});
this.emit('chat:click-link', evt);
if ( evt.defaultPrevented ) {
event.preventDefault();
event.stopPropagation();
return true;
}
}
handleReplyClick(event) { handleReplyClick(event) {
const target = event.target, const target = event.target,
fine = this.resolve('site.fine'); fine = this.resolve('site.fine');
@ -1497,7 +1654,9 @@ export default class Chat extends Module {
{ {
type: 'reply', type: 'reply',
text: reply.parentDisplayName, text: reply.parentDisplayName,
color: this.color_cache ? this.color_cache.get(reply.parentUserLogin) : null, color: (this.context.get('chat.filtering.color-mentions') && this.color_cache)
? this.color_cache.get(reply.parentUserLogin)
: null,
recipient: reply.parentUserLogin recipient: reply.parentUserLogin
}, },
{ {
@ -1600,6 +1759,10 @@ export default class Chat extends Module {
b[item.setID] = item.version; b[item.setID] = item.version;
} }
// Validate User Type
if ( user.type == null && msg.badges && msg.badges.moderator )
user.type = 'mod';
// Standardize Timestamp // Standardize Timestamp
if ( ! msg.timestamp && msg.sentAt ) if ( ! msg.timestamp && msg.sentAt )
msg.timestamp = new Date(msg.sentAt).getTime(); msg.timestamp = new Date(msg.sentAt).getTime();
@ -1790,7 +1953,7 @@ export default class Chat extends Module {
className: 'chat-author__intl-login' className: 'chat-author__intl-login'
}, ` (${user.login})`)); }, ` (${user.login})`));
return [out]; return out;
} }
@ -1834,6 +1997,11 @@ export default class Chat extends Module {
addTokenizer(tokenizer) { addTokenizer(tokenizer) {
const type = tokenizer.type; const type = tokenizer.type;
if ( has(this.tokenizers, type) ) {
this.log.warn(`Tried adding tokenizer of type '${type}' when one was already present.`);
return;
}
this.tokenizers[type] = tokenizer; this.tokenizers[type] = tokenizer;
if ( tokenizer.priority == null ) if ( tokenizer.priority == null )
tokenizer.priority = 0; tokenizer.priority = 0;
@ -1873,8 +2041,48 @@ export default class Chat extends Module {
return tokenizer; return tokenizer;
} }
addLinkProvider(provider) {
const type = provider.type;
if ( has(this.link_providers, type) ) {
this.log.warn(`Tried adding link provider of type '${type}' when one was already present.`);
return;
}
this.link_providers[type] = provider;
if ( provider.priority == null )
provider.priority = 0;
this.__link_providers.push(provider);
this.__link_providers.sort((a,b) => {
if ( a.priority > b.priority ) return -1;
if ( a.priority < b.priority ) return 1;
return a.type < b.type;
});
}
removeLinkProvider(provider) {
let type;
if ( typeof provider === 'string' ) type = provider;
else type = provider.type;
provider = this.link_providers[type];
if ( ! provider )
return null;
const idx = this.__link_providers.indexOf(provider);
if ( idx !== -1 )
this.__link_providers.splice(idx, 1);
return provider;
}
addRichProvider(provider) { addRichProvider(provider) {
const type = provider.type; const type = provider.type;
if ( has(this.rich_providers, type) ) {
this.log.warn(`Tried adding rich provider of type '${type}' when one was already present.`);
return;
}
this.rich_providers[type] = provider; this.rich_providers[type] = provider;
if ( provider.priority == null ) if ( provider.priority == null )
provider.priority = 0; provider.priority = 0;
@ -1937,11 +2145,12 @@ export default class Chat extends Module {
const want_mid = this.context.get('chat.rich.want-mid'); const want_mid = this.context.get('chat.rich.want-mid');
for(const token of tokens) { for(const token of tokens) {
for(const provider of providers) if ( token.allow_rich ?? true )
if ( provider.test.call(this, token, msg) ) { for(const provider of providers)
token.hidden = provider.can_hide_token && (this.context.get('chat.rich.hide-tokens') || provider.hide_token); if ( provider.test.call(this, token, msg) ) {
return provider.process.call(this, token, want_mid); token.hidden = provider.can_hide_token && (this.context.get('chat.rich.hide-tokens') || provider.hide_token);
} return provider.process.call(this, token, want_mid);
}
} }
} }
@ -1984,12 +2193,14 @@ export default class Chat extends Module {
tokenizers = this.tokenizers, tokenizers = this.tokenizers,
l = tokens.length; l = tokens.length;
const hidden = this.context.get('chat.filtering.hidden-tokens');
for(let i=0; i < l; i++) { for(let i=0; i < l; i++) {
const token = tokens[i], const token = tokens[i],
type = token.type, type = token.type,
tk = tokenizers[type]; tk = tokenizers[type];
if ( token.hidden ) if ( token.hidden || hidden.has(type) )
continue; continue;
let res; let res;
@ -2085,6 +2296,17 @@ export default class Chat extends Module {
cbs[success ? 0 : 1](data); cbs[success ? 0 : 1](data);
} }
// Try using a link provider.
for(const lp of this.__link_providers) {
const match = lp.test.call(this, url);
if ( match ) {
timeout(lp.process.call(this, match), 15000)
.then(data => handle(true, data))
.catch(err => handle(false, err));
return;
}
}
let provider = this.settings.get('debug.link-resolver.source'); let provider = this.settings.get('debug.link-resolver.source');
if ( provider == null ) if ( provider == null )
provider = this.experiments.getAssignment('api_links') ? 'test' : 'socket'; provider = this.experiments.getAssignment('api_links') ? 'test' : 'socket';
@ -2109,6 +2331,9 @@ export default class Chat extends Module {
} }
fixLinkInfo(data) { fixLinkInfo(data) {
if ( ! data )
return data;
if ( data.error && data.message ) if ( data.error && data.message )
data.error = data.message; data.error = data.message;
@ -2122,7 +2347,9 @@ export default class Chat extends Module {
image: {type: 'image', url: ERROR_IMAGE}, image: {type: 'image', url: ERROR_IMAGE},
title: {type: 'i18n', key: 'card.error', phrase: 'An error occurred.'}, title: {type: 'i18n', key: 'card.error', phrase: 'An error occurred.'},
subtitle: data.error subtitle: data.error
} },
unsafe: data.unsafe,
urls: data.urls
} }
if ( data.v < 5 && ! data.short && ! data.full && (data.title || data.desc_1 || data.desc_2) ) { if ( data.v < 5 && ! data.short && ! data.full && (data.title || data.desc_1 || data.desc_2) ) {

View file

@ -0,0 +1,459 @@
'use strict';
// ============================================================================
// Rich Content Providers
// ============================================================================
const CLIP_URL = /^(?:https?:\/\/)?clips\.twitch\.tv\/([a-z0-9-_=]+)(?:\/)?(\w+)?(?:\/edit)?/i;
const NEW_CLIP_URL = /^(?:https?:\/\/)?(?:(?:www|m)\.)?twitch\.tv\/\w+\/clip\/([a-z0-9-_=]+)/i;
const VIDEO_URL = /^(?:https?:\/\/)?(?:www\.)?twitch\.tv\/(?:\w+\/v|videos)\/(\w+)/;
const USER_URL = /^(?:https?:\/\/)?(?:www\.)?twitch\.tv\/([^/]+)$/;
const BAD_USERS = [
'directory', '_deck', 'p', 'downloads', 'jobs', 'turbo', 'settings', 'friends',
'subscriptions', 'inventory', 'wallet', 'store', 'drops', 'search', 'prime'
];
import GET_CLIP from './clip_info.gql';
import GET_VIDEO from './video_info.gql';
// ============================================================================
// Clips
// ============================================================================
export const Clip = {
type: 'clip',
test(url) {
const match = CLIP_URL.exec(url) || NEW_CLIP_URL.exec(url);
if ( match && match[1] && match[1] !== 'create' )
return match[1];
},
receive(match, data) {
const cd = data?.twitch_metadata?.clip_metadata;
if ( ! cd )
return;
return {
id: cd.id,
slug: cd.slug,
title: data.title,
thumbnailURL: data.thumbnail_url,
curator: {
id: cd.curator_id,
displayName: data.author_name
},
broadcaster: {
id: cd.broadcaster_id,
displayName: cd.channel_display_name
},
game: {
displayName: cd.game
}
}
},
async process(match, received) {
let clip = received;
if ( ! clip ) {
const apollo = this.resolve('site.apollo');
if ( ! apollo )
return null;
const result = await apollo.client.query({
query: GET_CLIP,
variables: {
slug: match
}
});
clip = result?.data?.clip;
}
if ( ! clip || ! clip.broadcaster )
return null;
const game = clip.game,
game_display = game && game.displayName;
let user = {
type: 'style', weight: 'semibold', color: 'alt-2',
content: clip.broadcaster.displayName
};
if ( clip.broadcaster.login )
user = {
type: 'link', url: `https://www.twitch.tv/${clip.broadcaster.login}`,
content: user
};
const subtitle = game_display ? {
type: 'i18n', key: 'clip.desc.1.playing', phrase: '{user} playing {game}', content: {
user,
game: {type: 'style', weight: 'semibold', content: game_display}
}
} : {type: 'i18n', key: 'clip.desc.1', phrase: 'Clip of {user}', content: {user}};
let curator = clip.curator ? {
type: 'style', color: 'alt-2',
content: clip.curator.displayName
} : {type: 'i18n', key: 'clip.unknown', phrase: 'Unknown'};
if ( clip.curator?.login )
curator = {
type: 'link', url: `https://www.twitch.tv/${clip.curator.login}`,
content: curator
};
let extra;
if ( clip.viewCount > 0 )
extra = {
type: 'i18n', key: 'clip.desc.2',
phrase: 'Clipped by {curator} — {views, plural, one {# View} other {# Views}}',
content: {
curator,
views: clip.viewCount
}
};
else
extra = {
type: 'i18n', key: 'clip.desc.no-views',
phrase: 'Clipped by {curator}',
content: {
curator
}
};
return {
accent: '#6441a4',
short: {
type: 'header',
image: {type: 'image', url: clip.thumbnailURL, sfw: true, aspect: 16/9},
title: clip.title,
subtitle,
extra
}
};
}
}
// ============================================================================
// Users
// ============================================================================
export const User = {
type: 'user',
test(url) {
const match = USER_URL.exec(url);
if ( match && ! BAD_USERS.includes(match[1]) )
return match[1];
},
async process(match) {
const twitch_data = this.resolve('site.twitch_data'),
user = twitch_data ? await twitch_data.getUser(null, match) : null;
if ( ! user || ! user.id )
return null;
const game = user.broadcastSettings?.game?.displayName,
stream_id = user.stream?.id;
const fragments = {
avatar: {
type: 'image',
url: user.profileImageURL,
rounding: -1,
aspect: 1
},
desc: user.description,
title: [user.displayName]
};
if ( stream_id && game )
fragments.game = {type: 'style', weight: 'semibold', content: game};
if ( user.displayName.trim().toLowerCase() !== user.login )
fragments.title.push({
type: 'style', color: 'alt-2',
content: [' (', user.login, ')']
});
if ( user.roles?.isPartner )
fragments.title.push({
type: 'style', color: 'link',
content: {type: 'icon', name: 'verified'}
});
const full = [
{
type: 'header',
image: {type: 'ref', name: 'avatar'},
title: {type: 'ref', name: 'title'},
},
{
type: 'box',
'mg-y': 'small',
wrap: 'pre-wrap',
lines: 5,
content: {
type: 'ref',
name: 'desc'
}
}
];
if ( stream_id && game ) {
const thumb_url = user.stream.previewImageURL
? user.stream.previewImageURL
.replace('{width}', '320')
.replace('{height}', '180')
: null;
full.push({
type: 'link',
url: `https://www.twitch.tv/${user.login}`,
embed: true,
interactive: true,
tooltip: false,
content: [
{
type: 'conditional',
media: true,
content: {
type: 'gallery',
items: [
{
type: 'image',
url: thumb_url,
aspect: 16/9
}
]
}
},
{
type: 'box',
'mg-y': 'small',
lines: 2,
content: user.broadcastSettings.title
},
{
type: 'ref',
name: 'game'
}
]
});
}
full.push({
type: 'header',
compact: true,
subtitle: [
{
type: 'icon',
name: 'twitch'
},
' Twitch'
]
});
return {
v: 5,
accent: user.primaryColorHex ? `#${user.primaryColorHex}` : null,
fragments,
short: {
type: 'header',
image: {type: 'ref', name: 'avatar'},
title: {type: 'ref', name: 'title'},
subtitle: {type: 'ref', name: 'desc'},
extra: stream_id ? {
type: 'i18n',
key: 'cards.user.streaming',
phrase: 'streaming {game}',
content: {
game: {type: 'ref', name: 'game'}
}
} : null
},
full
}
}
}
// ============================================================================
// Videos
// ============================================================================
export const Video = {
type: 'video',
test(url) {
const match = VIDEO_URL.exec(url);
if ( match )
return match[1];
},
async process(match) {
const apollo = this.resolve('site.apollo');
if ( ! apollo )
return null;
const result = await apollo.client.query({
query: GET_VIDEO,
variables: {
id: match
}
});
if ( ! result || ! result.data || ! result.data.video || ! result.data.video.owner )
return null;
const video = result.data.video,
game = video.game,
game_display = game && game.displayName;
const fragments = {
title: video.title,
thumbnail: {
type: 'image',
url: video.previewThumbnailURL,
aspect: 16/9
}
};
const user = {
type: 'link',
url: `https://www.twitch.tv/${video.owner.login}`,
content: {
type: 'style',
weight: 'semibold',
color: 'alt-2',
content: video.owner.displayName
}
};
fragments.subtitle = video.game?.displayName
? {
type: 'i18n',
key: 'video.desc.1.playing',
phrase: 'Video of {user} playing {game}',
content: {
user,
game: {
type: 'style',
weight: 'semibold',
content: video.game.displayName
}
}
}
: {
type: 'i18n',
key: 'video.desc.1',
phrase: 'Video of {user}',
content: {
user
}
};
let length = video.lengthSeconds;
return {
v: 5,
fragments,
short: {
type: 'header',
image: {type: 'ref', name: 'thumbnail'},
title: {type: 'ref', name: 'title'},
subtitle: {type: 'ref', name: 'subtitle'},
extra: {
type: 'i18n',
key: 'video.desc.2',
phrase: '{length,duration} — {views,number} Views — {date,datetime}',
content: {
length,
views: video.viewCount,
date: video.publishedAt
}
}
},
full: [
{
type: 'header',
image: {
type: 'image',
url: video.owner.profileImageURL,
rounding: -1,
aspect: 1
},
title: {type: 'ref', name: 'title'},
subtitle: {type: 'ref', name: 'subtitle'}
},
{
type: 'box',
'mg-y': 'small',
lines: 5,
wrap: 'pre-wrap',
content: video.description
},
{
type: 'conditional',
media: true,
content: {
type: 'gallery',
items: [
{
type: 'overlay',
content: {type: 'ref', name: 'thumbnail'},
'top-left': {
type: 'format',
format: 'duration',
value: length
},
'bottom-left': {
type: 'i18n',
key: 'video.views',
phrase: '{views,number} views',
content: {
views: video.viewCount
}
}
}
]
}
},
{
type: 'header',
compact: true,
subtitle: [
{
type: 'icon',
name: 'twitch'
},
" Twitch • ",
{
type: 'format',
format: 'datetime',
value: video.publishedAt
}
]
}
]
};
}
}

View file

@ -25,6 +25,17 @@
@change="updateName" @change="updateName"
> >
<button
v-if="originalName"
class="tw-mg-l-05 tw-button tw-button--text ffz-il-tooltip__container"
@click="setOriginalName"
>
<span class="tw-button__text ffz-i-plus" />
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ t('setting.set-to-current', 'Set to Current') }}
</div>
</button>
<button <button
class="tw-mg-l-05 tw-button tw-button--text ffz-il-tooltip__container" class="tw-mg-l-05 tw-button tw-button--text ffz-il-tooltip__container"
:class="{'tw-button--disabled': name == null}" :class="{'tw-button--disabled': name == null}"
@ -51,6 +62,17 @@
@input="updateColor" @input="updateColor"
/> />
<button
v-if="originalColor"
class="tw-mg-l-05 tw-button tw-button--text ffz-il-tooltip__container"
@click="setOriginalColor"
>
<span class="tw-button__text ffz-i-plus" />
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ t('setting.set-to-current', 'Set to Current') }}
</div>
</button>
<button <button
class="tw-mg-l-05 tw-button tw-button--text ffz-il-tooltip__container" class="tw-mg-l-05 tw-button tw-button--text ffz-il-tooltip__container"
:class="{'tw-button--disabled': color == null}" :class="{'tw-button--disabled': color == null}"
@ -93,6 +115,16 @@ export default {
}, },
methods: { methods: {
setOriginalName() {
this.name = this.originalName;
this.setName(this.name);
},
setOriginalColor() {
this.color = this.originalColor;
this.setColor(this.color);
},
clearName() { clearName() {
this.name = null; this.name = null;
this.deleteName(); this.deleteName();

View file

@ -99,6 +99,9 @@ export default class Overrides extends Module {
name: this.getName(user.id), name: this.getName(user.id),
color: this.getColor(user.id), color: this.getColor(user.id),
originalName: user.displayName ?? user.login,
originalColor: user.color,
updateTip: () => tip.update(), updateTip: () => tip.update(),
setColor: val => this.setColor(user.id, val), setColor: val => this.setColor(user.id, val),
deleteColor: () => this.deleteColor(user.id), deleteColor: () => this.deleteColor(user.id),

View file

@ -4,24 +4,6 @@
// Rich Content Providers // Rich Content Providers
// ============================================================================ // ============================================================================
//const CLIP_URL = /^(?:https?:\/\/)?clips\.twitch\.tv\/(\w+)(?:\/)?(\w+)?(?:\/edit)?/;
//const NEW_CLIP_URL = /^(?:https?:\/\/)?(?:www\.)?twitch\.tv\/\w+\/clip\/(\w+)/;
const CLIP_URL = /^(?:https?:\/\/)?clips\.twitch\.tv\/([a-z0-9-_=]+)(?:\/)?(\w+)?(?:\/edit)?/i;
const NEW_CLIP_URL = /^(?:https?:\/\/)?(?:(?:www|m)\.)?twitch\.tv\/\w+\/clip\/([a-z0-9-_=]+)/i;
const VIDEO_URL = /^(?:https?:\/\/)?(?:www\.)?twitch\.tv\/(?:\w+\/v|videos)\/(\w+)/;
const USER_URL = /^(?:https?:\/\/)?(?:www\.)?twitch\.tv\/([^/]+)$/;
const BAD_USERS = [
'directory', '_deck', 'p', 'downloads', 'jobs', 'turbo', 'settings', 'friends',
'subscriptions', 'inventory', 'wallet'
];
import GET_CLIP from './clip_info.gql';
import GET_VIDEO from './video_info.gql';
import {truncate} from 'utilities/object';
// ============================================================================ // ============================================================================
// General Links // General Links
// ============================================================================ // ============================================================================
@ -32,10 +14,18 @@ export const Links = {
priority: -10, priority: -10,
test(token) { test(token) {
if ( ! this.context.get('chat.rich.all-links') && ! token.force_rich ) if ( token.type !== 'link' )
return false; return false;
return token.type === 'link' const url = token.url;
// Link providers always result in embeds.
for(const provider of this.__link_providers) {
if ( provider.test.call(this, url) )
return true;
}
return this.context.get('chat.rich.all-links') || token.force_rich;
}, },
process(token, want_mid) { process(token, want_mid) {
@ -70,279 +60,3 @@ export const Links = {
} }
} }
} }
// ============================================================================
// Users
// ============================================================================
export const Users = {
type: 'user',
can_hide_token: true,
test(token) {
if ( token.type !== 'link' || (! this.context.get('chat.rich.all-links') && ! token.force_rich) )
return false;
return USER_URL.test(token.url);
},
process(token) {
const match = USER_URL.exec(token.url),
twitch_data = this.resolve('site.twitch_data');
if ( ! twitch_data || ! match || BAD_USERS.includes(match[1]) )
return;
return {
url: token.url,
getData: async () => {
const user = await twitch_data.getUser(null, match[1]);
if ( ! user || ! user.id )
return null;
const game = user.broadcastSettings?.game?.displayName,
stream_id = user.stream?.id;
let subtitle
if ( stream_id && game )
subtitle = {
type: 'i18n',
key: 'cards.user.streaming', phrase: 'streaming {game}', content: {
game: {type: 'style', weight: 'semibold', content: game}
}
};
const extra = truncate(user.description);
const title = [user.displayName];
if ( user.displayName.trim().toLowerCase() !== user.login )
title.push({
type: 'style', color: 'alt-2',
content: [' (', user.login, ')']
});
if ( user.roles?.isPartner )
title.push({
type: 'style', color: 'link',
content: {type: 'icon', name: 'verified'}
});
/*const full = [{
type: 'header',
image: {type: 'image', url: user.profileImageURL, rounding: -1, aspect: 1},
title,
subtitle,
extra: stream_id ? extra : null
}];
if ( stream_id ) {
full.push({type: 'box', 'mg-y': 'small', lines: 1, content: user.broadcastSettings.title});
full.push({type: 'conditional', content: {
type: 'gallery', items: [{
type: 'image', aspect: 16/9, sfw: false, url: user.stream.previewImageURL
}]
}});
} else
full.push({type: 'box', 'mg-y': 'small', wrap: 'pre-wrap', lines: 5, content: truncate(user.description, 1000, undefined, undefined, false)})
full.push({
type: 'fieldset',
fields: [
{
name: {type: 'i18n', key: 'embed.twitch.views', phrase: 'Views'},
value: {type: 'format', format: 'number', value: user.profileViewCount},
inline: true
},
{
name: {type: 'i18n', key: 'embed.twitch.followers', phrase: 'Followers'},
value: {type: 'format', format: 'number', value: user.followers?.totalCount},
inline: true
}
]
});
full.push({
type: 'header',
subtitle: [{type: 'icon', name: 'twitch'}, ' Twitch']
});*/
return {
url: token.url,
accent: user.primaryColorHex ? `#${user.primaryColorHex}` : null,
short: {
type: 'header',
image: {type: 'image', url: user.profileImageURL, rounding: -1, aspect: 1},
title,
subtitle,
extra
}
}
}
}
}
}
// ============================================================================
// Clips
// ============================================================================
export const Clips = {
type: 'clip',
can_hide_token: true,
test(token) {
if ( token.type !== 'link' )
return false;
return CLIP_URL.test(token.url) || NEW_CLIP_URL.test(token.url);
},
process(token) {
let match = CLIP_URL.exec(token.url);
if ( ! match )
match = NEW_CLIP_URL.exec(token.url);
const apollo = this.resolve('site.apollo');
if ( ! apollo || ! match || match[1] === 'create' )
return;
return {
url: token.url,
getData: async () => {
const result = await apollo.client.query({
query: GET_CLIP,
variables: {
slug: match[1]
}
});
if ( ! result || ! result.data || ! result.data.clip || ! result.data.clip.broadcaster )
return null;
const clip = result.data.clip,
game = clip.game,
game_display = game && game.displayName;
const user = {
type: 'link', url: `https://www.twitch.tv/${clip.broadcaster.login}`,
content: {
type: 'style', weight: 'semibold', color: 'alt-2',
content: clip.broadcaster.displayName
}
};
const subtitle = game_display ? {
type: 'i18n', key: 'clip.desc.1.playing', phrase: '{user} playing {game}', content: {
user,
game: {type: 'style', weight: 'semibold', content: game_display}
}
} : {type: 'i18n', key: 'clip.desc.1', phrase: 'Clip of {user}', content: {user}};
const curator = clip.curator ? {
type: 'link', url: `https://www.twitch.tv/${clip.curator.login}`,
content: {
type: 'style', color: 'alt-2',
content: clip.curator.displayName
}
} : {type: 'i18n', key: 'clip.unknown', phrase: 'Unknown'};
const extra = {
type: 'i18n', key: 'clip.desc.2',
phrase: 'Clipped by {curator} — {views, plural, one {# View} other {# Views}}',
content: {
curator,
views: clip.viewCount
}
};
return {
url: token.url,
accent: '#6441a4',
short: {
type: 'header',
image: {type: 'image', url: clip.thumbnailURL, sfw: true, aspect: 16/9},
title: clip.title,
subtitle,
extra
}
}
}
}
}
}
export const Videos = {
type: 'video',
can_hide_token: true,
test(token) {
return token.type === 'link' && VIDEO_URL.test(token.url)
},
process(token) {
const match = VIDEO_URL.exec(token.url),
apollo = this.resolve('site.apollo');
if ( ! apollo || ! match )
return;
return {
getData: async () => {
const result = await apollo.client.query({
query: GET_VIDEO,
variables: {
id: match[1]
}
});
if ( ! result || ! result.data || ! result.data.video || ! result.data.video.owner )
return null;
const video = result.data.video,
game = video.game,
game_display = game && game.displayName;
const user = {
type: 'link', url: `https://www.twitch.tv/${video.owner.login}`,
content: {
type: 'style', weight: 'semibold', color: 'alt-2',
content: video.owner.displayName
}
};
const subtitle = game_display ? {
type: 'i18n', key: 'clip.desc.1.playing', phrase: '{user} playing {game}', content: {
user,
game: {type: 'style', weight: 'semibold', content: game_display}
}
} : {type: 'i18n', key: 'video.desc.1', phrase: 'Video of {user}', content: {user}};
const extra = {
type: 'i18n', key: 'video.desc.2',
phrase: '{length,duration} — {views,number} Views — {date,datetime}', content: {
length: video.lengthSeconds,
views: video.viewCount,
date: video.publishedAt
}
};
return {
url: token.url,
short: {
type: 'header',
image: {type: 'image', url: video.previewThumbnailURL, sfw: true, aspect: 16/9},
title: video.title,
subtitle,
extra
}
};
}
}
}
}

View file

@ -6,7 +6,7 @@
import User from './user'; import User from './user';
import {NEW_API, API_SERVER, WEBKIT_CSS as WEBKIT, IS_FIREFOX} from 'utilities/constants'; import {NEW_API, WEBKIT_CSS as WEBKIT, IS_FIREFOX} from 'utilities/constants';
import {ManagedStyle} from 'utilities/dom'; import {ManagedStyle} from 'utilities/dom';
import {has, SourcedSet, set_equals} from 'utilities/object'; import {has, SourcedSet, set_equals} from 'utilities/object';
@ -32,6 +32,9 @@ export default class Room {
if ( id ) if ( id )
this.manager.room_ids[id] = this; this.manager.room_ids[id] = this;
if ( id && this.manager.pubsub )
this.manager.pubsub.subscribe(this, `twitch/${id}/chat`);
this.manager.emit(':room-add', this); this.manager.emit(':room-add', this);
this.load_data(); this.load_data();
} }
@ -81,6 +84,9 @@ export default class Room {
this.manager.socket.unsubscribe(this, `room.${this.login}`); this.manager.socket.unsubscribe(this, `room.${this.login}`);
} }
if ( this._id && this.manager.pubsub )
this.manager.pubsub.unsubscribe(this, `twitch/${this._id}/chat`);
if ( this.manager.room_ids[this._id] === this ) if ( this.manager.room_ids[this._id] === this )
this.manager.room_ids[this._id] = null; this.manager.room_ids[this._id] = null;
} }
@ -253,6 +259,9 @@ export default class Room {
if ( this.destroyed ) if ( this.destroyed )
return; return;
const load_key = `ffz-room-${this.id ? `id:${this.id}` : this.login}`;
this.manager.load_tracker.schedule('chat-data', load_key);
if ( this.manager.experiments.getAssignment('api_load') ) if ( this.manager.experiments.getAssignment('api_load') )
try { try {
fetch(`${NEW_API}/v1/room/${this.id ? `id/${this.id}` : this.login}`).catch(() => {}); fetch(`${NEW_API}/v1/room/${this.id ? `id/${this.id}` : this.login}`).catch(() => {});
@ -260,23 +269,27 @@ export default class Room {
let response, data; let response, data;
try { try {
response = await fetch(`${API_SERVER}/v1/room/${this.id ? `id/${this.id}` : this.login}`); response = await fetch(`${this.manager.staging.api}/v1/room/${this.id ? `id/${this.id}` : this.login}`);
} catch(err) { } catch(err) {
tries++; tries++;
if ( tries < 10 ) if ( tries < 10 )
return setTimeout(() => this.load_data(tries), 500 * tries); return setTimeout(() => this.load_data(tries), 500 * tries);
this.manager.log.error(`Error loading room data for ${this.id}:${this.login}`, err); this.manager.log.error(`Error loading room data for ${this.id}:${this.login}`, err);
this.manager.load_tracker.notify('chat-data', load_key, false);
return false; return false;
} }
if ( ! response.ok ) if ( ! response.ok ) {
this.manager.load_tracker.notify('chat-data', load_key, false);
return false; return false;
}
try { try {
data = await response.json(); data = await response.json();
} catch(err) { } catch(err) {
this.manager.log.error(`Error parsing room data for ${this.id}:${this.login}`, err); this.manager.log.error(`Error parsing room data for ${this.id}:${this.login}`, err);
this.manager.load_tracker.notify('chat-data', load_key, false);
return false; return false;
} }
@ -296,6 +309,7 @@ export default class Room {
} else if ( this._id !== id ) { } else if ( this._id !== id ) {
this.manager.log.warn(`Received data for ${this.id}:${this.login} with the wrong ID: ${id}`); this.manager.log.warn(`Received data for ${this.id}:${this.login} with the wrong ID: ${id}`);
this.manager.load_tracker.notify('chat-data', load_key, false);
return false; return false;
} }
@ -306,10 +320,10 @@ export default class Room {
this.data = d; this.data = d;
this.removeAllSets('main');
if ( d.set ) if ( d.set )
this.addSet('main', d.set); this.addSet('main', d.set);
else
this.removeAllSets('main');
if ( data.sets ) if ( data.sets )
for(const set_id in data.sets) for(const set_id in data.sets)
@ -331,6 +345,7 @@ export default class Room {
this.buildModBadgeCSS(); this.buildModBadgeCSS();
this.buildVIPBadgeCSS(); this.buildVIPBadgeCSS();
this.manager.load_tracker.notify('chat-data', load_key);
return true; return true;
} }

View file

@ -6,15 +6,25 @@
import {sanitize, createElement} from 'utilities/dom'; import {sanitize, createElement} from 'utilities/dom';
import {has, getTwitchEmoteURL, split_chars, getTwitchEmoteSrcSet} from 'utilities/object'; import {has, getTwitchEmoteURL, split_chars, getTwitchEmoteSrcSet} from 'utilities/object';
import { NoContent } from 'utilities/tooltip';
import {EmoteTypes, REPLACEMENT_BASE, REPLACEMENTS} from 'utilities/constants'; import {EmoteTypes, REPLACEMENT_BASE, REPLACEMENTS, WEIRD_EMOTE_SIZES} from 'utilities/constants';
import {CATEGORIES, JOINER_REPLACEMENT} from './emoji'; import {CATEGORIES, JOINER_REPLACEMENT} from './emoji';
import { MODIFIER_FLAGS } from './emotes';
const SHRINK_X = MODIFIER_FLAGS.ShrinkX,
SLIDE_X = MODIFIER_FLAGS.Slide,
STRETCH_X = MODIFIER_FLAGS.GrowX;
//SHRINK_Y = MODIFIER_FLAGS.ShrinkY,
//STRETCH_Y = MODIFIER_FLAGS.GrowY,
const EMOTE_CLASS = 'chat-image chat-line__message--emote', const EMOTE_CLASS = 'chat-image chat-line__message--emote',
//WHITESPACE = /^\s*$/, //WHITESPACE = /^\s*$/,
//LINK_REGEX = /([^\w@#%\-+=:~])?((?:(https?:\/\/)?(?:[\w@#%\-+=:~]+\.)+[a-z]{2,6}(?:\/[\w./@#%&()\-+=:?~]*)?))([^\w./@#%&()\-+=:?~]|\s|$)/g, //LINK_REGEX = /([^\w@#%\-+=:~])?((?:(https?:\/\/)?(?:[\w@#%\-+=:~]+\.)+[a-z]{2,6}(?:\/[\w./@#%&()\-+=:?~]*)?))([^\w./@#%&()\-+=:?~]|\s|$)/g,
NEW_LINK_REGEX = /(?:(https?:\/\/)?((?:[\w#%\-+=:~]+\.)+[a-z]{2,10}(?:\/[\w./#%&@()\-+=:?~]*)?))/g, NEW_LINK_REGEX = /(?:(https?:\/\/)?((?:[\w#%\-+=:~]+\.)+[a-z]{2,10}(?:\/[\w./#%&@()\-+=:?~]*[^\s\.!,?])?))/g,
//OLD_NEW_LINK_REGEX = /(?:(https?:\/\/)?((?:[\w#%\-+=:~]+\.)+[a-z]{2,10}(?:\/[\w./#%&@()\-+=:?~]*)?))/g,
//MENTION_REGEX = /([^\w@#%\-+=:~])?(@([^\u0000-\u007F]+|\w+)+)([^\w./@#%&()\-+=:?~]|\s|$)/g; // eslint-disable-line no-control-regex //MENTION_REGEX = /([^\w@#%\-+=:~])?(@([^\u0000-\u007F]+|\w+)+)([^\w./@#%&()\-+=:?~]|\s|$)/g; // eslint-disable-line no-control-regex
MENTION_REGEX = /^(['"*([{<\\/]*)(@)((?:[^\u0000-\u007F]|[\w-])+)(?:\b|$)/; // eslint-disable-line no-control-regex MENTION_REGEX = /^(['"*([{<\\/]*)(@)((?:[^\u0000-\u007F]|[\w-])+)(?:\b|$)/; // eslint-disable-line no-control-regex
@ -71,12 +81,13 @@ export const Links = {
rel="noopener noreferrer" rel="noopener noreferrer"
target="_blank" target="_blank"
href={token.url} href={token.url}
onClick={this.handleLinkClick}
>{token.text}</a>); >{token.text}</a>);
}, },
tooltip(target, tip) { tooltip(target, tip) {
if ( ! this.context.get('tooltip.rich-links') && ! target.dataset.forceTooltip ) if ( ! this.context.get('tooltip.rich-links') && ! target.dataset.forceTooltip )
return ''; return NoContent;
if ( target.dataset.isMail === 'true' ) if ( target.dataset.isMail === 'true' )
return [this.i18n.t('tooltip.email-link', 'E-Mail {address}', {address: target.textContent})]; return [this.i18n.t('tooltip.email-link', 'E-Mail {address}', {address: target.textContent})];
@ -97,6 +108,7 @@ export const Links = {
i18n: this.i18n, i18n: this.i18n,
fragments: data.fragments, fragments: data.fragments,
i18n_prefix: data.i18n_prefix,
allow_media: show_images, allow_media: show_images,
allow_unsafe: show_unsafe, allow_unsafe: show_unsafe,
@ -303,6 +315,81 @@ export const Replies = {
// Mentions // Mentions
// ============================================================================ // ============================================================================
function mention_processAll(tokens, msg, user, color_mentions) {
const can_highlight_user = user && user.login && user.login == msg.user.login && ! this.context.get('chat.filtering.process-own'),
priority = this.context.get('chat.filtering.mention-priority');
let login, display, mentionable = false;
if ( user && user.login && ! can_highlight_user ) {
login = user.login.toLowerCase();
display = user.displayName && user.displayName.toLowerCase();
if ( display === login )
display = null;
mentionable = true;
}
const out = [];
for(const token of tokens) {
if ( token.type !== 'text' ) {
out.push(token);
continue;
}
let text = [];
for(const segment of token.text.split(/ +/)) {
const match = /^(@?)(\S+?)(?:\b|$)/.exec(segment);
if ( match ) {
let recipient = match[2],
has_at = match[1] === '@',
mentioned = false;
const rlower = recipient ? recipient.toLowerCase() : '',
color = this.color_cache ? this.color_cache.get(rlower) : null;
if ( rlower === login || rlower === display )
mentioned = true;
if ( ! has_at && ! color && ! mentioned ) {
text.push(segment);
} else {
// If we have pending text, join it together.
if ( text.length ) {
out.push({
type: 'text',
text: `${text.join(' ')} `
});
text = [];
}
out.push({
type: 'mention',
text: match[0],
me: mentioned,
color: color_mentions ? color : null,
recipient: rlower
});
if ( mentioned )
this.applyHighlight(msg, priority, null, 'mention', true);
// Push the remaining text from the token.
text.push(segment.substr(match[0].length));
}
} else
text.push(segment);
}
if ( text.length > 1 || (text.length === 1 && text[0] !== '') )
out.push({type: 'text', text: text.join(' ')})
}
return out;
}
export const Mentions = { export const Mentions = {
type: 'mention', type: 'mention',
priority: 0, priority: 0,
@ -336,6 +423,12 @@ export const Mentions = {
if ( ! tokens || ! tokens.length ) if ( ! tokens || ! tokens.length )
return; return;
const all_mentions = this.context.get('chat.filtering.all-mentions'),
color_mentions = this.context.get('chat.filtering.color-mentions');
if ( all_mentions )
return mention_processAll.call(this, tokens, msg, user, color_mentions);
const can_highlight_user = user && user.login && user.login == msg.user.login && ! this.context.get('chat.filtering.process-own'), const can_highlight_user = user && user.login && user.login == msg.user.login && ! this.context.get('chat.filtering.process-own'),
priority = this.context.get('chat.filtering.mention-priority'); priority = this.context.get('chat.filtering.mention-priority');
@ -386,7 +479,7 @@ export const Mentions = {
} }
const rlower = recipient ? recipient.toLowerCase() : '', const rlower = recipient ? recipient.toLowerCase() : '',
color = this.color_cache ? this.color_cache.get(rlower) : null; color = (color_mentions && this.color_cache) ? this.color_cache.get(rlower) : null;
out.push({ out.push({
type: 'mention', type: 'mention',
@ -1135,7 +1228,7 @@ const render_emote = (token, createElement, wrapped) => {
const mods = token.modifiers || [], ml = mods.length, const mods = token.modifiers || [], ml = mods.length,
emote = createElement('img', { emote = createElement('img', {
class: `${EMOTE_CLASS} ffz-tooltip${hoverSrc ? ' ffz-hover-emote' : ''}${token.provider === 'ffz' ? ' ffz-emote' : token.provider === 'emoji' ? ' ffz-emoji' : ''}`, class: `${EMOTE_CLASS} ffz-tooltip${hoverSrc ? ' ffz-hover-emote' : ''}${token.provider === 'twitch' ? ' twitch-emote' : token.provider === 'ffz' ? ' ffz-emote' : token.provider === 'emoji' ? ' ffz-emoji' : ''}`,
attrs: { attrs: {
src, src,
srcSet, srcSet,
@ -1212,12 +1305,134 @@ export const AddonEmotes = {
hoverSrcSet = big ? token.animSrcSet2 : token.animSrcSet; hoverSrcSet = big ? token.animSrcSet2 : token.animSrcSet;
} }
let style = undefined, outerStyle = undefined;
const mods = token.modifiers || [], ml = mods.length, const mods = token.modifiers || [], ml = mods.length,
effects = token.modifier_flags,
is_big = (token.big && ! token.can_big && token.height);
let as_bg = (this.emotes.activeAsBackgroundMask & effects) !== 0;
let no_wide = (this.emotes.activeNoWideMask & effects) !== 0;
if ( no_wide || effects || ml ) {
// We need to calculate the size of the emote and the biggest
// modifier so that everything can be nicely centered.
if ( token.provider === 'emoji' ) {
const factor = token.big_emoji ? 2 : 1,
size = factor * 1.5 * (this.context.get('chat.font-size') ?? 13);
style = {
width: size,
height: size,
};
outerStyle = {
width: size,
height: size
};
} else {
const factor = token.big ? 2 : 1;
style = {
width: token.width * factor,
height: token.height * factor
};
outerStyle = {
width: style.width,
height: style.height
};
}
for(const mod of mods) {
if ( mod.effect_bg )
as_bg = true;
if ( ! mod.mod_hidden && mod.set !== 'info' ) {
const factor = mod.big ? 2 : 1,
width = mod.width * factor,
height = mod.height * factor;
if ( width > outerStyle.width )
outerStyle.width = width;
if ( height > outerStyle.height )
outerStyle.height = height;
}
}
if ( effects ) {
this.emotes.ensureEffect(effects);
if ( (effects & SHRINK_X) === SHRINK_X && this.emotes.effects_enabled?.ShrinkX )
style.width *= 0.5;
if ( (effects & STRETCH_X) === STRETCH_X && this.emotes.effects_enabled?.GrowX )
style.width *= 2;
/*if ( (effects & SHRINK_Y) === SHRINK_Y )
style.height *= 0.5;
if ( (effects & STRETCH_Y) === STRETCH_Y )
style.height *= 2;*/
style.width = Math.min(style.width, token.big ? 256 : 128);
style.height = Math.min(style.height, token.big ? 80 : 40);
}
if ( no_wide ) {
const limit = token.big ? 64 : 32;
if ( style.width > limit ) {
const factor = limit / style.width;
style.width *= factor;
style.height *= factor;
}
}
if ( style.width > outerStyle.width )
outerStyle.width = style.width;
if ( style.height > outerStyle.height )
outerStyle.height = style.height;
if ( style.width !== outerStyle.width )
style.marginLeft = (outerStyle.width - style.width) / 2;
if ( style.height !== outerStyle.height )
style.marginTop = (outerStyle.height - style.height) / 2;
if ( effects ) {
if ( (effects & SLIDE_X) === SLIDE_X ) {
style['--ffz-width'] = `${style.width}px`;
style['--ffz-speed-x'] = `${0.5 * (style.width / (token.big ? 64 : 32))}s`;
}
}
}
let emote;
if ( as_bg ) {
style = style || {};
style.backgroundImage = `url("${src}")`;
style.backgroundSize = '100%';
emote = (<div
class={`${EMOTE_CLASS} ffz--pointer-events ffz-tooltip${hoverSrc ? ' ffz-hover-emote' : ''}${token.provider === 'twitch' ? ' twitch-emote' : token.provider === 'ffz' ? ' ffz-emote' : token.provider === 'emoji' ? ' ffz-emoji' : ''}`}
style={style}
data-name={token.text}
data-tooltip-type="emote"
data-provider={token.provider}
data-id={token.id}
data-set={token.set}
data-code={token.code}
data-variant={token.variant}
data-normal-src={normalSrc}
data-normal-src-set={normalSrcSet}
data-hover-src={hoverSrc}
data-hover-src-set={hoverSrcSet}
data-modifiers={ml ? mods.map(x => x.id).join(' ') : null}
data-modifier-info={ml ? JSON.stringify(mods.map(x => [x.set, x.id])) : null}
onClick={this.emotes.handleClick}
><div class="ffz-alt-text">{ token.text }</div></div>);
}
else
emote = (<img emote = (<img
class={`${EMOTE_CLASS} ffz--pointer-events ffz-tooltip${hoverSrc ? ' ffz-hover-emote' : ''}${token.provider === 'ffz' ? ' ffz-emote' : token.provider === 'emoji' ? ' ffz-emoji' : ''}`} class={`${EMOTE_CLASS} ffz--pointer-events ffz-tooltip${hoverSrc ? ' ffz-hover-emote' : ''}${token.provider === 'twitch' ? ' twitch-emote' : token.provider === 'ffz' ? ' ffz-emote' : token.provider === 'emoji' ? ' ffz-emoji' : ''}`}
src={src} src={src}
srcSet={srcSet} srcSet={srcSet}
height={(token.big && ! token.can_big && token.height) ? `${token.height * 2}px` : undefined} style={style}
height={style ? undefined : is_big ? `${token.height * 2}px` : undefined}
alt={token.text} alt={token.text}
data-tooltip-type="emote" data-tooltip-type="emote"
data-provider={token.provider} data-provider={token.provider}
@ -1234,7 +1449,7 @@ export const AddonEmotes = {
onClick={this.emotes.handleClick} onClick={this.emotes.handleClick}
/>); />);
if ( ! ml ) { if ( ! ml && ! token.modifier_flags ) {
if ( wrapped ) if ( wrapped )
return emote; return emote;
@ -1242,16 +1457,28 @@ export const AddonEmotes = {
} }
return (<div return (<div
class="ffz--inline ffz--pointer-events modified-emote" class={`ffz--inline ffz--pointer-events modified-emote${style ? ' scaled-modified-emote' : ''}`}
data-test-selector="emote-button" data-test-selector="emote-button"
data-provider={token.provider} data-provider={token.provider}
data-id={token.id} data-id={token.id}
data-set={token.set} data-set={token.set}
style={outerStyle}
data-modifiers={ml ? mods.map(x => x.id).join(' ') : null} data-modifiers={ml ? mods.map(x => x.id).join(' ') : null}
onClick={this.emotes.handleClick} data-effects={effects ? effects : undefined}
//onClick={this.emotes.handleClick}
> >
{emote} {emote}
{mods.map(t => <span key={t.text}>{this.tokenizers.emote.render.call(this, t, createElement, true)}</span>)} {mods.map(t => {
if (t.set === 'info')
return null;
if ((t.source_modifier_flags & 1) === 1 && t.text)
return null;
// This is currently weird and breaks copy/paste
// so since it doesn't *fix* copy/paste just leave
// it out for now.
//return <div class="ffz-alt-text">{` ${t.text}`}</div>;
return <span key={t.text}>{this.tokenizers.emote.render.call(this, t, createElement, true)}</span>
})}
</div>); </div>);
}, },
@ -1260,13 +1487,20 @@ export const AddonEmotes = {
provider = ds.provider, provider = ds.provider,
modifiers = ds.modifierInfo; modifiers = ds.modifierInfo;
let name, preview, source, owner, mods, fav_source, emote_id, let name, preview, source, artist, owner, mods, fav_source, emote_id,
original_name,
plain_name = false; plain_name = false;
const hide_source = ds.noSource === 'true'; const hide_source = ds.noSource === 'true';
if ( modifiers && modifiers !== 'null' ) { if ( modifiers && modifiers !== 'null' ) {
mods = JSON.parse(modifiers).map(([set_id, emote_id]) => { mods = JSON.parse(modifiers).map(([set_id, emote_id]) => {
if ( set_id === 'info' )
return (<span class="tw-mg-05">
{emote_id?.icon ? <img class="ffz__tooltip__mod-icon" src={emote_id.icon} /> : null}
{emote_id?.icon ? ` - ${emote_id?.label}` : emote_id?.label}
</span>);
const emote_set = this.emotes.emote_sets[set_id], const emote_set = this.emotes.emote_sets[set_id],
emote = emote_set && emote_set.emotes[emote_id]; emote = emote_set && emote_set.emotes[emote_id];
@ -1281,11 +1515,15 @@ export const AddonEmotes = {
if ( provider === 'twitch' ) { if ( provider === 'twitch' ) {
emote_id = ds.id; emote_id = ds.id;
const set_id = hide_source ? null : await this.emotes.getTwitchEmoteSet(emote_id), const set_id = hide_source ? null : await this.emotes.getTwitchEmoteSet(emote_id),
emote_set = set_id != null && await this.emotes.getTwitchSetChannel(set_id); emote_set = set_id != null && await this.emotes.getTwitchSetChannel(set_id),
raw_artist = hide_source ? null : await this.emotes.getTwitchEmoteArtist(emote_id);
preview = `${getTwitchEmoteURL(ds.id, 4, true, true)}?_=preview`; preview = `${getTwitchEmoteURL(ds.id, 4, true, true)}?_=preview`;
fav_source = 'twitch'; fav_source = 'twitch';
if ( raw_artist )
artist = raw_artist.displayName || raw_artist.login;
if ( emote_set ) { if ( emote_set ) {
const type = emote_set.type; const type = emote_set.type;
if ( type === EmoteTypes.Global ) { if ( type === EmoteTypes.Global ) {
@ -1339,6 +1577,15 @@ export const AddonEmotes = {
if ( emote ) { if ( emote ) {
emote_id = emote.id; emote_id = emote.id;
if ( emote.artist )
artist = emote.artist.display_name || emote.artist.name;
if ( emote.original_name && emote.original_name !== emote.name )
original_name = this.i18n.t(
'emote.original-name', 'Name: {name}',
{name: emote.original_name}
);
if ( emote.owner ) if ( emote.owner )
owner = this.i18n.t( owner = this.i18n.t(
'emote.owner', 'By: {owner}', 'emote.owner', 'By: {owner}',
@ -1357,6 +1604,123 @@ export const AddonEmotes = {
else if ( emote.urls[2] ) else if ( emote.urls[2] )
preview = emote.urls[2]; preview = emote.urls[2];
} }
if ( ds.effects && emote.modifier && emote.modifier_flags ) {
owner = null;
const effects = emote.modifier_flags;
this.emotes.ensureEffect(effects);
const target = this.emotes.getTargetEmote();
let style = {
width: (target.width ?? 28) * 2,
height: (target.height ?? 28) * 2
};
let outerStyle = {
width: style.width,
height: style.height
};
let as_bg = (this.emotes.activeAsBackgroundMask & effects) !== 0;
let no_wide = (this.emotes.activeNoWideMask & effects) !== 0;
let changed = false;
if ( (effects & SHRINK_X) === SHRINK_X && this.emotes.effects_enabled?.ShrinkX ) {
style.width *= 0.5;
changed = true;
}
if ( (effects & STRETCH_X) === STRETCH_X && this.emotes.effects_enabled?.GrowX ) {
style.width *= 2;
changed = true;
}
/*if ( (effects & SHRINK_Y) === SHRINK_Y ) {
style.height *= 0.5;
changed = true;
}
if ( (effects & STRETCH_Y) === STRETCH_Y ) {
style.height *= 2;
changed = true;
}*/
if ( changed ) {
if ( style.width > 512 )
style.width = 512;
if ( style.height > 160 )
style.height = 160;
}
if ( no_wide ) {
const limit = 64;
if ( style.width > limit ) {
const factor = limit / style.width;
style.width *= factor;
style.height *= factor;
}
}
if ( style.width > outerStyle.width )
outerStyle.width = style.width;
if ( style.height > outerStyle.height )
outerStyle.height = style.height;
if ( style.width !== outerStyle.width )
style.marginLeft = (outerStyle.width - style.width) / 2;
if ( style.height !== outerStyle.height )
style.marginTop = (outerStyle.height - style.height) / 2;
if ( (effects & SLIDE_X) === SLIDE_X ) {
style['--ffz-width'] = `${style.width}px`;
style['--ffz-speed-x'] = `${0.5 * style.width / 64}s`;
}
style.width = `${style.width}px`;
style.height = `${style.height}px`;
outerStyle.width = `${outerStyle.width}px`;
outerStyle.height = `${outerStyle.height}px`;
if ( as_bg ) {
style.backgroundImage = `url("${target.src}")`;
style.backgroundSize = '100%';
}
// Whip up a special preview.
preview = (<div class="ffz-effect-tip">
<img
src={target.src}
srcSet={target.srcSet}
width={(target.width ?? 28) * 2}
height={(target.height ?? 28) * 2}
onLoad={tip.update}
/>
<span class="ffz-i-right-open"></span>
<div
class={`ffz--inline ffz--pointer-events modified-emote${style ? ' scaled-modified-emote' : ''}`}
style={outerStyle}
data-modifiers={emote.id}
data-effects={effects}
>
{as_bg
? <div
class={`${EMOTE_CLASS} ffz--pointer-events ffz-tooltip ffz-emote`}
style={style}
/>
: <img
class={`${EMOTE_CLASS} ffz--pointer-events ffz-tooltip ffz-emote`}
src={target.src}
srcSet={target.srcSet}
style={style}
height={style ? undefined : `${target.height * 2}px`}
onLoad={tip.update}
/>
}
</div>
</div>);
}
} }
} else if ( provider === 'emoji' ) { } else if ( provider === 'emoji' ) {
@ -1396,19 +1760,32 @@ export const AddonEmotes = {
onLoad={tip.update} onLoad={tip.update}
/>) : preview), />) : preview),
plain_name || (hide_source && ! owner) ? name : this.i18n.t('tooltip.emote', 'Emote: {name}', {name}), plain_name || (hide_source && ! owner)
? name
: this.i18n.t('tooltip.emote', 'Emote: {name}', {name}),
! hide_source && source && this.context.get('tooltip.emote-sources') && (<div class="tw-pd-t-05"> ! hide_source && source && this.context.get('tooltip.emote-sources') && (<div class="tw-pd-t-05">
{source} {source}
</div>), </div>),
original_name && (<div class="tw-pd-t-05">
{original_name}
</div>),
owner && this.context.get('tooltip.emote-sources') && (<div class="tw-pd-t-05"> owner && this.context.get('tooltip.emote-sources') && (<div class="tw-pd-t-05">
{owner} {owner}
</div>), </div>),
artist && this.context.get('tooltip.emote-sources') && (<div class="tw-pd-t-05">
{this.i18n.t(
'emote.artist', 'Artist: {artist}',
{artist}
)}
</div>),
ds.sellout && (<div class="tw-mg-t-05 tw-border-t tw-pd-t-05">{ds.sellout}</div>), ds.sellout && (<div class="tw-mg-t-05 tw-border-t tw-pd-t-05">{ds.sellout}</div>),
mods && (<div class="tw-pd-t-1">{mods}</div>), mods && (<div class="tw-pd-t-1 tw-pd-b-05">{mods}</div>),
favorite && (<figure class="ffz--favorite ffz-i-star" />) favorite && (<figure class="ffz--favorite ffz-i-star" />)
]; ];
@ -1435,15 +1812,22 @@ export const AddonEmotes = {
anim = this.context.get('chat.emotes.animated'), anim = this.context.get('chat.emotes.animated'),
out = []; out = [];
let had_prefix_mods = false;
let had_no_space = false;
let last_token, emote; let last_token, emote;
const NoSpace = this.emotes.ModifierFlags?.NoSpace;
for(const token of tokens) { for(const token of tokens) {
if ( ! token ) if ( ! token )
continue; continue;
if ( token.type !== 'text' ) { if ( token.type !== 'text' ) {
if ( token.type === 'emote' ) { if ( token.type === 'emote' ) {
if ( ! token.modifiers ) if ( ! token.modifiers ) {
token.modifiers = []; token.modifiers = [];
token.modifier_flags = 0;
}
} }
out.push(token); out.push(token);
@ -1458,8 +1842,16 @@ export const AddonEmotes = {
emote = emotes[segment]; emote = emotes[segment];
// Is this emote a modifier? // Is this emote a modifier?
if ( emote.modifier && last_token && last_token.modifiers && (!text.length || (text.length === 1 && text[0] === '')) ) { if ( emote.modifier && emote.modifier_prefix )
had_prefix_mods = true;
else if ( emote.modifier && last_token && last_token.modifiers && (!text.length || (text.length === 1 && text[0] === '')) ) {
if ( last_token.modifiers.indexOf(emote.token) === -1 ) { if ( last_token.modifiers.indexOf(emote.token) === -1 ) {
if ( emote.modifier_flags ) {
last_token.modifier_flags |= emote.modifier_flags;
if ( NoSpace && (emote.modifier_flags & NoSpace) === NoSpace )
had_no_space = true;
}
last_token.modifiers.push( last_token.modifiers.push(
Object.assign({ Object.assign({
big, big,
@ -1485,6 +1877,7 @@ export const AddonEmotes = {
const t = Object.assign({ const t = Object.assign({
modifiers: [], modifiers: [],
modifier_flags: 0,
big, big,
anim anim
}, emote.token); }, emote.token);
@ -1503,6 +1896,66 @@ export const AddonEmotes = {
} }
} }
if ( had_prefix_mods ) {
// We need to scan through and apply prefix modifiers as appropriate.
let last_emote,
had_text = false;
let i = out.length;
while(i--) {
const token = out[i];
// Is it a new emote?
if ( token.type === 'emote' && ! token.mod ) {
last_emote = token;
had_text = false;
}
// Is it a prefix mod with a target emote?
else if ( last_emote && token.type === 'emote' && token.mod && token.mod_prefix ) {
last_emote.modifiers.push(token);
if ( token.source_modifier_flags ) {
last_emote.modifier_flags |= token.source_modifier_flags;
if ( NoSpace && (token.source_modifier_flags & NoSpace) === NoSpace )
had_no_space = true;
}
// Remove one or two tokens, depending on if we had a space.
// (We should always have a space, but be flexible.)
out.splice(i, had_text ? 2 : 1);
had_text = false;
}
// Make a note of at most one space.
else if ( last_emote && ! had_text && token.type === 'text' && token.text === ' ' ) {
had_text = true;
}
// Absolutely anything else means it's a broken sequence.
else {
last_emote = null;
had_text = false;
}
}
}
if ( had_no_space ) {
// We need to remove prefix spaces before emotes with the no-space effect.
let no_space = false;
let i = out.length;
while(i--) {
const token = out[i];
if ( token.type === 'emote' && (token.modifier_flags & NoSpace) === NoSpace )
no_space = true;
else {
if ( no_space && token.type === 'text' && token.text === ' ' )
out.splice(i, 1);
no_space = false;
}
}
}
return out; return out;
} }
} }
@ -1531,6 +1984,7 @@ export const Emoji = {
return; return;
const splitter = this.emoji.splitter, const splitter = this.emoji.splitter,
big = this.context.get('chat.emotes.2x') > 1,
replace = this.context.get('chat.emoji.replace-joiner') > 0, replace = this.context.get('chat.emoji.replace-joiner') > 0,
style = this.context.get('chat.emoji.style'); style = this.context.get('chat.emoji.style');
@ -1576,12 +2030,15 @@ export const Emoji = {
code: key[0], code: key[0],
variant: key[1], variant: key[1],
big_emoji: big,
src: this.emoji.getFullImage(variant.image, style), src: this.emoji.getFullImage(variant.image, style),
srcSet: this.emoji.getFullImageSet(variant.image, style), srcSet: this.emoji.getFullImageSet(variant.image, style),
text: match[0], text: match[0],
length, length,
modifiers: [] modifiers: [],
modifier_flags: 0
}); });
idx = start + match[0].length; idx = start + match[0].length;
@ -1652,6 +2109,15 @@ export const TwitchEmotes = {
while( eix < e_length ) { while( eix < e_length ) {
const [e_id, e_start, e_end] = emotes[eix]; const [e_id, e_start, e_end] = emotes[eix];
// Do not honor fake emotes that were created for the sake
// of WYSIWYG / autocompletion.
if ( typeof e_id === 'string' ) {
if ( e_id.startsWith('__FFZ__') || e_id.startsWith('__BTTV__') ) {
eix++;
continue;
}
}
// Does this emote go outside the bounds of this token? // Does this emote go outside the bounds of this token?
if ( e_start > t_end || e_end > t_end ) { if ( e_start > t_end || e_end > t_end ) {
// Output the remainder of this token. // Output the remainder of this token.
@ -1716,6 +2182,11 @@ export const TwitchEmotes = {
} }
} }
const sizes = WEIRD_EMOTE_SIZES[e_id];
const width = sizes ? sizes[0] : 28,
height = sizes ? sizes[1] : 28;
out.push({ out.push({
type: 'emote', type: 'emote',
id: e_id, id: e_id,
@ -1731,9 +2202,11 @@ export const TwitchEmotes = {
anim, anim,
big, big,
can_big, can_big,
height: 28, // Not always accurate but close enough. width,
height,
text: text.slice(e_start - t_start, e_end - t_start).join(''), text: text.slice(e_start - t_start, e_end - t_start).join(''),
modifiers: [] modifiers: [],
modifier_flags: 0
}); });
idx = e_end; idx = e_end;

View file

@ -2,7 +2,7 @@ query FFZ_GetVideoInfo($id: ID!) {
video(id: $id) { video(id: $id) {
id id
title title
previewThumbnailURL(width: 86, height: 45) previewThumbnailURL(width: 320, height: 180)
lengthSeconds lengthSeconds
publishedAt publishedAt
viewCount viewCount
@ -14,6 +14,7 @@ query FFZ_GetVideoInfo($id: ID!) {
id id
login login
displayName displayName
profileImageURL(width: 50)
} }
} }
} }

View file

@ -0,0 +1,448 @@
<template>
<div
:style="{zIndex: z}"
class="ffz-viewer-card tw-border tw-border-radius-medium tw-c-background-base tw-c-text-base tw-elevation-2 tw-flex tw-flex-column viewer-card"
tabindex="0"
@focusin="onFocus"
@keyup.esc="close"
>
<div
class="ffz-viewer-card__header tw-border-radius-medium tw-c-background-accent-alt tw-flex-grow-0 tw-flex-shrink-0 viewer-card__background tw-relative"
>
<div class="tw-flex tw-flex-column tw-full-height tw-full-width viewer-card__overlay">
<div class="tw-align-center tw-border-radius-medium tw-align-items-center tw-c-background-alt tw-c-text-base tw-flex tw-flex-grow-1 tw-flex-row tw-full-width tw-justify-content-start tw-pd-05 tw-relative viewer-card__banner">
<div class="tw-mg-l-05 tw-mg-y-05 tw-inline-flex viewer-card-drag-cancel">
<figure v-if="! loaded" class="tw-mg-x-1 tw-font-size-2 ffz-i-zreknarf loading" />
<figure v-else class="ffz-avatar tw-flex tw-align-items-center" :style="imageStyle">
<img
v-if="emote.src"
:src="emote.src"
class="tw-block tw-image tw-image-avatar"
>
</figure>
</div>
<div class="tw-align-left tw-flex-grow-1 tw-ellipsis tw-mg-l-1 tw-mg-y-05 viewer-card__display-name">
<h4
class="tw-inline tw-ellipsis"
:class="{'tw-italic': hasOriginalName}"
:title="emote ? emote.name : raw_emote.name"
>
{{ emote ? emote.name : raw_emote.name }}
</h4>
<P
v-if="! loaded"
class="tw-c-text-alt-2 tw-font-size-6"
>
{{ t('emote-card.loading', 'Loading...') }}
</P>
<p
v-if="loaded && emote.source"
class="tw-c-text-alt-2 tw-font-size-6 tw-ellipsis"
:title="emote.source_i18n ? t(emote.source_i18n, emote.source) : emote.source"
>
{{ emote.source_i18n ? t(emote.source_i18n, emote.source) : emote.source }}
</p>
<p v-if="hasOriginalName" class="tw-c-text-alt-2 tw-font-size-6 tw-ellipsis">
<t-list
phrase="emote.original-name"
default="Name: {name}"
>
<template #name>
{{ emote.originalName }}
</template>
</t-list>
</p>
<p v-if="loaded && emote.owner" class="tw-c-text-alt-2 tw-font-size-6 tw-ellipsis">
<t-list
phrase="emote-card.owner"
default="Owner: {owner}"
>
<template #owner>
<a
v-if="emote.ownerLink"
rel="noopener noreferrer"
target="_blank"
:href="emote.ownerLink"
>{{ emote.owner }}</a>
<span v-else>{{ emote.owner }}</span>
</template>
</t-list>
</p>
<p v-if="loaded && emote.artist" class="tw-c-text-alt-2 tw-font-size-6 tw-ellipsis">
<t-list
phrase="emote-card.artist"
default="Artist: {artist}"
>
<template #artist>
<a
v-if="emote.artistLink"
rel="noopener noreferrer"
target="_blank"
:href="emote.artistLink"
class="ffz-i-artist"
>{{ emote.artist }}</a>
<span v-else>{{ emote.artist }}</span>
</template>
</t-list>
</p>
</div>
<button
v-if="canFavorite"
:data-title="favoriteLabel"
:aria-label="favoriteLabel"
class="tw-flex-shrink-0 viewer-card-drag-cancel tw-align-self-start tw-align-items-center tw-align-middle tw-border-radius-medium tw-button-icon tw-core-button tw-inline-flex tw-interactive tw-justify-content-center tw-overflow-hidden tw-relative ffz-tooltip"
@click="toggleFavorite"
>
<span class="tw-button-icon__icon">
<figure :class="{
'ffz-i-star': isFavorite,
'ffz-i-star-empty': ! isFavorite
}" />
</span>
</button>
<div class="tw-flex tw-flex-column tw-align-self-start">
<button
:data-title="t('emote-card.close', 'Close')"
:aria-label="t('emote-card.close', 'Close')"
class="viewer-card-drag-cancel tw-align-items-center tw-align-middle tw-border-radius-medium tw-button-icon tw-core-button tw-inline-flex tw-interactive tw-justify-content-center tw-overflow-hidden tw-relative ffz-tooltip"
@click="close"
>
<span class="tw-button-icon__icon">
<figure class="ffz-i-cancel" />
</span>
</button>
<div
v-if="hasMoreActions"
v-on-clickaway="closeMore"
class="tw-relative viewer-card-drag-cancel"
>
<button
:data-title="t('emote-card.more', 'More')"
:aria-label="t('emote-card.more', 'More')"
class="tw-align-items-center tw-align-middle tw-border-radius-medium tw-button-icon tw-core-button tw-inline-flex tw-interactive tw-justify-content-center tw-overflow-hidden tw-relative ffz-tooltip"
@click="toggleMore"
>
<span class="tw-button-icon__icon">
<figure class="ffz-i-ellipsis-vert" />
</span>
</button>
<balloon
v-if="moreOpen"
color="background-alt-2"
dir="down-right"
size="sm"
class="tw-border-radius-medium"
>
<simplebar classes="ffz-mh-30">
<div class="tw-pd-y-05">
<template v-for="(entry, idx) in moreActions">
<div
v-if="entry.divider"
:key="idx"
class="tw-mg-1 tw-border-b"
/>
<a
:key="idx"
:disabled="entry.disabled"
:href="entry.href"
rel="noopener noreferrer"
target="_blank"
class="tw-block ffz-interactable ffz-interactable--hover-enabled ffz-interactable--default tw-interactive tw-full-width ffz--cursor"
@click="clickMore(entry, $event)"
>
<div class="tw-flex tw-align-items-center tw-pd-y-05 tw-pd-x-1">
<div
class="tw-flex-grow-1"
:class="{'tw-mg-r-1' : !! entry.icon}"
>
{{ entry.title_i18n ? t(entry.title_i18n, entry.title, entry) : entry.title }}
</div>
<figure
v-if="entry.icon || entry.type === 'link'"
:class="entry.icon || 'ffz-i-link-ext'"
/>
</div>
</a>
</template>
</div>
</simplebar>
</balloon>
</div>
</div>
</div>
</div>
</div>
<ReportForm
v-if="reporting"
:emote="emote"
:getFFZ="getFFZ"
@close="close"
/>
<component
v-if="! reporting && loaded && hasBody"
:is="bodyComponent"
:emote="emote"
:getFFZ="getFFZ"
@close="close"
/>
<Modifiers
v-if="! reporting && raw_modifiers && raw_modifiers.length"
:raw_modifiers="raw_modifiers"
:getFFZ="getFFZ"
/>
</div>
</template>
<script>
import ManageFFZ from './manage-ffz.vue';
import Modifiers from './modifiers.vue';
import ReportForm from './report-form.vue';
import TwitchBody from './twitch-body.vue';
import displace from 'displacejs';
export default {
components: {
Modifiers,
ReportForm
},
props: [
'raw_emote', 'data',
'pos_x', 'pos_y',
'getZ', 'getFFZ', 'reportTwitchEmote',
'raw_modifiers'
],
data() {
return {
z: this.getZ(),
moreOpen: false,
isFavorite: false,
reporting: false,
loaded: false,
errored: false,
pinned: false,
emote: null
}
},
computed: {
favoriteLabel() {
return this.t('emote-card.fav', 'Favorite This Emote');
},
hasBody() {
return this.bodyComponent != null
},
hasOriginalName() {
return this.loaded && this.emote.originalName && this.emote.originalName !== this.emote.name;
},
bodyComponent() {
const body = this.emote?.body;
if ( body === 'twitch' )
return TwitchBody;
if ( body === 'manage-ffz' )
return ManageFFZ;
return null;
},
canFavorite() {
return this.loaded && this.emote.fav_source;
},
moreActions() {
if ( ! this.loaded || ! this.emote.more )
return null;
return this.emote.more;
},
hasMoreActions() {
return (this.moreActions?.length ?? 0) > 0;
},
imageStyle() {
if ( ! this.loaded )
return {};
return {
width: `${Math.min(112, (this.emote.width ?? 28) * 2)}px`,
height: `${(this.emote.height ?? 28) * 2}px`
};
}
},
beforeMount() {
this.ffzEmit(':open', this);
this.data.then(data => {
this.loaded = true;
this.ffzEmit(':load', this);
this.emote = data;
this.updateIsFavorite();
this.$nextTick(() => this.handleResize());
}).catch(err => {
console.error('Error loading emote card data', err);
this.errored = true;
});
},
mounted() {
this._on_resize = this.handleResize.bind(this);
window.addEventListener('resize', this._on_resize);
this.createDrag();
},
beforeDestroy() {
this.ffzEmit(':close', this);
this.destroyDrag();
if ( this._on_resize ) {
window.removeEventListener('resize', this._on_resize);
this._on_resize = null;
}
},
methods: {
updateIsFavorite() {
if ( ! this.emote || ! this.emote.fav_source )
this.isFavorite = false;
else {
const emotes = this.getFFZ().resolve('chat.emotes');
this.isFavorite = emotes.isFavorite(this.emote.fav_source, this.emote.fav_id ?? this.emote.id);
}
},
toggleFavorite() {
if ( ! this.emote || ! this.emote.fav_source )
return;
const emotes = this.getFFZ().resolve('chat.emotes');
this.isFavorite = emotes.toggleFavorite(this.emote.fav_source, this.emote.fav_id ?? this.emote.id);
this.cleanTips();
},
toggleMore() {
this.moreOpen = ! this.moreOpen;
},
closeMore() {
this.moreOpen = false;
},
clickMore(entry, evt) {
this.moreOpen = false;
if ( entry.type === 'link' )
return;
evt.preventDefault();
if ( entry.type === 'report-ffz' )
this.reporting = true;
this.$nextTick(() => this.handleResize());
if ( entry.type === 'report-twitch' ) {
if ( this.reportTwitchEmote(this.emote.id, this.emote.channel_id) )
this.close();
return;
}
},
constrain() {
const el = this.$el;
let parent = el.parentElement,
moved = false;
if ( ! parent )
parent = document.body;
const box = el.getBoundingClientRect(),
pbox = parent.getBoundingClientRect();
if ( box.top < pbox.top ) {
el.style.top = `${el.offsetTop + (pbox.top - box.top)}px`;
moved = true;
} else if ( box.bottom > pbox.bottom ) {
el.style.top = `${el.offsetTop - (box.bottom - pbox.bottom)}px`;
moved = true;
}
if ( box.left < pbox.left ) {
el.style.left = `${el.offsetLeft + (pbox.left - box.left)}px`;
moved = true;
} else if ( box.right > pbox.right ) {
el.style.left = `${el.offsetLeft - (box.right - pbox.right)}px`;
moved = true;
}
if ( moved && this.displace )
this.displace.reinit();
},
pin() {
this.pinned = true;
this.$emit('pin');
this.ffzEmit(':pin', this);
},
cleanTips() {
this.$nextTick(() => this.ffzEmit('tooltips:cleanup'))
},
close() {
this.$emit('close');
},
createDrag() {
this.$nextTick(() => {
this.displace = displace(this.$el, {
handle: this.$el.querySelector('.ffz-viewer-card__header'),
highlightInputs: true,
constrain: true,
ignoreFn: e => e.target.closest('.viewer-card-drag-cancel') != null
});
})
},
destroyDrag() {
if ( this.displace ) {
this.displace.destroy();
this.displace = null;
}
},
handleResize() {
if ( this.displace )
this.displace.reinit();
},
onFocus() {
this.z = this.getZ();
},
focus() {
this.$el.focus();
},
ffzEmit(event, ...args) {
this.$emit('emit', event, ...args);
}
}
}
</script>

View file

@ -0,0 +1,197 @@
<template>
<div
ref="root"
class="tw-mg-05 tw-border tw-border-radius-medium tw-pd-05 ffz--cursor"
role="checkbox"
tabindex="0"
:aria-checked="isInCollection"
:class="entryClasses"
@click="toggle"
@keypress="onPress($event)"
>
<div class="panel-heading tw-flex tw-align-items-center">
<span
:data-title="iconTip"
class="ffz-tooltip tw-mg-r-1"
:class="iconClasses"
/>
<figure v-if="image" class="ffz-avatar ffz-avatar--size-20 tw-mg-r-1">
<img
class="tw-block tw-image tw-image-avatar"
:src="image"
/>
</figure>
<div class="tw-flex-grow-1">
{{ collection.title }}
</div>
<span
class="ffz-pill"
:class="{
'ffz-pill--alert': collection.count > collection.limit,
'ffz-pill--warn': collection.count === collection.limit,
//'tw-pill--': collection.count < collection.limit
}"
>
{{ t('collection.count', '{count,number} of {limit,number}', collection) }}
</span>
</div>
</div>
</template>
<script>
export default {
props: [
'initial',
'collection',
'emote',
'getFFZ'
],
data() {
return {
isInCollection: this.initial,
shaking: false,
loading: false
}
},
computed: {
image() {
if ( this.collection.icon )
return this.collection.icon;
const owner = this.collection.owner;
if ( owner.provider && owner.provider_id )
return `https://cdn.frankerfacez.com/avatar/${owner.provider}/${owner.provider_id}`;
return null;
},
iconTip() {
if ( this.isInCollection )
return this.t('emote-card.in-collection', 'This emote is in this collection.');
return this.t('emote-card.not-in-collection', 'This emote is not in this collection.');
},
iconClasses() {
if ( this.loading )
return 'ffz-i-arrows-cw ffz--rotate';
if ( this.isInCollection )
return 'ffz-i-ok';
return 'ffz-i-minus';
},
entryClasses() {
if ( this.shaking )
return 'tw-c-background-alt ffz--shaking';
if ( this.loading )
return 'tw-c-background-alt-2';
if ( this.isInCollection )
return 'tw-c-background-accent';
return 'tw-c-background-alt';
}
},
created() {
this.onAnimationEnd = this.onAnimationEnd.bind(this);
},
mounted() {
this.$refs.root.addEventListener('animationend', this.onAnimationEnd);
},
beforeUnmount() {
this.$refs.root.removeEventListener('animationend', this.onAnimationEnd);
},
methods: {
onAnimationEnd() {
this.shaking = false;
},
errorShake() {
this.shaking = true;
},
onPress(evt) {
if ( evt.keyCode !== 32 )
return;
evt.preventDefault();
this.toggle();
},
async toggle() {
if ( this.loading )
return;
this.loading = true;
try {
await this.toggleInternal();
} catch(err) {
console.error(err);
this.errorShake();
}
this.loading = false;
},
async toggleInternal() {
const server = this.getFFZ().resolve('staging').api,
url = `${server}/v2/collection/${this.collection.id}/emote/${this.emote.id}`;
const socket = this.getFFZ().resolve('socket'),
token = socket && await socket.getBareAPIToken();
if ( ! token )
throw new Error('Unable to get API token. Are you logged in?');
if ( ! this.isInCollection ) {
if ( this.collection.count >= this.collection.limit )
throw new Error('collection at limit');
const resp = await fetch(url, {
method: 'PUT',
headers: {
Authorization: `Bearer ${token}`
}
}).then(r => r.ok ? r.json() : null);
this.isInCollection = true;
if ( resp?.collection )
this.collection.count = resp.collection.count;
else
this.collection.count++;
} else {
const resp = await fetch(url, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`
}
}).then(r => r.ok ? r.json() : null);
this.isInCollection = false;
if ( resp?.collection )
this.collection.count = resp.collection.count;
else
this.collection.count--;
}
}
}
}
</script>

View file

@ -0,0 +1,131 @@
<template>
<section
class="ffz-emote-card__management"
:class="{'tw-pd-b-05': expanded}"
>
<div
v-if="! noHeader"
class="tw-flex tw-align-items-center tw-c-background-alt-2 tw-pd-y-05 tw-pd-x-1 ffz--cursor"
@click="toggle"
>
<div class="tw-flex-grow-1">
<h4>{{ t('emote-card.manage', 'Manage My Collections') }}</h4>
</div>
<figure
:class="{
'ffz-i-down-dir': expanded,
'ffz-i-left-dir': ! expanded
}"
/>
</div>
<simplebar
v-if="expanded"
classes="ffz-mh-30"
>
<div v-if="loading" class="tw-align-center tw-pd-1">
<h1 class="tw-mg-5 ffz-i-zreknarf loading" />
</div>
<div v-else-if="error" class="tw-align-center tw-pd-1">
<div class="tw-mg-t-1 tw-mg-b-2">
<img
src="//cdn.frankerfacez.com/emoticon/26608/2"
srcSet="//cdn.frankerfacez.com/emoticon/26608/2 1x, //cdn.frankerfacez.com/emoticon/26608/4 2x"
>
</div>
{{ t('emote-card.error', 'There was an error loading data.') }}
</div>
<CollectionEntry
v-else
v-for="collection in collections"
:key="collection.id"
:collection="collection"
:emote="emote"
:getFFZ="getFFZ"
:initial="presence.includes(collection.id)"
/>
</simplebar>
</section>
</template>
<script>
import CollectionEntry from './manage-ffz-collection.vue'
export default {
components: {
CollectionEntry
},
props: [
'emote',
'getFFZ',
'noHeader'
],
data() {
return {
expanded: this.noHeader ? true : false,
loading: false,
error: false,
presence: null,
collections: null
}
},
created() {
if ( this.expanded && ! this.collections )
this.loadCollections();
},
methods: {
toggle() {
this.expanded = ! this.expanded;
if ( this.expanded && ! this.collections )
this.loadCollections();
},
loadCollections() {
if ( this.loading )
return;
this.loading = true;
this._loadCollections()
.then(() => {
this.loading = false;
})
.catch(err => {
console.error(err);
this.error = true;
this.loading = false;
});
},
async _loadCollections() {
const socket = this.getFFZ().resolve('socket'),
token = socket && await socket.getBareAPIToken();
if ( ! token )
throw new Error('Unable to get API token. Are you logged in?');
const server = this.getFFZ().resolve('staging').api,
results = await fetch(`${server}/v2/emote/${this.emote.id}/collections/editable?include=collection`, {
headers: {
Authorization: `Bearer ${token}`
}
}).then(r => r.ok ? r.json() : null);
this.presence = results?.emote?.collections ?? [];
this.collections = results?.collections;
if ( this.collections != null && Object.keys(this.collections).length === 0 )
throw new Error('No collections returned');
}
}
}
</script>

View file

@ -0,0 +1,185 @@
<template>
<section
class="ffz-emote-card__modifiers"
:class="{'tw-pd-b-05': expanded}"
>
<div
class="tw-flex tw-align-items-center tw-c-background-alt-2 tw-pd-y-05 tw-pd-x-1 ffz--cursor"
@click="toggle"
>
<div class="tw-flex-grow-1">
<h4>{{ t('emote-card.modifiers', 'Modifiers') }}</h4>
</div>
<figure
:class="{
'ffz-i-down-dir': expanded,
'ffz-i-left-dir': ! expanded
}"
/>
</div>
<div
v-if="expanded"
v-for="(mod, idx) in modifiers"
:key="idx"
class="tw-pd-05 tw-flex tw-align-items-center tw-border-t"
>
<div class="tw-mg-l-05 tw-inline-flex">
<figure
v-if="mod.icon"
class="ffz-avatar ffz-avatar--50"
>
<img
:src="mod.icon"
class="tw-block tw-image tw-image-avatar"
>
</figure>
<figure
v-else
class="ffz-avatar"
:style="mod.imageStyle"
>
<img
v-if="mod.src"
:src="mod.src"
:srcset="mod.srcSet"
class="tw-block tw-image tw-image-avatar"
>
</figure>
</div>
<div class="tw-align-left tw-flex-grow-1 tw-ellipsis tw-mg-x-1">
<h4 class="tw-inline" :title="mod.name">{{ mod.name }}</h4>
<p
v-if="mod.source"
class="tw-c-text-alt-2 tw-font-size-6"
:title="mod.source_i18n ? t(mod.source_i18n, mod.source) : mod.source"
>
{{ mod.source_i18n ? t(mod.source_i18n, mod.source) : mod.source }}
</p>
<p v-if="mod.owner" class="tw-c-text-alt-2 tw-font-size-6">
<t-list
phrase="emote-card.owner"
default="Owner: {owner}"
>
<template #owner>
<a
v-if="mod.ownerLink"
rel="noopener noreferrer"
target="_blank"
:href="mod.ownerLink"
>{{ mod.owner }}</a>
<span v-else>{{ mod.owner }}</span>
</template>
</t-list>
</p>
<p v-if="mod.artist" class="tw-c-text-alt-2 tw-font-size-6">
<t-list
phrase="emote-card.artist"
default="Artist: {artist}"
>
<template #artist>
<a
v-if="mod.artistLink"
rel="noopener noreferrer"
target="_blank"
:href="mod.artistLink"
class="ffz-i-artist"
>{{ mod.artist }}</a>
<span v-else>{{ mod.artist }}</span>
</template>
</t-list>
</p>
</div>
</div>
</section>
</template>
<script>
export default {
props: [
'raw_modifiers',
'getFFZ'
],
data() {
const ffz = this.getFFZ(),
settings = ffz.resolve('settings'),
provider = settings.provider;
return {
expanded: provider.get('emote-card.expand-mods', true)
}
},
computed: {
modifiers() {
const ffz = this.getFFZ(),
emotes = ffz.resolve('chat.emotes');
const out = [];
for(const [set_id, emote_id] of this.raw_modifiers) {
if ( set_id === 'info' ) {
out.push({
type: 'info',
icon: emote_id?.icon,
name: emote_id?.label
});
continue;
}
const emote_set = emotes.emote_sets[set_id],
emote = emote_set?.emotes?.[emote_id];
if ( emote ) {
const is_effect = emote.modifier_flags != 0;
out.push({
type: 'emote',
id: emote.id,
src: emote.animSrc ?? emote.src,
srcSet: emote.animSrcSet ?? emote.srcSet,
width: emote.width,
height: emote.height,
name: emote.name,
imageStyle: {
width: `${Math.min(112, (emote.width ?? 28) * 1)}px`,
height: `${(emote.height ?? 28) * 1}px`
},
source: emote_set.source_line || (`${emote_set.source || 'FFZ'} ${emote_set.title || 'Global Emotes'}`),
owner: emote.owner && ! is_effect
? (emote.owner.display_name || emote.owner.name)
: null,
ownerLink: emote.owner && ! is_effect && ! emote_set.source
? `https://www.frankerfacez.com/${emote.owner.name}`
: null,
artist: emote.artist
? (emote.artist.display_name || emote.artist.name)
: null,
artistLink: emote.artist && ! emote_set.source
? `https://www.frankerfacez.com/${emote.artist.name}`
: null,
});
}
}
return out;
}
},
methods: {
toggle() {
const ffz = this.getFFZ(),
settings = ffz.resolve('settings'),
provider = settings.provider;
this.expanded = ! this.expanded
provider.set('emote-card.expand-mods', this.expanded);
}
}
}
</script>

View file

@ -0,0 +1,257 @@
<template>
<section class="viewer-card__actions tw-bottom-0 tw-pd-1">
<template v-if="loading">
<div class="tw-align-center tw-pd-1">
<h1 class="tw-mg-5 ffz-i-zreknarf loading" />
</div>
</template>
<template v-else-if="errorNoUser">
<div class="tw-align-center tw-pd-1">
<div class="tw-mg-t-1 tw-mg-b-2">
<img
src="//cdn.frankerfacez.com/emoticon/26608/2"
srcSet="//cdn.frankerfacez.com/emoticon/26608/2 1x, //cdn.frankerfacez.com/emoticon/26608/4 2x"
>
</div>
{{ t('emote-card.report.no-user', 'Sorry, but you don\'t appear to have a FrankerFaceZ account or you aren\'t signed in. In order to submit a report, you need to have a FFZ account.') }}
</div>
</template>
<template v-else-if="error">
<div class="tw-align-center tw-pd-1">
<div class="tw-mg-t-1 tw-mg-b-2">
<img
src="//cdn.frankerfacez.com/emoticon/26608/2"
srcSet="//cdn.frankerfacez.com/emoticon/26608/2 1x, //cdn.frankerfacez.com/emoticon/26608/4 2x"
>
</div>
{{ t('emote-card.report.error', 'There was an error submitting your report.') }}
</div>
</template>
<template v-else-if="success">
<div class="tw-align-center tw-pd-1">
{{ t('emote-card.report.success', 'Your report was submitted successfully.') }}
</div>
<div class="tw-align-center">
<button
class="tw-button tw-mg-x-1"
@click="$emit('close')"
>
<span class="tw-button__text">
{{ t('emote-card.close', 'Close') }}
</span>
</button>
</div>
</template>
<template v-else-if="category">
<p class="tw-strong tw-mg-b-05">
<t-list
phrase="emote-card.report-details"
default="You are reporting this emote for {reason}. Please enter any additional details below:"
>
<template #reason><code>{{ category.i18n ? t(category.i18n, category.title, category) : category.title }}</code></template>
</t-list>
</p>
<textarea
v-model="message"
class="tw-full-width tw-border-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 ffz-input"
:placeholder="t('emote-card.report.placeholder', 'Enter a report message here.')"
/>
<div class="tw-mg-t-05 tw-align-center">
<button
:disabled="! canReport"
class="tw-button tw-mg-x-1"
:class="{'tw-button--disabled': ! canReport}"
@click="submitReport"
>
<span class="tw-button__icon tw-button__icon--left">
<figure class="ffz-i-flag" />
</span>
<span class="tw-button__text">
{{ t('emote-card.report', 'Report Emote') }}
</span>
</button>
</div>
</template>
<template v-else>
<p class="tw-strong tw-mg-b-1">
{{ t('emote-card.report-why', 'Why are you submitting this report?') }}
</p>
<form class="tw-flex tw-flex-column tw-border tw-c-background-body tw-border-radius-small tw-full-width">
<div
v-for="(reason, idx) in REASONS"
:key="idx"
class="ffz-radio tw-relative tw-pd-l-1"
:class="{'tw-border-t': idx > 0}"
>
<input
:id="'report$' + id + '$reason$' + idx"
:name="'report-reasons$' + id"
v-model="pendingCategory"
:value="reason"
type="radio"
class="ffz-radio__input"
/>
<label
:for="'report$' + id + '$reason$' + idx"
class="tw-block ffz-radio__label tw-pd-r-1 tw-pd-y-1"
>
<div class="tw-pd-l-1">
{{ reason.i18n ? t(reason.i18n, reason.title, reason) : reason.title }}
</div>
</label>
</div>
</form>
<div class="tw-mg-t-05 tw-align-center">
<button
:disabled="pendingCategory == null"
class="tw-button tw-mg-x-1"
:class="{'tw-button--disabled': pendingCategory == null}"
@click="category = pendingCategory"
>
<span class="tw-button__text">
{{ t('emote-card.report.next', 'Next') }}
</span>
</button>
</div>
</template>
</section>
</template>
<script>
const REASONS = [
{
title: 'Bullying or Harassment',
i18n: 'emote-card.report.bully-harass'
},
{
title: 'Hateful Conduct',
i18n: 'emote-card.report.hateful'
},
{
title: 'Nudity or Sexually Explicit',
i18n: 'emote-card.report.explicit'
},
{
title: 'Other',
i18n: 'emote-card.report.other',
skip_report: true
}
];
let id = 0;
export default {
props: [
'emote',
'getFFZ'
],
data() {
return {
REASONS: REASONS,
id: id++,
message: '',
pendingCategory: null,
category: null,
loading: false,
success: false,
error: false,
errorNoUser: false
}
},
computed: {
canReport() {
if ( ! this.category )
return false;
if ( this.category.skip_report )
return ! /^\s*$/.test(this.message)
return true;
}
},
created() {
this.checkToken();
},
methods: {
async checkToken() {
this.loading = true;
let token;
try {
token = await this.getFFZ().resolve('socket').getAPIToken();
} catch(err) {
console.error(err);
token = null;
}
this.loading = false;
this.errorNoUser = token == null;
},
async submitReport() {
if ( this.loading || ! this.canReport )
return;
this.loading = true;
try {
await this._submitReport();
} catch(err) {
console.error(err);
this.loading = false;
this.error = true;
this.success = false;
return;
}
this.loading = false;
this.success = true;
},
async _submitReport() {
const token = await this.getFFZ().resolve('socket').getAPIToken();
if ( ! token?.token )
throw new Error('Unable to get token');
const server = this.getFFZ().resolve('staging').api;
const params = new URLSearchParams;
let msg = this.message;
if ( this.category && ! this.category.skip_report )
msg = `${this.category.title}${msg.length ? `\r\nDetails: ${msg}` : ''}`;
params.append('report', msg);
const resp = await fetch(`${server}/v2/emote/${this.emote.id}/report`, {
method: 'POST',
body: params,
headers: {
Authorization: `Bearer ${token.token}`
}
});
if ( ! resp || ! resp.ok )
throw new Error('Invalid response from server.');
const data = await resp.json();
if ( ! data?.success )
throw new Error('Did not succeed');
}
}
}
</script>

View file

@ -0,0 +1,194 @@
<template>
<section class="ffz-emote-card__twitch tw-pd-1">
<section
v-if="emote.channel_id"
class="tw-mg-b-05 tw-flex tw-align-items-center"
>
<a
:href="`https://www.twitch.tv/${emote.channel_login}`"
rel="noopener noreferrer"
target="_blank"
class="tw-semibold tw-font-size-4 ffz-i-camera"
>
{{ emote.channel_title }}
</a>
<div v-if="emote.channel_live" class="tw-mg-l-1">
<figure class="ffz-emote-card__live-indicator tw-mg-r-05" />
{{ t('emote-card.live', 'LIVE') }}
</div>
</section>
<template v-if="isSubscriptionEmote">
<p v-if="emote.unlocked">
{{ t('emote-card.sub.unlocked', 'You have unlocked this emote by subscribing to {user}\'s channel at Tier {tier}.', {
tier: emote.unlock_tier,
user: emote.channel_title
}) }}
</p>
<p v-else-if="emote.unlock_tier <= 1">
{{ t('emote-card.sub.upsell', "Subscribe to {user}'s channel to use {emote} along with {emotes, plural, one {# more emote} other {# more of their emotes} }, including:", {
emote: emote.name,
emotes: unlockCount,
user: emote.channel_title
}) }}
</p>
<p v-else>
{{ t('emote-card.sub.upsell-tier', "Subscribe to {user}'s channel at Tier {tier} to use {emote} along with {emotes, plural, one {# more emote} other {# more of their emotes} }, including:", {
emote: emote.name,
tier: emote.unlock_tier,
emotes: unlockCount,
user: emote.channel_title
}) }}
</p>
</template>
<template v-else-if="isBitsEmote">
<p v-if="emote.unlocked">
{{ t('emote-card.bits.unlocked', 'You have unlocked this emote by using {count, plural, one {# bit} other {# bits} } in {user}\'s channel.', {
count: emote.bits_amount,
user: emote.channel_title
}) }}
</p>
<p v-else>
{{ t('emote-card.bits.upsell', "Use {count, plural, one {# more bit} other {# more bits} } in {user}'s channel to permanently unlock this emote reward.", {
count: emote.bits_remain,
user: emote.channel_title
}) }}
</p>
</template>
<template v-else-if="isFollowEmote">
<p v-if="emote.unlocked">
{{ t('emote-card.follow.unlocked', 'You have unlocked this emote by following {user}\'s channel.', {
user: emote.channel_title
}) }}
</p>
<p v-else>
{{ t('emote-card.follow.upsell', "Follow {user}'s channel to use {emotes, plural, one {their emote} other {# of their emotes} }, including:", {
emotes: unlockCount,
user: emote.channel_title
}) }}
</p>
</template>
<div v-if="extras.length" class="ffz-emote-card__emote-list tw-mg-t-05">
<div
v-for="extra in extras"
:key="extra.id"
:data-title="extra.name"
class="ffz-tooltip"
>
<img :src="extra.src" :srcset="extra.srcSet" :alt="extra.name" />
</div>
</div>
<div v-if="emote.channel_id" class="tw-mg-t-1 tw-flex">
<follow-button
:channel="emote.channel_id"
:initial="emote.channel_followed"
/>
<button
v-if="canSubscribe"
class="tw-button tw-mg-l-1"
@click="subscribe"
>
<span class="tw-button__icon tw-button__icon--left">
<figure class="ffz-i-star" />
</span>
<span class="tw-button__text">
{{ t('emote-card.sub-button', 'Subscribe') }}
</span>
<span v-if="emote.product_price" class="ffz-button__sub-price">
{{ emote.product_price }}
</span>
</button>
</div>
</section>
</template>
<script>
export default {
props: [
'emote',
'getFFZ'
],
computed: {
unlockCount() {
if ( Array.isArray(this.emote.extra_emotes) )
return this.emote.extra_emotes.length;
return 0;
},
extras() {
if ( Array.isArray(this.emote.extra_emotes) )
return this.emote.extra_emotes.slice(0, 8);
return [];
},
isSubscriptionEmote() {
return this.emote.unlock_mode === 'subscribe';
},
isBitsEmote() {
return this.emote.unlock_mode === 'bits';
},
isFollowEmote() {
return this.emote.unlock_mode === 'follow';
},
canSubscribe() {
if ( ! this.isSubscriptionEmote || this.emote.unlocked )
return false;
// Only show the sub button if we have a target product.
if ( ! this.emote.channel_product )
return false;
const settings = this.getFFZ().resolve('settings'),
current_channel = settings.get('context.channelID');
// Only show the subscribe button for the current channel.
if ( current_channel !== this.emote.channel_id )
return false;
// Finally, make sure we can find the right UI elements.
const store = this.getFFZ().resolve('site')?.store,
web_munch = this.getFFZ().resolve('site.web_munch'),
sub_form = web_munch?.getModule?.('sub-form');
if ( ! store?.dispatch || ! sub_form )
return false;
return true;
}
},
methods: {
subscribe() {
if ( ! this.canSubscribe )
return;
const store = this.getFFZ().resolve('site')?.store,
web_munch = this.getFFZ().resolve('site.web_munch'),
sub_form = web_munch?.getModule?.('sub-form');
if ( ! store?.dispatch || ! sub_form )
return;
sub_form({
productName: this.emote.channel_product,
trackingContext: {
source: 'emote_card'
}
})(store.dispatch);
this.$emit('close');
}
}
}
</script>

View file

@ -0,0 +1,557 @@
'use strict';
// ============================================================================
// Emote Cards
// ============================================================================
import {createElement} from 'utilities/dom';
import {deep_copy, getTwitchEmoteURL} from 'utilities/object';
import { EmoteTypes, TWITCH_GLOBAL_SETS, TWITCH_POINTS_SETS, TWITCH_PRIME_SETS } from 'utilities/constants';
import GET_EMOTE from './twitch_data.gql';
import Module from 'utilities/module';
function getEmoteTypeFromTwitchType(type) {
if ( type === 'SUBSCRIPTIONS' )
return EmoteTypes.Subscription;
if ( type === 'FOLLOWER' )
return EmoteTypes.Follower;
if ( type === 'GLOBALS' || type === 'SMILIES' )
return EmoteTypes.Global;
if ( type === 'LIMITED_TIME' || type === 'MEGA_COMMERCE' )
return EmoteTypes.LimitedTime;
if ( type === 'BITS_BADGE_TIERS' )
return EmoteTypes.BitsTier;
if ( type === 'TWO_FACTOR' )
return EmoteTypes.TwoFactor;
if ( type === 'PRIME' )
return EmoteTypes.Prime;
if ( type === 'TURBO' )
return EmoteTypes.Turbo;
return EmoteTypes.Unknown;
}
function tierToNumber(tier) {
if ( tier === '1000' || tier === 'prime' )
return 1;
if ( tier === '2000' )
return 2;
if ( tier === '3000' )
return 3;
return 1;
}
export default class EmoteCard extends Module {
constructor(...args) {
super(...args);
this.should_enable = true;
this.inject('i18n');
this.inject('chat');
this.inject('chat.emotes');
this.inject('chat.emoji');
this.inject('site');
this.inject('site.apollo');
this.inject('site.twitch_data');
this.vue = this.resolve('vue');
this.last_z = 9000;
this.open_cards = {};
this.last_card = null;
}
onEnable() {
this.on('chat.emotes:click', this.handleClick, this);
}
handleClick(evt) {
if ( ! this.chat.context.get('chat.emote-dialogs') )
return;
evt.preventDefault();
this.openCard({
provider: evt.provider,
code: evt.code,
variant: evt.variant,
name: evt.name,
set: evt.set,
id: evt.id ?? `${evt.code}::${evt.variant}`
}, evt.modifiers, evt.source);
}
async loadVue() {
if ( this._vue_loaded )
return;
await this.vue.enable();
const card_component = await import(/* webpackChunkName: 'emote-cards' */ './components/card.vue');
this.vue.component('emote-card', card_component.default);
this._vue_loaded = true;
}
canReportTwitch() {
const site = this.resolve('site'),
core = site.getCore?.(),
user = site.getUser(),
web_munch = this.resolve('site.web_munch');
let report_form;
try {
report_form = web_munch.getModule('user-report');
} catch(err) {
return false;
}
return !! report_form && !! user?.id && core?.store?.dispatch;
}
reportTwitchEmote(id, channel) {
const site = this.resolve('site'),
core = site.getCore(),
user = site.getUser(),
web_munch = this.resolve('site.web_munch');
let report_form;
try {
report_form = web_munch.getModule('user-report');
} catch(err) {
return false;
}
if ( ! user?.id || ! core?.store?.dispatch )
return false;
core.store.dispatch({
type: 'core.modal.MODAL_SHOWN',
modalComponent: report_form,
modalProps: {
reportContext: {
contentID: String(id),
contentMetadata: {
channelID: String(user.id)
},
contentType: 'EMOTE_REPORT',
targetUserID: String(channel),
trackingContext: 'emote_card'
}
}
});
return true;
}
async loadData(emote) {
if ( emote.provider === 'twitch' ) {
const apollo = this.resolve('site.apollo');
if ( ! apollo )
throw new Error('Unable to load emote data');
const result = await apollo.client.query({
query: GET_EMOTE,
variables: {
emoteID: emote.id
}
});
if ( ! result?.data?.emote )
throw new Error('Unable to load emote data');
const data = result.data.emote;
const src = getTwitchEmoteURL(data.id, 2, true, true);
const srcSet = `${src} 1x, ${getTwitchEmoteURL(data.id, 4, true, true)} 2x`;
let source;
let body;
let tier;
//console.log("loaded data", data);
let type = getEmoteTypeFromTwitchType(data.type);
let set;
try {
set = parseInt(data.setID, 10);
} catch(err) { /* no-op */ }
if ( TWITCH_GLOBAL_SETS.includes(set) )
type = EmoteTypes.Global;
else if ( TWITCH_POINTS_SETS.includes(set) )
type = EmoteTypes.ChannelPoints;
else if ( TWITCH_PRIME_SETS.includes(set) )
type = EmoteTypes.Prime;
//console.log('loaded data', data, type);
if ( type === EmoteTypes.Subscription ) {
const products = data.owner?.subscriptionProducts;
if ( Array.isArray(products) ) {
for(const product of products) {
if ( product.emotes.some(em => em.id === data.id) ) {
tier = tierToNumber(product.tier);
break;
}
}
}
source = this.i18n.t('emote-card.sub', 'Tier {tier} Sub Emote ({source})', {
tier: tier,
source: data.owner?.displayName || data.owner?.login
});
body = 'twitch';
} else if ( type === EmoteTypes.Follower ) {
source = this.i18n.t('emote.follower', 'Follower Emote ({source})', {
source: data.owner.displayName || data.owner.login
});
body = 'twitch';
} else if ( type === EmoteTypes.Global )
source = this.i18n.t('emote.global', 'Twitch Global');
else if ( type === EmoteTypes.LimitedTime )
source = this.i18n.t('emote.limited', 'Limited-Time Only Emote');
else if ( type === EmoteTypes.BitsTier ) {
source = this.i18n.t('emote-card.bits', '{amount,number} Bits Reward ({source})', {
amount: data.bitsBadgeTierSummary?.threshold,
source: data.owner.displayName || data.owner.login
});
body = 'twitch';
} else if ( type === EmoteTypes.TwoFactor )
source = this.i18n.t('emote.2fa', 'Twitch 2FA Emote');
else if ( type === EmoteTypes.ChannelPoints ) {
source = this.i18n.t('emote.points', 'Channel Points Emote');
body = 'twitch';
} else if ( type === EmoteTypes.Prime || type === EmoteTypes.Turbo )
source = this.i18n.t('emote.prime', 'Prime Gaming');
else
source = data.type;
//console.log('raw data', data);
const out = {
//raw: data,
id: data.id,
fav_source: 'twitch',
channel_id: data.owner?.id,
more: [],
body,
src,
srcSet,
name: data.token,
source,
artist: data.artist
? (data.artist.displayName || data.artist.login)
: null,
artistLink: data.artist
? `https://www.twitch.tv/${data.artist.login}`
: null
};
if ( data.owner?.id ) {
out.channel_title = data.owner.displayName ?? data.owner.login;
out.channel_login = data.owner.login;
out.channel_live = !! data.owner.stream?.id;
out.channel_followed = !! data.owner?.self?.follower?.followedAt;
out.more.push({
type: 'link',
icon: 'ffz-i-link-ext',
title: 'View Channel on TwitchEmotes.com',
href: `https://twitchemotes.com/channels/${data.owner.id}`
});
// Check if we can actually submit a report.
if ( this.canReportTwitch() )
out.more.push({
type: 'report-twitch',
title_i18n: 'emote-card.report',
title: 'Report Emote',
icon: 'ffz-i-flag'
});
}
if ( data.bitsBadgeTierSummary?.threshold ) {
out.unlock_mode = 'bits';
out.unlocked = data.bitsBadgeTierSummary.self?.isUnlocked;
out.bits_amount = data.bitsBadgeTierSummary.threshold;
out.bits_remain = data.bitsBadgeTierSummary.self?.numberOfBitsUntilUnlock ?? out.unlock_amount;
} else if ( type === EmoteTypes.Follower ) {
out.unlock_mode = 'follow';
out.unlocked = false; // out.channel_followed ?? false;
const extras = out.extra_emotes = [];
if ( ! out.unlocked && Array.isArray(data.owner?.channel?.localEmoteSets) )
for(const set of data.owner.channel.localEmoteSets)
if ( Array.isArray(set.emotes) )
for(const em of set.emotes) {
const src = getTwitchEmoteURL(em.id, 1, true, true);
const srcSet = `${src} 1x, ${getTwitchEmoteURL(em.id, 2, true, true)} 2x`;
extras.push({
id: em.id,
name: em.token,
src,
srcSet
});
}
} else if ( type === EmoteTypes.Subscription ) {
out.unlock_mode = 'subscribe';
out.unlocked = false;
out.unlock_tier = tier;
out.existing_tier = 0;
const bene = data.owner?.self?.subscriptionBenefit;
if ( bene?.tier )
out.existing_tier = tierToNumber(bene.tier);
const extras = out.extra_emotes = [],
extier = out.existing_tier;
if ( extier >= tier )
out.unlocked = true;
else if ( Array.isArray(data.owner?.subscriptionProducts) )
for(const product of data.owner.subscriptionProducts) {
const ptier = tierToNumber(product.tier);
if ( ptier === tier ) {
out.channel_product = product.name;
if ( product.priceInfo?.price && product.priceInfo.currency ) {
const formatter = new Intl.NumberFormat(navigator.languages, {
style: 'currency',
currency: product.priceInfo.currency
});
out.product_price = formatter.format(product.priceInfo.price / 100);
}
}
if ( ptier > extier && ptier <= tier && Array.isArray(product.emotes) )
for(const em of product.emotes) {
const src = getTwitchEmoteURL(em.id, 1, true, true);
const srcSet = `${src} 1x, ${getTwitchEmoteURL(em.id, 2, true, true)} 2x`;
extras.push({
id: em.id,
name: em.token,
src,
srcSet
});
}
}
}
return out;
}
// Emoji
if ( emote.provider === 'emoji' ) {
const emoji = this.emoji.emoji[emote.code],
style = this.chat.context.get('chat.emoji.style'),
variant = emote.variant ? emoji.variants[emote.variant] : emoji,
vcode = emote.variant ? this.emoji.emoji[emote.variant] : null;
const category = emoji.category ? this.i18n.t(`emoji.category.${emoji.category.toSnakeCase()}`, this.emoji.categories[emoji.category] || emoji.category) : null;
const out = {
id: emote.code,
fav_source: 'emoji',
more: [],
src: this.emoji.getFullImage(variant.image, style),
srcSet: this.emoji.getFullImageSet(variant.image, style),
width: 18,
height: 18,
name: `:${emoji.names[0]}:${vcode ? `:${vcode.names[0]}:` : ''}`,
source: this.i18n.t('tooltip.emoji', 'Emoji - {category}', {category})
};
return out;
}
if ( emote.provider !== 'ffz' )
throw new Error('Invalid provider');
// Try to get the emote set.
const emote_set = this.emotes.emote_sets[emote.set],
data = emote_set?.emotes?.[emote.id];
if ( ! data )
throw new Error('Unable to load emote data');
const out = {
id: data.id,
fav_source: emote_set.source ?? 'ffz',
more: [],
src: data.animSrc2 ?? data.src2,
srcSet: data.animSrcSet2 ?? data.srcSet2,
width: data.width,
height: data.height,
name: data.name,
originalName: data.original_name,
source: emote_set.source_line || (`${emote_set.source || 'FFZ'} ${emote_set.title || 'Global Emotes'}`),
owner: data.owner
? (data.owner.display_name || data.owner.name)
: null,
ownerLink: data.owner && ! emote_set.source
? `https://www.frankerfacez.com/${data.owner.name}`
: null,
artist: data.artist
? (data.artist.display_name || data.artist.name)
: null,
artistLink: data.artist && ! emote_set.source
? `https://www.frankerfacez.com/${data.artist.name}`
: null,
};
if ( ! emote_set.source ) {
if ( data.public )
out.body = 'manage-ffz';
out.more.push({
type: 'link',
title_i18n: 'emote-card.view-on-ffz',
title: 'View on FrankerFaceZ',
href: `https://www.frankerfacez.com/emoticon/${data.id}-`
});
out.more.push({
type: 'report-ffz',
title_i18n: 'emote-card.report',
title: 'Report Emote',
icon: 'ffz-i-flag'
});
} else if ( data.click_url ) {
out.more.push({
type: 'link',
title_i18n: 'emote-card.view-external',
title: 'View on {source}',
source: emote_set.source,
href: data.click_url
});
}
return out;
}
async openCard(emote, modifiers, event) {
const card_key = `${emote.provider}::${emote.id}::${modifiers ?? ''}`,
old_card = this.open_cards[card_key];
if ( old_card ) {
old_card.$el.style.zIndex = ++this.last_z;
old_card.focus();
return;
}
let pos_x = event ? event.clientX : window.innerWidth / 2,
pos_y = event ? event.clientY + 15 : window.innerHeight / 2;
if ( this.last_card ) {
const card = this.last_card;
if ( ! event ) {
pos_x = card.$el.offsetLeft;
pos_y = card.$el.offsetTop;
}
card.close();
}
// Start loading data. Don't await it yet, so we can
// wait for Vue at the same time.
const data = this.loadData(emote);
// Now load vue.
await this.loadVue();
// Display the card.
this.last_card = this.open_cards[card_key] = this.buildCard(
pos_x,
pos_y,
emote,
modifiers,
data
);
}
buildCard(pos_x, pos_y, emote, modifiers, data) {
let child;
const component = new this.vue.Vue({
el: createElement('div'),
render: h => h('emote-card', {
props: {
raw_emote: deep_copy(emote),
raw_modifiers: modifiers,
data: data,
getFFZ: () => this,
reportTwitchEmote: (...args) => this.reportTwitchEmote(...args),
getZ: () => ++this.last_z
},
on: {
emit: (event, ...data) => this.emit(event, ...data),
close: () => {
const el = component.$el;
el.remove();
component.$destroy();
if ( this.last_card === child )
this.last_card = null;
const card_key = `${emote.provider}::${emote.id}::${modifiers ?? ''}`;
if ( this.open_cards[card_key] === child )
this.open_cards[card_key] = null;
this.emit('tooltips:cleanup');
},
pin: () => {
if ( this.last_card === child )
this.last_card = null;
}
}
})
});
child = component.$children[0];
const el = component.$el;
el.style.left = `${pos_x}px`;
el.style.top = `${pos_y}px`;
const container = document.querySelector(this.site.constructor.DIALOG_SELECTOR ?? '#root>div>.tw-full-height,.twilight-minimal-root>.tw-full-height');
container.appendChild(el);
requestAnimationFrame(() => child.constrain());
return child;
}
}

View file

@ -0,0 +1,65 @@
query FFZ_EmoteCard ($emoteID: ID!) {
emote(id: $emoteID) {
id
type
token
setID
artist {
id
login
displayName
}
owner {
id
login
displayName
channel {
id
localEmoteSets {
id
emotes {
id
token
}
}
}
stream {
id
type
}
self {
follower {
followedAt
}
subscriptionBenefit {
id
tier
}
}
subscriptionProducts {
id
displayName
tier
name
url
emotes {
id
token
}
priceInfo {
id
currency
price
}
}
}
bitsBadgeTierSummary {
threshold
self {
isUnlocked
numberOfBitsUntilUnlock
}
}
type
}
}

View file

@ -0,0 +1,525 @@
<template>
<div
:style="{zIndex: z, '--ffz-color-accent': accent}"
class="ffz-viewer-card tw-border tw-border-radius-medium tw-c-background-base tw-c-text-base tw-elevation-2 tw-flex tw-flex-column viewer-card ffz-accent-card"
tabindex="0"
@focusin="onFocus"
@keyup.esc="close"
>
<div
class="ffz-viewer-card__header tw-border-radius-medium tw-c-background-accent-alt tw-flex-grow-0 tw-flex-shrink-0 viewer-card__background tw-relative"
>
<div
v-if="isUnsafe"
class="ffz--corner-flag ffz--corner-flag--left ffz--corner-flag__warn ffz-tooltip ffz-tooltip--no-mouse tw-border-top-left-radius-medium"
:data-title="unsafeTip"
>
<figure class="ffz-i-attention" />
</div>
<div class="tw-flex tw-flex-column tw-full-height tw-full-width viewer-card__overlay">
<div
class="tw-align-center tw-border-radius-medium tw-align-items-center tw-c-background-alt tw-c-text-base tw-flex tw-flex-grow-1 tw-flex-row tw-full-width tw-justify-content-start tw-pd-05 tw-relative viewer-card__banner"
:class="{'tw-pd-l-3': isUnsafe}"
>
<div class="tw-align-left tw-flex-grow-1 tw-ellipsis tw-mg-l-05 tw-mg-y-05 viewer-card__display-name">
<p class="tw-font-size-6 tw-ellipsis" :title="url">
<span class="tw-c-text-alt-2">{{ urlPrefix }}</span><span>{{ urlDomain }}</span><span class="tw-c-text-alt-2">{{ urlPath }}</span>
</p>
</div>
<div class="tw-flex tw-align-self-start">
<a
:data-title="t('link-card.open-ext', 'Open Link')"
:data-url="targetUrl"
:href="targetUrl"
class="ffz--cursor viewer-card-drag-cancel tw-align-items-center tw-align-middle tw-border-radius-medium tw-button-icon tw-core-button tw-inline-flex tw-interactive tw-justify-content-center tw-overflow-hidden tw-relative ffz-tooltip"
rel="noreferrer noopener"
target="_blank"
>
<span class="tw-button-icon__icon">
<figure class="ffz-i-link-ext" />
</span>
</a>
<div
v-if="hasMoreActions"
v-on-clickaway="closeMore"
class="tw-relative viewer-card-drag-cancel"
>
<button
:data-title="t('emote-card.more', 'More')"
:aria-label="t('emote-card.more', 'More')"
class="tw-align-items-center tw-align-middle tw-border-radius-medium tw-button-icon tw-core-button tw-inline-flex tw-interactive tw-justify-content-center tw-overflow-hidden tw-relative ffz-tooltip"
@click="toggleMore"
>
<span class="tw-button-icon__icon">
<figure class="ffz-i-ellipsis-vert" />
</span>
</button>
<balloon
v-if="moreOpen"
color="background-alt-2"
dir="down-right"
size="sm"
class="tw-border-radius-medium"
>
<simplebar classes="ffz-mh-30">
<div class="tw-pd-y-05">
<template v-for="(entry, idx) in moreActions">
<div
v-if="entry.divider"
:key="idx"
class="tw-mg-1 tw-border-b"
/>
<a
:key="idx"
:disabled="entry.disabled"
:href="entry.href"
rel="noopener noreferrer"
target="_blank"
class="tw-block ffz-interactable ffz-interactable--hover-enabled ffz-interactable--default tw-interactive tw-full-width ffz--cursor"
@click="clickMore(entry, $event)"
>
<div class="tw-flex tw-align-items-center tw-pd-y-05 tw-pd-x-1">
<div
class="tw-flex-grow-1"
:class="{'tw-mg-r-1' : !! entry.icon}"
>
{{ entry.title_i18n ? t(entry.title_i18n, entry.title, entry) : entry.title }}
</div>
<figure
v-if="entry.icon || entry.type === 'link'"
:class="entry.icon || 'ffz-i-link-ext'"
/>
</div>
</a>
</template>
</div>
</simplebar>
</balloon>
</div>
<button
:data-title="t('emote-card.close', 'Close')"
:aria-label="t('emote-card.close', 'Close')"
class="viewer-card-drag-cancel tw-align-items-center tw-align-middle tw-border-radius-medium tw-button-icon tw-core-button tw-inline-flex tw-interactive tw-justify-content-center tw-overflow-hidden tw-relative ffz-tooltip"
@click="close"
>
<span class="tw-button-icon__icon">
<figure class="ffz-i-cancel" />
</span>
</button>
</div>
</div>
</div>
</div>
<section class="tw-c-background-body">
<div class="viewer-card__tabs-container tw-border-t">
<div
v-for="(d, key) in tabs"
:id="`link-card__${key}`"
:key="key"
:class="{
active: active_tab === key,
'tw-inline-flex': !! d.pill,
'tw-align-items-center': !! d.pill
}"
class="viewer-card__tab tw-pd-x-1"
@click="active_tab = key"
>
<span>{{ d.label_i18n ? t(d.label_i18n, d.label, d) : d.label }}</span>
<span v-if="d.pill" class="tw-mg-l-05 ffz-pill" :class="d.pill_classes || ''">{{ d.pill_i18n ? t(d.pill_i18n, d.pill, d) : d.pill }}</span>
</div>
</div>
</section>
<keep-alive>
<chat-rich
v-if="rich_data && active_tab === 'preview'"
:data="rich_data"
:url="url"
:events="events"
:no-unsafe="true"
:no-elevation="true"
:no-tooltip="true"
:no-link="true"
/>
</keep-alive>
<keep-alive>
<ManageFFZ
v-if="active_tab === 'manage' && ffzEmote"
:emote="ffzEmote"
:getFFZ="getFFZ"
:no-header="true"
/>
</keep-alive>
<div
class="tw-c-background-base tw-pd-05"
v-if="active_tab === 'urls'"
>
<table v-if="embed && embed.urls && embed.urls.length">
<tbody
v-for="(url, idx) in embed.urls"
:key="idx"
>
<tr>
<td class="tw-c-text-alt-2">{{ tNumber(idx + 1) }}.</td>
<td class="tw-pd-x-05 tw-word-break-all">
<a
:data-url="url.url"
:href="url.url"
rel="noreferrer noopener"
target="_blank"
class="ffz-link--inherit"
>
<lc-url :url="url.url" :show-protocol="true" />
</a>
</td>
</tr>
<tr v-if="url.shortened || (url.flags && url.flags.length)">
<td>&nbsp;</td>
<td class="tw-pd-x-05">
<span
v-if="url.shortened"
class="ffz-pill"
>{{ t('link-card.shortened', 'shortened') }}</span>
<span
v-if="url.flags"
v-for="flag in url.flags"
class="ffz-pill ffz-pill--live"
>{{ flag }}</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script>
import {deep_copy, sha256} from 'utilities/object';
import displace from 'displacejs';
import ManageFFZ from '../../emote_card/components/manage-ffz.vue';
export default {
components: {
ManageFFZ,
'chat-rich': async () => {
const stuff = await import(/* webpackChunkName: "chat" */ 'src/modules/chat/components');
return stuff.default('./chat-rich.vue').default;
}
},
props: [
'url', 'data',
'pos_x', 'pos_y',
'getZ', 'getFFZ',
'use_dest'
],
data() {
const token = {
type: 'link',
force_rich: true,
is_mail: false,
url: this.url,
text: this.url
};
const chat = this.getFFZ().resolve('chat');
return {
z: this.getZ(),
active_tab: 'preview',
moreOpen: false,
rich_data: chat.rich_providers.link.process.call(chat, token),
url_hash: null,
loaded: false,
errored: false,
pinned: false,
embed: null,
events: {
on: (...args) => this.getFFZ().on(...args),
off: (...args) => this.getFFZ().off(...args),
emit: (...args) => this.getFFZ().emit(...args)
}
}
},
computed: {
isUnsafe() {
return this.embed?.unsafe;
},
ffzEmote() {
if ( this.embed?.special?.type !== 'ffz-emote' )
return null;
return {
id: this.embed.special.id
}
},
unsafeTip() {
if ( ! Array.isArray(this.embed?.urls) )
return null;
const reasons = Array.from(new Set(this.embed.urls.map(url => url.flags).flat())).join(', ');
return this.t(
'tooltip.link-unsafe',
'Caution: This URL is has been flagged as potentially harmful by: {reasons}',
{
reasons
}
)
},
tabs() {
const tabs = {
preview: {
label: 'Preview',
label_i18n: 'link-card.preview'
}
};
if ( this.ffzEmote?.id )
tabs.manage = {
label: 'Manage Emote',
label_i18n: 'link-card.manage-emote'
};
if ( Array.isArray(this.embed?.urls) ) {
tabs.urls = {
label: 'Visited URLs',
label_i18n: 'tooltip.link.urls'
};
if ( this.embed.urls.length > 1 ) {
tabs.urls.pill = this.tNumber(this.embed.urls.length);
if ( this.embed.unsafe )
tabs.urls.pill_classes = ['ffz-pill--live'];
}
}
return tabs;
},
accent() {
return this.embed?.accent
},
_url() {
if ( this.url instanceof URL )
return this.url;
return new URL(this.url);
},
targetUrl() {
const urls = this.use_dest ? this.embed?.urls : null;
if ( Array.isArray(urls) )
for(const url of urls) {
if ( ! url.shortened )
return url.url;
}
return this.url;
},
urlPrefix() {
return null;
//return this._url.protocol;
},
urlDomain() {
return this._url.host;
},
urlPath() {
return this._url.toString().slice(this._url.origin.length);
},
moreActions() {
const actions = [];
/*if ( this.url_hash && this.vt_key )
actions.push({
type: 'virus-total',
title_i18n: 'link-card.virus-check',
title: 'Check URL on VirusTotal',
icon: 'ffz-i-flag'
});*/
if ( Array.isArray(this.embed?.actions) )
for(const act of this.embed.actions)
actions.push(act);
return actions;
},
hasMoreActions() {
return (this.moreActions?.length ?? 0) > 0;
},
},
beforeMount() {
this.ffzEmit(':open', this);
sha256(this.url).then(hash => {
this.url_hash = hash;
});
this.data.then(data => {
this.loaded = true;
this.ffzEmit(':load', this);
this.embed = deep_copy(data);
this.$nextTick(() => this.handleResize());
}).catch(err => {
console.error('Error loading link card data', err);
this.errored = true;
});
},
mounted() {
this._on_resize = this.handleResize.bind(this);
window.addEventListener('resize', this._on_resize);
this.createDrag();
},
beforeDestroy() {
this.ffzEmit(':close', this);
this.destroyDrag();
if ( this._on_resize ) {
window.removeEventListener('resize', this._on_resize);
this._on_resize = null;
}
},
methods: {
toggleMore() {
this.moreOpen = ! this.moreOpen;
},
closeMore() {
this.moreOpen = false;
},
clickMore(entry, evt) {
this.moreOpen = false;
if ( entry.type === 'link' )
return;
evt.preventDefault();
//if ( entry.type === 'virus-total' )
// this.openVirusTotal();
},
/*async openVirusTotal() {
if ( ! this.url_hash || ! this.vt_key )
return;
const resp = await fetch(`https://www.virustotal.com/api/v3/urls`, {
method: 'POST',
headers: {
'x-apikey': this.vt_key
},
body: new URLSearchParams({
url: this.url
})
}).then(resp => resp.ok ? resp.json() : null);
console.log('response', resp);
},*/
constrain() {
const el = this.$el;
let parent = el.parentElement,
moved = false;
if ( ! parent )
parent = document.body;
const box = el.getBoundingClientRect(),
pbox = parent.getBoundingClientRect();
if ( box.top < pbox.top ) {
el.style.top = `${el.offsetTop + (pbox.top - box.top)}px`;
moved = true;
} else if ( box.bottom > pbox.bottom ) {
el.style.top = `${el.offsetTop - (box.bottom - pbox.bottom)}px`;
moved = true;
}
if ( box.left < pbox.left ) {
el.style.left = `${el.offsetLeft + (pbox.left - box.left)}px`;
moved = true;
} else if ( box.right > pbox.right ) {
el.style.left = `${el.offsetLeft - (box.right - pbox.right)}px`;
moved = true;
}
if ( moved && this.displace )
this.displace.reinit();
},
pin() {
this.pinned = true;
this.$emit('pin');
this.ffzEmit(':pin', this);
},
cleanTips() {
this.$nextTick(() => this.ffzEmit('tooltips:cleanup'))
},
close() {
this.$emit('close');
},
createDrag() {
this.$nextTick(() => {
this.displace = displace(this.$el, {
handle: this.$el.querySelector('.ffz-viewer-card__header'),
highlightInputs: true,
constrain: true,
ignoreFn: e => e.target.closest('.viewer-card-drag-cancel') != null
});
})
},
destroyDrag() {
if ( this.displace ) {
this.displace.destroy();
this.displace = null;
}
},
handleResize() {
if ( this.displace )
this.displace.reinit();
},
onFocus() {
this.z = this.getZ();
},
focus() {
this.$el.focus();
},
ffzEmit(event, ...args) {
this.$emit('emit', event, ...args);
}
}
}
</script>

View file

@ -0,0 +1,199 @@
'use strict';
// ============================================================================
// Link Cards
// ============================================================================
import { createElement } from 'utilities/dom';
import { deep_copy } from 'utilities/object';
import Module from 'utilities/module';
export default class LinkCard extends Module {
constructor(...args) {
super(...args);
this.should_enable = true;
this.inject('i18n');
this.inject('chat');
this.inject('site');
this.inject('settings');
this.vue = this.resolve('vue');
this.settings.add('link-cards.enable', {
default: false,
ui: {
path: 'Chat > Link Cards >> General',
title: 'Enable Link Cards.',
description: 'When this is enabled and you click a link in chat or whispers, a popup will open with information about the link. This provides the same data as rich link tooltips, but in a form that allows more interaction.',
component: 'setting-check-box'
}
});
this.settings.add('link-cards.use-destination', {
default: false,
ui: {
path: 'Chat > Link Cards >> General',
title: 'Bypass Known Shorteners',
description: 'When clicking "Open Link" from a Link Card with this enabled, you will bypass known shorteners and tracking services and go directly to the destination URL.',
component: 'setting-check-box'
}
});
this.last_z = 9000;
this.open_cards = {};
this.last_card = null;
}
onEnable() {
this.on('chat:click-link', this.handleClick, this);
}
handleClick(evt) {
if ( ! this.settings.get('link-cards.enable') )
return;
evt.preventDefault();
this.openCard(evt.url, evt.source);
}
async loadVue() {
if ( this._vue_loaded )
return;
await this.vue.enable();
const card_component = await import(/* webpackChunkName: 'emote-cards' */ './components/card.vue');
this.vue.component('link-card', card_component.default);
this.vue.component('lc-url', {
functional: true,
props: ['url', 'show-protocol'],
render(createElement, context) {
let url = context.props.url;
if ( !(url instanceof URL) )
url = new URL(url);
const out = [];
if ( context.props.showProtocol )
out.push(createElement('span', {
class: 'tw-c-text-alt-2'
}, `${url.protocol}//`));
out.push(createElement('span', url.host));
let suffix = url.toString().slice(url.origin.length);
if ( suffix.length && suffix !== '/' )
out.push(createElement('span', {
class: 'tw-c-text-alt-2'
}, suffix));
return createElement('span', out);
}
});
this._vue_loaded = true;
}
async openCard(link, event) {
const card_key = `${link}`,
old_card = this.open_cards[card_key];
if ( old_card ) {
old_card.$el.style.zIndex = ++this.last_z;
old_card.focus();
return;
}
let pos_x = event ? event.clientX : window.innerWidth / 2,
pos_y = event ? event.clientY + 15 : window.innerHeight / 2;
/*if ( this.last_card ) {
const card = this.last_card;
if ( ! event ) {
pos_x = card.$el.offsetLeft;
pos_y = card.$el.offsetTop;
}
card.close();
}*/
// Start loading data. Don't await it yet, so we can
// wait for Vue at the same time.
const data = this.chat.get_link_info(link);
// Now load vue.
await this.loadVue();
// Display the card.
this.last_card = this.open_cards[card_key] = this.buildCard(
pos_x,
pos_y,
link,
data
);
}
buildCard(pos_x, pos_y, link, data) {
let child;
const component = new this.vue.Vue({
el: createElement('div'),
render: h => h('link-card', {
props: {
url: link,
data: data,
use_dest: this.settings.get('link-cards.use-destination'),
getFFZ: () => this,
getZ: () => ++this.last_z
},
on: {
emit: (event, ...data) => this.emit(event, ...data),
close: () => {
const el = component.$el;
el.remove();
component.$destroy();
if ( this.last_card === child )
this.last_card = null;
const card_key = link;
if ( this.open_cards[card_key] === child )
this.open_cards[card_key] = null;
this.emit('tooltips:cleanup');
},
pin: () => {
if ( this.last_card === child )
this.last_card = null;
}
}
})
});
child = component.$children[0];
const el = component.$el;
el.style.left = `${pos_x}px`;
el.style.top = `${pos_y}px`;
const container = document.querySelector(this.site.constructor.DIALOG_SELECTOR ?? '#root>div>.tw-full-height,.twilight-minimal-root>.tw-full-height');
container.appendChild(el);
requestAnimationFrame(() => child.constrain());
return child;
}
}

View file

@ -51,6 +51,7 @@
> >
<option <option
v-for="(r, key) in data.renderers" v-for="(r, key) in data.renderers"
v-if="supportsRenderer(key)"
:key="key" :key="key"
:value="key" :value="key"
> >
@ -330,7 +331,7 @@
</label> </label>
</div> </div>
<div class="tw-pd-r-1 ffz-checkbox"> <div v-if="has_hover_modifier" class="tw-pd-r-1 ffz-checkbox">
<input <input
:id="'key_hover$' + id" :id="'key_hover$' + id"
ref="key_hover" ref="key_hover"
@ -379,6 +380,7 @@
:value="edit_data.options" :value="edit_data.options"
:defaults="action_def.defaults" :defaults="action_def.defaults"
:vars="vars" :vars="vars"
:fmts="fmts"
@input="onChangeAction($event)" @input="onChangeAction($event)"
/> />
</section> </section>
@ -387,8 +389,8 @@
<div v-if="canPreview" class="tw-mg-l-1 tw-border-l tw-pd-l-1 tw-pd-y-05 tw-flex tw-flex-shrink-0 tw-align-items-start"> <div v-if="canPreview" class="tw-mg-l-1 tw-border-l tw-pd-l-1 tw-pd-y-05 tw-flex tw-flex-shrink-0 tw-align-items-start">
<action-preview <action-preview
:act="display" :act="maybeDynamic(display)"
:color="display.appearance && data.color(display.appearance.color)" :process-color="data.color"
:renderers="data.renderers" :renderers="data.renderers"
/> />
</div> </div>
@ -457,7 +459,7 @@ import {has, maybe_call, deep_copy} from 'utilities/object';
let id = 0; let id = 0;
export default { export default {
props: ['action', 'data', 'inline', 'mod_icons', 'context', 'modifiers'], props: ['vuectx', 'action', 'data', 'inline', 'mod_icons', 'context', 'modifiers', 'hover_modifier'],
data() { data() {
return { return {
@ -493,6 +495,25 @@ export default {
return this.modifiers return this.modifiers
}, },
has_hover_modifier() {
return this.hover_modifier !== false
},
fmts() {
const out = [];
out.push('word(start)');
out.push('word(start,end)');
out.push('upper');
out.push('lower');
out.push('snakecase');
out.push('slugify');
out.push('slugify(separator)');
out.push('urlencode');
return out.join(', ');
},
vars() { vars() {
const out = [], const out = [],
ctx = this.context || []; ctx = this.context || [];
@ -537,6 +558,9 @@ export default {
if ( this.action.t === 'inherit' ) if ( this.action.t === 'inherit' )
return this.t('setting.inheritance', 'Inheritance Point'); return this.t('setting.inheritance', 'Inheritance Point');
if ( this.action.t === 'skip' )
return this.t('setting.inheritance.skip', 'Not Inheriting');
else if ( ! this.display ) else if ( ! this.display )
return this.t('setting.unknown', 'Unknown Value'); return this.t('setting.unknown', 'Unknown Value');
@ -557,11 +581,15 @@ export default {
if ( def.title ) { if ( def.title ) {
const data = this.getData(), const data = this.getData(),
out = maybe_call(def.title, this, data, def), out = maybe_call(def.title, this, data, def);
i18n = def.title_i18n || `chat.actions.${this.display.action}`; let i18n = maybe_call(def.title_i18n, this, data, def);
if ( i18n === undefined )
i18n = `chat.actions.${this.display.action}`;
if ( out ) if ( out && i18n )
return this.t(i18n, out, data); return this.t(i18n, out, data);
else if ( out )
return out;
} }
return this.t('setting.actions.untitled', 'Action: {action}', this.display); return this.t('setting.actions.untitled', 'Action: {action}', this.display);
@ -570,7 +598,10 @@ export default {
description() { description() {
if ( this.action.t === 'inherit' ) if ( this.action.t === 'inherit' )
return this.t('setting.inheritance.desc', 'Inherit values from lower priority profiles at this position.'); return this.t('setting.inheritance.desc', 'Inherit values from lower priority profiles or the default values at this position.');
if ( this.action.t === 'skip' )
return this.t('setting.inheritance.skip.desc', 'This profile does not inherit values from lower priority profiles or the default values, despite having no values of its own.');
const type = this.display && this.display.type; const type = this.display && this.display.type;
@ -588,11 +619,15 @@ export default {
return null; return null;
const data = this.getData(), const data = this.getData(),
out = maybe_call(def.description, this, data, def), out = maybe_call(def.description, this, data, def);
i18n = def.description_i18n || `chat.actions.${this.display.action}.desc`; let i18n = maybe_call(def.description_i18n, this, data, def);
if ( i18n === undefined )
i18n = `chat.actions.${this.display.action}.desc`;
if ( out ) if ( out && i18n )
return this.t(i18n, out, data); return this.t(i18n, out, data);
else if ( out )
return out;
return null; return null;
}, },
@ -676,7 +711,7 @@ export default {
})); }));
} }
if ( disp.hover ) if ( this.has_hover_modifier && disp.hover )
out.push(this.t('setting.actions.visible.hover', 'when hovering')); out.push(this.t('setting.actions.visible.hover', 'when hovering'));
if ( ! out.length ) if ( ! out.length )
@ -712,8 +747,10 @@ export default {
if ( this.$refs.key_meta.checked ) if ( this.$refs.key_meta.checked )
i |= 8; i |= 8;
this.edit_data.display.hover = this.$refs.key_hover.checked;
this.edit_data.display.keys = i; this.edit_data.display.keys = i;
if ( this.has_hover_modifier )
this.edit_data.display.hover = this.$refs.key_hover.checked;
}, },
edit() { edit() {
@ -751,6 +788,29 @@ export default {
this.edit_data = null; this.edit_data = null;
}, },
maybeDynamic(data) {
let ap = data.appearance;
if (ap?.type === 'dynamic') {
const act = this.action_def,
ffz = this.vuectx.getFFZ(),
actions = ffz && ffz.resolve('chat.actions');
const out = actions && act?.dynamicAppearance && act.dynamicAppearance
.call(actions, deep_copy(ap), data, null, null, null, null);
if ( out )
return Object.assign({}, data, {appearance: out});
}
return data;
},
supportsRenderer(key) {
if (key !== 'dynamic')
return true;
return this.action_def?.supports_dynamic;
},
getData() { getData() {
const def = this.display && this.data.actions[this.display.action]; const def = this.display && this.data.actions[this.display.action];
if ( ! def ) if ( ! def )

View file

@ -19,9 +19,13 @@
<script> <script>
export default { export default {
props: ['act', 'color', 'tooltip', 'pad', 'renderers'], props: ['act', 'process-color', 'tooltip', 'pad', 'renderers'],
computed: { computed: {
color() {
return this['processColor'](this.act.appearance.color);
},
renderer() { renderer() {
return this.renderers[this.act.appearance.type] return this.renderers[this.act.appearance.type]
} }

View file

@ -63,6 +63,16 @@
/> />
</div> </div>
<div v-if="ready && visible_addons.length !== listed_addons.length" class="tw-align-center tw-pd-1">
{{ t('addon.displaying', 'Displaying {visible, number} of {total, plural, one {# add-on} other {# add-ons} }.', {
visible: visible_addons.length,
total: listed_addons.length
}) }}
<template v-if="filter && filter.length">
{{ t('addon.displaying.filtered', 'The visible add-ons are being filtered by your search. Clear it to view all available add-ons.') }}
</template>
</div>
<div v-if="visible_unlisted" class="tw-flex tw-align-items-center"> <div v-if="visible_unlisted" class="tw-flex tw-align-items-center">
<div class="tw-flex-grow-1" /> <div class="tw-flex-grow-1" />
<div <div
@ -136,6 +146,10 @@ export default {
return this.sorted_addons.filter(addon => this.shouldShow(addon)); return this.sorted_addons.filter(addon => this.shouldShow(addon));
}, },
listed_addons() {
return this.sorted_addons.filter(addon => ! addon.unlisted)
},
sorted_addons() { sorted_addons() {
const addons = this.item.getAddons(); const addons = this.item.getAddons();

View file

@ -5,6 +5,10 @@
<img :src="icon" class="tw-image"> <img :src="icon" class="tw-image">
</div> </div>
<div v-if="reloading" class="tw-mg-b-05 ffz-pill">
{{ t('addon.reloading', 'Reloading') }}
</div>
<div v-if="external" class="tw-mg-b-05 ffz-pill"> <div v-if="external" class="tw-mg-b-05 ffz-pill">
{{ t('addon.external', 'External') }} {{ t('addon.external', 'External') }}
</div> </div>
@ -96,6 +100,20 @@
{{ t('addon.disable', 'Disable') }} {{ t('addon.disable', 'Disable') }}
</span> </span>
</button> </button>
<button
v-if="addon.dev && can_reload"
class="tw-button ffz-button--hollow tw-mg-r-1"
:class="{'tw-button--disabled': reloading}"
:disabled="reloading"
@click="reloadAddon()"
>
<span class="tw-button__icon tw-button__icon--left">
<figure class="ffz-i-arrows-cw" />
</span>
<span class="tw-button__text">
{{ t('addon.reload', 'Reload') }}
</span>
</button>
<button <button
v-if="addon.settings" v-if="addon.settings"
class="tw-button ffz-button--hollow tw-mg-r-1" class="tw-button ffz-button--hollow tw-mg-r-1"
@ -151,6 +169,8 @@ export default {
data() { data() {
return { return {
enabled: this.item.isAddonEnabled(this.id), enabled: this.item.isAddonEnabled(this.id),
can_reload: this.addon.dev && this.item.canReloadAddon(this.id),
reloading: false,
external: this.item.isAddonExternal(this.id), external: this.item.isAddonExternal(this.id),
version: this.item.getVersion(this.id), version: this.item.getVersion(this.id),
expanded: false expanded: false
@ -251,6 +271,19 @@ export default {
list.push(`add_ons.${this.addon.name.toSnakeCase()}`); list.push(`add_ons.${this.addon.name.toSnakeCase()}`);
this.$emit('navigate', ...list); this.$emit('navigate', ...list);
},
reloadAddon() {
this.reloading = true;
this.item.reloadAddon(this.id)
.then(() => {
this.reloading = false;
this.can_reload = this.item.canReloadAddon(this.id);
})
.catch(err => {
console.error(err);
this.reloading = false;
});
} }
} }
} }

View file

@ -207,8 +207,8 @@
<action-preview <action-preview
v-else v-else
:key="act.id" :key="act.id"
:act="act.v" :act="maybeDynamic(act.v)"
:color="color(act.v.appearance.color)" :process-color="color"
:renderers="data.renderers" :renderers="data.renderers"
tooltip="true" tooltip="true"
pad="true" pad="true"
@ -291,7 +291,12 @@
<div class="tw-flex-grow-1 tw-mg-r-1"> <div class="tw-flex-grow-1 tw-mg-r-1">
{{ preset.title_i18n ? t(preset.title_i18n, preset.title, preset) : preset.title }} {{ preset.title_i18n ? t(preset.title_i18n, preset.title, preset) : preset.title }}
</div> </div>
<action-preview v-if="preset.appearance" :act="preset" :renderers="data.renderers" /> <action-preview
v-if="preset.appearance"
:act="maybeDynamic(preset)"
:process-color="color"
:renderers="data.renderers"
/>
</div> </div>
</button> </button>
</template> </template>
@ -300,7 +305,7 @@
</balloon> </balloon>
</div> </div>
<button <button
v-if="! maybe_clear && val.length" v-if="! maybe_clear && strip_skip_val.length"
class="tw-mg-l-1 tw-button tw-button--text ffz-il-tooltip__container" class="tw-mg-l-1 tw-button tw-button--text ffz-il-tooltip__container"
@click="maybe_clear = true" @click="maybe_clear = true"
> >
@ -333,7 +338,7 @@
</span> </span>
</button> </button>
<button <button
v-if="! val.length && has_default" v-if="! strip_skip_val.length && has_default"
class="tw-mg-l-1 tw-button tw-button--text ffz-il-tooltip__container" class="tw-mg-l-1 tw-button tw-button--text ffz-il-tooltip__container"
@click="populate" @click="populate"
> >
@ -349,6 +354,20 @@
<div ref="list" class="ffz--action-list"> <div ref="list" class="ffz--action-list">
<div v-if="! val.length" class="tw-c-text-alt-2 tw-font-size-4 tw-align-center tw-c-text-alt-2 tw-pd-1"> <div v-if="! val.length" class="tw-c-text-alt-2 tw-font-size-4 tw-align-center tw-c-text-alt-2 tw-pd-1">
{{ t('setting.actions.no-actions', 'no actions are defined in this profile') }} {{ t('setting.actions.no-actions', 'no actions are defined in this profile') }}
<div class="tw-mg-t-1">
<button
class="tw-button tw-button--text ffz-il-tooltip__container"
@click="addSkip"
>
<span class="tw-button__text ffz-i-block">
{{ t('setting.actions.do-not-inherit', 'Do Not Inherit') }}
</span>
<span class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-center">
{{ t('setting.actions.do-not-inherit.desc', 'Do not inherit values from lower priority profiles or the defaults.') }}
</span>
</button>
</div>
</div> </div>
<section v-for="act in val" :key="act.id"> <section v-for="act in val" :key="act.id">
<action-editor <action-editor
@ -357,7 +376,9 @@
:inline="item.inline" :inline="item.inline"
:mod_icons="has_icons" :mod_icons="has_icons"
:context="item.context" :context="item.context"
:vuectx="context"
:modifiers="item.modifiers" :modifiers="item.modifiers"
:hover_modifier="item.hover_modifier"
@remove="remove(act)" @remove="remove(act)"
@save="save(act, $event)" @save="save(act, $event)"
/> />
@ -409,6 +430,14 @@ export default {
return false; return false;
}, },
hasSkip() {
for(const val of this.val)
if ( val.t === 'skip' )
return true;
return false;
},
sample_user() { sample_user() {
return this.has_user ? { return this.has_user ? {
displayName: 'SirStendec', displayName: 'SirStendec',
@ -560,6 +589,10 @@ export default {
return out; return out;
}, },
strip_skip_val() {
return this.val.filter(x => x.t !== 'skip');
},
val() { val() {
if ( ! this.has_value ) if ( ! this.has_value )
return []; return [];
@ -657,8 +690,28 @@ export default {
this.set(deep_copy(this.default_value)); this.set(deep_copy(this.default_value));
}, },
addSkip() {
const vals = Array.from(this.val);
if(vals.length > 0)
return;
vals.push({
t: 'skip'
});
this.set(deep_copy(vals));
},
add(val) { add(val) {
const vals = Array.from(this.val); const vals = Array.from(this.val);
// Remove any skip entry.
let i = vals.length;
while(i--) {
if (vals[i]?.t === 'skip')
vals.splice(i, 1);
}
vals.push(val); vals.push(val);
this.set(deep_copy(vals)); this.set(deep_copy(vals));
this.add_open = false; this.add_open = false;
@ -739,6 +792,22 @@ export default {
return true; return true;
}, },
maybeDynamic(data) {
let ap = data.appearance;
if (ap?.type === 'dynamic') {
const act = this.data.actions[data.action],
ffz = this.context.getFFZ(),
actions = ffz && ffz.resolve('chat.actions');
const out = actions && act?.dynamicAppearance && act.dynamicAppearance
.call(actions, deep_copy(ap), data, this.sample_message, this.sample_room, this.sample_user, this.with_mod_icons);
if ( out )
return Object.assign({}, data, {appearance: out});
}
return data;
},
color(input) { color(input) {
if ( ! input ) if ( ! input )
return input; return input;

View file

@ -0,0 +1,734 @@
<template>
<div class="ffz--chat-tester">
<div v-if="context.exclusive" class="tw-c-background-accent tw-c-text-overlay tw-pd-1 tw-mg-b-2">
<h3 class="ffz-i-attention">
{{ t('debug.chat-tester.exclusive', "Hey! This won't work here!") }}
</h3>
<markdown :source="t('debug.chat-tester.exclusive-explain', 'This feature does not work when the FFZ Control Center is popped out. It needs to be used in a window where you can see chat.')" />
</div>
<div class="tw-flex tw-align-items-start">
<label for="selector" class="tw-mg-y-05">
{{ t('debug.chat-tester.message', 'Test Message') }}
</label>
<div class="tw-flex tw-flex-column tw-mg-05 tw-full-width">
<select
id="selector"
ref="selector"
class="tw-full-width tw-border-top-left-radius-medium tw-border-top-right-radius-medium tw-font-size-6 ffz-select tw-pd-l-1 tw-pd-r-3 tw-pd-y-05"
@change="onSelectChange"
>
<option :selected="is_custom" value="custom">
{{ t('setting.combo-box.custom', 'Custom') }}
</option>
<option
v-for="(sample, idx) in samples"
:key="idx"
:selected="sample.data === message && sample.topic === topic"
:value="idx"
>
{{ sample.name }}
</option>
</select>
<input
ref="topic"
class="tw-block tw-font-size-6 tw-full-width ffz-textarea ffz-mg-t-1p"
@blur="updateMessage"
@input="onMessageChange"
/>
<textarea
ref="message"
class="tw-block tw-font-size-6 tw-full-width ffz-textarea ffz-mg-t-1p tw-border-bottom-left-radius-medium tw-border-bottom-right-radius-medium"
rows="10"
@blur="updateMessage"
@input="onMessageChange"
/>
</div>
</div>
<div class="tw-mg-t-1 tw-flex tw-align-items-center">
<div class="tw-flex-grow-1" />
<div class="tw-pd-x-1 ffz-checkbox">
<input
id="replay_fix"
ref="replay_fix"
:checked="replay_fix"
type="checkbox"
class="ffz-checkbox__input"
@change="onCheck"
>
<label for="replay_fix" class="ffz-checkbox__label">
<span class="tw-mg-l-1">
{{ t('debug.chat-tester.replay-fix', 'Fix ID and Channel') }}
</span>
</label>
</div>
<button
class="tw-mg-l-1 tw-button tw-button--text"
@click="playMessage"
>
<span class="tw-button__text ffz-i-play">
{{ t('debug.chat-tester.play', 'Play Message') }}
</span>
</button>
</div>
<div class="tw-pd-t-1 tw-border-t tw-mg-t-1 tw-flex tw-mg-b-1 tw-align-items-center">
<div class="tw-flex-grow-1" />
<div class="tw-pd-x-1 ffz-checkbox">
<input
id="capture_chat"
ref="capture_chat"
:checked="capture_chat"
type="checkbox"
class="ffz-checkbox__input"
@change="onCheck"
>
<label for="capture_chat" class="ffz-checkbox__label">
<span class="tw-mg-l-1">
{{ t('debug.chat-tester.capture-chat', 'Capture Chat') }}
</span>
</label>
</div>
<div class="tw-pd-x-1 ffz-checkbox">
<input
id="ignore_privmsg"
ref="ignore_privmsg"
:checked="ignore_privmsg"
type="checkbox"
class="ffz-checkbox__input"
@change="onCheck"
>
<label for="ignore_privmsg" class="ffz-checkbox__label">
<span class="tw-mg-l-1">
{{ t('debug.chat-tester.ignore-privmsg', 'Ignore PRIVMSG') }}
</span>
</label>
</div>
<div class="tw-pd-x-1 ffz-checkbox">
<input
id="capture_pubsub"
ref="capture_pubsub"
:checked="capture_pubsub"
type="checkbox"
class="ffz-checkbox__input"
@change="onCheck"
>
<label for="capture_pubsub" class="ffz-checkbox__label">
<span class="tw-mg-l-1">
{{ t('debug.chat-tester.capture-pubsub', 'Capture PubSub') }}
</span>
</label>
</div>
<button
class="tw-mg-l-1 tw-button tw-button--text"
@click="clearLog"
>
<span class="tw-button__text ffz-i-trash">
{{ t('debug.chat-tester.clear-log', 'Clear Log') }}
</span>
</button>
</div>
<div
v-for="item in log"
:key="item._id"
class="tw-elevation-1 tw-border tw-pd-y-05 tw-pd-r-1 tw-mg-y-05 tw-flex tw-flex-nowrap tw-align-items-center"
:class="{'tw-c-background-base': item.pubsub, 'tw-c-background-alt-2': !item.pubsub}"
>
<time class="tw-mg-l-05 tw-mg-r-1 tw-flex-shrink-0">
{{ tTime(item.timestamp, 'HH:mm:ss') }}
</time>
<div v-if="item.pubsub" class="tw-flex-grow-1">
<div class="tw-mg-b-05 tw-border-b tw-pd-b-05">{{ item.topic }}</div>
<div v-html="highlightJson(item.data)" />
</div>
<div v-else-if="item.chat" class="tw-flex-grow-1">
<div v-if="item.tags" class="ffz-ct--tags">
@<template v-for="(tag, key) in item.tags"><span class="ffz-ct--tag">{{ key }}</span>=<span class="ffz-ct--tag-value">{{ tag }}</span>;</template>
</div>
<div class="ffz-ct--prefix">
<template v-if="item.prefix">:<span v-if="item.user" class="ffz-ct--user">{{ item.user }}</span><span class="ffz-ct--prefix">{{ item.prefix }}</span></template>
<span class="ffz-ct--command">{{ item.command }}</span>
<template v-if="item.channel">#<span class="ffz-ct--channel">{{ item.channel }}</span></template>
</div>
<div v-if="item.last_param" class="ffz-ct--params">
<span v-for="para in item.params" class="ffz-ct--param">{{ para }}</span>
:<span class="ffz-ct--param">{{ item.last_param }}</span>
</div>
</div>
<div v-else class="tw-flex-grow-1">
{{ item.data }}
</div>
<div class="tw-mg-l-1 tw-flex tw-flex-wrap tw-flex-column tw-justify-content-start tw-align-items-start">
<button
v-if="item.chat || item.pubsub"
class="tw-button tw-button--text"
@click="replayItem(item)"
>
<span class="tw-button__text ffz-i-arrows-cw">
{{ t('debug.chat-tester.replay', 'Replay') }}
</span>
</button>
<button
class="tw-button tw-button--text"
@click="copyItem(item)"
>
<span class="tw-button__text ffz-i-docs">
{{ t('setting.copy-json', 'Copy') }}
</span>
</button>
</div>
</div>
</div>
</template>
<script>
import { DEBUG, SERVER } from 'utilities/constants';
import { highlightJson } from 'utilities/dom';
import { deep_copy, generateUUID } from 'utilities/object';
import { getBuster } from 'utilities/time';
import SAMPLES from '../sample-chat-messages.json'; // eslint-disable-line no-unused-vars
const IGNORE_COMMANDS = [
'PONG',
'PING',
'366',
'353'
];
let LOADED_SAMPLES = [
{
"name": "Ping",
"data": "PING :tmi.twitch.tv"
}
];
let has_loaded_samples = false;
export default {
props: ['item', 'context'],
data() {
const state = window.history.state;
const samples = deep_copy(LOADED_SAMPLES);
const message = state?.ffz_ct_message ?? samples[0].data;
const topic = state?.ffz_ct_topic ?? samples[0].topic ?? '';
let is_custom = true;
/*for(const item of samples) {
if ( ! item.topic )
item.topic = '';
if ( typeof item.data !== 'string' )
item.data = JSON.stringify(item.data, null, 4);
if (item.data === message && item.topic === topic) {
is_custom = false;
break;
}
}*/
return {
has_client: false,
samples,
is_custom,
message,
topic,
replay_fix: state?.ffz_ct_replay ?? true,
ignore_privmsg: state?.ffz_ct_privmsg ?? false,
capture_chat: state?.ffz_ct_chat ?? false,
capture_pubsub: state?.ffz_ct_pubsub ?? false,
log: [],
logi: 0
}
},
watch: {
message() {
if ( ! this.is_custom )
this.$refs.message.value = this.message;
},
topic() {
if ( ! this.is_custom )
this.$refs.topic.value = this.topic;
},
capture_chat() {
if ( this.capture_chat )
this.listenChat();
else
this.unlistenChat();
},
capture_pubsub() {
if ( this.capture_pubsub )
this.listenPubsub();
else
this.unlistenPubsub();
}
},
created() {
this.loadSamples();
this.chat = this.item.getChat();
this.client = this.chat.ChatService.first?.client;
this.has_client = !!this.client;
if ( this.capture_chat )
this.listenChat();
if ( this.capture_pubsub )
this.listenPubsub();
},
beforeDestroy() {
this.unlistenChat();
this.unlistenPubsub();
this.client = null;
this.chat = null;
},
mounted() {
this.$refs.message.value = this.message;
this.$refs.topic.value = this.topic;
},
methods: {
highlightJson(object, pretty) {
return highlightJson(object, pretty);
},
// Samples
async loadSamples() {
if ( has_loaded_samples )
return;
const values = await fetch(DEBUG ? SAMPLES : `${SERVER}/script/sample-chat-messages.json?_=${getBuster()}`).then(r => r.ok ? r.json() : null);
if ( Array.isArray(values) && values.length > 0 ) {
has_loaded_samples = true;
for(const item of values) {
if ( ! item.topic )
item.topic = '';
if ( Array.isArray(item.data) )
item.data = item.data.join('\n\n');
else if ( typeof item.data !== 'string' )
item.data = JSON.stringify(item.data, null, 4);
}
LOADED_SAMPLES = values;
this.samples = deep_copy(values);
let is_custom = true;
for(const item of this.samples) {
if (item.data === this.message && item.topic === this.topic) {
is_custom = false;
break;
}
}
this.is_custom = is_custom;
}
},
// Chat
listenChat() {
if ( this.listening_chat )
return;
// Ensure we have the chat client.
if ( ! this.has_client ) {
this.client = this.chat.ChatService.first?.client;
this.has_client = !!this.client;
if ( ! this.has_client )
return;
}
// Hook into the connection.
const conn = this.client.connection;
if ( ! conn.ffzOnSocketMessage )
conn.ffzOnSocketMessage = conn.onSocketMessage;
conn.onSocketMessage = event => {
try {
this.handleChat(event);
} catch(err) {
/* no-op */
}
return conn.ffzOnSocketMessage(event);
}
if ( conn.ws )
conn.ws.onmessage = conn.onSocketMessage;
this.addLog("Started capturing chat.");
this.listening_chat = true;
},
unlistenChat() {
if ( ! this.listening_chat )
return;
const conn = this.client.connection;
conn.onSocketMessage = conn.ffzOnSocketMessage;
if ( conn.ws )
conn.ws.onmessage = conn.onSocketMessage;
this.addLog("Stopped capturing chat.");
this.listening_chat = false;
},
handleChat(event) {
for(const raw of event.data.split(/\r?\n/g)) {
const msg = this.parseChat(raw);
if ( msg ) {
if ( this.ignore_privmsg && msg.command === 'PRIVMSG' )
continue;
if ( IGNORE_COMMANDS.includes(msg.command) )
continue;
this.addLog(msg);
}
}
},
parseChat(raw) {
const msg = this.client.parser.msg(raw);
msg.chat = true;
if ( Object.keys(msg.tags).length === 0 )
msg.tags = null;
if ( msg.params.length > 0 && msg.params[0].startsWith('#') )
msg.channel = msg.params.shift().slice(1);
if ( msg.params.length > 0 )
msg.last_param = msg.params.pop();
const idx = msg.prefix ? msg.prefix.indexOf('!') : -1;
if ( idx === -1 )
msg.user = null;
else {
msg.user = msg.prefix.substr(0, idx);
msg.prefix = msg.prefix.substr(idx);
}
return msg;
},
// Pubsub
listenPubsub() {
if ( this.listening_pubsub )
return;
this.chat.on('site.subpump:pubsub-message', this.handlePubsub, this);
this.addLog("Started capturing PubSub.");
this.listening_pubsub = true;
},
unlistenPubsub() {
if ( ! this.listening_pubsub )
return;
this.chat.off('site.subpump:pubsub-message', this.handlePubsub, this);
this.addLog("Stopped capturing PubSub.");
this.listening_pubsub = false;
},
handlePubsub(event) {
if ( event.prefix === 'video-playback-by-id' )
return;
this.addLog({
pubsub: true,
topic: event.topic,
data: deep_copy(event.message)
});
},
// State
saveState() {
try {
window.history.replaceState({
...window.history.state,
ffz_ct_replay: this.replay_fix,
ffz_ct_message: this.message,
ffz_ct_chat: this.capture_chat,
ffz_ct_pubsub: this.capture_pubsub,
ffz_ct_privmsg: this.ignore_privmsg
}, document.title);
} catch(err) {
/* no-op */
}
},
// Event Handlers
onSelectChange() {
const idx = this.$refs.selector.value,
item = this.samples[idx];
if ( idx !== 'custom' && item?.data ) {
this.message = item.data;
this.topic = item.topic ?? '';
this.is_custom = false;
} else
this.is_custom = true;
},
updateMessage() {
const value = this.$refs.message.value,
topic = this.$refs.topic.value;
let is_custom = true;
for(const item of this.samples) {
if (item.data === value && item.topic === topic) {
is_custom = false;
break;
}
}
this.is_custom = is_custom;
if ( this.is_custom ) {
this.topic = topic;
this.message = value;
}
},
onMessageChange() {
this.updateMessage();
},
onCheck() {
this.replay_fix = this.$refs.replay_fix.checked;
this.capture_chat = this.$refs.capture_chat.checked;
this.capture_pubsub = this.$refs.capture_pubsub.checked;
this.ignore_privmsg = this.$refs.ignore_privmsg.checked;
this.saveState();
},
// Log
addLog(msg) {
if ( typeof msg !== 'object' )
msg = {
data: msg
};
msg.timestamp = Date.now();
msg._id = this.logi++;
this.log.unshift(msg);
const extra = this.log.length - 100;
if ( extra > 0 )
this.log.splice(100, extra);
},
clearLog() {
this.log = [];
this.addLog('Cleared log.');
},
// Item Actions
copyItem(item) {
let value;
if ( item.raw )
value = item.raw;
else if ( item.data )
value = item.data;
else
value = item;
if ( typeof value !== 'string' )
value = JSON.stringify(value);
navigator.clipboard.writeText(value);
},
playMessage() {
// Check for PubSub
if ( this.topic.trim().length > 0 ) {
let data;
try {
data = JSON.parse(this.message);
} catch(err) {
console.error(err);
alert("Unable to parse message.");
return;
}
this.replayItem({
pubsub: true,
topic: this.topic,
data
});
return;
}
const msgs = [];
const parts = this.message.split(/\r?\n/g);
for(const part of parts) {
try {
if ( part && part.length > 0 )
msgs.push(this.parseChat(part));
} catch (err) {
console.error(err);
alert("Unable to parse message.");
return;
}
}
for(const msg of msgs)
this.replayItem(msg);
},
replayItem(item) {
if ( item.pubsub ) {
const channel = this.chat.ChatService.first?.props?.channelID,
user = this.chat.resolve('site').getUser();
if ( this.replay_fix ) {
item.topic = item.topic.replace(/<channel>/gi, channel);
item.topic = item.topic.replace(/<user>/gi, user.id);
// TODO: Crawl, replacing ids.
// TODO: Update timestamps for pinned chat?
}
this.chat.resolve('site.subpump').inject(item.topic, item.data);
}
if ( item.chat ) {
// While building the string, also build colors for the console log.
const out = [];
const colors = [];
if ( item.tags ) {
out.push('@');
colors.push('gray');
for(const [key, val] of Object.entries(item.tags)) {
let v = val;
// If the tag is "id", return a new id so the message
// won't be deduplicated automatically.
if ( key === 'id' && this.replay_fix )
v = generateUUID();
out.push(key);
out.push('=');
out.push(`${v}`);
out.push(';');
colors.push('orange');
colors.push('gray');
colors.push('white');
colors.push('gray');
}
}
if ( item.user || item.prefix ) {
if ( out.length ) {
out.push(' ');
colors.push('');
}
out.push(':');
colors.push('gray');
if (item.user) {
out.push(item.user);
colors.push('green');
}
if (item.prefix) {
out.push(item.prefix);
colors.push('gray');
}
}
if ( out.length ) {
out.push(' ');
colors.push('');
}
out.push(item.command);
colors.push('orange');
// If there's a channel, use the current channel rather
// than the logged channel.
if ( item.channel ) {
out.push(` #`);
colors.push('gray');
out.push(this.replay_fix ? this.chat.ChatService.first?.props?.channelLogin ?? item.channel : item.channel);
colors.push('green');
}
for(const para of item.params) {
out.push(` ${para}`);
colors.push('skyblue');
}
if ( item.last_param ) {
out.push(` :`);
colors.push('gray');
out.push(item.last_param);
colors.push('skyblue');
}
const msg = out.join(''),
conn = this.client.connection,
handler = conn.ffzOnSocketMessage ?? conn.onSocketMessage;
const log_msg = out.join('%c'),
log_colors = colors.map(x => x?.length ? `color: ${x};` : '');
this.chat.log.debugColor(`Injecting chat message: %c${log_msg}`, log_colors);
handler.call(conn, {
data: msg
});
}
}
}
}
</script>

View file

@ -4,6 +4,117 @@
{{ t('setting.experiments.about', 'This feature allows you to override experiment values. Please note that, for most experiments, you may have to refresh the page for your changes to take effect.') }} {{ t('setting.experiments.about', 'This feature allows you to override experiment values. Please note that, for most experiments, you may have to refresh the page for your changes to take effect.') }}
</div> </div>
<div class="tw-mg-b-2 tw-flex tw-align-items-center">
<div class="tw-flex-grow-1">
{{ t('setting.experiments.unique-id', 'Unique ID: {id}', {id: unique_id}) }}
</div>
<select
ref="sort_select"
class="tw-border-radius-medium tw-font-size-6 ffz-select tw-pd-l-1 tw-pd-r-3 tw-pd-y-05 tw-mg-x-05"
@change="onSort"
>
<option :selected="sort_by === 0">
{{ t('setting.experiments.sort-name', 'Sort By: Name') }}
</option>
<option :selected="sort_by === 1">
{{ t('setting.experiments.sort-rarity', 'Sort By: Rarity') }}
</option>
</select>
</div>
<div class="tw-mg-b-2 tw-flex tw-align-items-center">
<div class="tw-flex-grow-1" />
<div class="ffz-checkbox tw-relative">
<input
id="unused"
ref="unused"
v-model="unused"
type="checkbox"
class="ffz-checkbox__input"
>
<label for="unused" class="ffz-checkbox__label">
<span class="tw-mg-l-1">
{{ t('setting.experiments.show-unused', 'Display unused experiments.') }}
</span>
</label>
</div>
</div>
<h3 class="tw-mg-b-1">
<span>
{{ t('setting.experiments.ffz', 'FrankerFaceZ Experiments') }}
</span>
<span v-if="filter" class="tw-mg-l-1 tw-font-size-base tw-regular tw-c-text-alt-2">
{{ t('setting.experiments.visible', '(Showing {visible,number} of {total,number})', {
visible: visible_ffz.length,
total: sorted_ffz.length
}) }}
</span>
</h3>
<div class="ffz--experiment-list">
<section
v-for="({key, exp}) of visible_ffz"
:key="key"
:data-key="key"
>
<div class="tw-elevation-1 tw-c-background-base tw-border tw-pd-y-05 tw-pd-x-1 tw-mg-y-05 tw-flex tw-flex-nowrap">
<div class="tw-flex-grow-1">
<h4>{{ exp.name }}</h4>
<div v-if="exp.description" class="description">
{{ exp.description }}
</div>
</div>
<div class="tw-flex tw-flex-shrink-0 tw-align-items-start">
<select
:data-key="key"
class="tw-border-radius-medium tw-font-size-6 ffz-select tw-pd-l-1 tw-pd-r-3 tw-pd-y-05 tw-mg-x-05"
@change="onChange($event)"
>
<option
v-for="(i, idx) in exp.groups"
:key="idx"
:selected="i.value === exp.value"
>
{{ t('setting.experiments.entry', '{value,tostring} (weight: {weight,tostring})', i) }}
</option>
</select>
<button
:disabled="exp.default"
:class="{'tw-button--disabled': exp.default}"
class="tw-mg-t-05 tw-button tw-button--text ffz-il-tooltip__container"
@click="reset(key)"
>
<span class="tw-button__text ffz-i-cancel" />
<span class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ t('setting.reset', 'Reset to Default') }}
</span>
</button>
</div>
</div>
</section>
<div v-if="! Object.keys(ffz_data).length">
{{ t('setting.experiments.none', 'There are no current experiments.') }}
</div>
<div v-else-if="! visible_ffz.length">
{{ t('setting.experiments.none-filter', 'There are no matching experiments.') }}
</div>
</div>
<h3 class="tw-mg-t-5 tw-mg-b-1">
<span>
{{ t('setting.experiments.twitch', 'Twitch Experiments') }}
</span>
<span v-if="experiments_locked && filter" class="tw-mg-l-1 tw-font-size-base tw-regular tw-c-text-alt-2">
{{ t('setting.experiments.visible', '(Showing {visible,number} of {total,number})', {
visible: visible_twitch.length,
total: sorted_twitch.length
}) }}
</span>
</h3>
<section v-if="experiments_locked"> <section v-if="experiments_locked">
<div class="tw-c-background-accent tw-c-text-overlay tw-pd-1 tw-mg-b-2"> <div class="tw-c-background-accent tw-c-text-overlay tw-pd-1 tw-mg-b-2">
<h3 class="ffz-i-attention"> <h3 class="ffz-i-attention">
@ -16,7 +127,7 @@
<input <input
ref="code" ref="code"
type="text" type="text"
class="tw-block tw-full-width tw-border-radius-medium tw-font-size-6 tw-full-width ffz-input tw-pd-x-1 tw-pd-y-05" class="tw-block tw-full-width tw-border-radius-medium tw-font-size-6 tw-full-width ffz-input tw-pd-x-1 tw-pd-y-05 tw-mg-b-5"
autocapitalize="off" autocapitalize="off"
autocorrect="off" autocorrect="off"
@keydown.enter="enterCode" @keydown.enter="enterCode"
@ -25,117 +136,6 @@
</section> </section>
<section v-else> <section v-else>
<div class="tw-mg-b-2 tw-flex tw-align-items-center">
<div class="tw-flex-grow-1">
{{ t('setting.experiments.unique-id', 'Unique ID: {id}', {id: unique_id}) }}
</div>
<select
ref="sort_select"
class="tw-border-radius-medium tw-font-size-6 ffz-select tw-pd-l-1 tw-pd-r-3 tw-pd-y-05 tw-mg-x-05"
@change="onSort"
>
<option :selected="sort_by === 0">
{{ t('setting.experiments.sort-name', 'Sort By: Name') }}
</option>
<option :selected="sort_by === 1">
{{ t('setting.experiments.sort-rarity', 'Sort By: Rarity') }}
</option>
</select>
</div>
<div class="tw-mg-b-2 tw-flex tw-align-items-center">
<div class="tw-flex-grow-1" />
<div class="ffz-checkbox tw-relative">
<input
id="unused"
ref="unused"
v-model="unused"
type="checkbox"
class="ffz-checkbox__input"
>
<label for="unused" class="ffz-checkbox__label">
<span class="tw-mg-l-1">
{{ t('setting.experiments.show-unused', 'Display unused experiments.') }}
</span>
</label>
</div>
</div>
<h3 class="tw-mg-b-1">
<span>
{{ t('setting.experiments.ffz', 'FrankerFaceZ Experiments') }}
</span>
<span v-if="filter" class="tw-mg-l-1 tw-font-size-base tw-regular tw-c-text-alt-2">
{{ t('setting.experiments.visible', '(Showing {visible,number} of {total,number})', {
visible: visible_ffz.length,
total: sorted_ffz.length
}) }}
</span>
</h3>
<div class="ffz--experiment-list">
<section
v-for="({key, exp}) of visible_ffz"
:key="key"
:data-key="key"
>
<div class="tw-elevation-1 tw-c-background-base tw-border tw-pd-y-05 tw-pd-x-1 tw-mg-y-05 tw-flex tw-flex-nowrap">
<div class="tw-flex-grow-1">
<h4>{{ exp.name }}</h4>
<div v-if="exp.description" class="description">
{{ exp.description }}
</div>
</div>
<div class="tw-flex tw-flex-shrink-0 tw-align-items-start">
<select
:data-key="key"
class="tw-border-radius-medium tw-font-size-6 ffz-select tw-pd-l-1 tw-pd-r-3 tw-pd-y-05 tw-mg-x-05"
@change="onChange($event)"
>
<option
v-for="(i, idx) in exp.groups"
:key="idx"
:selected="i.value === exp.value"
>
{{ t('setting.experiments.entry', '{value,tostring} (weight: {weight,tostring})', i) }}
</option>
</select>
<button
:disabled="exp.default"
:class="{'tw-button--disabled': exp.default}"
class="tw-mg-t-05 tw-button tw-button--text ffz-il-tooltip__container"
@click="reset(key)"
>
<span class="tw-button__text ffz-i-cancel" />
<span class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ t('setting.reset', 'Reset to Default') }}
</span>
</button>
</div>
</div>
</section>
<div v-if="! Object.keys(ffz_data).length">
{{ t('setting.experiments.none', 'There are no current experiments.') }}
</div>
<div v-else-if="! visible_ffz.length">
{{ t('setting.experiments.none-filter', 'There are no matching experiments.') }}
</div>
</div>
<h3 class="tw-mg-t-5 tw-mg-b-1">
<span>
{{ t('setting.experiments.twitch', 'Twitch Experiments') }}
</span>
<span v-if="filter" class="tw-mg-l-1 tw-font-size-base tw-regular tw-c-text-alt-2">
{{ t('setting.experiments.visible', '(Showing {visible,number} of {total,number})', {
visible: visible_twitch.length,
total: sorted_twitch.length
}) }}
</span>
</h3>
<div class="ffz--experiment-list"> <div class="ffz--experiment-list">
<section <section
v-for="({key, exp}) of visible_twitch" v-for="({key, exp}) of visible_twitch"

View file

@ -9,7 +9,9 @@
v-for="rule in editing" v-for="rule in editing"
:key="rule.id" :key="rule.id"
:value="rule" :value="rule"
:disabled="disabled"
:filters="filters" :filters="filters"
:preview="preview"
:context="context" :context="context"
:data-id="rule.id" :data-id="rule.id"
@input="updateRule(rule.id, $event)" @input="updateRule(rule.id, $event)"
@ -60,11 +62,20 @@ export default {
props: { props: {
value: Array, value: Array,
filters: Object, filters: Object,
disabled: {
type: Boolean,
default: false
},
maxRules: { maxRules: {
tpye: Number, type: Number,
required: false, required: false,
default: 0 default: 0
}, },
preview: {
type: Boolean,
required: false,
default: true
},
context: { context: {
type: Object, type: Object,
required: false required: false
@ -74,20 +85,30 @@ export default {
data() { data() {
return { return {
adding: false, adding: false,
resetting: false,
editing: this.copyValue() editing: this.copyValue()
} }
}, },
computed: { computed: {
canAddRule() { canAddRule() {
if ( this.disabled )
return false;
return ! this.maxRules || (this.editing.length < this.maxRules); return ! this.maxRules || (this.editing.length < this.maxRules);
} }
}, },
watch: { watch: {
value() {
this.resetting = true;
this.editing = this.copyValue();
},
editing: { editing: {
handler() { handler() {
this.$emit('input', this.editing) if ( ! this.resetting && ! this.disabled)
this.$emit('input', this.editing)
this.resetting = false;
}, },
deep: true deep: true
} }
@ -125,6 +146,11 @@ export default {
}, },
onAdd: event => { onAdd: event => {
if ( this.disabled ) {
event.preventDefault();
return;
}
if ( ! this.canAddRule ) { if ( ! this.canAddRule ) {
event.preventDefault(); event.preventDefault();
return; return;
@ -142,6 +168,11 @@ export default {
}, },
onRemove: event => { onRemove: event => {
if ( this.disabled ) {
event.preventDefault();
return;
}
let rule; let rule;
try { try {
rule = JSON.parse(event.originalEvent.dataTransfer.getData('JSON')); rule = JSON.parse(event.originalEvent.dataTransfer.getData('JSON'));
@ -154,6 +185,9 @@ export default {
}, },
onUpdate: event => { onUpdate: event => {
if ( this.disabled )
return;
if ( event.newIndex === event.oldIndex ) if ( event.newIndex === event.oldIndex )
return; return;
@ -196,6 +230,9 @@ export default {
}, },
addRule() { addRule() {
if ( this.disabled )
return;
this.adding = false; this.adding = false;
const key = this.$refs.add_box.value, const key = this.$refs.add_box.value,
@ -214,6 +251,9 @@ export default {
}, },
updateRule(id, data) { updateRule(id, data) {
if ( this.disabled )
return;
for(let i=0; i < this.editing.length; i++) { for(let i=0; i < this.editing.length; i++) {
if ( this.editing[i].id === id ) { if ( this.editing[i].id === id ) {
this.editing[i] = Object.assign(this.editing[i], data); this.editing[i] = Object.assign(this.editing[i], data);
@ -223,6 +263,9 @@ export default {
}, },
deleteRule(id) { deleteRule(id) {
if ( this.disabled )
return;
for(let i=0; i < this.editing.length; i++) { for(let i=0; i < this.editing.length; i++) {
if ( this.editing[i].id === id ) { if ( this.editing[i].id === id ) {
this.editing.splice(i, 1); this.editing.splice(i, 1);

View file

@ -4,16 +4,21 @@
<h5 class="ffz-i-ellipsis-vert" /> <h5 class="ffz-i-ellipsis-vert" />
</div> </div>
<div v-if="! component" class="tw-flex tw-flex-grow-1 tw-align-self-center tw-align-items-center">
{{ t('setting.filters.missing', 'This rule of type "{type}" cannot be loaded. It may be from an add-on that is not loaded.', {type: editing && editing.type}) }}
</div>
<component <component
v-else
:is="component" :is="component"
v-model="editing" v-model="editing"
:type="type" :type="type"
:filters="filters" :filters="filters"
:context="context" :context="context"
:preview="preview"
/> />
<div <div
v-if="isShort" v-if="isShort && preview"
class="tw-mg-l-1 tw-pd-x-1 tw-border-l tw-flex tw-align-items-center ffz--profile__icon tw-relative ffz-il-tooltip__container" class="tw-mg-l-1 tw-pd-x-1 tw-border-l tw-flex tw-align-items-center ffz--profile__icon tw-relative ffz-il-tooltip__container"
> >
<figure :class="[passes ? 'ffz-i-ok' : 'ffz-i-cancel']" /> <figure :class="[passes ? 'ffz-i-ok' : 'ffz-i-cancel']" />
@ -31,7 +36,7 @@
:class="[isShort ? '' : 'tw-mg-l-1']" :class="[isShort ? '' : 'tw-mg-l-1']"
class="tw-border-l tw-pd-l-1 tw-flex tw-flex-column tw-flex-wrap tw-justify-content-start tw-align-items-start" class="tw-border-l tw-pd-l-1 tw-flex tw-flex-column tw-flex-wrap tw-justify-content-start tw-align-items-start"
> >
<div v-if="! isShort" class="tw-mg-b-1 tw-border-b tw-pd-b-1 tw-full-width tw-flex tw-justify-content-center ffz--profile__icon tw-relative ffz-il-tooltip__container"> <div v-if="! isShort && preview" class="tw-mg-b-1 tw-border-b tw-pd-b-1 tw-full-width tw-flex tw-justify-content-center ffz--profile__icon tw-relative ffz-il-tooltip__container">
<figure :class="[passes ? 'ffz-i-ok' : 'ffz-i-cancel']" /> <figure :class="[passes ? 'ffz-i-ok' : 'ffz-i-cancel']" />
<div class="ffz-il-tooltip ffz-il-tooltip--up ffz-il-tooltip--align-right"> <div class="ffz-il-tooltip ffz-il-tooltip--up ffz-il-tooltip--align-right">
<span v-if="passes"> <span v-if="passes">
@ -44,23 +49,23 @@
</div> </div>
<template v-if="deleting"> <template v-if="deleting">
<button class="tw-button tw-button--text tw-relative ffz-il-tooltip__container" @click="$emit('delete')">
<span class="tw-button__text ffz-i-trash" />
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ t('setting.delete', 'Delete') }}
</div>
</button>
<button class="tw-button tw-button--text tw-relative ffz-il-tooltip__container" @click="deleting = false"> <button class="tw-button tw-button--text tw-relative ffz-il-tooltip__container" @click="deleting = false">
<span class="tw-button__text ffz-i-cancel" /> <span class="tw-button__text ffz-i-cancel" />
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right"> <div class="ffz-il-tooltip ffz-il-tooltip--up ffz-il-tooltip--align-right">
{{ t('setting.cancel', 'Cancel') }} {{ t('setting.cancel', 'Cancel') }}
</div> </div>
</button> </button>
<button class="tw-button tw-button--text tw-relative ffz-il-tooltip__container" @click="$emit('delete')">
<span class="tw-button__text ffz-i-trash" />
<div class="ffz-il-tooltip ffz-il-tooltip--up ffz-il-tooltip--align-right">
{{ t('setting.delete', 'Delete') }}
</div>
</button>
</template> </template>
<template v-else> <template v-else>
<button class="tw-button tw-button--text tw-relative ffz-il-tooltip__container" @click="deleting = true"> <button class="tw-button tw-button--text tw-relative ffz-il-tooltip__container" @click="deleting = true">
<span class="tw-button__text ffz-i-trash" /> <span class="tw-button__text ffz-i-trash" />
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right"> <div class="ffz-il-tooltip ffz-il-tooltip--up ffz-il-tooltip--align-right">
{{ t('setting.delete', 'Delete') }} {{ t('setting.delete', 'Delete') }}
</div> </div>
</button> </button>
@ -80,6 +85,11 @@ export default {
context: { context: {
type: Object, type: Object,
required: false required: false
},
preview: {
type: Boolean,
required: false,
default: true
} }
}, },
@ -93,7 +103,7 @@ export default {
computed: { computed: {
passes() { passes() {
return this.tester && this.tester(this.context); return this.preview && this.tester && this.tester(this.context);
}, },
type() { type() {
@ -105,7 +115,7 @@ export default {
}, },
isShort() { isShort() {
return this.type && ! this.type.tall; return ! this.component || (this.type && ! this.type.tall);
} }
}, },

View file

@ -0,0 +1,160 @@
<template>
<div class="ffz--graphql-inspector">
<div class="tw-flex tw-align-items-start">
<label for="selector" class="tw-mg-y-05">
{{ t('debug.graphql.query', 'Query:') }}
</label>
<div class="tw-flex tw-flex-column tw-mg-l-05 tw-full-width">
<select
id="selector"
ref="selector"
class="tw-full-width tw-border-top-left-radius-medium tw-border-top-right-radius-medium tw-font-size-6 ffz-select tw-pd-l-1 tw-pd-r-3 tw-pd-y-05"
@change="onSelectChange"
>
<option
v-for="(query, idx) in queries"
:key="query.name"
:selected="current === query"
:value="idx"
>
{{ query.name }}
</option>
</select>
</div>
</div>
<div v-if="current" class="ffz--example-report">
<div class="tw-mg-t-1 tw-c-background-alt-2 tw-font-size-5 tw-pd-y-05 tw-pd-x-1 tw-border-radius-large">
<code>{{ current.source }}</code>
</div>
</div>
<div v-if="current && current.variables" class="tw-mg-t-1">
<div v-html="highlightJson(current.variables)" />
</div>
<div v-if="current && current.result" class="ffz--example-report ffz--tall">
<div class="tw-mg-t-1 tw-c-background-alt-2 tw-font-size-5 tw-pd-y-05 tw-pd-x-1 tw-border-radius-large">
<code v-html="highlightJson(current.result, true)" />
</div>
</div>
</div>
</template>
<script>
import { highlightJson } from 'utilities/dom';
import { deep_copy } from 'utilities/object';
const BAD_KEYS = [
'kind',
'definitions',
'loc'
];
export default {
props: ['item', 'context'],
data() {
return {
has_client: false,
has_printer: false,
queryMap: {},
current: null
}
},
computed: {
queries() {
const queries = Object.values(this.queryMap);
queries.sort((a,b) => a.name.localeCompare(b.name));
return queries;
}
},
created() {
this.ffz = this.item.getFFZ();
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.has_printer = !! this.printer;
},
beforeDestroy() {
this.client = null;
this.ffz = null;
this.has_client = false;
},
mounted() {
this.updateQueries();
},
methods: {
updateQueries() {
if ( ! this.client )
return;
const map = this.client.queryManager?.queries;
if ( ! map || ! map.values )
return;
for(const query of map.values()) {
if ( ! query?.document )
continue;
let name = guessNameFromDocument(query.document);
if ( ! name )
name = query.observableQuery?.queryName;
if ( ! this.queryMap[name] )
this.$set(this.queryMap, name, {
id: query.queryId,
name,
source: this.printQuery(query.document),
variables: null,
result: null
});
this.queryMap[name].variables = deep_copy(query.observableQuery?.variables);
this.queryMap[name].result = deep_copy(query.observableQuery?.lastResult?.data ?? null);
}
if ( ! this.current )
this.current = Object.values(this.queries)[0];
},
highlightJson(object, pretty) {
return highlightJson(object, pretty);
},
printQuery(doc) {
if ( this.printer )
try {
return this.printer(doc);
} catch(err) {
this.ffz.log.warn('Unable to print GQL using gql-printer.', err);
}
return doc.loc?.source?.body;
},
onSelectChange() {
const idx = this.$refs.selector.value,
item = this.queries[idx];
this.current = item;
}
}
}
function guessNameFromDocument(doc) {
const keys = Object.keys(doc).filter(key => ! BAD_KEYS.includes(key));
if ( keys.length === 1 )
return keys[0];
}
</script>

View file

@ -189,14 +189,16 @@
</a> </a>
</div> </div>
<a <template v-if="not_extension">
:data-theme="theme" <a
class="twitter-timeline" :data-theme="theme"
data-width="300" class="twitter-timeline"
href="https://twitter.com/FrankerFaceZ?ref_src=twsrc%5Etfw" data-width="300"
> href="https://twitter.com/FrankerFaceZ?ref_src=twsrc%5Etfw"
{{ t('home.tweets', 'Tweets by FrankerFaceZ') }} >
</a> {{ t('home.tweets', 'Tweets by FrankerFaceZ') }}
</a>
</template>
</div> </div>
</div> </div>
</template> </template>
@ -207,6 +209,7 @@
import HOME_MD from '../home.md'; import HOME_MD from '../home.md';
import {createElement as e} from 'utilities/dom'; import {createElement as e} from 'utilities/dom';
import { EXTENSION } from 'utilities/constants';
export default { export default {
props: ['item', 'context'], props: ['item', 'context'],
@ -217,7 +220,8 @@ export default {
theme: '', theme: '',
addons: null, addons: null,
new_addons: null, new_addons: null,
unseen: this.item.getUnseen() unseen: this.item.getUnseen(),
not_extension: ! EXTENSION
} }
}, },
@ -241,13 +245,14 @@ export default {
mounted() { mounted() {
let el; let el;
document.head.appendChild(el = e('script', { if ( this.not_extension )
id: 'ffz--twitter-widget-script', document.head.appendChild(el = e('script', {
async: true, id: 'ffz--twitter-widget-script',
charset: 'utf-8', async: true,
src: 'https://platform.twitter.com/widgets.js', charset: 'utf-8',
onLoad: () => el.remove() src: 'https://platform.twitter.com/widgets.js',
})); onLoad: () => el.remove()
}));
}, },
methods: { methods: {

View file

@ -152,6 +152,8 @@
v-if="rich_data" v-if="rich_data"
:data="rich_data" :data="rich_data"
:url="url" :url="url"
:force-mid="false"
:force-full="false"
:force-media="force_media" :force-media="force_media"
:force-unsafe="force_unsafe" :force-unsafe="force_unsafe"
:events="events" :events="events"
@ -168,6 +170,7 @@
:data="rich_data" :data="rich_data"
:url="url" :url="url"
:force-mid="true" :force-mid="true"
:force-full="false"
:force-media="force_media" :force-media="force_media"
:force-unsafe="force_unsafe" :force-unsafe="force_unsafe"
:events="events" :events="events"
@ -207,7 +210,8 @@
<div v-if="raw_loading" class="tw-align-center"> <div v-if="raw_loading" class="tw-align-center">
<h1 class="tw-mg-5 ffz-i-zreknarf loading" /> <h1 class="tw-mg-5 ffz-i-zreknarf loading" />
</div> </div>
<code v-else>{{ raw_data }}</code> <code v-else-if="typeof raw_data === 'string'">{{ raw_data }}</code>
<code v-else v-html="highlightJson(raw_data, true)"></code>
</div> </div>
</div> </div>
</div> </div>
@ -215,7 +219,9 @@
</template> </template>
<script> <script>
import { debounce, timeout, pick_random } from 'utilities/object' import { debounce, timeout, pick_random } from 'utilities/object'
import { highlightJson } from 'utilities/dom';
const STOCK_URLS = [ const STOCK_URLS = [
'https://www.twitch.tv/sirstendec', 'https://www.twitch.tv/sirstendec',
@ -366,6 +372,11 @@ export default {
}, },
beforeDestroy() { beforeDestroy() {
if (this.es) {
this.es.close();
this.es = null;
}
this.chat.off('chat:update-link-resolver', this.checkRefreshRaw, this); this.chat.off('chat:update-link-resolver', this.checkRefreshRaw, this);
this.settings.off(':changed:debug.link-resolver.source', this.changeProvider, this); this.settings.off(':changed:debug.link-resolver.source', this.changeProvider, this);
this.chat = null; this.chat = null;
@ -498,7 +509,7 @@ export default {
}, },
async refreshRaw() { async refreshRaw() {
this.raw_data = null; this.raw_data = undefined;
this.length = 0; this.length = 0;
if ( ! this.rich_data ) { if ( ! this.rich_data ) {
this.raw_loading = false; this.raw_loading = false;
@ -508,7 +519,7 @@ export default {
this.raw_loading = true; this.raw_loading = true;
try { try {
const data = await this.chat.get_link_info(this.url); const data = await this.chat.get_link_info(this.url);
this.raw_data = JSON.stringify(data, null, '\t'); this.raw_data = data; //JSON.stringify(data, null, '\t');
this.length = JSON.stringify(data).length; this.length = JSON.stringify(data).length;
} catch(err) { } catch(err) {
this.raw_data = `Error\n\n${err.toString()}`; this.raw_data = `Error\n\n${err.toString()}`;
@ -580,7 +591,11 @@ export default {
this.force_tooltip = this.$refs.force_tooltip.checked; this.force_tooltip = this.$refs.force_tooltip.checked;
this.saveState(); this.saveState();
} },
highlightJson(object, pretty) {
return highlightJson(object, pretty);
},
} }
} }

View file

@ -98,7 +98,7 @@
{{ version.commit.slice(0,7) }} {{ version.commit.slice(0,7) }}
</a> </a>
<span v-else> <span v-else>
{{ version.build }} {{ version.hash }}
</span> </span>
</div> </div>
</footer> </footer>

View file

@ -7,6 +7,23 @@
<template v-if="i !== item">&raquo; </template> <template v-if="i !== item">&raquo; </template>
</span> </span>
</header> </header>
<section v-if="context.currentProfile.ephemeral && item.profile_warning !== false" class="tw-border-t tw-pd-t-1 tw-pd-b-2">
<div class="tw-c-background-accent tw-c-text-overlay tw-pd-1">
<h3 class="ffz-i-attention">
{{ t('setting.profiles.ephemeral', "This profile is ephemeral.") }}
</h3>
<span>
{{ t('setting.profiles.ephemeral.description',
"The currently selected profile is ephemeral, which is a fancy way of saying that it was automatically generated, that it only exists temporarily, and that any changes you make won't be saved."
) }}
</span>
<span>{{ t('setting.profiles.ephemeral.description-2',
"Please select a different profile from the selector at the upper left of this menu to edit your settings."
) }}</span>
</div>
</section>
<section v-if="(! context.currentProfile.live || ! context.currentProfile.toggled) && item.profile_warning !== false" class="tw-border-t tw-pd-t-1 tw-pd-b-2"> <section v-if="(! context.currentProfile.live || ! context.currentProfile.toggled) && item.profile_warning !== false" class="tw-border-t tw-pd-t-1 tw-pd-b-2">
<div class="tw-c-background-accent tw-c-text-overlay tw-pd-1"> <div class="tw-c-background-accent tw-c-text-overlay tw-pd-1">
<h3 class="ffz-i-attention"> <h3 class="ffz-i-attention">

View file

@ -1,9 +1,29 @@
<template lang="html"> <template lang="html">
<div class="ffz--profile-editor"> <div class="ffz--profile-editor">
<section v-if="isEphemeral" 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">
<h3 class="ffz-i-attention">
{{ t('setting.profiles.ephemeral', "This profile is ephemeral.") }}
</h3>
<span>
{{ t('setting.profiles.ephemeral.description',
"The currently selected profile is ephemeral, which is a fancy way of saying that it was automatically generated, that it only exists temporarily, and that any changes you make won't be saved."
) }}
</span>
<span>{{ t('setting.profiles.ephemeral.description-2',
"Please select a different profile from the selector at the upper left of this menu to edit your settings."
) }}</span>
</div>
</section>
<div class="tw-flex tw-align-items-center tw-border-t tw-pd-1"> <div class="tw-flex tw-align-items-center tw-border-t tw-pd-1">
<div class="tw-flex-grow-1" /> <div class="tw-flex-grow-1" />
<button <button
:disabled="isEphemeral"
class="tw-button tw-button--text" class="tw-button tw-button--text"
:class="{'tw-button--disabled': isEphemeral}"
@click="save" @click="save"
> >
<span class="tw-button__text ffz-i-floppy"> <span class="tw-button__text ffz-i-floppy">
@ -80,6 +100,7 @@
<input <input
id="ffz:editor:name" id="ffz:editor:name"
ref="name" ref="name"
:disabled="isEphemeral"
v-model="name" v-model="name"
class="tw-full-width tw-border-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 ffz-input" class="tw-full-width tw-border-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 ffz-input"
> >
@ -93,6 +114,7 @@
<textarea <textarea
id="ffz:editor:description" id="ffz:editor:description"
ref="desc" ref="desc"
:disabled="isEphemeral"
v-model="desc" v-model="desc"
class="tw-full-width tw-border-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 ffz-input" class="tw-full-width tw-border-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 ffz-input"
/> />
@ -107,6 +129,7 @@
<key-picker <key-picker
id="ffz:editor:hotkey" id="ffz:editor:hotkey"
ref="hotkey" ref="hotkey"
:disabled="isEphemeral"
v-model="hotkey" v-model="hotkey"
/> />
</div> </div>
@ -148,6 +171,7 @@
id="ffz:editor:update" id="ffz:editor:update"
ref="update" ref="update"
:checked="! pause" :checked="! pause"
:disabled="isEphemeral"
type="checkbox" type="checkbox"
class="ffz-checkbox__input" class="ffz-checkbox__input"
@change="onPauseChange" @change="onPauseChange"
@ -175,7 +199,9 @@
<filter-editor <filter-editor
v-model="rules" v-model="rules"
:filters="filters" :filters="filters"
:disabled="isEphemeral"
:context="test_context" :context="test_context"
:preview="true"
/> />
</div> </div>
</div> </div>
@ -218,6 +244,10 @@ export default {
}, },
computed: { computed: {
isEphemeral() {
return this.item.profile?.ephemeral ?? false
},
canExport() { canExport() {
return this.item.profile != null return this.item.profile != null
} }

View file

@ -235,6 +235,16 @@
<span class="ffz-i-ellipsis-vert" /> <span class="ffz-i-ellipsis-vert" />
</div> </div>
<div
v-if="p.ephemeral"
class="tw-flex tw-flex-shrink-0 tw-align-items-center tw-mg-r-1 tw-relative ffz-il-tooltip__container tw-font-size-4"
>
<span class="ffz-i-user-secret" />
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-left">
{{ t('setting.profiles.ephemeral', 'This profile is ephemeral.') }}
</div>
</div>
<div <div
v-if="p.url" v-if="p.url"
class="tw-flex tw-flex-shrink-0 tw-align-items-center tw-mg-r-1 tw-relative ffz-il-tooltip__container tw-font-size-4" class="tw-flex tw-flex-shrink-0 tw-align-items-center tw-mg-r-1 tw-relative ffz-il-tooltip__container tw-font-size-4"

View file

@ -26,7 +26,7 @@
@focusin="focus" @focusin="focus"
@focusout="blur" @focusout="blur"
> >
<div class="scrollable-area tw-border-b" data-simplebar> <div ref="scroller" class="scrollable-area tw-border-b" data-simplebar>
<div class="simplebar-scroll-content"> <div class="simplebar-scroll-content">
<div ref="popup" class="simplebar-content"> <div ref="popup" class="simplebar-content">
<div <div
@ -52,6 +52,14 @@
@click="changeProfile(p)" @click="changeProfile(p)"
> >
<div class="ffz--profile-row__icon-tray tw-flex"> <div class="ffz--profile-row__icon-tray tw-flex">
<div
v-if="p.ephemeral"
class="ffz-il-tooltip__container ffz--profile-row__icon ffz-i-user-secret tw-relative"
>
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ t('setting.profiles.ephemeral', 'This profile is ephemeral.') }}
</div>
</div>
<div <div
v-if="p.url" v-if="p.url"
:class="`ffz-il-tooltip__container ffz--profile-row__icon ffz-i-download-cloud tw-relative${p.pause_updates ? ' ffz-unmatched-item' : ''}`" :class="`ffz-il-tooltip__container ffz--profile-row__icon ffz-i-download-cloud tw-relative${p.pause_updates ? ' ffz-unmatched-item' : ''}`"
@ -111,7 +119,22 @@ export default {
} }
}, },
watch: {
opened() {
if (this.opened)
this.$nextTick(() => this.updateScroller());
}
},
methods: { methods: {
updateScroller() {
const scroller = this.$refs.scroller;
if (!scroller || ! window.ffzSimplebar || scroller.SimpleBar)
return;
new ffzSimplebar(scroller, ffzSimplebar.getElOptions(scroller));
},
openConfigure() { openConfigure() {
this.hide(); this.hide();
this.$emit('navigate', 'data_management.profiles'); this.$emit('navigate', 'data_management.profiles');

View file

@ -8,6 +8,7 @@
:id="item.full_key" :id="item.full_key"
ref="control" ref="control"
:checked="value" :checked="value"
:disabled="isReadOnly"
type="checkbox" type="checkbox"
class="ffz-checkbox__input" class="ffz-checkbox__input"
@change="onChange" @change="onChange"
@ -16,7 +17,7 @@
<label :for="item.full_key" class="ffz-checkbox__label"> <label :for="item.full_key" class="ffz-checkbox__label">
<span class="tw-mg-l-1"> <span class="tw-mg-l-1">
{{ item.i18n_key ? t(item.i18n_key, item.title) : item.title }} {{ item.i18n_key ? t(item.i18n_key, item.title) : item.title }}
<span v-if="unseen" class="tw-pill">{{ t('setting.new', 'New') }}</span> <span v-if="unseen" class="ffz-pill ffz-pill--success">{{ t('setting.new', 'New') }}</span>
</span> </span>
</label> </label>
@ -39,7 +40,13 @@
</button> </button>
<div class="ffz--reset-button"> <div class="ffz--reset-button">
<button v-if="has_value" class="tw-mg-l-05 tw-button tw-button--text ffz-il-tooltip__container" @click="clear"> <button
v-if="has_value"
:disabled="isReadOnly"
class="tw-mg-l-05 tw-button tw-button--text ffz-il-tooltip__container"
:class="{'tw-button--disabled': isReadOnly}"
@click="clear"
>
<span class="tw-button__text ffz-i-cancel" /> <span class="tw-button__text ffz-i-cancel" />
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right"> <div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ t('setting.reset', 'Reset to Default') }} {{ t('setting.reset', 'Reset to Default') }}
@ -58,7 +65,11 @@
v-if="item.extra" v-if="item.extra"
style="padding-left:2.5rem" style="padding-left:2.5rem"
> >
<component :is="item.extra.component" :context="context" :item="item" /> <component
:is="item.extra.component"
:context="context"
:item="item"
/>
</section> </section>
</div> </div>
</template> </template>

View file

@ -6,12 +6,13 @@
<div class="tw-flex tw-align-items-center"> <div class="tw-flex tw-align-items-center">
<label :for="item.full_key"> <label :for="item.full_key">
{{ item.i18n_key ? t(item.i18n_key, item.title) : item.title }} {{ item.i18n_key ? t(item.i18n_key, item.title) : item.title }}
<span v-if="unseen" class="tw-pill">{{ t('setting.new', 'New') }}</span> <span v-if="unseen" class="ffz-pill ffz-pill--success">{{ t('setting.new', 'New') }}</span>
</label> </label>
<color-picker <color-picker
:id="item.full_key" :id="item.full_key"
ref="control" ref="control"
:disabled="isReadOnly"
:alpha="alpha" :alpha="alpha"
:open-up="openUp" :open-up="openUp"
:nullable="true" :nullable="true"
@ -38,7 +39,13 @@
</span> </span>
</button> </button>
<button v-if="has_value" class="tw-mg-l-05 tw-button tw-button--text ffz-il-tooltip__container" @click="clear"> <button
v-if="has_value"
:disabled="isReadOnly"
class="tw-mg-l-05 tw-button tw-button--text ffz-il-tooltip__container"
:class="{'tw-button--disabled': isReadOnly}"
@click="clear"
>
<span class="tw-button__text ffz-i-cancel" /> <span class="tw-button__text ffz-i-cancel" />
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right"> <div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ t('setting.reset', 'Reset to Default') }} {{ t('setting.reset', 'Reset to Default') }}

View file

@ -6,13 +6,14 @@
<div class="tw-flex tw-align-items-start"> <div class="tw-flex tw-align-items-start">
<label :for="item.full_key" class="tw-mg-y-05"> <label :for="item.full_key" class="tw-mg-y-05">
{{ item.i18n_key ? t(item.i18n_key, item.title) : item.title }} {{ item.i18n_key ? t(item.i18n_key, item.title) : item.title }}
<span v-if="unseen" class="tw-pill">{{ t('setting.new', 'New') }}</span> <span v-if="unseen" class="ffz-pill ffz-pill--success">{{ t('setting.new', 'New') }}</span>
</label> </label>
<div class="tw-flex tw-flex-column tw-mg-05"> <div class="tw-flex tw-flex-column tw-mg-05">
<select <select
:id="item.full_key" :id="item.full_key"
ref="control" ref="control"
:disabled="isReadOnly"
class="tw-border-top-left-radius-medium tw-border-top-right-radius-medium tw-font-size-6 ffz-select tw-pd-l-1 tw-pd-r-3 tw-pd-y-05" class="tw-border-top-left-radius-medium tw-border-top-right-radius-medium tw-font-size-6 ffz-select tw-pd-l-1 tw-pd-r-3 tw-pd-y-05"
@change="onChange" @change="onChange"
> >
@ -48,7 +49,7 @@
<input <input
ref="text" ref="text"
:value="value" :value="value"
:disabled="! isCustom" :disabled="isReadOnly || ! isCustom"
class="ffz-mg-t-1p tw-border-bottom-left-radius-medium tw-border-bottom-right-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 ffz-input" class="ffz-mg-t-1p tw-border-bottom-left-radius-medium tw-border-bottom-right-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 ffz-input"
@change="onTextChange" @change="onTextChange"
> >
@ -72,7 +73,13 @@
</span> </span>
</button> </button>
<button v-if="has_value" class="tw-mg-l-05 tw-mg-y-05 tw-button tw-button--text ffz-il-tooltip__container" @click="clear"> <button
v-if="has_value"
:disabled="isReadOnly"
class="tw-mg-l-05 tw-mg-y-05 tw-button tw-button--text ffz-il-tooltip__container"
:class="{'tw-button--disabled': isReadOnly}"
@click="clear"
>
<span class="tw-button__text ffz-i-cancel" /> <span class="tw-button__text ffz-i-cancel" />
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right"> <div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ t('setting.reset', 'Reset to Default') }} {{ t('setting.reset', 'Reset to Default') }}

View file

@ -0,0 +1,130 @@
<template lang="html">
<div class="ffz--widget ffz--filter-editor tw-border-t tw-pd-y-1">
<div
v-if="source && source !== profile"
class="tw-c-background-accent tw-c-text-overlay tw-pd-1 tw-mg-b-1"
>
<span class="ffz-i-info" />
{{ t('setting.warn-inheritence', 'These values are being overridden by another profile and may not take effect.') }}
</div>
<div class="tw-flex tw-align-items-center tw-pd-b-05">
<div class="tw-flex-grow-1">
{{ t('setting.filter.drag', 'Drag entries to re-order them.') }}
</div>
<button
v-if="! maybe_clear && rules.length"
class="tw-mg-l-1 tw-button tw-button--text ffz-il-tooltip__container"
@click="maybe_clear = true"
>
<span class="tw-button__text ffz-i-trash">
{{ t('setting.delete-all', 'Delete All') }}
</span>
<span class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ t('setting.filter.delete-all', "Delete all of this profile's entries.") }}
</span>
</button>
<button
v-if="maybe_clear"
class="tw-mg-l-1 tw-button tw-button--text ffz-il-tooltip__container"
@click="doClear"
>
<span class="tw-button__text ffz-i-trash">
{{ t('setting.delete-all', 'Delete All') }}
</span>
<span class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ t('setting.filter.delete-all', "Delete all of this profile's entries.") }}
</span>
</button>
<button
v-if="maybe_clear"
class="tw-mg-l-1 tw-button tw-button--text ffz-il-tooltip__container"
@click="maybe_clear = false"
>
<span class="tw-button__text ffz-i-cancel">
{{ t('setting.cancel', 'Cancel') }}
</span>
</button>
<button
v-if="! rules.length && has_default"
class="tw-mg-l-1 tw-button tw-button--text ffz-il-tooltip__container"
@click="populate"
>
<span class="tw-button__text ffz-i-trash">
{{ t('setting.filter.add-default', 'Add Defaults') }}
</span>
<span class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ t('setting.filter.add-default-tip', 'Add all of the default values to this profile.') }}
</span>
</button>
</div>
<filter-editor
:value="rules"
:filters="filters"
:context="test_context"
:preview="preview"
@input="onInput"
/>
</div>
</template>
<script>
import {deep_copy} from 'utilities/object';
import SettingMixin from '../setting-mixin';
export default {
mixins: [SettingMixin],
props: ['item', 'context'],
data() {
return {
filters: this.item.data(),
maybe_clear: false,
test_context: this.item.test_context ? this.item.test_context() : {},
};
},
computed: {
has_default() {
return this.default_value && this.default_value.length
},
preview() {
return this.item.preview || false
},
rules() {
if ( ! this.has_value || ! Array.isArray(this.value) )
return [];
return this.value.filter(x => x?.v).map(x => x.v);
}
},
methods: {
doClear() {
this.maybe_clear = false;
this.clear();
},
populate() {
this.set(deep_copy(this.default_value));
},
onInput(data) {
const val = deep_copy(data).map(x => ({v: x}));
if (val.length == 0)
val.push({t: 'skip'});
this.set(val);
}
}
}
</script>

View file

@ -6,12 +6,13 @@
<div class="tw-flex tw-align-items-center"> <div class="tw-flex tw-align-items-center">
<label :for="item.full_key"> <label :for="item.full_key">
{{ item.i18n_key ? t(item.i18n_key, item.title) : item.title }} {{ item.i18n_key ? t(item.i18n_key, item.title) : item.title }}
<span v-if="unseen" class="tw-pill">{{ t('setting.new', 'New') }}</span> <span v-if="unseen" class="ffz-pill ffz-pill--success">{{ t('setting.new', 'New') }}</span>
</label> </label>
<key-picker <key-picker
:id="item.full_key" :id="item.full_key"
ref="control" ref="control"
:disabled="isReadOnly"
:value="value" :value="value"
class="tw-mg-05" class="tw-mg-05"
@input="onInput" @input="onInput"
@ -35,7 +36,13 @@
</span> </span>
</button> </button>
<button v-if="has_value" class="tw-mg-l-05 tw-button tw-button--text ffz-il-tooltip__container" @click="clear"> <button
v-if="has_value"
:disabled="isReadOnly"
class="tw-mg-l-05 tw-button tw-button--text ffz-il-tooltip__container"
:class="{'tw-button--disabled': isReadOnly}"
@click="clear"
>
<span class="tw-button__text ffz-i-cancel" /> <span class="tw-button__text ffz-i-cancel" />
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right"> <div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ t('setting.reset', 'Reset to Default') }} {{ t('setting.reset', 'Reset to Default') }}

View file

@ -6,12 +6,13 @@
<div class="tw-flex tw-align-items-center"> <div class="tw-flex tw-align-items-center">
<label :for="item.full_key" class="tw-mg-y-05"> <label :for="item.full_key" class="tw-mg-y-05">
{{ item.i18n_key ? t(item.i18n_key, item.title) : item.title }} {{ item.i18n_key ? t(item.i18n_key, item.title) : item.title }}
<span v-if="unseen" class="tw-pill">{{ t('setting.new', 'New') }}</span> <span v-if="unseen" class="ffz-pill ffz-pill--success">{{ t('setting.new', 'New') }}</span>
</label> </label>
<select <select
:id="item.full_key" :id="item.full_key"
ref="control" ref="control"
:disabled="isReadOnly"
:multiple="item.multiple || false" :multiple="item.multiple || false"
:size="item.size || 0" :size="item.size || 0"
class="tw-border-radius-medium tw-font-size-6 ffz-select tw-pd-l-1 tw-pd-r-3 tw-pd-y-05 tw-mg-05" class="tw-border-radius-medium tw-font-size-6 ffz-select tw-pd-l-1 tw-pd-r-3 tw-pd-y-05 tw-mg-05"
@ -62,7 +63,13 @@
</span> </span>
</button> </button>
<button v-if="has_value" class="tw-mg-l-05 tw-button tw-button--text ffz-il-tooltip__container" @click="clear"> <button
v-if="has_value"
:disabled="isReadOnly"
class="tw-mg-l-05 tw-button tw-button--text ffz-il-tooltip__container"
:class="{'tw-button--disabled': isReadOnly}"
@click="clear"
>
<span class="tw-button__text ffz-i-cancel" /> <span class="tw-button__text ffz-i-cancel" />
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right"> <div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ t('setting.reset', 'Reset to Default') }} {{ t('setting.reset', 'Reset to Default') }}

View file

@ -6,7 +6,7 @@
<div class="tw-flex tw-align-items-center"> <div class="tw-flex tw-align-items-center">
<label :for="item.full_key"> <label :for="item.full_key">
{{ item.i18n_key ? t(item.i18n_key, item.title) : item.title }} {{ item.i18n_key ? t(item.i18n_key, item.title) : item.title }}
<span v-if="unseen" class="tw-pill">{{ t('setting.new', 'New') }}</span> <span v-if="unseen" class="ffz-pill ffz-pill--success">{{ t('setting.new', 'New') }}</span>
</label> </label>
<input <input
@ -15,6 +15,7 @@
:type="type" :type="type"
:placeholder="placeholder" :placeholder="placeholder"
:value="value" :value="value"
:disabled="isReadOnly"
:class="{'ffz-input--error': ! isValid}" :class="{'ffz-input--error': ! isValid}"
class="tw-border-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 tw-mg-05 ffz-input" class="tw-border-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 tw-mg-05 ffz-input"
@change="onChange" @change="onChange"
@ -38,7 +39,13 @@
</span> </span>
</button> </button>
<button v-if="has_value" class="tw-mg-l-05 tw-button tw-button--text ffz-il-tooltip__container" @click="clear"> <button
v-if="has_value"
:disabled="isReadOnly"
class="tw-mg-l-05 tw-button tw-button--text ffz-il-tooltip__container"
:class="{'tw-button--disabled': isReadOnly}"
@click="clear"
>
<span class="tw-button__text ffz-i-cancel" /> <span class="tw-button__text ffz-i-cancel" />
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right"> <div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ t('setting.reset', 'Reset to Default') }} {{ t('setting.reset', 'Reset to Default') }}

View file

@ -0,0 +1,134 @@
<template lang="html">
<section class="ffz--widget ffz--tag-list">
<div class="tw-align-items-center tw-flex tw-flex-nowrap tw-flex-row tw-full-width">
<div class="tw-flex-grow-1 tw-mg-r-05">
<autocomplete
v-slot="slot"
v-model="adding"
:input-id="'tag$' + id"
:items="fetchTags"
:suggest-on-focus="true"
:escape-to-clear="false"
class="tw-flex-grow-1"
/>
</div>
<div class="tw-flex-shrink-0">
<button class="tw-button" @click="add">
<span class="tw-button__text">
{{ t('setting.terms.add-term', 'Add') }}
</span>
</button>
</div>
</div>
<div v-if="! value || ! value.length" class="tw-mg-t-05 tw-c-text-alt-2 tw-font-size-4 tw-align-center tw-c-text-alt-2 tw-pd-05">
{{ t('setting.no-items', 'no items') }}
</div>
<ul v-else class="ffz--term-list tw-mg-t-05">
<li
v-for="i in value"
:key="i"
class="ffz--term ffz--game-term tw-align-items-center tw-flex tw-flex-nowrap tw-flex-row tw-full-width"
>
<div class="tw-flex-grow-1 tw-mg-r-05">
<a
v-if="can_link"
:href="`/directory/all/tags/${i}`"
class="ffz-link"
@click.prevent="handleLink(i)"
>
{{ i }}
</a>
<span v-else>
{{ i }}
</span>
</div>
<div class="tw-flex-shrink-0">
<button class="tw-button tw-button--text ffz-il-tooltip__container" @click="remove(i)">
<span class="tw-button__text ffz-i-trash" />
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ t('setting.delete', 'Delete') }}
</div>
</button>
</div>
</li>
</ul>
</section>
</template>
<script>
import SettingMixin from '../setting-mixin';
import {deep_copy} from 'utilities/object';
let last_id = 0;
export default {
mixins: [SettingMixin],
props: ['item', 'context'],
data() {
return {
id: last_id++,
adding: '',
can_link: false
}
},
created() {
const ffz = this.context.getFFZ();
this.loader = ffz.resolve('site.twitch_data');
this.router = ffz.resolve('site.router');
this.can_link = false; //! ffz.resolve('main_menu').exclusive;
},
methods: {
add() {
if ( ! this.adding?.length )
return;
const adding = this.adding.toLowerCase();
const values = Array.from(this.value);
if ( values.includes(adding) )
return;
values.push(adding);
this.set(values);
},
remove(item) {
const values = Array.from(this.value),
idx = values.indexOf(item);
if ( idx === -1 )
return;
if ( values.length === 1 )
this.clear();
else {
values.splice(idx, 1);
this.set(values);
}
},
handleLink(i) {
//this.router.navigate('dir-game-index', {gameName: i});
},
async fetchTags(query) {
if ( ! this.loader )
return [];
const data = await this.loader.getMatchingTags(query);
if ( ! Array.isArray(data) )
return [];
return data.map(x => ({name: x}));
}
}
}
</script>

View file

@ -13,7 +13,7 @@ Due to performance problems with our current website, we have to use caching on
* *I don't want the `FFZ Supporter` badge.* * *I don't want the `FFZ Supporter` badge.*
Users can toggle the visibility of their supporter badge at: [https://www.frankerfacez.com/donate](https://www.frankerfacez.com/donate) Users can toggle the visibility of their FFZ badges at: [https://www.frankerfacez.com/settings/profile](https://www.frankerfacez.com/settings/profile)
* *I can see my emotes, but someone in chat said they can't.* * *I can see my emotes, but someone in chat said they can't.*

View file

@ -63,18 +63,21 @@ export default class MainMenu extends Module {
this.settings.addUI('backup', { this.settings.addUI('backup', {
path: 'Data Management > Backup and Restore @{"profile_warning": false}', path: 'Data Management > Backup and Restore @{"profile_warning": false}',
component: 'backup-restore', component: 'backup-restore',
getExtraTerms: () => ['restore'],
getFFZ: () => this.resolve('core') getFFZ: () => this.resolve('core')
}); });
this.settings.addUI('clear', { this.settings.addUI('clear', {
path: 'Data Management > Storage @{"profile_warning": false} >> tabs ~> Clear', path: 'Data Management > Storage @{"profile_warning": false} >> tabs ~> Clear',
component: 'clear-settings', component: 'clear-settings',
getExtraTerms: () => ['reset'],
force_seen: true force_seen: true
}); });
this.settings.addUI('provider', { this.settings.addUI('provider', {
path: 'Data Management > Storage >> tabs ~> Provider', path: 'Data Management > Storage >> tabs ~> Provider',
component: 'provider', component: 'provider',
getExtraTerms: () => ['storage', 'local', 'indexeddb', 'localstorage'],
force_seen: true force_seen: true
}); });
@ -148,6 +151,13 @@ export default class MainMenu extends Module {
} }
}); });
this.settings.addUI('debug.graphql-test', {
path: 'Debugging > GraphQL >> Inspector',
component: 'graphql-inspect',
getFFZ: () => this.resolve('core'),
force_seen: true
});
this.settings.addUI('faq', { this.settings.addUI('faq', {
path: 'Home > FAQ @{"profile_warning": false}', path: 'Home > FAQ @{"profile_warning": false}',
component: 'md-page', component: 'md-page',
@ -215,7 +225,12 @@ export default class MainMenu extends Module {
this.on('settings:added-definition', (key, definition) => { this.on('settings:added-definition', (key, definition) => {
this._addDefinitionToTree(key, definition); this._addDefinitionToTree(key, definition);
this.scheduleUpdate(); this.scheduleUpdate();
}) });
this.on('settings:removed-definition', key => {
this._removeDefinitionFromTree(key);
this.scheduleUpdate();
});
this.on('socket:command:new_version', version => { this.on('socket:command:new_version', version => {
if ( version === window.FrankerFaceZ.version_info.commit ) if ( version === window.FrankerFaceZ.version_info.commit )
@ -361,6 +376,7 @@ export default class MainMenu extends Module {
this.log.info('Context proxy gone.'); this.log.info('Context proxy gone.');
this.updateContext({proxied: false}); this.updateContext({proxied: false});
} }
}); });
try { try {
@ -508,6 +524,58 @@ export default class MainMenu extends Module {
} }
_removeDefinitionFromTree(key) {
if ( ! this._settings_tree )
return;
let page;
for(const val of Object.values(this._settings_tree)) {
if ( ! val || ! Array.isArray(val.settings) )
continue;
for(let i = 0; i < val.settings.length; i++) {
const entry = val.settings[i];
if ( entry && entry[0] === key ) {
val.settings.splice(i, 1);
page = val;
break;
}
}
if ( page )
break;
}
// Was it found?
if ( ! page )
return;
this._maybeDeleteSection(page);
}
_maybeDeleteSection(page) {
// Is the section empty?
if ( page.settings && page.settings.length )
return;
const id = page.full_key;
// Check for children.
for(const val of Object.values(this._settings_tree)) {
if ( val.parent === id )
return;
}
// Nope~
delete this._settings_tree[id];
if ( page.parent ) {
const parent = this._settings_tree[page.parent];
if ( parent )
this._maybeDeleteSection(parent);
}
}
_addDefinitionToTree(key, def) { _addDefinitionToTree(key, def) {
if ( ! def.ui || ! this._settings_tree ) if ( ! def.ui || ! this._settings_tree )
return; return;
@ -779,6 +847,8 @@ export default class MainMenu extends Module {
title: profile.name, title: profile.name,
i18n_key: profile.i18n_key, i18n_key: profile.i18n_key,
ephemeral: profile.ephemeral,
description: profile.description, description: profile.description,
desc_i18n_key: profile.desc_i18n_key || profile.i18n_key && `${profile.i18n_key}.description`, desc_i18n_key: profile.desc_i18n_key || profile.i18n_key && `${profile.i18n_key}.description`,
@ -825,7 +895,7 @@ export default class MainMenu extends Module {
if ( ! currentProfile ) { if ( ! currentProfile ) {
for(let i=profiles.length - 1; i >= 0; i--) { for(let i=profiles.length - 1; i >= 0; i--) {
if ( profiles[i].live ) { if ( profiles[i].live && ! profiles[i].ephemeral && profiles[i].title && ! /7tv/i.test(profiles[i].title) ) {
currentProfile = profiles[i]; currentProfile = profiles[i];
break; break;
} }
@ -1147,7 +1217,7 @@ export default class MainMenu extends Module {
if ( this.dialog.exclusive || this.site?.router?.current_name === 'squad' || this.site?.router?.current_name === 'command-center' ) if ( this.dialog.exclusive || this.site?.router?.current_name === 'squad' || this.site?.router?.current_name === 'command-center' )
return; return;
if ( this.settings.get('context.ui.theatreModeEnabled') ) if ( this.settings.get('layout.is-theater-mode') )
return; return;
this.dialog.toggleSize(e); this.dialog.toggleSize(e);

View file

@ -37,7 +37,7 @@ We use the APIs of the following services for scraping link information:
* Twitch ([Terms of Service](https://www.twitch.tv/p/legal/terms-of-service/), [Developer Agreement](https://www.twitch.tv/p/legal/developer-agreement/)) * Twitch ([Terms of Service](https://www.twitch.tv/p/legal/terms-of-service/), [Developer Agreement](https://www.twitch.tv/p/legal/developer-agreement/))
* Twitter ([Terms of Service](https://twitter.com/en/tos), [Developer Terms](https://developer.twitter.com/en/more/developer-terms.html)) * Twitter ([Terms of Service](https://twitter.com/en/tos), [Developer Terms](https://developer.twitter.com/en/more/developer-terms.html))
* xkcd * xkcd
* YouTube ([Terms of Service](https://www.youtube.com/t/terms), [Developer Terms of Service](https://developers.google.com/youtube/terms/developer-policies)) * YouTube ([Terms of Service](https://www.youtube.com/t/terms), [Developer Terms of Service](https://developers.google.com/youtube/terms/developer-policies), [Privacy Policy](https://policies.google.com/privacy))
In addition to scraping via APIs, our link information reads standard metadata tags from In addition to scraping via APIs, our link information reads standard metadata tags from
HTML responses to support a wide array of other websites. HTML responses to support a wide array of other websites.

View file

@ -0,0 +1,51 @@
[
{
"name": "Cheer",
"data": "@bits=42069;badge-info=subscriber/69;badges=subscriber/3024,bits/100;color=#FF526F;display-name=SirStendec;id=813a1ef5-f8dd-406c-a2dd-f74d99db2799 :sirstendec!sirstendec@sirstendec.tmi.twitch.tv PRIVMSG #sirstendec :Hello. cheer42069"
},
{
"name": "Ping",
"data": "PING :tmi.twitch.tv"
},
{
"name": "Subscribe (Tier 1, No Message)",
"data": "@badge-info=subscriber/1;badges=subscriber/0,premium/1;color=#007ECC;display-name=Kerokai;emotes=;flags=;id=80b7174c-830b-487b-8bce-99bab02b6378;login=kerokai;mod=0;msg-id=sub;msg-param-cumulative-months=1;msg-param-months=0;msg-param-multimonth-duration=1;msg-param-multimonth-tenure=0;msg-param-should-share-streak=0;msg-param-sub-plan-name=Channel\\sSubscription\\s(maxisgoatlord);msg-param-sub-plan=1000;msg-param-was-gifted=false;room-id=42490770;subscriber=1;system-msg=Kerokai\\ssubscribed\\sat\\sTier\\s1.;tmi-sent-ts=1671231043209;user-id=32357552;user-type=; :tmi.twitch.tv USERNOTICE #maximum"
},
{
"name": "Resubscribe (Tier 1, 17 Months, Message)",
"data": "@badge-info=subscriber/17;badges=subscriber/12,moments/1;color=#2E8B57;display-name=FaustDaimos;emotes=;flags=;id=cdb378a9-4e56-4933-a78b-f4bcf2a3961a;login=faustdaimos;mod=0;msg-id=resub;msg-param-cumulative-months=17;msg-param-months=0;msg-param-multimonth-duration=0;msg-param-multimonth-tenure=0;msg-param-should-share-streak=0;msg-param-sub-plan-name=Baka\\sBrigade!;msg-param-sub-plan=1000;msg-param-was-gifted=false;room-id=24761645;subscriber=1;system-msg=FaustDaimos\\ssubscribed\\sat\\sTier\\s1.\\sThey've\\ssubscribed\\sfor\\s17\\smonths!;tmi-sent-ts=1671231277242;user-id=37613709;user-type= :tmi.twitch.tv USERNOTICE #cirno_tv :when do we try it out"
},
{
"name": "Subscribe (Tier 1, 6 Months, No Message)",
"data": "@badge-info=subscriber/1;badges=subscriber/0;color=#191970;display-name=0x800CCC0F;emotes=;flags=;id=929243d4-3a4c-4cd6-ad78-31faa187d4f5;login=0x800ccc0f;mod=0;msg-id=sub;msg-param-cumulative-months=1;msg-param-months=0;msg-param-multimonth-duration=6;msg-param-multimonth-tenure=0;msg-param-should-share-streak=0;msg-param-sub-plan-name=The\\sGeologists;msg-param-sub-plan=1000;msg-param-was-gifted=false;room-id=49399878;subscriber=1;system-msg=0x800CCC0F\\ssubscribed\\sat\\sTier\\s1.;tmi-sent-ts=1695428795186;user-id=128451573;user-type=;vip=0 :tmi.twitch.tv USERNOTICE #sirstendec"
},
{
"name": "Mass Gift Sub (Tier 1, 5 Total)",
"data": [
"@badge-info=;badges=premium/1;color=;display-name=HellbirDza;emotes=;flags=;id=d0e4a1d9-75c7-406d-8423-cfa3dfb514b5;login=hellbirdza;mod=0;msg-id=submysterygift;msg-param-mass-gift-count=5;msg-param-origin-id=ee\\se2\\s3f\\s01\\s75\\s45\\se1\\sfa\\s24\\s47\\s29\\s08\\sdb\\sf8\\sab\\se1\\s56\\s27\\s83\\s2e;msg-param-sender-count=15;msg-param-sub-plan=1000;room-id=26261471;subscriber=0;system-msg=HellbirDza\\sis\\sgifting\\s5\\sTier\\s1\\sSubs\\sto\\sAsmongold's\\scommunity!\\sThey've\\sgifted\\sa\\stotal\\sof\\s15\\sin\\sthe\\schannel!;tmi-sent-ts=1671302976346;user-id=28678305;user-type= :tmi.twitch.tv USERNOTICE #asmongold",
"@badge-info=;badges=premium/1;color=;display-name=HellbirDza;emotes=;flags=;id=182675e7-db1b-49d3-9650-54c31d938203;login=hellbirdza;mod=0;msg-id=subgift;msg-param-gift-months=1;msg-param-months=1;msg-param-origin-id=ee\\se2\\s3f\\s01\\s75\\s45\\se1\\sfa\\s24\\s47\\s29\\s08\\sdb\\sf8\\sab\\se1\\s56\\s27\\s83\\s2e;msg-param-recipient-display-name=buddyunique1;msg-param-recipient-id=431251927;msg-param-recipient-user-name=buddyunique1;msg-param-sender-count=0;msg-param-sub-plan-name=Channel\\sSubscription\\s(asmongold);msg-param-sub-plan=1000;room-id=26261471;subscriber=0;system-msg=HellbirDza\\sgifted\\sa\\sTier\\s1\\ssub\\sto\\sbuddyunique1!;tmi-sent-ts=1671302976704;user-id=28678305;user-type= :tmi.twitch.tv USERNOTICE #asmongold",
"@badge-info=;badges=premium/1;color=;display-name=HellbirDza;emotes=;flags=;id=594ce86d-f956-43cd-8b5d-1b7e8499dca1;login=hellbirdza;mod=0;msg-id=subgift;msg-param-gift-months=1;msg-param-months=1;msg-param-origin-id=ee\\se2\\s3f\\s01\\s75\\s45\\se1\\sfa\\s24\\s47\\s29\\s08\\sdb\\sf8\\sab\\se1\\s56\\s27\\s83\\s2e;msg-param-recipient-display-name=tartarin_e;msg-param-recipient-id=144049812;msg-param-recipient-user-name=tartarin_e;msg-param-sender-count=0;msg-param-sub-plan-name=Channel\\sSubscription\\s(asmongold);msg-param-sub-plan=1000;room-id=26261471;subscriber=0;system-msg=HellbirDza\\sgifted\\sa\\sTier\\s1\\ssub\\sto\\startarin_e!;tmi-sent-ts=1671302976722;user-id=28678305;user-type= :tmi.twitch.tv USERNOTICE #asmongold",
"@badge-info=;badges=premium/1;color=;display-name=HellbirDza;emotes=;flags=;id=527abc39-e599-4c1d-a480-e724a9c69823;login=hellbirdza;mod=0;msg-id=subgift;msg-param-gift-months=1;msg-param-months=1;msg-param-origin-id=ee\\se2\\s3f\\s01\\s75\\s45\\se1\\sfa\\s24\\s47\\s29\\s08\\sdb\\sf8\\sab\\se1\\s56\\s27\\s83\\s2e;msg-param-recipient-display-name=haaryho_stracene_vlasy;msg-param-recipient-id=96664018;msg-param-recipient-user-name=haaryho_stracene_vlasy;msg-param-sender-count=0;msg-param-sub-plan-name=Channel\\sSubscription\\s(asmongold);msg-param-sub-plan=1000;room-id=26261471;subscriber=0;system-msg=HellbirDza\\sgifted\\sa\\sTier\\s1\\ssub\\sto\\shaaryho_stracene_vlasy!;tmi-sent-ts=1671302976759;user-id=28678305;user-type= :tmi.twitch.tv USERNOTICE #asmongold",
"@badge-info=;badges=premium/1;color=;display-name=HellbirDza;emotes=;flags=;id=f694b0fc-0b5e-4adf-8002-03dae340e9b5;login=hellbirdza;mod=0;msg-id=subgift;msg-param-gift-months=1;msg-param-months=1;msg-param-origin-id=ee\\se2\\s3f\\s01\\s75\\s45\\se1\\sfa\\s24\\s47\\s29\\s08\\sdb\\sf8\\sab\\se1\\s56\\s27\\s83\\s2e;msg-param-recipient-display-name=corette0;msg-param-recipient-id=149790291;msg-param-recipient-user-name=corette0;msg-param-sender-count=0;msg-param-sub-plan-name=Channel\\sSubscription\\s(asmongold);msg-param-sub-plan=1000;room-id=26261471;subscriber=0;system-msg=HellbirDza\\sgifted\\sa\\sTier\\s1\\ssub\\sto\\scorette0!;tmi-sent-ts=1671302976767;user-id=28678305;user-type= :tmi.twitch.tv USERNOTICE #asmongold",
"@badge-info=;badges=premium/1;color=;display-name=HellbirDza;emotes=;flags=;id=5893d8a8-5eb3-46d6-9737-f1b2b76400d4;login=hellbirdza;mod=0;msg-id=subgift;msg-param-gift-months=1;msg-param-months=2;msg-param-origin-id=ee\\se2\\s3f\\s01\\s75\\s45\\se1\\sfa\\s24\\s47\\s29\\s08\\sdb\\sf8\\sab\\se1\\s56\\s27\\s83\\s2e;msg-param-recipient-display-name=yo_adg;msg-param-recipient-id=465861822;msg-param-recipient-user-name=yo_adg;msg-param-sender-count=0;msg-param-sub-plan-name=Channel\\sSubscription\\s(asmongold);msg-param-sub-plan=1000;room-id=26261471;subscriber=0;system-msg=HellbirDza\\sgifted\\sa\\sTier\\s1\\ssub\\sto\\syo_adg!;tmi-sent-ts=1671302976798;user-id=28678305;user-type= :tmi.twitch.tv USERNOTICE #asmongold"
]
},
{
"name": "Hype Chat (Message)",
"data": "@badge-info=;badges=partner/1;color=#D6A3FF;display-name=GalaxyAUS;emotes=;first-msg=0;flags=;id=ab02f65f-c600-4e95-a97d-48163f686830;mod=0;pinned-chat-paid-amount=170;pinned-chat-paid-canonical-amount=170;pinned-chat-paid-currency=AUD;pinned-chat-paid-exponent=2;pinned-chat-paid-is-system-message=0;pinned-chat-paid-level=ONE;returning-chatter=0;room-id=29158331;subscriber=0;tmi-sent-ts=1687494193366;turbo=0;user-id=68458131;user-type= :galaxyaus!galaxyaus@galaxyaus.tmi.twitch.tv PRIVMSG #cardboard_cowboy :SCIENCE"
},
{
"name": "Hype Chat (No Message)",
"data": "@badge-info=;badges=;color=#00FF7F;display-name=옥메왁까;emotes=;first-msg=0;flags=;id=36999eea-314c-4bb8-8cc2-8be1175c3e31;mod=0;pinned-chat-paid-amount=7500;pinned-chat-paid-canonical-amount=7500;pinned-chat-paid-currency=KRW;pinned-chat-paid-exponent=0;pinned-chat-paid-is-system-message=1;pinned-chat-paid-level=TWO;returning-chatter=0;room-id=148057505;subscriber=0;tmi-sent-ts=1687458800209;turbo=0;user-id=184488771;user-type= :okmewaka!okmewaka@okmewaka.tmi.twitch.tv PRIVMSG #tmxk319 :User sent Hype Chat"
},
{
"name": "Hype Chat (PubSub)",
"topic": "pinned-chat-updates-v1.<channel>",
"data": {"type":"pin-message","data":{"id":"deea93ac-66c7-4500-aa38-d9cfb82f14bc","pinned_by":{"id":"128900149","display_name":"JailBreakRules"},"message":{"id":"deea93ac-66c7-4500-aa38-d9cfb82f14bc","sender":{"id":"128900149","display_name":"JailBreakRules","badges":[{"id":"subscriber","version":"12"},{"id":"glhf-pledge","version":"1"}],"chat_color":"#F31995"},"content":{"text":"xQc gets 70 cents off this lmfao","fragments":[{"text":"xQc gets 70 cents off this lmfao"}]},"type":"PAID","starts_at":1687737336,"updated_at":1687737336,"ends_at":1687737366,"sent_at":1687737336,"metadata":{"amount":"100","canonical-amount":"100","currency":"USD","exponent":"2","isSystemMessage":"false","level":"ONE"}}}}
},
{
"name": "Drop Claim Reward (PubSub)",
"topic": "user-drop-events.<user>",
"data": {"type":"drop-claim","data":{"drop_instance_id":"9f21b210-63b0-4725-be46-b8e49207f533","drop_id":"4da46d69-269e-4709-baf0-1dc62dcf39b2","channel_id":"118336478"}}
}
]

View file

@ -96,6 +96,10 @@ export default {
return this.isDefault || this.validate(this.value); return this.isDefault || this.validate(this.value);
}, },
isReadOnly() {
return this.profile && this.profile.ephemeral
},
sourceOrder() { sourceOrder() {
return this.source ? this.source.order : Infinity return this.source ? this.source.order : Infinity
}, },
@ -210,6 +214,8 @@ export default {
set(value) { set(value) {
// TODO: Run validation. // TODO: Run validation.
if ( this.isReadOnly )
return;
let process = this.item.process; let process = this.item.process;
if ( process ) { if ( process ) {
@ -228,6 +234,9 @@ export default {
}, },
clear() { clear() {
if ( this.isReadOnly )
return;
this.profile.delete(this.item.setting); this.profile.delete(this.item.setting);
if ( this.item.onUIChange ) if ( this.item.onUIChange )

View file

@ -361,6 +361,14 @@ export default class Metadata extends Module {
} }
} }
// Get the video element.
if ( stats ) {
const video = player && maybe_call(player.getHTMLVideoElement, player);
stats.avOffset = 0;
if ( video?._ffz_context )
stats.avOffset = (video._ffz_context_offset ?? 0) + video._ffz_context.currentTime - video.currentTime;
}
let tampered = false; let tampered = false;
try { try {
const url = player.core.state.path; const url = player.core.state.path;
@ -370,7 +378,6 @@ export default class Metadata extends Module {
} }
} catch(err) { /* no op */ } } catch(err) { /* no op */ }
if ( ! stats || stats.hlsLatencyBroadcaster < -100 ) if ( ! stats || stats.hlsLatencyBroadcaster < -100 )
return {stats}; return {stats};
@ -493,6 +500,24 @@ export default class Metadata extends Module {
stats stats
); );
const desync = /*data.avOffset !== 0
? (<div>{this.i18n.t(
'metadata.player-stats.av-offset',
'A/V Offset: {avOffset, number} seconds',
stats
)}</div>)
:*/ null;
const buffer = stats.bufferSize > 0
? (<div>{this.i18n.t(
'metadata.player-stats.buffered',
'Buffered: {buffered} seconds',
{
buffered: stats.bufferSize.toFixed(2)
}
)}</div>)
: null;
if ( data.old ) if ( data.old )
return [ return [
delayed, delayed,
@ -510,6 +535,8 @@ export default class Metadata extends Module {
<div class="tw-pd-t-05"> <div class="tw-pd-t-05">
{video_info} {video_info}
</div>, </div>,
desync,
buffer,
tampered tampered
]; ];
@ -522,6 +549,8 @@ export default class Metadata extends Module {
<div class="tw-pd-t-05"> <div class="tw-pd-t-05">
{video_info} {video_info}
</div>, </div>,
desync,
buffer,
tampered tampered
]; ];
} }
@ -832,6 +861,7 @@ export default class Metadata extends Module {
const tooltip = maybe_call(def.tooltip, this, data); const tooltip = maybe_call(def.tooltip, this, data);
if ( el.tip_content !== tooltip ) { if ( el.tip_content !== tooltip ) {
el.tip_content = tooltip; el.tip_content = tooltip;
el.tip.element.innerHTML = '';
setChildren(el.tip.element, tooltip); setChildren(el.tip.element, tooltip);
} }
} }

View file

@ -89,7 +89,11 @@ export default class TooltipProvider extends Module {
} }
getRoot() { // eslint-disable-line class-methods-use-this getRoot() { // eslint-disable-line class-methods-use-this
return document.querySelector('.sunlight-root') || document.querySelector('#root>div') || document.querySelector('#root') || document.querySelector('.clips-root') || document.body; return document.querySelector('.sunlight-root') ||
//document.querySelector('#root>div') ||
document.querySelector('#root') ||
document.querySelector('.clips-root') ||
document.body;
} }
_createInstance(container, klass = 'ffz-tooltip', default_type = 'text', tip_container) { _createInstance(container, klass = 'ffz-tooltip', default_type = 'text', tip_container) {

View file

@ -13,6 +13,9 @@ import SettingsManager from './settings/index';
import AddonManager from './addons'; import AddonManager from './addons';
import ExperimentManager from './experiments'; import ExperimentManager from './experiments';
import {TranslationManager} from './i18n'; import {TranslationManager} from './i18n';
import StagingSelector from './staging';
import PubSubClient from './pubsub';
import LoadTracker from './load_tracker';
import Site from './sites/player'; import Site from './sites/player';
class FrankerFaceZ extends Module { class FrankerFaceZ extends Module {
@ -49,6 +52,9 @@ class FrankerFaceZ extends Module {
this.inject('settings', SettingsManager); this.inject('settings', SettingsManager);
this.inject('experiments', ExperimentManager); this.inject('experiments', ExperimentManager);
this.inject('i18n', TranslationManager); this.inject('i18n', TranslationManager);
this.inject('staging', StagingSelector);
this.inject('load_tracker', LoadTracker);
this.inject('pubsub', PubSubClient);
this.inject('site', Site); this.inject('site', Site);
this.inject('addons', AddonManager); this.inject('addons', AddonManager);
@ -115,21 +121,23 @@ ${typeof x[1] === 'string' ? x[1] : JSON.stringify(x[1], null, 4)}`).join('\n\n'
FrankerFaceZ.Logger = Logger; FrankerFaceZ.Logger = Logger;
const VER = FrankerFaceZ.version_info = { const VER = FrankerFaceZ.version_info = Object.freeze({
major: __version_major__, major: __version_major__,
minor: __version_minor__, minor: __version_minor__,
revision: __version_patch__, revision: __version_patch__,
extra: __version_prerelease__?.length && __version_prerelease__[0], extra: __version_prerelease__?.length && __version_prerelease__[0],
commit: __git_commit__, commit: __git_commit__,
build: __webpack_hash__, build: __version_build__,
hash: __webpack_hash__,
toString: () => toString: () =>
`${VER.major}.${VER.minor}.${VER.revision}${VER.extra || ''}${DEBUG ? '-dev' : ''}` `${VER.major}.${VER.minor}.${VER.revision}${VER.build ? `.${VER.build}` : ''}${VER.extra || ''}${DEBUG ? '-dev' : ''}`
} });
// We don't support addons in the player right now, so a few // We don't support addons in the player right now, so a few
// of these are unavailable. // of these are unavailable.
FrankerFaceZ.utilities = { FrankerFaceZ.utilities = {
addon: require('utilities/addon'), addon: require('utilities/addon'),
blobs: require('utilities/blobs'),
//color: require('utilities/color'), //color: require('utilities/color'),
constants: require('utilities/constants'), constants: require('utilities/constants'),
dom: require('utilities/dom'), dom: require('utilities/dom'),

258
src/pubsub/index.js Normal file
View file

@ -0,0 +1,258 @@
'use strict';
// ============================================================================
// PubSub Client
// ============================================================================
import Module from 'utilities/module';
import {DEBUG, PUBSUB_CLUSTERS} from 'utilities/constants';
export const State = {
DISCONNECTED: 0,
CONNECTING: 1,
CONNECTED: 2
}
export default class PubSubClient extends Module {
constructor(...args) {
super(...args);
this.inject('settings');
this.inject('experiments');
this.settings.add('pubsub.use-cluster', {
default: 'Staging',
ui: {
path: 'Debugging @{"expanded": false, "sort": 9999} > PubSub >> General',
title: 'Server Cluster',
description: 'Which server cluster to connect to. You can use this setting to disable PubSub if you want, but should otherwise leave this on the default value unless you know what you\'re doing.',
force_seen: true,
component: 'setting-select-box',
data: [{
value: null,
title: 'Disabled'
}].concat(Object.keys(PUBSUB_CLUSTERS).map(x => ({
value: x,
title: x
})))
},
changed: () => {
if ( this.experiments.getAssignment('pubsub') )
this.reconnect();
}
});
this._topics = new Map;
this._client = null;
this._state = 0;
}
loadMQTT() {
if ( this._mqtt )
return Promise.resolve(this._mqtt);
if ( this._mqtt_loader )
return new Promise((s,f) => this._mqtt_loader.push([s,f]));
return new Promise((s,f) => {
const loaders = this._mqtt_loader = [[s,f]];
import('u8-mqtt')
.then(thing => {
this._mqtt = thing;
this._mqtt_loader = null;
for(const pair of loaders)
pair[0](thing);
})
.catch(err => {
this._mqtt_loader = null;
for(const pair of loaders)
pair[1](err);
});
});
}
onEnable() {
// Check to see if we should be using PubSub.
if ( ! this.experiments.getAssignment('pubsub') )
return;
this.connect();
}
onDisable() {
this.disconnect();
}
// ========================================================================
// Properties
// ========================================================================
get connected() {
return this._state === State.CONNECTED;
}
get connecting() {
return this._state === State.CONNECTING;
}
get disconnected() {
return this._state === State.DISCONNECTED;
}
// ========================================================================
// Connection Logic
// ========================================================================
reconnect() {
this.disconnect();
this.connect();
}
async connect() {
if ( this._client )
return;
let cluster_id = this.settings.get('pubsub.use-cluster');
if ( cluster_id === null )
return;
let cluster = PUBSUB_CLUSTERS[cluster_id];
// If we didn't get a valid cluster, use production.
if ( ! cluster?.length ) {
cluster_id = 'Production';
cluster = PUBSUB_CLUSTERS.Production;
}
this.log.info(`Using Cluster: ${cluster_id}`);
this._state = State.CONNECTING;
let client;
try {
const mqtt = await this.loadMQTT();
client = this._client = mqtt.mqtt_v5({
})
.with_websock(cluster)
.with_autoreconnect();
await client.connect({
client_id: [`ffz_${FrankerFaceZ.version_info}--`, '']
});
this._state = State.CONNECTED;
} catch(err) {
this._state = State.DISCONNECTED;
if ( this._client )
try {
this._client.end(true);
} catch(err) { /* no-op */ }
this._client = null;
throw err;
}
client.on_topic('*', pkt => {
const topic = pkt.topic;
let data;
try {
data = pkt.json();
} catch(err) {
this.log.warn(`Error decoding PubSub message on topic "${topic}":`, err);
return;
}
if ( ! data?.cmd ) {
this.log.warn(`Received invalid PubSub message on topic "${topic}":`, data);
return;
}
data.topic = topic;
this.log.debug(`Received command on topic "${topic}" for command "${data.cmd}":`, data.data);
this.emit(`socket:command:${data.cmd}`, data.data, data);
});
// Subscribe to topics.
const topics = [...this._topics.keys()];
client.subscribe(topics);
}
disconnect() {
if ( ! this._client )
return;
this._client.disconnect();
this._client = null;
this._state = State.DISCONNECTED;
}
// ========================================================================
// Topics
// ========================================================================
subscribe(referrer, ...topics) {
const t = this._topics;
let changed = false;
for(const topic of topics) {
if ( ! t.has(topic) ) {
if ( this._client )
this._client.subscribe(topic);
t.set(topic, new Set);
changed = true;
}
const tp = t.get(topic);
tp.add(referrer);
}
if ( changed )
this.emit(':sub-change');
}
unsubscribe(referrer, ...topics) {
const t = this._topics;
let changed = false;
for(const topic of topics) {
if ( ! t.has(topic) )
continue;
const tp = t.get(topic);
tp.delete(referrer);
if ( ! tp.size ) {
changed = true;
t.delete(topic);
if ( this._client )
this._client.unsubscribe(topic);
}
}
if ( changed )
this.emit(':sub-change');
}
get topics() {
return Array.from(this._topics.keys());
}
}
PubSubClient.State = State;

View file

@ -0,0 +1,99 @@
<template>
<section class="tw-flex-grow-1 tw-align-self-start">
<div class="tw-flex tw-align-items-center">
<label :for="'label$' + id">
{{ t(type.i18n, type.title) }}
</label>
<select
:id="'label$' + id"
v-model="selected"
class="tw-flex-grow-1 tw-mg-l-1 tw-border-radius-medium tw-font-size-6 tw-pd-x-1 tw-pd-y-05 ffz-select"
>
<template v-for="(mon, idx) in monitors">
<option :value="mon">
{{ idx + 1 }}. {{ mon.label }} ({{ mon.width }}&times;{{ mon.height }})
</option>
</template>
</select>
</div>
<div class="tw-c-text-alt-2">
{{ t('setting.filter.monitor.about', 'This setting requires that this site has the Window Management permission. Please be sure that it is allowed.') }}
</div>
</section>
</template>
<script>
import { matchScreen } from 'utilities/object';
let last_id = 0;
export default {
props: ['value', 'type', 'filters', 'context'],
data() {
return {
id: last_id++,
has_monitors: true,
monitors: [],
ready: false,
selected: null
}
},
created() {
this.detectMonitors();
},
watch: {
selected() {
if ( ! this.ready || ! this.selected )
return;
const data = this.value.data = this.value.data || {};
data.label = this.selected.label;
data.index = this.monitors.indexOf(this.selected);
data.top = this.selected.top;
data.left = this.selected.left;
data.width = this.selected.width;
data.height = this.selected.height;
}
},
methods: {
async detectMonitors() {
let data;
try {
data = await window.getScreenDetails();
} catch(err) {
console.error('Unable to get screen details', err);
this.has_monitors = false;
this.monitors = [];
return;
}
this.monitors = [];
for(const mon of data.screens)
this.monitors.push({
top: mon.top,
left: mon.left,
label: mon.label,
width: mon.width,
height: mon.height
});
//sortScreens(this.monitors);
if ( this.value.data )
this.selected = matchScreen(this.monitors, this.value.data);
this.ready = true;
if ( ! this.selected )
this.selected = this.monitors[0];
}
}
}
</script>

View file

@ -4,7 +4,7 @@
// Profile Filters for Settings // Profile Filters for Settings
// ============================================================================ // ============================================================================
import {glob_to_regex, escape_regex} from 'utilities/object'; import {glob_to_regex, escape_regex, matchScreen} from 'utilities/object';
import {createTester} from 'utilities/filtering'; import {createTester} from 'utilities/filtering';
import { DEBUG } from 'utilities/constants'; import { DEBUG } from 'utilities/constants';
@ -37,6 +37,21 @@ export const Invert = {
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/nested.vue') editor: () => import(/* webpackChunkName: 'main-menu' */ './components/nested.vue')
}; };
export const And = {
createTest(config, rule_types, rebuild) {
return createTester(config, rule_types, false, false, rebuild);
},
childRules: true,
tall: true,
title: 'And',
i18n: 'settings.filter.and',
default: () => [],
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/nested.vue')
};
export const Or = { export const Or = {
createTest(config, rule_types, rebuild) { createTest(config, rule_types, rebuild) {
return createTester(config, rule_types, false, true, rebuild); return createTester(config, rule_types, false, true, rebuild);
@ -83,7 +98,7 @@ export const Constant = {
default: true, default: true,
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue') editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
} };
// Context Stuff // Context Stuff
@ -157,7 +172,11 @@ export const Time = {
export const TheaterMode = { export const TheaterMode = {
createTest(config) { createTest(config) {
return ctx => ctx.ui && ctx.ui.theatreModeEnabled === config; return ctx => {
if ( ctx.fullscreen )
return config === false;
return ctx.ui && ctx.ui.theatreModeEnabled === config;
}
}, },
title: 'Theater Mode', title: 'Theater Mode',
@ -168,6 +187,19 @@ export const TheaterMode = {
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue') editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
}; };
export const Fullscreen = {
createTest(config) {
return ctx => ctx.fullscreen === config;
},
title: 'Fullscreen',
i18n: 'settings.filter.fullscreen',
default: true,
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
};
export const Moderator = { export const Moderator = {
createTest(config) { createTest(config) {
return ctx => ctx.moderator === config; return ctx => ctx.moderator === config;
@ -375,3 +407,59 @@ export const Title = {
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/title.vue') editor: () => import(/* webpackChunkName: 'main-menu' */ './components/title.vue')
}; };
// Monitor Stuff
export let Monitor = null;
if ( window.getScreenDetails ) {
Monitor = {
_used: false,
details: undefined,
used: () => {
const out = Monitor._used;
Monitor._used = false;
return out;
},
createTest(config = {}, _, reload) {
if ( ! config.label )
return () => false;
Monitor._used = true;
if ( Monitor.details === undefined ) {
const FFZ = window.FrankerFaceZ ?? window.FFZBridge;
if ( FFZ )
FFZ.get().resolve('settings').createMonitorUpdate().then(() => {
reload();
});
}
return () => {
Monitor._used = true;
const details = Monitor.details,
screen = details?.currentScreen;
if ( ! screen )
return false;
const sorted = details.screens, // sortScreens(Array.from(details.screens)),
matched = matchScreen(sorted, config);
return matched === screen;
};
},
default: () => ({
label: null
}),
title: 'Current Monitor',
i18n: 'settings.filter.monitor',
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/monitor.vue')
};
}

View file

@ -110,7 +110,7 @@ export default class SettingsManager extends Module {
this.filters = {}; this.filters = {};
for(const key in FILTERS) for(const key in FILTERS)
if ( has(FILTERS, key) ) if ( has(FILTERS, key) && FILTERS[key] )
this.filters[key] = FILTERS[key]; this.filters[key] = FILTERS[key];
@ -184,7 +184,6 @@ export default class SettingsManager extends Module {
} }
addFilter(key, data) { addFilter(key, data) {
if ( this.filters[key] ) if ( this.filters[key] )
return this.log.warn('Tried to add already existing filter', key); return this.log.warn('Tried to add already existing filter', key);
@ -247,8 +246,28 @@ export default class SettingsManager extends Module {
} }
async createMonitorUpdate() {
const Monitor = FILTERS?.Monitor;
if ( ! Monitor || Monitor.details !== undefined )
return;
Monitor.details = null;
try {
Monitor.details = await window.getScreenDetails();
Monitor.details.addEventListener('currentscreenchange', () => {
for(const context of this.__contexts)
context.selectProfiles();
});
} catch(err) {
this.log.error('Unable to get monitor details', err);
Monitor.details = false;
}
}
updateClock() { updateClock() {
const captured = require('./filters').Time.captured(); const captured = FILTERS?.Time?.captured?.();
if ( ! captured?.length ) if ( ! captured?.length )
return; return;
@ -743,13 +762,22 @@ export default class SettingsManager extends Module {
old_ids = new Set(old_profiles.map(x => x.id)), old_ids = new Set(old_profiles.map(x => x.id)),
new_ids = new Set, new_ids = new Set,
changed_ids = new Set, changed_ids = new Set;
raw_profiles = this.provider.get('profiles', [ let raw_profiles = this.provider.get('profiles', [
SettingsProfile.Moderation, SettingsProfile.Moderation,
SettingsProfile.Default SettingsProfile.Default
]); ]);
// Sanity check. If we have no profiles, delete the old data.
if ( ! raw_profiles?.length ) {
this.provider.delete('profiles');
raw_profiles = [
SettingsProfile.Moderation,
SettingsProfile.Default
];
}
let reordered = false, let reordered = false,
changed = false; changed = false;
@ -831,6 +859,9 @@ export default class SettingsManager extends Module {
* @returns {SettingsProfile} * @returns {SettingsProfile}
*/ */
createProfile(options) { createProfile(options) {
if ( ! this.enabled )
throw new Error('Unable to create profile before settings have initialized. Please await enable()');
let i = 0; let i = 0;
while( this.__profile_ids[i] ) while( this.__profile_ids[i] )
i++; i++;
@ -858,6 +889,9 @@ export default class SettingsManager extends Module {
* @param {number|SettingsProfile} id - The profile to delete * @param {number|SettingsProfile} id - The profile to delete
*/ */
deleteProfile(id) { deleteProfile(id) {
if ( ! this.enabled )
throw new Error('Unable to delete profile before settings have initialized. Please await enable()');
if ( typeof id === 'object' && id.id != null ) if ( typeof id === 'object' && id.id != null )
id = id.id; id = id.id;
@ -885,6 +919,9 @@ export default class SettingsManager extends Module {
moveProfile(id, index) { moveProfile(id, index) {
if ( ! this.enabled )
throw new Error('Unable to move profiles before settings have initialized. Please await enable()');
if ( typeof id === 'object' && id.id ) if ( typeof id === 'object' && id.id )
id = id.id; id = id.id;
@ -905,6 +942,9 @@ export default class SettingsManager extends Module {
saveProfile(id) { saveProfile(id) {
if ( ! this.enabled )
throw new Error('Unable to save profile before settings have initialized. Please await enable()');
if ( typeof id === 'object' && id.id ) if ( typeof id === 'object' && id.id )
id = id.id; id = id.id;
@ -918,7 +958,19 @@ export default class SettingsManager extends Module {
_saveProfiles() { _saveProfiles() {
this.provider.set('profiles', this.__profiles.map(prof => prof.data)); const out = this.__profiles.filter(prof => ! prof.ephemeral).map(prof => prof.data);
// Ensure that we always have a non-ephemeral profile.
if ( ! out ) {
this.createProfile({
name: 'Default Profile',
i18n_key: 'setting.profiles.default',
description: 'Settings that apply everywhere on Twitch.'
});
return;
}
this.provider.set('profiles', out);
for(const context of this.__contexts) for(const context of this.__contexts)
context.selectProfiles(); context.selectProfiles();
@ -941,11 +993,45 @@ export default class SettingsManager extends Module {
setContext(context) { return this.main_context.setContext(context) } setContext(context) { return this.main_context.setContext(context) }
// ========================================================================
// Add-On Proxy
// ========================================================================
getAddonProxy(module) {
const path = module.__path;
const add = (key, definition) => {
return this.add(key, definition, path);
}
const addUI = (key, definition) => {
return this.addUI(key, definition, path);
}
const addClearable = (key, definition) => {
return this.addClearable(key, definition, path);
}
const handler = {
get(obj, prop) {
if ( prop === 'add' )
return add;
if ( prop === 'addUI' )
return addUI;
if ( prop === 'addClearable' )
return addClearable;
return Reflect.get(...arguments);
}
}
return new Proxy(this, handler);
}
// ======================================================================== // ========================================================================
// Definitions // Definitions
// ======================================================================== // ========================================================================
add(key, definition) { add(key, definition, source) {
if ( typeof key === 'object' ) { if ( typeof key === 'object' ) {
for(const k in key) for(const k in key)
if ( has(key, k) ) if ( has(key, k) )
@ -960,6 +1046,8 @@ export default class SettingsManager extends Module {
definition.required_by = required_by; definition.required_by = required_by;
definition.requires = definition.requires || []; definition.requires = definition.requires || [];
definition.__source = source;
for(const req_key of definition.requires) { for(const req_key of definition.requires) {
const req = this.definitions.get(req_key); const req = this.definitions.get(req_key);
if ( ! req ) if ( ! req )
@ -1007,7 +1095,42 @@ export default class SettingsManager extends Module {
} }
addUI(key, definition) { remove(key) {
const definition = this.definitions.get(key);
if ( ! definition )
return;
// If the definition is an array, we're already not defined.
if ( Array.isArray(definition) )
return;
// Remove this definition from the definitions list.
if ( Array.isArray(definition.required_by) && definition.required_by.length > 0 )
this.definitions.set(key, definition.required_by);
else
this.definitions.delete(key);
// Remove it from all the things it required.
if ( Array.isArray(definition.requires) )
for(const req_key of definition.requires) {
let req = this.definitions.get(req_key);
if ( req.required_by )
req = req.required_by;
if ( Array.isArray(req) ) {
const idx = req.indexOf(key);
if ( idx !== -1 )
req.splice(idx, 1);
}
}
if ( definition.changed )
this.off(`:changed:${key}`, definition.changed);
this.emit(':removed-definition', key, definition);
}
addUI(key, definition, source) {
if ( typeof key === 'object' ) { if ( typeof key === 'object' ) {
for(const k in key) for(const k in key)
if ( has(key, k) ) if ( has(key, k) )
@ -1018,6 +1141,8 @@ export default class SettingsManager extends Module {
if ( ! definition.ui ) if ( ! definition.ui )
definition = {ui: definition}; definition = {ui: definition};
definition.__source = source;
const ui = definition.ui; const ui = definition.ui;
ui.path_tokens = ui.path_tokens ? ui.path_tokens = ui.path_tokens ?
format_path_tokens(ui.path_tokens) : format_path_tokens(ui.path_tokens) :
@ -1038,14 +1163,16 @@ export default class SettingsManager extends Module {
} }
addClearable(key, definition) { addClearable(key, definition, source) {
if ( typeof key === 'object' ) { if ( typeof key === 'object' ) {
for(const k in key) for(const k in key)
if ( has(key, k) ) if ( has(key, k) )
this.addClearable(k, key[k]); this.addClearable(k, key[k], source);
return; return;
} }
definition.__source = source;
this.clearables[key] = definition; this.clearables[key] = definition;
} }

View file

@ -28,6 +28,11 @@ export default class SettingsProfile extends EventEmitter {
this.data = data; this.data = data;
this.prefix = `p:${this.id}:`; this.prefix = `p:${this.id}:`;
this.enabled_key = `${this.prefix}:enabled`; this.enabled_key = `${this.prefix}:enabled`;
if ( this.ephemeral ) {
this._enabled = true;
this._storage = new Map;
}
} }
get data() { get data() {
@ -40,6 +45,8 @@ export default class SettingsProfile extends EventEmitter {
hotkey: this.hotkey, hotkey: this.hotkey,
pause_updates: this.pause_updates, pause_updates: this.pause_updates,
ephemeral: this.ephemeral,
description: this.description, description: this.description,
desc_i18n_key: this.desc_i18n_key, desc_i18n_key: this.desc_i18n_key,
@ -56,6 +63,10 @@ export default class SettingsProfile extends EventEmitter {
this.matcher = null; this.matcher = null;
// Make sure ephemeral is set first.
if ( val.ephemeral )
this.ephemeral = true;
for(const key in val) for(const key in val)
if ( has(val, key) ) if ( has(val, key) )
this[key] = val[key]; this[key] = val[key];
@ -70,7 +81,8 @@ export default class SettingsProfile extends EventEmitter {
save() { save() {
this.manager.saveProfile(this.id); if ( ! this.ephemeral )
this.manager.saveProfile(this.id);
} }
@ -83,6 +95,8 @@ export default class SettingsProfile extends EventEmitter {
values: {} values: {}
}; };
delete out.profile.ephemeral;
for(const [k,v] of this.entries()) for(const [k,v] of this.entries())
out.values[k] = v; out.values[k] = v;
@ -99,6 +113,7 @@ export default class SettingsProfile extends EventEmitter {
return false; return false;
// We don't want to override general settings. // We don't want to override general settings.
delete data.profile.ephemeral;
delete data.profile.id; delete data.profile.id;
delete data.profile.name; delete data.profile.name;
delete data.profile.i18n_key; delete data.profile.i18n_key;
@ -186,14 +201,20 @@ export default class SettingsProfile extends EventEmitter {
// ======================================================================== // ========================================================================
get toggled() { get toggled() {
if ( this.ephemeral )
return this._enabled;
return this.provider.get(this.enabled_key, true); return this.provider.get(this.enabled_key, true);
} }
set toggled(val) { set toggled(val) {
if ( val === this.toggleState ) if ( val === this.toggled )
return; return;
this.provider.set(this.enabled_key, val); if ( this.ephemeral )
this._enabled = val;
else
this.provider.set(this.enabled_key, val);
this.emit('toggled', this, val); this.emit('toggled', this, val);
} }
@ -226,24 +247,37 @@ export default class SettingsProfile extends EventEmitter {
// ======================================================================== // ========================================================================
get(key, default_value) { get(key, default_value) {
if ( this.ephemeral )
return this._storage.get(key, default_value);
return this.provider.get(this.prefix + key, default_value); return this.provider.get(this.prefix + key, default_value);
} }
set(key, value) { set(key, value) {
this.provider.set(this.prefix + key, value); if ( this.ephemeral )
this._storage.set(key, value);
else
this.provider.set(this.prefix + key, value);
this.emit('changed', key, value); this.emit('changed', key, value);
} }
delete(key) { delete(key) {
this.provider.delete(this.prefix + key); if ( this.ephemeral )
this._storage.delete(key);
else
this.provider.delete(this.prefix + key);
this.emit('changed', key, undefined, true); this.emit('changed', key, undefined, true);
} }
has(key) { has(key) {
if ( this.ephemeral )
return this._storage.has(key);
return this.provider.has(this.prefix + key); return this.provider.has(this.prefix + key);
} }
keys() { keys() {
if ( this.ephemeral )
return Array.from(this._storage.keys());
const out = [], const out = [],
p = this.prefix, p = this.prefix,
len = p.length; len = p.length;
@ -256,6 +290,15 @@ export default class SettingsProfile extends EventEmitter {
} }
clear() { clear() {
if ( this.ephemeral ) {
const keys = this.keys();
this._storage.clear();
for(const key of keys) {
this.emit('changed', key, undefined, true);
}
return;
}
const p = this.prefix, const p = this.prefix,
len = p.length; len = p.length;
for(const key of this.provider.keys()) for(const key of this.provider.keys())
@ -266,15 +309,24 @@ export default class SettingsProfile extends EventEmitter {
} }
*entries() { *entries() {
const p = this.prefix, if ( this.ephemeral ) {
len = p.length; for(const entry of this._storage.entries())
yield entry;
for(const key of this.provider.keys()) } else {
if ( key.startsWith(p) && key !== this.enabled_key ) const p = this.prefix,
yield [key.slice(len), this.provider.get(key)]; 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)];
}
} }
get size() { get size() {
if ( this.ephemeral )
return this._storage.size;
const p = this.prefix; const p = this.prefix;
let count = 0; let count = 0;

View file

@ -1,6 +1,8 @@
'use strict'; 'use strict';
import { isValidBlob, deserializeBlob, serializeBlob } from 'src/utilities/blobs'; import { isValidBlob, deserializeBlob, serializeBlob } from 'utilities/blobs';
import { EXTENSION } from 'utilities/constants';
// ============================================================================ // ============================================================================
// Settings Providers // Settings Providers
// ============================================================================ // ============================================================================
@ -1027,9 +1029,9 @@ export class CrossOriginStorageBridge extends SettingsProvider {
this._last_id = 0; this._last_id = 0;
const frame = this.frame = document.createElement('iframe'); const frame = this.frame = document.createElement('iframe');
frame.src = this.manager.root.host === 'twitch' ? frame.src = this.manager.root.host === 'youtube' ?
'//www.twitch.tv/p/ffz_bridge/' : '//www.youtube.com/__ffz_bridge/' :
'//www.youtube.com/__ffz_bridge/'; '//www.twitch.tv/p/ffz_bridge/';
frame.id = 'ffz-settings-bridge'; frame.id = 'ffz-settings-bridge';
frame.style.width = 0; frame.style.width = 0;
frame.style.height = 0; frame.style.height = 0;
@ -1041,7 +1043,7 @@ export class CrossOriginStorageBridge extends SettingsProvider {
// Static Properties // Static Properties
static supported(manager) { return manager.root.host === 'twitch' ? NOT_WWW_TWITCH : NOT_WWW_YT; } static supported() { return NOT_WWW_TWITCH && NOT_WWW_YT; }
static hasContent(manager) { return CrossOriginStorageBridge.supported(manager); } static hasContent(manager) { return CrossOriginStorageBridge.supported(manager); }
static key = 'cosb'; static key = 'cosb';
@ -1255,3 +1257,268 @@ export class CrossOriginStorageBridge extends SettingsProvider {
cbs[success ? 0 : 1](msg); cbs[success ? 0 : 1](msg);
} }
} }
// ============================================================================
// ExtensionStorage
// ============================================================================
/*export class ExtensionStorageBridge extends SettingsProvider {
constructor(manager, start = true) {
super(manager);
this._start_time = performance.now();
this._rpc = new Map;
this._cached = new Map;
this.resolved_ready = false;
this.ready = false;
this._ready_wait = null;
this._last_id = 0;
if ( start ) {
window.addEventListener('message', this.onMessage.bind(this));
this.send('init');
}
}
// Static Properties
static supported() { return EXTENSION && document.documentElement.dataset.ffzEsbridge; }
static hasContent(manager) {
if ( ! ExtensionStorageBridge.supported(manager) )
return Promise.resolve(false);
return new Promise((s,f) => {
const onMessage = event => {
if ( event.origin !== location.origin )
return;
const msg = event.data,
type = msg?.ffz_esb_type;
if ( type === 'content-state' ) {
window.removeEventListener('message', onMessage);
s(msg.has_content);
}
else if ( type === 'error' && msg.id === -1 ) {
window.removeEventListener('message', onMessage);
s(false);
}
};
window.addEventListener('message', onMessage);
window.postMessage({ffz_esb_type: 'check-content', id: -1});
});
}
static key = 'ext';
static priority = 200;
static title = 'Extension Storage';
static description = 'This provider stores your settings within the FrankerFaceZ extension context, rather than the webpage context. Extension storage is independent from individual websites and should not be cleared automatically by your browser.';
static supportsBlobs = true;
// Initialization
_resolveReady(success, data) {
if ( this.manager )
this.manager.log.info(`ESB ready in ${(performance.now() - this._start_time).toFixed(5)}ms`);
this.resolved_ready = true;
this.ready = success;
const waiters = this._ready_wait;
this._ready_wait = null;
if ( waiters )
for(const pair of waiters)
pair[success ? 0 : 1](data);
}
awaitReady() {
if ( this.resolved_ready ) {
if ( this.ready )
return Promise.resolve();
return Promise.reject();
}
return new Promise((s,f) => {
const waiters = this._ready_wait = this._ready_wait || [];
waiters.push([s,f]);
})
}
// Provider Methods
get(key, default_value) {
return this._cached.has(key) ? this._cached.get(key) : default_value;
}
set(key, value) {
if ( value === undefined ) {
if ( this.has(key) )
this.delete(key);
return;
}
this._cached.set(key, value);
this.rpc({ffz_esb_type: 'set', key, value})
.catch(err => this.manager.log.error('Error setting value', err));
this.emit('set', key, value, false);
}
delete(key) {
this._cached.delete(key);
this.rpc({ffz_esb_type: 'delete', key})
.catch(err => this.manager.log.error('Error deleting value', err));
this.emit('set', key, undefined, true);
}
clear() {
const old_cache = this._cached;
this._cached = new Map;
for(const key of old_cache.keys())
this.emit('changed', key, undefined, true);
this.rpc('clear').catch(err => this.manager.log.error('Error clearing storage', err));
}
has(key) { return this._cached.has(key); }
keys() { return this._cached.keys(); }
entries() { return this._cached.entries(); }
get size() { return this._cached.size; }
async flush() {
await this.rpc('flush');
}
// Provider Methods: Blobs
async getBlob(key) {
const msg = await this.rpc({ffz_esb_type: 'get-blob', key});
return msg.reply && deserializeBlobForExt(msg.reply);
}
async setBlob(key, value) {
await this.rpc({
ffz_esb_type: 'set-blob',
key,
value: await serializeBlobForExt(value)
});
}
async deleteBlob(key) {
await this.rpc({
ffz_esb_type: 'delete-blob',
key
});
}
async hasBlob(key) {
const msg = await this.rpc({ffz_esb_type: 'has-blob', key});
return msg.reply;
}
async clearBlobs() {
await this.rpc('clear-blobs');
}
async blobKeys() {
const msg = await this.rpc('blob-keys');
return msg.reply;
}
// CORS Communication
send(msg, transfer) {
if ( typeof msg === 'string' )
msg = {ffz_esb_type: msg};
try {
window.postMessage(
msg,
location.origin,
transfer ? (Array.isArray(transfer) ? transfer : [transfer]) : undefined
);
} catch(err) {
this.manager.log.error('Error sending message to bridge.', err, msg, transfer);
}
}
rpc(msg, transfer) {
const id = ++this._last_id;
return new Promise((s,f) => {
this._rpc.set(id, [s,f]);
if ( typeof msg === 'string' )
msg = {ffz_esb_type: msg};
msg.id = id;
this.send(msg, transfer);
});
}
onMessage(event) {
if ( event.origin !== location.origin )
return;
const msg = event.data;
if ( ! msg || ! msg.ffz_esb_type )
return;
if ( msg.ffz_esb_type === 'ready' )
this.rpc('init-load').then(msg => {
for(const [key, value] of Object.entries(msg.reply.values))
this._cached.set(key, value);
this._resolveReady(true);
}).catch(err => {
this._resolveReady(false, err);
});
else if ( msg.ffz_esb_type === 'change' )
this.onChange(msg);
else if ( msg.ffz_esb_type === 'change-blob' )
this.emit('changed-blob', msg.key, msg.deleted);
else if ( msg.ffz_esb_type === 'clear-blobs' )
this.emit('clear-blobs');
else if ( msg.ffz_esb_type === 'reply' || msg.ffz_esb_type === 'reply-error' )
this.onReply(msg);
else
this.manager.log.warn('Unknown Message', msg.ffz_esb_type, msg);
}
onChange(msg) {
const key = msg.key,
value = msg.value,
deleted = msg.deleted;
if ( deleted ) {
this._cached.delete(key);
this.emit('changed', key, undefined, true);
} else {
this._cached.set(key, value);
this.emit('changed', key, value, false);
}
}
onReply(msg) {
const id = msg.id,
success = msg.ffz_esb_type === 'reply',
cbs = this._rpc.get(id);
if ( ! cbs )
return this.manager.log.warn('Received reply for unknown ID', id);
this._rpc.delete(id);
cbs[success ? 0 : 1](msg);
}
}*/

View file

@ -75,7 +75,7 @@ export const array_merge = {
default(val) { default(val) {
const values = []; const values = [];
for(const v of val) for(const v of val)
if ( v.t !== 'inherit' && v.v ) if ( v.t !== 'inherit' && v.t !== 'skip' && v.v )
values.push(v.v); values.push(v.v);
return values; return values;
@ -119,6 +119,8 @@ export const array_merge = {
had_value = true; had_value = true;
if ( val.t === 'inherit' ) if ( val.t === 'inherit' )
is_trailing = true; is_trailing = true;
else if ( val.t === 'skip' )
continue;
else if ( is_trailing ) else if ( is_trailing )
trail.push(val.v); trail.push(val.v);
else else

View file

@ -1,5 +1,8 @@
.message > div > .chat-line__message--emote { .message > div > .chat-line__message--emote {
vertical-align: baseline; vertical-align: baseline;
}
.message > div:not(.modified-emote) > .chat-line__message--emote {
padding-top: 5px; padding-top: 5px;
} }

View file

@ -0,0 +1,4 @@
.video-player__container[data-paused="true"] video,
.video-player__container[data-buffering="true"] video {
filter: grayscale(0.25) brightness(0.5);
}

View file

@ -40,7 +40,7 @@ export default class ClipsSite extends BaseSite {
this.inject('css_tweaks', CSSTweaks); this.inject('css_tweaks', CSSTweaks);
this.css_tweaks.loader = require.context( this.css_tweaks.loader = require.context(
'!raw-loader!sass-loader!./css_tweaks', false, /\.s?css$/, 'lazy-once' './css_tweaks?css_tweaks', false, /\.s?css$/, 'lazy-once'
); );
this.css_tweaks.rules = { this.css_tweaks.rules = {
@ -57,7 +57,7 @@ export default class ClipsSite extends BaseSite {
this.ClipsMenu = this.fine.define( this.ClipsMenu = this.fine.define(
'clips-menu', 'clips-menu',
n => n.props?.changeTheme && has(n.state, 'dropdownOpen') n => n.props?.signup && has(n.state, 'dropdownOpen')
) )
document.head.appendChild(createElement('link', { document.head.appendChild(createElement('link', {
@ -217,3 +217,5 @@ export default class ClipsSite extends BaseSite {
ClipsSite.CLIP_ROUTES = { ClipsSite.CLIP_ROUTES = {
'clip-page': '/:slug' 'clip-page': '/:slug'
}; };
ClipsSite.DIALOG_SELECTOR = '#root > div';

View file

@ -107,7 +107,7 @@ export default class Line extends Module {
}, user_block) }, user_block)
] : user_block); ] : user_block);
return (<div class="tw-mg-b-1" style={{marginBottom:'0 !important'}}> return (<div class="tw-mg-b-1">
<div <div
data-a-target="tw-animation-target" data-a-target="tw-animation-target"
class="ffz--clip-chat-line tw-animation tw-animation--animate tw-animation--duration-short tw-animation--fill-mode-both tw-animation--slide-in-bottom tw-animation--timing-ease" class="ffz--clip-chat-line tw-animation tw-animation--animate tw-animation--duration-short tw-animation--fill-mode-both tw-animation--slide-in-bottom tw-animation--timing-ease"
@ -200,7 +200,7 @@ export default class Line extends Module {
if ( msg.message.userBadges ) if ( msg.message.userBadges )
for(const badge of msg.message.userBadges) for(const badge of msg.message.userBadges)
if ( badge ) if ( badge?.setID )
badges[badge.setID] = badge.version; badges[badge.setID] = badge.version;
const out = { const out = {
@ -215,6 +215,7 @@ export default class Line extends Module {
roomLogin: room && room.login, roomLogin: room && room.login,
roomID: room && room.id, roomID: room && room.id,
badges, badges,
id: msg.id,
ffz_badges: this.chat.badges.getBadges(author.id, author.login, room?.id, room?.login), ffz_badges: this.chat.badges.getBadges(author.id, author.login, room?.id, room?.login),
messageParts: msg.message.fragments messageParts: msg.message.fragments
}; };

View file

@ -1,4 +1,5 @@
@import 'styles/main.scss'; @import 'styles/main.scss';
@import '../../twitch-twilight/styles/mod_card.scss';
.tw-root--theme-dark, html { .tw-root--theme-dark, html {
body { body {

View file

@ -0,0 +1,4 @@
.video-player__container[data-paused="true"] video,
.video-player__container[data-buffering="true"] video {
filter: grayscale(0.25) brightness(0.5);
}

View file

@ -1,3 +1,3 @@
.video-player__overlay[data-controls="false"][data-paused="false"][data-ended="false"] { .video-player__container[data-controls="false"][data-paused="false"][data-ended="false"] {
cursor: none; cursor: none;
} }

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