From 7aaec5f5c6a47f4ebaa21aa1c9df4d740b89ab30 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 28 Mar 2023 08:08:49 +0200 Subject: [PATCH 01/38] Bump ZedThree/clang-tidy-review from 0.12.1 to 0.12.2 (#4484) Bumps [ZedThree/clang-tidy-review](https://github.com/ZedThree/clang-tidy-review) from 0.12.1 to 0.12.2. - [Release notes](https://github.com/ZedThree/clang-tidy-review/releases) - [Changelog](https://github.com/ZedThree/clang-tidy-review/blob/master/CHANGELOG.md) - [Commits](https://github.com/ZedThree/clang-tidy-review/compare/v0.12.1...v0.12.2) --- updated-dependencies: - dependency-name: ZedThree/clang-tidy-review dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 4 ++-- .github/workflows/post-clang-tidy-review.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3c7062098b5..0c03dc20f92 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -247,7 +247,7 @@ jobs: - name: clang-tidy review if: (startsWith(matrix.os, 'ubuntu') && matrix.pch == false && matrix.qt-version == '5.15.2' && github.event_name == 'pull_request') - uses: ZedThree/clang-tidy-review@v0.12.1 + uses: ZedThree/clang-tidy-review@v0.12.2 with: build_dir: build config_file: ".clang-tidy" @@ -256,7 +256,7 @@ jobs: - name: clang-tidy-review upload if: (startsWith(matrix.os, 'ubuntu') && matrix.pch == false && matrix.qt-version == '5.15.2' && github.event_name == 'pull_request') - uses: ZedThree/clang-tidy-review/upload@v0.12.1 + uses: ZedThree/clang-tidy-review/upload@v0.12.2 - name: Package - AppImage (Ubuntu) if: startsWith(matrix.os, 'ubuntu-20.04') && matrix.skip_artifact != 'yes' diff --git a/.github/workflows/post-clang-tidy-review.yml b/.github/workflows/post-clang-tidy-review.yml index 5757ae2fb08..c0b9c805172 100644 --- a/.github/workflows/post-clang-tidy-review.yml +++ b/.github/workflows/post-clang-tidy-review.yml @@ -12,6 +12,6 @@ jobs: runs-on: ubuntu-latest steps: - - uses: ZedThree/clang-tidy-review/post@v0.12.1 + - uses: ZedThree/clang-tidy-review/post@v0.12.2 with: lgtm_comment_body: "" From 08ae43e88ea7cc521d3a1d53ab6e3c7b104b7235 Mon Sep 17 00:00:00 2001 From: nerix Date: Wed, 29 Mar 2023 10:21:35 +0200 Subject: [PATCH 02/38] Add Placeholder Color in Palette (#4477) Co-authored-by: pajlada --- CHANGELOG.md | 1 + src/RunGui.cpp | 16 +++++++++------- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b7b879fcf8..df14b585351 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - 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) - Bugfix: Fixed username rendering in Qt 6. (#4476) +- Bugfix: Fixed placeholder color in Qt 6. (#4477) - Bugfix: Fixed blocked user list being empty when opening the settings dialog for the first time. (#4437) - Bugfix: Fixed blocked user list sticking around when switching from a logged in user to being logged out. (#4437) - Dev: Disabling precompiled headers on Windows is now tested in CI. (#4472) diff --git a/src/RunGui.cpp b/src/RunGui.cpp index 49c800a69c9..1f08d1a6418 100644 --- a/src/RunGui.cpp +++ b/src/RunGui.cpp @@ -44,28 +44,30 @@ namespace { dark.setColor(QPalette::Window, QColor(22, 22, 22)); dark.setColor(QPalette::WindowText, Qt::white); dark.setColor(QPalette::Text, Qt::white); - dark.setColor(QPalette::Disabled, QPalette::WindowText, - QColor(127, 127, 127)); dark.setColor(QPalette::Base, QColor("#333")); dark.setColor(QPalette::AlternateBase, QColor("#444")); dark.setColor(QPalette::ToolTipBase, Qt::white); dark.setColor(QPalette::ToolTipText, Qt::white); - dark.setColor(QPalette::Disabled, QPalette::Text, - QColor(127, 127, 127)); dark.setColor(QPalette::Dark, QColor(35, 35, 35)); dark.setColor(QPalette::Shadow, QColor(20, 20, 20)); dark.setColor(QPalette::Button, QColor(70, 70, 70)); dark.setColor(QPalette::ButtonText, Qt::white); - dark.setColor(QPalette::Disabled, QPalette::ButtonText, - QColor(127, 127, 127)); dark.setColor(QPalette::BrightText, Qt::red); dark.setColor(QPalette::Link, QColor(42, 130, 218)); dark.setColor(QPalette::Highlight, QColor(42, 130, 218)); + dark.setColor(QPalette::HighlightedText, Qt::white); + dark.setColor(QPalette::PlaceholderText, QColor(127, 127, 127)); + dark.setColor(QPalette::Disabled, QPalette::Highlight, QColor(80, 80, 80)); - dark.setColor(QPalette::HighlightedText, Qt::white); dark.setColor(QPalette::Disabled, QPalette::HighlightedText, QColor(127, 127, 127)); + dark.setColor(QPalette::Disabled, QPalette::ButtonText, + QColor(127, 127, 127)); + dark.setColor(QPalette::Disabled, QPalette::Text, + QColor(127, 127, 127)); + dark.setColor(QPalette::Disabled, QPalette::WindowText, + QColor(127, 127, 127)); qApp->setPalette(dark); } From 7a081fdcfbe1642638a7ddeb76222cac460e4c78 Mon Sep 17 00:00:00 2001 From: Scrub <72096833+ScrubN@users.noreply.github.com> Date: Thu, 30 Mar 2023 19:10:05 -0400 Subject: [PATCH 03/38] Add ScrubN to contributors list (#4490) --- resources/contributors.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/resources/contributors.txt b/resources/contributors.txt index 035bd2bf27f..e73ee499b9c 100644 --- a/resources/contributors.txt +++ b/resources/contributors.txt @@ -58,6 +58,7 @@ Explooosion | https://github.com/Explooosion-code | :/avatars/explooosion_code.p mohad12211 | https://github.com/mohad12211 | :/avatars/mohad12211.png | Contributor Wissididom | https://github.com/Wissididom | :/avatars/wissididom.png | Contributor 03y | https://github.com/03y | | Contributor +ScrubN | https://github.com/ScrubN | | Contributor # If you are a contributor add yourself above this line From 0177ab48290749f6bfafbb8e8608ccd8382c4d4c Mon Sep 17 00:00:00 2001 From: pajlada Date: Sat, 1 Apr 2023 13:23:18 +0200 Subject: [PATCH 04/38] Ensure tests have default-initialized settings (#4498) Also rework HighlightController test directory creation/saving to ensure the test directory is written to & cleaned up appropriately --- CHANGELOG.md | 1 + src/singletons/Settings.cpp | 2 ++ tests/src/HighlightController.cpp | 18 +++++++++--------- tests/src/main.cpp | 4 ++++ 4 files changed, 16 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df14b585351..b450f052d3e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - 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) +- Dev: Ensure tests have default-initialized settings. (#4498) ## 2.4.2 diff --git a/src/singletons/Settings.cpp b/src/singletons/Settings.cpp index a4cf20f9632..b32623c428a 100644 --- a/src/singletons/Settings.cpp +++ b/src/singletons/Settings.cpp @@ -140,6 +140,8 @@ Settings::Settings(const QString &settingsDirectory) Settings &Settings::instance() { + assert(instance_ != nullptr); + return *instance_; } diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index eecb9cc3754..a3f37b26b1a 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -550,14 +550,14 @@ class HighlightControllerTest : public ::testing::Test protected: void SetUp() override { - { - // Write default settings to the mock settings json file - QDir().mkpath("/tmp/c2-tests"); - QFile settingsFile("/tmp/c2-tests/settings.json"); - assert(settingsFile.open(QIODevice::WriteOnly | QIODevice::Text)); - QTextStream out(&settingsFile); - out << DEFAULT_SETTINGS; - } + // Write default settings to the mock settings json file + ASSERT_TRUE(QDir().mkpath("/tmp/c2-tests")); + + QFile settingsFile("/tmp/c2-tests/settings.json"); + ASSERT_TRUE(settingsFile.open(QIODevice::WriteOnly | QIODevice::Text)); + ASSERT_GT(settingsFile.write(DEFAULT_SETTINGS.toUtf8()), 0); + ASSERT_TRUE(settingsFile.flush()); + settingsFile.close(); this->mockHelix = new MockHelix; @@ -579,7 +579,7 @@ class HighlightControllerTest : public ::testing::Test void TearDown() override { - QDir().rmdir("/tmp/c2-tests"); + ASSERT_TRUE(QDir("/tmp/c2-tests").removeRecursively()); this->mockApplication.reset(); this->settings.reset(); this->paths.reset(); diff --git a/tests/src/main.cpp b/tests/src/main.cpp index d6452c5a000..098d5b36600 100644 --- a/tests/src/main.cpp +++ b/tests/src/main.cpp @@ -4,6 +4,7 @@ #include "common/Outcome.hpp" #include "common/QLogging.hpp" #include "providers/twitch/api/Helix.hpp" +#include "singletons/Settings.hpp" #include #include @@ -27,6 +28,9 @@ int main(int argc, char **argv) chatterino::NetworkManager::init(); + // Ensure settings are initialized before any tests are run + chatterino::Settings settings("/tmp/c2-empty-test"); + QtConcurrent::run([&app] { auto res = RUN_ALL_TESTS(); From d8df716fc204f7426b65d7d78a0204b0b7d6d834 Mon Sep 17 00:00:00 2001 From: pajlada Date: Sat, 1 Apr 2023 13:57:48 +0200 Subject: [PATCH 05/38] Fix memory leak when using the Recent Messages API (#4499) --- CHANGELOG.md | 1 + src/providers/RecentMessagesApi.cpp | 2 ++ 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b450f052d3e..77c4fb790f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Bugfix: Fixed placeholder color in Qt 6. (#4477) - Bugfix: Fixed blocked user list being empty when opening the settings dialog for the first time. (#4437) - Bugfix: Fixed blocked user list sticking around when switching from a logged in user to being logged out. (#4437) +- Bugfix: Fixed a memory leak that occurred when loading message history. This was mostly noticeable with unstable internet connections where reconnections were frequent or long-running instances of Chatterino. (#4499) - 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) diff --git a/src/providers/RecentMessagesApi.cpp b/src/providers/RecentMessagesApi.cpp index dad429d3c69..9fd524f684d 100644 --- a/src/providers/RecentMessagesApi.cpp +++ b/src/providers/RecentMessagesApi.cpp @@ -140,6 +140,8 @@ namespace { builtMessage->flags.set(MessageFlag::RecentMessage); allBuiltMessages.emplace_back(builtMessage); } + + message->deleteLater(); } return allBuiltMessages; From 92c9137d10bb5a699f64821d51fa786d744c0215 Mon Sep 17 00:00:00 2001 From: Brian <18603393+brian6932@users.noreply.github.com> Date: Sat, 1 Apr 2023 08:05:11 -0400 Subject: [PATCH 06/38] docs: Windows, better natvis script (#4489) Just a bit cleaner, no need for props when using `Inovke-RestMethod`, and swapped regex replace to string replace, since no regex was used. --- BUILDING_ON_WINDOWS.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BUILDING_ON_WINDOWS.md b/BUILDING_ON_WINDOWS.md index cd89e44268b..027e0f23c89 100644 --- a/BUILDING_ON_WINDOWS.md +++ b/BUILDING_ON_WINDOWS.md @@ -227,9 +227,9 @@ https://github.com/qt-labs/vstools/blob/0769d945f8d0040917d654d9731e6b65951e102c --> ```powershell -(iwr "https://github.com/qt-labs/vstools/raw/dev/QtVsTools.Package/qt5.natvis.xml").Content -replace '##NAMESPACE##::', '' | Out-File qt5.natvis +(irm "https://github.com/qt-labs/vstools/raw/dev/QtVsTools.Package/qt5.natvis.xml").Replace('##NAMESPACE##::', '') | Out-File qt5.natvis # [OR] using the permalink -(iwr "https://github.com/qt-labs/vstools/raw/0769d945f8d0040917d654d9731e6b65951e102c/QtVsTools.Package/qt5.natvis.xml").Content -replace '##NAMESPACE##::', '' | Out-File qt5.natvis +(irm "https://github.com/qt-labs/vstools/raw/0769d945f8d0040917d654d9731e6b65951e102c/QtVsTools.Package/qt5.natvis.xml").Replace('##NAMESPACE##::', '') | Out-File qt5.natvis ``` Now you can debug the application and see QT types rendered correctly. From b209c50b019dc3c2c07309745efd85f5cb16e47a Mon Sep 17 00:00:00 2001 From: kornes <28986062+kornes@users.noreply.github.com> Date: Sat, 1 Apr 2023 12:34:34 +0000 Subject: [PATCH 07/38] Fix channel search when custom scrollback limit is used (#4496) --- CHANGELOG.md | 1 + src/common/Channel.cpp | 1 + src/widgets/helper/SearchPopup.cpp | 6 ++++-- 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 77c4fb790f5..965ea6236b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Bugfix: Fixed placeholder color in Qt 6. (#4477) - Bugfix: Fixed blocked user list being empty when opening the settings dialog for the first time. (#4437) - Bugfix: Fixed blocked user list sticking around when switching from a logged in user to being logged out. (#4437) +- Bugfix: Fixed search popup ignoring setting for message scrollback limit. (#4496) - Bugfix: Fixed a memory leak that occurred when loading message history. This was mostly noticeable with unstable internet connections where reconnections were frequent or long-running instances of Chatterino. (#4499) - Dev: Disabling precompiled headers on Windows is now tested in CI. (#4472) - Dev: Ignore unhandled BTTV user-events. (#4438) diff --git a/src/common/Channel.cpp b/src/common/Channel.cpp index e47362572da..a0c963b0bc3 100644 --- a/src/common/Channel.cpp +++ b/src/common/Channel.cpp @@ -28,6 +28,7 @@ Channel::Channel(const QString &name, Type type) : completionModel(*this) , lastDate_(QDate::currentDate()) , name_(name) + , messages_(getSettings()->scrollbackSplitLimit) , type_(type) { } diff --git a/src/widgets/helper/SearchPopup.cpp b/src/widgets/helper/SearchPopup.cpp index 0c0f055272f..75f80e231af 100644 --- a/src/widgets/helper/SearchPopup.cpp +++ b/src/widgets/helper/SearchPopup.cpp @@ -13,6 +13,7 @@ #include "messages/search/RegexPredicate.hpp" #include "messages/search/SubstringPredicate.hpp" #include "messages/search/SubtierPredicate.hpp" +#include "singletons/Settings.hpp" #include "singletons/WindowManager.hpp" #include "widgets/helper/ChannelView.hpp" #include "widgets/splits/Split.hpp" @@ -285,8 +286,9 @@ void SearchPopup::initLayout() // CHANNELVIEW { - this->channelView_ = new ChannelView(this, this->split_, - ChannelView::Context::Search); + this->channelView_ = new ChannelView( + this, this->split_, ChannelView::Context::Search, + getSettings()->scrollbackSplitLimit); layout1->addWidget(this->channelView_); } From 281bddb4cf4614a0cd6c0b014c1bb42fc5e94e9a Mon Sep 17 00:00:00 2001 From: nerix Date: Sun, 2 Apr 2023 12:48:22 +0200 Subject: [PATCH 08/38] Upgrade from Conan 1.x to 2.x (#4417) Conan 1.x is no longer supported - upgrade if you used it for dependency management Co-authored-by: Rasmus Karlsson --- .github/workflows/build.yml | 34 ++++++++++++++---------- BUILDING_ON_WINDOWS.md | 12 ++++----- CHANGELOG.md | 1 + conanfile.py | 52 +++++++++++++++++++++++++++++++++++++ conanfile.txt | 15 ----------- 5 files changed, 79 insertions(+), 35 deletions(-) create mode 100644 conanfile.py delete mode 100644 conanfile.txt diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0c03dc20f92..cbb2d3045c4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -108,19 +108,19 @@ jobs: version: ${{ matrix.qt-version }} # WINDOWS - - name: Cache conan packages part 1 + - name: Setup conan variables (Windows) if: startsWith(matrix.os, 'windows') - uses: actions/cache@v3 - with: - key: ${{ runner.os }}-${{ matrix.crashpad }}-conan-user-${{ hashFiles('**/conanfile.txt') }} - path: ~/.conan/ + run: | + "C2_USE_OPENSSL3=$(if ($Env:C2_BUILD_WITH_QT6 -eq "on") { "True" } else { "False" })" >> "$Env:GITHUB_ENV" + "C2_CONAN_CACHE_SUFFIX=$(if ($Env:C2_BUILD_WITH_QT6 -eq "on") { "-QT6" } else { "`" })" >> "$Env:GITHUB_ENV" + shell: powershell - - name: Cache conan packages part 2 + - name: Cache conan packages if: startsWith(matrix.os, 'windows') uses: actions/cache@v3 with: - key: ${{ runner.os }}-${{ matrix.crashpad }}-conan-root-${{ hashFiles('**/conanfile.txt') }} - path: C:/.conan/ + key: ${{ runner.os }}-conan-user-${{ hashFiles('**/conanfile.py') }}${{ env.C2_CONAN_CACHE_SUFFIX }} + path: ~/.conan2/ - name: Add Conan to path if: startsWith(matrix.os, 'windows') @@ -129,7 +129,7 @@ jobs: - name: Install dependencies (Windows) if: startsWith(matrix.os, 'windows') run: | - choco install conan -y --version 1.58.0 + choco install conan -y - name: Enable Developer Command Prompt if: startsWith(matrix.os, 'windows') @@ -138,15 +138,21 @@ jobs: - name: Setup Conan (Windows) if: startsWith(matrix.os, 'windows') run: | - conan profile new --detect --force default - conan profile update conf.tools.cmake.cmaketoolchain:generator="NMake Makefiles" default + conan --version + conan profile detect -f + shell: powershell - name: Build (Windows) if: startsWith(matrix.os, 'windows') run: | mkdir build cd build - conan install .. -s build_type=RelWithDebInfo -b missing -pr:b=default + conan install .. ` + -s build_type=RelWithDebInfo ` + -c tools.cmake.cmaketoolchain:generator="NMake Makefiles" ` + -b missing ` + --output-folder=. ` + -o with_openssl3="$Env:C2_USE_OPENSSL3" cmake ` -G"NMake Makefiles" ` -DCMAKE_BUILD_TYPE=RelWithDebInfo ` @@ -192,9 +198,9 @@ jobs: name: chatterino-windows-x86-64-${{ matrix.qt-version }}-symbols.pdb.7z path: build/bin/chatterino.pdb.7z - - name: Clean Conan pkgs + - name: Clean Conan cache if: startsWith(matrix.os, 'windows') - run: conan remove "*" -fsb + run: conan cache clean --source --build --download "*" shell: bash # LINUX diff --git a/BUILDING_ON_WINDOWS.md b/BUILDING_ON_WINDOWS.md index 027e0f23c89..29e3cc54a1f 100644 --- a/BUILDING_ON_WINDOWS.md +++ b/BUILDING_ON_WINDOWS.md @@ -79,16 +79,14 @@ Note: This installation will take about 200 MB of disk space. ### Using CMake -#### Install conan +#### Install conan 2 -Install [conan](https://conan.io/downloads.html) and make sure it's in your `PATH` (default setting). +Install [conan 2](https://conan.io/downloads.html) and make sure it's in your `PATH` (default setting). Then in a terminal, configure conan to use `NMake Makefiles` as its generator: 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` + `conan profile detect` #### Build @@ -96,10 +94,12 @@ Open up your terminal with the Visual Studio environment variables (e.g. `x64 Na 1. `mkdir build` 1. `cd build` -1. `conan install .. -s build_type=Release --build=missing` +1. `conan install .. -s build_type=Release -c tools.cmake.cmaketoolchain:generator="NMake Makefiles" --build=missing --output-folder=.` 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` +To build a debug build, you'll also need to add the `-s compiler.runtime_type=Debug` flag to the `conan install` invocation. See [this StackOverflow post](https://stackoverflow.com/questions/59828611/windeployqt-doesnt-deploy-qwindowsd-dll-for-a-debug-application/75607313#75607313) + #### Ensure DLLs are available Once Chatterino has finished building, to ensure all .dll's are available you can run this from the build directory: diff --git a/CHANGELOG.md b/CHANGELOG.md index 965ea6236b0..f3c0478bd4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - Dev: Only log debug messages when NDEBUG is not defined. (#4442) - Dev: Cleaned up theme related code. (#4450) - Dev: Ensure tests have default-initialized settings. (#4498) +- Dev: Conan 2.0 is now used instead of Conan 1.0. (#4417) ## 2.4.2 diff --git a/conanfile.py b/conanfile.py new file mode 100644 index 00000000000..7db329b8ba7 --- /dev/null +++ b/conanfile.py @@ -0,0 +1,52 @@ +from conan import ConanFile +from conan.tools.files import copy +from os import path + + +class Chatterino(ConanFile): + name = "Chatterino" + requires = "boost/1.81.0" + settings = "os", "compiler", "build_type", "arch" + default_options = { + "with_benchmark": False, + "with_openssl3": False, + "openssl*:shared": True, + } + options = { + "with_benchmark": [True, False], + # Qt is built with OpenSSL 3 from version 6.5.0 onwards + "with_openssl3": [True, False], + } + generators = "CMakeDeps", "CMakeToolchain" + + def requirements(self): + if self.options.get_safe("with_benchmark", False): + self.requires("benchmark/1.7.1") + + if self.options.get_safe("with_openssl3", False): + self.requires("openssl/3.1.0") + else: + self.requires("openssl/1.1.1t") + + def generate(self): + copy_bin = lambda dep, selector, subdir: copy( + self, + selector, + dep.cpp_info.bindirs[0], + path.join(self.build_folder, subdir), + keep_path=False, + ) + for dep in self.dependencies.values(): + # macOS + copy_bin(dep, "*.dylib", "bin") + # Windows + copy_bin(dep, "*.dll", "bin") + copy_bin(dep, "*.dll", "Chatterino2") # used in CI + # Linux + copy( + self, + "*.so*", + dep.cpp_info.libdirs[0], + path.join(self.build_folder, "bin"), + keep_path=False, + ) diff --git a/conanfile.txt b/conanfile.txt deleted file mode 100644 index 52f1d9109aa..00000000000 --- a/conanfile.txt +++ /dev/null @@ -1,15 +0,0 @@ -[requires] -openssl/1.1.1s -boost/1.80.0 - -[generators] -CMakeDeps -CMakeToolchain - -[options] -openssl:shared=True - -[imports] -bin, *.dll -> ./bin @ keep_path=False -bin, *.dll -> ./Chatterino2 @ keep_path=False -lib, *.so* -> ./bin @ keep_path=False From 5836073d52a710b1cbd26d2dfd2d0d2541cdaf60 Mon Sep 17 00:00:00 2001 From: nerix Date: Sun, 2 Apr 2023 14:44:43 +0200 Subject: [PATCH 09/38] Update `lib/settings` and `lib/signals` (#4503) Co-authored-by: pajlada --- lib/settings | 2 +- lib/signals | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/lib/settings b/lib/settings index 04792d853c7..f168c0997fb 160000 --- a/lib/settings +++ b/lib/settings @@ -1 +1 @@ -Subproject commit 04792d853c7f83c9d7ab4df00279442a658d3be3 +Subproject commit f168c0997fb85789bbc54513fce8bbc212dda2ff diff --git a/lib/signals b/lib/signals index 25e4ec3b8d6..6561aa559ff 160000 --- a/lib/signals +++ b/lib/signals @@ -1 +1 @@ -Subproject commit 25e4ec3b8d6ea94a5e65a26e7cfcbbce3b87c5d6 +Subproject commit 6561aa559ff47cbad4058b8144d4a72fd14edc29 From 5ba809804e610f2e62db4cf6c7a98655f4a15c6c Mon Sep 17 00:00:00 2001 From: Mm2PL Date: Sun, 2 Apr 2023 15:31:53 +0200 Subject: [PATCH 10/38] Add basic lua scripting capabilities (#4341) The scripting capabilities is locked behind a cmake flag, and is not enabled by default. Co-authored-by: nerix Co-authored-by: pajlada --- .clang-tidy | 4 + .github/workflows/build.yml | 15 +- .gitmodules | 3 + CHANGELOG.md | 1 + CMakeLists.txt | 6 + docs/chatterino.d.ts | 22 ++ docs/plugin-info.schema.json | 49 +++ docs/wip-plugins.md | 177 +++++++++ lib/lua/CMakeLists.txt | 53 +++ lib/lua/src | 1 + resources/licenses/fluenticons.txt | 21 ++ resources/licenses/lua.txt | 7 + resources/settings/plugins.svg | 13 + src/Application.cpp | 6 + src/Application.hpp | 7 + src/CMakeLists.txt | 22 ++ src/common/CompletionModel.cpp | 7 +- src/common/CompletionModel.hpp | 3 + src/common/QLogging.cpp | 1 + src/common/QLogging.hpp | 1 + .../commands/CommandController.cpp | 27 ++ .../commands/CommandController.hpp | 12 + src/controllers/plugins/LuaAPI.cpp | 338 ++++++++++++++++++ src/controllers/plugins/LuaAPI.hpp | 28 ++ src/controllers/plugins/LuaUtilities.cpp | 196 ++++++++++ src/controllers/plugins/LuaUtilities.hpp | 191 ++++++++++ src/controllers/plugins/Plugin.cpp | 177 +++++++++ src/controllers/plugins/Plugin.hpp | 98 +++++ src/controllers/plugins/PluginController.cpp | 302 ++++++++++++++++ src/controllers/plugins/PluginController.hpp | 68 ++++ src/singletons/Paths.cpp | 1 + src/singletons/Paths.hpp | 3 + src/singletons/Settings.hpp | 4 + src/widgets/dialogs/SettingsDialog.cpp | 4 + src/widgets/settingspages/AboutPage.cpp | 7 + src/widgets/settingspages/PluginsPage.cpp | 185 ++++++++++ src/widgets/settingspages/PluginsPage.hpp | 30 ++ 37 files changed, 2087 insertions(+), 3 deletions(-) create mode 100644 docs/chatterino.d.ts create mode 100644 docs/plugin-info.schema.json create mode 100644 docs/wip-plugins.md create mode 100644 lib/lua/CMakeLists.txt create mode 160000 lib/lua/src create mode 100644 resources/licenses/fluenticons.txt create mode 100644 resources/licenses/lua.txt create mode 100644 resources/settings/plugins.svg create mode 100644 src/controllers/plugins/LuaAPI.cpp create mode 100644 src/controllers/plugins/LuaAPI.hpp create mode 100644 src/controllers/plugins/LuaUtilities.cpp create mode 100644 src/controllers/plugins/LuaUtilities.hpp create mode 100644 src/controllers/plugins/Plugin.cpp create mode 100644 src/controllers/plugins/Plugin.hpp create mode 100644 src/controllers/plugins/PluginController.cpp create mode 100644 src/controllers/plugins/PluginController.hpp create mode 100644 src/widgets/settingspages/PluginsPage.cpp create mode 100644 src/widgets/settingspages/PluginsPage.hpp diff --git a/.clang-tidy b/.clang-tidy index b701ee666c2..d75c5dce243 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -53,3 +53,7 @@ CheckOptions: value: camelBack - key: readability-implicit-bool-conversion.AllowPointerConditions value: true + + # Lua state + - key: readability-identifier-naming.LocalPointerIgnoredRegexp + value: ^L$ diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index cbb2d3045c4..01a9d68d940 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,6 +27,7 @@ jobs: qt-version: [5.15.2, 5.12.12] pch: [true] force-lto: [false] + plugins: [false] skip_artifact: ["no"] crashpad: [true] include: @@ -45,12 +46,13 @@ jobs: qt-version: 6.2.4 pch: false force-lto: false - # Test for disabling Precompiled Headers & enabling link-time optimization + # Test for disabling Precompiled Headers & enabling link-time optimization and plugins - os: ubuntu-22.04 qt-version: 5.15.2 pch: false force-lto: true skip_artifact: "yes" + plugins: true # Test for disabling crashpad on Windows - os: windows-latest qt-version: 5.15.2 @@ -58,6 +60,7 @@ jobs: force-lto: true skip_artifact: "yes" crashpad: false + fail-fast: false steps: @@ -67,6 +70,12 @@ jobs: echo "C2_ENABLE_LTO=ON" >> "$GITHUB_ENV" shell: bash + - name: Enable plugin support + if: matrix.plugins == true + run: | + echo "C2_PLUGINS=ON" >> "$GITHUB_ENV" + shell: bash + - name: Set Crashpad if: matrix.crashpad == true run: | @@ -160,6 +169,7 @@ jobs: -DUSE_PRECOMPILED_HEADERS=${{ matrix.pch }} ` -DBUILD_WITH_CRASHPAD="$Env:C2_ENABLE_CRASHPAD" ` -DCHATTERINO_LTO="$Env:C2_ENABLE_LTO" ` + -DCHATTERINO_PLUGINS="$Env:C2_PLUGINS" ` -DBUILD_WITH_QT6="$Env:C2_BUILD_WITH_QT6" ` .. set cl=/MP @@ -246,6 +256,7 @@ jobs: -DUSE_PRECOMPILED_HEADERS=${{ matrix.pch }} \ -DCMAKE_EXPORT_COMPILE_COMMANDS=On \ -DCHATTERINO_LTO="$C2_ENABLE_LTO" \ + -DCHATTERINO_PLUGINS="$C2_PLUGINS" \ -DBUILD_WITH_QT6="$C2_BUILD_WITH_QT6" \ .. make -j"$(nproc)" @@ -310,6 +321,7 @@ jobs: -DOPENSSL_ROOT_DIR=/usr/local/opt/openssl \ -DUSE_PRECOMPILED_HEADERS=${{ matrix.pch }} \ -DCHATTERINO_LTO="$C2_ENABLE_LTO" \ + -DCHATTERINO_PLUGINS="$C2_PLUGINS" \ -DBUILD_WITH_QT6="$C2_BUILD_WITH_QT6" \ .. make -j"$(sysctl -n hw.logicalcpu)" @@ -331,7 +343,6 @@ jobs: with: name: chatterino-osx-${{ matrix.qt-version }}.dmg path: build/chatterino-osx.dmg - create-release: needs: build runs-on: ubuntu-latest diff --git a/.gitmodules b/.gitmodules index 741e3104110..571cc0f44e6 100644 --- a/.gitmodules +++ b/.gitmodules @@ -35,6 +35,9 @@ [submodule "lib/miniaudio"] path = lib/miniaudio url = https://github.com/mackron/miniaudio.git +[submodule "lib/lua/src"] + path = lib/lua/src + url = https://github.com/lua/lua [submodule "lib/crashpad"] path = lib/crashpad url = https://github.com/getsentry/crashpad diff --git a/CHANGELOG.md b/CHANGELOG.md index f3c0478bd4d..7dcd34cdef0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ - Dev: Only log debug messages when NDEBUG is not defined. (#4442) - Dev: Cleaned up theme related code. (#4450) - Dev: Ensure tests have default-initialized settings. (#4498) +- Dev: Add scripting capabilities with Lua (#4341) - Dev: Conan 2.0 is now used instead of Conan 1.0. (#4417) ## 2.4.2 diff --git a/CMakeLists.txt b/CMakeLists.txt index cdaa5719f65..bc3844e6049 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,6 +25,7 @@ option(CHATTERINO_GENERATE_COVERAGE "Generate coverage files" OFF) option(BUILD_TRANSLATIONS "" OFF) option(BUILD_SHARED_LIBS "" OFF) option(CHATTERINO_LTO "Enable LTO for all targets" OFF) +option(CHATTERINO_PLUGINS "Enable EXPERIMENTAL plugin support in Chatterino" OFF) if(CHATTERINO_LTO) include(CheckIPOSupported) @@ -156,6 +157,11 @@ else() add_subdirectory("${CMAKE_SOURCE_DIR}/lib/settings" EXCLUDE_FROM_ALL) endif() +if (CHATTERINO_PLUGINS) + set(LUA_INCLUDE_DIRS "${CMAKE_SOURCE_DIR}/lib/lua/src") + add_subdirectory(lib/lua) +endif() + if (BUILD_WITH_CRASHPAD) add_subdirectory("${CMAKE_SOURCE_DIR}/lib/crashpad" EXCLUDE_FROM_ALL) endif() diff --git a/docs/chatterino.d.ts b/docs/chatterino.d.ts new file mode 100644 index 00000000000..c2efdb1ba8a --- /dev/null +++ b/docs/chatterino.d.ts @@ -0,0 +1,22 @@ +/** @noSelfInFile */ + +declare module c2 { + enum LogLevel { + Debug, + Info, + Warning, + Critical, + } + class CommandContext { + words: String[]; + channel_name: String; + } + + function log(level: LogLevel, ...data: any[]): void; + function register_command( + name: String, + handler: (ctx: CommandContext) => void + ): boolean; + function send_msg(channel: String, text: String): boolean; + function system_msg(channel: String, text: String): boolean; +} diff --git a/docs/plugin-info.schema.json b/docs/plugin-info.schema.json new file mode 100644 index 00000000000..da4750c1a37 --- /dev/null +++ b/docs/plugin-info.schema.json @@ -0,0 +1,49 @@ +{ + "$schema": "http://json-schema.org/schema", + "$id": "https://raw.githubusercontent.com/Chatterino/chatterino2/master/docs/plugin-info.schema.json", + "title": "Plugin info", + "description": "Describes a Chatterino2 plugin (draft)", + "type": "object", + "additionalProperties": false, + "properties": { + "name": { + "type": "string", + "description": "Plugin name shown to the user." + }, + "description": { + "type": "string", + "description": "Plugin description shown to the user." + }, + "authors": { + "type": "array", + "description": "An array of authors of this Plugin.", + "items": { + "type": "string" + } + }, + "homepage": { + "type": "string", + "description": "Optional URL to your Plugin's homepage. This could be your GitHub repo for example." + }, + "tags": { + "description": "Something that could in the future be used to find your plugin.", + "type": "array", + "items": { + "type": "string", + "examples": ["moderation", "utility", "commands"] + }, + "uniqueItems": true + }, + "version": { + "type": "string", + "description": "Semver version string, for more info see https://semver.org.", + "examples": ["0.0.1", "1.0.0-rc.1"] + }, + "license": { + "type": "string", + "description": "A small description of your license.", + "examples": ["MIT", "GPL-2.0-or-later"] + } + }, + "required": ["name", "description", "authors", "version", "license"] +} diff --git a/docs/wip-plugins.md b/docs/wip-plugins.md new file mode 100644 index 00000000000..d27f0ceda60 --- /dev/null +++ b/docs/wip-plugins.md @@ -0,0 +1,177 @@ +# Plugins + +If Chatterino is compiled with the `CHATTERINO_PLUGINS` CMake option, it can +load and execute Lua files. Note that while there are attempts at making this +decently safe, we cannot guarantee safety. + +## Plugin structure + +Chatterino searches for plugins in the `Plugins` directory in the app data, right next to `Settings` and `Logs`. + +Each plugin should have its own directory. + +``` +Chatterino Plugins dir/ +└── plugin_name/ + ├── init.lua + └── info.json +``` + +`init.lua` will be the file loaded when the plugin is enabled. You may load other files using [`import` global function](#importfilename=). + +`info.json` contains metadata about the plugin, like its name, description, +authors, homepage link, tags, version, license name. The version field **must** +be [semver 2.0](https://semver.org/) compliant. The general idea of `info.json` +will not change however the exact contents probably will, for example with +permission system ideas. +Example file: + +```json +{ + "$schema": "https://raw.githubusercontent.com/Chatterino/chatterino2/master/docs/plugin-info.schema.json", + "name": "Test plugin", + "description": "This plugin is for testing stuff.", + "authors": "Mm2PL", + "homepage": "https://github.com/Chatterino/Chatterino2", + "tags": ["test"], + "version": "0.0.0", + "license": "MIT" +} +``` + +An example plugin is available at [https://github.com/Mm2PL/Chatterino-test-plugin](https://github.com/Mm2PL/Chatterino-test-plugin) + +## Plugins with Typescript + +If you prefer, you may use [TypescriptToLua](https://typescripttolua.github.io) +to typecheck your plugins. There is a `chatterino.d.ts` file describing the API +in this directory. However this has several drawbacks like harder debugging at +runtime. + +## API + +The following parts of the Lua standard library are loaded: + +- `_G` (most globals) +- `table` +- `string` +- `math` +- `utf8` + +The official manual for them is available [here](https://www.lua.org/manual/5.4/manual.html#6). + +### Chatterino API + +All Chatterino functions are exposed in a global table called `c2`. The following members are available: + +#### `log(level, args...)` + +Writes a message to the Chatterino log. The `level` argument should be a +`LogLevel` member. All `args` should be convertible to a string with +`tostring()`. + +Example: + +```lua +c2.log(c2.LogLevel.Warning, "Hello, this should show up in the Chatterino log by default") + +c2.log(c2.LogLevel.Debug, "Hello world") +-- Equivalent to doing qCDebug(chatterinoLua) << "[pluginDirectory:Plugin Name]" << "Hello, world"; from C++ +``` + +#### `LogLevel` enum + +This table describes log levels available to Lua Plugins. The values behind the names may change, do not count on them. It has the following keys: + +- `Debug` +- `Info` +- `Warning` +- `Critical` + +#### `register_command(name, handler)` + +Registers a new command called `name` which when executed will call `handler`. +Returns `true` if everything went ok, `false` if there already exists another +command with this name. + +Example: + +```lua +function cmdWords(ctx) + -- ctx contains: + -- words - table of words supplied to the command including the trigger + -- channelName - name of the channel the command is being run in + c2.system_msg(ctx.channelName, "Words are: " .. table.concat(ctx.words, " ")) +end + +c2.register_command("/words", cmdWords) +``` + +Limitations/known issues: + +- Commands registered in functions, not in the global scope might not show up in the settings UI, + rebuilding the window content caused by reloading another plugin will solve this. +- Spaces in command names aren't handled very well (https://github.com/Chatterino/chatterino2/issues/1517). + +#### `send_msg(channel, text)` + +Sends a message to `channel` with the specified text. Also executes commands. + +Example: + +```lua +function cmdShout(ctx) + table.remove(ctx.words, 1) + local output = table.concat(ctx.words, " ") + c2.send_msg(ctx.channelName, string.upper(output)) +end +c2.register_command("/shout", cmdShout) +``` + +Limitations/Known issues: + +- It is possible to trigger your own Lua command with this causing a potentially infinite loop. + +#### `system_msg(channel, text)` + +Creates a system message and adds it to the twitch channel specified by +`channel`. Returns `true` if everything went ok, `false` otherwise. It will +throw an error if the number of arguments received doesn't match what it +expects. + +Example: + +```lua +local ok = c2.system_msg("pajlada", "test") +if (not ok) + -- channel not found +end +``` + +### Changed globals + +#### `load(chunk [, chunkname [, mode [, env]]])` + +This function is only available if Chatterino is compiled in debug mode. It is meant for debugging with little exception. +This function behaves really similarity to Lua's `load`, however it does not allow for bytecode to be executed. +It achieves this by forcing all inputs to be encoded with `UTF-8`. + +See [official documentation](https://www.lua.org/manual/5.4/manual.html#pdf-load) + +#### `import(filename)` + +This function mimics Lua's `dofile` however relative paths are relative to your plugin's directory. +You are restricted to loading files in your plugin's directory. You cannot load files with bytecode inside. + +Example: + +```lua +import("stuff.lua") -- executes Plugins/name/stuff.lua +import("./stuff.lua") -- executes Plugins/name/stuff.lua +import("../stuff.lua") -- tries to load Plugins/stuff.lua and errors +import("luac.out") -- tried to load Plugins/name/luac.out and errors because it contains non-utf8 data +``` + +#### `print(Args...)` + +The `print` global function is equivalent to calling `c2.log(c2.LogLevel.Debug, Args...)` diff --git a/lib/lua/CMakeLists.txt b/lib/lua/CMakeLists.txt new file mode 100644 index 00000000000..086f5949511 --- /dev/null +++ b/lib/lua/CMakeLists.txt @@ -0,0 +1,53 @@ +project(lua CXX) + +#[====[ +Updating this list: +remove all listed files +go to line below, ^y2j4j$@" and then reindent the file names +/LUA_SRC +:r!ls lib/lua/src | grep '\.c' | grep -Ev 'lua\.c|onelua\.c' | sed 's#^#src/#' + +#]====] +set(LUA_SRC + "src/lapi.c" + "src/lauxlib.c" + "src/lbaselib.c" + "src/lcode.c" + "src/lcorolib.c" + "src/lctype.c" + "src/ldblib.c" + "src/ldebug.c" + "src/ldo.c" + "src/ldump.c" + "src/lfunc.c" + "src/lgc.c" + "src/linit.c" + "src/liolib.c" + "src/llex.c" + "src/lmathlib.c" + "src/lmem.c" + "src/loadlib.c" + "src/lobject.c" + "src/lopcodes.c" + "src/loslib.c" + "src/lparser.c" + "src/lstate.c" + "src/lstring.c" + "src/lstrlib.c" + "src/ltable.c" + "src/ltablib.c" + "src/ltests.c" + "src/ltm.c" + "src/lua.c" + "src/lundump.c" + "src/lutf8lib.c" + "src/lvm.c" + "src/lzio.c" +) + +add_library(lua STATIC ${LUA_SRC}) +target_include_directories(lua + PUBLIC + ${LUA_INCLUDE_DIRS} +) +set_source_files_properties(${LUA_SRC} PROPERTIES LANGUAGE CXX) diff --git a/lib/lua/src b/lib/lua/src new file mode 160000 index 00000000000..5d708c3f9ca --- /dev/null +++ b/lib/lua/src @@ -0,0 +1 @@ +Subproject commit 5d708c3f9cae12820e415d4f89c9eacbe2ab964b diff --git a/resources/licenses/fluenticons.txt b/resources/licenses/fluenticons.txt new file mode 100644 index 00000000000..bc9c36b28f4 --- /dev/null +++ b/resources/licenses/fluenticons.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Microsoft Corporation + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/resources/licenses/lua.txt b/resources/licenses/lua.txt new file mode 100644 index 00000000000..b6ed3539e3c --- /dev/null +++ b/resources/licenses/lua.txt @@ -0,0 +1,7 @@ +Copyright © 1994–2021 Lua.org, PUC-Rio. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/resources/settings/plugins.svg b/resources/settings/plugins.svg new file mode 100644 index 00000000000..e4314ddf206 --- /dev/null +++ b/resources/settings/plugins.svg @@ -0,0 +1,13 @@ + + + + + + + diff --git a/src/Application.cpp b/src/Application.cpp index 7804169ce06..b794a79667f 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -10,6 +10,9 @@ #include "controllers/hotkeys/HotkeyController.hpp" #include "controllers/ignores/IgnoreController.hpp" #include "controllers/notifications/NotificationController.hpp" +#ifdef CHATTERINO_HAVE_PLUGINS +# include "controllers/plugins/PluginController.hpp" +#endif #include "controllers/sound/SoundController.hpp" #include "controllers/userdata/UserDataController.hpp" #include "debug/AssertInGuiThread.hpp" @@ -85,6 +88,9 @@ Application::Application(Settings &_settings, Paths &_paths) , seventvBadges(&this->emplace()) , userData(&this->emplace()) , sound(&this->emplace()) +#ifdef CHATTERINO_HAVE_PLUGINS + , plugins(&this->emplace()) +#endif , logging(&this->emplace()) { this->instance = this; diff --git a/src/Application.hpp b/src/Application.hpp index dada8d02a09..7c55255055c 100644 --- a/src/Application.hpp +++ b/src/Application.hpp @@ -20,6 +20,9 @@ class HotkeyController; class IUserDataController; class UserDataController; class SoundController; +#ifdef CHATTERINO_HAVE_PLUGINS +class PluginController; +#endif class Theme; class WindowManager; @@ -95,6 +98,10 @@ class Application : public IApplication UserDataController *const userData{}; SoundController *const sound{}; +#ifdef CHATTERINO_HAVE_PLUGINS + PluginController *const plugins{}; +#endif + /*[[deprecated]]*/ Logging *const logging{}; Theme *getThemes() override diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 98368033c33..e5bb4741384 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -136,6 +136,15 @@ set(SOURCE_FILES controllers/pings/MutedChannelModel.cpp controllers/pings/MutedChannelModel.hpp + controllers/plugins/LuaAPI.cpp + controllers/plugins/LuaAPI.hpp + controllers/plugins/Plugin.cpp + controllers/plugins/Plugin.hpp + controllers/plugins/PluginController.hpp + controllers/plugins/PluginController.cpp + controllers/plugins/LuaUtilities.cpp + controllers/plugins/LuaUtilities.hpp + controllers/userdata/UserDataController.cpp controllers/userdata/UserDataController.hpp controllers/userdata/UserData.hpp @@ -545,6 +554,8 @@ set(SOURCE_FILES widgets/settingspages/NicknamesPage.hpp widgets/settingspages/NotificationPage.cpp widgets/settingspages/NotificationPage.hpp + widgets/settingspages/PluginsPage.cpp + widgets/settingspages/PluginsPage.hpp widgets/settingspages/SettingsPage.cpp widgets/settingspages/SettingsPage.hpp @@ -590,6 +601,14 @@ list(APPEND SOURCE_FILES ${RES_AUTOGEN_FILES}) add_library(${LIBRARY_PROJECT} OBJECT ${SOURCE_FILES}) +if(CHATTERINO_PLUGINS) + target_compile_definitions(${LIBRARY_PROJECT} + PRIVATE + CHATTERINO_HAVE_PLUGINS + ) + message(STATUS "Building Chatterino with lua plugin support enabled.") +endif() + if (CHATTERINO_GENERATE_COVERAGE) include(CodeCoverage) append_coverage_compiler_flags_to_target(${LIBRARY_PROJECT}) @@ -624,6 +643,9 @@ target_link_libraries(${LIBRARY_PROJECT} LRUCache MagicEnum ) +if (CHATTERINO_PLUGINS) + target_link_libraries(${LIBRARY_PROJECT} PUBLIC lua) +endif() if (BUILD_WITH_QT6) target_link_libraries(${LIBRARY_PROJECT} diff --git a/src/common/CompletionModel.cpp b/src/common/CompletionModel.cpp index 08bddf2e8b7..9b123aa4c6a 100644 --- a/src/common/CompletionModel.cpp +++ b/src/common/CompletionModel.cpp @@ -230,7 +230,12 @@ void CompletionModel::refresh(const QString &prefix, bool isFirstWord) { addString(emote.first.string, TaggedString::Type::BTTVGlobalEmote); } - +#ifdef CHATTERINO_HAVE_PLUGINS + for (const auto &command : getApp()->commands->pluginCommands()) + { + addString(command, TaggedString::PluginCommand); + } +#endif // Custom Chatterino commands for (const auto &command : getApp()->commands->items) { diff --git a/src/common/CompletionModel.hpp b/src/common/CompletionModel.hpp index c2670c08ee4..5b46fb2de3b 100644 --- a/src/common/CompletionModel.hpp +++ b/src/common/CompletionModel.hpp @@ -34,6 +34,9 @@ class CompletionModel : public QAbstractListModel CustomCommand, ChatterinoCommand, TwitchCommand, +#ifdef CHATTERINO_HAVE_PLUGINS + PluginCommand, +#endif }; TaggedString(QString _string, Type type); diff --git a/src/common/QLogging.cpp b/src/common/QLogging.cpp index de902b18a6f..fb9afa26363 100644 --- a/src/common/QLogging.cpp +++ b/src/common/QLogging.cpp @@ -24,6 +24,7 @@ Q_LOGGING_CATEGORY(chatterinoIrc, "chatterino.irc", logThreshold); Q_LOGGING_CATEGORY(chatterinoIvr, "chatterino.ivr", logThreshold); Q_LOGGING_CATEGORY(chatterinoLiveupdates, "chatterino.liveupdates", logThreshold); +Q_LOGGING_CATEGORY(chatterinoLua, "chatterino.lua", logThreshold); Q_LOGGING_CATEGORY(chatterinoMain, "chatterino.main", logThreshold); Q_LOGGING_CATEGORY(chatterinoMessage, "chatterino.message", logThreshold); Q_LOGGING_CATEGORY(chatterinoNativeMessage, "chatterino.nativemessage", diff --git a/src/common/QLogging.hpp b/src/common/QLogging.hpp index 8c8f0d6c49e..d3585f18c8d 100644 --- a/src/common/QLogging.hpp +++ b/src/common/QLogging.hpp @@ -19,6 +19,7 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoImage); Q_DECLARE_LOGGING_CATEGORY(chatterinoIrc); Q_DECLARE_LOGGING_CATEGORY(chatterinoIvr); Q_DECLARE_LOGGING_CATEGORY(chatterinoLiveupdates); +Q_DECLARE_LOGGING_CATEGORY(chatterinoLua); Q_DECLARE_LOGGING_CATEGORY(chatterinoMain); Q_DECLARE_LOGGING_CATEGORY(chatterinoMessage); Q_DECLARE_LOGGING_CATEGORY(chatterinoNativeMessage); diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index a77dc722f86..ae1ce9093c6 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -10,6 +10,7 @@ #include "controllers/commands/Command.hpp" #include "controllers/commands/CommandContext.hpp" #include "controllers/commands/CommandModel.hpp" +#include "controllers/plugins/PluginController.hpp" #include "controllers/userdata/UserDataController.hpp" #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" @@ -3261,6 +3262,32 @@ QString CommandController::execCommand(const QString &textNoEmoji, return text; } +#ifdef CHATTERINO_HAVE_PLUGINS +bool CommandController::registerPluginCommand(const QString &commandName) +{ + if (this->commands_.contains(commandName)) + { + return false; + } + + this->commands_[commandName] = [commandName](const CommandContext &ctx) { + return getApp()->plugins->tryExecPluginCommand(commandName, ctx); + }; + this->pluginCommands_.append(commandName); + return true; +} + +bool CommandController::unregisterPluginCommand(const QString &commandName) +{ + if (!this->pluginCommands_.contains(commandName)) + { + return false; + } + this->pluginCommands_.removeAll(commandName); + return this->commands_.erase(commandName) != 0; +} +#endif + void CommandController::registerCommand(const QString &commandName, CommandFunctionVariants commandFunction) { diff --git a/src/controllers/commands/CommandController.hpp b/src/controllers/commands/CommandController.hpp index 3816fa71db0..b7135279c76 100644 --- a/src/controllers/commands/CommandController.hpp +++ b/src/controllers/commands/CommandController.hpp @@ -42,6 +42,15 @@ class CommandController final : public Singleton const QStringList &words, const Command &command, bool dryRun, ChannelPtr channel, const Message *message = nullptr, std::unordered_map context = {}); +#ifdef CHATTERINO_HAVE_PLUGINS + bool registerPluginCommand(const QString &commandName); + bool unregisterPluginCommand(const QString &commandName); + + const QStringList &pluginCommands() + { + return this->pluginCommands_; + } +#endif private: void load(Paths &paths); @@ -73,6 +82,9 @@ class CommandController final : public Singleton commandsSetting_; QStringList defaultChatterinoCommandAutoCompletions_; +#ifdef CHATTERINO_HAVE_PLUGINS + QStringList pluginCommands_; +#endif }; } // namespace chatterino diff --git a/src/controllers/plugins/LuaAPI.cpp b/src/controllers/plugins/LuaAPI.cpp new file mode 100644 index 00000000000..ff57d7e282b --- /dev/null +++ b/src/controllers/plugins/LuaAPI.cpp @@ -0,0 +1,338 @@ +#ifdef CHATTERINO_HAVE_PLUGINS +# include "controllers/plugins/LuaAPI.hpp" + +# include "Application.hpp" +# include "common/QLogging.hpp" +# include "controllers/commands/CommandController.hpp" +# include "controllers/plugins/LuaUtilities.hpp" +# include "controllers/plugins/PluginController.hpp" +# include "messages/MessageBuilder.hpp" +# include "providers/twitch/TwitchIrcServer.hpp" + +# include +# include +# include +# include +# include +# include + +namespace { +using namespace chatterino; + +void logHelper(lua_State *L, Plugin *pl, QDebug stream, int argc) +{ + stream.noquote(); + stream << "[" + pl->id + ":" + pl->meta.name + "]"; + for (int i = 1; i <= argc; i++) + { + stream << lua::toString(L, i); + } + lua_pop(L, argc); +} + +QDebug qdebugStreamForLogLevel(lua::api::LogLevel lvl) +{ + auto base = + (QMessageLogger(QT_MESSAGELOG_FILE, QT_MESSAGELOG_LINE, + QT_MESSAGELOG_FUNC, chatterinoLua().categoryName())); + + using LogLevel = lua::api::LogLevel; + + switch (lvl) + { + case LogLevel::Debug: + return base.debug(); + case LogLevel::Info: + return base.info(); + case LogLevel::Warning: + return base.warning(); + case LogLevel::Critical: + return base.critical(); + default: + assert(false && "if this happens magic_enum must have failed us"); + return {(QString *)nullptr}; + } +} + +} // namespace + +// NOLINTBEGIN(*vararg) +// luaL_error is a c-style vararg function, this makes clang-tidy not dislike it so much +namespace chatterino::lua::api { + +int c2_register_command(lua_State *L) +{ + auto *pl = getApp()->plugins->getPluginByStatePtr(L); + if (pl == nullptr) + { + luaL_error(L, "internal error: no plugin"); + return 0; + } + + QString name; + if (!lua::peek(L, &name, 1)) + { + luaL_error(L, "cannot get command name (1st arg of register_command, " + "expected a string)"); + return 0; + } + if (lua_isnoneornil(L, 2)) + { + luaL_error(L, "missing argument for register_command: function " + "\"pointer\""); + return 0; + } + + auto callbackSavedName = QString("c2commandcb-%1").arg(name); + lua_setfield(L, LUA_REGISTRYINDEX, callbackSavedName.toStdString().c_str()); + auto ok = pl->registerCommand(name, callbackSavedName); + + // delete both name and callback + lua_pop(L, 2); + + lua::push(L, ok); + return 1; +} + +int c2_send_msg(lua_State *L) +{ + QString text; + QString channel; + if (lua_gettop(L) != 2) + { + luaL_error(L, "send_msg needs exactly 2 arguments (channel and text)"); + lua::push(L, false); + return 1; + } + if (!lua::pop(L, &text)) + { + luaL_error( + L, "cannot get text (2nd argument of send_msg, expected a string)"); + lua::push(L, false); + return 1; + } + if (!lua::pop(L, &channel)) + { + luaL_error( + L, + "cannot get channel (1st argument of send_msg, expected a string)"); + lua::push(L, false); + return 1; + } + + const auto chn = getApp()->twitch->getChannelOrEmpty(channel); + if (chn->isEmpty()) + { + auto *pl = getApp()->plugins->getPluginByStatePtr(L); + + qCWarning(chatterinoLua) + << "Plugin" << pl->id + << "tried to send a message (using send_msg) to channel" << channel + << "which is not known"; + lua::push(L, false); + return 1; + } + QString message = text; + message = message.replace('\n', ' '); + QString outText = getApp()->commands->execCommand(message, chn, false); + chn->sendMessage(outText); + lua::push(L, true); + return 1; +} + +int c2_system_msg(lua_State *L) +{ + if (lua_gettop(L) != 2) + { + luaL_error(L, + "system_msg needs exactly 2 arguments (channel and text)"); + lua::push(L, false); + return 1; + } + QString channel; + QString text; + + if (!lua::pop(L, &text)) + { + luaL_error( + L, + "cannot get text (2nd argument of system_msg, expected a string)"); + lua::push(L, false); + return 1; + } + if (!lua::pop(L, &channel)) + { + luaL_error(L, "cannot get channel (1st argument of system_msg, " + "expected a string)"); + lua::push(L, false); + return 1; + } + const auto chn = getApp()->twitch->getChannelOrEmpty(channel); + if (chn->isEmpty()) + { + auto *pl = getApp()->plugins->getPluginByStatePtr(L); + qCWarning(chatterinoLua) + << "Plugin" << pl->id + << "tried to show a system message (using system_msg) in channel" + << channel << "which is not known"; + lua::push(L, false); + return 1; + } + chn->addMessage(makeSystemMessage(text)); + lua::push(L, true); + return 1; +} + +int c2_log(lua_State *L) +{ + auto *pl = getApp()->plugins->getPluginByStatePtr(L); + if (pl == nullptr) + { + luaL_error(L, "c2_log: internal error: no plugin?"); + return 0; + } + auto logc = lua_gettop(L) - 1; + // This is almost the expansion of qCDebug() macro, actual thing is wrapped in a for loop + LogLevel lvl{}; + if (!lua::pop(L, &lvl, 1)) + { + luaL_error(L, "Invalid log level, use one from c2.LogLevel."); + return 0; + } + QDebug stream = qdebugStreamForLogLevel(lvl); + logHelper(L, pl, stream, logc); + return 0; +} + +int g_load(lua_State *L) +{ +# ifdef NDEBUG + luaL_error(L, "load() is only usable in debug mode"); + return 0; +# else + auto countArgs = lua_gettop(L); + QByteArray data; + if (lua::peek(L, &data, 1)) + { + auto *utf8 = QTextCodec::codecForName("UTF-8"); + QTextCodec::ConverterState state; + utf8->toUnicode(data.constData(), data.size(), &state); + if (state.invalidChars != 0) + { + luaL_error(L, "invalid utf-8 in load() is not allowed"); + return 0; + } + } + else + { + luaL_error(L, "using reader function in load() is not allowed"); + return 0; + } + + for (int i = 0; i < countArgs; i++) + { + lua_seti(L, LUA_REGISTRYINDEX, i); + } + + // fetch load and call it + lua_getfield(L, LUA_REGISTRYINDEX, "real_load"); + + for (int i = 0; i < countArgs; i++) + { + lua_geti(L, LUA_REGISTRYINDEX, i); + lua_pushnil(L); + lua_seti(L, LUA_REGISTRYINDEX, i); + } + + lua_call(L, countArgs, LUA_MULTRET); + + return lua_gettop(L); +# endif +} + +int g_import(lua_State *L) +{ + auto countArgs = lua_gettop(L); + // Lua allows dofile() which loads from stdin, but this is very useless in our case + if (countArgs == 0) + { + lua_pushnil(L); + luaL_error(L, "it is not allowed to call import() without arguments"); + return 1; + } + + auto *pl = getApp()->plugins->getPluginByStatePtr(L); + QString fname; + if (!lua::pop(L, &fname)) + { + lua_pushnil(L); + luaL_error(L, "chatterino g_import: expected a string for a filename"); + return 1; + } + auto dir = QUrl(pl->loadDirectory().canonicalPath() + "/"); + auto file = dir.resolved(fname); + + qCDebug(chatterinoLua) << "plugin" << pl->id << "is trying to load" << file + << "(its dir is" << dir << ")"; + if (!dir.isParentOf(file)) + { + lua_pushnil(L); + luaL_error(L, "chatterino g_import: filename must be inside of the " + "plugin directory"); + return 1; + } + + auto path = file.path(QUrl::FullyDecoded); + QFile qf(path); + qf.open(QIODevice::ReadOnly); + if (qf.size() > 10'000'000) + { + lua_pushnil(L); + luaL_error(L, "chatterino g_import: size limit of 10MB exceeded, what " + "the hell are you doing"); + return 1; + } + + // validate utf-8 to block bytecode exploits + auto data = qf.readAll(); + auto *utf8 = QTextCodec::codecForName("UTF-8"); + QTextCodec::ConverterState state; + utf8->toUnicode(data.constData(), data.size(), &state); + if (state.invalidChars != 0) + { + lua_pushnil(L); + luaL_error(L, "invalid utf-8 in import() target (%s) is not allowed", + fname.toStdString().c_str()); + return 1; + } + + // fetch dofile and call it + lua_getfield(L, LUA_REGISTRYINDEX, "real_dofile"); + // maybe data race here if symlink was swapped? + lua::push(L, path); + lua_call(L, 1, LUA_MULTRET); + + return lua_gettop(L); +} + +int g_print(lua_State *L) +{ + auto *pl = getApp()->plugins->getPluginByStatePtr(L); + if (pl == nullptr) + { + luaL_error(L, "c2_print: internal error: no plugin?"); + return 0; + } + auto argc = lua_gettop(L); + // This is almost the expansion of qCDebug() macro, actual thing is wrapped in a for loop + auto stream = + (QMessageLogger(QT_MESSAGELOG_FILE, QT_MESSAGELOG_LINE, + QT_MESSAGELOG_FUNC, chatterinoLua().categoryName()) + .debug()); + logHelper(L, pl, stream, argc); + return 0; +} + +} // namespace chatterino::lua::api +// NOLINTEND(*vararg) +#endif diff --git a/src/controllers/plugins/LuaAPI.hpp b/src/controllers/plugins/LuaAPI.hpp new file mode 100644 index 00000000000..dfa95447eee --- /dev/null +++ b/src/controllers/plugins/LuaAPI.hpp @@ -0,0 +1,28 @@ +#pragma once + +#ifdef CHATTERINO_HAVE_PLUGINS + +struct lua_State; +namespace chatterino::lua::api { +// names in this namespace reflect what's visible inside Lua and follow the lua naming scheme + +// NOLINTBEGIN(readability-identifier-naming) +// Following functions are exposed in c2 table. +int c2_register_command(lua_State *L); +int c2_send_msg(lua_State *L); +int c2_system_msg(lua_State *L); +int c2_log(lua_State *L); + +// These ones are global +int g_load(lua_State *L); +int g_print(lua_State *L); +int g_import(lua_State *L); +// NOLINTEND(readability-identifier-naming) + +// Exposed as c2.LogLevel +// Represents "calls" to qCDebug, qCInfo ... +enum class LogLevel { Debug, Info, Warning, Critical }; + +} // namespace chatterino::lua::api + +#endif diff --git a/src/controllers/plugins/LuaUtilities.cpp b/src/controllers/plugins/LuaUtilities.cpp new file mode 100644 index 00000000000..3477e4e383f --- /dev/null +++ b/src/controllers/plugins/LuaUtilities.cpp @@ -0,0 +1,196 @@ +#ifdef CHATTERINO_HAVE_PLUGINS +# include "controllers/plugins/LuaUtilities.hpp" + +# include "common/Channel.hpp" +# include "common/QLogging.hpp" +# include "controllers/commands/CommandContext.hpp" + +# include +# include + +# include +# include + +namespace chatterino::lua { + +void stackDump(lua_State *L, const QString &tag) +{ + qCDebug(chatterinoLua) << "--------------------"; + auto count = lua_gettop(L); + if (!tag.isEmpty()) + { + qCDebug(chatterinoLua) << "Tag: " << tag; + } + qCDebug(chatterinoLua) << "Count elems: " << count; + for (int i = 1; i <= count; i++) + { + auto typeint = lua_type(L, i); + if (typeint == LUA_TSTRING) + { + QString str; + lua::peek(L, &str, i); + qCDebug(chatterinoLua) + << "At" << i << "is a" << lua_typename(L, typeint) << "(" + << typeint << "): " << str; + } + else if (typeint == LUA_TTABLE) + { + qCDebug(chatterinoLua) + << "At" << i << "is a" << lua_typename(L, typeint) << "(" + << typeint << ")" + << "its length is " << lua_rawlen(L, i); + } + else + { + qCDebug(chatterinoLua) + << "At" << i << "is a" << lua_typename(L, typeint) << "(" + << typeint << ")"; + } + } + qCDebug(chatterinoLua) << "--------------------"; +} + +QString humanErrorText(lua_State *L, int errCode) +{ + QString errName; + switch (errCode) + { + case LUA_OK: + return "ok"; + case LUA_ERRRUN: + errName = "(runtime error)"; + break; + case LUA_ERRMEM: + errName = "(memory error)"; + break; + case LUA_ERRERR: + errName = "(error while handling another error)"; + break; + case LUA_ERRSYNTAX: + errName = "(syntax error)"; + break; + case LUA_YIELD: + errName = "(illegal coroutine yield)"; + break; + case LUA_ERRFILE: + errName = "(file error)"; + break; + default: + errName = "(unknown error type)"; + } + QString errText; + if (peek(L, &errText)) + { + errName += " " + errText; + } + return errName; +} + +StackIdx pushEmptyArray(lua_State *L, int countArray) +{ + lua_createtable(L, countArray, 0); + return lua_gettop(L); +} + +StackIdx pushEmptyTable(lua_State *L, int countProperties) +{ + lua_createtable(L, 0, countProperties); + return lua_gettop(L); +} + +StackIdx push(lua_State *L, const QString &str) +{ + return lua::push(L, str.toStdString()); +} + +StackIdx push(lua_State *L, const std::string &str) +{ + lua_pushstring(L, str.c_str()); + return lua_gettop(L); +} + +StackIdx push(lua_State *L, const CommandContext &ctx) +{ + auto outIdx = pushEmptyTable(L, 2); + + push(L, ctx.words); + lua_setfield(L, outIdx, "words"); + push(L, ctx.channel->getName()); + lua_setfield(L, outIdx, "channel_name"); + + return outIdx; +} + +StackIdx push(lua_State *L, const bool &b) +{ + lua_pushboolean(L, int(b)); + return lua_gettop(L); +} + +bool peek(lua_State *L, double *out, StackIdx idx) +{ + int ok{0}; + auto v = lua_tonumberx(L, idx, &ok); + if (ok != 0) + { + *out = v; + } + return ok != 0; +} + +bool peek(lua_State *L, QString *out, StackIdx idx) +{ + size_t len{0}; + const char *str = lua_tolstring(L, idx, &len); + if (str == nullptr) + { + return false; + } + if (len >= INT_MAX) + { + assert(false && "string longer than INT_MAX, shit's fucked, yo"); + } + *out = QString::fromUtf8(str, int(len)); + return true; +} + +bool peek(lua_State *L, QByteArray *out, StackIdx idx) +{ + size_t len{0}; + const char *str = lua_tolstring(L, idx, &len); + if (str == nullptr) + { + return false; + } + if (len >= INT_MAX) + { + assert(false && "string longer than INT_MAX, shit's fucked, yo"); + } + *out = QByteArray(str, int(len)); + return true; +} + +bool peek(lua_State *L, std::string *out, StackIdx idx) +{ + size_t len{0}; + const char *str = lua_tolstring(L, idx, &len); + if (str == nullptr) + { + return false; + } + if (len >= INT_MAX) + { + assert(false && "string longer than INT_MAX, shit's fucked, yo"); + } + *out = std::string(str, len); + return true; +} + +QString toString(lua_State *L, StackIdx idx) +{ + size_t len{}; + const auto *ptr = luaL_tolstring(L, idx, &len); + return QString::fromUtf8(ptr, int(len)); +} +} // namespace chatterino::lua +#endif diff --git a/src/controllers/plugins/LuaUtilities.hpp b/src/controllers/plugins/LuaUtilities.hpp new file mode 100644 index 00000000000..6a75b774a35 --- /dev/null +++ b/src/controllers/plugins/LuaUtilities.hpp @@ -0,0 +1,191 @@ +#pragma once + +#ifdef CHATTERINO_HAVE_PLUGINS + +# include +# include +# include +# include + +# include +# include +# include +# include +struct lua_State; +class QJsonObject; +namespace chatterino { +struct CommandContext; +} // namespace chatterino + +namespace chatterino::lua { + +/** + * @brief Dumps the Lua stack into qCDebug(chatterinoLua) + * + * @param tag is a string to let you know which dump is which when browsing logs + */ +void stackDump(lua_State *L, const QString &tag); + +/** + * @brief Converts a lua error code and potentially string on top of the stack into a human readable message + */ +QString humanErrorText(lua_State *L, int errCode); + +/** + * Represents an index into Lua's stack + */ +using StackIdx = int; + +/** + * @brief Creates a table with countArray array properties on the Lua stack + * @return stack index of the newly created table + */ +StackIdx pushEmptyArray(lua_State *L, int countArray); + +/** + * @brief Creates a table with countProperties named properties on the Lua stack + * @return stack index of the newly created table + */ +StackIdx pushEmptyTable(lua_State *L, int countProperties); + +StackIdx push(lua_State *L, const CommandContext &ctx); +StackIdx push(lua_State *L, const QString &str); +StackIdx push(lua_State *L, const std::string &str); +StackIdx push(lua_State *L, const bool &b); + +// returns OK? +bool peek(lua_State *L, double *out, StackIdx idx = -1); +bool peek(lua_State *L, QString *out, StackIdx idx = -1); +bool peek(lua_State *L, QByteArray *out, StackIdx idx = -1); +bool peek(lua_State *L, std::string *out, StackIdx idx = -1); + +/** + * @brief Converts Lua object at stack index idx to a string. + */ +QString toString(lua_State *L, StackIdx idx = -1); + +/// TEMPLATES + +/** + * @brief Converts object at stack index idx to enum given by template parameter T + */ +template , bool>::type = true> +bool peek(lua_State *L, T *out, StackIdx idx = -1) +{ + std::string tmp; + if (!lua::peek(L, &tmp, idx)) + { + return false; + } + std::optional opt = magic_enum::enum_cast(tmp); + if (opt.has_value()) + { + *out = opt.value(); + return true; + } + + return false; +} + +/** + * @brief Converts a vector to Lua and pushes it onto the stack. + * + * Needs StackIdx push(lua_State*, T); to work. + * + * @return Stack index of newly created table. + */ +template +StackIdx push(lua_State *L, std::vector vec) +{ + auto out = pushEmptyArray(L, vec.size()); + int i = 1; + for (const auto &el : vec) + { + push(L, el); + lua_seti(L, out, i); + i += 1; + } + return out; +} + +/** + * @brief Converts a QList to Lua and pushes it onto the stack. + * + * Needs StackIdx push(lua_State*, T); to work. + * + * @return Stack index of newly created table. + */ +template +StackIdx push(lua_State *L, QList vec) +{ + auto out = pushEmptyArray(L, vec.size()); + int i = 1; + for (const auto &el : vec) + { + push(L, el); + lua_seti(L, out, i); + i += 1; + } + return out; +} + +/** + * @brief Converts an enum given by T to Lua (into a string) and pushes it onto the stack. + * + * @return Stack index of newly created string. + */ +template >> +StackIdx push(lua_State *L, T inp) +{ + std::string_view name = magic_enum::enum_name(inp); + return lua::push(L, std::string(name)); +} + +/** + * @brief Converts a Lua object into c++ and removes it from the stack. + * + * Relies on bool peek(lua_State*, T*, StackIdx) existing. + */ +template +bool pop(lua_State *L, T *out, StackIdx idx = -1) +{ + auto ok = peek(L, out, idx); + if (ok) + { + if (idx < 0) + { + idx = lua_gettop(L) + idx + 1; + } + lua_remove(L, idx); + } + return ok; +} + +/** + * @brief Creates a table mapping enum names to unique values. + * + * Values in this table may change. + * + * @returns stack index of newly created table + */ +template +StackIdx pushEnumTable(lua_State *L) +{ + // std::array + auto values = magic_enum::enum_values(); + StackIdx out = lua::pushEmptyTable(L, values.size()); + for (const T v : values) + { + std::string_view name = magic_enum::enum_name(v); + std::string str(name); + + lua::push(L, str); + lua_setfield(L, out, str.c_str()); + } + return out; +} + +} // namespace chatterino::lua + +#endif diff --git a/src/controllers/plugins/Plugin.cpp b/src/controllers/plugins/Plugin.cpp new file mode 100644 index 00000000000..3fdc8e4dca8 --- /dev/null +++ b/src/controllers/plugins/Plugin.cpp @@ -0,0 +1,177 @@ +#ifdef CHATTERINO_HAVE_PLUGINS +# include "controllers/plugins/Plugin.hpp" + +# include "controllers/commands/CommandController.hpp" + +# include +# include +# include +# include + +# include +# include + +namespace chatterino { + +PluginMeta::PluginMeta(const QJsonObject &obj) +{ + auto homepageObj = obj.value("homepage"); + if (homepageObj.isString()) + { + this->homepage = homepageObj.toString(); + } + else if (!homepageObj.isUndefined()) + { + QString type = magic_enum::enum_name(homepageObj.type()).data(); + this->errors.emplace_back( + QString("homepage is defined but is not a string (its type is %1)") + .arg(type)); + } + auto nameObj = obj.value("name"); + if (nameObj.isString()) + { + this->name = nameObj.toString(); + } + else + { + QString type = magic_enum::enum_name(nameObj.type()).data(); + this->errors.emplace_back( + QString("name is not a string (its type is %1)").arg(type)); + } + + auto descrObj = obj.value("description"); + if (descrObj.isString()) + { + this->description = descrObj.toString(); + } + else + { + QString type = magic_enum::enum_name(descrObj.type()).data(); + this->errors.emplace_back( + QString("description is not a string (its type is %1)").arg(type)); + } + + auto authorsObj = obj.value("authors"); + if (authorsObj.isArray()) + { + auto authorsArr = authorsObj.toArray(); + for (int i = 0; i < authorsArr.size(); i++) + { + const auto &t = authorsArr.at(i); + if (!t.isString()) + { + QString type = magic_enum::enum_name(t.type()).data(); + this->errors.push_back( + QString("authors element #%1 is not a string (it is a %2)") + .arg(i) + .arg(type)); + break; + } + this->authors.push_back(t.toString()); + } + } + else + { + QString type = magic_enum::enum_name(authorsObj.type()).data(); + this->errors.emplace_back( + QString("authors is not an array (its type is %1)").arg(type)); + } + + auto licenseObj = obj.value("license"); + if (licenseObj.isString()) + { + this->license = licenseObj.toString(); + } + else + { + QString type = magic_enum::enum_name(licenseObj.type()).data(); + this->errors.emplace_back( + QString("license is not a string (its type is %1)").arg(type)); + } + + auto verObj = obj.value("version"); + if (verObj.isString()) + { + auto v = semver::from_string_noexcept(verObj.toString().toStdString()); + if (v.has_value()) + { + this->version = v.value(); + } + else + { + this->errors.emplace_back("unable to parse version (use semver)"); + this->version = semver::version(0, 0, 0); + } + } + else + { + QString type = magic_enum::enum_name(verObj.type()).data(); + this->errors.emplace_back( + QString("version is not a string (its type is %1)").arg(type)); + this->version = semver::version(0, 0, 0); + } + auto tagsObj = obj.value("tags"); + if (!tagsObj.isUndefined()) + { + if (!tagsObj.isArray()) + { + QString type = magic_enum::enum_name(tagsObj.type()).data(); + this->errors.emplace_back( + QString("tags is not an array (its type is %1)").arg(type)); + return; + } + + auto tagsArr = tagsObj.toArray(); + for (int i = 0; i < tagsArr.size(); i++) + { + const auto &t = tagsArr.at(i); + if (!t.isString()) + { + QString type = magic_enum::enum_name(t.type()).data(); + this->errors.push_back( + QString("tags element #%1 is not a string (its type is %2)") + .arg(i) + .arg(type)); + return; + } + this->tags.push_back(t.toString()); + } + } +} + +bool Plugin::registerCommand(const QString &name, const QString &functionName) +{ + if (this->ownedCommands.find(name) != this->ownedCommands.end()) + { + return false; + } + + auto ok = getApp()->commands->registerPluginCommand(name); + if (!ok) + { + return false; + } + this->ownedCommands.insert({name, functionName}); + return true; +} + +std::unordered_set Plugin::listRegisteredCommands() +{ + std::unordered_set out; + for (const auto &[name, _] : this->ownedCommands) + { + out.insert(name); + } + return out; +} + +Plugin::~Plugin() +{ + if (this->state_ != nullptr) + { + lua_close(this->state_); + } +} + +} // namespace chatterino +#endif diff --git a/src/controllers/plugins/Plugin.hpp b/src/controllers/plugins/Plugin.hpp new file mode 100644 index 00000000000..456ac4ff1fd --- /dev/null +++ b/src/controllers/plugins/Plugin.hpp @@ -0,0 +1,98 @@ +#pragma once + +#ifdef CHATTERINO_HAVE_PLUGINS +# include "Application.hpp" + +# include +# include +# include + +# include +# include +# include + +struct lua_State; + +namespace chatterino { + +struct PluginMeta { + // for more info on these fields see docs/plugin-info.schema.json + + // display name of the plugin + QString name; + + // description shown to the user + QString description; + + // plugin authors shown to the user + std::vector authors; + + // license name + QString license; + + // version of the plugin + semver::version version; + + // optionally a homepage link + QString homepage; + + // optionally tags that might help in searching for the plugin + std::vector tags; + + // errors that occurred while parsing info.json + std::vector errors; + + bool isValid() const + { + return this->errors.empty(); + } + + explicit PluginMeta(const QJsonObject &obj); +}; + +class Plugin +{ +public: + QString id; + PluginMeta meta; + + Plugin(QString id, lua_State *state, PluginMeta meta, + const QDir &loadDirectory) + : id(std::move(id)) + , meta(std::move(meta)) + , loadDirectory_(loadDirectory) + , state_(state) + { + } + + ~Plugin(); + + /** + * @brief Perform all necessary tasks to bind a command name to this plugin + * @param name name of the command to create + * @param functionName name of the function that should be called when the command is executed + * @return true if addition succeeded, false otherwise (for example because the command name is already taken) + */ + bool registerCommand(const QString &name, const QString &functionName); + + /** + * @brief Get names of all commands belonging to this plugin + */ + std::unordered_set listRegisteredCommands(); + + const QDir &loadDirectory() const + { + return this->loadDirectory_; + } + +private: + QDir loadDirectory_; + lua_State *state_; + + // maps command name -> function name + std::unordered_map ownedCommands; + + friend class PluginController; +}; +} // namespace chatterino +#endif diff --git a/src/controllers/plugins/PluginController.cpp b/src/controllers/plugins/PluginController.cpp new file mode 100644 index 00000000000..e98a30720d0 --- /dev/null +++ b/src/controllers/plugins/PluginController.cpp @@ -0,0 +1,302 @@ +#ifdef CHATTERINO_HAVE_PLUGINS +# include "controllers/plugins/PluginController.hpp" + +# include "Application.hpp" +# include "common/QLogging.hpp" +# include "controllers/commands/CommandContext.hpp" +# include "controllers/commands/CommandController.hpp" +# include "controllers/plugins/LuaAPI.hpp" +# include "controllers/plugins/LuaUtilities.hpp" +# include "messages/MessageBuilder.hpp" +# include "singletons/Paths.hpp" +# include "singletons/Settings.hpp" + +# include +# include +# include +# include + +# include +# include + +namespace chatterino { + +void PluginController::initialize(Settings &settings, Paths &paths) +{ + (void)paths; + + // actuallyInitialize will be called by this connection + settings.pluginsEnabled.connect([this](bool enabled) { + if (enabled) + { + this->loadPlugins(); + } + else + { + // uninitialize plugins + this->plugins_.clear(); + } + }); +} + +void PluginController::loadPlugins() +{ + this->plugins_.clear(); + auto dir = QDir(getPaths()->pluginsDirectory); + qCDebug(chatterinoLua) << "Loading plugins in" << dir.path(); + for (const auto &info : + dir.entryInfoList(QDir::NoFilter | QDir::NoDotAndDotDot)) + { + if (info.isDir()) + { + auto pluginDir = QDir(info.absoluteFilePath()); + this->tryLoadFromDir(pluginDir); + } + } +} +bool PluginController::tryLoadFromDir(const QDir &pluginDir) +{ + // look for init.lua + auto index = QFileInfo(pluginDir.filePath("init.lua")); + qCDebug(chatterinoLua) << "Looking for init.lua and info.json in" + << pluginDir.path(); + if (!index.exists()) + { + qCWarning(chatterinoLua) + << "Missing init.lua in plugin directory:" << pluginDir.path(); + return false; + } + qCDebug(chatterinoLua) << "Found init.lua, now looking for info.json!"; + auto infojson = QFileInfo(pluginDir.filePath("info.json")); + if (!infojson.exists()) + { + qCWarning(chatterinoLua) + << "Missing info.json in plugin directory" << pluginDir.path(); + return false; + } + QFile infoFile(infojson.absoluteFilePath()); + infoFile.open(QIODevice::ReadOnly); + auto everything = infoFile.readAll(); + auto doc = QJsonDocument::fromJson(everything); + if (!doc.isObject()) + { + qCWarning(chatterinoLua) + << "info.json root is not an object" << pluginDir.path(); + return false; + } + + auto meta = PluginMeta(doc.object()); + if (!meta.isValid()) + { + qCWarning(chatterinoLua) + << "Plugin from" << pluginDir << "is invalid because:"; + for (const auto &why : meta.errors) + { + qCWarning(chatterinoLua) << "- " << why; + } + auto plugin = std::make_unique(pluginDir.dirName(), nullptr, + meta, pluginDir); + this->plugins_.insert({pluginDir.dirName(), std::move(plugin)}); + return false; + } + this->load(index, pluginDir, meta); + return true; +} + +void PluginController::openLibrariesFor(lua_State *L, + const PluginMeta & /*meta*/) +{ + // Stuff to change, remove or hide behind a permission system: + static const std::vector loadedlibs = { + luaL_Reg{LUA_GNAME, luaopen_base}, + // - load - don't allow in release mode + + //luaL_Reg{LUA_COLIBNAME, luaopen_coroutine}, + // - needs special support + luaL_Reg{LUA_TABLIBNAME, luaopen_table}, + // luaL_Reg{LUA_IOLIBNAME, luaopen_io}, + // - explicit fs access, needs wrapper with permissions, no usage ideas yet + // luaL_Reg{LUA_OSLIBNAME, luaopen_os}, + // - fs access + // - environ access + // - exit + luaL_Reg{LUA_STRLIBNAME, luaopen_string}, + luaL_Reg{LUA_MATHLIBNAME, luaopen_math}, + luaL_Reg{LUA_UTF8LIBNAME, luaopen_utf8}, + }; + // Warning: Do not add debug library to this, it would make the security of + // this a living nightmare due to stuff like registry access + // - Mm2PL + + for (const auto ® : loadedlibs) + { + luaL_requiref(L, reg.name, reg.func, int(true)); + lua_pop(L, 1); + } + + // NOLINTNEXTLINE(*-avoid-c-arrays) + static const luaL_Reg c2Lib[] = { + {"system_msg", lua::api::c2_system_msg}, + {"register_command", lua::api::c2_register_command}, + {"send_msg", lua::api::c2_send_msg}, + {"log", lua::api::c2_log}, + {nullptr, nullptr}, + }; + lua_pushglobaltable(L); + auto global = lua_gettop(L); + + // count of elements in C2LIB + LogLevel + auto c2libIdx = lua::pushEmptyTable(L, 5); + + luaL_setfuncs(L, c2Lib, 0); + + lua::pushEnumTable(L); + lua_setfield(L, c2libIdx, "LogLevel"); + + lua_setfield(L, global, "c2"); + + // ban functions + // Note: this might not be fully secure? some kind of metatable fuckery might come up? + + lua_pushglobaltable(L); + auto gtable = lua_gettop(L); + + // possibly randomize this name at runtime to prevent some attacks? + +# ifndef NDEBUG + lua_getfield(L, gtable, "load"); + lua_setfield(L, LUA_REGISTRYINDEX, "real_load"); +# endif + + lua_getfield(L, gtable, "dofile"); + lua_setfield(L, LUA_REGISTRYINDEX, "real_dofile"); + + // NOLINTNEXTLINE(*-avoid-c-arrays) + static const luaL_Reg replacementFuncs[] = { + {"load", lua::api::g_load}, + {"print", lua::api::g_print}, + + // This function replaces both `dofile` and `require`, see docs/wip-plugins.md for more info + {"import", lua::api::g_import}, + {nullptr, nullptr}, + }; + luaL_setfuncs(L, replacementFuncs, 0); + + lua_pushnil(L); + lua_setfield(L, gtable, "loadfile"); + + lua_pushnil(L); + lua_setfield(L, gtable, "dofile"); + + lua_pop(L, 1); +} + +void PluginController::load(const QFileInfo &index, const QDir &pluginDir, + const PluginMeta &meta) +{ + lua_State *l = luaL_newstate(); + PluginController::openLibrariesFor(l, meta); + + auto pluginName = pluginDir.dirName(); + auto plugin = std::make_unique(pluginName, l, meta, pluginDir); + this->plugins_.insert({pluginName, std::move(plugin)}); + if (!PluginController::isPluginEnabled(pluginName) || + !getSettings()->pluginsEnabled) + { + qCDebug(chatterinoLua) << "Skipping loading" << pluginName << "(" + << meta.name << ") because it is disabled"; + return; + } + qCDebug(chatterinoLua) << "Running lua file:" << index; + int err = luaL_dofile(l, index.absoluteFilePath().toStdString().c_str()); + if (err != 0) + { + qCWarning(chatterinoLua) + << "Failed to load" << pluginName << "plugin from" << index << ": " + << lua::humanErrorText(l, err); + return; + } + qCInfo(chatterinoLua) << "Loaded" << pluginName << "plugin from" << index; +} + +bool PluginController::reload(const QString &id) +{ + auto it = this->plugins_.find(id); + if (it == this->plugins_.end()) + { + return false; + } + if (it->second->state_ != nullptr) + { + lua_close(it->second->state_); + it->second->state_ = nullptr; + } + for (const auto &[cmd, _] : it->second->ownedCommands) + { + getApp()->commands->unregisterPluginCommand(cmd); + } + it->second->ownedCommands.clear(); + QDir loadDir = it->second->loadDirectory_; + this->plugins_.erase(id); + this->tryLoadFromDir(loadDir); + return true; +} + +QString PluginController::tryExecPluginCommand(const QString &commandName, + const CommandContext &ctx) +{ + for (auto &[name, plugin] : this->plugins_) + { + if (auto it = plugin->ownedCommands.find(commandName); + it != plugin->ownedCommands.end()) + { + const auto &funcName = it->second; + + auto *L = plugin->state_; + lua_getfield(L, LUA_REGISTRYINDEX, funcName.toStdString().c_str()); + lua::push(L, ctx); + + auto res = lua_pcall(L, 1, 0, 0); + if (res != LUA_OK) + { + ctx.channel->addMessage(makeSystemMessage( + "Lua error: " + lua::humanErrorText(L, res))); + return ""; + } + return ""; + } + } + qCCritical(chatterinoLua) + << "Something's seriously up, no plugin owns command" << commandName + << "yet a call to execute it came in"; + assert(false && "missing plugin command owner"); + return ""; +} + +bool PluginController::isPluginEnabled(const QString &id) +{ + auto vec = getSettings()->enabledPlugins.getValue(); + auto it = std::find(vec.begin(), vec.end(), id); + return it != vec.end(); +} + +Plugin *PluginController::getPluginByStatePtr(lua_State *L) +{ + for (auto &[name, plugin] : this->plugins_) + { + if (plugin->state_ == L) + { + return plugin.get(); + } + } + return nullptr; +} + +const std::map> &PluginController::plugins() + const +{ + return this->plugins_; +} + +}; // namespace chatterino +#endif diff --git a/src/controllers/plugins/PluginController.hpp b/src/controllers/plugins/PluginController.hpp new file mode 100644 index 00000000000..9630e889bcc --- /dev/null +++ b/src/controllers/plugins/PluginController.hpp @@ -0,0 +1,68 @@ +#pragma once + +#ifdef CHATTERINO_HAVE_PLUGINS + +# include "common/Singleton.hpp" +# include "controllers/commands/CommandContext.hpp" +# include "controllers/plugins/Plugin.hpp" + +# include +# include +# include +# include +# include + +# include +# include +# include +# include +# include + +struct lua_State; + +namespace chatterino { + +class Paths; + +class PluginController : public Singleton +{ +public: + void initialize(Settings &settings, Paths &paths) override; + + QString tryExecPluginCommand(const QString &commandName, + const CommandContext &ctx); + + // NOTE: this pointer does not own the Plugin, unique_ptr still owns it + // This is required to be public because of c functions + Plugin *getPluginByStatePtr(lua_State *L); + + const std::map> &plugins() const; + + /** + * @brief Reload plugin given by id + * + * @param id This is the unique identifier of the plugin, the name of the directory it is in + */ + bool reload(const QString &id); + + /** + * @brief Checks settings to tell if a plugin named by id is enabled. + * + * It is the callers responsibility to check Settings::pluginsEnabled + */ + static bool isPluginEnabled(const QString &id); + +private: + void loadPlugins(); + void load(const QFileInfo &index, const QDir &pluginDir, + const PluginMeta &meta); + + // This function adds lua standard libraries into the state + static void openLibrariesFor(lua_State *L, const PluginMeta & /*meta*/); + static void loadChatterinoLib(lua_State *l); + bool tryLoadFromDir(const QDir &pluginDir); + std::map> plugins_; +}; + +}; // namespace chatterino +#endif diff --git a/src/singletons/Paths.cpp b/src/singletons/Paths.cpp index 8fd6b13cbea..79344ac7279 100644 --- a/src/singletons/Paths.cpp +++ b/src/singletons/Paths.cpp @@ -141,6 +141,7 @@ void Paths::initSubDirectories() this->messageLogDirectory = makePath("Logs"); this->miscDirectory = makePath("Misc"); this->twitchProfileAvatars = makePath("ProfileAvatars"); + this->pluginsDirectory = makePath("Plugins"); this->crashdumpDirectory = makePath("Crashes"); //QDir().mkdir(this->twitchProfileAvatars + "/twitch"); } diff --git a/src/singletons/Paths.hpp b/src/singletons/Paths.hpp index 7ff5f8e1772..f20195fef7e 100644 --- a/src/singletons/Paths.hpp +++ b/src/singletons/Paths.hpp @@ -34,6 +34,9 @@ class Paths // Profile avatars for Twitch /cache/twitch QString twitchProfileAvatars; + // Plugin files live here. /Plugins + QString pluginsDirectory; + bool createFolder(const QString &folderPath); bool isPortable(); diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index bd1620e5ec8..467681412e3 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -528,6 +528,10 @@ class Settings : public ABSettings, public ConcurrentSettings {"d", 1}, {"w", 1}}}; + BoolSetting pluginsEnabled = {"/plugins/supportEnabled", false}; + ChatterinoSetting> enabledPlugins = { + "/plugins/enabledPlugins", {}}; + private: void updateModerationActions(); }; diff --git a/src/widgets/dialogs/SettingsDialog.cpp b/src/widgets/dialogs/SettingsDialog.cpp index ebc33774a46..5bd218860af 100644 --- a/src/widgets/dialogs/SettingsDialog.cpp +++ b/src/widgets/dialogs/SettingsDialog.cpp @@ -20,6 +20,7 @@ #include "widgets/settingspages/ModerationPage.hpp" #include "widgets/settingspages/NicknamesPage.hpp" #include "widgets/settingspages/NotificationPage.hpp" +#include "widgets/settingspages/PluginsPage.hpp" #include #include @@ -216,6 +217,9 @@ void SettingsDialog::addTabs() this->addTab([]{return new ModerationPage;}, "Moderation", ":/settings/moderation.svg", SettingsTabId::Moderation); this->addTab([]{return new NotificationPage;}, "Live Notifications", ":/settings/notification2.svg"); this->addTab([]{return new ExternalToolsPage;}, "External tools", ":/settings/externaltools.svg"); +#ifdef CHATTERINO_HAVE_PLUGINS + this->addTab([]{return new PluginsPage;}, "Plugins", ":/settings/plugins.svg"); +#endif this->ui_.tabContainer->addStretch(1); this->addTab([]{return new AboutPage;}, "About", ":/settings/about.svg", SettingsTabId(), Qt::AlignBottom); // clang-format on diff --git a/src/widgets/settingspages/AboutPage.cpp b/src/widgets/settingspages/AboutPage.cpp index 712549e1c06..05037621e6d 100644 --- a/src/widgets/settingspages/AboutPage.cpp +++ b/src/widgets/settingspages/AboutPage.cpp @@ -114,6 +114,13 @@ AboutPage::AboutPage() addLicense(form.getElement(), "miniaudio", "https://github.com/mackron/miniaudio", ":/licenses/miniaudio.txt"); +#ifdef CHATTERINO_HAVE_PLUGINS + addLicense(form.getElement(), "lua", "https://lua.org", + ":/licenses/lua.txt"); + addLicense(form.getElement(), "Fluent icons", + "https://github.com/microsoft/fluentui-system-icons", + ":/licenses/fluenticons.txt"); +#endif #ifdef CHATTERINO_WITH_CRASHPAD addLicense(form.getElement(), "sentry-crashpad", "https://github.com/getsentry/crashpad", diff --git a/src/widgets/settingspages/PluginsPage.cpp b/src/widgets/settingspages/PluginsPage.cpp new file mode 100644 index 00000000000..20e65fd3c84 --- /dev/null +++ b/src/widgets/settingspages/PluginsPage.cpp @@ -0,0 +1,185 @@ +#ifdef CHATTERINO_HAVE_PLUGINS +# include "widgets/settingspages/PluginsPage.hpp" + +# include "Application.hpp" +# include "controllers/plugins/PluginController.hpp" +# include "singletons/Paths.hpp" +# include "singletons/Settings.hpp" +# include "util/Helpers.hpp" +# include "util/LayoutCreator.hpp" +# include "util/RemoveScrollAreaBackground.hpp" + +# include +# include +# include +# include +# include +# include +# include + +namespace chatterino { + +PluginsPage::PluginsPage() + : scrollAreaWidget_(nullptr) + , dataFrame_(nullptr) +{ + LayoutCreator layoutCreator(this); + auto scrollArea = layoutCreator.emplace(); + + auto widget = scrollArea.emplaceScrollAreaWidget(); + this->scrollAreaWidget_ = widget; + removeScrollAreaBackground(scrollArea.getElement(), widget.getElement()); + + auto layout = widget.setLayoutType(); + + { + auto group = layout.emplace("General plugin settings"); + this->generalGroup = group.getElement(); + auto groupLayout = group.setLayoutType(); + auto *description = new QLabel( + "You can load plugins by putting them into " + + formatRichNamedLink("file:///" + getPaths()->pluginsDirectory, + "the Plugins directory") + + ". Each one is a new directory."); + description->setOpenExternalLinks(true); + description->setWordWrap(true); + description->setStyleSheet("color: #bbb"); + groupLayout->addRow(description); + + auto *box = this->createCheckBox("Enable plugins", + getSettings()->pluginsEnabled); + QObject::connect(box, &QCheckBox::released, [this]() { + this->rebuildContent(); + }); + groupLayout->addRow(box); + } + + this->rebuildContent(); +} + +void PluginsPage::rebuildContent() +{ + if (this->dataFrame_ != nullptr) + { + this->dataFrame_->deleteLater(); + this->dataFrame_ = nullptr; + } + auto frame = LayoutCreator(new QFrame(this)); + this->dataFrame_ = frame.getElement(); + this->scrollAreaWidget_.append(this->dataFrame_); + auto layout = frame.setLayoutType(); + layout->setParent(this->dataFrame_); + for (const auto &[id, plugin] : getApp()->plugins->plugins()) + { + auto groupHeaderText = + QString("%1 (%2, from %3)") + .arg(plugin->meta.name, + QString::fromStdString(plugin->meta.version.to_string()), + id); + auto groupBox = layout.emplace(groupHeaderText); + groupBox->setParent(this->dataFrame_); + auto pluginEntry = groupBox.setLayoutType(); + pluginEntry->setParent(groupBox.getElement()); + + if (!plugin->meta.isValid()) + { + QString errors = "
    "; + for (const auto &err : plugin->meta.errors) + { + errors += "
  • " + err.toHtmlEscaped() + "
  • "; + } + errors += "
"; + + auto *warningLabel = new QLabel( + "There were errors while loading metadata for this plugin:" + + errors, + this->dataFrame_); + warningLabel->setTextFormat(Qt::RichText); + warningLabel->setStyleSheet("color: #f00"); + pluginEntry->addRow(warningLabel); + } + + auto *description = + new QLabel(plugin->meta.description, this->dataFrame_); + description->setWordWrap(true); + description->setStyleSheet("color: #bbb"); + pluginEntry->addRow(description); + + QString authorsTxt; + for (const auto &author : plugin->meta.authors) + { + if (!authorsTxt.isEmpty()) + { + authorsTxt += ", "; + } + + authorsTxt += author; + } + pluginEntry->addRow("Authors", + new QLabel(authorsTxt, this->dataFrame_)); + + if (!plugin->meta.homepage.isEmpty()) + { + auto *homepage = new QLabel(formatRichLink(plugin->meta.homepage), + this->dataFrame_); + homepage->setOpenExternalLinks(true); + pluginEntry->addRow("Homepage", homepage); + } + pluginEntry->addRow("License", + new QLabel(plugin->meta.license, this->dataFrame_)); + + QString commandsTxt; + for (const auto &cmdName : plugin->listRegisteredCommands()) + { + if (!commandsTxt.isEmpty()) + { + commandsTxt += ", "; + } + + commandsTxt += cmdName; + } + pluginEntry->addRow("Commands", + new QLabel(commandsTxt, this->dataFrame_)); + + if (plugin->meta.isValid()) + { + QString toggleTxt = "Enable"; + if (PluginController::isPluginEnabled(id)) + { + toggleTxt = "Disable"; + } + + auto *toggleButton = new QPushButton(toggleTxt, this->dataFrame_); + QObject::connect( + toggleButton, &QPushButton::pressed, [name = id, this]() { + std::vector val = + getSettings()->enabledPlugins.getValue(); + if (PluginController::isPluginEnabled(name)) + { + val.erase(std::remove(val.begin(), val.end(), name), + val.end()); + } + else + { + val.push_back(name); + } + getSettings()->enabledPlugins.setValue(val); + getApp()->plugins->reload(name); + this->rebuildContent(); + }); + pluginEntry->addRow(toggleButton); + } + + auto *reloadButton = new QPushButton("Reload", this->dataFrame_); + QObject::connect(reloadButton, &QPushButton::pressed, + [name = id, this]() { + getApp()->plugins->reload(name); + this->rebuildContent(); + }); + pluginEntry->addRow(reloadButton); + } +} + +} // namespace chatterino + +#endif diff --git a/src/widgets/settingspages/PluginsPage.hpp b/src/widgets/settingspages/PluginsPage.hpp new file mode 100644 index 00000000000..c27dd0870aa --- /dev/null +++ b/src/widgets/settingspages/PluginsPage.hpp @@ -0,0 +1,30 @@ +#pragma once + +#ifdef CHATTERINO_HAVE_PLUGINS +# include "util/LayoutCreator.hpp" +# include "widgets/settingspages/SettingsPage.hpp" + +# include +# include +# include +# include + +namespace chatterino { +class Plugin; + +class PluginsPage : public SettingsPage +{ +public: + PluginsPage(); + +private: + void rebuildContent(); + + LayoutCreator scrollAreaWidget_; + QGroupBox *generalGroup; + QFrame *dataFrame_; +}; + +} // namespace chatterino + +#endif From b54fcd28697fe943dbe3a682b40459866efc3f77 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Apr 2023 14:20:16 +0000 Subject: [PATCH 11/38] Bump lib/miniaudio from `c153a94` to `9a76634` (#4487) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: pajlada --- lib/miniaudio | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/miniaudio b/lib/miniaudio index c153a947919..9a7663496fc 160000 --- a/lib/miniaudio +++ b/lib/miniaudio @@ -1 +1 @@ -Subproject commit c153a947919808419b0bf3f56b6f2ee606d6c5f4 +Subproject commit 9a7663496fc06f7a9439c752fd7666ca93328c20 From bdab5e021c822963fa79dbe640b2ba7b82a5f3a9 Mon Sep 17 00:00:00 2001 From: nerix Date: Sun, 2 Apr 2023 16:59:26 +0200 Subject: [PATCH 12/38] Benchmark and Test `LinkParser` (#4436) Co-authored-by: pajlada --- CHANGELOG.md | 1 + benchmarks/CMakeLists.txt | 1 + benchmarks/src/Highlights.cpp | 8 ++- benchmarks/src/LinkParser.cpp | 43 ++++++++++++ tests/CMakeLists.txt | 1 + tests/src/LinkParser.cpp | 119 ++++++++++++++++++++++++++++++++++ 6 files changed, 171 insertions(+), 2 deletions(-) create mode 100644 benchmarks/src/LinkParser.cpp create mode 100644 tests/src/LinkParser.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 7dcd34cdef0..7c65d6ff431 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,7 @@ - Dev: Ensure tests have default-initialized settings. (#4498) - Dev: Add scripting capabilities with Lua (#4341) - Dev: Conan 2.0 is now used instead of Conan 1.0. (#4417) +- Dev: Added tests and benchmarks for `LinkParser`. (#4436) ## 2.4.2 diff --git a/benchmarks/CMakeLists.txt b/benchmarks/CMakeLists.txt index 1bc975fd0bf..b655b7f843d 100644 --- a/benchmarks/CMakeLists.txt +++ b/benchmarks/CMakeLists.txt @@ -7,6 +7,7 @@ set(benchmark_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/FormatTime.cpp ${CMAKE_CURRENT_LIST_DIR}/src/Helpers.cpp ${CMAKE_CURRENT_LIST_DIR}/src/LimitedQueue.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/LinkParser.cpp # Add your new file above this line! ) diff --git a/benchmarks/src/Highlights.cpp b/benchmarks/src/Highlights.cpp index e87a38bac37..c35a0847ffe 100644 --- a/benchmarks/src/Highlights.cpp +++ b/benchmarks/src/Highlights.cpp @@ -1,5 +1,5 @@ #include "Application.hpp" -#include "BaseSettings.hpp" +#include "singletons/Settings.hpp" #include "common/Channel.hpp" #include "controllers/accounts/AccountController.hpp" #include "controllers/highlights/HighlightController.hpp" @@ -56,7 +56,7 @@ class MockApplication : IApplication { return nullptr; } - Emotes *getEmotes() override + IEmotes *getEmotes() override { return nullptr; } @@ -100,6 +100,10 @@ class MockApplication : IApplication { return nullptr; } + IUserDataController *getUserData() override + { + return nullptr; + } AccountController accounts; HighlightController highlights; diff --git a/benchmarks/src/LinkParser.cpp b/benchmarks/src/LinkParser.cpp new file mode 100644 index 00000000000..9178e63ee14 --- /dev/null +++ b/benchmarks/src/LinkParser.cpp @@ -0,0 +1,43 @@ +#include "common/LinkParser.hpp" + +#include +#include +#include +#include + +#include + +using namespace chatterino; + +const QString INPUT = QStringLiteral( + "If your Chatterino isn't loading FFZ emotes, update to the latest nightly " + "(or 2.4.2 if its out) " + "https://github.com/Chatterino/chatterino2/releases/tag/nightly-build " + "AlienPls https://www.youtube.com/watch?v=ELBBiBDcWc0 " + "127.0.3 aaaa xd 256.256.256.256 AsdQwe xd 127.0.0.1 https://. https://.be " + "https://a http://a.b https://a.be ftp://xdd.com " + "this is a text lol . ://foo.com //aa.de :/foo.de xd.XDDDDDD "); + +static void BM_LinkParsing(benchmark::State &state) +{ + QStringList words = INPUT.split(' '); + + // Make sure the TLDs are loaded + { + benchmark::DoNotOptimize(LinkParser("xd.com").getCaptured()); + } + + for (auto _ : state) + { + for (auto word : words) + { + LinkParser parser(word); + if (parser.hasMatch()) + { + benchmark::DoNotOptimize(parser.getCaptured()); + } + } + } +} + +BENCHMARK(BM_LinkParsing); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a5663126a4d..e57515d7b93 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -24,6 +24,7 @@ set(test_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/SeventvEventAPI.cpp ${CMAKE_CURRENT_LIST_DIR}/src/BttvLiveUpdates.cpp ${CMAKE_CURRENT_LIST_DIR}/src/Updates.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/LinkParser.cpp # Add your new file above this line! ) diff --git a/tests/src/LinkParser.cpp b/tests/src/LinkParser.cpp new file mode 100644 index 00000000000..33c3a7adc79 --- /dev/null +++ b/tests/src/LinkParser.cpp @@ -0,0 +1,119 @@ +#include "common/LinkParser.hpp" + +#include +#include +#include + +using namespace chatterino; + +TEST(LinkParser, parseDomainLinks) +{ + const QStringList inputs = { + "https://chatterino.com", + "http://chatterino.com", + "chatterino.com", + "wiki.chatterino.com", + "https://wiki.chatterino.com", + "http://chatterino.co.uk", + "http://a.io", + "chatterino.com:80", + "wiki.chatterino.com:80", + "a.b.c.chatterino.com", + "https://a.b.c.chatterino.com/foo", + "http://chatterino.com?foo", + "http://xd.chatterino.com/#?foo", + "chatterino.com#foo", + "1.com", + "127.0.0.1.com", + "https://127.0.0.1.com", + }; + + for (const auto &input : inputs) + { + LinkParser p(input); + ASSERT_TRUE(p.hasMatch()) << input.toStdString(); + ASSERT_EQ(p.getCaptured(), input); + } +} + +TEST(LinkParser, parseIpv4Links) +{ + const QStringList inputs = { + "https://127.0.0.1", + "http://127.0.0.1", + "127.0.0.1", + "127.0.0.1:8080", + "255.255.255.255", + "0.0.0.0", + "1.1.1.1", + "001.001.01.1", + "123.246.87.0", + "196.168.0.1:", + "196.168.4.2/foo", + "196.168.4.2?foo", + "http://196.168.4.0#foo", + "196.168.4.0/?#foo", + "196.168.4.0#?/foo", + "256.255.255.255", + "http://256.255.255.255", + "255.256.255.255", + "255.255.256.255", + "255.255.255.256", + }; + + for (const auto &input : inputs) + { + LinkParser p(input); + ASSERT_TRUE(p.hasMatch()) << input.toStdString(); + ASSERT_EQ(p.getCaptured(), input); + } +} + +TEST(LinkParser, doesntParseInvalidIpv4Links) +{ + const QStringList inputs = { + "https://127.0.0.", + "http://127.0.01", + "127.0.0000.1", + "1.", + ".127.0.0.1", + "1.2", + "1", + "1.2.3", + }; + + for (const auto &input : inputs) + { + LinkParser p(input); + ASSERT_FALSE(p.hasMatch()) << input.toStdString(); + } +} + +TEST(LinkParser, doesntParseInvalidLinks) +{ + const QStringList inputs = { + "h://foo.com", + "spotify:1234", + "ftp://chatterino.com", + "ftps://chatterino.com", + "spotify://chatterino.com", + "httpsx://chatterino.com", + "https:chatterino.com", + "/chatterino.com", + "word", + ".", + "/", + "#", + ":", + "?", + "a", + "://chatterino.com", + "//chatterino.com", + }; + + for (const auto &input : inputs) + { + LinkParser p(input); + ASSERT_FALSE(p.hasMatch()) << input.toStdString(); + } +} From 149399a0722cc27961114435e97cc97c76d8c7a6 Mon Sep 17 00:00:00 2001 From: nerix Date: Sun, 2 Apr 2023 20:30:15 +0200 Subject: [PATCH 13/38] Fix plugin compilation error when using Qt 6 (#4504) --- CHANGELOG.md | 2 +- src/controllers/plugins/LuaAPI.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c65d6ff431..ca71d500c0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,7 +22,7 @@ - Dev: Only log debug messages when NDEBUG is not defined. (#4442) - Dev: Cleaned up theme related code. (#4450) - Dev: Ensure tests have default-initialized settings. (#4498) -- Dev: Add scripting capabilities with Lua (#4341) +- Dev: Add scripting capabilities with Lua (#4341, #4504) - Dev: Conan 2.0 is now used instead of Conan 1.0. (#4417) - Dev: Added tests and benchmarks for `LinkParser`. (#4436) diff --git a/src/controllers/plugins/LuaAPI.cpp b/src/controllers/plugins/LuaAPI.cpp index ff57d7e282b..6ffaf498265 100644 --- a/src/controllers/plugins/LuaAPI.cpp +++ b/src/controllers/plugins/LuaAPI.cpp @@ -50,7 +50,7 @@ QDebug qdebugStreamForLogLevel(lua::api::LogLevel lvl) return base.critical(); default: assert(false && "if this happens magic_enum must have failed us"); - return {(QString *)nullptr}; + return QDebug((QString *)nullptr); } } From 5c08e996c6a8ed01edae9ac4a882eb935fc570c4 Mon Sep 17 00:00:00 2001 From: nerix Date: Tue, 4 Apr 2023 21:29:49 +0200 Subject: [PATCH 14/38] Refactor Windows CI and Conan Usage (#4513) --- .github/workflows/build.yml | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 01a9d68d940..be0f469375e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -16,6 +16,9 @@ env: C2_ENABLE_LTO: ${{ github.ref == 'refs/heads/master' }} CHATTERINO_REQUIRE_CLEAN_GIT: On C2_BUILD_WITH_QT6: Off + # Last known good conan version + # 2.0.3 has a bug on Windows (conan-io/conan#13606) + CONAN_VERSION: 2.0.2 jobs: build: @@ -117,6 +120,10 @@ jobs: version: ${{ matrix.qt-version }} # WINDOWS + - name: Enable Developer Command Prompt (Windows) + if: startsWith(matrix.os, 'windows') + uses: ilammy/msvc-dev-cmd@v1.12.1 + - name: Setup conan variables (Windows) if: startsWith(matrix.os, 'windows') run: | @@ -124,25 +131,19 @@ jobs: "C2_CONAN_CACHE_SUFFIX=$(if ($Env:C2_BUILD_WITH_QT6 -eq "on") { "-QT6" } else { "`" })" >> "$Env:GITHUB_ENV" shell: powershell - - name: Cache conan packages + - name: Cache conan packages (Windows) if: startsWith(matrix.os, 'windows') uses: actions/cache@v3 with: key: ${{ runner.os }}-conan-user-${{ hashFiles('**/conanfile.py') }}${{ env.C2_CONAN_CACHE_SUFFIX }} path: ~/.conan2/ - - name: Add Conan to path - if: startsWith(matrix.os, 'windows') - run: echo "C:\Program Files\Conan\conan\" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - - - name: Install dependencies (Windows) + - name: Install Conan (Windows) if: startsWith(matrix.os, 'windows') run: | - choco install conan -y - - - name: Enable Developer Command Prompt - if: startsWith(matrix.os, 'windows') - uses: ilammy/msvc-dev-cmd@v1.12.1 + python3 -c "import site; import sys; print(f'{site.USER_BASE}\\Python{sys.version_info.major}{sys.version_info.minor}\\Scripts')" >> "$GITHUB_PATH" + pip3 install --user "conan==${{ env.CONAN_VERSION }}" + shell: powershell - name: Setup Conan (Windows) if: startsWith(matrix.os, 'windows') @@ -151,7 +152,7 @@ jobs: conan profile detect -f shell: powershell - - name: Build (Windows) + - name: Install dependencies (Windows) if: startsWith(matrix.os, 'windows') run: | mkdir build @@ -162,6 +163,12 @@ jobs: -b missing ` --output-folder=. ` -o with_openssl3="$Env:C2_USE_OPENSSL3" + shell: powershell + + - name: Build (Windows) + if: startsWith(matrix.os, 'windows') + run: | + cd build cmake ` -G"NMake Makefiles" ` -DCMAKE_BUILD_TYPE=RelWithDebInfo ` From 7a286480d6e75dce7bd97bbc4982553a7b7509de Mon Sep 17 00:00:00 2001 From: Guilherme Espada Date: Sat, 8 Apr 2023 07:58:04 +0100 Subject: [PATCH 15/38] Fix build on latest Fedora (#4518) gcc (GCC) 13.0.1 20230401 (Red Hat 13.0.1-0) Co-authored-by: Rasmus Karlsson --- src/widgets/Notebook.cpp | 5 +++-- src/widgets/splits/SplitHeader.cpp | 12 +++++++----- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/widgets/Notebook.cpp b/src/widgets/Notebook.cpp index 1d7671f7c52..3f9a9c67bfc 100644 --- a/src/widgets/Notebook.cpp +++ b/src/widgets/Notebook.cpp @@ -391,7 +391,8 @@ void Notebook::setShowTabs(bool value) if (!value && getSettings()->informOnTabVisibilityToggle.getValue()) { auto unhideSeq = getApp()->hotkeys->getDisplaySequence( - HotkeyCategory::Window, "setTabVisibility", {{}}); + HotkeyCategory::Window, "setTabVisibility", + {std::vector()}); if (unhideSeq.isEmpty()) { unhideSeq = getApp()->hotkeys->getDisplaySequence( @@ -436,7 +437,7 @@ void Notebook::setShowTabs(bool value) void Notebook::updateTabVisibilityMenuAction() { auto toggleSeq = getApp()->hotkeys->getDisplaySequence( - HotkeyCategory::Window, "setTabVisibility", {{}}); + HotkeyCategory::Window, "setTabVisibility", {std::vector()}); if (toggleSeq.isEmpty()) { toggleSeq = getApp()->hotkeys->getDisplaySequence( diff --git a/src/widgets/splits/SplitHeader.cpp b/src/widgets/splits/SplitHeader.cpp index 4b6601e17f7..412c6f79807 100644 --- a/src/widgets/splits/SplitHeader.cpp +++ b/src/widgets/splits/SplitHeader.cpp @@ -451,8 +451,8 @@ std::unique_ptr SplitHeader::createMainMenu() if (twitchChannel) { - auto bothSeq = - h->getDisplaySequence(HotkeyCategory::Split, "reloadEmotes", {{}}); + auto bothSeq = h->getDisplaySequence( + HotkeyCategory::Split, "reloadEmotes", {std::vector()}); auto channelSeq = h->getDisplaySequence(HotkeyCategory::Split, "reloadEmotes", {{"channel"}}); auto subSeq = h->getDisplaySequence(HotkeyCategory::Split, @@ -484,8 +484,9 @@ std::unique_ptr SplitHeader::createMainMenu() "setModerationMode", {{"toggle"}}); if (modModeSeq.isEmpty()) { - modModeSeq = h->getDisplaySequence(HotkeyCategory::Split, - "setModerationMode", {{}}); + modModeSeq = + h->getDisplaySequence(HotkeyCategory::Split, "setModerationMode", + {std::vector()}); // this makes a full std::optional<> with an empty vector inside } moreMenu->addAction( @@ -529,7 +530,8 @@ std::unique_ptr SplitHeader::createMainMenu() if (notifySeq.isEmpty()) { notifySeq = h->getDisplaySequence(HotkeyCategory::Split, - "setChannelNotification", {{}}); + "setChannelNotification", + {std::vector()}); // this makes a full std::optional<> with an empty vector inside } action->setShortcut(notifySeq); From 4e3433e96672e861d08f88813c12b6b442654b2f Mon Sep 17 00:00:00 2001 From: nerix Date: Sat, 8 Apr 2023 11:05:55 +0200 Subject: [PATCH 16/38] Store Themes as JSON files (#4471) Co-authored-by: pajlada --- .github/workflows/lint.yml | 4 + .prettierignore | 4 +- CHANGELOG.md | 1 + cmake/resources/generate_resources.cmake | 1 + docs/ChatterinoTheme.schema.json | 398 +++++++++++++++++++++++ resources/themes/Black.json | 112 +++++++ resources/themes/Dark.json | 112 +++++++ resources/themes/Light.json | 112 +++++++ resources/themes/White.json | 112 +++++++ src/common/QLogging.cpp | 1 + src/common/QLogging.hpp | 1 + src/singletons/Theme.cpp | 282 ++++++++-------- src/singletons/Theme.hpp | 4 +- 13 files changed, 1012 insertions(+), 132 deletions(-) create mode 100644 docs/ChatterinoTheme.schema.json create mode 100644 resources/themes/Black.json create mode 100644 resources/themes/Dark.json create mode 100644 resources/themes/Light.json create mode 100644 resources/themes/White.json diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 01be6af8369..8e7de38cd3a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -26,3 +26,7 @@ jobs: - name: Show diff run: git --no-pager diff --exit-code --color=never shell: bash + - name: Check Theme files + run: | + npm i ajv-cli + npx -- ajv validate -s docs/ChatterinoTheme.schema.json -d "resources/themes/*.json" diff --git a/.prettierignore b/.prettierignore index 2c684666b39..c08429e13d4 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,5 +1,7 @@ -# JSON resources should not be prettified +# JSON resources should not be prettified... resources/*.json +# ...themes should be prettified for readability. +!resources/themes/*.json # Ignore submodule files lib/*/ diff --git a/CHANGELOG.md b/CHANGELOG.md index ca71d500c0f..17e2dc96430 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - Bugfix: Fixed search popup ignoring setting for message scrollback limit. (#4496) - Bugfix: Fixed a memory leak that occurred when loading message history. This was mostly noticeable with unstable internet connections where reconnections were frequent or long-running instances of Chatterino. (#4499) - Dev: Disabling precompiled headers on Windows is now tested in CI. (#4472) +- Dev: Themes are now stored as JSON files in `resources/themes`. (#4471) - 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/cmake/resources/generate_resources.cmake b/cmake/resources/generate_resources.cmake index 048d81f916c..d9ceccad26c 100644 --- a/cmake/resources/generate_resources.cmake +++ b/cmake/resources/generate_resources.cmake @@ -7,6 +7,7 @@ set( resources.qrc resources_autogenerated.qrc windows.rc + themes/ChatterinoTheme.schema.json ) set(RES_IMAGE_EXCLUDE_FILTER ^linuxinstall/) diff --git a/docs/ChatterinoTheme.schema.json b/docs/ChatterinoTheme.schema.json new file mode 100644 index 00000000000..a91de0129d0 --- /dev/null +++ b/docs/ChatterinoTheme.schema.json @@ -0,0 +1,398 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema", + "title": "Chatterino Theme", + "description": "Colors and metadata for a Chatterino 2 theme", + "definitions": { + "qt-color": { + "type": "string", + "$comment": "https://doc.qt.io/qt-5/qcolor.html#setNamedColor", + "anyOf": [ + { + "title": "#RGB", + "pattern": "^#[a-fA-F0-9]{3}$" + }, + { + "title": "#RRGGBB", + "pattern": "^#[a-fA-F0-9]{6}$" + }, + { + "title": "#AARRGGBB", + "$comment": "Note that this isn't identical to the CSS Color Moudle Level 4 where the alpha value is at the end.", + "pattern": "^#[a-fA-F0-9]{8}$" + }, + { + "title": "#RRRGGGBBB", + "pattern": "^#[a-fA-F0-9]{9}$" + }, + { + "title": "#RRRRGGGGBBBB", + "pattern": "^#[a-fA-F0-9]{12}$" + }, + { + "title": "SVG Color", + "description": "This is stricter than Qt. You could theoretically put tabs an spaces between characters in a named color and capitalize the color.", + "$comment": "https://www.w3.org/TR/SVG11/types.html#ColorKeywords", + "enum": [ + "aliceblue", + "antiquewhite", + "aqua", + "aquamarine", + "azure", + "beige", + "bisque", + "black", + "blanchedalmond", + "blue", + "blueviolet", + "brown", + "burlywood", + "cadetblue", + "chartreuse", + "chocolate", + "coral", + "cornflowerblue", + "cornsilk", + "crimson", + "cyan", + "darkblue", + "darkcyan", + "darkgoldenrod", + "darkgray", + "darkgreen", + "darkgrey", + "darkkhaki", + "darkmagenta", + "darkolivegreen", + "darkorange", + "darkorchid", + "darkred", + "darksalmon", + "darkseagreen", + "darkslateblue", + "darkslategray", + "darkslategrey", + "darkturquoise", + "darkviolet", + "deeppink", + "deepskyblue", + "dimgray", + "dimgrey", + "dodgerblue", + "firebrick", + "floralwhite", + "forestgreen", + "fuchsia", + "gainsboro", + "ghostwhite", + "gold", + "goldenrod", + "gray", + "grey", + "green", + "greenyellow", + "honeydew", + "hotpink", + "indianred", + "indigo", + "ivory", + "khaki", + "lavender", + "lavenderblush", + "lawngreen", + "lemonchiffon", + "lightblue", + "lightcoral", + "lightcyan", + "lightgoldenrodyellow", + "lightgray", + "lightgreen", + "lightgrey", + "lightpink", + "lightsalmon", + "lightseagreen", + "lightskyblue", + "lightslategray", + "lightslategrey", + "lightsteelblue", + "lightyellow", + "lime", + "limegreen", + "linen", + "magenta", + "maroon", + "mediumaquamarine", + "mediumblue", + "mediumorchid", + "mediumpurple", + "mediumseagreen", + "mediumslateblue", + "mediumspringgreen", + "mediumturquoise", + "mediumvioletred", + "midnightblue", + "mintcream", + "mistyrose", + "moccasin", + "navajowhite", + "navy", + "oldlace", + "olive", + "olivedrab", + "orange", + "orangered", + "orchid", + "palegoldenrod", + "palegreen", + "paleturquoise", + "palevioletred", + "papayawhip", + "peachpuff", + "peru", + "pink", + "plum", + "powderblue", + "purple", + "red", + "rosybrown", + "royalblue", + "saddlebrown", + "salmon", + "sandybrown", + "seagreen", + "seashell", + "sienna", + "silver", + "skyblue", + "slateblue", + "slategray", + "slategrey", + "snow", + "springgreen", + "steelblue", + "tan", + "teal", + "thistle", + "tomato", + "turquoise", + "violet", + "wheat", + "white", + "whitesmoke", + "yellow", + "yellowgreen" + ] + }, + { + "title": "transparent", + "enum": ["transparent"] + } + ] + }, + "tab-colors": { + "type": "object", + "additionalProperties": false, + "properties": { + "backgrounds": { + "type": "object", + "additionalProperties": false, + "properties": { + "hover": { "$ref": "#/definitions/qt-color" }, + "regular": { "$ref": "#/definitions/qt-color" }, + "unfocused": { "$ref": "#/definitions/qt-color" } + }, + "required": ["hover", "regular", "unfocused"] + }, + "line": { + "type": "object", + "additionalProperties": false, + "properties": { + "hover": { "$ref": "#/definitions/qt-color" }, + "regular": { "$ref": "#/definitions/qt-color" }, + "unfocused": { "$ref": "#/definitions/qt-color" } + }, + "required": ["hover", "regular", "unfocused"] + }, + "text": { "$ref": "#/definitions/qt-color" } + }, + "required": ["backgrounds", "line", "text"] + } + }, + "type": "object", + "additionalProperties": false, + "properties": { + "colors": { + "type": "object", + "additionalProperties": false, + "properties": { + "accent": { "$ref": "#/definitions/qt-color" }, + "messages": { + "type": "object", + "additionalProperties": false, + "properties": { + "backgrounds": { + "type": "object", + "additionalProperties": false, + "properties": { + "alternate": { "$ref": "#/definitions/qt-color" }, + "regular": { "$ref": "#/definitions/qt-color" } + }, + "required": ["alternate", "regular"] + }, + "disabled": { "$ref": "#/definitions/qt-color" }, + "highlightAnimationEnd": { "$ref": "#/definitions/qt-color" }, + "highlightAnimationStart": { "$ref": "#/definitions/qt-color" }, + "selection": { "$ref": "#/definitions/qt-color" }, + "textColors": { + "type": "object", + "additionalProperties": false, + "properties": { + "caret": { "$ref": "#/definitions/qt-color" }, + "chatPlaceholder": { "$ref": "#/definitions/qt-color" }, + "link": { "$ref": "#/definitions/qt-color" }, + "regular": { "$ref": "#/definitions/qt-color" }, + "system": { "$ref": "#/definitions/qt-color" } + }, + "required": [ + "caret", + "chatPlaceholder", + "link", + "regular", + "system" + ] + } + }, + "required": [ + "backgrounds", + "disabled", + "highlightAnimationEnd", + "highlightAnimationStart", + "selection", + "textColors" + ] + }, + "scrollbars": { + "type": "object", + "additionalProperties": false, + "properties": { + "background": { "$ref": "#/definitions/qt-color" }, + "thumb": { "$ref": "#/definitions/qt-color" }, + "thumbSelected": { "$ref": "#/definitions/qt-color" } + }, + "required": ["background", "thumb", "thumbSelected"] + }, + "splits": { + "type": "object", + "additionalProperties": false, + "properties": { + "background": { "$ref": "#/definitions/qt-color" }, + "dropPreview": { "$ref": "#/definitions/qt-color" }, + "dropPreviewBorder": { "$ref": "#/definitions/qt-color" }, + "dropTargetRect": { "$ref": "#/definitions/qt-color" }, + "dropTargetRectBorder": { "$ref": "#/definitions/qt-color" }, + "header": { + "type": "object", + "additionalProperties": false, + "properties": { + "background": { "$ref": "#/definitions/qt-color" }, + "border": { "$ref": "#/definitions/qt-color" }, + "focusedBackground": { "$ref": "#/definitions/qt-color" }, + "focusedBorder": { "$ref": "#/definitions/qt-color" }, + "focusedText": { "$ref": "#/definitions/qt-color" }, + "text": { "$ref": "#/definitions/qt-color" } + }, + "required": [ + "background", + "border", + "focusedBackground", + "focusedBorder", + "focusedText", + "text" + ] + }, + "input": { + "type": "object", + "additionalProperties": false, + "properties": { + "background": { "$ref": "#/definitions/qt-color" }, + "text": { "$ref": "#/definitions/qt-color" } + }, + "required": ["background", "text"] + }, + "messageSeperator": { "$ref": "#/definitions/qt-color" }, + "resizeHandle": { "$ref": "#/definitions/qt-color" }, + "resizeHandleBackground": { "$ref": "#/definitions/qt-color" } + }, + "required": [ + "background", + "dropPreview", + "dropPreviewBorder", + "dropTargetRect", + "dropTargetRectBorder", + "header", + "input", + "messageSeperator", + "resizeHandle", + "resizeHandleBackground" + ] + }, + "tabs": { + "type": "object", + "additionalProperties": false, + "properties": { + "dividerLine": { "$ref": "#/definitions/qt-color" }, + "highlighted": { + "$ref": "#/definitions/tab-colors" + }, + "newMessage": { + "$ref": "#/definitions/tab-colors" + }, + "regular": { + "$ref": "#/definitions/tab-colors" + }, + "selected": { + "$ref": "#/definitions/tab-colors" + } + }, + "required": [ + "dividerLine", + "highlighted", + "newMessage", + "regular", + "selected" + ] + }, + "window": { + "type": "object", + "additionalProperties": false, + "properties": { + "background": { "$ref": "#/definitions/qt-color" }, + "text": { "$ref": "#/definitions/qt-color" } + }, + "required": ["background", "text"] + } + }, + "required": [ + "accent", + "messages", + "scrollbars", + "splits", + "tabs", + "window" + ] + }, + "metadata": { + "type": "object", + "additionalProperties": false, + "properties": { + "iconTheme": { + "$comment": "Determines which icons to use. 'dark' will use dark icons (best for a light theme). 'light' will use light icons.", + "enum": ["light", "dark"], + "default": "light" + } + }, + "required": ["iconTheme"] + }, + "$schema": { "type": "string" } + }, + "required": ["colors", "metadata"] +} diff --git a/resources/themes/Black.json b/resources/themes/Black.json new file mode 100644 index 00000000000..970f3c3787f --- /dev/null +++ b/resources/themes/Black.json @@ -0,0 +1,112 @@ +{ + "$schema": "../../docs/ChatterinoTheme.schema.json", + "metadata": { + "iconTheme": "light" + }, + "colors": { + "accent": "#00aeef", + "messages": { + "backgrounds": { + "alternate": "#0a0a0a", + "regular": "#000000" + }, + "disabled": "#99000000", + "highlightAnimationEnd": "#00e6e6e6", + "highlightAnimationStart": "#6ee6e6e6", + "selection": "#40ffffff", + "textColors": { + "caret": "#ffffff", + "chatPlaceholder": "#5d5555", + "link": "#4286f4", + "regular": "#ffffff", + "system": "#8c7f7f" + } + }, + "scrollbars": { + "background": "#00000000", + "thumb": "#4d4d4d", + "thumbSelected": "#595959" + }, + "splits": { + "background": "#000000", + "dropPreview": "#300094ff", + "dropPreviewBorder": "#0094ff", + "dropTargetRect": "#000094ff", + "dropTargetRectBorder": "#000094ff", + "header": { + "background": "#191919", + "border": "#262626", + "focusedBackground": "#363636", + "focusedBorder": "#383838", + "focusedText": "#84c1ff", + "text": "#ffffff" + }, + "input": { + "background": "#0d0d0d", + "text": "#ffffff" + }, + "messageSeperator": "#3c3c3c", + "resizeHandle": "#700094ff", + "resizeHandleBackground": "#200094ff" + }, + "tabs": { + "dividerLine": "#555555", + "highlighted": { + "backgrounds": { + "hover": "#252525", + "regular": "#252525", + "unfocused": "#252525" + }, + "line": { + "hover": "#ee6166", + "regular": "#ee6166", + "unfocused": "#ee6166" + }, + "text": "#eeeeee" + }, + "newMessage": { + "backgrounds": { + "hover": "#252525", + "regular": "#252525", + "unfocused": "#252525" + }, + "line": { + "hover": "#888888", + "regular": "#888888", + "unfocused": "#888888" + }, + "text": "#eeeeee" + }, + "regular": { + "backgrounds": { + "hover": "#252525", + "regular": "#252525", + "unfocused": "#252525" + }, + "line": { + "hover": "#444444", + "regular": "#444444", + "unfocused": "#444444" + }, + "text": "#aaaaaa" + }, + "selected": { + "backgrounds": { + "hover": "#555555", + "regular": "#555555", + "unfocused": "#555555" + }, + "line": { + "hover": "#00aeef", + "regular": "#00aeef", + "unfocused": "#00aeef" + }, + "text": "#ffffff" + } + }, + "window": { + "background": "#111111", + "text": "#eeeeee" + } + } +} diff --git a/resources/themes/Dark.json b/resources/themes/Dark.json new file mode 100644 index 00000000000..036ce18a3a3 --- /dev/null +++ b/resources/themes/Dark.json @@ -0,0 +1,112 @@ +{ + "$schema": "../../docs/ChatterinoTheme.schema.json", + "metadata": { + "iconTheme": "light" + }, + "colors": { + "accent": "#00aeef", + "messages": { + "backgrounds": { + "alternate": "#222222", + "regular": "#191919" + }, + "disabled": "#99191919", + "highlightAnimationEnd": "#00e6e6e6", + "highlightAnimationStart": "#6ee6e6e6", + "selection": "#40ffffff", + "textColors": { + "caret": "#ffffff", + "chatPlaceholder": "#5d5555", + "link": "#4286f4", + "regular": "#ffffff", + "system": "#8c7f7f" + } + }, + "scrollbars": { + "background": "#00000000", + "thumb": "#575757", + "thumbSelected": "#616161" + }, + "splits": { + "background": "#191919", + "dropPreview": "#300094ff", + "dropPreviewBorder": "#0094ff", + "dropTargetRect": "#000094ff", + "dropTargetRectBorder": "#000094ff", + "header": { + "background": "#2e2e2e", + "border": "#383838", + "focusedBackground": "#444444", + "focusedBorder": "#464646", + "focusedText": "#84c1ff", + "text": "#ffffff" + }, + "input": { + "background": "#242424", + "text": "#ffffff" + }, + "messageSeperator": "#3c3c3c", + "resizeHandle": "#700094ff", + "resizeHandleBackground": "#200094ff" + }, + "tabs": { + "dividerLine": "#555555", + "highlighted": { + "backgrounds": { + "hover": "#252525", + "regular": "#252525", + "unfocused": "#252525" + }, + "line": { + "hover": "#ee6166", + "regular": "#ee6166", + "unfocused": "#ee6166" + }, + "text": "#eeeeee" + }, + "newMessage": { + "backgrounds": { + "hover": "#252525", + "regular": "#252525", + "unfocused": "#252525" + }, + "line": { + "hover": "#888888", + "regular": "#888888", + "unfocused": "#888888" + }, + "text": "#eeeeee" + }, + "regular": { + "backgrounds": { + "hover": "#252525", + "regular": "#252525", + "unfocused": "#252525" + }, + "line": { + "hover": "#444444", + "regular": "#444444", + "unfocused": "#444444" + }, + "text": "#aaaaaa" + }, + "selected": { + "backgrounds": { + "hover": "#555555", + "regular": "#555555", + "unfocused": "#555555" + }, + "line": { + "hover": "#00aeef", + "regular": "#00aeef", + "unfocused": "#00aeef" + }, + "text": "#ffffff" + } + }, + "window": { + "background": "#111111", + "text": "#eeeeee" + } + } +} diff --git a/resources/themes/Light.json b/resources/themes/Light.json new file mode 100644 index 00000000000..338c642e25a --- /dev/null +++ b/resources/themes/Light.json @@ -0,0 +1,112 @@ +{ + "$schema": "../../docs/ChatterinoTheme.schema.json", + "metadata": { + "iconTheme": "dark" + }, + "colors": { + "accent": "#00aeef", + "messages": { + "backgrounds": { + "alternate": "#dddddd", + "regular": "#e6e6e6" + }, + "disabled": "#99e6e6e6", + "highlightAnimationEnd": "#00141414", + "highlightAnimationStart": "#6e141414", + "selection": "#40000000", + "textColors": { + "caret": "#000000", + "chatPlaceholder": "#af9f9f", + "link": "#4286f4", + "regular": "#000000", + "system": "#8c7f7f" + } + }, + "scrollbars": { + "background": "#00000000", + "thumb": "#a8a8a8", + "thumbSelected": "#9e9e9e" + }, + "splits": { + "background": "#e6e6e6", + "dropPreview": "#300094ff", + "dropPreviewBorder": "#0094ff", + "dropTargetRect": "#00ffffff", + "dropTargetRectBorder": "#000094ff", + "header": { + "background": "#e6e6e6", + "border": "#e6e6e6", + "focusedBackground": "#dbdbdb", + "focusedBorder": "#d1d1d1", + "focusedText": "#0051a3", + "text": "#000000" + }, + "input": { + "background": "#dbdbdb", + "text": "#000000" + }, + "messageSeperator": "#7f7f7f", + "resizeHandle": "#0094ff", + "resizeHandleBackground": "#500094ff" + }, + "tabs": { + "dividerLine": "#b4d7ff", + "highlighted": { + "backgrounds": { + "hover": "#eeeeee", + "regular": "#ffffff", + "unfocused": "#ffffff" + }, + "line": { + "hover": "#ff0000", + "regular": "#ff0000", + "unfocused": "#ff0000" + }, + "text": "#000000" + }, + "newMessage": { + "backgrounds": { + "hover": "#eeeeee", + "regular": "#ffffff", + "unfocused": "#ffffff" + }, + "line": { + "hover": "#bbbbbb", + "regular": "#bbbbbb", + "unfocused": "#bbbbbb" + }, + "text": "#222222" + }, + "regular": { + "backgrounds": { + "hover": "#eeeeee", + "regular": "#ffffff", + "unfocused": "#ffffff" + }, + "line": { + "hover": "#ffffff", + "regular": "#ffffff", + "unfocused": "#ffffff" + }, + "text": "#444444" + }, + "selected": { + "backgrounds": { + "hover": "#b4d7ff", + "regular": "#b4d7ff", + "unfocused": "#b4d7ff" + }, + "line": { + "hover": "#00aeef", + "regular": "#00aeef", + "unfocused": "#00aeef" + }, + "text": "#000000" + } + }, + "window": { + "background": "#ffffff", + "text": "#000000" + } + } +} diff --git a/resources/themes/White.json b/resources/themes/White.json new file mode 100644 index 00000000000..7676ac629fa --- /dev/null +++ b/resources/themes/White.json @@ -0,0 +1,112 @@ +{ + "$schema": "../../docs/ChatterinoTheme.schema.json", + "metadata": { + "iconTheme": "dark" + }, + "colors": { + "accent": "#00aeef", + "messages": { + "backgrounds": { + "alternate": "#f5f5f5", + "regular": "#ffffff" + }, + "disabled": "#99ffffff", + "highlightAnimationEnd": "#00141414", + "highlightAnimationStart": "#6e141414", + "selection": "#40000000", + "textColors": { + "caret": "#000000", + "chatPlaceholder": "#af9f9f", + "link": "#4286f4", + "regular": "#000000", + "system": "#8c7f7f" + } + }, + "scrollbars": { + "background": "#00000000", + "thumb": "#b3b3b3", + "thumbSelected": "#a6a6a6" + }, + "splits": { + "background": "#ffffff", + "dropPreview": "#300094ff", + "dropPreviewBorder": "#0094ff", + "dropTargetRect": "#00ffffff", + "dropTargetRectBorder": "#000094ff", + "header": { + "background": "#ffffff", + "border": "#ffffff", + "focusedBackground": "#f2f2f2", + "focusedBorder": "#e6e6e6", + "focusedText": "#0051a3", + "text": "#000000" + }, + "input": { + "background": "#f2f2f2", + "text": "#000000" + }, + "messageSeperator": "#7f7f7f", + "resizeHandle": "#0094ff", + "resizeHandleBackground": "#500094ff" + }, + "tabs": { + "dividerLine": "#b4d7ff", + "highlighted": { + "backgrounds": { + "hover": "#eeeeee", + "regular": "#ffffff", + "unfocused": "#ffffff" + }, + "line": { + "hover": "#ff0000", + "regular": "#ff0000", + "unfocused": "#ff0000" + }, + "text": "#000000" + }, + "newMessage": { + "backgrounds": { + "hover": "#eeeeee", + "regular": "#ffffff", + "unfocused": "#ffffff" + }, + "line": { + "hover": "#bbbbbb", + "regular": "#bbbbbb", + "unfocused": "#bbbbbb" + }, + "text": "#222222" + }, + "regular": { + "backgrounds": { + "hover": "#eeeeee", + "regular": "#ffffff", + "unfocused": "#ffffff" + }, + "line": { + "hover": "#ffffff", + "regular": "#ffffff", + "unfocused": "#ffffff" + }, + "text": "#444444" + }, + "selected": { + "backgrounds": { + "hover": "#b4d7ff", + "regular": "#b4d7ff", + "unfocused": "#b4d7ff" + }, + "line": { + "hover": "#00aeef", + "regular": "#00aeef", + "unfocused": "#00aeef" + }, + "text": "#000000" + } + }, + "window": { + "background": "#ffffff", + "text": "#000000" + } + } +} diff --git a/src/common/QLogging.cpp b/src/common/QLogging.cpp index fb9afa26363..a3168a9938c 100644 --- a/src/common/QLogging.cpp +++ b/src/common/QLogging.cpp @@ -45,6 +45,7 @@ Q_LOGGING_CATEGORY(chatterinoSound, "chatterino.sound", logThreshold); Q_LOGGING_CATEGORY(chatterinoStreamerMode, "chatterino.streamermode", logThreshold); Q_LOGGING_CATEGORY(chatterinoStreamlink, "chatterino.streamlink", logThreshold); +Q_LOGGING_CATEGORY(chatterinoTheme, "chatterino.theme", logThreshold); Q_LOGGING_CATEGORY(chatterinoTokenizer, "chatterino.tokenizer", logThreshold); Q_LOGGING_CATEGORY(chatterinoTwitch, "chatterino.twitch", logThreshold); Q_LOGGING_CATEGORY(chatterinoUpdate, "chatterino.update", logThreshold); diff --git a/src/common/QLogging.hpp b/src/common/QLogging.hpp index d3585f18c8d..c2d0ae2ca5e 100644 --- a/src/common/QLogging.hpp +++ b/src/common/QLogging.hpp @@ -34,6 +34,7 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoSeventvEventAPI); Q_DECLARE_LOGGING_CATEGORY(chatterinoSound); Q_DECLARE_LOGGING_CATEGORY(chatterinoStreamerMode); Q_DECLARE_LOGGING_CATEGORY(chatterinoStreamlink); +Q_DECLARE_LOGGING_CATEGORY(chatterinoTheme); Q_DECLARE_LOGGING_CATEGORY(chatterinoTokenizer); Q_DECLARE_LOGGING_CATEGORY(chatterinoTwitch); Q_DECLARE_LOGGING_CATEGORY(chatterinoUpdate); diff --git a/src/singletons/Theme.cpp b/src/singletons/Theme.cpp index 19f44bf27c3..1a4263cc8cc 100644 --- a/src/singletons/Theme.cpp +++ b/src/singletons/Theme.cpp @@ -2,34 +2,153 @@ #include "singletons/Theme.hpp" #include "Application.hpp" -#include "singletons/Resources.hpp" +#include "common/QLogging.hpp" #include +#include +#include +#include +#include #include namespace { -double getMultiplierByTheme(const QString &themeName) +void parseInto(const QJsonObject &obj, const QLatin1String &key, QColor &color) { - if (themeName == "Light") + const auto &jsonValue = obj[key]; + if (!jsonValue.isString()) [[unlikely]] { - return 0.8; + qCWarning(chatterinoTheme) << key + << "was expected but not found in the " + "current theme - using previous value."; + return; } - if (themeName == "White") + QColor parsed = {jsonValue.toString()}; + if (!parsed.isValid()) [[unlikely]] { - return 1.0; + qCWarning(chatterinoTheme).nospace() + << "While parsing " << key << ": '" << jsonValue.toString() + << "' isn't a valid color."; + return; } - if (themeName == "Black") + color = parsed; +} + +// NOLINTBEGIN(cppcoreguidelines-macro-usage) +#define parseColor(to, from, key) \ + parseInto(from, QLatin1String(#key), (to).from.key) +// NOLINTEND(cppcoreguidelines-macro-usage) + +void parseWindow(const QJsonObject &window, chatterino::Theme &theme) +{ + parseColor(theme, window, background); + parseColor(theme, window, text); +} + +void parseTabs(const QJsonObject &tabs, chatterino::Theme &theme) +{ + const auto parseTabColors = [](auto json, auto &tab) { + parseInto(json, QLatin1String("text"), tab.text); + { + const auto backgrounds = json["backgrounds"].toObject(); + parseColor(tab, backgrounds, regular); + parseColor(tab, backgrounds, hover); + parseColor(tab, backgrounds, unfocused); + } + { + const auto line = json["line"].toObject(); + parseColor(tab, line, regular); + parseColor(tab, line, hover); + parseColor(tab, line, unfocused); + } + }; + parseColor(theme, tabs, dividerLine); + parseTabColors(tabs["regular"].toObject(), theme.tabs.regular); + parseTabColors(tabs["newMessage"].toObject(), theme.tabs.newMessage); + parseTabColors(tabs["highlighted"].toObject(), theme.tabs.highlighted); + parseTabColors(tabs["selected"].toObject(), theme.tabs.selected); +} + +void parseMessages(const QJsonObject &messages, chatterino::Theme &theme) +{ + { + const auto textColors = messages["textColors"].toObject(); + parseColor(theme.messages, textColors, regular); + parseColor(theme.messages, textColors, caret); + parseColor(theme.messages, textColors, link); + parseColor(theme.messages, textColors, system); + parseColor(theme.messages, textColors, chatPlaceholder); + } + { + const auto backgrounds = messages["backgrounds"].toObject(); + parseColor(theme.messages, backgrounds, regular); + parseColor(theme.messages, backgrounds, alternate); + } + parseColor(theme, messages, disabled); + parseColor(theme, messages, selection); + parseColor(theme, messages, highlightAnimationStart); + parseColor(theme, messages, highlightAnimationEnd); +} + +void parseScrollbars(const QJsonObject &scrollbars, chatterino::Theme &theme) +{ + parseColor(theme, scrollbars, background); + parseColor(theme, scrollbars, thumb); + parseColor(theme, scrollbars, thumbSelected); +} + +void parseSplits(const QJsonObject &splits, chatterino::Theme &theme) +{ + parseColor(theme, splits, messageSeperator); + parseColor(theme, splits, background); + parseColor(theme, splits, dropPreview); + parseColor(theme, splits, dropPreviewBorder); + parseColor(theme, splits, dropTargetRect); + parseColor(theme, splits, dropTargetRectBorder); + parseColor(theme, splits, resizeHandle); + parseColor(theme, splits, resizeHandleBackground); + { - return -1.0; + const auto header = splits["header"].toObject(); + parseColor(theme.splits, header, border); + parseColor(theme.splits, header, focusedBorder); + parseColor(theme.splits, header, background); + parseColor(theme.splits, header, focusedBackground); + parseColor(theme.splits, header, text); + parseColor(theme.splits, header, focusedText); } - if (themeName == "Dark") { - return -0.8; + const auto input = splits["input"].toObject(); + parseColor(theme.splits, input, background); + parseColor(theme.splits, input, text); } +} + +void parseColors(const QJsonObject &root, chatterino::Theme &theme) +{ + const auto colors = root["colors"].toObject(); + + parseInto(colors, QLatin1String("accent"), theme.accent); - return -0.8; // default: Dark + parseWindow(colors["window"].toObject(), theme); + parseTabs(colors["tabs"].toObject(), theme); + parseMessages(colors["messages"].toObject(), theme); + parseScrollbars(colors["scrollbars"].toObject(), theme); + parseSplits(colors["splits"].toObject(), theme); } +#undef parseColor + +QString getThemePath(const QString &name) +{ + static QSet knownThemes = {"White", "Light", "Dark", "Black"}; + + if (knownThemes.contains(name)) + { + return QStringLiteral(":/themes/%1.json").arg(name); + } + return name; +} + } // namespace namespace chatterino { @@ -52,142 +171,45 @@ Theme::Theme() void Theme::update() { - this->actuallyUpdate(getMultiplierByTheme(this->themeName.getValue())); - + this->parse(); this->updated.invoke(); } -// multiplier: 1 = white, 0.8 = light, -0.8 dark, -1 black -void Theme::actuallyUpdate(double multiplier) +void Theme::parse() { - this->isLight_ = multiplier > 0; - - const auto isLight = this->isLightTheme(); - - 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 = isLight ? "#fff" : QColor(61, 60, 56); -#else - this->window.background = isLight ? "#fff" : "#111"; -#endif - this->window.text = isLight ? "#000" : "#eee"; - - /// TABSs - if (isLight) + QFile file(getThemePath(this->themeName)); + if (!file.open(QFile::ReadOnly)) { - 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}}; + qCWarning(chatterinoTheme) << "Failed to open" << file.fileName(); + return; } - else + + QJsonParseError error{}; + auto json = QJsonDocument::fromJson(file.readAll(), &error); + if (json.isNull()) { - 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}}; + qCWarning(chatterinoTheme) << "Failed to parse" << file.fileName() + << "error:" << error.errorString(); + return; } - this->tabs.dividerLine = this->tabs.selected.backgrounds.regular; - - // Message - 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); - - this->messages.backgrounds.regular = getGray(1); - this->messages.backgrounds.alternate = getGray(0.96); - - this->messages.disabled = getGray(1, 0.6); - - int complementaryGray = isLight ? 20 : 230; - this->messages.highlightAnimationStart = - QColor(complementaryGray, complementaryGray, complementaryGray, 110); - this->messages.highlightAnimationEnd = - QColor(complementaryGray, complementaryGray, complementaryGray, 0); + this->parseFrom(json.object()); +} - // Scrollbar - this->scrollbars.background = QColor(0, 0, 0, 0); - this->scrollbars.thumb = getGray(0.70); - this->scrollbars.thumbSelected = getGray(0.65); +void Theme::parseFrom(const QJsonObject &root) +{ + parseColors(root, *this); - // Selection - this->messages.selection = - isLight ? QColor(0, 0, 0, 64) : QColor(255, 255, 255, 64); + this->isLight_ = + root["metadata"]["iconTheme"].toString() == QStringLiteral("dark"); - // Splits - if (isLight) - { - this->splits.dropTargetRect = QColor(255, 255, 255, 0); - } - else - { - 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 = 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 = 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 = 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.name() + ";" + "color:" + this->messages.textColors.regular.name() + ";" + "selection-background-color:" + - (isLight ? "#68B1FF" : this->tabs.selected.backgrounds.regular.name()); - - this->splits.messageSeperator = - isLight ? QColor(127, 127, 127) : QColor(60, 60, 60); - this->splits.background = getGray(1); - - // Copy button - if (isLight) - { - this->buttons.copy = getResources().buttons.copyDark; - this->buttons.pin = getResources().buttons.pinDisabledDark; - } - else - { - this->buttons.copy = getResources().buttons.copyLight; - this->buttons.pin = getResources().buttons.pinDisabledLight; - } + (this->isLightTheme() ? "#68B1FF" + : this->tabs.selected.backgrounds.regular.name()); } void Theme::normalizeColor(QColor &color) const diff --git a/src/singletons/Theme.hpp b/src/singletons/Theme.hpp index fb6690ee606..034bb799e54 100644 --- a/src/singletons/Theme.hpp +++ b/src/singletons/Theme.hpp @@ -120,7 +120,9 @@ class Theme final : public Singleton private: bool isLight_ = false; - void actuallyUpdate(double multiplier); + + void parse(); + void parseFrom(const QJsonObject &root); pajlada::Signals::NoArgSignal repaintVisibleChatWidgets_; From 73435b4cf404d4a06d5f714e8de21741dc7cd9f4 Mon Sep 17 00:00:00 2001 From: Daniel Sage <24928223+dnsge@users.noreply.github.com> Date: Sat, 8 Apr 2023 06:25:43 -0400 Subject: [PATCH 17/38] Remove unused variables, Refactor in MessageLayout (#4520) Co-authored-by: pajlada --- src/messages/layouts/MessageLayout.cpp | 92 ++++++++++++++------------ src/messages/layouts/MessageLayout.hpp | 26 +++++--- 2 files changed, 65 insertions(+), 53 deletions(-) diff --git a/src/messages/layouts/MessageLayout.cpp b/src/messages/layouts/MessageLayout.cpp index 9e39c557a8e..00bc3b46daf 100644 --- a/src/messages/layouts/MessageLayout.cpp +++ b/src/messages/layouts/MessageLayout.cpp @@ -44,7 +44,6 @@ namespace { MessageLayout::MessageLayout(MessagePtr message) : message_(std::move(message)) - , container_(std::make_shared()) { DebugCount::increase("message layout"); } @@ -67,12 +66,12 @@ const MessagePtr &MessageLayout::getMessagePtr() const // Height int MessageLayout::getHeight() const { - return container_->getHeight(); + return this->container_.getHeight(); } int MessageLayout::getWidth() const { - return this->container_->getWidth(); + return this->container_.getWidth(); } // Layout @@ -115,9 +114,9 @@ bool MessageLayout::layout(int width, float scale, MessageElementFlags flags) return false; } - int oldHeight = this->container_->getHeight(); + int oldHeight = this->container_.getHeight(); this->actuallyLayout(width, flags); - if (widthChanged || this->container_->getHeight() != oldHeight) + if (widthChanged || this->container_.getHeight() != oldHeight) { this->deleteBuffer(); } @@ -128,7 +127,10 @@ bool MessageLayout::layout(int width, float scale, MessageElementFlags flags) void MessageLayout::actuallyLayout(int width, MessageElementFlags flags) { +#ifdef FOURTF this->layoutCount_++; +#endif + auto messageFlags = this->message_->flags; if (this->flags.has(MessageLayoutFlag::Expanded) || @@ -143,7 +145,7 @@ void MessageLayout::actuallyLayout(int width, MessageElementFlags flags) bool hideSimilar = getSettings()->hideSimilar; bool hideReplies = !flags.has(MessageElementFlag::RepliedMessage); - this->container_->begin(width, this->scale_, messageFlags); + this->container_.begin(width, this->scale_, messageFlags); for (const auto &element : this->message_->elements) { @@ -176,20 +178,20 @@ void MessageLayout::actuallyLayout(int width, MessageElementFlags flags) continue; } - element->addToContainer(*this->container_, flags); + element->addToContainer(this->container_, flags); } - if (this->height_ != this->container_->getHeight()) + if (this->height_ != this->container_.getHeight()) { this->deleteBuffer(); } - this->container_->end(); - this->height_ = this->container_->getHeight(); + this->container_.end(); + this->height_ = this->container_.getHeight(); // collapsed state this->flags.unset(MessageLayoutFlag::Collapsed); - if (this->container_->isCollapsed()) + if (this->container_.isCollapsed()) { this->flags.set(MessageLayoutFlag::Collapsed); } @@ -201,25 +203,7 @@ void MessageLayout::paint(QPainter &painter, int width, int y, int messageIndex, bool isWindowFocused, bool isMentions) { auto app = getApp(); - QPixmap *pixmap = this->buffer_.get(); - - // create new buffer if required - if (!pixmap) - { -#if defined(Q_OS_MACOS) || defined(Q_OS_LINUX) - pixmap = new QPixmap(int(width * painter.device()->devicePixelRatioF()), - int(container_->getHeight() * - painter.device()->devicePixelRatioF())); - pixmap->setDevicePixelRatio(painter.device()->devicePixelRatioF()); -#else - pixmap = - new QPixmap(width, std::max(16, this->container_->getHeight())); -#endif - - this->buffer_ = std::shared_ptr(pixmap); - this->bufferValid_ = false; - DebugCount::increase("message drawing buffers"); - } + QPixmap *pixmap = this->ensureBuffer(painter, width); if (!this->bufferValid_ || !selection.isEmpty()) { @@ -232,7 +216,7 @@ void MessageLayout::paint(QPainter &painter, int width, int y, int messageIndex, // this->container.getHeight(), *pixmap); // draw gif emotes - this->container_->paintAnimatedElements(painter, y); + this->container_.paintAnimatedElements(painter, y); // draw disabled if (this->message_->flags.has(MessageFlag::Disabled)) @@ -262,13 +246,13 @@ void MessageLayout::paint(QPainter &painter, int width, int y, int messageIndex, // draw selection if (!selection.isEmpty()) { - this->container_->paintSelection(painter, messageIndex, selection, y); + this->container_.paintSelection(painter, messageIndex, selection, y); } // draw message seperation line if (getSettings()->separateMessages.getValue()) { - painter.fillRect(0, y, this->container_->getWidth() + 64, 1, + painter.fillRect(0, y, this->container_.getWidth() + 64, 1, app->themes->splits.messageSeperator); } @@ -290,13 +274,37 @@ void MessageLayout::paint(QPainter &painter, int width, int y, int messageIndex, QBrush brush(color, static_cast( getSettings()->lastMessagePattern.getValue())); - painter.fillRect(0, y + this->container_->getHeight() - 1, + painter.fillRect(0, y + this->container_.getHeight() - 1, pixmap->width(), 1, brush); } this->bufferValid_ = true; } +QPixmap *MessageLayout::ensureBuffer(QPainter &painter, int width) +{ + if (this->buffer_ != nullptr) + { + return this->buffer_.get(); + } + + // Create new buffer +#if defined(Q_OS_MACOS) || defined(Q_OS_LINUX) + this->buffer_ = std::make_unique( + int(width * painter.device()->devicePixelRatioF()), + int(this->container_.getHeight() * + painter.device()->devicePixelRatioF())); + this->buffer_->setDevicePixelRatio(painter.device()->devicePixelRatioF()); +#else + this->buffer_ = std::make_unique( + width, std::max(16, this->container_.getHeight())); +#endif + + this->bufferValid_ = false; + DebugCount::increase("message drawing buffers"); + return this->buffer_.get(); +} + void MessageLayout::updateBuffer(QPixmap *buffer, int /*messageIndex*/, Selection & /*selection*/) { @@ -375,7 +383,7 @@ void MessageLayout::updateBuffer(QPixmap *buffer, int /*messageIndex*/, painter.fillRect(buffer->rect(), backgroundColor); // draw message - this->container_->paintElements(painter); + this->container_.paintElements(painter); #ifdef FOURTF // debug @@ -386,7 +394,7 @@ void MessageLayout::updateBuffer(QPixmap *buffer, int /*messageIndex*/, QTextOption option; option.setAlignment(Qt::AlignRight | Qt::AlignTop); - painter.drawText(QRectF(1, 1, this->container_->getWidth() - 3, 1000), + painter.drawText(QRectF(1, 1, this->container_.getWidth() - 3, 1000), QString::number(this->layoutCount_) + ", " + QString::number(++this->bufferUpdatedCount_), option); @@ -413,7 +421,7 @@ void MessageLayout::deleteCache() this->deleteBuffer(); #ifdef XD - this->container_->clear(); + this->container_.clear(); #endif } @@ -426,28 +434,28 @@ void MessageLayout::deleteCache() const MessageLayoutElement *MessageLayout::getElementAt(QPoint point) { // go through all words and return the first one that contains the point. - return this->container_->getElementAt(point); + return this->container_.getElementAt(point); } int MessageLayout::getLastCharacterIndex() const { - return this->container_->getLastCharacterIndex(); + return this->container_.getLastCharacterIndex(); } int MessageLayout::getFirstMessageCharacterIndex() const { - return this->container_->getFirstMessageCharacterIndex(); + return this->container_.getFirstMessageCharacterIndex(); } int MessageLayout::getSelectionIndex(QPoint position) { - return this->container_->getSelectionIndex(position); + return this->container_.getSelectionIndex(position); } void MessageLayout::addSelectionText(QString &str, uint32_t from, uint32_t to, CopyMode copymode) { - this->container_->addSelectionText(str, from, to, copymode); + this->container_.addSelectionText(str, from, to, copymode); } bool MessageLayout::isReplyable() const diff --git a/src/messages/layouts/MessageLayout.hpp b/src/messages/layouts/MessageLayout.hpp index 5bbf437e465..c0e64709f53 100644 --- a/src/messages/layouts/MessageLayout.hpp +++ b/src/messages/layouts/MessageLayout.hpp @@ -2,6 +2,7 @@ #include "common/Common.hpp" #include "common/FlagsEnum.hpp" +#include "messages/layouts/MessageLayoutContainer.hpp" #include #include @@ -69,27 +70,30 @@ class MessageLayout : boost::noncopyable bool isReplyable() const; private: + // methods + void actuallyLayout(int width, MessageElementFlags flags); + void updateBuffer(QPixmap *pixmap, int messageIndex, Selection &selection); + + // Create new buffer if required, returning the buffer + QPixmap *ensureBuffer(QPainter &painter, int width); + // variables MessagePtr message_; - std::shared_ptr container_; - std::shared_ptr buffer_{}; + MessageLayoutContainer container_; + std::unique_ptr buffer_{}; bool bufferValid_ = false; int height_ = 0; - int currentLayoutWidth_ = -1; int layoutState_ = -1; float scale_ = -1; - unsigned int layoutCount_ = 0; - unsigned int bufferUpdatedCount_ = 0; - MessageElementFlags currentWordFlags_; - int collapsedHeight_ = 32; - - // methods - void actuallyLayout(int width, MessageElementFlags flags); - void updateBuffer(QPixmap *pixmap, int messageIndex, Selection &selection); +#ifdef FOURTF + // Debug counters + unsigned int layoutCount_ = 0; + unsigned int bufferUpdatedCount_ = 0; +#endif }; using MessageLayoutPtr = std::shared_ptr; From 073192b4e51a9f658256320df747db081198b4c0 Mon Sep 17 00:00:00 2001 From: pajlada Date: Sat, 8 Apr 2023 12:38:21 +0200 Subject: [PATCH 18/38] Remove the "Pull request checklist" from the template (#4521) --- .github/pull_request_template.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d42cd316451..9880f2ce162 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,7 +1,3 @@ -Pull request checklist: - -- [ ] `CHANGELOG.md` was updated, if applicable - # Description From 5c55f62600d60d14bd08c385e5d90ef772932980 Mon Sep 17 00:00:00 2001 From: kornes <28986062+kornes@users.noreply.github.com> Date: Sat, 8 Apr 2023 13:43:38 +0000 Subject: [PATCH 19/38] Fix emote & badge tooltips not showing up when thumbnails were hidden (#4509) Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 1 + src/singletons/Settings.hpp | 14 ++- src/widgets/helper/ChannelView.cpp | 129 ++++++++++++---------- src/widgets/settingspages/GeneralPage.cpp | 24 +++- 4 files changed, 100 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17e2dc96430..e3377e24337 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Bugfix: Fixed blocked user list sticking around when switching from a logged in user to being logged out. (#4437) - Bugfix: Fixed search popup ignoring setting for message scrollback limit. (#4496) - Bugfix: Fixed a memory leak that occurred when loading message history. This was mostly noticeable with unstable internet connections where reconnections were frequent or long-running instances of Chatterino. (#4499) +- Bugfix: Fixed emote & badge tooltips not showing up when thumbnails were hidden. (#4509) - Dev: Disabling precompiled headers on Windows is now tested in CI. (#4472) - Dev: Themes are now stored as JSON files in `resources/themes`. (#4471) - Dev: Ignore unhandled BTTV user-events. (#4438) diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 467681412e3..3ac50388622 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -74,6 +74,14 @@ enum HelixTimegateOverride : int { AlwaysUseHelix = 3, }; +enum ThumbnailPreviewMode : int { + DontShow = 0, + + AlwaysShow = 1, + + ShowOnShift = 2, +}; + /// Settings which are availlable for reading and writing on the gui thread. // These settings are still accessed concurrently in the code but it is bad practice. class Settings : public ABSettings, public ConcurrentSettings @@ -217,7 +225,6 @@ class Settings : public ABSettings, public ConcurrentSettings FloatSetting emoteScale = {"/emotes/scale", 1.f}; BoolSetting showUnlistedSevenTVEmotes = { "/emotes/showUnlistedSevenTVEmotes", false}; - QStringSetting emojiSet = {"/emotes/emojiSet", "Twitter"}; BoolSetting stackBits = {"/emotes/stackBits", false}; @@ -478,9 +485,12 @@ class Settings : public ABSettings, public ConcurrentSettings HelixTimegateOverride::Timegate, }; - IntSetting emotesTooltipPreview = {"/misc/emotesTooltipPreview", 1}; BoolSetting openLinksIncognito = {"/misc/openLinksIncognito", 0}; + EnumSetting emotesTooltipPreview = { + "/misc/emotesTooltipPreview", + ThumbnailPreviewMode::AlwaysShow, + }; QStringSetting cachePath = {"/cache/path", ""}; BoolSetting restartOnCrash = {"/misc/restartOnCrash", false}; BoolSetting attachExtensionToAnyProcess = { diff --git a/src/widgets/helper/ChannelView.cpp b/src/widgets/helper/ChannelView.cpp index a534db3e0ea..b05b8623967 100644 --- a/src/widgets/helper/ChannelView.cpp +++ b/src/widgets/helper/ChannelView.cpp @@ -1672,81 +1672,88 @@ void ChannelView::mouseMoveEvent(QMouseEvent *event) { auto badgeElement = dynamic_cast(element); - if ((badgeElement || emoteElement || layeredEmoteElement) && - getSettings()->emotesTooltipPreview.getValue()) + if (badgeElement || emoteElement || layeredEmoteElement) { - if (event->modifiers() == Qt::ShiftModifier || - getSettings()->emotesTooltipPreview.getValue() == 1) + auto showThumbnailSetting = + getSettings()->emotesTooltipPreview.getValue(); + + bool showThumbnail = + showThumbnailSetting == ThumbnailPreviewMode::AlwaysShow || + (showThumbnailSetting == ThumbnailPreviewMode::ShowOnShift && + event->modifiers() == Qt::ShiftModifier); + + if (emoteElement) { - if (emoteElement) - { - tooltipWidget->setOne({ - emoteElement->getEmote()->images.getImage(3.0), - element->getTooltip(), - }); - } - else if (layeredEmoteElement) + tooltipWidget->setOne({ + showThumbnail + ? emoteElement->getEmote()->images.getImage(3.0) + : nullptr, + element->getTooltip(), + }); + } + else if (layeredEmoteElement) + { + auto &layeredEmotes = layeredEmoteElement->getEmotes(); + // Should never be empty but ensure it + if (!layeredEmotes.empty()) { - 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) { - 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; - } + upperLimit = TOOLTIP_EMOTE_ENTRIES_LIMIT - 1; + truncating = true; + } - for (size_t i = 0; i < upperLimit; ++i) + for (size_t i = 0; i < upperLimit; ++i) + { + const auto &emote = layeredEmotes[i].ptr; + if (i == 0) { - const auto &emote = layeredEmotes[i].ptr; - 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}); - } + // First entry gets a large image and full description + entries.push_back({showThumbnail + ? emote->images.getImage(3.0) + : nullptr, + emoteTooltips[i]}); } - - if (truncating) + else { - entries.push_back({nullptr, "..."}); + // Every other entry gets a small image and just the emote name + entries.push_back({showThumbnail + ? emote->images.getImage(1.0) + : nullptr, + emote->name.string}); } + } - auto style = layeredEmotes.size() > 2 - ? TooltipStyle::Grid - : TooltipStyle::Vertical; - tooltipWidget->set(entries, style); + if (truncating) + { + entries.push_back({nullptr, "..."}); } - } - else if (badgeElement) - { - tooltipWidget->setOne({ - badgeElement->getEmote()->images.getImage(3.0), - element->getTooltip(), - }); + + auto style = layeredEmotes.size() > 2 + ? TooltipStyle::Grid + : TooltipStyle::Vertical; + tooltipWidget->set(entries, style); } } - else + else if (badgeElement) { - tooltipWidget->clearEntries(); + tooltipWidget->setOne({ + showThumbnail + ? badgeElement->getEmote()->images.getImage(3.0) + : nullptr, + element->getTooltip(), + }); } } else diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index 27e9d94a717..4054870a19c 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -416,16 +416,30 @@ void GeneralPage::initLayout(GeneralPageView &layout) }); }, false); - layout.addDropdown( - "Show info on hover", {"Don't show", "Always show", "Hold shift"}, + layout.addDropdown::type>( + "Show emote & badge thumbnail on hover", + { + "Don't show", + "Always show", + "Hold shift", + }, s.emotesTooltipPreview, - [](int index) { - return index; + [](auto val) { + switch (val) + { + case ThumbnailPreviewMode::DontShow: + return "Don't show"; + case ThumbnailPreviewMode::AlwaysShow: + return "Always show"; + case ThumbnailPreviewMode::ShowOnShift: + return "Hold shift"; + } + return ""; }, [](auto args) { return args.index; }, - false, "Show emote name, provider, and author on hover."); + false); layout.addDropdown("Emoji style", { "Twitter", From 6cbf750ec549927fa38e861953c581b806fb4b7e Mon Sep 17 00:00:00 2001 From: CycloneTM Date: Sun, 9 Apr 2023 04:38:38 -0500 Subject: [PATCH 20/38] Streamline the look of the Black theme (#4523) This makes it be more in line with the other themes --- CHANGELOG.md | 1 + resources/themes/Black.json | 36 ++++++++++++++++++------------------ 2 files changed, 19 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3377e24337..28d7bf91550 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Minor: Added support for FrankerFaceZ animated emotes. (#4434) - Minor: Added a local backup of the Twitch Badges API in case the request fails. (#4463) - Minor: Added the ability to reply to a message by `Shift + Right Click`ing the username. (#4424) +- Minor: Updated the look of the Black Theme to be more in line with the other themes. (#4523) - 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/themes/Black.json b/resources/themes/Black.json index 970f3c3787f..79f46dc76b7 100644 --- a/resources/themes/Black.json +++ b/resources/themes/Black.json @@ -34,15 +34,15 @@ "dropTargetRect": "#000094ff", "dropTargetRectBorder": "#000094ff", "header": { - "background": "#191919", - "border": "#262626", - "focusedBackground": "#363636", - "focusedBorder": "#383838", + "background": "#050505", + "border": "#121212", + "focusedBackground": "#1a1a1a", + "focusedBorder": "#1c1c1c", "focusedText": "#84c1ff", "text": "#ffffff" }, "input": { - "background": "#0d0d0d", + "background": "#080808", "text": "#ffffff" }, "messageSeperator": "#3c3c3c", @@ -53,9 +53,9 @@ "dividerLine": "#555555", "highlighted": { "backgrounds": { - "hover": "#252525", - "regular": "#252525", - "unfocused": "#252525" + "hover": "#0b0b0b", + "regular": "#0b0b0b", + "unfocused": "#0b0b0b" }, "line": { "hover": "#ee6166", @@ -66,9 +66,9 @@ }, "newMessage": { "backgrounds": { - "hover": "#252525", - "regular": "#252525", - "unfocused": "#252525" + "hover": "#0b0b0b", + "regular": "#0b0b0b", + "unfocused": "#0b0b0b" }, "line": { "hover": "#888888", @@ -79,9 +79,9 @@ }, "regular": { "backgrounds": { - "hover": "#252525", - "regular": "#252525", - "unfocused": "#252525" + "hover": "#0b0b0b", + "regular": "#0b0b0b", + "unfocused": "#0b0b0b" }, "line": { "hover": "#444444", @@ -92,9 +92,9 @@ }, "selected": { "backgrounds": { - "hover": "#555555", - "regular": "#555555", - "unfocused": "#555555" + "hover": "#333333", + "regular": "#333333", + "unfocused": "#333333" }, "line": { "hover": "#00aeef", @@ -105,7 +105,7 @@ } }, "window": { - "background": "#111111", + "background": "#040404", "text": "#eeeeee" } } From c8e1741e47767f182e6f304b782212d63362791a Mon Sep 17 00:00:00 2001 From: pajlada Date: Sun, 9 Apr 2023 12:18:56 +0200 Subject: [PATCH 21/38] Remove CHATTERINO_TEST definition (#4526) --- CHANGELOG.md | 1 + CMakeLists.txt | 4 ---- benchmarks/CMakeLists.txt | 4 ---- src/messages/Image.cpp | 24 +++++++++--------------- src/messages/Image.hpp | 10 +--------- src/messages/ImageSet.cpp | 2 -- src/providers/emoji/Emojis.cpp | 6 ------ tests/CMakeLists.txt | 4 ---- 8 files changed, 11 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28d7bf91550..287b096324c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ - Dev: Add scripting capabilities with Lua (#4341, #4504) - Dev: Conan 2.0 is now used instead of Conan 1.0. (#4417) - Dev: Added tests and benchmarks for `LinkParser`. (#4436) +- Dev: Removed `CHATTERINO_TEST` definitions. (#4526) ## 2.4.2 diff --git a/CMakeLists.txt b/CMakeLists.txt index bc3844e6049..74fab03e418 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -169,10 +169,6 @@ endif() set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD_REQUIRED ON) -if (BUILD_TESTS OR BUILD_BENCHMARKS) - add_definitions(-DCHATTERINO_TEST) -endif () - # Generate resource files include(cmake/resources/generate_resources.cmake) diff --git a/benchmarks/CMakeLists.txt b/benchmarks/CMakeLists.txt index b655b7f843d..344258516c1 100644 --- a/benchmarks/CMakeLists.txt +++ b/benchmarks/CMakeLists.txt @@ -18,10 +18,6 @@ target_link_libraries(${PROJECT_NAME} PRIVATE chatterino-lib) target_link_libraries(${PROJECT_NAME} PRIVATE benchmark::benchmark) -target_compile_definitions(${PROJECT_NAME} PRIVATE - CHATTERINO_TEST - ) - set_target_properties(${PROJECT_NAME} PROPERTIES ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib" diff --git a/src/messages/Image.cpp b/src/messages/Image.cpp index 952b6146578..2e8616b19f0 100644 --- a/src/messages/Image.cpp +++ b/src/messages/Image.cpp @@ -8,6 +8,11 @@ #include "common/QLogging.hpp" #include "debug/AssertInGuiThread.hpp" #include "debug/Benchmark.hpp" +#include "singletons/Emotes.hpp" +#include "singletons/helper/GifTimer.hpp" +#include "singletons/WindowManager.hpp" +#include "util/DebugCount.hpp" +#include "util/PostToThread.hpp" #include #include @@ -20,13 +25,6 @@ #include #include #include -#ifndef CHATTERINO_TEST -# include "singletons/Emotes.hpp" -#endif -#include "singletons/helper/GifTimer.hpp" -#include "singletons/WindowManager.hpp" -#include "util/DebugCount.hpp" -#include "util/PostToThread.hpp" // Duration between each check of every Image instance const auto IMAGE_POOL_CLEANUP_INTERVAL = std::chrono::minutes(1); @@ -55,12 +53,10 @@ namespace detail { { DebugCount::increase("animated images"); -#ifndef CHATTERINO_TEST this->gifTimerConnection_ = getApp()->emotes->gifTimer.signal.connect([this] { this->advance(); }); -#endif } auto totalLength = @@ -75,11 +71,9 @@ namespace detail { } else { -#ifndef CHATTERINO_TEST this->durationOffset_ = std::min( int(getApp()->emotes->gifTimer.position() % totalLength), 60000); -#endif } this->processOffset(); } @@ -228,9 +222,8 @@ namespace detail { } } -#ifndef CHATTERINO_TEST getApp()->windows->forceLayoutChannelViews(); -#endif + loadedEventQueued = false; } @@ -558,8 +551,9 @@ void Image::expireFrames() #ifndef DISABLE_IMAGE_EXPIRATION_POOL ImageExpirationPool::ImageExpirationPool() + : freeTimer_(new QTimer) { - QObject::connect(&this->freeTimer_, &QTimer::timeout, [this] { + QObject::connect(this->freeTimer_, &QTimer::timeout, [this] { if (isGuiThread()) { this->freeOld(); @@ -572,7 +566,7 @@ ImageExpirationPool::ImageExpirationPool() } }); - this->freeTimer_.start( + this->freeTimer_->start( std::chrono::duration_cast( IMAGE_POOL_CLEANUP_INTERVAL)); } diff --git a/src/messages/Image.hpp b/src/messages/Image.hpp index 5ac7fafe55e..90159a442eb 100644 --- a/src/messages/Image.hpp +++ b/src/messages/Image.hpp @@ -19,14 +19,6 @@ #include #include -#ifdef CHATTERINO_TEST -// When running tests, the ImageExpirationPool destructor can be called before -// all images are deleted, leading to a use-after-free of its mutex. This -// happens despite the lifetime of the ImageExpirationPool being (apparently) -// static. Therefore, just disable it during testing. -# define DISABLE_IMAGE_EXPIRATION_POOL -#endif - namespace chatterino { namespace detail { template @@ -136,7 +128,7 @@ class ImageExpirationPool private: // Timer to periodically run freeOld() - QTimer freeTimer_; + QTimer *freeTimer_; std::map> allImages_; std::mutex mutex_; }; diff --git a/src/messages/ImageSet.cpp b/src/messages/ImageSet.cpp index 09294f7664e..b4fa4a85778 100644 --- a/src/messages/ImageSet.cpp +++ b/src/messages/ImageSet.cpp @@ -61,9 +61,7 @@ const ImagePtr &ImageSet::getImage3() const const std::shared_ptr &getImagePriv(const ImageSet &set, float scale) { -#ifndef CHATTERINO_TEST scale *= getSettings()->emoteScale; -#endif int quality = 1; diff --git a/src/providers/emoji/Emojis.cpp b/src/providers/emoji/Emojis.cpp index 1727f0bf302..72c42983742 100644 --- a/src/providers/emoji/Emojis.cpp +++ b/src/providers/emoji/Emojis.cpp @@ -216,11 +216,7 @@ void Emojis::sortEmojis() void Emojis::loadEmojiSet() { -#ifndef CHATTERINO_TEST getSettings()->emojiSet.connect([this](const auto &emojiSet) { -#else - const QString emojiSet = "twitter"; -#endif this->emojis.each([=](const auto &name, std::shared_ptr &emoji) { QString emojiSetToUse = emojiSet; @@ -265,9 +261,7 @@ void Emojis::loadEmojiSet() EmoteName{emoji->value}, ImageSet{Image::fromUrl({url}, 0.35)}, Tooltip{":" + emoji->shortCodes[0] + ":
Emoji"}, Url{}}); }); -#ifndef CHATTERINO_TEST }); -#endif } std::vector> Emojis::parse( diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e57515d7b93..7c9afa29d9d 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -35,10 +35,6 @@ target_link_libraries(${PROJECT_NAME} PRIVATE chatterino-lib) target_link_libraries(${PROJECT_NAME} PRIVATE gtest gmock) -target_compile_definitions(${PROJECT_NAME} PRIVATE - CHATTERINO_TEST - ) - set_target_properties(${PROJECT_NAME} PROPERTIES ARCHIVE_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib" From 34db692895b3a5e49092e33a2513617938018c21 Mon Sep 17 00:00:00 2001 From: Daniel Sage <24928223+dnsge@users.noreply.github.com> Date: Sun, 9 Apr 2023 17:35:06 -0400 Subject: [PATCH 22/38] Implement type checking/validation for filters (#4364) Co-authored-by: pajlada --- CHANGELOG.md | 1 + src/CMakeLists.txt | 26 +- src/controllers/filters/FilterRecord.cpp | 48 ++- src/controllers/filters/FilterRecord.hpp | 16 +- src/controllers/filters/FilterSet.cpp | 3 +- src/controllers/filters/lang/Filter.cpp | 161 +++++++ src/controllers/filters/lang/Filter.hpp | 77 ++++ .../filters/{parser => lang}/FilterParser.cpp | 156 ++----- .../filters/{parser => lang}/FilterParser.hpp | 24 +- .../filters/{parser => lang}/Tokenizer.cpp | 77 +++- .../filters/{parser => lang}/Tokenizer.hpp | 60 ++- src/controllers/filters/lang/Types.cpp | 101 +++++ src/controllers/filters/lang/Types.hpp | 95 ++++ .../expressions/BinaryOperation.cpp} | 404 ++++++------------ .../lang/expressions/BinaryOperation.hpp | 24 ++ .../filters/lang/expressions/Expression.cpp | 25 ++ .../filters/lang/expressions/Expression.hpp | 28 ++ .../lang/expressions/ListExpression.cpp | 94 ++++ .../lang/expressions/ListExpression.hpp | 22 + .../lang/expressions/RegexExpression.cpp | 36 ++ .../lang/expressions/RegexExpression.hpp | 26 ++ .../lang/expressions/UnaryOperation.cpp | 69 +++ .../lang/expressions/UnaryOperation.hpp | 23 + .../lang/expressions/ValueExpression.cpp | 70 +++ .../lang/expressions/ValueExpression.hpp | 24 ++ src/controllers/filters/parser/Types.hpp | 168 -------- .../dialogs/ChannelFilterEditorDialog.cpp | 13 +- src/widgets/settingspages/FiltersPage.cpp | 34 +- tests/CMakeLists.txt | 1 + tests/src/Filters.cpp | 172 ++++++++ 30 files changed, 1449 insertions(+), 629 deletions(-) create mode 100644 src/controllers/filters/lang/Filter.cpp create mode 100644 src/controllers/filters/lang/Filter.hpp rename src/controllers/filters/{parser => lang}/FilterParser.cpp (67%) rename src/controllers/filters/{parser => lang}/FilterParser.hpp (62%) rename src/controllers/filters/{parser => lang}/Tokenizer.cpp (71%) rename src/controllers/filters/{parser => lang}/Tokenizer.hpp (70%) create mode 100644 src/controllers/filters/lang/Types.cpp create mode 100644 src/controllers/filters/lang/Types.hpp rename src/controllers/filters/{parser/Types.cpp => lang/expressions/BinaryOperation.cpp} (53%) create mode 100644 src/controllers/filters/lang/expressions/BinaryOperation.hpp create mode 100644 src/controllers/filters/lang/expressions/Expression.cpp create mode 100644 src/controllers/filters/lang/expressions/Expression.hpp create mode 100644 src/controllers/filters/lang/expressions/ListExpression.cpp create mode 100644 src/controllers/filters/lang/expressions/ListExpression.hpp create mode 100644 src/controllers/filters/lang/expressions/RegexExpression.cpp create mode 100644 src/controllers/filters/lang/expressions/RegexExpression.hpp create mode 100644 src/controllers/filters/lang/expressions/UnaryOperation.cpp create mode 100644 src/controllers/filters/lang/expressions/UnaryOperation.hpp create mode 100644 src/controllers/filters/lang/expressions/ValueExpression.cpp create mode 100644 src/controllers/filters/lang/expressions/ValueExpression.hpp delete mode 100644 src/controllers/filters/parser/Types.hpp create mode 100644 tests/src/Filters.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 287b096324c..b540c6b8670 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Minor: Added support for FrankerFaceZ animated emotes. (#4434) - Minor: Added a local backup of the Twitch Badges API in case the request fails. (#4463) - Minor: Added the ability to reply to a message by `Shift + Right Click`ing the username. (#4424) +- Minor: Added better filter validation and error messages. (#4364) - Minor: Updated the look of the Black Theme to be more in line with the other themes. (#4523) - 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) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index e5bb4741384..a6de2b92eae 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -74,12 +74,26 @@ set(SOURCE_FILES controllers/filters/FilterRecord.hpp controllers/filters/FilterSet.cpp controllers/filters/FilterSet.hpp - controllers/filters/parser/FilterParser.cpp - controllers/filters/parser/FilterParser.hpp - controllers/filters/parser/Tokenizer.cpp - controllers/filters/parser/Tokenizer.hpp - controllers/filters/parser/Types.cpp - controllers/filters/parser/Types.hpp + controllers/filters/lang/expressions/Expression.cpp + controllers/filters/lang/expressions/Expression.hpp + controllers/filters/lang/expressions/BinaryOperation.cpp + controllers/filters/lang/expressions/BinaryOperation.hpp + controllers/filters/lang/expressions/ListExpression.cpp + controllers/filters/lang/expressions/ListExpression.hpp + controllers/filters/lang/expressions/RegexExpression.cpp + controllers/filters/lang/expressions/RegexExpression.hpp + controllers/filters/lang/expressions/UnaryOperation.hpp + controllers/filters/lang/expressions/UnaryOperation.cpp + controllers/filters/lang/expressions/ValueExpression.cpp + controllers/filters/lang/expressions/ValueExpression.hpp + controllers/filters/lang/Filter.cpp + controllers/filters/lang/Filter.hpp + controllers/filters/lang/FilterParser.cpp + controllers/filters/lang/FilterParser.hpp + controllers/filters/lang/Tokenizer.cpp + controllers/filters/lang/Tokenizer.hpp + controllers/filters/lang/Types.cpp + controllers/filters/lang/Types.hpp controllers/highlights/BadgeHighlightModel.cpp controllers/highlights/BadgeHighlightModel.hpp diff --git a/src/controllers/filters/FilterRecord.cpp b/src/controllers/filters/FilterRecord.cpp index 24bd22f6212..409aa64fa32 100644 --- a/src/controllers/filters/FilterRecord.cpp +++ b/src/controllers/filters/FilterRecord.cpp @@ -1,21 +1,40 @@ #include "controllers/filters/FilterRecord.hpp" +#include "controllers/filters/lang/Filter.hpp" + namespace chatterino { -FilterRecord::FilterRecord(const QString &name, const QString &filter) - : name_(name) - , filter_(filter) - , id_(QUuid::createUuid()) - , parser_(std::make_unique(filter)) +static std::unique_ptr buildFilter(const QString &filterText) +{ + using namespace filters; + auto result = Filter::fromString(filterText); + if (std::holds_alternative(result)) + { + auto filter = + std::make_unique(std::move(std::get(result))); + + if (filter->returnType() != Type::Bool) + { + // Only accept Bool results + return nullptr; + } + + return filter; + } + + return nullptr; +} + +FilterRecord::FilterRecord(QString name, QString filter) + : FilterRecord(std::move(name), std::move(filter), QUuid::createUuid()) { } -FilterRecord::FilterRecord(const QString &name, const QString &filter, - const QUuid &id) - : name_(name) - , filter_(filter) +FilterRecord::FilterRecord(QString name, QString filter, const QUuid &id) + : name_(std::move(name)) + , filterText_(std::move(filter)) , id_(id) - , parser_(std::make_unique(filter)) + , filter_(buildFilter(this->filterText_)) { } @@ -26,7 +45,7 @@ const QString &FilterRecord::getName() const const QString &FilterRecord::getFilter() const { - return this->filter_; + return this->filterText_; } const QUuid &FilterRecord::getId() const @@ -36,12 +55,13 @@ const QUuid &FilterRecord::getId() const bool FilterRecord::valid() const { - return this->parser_->valid(); + return this->filter_ != nullptr; } -bool FilterRecord::filter(const filterparser::ContextMap &context) const +bool FilterRecord::filter(const filters::ContextMap &context) const { - return this->parser_->execute(context); + assert(this->valid()); + return this->filter_->execute(context).toBool(); } bool FilterRecord::operator==(const FilterRecord &other) const diff --git a/src/controllers/filters/FilterRecord.hpp b/src/controllers/filters/FilterRecord.hpp index bb6eeeff327..c5f120040a8 100644 --- a/src/controllers/filters/FilterRecord.hpp +++ b/src/controllers/filters/FilterRecord.hpp @@ -1,6 +1,6 @@ #pragma once -#include "controllers/filters/parser/FilterParser.hpp" +#include "controllers/filters/lang/Filter.hpp" #include "util/RapidjsonHelpers.hpp" #include "util/RapidJsonSerializeQString.hpp" @@ -16,9 +16,9 @@ namespace chatterino { class FilterRecord { public: - FilterRecord(const QString &name, const QString &filter); + FilterRecord(QString name, QString filter); - FilterRecord(const QString &name, const QString &filter, const QUuid &id); + FilterRecord(QString name, QString filter, const QUuid &id); const QString &getName() const; @@ -28,16 +28,16 @@ class FilterRecord bool valid() const; - bool filter(const filterparser::ContextMap &context) const; + bool filter(const filters::ContextMap &context) const; bool operator==(const FilterRecord &other) const; private: - QString name_; - QString filter_; - QUuid id_; + const QString name_; + const QString filterText_; + const QUuid id_; - std::unique_ptr parser_; + const std::unique_ptr filter_; }; using FilterRecordPtr = std::shared_ptr; diff --git a/src/controllers/filters/FilterSet.cpp b/src/controllers/filters/FilterSet.cpp index 8bd20414cf5..a6dbc1059d6 100644 --- a/src/controllers/filters/FilterSet.cpp +++ b/src/controllers/filters/FilterSet.cpp @@ -38,8 +38,7 @@ bool FilterSet::filter(const MessagePtr &m, ChannelPtr channel) const if (this->filters_.size() == 0) return true; - filterparser::ContextMap context = - filterparser::buildContextMap(m, channel.get()); + filters::ContextMap context = filters::buildContextMap(m, channel.get()); for (const auto &f : this->filters_.values()) { if (!f->valid() || !f->filter(context)) diff --git a/src/controllers/filters/lang/Filter.cpp b/src/controllers/filters/lang/Filter.cpp new file mode 100644 index 00000000000..99b569c3c79 --- /dev/null +++ b/src/controllers/filters/lang/Filter.cpp @@ -0,0 +1,161 @@ +#include "controllers/filters/lang/Filter.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/filters/lang/FilterParser.hpp" +#include "messages/Message.hpp" +#include "providers/twitch/TwitchBadge.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchIrcServer.hpp" + +namespace chatterino::filters { + +ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel) +{ + auto watchingChannel = chatterino::getApp()->twitch->watchingChannel.get(); + + /* + * Looking to add a new identifier to filters? Here's what to do: + * 1. Update validIdentifiersMap in Tokenizer.hpp + * 2. Add the identifier to the list below + * 3. Add the type of the identifier to MESSAGE_TYPING_CONTEXT in Filter.hpp + * 4. Add the value for the identifier to the ContextMap returned by this function + * + * List of identifiers: + * + * author.badges + * author.color + * author.name + * author.no_color + * author.subbed + * author.sub_length + * + * channel.name + * channel.watching + * + * flags.highlighted + * flags.points_redeemed + * flags.sub_message + * flags.system_message + * flags.reward_message + * flags.first_message + * flags.elevated_message + * flags.cheer_message + * flags.whisper + * flags.reply + * flags.automod + * + * message.content + * message.length + * + */ + + using MessageFlag = chatterino::MessageFlag; + + QStringList badges; + badges.reserve(m->badges.size()); + for (const auto &e : m->badges) + { + badges << e.key_; + } + + bool watching = !watchingChannel->getName().isEmpty() && + watchingChannel->getName().compare( + m->channelName, Qt::CaseInsensitive) == 0; + + bool subscribed = false; + int subLength = 0; + for (const auto &subBadge : {"subscriber", "founder"}) + { + if (!badges.contains(subBadge)) + { + continue; + } + subscribed = true; + if (m->badgeInfos.find(subBadge) != m->badgeInfos.end()) + { + subLength = m->badgeInfos.at(subBadge).toInt(); + } + } + ContextMap vars = { + {"author.badges", std::move(badges)}, + {"author.color", m->usernameColor}, + {"author.name", m->displayName}, + {"author.no_color", !m->usernameColor.isValid()}, + {"author.subbed", subscribed}, + {"author.sub_length", subLength}, + + {"channel.name", m->channelName}, + {"channel.watching", watching}, + + {"flags.highlighted", m->flags.has(MessageFlag::Highlighted)}, + {"flags.points_redeemed", m->flags.has(MessageFlag::RedeemedHighlight)}, + {"flags.sub_message", m->flags.has(MessageFlag::Subscription)}, + {"flags.system_message", m->flags.has(MessageFlag::System)}, + {"flags.reward_message", + m->flags.has(MessageFlag::RedeemedChannelPointReward)}, + {"flags.first_message", m->flags.has(MessageFlag::FirstMessage)}, + {"flags.elevated_message", m->flags.has(MessageFlag::ElevatedMessage)}, + {"flags.cheer_message", m->flags.has(MessageFlag::CheerMessage)}, + {"flags.whisper", m->flags.has(MessageFlag::Whisper)}, + {"flags.reply", m->flags.has(MessageFlag::ReplyMessage)}, + {"flags.automod", m->flags.has(MessageFlag::AutoMod)}, + + {"message.content", m->messageText}, + {"message.length", m->messageText.length()}, + }; + { + auto *tc = dynamic_cast(channel); + if (channel && !channel->isEmpty() && tc) + { + vars["channel.live"] = tc->isLive(); + } + else + { + vars["channel.live"] = false; + } + } + return vars; +} + +FilterResult Filter::fromString(const QString &str) +{ + FilterParser parser(str); + + if (parser.valid()) + { + auto exp = parser.release(); + auto typ = parser.returnType(); + return Filter(std::move(exp), typ); + } + + return FilterError{parser.errors().join("\n")}; +} + +Filter::Filter(ExpressionPtr expression, Type returnType) + : expression_(std::move(expression)) + , returnType_(returnType) +{ +} + +Type Filter::returnType() const +{ + return this->returnType_; +} + +QVariant Filter::execute(const ContextMap &context) const +{ + return this->expression_->execute(context); +} + +QString Filter::filterString() const +{ + return this->expression_->filterString(); +} + +QString Filter::debugString(const TypingContext &context) const +{ + return this->expression_->debug(context); +} + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/Filter.hpp b/src/controllers/filters/lang/Filter.hpp new file mode 100644 index 00000000000..6b492a6a116 --- /dev/null +++ b/src/controllers/filters/lang/Filter.hpp @@ -0,0 +1,77 @@ +#pragma once + +#include "controllers/filters/lang/expressions/Expression.hpp" +#include "controllers/filters/lang/Types.hpp" + +#include + +#include +#include + +namespace chatterino { + +class Channel; +struct Message; +using MessagePtr = std::shared_ptr; + +} // namespace chatterino + +namespace chatterino::filters { + +// MESSAGE_TYPING_CONTEXT maps filter variables to their expected type at evaluation. +// For example, flags.highlighted is a boolean variable, so it is marked as Type::Bool +// below. These variable types will be used to check whether a filter "makes sense", +// i.e. if all the variables and operators being used have compatible types. +static const QMap MESSAGE_TYPING_CONTEXT = { + {"author.badges", Type::StringList}, + {"author.color", Type::Color}, + {"author.name", Type::String}, + {"author.no_color", Type::Bool}, + {"author.subbed", Type::Bool}, + {"author.sub_length", Type::Int}, + {"channel.name", Type::String}, + {"channel.watching", Type::Bool}, + {"channel.live", Type::Bool}, + {"flags.highlighted", Type::Bool}, + {"flags.points_redeemed", Type::Bool}, + {"flags.sub_message", Type::Bool}, + {"flags.system_message", Type::Bool}, + {"flags.reward_message", Type::Bool}, + {"flags.first_message", Type::Bool}, + {"flags.elevated_message", Type::Bool}, + {"flags.cheer_message", Type::Bool}, + {"flags.whisper", Type::Bool}, + {"flags.reply", Type::Bool}, + {"flags.automod", Type::Bool}, + {"message.content", Type::String}, + {"message.length", Type::Int}, +}; + +ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel); + +class Filter; +struct FilterError { + QString message; +}; + +using FilterResult = std::variant; + +class Filter +{ +public: + static FilterResult fromString(const QString &str); + + Type returnType() const; + QVariant execute(const ContextMap &context) const; + + QString filterString() const; + QString debugString(const TypingContext &context) const; + +private: + Filter(ExpressionPtr expression, Type returnType); + + ExpressionPtr expression_; + Type returnType_; +}; + +} // namespace chatterino::filters diff --git a/src/controllers/filters/parser/FilterParser.cpp b/src/controllers/filters/lang/FilterParser.cpp similarity index 67% rename from src/controllers/filters/parser/FilterParser.cpp rename to src/controllers/filters/lang/FilterParser.cpp index 00c5bd6b76c..00e3bf77618 100644 --- a/src/controllers/filters/parser/FilterParser.cpp +++ b/src/controllers/filters/lang/FilterParser.cpp @@ -1,132 +1,61 @@ -#include "FilterParser.hpp" +#include "controllers/filters/lang/FilterParser.hpp" -#include "Application.hpp" -#include "common/Channel.hpp" -#include "controllers/filters/parser/Types.hpp" -#include "messages/Message.hpp" -#include "providers/twitch/TwitchBadge.hpp" -#include "providers/twitch/TwitchChannel.hpp" -#include "providers/twitch/TwitchIrcServer.hpp" +#include "controllers/filters/lang/expressions/BinaryOperation.hpp" +#include "controllers/filters/lang/expressions/Expression.hpp" +#include "controllers/filters/lang/expressions/ListExpression.hpp" +#include "controllers/filters/lang/expressions/RegexExpression.hpp" +#include "controllers/filters/lang/expressions/UnaryOperation.hpp" +#include "controllers/filters/lang/expressions/ValueExpression.hpp" +#include "controllers/filters/lang/Filter.hpp" +#include "controllers/filters/lang/Types.hpp" -namespace filterparser { +namespace chatterino::filters { -ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel) +QString explainIllType(const IllTyped &ill) { - auto watchingChannel = chatterino::getApp()->twitch->watchingChannel.get(); - - /* Known Identifiers - * - * author.badges - * author.color - * author.name - * author.no_color - * author.subbed - * author.sub_length - * - * channel.name - * channel.watching - * - * flags.highlighted - * flags.points_redeemed - * flags.sub_message - * flags.system_message - * flags.reward_message - * flags.first_message - * flags.elevated_message - * flags.cheer_message - * flags.whisper - * flags.reply - * flags.automod - * - * message.content - * message.length - * - */ - - using MessageFlag = chatterino::MessageFlag; + return QString("%1\n\nProblem occurred here:\n%2") + .arg(ill.message) + .arg(ill.expr->filterString()); +} - QStringList badges; - badges.reserve(m->badges.size()); - for (const auto &e : m->badges) +FilterParser::FilterParser(const QString &text) + : text_(text) + , tokenizer_(Tokenizer(text)) + , builtExpression_(this->parseExpression(true)) +{ + if (!this->valid_) { - badges << e.key_; + return; } - bool watching = !watchingChannel->getName().isEmpty() && - watchingChannel->getName().compare( - m->channelName, Qt::CaseInsensitive) == 0; - - bool subscribed = false; - int subLength = 0; - for (const auto &subBadge : {"subscriber", "founder"}) + // safety: returnType must not live longer than the parsed expression. See + // comment on IllTyped::expr. + auto returnType = + this->builtExpression_->synthesizeType(MESSAGE_TYPING_CONTEXT); + if (isIllTyped(returnType)) { - if (!badges.contains(subBadge)) - { - continue; - } - subscribed = true; - if (m->badgeInfos.find(subBadge) != m->badgeInfos.end()) - { - subLength = m->badgeInfos.at(subBadge).toInt(); - } + this->errorLog(explainIllType(std::get(returnType))); + return; } - ContextMap vars = { - {"author.badges", std::move(badges)}, - {"author.color", m->usernameColor}, - {"author.name", m->displayName}, - {"author.no_color", !m->usernameColor.isValid()}, - {"author.subbed", subscribed}, - {"author.sub_length", subLength}, - {"channel.name", m->channelName}, - {"channel.watching", watching}, - - {"flags.highlighted", m->flags.has(MessageFlag::Highlighted)}, - {"flags.points_redeemed", m->flags.has(MessageFlag::RedeemedHighlight)}, - {"flags.sub_message", m->flags.has(MessageFlag::Subscription)}, - {"flags.system_message", m->flags.has(MessageFlag::System)}, - {"flags.reward_message", - m->flags.has(MessageFlag::RedeemedChannelPointReward)}, - {"flags.first_message", m->flags.has(MessageFlag::FirstMessage)}, - {"flags.elevated_message", m->flags.has(MessageFlag::ElevatedMessage)}, - {"flags.cheer_message", m->flags.has(MessageFlag::CheerMessage)}, - {"flags.whisper", m->flags.has(MessageFlag::Whisper)}, - {"flags.reply", m->flags.has(MessageFlag::ReplyMessage)}, - {"flags.automod", m->flags.has(MessageFlag::AutoMod)}, - - {"message.content", m->messageText}, - {"message.length", m->messageText.length()}, - }; - { - using namespace chatterino; - auto *tc = dynamic_cast(channel); - if (channel && !channel->isEmpty() && tc) - { - vars["channel.live"] = tc->isLive(); - } - else - { - vars["channel.live"] = false; - } - } - return vars; + this->returnType_ = std::get(returnType).type; } -FilterParser::FilterParser(const QString &text) - : text_(text) - , tokenizer_(Tokenizer(text)) - , builtExpression_(this->parseExpression(true)) +bool FilterParser::valid() const { + return this->valid_; } -bool FilterParser::execute(const ContextMap &context) const +Type FilterParser::returnType() const { - return this->builtExpression_->execute(context).toBool(); + return this->returnType_; } -bool FilterParser::valid() const +ExpressionPtr FilterParser::release() { - return this->valid_; + ExpressionPtr ret; + this->builtExpression_.swap(ret); + return ret; } ExpressionPtr FilterParser::parseExpression(bool top) @@ -379,12 +308,7 @@ const QStringList &FilterParser::errors() const const QString FilterParser::debugString() const { - return this->builtExpression_->debug(); -} - -const QString FilterParser::filterString() const -{ - return this->builtExpression_->filterString(); + return this->builtExpression_->debug(MESSAGE_TYPING_CONTEXT); } -} // namespace filterparser +} // namespace chatterino::filters diff --git a/src/controllers/filters/parser/FilterParser.hpp b/src/controllers/filters/lang/FilterParser.hpp similarity index 62% rename from src/controllers/filters/parser/FilterParser.hpp rename to src/controllers/filters/lang/FilterParser.hpp index 70037993e70..3f0344627fd 100644 --- a/src/controllers/filters/parser/FilterParser.hpp +++ b/src/controllers/filters/lang/FilterParser.hpp @@ -1,28 +1,22 @@ #pragma once -#include "controllers/filters/parser/Tokenizer.hpp" -#include "controllers/filters/parser/Types.hpp" +#include "controllers/filters/lang/expressions/Expression.hpp" +#include "controllers/filters/lang/Tokenizer.hpp" +#include "controllers/filters/lang/Types.hpp" -namespace chatterino { - -class Channel; - -} // namespace chatterino - -namespace filterparser { - -ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel); +namespace chatterino::filters { class FilterParser { public: FilterParser(const QString &text); - bool execute(const ContextMap &context) const; + bool valid() const; + Type returnType() const; + ExpressionPtr release(); const QStringList &errors() const; const QString debugString() const; - const QString filterString() const; private: ExpressionPtr parseExpression(bool top = false); @@ -41,5 +35,7 @@ class FilterParser QString text_; Tokenizer tokenizer_; ExpressionPtr builtExpression_; + Type returnType_ = Type::Bool; }; -} // namespace filterparser + +} // namespace chatterino::filters diff --git a/src/controllers/filters/parser/Tokenizer.cpp b/src/controllers/filters/lang/Tokenizer.cpp similarity index 71% rename from src/controllers/filters/parser/Tokenizer.cpp rename to src/controllers/filters/lang/Tokenizer.cpp index e1bb28bcb2a..e8d02e03486 100644 --- a/src/controllers/filters/parser/Tokenizer.cpp +++ b/src/controllers/filters/lang/Tokenizer.cpp @@ -1,8 +1,79 @@ -#include "controllers/filters/parser/Tokenizer.hpp" +#include "controllers/filters/lang/Tokenizer.hpp" #include "common/QLogging.hpp" -namespace filterparser { +namespace chatterino::filters { + +QString tokenTypeToInfoString(TokenType type) +{ + switch (type) + { + case AND: + return "And"; + case OR: + return "Or"; + case LP: + return ""; + case RP: + return ""; + case LIST_START: + return ""; + case LIST_END: + return ""; + case COMMA: + return ""; + case PLUS: + return "Plus"; + case MINUS: + return "Minus"; + case MULTIPLY: + return "Multiply"; + case DIVIDE: + return "Divide"; + case MOD: + return "Mod"; + case EQ: + return "Eq"; + case NEQ: + return "NotEq"; + case LT: + return "LessThan"; + case GT: + return "GreaterThan"; + case LTE: + return "LessThanEq"; + case GTE: + return "GreaterThanEq"; + case CONTAINS: + return "Contains"; + case STARTS_WITH: + return "StartsWith"; + case ENDS_WITH: + return "EndsWith"; + case MATCH: + return "Match"; + case NOT: + return "Not"; + case STRING: + return ""; + case INT: + return ""; + case IDENTIFIER: + return ""; + case CONTROL_START: + case CONTROL_END: + case BINARY_START: + case BINARY_END: + case UNARY_START: + case UNARY_END: + case MATH_START: + case MATH_END: + case OTHER_START: + case NONE: + default: + return ""; + } +} Tokenizer::Tokenizer(const QString &text) { @@ -190,4 +261,4 @@ bool Tokenizer::typeIsMathOp(TokenType token) return token > TokenType::MATH_START && token < TokenType::MATH_END; } -} // namespace filterparser +} // namespace chatterino::filters diff --git a/src/controllers/filters/parser/Tokenizer.hpp b/src/controllers/filters/lang/Tokenizer.hpp similarity index 70% rename from src/controllers/filters/parser/Tokenizer.hpp rename to src/controllers/filters/lang/Tokenizer.hpp index 59f4b9cefff..63c310b0183 100644 --- a/src/controllers/filters/parser/Tokenizer.hpp +++ b/src/controllers/filters/lang/Tokenizer.hpp @@ -1,12 +1,12 @@ #pragma once -#include "controllers/filters/parser/Types.hpp" +#include "controllers/filters/lang/Types.hpp" #include #include #include -namespace filterparser { +namespace chatterino::filters { static const QMap validIdentifiersMap = { {"author.badges", "author badges"}, @@ -17,7 +17,7 @@ static const QMap validIdentifiersMap = { {"author.sub_length", "author sub length"}, {"channel.name", "channel name"}, {"channel.watching", "/watching channel?"}, - {"channel.live", "Channel live?"}, + {"channel.live", "channel live?"}, {"flags.highlighted", "highlighted?"}, {"flags.points_redeemed", "redeemed points?"}, {"flags.sub_message", "sub/resub message?"}, @@ -42,6 +42,58 @@ static const QRegularExpression tokenRegex( ); // clang-format on +enum TokenType { + // control + CONTROL_START = 0, + AND = 1, + OR = 2, + LP = 3, + RP = 4, + LIST_START = 5, + LIST_END = 6, + COMMA = 7, + CONTROL_END = 19, + + // binary operator + BINARY_START = 20, + EQ = 21, + NEQ = 22, + LT = 23, + GT = 24, + LTE = 25, + GTE = 26, + CONTAINS = 27, + STARTS_WITH = 28, + ENDS_WITH = 29, + MATCH = 30, + BINARY_END = 49, + + // unary operator + UNARY_START = 50, + NOT = 51, + UNARY_END = 99, + + // math operators + MATH_START = 100, + PLUS = 101, + MINUS = 102, + MULTIPLY = 103, + DIVIDE = 104, + MOD = 105, + MATH_END = 149, + + // other types + OTHER_START = 150, + STRING = 151, + INT = 152, + IDENTIFIER = 153, + REGULAR_EXPRESSION = 154, + + NONE = 200 +}; + +QString tokenTypeToInfoString(TokenType type); + class Tokenizer { public: @@ -74,4 +126,4 @@ class Tokenizer TokenType tokenize(const QString &text); }; -} // namespace filterparser +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/Types.cpp b/src/controllers/filters/lang/Types.cpp new file mode 100644 index 00000000000..66a715960ad --- /dev/null +++ b/src/controllers/filters/lang/Types.cpp @@ -0,0 +1,101 @@ +#include "controllers/filters/lang/Types.hpp" + +#include "controllers/filters/lang/expressions/Expression.hpp" +#include "controllers/filters/lang/Tokenizer.hpp" + +namespace chatterino::filters { + +bool isList(const PossibleType &possibleType) +{ + using T = Type; + if (isIllTyped(possibleType)) + { + return false; + } + + auto typ = std::get(possibleType); + return typ == T::List || typ == T::StringList || + typ == T::MatchingSpecifier; +} + +QString typeToString(Type type) +{ + using T = Type; + switch (type) + { + case T::String: + return "String"; + case T::Int: + return "Int"; + case T::Bool: + return "Bool"; + case T::Color: + return "Color"; + case T::RegularExpression: + return "RegularExpression"; + case T::List: + return "List"; + case T::StringList: + return "StringList"; + case T::MatchingSpecifier: + return "MatchingSpecifier"; + case T::Map: + return "Map"; + default: + return "Unknown"; + } +} + +QString TypeClass::string() const +{ + return typeToString(this->type); +} + +bool TypeClass::operator==(Type t) const +{ + return this->type == t; +} + +bool TypeClass::operator==(const TypeClass &t) const +{ + return this->type == t.type; +} + +bool TypeClass::operator==(const IllTyped &t) const +{ + return false; +} + +bool TypeClass::operator!=(Type t) const +{ + return !this->operator==(t); +} + +bool TypeClass::operator!=(const TypeClass &t) const +{ + return !this->operator==(t); +} + +bool TypeClass::operator!=(const IllTyped &t) const +{ + return true; +} + +QString IllTyped::string() const +{ + return "IllTyped"; +} + +QString possibleTypeToString(const PossibleType &possible) +{ + if (isWellTyped(possible)) + { + return std::get(possible).string(); + } + else + { + return std::get(possible).string(); + } +} + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/Types.hpp b/src/controllers/filters/lang/Types.hpp new file mode 100644 index 00000000000..8debaa6975c --- /dev/null +++ b/src/controllers/filters/lang/Types.hpp @@ -0,0 +1,95 @@ +#pragma once + +#include +#include +#include + +#include +#include +#include + +namespace chatterino::filters { + +class Expression; + +enum class Type { + String, + Int, + Bool, + Color, + RegularExpression, + List, + StringList, // List of only strings + MatchingSpecifier, // 2-element list in {RegularExpression, Int} form + Map +}; + +using ContextMap = QMap; +using TypingContext = QMap; + +QString typeToString(Type type); + +struct IllTyped; + +struct TypeClass { + Type type; + + QString string() const; + + bool operator==(Type t) const; + bool operator==(const TypeClass &t) const; + bool operator==(const IllTyped &t) const; + bool operator!=(Type t) const; + bool operator!=(const TypeClass &t) const; + bool operator!=(const IllTyped &t) const; +}; + +struct IllTyped { + // Important nuance to expr: + // During type synthesis, should an error occur and an IllTyped PossibleType be + // returned, expr is a pointer to an Expression that exists in the Expression + // tree that was parsed. Therefore, you cannot hold on to this pointer longer + // than the Expression tree exists. Be careful! + const Expression *expr; + QString message; + + QString string() const; +}; + +using PossibleType = std::variant; + +inline bool isWellTyped(const PossibleType &possible) +{ + return std::holds_alternative(possible); +} + +inline bool isIllTyped(const PossibleType &possible) +{ + return std::holds_alternative(possible); +} + +QString possibleTypeToString(const PossibleType &possible); + +bool isList(const PossibleType &possibleType); + +inline bool variantIs(const QVariant &a, QMetaType::Type type) +{ + return static_cast(a.type()) == type; +} + +inline bool variantIsNot(const QVariant &a, QMetaType::Type type) +{ + return static_cast(a.type()) != type; +} + +inline bool convertVariantTypes(QVariant &a, QVariant &b, int type) +{ + return a.convert(type) && b.convert(type); +} + +inline bool variantTypesMatch(QVariant &a, QVariant &b, QMetaType::Type type) +{ + return variantIs(a, type) && variantIs(b, type); +} + +} // namespace chatterino::filters diff --git a/src/controllers/filters/parser/Types.cpp b/src/controllers/filters/lang/expressions/BinaryOperation.cpp similarity index 53% rename from src/controllers/filters/parser/Types.cpp rename to src/controllers/filters/lang/expressions/BinaryOperation.cpp index 159e89ce9af..d3dbac4fe9c 100644 --- a/src/controllers/filters/parser/Types.cpp +++ b/src/controllers/filters/lang/expressions/BinaryOperation.cpp @@ -1,214 +1,8 @@ -#include "controllers/filters/parser/Types.hpp" +#include "controllers/filters/lang/expressions/BinaryOperation.hpp" -namespace filterparser { +#include -bool convertVariantTypes(QVariant &a, QVariant &b, int type) -{ - return a.convert(type) && b.convert(type); -} - -bool variantTypesMatch(QVariant &a, QVariant &b, QVariant::Type type) -{ - return a.type() == type && b.type() == type; -} - -QString tokenTypeToInfoString(TokenType type) -{ - switch (type) - { - case CONTROL_START: - case CONTROL_END: - case BINARY_START: - case BINARY_END: - case UNARY_START: - case UNARY_END: - case MATH_START: - case MATH_END: - case OTHER_START: - case NONE: - return ""; - case AND: - return ""; - case OR: - return ""; - case LP: - return ""; - case RP: - return ""; - case LIST_START: - return ""; - case LIST_END: - return ""; - case COMMA: - return ""; - case PLUS: - return ""; - case MINUS: - return ""; - case MULTIPLY: - return ""; - case DIVIDE: - return ""; - case MOD: - return ""; - case EQ: - return ""; - case NEQ: - return ""; - case LT: - return ""; - case GT: - return ""; - case LTE: - return ""; - case GTE: - return ""; - case CONTAINS: - return ""; - case STARTS_WITH: - return ""; - case ENDS_WITH: - return ""; - case MATCH: - return ""; - case NOT: - return ""; - case STRING: - return ""; - case INT: - return ""; - case IDENTIFIER: - return ""; - default: - return ""; - } -} - -// ValueExpression - -ValueExpression::ValueExpression(QVariant value, TokenType type) - : value_(value) - , type_(type){}; - -QVariant ValueExpression::execute(const ContextMap &context) const -{ - if (this->type_ == TokenType::IDENTIFIER) - { - return context.value(this->value_.toString()); - } - return this->value_; -} - -TokenType ValueExpression::type() -{ - return this->type_; -} - -QString ValueExpression::debug() const -{ - return this->value_.toString(); -} - -QString ValueExpression::filterString() const -{ - switch (this->type_) - { - case INT: - return QString::number(this->value_.toInt()); - case STRING: - return QString("\"%1\"").arg( - this->value_.toString().replace("\"", "\\\"")); - case IDENTIFIER: - return this->value_.toString(); - default: - return ""; - } -} - -// RegexExpression - -RegexExpression::RegexExpression(QString regex, bool caseInsensitive) - : regexString_(regex) - , caseInsensitive_(caseInsensitive) - , regex_(QRegularExpression( - regex, caseInsensitive ? QRegularExpression::CaseInsensitiveOption - : QRegularExpression::NoPatternOption)){}; - -QVariant RegexExpression::execute(const ContextMap &) const -{ - return this->regex_; -} - -QString RegexExpression::debug() const -{ - return this->regexString_; -} - -QString RegexExpression::filterString() const -{ - auto s = this->regexString_; - return QString("%1\"%2\"") - .arg(this->caseInsensitive_ ? "ri" : "r") - .arg(s.replace("\"", "\\\"")); -} - -// ListExpression - -ListExpression::ListExpression(ExpressionList list) - : list_(std::move(list)){}; - -QVariant ListExpression::execute(const ContextMap &context) const -{ - QList results; - bool allStrings = true; - for (const auto &exp : this->list_) - { - auto res = exp->execute(context); - if (allStrings && res.type() != QVariant::Type::String) - { - allStrings = false; - } - results.append(res); - } - - // if everything is a string return a QStringList for case-insensitive comparison - if (allStrings) - { - QStringList strings; - strings.reserve(results.size()); - for (const auto &val : results) - { - strings << val.toString(); - } - return strings; - } - else - { - return results; - } -} - -QString ListExpression::debug() const -{ - QStringList debugs; - for (const auto &exp : this->list_) - { - debugs.append(exp->debug()); - } - return QString("{%1}").arg(debugs.join(", ")); -} - -QString ListExpression::filterString() const -{ - QStringList strings; - for (const auto &exp : this->list_) - { - strings.append(QString("(%1)").arg(exp->filterString())); - } - return QString("{%1}").arg(strings.join(", ")); -} - -// BinaryOperation +namespace chatterino::filters { BinaryOperation::BinaryOperation(TokenType op, ExpressionPtr left, ExpressionPtr right) @@ -225,7 +19,8 @@ QVariant BinaryOperation::execute(const ContextMap &context) const switch (this->op_) { case PLUS: - if (left.type() == QVariant::Type::String && + if (static_cast(left.type()) == + QMetaType::QString && right.canConvert(QMetaType::QString)) { return left.toString().append(right.toString()); @@ -260,14 +55,14 @@ QVariant BinaryOperation::execute(const ContextMap &context) const return left.toBool() && right.toBool(); return false; case EQ: - if (variantTypesMatch(left, right, QVariant::Type::String)) + if (variantTypesMatch(left, right, QMetaType::QString)) { return left.toString().compare(right.toString(), Qt::CaseInsensitive) == 0; } return left == right; case NEQ: - if (variantTypesMatch(left, right, QVariant::Type::String)) + if (variantTypesMatch(left, right, QMetaType::QString)) { return left.toString().compare(right.toString(), Qt::CaseInsensitive) != 0; @@ -290,20 +85,20 @@ QVariant BinaryOperation::execute(const ContextMap &context) const return left.toInt() >= right.toInt(); return false; case CONTAINS: - if (left.type() == QVariant::Type::StringList && + if (variantIs(left, QMetaType::QStringList) && right.canConvert(QMetaType::QString)) { return left.toStringList().contains(right.toString(), Qt::CaseInsensitive); } - if (left.type() == QVariant::Type::Map && + if (variantIs(left.type(), QMetaType::QVariantMap) && right.canConvert(QMetaType::QString)) { return left.toMap().contains(right.toString()); } - if (left.type() == QVariant::Type::List) + if (variantIs(left.type(), QMetaType::QVariantList)) { return left.toList().contains(right); } @@ -317,16 +112,16 @@ QVariant BinaryOperation::execute(const ContextMap &context) const return false; case STARTS_WITH: - if (left.type() == QVariant::Type::StringList && + if (variantIs(left.type(), QMetaType::QStringList) && right.canConvert(QMetaType::QString)) { auto list = left.toStringList(); return !list.isEmpty() && list.first().compare(right.toString(), - Qt::CaseInsensitive); + Qt::CaseInsensitive) == 0; } - if (left.type() == QVariant::Type::List) + if (variantIs(left.type(), QMetaType::QVariantList)) { return left.toList().startsWith(right); } @@ -341,16 +136,16 @@ QVariant BinaryOperation::execute(const ContextMap &context) const return false; case ENDS_WITH: - if (left.type() == QVariant::Type::StringList && + if (variantIs(left.type(), QMetaType::QStringList) && right.canConvert(QMetaType::QString)) { auto list = left.toStringList(); return !list.isEmpty() && list.last().compare(right.toString(), - Qt::CaseInsensitive); + Qt::CaseInsensitive) == 0; } - if (left.type() == QVariant::Type::List) + if (variantIs(left.type(), QMetaType::QVariantList)) { return left.toList().endsWith(right); } @@ -371,14 +166,14 @@ QVariant BinaryOperation::execute(const ContextMap &context) const auto matching = left.toString(); - switch (right.type()) + switch (static_cast(right.type())) { - case QVariant::Type::RegularExpression: { + case QMetaType::QRegularExpression: { return right.toRegularExpression() .match(matching) .hasMatch(); } - case QVariant::Type::List: { + case QMetaType::QVariantList: { auto list = right.toList(); // list must be two items @@ -386,19 +181,19 @@ QVariant BinaryOperation::execute(const ContextMap &context) const return false; // list must be a regular expression and an int - if (list.at(0).type() != - QVariant::Type::RegularExpression || - list.at(1).type() != QVariant::Type::Int) + if (variantIsNot(list.at(0), + QMetaType::QRegularExpression) || + variantIsNot(list.at(1), QMetaType::Int)) return false; auto match = list.at(0).toRegularExpression().match(matching); - // if matched, return nth capture group. Otherwise, return false + // if matched, return nth capture group. Otherwise, return "" if (match.hasMatch()) return match.captured(list.at(1).toInt()); else - return false; + return ""; } default: return false; @@ -409,11 +204,105 @@ QVariant BinaryOperation::execute(const ContextMap &context) const } } -QString BinaryOperation::debug() const +PossibleType BinaryOperation::synthesizeType(const TypingContext &context) const { - return QString("(%1 %2 %3)") - .arg(this->left_->debug(), tokenTypeToInfoString(this->op_), - this->right_->debug()); + auto leftSyn = this->left_->synthesizeType(context); + auto rightSyn = this->right_->synthesizeType(context); + + // Return if either operand is ill-typed + if (isIllTyped(leftSyn)) + { + return leftSyn; + } + else if (isIllTyped(rightSyn)) + { + return rightSyn; + } + + auto left = std::get(leftSyn); + auto right = std::get(rightSyn); + + switch (this->op_) + { + case PLUS: + if (left == Type::String) + return TypeClass{Type::String}; // String concatenation + else if (left == Type::Int && right == Type::Int) + return TypeClass{Type::Int}; + + return IllTyped{this, "Can only add Ints or concatenate a String"}; + case MINUS: + case MULTIPLY: + case DIVIDE: + case MOD: + if (left == Type::Int && right == Type::Int) + return TypeClass{Type::Int}; + + return IllTyped{this, "Can only perform operation with Ints"}; + case OR: + case AND: + if (left == Type::Bool && right == Type::Bool) + return TypeClass{Type::Bool}; + + return IllTyped{this, + "Can only perform logical operations with Bools"}; + case EQ: + case NEQ: + // equals/not equals always produces a valid output + return TypeClass{Type::Bool}; + case LT: + case GT: + case LTE: + case GTE: + if (left == Type::Int && right == Type::Int) + return TypeClass{Type::Bool}; + + return IllTyped{this, "Can only perform comparisons with Ints"}; + case STARTS_WITH: + case ENDS_WITH: + if (isList(left)) + return TypeClass{Type::Bool}; + if (left == Type::String && right == Type::String) + return TypeClass{Type::Bool}; + + return IllTyped{ + this, + "Can only perform starts/ends with a List or two Strings"}; + case CONTAINS: + if (isList(left) || left == Type::Map) + return TypeClass{Type::Bool}; + if (left == Type::String && right == Type::String) + return TypeClass{Type::Bool}; + + return IllTyped{ + this, + "Can only perform contains with a List, a Map, or two Strings"}; + case MATCH: { + if (left != Type::String) + return IllTyped{this, + "Left argument of match must be a String"}; + + if (right == Type::RegularExpression) + return TypeClass{Type::Bool}; + if (right == Type::MatchingSpecifier) // group capturing + return TypeClass{Type::String}; + + return IllTyped{this, "Can only match on a RegularExpression or a " + "MatchingSpecifier"}; + } + default: + return IllTyped{this, "Not implemented"}; + } +} + +QString BinaryOperation::debug(const TypingContext &context) const +{ + return QString("BinaryOp[%1](%2 : %3, %4 : %5)") + .arg(tokenTypeToInfoString(this->op_)) + .arg(this->left_->debug(context)) + .arg(possibleTypeToString(this->left_->synthesizeType(context))) + .arg(this->right_->debug(context)) + .arg(possibleTypeToString(this->right_->synthesizeType(context))); } QString BinaryOperation::filterString() const @@ -456,57 +345,14 @@ QString BinaryOperation::filterString() const case MATCH: return "match"; default: - return QString(); + return ""; } }(); - return QString("(%1) %2 (%3)") + return QString("(%1 %2 %3)") .arg(this->left_->filterString()) .arg(opText) .arg(this->right_->filterString()); } -// UnaryOperation - -UnaryOperation::UnaryOperation(TokenType op, ExpressionPtr right) - : op_(op) - , right_(std::move(right)) -{ -} - -QVariant UnaryOperation::execute(const ContextMap &context) const -{ - auto right = this->right_->execute(context); - switch (this->op_) - { - case NOT: - if (right.canConvert()) - return !right.toBool(); - return false; - default: - return false; - } -} - -QString UnaryOperation::debug() const -{ - return QString("(%1 %2)").arg(tokenTypeToInfoString(this->op_), - this->right_->debug()); -} - -QString UnaryOperation::filterString() const -{ - const auto opText = [&]() -> QString { - switch (this->op_) - { - case NOT: - return "!"; - default: - return QString(); - } - }(); - - return QString("%1(%2)").arg(opText).arg(this->right_->filterString()); -} - -} // namespace filterparser +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/BinaryOperation.hpp b/src/controllers/filters/lang/expressions/BinaryOperation.hpp new file mode 100644 index 00000000000..b42f81bf5ad --- /dev/null +++ b/src/controllers/filters/lang/expressions/BinaryOperation.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include "controllers/filters/lang/expressions/Expression.hpp" +#include "controllers/filters/lang/Types.hpp" + +namespace chatterino::filters { + +class BinaryOperation : public Expression +{ +public: + BinaryOperation(TokenType op, ExpressionPtr left, ExpressionPtr right); + + QVariant execute(const ContextMap &context) const override; + PossibleType synthesizeType(const TypingContext &context) const override; + QString debug(const TypingContext &context) const override; + QString filterString() const override; + +private: + TokenType op_; + ExpressionPtr left_; + ExpressionPtr right_; +}; + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/Expression.cpp b/src/controllers/filters/lang/expressions/Expression.cpp new file mode 100644 index 00000000000..74f987f2d0a --- /dev/null +++ b/src/controllers/filters/lang/expressions/Expression.cpp @@ -0,0 +1,25 @@ +#include "controllers/filters/lang/expressions/Expression.hpp" + +namespace chatterino::filters { + +QVariant Expression::execute(const ContextMap & /*context*/) const +{ + return false; +} + +PossibleType Expression::synthesizeType(const TypingContext & /*context*/) const +{ + return IllTyped{this, "Not implemented"}; +} + +QString Expression::debug(const TypingContext & /*context*/) const +{ + return ""; +} + +QString Expression::filterString() const +{ + return ""; +} + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/Expression.hpp b/src/controllers/filters/lang/expressions/Expression.hpp new file mode 100644 index 00000000000..d08fa6ef2ee --- /dev/null +++ b/src/controllers/filters/lang/expressions/Expression.hpp @@ -0,0 +1,28 @@ +#pragma once + +#include "controllers/filters/lang/Tokenizer.hpp" +#include "controllers/filters/lang/Types.hpp" + +#include +#include + +#include +#include + +namespace chatterino::filters { + +class Expression +{ +public: + virtual ~Expression() = default; + + virtual QVariant execute(const ContextMap &context) const; + virtual PossibleType synthesizeType(const TypingContext &context) const; + virtual QString debug(const TypingContext &context) const; + virtual QString filterString() const; +}; + +using ExpressionPtr = std::unique_ptr; +using ExpressionList = std::vector>; + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/ListExpression.cpp b/src/controllers/filters/lang/expressions/ListExpression.cpp new file mode 100644 index 00000000000..cd57a64ecfd --- /dev/null +++ b/src/controllers/filters/lang/expressions/ListExpression.cpp @@ -0,0 +1,94 @@ +#include "controllers/filters/lang/expressions/ListExpression.hpp" + +namespace chatterino::filters { + +ListExpression::ListExpression(ExpressionList &&list) + : list_(std::move(list)){}; + +QVariant ListExpression::execute(const ContextMap &context) const +{ + QList results; + bool allStrings = true; + for (const auto &exp : this->list_) + { + auto res = exp->execute(context); + if (allStrings && variantIsNot(res.type(), QMetaType::QString)) + { + allStrings = false; + } + results.append(res); + } + + // if everything is a string return a QStringList for case-insensitive comparison + if (allStrings) + { + QStringList strings; + strings.reserve(results.size()); + for (const auto &val : results) + { + strings << val.toString(); + } + return strings; + } + + return results; +} + +PossibleType ListExpression::synthesizeType(const TypingContext &context) const +{ + std::vector types; + types.reserve(this->list_.size()); + bool allStrings = true; + for (const auto &exp : this->list_) + { + auto typSyn = exp->synthesizeType(context); + if (isIllTyped(typSyn)) + { + return typSyn; // Ill-typed + } + + auto typ = std::get(typSyn); + + if (typ != Type::String) + { + allStrings = false; + } + + types.push_back(typ); + } + + if (types.size() == 2 && types[0] == Type::RegularExpression && + types[1] == Type::Int) + { + // Specific {RegularExpression, Int} form + return TypeClass{Type::MatchingSpecifier}; + } + + return allStrings ? TypeClass{Type::StringList} : TypeClass{Type::List}; +} + +QString ListExpression::debug(const TypingContext &context) const +{ + QStringList debugs; + for (const auto &exp : this->list_) + { + debugs.append( + QString("%1 : %2") + .arg(exp->debug(context)) + .arg(possibleTypeToString(exp->synthesizeType(context)))); + } + + return QString("List(%1)").arg(debugs.join(", ")); +} + +QString ListExpression::filterString() const +{ + QStringList strings; + for (const auto &exp : this->list_) + { + strings.append(exp->filterString()); + } + return QString("{%1}").arg(strings.join(", ")); +} + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/ListExpression.hpp b/src/controllers/filters/lang/expressions/ListExpression.hpp new file mode 100644 index 00000000000..6de6a46eecd --- /dev/null +++ b/src/controllers/filters/lang/expressions/ListExpression.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "controllers/filters/lang/expressions/Expression.hpp" +#include "controllers/filters/lang/Types.hpp" + +namespace chatterino::filters { + +class ListExpression : public Expression +{ +public: + ListExpression(ExpressionList &&list); + + QVariant execute(const ContextMap &context) const override; + PossibleType synthesizeType(const TypingContext &context) const override; + QString debug(const TypingContext &context) const override; + QString filterString() const override; + +private: + ExpressionList list_; +}; + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/RegexExpression.cpp b/src/controllers/filters/lang/expressions/RegexExpression.cpp new file mode 100644 index 00000000000..8481757348c --- /dev/null +++ b/src/controllers/filters/lang/expressions/RegexExpression.cpp @@ -0,0 +1,36 @@ +#include "controllers/filters/lang/expressions/RegexExpression.hpp" + +namespace chatterino::filters { + +RegexExpression::RegexExpression(const QString ®ex, bool caseInsensitive) + : regexString_(regex) + , caseInsensitive_(caseInsensitive) + , regex_(QRegularExpression( + regex, caseInsensitive ? QRegularExpression::CaseInsensitiveOption + : QRegularExpression::NoPatternOption)){}; + +QVariant RegexExpression::execute(const ContextMap & /*context*/) const +{ + return this->regex_; +} + +PossibleType RegexExpression::synthesizeType( + const TypingContext & /*context*/) const +{ + return TypeClass{Type::RegularExpression}; +} + +QString RegexExpression::debug(const TypingContext & /*context*/) const +{ + return QString("RegEx(%1)").arg(this->regexString_); +} + +QString RegexExpression::filterString() const +{ + auto s = this->regexString_; + return QString("%1\"%2\"") + .arg(this->caseInsensitive_ ? "ri" : "r") + .arg(s.replace("\"", "\\\"")); +} + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/RegexExpression.hpp b/src/controllers/filters/lang/expressions/RegexExpression.hpp new file mode 100644 index 00000000000..75fa5a08863 --- /dev/null +++ b/src/controllers/filters/lang/expressions/RegexExpression.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include "controllers/filters/lang/expressions/Expression.hpp" +#include "controllers/filters/lang/Types.hpp" + +#include + +namespace chatterino::filters { + +class RegexExpression : public Expression +{ +public: + RegexExpression(const QString ®ex, bool caseInsensitive); + + QVariant execute(const ContextMap &context) const override; + PossibleType synthesizeType(const TypingContext &context) const override; + QString debug(const TypingContext &context) const override; + QString filterString() const override; + +private: + QString regexString_; + bool caseInsensitive_; + QRegularExpression regex_; +}; + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/UnaryOperation.cpp b/src/controllers/filters/lang/expressions/UnaryOperation.cpp new file mode 100644 index 00000000000..11f487aa1f2 --- /dev/null +++ b/src/controllers/filters/lang/expressions/UnaryOperation.cpp @@ -0,0 +1,69 @@ +#include "controllers/filters/lang/expressions/UnaryOperation.hpp" + +namespace chatterino::filters { + +UnaryOperation::UnaryOperation(TokenType op, ExpressionPtr right) + : op_(op) + , right_(std::move(right)) +{ +} + +QVariant UnaryOperation::execute(const ContextMap &context) const +{ + auto right = this->right_->execute(context); + switch (this->op_) + { + case NOT: + return right.canConvert() && !right.toBool(); + default: + return false; + } +} + +PossibleType UnaryOperation::synthesizeType(const TypingContext &context) const +{ + auto rightSyn = this->right_->synthesizeType(context); + if (isIllTyped(rightSyn)) + { + return rightSyn; + } + + auto right = std::get(rightSyn); + + switch (this->op_) + { + case NOT: + if (right == Type::Bool) + { + return TypeClass{Type::Bool}; + } + return IllTyped{this, "Can only negate boolean values"}; + default: + return IllTyped{this, "Not implemented"}; + } +} + +QString UnaryOperation::debug(const TypingContext &context) const +{ + return QString("UnaryOp[%1](%2 : %3)") + .arg(tokenTypeToInfoString(this->op_)) + .arg(this->right_->debug(context)) + .arg(possibleTypeToString(this->right_->synthesizeType(context))); +} + +QString UnaryOperation::filterString() const +{ + const auto opText = [&]() -> QString { + switch (this->op_) + { + case NOT: + return "!"; + default: + return ""; + } + }(); + + return QString("(%1%2)").arg(opText).arg(this->right_->filterString()); +} + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/UnaryOperation.hpp b/src/controllers/filters/lang/expressions/UnaryOperation.hpp new file mode 100644 index 00000000000..155a78b7119 --- /dev/null +++ b/src/controllers/filters/lang/expressions/UnaryOperation.hpp @@ -0,0 +1,23 @@ +#pragma once + +#include "controllers/filters/lang/expressions/Expression.hpp" +#include "controllers/filters/lang/Types.hpp" + +namespace chatterino::filters { + +class UnaryOperation : public Expression +{ +public: + UnaryOperation(TokenType op, ExpressionPtr right); + + QVariant execute(const ContextMap &context) const override; + PossibleType synthesizeType(const TypingContext &context) const override; + QString debug(const TypingContext &context) const override; + QString filterString() const override; + +private: + TokenType op_; + ExpressionPtr right_; +}; + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/ValueExpression.cpp b/src/controllers/filters/lang/expressions/ValueExpression.cpp new file mode 100644 index 00000000000..23244613de3 --- /dev/null +++ b/src/controllers/filters/lang/expressions/ValueExpression.cpp @@ -0,0 +1,70 @@ +#include "controllers/filters/lang/expressions/ValueExpression.hpp" + +#include "controllers/filters/lang/Tokenizer.hpp" + +namespace chatterino::filters { + +ValueExpression::ValueExpression(QVariant value, TokenType type) + : value_(std::move(value)) + , type_(type) +{ +} + +QVariant ValueExpression::execute(const ContextMap &context) const +{ + if (this->type_ == TokenType::IDENTIFIER) + { + return context.value(this->value_.toString()); + } + return this->value_; +} + +PossibleType ValueExpression::synthesizeType(const TypingContext &context) const +{ + switch (this->type_) + { + case TokenType::IDENTIFIER: { + auto it = context.find(this->value_.toString()); + if (it != context.end()) + { + return TypeClass{it.value()}; + } + + return IllTyped{this, "Unbound identifier"}; + } + case TokenType::INT: + return TypeClass{Type::Int}; + case TokenType::STRING: + return TypeClass{Type::String}; + default: + return IllTyped{this, "Invalid value type"}; + } +} + +TokenType ValueExpression::type() +{ + return this->type_; +} + +QString ValueExpression::debug(const TypingContext & /*context*/) const +{ + return QString("Val(%1)").arg(this->value_.toString()); +} + +QString ValueExpression::filterString() const +{ + switch (this->type_) + { + case INT: + return QString::number(this->value_.toInt()); + case STRING: + return QString("\"%1\"").arg( + this->value_.toString().replace("\"", "\\\"")); + case IDENTIFIER: + return this->value_.toString(); + default: + return ""; + } +} + +} // namespace chatterino::filters diff --git a/src/controllers/filters/lang/expressions/ValueExpression.hpp b/src/controllers/filters/lang/expressions/ValueExpression.hpp new file mode 100644 index 00000000000..56cbf80c43b --- /dev/null +++ b/src/controllers/filters/lang/expressions/ValueExpression.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include "controllers/filters/lang/expressions/Expression.hpp" +#include "controllers/filters/lang/Types.hpp" + +namespace chatterino::filters { + +class ValueExpression : public Expression +{ +public: + ValueExpression(QVariant value, TokenType type); + TokenType type(); + + QVariant execute(const ContextMap &context) const override; + PossibleType synthesizeType(const TypingContext &context) const override; + QString debug(const TypingContext &context) const override; + QString filterString() const override; + +private: + QVariant value_; + TokenType type_; +}; + +} // namespace chatterino::filters diff --git a/src/controllers/filters/parser/Types.hpp b/src/controllers/filters/parser/Types.hpp deleted file mode 100644 index d6fcd7c9ecb..00000000000 --- a/src/controllers/filters/parser/Types.hpp +++ /dev/null @@ -1,168 +0,0 @@ -#pragma once - -#include - -#include - -namespace chatterino { - -struct Message; - -} - -namespace filterparser { - -using MessagePtr = std::shared_ptr; -using ContextMap = QMap; - -enum TokenType { - // control - CONTROL_START = 0, - AND = 1, - OR = 2, - LP = 3, - RP = 4, - LIST_START = 5, - LIST_END = 6, - COMMA = 7, - CONTROL_END = 19, - - // binary operator - BINARY_START = 20, - EQ = 21, - NEQ = 22, - LT = 23, - GT = 24, - LTE = 25, - GTE = 26, - CONTAINS = 27, - STARTS_WITH = 28, - ENDS_WITH = 29, - MATCH = 30, - BINARY_END = 49, - - // unary operator - UNARY_START = 50, - NOT = 51, - UNARY_END = 99, - - // math operators - MATH_START = 100, - PLUS = 101, - MINUS = 102, - MULTIPLY = 103, - DIVIDE = 104, - MOD = 105, - MATH_END = 149, - - // other types - OTHER_START = 150, - STRING = 151, - INT = 152, - IDENTIFIER = 153, - REGULAR_EXPRESSION = 154, - - NONE = 200 -}; - -bool convertVariantTypes(QVariant &a, QVariant &b, int type); -QString tokenTypeToInfoString(TokenType type); - -class Expression -{ -public: - virtual ~Expression() = default; - - virtual QVariant execute(const ContextMap &) const - { - return false; - } - - virtual QString debug() const - { - return "(false)"; - } - - virtual QString filterString() const - { - return ""; - } -}; - -using ExpressionPtr = std::unique_ptr; - -class ValueExpression : public Expression -{ -public: - ValueExpression(QVariant value, TokenType type); - TokenType type(); - - QVariant execute(const ContextMap &context) const override; - QString debug() const override; - QString filterString() const override; - -private: - QVariant value_; - TokenType type_; -}; - -class RegexExpression : public Expression -{ -public: - RegexExpression(QString regex, bool caseInsensitive); - - QVariant execute(const ContextMap &context) const override; - QString debug() const override; - QString filterString() const override; - -private: - QString regexString_; - bool caseInsensitive_; - QRegularExpression regex_; -}; - -using ExpressionList = std::vector>; - -class ListExpression : public Expression -{ -public: - ListExpression(ExpressionList list); - - QVariant execute(const ContextMap &context) const override; - QString debug() const override; - QString filterString() const override; - -private: - ExpressionList list_; -}; - -class BinaryOperation : public Expression -{ -public: - BinaryOperation(TokenType op, ExpressionPtr left, ExpressionPtr right); - - QVariant execute(const ContextMap &context) const override; - QString debug() const override; - QString filterString() const override; - -private: - TokenType op_; - ExpressionPtr left_; - ExpressionPtr right_; -}; - -class UnaryOperation : public Expression -{ -public: - UnaryOperation(TokenType op, ExpressionPtr right); - - QVariant execute(const ContextMap &context) const override; - QString debug() const override; - QString filterString() const override; - -private: - TokenType op_; - ExpressionPtr right_; -}; - -} // namespace filterparser diff --git a/src/widgets/dialogs/ChannelFilterEditorDialog.cpp b/src/widgets/dialogs/ChannelFilterEditorDialog.cpp index 682328f43b0..068cc64a826 100644 --- a/src/widgets/dialogs/ChannelFilterEditorDialog.cpp +++ b/src/widgets/dialogs/ChannelFilterEditorDialog.cpp @@ -1,6 +1,6 @@ -#include "ChannelFilterEditorDialog.hpp" +#include "widgets/dialogs/ChannelFilterEditorDialog.hpp" -#include "controllers/filters/parser/FilterParser.hpp" +#include "controllers/filters/lang/Tokenizer.hpp" #include #include @@ -99,7 +99,8 @@ ChannelFilterEditorDialog::ValueSpecifier::ValueSpecifier() this->typeCombo_->insertItems( 0, {"Constant Text", "Constant Number", "Variable"}); - this->varCombo_->insertItems(0, filterparser::validIdentifiersMap.values()); + + this->varCombo_->insertItems(0, filters::validIdentifiersMap.values()); this->layout_->addWidget(this->typeCombo_); this->layout_->addWidget(this->varCombo_, 1); @@ -141,7 +142,7 @@ void ChannelFilterEditorDialog::ValueSpecifier::setValue(const QString &value) if (this->typeCombo_->currentIndex() == 2) { this->varCombo_->setCurrentText( - filterparser::validIdentifiersMap.value(value)); + filters::validIdentifiersMap.value(value)); } else { @@ -164,7 +165,7 @@ QString ChannelFilterEditorDialog::ValueSpecifier::expressionText() case 1: // number return this->valueInput_->text(); case 2: // variable - return filterparser::validIdentifiersMap.key( + return filters::validIdentifiersMap.key( this->varCombo_->currentText()); default: return ""; @@ -221,7 +222,7 @@ QString ChannelFilterEditorDialog::BinaryOperationSpecifier::expressionText() return this->left_->expressionText(); } - return QString("(%1) %2 (%3)") + return QString("(%1 %2 %3)") .arg(this->left_->expressionText()) .arg(opText) .arg(this->right_->expressionText()); diff --git a/src/widgets/settingspages/FiltersPage.cpp b/src/widgets/settingspages/FiltersPage.cpp index 1268ff132e8..e28c7c5a754 100644 --- a/src/widgets/settingspages/FiltersPage.cpp +++ b/src/widgets/settingspages/FiltersPage.cpp @@ -91,23 +91,39 @@ void FiltersPage::tableCellClicked(const QModelIndex &clicked, { QMessageBox popup(this->window()); - filterparser::FilterParser f( - view->getModel()->data(clicked.siblingAtColumn(1)).toString()); + auto filterText = + view->getModel()->data(clicked.siblingAtColumn(1)).toString(); + auto filterResult = filters::Filter::fromString(filterText); - if (f.valid()) + if (std::holds_alternative(filterResult)) { - popup.setIcon(QMessageBox::Icon::Information); - popup.setWindowTitle("Valid filter"); - popup.setText("Filter is valid"); - popup.setInformativeText( - QString("Parsed as:\n%1").arg(f.filterString())); + auto f = std::move(std::get(filterResult)); + if (f.returnType() == filters::Type::Bool) + { + popup.setIcon(QMessageBox::Icon::Information); + popup.setWindowTitle("Valid filter"); + popup.setText("Filter is valid"); + popup.setInformativeText( + QString("Parsed as:\n%1").arg(f.filterString())); + } + else + { + popup.setIcon(QMessageBox::Icon::Warning); + popup.setWindowTitle("Invalid filter"); + popup.setText(QString("Unexpected filter return type")); + popup.setInformativeText( + QString("Expected %1 but got %2") + .arg(filters::typeToString(filters::Type::Bool)) + .arg(filters::typeToString(f.returnType()))); + } } else { + auto err = std::move(std::get(filterResult)); popup.setIcon(QMessageBox::Icon::Warning); popup.setWindowTitle("Invalid filter"); popup.setText(QString("Parsing errors occurred:")); - popup.setInformativeText(f.errors().join("\n")); + popup.setInformativeText(err.message); } popup.exec(); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 7c9afa29d9d..a3cb5ef0e59 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -24,6 +24,7 @@ set(test_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/SeventvEventAPI.cpp ${CMAKE_CURRENT_LIST_DIR}/src/BttvLiveUpdates.cpp ${CMAKE_CURRENT_LIST_DIR}/src/Updates.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/Filters.cpp ${CMAKE_CURRENT_LIST_DIR}/src/LinkParser.cpp # Add your new file above this line! ) diff --git a/tests/src/Filters.cpp b/tests/src/Filters.cpp new file mode 100644 index 00000000000..97763dc1dce --- /dev/null +++ b/tests/src/Filters.cpp @@ -0,0 +1,172 @@ +#include "controllers/filters/lang/Filter.hpp" +#include "controllers/filters/lang/Types.hpp" + +#include +#include +#include + +using namespace chatterino; +using namespace chatterino::filters; + +TypingContext typingContext = MESSAGE_TYPING_CONTEXT; + +namespace chatterino::filters { + +std::ostream &operator<<(std::ostream &os, Type t) +{ + os << qUtf8Printable(typeToString(t)); + return os; +} + +} // namespace chatterino::filters + +TEST(Filters, Validity) +{ + struct TestCase { + QString input; + bool valid; + }; + + // clang-format off + std::vector tests{ + {"", false}, + {R".(1 + 1).", true}, + {R".(1 + ).", false}, + {R".(1 + 1)).", false}, + {R".((1 + 1).", false}, + {R".(author.name contains "icelys").", true}, + {R".(author.color == "#ff0000").", true}, + {R".(author.name - 5).", false}, // can't perform String - Int + {R".(message.content match {r"(\d\d)/(\d\d)/(\d\d\d\d)", 3}).", true}, + {R".("abc" + 123 == "abc123").", true}, + {R".(123 + "abc" == "hello").", false}, + {R".(flags.reply && flags.automod).", true}, + {R".(unknown.identifier).", false}, + {R".(channel.name == "forsen" && author.badges contains "moderator").", true}, + }; + // clang-format on + + for (const auto &[input, expected] : tests) + { + auto filterResult = Filter::fromString(input); + bool isValid = std::holds_alternative(filterResult); + EXPECT_EQ(isValid, expected) + << "Filter::fromString( " << qUtf8Printable(input) + << " ) should be " << (expected ? "valid" : "invalid"); + } +} + +TEST(Filters, TypeSynthesis) +{ + using T = Type; + struct TestCase { + QString input; + T type; + }; + + // clang-format off + std::vector tests + { + {R".(1 + 1).", T::Int}, + {R".(author.color).", T::Color}, + {R".(author.name).", T::String}, + {R".(!author.subbed).", T::Bool}, + {R".(author.badges).", T::StringList}, + {R".(channel.name == "forsen" && author.badges contains "moderator").", T::Bool}, + {R".(message.content match {r"(\d\d)/(\d\d)/(\d\d\d\d)", 3}).", T::String}, + }; + // clang-format on + + for (const auto &[input, expected] : tests) + { + auto filterResult = Filter::fromString(input); + bool isValid = std::holds_alternative(filterResult); + ASSERT_TRUE(isValid) << "Filter::fromString( " << qUtf8Printable(input) + << " ) is invalid"; + + auto filter = std::move(std::get(filterResult)); + T type = filter.returnType(); + EXPECT_EQ(type, expected) + << "Filter{ " << qUtf8Printable(input) << " } has type " << type + << " instead of " << expected << ".\nDebug: " + << qUtf8Printable(filter.debugString(typingContext)); + } +} + +TEST(Filters, Evaluation) +{ + struct TestCase { + QString input; + QVariant output; + }; + + ContextMap contextMap = { + {"author.name", QVariant("icelys")}, + {"author.color", QVariant(QColor("#ff0000"))}, + {"author.subbed", QVariant(false)}, + {"message.content", QVariant("hey there :) 2038-01-19 123 456")}, + {"channel.name", QVariant("forsen")}, + {"author.badges", QVariant(QStringList({"moderator", "staff"}))}}; + + // clang-format off + std::vector tests + { + // Evaluation semantics + {R".(1 + 1).", QVariant(2)}, + {R".(!(1 == 1)).", QVariant(false)}, + {R".(2 + 3 * 4).", QVariant(20)}, // math operators have the same precedence + {R".(1 > 2 || 3 >= 3).", QVariant(true)}, + {R".(1 > 2 && 3 > 1).", QVariant(false)}, + {R".("abc" + 123).", QVariant("abc123")}, + {R".("abc" + "456").", QVariant("abc456")}, + {R".(3 - 4).", QVariant(-1)}, + {R".(3 * 4).", QVariant(12)}, + {R".(8 / 3).", QVariant(2)}, + {R".(7 % 3).", QVariant(1)}, + {R".(5 == 5).", QVariant(true)}, + {R".(5 == "5").", QVariant(true)}, + {R".(5 != 7).", QVariant(true)}, + {R".(5 == "abc").", QVariant(false)}, + {R".("ABC123" == "abc123").", QVariant(true)}, // String comparison is case-insensitive + {R".("Hello world" contains "Hello").", QVariant(true)}, + {R".("Hello world" contains "LLO W").", QVariant(true)}, // Case-insensitive + {R".({"abc", "def"} contains "abc").", QVariant(true)}, + {R".({"abc", "def"} contains "ABC").", QVariant(true)}, // Case-insensitive when list is all strings + {R".({123, "def"} contains "DEF").", QVariant(false)}, // Case-sensitive if list not all strings + {R".({"a123", "b456"} startswith "a123").", QVariant(true)}, + {R".({"a123", "b456"} startswith "A123").", QVariant(true)}, + {R".({} startswith "A123").", QVariant(false)}, + {R".("Hello world" startswith "Hello").", QVariant(true)}, + {R".("Hello world" startswith "world").", QVariant(false)}, + {R".({"a123", "b456"} endswith "b456").", QVariant(true)}, + {R".({"a123", "b456"} endswith "B456").", QVariant(true)}, + {R".("Hello world" endswith "world").", QVariant(true)}, + {R".("Hello world" endswith "Hello").", QVariant(false)}, + // Context map usage + {R".(author.name).", QVariant("icelys")}, + {R".(!author.subbed).", QVariant(true)}, + {R".(author.color == "#ff0000").", QVariant(true)}, + {R".(channel.name == "forsen" && author.badges contains "moderator").", QVariant(true)}, + {R".(message.content match {r"(\d\d\d\d)\-(\d\d)\-(\d\d)", 3}).", QVariant("19")}, + {R".(message.content match r"HEY THERE").", QVariant(false)}, + {R".(message.content match ri"HEY THERE").", QVariant(true)}, + }; + // clang-format on + + for (const auto &[input, expected] : tests) + { + auto filterResult = Filter::fromString(input); + bool isValid = std::holds_alternative(filterResult); + ASSERT_TRUE(isValid) << "Filter::fromString( " << qUtf8Printable(input) + << " ) is invalid"; + + auto filter = std::move(std::get(filterResult)); + auto result = filter.execute(contextMap); + + EXPECT_EQ(result, expected) + << "Filter{ " << qUtf8Printable(input) << " } evaluated to " + << qUtf8Printable(result.toString()) << " instead of " + << qUtf8Printable(expected.toString()) << ".\nDebug: " + << qUtf8Printable(filter.debugString(typingContext)); + } +} From 00642ef7836c15c7a67140bbb0ea9567ab615ac9 Mon Sep 17 00:00:00 2001 From: CycloneTM Date: Sun, 9 Apr 2023 17:21:46 -0500 Subject: [PATCH 23/38] Add Cyclone to contributors list (#4527) Co-authored-by: Rasmus Karlsson --- resources/avatars/cyclone.png | Bin 0 -> 30135 bytes resources/contributors.txt | 1 + 2 files changed, 1 insertion(+) create mode 100644 resources/avatars/cyclone.png diff --git a/resources/avatars/cyclone.png b/resources/avatars/cyclone.png new file mode 100644 index 0000000000000000000000000000000000000000..d1517180f3a40efcae785af3f6a2ada6d35025b3 GIT binary patch literal 30135 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 Date: Mon, 10 Apr 2023 11:20:27 +0200 Subject: [PATCH 24/38] Restore missing copy and pin buttons in the Usercard (#4533) --- CHANGELOG.md | 2 +- src/singletons/Theme.cpp | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b540c6b8670..0509dd4fc67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,7 +21,7 @@ - Bugfix: Fixed a memory leak that occurred when loading message history. This was mostly noticeable with unstable internet connections where reconnections were frequent or long-running instances of Chatterino. (#4499) - Bugfix: Fixed emote & badge tooltips not showing up when thumbnails were hidden. (#4509) - Dev: Disabling precompiled headers on Windows is now tested in CI. (#4472) -- Dev: Themes are now stored as JSON files in `resources/themes`. (#4471) +- Dev: Themes are now stored as JSON files in `resources/themes`. (#4471, #4533) - 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/singletons/Theme.cpp b/src/singletons/Theme.cpp index 1a4263cc8cc..1b4f40cd2c4 100644 --- a/src/singletons/Theme.cpp +++ b/src/singletons/Theme.cpp @@ -3,6 +3,7 @@ #include "Application.hpp" #include "common/QLogging.hpp" +#include "singletons/Resources.hpp" #include #include @@ -210,6 +211,18 @@ void Theme::parseFrom(const QJsonObject &root) "selection-background-color:" + (this->isLightTheme() ? "#68B1FF" : this->tabs.selected.backgrounds.regular.name()); + + // Usercard buttons + if (this->isLightTheme()) + { + this->buttons.copy = getResources().buttons.copyDark; + this->buttons.pin = getResources().buttons.pinDisabledDark; + } + else + { + this->buttons.copy = getResources().buttons.copyLight; + this->buttons.pin = getResources().buttons.pinDisabledLight; + } } void Theme::normalizeColor(QColor &color) const From 610394d696fdc643198bbaf928f8e923bcb08bde Mon Sep 17 00:00:00 2001 From: nerix Date: Mon, 10 Apr 2023 12:08:15 +0200 Subject: [PATCH 25/38] Fix Twitch-Specific Filters Not Being Applied (#4529) Co-authored-by: pajlada --- CHANGELOG.md | 1 + src/providers/twitch/TwitchChannel.cpp | 141 ++++++++++++------------- src/widgets/helper/ChannelView.cpp | 2 +- 3 files changed, 71 insertions(+), 73 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0509dd4fc67..535af230591 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - Bugfix: Fixed blocked user list sticking around when switching from a logged in user to being logged out. (#4437) - Bugfix: Fixed search popup ignoring setting for message scrollback limit. (#4496) - Bugfix: Fixed a memory leak that occurred when loading message history. This was mostly noticeable with unstable internet connections where reconnections were frequent or long-running instances of Chatterino. (#4499) +- Bugfix: Fixed Twitch channel-specific filters not being applied correctly. (#4529) - Bugfix: Fixed emote & badge tooltips not showing up when thumbnails were hidden. (#4509) - Dev: Disabling precompiled headers on Windows is now tested in CI. (#4472) - Dev: Themes are now stored as JSON files in `resources/themes`. (#4471, #4533) diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index 89972c8d023..96c3217cbff 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -575,7 +575,7 @@ void TwitchChannel::setRoomModes(const RoomModes &_roomModes) bool TwitchChannel::isLive() const { - return this->streamStatus_.access()->live; + return this->streamStatus_.accessConst()->live; } SharedAccessGuard @@ -911,88 +911,85 @@ int TwitchChannel::chatterCount() void TwitchChannel::setLive(bool newLiveStatus) { - bool gotNewLiveStatus = false; { auto guard = this->streamStatus_.access(); - if (guard->live != newLiveStatus) + if (guard->live == newLiveStatus) { - gotNewLiveStatus = true; - if (newLiveStatus) + return; + } + guard->live = newLiveStatus; + } + + if (newLiveStatus) + { + if (getApp()->notifications->isChannelNotified(this->getName(), + Platform::Twitch)) + { + if (Toasts::isEnabled()) { - if (getApp()->notifications->isChannelNotified( - this->getName(), Platform::Twitch)) - { - if (Toasts::isEnabled()) - { - getApp()->toasts->sendChannelNotification( - this->getName(), guard->title, Platform::Twitch); - } - if (getSettings()->notificationPlaySound) - { - getApp()->notifications->playSound(); - } - if (getSettings()->notificationFlashTaskbar) - { - getApp()->windows->sendAlert(); - } - } - // Channel live message - MessageBuilder builder; - TwitchMessageBuilder::liveSystemMessage(this->getDisplayName(), - &builder); - this->addMessage(builder.release()); - - // Message in /live channel - MessageBuilder builder2; - TwitchMessageBuilder::liveMessage(this->getDisplayName(), - &builder2); - getApp()->twitch->liveChannel->addMessage(builder2.release()); - - // Notify on all channels with a ping sound - if (getSettings()->notificationOnAnyChannel && - !(isInStreamerMode() && - getSettings()->streamerModeSuppressLiveNotifications)) - { - getApp()->notifications->playSound(); - } + getApp()->toasts->sendChannelNotification( + this->getName(), this->accessStreamStatus()->title, + Platform::Twitch); } - else + if (getSettings()->notificationPlaySound) { - // Channel offline message - MessageBuilder builder; - TwitchMessageBuilder::offlineSystemMessage( - this->getDisplayName(), &builder); - this->addMessage(builder.release()); - - // "delete" old 'CHANNEL is live' message - LimitedQueueSnapshot snapshot = - getApp()->twitch->liveChannel->getMessageSnapshot(); - int snapshotLength = snapshot.size(); - - // MSVC hates this code if the parens are not there - int end = (std::max)(0, snapshotLength - 200); - auto liveMessageSearchText = - QString("%1 is live!").arg(this->getDisplayName()); - - for (int i = snapshotLength - 1; i >= end; --i) - { - auto &s = snapshot[i]; - - if (s->messageText == liveMessageSearchText) - { - s->flags.set(MessageFlag::Disabled); - break; - } - } + getApp()->notifications->playSound(); + } + if (getSettings()->notificationFlashTaskbar) + { + getApp()->windows->sendAlert(); } - guard->live = newLiveStatus; } - } + // Channel live message + MessageBuilder builder; + TwitchMessageBuilder::liveSystemMessage(this->getDisplayName(), + &builder); + this->addMessage(builder.release()); - if (gotNewLiveStatus) + // Message in /live channel + MessageBuilder builder2; + TwitchMessageBuilder::liveMessage(this->getDisplayName(), &builder2); + getApp()->twitch->liveChannel->addMessage(builder2.release()); + + // Notify on all channels with a ping sound + if (getSettings()->notificationOnAnyChannel && + !(isInStreamerMode() && + getSettings()->streamerModeSuppressLiveNotifications)) + { + getApp()->notifications->playSound(); + } + } + else { - this->liveStatusChanged.invoke(); + // Channel offline message + MessageBuilder builder; + TwitchMessageBuilder::offlineSystemMessage(this->getDisplayName(), + &builder); + this->addMessage(builder.release()); + + // "delete" old 'CHANNEL is live' message + LimitedQueueSnapshot snapshot = + getApp()->twitch->liveChannel->getMessageSnapshot(); + int snapshotLength = snapshot.size(); + + // MSVC hates this code if the parens are not there + int end = (std::max)(0, snapshotLength - 200); + auto liveMessageSearchText = + QString("%1 is live!").arg(this->getDisplayName()); + + for (int i = snapshotLength - 1; i >= end; --i) + { + auto &s = snapshot[i]; + + if (s->messageText == liveMessageSearchText) + { + s->flags.set(MessageFlag::Disabled); + break; + } + } } + + this->liveStatusChanged.invoke(); } void TwitchChannel::refreshTitle() diff --git a/src/widgets/helper/ChannelView.cpp b/src/widgets/helper/ChannelView.cpp index b05b8623967..ef8c4542506 100644 --- a/src/widgets/helper/ChannelView.cpp +++ b/src/widgets/helper/ChannelView.cpp @@ -838,7 +838,7 @@ bool ChannelView::shouldIncludeMessage(const MessagePtr &m) const m->loginName, Qt::CaseInsensitive) == 0) return true; - return this->channelFilters_->filter(m, this->channel_); + return this->channelFilters_->filter(m, this->underlyingChannel_); } return true; From fe2b82bc7bf63445b7eebb4174348c65ff9b669d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 10 Apr 2023 15:23:38 +0000 Subject: [PATCH 26/38] Bump lib/serialize from `1f99aa8` to `bbf0a34` (#4531) Bumps [lib/serialize](https://github.com/pajlada/serialize) from `1f99aa8` to `bbf0a34`. - [Release notes](https://github.com/pajlada/serialize/releases) - [Commits](https://github.com/pajlada/serialize/compare/1f99aa808eda5e717245254032c6bf58b0fc088a...bbf0a34260a3e8d6e6c48be57653840ac3fa8c30) --- updated-dependencies: - dependency-name: lib/serialize dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: pajlada --- lib/serialize | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/serialize b/lib/serialize index 1f99aa808ed..bbf0a34260a 160000 --- a/lib/serialize +++ b/lib/serialize @@ -1 +1 @@ -Subproject commit 1f99aa808eda5e717245254032c6bf58b0fc088a +Subproject commit bbf0a34260a3e8d6e6c48be57653840ac3fa8c30 From 52a6f259cff60d8827c7944dd4bccc1e9cc109f8 Mon Sep 17 00:00:00 2001 From: 2547techno <109011672+2547techno@users.noreply.github.com> Date: Thu, 13 Apr 2023 12:13:49 -0400 Subject: [PATCH 27/38] Fix: check reply-parent-user-id for blocked user (#4502) --- CHANGELOG.md | 1 + src/providers/twitch/TwitchMessageBuilder.cpp | 33 +++++++++++++++---- src/providers/twitch/TwitchMessageBuilder.hpp | 1 + 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 535af230591..b63d780e934 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ - Minor: Added the ability to reply to a message by `Shift + Right Click`ing the username. (#4424) - Minor: Added better filter validation and error messages. (#4364) - Minor: Updated the look of the Black Theme to be more in line with the other themes. (#4523) +- Minor: Reply context now censors blocked users. (#4502) - 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/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index 3cf157c5488..ab95cebc0b3 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -157,6 +157,17 @@ bool TwitchMessageBuilder::isIgnored() const }); } +bool TwitchMessageBuilder::isIgnoredReply() const +{ + return isIgnoredMessage({ + /*.message = */ this->originalMessage_, + /*.twitchUserID = */ + this->tags.value("reply-parent-user-id").toString(), + /*.isMod = */ this->channel->isMod(), + /*.isBroadcaster = */ this->channel->isBroadcaster(), + }); +} + void TwitchMessageBuilder::triggerHighlights() { if (this->historicalMessage_) @@ -622,19 +633,27 @@ void TwitchMessageBuilder::parseThread() if (replyDisplayName != this->tags.end() && replyBody != this->tags.end()) { - auto name = replyDisplayName->toString(); - auto body = parseTagString(replyBody->toString()); + QString body; this->emplace(); - this->emplace( "Replying to", MessageElementFlag::RepliedMessage, MessageColor::System, FontStyle::ChatMediumSmall); - this->emplace( - "@" + name + ":", MessageElementFlag::RepliedMessage, - this->textColor_, FontStyle::ChatMediumSmall) - ->setLink({Link::UserInfo, name}); + if (this->isIgnoredReply()) + { + body = QString("[Blocked user]"); + } + else + { + auto name = replyDisplayName->toString(); + body = parseTagString(replyBody->toString()); + + this->emplace( + "@" + name + ":", MessageElementFlag::RepliedMessage, + this->textColor_, FontStyle::ChatMediumSmall) + ->setLink({Link::UserInfo, name}); + } this->emplace( body, diff --git a/src/providers/twitch/TwitchMessageBuilder.hpp b/src/providers/twitch/TwitchMessageBuilder.hpp index c866d967596..480e18688fd 100644 --- a/src/providers/twitch/TwitchMessageBuilder.hpp +++ b/src/providers/twitch/TwitchMessageBuilder.hpp @@ -53,6 +53,7 @@ class TwitchMessageBuilder : public SharedMessageBuilder TwitchChannel *twitchChannel; [[nodiscard]] bool isIgnored() const override; + bool isIgnoredReply() const; void triggerHighlights() override; MessagePtr build() override; From cd6e1c04b22c20adca1f76ee0dd162a8df09f1be Mon Sep 17 00:00:00 2001 From: Arne <78976058+4rneee@users.noreply.github.com> Date: Fri, 14 Apr 2023 20:19:24 +0200 Subject: [PATCH 28/38] Adjust plugin documentation to match implementation (#4540) --- docs/wip-plugins.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/wip-plugins.md b/docs/wip-plugins.md index d27f0ceda60..8bb0cf61c0d 100644 --- a/docs/wip-plugins.md +++ b/docs/wip-plugins.md @@ -31,7 +31,7 @@ Example file: "$schema": "https://raw.githubusercontent.com/Chatterino/chatterino2/master/docs/plugin-info.schema.json", "name": "Test plugin", "description": "This plugin is for testing stuff.", - "authors": "Mm2PL", + "authors": ["Mm2PL"], "homepage": "https://github.com/Chatterino/Chatterino2", "tags": ["test"], "version": "0.0.0", @@ -100,8 +100,8 @@ Example: function cmdWords(ctx) -- ctx contains: -- words - table of words supplied to the command including the trigger - -- channelName - name of the channel the command is being run in - c2.system_msg(ctx.channelName, "Words are: " .. table.concat(ctx.words, " ")) + -- channel_name - name of the channel the command is being run in + c2.system_msg(ctx.channel_name, "Words are: " .. table.concat(ctx.words, " ")) end c2.register_command("/words", cmdWords) @@ -123,7 +123,7 @@ Example: function cmdShout(ctx) table.remove(ctx.words, 1) local output = table.concat(ctx.words, " ") - c2.send_msg(ctx.channelName, string.upper(output)) + c2.send_msg(ctx.channel_name, string.upper(output)) end c2.register_command("/shout", cmdShout) ``` From 782684e41a1bd9c22a73b7079e9c88460a8c00b1 Mon Sep 17 00:00:00 2001 From: Rasmus Karlsson Date: Sat, 15 Apr 2023 11:08:25 +0200 Subject: [PATCH 29/38] Add 2547techno to contributors list Author: 2547techno --- resources/avatars/techno.png | Bin 0 -> 22309 bytes resources/contributors.txt | 1 + 2 files changed, 1 insertion(+) create mode 100644 resources/avatars/techno.png diff --git a/resources/avatars/techno.png b/resources/avatars/techno.png new file mode 100644 index 0000000000000000000000000000000000000000..874acb97318023215a0479b972601a87c5995168 GIT binary patch literal 22309 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 Q$^ZZW07*qoM6N<$g4j>{-T(jq literal 0 HcmV?d00001 diff --git a/resources/contributors.txt b/resources/contributors.txt index dc2c322d2e6..b1259d936d7 100644 --- a/resources/contributors.txt +++ b/resources/contributors.txt @@ -60,6 +60,7 @@ Wissididom | https://github.com/Wissididom | :/avatars/wissididom.png | Contribu 03y | https://github.com/03y | | Contributor ScrubN | https://github.com/ScrubN | | Contributor Cyclone | https://github.com/PsycloneTM | :/avatars/cyclone.png | Contributor +2547techno | https://github.com/2547techno | :/avatars/techno.png | Contributor # If you are a contributor add yourself above this line From c44e7295dac88606ed30c9d43c1690eae622fbf6 Mon Sep 17 00:00:00 2001 From: iProdigy <8106344+iProdigy@users.noreply.github.com> Date: Sat, 15 Apr 2023 03:13:26 -0700 Subject: [PATCH 30/38] feat: add /lowtrust command (#4542) Co-authored-by: pajlada --- CHANGELOG.md | 1 + .../commands/CommandController.cpp | 30 +++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b63d780e934..fd24b974c48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Minor: Added better filter validation and error messages. (#4364) - Minor: Updated the look of the Black Theme to be more in line with the other themes. (#4523) - Minor: Reply context now censors blocked users. (#4502) +- Minor: Added `/lowtrust` command to open the suspicious user activity feed in browser. (#4542) - 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/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index ae1ce9093c6..01abcc26f3b 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -952,6 +952,36 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); + this->registerCommand("/lowtrust", [](const QStringList &words, + ChannelPtr channel) { + QString target(words.value(1)); + + if (target.isEmpty()) + { + if (channel->getType() == Channel::Type::Twitch && + !channel->isEmpty()) + { + target = channel->getName(); + } + else + { + channel->addMessage(makeSystemMessage( + "Usage: /lowtrust [channel]. You can also use the command " + "without arguments in any Twitch channel to open its " + "suspicious user activity feed. Only the broadcaster and " + "moderators have permission to view this feed.")); + return ""; + } + } + + stripChannelName(target); + QDesktopServices::openUrl(QUrl( + QString("https://www.twitch.tv/popout/moderator/%1/low-trust-users") + .arg(target))); + + return ""; + }); + auto formatChattersError = [](HelixGetChattersError error, QString message) { using Error = HelixGetChattersError; From bf6350ad793f1315258113aebcb400cd83b65428 Mon Sep 17 00:00:00 2001 From: nerix Date: Sat, 15 Apr 2023 13:25:51 +0200 Subject: [PATCH 31/38] Add macOS, Windows, & Ubuntu 22.04 Qt6 builds (#4522) Co-authored-by: Rasmus Karlsson --- .CI/CreateAppImage.sh | 16 ++++-- .CI/CreateDMG.sh | 18 ++++++- .CI/CreateUbuntuDeb.sh | 7 ++- .docker/Dockerfile-ubuntu-22.04-qt6-build | 59 +++++++++++++++++++++ .docker/Dockerfile-ubuntu-22.04-qt6-package | 23 ++++++++ .github/workflows/build.yml | 52 +++++++++++++----- CHANGELOG.md | 1 + 7 files changed, 157 insertions(+), 19 deletions(-) create mode 100644 .docker/Dockerfile-ubuntu-22.04-qt6-build create mode 100644 .docker/Dockerfile-ubuntu-22.04-qt6-package diff --git a/.CI/CreateAppImage.sh b/.CI/CreateAppImage.sh index 2e13d61efb9..12995b33a4b 100755 --- a/.CI/CreateAppImage.sh +++ b/.CI/CreateAppImage.sh @@ -10,10 +10,20 @@ if [ ! -f ./bin/chatterino ] || [ ! -x ./bin/chatterino ]; then exit 1 fi -echo "Qt5_DIR set to: ${Qt5_DIR}" +if [ -n "$Qt5_DIR" ]; then + echo "Using Qt DIR from Qt5_DIR: $Qt5_DIR" + _QT_DIR="$Qt5_DIR" +elif [ -n "$Qt6_DIR" ]; then + echo "Using Qt DIR from Qt6_DIR: $Qt6_DIR" + _QT_DIR="$Qt6_DIR" +fi -export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${Qt5_DIR}/lib" -export PATH="${Qt5_DIR}/bin:$PATH" +if [ -n "$_QT_DIR" ]; then + export LD_LIBRARY_PATH="$LD_LIBRARY_PATH:${_QT_DIR}/lib" + export PATH="${_QT_DIR}/bin:$PATH" +else + echo "No Qt environment variable set, assuming system-installed Qt" +fi script_path=$(readlink -f "$0") script_dir=$(dirname "$script_path") diff --git a/.CI/CreateDMG.sh b/.CI/CreateDMG.sh index 3eb2202c4b8..c5cf2d209ec 100755 --- a/.CI/CreateDMG.sh +++ b/.CI/CreateDMG.sh @@ -5,8 +5,22 @@ if [ -d bin/chatterino.app ] && [ ! -d chatterino.app ]; then mv bin/chatterino.app chatterino.app fi +if [ -n "$Qt5_DIR" ]; then + echo "Using Qt DIR from Qt5_DIR: $Qt5_DIR" + _QT_DIR="$Qt5_DIR" +elif [ -n "$Qt6_DIR" ]; then + echo "Using Qt DIR from Qt6_DIR: $Qt6_DIR" + _QT_DIR="$Qt6_DIR" +fi + +if [ -n "$_QT_DIR" ]; then + export PATH="${_QT_DIR}/bin:$PATH" +else + echo "No Qt environment variable set, assuming system-installed Qt" +fi + echo "Running MACDEPLOYQT" -$Qt5_DIR/bin/macdeployqt chatterino.app +macdeployqt chatterino.app echo "Creating python3 virtual environment" python3 -m venv venv echo "Entering python3 virtual environment" @@ -14,5 +28,5 @@ echo "Entering python3 virtual environment" echo "Installing dmgbuild" python3 -m pip install dmgbuild echo "Running dmgbuild.." -dmgbuild --settings ./../.CI/dmg-settings.py -D app=./chatterino.app Chatterino2 chatterino-osx.dmg +dmgbuild --settings ./../.CI/dmg-settings.py -D app=./chatterino.app Chatterino2 chatterino-osx-Qt-$1.dmg echo "Done!" diff --git a/.CI/CreateUbuntuDeb.sh b/.CI/CreateUbuntuDeb.sh index 9b89fddb8e9..edfc26c6ca5 100755 --- a/.CI/CreateUbuntuDeb.sh +++ b/.CI/CreateUbuntuDeb.sh @@ -24,7 +24,12 @@ case "$ubuntu_release" in dependencies="libc6, libstdc++6, libqt5core5a, libqt5concurrent5, libqt5dbus5, libqt5gui5, libqt5network5, libqt5svg5, libqt5widgets5, qt5-image-formats-plugins, libboost-filesystem1.71.0" ;; 22.04) - dependencies="libc6, libstdc++6, libqt5core5a, libqt5concurrent5, libqt5dbus5, libqt5gui5, libqt5network5, libqt5svg5, libqt5widgets5, qt5-image-formats-plugins, libboost-filesystem1.74.0" + if [ -n "$Qt6_DIR" ]; then + echo "Qt6_DIR set, assuming Qt6" + dependencies="libc6, libstdc++6, libqt6core6, libqt6widgets6, libqt6network6, libqt6core5compat6, libqt6svg6, qt6-qpa-plugins, qt6-image-formats-plugins" + else + dependencies="libc6, libstdc++6, libqt5core5a, libqt5concurrent5, libqt5dbus5, libqt5gui5, libqt5network5, libqt5svg5, libqt5widgets5, qt5-image-formats-plugins, libboost-filesystem1.74.0" + fi ;; *) echo "Unsupported Ubuntu release $ubuntu_release" diff --git a/.docker/Dockerfile-ubuntu-22.04-qt6-build b/.docker/Dockerfile-ubuntu-22.04-qt6-build new file mode 100644 index 00000000000..3fd5b8a20b4 --- /dev/null +++ b/.docker/Dockerfile-ubuntu-22.04-qt6-build @@ -0,0 +1,59 @@ +ARG UBUNTU_VERSION=22.04 + +FROM ubuntu:$UBUNTU_VERSION + +ARG QT_VERSION=6.2.4 +ARG BUILD_WITH_QT6=ON + +ENV TZ=UTC +RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone + +RUN apt-get update && apt-get -y install --no-install-recommends \ + cmake \ + virtualenv \ + rapidjson-dev \ + libfuse2 \ + libssl-dev \ + libboost-dev \ + libxcb-randr0-dev \ + libboost-system-dev \ + libboost-filesystem-dev \ + libpulse-dev \ + libxkbcommon-x11-0 \ + build-essential \ + libgl1-mesa-dev \ + libxcb-icccm4 \ + libxcb-image0 \ + libxcb-keysyms1 \ + libxcb-render-util0 \ + libxcb-xinerama0 \ + libfontconfig1-dev + +RUN apt-get -y install \ + git \ + lsb-release \ + python3-pip && \ + apt-get clean all + +# Install Qt as we do in CI + +RUN pip3 install -U pip && \ + pip3 install aqtinstall && \ + aqt install-qt linux desktop $QT_VERSION -O /opt/qt --modules qt5compat + +ADD . /src + +RUN mkdir /src/build + +# cmake +RUN cd /src/build && \ + CXXFLAGS=-fno-sized-deallocation cmake \ + -DBUILD_WITH_QT6=$BUILD_WITH_QT6 \ + -DCMAKE_INSTALL_PREFIX=appdir/usr/ \ + -DCMAKE_PREFIX_PATH=/opt/qt/$QT_VERSION/gcc_64/lib/cmake \ + -DBUILD_WITH_QTKEYCHAIN=OFF \ + .. + +# build +RUN cd /src/build && \ + make -j8 diff --git a/.docker/Dockerfile-ubuntu-22.04-qt6-package b/.docker/Dockerfile-ubuntu-22.04-qt6-package new file mode 100644 index 00000000000..265754915c9 --- /dev/null +++ b/.docker/Dockerfile-ubuntu-22.04-qt6-package @@ -0,0 +1,23 @@ +ARG UBUNTU_VERSION=22.04 + +FROM chatterino-ubuntu-$UBUNTU_VERSION-qt6-build + +# In CI, this is set from the aqtinstall action +ENV Qt6_DIR=/opt/qt/6.2.4/gcc_64 + +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/.github/workflows/build.yml b/.github/workflows/build.yml index be0f469375e..530dc5de536 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -27,7 +27,7 @@ jobs: strategy: matrix: os: [windows-latest, macos-latest] - qt-version: [5.15.2, 5.12.12] + qt-version: [5.15.2, 6.5.0] pch: [true] force-lto: [false] plugins: [false] @@ -190,7 +190,7 @@ jobs: nmake /S /NOLOGO crashpad_handler mkdir Chatterino2/crashpad cp bin/crashpad/crashpad_handler.exe Chatterino2/crashpad/crashpad_handler.exe - 7z a bin/chatterino.pdb.7z bin/chatterino.pdb + 7z a bin/chatterino-Qt-${{ matrix.qt-version }}.pdb.7z bin/chatterino.pdb - name: Package (windows) if: startsWith(matrix.os, 'windows') @@ -199,21 +199,21 @@ jobs: windeployqt bin/chatterino.exe --release --no-compiler-runtime --no-translations --no-opengl-sw --dir Chatterino2/ cp bin/chatterino.exe Chatterino2/ echo nightly > Chatterino2/modes - 7z a chatterino-windows-x86-64.zip Chatterino2/ + 7z a chatterino-windows-x86-64-Qt-${{ matrix.qt-version }}.zip Chatterino2/ - name: Upload artifact (Windows - binary) if: startsWith(matrix.os, 'windows') && matrix.skip_artifact != 'yes' uses: actions/upload-artifact@v3 with: - name: chatterino-windows-x86-64-${{ matrix.qt-version }}.zip - path: build/chatterino-windows-x86-64.zip + name: chatterino-windows-x86-64-Qt-${{ matrix.qt-version }}.zip + path: build/chatterino-windows-x86-64-Qt-${{ matrix.qt-version }}.zip - name: Upload artifact (Windows - symbols) if: startsWith(matrix.os, 'windows') && matrix.skip_artifact != 'yes' uses: actions/upload-artifact@v3 with: - name: chatterino-windows-x86-64-${{ matrix.qt-version }}-symbols.pdb.7z - path: build/bin/chatterino.pdb.7z + name: chatterino-windows-x86-64-Qt-${{ matrix.qt-version }}-symbols.pdb.7z + path: build/bin/chatterino-Qt-${{ matrix.qt-version }}.pdb.7z - name: Clean Conan cache if: startsWith(matrix.os, 'windows') @@ -341,15 +341,15 @@ jobs: pwd ls -la build || true cd build - sh ./../.CI/CreateDMG.sh + sh ./../.CI/CreateDMG.sh ${{ matrix.qt-version }} shell: bash - name: Upload artifact (MacOS) if: startsWith(matrix.os, 'macos') uses: actions/upload-artifact@v3 with: - name: chatterino-osx-${{ matrix.qt-version }}.dmg - path: build/chatterino-osx.dmg + name: chatterino-osx-Qt-${{ matrix.qt-version }}.dmg + path: build/chatterino-osx-Qt-${{ matrix.qt-version }}.dmg create-release: needs: build runs-on: ubuntu-latest @@ -361,12 +361,22 @@ jobs: fetch-depth: 0 # allows for tags access - uses: actions/download-artifact@v3 with: - name: chatterino-windows-x86-64-5.15.2.zip + name: chatterino-windows-x86-64-Qt-5.15.2.zip path: release-artifacts/ - uses: actions/download-artifact@v3 with: - name: chatterino-windows-x86-64-5.15.2-symbols.pdb.7z + name: chatterino-windows-x86-64-Qt-5.15.2-symbols.pdb.7z + path: release-artifacts/ + + - uses: actions/download-artifact@v3 + with: + name: chatterino-windows-x86-Qt-64-6.5.0.zip + path: release-artifacts/ + + - uses: actions/download-artifact@v3 + with: + name: chatterino-windows-x86-64-Qt-6.5.0-symbols.pdb.7z path: release-artifacts/ - uses: actions/download-artifact@v3 @@ -386,7 +396,17 @@ jobs: - uses: actions/download-artifact@v3 with: - name: chatterino-osx-5.15.2.dmg + name: Chatterino-ubuntu-22.04-Qt-6.2.4.deb + path: release-artifacts/ + + - uses: actions/download-artifact@v3 + with: + name: chatterino-osx-Qt-5.15.2.dmg + path: release-artifacts/ + + - uses: actions/download-artifact@v3 + with: + name: chatterino-osx-Qt-6.5.0.dmg path: release-artifacts/ - name: Copy flatpakref @@ -394,6 +414,12 @@ jobs: cp .CI/chatterino-nightly.flatpakref release-artifacts/ shell: bash + - name: Mark experimental + run: | + for file in *; do mv -n "$file" "$(echo $file | sed 's/\(6\(\.[[:digit:]]\)\{2\}\)/\1-EXPERIMENTAL/g')"; done + working-directory: release-artifacts + shell: bash + - name: Create release uses: ncipollo/release-action@v1.12.0 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index fd24b974c48..094d3f11356 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Dev: Add scripting capabilities with Lua (#4341, #4504) - Dev: Conan 2.0 is now used instead of Conan 1.0. (#4417) - Dev: Added tests and benchmarks for `LinkParser`. (#4436) +- Dev: Experimental builds with Qt 6 are now provided. (#4522) - Dev: Removed `CHATTERINO_TEST` definitions. (#4526) ## 2.4.2 From 8fd975270a0049367ae73b6def3c0c9b011f79eb Mon Sep 17 00:00:00 2001 From: pajlada Date: Sat, 15 Apr 2023 14:23:33 +0200 Subject: [PATCH 32/38] Update macOS build instructions & fix release artifacts (#4545) --- .github/workflows/build.yml | 13 +++++++++++- BUILDING_ON_MAC.md | 40 ++++++++++++++++++------------------- 2 files changed, 32 insertions(+), 21 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 530dc5de536..b786a41572f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -359,52 +359,63 @@ jobs: - uses: actions/checkout@v3 with: fetch-depth: 0 # allows for tags access + - uses: actions/download-artifact@v3 + name: Windows Qt5.15.2 with: name: chatterino-windows-x86-64-Qt-5.15.2.zip path: release-artifacts/ - uses: actions/download-artifact@v3 + name: Windows Qt5.15 symbols with: name: chatterino-windows-x86-64-Qt-5.15.2-symbols.pdb.7z path: release-artifacts/ - uses: actions/download-artifact@v3 + name: Windows Qt6.5.0 with: - name: chatterino-windows-x86-Qt-64-6.5.0.zip + name: chatterino-windows-x86-64-Qt-6.5.0.zip path: release-artifacts/ - uses: actions/download-artifact@v3 + name: Windows Qt6.5.0 symbols with: name: chatterino-windows-x86-64-Qt-6.5.0-symbols.pdb.7z path: release-artifacts/ - uses: actions/download-artifact@v3 + name: Linux Qt5.12.12 AppImage with: name: Chatterino-x86_64-5.12.12.AppImage path: release-artifacts/ - uses: actions/download-artifact@v3 + name: Ubuntu 20.04 Qt5.12.12 deb with: name: Chatterino-ubuntu-20.04-Qt-5.12.12.deb path: release-artifacts/ - uses: actions/download-artifact@v3 + name: Ubuntu 22.04 Qt5.15.2 deb with: name: Chatterino-ubuntu-22.04-Qt-5.15.2.deb path: release-artifacts/ - uses: actions/download-artifact@v3 + name: Ubuntu 22.04 Qt6.2.4 deb with: name: Chatterino-ubuntu-22.04-Qt-6.2.4.deb path: release-artifacts/ - uses: actions/download-artifact@v3 + name: macOS x86_64 Qt5.15.2 dmg with: name: chatterino-osx-Qt-5.15.2.dmg path: release-artifacts/ - uses: actions/download-artifact@v3 + name: macOS x86_64 Qt6.5.0 dmg with: name: chatterino-osx-Qt-6.5.0.dmg path: release-artifacts/ diff --git a/BUILDING_ON_MAC.md b/BUILDING_ON_MAC.md index cedfc5c0ead..53e29cac6c6 100644 --- a/BUILDING_ON_MAC.md +++ b/BUILDING_ON_MAC.md @@ -1,32 +1,32 @@ # Building on macOS -#### Note - If you want to develop Chatterino 2 you might also want to install Qt Creator (make sure to install **Qt 5.12 or newer**), it is not required though and any C++ IDE (might require additional setup for cmake to find Qt libraries) or a normal text editor + running cmake from terminal should work as well +Chatterino2 is built in CI on Intel on macOS 12. +Local dev machines for testing are available on Apple Silicon on macOS 13. -#### Note - Chatterino 2 is only tested on macOS 10.14 and above - anything below that is considered unsupported. It may or may not work on earlier versions +## Installing dependencies 1. Install Xcode and Xcode Command Line Utilities 1. Start Xcode, go into Settings -> Locations, and activate your Command Line Tools -1. Install brew https://brew.sh/ -1. Install the dependencies using `brew install boost openssl rapidjson cmake` -1. Install Qt5 using `brew install qt@5` -1. (_OPTIONAL_) Install [ccache](https://ccache.dev) (used to speed up compilation by using cached results from previous builds) using `brew install ccache` -1. Go into the project directory -1. Create a build folder and go into it (`mkdir build && cd build`) -1. Compile using `cmake .. && make` +1. Install [Homebrew](https://brew.sh/#install) + We use this for dependency management on macOS +1. Install all dependencies: + `brew install boost openssl@1.1 rapidjson cmake qt@5` -If the Project does not build at this point, you might need to add additional Paths/Libs, because brew does not install openssl and boost in the common path. You can get their path using +## Building -`brew info openssl` -`brew info boost` +### Building from terminal -If brew doesn't link OpenSSL properly then you should be able to link it yourself by using these two commands: +1. Open a terminal +1. Go to the project directory where you cloned Chatterino2 & its submodules +1. Create a build directory and go into it: + `mkdir build && cd build` +1. Run cmake: + `cmake -DCMAKE_PREFIX_PATH=/opt/homebrew/opt/qt@5 -DOPENSSL_ROOT_DIR=/opt/homebrew/opt/openssl@1.1 ..` +1. Build: + `make` -- `ln -s /usr/local/opt/openssl/lib/* /usr/local/lib` -- `ln -s /usr/local/opt/openssl/include/openssl /usr/local/include/openssl` +Your binary can now be found under bin/chatterino.app/Contents/MacOS/chatterino directory -The lines which you need to add to your project file should look similar to this +### Other building methods -``` -INCLUDEPATH += /usr/local/opt/openssl/include -LIBS += -L/usr/local/opt/openssl/lib -``` +You can achieve similar results by using an IDE like Qt Creator, although this is undocumented but if you know the IDE you should have no problems applying the terminal instructions to your IDE. From 88bb1b4ae742f66e4f7505cfdec9edf157118744 Mon Sep 17 00:00:00 2001 From: 2547techno <109011672+2547techno@users.noreply.github.com> Date: Sat, 15 Apr 2023 13:59:46 -0400 Subject: [PATCH 33/38] Add message for empty mod list. (#4546) --- CHANGELOG.md | 1 + src/controllers/commands/CommandController.cpp | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 094d3f11356..dbd8eb8e37e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - Minor: Added better filter validation and error messages. (#4364) - Minor: Updated the look of the Black Theme to be more in line with the other themes. (#4523) - Minor: Reply context now censors blocked users. (#4502) +- Minor: Added system message for empty mod list. (#4546) - Minor: Added `/lowtrust` command to open the suspicious user activity feed in browser. (#4542) - 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) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 01abcc26f3b..5fe738fa5e3 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -1161,6 +1161,13 @@ void CommandController::initialize(Settings &, Paths &paths) getHelix()->getModerators( twitchChannel->roomId(), 500, [channel, twitchChannel](auto result) { + if (result.empty()) + { + channel->addMessage(makeSystemMessage( + "This channel does not have any moderators.")); + return; + } + // TODO: sort results? MessageBuilder builder; From 594477d8b69bf2c80aa4de74a0906f4beb1a8eb4 Mon Sep 17 00:00:00 2001 From: Rasmus Karlsson Date: Sun, 16 Apr 2023 11:18:27 +0200 Subject: [PATCH 34/38] test --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 47986f53414..b5b434218f0 100644 --- a/README.md +++ b/README.md @@ -63,3 +63,5 @@ Qt creator should now format the documents when saving it. ## Doxygen Doxygen is used to generate project information daily and is available [here](https://doxygen.chatterino.com). + +test From d6ef48d4ef62e4967fdfc53173304d1bffd2dead Mon Sep 17 00:00:00 2001 From: Zonian <57513632+ZonianMidian@users.noreply.github.com> Date: Sun, 16 Apr 2023 04:58:45 -0500 Subject: [PATCH 35/38] Migrate Twitch badges to Helix (#4537) Co-authored-by: iProdigy <8106344+iProdigy@users.noreply.github.com> Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 1 + src/providers/twitch/TwitchBadges.cpp | 63 ++++++++++++------ src/providers/twitch/TwitchBadges.hpp | 3 - src/providers/twitch/TwitchChannel.cpp | 85 +++++++++++++++--------- src/providers/twitch/api/Helix.cpp | 92 ++++++++++++++++++++++++++ src/providers/twitch/api/Helix.hpp | 86 ++++++++++++++++++++++++ src/providers/twitch/api/README.md | 16 +++++ tests/src/HighlightController.cpp | 15 +++++ 8 files changed, 307 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dbd8eb8e37e..4aa0a7ccdcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Minor: Reply context now censors blocked users. (#4502) - Minor: Added system message for empty mod list. (#4546) - Minor: Added `/lowtrust` command to open the suspicious user activity feed in browser. (#4542) +- Minor: Migrated badges to Helix API. (#4537) - 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/src/providers/twitch/TwitchBadges.cpp b/src/providers/twitch/TwitchBadges.cpp index e959f66cba7..daf782f2918 100644 --- a/src/providers/twitch/TwitchBadges.cpp +++ b/src/providers/twitch/TwitchBadges.cpp @@ -6,6 +6,7 @@ #include "common/QLogging.hpp" #include "messages/Emote.hpp" #include "messages/Image.hpp" +#include "providers/twitch/api/Helix.hpp" #include "util/DisplayBadge.hpp" #include @@ -29,25 +30,49 @@ void TwitchBadges::loadTwitchBadges() { assert(this->loaded_ == false); - QUrl url("https://badges.twitch.tv/v1/badges/global/display"); + getHelix()->getGlobalBadges( + [this](auto globalBadges) { + auto badgeSets = this->badgeSets_.access(); - QUrlQuery urlQuery; - urlQuery.addQueryItem("language", "en"); - url.setQuery(urlQuery); - - NetworkRequest(url) - .onSuccess([this](auto result) -> Outcome { - auto root = result.parseJson(); - - this->parseTwitchBadges(root); + for (const auto &badgeSet : globalBadges.badgeSets) + { + const auto &setID = badgeSet.setID; + for (const auto &version : badgeSet.versions) + { + const auto &emote = Emote{ + EmoteName{}, + ImageSet{ + Image::fromUrl(version.imageURL1x, 1), + Image::fromUrl(version.imageURL2x, .5), + Image::fromUrl(version.imageURL4x, .25), + }, + Tooltip{version.title}, + version.clickURL, + }; + (*badgeSets)[setID][version.id] = + std::make_shared(emote); + } + } this->loaded(); - return Success; - }) - .onError([this](auto res) { - qCWarning(chatterinoTwitch) - << "Error loading Twitch Badges from the badges API:" - << res.status() << " - falling back to backup"; + }, + [this](auto error, auto message) { + QString errorMessage("Failed to load global badges - "); + + switch (error) + { + case HelixGetGlobalBadgesError::Forwarded: { + errorMessage += message; + } + break; + + // This would most likely happen if the service is down, or if the JSON payload returned has changed format + case HelixGetGlobalBadgesError::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + qCWarning(chatterinoTwitch) << errorMessage; QFile file(":/twitch-badges.json"); if (!file.open(QFile::ReadOnly)) { @@ -64,8 +89,7 @@ void TwitchBadges::loadTwitchBadges() this->parseTwitchBadges(doc.object()); this->loaded(); - }) - .execute(); + }); } void TwitchBadges::parseTwitchBadges(QJsonObject root) @@ -93,7 +117,8 @@ void TwitchBadges::parseTwitchBadges(QJsonObject root) {versionObj.value("image_url_4x").toString()}, .25), }, Tooltip{versionObj.value("title").toString()}, - Url{versionObj.value("click_url").toString()}}; + Url{versionObj.value("click_url").toString()}, + }; // "title" // "clickAction" diff --git a/src/providers/twitch/TwitchBadges.hpp b/src/providers/twitch/TwitchBadges.hpp index a87e2246d37..42b2494db52 100644 --- a/src/providers/twitch/TwitchBadges.hpp +++ b/src/providers/twitch/TwitchBadges.hpp @@ -50,9 +50,6 @@ class TwitchBadges 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, diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index 96c3217cbff..4a81da24387 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -1282,50 +1282,71 @@ void TwitchChannel::cleanUpReplyThreads() void TwitchChannel::refreshBadges() { - auto url = Url{"https://badges.twitch.tv/v1/badges/channels/" + - this->roomId() + "/display?language=en"}; - NetworkRequest(url.string) + if (this->roomId().isEmpty()) + { + return; + } - .onSuccess([this, - weak = weakOf(this)](auto result) -> Outcome { + getHelix()->getChannelBadges( + this->roomId(), + // successCallback + [this, weak = weakOf(this)](auto channelBadges) { auto shared = weak.lock(); if (!shared) - return Failure; + { + // The channel has been closed inbetween us making the request and the request finishing + return; + } auto badgeSets = this->badgeSets_.access(); - auto jsonRoot = result.parseJson(); - - auto _ = jsonRoot["badge_sets"].toObject(); - for (auto jsonBadgeSet = _.begin(); jsonBadgeSet != _.end(); - jsonBadgeSet++) + for (const auto &badgeSet : channelBadges.badgeSets) { - auto &versions = (*badgeSets)[jsonBadgeSet.key()]; - - auto _set = jsonBadgeSet->toObject()["versions"].toObject(); - for (auto jsonVersion_ = _set.begin(); - jsonVersion_ != _set.end(); jsonVersion_++) + const auto &setID = badgeSet.setID; + for (const auto &version : badgeSet.versions) { - auto jsonVersion = jsonVersion_->toObject(); - auto emote = std::make_shared(Emote{ + auto emote = Emote{ EmoteName{}, ImageSet{ - Image::fromUrl( - {jsonVersion["image_url_1x"].toString()}, 1), - Image::fromUrl( - {jsonVersion["image_url_2x"].toString()}, .5), - Image::fromUrl( - {jsonVersion["image_url_4x"].toString()}, .25)}, - Tooltip{jsonVersion["description"].toString()}, - Url{jsonVersion["clickURL"].toString()}}); - - versions.emplace(jsonVersion_.key(), emote); - }; + Image::fromUrl(version.imageURL1x, 1), + Image::fromUrl(version.imageURL2x, .5), + Image::fromUrl(version.imageURL4x, .25), + }, + Tooltip{version.title}, + version.clickURL, + }; + (*badgeSets)[setID][version.id] = + std::make_shared(emote); + } + } + }, + // failureCallback + [this, weak = weakOf(this)](auto error, auto message) { + auto shared = weak.lock(); + if (!shared) + { + // The channel has been closed inbetween us making the request and the request finishing + return; } - return Success; - }) - .execute(); + QString errorMessage("Failed to load channel badges - "); + + switch (error) + { + case HelixGetChannelBadgesError::Forwarded: { + errorMessage += message; + } + break; + + // This would most likely happen if the service is down, or if the JSON payload returned has changed format + case HelixGetChannelBadgesError::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + + this->addMessage(makeSystemMessage(errorMessage)); + }); } void TwitchChannel::refreshCheerEmotes() diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 5df03372e09..71c19175a24 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -2468,6 +2468,98 @@ void Helix::startCommercial( .execute(); } +// Twitch global badges +// https://dev.twitch.tv/docs/api/reference/#get-global-chat-badges +void Helix::getGlobalBadges( + ResultCallback successCallback, + FailureCallback failureCallback) +{ + using Error = HelixGetGlobalBadgesError; + + this->makeRequest("chat/badges/global", QUrlQuery()) + .onSuccess([successCallback](auto result) -> Outcome { + if (result.status() != 200) + { + qCWarning(chatterinoTwitch) + << "Success result for getting global badges was " + << result.status() << "but we expected it to be 200"; + } + + auto response = result.parseJson(); + successCallback(HelixGlobalBadges(response)); + return Success; + }) + .onError([failureCallback](auto result) { + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (result.status()) + { + case 401: { + failureCallback(Error::Forwarded, message); + } + break; + + default: { + qCWarning(chatterinoTwitch) + << "Helix global badges, unhandled error data:" + << result.status() << result.getData() << obj; + failureCallback(Error::Unknown, message); + } + break; + } + }) + .execute(); +} + +// Badges for the `broadcasterID` channel +// https://dev.twitch.tv/docs/api/reference/#get-channel-chat-badges +void Helix::getChannelBadges( + QString broadcasterID, ResultCallback successCallback, + FailureCallback failureCallback) +{ + using Error = HelixGetChannelBadgesError; + + QUrlQuery urlQuery; + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + + this->makeRequest("chat/badges", urlQuery) + .onSuccess([successCallback](auto result) -> Outcome { + if (result.status() != 200) + { + qCWarning(chatterinoTwitch) + << "Success result for getting badges was " + << result.status() << "but we expected it to be 200"; + } + + auto response = result.parseJson(); + successCallback(HelixChannelBadges(response)); + return Success; + }) + .onError([failureCallback](auto result) { + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (result.status()) + { + case 400: + case 401: { + failureCallback(Error::Forwarded, message); + } + break; + + default: { + qCWarning(chatterinoTwitch) + << "Helix channel badges, unhandled error data:" + << result.status() << result.getData() << obj; + failureCallback(Error::Unknown, message); + } + break; + } + }) + .execute(); +} + NetworkRequest Helix::makeRequest(QString url, QUrlQuery urlQuery) { assert(!url.startsWith("/")); diff --git a/src/providers/twitch/api/Helix.hpp b/src/providers/twitch/api/Helix.hpp index 42242fc8e06..f3286a72903 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -384,6 +384,55 @@ struct HelixModerators { } }; +struct HelixBadgeVersion { + QString id; + Url imageURL1x; + Url imageURL2x; + Url imageURL4x; + QString title; + Url clickURL; + + explicit HelixBadgeVersion(const QJsonObject &jsonObject) + : id(jsonObject.value("id").toString()) + , imageURL1x(Url{jsonObject.value("image_url_1x").toString()}) + , imageURL2x(Url{jsonObject.value("image_url_2x").toString()}) + , imageURL4x(Url{jsonObject.value("image_url_4x").toString()}) + , title(jsonObject.value("title").toString()) + , clickURL(Url{jsonObject.value("click_url").toString()}) + { + } +}; + +struct HelixBadgeSet { + QString setID; + std::vector versions; + + explicit HelixBadgeSet(const QJsonObject &json) + : setID(json.value("set_id").toString()) + { + const auto jsonVersions = json.value("versions").toArray(); + for (const auto &version : jsonVersions) + { + versions.emplace_back(version.toObject()); + } + } +}; + +struct HelixGlobalBadges { + std::vector badgeSets; + + explicit HelixGlobalBadges(const QJsonObject &jsonObject) + { + const auto &data = jsonObject.value("data").toArray(); + for (const auto &set : data) + { + this->badgeSets.emplace_back(set.toObject()); + } + } +}; + +using HelixChannelBadges = HelixGlobalBadges; + enum class HelixAnnouncementColor { Blue, Green, @@ -616,6 +665,15 @@ enum class HelixStartCommercialError { Forwarded, }; +enum class HelixGetGlobalBadgesError { + Unknown, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; + +using HelixGetChannelBadgesError = HelixGetGlobalBadgesError; + class IHelix { public: @@ -899,6 +957,21 @@ class IHelix FailureCallback failureCallback) = 0; + // Get global Twitch badges + // https://dev.twitch.tv/docs/api/reference/#get-global-chat-badges + virtual void getGlobalBadges( + ResultCallback successCallback, + FailureCallback + failureCallback) = 0; + + // Get badges for the `broadcasterID` channel + // https://dev.twitch.tv/docs/api/reference/#get-channel-chat-badges + virtual void getChannelBadges( + QString broadcasterID, + ResultCallback successCallback, + FailureCallback + failureCallback) = 0; + virtual void update(QString clientId, QString oauthToken) = 0; protected: @@ -1184,6 +1257,19 @@ class Helix final : public IHelix FailureCallback failureCallback) final; + // Get global Twitch badges + // https://dev.twitch.tv/docs/api/reference/#get-global-chat-badges + void getGlobalBadges(ResultCallback successCallback, + FailureCallback + failureCallback) final; + + // Get badges for the `broadcasterID` channel + // https://dev.twitch.tv/docs/api/reference/#get-channel-chat-badges + void getChannelBadges(QString broadcasterID, + ResultCallback successCallback, + FailureCallback + failureCallback) final; + void update(QString clientId, QString oauthToken) final; static void initialize(); diff --git a/src/providers/twitch/api/README.md b/src/providers/twitch/api/README.md index b889761840c..b05be517e33 100644 --- a/src/providers/twitch/api/README.md +++ b/src/providers/twitch/api/README.md @@ -136,6 +136,22 @@ Used in: - `providers/twitch/TwitchChannel.cpp` to resolve a chats available cheer emotes. This helps us parse incoming messages like `pajaCheer1000` +### Get Global Badges + +URL: https://dev.twitch.tv/docs/api/reference/#get-global-chat-badges + +Used in: + +- `providers/twitch/TwitchBadges.cpp` to load global badges + +### Get Channel Badges + +URL: https://dev.twitch.tv/docs/api/reference/#get-channel-chat-badges + +Used in: + +- `providers/twitch/TwitchChannel.cpp` to load channel badges + ### Get Emote Sets URL: https://dev.twitch.tv/docs/api/reference#get-emote-sets diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index a3f37b26b1a..181ca57a3b8 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -225,6 +225,21 @@ class MockHelix : public IHelix HelixFailureCallback failureCallback), (override)); + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD( + void, getGlobalBadges, + (ResultCallback successCallback, + (FailureCallback failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, getChannelBadges, + (QString broadcasterID, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + // The extra parenthesis around the failure callback is because its type contains a comma MOCK_METHOD(void, updateUserChatColor, (QString userID, QString color, From 8124ab439ce34bacc68e9a8cd4c454dc1a2cd2f9 Mon Sep 17 00:00:00 2001 From: pajlada Date: Sun, 16 Apr 2023 13:16:12 +0200 Subject: [PATCH 36/38] Restart the sound device if it's been stopped (#4549) Fixes a bug where we don't check if the sound device is active before trying to play a sound to it We fix this by checking its state before every sound we try to play, and if the device is not in the started state we try to restart it. --- CHANGELOG.md | 1 + src/controllers/sound/SoundController.cpp | 20 ++++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4aa0a7ccdcf..bdf47b1d609 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ - Minor: Added system message for empty mod list. (#4546) - Minor: Added `/lowtrust` command to open the suspicious user activity feed in browser. (#4542) - Minor: Migrated badges to Helix API. (#4537) +- Bugfix: Fixed an issue where Chatterino could lose track of the sound device in certain scenarios. (#4549) - 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/src/controllers/sound/SoundController.cpp b/src/controllers/sound/SoundController.cpp index be9313ef7b5..449bc3c479b 100644 --- a/src/controllers/sound/SoundController.cpp +++ b/src/controllers/sound/SoundController.cpp @@ -190,6 +190,26 @@ void SoundController::play(const QUrl &sound) return; } + auto deviceState = ma_device_get_state(this->device.get()); + + if (deviceState != ma_device_state_started) + { + // Device state is not as it should be, try to restart it + qCWarning(chatterinoSound) + << "Sound device was not started, attempting to restart it" + << deviceState; + + auto result = ma_device_start(this->device.get()); + if (result != MA_SUCCESS) + { + qCWarning(chatterinoSound) + << "Failed to start the sound device" << result; + return; + } + + qCInfo(chatterinoSound) << "Successfully restarted the sound device"; + } + if (sound.isLocalFile()) { auto soundPath = sound.toLocalFile(); From 517382ebc967f0ab3b512cf3619fa3b1ba213d76 Mon Sep 17 00:00:00 2001 From: Zonian <57513632+ZonianMidian@users.noreply.github.com> Date: Sun, 16 Apr 2023 06:49:54 -0500 Subject: [PATCH 37/38] Add ZonianMidian to the contributors list (#4548) Co-authored-by: Rasmus Karlsson --- resources/avatars/zonianmidian.png | Bin 0 -> 26692 bytes resources/contributors.txt | 1 + 2 files changed, 1 insertion(+) create mode 100644 resources/avatars/zonianmidian.png diff --git a/resources/avatars/zonianmidian.png b/resources/avatars/zonianmidian.png new file mode 100644 index 0000000000000000000000000000000000000000..d0d36ee01dce9af6eeecd9c4a3f402172a18810b GIT binary patch literal 26692 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 Date: Sun, 16 Apr 2023 21:05:51 +0200 Subject: [PATCH 38/38] Use `macos` rather than `osx` in filenames (#4550) --- .CI/CreateDMG.sh | 2 +- .github/workflows/build.yml | 8 ++++---- CHANGELOG.md | 1 + src/singletons/Toasts.cpp | 2 +- 4 files changed, 7 insertions(+), 6 deletions(-) diff --git a/.CI/CreateDMG.sh b/.CI/CreateDMG.sh index c5cf2d209ec..5eaddc071a7 100755 --- a/.CI/CreateDMG.sh +++ b/.CI/CreateDMG.sh @@ -28,5 +28,5 @@ echo "Entering python3 virtual environment" echo "Installing dmgbuild" python3 -m pip install dmgbuild echo "Running dmgbuild.." -dmgbuild --settings ./../.CI/dmg-settings.py -D app=./chatterino.app Chatterino2 chatterino-osx-Qt-$1.dmg +dmgbuild --settings ./../.CI/dmg-settings.py -D app=./chatterino.app Chatterino2 chatterino-macos-Qt-$1.dmg echo "Done!" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b786a41572f..c9beb6b347d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -348,8 +348,8 @@ jobs: if: startsWith(matrix.os, 'macos') uses: actions/upload-artifact@v3 with: - name: chatterino-osx-Qt-${{ matrix.qt-version }}.dmg - path: build/chatterino-osx-Qt-${{ matrix.qt-version }}.dmg + name: chatterino-macos-Qt-${{ matrix.qt-version }}.dmg + path: build/chatterino-macos-Qt-${{ matrix.qt-version }}.dmg create-release: needs: build runs-on: ubuntu-latest @@ -411,13 +411,13 @@ jobs: - uses: actions/download-artifact@v3 name: macOS x86_64 Qt5.15.2 dmg with: - name: chatterino-osx-Qt-5.15.2.dmg + name: chatterino-macos-Qt-5.15.2.dmg path: release-artifacts/ - uses: actions/download-artifact@v3 name: macOS x86_64 Qt6.5.0 dmg with: - name: chatterino-osx-Qt-6.5.0.dmg + name: chatterino-macos-Qt-6.5.0.dmg path: release-artifacts/ - name: Copy flatpakref diff --git a/CHANGELOG.md b/CHANGELOG.md index bdf47b1d609..2378a428edd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ - Dev: Added tests and benchmarks for `LinkParser`. (#4436) - Dev: Experimental builds with Qt 6 are now provided. (#4522) - Dev: Removed `CHATTERINO_TEST` definitions. (#4526) +- Dev: Builds for macOS now have `macos` in their name (previously: `osx`). (#4550) ## 2.4.2 diff --git a/src/singletons/Toasts.cpp b/src/singletons/Toasts.cpp index 09fbf8fbcc0..b421e46d8a3 100644 --- a/src/singletons/Toasts.cpp +++ b/src/singletons/Toasts.cpp @@ -76,7 +76,7 @@ void Toasts::sendChannelNotification(const QString &channelName, }; #else auto sendChannelNotification = [] { - // Unimplemented for OSX and Linux + // Unimplemented for macOS and Linux }; #endif // Fetch user profile avatar