1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-09-17 02:16:54 +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'
],
'parserOptions': {
'parser': '@babel/eslint-parser',
'ecmaVersion': 8,
//'parser': '@babel/eslint-parser',
'ecmaVersion': 'latest',
'sourceType': 'module',
'ecmaFeatures': {
'jsx': true
@ -34,6 +34,7 @@ module.exports = {
'__version_minor__': false,
'__version_patch__': false,
'__version_prerelease__': false,
'__extension__': false,
'FrankerFaceZ': false
},
'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",
"code": 61642,
"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",
"es2017"
],
"target": "es2017",
"target": "esnext",
"module": "es6",
"baseUrl": ".",
"paths": {

View file

@ -1,60 +1,51 @@
{
"name": "frankerfacez",
"author": "Dan Salvato LLC",
"version": "4.32.1",
"version": "4.55.1",
"description": "FrankerFaceZ is a Twitch enhancement suite.",
"private": true,
"license": "Apache-2.0",
"scripts": {
"start": "webpack-dev-server --config webpack.web.dev.js",
"start": "pnpm dev",
"eslint": "eslint \"src/**/*.{js,jsx,vue}\"",
"clean": "rimraf dist",
"dev": "webpack-dev-server --config webpack.web.dev.js",
"dev:prod": "webpack-dev-server --config webpack.web.dev.prod.js",
"dev": "cross-env NODE_ENV=development webpack serve",
"dev:prod": "cross-env NODE_ENV=production webpack serve",
"build": "pnpm build:prod",
"build:stats": "cross-env NODE_ENV=production webpack --config webpack.web.prod.js --json > stats.json",
"build:prod": "cross-env NODE_ENV=production webpack --config webpack.web.prod.js",
"build:dev": "pnpm clean && webpack --config webpack.web.dev.js",
"build:stats": "cross-env NODE_ENV=production webpack build --json > stats.json",
"build:prod": "cross-env NODE_ENV=production webpack build",
"build:dev": "cross-env NODE_ENV=development webpack build",
"font": "pnpm font:edit",
"font:edit": "fontello-cli --cli-config fontello.client.json edit",
"font:save": "fontello-cli --cli-config fontello.client.json save && pnpm font:update",
"font:update": "node bin/update_fonts"
},
"devDependencies": {
"@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",
"@webpack-cli/serve": "^1.6.0",
"babel-loader": "^8.2.3",
"clean-webpack-plugin": "^3.0.0",
"copy-webpack-plugin": "^5.1.2",
"browserslist": "^4.21.10",
"copy-webpack-plugin": "^11.0.0",
"cross-env": "^7.0.3",
"css-loader": "^3.6.0",
"eslint": "^7.32.0",
"eslint-plugin-react": "^7.26.1",
"eslint-plugin-vue": "^6.2.2",
"extract-loader": "^2.0.1",
"file-loader": "^4.3.0",
"css-loader": "^6.8.1",
"esbuild-loader": "^4.0.2",
"eslint": "^8.48.0",
"eslint-plugin-import": "^2.28.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-vue": "^9.17.0",
"extract-loader": "^5.1.0",
"file-loader": "^6.2.0",
"json-loader": "^0.5.7",
"raw-loader": "^3.1.0",
"rimraf": "^3.0.2",
"sass": "^1.43.4",
"sass-loader": "^7.3.1",
"semver": "^7.3.5",
"terser-webpack-plugin": "4",
"vue-loader": "^15.9.8",
"minify-graphql-loader": "^1.0.2",
"raw-loader": "^4.0.2",
"rimraf": "^5.0.1",
"sass": "^1.66.1",
"sass-loader": "^13.3.2",
"semver": "^7.5.4",
"vue-loader": "^15.10.2",
"vue-template-compiler": "^2.6.14",
"webpack": "^4.46.0",
"webpack-cli": "^4.9.1",
"webpack-dev-server": "^4.4.0",
"webpack-manifest-plugin": "^4.0.2",
"webpack-merge": "^4.2.2"
"webpack": "^5.88.2",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1",
"webpack-manifest-plugin": "^5.0.0"
},
"repository": {
"type": "git",
@ -70,8 +61,8 @@
"file-saver": "^2.0.5",
"graphql": "^16.0.1",
"graphql-tag": "^2.12.6",
"jszip": "^3.7.1",
"js-cookie": "^2.2.1",
"jszip": "^3.7.1",
"markdown-it": "^12.2.0",
"markdown-it-link-attributes": "^3.0.0",
"mnemonist": "^0.38.5",
@ -82,6 +73,7 @@
"sortablejs": "^1.14.0",
"sourcemapped-stacktrace": "^1.1.11",
"text-diff": "^1.0.1",
"u8-mqtt": "^0.5.3",
"vue": "^2.6.14",
"vue-clickaway": "^2.2.2",
"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"?>
<!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">
<metadata>Copyright (C) 2021 by original authors @ fontello.com</metadata>
<metadata>Copyright (C) 2023 by original authors @ fontello.com</metadata>
<defs>
<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" />
@ -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="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="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="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="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 { SERVER } from 'utilities/constants';
import { EXTENSION, SERVER_OR_EXT } from 'utilities/constants';
import { createElement } from 'utilities/dom';
import { timeout, has } from 'utilities/object';
import { getBuster } from 'utilities/time';
@ -24,6 +24,7 @@ export default class AddonManager extends Module {
this.inject('settings');
this.inject('i18n');
this.inject('load_tracker');
this.load_requires = ['settings'];
@ -33,6 +34,8 @@ export default class AddonManager extends Module {
this.reload_required = false;
this.addons = {};
this.enabled_addons = [];
this.load_tracker.schedule('chat-data', 'addon-initial');
}
onLoad() {
@ -57,6 +60,8 @@ export default class AddonManager extends Module {
isAddonExternal: id => this.isAddonExternal(id),
enableAddon: id => this.enableAddon(id),
disableAddon: id => this.disableAddon(id),
reloadAddon: id => this.reloadAddon(id),
canReloadAddon: id => this.canReloadAddon(id),
isReloadRequired: () => this.reload_required,
refresh: () => window.location.reload(),
@ -64,15 +69,16 @@ export default class AddonManager extends Module {
off: (...args) => this.off(...args)
});
this.settings.add('addons.dev.server', {
default: false,
ui: {
path: 'Add-Ons >> Development',
title: 'Use Local Development Server',
description: 'Attempt to load add-ons from local development server on port 8001.',
component: 'setting-check-box'
}
});
if ( ! EXTENSION )
this.settings.add('addons.dev.server', {
default: false,
ui: {
path: 'Add-Ons >> Development',
title: 'Use Local Development Server',
description: 'Attempt to load add-ons from local development server on port 8001.',
component: 'setting-check-box'
}
});
this.on('i18n:update', this.rebuildAddonSearch, this);
@ -90,6 +96,7 @@ export default class AddonManager extends Module {
this.log.capture(err);
});
this.load_tracker.notify('chat-data', 'addon-initial');
this.emit(':ready');
});
}
@ -145,14 +152,25 @@ export default class AddonManager extends Module {
async loadAddonData() {
const [cdn_data, local_data] = await Promise.all([
fetchJSON(`${SERVER}/script/addons.json?_=${getBuster(30)}`),
this.settings.get('addons.dev.server') ?
fetchJSON(`https://localhost:8001/script/addons.json?_=${getBuster()}`) : null
fetchJSON(`${SERVER_OR_EXT}/addons.json?_=${getBuster(30)}`),
// 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) )
for(const addon of cdn_data )
if ( Array.isArray(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);
}
}
if ( Array.isArray(local_data) ) {
this.has_dev = true;
@ -280,16 +298,168 @@ export default class AddonManager extends Module {
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) {
const addon = this.getAddon(id);
if ( ! addon )
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);
const module = this.resolve(`addon.${id}`);
if ( module && ! module.enabled )
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) {
@ -311,7 +481,7 @@ export default class AddonManager extends Module {
document.head.appendChild(createElement('script', {
id: `ffz-loaded-addon-${addon.id}`,
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'
}));

View file

@ -208,16 +208,17 @@ class FFZBridge extends Module {
FFZBridge.Logger = Logger;
const VER = FFZBridge.version_info = {
const VER = FFZBridge.version_info = Object.freeze({
major: __version_major__,
minor: __version_minor__,
revision: __version_patch__,
extra: __version_prerelease__?.length && __version_prerelease__[0],
commit: __git_commit__,
build: __webpack_hash__,
build: __version_build__,
hash: __webpack_hash__,
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.ffz_bridge = new FFZBridge();

View file

@ -13,10 +13,16 @@ import SettingsManager from './settings/index';
import AddonManager from './addons';
import ExperimentManager from './experiments';
import {TranslationManager} from './i18n';
import PubSubClient from './pubsub';
import StagingSelector from './staging';
import LoadTracker from './load_tracker';
import Site from './sites/clips';
import Vue from 'utilities/vue';
import Tooltips from 'src/modules/tooltips';
import Chat from 'src/modules/chat';
import EmoteCard from 'src/modules/emote_card';
class FrankerFaceZ extends Module {
constructor() {
@ -52,30 +58,52 @@ class FrankerFaceZ extends Module {
this.inject('settings', SettingsManager);
this.inject('experiments', ExperimentManager);
this.inject('i18n', TranslationManager);
this.inject('staging', StagingSelector);
this.inject('load_tracker', LoadTracker);
this.inject('pubsub', PubSubClient);
this.inject('site', Site);
this.inject('addons', AddonManager);
this.register('vue', Vue);
// ========================================================================
// Startup
// ========================================================================
this.inject('tooltips', Tooltips);
this.register('chat', Chat);
this.enable().then(() => {
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;
});
this.register('chat', Chat);
this.register('emote_card', EmoteCard);
this.enable()
.then(() => this.enableInitialModules())
.then(() => {
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() {
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
// ========================================================================
@ -121,20 +149,22 @@ ${typeof x[1] === 'string' ? x[1] : JSON.stringify(x[1], null, 4)}`).join('\n\n'
FrankerFaceZ.Logger = Logger;
const VER = FrankerFaceZ.version_info = {
const VER = FrankerFaceZ.version_info = Object.freeze({
major: __version_major__,
minor: __version_minor__,
revision: __version_patch__,
extra: __version_prerelease__?.length && __version_prerelease__[0],
commit: __git_commit__,
build: __webpack_hash__,
build: __version_build__,
hash: __webpack_hash__,
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
FrankerFaceZ.utilities = {
addon: require('utilities/addon'),
blobs: require('utilities/blobs'),
color: require('utilities/color'),
constants: require('utilities/constants'),
dom: require('utilities/dom'),

View file

@ -2,19 +2,21 @@
'use strict';
(() => {
// 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;
const DEBUG = localStorage.ffzDebugMode == 'true' && document.body.classList.contains('ffz-dev'),
HOST = location.hostname,
FLAVOR =
SERVER = DEBUG ? '//localhost:8000' : '//cdn.frankerfacez.com',
script = document.createElement('script');
let FLAVOR =
HOST.includes('player') ? 'player' :
HOST.includes('clips') ? 'clips' :
(location.pathname === '/p/ffz_bridge/' ? 'bridge' : 'avalon'),
SERVER = DEBUG ? '//localhost:8000' : '//cdn.frankerfacez.com',
//CLIPS = /clips\.twitch\.tv/.test(location.hostname) ? 'clips/' : '',
(location.pathname === '/p/ffz_bridge/' ? 'bridge' : 'avalon');
script = document.createElement('script');
if (FLAVOR === 'clips' && location.pathname === '/embed')
FLAVOR = 'player';
script.id = 'ffz-script';
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) {
super(...args);
this.get = this.getAssignment;
this.inject('settings');
this.settings.addUI('experiments', {
@ -301,7 +303,12 @@ export default class ExperimentManager extends Module {
setTwitchOverride(key, value = null) {
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);
const core = this.resolve('site')?.getCore?.();
@ -312,12 +319,13 @@ export default class ExperimentManager extends Module {
}
deleteTwitchOverride(key) {
const overrides = Cookie.getJSON(OVERRIDE_COOKIE);
if ( ! overrides || ! has(overrides, key) )
const overrides = Cookie.getJSON(OVERRIDE_COOKIE),
experiments = overrides?.experiments;
if ( ! experiments || ! has(experiments, key) )
return;
const old_val = overrides[key];
delete overrides[key];
const old_val = experiments[key];
delete experiments[key];
Cookie.set(OVERRIDE_COOKIE, overrides, COOKIE_OPTIONS);
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
const overrides = Cookie.getJSON(OVERRIDE_COOKIE);
return overrides && has(overrides, key);
const overrides = Cookie.getJSON(OVERRIDE_COOKIE),
experiments = overrides?.experiments;
return experiments && has(experiments, key);
}
getTwitchAssignment(key, channel = null) {

View file

@ -14,5 +14,13 @@
{"value": true, "weight": 30},
{"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: () => {
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({
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() {
return this._ && this._.locale;
}
@ -661,6 +665,9 @@ export class TranslationManager extends Module {
async loadLocale(locale, chunk = null) {
// Normalize the locale.
locale = locale.toLowerCase();
if ( locale === 'en' )
return {};
@ -710,12 +717,13 @@ export class TranslationManager extends Module {
}
async setLocale(new_locale) {
// Normalize the locale.
new_locale = new_locale.toLowerCase();
const old_locale = this._.locale;
if ( new_locale === old_locale )
return [];
await this.loadDayjsLocale(new_locale);
this._.locale = new_locale;
this._.clear();
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
// logic to load the translations.
this.emit(':loaded', []);
this._._dayjs_locale = 'en';
return [];
}
const data = this.localeData[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 )
throw new Error('locale has changed since we started loading');
this._._dayjs_locale = djs;
const added = this._.extend(phrases);
if ( 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);
}
formatCurrency(...args) {
return this._.formatCurrency(...args);
}
formatDuration(...args) {
return this._.formatDuration(...args);
}
@ -815,6 +839,28 @@ export class TranslationManager extends Module {
const DOLLAR_REGEX = /\$/g;
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) {
const is_array = Array.isArray(phrase);
if ( substitutions == null )
@ -828,14 +874,23 @@ export function transformPhrase(phrase, substitutions, locale, token_regex, form
if ( typeof result === 'string' )
result = REPLACE.call(result, token_regex, (expr, arg, fmt) => {
let val = get(arg, options);
let val = get(arg.trim(), options);
if ( val == null )
return '';
const formatter = formatters[fmt];
if ( typeof formatter === 'function' )
val = formatter(val, locale, options);
else if ( typeof val === 'string' )
const fmts = parseFormatters(fmt);
let formatted = false;
if (fmts) {
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, '$$');
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 {TranslationManager} from './i18n';
import SocketClient from './socket';
//import PubSubClient from './pubsub';
import PubSubClient from './pubsub';
import Site from 'site';
import Vue from 'utilities/vue';
import StagingSelector from './staging';
import LoadTracker from './load_tracker';
//import Timing from 'utilities/timing';
class FrankerFaceZ extends Module {
@ -56,8 +58,10 @@ class FrankerFaceZ extends Module {
this.inject('settings', SettingsManager);
this.inject('experiments', ExperimentManager);
this.inject('i18n', TranslationManager);
this.inject('staging', StagingSelector);
this.inject('load_tracker', LoadTracker);
this.inject('socket', SocketClient);
//this.inject('pubsub', PubSubClient);
this.inject('pubsub', PubSubClient);
this.inject('site', Site);
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() {
// 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);
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;
const VER = FrankerFaceZ.version_info = {
const VER = FrankerFaceZ.version_info = Object.freeze({
major: __version_major__,
minor: __version_minor__,
revision: __version_patch__,
extra: __version_prerelease__?.length && __version_prerelease__[0],
commit: __git_commit__,
build: __webpack_hash__,
build: __version_build__,
hash: __webpack_hash__,
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 = {
addon: require('utilities/addon'),
blobs: require('utilities/blobs'),
color: require('utilities/color'),
constants: require('utilities/constants'),
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', {
default: [
{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', {
// Filter out actions
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: '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: '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: {}}}
{v: {action: 'msg_delete', appearance: {type: 'icon', icon: 'ffz-i-trash'}, options: {}, display: {mod: true, mod_icons: true}}}
],
type: 'array_merge',
@ -223,8 +270,12 @@ export default class Actions extends Module {
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.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;
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');
}
}
@ -245,7 +301,82 @@ export default class Actions extends Module {
data,
this.i18n.locale,
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 )
continue;
data.ctx = 'room';
const type = data.type;
if ( type ) {
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) )
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 ) {
const out = act.override_appearance.call(this, Object.assign({}, ap), data, null, current_room, current_user, mod_icons);
if ( out )
@ -543,16 +682,17 @@ export default class Actions extends Module {
const u = site.getUser(),
r = {id: line.props.channelID, login: room};
const has_replies = line.chatRepliesTreatment ? line.chatRepliesTreatment !== 'control' : false,
can_replies = has_replies && ! msg.deleted && ! line.props.disableReplyClick,
can_reply = can_replies && u.login !== msg.user?.login && ! msg.reply;
const has_replies = !!(line.props.hasReply || line.props.reply || ! line.props.replyRestrictedReason),
can_replies = has_replies && msg.message && ! msg.deleted && ! line.props.disableReplyClick,
can_reply = can_replies && (has_replies || (u && u.login !== msg.user?.login));
msg.roomId = r.id;
if ( u ) {
u.moderator = line.props.isCurrentUserModerator;
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),
@ -577,6 +717,8 @@ export default class Actions extends Module {
if ( ! data )
continue;
data.ctx = 'user_context';
if ( data.type === 'new-line' ) {
line = null;
continue;
@ -609,11 +751,17 @@ export default class Actions extends Module {
(disp.deleted != null && disp.deleted !== !!msg.deleted) )
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;
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 ) {
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 )
ap = out;
}
@ -623,7 +771,7 @@ export default class Actions extends Module {
continue;
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),
contents = def.render.call(this, ap, createElement, color);
@ -632,7 +780,7 @@ export default class Actions extends Module {
const btn = (<button
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-action={data.action}
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 current_level = this.getUserLevel(current_room, current_user),
@ -690,6 +926,8 @@ export default class Actions extends Module {
if ( ! data.action || ! data.appearance )
continue;
data.ctx = 'inline';
let ap = data.appearance || {};
const disp = data.display || {},
keys = disp.keys,
@ -706,11 +944,17 @@ export default class Actions extends Module {
if ( is_self && ! act.can_self )
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;
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);
const out = act.override_appearance.call(this, Object.assign({}, ap), data, msg, current_room, current_user, mod_icons, instance);
if ( out )
ap = out;
}
@ -720,7 +964,7 @@ export default class Actions extends Module {
continue;
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),
contents = def.render.call(this, ap, createElement, color);
@ -732,7 +976,7 @@ export default class Actions extends Module {
had_action = true;
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' : ''}`}
disabled={disabled}
//disabled={disabled}
data-tooltip-type="action"
data-action={data.action}
data-options={data.options ? JSON.stringify(data.options) : null}
@ -747,14 +991,6 @@ export default class Actions extends Module {
if ( ! had_action )
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;
if ( actions.length )
out = (<div
@ -891,6 +1127,8 @@ export default class Actions extends Module {
if ( target._ffz_tooltip )
target._ffz_tooltip.hide();
return data.definition.click.call(this, event, data);
}

View file

@ -1,38 +1,68 @@
<template lang="html">
<div class="tw-flex tw-align-items-start">
<label :for="'edit_chat$' + id" class="tw-mg-y-05">
{{ t('setting.actions.chat', 'Chat Command') }}
</label>
<div>
<div class="tw-flex tw-align-items-start">
<label :for="'edit_chat$' + id" class="tw-mg-y-05">
{{ t('setting.actions.chat', 'Chat Command') }}
</label>
<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">
<div class="tw-full-width">
<input
:id="'chat-paste$' + id"
v-model="value.paste"
type="checkbox"
class="ffz-checkbox__input"
@change="$emit('input', value)"
: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)"
>
<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 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 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 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>
</template>
@ -41,7 +71,7 @@
let last_id = 0;
export default {
props: ['value', 'defaults', 'vars'],
props: ['value', 'defaults', 'vars', 'fmts'],
data() {
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">
{{ 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>
@ -23,7 +27,7 @@
<script>
export default {
props: ['value', 'defaults', 'vars'],
props: ['value', 'defaults', 'vars', 'fmts'],
}
</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';
// ============================================================================
// 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
// ============================================================================
@ -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

View file

@ -3,6 +3,73 @@
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
// ============================================================================
@ -10,19 +77,37 @@ import {createElement} from 'utilities/dom';
export const reply = {
presets: [{
appearance: {
type: 'icon',
icon: 'ffz-i-reply'
type: 'dynamic'
}
}],
required_context: ['message'],
supports_dynamic: true,
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,
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')
},
@ -31,10 +116,16 @@ export const reply = {
if ( typeof id !== 'string' || ! /^[0-9a-f]+-[0-9a-f]+/.test(id) )
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;
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;
},
@ -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
// ============================================================================
@ -102,7 +238,10 @@ export const open_url = {
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/edit-url.vue'),
title: 'Open URL',
description: '{options.url}',
description(data) {
return data.options.url;
},
description_i18n: null,
can_self: true,
@ -151,7 +290,20 @@ export const chat = {
},
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,
@ -159,10 +311,15 @@ export const chat = {
tooltip(data) {
const msg = this.replaceVariables(data.options.command, data);
let target = this.replaceVariables(data.options.target ?? '', data);
if ( /^\s*$/.test(target) )
target = null;
return [
(<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 class="tw-align-left">{ // eslint-disable-line react/jsx-key
msg
@ -172,10 +329,14 @@ export const chat = {
click(event, 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 )
this.pasteMessage(data.room.login, msg);
this.pasteMessage(target, msg);
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
// ============================================================================

View file

@ -4,7 +4,7 @@
// 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 {has, maybe_call, SourcedSet} from 'utilities/object';
@ -44,7 +44,10 @@ const CSS_BADGES = {
turbo: { 1: { color: '#59399A' } },
premium: { 1: { color: '#00A0D6' } },
'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('tooltips');
this.inject('experiments');
this.inject('staging');
this.inject('load_tracker');
this.style = new ManagedStyle('badges');
@ -223,12 +228,24 @@ export default class Badges extends Module {
});
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: {
path: 'Chat > Badges >> Behavior',
title: 'Allow clicking badges.',
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.loadGlobalBadges();
this.on('chat:reload-data', flags => {
if ( ! flags || flags.badges )
this.loadGlobalBadges();
});
this.tooltips.types.badge = (target, tip) => {
tip.add_class = 'ffz__tooltip--badges';
@ -534,7 +556,8 @@ export default class Badges extends Module {
handleClick(event) {
if ( ! this.parent.context.get('chat.badges.clickable') )
const mode = this.parent.context.get('chat.badges.clickable');
if ( ! mode )
return;
const target = event.target;
@ -544,6 +567,7 @@ export default class Badges extends Module {
return;
let url = null;
let click_badge = null;
for(const d of ds.data) {
const p = d.provider;
@ -553,14 +577,14 @@ export default class Badges extends Module {
if ( ! bd )
continue;
if ( bd.click_url )
if ( mode == 1 && 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;
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}`;
else
continue;
click_badge = bd;
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 ) {
const link = createElement('a', {
target: '_blank',
@ -630,7 +665,7 @@ export default class Badges extends Module {
bdata = tb && tb[badge_id],
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;
if ( has(BADGE_POSITIONS, badge_id) )
@ -922,6 +957,8 @@ export default class Badges extends Module {
async loadGlobalBadges(tries = 0) {
this.load_tracker.schedule('chat-data', 'ffz-global-badges');
let response, data;
if ( this.experiments.getAssignment('api_load') && tries < 1 )
@ -930,23 +967,27 @@ export default class Badges extends Module {
} catch(err) { /* do nothing */ }
try {
response = await fetch(`${API_SERVER}/v1/badges/ids`);
response = await fetch(`${this.staging.api}/v1/badges/ids`);
} catch(err) {
tries++;
if ( tries < 10 )
return setTimeout(() => this.loadGlobalBadges(tries), 500 * tries);
this.log.error('Error loading global badge data.', err);
this.load_tracker.notify('chat-data', 'ffz-global-badges', false);
return false;
}
if ( ! response.ok )
if ( ! response.ok ) {
this.load_tracker.notify('chat-data', 'ffz-global-badges', false);
return false;
}
try {
data = await response.json();
} catch(err) {
this.log.error('Error parsing global badge data.', err);
this.load_tracker.notify('chat-data', 'ffz-global-badges', false);
return false;
}
@ -966,7 +1007,7 @@ export default class Badges extends Module {
name = badge?.name;
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.supporter_id = 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.buildBadgeCSS();
this.load_tracker.notify('chat-data', 'ffz-global-badges');
}
@ -1003,8 +1045,8 @@ export default class Badges extends Module {
data.replaces = true;
}
if ( ! data.addon && (data.name === 'developer' || data.name === 'supporter') )
data.click_url = 'https://www.frankerfacez.com/donate';
if ( ! data.addon && (data.name === 'developer' || data.name === 'subwoofer' || data.name === 'supporter') )
data.click_url = 'https://www.frankerfacez.com/subscribe';
}
if ( generate_css )

View file

@ -1,5 +1,6 @@
<script>
import {FFZEvent} from 'utilities/events';
import {has, timeout} from 'utilities/object';
const ERROR_IMAGE = 'https://static-cdn.jtvnw.net/emoticons/v1/58765/2.0';
@ -8,13 +9,14 @@ let tokenizer;
export default {
props: ['data', 'url', 'events', 'forceFull', 'forceUnsafe', 'forceMedia', 'forceMid'],
props: ['data', 'url', 'events', 'forceFull', 'forceUnsafe', 'forceMedia', 'forceMid', 'noLink', 'noTooltip', 'noElevation', 'noUnsafe'],
data() {
return {
has_tokenizer: false,
loaded: false,
version: null,
player_state: {},
fragments: {},
error: null,
accent: null,
@ -23,6 +25,7 @@ export default {
full: null,
unsafe: false,
urls: null,
i18n_prefix: null,
allow_media: false,
allow_unsafe: false
}
@ -55,14 +58,40 @@ export default {
this.listen();
this.load();
this.handle_click = this.handleClick.bind(this);
},
beforeDestroy() {
this.unlisten();
this.clearRefresh();
this.handle_click = null;
},
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() {
if ( tokenizer )
this.has_tokenizer = true;
@ -104,6 +133,7 @@ export default {
reset(refresh = false) {
this.clearRefresh();
this.player_state = {};
this.loaded = false;
this.error = null;
this.version = null;
@ -114,6 +144,7 @@ export default {
this.fragments = {};
this.unsafe = false;
this.urls = null;
this.i18n_prefix = null;
this.allow_media = false;
this.allow_unsafe = false;
this.load(refresh);
@ -179,6 +210,7 @@ export default {
this.fragments = data.fragments ?? {};
this.unsafe = data.unsafe;
this.urls = data.urls;
this.i18n_prefix = data.i18n_prefix;
this.allow_media = data.allow_media;
this.allow_unsafe = data.allow_unsafe;
},
@ -199,7 +231,7 @@ export default {
},
renderUnsafe(h) {
if ( ! this.unsafe )
if ( ! this.unsafe || this.noUnsafe )
return null;
const reasons = Array.from(new Set(this.urls.map(url => url.flags).flat())).join(', ');
@ -223,8 +255,13 @@ export default {
},
renderBody(h) {
let body = this.forceFull ? this.full :
this.forceMid ? this.mid : this.short;
let body;
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 )
body = null;
@ -237,7 +274,17 @@ export default {
tList: (...args) => this.tList(...args),
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,
i18n_prefix: this.i18n_prefix,
allow_media: this.forceMedia ?? this.allow_media,
allow_unsafe: this.forceUnsafe ?? this.allow_unsafe
@ -291,12 +338,12 @@ export default {
render(h) {
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));
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', {
class: [
tooltip && 'ffz-tooltip',
@ -304,6 +351,9 @@ export default {
!this.error && 'ffz-interactable--hover-enabled',
'tw-block tw-border-radius-medium tw-full-width ffz-interactable ffz-interactable--default tw-interactive'
],
on: {
click: this.handleClick
},
attrs: {
'data-tooltip-type': 'link',
'data-url': this.url,
@ -325,7 +375,8 @@ export default {
return h('div', {
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' : ''
],
style: {

View file

@ -92,7 +92,12 @@ export default class Emoji extends Module {
if (enable_replace_joiner)
this.settings.add('chat.emoji.replace-joiner', {
default: 2,
default: 1,
process(ctx, val) {
if ( val === 2 )
return 1;
return val;
},
ui: {
path: 'Chat > Behavior >> Emoji',
title: 'Emoji Joiner Workaround',
@ -101,7 +106,7 @@ export default class Emoji extends Module {
data: [
{value: 0, title: 'Disabled'},
{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!) {
emote(id: $id) {
id
artist {
id
login
displayName
}
owner {
id
login

File diff suppressed because it is too large Load diff

View file

@ -7,9 +7,11 @@
import dayjs from 'dayjs';
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 {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 Emotes from './emotes';
@ -20,11 +22,10 @@ import Room from './room';
import User from './user';
import * as TOKENIZERS from './tokenizers';
import * as RICH_PROVIDERS from './rich_providers';
import * as LINK_PROVIDERS from './link_providers';
import Actions from './actions';
import { getFontsList } from 'src/utilities/fonts';
import Actions from './actions/actions';
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) {
list.sort((a,b) => {
@ -37,19 +38,19 @@ function sortPriorityColorTerms(list) {
return list;
}
function addSeparators(str) {
return `(^|.*?${SEPARATORS})(?:${str})(?=$|${SEPARATORS})`
}
const TERM_FLAGS = ['g', 'gi'];
const UNBLOCKABLE_TOKENS = [
'filter_test'
];
function formatTerms(data) {
const out = [];
for(let i=0; i < data.length; i++) {
const list = data[i];
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);
}
@ -71,17 +72,20 @@ export default class Chat extends Module {
this.inject('i18n');
this.inject('tooltips');
this.inject('experiments');
this.inject('staging');
this.inject('load_tracker');
this.inject(Badges);
this.inject(Emotes);
this.inject(Emoji);
this.inject(Actions);
this.inject(Overrides);
this.inject('overrides', Overrides);
this._link_info = {};
// Bind for JSX stuff
this.clickToReveal = this.clickToReveal.bind(this);
this.handleLinkClick = this.handleLinkClick.bind(this);
this.handleMentionClick = this.handleMentionClick.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.link_providers = {};
this.__link_providers = [];
this._hl_reasons = {};
this.addHighlightReason('mention', 'Mentioned');
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', {
default: [],
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', {
default: false,
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', {
default: true,
ui: {
@ -1191,7 +1238,7 @@ export default class Chat extends Module {
room.buildBitsCSS();
});
this.context.on('changed:chat.filtering.color-mentions', async val => {
this.context.on('changed:chat.filtering.need-colors', async val => {
if ( val )
await this.createColorCache();
else
@ -1199,6 +1246,9 @@ export default class Chat extends Module {
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() {
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'));
for(const key in TOKENIZERS)
@ -1230,6 +1283,85 @@ export default class Chat extends Module {
for(const key in RICH_PROVIDERS)
if ( has(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) {
const target = event.target,
fine = this.resolve('site.fine');
@ -1497,7 +1654,9 @@ export default class Chat extends Module {
{
type: 'reply',
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
},
{
@ -1600,6 +1759,10 @@ export default class Chat extends Module {
b[item.setID] = item.version;
}
// Validate User Type
if ( user.type == null && msg.badges && msg.badges.moderator )
user.type = 'mod';
// Standardize Timestamp
if ( ! msg.timestamp && msg.sentAt )
msg.timestamp = new Date(msg.sentAt).getTime();
@ -1790,7 +1953,7 @@ export default class Chat extends Module {
className: 'chat-author__intl-login'
}, ` (${user.login})`));
return [out];
return out;
}
@ -1834,6 +1997,11 @@ export default class Chat extends Module {
addTokenizer(tokenizer) {
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;
if ( tokenizer.priority == null )
tokenizer.priority = 0;
@ -1873,8 +2041,48 @@ export default class Chat extends Module {
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) {
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;
if ( provider.priority == null )
provider.priority = 0;
@ -1937,11 +2145,12 @@ export default class Chat extends Module {
const want_mid = this.context.get('chat.rich.want-mid');
for(const token of tokens) {
for(const provider of providers)
if ( provider.test.call(this, token, msg) ) {
token.hidden = provider.can_hide_token && (this.context.get('chat.rich.hide-tokens') || provider.hide_token);
return provider.process.call(this, token, want_mid);
}
if ( token.allow_rich ?? true )
for(const provider of providers)
if ( provider.test.call(this, token, msg) ) {
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,
l = tokens.length;
const hidden = this.context.get('chat.filtering.hidden-tokens');
for(let i=0; i < l; i++) {
const token = tokens[i],
type = token.type,
tk = tokenizers[type];
if ( token.hidden )
if ( token.hidden || hidden.has(type) )
continue;
let res;
@ -2085,6 +2296,17 @@ export default class Chat extends Module {
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');
if ( provider == null )
provider = this.experiments.getAssignment('api_links') ? 'test' : 'socket';
@ -2109,6 +2331,9 @@ export default class Chat extends Module {
}
fixLinkInfo(data) {
if ( ! data )
return data;
if ( data.error && data.message )
data.error = data.message;
@ -2122,7 +2347,9 @@ export default class Chat extends Module {
image: {type: 'image', url: ERROR_IMAGE},
title: {type: 'i18n', key: 'card.error', phrase: 'An error occurred.'},
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) ) {

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"
>
<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
class="tw-mg-l-05 tw-button tw-button--text ffz-il-tooltip__container"
:class="{'tw-button--disabled': name == null}"
@ -51,6 +62,17 @@
@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
class="tw-mg-l-05 tw-button tw-button--text ffz-il-tooltip__container"
:class="{'tw-button--disabled': color == null}"
@ -93,6 +115,16 @@ export default {
},
methods: {
setOriginalName() {
this.name = this.originalName;
this.setName(this.name);
},
setOriginalColor() {
this.color = this.originalColor;
this.setColor(this.color);
},
clearName() {
this.name = null;
this.deleteName();

View file

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

View file

@ -4,24 +4,6 @@
// 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
// ============================================================================
@ -32,10 +14,18 @@ export const Links = {
priority: -10,
test(token) {
if ( ! this.context.get('chat.rich.all-links') && ! token.force_rich )
if ( token.type !== 'link' )
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) {
@ -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 {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 {has, SourcedSet, set_equals} from 'utilities/object';
@ -32,6 +32,9 @@ export default class Room {
if ( id )
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.load_data();
}
@ -81,6 +84,9 @@ export default class Room {
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 )
this.manager.room_ids[this._id] = null;
}
@ -253,6 +259,9 @@ export default class Room {
if ( this.destroyed )
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') )
try {
fetch(`${NEW_API}/v1/room/${this.id ? `id/${this.id}` : this.login}`).catch(() => {});
@ -260,23 +269,27 @@ export default class Room {
let response, data;
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) {
tries++;
if ( tries < 10 )
return setTimeout(() => this.load_data(tries), 500 * tries);
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;
}
if ( ! response.ok )
if ( ! response.ok ) {
this.manager.load_tracker.notify('chat-data', load_key, false);
return false;
}
try {
data = await response.json();
} catch(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;
}
@ -296,6 +309,7 @@ export default class Room {
} else if ( this._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;
}
@ -306,10 +320,10 @@ export default class Room {
this.data = d;
this.removeAllSets('main');
if ( d.set )
this.addSet('main', d.set);
else
this.removeAllSets('main');
if ( data.sets )
for(const set_id in data.sets)
@ -331,6 +345,7 @@ export default class Room {
this.buildModBadgeCSS();
this.buildVIPBadgeCSS();
this.manager.load_tracker.notify('chat-data', load_key);
return true;
}

View file

@ -6,15 +6,25 @@
import {sanitize, createElement} from 'utilities/dom';
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 { 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',
//WHITESPACE = /^\s*$/,
//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 = /^(['"*([{<\\/]*)(@)((?:[^\u0000-\u007F]|[\w-])+)(?:\b|$)/; // eslint-disable-line no-control-regex
@ -71,12 +81,13 @@ export const Links = {
rel="noopener noreferrer"
target="_blank"
href={token.url}
onClick={this.handleLinkClick}
>{token.text}</a>);
},
tooltip(target, tip) {
if ( ! this.context.get('tooltip.rich-links') && ! target.dataset.forceTooltip )
return '';
return NoContent;
if ( target.dataset.isMail === 'true' )
return [this.i18n.t('tooltip.email-link', 'E-Mail {address}', {address: target.textContent})];
@ -97,6 +108,7 @@ export const Links = {
i18n: this.i18n,
fragments: data.fragments,
i18n_prefix: data.i18n_prefix,
allow_media: show_images,
allow_unsafe: show_unsafe,
@ -303,6 +315,81 @@ export const Replies = {
// 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 = {
type: 'mention',
priority: 0,
@ -336,6 +423,12 @@ export const Mentions = {
if ( ! tokens || ! tokens.length )
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'),
priority = this.context.get('chat.filtering.mention-priority');
@ -386,7 +479,7 @@ export const Mentions = {
}
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({
type: 'mention',
@ -1135,7 +1228,7 @@ const render_emote = (token, createElement, wrapped) => {
const mods = token.modifiers || [], ml = mods.length,
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: {
src,
srcSet,
@ -1212,12 +1305,134 @@ export const AddonEmotes = {
hoverSrcSet = big ? token.animSrcSet2 : token.animSrcSet;
}
let style = undefined, outerStyle = undefined;
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
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}
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}
data-tooltip-type="emote"
data-provider={token.provider}
@ -1234,7 +1449,7 @@ export const AddonEmotes = {
onClick={this.emotes.handleClick}
/>);
if ( ! ml ) {
if ( ! ml && ! token.modifier_flags ) {
if ( wrapped )
return emote;
@ -1242,16 +1457,28 @@ export const AddonEmotes = {
}
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-provider={token.provider}
data-id={token.id}
data-set={token.set}
style={outerStyle}
data-modifiers={ml ? mods.map(x => x.id).join(' ') : null}
onClick={this.emotes.handleClick}
data-effects={effects ? effects : undefined}
//onClick={this.emotes.handleClick}
>
{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>);
},
@ -1260,13 +1487,20 @@ export const AddonEmotes = {
provider = ds.provider,
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;
const hide_source = ds.noSource === 'true';
if ( modifiers && modifiers !== 'null' ) {
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],
emote = emote_set && emote_set.emotes[emote_id];
@ -1281,11 +1515,15 @@ export const AddonEmotes = {
if ( provider === 'twitch' ) {
emote_id = ds.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`;
fav_source = 'twitch';
if ( raw_artist )
artist = raw_artist.displayName || raw_artist.login;
if ( emote_set ) {
const type = emote_set.type;
if ( type === EmoteTypes.Global ) {
@ -1339,6 +1577,15 @@ export const AddonEmotes = {
if ( emote ) {
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 )
owner = this.i18n.t(
'emote.owner', 'By: {owner}',
@ -1357,6 +1604,123 @@ export const AddonEmotes = {
else if ( 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' ) {
@ -1396,19 +1760,32 @@ export const AddonEmotes = {
onLoad={tip.update}
/>) : 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">
{source}
</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}
</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>),
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" />)
];
@ -1435,15 +1812,22 @@ export const AddonEmotes = {
anim = this.context.get('chat.emotes.animated'),
out = [];
let had_prefix_mods = false;
let had_no_space = false;
let last_token, emote;
const NoSpace = this.emotes.ModifierFlags?.NoSpace;
for(const token of tokens) {
if ( ! token )
continue;
if ( token.type !== 'text' ) {
if ( token.type === 'emote' ) {
if ( ! token.modifiers )
if ( ! token.modifiers ) {
token.modifiers = [];
token.modifier_flags = 0;
}
}
out.push(token);
@ -1458,8 +1842,16 @@ export const AddonEmotes = {
emote = emotes[segment];
// 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 ( 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(
Object.assign({
big,
@ -1485,6 +1877,7 @@ export const AddonEmotes = {
const t = Object.assign({
modifiers: [],
modifier_flags: 0,
big,
anim
}, 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;
}
}
@ -1531,6 +1984,7 @@ export const Emoji = {
return;
const splitter = this.emoji.splitter,
big = this.context.get('chat.emotes.2x') > 1,
replace = this.context.get('chat.emoji.replace-joiner') > 0,
style = this.context.get('chat.emoji.style');
@ -1576,12 +2030,15 @@ export const Emoji = {
code: key[0],
variant: key[1],
big_emoji: big,
src: this.emoji.getFullImage(variant.image, style),
srcSet: this.emoji.getFullImageSet(variant.image, style),
text: match[0],
length,
modifiers: []
modifiers: [],
modifier_flags: 0
});
idx = start + match[0].length;
@ -1652,6 +2109,15 @@ export const TwitchEmotes = {
while( eix < e_length ) {
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?
if ( e_start > t_end || e_end > t_end ) {
// 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({
type: 'emote',
id: e_id,
@ -1731,9 +2202,11 @@ export const TwitchEmotes = {
anim,
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(''),
modifiers: []
modifiers: [],
modifier_flags: 0
});
idx = e_end;

View file

@ -2,7 +2,7 @@ query FFZ_GetVideoInfo($id: ID!) {
video(id: $id) {
id
title
previewThumbnailURL(width: 86, height: 45)
previewThumbnailURL(width: 320, height: 180)
lengthSeconds
publishedAt
viewCount
@ -14,6 +14,7 @@ query FFZ_GetVideoInfo($id: ID!) {
id
login
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
v-for="(r, key) in data.renderers"
v-if="supportsRenderer(key)"
:key="key"
:value="key"
>
@ -330,7 +331,7 @@
</label>
</div>
<div class="tw-pd-r-1 ffz-checkbox">
<div v-if="has_hover_modifier" class="tw-pd-r-1 ffz-checkbox">
<input
:id="'key_hover$' + id"
ref="key_hover"
@ -379,6 +380,7 @@
:value="edit_data.options"
:defaults="action_def.defaults"
:vars="vars"
:fmts="fmts"
@input="onChangeAction($event)"
/>
</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">
<action-preview
:act="display"
:color="display.appearance && data.color(display.appearance.color)"
:act="maybeDynamic(display)"
:process-color="data.color"
:renderers="data.renderers"
/>
</div>
@ -457,7 +459,7 @@ import {has, maybe_call, deep_copy} from 'utilities/object';
let id = 0;
export default {
props: ['action', 'data', 'inline', 'mod_icons', 'context', 'modifiers'],
props: ['vuectx', 'action', 'data', 'inline', 'mod_icons', 'context', 'modifiers', 'hover_modifier'],
data() {
return {
@ -493,6 +495,25 @@ export default {
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() {
const out = [],
ctx = this.context || [];
@ -537,6 +558,9 @@ export default {
if ( this.action.t === 'inherit' )
return this.t('setting.inheritance', 'Inheritance Point');
if ( this.action.t === 'skip' )
return this.t('setting.inheritance.skip', 'Not Inheriting');
else if ( ! this.display )
return this.t('setting.unknown', 'Unknown Value');
@ -557,11 +581,15 @@ export default {
if ( def.title ) {
const data = this.getData(),
out = maybe_call(def.title, this, data, def),
i18n = def.title_i18n || `chat.actions.${this.display.action}`;
out = maybe_call(def.title, this, data, def);
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);
else if ( out )
return out;
}
return this.t('setting.actions.untitled', 'Action: {action}', this.display);
@ -570,7 +598,10 @@ export default {
description() {
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;
@ -588,11 +619,15 @@ export default {
return null;
const data = this.getData(),
out = maybe_call(def.description, this, data, def),
i18n = def.description_i18n || `chat.actions.${this.display.action}.desc`;
out = maybe_call(def.description, this, data, def);
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);
else if ( out )
return out;
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'));
if ( ! out.length )
@ -712,8 +747,10 @@ export default {
if ( this.$refs.key_meta.checked )
i |= 8;
this.edit_data.display.hover = this.$refs.key_hover.checked;
this.edit_data.display.keys = i;
if ( this.has_hover_modifier )
this.edit_data.display.hover = this.$refs.key_hover.checked;
},
edit() {
@ -751,6 +788,29 @@ export default {
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() {
const def = this.display && this.data.actions[this.display.action];
if ( ! def )

View file

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

View file

@ -63,6 +63,16 @@
/>
</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 class="tw-flex-grow-1" />
<div
@ -136,6 +146,10 @@ export default {
return this.sorted_addons.filter(addon => this.shouldShow(addon));
},
listed_addons() {
return this.sorted_addons.filter(addon => ! addon.unlisted)
},
sorted_addons() {
const addons = this.item.getAddons();

View file

@ -5,6 +5,10 @@
<img :src="icon" class="tw-image">
</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">
{{ t('addon.external', 'External') }}
</div>
@ -96,6 +100,20 @@
{{ t('addon.disable', 'Disable') }}
</span>
</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
v-if="addon.settings"
class="tw-button ffz-button--hollow tw-mg-r-1"
@ -151,6 +169,8 @@ export default {
data() {
return {
enabled: this.item.isAddonEnabled(this.id),
can_reload: this.addon.dev && this.item.canReloadAddon(this.id),
reloading: false,
external: this.item.isAddonExternal(this.id),
version: this.item.getVersion(this.id),
expanded: false
@ -251,6 +271,19 @@ export default {
list.push(`add_ons.${this.addon.name.toSnakeCase()}`);
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
v-else
:key="act.id"
:act="act.v"
:color="color(act.v.appearance.color)"
:act="maybeDynamic(act.v)"
:process-color="color"
:renderers="data.renderers"
tooltip="true"
pad="true"
@ -291,7 +291,12 @@
<div class="tw-flex-grow-1 tw-mg-r-1">
{{ preset.title_i18n ? t(preset.title_i18n, preset.title, preset) : preset.title }}
</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>
</button>
</template>
@ -300,7 +305,7 @@
</balloon>
</div>
<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"
@click="maybe_clear = true"
>
@ -333,7 +338,7 @@
</span>
</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"
@click="populate"
>
@ -349,6 +354,20 @@
<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">
{{ 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>
<section v-for="act in val" :key="act.id">
<action-editor
@ -357,7 +376,9 @@
:inline="item.inline"
:mod_icons="has_icons"
:context="item.context"
:vuectx="context"
:modifiers="item.modifiers"
:hover_modifier="item.hover_modifier"
@remove="remove(act)"
@save="save(act, $event)"
/>
@ -409,6 +430,14 @@ export default {
return false;
},
hasSkip() {
for(const val of this.val)
if ( val.t === 'skip' )
return true;
return false;
},
sample_user() {
return this.has_user ? {
displayName: 'SirStendec',
@ -560,6 +589,10 @@ export default {
return out;
},
strip_skip_val() {
return this.val.filter(x => x.t !== 'skip');
},
val() {
if ( ! this.has_value )
return [];
@ -657,8 +690,28 @@ export default {
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) {
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);
this.set(deep_copy(vals));
this.add_open = false;
@ -739,6 +792,22 @@ export default {
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) {
if ( ! 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.') }}
</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">
<div class="tw-c-background-accent tw-c-text-overlay tw-pd-1 tw-mg-b-2">
<h3 class="ffz-i-attention">
@ -16,7 +127,7 @@
<input
ref="code"
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"
autocorrect="off"
@keydown.enter="enterCode"
@ -25,117 +136,6 @@
</section>
<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">
<section
v-for="({key, exp}) of visible_twitch"

View file

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

View file

@ -4,16 +4,21 @@
<h5 class="ffz-i-ellipsis-vert" />
</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
v-else
:is="component"
v-model="editing"
:type="type"
:filters="filters"
:context="context"
:preview="preview"
/>
<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"
>
<figure :class="[passes ? 'ffz-i-ok' : 'ffz-i-cancel']" />
@ -31,7 +36,7 @@
: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"
>
<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']" />
<div class="ffz-il-tooltip ffz-il-tooltip--up ffz-il-tooltip--align-right">
<span v-if="passes">
@ -44,23 +49,23 @@
</div>
<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">
<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') }}
</div>
</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 v-else>
<button class="tw-button tw-button--text tw-relative ffz-il-tooltip__container" @click="deleting = true">
<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') }}
</div>
</button>
@ -80,6 +85,11 @@ export default {
context: {
type: Object,
required: false
},
preview: {
type: Boolean,
required: false,
default: true
}
},
@ -93,7 +103,7 @@ export default {
computed: {
passes() {
return this.tester && this.tester(this.context);
return this.preview && this.tester && this.tester(this.context);
},
type() {
@ -105,7 +115,7 @@ export default {
},
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>
</div>
<a
:data-theme="theme"
class="twitter-timeline"
data-width="300"
href="https://twitter.com/FrankerFaceZ?ref_src=twsrc%5Etfw"
>
{{ t('home.tweets', 'Tweets by FrankerFaceZ') }}
</a>
<template v-if="not_extension">
<a
:data-theme="theme"
class="twitter-timeline"
data-width="300"
href="https://twitter.com/FrankerFaceZ?ref_src=twsrc%5Etfw"
>
{{ t('home.tweets', 'Tweets by FrankerFaceZ') }}
</a>
</template>
</div>
</div>
</template>
@ -207,6 +209,7 @@
import HOME_MD from '../home.md';
import {createElement as e} from 'utilities/dom';
import { EXTENSION } from 'utilities/constants';
export default {
props: ['item', 'context'],
@ -217,7 +220,8 @@ export default {
theme: '',
addons: null,
new_addons: null,
unseen: this.item.getUnseen()
unseen: this.item.getUnseen(),
not_extension: ! EXTENSION
}
},
@ -241,13 +245,14 @@ export default {
mounted() {
let el;
document.head.appendChild(el = e('script', {
id: 'ffz--twitter-widget-script',
async: true,
charset: 'utf-8',
src: 'https://platform.twitter.com/widgets.js',
onLoad: () => el.remove()
}));
if ( this.not_extension )
document.head.appendChild(el = e('script', {
id: 'ffz--twitter-widget-script',
async: true,
charset: 'utf-8',
src: 'https://platform.twitter.com/widgets.js',
onLoad: () => el.remove()
}));
},
methods: {

View file

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

View file

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

View file

@ -7,6 +7,23 @@
<template v-if="i !== item">&raquo; </template>
</span>
</header>
<section v-if="context.currentProfile.ephemeral && item.profile_warning !== false" class="tw-border-t tw-pd-t-1 tw-pd-b-2">
<div class="tw-c-background-accent tw-c-text-overlay tw-pd-1">
<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">
<div class="tw-c-background-accent tw-c-text-overlay tw-pd-1">
<h3 class="ffz-i-attention">

View file

@ -1,9 +1,29 @@
<template lang="html">
<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-grow-1" />
<button
:disabled="isEphemeral"
class="tw-button tw-button--text"
:class="{'tw-button--disabled': isEphemeral}"
@click="save"
>
<span class="tw-button__text ffz-i-floppy">
@ -80,6 +100,7 @@
<input
id="ffz:editor:name"
ref="name"
:disabled="isEphemeral"
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"
>
@ -93,6 +114,7 @@
<textarea
id="ffz:editor:description"
ref="desc"
:disabled="isEphemeral"
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"
/>
@ -107,6 +129,7 @@
<key-picker
id="ffz:editor:hotkey"
ref="hotkey"
:disabled="isEphemeral"
v-model="hotkey"
/>
</div>
@ -148,6 +171,7 @@
id="ffz:editor:update"
ref="update"
:checked="! pause"
:disabled="isEphemeral"
type="checkbox"
class="ffz-checkbox__input"
@change="onPauseChange"
@ -175,7 +199,9 @@
<filter-editor
v-model="rules"
:filters="filters"
:disabled="isEphemeral"
:context="test_context"
:preview="true"
/>
</div>
</div>
@ -218,6 +244,10 @@ export default {
},
computed: {
isEphemeral() {
return this.item.profile?.ephemeral ?? false
},
canExport() {
return this.item.profile != null
}

View file

@ -235,6 +235,16 @@
<span class="ffz-i-ellipsis-vert" />
</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
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"

View file

@ -26,7 +26,7 @@
@focusin="focus"
@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 ref="popup" class="simplebar-content">
<div
@ -52,6 +52,14 @@
@click="changeProfile(p)"
>
<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
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' : ''}`"
@ -111,7 +119,22 @@ export default {
}
},
watch: {
opened() {
if (this.opened)
this.$nextTick(() => this.updateScroller());
}
},
methods: {
updateScroller() {
const scroller = this.$refs.scroller;
if (!scroller || ! window.ffzSimplebar || scroller.SimpleBar)
return;
new ffzSimplebar(scroller, ffzSimplebar.getElOptions(scroller));
},
openConfigure() {
this.hide();
this.$emit('navigate', 'data_management.profiles');

View file

@ -8,6 +8,7 @@
:id="item.full_key"
ref="control"
:checked="value"
:disabled="isReadOnly"
type="checkbox"
class="ffz-checkbox__input"
@change="onChange"
@ -16,7 +17,7 @@
<label :for="item.full_key" class="ffz-checkbox__label">
<span class="tw-mg-l-1">
{{ 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>
</label>
@ -39,7 +40,13 @@
</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" />
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ t('setting.reset', 'Reset to Default') }}
@ -58,7 +65,11 @@
v-if="item.extra"
style="padding-left:2.5rem"
>
<component :is="item.extra.component" :context="context" :item="item" />
<component
:is="item.extra.component"
:context="context"
:item="item"
/>
</section>
</div>
</template>

View file

@ -6,12 +6,13 @@
<div class="tw-flex tw-align-items-center">
<label :for="item.full_key">
{{ 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>
<color-picker
:id="item.full_key"
ref="control"
:disabled="isReadOnly"
:alpha="alpha"
:open-up="openUp"
:nullable="true"
@ -38,7 +39,13 @@
</span>
</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" />
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ t('setting.reset', 'Reset to Default') }}

View file

@ -6,13 +6,14 @@
<div class="tw-flex tw-align-items-start">
<label :for="item.full_key" class="tw-mg-y-05">
{{ 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>
<div class="tw-flex tw-flex-column tw-mg-05">
<select
:id="item.full_key"
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"
@change="onChange"
>
@ -48,7 +49,7 @@
<input
ref="text"
: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"
@change="onTextChange"
>
@ -72,7 +73,13 @@
</span>
</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" />
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ 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">
<label :for="item.full_key">
{{ 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>
<key-picker
:id="item.full_key"
ref="control"
:disabled="isReadOnly"
:value="value"
class="tw-mg-05"
@input="onInput"
@ -35,7 +36,13 @@
</span>
</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" />
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ t('setting.reset', 'Reset to Default') }}

View file

@ -6,12 +6,13 @@
<div class="tw-flex tw-align-items-center">
<label :for="item.full_key" class="tw-mg-y-05">
{{ 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>
<select
:id="item.full_key"
ref="control"
:disabled="isReadOnly"
:multiple="item.multiple || false"
: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"
@ -62,7 +63,13 @@
</span>
</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" />
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ t('setting.reset', 'Reset to Default') }}

View file

@ -6,7 +6,7 @@
<div class="tw-flex tw-align-items-center">
<label :for="item.full_key">
{{ 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>
<input
@ -15,6 +15,7 @@
:type="type"
:placeholder="placeholder"
:value="value"
:disabled="isReadOnly"
: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"
@change="onChange"
@ -38,7 +39,13 @@
</span>
</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" />
<div class="ffz-il-tooltip ffz-il-tooltip--down ffz-il-tooltip--align-right">
{{ 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.*
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.*

View file

@ -63,18 +63,21 @@ export default class MainMenu extends Module {
this.settings.addUI('backup', {
path: 'Data Management > Backup and Restore @{"profile_warning": false}',
component: 'backup-restore',
getExtraTerms: () => ['restore'],
getFFZ: () => this.resolve('core')
});
this.settings.addUI('clear', {
path: 'Data Management > Storage @{"profile_warning": false} >> tabs ~> Clear',
component: 'clear-settings',
getExtraTerms: () => ['reset'],
force_seen: true
});
this.settings.addUI('provider', {
path: 'Data Management > Storage >> tabs ~> Provider',
component: 'provider',
getExtraTerms: () => ['storage', 'local', 'indexeddb', 'localstorage'],
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', {
path: 'Home > FAQ @{"profile_warning": false}',
component: 'md-page',
@ -215,7 +225,12 @@ export default class MainMenu extends Module {
this.on('settings:added-definition', (key, definition) => {
this._addDefinitionToTree(key, definition);
this.scheduleUpdate();
})
});
this.on('settings:removed-definition', key => {
this._removeDefinitionFromTree(key);
this.scheduleUpdate();
});
this.on('socket:command:new_version', version => {
if ( version === window.FrankerFaceZ.version_info.commit )
@ -361,6 +376,7 @@ export default class MainMenu extends Module {
this.log.info('Context proxy gone.');
this.updateContext({proxied: false});
}
});
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) {
if ( ! def.ui || ! this._settings_tree )
return;
@ -779,6 +847,8 @@ export default class MainMenu extends Module {
title: profile.name,
i18n_key: profile.i18n_key,
ephemeral: profile.ephemeral,
description: profile.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 ) {
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];
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' )
return;
if ( this.settings.get('context.ui.theatreModeEnabled') )
if ( this.settings.get('layout.is-theater-mode') )
return;
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/))
* Twitter ([Terms of Service](https://twitter.com/en/tos), [Developer Terms](https://developer.twitter.com/en/more/developer-terms.html))
* 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
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);
},
isReadOnly() {
return this.profile && this.profile.ephemeral
},
sourceOrder() {
return this.source ? this.source.order : Infinity
},
@ -210,6 +214,8 @@ export default {
set(value) {
// TODO: Run validation.
if ( this.isReadOnly )
return;
let process = this.item.process;
if ( process ) {
@ -228,6 +234,9 @@ export default {
},
clear() {
if ( this.isReadOnly )
return;
this.profile.delete(this.item.setting);
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;
try {
const url = player.core.state.path;
@ -370,7 +378,6 @@ export default class Metadata extends Module {
}
} catch(err) { /* no op */ }
if ( ! stats || stats.hlsLatencyBroadcaster < -100 )
return {stats};
@ -493,6 +500,24 @@ export default class Metadata extends Module {
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 )
return [
delayed,
@ -510,6 +535,8 @@ export default class Metadata extends Module {
<div class="tw-pd-t-05">
{video_info}
</div>,
desync,
buffer,
tampered
];
@ -522,6 +549,8 @@ export default class Metadata extends Module {
<div class="tw-pd-t-05">
{video_info}
</div>,
desync,
buffer,
tampered
];
}
@ -832,6 +861,7 @@ export default class Metadata extends Module {
const tooltip = maybe_call(def.tooltip, this, data);
if ( el.tip_content !== tooltip ) {
el.tip_content = tooltip;
el.tip.element.innerHTML = '';
setChildren(el.tip.element, tooltip);
}
}

View file

@ -89,7 +89,11 @@ export default class TooltipProvider extends Module {
}
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) {

View file

@ -13,6 +13,9 @@ import SettingsManager from './settings/index';
import AddonManager from './addons';
import ExperimentManager from './experiments';
import {TranslationManager} from './i18n';
import StagingSelector from './staging';
import PubSubClient from './pubsub';
import LoadTracker from './load_tracker';
import Site from './sites/player';
class FrankerFaceZ extends Module {
@ -49,6 +52,9 @@ class FrankerFaceZ extends Module {
this.inject('settings', SettingsManager);
this.inject('experiments', ExperimentManager);
this.inject('i18n', TranslationManager);
this.inject('staging', StagingSelector);
this.inject('load_tracker', LoadTracker);
this.inject('pubsub', PubSubClient);
this.inject('site', Site);
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;
const VER = FrankerFaceZ.version_info = {
const VER = FrankerFaceZ.version_info = Object.freeze({
major: __version_major__,
minor: __version_minor__,
revision: __version_patch__,
extra: __version_prerelease__?.length && __version_prerelease__[0],
commit: __git_commit__,
build: __webpack_hash__,
build: __version_build__,
hash: __webpack_hash__,
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
// of these are unavailable.
FrankerFaceZ.utilities = {
addon: require('utilities/addon'),
blobs: require('utilities/blobs'),
//color: require('utilities/color'),
constants: require('utilities/constants'),
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
// ============================================================================
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 { DEBUG } from 'utilities/constants';
@ -37,6 +37,21 @@ export const Invert = {
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 = {
createTest(config, rule_types, rebuild) {
return createTester(config, rule_types, false, true, rebuild);
@ -83,7 +98,7 @@ export const Constant = {
default: true,
editor: () => import(/* webpackChunkName: 'main-menu' */ './components/basic-toggle.vue')
}
};
// Context Stuff
@ -157,7 +172,11 @@ export const Time = {
export const TheaterMode = {
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',
@ -168,6 +187,19 @@ export const TheaterMode = {
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 = {
createTest(config) {
return ctx => ctx.moderator === config;
@ -374,4 +406,60 @@ export const Title = {
}),
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 = {};
for(const key in FILTERS)
if ( has(FILTERS, key) )
if ( has(FILTERS, key) && FILTERS[key] )
this.filters[key] = FILTERS[key];
@ -184,7 +184,6 @@ export default class SettingsManager extends Module {
}
addFilter(key, data) {
if ( this.filters[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() {
const captured = require('./filters').Time.captured();
const captured = FILTERS?.Time?.captured?.();
if ( ! captured?.length )
return;
@ -743,13 +762,22 @@ export default class SettingsManager extends Module {
old_ids = new Set(old_profiles.map(x => x.id)),
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.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,
changed = false;
@ -831,6 +859,9 @@ export default class SettingsManager extends Module {
* @returns {SettingsProfile}
*/
createProfile(options) {
if ( ! this.enabled )
throw new Error('Unable to create profile before settings have initialized. Please await enable()');
let i = 0;
while( this.__profile_ids[i] )
i++;
@ -858,6 +889,9 @@ export default class SettingsManager extends Module {
* @param {number|SettingsProfile} id - The profile to delete
*/
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 )
id = id.id;
@ -885,6 +919,9 @@ export default class SettingsManager extends Module {
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 )
id = id.id;
@ -905,6 +942,9 @@ export default class SettingsManager extends Module {
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 )
id = id.id;
@ -918,7 +958,19 @@ export default class SettingsManager extends Module {
_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)
context.selectProfiles();
@ -941,11 +993,45 @@ export default class SettingsManager extends Module {
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
// ========================================================================
add(key, definition) {
add(key, definition, source) {
if ( typeof key === 'object' ) {
for(const k in key)
if ( has(key, k) )
@ -960,6 +1046,8 @@ export default class SettingsManager extends Module {
definition.required_by = required_by;
definition.requires = definition.requires || [];
definition.__source = source;
for(const req_key of definition.requires) {
const req = this.definitions.get(req_key);
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' ) {
for(const k in key)
if ( has(key, k) )
@ -1018,6 +1141,8 @@ export default class SettingsManager extends Module {
if ( ! definition.ui )
definition = {ui: definition};
definition.__source = source;
const ui = definition.ui;
ui.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' ) {
for(const k in key)
if ( has(key, k) )
this.addClearable(k, key[k]);
this.addClearable(k, key[k], source);
return;
}
definition.__source = source;
this.clearables[key] = definition;
}

View file

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

View file

@ -1,6 +1,8 @@
'use strict';
import { isValidBlob, deserializeBlob, serializeBlob } from 'src/utilities/blobs';
import { isValidBlob, deserializeBlob, serializeBlob } from 'utilities/blobs';
import { EXTENSION } from 'utilities/constants';
// ============================================================================
// Settings Providers
// ============================================================================
@ -1027,9 +1029,9 @@ export class CrossOriginStorageBridge extends SettingsProvider {
this._last_id = 0;
const frame = this.frame = document.createElement('iframe');
frame.src = this.manager.root.host === 'twitch' ?
'//www.twitch.tv/p/ffz_bridge/' :
'//www.youtube.com/__ffz_bridge/';
frame.src = this.manager.root.host === 'youtube' ?
'//www.youtube.com/__ffz_bridge/' :
'//www.twitch.tv/p/ffz_bridge/';
frame.id = 'ffz-settings-bridge';
frame.style.width = 0;
frame.style.height = 0;
@ -1041,7 +1043,7 @@ export class CrossOriginStorageBridge extends SettingsProvider {
// 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 key = 'cosb';
@ -1254,4 +1256,269 @@ export class CrossOriginStorageBridge extends SettingsProvider {
this._rpc.delete(id);
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) {
const values = [];
for(const v of val)
if ( v.t !== 'inherit' && v.v )
if ( v.t !== 'inherit' && v.t !== 'skip' && v.v )
values.push(v.v);
return values;
@ -119,6 +119,8 @@ export const array_merge = {
had_value = true;
if ( val.t === 'inherit' )
is_trailing = true;
else if ( val.t === 'skip' )
continue;
else if ( is_trailing )
trail.push(val.v);
else

View file

@ -1,5 +1,8 @@
.message > div > .chat-line__message--emote {
vertical-align: baseline;
}
.message > div:not(.modified-emote) > .chat-line__message--emote {
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.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 = {
@ -57,7 +57,7 @@ export default class ClipsSite extends BaseSite {
this.ClipsMenu = this.fine.define(
'clips-menu',
n => n.props?.changeTheme && has(n.state, 'dropdownOpen')
n => n.props?.signup && has(n.state, 'dropdownOpen')
)
document.head.appendChild(createElement('link', {
@ -216,4 +216,6 @@ export default class ClipsSite extends BaseSite {
ClipsSite.CLIP_ROUTES = {
'clip-page': '/:slug'
};
};
ClipsSite.DIALOG_SELECTOR = '#root > div';

View file

@ -107,7 +107,7 @@ export default class Line extends Module {
}, user_block)
] : user_block);
return (<div class="tw-mg-b-1" style={{marginBottom:'0 !important'}}>
return (<div class="tw-mg-b-1">
<div
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"
@ -200,7 +200,7 @@ export default class Line extends Module {
if ( msg.message.userBadges )
for(const badge of msg.message.userBadges)
if ( badge )
if ( badge?.setID )
badges[badge.setID] = badge.version;
const out = {
@ -215,6 +215,7 @@ export default class Line extends Module {
roomLogin: room && room.login,
roomID: room && room.id,
badges,
id: msg.id,
ffz_badges: this.chat.badges.getBadges(author.id, author.login, room?.id, room?.login),
messageParts: msg.message.fragments
};

View file

@ -1,4 +1,5 @@
@import 'styles/main.scss';
@import '../../twitch-twilight/styles/mod_card.scss';
.tw-root--theme-dark, html {
body {
@ -38,4 +39,4 @@
padding: 0.5rem;
display: block !important;
}
}
}

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

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