From 7e005ba661a99c94dee736955bf5730061fe2d1f Mon Sep 17 00:00:00 2001 From: pajlada Date: Tue, 21 Feb 2023 09:47:18 +0100 Subject: [PATCH 01/24] Fix AppImage not containing all SSL dependencies (#4400) This means the AppImage is built on Ubuntu 20.04 using Qt 5.12 --- .CI/CreateAppImage.sh | 39 ++++++++++++++++++------- .docker/Dockerfile-ubuntu-20.04-package | 18 ++++++++---- .docker/Dockerfile-ubuntu-22.04-package | 15 +++++++++- .docker/README.md | 10 ++++--- .github/workflows/build.yml | 6 ++-- CHANGELOG.md | 1 + 6 files changed, 66 insertions(+), 23 deletions(-) diff --git a/.CI/CreateAppImage.sh b/.CI/CreateAppImage.sh index d245ec9f1..2e13d61ef 100755 --- a/.CI/CreateAppImage.sh +++ b/.CI/CreateAppImage.sh @@ -2,13 +2,18 @@ set -e +# Print all commands as they are run +set -x + if [ ! -f ./bin/chatterino ] || [ ! -x ./bin/chatterino ]; then echo "ERROR: No chatterino binary file found. This script must be run in the build folder, and chatterino must be built first." exit 1 fi -export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:/opt/qt512/lib/" -export PATH="/opt/qt512/bin:$PATH" +echo "Qt5_DIR set to: ${Qt5_DIR}" + +export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${Qt5_DIR}/lib" +export PATH="${Qt5_DIR}/bin:$PATH" script_path=$(readlink -f "$0") script_dir=$(dirname "$script_path") @@ -25,20 +30,32 @@ echo "" cp "$chatterino_dir"/resources/icon.png ./appdir/chatterino.png -linuxdeployqt_path="linuxdeployqt-6-x86_64.AppImage" -linuxdeployqt_url="https://github.com/probonopd/linuxdeployqt/releases/download/6/linuxdeployqt-6-x86_64.AppImage" +linuxdeployqt_path="linuxdeployqt-x86_64.AppImage" +linuxdeployqt_url="https://github.com/probonopd/linuxdeployqt/releases/download/continuous/linuxdeployqt-continuous-x86_64.AppImage" if [ ! -f "$linuxdeployqt_path" ]; then - wget -nv "$linuxdeployqt_url" + echo "Downloading LinuxDeployQT from $linuxdeployqt_url to $linuxdeployqt_path" + curl --location --fail --silent "$linuxdeployqt_url" -o "$linuxdeployqt_path" chmod a+x "$linuxdeployqt_path" fi -if [ ! -f appimagetool-x86_64.AppImage ]; then - wget -nv "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" - chmod a+x appimagetool-x86_64.AppImage + +appimagetool_path="appimagetool-x86_64.AppImage" +appimagetool_url="https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage" + +if [ ! -f "$appimagetool_path" ]; then + echo "Downloading AppImageTool from $appimagetool_url to $appimagetool_path" + curl --location --fail --silent "$appimagetool_url" -o "$appimagetool_path" + chmod a+x "$appimagetool_path" fi + +# For some reason, the copyright file for libc was not found. We need to manually copy it from the host system +mkdir -p appdir/usr/share/doc/libc6/ +cp /usr/share/doc/libc6/copyright appdir/usr/share/doc/libc6/ + echo "Run LinuxDeployQT" ./"$linuxdeployqt_path" \ - appdir/usr/share/applications/*.desktop \ + --appimage-extract-and-run \ + appdir/usr/share/applications/com.chatterino.chatterino.desktop \ -no-translations \ -bundle-non-qt-libs \ -unsupported-allow-new-glibc @@ -56,7 +73,9 @@ cd "$here/usr" exec "$here/usr/bin/chatterino" "$@"' > appdir/AppRun chmod a+x appdir/AppRun -./appimagetool-x86_64.AppImage appdir +./"$appimagetool_path" \ + --appimage-extract-and-run \ + appdir # TODO: Create appimage in a unique directory instead maybe idk? rm -rf appdir diff --git a/.docker/Dockerfile-ubuntu-20.04-package b/.docker/Dockerfile-ubuntu-20.04-package index 6c41156f3..4d0a7b189 100644 --- a/.docker/Dockerfile-ubuntu-20.04-package +++ b/.docker/Dockerfile-ubuntu-20.04-package @@ -1,13 +1,21 @@ FROM chatterino-ubuntu-20.04-build -ADD .CI /src/.CI +# In CI, this is set from the aqtinstall action +ENV Qt5_DIR=/opt/qt512 WORKDIR /src/build -# RUN apt-get install -y wget +ADD .CI /src/.CI -# create appimage -# RUN pwd && ./../.CI/CreateAppImage.sh +# Install dependencies necessary for AppImage packaging +RUN apt-get update && apt-get -y install --no-install-recommends \ + curl \ + libfontconfig \ + libxrender1 \ + file # package deb -RUN pwd && ./../.CI/CreateUbuntuDeb.sh +RUN ./../.CI/CreateUbuntuDeb.sh + +# package appimage +RUN ./../.CI/CreateAppImage.sh diff --git a/.docker/Dockerfile-ubuntu-22.04-package b/.docker/Dockerfile-ubuntu-22.04-package index 193c666a2..e3b546918 100644 --- a/.docker/Dockerfile-ubuntu-22.04-package +++ b/.docker/Dockerfile-ubuntu-22.04-package @@ -1,8 +1,21 @@ FROM chatterino-ubuntu-22.04-build -ADD .CI /src/.CI +# In CI, this is set from the aqtinstall action +ENV Qt5_DIR=/opt/qt515 WORKDIR /src/build +ADD .CI /src/.CI + +# Install dependencies necessary for AppImage packaging +RUN apt-get update && apt-get -y install --no-install-recommends \ + curl \ + libxcb-shape0 \ + libfontconfig1 \ + file + # package deb RUN ./../.CI/CreateUbuntuDeb.sh + +# package appimage +RUN ./../.CI/CreateAppImage.sh diff --git a/.docker/README.md b/.docker/README.md index 869a1e391..c0af68619 100644 --- a/.docker/README.md +++ b/.docker/README.md @@ -7,9 +7,9 @@ To build, from the repo root 1. Build a docker image that contains all the build artifacts and source from building Chatterino on Ubuntu 20.04 - `docker build -t chatterino-ubuntu-20.04-build -f .docker/Dockerfile-ubuntu-20.04-build .` + `docker buildx build -t chatterino-ubuntu-20.04-build -f .docker/Dockerfile-ubuntu-20.04-build .` 1. Build a docker image that uses the above-built image & packages it into a .deb file - `docker build -t chatterino-ubuntu-20.04-package -f .docker/Dockerfile-ubuntu-20.04-package .` + `docker buildx build -t chatterino-ubuntu-20.04-package -f .docker/Dockerfile-ubuntu-20.04-package .` To extract the final package, you can run the following command: `docker run -v $PWD:/opt/mount --rm -it chatterino-ubuntu-20.04-package bash -c "cp /src/build/Chatterino-x86_64.deb /opt/mount/"` @@ -21,9 +21,11 @@ To extract the final package, you can run the following command: To build, from the repo root 1. Build a docker image that contains all the build artifacts and source from building Chatterino on Ubuntu 22.04 - `docker build -t chatterino-ubuntu-22.04-build -f .docker/Dockerfile-ubuntu-22.04-build .` + `docker buildx build -t chatterino-ubuntu-22.04-build -f .docker/Dockerfile-ubuntu-22.04-build .` 1. Build a docker image that uses the above-built image & packages it into a .deb file - `docker build -t chatterino-ubuntu-22.04-package -f .docker/Dockerfile-ubuntu-22.04-package .` + `docker buildx build -t chatterino-ubuntu-22.04-package -f .docker/Dockerfile-ubuntu-22.04-package .` To extract the final package, you can run the following command: `docker run -v $PWD:/opt/mount --rm -it chatterino-ubuntu-22.04-package bash -c "cp /src/build/Chatterino-x86_64.deb /opt/mount/"` + +NOTE: The AppImage from Ubuntu 22.04 is broken. Approach with caution diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f961b5aaf..96aa677b1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -263,7 +263,7 @@ jobs: clang-tidy-review-metadata.json - name: Package - AppImage (Ubuntu) - if: startsWith(matrix.os, 'ubuntu') && matrix.skip_artifact != 'yes' + if: startsWith(matrix.os, 'ubuntu-20.04') && matrix.skip_artifact != 'yes' run: | cd build sh ./../.CI/CreateAppImage.sh @@ -277,7 +277,7 @@ jobs: shell: bash - name: Upload artifact - AppImage (Ubuntu) - if: startsWith(matrix.os, 'ubuntu') && matrix.skip_artifact != 'yes' + if: startsWith(matrix.os, 'ubuntu-20.04') && matrix.skip_artifact != 'yes' uses: actions/upload-artifact@v3 with: name: Chatterino-x86_64-${{ matrix.qt-version }}.AppImage @@ -351,7 +351,7 @@ jobs: - uses: actions/download-artifact@v3 with: - name: Chatterino-x86_64-5.15.2.AppImage + name: Chatterino-x86_64-5.12.12.AppImage path: release-artifacts/ - uses: actions/download-artifact@v3 diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b4d89d14..fb4e53929 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unversioned - Minor: Delete all but the last 5 crashdumps on application start. (#4392) +- Bugfix: Fixed uploaded AppImage not being able most web requests. (#4400) - Dev: Add capability to build Chatterino with Qt6. (#4393) - Dev: Fix homebrew update action. (#4394) From a75feba4caee67f66a937721bdbd0b3d5eda7149 Mon Sep 17 00:00:00 2001 From: nerix Date: Wed, 22 Feb 2023 13:01:47 +0100 Subject: [PATCH 02/24] Use `unique_lock` when loading 7TV badges (#4402) Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 1 + src/providers/seventv/SeventvBadges.cpp | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fb4e53929..d0bddeaad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Minor: Delete all but the last 5 crashdumps on application start. (#4392) - Bugfix: Fixed uploaded AppImage not being able most web requests. (#4400) +- Bugfix: Fixed a potential race condition due to using the wrong lock when loading 7TV badges. (#4402) - Dev: Add capability to build Chatterino with Qt6. (#4393) - Dev: Fix homebrew update action. (#4394) diff --git a/src/providers/seventv/SeventvBadges.cpp b/src/providers/seventv/SeventvBadges.cpp index 9bdc51bf6..1216d0863 100644 --- a/src/providers/seventv/SeventvBadges.cpp +++ b/src/providers/seventv/SeventvBadges.cpp @@ -45,7 +45,7 @@ void SeventvBadges::loadSeventvBadges() .onSuccess([this](const NetworkResult &result) -> Outcome { auto root = result.parseJson(); - std::shared_lock lock(this->mutex_); + std::unique_lock lock(this->mutex_); int index = 0; for (const auto &jsonBadge : root.value("badges").toArray()) From 4923549fdffbc2400c5f3929da11721babb75e29 Mon Sep 17 00:00:00 2001 From: James Upjohn Date: Sat, 25 Feb 2023 03:22:23 +1300 Subject: [PATCH 03/24] fix: update emote picker labels to use `7TV` naming (#4405) --- src/widgets/dialogs/EmotePopup.cpp | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/widgets/dialogs/EmotePopup.cpp b/src/widgets/dialogs/EmotePopup.cpp index 5d6b5bf35..c81abd04d 100644 --- a/src/widgets/dialogs/EmotePopup.cpp +++ b/src/widgets/dialogs/EmotePopup.cpp @@ -493,7 +493,7 @@ void EmotePopup::filterTwitchEmotes(std::shared_ptr searchChannel, } if (!seventvGlobalEmotes.empty()) { - addEmotes(*searchChannel, seventvGlobalEmotes, "SevenTV (Global)", + addEmotes(*searchChannel, seventvGlobalEmotes, "7TV (Global)", MessageElementFlag::SevenTVEmote); } @@ -522,7 +522,7 @@ void EmotePopup::filterTwitchEmotes(std::shared_ptr searchChannel, } if (!seventvChannelEmotes.empty()) { - addEmotes(*searchChannel, seventvChannelEmotes, "SevenTV (Channel)", + addEmotes(*searchChannel, seventvChannelEmotes, "7TV (Channel)", MessageElementFlag::SevenTVEmote); } } From f9b23882f4221cb0c9d71ebcb6a490dafc3492c5 Mon Sep 17 00:00:00 2001 From: pajlada Date: Sat, 25 Feb 2023 12:44:45 +0100 Subject: [PATCH 04/24] Templatize localizeNumbers (#4412) original idea by MM2PL --- src/util/Helpers.cpp | 18 ------------------ src/util/Helpers.hpp | 10 +++++++--- 2 files changed, 7 insertions(+), 21 deletions(-) diff --git a/src/util/Helpers.cpp b/src/util/Helpers.cpp index d4d4c6173..d780910a0 100644 --- a/src/util/Helpers.cpp +++ b/src/util/Helpers.cpp @@ -162,24 +162,6 @@ QString shortenString(const QString &str, unsigned maxWidth) return shortened; } -QString localizeNumbers(const int &number) -{ - QLocale locale; - return locale.toString(number); -} - -QString localizeNumbers(unsigned int number) -{ - QLocale locale; - return locale.toString(number); -} - -QString localizeNumbers(qsizetype number) -{ - QLocale locale; - return locale.toString(number); -} - QString kFormatNumbers(const int &number) { return QString("%1K").arg(number / 1000); diff --git a/src/util/Helpers.hpp b/src/util/Helpers.hpp index 34a374d43..b2b64d7e4 100644 --- a/src/util/Helpers.hpp +++ b/src/util/Helpers.hpp @@ -1,6 +1,7 @@ #pragma once #include +#include #include #include @@ -72,9 +73,12 @@ QString formatRichNamedLink(const QString &url, const QString &name, QString shortenString(const QString &str, unsigned maxWidth = 50); -QString localizeNumbers(const int &number); -QString localizeNumbers(unsigned int number); -QString localizeNumbers(qsizetype number); +template +QString localizeNumbers(T number) +{ + QLocale locale; + return locale.toString(number); +} QString kFormatNumbers(const int &number); From b5b85501ee4446961b5f3d412c441de6adaaabc3 Mon Sep 17 00:00:00 2001 From: iProdigy <8106344+iProdigy@users.noreply.github.com> Date: Sun, 26 Feb 2023 12:03:14 -0800 Subject: [PATCH 05/24] Add ban user by id command `/banid` (#4411) Co-authored-by: Felanbird <41973452+Felanbird@users.noreply.github.com> Co-authored-by: pajlada --- CHANGELOG.md | 1 + .../commands/CommandController.cpp | 55 +++++++++++++++++-- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d0bddeaad..36505727d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unversioned - Minor: Delete all but the last 5 crashdumps on application start. (#4392) +- Minor: Added `/banid` command that allows banning by user ID. (#4411) - Bugfix: Fixed uploaded AppImage not being able most web requests. (#4400) - Bugfix: Fixed a potential race condition due to using the wrong lock when loading 7TV badges. (#4402) - Dev: Add capability to build Chatterino with Qt6. (#4393) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 42fa76e4f..a77dc722f 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -2632,7 +2632,7 @@ void CommandController::initialize(Settings &, Paths &paths) auto formatBanTimeoutError = [](const char *operation, HelixBanUserError error, - const QString &message, const QString &userDisplayName) -> QString { + const QString &message, const QString &userTarget) -> QString { using Error = HelixBanUserError; QString errorMessage = QString("Failed to %1 user - ").arg(operation); @@ -2659,7 +2659,7 @@ void CommandController::initialize(Settings &, Paths &paths) case Error::TargetBanned: { // Equivalent IRC error errorMessage += QString("%1 is already banned in this channel.") - .arg(userDisplayName); + .arg(userTarget); } break; @@ -2669,8 +2669,8 @@ void CommandController::initialize(Settings &, Paths &paths) // The messages from IRC are formatted like this: // "You cannot {op} moderator {mod} unless you are the owner of this channel." // "You cannot {op} the broadcaster." - errorMessage += QString("You cannot %1 %2.") - .arg(operation, userDisplayName); + errorMessage += + QString("You cannot %1 %2.").arg(operation, userTarget); } break; @@ -2829,6 +2829,53 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); + this->registerCommand("/banid", [formatBanTimeoutError]( + const QStringList &words, + auto channel) { + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + QString("The /banid command only works in Twitch channels"))); + return ""; + } + + const auto *usageStr = + "Usage: \"/banid [reason]\" - Permanently prevent a user " + "from chatting via their user ID. Reason is optional and will be " + "shown to the target user and other moderators."; + if (words.size() < 2) + { + channel->addMessage(makeSystemMessage(usageStr)); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage( + makeSystemMessage("You must be logged in to ban someone!")); + return ""; + } + + auto target = words.at(1); + auto reason = words.mid(2).join(' '); + + getHelix()->banUser( + twitchChannel->roomId(), currentUser->getUserId(), target, + boost::none, reason, + [] { + // No response for bans, they're emitted over pubsub/IRC instead + }, + [channel, target, formatBanTimeoutError](auto error, auto message) { + auto errorMessage = + formatBanTimeoutError("ban", error, message, "#" + target); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; + }); + for (const auto &cmd : TWITCH_WHISPER_COMMANDS) { this->registerCommand(cmd, [](const QStringList &words, auto channel) { From 3c73801d20275f1dd34f646d891e99936bd62f92 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Feb 2023 21:02:37 -0500 Subject: [PATCH 06/24] Bump lib/serialize from `7d37cbf` to `1f99aa8` (#4418) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- lib/serialize | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/serialize b/lib/serialize index 7d37cbfd5..1f99aa808 160000 --- a/lib/serialize +++ b/lib/serialize @@ -1 +1 @@ -Subproject commit 7d37cbfd5ac3bfbe046118e1cec3d32ba4696469 +Subproject commit 1f99aa808eda5e717245254032c6bf58b0fc088a From 35ac9d5f2251b1da5bffd0e43a6047f000617c4e Mon Sep 17 00:00:00 2001 From: pajlada Date: Sat, 4 Mar 2023 12:09:20 +0100 Subject: [PATCH 07/24] Update Windows build documentation (#4413) This moves CMake to be our top supported build method, with Qt creator being lower priority now. --- .prettierignore | 3 + BUILDING_ON_WINDOWS.md | 130 ++++++++++++++++-------------- BUILDING_ON_WINDOWS_WITH_VCPKG.md | 30 ++++--- 3 files changed, 93 insertions(+), 70 deletions(-) diff --git a/.prettierignore b/.prettierignore index df4877b88..c23325ed8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -21,3 +21,6 @@ dependencies # vcpkg vcpkg_installed/ + +# Compile commands generated by CMake +compile_commands.json diff --git a/BUILDING_ON_WINDOWS.md b/BUILDING_ON_WINDOWS.md index ef16c1457..11de371d4 100644 --- a/BUILDING_ON_WINDOWS.md +++ b/BUILDING_ON_WINDOWS.md @@ -1,19 +1,50 @@ # Building on Windows -**Note that installing all of the development prerequisites and libraries will require about 30 GB of free disk space. Please ensure this space is available on your `C:` drive before proceeding.** +**Note that installing all of the development prerequisites and libraries will require about 40 GB of free disk space. Please ensure this space is available on your `C:` drive before proceeding.** This guide assumes you are on a 64-bit system. You might need to manually search out alternate download links should you desire to build Chatterino on a 32-bit system. -## Visual Studio 2022 +## Installing prerequisites + +### Visual Studio Download and install [Visual Studio 2022 Community](https://visualstudio.microsoft.com/downloads/). In the installer, select "Desktop development with C++" and "Universal Windows Platform development". Notes: -- This installation will take about 17 GB of disk space +- This installation will take about 21 GB of disk space - You do not need to sign in with a Microsoft account after setup completes. You may simply exit the login dialog. -## Boost +### Qt + +1. Visit the [Qt Open Source Page](https://www.qt.io/download-open-source). +2. Scroll down to the bottom +3. Then select "Download the Qt Online Installer" + +Notes: + +- Installing the latest **stable** Qt version is advised for new installations, but if you want to use your existing installation please ensure you are running **Qt 5.12 or later**. + +#### When prompted which components to install: + +1. Unfold the tree element that says "Qt" +2. Unfold the top most tree element (latest stable Qt version, e.g. `Qt 5.15.2`) +3. Under this version, select the following entries: + - `MSVC 2019 64-bit` (or alternative version if you are using that) + - `Qt WebEngine` (optional) +4. Under the "Tools" tree element (at the bottom), ensure that `Qt Creator X.X.X` and `Debugging Tools for Windows` are selected. (they should be checked by default) +5. Continue through the installer and let the installer finish installing Qt. + +Note: This installation will take about 2 GB of disk space. + +Once Qt is done installing, make sure you add its bin directory to your `PATH` (e.g. `C:\Qt\5.15.2\msvc2019_64\bin`) + +### Advanced dependencies + +These dependencies are only required if you are not using a package manager + +
+Boost 1. First, download a boost installer appropriate for your version of Visual Studio. @@ -29,7 +60,10 @@ Notes: Note: This installation will take about 2.1 GB of disk space. -## OpenSSL +
+ +
+OpenSSL ### For our websocket library, we need OpenSSL 1.1 @@ -37,58 +71,50 @@ Note: This installation will take about 2.1 GB of disk space. 2. When prompted, install OpenSSL to `C:\local\openssl` 3. When prompted, copy the OpenSSL DLLs to "The OpenSSL binaries (/bin) directory". -### For Qt SSL, we need OpenSSL 1.0 - -1. Download OpenSSL for Windows, version `1.0.2u`: **[Download](https://web.archive.org/web/20211109231823/https://slproweb.com/download/Win64OpenSSL-1_0_2u.exe)** -2. When prompted, install it to any arbitrary empty directory. -3. When prompted, copy the OpenSSL DLLs to "The OpenSSL binaries (/bin) directory". -4. Copy the OpenSSL 1.0 files from its `\bin` folder to `C:\local\bin` (You will need to create the folder) -5. Then copy the OpenSSL 1.1 files from its `\bin` folder to `C:\local\bin` (Overwrite any duplicate files) -6. Add `C:\local\bin` to your path folder ([Follow the guide here if you don't know how to do it](https://www.computerhope.com/issues/ch000549.htm#windows10)) - -**If the 1.1.x download link above does not work, try downloading the similar 1.1.x version found [here](https://slproweb.com/products/Win32OpenSSL.html). Note: Don't download the "light" installer, it does not have the required files.** -![Screenshot Slproweb layout](https://user-images.githubusercontent.com/41973452/175827529-97802939-5549-4ab1-95c4-d39f012d06e9.png) - Note: This installation will take about 200 MB of disk space. -## Qt +
-1. Visit the [Qt Open Source Page](https://www.qt.io/download-open-source). -2. Scroll down to the bottom -3. Then select "Download the Qt Online Installer" +## Building -Notes: +### Using CMake -- Installing the latest **stable** Qt version is advised for new installations, but if you want to use your existing installation please ensure you are running **Qt 5.12 or later**. +#### Install conan -### When prompted which components to install: +Install [conan](https://conan.io/downloads.html) and make sure it's in your `PATH` (default setting). -1. Unfold the tree element that says "Qt" -2. Unfold the top most tree element (latest stable Qt version, e.g. `Qt 5.15.2`) -3. Under this version, select the following entries: - - `MSVC 2019 64-bit` (or alternative version if you are using that) - - `Qt WebEngine` (optional) -4. Under the "Tools" tree element (at the bottom), ensure that `Qt Creator X.X.X` and `Debugging Tools for Windows` are selected. (they should be checked by default) -5. Continue through the installer and let the installer finish installing Qt. +Then in a terminal, configure conan to use `NMake Makefiles` as its generator: -Note: This installation will take about 2 GB of disk space. +1. Generate a new profile + `conan profile new --detect --force default` +1. Configure the profile to use `NMake Makefiles` as its generator + `conan profile update conf.tools.cmake.cmaketoolchain:generator="NMake Makefiles" default` -## Compile with Breakpad support (Optional) +#### Build -Compiling with Breakpad support enables crash reports that can be of use for developing/beta versions of Chatterino. If you have no interest in reporting crashes anyways, this optional dependency will probably be of no use to you. +Open up your terminal with the Visual Studio environment variables (e.g. `x64 Native Tools Command Prompt for VS 2022`), cd to the cloned c2 directory and run the following commands: -1. Open up `lib/qBreakpad/handler/handler.pro`in Qt Creator -2. Build it in whichever mode you want to build Chatterino in (Debug/Profile/Release) -3. Copy the newly built `qBreakpad.lib` to the following directory: `lib/qBreakpad/build/handler` (You will have to manually create this directory) +1. `mkdir build` +1. `cd build` +1. `conan install .. -s build_type=Release --build=missing` +1. `cmake -G"NMake Makefiles" -DCMAKE_BUILD_TYPE=Release -DCMAKE_TOOLCHAIN_FILE="conan_toolchain.cmake" -DCMAKE_PREFIX_PATH="C:\Qt\5.15.2\msvc2019_64" ..` +1. `nmake` -## Run the build in Qt Creator +#### Ensure DLLs are available + +Once Chatterino has finished building, to ensure all .dll's are available you can run this from the build directory: +`windeployqt bin/chatterino.exe --release --no-compiler-runtime --no-translations --no-opengl-sw --dir bin/` + +Can't find windeployqt? You forgot to add your Qt bin directory (e.g. `C:\Qt\5.15.2\msvc2019_64\bin`) to your `PATH` + +### Run the build in Qt Creator 1. Open the `CMakeLists.txt` file by double-clicking it, or by opening it via Qt Creator. 2. You will be presented with a screen that is titled "Configure Project". In this screen, you should have at least one option present ready to be configured, like this: ![Qt Create Configure Project screenshot](https://user-images.githubusercontent.com/69117321/169887645-2ae0871a-fe8a-4eb9-98db-7b996dea3a54.png) 3. Select the profile(s) you want to build with and click "Configure Project". -### How to run and produce builds +#### How to run and produce builds - In the main screen, click the green "play symbol" on the bottom left to run the project directly. - Click the hammer on the bottom left to generate a build (does not run the build though). @@ -98,7 +124,7 @@ Build results will be placed in a folder at the same level as the "chatterino2" - Note that if you are building chatterino purely for usage, not for development, it is recommended that you click the "PC" icon above the play icon and select "Release" instead of "Debug". - Output and error messages produced by the compiler can be seen under the "4 Compile Output" tab in Qt Creator. -## Producing standalone builds +#### Producing standalone builds If you build chatterino, the result directories will contain a `chatterino.exe` file in the `$OUTPUTDIR\release\` directory. This `.exe` file will not directly run on any given target system, because it will be lacking various Qt runtimes. @@ -114,27 +140,11 @@ To produce all supplement files for a standalone build, follow these steps (adju cd C:\Users\example\src\build-chatterino-Desktop_Qt_5_15_2_MSVC2019_64bit-Release\release C:\Qt\5.15.2\msvc2019_64\bin\windeployqt.exe chatterino.exe -5. Go to `C:\local\bin\` and copy these dll's into your `release folder`. - - libssl-1_1-x64.dll - libcrypto-1_1-x64.dll - ssleay32.dll - libeay32.dll - -6. The `releases` directory will now be populated with all the required files to make the chatterino build standalone. +5. The `releases` directory will now be populated with all the required files to make the chatterino build standalone. You can now create a zip archive of all the contents in `releases` and distribute the program as is, without requiring any development tools to be present on the target system. (However, the vcredist package must be present, as usual - see the [README](README.md)). -## Using CMake - -Open up your terminal with the Visual Studio environment variables, then enter the following commands: - -1. `mkdir build` -2. `cd build` -3. `cmake -G"NMake Makefiles" -DCMAKE_BUILD_TYPE=Release -DCMAKE_TOOLCHAIN_FILE="conan_toolchain.cmake" ..` -4. `nmake` - -## Building on MSVC with AddressSanitizer +### Building on MSVC with AddressSanitizer Make sure you installed `C++ AddressSanitizer` in your VisualStudio installation like described in the [Microsoft Docs](https://learn.microsoft.com/en-us/cpp/sanitizers/asan#install-the-addresssanitizer). @@ -145,7 +155,7 @@ copy the file found in `\VC\Tools\MSVC\ To learn more about AddressSanitizer and MSVC, visit the [Microsoft Docs](https://learn.microsoft.com/en-us/cpp/sanitizers/asan). -## Building/Running in CLion +### Building/Running in CLion _Note:_ We're using `build` instead of the CLion default `cmake-build-debug` folder. @@ -200,7 +210,7 @@ Now you can run the `chatterino | Debug` configuration. If you want to run the portable version of Chatterino, create a file called `modes` inside of `build/bin` and write `portable` into it. -### Debugging +#### Debugging To visualize QT types like `QString`, you need to inform CLion and LLDB about these types. diff --git a/BUILDING_ON_WINDOWS_WITH_VCPKG.md b/BUILDING_ON_WINDOWS_WITH_VCPKG.md index 4bfac943d..eea127ac3 100644 --- a/BUILDING_ON_WINDOWS_WITH_VCPKG.md +++ b/BUILDING_ON_WINDOWS_WITH_VCPKG.md @@ -1,28 +1,38 @@ # Building on Windows with vcpkg +This will require more than 30GB of free space on your hard drive. + ## Prerequisites -1. Install [Visual Studio](https://visualstudio.microsoft.com/) with "Desktop development with C++" (~9.66 GB) -1. Install [CMake](https://cmake.org/) (~109 MB) -1. Install [git](https://git-scm.com/) (~264 MB) -1. Install [vcpkg](https://vcpkg.io/) (~80 MB) +1. Install [Visual Studio](https://visualstudio.microsoft.com/) with "Desktop development with C++" +1. Install [CMake](https://cmake.org/) +1. Install [git](https://git-scm.com/) +1. Install [vcpkg](https://vcpkg.io/) - `git clone https://github.com/Microsoft/vcpkg.git` - `cd .\vcpkg\` - `.\bootstrap-vcpkg.bat` - `.\vcpkg integrate install` - `.\vcpkg integrate powershell` - `cd ..` -1. Configure the environment for vcpkg - - `set VCPKG_DEFAULT_TRIPLET=x64-windows` - - [default](https://github.com/microsoft/vcpkg/blob/master/docs/users/triplets.md#additional-remarks) is `x86-windows` - - `set VCPKG_ROOT=C:\path\to\vcpkg\` - - `set PATH=%PATH%;%VCPKG_ROOT%` +1. Configure the environment variables for vcpkg. + Check [this document](https://gist.github.com/mitchmindtree/92c8e37fa80c8dddee5b94fc88d1288b#setting-an-environment-variable-on-windows) for more information for how to set environment variables on Windows. + - Ensure your dependencies are built as 64-bit + e.g. `setx VCPKG_DEFAULT_TRIPLET x64-windows` + See [documentation about Triplets](https://learn.microsoft.com/en-gb/vcpkg/users/triplets) + [default](https://github.com/microsoft/vcpkg/blob/master/docs/users/triplets.md#additional-remarks) is `x86-windows` + - Set VCPKG_ROOT to the vcpkg path + e.g. `setx VCPKG_ROOT ` + See [VCPKG_ROOT documentation](https://learn.microsoft.com/en-gb/vcpkg/users/config-environment#vcpkg_root) + - Append the vcpkg path to your path + e.g. `setx PATH "%PATH%;"` + - For more configurations, see https://learn.microsoft.com/en-gb/vcpkg/users/config-environment +1. You may need to restart your computer to ensure all your environment variables and what-not are loaded everywhere. ## Building 1. Clone - `git clone --recurse-submodules https://github.com/Chatterino/chatterino2.git` -1. Install dependencies (~21 GB) +1. Install dependencies - `cd .\chatterino2\` - `vcpkg install` 1. Build From 4a26c87d5d8bb195cc6b6933d397ac4026acc96e Mon Sep 17 00:00:00 2001 From: Mm2PL Date: Sat, 4 Mar 2023 11:49:06 +0000 Subject: [PATCH 08/24] Add cppcoreguidelines-pro-type-member-init back to clang-tidy checks. (#4425) Uninitialized pointers no more! --- .clang-tidy | 1 - 1 file changed, 1 deletion(-) diff --git a/.clang-tidy b/.clang-tidy index e1d6bfca6..b701ee666 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -16,7 +16,6 @@ Checks: "-*, -cppcoreguidelines-pro-type-cstyle-cast, -cppcoreguidelines-pro-bounds-pointer-arithmetic, -cppcoreguidelines-pro-bounds-array-to-pointer-decay, - -cppcoreguidelines-pro-type-member-init, -cppcoreguidelines-owning-memory, -cppcoreguidelines-avoid-magic-numbers, -readability-magic-numbers, From 1dc2bcc445d50378ed6c6664cf009373cbf7b0a7 Mon Sep 17 00:00:00 2001 From: pajlada Date: Sat, 4 Mar 2023 13:35:48 +0100 Subject: [PATCH 09/24] Install conan v1.58.0 (#4427) --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 96aa677b1..8a1eca01d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -129,7 +129,7 @@ jobs: - name: Install dependencies (Windows) if: startsWith(matrix.os, 'windows') run: | - choco install conan -y + choco install conan -y --version 1.58.0 - name: Enable Developer Command Prompt if: startsWith(matrix.os, 'windows') From b9e87dcd2b1d3011c5d22de3f5652a40dcfda8c0 Mon Sep 17 00:00:00 2001 From: Daniel Sage <24928223+dnsge@users.noreply.github.com> Date: Sat, 4 Mar 2023 19:34:36 -0500 Subject: [PATCH 10/24] Fix Handling of FFZ CDN URLs with https already appended (#4432) --- CHANGELOG.md | 1 + src/CMakeLists.txt | 2 ++ src/providers/ffz/FfzBadges.cpp | 15 +++++++-------- src/providers/ffz/FfzEmotes.cpp | 4 +++- src/providers/ffz/FfzUtil.cpp | 12 ++++++++++++ src/providers/ffz/FfzUtil.hpp | 12 ++++++++++++ 6 files changed, 37 insertions(+), 9 deletions(-) create mode 100644 src/providers/ffz/FfzUtil.cpp create mode 100644 src/providers/ffz/FfzUtil.hpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 36505727d..445033467 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Minor: Delete all but the last 5 crashdumps on application start. (#4392) - Minor: Added `/banid` command that allows banning by user ID. (#4411) +- Bugfix: Fixed FrankerFaceZ emotes/badges not loading due to API change. (#4432) - Bugfix: Fixed uploaded AppImage not being able most web requests. (#4400) - Bugfix: Fixed a potential race condition due to using the wrong lock when loading 7TV badges. (#4402) - Dev: Add capability to build Chatterino with Qt6. (#4393) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index fd705c125..0c53153b7 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -225,6 +225,8 @@ set(SOURCE_FILES providers/ffz/FfzBadges.hpp providers/ffz/FfzEmotes.cpp providers/ffz/FfzEmotes.hpp + providers/ffz/FfzUtil.cpp + providers/ffz/FfzUtil.hpp providers/irc/AbstractIrcServer.cpp providers/irc/AbstractIrcServer.hpp diff --git a/src/providers/ffz/FfzBadges.cpp b/src/providers/ffz/FfzBadges.cpp index 7407c8603..ebeaabf16 100644 --- a/src/providers/ffz/FfzBadges.cpp +++ b/src/providers/ffz/FfzBadges.cpp @@ -4,6 +4,7 @@ #include "common/NetworkResult.hpp" #include "common/Outcome.hpp" #include "messages/Emote.hpp" +#include "providers/ffz/FfzUtil.hpp" #include #include @@ -67,14 +68,12 @@ void FfzBadges::load() auto jsonBadge = jsonBadge_.toObject(); auto jsonUrls = jsonBadge.value("urls").toObject(); - auto emote = Emote{ - EmoteName{}, - ImageSet{ - Url{QString("https:") + jsonUrls.value("1").toString()}, - Url{QString("https:") + jsonUrls.value("2").toString()}, - Url{QString("https:") + - jsonUrls.value("4").toString()}}, - Tooltip{jsonBadge.value("title").toString()}, Url{}}; + auto emote = + Emote{EmoteName{}, + ImageSet{parseFfzUrl(jsonUrls.value("1").toString()), + parseFfzUrl(jsonUrls.value("2").toString()), + parseFfzUrl(jsonUrls.value("4").toString())}, + Tooltip{jsonBadge.value("title").toString()}, Url{}}; Badge badge; diff --git a/src/providers/ffz/FfzEmotes.cpp b/src/providers/ffz/FfzEmotes.cpp index 0cf533e5b..5591f9d51 100644 --- a/src/providers/ffz/FfzEmotes.cpp +++ b/src/providers/ffz/FfzEmotes.cpp @@ -7,6 +7,7 @@ #include "messages/Emote.hpp" #include "messages/Image.hpp" #include "messages/MessageBuilder.hpp" +#include "providers/ffz/FfzUtil.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "singletons/Settings.hpp" @@ -26,8 +27,9 @@ namespace { assert(emote.isString()); - return {"https:" + emote.toString()}; + return parseFfzUrl(emote.toString()); } + void fillInEmoteData(const QJsonObject &urls, const EmoteName &name, const QString &tooltip, Emote &emoteData) { diff --git a/src/providers/ffz/FfzUtil.cpp b/src/providers/ffz/FfzUtil.cpp new file mode 100644 index 000000000..762683a28 --- /dev/null +++ b/src/providers/ffz/FfzUtil.cpp @@ -0,0 +1,12 @@ +#include "providers/ffz/FfzUtil.hpp" + +namespace chatterino { + +Url parseFfzUrl(const QString &ffzUrl) +{ + QUrl asURL(ffzUrl); + asURL.setScheme("https"); + return {asURL.toString()}; +} + +} // namespace chatterino diff --git a/src/providers/ffz/FfzUtil.hpp b/src/providers/ffz/FfzUtil.hpp new file mode 100644 index 000000000..1d4ef65c3 --- /dev/null +++ b/src/providers/ffz/FfzUtil.hpp @@ -0,0 +1,12 @@ +#pragma once + +#include "common/Aliases.hpp" + +#include +#include + +namespace chatterino { + +Url parseFfzUrl(const QString &ffzUrl); + +} // namespace chatterino From c8204ef7e4d799c77d98a168be7a5986b1fc082d Mon Sep 17 00:00:00 2001 From: Felanbird <41973452+Felanbird@users.noreply.github.com> Date: Mon, 6 Mar 2023 03:13:25 -0500 Subject: [PATCH 11/24] Release v2.4.2 (#4433) * Update `CMakeLists.txt` * Update `resources/com.chatterino.chatterino.appdata.xml` * Update `src/common/Version.hpp` * Update `CHANGELOG.md` This includes changelog re-ordering changes that would normally be in a seperate PR, but this release will be an exception to that rule --- CHANGELOG.md | 12 +++++++----- CMakeLists.txt | 2 +- resources/com.chatterino.chatterino.appdata.xml | 2 +- src/common/Version.hpp | 2 +- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 445033467..fcb306f34 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,15 @@ ## Unversioned -- Minor: Delete all but the last 5 crashdumps on application start. (#4392) +## 2.4.2 + - Minor: Added `/banid` command that allows banning by user ID. (#4411) -- Bugfix: Fixed FrankerFaceZ emotes/badges not loading due to API change. (#4432) -- Bugfix: Fixed uploaded AppImage not being able most web requests. (#4400) +- Bugfix: Fixed FrankerFaceZ emotes/badges not loading due to an API change. (#4432) +- Bugfix: Fixed uploaded AppImage not being able to execute most web requests. (#4400) - Bugfix: Fixed a potential race condition due to using the wrong lock when loading 7TV badges. (#4402) -- Dev: Add capability to build Chatterino with Qt6. (#4393) -- Dev: Fix homebrew update action. (#4394) +- Dev: Delete all but the last 5 crashdumps on application start. (#4392) +- Dev: Added capability to build Chatterino with Qt6. (#4393) +- Dev: Fixed homebrew update action. (#4394) ## 2.4.1 diff --git a/CMakeLists.txt b/CMakeLists.txt index 68e07ca33..cdaa5719f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,7 +8,7 @@ list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake/sanitizers-cmake/cmake" ) -project(chatterino VERSION 2.4.1) +project(chatterino VERSION 2.4.2) option(BUILD_APP "Build Chatterino" ON) option(BUILD_TESTS "Build the tests for Chatterino" OFF) diff --git a/resources/com.chatterino.chatterino.appdata.xml b/resources/com.chatterino.chatterino.appdata.xml index fec7a8f46..3f0646c2f 100644 --- a/resources/com.chatterino.chatterino.appdata.xml +++ b/resources/com.chatterino.chatterino.appdata.xml @@ -32,6 +32,6 @@ chatterino - + diff --git a/src/common/Version.hpp b/src/common/Version.hpp index 55d8d167f..b72a990ba 100644 --- a/src/common/Version.hpp +++ b/src/common/Version.hpp @@ -24,7 +24,7 @@ * - 2.4.0-alpha.2 * - 2.4.0-alpha **/ -#define CHATTERINO_VERSION "2.4.1" +#define CHATTERINO_VERSION "2.4.2" #if defined(Q_OS_WIN) # define CHATTERINO_OS "win" From 01a4861d7602b75b1f553f3adc9e6e622761c089 Mon Sep 17 00:00:00 2001 From: Sheepposu <49356627+Sheepposu@users.noreply.github.com> Date: Mon, 6 Mar 2023 14:12:42 -0600 Subject: [PATCH 12/24] Use an archive link for OpenSSL 1.1.1s in BUILDING_ON_WINDOWS.md (#4404) --- BUILDING_ON_WINDOWS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUILDING_ON_WINDOWS.md b/BUILDING_ON_WINDOWS.md index 11de371d4..cd89e4426 100644 --- a/BUILDING_ON_WINDOWS.md +++ b/BUILDING_ON_WINDOWS.md @@ -67,7 +67,7 @@ Note: This installation will take about 2.1 GB of disk space. ### For our websocket library, we need OpenSSL 1.1 -1. Download OpenSSL for windows, version `1.1.1s`: **[Download](https://slproweb.com/download/Win64OpenSSL-1_1_1s.exe)** +1. Download OpenSSL for windows, version `1.1.1s`: **[Download](https://web.archive.org/web/20221101204129/https://slproweb.com/download/Win64OpenSSL-1_1_1s.exe)** 2. When prompted, install OpenSSL to `C:\local\openssl` 3. When prompted, copy the OpenSSL DLLs to "The OpenSSL binaries (/bin) directory". From 3809fd10752dc8364f9f3fff0455e0138d9a26b2 Mon Sep 17 00:00:00 2001 From: llyyr Date: Wed, 15 Mar 2023 19:31:45 +0530 Subject: [PATCH 13/24] Only log debug messages when NDEBUG is not defined (#4442) --- CHANGELOG.md | 2 ++ src/common/QLogging.cpp | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fcb306f34..48f204b88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unversioned +- Dev: Only log debug messages when NDEBUG is not defined. (#4442) + ## 2.4.2 - Minor: Added `/banid` command that allows banning by user ID. (#4411) diff --git a/src/common/QLogging.cpp b/src/common/QLogging.cpp index 473d5e4d7..de902b18a 100644 --- a/src/common/QLogging.cpp +++ b/src/common/QLogging.cpp @@ -1,6 +1,6 @@ #include "common/QLogging.hpp" -#ifdef DEBUG_OFF +#ifdef NDEBUG static constexpr QtMsgType logThreshold = QtWarningMsg; #else static constexpr QtMsgType logThreshold = QtDebugMsg; From e6e21269a0a1160f75914ad5fa7ef9d4c839dd6b Mon Sep 17 00:00:00 2001 From: kyle <98747393+03y@users.noreply.github.com> Date: Thu, 16 Mar 2023 19:30:23 +0000 Subject: [PATCH 14/24] Update Debian dependencies to include libsecret (#4451) --- BUILDING_ON_LINUX.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUILDING_ON_LINUX.md b/BUILDING_ON_LINUX.md index 5f80b2d39..95e89c84b 100644 --- a/BUILDING_ON_LINUX.md +++ b/BUILDING_ON_LINUX.md @@ -8,7 +8,7 @@ Note on Qt version compatibility: If you are installing Qt from a package manage _Most likely works the same for other Debian-like distros_ -Install all of the dependencies using `sudo apt install qttools5-dev qt5-image-formats-plugins libqt5svg5-dev libboost-dev libssl-dev libboost-system-dev libboost-filesystem-dev cmake g++` +Install all of the dependencies using `sudo apt install qttools5-dev qt5-image-formats-plugins libqt5svg5-dev libboost-dev libssl-dev libboost-system-dev libboost-filesystem-dev cmake g++ libsecret-1-dev` ### Arch Linux From 85b982bb699290c96306887defc4b27cb672c857 Mon Sep 17 00:00:00 2001 From: kyle <98747393+03y@users.noreply.github.com> Date: Thu, 16 Mar 2023 23:15:32 +0000 Subject: [PATCH 15/24] Add 03y to contributors list (#4452) --- resources/contributors.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/contributors.txt b/resources/contributors.txt index 0870ae2fa..035bd2bf2 100644 --- a/resources/contributors.txt +++ b/resources/contributors.txt @@ -57,6 +57,7 @@ Jaxkey | https://github.com/Jaxkey | :/avatars/jaxkey.png | Contributor Explooosion | https://github.com/Explooosion-code | :/avatars/explooosion_code.png | Contributor mohad12211 | https://github.com/mohad12211 | :/avatars/mohad12211.png | Contributor Wissididom | https://github.com/Wissididom | :/avatars/wissididom.png | Contributor +03y | https://github.com/03y | | Contributor # If you are a contributor add yourself above this line From 93a9e41d31dc8cbc838bdb7857f26cc36933e6d8 Mon Sep 17 00:00:00 2001 From: nerix Date: Fri, 17 Mar 2023 20:53:03 +0100 Subject: [PATCH 16/24] Cleanup `Theme`-related Code (#4450) --- CHANGELOG.md | 1 + src/messages/layouts/MessageLayout.cpp | 7 +- src/singletons/Theme.cpp | 251 ++++++------------ src/singletons/Theme.hpp | 35 +-- .../dialogs/switcher/QuickSwitcherPopup.cpp | 2 +- src/widgets/helper/NotebookButton.cpp | 4 +- src/widgets/listview/GenericListView.cpp | 2 +- src/widgets/splits/SplitContainer.cpp | 7 +- 8 files changed, 94 insertions(+), 215 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48f204b88..d8a52f28c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unversioned - Dev: Only log debug messages when NDEBUG is not defined. (#4442) +- Dev: Cleaned up theme related code. (#4450) ## 2.4.2 diff --git a/src/messages/layouts/MessageLayout.cpp b/src/messages/layouts/MessageLayout.cpp index d00d092e4..9e39c557a 100644 --- a/src/messages/layouts/MessageLayout.cpp +++ b/src/messages/layouts/MessageLayout.cpp @@ -282,10 +282,9 @@ void MessageLayout::paint(QPainter &painter, int width, int y, int messageIndex, } else { - color = - isWindowFocused - ? app->themes->tabs.selected.backgrounds.regular.color() - : app->themes->tabs.selected.backgrounds.unfocused.color(); + color = isWindowFocused + ? app->themes->tabs.selected.backgrounds.regular + : app->themes->tabs.selected.backgrounds.unfocused; } QBrush brush(color, static_cast( diff --git a/src/singletons/Theme.cpp b/src/singletons/Theme.cpp index e13df1cd2..19f44bf27 100644 --- a/src/singletons/Theme.cpp +++ b/src/singletons/Theme.cpp @@ -8,8 +8,6 @@ #include -#define LOOKUP_COLOR_COUNT 360 - namespace { double getMultiplierByTheme(const QString &themeName) { @@ -17,26 +15,20 @@ double getMultiplierByTheme(const QString &themeName) { return 0.8; } - else if (themeName == "White") + if (themeName == "White") { return 1.0; } - else if (themeName == "Black") + if (themeName == "Black") { return -1.0; } - else if (themeName == "Dark") + if (themeName == "Dark") { return -0.8; } - /* - else if (themeName == "Custom") - { - return getSettings()->customThemeMultiplier.getValue(); - } - */ - return -0.8; + return -0.8; // default: Dark } } // namespace @@ -47,16 +39,6 @@ bool Theme::isLightTheme() const return this->isLight_; } -QColor Theme::blendColors(const QColor &color1, const QColor &color2, - qreal ratio) -{ - int r = int(color1.red() * (1 - ratio) + color2.red() * ratio); - int g = int(color1.green() * (1 - ratio) + color2.green() * ratio); - int b = int(color1.blue() * (1 - ratio) + color2.blue() * ratio); - - return QColor(r, g, b, 255); -} - Theme::Theme() { this->update(); @@ -66,149 +48,84 @@ Theme::Theme() this->update(); }, false); - this->themeHue.connectSimple( - [this](auto) { - this->update(); - }, - false); } void Theme::update() { - this->actuallyUpdate(this->themeHue, - getMultiplierByTheme(this->themeName.getValue())); + this->actuallyUpdate(getMultiplierByTheme(this->themeName.getValue())); this->updated.invoke(); } -// hue: theme color (0 - 1) // multiplier: 1 = white, 0.8 = light, -0.8 dark, -1 black -void Theme::actuallyUpdate(double hue, double multiplier) +void Theme::actuallyUpdate(double multiplier) { this->isLight_ = multiplier > 0; - bool lightWin = isLight_; - // QColor themeColor = QColor::fromHslF(hue, 0.43, 0.5); - QColor themeColor = QColor::fromHslF(hue, 0.8, 0.5); - QColor themeColorNoSat = QColor::fromHslF(hue, 0, 0.5); - - const auto sat = qreal(0); const auto isLight = this->isLightTheme(); - const auto flat = isLight; - auto getColor = [multiplier](double h, double s, double l, double a = 1.0) { - return QColor::fromHslF(h, s, ((l - 0.5) * multiplier) + 0.5, a); + auto getGray = [multiplier](double l, double a = 1.0) { + return QColor::fromHslF(0, 0, ((l - 0.5) * multiplier) + 0.5, a); }; /// WINDOW - { #ifdef Q_OS_LINUX - this->window.background = lightWin ? "#fff" : QColor(61, 60, 56); + this->window.background = isLight ? "#fff" : QColor(61, 60, 56); #else - this->window.background = lightWin ? "#fff" : "#111"; + this->window.background = isLight ? "#fff" : "#111"; #endif + this->window.text = isLight ? "#000" : "#eee"; - QColor fg = this->window.text = lightWin ? "#000" : "#eee"; - this->window.borderFocused = lightWin ? "#ccc" : themeColor; - this->window.borderUnfocused = lightWin ? "#ccc" : themeColorNoSat; - - // Ubuntu style - // TODO: add setting for this - // TabText = QColor(210, 210, 210); - // TabBackground = QColor(61, 60, 56); - // TabHoverText = QColor(210, 210, 210); - // TabHoverBackground = QColor(73, 72, 68); - - // message (referenced later) - this->messages.textColors.caret = // - this->messages.textColors.regular = isLight_ ? "#000" : "#fff"; - - QColor highlighted = lightWin ? QColor("#ff0000") : QColor("#ee6166"); - - /// TABS - if (lightWin) - { - this->tabs.regular = { - QColor("#444"), - {QColor("#fff"), QColor("#eee"), QColor("#fff")}, - {QColor("#fff"), QColor("#fff"), QColor("#fff")}}; - this->tabs.newMessage = { - QColor("#222"), - {QColor("#fff"), QColor("#eee"), QColor("#fff")}, - {QColor("#bbb"), QColor("#bbb"), QColor("#bbb")}}; - this->tabs.highlighted = { - fg, - {QColor("#fff"), QColor("#eee"), QColor("#fff")}, - {highlighted, highlighted, highlighted}}; - this->tabs.selected = { - QColor("#000"), - {QColor("#b4d7ff"), QColor("#b4d7ff"), QColor("#b4d7ff")}, - {this->accent, this->accent, this->accent}}; - } - else - { - this->tabs.regular = { - QColor("#aaa"), - {QColor("#252525"), QColor("#252525"), QColor("#252525")}, - {QColor("#444"), QColor("#444"), QColor("#444")}}; - this->tabs.newMessage = { - fg, - {QColor("#252525"), QColor("#252525"), QColor("#252525")}, - {QColor("#888"), QColor("#888"), QColor("#888")}}; - this->tabs.highlighted = { - fg, - {QColor("#252525"), QColor("#252525"), QColor("#252525")}, - {highlighted, highlighted, highlighted}}; - - this->tabs.selected = { - QColor("#fff"), - {QColor("#555555"), QColor("#555555"), QColor("#555555")}, - {this->accent, this->accent, this->accent}}; - } - - // scrollbar - this->scrollbars.highlights.highlight = QColor("#ee6166"); - this->scrollbars.highlights.subscription = QColor("#C466FF"); - - // this->tabs.newMessage = { - // fg, - // {QBrush(blendColors(themeColor, "#ccc", 0.9), Qt::FDiagPattern), - // QBrush(blendColors(themeColor, "#ccc", 0.9), Qt::FDiagPattern), - // QBrush(blendColors(themeColorNoSat, "#ccc", 0.9), - // Qt::FDiagPattern)}}; - - // this->tabs.newMessage = { - // fg, - // {QBrush(blendColors(themeColor, "#666", 0.7), - // Qt::FDiagPattern), - // QBrush(blendColors(themeColor, "#666", 0.5), - // Qt::FDiagPattern), - // QBrush(blendColors(themeColorNoSat, "#666", 0.7), - // Qt::FDiagPattern)}}; - // this->tabs.highlighted = {fg, {QColor("#777"), - // QColor("#777"), QColor("#666")}}; - - this->tabs.dividerLine = - this->tabs.selected.backgrounds.regular.color(); + /// TABSs + if (isLight) + { + this->tabs.regular = {.text = "#444", + .backgrounds = {"#fff", "#eee", "#fff"}, + .line = {"#fff", "#fff", "#fff"}}; + this->tabs.newMessage = {.text = "#222", + .backgrounds = {"#fff", "#eee", "#fff"}, + .line = {"#bbb", "#bbb", "#bbb"}}; + this->tabs.highlighted = {.text = "#000", + .backgrounds = {"#fff", "#eee", "#fff"}, + .line = {"#f00", "#f00", "#f00"}}; + this->tabs.selected = { + .text = "#000", + .backgrounds = {"#b4d7ff", "#b4d7ff", "#b4d7ff"}, + .line = {this->accent, this->accent, this->accent}}; + } + else + { + this->tabs.regular = {.text = "#aaa", + .backgrounds{"#252525", "#252525", "#252525"}, + .line = {"#444", "#444", "#444"}}; + this->tabs.newMessage = {.text = "#eee", + .backgrounds{"#252525", "#252525", "#252525"}, + .line = {"#888", "#888", "#888"}}; + this->tabs.highlighted = {.text = "#eee", + .backgrounds{"#252525", "#252525", "#252525"}, + .line = {"#ee6166", "#ee6166", "#ee6166"}}; + this->tabs.selected = { + .text = "#fff", + .backgrounds{"#555", "#555", "#555"}, + .line = {this->accent, this->accent, this->accent}}; } + this->tabs.dividerLine = this->tabs.selected.backgrounds.regular; + // Message - this->messages.textColors.link = - isLight_ ? QColor(66, 134, 244) : QColor(66, 134, 244); + this->messages.textColors.caret = isLight ? "#000" : "#fff"; + this->messages.textColors.regular = isLight ? "#000" : "#fff"; + this->messages.textColors.link = QColor(66, 134, 244); this->messages.textColors.system = QColor(140, 127, 127); this->messages.textColors.chatPlaceholder = - isLight_ ? QColor(175, 159, 159) : QColor(93, 85, 85); + isLight ? QColor(175, 159, 159) : QColor(93, 85, 85); - this->messages.backgrounds.regular = getColor(0, sat, 1); - this->messages.backgrounds.alternate = getColor(0, sat, 0.96); + this->messages.backgrounds.regular = getGray(1); + this->messages.backgrounds.alternate = getGray(0.96); - // this->messages.backgrounds.resub - // this->messages.backgrounds.whisper - this->messages.disabled = getColor(0, sat, 1, 0.6); - // this->messages.seperator = - // this->messages.seperatorInner = + this->messages.disabled = getGray(1, 0.6); - int complementaryGray = this->isLightTheme() ? 20 : 230; + int complementaryGray = isLight ? 20 : 230; this->messages.highlightAnimationStart = QColor(complementaryGray, complementaryGray, complementaryGray, 110); this->messages.highlightAnimationEnd = @@ -216,66 +133,52 @@ void Theme::actuallyUpdate(double hue, double multiplier) // Scrollbar this->scrollbars.background = QColor(0, 0, 0, 0); - // this->scrollbars.background = splits.background; - // this->scrollbars.background.setAlphaF(qreal(0.2)); - this->scrollbars.thumb = getColor(0, sat, 0.70); - this->scrollbars.thumbSelected = getColor(0, sat, 0.65); - - // tooltip - this->tooltip.background = QColor(0, 0, 0); - this->tooltip.text = QColor(255, 255, 255); + this->scrollbars.thumb = getGray(0.70); + this->scrollbars.thumbSelected = getGray(0.65); // Selection this->messages.selection = - isLightTheme() ? QColor(0, 0, 0, 64) : QColor(255, 255, 255, 64); + isLight ? QColor(0, 0, 0, 64) : QColor(255, 255, 255, 64); - if (this->isLightTheme()) + // Splits + if (isLight) { - this->splits.dropTargetRect = QColor(255, 255, 255, 0x00); - this->splits.dropTargetRectBorder = QColor(0, 148, 255, 0x00); - - this->splits.resizeHandle = QColor(0, 148, 255, 0xff); - this->splits.resizeHandleBackground = QColor(0, 148, 255, 0x50); + this->splits.dropTargetRect = QColor(255, 255, 255, 0); } else { - this->splits.dropTargetRect = QColor(0, 148, 255, 0x00); - this->splits.dropTargetRectBorder = QColor(0, 148, 255, 0x00); - - this->splits.resizeHandle = QColor(0, 148, 255, 0x70); - this->splits.resizeHandleBackground = QColor(0, 148, 255, 0x20); + this->splits.dropTargetRect = QColor(0, 148, 255, 0); } + this->splits.dropTargetRectBorder = QColor(0, 148, 255, 0); + this->splits.dropPreview = QColor(0, 148, 255, 48); + this->splits.dropPreviewBorder = QColor(0, 148, 255); + this->splits.resizeHandle = QColor(0, 148, 255, isLight ? 255 : 112); + this->splits.resizeHandleBackground = + QColor(0, 148, 255, isLight ? 80 : 32); - this->splits.header.background = getColor(0, sat, flat ? 1 : 0.9); - this->splits.header.border = getColor(0, sat, flat ? 1 : 0.85); + this->splits.header.background = getGray(isLight ? 1 : 0.9); + this->splits.header.border = getGray(isLight ? 1 : 0.85); this->splits.header.text = this->messages.textColors.regular; - this->splits.header.focusedBackground = - getColor(0, sat, isLight ? 0.95 : 0.79); - this->splits.header.focusedBorder = getColor(0, sat, isLight ? 0.90 : 0.78); + this->splits.header.focusedBackground = getGray(isLight ? 0.95 : 0.79); + this->splits.header.focusedBorder = getGray(isLight ? 0.90 : 0.78); this->splits.header.focusedText = QColor::fromHsvF( 0.58388, isLight ? 1.0 : 0.482, isLight ? 0.6375 : 1.0); - this->splits.input.background = getColor(0, sat, flat ? 0.95 : 0.95); - this->splits.input.border = getColor(0, sat, flat ? 1 : 1); + this->splits.input.background = getGray(0.95); this->splits.input.text = this->messages.textColors.regular; this->splits.input.styleSheet = "background:" + this->splits.input.background.name() + ";" + - "border:" + this->tabs.selected.backgrounds.regular.color().name() + - ";" + "color:" + this->messages.textColors.regular.name() + ";" + + "border:" + this->tabs.selected.backgrounds.regular.name() + ";" + + "color:" + this->messages.textColors.regular.name() + ";" + "selection-background-color:" + - (isLight ? "#68B1FF" - : this->tabs.selected.backgrounds.regular.color().name()); - - this->splits.input.focusedLine = this->tabs.highlighted.line.regular; + (isLight ? "#68B1FF" : this->tabs.selected.backgrounds.regular.name()); this->splits.messageSeperator = isLight ? QColor(127, 127, 127) : QColor(60, 60, 60); - this->splits.background = getColor(0, sat, 1); - this->splits.dropPreview = QColor(0, 148, 255, 0x30); - this->splits.dropPreviewBorder = QColor(0, 148, 255, 0xff); + this->splits.background = getGray(1); // Copy button - if (this->isLightTheme()) + if (isLight) { this->buttons.copy = getResources().buttons.copyDark; this->buttons.pin = getResources().buttons.pinDisabledDark; @@ -287,7 +190,7 @@ void Theme::actuallyUpdate(double hue, double multiplier) } } -void Theme::normalizeColor(QColor &color) +void Theme::normalizeColor(QColor &color) const { if (this->isLightTheme()) { diff --git a/src/singletons/Theme.hpp b/src/singletons/Theme.hpp index f99ba169d..fb6690ee6 100644 --- a/src/singletons/Theme.hpp +++ b/src/singletons/Theme.hpp @@ -5,8 +5,8 @@ #include "util/RapidJsonSerializeQString.hpp" #include -#include #include +#include namespace chatterino { @@ -22,9 +22,9 @@ public: struct TabColors { QColor text; struct { - QBrush regular; - QBrush hover; - QBrush unfocused; + QColor regular; + QColor hover; + QColor unfocused; } backgrounds; struct { QColor regular; @@ -39,8 +39,6 @@ public: struct { QColor background; QColor text; - QColor borderUnfocused; - QColor borderFocused; } window; /// TABS @@ -49,7 +47,6 @@ public: TabColors newMessage; TabColors highlighted; TabColors selected; - QColor border; QColor dividerLine; } tabs; @@ -66,12 +63,9 @@ public: struct { QColor regular; QColor alternate; - // QColor whisper; } backgrounds; QColor disabled; - // QColor seperator; - // QColor seperatorInner; QColor selection; QColor highlightAnimationStart; @@ -83,18 +77,8 @@ public: QColor background; QColor thumb; QColor thumbSelected; - struct { - QColor highlight; - QColor subscription; - } highlights; } scrollbars; - /// TOOLTIP - struct { - QColor text; - QColor background; - } tooltip; - /// SPLITS struct { QColor messageSeperator; @@ -113,17 +97,12 @@ public: QColor focusedBackground; QColor text; QColor focusedText; - // int margin; } header; struct { - QColor border; QColor background; - QColor selection; - QColor focusedLine; QColor text; QString styleSheet; - // int margin; } input; } splits; @@ -132,18 +111,16 @@ public: QPixmap pin; } buttons; - void normalizeColor(QColor &color); + void normalizeColor(QColor &color) const; void update(); - QColor blendColors(const QColor &color1, const QColor &color2, qreal ratio); pajlada::Signals::NoArgSignal updated; QStringSetting themeName{"/appearance/theme/name", "Dark"}; - DoubleSetting themeHue{"/appearance/theme/hue", 0.0}; private: bool isLight_ = false; - void actuallyUpdate(double hue, double multiplier); + void actuallyUpdate(double multiplier); pajlada::Signals::NoArgSignal repaintVisibleChatWidgets_; diff --git a/src/widgets/dialogs/switcher/QuickSwitcherPopup.cpp b/src/widgets/dialogs/switcher/QuickSwitcherPopup.cpp index f6e2836d7..44cd48fa8 100644 --- a/src/widgets/dialogs/switcher/QuickSwitcherPopup.cpp +++ b/src/widgets/dialogs/switcher/QuickSwitcherPopup.cpp @@ -149,7 +149,7 @@ void QuickSwitcherPopup::themeChangedEvent() const QString selCol = (this->theme->isLightTheme() ? "#68B1FF" // Copied from Theme::splits.input.styleSheet - : this->theme->tabs.selected.backgrounds.regular.color().name()); + : this->theme->tabs.selected.backgrounds.regular.name()); const QString listStyle = QString( diff --git a/src/widgets/helper/NotebookButton.cpp b/src/widgets/helper/NotebookButton.cpp index 99e655a1f..f6cc67b4d 100644 --- a/src/widgets/helper/NotebookButton.cpp +++ b/src/widgets/helper/NotebookButton.cpp @@ -51,12 +51,12 @@ void NotebookButton::paintEvent(QPaintEvent *event) if (mouseDown_ || mouseOver_) { - background = this->theme->tabs.regular.backgrounds.hover.color(); + background = this->theme->tabs.regular.backgrounds.hover; foreground = this->theme->tabs.regular.text; } else { - background = this->theme->tabs.regular.backgrounds.regular.color(); + background = this->theme->tabs.regular.backgrounds.regular; foreground = this->theme->tabs.regular.text; } diff --git a/src/widgets/listview/GenericListView.cpp b/src/widgets/listview/GenericListView.cpp index d38c7b0ea..d1ff57641 100644 --- a/src/widgets/listview/GenericListView.cpp +++ b/src/widgets/listview/GenericListView.cpp @@ -109,7 +109,7 @@ void GenericListView::refreshTheme(const Theme &theme) const QString selCol = (theme.isLightTheme() ? "#68B1FF" // Copied from Theme::splits.input.styleSheet - : theme.tabs.selected.backgrounds.regular.color().name()); + : theme.tabs.selected.backgrounds.regular.name()); const QString listStyle = QString( diff --git a/src/widgets/splits/SplitContainer.cpp b/src/widgets/splits/SplitContainer.cpp index 2fdc5d0cb..d7de47402 100644 --- a/src/widgets/splits/SplitContainer.cpp +++ b/src/widgets/splits/SplitContainer.cpp @@ -688,10 +688,9 @@ void SplitContainer::paintEvent(QPaintEvent * /*event*/) rect.top() + rect.height() / 2 + (s / 2)); } - QBrush accentColor = - (QApplication::activeWindow() == this->window() - ? this->theme->tabs.selected.backgrounds.regular - : this->theme->tabs.selected.backgrounds.unfocused); + auto accentColor = (QApplication::activeWindow() == this->window() + ? this->theme->tabs.selected.backgrounds.regular + : this->theme->tabs.selected.backgrounds.unfocused); painter.fillRect(0, 0, width(), 1, accentColor); } From d7206a2203ebb16b7d500ccf7181d1ef8d43105f Mon Sep 17 00:00:00 2001 From: nerix Date: Sat, 18 Mar 2023 12:22:40 +0100 Subject: [PATCH 17/24] Support Animated FFZ Emotes and Authors for Global Emotes (#4434) Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 1 + src/providers/ffz/FfzEmotes.cpp | 151 +++++++++++++++----------------- 2 files changed, 70 insertions(+), 82 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8a52f28c..ecc29e6dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unversioned +- Minor: Added support for FrankerFaceZ animated emotes. (#4434) - Dev: Only log debug messages when NDEBUG is not defined. (#4442) - Dev: Cleaned up theme related code. (#4450) diff --git a/src/providers/ffz/FfzEmotes.cpp b/src/providers/ffz/FfzEmotes.cpp index 5591f9d51..180628545 100644 --- a/src/providers/ffz/FfzEmotes.cpp +++ b/src/providers/ffz/FfzEmotes.cpp @@ -19,7 +19,7 @@ namespace { Url getEmoteLink(const QJsonObject &urls, const QString &emoteScale) { - auto emote = urls.value(emoteScale); + auto emote = urls[emoteScale]; if (emote.isUndefined() || emote.isNull()) { return {""}; @@ -47,6 +47,7 @@ namespace { : Image::fromUrl(url3x, 0.25)}; emoteData.tooltip = {tooltip}; } + EmotePtr cachedOrMake(Emote &&emote, const EmoteId &id) { static std::unordered_map> cache; @@ -54,25 +55,57 @@ namespace { return cachedOrMakeEmotePtr(std::move(emote), cache, mutex, id); } - std::pair parseGlobalEmotes( - const QJsonObject &jsonRoot, const EmoteMap ¤tEmotes) + + void parseEmoteSetInto(const QJsonObject &emoteSet, const QString &kind, + EmoteMap &map) + { + for (const auto emoteRef : emoteSet["emoticons"].toArray()) + { + const auto emoteJson = emoteRef.toObject(); + + // margins + auto id = EmoteId{QString::number(emoteJson["id"].toInt())}; + auto name = EmoteName{emoteJson["name"].toString()}; + auto author = + EmoteAuthor{emoteJson["owner"]["display_name"].toString()}; + auto urls = emoteJson["urls"].toObject(); + if (emoteJson["animated"].isObject()) + { + // prefer animated images if available + urls = emoteJson["animated"].toObject(); + } + + Emote emote; + fillInEmoteData(urls, name, + QString("%1
%2 FFZ Emote
By: %3") + .arg(name.string, kind, author.string), + emote); + emote.homePage = + Url{QString("https://www.frankerfacez.com/emoticon/%1-%2") + .arg(id.string) + .arg(name.string)}; + + map[name] = cachedOrMake(std::move(emote), id); + } + } + + EmoteMap parseGlobalEmotes(const QJsonObject &jsonRoot) { // Load default sets from the `default_sets` object std::unordered_set defaultSets{}; - auto jsonDefaultSets = jsonRoot.value("default_sets").toArray(); + auto jsonDefaultSets = jsonRoot["default_sets"].toArray(); for (auto jsonDefaultSet : jsonDefaultSets) { defaultSets.insert(jsonDefaultSet.toInt()); } - auto jsonSets = jsonRoot.value("sets").toObject(); auto emotes = EmoteMap(); - for (auto jsonSet : jsonSets) + for (const auto emoteSetRef : jsonRoot["sets"].toObject()) { - auto jsonSetObject = jsonSet.toObject(); - const auto emoteSetID = jsonSetObject.value("id").toInt(); - if (defaultSets.find(emoteSetID) == defaultSets.end()) + const auto emoteSet = emoteSetRef.toObject(); + auto emoteSetID = emoteSet["id"].toInt(); + if (!defaultSets.contains(emoteSetID)) { qCDebug(chatterinoFfzemotes) << "Skipping global emote set" << emoteSetID @@ -80,35 +113,14 @@ namespace { continue; } - auto jsonEmotes = jsonSetObject.value("emoticons").toArray(); - - for (auto jsonEmoteValue : jsonEmotes) - { - auto jsonEmote = jsonEmoteValue.toObject(); - - auto name = EmoteName{jsonEmote.value("name").toString()}; - auto id = - EmoteId{QString::number(jsonEmote.value("id").toInt())}; - auto urls = jsonEmote.value("urls").toObject(); - - auto emote = Emote(); - fillInEmoteData(urls, name, - name.string + "
Global FFZ Emote", emote); - emote.homePage = - Url{QString("https://www.frankerfacez.com/emoticon/%1-%2") - .arg(id.string) - .arg(name.string)}; - - emotes[name] = - cachedOrMakeEmotePtr(std::move(emote), currentEmotes); - } + parseEmoteSetInto(emoteSet, "Global", emotes); } - return {Success, std::move(emotes)}; + return emotes; } boost::optional parseAuthorityBadge(const QJsonObject &badgeUrls, - const QString tooltip) + const QString &tooltip) { boost::optional authorityBadge; @@ -140,40 +152,11 @@ namespace { EmoteMap parseChannelEmotes(const QJsonObject &jsonRoot) { - auto jsonSets = jsonRoot.value("sets").toObject(); auto emotes = EmoteMap(); - for (auto jsonSet : jsonSets) + for (const auto emoteSetRef : jsonRoot["sets"].toObject()) { - auto jsonEmotes = jsonSet.toObject().value("emoticons").toArray(); - - for (auto _jsonEmote : jsonEmotes) - { - auto jsonEmote = _jsonEmote.toObject(); - - // margins - auto id = - EmoteId{QString::number(jsonEmote.value("id").toInt())}; - auto name = EmoteName{jsonEmote.value("name").toString()}; - auto author = EmoteAuthor{jsonEmote.value("owner") - .toObject() - .value("display_name") - .toString()}; - auto urls = jsonEmote.value("urls").toObject(); - - Emote emote; - fillInEmoteData(urls, name, - QString("%1
Channel FFZ Emote
By: %2") - .arg(name.string) - .arg(author.string), - emote); - emote.homePage = - Url{QString("https://www.frankerfacez.com/emoticon/%1-%2") - .arg(id.string) - .arg(name.string)}; - - emotes[name] = cachedOrMake(std::move(emote), id); - } + parseEmoteSetInto(emoteSetRef.toObject(), "Channel", emotes); } return emotes; @@ -195,7 +178,9 @@ boost::optional FfzEmotes::emote(const EmoteName &name) const auto emotes = this->global_.get(); auto it = emotes->find(name); if (it != emotes->end()) + { return it->second; + } return boost::none; } @@ -213,41 +198,38 @@ void FfzEmotes::loadEmotes() .timeout(30000) .onSuccess([this](auto result) -> Outcome { - auto emotes = this->emotes(); - auto pair = parseGlobalEmotes(result.parseJson(), *emotes); - if (pair.first) - this->global_.set( - std::make_shared(std::move(pair.second))); - return pair.first; + auto parsedSet = parseGlobalEmotes(result.parseJson()); + this->global_.set(std::make_shared(std::move(parsedSet))); + + return Success; }) .execute(); } void FfzEmotes::loadChannel( - std::weak_ptr channel, const QString &channelId, + std::weak_ptr channel, const QString &channelID, std::function emoteCallback, std::function)> modBadgeCallback, std::function)> vipBadgeCallback, bool manualRefresh) { qCDebug(chatterinoFfzemotes) - << "[FFZEmotes] Reload FFZ Channel Emotes for channel" << channelId; + << "[FFZEmotes] Reload FFZ Channel Emotes for channel" << channelID; - NetworkRequest("https://api.frankerfacez.com/v1/room/id/" + channelId) + NetworkRequest("https://api.frankerfacez.com/v1/room/id/" + channelID) .timeout(20000) .onSuccess([emoteCallback = std::move(emoteCallback), modBadgeCallback = std::move(modBadgeCallback), vipBadgeCallback = std::move(vipBadgeCallback), channel, - manualRefresh](auto result) -> Outcome { - auto json = result.parseJson(); + manualRefresh](const auto &result) -> Outcome { + const auto json = result.parseJson(); + auto emoteMap = parseChannelEmotes(json); auto modBadge = parseAuthorityBadge( - json.value("room").toObject().value("mod_urls").toObject(), - "Moderator"); + json["room"]["mod_urls"].toObject(), "Moderator"); auto vipBadge = parseAuthorityBadge( - json.value("room").toObject().value("vip_badge").toObject(), - "VIP"); + json["room"]["vip_badge"].toObject(), "VIP"); bool hasEmotes = !emoteMap.empty(); @@ -270,22 +252,27 @@ void FfzEmotes::loadChannel( return Success; }) - .onError([channelId, channel, manualRefresh](NetworkResult result) { + .onError([channelID, channel, manualRefresh](const auto &result) { auto shared = channel.lock(); if (!shared) + { return; + } + if (result.status() == 404) { // User does not have any FFZ emotes if (manualRefresh) + { shared->addMessage( makeSystemMessage(CHANNEL_HAS_NO_EMOTES)); + } } else if (result.status() == NetworkResult::timedoutStatus) { // TODO: Auto retry in case of a timeout, with a delay qCWarning(chatterinoFfzemotes) - << "Fetching FFZ emotes for channel" << channelId + << "Fetching FFZ emotes for channel" << channelID << "failed due to timeout"; shared->addMessage( makeSystemMessage("Failed to fetch FrankerFaceZ channel " @@ -294,7 +281,7 @@ void FfzEmotes::loadChannel( else { qCWarning(chatterinoFfzemotes) - << "Error fetching FFZ emotes for channel" << channelId + << "Error fetching FFZ emotes for channel" << channelID << ", error" << result.status(); shared->addMessage( makeSystemMessage("Failed to fetch FrankerFaceZ channel " From db97a14cdc49211e6e3b075d0cd9bc4b14828dd1 Mon Sep 17 00:00:00 2001 From: nerix Date: Sat, 18 Mar 2023 12:47:38 +0100 Subject: [PATCH 18/24] Ignore BTTV user-events (#4438) Co-authored-by: pajlada --- CHANGELOG.md | 1 + src/providers/bttv/BttvLiveUpdates.cpp | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ecc29e6dd..887ba3c20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unversioned - Minor: Added support for FrankerFaceZ animated emotes. (#4434) +- Dev: Ignore unhandled BTTV user-events. (#4438) - Dev: Only log debug messages when NDEBUG is not defined. (#4442) - Dev: Cleaned up theme related code. (#4450) diff --git a/src/providers/bttv/BttvLiveUpdates.cpp b/src/providers/bttv/BttvLiveUpdates.cpp index 1a0752297..f9128b5c7 100644 --- a/src/providers/bttv/BttvLiveUpdates.cpp +++ b/src/providers/bttv/BttvLiveUpdates.cpp @@ -83,6 +83,10 @@ void BttvLiveUpdates::onMessage( this->signals_.emoteRemoved.invoke(message); } + else if (eventType == "lookup_user") + { + // ignored + } else { qCDebug(chatterinoBttv) << "Unhandled event:" << json; From 0acbc0d2c346fe190e451c8ae6f81d4a395245f1 Mon Sep 17 00:00:00 2001 From: Daniel Sage <24928223+dnsge@users.noreply.github.com> Date: Sat, 18 Mar 2023 12:30:08 -0400 Subject: [PATCH 19/24] Formalize zero-width emote implementation (#4314) Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 2 + src/CMakeLists.txt | 2 + src/messages/MessageBuilder.cpp | 20 ++ src/messages/MessageBuilder.hpp | 4 + src/messages/MessageElement.cpp | 180 +++++++++++ src/messages/MessageElement.hpp | 41 ++- .../layouts/MessageLayoutContainer.cpp | 30 +- src/messages/layouts/MessageLayoutElement.cpp | 135 ++++++++ src/messages/layouts/MessageLayoutElement.hpp | 20 ++ src/providers/twitch/TwitchMessageBuilder.cpp | 46 ++- src/singletons/Settings.hpp | 1 + src/widgets/TooltipEntryWidget.cpp | 119 +++++++ src/widgets/TooltipEntryWidget.hpp | 42 +++ src/widgets/TooltipWidget.cpp | 298 +++++++++++++----- src/widgets/TooltipWidget.hpp | 48 ++- src/widgets/helper/ChannelView.cpp | 98 +++++- src/widgets/settingspages/GeneralPage.cpp | 4 + src/widgets/splits/SplitHeader.cpp | 3 +- 18 files changed, 947 insertions(+), 146 deletions(-) create mode 100644 src/widgets/TooltipEntryWidget.cpp create mode 100644 src/widgets/TooltipEntryWidget.hpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 887ba3c20..5e616c300 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ ## Unversioned - Minor: Added support for FrankerFaceZ animated emotes. (#4434) +- Bugfix: Fixed an issue where animated emotes would render on top of zero-width emotes. (#4314) +- Bugfix: Fixed an issue where it was difficult to hover a zero-width emote. (#4314) - Dev: Ignore unhandled BTTV user-events. (#4438) - Dev: Only log debug messages when NDEBUG is not defined. (#4442) - Dev: Cleaned up theme related code. (#4450) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0c53153b7..98368033c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -420,6 +420,8 @@ set(SOURCE_FILES widgets/Scrollbar.hpp widgets/StreamView.cpp widgets/StreamView.hpp + widgets/TooltipEntryWidget.cpp + widgets/TooltipEntryWidget.hpp widgets/TooltipWidget.cpp widgets/TooltipWidget.hpp widgets/Window.cpp diff --git a/src/messages/MessageBuilder.cpp b/src/messages/MessageBuilder.cpp index d97b12e24..bc0cfdbab 100644 --- a/src/messages/MessageBuilder.cpp +++ b/src/messages/MessageBuilder.cpp @@ -687,6 +687,26 @@ void MessageBuilder::append(std::unique_ptr element) this->message().elements.push_back(std::move(element)); } +bool MessageBuilder::isEmpty() const +{ + return this->message_->elements.empty(); +} + +MessageElement &MessageBuilder::back() +{ + assert(!this->isEmpty()); + return *this->message().elements.back(); +} + +std::unique_ptr MessageBuilder::releaseBack() +{ + assert(!this->isEmpty()); + + auto ptr = std::move(this->message().elements.back()); + this->message().elements.pop_back(); + return ptr; +} + QString MessageBuilder::matchLink(const QString &string) { LinkParser linkParser(string); diff --git a/src/messages/MessageBuilder.hpp b/src/messages/MessageBuilder.hpp index 96ab66586..9a7d68643 100644 --- a/src/messages/MessageBuilder.hpp +++ b/src/messages/MessageBuilder.hpp @@ -123,6 +123,10 @@ protected: virtual void addTextOrEmoji(EmotePtr emote); virtual void addTextOrEmoji(const QString &value); + bool isEmpty() const; + MessageElement &back(); + std::unique_ptr releaseBack(); + MessageColor textColor_ = MessageColor::Text; private: diff --git a/src/messages/MessageElement.cpp b/src/messages/MessageElement.cpp index ea76b5918..29558a8e3 100644 --- a/src/messages/MessageElement.cpp +++ b/src/messages/MessageElement.cpp @@ -15,6 +15,24 @@ namespace chatterino { +namespace { + + // Computes the bounding box for the given vector of images + QSize getBoundingBoxSize(const std::vector &images) + { + int width = 0; + int height = 0; + for (const auto &img : images) + { + width = std::max(width, img->width()); + height = std::max(height, img->height()); + } + + return QSize(width, height); + } + +} // namespace + MessageElement::MessageElement(MessageElementFlags flags) : flags_(flags) { @@ -216,6 +234,168 @@ MessageLayoutElement *EmoteElement::makeImageLayoutElement( return new ImageLayoutElement(*this, image, size); } +LayeredEmoteElement::LayeredEmoteElement(std::vector &&emotes, + MessageElementFlags flags, + const MessageColor &textElementColor) + : MessageElement(flags) + , emotes_(std::move(emotes)) + , textElementColor_(textElementColor) +{ + this->updateTooltips(); +} + +void LayeredEmoteElement::addEmoteLayer(const EmotePtr &emote) +{ + this->emotes_.push_back(emote); + this->updateTooltips(); +} + +void LayeredEmoteElement::addToContainer(MessageLayoutContainer &container, + MessageElementFlags flags) +{ + if (flags.hasAny(this->getFlags())) + { + if (flags.has(MessageElementFlag::EmoteImages)) + { + auto images = this->getLoadedImages(container.getScale()); + if (images.empty()) + { + return; + } + + auto emoteScale = getSettings()->emoteScale.getValue(); + float overallScale = emoteScale * container.getScale(); + + auto largestSize = getBoundingBoxSize(images) * overallScale; + std::vector individualSizes; + individualSizes.reserve(this->emotes_.size()); + for (auto img : images) + { + individualSizes.push_back(QSize(img->width(), img->height()) * + overallScale); + } + + container.addElement(this->makeImageLayoutElement( + images, individualSizes, largestSize) + ->setLink(this->getLink())); + } + else + { + if (this->textElement_) + { + this->textElement_->addToContainer(container, + MessageElementFlag::Misc); + } + } + } +} + +std::vector LayeredEmoteElement::getLoadedImages(float scale) +{ + std::vector res; + res.reserve(this->emotes_.size()); + + for (auto emote : this->emotes_) + { + auto image = emote->images.getImageOrLoaded(scale); + if (image->isEmpty()) + { + continue; + } + res.push_back(image); + } + return res; +} + +MessageLayoutElement *LayeredEmoteElement::makeImageLayoutElement( + const std::vector &images, const std::vector &sizes, + QSize largestSize) +{ + return new LayeredImageLayoutElement(*this, images, sizes, largestSize); +} + +void LayeredEmoteElement::updateTooltips() +{ + if (!this->emotes_.empty()) + { + QString copyStr = this->getCopyString(); + this->textElement_.reset(new TextElement( + copyStr, MessageElementFlag::Misc, this->textElementColor_)); + this->setTooltip(copyStr); + } + + std::vector result; + result.reserve(this->emotes_.size()); + + for (auto &emote : this->emotes_) + { + result.push_back(emote->tooltip.string); + } + + this->emoteTooltips_ = std::move(result); +} + +const std::vector &LayeredEmoteElement::getEmoteTooltips() const +{ + return this->emoteTooltips_; +} + +QString LayeredEmoteElement::getCleanCopyString() const +{ + QString result; + for (size_t i = 0; i < this->emotes_.size(); ++i) + { + if (i != 0) + { + result += " "; + } + result += + TwitchEmotes::cleanUpEmoteCode(this->emotes_[i]->getCopyString()); + } + return result; +} + +QString LayeredEmoteElement::getCopyString() const +{ + QString result; + for (size_t i = 0; i < this->emotes_.size(); ++i) + { + if (i != 0) + { + result += " "; + } + result += this->emotes_[i]->getCopyString(); + } + return result; +} + +const std::vector &LayeredEmoteElement::getEmotes() const +{ + return this->emotes_; +} + +std::vector LayeredEmoteElement::getUniqueEmotes() const +{ + // Functor for std::copy_if that keeps track of seen elements + struct NotDuplicate { + bool operator()(const EmotePtr &element) + { + return seen.insert(element).second; + } + + private: + std::set seen; + }; + + // Get unique emotes while maintaining relative layering order + NotDuplicate dup; + std::vector unique; + std::copy_if(this->emotes_.begin(), this->emotes_.end(), + std::back_insert_iterator(unique), dup); + + return unique; +} + // BADGE BadgeElement::BadgeElement(const EmotePtr &emote, MessageElementFlags flags) : MessageElement(flags) diff --git a/src/messages/MessageElement.hpp b/src/messages/MessageElement.hpp index 0ce8443fc..5f6dcca1d 100644 --- a/src/messages/MessageElement.hpp +++ b/src/messages/MessageElement.hpp @@ -141,9 +141,7 @@ enum class MessageElementFlag : int64_t { LowercaseLink = (1LL << 29), OriginalLink = (1LL << 30), - // ZeroWidthEmotes are emotes that are supposed to overlay over any pre-existing emotes - // e.g. BTTV's SoSnowy during christmas season or 7TV's RainTime - ZeroWidthEmote = (1LL << 31), + // Unused: (1LL << 31) // for elements of the message reply RepliedMessage = (1LL << 32), @@ -321,6 +319,43 @@ private: EmotePtr emote_; }; +// A LayeredEmoteElement represents multiple Emotes layered on top of each other. +// This class takes care of rendering animated and non-animated emotes in the +// correct order and aligning them in the right way. +class LayeredEmoteElement : public MessageElement +{ +public: + LayeredEmoteElement( + std::vector &&emotes, MessageElementFlags flags, + const MessageColor &textElementColor = MessageColor::Text); + + void addEmoteLayer(const EmotePtr &emote); + + void addToContainer(MessageLayoutContainer &container, + MessageElementFlags flags) override; + + // Returns a concatenation of each emote layer's cleaned copy string + QString getCleanCopyString() const; + const std::vector &getEmotes() const; + std::vector getUniqueEmotes() const; + const std::vector &getEmoteTooltips() const; + +private: + MessageLayoutElement *makeImageLayoutElement( + const std::vector &image, const std::vector &sizes, + QSize largestSize); + + QString getCopyString() const; + void updateTooltips(); + std::vector getLoadedImages(float scale); + + std::vector emotes_; + std::vector emoteTooltips_; + + std::unique_ptr textElement_; + MessageColor textElementColor_; +}; + class BadgeElement : public MessageElement { public: diff --git a/src/messages/layouts/MessageLayoutContainer.cpp b/src/messages/layouts/MessageLayoutContainer.cpp index 28050782a..540e9dfac 100644 --- a/src/messages/layouts/MessageLayoutContainer.cpp +++ b/src/messages/layouts/MessageLayoutContainer.cpp @@ -67,10 +67,7 @@ void MessageLayoutContainer::clear() void MessageLayoutContainer::addElement(MessageLayoutElement *element) { - bool isZeroWidth = - element->getFlags().has(MessageElementFlag::ZeroWidthEmote); - - if (!isZeroWidth && !this->fitsInLine(element->getRect().width())) + if (!this->fitsInLine(element->getRect().width())) { this->breakLine(); } @@ -175,14 +172,6 @@ void MessageLayoutContainer::_addElement(MessageLayoutElement *element, this->lineHeight_ = std::max(this->lineHeight_, elementLineHeight); auto xOffset = 0; - bool isZeroWidthEmote = element->getCreator().getFlags().has( - MessageElementFlag::ZeroWidthEmote); - - if (isZeroWidthEmote && !isRTLMode) - { - xOffset -= element->getRect().width() + this->spaceWidth_; - } - auto yOffset = 0; if (element->getCreator().getFlags().has( @@ -195,7 +184,7 @@ void MessageLayoutContainer::_addElement(MessageLayoutElement *element, if (getSettings()->removeSpacesBetweenEmotes && element->getFlags().hasAny({MessageElementFlag::EmoteImages}) && - !isZeroWidthEmote && shouldRemoveSpaceBetweenEmotes()) + shouldRemoveSpaceBetweenEmotes()) { // Move cursor one 'space width' to the left (right in case of RTL) to combine hug the previous emote if (isRTLMode) @@ -230,16 +219,13 @@ void MessageLayoutContainer::_addElement(MessageLayoutElement *element, } // set current x - if (!isZeroWidthEmote) + if (isRTLMode) { - if (isRTLMode) - { - this->currentX_ -= element->getRect().width(); - } - else - { - this->currentX_ += element->getRect().width(); - } + this->currentX_ -= element->getRect().width(); + } + else + { + this->currentX_ += element->getRect().width(); } if (element->hasTrailingSpace()) diff --git a/src/messages/layouts/MessageLayoutElement.cpp b/src/messages/layouts/MessageLayoutElement.cpp index ab75c16db..cd071b4f2 100644 --- a/src/messages/layouts/MessageLayoutElement.cpp +++ b/src/messages/layouts/MessageLayoutElement.cpp @@ -15,6 +15,14 @@ namespace { const QChar RTL_EMBED(0x202B); + +void alignRectBottomCenter(QRectF &rect, const QRectF &reference) +{ + QPointF newCenter(reference.center().x(), + reference.bottom() - (rect.height() / 2.0)); + rect.moveCenter(newCenter); +} + } // namespace namespace chatterino { @@ -184,6 +192,133 @@ int ImageLayoutElement::getXFromIndex(int index) } } +// +// LAYERED IMAGE +// + +LayeredImageLayoutElement::LayeredImageLayoutElement( + MessageElement &creator, std::vector images, + std::vector sizes, QSize largestSize) + : MessageLayoutElement(creator, largestSize) + , images_(std::move(images)) + , sizes_(std::move(sizes)) +{ + assert(this->images_.size() == this->sizes_.size()); + this->trailingSpace = creator.hasTrailingSpace(); +} + +void LayeredImageLayoutElement::addCopyTextToString(QString &str, uint32_t from, + uint32_t to) const +{ + const auto *layeredEmoteElement = + dynamic_cast(&this->getCreator()); + if (layeredEmoteElement) + { + // cleaning is taken care in call + str += layeredEmoteElement->getCleanCopyString(); + if (this->hasTrailingSpace()) + { + str += " "; + } + } +} + +int LayeredImageLayoutElement::getSelectionIndexCount() const +{ + return this->trailingSpace ? 2 : 1; +} + +void LayeredImageLayoutElement::paint(QPainter &painter) +{ + auto fullRect = QRectF(this->getRect()); + + for (size_t i = 0; i < this->images_.size(); ++i) + { + auto &img = this->images_[i]; + if (img == nullptr) + { + continue; + } + + auto pixmap = img->pixmapOrLoad(); + if (img->animated()) + { + // As soon as we see an animated emote layer, we can stop rendering + // the static emotes. The paintAnimated function will render any + // static emotes layered on top of the first seen animated emote. + return; + } + + if (pixmap) + { + // Matching the web chat behavior, we center the emote within the overall + // binding box. E.g. small overlay emotes like cvMask will sit in the direct + // center of even wide emotes. + auto &size = this->sizes_[i]; + QRectF destRect(0, 0, size.width(), size.height()); + alignRectBottomCenter(destRect, fullRect); + + painter.drawPixmap(destRect, *pixmap, QRectF()); + } + } +} + +void LayeredImageLayoutElement::paintAnimated(QPainter &painter, int yOffset) +{ + auto fullRect = QRectF(this->getRect()); + fullRect.moveTop(fullRect.y() + yOffset); + bool animatedFlag = false; + + for (size_t i = 0; i < this->images_.size(); ++i) + { + auto &img = this->images_[i]; + if (img == nullptr) + { + continue; + } + + // If we have a static emote layered on top of an animated emote, we need + // to render the static emote again after animating anything below it. + if (img->animated() || animatedFlag) + { + if (auto pixmap = img->pixmapOrLoad()) + { + // Matching the web chat behavior, we center the emote within the overall + // binding box. E.g. small overlay emotes like cvMask will sit in the direct + // center of even wide emotes. + auto &size = this->sizes_[i]; + QRectF destRect(0, 0, size.width(), size.height()); + alignRectBottomCenter(destRect, fullRect); + + painter.drawPixmap(destRect, *pixmap, QRectF()); + animatedFlag = true; + } + } + } +} + +int LayeredImageLayoutElement::getMouseOverIndex(const QPoint &abs) const +{ + return 0; +} + +int LayeredImageLayoutElement::getXFromIndex(int index) +{ + if (index <= 0) + { + return this->getRect().left(); + } + else if (index == 1) + { + // fourtf: remove space width + return this->getRect().right(); + } + else + { + return this->getRect().right(); + } +} + // // IMAGE WITH BACKGROUND // diff --git a/src/messages/layouts/MessageLayoutElement.hpp b/src/messages/layouts/MessageLayoutElement.hpp index 86e662e54..e4c930845 100644 --- a/src/messages/layouts/MessageLayoutElement.hpp +++ b/src/messages/layouts/MessageLayoutElement.hpp @@ -83,6 +83,26 @@ protected: ImagePtr image_; }; +class LayeredImageLayoutElement : public MessageLayoutElement +{ +public: + LayeredImageLayoutElement(MessageElement &creator, + std::vector images, + std::vector sizes, QSize largestSize); + +protected: + void addCopyTextToString(QString &str, uint32_t from = 0, + uint32_t to = UINT32_MAX) const override; + int getSelectionIndexCount() const override; + void paint(QPainter &painter) override; + void paintAnimated(QPainter &painter, int yOffset) override; + int getMouseOverIndex(const QPoint &abs) const override; + int getXFromIndex(int index) override; + + std::vector images_; + std::vector sizes_; +}; + class ImageWithBackgroundLayoutElement : public ImageLayoutElement { public: diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index c8c4da409..99895a246 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -1023,6 +1023,7 @@ Outcome TwitchMessageBuilder::tryAppendEmote(const EmoteName &name) auto flags = MessageElementFlags(); auto emote = boost::optional{}; + bool zeroWidth = false; // Emote order: // - FrankerFaceZ Channel @@ -1044,10 +1045,7 @@ Outcome TwitchMessageBuilder::tryAppendEmote(const EmoteName &name) (emote = this->twitchChannel->seventvEmote(name))) { flags = MessageElementFlag::SevenTVEmote; - if (emote.value()->zeroWidth) - { - flags.set(MessageElementFlag::ZeroWidthEmote); - } + zeroWidth = emote.value()->zeroWidth; } else if ((emote = globalFfzEmotes.emote(name))) { @@ -1056,23 +1054,45 @@ Outcome TwitchMessageBuilder::tryAppendEmote(const EmoteName &name) else if ((emote = globalBttvEmotes.emote(name))) { flags = MessageElementFlag::BttvEmote; - - if (zeroWidthEmotes.contains(name.string)) - { - flags.set(MessageElementFlag::ZeroWidthEmote); - } + zeroWidth = zeroWidthEmotes.contains(name.string); } else if ((emote = globalSeventvEmotes.globalEmote(name))) { flags = MessageElementFlag::SevenTVEmote; - if (emote.value()->zeroWidth) - { - flags.set(MessageElementFlag::ZeroWidthEmote); - } + zeroWidth = emote.value()->zeroWidth; } if (emote) { + if (zeroWidth && getSettings()->enableZeroWidthEmotes && + !this->isEmpty()) + { + // Attempt to merge current zero-width emote into any previous emotes + auto asEmote = dynamic_cast(&this->back()); + if (asEmote) + { + // Make sure to access asEmote before taking ownership when releasing + auto baseEmote = asEmote->getEmote(); + // Need to remove EmoteElement and replace with LayeredEmoteElement + auto baseEmoteElement = this->releaseBack(); + + std::vector layers = {baseEmote, emote.get()}; + this->emplace(std::move(layers), + baseEmoteElement->getFlags(), + this->textColor_); + return Success; + } + + auto asLayered = dynamic_cast(&this->back()); + if (asLayered) + { + asLayered->addEmoteLayer(emote.get()); + return Success; + } + + // No emote to merge with, just show as regular emote + } + this->emplace(emote.get(), flags, this->textColor_); return Success; } diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 8b0b32f77..bd1620e5e 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -213,6 +213,7 @@ public: false}; BoolSetting enableEmoteImages = {"/emotes/enableEmoteImages", true}; BoolSetting animateEmotes = {"/emotes/enableGifAnimations", true}; + BoolSetting enableZeroWidthEmotes = {"/emotes/enableZeroWidthEmotes", true}; FloatSetting emoteScale = {"/emotes/scale", 1.f}; BoolSetting showUnlistedSevenTVEmotes = { "/emotes/showUnlistedSevenTVEmotes", false}; diff --git a/src/widgets/TooltipEntryWidget.cpp b/src/widgets/TooltipEntryWidget.cpp new file mode 100644 index 000000000..6fbaec1fd --- /dev/null +++ b/src/widgets/TooltipEntryWidget.cpp @@ -0,0 +1,119 @@ +#include "widgets/TooltipEntryWidget.hpp" + +#include + +namespace chatterino { + +TooltipEntryWidget::TooltipEntryWidget(QWidget *parent) + : TooltipEntryWidget(nullptr, "", 0, 0, parent) +{ +} + +TooltipEntryWidget::TooltipEntryWidget(ImagePtr image, const QString &text, + int customWidth, int customHeight, + QWidget *parent) + : QWidget(parent) + , image_(image) + , customImgWidth_(customWidth) + , customImgHeight_(customHeight) +{ + auto *layout = new QVBoxLayout(this); + layout->setContentsMargins(0, 0, 0, 0); + this->setLayout(layout); + + this->displayImage_ = new QLabel(); + this->displayImage_->setAlignment(Qt::AlignHCenter); + this->displayImage_->setStyleSheet("background: transparent"); + this->displayText_ = new QLabel(text); + this->displayText_->setAlignment(Qt::AlignHCenter); + this->displayText_->setStyleSheet("background: transparent"); + + layout->addWidget(this->displayImage_); + layout->addWidget(this->displayText_); +} + +void TooltipEntryWidget::setWordWrap(bool wrap) +{ + this->displayText_->setWordWrap(wrap); +} + +void TooltipEntryWidget::setImageScale(int w, int h) +{ + if (this->customImgWidth_ == w && this->customImgHeight_ == h) + { + return; + } + this->customImgWidth_ = w; + this->customImgHeight_ = h; + this->refreshPixmap(); +} + +void TooltipEntryWidget::setText(const QString &text) +{ + this->displayText_->setText(text); +} + +void TooltipEntryWidget::setImage(ImagePtr image) +{ + if (this->image_ == image) + { + return; + } + + this->clearImage(); + this->image_ = std::move(image); + this->refreshPixmap(); +} + +void TooltipEntryWidget::clearImage() +{ + this->displayImage_->hide(); + this->image_ = nullptr; + this->setImageScale(0, 0); +} + +bool TooltipEntryWidget::refreshPixmap() +{ + if (!this->image_) + { + return false; + } + + auto pixmap = this->image_->pixmapOrLoad(); + if (!pixmap) + { + this->attemptRefresh_ = true; + return false; + } + + if (this->customImgWidth_ > 0 || this->customImgHeight_ > 0) + { + this->displayImage_->setPixmap(pixmap->scaled(this->customImgWidth_, + this->customImgHeight_, + Qt::KeepAspectRatio)); + } + else + { + this->displayImage_->setPixmap(*pixmap); + } + this->displayImage_->show(); + + return true; +} + +bool TooltipEntryWidget::animated() const +{ + return this->image_ && this->image_->animated(); +} + +bool TooltipEntryWidget::hasImage() const +{ + return this->image_ != nullptr; +} + +bool TooltipEntryWidget::attemptRefresh() const +{ + return this->attemptRefresh_; +} + +} // namespace chatterino diff --git a/src/widgets/TooltipEntryWidget.hpp b/src/widgets/TooltipEntryWidget.hpp new file mode 100644 index 000000000..0bc6d68f4 --- /dev/null +++ b/src/widgets/TooltipEntryWidget.hpp @@ -0,0 +1,42 @@ +#pragma once + +#include "messages/Image.hpp" + +#include +#include + +namespace chatterino { + +class TooltipEntryWidget : public QWidget +{ + Q_OBJECT + +public: + TooltipEntryWidget(QWidget *parent = nullptr); + TooltipEntryWidget(ImagePtr image, const QString &text, int customWidth, + int customHeight, QWidget *parent = nullptr); + + void setImageScale(int w, int h); + void setWordWrap(bool wrap); + + void setText(const QString &text); + void setImage(ImagePtr image); + void clearImage(); + bool refreshPixmap(); + + bool animated() const; + bool hasImage() const; + bool attemptRefresh() const; + +private: + QLabel *displayImage_ = nullptr; + QLabel *displayText_ = nullptr; + + bool attemptRefresh_ = false; + + ImagePtr image_ = nullptr; + int customImgWidth_ = 0; + int customImgHeight_ = 0; +}; + +} // namespace chatterino diff --git a/src/widgets/TooltipWidget.cpp b/src/widgets/TooltipWidget.cpp index ac89f5bfa..98ad4b971 100644 --- a/src/widgets/TooltipWidget.cpp +++ b/src/widgets/TooltipWidget.cpp @@ -6,7 +6,9 @@ #include "singletons/WindowManager.hpp" #include -#include + +// number of columns in grid mode +#define GRID_NUM_COLS 3 namespace chatterino { @@ -20,8 +22,6 @@ TooltipWidget::TooltipWidget(BaseWidget *parent) : BaseWindow({BaseWindow::TopMost, BaseWindow::DontFocus, BaseWindow::DisableLayoutSave}, parent) - , displayImage_(new QLabel(this)) - , displayText_(new QLabel(this)) { this->setStyleSheet("color: #fff; background: rgba(11, 11, 11, 0.8)"); this->setAttribute(Qt::WA_TranslucentBackground); @@ -29,18 +29,10 @@ TooltipWidget::TooltipWidget(BaseWidget *parent) this->setStayInScreenRect(true); - displayImage_->setAlignment(Qt::AlignHCenter); - displayImage_->setStyleSheet("background: transparent"); - - displayText_->setAlignment(Qt::AlignHCenter); - displayText_->setStyleSheet("background: transparent"); - - auto *layout = new QVBoxLayout(this); - layout->setSizeConstraint(QLayout::SetFixedSize); - layout->setContentsMargins(10, 5, 10, 5); - layout->addWidget(displayImage_); - layout->addWidget(displayText_); - this->setLayout(layout); + // Default to using vertical layout + this->initializeVLayout(); + this->setLayout(this->vLayout_); + this->currentStyle_ = TooltipStyle::Vertical; this->connections_.managedConnect(getFonts()->fontChanged, [this] { this->updateFont(); @@ -49,24 +41,219 @@ TooltipWidget::TooltipWidget(BaseWidget *parent) auto windows = getApp()->windows; this->connections_.managedConnect(windows->gifRepaintRequested, [this] { - if (this->image_ && this->image_->animated()) + for (int i = 0; i < this->visibleEntries_; ++i) { - this->refreshPixmap(); + auto entry = this->entryAt(i); + if (entry && entry->animated()) + { + entry->refreshPixmap(); + } } }); this->connections_.managedConnect(windows->miscUpdate, [this] { - if (this->image_ && this->attemptRefresh) + bool needSizeAdjustment = false; + for (int i = 0; i < this->visibleEntries_; ++i) { - if (this->refreshPixmap()) + auto entry = this->entryAt(i); + if (entry->hasImage() && entry->attemptRefresh()) { - this->attemptRefresh = false; - this->adjustSize(); + bool successfullyUpdated = entry->refreshPixmap(); + needSizeAdjustment |= successfullyUpdated; } } + + if (needSizeAdjustment) + { + this->adjustSize(); + } }); } +void TooltipWidget::setOne(const TooltipEntry &entry, TooltipStyle style) +{ + this->set({entry}, style); +} + +void TooltipWidget::set(const std::vector &entries, + TooltipStyle style) +{ + this->setCurrentStyle(style); + + int delta = entries.size() - this->currentLayoutCount(); + if (delta > 0) + { + // Need to add more TooltipEntry instances + int base = this->currentLayoutCount(); + for (int i = 0; i < delta; ++i) + { + this->addNewEntry(base + i); + } + } + + this->setVisibleEntries(entries.size()); + + for (int i = 0; i < entries.size(); ++i) + { + if (auto entryWidget = this->entryAt(i)) + { + auto &entry = entries[i]; + entryWidget->setImage(entry.image); + entryWidget->setText(entry.text); + entryWidget->setImageScale(entry.customWidth, entry.customHeight); + } + } +} + +void TooltipWidget::setVisibleEntries(int n) +{ + for (int i = 0; i < this->currentLayoutCount(); ++i) + { + auto *entry = this->entryAt(i); + if (entry == nullptr) + { + continue; + } + + if (i >= n) + { + entry->hide(); + entry->clearImage(); + } + else + { + entry->show(); + } + } + this->visibleEntries_ = n; +} + +void TooltipWidget::addNewEntry(int absoluteIndex) +{ + switch (this->currentStyle_) + { + case TooltipStyle::Vertical: + this->vLayout_->addWidget(new TooltipEntryWidget(), + Qt::AlignHCenter); + return; + case TooltipStyle::Grid: + if (absoluteIndex == 0) + { + // Top row spans all columns + this->gLayout_->addWidget(new TooltipEntryWidget(), 0, 0, 1, + GRID_NUM_COLS, Qt::AlignCenter); + } + else + { + int row = ((absoluteIndex - 1) / GRID_NUM_COLS) + 1; + int col = (absoluteIndex - 1) % GRID_NUM_COLS; + this->gLayout_->addWidget(new TooltipEntryWidget(), row, col, + Qt::AlignHCenter | Qt::AlignBottom); + } + return; + default: + return; + } +} + +// May be nullptr +QLayout *TooltipWidget::currentLayout() const +{ + switch (this->currentStyle_) + { + case TooltipStyle::Vertical: + return this->vLayout_; + case TooltipStyle::Grid: + return this->gLayout_; + default: + return nullptr; + } +} + +int TooltipWidget::currentLayoutCount() const +{ + if (auto *layout = this->currentLayout()) + { + return layout->count(); + } + return 0; +} + +// May be nullptr +TooltipEntryWidget *TooltipWidget::entryAt(int n) +{ + if (auto *layout = this->currentLayout()) + { + return dynamic_cast(layout->itemAt(n)->widget()); + } + return nullptr; +} + +void TooltipWidget::setCurrentStyle(TooltipStyle style) +{ + if (this->currentStyle_ == style) + { + // Nothing to update + return; + } + + this->clearEntries(); + this->deleteCurrentLayout(); + + switch (style) + { + case TooltipStyle::Vertical: + this->initializeVLayout(); + this->setLayout(this->vLayout_); + break; + case TooltipStyle::Grid: + this->initializeGLayout(); + this->setLayout(this->gLayout_); + break; + default: + break; + } + + this->currentStyle_ = style; +} + +void TooltipWidget::deleteCurrentLayout() +{ + auto *currentLayout = this->layout(); + delete currentLayout; + + switch (this->currentStyle_) + { + case TooltipStyle::Vertical: + this->vLayout_ = nullptr; + break; + case TooltipStyle::Grid: + this->gLayout_ = nullptr; + break; + default: + break; + } +} + +void TooltipWidget::initializeVLayout() +{ + auto *vLayout = new QVBoxLayout(this); + vLayout->setSizeConstraint(QLayout::SetFixedSize); + vLayout->setContentsMargins(10, 5, 10, 5); + vLayout->setSpacing(10); + this->vLayout_ = vLayout; +} + +void TooltipWidget::initializeGLayout() +{ + auto *gLayout = new QGridLayout(this); + gLayout->setSizeConstraint(QLayout::SetFixedSize); + gLayout->setContentsMargins(10, 5, 10, 5); + gLayout->setHorizontalSpacing(8); + gLayout->setVerticalSpacing(10); + this->gLayout_ = gLayout; +} + void TooltipWidget::themeChangedEvent() { // this->setStyleSheet("color: #fff; background: #000"); @@ -90,49 +277,26 @@ void TooltipWidget::updateFont() getFonts()->getFont(FontStyle::ChatMediumSmall, this->scale())); } -void TooltipWidget::setText(QString text) -{ - this->displayText_->setText(text); -} - void TooltipWidget::setWordWrap(bool wrap) { - this->displayText_->setWordWrap(wrap); -} - -void TooltipWidget::clearImage() -{ - this->displayImage_->hide(); - this->image_ = nullptr; - this->setImageScale(0, 0); -} - -void TooltipWidget::setImage(ImagePtr image) -{ - if (this->image_ == image) + for (int i = 0; i < this->visibleEntries_; ++i) { - return; + auto entry = this->entryAt(i); + if (entry) + { + entry->setWordWrap(wrap); + } } - // hide image until loaded and reset scale - this->clearImage(); - this->image_ = std::move(image); - this->refreshPixmap(); } -void TooltipWidget::setImageScale(int w, int h) +void TooltipWidget::clearEntries() { - if (this->customImgWidth == w && this->customImgHeight == h) - { - return; - } - this->customImgWidth = w; - this->customImgHeight = h; - this->refreshPixmap(); + this->setVisibleEntries(0); } void TooltipWidget::hideEvent(QHideEvent *) { - this->clearImage(); + this->clearEntries(); } void TooltipWidget::showEvent(QShowEvent *) @@ -140,34 +304,6 @@ void TooltipWidget::showEvent(QShowEvent *) this->adjustSize(); } -bool TooltipWidget::refreshPixmap() -{ - if (!this->image_) - { - return false; - } - - auto pixmap = this->image_->pixmapOrLoad(); - if (!pixmap) - { - this->attemptRefresh = true; - return false; - } - - if (this->customImgWidth > 0 || this->customImgHeight > 0) - { - this->displayImage_->setPixmap(pixmap->scaled( - this->customImgWidth, this->customImgHeight, Qt::KeepAspectRatio)); - } - else - { - this->displayImage_->setPixmap(*pixmap); - } - this->displayImage_->show(); - - return true; -} - void TooltipWidget::changeEvent(QEvent *) { // clear parents event diff --git a/src/widgets/TooltipWidget.hpp b/src/widgets/TooltipWidget.hpp index 7e1c0d2dd..526ec7a1c 100644 --- a/src/widgets/TooltipWidget.hpp +++ b/src/widgets/TooltipWidget.hpp @@ -1,9 +1,13 @@ #pragma once #include "widgets/BaseWindow.hpp" +#include "widgets/TooltipEntryWidget.hpp" #include +#include #include +#include +#include #include namespace chatterino { @@ -11,6 +15,15 @@ namespace chatterino { class Image; using ImagePtr = std::shared_ptr; +struct TooltipEntry { + ImagePtr image; + QString text; + int customWidth = 0; + int customHeight = 0; +}; + +enum class TooltipStyle { Vertical, Grid }; + class TooltipWidget : public BaseWindow { Q_OBJECT @@ -21,11 +34,13 @@ public: TooltipWidget(BaseWidget *parent = nullptr); ~TooltipWidget() override = default; - void setText(QString text); + void setOne(const TooltipEntry &entry, + TooltipStyle style = TooltipStyle::Vertical); + void set(const std::vector &entries, + TooltipStyle style = TooltipStyle::Vertical); + void setWordWrap(bool wrap); - void clearImage(); - void setImage(ImagePtr image); - void setImageScale(int w, int h); + void clearEntries(); protected: void showEvent(QShowEvent *) override; @@ -39,17 +54,24 @@ protected: private: void updateFont(); - // used by WindowManager::gifRepaintRequested signal to progress frames when tooltip image is animated - bool refreshPixmap(); + QLayout *currentLayout() const; + int currentLayoutCount() const; + TooltipEntryWidget *entryAt(int n); - // set to true when tooltip image did not finish loading yet (pixmapOrLoad returned false) - bool attemptRefresh{false}; + void setVisibleEntries(int n); + void setCurrentStyle(TooltipStyle style); + void addNewEntry(int absoluteIndex); + + void deleteCurrentLayout(); + void initializeVLayout(); + void initializeGLayout(); + + int visibleEntries_ = 0; + + TooltipStyle currentStyle_; + QVBoxLayout *vLayout_; + QGridLayout *gLayout_; - ImagePtr image_ = nullptr; - int customImgWidth = 0; - int customImgHeight = 0; - QLabel *displayImage_; - QLabel *displayText_; pajlada::Signals::SignalHolder connections_; }; diff --git a/src/widgets/helper/ChannelView.cpp b/src/widgets/helper/ChannelView.cpp index ac8b153c9..46a0dcfbb 100644 --- a/src/widgets/helper/ChannelView.cpp +++ b/src/widgets/helper/ChannelView.cpp @@ -64,6 +64,7 @@ #define DRAW_WIDTH (this->width()) #define SELECTION_RESUME_SCROLLING_MSG_THRESHOLD 3 #define CHAT_HOVER_PAUSE_DURATION 1000 +#define TOOLTIP_EMOTE_ENTRIES_LIMIT 7 namespace chatterino { namespace { @@ -1658,10 +1659,12 @@ void ChannelView::mouseMoveEvent(QMouseEvent *event) auto element = &hoverLayoutElement->getCreator(); bool isLinkValid = hoverLayoutElement->getLink().isValid(); auto emoteElement = dynamic_cast(element); + auto layeredEmoteElement = + dynamic_cast(element); + bool isNotEmote = emoteElement == nullptr && layeredEmoteElement == nullptr; if (element->getTooltip().isEmpty() || - (isLinkValid && emoteElement == nullptr && - !getSettings()->linkInfoTooltip)) + (isLinkValid && isNotEmote && !getSettings()->linkInfoTooltip)) { tooltipWidget->hide(); } @@ -1669,7 +1672,7 @@ void ChannelView::mouseMoveEvent(QMouseEvent *event) { auto badgeElement = dynamic_cast(element); - if ((badgeElement || emoteElement) && + if ((badgeElement || emoteElement || layeredEmoteElement) && getSettings()->emotesTooltipPreview.getValue()) { if (event->modifiers() == Qt::ShiftModifier || @@ -1677,18 +1680,73 @@ void ChannelView::mouseMoveEvent(QMouseEvent *event) { if (emoteElement) { - tooltipWidget->setImage( - emoteElement->getEmote()->images.getImage(3.0)); + tooltipWidget->setOne({ + emoteElement->getEmote()->images.getImage(3.0), + element->getTooltip(), + }); + } + else if (layeredEmoteElement) + { + auto &layeredEmotes = layeredEmoteElement->getEmotes(); + // Should never be empty but ensure it + if (!layeredEmotes.empty()) + { + std::vector entries; + entries.reserve(layeredEmotes.size()); + + auto &emoteTooltips = + layeredEmoteElement->getEmoteTooltips(); + + // Someone performing some tomfoolery could put an emote with tens, + // if not hundreds of zero-width emotes on a single emote. If the + // tooltip may take up more than three rows, truncate everything else. + bool truncating = false; + size_t upperLimit = layeredEmotes.size(); + if (layeredEmotes.size() > TOOLTIP_EMOTE_ENTRIES_LIMIT) + { + upperLimit = TOOLTIP_EMOTE_ENTRIES_LIMIT - 1; + truncating = true; + } + + for (size_t i = 0; i < upperLimit; ++i) + { + auto &emote = layeredEmotes[i]; + if (i == 0) + { + // First entry gets a large image and full description + entries.push_back({emote->images.getImage(3.0), + emoteTooltips[i]}); + } + else + { + // Every other entry gets a small image and just the emote name + entries.push_back({emote->images.getImage(1.0), + emote->name.string}); + } + } + + if (truncating) + { + entries.push_back({nullptr, "..."}); + } + + auto style = layeredEmotes.size() > 2 + ? TooltipStyle::Grid + : TooltipStyle::Vertical; + tooltipWidget->set(entries, style); + } } else if (badgeElement) { - tooltipWidget->setImage( - badgeElement->getEmote()->images.getImage(3.0)); + tooltipWidget->setOne({ + badgeElement->getEmote()->images.getImage(3.0), + element->getTooltip(), + }); } } else { - tooltipWidget->clearImage(); + tooltipWidget->clearEntries(); } } else @@ -1711,7 +1769,7 @@ void ChannelView::mouseMoveEvent(QMouseEvent *event) auto thumbnailSize = getSettings()->thumbnailSize; if (!thumbnailSize) { - tooltipWidget->clearImage(); + tooltipWidget->clearEntries(); } else { @@ -1724,19 +1782,23 @@ void ChannelView::mouseMoveEvent(QMouseEvent *event) shouldHideThumbnail ? Image::fromResourcePixmap(getResources().streamerMode) : element->getThumbnail(); - tooltipWidget->setImage(std::move(thumb)); if (element->getThumbnailType() == MessageElement::ThumbnailType::Link_Thumbnail) { - tooltipWidget->setImageScale(thumbnailSize, thumbnailSize); + tooltipWidget->setOne({std::move(thumb), + element->getTooltip(), thumbnailSize, + thumbnailSize}); + } + else + { + tooltipWidget->setOne({std::move(thumb), ""}); } } } tooltipWidget->moveTo(this, event->globalPos()); tooltipWidget->setWordWrap(isLinkValid); - tooltipWidget->setText(element->getTooltip()); tooltipWidget->show(); } @@ -2134,6 +2196,18 @@ void ChannelView::addImageContextMenuItems( addEmoteContextMenuItems(*emoteElement->getEmote(), creatorFlags, menu); } + else if (auto layeredElement = + dynamic_cast(&creator)) + { + // Give each emote its own submenu + for (auto &emote : layeredElement->getUniqueEmotes()) + { + auto emoteAction = menu.addAction(emote->name.string); + auto emoteMenu = new QMenu(&menu); + emoteAction->setMenu(emoteMenu); + addEmoteContextMenuItems(*emote, creatorFlags, *emoteMenu); + } + } } // add seperator diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index 4f6523aa5..b016d68a1 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -374,6 +374,10 @@ void GeneralPage::initLayout(GeneralPageView &layout) layout.addCheckbox("Animate", s.animateEmotes); layout.addCheckbox("Animate only when Chatterino is focused", s.animationsWhenFocused); + layout.addCheckbox( + "Enable zero-width emotes", s.enableZeroWidthEmotes, + "When disabled, emotes that overlap other emotes, such as BTTV's " + "cvMask and 7TV's RainTime, will appear as normal emotes."); layout.addCheckbox("Enable emote auto-completion by typing :", s.emoteCompletionWithColon); layout.addDropdown( diff --git a/src/widgets/splits/SplitHeader.cpp b/src/widgets/splits/SplitHeader.cpp index a7781119b..4b6601e17 100644 --- a/src/widgets/splits/SplitHeader.cpp +++ b/src/widgets/splits/SplitHeader.cpp @@ -960,8 +960,7 @@ void SplitHeader::enterEvent(QEvent *event) } auto *tooltip = TooltipWidget::instance(); - tooltip->clearImage(); - tooltip->setText(this->tooltipText_); + tooltip->setOne({nullptr, this->tooltipText_}); tooltip->setWordWrap(true); tooltip->adjustSize(); auto pos = this->mapToGlobal(this->rect().bottomLeft()) + From a777a227d4dcbbdecabdb5de79f18a00089b07e1 Mon Sep 17 00:00:00 2001 From: nerix Date: Sun, 19 Mar 2023 11:26:30 +0100 Subject: [PATCH 20/24] Allow each layered image to retain its own flags (#4460) This fixes an issue where context-menu items for zero-width emotes displayed the wrong provider. --- CHANGELOG.md | 1 + src/common/FlagsEnum.hpp | 12 ++++++ src/messages/MessageElement.cpp | 39 +++++++++++-------- src/messages/MessageElement.hpp | 16 +++++--- src/providers/twitch/TwitchMessageBuilder.cpp | 13 ++++--- src/widgets/helper/ChannelView.cpp | 6 +-- 6 files changed, 58 insertions(+), 29 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e616c300..deec79543 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Minor: Added support for FrankerFaceZ animated emotes. (#4434) - Bugfix: Fixed an issue where animated emotes would render on top of zero-width emotes. (#4314) - Bugfix: Fixed an issue where it was difficult to hover a zero-width emote. (#4314) +- Bugfix: Fixed an issue where context-menu items for zero-width emotes displayed the wrong provider. (#4460) - Dev: Ignore unhandled BTTV user-events. (#4438) - Dev: Only log debug messages when NDEBUG is not defined. (#4442) - Dev: Cleaned up theme related code. (#4450) diff --git a/src/common/FlagsEnum.hpp b/src/common/FlagsEnum.hpp index 5eee93879..07d672751 100644 --- a/src/common/FlagsEnum.hpp +++ b/src/common/FlagsEnum.hpp @@ -42,6 +42,12 @@ public: reinterpret_cast(this->value_) |= static_cast(flag); } + /** Adds the flags from `flags` in this enum. */ + void set(FlagsEnum flags) + { + reinterpret_cast(this->value_) |= static_cast(flags.value_); + } + void unset(T flag) { reinterpret_cast(this->value_) &= ~static_cast(flag); @@ -69,6 +75,12 @@ public: return xd; } + FlagsEnum operator|(FlagsEnum rhs) + { + return static_cast(static_cast(this->value_) | + static_cast(rhs.value_)); + } + bool hasAny(FlagsEnum flags) const { return static_cast(this->value_) & static_cast(flags.value_); diff --git a/src/messages/MessageElement.cpp b/src/messages/MessageElement.cpp index 29558a8e3..63d274713 100644 --- a/src/messages/MessageElement.cpp +++ b/src/messages/MessageElement.cpp @@ -110,6 +110,11 @@ MessageElementFlags MessageElement::getFlags() const return this->flags_; } +void MessageElement::addFlags(MessageElementFlags flags) +{ + this->flags_.set(flags); +} + MessageElement *MessageElement::updateLink() { this->linkChanged.invoke(); @@ -234,9 +239,9 @@ MessageLayoutElement *EmoteElement::makeImageLayoutElement( return new ImageLayoutElement(*this, image, size); } -LayeredEmoteElement::LayeredEmoteElement(std::vector &&emotes, - MessageElementFlags flags, - const MessageColor &textElementColor) +LayeredEmoteElement::LayeredEmoteElement( + std::vector &&emotes, MessageElementFlags flags, + const MessageColor &textElementColor) : MessageElement(flags) , emotes_(std::move(emotes)) , textElementColor_(textElementColor) @@ -244,7 +249,7 @@ LayeredEmoteElement::LayeredEmoteElement(std::vector &&emotes, this->updateTooltips(); } -void LayeredEmoteElement::addEmoteLayer(const EmotePtr &emote) +void LayeredEmoteElement::addEmoteLayer(const LayeredEmoteElement::Emote &emote) { this->emotes_.push_back(emote); this->updateTooltips(); @@ -295,9 +300,9 @@ std::vector LayeredEmoteElement::getLoadedImages(float scale) std::vector res; res.reserve(this->emotes_.size()); - for (auto emote : this->emotes_) + for (const auto &emote : this->emotes_) { - auto image = emote->images.getImageOrLoaded(scale); + auto image = emote.ptr->images.getImageOrLoaded(scale); if (image->isEmpty()) { continue; @@ -327,9 +332,9 @@ void LayeredEmoteElement::updateTooltips() std::vector result; result.reserve(this->emotes_.size()); - for (auto &emote : this->emotes_) + for (const auto &emote : this->emotes_) { - result.push_back(emote->tooltip.string); + result.push_back(emote.ptr->tooltip.string); } this->emoteTooltips_ = std::move(result); @@ -349,8 +354,8 @@ QString LayeredEmoteElement::getCleanCopyString() const { result += " "; } - result += - TwitchEmotes::cleanUpEmoteCode(this->emotes_[i]->getCopyString()); + result += TwitchEmotes::cleanUpEmoteCode( + this->emotes_[i].ptr->getCopyString()); } return result; } @@ -364,23 +369,25 @@ QString LayeredEmoteElement::getCopyString() const { result += " "; } - result += this->emotes_[i]->getCopyString(); + result += this->emotes_[i].ptr->getCopyString(); } return result; } -const std::vector &LayeredEmoteElement::getEmotes() const +const std::vector &LayeredEmoteElement::getEmotes() + const { return this->emotes_; } -std::vector LayeredEmoteElement::getUniqueEmotes() const +std::vector LayeredEmoteElement::getUniqueEmotes() + const { // Functor for std::copy_if that keeps track of seen elements struct NotDuplicate { - bool operator()(const EmotePtr &element) + bool operator()(const Emote &element) { - return seen.insert(element).second; + return seen.insert(element.ptr).second; } private: @@ -389,7 +396,7 @@ std::vector LayeredEmoteElement::getUniqueEmotes() const // Get unique emotes while maintaining relative layering order NotDuplicate dup; - std::vector unique; + std::vector unique; std::copy_if(this->emotes_.begin(), this->emotes_.end(), std::back_insert_iterator(unique), dup); diff --git a/src/messages/MessageElement.hpp b/src/messages/MessageElement.hpp index 5f6dcca1d..ddff4bf7a 100644 --- a/src/messages/MessageElement.hpp +++ b/src/messages/MessageElement.hpp @@ -187,6 +187,7 @@ public: const Link &getLink() const; bool hasTrailingSpace() const; MessageElementFlags getFlags() const; + void addFlags(MessageElementFlags flags); MessageElement *updateLink(); virtual void addToContainer(MessageLayoutContainer &container, @@ -325,19 +326,24 @@ private: class LayeredEmoteElement : public MessageElement { public: + struct Emote { + EmotePtr ptr; + MessageElementFlags flags; + }; + LayeredEmoteElement( - std::vector &&emotes, MessageElementFlags flags, + std::vector &&emotes, MessageElementFlags flags, const MessageColor &textElementColor = MessageColor::Text); - void addEmoteLayer(const EmotePtr &emote); + void addEmoteLayer(const Emote &emote); void addToContainer(MessageLayoutContainer &container, MessageElementFlags flags) override; // Returns a concatenation of each emote layer's cleaned copy string QString getCleanCopyString() const; - const std::vector &getEmotes() const; - std::vector getUniqueEmotes() const; + const std::vector &getEmotes() const; + std::vector getUniqueEmotes() const; const std::vector &getEmoteTooltips() const; private: @@ -349,7 +355,7 @@ private: void updateTooltips(); std::vector getLoadedImages(float scale); - std::vector emotes_; + std::vector emotes_; std::vector emoteTooltips_; std::unique_ptr textElement_; diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index 99895a246..3cf157c54 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -1076,17 +1076,20 @@ Outcome TwitchMessageBuilder::tryAppendEmote(const EmoteName &name) // Need to remove EmoteElement and replace with LayeredEmoteElement auto baseEmoteElement = this->releaseBack(); - std::vector layers = {baseEmote, emote.get()}; - this->emplace(std::move(layers), - baseEmoteElement->getFlags(), - this->textColor_); + std::vector layers = { + {baseEmote, baseEmoteElement->getFlags()}, + {emote.get(), flags}}; + this->emplace( + std::move(layers), baseEmoteElement->getFlags() | flags, + this->textColor_); return Success; } auto asLayered = dynamic_cast(&this->back()); if (asLayered) { - asLayered->addEmoteLayer(emote.get()); + asLayered->addEmoteLayer({emote.get(), flags}); + asLayered->addFlags(flags); return Success; } diff --git a/src/widgets/helper/ChannelView.cpp b/src/widgets/helper/ChannelView.cpp index 46a0dcfbb..137c45fbc 100644 --- a/src/widgets/helper/ChannelView.cpp +++ b/src/widgets/helper/ChannelView.cpp @@ -1710,7 +1710,7 @@ void ChannelView::mouseMoveEvent(QMouseEvent *event) for (size_t i = 0; i < upperLimit; ++i) { - auto &emote = layeredEmotes[i]; + const auto &emote = layeredEmotes[i].ptr; if (i == 0) { // First entry gets a large image and full description @@ -2202,10 +2202,10 @@ void ChannelView::addImageContextMenuItems( // Give each emote its own submenu for (auto &emote : layeredElement->getUniqueEmotes()) { - auto emoteAction = menu.addAction(emote->name.string); + auto emoteAction = menu.addAction(emote.ptr->name.string); auto emoteMenu = new QMenu(&menu); emoteAction->setMenu(emoteMenu); - addEmoteContextMenuItems(*emote, creatorFlags, *emoteMenu); + addEmoteContextMenuItems(*emote.ptr, emote.flags, *emoteMenu); } } } From 1ad93b7accbff58470266c9cfd0d9b6d89f386a7 Mon Sep 17 00:00:00 2001 From: pajlada Date: Sun, 19 Mar 2023 12:00:24 +0100 Subject: [PATCH 21/24] Fix an issue where the "Enable zero-width emotes" setting was showing the inverse state (#4462) --- CHANGELOG.md | 1 + src/widgets/settingspages/GeneralPage.cpp | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index deec79543..2b7630554 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Bugfix: Fixed an issue where animated emotes would render on top of zero-width emotes. (#4314) - Bugfix: Fixed an issue where it was difficult to hover a zero-width emote. (#4314) - Bugfix: Fixed an issue where context-menu items for zero-width emotes displayed the wrong provider. (#4460) +- Bugfix: Fixed an issue where the "Enable zero-width emotes" setting was showing the inverse state. (#4462) - Dev: Ignore unhandled BTTV user-events. (#4438) - Dev: Only log debug messages when NDEBUG is not defined. (#4442) - Dev: Cleaned up theme related code. (#4450) diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index b016d68a1..27e9d94a7 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -375,7 +375,7 @@ void GeneralPage::initLayout(GeneralPageView &layout) layout.addCheckbox("Animate only when Chatterino is focused", s.animationsWhenFocused); layout.addCheckbox( - "Enable zero-width emotes", s.enableZeroWidthEmotes, + "Enable zero-width emotes", s.enableZeroWidthEmotes, false, "When disabled, emotes that overlap other emotes, such as BTTV's " "cvMask and 7TV's RainTime, will appear as normal emotes."); layout.addCheckbox("Enable emote auto-completion by typing :", From 130b23edafa9bca634e9a79ab58ab3def307fee7 Mon Sep 17 00:00:00 2001 From: pajlada Date: Sun, 19 Mar 2023 12:28:28 +0100 Subject: [PATCH 22/24] Add a local backup of the Twitch Badges API (#4463) --- .prettierignore | 4 +- CHANGELOG.md | 1 + resources/twitch-badges.json | 1 + src/providers/twitch/TwitchBadges.cpp | 98 +++++++++++++++------------ src/providers/twitch/TwitchBadges.hpp | 5 ++ 5 files changed, 65 insertions(+), 44 deletions(-) create mode 100644 resources/twitch-badges.json diff --git a/.prettierignore b/.prettierignore index c23325ed8..2c684666b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,5 @@ -# emoji.json should remain minified -resources/emoji.json +# JSON resources should not be prettified +resources/*.json # Ignore submodule files lib/*/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b7630554..31f68fe6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unversioned - Minor: Added support for FrankerFaceZ animated emotes. (#4434) +- Minor: Added a local backup of the Twitch Badges API in case the request fails. (#4463) - Bugfix: Fixed an issue where animated emotes would render on top of zero-width emotes. (#4314) - Bugfix: Fixed an issue where it was difficult to hover a zero-width emote. (#4314) - Bugfix: Fixed an issue where context-menu items for zero-width emotes displayed the wrong provider. (#4460) diff --git a/resources/twitch-badges.json b/resources/twitch-badges.json new file mode 100644 index 000000000..12f65d0e6 --- /dev/null +++ b/resources/twitch-badges.json @@ -0,0 +1 @@ +{"badge_sets":{"1979-revolution_1":{"versions":{"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","description":"1979 Revolution","title":"1979 Revolution","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/1979%20Revolution/details","last_updated":null}}},"60-seconds_1":{"versions":{"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","description":"60 Seconds!","title":"60 Seconds!","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/60%20Seconds!/details","last_updated":null}}},"60-seconds_2":{"versions":{"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","description":"60 Seconds!","title":"60 Seconds!","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/60%20Seconds!/details","last_updated":null}}},"60-seconds_3":{"versions":{"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","description":"60 Seconds!","title":"60 Seconds!","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/60%20Seconds!/details","last_updated":null}}},"H1Z1_1":{"versions":{"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","description":"H1Z1","title":"H1Z1","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/H1Z1/details","last_updated":null}}},"admin":{"versions":{"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","description":"Twitch Admin","title":"Admin","click_action":"none","click_url":"","last_updated":null}}},"ambassador":{"versions":{"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","description":"Twitch Ambassador","title":"Twitch Ambassador","click_action":"visit_url","click_url":"https://www.twitch.tv/team/ambassadors","last_updated":null}}},"anomaly-2_1":{"versions":{"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","description":"Anomaly 2","title":"Anomaly 2","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Anomaly%202/details","last_updated":null}}},"anomaly-warzone-earth_1":{"versions":{"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","description":"Anomaly Warzone Earth","title":"Anomaly Warzone Earth","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Anomaly:%20Warzone%20Earth/details","last_updated":null}}},"anonymous-cheerer":{"versions":{"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","description":"Anonymous Cheerer","title":"Anonymous Cheerer","click_action":"none","click_url":"","last_updated":null}}},"artist-badge":{"versions":{"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","description":"Artist on this Channel","title":"Artist","click_action":"none","click_url":"","last_updated":null}}},"axiom-verge_1":{"versions":{"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","description":"Axiom Verge","title":"Axiom Verge","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Axiom%20Verge/details","last_updated":null}}},"battlechefbrigade_1":{"versions":{"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","description":"Battle Chef Brigade","title":"Battle Chef Brigade","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Battle%20Chef%20Brigade/details","last_updated":null}}},"battlechefbrigade_2":{"versions":{"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","description":"Battle Chef Brigade","title":"Battle Chef Brigade","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Battle%20Chef%20Brigade/details","last_updated":null}}},"battlechefbrigade_3":{"versions":{"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","description":"Battle Chef Brigade","title":"Battle Chef Brigade","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Battle%20Chef%20Brigade/details","last_updated":null}}},"battlerite_1":{"versions":{"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","description":"Battlerite","title":"Battlerite","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Battlerite/details","last_updated":null}}},"bits":{"versions":{"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","description":" ","title":"cheer 1","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":" ","title":"cheer 100","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":" ","title":"cheer 1000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":" ","title":"cheer 10000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":" ","title":"cheer 100000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":" ","title":"cheer 1000000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":" ","title":"cheer 1250000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":" ","title":"cheer 1500000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":" ","title":"cheer 1750000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":" ","title":"cheer 200000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":" ","title":"cheer 2000000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":" ","title":"cheer 25000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":" ","title":"cheer 2500000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":" ","title":"cheer 300000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":" ","title":"cheer 3000000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":" ","title":"cheer 3500000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":" ","title":"cheer 400000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":" ","title":"cheer 4000000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":" ","title":"cheer 4500000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":" ","title":"cheer 5000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":" ","title":"cheer 50000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":" ","title":"cheer 500000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":" ","title":"cheer 5000000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":" ","title":"cheer 600000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":" ","title":"cheer 700000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":" ","title":"cheer 75000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":" ","title":"cheer 800000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":" ","title":"cheer 900000","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null}}},"bits-charity":{"versions":{"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","description":"Supported their favorite streamer during the 2018 Blizzard of Bits","title":"Direct Relief - Charity 2018","click_action":"visit_url","click_url":"https://link.twitch.tv/blizzardofbits","last_updated":null}}},"bits-leader":{"versions":{"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","description":"Ranked as a top cheerer on this channel","title":"Bits Leader 1","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":"Ranked as a top cheerer on this channel","title":"Bits Leader 2","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null},"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","description":"Ranked as a top cheerer on this channel","title":"Bits Leader 3","click_action":"visit_url","click_url":"https://bits.twitch.tv","last_updated":null}}},"brawlhalla_1":{"versions":{"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","description":"Brawlhalla","title":"Brawlhalla","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Brawlhalla/details","last_updated":null}}},"broadcaster":{"versions":{"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","description":"Broadcaster","title":"Broadcaster","click_action":"none","click_url":"","last_updated":null}}},"broken-age_1":{"versions":{"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","description":"Broken Age","title":"Broken Age","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Broken%20Age/details","last_updated":null}}},"bubsy-the-woolies_1":{"versions":{"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","description":"Bubsy: The Woolies Strike Back","title":"Bubsy: The Woolies Strike Back","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Bubsy:%20The%20Woolies%20Strike%20Back/details","last_updated":null}}},"chatter-cs-go-2022":{"versions":{"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","description":"Chatted during CS:GO Week Brazil 2022","title":"CS:GO Week Brazil 2022","click_action":"none","click_url":"","last_updated":null}}},"clip-champ":{"versions":{"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","description":"Power Clipper","title":"Power Clipper","click_action":"visit_url","click_url":"https://help.twitch.tv/customer/portal/articles/2918323-clip-champs-guide","last_updated":null}}},"creator-cs-go-2022":{"versions":{"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","description":"Streamed during CS:GO Week Brazil 2022","title":"CS:GO Week Brazil 2022","click_action":"none","click_url":"","last_updated":null}}},"cuphead_1":{"versions":{"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","description":"Cuphead","title":"Cuphead","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Cuphead/details","last_updated":null}}},"darkest-dungeon_1":{"versions":{"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","description":"Darkest Dungeon","title":"Darkest Dungeon","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Darkest%20Dungeon/details","last_updated":null}}},"deceit_1":{"versions":{"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","description":"Deceit","title":"Deceit","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Deceit/details","last_updated":null}}},"devil-may-cry-hd_1":{"versions":{"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","description":"Devil May Cry HD Collection","title":"Devil May Cry HD Collection","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Devil%20May%20Cry%20HD%20Collection/details","last_updated":null}}},"devil-may-cry-hd_2":{"versions":{"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","description":"Devil May Cry HD Collection","title":"Devil May Cry HD Collection","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Devil%20May%20Cry%20HD%20Collection/details","last_updated":null}}},"devil-may-cry-hd_3":{"versions":{"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","description":"Devil May Cry HD Collection","title":"Devil May Cry HD Collection","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Devil%20May%20Cry%20HD%20Collection/details","last_updated":null}}},"devil-may-cry-hd_4":{"versions":{"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","description":"Devil May Cry HD Collection","title":"Devil May Cry HD Collection","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Devil%20May%20Cry%20HD%20Collection/details","last_updated":null}}},"devilian_1":{"versions":{"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","description":"Devilian","title":"Devilian","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Devilian/details","last_updated":null}}},"duelyst_1":{"versions":{"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","description":"Duelyst","title":"Duelyst","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Duelyst/details","last_updated":null}}},"duelyst_2":{"versions":{"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","description":"Duelyst","title":"Duelyst","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Duelyst/details","last_updated":null}}},"duelyst_3":{"versions":{"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","description":"Duelyst","title":"Duelyst","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Duelyst/details","last_updated":null}}},"duelyst_4":{"versions":{"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","description":"Duelyst","title":"Duelyst","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Duelyst/details","last_updated":null}}},"duelyst_5":{"versions":{"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","description":"Duelyst","title":"Duelyst","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Duelyst/details","last_updated":null}}},"duelyst_6":{"versions":{"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","description":"Duelyst","title":"Duelyst","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Duelyst/details","last_updated":null}}},"duelyst_7":{"versions":{"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","description":"Duelyst","title":"Duelyst","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Duelyst/details","last_updated":null}}},"enter-the-gungeon_1":{"versions":{"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","description":"Enter The Gungeon","title":"Enter The Gungeon","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Enter%20the%20Gungeon/details","last_updated":null}}},"eso_1":{"versions":{"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","description":"Elder Scrolls Online","title":"Elder Scrolls Online","click_action":"none","click_url":"","last_updated":null}}},"extension":{"versions":{"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","description":"Extension","title":"Extension","click_action":"none","click_url":"","last_updated":null}}},"firewatch_1":{"versions":{"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","description":"Firewatch","title":"Firewatch","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Firewatch/details","last_updated":null}}},"founder":{"versions":{"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","description":"Founder","title":"Founder","click_action":"visit_url","click_url":"https://help.twitch.tv/s/article/founders-badge","last_updated":null}}},"frozen-cortext_1":{"versions":{"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","description":"Frozen Cortext","title":"Frozen Cortext","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Frozen%20Cortex/details","last_updated":null}}},"frozen-synapse_1":{"versions":{"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","description":"Frozen Synapse","title":"Frozen Synapse","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Frozen%20Synapse/details","last_updated":null}}},"game-developer":{"versions":{"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","description":"Game Developer for:","title":"Game Developer","click_action":"none","click_url":"","last_updated":null}}},"getting-over-it_1":{"versions":{"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","description":"Getting Over It","title":"Getting Over It","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Getting%20Over%20It/details","last_updated":null}}},"getting-over-it_2":{"versions":{"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","description":"Getting Over It","title":"Getting Over It","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Getting%20Over%20It/details","last_updated":null}}},"glhf-pledge":{"versions":{"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","description":"Signed the GLHF pledge in support for inclusive gaming communities","title":"GLHF Pledge","click_action":"visit_url","click_url":"https://www.anykey.org/pledge","last_updated":null}}},"glitchcon2020":{"versions":{"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","description":"Earned for Watching Glitchcon 2020","title":"GlitchCon 2020","click_action":"visit_url","click_url":"https://www.twitchcon.com/","last_updated":null}}},"global_mod":{"versions":{"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","description":"Global Moderator","title":"Global Moderator","click_action":"none","click_url":"","last_updated":null}}},"heavy-bullets_1":{"versions":{"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","description":"Heavy Bullets","title":"Heavy Bullets","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Heavy%20Bullets/details","last_updated":null}}},"hello_neighbor_1":{"versions":{"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","description":"Hello Neighbor","title":"Hello Neighbor","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Hello%20Neighbor/details","last_updated":null}}},"hype-train":{"versions":{"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","description":"Top supporter during the most recent hype train","title":"Current Hype Train Conductor","click_action":"visit_url","click_url":"https://help.twitch.tv/s/article/hype-train-guide","last_updated":null},"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","description":"Top supporter during prior hype trains","title":"Former Hype Train Conductor","click_action":"visit_url","click_url":"https://help.twitch.tv/s/article/hype-train-guide","last_updated":null}}},"innerspace_1":{"versions":{"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","description":"Innerspace","title":"Innerspace","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Innerspace/details","last_updated":null}}},"innerspace_2":{"versions":{"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","description":"Innerspace","title":"Innerspace","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Innerspace/details","last_updated":null}}},"jackbox-party-pack_1":{"versions":{"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","description":"Jackbox Party Pack","title":"Jackbox Party Pack","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/The%20Jackbox%20Party%20Pack/details","last_updated":null}}},"kingdom-new-lands_1":{"versions":{"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","description":"Kingdom: New Lands","title":"Kingdom: New Lands","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Kingdom:%20New%20Lands/details","last_updated":null}}},"moderator":{"versions":{"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","description":"Moderator","title":"Moderator","click_action":"none","click_url":"","last_updated":null}}},"moments":{"versions":{"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","description":"Earned for being a part of at least 1 moment on a channel","title":"Moments Badge - Tier 1","click_action":"none","click_url":"","last_updated":null},"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","description":"Earned for being a part of at least 75 moments on a channel","title":"Moments Badge - Tier 10","click_action":"none","click_url":"","last_updated":null},"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","description":"Earned for being a part of at least 90 moments on a channel","title":"Moments Badge - Tier 11","click_action":"none","click_url":"","last_updated":null},"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","description":"Earned for being a part of at least 105 moments on a channel","title":"Moments Badge - Tier 12","click_action":"none","click_url":"","last_updated":null},"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","description":"Earned for being a part of at least 120 moments on a channel","title":"Moments Badge - Tier 13","click_action":"none","click_url":"","last_updated":null},"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","description":"Earned for being a part of at least 140 moments on a channel","title":"Moments Badge - Tier 14","click_action":"none","click_url":"","last_updated":null},"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","description":"Earned for being a part of at least 160 moments on a channel","title":"Moments Badge - Tier 15","click_action":"none","click_url":"","last_updated":null},"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","description":"Earned for being a part of at least 180 moments on a channel","title":"Moments Badge - Tier 16","click_action":"none","click_url":"","last_updated":null},"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","description":"Earned for being a part of at least 200 moments on a channel","title":"Moments Badge - Tier 17","click_action":"none","click_url":"","last_updated":null},"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","description":"Earned for being a part of at least 225 moments on a channel","title":"Moments Badge - Tier 18","click_action":"none","click_url":"","last_updated":null},"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","description":"Earned for being a part of at least 250 moments on a channel","title":"Moments Badge - Tier 19","click_action":"none","click_url":"","last_updated":null},"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","description":"Earned for being a part of at least 5 moments on a channel","title":"Moments Badge - Tier 2","click_action":"none","click_url":"","last_updated":null},"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","description":"Earned for being a part of at least 275 moments on a channel","title":"Moments Badge - Tier 20","click_action":"none","click_url":"","last_updated":null},"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","description":"Earned for being a part of at least 10 moments on a channel","title":"Moments Badge - Tier 3","click_action":"none","click_url":"","last_updated":null},"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","description":"Earned for being a part of at least 15 moments on a channel","title":"Moments Badge - Tier 4","click_action":"none","click_url":"","last_updated":null},"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","description":"Earned for being a part of at least 20 moments on a channel","title":"Moments Badge - Tier 5","click_action":"none","click_url":"","last_updated":null},"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","description":"Earned for being a part of at least 30 moments on a channel","title":"Moments Badge - Tier 6","click_action":"none","click_url":"","last_updated":null},"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","description":"Earned for being a part of at least 40 moments on a channel","title":"Moments Badge - Tier 7","click_action":"none","click_url":"","last_updated":null},"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","description":"Earned for being a part of at least 50 moments on a channel","title":"Moments Badge - Tier 8","click_action":"none","click_url":"","last_updated":null},"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","description":"Earned for being a part of at least 60 moments on a channel","title":"Moments Badge - Tier 9","click_action":"none","click_url":"","last_updated":null}}},"no_audio":{"versions":{"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","description":"Individuals with unreliable or no sound can select this badge","title":"Watching without audio","click_action":"none","click_url":"","last_updated":null}}},"no_video":{"versions":{"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","description":"Individuals with unreliable or no video can select this badge","title":"Listening only","click_action":"none","click_url":"","last_updated":null}}},"okhlos_1":{"versions":{"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","description":"Okhlos","title":"Okhlos","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Okhlos/details","last_updated":null}}},"overwatch-league-insider_1":{"versions":{"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","description":"OWL All-Access Pass 2018","title":"OWL All-Access Pass 2018","click_action":"visit_url","click_url":"https://www.twitch.tv/overwatchleague","last_updated":null}}},"overwatch-league-insider_2018B":{"versions":{"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","description":"OWL All-Access Pass 2018","title":"OWL All-Access Pass 2018","click_action":"visit_url","click_url":"https://www.twitch.tv/overwatchleague","last_updated":null}}},"overwatch-league-insider_2019A":{"versions":{"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","description":"OWL All-Access Pass 2019","title":"OWL All-Access Pass 2019","click_action":"visit_url","click_url":"https://www.twitch.tv/overwatchleague","last_updated":null},"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","description":"OWL All-Access Pass 2019","title":"OWL All-Access Pass 2019","click_action":"visit_url","click_url":"https://www.twitch.tv/overwatchleague","last_updated":null}}},"overwatch-league-insider_2019B":{"versions":{"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","description":"OWL All-Access Pass 2019","title":"OWL All-Access Pass 2019","click_action":"visit_url","click_url":"https://www.twitch.tv/overwatchleague","last_updated":null},"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","description":"OWL All-Access Pass 2019","title":"OWL All-Access Pass 2019","click_action":"visit_url","click_url":"https://www.twitch.tv/overwatchleague","last_updated":null},"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","description":"OWL All-Access Pass 2019","title":"OWL All-Access Pass 2019","click_action":"visit_url","click_url":"https://www.twitch.tv/overwatchleague","last_updated":null},"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","description":"OWL All-Access Pass 2019","title":"OWL All-Access Pass 2019","click_action":"visit_url","click_url":"https://www.twitch.tv/overwatchleague","last_updated":null},"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","description":"OWL All-Access Pass 2019","title":"OWL All-Access Pass 2019","click_action":"visit_url","click_url":"https://www.twitch.tv/overwatchleague","last_updated":null}}},"partner":{"versions":{"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","description":"Verified","title":"Verified","click_action":"visit_url","click_url":"https://blog.twitch.tv/2017/04/24/the-verified-badge-is-here-13381bc05735","last_updated":null}}},"power-rangers":{"versions":{"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","description":"Black Ranger","title":"Black Ranger","click_action":"none","click_url":"","last_updated":null},"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","description":"Blue Ranger","title":"Blue Ranger","click_action":"none","click_url":"","last_updated":null},"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","description":"Green Ranger","title":"Green Ranger","click_action":"none","click_url":"","last_updated":null},"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","description":"Pink Ranger","title":"Pink Ranger","click_action":"none","click_url":"","last_updated":null},"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","description":"Red Ranger","title":"Red Ranger","click_action":"none","click_url":"","last_updated":null},"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","description":"White Ranger","title":"White Ranger","click_action":"none","click_url":"","last_updated":null},"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","description":"Yellow Ranger","title":"Yellow Ranger","click_action":"none","click_url":"","last_updated":null}}},"predictions":{"versions":{"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","description":"Predicted Outcome One","title":"Predicted Blue (1)","click_action":"none","click_url":"","last_updated":null},"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","description":"Predicted Outcome Ten","title":"Predicted Blue (10)","click_action":"none","click_url":"","last_updated":null},"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","description":"Predicted Outcome Two","title":"Predicted Blue (2)","click_action":"none","click_url":"","last_updated":null},"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","description":"Predicted Outcome Three","title":"Predicted Blue (3)","click_action":"none","click_url":"","last_updated":null},"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","description":"Predicted Outcome Four","title":"Predicted Blue (4)","click_action":"none","click_url":"","last_updated":null},"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","description":"Predicted Outcome Five","title":"Predicted Blue (5)","click_action":"none","click_url":"","last_updated":null},"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","description":"Predicted Outcome Six","title":"Predicted Blue (6)","click_action":"none","click_url":"","last_updated":null},"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","description":"Predicted Outcome Seven","title":"Predicted Blue (7)","click_action":"none","click_url":"","last_updated":null},"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","description":"Predicted Outcome Eight","title":"Predicted Blue (8)","click_action":"none","click_url":"","last_updated":null},"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","description":"Predicted Outcome Nine","title":"Predicted Blue (9)","click_action":"none","click_url":"","last_updated":null},"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","description":"Predicted Gray (1)","title":"Predicted Gray (1)","click_action":"none","click_url":"","last_updated":null},"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","description":"Predicted Gray (2)","title":"Predicted Gray (2)","click_action":"none","click_url":"","last_updated":null},"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","description":"Predicted Outcome One","title":"Predicted Pink (1)","click_action":"none","click_url":"","last_updated":null},"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","description":"Predicted Outcome Two","title":"Predicted Pink (2)","click_action":"none","click_url":"","last_updated":null}}},"premium":{"versions":{"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","description":"Prime Gaming","title":"Prime Gaming","click_action":"visit_url","click_url":"https://gaming.amazon.com","last_updated":null}}},"psychonauts_1":{"versions":{"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","description":"Psychonauts","title":"Psychonauts","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Psychonauts/details","last_updated":null}}},"raiden-v-directors-cut_1":{"versions":{"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","description":"Raiden V","title":"Raiden V","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Raiden%20V/details","last_updated":null}}},"rift_1":{"versions":{"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","description":"RIFT","title":"RIFT","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Rift/details","last_updated":null}}},"samusoffer_beta":{"versions":{"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","description":"beta_title1","title":"beta_title1","click_action":"visit_url","click_url":"https://twitch.amazon.com/prime","last_updated":null}}},"staff":{"versions":{"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","description":"Twitch Staff","title":"Staff","click_action":"visit_url","click_url":"https://www.twitch.tv/jobs?ref=chat_badge","last_updated":null}}},"starbound_1":{"versions":{"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","description":"Starbound","title":"Starbound","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Starbound/details","last_updated":null}}},"strafe_1":{"versions":{"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","description":"Strafe","title":"Strafe","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/strafe/details","last_updated":null}}},"sub-gift-leader":{"versions":{"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","description":"Ranked as a top subscription gifter in this community","title":"Gifter Leader 1","click_action":"none","click_url":"","last_updated":null},"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","description":"Ranked as a top subscription gifter in this community","title":"Gifter Leader 2","click_action":"none","click_url":"","last_updated":null},"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","description":"Ranked as a top subscription gifter in this community","title":"Gifter Leader 3","click_action":"none","click_url":"","last_updated":null}}},"sub-gifter":{"versions":{"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","description":"Has gifted a subscription to another viewer in this community","title":"Sub Gifter","click_action":"none","click_url":"","last_updated":null},"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","description":"Has gifted a subscription to another viewer in this community","title":"10 Gift Subs","click_action":"none","click_url":"","last_updated":null},"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","description":"Has gifted a subscription to another viewer in this community","title":"100 Gift Subs","click_action":"none","click_url":"","last_updated":null},"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","description":"Has gifted a subscription to another viewer in this community","title":"1000 Gift Subs","click_action":"none","click_url":"","last_updated":null},"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","description":"Has gifted a subscription to another viewer in this community","title":"150 Gift Subs","click_action":"none","click_url":"","last_updated":null},"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","description":"Has gifted a subscription to another viewer in this community","title":"200 Gift Subs","click_action":"none","click_url":"","last_updated":null},"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","description":"Has gifted a subscription to another viewer in this community","title":"2000 Gift Subs","click_action":"none","click_url":"","last_updated":null},"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","description":"Has gifted a subscription to another viewer in this community","title":"25 Gift Subs","click_action":"none","click_url":"","last_updated":null},"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","description":"Has gifted a subscription to another viewer in this community","title":"250 Gift Subs","click_action":"none","click_url":"","last_updated":null},"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","description":"Has gifted a subscription to another viewer in this community","title":"300 Gift Subs","click_action":"none","click_url":"","last_updated":null},"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","description":"Has gifted a subscription to another viewer in this community","title":"3000 Gift Subs","click_action":"none","click_url":"","last_updated":null},"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","description":"Has gifted a subscription to another viewer in this community","title":"350 Gift Subs","click_action":"none","click_url":"","last_updated":null},"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","description":"Has gifted a subscription to another viewer in this community","title":"400 Gift Subs","click_action":"none","click_url":"","last_updated":null},"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","description":"Has gifted a subscription to another viewer in this community","title":"4000 Gift Subs","click_action":"none","click_url":"","last_updated":null},"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","description":"Has gifted a subscription to another viewer in this community","title":"450 Gift Subs","click_action":"none","click_url":"","last_updated":null},"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","description":"Has gifted a subscription to another viewer in this community","title":"5 Gift Subs","click_action":"none","click_url":"","last_updated":null},"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","description":"Has gifted a subscription to another viewer in this community","title":"50 Gift Subs","click_action":"none","click_url":"","last_updated":null},"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","description":"Has gifted a subscription to another viewer in this community","title":"500 Gift Subs","click_action":"none","click_url":"","last_updated":null},"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","description":"Has gifted a subscription to another viewer in this community","title":"5000 Gift Subs","click_action":"none","click_url":"","last_updated":null},"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","description":"Has gifted a subscription to another viewer in this community","title":"550 Gift Subs","click_action":"none","click_url":"","last_updated":null},"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","description":"Has gifted a subscription to another viewer in this community","title":"600 Gift Subs","click_action":"none","click_url":"","last_updated":null},"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","description":"Has gifted a subscription to another viewer in this community","title":"650 Gift Subs","click_action":"none","click_url":"","last_updated":null},"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","description":"Has gifted a subscription to another viewer in this community","title":"700 Gift Subs","click_action":"none","click_url":"","last_updated":null},"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","description":"Has gifted a subscription to another viewer in this community","title":"750 Gift Subs","click_action":"none","click_url":"","last_updated":null},"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","description":"Has gifted a subscription to another viewer in this community","title":"800 Gift Subs","click_action":"none","click_url":"","last_updated":null},"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","description":"Has gifted a subscription to another viewer in this community","title":"850 Gift Subs","click_action":"none","click_url":"","last_updated":null},"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","description":"Has gifted a subscription to another viewer in this community","title":"900 Gift Subs","click_action":"none","click_url":"","last_updated":null},"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","description":"Has gifted a subscription to another viewer in this community","title":"950 Gift Subs","click_action":"none","click_url":"","last_updated":null}}},"subscriber":{"versions":{"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","description":"Subscriber","title":"Subscriber","click_action":"subscribe_to_channel","click_url":"","last_updated":null},"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","description":"Subscriber","title":"Subscriber","click_action":"subscribe_to_channel","click_url":"","last_updated":null},"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","description":"2-Month Subscriber","title":"2-Month Subscriber","click_action":"subscribe_to_channel","click_url":"","last_updated":null},"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","description":"3-Month Subscriber","title":"3-Month Subscriber","click_action":"subscribe_to_channel","click_url":"","last_updated":null},"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","description":"6-Month Subscriber","title":"6-Month Subscriber","click_action":"subscribe_to_channel","click_url":"","last_updated":null},"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","description":"9-Month Subscriber","title":"9-Month Subscriber","click_action":"subscribe_to_channel","click_url":"","last_updated":null},"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","description":"1-Year Subscriber","title":"6-Month Subscriber","click_action":"subscribe_to_channel","click_url":"","last_updated":null}}},"superhot_1":{"versions":{"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","description":"Superhot","title":"Superhot","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/superhot/details","last_updated":null}}},"the-surge_1":{"versions":{"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","description":"The Surge","title":"The Surge","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/The%20Surge/details","last_updated":null}}},"the-surge_2":{"versions":{"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","description":"The Surge","title":"The Surge","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/The%20Surge/details","last_updated":null}}},"the-surge_3":{"versions":{"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","description":"The Surge","title":"The Surge","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/The%20Surge/details","last_updated":null}}},"this-war-of-mine_1":{"versions":{"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","description":"This War of Mine","title":"This War of Mine","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/This%20War%20of%20Mine/details","last_updated":null}}},"titan-souls_1":{"versions":{"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","description":"Titan Souls","title":"Titan Souls","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Titan%20Souls/details","last_updated":null}}},"treasure-adventure-world_1":{"versions":{"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","description":"Treasure Adventure World","title":"Treasure Adventure World","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Treasure%20Adventure%20World/details","last_updated":null}}},"turbo":{"versions":{"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","description":"A subscriber of Twitch's monthly premium user service","title":"Turbo","click_action":"turbo","click_url":"","last_updated":null}}},"twitchbot":{"versions":{"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","description":"AutoMod","title":"AutoMod","click_action":"visit_url","click_url":"http://link.twitch.tv/automod_blog","last_updated":null}}},"twitchcon2017":{"versions":{"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","description":"Attended TwitchCon Long Beach 2017","title":"TwitchCon 2017 - Long Beach","click_action":"visit_url","click_url":"https://www.twitchcon.com/","last_updated":null}}},"twitchcon2018":{"versions":{"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","description":"Attended TwitchCon San Jose 2018","title":"TwitchCon 2018 - San Jose","click_action":"visit_url","click_url":"https://www.twitchcon.com/?utm_source=twitch-chat&utm_medium=badge&utm_campaign=tc18","last_updated":null}}},"twitchconAmsterdam2020":{"versions":{"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","description":"Registered for TwitchCon Amsterdam 2020","title":"TwitchCon 2020 - Amsterdam","click_action":"visit_url","click_url":"https://www.twitchcon.com/amsterdam/?utm_source=twitch-chat&utm_medium=badge&utm_campaign=tcamsterdam20","last_updated":null}}},"twitchconEU2019":{"versions":{"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","description":"Attended TwitchCon Berlin 2019","title":"TwitchCon 2019 - Berlin","click_action":"visit_url","click_url":"https://europe.twitchcon.com/?utm_source=twitch-chat&utm_medium=badge&utm_campaign=tceu19","last_updated":null}}},"twitchconEU2022":{"versions":{"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","description":"Attended TwitchCon Amsterdam 2022","title":"TwitchCon 2022 - Amsterdam","click_action":"visit_url","click_url":"https://www.twitchcon.com/amsterdam-2022/?utm_source=twitch-chat&utm_medium=badge&utm_campaign=tceu22","last_updated":null}}},"twitchconNA2019":{"versions":{"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","description":"Attended TwitchCon San Diego 2019","title":"TwitchCon 2019 - San Diego","click_action":"visit_url","click_url":"https://www.twitchcon.com/?utm_source=twitch-chat&utm_medium=badge&utm_campaign=tcna19","last_updated":null}}},"twitchconNA2020":{"versions":{"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","description":"Registered for TwitchCon North America 2020","title":"TwitchCon 2020 - North America","click_action":"visit_url","click_url":"https://www.twitchcon.com/?utm_source=twitch-chat&utm_medium=badge&utm_campaign=tcna20","last_updated":null}}},"twitchconNA2022":{"versions":{"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","description":"Attended TwitchCon San Diego 2022","title":"TwitchCon 2022 - San Diego","click_action":"visit_url","click_url":"https://www.twitchcon.com/san-diego-2022/?utm_source=twitch-chat&utm_medium=badge&utm_campaign=tcna22","last_updated":null}}},"tyranny_1":{"versions":{"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","description":"Tyranny","title":"Tyranny","click_action":"visit_url","click_url":"https://www.twitch.tv/directory/game/Tyranny/details","last_updated":null}}},"user-anniversary":{"versions":{"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","description":"Staff badge celebrating Twitch tenure","title":"Twitchiversary Badge","click_action":"none","click_url":"","last_updated":null}}},"vga-champ-2017":{"versions":{"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","description":"2017 VGA Champ","title":"2017 VGA Champ","click_action":"visit_url","click_url":"https://blog.twitch.tv/watch-and-co-stream-the-game-awards-this-thursday-on-twitch-3d8e34d2345d","last_updated":null}}},"vip":{"versions":{"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","description":"VIP","title":"VIP","click_action":"visit_url","click_url":"https://help.twitch.tv/customer/en/portal/articles/659115-twitch-chat-badges-guide","last_updated":null}}},"warcraft":{"versions":{"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","description":"For Lordaeron!","title":"Alliance","click_action":"visit_url","click_url":"http://warcraftontwitch.tv/","last_updated":null},"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","description":"For the Horde!","title":"Horde","click_action":"visit_url","click_url":"http://warcraftontwitch.tv/","last_updated":null}}}}} diff --git a/src/providers/twitch/TwitchBadges.cpp b/src/providers/twitch/TwitchBadges.cpp index 2eb0b1910..e959f66cb 100644 --- a/src/providers/twitch/TwitchBadges.cpp +++ b/src/providers/twitch/TwitchBadges.cpp @@ -9,10 +9,11 @@ #include "util/DisplayBadge.hpp" #include +#include #include #include #include -#include +#include #include #include #include @@ -36,58 +37,71 @@ void TwitchBadges::loadTwitchBadges() NetworkRequest(url) .onSuccess([this](auto result) -> Outcome { - { - auto root = result.parseJson(); - auto badgeSets = this->badgeSets_.access(); + auto root = result.parseJson(); - 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(); + this->parseTwitchBadges(root); - for (auto vIt = versions.begin(); vIt != versions.end(); - ++vIt) - { - auto versionObj = vIt.value().toObject(); - - auto emote = Emote{ - {""}, - ImageSet{ - Image::fromUrl({versionObj.value("image_url_1x") - .toString()}, - 1), - Image::fromUrl({versionObj.value("image_url_2x") - .toString()}, - .5), - Image::fromUrl({versionObj.value("image_url_4x") - .toString()}, - .25), - }, - Tooltip{versionObj.value("title").toString()}, - Url{versionObj.value("click_url").toString()}}; - // "title" - // "clickAction" - - (*badgeSets)[key][vIt.key()] = - std::make_shared(emote); - } - } - } this->loaded(); return Success; }) .onError([this](auto res) { - qCDebug(chatterinoTwitch) - << "Error loading Twitch Badges:" << res.status(); - // 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 badges API:" + << res.status() << " - falling back to backup"; + 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(); }) .execute(); } +void TwitchBadges::parseTwitchBadges(QJsonObject root) +{ + auto badgeSets = this->badgeSets_.access(); + + 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(); + + for (auto vIt = versions.begin(); vIt != versions.end(); ++vIt) + { + auto versionObj = vIt.value().toObject(); + + auto emote = Emote{ + {""}, + ImageSet{ + Image::fromUrl( + {versionObj.value("image_url_1x").toString()}, 1), + Image::fromUrl( + {versionObj.value("image_url_2x").toString()}, .5), + Image::fromUrl( + {versionObj.value("image_url_4x").toString()}, .25), + }, + Tooltip{versionObj.value("title").toString()}, + Url{versionObj.value("click_url").toString()}}; + // "title" + // "clickAction" + + (*badgeSets)[key][vIt.key()] = std::make_shared(emote); + } + } +} + void TwitchBadges::loaded() { std::unique_lock loadedLock(this->loadedMutex_); diff --git a/src/providers/twitch/TwitchBadges.hpp b/src/providers/twitch/TwitchBadges.hpp index 5d371d5a9..a87e2246d 100644 --- a/src/providers/twitch/TwitchBadges.hpp +++ b/src/providers/twitch/TwitchBadges.hpp @@ -6,6 +6,7 @@ #include #include #include +#include #include #include @@ -49,6 +50,10 @@ private: TwitchBadges(); void loadTwitchBadges(); + /** + * @brief Accepts a JSON blob from https://badges.twitch.tv/v1/badges/global/display and updates our badges with it + **/ + void parseTwitchBadges(QJsonObject root); void loaded(); void loadEmoteImage(const QString &name, ImagePtr image, BadgeIconCallback &&callback); From 7b6094909ee807558263c30a5ebc97fa968ce170 Mon Sep 17 00:00:00 2001 From: Scrub <72096833+ScrubN@users.noreply.github.com> Date: Sun, 19 Mar 2023 08:29:46 -0400 Subject: [PATCH 23/24] Include reply mention when logging (#4420) Co-authored-by: Felanbird <41973452+Felanbird@users.noreply.github.com> Co-authored-by: pajlada --- CHANGELOG.md | 1 + src/singletons/helper/LoggingChannel.cpp | 16 +++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 31f68fe6a..02165b25c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unversioned +- Minor: Include normally-stripped mention in replies in logs. (#4420) - Minor: Added support for FrankerFaceZ animated emotes. (#4434) - Minor: Added a local backup of the Twitch Badges API in case the request fails. (#4463) - Bugfix: Fixed an issue where animated emotes would render on top of zero-width emotes. (#4314) diff --git a/src/singletons/helper/LoggingChannel.cpp b/src/singletons/helper/LoggingChannel.cpp index 24da8d203..9f10f87c3 100644 --- a/src/singletons/helper/LoggingChannel.cpp +++ b/src/singletons/helper/LoggingChannel.cpp @@ -2,6 +2,7 @@ #include "common/QLogging.hpp" #include "messages/Message.hpp" +#include "messages/MessageThread.hpp" #include "singletons/Paths.hpp" #include "singletons/Settings.hpp" @@ -104,7 +105,20 @@ void LoggingChannel::addMessage(MessagePtr message) str.append(now.toString("HH:mm:ss")); str.append("] "); - str.append(message->searchText); + QString messageSearchText = message->searchText; + if ((message->flags.has(MessageFlag::ReplyMessage) && + getSettings()->stripReplyMention) && + !getSettings()->hideReplyContext) + { + qsizetype colonIndex = messageSearchText.indexOf(':'); + if (colonIndex != -1) + { + QString rootMessageChatter = + message->replyThread->root()->loginName; + messageSearchText.insert(colonIndex + 1, " @" + rootMessageChatter); + } + } + str.append(messageSearchText); str.append(endline); this->appendLine(str); From 19cc72f92741cf82dc7e549075e8c23a29d67cd6 Mon Sep 17 00:00:00 2001 From: nerix Date: Thu, 23 Mar 2023 23:04:16 +0100 Subject: [PATCH 24/24] Respect PCH Setting in Windows CI (#4472) --- .github/workflows/build.yml | 1 + CHANGELOG.md | 1 + src/util/WindowsHelper.hpp | 1 + 3 files changed, 3 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8a1eca01d..a4b39938f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -151,6 +151,7 @@ jobs: -G"NMake Makefiles" ` -DCMAKE_BUILD_TYPE=RelWithDebInfo ` -DCMAKE_TOOLCHAIN_FILE="conan_toolchain.cmake" ` + -DUSE_PRECOMPILED_HEADERS=${{ matrix.pch }} ` -DBUILD_WITH_CRASHPAD="$Env:C2_ENABLE_CRASHPAD" ` -DCHATTERINO_LTO="$Env:C2_ENABLE_LTO" ` -DBUILD_WITH_QT6="$Env:C2_BUILD_WITH_QT6" ` diff --git a/CHANGELOG.md b/CHANGELOG.md index 02165b25c..a79b0c316 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Bugfix: Fixed an issue where it was difficult to hover a zero-width emote. (#4314) - Bugfix: Fixed an issue where context-menu items for zero-width emotes displayed the wrong provider. (#4460) - Bugfix: Fixed an issue where the "Enable zero-width emotes" setting was showing the inverse state. (#4462) +- Dev: Disabling precompiled headers on Windows is now tested in CI. (#4472) - Dev: Ignore unhandled BTTV user-events. (#4438) - Dev: Only log debug messages when NDEBUG is not defined. (#4442) - Dev: Cleaned up theme related code. (#4450) diff --git a/src/util/WindowsHelper.hpp b/src/util/WindowsHelper.hpp index 478368b81..0cf2cbd2d 100644 --- a/src/util/WindowsHelper.hpp +++ b/src/util/WindowsHelper.hpp @@ -3,6 +3,7 @@ #ifdef USEWINSDK # include +# include # include namespace chatterino {