diff --git a/.github/workflows/changelog-category-check.yml b/.github/workflows/changelog-category-check.yml index fb4bd9b23da..61f57d90498 100644 --- a/.github/workflows/changelog-category-check.yml +++ b/.github/workflows/changelog-category-check.yml @@ -29,5 +29,5 @@ jobs: } return ""; - - uses: pajlads/changelog-checker@v1.0.1 + - uses: pajlads/changelog-checker@v1.0.2 if: steps.label-checker.outputs.result != 'skip' diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index df0c26347c7..23a842223ec 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -20,7 +20,7 @@ jobs: - uses: actions/checkout@v4 - name: Check formatting with Prettier - uses: actionsx/prettier@3d9f7c3fa44c9cb819e68292a328d7f4384be206 + uses: Nerixyz/actionsx-prettier@v3-adj with: # prettier CLI arguments. args: --write . diff --git a/.github/workflows/test-macos.yml b/.github/workflows/test-macos.yml index ea9a288fd6d..dcfe4066918 100644 --- a/.github/workflows/test-macos.yml +++ b/.github/workflows/test-macos.yml @@ -94,5 +94,5 @@ jobs: cd ../pubsub-server-test ./server 127.0.0.1:9050 & cd ../build-test - ctest --repeat until-pass:4 --output-on-failure --exclude-regex ClassicEmoteNameFiltering + ctest --repeat until-pass:4 --output-on-failure working-directory: build-test diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 8d97fe2aeb7..fd11aefdb2c 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -150,7 +150,7 @@ jobs: cd ..\pubsub-server-test .\server.exe 127.0.0.1:9050 & cd ..\build-test - ctest --repeat until-pass:4 --output-on-failure --exclude-regex ClassicEmoteNameFiltering + ctest --repeat until-pass:4 --output-on-failure working-directory: build-test - name: Clean Conan cache diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 03a88ade61a..d41740acaf1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -62,7 +62,7 @@ jobs: -DCHATTERINO_GENERATE_COVERAGE=On \ -DCMAKE_BUILD_TYPE=Debug \ .. - cmake --build . + cmake --build . -j $(nproc) working-directory: build-test - name: Download and extract Twitch PubSub Server Test @@ -88,11 +88,17 @@ jobs: cd ../pubsub-server-test ./server 127.0.0.1:9050 & cd ../build-test - ctest --repeat until-pass:4 --output-on-failure --exclude-regex ClassicEmoteNameFiltering + ctest --repeat until-pass:4 --output-on-failure + working-directory: build-test + + - name: Run benchmark + timeout-minutes: 2 + run: | + ./bin/chatterino-benchmark --benchmark_min_time=1x working-directory: build-test - name: Upload coverage reports to Codecov - uses: codecov/codecov-action@v4.5.0 + uses: codecov/codecov-action@v4.6.0 with: token: ${{ secrets.CODECOV_TOKEN }} plugin: gcov diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d8e976681b..4976ddfaa1c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,9 @@ - Major: Add option to show pronouns in user card. (#5442, #5583) - Major: Release plugins alpha. (#5288) - Major: Improve high-DPI support on Windows. (#4868, #5391) +- Major: Added transparent overlay window (default keybind: CTRL + ALT + N). (#4746) - Minor: Removed the Ctrl+Shift+L hotkey for toggling the "live only" tab visibility state. (#5530) +- Minor: Add support for Shared Chat messages. Shared chat messages can be filtered with the `flags.shared` filter variable, or with search using `is:shared`. Some messages like subscriptions are filtered on purpose to avoid confusion for the broadcaster. If you have both channels participating in Shared Chat open, only one of the message triggering your highlight will trigger. (#5606, #5625) - Minor: Moved tab visibility control to a submenu, without any toggle actions. (#5530) - Minor: Add option to customise Moderation buttons with images. (#5369) - Minor: Colored usernames now update on the fly when changing the "Color @usernames" setting. (#5300) @@ -30,7 +32,8 @@ - Minor: Removed experimental IRC support. (#5547) - Minor: Moderators can now see which mods start and cancel raids. (#5563) - Minor: The emote popup now reloads when Twitch emotes are reloaded. (#5580) -- Bugfix: Fixed tab move animation occasionally failing to start after closing a tab. (#5426) +- Minor: Added `--login ` CLI argument to specify which account to start logged in as. (#5626) +- Bugfix: Fixed tab move animation occasionally failing to start after closing a tab. (#5426, #5612) - Bugfix: If a network request errors with 200 OK, Qt's error code is now reported instead of the HTTP status. (#5378) - Bugfix: Fixed restricted users usernames not being clickable. (#5405) - Bugfix: Fixed a crash that could occur when logging was enabled in IRC servers that were removed. (#5419) @@ -49,6 +52,11 @@ - Bugfix: Fixed account switch not being saved if no other settings were changed. (#5558) - Bugfix: Fixed some tooltips not being readable. (#5578) - Bugfix: Fixed log files being locked longer than needed. (#5592) +- Bugfix: Fixed global badges not showing in anonymous mode. (#5599) +- Bugfix: Fixed grammar in the user highlight page. (#5602) +- Bugfix: Fixed incorrect message being disabled in some cases upon approving or denying an automod caught message. (#5611) +- Bugfix: Fixed double-click selection not working when clicking outside a message. (#5617) +- Bugfix: Fixed emotes starting with ":" not tab-completing. (#5603) - Dev: Update Windows build from Qt 6.5.0 to Qt 6.7.1. (#5420) - Dev: Update vcpkg build Qt from 6.5.0 to 6.7.0, boost from 1.83.0 to 1.85.0, openssl from 3.1.3 to 3.3.0. (#5422) - Dev: Unsingletonize `ISoundController`. (#5462) @@ -85,8 +93,16 @@ - Dev: Fixed benchmarks segfaulting on run. (#5559) - Dev: Refactored `MessageBuilder` to be a single class. (#5548) - Dev: Recent changes are now shown in the nightly release description. (#5553, #5554, #5593) -- Dev: The timer for `StreamerMode` is now destroyed on the correct thread. (#5571) +- Dev: The timer for `StreamerMode` is now destroyed on the correct thread. (#5571, #5624) - Dev: Cleanup some parts of the `magic_enum` adaptation for Qt. (#5587) +- Dev: Refactored `static`s in headers to only be present once in the final app. (#5588) +- Dev: Run benchmarks in CI. (#5610) +- Dev: Added more tests for input completion. (#5604) +- Dev: Refactored legacy Unicode zero-width-joiner replacement. (#5594) +- Dev: The JSON output when copying a message (SHIFT + right-click) is now more extensive. (#5600) +- Dev: Twitch messages are now sent using Twitch's Helix API instead of IRC by default. (#5607) +- Dev: `GIFTimer` is no longer initialized in tests. (#5608) +- Dev: Emojis now use flags instead of a set of strings for capabilities. (#5616) ## 2.5.1 diff --git a/benchmarks/src/RecentMessages.cpp b/benchmarks/src/RecentMessages.cpp index 81a7c1cf9d2..530d07f1e1f 100644 --- a/benchmarks/src/RecentMessages.cpp +++ b/benchmarks/src/RecentMessages.cpp @@ -4,7 +4,9 @@ #include "messages/Emote.hpp" #include "mocks/BaseApplication.hpp" #include "mocks/DisabledStreamerMode.hpp" +#include "mocks/Emotes.hpp" #include "mocks/LinkResolver.hpp" +#include "mocks/Logging.hpp" #include "mocks/TwitchIrcServer.hpp" #include "mocks/UserData.hpp" #include "providers/bttv/BttvEmotes.hpp" @@ -16,7 +18,6 @@ #include "providers/seventv/SeventvEmotes.hpp" #include "providers/twitch/TwitchBadges.hpp" #include "providers/twitch/TwitchChannel.hpp" -#include "singletons/Emotes.hpp" #include "singletons/Resources.hpp" #include @@ -110,8 +111,14 @@ class MockApplication : public mock::BaseApplication return &this->linkResolver; } + ILogging *getChatLogger() override + { + return &this->logging; + } + + mock::EmptyLogging logging; AccountController accounts; - Emotes emotes; + mock::Emotes emotes; mock::UserDataController userData; mock::MockTwitchIrcServer twitch; mock::EmptyLinkResolver linkResolver; diff --git a/docs/ChatterinoTheme.schema.json b/docs/ChatterinoTheme.schema.json index 972d7598af0..7e23e73a2a3 100644 --- a/docs/ChatterinoTheme.schema.json +++ b/docs/ChatterinoTheme.schema.json @@ -215,6 +215,47 @@ "text": { "$ref": "#/definitions/qt-color" } }, "required": ["backgrounds", "line", "text"] + }, + "text-colors": { + "type": "object", + "additionalProperties": false, + "properties": { + "caret": { "$ref": "#/definitions/qt-color" }, + "chatPlaceholder": { "$ref": "#/definitions/qt-color" }, + "link": { "$ref": "#/definitions/qt-color" }, + "regular": { "$ref": "#/definitions/qt-color" }, + "system": { "$ref": "#/definitions/qt-color" } + }, + "required": ["caret", "chatPlaceholder", "link", "regular", "system"] + }, + "message-backgrounds": { + "type": "object", + "additionalProperties": false, + "properties": { + "alternate": { "$ref": "#/definitions/qt-color" }, + "regular": { "$ref": "#/definitions/qt-color" } + }, + "required": ["alternate", "regular"] + }, + "message-colors": { + "type": "object", + "additionalProperties": false, + "properties": { + "backgrounds": { "$ref": "#/definitions/message-backgrounds" }, + "disabled": { "$ref": "#/definitions/qt-color" }, + "highlightAnimationEnd": { "$ref": "#/definitions/qt-color" }, + "highlightAnimationStart": { "$ref": "#/definitions/qt-color" }, + "selection": { "$ref": "#/definitions/qt-color" }, + "textColors": { "$ref": "#/definitions/text-colors" } + }, + "required": [ + "backgrounds", + "disabled", + "highlightAnimationEnd", + "highlightAnimationStart", + "selection", + "textColors" + ] } }, "type": "object", @@ -229,37 +270,12 @@ "type": "object", "additionalProperties": false, "properties": { - "backgrounds": { - "type": "object", - "additionalProperties": false, - "properties": { - "alternate": { "$ref": "#/definitions/qt-color" }, - "regular": { "$ref": "#/definitions/qt-color" } - }, - "required": ["alternate", "regular"] - }, + "backgrounds": { "$ref": "#/definitions/message-backgrounds" }, "disabled": { "$ref": "#/definitions/qt-color" }, "highlightAnimationEnd": { "$ref": "#/definitions/qt-color" }, "highlightAnimationStart": { "$ref": "#/definitions/qt-color" }, "selection": { "$ref": "#/definitions/qt-color" }, - "textColors": { - "type": "object", - "additionalProperties": false, - "properties": { - "caret": { "$ref": "#/definitions/qt-color" }, - "chatPlaceholder": { "$ref": "#/definitions/qt-color" }, - "link": { "$ref": "#/definitions/qt-color" }, - "regular": { "$ref": "#/definitions/qt-color" }, - "system": { "$ref": "#/definitions/qt-color" } - }, - "required": [ - "caret", - "chatPlaceholder", - "link", - "regular", - "system" - ] - } + "textColors": { "$ref": "#/definitions/text-colors" } }, "required": [ "backgrounds", @@ -270,6 +286,27 @@ "textColors" ] }, + "overlayMessages": { + "type": "object", + "additionalProperties": false, + "properties": { + "backgrounds": { "$ref": "#/definitions/message-backgrounds" }, + "disabled": { "$ref": "#/definitions/qt-color" }, + "selection": { "$ref": "#/definitions/qt-color" }, + "textColors": { "$ref": "#/definitions/text-colors" }, + "background": { + "$ref": "#/definitions/qt-color", + "description": "Note: The alpha value is ignored (set through the settings)" + } + }, + "required": [ + "backgrounds", + "disabled", + "selection", + "textColors", + "background" + ] + }, "scrollbars": { "type": "object", "additionalProperties": false, @@ -376,6 +413,7 @@ "required": [ "accent", "messages", + "overlayMessages", "scrollbars", "splits", "tabs", diff --git a/lib/settings b/lib/settings index 7011003eabf..c58874c1aa5 160000 --- a/lib/settings +++ b/lib/settings @@ -1 +1 @@ -Subproject commit 7011003eabf6ac95c19f523968a72771fc177fcd +Subproject commit c58874c1aa5d0619df2c975bcb87433941b46920 diff --git a/mocks/include/mocks/Emotes.hpp b/mocks/include/mocks/Emotes.hpp new file mode 100644 index 00000000000..96c5dcc6a24 --- /dev/null +++ b/mocks/include/mocks/Emotes.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include "singletons/Emotes.hpp" + +namespace chatterino::mock { + +class Emotes : public IEmotes +{ +public: + Emotes() + { + this->emojis.load(); + // don't initialize GIFTimer + } + + ITwitchEmotes *getTwitchEmotes() override + { + return &this->twitch; + } + + IEmojis *getEmojis() override + { + return &this->emojis; + } + + GIFTimer &getGIFTimer() override + { + return this->gifTimer; + } + +private: + TwitchEmotes twitch; + Emojis emojis; + + GIFTimer gifTimer; +}; + +} // namespace chatterino::mock diff --git a/resources/themes/Black.json b/resources/themes/Black.json index 20014cbed0d..c194aa793ad 100644 --- a/resources/themes/Black.json +++ b/resources/themes/Black.json @@ -22,6 +22,22 @@ "system": "#8c7f7f" } }, + "overlayMessages": { + "backgrounds": { + "alternate": "#32000000", + "regular": "transparent" + }, + "disabled": "#64000000", + "selection": "#40ffffff", + "textColors": { + "caret": "#ffffff", + "chatPlaceholder": "#5d5555", + "link": "#4286f4", + "regular": "#ffffff", + "system": "#8c7f7f" + }, + "background": "#000" + }, "scrollbars": { "background": "#00000000", "thumb": "#4d4d4d", diff --git a/resources/themes/Dark.json b/resources/themes/Dark.json index cc0ff7f07fd..2b85545d766 100644 --- a/resources/themes/Dark.json +++ b/resources/themes/Dark.json @@ -22,6 +22,22 @@ "system": "#8c7f7f" } }, + "overlayMessages": { + "backgrounds": { + "alternate": "#32000000", + "regular": "transparent" + }, + "disabled": "#64000000", + "selection": "#40ffffff", + "textColors": { + "caret": "#ffffff", + "chatPlaceholder": "#5d5555", + "link": "#4286f4", + "regular": "#ffffff", + "system": "#8c7f7f" + }, + "background": "#000" + }, "scrollbars": { "background": "#00000000", "thumb": "#575757", diff --git a/resources/themes/Light.json b/resources/themes/Light.json index 2097ff81b95..ed610e313d9 100644 --- a/resources/themes/Light.json +++ b/resources/themes/Light.json @@ -22,6 +22,22 @@ "system": "#8c7f7f" } }, + "overlayMessages": { + "backgrounds": { + "alternate": "#32000000", + "regular": "transparent" + }, + "disabled": "#64000000", + "selection": "#40ffffff", + "textColors": { + "caret": "#ffffff", + "chatPlaceholder": "#5d5555", + "link": "#4286f4", + "regular": "#ffffff", + "system": "#8c7f7f" + }, + "background": "#333" + }, "scrollbars": { "background": "#00000000", "thumb": "#a8a8a8", diff --git a/resources/themes/White.json b/resources/themes/White.json index 950cfc6e039..89b317f3742 100644 --- a/resources/themes/White.json +++ b/resources/themes/White.json @@ -22,6 +22,22 @@ "system": "#8c7f7f" } }, + "overlayMessages": { + "backgrounds": { + "alternate": "#32000000", + "regular": "transparent" + }, + "disabled": "#64000000", + "selection": "#40ffffff", + "textColors": { + "caret": "#ffffff", + "chatPlaceholder": "#5d5555", + "link": "#4286f4", + "regular": "#ffffff", + "system": "#8c7f7f" + }, + "background": "#333" + }, "scrollbars": { "background": "#00000000", "thumb": "#b3b3b3", diff --git a/resources/twitch-badges.json b/resources/twitch-badges.json index b90fa1f25a4..4bef96a717a 100644 --- a/resources/twitch-badges.json +++ b/resources/twitch-badges.json @@ -1 +1 @@ -{"data":[{"set_id":"1979-revolution_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/1979%20Revolution/details","description":"1979 Revolution","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/7833bb6e-d20d-48ff-a58d-67fe827a4f84/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/7833bb6e-d20d-48ff-a58d-67fe827a4f84/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/7833bb6e-d20d-48ff-a58d-67fe827a4f84/3","title":"1979 Revolution"}]},{"set_id":"60-seconds_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/60%20Seconds!/details","description":"60 Seconds!","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/1e7252f9-7e80-4d3d-ae42-319f030cca99/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/1e7252f9-7e80-4d3d-ae42-319f030cca99/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/1e7252f9-7e80-4d3d-ae42-319f030cca99/3","title":"60 Seconds!"}]},{"set_id":"60-seconds_2","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/60%20Seconds!/details","description":"60 Seconds!","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/64513f7d-21dd-4a05-a699-d73761945cf9/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/64513f7d-21dd-4a05-a699-d73761945cf9/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/64513f7d-21dd-4a05-a699-d73761945cf9/3","title":"60 Seconds!"}]},{"set_id":"60-seconds_3","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/60%20Seconds!/details","description":"60 Seconds!","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/f4306617-0f96-476f-994e-5304f81bcc6e/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/f4306617-0f96-476f-994e-5304f81bcc6e/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/f4306617-0f96-476f-994e-5304f81bcc6e/3","title":"60 Seconds!"}]},{"set_id":"H1Z1_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/H1Z1/details","description":"H1Z1","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/fc71386c-86cd-11e7-a55d-43f591dc0c71/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/fc71386c-86cd-11e7-a55d-43f591dc0c71/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/fc71386c-86cd-11e7-a55d-43f591dc0c71/3","title":"H1Z1"}]},{"set_id":"admin","versions":[{"click_action":null,"click_url":null,"description":"Twitch Admin","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/9ef7e029-4cdf-4d4d-a0d5-e2b3fb2583fe/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/9ef7e029-4cdf-4d4d-a0d5-e2b3fb2583fe/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/9ef7e029-4cdf-4d4d-a0d5-e2b3fb2583fe/3","title":"Admin"}]},{"set_id":"ambassador","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/team/ambassadors","description":"Twitch Ambassador","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/2cbc339f-34f4-488a-ae51-efdf74f4e323/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/2cbc339f-34f4-488a-ae51-efdf74f4e323/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/2cbc339f-34f4-488a-ae51-efdf74f4e323/3","title":"Twitch Ambassador"}]},{"set_id":"anomaly-2_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Anomaly%202/details","description":"Anomaly 2","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/d1d1ad54-40a6-492b-882e-dcbdce5fa81e/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/d1d1ad54-40a6-492b-882e-dcbdce5fa81e/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/d1d1ad54-40a6-492b-882e-dcbdce5fa81e/3","title":"Anomaly 2"}]},{"set_id":"anomaly-warzone-earth_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Anomaly:%20Warzone%20Earth/details","description":"Anomaly Warzone Earth","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/858be873-fb1f-47e5-ad34-657f40d3d156/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/858be873-fb1f-47e5-ad34-657f40d3d156/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/858be873-fb1f-47e5-ad34-657f40d3d156/3","title":"Anomaly Warzone Earth"}]},{"set_id":"anonymous-cheerer","versions":[{"click_action":null,"click_url":null,"description":"Anonymous Cheerer","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ca3db7f7-18f5-487e-a329-cd0b538ee979/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ca3db7f7-18f5-487e-a329-cd0b538ee979/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ca3db7f7-18f5-487e-a329-cd0b538ee979/3","title":"Anonymous Cheerer"}]},{"set_id":"artist-badge","versions":[{"click_action":null,"click_url":null,"description":"Artist on this Channel","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/4300a897-03dc-4e83-8c0e-c332fee7057f/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/4300a897-03dc-4e83-8c0e-c332fee7057f/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/4300a897-03dc-4e83-8c0e-c332fee7057f/3","title":"Artist"}]},{"set_id":"axiom-verge_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Axiom%20Verge/details","description":"Axiom Verge","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/f209b747-45ee-42f6-8baf-ea7542633d10/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/f209b747-45ee-42f6-8baf-ea7542633d10/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/f209b747-45ee-42f6-8baf-ea7542633d10/3","title":"Axiom Verge"}]},{"set_id":"battlechefbrigade_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Battle%20Chef%20Brigade/details","description":"Battle Chef Brigade","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/24e32e67-33cd-4227-ad96-f0a7fc836107/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/24e32e67-33cd-4227-ad96-f0a7fc836107/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/24e32e67-33cd-4227-ad96-f0a7fc836107/3","title":"Battle Chef Brigade"}]},{"set_id":"battlechefbrigade_2","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Battle%20Chef%20Brigade/details","description":"Battle Chef Brigade","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ef1e96e8-a0f9-40b6-87af-2977d3c004bb/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ef1e96e8-a0f9-40b6-87af-2977d3c004bb/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ef1e96e8-a0f9-40b6-87af-2977d3c004bb/3","title":"Battle Chef Brigade"}]},{"set_id":"battlechefbrigade_3","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Battle%20Chef%20Brigade/details","description":"Battle Chef Brigade","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/107ebb20-4fcd-449a-9931-cd3f81b84c70/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/107ebb20-4fcd-449a-9931-cd3f81b84c70/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/107ebb20-4fcd-449a-9931-cd3f81b84c70/3","title":"Battle Chef Brigade"}]},{"set_id":"battlerite_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Battlerite/details","description":"Battlerite","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/484ebda9-f7fa-4c67-b12b-c80582f3cc61/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/484ebda9-f7fa-4c67-b12b-c80582f3cc61/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/484ebda9-f7fa-4c67-b12b-c80582f3cc61/3","title":"Battlerite"}]},{"set_id":"bits","versions":[{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":" ","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/73b5c3fb-24f9-4a82-a852-2f475b59411c/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/73b5c3fb-24f9-4a82-a852-2f475b59411c/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/73b5c3fb-24f9-4a82-a852-2f475b59411c/3","title":"cheer 1"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":" ","id":"100","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/09d93036-e7ce-431c-9a9e-7044297133f2/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/09d93036-e7ce-431c-9a9e-7044297133f2/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/09d93036-e7ce-431c-9a9e-7044297133f2/3","title":"cheer 100"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":" ","id":"1000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/0d85a29e-79ad-4c63-a285-3acd2c66f2ba/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/0d85a29e-79ad-4c63-a285-3acd2c66f2ba/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/0d85a29e-79ad-4c63-a285-3acd2c66f2ba/3","title":"cheer 1000"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":" ","id":"10000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/68af213b-a771-4124-b6e3-9bb6d98aa732/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/68af213b-a771-4124-b6e3-9bb6d98aa732/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/68af213b-a771-4124-b6e3-9bb6d98aa732/3","title":"cheer 10000"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":" ","id":"100000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/96f0540f-aa63-49e1-a8b3-259ece3bd098/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/96f0540f-aa63-49e1-a8b3-259ece3bd098/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/96f0540f-aa63-49e1-a8b3-259ece3bd098/3","title":"cheer 100000"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":" ","id":"1000000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/494d1c8e-c3b2-4d88-8528-baff57c9bd3f/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/494d1c8e-c3b2-4d88-8528-baff57c9bd3f/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/494d1c8e-c3b2-4d88-8528-baff57c9bd3f/3","title":"cheer 1000000"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":" ","id":"1250000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ce217209-4615-4bf8-81e3-57d06b8b9dc7/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ce217209-4615-4bf8-81e3-57d06b8b9dc7/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ce217209-4615-4bf8-81e3-57d06b8b9dc7/3","title":"cheer 1250000"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":" ","id":"1500000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/c4eba5b4-17a7-40a1-a668-bc1972c1e24d/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/c4eba5b4-17a7-40a1-a668-bc1972c1e24d/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/c4eba5b4-17a7-40a1-a668-bc1972c1e24d/3","title":"cheer 1500000"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":" ","id":"1750000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/183f1fd8-aaf4-450c-a413-e53f839f0f82/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/183f1fd8-aaf4-450c-a413-e53f839f0f82/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/183f1fd8-aaf4-450c-a413-e53f839f0f82/3","title":"cheer 1750000"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":" ","id":"200000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/4a0b90c4-e4ef-407f-84fe-36b14aebdbb6/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/4a0b90c4-e4ef-407f-84fe-36b14aebdbb6/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/4a0b90c4-e4ef-407f-84fe-36b14aebdbb6/3","title":"cheer 200000"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":" ","id":"2000000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/7ea89c53-1a3b-45f9-9223-d97ae19089f2/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/7ea89c53-1a3b-45f9-9223-d97ae19089f2/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/7ea89c53-1a3b-45f9-9223-d97ae19089f2/3","title":"cheer 2000000"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":" ","id":"25000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/64ca5920-c663-4bd8-bfb1-751b4caea2dd/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/64ca5920-c663-4bd8-bfb1-751b4caea2dd/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/64ca5920-c663-4bd8-bfb1-751b4caea2dd/3","title":"cheer 25000"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":" ","id":"2500000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/cf061daf-d571-4811-bcc2-c55c8792bc8f/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/cf061daf-d571-4811-bcc2-c55c8792bc8f/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/cf061daf-d571-4811-bcc2-c55c8792bc8f/3","title":"cheer 2500000"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":" ","id":"300000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ac13372d-2e94-41d1-ae11-ecd677f69bb6/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ac13372d-2e94-41d1-ae11-ecd677f69bb6/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ac13372d-2e94-41d1-ae11-ecd677f69bb6/3","title":"cheer 300000"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":" ","id":"3000000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/5671797f-5e9f-478c-a2b5-eb086c8928cf/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/5671797f-5e9f-478c-a2b5-eb086c8928cf/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/5671797f-5e9f-478c-a2b5-eb086c8928cf/3","title":"cheer 3000000"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":" ","id":"3500000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/c3d218f5-1e45-419d-9c11-033a1ae54d3a/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/c3d218f5-1e45-419d-9c11-033a1ae54d3a/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/c3d218f5-1e45-419d-9c11-033a1ae54d3a/3","title":"cheer 3500000"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":" ","id":"400000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/a8f393af-76e6-4aa2-9dd0-7dcc1c34f036/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/a8f393af-76e6-4aa2-9dd0-7dcc1c34f036/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/a8f393af-76e6-4aa2-9dd0-7dcc1c34f036/3","title":"cheer 400000"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":" ","id":"4000000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/79fe642a-87f3-40b1-892e-a341747b6e08/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/79fe642a-87f3-40b1-892e-a341747b6e08/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/79fe642a-87f3-40b1-892e-a341747b6e08/3","title":"cheer 4000000"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":" ","id":"4500000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/736d4156-ac67-4256-a224-3e6e915436db/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/736d4156-ac67-4256-a224-3e6e915436db/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/736d4156-ac67-4256-a224-3e6e915436db/3","title":"cheer 4500000"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":" ","id":"5000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/57cd97fc-3e9e-4c6d-9d41-60147137234e/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/57cd97fc-3e9e-4c6d-9d41-60147137234e/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/57cd97fc-3e9e-4c6d-9d41-60147137234e/3","title":"cheer 5000"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":" ","id":"50000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/62310ba7-9916-4235-9eba-40110d67f85d/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/62310ba7-9916-4235-9eba-40110d67f85d/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/62310ba7-9916-4235-9eba-40110d67f85d/3","title":"cheer 50000"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":" ","id":"500000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/f6932b57-6a6e-4062-a770-dfbd9f4302e5/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/f6932b57-6a6e-4062-a770-dfbd9f4302e5/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/f6932b57-6a6e-4062-a770-dfbd9f4302e5/3","title":"cheer 500000"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":" ","id":"5000000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/3f085f85-8d15-4a03-a829-17fca7bf1bc2/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/3f085f85-8d15-4a03-a829-17fca7bf1bc2/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/3f085f85-8d15-4a03-a829-17fca7bf1bc2/3","title":"cheer 5000000"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":" ","id":"600000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/4d908059-f91c-4aef-9acb-634434f4c32e/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/4d908059-f91c-4aef-9acb-634434f4c32e/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/4d908059-f91c-4aef-9acb-634434f4c32e/3","title":"cheer 600000"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":" ","id":"700000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/a1d2a824-f216-4b9f-9642-3de8ed370957/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/a1d2a824-f216-4b9f-9642-3de8ed370957/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/a1d2a824-f216-4b9f-9642-3de8ed370957/3","title":"cheer 700000"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":" ","id":"75000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ce491fa4-b24f-4f3b-b6ff-44b080202792/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ce491fa4-b24f-4f3b-b6ff-44b080202792/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ce491fa4-b24f-4f3b-b6ff-44b080202792/3","title":"cheer 75000"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":" ","id":"800000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/5ec2ee3e-5633-4c2a-8e77-77473fe409e6/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/5ec2ee3e-5633-4c2a-8e77-77473fe409e6/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/5ec2ee3e-5633-4c2a-8e77-77473fe409e6/3","title":"cheer 800000"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":" ","id":"900000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/088c58c6-7c38-45ba-8f73-63ef24189b84/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/088c58c6-7c38-45ba-8f73-63ef24189b84/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/088c58c6-7c38-45ba-8f73-63ef24189b84/3","title":"cheer 900000"}]},{"set_id":"bits-charity","versions":[{"click_action":"visit_url","click_url":"https://link.twitch.tv/blizzardofbits","description":"Supported their favorite streamer during the 2018 Blizzard of Bits","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/a539dc18-ae19-49b0-98c4-8391a594332b/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/a539dc18-ae19-49b0-98c4-8391a594332b/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/a539dc18-ae19-49b0-98c4-8391a594332b/3","title":"Direct Relief - Charity 2018"}]},{"set_id":"bits-leader","versions":[{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":"Ranked as a top cheerer on this channel","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/8bedf8c3-7a6d-4df2-b62f-791b96a5dd31/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/8bedf8c3-7a6d-4df2-b62f-791b96a5dd31/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/8bedf8c3-7a6d-4df2-b62f-791b96a5dd31/3","title":"Bits Leader 1"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":"Ranked as a top cheerer on this channel","id":"2","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/f04baac7-9141-4456-a0e7-6301bcc34138/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/f04baac7-9141-4456-a0e7-6301bcc34138/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/f04baac7-9141-4456-a0e7-6301bcc34138/3","title":"Bits Leader 2"},{"click_action":"visit_url","click_url":"https://bits.twitch.tv","description":"Ranked as a top cheerer on this channel","id":"3","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/f1d2aab6-b647-47af-965b-84909cf303aa/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/f1d2aab6-b647-47af-965b-84909cf303aa/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/f1d2aab6-b647-47af-965b-84909cf303aa/3","title":"Bits Leader 3"}]},{"set_id":"brawlhalla_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Brawlhalla/details","description":"Brawlhalla","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/bf6d6579-ab02-4f0a-9f64-a51c37040858/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/bf6d6579-ab02-4f0a-9f64-a51c37040858/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/bf6d6579-ab02-4f0a-9f64-a51c37040858/3","title":"Brawlhalla"}]},{"set_id":"broadcaster","versions":[{"click_action":null,"click_url":null,"description":"Broadcaster","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/5527c58c-fb7d-422d-b71b-f309dcb85cc1/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/5527c58c-fb7d-422d-b71b-f309dcb85cc1/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/5527c58c-fb7d-422d-b71b-f309dcb85cc1/3","title":"Broadcaster"}]},{"set_id":"broken-age_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Broken%20Age/details","description":"Broken Age","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/56885ed2-9a09-4c8e-8131-3eb9ec15af94/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/56885ed2-9a09-4c8e-8131-3eb9ec15af94/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/56885ed2-9a09-4c8e-8131-3eb9ec15af94/3","title":"Broken Age"}]},{"set_id":"bubsy-the-woolies_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Bubsy:%20The%20Woolies%20Strike%20Back/details","description":"Bubsy: The Woolies Strike Back","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/c8129382-1f4e-4d15-a8d2-48bdddba9b81/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/c8129382-1f4e-4d15-a8d2-48bdddba9b81/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/c8129382-1f4e-4d15-a8d2-48bdddba9b81/3","title":"Bubsy: The Woolies Strike Back"}]},{"set_id":"chatter-cs-go-2022","versions":[{"click_action":null,"click_url":null,"description":"Chatted during CS:GO Week Brazil 2022","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/57b6bd6b-a1b5-4204-9e6c-eb8ed5831603/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/57b6bd6b-a1b5-4204-9e6c-eb8ed5831603/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/57b6bd6b-a1b5-4204-9e6c-eb8ed5831603/3","title":"CS:GO Week Brazil 2022"}]},{"set_id":"clip-champ","versions":[{"click_action":"visit_url","click_url":"https://help.twitch.tv/customer/portal/articles/2918323-clip-champs-guide","description":"Power Clipper","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/f38976e0-ffc9-11e7-86d6-7f98b26a9d79/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/f38976e0-ffc9-11e7-86d6-7f98b26a9d79/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/f38976e0-ffc9-11e7-86d6-7f98b26a9d79/3","title":"Power Clipper"}]},{"set_id":"creator-cs-go-2022","versions":[{"click_action":null,"click_url":null,"description":"Streamed during CS:GO Week Brazil 2022","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/a2ea6df9-ac0a-4956-bfe9-e931f50b94fa/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/a2ea6df9-ac0a-4956-bfe9-e931f50b94fa/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/a2ea6df9-ac0a-4956-bfe9-e931f50b94fa/3","title":"CS:GO Week Brazil 2022"}]},{"set_id":"cuphead_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Cuphead/details","description":"Cuphead","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/4384659a-a2e3-11e7-a564-87f6b1288bab/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/4384659a-a2e3-11e7-a564-87f6b1288bab/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/4384659a-a2e3-11e7-a564-87f6b1288bab/3","title":"Cuphead"}]},{"set_id":"darkest-dungeon_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Darkest%20Dungeon/details","description":"Darkest Dungeon","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/52a98ddd-cc79-46a8-9fe3-30f8c719bc2d/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/52a98ddd-cc79-46a8-9fe3-30f8c719bc2d/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/52a98ddd-cc79-46a8-9fe3-30f8c719bc2d/3","title":"Darkest Dungeon"}]},{"set_id":"deceit_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Deceit/details","description":"Deceit","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/b14fef48-4ff9-4063-abf6-579489234fe9/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/b14fef48-4ff9-4063-abf6-579489234fe9/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/b14fef48-4ff9-4063-abf6-579489234fe9/3","title":"Deceit"}]},{"set_id":"devil-may-cry-hd_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Devil%20May%20Cry%20HD%20Collection/details","description":"Devil May Cry HD Collection","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/633877d4-a91c-4c36-b75b-803f82b1352f/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/633877d4-a91c-4c36-b75b-803f82b1352f/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/633877d4-a91c-4c36-b75b-803f82b1352f/3","title":"Devil May Cry HD Collection"}]},{"set_id":"devil-may-cry-hd_2","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Devil%20May%20Cry%20HD%20Collection/details","description":"Devil May Cry HD Collection","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/408548fe-aa74-4d53-b5e9-960103d9b865/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/408548fe-aa74-4d53-b5e9-960103d9b865/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/408548fe-aa74-4d53-b5e9-960103d9b865/3","title":"Devil May Cry HD Collection"}]},{"set_id":"devil-may-cry-hd_3","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Devil%20May%20Cry%20HD%20Collection/details","description":"Devil May Cry HD Collection","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/df84c5bf-8d66-48e2-b9fb-c014cc9b3945/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/df84c5bf-8d66-48e2-b9fb-c014cc9b3945/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/df84c5bf-8d66-48e2-b9fb-c014cc9b3945/3","title":"Devil May Cry HD Collection"}]},{"set_id":"devil-may-cry-hd_4","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Devil%20May%20Cry%20HD%20Collection/details","description":"Devil May Cry HD Collection","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/af836b94-8ffd-4c0a-b7d8-a92fad5e3015/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/af836b94-8ffd-4c0a-b7d8-a92fad5e3015/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/af836b94-8ffd-4c0a-b7d8-a92fad5e3015/3","title":"Devil May Cry HD Collection"}]},{"set_id":"devilian_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Devilian/details","description":"Devilian","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/3cb92b57-1eef-451c-ac23-4d748128b2c5/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/3cb92b57-1eef-451c-ac23-4d748128b2c5/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/3cb92b57-1eef-451c-ac23-4d748128b2c5/3","title":"Devilian"}]},{"set_id":"duelyst_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Duelyst/details","description":"Duelyst","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/7d9c12f4-a2ac-4e88-8026-d1a330468282/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/7d9c12f4-a2ac-4e88-8026-d1a330468282/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/7d9c12f4-a2ac-4e88-8026-d1a330468282/3","title":"Duelyst"}]},{"set_id":"duelyst_2","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Duelyst/details","description":"Duelyst","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/1938acd3-2d18-471d-b1af-44f2047c033c/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/1938acd3-2d18-471d-b1af-44f2047c033c/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/1938acd3-2d18-471d-b1af-44f2047c033c/3","title":"Duelyst"}]},{"set_id":"duelyst_3","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Duelyst/details","description":"Duelyst","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/344c07fc-1632-47c6-9785-e62562a6b760/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/344c07fc-1632-47c6-9785-e62562a6b760/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/344c07fc-1632-47c6-9785-e62562a6b760/3","title":"Duelyst"}]},{"set_id":"duelyst_4","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Duelyst/details","description":"Duelyst","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/39e717a8-00bc-49cc-b6d4-3ea91ee1be25/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/39e717a8-00bc-49cc-b6d4-3ea91ee1be25/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/39e717a8-00bc-49cc-b6d4-3ea91ee1be25/3","title":"Duelyst"}]},{"set_id":"duelyst_5","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Duelyst/details","description":"Duelyst","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/290419b6-484a-47da-ad14-a99d6581f758/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/290419b6-484a-47da-ad14-a99d6581f758/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/290419b6-484a-47da-ad14-a99d6581f758/3","title":"Duelyst"}]},{"set_id":"duelyst_6","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Duelyst/details","description":"Duelyst","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/c5e54a4b-0bf1-463a-874a-38524579aed0/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/c5e54a4b-0bf1-463a-874a-38524579aed0/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/c5e54a4b-0bf1-463a-874a-38524579aed0/3","title":"Duelyst"}]},{"set_id":"duelyst_7","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Duelyst/details","description":"Duelyst","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/cf508179-3183-4987-97e0-56ca44babb9f/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/cf508179-3183-4987-97e0-56ca44babb9f/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/cf508179-3183-4987-97e0-56ca44babb9f/3","title":"Duelyst"}]},{"set_id":"enter-the-gungeon_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Enter%20the%20Gungeon/details","description":"Enter The Gungeon","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/53c9af0b-84f6-4f9d-8c80-4bc51321a37d/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/53c9af0b-84f6-4f9d-8c80-4bc51321a37d/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/53c9af0b-84f6-4f9d-8c80-4bc51321a37d/3","title":"Enter The Gungeon"}]},{"set_id":"eso_1","versions":[{"click_action":null,"click_url":null,"description":"Elder Scrolls Online","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/18647a68-a35f-48d7-bf97-ae5deb6b277d/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/18647a68-a35f-48d7-bf97-ae5deb6b277d/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/18647a68-a35f-48d7-bf97-ae5deb6b277d/3","title":"Elder Scrolls Online"}]},{"set_id":"extension","versions":[{"click_action":null,"click_url":null,"description":"Extension","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ea8b0f8c-aa27-11e8-ba0c-1370ffff3854/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ea8b0f8c-aa27-11e8-ba0c-1370ffff3854/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ea8b0f8c-aa27-11e8-ba0c-1370ffff3854/3","title":"Extension"}]},{"set_id":"firewatch_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Firewatch/details","description":"Firewatch","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/b6bf4889-4902-49e2-9658-c0132e71c9c4/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/b6bf4889-4902-49e2-9658-c0132e71c9c4/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/b6bf4889-4902-49e2-9658-c0132e71c9c4/3","title":"Firewatch"}]},{"set_id":"founder","versions":[{"click_action":"visit_url","click_url":"https://help.twitch.tv/s/article/founders-badge","description":"Founder","id":"0","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/511b78a9-ab37-472f-9569-457753bbe7d3/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/511b78a9-ab37-472f-9569-457753bbe7d3/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/511b78a9-ab37-472f-9569-457753bbe7d3/3","title":"Founder"}]},{"set_id":"frozen-cortext_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Frozen%20Cortex/details","description":"Frozen Cortext","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/2015f087-01b5-4a01-a2bb-ecb4d6be5240/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/2015f087-01b5-4a01-a2bb-ecb4d6be5240/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/2015f087-01b5-4a01-a2bb-ecb4d6be5240/3","title":"Frozen Cortext"}]},{"set_id":"frozen-synapse_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Frozen%20Synapse/details","description":"Frozen Synapse","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/d4bd464d-55ea-4238-a11d-744f034e2375/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/d4bd464d-55ea-4238-a11d-744f034e2375/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/d4bd464d-55ea-4238-a11d-744f034e2375/3","title":"Frozen Synapse"}]},{"set_id":"game-developer","versions":[{"click_action":null,"click_url":null,"description":"Game Developer for:","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/85856a4a-eb7d-4e26-a43e-d204a977ade4/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/85856a4a-eb7d-4e26-a43e-d204a977ade4/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/85856a4a-eb7d-4e26-a43e-d204a977ade4/3","title":"Game Developer"}]},{"set_id":"getting-over-it_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Getting%20Over%20It/details","description":"Getting Over It","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/8d4e178c-81ec-4c71-af68-745b40733984/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/8d4e178c-81ec-4c71-af68-745b40733984/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/8d4e178c-81ec-4c71-af68-745b40733984/3","title":"Getting Over It"}]},{"set_id":"getting-over-it_2","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Getting%20Over%20It/details","description":"Getting Over It","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/bb620b42-e0e1-4373-928e-d4a732f99ccb/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/bb620b42-e0e1-4373-928e-d4a732f99ccb/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/bb620b42-e0e1-4373-928e-d4a732f99ccb/3","title":"Getting Over It"}]},{"set_id":"glhf-pledge","versions":[{"click_action":"visit_url","click_url":"https://www.anykey.org/pledge","description":"Signed the GLHF pledge in support for inclusive gaming communities","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/3158e758-3cb4-43c5-94b3-7639810451c5/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/3158e758-3cb4-43c5-94b3-7639810451c5/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/3158e758-3cb4-43c5-94b3-7639810451c5/3","title":"GLHF Pledge"}]},{"set_id":"glitchcon2020","versions":[{"click_action":"visit_url","click_url":"https://www.twitchcon.com/","description":"Earned for Watching Glitchcon 2020","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/1d4b03b9-51ea-42c9-8f29-698e3c85be3d/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/1d4b03b9-51ea-42c9-8f29-698e3c85be3d/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/1d4b03b9-51ea-42c9-8f29-698e3c85be3d/3","title":"GlitchCon 2020"}]},{"set_id":"global_mod","versions":[{"click_action":null,"click_url":null,"description":"Global Moderator","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/9384c43e-4ce7-4e94-b2a1-b93656896eba/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/9384c43e-4ce7-4e94-b2a1-b93656896eba/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/9384c43e-4ce7-4e94-b2a1-b93656896eba/3","title":"Global Moderator"}]},{"set_id":"gold-pixel-heart","versions":[{"click_action":"visit_url","click_url":"https://help.twitch.tv/s/article/twitch-charity","description":"Thank you for donating via the Twitch Charity tool during Twitch Together for Good 2023!","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/1687873b-cf38-412c-aad3-f9a4ce17f8b6/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/1687873b-cf38-412c-aad3-f9a4ce17f8b6/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/1687873b-cf38-412c-aad3-f9a4ce17f8b6/3","title":"Gold Pixel Heart"}]},{"set_id":"heavy-bullets_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Heavy%20Bullets/details","description":"Heavy Bullets","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/fc83b76b-f8b2-4519-9f61-6faf84eef4cd/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/fc83b76b-f8b2-4519-9f61-6faf84eef4cd/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/fc83b76b-f8b2-4519-9f61-6faf84eef4cd/3","title":"Heavy Bullets"}]},{"set_id":"hello_neighbor_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Hello%20Neighbor/details","description":"Hello Neighbor","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/030cab2c-5d14-11e7-8d91-43a5a4306286/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/030cab2c-5d14-11e7-8d91-43a5a4306286/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/030cab2c-5d14-11e7-8d91-43a5a4306286/3","title":"Hello Neighbor"}]},{"set_id":"hype-train","versions":[{"click_action":"visit_url","click_url":"https://help.twitch.tv/s/article/hype-train-guide","description":"Top supporter during the most recent hype train","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/fae4086c-3190-44d4-83c8-8ef0cbe1a515/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/fae4086c-3190-44d4-83c8-8ef0cbe1a515/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/fae4086c-3190-44d4-83c8-8ef0cbe1a515/3","title":"Current Hype Train Conductor"},{"click_action":"visit_url","click_url":"https://help.twitch.tv/s/article/hype-train-guide","description":"Top supporter during prior hype trains","id":"2","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/9c8d038a-3a29-45ea-96d4-5031fb1a7a81/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/9c8d038a-3a29-45ea-96d4-5031fb1a7a81/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/9c8d038a-3a29-45ea-96d4-5031fb1a7a81/3","title":"Former Hype Train Conductor"}]},{"set_id":"innerspace_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Innerspace/details","description":"Innerspace","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/97532ccd-6a07-42b5-aecf-3458b6b3ebea/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/97532ccd-6a07-42b5-aecf-3458b6b3ebea/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/97532ccd-6a07-42b5-aecf-3458b6b3ebea/3","title":"Innerspace"}]},{"set_id":"innerspace_2","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Innerspace/details","description":"Innerspace","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/fc7d6018-657a-40e4-9246-0acdc85886d1/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/fc7d6018-657a-40e4-9246-0acdc85886d1/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/fc7d6018-657a-40e4-9246-0acdc85886d1/3","title":"Innerspace"}]},{"set_id":"jackbox-party-pack_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/The%20Jackbox%20Party%20Pack/details","description":"Jackbox Party Pack","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/0f964fc1-f439-485f-a3c0-905294ee70e8/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/0f964fc1-f439-485f-a3c0-905294ee70e8/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/0f964fc1-f439-485f-a3c0-905294ee70e8/3","title":"Jackbox Party Pack"}]},{"set_id":"kingdom-new-lands_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Kingdom:%20New%20Lands/details","description":"Kingdom: New Lands","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/e3c2a67e-ef80-4fe3-ae41-b933cd11788a/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/e3c2a67e-ef80-4fe3-ae41-b933cd11788a/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/e3c2a67e-ef80-4fe3-ae41-b933cd11788a/3","title":"Kingdom: New Lands"}]},{"set_id":"moderator","versions":[{"click_action":null,"click_url":null,"description":"Moderator","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/3267646d-33f0-4b17-b3df-f923a41db1d0/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/3267646d-33f0-4b17-b3df-f923a41db1d0/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/3267646d-33f0-4b17-b3df-f923a41db1d0/3","title":"Moderator"}]},{"set_id":"moments","versions":[{"click_action":null,"click_url":null,"description":"Earned for being a part of at least 1 moment on a channel","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/bf370830-d79a-497b-81c6-a365b2b60dda/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/bf370830-d79a-497b-81c6-a365b2b60dda/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/bf370830-d79a-497b-81c6-a365b2b60dda/3","title":"Moments Badge - Tier 1"},{"click_action":null,"click_url":null,"description":"Earned for being a part of at least 75 moments on a channel","id":"10","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/9c13f2b6-69cd-4537-91b4-4a8bd8b6b1fd/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/9c13f2b6-69cd-4537-91b4-4a8bd8b6b1fd/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/9c13f2b6-69cd-4537-91b4-4a8bd8b6b1fd/3","title":"Moments Badge - Tier 10"},{"click_action":null,"click_url":null,"description":"Earned for being a part of at least 90 moments on a channel","id":"11","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/7573e7a2-0f1f-4508-b833-d822567a1e03/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/7573e7a2-0f1f-4508-b833-d822567a1e03/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/7573e7a2-0f1f-4508-b833-d822567a1e03/3","title":"Moments Badge - Tier 11"},{"click_action":null,"click_url":null,"description":"Earned for being a part of at least 105 moments on a channel","id":"12","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/f2c91d14-85c8-434b-a6c0-6d7930091150/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/f2c91d14-85c8-434b-a6c0-6d7930091150/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/f2c91d14-85c8-434b-a6c0-6d7930091150/3","title":"Moments Badge - Tier 12"},{"click_action":null,"click_url":null,"description":"Earned for being a part of at least 120 moments on a channel","id":"13","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/35eb3395-a1d3-4170-969a-86402ecfb11a/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/35eb3395-a1d3-4170-969a-86402ecfb11a/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/35eb3395-a1d3-4170-969a-86402ecfb11a/3","title":"Moments Badge - Tier 13"},{"click_action":null,"click_url":null,"description":"Earned for being a part of at least 140 moments on a channel","id":"14","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/cb40eb03-1015-45ba-8793-51c66a24a3d5/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/cb40eb03-1015-45ba-8793-51c66a24a3d5/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/cb40eb03-1015-45ba-8793-51c66a24a3d5/3","title":"Moments Badge - Tier 14"},{"click_action":null,"click_url":null,"description":"Earned for being a part of at least 160 moments on a channel","id":"15","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/b241d667-280b-4183-96ae-2d0053631186/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/b241d667-280b-4183-96ae-2d0053631186/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/b241d667-280b-4183-96ae-2d0053631186/3","title":"Moments Badge - Tier 15"},{"click_action":null,"click_url":null,"description":"Earned for being a part of at least 180 moments on a channel","id":"16","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/5684d1bc-8132-4a4f-850c-18d3c5bd04f3/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/5684d1bc-8132-4a4f-850c-18d3c5bd04f3/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/5684d1bc-8132-4a4f-850c-18d3c5bd04f3/3","title":"Moments Badge - Tier 16"},{"click_action":null,"click_url":null,"description":"Earned for being a part of at least 200 moments on a channel","id":"17","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/3b08c1ee-0f77-451b-9226-b5b22d7f023c/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/3b08c1ee-0f77-451b-9226-b5b22d7f023c/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/3b08c1ee-0f77-451b-9226-b5b22d7f023c/3","title":"Moments Badge - Tier 17"},{"click_action":null,"click_url":null,"description":"Earned for being a part of at least 225 moments on a channel","id":"18","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/14057e75-080c-42da-a412-6232c6f33b68/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/14057e75-080c-42da-a412-6232c6f33b68/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/14057e75-080c-42da-a412-6232c6f33b68/3","title":"Moments Badge - Tier 18"},{"click_action":null,"click_url":null,"description":"Earned for being a part of at least 250 moments on a channel","id":"19","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/6100cc6f-6b4b-4a3d-a55b-a5b34edb5ea1/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/6100cc6f-6b4b-4a3d-a55b-a5b34edb5ea1/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/6100cc6f-6b4b-4a3d-a55b-a5b34edb5ea1/3","title":"Moments Badge - Tier 19"},{"click_action":null,"click_url":null,"description":"Earned for being a part of at least 5 moments on a channel","id":"2","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/fc46b10c-5b45-43fd-81ad-d5cb0de6d2f4/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/fc46b10c-5b45-43fd-81ad-d5cb0de6d2f4/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/fc46b10c-5b45-43fd-81ad-d5cb0de6d2f4/3","title":"Moments Badge - Tier 2"},{"click_action":null,"click_url":null,"description":"Earned for being a part of at least 275 moments on a channel","id":"20","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/43399796-e74c-4741-a975-56202f0af30e/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/43399796-e74c-4741-a975-56202f0af30e/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/43399796-e74c-4741-a975-56202f0af30e/3","title":"Moments Badge - Tier 20"},{"click_action":null,"click_url":null,"description":"Earned for being a part of at least 10 moments on a channel","id":"3","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/d08658d7-205f-4f75-ad44-8c6e0acd8ef6/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/d08658d7-205f-4f75-ad44-8c6e0acd8ef6/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/d08658d7-205f-4f75-ad44-8c6e0acd8ef6/3","title":"Moments Badge - Tier 3"},{"click_action":null,"click_url":null,"description":"Earned for being a part of at least 15 moments on a channel","id":"4","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/fe5b5ddc-93e7-4aaf-9b3e-799cd41808b1/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/fe5b5ddc-93e7-4aaf-9b3e-799cd41808b1/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/fe5b5ddc-93e7-4aaf-9b3e-799cd41808b1/3","title":"Moments Badge - Tier 4"},{"click_action":null,"click_url":null,"description":"Earned for being a part of at least 20 moments on a channel","id":"5","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/c8a0d95a-856e-4097-9fc0-7765300a4f58/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/c8a0d95a-856e-4097-9fc0-7765300a4f58/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/c8a0d95a-856e-4097-9fc0-7765300a4f58/3","title":"Moments Badge - Tier 5"},{"click_action":null,"click_url":null,"description":"Earned for being a part of at least 30 moments on a channel","id":"6","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/f9e3b4e4-200e-4045-bd71-3a6b480c23ae/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/f9e3b4e4-200e-4045-bd71-3a6b480c23ae/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/f9e3b4e4-200e-4045-bd71-3a6b480c23ae/3","title":"Moments Badge - Tier 6"},{"click_action":null,"click_url":null,"description":"Earned for being a part of at least 40 moments on a channel","id":"7","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/a90a26a4-fdf7-4ac3-a782-76a413da16c1/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/a90a26a4-fdf7-4ac3-a782-76a413da16c1/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/a90a26a4-fdf7-4ac3-a782-76a413da16c1/3","title":"Moments Badge - Tier 7"},{"click_action":null,"click_url":null,"description":"Earned for being a part of at least 50 moments on a channel","id":"8","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/f22286cd-6aa3-42ce-b3fb-10f5d18c4aa0/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/f22286cd-6aa3-42ce-b3fb-10f5d18c4aa0/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/f22286cd-6aa3-42ce-b3fb-10f5d18c4aa0/3","title":"Moments Badge - Tier 8"},{"click_action":null,"click_url":null,"description":"Earned for being a part of at least 60 moments on a channel","id":"9","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/5cb2e584-1efd-469b-ab1d-4d1b59a944e7/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/5cb2e584-1efd-469b-ab1d-4d1b59a944e7/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/5cb2e584-1efd-469b-ab1d-4d1b59a944e7/3","title":"Moments Badge - Tier 9"}]},{"set_id":"no_audio","versions":[{"click_action":null,"click_url":null,"description":"Individuals with unreliable or no sound can select this badge","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/aef2cd08-f29b-45a1-8c12-d44d7fd5e6f0/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/aef2cd08-f29b-45a1-8c12-d44d7fd5e6f0/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/aef2cd08-f29b-45a1-8c12-d44d7fd5e6f0/3","title":"Watching without audio"}]},{"set_id":"no_video","versions":[{"click_action":null,"click_url":null,"description":"Individuals with unreliable or no video can select this badge","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/199a0dba-58f3-494e-a7fc-1fa0a1001fb8/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/199a0dba-58f3-494e-a7fc-1fa0a1001fb8/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/199a0dba-58f3-494e-a7fc-1fa0a1001fb8/3","title":"Listening only"}]},{"set_id":"okhlos_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Okhlos/details","description":"Okhlos","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/dc088bd6-8965-4907-a1a2-c0ba83874a7d/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/dc088bd6-8965-4907-a1a2-c0ba83874a7d/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/dc088bd6-8965-4907-a1a2-c0ba83874a7d/3","title":"Okhlos"}]},{"set_id":"overwatch-league-insider_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/overwatchleague","description":"OWL All-Access Pass 2018","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/51e9e0aa-12e3-48ce-b961-421af0787dad/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/51e9e0aa-12e3-48ce-b961-421af0787dad/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/51e9e0aa-12e3-48ce-b961-421af0787dad/3","title":"OWL All-Access Pass 2018"}]},{"set_id":"overwatch-league-insider_2018B","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/overwatchleague","description":"OWL All-Access Pass 2018","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/34ec1979-d9bb-4706-ad15-464de814a79d/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/34ec1979-d9bb-4706-ad15-464de814a79d/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/34ec1979-d9bb-4706-ad15-464de814a79d/3","title":"OWL All-Access Pass 2018"}]},{"set_id":"overwatch-league-insider_2019A","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/overwatchleague","description":"OWL All-Access Pass 2019","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ca980da1-3639-48a6-95a3-a03b002eb0e5/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ca980da1-3639-48a6-95a3-a03b002eb0e5/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ca980da1-3639-48a6-95a3-a03b002eb0e5/3","title":"OWL All-Access Pass 2019"},{"click_action":"visit_url","click_url":"https://www.twitch.tv/overwatchleague","description":"OWL All-Access Pass 2019","id":"2","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ab7fa7a7-c2d9-403f-9f33-215b29b43ce4/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ab7fa7a7-c2d9-403f-9f33-215b29b43ce4/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ab7fa7a7-c2d9-403f-9f33-215b29b43ce4/3","title":"OWL All-Access Pass 2019"}]},{"set_id":"overwatch-league-insider_2019B","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/overwatchleague","description":"OWL All-Access Pass 2019","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/c5860811-d714-4413-9433-d6b1c9fc803c/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/c5860811-d714-4413-9433-d6b1c9fc803c/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/c5860811-d714-4413-9433-d6b1c9fc803c/3","title":"OWL All-Access Pass 2019"},{"click_action":"visit_url","click_url":"https://www.twitch.tv/overwatchleague","description":"OWL All-Access Pass 2019","id":"2","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/75f05d4b-3042-415c-8b0b-e87620a24daf/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/75f05d4b-3042-415c-8b0b-e87620a24daf/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/75f05d4b-3042-415c-8b0b-e87620a24daf/3","title":"OWL All-Access Pass 2019"},{"click_action":"visit_url","click_url":"https://www.twitch.tv/overwatchleague","description":"OWL All-Access Pass 2019","id":"3","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/765a0dcf-2a94-43ff-9b9c-ef6c209b90cd/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/765a0dcf-2a94-43ff-9b9c-ef6c209b90cd/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/765a0dcf-2a94-43ff-9b9c-ef6c209b90cd/3","title":"OWL All-Access Pass 2019"},{"click_action":"visit_url","click_url":"https://www.twitch.tv/overwatchleague","description":"OWL All-Access Pass 2019","id":"4","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/a8ae0ccd-783d-460d-93ee-57c485c558a6/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/a8ae0ccd-783d-460d-93ee-57c485c558a6/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/a8ae0ccd-783d-460d-93ee-57c485c558a6/3","title":"OWL All-Access Pass 2019"},{"click_action":"visit_url","click_url":"https://www.twitch.tv/overwatchleague","description":"OWL All-Access Pass 2019","id":"5","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/be87fd6d-1560-4e33-9ba4-2401b58d901f/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/be87fd6d-1560-4e33-9ba4-2401b58d901f/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/be87fd6d-1560-4e33-9ba4-2401b58d901f/3","title":"OWL All-Access Pass 2019"}]},{"set_id":"partner","versions":[{"click_action":"visit_url","click_url":"https://blog.twitch.tv/2017/04/24/the-verified-badge-is-here-13381bc05735","description":"Verified","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/d12a2e27-16f6-41d0-ab77-b780518f00a3/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/d12a2e27-16f6-41d0-ab77-b780518f00a3/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/d12a2e27-16f6-41d0-ab77-b780518f00a3/3","title":"Verified"}]},{"set_id":"power-rangers","versions":[{"click_action":null,"click_url":null,"description":"Black Ranger","id":"0","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/9edf3e7f-62e4-40f5-86ab-7a646b10f1f0/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/9edf3e7f-62e4-40f5-86ab-7a646b10f1f0/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/9edf3e7f-62e4-40f5-86ab-7a646b10f1f0/3","title":"Black Ranger"},{"click_action":null,"click_url":null,"description":"Blue Ranger","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/1eeae8fe-5bc6-44ed-9c88-fb84d5e0df52/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/1eeae8fe-5bc6-44ed-9c88-fb84d5e0df52/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/1eeae8fe-5bc6-44ed-9c88-fb84d5e0df52/3","title":"Blue Ranger"},{"click_action":null,"click_url":null,"description":"Green Ranger","id":"2","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/21bbcd6d-1751-4d28-a0c3-0b72453dd823/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/21bbcd6d-1751-4d28-a0c3-0b72453dd823/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/21bbcd6d-1751-4d28-a0c3-0b72453dd823/3","title":"Green Ranger"},{"click_action":null,"click_url":null,"description":"Pink Ranger","id":"3","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/5c58cb40-9028-4d16-af67-5bc0c18b745e/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/5c58cb40-9028-4d16-af67-5bc0c18b745e/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/5c58cb40-9028-4d16-af67-5bc0c18b745e/3","title":"Pink Ranger"},{"click_action":null,"click_url":null,"description":"Red Ranger","id":"4","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/8843d2de-049f-47d5-9794-b6517903db61/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/8843d2de-049f-47d5-9794-b6517903db61/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/8843d2de-049f-47d5-9794-b6517903db61/3","title":"Red Ranger"},{"click_action":null,"click_url":null,"description":"White Ranger","id":"5","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/06c85e34-477e-4939-9537-fd9978976042/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/06c85e34-477e-4939-9537-fd9978976042/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/06c85e34-477e-4939-9537-fd9978976042/3","title":"White Ranger"},{"click_action":null,"click_url":null,"description":"Yellow Ranger","id":"6","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/d6dca630-1ca4-48de-94e7-55ed0a24d8d1/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/d6dca630-1ca4-48de-94e7-55ed0a24d8d1/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/d6dca630-1ca4-48de-94e7-55ed0a24d8d1/3","title":"Yellow Ranger"}]},{"set_id":"predictions","versions":[{"click_action":null,"click_url":null,"description":"Predicted Outcome One","id":"blue-1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/e33d8b46-f63b-4e67-996d-4a7dcec0ad33/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/e33d8b46-f63b-4e67-996d-4a7dcec0ad33/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/e33d8b46-f63b-4e67-996d-4a7dcec0ad33/3","title":"Predicted Blue (1)"},{"click_action":null,"click_url":null,"description":"Predicted Outcome Ten","id":"blue-10","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/072ae906-ecf7-44f1-ac69-a5b2261d8892/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/072ae906-ecf7-44f1-ac69-a5b2261d8892/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/072ae906-ecf7-44f1-ac69-a5b2261d8892/3","title":"Predicted Blue (10)"},{"click_action":null,"click_url":null,"description":"Predicted Outcome Two","id":"blue-2","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ffdda3fe-8012-4db3-981e-7a131402b057/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ffdda3fe-8012-4db3-981e-7a131402b057/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ffdda3fe-8012-4db3-981e-7a131402b057/3","title":"Predicted Blue (2)"},{"click_action":null,"click_url":null,"description":"Predicted Outcome Three","id":"blue-3","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/f2ab9a19-8ef7-4f9f-bd5d-9cf4e603f845/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/f2ab9a19-8ef7-4f9f-bd5d-9cf4e603f845/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/f2ab9a19-8ef7-4f9f-bd5d-9cf4e603f845/3","title":"Predicted Blue (3)"},{"click_action":null,"click_url":null,"description":"Predicted Outcome Four","id":"blue-4","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/df95317d-9568-46de-a421-a8520edb9349/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/df95317d-9568-46de-a421-a8520edb9349/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/df95317d-9568-46de-a421-a8520edb9349/3","title":"Predicted Blue (4)"},{"click_action":null,"click_url":null,"description":"Predicted Outcome Five","id":"blue-5","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/88758be8-de09-479b-9383-e3bb6d9e6f06/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/88758be8-de09-479b-9383-e3bb6d9e6f06/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/88758be8-de09-479b-9383-e3bb6d9e6f06/3","title":"Predicted Blue (5)"},{"click_action":null,"click_url":null,"description":"Predicted Outcome Six","id":"blue-6","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/46b1537e-d8b0-4c0d-8fba-a652e57b9df0/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/46b1537e-d8b0-4c0d-8fba-a652e57b9df0/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/46b1537e-d8b0-4c0d-8fba-a652e57b9df0/3","title":"Predicted Blue (6)"},{"click_action":null,"click_url":null,"description":"Predicted Outcome Seven","id":"blue-7","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/07cd34b2-c6a1-45f5-8d8a-131e3c8b2279/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/07cd34b2-c6a1-45f5-8d8a-131e3c8b2279/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/07cd34b2-c6a1-45f5-8d8a-131e3c8b2279/3","title":"Predicted Blue (7)"},{"click_action":null,"click_url":null,"description":"Predicted Outcome Eight","id":"blue-8","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/4416dfd7-db97-44a0-98e7-40b4e250615e/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/4416dfd7-db97-44a0-98e7-40b4e250615e/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/4416dfd7-db97-44a0-98e7-40b4e250615e/3","title":"Predicted Blue (8)"},{"click_action":null,"click_url":null,"description":"Predicted Outcome Nine","id":"blue-9","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/fc74bd90-2b74-4f56-8e42-04d405e10fae/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/fc74bd90-2b74-4f56-8e42-04d405e10fae/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/fc74bd90-2b74-4f56-8e42-04d405e10fae/3","title":"Predicted Blue (9)"},{"click_action":null,"click_url":null,"description":"Predicted Gray (1)","id":"gray-1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/144f77a2-e324-4a6b-9c17-9304fa193a27/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/144f77a2-e324-4a6b-9c17-9304fa193a27/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/144f77a2-e324-4a6b-9c17-9304fa193a27/3","title":"Predicted Gray (1)"},{"click_action":null,"click_url":null,"description":"Predicted Gray (2)","id":"gray-2","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/097a4b14-b458-47eb-91b6-fe74d3dbb3f5/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/097a4b14-b458-47eb-91b6-fe74d3dbb3f5/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/097a4b14-b458-47eb-91b6-fe74d3dbb3f5/3","title":"Predicted Gray (2)"},{"click_action":null,"click_url":null,"description":"Predicted Outcome One","id":"pink-1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/75e27613-caf7-4585-98f1-cb7363a69a4a/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/75e27613-caf7-4585-98f1-cb7363a69a4a/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/75e27613-caf7-4585-98f1-cb7363a69a4a/3","title":"Predicted Pink (1)"},{"click_action":null,"click_url":null,"description":"Predicted Outcome Two","id":"pink-2","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/4b76d5f2-91cc-4400-adf2-908a1e6cfd1e/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/4b76d5f2-91cc-4400-adf2-908a1e6cfd1e/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/4b76d5f2-91cc-4400-adf2-908a1e6cfd1e/3","title":"Predicted Pink (2)"}]},{"set_id":"premium","versions":[{"click_action":"visit_url","click_url":"https://gaming.amazon.com","description":"Prime Gaming","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/3","title":"Prime Gaming"}]},{"set_id":"psychonauts_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Psychonauts/details","description":"Psychonauts","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/a9811799-dce3-475f-8feb-3745ad12b7ea/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/a9811799-dce3-475f-8feb-3745ad12b7ea/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/a9811799-dce3-475f-8feb-3745ad12b7ea/3","title":"Psychonauts"}]},{"set_id":"raiden-v-directors-cut_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Raiden%20V/details","description":"Raiden V","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/441b50ae-a2e3-11e7-8a3e-6bff0c840878/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/441b50ae-a2e3-11e7-8a3e-6bff0c840878/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/441b50ae-a2e3-11e7-8a3e-6bff0c840878/3","title":"Raiden V"}]},{"set_id":"rift_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Rift/details","description":"RIFT","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/f939686b-2892-46a4-9f0d-5f582578173e/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/f939686b-2892-46a4-9f0d-5f582578173e/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/f939686b-2892-46a4-9f0d-5f582578173e/3","title":"RIFT"}]},{"set_id":"rplace-2023","versions":[{"click_action":"visit_url","click_url":"https://www.reddit.com/r/place/","description":"A very delicious badge earned by watching Reddit's r/place 2023 event on Twitch Rivals or other participating channels.","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/e33e0c67-c380-4241-828a-099c46e51c66/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/e33e0c67-c380-4241-828a-099c46e51c66/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/e33e0c67-c380-4241-828a-099c46e51c66/3","title":"r/place 2023 Cake"}]},{"set_id":"samusoffer_beta","versions":[{"click_action":"visit_url","click_url":"https://twitch.amazon.com/prime","description":"beta_title1","id":"0","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/aa960159-a7b8-417e-83c1-035e4bc2deb5/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/aa960159-a7b8-417e-83c1-035e4bc2deb5/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/aa960159-a7b8-417e-83c1-035e4bc2deb5/3","title":"beta_title1"}]},{"set_id":"staff","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/jobs?ref=chat_badge","description":"Twitch Staff","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/d97c37bd-a6f5-4c38-8f57-4e4bef88af34/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/d97c37bd-a6f5-4c38-8f57-4e4bef88af34/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/d97c37bd-a6f5-4c38-8f57-4e4bef88af34/3","title":"Staff"}]},{"set_id":"starbound_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Starbound/details","description":"Starbound","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/e838e742-0025-4646-9772-18a87ba99358/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/e838e742-0025-4646-9772-18a87ba99358/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/e838e742-0025-4646-9772-18a87ba99358/3","title":"Starbound"}]},{"set_id":"strafe_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/strafe/details","description":"Strafe","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/0051508d-2d42-4e4b-a328-c86b04510ca4/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/0051508d-2d42-4e4b-a328-c86b04510ca4/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/0051508d-2d42-4e4b-a328-c86b04510ca4/3","title":"Strafe"}]},{"set_id":"sub-gift-leader","versions":[{"click_action":null,"click_url":null,"description":"Ranked as a top subscription gifter in this community","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/21656088-7da2-4467-acd2-55220e1f45ad/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/21656088-7da2-4467-acd2-55220e1f45ad/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/21656088-7da2-4467-acd2-55220e1f45ad/3","title":"Gifter Leader 1"},{"click_action":null,"click_url":null,"description":"Ranked as a top subscription gifter in this community","id":"2","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/0d9fe96b-97b7-4215-b5f3-5328ebad271c/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/0d9fe96b-97b7-4215-b5f3-5328ebad271c/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/0d9fe96b-97b7-4215-b5f3-5328ebad271c/3","title":"Gifter Leader 2"},{"click_action":null,"click_url":null,"description":"Ranked as a top subscription gifter in this community","id":"3","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/4c6e4497-eed9-4dd3-ac64-e0599d0a63e5/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/4c6e4497-eed9-4dd3-ac64-e0599d0a63e5/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/4c6e4497-eed9-4dd3-ac64-e0599d0a63e5/3","title":"Gifter Leader 3"}]},{"set_id":"sub-gifter","versions":[{"click_action":null,"click_url":null,"description":"Has gifted a subscription to another viewer in this community","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/a5ef6c17-2e5b-4d8f-9b80-2779fd722414/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/a5ef6c17-2e5b-4d8f-9b80-2779fd722414/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/a5ef6c17-2e5b-4d8f-9b80-2779fd722414/3","title":"Sub Gifter"},{"click_action":null,"click_url":null,"description":"Has gifted a subscription to another viewer in this community","id":"10","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/d333288c-65d7-4c7b-b691-cdd7b3484bf8/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/d333288c-65d7-4c7b-b691-cdd7b3484bf8/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/d333288c-65d7-4c7b-b691-cdd7b3484bf8/3","title":"10 Gift Subs"},{"click_action":null,"click_url":null,"description":"Has gifted a subscription to another viewer in this community","id":"100","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/8343ada7-3451-434e-91c4-e82bdcf54460/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/8343ada7-3451-434e-91c4-e82bdcf54460/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/8343ada7-3451-434e-91c4-e82bdcf54460/3","title":"100 Gift Subs"},{"click_action":null,"click_url":null,"description":"Has gifted a subscription to another viewer in this community","id":"1000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/bfb7399a-c632-42f7-8d5f-154610dede81/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/bfb7399a-c632-42f7-8d5f-154610dede81/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/bfb7399a-c632-42f7-8d5f-154610dede81/3","title":"1000 Gift Subs"},{"click_action":null,"click_url":null,"description":"Has gifted a subscription to another viewer in this community","id":"150","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/514845ba-0fc3-4771-bce1-14d57e91e621/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/514845ba-0fc3-4771-bce1-14d57e91e621/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/514845ba-0fc3-4771-bce1-14d57e91e621/3","title":"150 Gift Subs"},{"click_action":null,"click_url":null,"description":"Has gifted a subscription to another viewer in this community","id":"200","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/c6b1893e-8059-4024-b93c-39c84b601732/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/c6b1893e-8059-4024-b93c-39c84b601732/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/c6b1893e-8059-4024-b93c-39c84b601732/3","title":"200 Gift Subs"},{"click_action":null,"click_url":null,"description":"Has gifted a subscription to another viewer in this community","id":"2000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/4e8b3a32-1513-44ad-8a12-6c90232c77f9/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/4e8b3a32-1513-44ad-8a12-6c90232c77f9/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/4e8b3a32-1513-44ad-8a12-6c90232c77f9/3","title":"2000 Gift Subs"},{"click_action":null,"click_url":null,"description":"Has gifted a subscription to another viewer in this community","id":"25","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/052a5d41-f1cc-455c-bc7b-fe841ffaf17f/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/052a5d41-f1cc-455c-bc7b-fe841ffaf17f/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/052a5d41-f1cc-455c-bc7b-fe841ffaf17f/3","title":"25 Gift Subs"},{"click_action":null,"click_url":null,"description":"Has gifted a subscription to another viewer in this community","id":"250","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/cd479dc0-4a15-407d-891f-9fd2740bddda/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/cd479dc0-4a15-407d-891f-9fd2740bddda/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/cd479dc0-4a15-407d-891f-9fd2740bddda/3","title":"250 Gift Subs"},{"click_action":null,"click_url":null,"description":"Has gifted a subscription to another viewer in this community","id":"300","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/9e1bb24f-d238-4078-871a-ac401b76ecf2/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/9e1bb24f-d238-4078-871a-ac401b76ecf2/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/9e1bb24f-d238-4078-871a-ac401b76ecf2/3","title":"300 Gift Subs"},{"click_action":null,"click_url":null,"description":"Has gifted a subscription to another viewer in this community","id":"3000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/b18852ba-65d2-4b84-97d2-aeb6c44a0956/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/b18852ba-65d2-4b84-97d2-aeb6c44a0956/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/b18852ba-65d2-4b84-97d2-aeb6c44a0956/3","title":"3000 Gift Subs"},{"click_action":null,"click_url":null,"description":"Has gifted a subscription to another viewer in this community","id":"350","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/6c4783cd-0aba-4e75-a7a4-f48a70b665b0/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/6c4783cd-0aba-4e75-a7a4-f48a70b665b0/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/6c4783cd-0aba-4e75-a7a4-f48a70b665b0/3","title":"350 Gift Subs"},{"click_action":null,"click_url":null,"description":"Has gifted a subscription to another viewer in this community","id":"400","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/6f4cab6b-def9-4d99-ad06-90b0013b28c8/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/6f4cab6b-def9-4d99-ad06-90b0013b28c8/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/6f4cab6b-def9-4d99-ad06-90b0013b28c8/3","title":"400 Gift Subs"},{"click_action":null,"click_url":null,"description":"Has gifted a subscription to another viewer in this community","id":"4000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/efbf3c93-ecfa-4b67-8d0a-1f732fb07397/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/efbf3c93-ecfa-4b67-8d0a-1f732fb07397/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/efbf3c93-ecfa-4b67-8d0a-1f732fb07397/3","title":"4000 Gift Subs"},{"click_action":null,"click_url":null,"description":"Has gifted a subscription to another viewer in this community","id":"450","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/b593d68a-f8fb-4516-a09a-18cce955402c/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/b593d68a-f8fb-4516-a09a-18cce955402c/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/b593d68a-f8fb-4516-a09a-18cce955402c/3","title":"450 Gift Subs"},{"click_action":null,"click_url":null,"description":"Has gifted a subscription to another viewer in this community","id":"5","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ee113e59-c839-4472-969a-1e16d20f3962/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ee113e59-c839-4472-969a-1e16d20f3962/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ee113e59-c839-4472-969a-1e16d20f3962/3","title":"5 Gift Subs"},{"click_action":null,"click_url":null,"description":"Has gifted a subscription to another viewer in this community","id":"50","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/c4a29737-e8a5-4420-917a-314a447f083e/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/c4a29737-e8a5-4420-917a-314a447f083e/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/c4a29737-e8a5-4420-917a-314a447f083e/3","title":"50 Gift Subs"},{"click_action":null,"click_url":null,"description":"Has gifted a subscription to another viewer in this community","id":"500","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/60e9504c-8c3d-489f-8a74-314fb195ad8d/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/60e9504c-8c3d-489f-8a74-314fb195ad8d/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/60e9504c-8c3d-489f-8a74-314fb195ad8d/3","title":"500 Gift Subs"},{"click_action":null,"click_url":null,"description":"Has gifted a subscription to another viewer in this community","id":"5000","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/d775275d-fd19-4914-b63a-7928a22135c3/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/d775275d-fd19-4914-b63a-7928a22135c3/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/d775275d-fd19-4914-b63a-7928a22135c3/3","title":"5000 Gift Subs"},{"click_action":null,"click_url":null,"description":"Has gifted a subscription to another viewer in this community","id":"550","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/024d2563-1794-43ed-b8dc-33df3efae900/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/024d2563-1794-43ed-b8dc-33df3efae900/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/024d2563-1794-43ed-b8dc-33df3efae900/3","title":"550 Gift Subs"},{"click_action":null,"click_url":null,"description":"Has gifted a subscription to another viewer in this community","id":"600","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/3ecc3aab-09bf-4823-905e-3a4647171fc1/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/3ecc3aab-09bf-4823-905e-3a4647171fc1/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/3ecc3aab-09bf-4823-905e-3a4647171fc1/3","title":"600 Gift Subs"},{"click_action":null,"click_url":null,"description":"Has gifted a subscription to another viewer in this community","id":"650","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/eeabf43c-8e4c-448d-9790-4c2172c57944/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/eeabf43c-8e4c-448d-9790-4c2172c57944/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/eeabf43c-8e4c-448d-9790-4c2172c57944/3","title":"650 Gift Subs"},{"click_action":null,"click_url":null,"description":"Has gifted a subscription to another viewer in this community","id":"700","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/4a9acdc7-30be-4dd1-9898-fc9e42b3d304/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/4a9acdc7-30be-4dd1-9898-fc9e42b3d304/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/4a9acdc7-30be-4dd1-9898-fc9e42b3d304/3","title":"700 Gift Subs"},{"click_action":null,"click_url":null,"description":"Has gifted a subscription to another viewer in this community","id":"750","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ca17277c-53e5-422b-8bb4-7c5dcdb0ac67/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ca17277c-53e5-422b-8bb4-7c5dcdb0ac67/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ca17277c-53e5-422b-8bb4-7c5dcdb0ac67/3","title":"750 Gift Subs"},{"click_action":null,"click_url":null,"description":"Has gifted a subscription to another viewer in this community","id":"800","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/9c1fb96d-0579-43d7-ba94-94672eaef63a/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/9c1fb96d-0579-43d7-ba94-94672eaef63a/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/9c1fb96d-0579-43d7-ba94-94672eaef63a/3","title":"800 Gift Subs"},{"click_action":null,"click_url":null,"description":"Has gifted a subscription to another viewer in this community","id":"850","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/cc924aaf-dfd4-4f3f-822a-f5a87eb24069/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/cc924aaf-dfd4-4f3f-822a-f5a87eb24069/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/cc924aaf-dfd4-4f3f-822a-f5a87eb24069/3","title":"850 Gift Subs"},{"click_action":null,"click_url":null,"description":"Has gifted a subscription to another viewer in this community","id":"900","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/193d86f6-83e1-428c-9638-d6ca9e408166/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/193d86f6-83e1-428c-9638-d6ca9e408166/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/193d86f6-83e1-428c-9638-d6ca9e408166/3","title":"900 Gift Subs"},{"click_action":null,"click_url":null,"description":"Has gifted a subscription to another viewer in this community","id":"950","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/7ce130bd-6f55-40cc-9231-e2a4cb712962/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/7ce130bd-6f55-40cc-9231-e2a4cb712962/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/7ce130bd-6f55-40cc-9231-e2a4cb712962/3","title":"950 Gift Subs"}]},{"set_id":"subscriber","versions":[{"click_action":"subscribe_to_channel","click_url":null,"description":"Subscriber","id":"0","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/5d9f2208-5dd8-11e7-8513-2ff4adfae661/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/5d9f2208-5dd8-11e7-8513-2ff4adfae661/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/5d9f2208-5dd8-11e7-8513-2ff4adfae661/3","title":"Subscriber"},{"click_action":"subscribe_to_channel","click_url":null,"description":"Subscriber","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/5d9f2208-5dd8-11e7-8513-2ff4adfae661/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/5d9f2208-5dd8-11e7-8513-2ff4adfae661/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/5d9f2208-5dd8-11e7-8513-2ff4adfae661/3","title":"Subscriber"},{"click_action":"subscribe_to_channel","click_url":null,"description":"2-Month Subscriber","id":"2","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/25a03e36-2bb2-4625-bd37-d6d9d406238d/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/25a03e36-2bb2-4625-bd37-d6d9d406238d/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/25a03e36-2bb2-4625-bd37-d6d9d406238d/3","title":"2-Month Subscriber"},{"click_action":"subscribe_to_channel","click_url":null,"description":"3-Month Subscriber","id":"3","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/e8984705-d091-4e54-8241-e53b30a84b0e/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/e8984705-d091-4e54-8241-e53b30a84b0e/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/e8984705-d091-4e54-8241-e53b30a84b0e/3","title":"3-Month Subscriber"},{"click_action":"subscribe_to_channel","click_url":null,"description":"6-Month Subscriber","id":"4","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/2d2485f6-d19b-4daa-8393-9493b019156b/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/2d2485f6-d19b-4daa-8393-9493b019156b/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/2d2485f6-d19b-4daa-8393-9493b019156b/3","title":"6-Month Subscriber"},{"click_action":"subscribe_to_channel","click_url":null,"description":"9-Month Subscriber","id":"5","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/b4e6b13a-a76f-4c56-87e1-9375a7aaa610/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/b4e6b13a-a76f-4c56-87e1-9375a7aaa610/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/b4e6b13a-a76f-4c56-87e1-9375a7aaa610/3","title":"9-Month Subscriber"},{"click_action":"subscribe_to_channel","click_url":null,"description":"1-Year Subscriber","id":"6","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ed51a614-2c44-4a60-80b6-62908436b43a/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ed51a614-2c44-4a60-80b6-62908436b43a/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ed51a614-2c44-4a60-80b6-62908436b43a/3","title":"6-Month Subscriber"}]},{"set_id":"superhot_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/superhot/details","description":"Superhot","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/c5a06922-83b5-40cb-885f-bcffd3cd6c68/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/c5a06922-83b5-40cb-885f-bcffd3cd6c68/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/c5a06922-83b5-40cb-885f-bcffd3cd6c68/3","title":"Superhot"}]},{"set_id":"superultracombo-2023","versions":[{"click_action":null,"click_url":null,"description":"This user joined Twitch's SuperUltraCombo 2023","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/5864739a-5e58-4623-9450-a2c0555ef90b/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/5864739a-5e58-4623-9450-a2c0555ef90b/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/5864739a-5e58-4623-9450-a2c0555ef90b/3","title":"SuperUltraCombo 2023"}]},{"set_id":"the-game-awards-2023","versions":[{"click_action":"visit_url","click_url":"https://blog.twitch.tv/2023/11/30/the-2023-game-awards-is-live-on-twitch-december-7th/","description":"You’ve completed all categories of the 2023 Twitch Predicts: The Game Awards extension!","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/10cf46de-61e7-4a42-807a-7898408ce352/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/10cf46de-61e7-4a42-807a-7898408ce352/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/10cf46de-61e7-4a42-807a-7898408ce352/3","title":"The Game Awards 2023"}]},{"set_id":"the-golden-predictor-of-the-game-awards-2023","versions":[{"click_action":"visit_url","click_url":"https://blog.twitch.tv/2023/11/30/the-2023-game-awards-is-live-on-twitch-december-7th/","description":"You've predicted the entire 2023 Game Awards perfectly, here is a special gift for your work. Go ahead, show it off!","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/c84c4dd7-9318-4e8b-9f01-1612d3f83dae/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/c84c4dd7-9318-4e8b-9f01-1612d3f83dae/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/c84c4dd7-9318-4e8b-9f01-1612d3f83dae/3","title":"The Golden Predictor of the Game Awards 2023"}]},{"set_id":"the-surge_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/The%20Surge/details","description":"The Surge","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/c9f69d89-31c8-41aa-843b-fee956dfbe23/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/c9f69d89-31c8-41aa-843b-fee956dfbe23/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/c9f69d89-31c8-41aa-843b-fee956dfbe23/3","title":"The Surge"}]},{"set_id":"the-surge_2","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/The%20Surge/details","description":"The Surge","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/2c4d7e95-e138-4dde-a783-7956a8ecc408/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/2c4d7e95-e138-4dde-a783-7956a8ecc408/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/2c4d7e95-e138-4dde-a783-7956a8ecc408/3","title":"The Surge"}]},{"set_id":"the-surge_3","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/The%20Surge/details","description":"The Surge","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/0a8fc2d4-3125-4ccb-88db-e970dfbee189/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/0a8fc2d4-3125-4ccb-88db-e970dfbee189/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/0a8fc2d4-3125-4ccb-88db-e970dfbee189/3","title":"The Surge"}]},{"set_id":"this-war-of-mine_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/This%20War%20of%20Mine/details","description":"This War of Mine","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/6a20f814-cb2c-414e-89cc-f8dd483e1785/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/6a20f814-cb2c-414e-89cc-f8dd483e1785/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/6a20f814-cb2c-414e-89cc-f8dd483e1785/3","title":"This War of Mine"}]},{"set_id":"titan-souls_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Titan%20Souls/details","description":"Titan Souls","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/092a7ce2-709c-434f-8df4-a6b075ef867d/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/092a7ce2-709c-434f-8df4-a6b075ef867d/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/092a7ce2-709c-434f-8df4-a6b075ef867d/3","title":"Titan Souls"}]},{"set_id":"treasure-adventure-world_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Treasure%20Adventure%20World/details","description":"Treasure Adventure World","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/59810027-2988-4b0d-b88d-fc414c751305/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/59810027-2988-4b0d-b88d-fc414c751305/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/59810027-2988-4b0d-b88d-fc414c751305/3","title":"Treasure Adventure World"}]},{"set_id":"turbo","versions":[{"click_action":"turbo","click_url":null,"description":"A subscriber of Twitch's monthly premium user service","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/bd444ec6-8f34-4bf9-91f4-af1e3428d80f/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/bd444ec6-8f34-4bf9-91f4-af1e3428d80f/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/bd444ec6-8f34-4bf9-91f4-af1e3428d80f/3","title":"Turbo"}]},{"set_id":"twitch-intern-2023","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/jobs/early-career/","description":"This user was an intern at Twitch for the summer of 2023","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/e239e7e0-e373-4fdf-b95e-3469aec28485/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/e239e7e0-e373-4fdf-b95e-3469aec28485/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/e239e7e0-e373-4fdf-b95e-3469aec28485/3","title":"Twitch Intern 2023"}]},{"set_id":"twitch-recap-2023","versions":[{"click_action":"visit_url","click_url":"https://twitch-web.app.link/e/twitch-recap","description":"This user bled purple like it was their job, and was one of the most engaged members of Twitch in 2023!","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/4d9e9812-ba9b-48a6-8690-13f3f338ee65/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/4d9e9812-ba9b-48a6-8690-13f3f338ee65/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/4d9e9812-ba9b-48a6-8690-13f3f338ee65/3","title":"Twitch Recap 2023"}]},{"set_id":"twitchbot","versions":[{"click_action":"visit_url","click_url":"http://link.twitch.tv/automod_blog","description":"AutoMod","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/df9095f6-a8a0-4cc2-bb33-d908c0adffb8/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/df9095f6-a8a0-4cc2-bb33-d908c0adffb8/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/df9095f6-a8a0-4cc2-bb33-d908c0adffb8/3","title":"AutoMod"}]},{"set_id":"twitchcon2017","versions":[{"click_action":"visit_url","click_url":"https://www.twitchcon.com/","description":"Attended TwitchCon Long Beach 2017","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/0964bed0-5c31-11e7-a90b-0739918f1d9b/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/0964bed0-5c31-11e7-a90b-0739918f1d9b/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/0964bed0-5c31-11e7-a90b-0739918f1d9b/3","title":"TwitchCon 2017 - Long Beach"}]},{"set_id":"twitchcon2018","versions":[{"click_action":"visit_url","click_url":"https://www.twitchcon.com/?utm_source=twitch-chat&utm_medium=badge&utm_campaign=tc18","description":"Attended TwitchCon San Jose 2018","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/e68164e4-087d-4f62-81da-d3557efae3cb/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/e68164e4-087d-4f62-81da-d3557efae3cb/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/e68164e4-087d-4f62-81da-d3557efae3cb/3","title":"TwitchCon 2018 - San Jose"}]},{"set_id":"twitchconAmsterdam2020","versions":[{"click_action":"visit_url","click_url":"https://www.twitchcon.com/amsterdam/?utm_source=twitch-chat&utm_medium=badge&utm_campaign=tcamsterdam20","description":"Registered for TwitchCon Amsterdam 2020","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ed917c9a-1a45-4340-9c64-ca8be4348c51/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ed917c9a-1a45-4340-9c64-ca8be4348c51/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ed917c9a-1a45-4340-9c64-ca8be4348c51/3","title":"TwitchCon 2020 - Amsterdam"}]},{"set_id":"twitchconEU2019","versions":[{"click_action":"visit_url","click_url":"https://europe.twitchcon.com/?utm_source=twitch-chat&utm_medium=badge&utm_campaign=tceu19","description":"Attended TwitchCon Berlin 2019","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/590eee9e-f04d-474c-90e7-b304d9e74b32/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/590eee9e-f04d-474c-90e7-b304d9e74b32/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/590eee9e-f04d-474c-90e7-b304d9e74b32/3","title":"TwitchCon 2019 - Berlin"}]},{"set_id":"twitchconEU2022","versions":[{"click_action":"visit_url","click_url":"https://www.twitchcon.com/amsterdam-2022/?utm_source=twitch-chat&utm_medium=badge&utm_campaign=tceu22","description":"Attended TwitchCon Amsterdam 2022","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/e4744003-50b7-4eb8-9b47-a7b1616a30c6/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/e4744003-50b7-4eb8-9b47-a7b1616a30c6/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/e4744003-50b7-4eb8-9b47-a7b1616a30c6/3","title":"TwitchCon 2022 - Amsterdam"}]},{"set_id":"twitchconEU2023","versions":[{"click_action":"visit_url","click_url":"https://www.twitchcon.com/paris-2023/?utm_source=chat_badge","description":"TwitchCon 2023 - Paris","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/a8f2084e-46b9-4bb9-ae5e-00d594aafc64/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/a8f2084e-46b9-4bb9-ae5e-00d594aafc64/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/a8f2084e-46b9-4bb9-ae5e-00d594aafc64/3","title":"TwitchCon 2023 - Paris"}]},{"set_id":"twitchconNA2019","versions":[{"click_action":"visit_url","click_url":"https://www.twitchcon.com/?utm_source=twitch-chat&utm_medium=badge&utm_campaign=tcna19","description":"Attended TwitchCon San Diego 2019","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/569c829d-c216-4f56-a191-3db257ed657c/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/569c829d-c216-4f56-a191-3db257ed657c/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/569c829d-c216-4f56-a191-3db257ed657c/3","title":"TwitchCon 2019 - San Diego"}]},{"set_id":"twitchconNA2020","versions":[{"click_action":"visit_url","click_url":"https://www.twitchcon.com/?utm_source=twitch-chat&utm_medium=badge&utm_campaign=tcna20","description":"Registered for TwitchCon North America 2020","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ed917c9a-1a45-4340-9c64-ca8be4348c51/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ed917c9a-1a45-4340-9c64-ca8be4348c51/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ed917c9a-1a45-4340-9c64-ca8be4348c51/3","title":"TwitchCon 2020 - North America"}]},{"set_id":"twitchconNA2022","versions":[{"click_action":"visit_url","click_url":"https://www.twitchcon.com/san-diego-2022/?utm_source=twitch-chat&utm_medium=badge&utm_campaign=tcna22","description":"Attended TwitchCon San Diego 2022","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/344d429a-0b34-48e5-a84c-14a1b5772a3a/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/344d429a-0b34-48e5-a84c-14a1b5772a3a/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/344d429a-0b34-48e5-a84c-14a1b5772a3a/3","title":"TwitchCon 2022 - San Diego"}]},{"set_id":"twitchconNA2023","versions":[{"click_action":"visit_url","click_url":"https://www.twitchcon.com/en/las-vegas-2023/","description":"Attended TwitchCon Las Vegas 2023","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/c90a753f-ab20-41bc-9c42-ede062485d2c/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/c90a753f-ab20-41bc-9c42-ede062485d2c/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/c90a753f-ab20-41bc-9c42-ede062485d2c/3","title":"TwitchCon 2023 - Las Vegas"}]},{"set_id":"tyranny_1","versions":[{"click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Tyranny/details","description":"Tyranny","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/0c79afdf-28ce-4b0b-9e25-4f221c30bfde/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/0c79afdf-28ce-4b0b-9e25-4f221c30bfde/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/0c79afdf-28ce-4b0b-9e25-4f221c30bfde/3","title":"Tyranny"}]},{"set_id":"user-anniversary","versions":[{"click_action":null,"click_url":null,"description":"Staff badge celebrating Twitch tenure","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/ccbbedaa-f4db-4d0b-9c2a-375de7ad947c/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/ccbbedaa-f4db-4d0b-9c2a-375de7ad947c/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/ccbbedaa-f4db-4d0b-9c2a-375de7ad947c/3","title":"Twitchiversary Badge"}]},{"set_id":"vga-champ-2017","versions":[{"click_action":"visit_url","click_url":"https://blog.twitch.tv/watch-and-co-stream-the-game-awards-this-thursday-on-twitch-3d8e34d2345d","description":"2017 VGA Champ","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/03dca92e-dc69-11e7-ac5b-9f942d292dc7/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/03dca92e-dc69-11e7-ac5b-9f942d292dc7/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/03dca92e-dc69-11e7-ac5b-9f942d292dc7/3","title":"2017 VGA Champ"}]},{"set_id":"vip","versions":[{"click_action":"visit_url","click_url":"https://help.twitch.tv/customer/en/portal/articles/659115-twitch-chat-badges-guide","description":"VIP","id":"1","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/b817aba4-fad8-49e2-b88a-7cc744dfa6ec/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/b817aba4-fad8-49e2-b88a-7cc744dfa6ec/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/b817aba4-fad8-49e2-b88a-7cc744dfa6ec/3","title":"VIP"}]},{"set_id":"warcraft","versions":[{"click_action":"visit_url","click_url":"http://warcraftontwitch.tv/","description":"For Lordaeron!","id":"alliance","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/c4816339-bad4-4645-ae69-d1ab2076a6b0/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/c4816339-bad4-4645-ae69-d1ab2076a6b0/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/c4816339-bad4-4645-ae69-d1ab2076a6b0/3","title":"Alliance"},{"click_action":"visit_url","click_url":"http://warcraftontwitch.tv/","description":"For the Horde!","id":"horde","image_url_1x":"https://static-cdn.jtvnw.net/badges/v1/de8b26b6-fd28-4e6c-bc89-3d597343800d/1","image_url_2x":"https://static-cdn.jtvnw.net/badges/v1/de8b26b6-fd28-4e6c-bc89-3d597343800d/2","image_url_4x":"https://static-cdn.jtvnw.net/badges/v1/de8b26b6-fd28-4e6c-bc89-3d597343800d/3","title":"Horde"}]}]} +{"1979-revolution_1":[{"id":"1","title":"1979 Revolution","image":"https://static-cdn.jtvnw.net/badges/v1/7833bb6e-d20d-48ff-a58d-67fe827a4f84/","url":"https://www.twitch.tv/directory/game/1979%20Revolution/details"}],"60-seconds_1":[{"id":"1","title":"60 Seconds!","image":"https://static-cdn.jtvnw.net/badges/v1/1e7252f9-7e80-4d3d-ae42-319f030cca99/","url":"https://www.twitch.tv/directory/game/60%20Seconds!/details"}],"60-seconds_2":[{"id":"1","title":"60 Seconds!","image":"https://static-cdn.jtvnw.net/badges/v1/64513f7d-21dd-4a05-a699-d73761945cf9/","url":"https://www.twitch.tv/directory/game/60%20Seconds!/details"}],"60-seconds_3":[{"id":"1","title":"60 Seconds!","image":"https://static-cdn.jtvnw.net/badges/v1/f4306617-0f96-476f-994e-5304f81bcc6e/","url":"https://www.twitch.tv/directory/game/60%20Seconds!/details"}],"H1Z1_1":[{"id":"1","title":"H1Z1","image":"https://static-cdn.jtvnw.net/badges/v1/fc71386c-86cd-11e7-a55d-43f591dc0c71/","url":"https://www.twitch.tv/directory/game/H1Z1/details"}],"admin":[{"id":"1","title":"Admin","image":"https://static-cdn.jtvnw.net/badges/v1/9ef7e029-4cdf-4d4d-a0d5-e2b3fb2583fe/"}],"ambassador":[{"id":"1","title":"Twitch Ambassador","image":"https://static-cdn.jtvnw.net/badges/v1/2cbc339f-34f4-488a-ae51-efdf74f4e323/","url":"https://www.twitch.tv/team/ambassadors"}],"anomaly-2_1":[{"id":"1","title":"Anomaly 2","image":"https://static-cdn.jtvnw.net/badges/v1/d1d1ad54-40a6-492b-882e-dcbdce5fa81e/","url":"https://www.twitch.tv/directory/game/Anomaly%202/details"}],"anomaly-warzone-earth_1":[{"id":"1","title":"Anomaly Warzone Earth","image":"https://static-cdn.jtvnw.net/badges/v1/858be873-fb1f-47e5-ad34-657f40d3d156/","url":"https://www.twitch.tv/directory/game/Anomaly:%20Warzone%20Earth/details"}],"anonymous-cheerer":[{"id":"1","title":"Anonymous Cheerer","image":"https://static-cdn.jtvnw.net/badges/v1/ca3db7f7-18f5-487e-a329-cd0b538ee979/"}],"artist-badge":[{"id":"1","title":"Artist","image":"https://static-cdn.jtvnw.net/badges/v1/4300a897-03dc-4e83-8c0e-c332fee7057f/"}],"axiom-verge_1":[{"id":"1","title":"Axiom Verge","image":"https://static-cdn.jtvnw.net/badges/v1/f209b747-45ee-42f6-8baf-ea7542633d10/","url":"https://www.twitch.tv/directory/game/Axiom%20Verge/details"}],"battlechefbrigade_1":[{"id":"1","title":"Battle Chef Brigade","image":"https://static-cdn.jtvnw.net/badges/v1/24e32e67-33cd-4227-ad96-f0a7fc836107/","url":"https://www.twitch.tv/directory/game/Battle%20Chef%20Brigade/details"}],"battlechefbrigade_2":[{"id":"1","title":"Battle Chef Brigade","image":"https://static-cdn.jtvnw.net/badges/v1/ef1e96e8-a0f9-40b6-87af-2977d3c004bb/","url":"https://www.twitch.tv/directory/game/Battle%20Chef%20Brigade/details"}],"battlechefbrigade_3":[{"id":"1","title":"Battle Chef Brigade","image":"https://static-cdn.jtvnw.net/badges/v1/107ebb20-4fcd-449a-9931-cd3f81b84c70/","url":"https://www.twitch.tv/directory/game/Battle%20Chef%20Brigade/details"}],"battlerite_1":[{"id":"1","title":"Battlerite","image":"https://static-cdn.jtvnw.net/badges/v1/484ebda9-f7fa-4c67-b12b-c80582f3cc61/","url":"https://www.twitch.tv/directory/game/Battlerite/details"}],"bits":[{"id":"1","title":"cheer 1","image":"https://static-cdn.jtvnw.net/badges/v1/73b5c3fb-24f9-4a82-a852-2f475b59411c/","url":"https://bits.twitch.tv"},{"id":"100","title":"cheer 100","image":"https://static-cdn.jtvnw.net/badges/v1/09d93036-e7ce-431c-9a9e-7044297133f2/","url":"https://bits.twitch.tv"},{"id":"1000","title":"cheer 1000","image":"https://static-cdn.jtvnw.net/badges/v1/0d85a29e-79ad-4c63-a285-3acd2c66f2ba/","url":"https://bits.twitch.tv"},{"id":"10000","title":"cheer 10000","image":"https://static-cdn.jtvnw.net/badges/v1/68af213b-a771-4124-b6e3-9bb6d98aa732/","url":"https://bits.twitch.tv"},{"id":"100000","title":"cheer 100000","image":"https://static-cdn.jtvnw.net/badges/v1/96f0540f-aa63-49e1-a8b3-259ece3bd098/","url":"https://bits.twitch.tv"},{"id":"1000000","title":"cheer 1000000","image":"https://static-cdn.jtvnw.net/badges/v1/494d1c8e-c3b2-4d88-8528-baff57c9bd3f/","url":"https://bits.twitch.tv"},{"id":"1250000","title":"cheer 1250000","image":"https://static-cdn.jtvnw.net/badges/v1/ce217209-4615-4bf8-81e3-57d06b8b9dc7/","url":"https://bits.twitch.tv"},{"id":"1500000","title":"cheer 1500000","image":"https://static-cdn.jtvnw.net/badges/v1/c4eba5b4-17a7-40a1-a668-bc1972c1e24d/","url":"https://bits.twitch.tv"},{"id":"1750000","title":"cheer 1750000","image":"https://static-cdn.jtvnw.net/badges/v1/183f1fd8-aaf4-450c-a413-e53f839f0f82/","url":"https://bits.twitch.tv"},{"id":"200000","title":"cheer 200000","image":"https://static-cdn.jtvnw.net/badges/v1/4a0b90c4-e4ef-407f-84fe-36b14aebdbb6/","url":"https://bits.twitch.tv"},{"id":"2000000","title":"cheer 2000000","image":"https://static-cdn.jtvnw.net/badges/v1/7ea89c53-1a3b-45f9-9223-d97ae19089f2/","url":"https://bits.twitch.tv"},{"id":"25000","title":"cheer 25000","image":"https://static-cdn.jtvnw.net/badges/v1/64ca5920-c663-4bd8-bfb1-751b4caea2dd/","url":"https://bits.twitch.tv"},{"id":"2500000","title":"cheer 2500000","image":"https://static-cdn.jtvnw.net/badges/v1/cf061daf-d571-4811-bcc2-c55c8792bc8f/","url":"https://bits.twitch.tv"},{"id":"300000","title":"cheer 300000","image":"https://static-cdn.jtvnw.net/badges/v1/ac13372d-2e94-41d1-ae11-ecd677f69bb6/","url":"https://bits.twitch.tv"},{"id":"3000000","title":"cheer 3000000","image":"https://static-cdn.jtvnw.net/badges/v1/5671797f-5e9f-478c-a2b5-eb086c8928cf/","url":"https://bits.twitch.tv"},{"id":"3500000","title":"cheer 3500000","image":"https://static-cdn.jtvnw.net/badges/v1/c3d218f5-1e45-419d-9c11-033a1ae54d3a/","url":"https://bits.twitch.tv"},{"id":"400000","title":"cheer 400000","image":"https://static-cdn.jtvnw.net/badges/v1/a8f393af-76e6-4aa2-9dd0-7dcc1c34f036/","url":"https://bits.twitch.tv"},{"id":"4000000","title":"cheer 4000000","image":"https://static-cdn.jtvnw.net/badges/v1/79fe642a-87f3-40b1-892e-a341747b6e08/","url":"https://bits.twitch.tv"},{"id":"4500000","title":"cheer 4500000","image":"https://static-cdn.jtvnw.net/badges/v1/736d4156-ac67-4256-a224-3e6e915436db/","url":"https://bits.twitch.tv"},{"id":"5000","title":"cheer 5000","image":"https://static-cdn.jtvnw.net/badges/v1/57cd97fc-3e9e-4c6d-9d41-60147137234e/","url":"https://bits.twitch.tv"},{"id":"50000","title":"cheer 50000","image":"https://static-cdn.jtvnw.net/badges/v1/62310ba7-9916-4235-9eba-40110d67f85d/","url":"https://bits.twitch.tv"},{"id":"500000","title":"cheer 500000","image":"https://static-cdn.jtvnw.net/badges/v1/f6932b57-6a6e-4062-a770-dfbd9f4302e5/","url":"https://bits.twitch.tv"},{"id":"5000000","title":"cheer 5000000","image":"https://static-cdn.jtvnw.net/badges/v1/3f085f85-8d15-4a03-a829-17fca7bf1bc2/","url":"https://bits.twitch.tv"},{"id":"600000","title":"cheer 600000","image":"https://static-cdn.jtvnw.net/badges/v1/4d908059-f91c-4aef-9acb-634434f4c32e/","url":"https://bits.twitch.tv"},{"id":"700000","title":"cheer 700000","image":"https://static-cdn.jtvnw.net/badges/v1/a1d2a824-f216-4b9f-9642-3de8ed370957/","url":"https://bits.twitch.tv"},{"id":"75000","title":"cheer 75000","image":"https://static-cdn.jtvnw.net/badges/v1/ce491fa4-b24f-4f3b-b6ff-44b080202792/","url":"https://bits.twitch.tv"},{"id":"800000","title":"cheer 800000","image":"https://static-cdn.jtvnw.net/badges/v1/5ec2ee3e-5633-4c2a-8e77-77473fe409e6/","url":"https://bits.twitch.tv"},{"id":"900000","title":"cheer 900000","image":"https://static-cdn.jtvnw.net/badges/v1/088c58c6-7c38-45ba-8f73-63ef24189b84/","url":"https://bits.twitch.tv"}],"bits-charity":[{"id":"1","title":"Direct Relief - Charity 2018","image":"https://static-cdn.jtvnw.net/badges/v1/a539dc18-ae19-49b0-98c4-8391a594332b/","url":"https://link.twitch.tv/blizzardofbits"}],"bits-leader":[{"id":"1","title":"Bits Leader 1","image":"https://static-cdn.jtvnw.net/badges/v1/8bedf8c3-7a6d-4df2-b62f-791b96a5dd31/","url":"https://bits.twitch.tv"},{"id":"2","title":"Bits Leader 2","image":"https://static-cdn.jtvnw.net/badges/v1/f04baac7-9141-4456-a0e7-6301bcc34138/","url":"https://bits.twitch.tv"},{"id":"3","title":"Bits Leader 3","image":"https://static-cdn.jtvnw.net/badges/v1/f1d2aab6-b647-47af-965b-84909cf303aa/","url":"https://bits.twitch.tv"}],"brawlhalla_1":[{"id":"1","title":"Brawlhalla","image":"https://static-cdn.jtvnw.net/badges/v1/bf6d6579-ab02-4f0a-9f64-a51c37040858/","url":"https://www.twitch.tv/directory/game/Brawlhalla/details"}],"broadcaster":[{"id":"1","title":"Broadcaster","image":"https://static-cdn.jtvnw.net/badges/v1/5527c58c-fb7d-422d-b71b-f309dcb85cc1/"}],"broken-age_1":[{"id":"1","title":"Broken Age","image":"https://static-cdn.jtvnw.net/badges/v1/56885ed2-9a09-4c8e-8131-3eb9ec15af94/","url":"https://www.twitch.tv/directory/game/Broken%20Age/details"}],"bubsy-the-woolies_1":[{"id":"1","title":"Bubsy: The Woolies Strike Back","image":"https://static-cdn.jtvnw.net/badges/v1/c8129382-1f4e-4d15-a8d2-48bdddba9b81/","url":"https://www.twitch.tv/directory/game/Bubsy:%20The%20Woolies%20Strike%20Back/details"}],"chatter-cs-go-2022":[{"id":"1","title":"CS:GO Week Brazil 2022","image":"https://static-cdn.jtvnw.net/badges/v1/57b6bd6b-a1b5-4204-9e6c-eb8ed5831603/"}],"clip-champ":[{"id":"1","title":"Power Clipper","image":"https://static-cdn.jtvnw.net/badges/v1/f38976e0-ffc9-11e7-86d6-7f98b26a9d79/","url":"https://help.twitch.tv/customer/portal/articles/2918323-clip-champs-guide"}],"creator-cs-go-2022":[{"id":"1","title":"CS:GO Week Brazil 2022","image":"https://static-cdn.jtvnw.net/badges/v1/a2ea6df9-ac0a-4956-bfe9-e931f50b94fa/"}],"cuphead_1":[{"id":"1","title":"Cuphead","image":"https://static-cdn.jtvnw.net/badges/v1/4384659a-a2e3-11e7-a564-87f6b1288bab/","url":"https://www.twitch.tv/directory/game/Cuphead/details"}],"darkest-dungeon_1":[{"id":"1","title":"Darkest Dungeon","image":"https://static-cdn.jtvnw.net/badges/v1/52a98ddd-cc79-46a8-9fe3-30f8c719bc2d/","url":"https://www.twitch.tv/directory/game/Darkest%20Dungeon/details"}],"deceit_1":[{"id":"1","title":"Deceit","image":"https://static-cdn.jtvnw.net/badges/v1/b14fef48-4ff9-4063-abf6-579489234fe9/","url":"https://www.twitch.tv/directory/game/Deceit/details"}],"destiny-2-final-shape-raid-race":[{"id":"1","title":"Destiny 2: The Final Shape Raid Race","image":"https://static-cdn.jtvnw.net/badges/v1/e79ee64f-31f1-4485-9c81-b93957e69f8a/","url":"https://www.twitch.tv/directory/category/destiny-2"}],"destiny-2-the-final-shape-streamer":[{"id":"1","title":"Destiny 2: The Final Shape Streamer","image":"https://static-cdn.jtvnw.net/badges/v1/b1bcaf3c-d7a2-442b-b407-03f2b8ff624d/","url":"https://www.twitch.tv/directory/category/destiny-2\t"}],"devil-may-cry-hd_1":[{"id":"1","title":"Devil May Cry HD Collection","image":"https://static-cdn.jtvnw.net/badges/v1/633877d4-a91c-4c36-b75b-803f82b1352f/","url":"https://www.twitch.tv/directory/game/Devil%20May%20Cry%20HD%20Collection/details"}],"devil-may-cry-hd_2":[{"id":"1","title":"Devil May Cry HD Collection","image":"https://static-cdn.jtvnw.net/badges/v1/408548fe-aa74-4d53-b5e9-960103d9b865/","url":"https://www.twitch.tv/directory/game/Devil%20May%20Cry%20HD%20Collection/details"}],"devil-may-cry-hd_3":[{"id":"1","title":"Devil May Cry HD Collection","image":"https://static-cdn.jtvnw.net/badges/v1/df84c5bf-8d66-48e2-b9fb-c014cc9b3945/","url":"https://www.twitch.tv/directory/game/Devil%20May%20Cry%20HD%20Collection/details"}],"devil-may-cry-hd_4":[{"id":"1","title":"Devil May Cry HD Collection","image":"https://static-cdn.jtvnw.net/badges/v1/af836b94-8ffd-4c0a-b7d8-a92fad5e3015/","url":"https://www.twitch.tv/directory/game/Devil%20May%20Cry%20HD%20Collection/details"}],"devilian_1":[{"id":"1","title":"Devilian","image":"https://static-cdn.jtvnw.net/badges/v1/3cb92b57-1eef-451c-ac23-4d748128b2c5/","url":"https://www.twitch.tv/directory/game/Devilian/details"}],"dreamcon-2024":[{"id":"1","title":"Dream Con 2024","image":"https://static-cdn.jtvnw.net/badges/v1/5dfbd056-8ac1-407f-bdf3-f83183fa97ae/"}],"duelyst_1":[{"id":"1","title":"Duelyst","image":"https://static-cdn.jtvnw.net/badges/v1/7d9c12f4-a2ac-4e88-8026-d1a330468282/","url":"https://www.twitch.tv/directory/game/Duelyst/details"}],"duelyst_2":[{"id":"1","title":"Duelyst","image":"https://static-cdn.jtvnw.net/badges/v1/1938acd3-2d18-471d-b1af-44f2047c033c/","url":"https://www.twitch.tv/directory/game/Duelyst/details"}],"duelyst_3":[{"id":"1","title":"Duelyst","image":"https://static-cdn.jtvnw.net/badges/v1/344c07fc-1632-47c6-9785-e62562a6b760/","url":"https://www.twitch.tv/directory/game/Duelyst/details"}],"duelyst_4":[{"id":"1","title":"Duelyst","image":"https://static-cdn.jtvnw.net/badges/v1/39e717a8-00bc-49cc-b6d4-3ea91ee1be25/","url":"https://www.twitch.tv/directory/game/Duelyst/details"}],"duelyst_5":[{"id":"1","title":"Duelyst","image":"https://static-cdn.jtvnw.net/badges/v1/290419b6-484a-47da-ad14-a99d6581f758/","url":"https://www.twitch.tv/directory/game/Duelyst/details"}],"duelyst_6":[{"id":"1","title":"Duelyst","image":"https://static-cdn.jtvnw.net/badges/v1/c5e54a4b-0bf1-463a-874a-38524579aed0/","url":"https://www.twitch.tv/directory/game/Duelyst/details"}],"duelyst_7":[{"id":"1","title":"Duelyst","image":"https://static-cdn.jtvnw.net/badges/v1/cf508179-3183-4987-97e0-56ca44babb9f/","url":"https://www.twitch.tv/directory/game/Duelyst/details"}],"enter-the-gungeon_1":[{"id":"1","title":"Enter The Gungeon","image":"https://static-cdn.jtvnw.net/badges/v1/53c9af0b-84f6-4f9d-8c80-4bc51321a37d/","url":"https://www.twitch.tv/directory/game/Enter%20the%20Gungeon/details"}],"eso_1":[{"id":"1","title":"Elder Scrolls Online","image":"https://static-cdn.jtvnw.net/badges/v1/18647a68-a35f-48d7-bf97-ae5deb6b277d/"}],"extension":[{"id":"1","title":"Extension","image":"https://static-cdn.jtvnw.net/badges/v1/ea8b0f8c-aa27-11e8-ba0c-1370ffff3854/"}],"firewatch_1":[{"id":"1","title":"Firewatch","image":"https://static-cdn.jtvnw.net/badges/v1/b6bf4889-4902-49e2-9658-c0132e71c9c4/","url":"https://www.twitch.tv/directory/game/Firewatch/details"}],"founder":[{"id":"0","title":"Founder","image":"https://static-cdn.jtvnw.net/badges/v1/511b78a9-ab37-472f-9569-457753bbe7d3/","url":"https://help.twitch.tv/s/article/founders-badge"}],"frozen-cortext_1":[{"id":"1","title":"Frozen Cortext","image":"https://static-cdn.jtvnw.net/badges/v1/2015f087-01b5-4a01-a2bb-ecb4d6be5240/","url":"https://www.twitch.tv/directory/game/Frozen%20Cortex/details"}],"frozen-synapse_1":[{"id":"1","title":"Frozen Synapse","image":"https://static-cdn.jtvnw.net/badges/v1/d4bd464d-55ea-4238-a11d-744f034e2375/","url":"https://www.twitch.tv/directory/game/Frozen%20Synapse/details"}],"game-developer":[{"id":"1","title":"Game Developer","image":"https://static-cdn.jtvnw.net/badges/v1/85856a4a-eb7d-4e26-a43e-d204a977ade4/"}],"getting-over-it_1":[{"id":"1","title":"Getting Over It","image":"https://static-cdn.jtvnw.net/badges/v1/8d4e178c-81ec-4c71-af68-745b40733984/","url":"https://www.twitch.tv/directory/game/Getting%20Over%20It/details"}],"getting-over-it_2":[{"id":"1","title":"Getting Over It","image":"https://static-cdn.jtvnw.net/badges/v1/bb620b42-e0e1-4373-928e-d4a732f99ccb/","url":"https://www.twitch.tv/directory/game/Getting%20Over%20It/details"}],"glhf-pledge":[{"id":"1","title":"GLHF Pledge","image":"https://static-cdn.jtvnw.net/badges/v1/3158e758-3cb4-43c5-94b3-7639810451c5/","url":"https://www.anykey.org/pledge"}],"glitchcon2020":[{"id":"1","title":"GlitchCon 2020","image":"https://static-cdn.jtvnw.net/badges/v1/1d4b03b9-51ea-42c9-8f29-698e3c85be3d/","url":"https://www.twitchcon.com/"}],"global_mod":[{"id":"1","title":"Global Moderator","image":"https://static-cdn.jtvnw.net/badges/v1/9384c43e-4ce7-4e94-b2a1-b93656896eba/"}],"gold-pixel-heart":[{"id":"1","title":"Gold Pixel Heart","image":"https://static-cdn.jtvnw.net/badges/v1/1687873b-cf38-412c-aad3-f9a4ce17f8b6/","url":"https://help.twitch.tv/s/article/twitch-charity"}],"heavy-bullets_1":[{"id":"1","title":"Heavy Bullets","image":"https://static-cdn.jtvnw.net/badges/v1/fc83b76b-f8b2-4519-9f61-6faf84eef4cd/","url":"https://www.twitch.tv/directory/game/Heavy%20Bullets/details"}],"hello_neighbor_1":[{"id":"1","title":"Hello Neighbor","image":"https://static-cdn.jtvnw.net/badges/v1/030cab2c-5d14-11e7-8d91-43a5a4306286/","url":"https://www.twitch.tv/directory/game/Hello%20Neighbor/details"}],"hype-train":[{"id":"1","title":"Current Hype Train Conductor","image":"https://static-cdn.jtvnw.net/badges/v1/fae4086c-3190-44d4-83c8-8ef0cbe1a515/","url":"https://help.twitch.tv/s/article/hype-train-guide"},{"id":"2","title":"Former Hype Train Conductor","image":"https://static-cdn.jtvnw.net/badges/v1/9c8d038a-3a29-45ea-96d4-5031fb1a7a81/","url":"https://help.twitch.tv/s/article/hype-train-guide"}],"innerspace_1":[{"id":"1","title":"Innerspace","image":"https://static-cdn.jtvnw.net/badges/v1/97532ccd-6a07-42b5-aecf-3458b6b3ebea/","url":"https://www.twitch.tv/directory/game/Innerspace/details"}],"innerspace_2":[{"id":"1","title":"Innerspace","image":"https://static-cdn.jtvnw.net/badges/v1/fc7d6018-657a-40e4-9246-0acdc85886d1/","url":"https://www.twitch.tv/directory/game/Innerspace/details"}],"jackbox-party-pack_1":[{"id":"1","title":"Jackbox Party Pack","image":"https://static-cdn.jtvnw.net/badges/v1/0f964fc1-f439-485f-a3c0-905294ee70e8/","url":"https://www.twitch.tv/directory/game/The%20Jackbox%20Party%20Pack/details"}],"kingdom-new-lands_1":[{"id":"1","title":"Kingdom: New Lands","image":"https://static-cdn.jtvnw.net/badges/v1/e3c2a67e-ef80-4fe3-ae41-b933cd11788a/","url":"https://www.twitch.tv/directory/game/Kingdom:%20New%20Lands/details"}],"la-velada-iv":[{"id":"1","title":"La Velada del A\u00c3\u00b1o IV","image":"https://static-cdn.jtvnw.net/badges/v1/655dac77-0b2f-4b62-8871-6ae21f82b34e/"}],"minecraft-15th-anniversary-celebration":[{"id":"1","title":"Minecraft 15th Anniversary Celebration","image":"https://static-cdn.jtvnw.net/badges/v1/178077b2-8b86-4f8d-927c-66ed6c1b025f/","url":"https://twitch-web.app.link/e/vkOhfCa7nJb"}],"moderator":[{"id":"1","title":"Moderator","image":"https://static-cdn.jtvnw.net/badges/v1/3267646d-33f0-4b17-b3df-f923a41db1d0/"}],"moments":[{"id":"1","title":"Moments Badge - Tier 1","image":"https://static-cdn.jtvnw.net/badges/v1/bf370830-d79a-497b-81c6-a365b2b60dda/"},{"id":"10","title":"Moments Badge - Tier 10","image":"https://static-cdn.jtvnw.net/badges/v1/9c13f2b6-69cd-4537-91b4-4a8bd8b6b1fd/"},{"id":"11","title":"Moments Badge - Tier 11","image":"https://static-cdn.jtvnw.net/badges/v1/7573e7a2-0f1f-4508-b833-d822567a1e03/"},{"id":"12","title":"Moments Badge - Tier 12","image":"https://static-cdn.jtvnw.net/badges/v1/f2c91d14-85c8-434b-a6c0-6d7930091150/"},{"id":"13","title":"Moments Badge - Tier 13","image":"https://static-cdn.jtvnw.net/badges/v1/35eb3395-a1d3-4170-969a-86402ecfb11a/"},{"id":"14","title":"Moments Badge - Tier 14","image":"https://static-cdn.jtvnw.net/badges/v1/cb40eb03-1015-45ba-8793-51c66a24a3d5/"},{"id":"15","title":"Moments Badge - Tier 15","image":"https://static-cdn.jtvnw.net/badges/v1/b241d667-280b-4183-96ae-2d0053631186/"},{"id":"16","title":"Moments Badge - Tier 16","image":"https://static-cdn.jtvnw.net/badges/v1/5684d1bc-8132-4a4f-850c-18d3c5bd04f3/"},{"id":"17","title":"Moments Badge - Tier 17","image":"https://static-cdn.jtvnw.net/badges/v1/3b08c1ee-0f77-451b-9226-b5b22d7f023c/"},{"id":"18","title":"Moments Badge - Tier 18","image":"https://static-cdn.jtvnw.net/badges/v1/14057e75-080c-42da-a412-6232c6f33b68/"},{"id":"19","title":"Moments Badge - Tier 19","image":"https://static-cdn.jtvnw.net/badges/v1/6100cc6f-6b4b-4a3d-a55b-a5b34edb5ea1/"},{"id":"2","title":"Moments Badge - Tier 2","image":"https://static-cdn.jtvnw.net/badges/v1/fc46b10c-5b45-43fd-81ad-d5cb0de6d2f4/"},{"id":"20","title":"Moments Badge - Tier 20","image":"https://static-cdn.jtvnw.net/badges/v1/43399796-e74c-4741-a975-56202f0af30e/"},{"id":"3","title":"Moments Badge - Tier 3","image":"https://static-cdn.jtvnw.net/badges/v1/d08658d7-205f-4f75-ad44-8c6e0acd8ef6/"},{"id":"4","title":"Moments Badge - Tier 4","image":"https://static-cdn.jtvnw.net/badges/v1/fe5b5ddc-93e7-4aaf-9b3e-799cd41808b1/"},{"id":"5","title":"Moments Badge - Tier 5","image":"https://static-cdn.jtvnw.net/badges/v1/c8a0d95a-856e-4097-9fc0-7765300a4f58/"},{"id":"6","title":"Moments Badge - Tier 6","image":"https://static-cdn.jtvnw.net/badges/v1/f9e3b4e4-200e-4045-bd71-3a6b480c23ae/"},{"id":"7","title":"Moments Badge - Tier 7","image":"https://static-cdn.jtvnw.net/badges/v1/a90a26a4-fdf7-4ac3-a782-76a413da16c1/"},{"id":"8","title":"Moments Badge - Tier 8","image":"https://static-cdn.jtvnw.net/badges/v1/f22286cd-6aa3-42ce-b3fb-10f5d18c4aa0/"},{"id":"9","title":"Moments Badge - Tier 9","image":"https://static-cdn.jtvnw.net/badges/v1/5cb2e584-1efd-469b-ab1d-4d1b59a944e7/"}],"no_audio":[{"id":"1","title":"Watching without audio","image":"https://static-cdn.jtvnw.net/badges/v1/aef2cd08-f29b-45a1-8c12-d44d7fd5e6f0/"}],"no_video":[{"id":"1","title":"Listening only","image":"https://static-cdn.jtvnw.net/badges/v1/199a0dba-58f3-494e-a7fc-1fa0a1001fb8/"}],"okhlos_1":[{"id":"1","title":"Okhlos","image":"https://static-cdn.jtvnw.net/badges/v1/dc088bd6-8965-4907-a1a2-c0ba83874a7d/","url":"https://www.twitch.tv/directory/game/Okhlos/details"}],"overwatch-league-insider_1":[{"id":"1","title":"OWL All-Access Pass 2018","image":"https://static-cdn.jtvnw.net/badges/v1/51e9e0aa-12e3-48ce-b961-421af0787dad/","url":"https://www.twitch.tv/overwatchleague"}],"overwatch-league-insider_2018B":[{"id":"1","title":"OWL All-Access Pass 2018","image":"https://static-cdn.jtvnw.net/badges/v1/34ec1979-d9bb-4706-ad15-464de814a79d/","url":"https://www.twitch.tv/overwatchleague"}],"overwatch-league-insider_2019A":[{"id":"1","title":"OWL All-Access Pass 2019","image":"https://static-cdn.jtvnw.net/badges/v1/ca980da1-3639-48a6-95a3-a03b002eb0e5/","url":"https://www.twitch.tv/overwatchleague"},{"id":"2","title":"OWL All-Access Pass 2019","image":"https://static-cdn.jtvnw.net/badges/v1/ab7fa7a7-c2d9-403f-9f33-215b29b43ce4/","url":"https://www.twitch.tv/overwatchleague"}],"overwatch-league-insider_2019B":[{"id":"1","title":"OWL All-Access Pass 2019","image":"https://static-cdn.jtvnw.net/badges/v1/c5860811-d714-4413-9433-d6b1c9fc803c/","url":"https://www.twitch.tv/overwatchleague"},{"id":"2","title":"OWL All-Access Pass 2019","image":"https://static-cdn.jtvnw.net/badges/v1/75f05d4b-3042-415c-8b0b-e87620a24daf/","url":"https://www.twitch.tv/overwatchleague"},{"id":"3","title":"OWL All-Access Pass 2019","image":"https://static-cdn.jtvnw.net/badges/v1/765a0dcf-2a94-43ff-9b9c-ef6c209b90cd/","url":"https://www.twitch.tv/overwatchleague"},{"id":"4","title":"OWL All-Access Pass 2019","image":"https://static-cdn.jtvnw.net/badges/v1/a8ae0ccd-783d-460d-93ee-57c485c558a6/","url":"https://www.twitch.tv/overwatchleague"},{"id":"5","title":"OWL All-Access Pass 2019","image":"https://static-cdn.jtvnw.net/badges/v1/be87fd6d-1560-4e33-9ba4-2401b58d901f/","url":"https://www.twitch.tv/overwatchleague"}],"partner":[{"id":"1","title":"Verified","image":"https://static-cdn.jtvnw.net/badges/v1/d12a2e27-16f6-41d0-ab77-b780518f00a3/","url":"https://blog.twitch.tv/2017/04/24/the-verified-badge-is-here-13381bc05735"}],"power-rangers":[{"id":"0","title":"Black Ranger","image":"https://static-cdn.jtvnw.net/badges/v1/9edf3e7f-62e4-40f5-86ab-7a646b10f1f0/"},{"id":"1","title":"Blue Ranger","image":"https://static-cdn.jtvnw.net/badges/v1/1eeae8fe-5bc6-44ed-9c88-fb84d5e0df52/"},{"id":"2","title":"Green Ranger","image":"https://static-cdn.jtvnw.net/badges/v1/21bbcd6d-1751-4d28-a0c3-0b72453dd823/"},{"id":"3","title":"Pink Ranger","image":"https://static-cdn.jtvnw.net/badges/v1/5c58cb40-9028-4d16-af67-5bc0c18b745e/"},{"id":"4","title":"Red Ranger","image":"https://static-cdn.jtvnw.net/badges/v1/8843d2de-049f-47d5-9794-b6517903db61/"},{"id":"5","title":"White Ranger","image":"https://static-cdn.jtvnw.net/badges/v1/06c85e34-477e-4939-9537-fd9978976042/"},{"id":"6","title":"Yellow Ranger","image":"https://static-cdn.jtvnw.net/badges/v1/d6dca630-1ca4-48de-94e7-55ed0a24d8d1/"}],"predictions":[{"id":"blue-1","title":"Predicted Blue (1)","image":"https://static-cdn.jtvnw.net/badges/v1/e33d8b46-f63b-4e67-996d-4a7dcec0ad33/"},{"id":"blue-10","title":"Predicted Blue (10)","image":"https://static-cdn.jtvnw.net/badges/v1/072ae906-ecf7-44f1-ac69-a5b2261d8892/"},{"id":"blue-2","title":"Predicted Blue (2)","image":"https://static-cdn.jtvnw.net/badges/v1/ffdda3fe-8012-4db3-981e-7a131402b057/"},{"id":"blue-3","title":"Predicted Blue (3)","image":"https://static-cdn.jtvnw.net/badges/v1/f2ab9a19-8ef7-4f9f-bd5d-9cf4e603f845/"},{"id":"blue-4","title":"Predicted Blue (4)","image":"https://static-cdn.jtvnw.net/badges/v1/df95317d-9568-46de-a421-a8520edb9349/"},{"id":"blue-5","title":"Predicted Blue (5)","image":"https://static-cdn.jtvnw.net/badges/v1/88758be8-de09-479b-9383-e3bb6d9e6f06/"},{"id":"blue-6","title":"Predicted Blue (6)","image":"https://static-cdn.jtvnw.net/badges/v1/46b1537e-d8b0-4c0d-8fba-a652e57b9df0/"},{"id":"blue-7","title":"Predicted Blue (7)","image":"https://static-cdn.jtvnw.net/badges/v1/07cd34b2-c6a1-45f5-8d8a-131e3c8b2279/"},{"id":"blue-8","title":"Predicted Blue (8)","image":"https://static-cdn.jtvnw.net/badges/v1/4416dfd7-db97-44a0-98e7-40b4e250615e/"},{"id":"blue-9","title":"Predicted Blue (9)","image":"https://static-cdn.jtvnw.net/badges/v1/fc74bd90-2b74-4f56-8e42-04d405e10fae/"},{"id":"gray-1","title":"Predicted Gray (1)","image":"https://static-cdn.jtvnw.net/badges/v1/144f77a2-e324-4a6b-9c17-9304fa193a27/"},{"id":"gray-2","title":"Predicted Gray (2)","image":"https://static-cdn.jtvnw.net/badges/v1/097a4b14-b458-47eb-91b6-fe74d3dbb3f5/"},{"id":"pink-1","title":"Predicted Pink (1)","image":"https://static-cdn.jtvnw.net/badges/v1/75e27613-caf7-4585-98f1-cb7363a69a4a/"},{"id":"pink-2","title":"Predicted Pink (2)","image":"https://static-cdn.jtvnw.net/badges/v1/4b76d5f2-91cc-4400-adf2-908a1e6cfd1e/"}],"premium":[{"id":"1","title":"Prime Gaming","image":"https://static-cdn.jtvnw.net/badges/v1/bbbe0db0-a598-423e-86d0-f9fb98ca1933/","url":"https://gaming.amazon.com"}],"psychonauts_1":[{"id":"1","title":"Psychonauts","image":"https://static-cdn.jtvnw.net/badges/v1/a9811799-dce3-475f-8feb-3745ad12b7ea/","url":"https://www.twitch.tv/directory/game/Psychonauts/details"}],"raging-wolf-helm":[{"id":"1","title":"Raging Wolf Helm","image":"https://static-cdn.jtvnw.net/badges/v1/3ff668be-59a3-4e3e-96af-e6b2908b3171/"}],"raiden-v-directors-cut_1":[{"id":"1","title":"Raiden V","image":"https://static-cdn.jtvnw.net/badges/v1/441b50ae-a2e3-11e7-8a3e-6bff0c840878/","url":"https://www.twitch.tv/directory/game/Raiden%20V/details"}],"rift_1":[{"id":"1","title":"RIFT","image":"https://static-cdn.jtvnw.net/badges/v1/f939686b-2892-46a4-9f0d-5f582578173e/","url":"https://www.twitch.tv/directory/game/Rift/details"}],"rplace-2023":[{"id":"1","title":"r/place 2023 Cake","image":"https://static-cdn.jtvnw.net/badges/v1/e33e0c67-c380-4241-828a-099c46e51c66/","url":"https://www.reddit.com/r/place/"}],"samusoffer_beta":[{"id":"0","title":"beta_title1","image":"https://static-cdn.jtvnw.net/badges/v1/aa960159-a7b8-417e-83c1-035e4bc2deb5/","url":"https://twitch.amazon.com/prime"}],"staff":[{"id":"1","title":"Staff","image":"https://static-cdn.jtvnw.net/badges/v1/d97c37bd-a6f5-4c38-8f57-4e4bef88af34/","url":"https://www.twitch.tv/jobs?ref=chat_badge"}],"starbound_1":[{"id":"1","title":"Starbound","image":"https://static-cdn.jtvnw.net/badges/v1/e838e742-0025-4646-9772-18a87ba99358/","url":"https://www.twitch.tv/directory/game/Starbound/details"}],"strafe_1":[{"id":"1","title":"Strafe","image":"https://static-cdn.jtvnw.net/badges/v1/0051508d-2d42-4e4b-a328-c86b04510ca4/","url":"https://www.twitch.tv/directory/game/strafe/details"}],"streamer-awards-2024":[{"id":"1","title":"Streamer Awards 2024","image":"https://static-cdn.jtvnw.net/badges/v1/efc07d3d-46e4-4738-827b-a5bf3508983a/","url":"https://thestreamerawards.com/home"}],"sub-gift-leader":[{"id":"1","title":"Gifter Leader 1","image":"https://static-cdn.jtvnw.net/badges/v1/21656088-7da2-4467-acd2-55220e1f45ad/"},{"id":"2","title":"Gifter Leader 2","image":"https://static-cdn.jtvnw.net/badges/v1/0d9fe96b-97b7-4215-b5f3-5328ebad271c/"},{"id":"3","title":"Gifter Leader 3","image":"https://static-cdn.jtvnw.net/badges/v1/4c6e4497-eed9-4dd3-ac64-e0599d0a63e5/"}],"sub-gifter":[{"id":"1","title":"Sub Gifter","image":"https://static-cdn.jtvnw.net/badges/v1/a5ef6c17-2e5b-4d8f-9b80-2779fd722414/"},{"id":"10","title":"10 Gift Subs","image":"https://static-cdn.jtvnw.net/badges/v1/d333288c-65d7-4c7b-b691-cdd7b3484bf8/"},{"id":"100","title":"100 Gift Subs","image":"https://static-cdn.jtvnw.net/badges/v1/8343ada7-3451-434e-91c4-e82bdcf54460/"},{"id":"1000","title":"1000 Gift Subs","image":"https://static-cdn.jtvnw.net/badges/v1/bfb7399a-c632-42f7-8d5f-154610dede81/"},{"id":"150","title":"150 Gift Subs","image":"https://static-cdn.jtvnw.net/badges/v1/514845ba-0fc3-4771-bce1-14d57e91e621/"},{"id":"200","title":"200 Gift Subs","image":"https://static-cdn.jtvnw.net/badges/v1/c6b1893e-8059-4024-b93c-39c84b601732/"},{"id":"2000","title":"2000 Gift Subs","image":"https://static-cdn.jtvnw.net/badges/v1/4e8b3a32-1513-44ad-8a12-6c90232c77f9/"},{"id":"25","title":"25 Gift Subs","image":"https://static-cdn.jtvnw.net/badges/v1/052a5d41-f1cc-455c-bc7b-fe841ffaf17f/"},{"id":"250","title":"250 Gift Subs","image":"https://static-cdn.jtvnw.net/badges/v1/cd479dc0-4a15-407d-891f-9fd2740bddda/"},{"id":"300","title":"300 Gift Subs","image":"https://static-cdn.jtvnw.net/badges/v1/9e1bb24f-d238-4078-871a-ac401b76ecf2/"},{"id":"3000","title":"3000 Gift Subs","image":"https://static-cdn.jtvnw.net/badges/v1/b18852ba-65d2-4b84-97d2-aeb6c44a0956/"},{"id":"350","title":"350 Gift Subs","image":"https://static-cdn.jtvnw.net/badges/v1/6c4783cd-0aba-4e75-a7a4-f48a70b665b0/"},{"id":"400","title":"400 Gift Subs","image":"https://static-cdn.jtvnw.net/badges/v1/6f4cab6b-def9-4d99-ad06-90b0013b28c8/"},{"id":"4000","title":"4000 Gift Subs","image":"https://static-cdn.jtvnw.net/badges/v1/efbf3c93-ecfa-4b67-8d0a-1f732fb07397/"},{"id":"450","title":"450 Gift Subs","image":"https://static-cdn.jtvnw.net/badges/v1/b593d68a-f8fb-4516-a09a-18cce955402c/"},{"id":"5","title":"5 Gift Subs","image":"https://static-cdn.jtvnw.net/badges/v1/ee113e59-c839-4472-969a-1e16d20f3962/"},{"id":"50","title":"50 Gift Subs","image":"https://static-cdn.jtvnw.net/badges/v1/c4a29737-e8a5-4420-917a-314a447f083e/"},{"id":"500","title":"500 Gift Subs","image":"https://static-cdn.jtvnw.net/badges/v1/60e9504c-8c3d-489f-8a74-314fb195ad8d/"},{"id":"5000","title":"5000 Gift Subs","image":"https://static-cdn.jtvnw.net/badges/v1/d775275d-fd19-4914-b63a-7928a22135c3/"},{"id":"550","title":"550 Gift Subs","image":"https://static-cdn.jtvnw.net/badges/v1/024d2563-1794-43ed-b8dc-33df3efae900/"},{"id":"600","title":"600 Gift Subs","image":"https://static-cdn.jtvnw.net/badges/v1/3ecc3aab-09bf-4823-905e-3a4647171fc1/"},{"id":"650","title":"650 Gift Subs","image":"https://static-cdn.jtvnw.net/badges/v1/eeabf43c-8e4c-448d-9790-4c2172c57944/"},{"id":"700","title":"700 Gift Subs","image":"https://static-cdn.jtvnw.net/badges/v1/4a9acdc7-30be-4dd1-9898-fc9e42b3d304/"},{"id":"750","title":"750 Gift Subs","image":"https://static-cdn.jtvnw.net/badges/v1/ca17277c-53e5-422b-8bb4-7c5dcdb0ac67/"},{"id":"800","title":"800 Gift Subs","image":"https://static-cdn.jtvnw.net/badges/v1/9c1fb96d-0579-43d7-ba94-94672eaef63a/"},{"id":"850","title":"850 Gift Subs","image":"https://static-cdn.jtvnw.net/badges/v1/cc924aaf-dfd4-4f3f-822a-f5a87eb24069/"},{"id":"900","title":"900 Gift Subs","image":"https://static-cdn.jtvnw.net/badges/v1/193d86f6-83e1-428c-9638-d6ca9e408166/"},{"id":"950","title":"950 Gift Subs","image":"https://static-cdn.jtvnw.net/badges/v1/7ce130bd-6f55-40cc-9231-e2a4cb712962/"}],"subscriber":[{"id":"0","title":"Subscriber","image":"https://static-cdn.jtvnw.net/badges/v1/5d9f2208-5dd8-11e7-8513-2ff4adfae661/"},{"id":"1","title":"Subscriber","image":"https://static-cdn.jtvnw.net/badges/v1/5d9f2208-5dd8-11e7-8513-2ff4adfae661/"},{"id":"2","title":"2-Month Subscriber","image":"https://static-cdn.jtvnw.net/badges/v1/25a03e36-2bb2-4625-bd37-d6d9d406238d/"},{"id":"3","title":"3-Month Subscriber","image":"https://static-cdn.jtvnw.net/badges/v1/e8984705-d091-4e54-8241-e53b30a84b0e/"},{"id":"4","title":"6-Month Subscriber","image":"https://static-cdn.jtvnw.net/badges/v1/2d2485f6-d19b-4daa-8393-9493b019156b/"},{"id":"5","title":"9-Month Subscriber","image":"https://static-cdn.jtvnw.net/badges/v1/b4e6b13a-a76f-4c56-87e1-9375a7aaa610/"},{"id":"6","title":"6-Month Subscriber","image":"https://static-cdn.jtvnw.net/badges/v1/ed51a614-2c44-4a60-80b6-62908436b43a/"}],"superhot_1":[{"id":"1","title":"Superhot","image":"https://static-cdn.jtvnw.net/badges/v1/c5a06922-83b5-40cb-885f-bcffd3cd6c68/","url":"https://www.twitch.tv/directory/game/superhot/details"}],"superultracombo-2023":[{"id":"1","title":"SuperUltraCombo 2023","image":"https://static-cdn.jtvnw.net/badges/v1/5864739a-5e58-4623-9450-a2c0555ef90b/"}],"the-game-awards-2023":[{"id":"1","title":"The Game Awards 2023","image":"https://static-cdn.jtvnw.net/badges/v1/10cf46de-61e7-4a42-807a-7898408ce352/","url":"https://blog.twitch.tv/2023/11/30/the-2023-game-awards-is-live-on-twitch-december-7th/"}],"the-golden-predictor-of-the-game-awards-2023":[{"id":"1","title":"The Golden Predictor of the Game Awards 2023","image":"https://static-cdn.jtvnw.net/badges/v1/c84c4dd7-9318-4e8b-9f01-1612d3f83dae/","url":"https://blog.twitch.tv/2023/11/30/the-2023-game-awards-is-live-on-twitch-december-7th/"}],"the-surge_1":[{"id":"1","title":"The Surge","image":"https://static-cdn.jtvnw.net/badges/v1/c9f69d89-31c8-41aa-843b-fee956dfbe23/","url":"https://www.twitch.tv/directory/game/The%20Surge/details"}],"the-surge_2":[{"id":"1","title":"The Surge","image":"https://static-cdn.jtvnw.net/badges/v1/2c4d7e95-e138-4dde-a783-7956a8ecc408/","url":"https://www.twitch.tv/directory/game/The%20Surge/details"}],"the-surge_3":[{"id":"1","title":"The Surge","image":"https://static-cdn.jtvnw.net/badges/v1/0a8fc2d4-3125-4ccb-88db-e970dfbee189/","url":"https://www.twitch.tv/directory/game/The%20Surge/details"}],"this-war-of-mine_1":[{"id":"1","title":"This War of Mine","image":"https://static-cdn.jtvnw.net/badges/v1/6a20f814-cb2c-414e-89cc-f8dd483e1785/","url":"https://www.twitch.tv/directory/game/This%20War%20of%20Mine/details"}],"titan-souls_1":[{"id":"1","title":"Titan Souls","image":"https://static-cdn.jtvnw.net/badges/v1/092a7ce2-709c-434f-8df4-a6b075ef867d/","url":"https://www.twitch.tv/directory/game/Titan%20Souls/details"}],"treasure-adventure-world_1":[{"id":"1","title":"Treasure Adventure World","image":"https://static-cdn.jtvnw.net/badges/v1/59810027-2988-4b0d-b88d-fc414c751305/","url":"https://www.twitch.tv/directory/game/Treasure%20Adventure%20World/details"}],"turbo":[{"id":"1","title":"Turbo","image":"https://static-cdn.jtvnw.net/badges/v1/bd444ec6-8f34-4bf9-91f4-af1e3428d80f/"}],"twitch-dj":[{"id":"1","title":"Twitch DJ","image":"https://static-cdn.jtvnw.net/badges/v1/cf91bbc0-0332-413a-a7f3-e36bac08b624/","url":"https://www.twitch.tv/dj-program"}],"twitch-intern-2023":[{"id":"1","title":"Twitch Intern 2023","image":"https://static-cdn.jtvnw.net/badges/v1/e239e7e0-e373-4fdf-b95e-3469aec28485/","url":"https://www.twitch.tv/jobs/early-career/"}],"twitch-intern-2024":[{"id":"1","title":"Twitch Intern 2024","image":"https://static-cdn.jtvnw.net/badges/v1/ae96ce48-e764-4232-aa48-d9abf9a5fdab/","url":"https://www.twitch.tv/jobs/early-career/"}],"twitch-recap-2023":[{"id":"1","title":"Twitch Recap 2023","image":"https://static-cdn.jtvnw.net/badges/v1/4d9e9812-ba9b-48a6-8690-13f3f338ee65/","url":"https://twitch-web.app.link/e/twitch-recap"}],"twitchbot":[{"id":"1","title":"AutoMod","image":"https://static-cdn.jtvnw.net/badges/v1/df9095f6-a8a0-4cc2-bb33-d908c0adffb8/","url":"http://link.twitch.tv/automod_blog"}],"twitchcon-2024---rotterdam":[{"id":"1","title":"TwitchCon 2024 - Rotterdam","image":"https://static-cdn.jtvnw.net/badges/v1/95b10c66-775c-4652-9b86-10bd3a709422/","url":"https://twitchcon.com/rotterdam-2024/?utm_source=twitch&utm_medium=chat-badge&utm_campaign=tceu24-chat-badge"}],"twitchcon-2024---san-diego":[{"id":"1","title":"TwitchCon 2024 - San Diego","image":"https://static-cdn.jtvnw.net/badges/v1/6575f0d1-2dc2-4f45-a13f-a1a969dcf8fa/","url":"https://twitchcon.com/san-diego-2024/?utm_source=twitch&utm_medium=chat-badge&utm_campaign=tcsd24-chat-badge"}],"twitchcon2017":[{"id":"1","title":"TwitchCon 2017 - Long Beach","image":"https://static-cdn.jtvnw.net/badges/v1/0964bed0-5c31-11e7-a90b-0739918f1d9b/","url":"https://www.twitchcon.com/"}],"twitchcon2018":[{"id":"1","title":"TwitchCon 2018 - San Jose","image":"https://static-cdn.jtvnw.net/badges/v1/e68164e4-087d-4f62-81da-d3557efae3cb/","url":"https://www.twitchcon.com/?utm_source=twitch-chat&utm_medium=badge&utm_campaign=tc18"}],"twitchconAmsterdam2020":[{"id":"1","title":"TwitchCon 2020 - Amsterdam","image":"https://static-cdn.jtvnw.net/badges/v1/ed917c9a-1a45-4340-9c64-ca8be4348c51/","url":"https://www.twitchcon.com/amsterdam/?utm_source=twitch-chat&utm_medium=badge&utm_campaign=tcamsterdam20"}],"twitchconEU2019":[{"id":"1","title":"TwitchCon 2019 - Berlin","image":"https://static-cdn.jtvnw.net/badges/v1/590eee9e-f04d-474c-90e7-b304d9e74b32/","url":"https://europe.twitchcon.com/?utm_source=twitch-chat&utm_medium=badge&utm_campaign=tceu19"}],"twitchconEU2022":[{"id":"1","title":"TwitchCon 2022 - Amsterdam","image":"https://static-cdn.jtvnw.net/badges/v1/e4744003-50b7-4eb8-9b47-a7b1616a30c6/","url":"https://www.twitchcon.com/amsterdam-2022/?utm_source=twitch-chat&utm_medium=badge&utm_campaign=tceu22"}],"twitchconEU2023":[{"id":"1","title":"TwitchCon 2023 - Paris","image":"https://static-cdn.jtvnw.net/badges/v1/a8f2084e-46b9-4bb9-ae5e-00d594aafc64/","url":"https://www.twitchcon.com/paris-2023/?utm_source=chat_badge"}],"twitchconNA2019":[{"id":"1","title":"TwitchCon 2019 - San Diego","image":"https://static-cdn.jtvnw.net/badges/v1/569c829d-c216-4f56-a191-3db257ed657c/","url":"https://www.twitchcon.com/?utm_source=twitch-chat&utm_medium=badge&utm_campaign=tcna19"}],"twitchconNA2020":[{"id":"1","title":"TwitchCon 2020 - North America","image":"https://static-cdn.jtvnw.net/badges/v1/ed917c9a-1a45-4340-9c64-ca8be4348c51/","url":"https://www.twitchcon.com/?utm_source=twitch-chat&utm_medium=badge&utm_campaign=tcna20"}],"twitchconNA2022":[{"id":"1","title":"TwitchCon 2022 - San Diego","image":"https://static-cdn.jtvnw.net/badges/v1/344d429a-0b34-48e5-a84c-14a1b5772a3a/","url":"https://www.twitchcon.com/san-diego-2022/?utm_source=twitch-chat&utm_medium=badge&utm_campaign=tcna22"}],"twitchconNA2023":[{"id":"1","title":"TwitchCon 2023 - Las Vegas","image":"https://static-cdn.jtvnw.net/badges/v1/c90a753f-ab20-41bc-9c42-ede062485d2c/","url":"https://www.twitchcon.com/en/las-vegas-2023/"}],"tyranny_1":[{"id":"1","title":"Tyranny","image":"https://static-cdn.jtvnw.net/badges/v1/0c79afdf-28ce-4b0b-9e25-4f221c30bfde/","url":"https://www.twitch.tv/directory/game/Tyranny/details"}],"user-anniversary":[{"id":"1","title":"Twitchiversary Badge","image":"https://static-cdn.jtvnw.net/badges/v1/ccbbedaa-f4db-4d0b-9c2a-375de7ad947c/"}],"vga-champ-2017":[{"id":"1","title":"2017 VGA Champ","image":"https://static-cdn.jtvnw.net/badges/v1/03dca92e-dc69-11e7-ac5b-9f942d292dc7/","url":"https://blog.twitch.tv/watch-and-co-stream-the-game-awards-this-thursday-on-twitch-3d8e34d2345d"}],"vip":[{"id":"1","title":"VIP","image":"https://static-cdn.jtvnw.net/badges/v1/b817aba4-fad8-49e2-b88a-7cc744dfa6ec/","url":"https://help.twitch.tv/customer/en/portal/articles/659115-twitch-chat-badges-guide"}],"warcraft":[{"id":"alliance","title":"Alliance","image":"https://static-cdn.jtvnw.net/badges/v1/c4816339-bad4-4645-ae69-d1ab2076a6b0/","url":"http://warcraftontwitch.tv/"},{"id":"horde","title":"Horde","image":"https://static-cdn.jtvnw.net/badges/v1/de8b26b6-fd28-4e6c-bc89-3d597343800d/","url":"http://warcraftontwitch.tv/"}],"zevent-2024":[{"id":"1","title":"ZEVENT 2024","image":"https://static-cdn.jtvnw.net/badges/v1/2040d479-b815-4617-8a55-9aed027e30d0/"}]} \ No newline at end of file diff --git a/scripts/update-badges.py b/scripts/update-badges.py new file mode 100644 index 00000000000..5c5f5cc3ce7 --- /dev/null +++ b/scripts/update-badges.py @@ -0,0 +1,28 @@ +import sys +import json +from pathlib import Path + + +def trim_version(version): + base_url = version["image_url_1x"].removesuffix("1") + assert version["image_url_2x"] == base_url + "2" + assert version["image_url_4x"] == base_url + "3" + v = { + "id": version["id"], + "title": version["title"], + "image": base_url, + } + if version["click_url"]: + v["url"] = version["click_url"] + return v + + +raw = sys.stdin.read() +assert len(raw) > 0, "Response from Helix' chat/badges/global needs to be piped" +base = json.loads(raw)["data"] +out = {set["set_id"]: [trim_version(v) for v in set["versions"]] for set in base} + +with open( + Path(__file__).parent.parent / "resources" / "twitch-badges.json", mode="w" +) as f: + f.write(json.dumps(out, separators=(",", ":"))) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b72256dda23..18d67f26493 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -422,6 +422,8 @@ set(SOURCE_FILES providers/twitch/TwitchBadges.hpp providers/twitch/TwitchChannel.cpp providers/twitch/TwitchChannel.hpp + providers/twitch/TwitchCommon.cpp + providers/twitch/TwitchCommon.hpp providers/twitch/TwitchEmotes.cpp providers/twitch/TwitchEmotes.hpp providers/twitch/TwitchHelpers.cpp @@ -568,6 +570,8 @@ set(SOURCE_FILES widgets/Label.hpp widgets/Notebook.cpp widgets/Notebook.hpp + widgets/OverlayWindow.cpp + widgets/OverlayWindow.hpp widgets/Scrollbar.cpp widgets/Scrollbar.hpp widgets/TooltipEntryWidget.cpp @@ -655,6 +659,8 @@ set(SOURCE_FILES widgets/helper/NotebookButton.hpp widgets/helper/NotebookTab.cpp widgets/helper/NotebookTab.hpp + widgets/helper/OverlayInteraction.cpp + widgets/helper/OverlayInteraction.hpp widgets/helper/RegExpItemDelegate.cpp widgets/helper/RegExpItemDelegate.hpp widgets/helper/ResizingTextEdit.cpp diff --git a/src/common/Args.cpp b/src/common/Args.cpp index eeebbfc201e..795e1fa1558 100644 --- a/src/common/Args.cpp +++ b/src/common/Args.cpp @@ -117,6 +117,13 @@ Args::Args(const QApplication &app, const Paths &paths) "safe-mode", "Starts Chatterino without loading Plugins and always " "show the settings button."); + QCommandLineOption loginOption( + "login", + "Starts Chatterino logged in as the account matching the supplied " + "username. If the supplied username does not match any account " + "Chatterino starts logged in as anonymous.", + "username"); + // Channel layout auto channelLayout = QCommandLineOption( {"c", "channels"}, @@ -142,6 +149,7 @@ Args::Args(const QApplication &app, const Paths &paths) parentWindowIdOption, verboseOption, safeModeOption, + loginOption, channelLayout, activateOption, }); @@ -197,6 +205,11 @@ Args::Args(const QApplication &app, const Paths &paths) this->safeMode = true; } + if (parser.isSet(loginOption)) + { + this->initialLogin = parser.value(loginOption); + } + if (parser.isSet(activateOption)) { this->activateChannel = @@ -206,6 +219,7 @@ Args::Args(const QApplication &app, const Paths &paths) this->currentArguments_ = extractCommandLine(parser, { verboseOption, safeModeOption, + loginOption, channelLayout, activateOption, }); diff --git a/src/common/Args.hpp b/src/common/Args.hpp index b3eeb20ffe1..cac8fb674e3 100644 --- a/src/common/Args.hpp +++ b/src/common/Args.hpp @@ -60,6 +60,7 @@ class Args bool dontLoadMainWindow{}; std::optional customChannelLayout; std::optional activateChannel; + std::optional initialLogin; bool verbose{}; bool safeMode{}; diff --git a/src/common/Common.hpp b/src/common/Common.hpp index f1ce967549f..7a0436fc134 100644 --- a/src/common/Common.hpp +++ b/src/common/Common.hpp @@ -8,13 +8,14 @@ namespace chatterino { -constexpr QStringView LINK_CHATTERINO_WIKI = u"https://wiki.chatterino.com"; -constexpr QStringView LINK_CHATTERINO_DISCORD = +inline constexpr QStringView LINK_CHATTERINO_WIKI = + u"https://wiki.chatterino.com"; +inline constexpr QStringView LINK_CHATTERINO_DISCORD = u"https://discord.gg/7Y5AYhAK4z"; -constexpr QStringView LINK_CHATTERINO_SOURCE = +inline constexpr QStringView LINK_CHATTERINO_SOURCE = u"https://github.com/Chatterino/chatterino2"; -constexpr QStringView TWITCH_PLAYER_URL = +inline constexpr QStringView TWITCH_PLAYER_URL = u"https://player.twitch.tv/?channel=%1&parent=twitch.tv"; enum class HighlightState { @@ -23,14 +24,14 @@ enum class HighlightState { NewMessage, }; -constexpr Qt::KeyboardModifiers SHOW_SPLIT_OVERLAY_MODIFIERS = +inline constexpr Qt::KeyboardModifiers SHOW_SPLIT_OVERLAY_MODIFIERS = Qt::ControlModifier | Qt::AltModifier; -constexpr Qt::KeyboardModifiers SHOW_ADD_SPLIT_REGIONS = +inline constexpr Qt::KeyboardModifiers SHOW_ADD_SPLIT_REGIONS = Qt::ControlModifier | Qt::AltModifier; -constexpr Qt::KeyboardModifiers SHOW_RESIZE_HANDLES_MODIFIERS = +inline constexpr Qt::KeyboardModifiers SHOW_RESIZE_HANDLES_MODIFIERS = Qt::ControlModifier; -constexpr const char *ANONYMOUS_USERNAME_LABEL = " - anonymous - "; +inline constexpr const char *ANONYMOUS_USERNAME_LABEL = " - anonymous - "; template std::weak_ptr weakOf(T *element) diff --git a/src/controllers/completion/strategies/ClassicEmoteStrategy.cpp b/src/controllers/completion/strategies/ClassicEmoteStrategy.cpp index 1a427483a7f..134e6e1e9e0 100644 --- a/src/controllers/completion/strategies/ClassicEmoteStrategy.cpp +++ b/src/controllers/completion/strategies/ClassicEmoteStrategy.cpp @@ -52,25 +52,36 @@ void ClassicTabEmoteStrategy::apply(const std::vector &items, std::vector &output, const QString &query) const { - bool emojiOnly = false; - QString normalizedQuery = query; - if (normalizedQuery.startsWith(':')) + bool colonStart = query.startsWith(':'); + QStringView normalizedQuery = query; + if (colonStart) { + // TODO(Qt6): use sliced normalizedQuery = normalizedQuery.mid(1); - // tab completion with : prefix should do emojis only - emojiOnly = true; } std::set emotes; for (const auto &item : items) { - if (emojiOnly ^ item.isEmoji) + QStringView itemQuery; + if (item.isEmoji) + { + if (colonStart) + { + itemQuery = normalizedQuery; + } + else + { + continue; // ignore emojis when not completing with ':' + } + } + else { - continue; + itemQuery = query; } - if (startsWithOrContains(item.searchName, normalizedQuery, + if (startsWithOrContains(item.searchName, itemQuery, Qt::CaseInsensitive, getSettings()->prefixOnlyEmoteCompletion)) { diff --git a/src/controllers/completion/strategies/SmartEmoteStrategy.cpp b/src/controllers/completion/strategies/SmartEmoteStrategy.cpp index aa6e43127ef..a01be15b23e 100644 --- a/src/controllers/completion/strategies/SmartEmoteStrategy.cpp +++ b/src/controllers/completion/strategies/SmartEmoteStrategy.cpp @@ -25,8 +25,7 @@ namespace { * @return How different the emote is from query. Values in the range [-10, * \infty]. */ - int costOfEmote(const QString &query, const QString &emote, - bool prioritizeUpper) + int costOfEmote(QStringView query, QStringView emote, bool prioritizeUpper) { int score = 0; @@ -68,8 +67,8 @@ namespace { // matchingFunction is used for testing if the emote should be included in the search. void completeEmotes( const std::vector &items, std::vector &output, - const QString &query, bool ignoreColonForCost, - const std::function + QStringView query, bool ignoreColonForCost, + const std::function &matchingFunction) { // Given these emotes: pajaW, PAJAW @@ -92,8 +91,7 @@ namespace { for (const auto &item : items) { if (matchingFunction( - item, query, - haveUpper ? Qt::CaseSensitive : Qt::CaseInsensitive)) + item, haveUpper ? Qt::CaseSensitive : Qt::CaseInsensitive)) { output.push_back(item); } @@ -118,7 +116,7 @@ namespace { // Run the search again but this time without case sensitivity for (const auto &item : items) { - if (matchingFunction(item, query, Qt::CaseInsensitive)) + if (matchingFunction(item, Qt::CaseInsensitive)) { output.push_back(item); } @@ -170,9 +168,10 @@ void SmartEmoteStrategy::apply(const std::vector &items, ignoreColonForCost = true; } completeEmotes(items, output, normalizedQuery, ignoreColonForCost, - [](const EmoteItem &left, const QString &right, - Qt::CaseSensitivity caseHandling) { - return left.searchName.contains(right, caseHandling); + [normalizedQuery](const EmoteItem &left, + Qt::CaseSensitivity caseHandling) { + return left.searchName.contains(normalizedQuery, + caseHandling); }); } @@ -180,25 +179,38 @@ void SmartTabEmoteStrategy::apply(const std::vector &items, std::vector &output, const QString &query) const { - bool emojiOnly = false; - QString normalizedQuery = query; - if (normalizedQuery.startsWith(':')) + bool colonStart = query.startsWith(':'); + QStringView normalizedQuery = query; + if (colonStart) { + // TODO(Qt6): use sliced normalizedQuery = normalizedQuery.mid(1); - // tab completion with : prefix should do emojis only - emojiOnly = true; } - completeEmotes(items, output, normalizedQuery, false, - [emojiOnly](const EmoteItem &left, const QString &right, - Qt::CaseSensitivity caseHandling) -> bool { - if (emojiOnly ^ left.isEmoji) - { - return false; - } - return startsWithOrContains( - left.searchName, right, caseHandling, - getSettings()->prefixOnlyEmoteCompletion); - }); + + completeEmotes( + items, output, normalizedQuery, false, + [&](const EmoteItem &item, Qt::CaseSensitivity caseHandling) -> bool { + QStringView itemQuery; + if (item.isEmoji) + { + if (colonStart) + { + itemQuery = normalizedQuery; + } + else + { + return false; // ignore emojis when not completing with ':' + } + } + else + { + itemQuery = query; + } + + return startsWithOrContains( + item.searchName, itemQuery, caseHandling, + getSettings()->prefixOnlyEmoteCompletion); + }); } } // namespace chatterino::completion diff --git a/src/controllers/filters/lang/Filter.cpp b/src/controllers/filters/lang/Filter.cpp index 2cda8a13b19..a2e79150ba8 100644 --- a/src/controllers/filters/lang/Filter.cpp +++ b/src/controllers/filters/lang/Filter.cpp @@ -10,13 +10,46 @@ namespace chatterino::filters { +const QMap MESSAGE_TYPING_CONTEXT{ + {"author.badges", Type::StringList}, + {"author.color", Type::Color}, + {"author.name", Type::String}, + {"author.no_color", Type::Bool}, + {"author.subbed", Type::Bool}, + {"author.sub_length", Type::Int}, + {"channel.name", Type::String}, + {"channel.watching", Type::Bool}, + {"channel.live", Type::Bool}, + {"flags.action", Type::Bool}, + {"flags.highlighted", Type::Bool}, + {"flags.points_redeemed", Type::Bool}, + {"flags.sub_message", Type::Bool}, + {"flags.system_message", Type::Bool}, + {"flags.reward_message", Type::Bool}, + {"flags.first_message", Type::Bool}, + {"flags.elevated_message", Type::Bool}, + {"flags.hype_chat", Type::Bool}, + {"flags.cheer_message", Type::Bool}, + {"flags.whisper", Type::Bool}, + {"flags.reply", Type::Bool}, + {"flags.automod", Type::Bool}, + {"flags.restricted", Type::Bool}, + {"flags.monitored", Type::Bool}, + {"flags.shared", Type::Bool}, + {"message.content", Type::String}, + {"message.length", Type::Int}, + {"reward.title", Type::String}, + {"reward.cost", Type::Int}, + {"reward.id", Type::String}, +}; + ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel) { auto watchingChannel = getApp()->getTwitch()->getWatchingChannel().get(); /* * Looking to add a new identifier to filters? Here's what to do: - * 1. Update validIdentifiersMap in Tokenizer.hpp + * 1. Update validIdentifiersMap in Tokenizer.cpp * 2. Add the identifier to the list below * 3. Add the type of the identifier to MESSAGE_TYPING_CONTEXT in Filter.hpp * 4. Add the value for the identifier to the ContextMap returned by this function @@ -46,6 +79,7 @@ ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel) * flags.automod * flags.restricted * flags.monitored + * flags.shared * * message.content * message.length @@ -109,6 +143,7 @@ ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel) {"flags.automod", m->flags.has(MessageFlag::AutoMod)}, {"flags.restricted", m->flags.has(MessageFlag::RestrictedMessage)}, {"flags.monitored", m->flags.has(MessageFlag::MonitoredMessage)}, + {"flags.shared", m->flags.has(MessageFlag::SharedMessage)}, {"message.content", m->messageText}, {"message.length", m->messageText.length()}, diff --git a/src/controllers/filters/lang/Filter.hpp b/src/controllers/filters/lang/Filter.hpp index 7a0f44805a1..5425900266b 100644 --- a/src/controllers/filters/lang/Filter.hpp +++ b/src/controllers/filters/lang/Filter.hpp @@ -22,37 +22,7 @@ namespace chatterino::filters { // For example, flags.highlighted is a boolean variable, so it is marked as Type::Bool // below. These variable types will be used to check whether a filter "makes sense", // i.e. if all the variables and operators being used have compatible types. -static const QMap MESSAGE_TYPING_CONTEXT = { - {"author.badges", Type::StringList}, - {"author.color", Type::Color}, - {"author.name", Type::String}, - {"author.no_color", Type::Bool}, - {"author.subbed", Type::Bool}, - {"author.sub_length", Type::Int}, - {"channel.name", Type::String}, - {"channel.watching", Type::Bool}, - {"channel.live", Type::Bool}, - {"flags.action", Type::Bool}, - {"flags.highlighted", Type::Bool}, - {"flags.points_redeemed", Type::Bool}, - {"flags.sub_message", Type::Bool}, - {"flags.system_message", Type::Bool}, - {"flags.reward_message", Type::Bool}, - {"flags.first_message", Type::Bool}, - {"flags.elevated_message", Type::Bool}, - {"flags.hype_chat", Type::Bool}, - {"flags.cheer_message", Type::Bool}, - {"flags.whisper", Type::Bool}, - {"flags.reply", Type::Bool}, - {"flags.automod", Type::Bool}, - {"flags.restricted", Type::Bool}, - {"flags.monitored", Type::Bool}, - {"message.content", Type::String}, - {"message.length", Type::Int}, - {"reward.title", Type::String}, - {"reward.cost", Type::Int}, - {"reward.id", Type::String}, -}; +extern const QMap MESSAGE_TYPING_CONTEXT; ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel); diff --git a/src/controllers/filters/lang/Tokenizer.cpp b/src/controllers/filters/lang/Tokenizer.cpp index f25f1976b07..5e9d11374ec 100644 --- a/src/controllers/filters/lang/Tokenizer.cpp +++ b/src/controllers/filters/lang/Tokenizer.cpp @@ -2,8 +2,55 @@ #include "common/QLogging.hpp" +namespace { + +const QRegularExpression TOKEN_REGEX( + "((r|ri)?\\\")((\\\\\")|[^\\\"])*\\\"|" // String/Regex literal + "[\\w\\.]+|" // Identifier or reserved keyword + "(<=?|>=?|!=?|==|\\|\\||&&|\\+|-|\\*|\\/|%)+|" // Operator + "[\\(\\)]|" // Parentheses + "[{},]" // List +); + +} // namespace + namespace chatterino::filters { +const QMap VALID_IDENTIFIERS_MAP{ + {"author.badges", "author badges"}, + {"author.color", "author color"}, + {"author.name", "author name"}, + {"author.no_color", "author has no color?"}, + {"author.subbed", "author subscribed?"}, + {"author.sub_length", "author sub length"}, + {"channel.name", "channel name"}, + {"channel.watching", "/watching channel?"}, + {"channel.live", "channel live?"}, + {"flags.action", "action/me message?"}, + {"flags.highlighted", "highlighted?"}, + {"flags.points_redeemed", "redeemed points?"}, + {"flags.sub_message", "sub/resub message?"}, + {"flags.system_message", "system message?"}, + {"flags.reward_message", "channel point reward message?"}, + {"flags.first_message", "first message?"}, + {"flags.elevated_message", "hype chat message?"}, + // Ideally these values are unique, because ChannelFilterEditorDialog::ValueSpecifier::expressionText depends on + // std::map layout in Qt 6 and internal implementation in Qt 5. + {"flags.hype_chat", "hype chat message?"}, + {"flags.cheer_message", "cheer message?"}, + {"flags.whisper", "whisper message?"}, + {"flags.reply", "reply message?"}, + {"flags.automod", "automod message?"}, + {"flags.restricted", "restricted message?"}, + {"flags.monitored", "monitored message?"}, + {"flags.shared", "shared message?"}, + {"message.content", "message text"}, + {"message.length", "message length"}, + {"reward.title", "point reward title"}, + {"reward.cost", "point reward cost"}, + {"reward.id", "point reward id"}, +}; + QString tokenTypeToInfoString(TokenType type) { switch (type) @@ -77,7 +124,7 @@ QString tokenTypeToInfoString(TokenType type) Tokenizer::Tokenizer(const QString &text) { - QRegularExpressionMatchIterator i = tokenRegex.globalMatch(text); + QRegularExpressionMatchIterator i = TOKEN_REGEX.globalMatch(text); while (i.hasNext()) { auto capturedText = i.next().captured(); @@ -278,7 +325,7 @@ TokenType Tokenizer::tokenize(const QString &text) return TokenType::STRING; } - if (validIdentifiersMap.keys().contains(text)) + if (VALID_IDENTIFIERS_MAP.keys().contains(text)) { return TokenType::IDENTIFIER; } diff --git a/src/controllers/filters/lang/Tokenizer.hpp b/src/controllers/filters/lang/Tokenizer.hpp index ced78c5d2e7..27dfc418a96 100644 --- a/src/controllers/filters/lang/Tokenizer.hpp +++ b/src/controllers/filters/lang/Tokenizer.hpp @@ -8,49 +8,7 @@ namespace chatterino::filters { -static const QMap validIdentifiersMap = { - {"author.badges", "author badges"}, - {"author.color", "author color"}, - {"author.name", "author name"}, - {"author.no_color", "author has no color?"}, - {"author.subbed", "author subscribed?"}, - {"author.sub_length", "author sub length"}, - {"channel.name", "channel name"}, - {"channel.watching", "/watching channel?"}, - {"channel.live", "channel live?"}, - {"flags.action", "action/me message?"}, - {"flags.highlighted", "highlighted?"}, - {"flags.points_redeemed", "redeemed points?"}, - {"flags.sub_message", "sub/resub message?"}, - {"flags.system_message", "system message?"}, - {"flags.reward_message", "channel point reward message?"}, - {"flags.first_message", "first message?"}, - {"flags.elevated_message", "hype chat message?"}, - // Ideally these values are unique, because ChannelFilterEditorDialog::ValueSpecifier::expressionText depends on - // std::map layout in Qt 6 and internal implementation in Qt 5. - {"flags.hype_chat", "hype chat message?"}, - {"flags.cheer_message", "cheer message?"}, - {"flags.whisper", "whisper message?"}, - {"flags.reply", "reply message?"}, - {"flags.automod", "automod message?"}, - {"flags.restricted", "restricted message?"}, - {"flags.monitored", "monitored message?"}, - {"message.content", "message text"}, - {"message.length", "message length"}, - {"reward.title", "point reward title"}, - {"reward.cost", "point reward cost"}, - {"reward.id", "point reward id"}, -}; - -// clang-format off -static const QRegularExpression tokenRegex( - QString("((r|ri)?\\\")((\\\\\")|[^\\\"])*\\\"|") + // String/Regex literal - QString("[\\w\\.]+|") + // Identifier or reserved keyword - QString("(<=?|>=?|!=?|==|\\|\\||&&|\\+|-|\\*|\\/|%)+|") + // Operator - QString("[\\(\\)]|") + // Parentheses - QString("[{},]") // List -); -// clang-format on +extern const QMap VALID_IDENTIFIERS_MAP; enum TokenType { // control diff --git a/src/controllers/hotkeys/ActionNames.hpp b/src/controllers/hotkeys/ActionNames.hpp index 3ebd7d051c0..f15d2e2026b 100644 --- a/src/controllers/hotkeys/ActionNames.hpp +++ b/src/controllers/hotkeys/ActionNames.hpp @@ -181,6 +181,20 @@ inline const std::map actionNames{ {"showSearch", ActionDefinition{"Search current channel"}}, {"showGlobalSearch", ActionDefinition{"Search all channels"}}, {"debug", ActionDefinition{"Show debug popup"}}, + {"popupOverlay", ActionDefinition{"New overlay popup"}}, + {"toggleOverlayInertia", + ActionDefinition{ + .displayName = "Toggle overlay click-through", + .argumentDescription = "", + .minCountArguments = 1, + .maxCountArguments = 1, + .possibleArguments{ + {"This", {"this"}}, + {"All", {"all"}}, + {"This or all", {"thisOrAll"}}, + }, + .argumentsPrompt = "Target popup:", + }}, }}, {HotkeyCategory::SplitInput, { @@ -259,7 +273,8 @@ inline const std::map actionNames{ {"moveTab", ActionDefinition{ "Move tab", - "", + "", 1, }}, {"newSplit", ActionDefinition{"Create a new split"}}, diff --git a/src/controllers/hotkeys/HotkeyController.cpp b/src/controllers/hotkeys/HotkeyController.cpp index f435bf4afa2..1aa6e7c9664 100644 --- a/src/controllers/hotkeys/HotkeyController.cpp +++ b/src/controllers/hotkeys/HotkeyController.cpp @@ -403,6 +403,12 @@ void HotkeyController::addDefaults(std::set &addedHotkeys) this->tryAddDefault(addedHotkeys, HotkeyCategory::Split, QKeySequence("F10"), "debug", std::vector(), "open debug popup"); + this->tryAddDefault(addedHotkeys, HotkeyCategory::Split, + QKeySequence("Ctrl+Alt+N"), "popupOverlay", {}, + "open overlay"); + this->tryAddDefault( + addedHotkeys, HotkeyCategory::Split, QKeySequence("Ctrl+Shift+U"), + "toggleOverlayInertia", {"all"}, "toggle overlay click-through"); } // split input diff --git a/src/messages/Emote.cpp b/src/messages/Emote.cpp index f19fc3027b5..4d7a7bbb90f 100644 --- a/src/messages/Emote.cpp +++ b/src/messages/Emote.cpp @@ -1,9 +1,15 @@ #include "messages/Emote.hpp" +#include "common/Literals.hpp" + +#include + #include namespace chatterino { +using namespace literals; + bool operator==(const Emote &a, const Emote &b) { return std::tie(a.homePage, a.name, a.tooltip, a.images) == @@ -15,6 +21,37 @@ bool operator!=(const Emote &a, const Emote &b) return !(a == b); } +QJsonObject Emote::toJson() const +{ + QJsonObject obj{ + {"name"_L1, this->name.string}, + {"images"_L1, this->images.toJson()}, + {"tooltip"_L1, this->tooltip.string}, + }; + if (!this->homePage.string.isEmpty()) + { + obj["homePage"_L1] = this->homePage.string; + } + if (this->zeroWidth) + { + obj["zeroWidth"_L1] = this->zeroWidth; + } + if (!this->id.string.isEmpty()) + { + obj["id"_L1] = this->id.string; + } + if (!this->author.string.isEmpty()) + { + obj["author"_L1] = this->author.string; + } + if (this->baseName) + { + obj["baseName"_L1] = this->baseName->string; + } + + return obj; +} + EmotePtr cachedOrMakeEmotePtr(Emote &&emote, const EmoteMap &cache) { // reuse old shared_ptr if nothing changed diff --git a/src/messages/Emote.hpp b/src/messages/Emote.hpp index 57e4a8e68e3..c2fd3885ad1 100644 --- a/src/messages/Emote.hpp +++ b/src/messages/Emote.hpp @@ -9,6 +9,8 @@ #include #include +class QJsonObject; + namespace chatterino { struct Emote { @@ -30,6 +32,8 @@ struct Emote { { return name.string; } + + QJsonObject toJson() const; }; bool operator==(const Emote &a, const Emote &b); @@ -55,7 +59,7 @@ class EmoteMap : public std::unordered_map const QString &emoteID) const; }; -static const std::shared_ptr EMPTY_EMOTE_MAP = std::make_shared< +inline const std::shared_ptr EMPTY_EMOTE_MAP = std::make_shared< const EmoteMap>(); // NOLINT(cert-err58-cpp) -- assume this doesn't throw an exception EmotePtr cachedOrMakeEmotePtr(Emote &&emote, const EmoteMap &cache); diff --git a/src/messages/ImageSet.cpp b/src/messages/ImageSet.cpp index 7e25e3f86b6..1c591f64400 100644 --- a/src/messages/ImageSet.cpp +++ b/src/messages/ImageSet.cpp @@ -3,6 +3,8 @@ #include "messages/Image.hpp" #include "singletons/Settings.hpp" +#include + namespace chatterino { ImageSet::ImageSet() @@ -135,4 +137,22 @@ bool ImageSet::operator!=(const ImageSet &other) const return !this->operator==(other); } +QJsonObject ImageSet::toJson() const +{ + QJsonObject obj; + if (!this->imageX1_->isEmpty()) + { + obj[u"1x"] = this->imageX1_->url().string; + } + if (!this->imageX2_->isEmpty()) + { + obj[u"2x"] = this->imageX2_->url().string; + } + if (!this->imageX3_->isEmpty()) + { + obj[u"3x"] = this->imageX3_->url().string; + } + return obj; +} + } // namespace chatterino diff --git a/src/messages/ImageSet.hpp b/src/messages/ImageSet.hpp index 49c94eed0cb..c5cba49e2f4 100644 --- a/src/messages/ImageSet.hpp +++ b/src/messages/ImageSet.hpp @@ -4,6 +4,8 @@ #include +class QJsonObject; + namespace chatterino { class Image; @@ -34,6 +36,8 @@ class ImageSet bool operator==(const ImageSet &other) const; bool operator!=(const ImageSet &other) const; + QJsonObject toJson() const; + private: ImagePtr imageX1_; ImagePtr imageX2_; diff --git a/src/messages/Message.cpp b/src/messages/Message.cpp index 011752f0359..a9ae40aa8c0 100644 --- a/src/messages/Message.cpp +++ b/src/messages/Message.cpp @@ -1,13 +1,23 @@ #include "messages/Message.hpp" +#include "Application.hpp" +#include "common/Literals.hpp" +#include "messages/MessageThread.hpp" #include "providers/colors/ColorProvider.hpp" #include "providers/twitch/TwitchBadge.hpp" #include "singletons/Settings.hpp" #include "util/DebugCount.hpp" +#include "util/QMagicEnum.hpp" #include "widgets/helper/ScrollbarHighlight.hpp" +#include +#include +#include + namespace chatterino { +using namespace literals; + Message::Message() : parseTime(QTime::currentTime()) { @@ -111,4 +121,72 @@ std::shared_ptr Message::cloneWith( return std::move(cloned); } +QJsonObject Message::toJson() const +{ + QJsonObject msg{ + {"flags"_L1, qmagicenum::enumFlagsName(this->flags.value())}, + {"id"_L1, this->id}, + {"searchText"_L1, this->searchText}, + {"messageText"_L1, this->messageText}, + {"loginName"_L1, this->loginName}, + {"displayName"_L1, this->displayName}, + {"localizedName"_L1, this->localizedName}, + {"timeoutUser"_L1, this->timeoutUser}, + {"channelName"_L1, this->channelName}, + {"usernameColor"_L1, this->usernameColor.name(QColor::HexArgb)}, + {"count"_L1, static_cast(this->count)}, + {"serverReceivedTime"_L1, + this->serverReceivedTime.toString(Qt::ISODate)}, + }; + + QJsonArray badges; + for (const auto &badge : this->badges) + { + badges.append(badge.key_); + } + msg["badges"_L1] = badges; + + QJsonObject badgeInfos; + for (const auto &[key, value] : this->badgeInfos) + { + badgeInfos.insert(key, value); + } + msg["badgeInfos"_L1] = badgeInfos; + + if (this->highlightColor) + { + msg["highlightColor"_L1] = this->highlightColor->name(QColor::HexArgb); + } + + if (this->replyThread) + { + msg["replyThread"_L1] = this->replyThread->toJson(); + } + + if (this->replyParent) + { + msg["replyParent"_L1] = this->replyParent->id; + } + + if (this->reward) + { + msg["reward"_L1] = this->reward->toJson(); + } + + // XXX: figure out if we can add this in tests + if (!getApp()->isTest()) + { + msg["parseTime"_L1] = this->parseTime.toString(Qt::ISODate); + } + + QJsonArray elements; + for (const auto &element : this->elements) + { + elements.append(element->toJson()); + } + msg["elements"_L1] = elements; + + return msg; +} + } // namespace chatterino diff --git a/src/messages/Message.hpp b/src/messages/Message.hpp index c06dd947e8a..e213eb8ad12 100644 --- a/src/messages/Message.hpp +++ b/src/messages/Message.hpp @@ -13,6 +13,8 @@ #include #include +class QJsonObject; + namespace chatterino { class MessageElement; class MessageThread; @@ -72,6 +74,8 @@ struct Message { */ std::shared_ptr cloneWith( const std::function &fn) const; + + QJsonObject toJson() const; }; } // namespace chatterino diff --git a/src/messages/MessageBuilder.cpp b/src/messages/MessageBuilder.cpp index 2a2cb5a7c21..d43d0fdc309 100644 --- a/src/messages/MessageBuilder.cpp +++ b/src/messages/MessageBuilder.cpp @@ -1836,6 +1836,7 @@ std::pair MessageBuilder::makeAutomodMessage( // // Builder for AutoMod message with explanation + builder.message().id = "automod_" + action.msgID; builder.message().loginName = "automod"; builder.message().channelName = channelName; builder.message().flags.set(MessageFlag::PubSub); @@ -2678,6 +2679,26 @@ void MessageBuilder::parseRoomID() { this->twitchChannel->setRoomId(this->roomID_); } + + if (auto it = this->tags.find("source-room-id"); it != this->tags.end()) + { + auto sourceRoom = it.value().toString(); + if (this->roomID_ != sourceRoom) + { + this->message().flags.set(MessageFlag::SharedMessage); + + auto sourceChan = + getApp()->getTwitch()->getChannelOrEmptyByID(sourceRoom); + if (sourceChan && !sourceChan->isEmpty()) + { + this->sourceChannel = + dynamic_cast(sourceChan.get()); + // avoid duplicate pings + this->message().flags.set( + MessageFlag::DoNotTriggerNotification); + } + } + } } } @@ -2895,18 +2916,19 @@ void MessageBuilder::appendUsername() } } -Outcome MessageBuilder::tryAppendEmote(const EmoteName &name) +const TwitchChannel *MessageBuilder::getSourceChannel() const { - auto *app = getApp(); - - const auto *globalBttvEmotes = app->getBttvEmotes(); - const auto *globalFfzEmotes = app->getFfzEmotes(); - const auto *globalSeventvEmotes = app->getSeventvEmotes(); + if (this->sourceChannel != nullptr) + { + return this->sourceChannel; + } - auto flags = MessageElementFlags(); - auto emote = std::optional{}; - bool zeroWidth = false; + return this->twitchChannel; +} +std::tuple, MessageElementFlags, bool> + MessageBuilder::parseEmote(const EmoteName &name) const +{ // Emote order: // - 7TV Personal // - FrankerFaceZ Channel @@ -2915,43 +2937,103 @@ Outcome MessageBuilder::tryAppendEmote(const EmoteName &name) // - FrankerFaceZ Global // - BetterTTV Global // - 7TV Global - if (this->twitchChannel != nullptr && - (emote = app->getSeventvPersonalEmotes()->getEmoteForUser(this->userId_, - name))) - { - flags = MessageElementFlag::SevenTVEmote; - } - else if (this->twitchChannel && - (emote = this->twitchChannel->ffzEmote(name))) - { - flags = MessageElementFlag::FfzEmote; - } - else if (this->twitchChannel && - (emote = this->twitchChannel->bttvEmote(name))) - { - flags = MessageElementFlag::BttvEmote; - } - else if (this->twitchChannel != nullptr && - (emote = this->twitchChannel->seventvEmote(name))) + + const auto *globalFfzEmotes = getApp()->getFfzEmotes(); + const auto *globalBttvEmotes = getApp()->getBttvEmotes(); + const auto *globalSeventvEmotes = getApp()->getSeventvEmotes(); + + const auto *sourceChannel = this->getSourceChannel(); + + std::optional emote{}; + + if (sourceChannel != nullptr) { - flags = MessageElementFlag::SevenTVEmote; - zeroWidth = emote.value()->zeroWidth; + // Check for channel emotes + emote = getApp()->getSeventvPersonalEmotes()->getEmoteForUser( + this->userId_, name); + if (emote) + { + return { + emote, + MessageElementFlag::SevenTVEmote, + emote.value()->zeroWidth, + }; + } + + emote = sourceChannel->ffzEmote(name); + if (emote) + { + return { + emote, + MessageElementFlag::FfzEmote, + false, + }; + } + + emote = sourceChannel->bttvEmote(name); + if (emote) + { + return { + emote, + MessageElementFlag::BttvEmote, + false, + }; + } + + emote = sourceChannel->seventvEmote(name); + if (emote) + { + return { + emote, + MessageElementFlag::SevenTVEmote, + emote.value()->zeroWidth, + }; + } } - else if ((emote = globalFfzEmotes->emote(name))) + + // Check for global emotes + + emote = globalFfzEmotes->emote(name); + if (emote) { - flags = MessageElementFlag::FfzEmote; + return { + emote, + MessageElementFlag::FfzEmote, + false, + }; } - else if ((emote = globalBttvEmotes->emote(name))) + + emote = globalBttvEmotes->emote(name); + if (emote) { - flags = MessageElementFlag::BttvEmote; - zeroWidth = zeroWidthEmotes.contains(name.string); + return { + emote, + MessageElementFlag::BttvEmote, + zeroWidthEmotes.contains(name.string), + }; } - else if ((emote = globalSeventvEmotes->globalEmote(name))) + + emote = globalSeventvEmotes->globalEmote(name); + if (emote) { - flags = MessageElementFlag::SevenTVEmote; - zeroWidth = emote.value()->zeroWidth; + return { + emote, + MessageElementFlag::SevenTVEmote, + emote.value()->zeroWidth, + }; } + return { + {}, + {}, + false, + }; +} + +Outcome MessageBuilder::tryAppendEmote(const EmoteName &name) +{ + const auto [emote, flags, zeroWidth] = this->parseEmote(name); + if (emote) { if (zeroWidth && getSettings()->enableZeroWidthEmotes && @@ -3137,7 +3219,8 @@ Outcome MessageBuilder::tryParseCheermote(const QString &string) return Failure; } - auto cheerOpt = this->twitchChannel->cheerEmote(string); + const auto *chan = this->getSourceChannel(); + auto cheerOpt = chan->cheerEmote(string); if (!cheerOpt) { diff --git a/src/messages/MessageBuilder.hpp b/src/messages/MessageBuilder.hpp index 996595a8e8f..651de404513 100644 --- a/src/messages/MessageBuilder.hpp +++ b/src/messages/MessageBuilder.hpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include @@ -164,7 +165,10 @@ class MessageBuilder QString userName; + /// The Twitch Channel the message was received in TwitchChannel *twitchChannel = nullptr; + /// The Twitch Channel the message was sent in, according to the Shared Chat feature + TwitchChannel *sourceChannel = nullptr; Message *operator->(); Message &message(); @@ -278,6 +282,15 @@ class MessageBuilder void appendChannelName(); void appendUsername(); + /// Return the Twitch Channel this message originated from + /// + /// Useful to handle messages from the "Shared Chat" feature + /// + /// Can return nullptr + const TwitchChannel *getSourceChannel() const; + + std::tuple, MessageElementFlags, bool> parseEmote( + const EmoteName &name) const; Outcome tryAppendEmote(const EmoteName &name); void addWords(const QStringList &words, diff --git a/src/messages/MessageColor.cpp b/src/messages/MessageColor.cpp index 6e2f01c6737..ff5f01aca6f 100644 --- a/src/messages/MessageColor.cpp +++ b/src/messages/MessageColor.cpp @@ -1,6 +1,6 @@ #include "messages/MessageColor.hpp" -#include "singletons/Theme.hpp" +#include "messages/layouts/MessageLayoutContext.hpp" namespace chatterino { @@ -15,22 +15,39 @@ MessageColor::MessageColor(Type type) { } -const QColor &MessageColor::getColor(Theme &themeManager) const +const QColor &MessageColor::getColor(const MessageColors &colors) const { switch (this->type_) { case Type::Custom: return this->customColor_; case Type::Text: - return themeManager.messages.textColors.regular; + return colors.regularText; case Type::System: - return themeManager.messages.textColors.system; + return colors.systemText; case Type::Link: - return themeManager.messages.textColors.link; + return colors.linkText; } static QColor _default; return _default; } +QString MessageColor::toString() const +{ + switch (this->type_) + { + case Type::Custom: + return this->customColor_.name(QColor::HexArgb); + case Type::Text: + return QStringLiteral("Text"); + case Type::System: + return QStringLiteral("System"); + case Type::Link: + return QStringLiteral("Link"); + default: + return {}; + } +} + } // namespace chatterino diff --git a/src/messages/MessageColor.hpp b/src/messages/MessageColor.hpp index c111e880383..013efa42776 100644 --- a/src/messages/MessageColor.hpp +++ b/src/messages/MessageColor.hpp @@ -1,9 +1,11 @@ #pragma once #include +#include namespace chatterino { -class Theme; + +struct MessageColors; struct MessageColor { enum Type : uint8_t { Custom, Text, Link, System }; @@ -16,7 +18,9 @@ struct MessageColor { return this->type_; } - const QColor &getColor(Theme &themeManager) const; + const QColor &getColor(const MessageColors &colors) const; + + QString toString() const; private: Type type_; diff --git a/src/messages/MessageElement.cpp b/src/messages/MessageElement.cpp index 33de9910389..31edfe1284b 100644 --- a/src/messages/MessageElement.cpp +++ b/src/messages/MessageElement.cpp @@ -1,11 +1,13 @@ #include "messages/MessageElement.hpp" #include "Application.hpp" +#include "common/Literals.hpp" #include "controllers/moderationactions/ModerationAction.hpp" #include "debug/Benchmark.hpp" #include "messages/Emote.hpp" #include "messages/Image.hpp" #include "messages/layouts/MessageLayoutContainer.hpp" +#include "messages/layouts/MessageLayoutContext.hpp" #include "messages/layouts/MessageLayoutElement.hpp" #include "providers/emoji/Emojis.hpp" #include "singletons/Emotes.hpp" @@ -14,10 +16,16 @@ #include "util/DebugCount.hpp" #include "util/Variant.hpp" +#include +#include +#include + #include namespace chatterino { +using namespace literals; + namespace { // Computes the bounding box for the given vector of images @@ -98,6 +106,22 @@ void MessageElement::cloneFrom(const MessageElement &source) this->trailingSpace = source.trailingSpace; } +QJsonObject MessageElement::toJson() const +{ + return { + {"trailingSpace"_L1, this->trailingSpace}, + { + "link"_L1, + {{ + {"type"_L1, qmagicenum::enumNameString(this->link_.type)}, + {"value"_L1, this->link_.value}, + }}, + }, + {"tooltip"_L1, this->tooltip_}, + {"flags"_L1, qmagicenum::enumFlagsName(this->flags_.value())}, + }; +} + // IMAGE ImageElement::ImageElement(ImagePtr image, MessageElementFlags flags) : MessageElement(flags) @@ -106,9 +130,9 @@ ImageElement::ImageElement(ImagePtr image, MessageElementFlags flags) } void ImageElement::addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags) + const MessageLayoutContext &ctx) { - if (flags.hasAny(this->getFlags())) + if (ctx.flags.hasAny(this->getFlags())) { auto size = QSize(this->image_->width() * container.getScale(), this->image_->height() * container.getScale()); @@ -125,6 +149,15 @@ std::unique_ptr ImageElement::clone() const return el; } +QJsonObject ImageElement::toJson() const +{ + auto base = MessageElement::toJson(); + base["type"_L1] = u"ImageElement"_s; + base["url"_L1] = this->image_->url().string; + + return base; +} + CircularImageElement::CircularImageElement(ImagePtr image, int padding, QColor background, MessageElementFlags flags) @@ -136,9 +169,9 @@ CircularImageElement::CircularImageElement(ImagePtr image, int padding, } void CircularImageElement::addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags) + const MessageLayoutContext &ctx) { - if (flags.hasAny(this->getFlags())) + if (ctx.flags.hasAny(this->getFlags())) { auto imgSize = QSize(this->image_->width(), this->image_->height()) * container.getScale(); @@ -156,6 +189,17 @@ std::unique_ptr CircularImageElement::clone() const return el; } +QJsonObject CircularImageElement::toJson() const +{ + auto base = MessageElement::toJson(); + base["type"_L1] = u"CircularImageElement"_s; + base["url"_L1] = this->image_->url().string; + base["padding"_L1] = this->padding_; + base["background"_L1] = this->background_.name(QColor::HexArgb); + + return base; +} + // EMOTE EmoteElement::EmoteElement(const EmotePtr &emote, MessageElementFlags flags, const MessageColor &textElementColor) @@ -174,11 +218,11 @@ EmotePtr EmoteElement::getEmote() const } void EmoteElement::addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags) + const MessageLayoutContext &ctx) { - if (flags.hasAny(this->getFlags())) + if (ctx.flags.hasAny(this->getFlags())) { - if (flags.has(MessageElementFlag::EmoteImages)) + if (ctx.flags.has(MessageElementFlag::EmoteImages)) { auto image = this->emote_->images.getImageOrLoaded( container.getImageScale()); @@ -199,8 +243,9 @@ void EmoteElement::addToContainer(MessageLayoutContainer &container, { if (this->textElement_) { - this->textElement_->addToContainer(container, - MessageElementFlag::Misc); + auto textCtx = ctx; + textCtx.flags = MessageElementFlag::Misc; + this->textElement_->addToContainer(container, textCtx); } } } @@ -221,6 +266,19 @@ std::unique_ptr EmoteElement::clone() const return el; } +QJsonObject EmoteElement::toJson() const +{ + auto base = MessageElement::toJson(); + base["type"_L1] = u"EmoteElement"_s; + base["emote"_L1] = this->emote_->toJson(); + if (this->textElement_) + { + base["text"_L1] = this->textElement_->toJson(); + } + + return base; +} + LayeredEmoteElement::LayeredEmoteElement( std::vector &&emotes, MessageElementFlags flags, const MessageColor &textElementColor) @@ -238,11 +296,11 @@ void LayeredEmoteElement::addEmoteLayer(const LayeredEmoteElement::Emote &emote) } void LayeredEmoteElement::addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags) + const MessageLayoutContext &ctx) { - if (flags.hasAny(this->getFlags())) + if (ctx.flags.hasAny(this->getFlags())) { - if (flags.has(MessageElementFlag::EmoteImages)) + if (ctx.flags.has(MessageElementFlag::EmoteImages)) { auto images = this->getLoadedImages(container.getImageScale()); if (images.empty()) @@ -269,8 +327,9 @@ void LayeredEmoteElement::addToContainer(MessageLayoutContainer &container, { if (this->textElement_) { - this->textElement_->addToContainer(container, - MessageElementFlag::Misc); + auto textCtx = ctx; + textCtx.flags = MessageElementFlag::Misc; + this->textElement_->addToContainer(container, textCtx); } } } @@ -398,6 +457,38 @@ std::unique_ptr LayeredEmoteElement::clone() const return el; } +QJsonObject LayeredEmoteElement::toJson() const +{ + auto base = MessageElement::toJson(); + base["type"_L1] = u"LayeredEmoteElement"_s; + + QJsonArray emotes; + for (const auto &emote : this->emotes_) + { + emotes.append({{ + {"flags"_L1, qmagicenum::enumFlagsName(emote.flags.value())}, + {"emote"_L1, emote.ptr->toJson()}, + }}); + } + base["emotes"_L1] = emotes; + + QJsonArray tooltips; + for (const auto &tooltip : this->emoteTooltips_) + { + emotes.append(tooltip); + } + base["tooltips"_L1] = tooltips; + + if (this->textElement_) + { + base["text"_L1] = this->textElement_->toJson(); + } + + base["textElementColor"_L1] = this->textElementColor_.toString(); + + return base; +} + // BADGE BadgeElement::BadgeElement(const EmotePtr &emote, MessageElementFlags flags) : MessageElement(flags) @@ -407,9 +498,9 @@ BadgeElement::BadgeElement(const EmotePtr &emote, MessageElementFlags flags) } void BadgeElement::addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags) + const MessageLayoutContext &ctx) { - if (flags.hasAny(this->getFlags())) + if (ctx.flags.hasAny(this->getFlags())) { auto image = this->emote_->images.getImageOrLoaded(container.getImageScale()); @@ -445,6 +536,15 @@ std::unique_ptr BadgeElement::clone() const return el; } +QJsonObject BadgeElement::toJson() const +{ + auto base = MessageElement::toJson(); + base["type"_L1] = u"BadgeElement"_s; + base["emote"_L1] = this->emote_->toJson(); + + return base; +} + // MOD BADGE ModBadgeElement::ModBadgeElement(const EmotePtr &data, MessageElementFlags flags_) @@ -470,6 +570,14 @@ std::unique_ptr ModBadgeElement::clone() const return el; } +QJsonObject ModBadgeElement::toJson() const +{ + auto base = BadgeElement::toJson(); + base["type"_L1] = u"ModBadgeElement"_s; + + return base; +} + // VIP BADGE VipBadgeElement::VipBadgeElement(const EmotePtr &data, MessageElementFlags flags_) @@ -492,6 +600,14 @@ std::unique_ptr VipBadgeElement::clone() const return el; } +QJsonObject VipBadgeElement::toJson() const +{ + auto base = BadgeElement::toJson(); + base["type"_L1] = u"VipBadgeElement"_s; + + return base; +} + // FFZ Badge FfzBadgeElement::FfzBadgeElement(const EmotePtr &data, MessageElementFlags flags_, QColor color_) @@ -517,6 +633,15 @@ std::unique_ptr FfzBadgeElement::clone() const return el; } +QJsonObject FfzBadgeElement::toJson() const +{ + auto base = BadgeElement::toJson(); + base["type"_L1] = u"FfzBadgeElement"_s; + base["color"_L1] = this->color.name(QColor::HexArgb); + + return base; +} + // TEXT TextElement::TextElement(const QString &text, MessageElementFlags flags, const MessageColor &color, FontStyle style) @@ -553,11 +678,11 @@ QStringList TextElement::words() const } void TextElement::addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags) + const MessageLayoutContext &ctx) { auto *app = getApp(); - if (flags.hasAny(this->getFlags())) + if (ctx.flags.hasAny(this->getFlags())) { QFontMetrics metrics = app->getFonts()->getFontMetrics(this->style_, container.getScale()); @@ -568,7 +693,7 @@ void TextElement::addToContainer(MessageLayoutContainer &container, auto getTextLayoutElement = [&](QString text, int width, bool hasTrailingSpace) { - auto color = this->color_.getColor(*app->getThemes()); + auto color = this->color_.getColor(ctx.messageColors); app->getThemes()->normalizeColor(color); auto *e = new TextLayoutElement( @@ -660,6 +785,17 @@ std::unique_ptr TextElement::clone() const return el; } +QJsonObject TextElement::toJson() const +{ + auto base = MessageElement::toJson(); + base["type"_L1] = u"TextElement"_s; + base["words"_L1] = QJsonArray::fromStringList(this->words_); + base["color"_L1] = this->color_.toString(); + base["style"_L1] = qmagicenum::enumNameString(this->style_); + + return base; +} + SingleLineTextElement::SingleLineTextElement(const QString &text, MessageElementFlags flags, const MessageColor &color, @@ -675,18 +811,18 @@ SingleLineTextElement::SingleLineTextElement(const QString &text, } void SingleLineTextElement::addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags) + const MessageLayoutContext &ctx) { auto *app = getApp(); - if (flags.hasAny(this->getFlags())) + if (ctx.flags.hasAny(this->getFlags())) { QFontMetrics metrics = app->getFonts()->getFontMetrics(this->style_, container.getScale()); auto getTextLayoutElement = [&](QString text, int width, bool hasTrailingSpace) { - auto color = this->color_.getColor(*app->getThemes()); + auto color = this->color_.getColor(ctx.messageColors); app->getThemes()->normalizeColor(color); auto *e = new TextLayoutElement( @@ -797,6 +933,22 @@ std::unique_ptr SingleLineTextElement::clone() const return el; } +QJsonObject SingleLineTextElement::toJson() const +{ + auto base = MessageElement::toJson(); + base["type"_L1] = u"SingleLineTextElement"_s; + QJsonArray words; + for (const auto &word : this->words_) + { + words.append(word.text); + } + base["words"_L1] = words; + base["color"_L1] = this->color_.toString(); + base["style"_L1] = qmagicenum::enumNameString(this->style_); + + return base; +} + LinkElement::LinkElement(const Parsed &parsed, const QString &fullUrl, MessageElementFlags flags, const MessageColor &color, FontStyle style) @@ -809,11 +961,11 @@ LinkElement::LinkElement(const Parsed &parsed, const QString &fullUrl, } void LinkElement::addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags) + const MessageLayoutContext &ctx) { this->words_ = getSettings()->lowercaseDomains ? this->lowercase_ : this->original_; - TextElement::addToContainer(container, flags); + TextElement::addToContainer(container, ctx); } Link LinkElement::getLink() const @@ -834,6 +986,17 @@ std::unique_ptr LinkElement::clone() const return el; } +QJsonObject LinkElement::toJson() const +{ + auto base = TextElement::toJson(); + base["type"_L1] = u"LinkElement"_s; + base["link"_L1] = this->linkInfo_.originalUrl(); + base["lowercase"_L1] = QJsonArray::fromStringList(this->lowercase_); + base["original"_L1] = QJsonArray::fromStringList(this->original_); + + return base; +} + MentionElement::MentionElement(const QString &displayName, QString loginName_, MessageColor fallbackColor_, MessageColor userColor_) @@ -855,7 +1018,7 @@ MentionElement::MentionElement(QStringList &&words, MessageColor fallbackColor_, } void MentionElement::addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags) + const MessageLayoutContext &ctx) { if (getSettings()->colorUsernames) { @@ -875,7 +1038,7 @@ void MentionElement::addToContainer(MessageLayoutContainer &container, this->style_ = FontStyle::ChatMedium; } - TextElement::addToContainer(container, flags); + TextElement::addToContainer(container, ctx); } std::unique_ptr MentionElement::clone() const @@ -906,7 +1069,24 @@ Link MentionElement::getLink() const return {Link::UserInfo, this->userLoginName}; } +QJsonObject MentionElement::toJson() const +{ + auto base = TextElement::toJson(); + base["type"_L1] = u"MentionElement"_s; + base["fallbackColor"_L1] = this->fallbackColor.toString(); + base["userColor"_L1] = this->userColor.toString(); + base["userLoginName"_L1] = this->userLoginName; + + return base; +} + // TIMESTAMP +TimestampElement::TimestampElement() + : TimestampElement(getApp()->isTest() ? QTime::fromMSecsSinceStartOfDay(0) + : QTime::currentTime()) +{ +} + TimestampElement::TimestampElement(QTime time) : MessageElement(MessageElementFlag::Timestamp) , time_(time) @@ -916,9 +1096,9 @@ TimestampElement::TimestampElement(QTime time) } void TimestampElement::addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags) + const MessageLayoutContext &ctx) { - if (flags.hasAny(this->getFlags())) + if (ctx.flags.hasAny(this->getFlags())) { if (getSettings()->timestampFormat != this->format_) { @@ -926,7 +1106,7 @@ void TimestampElement::addToContainer(MessageLayoutContainer &container, this->element_.reset(this->formatTime(this->time_)); } - this->element_->addToContainer(container, flags); + this->element_->addToContainer(container, ctx); } } @@ -947,6 +1127,17 @@ std::unique_ptr TimestampElement::clone() const return el; } +QJsonObject TimestampElement::toJson() const +{ + auto base = MessageElement::toJson(); + base["type"_L1] = u"TimestampElement"_s; + base["time"_L1] = this->time_.toString(Qt::ISODate); + base["element"_L1] = this->element_->toJson(); + base["format"_L1] = this->format_; + + return base; +} + // TWITCH MODERATION TwitchModerationElement::TwitchModerationElement() : MessageElement(MessageElementFlag::ModeratorTools) @@ -954,9 +1145,9 @@ TwitchModerationElement::TwitchModerationElement() } void TwitchModerationElement::addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags) + const MessageLayoutContext &ctx) { - if (flags.has(MessageElementFlag::ModeratorTools)) + if (ctx.flags.has(MessageElementFlag::ModeratorTools)) { QSize size(int(container.getScale() * 16), int(container.getScale() * 16)); @@ -988,15 +1179,23 @@ std::unique_ptr TwitchModerationElement::clone() const return el; } +QJsonObject TwitchModerationElement::toJson() const +{ + auto base = MessageElement::toJson(); + base["type"_L1] = u"TwitchModerationElement"_s; + + return base; +} + LinebreakElement::LinebreakElement(MessageElementFlags flags) : MessageElement(flags) { } void LinebreakElement::addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags) + const MessageLayoutContext &ctx) { - if (flags.hasAny(this->getFlags())) + if (ctx.flags.hasAny(this->getFlags())) { container.breakLine(); } @@ -1009,6 +1208,14 @@ std::unique_ptr LinebreakElement::clone() const return el; } +QJsonObject LinebreakElement::toJson() const +{ + auto base = MessageElement::toJson(); + base["type"_L1] = u"LinebreakElement"_s; + + return base; +} + ScalingImageElement::ScalingImageElement(ImageSet images, MessageElementFlags flags) : MessageElement(flags) @@ -1017,9 +1224,9 @@ ScalingImageElement::ScalingImageElement(ImageSet images, } void ScalingImageElement::addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags) + const MessageLayoutContext &ctx) { - if (flags.hasAny(this->getFlags())) + if (ctx.flags.hasAny(this->getFlags())) { const auto &image = this->images_.getImageOrLoaded(container.getImageScale()); @@ -1043,20 +1250,29 @@ std::unique_ptr ScalingImageElement::clone() const return el; } +QJsonObject ScalingImageElement::toJson() const +{ + auto base = MessageElement::toJson(); + base["type"_L1] = u"ScalingImageElement"_s; + base["image"_L1] = this->images_.getImage1()->url().string; + + return base; +} + ReplyCurveElement::ReplyCurveElement() : MessageElement(MessageElementFlag::RepliedMessage) { } void ReplyCurveElement::addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags) + const MessageLayoutContext &ctx) { static const int width = 18; // Overall width static const float thickness = 1.5; // Pen width static const int radius = 6; // Radius of the top left corner static const int margin = 2; // Top/Left/Bottom margin - if (flags.hasAny(this->getFlags())) + if (ctx.flags.hasAny(this->getFlags())) { float scale = container.getScale(); container.addElement( @@ -1072,4 +1288,12 @@ std::unique_ptr ReplyCurveElement::clone() const return el; } +QJsonObject ReplyCurveElement::toJson() const +{ + auto base = MessageElement::toJson(); + base["type"_L1] = u"ReplyCurveElement"_s; + + return base; +} + } // namespace chatterino diff --git a/src/messages/MessageElement.hpp b/src/messages/MessageElement.hpp index beb4d2160fb..00f7046ec77 100644 --- a/src/messages/MessageElement.hpp +++ b/src/messages/MessageElement.hpp @@ -7,6 +7,7 @@ #include "providers/links/LinkInfo.hpp" #include "singletons/Fonts.hpp" +#include #include #include #include @@ -16,10 +17,13 @@ #include #include +class QJsonObject; + namespace chatterino { class Channel; struct MessageLayoutContainer; class MessageLayoutElement; +struct MessageLayoutContext; class Image; using ImagePtr = std::shared_ptr; @@ -181,7 +185,9 @@ class MessageElement void addFlags(MessageElementFlags flags); virtual void addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags) = 0; + const MessageLayoutContext &ctx) = 0; + + virtual QJsonObject toJson() const; virtual std::unique_ptr clone() const = 0; @@ -204,7 +210,9 @@ class ImageElement : public MessageElement ImageElement(ImagePtr image, MessageElementFlags flags); void addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags) override; + const MessageLayoutContext &ctx) override; + + QJsonObject toJson() const override; std::unique_ptr clone() const override; @@ -220,7 +228,9 @@ class CircularImageElement : public MessageElement MessageElementFlags flags); void addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags) override; + const MessageLayoutContext &ctx) override; + + QJsonObject toJson() const override; std::unique_ptr clone() const override; @@ -248,7 +258,9 @@ class TextElement : public MessageElement QStringList words() const; void addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags) override; + const MessageLayoutContext &ctx) override; + + QJsonObject toJson() const override; std::unique_ptr clone() const override; @@ -269,7 +281,9 @@ class SingleLineTextElement : public MessageElement ~SingleLineTextElement() override = default; void addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags) override; + const MessageLayoutContext &ctx) override; + + QJsonObject toJson() const override; std::unique_ptr clone() const override; @@ -305,7 +319,7 @@ class LinkElement : public TextElement LinkElement &operator=(LinkElement &&) = delete; void addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags) override; + const MessageLayoutContext &ctx) override; Link getLink() const override; @@ -316,6 +330,8 @@ class LinkElement : public TextElement std::unique_ptr clone() const override; + QJsonObject toJson() const override; + private: LinkInfo linkInfo_; // these are implicitly shared @@ -345,13 +361,15 @@ class MentionElement : public TextElement MentionElement &operator=(MentionElement &&) = delete; void addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags) override; + const MessageLayoutContext &ctx) override; std::unique_ptr clone() const override; MessageElement *setLink(const Link &link) override; Link getLink() const override; + QJsonObject toJson() const override; + private: MentionElement(QStringList &&words, MessageColor fallbackColor, MessageColor userColor); @@ -379,11 +397,13 @@ class EmoteElement : public MessageElement const MessageColor &textElementColor = MessageColor::Text); void addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags_) override; + const MessageLayoutContext &ctx) override; EmotePtr getEmote() const; std::unique_ptr clone() const override; + QJsonObject toJson() const override; + protected: virtual MessageLayoutElement *makeImageLayoutElement(const ImagePtr &image, const QSize &size); @@ -411,7 +431,7 @@ class LayeredEmoteElement : public MessageElement void addEmoteLayer(const Emote &emote); void addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags) override; + const MessageLayoutContext &ctx) override; // Returns a concatenation of each emote layer's cleaned copy string QString getCleanCopyString() const; @@ -422,6 +442,8 @@ class LayeredEmoteElement : public MessageElement std::unique_ptr clone() const override; + QJsonObject toJson() const override; + private: MessageLayoutElement *makeImageLayoutElement( const std::vector &image, const std::vector &sizes, @@ -444,12 +466,14 @@ class BadgeElement : public MessageElement BadgeElement(const EmotePtr &data, MessageElementFlags flags_); void addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags_) override; + const MessageLayoutContext &ctx) override; EmotePtr getEmote() const; std::unique_ptr clone() const override; + QJsonObject toJson() const override; + protected: virtual MessageLayoutElement *makeImageLayoutElement(const ImagePtr &image, const QSize &size); @@ -463,6 +487,8 @@ class ModBadgeElement : public BadgeElement std::unique_ptr clone() const override; + QJsonObject toJson() const override; + protected: MessageLayoutElement *makeImageLayoutElement(const ImagePtr &image, const QSize &size) override; @@ -475,6 +501,8 @@ class VipBadgeElement : public BadgeElement std::unique_ptr clone() const override; + QJsonObject toJson() const override; + protected: MessageLayoutElement *makeImageLayoutElement(const ImagePtr &image, const QSize &size) override; @@ -488,6 +516,8 @@ class FfzBadgeElement : public BadgeElement std::unique_ptr clone() const override; + QJsonObject toJson() const override; + protected: MessageLayoutElement *makeImageLayoutElement(const ImagePtr &image, const QSize &size) override; @@ -498,16 +528,19 @@ class FfzBadgeElement : public BadgeElement class TimestampElement : public MessageElement { public: - TimestampElement(QTime time_ = QTime::currentTime()); + TimestampElement(); + TimestampElement(QTime time_); ~TimestampElement() override = default; void addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags) override; + const MessageLayoutContext &ctx) override; TextElement *formatTime(const QTime &time); std::unique_ptr clone() const override; + QJsonObject toJson() const override; + private: QTime time_; std::unique_ptr element_; @@ -522,9 +555,11 @@ class TwitchModerationElement : public MessageElement TwitchModerationElement(); void addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags) override; + const MessageLayoutContext &ctx) override; std::unique_ptr clone() const override; + + QJsonObject toJson() const override; }; // Forces a linebreak @@ -534,9 +569,11 @@ class LinebreakElement : public MessageElement LinebreakElement(MessageElementFlags flags); void addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags) override; + const MessageLayoutContext &ctx) override; std::unique_ptr clone() const override; + + QJsonObject toJson() const override; }; // Image element which will pick the quality of the image based on ui scale @@ -546,7 +583,9 @@ class ScalingImageElement : public MessageElement ScalingImageElement(ImageSet images, MessageElementFlags flags); void addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags) override; + const MessageLayoutContext &ctx) override; + + QJsonObject toJson() const override; std::unique_ptr clone() const override; @@ -560,9 +599,16 @@ class ReplyCurveElement : public MessageElement ReplyCurveElement(); void addToContainer(MessageLayoutContainer &container, - MessageElementFlags flags) override; + const MessageLayoutContext &ctx) override; std::unique_ptr clone() const override; + + QJsonObject toJson() const override; }; } // namespace chatterino + +template <> +struct magic_enum::customize::enum_range { + static constexpr bool is_flags = true; // NOLINT(readability-identifier-*) +}; diff --git a/src/messages/MessageFlag.hpp b/src/messages/MessageFlag.hpp index 7648dadc793..60c213f036c 100644 --- a/src/messages/MessageFlag.hpp +++ b/src/messages/MessageFlag.hpp @@ -50,6 +50,8 @@ enum class MessageFlag : std::int64_t { MonitoredMessage = (1LL << 35), /// The message is an ACTION message (/me) Action = (1LL << 36), + /// The message is sent in a different source channel as part of a Shared Chat session + SharedMessage = (1LL << 37), }; using MessageFlags = FlagsEnum; diff --git a/src/messages/MessageThread.cpp b/src/messages/MessageThread.cpp index e1227ab09e0..c59a3a26a03 100644 --- a/src/messages/MessageThread.cpp +++ b/src/messages/MessageThread.cpp @@ -1,12 +1,20 @@ #include "messages/MessageThread.hpp" +#include "common/Literals.hpp" #include "messages/Message.hpp" #include "util/DebugCount.hpp" +#include "util/QMagicEnum.hpp" + +#include +#include +#include #include namespace chatterino { +using namespace literals; + MessageThread::MessageThread(std::shared_ptr rootMessage) : rootMessageId_(rootMessage->id) , rootMessage_(std::move(rootMessage)) @@ -80,4 +88,29 @@ void MessageThread::markUnsubscribed() this->subscriptionUpdated(); } +QJsonObject MessageThread::toJson() const +{ + QJsonObject obj{ + {"rootId"_L1, this->rootMessageId_}, + {"subscription"_L1, qmagicenum::enumNameString(this->subscription_)}, + }; + + QJsonArray replies; + for (const auto &msg : this->replies_) + { + auto locked = msg.lock(); + if (locked) + { + replies.append(locked->id); + } + else + { + replies.append(QJsonValue::Null); + } + } + obj["replies"_L1] = replies; + + return obj; +} + } // namespace chatterino diff --git a/src/messages/MessageThread.hpp b/src/messages/MessageThread.hpp index 442db46a67d..56b088c0f60 100644 --- a/src/messages/MessageThread.hpp +++ b/src/messages/MessageThread.hpp @@ -6,6 +6,8 @@ #include #include +class QJsonObject; + namespace chatterino { struct Message; @@ -62,6 +64,8 @@ class MessageThread return replies_; } + QJsonObject toJson() const; + boost::signals2::signal subscriptionUpdated; private: diff --git a/src/messages/layouts/MessageLayout.cpp b/src/messages/layouts/MessageLayout.cpp index e1a3d8a3c48..81c75c6b471 100644 --- a/src/messages/layouts/MessageLayout.cpp +++ b/src/messages/layouts/MessageLayout.cpp @@ -68,8 +68,7 @@ int MessageLayout::getWidth() const // Layout // return true if redraw is required -bool MessageLayout::layout(int width, float scale, float imageScale, - MessageElementFlags flags, +bool MessageLayout::layout(const MessageLayoutContext &ctx, bool shouldInvalidateBuffer) { // BenchmarkGuard benchmark("MessageLayout::layout()"); @@ -77,9 +76,9 @@ bool MessageLayout::layout(int width, float scale, float imageScale, bool layoutRequired = false; // check if width changed - bool widthChanged = width != this->currentLayoutWidth_; + bool widthChanged = ctx.width != this->currentLayoutWidth_; layoutRequired |= widthChanged; - this->currentLayoutWidth_ = width; + this->currentLayoutWidth_ = ctx.width; // check if layout state changed const auto layoutGeneration = getApp()->getWindows()->getGeneration(); @@ -91,18 +90,18 @@ bool MessageLayout::layout(int width, float scale, float imageScale, } // check if work mask changed - layoutRequired |= this->currentWordFlags_ != flags; - this->currentWordFlags_ = flags; // getSettings()->getWordTypeMask(); + layoutRequired |= this->currentWordFlags_ != ctx.flags; + this->currentWordFlags_ = ctx.flags; // getSettings()->getWordTypeMask(); // check if layout was requested manually layoutRequired |= this->flags.has(MessageLayoutFlag::RequiresLayout); this->flags.unset(MessageLayoutFlag::RequiresLayout); // check if dpi changed - layoutRequired |= this->scale_ != scale; - this->scale_ = scale; - layoutRequired |= this->imageScale_ != imageScale; - this->imageScale_ = imageScale; + layoutRequired |= this->scale_ != ctx.scale; + this->scale_ = ctx.scale; + layoutRequired |= this->imageScale_ != ctx.imageScale; + this->imageScale_ = ctx.imageScale; if (!layoutRequired) { @@ -115,7 +114,7 @@ bool MessageLayout::layout(int width, float scale, float imageScale, } int oldHeight = this->container_.getHeight(); - this->actuallyLayout(width, flags); + this->actuallyLayout(ctx); if (widthChanged || this->container_.getHeight() != oldHeight) { this->deleteBuffer(); @@ -125,7 +124,7 @@ bool MessageLayout::layout(int width, float scale, float imageScale, return true; } -void MessageLayout::actuallyLayout(int width, MessageElementFlags flags) +void MessageLayout::actuallyLayout(const MessageLayoutContext &ctx) { #ifdef FOURTF this->layoutCount_++; @@ -134,7 +133,7 @@ void MessageLayout::actuallyLayout(int width, MessageElementFlags flags) auto messageFlags = this->message_->flags; if (this->flags.has(MessageLayoutFlag::Expanded) || - (flags.has(MessageElementFlag::ModeratorTools) && + (ctx.flags.has(MessageElementFlag::ModeratorTools) && !this->message_->flags.has(MessageFlag::Disabled))) { messageFlags.unset(MessageFlag::Collapsed); @@ -143,9 +142,9 @@ void MessageLayout::actuallyLayout(int width, MessageElementFlags flags) bool hideModerated = getSettings()->hideModerated; bool hideModerationActions = getSettings()->hideModerationActions; bool hideSimilar = getSettings()->hideSimilar; - bool hideReplies = !flags.has(MessageElementFlag::RepliedMessage); + bool hideReplies = !ctx.flags.has(MessageElementFlag::RepliedMessage); - this->container_.beginLayout(width, this->scale_, this->imageScale_, + this->container_.beginLayout(ctx.width, this->scale_, this->imageScale_, messageFlags); for (const auto &element : this->message_->elements) @@ -177,7 +176,7 @@ void MessageLayout::actuallyLayout(int width, MessageElementFlags flags) continue; } - element->addToContainer(this->container_, flags); + element->addToContainer(this->container_, ctx); } if (this->height_ != this->container_.getHeight()) @@ -201,10 +200,15 @@ MessagePaintResult MessageLayout::paint(const MessagePaintContext &ctx) { MessagePaintResult result; - QPixmap *pixmap = this->ensureBuffer(ctx.painter, ctx.canvasWidth); + QPixmap *pixmap = this->ensureBuffer(ctx.painter, ctx.canvasWidth, + ctx.messageColors.hasTransparency); if (!this->bufferValid_) { + if (ctx.messageColors.hasTransparency) + { + pixmap->fill(Qt::transparent); + } this->updateBuffer(pixmap, ctx); } @@ -278,7 +282,7 @@ MessagePaintResult MessageLayout::paint(const MessagePaintContext &ctx) return result; } -QPixmap *MessageLayout::ensureBuffer(QPainter &painter, int width) +QPixmap *MessageLayout::ensureBuffer(QPainter &painter, int width, bool clear) { if (this->buffer_ != nullptr) { @@ -292,6 +296,11 @@ QPixmap *MessageLayout::ensureBuffer(QPainter &painter, int width) painter.device()->devicePixelRatioF())); this->buffer_->setDevicePixelRatio(painter.device()->devicePixelRatioF()); + if (clear) + { + this->buffer_->fill(Qt::transparent); + } + this->bufferValid_ = false; DebugCount::increase("message drawing buffers"); return this->buffer_.get(); @@ -313,10 +322,10 @@ void MessageLayout::updateBuffer(QPixmap *buffer, if (ctx.preferences.alternateMessages && this->flags.has(MessageLayoutFlag::AlternateBackground)) { - return ctx.messageColors.alternate; + return ctx.messageColors.alternateBg; } - return ctx.messageColors.regular; + return ctx.messageColors.regularBg; }(); if (this->message_->flags.has(MessageFlag::ElevatedMessage) && diff --git a/src/messages/layouts/MessageLayout.hpp b/src/messages/layouts/MessageLayout.hpp index 12a8b153a76..841fd328d9f 100644 --- a/src/messages/layouts/MessageLayout.hpp +++ b/src/messages/layouts/MessageLayout.hpp @@ -18,6 +18,7 @@ struct Selection; struct MessageLayoutContainer; class MessageLayoutElement; struct MessagePaintContext; +struct MessageLayoutContext; enum class MessageElementFlag : int64_t; using MessageElementFlags = FlagsEnum; @@ -56,8 +57,7 @@ class MessageLayout MessageLayoutFlags flags; - bool layout(int width, float scale_, float imageScale, - MessageElementFlags flags, bool shouldInvalidateBuffer); + bool layout(const MessageLayoutContext &ctx, bool shouldInvalidateBuffer); // Painting MessagePaintResult paint(const MessagePaintContext &ctx); @@ -112,11 +112,11 @@ class MessageLayout private: // methods - void actuallyLayout(int width, MessageElementFlags flags); + void actuallyLayout(const MessageLayoutContext &ctx); void updateBuffer(QPixmap *buffer, const MessagePaintContext &ctx); // Create new buffer if required, returning the buffer - QPixmap *ensureBuffer(QPainter &painter, int width); + QPixmap *ensureBuffer(QPainter &painter, int width, bool clear); // variables const MessagePtr message_; diff --git a/src/messages/layouts/MessageLayoutContext.cpp b/src/messages/layouts/MessageLayoutContext.cpp index 98c963919d5..f754ea408f3 100644 --- a/src/messages/layouts/MessageLayoutContext.cpp +++ b/src/messages/layouts/MessageLayoutContext.cpp @@ -3,21 +3,44 @@ #include "singletons/Settings.hpp" #include "singletons/Theme.hpp" +#include + namespace chatterino { -void MessageColors::applyTheme(Theme *theme) +void MessageColors::applyTheme(Theme *theme, bool isOverlay, + int backgroundOpacity) { - this->regular = theme->messages.backgrounds.regular; - this->alternate = theme->messages.backgrounds.alternate; - - this->disabled = theme->messages.disabled; - this->selection = theme->messages.selection; - this->system = theme->messages.textColors.system; + auto applyColors = [this](const auto &src) { + this->regularBg = src.backgrounds.regular; + this->alternateBg = src.backgrounds.alternate; + + this->disabled = src.disabled; + this->selection = src.selection; + + this->regularText = src.textColors.regular; + this->linkText = src.textColors.link; + this->systemText = src.textColors.system; + }; + + if (isOverlay) + { + this->channelBackground = theme->overlayMessages.background; + this->channelBackground.setAlpha(std::clamp(backgroundOpacity, 0, 255)); + applyColors(theme->overlayMessages); + } + else + { + this->channelBackground = theme->splits.background; + applyColors(theme->messages); + } this->messageSeperator = theme->splits.messageSeperator; this->focusedLastMessageLine = theme->tabs.selected.backgrounds.regular; this->unfocusedLastMessageLine = theme->tabs.selected.backgrounds.unfocused; + + this->hasTransparency = + this->regularBg.alpha() != 255 || this->alternateBg.alpha() != 255; } void MessagePreferences::connectSettings(Settings *settings, diff --git a/src/messages/layouts/MessageLayoutContext.hpp b/src/messages/layouts/MessageLayoutContext.hpp index d8f08ab3abf..b7a9b6886cf 100644 --- a/src/messages/layouts/MessageLayoutContext.hpp +++ b/src/messages/layouts/MessageLayoutContext.hpp @@ -1,5 +1,7 @@ #pragma once +#include "messages/MessageElement.hpp" + #include #include @@ -16,18 +18,27 @@ struct Selection; // TODO: Figure out if this could be a subset of Theme instead (e.g. Theme::MessageColors) struct MessageColors { - QColor regular; - QColor alternate; + QColor channelBackground; + + // true if any of the background colors have transparency + bool hasTransparency = false; + + QColor regularBg; + QColor alternateBg; + QColor disabled; QColor selection; - QColor system; + + QColor regularText; + QColor linkText; + QColor systemText; QColor messageSeperator; QColor focusedLastMessageLine; QColor unfocusedLastMessageLine; - void applyTheme(Theme *theme); + void applyTheme(Theme *theme, bool isOverlay, int backgroundOpacity); }; // TODO: Explore if we can let settings own this @@ -72,4 +83,13 @@ struct MessagePaintContext { bool isLastReadMessage{}; }; +struct MessageLayoutContext { + const MessageColors &messageColors; + MessageElementFlags flags; + + int width = 1; + float scale = 1; + float imageScale = 1; +}; + } // namespace chatterino diff --git a/src/messages/layouts/MessageLayoutElement.cpp b/src/messages/layouts/MessageLayoutElement.cpp index 98b6a6dd31a..7b1f7308c98 100644 --- a/src/messages/layouts/MessageLayoutElement.cpp +++ b/src/messages/layouts/MessageLayoutElement.cpp @@ -620,7 +620,7 @@ void TextIconLayoutElement::paint(QPainter &painter, QFont font = app->getFonts()->getFont(FontStyle::Tiny, this->scale); - painter.setPen(messageColors.system); + painter.setPen(messageColors.systemText); painter.setFont(font); QTextOption option; diff --git a/src/messages/search/MessageFlagsPredicate.cpp b/src/messages/search/MessageFlagsPredicate.cpp index d36fc72bb1d..82a7748adf1 100644 --- a/src/messages/search/MessageFlagsPredicate.cpp +++ b/src/messages/search/MessageFlagsPredicate.cpp @@ -58,6 +58,10 @@ MessageFlagsPredicate::MessageFlagsPredicate(const QString &flags, bool negate) { this->flags_.set(MessageFlag::MonitoredMessage); } + else if (flag == "shared") + { + this->flags_.set(MessageFlag::SharedMessage); + } } } diff --git a/src/providers/emoji/Emojis.cpp b/src/providers/emoji/Emojis.cpp index eefdd827e7d..562c7402613 100644 --- a/src/providers/emoji/Emojis.cpp +++ b/src/providers/emoji/Emojis.cpp @@ -4,6 +4,7 @@ #include "messages/Emote.hpp" #include "messages/Image.hpp" #include "singletons/Settings.hpp" +#include "util/QMagicEnum.hpp" #include "util/RapidjsonHelpers.hpp" #include @@ -62,19 +63,19 @@ void parseEmoji(const std::shared_ptr &emojiData, if (capabilities.apple) { - emojiData->capabilities.insert("Apple"); + emojiData->capabilities.set(EmojiData::Capability::Apple); } if (capabilities.google) { - emojiData->capabilities.insert("Google"); + emojiData->capabilities.set(EmojiData::Capability::Google); } if (capabilities.twitter) { - emojiData->capabilities.insert("Twitter"); + emojiData->capabilities.set(EmojiData::Capability::Twitter); } if (capabilities.facebook) { - emojiData->capabilities.insert("Facebook"); + emojiData->capabilities.set(EmojiData::Capability::Facebook); } QStringList unicodeCharacters = emojiData->unifiedCode.toLower().split('-'); @@ -244,6 +245,10 @@ void Emojis::sortEmojis() void Emojis::loadEmojiSet() { getSettings()->emojiSet.connect([this](const auto &emojiSet) { + EmojiData::Capability setCapability = + qmagicenum::enumCast(emojiSet).value_or( + EmojiData::Capability::Google); + for (const auto &emoji : this->emojis) { QString emojiSetToUse = emojiSet; @@ -271,9 +276,9 @@ void Emojis::loadEmojiSet() // clang-format on // As of emoji-data v15.1.1, google is the only source missing no images. - if (!emoji->capabilities.contains(emojiSetToUse)) + if (!emoji->capabilities.has(setCapability)) { - emojiSetToUse = "Google"; + emojiSetToUse = QStringLiteral("Google"); } QString code = emoji->unifiedCode.toLower(); diff --git a/src/providers/emoji/Emojis.hpp b/src/providers/emoji/Emojis.hpp index d6d783fc5a8..bbc5b747f89 100644 --- a/src/providers/emoji/Emojis.hpp +++ b/src/providers/emoji/Emojis.hpp @@ -1,12 +1,13 @@ #pragma once +#include "common/FlagsEnum.hpp" + #include #include #include #include #include -#include #include namespace chatterino { @@ -29,7 +30,15 @@ struct EmojiData { // i.e. thinking std::vector shortCodes; - std::set capabilities; + enum class Capability : uint8_t { + Apple = 1 << 0, + Google = 1 << 1, + Twitter = 1 << 2, + Facebook = 1 << 3, + }; + using Capabilities = FlagsEnum; + + Capabilities capabilities; std::vector variations; diff --git a/src/providers/recentmessages/Impl.cpp b/src/providers/recentmessages/Impl.cpp index 6971b55473b..410a34aac75 100644 --- a/src/providers/recentmessages/Impl.cpp +++ b/src/providers/recentmessages/Impl.cpp @@ -1,22 +1,13 @@ #include "providers/recentmessages/Impl.hpp" #include "common/Env.hpp" -#include "common/QLogging.hpp" #include "messages/MessageBuilder.hpp" #include "providers/twitch/IrcMessageHandler.hpp" -#include "providers/twitch/TwitchChannel.hpp" -#include "util/FormatTime.hpp" +#include "util/Helpers.hpp" #include #include -namespace { - -// NOLINTNEXTLINE(cppcoreguidelines-avoid-non-const-global-variables) -const auto &LOG = chatterinoRecentMessages; - -} // namespace - namespace chatterino::recentmessages::detail { // Parse the IRC messages returned in JSON form into Communi messages @@ -33,11 +24,7 @@ std::vector parseRecentMessages( for (const auto &jsonMessage : jsonMessages) { - auto content = jsonMessage.toString(); - - // For explanation of why this exists, see src/providers/twitch/TwitchChannel.hpp, - // where these constants are defined - content.replace(COMBINED_FIXER, ZERO_WIDTH_JOINER); + auto content = unescapeZeroWidthJoiner(jsonMessage.toString()); auto *message = Communi::IrcMessage::fromData(content.toUtf8(), nullptr); diff --git a/src/providers/twitch/ChannelPointReward.cpp b/src/providers/twitch/ChannelPointReward.cpp index 8849b8b62a0..0d4ab132b95 100644 --- a/src/providers/twitch/ChannelPointReward.cpp +++ b/src/providers/twitch/ChannelPointReward.cpp @@ -1,5 +1,6 @@ #include "providers/twitch/ChannelPointReward.hpp" +#include "common/Literals.hpp" #include "messages/Image.hpp" #include @@ -15,6 +16,8 @@ QString twitchChannelPointRewardUrl(const QString &file) namespace chatterino { +using namespace literals; + ChannelPointReward::ChannelPointReward(const QJsonObject &redemption) { auto reward = redemption.value("reward").toObject(); @@ -113,4 +116,25 @@ ChannelPointReward::ChannelPointReward(const QJsonObject &redemption) } } +QJsonObject ChannelPointReward::toJson() const +{ + return { + {"id"_L1, this->id}, + {"channelId"_L1, this->channelId}, + {"title"_L1, this->title}, + {"cost"_L1, this->cost}, + {"image"_L1, this->image.toJson()}, + {"isUserInputRequired"_L1, this->isUserInputRequired}, + {"isBits"_L1, this->isBits}, + {"emoteId"_L1, this->emoteId}, + {"emoteName"_L1, this->emoteName}, + {"user"_L1, + {{ + {"id"_L1, this->user.id}, + {"login"_L1, this->user.login}, + {"displayName"_L1, this->user.displayName}, + }}}, + }; +} + } // namespace chatterino diff --git a/src/providers/twitch/ChannelPointReward.hpp b/src/providers/twitch/ChannelPointReward.hpp index 6fdd985b6e7..9015adfc83a 100644 --- a/src/providers/twitch/ChannelPointReward.hpp +++ b/src/providers/twitch/ChannelPointReward.hpp @@ -24,6 +24,8 @@ struct ChannelPointReward { QString login; QString displayName; } user; + + QJsonObject toJson() const; }; } // namespace chatterino diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp index 0e4571cfff2..5dea75d211d 100644 --- a/src/providers/twitch/IrcMessageHandler.cpp +++ b/src/providers/twitch/IrcMessageHandler.cpp @@ -456,6 +456,27 @@ std::vector parseUserNoticeMessage(Channel *channel, auto parameters = message->parameters(); QString msgType = tags.value("msg-id").toString(); + bool mirrored = msgType == "sharedchatnotice"; + if (mirrored) + { + msgType = tags.value("source-msg-id").toString(); + } + else + { + auto rIt = tags.find("room-id"); + auto sIt = tags.find("source-room-id"); + if (rIt != tags.end() && sIt != tags.end()) + { + mirrored = rIt.value().toString() != sIt.value().toString(); + } + } + + if (mirrored && msgType != "announcement") + { + // avoid confusing broadcasters with user payments to other channels + return {}; + } + QString content; if (parameters.size() >= 2) { @@ -483,6 +504,10 @@ std::vector parseUserNoticeMessage(Channel *channel, MessageBuilder builder(channel, message, args, content, false); builder->flags.set(MessageFlag::Subscription); builder->flags.unset(MessageFlag::Highlighted); + if (mirrored) + { + builder->flags.set(MessageFlag::SharedMessage); + } builtMessages.emplace_back(builder.build()); } } @@ -546,6 +571,10 @@ std::vector parseUserNoticeMessage(Channel *channel, calculateMessageTime(message).time()); b->flags.set(MessageFlag::Subscription); + if (mirrored) + { + b->flags.set(MessageFlag::SharedMessage); + } auto newMessage = b.release(); builtMessages.emplace_back(newMessage); } @@ -702,15 +731,8 @@ void IrcMessageHandler::handlePrivMessage(Communi::IrcPrivateMessage *message, } } - // This is for compatibility with older Chatterino versions. Twitch didn't use - // to allow ZERO WIDTH JOINER unicode character, so Chatterino used ESCAPE_TAG - // instead. - // See https://github.com/Chatterino/chatterino2/issues/3384 and - // https://mm2pl.github.io/emoji_rfc.pdf for more details - this->addMessage( - message, chan, - message->content().replace(COMBINED_FIXER, ZERO_WIDTH_JOINER), - twitchServer, false, message->isAction()); + this->addMessage(message, chan, unescapeZeroWidthJoiner(message->content()), + twitchServer, false, message->isAction()); if (message->tags().contains(u"pinned-chat-paid-amount"_s)) { @@ -915,10 +937,9 @@ void IrcMessageHandler::handleWhisperMessage(Communi::IrcMessage *ircMessage) auto *c = getApp()->getTwitch()->getWhispersChannel().get(); - MessageBuilder builder( - c, ircMessage, args, - ircMessage->parameter(1).replace(COMBINED_FIXER, ZERO_WIDTH_JOINER), - false); + MessageBuilder builder(c, ircMessage, args, + unescapeZeroWidthJoiner(ircMessage->parameter(1)), + false); if (builder.isIgnored()) { @@ -962,6 +983,27 @@ void IrcMessageHandler::handleUserNoticeMessage(Communi::IrcMessage *message, auto target = parameters[0]; QString msgType = tags.value("msg-id").toString(); + bool mirrored = msgType == "sharedchatnotice"; + if (mirrored) + { + msgType = tags.value("source-msg-id").toString(); + } + else + { + auto rIt = tags.find("room-id"); + auto sIt = tags.find("source-room-id"); + if (rIt != tags.end() && sIt != tags.end()) + { + mirrored = rIt.value().toString() != sIt.value().toString(); + } + } + + if (mirrored && msgType != "announcement") + { + // avoid confusing broadcasters with user payments to other channels + return; + } + QString content; if (parameters.size() >= 2) { @@ -1047,6 +1089,10 @@ void IrcMessageHandler::handleUserNoticeMessage(Communi::IrcMessage *message, calculateMessageTime(message).time()); b->flags.set(MessageFlag::Subscription); + if (mirrored) + { + b->flags.set(MessageFlag::SharedMessage); + } auto newMessage = b.release(); QString channelName; diff --git a/src/providers/twitch/TwitchAccount.cpp b/src/providers/twitch/TwitchAccount.cpp index 3fe84f7bb13..52b3fe815f6 100644 --- a/src/providers/twitch/TwitchAccount.cpp +++ b/src/providers/twitch/TwitchAccount.cpp @@ -406,7 +406,7 @@ void TwitchAccount::reloadEmotes(void *caller) auto meta = getTwitchEmoteSetMeta(emote); auto emotePtr = twitchEmotes->getOrCreateEmote(id, name); - if (!emoteMap->try_emplace(name, emotePtr).second) + if (!emoteMap->try_emplace(emotePtr->name, emotePtr).second) { // if the emote already exists, we don't want to add it to a set as // those are assumed to be disjoint diff --git a/src/providers/twitch/TwitchAccountManager.cpp b/src/providers/twitch/TwitchAccountManager.cpp index 3939bea1450..7f4ed78a038 100644 --- a/src/providers/twitch/TwitchAccountManager.cpp +++ b/src/providers/twitch/TwitchAccountManager.cpp @@ -1,5 +1,7 @@ #include "providers/twitch/TwitchAccountManager.hpp" +#include "Application.hpp" +#include "common/Args.hpp" #include "common/Common.hpp" #include "common/QLogging.hpp" #include "providers/twitch/api/Helix.hpp" @@ -144,6 +146,11 @@ void TwitchAccountManager::reloadUsers() void TwitchAccountManager::load() { + if (getApp()->getArgs().initialLogin.has_value()) + { + this->currentUsername = getApp()->getArgs().initialLogin.value(); + } + this->reloadUsers(); this->currentUsername.connect([this](const QString &newUsername) { diff --git a/src/providers/twitch/TwitchBadges.cpp b/src/providers/twitch/TwitchBadges.cpp index 6e2b4c4aad8..95e42af68f0 100644 --- a/src/providers/twitch/TwitchBadges.cpp +++ b/src/providers/twitch/TwitchBadges.cpp @@ -79,59 +79,59 @@ void TwitchBadges::loadTwitchBadges() break; } qCWarning(chatterinoTwitch) << errorMessage; - QFile file(":/twitch-badges.json"); - if (!file.open(QFile::ReadOnly)) - { - // Despite erroring out, we still want to reach the same point - // Loaded should still be set to true to not build up an endless queue, and the quuee should still be flushed. - qCWarning(chatterinoTwitch) - << "Error loading Twitch Badges from the local backup file"; - this->loaded(); - return; - } - auto bytes = file.readAll(); - auto doc = QJsonDocument::fromJson(bytes); - - this->parseTwitchBadges(doc.object()); - - this->loaded(); + this->loadLocalBadges(); }); } -void TwitchBadges::parseTwitchBadges(QJsonObject root) +void TwitchBadges::loadLocalBadges() { - auto badgeSets = this->badgeSets_.access(); + QFile file(":/twitch-badges.json"); + if (!file.open(QFile::ReadOnly)) + { + // Despite erroring out, we still want to reach the same point + // Loaded should still be set to true to not build up an endless queue, and the quuee should still be flushed. + qCWarning(chatterinoTwitch) + << "Error loading Twitch Badges from the local backup file"; + this->loaded(); + return; + } + auto bytes = file.readAll(); + auto doc = QJsonDocument::fromJson(bytes); - auto jsonSets = root.value("badge_sets").toObject(); - for (auto sIt = jsonSets.begin(); sIt != jsonSets.end(); ++sIt) { - auto key = sIt.key(); - auto versions = sIt.value().toObject().value("versions").toObject(); + const auto &root = doc.object(); + auto badgeSets = this->badgeSets_.access(); - for (auto vIt = versions.begin(); vIt != versions.end(); ++vIt) + for (auto setIt = root.begin(); setIt != root.end(); setIt++) { - auto versionObj = vIt.value().toObject(); - auto emote = Emote{ - .name = {""}, - .images = - ImageSet{ - Image::fromUrl( - {versionObj.value("image_url_1x").toString()}, 1, - BADGE_BASE_SIZE), - Image::fromUrl( - {versionObj.value("image_url_2x").toString()}, .5, - BADGE_BASE_SIZE * 2), - Image::fromUrl( - {versionObj.value("image_url_4x").toString()}, .25, - BADGE_BASE_SIZE * 4), - }, - .tooltip = Tooltip{versionObj.value("title").toString()}, - .homePage = Url{versionObj.value("click_url").toString()}, - }; - - (*badgeSets)[key][vIt.key()] = std::make_shared(emote); + auto key = setIt.key(); + + for (auto versionValue : setIt.value().toArray()) + { + const auto versionObj = versionValue.toObject(); + auto id = versionObj["id"].toString(); + auto baseImage = versionObj["image"].toString(); + auto emote = Emote{ + .name = {}, + .images = + ImageSet{ + Image::fromUrl({baseImage + '1'}, 1, + BADGE_BASE_SIZE), + Image::fromUrl({baseImage + '2'}, .5, + BADGE_BASE_SIZE * 2), + Image::fromUrl({baseImage + '3'}, .25, + BADGE_BASE_SIZE * 4), + }, + .tooltip = Tooltip{versionObj["title"].toString()}, + .homePage = Url{versionObj["url"].toString()}, + }; + + (*badgeSets)[key][id] = std::make_shared(emote); + } } } + + this->loaded(); } void TwitchBadges::loaded() diff --git a/src/providers/twitch/TwitchBadges.hpp b/src/providers/twitch/TwitchBadges.hpp index fff0f5aff0b..b9519a90650 100644 --- a/src/providers/twitch/TwitchBadges.hpp +++ b/src/providers/twitch/TwitchBadges.hpp @@ -46,7 +46,9 @@ class TwitchBadges void loadTwitchBadges(); private: - void parseTwitchBadges(QJsonObject root); + /// Loads the badges shipped with Chatterino (twitch-badges.json) + void loadLocalBadges(); + void loaded(); void loadEmoteImage(const QString &name, const ImagePtr &image, BadgeIconCallback &&callback); diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index 0fe1c37622b..ee0581c7af3 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -1872,7 +1872,7 @@ std::optional TwitchChannel::ffzCustomVipBadge() const return this->ffzCustomVipBadge_.get(); } -std::optional TwitchChannel::cheerEmote(const QString &string) +std::optional TwitchChannel::cheerEmote(const QString &string) const { auto sets = this->cheerEmoteSets_.access(); for (const auto &set : *sets) diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index 667db2351c7..28f3dc1a857 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -27,24 +27,6 @@ namespace chatterino { -// This is for compatibility with older Chatterino versions. Twitch didn't use -// to allow ZERO WIDTH JOINER unicode character, so Chatterino used ESCAPE_TAG -// instead. -// See https://github.com/Chatterino/chatterino2/issues/3384 and -// https://mm2pl.github.io/emoji_rfc.pdf for more details -const QString ZERO_WIDTH_JOINER = QString(QChar(0x200D)); - -// Here be MSVC: Do NOT replace with "\U" literal, it will fail silently. -namespace { - const QChar ESCAPE_TAG_CHARS[2] = {QChar::highSurrogate(0xE0002), - QChar::lowSurrogate(0xE0002)}; -} -const QString ESCAPE_TAG = QString(ESCAPE_TAG_CHARS, 2); - -const static QRegularExpression COMBINED_FIXER( - QString("(? ffzChannelBadges(const QString &userID) const; // Cheers - std::optional cheerEmote(const QString &string); + std::optional cheerEmote(const QString &string) const; // Replies /** diff --git a/src/providers/twitch/TwitchCommon.cpp b/src/providers/twitch/TwitchCommon.cpp new file mode 100644 index 00000000000..91d09e6a739 --- /dev/null +++ b/src/providers/twitch/TwitchCommon.cpp @@ -0,0 +1,63 @@ +#include "providers/twitch/TwitchCommon.hpp" + +namespace chatterino { + +const std::vector TWITCH_USERNAME_COLORS = { + {255, 0, 0}, // Red + {0, 0, 255}, // Blue + {0, 255, 0}, // Green + {178, 34, 34}, // FireBrick + {255, 127, 80}, // Coral + {154, 205, 50}, // YellowGreen + {255, 69, 0}, // OrangeRed + {46, 139, 87}, // SeaGreen + {218, 165, 32}, // GoldenRod + {210, 105, 30}, // Chocolate + {95, 158, 160}, // CadetBlue + {30, 144, 255}, // DodgerBlue + {255, 105, 180}, // HotPink + {138, 43, 226}, // BlueViolet + {0, 255, 127}, // SpringGreen +}; + +const QStringList TWITCH_DEFAULT_COMMANDS{ + "help", + "w", + "me", + "disconnect", + "mods", + "vips", + "color", + "commercial", + "mod", + "unmod", + "vip", + "unvip", + "ban", + "unban", + "timeout", + "untimeout", + "slow", + "slowoff", + "r9kbeta", + "r9kbetaoff", + "emoteonly", + "emoteonlyoff", + "clear", + "subscribers", + "subscribersoff", + "followers", + "followersoff", + "host", + "unhost", + "raid", + "unraid", + "delete", + "announce", + "requests", + "warn", +}; + +const QStringList TWITCH_WHISPER_COMMANDS{"/w", ".w"}; + +} // namespace chatterino diff --git a/src/providers/twitch/TwitchCommon.hpp b/src/providers/twitch/TwitchCommon.hpp index a5c38824500..e0fffc66d07 100644 --- a/src/providers/twitch/TwitchCommon.hpp +++ b/src/providers/twitch/TwitchCommon.hpp @@ -7,79 +7,19 @@ namespace chatterino { -#ifndef ATTR_UNUSED -# ifdef Q_OS_WIN -# define ATTR_UNUSED -# else -# define ATTR_UNUSED __attribute__((unused)) -# endif -#endif +[[maybe_unused]] inline const char *const ANONYMOUS_USERNAME = "justinfan64537"; -static const char *ANONYMOUS_USERNAME ATTR_UNUSED = "justinfan64537"; - -static constexpr int TWITCH_MESSAGE_LIMIT = 500; +inline constexpr int TWITCH_MESSAGE_LIMIT = 500; inline QByteArray getDefaultClientID() { - return QByteArray("7ue61iz46fz11y3cugd0l3tawb4taal"); + return QByteArrayLiteral("7ue61iz46fz11y3cugd0l3tawb4taal"); } -static const std::vector TWITCH_USERNAME_COLORS = { - {255, 0, 0}, // Red - {0, 0, 255}, // Blue - {0, 255, 0}, // Green - {178, 34, 34}, // FireBrick - {255, 127, 80}, // Coral - {154, 205, 50}, // YellowGreen - {255, 69, 0}, // OrangeRed - {46, 139, 87}, // SeaGreen - {218, 165, 32}, // GoldenRod - {210, 105, 30}, // Chocolate - {95, 158, 160}, // CadetBlue - {30, 144, 255}, // DodgerBlue - {255, 105, 180}, // HotPink - {138, 43, 226}, // BlueViolet - {0, 255, 127}, // SpringGreen -}; +extern const std::vector TWITCH_USERNAME_COLORS; -static const QStringList TWITCH_DEFAULT_COMMANDS{ - "help", - "w", - "me", - "disconnect", - "mods", - "vips", - "color", - "commercial", - "mod", - "unmod", - "vip", - "unvip", - "ban", - "unban", - "timeout", - "untimeout", - "slow", - "slowoff", - "r9kbeta", - "r9kbetaoff", - "emoteonly", - "emoteonlyoff", - "clear", - "subscribers", - "subscribersoff", - "followers", - "followersoff", - "host", - "unhost", - "raid", - "unraid", - "delete", - "announce", - "requests", - "warn", -}; +extern const QStringList TWITCH_DEFAULT_COMMANDS; -static const QStringList TWITCH_WHISPER_COMMANDS{"/w", ".w"}; +extern const QStringList TWITCH_WHISPER_COMMANDS; } // namespace chatterino diff --git a/src/providers/twitch/TwitchEmotes.hpp b/src/providers/twitch/TwitchEmotes.hpp index 06fbbe95099..eac4077d652 100644 --- a/src/providers/twitch/TwitchEmotes.hpp +++ b/src/providers/twitch/TwitchEmotes.hpp @@ -18,7 +18,7 @@ namespace chatterino { // variant /// %1 <-> {id} /// %2 <-> {scale} (1.0, 2.0, 3.0) -constexpr QStringView TWITCH_EMOTE_TEMPLATE = +inline constexpr QStringView TWITCH_EMOTE_TEMPLATE = u"https://static-cdn.jtvnw.net/emoticons/v2/%1/default/dark/%2"; struct Emote; @@ -63,8 +63,8 @@ using TwitchEmoteSetMap = boost::unordered_flat_map; struct HelixChannelEmote; -constexpr QStringView TWITCH_SUB_EMOTE_SET_PREFIX = u"x-c2-s-"; -constexpr QStringView TWITCH_BIT_EMOTE_SET_PREFIX = u"x-c2-b-"; +inline constexpr QStringView TWITCH_SUB_EMOTE_SET_PREFIX = u"x-c2-s-"; +inline constexpr QStringView TWITCH_BIT_EMOTE_SET_PREFIX = u"x-c2-b-"; struct TwitchEmoteSetMeta { QString setID; diff --git a/src/providers/twitch/TwitchIrcServer.cpp b/src/providers/twitch/TwitchIrcServer.cpp index 6c3e6ae50e9..9ca93f6fd78 100644 --- a/src/providers/twitch/TwitchIrcServer.cpp +++ b/src/providers/twitch/TwitchIrcServer.cpp @@ -132,14 +132,14 @@ bool shouldSendHelixChat() { switch (getSettings()->chatSendProtocol) { + case ChatSendProtocol::Default: case ChatSendProtocol::Helix: return true; - case ChatSendProtocol::Default: case ChatSendProtocol::IRC: return false; default: assert(false && "Invalid chat protocol value"); - return false; + return true; } } @@ -580,8 +580,12 @@ void TwitchIrcServer::initialize() } }); } - // "ALLOWED" and "DENIED" statuses remain unimplemented - // They are versions of automod_message_(denied|approved) but for mods. + else + { + // Gray out approve/deny button upon "ALLOWED" and "DENIED" statuses + // They are versions of automod_message_(denied|approved) but for mods. + chan->deleteMessage("automod_" + msg.messageID); + } } break; @@ -629,7 +633,6 @@ void TwitchIrcServer::initialize() postToThread([chan, msg] { chan->addMessage(msg, MessageContext::Original); }); - chan->deleteMessage(msg->id); }); this->connections_.managedConnect( diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 6844a2dccbc..a756a0f47a2 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -9,6 +9,7 @@ #include #include +#include namespace { @@ -3015,8 +3016,13 @@ void Helix::sendChatMessage( } const auto obj = result.parseJson(); - auto message = - obj["message"].toString(u"Twitch internal server error"_s); + auto message = obj["message"].toString(); + + if (message.isEmpty()) + { + message = u"Twitch internal server error (" % + result.formatError() % ')'; + } switch (*result.status()) { diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index bcc0b83400c..87ac4cb77e5 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -184,6 +184,18 @@ class Settings // BoolSetting useCustomWindowFrame = {"/appearance/useCustomWindowFrame", // false}; + IntSetting overlayBackgroundOpacity = { + "/appearance/overlay/backgroundOpacity", 50}; + BoolSetting enableOverlayShadow = {"/appearance/overlay/shadow", true}; + IntSetting overlayShadowOpacity = {"/appearance/overlay/shadowOpacity", + 255}; + QStringSetting overlayShadowColor = {"/appearance/overlay/shadowColor", + "#000"}; + // These should be floats, but there's no good input UI for them + IntSetting overlayShadowOffsetX = {"/appearance/overlay/shadowOffsetX", 2}; + IntSetting overlayShadowOffsetY = {"/appearance/overlay/shadowOffsetY", 2}; + IntSetting overlayShadowRadius = {"/appearance/overlay/shadowRadius", 8}; + // Badges BoolSetting showBadgesGlobalAuthority = { "/appearance/badges/GlobalAuthority", true}; @@ -534,6 +546,7 @@ class Settings IntSetting startUpNotification = {"/misc/startUpNotification", 0}; QStringSetting currentVersion = {"/misc/currentVersion", ""}; + IntSetting overlayKnowledgeLevel = {"/misc/overlayKnowledgeLevel", 0}; BoolSetting loadTwitchMessageHistoryOnConnect = { "/misc/twitch/loadMessageHistoryOnConnect", true}; diff --git a/src/singletons/StreamerMode.cpp b/src/singletons/StreamerMode.cpp index 67ca2d348b5..ca63316a119 100644 --- a/src/singletons/StreamerMode.cpp +++ b/src/singletons/StreamerMode.cpp @@ -281,24 +281,27 @@ void StreamerModePrivate::settingChanged(StreamerModeSetting value) } this->currentSetting_ = value; + // in all cases: timer_ must be invoked from the correct thread switch (this->currentSetting_) { case StreamerModeSetting::Disabled: { this->setEnabled(false); - this->timer_->stop(); + QMetaObject::invokeMethod(this->timer_, &QTimer::stop); } break; case StreamerModeSetting::Enabled: { this->setEnabled(true); - this->timer_->stop(); + QMetaObject::invokeMethod(this->timer_, &QTimer::stop); } break; case StreamerModeSetting::DetectStreamingSoftware: { - if (!this->timer_->isActive()) - { - this->timer_->start(20s); - this->check(); - } + QMetaObject::invokeMethod(this->timer_, [this] { + if (!this->timer_->isActive()) + { + this->timer_->start(20s); + this->check(); + } + }); } break; default: diff --git a/src/singletons/Theme.cpp b/src/singletons/Theme.cpp index 3680e9d949b..0c29d429ca2 100644 --- a/src/singletons/Theme.cpp +++ b/src/singletons/Theme.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0) @@ -118,33 +119,56 @@ void parseTabs(const QJsonObject &tabs, const QJsonObject &tabsFallback, tabsFallback["selected"_L1].toObject(), theme.tabs.selected); } +void parseTextColors(const QJsonObject &textColors, + const QJsonObject &textColorsFallback, auto &messages) +{ + parseColor(messages, textColors, regular); + parseColor(messages, textColors, caret); + parseColor(messages, textColors, link); + parseColor(messages, textColors, system); + parseColor(messages, textColors, chatPlaceholder); +} + +void parseMessageBackgrounds(const QJsonObject &backgrounds, + const QJsonObject &backgroundsFallback, + auto &messages) +{ + parseColor(messages, backgrounds, regular); + parseColor(messages, backgrounds, alternate); +} + void parseMessages(const QJsonObject &messages, const QJsonObject &messagesFallback, chatterino::Theme &theme) { - { - const auto textColors = messages["textColors"_L1].toObject(); - const auto textColorsFallback = - messagesFallback["textColors"_L1].toObject(); - parseColor(theme.messages, textColors, regular); - parseColor(theme.messages, textColors, caret); - parseColor(theme.messages, textColors, link); - parseColor(theme.messages, textColors, system); - parseColor(theme.messages, textColors, chatPlaceholder); - } - { - const auto backgrounds = messages["backgrounds"_L1].toObject(); - const auto backgroundsFallback = - messagesFallback["backgrounds"_L1].toObject(); - parseColor(theme.messages, backgrounds, regular); - parseColor(theme.messages, backgrounds, alternate); - } + parseTextColors(messages["textColors"_L1].toObject(), + messagesFallback["textColors"_L1].toObject(), + theme.messages); + parseMessageBackgrounds(messages["backgrounds"_L1].toObject(), + messagesFallback["backgrounds"_L1].toObject(), + theme.messages); parseColor(theme, messages, disabled); parseColor(theme, messages, selection); parseColor(theme, messages, highlightAnimationStart); parseColor(theme, messages, highlightAnimationEnd); } +void parseOverlayMessages(const QJsonObject &overlayMessages, + const QJsonObject &overlayMessagesFallback, + chatterino::Theme &theme) +{ + parseTextColors(overlayMessages["textColors"_L1].toObject(), + overlayMessagesFallback["textColors"_L1].toObject(), + theme.overlayMessages); + parseMessageBackgrounds( + overlayMessages["backgrounds"_L1].toObject(), + overlayMessagesFallback["backgrounds"_L1].toObject(), + theme.overlayMessages); + parseColor(theme, overlayMessages, disabled); + parseColor(theme, overlayMessages, selection); + parseColor(theme, overlayMessages, background); +} + void parseScrollbars(const QJsonObject &scrollbars, const QJsonObject &scrollbarsFallback, chatterino::Theme &theme) @@ -198,6 +222,9 @@ void parseColors(const QJsonObject &root, const QJsonObject &fallbackTheme, fallbackColors["tabs"_L1].toObject(), theme); parseMessages(colors["messages"_L1].toObject(), fallbackColors["messages"_L1].toObject(), theme); + parseOverlayMessages(colors["overlayMessages"_L1].toObject(), + fallbackColors["overlayMessages"_L1].toObject(), + theme); parseScrollbars(colors["scrollbars"_L1].toObject(), fallbackColors["scrollbars"_L1].toObject(), theme); parseSplits(colors["splits"_L1].toObject(), diff --git a/src/singletons/Theme.hpp b/src/singletons/Theme.hpp index cb811e2db24..ac9fa070c1d 100644 --- a/src/singletons/Theme.hpp +++ b/src/singletons/Theme.hpp @@ -62,6 +62,19 @@ class Theme final } line; }; + struct TextColors { + QColor regular; + QColor caret; + QColor link; + QColor system; + QColor chatPlaceholder; + }; + + struct MessageBackgrounds { + QColor regular; + QColor alternate; + }; + QColor accent{"#00aeef"}; /// WINDOW @@ -84,18 +97,8 @@ class Theme final /// MESSAGES struct { - struct { - QColor regular; - QColor caret; - QColor link; - QColor system; - QColor chatPlaceholder; - } textColors; - - struct { - QColor regular; - QColor alternate; - } backgrounds; + TextColors textColors; + MessageBackgrounds backgrounds; QColor disabled; QColor selection; @@ -104,6 +107,15 @@ class Theme final QColor highlightAnimationEnd; } messages; + struct { + TextColors textColors; + MessageBackgrounds backgrounds; + + QColor disabled; + QColor selection; + QColor background; + } overlayMessages; + /// SCROLLBAR struct { QColor background; diff --git a/src/singletons/WindowManager.cpp b/src/singletons/WindowManager.cpp index bf0192e73ac..f64eb47e77c 100644 --- a/src/singletons/WindowManager.cpp +++ b/src/singletons/WindowManager.cpp @@ -16,6 +16,7 @@ #include "widgets/FramelessEmbedWindow.hpp" #include "widgets/helper/NotebookTab.hpp" #include "widgets/Notebook.hpp" +#include "widgets/OverlayWindow.hpp" #include "widgets/splits/Split.hpp" #include "widgets/splits/SplitContainer.hpp" #include "widgets/Window.hpp" @@ -544,6 +545,37 @@ void WindowManager::queueSave() this->saveTimer->start(10s); } +void WindowManager::toggleAllOverlayInertia() +{ + // check if any window is not inert + bool anyNonInert = false; + for (auto *window : this->windows_) + { + if (anyNonInert) + { + break; + } + window->getNotebook().forEachSplit([&](auto *split) { + auto *overlay = split->overlayWindow(); + if (overlay) + { + anyNonInert = anyNonInert || !overlay->isInert(); + } + }); + } + + for (auto *window : this->windows_) + { + window->getNotebook().forEachSplit([&](auto *split) { + auto *overlay = split->overlayWindow(); + if (overlay) + { + overlay->setInert(anyNonInert); + } + }); + } +} + void WindowManager::encodeTab(SplitContainer *tab, bool isSelected, QJsonObject &obj) { diff --git a/src/singletons/WindowManager.hpp b/src/singletons/WindowManager.hpp index 7f0dcdee6b2..8e737147d27 100644 --- a/src/singletons/WindowManager.hpp +++ b/src/singletons/WindowManager.hpp @@ -128,6 +128,9 @@ class WindowManager final // again void queueSave(); + /// Toggles the inertia in all open overlay windows + void toggleAllOverlayInertia(); + /// Signals pajlada::Signals::NoArgSignal gifRepaintRequested; diff --git a/src/util/Helpers.cpp b/src/util/Helpers.cpp index 88ba17fe1c9..e32c1c7ff14 100644 --- a/src/util/Helpers.cpp +++ b/src/util/Helpers.cpp @@ -7,6 +7,18 @@ #include #include +namespace { + +const QString ZERO_WIDTH_JOINER = QStringLiteral("\u200D"); + +// Note: \U requires /utf-8 for MSVC +// See https://mm2pl.github.io/emoji_rfc.pdf +const QRegularExpression ESCAPE_TAG_REGEX( + QStringLiteral("(?> makeConditionedOptional(bool condition, return std::nullopt; } +/// @brief Unescapes zero width joiners (ZWJ; U+200D) from Twitch messages +/// +/// Older Chatterino versions escape ZWJ with an ESCAPE TAG (U+E0002), following +/// https://mm2pl.github.io/emoji_rfc.pdf. This function unescapes all tags with +/// a ZWJ. See also: https://github.com/Chatterino/chatterino2/issues/3384. +QString unescapeZeroWidthJoiner(QString escaped); + } // namespace chatterino diff --git a/src/util/SampleData.cpp b/src/util/SampleData.cpp index 7b12cd31cba..bc26fee0be9 100644 --- a/src/util/SampleData.cpp +++ b/src/util/SampleData.cpp @@ -57,6 +57,8 @@ const QStringList &getSampleCheerMessages() R"(@badge-info=;badges=bits/1;bits=1;color=#00FF7F;display-name=Baekjoon;emotes=;flags=;id=da47f91a-40d3-4209-ba1c-0219d8b8ecaf;mod=0;room-id=111448817;subscriber=0;tmi-sent-ts=1567440720363;turbo=0;user-id=73587716;user-type= :baekjoon!baekjoon@baekjoon.tmi.twitch.tv PRIVMSG #pajlada :Scoops1)", R"(@badge-info=;badges=bits/1;bits=10;color=#8A2BE2;display-name=EkimSky;emotes=;flags=;id=8adea5b4-7430-44ea-a666-5ebaceb69441;mod=0;room-id=111448817;subscriber=0;tmi-sent-ts=1567833047623;turbo=0;user-id=42132818;user-type= :ekimsky!ekimsky@ekimsky.tmi.twitch.tv PRIVMSG #pajlada :Hi Cheer10)", R"(@badge-info=;badges=bits-leader/2;bits=500;color=;display-name=godkiller76;emotes=;flags=;id=80e86bcc-d048-44f3-8073-9a1014568e0c;mod=0;room-id=111448817;subscriber=0;tmi-sent-ts=1567753685704;turbo=0;user-id=258838478;user-type= :godkiller76!godkiller76@godkiller76.tmi.twitch.tv PRIVMSG #pajlada :Party100 Party100 Party100 Party100 Party100)", + R"(@mod=0;flags=;badge-info=;source-badge-info=;color=#DAA520;user-id=612865661;subscriber=0;id=886028cc-9985-47b9-a273-8164c6d59a76;turbo=0;source-badges=staff/1,moderator/1,twitch-recap-2023/1;room-id=11148817;source-id=eefbae4a-d3a1-4307-8d15-fab0f03fd9b9;source-room-id=1025594235;emotes=;display-name=lahoooo;tmi-sent-ts=1727304317562;badges=staff/1,raging-wolf-helm/1;user-type=staff;bits=1 :lahoooo!lahoooo@lahoooo.tmi.twitch.tv PRIVMSG #pajlada Cheer1)", + R"(@id=7bf90f3f-75de-4e89-ab3d-2fdfefd6bfb1;source-id=590821fd-4a5c-4dd8-b27e-9cea4ffb8d87;source-badges=staff/1,moderator/1,bits-leader/3;user-id=612865661;badges=staff/1,raging-wolf-helm/1;emotes=;source-badge-info=;badge-info=;mod=0;source-room-id=1025594235;user-type=staff;color=#DAA520;tmi-sent-ts=1727375798676;display-name=lahoooo;bits=1;flags=;turbo=0;room-id=11148817;subscriber=0 :lahoooo!lahoooo@lahoooo.tmi.twitch.tv PRIVMSG #pajlada shared9Cheer1)", }; return list; } @@ -116,6 +118,12 @@ const QStringList &getSampleMiscMessages() // mod announcement R"(@badge-info=subscriber/47;badges=broadcaster/1,subscriber/3012,twitchconAmsterdam2020/1;color=#FF0000;display-name=Supinic;emotes=;flags=;id=8c26e1ab-b50c-4d9d-bc11-3fd57a941d90;login=supinic;mod=0;msg-id=announcement;msg-param-color=PRIMARY;room-id=31400525;subscriber=1;system-msg=;tmi-sent-ts=1648762219962;user-id=31400525;user-type= :tmi.twitch.tv USERNOTICE #supinic :mm test lol)", + // mod announcement from another channel + R"(@badge-info=;badges=staff/1,raging-wolf-helm/1;color=#DAA520;display-name=lahoooo;emotes=;flags=;id=01cd601f-bc3f-49d5-ab4b-136fa9d6ec22;login=lahoooo;mod=0;msg-id=sharedchatnotice;msg-param-color=PRIMARY;room-id=11148817;source-badge-info=;source-badges=staff/1,moderator/1,bits-leader/1;source-id=4083dadc-9f20-40f9-ba92-949ebf6bc294;source-msg-id=announcement;source-room-id=1025594235;subscriber=0;system-msg=;tmi-sent-ts=1726118378465;user-id=612865661;user-type=staff;vip=0 :tmi.twitch.tv USERNOTICE #pajlada :hi this is an announcement from 1)", + + // shared chat message + R"(@badge-info=;flags=;room-id=11148817;color=;client-nonce=0d1632f37b6baee51576859d5dbaf325;emotes=;subscriber=0;tmi-sent-ts=1727395701680;id=19ee1663-c14d-41cd-a4a2-30a4bb609c5a;turbo=1;badges=staff/1,turbo/1;source-badges=staff/1,moderator/1,bits-leader/1;source-badge-info=;display-name=creativewind;source-room-id=1025594235;source-id=b97eea45-f9dc-4f0c-8744-f8256c3ed950;user-type=staff;user-id=106940612;mod=0 :creativewind!creativewind@creativewind.tmi.twitch.tv PRIVMSG #pajlada :Guys can you please not share the chat. My mom bought me this new laptop and it gets really hot when the chat is being shared. Now my leg is starting to hurt because it is getting so hot. Please, if you don't want me to get burned, then dont share the chat.)", + // Hype Chat (Paid option for keeping a message in chat longer) // no level R"(@badge-info=subscriber/3;badges=subscriber/0,bits-charity/1;color=#0000FF;display-name=SnoopyTheBot;emotes=;first-msg=0;flags=;id=8779a9e5-cf1b-47b3-b9fe-67a5b1b605f6;mod=0;pinned-chat-paid-amount=500;pinned-chat-paid-canonical-amount=5;pinned-chat-paid-currency=USD;pinned-chat-paid-exponent=2;returning-chatter=0;room-id=36340781;subscriber=1;tmi-sent-ts=1664505974154;turbo=0;user-id=136881249;user-type= :snoopythebot!snoopythebot@snoopythebot.tmi.twitch.tv PRIVMSG #pajlada :-$5)", @@ -139,6 +147,7 @@ const QStringList &getSampleEmoteTestMessages() R"(@badge-info=;badges=moderator/1,partner/1;color=#5B99FF;display-name=StreamElements;emotes=86:30-39/822112:73-79;flags=22-27:S.5;id=03c3eec9-afd1-4858-a2e0-fccbf6ad8d1a;mod=1;room-id=11148817;subscriber=0;tmi-sent-ts=1588638345928;turbo=0;user-id=100135110;user-type=mod :streamelements!streamelements@streamelements.tmi.twitch.tv PRIVMSG #pajlada :╔ACTION A LOJA AINDA NÃO ESTÁ PRONTA BibleThump , AGUARDE... NOVIDADES EM BREVE FortOne╔)", R"(@badge-info=subscriber/20;badges=moderator/1,subscriber/12;color=#19E6E6;display-name=randers;emotes=25:39-43;flags=;id=3ea97f01-abb2-4acf-bdb8-f52e79cd0324;mod=1;room-id=11148817;subscriber=1;tmi-sent-ts=1588837097115;turbo=0;user-id=40286300;user-type=mod :randers!randers@randers.tmi.twitch.tv PRIVMSG #pajlada :Då kan du begära skadestånd och förtal Kappa)", R"(@badge-info=subscriber/34;badges=moderator/1,subscriber/24;color=#FF0000;display-name=테스트계정420;emotes=41:6-13,15-22;flags=;id=a3196c7e-be4c-4b49-9c5a-8b8302b50c2a;mod=1;room-id=11148817;subscriber=1;tmi-sent-ts=1590922213730;turbo=0;user-id=117166826;user-type=mod :testaccount_420!testaccount_420@testaccount_420.tmi.twitch.tv PRIVMSG #pajlada :-tags Kreygasm,Kreygasm (no space))", + R"(@client-nonce=9e118ff9b63fbbeea66520f37929685d;source-id=040d1673-826d-48d1-9728-badc945e4a5e;user-type=staff;user-id=612865661;turbo=0;id=3ece90ea-9aaa-4634-bf3a-0105185da505;tmi-sent-ts=1727304403389;color=#DAA520;source-room-id=1025594235;flags=;emotes=emotesv2_8811dd848a214cef8a77575476cc33f4:0-9;subscriber=0;mod=0;emote-only=1;badges=staff/1,raging-wolf-helm/1;room-id=11148817;display-name=lahoooo;badge-info=;source-badge-info=;source-badges=staff/1,moderator/1,bits-leader/3 :lahoooo!lahoooo@lahoooo.tmi.twitch.tv PRIVMSG #pajlada shared9Dog)", }; return list; } diff --git a/src/widgets/Notebook.cpp b/src/widgets/Notebook.cpp index db2bcac4948..2a01020fc8f 100644 --- a/src/widgets/Notebook.cpp +++ b/src/widgets/Notebook.cpp @@ -1633,4 +1633,20 @@ void SplitNotebook::select(QWidget *page, bool focusPage) this->Notebook::select(page, focusPage); } +void SplitNotebook::forEachSplit(const std::function &cb) +{ + for (const auto &item : this->items()) + { + auto *page = dynamic_cast(item.page); + if (!page) + { + continue; + } + for (auto *split : page->getSplits()) + { + cb(split); + } + } +} + } // namespace chatterino diff --git a/src/widgets/Notebook.hpp b/src/widgets/Notebook.hpp index e5cbd17d107..ac6c4dad79f 100644 --- a/src/widgets/Notebook.hpp +++ b/src/widgets/Notebook.hpp @@ -18,6 +18,7 @@ class UpdateDialog; class NotebookButton; class NotebookTab; class SplitContainer; +class Split; enum NotebookTabLocation { Top = 0, Left = 1, Right = 2, Bottom = 3 }; @@ -229,6 +230,8 @@ class SplitNotebook : public Notebook void addNotebookActionsToMenu(QMenu *menu) override; + void forEachSplit(const std::function &cb); + /** * Toggles between the "Show all tabs" and "Hide all tabs" tab visibility states */ diff --git a/src/widgets/OverlayWindow.cpp b/src/widgets/OverlayWindow.cpp new file mode 100644 index 00000000000..f81ebf0ca86 --- /dev/null +++ b/src/widgets/OverlayWindow.cpp @@ -0,0 +1,530 @@ +#include "widgets/OverlayWindow.hpp" + +#include "Application.hpp" +#include "common/FlagsEnum.hpp" +#include "common/Literals.hpp" +#include "controllers/hotkeys/HotkeyController.hpp" +#include "singletons/Settings.hpp" +#include "singletons/WindowManager.hpp" +#include "widgets/BaseWidget.hpp" +#include "widgets/helper/ChannelView.hpp" +#include "widgets/helper/InvisibleSizeGrip.hpp" +#include "widgets/Scrollbar.hpp" +#include "widgets/splits/Split.hpp" + +#include +#include +#include +#include +#include +#include + +#ifdef Q_OS_WIN +# include +# include + +// This definition can be used to test the move interaction for other platforms +// on Windows by commenting it out. In a final build, Windows must always use +// this, as it's much smoother. +# define OVERLAY_NATIVE_MOVE +#endif + +namespace { + +using namespace chatterino; +using namespace literals; + +/// Progress the user has made in exploring the overlay +enum class Knowledge : std::int32_t { // NOLINT(performance-enum-size) + None = 0, + // User opened the overlay at least once + Activation = 1 << 0, +}; + +bool hasKnowledge(Knowledge knowledge) +{ + FlagsEnum current(static_cast( + getSettings()->overlayKnowledgeLevel.getValue())); + return current.has(knowledge); +} + +void acquireKnowledge(Knowledge knowledge) +{ + FlagsEnum current(static_cast( + getSettings()->overlayKnowledgeLevel.getValue())); + current.set(knowledge); + getSettings()->overlayKnowledgeLevel = + static_cast>(current.value()); +} + +/// Returns [seq?, toggleAllOverlays] +std::pair toggleIntertiaShortcut() +{ + auto seq = getApp()->getHotkeys()->getDisplaySequence( + HotkeyCategory::Split, u"toggleOverlayInertia"_s, {{u"this"_s}}); + if (!seq.isEmpty()) + { + return {seq, false}; + } + seq = getApp()->getHotkeys()->getDisplaySequence( + HotkeyCategory::Split, u"toggleOverlayInertia"_s, {{u"thisOrAll"_s}}); + if (!seq.isEmpty()) + { + return {seq, false}; + } + return { + getApp()->getHotkeys()->getDisplaySequence(HotkeyCategory::Split, + u"toggleOverlayInertia"_s), + true, + }; +} + +} // namespace + +namespace chatterino { + +using namespace std::chrono_literals; + +OverlayWindow::OverlayWindow(IndirectChannel channel) + : QWidget(nullptr, + Qt::Window | Qt::FramelessWindowHint | Qt::WindowStaysOnTopHint) +#ifdef Q_OS_WIN + , sizeAllCursor_(::LoadCursor(nullptr, IDC_SIZEALL)) +#endif + , channel_(std::move(channel)) + , channelView_(nullptr) + , interaction_(this) +{ + this->setAttribute(Qt::WA_DeleteOnClose); + this->setWindowTitle(u"Chatterino - Overlay"_s); + + // QGridLayout is (ab)used to stack widgets and position them + auto *grid = new QGridLayout(this); + grid->addWidget(&this->channelView_, 0, 0); + this->interaction_.attach(grid); +#ifndef OVERLAY_NATIVE_MOVE + grid->addWidget(new InvisibleSizeGrip(this), 0, 0, + Qt::AlignBottom | Qt::AlignRight); +#endif + + // the interaction overlay currently captures all events + this->interaction_.installEventFilter(this); + + this->shortInteraction_.setInterval(750ms); + QObject::connect(&this->shortInteraction_, &QTimer::timeout, [this] { + this->endInteraction(); + }); + + this->channelView_.installEventFilter(this); + this->channelView_.setChannel(this->channel_.get()); + this->channelView_.setIsOverlay(true); // use overlay colors + this->channelView_.setAttribute(Qt::WA_TranslucentBackground); + this->holder_.managedConnect(this->channel_.getChannelChanged(), [this]() { + this->channelView_.setChannel(this->channel_.get()); + }); + this->channelView_.scrollbar()->setShowThumb(false); + + this->setAutoFillBackground(false); + this->resize(300, 500); + this->move(QCursor::pos() - this->rect().center()); + this->setContentsMargins(0, 0, 0, 0); + this->setAttribute(Qt::WA_TranslucentBackground); + + auto *settings = getSettings(); + settings->enableOverlayShadow.connect( + [this](bool value) { + if (value) + { + this->dropShadow_ = new QGraphicsDropShadowEffect; + this->channelView_.setGraphicsEffect(this->dropShadow_); + } + else + { + this->channelView_.setGraphicsEffect(nullptr); + this->dropShadow_ = nullptr; // deleted by setGraphicsEffect + } + this->applyTheme(); + }, + this->holder_); + settings->overlayBackgroundOpacity.connect( + [this] { + this->channelView_.updateColorTheme(); + this->update(); + }, + this->holder_, false); + + auto applyIt = [this](auto /*unused*/) { + this->applyTheme(); + }; + settings->overlayShadowOffsetX.connect(applyIt, this->holder_, false); + settings->overlayShadowOffsetY.connect(applyIt, this->holder_, false); + settings->overlayShadowOpacity.connect(applyIt, this->holder_, false); + settings->overlayShadowRadius.connect(applyIt, this->holder_, false); + settings->overlayShadowColor.connect(applyIt, this->holder_, false); + + this->addShortcuts(); + this->triggerFirstActivation(); +} + +OverlayWindow::~OverlayWindow() +{ +#ifdef Q_OS_WIN + ::DestroyCursor(this->sizeAllCursor_); +#endif +} + +void OverlayWindow::applyTheme() +{ + auto *settings = getSettings(); + + if (this->dropShadow_) + { + QColor shadowColor(settings->overlayShadowColor.getValue()); + shadowColor.setAlpha( + std::clamp(settings->overlayShadowOpacity.getValue(), 0, 255)); + this->dropShadow_->setColor(shadowColor); + this->dropShadow_->setOffset(settings->overlayShadowOffsetX, + settings->overlayShadowOffsetY); + this->dropShadow_->setBlurRadius(settings->overlayShadowRadius); + } + this->update(); +} + +bool OverlayWindow::eventFilter(QObject * /*object*/, QEvent *event) +{ +#ifndef OVERLAY_NATIVE_MOVE + switch (event->type()) + { + case QEvent::MouseButtonPress: { + auto *evt = dynamic_cast(event); + this->moving_ = true; + this->moveOrigin_ = evt->globalPos(); + return true; + } + break; + case QEvent::MouseButtonRelease: { + if (this->moving_) + { + this->moving_ = false; + return true; + } + return false; + } + break; + case QEvent::MouseMove: { + auto *evt = dynamic_cast(event); + if (this->moving_) + { + auto newPos = evt->globalPos() - this->moveOrigin_; + this->move(newPos + this->pos()); + this->moveOrigin_ = evt->globalPos(); + return true; + } + if (this->interaction_.isInteracting()) + { + this->setOverrideCursor(Qt::SizeAllCursor); + return true; + } + return false; + } + break; + default: + return false; + } +#else + (void)event; + return false; +#endif +} + +void OverlayWindow::setOverrideCursor(const QCursor &cursor) +{ + this->channelView_.setCursor(cursor); + this->setCursor(cursor); +} + +bool OverlayWindow::isInert() const +{ + return this->inert_; +} + +void OverlayWindow::toggleInertia() +{ + this->setInert(!this->inert_); +} + +void OverlayWindow::enterEvent(EnterEvent * /*event*/) +{ +#ifndef OVERLAY_NATIVE_MOVE + this->startInteraction(); +#endif +} + +void OverlayWindow::leaveEvent(QEvent * /*event*/) +{ +#ifndef OVERLAY_NATIVE_MOVE + this->endInteraction(); +#endif +} + +#ifdef Q_OS_WIN +bool OverlayWindow::nativeEvent(const QByteArray &eventType, void *message, + NativeResult *result) +{ + // NOLINTNEXTLINE(cppcoreguidelines-pro-type-reinterpret-cast) + MSG *msg = reinterpret_cast(message); + + bool returnValue = false; + + switch (msg->message) + { +# ifdef OVERLAY_NATIVE_MOVE + case WM_NCHITTEST: + this->handleNCHITTEST(msg, result); + returnValue = true; + break; + case WM_MOUSEMOVE: + case WM_NCMOUSEMOVE: + this->startShortInteraction(); + break; + case WM_ENTERSIZEMOVE: + this->startInteraction(); + break; + case WM_EXITSIZEMOVE: + // wait a few seconds before hiding + this->startShortInteraction(); + break; + case WM_SETCURSOR: { + // When the window can be moved, the size-all cursor should be + // shown. Qt doesn't provide an interface to do this, so this + // manually sets the cursor. + if (LOWORD(msg->lParam) == HTCAPTION) + { + ::SetCursor(this->sizeAllCursor_); + *result = TRUE; + returnValue = true; + } + } + break; +# endif + + default: + return QWidget::nativeEvent(eventType, message, result); + } + + QWidget::nativeEvent(eventType, message, result); + + return returnValue; +} + +void OverlayWindow::handleNCHITTEST(MSG *msg, NativeResult *result) +{ + // This implementation is similar to the one of BaseWindow, but has the + // following differences: + // - The window can always be resized (or: it can't be maximized) + // - The close button is advertised as HTCLIENT instead of HTCLOSE + // - There isn't any other client area (the entire window can be moved) + const LONG borderWidth = 8; // in device independent pixels + + auto rect = this->rect(); + + POINT p{GET_X_LPARAM(msg->lParam), GET_Y_LPARAM(msg->lParam)}; + ScreenToClient(msg->hwnd, &p); + + QPoint point(p.x, p.y); + point /= this->devicePixelRatio(); + + auto x = point.x(); + auto y = point.y(); + + *result = 0; + + // left border + if (x < rect.left() + borderWidth) + { + *result = HTLEFT; + } + // right border + if (x >= rect.right() - borderWidth) + { + *result = HTRIGHT; + } + + // bottom border + if (y >= rect.bottom() - borderWidth) + { + *result = HTBOTTOM; + } + // top border + if (y < rect.top() + borderWidth) + { + *result = HTTOP; + } + + // bottom left corner + if (x >= rect.left() && x < rect.left() + borderWidth && + y < rect.bottom() && y >= rect.bottom() - borderWidth) + { + *result = HTBOTTOMLEFT; + } + // bottom right corner + if (x < rect.right() && x >= rect.right() - borderWidth && + y < rect.bottom() && y >= rect.bottom() - borderWidth) + { + *result = HTBOTTOMRIGHT; + } + // top left corner + if (x >= rect.left() && x < rect.left() + borderWidth && y >= rect.top() && + y < rect.top() + borderWidth) + { + *result = HTTOPLEFT; + } + // top right corner + if (x < rect.right() && x >= rect.right() - borderWidth && + y >= rect.top() && y < rect.top() + borderWidth) + { + *result = HTTOPRIGHT; + } + + if (*result == 0) + { + auto *closeButton = this->interaction_.closeButton(); + if (closeButton->isVisible() && closeButton->geometry().contains(point)) + { + *result = HTCLIENT; + } + else + { + *result = HTCAPTION; + } + } +} +#endif + +void OverlayWindow::triggerFirstActivation() +{ + if (hasKnowledge(Knowledge::Activation)) + { + return; + } + acquireKnowledge(Knowledge::Activation); + + auto welcomeText = + u"Hey! It looks like this is the first time you're using the overlay. "_s + "You can move the overlay by dragging it with your mouse. " +#ifdef OVERLAY_NATIVE_MOVE + "To resize the window, drag on any edge." +#else + "To resize the window, drag on the bottom right corner." +#endif + "

" + "By default, the overlay is interactive. "; + + auto [actualShortcut, allOverlays] = toggleIntertiaShortcut(); + if (actualShortcut.isEmpty()) + { + welcomeText += + u"To toggle the click-through mode, " + "add a hotkey for \"Toggle overlay click-through\" in the split " + "category to press while any Chatterino window is focused."_s; + } + else + { + welcomeText += + u"To toggle the click-through mode, press %1 (customizable "_s + "in the settings) while any Chatterino window is focused.".arg( + actualShortcut.toString()); + } + + welcomeText += u"

"_s + "This is still an early version and some features are " + "missing. Please provide feedback on GitHub."; + + auto *box = + new QMessageBox(QMessageBox::Information, u"Chatterino - Overlay"_s, + welcomeText, QMessageBox::Ok, this); + box->open(); +} + +void OverlayWindow::addShortcuts() +{ + auto [seq, allOverlays] = toggleIntertiaShortcut(); + if (seq.isEmpty()) + { + return; + } + + auto *shortcut = new QShortcut(seq, this); + if (allOverlays) + { + QObject::connect(shortcut, &QShortcut::activated, this, [] { + getApp()->getWindows()->toggleAllOverlayInertia(); + }); + } + else + { + QObject::connect(shortcut, &QShortcut::activated, this, + &OverlayWindow::toggleInertia); + } +} + +void OverlayWindow::startInteraction() +{ + if (this->inert_) + { + return; + } + + this->interaction_.startInteraction(); + this->shortInteraction_.stop(); +} + +void OverlayWindow::startShortInteraction() +{ + if (this->inert_) + { + return; + } + + this->interaction_.startInteraction(); + this->shortInteraction_.start(); +} + +void OverlayWindow::endInteraction() +{ + this->interaction_.endInteraction(); +} + +void OverlayWindow::setInert(bool inert) +{ + if (this->inert_ == inert) + { + return; + } + + this->inert_ = inert; + + this->setWindowFlag(Qt::WindowTransparentForInput, inert); + if (this->isHidden()) + { + this->show(); + } + this->endInteraction(); + + if (inert) + { + if (this->channelView_.scrollbar()->isVisible()) + { + this->channelView_.scrollbar()->scrollToBottom(); + } + this->interaction_.hide(); + } + else + { + this->interaction_.show(); + } +} + +} // namespace chatterino diff --git a/src/widgets/OverlayWindow.hpp b/src/widgets/OverlayWindow.hpp new file mode 100644 index 00000000000..322dae468ab --- /dev/null +++ b/src/widgets/OverlayWindow.hpp @@ -0,0 +1,87 @@ +#pragma once + +#include "common/Channel.hpp" +#include "widgets/helper/ChannelView.hpp" +#include "widgets/helper/OverlayInteraction.hpp" + +#include +#include +#include +#include + +#ifdef Q_OS_WIN +# include +#endif + +class QGraphicsDropShadowEffect; + +namespace chatterino { + +class OverlayWindow : public QWidget +{ + Q_OBJECT +public: + OverlayWindow(IndirectChannel channel); + ~OverlayWindow() override; + OverlayWindow(const OverlayWindow &) = delete; + OverlayWindow(OverlayWindow &&) = delete; + OverlayWindow &operator=(const OverlayWindow &) = delete; + OverlayWindow &operator=(OverlayWindow &&) = delete; + + void setOverrideCursor(const QCursor &cursor); + + bool isInert() const; + void setInert(bool inert); + void toggleInertia(); + +protected: +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + using NativeResult = qintptr; + using EnterEvent = QEnterEvent; +#else + using NativeResult = long; + using EnterEvent = QEvent; +#endif + + bool eventFilter(QObject *object, QEvent *event) override; + void enterEvent(EnterEvent *event) override; + void leaveEvent(QEvent *event) override; + +#ifdef Q_OS_WIN + bool nativeEvent(const QByteArray &eventType, void *message, + NativeResult *result) override; +#endif + +private: + void triggerFirstActivation(); + + void addShortcuts(); + + void startInteraction(); + void startShortInteraction(); + void endInteraction(); + + void applyTheme(); + +#ifdef Q_OS_WIN + void handleNCHITTEST(MSG *msg, NativeResult *result); + + HCURSOR sizeAllCursor_; +#endif + + IndirectChannel channel_; + pajlada::Signals::SignalHolder holder_; + + ChannelView channelView_; + QGraphicsDropShadowEffect *dropShadow_; + + bool inert_ = false; + + bool moving_ = false; + QPoint moveOrigin_; + + OverlayInteraction interaction_; + QTimer shortInteraction_; +}; + +} // namespace chatterino diff --git a/src/widgets/Scrollbar.cpp b/src/widgets/Scrollbar.cpp index 827ea645bec..4aef450363a 100644 --- a/src/widgets/Scrollbar.cpp +++ b/src/widgets/Scrollbar.cpp @@ -285,18 +285,21 @@ void Scrollbar::paintEvent(QPaintEvent * /*event*/) bool enableElevatedMessageHighlights = getSettings()->enableElevatedMessageHighlight; - this->thumbRect_.setX(xOffset); - - // mouse over thumb - if (this->mouseDownLocation_ == MouseLocation::InsideThumb) + if (this->showThumb_) { - painter.fillRect(this->thumbRect_, - this->theme->scrollbars.thumbSelected); - } - // mouse not over thumb - else - { - painter.fillRect(this->thumbRect_, this->theme->scrollbars.thumb); + this->thumbRect_.setX(xOffset); + + // mouse over thumb + if (this->mouseDownLocation_ == MouseLocation::InsideThumb) + { + painter.fillRect(this->thumbRect_, + this->theme->scrollbars.thumbSelected); + } + // mouse not over thumb + else + { + painter.fillRect(this->thumbRect_, this->theme->scrollbars.thumb); + } } // draw highlights @@ -449,6 +452,17 @@ void Scrollbar::updateScroll() this->update(); } +void Scrollbar::setShowThumb(bool showThumb) +{ + if (this->showThumb_ == showThumb) + { + return; + } + + this->showThumb_ = showThumb; + this->update(); +} + Scrollbar::MouseLocation Scrollbar::locationOfMouseEvent( QMouseEvent *event) const { diff --git a/src/widgets/Scrollbar.hpp b/src/widgets/Scrollbar.hpp index 08a843586fa..65fad101101 100644 --- a/src/widgets/Scrollbar.hpp +++ b/src/widgets/Scrollbar.hpp @@ -127,6 +127,8 @@ class Scrollbar : public BaseWidget /// unaffected by simultaneous shifts of minimum and maximum. qreal getRelativeCurrentValue() const; + void setShowThumb(bool showthumb); + // offset the desired value without breaking smooth scolling void offset(qreal value); pajlada::Signals::NoArgSignal &getCurrentValueChanged(); @@ -169,6 +171,7 @@ class Scrollbar : public BaseWidget boost::circular_buffer highlights_; bool atBottom_{false}; + bool showThumb_ = true; MouseLocation mouseOverLocation_ = MouseLocation::Outside; MouseLocation mouseDownLocation_ = MouseLocation::Outside; diff --git a/src/widgets/dialogs/ChannelFilterEditorDialog.cpp b/src/widgets/dialogs/ChannelFilterEditorDialog.cpp index 85a778546ac..5d7d89afcba 100644 --- a/src/widgets/dialogs/ChannelFilterEditorDialog.cpp +++ b/src/widgets/dialogs/ChannelFilterEditorDialog.cpp @@ -100,7 +100,7 @@ ChannelFilterEditorDialog::ValueSpecifier::ValueSpecifier() this->typeCombo_->insertItems( 0, {"Constant Text", "Constant Number", "Variable"}); - this->varCombo_->insertItems(0, filters::validIdentifiersMap.values()); + this->varCombo_->insertItems(0, filters::VALID_IDENTIFIERS_MAP.values()); this->layout_->addWidget(this->typeCombo_); this->layout_->addWidget(this->varCombo_, 1); @@ -142,7 +142,7 @@ void ChannelFilterEditorDialog::ValueSpecifier::setValue(const QString &value) if (this->typeCombo_->currentIndex() == 2) { this->varCombo_->setCurrentText( - filters::validIdentifiersMap.value(value)); + filters::VALID_IDENTIFIERS_MAP.value(value)); } else { @@ -165,7 +165,7 @@ QString ChannelFilterEditorDialog::ValueSpecifier::expressionText() case 1: // number return this->valueInput_->text(); case 2: // variable - return filters::validIdentifiersMap.key( + return filters::VALID_IDENTIFIERS_MAP.key( this->varCombo_->currentText()); default: return ""; diff --git a/src/widgets/helper/ChannelView.cpp b/src/widgets/helper/ChannelView.cpp index ab0e632260c..066085030d0 100644 --- a/src/widgets/helper/ChannelView.cpp +++ b/src/widgets/helper/ChannelView.cpp @@ -256,40 +256,14 @@ void addHiddenContextMenuItems(QMenu *menu, }); } - const auto *message = layout->getMessage(); + auto message = layout->getMessagePtr(); - if (message != nullptr) + if (message) { - QJsonDocument jsonDocument; - - QJsonObject jsonObject; - - jsonObject["id"] = message->id; - jsonObject["searchText"] = message->searchText; - jsonObject["messageText"] = message->messageText; - jsonObject["flags"] = qmagicenum::enumFlagsName(message->flags.value()); - if (message->reward) - { - QJsonObject reward; - reward["id"] = message->reward->id; - reward["title"] = message->reward->title; - reward["cost"] = message->reward->cost; - reward["isUserInputRequired"] = - message->reward->isUserInputRequired; - jsonObject["reward"] = reward; - } - else - { - jsonObject["reward"] = QJsonValue(); - } - - jsonDocument.setObject(jsonObject); - - auto jsonString = - jsonDocument.toJson(QJsonDocument::JsonFormat::Indented); - - menu->addAction("Copy message &JSON", [jsonString] { - crossPlatformCopy(jsonString); + menu->addAction("Copy message &JSON", [message] { + auto jsonString = QJsonDocument{message->toJson()}.toJson( + QJsonDocument::Indented); + crossPlatformCopy(QString::fromUtf8(jsonString)); }); } } @@ -388,7 +362,8 @@ ChannelView::ChannelView(InternalCtor /*tag*/, QWidget *parent, Split *split, this->queueUpdate(); }); - this->messageColors_.applyTheme(getTheme()); + this->messageColors_.applyTheme(getTheme(), this->isOverlay_, + getSettings()->overlayBackgroundOpacity); this->messagePreferences_.connectSettings(getSettings(), this->signalHolder_); } @@ -476,6 +451,11 @@ void ChannelView::initializeSignals() }); } +Scrollbar *ChannelView::scrollbar() +{ + return this->scrollBar_; +} + bool ChannelView::pausable() const { return pausable_; @@ -600,7 +580,19 @@ void ChannelView::themeChangedEvent() this->setupHighlightAnimationColors(); this->queueLayout(); - this->messageColors_.applyTheme(getTheme()); + this->messageColors_.applyTheme(getTheme(), this->isOverlay_, + getSettings()->overlayBackgroundOpacity); +} + +void ChannelView::updateColorTheme() +{ + this->themeChangedEvent(); +} + +void ChannelView::setIsOverlay(bool isOverlay) +{ + this->isOverlay_ = isOverlay; + this->themeChangedEvent(); } void ChannelView::setupHighlightAnimationColors() @@ -706,9 +698,15 @@ void ChannelView::layoutVisibleMessages( const auto &message = messages[i]; redrawRequired |= message->layout( - layoutWidth, this->scale(), - this->scale() * static_cast(this->devicePixelRatio()), - flags, this->bufferInvalidationQueued_); + { + .messageColors = this->messageColors_, + .flags = flags, + .width = layoutWidth, + .scale = this->scale(), + .imageScale = this->scale() * + static_cast(this->devicePixelRatio()), + }, + this->bufferInvalidationQueued_); y += message->getHeight(); } @@ -743,8 +741,14 @@ void ChannelView::updateScrollbar( auto *message = messages[i].get(); message->layout( - layoutWidth, this->scale(), - this->scale() * static_cast(this->devicePixelRatio()), flags, + { + .messageColors = this->messageColors_, + .flags = flags, + .width = layoutWidth, + .scale = this->scale(), + .imageScale = this->scale() * + static_cast(this->devicePixelRatio()), + }, false); h -= message->getHeight(); @@ -1512,7 +1516,7 @@ void ChannelView::paintEvent(QPaintEvent *event) QPainter painter(this); - painter.fillRect(rect(), this->theme->splits.background); + painter.fillRect(rect(), this->messageColors_.channelBackground); // draw messages this->drawMessages(painter, event->rect()); @@ -1731,10 +1735,16 @@ void ChannelView::wheelEvent(QWheelEvent *event) else { snapshot[i - 1]->layout( - this->getLayoutWidth(), this->scale(), - this->scale() * - static_cast(this->devicePixelRatio()), - this->getFlags(), false); + { + .messageColors = this->messageColors_, + .flags = this->getFlags(), + .width = this->getLayoutWidth(), + .scale = this->scale(), + .imageScale = + this->scale() * + static_cast(this->devicePixelRatio()), + }, + false); scrollFactor = 1; currentScrollLeft = snapshot[i - 1]->getHeight(); } @@ -1768,10 +1778,16 @@ void ChannelView::wheelEvent(QWheelEvent *event) else { snapshot[i + 1]->layout( - this->getLayoutWidth(), this->scale(), - this->scale() * - static_cast(this->devicePixelRatio()), - this->getFlags(), false); + { + .messageColors = this->messageColors_, + .flags = this->getFlags(), + .width = this->getLayoutWidth(), + .scale = this->scale(), + .imageScale = + this->scale() * + static_cast(this->devicePixelRatio()), + }, + false); scrollFactor = 1; currentScrollLeft = snapshot[i + 1]->getHeight(); @@ -2733,6 +2749,10 @@ void ChannelView::mouseDoubleClickEvent(QMouseEvent *event) if (hoverLayoutElement == nullptr) { + // XXX: this is duplicate work + auto idx = layout->getSelectionIndex(relativePos); + SelectionItem item(messageIndex, idx); + this->doubleClickSelection_ = {item, item}; return; } diff --git a/src/widgets/helper/ChannelView.hpp b/src/widgets/helper/ChannelView.hpp index 64683aca68c..7042107125a 100644 --- a/src/widgets/helper/ChannelView.hpp +++ b/src/widgets/helper/ChannelView.hpp @@ -202,6 +202,16 @@ class ChannelView final : public BaseWidget */ bool mayContainMessage(const MessagePtr &message); + void updateColorTheme(); + + /// @brief Adjusts the colors this view uses + /// + /// If @a isOverlay is true, the overlay colors (as specified in the theme) + /// will be used. Otherwise, regular message-colors will be used. + void setIsOverlay(bool isOverlay); + + Scrollbar *scrollbar(); + pajlada::Signals::Signal mouseDown; pajlada::Signals::NoArgSignal selectionChanged; pajlada::Signals::Signal tabHighlightRequested; @@ -377,6 +387,8 @@ class ChannelView final : public BaseWidget bool onlyUpdateEmotes_ = false; + bool isOverlay_ = false; + // Mouse event variables bool isLeftMouseDown_ = false; bool isRightMouseDown_ = false; diff --git a/src/widgets/helper/MessageView.cpp b/src/widgets/helper/MessageView.cpp index 6383ec5a6e5..ceb004c2795 100644 --- a/src/widgets/helper/MessageView.cpp +++ b/src/widgets/helper/MessageView.cpp @@ -97,8 +97,8 @@ void MessageView::paintEvent(QPaintEvent * /*event*/) void MessageView::themeChangedEvent() { - this->messageColors_.applyTheme(getTheme()); - this->messageColors_.regular = getTheme()->splits.input.background; + this->messageColors_.applyTheme(getTheme(), false, 255); + this->messageColors_.regularBg = getTheme()->splits.input.background; if (this->messageLayout_) { this->messageLayout_->invalidateBuffer(); @@ -120,9 +120,15 @@ void MessageView::layoutMessage() } bool updateRequired = this->messageLayout_->layout( - this->width_, this->scale(), - this->scale() * static_cast(this->devicePixelRatio()), - MESSAGE_FLAGS, false); + { + .messageColors = this->messageColors_, + .flags = MESSAGE_FLAGS, + .width = this->width_, + .scale = this->scale(), + .imageScale = + this->scale() * static_cast(this->devicePixelRatio()), + }, + false); if (updateRequired) { diff --git a/src/widgets/helper/NotebookTab.cpp b/src/widgets/helper/NotebookTab.cpp index ea893f6ea67..be2b371a3b0 100644 --- a/src/widgets/helper/NotebookTab.cpp +++ b/src/widgets/helper/NotebookTab.cpp @@ -412,11 +412,6 @@ void NotebookTab::moveAnimated(QPoint targetPos, bool animated) { this->positionAnimationDesiredPoint_ = targetPos; - if (this->pos() == targetPos) - { - return; - } - if (!animated || !this->notebook_->isVisible()) { this->move(targetPos); diff --git a/src/widgets/helper/NotebookTab.hpp b/src/widgets/helper/NotebookTab.hpp index d7067077ea7..6ae7802d0d0 100644 --- a/src/widgets/helper/NotebookTab.hpp +++ b/src/widgets/helper/NotebookTab.hpp @@ -11,7 +11,7 @@ namespace chatterino { -constexpr int NOTEBOOK_TAB_HEIGHT = 28; +inline constexpr int NOTEBOOK_TAB_HEIGHT = 28; class SplitContainer; diff --git a/src/widgets/helper/OverlayInteraction.cpp b/src/widgets/helper/OverlayInteraction.cpp new file mode 100644 index 00000000000..d4a0d84feb7 --- /dev/null +++ b/src/widgets/helper/OverlayInteraction.cpp @@ -0,0 +1,123 @@ +#include "widgets/helper/OverlayInteraction.hpp" + +#include "common/Literals.hpp" +#include "widgets/OverlayWindow.hpp" + +#include + +namespace chatterino { + +using namespace literals; + +OverlayInteraction::OverlayInteraction(OverlayWindow *parent) + : QWidget(nullptr) + , interactAnimation_(this, "interactionProgress"_ba) + , window_(parent) +{ + this->interactAnimation_.setStartValue(0.0); + this->interactAnimation_.setEndValue(1.0); + + this->closeButton_.setButtonStyle(TitleBarButtonStyle::Close); + this->closeButton_.setScaleIndependantSize(46, 30); + this->closeButton_.hide(); + this->closeButton_.setCursor(Qt::PointingHandCursor); +} + +void OverlayInteraction::attach(QGridLayout *layout) +{ + layout->addWidget(this, 0, 0); + layout->addWidget(&this->closeButton_, 0, 0, Qt::AlignTop | Qt::AlignRight); + layout->setContentsMargins(0, 0, 0, 0); + + QObject::connect(&this->closeButton_, &TitleBarButton::leftClicked, + [this]() { + this->window_->close(); + }); +} + +QWidget *OverlayInteraction::closeButton() +{ + return &this->closeButton_; +} + +void OverlayInteraction::startInteraction() +{ + if (this->interacting_) + { + return; + } + + this->interacting_ = true; + if (this->interactAnimation_.state() != QPropertyAnimation::Stopped) + { + this->interactAnimation_.stop(); + } + this->interactAnimation_.setDirection(QPropertyAnimation::Forward); + this->interactAnimation_.setDuration(100); + this->interactAnimation_.start(); + this->window_->setOverrideCursor(Qt::SizeAllCursor); + this->closeButton_.show(); +} + +void OverlayInteraction::endInteraction() +{ + if (!this->interacting_) + { + return; + } + + this->interacting_ = false; + if (this->interactAnimation_.state() != QPropertyAnimation::Stopped) + { + this->interactAnimation_.stop(); + } + this->interactAnimation_.setDirection(QPropertyAnimation::Backward); + this->interactAnimation_.setDuration(200); + this->interactAnimation_.start(); + this->window_->setOverrideCursor(Qt::ArrowCursor); + this->closeButton_.hide(); +} + +bool OverlayInteraction::isInteracting() const +{ + return this->interacting_; +} + +void OverlayInteraction::paintEvent(QPaintEvent * /*event*/) +{ + QPainter painter(this); + QColor highlightColor( + 255, 255, 255, std::max(int(255.0 * this->interactionProgress()), 50)); + + painter.setPen({highlightColor, 2}); + // outline + auto bounds = this->rect(); + painter.drawRect(bounds); + + if (this->interactionProgress() <= 0.0) + { + return; + } + + highlightColor.setAlpha(highlightColor.alpha() / 4); + painter.setBrush(highlightColor); + painter.setPen(Qt::transparent); + + // close button + auto buttonSize = this->closeButton_.size(); + painter.drawRect( + QRect{bounds.topRight() - QPoint{buttonSize.width(), 0}, buttonSize}); +} + +double OverlayInteraction::interactionProgress() const +{ + return this->interactionProgress_; +} + +void OverlayInteraction::setInteractionProgress(double progress) +{ + this->interactionProgress_ = progress; + this->update(); +} + +} // namespace chatterino diff --git a/src/widgets/helper/OverlayInteraction.hpp b/src/widgets/helper/OverlayInteraction.hpp new file mode 100644 index 00000000000..25be810adfa --- /dev/null +++ b/src/widgets/helper/OverlayInteraction.hpp @@ -0,0 +1,47 @@ +#pragma once + +#include "widgets/helper/TitlebarButton.hpp" + +#include +#include + +class QGridLayout; + +namespace chatterino { + +class OverlayWindow; +class OverlayInteraction : public QWidget +{ + Q_OBJECT +public: + OverlayInteraction(OverlayWindow *parent); + + void attach(QGridLayout *layout); + + QWidget *closeButton(); + + void startInteraction(); + void endInteraction(); + + bool isInteracting() const; + +protected: + void paintEvent(QPaintEvent *event) override; + +private: + Q_PROPERTY(double interactionProgress READ interactionProgress WRITE + setInteractionProgress) + + TitleBarButton closeButton_; + + double interactionProgress() const; + void setInteractionProgress(double progress); + + bool interacting_ = false; + double interactionProgress_ = 0.0; + QPropertyAnimation interactAnimation_; + + OverlayWindow *window_; +}; + +} // namespace chatterino diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index e4cd4254f15..c649b24a325 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -975,6 +975,40 @@ void GeneralPage::initLayout(GeneralPageView &layout) layout.addCheckbox("Use custom FrankerFaceZ VIP badges", s.useCustomFfzVipBadges); + layout.addSubtitle("Overlay"); + layout.addIntInput( + "Background opacity (0-255)", s.overlayBackgroundOpacity, 0, 255, 1, + "Controls the opacity of the (possibly alternating) background behind " + "messages. The color is set through the current theme. 255 corresponds " + "to a fully opaque background."); + layout.addCheckbox("Enable Shadow", s.enableOverlayShadow, false, + "Enables a drop shadow on the overlay. This will use " + "more processing power."); + layout.addIntInput("Shadow opacity (0-255)", s.overlayShadowOpacity, 0, 255, + 1, + "Controls the opacity of the added drop shadow. 255 " + "corresponds to a fully opaque shadow."); + layout.addColorButton("Shadow color", + QColor(getSettings()->overlayShadowColor.getValue()), + getSettings()->overlayShadowColor); + layout + .addIntInput("Shadow radius", s.overlayShadowRadius, 0, 40, 1, + "Controls how far the shadow is spread (the blur " + "radius) in device-independent pixels.") + ->setSuffix("dp"); + layout + .addIntInput("Shadow offset x", s.overlayShadowOffsetX, -20, 20, 1, + "Controls how far the shadow is offset on the x axis in " + "device-independent pixels. A negative value offsets to " + "the left and a positive to the right.") + ->setSuffix("dp"); + layout + .addIntInput("Shadow offset y", s.overlayShadowOffsetY, -20, 20, 1, + "Controls how far the shadow is offset on the y axis in " + "device-independent pixels. A negative value offsets to " + "the top and a positive to the bottom.") + ->setSuffix("dp"); + layout.addSubtitle("Miscellaneous"); if (supportsIncognitoLinks()) diff --git a/src/widgets/settingspages/HighlightingPage.cpp b/src/widgets/settingspages/HighlightingPage.cpp index 2a1b6884b00..a6459d9b4fe 100644 --- a/src/widgets/settingspages/HighlightingPage.cpp +++ b/src/widgets/settingspages/HighlightingPage.cpp @@ -110,8 +110,8 @@ HighlightingPage::HighlightingPage() pingUsers.emplace( "Play notification sounds and highlight messages from " "certain users.\n" - "User highlights are prioritized badge highlights, but " - "under message highlights."); + "User highlights are prioritized over badge highlights, " + "but under message highlights."); EditableModelView *view = pingUsers .emplace( diff --git a/src/widgets/splits/Split.cpp b/src/widgets/splits/Split.cpp index 641a87a1018..2356653d628 100644 --- a/src/widgets/splits/Split.cpp +++ b/src/widgets/splits/Split.cpp @@ -33,6 +33,7 @@ #include "widgets/helper/ResizingTextEdit.hpp" #include "widgets/helper/SearchPopup.hpp" #include "widgets/Notebook.hpp" +#include "widgets/OverlayWindow.hpp" #include "widgets/Scrollbar.hpp" #include "widgets/splits/DraggedSplit.hpp" #include "widgets/splits/SplitContainer.hpp" @@ -765,6 +766,47 @@ void Split::addShortcuts() } return ""; }}, + {"popupOverlay", + [this](const auto &) -> QString { + this->showOverlayWindow(); + return {}; + }}, + {"toggleOverlayInertia", + [this](const auto &args) -> QString { + if (args.empty()) + { + return "No arguments provided to toggleOverlayInertia " + "(expected one)"; + } + const auto &arg = args.front(); + + if (arg == "this") + { + if (this->overlayWindow_) + { + this->overlayWindow_->toggleInertia(); + } + return {}; + } + if (arg == "thisOrAll") + { + if (this->overlayWindow_) + { + this->overlayWindow_->toggleInertia(); + } + else + { + getApp()->getWindows()->toggleAllOverlayInertia(); + } + return {}; + } + if (arg == "all") + { + getApp()->getWindows()->toggleAllOverlayInertia(); + return {}; + } + return {}; + }}, }; this->shortcuts_ = getApp()->getHotkeys()->shortcutsForCategory( @@ -1104,6 +1146,20 @@ void Split::popup() window.show(); } +OverlayWindow *Split::overlayWindow() +{ + return this->overlayWindow_.data(); +} + +void Split::showOverlayWindow() +{ + if (!this->overlayWindow_) + { + this->overlayWindow_ = new OverlayWindow(this->getIndirectChannel()); + } + this->overlayWindow_->show(); +} + void Split::clear() { this->view_->clearMessages(); diff --git a/src/widgets/splits/Split.hpp b/src/widgets/splits/Split.hpp index ab6322d5cdd..6d604bdf8d5 100644 --- a/src/widgets/splits/Split.hpp +++ b/src/widgets/splits/Split.hpp @@ -21,6 +21,7 @@ class SplitInput; class SplitContainer; class SplitOverlay; class SelectChannelDialog; +class OverlayWindow; // Each ChatWidget consists of three sub-elements that handle their own part of // the chat widget: ChatWidgetHeader @@ -80,6 +81,8 @@ class Split : public BaseWidget // This is called on window focus lost void unpause(); + OverlayWindow *overlayWindow(); + static pajlada::Signals::Signal modifierStatusChanged; static Qt::KeyboardModifiers modifierStatus; @@ -158,6 +161,8 @@ class Split : public BaseWidget SplitInput *const input_; SplitOverlay *const overlay_; + QPointer overlayWindow_; + QPointer selectChannelDialog_; pajlada::Signals::Connection channelIDChangedConnection_; @@ -179,6 +184,7 @@ public slots: void explainMoving(); void explainSplitting(); void popup(); + void showOverlayWindow(); void clear(); void openInBrowser(); void openModViewInBrowser(); diff --git a/src/widgets/splits/SplitHeader.cpp b/src/widgets/splits/SplitHeader.cpp index 09725879b23..b82f817be66 100644 --- a/src/widgets/splits/SplitHeader.cpp +++ b/src/widgets/splits/SplitHeader.cpp @@ -390,6 +390,9 @@ std::unique_ptr SplitHeader::createMainMenu() menu->addAction( "Popup", this->split_, &Split::popup, h->getDisplaySequence(HotkeyCategory::Window, "popup", {{"split"}})); + menu->addAction( + "Popup overlay", this->split_, &Split::showOverlayWindow, + h->getDisplaySequence(HotkeyCategory::Split, "popupOverlay")); menu->addAction( "Search", this->split_, [this] { diff --git a/tests/src/Commands.cpp b/tests/src/Commands.cpp index f1b872b7dc1..e44366f2091 100644 --- a/tests/src/Commands.cpp +++ b/tests/src/Commands.cpp @@ -4,12 +4,12 @@ #include "controllers/commands/CommandController.hpp" #include "controllers/commands/common/ChannelAction.hpp" #include "mocks/BaseApplication.hpp" +#include "mocks/Emotes.hpp" #include "mocks/Helix.hpp" #include "mocks/Logging.hpp" #include "mocks/TwitchIrcServer.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchChannel.hpp" -#include "singletons/Emotes.hpp" #include "singletons/Settings.hpp" #include "Test.hpp" @@ -59,7 +59,7 @@ class MockApplication : public mock::BaseApplication AccountController accounts; CommandController commands; mock::MockTwitchIrcServer twitch; - Emotes emotes; + mock::Emotes emotes; }; } // namespace diff --git a/tests/src/Filters.cpp b/tests/src/Filters.cpp index af230454619..b0c728059c3 100644 --- a/tests/src/Filters.cpp +++ b/tests/src/Filters.cpp @@ -7,6 +7,7 @@ #include "mocks/BaseApplication.hpp" #include "mocks/Channel.hpp" #include "mocks/ChatterinoBadges.hpp" +#include "mocks/Emotes.hpp" #include "mocks/EmptyApplication.hpp" #include "mocks/Logging.hpp" #include "mocks/TwitchIrcServer.hpp" @@ -14,7 +15,6 @@ #include "providers/ffz/FfzBadges.hpp" #include "providers/seventv/SeventvBadges.hpp" #include "providers/twitch/TwitchBadge.hpp" -#include "singletons/Emotes.hpp" #include "Test.hpp" #include @@ -24,8 +24,6 @@ using namespace chatterino; using namespace chatterino::filters; using chatterino::mock::MockChannel; -TypingContext typingContext = MESSAGE_TYPING_CONTEXT; - namespace { class MockApplication : public mock::BaseApplication @@ -83,7 +81,7 @@ class MockApplication : public mock::BaseApplication mock::EmptyLogging logging; AccountController accounts; - Emotes emotes; + mock::Emotes emotes; mock::UserDataController userData; mock::MockTwitchIrcServer twitch; mock::ChatterinoBadges chatterinoBadges; @@ -188,7 +186,8 @@ TEST(Filters, TypeSynthesis) T type = filter.returnType(); EXPECT_EQ(type, expected) << "Filter{ " << input << " } has type " << type << " instead of " - << expected << ".\nDebug: " << filter.debugString(typingContext); + << expected + << ".\nDebug: " << filter.debugString(MESSAGE_TYPING_CONTEXT); } } @@ -265,7 +264,7 @@ TEST(Filters, Evaluation) EXPECT_EQ(result, expected) << "Filter{ " << input << " } evaluated to " << result.toString() << " instead of " << expected.toString() - << ".\nDebug: " << filter.debugString(typingContext); + << ".\nDebug: " << filter.debugString(MESSAGE_TYPING_CONTEXT); } } @@ -368,7 +367,8 @@ TEST_F(FiltersF, ExpressionDebug) EXPECT_NE(filter, nullptr) << "Filter::fromString(" << input << ") did not build a proper filter"; - const auto actualDebugString = filter->debugString(typingContext); + const auto actualDebugString = + filter->debugString(MESSAGE_TYPING_CONTEXT); EXPECT_EQ(actualDebugString, debugString) << "filter->debugString() on '" << input << "' should be '" << debugString << "', but got '" << actualDebugString << "'"; diff --git a/tests/src/Helpers.cpp b/tests/src/Helpers.cpp index 4327bf51a1e..c2cf5694007 100644 --- a/tests/src/Helpers.cpp +++ b/tests/src/Helpers.cpp @@ -2,6 +2,8 @@ #include "Test.hpp" +#include + using namespace chatterino; using namespace helpers::detail; @@ -500,3 +502,57 @@ TEST(Helpers, parseDurationToSeconds) << c.output; } } + +TEST(Helpers, unescapeZeroWidthJoiner) +{ + struct TestCase { + QStringView input; + QStringView output; + }; + + std::vector tests{ + {u"foo bar", u"foo bar"}, + {u"", u""}, + {u"a", u"a"}, + {u"\U000E0002", u"\u200D"}, + {u"foo\U000E0002bar", u"foo\u200Dbar"}, + {u"foo \U000E0002 bar", u"foo \u200D bar"}, + {u"\U0001F468\U000E0002\U0001F33E", u"\U0001F468\u200D\U0001F33E"}, + // don't replace ZWJ + {u"\U0001F468\u200D\U0001F33E", u"\U0001F468\u200D\U0001F33E"}, + // only replace the first escape tag in sequences + { + u"\U0001F468\U000E0002\U000E0002\U0001F33E", + u"\U0001F468\u200D\U000E0002\U0001F33E", + }, + { + u"\U0001F468\U000E0002\U000E0002\U000E0002\U0001F33E", + u"\U0001F468\u200D\U000E0002\U000E0002\U0001F33E", + }, + }; + + // sanity check that the compiler supports unicode string literals + static_assert( + [] { + constexpr std::span zwj = u"\u200D"; + static_assert(zwj.size() == 2); + static_assert(zwj[0] == u'\x200D'); + static_assert(zwj[1] == u'\0'); + + constexpr std::span escapeTag = u"\U000E0002"; + static_assert(escapeTag.size() == 3); + static_assert(escapeTag[0] == u'\xDB40'); + static_assert(escapeTag[1] == u'\xDC02'); + static_assert(escapeTag[2] == u'\0'); + + return true; + }(), + "The compiler must support Unicode string literals"); + + for (const auto &c : tests) + { + const auto actual = unescapeZeroWidthJoiner(c.input.toString()); + + EXPECT_EQ(actual, c.output); + } +} diff --git a/tests/src/InputCompletion.cpp b/tests/src/InputCompletion.cpp index 1f219570c49..1d3f8a5ffde 100644 --- a/tests/src/InputCompletion.cpp +++ b/tests/src/InputCompletion.cpp @@ -3,10 +3,12 @@ #include "controllers/accounts/AccountController.hpp" #include "controllers/completion/strategies/ClassicEmoteStrategy.hpp" #include "controllers/completion/strategies/ClassicUserStrategy.hpp" +#include "controllers/completion/strategies/SmartEmoteStrategy.hpp" #include "controllers/completion/strategies/Strategy.hpp" #include "messages/Emote.hpp" #include "mocks/BaseApplication.hpp" #include "mocks/Channel.hpp" +#include "mocks/Emotes.hpp" #include "mocks/Helix.hpp" #include "mocks/Logging.hpp" #include "mocks/TwitchIrcServer.hpp" @@ -78,13 +80,36 @@ class MockApplication : public mock::BaseApplication mock::EmptyLogging logging; AccountController accounts; mock::MockTwitchIrcServer twitch; - Emotes emotes; + mock::Emotes emotes; BttvEmotes bttvEmotes; FfzEmotes ffzEmotes; SeventvEmotes seventvEmotes; }; -} // namespace +void containsRoughly(std::span span, const std::set &values) +{ + for (const auto &v : values) + { + bool found = false; + for (const auto &actualValue : span) + { + if (actualValue.displayName == v) + { + found = true; + break; + } + } + + ASSERT_TRUE(found) << v << " was not found in the span"; + } +} + +[[nodiscard]] bool allEmoji(std::span span) +{ + return std::ranges::all_of(span, [](const auto &it) { + return it.isEmoji && it.providerName == u"Emoji"; + }); +} EmotePtr namedEmote(const EmoteName &name) { @@ -104,7 +129,7 @@ void addEmote(EmoteMap &map, const QString &name) map.insert(std::pair(eName, namedEmote(eName))); } -static QString DEFAULT_SETTINGS = R"!( +const QString DEFAULT_SETTINGS = R"!( { "accounts": { "uid117166826": { @@ -117,6 +142,8 @@ static QString DEFAULT_SETTINGS = R"!( } })!"; +} // namespace + class InputCompletionTest : public ::testing::Test { protected: @@ -164,6 +191,7 @@ class InputCompletionTest : public ::testing::Test addEmote(*bttvEmotes, ":-)"); addEmote(*bttvEmotes, "B-)"); addEmote(*bttvEmotes, "Clap"); + addEmote(*bttvEmotes, ":tf:"); this->mockApplication->bttvEmotes.setEmotes(std::move(bttvEmotes)); auto ffzEmotes = std::make_shared(); @@ -175,50 +203,56 @@ class InputCompletionTest : public ::testing::Test auto seventvEmotes = std::make_shared(); addEmote(*seventvEmotes, "Clap"); addEmote(*seventvEmotes, "Clap2"); + addEmote(*seventvEmotes, "pajaW"); + addEmote(*seventvEmotes, "PAJAW"); this->mockApplication->seventvEmotes.setGlobalEmotes( std::move(seventvEmotes)); } protected: - auto queryClassicEmoteCompletion(const QString &fullQuery) + template + auto queryEmoteCompletion(const QString &fullQuery) { - EmoteSource source(this->channelPtr.get(), - std::make_unique()); + EmoteSource source(this->channelPtr.get(), std::make_unique()); source.update(fullQuery); std::vector out(source.output()); return out; } - auto queryClassicTabCompletion(const QString &fullQuery, bool isFirstWord) + template + auto queryTabCompletion(const QString &fullQuery, bool isFirstWord) { - EmoteSource source(this->channelPtr.get(), - std::make_unique()); + EmoteSource source(this->channelPtr.get(), std::make_unique()); source.update(fullQuery); QStringList m; source.addToStringList(m, 0, isFirstWord); return m; } -}; -void containsRoughly(std::span span, std::set values) -{ - for (const auto &v : values) + auto queryClassicEmoteCompletion(const QString &fullQuery) { - bool found = false; - for (const auto &actualValue : span) - { - if (actualValue.displayName == v) - { - found = true; - break; - } - } + return queryEmoteCompletion(fullQuery); + } - ASSERT_TRUE(found) << v << " was not found in the span"; + auto queryClassicTabCompletion(const QString &fullQuery, bool isFirstWord) + { + return queryTabCompletion(fullQuery, + isFirstWord); } -} + + auto querySmartEmoteCompletion(const QString &fullQuery) + { + return queryEmoteCompletion(fullQuery); + } + + auto querySmartTabCompletion(const QString &fullQuery, bool isFirstWord) + { + return queryTabCompletion(fullQuery, + isFirstWord); + } +}; TEST_F(InputCompletionTest, ClassicEmoteNameFiltering) { @@ -230,9 +264,9 @@ TEST_F(InputCompletionTest, ClassicEmoteNameFiltering) auto completion = queryClassicEmoteCompletion(":feels"); ASSERT_EQ(completion.size(), 3); // all these matches are BTTV global emotes - ASSERT_EQ(completion[0].displayName, "FeelsBirthdayMan"); - ASSERT_EQ(completion[1].displayName, "FeelsBadMan"); - ASSERT_EQ(completion[2].displayName, "FeelsGoodMan"); + // these are in no specific order + containsRoughly(completion, + {"FeelsBirthdayMan", "FeelsBadMan", "FeelsGoodMan"}); completion = queryClassicEmoteCompletion(":)"); ASSERT_EQ(completion.size(), 3); @@ -299,6 +333,30 @@ TEST_F(InputCompletionTest, ClassicEmoteProviderOrdering) ASSERT_EQ(completion[4].providerName, "Emoji"); } +TEST_F(InputCompletionTest, ClassicEmoteCase) +{ + auto completion = queryClassicEmoteCompletion(":pajaw"); + ASSERT_EQ(completion.size(), 2); + // there's no order here + containsRoughly(completion, {"pajaW", "PAJAW"}); + + completion = queryClassicEmoteCompletion(":PA"); + ASSERT_GT(completion.size(), 3); + containsRoughly({completion.begin(), 2}, {"pajaW", "PAJAW"}); + containsRoughly({completion.begin() + 2, completion.end()}, {"parking"}); + ASSERT_TRUE(allEmoji({completion.begin() + 2, completion.end()})); + + completion = queryClassicEmoteCompletion(":Pajaw"); + ASSERT_EQ(completion.size(), 2); + containsRoughly(completion, {"pajaW", "PAJAW"}); + + completion = queryClassicEmoteCompletion(":NOTHING"); + ASSERT_EQ(completion.size(), 0); + + completion = queryClassicEmoteCompletion(":nothing"); + ASSERT_EQ(completion.size(), 0); +} + TEST_F(InputCompletionTest, ClassicTabCompletionEmote) { auto completion = queryClassicTabCompletion(":feels", false); @@ -328,7 +386,15 @@ TEST_F(InputCompletionTest, ClassicTabCompletionEmote) TEST_F(InputCompletionTest, ClassicTabCompletionEmoji) { - auto completion = queryClassicTabCompletion(":cla", false); + auto completion = queryClassicTabCompletion(":tf", false); + ASSERT_EQ(completion.size(), 1); + ASSERT_EQ(completion[0], ":tf: "); + + completion = queryClassicTabCompletion(":)", false); + ASSERT_EQ(completion.size(), 1); + ASSERT_EQ(completion[0], ":) "); + + completion = queryClassicTabCompletion(":cla", false); ASSERT_EQ(completion.size(), 8); ASSERT_EQ(completion[0], ":clap: "); ASSERT_EQ(completion[1], ":clap_tone1: "); @@ -339,3 +405,177 @@ TEST_F(InputCompletionTest, ClassicTabCompletionEmoji) ASSERT_EQ(completion[6], ":clapper: "); ASSERT_EQ(completion[7], ":classical_building: "); } + +TEST_F(InputCompletionTest, ClassicTabCompletionCase) +{ + auto completion = queryClassicTabCompletion("pajaw", false); + ASSERT_EQ(completion.size(), 2); + ASSERT_EQ(completion[0], "pajaW "); + ASSERT_EQ(completion[1], "PAJAW "); + + completion = queryClassicTabCompletion("PA", false); + ASSERT_EQ(completion.size(), 2); + ASSERT_EQ(completion[0], "pajaW "); + ASSERT_EQ(completion[1], "PAJAW "); + + completion = queryClassicTabCompletion("Pajaw", false); + ASSERT_EQ(completion.size(), 2); + ASSERT_EQ(completion[0], "pajaW "); + ASSERT_EQ(completion[1], "PAJAW "); + + completion = queryClassicTabCompletion("NOTHING", false); + ASSERT_EQ(completion.size(), 0); + + completion = queryClassicTabCompletion("nothing", false); + ASSERT_EQ(completion.size(), 0); +} + +TEST_F(InputCompletionTest, SmartEmoteNameFiltering) +{ + auto completion = querySmartEmoteCompletion(":feels"); + ASSERT_EQ(completion.size(), 3); + ASSERT_EQ(completion[0].displayName, "FeelsBadMan"); + ASSERT_EQ(completion[1].displayName, "FeelsGoodMan"); + ASSERT_EQ(completion[2].displayName, "FeelsBirthdayMan"); + + completion = querySmartEmoteCompletion(":)"); + ASSERT_EQ(completion.size(), 3); + ASSERT_EQ(completion[0].displayName, ":)"); + ASSERT_EQ(completion[1].displayName, ":-)"); + ASSERT_EQ(completion[2].displayName, "B-)"); + + completion = querySmartEmoteCompletion(":cat"); + ASSERT_TRUE(completion.size() >= 4); + ASSERT_EQ(completion[0].displayName, "cat"); + ASSERT_EQ(completion[1].displayName, "cat2"); + ASSERT_EQ(completion[2].displayName, "CatBag"); + ASSERT_EQ(completion[3].displayName, "joy_cat"); +} + +TEST_F(InputCompletionTest, SmartEmoteExactNameMatching) +{ + auto completion = querySmartEmoteCompletion(":sal"); + ASSERT_TRUE(completion.size() >= 4); + ASSERT_EQ(completion[0].displayName, "salt"); + ASSERT_EQ(completion[1].displayName, "SaltyCorn"); + ASSERT_EQ(completion[2].displayName, "green_salad"); + ASSERT_EQ(completion[3].displayName, "saluting_face"); + + completion = querySmartEmoteCompletion(":salt"); + ASSERT_TRUE(completion.size() >= 2); + ASSERT_EQ(completion[0].displayName, "salt"); + ASSERT_EQ(completion[1].displayName, "SaltyCorn"); +} + +TEST_F(InputCompletionTest, SmartEmoteProviderOrdering) +{ + auto completion = querySmartEmoteCompletion(":clap"); + ASSERT_TRUE(completion.size() >= 6); + ASSERT_EQ(completion[0].displayName, "clap"); + ASSERT_EQ(completion[0].providerName, "Emoji"); + ASSERT_EQ(completion[1].displayName, "Clap"); + ASSERT_EQ(completion[1].providerName, "Global BetterTTV"); + ASSERT_EQ(completion[2].displayName, "Clap"); + ASSERT_EQ(completion[2].providerName, "Global 7TV"); + ASSERT_EQ(completion[3].displayName, "Clap2"); + ASSERT_EQ(completion[3].providerName, "Global 7TV"); + ASSERT_EQ(completion[4].displayName, "clapper"); + ASSERT_EQ(completion[4].providerName, "Emoji"); + ASSERT_EQ(completion[5].displayName, "clap_tone1"); + ASSERT_EQ(completion[5].providerName, "Emoji"); +} + +TEST_F(InputCompletionTest, SmartEmoteCase) +{ + auto completion = querySmartEmoteCompletion(":pajaw"); + ASSERT_EQ(completion.size(), 2); + ASSERT_EQ(completion[0].displayName, "pajaW"); + ASSERT_EQ(completion[1].displayName, "PAJAW"); + + completion = querySmartEmoteCompletion(":PA"); + ASSERT_EQ(completion.size(), 1); + ASSERT_EQ(completion[0].displayName, "PAJAW"); + + completion = querySmartEmoteCompletion(":Pajaw"); + ASSERT_EQ(completion.size(), 2); + ASSERT_EQ(completion[0].displayName, "PAJAW"); + ASSERT_EQ(completion[1].displayName, "pajaW"); + + completion = querySmartEmoteCompletion(":NOTHING"); + ASSERT_EQ(completion.size(), 0); + + completion = querySmartEmoteCompletion(":nothing"); + ASSERT_EQ(completion.size(), 0); +} + +TEST_F(InputCompletionTest, SmartTabCompletionEmote) +{ + auto completion = querySmartTabCompletion(":feels", false); + ASSERT_EQ(completion.size(), 0); // : prefix matters here + + // no : prefix defaults to emote completion + completion = querySmartTabCompletion("feels", false); + ASSERT_EQ(completion.size(), 3); + ASSERT_EQ(completion[0], "FeelsBadMan "); + ASSERT_EQ(completion[1], "FeelsGoodMan "); + ASSERT_EQ(completion[2], "FeelsBirthdayMan "); + + // no : prefix, emote completion. Duplicate Clap should be removed + completion = querySmartTabCompletion("cla", false); + ASSERT_EQ(completion.size(), 3); + ASSERT_EQ(completion[0], "Clap "); + ASSERT_EQ(completion[1], "Clap "); + ASSERT_EQ(completion[2], "Clap2 "); + + completion = querySmartTabCompletion("peepoHappy", false); + ASSERT_EQ(completion.size(), 0); + + completion = querySmartTabCompletion("Aware", false); + ASSERT_EQ(completion.size(), 1); + ASSERT_EQ(completion[0], "Aware "); +} + +TEST_F(InputCompletionTest, SmartTabCompletionEmoji) +{ + auto completion = querySmartTabCompletion(":tf", false); + ASSERT_EQ(completion.size(), 1); + ASSERT_EQ(completion[0], ":tf: "); + + completion = querySmartTabCompletion(":)", false); + ASSERT_EQ(completion.size(), 1); + ASSERT_EQ(completion[0], ":) "); + + completion = querySmartTabCompletion(":cla", false); + ASSERT_EQ(completion.size(), 8); + ASSERT_EQ(completion[0], ":clap: "); + ASSERT_EQ(completion[1], ":clapper: "); + ASSERT_EQ(completion[2], ":clap_tone1: "); + ASSERT_EQ(completion[3], ":clap_tone2: "); + ASSERT_EQ(completion[4], ":clap_tone3: "); + ASSERT_EQ(completion[5], ":clap_tone4: "); + ASSERT_EQ(completion[6], ":clap_tone5: "); + ASSERT_EQ(completion[7], ":classical_building: "); +} + +TEST_F(InputCompletionTest, SmartTabCompletionCase) +{ + auto completion = querySmartTabCompletion("pajaw", false); + ASSERT_EQ(completion.size(), 2); + ASSERT_EQ(completion[0], "pajaW "); + ASSERT_EQ(completion[1], "PAJAW "); + + completion = querySmartTabCompletion("PA", false); + ASSERT_EQ(completion.size(), 1); + ASSERT_EQ(completion[0], "PAJAW "); + + completion = querySmartTabCompletion("Pajaw", false); + ASSERT_EQ(completion.size(), 2); + ASSERT_EQ(completion[0], "PAJAW "); + ASSERT_EQ(completion[1], "pajaW "); + + completion = querySmartTabCompletion("NOTHING", false); + ASSERT_EQ(completion.size(), 0); + + completion = querySmartTabCompletion("nothing", false); + ASSERT_EQ(completion.size(), 0); +} diff --git a/tests/src/MessageBuilder.cpp b/tests/src/MessageBuilder.cpp index dbd334a0641..d94fb3ae559 100644 --- a/tests/src/MessageBuilder.cpp +++ b/tests/src/MessageBuilder.cpp @@ -7,6 +7,7 @@ #include "mocks/Channel.hpp" #include "mocks/ChatterinoBadges.hpp" #include "mocks/DisabledStreamerMode.hpp" +#include "mocks/Emotes.hpp" #include "mocks/Logging.hpp" #include "mocks/TwitchIrcServer.hpp" #include "mocks/UserData.hpp" @@ -14,7 +15,6 @@ #include "providers/seventv/SeventvBadges.hpp" #include "providers/seventv/SeventvPersonalEmotes.hpp" #include "providers/twitch/TwitchBadge.hpp" -#include "singletons/Emotes.hpp" #include "Test.hpp" #include "util/IrcHelpers.hpp" @@ -105,7 +105,7 @@ class MockApplication : public mock::BaseApplication mock::EmptyLogging logging; AccountController accounts; - Emotes emotes; + mock::Emotes emotes; mock::UserDataController userData; mock::MockTwitchIrcServer twitch; mock::ChatterinoBadges chatterinoBadges; diff --git a/tests/src/MessageLayout.cpp b/tests/src/MessageLayout.cpp index 06fb4db5932..72b50f872de 100644 --- a/tests/src/MessageLayout.cpp +++ b/tests/src/MessageLayout.cpp @@ -2,6 +2,7 @@ #include "Application.hpp" #include "controllers/accounts/AccountController.hpp" +#include "messages/layouts/MessageLayoutContext.hpp" #include "messages/MessageBuilder.hpp" #include "messages/MessageElement.hpp" #include "mocks/BaseApplication.hpp" @@ -55,7 +56,16 @@ class MessageLayoutTest builder.append( std::make_unique(text, MessageElementFlag::Text)); this->layout = std::make_unique(builder.release()); - this->layout->layout(WIDTH, 1, 1, MessageElementFlag::Text, false); + MessageColors colors; + this->layout->layout( + { + .messageColors = colors, + .flags = MessageElementFlag::Text, + .width = WIDTH, + .scale = 1, + .imageScale = 1, + }, + false); } MockApplication mockApplication; diff --git a/tests/src/MessageLayoutContainer.cpp b/tests/src/MessageLayoutContainer.cpp index b0aa4f30e4a..2f1b810fd02 100644 --- a/tests/src/MessageLayoutContainer.cpp +++ b/tests/src/MessageLayoutContainer.cpp @@ -2,6 +2,7 @@ #include "common/Literals.hpp" #include "messages/Emote.hpp" +#include "messages/layouts/MessageLayoutContext.hpp" #include "messages/layouts/MessageLayoutElement.hpp" #include "messages/Message.hpp" #include "messages/MessageElement.hpp" @@ -107,16 +108,25 @@ TEST_P(MessageLayoutContainerTest, RtlReordering) { auto [inputText, expected, expectedDirection] = GetParam(); MessageLayoutContainer container; - container.beginLayout(10000, 1.0F, 1.0F, {MessageFlag::Collapsed}); + MessageLayoutContext ctx{ + .messageColors = {}, + .flags = + { + MessageElementFlag::Text, + MessageElementFlag::Username, + MessageElementFlag::TwitchEmote, + }, + .width = 10000, + .scale = 1.0F, + .imageScale = 1.0F, + }; + container.beginLayout(ctx.width, ctx.scale, ctx.imageScale, + {MessageFlag::Collapsed}); auto elements = makeElements(inputText); for (const auto &element : elements) { - element->addToContainer(container, { - MessageElementFlag::Text, - MessageElementFlag::Username, - MessageElementFlag::TwitchEmote, - }); + element->addToContainer(container, ctx); } container.endLayout(); ASSERT_EQ(container.line_, 1) << "unexpected linebreak"; diff --git a/tests/src/ModerationAction.cpp b/tests/src/ModerationAction.cpp index 39118fe5fe9..b1c306c97ec 100644 --- a/tests/src/ModerationAction.cpp +++ b/tests/src/ModerationAction.cpp @@ -2,7 +2,7 @@ #include "messages/Image.hpp" #include "mocks/BaseApplication.hpp" -#include "singletons/Emotes.hpp" +#include "mocks/Emotes.hpp" #include "singletons/Resources.hpp" #include "Test.hpp" @@ -24,7 +24,7 @@ class MockApplication : public mock::BaseApplication return &this->emotes; } - Emotes emotes; + mock::Emotes emotes; }; class ModerationActionTest : public ::testing::Test diff --git a/tests/src/SplitInput.cpp b/tests/src/SplitInput.cpp index 6528583eef4..edd9431d530 100644 --- a/tests/src/SplitInput.cpp +++ b/tests/src/SplitInput.cpp @@ -6,7 +6,7 @@ #include "controllers/commands/CommandController.hpp" #include "controllers/hotkeys/HotkeyController.hpp" #include "mocks/BaseApplication.hpp" -#include "singletons/Emotes.hpp" +#include "mocks/Emotes.hpp" #include "singletons/Fonts.hpp" #include "singletons/Paths.hpp" #include "singletons/Settings.hpp" @@ -62,7 +62,7 @@ class MockApplication : public mock::BaseApplication WindowManager windowManager; AccountController accounts; CommandController commands; - Emotes emotes; + mock::Emotes emotes; }; class SplitInputTest