From dcd42cb28bde2efc17d9afa0e36d6f5f1d3971de Mon Sep 17 00:00:00 2001 From: pajlada Date: Sun, 28 Aug 2022 13:31:53 +0200 Subject: [PATCH 001/946] Add AutoMod message flag filter (#3938) --- CHANGELOG.md | 1 + src/controllers/filters/parser/FilterParser.cpp | 3 +++ src/controllers/filters/parser/Tokenizer.hpp | 1 + 3 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9ed9a7339..5aab1fd16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ - Minor: Warn when parsing an environment variable fails. (#3904) - Minor: Load missing messages from Recent Messages API upon reconnecting (#3878, #3932) - Minor: Add settings to toggle BTTV/FFZ global/channel emotes (#3935) +- Minor: Add AutoMod message flag filter. (#3938) - Bugfix: Fix crash that can occur when closing and quickly reopening a split, then running a command. (#3852) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fix crash that can occur when changing channels. (#3799) diff --git a/src/controllers/filters/parser/FilterParser.cpp b/src/controllers/filters/parser/FilterParser.cpp index f4c80efa6..104192641 100644 --- a/src/controllers/filters/parser/FilterParser.cpp +++ b/src/controllers/filters/parser/FilterParser.cpp @@ -30,6 +30,8 @@ ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel) * flags.reward_message * flags.first_message * flags.whisper + * flags.reply + * flags.automod * * message.content * message.length @@ -83,6 +85,7 @@ ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel) {"flags.first_message", m->flags.has(MessageFlag::FirstMessage)}, {"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()}, diff --git a/src/controllers/filters/parser/Tokenizer.hpp b/src/controllers/filters/parser/Tokenizer.hpp index 752616078..4e5a6798d 100644 --- a/src/controllers/filters/parser/Tokenizer.hpp +++ b/src/controllers/filters/parser/Tokenizer.hpp @@ -26,6 +26,7 @@ static const QMap validIdentifiersMap = { {"flags.first_message", "first message?"}, {"flags.whisper", "whisper message?"}, {"flags.reply", "reply message?"}, + {"flags.automod", "automod message?"}, {"message.content", "message text"}, {"message.length", "message length"}}; From 2fd962261b5dc36807cb11c5182de829b9a71afe Mon Sep 17 00:00:00 2001 From: pajlada Date: Sun, 28 Aug 2022 13:32:07 +0200 Subject: [PATCH 002/946] Switch to dev branch of clang-tidy-review (#3937) This supports the split workflow logic which makes it work on fork PRs --- .github/workflows/build.yml | 11 +++++- .github/workflows/post-clang-tidy-review.yml | 39 ++++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/post-clang-tidy-review.yml diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a5706046b..96cf0fa32 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -149,11 +149,20 @@ 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.9.0 + uses: pajlada/clang-tidy-review@feat/split-up-review-and-post-workflows id: review with: build_dir: build config_file: '.clang-tidy' + split_workflow: true + + - uses: actions/upload-artifact@v3 + if: (startsWith(matrix.os, 'ubuntu') && matrix.pch == false && matrix.qt-version == '5.15.2' && github.event_name == 'pull_request') + with: + name: clang-tidy-review + path: | + clang-tidy-review-output.json + clang-tidy-review-metadata.json - name: Package - AppImage (Ubuntu) if: startsWith(matrix.os, 'ubuntu') diff --git a/.github/workflows/post-clang-tidy-review.yml b/.github/workflows/post-clang-tidy-review.yml new file mode 100644 index 000000000..8a49322b9 --- /dev/null +++ b/.github/workflows/post-clang-tidy-review.yml @@ -0,0 +1,39 @@ +--- +name: Post clang-tidy review comments + +on: + workflow_run: + workflows: ["Build"] + types: + - completed + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: 'Download artifact' + uses: actions/github-script@v6 + with: + script: | + const artifacts = await github.rest.actions.listWorkflowRunArtifacts({ + owner: context.repo.owner, + repo: context.repo.repo, + run_id: ${{github.event.workflow_run.id }}, + }); + const matchArtifact = artifacts.data.artifacts.filter((artifact) => { + return artifact.name == "clang-tidy-review" + })[0]; + const download = await github.rest.actions.downloadArtifact({ + owner: context.repo.owner, + repo: context.repo.repo, + artifact_id: matchArtifact.id, + archive_format: 'zip', + }); + const fs = require('fs'); + fs.writeFileSync('${{github.workspace}}/clang-tidy-review.zip', Buffer.from(download.data)); + - name: 'Unzip artifact' + run: unzip clang-tidy-review.zip + + - uses: pajlada/clang-tidy-review/post@feat/split-up-review-and-post-workflows + id: review From d849b978e26e056e29ec166f45883a39e248a776 Mon Sep 17 00:00:00 2001 From: pajlada Date: Mon, 29 Aug 2022 19:16:08 +0200 Subject: [PATCH 003/946] Add missing boost-circular-buffer dependency to vcpkg (#3941) --- vcpkg.json | 1 + 1 file changed, 1 insertion(+) diff --git a/vcpkg.json b/vcpkg.json index 31da7aa23..c515ff196 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -10,6 +10,7 @@ "boost-interprocess", "boost-random", "boost-variant", + "boost-circular-buffer", "gtest", "openssl", "qt5-multimedia", From a9fc9f949f555e93dfe403727968ee97a64c588c Mon Sep 17 00:00:00 2001 From: pajlada Date: Sat, 3 Sep 2022 13:01:56 +0200 Subject: [PATCH 004/946] Remove unused mutex from Emotes (#3943) The mutex was initially used to limit access to the twitchEmotesCache_ member but it's no longer necessary since it's been made a UniqueAccess type --- src/providers/twitch/TwitchEmotes.hpp | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/providers/twitch/TwitchEmotes.hpp b/src/providers/twitch/TwitchEmotes.hpp index 0f0234a47..437157d33 100644 --- a/src/providers/twitch/TwitchEmotes.hpp +++ b/src/providers/twitch/TwitchEmotes.hpp @@ -45,8 +45,6 @@ private: Url getEmoteLink(const EmoteId &id, const QString &emoteScale); UniqueAccess>> twitchEmotesCache_; - - std::mutex mutex_; }; } // namespace chatterino From 8f551519b15fb0eee20ff2cfee05eeb02d02fcd1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 3 Sep 2022 13:56:41 +0200 Subject: [PATCH 005/946] Bump ilammy/msvc-dev-cmd from 1.10.0 to 1.11.0 (#3939) Bumps [ilammy/msvc-dev-cmd](https://github.com/ilammy/msvc-dev-cmd) from 1.10.0 to 1.11.0. - [Release notes](https://github.com/ilammy/msvc-dev-cmd/releases) - [Commits](https://github.com/ilammy/msvc-dev-cmd/compare/v1.10.0...v1.11.0) --- updated-dependencies: - dependency-name: ilammy/msvc-dev-cmd dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 96cf0fa32..507a6e923 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -79,7 +79,7 @@ jobs: - name: Enable Developer Command Prompt if: startsWith(matrix.os, 'windows') - uses: ilammy/msvc-dev-cmd@v1.10.0 + uses: ilammy/msvc-dev-cmd@v1.11.0 - name: Build (Windows) if: startsWith(matrix.os, 'windows') From 46efa5df3d68fa79a83a8adc9e109f9271c122c9 Mon Sep 17 00:00:00 2001 From: pajlada Date: Sat, 3 Sep 2022 18:12:44 +0200 Subject: [PATCH 006/946] Treat `reorder` warnings as errors (#3944) --- src/CMakeLists.txt | 1 + src/providers/twitch/TwitchIrcServer.cpp | 2 +- src/widgets/helper/ChannelView.cpp | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 00d16c3f5..af6067cf7 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -774,6 +774,7 @@ else () -Wno-strict-aliasing -Werror=return-type + -Werror=reorder ) if (CMAKE_CXX_COMPILER_ID MATCHES "Clang") diff --git a/src/providers/twitch/TwitchIrcServer.cpp b/src/providers/twitch/TwitchIrcServer.cpp index b052134db..2526898ca 100644 --- a/src/providers/twitch/TwitchIrcServer.cpp +++ b/src/providers/twitch/TwitchIrcServer.cpp @@ -30,8 +30,8 @@ namespace chatterino { TwitchIrcServer::TwitchIrcServer() : whispersChannel(new Channel("/whispers", Channel::Type::TwitchWhispers)) , mentionsChannel(new Channel("/mentions", Channel::Type::TwitchMentions)) - , watchingChannel(Channel::getEmpty(), Channel::Type::TwitchWatching) , liveChannel(new Channel("/live", Channel::Type::TwitchLive)) + , watchingChannel(Channel::getEmpty(), Channel::Type::TwitchWatching) { this->initializeIrc(); diff --git a/src/widgets/helper/ChannelView.cpp b/src/widgets/helper/ChannelView.cpp index 54837c8de..a7d3ad4ad 100644 --- a/src/widgets/helper/ChannelView.cpp +++ b/src/widgets/helper/ChannelView.cpp @@ -122,8 +122,8 @@ namespace { ChannelView::ChannelView(BaseWidget *parent, Split *split, Context context) : BaseWidget(parent) - , scrollBar_(new Scrollbar(this)) , split_(split) + , scrollBar_(new Scrollbar(this)) , context_(context) { this->setMouseTracking(true); From bc38d696bc11ec882e94f1c6e7944ad272af6ab0 Mon Sep 17 00:00:00 2001 From: Troy <49777269+TroyKomodo@users.noreply.github.com> Date: Sat, 3 Sep 2022 23:20:30 -0400 Subject: [PATCH 007/946] Reduce GIF frame window from 30ms to 20ms (#3907) --- CHANGELOG.md | 1 + src/singletons/helper/GifTimer.cpp | 3 ++- src/singletons/helper/GifTimer.hpp | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5aab1fd16..52b87d295 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ - Minor: Added `/copy` command. Usage: `/copy `. Copies provided text to clipboard - can be useful with custom commands. (#3763) - Minor: Removed total views from the usercard, as Twitch no longer updates the number. (#3792) - Minor: Add Quick Switcher item to open a channel in a new popup window. (#3828) +- Minor: Reduced GIF frame window from 30ms to 20ms, causing fewer frame skips in animated emotes. (#3886, #3907) - Minor: Warn when parsing an environment variable fails. (#3904) - Minor: Load missing messages from Recent Messages API upon reconnecting (#3878, #3932) - Minor: Add settings to toggle BTTV/FFZ global/channel emotes (#3935) diff --git a/src/singletons/helper/GifTimer.cpp b/src/singletons/helper/GifTimer.cpp index 2edf7bacd..c1ad6b734 100644 --- a/src/singletons/helper/GifTimer.cpp +++ b/src/singletons/helper/GifTimer.cpp @@ -8,7 +8,8 @@ namespace chatterino { void GIFTimer::initialize() { - this->timer.setInterval(30); + this->timer.setInterval(gifFrameLength); + this->timer.setTimerType(Qt::PreciseTimer); getSettings()->animateEmotes.connect([this](bool enabled, auto) { if (enabled) diff --git a/src/singletons/helper/GifTimer.hpp b/src/singletons/helper/GifTimer.hpp index da47149b8..d20d933bc 100644 --- a/src/singletons/helper/GifTimer.hpp +++ b/src/singletons/helper/GifTimer.hpp @@ -5,7 +5,7 @@ namespace chatterino { -constexpr long unsigned gifFrameLength = 33; +constexpr long unsigned gifFrameLength = 20; class GIFTimer { From 8ec032fc848f9b2ec8006049b042e21ee9671291 Mon Sep 17 00:00:00 2001 From: Daniel Sage <24928223+dnsge@users.noreply.github.com> Date: Sun, 4 Sep 2022 07:23:14 -0400 Subject: [PATCH 008/946] Periodically free memory from unused images (#3915) Co-authored-by: pajlada --- CHANGELOG.md | 1 + .../moderationactions/ModerationAction.cpp | 6 +- src/messages/Image.cpp | 213 ++++++++++++++++-- src/messages/Image.hpp | 44 +++- src/messages/MessageBuilder.cpp | 3 +- src/providers/twitch/TwitchMessageBuilder.cpp | 12 +- src/widgets/helper/ChannelView.cpp | 2 +- 7 files changed, 246 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 52b87d295..1e6e6f9a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ - Minor: Reduced GIF frame window from 30ms to 20ms, causing fewer frame skips in animated emotes. (#3886, #3907) - Minor: Warn when parsing an environment variable fails. (#3904) - Minor: Load missing messages from Recent Messages API upon reconnecting (#3878, #3932) +- Minor: Reduced image memory usage when running Chatterino for a long time. (#3915) - Minor: Add settings to toggle BTTV/FFZ global/channel emotes (#3935) - Minor: Add AutoMod message flag filter. (#3938) - Bugfix: Fix crash that can occur when closing and quickly reopening a split, then running a command. (#3852) diff --git a/src/controllers/moderationactions/ModerationAction.cpp b/src/controllers/moderationactions/ModerationAction.cpp index a24ce9e85..90b3e1de4 100644 --- a/src/controllers/moderationactions/ModerationAction.cpp +++ b/src/controllers/moderationactions/ModerationAction.cpp @@ -141,9 +141,11 @@ const boost::optional &ModerationAction::getImage() const if (this->imageToLoad_ != 0) { if (this->imageToLoad_ == 1) - this->image_ = Image::fromPixmap(getResources().buttons.ban); + this->image_ = + Image::fromResourcePixmap(getResources().buttons.ban); else if (this->imageToLoad_ == 2) - this->image_ = Image::fromPixmap(getResources().buttons.trashCan); + this->image_ = + Image::fromResourcePixmap(getResources().buttons.trashCan); } return this->image_; diff --git a/src/messages/Image.cpp b/src/messages/Image.cpp index fda0637f6..95abb3bcd 100644 --- a/src/messages/Image.cpp +++ b/src/messages/Image.cpp @@ -6,7 +6,9 @@ #include #include #include +#include #include +#include #include #include "Application.hpp" @@ -23,7 +25,10 @@ #include "util/DebugCount.hpp" #include "util/PostToThread.hpp" -#include +// Duration between each check of every Image instance +const auto IMAGE_POOL_CLEANUP_INTERVAL = std::chrono::minutes(1); +// Duration since last usage of Image pixmap before expiration of frames +const auto IMAGE_POOL_IMAGE_LIFETIME = std::chrono::minutes(10); namespace chatterino { namespace detail { @@ -33,11 +38,15 @@ namespace detail { DebugCount::increase("images"); } - Frames::Frames(const QVector> &frames) - : items_(frames) + Frames::Frames(QVector> &&frames) + : items_(std::move(frames)) { assertInGuiThread(); DebugCount::increase("images"); + if (!this->empty()) + { + DebugCount::increase("loaded images"); + } if (this->animated()) { @@ -76,6 +85,10 @@ namespace detail { { assertInGuiThread(); DebugCount::decrease("images"); + if (!this->empty()) + { + DebugCount::decrease("loaded images"); + } if (this->animated()) { @@ -114,6 +127,25 @@ namespace detail { } } + void Frames::clear() + { + assertInGuiThread(); + if (!this->empty()) + { + DebugCount::decrease("loaded images"); + } + + this->items_.clear(); + this->index_ = 0; + this->durationOffset_ = 0; + this->gifTimerConnection_.disconnect(); + } + + bool Frames::empty() const + { + return this->items_.empty(); + } + bool Frames::animated() const { return this->items_.size() > 1; @@ -137,13 +169,13 @@ namespace detail { QVector> readFrames(QImageReader &reader, const Url &url) { QVector> frames; + frames.reserve(reader.imageCount()); QImage image; for (int index = 0; index < reader.imageCount(); ++index) { if (reader.read(&image)) { - QPixmap::fromImage(image); // It seems that browsers have special logic for fast animations. // This implements Chrome and Firefox's behavior which uses // a duration of 100 ms for any frames that specify a duration of <= 10 ms. @@ -153,7 +185,7 @@ namespace detail { if (duration <= 10) duration = 100; duration = std::max(20, duration); - frames.push_back(Frame{image, duration}); + frames.push_back(Frame{std::move(image), duration}); } } @@ -178,9 +210,12 @@ namespace detail { while (!queued.empty()) { - queued.front().first(queued.front().second); + auto front = std::move(queued.front()); queued.pop(); + // Call Assign with the vector of frames + front.first(std::move(front.second)); + if (++i > 50) { QTimer::singleShot(3, [&] { @@ -200,9 +235,14 @@ namespace detail { auto makeConvertCallback(const QVector> &parsed, Assign assign) { + static std::queue>>> queued; + static std::mutex mutex; + static std::atomic_bool loadedEventQueued{false}; + return [parsed, assign] { // convert to pixmap - auto frames = QVector>(); + QVector> frames; + frames.reserve(parsed.size()); std::transform(parsed.begin(), parsed.end(), std::back_inserter(frames), [](auto &frame) { return Frame{ @@ -211,15 +251,9 @@ namespace detail { }); // put into stack - static std::queue>>> - queued; - static std::mutex mutex; - std::lock_guard lock(mutex); queued.emplace(assign, frames); - static std::atomic_bool loadedEventQueued{false}; - if (!loadedEventQueued) { loadedEventQueued = true; @@ -235,7 +269,9 @@ namespace detail { // IMAGE2 Image::~Image() { - if (this->empty_) + ImageExpirationPool::instance().removeImagePtr(this); + + if (this->empty_ && !this->frames_) { // No data in this image, don't bother trying to release it // The reason we do this check is that we keep a few (or one) static empty image around that are deconstructed at the end of the programs lifecycle, and we want to prevent the isGuiThread call to be called after the QApplication has been exited @@ -268,13 +304,37 @@ ImagePtr Image::fromUrl(const Url &url, qreal scale) return shared; } -ImagePtr Image::fromPixmap(const QPixmap &pixmap, qreal scale) +ImagePtr Image::fromResourcePixmap(const QPixmap &pixmap, qreal scale) { - auto result = ImagePtr(new Image(scale)); + using key_t = std::pair; + static std::unordered_map, boost::hash> + cache; + static std::mutex mutex; - result->setPixmap(pixmap); + std::lock_guard lock(mutex); - return result; + auto it = cache.find({&pixmap, scale}); + if (it != cache.end()) + { + auto shared = it->second.lock(); + if (shared) + { + return shared; + } + else + { + cache.erase(it); + } + } + + auto newImage = ImagePtr(new Image(scale)); + + newImage->setPixmap(pixmap); + + // store in cache + cache.insert({{&pixmap, scale}, std::weak_ptr(newImage)}); + + return newImage; } ImagePtr Image::getEmpty() @@ -335,6 +395,11 @@ boost::optional Image::pixmapOrLoad() const { assertInGuiThread(); + // Mark the image as just used. + // Any time this Image is painted, this method is invoked. + // See src/messages/layouts/MessageLayoutElement.cpp ImageLayoutElement::paint, for example. + this->lastUsed_ = std::chrono::steady_clock::now(); + this->load(); return this->frames_->current(); @@ -346,8 +411,10 @@ void Image::load() const if (this->shouldLoad_) { - const_cast(this)->shouldLoad_ = false; - const_cast(this)->actuallyLoad(); + Image *this2 = const_cast(this); + this2->shouldLoad_ = false; + this2->actuallyLoad(); + ImageExpirationPool::instance().addImagePtr(this2->shared_from_this()); } } @@ -439,9 +506,12 @@ void Image::actuallyLoad() auto parsed = detail::readFrames(reader, shared->url()); - postToThread(makeConvertCallback(parsed, [weak](auto frames) { + postToThread(makeConvertCallback(parsed, [weak](auto &&frames) { if (auto shared = weak.lock()) - shared->frames_ = std::make_unique(frames); + { + shared->frames_ = + std::make_unique(std::move(frames)); + } })); return Success; @@ -459,6 +529,13 @@ void Image::actuallyLoad() .execute(); } +void Image::expireFrames() +{ + assertInGuiThread(); + this->frames_->clear(); + this->shouldLoad_ = true; // Mark as needing load again +} + bool Image::operator==(const Image &other) const { if (this->isEmpty() && other.isEmpty()) @@ -476,4 +553,96 @@ bool Image::operator!=(const Image &other) const return !this->operator==(other); } +ImageExpirationPool::ImageExpirationPool() +{ + QObject::connect(&this->freeTimer_, &QTimer::timeout, [this] { + if (isGuiThread()) + { + this->freeOld(); + } + else + { + postToThread([this] { + this->freeOld(); + }); + } + }); + + this->freeTimer_.start( + std::chrono::duration_cast( + IMAGE_POOL_CLEANUP_INTERVAL)); +} + +ImageExpirationPool &ImageExpirationPool::instance() +{ + static ImageExpirationPool instance; + return instance; +} + +void ImageExpirationPool::addImagePtr(ImagePtr imgPtr) +{ + std::lock_guard lock(this->mutex_); + this->allImages_.emplace(imgPtr.get(), std::weak_ptr(imgPtr)); +} + +void ImageExpirationPool::removeImagePtr(Image *rawPtr) +{ + std::lock_guard lock(this->mutex_); + this->allImages_.erase(rawPtr); +} + +void ImageExpirationPool::freeOld() +{ + std::lock_guard lock(this->mutex_); + +#ifndef NDEBUG + size_t numExpired = 0; + size_t eligible = 0; +#endif + + auto now = std::chrono::steady_clock::now(); + for (auto it = this->allImages_.begin(); it != this->allImages_.end();) + { + auto img = it->second.lock(); + if (!img) + { + // This can only really happen from a race condition because ~Image + // should remove itself from the ImageExpirationPool automatically. + it = this->allImages_.erase(it); + continue; + } + + if (img->frames_->empty()) + { + // No frame data, nothing to do + ++it; + continue; + } + +#ifndef NDEBUG + ++eligible; +#endif + + // Check if image has expired and, if so, expire its frame data + auto diff = now - img->lastUsed_; + if (diff > IMAGE_POOL_IMAGE_LIFETIME) + { +#ifndef NDEBUG + ++numExpired; +#endif + img->expireFrames(); + // erase without mutex locking issue + it = this->allImages_.erase(it); + continue; + } + + ++it; + } + +#ifndef NDEBUG + qCDebug(chatterinoImage) << "freed frame data for" << numExpired << "/" + << eligible << "eligible images"; +#endif +} + } // namespace chatterino diff --git a/src/messages/Image.hpp b/src/messages/Image.hpp index a4ad674fe..d47532acc 100644 --- a/src/messages/Image.hpp +++ b/src/messages/Image.hpp @@ -3,11 +3,14 @@ #include #include #include +#include #include #include #include #include #include +#include +#include #include #include #include @@ -26,9 +29,11 @@ namespace detail { { public: Frames(); - Frames(const QVector> &frames); + Frames(QVector> &&frames); ~Frames(); + void clear(); + bool empty() const; bool animated() const; void advance(); boost::optional current() const; @@ -56,7 +61,7 @@ public: ~Image(); static ImagePtr fromUrl(const Url &url, qreal scale = 1); - static ImagePtr fromPixmap(const QPixmap &pixmap, qreal scale = 1); + static ImagePtr fromResourcePixmap(const QPixmap &pixmap, qreal scale = 1); static ImagePtr getEmpty(); const Url &url() const; @@ -80,13 +85,46 @@ private: void setPixmap(const QPixmap &pixmap); void actuallyLoad(); + void expireFrames(); const Url url_{}; const qreal scale_{1}; std::atomic_bool empty_{false}; - // gui thread only + mutable std::chrono::time_point lastUsed_; + bool shouldLoad_{false}; + + // gui thread only std::unique_ptr frames_{}; + + friend class ImageExpirationPool; }; + +class ImageExpirationPool +{ +private: + friend class Image; + + ImageExpirationPool(); + static ImageExpirationPool &instance(); + + void addImagePtr(ImagePtr imgPtr); + void removeImagePtr(Image *rawPtr); + + /** + * @brief Frees frame data for all images that ImagePool deems to have expired. + * + * Expiration is based on last accessed time of the Image, stored in Image::lastUsed_. + * Must be ran in the GUI thread. + */ + void freeOld(); + +private: + // Timer to periodically run freeOld() + QTimer freeTimer_; + std::map> allImages_; + std::mutex mutex_; +}; + } // namespace chatterino diff --git a/src/messages/MessageBuilder.cpp b/src/messages/MessageBuilder.cpp index d76b8b4a1..18f2b9102 100644 --- a/src/messages/MessageBuilder.cpp +++ b/src/messages/MessageBuilder.cpp @@ -31,7 +31,8 @@ MessagePtr makeSystemMessage(const QString &text, const QTime &time) EmotePtr makeAutoModBadge() { return std::make_shared(Emote{ - EmoteName{}, ImageSet{Image::fromPixmap(getResources().twitch.automod)}, + EmoteName{}, + ImageSet{Image::fromResourcePixmap(getResources().twitch.automod)}, Tooltip{"AutoMod"}, Url{"https://dashboard.twitch.tv/settings/moderation/automod"}}); } diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index b2eb7488d..38572b6e2 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -344,17 +344,17 @@ MessagePtr TwitchMessageBuilder::build() if (this->thread_) { auto &img = getResources().buttons.replyThreadDark; - this->emplace(Image::fromPixmap(img, 0.15), 2, - Qt::gray, - MessageElementFlag::ReplyButton) + this->emplace( + Image::fromResourcePixmap(img, 0.15), 2, Qt::gray, + MessageElementFlag::ReplyButton) ->setLink({Link::ViewThread, this->thread_->rootId()}); } else { auto &img = getResources().buttons.replyDark; - this->emplace(Image::fromPixmap(img, 0.15), 2, - Qt::gray, - MessageElementFlag::ReplyButton) + this->emplace( + Image::fromResourcePixmap(img, 0.15), 2, Qt::gray, + MessageElementFlag::ReplyButton) ->setLink({Link::ReplyToMessage, this->message().id}); } diff --git a/src/widgets/helper/ChannelView.cpp b/src/widgets/helper/ChannelView.cpp index a7d3ad4ad..837df651a 100644 --- a/src/widgets/helper/ChannelView.cpp +++ b/src/widgets/helper/ChannelView.cpp @@ -1567,7 +1567,7 @@ void ChannelView::mouseMoveEvent(QMouseEvent *event) !element->getThumbnail()->url().string.isEmpty(); auto thumb = shouldHideThumbnail - ? Image::fromPixmap(getResources().streamerMode) + ? Image::fromResourcePixmap(getResources().streamerMode) : element->getThumbnail(); tooltipPreviewImage.setImage(std::move(thumb)); From 7a4eda0e3007156739068339cb20ba1a9ca1d2b1 Mon Sep 17 00:00:00 2001 From: Explooosion-code <61145047+Explooosion-code@users.noreply.github.com> Date: Sun, 4 Sep 2022 18:58:44 +0200 Subject: [PATCH 009/946] Filtering trailing/leading whitespace in username field in nicknames. (#3946) --- CHANGELOG.md | 1 + src/controllers/nicknames/NicknamesModel.cpp | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e6e6f9a1..f76e6e242 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ - Minor: Reduced image memory usage when running Chatterino for a long time. (#3915) - Minor: Add settings to toggle BTTV/FFZ global/channel emotes (#3935) - Minor: Add AutoMod message flag filter. (#3938) +- Minor: Added whitespace trim to username field in nicknames (#3946) - Bugfix: Fix crash that can occur when closing and quickly reopening a split, then running a command. (#3852) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fix crash that can occur when changing channels. (#3799) diff --git a/src/controllers/nicknames/NicknamesModel.cpp b/src/controllers/nicknames/NicknamesModel.cpp index 2748f49b6..2028fa3e2 100644 --- a/src/controllers/nicknames/NicknamesModel.cpp +++ b/src/controllers/nicknames/NicknamesModel.cpp @@ -16,7 +16,7 @@ NicknamesModel::NicknamesModel(QObject *parent) Nickname NicknamesModel::getItemFromRow(std::vector &row, const Nickname &original) { - return Nickname{row[0]->data(Qt::DisplayRole).toString(), + return Nickname{row[0]->data(Qt::DisplayRole).toString().trimmed(), row[1]->data(Qt::DisplayRole).toString(), row[2]->data(Qt::CheckStateRole).toBool(), row[3]->data(Qt::CheckStateRole).toBool()}; From 92301e7d725ef751b24227cba930e1d4b6d96c02 Mon Sep 17 00:00:00 2001 From: Felanbird <41973452+Felanbird@users.noreply.github.com> Date: Sun, 4 Sep 2022 13:25:34 -0400 Subject: [PATCH 010/946] Update gifFrameLength name as suggested by clang-tidy (#3947) --- src/messages/Image.cpp | 2 +- src/singletons/helper/GifTimer.cpp | 4 ++-- src/singletons/helper/GifTimer.hpp | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/messages/Image.cpp b/src/messages/Image.cpp index 95abb3bcd..e189d54be 100644 --- a/src/messages/Image.cpp +++ b/src/messages/Image.cpp @@ -100,7 +100,7 @@ namespace detail { void Frames::advance() { - this->durationOffset_ += gifFrameLength; + this->durationOffset_ += GIF_FRAME_LENGTH; this->processOffset(); } diff --git a/src/singletons/helper/GifTimer.cpp b/src/singletons/helper/GifTimer.cpp index c1ad6b734..6f567ea74 100644 --- a/src/singletons/helper/GifTimer.cpp +++ b/src/singletons/helper/GifTimer.cpp @@ -8,7 +8,7 @@ namespace chatterino { void GIFTimer::initialize() { - this->timer.setInterval(gifFrameLength); + this->timer.setInterval(GIF_FRAME_LENGTH); this->timer.setTimerType(Qt::PreciseTimer); getSettings()->animateEmotes.connect([this](bool enabled, auto) { @@ -23,7 +23,7 @@ void GIFTimer::initialize() qApp->activeWindow() == nullptr) return; - this->position_ += gifFrameLength; + this->position_ += GIF_FRAME_LENGTH; this->signal.invoke(); getApp()->windows->repaintGifEmotes(); }); diff --git a/src/singletons/helper/GifTimer.hpp b/src/singletons/helper/GifTimer.hpp index d20d933bc..d24f2d111 100644 --- a/src/singletons/helper/GifTimer.hpp +++ b/src/singletons/helper/GifTimer.hpp @@ -5,7 +5,7 @@ namespace chatterino { -constexpr long unsigned gifFrameLength = 20; +constexpr long unsigned GIF_FRAME_LENGTH = 20; class GIFTimer { From 7ad708253592297e9d4ca9eaf0911f033b889b9f Mon Sep 17 00:00:00 2001 From: Explooosion-code <61145047+Explooosion-code@users.noreply.github.com> Date: Sun, 4 Sep 2022 20:48:35 +0200 Subject: [PATCH 011/946] Added Explooosion to contributors list (#3948) Co-authored-by: Rasmus Karlsson --- resources/avatars/explooosion_code.png | Bin 0 -> 15702 bytes resources/contributors.txt | 1 + resources/resources_autogenerated.qrc | 1 + src/autogenerated/ResourcesAutogen.cpp | 1 + src/autogenerated/ResourcesAutogen.hpp | 1 + 5 files changed, 4 insertions(+) create mode 100644 resources/avatars/explooosion_code.png diff --git a/resources/avatars/explooosion_code.png b/resources/avatars/explooosion_code.png new file mode 100644 index 0000000000000000000000000000000000000000..0f65da9f27fef4199b1d18eb1fb38598e05ba133 GIT binary patch literal 15702 zcmXY2Wl)_x)4mVx?(SaP-L+8M-L1Im!QI_miu= zWOuJ@c4Jgk5S1{oI)oBeq(EuPqsvs?)>GS(M(9=+J?SVIy zYY;NQ?%};WV>E{<#Wyzx%@k{DfzPyJ&KVmM6A|LqFaF;Ut7->t_UVRu@bz8qP=*)8 zeTn>(3)0S9BM8&?DOQk-IZM z(EuU5x_T{(^Hm36qW;?5u!1P^`*rqejy|SbvD+miwr?7H}nTVS<>Qt1A$wK@m{iQQ-Qug?eF@5AYL)&hpVar|e3xgjm1b_2igK>_)hY1nDVS5J}+Ci$P{ zZVi9(>wv#V-a5Fh%OlVH93+E#yrT5~?7x)poFXCjfq01f*Gl; zv&f;MsWT|K)_;N=MfQbU8PH7sTG`MmA$y3qPl}zEU0R^o9e!g0TyRn|Qm$Z<*Xa1Q zAuu2XiY{g$F(3=!5N~P&C}-J4-o4}F;pF7B=hVKt`wpW=sCT|=-sj#+xLM!(#d_Kt zDJ9w$&8z9Q*%n2^r~F4P&nnL%OYLamh=8TKcD+k>lW2#p!6!Atqt=<;sK4#4hH)K{ zP_}0jLWqO_j9pFUXc?GEu?b`T1EndbVA#n3C)!58G(GSd^ln|QBh9V~!JGMJen|Z$ zz{khx1e`c@^t-A6&N|kcv65gHU%mX$4-4?umVhFZ1ek0?v#*Z2sN!8EwN%rH#oQ*< zF#5RJzCo6|<{M?q>fZQU_zQNDwF5JKfk>(m8e~wFIqWTi`=Xm1SNE3Q~TIv`@u$Jf?htG~{E1u%pO zr6@>7KU=F*WI8y(tOTum@v<6cLN^ zU9)B#whlQ%oP9K?^OLHsi&41L*Tbdg2^S&z%^dIFP3&zASTOXI`^HB_vBLb@FR+Bz&w&?XxL{VZW4Em&dbZ7 z9s(swzsUm_@5HiMsw(UIF8L>f2|@D4SnjIrS(@N#-#uc3*zQ>&K`)Sjh5^vo(nxf4 z6}5P_96NP(>wd~Rl?cr#&M(RSr}9-{U7NjP5ta%T&dJL)a6rT>$aCjv>0V`t7c5V5L#&lg8_Z5c6)|?F~d5~6k(*?S;v`!|VamcDxX7eSC zfu9Qn=$-fK2Dce6E?-hq$S>Mg5dH$@3fXi zsuk3jAtIJ%lnZ{pATtSN=}*T%g4hj^4K~1ts|^Pp5F%12>~@9r!9s#FPALu%0W_O? z<}({i>c5onlq5l%YEhL^3zV7T21EwEE%qNzp73XXpF}hU#|v346)&laQft)n=c>bD zf<$|F0tQSL{J0qf6^mfnDe&J$9wsT=*4Y{t;p`zBtNA|MM>r7U9I)VFE1;xgh*GC| zE?U39JM~Bap#kLu?6H6e@g28vl0n)N9;6ltP{+w~F3D?ZVEQNxlZcx8_qUDuFY2g6 z&^}w*M6~k&#o#CYFYHwnl41(Mr4p&A*-Uv%c?3JLs6|AwM7?Y<%@iC098*i$JOb(k zGZ=#m4iiYX)}V!QxU9~Stv-|ym!x#r!>q}Db5U=(uE3Ry7NG;Q?^1r|b~98k;ugX+ zUI^^A$eMxs^?=WtPvzgoA6r_5#odNak{#XdF&Z@*$AHg9P5LK3L#KA1oC(1khwp78 z;(yKc%q>n&8AWkOW7&ZQ%;iuyxC1+xPzHh^o?fhAL`rAeC*Wy)z=xAwFEL+J`y|m@ zWQTL~VRTRKb3g9KnV+0PB4!dPJ{xv7!x0sKuOB&OgisPQu4!1&F!Y*^Zsf~GNYJ%( zd;Fmkcp>Mx^ke|AM1lF^o3e+^i(!v|ko?KnV6#o~9pfDL94CP!IDMI6P96rZNI=#{ zP6dvtj?#`AfRaghW9TDiaW|N!X_O15i!cB#qSVk51_$|xl+e0J6+$|}kns7W=P;Y2 ze`$~amc@oLiaAQbpisPAya5s;r1HH8f%!2EL?B~~c25V8)6Xee1o~ZeqUw~@2h2Ww z#u>zAWChfx)O|)P+k|tN^z2qeG?~U>y3ZZf%-a5YZVexWx`90#-*@F}*XZc^ec4$) zwy2OvA#Kw#%KICp$w3lW)x+G**GRAU!Z|jAZPE7lRkn3yeP*A`9nkjgByF9AmUmk& zXc&SAqXZNL^OY*P%F}J;F{)8wvh-^h)-hDJtGZJ5YicPh>fAwRFg@sA#IEo8a~VPH zlPQ#b^wAe^Gx2vmkhIq@yt`3!UdBEZMxAArOyJmij=PXRuHfO?h?GF)<=i!jsXz)V z5nDPp2#f55L_Gm^WXp`H?lg*XnM6nE+fTq|g(|Km546c>*_ar_^$7JoFqf~>T>V2| z$<@RzOp!UzQ_apz1l-JvMGfB7`R}r-^s|q(SnA#g+6T$R7FSZksm^GlxpB$wz%QeP zwEn7Ha*cA|vBgUZZnJgal7}76ssjl*B0LA)ereynJ}nh`Hn!MFAdaVUh3{cNa=kA) zc)MQu8%724EE*`parolqr65Chu{SJ-A#K9-`}#tyVLhK;M7D1V4=N5Tus_Sp$z;X~ z7cxr|QA7Sqs#a=iH7m`eFXg6;nGcQiZ+y5<_Eb&CqXaM0{pUN!25L`osXx%aF3og6 zJw#CHYwK?2nm{`(`F!OH8|ZPC>O}CUHB~`?Q~dIQW_VVM%zoCC*v5n@9wIA9oepp6 z_nUi_8=%mj(h$`kvmzRk%!wG8{Ym89`a72B&$bUu4iLqY%mADfHv9j&m<7H)YB%V1 zkby$nd-Zxj;5C+?xK93RUu&p|75w_JHidM+>Pl4c+TC}8SL zVJ9-YLr)`h-EHu1EgAOk6Q&Ck&a4zTLm$~{gvY#qxRJTvczA#@pD~#6+Z1Y28^uc@ zTLYq0ADl;(47?0H0Pg?~4G(jqgbX1Fg8;e7y|%9|jS{)fMyzlEa}OoiYX+z?L#4Q6 z^)|VHtMxUoH(n07_yB)oIO)%5&S{4H*3*5dyY~URX5#t6y^H)D_>a07^&R0zUfPf$ ziBsISn|R|n@S}C$cD#*aLKF)mn975`R~2umNFMKSbab1HlWLppvF5&fAAZZ}Y7Dk< zvNPQIH=)?S@WsSkvLgHn@iizPUVIVb1EVUoq3CdD5+^TN4%k#y)Ub3aop3V${tqnb zEbiC3YfWS9tqi2Ud(V8S|15z*V;S5sxQQuy)b#{%o+ z+fO*&3|{!JXbrvPZ2Y9PkZLcP5ft>Ue{1YE z;@-elqw5%z^qpAdImZQswXwwbj7^=r#T9V}{b8`-hB3-IX!z$3&whgH!M%%8YtXv8(D^2A z(_{x07}772_zhuO*I`8Kff_hf7Cu;^64!MZU|c9A+)j6E&%-ykY@H4nq3Aw!1Ma=< z8P7B@=2y$R)RJbA8_^ddzs)Yx^?Y`vZZ{NY1m)i%W!-kKJ)n6D@U%^mFS?Wlb=zDe zRv(PsI<^R{-rZuQ3lf0?BtSHRcb-o?m0q1PZ>k1<)evxQkF zO>Az|Lp<;!qaX=W7wGjggWBpG#96c8p)B3wE&SJ=SP;)Dy+A;ag9D&GAZ}cqg+svC zcGh@Zc4(h6zO);>A~j>sU~Hg^#lDBq9MVD3MnWT#`?~epc-9zX+B5$gn}AcA-kNAJ zp>i9v-Ip}BW8-ywY=~<|MJ~?sl`wU;AODf`xq}tUVvt`angvJV^Qog*di)pZ>GQ`K z)+b2>LGmXK<8+&sX%O%;%z_>sb9;6&-KvCXqt95$>STu<6X?D1VIqqH)=jher}}Vl zI#GZz!Lh`#fs<1?#JtDTv&-j%iB(HE_QEJK7d8W4gOf%~)w55RUk!=DWANtRia`*i z9hAjrZ=*gZ$H*l;D@`Vx5gY^@Rk+3h(UmLHM`&qE+?GMn*b8x538PMfaQ90P_33ODu`M;?>AlejjdDU2jTN6f3@n3u2tUr%_$=a zt|0>j$dt@Ubg6r>v~xa$+!J1bK`nYMel5+PAHvbBYKML|W%6jOovOs%$vzcQ7D4^E zjQ(1``7|On!Ps(&9)O&Mff>!29-BC)N-LwO_=C&pO2{$MCD9FS%ZcJ_q~wgh4CndOx_2o%KNi_;el~r&JyJTB#svVJ~rIsg}_X^cZOr_FuiY|Ooq0rJmJ7(C{(qmoqDuA|Dy=*&208TEH zGw&U#E)-IkoZB|Ihz_ltU8Jk|DxR1(o^H}tU!ARssbL12%$nsT_7zk7y8jR1=sPfO z!)DSJKn@`mszf2_(r=J*BQAE*mIg1^8=tKw#;5)5OTzl2pSVVXX7Xl|sdBX|7jyH8 zvPHz+kPQ$ogei)~0v{vH7g$3X0e};slgY&fcWyXEFwEsygUO14{BfA0_93(hL(*^o z@gmE#&wTp;YUf|bH#gkf@Z$>ER@I2Ugb^^+Y0_zD9y!|){M*QFwU|;tv%t=l>28zVzeuSAgQqp>DPb zj7r#(qyn0>=K>o)$vvDOYn9T_hAGzcJS4vs-+!xb+$d_Sy@7}hn(LHge1@Ygj9y)IasdM^Y!5#?StOvW+ ze{@h|#CPDk)B}%e6zB4fDP-OEx<{E`2jG)E1Q3fJ$q60uCYLM>>OuU0{963_f|pHy zh`!U>(Lw6nXkPS|%?^2|HblK~#WR~qIy2QC&TH%xf+%l+ZL{-%$SCyQzNySvc`t8~ ztG-3p7o(*QbT1NeZSKUd^f>z7v>7TYcGu$@? z6P|F%>tD~F`2h!angMQtGi@od7IaYiUUECDt;w>p<(8vI^8=cy=11_j z$-`}kTmskVl0a0o{%X7ymv+!r^5jik72g+l3Rx_!==}|E-7R$!f0Iv3o-5~|HP|*S zii1#%b%wpUK!3$X${Ga9gXMQVjUWiOVlulQ8W5|I!eWiqZ-sQadc#)KxBEeqpvWv{ zMs4o91%h;8+J&VGQ`uYbuM=w%&Rj6CE2+UwZkIfM*aW z@hSgC{Y8Exjj`6-=xff)MU;a2irE^_;G-amZ>7n+E=ooO8CN3|qVg~C=1DDS)s-PA z4p4u3z19*NiI#Gxz zxw9F?Ms$()GKcFgWg$1;({hyf1789?o$gjn80!w3Jc&=@|A*EfO@*oJohZ8Fh2l$!&%kRF9x3R79UXB(* zr$CuP8z=_F>ldS)aZL)=tMF2z(>KZ{?V@_@5mI#N&T}E-rv&!B$1XL6>p1;|Vfe4q zi@l5aQbsn$2L3B^cRpb0G^$qMt9GBL zG|N6Y81h=J)?E^F+NvuYTnRokZmo5oLuE95C%MJ=Iczt-Hzsd64EF4Bj(j#+!e14hv zR05w39am?f%g6QNq}y7qf7ZKV+k7Gx!dT+C@BpYX?tOJgdY!awU18s;Fv0)yc6_Fr z1umQF0;HF&L=I#sVnvO_RDz`xFWH#vjjYdY>TzZVJq!0p(0c+OW4wnP;>Zgh}C3H4Q zG1DaLvBq;osT5Jh0@UrIqsv3*w>xhG>g4t&R)8kqGf@^dmg($RiQNJ73mH%tSJfEH z01Nr+GPBwUwf;%r7re;F@{Hpe$#S+GP{#Q=-m?*Y$gchOrU0qqL)mGZLhz&+k zRLW0<4mT8mSS;s(7b;XHM8i->LhE}3>SU`lAZw5}cJzGPyR&558Qi7}l8^oz{U!Rf zmvTBiU(f9T1Cs13m5EaG+&WMjQS)C7svO$yw!2^2gDk>v!f_(wAnxBh7j`7ig3d*Y zByCqtW->dv;Fzh*mzuvDKW!HI`?Tpb%ZN8Q11ukp4huYmRr|HoK@wSPY{I8r>(3lBy+@0N8+`NI!RMTs)7js~Uw5TMJY^kEQ`6FFn z92usyt6J9=8ufJhk)K+#d^7we&5ToBMJ_Arb9p9Fm9BihNgzFGRfzHVDWyT{TSnvJ zTfb)?Fz~y$bea&BbOh2~CA8vFFD zdVtT-x*Ks3Jf6iI>fjyNqslXFaDmJ)(~wKL@PfQuuC{_S4FhOl?@r9zDmL9Zj3$F5+ufkUf-xT)brPL;!d&eD? zQU2M8VVR^V(ZVRO*Qa9v_w!$gDJdeJ3s3){u}xqsqO4>32sd%LGxb{DR@r|k6qeAu zPZ7p)U7EbzLbVee#~Hk3Ao@N*JJqIgD{y2x6P)uAyk~nr=KvE7K=ypdu1H`JdPo9z z*ue~g*W=*vUCYcd=^0x$iblpFeUs^&DUK`)l+ptU!Qh(2WB&nVWAhAo8B~4=>(FbM zeG|S%0cV<;>ZQ9JJ1z}a(DHd`->@50-GV_)r#kw!0$wVJy~^~ue>9Dbx|nI*zObHr zEiTV#%;r#z58Af3e4T<6x>~@{!k|pZ=TLLPe1bE_mjt4s<=4UwHItO$k)wMW1OrBk ze2Ji}PLapX?jjwxrdqXxhIZOR-*nuB?Oy)&kipkc|C93JEmJQkR>NFUANccHkx!#G zdGCd;0r&US6GWpwBJny%9xFsa$Ii2FSnYf%bZ{>ymR1sNCLff(n?hBJQgq`nF&`5) zoM@AkZ{&}ck%$!+LmdvK3P+a8O}A2a$q;1YnO??1y{oeX2U63RqC&c;d}Kht2$cJh*JWP9Lz zhK7;#L%i%6;qE zTsX`(!5V&!`z;%Y1zO1~nH#{=xWAG!9&ADwZ1tW&3(bDjciWaF%KT`GF~(-4NMCHKv+bh^LHq2V|j{{0_1cLS`-DtqGJBvVT~uZLzY60*xHT z*sBqN?tNeu1f4B50`ZO4C)s0hU?6JktSooM zl7$*!ECnwmc+RZUSW`5?fe!Z-7hI-qkl^vZnR}CRV3-`3dKevG{tj!Gij(20HI)p$ z>TUaOqvx!W0LG_=+~Ki?G>%(h)ObGD=WtdktDOc?c-x z#LHqj)EC~;%YXJSLfhndx7w;VP(b;0g?Z_mKiSpwF*2|v>SXb?^!ApoHqWjXd7|js zU{ap7Vu|~OCX1S(KuY=IHq%dW6O3 zae{Bd`=3qZ$0ED;NyD1_qoBewGEv5}*X7fX^JG@M5GYoiaodJAA+RZXiSylie&bHr>ogVM zCgw)vfld-=a@;;_w26fan@^lCz=mCD{WgFAvSW#Fx;5mphQ2WT2W)VVii?=dm43q% zw}TMfW@1FKGw2ne4Yviw>USuTzbGH9lGVZy(@5w*YmAmKh!>b>dH3# z6;*v3nwGeD00Vv?5s)%J>zY}nl>g?bi|HzqVDvOW9r zd-Hn}P$-L2A#HoYA(pmRrhQh)9W36e$9Q3=49%9!{Ub9g6J0yS5mb|gu3AQrf1nL} zwPQ8)ZH4tr;UPduR>G14DPKVJNri#S=gX{cUG7?Nh}fXN{C4urk*`yYp>{5ms>GF! zr^LfYVu}SGS0Dhp^0RUO*%p6`jBW0O#PED-Jf9{Ud*IDkn;DrbU4Kk2C4fkIAzBBO z@A~wj>&>aXLA^xAo@K3APz)O)uvus-1-fFH_neJ|YB$T0NA2Wz4ObdN8 z_1IS{OMP1~;Q5w0hl+h!d^$@kt3wHQb7U*!iOZBj7B6DZB}GJ5hwNr_RH`{U+0IrEk1IQ7fyEqq*8K7Jk#a{gLe_z_SuBXkVU@m{p>9df$FHzm&uN%F6M8l zZq~I+$^GSr0x4bo#xE&j7 zAAPq)ey=pu*`J7k1p8`_OtykwI=*%8b&I{crk$FginbN>Lz`YW^DmkrK|swjI>v=8 zmWQANd@1x%H5xTTOgyZ2taOY14iJtWQqFF#pLJ!KS;|tSM1Dzdri9;k3Ed%3@5IyLhtR+N;3BXhct;H3waC!f z{SQ(O;VgrDOifK~@!bCTSY76kVcm~YChQoE*(LQ9*?5<7y<9zvusZ@kA#z39lAS>2 z-Wtts9lG7jubovQDJ!znN>{qGbV7lhhUn}?g6=~L?*0B{0nSg$n4Z%Ye&FaI9gf7r ztv4iH!S{w)Q$wp5&g{ZFqqsOAN-pZf-^Lu6(Gpo2F_bhx2M46Rs;uP0f`DMqWfYq= zJ6*wdMBak|qPn%T9HKc^X&69Plq-eL?NoZ`{r+Yes-?{9V|DAW^m6=zy14Jy9=o?np}dL{XS?J!jYFu8m?=na#Bm15JYh8`iy6k)3x`rZOf81E}} z<`&=hCP3Kr^N!xgPlaV{Wk_eC;oB3)@TbU5>+w_Y!ZRT%<3Eu#MKU_aKH{JLiLzr8 zzq|JFQficcKBYGfkeDI|?(<+lKR0A1=iLP22?zqoUa8^HQJu|uUrflawXaXE-J*&; z!oulIJk4m^zWz` zgycv2UxDq96^poeN^rHeL{eY_Rc%fRb_xM(lhv+b3L!RdQN5JbCiosl8St{Sv(_8t z+`Dxfq_^k6q1n}0+pGNqT)`D(kSLRc7lZ_^*Z2&wS$4jl0u68t4(l*@Fn~SdV#vu1 zfvPsg?T@FFkf^4*O7bZMBO+eJ3@9djdLB$GV>Payoks)^=&+U+ud4uZ+XO!zW#T!I zwn%oLWjDTi#vcSlz#J{o(XY*~0hmpq+LSo}SSIyfZ4qlnziK!#IbLvPImVIW_n~Y* zMq4Bdy8`1TpOnll8Zo+9e#dr9fsZXun3;%yH-%VDoUuRfe3~kstUi`~z(wgui8v=H;(e%~QP>C(Jpn$j}P_LKGr>wu9sMP4VIs!97pwrR9tW!Sw80 z)AWX)9${`IHV|GwsB;L92S;^i>iOX=h5GTrYmIM#1eUY!i5)w=W094?NZZT$0vQ8? za0ac(>yYS`NEv)MG~HVGVXEJ-;nsEMbqcD?R&FlhycfeGJA}Fxw9A0SFP_wJ5 zs$FgHgj0KVM?2v428N7A@-kCV*mWFtM=>RV(cD=z3AUM!dL6>av(^Se?Dy5EqzI*6 zkh&yq8me92V;a z)jymA+VG%|^q!830DUJ-oD={~DV)t$Ejm3A(-L}S3KuYi&H}o1W;!~QPeWk($~T_ z-ThwjUuzdc2mS;7ckmwwNPEy5Al?;4rxU%=dqRMUY3Exd~o- zQWnPcrn~s^ifjry<=N)RfV@`KNT6>bVnaXe5Mx9yzIPVUeLSlFmIB6XDJA;NfT)-#QL^Vp9#eziAJ#vup<(oAPV7*CbDW*e zrJN9p7@TtqO(+6{*ck;I4EA*;(YyfM;`-imbA$Qit)CT?G4Kp(44BMn0LBi@AmSfD zSU0^6Ctw#nx22!z-8_S`N3WYeT;%gxXC#0O0y_T1GY}~&C^3ke^2shf2STU!y)K~} zp%ia3;D}>LJpcG56Vb(B${2nM6-|l4w$xV=0`*wo8L2w>UoffwS>Z48@8pna?N!|+ z^tT3SR((8$PiYL;_aEewAF&hc(fAjb5p>0cGeE=N7W7%hNb zfo7Ej28i{IoyXOgvz7cSjJuwuR_~i(OlXh@YaMJ;9(-?Z>9menSc@6U;ODQ$>;FUN zaN3`X$4rgF9LTHf_7;qXwe8Imr2T9bA0DEKedo@sz^!jOaox+x!& z&kKtZhdtA2eEIWNrBpMbvg;l-q`;?w7tD@PtSV4=SvXI4%s);PIisO{Y}r@d$v7<$ z5Erw186zmk;X$b%tWo`Ny(nnn=lfo_BdP?HlaT;KedS9#ID-Jv{&#IiY|iv5oFWG_ z(=KwK{9!y1!trLq@@LNEx00k?ZygGlCXXg>5c=Qgo7x~H;TU6QB{D~ z3(j!DXa4nH4hs8$bMw3GRnv*q95{_QL=Lv31djM=z9<+c2Ou`_Eb6Q_=SNg;Ulz)3 zZoFJxZkgc}R1LJ$$|oed%_4xP*C<8Y4%H=@Mea<8%0`Z*}p~I!or$p(=(7*m;u{a zhkU!qnr_H;xvvXW!eesH)wt;v%veDn1DYG+&(Gi-e(kGHqV^O{Qln#{__hagi9&&oj6OspKFAoUO|kx%sctcvY6^F zD~)IWsj&=@xYmqj@+N_cJXQ6OGP1*wn3ETE<%_fpDi1}qGwe1c^^Ua}P`siM9zmX; zysmxfKCgCxjzFPZQL1~)Qw?w*_Rh+kM*)8@zN4)sM1X*-q?_S-b9U_(-|RKxw=jo& zF&=k|_=+#L)qB>yehSsp?BcXfJCy_?SwpksC=E$4ls&%{;vbJHpcFf{+)%8DWlA8}_Az(3iy~ zU%N9@3J!Ji)WKNl7F|4m=2LQbW1#@5^If)|7Y0At&}a5Zs37*sl(5EQ5nvOuTc-QC zzJ51n7j{?cn@uWjns(|t6U;!$0_H*vrZQm+A^!KP*dBNc!g|7gvHriF8lnuzJ2NVO zDsD>8H4TXCYOkpI99VTIkZuj(XCchK#?w{Us!_aO)|> zzjP6`eL@f~(e+CG=Q*mbEry8$;EE9x2QWXv^mQJRq;js0$REcx|SoFaX8jxbuG$Y0#0yhUk@HGhU|H?S5o%}W+YHqOU$@j$v(w*oX23W!Fy zzQcoho6YnV7lH#fXPbIzcj1913NZ#xDkv~lCAMdgDPitD*s>VF;eFWqnCGFS9J+`D zG_}&Fm|b3(FYfuy8h$@E`kXz!ron8icu8BnF~b1r|EbHU%Y4FZ9+%|xr===i@y~4P zxiSga454wKJ-k){eq~L6SO5>7M>}H(v|cyX6fFon!NTw&%bza9@a9Fn5#04>p;72l zd{5@wI|RKK6wa`B+CM)09kce@U{GAf-`=@*U#=YtPF@vCwv-uotu6gPctv~L|3LC+ zAsfyC;zcccC&khJsY&+_GcUB!);RaGw;SG^|D4NOOZ9un#6U$M_V1{NE8gXAV9+ZP zM(-foT4!Edp-TE&Qt)eAo9=8=gZ+6h#YEQhK-?Y;^cNsL*xom;6VBb`thd?g0(E0f zIKWVs3-JHs&+U%^6`rZF$E)*Bp>p`|8U2BEo7GpehTYMNvfH3OMMy#+kyPs zMf6HL?9z_`Hiq{`kG0ymX0^kAzEM|(mSrgJIuU}N%*yRNVAr#B zT6zTos9s_y$nXb>)2oUpZs5eV#A9fx;9V|$hnzaFeo$NqsXU%XP!XxuV$iPstl0HM z1c{sXt%zcJltj!)#Z7FPCq`Szetw92zfJjxhCbac}1q!3gW|KN?;`s;$Dvln$F_WMscf>ylX zcgl0PR#YII7RLWqbenK*(ksbpC7ZpDvFCsR8<}(gx32YnXSZdaHwPv*wLU4)N3-j! zW0A0BQ}cILNCt+-CJV>5c#)?Mgfo23L45dc=(eKEzH8Cq@!Cl2WcX$;!z6|0I*qRI5cxqm$FNp=Vp6 z`2V0vr-yIR+m<5=dYqhl=*~_L&#b`3MA3N3&d}A_l%rpwf7VL6*)+@~k4g=p5JH<$ zDeuq9(*AOb5ZJMR2At^ffAws(Z>t+$}R>I#|ogU=bG)GR+glz2I&VP|klS9tvhkA=Bh-tV_+7*iZ))ly~Ww`y*x?6z!W4R@&1M9t5Ir(X(XH0>p(qVe^TVg`E+u+G5S zcKw<6UCKm%SAU4;9?pYnMw>;^GK(Mk<`ix`G&CDQBNwUlDLvo0nZ2@`m9i#37)r} z$GLwwkfj}b86>I6-WIc^CyM8^Nbe)RU9jSPh)*GRSJ@`%7YK;}AXJ;?MHLhoiaGxg zNBBoL`S>+B=hNSd68ZN;eS-R(=ZCo1vHG1m$f8 z&fX#7mzT>PJywk&xh265fgf~zb%VaXzeNCw(-hb8fqwv4@Qdfru`Ml4c4pkN8kz+m z#=Nf3y^PJyMa2&w%MuVKHYT|okpw9A8Lb%hbCS2|C`{;iNQrY}QDKJ)f3n?<{$fuZ z4gd<6rQ7JEH_2%B#cHk8Ur?|XB*FZ}w`$}oG8frKLC7F!A87fi zBF*Ccqe}IU1$O?iUj^d2qu|vVHv7jQB8*!aQBV6LGoYc$kjF4h7XfhLyQ1k<3VaKd z95!r`oiKwl68GehvM8twEH%lnOqbCIw#m0!uMYmP(rx{@Bi_V&ih9zE3QvAToapmI zgwx)nc9Jn1uNJZU)TR}vJz8u7(JMU0wvyqRfS)V;zMnEK7|OahP)83YwV zrFToYY3@CsBC@KoT(J<8^lvZidF>UVxCrn#xq*4`Om!nkitmXjyzsI5oz2lYzEII& zV($e}1CQPJLC5En{iY@ZL7_c_+pbQv;9IOXR=2V1t@ms0EqOv_=5$H2>^t0sASUYOWEi-t;=8K zk1+f95N+xQZNOA1{%x~l((86X7au5*Uza6=gOtk2qrJ)k$v?6XGHoknxyKj;%cbii zUPNZtH~Q{R?OyJ$dPUbqM-xZkx0Ux^2MM8Ep4lo@ZTnCG&2B9$%_+LUJcj7zG~(um zhwLE_lM}vYJ@3*u&{fh^+~439h&Tl*87fyoie4+P<)|GMsxGwIGoQ`E`|qtCK8_!U zh<0W>-w^)8Wkm;#8*0$? zi_@-3Y@3IaciYavatars/_1xelerate.png avatars/alazymeme.png avatars/brian6932.png + avatars/explooosion_code.png avatars/fourtf.png avatars/hicupalot.png avatars/iprodigy.png diff --git a/src/autogenerated/ResourcesAutogen.cpp b/src/autogenerated/ResourcesAutogen.cpp index 9661241da..43f5e7c4d 100644 --- a/src/autogenerated/ResourcesAutogen.cpp +++ b/src/autogenerated/ResourcesAutogen.cpp @@ -7,6 +7,7 @@ Resources2::Resources2() this->avatars._1xelerate = QPixmap(":/avatars/_1xelerate.png"); this->avatars.alazymeme = QPixmap(":/avatars/alazymeme.png"); this->avatars.brian6932 = QPixmap(":/avatars/brian6932.png"); + this->avatars.explooosion_code = QPixmap(":/avatars/explooosion_code.png"); this->avatars.fourtf = QPixmap(":/avatars/fourtf.png"); this->avatars.hicupalot = QPixmap(":/avatars/hicupalot.png"); this->avatars.iprodigy = QPixmap(":/avatars/iprodigy.png"); diff --git a/src/autogenerated/ResourcesAutogen.hpp b/src/autogenerated/ResourcesAutogen.hpp index 7aed77a2b..b1ea09813 100644 --- a/src/autogenerated/ResourcesAutogen.hpp +++ b/src/autogenerated/ResourcesAutogen.hpp @@ -12,6 +12,7 @@ public: QPixmap _1xelerate; QPixmap alazymeme; QPixmap brian6932; + QPixmap explooosion_code; QPixmap fourtf; QPixmap hicupalot; QPixmap iprodigy; From 9fd00a9c6ce4e8791e785c0143c2fc49fadd0bc9 Mon Sep 17 00:00:00 2001 From: nerix Date: Thu, 8 Sep 2022 21:18:32 +0200 Subject: [PATCH 012/946] docs: add CLion debugging info (#3954) --- .gitignore | 4 ++++ BUILDING_ON_WINDOWS.md | 27 ++++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 00a1d7659..9ef6b83f8 100644 --- a/.gitignore +++ b/.gitignore @@ -111,3 +111,7 @@ Dependencies_* # vcpkg vcpkg_installed/ + +# NatVis files +qt5.natvis +qt6.natvis diff --git a/BUILDING_ON_WINDOWS.md b/BUILDING_ON_WINDOWS.md index 8533f02fa..e0ddf877e 100644 --- a/BUILDING_ON_WINDOWS.md +++ b/BUILDING_ON_WINDOWS.md @@ -150,7 +150,6 @@ Now open the project in CLion. You will be greeted with the _Open Project Wizard ``` -DCMAKE_PREFIX_PATH=C:\Qt\5.15.2\msvc2019_64\lib\cmake\Qt5 -DUSE_CONAN=ON --DCMAKE_CXX_FLAGS=/bigobj ``` and the _Build Directory_ to `build`. @@ -189,3 +188,29 @@ Now you can run the `chatterino | Debug` configuration. If you want to run the portable version of Chatterino, create a file called `modes` inside of `build/bin` and write `portable` into it. + +### Debugging + +To visualize QT types like `QString`, you need to inform CLion and LLDB +about these types. + +1. Set `Enable NatVis renderers for LLDB option` + in `Settings | Build, Execution, Deployment | Debugger | Data Views | C/C++` (should be enabled by default). +2. Use the official NatVis file for QT from [`qt-labs/vstools`](https://github.com/qt-labs/vstools) by saving them to + the project root using PowerShell: + + + +```powershell +(iwr "https://github.com/qt-labs/vstools/raw/dev/QtVsTools.Package/qt5.natvis.xml").Content -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 +``` + +Now you can debug the application and see QT types rendered correctly. +If this didn't work for you, try following +the [tutorial from JetBrains](https://www.jetbrains.com/help/clion/qt-tutorial.html#debug-renderers). From 7337e93a2721793ffecb5e9a941362970b1a11d9 Mon Sep 17 00:00:00 2001 From: Felanbird <41973452+Felanbird@users.noreply.github.com> Date: Thu, 8 Sep 2022 15:19:13 -0400 Subject: [PATCH 013/946] Remove comment suggesting `Build Qt` is a Linux only build step (#3955) * Remove comment suggesting `Build Qt` is a Linux only build step * I forgot to no ci [no ci] Co-authored-by: pajlada --- .github/workflows/build.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 507a6e923..252fb7e83 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -45,7 +45,6 @@ jobs: path: "${{ github.workspace }}/qt/" key: ${{ runner.os }}-QtCache-${{ matrix.qt-version }} - # LINUX - name: Install Qt uses: jurplel/install-qt-action@v2 with: From 5655a7d71889b33b06675d72362eb998adb82fbf Mon Sep 17 00:00:00 2001 From: pajlada Date: Sun, 11 Sep 2022 14:32:08 +0200 Subject: [PATCH 014/946] Include network response body in errors (#3987) --- CHANGELOG.md | 1 + src/common/NetworkPrivate.cpp | 4 ++-- tests/src/NetworkRequest.cpp | 5 +++++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f76e6e242..311eb76c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ - Dev: Use Game Name returned by Get Streams instead of querying it from the Get Games API. (#3662) - Dev: Batch checking live status for all channels after startup. (#3757, #3762, #3767) - Dev: Move most command context into the command controller. (#3824) +- Dev: Error NetworkResults now include the body data. (#3987) ## 2.3.5 diff --git a/src/common/NetworkPrivate.cpp b/src/common/NetworkPrivate.cpp index c49fcff97..dcca585eb 100644 --- a/src/common/NetworkPrivate.cpp +++ b/src/common/NetworkPrivate.cpp @@ -218,8 +218,8 @@ void loadUncached(const std::shared_ptr &data) QString(data->payload_)); } // TODO: Should this always be run on the GUI thread? - postToThread([data, code = status.toInt()] { - data->onError_(NetworkResult({}, code)); + postToThread([data, code = status.toInt(), reply] { + data->onError_(NetworkResult(reply->readAll(), code)); }); } diff --git a/tests/src/NetworkRequest.cpp b/tests/src/NetworkRequest.cpp index 96d62ca4b..9c788ecee 100644 --- a/tests/src/NetworkRequest.cpp +++ b/tests/src/NetworkRequest.cpp @@ -145,6 +145,11 @@ TEST(NetworkRequest, Error) .onError([code, &mut, &requestDone, &requestDoneCondition, url](NetworkResult result) { EXPECT_EQ(result.status(), code); + if (code == 402) + { + EXPECT_EQ(result.getData(), + QString("Fuck you, pay me!").toUtf8()); + } { std::unique_lock lck(mut); From be72d73c3d9980e320ab16d678a9542e3fe5837c Mon Sep 17 00:00:00 2001 From: nerix Date: Sun, 11 Sep 2022 16:37:13 +0200 Subject: [PATCH 015/946] feat: add `Go to message` action in various places (#3953) * feat: add `Go to message` action in search popup * chore: add changelog entry * fix: only scroll if the scrollbar is shown * fix: go to message when view isn't focused * feat: animate highlighted message * fix: missing includes * fix: order of initialization * fix: add `ChannelView::mayContainMessage` to filter messages * feat: add `Go to message` action in `/mentions` * fix: ignore any mentions channel when searching for split * feat: add `Go to message` action in reply-threads * fix: remove redundant `source` parameter * feat: add `Go to message` action in user-cards * feat: add link to deleted message * fix: set current time to 0 when starting animation * chore: update changelog * fix: add default case (unreachable) * chore: removed unused variable * fix: search in mentions * fix: always attempt to focus split * fix: rename `Link::MessageId` to `Link::JumpToMessage` * fix: rename `selectAndScrollToMessage` to `scrollToMessage` * fix: rename internal `scrollToMessage` to `scrollToMessageLayout` * fix: deleted message link in search popup * chore: reword explanation * fix: use for-loop instead of `std::find_if` * refactor: define highlight colors in `BaseTheme` * core: replace `iff` with `if` * fix: only return if the message found * Reword/phrase/dot changelog entries Co-authored-by: pajlada --- CHANGELOG.md | 2 + src/BaseTheme.cpp | 6 + src/BaseTheme.hpp | 3 + src/messages/Link.hpp | 1 + src/messages/layouts/MessageLayout.cpp | 5 + src/messages/layouts/MessageLayout.hpp | 1 + src/providers/twitch/TwitchMessageBuilder.cpp | 27 ++- src/singletons/WindowManager.cpp | 5 + src/singletons/WindowManager.hpp | 9 + src/widgets/Notebook.cpp | 24 +++ src/widgets/helper/ChannelView.cpp | 203 +++++++++++++++++- src/widgets/helper/ChannelView.hpp | 33 +++ src/widgets/helper/SearchPopup.cpp | 29 +++ src/widgets/helper/SearchPopup.hpp | 8 + 14 files changed, 344 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 311eb76c8..c87b8d12d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,8 @@ - Minor: Add settings to toggle BTTV/FFZ global/channel emotes (#3935) - Minor: Add AutoMod message flag filter. (#3938) - Minor: Added whitespace trim to username field in nicknames (#3946) +- Minor: Added `Go to message` context menu action to search popup, mentions, usercard and reply threads. (#3953) +- Minor: Added link back to original message that was deleted. (#3953) - Bugfix: Fix crash that can occur when closing and quickly reopening a split, then running a command. (#3852) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fix crash that can occur when changing channels. (#3799) diff --git a/src/BaseTheme.cpp b/src/BaseTheme.cpp index 4c58ccba2..98f8a2df1 100644 --- a/src/BaseTheme.cpp +++ b/src/BaseTheme.cpp @@ -168,6 +168,12 @@ void AB_THEME_CLASS::actuallyUpdate(double hue, double multiplier) // this->messages.seperator = // this->messages.seperatorInner = + int complementaryGray = this->isLightTheme() ? 20 : 230; + this->messages.highlightAnimationStart = + QColor(complementaryGray, complementaryGray, complementaryGray, 110); + this->messages.highlightAnimationEnd = + QColor(complementaryGray, complementaryGray, complementaryGray, 0); + // Scrollbar this->scrollbars.background = QColor(0, 0, 0, 0); // this->scrollbars.background = splits.background; diff --git a/src/BaseTheme.hpp b/src/BaseTheme.hpp index 2d6ee5cdc..e04fce285 100644 --- a/src/BaseTheme.hpp +++ b/src/BaseTheme.hpp @@ -74,6 +74,9 @@ public: // QColor seperator; // QColor seperatorInner; QColor selection; + + QColor highlightAnimationStart; + QColor highlightAnimationEnd; } messages; /// SCROLLBAR diff --git a/src/messages/Link.hpp b/src/messages/Link.hpp index f6a48a7d3..2692ace69 100644 --- a/src/messages/Link.hpp +++ b/src/messages/Link.hpp @@ -25,6 +25,7 @@ public: CopyToClipboard, ReplyToMessage, ViewThread, + JumpToMessage, }; Link(); diff --git a/src/messages/layouts/MessageLayout.cpp b/src/messages/layouts/MessageLayout.cpp index f0f01f933..23917f2a5 100644 --- a/src/messages/layouts/MessageLayout.cpp +++ b/src/messages/layouts/MessageLayout.cpp @@ -66,6 +66,11 @@ int MessageLayout::getHeight() const return container_->getHeight(); } +int MessageLayout::getWidth() const +{ + return this->container_->getWidth(); +} + // Layout // return true if redraw is required bool MessageLayout::layout(int width, float scale, MessageElementFlags flags) diff --git a/src/messages/layouts/MessageLayout.hpp b/src/messages/layouts/MessageLayout.hpp index 33c1fad72..2de8ed987 100644 --- a/src/messages/layouts/MessageLayout.hpp +++ b/src/messages/layouts/MessageLayout.hpp @@ -40,6 +40,7 @@ public: const MessagePtr &getMessagePtr() const; int getHeight() const; + int getWidth() const; MessageLayoutFlags flags; diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index 38572b6e2..d04462d7c 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -1471,15 +1471,17 @@ void TwitchMessageBuilder::deletionMessage(const MessagePtr originalMessage, MessageColor::System); if (originalMessage->messageText.length() > 50) { - builder->emplace( - originalMessage->messageText.left(50) + "…", - MessageElementFlag::Text, MessageColor::Text); + builder + ->emplace(originalMessage->messageText.left(50) + "…", + MessageElementFlag::Text, MessageColor::Text) + ->setLink({Link::JumpToMessage, originalMessage->id}); } else { - builder->emplace(originalMessage->messageText, - MessageElementFlag::Text, - MessageColor::Text); + builder + ->emplace(originalMessage->messageText, + MessageElementFlag::Text, MessageColor::Text) + ->setLink({Link::JumpToMessage, originalMessage->id}); } builder->message().timeoutUser = "msg:" + originalMessage->id; } @@ -1511,14 +1513,17 @@ void TwitchMessageBuilder::deletionMessage(const DeleteAction &action, MessageColor::System); if (action.messageText.length() > 50) { - builder->emplace(action.messageText.left(50) + "…", - MessageElementFlag::Text, - MessageColor::Text); + builder + ->emplace(action.messageText.left(50) + "…", + MessageElementFlag::Text, MessageColor::Text) + ->setLink({Link::JumpToMessage, action.messageId}); } else { - builder->emplace( - action.messageText, MessageElementFlag::Text, MessageColor::Text); + builder + ->emplace(action.messageText, MessageElementFlag::Text, + MessageColor::Text) + ->setLink({Link::JumpToMessage, action.messageId}); } builder->message().timeoutUser = "msg:" + action.messageId; } diff --git a/src/singletons/WindowManager.cpp b/src/singletons/WindowManager.cpp index f7f555d66..64d6cf7f4 100644 --- a/src/singletons/WindowManager.cpp +++ b/src/singletons/WindowManager.cpp @@ -320,6 +320,11 @@ void WindowManager::select(SplitContainer *container) this->selectSplitContainer.invoke(container); } +void WindowManager::scrollToMessage(const MessagePtr &message) +{ + this->scrollToMessageSignal.invoke(message); +} + QPoint WindowManager::emotePopupPos() { return this->emotePopupPos_; diff --git a/src/singletons/WindowManager.hpp b/src/singletons/WindowManager.hpp index 5ad9cb890..5b3ee7cc9 100644 --- a/src/singletons/WindowManager.hpp +++ b/src/singletons/WindowManager.hpp @@ -15,6 +15,7 @@ class Settings; class Paths; class Window; class SplitContainer; +class ChannelView; enum class MessageElementFlag : int64_t; using MessageElementFlags = FlagsEnum; @@ -66,6 +67,13 @@ public: void select(Split *split); void select(SplitContainer *container); + /** + * Scrolls to the message in a split that's not + * a mentions view and focuses the split. + * + * @param message Message to scroll to. + */ + void scrollToMessage(const MessagePtr &message); QPoint emotePopupPos(); void setEmotePopupPos(QPoint pos); @@ -105,6 +113,7 @@ public: pajlada::Signals::Signal selectSplit; pajlada::Signals::Signal selectSplitContainer; + pajlada::Signals::Signal scrollToMessageSignal; private: static void encodeNodeRecursively(SplitContainer::Node *node, diff --git a/src/widgets/Notebook.cpp b/src/widgets/Notebook.cpp index 4ed2e2c19..b1ccf4a74 100644 --- a/src/widgets/Notebook.cpp +++ b/src/widgets/Notebook.cpp @@ -8,6 +8,7 @@ #include "util/InitUpdateButton.hpp" #include "widgets/Window.hpp" #include "widgets/dialogs/SettingsDialog.hpp" +#include "widgets/helper/ChannelView.hpp" #include "widgets/helper/NotebookButton.hpp" #include "widgets/helper/NotebookTab.hpp" #include "widgets/splits/Split.hpp" @@ -1006,6 +1007,29 @@ SplitNotebook::SplitNotebook(Window *parent) [this](SplitContainer *sc) { this->select(sc); }); + + this->signalHolder_.managedConnect( + getApp()->windows->scrollToMessageSignal, + [this](const MessagePtr &message) { + for (auto &&item : this->items()) + { + if (auto sc = dynamic_cast(item.page)) + { + for (auto *split : sc->getSplits()) + { + if (split->getChannel()->getType() != + Channel::Type::TwitchMentions) + { + if (split->getChannelView().scrollToMessage( + message)) + { + return; + } + } + } + } + } + }); } void SplitNotebook::showEvent(QShowEvent *) diff --git a/src/widgets/helper/ChannelView.cpp b/src/widgets/helper/ChannelView.cpp index 837df651a..6042c18d6 100644 --- a/src/widgets/helper/ChannelView.cpp +++ b/src/widgets/helper/ChannelView.cpp @@ -1,13 +1,16 @@ #include "ChannelView.hpp" #include +#include #include #include #include +#include #include #include #include #include +#include #include #include #include @@ -118,12 +121,23 @@ namespace { addPageLink("FFZ"); } } + + // Current function: https://www.desmos.com/calculator/vdyamchjwh + qreal highlightEasingFunction(qreal progress) + { + if (progress <= 0.1) + { + return 1.0 - pow(10.0 * progress, 3.0); + } + return 1.0 + pow((20.0 / 9.0) * (0.5 * progress - 0.5), 3.0); + } } // namespace ChannelView::ChannelView(BaseWidget *parent, Split *split, Context context) : BaseWidget(parent) , split_(split) , scrollBar_(new Scrollbar(this)) + , highlightAnimation_(this) , context_(context) { this->setMouseTracking(true); @@ -164,6 +178,12 @@ ChannelView::ChannelView(BaseWidget *parent, Split *split, Context context) // of any place where you can, or where it would make sense, // to tab to a ChannelVieChannelView this->setFocusPolicy(Qt::FocusPolicy::ClickFocus); + + this->setupHighlightAnimationColors(); + this->highlightAnimation_.setDuration(1500); + auto curve = QEasingCurve(); + curve.setCustomType(highlightEasingFunction); + this->highlightAnimation_.setEasingCurve(curve); } void ChannelView::initializeLayout() @@ -339,9 +359,18 @@ void ChannelView::themeChangedEvent() { BaseWidget::themeChangedEvent(); + this->setupHighlightAnimationColors(); this->queueLayout(); } +void ChannelView::setupHighlightAnimationColors() +{ + this->highlightAnimation_.setStartValue( + this->theme->messages.highlightAnimationStart); + this->highlightAnimation_.setEndValue( + this->theme->messages.highlightAnimationEnd); +} + void ChannelView::scaleChangedEvent(float scale) { BaseWidget::scaleChangedEvent(scale); @@ -392,7 +421,8 @@ void ChannelView::performLayout(bool causedByScrollbar) auto &messages = this->getMessagesSnapshot(); this->showingLatestMessages_ = - this->scrollBar_->isAtBottom() || !this->scrollBar_->isVisible(); + this->scrollBar_->isAtBottom() || + (!this->scrollBar_->isVisible() && !causedByScrollbar); /// Layout visible messages this->layoutVisibleMessages(messages); @@ -475,6 +505,7 @@ void ChannelView::updateScrollbar( { this->scrollBar_->setDesiredValue(0); } + this->showScrollBar_ = showScrollbar; this->scrollBar_->setMaximum(messages.size()); @@ -1088,6 +1119,86 @@ MessageElementFlags ChannelView::getFlags() const return flags; } +bool ChannelView::scrollToMessage(const MessagePtr &message) +{ + if (!this->mayContainMessage(message)) + { + return false; + } + + auto &messagesSnapshot = this->getMessagesSnapshot(); + if (messagesSnapshot.size() == 0) + { + return false; + } + + // TODO: Figure out if we can somehow binary-search here. + // Currently, a message only sometimes stores a QDateTime, + // but always a QTime (inaccurate on midnight). + // + // We're searching from the bottom since it's more likely for a user + // wanting to go to a message that recently scrolled out of view. + size_t messageIdx = messagesSnapshot.size() - 1; + for (; messageIdx < SIZE_MAX; messageIdx--) + { + if (messagesSnapshot[messageIdx]->getMessagePtr() == message) + { + break; + } + } + + if (messageIdx == SIZE_MAX) + { + return false; + } + + this->scrollToMessageLayout(messagesSnapshot[messageIdx].get(), messageIdx); + getApp()->windows->select(this->split_); + return true; +} + +bool ChannelView::scrollToMessageId(const QString &messageId) +{ + auto &messagesSnapshot = this->getMessagesSnapshot(); + if (messagesSnapshot.size() == 0) + { + return false; + } + + // We're searching from the bottom since it's more likely for a user + // wanting to go to a message that recently scrolled out of view. + size_t messageIdx = messagesSnapshot.size() - 1; + for (; messageIdx < SIZE_MAX; messageIdx--) + { + if (messagesSnapshot[messageIdx]->getMessagePtr()->id == messageId) + { + break; + } + } + + if (messageIdx == SIZE_MAX) + { + return false; + } + + this->scrollToMessageLayout(messagesSnapshot[messageIdx].get(), messageIdx); + getApp()->windows->select(this->split_); + return true; +} + +void ChannelView::scrollToMessageLayout(MessageLayout *layout, + size_t messageIdx) +{ + this->highlightedMessage_ = layout; + this->highlightAnimation_.setCurrentTime(0); + this->highlightAnimation_.start(QAbstractAnimation::KeepWhenStopped); + + if (this->showScrollBar_) + { + this->getScrollBar().setDesiredValue(messageIdx); + } +} + void ChannelView::paintEvent(QPaintEvent * /*event*/) { // BenchmarkGuard benchmark("paint"); @@ -1144,6 +1255,17 @@ void ChannelView::drawMessages(QPainter &painter) layout->paint(painter, DRAW_WIDTH, y, i, this->selection_, isLastMessage, windowFocused, isMentions); + if (this->highlightedMessage_ == layout) + { + painter.fillRect( + 0, y, layout->getWidth(), layout->getHeight(), + this->highlightAnimation_.currentValue().value()); + if (this->highlightAnimation_.state() == QVariantAnimation::Stopped) + { + this->highlightedMessage_ = nullptr; + } + } + y += layout->getHeight(); end = layout; @@ -2070,6 +2192,46 @@ void ChannelView::addMessageContextMenuItems( }); } } + + bool isSearch = this->context_ == Context::Search; + bool isReplyOrUserCard = (this->context_ == Context::ReplyThread || + this->context_ == Context::UserCard) && + this->split_; + bool isMentions = + this->channel()->getType() == Channel::Type::TwitchMentions; + if (isSearch || isMentions || isReplyOrUserCard) + { + const auto &messagePtr = layout->getMessagePtr(); + menu.addAction("Go to message", [this, &messagePtr, isSearch, + isMentions, isReplyOrUserCard] { + if (isSearch) + { + if (const auto &search = + dynamic_cast(this->parentWidget())) + { + search->goToMessage(messagePtr); + } + } + else if (isMentions) + { + getApp()->windows->scrollToMessage(messagePtr); + } + else if (isReplyOrUserCard) + { + // If the thread is in the mentions channel, + // we need to find the original split. + if (this->split_->getChannel()->getType() == + Channel::Type::TwitchMentions) + { + getApp()->windows->scrollToMessage(messagePtr); + } + else + { + this->split_->getChannelView().scrollToMessage(messagePtr); + } + } + }); + } } void ChannelView::addTwitchLinkContextMenuItems( @@ -2321,6 +2483,30 @@ void ChannelView::showUserInfoPopup(const QString &userName, userPopup->show(); } +bool ChannelView::mayContainMessage(const MessagePtr &message) +{ + switch (this->channel()->getType()) + { + case Channel::Type::Direct: + case Channel::Type::Twitch: + case Channel::Type::TwitchWatching: + case Channel::Type::Irc: + return this->channel()->getName() == message->channelName; + case Channel::Type::TwitchWhispers: + return message->flags.has(MessageFlag::Whisper); + case Channel::Type::TwitchMentions: + return message->flags.has(MessageFlag::Highlighted); + case Channel::Type::TwitchLive: + return message->flags.has(MessageFlag::System); + case Channel::Type::TwitchEnd: // TODO: not used? + case Channel::Type::None: // Unspecific + case Channel::Type::Misc: // Unspecific + return true; + default: + return true; // unreachable + } +} + void ChannelView::handleLinkClick(QMouseEvent *event, const Link &link, MessageLayout *layout) { @@ -2442,6 +2628,21 @@ void ChannelView::handleLinkClick(QMouseEvent *event, const Link &link, this->showReplyThreadPopup(layout->getMessagePtr()); } break; + case Link::JumpToMessage: { + if (this->context_ == Context::Search) + { + if (auto search = + dynamic_cast(this->parentWidget())) + { + search->goToMessageId(link.value); + } + } + else + { + this->scrollToMessageId(link.value); + } + } + break; default:; } diff --git a/src/widgets/helper/ChannelView.hpp b/src/widgets/helper/ChannelView.hpp index 44679e7b0..123e5cc7c 100644 --- a/src/widgets/helper/ChannelView.hpp +++ b/src/widgets/helper/ChannelView.hpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include #include @@ -83,6 +84,17 @@ public: const boost::optional &getOverrideFlags() const; void updateLastReadMessage(); + /** + * Attempts to scroll to a message in this channel. + * @return true if the message was found and highlighted. + */ + bool scrollToMessage(const MessagePtr &message); + /** + * Attempts to scroll to a message id in this channel. + * @return true if the message was found and highlighted. + */ + bool scrollToMessageId(const QString &id); + /// Pausing bool pausable() const; void setPausable(bool value); @@ -119,6 +131,13 @@ public: void showUserInfoPopup(const QString &userName, QString alternativePopoutChannel = QString()); + /** + * @brief This method is meant to be used when filtering out channels. + * It must return true if a message belongs in this channel. + * It might return true if a message doesn't belong in this channel. + */ + bool mayContainMessage(const MessagePtr &message); + pajlada::Signals::Signal mouseDown; pajlada::Signals::NoArgSignal selectionChanged; pajlada::Signals::Signal tabHighlightRequested; @@ -208,6 +227,14 @@ private: void enableScrolling(const QPointF &scrollStart); void disableScrolling(); + /** + * Scrolls to a message layout that must be from this view. + * + * @param layout Must be from this channel. + * @param messageIdx Must be an index into this channel. + */ + void scrollToMessageLayout(MessageLayout *layout, size_t messageIdx); + void setInputReply(const MessagePtr &message); void showReplyThreadPopup(const MessagePtr &message); bool canReplyToMessages() const; @@ -241,6 +268,7 @@ private: Scrollbar *scrollBar_; EffectLabel *goToBottom_; + bool showScrollBar_ = false; FilterSetPtr channelFilters_; @@ -272,6 +300,11 @@ private: QPointF currentMousePosition_; QTimer scrollTimer_; + // We're only interested in the pointer, not the contents + MessageLayout *highlightedMessage_; + QVariantAnimation highlightAnimation_; + void setupHighlightAnimationColors(); + struct { QCursor neutral; QCursor up; diff --git a/src/widgets/helper/SearchPopup.cpp b/src/widgets/helper/SearchPopup.cpp index d33465499..85d2e21f0 100644 --- a/src/widgets/helper/SearchPopup.cpp +++ b/src/widgets/helper/SearchPopup.cpp @@ -12,6 +12,7 @@ #include "messages/search/MessageFlagsPredicate.hpp" #include "messages/search/RegexPredicate.hpp" #include "messages/search/SubstringPredicate.hpp" +#include "singletons/WindowManager.hpp" #include "widgets/helper/ChannelView.hpp" namespace chatterino { @@ -106,6 +107,34 @@ void SearchPopup::addChannel(ChannelView &channel) this->updateWindowTitle(); } +void SearchPopup::goToMessage(const MessagePtr &message) +{ + for (const auto &view : this->searchChannels_) + { + if (view.get().channel()->getType() == Channel::Type::TwitchMentions) + { + getApp()->windows->scrollToMessage(message); + return; + } + + if (view.get().scrollToMessage(message)) + { + return; + } + } +} + +void SearchPopup::goToMessageId(const QString &messageId) +{ + for (const auto &view : this->searchChannels_) + { + if (view.get().scrollToMessageId(messageId)) + { + return; + } + } +} + void SearchPopup::updateWindowTitle() { QString historyName; diff --git a/src/widgets/helper/SearchPopup.hpp b/src/widgets/helper/SearchPopup.hpp index c927ff5b1..7a0b3677c 100644 --- a/src/widgets/helper/SearchPopup.hpp +++ b/src/widgets/helper/SearchPopup.hpp @@ -19,6 +19,14 @@ public: SearchPopup(QWidget *parent, Split *split = nullptr); virtual void addChannel(ChannelView &channel); + void goToMessage(const MessagePtr &message); + /** + * This method should only be used for searches that + * don't include a mentions channel, + * since it will only search in the opened channels (not globally). + * @param messageId + */ + void goToMessageId(const QString &messageId); protected: virtual void updateWindowTitle(); From 6a2c4fc098a3dd74799fb873cbe8e4aeedea9cac Mon Sep 17 00:00:00 2001 From: nerix Date: Wed, 14 Sep 2022 12:55:52 +0200 Subject: [PATCH 016/946] fix: retain text from input when replying (#3989) Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 2 +- src/widgets/splits/SplitInput.cpp | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c87b8d12d..677d96966 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unversioned -- Major: Added support for Twitch's Chat Replies. [Wiki Page](https://wiki.chatterino.com/Features/#message-replies) (#3722) +- Major: Added support for Twitch's Chat Replies. [Wiki Page](https://wiki.chatterino.com/Features/#message-replies) (#3722, #3989) - Major: Added multi-channel searching to search dialog via keyboard shortcut. (Ctrl+Shift+F by default) (#3694, #3875) - Minor: Added option to display tabs on the right and bottom. (#3847) - Minor: Added `is:first-msg` search option. (#3700) diff --git a/src/widgets/splits/SplitInput.cpp b/src/widgets/splits/SplitInput.cpp index 4a4c73f2d..ab635bd3d 100644 --- a/src/widgets/splits/SplitInput.cpp +++ b/src/widgets/splits/SplitInput.cpp @@ -978,9 +978,13 @@ void SplitInput::setReply(std::shared_ptr reply, if (this->enableInlineReplying_) { // Only enable reply label if inline replying - this->ui_.textEdit->setPlainText( - "@" + this->replyThread_->root()->displayName + " "); - this->ui_.textEdit->moveCursor(QTextCursor::EndOfBlock); + auto replyPrefix = "@" + this->replyThread_->root()->displayName + " "; + auto plainText = this->ui_.textEdit->toPlainText().trimmed(); + if (!plainText.startsWith(replyPrefix)) + { + this->ui_.textEdit->setPlainText(replyPrefix + plainText + " "); + this->ui_.textEdit->moveCursor(QTextCursor::EndOfBlock); + } this->ui_.replyLabel->setText("Replying to @" + this->replyThread_->root()->displayName); } From c6ebb70e05c4b8c4b220498afde17846c7874ba9 Mon Sep 17 00:00:00 2001 From: nerix Date: Wed, 14 Sep 2022 13:21:01 +0200 Subject: [PATCH 017/946] fix: disable `autoInvoke` for emote settings (#3990) Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 2 +- src/Application.cpp | 32 ++++++++++++++++++++------------ 2 files changed, 21 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 677d96966..e7cae8c3b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,7 @@ - Minor: Warn when parsing an environment variable fails. (#3904) - Minor: Load missing messages from Recent Messages API upon reconnecting (#3878, #3932) - Minor: Reduced image memory usage when running Chatterino for a long time. (#3915) -- Minor: Add settings to toggle BTTV/FFZ global/channel emotes (#3935) +- Minor: Add settings to toggle BTTV/FFZ global/channel emotes (#3935, #3990) - Minor: Add AutoMod message flag filter. (#3938) - Minor: Added whitespace trim to username field in nicknames (#3946) - Minor: Added `Go to message` context menu action to search popup, mentions, usercard and reply threads. (#3953) diff --git a/src/Application.cpp b/src/Application.cpp index a8963384c..bd37ce2e0 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -179,18 +179,26 @@ int Application::run(QApplication &qtApp) this->windows->forceLayoutChannelViews(); }); - getSettings()->enableBTTVGlobalEmotes.connect([this] { - this->twitch->reloadBTTVGlobalEmotes(); - }); - getSettings()->enableBTTVChannelEmotes.connect([this] { - this->twitch->reloadAllBTTVChannelEmotes(); - }); - getSettings()->enableFFZGlobalEmotes.connect([this] { - this->twitch->reloadFFZGlobalEmotes(); - }); - getSettings()->enableFFZChannelEmotes.connect([this] { - this->twitch->reloadAllFFZChannelEmotes(); - }); + getSettings()->enableBTTVGlobalEmotes.connect( + [this] { + this->twitch->reloadBTTVGlobalEmotes(); + }, + false); + getSettings()->enableBTTVChannelEmotes.connect( + [this] { + this->twitch->reloadAllBTTVChannelEmotes(); + }, + false); + getSettings()->enableFFZGlobalEmotes.connect( + [this] { + this->twitch->reloadFFZGlobalEmotes(); + }, + false); + getSettings()->enableFFZChannelEmotes.connect( + [this] { + this->twitch->reloadAllFFZChannelEmotes(); + }, + false); return qtApp.exec(); } From 4f1976b1bee88708ecce5c0ce3b12f7b5d3a8a8c Mon Sep 17 00:00:00 2001 From: pajlada Date: Fri, 16 Sep 2022 23:15:28 +0200 Subject: [PATCH 018/946] Migrate /color command to Helix API (#3988) --- CHANGELOG.md | 1 + .../commands/CommandController.cpp | 74 +++++++++++++++++++ src/providers/twitch/api/Helix.cpp | 73 ++++++++++++++++++ src/providers/twitch/api/Helix.hpp | 24 ++++++ src/providers/twitch/api/README.md | 12 +++ src/util/Twitch.cpp | 34 +++++++++ src/util/Twitch.hpp | 8 ++ tests/src/HighlightController.cpp | 8 ++ tests/src/UtilTwitch.cpp | 36 +++++++++ 9 files changed, 270 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e7cae8c3b..e094ffa6c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ - Minor: Added whitespace trim to username field in nicknames (#3946) - Minor: Added `Go to message` context menu action to search popup, mentions, usercard and reply threads. (#3953) - Minor: Added link back to original message that was deleted. (#3953) +- Minor: Migrate /color command to Helix API. (#3988) - Bugfix: Fix crash that can occur when closing and quickly reopening a split, then running a command. (#3852) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fix crash that can occur when changing channels. (#3799) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 702262510..d610cd752 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -2,6 +2,7 @@ #include "Application.hpp" #include "common/Env.hpp" +#include "common/QLogging.hpp" #include "common/SignalVector.hpp" #include "controllers/accounts/AccountController.hpp" #include "controllers/commands/Command.hpp" @@ -1195,6 +1196,79 @@ void CommandController::initialize(Settings &, Paths &paths) crossPlatformCopy(words.mid(1).join(" ")); return ""; }); + + this->registerCommand("/color", [](const QStringList &words, auto channel) { + auto user = getApp()->accounts->twitch.getCurrent(); + + // Avoid Helix calls without Client ID and/or OAuth Token + if (user->isAnon()) + { + channel->addMessage(makeSystemMessage( + "You must be logged in to use the /color command")); + return ""; + } + + auto colorString = words.value(1); + + if (colorString.isEmpty()) + { + channel->addMessage(makeSystemMessage( + QString("Usage: /color - Color must be one of Twitch's " + "supported colors (%1) or a hex code (#000000) if you " + "have Turbo or Prime.") + .arg(VALID_HELIX_COLORS.join(", ")))); + return ""; + } + + cleanHelixColorName(colorString); + + getHelix()->updateUserChatColor( + user->getUserId(), colorString, + [colorString, channel] { + QString successMessage = + QString("Your color has been changed to %1.") + .arg(colorString); + channel->addMessage(makeSystemMessage(successMessage)); + }, + [colorString, channel](auto error, auto message) { + QString errorMessage = + QString("Failed to change color to %1 - ").arg(colorString); + + switch (error) + { + case HelixUpdateUserChatColorError::UserMissingScope: { + errorMessage += + "Missing required scope. Re-login with your " + "account and try again."; + } + break; + + case HelixUpdateUserChatColorError::InvalidColor: { + errorMessage += QString("Color must be one of Twitch's " + "supported colors (%1) or a " + "hex code (#000000) if you " + "have Turbo or Prime.") + .arg(VALID_HELIX_COLORS.join(", ")); + } + break; + + case HelixUpdateUserChatColorError::Forwarded: { + errorMessage += message + "."; + } + break; + + case HelixUpdateUserChatColorError::Unknown: + default: { + errorMessage += "An unknown error has occurred."; + } + break; + } + + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; + }); } void CommandController::save() diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 953d7d989..27f2ebcf0 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -770,6 +770,79 @@ void Helix::getChannelEmotes( .execute(); } +void Helix::updateUserChatColor( + QString userID, QString color, ResultCallback<> successCallback, + FailureCallback failureCallback) +{ + using Error = HelixUpdateUserChatColorError; + + QJsonObject payload; + + payload.insert("user_id", QJsonValue(userID)); + payload.insert("color", QJsonValue(color)); + + this->makeRequest("chat/color", QUrlQuery()) + .type(NetworkRequestType::Put) + .header("Content-Type", "application/json") + .payload(QJsonDocument(payload).toJson(QJsonDocument::Compact)) + .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + auto obj = result.parseJson(); + if (result.status() != 204) + { + qCWarning(chatterinoTwitch) + << "Success result for updating chat color was" + << result.status() << "but we only expected it to be 204"; + } + + successCallback(); + return Success; + }) + .onError([failureCallback](auto result) { + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (result.status()) + { + case 400: { + if (message.startsWith("invalid color", + Qt::CaseInsensitive)) + { + // Handle this error specifically since it allows us to list out the available colors + failureCallback(Error::InvalidColor, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 401: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + // Handle this error specifically because its API error is especially unfriendly + failureCallback(Error::UserMissingScope, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error changing user color:" + << 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 6e7ef55a6..5531ba22e 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -320,9 +320,21 @@ enum class HelixAutoModMessageError { MessageNotFound, }; +enum class HelixUpdateUserChatColorError { + Unknown, + UserMissingScope, + InvalidColor, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; + class IHelix { public: + template + using FailureCallback = std::function; + // https://dev.twitch.tv/docs/api/reference#get-users virtual void fetchUsers( QStringList userIds, QStringList userLogins, @@ -440,6 +452,12 @@ public: ResultCallback> successCallback, HelixFailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#update-user-chat-color + virtual void updateUserChatColor( + QString userID, QString color, ResultCallback<> successCallback, + FailureCallback + failureCallback) = 0; + virtual void update(QString clientId, QString oauthToken) = 0; }; @@ -556,6 +574,12 @@ public: ResultCallback> successCallback, HelixFailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference#update-user-chat-color + void updateUserChatColor( + QString userID, QString color, 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 31a7e7424..b88976184 100644 --- a/src/providers/twitch/api/README.md +++ b/src/providers/twitch/api/README.md @@ -6,6 +6,18 @@ this folder describes what sort of API requests we do, what permissions are requ Full Helix API reference: https://dev.twitch.tv/docs/api/reference +### Adding support for a new endpoint + +If you're adding support for a new endpoint, these are the things you should know. + +1. Add a virtual function in the `IHelix` class. Naming should reflect the API name as best as possible. +1. Override the virtual function in the `Helix` class. +1. Mock the function in the `MockHelix` class in the `tests/src/HighlightController.cpp` file. +1. (Optional) Make a new error enum for the failure callback. + +For a simple example, see the `updateUserChatColor` function and its error enum `HelixUpdateUserChatColorError`. +The API is used in the "/color" command in [CommandController.cpp](../../../controllers/commands/CommandController.cpp) + ### Get Users URL: https://dev.twitch.tv/docs/api/reference#get-users diff --git a/src/util/Twitch.cpp b/src/util/Twitch.cpp index 3bf821929..12e751984 100644 --- a/src/util/Twitch.cpp +++ b/src/util/Twitch.cpp @@ -1,16 +1,37 @@ #include "util/Twitch.hpp" +#include "util/QStringHash.hpp" + #include #include +#include + namespace chatterino { namespace { const auto TWITCH_USER_LOGIN_PATTERN = R"(^[a-z0-9]\w{0,24}$)"; + // Remember to keep VALID_HELIX_COLORS up-to-date if a new color is implemented to keep naming for users consistent + const std::unordered_map HELIX_COLOR_REPLACEMENTS{ + {"blueviolet", "blue_violet"}, {"cadetblue", "cadet_blue"}, + {"dodgerblue", "dodger_blue"}, {"goldenrod", "golden_rod"}, + {"hotpink", "hot_pink"}, {"orangered", "orange_red"}, + {"seagreen", "sea_green"}, {"springgreen", "spring_green"}, + {"yellowgreen", "yellow_green"}, + }; + } // namespace +// Colors retreived from https://dev.twitch.tv/docs/api/reference#update-user-chat-color 2022-09-11 +// Remember to keep HELIX_COLOR_REPLACEMENTS up-to-date if a new color is implemented to keep naming for users consistent +extern const QStringList VALID_HELIX_COLORS{ + "blue", "blue_violet", "cadet_blue", "chocolate", "coral", + "dodger_blue", "firebrick", "golden_rod", "green", "hot_pink", + "orange_red", "red", "sea_green", "spring_green", "yellow_green", +}; + void openTwitchUsercard(QString channel, QString username) { QDesktopServices::openUrl("https://www.twitch.tv/popout/" + channel + @@ -57,4 +78,17 @@ QRegularExpression twitchUserLoginRegexp() return re; } +void cleanHelixColorName(QString &color) +{ + color = color.toLower(); + auto it = HELIX_COLOR_REPLACEMENTS.find(color); + + if (it == HELIX_COLOR_REPLACEMENTS.end()) + { + return; + } + + color = it->second; +} + } // namespace chatterino diff --git a/src/util/Twitch.hpp b/src/util/Twitch.hpp index 08cb8eb57..c3bb346a9 100644 --- a/src/util/Twitch.hpp +++ b/src/util/Twitch.hpp @@ -2,9 +2,12 @@ #include #include +#include namespace chatterino { +extern const QStringList VALID_HELIX_COLORS; + void openTwitchUsercard(const QString channel, const QString username); // stripUserName removes any @ prefix or , suffix to make it more suitable for command use @@ -25,4 +28,9 @@ QRegularExpression twitchUserLoginRegexp(); // Must not start with an underscore QRegularExpression twitchUserNameRegexp(); +// Cleans up a color name input for use in the Helix API +// Will help massage color names like BlueViolet to the helix-acceptible blue_violet +// Will also lowercase the color +void cleanHelixColorName(QString &color); + } // namespace chatterino diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index 81d9029dc..959ecc32f 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -209,6 +209,14 @@ public: HelixFailureCallback failureCallback), (override)); + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, updateUserChatColor, + (QString userID, QString color, + ResultCallback<> successCallback, + (FailureCallback + failureCallback)), + (override)); + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override)); }; diff --git a/tests/src/UtilTwitch.cpp b/tests/src/UtilTwitch.cpp index 36450490f..c02753ca3 100644 --- a/tests/src/UtilTwitch.cpp +++ b/tests/src/UtilTwitch.cpp @@ -264,3 +264,39 @@ TEST(UtilTwitch, UserNameRegexp) << qUtf8Printable(inputUserLogin) << " did not match as expected"; } } + +TEST(UtilTwitch, CleanHelixColor) +{ + struct TestCase { + QString inputColor; + QString expectedColor; + }; + + std::vector tests{ + {"foo", "foo"}, + {"BlueViolet", "blue_violet"}, + {"blueviolet", "blue_violet"}, + {"DODGERBLUE", "dodger_blue"}, + {"blUEviolet", "blue_violet"}, + {"caDEtblue", "cadet_blue"}, + {"doDGerblue", "dodger_blue"}, + {"goLDenrod", "golden_rod"}, + {"hoTPink", "hot_pink"}, + {"orANgered", "orange_red"}, + {"seAGreen", "sea_green"}, + {"spRInggreen", "spring_green"}, + {"yeLLowgreen", "yellow_green"}, + {"xDxD", "xdxd"}, + }; + + for (const auto &[inputColor, expectedColor] : tests) + { + QString actualColor = inputColor; + cleanHelixColorName(actualColor); + + EXPECT_EQ(actualColor, expectedColor) + << qUtf8Printable(inputColor) << " cleaned up to " + << qUtf8Printable(actualColor) << " instead of " + << qUtf8Printable(expectedColor); + } +} From 6e7b4d8ec76d2aac684fea00b715b254f0afb22c Mon Sep 17 00:00:00 2001 From: Aiden Date: Sun, 18 Sep 2022 12:19:22 +0100 Subject: [PATCH 019/946] Migrate /clear command to Helix API (#3994) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 1 + .../commands/CommandController.cpp | 86 +++++++++++++++++++ src/providers/twitch/api/Helix.cpp | 79 +++++++++++++++++ src/providers/twitch/api/Helix.hpp | 25 ++++++ tests/src/HighlightController.cpp | 8 ++ 5 files changed, 199 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e094ffa6c..1d87404dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ - Minor: Added `Go to message` context menu action to search popup, mentions, usercard and reply threads. (#3953) - Minor: Added link back to original message that was deleted. (#3953) - Minor: Migrate /color command to Helix API. (#3988) +- Minor: Migrate /clear command to Helix API. (#3994) - Bugfix: Fix crash that can occur when closing and quickly reopening a split, then running a command. (#3852) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fix crash that can occur when changing channels. (#3799) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index d610cd752..d04e4c05e 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -1269,6 +1269,92 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); + + auto deleteMessages = [](auto channel, const QString &messageID) { + const auto *commandName = messageID.isEmpty() ? "/clear" : "/delete"; + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + QString("The %1 command only works in Twitch channels") + .arg(commandName))); + return ""; + } + + auto user = getApp()->accounts->twitch.getCurrent(); + + // Avoid Helix calls without Client ID and/or OAuth Token + if (user->isAnon()) + { + channel->addMessage(makeSystemMessage( + QString("You must be logged in to use the %1 command.") + .arg(commandName))); + return ""; + } + + getHelix()->deleteChatMessages( + twitchChannel->roomId(), user->getUserId(), messageID, + []() { + // Success handling, we do nothing: IRC/pubsub-edge will dispatch the correct + // events to update state for us. + }, + [channel, messageID](auto error, auto message) { + QString errorMessage = + QString("Failed to delete chat messages - "); + + switch (error) + { + case HelixDeleteChatMessagesError::UserMissingScope: { + errorMessage += + "Missing required scope. Re-login with your " + "account and try again."; + } + break; + + case HelixDeleteChatMessagesError::UserNotAuthorized: { + errorMessage += + "you don't have permission to perform that action."; + } + break; + + case HelixDeleteChatMessagesError::MessageUnavailable: { + // Override default message prefix to match with IRC message format + errorMessage = + QString( + "The message %1 does not exist, was deleted, " + "or is too old to be deleted.") + .arg(messageID); + } + break; + + case HelixDeleteChatMessagesError::UserNotAuthenticated: { + errorMessage += "you need to re-authenticate."; + } + break; + + case HelixDeleteChatMessagesError::Forwarded: { + errorMessage += message + "."; + } + break; + + case HelixDeleteChatMessagesError::Unknown: + default: { + errorMessage += "An unknown error has occurred."; + } + break; + } + + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; + }; + + this->registerCommand( + "/clear", [deleteMessages](const QStringList &words, auto channel) { + (void)words; // unused + return deleteMessages(channel, QString()); + }); } void CommandController::save() diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 27f2ebcf0..8726424e8 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -843,6 +843,85 @@ void Helix::updateUserChatColor( .execute(); }; +void Helix::deleteChatMessages( + QString broadcasterID, QString moderatorID, QString messageID, + ResultCallback<> successCallback, + FailureCallback failureCallback) +{ + using Error = HelixDeleteChatMessagesError; + + QUrlQuery urlQuery; + + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + urlQuery.addQueryItem("moderator_id", moderatorID); + + if (!messageID.isEmpty()) + { + // If message ID is empty, it's equivalent to /clear + urlQuery.addQueryItem("message_id", messageID); + } + + this->makeRequest("moderation/chat", urlQuery) + .type(NetworkRequestType::Delete) + .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + if (result.status() != 204) + { + qCWarning(chatterinoTwitch) + << "Success result for deleting chat messages was" + << result.status() << "but we only expected it to be 204"; + } + + successCallback(); + return Success; + }) + .onError([failureCallback](auto result) { + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (result.status()) + { + case 404: { + // A 404 on this endpoint means message id is invalid or unable to be deleted. + // See: https://dev.twitch.tv/docs/api/reference#delete-chat-messages + failureCallback(Error::MessageUnavailable, message); + } + break; + + case 403: { + // 403 endpoint means the user does not have permission to perform this action in that channel + // Most likely to missing moderator permissions + // Missing documentation issue: https://github.com/twitchdev/issues/issues/659 + // `message` value is well-formed so no need for a specific error type + failureCallback(Error::Forwarded, message); + } + break; + + case 401: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + // Handle this error specifically because its API error is especially unfriendly + failureCallback(Error::UserMissingScope, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error deleting chat messages:" + << 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 5531ba22e..742e86d18 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -329,6 +329,17 @@ enum class HelixUpdateUserChatColorError { Forwarded, }; +enum class HelixDeleteChatMessagesError { + Unknown, + UserMissingScope, + UserNotAuthenticated, + UserNotAuthorized, + MessageUnavailable, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; + class IHelix { public: @@ -458,6 +469,13 @@ public: FailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#delete-chat-messages + virtual void deleteChatMessages( + QString broadcasterID, QString moderatorID, QString messageID, + ResultCallback<> successCallback, + FailureCallback + failureCallback) = 0; + virtual void update(QString clientId, QString oauthToken) = 0; }; @@ -580,6 +598,13 @@ public: FailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference#delete-chat-messages + void deleteChatMessages( + QString broadcasterID, QString moderatorID, QString messageID, + ResultCallback<> successCallback, + FailureCallback failureCallback) + final; + void update(QString clientId, QString oauthToken) final; static void initialize(); diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index 959ecc32f..bb824c41e 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -217,6 +217,14 @@ public: failureCallback)), (override)); + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, deleteChatMessages, + (QString broadcasterID, QString moderatorID, QString messageID, + ResultCallback<> successCallback, + (FailureCallback + failureCallback)), + (override)); + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override)); }; From 838e156a042b032643f01636cc3eb96ed7baedd0 Mon Sep 17 00:00:00 2001 From: Aiden Date: Mon, 19 Sep 2022 23:26:48 +0100 Subject: [PATCH 020/946] Migrate /delete command to Helix API (#3999) --- CHANGELOG.md | 1 + .../commands/CommandController.cpp | 78 +++++++++---------- src/providers/twitch/api/Helix.cpp | 7 ++ 3 files changed, 47 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d87404dd..382cbb329 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ - Minor: Added link back to original message that was deleted. (#3953) - Minor: Migrate /color command to Helix API. (#3988) - Minor: Migrate /clear command to Helix API. (#3994) +- Minor: Migrate /delete command to Helix API. (#3999) - Bugfix: Fix crash that can occur when closing and quickly reopening a split, then running a command. (#3852) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fix crash that can occur when changing channels. (#3799) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index d04e4c05e..c84a6458f 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -1074,44 +1074,6 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); - this->registerCommand( - "/delete", [](const QStringList &words, ChannelPtr channel) -> QString { - // This is a wrapper over the standard Twitch /delete command - // We use this to ensure the user gets better error messages for missing or malformed arguments - if (words.size() < 2) - { - channel->addMessage( - makeSystemMessage("Usage: /delete - Deletes the " - "specified message.")); - return ""; - } - - auto messageID = words.at(1); - auto uuid = QUuid(messageID); - if (uuid.isNull()) - { - // The message id must be a valid UUID - channel->addMessage(makeSystemMessage( - QString("Invalid msg-id: \"%1\"").arg(messageID))); - return ""; - } - - auto msg = channel->findMessage(messageID); - if (msg != nullptr) - { - if (msg->loginName == channel->getName() && - !channel->isBroadcaster()) - { - channel->addMessage(makeSystemMessage( - "You cannot delete the broadcaster's messages unless " - "you are the broadcaster.")); - return ""; - } - } - - return QString("/delete ") + messageID; - }); - this->registerCommand("/raw", [](const QStringList &words, ChannelPtr) { getApp()->twitch->sendRawMessage(words.mid(1).join(" ")); return ""; @@ -1333,7 +1295,7 @@ void CommandController::initialize(Settings &, Paths &paths) break; case HelixDeleteChatMessagesError::Forwarded: { - errorMessage += message + "."; + errorMessage += message; } break; @@ -1355,6 +1317,44 @@ void CommandController::initialize(Settings &, Paths &paths) (void)words; // unused return deleteMessages(channel, QString()); }); + + this->registerCommand("/delete", [deleteMessages](const QStringList &words, + auto channel) { + // This is a wrapper over the Helix delete messages endpoint + // We use this to ensure the user gets better error messages for missing or malformed arguments + if (words.size() < 2) + { + channel->addMessage( + makeSystemMessage("Usage: /delete - Deletes the " + "specified message.")); + return ""; + } + + auto messageID = words.at(1); + auto uuid = QUuid(messageID); + if (uuid.isNull()) + { + // The message id must be a valid UUID + channel->addMessage(makeSystemMessage( + QString("Invalid msg-id: \"%1\"").arg(messageID))); + return ""; + } + + auto msg = channel->findMessage(messageID); + if (msg != nullptr) + { + if (msg->loginName == channel->getName() && + !channel->isBroadcaster()) + { + channel->addMessage(makeSystemMessage( + "You cannot delete the broadcaster's messages unless " + "you are the broadcaster.")); + return ""; + } + } + + return deleteMessages(channel, messageID); + }); } void CommandController::save() diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 8726424e8..68bb96c08 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -887,6 +887,13 @@ void Helix::deleteChatMessages( } break; + case 400: { + // These errors are generally well formatted, so we just forward them. + // This is currently undocumented behaviour, see: https://github.com/twitchdev/issues/issues/660 + failureCallback(Error::Forwarded, message); + } + break; + case 403: { // 403 endpoint means the user does not have permission to perform this action in that channel // Most likely to missing moderator permissions From 28de3e637dff94e1a9884888db8889ad8df34b72 Mon Sep 17 00:00:00 2001 From: Aiden Date: Fri, 23 Sep 2022 17:12:34 +0100 Subject: [PATCH 021/946] Migrate /mod command to Helix API (#4000) Co-authored-by: Felanbird <41973452+Felanbird@users.noreply.github.com> Co-authored-by: pajlada --- CHANGELOG.md | 1 + .../commands/CommandController.cpp | 111 ++++++++++++++++++ src/providers/twitch/api/Helix.cpp | 89 ++++++++++++++ src/providers/twitch/api/Helix.hpp | 24 ++++ tests/src/HighlightController.cpp | 8 ++ 5 files changed, 233 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 382cbb329..8214e5e7d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,7 @@ - Minor: Migrate /color command to Helix API. (#3988) - Minor: Migrate /clear command to Helix API. (#3994) - Minor: Migrate /delete command to Helix API. (#3999) +- Minor: Migrate /mod command to Helix API. (#4000) - Bugfix: Fix crash that can occur when closing and quickly reopening a split, then running a command. (#3852) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fix crash that can occur when changing channels. (#3799) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index c84a6458f..bc183dcc4 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -1355,6 +1355,117 @@ void CommandController::initialize(Settings &, Paths &paths) return deleteMessages(channel, messageID); }); + + this->registerCommand("/mod", [](const QStringList &words, auto channel) { + if (words.size() < 2) + { + channel->addMessage(makeSystemMessage( + "Usage: \"/mod \" - Grant moderator status to a " + "user. Use \"/mods\" to list the moderators of this channel.")); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage( + makeSystemMessage("You must be logged in to mod someone!")); + return ""; + } + + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /mod command only works in Twitch channels")); + return ""; + } + + auto target = words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [twitchChannel, channel](const HelixUser &targetUser) { + getHelix()->addChannelModerator( + twitchChannel->roomId(), targetUser.id, + [channel, targetUser] { + channel->addMessage(makeSystemMessage( + QString("You have added %1 as a moderator of this " + "channel.") + .arg(targetUser.displayName))); + }, + [channel, targetUser](auto error, auto message) { + QString errorMessage = + QString("Failed to add channel moderator - "); + + using Error = HelixAddChannelModeratorError; + + switch (error) + { + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::Ratelimited: { + errorMessage += + "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::TargetIsVIP: { + errorMessage += + QString("%1 is currently a VIP, \"/unvip\" " + "them and " + "retry this command.") + .arg(targetUser.displayName); + } + break; + + case Error::TargetAlreadyModded: { + // Equivalent irc error + errorMessage = + QString("%1 is already a moderator of this " + "channel.") + .arg(targetUser.displayName); + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Unknown: + default: { + errorMessage += + "An unknown error has occurred."; + } + break; + } + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel, target] { + // Equivalent error from IRC + channel->addMessage(makeSystemMessage( + QString("Invalid username: %1").arg(target))); + }); + + return ""; + }); } void CommandController::save() diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 68bb96c08..43989e5dd 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -929,6 +929,95 @@ void Helix::deleteChatMessages( .execute(); } +void Helix::addChannelModerator( + QString broadcasterID, QString userID, ResultCallback<> successCallback, + FailureCallback failureCallback) +{ + using Error = HelixAddChannelModeratorError; + + QUrlQuery urlQuery; + + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + urlQuery.addQueryItem("user_id", userID); + + this->makeRequest("moderation/moderators", urlQuery) + .type(NetworkRequestType::Post) + .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + if (result.status() != 204) + { + qCWarning(chatterinoTwitch) + << "Success result for deleting chat messages was" + << result.status() << "but we only expected it to be 204"; + } + + successCallback(); + return Success; + }) + .onError([failureCallback](auto result) { + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (result.status()) + { + case 401: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + // Handle this error specifically because its API error is especially unfriendly + failureCallback(Error::UserMissingScope, message); + } + else if (message.compare("incorrect user authorization", + Qt::CaseInsensitive) == 0) + { + // This error is pretty ugly, but essentially means they're not authorized to mod people in this channel + failureCallback(Error::UserNotAuthorized, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 400: { + if (message.compare("user is already a mod", + Qt::CaseInsensitive) == 0) + { + // This error is particularly ugly, handle it separately + failureCallback(Error::TargetAlreadyModded, message); + } + else + { + // The Twitch API error sufficiently tells the user what went wrong + failureCallback(Error::Forwarded, message); + } + } + break; + + case 422: { + // Target is already a VIP + failureCallback(Error::TargetIsVIP, message); + } + break; + + case 429: { + // Endpoint has a strict ratelimit + failureCallback(Error::Ratelimited, message); + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error adding channel moderator:" + << 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 742e86d18..8363224a4 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -340,6 +340,18 @@ enum class HelixDeleteChatMessagesError { Forwarded, }; +enum class HelixAddChannelModeratorError { + Unknown, + UserMissingScope, + UserNotAuthorized, + Ratelimited, + TargetAlreadyModded, + TargetIsVIP, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; + class IHelix { public: @@ -476,6 +488,12 @@ public: FailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#add-channel-moderator + virtual void addChannelModerator( + QString broadcasterID, QString userID, ResultCallback<> successCallback, + FailureCallback + failureCallback) = 0; + virtual void update(QString clientId, QString oauthToken) = 0; }; @@ -605,6 +623,12 @@ public: FailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference#add-channel-moderator + void addChannelModerator( + QString broadcasterID, QString userID, ResultCallback<> successCallback, + FailureCallback failureCallback) + final; + void update(QString clientId, QString oauthToken) final; static void initialize(); diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index bb824c41e..3ded5bd25 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -225,6 +225,14 @@ public: failureCallback)), (override)); + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, addChannelModerator, + (QString broadcasterID, QString userID, + ResultCallback<> successCallback, + (FailureCallback + failureCallback)), + (override)); + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override)); }; From 63119661aaaff3d56d1b433c7b26b7bf0681cdb5 Mon Sep 17 00:00:00 2001 From: Felanbird <41973452+Felanbird@users.noreply.github.com> Date: Fri, 23 Sep 2022 16:26:23 -0400 Subject: [PATCH 022/946] Fix windows toast notifications opening as http (#4005) --- src/singletons/Toasts.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/singletons/Toasts.cpp b/src/singletons/Toasts.cpp index 1cf99bea2..c06df70a5 100644 --- a/src/singletons/Toasts.cpp +++ b/src/singletons/Toasts.cpp @@ -129,7 +129,7 @@ public: case ToastReaction::OpenInBrowser: if (platform_ == Platform::Twitch) { - link = "http://www.twitch.tv/" + channelName_; + link = "https://www.twitch.tv/" + channelName_; } QDesktopServices::openUrl(QUrl(link)); break; From 1c97b3d094bdeb346c421b64b8c4e1339893a662 Mon Sep 17 00:00:00 2001 From: Aiden Date: Sat, 24 Sep 2022 11:49:13 +0100 Subject: [PATCH 023/946] Migrate /unmod command to Helix API (#4001) Co-authored-by: Felanbird <41973452+Felanbird@users.noreply.github.com> Co-authored-by: pajlada --- CHANGELOG.md | 1 + .../commands/CommandController.cpp | 102 ++++++++++++++++++ src/providers/twitch/api/Helix.cpp | 80 ++++++++++++++ src/providers/twitch/api/Helix.hpp | 23 ++++ tests/src/HighlightController.cpp | 8 ++ 5 files changed, 214 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8214e5e7d..ac893c2ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ - Minor: Migrate /clear command to Helix API. (#3994) - Minor: Migrate /delete command to Helix API. (#3999) - Minor: Migrate /mod command to Helix API. (#4000) +- Minor: Migrate /unmod command to Helix API. (#4001) - Bugfix: Fix crash that can occur when closing and quickly reopening a split, then running a command. (#3852) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fix crash that can occur when changing channels. (#3799) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index bc183dcc4..1b826cc08 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -1466,6 +1466,108 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); + + this->registerCommand("/unmod", [](const QStringList &words, auto channel) { + if (words.size() < 2) + { + channel->addMessage(makeSystemMessage( + "Usage: \"/unmod \" - Revoke moderator status from a " + "user. Use \"/mods\" to list the moderators of this channel.")); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage( + makeSystemMessage("You must be logged in to unmod someone!")); + return ""; + } + + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /unmod command only works in Twitch channels")); + return ""; + } + + auto target = words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [twitchChannel, channel](const HelixUser &targetUser) { + getHelix()->removeChannelModerator( + twitchChannel->roomId(), targetUser.id, + [channel, targetUser] { + channel->addMessage(makeSystemMessage( + QString("You have removed %1 as a moderator of " + "this channel.") + .arg(targetUser.displayName))); + }, + [channel, targetUser](auto error, auto message) { + QString errorMessage = + QString("Failed to remove channel moderator - "); + + using Error = HelixRemoveChannelModeratorError; + + switch (error) + { + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::Ratelimited: { + errorMessage += + "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::TargetNotModded: { + // Equivalent irc error + errorMessage += + QString("%1 is not a moderator of this " + "channel.") + .arg(targetUser.displayName); + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Unknown: + default: { + errorMessage += + "An unknown error has occurred."; + } + break; + } + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel, target] { + // Equivalent error from IRC + channel->addMessage(makeSystemMessage( + QString("Invalid username: %1").arg(target))); + }); + + return ""; + }); } void CommandController::save() diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 43989e5dd..4414c11d1 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -1018,6 +1018,86 @@ void Helix::addChannelModerator( .execute(); } +void Helix::removeChannelModerator( + QString broadcasterID, QString userID, ResultCallback<> successCallback, + FailureCallback failureCallback) +{ + using Error = HelixRemoveChannelModeratorError; + + QUrlQuery urlQuery; + + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + urlQuery.addQueryItem("user_id", userID); + + this->makeRequest("moderation/moderators", urlQuery) + .type(NetworkRequestType::Delete) + .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + if (result.status() != 204) + { + qCWarning(chatterinoTwitch) + << "Success result for unmodding user was" + << result.status() << "but we only expected it to be 204"; + } + + successCallback(); + return Success; + }) + .onError([failureCallback](auto result) { + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (result.status()) + { + case 400: { + if (message.compare("user is not a mod", + Qt::CaseInsensitive) == 0) + { + // This error message is particularly ugly, so we handle it differently + failureCallback(Error::TargetNotModded, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 401: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + // Handle this error specifically because its API error is especially unfriendly + failureCallback(Error::UserMissingScope, message); + } + else if (message.compare("incorrect user authorization", + Qt::CaseInsensitive) == 0) + { + failureCallback(Error::UserNotAuthorized, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 429: { + failureCallback(Error::Ratelimited, message); + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error unmodding user:" << 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 8363224a4..f18669c03 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -352,6 +352,17 @@ enum class HelixAddChannelModeratorError { Forwarded, }; +enum class HelixRemoveChannelModeratorError { + Unknown, + UserMissingScope, + UserNotAuthorized, + TargetNotModded, + Ratelimited, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; + class IHelix { public: @@ -494,6 +505,12 @@ public: FailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#remove-channel-moderator + virtual void removeChannelModerator( + QString broadcasterID, QString userID, ResultCallback<> successCallback, + FailureCallback + failureCallback) = 0; + virtual void update(QString clientId, QString oauthToken) = 0; }; @@ -629,6 +646,12 @@ public: FailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference#remove-channel-moderator + void removeChannelModerator( + QString broadcasterID, QString userID, ResultCallback<> successCallback, + FailureCallback + failureCallback) final; + void update(QString clientId, QString oauthToken) final; static void initialize(); diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index 3ded5bd25..7b6bbab66 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -233,6 +233,14 @@ public: failureCallback)), (override)); + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, removeChannelModerator, + (QString broadcasterID, QString userID, + ResultCallback<> successCallback, + (FailureCallback + failureCallback)), + (override)); + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override)); }; From c692dd9b4461f486762f44cc007125c229d28592 Mon Sep 17 00:00:00 2001 From: pajlada Date: Sat, 24 Sep 2022 14:50:28 +0200 Subject: [PATCH 024/946] Ignore cert-err58-cpp clang-tidy warning (#4008) --- .clang-tidy | 1 + 1 file changed, 1 insertion(+) diff --git a/.clang-tidy b/.clang-tidy index b279868d2..4c3f4adb5 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -27,6 +27,7 @@ Checks: '-*, -readability-identifier-length, -readability-function-cognitive-complexity, -bugprone-easily-swappable-parameters, + -cert-err58-cpp, ' CheckOptions: - key: readability-identifier-naming.ClassCase From 8bda8a8b2605de079a4767326092cc310c45f307 Mon Sep 17 00:00:00 2001 From: Mm2PL Date: Sat, 24 Sep 2022 17:50:02 +0200 Subject: [PATCH 025/946] Migrate /announce command to Helix API. (#4003) Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 1 + .../commands/CommandController.cpp | 64 +++++++++++++++ src/providers/twitch/api/Helix.cpp | 80 +++++++++++++++++++ src/providers/twitch/api/Helix.hpp | 32 ++++++++ tests/src/HighlightController.cpp | 8 ++ 5 files changed, 185 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ac893c2ec..30938f4e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,7 @@ - Minor: Migrate /delete command to Helix API. (#3999) - Minor: Migrate /mod command to Helix API. (#4000) - Minor: Migrate /unmod command to Helix API. (#4001) +- Minor: Migrate /announce command to Helix API. (#4003) - Bugfix: Fix crash that can occur when closing and quickly reopening a split, then running a command. (#3852) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fix crash that can occur when changing channels. (#3799) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 1b826cc08..b32becd80 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -1568,6 +1568,70 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); + + this->registerCommand( + "/announce", [](const QStringList &words, auto channel) -> QString { + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "This command can only be used in Twitch channels.")); + return ""; + } + + if (words.size() < 2) + { + channel->addMessage(makeSystemMessage( + "Usage: /announce - Call attention to your " + "message with a highlight.")); + return ""; + } + + auto user = getApp()->accounts->twitch.getCurrent(); + if (user->isAnon()) + { + channel->addMessage(makeSystemMessage( + "You must be logged in to use the /announce command")); + return ""; + } + + getHelix()->sendChatAnnouncement( + twitchChannel->roomId(), user->getUserId(), + words.mid(1).join(" "), HelixAnnouncementColor::Primary, + []() { + // do nothing. + }, + [channel](auto error, auto message) { + using Error = HelixSendChatAnnouncementError; + QString errorMessage = + QString("Failed to send announcement - "); + + switch (error) + { + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += + "Missing required scope. Re-login with your " + "account and try again."; + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Unknown: + default: { + errorMessage += "An unknown error has occurred."; + } + break; + } + + channel->addMessage(makeSystemMessage(errorMessage)); + }); + return ""; + }); } void CommandController::save() diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 4414c11d1..d151da322 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -4,6 +4,7 @@ #include "common/QLogging.hpp" #include +#include namespace chatterino { @@ -1098,6 +1099,85 @@ void Helix::removeChannelModerator( .execute(); } +void Helix::sendChatAnnouncement( + QString broadcasterID, QString moderatorID, QString message, + HelixAnnouncementColor color, ResultCallback<> successCallback, + FailureCallback failureCallback) +{ + using Error = HelixSendChatAnnouncementError; + + QUrlQuery urlQuery; + + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + urlQuery.addQueryItem("moderator_id", moderatorID); + + QJsonObject body; + body.insert("message", message); + const auto colorStr = + std::string{magic_enum::enum_name(color)}; + body.insert("color", QString::fromStdString(colorStr).toLower()); + + this->makeRequest("chat/announcements", urlQuery) + .type(NetworkRequestType::Post) + .header("Content-Type", "application/json") + .payload(QJsonDocument(body).toJson(QJsonDocument::Compact)) + .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + if (result.status() != 204) + { + qCWarning(chatterinoTwitch) + << "Success result for sending an announcement was" + << result.status() << "but we only expected it to be 204"; + } + + successCallback(); + return Success; + }) + .onError([failureCallback](auto result) { + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (result.status()) + { + case 400: { + // These errors are generally well formatted, so we just forward them. + // This is currently undocumented behaviour, see: https://github.com/twitchdev/issues/issues/660 + failureCallback(Error::Forwarded, message); + } + break; + + case 403: { + // 403 endpoint means the user does not have permission to perform this action in that channel + // `message` value is well-formed so no need for a specific error type + failureCallback(Error::Forwarded, message); + } + break; + + case 401: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + // Handle this error specifically because its API error is especially unfriendly + failureCallback(Error::UserMissingScope, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error sending an announcement:" + << 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 f18669c03..c3e099506 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -300,6 +300,16 @@ struct HelixChannelEmote { } }; +enum class HelixAnnouncementColor { + Blue, + Green, + Orange, + Purple, + + // this is the executor's chat color + Primary, +}; + enum class HelixClipError { Unknown, ClipsDisabled, @@ -340,6 +350,14 @@ enum class HelixDeleteChatMessagesError { Forwarded, }; +enum class HelixSendChatAnnouncementError { + Unknown, + UserMissingScope, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; + enum class HelixAddChannelModeratorError { Unknown, UserMissingScope, @@ -511,6 +529,13 @@ public: FailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#send-chat-announcement + virtual void sendChatAnnouncement( + QString broadcasterID, QString moderatorID, QString message, + HelixAnnouncementColor color, ResultCallback<> successCallback, + FailureCallback + failureCallback) = 0; + virtual void update(QString clientId, QString oauthToken) = 0; }; @@ -652,6 +677,13 @@ public: FailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference#send-chat-announcement + void sendChatAnnouncement( + QString broadcasterID, QString moderatorID, QString message, + HelixAnnouncementColor color, ResultCallback<> successCallback, + FailureCallback + failureCallback) final; + void update(QString clientId, QString oauthToken) final; static void initialize(); diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index 7b6bbab66..59219c03b 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -241,6 +241,14 @@ public: failureCallback)), (override)); + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, sendChatAnnouncement, + (QString broadcasterID, QString moderatorID, QString message, + HelixAnnouncementColor color, ResultCallback<> successCallback, + (FailureCallback + failureCallback)), + (override)); + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override)); }; From abb32f700ce7cbabfd05152ef83512ac1a184313 Mon Sep 17 00:00:00 2001 From: iProdigy Date: Sat, 24 Sep 2022 23:16:39 -0700 Subject: [PATCH 026/946] chore: fix debug text on non-204 add mod success (#4011) --- src/providers/twitch/api/Helix.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index d151da322..b3087b934 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -947,7 +947,7 @@ void Helix::addChannelModerator( if (result.status() != 204) { qCWarning(chatterinoTwitch) - << "Success result for deleting chat messages was" + << "Success result for adding a moderator was" << result.status() << "but we only expected it to be 204"; } From ced1525e758f40988aa47aa65de7c0c8e23ea69a Mon Sep 17 00:00:00 2001 From: Aiden Date: Sun, 25 Sep 2022 10:45:46 +0100 Subject: [PATCH 027/946] Migrate /vip to Helix API (#4010) Fixes #3983 Co-authored-by: iProdigy Co-authored-by: pajlada --- CHANGELOG.md | 1 + .../commands/CommandController.cpp | 93 +++++++++++++++++++ src/providers/twitch/api/Helix.cpp | 79 ++++++++++++++++ src/providers/twitch/api/Helix.hpp | 21 +++++ tests/src/HighlightController.cpp | 8 ++ 5 files changed, 202 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30938f4e0..dd63353db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -40,6 +40,7 @@ - Minor: Migrate /mod command to Helix API. (#4000) - Minor: Migrate /unmod command to Helix API. (#4001) - Minor: Migrate /announce command to Helix API. (#4003) +- Minor: Migrate /vip command to Helix API. (#4010) - Bugfix: Fix crash that can occur when closing and quickly reopening a split, then running a command. (#3852) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fix crash that can occur when changing channels. (#3799) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index b32becd80..913fd658f 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -1632,6 +1632,99 @@ void CommandController::initialize(Settings &, Paths &paths) }); return ""; }); + + this->registerCommand("/vip", [](const QStringList &words, auto channel) { + if (words.size() < 2) + { + channel->addMessage(makeSystemMessage( + "Usage: \"/vip \" - Grant VIP status to a user. Use " + "\"/vips\" to list the VIPs of this channel.")); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage( + makeSystemMessage("You must be logged in to VIP someone!")); + return ""; + } + + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /vip command only works in Twitch channels")); + return ""; + } + + auto target = words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [twitchChannel, channel](const HelixUser &targetUser) { + getHelix()->addChannelVIP( + twitchChannel->roomId(), targetUser.id, + [channel, targetUser] { + channel->addMessage(makeSystemMessage( + QString( + "You have added %1 as a VIP of this channel.") + .arg(targetUser.displayName))); + }, + [channel, targetUser](auto error, auto message) { + QString errorMessage = QString("Failed to add VIP - "); + + using Error = HelixAddChannelVIPError; + + switch (error) + { + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::Ratelimited: { + errorMessage += + "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::Forwarded: { + // These are actually the IRC equivalents, so we can ditch the prefix + errorMessage = message; + } + break; + + case Error::Unknown: + default: { + errorMessage += + "An unknown error has occurred."; + } + break; + } + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel, target] { + // Equivalent error from IRC + channel->addMessage(makeSystemMessage( + QString("Invalid username: %1").arg(target))); + }); + + return ""; + }); } void CommandController::save() diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index b3087b934..0fd906e45 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -1178,6 +1178,85 @@ void Helix::sendChatAnnouncement( .execute(); } +void Helix::addChannelVIP( + QString broadcasterID, QString userID, ResultCallback<> successCallback, + FailureCallback failureCallback) +{ + using Error = HelixAddChannelVIPError; + + QUrlQuery urlQuery; + + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + urlQuery.addQueryItem("user_id", userID); + + this->makeRequest("channels/vips", urlQuery) + .type(NetworkRequestType::Post) + .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + if (result.status() != 204) + { + qCWarning(chatterinoTwitch) + << "Success result for adding channel VIP was" + << result.status() << "but we only expected it to be 204"; + } + + successCallback(); + return Success; + }) + .onError([failureCallback](auto result) { + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (result.status()) + { + case 400: + case 409: + case 422: + case 425: { + // Most of the errors returned by this endpoint are pretty good. We can rely on Twitch's API messages + failureCallback(Error::Forwarded, message); + } + break; + + case 401: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + // Handle this error specifically because its API error is especially unfriendly + failureCallback(Error::UserMissingScope, message); + } + else if (message.compare("incorrect user authorization", + Qt::CaseInsensitive) == 0 || + message.startsWith("the id in broadcaster_id must " + "match the user id", + Qt::CaseInsensitive)) + { + // This error is particularly ugly, but is the equivalent to a user not having permissions + failureCallback(Error::UserNotAuthorized, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 429: { + failureCallback(Error::Ratelimited, message); + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error adding channel VIP:" + << 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 c3e099506..8c591c632 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -381,6 +381,16 @@ enum class HelixRemoveChannelModeratorError { Forwarded, }; +enum class HelixAddChannelVIPError { + Unknown, + UserMissingScope, + UserNotAuthorized, + Ratelimited, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; + class IHelix { public: @@ -536,6 +546,11 @@ public: FailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#add-channel-vip + virtual void addChannelVIP( + QString broadcasterID, QString userID, ResultCallback<> successCallback, + FailureCallback failureCallback) = 0; + virtual void update(QString clientId, QString oauthToken) = 0; }; @@ -684,6 +699,12 @@ public: FailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference#add-channel-vip + void addChannelVIP(QString broadcasterID, QString userID, + ResultCallback<> successCallback, + FailureCallback + failureCallback) final; + void update(QString clientId, QString oauthToken) final; static void initialize(); diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index 59219c03b..977fac8c3 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -249,6 +249,14 @@ public: failureCallback)), (override)); + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD( + void, addChannelVIP, + (QString broadcasterID, QString userID, + ResultCallback<> successCallback, + (FailureCallback failureCallback)), + (override)); + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override)); }; From d9e17e69cff5c1f0259f12d1181d2166ca7dc528 Mon Sep 17 00:00:00 2001 From: Felanbird <41973452+Felanbird@users.noreply.github.com> Date: Wed, 28 Sep 2022 11:53:20 -0400 Subject: [PATCH 028/946] Cleanup Changelog in preperation for the next release (#4014) --- CHANGELOG.md | 90 ++++++++++++++++++++++++++-------------------------- 1 file changed, 45 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dd63353db..379755750 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,46 +4,46 @@ - Major: Added support for Twitch's Chat Replies. [Wiki Page](https://wiki.chatterino.com/Features/#message-replies) (#3722, #3989) - Major: Added multi-channel searching to search dialog via keyboard shortcut. (Ctrl+Shift+F by default) (#3694, #3875) -- Minor: Added option to display tabs on the right and bottom. (#3847) -- Minor: Added `is:first-msg` search option. (#3700) -- Minor: Added quotation marks in the permitted/blocked Automod messages for clarity. (#3654) -- Minor: Added a `Scroll to top` keyboard shortcut for splits. (#3802) -- Minor: Adjust large stream thumbnail to 16:9 (#3655) -- Minor: Fixed being unable to load Twitch Usercards from the `/mentions` tab. (#3623) -- Minor: Add information about the user's operating system in the About page. (#3663) -- Minor: Added chatter count for each category in viewer list. (#3683, #3719) -- Minor: Sorted usernames in /vips message to be case-insensitive. (#3696) -- Minor: Strip leading @ and trailing , from usernames in the `/block` and `/unblock` commands. (#3816) -- Minor: Added option to open a user's chat in a new tab from the usercard profile picture context menu. (#3625) -- Minor: Fixed tag parsing for consecutive escaped characters. (#3711) -- Minor: Prevent user from entering incorrect characters in Live Notifications channels list. (#3715, #3730) -- Minor: Fixed automod caught message notice appearing twice for mods. (#3717) -- Minor: Streamer mode now automatically detects if XSplit, PRISM Live Studio, Twitch Studio, or vMix are running. (#3740) -- Minor: Add scrollbar to `Select filters` dialog. (#3737) -- Minor: Added `/requests` command. Usage: `/requests [channel]`. Opens the channel points requests queue for the provided channel or the current channel if no input is provided. (#3746) -- Minor: Added ability to execute commands on chat messages using the message context menu. (#3738, #3765) -- Minor: Added `/copy` command. Usage: `/copy `. Copies provided text to clipboard - can be useful with custom commands. (#3763) - Minor: Removed total views from the usercard, as Twitch no longer updates the number. (#3792) -- Minor: Add Quick Switcher item to open a channel in a new popup window. (#3828) -- Minor: Reduced GIF frame window from 30ms to 20ms, causing fewer frame skips in animated emotes. (#3886, #3907) -- Minor: Warn when parsing an environment variable fails. (#3904) - Minor: Load missing messages from Recent Messages API upon reconnecting (#3878, #3932) - Minor: Reduced image memory usage when running Chatterino for a long time. (#3915) -- Minor: Add settings to toggle BTTV/FFZ global/channel emotes (#3935, #3990) -- Minor: Add AutoMod message flag filter. (#3938) -- Minor: Added whitespace trim to username field in nicknames (#3946) +- Minor: Added the ability to execute commands on chat messages using the message context menu. (#3738, #3765) +- Minor: Added settings to toggle BTTV/FFZ global/channel emotes (#3935, #3990) +- Minor: Added an option to display tabs on the right and bottom. (#3847) +- Minor: Added a `Scroll to top` keyboard shortcut for splits. (#3802) +- Minor: Added `/copy` command. Usage: `/copy `. Copies provided text to clipboard - can be useful with custom commands. (#3763) +- Minor: Added `/requests` command. Usage: `/requests [channel]`. Opens the channel points requests queue for the provided channel or the current channel if no input is provided. (#3746) - Minor: Added `Go to message` context menu action to search popup, mentions, usercard and reply threads. (#3953) -- Minor: Added link back to original message that was deleted. (#3953) -- Minor: Migrate /color command to Helix API. (#3988) -- Minor: Migrate /clear command to Helix API. (#3994) -- Minor: Migrate /delete command to Helix API. (#3999) -- Minor: Migrate /mod command to Helix API. (#4000) -- Minor: Migrate /unmod command to Helix API. (#4001) -- Minor: Migrate /announce command to Helix API. (#4003) -- Minor: Migrate /vip command to Helix API. (#4010) -- Bugfix: Fix crash that can occur when closing and quickly reopening a split, then running a command. (#3852) +- Minor: Clicking `A message from x was deleted` messages will now jump to the message in question. (#3953) +- Minor: Added `is:first-msg` search option. (#3700) +- Minor: Added AutoMod message flag filter. (#3938) +- Minor: Added chatter count for each category in viewer list. (#3683, #3719) +- Minor: Added option to open a user's chat in a new tab from the usercard profile picture context menu. (#3625) +- Minor: Added scrollbar to `Select filters` dialog. (#3737) +- Minor: Added quotation marks in the permitted/blocked Automod messages for clarity. (#3654) +- Minor: Added Quick Switcher item to open a channel in a new popup window. (#3828) +- Minor: Added information about the user's operating system in the About page. (#3663) +- Minor: Adjusted large stream thumbnail to 16:9 (#3655) +- Minor: Prevented user from entering incorrect characters in Live Notifications channels list. (#3715, #3730) +- Minor: Added whitespace trim to username field in nicknames (#3946) +- Minor: Sorted usernames in /vips message to be case-insensitive. (#3696) +- Minor: Streamer mode now automatically detects if XSplit, PRISM Live Studio, Twitch Studio, or vMix are running. (#3740) +- Minor: Fixed automod caught message notice appearing twice for mods. (#3717) +- Minor: Fixed being unable to load Twitch Usercards from the `/mentions` tab. (#3623) +- Minor: Strip leading @ and trailing , from usernames in the `/block` and `/unblock` commands. (#3816) +- Minor: Fixed tag parsing for consecutive escaped characters. (#3711) +- Minor: Reduced GIF frame window from 30ms to 20ms, causing fewer frame skips in animated emotes. (#3886, #3907) +- Minor: Warn when parsing an environment variable fails. (#3904) +- Minor: Migrated /announce command to Helix API. (#4003) +- Minor: Migrated /clear command to Helix API. (#3994) +- Minor: Migrated /color command to Helix API. (#3988) +- Minor: Migrated /delete command to Helix API. (#3999) +- Minor: Migrated /mod command to Helix API. (#4000) +- Minor: Migrated /unmod command to Helix API. (#4001) +- Minor: Migrated /vip command to Helix API. (#4010) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) -- Bugfix: Fix crash that can occur when changing channels. (#3799) +- Bugfix: Fixed a crash that can occur when closing and quickly reopening a split, then running a command. (#3852) +- Bugfix: Fixed a crash that can occur when changing channels. (#3799) - Bugfix: Fixed viewers list search not working when used before loading finishes. (#3774) - Bugfix: Fixed live notifications for usernames containing uppercase characters. (#3646) - Bugfix: Fixed live notifications not getting updated for closed streams going offline. (#3678) @@ -56,19 +56,19 @@ - Bugfix: Fixed viewer list not closing after pressing escape key. (#3734) - Bugfix: Fixed links with no thumbnail having previous link's thumbnail. (#3720) - Bugfix: Fixed message only showing a maximum of one global FrankerFaceZ badge even if the user has multiple. (#3818) -- Bugfix: Add icon in the CMake macOS bundle. (#3832) -- Bugfix: Adopt popup windows in order to force floating behavior on some window managers. (#3836) -- Bugfix: Fix split focusing being broken in certain circumstances when the "Show input when it's empty" setting was disabled. (#3838, #3860) +- Bugfix: Added an icon in the CMake macOS bundle. (#3832) +- Bugfix: Adopted popup windows in order to force floating behavior on some window managers. (#3836) +- Bugfix: Fixed split focusing being broken in certain circumstances when the "Show input when it's empty" setting was disabled. (#3838, #3860) - Bugfix: Always refresh tab when a contained split's channel is set. (#3849) -- Bugfix: Drop trailing whitespace from Twitch system messages. (#3888) -- Bugfix: Fix crash related to logging IRC channels (#3918) +- Bugfix: Fixed an issue where Anonymous gift messages appeared larger than normal gift messages. (#3888) +- Bugfix: Fixed crash related to logging IRC channels (#3918) - Bugfix: Mentions of "You" in timeouts will link to your own user now instead of the user "You". (#3922) -- Dev: Remove official support for QMake. (#3839, #3883) -- Dev: Rewrite LimitedQueue (#3798) -- Dev: Overhaul highlight system by moving all checks into a Controller allowing for easier tests. (#3399, #3801, #3835) +- Dev: Removed official support for QMake. (#3839, #3883) +- Dev: Rewrote LimitedQueue (#3798) +- Dev: Overhauled highlight system by moving all checks into a Controller allowing for easier tests. (#3399, #3801, #3835) - Dev: Use Game Name returned by Get Streams instead of querying it from the Get Games API. (#3662) -- Dev: Batch checking live status for all channels after startup. (#3757, #3762, #3767) -- Dev: Move most command context into the command controller. (#3824) +- Dev: Batched checking live status for all channels after startup. (#3757, #3762, #3767) +- Dev: Moved most command context into the command controller. (#3824) - Dev: Error NetworkResults now include the body data. (#3987) ## 2.3.5 From 9554b83c1a98a6e33bef70e3ddf85ee93b60a63a Mon Sep 17 00:00:00 2001 From: nerix Date: Fri, 30 Sep 2022 22:59:52 +0200 Subject: [PATCH 029/946] fix: Show Emoji Completion in IRC Channels (#4021) --- CHANGELOG.md | 1 + src/widgets/splits/InputCompletionPopup.cpp | 4 ++-- src/widgets/splits/SplitInput.cpp | 3 +-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 379755750..decfa7cb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -63,6 +63,7 @@ - Bugfix: Fixed an issue where Anonymous gift messages appeared larger than normal gift messages. (#3888) - Bugfix: Fixed crash related to logging IRC channels (#3918) - Bugfix: Mentions of "You" in timeouts will link to your own user now instead of the user "You". (#3922) +- Bugfix: Fixed emoji popup not being shown in IRC channels (#4021) - Dev: Removed official support for QMake. (#3839, #3883) - Dev: Rewrote LimitedQueue (#3798) - Dev: Overhauled highlight system by moving all checks into a Controller allowing for easier tests. (#3399, #3801, #3835) diff --git a/src/widgets/splits/InputCompletionPopup.cpp b/src/widgets/splits/InputCompletionPopup.cpp index 1cf8fee5e..d20b9f88d 100644 --- a/src/widgets/splits/InputCompletionPopup.cpp +++ b/src/widgets/splits/InputCompletionPopup.cpp @@ -110,10 +110,10 @@ void InputCompletionPopup::updateEmotes(const QString &text, ChannelPtr channel) addEmotes(emotes, *bttvG, text, "Global BetterTTV"); if (auto ffzG = getApp()->twitch->getFfzEmotes().emotes()) addEmotes(emotes, *ffzG, text, "Global FrankerFaceZ"); - - addEmojis(emotes, getApp()->emotes->emojis.emojis, text); } + addEmojis(emotes, getApp()->emotes->emojis.emojis, text); + // if there is an exact match, put that emote first for (size_t i = 1; i < emotes.size(); i++) { diff --git a/src/widgets/splits/SplitInput.cpp b/src/widgets/splits/SplitInput.cpp index ab635bd3d..f78ce2746 100644 --- a/src/widgets/splits/SplitInput.cpp +++ b/src/widgets/splits/SplitInput.cpp @@ -661,8 +661,7 @@ void SplitInput::updateCompletionPopup() { auto channel = this->split_->getChannel().get(); auto tc = dynamic_cast(channel); - bool showEmoteCompletion = - channel->isTwitchChannel() && getSettings()->emoteCompletionWithColon; + bool showEmoteCompletion = getSettings()->emoteCompletionWithColon; bool showUsernameCompletion = tc && getSettings()->showUsernameCompletionMenu; if (!showEmoteCompletion && !showUsernameCompletion) From 0ab59d44f0a66176264fc67f4e60c3b71ca88cd7 Mon Sep 17 00:00:00 2001 From: Felanbird <41973452+Felanbird@users.noreply.github.com> Date: Fri, 30 Sep 2022 19:23:31 -0400 Subject: [PATCH 030/946] Add Basic Elevated Message support (#4016) --- CHANGELOG.md | 1 + src/controllers/highlights/HighlightModel.cpp | 61 +++++++++++++++++++ src/controllers/highlights/HighlightModel.hpp | 1 + .../highlights/HighlightPhrase.cpp | 2 + .../highlights/HighlightPhrase.hpp | 1 + src/messages/Message.cpp | 7 +++ src/messages/Message.hpp | 1 + src/messages/layouts/MessageLayout.cpp | 12 +++- src/providers/colors/ColorProvider.cpp | 14 +++++ src/providers/colors/ColorProvider.hpp | 1 + src/providers/twitch/TwitchMessageBuilder.cpp | 5 ++ src/singletons/Settings.hpp | 11 ++++ src/util/SampleData.cpp | 3 + src/widgets/Scrollbar.cpp | 8 +++ src/widgets/helper/ScrollbarHighlight.cpp | 9 ++- src/widgets/helper/ScrollbarHighlight.hpp | 5 +- 16 files changed, 138 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index decfa7cb6..cca3b0935 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Major: Added support for Twitch's Chat Replies. [Wiki Page](https://wiki.chatterino.com/Features/#message-replies) (#3722, #3989) - Major: Added multi-channel searching to search dialog via keyboard shortcut. (Ctrl+Shift+F by default) (#3694, #3875) +- Minor: Added highlights for `Elevated Messages`. (#4016) - Minor: Removed total views from the usercard, as Twitch no longer updates the number. (#3792) - Minor: Load missing messages from Recent Messages API upon reconnecting (#3878, #3932) - Minor: Reduced image memory usage when running Chatterino for a long time. (#3915) diff --git a/src/controllers/highlights/HighlightModel.cpp b/src/controllers/highlights/HighlightModel.cpp index f1473a262..3b5b30ccb 100644 --- a/src/controllers/highlights/HighlightModel.cpp +++ b/src/controllers/highlights/HighlightModel.cpp @@ -181,6 +181,38 @@ void HighlightModel::afterInit() this->insertCustomRow(firstMessageRow, HighlightRowIndexes::FirstMessageRow); + + // Highlight settings for elevated messages + std::vector elevatedMessageRow = this->createRow(); + setBoolItem(elevatedMessageRow[Column::Pattern], + getSettings()->enableElevatedMessageHighlight.getValue(), true, + false); + elevatedMessageRow[Column::Pattern]->setData("Elevated Messages", + Qt::DisplayRole); + elevatedMessageRow[Column::ShowInMentions]->setFlags({}); + // setBoolItem(elevatedMessageRow[Column::FlashTaskbar], + // getSettings()->enableElevatedMessageHighlightTaskbar.getValue(), + // true, false); + // setBoolItem(elevatedMessageRow[Column::PlaySound], + // getSettings()->enableElevatedMessageHighlightSound.getValue(), + // true, false); + elevatedMessageRow[Column::FlashTaskbar]->setFlags({}); + elevatedMessageRow[Column::PlaySound]->setFlags({}); + elevatedMessageRow[Column::UseRegex]->setFlags({}); + elevatedMessageRow[Column::CaseSensitive]->setFlags({}); + + QUrl elevatedMessageSound = + QUrl(getSettings()->elevatedMessageHighlightSoundUrl.getValue()); + setFilePathItem(elevatedMessageRow[Column::SoundPath], elevatedMessageSound, + false); + + auto elevatedMessageColor = + ColorProvider::instance().color(ColorType::ElevatedMessageHighlight); + setColorItem(elevatedMessageRow[Column::Color], *elevatedMessageColor, + false); + + this->insertCustomRow(elevatedMessageRow, + HighlightRowIndexes::ElevatedMessageRow); } void HighlightModel::customRowSetData(const std::vector &row, @@ -215,6 +247,11 @@ void HighlightModel::customRowSetData(const std::vector &row, getSettings()->enableFirstMessageHighlight.setValue( value.toBool()); } + else if (rowIndex == HighlightRowIndexes::ElevatedMessageRow) + { + getSettings()->enableElevatedMessageHighlight.setValue( + value.toBool()); + } } } break; @@ -257,6 +294,12 @@ void HighlightModel::customRowSetData(const std::vector &row, // getSettings()->enableFirstMessageHighlightTaskbar.setValue( // value.toBool()); } + else if (rowIndex == HighlightRowIndexes::ElevatedMessageRow) + { + // getSettings() + // ->enableElevatedMessageHighlightTaskbar.setvalue( + // value.toBool()); + } } } break; @@ -288,6 +331,11 @@ void HighlightModel::customRowSetData(const std::vector &row, // getSettings()->enableFirstMessageHighlightSound.setValue( // value.toBool()); } + else if (rowIndex == HighlightRowIndexes::ElevatedMessageRow) + { + // getSettings()->enableElevatedMessageHighlightSound.setValue( + // value.toBool()); + } } } break; @@ -328,6 +376,11 @@ void HighlightModel::customRowSetData(const std::vector &row, getSettings()->firstMessageHighlightSoundUrl.setValue( value.toString()); } + if (rowIndex == HighlightRowIndexes::ElevatedMessageRow) + { + getSettings()->elevatedMessageHighlightSoundUrl.setValue( + value.toString()); + } } } break; @@ -363,6 +416,14 @@ void HighlightModel::customRowSetData(const std::vector &row, .updateColor(ColorType::FirstMessageHighlight, QColor(colorName)); } + else if (rowIndex == HighlightRowIndexes::ElevatedMessageRow) + { + getSettings()->elevatedMessageHighlightColor.setValue( + colorName); + const_cast(ColorProvider::instance()) + .updateColor(ColorType::ElevatedMessageHighlight, + QColor(colorName)); + } } } break; diff --git a/src/controllers/highlights/HighlightModel.hpp b/src/controllers/highlights/HighlightModel.hpp index a5bbebc98..e18306fbc 100644 --- a/src/controllers/highlights/HighlightModel.hpp +++ b/src/controllers/highlights/HighlightModel.hpp @@ -31,6 +31,7 @@ public: SubRow = 2, RedeemedRow = 3, FirstMessageRow = 4, + ElevatedMessageRow = 5, }; protected: diff --git a/src/controllers/highlights/HighlightPhrase.cpp b/src/controllers/highlights/HighlightPhrase.cpp index ab8436c31..079d3686d 100644 --- a/src/controllers/highlights/HighlightPhrase.cpp +++ b/src/controllers/highlights/HighlightPhrase.cpp @@ -14,6 +14,8 @@ QColor HighlightPhrase::FALLBACK_REDEEMED_HIGHLIGHT_COLOR = QColor(28, 126, 141, 60); QColor HighlightPhrase::FALLBACK_FIRST_MESSAGE_HIGHLIGHT_COLOR = QColor(72, 127, 63, 60); +QColor HighlightPhrase::FALLBACK_ELEVATED_MESSAGE_HIGHLIGHT_COLOR = + QColor(255, 174, 66, 60); QColor HighlightPhrase::FALLBACK_SUB_COLOR = QColor(196, 102, 255, 100); bool HighlightPhrase::operator==(const HighlightPhrase &other) const diff --git a/src/controllers/highlights/HighlightPhrase.hpp b/src/controllers/highlights/HighlightPhrase.hpp index 41ab8b682..99d3fe377 100644 --- a/src/controllers/highlights/HighlightPhrase.hpp +++ b/src/controllers/highlights/HighlightPhrase.hpp @@ -83,6 +83,7 @@ public: static QColor FALLBACK_REDEEMED_HIGHLIGHT_COLOR; static QColor FALLBACK_SUB_COLOR; static QColor FALLBACK_FIRST_MESSAGE_HIGHLIGHT_COLOR; + static QColor FALLBACK_ELEVATED_MESSAGE_HIGHLIGHT_COLOR; private: QString pattern_; diff --git a/src/messages/Message.cpp b/src/messages/Message.cpp index da5c0a801..af5417f93 100644 --- a/src/messages/Message.cpp +++ b/src/messages/Message.cpp @@ -42,12 +42,19 @@ SBHighlight Message::getScrollBarHighlight() const ColorProvider::instance().color(ColorType::RedeemedHighlight), SBHighlight::Default, true); } + else if (this->flags.has(MessageFlag::ElevatedMessage)) + { + return SBHighlight(ColorProvider::instance().color( + ColorType::ElevatedMessageHighlight), + SBHighlight::Default, false, false, true); + } else if (this->flags.has(MessageFlag::FirstMessage)) { return SBHighlight( ColorProvider::instance().color(ColorType::FirstMessageHighlight), SBHighlight::Default, false, true); } + return SBHighlight(); } diff --git a/src/messages/Message.hpp b/src/messages/Message.hpp index cc9f36503..8097f9f24 100644 --- a/src/messages/Message.hpp +++ b/src/messages/Message.hpp @@ -42,6 +42,7 @@ enum class MessageFlag : uint32_t { ShowInMentions = (1 << 22), FirstMessage = (1 << 23), ReplyMessage = (1 << 24), + ElevatedMessage = (1 << 25), }; using MessageFlags = FlagsEnum; diff --git a/src/messages/layouts/MessageLayout.cpp b/src/messages/layouts/MessageLayout.cpp index 23917f2a5..07c2ddc0b 100644 --- a/src/messages/layouts/MessageLayout.cpp +++ b/src/messages/layouts/MessageLayout.cpp @@ -310,8 +310,16 @@ void MessageLayout::updateBuffer(QPixmap *buffer, int /*messageIndex*/, } }(); - if (this->message_->flags.has(MessageFlag::FirstMessage) && - getSettings()->enableFirstMessageHighlight.getValue()) + if (this->message_->flags.has(MessageFlag::ElevatedMessage) && + getSettings()->enableElevatedMessageHighlight.getValue()) + { + backgroundColor = blendColors(backgroundColor, + *ColorProvider::instance().color( + ColorType::ElevatedMessageHighlight)); + } + + else if (this->message_->flags.has(MessageFlag::FirstMessage) && + getSettings()->enableFirstMessageHighlight.getValue()) { backgroundColor = blendColors( backgroundColor, diff --git a/src/providers/colors/ColorProvider.cpp b/src/providers/colors/ColorProvider.cpp index 350db3536..39f30778d 100644 --- a/src/providers/colors/ColorProvider.cpp +++ b/src/providers/colors/ColorProvider.cpp @@ -133,6 +133,20 @@ void ColorProvider::initTypeColorMap() std::make_shared( HighlightPhrase::FALLBACK_FIRST_MESSAGE_HIGHLIGHT_COLOR)}); } + + customColor = getSettings()->elevatedMessageHighlightColor; + if (QColor(customColor).isValid()) + { + this->typeColorMap_.insert({ColorType::ElevatedMessageHighlight, + std::make_shared(customColor)}); + } + else + { + this->typeColorMap_.insert( + {ColorType::ElevatedMessageHighlight, + std::make_shared( + HighlightPhrase::FALLBACK_ELEVATED_MESSAGE_HIGHLIGHT_COLOR)}); + } } void ColorProvider::initDefaultColors() diff --git a/src/providers/colors/ColorProvider.hpp b/src/providers/colors/ColorProvider.hpp index 4540caaf3..d7fe80f9a 100644 --- a/src/providers/colors/ColorProvider.hpp +++ b/src/providers/colors/ColorProvider.hpp @@ -13,6 +13,7 @@ enum class ColorType { Whisper, RedeemedHighlight, FirstMessageHighlight, + ElevatedMessageHighlight, }; class ColorProvider diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index d04462d7c..d63b6b1fa 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -198,6 +198,11 @@ MessagePtr TwitchMessageBuilder::build() this->message().flags.set(MessageFlag::FirstMessage); } + if (this->tags.contains("pinned-chat-paid-amount")) + { + this->message().flags.set(MessageFlag::ElevatedMessage); + } + // reply threads if (this->thread_) { diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 40b0ef638..c21be1d69 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -292,6 +292,17 @@ public: QStringSetting firstMessageHighlightColor = { "/highlighting/firstMessageHighlightColor", ""}; + BoolSetting enableElevatedMessageHighlight = { + "/highlighting/elevatedMessageHighlight/highlighted", true}; + // BoolSetting enableElevatedMessageHighlightSound = { + // "/highlighting/elevatedMessageHighlight/enableSound", false}; + // BoolSetting enableElevatedMessageHighlightTaskbar = { + // "/highlighting/elevatedMessageHighlight/enableTaskbarFlashing", false}; + QStringSetting elevatedMessageHighlightSoundUrl = { + "/highlighting/elevatedMessageHighlight/soundUrl", ""}; + QStringSetting elevatedMessageHighlightColor = { + "/highlighting/elevatedMessageHighlight/color", ""}; + BoolSetting enableSubHighlight = { "/highlighting/subHighlight/subsHighlighted", true}; BoolSetting enableSubHighlightSound = { diff --git a/src/util/SampleData.cpp b/src/util/SampleData.cpp index 2a9af80f7..c165f1619 100644 --- a/src/util/SampleData.cpp +++ b/src/util/SampleData.cpp @@ -106,6 +106,9 @@ const QStringList &getSampleMiscMessages() // mod announcement R"(@badge-info=subscriber/47;badges=broadcaster/1,subscriber/3012,twitchconAmsterdam2020/1;color=#FF0000;display-name=Supinic;emotes=;flags=;id=8c26e1ab-b50c-4d9d-bc11-3fd57a941d90;login=supinic;mod=0;msg-id=announcement;msg-param-color=PRIMARY;room-id=31400525;subscriber=1;system-msg=;tmi-sent-ts=1648762219962;user-id=31400525;user-type= :tmi.twitch.tv USERNOTICE #supinic :mm test lol)", + + // Elevated Message (Paid option for keeping a message in chat longer) + R"(@badge-info=subscriber/3;badges=subscriber/0,bits-charity/1;color=#0000FF;display-name=SnoopyTheBot;emotes=;first-msg=0;flags=;id=8779a9e5-cf1b-47b3-b9fe-67a5b1b605f6;mod=0;pinned-chat-paid-amount=500;pinned-chat-paid-canonical-amount=5;pinned-chat-paid-currency=USD;pinned-chat-paid-exponent=2;returning-chatter=0;room-id=36340781;subscriber=1;tmi-sent-ts=1664505974154;turbo=0;user-id=136881249;user-type= :snoopythebot!snoopythebot@snoopythebot.tmi.twitch.tv PRIVMSG #pajlada :-$5)", }; return list; } diff --git a/src/widgets/Scrollbar.cpp b/src/widgets/Scrollbar.cpp index e9c98f2a6..df4100977 100644 --- a/src/widgets/Scrollbar.cpp +++ b/src/widgets/Scrollbar.cpp @@ -257,6 +257,8 @@ void Scrollbar::paintEvent(QPaintEvent *) bool enableRedeemedHighlights = getSettings()->enableRedeemedHighlight; bool enableFirstMessageHighlights = getSettings()->enableFirstMessageHighlight; + bool enableElevatedMessageHighlights = + getSettings()->enableElevatedMessageHighlight; // painter.fillRect(QRect(xOffset, 0, width(), this->buttonHeight), // this->themeManager->ScrollbarArrow); @@ -313,6 +315,12 @@ void Scrollbar::paintEvent(QPaintEvent *) continue; } + if (highlight.isElevatedMessageHighlight() && + !enableElevatedMessageHighlights) + { + continue; + } + QColor color = highlight.getColor(); color.setAlpha(255); diff --git a/src/widgets/helper/ScrollbarHighlight.cpp b/src/widgets/helper/ScrollbarHighlight.cpp index 256e2c185..efd50e43d 100644 --- a/src/widgets/helper/ScrollbarHighlight.cpp +++ b/src/widgets/helper/ScrollbarHighlight.cpp @@ -14,11 +14,13 @@ ScrollbarHighlight::ScrollbarHighlight() ScrollbarHighlight::ScrollbarHighlight(const std::shared_ptr color, Style style, bool isRedeemedHighlight, - bool isFirstMessageHighlight) + bool isFirstMessageHighlight, + bool isElevatedMessageHighlight) : color_(color) , style_(style) , isRedeemedHighlight_(isRedeemedHighlight) , isFirstMessageHighlight_(isFirstMessageHighlight) + , isElevatedMessageHighlight_(isElevatedMessageHighlight) { } @@ -42,6 +44,11 @@ bool ScrollbarHighlight::isFirstMessageHighlight() const return this->isFirstMessageHighlight_; } +bool ScrollbarHighlight::isElevatedMessageHighlight() const +{ + return this->isElevatedMessageHighlight_; +} + bool ScrollbarHighlight::isNull() const { return this->style_ == None || !this->color_; diff --git a/src/widgets/helper/ScrollbarHighlight.hpp b/src/widgets/helper/ScrollbarHighlight.hpp index f6b936b35..fb08286c7 100644 --- a/src/widgets/helper/ScrollbarHighlight.hpp +++ b/src/widgets/helper/ScrollbarHighlight.hpp @@ -22,12 +22,14 @@ public: ScrollbarHighlight(const std::shared_ptr color, Style style = Default, bool isRedeemedHighlight = false, - bool isFirstMessageHighlight = false); + bool isFirstMessageHighlight = false, + bool isElevatedMessageHighlight = false); QColor getColor() const; Style getStyle() const; bool isRedeemedHighlight() const; bool isFirstMessageHighlight() const; + bool isElevatedMessageHighlight() const; bool isNull() const; private: @@ -35,6 +37,7 @@ private: Style style_; bool isRedeemedHighlight_; bool isFirstMessageHighlight_; + bool isElevatedMessageHighlight_; }; } // namespace chatterino From 9e722d05e9c5ffc64cdfd1ccbc5d8c96875ba4c2 Mon Sep 17 00:00:00 2001 From: Felanbird <41973452+Felanbird@users.noreply.github.com> Date: Sat, 1 Oct 2022 06:27:25 -0400 Subject: [PATCH 031/946] Add `flags.elevated_message` filter variable (#4017) --- CHANGELOG.md | 1 + src/controllers/filters/parser/FilterParser.cpp | 2 ++ src/controllers/filters/parser/Tokenizer.hpp | 1 + 3 files changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index cca3b0935..78a379317 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,7 @@ - Minor: Fixed `/streamlink` command not stripping leading @'s or #'s (#3215) - Minor: Strip leading @ and trailing , from username in `/popout` command. (#3217) - Minor: Added `flags.reward_message` filter variable (#3231) +- Minor: Added `flags.elevated_message` filter variable. (#4017) - Minor: Added chatter count to viewer list popout (#3261) - Minor: Ignore out of bounds check for tiling wms (#3270) - Minor: Add clear cache button to cache settings section (#3277) diff --git a/src/controllers/filters/parser/FilterParser.cpp b/src/controllers/filters/parser/FilterParser.cpp index 104192641..6b74900c1 100644 --- a/src/controllers/filters/parser/FilterParser.cpp +++ b/src/controllers/filters/parser/FilterParser.cpp @@ -29,6 +29,7 @@ ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel) * flags.system_message * flags.reward_message * flags.first_message + * flags.elevated_message * flags.whisper * flags.reply * flags.automod @@ -83,6 +84,7 @@ ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel) {"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.whisper", m->flags.has(MessageFlag::Whisper)}, {"flags.reply", m->flags.has(MessageFlag::ReplyMessage)}, {"flags.automod", m->flags.has(MessageFlag::AutoMod)}, diff --git a/src/controllers/filters/parser/Tokenizer.hpp b/src/controllers/filters/parser/Tokenizer.hpp index 4e5a6798d..d0297545f 100644 --- a/src/controllers/filters/parser/Tokenizer.hpp +++ b/src/controllers/filters/parser/Tokenizer.hpp @@ -24,6 +24,7 @@ static const QMap validIdentifiersMap = { {"flags.system_message", "system message?"}, {"flags.reward_message", "channel point reward message?"}, {"flags.first_message", "first message?"}, + {"flags.elevated_message", "elevated message?"}, {"flags.whisper", "whisper message?"}, {"flags.reply", "reply message?"}, {"flags.automod", "automod message?"}, From d024a1ef7e1b7ed866a5662d562233453cf220b6 Mon Sep 17 00:00:00 2001 From: Felanbird <41973452+Felanbird@users.noreply.github.com> Date: Sat, 1 Oct 2022 07:01:54 -0400 Subject: [PATCH 032/946] Add `is:elevated-msg` search predicate (#4018) Co-authored-by: pajlada --- CHANGELOG.md | 1 + src/messages/search/MessageFlagsPredicate.cpp | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 78a379317..e6cb295b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ - Minor: Added `Go to message` context menu action to search popup, mentions, usercard and reply threads. (#3953) - Minor: Clicking `A message from x was deleted` messages will now jump to the message in question. (#3953) - Minor: Added `is:first-msg` search option. (#3700) +- Minor: Added `is:elevated-msg` search option. (#4018) - Minor: Added AutoMod message flag filter. (#3938) - Minor: Added chatter count for each category in viewer list. (#3683, #3719) - Minor: Added option to open a user's chat in a new tab from the usercard profile picture context menu. (#3625) diff --git a/src/messages/search/MessageFlagsPredicate.cpp b/src/messages/search/MessageFlagsPredicate.cpp index 01a124020..9f0d29d1b 100644 --- a/src/messages/search/MessageFlagsPredicate.cpp +++ b/src/messages/search/MessageFlagsPredicate.cpp @@ -34,6 +34,10 @@ MessageFlagsPredicate::MessageFlagsPredicate(const QString &flags) { this->flags_.set(MessageFlag::FirstMessage); } + else if (flag == "elevated-msg") + { + this->flags_.set(MessageFlag::ElevatedMessage); + } } } From 5e02fdab52c0a1e43be1d3ee25fec2575dfe0654 Mon Sep 17 00:00:00 2001 From: pajlada Date: Sat, 1 Oct 2022 13:42:05 +0200 Subject: [PATCH 033/946] Fix usage of FrankerFaceZ global emote API (#3921) We no longer blindly parse all sets as global emotes, but rather match them against the default_sets as intended. This means that some emotes will no longer be visible through Chatterino (e.g. AndKnuckles). This is more in line with how the FrankerFaceZ browser extension works. --- CHANGELOG.md | 1 + src/providers/ffz/FfzEmotes.cpp | 20 +++++++++++++++++++- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e6cb295b6..b66ea03ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,7 @@ - Bugfix: Fixed crash related to logging IRC channels (#3918) - Bugfix: Mentions of "You" in timeouts will link to your own user now instead of the user "You". (#3922) - Bugfix: Fixed emoji popup not being shown in IRC channels (#4021) +- Bugfix: Fixed non-global FrankerFaceZ emotes from being loaded as global emotes. (#3921) - Dev: Removed official support for QMake. (#3839, #3883) - Dev: Rewrote LimitedQueue (#3798) - Dev: Overhauled highlight system by moving all checks into a Controller allowing for easier tests. (#3399, #3801, #3835) diff --git a/src/providers/ffz/FfzEmotes.cpp b/src/providers/ffz/FfzEmotes.cpp index 25cba7d15..8a6ccdea7 100644 --- a/src/providers/ffz/FfzEmotes.cpp +++ b/src/providers/ffz/FfzEmotes.cpp @@ -56,12 +56,30 @@ namespace { std::pair parseGlobalEmotes( const QJsonObject &jsonRoot, const EmoteMap ¤tEmotes) { + // Load default sets from the `default_sets` object + std::unordered_set defaultSets{}; + auto jsonDefaultSets = jsonRoot.value("default_sets").toArray(); + for (auto jsonDefaultSet : jsonDefaultSets) + { + defaultSets.insert(jsonDefaultSet.toInt()); + } + auto jsonSets = jsonRoot.value("sets").toObject(); auto emotes = EmoteMap(); for (auto jsonSet : jsonSets) { - auto jsonEmotes = jsonSet.toObject().value("emoticons").toArray(); + auto jsonSetObject = jsonSet.toObject(); + const auto emoteSetID = jsonSetObject.value("id").toInt(); + if (defaultSets.find(emoteSetID) == defaultSets.end()) + { + qCDebug(chatterinoFfzemotes) + << "Skipping global emote set" << emoteSetID + << "as it's not part of the default sets"; + continue; + } + + auto jsonEmotes = jsonSetObject.value("emoticons").toArray(); for (auto jsonEmoteValue : jsonEmotes) { From b5241670ae6c7ef1579cd42c4d934576ed9ce79a Mon Sep 17 00:00:00 2001 From: nerix Date: Sat, 1 Oct 2022 14:05:05 +0200 Subject: [PATCH 034/946] fix: `smoothScrollingNewMessages` sometimes hiding messages (#4028) Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 1 + src/widgets/Scrollbar.cpp | 8 ++++++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b66ea03ce..e08fc631a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ - Bugfix: Mentions of "You" in timeouts will link to your own user now instead of the user "You". (#3922) - Bugfix: Fixed emoji popup not being shown in IRC channels (#4021) - Bugfix: Fixed non-global FrankerFaceZ emotes from being loaded as global emotes. (#3921) +- Bugfix: Fixed `Smooth scrolling on new messages` setting sometimes hiding messages. (#4028) - Dev: Removed official support for QMake. (#3839, #3883) - Dev: Rewrote LimitedQueue (#3798) - Dev: Overhauled highlight system by moving all checks into a Controller allowing for easier tests. (#3399, #3801, #3835) diff --git a/src/widgets/Scrollbar.cpp b/src/widgets/Scrollbar.cpp index df4100977..7fb44f49e 100644 --- a/src/widgets/Scrollbar.cpp +++ b/src/widgets/Scrollbar.cpp @@ -120,7 +120,7 @@ void Scrollbar::setDesiredValue(qreal value, bool animated) value = std::max(this->minimum_, std::min(this->maximum_ - this->largeChange_, value)); - if (std::abs(this->desiredValue_ + this->smoothScrollingOffset_ - value) > + if (std::abs(this->currentValue_ + this->smoothScrollingOffset_ - value) > 0.0001) { if (animated) @@ -142,8 +142,12 @@ void Scrollbar::setDesiredValue(qreal value, bool animated) } else { - if (this->currentValueAnimation_.state() != + if (this->currentValueAnimation_.state() == QPropertyAnimation::Running) + { + this->currentValueAnimation_.setEndValue(value); + } + else { this->smoothScrollingOffset_ = 0; this->desiredValue_ = value; From bfcc9ff7a4f042f02b1780b9f506831c0ac2b284 Mon Sep 17 00:00:00 2001 From: xel86 Date: Sat, 1 Oct 2022 08:30:29 -0400 Subject: [PATCH 035/946] Add search predicates for badges and sub tiers (#4013) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 2 + src/CMakeLists.txt | 4 ++ src/messages/search/BadgePredicate.cpp | 48 ++++++++++++++++++++++++ src/messages/search/BadgePredicate.hpp | 38 +++++++++++++++++++ src/messages/search/SubtierPredicate.cpp | 35 +++++++++++++++++ src/messages/search/SubtierPredicate.hpp | 38 +++++++++++++++++++ src/widgets/helper/SearchPopup.cpp | 26 +++++++++++++ 7 files changed, 191 insertions(+) create mode 100644 src/messages/search/BadgePredicate.cpp create mode 100644 src/messages/search/BadgePredicate.hpp create mode 100644 src/messages/search/SubtierPredicate.cpp create mode 100644 src/messages/search/SubtierPredicate.hpp diff --git a/CHANGELOG.md b/CHANGELOG.md index e08fc631a..8fe87b659 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,8 @@ - Minor: Clicking `A message from x was deleted` messages will now jump to the message in question. (#3953) - Minor: Added `is:first-msg` search option. (#3700) - Minor: Added `is:elevated-msg` search option. (#4018) +- Minor: Added `subtier:` search option (e.g. `subtier:3` to find Tier 3 subs). (#4013) +- Minor: Added `badge:` search option (e.g. `badge:mod` to users with the moderator badge). (#4013) - Minor: Added AutoMod message flag filter. (#3938) - Minor: Added chatter count for each category in viewer list. (#3683, #3719) - Minor: Added option to open a user's chat in a new tab from the usercard profile picture context menu. (#3625) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index af6067cf7..c9618370e 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -156,6 +156,8 @@ set(SOURCE_FILES messages/layouts/MessageLayoutElement.hpp messages/search/AuthorPredicate.cpp messages/search/AuthorPredicate.hpp + messages/search/BadgePredicate.cpp + messages/search/BadgePredicate.hpp messages/search/ChannelPredicate.cpp messages/search/ChannelPredicate.hpp messages/search/LinkPredicate.cpp @@ -166,6 +168,8 @@ set(SOURCE_FILES messages/search/RegexPredicate.hpp messages/search/SubstringPredicate.cpp messages/search/SubstringPredicate.hpp + messages/search/SubtierPredicate.cpp + messages/search/SubtierPredicate.hpp providers/IvrApi.cpp providers/IvrApi.hpp diff --git a/src/messages/search/BadgePredicate.cpp b/src/messages/search/BadgePredicate.cpp new file mode 100644 index 000000000..308624658 --- /dev/null +++ b/src/messages/search/BadgePredicate.cpp @@ -0,0 +1,48 @@ +#include "messages/search/BadgePredicate.hpp" + +#include "util/Qt.hpp" + +namespace chatterino { + +BadgePredicate::BadgePredicate(const QStringList &badges) +{ + // Check if any comma-seperated values were passed and transform those + for (const auto &entry : badges) + { + for (const auto &badge : entry.split(',', Qt::SkipEmptyParts)) + { + // convert short form name of certain badges to formal name + if (badge.compare("mod", Qt::CaseInsensitive) == 0) + { + this->badges_ << "moderator"; + } + else if (badge.compare("sub", Qt::CaseInsensitive) == 0) + { + this->badges_ << "subscriber"; + } + else if (badge.compare("prime", Qt::CaseInsensitive) == 0) + { + this->badges_ << "premium"; + } + else + { + this->badges_ << badge; + } + } + } +} + +bool BadgePredicate::appliesTo(const Message &message) +{ + for (const Badge &badge : message.badges) + { + if (badges_.contains(badge.key_, Qt::CaseInsensitive)) + { + return true; + } + } + + return false; +} + +} // namespace chatterino diff --git a/src/messages/search/BadgePredicate.hpp b/src/messages/search/BadgePredicate.hpp new file mode 100644 index 000000000..f4e990eec --- /dev/null +++ b/src/messages/search/BadgePredicate.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include "messages/search/MessagePredicate.hpp" + +namespace chatterino { + +/** + * @brief MessagePredicate checking for the badges of a message. + * + * This predicate will only allow messages that are sent by a list of users, + * specified by their user names, who have a badge specified (i.e 'staff'). + */ +class BadgePredicate : public MessagePredicate +{ +public: + /** + * @brief Create an BadgePredicate with a list of badges to search for. + * + * @param badges a list of badges that a message should contain + */ + BadgePredicate(const QStringList &badges); + + /** + * @brief Checks whether the message contains any of the badges passed + * in the constructor. + * + * @param message the message to check + * @return true if the message contains a badge listed in the specified badges, + * false otherwise + */ + bool appliesTo(const Message &message) override; + +private: + /// Holds the badges that will be searched for + QStringList badges_; +}; + +} // namespace chatterino diff --git a/src/messages/search/SubtierPredicate.cpp b/src/messages/search/SubtierPredicate.cpp new file mode 100644 index 000000000..8c4ce3f13 --- /dev/null +++ b/src/messages/search/SubtierPredicate.cpp @@ -0,0 +1,35 @@ +#include "messages/search/SubtierPredicate.hpp" + +#include "util/Qt.hpp" + +namespace chatterino { + +SubtierPredicate::SubtierPredicate(const QStringList &subtiers) +{ + // Check if any comma-seperated values were passed and transform those + for (const auto &entry : subtiers) + { + for (const auto &subtier : entry.split(',', Qt::SkipEmptyParts)) + { + this->subtiers_ << subtier; + } + } +} + +bool SubtierPredicate::appliesTo(const Message &message) +{ + for (const Badge &badge : message.badges) + { + if (badge.key_ == "subscriber") + { + const auto &subTier = + badge.value_.length() > 3 ? badge.value_.at(0) : '1'; + + return subtiers_.contains(subTier); + } + } + + return false; +} + +} // namespace chatterino diff --git a/src/messages/search/SubtierPredicate.hpp b/src/messages/search/SubtierPredicate.hpp new file mode 100644 index 000000000..87bcfe10d --- /dev/null +++ b/src/messages/search/SubtierPredicate.hpp @@ -0,0 +1,38 @@ +#pragma once + +#include "messages/search/MessagePredicate.hpp" + +namespace chatterino { + +/** + * @brief MessagePredicate checking for the badges of a message. + * + * This predicate will only allow messages that are sent by a subscribed user + * who has a specified subtier (i.e. 1,2,3..) + */ +class SubtierPredicate : public MessagePredicate +{ +public: + /** + * @brief Create an SubtierPredicate with a list of subtiers to search for. + * + * @param subtiers a list of subtiers that a message should contain + */ + SubtierPredicate(const QStringList &subtiers); + + /** + * @brief Checks whether the message contains any of the subtiers passed + * in the constructor. + * + * @param message the message to check + * @return true if the message contains a subtier listed in the specified subtiers, + * false otherwise + */ + bool appliesTo(const Message &message) override; + +private: + /// Holds the subtiers that will be searched for + QStringList subtiers_; +}; + +} // namespace chatterino diff --git a/src/widgets/helper/SearchPopup.cpp b/src/widgets/helper/SearchPopup.cpp index 85d2e21f0..f09186165 100644 --- a/src/widgets/helper/SearchPopup.cpp +++ b/src/widgets/helper/SearchPopup.cpp @@ -7,11 +7,13 @@ #include "common/Channel.hpp" #include "controllers/hotkeys/HotkeyController.hpp" #include "messages/search/AuthorPredicate.hpp" +#include "messages/search/BadgePredicate.hpp" #include "messages/search/ChannelPredicate.hpp" #include "messages/search/LinkPredicate.hpp" #include "messages/search/MessageFlagsPredicate.hpp" #include "messages/search/RegexPredicate.hpp" #include "messages/search/SubstringPredicate.hpp" +#include "messages/search/SubtierPredicate.hpp" #include "singletons/WindowManager.hpp" #include "widgets/helper/ChannelView.hpp" @@ -297,6 +299,8 @@ std::vector> SearchPopup::parsePredicates( std::vector> predicates; QStringList authors; QStringList channels; + QStringList badges; + QStringList subtiers; while (it.hasNext()) { @@ -312,6 +316,14 @@ std::vector> SearchPopup::parsePredicates( { authors.append(value); } + else if (name == "badge") + { + badges.append(value); + } + else if (name == "subtier") + { + subtiers.append(value); + } else if (name == "has" && value == "link") { predicates.push_back(std::make_unique()); @@ -337,10 +349,24 @@ std::vector> SearchPopup::parsePredicates( } if (!authors.empty()) + { predicates.push_back(std::make_unique(authors)); + } if (!channels.empty()) + { predicates.push_back(std::make_unique(channels)); + } + + if (!badges.empty()) + { + predicates.push_back(std::make_unique(badges)); + } + + if (!subtiers.empty()) + { + predicates.push_back(std::make_unique(subtiers)); + } return predicates; } From adbc4690afd3a0ce6eec2724be34ba74c771ae40 Mon Sep 17 00:00:00 2001 From: Aiden Date: Sat, 1 Oct 2022 15:00:45 +0100 Subject: [PATCH 036/946] Migrate /unvip to Helix API (#4025) Co-authored-by: iProdigy Co-authored-by: pajlada --- CHANGELOG.md | 1 + .../commands/CommandController.cpp | 94 +++++++++++++++++++ src/providers/twitch/api/Helix.cpp | 78 +++++++++++++++ src/providers/twitch/api/Helix.hpp | 22 +++++ tests/src/HighlightController.cpp | 8 ++ 5 files changed, 203 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fe87b659..710e734f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -45,6 +45,7 @@ - Minor: Migrated /mod command to Helix API. (#4000) - Minor: Migrated /unmod command to Helix API. (#4001) - Minor: Migrated /vip command to Helix API. (#4010) +- Minor: Migrated /unvip command to Helix API. (#4025) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fixed a crash that can occur when closing and quickly reopening a split, then running a command. (#3852) - Bugfix: Fixed a crash that can occur when changing channels. (#3799) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 913fd658f..8202e6c40 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -1725,6 +1725,100 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); + + this->registerCommand("/unvip", [](const QStringList &words, auto channel) { + if (words.size() < 2) + { + channel->addMessage(makeSystemMessage( + "Usage: \"/unvip \" - Revoke VIP status from a user. " + "Use \"/vips\" to list the VIPs of this channel.")); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage( + makeSystemMessage("You must be logged in to UnVIP someone!")); + return ""; + } + + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /unvip command only works in Twitch channels")); + return ""; + } + + auto target = words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [twitchChannel, channel](const HelixUser &targetUser) { + getHelix()->removeChannelVIP( + twitchChannel->roomId(), targetUser.id, + [channel, targetUser] { + channel->addMessage(makeSystemMessage( + QString( + "You have removed %1 as a VIP of this channel.") + .arg(targetUser.displayName))); + }, + [channel, targetUser](auto error, auto message) { + QString errorMessage = + QString("Failed to remove VIP - "); + + using Error = HelixRemoveChannelVIPError; + + switch (error) + { + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::Ratelimited: { + errorMessage += + "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::Forwarded: { + // These are actually the IRC equivalents, so we can ditch the prefix + errorMessage = message; + } + break; + + case Error::Unknown: + default: { + errorMessage += + "An unknown error has occurred."; + } + break; + } + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel, target] { + // Equivalent error from IRC + channel->addMessage(makeSystemMessage( + QString("Invalid username: %1").arg(target))); + }); + + return ""; + }); } void CommandController::save() diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 0fd906e45..c12cf4d3b 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -1257,6 +1257,84 @@ void Helix::addChannelVIP( .execute(); } +void Helix::removeChannelVIP( + QString broadcasterID, QString userID, ResultCallback<> successCallback, + FailureCallback failureCallback) +{ + using Error = HelixRemoveChannelVIPError; + + QUrlQuery urlQuery; + + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + urlQuery.addQueryItem("user_id", userID); + + this->makeRequest("channels/vips", urlQuery) + .type(NetworkRequestType::Delete) + .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + if (result.status() != 204) + { + qCWarning(chatterinoTwitch) + << "Success result for removing channel VIP was" + << result.status() << "but we only expected it to be 204"; + } + + successCallback(); + return Success; + }) + .onError([failureCallback](auto result) { + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (result.status()) + { + case 400: + case 409: + case 422: { + // Most of the errors returned by this endpoint are pretty good. We can rely on Twitch's API messages + failureCallback(Error::Forwarded, message); + } + break; + + case 401: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + // Handle this error specifically because its API error is especially unfriendly + failureCallback(Error::UserMissingScope, message); + } + else if (message.compare("incorrect user authorization", + Qt::CaseInsensitive) == 0 || + message.startsWith("the id in broadcaster_id must " + "match the user id", + Qt::CaseInsensitive)) + { + // This error is particularly ugly, but is the equivalent to a user not having permissions + failureCallback(Error::UserNotAuthorized, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 429: { + failureCallback(Error::Ratelimited, message); + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error removing channel VIP:" + << 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 8c591c632..20526cd37 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -391,6 +391,16 @@ enum class HelixAddChannelVIPError { Forwarded, }; +enum class HelixRemoveChannelVIPError { + Unknown, + UserMissingScope, + UserNotAuthorized, + Ratelimited, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; + class IHelix { public: @@ -551,6 +561,12 @@ public: QString broadcasterID, QString userID, ResultCallback<> successCallback, FailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#remove-channel-vip + virtual void removeChannelVIP( + QString broadcasterID, QString userID, ResultCallback<> successCallback, + FailureCallback + failureCallback) = 0; + virtual void update(QString clientId, QString oauthToken) = 0; }; @@ -705,6 +721,12 @@ public: FailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference#remove-channel-vip + void removeChannelVIP(QString broadcasterID, QString userID, + ResultCallback<> successCallback, + FailureCallback + failureCallback) final; + void update(QString clientId, QString oauthToken) final; static void initialize(); diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index 977fac8c3..2f07776b3 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -257,6 +257,14 @@ public: (FailureCallback failureCallback)), (override)); + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD( + void, removeChannelVIP, + (QString broadcasterID, QString userID, + ResultCallback<> successCallback, + (FailureCallback failureCallback)), + (override)); + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override)); }; From a275a1793a02fac09b21bf459224223a12c729ee Mon Sep 17 00:00:00 2001 From: Aiden Date: Sat, 1 Oct 2022 16:10:06 +0100 Subject: [PATCH 037/946] Migrate /unban and /untimeout to Helix API (#4026) Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 2 + .../commands/CommandController.cpp | 141 ++++++++++++++++++ src/providers/twitch/api/Helix.cpp | 112 ++++++++++++++ src/providers/twitch/api/Helix.hpp | 31 ++++ tests/src/HighlightController.cpp | 14 +- 5 files changed, 297 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 710e734f3..72faba7e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,8 @@ - Minor: Migrated /unmod command to Helix API. (#4001) - Minor: Migrated /vip command to Helix API. (#4010) - Minor: Migrated /unvip command to Helix API. (#4025) +- Minor: Migrated /untimeout to Helix API. (#4026) +- Minor: Migrated /unban to Helix API. (#4026) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fixed a crash that can occur when closing and quickly reopening a split, then running a command. (#3852) - Bugfix: Fixed a crash that can occur when changing channels. (#3799) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 8202e6c40..ba1b87fad 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -1819,6 +1819,147 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); + + // These changes are from the helix-command-migration/unban-untimeout branch + // These changes are from the helix-command-migration/unban-untimeout branch + // These changes are from the helix-command-migration/unban-untimeout branch + // These changes are from the helix-command-migration/unban-untimeout branch + // These changes are from the helix-command-migration/unban-untimeout branch + // These changes are from the helix-command-migration/unban-untimeout branch + // These changes are from the helix-command-migration/unban-untimeout branch + // These changes are from the helix-command-migration/unban-untimeout branch + // These changes are from the helix-command-migration/unban-untimeout branch + // These changes are from the helix-command-migration/unban-untimeout branch + // These changes are from the helix-command-migration/unban-untimeout branch + auto unbanLambda = [](auto words, auto channel) { + auto commandName = words.at(0).toLower(); + if (words.size() < 2) + { + channel->addMessage(makeSystemMessage( + QString("Usage: \"%1 \" - Removes a ban on a user.") + .arg(commandName))); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage( + makeSystemMessage("You must be logged in to unban someone!")); + return ""; + } + + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + QString("The %1 command only works in Twitch channels") + .arg(commandName))); + return ""; + } + + auto target = words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [channel, currentUser, twitchChannel, + target](const auto &targetUser) { + getHelix()->unbanUser( + twitchChannel->roomId(), currentUser->getUserId(), + targetUser.id, + [] { + // No response for unbans, they're emitted over pubsub/IRC instead + }, + [channel, target, targetUser](auto error, auto message) { + using Error = HelixUnbanUserError; + + QString errorMessage = + QString("Failed to unban user - "); + + switch (error) + { + case Error::ConflictingOperation: { + errorMessage += + "There was a conflicting ban operation on " + "this user. Please try again."; + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Ratelimited: { + errorMessage += + "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::TargetNotBanned: { + // Equivalent IRC error + errorMessage = + QString( + "%1 is not banned from this channel.") + .arg(targetUser.displayName); + } + break; + + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::Unknown: { + errorMessage += + "An unknown error has occurred."; + } + break; + } + + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel, target] { + // Equivalent error from IRC + channel->addMessage(makeSystemMessage( + QString("Invalid username: %1").arg(target))); + }); + + return ""; + }; // These changes are from the helix-command-migration/unban-untimeout branch + + this->registerCommand("/unban", [unbanLambda](const QStringList &words, + auto channel) { + return unbanLambda(words, channel); + }); // These changes are from the helix-command-migration/unban-untimeout branch + + this->registerCommand("/untimeout", [unbanLambda](const QStringList &words, + auto channel) { + return unbanLambda(words, channel); + }); // These changes are from the helix-command-migration/unban-untimeout branch + // These changes are from the helix-command-migration/unban-untimeout branch + // These changes are from the helix-command-migration/unban-untimeout branch + // These changes are from the helix-command-migration/unban-untimeout branch + // These changes are from the helix-command-migration/unban-untimeout branch + // These changes are from the helix-command-migration/unban-untimeout branch + // These changes are from the helix-command-migration/unban-untimeout branch + // These changes are from the helix-command-migration/unban-untimeout branch + // These changes are from the helix-command-migration/unban-untimeout branch + // These changes are from the helix-command-migration/unban-untimeout branch + // These changes are from the helix-command-migration/unban-untimeout branch } void CommandController::save() diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index c12cf4d3b..e2c3999d3 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -1335,6 +1335,118 @@ void Helix::removeChannelVIP( .execute(); } +// These changes are from the helix-command-migration/unban-untimeout branch +// These changes are from the helix-command-migration/unban-untimeout branch +// These changes are from the helix-command-migration/unban-untimeout branch +// These changes are from the helix-command-migration/unban-untimeout branch +// These changes are from the helix-command-migration/unban-untimeout branch +// These changes are from the helix-command-migration/unban-untimeout branch +// These changes are from the helix-command-migration/unban-untimeout branch +// These changes are from the helix-command-migration/unban-untimeout branch +// These changes are from the helix-command-migration/unban-untimeout branch +// These changes are from the helix-command-migration/unban-untimeout branch +void Helix::unbanUser( + QString broadcasterID, QString moderatorID, QString userID, + ResultCallback<> successCallback, + FailureCallback failureCallback) +{ + using Error = HelixUnbanUserError; + + QUrlQuery urlQuery; + + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + urlQuery.addQueryItem("moderator_id", moderatorID); + urlQuery.addQueryItem("user_id", userID); + + this->makeRequest("moderation/bans", urlQuery) + .type(NetworkRequestType::Delete) + .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + if (result.status() != 204) + { + qCWarning(chatterinoTwitch) + << "Success result for unbanning user was" + << result.status() << "but we only expected it to be 204"; + } + + successCallback(); + return Success; + }) + .onError([failureCallback](auto result) { + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (result.status()) + { + case 400: { + if (message.startsWith("The user in the user_id query " + "parameter is not banned", + Qt::CaseInsensitive)) + { + failureCallback(Error::TargetNotBanned, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 409: { + failureCallback(Error::ConflictingOperation, message); + } + break; + + case 401: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + // Handle this error specifically because its API error is especially unfriendly + failureCallback(Error::UserMissingScope, message); + } + else if (message.compare("incorrect user authorization", + Qt::CaseInsensitive) == 0 || + message.startsWith("the id in broadcaster_id must " + "match the user id", + Qt::CaseInsensitive)) + { + // This error is particularly ugly, but is the equivalent to a user not having permissions + failureCallback(Error::UserNotAuthorized, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 429: { + failureCallback(Error::Ratelimited, message); + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error unbanning user:" << result.status() + << result.getData() << obj; + failureCallback(Error::Unknown, message); + } + break; + } + }) + .execute(); +} // These changes are from the helix-command-migration/unban-untimeout branch +// These changes are from the helix-command-migration/unban-untimeout branch +// These changes are from the helix-command-migration/unban-untimeout branch +// These changes are from the helix-command-migration/unban-untimeout branch +// These changes are from the helix-command-migration/unban-untimeout branch +// These changes are from the helix-command-migration/unban-untimeout branch +// These changes are from the helix-command-migration/unban-untimeout branch +// These changes are from the helix-command-migration/unban-untimeout branch +// These changes are from the helix-command-migration/unban-untimeout branch +// These changes are from the helix-command-migration/unban-untimeout branch +// These changes are from the helix-command-migration/unban-untimeout branch +// These changes are from the helix-command-migration/unban-untimeout branch + 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 20526cd37..3bc6af36f 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -401,6 +401,19 @@ enum class HelixRemoveChannelVIPError { Forwarded, }; +// These changes are from the helix-command-migration/unban-untimeout branch +enum class HelixUnbanUserError { + Unknown, + UserMissingScope, + UserNotAuthorized, + Ratelimited, + ConflictingOperation, + TargetNotBanned, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; // These changes are from the helix-command-migration/unban-untimeout branch + class IHelix { public: @@ -567,6 +580,15 @@ public: FailureCallback failureCallback) = 0; + // These changes are from the helix-command-migration/unban-untimeout branch + // https://dev.twitch.tv/docs/api/reference#unban-user + // These changes are from the helix-command-migration/unban-untimeout branch + virtual void unbanUser( + QString broadcasterID, QString moderatorID, QString userID, + ResultCallback<> successCallback, + FailureCallback failureCallback) = 0; + // These changes are from the helix-command-migration/unban-untimeout branch + virtual void update(QString clientId, QString oauthToken) = 0; }; @@ -727,6 +749,15 @@ public: FailureCallback failureCallback) final; + // These changes are from the helix-command-migration/unban-untimeout branch + // https://dev.twitch.tv/docs/api/reference#unban-user + // These changes are from the helix-command-migration/unban-untimeout branch + void unbanUser( + QString broadcasterID, QString moderatorID, QString userID, + ResultCallback<> successCallback, + FailureCallback failureCallback) final; + // These changes are from the helix-command-migration/unban-untimeout branch + void update(QString clientId, QString oauthToken) final; static void initialize(); diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index 2f07776b3..f818ea7a3 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -257,12 +257,20 @@ public: (FailureCallback failureCallback)), (override)); + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, removeChannelVIP, + (QString broadcasterID, QString userID, + ResultCallback<> successCallback, + (FailureCallback + failureCallback)), + (override)); + // The extra parenthesis around the failure callback is because its type contains a comma MOCK_METHOD( - void, removeChannelVIP, - (QString broadcasterID, QString userID, + void, unbanUser, + (QString broadcasterID, QString moderatorID, QString userID, ResultCallback<> successCallback, - (FailureCallback failureCallback)), + (FailureCallback failureCallback)), (override)); MOCK_METHOD(void, update, (QString clientId, QString oauthToken), From ba586f01d0c015fd3111ae6d7e33db23c4c89631 Mon Sep 17 00:00:00 2001 From: nerix Date: Sat, 1 Oct 2022 17:36:22 +0200 Subject: [PATCH 038/946] fix: Display Sent IRC Messages Like Received Ones (#4027) --- CHANGELOG.md | 3 +- src/messages/MessageBuilder.cpp | 166 ++++++++++++++++++++++++ src/messages/MessageBuilder.hpp | 24 ++++ src/messages/SharedMessageBuilder.cpp | 35 ----- src/messages/SharedMessageBuilder.hpp | 4 - src/providers/irc/IrcChannel2.cpp | 48 +++++-- src/providers/irc/IrcMessageBuilder.cpp | 132 +------------------ src/providers/irc/IrcMessageBuilder.hpp | 4 - 8 files changed, 230 insertions(+), 186 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72faba7e7..62c460398 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ - Minor: Migrated /untimeout to Helix API. (#4026) - Minor: Migrated /unban to Helix API. (#4026) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) +- Bugfix: Fixed `Smooth scrolling on new messages` setting sometimes hiding messages. (#4028) - Bugfix: Fixed a crash that can occur when closing and quickly reopening a split, then running a command. (#3852) - Bugfix: Fixed a crash that can occur when changing channels. (#3799) - Bugfix: Fixed viewers list search not working when used before loading finishes. (#3774) @@ -71,8 +72,8 @@ - Bugfix: Fixed crash related to logging IRC channels (#3918) - Bugfix: Mentions of "You" in timeouts will link to your own user now instead of the user "You". (#3922) - Bugfix: Fixed emoji popup not being shown in IRC channels (#4021) +- Bugfix: Display sent IRC messages like received ones (#4027) - Bugfix: Fixed non-global FrankerFaceZ emotes from being loaded as global emotes. (#3921) -- Bugfix: Fixed `Smooth scrolling on new messages` setting sometimes hiding messages. (#4028) - Dev: Removed official support for QMake. (#3839, #3883) - Dev: Rewrote LimitedQueue (#3798) - Dev: Overhauled highlight system by moving all checks into a Controller allowing for easier tests. (#3399, #3801, #3835) diff --git a/src/messages/MessageBuilder.cpp b/src/messages/MessageBuilder.cpp index 18f2b9102..4eab00cf7 100644 --- a/src/messages/MessageBuilder.cpp +++ b/src/messages/MessageBuilder.cpp @@ -1,6 +1,7 @@ #include "MessageBuilder.hpp" #include "Application.hpp" +#include "common/IrcColors.hpp" #include "common/LinkParser.hpp" #include "controllers/accounts/AccountController.hpp" #include "messages/Image.hpp" @@ -16,6 +17,14 @@ #include +namespace { + +QRegularExpression IRC_COLOR_PARSE_REGEX( + "(\u0003(\\d{1,2})?(,(\\d{1,2}))?|\u000f)", + QRegularExpression::UseUnicodePropertiesOption); + +} // namespace + namespace chatterino { MessagePtr makeSystemMessage(const QString &text) @@ -579,6 +588,163 @@ void MessageBuilder::addLink(const QString &origLink, }); } +void MessageBuilder::addIrcMessageText(const QString &text) +{ + this->message().messageText = text; + + auto words = text.split(' '); + MessageColor defaultColorType = MessageColor::Text; + const auto &defaultColor = defaultColorType.getColor(*getApp()->themes); + QColor textColor = defaultColor; + int fg = -1; + int bg = -1; + + for (const auto &word : words) + { + if (word.isEmpty()) + { + continue; + } + + auto string = QString(word); + + // Actually just text + auto linkString = this->matchLink(string); + auto link = Link(); + + if (!linkString.isEmpty()) + { + this->addLink(string, linkString); + continue; + } + + // Does the word contain a color changer? If so, split on it. + // Add color indicators, then combine into the same word with the color being changed + + auto i = IRC_COLOR_PARSE_REGEX.globalMatch(string); + + if (!i.hasNext()) + { + this->addIrcWord(string, textColor); + continue; + } + + int lastPos = 0; + + while (i.hasNext()) + { + auto match = i.next(); + + if (lastPos != match.capturedStart() && match.capturedStart() != 0) + { + if (fg >= 0 && fg <= 98) + { + textColor = IRC_COLORS[fg]; + getApp()->themes->normalizeColor(textColor); + } + else + { + textColor = defaultColor; + } + this->addIrcWord( + string.mid(lastPos, match.capturedStart() - lastPos), + textColor, false); + lastPos = match.capturedStart() + match.capturedLength(); + } + if (!match.captured(1).isEmpty()) + { + fg = -1; + bg = -1; + } + + if (!match.captured(2).isEmpty()) + { + fg = match.captured(2).toInt(nullptr); + } + else + { + fg = -1; + } + if (!match.captured(4).isEmpty()) + { + bg = match.captured(4).toInt(nullptr); + } + else if (fg == -1) + { + bg = -1; + } + + lastPos = match.capturedStart() + match.capturedLength(); + } + + if (fg >= 0 && fg <= 98) + { + textColor = IRC_COLORS[fg]; + getApp()->themes->normalizeColor(textColor); + } + else + { + textColor = defaultColor; + } + this->addIrcWord(string.mid(lastPos), textColor); + } + + this->message().elements.back()->setTrailingSpace(false); +} + +void MessageBuilder::addTextOrEmoji(EmotePtr emote) +{ + this->emplace(emote, MessageElementFlag::EmojiAll); +} + +void MessageBuilder::addTextOrEmoji(const QString &string_) +{ + auto string = QString(string_); + + // Actually just text + auto linkString = this->matchLink(string); + auto link = Link(); + + auto &&textColor = this->textColor_; + if (linkString.isEmpty()) + { + if (string.startsWith('@')) + { + this->emplace(string, MessageElementFlag::BoldUsername, + textColor, FontStyle::ChatMediumBold); + this->emplace( + string, MessageElementFlag::NonBoldUsername, textColor); + } + else + { + this->emplace(string, MessageElementFlag::Text, + textColor); + } + } + else + { + this->addLink(string, linkString); + } +} + +void MessageBuilder::addIrcWord(const QString &text, const QColor &color, + bool addSpace) +{ + this->textColor_ = color; + for (auto &variant : getApp()->emotes->emojis.parse(text)) + { + boost::apply_visitor( + [&](auto &&arg) { + this->addTextOrEmoji(arg); + }, + variant); + if (!addSpace) + { + this->message().elements.back()->setTrailingSpace(false); + } + } +} + TextElement *MessageBuilder::emplaceSystemTextAndUpdate(const QString &text, QString &toUpdate) { diff --git a/src/messages/MessageBuilder.hpp b/src/messages/MessageBuilder.hpp index c217cf1ed..7d725b13b 100644 --- a/src/messages/MessageBuilder.hpp +++ b/src/messages/MessageBuilder.hpp @@ -64,6 +64,13 @@ public: QString matchLink(const QString &string); void addLink(const QString &origLink, const QString &matchedLink); + /** + * Adds the text, applies irc colors, adds links, + * and updates the message's messageText. + * See https://modern.ircdocs.horse/formatting.html + */ + void addIrcMessageText(const QString &text); + template // clang-format off // clang-format can be enabled once clang-format v11+ has been installed in CI @@ -79,6 +86,12 @@ public: return pointer; } +protected: + virtual void addTextOrEmoji(EmotePtr emote); + virtual void addTextOrEmoji(const QString &value); + + MessageColor textColor_ = MessageColor::Text; + private: // Helper method that emplaces some text stylized as system text // and then appends that text to the QString parameter "toUpdate". @@ -86,6 +99,17 @@ private: TextElement *emplaceSystemTextAndUpdate(const QString &text, QString &toUpdate); + /** + * This will add the text and replace any emojis + * with an emoji emote-element. + * + * @param text Text to add + * @param color Color of the text + * @param addSpace true if a trailing space should be added after emojis + */ + void addIrcWord(const QString &text, const QColor &color, + bool addSpace = true); + std::shared_ptr message_; }; diff --git a/src/messages/SharedMessageBuilder.cpp b/src/messages/SharedMessageBuilder.cpp index f059a6708..49508c0b7 100644 --- a/src/messages/SharedMessageBuilder.cpp +++ b/src/messages/SharedMessageBuilder.cpp @@ -181,41 +181,6 @@ void SharedMessageBuilder::parseHighlights() } } -void SharedMessageBuilder::addTextOrEmoji(EmotePtr emote) -{ - this->emplace(emote, MessageElementFlag::EmojiAll); -} - -void SharedMessageBuilder::addTextOrEmoji(const QString &string_) -{ - auto string = QString(string_); - - // Actually just text - auto linkString = this->matchLink(string); - auto link = Link(); - auto &&textColor = this->textColor_; - - if (linkString.isEmpty()) - { - if (string.startsWith('@')) - { - this->emplace(string, MessageElementFlag::BoldUsername, - textColor, FontStyle::ChatMediumBold); - this->emplace( - string, MessageElementFlag::NonBoldUsername, textColor); - } - else - { - this->emplace(string, MessageElementFlag::Text, - textColor); - } - } - else - { - this->addLink(string, linkString); - } -} - void SharedMessageBuilder::appendChannelName() { QString channelName("#" + this->channel->getName()); diff --git a/src/messages/SharedMessageBuilder.hpp b/src/messages/SharedMessageBuilder.hpp index 62adb45df..421339418 100644 --- a/src/messages/SharedMessageBuilder.hpp +++ b/src/messages/SharedMessageBuilder.hpp @@ -53,9 +53,6 @@ protected: // parseHighlights only updates the visual state of the message, but leaves the playing of alerts and sounds to the triggerHighlights function virtual void parseHighlights(); - virtual void addTextOrEmoji(EmotePtr emote); - virtual void addTextOrEmoji(const QString &value); - void appendChannelName(); Channel *channel; @@ -67,7 +64,6 @@ protected: const bool action_{}; QColor usernameColor_ = {153, 153, 153}; - MessageColor textColor_ = MessageColor::Text; bool highlightAlert_ = false; bool highlightSound_ = false; diff --git a/src/providers/irc/IrcChannel2.cpp b/src/providers/irc/IrcChannel2.cpp index 123ae738d..19140b4ee 100644 --- a/src/providers/irc/IrcChannel2.cpp +++ b/src/providers/irc/IrcChannel2.cpp @@ -4,7 +4,9 @@ #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" #include "providers/irc/IrcCommands.hpp" +#include "providers/irc/IrcMessageBuilder.hpp" #include "providers/irc/IrcServer.hpp" +#include "util/Helpers.hpp" namespace chatterino { @@ -33,20 +35,42 @@ void IrcChannel::sendMessage(const QString &message) } else { - if (this->server()) + if (this->server() != nullptr) + { this->server()->sendMessage(this->getName(), message); - MessageBuilder builder; - builder.emplace(); - const auto &nick = this->server()->nick(); - builder.emplace(nick + ":", MessageElementFlag::Username) - ->setLink({Link::UserInfo, nick}); - builder.emplace(message, MessageElementFlag::Text); - builder.message().messageText = message; - builder.message().searchText = nick + ": " + message; - builder.message().loginName = nick; - builder.message().displayName = nick; - this->addMessage(builder.release()); + MessageBuilder builder; + + builder + .emplace("#" + this->getName(), + MessageElementFlag::ChannelName, + MessageColor::System) + ->setLink({Link::JumpToChannel, this->getName()}); + + auto now = QDateTime::currentDateTime(); + builder.emplace(now.time()); + builder.message().serverReceivedTime = now; + + auto username = this->server()->nick(); + builder + .emplace( + username + ":", MessageElementFlag::Username, + getRandomColor(username), FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, username}); + builder.message().loginName = username; + builder.message().displayName = username; + + // message + builder.addIrcMessageText(message); + builder.message().messageText = message; + builder.message().searchText = username + ": " + message; + + this->addMessage(builder.release()); + } + else + { + this->addMessage(makeSystemMessage("You are not connected.")); + } } } diff --git a/src/providers/irc/IrcMessageBuilder.cpp b/src/providers/irc/IrcMessageBuilder.cpp index 77d36fc5a..4109a4eb8 100644 --- a/src/providers/irc/IrcMessageBuilder.cpp +++ b/src/providers/irc/IrcMessageBuilder.cpp @@ -16,14 +16,6 @@ #include "util/IrcHelpers.hpp" #include "widgets/Window.hpp" -namespace { - -QRegularExpression IRC_COLOR_PARSE_REGEX( - "(\u0003(\\d{1,2})?(,(\\d{1,2}))?|\u000f)", - QRegularExpression::UseUnicodePropertiesOption); - -} // namespace - namespace chatterino { IrcMessageBuilder::IrcMessageBuilder( @@ -63,10 +55,9 @@ MessagePtr IrcMessageBuilder::build() this->appendUsername(); - // words - this->addWords(this->originalMessage_.split(' ')); + // message + this->addIrcMessageText(this->originalMessage_); - this->message().messageText = this->originalMessage_; this->message().searchText = this->message().localizedName + " " + this->userName + ": " + this->originalMessage_; @@ -82,125 +73,6 @@ MessagePtr IrcMessageBuilder::build() return this->release(); } -void IrcMessageBuilder::addWords(const QStringList &words) -{ - MessageColor defaultColorType = this->textColor_; - auto defaultColor = defaultColorType.getColor(*getApp()->themes); - QColor textColor = defaultColor; - int fg = -1; - int bg = -1; - - for (auto word : words) - { - if (word.isEmpty()) - { - continue; - } - - auto string = QString(word); - - // Actually just text - auto linkString = this->matchLink(string); - auto link = Link(); - - if (!linkString.isEmpty()) - { - this->addLink(string, linkString); - continue; - } - - // Does the word contain a color changer? If so, split on it. - // Add color indicators, then combine into the same word with the color being changed - - auto i = IRC_COLOR_PARSE_REGEX.globalMatch(string); - - if (!i.hasNext()) - { - this->addText(string, textColor); - continue; - } - - int lastPos = 0; - - while (i.hasNext()) - { - auto match = i.next(); - - if (lastPos != match.capturedStart() && match.capturedStart() != 0) - { - if (fg >= 0 && fg <= 98) - { - textColor = IRC_COLORS[fg]; - getApp()->themes->normalizeColor(textColor); - } - else - { - textColor = defaultColor; - } - this->addText( - string.mid(lastPos, match.capturedStart() - lastPos), - textColor, false); - lastPos = match.capturedStart() + match.capturedLength(); - } - if (!match.captured(1).isEmpty()) - { - fg = -1; - bg = -1; - } - - if (!match.captured(2).isEmpty()) - { - fg = match.captured(2).toInt(nullptr); - } - else - { - fg = -1; - } - if (!match.captured(4).isEmpty()) - { - bg = match.captured(4).toInt(nullptr); - } - else if (fg == -1) - { - bg = -1; - } - - lastPos = match.capturedStart() + match.capturedLength(); - } - - if (fg >= 0 && fg <= 98) - { - textColor = IRC_COLORS[fg]; - getApp()->themes->normalizeColor(textColor); - } - else - { - textColor = defaultColor; - } - this->addText(string.mid(lastPos), textColor); - } - - this->message().elements.back()->setTrailingSpace(false); -} - -void IrcMessageBuilder::addText(const QString &text, const QColor &color, - bool addSpace) -{ - this->textColor_ = color; - for (auto &variant : getApp()->emotes->emojis.parse(text)) - { - boost::apply_visitor( - [&](auto &&arg) { - this->addTextOrEmoji(arg); - }, - variant); - if (!addSpace) - { - this->message().elements.back()->setTrailingSpace(false); - } - } -} - void IrcMessageBuilder::appendUsername() { QString username = this->userName; diff --git a/src/providers/irc/IrcMessageBuilder.hpp b/src/providers/irc/IrcMessageBuilder.hpp index 82f295b32..ef1f089f3 100644 --- a/src/providers/irc/IrcMessageBuilder.hpp +++ b/src/providers/irc/IrcMessageBuilder.hpp @@ -40,10 +40,6 @@ public: private: void appendUsername(); - - void addWords(const QStringList &words); - void addText(const QString &text, const QColor &color, - bool addSpace = true); }; } // namespace chatterino From 2deed8e1cb0d2afd32fb38f3ea883858bb866d98 Mon Sep 17 00:00:00 2001 From: nerix Date: Sat, 1 Oct 2022 20:46:59 +0200 Subject: [PATCH 039/946] fix: set `gtest_force_shared_crt` in tests (#4033) --- CMakeLists.txt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 571bb1637..aef02de4e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -104,12 +104,16 @@ find_package(RapidJSON REQUIRED) find_package(Websocketpp REQUIRED) if (BUILD_TESTS) + # For MSVC: Prevent overriding the parent project's compiler/linker settings + # See https://github.com/google/googletest/blob/main/googletest/README.md#visual-studio-dynamic-vs-static-runtimes + set(gtest_force_shared_crt ON CACHE BOOL "" FORCE) + add_subdirectory("${CMAKE_CURRENT_LIST_DIR}/lib/googletest" "lib/googletest") mark_as_advanced( - BUILD_GMOCK BUILD_GTEST BUILD_SHARED_LIBS - gmock_build_tests gtest_build_samples gtest_build_tests - gtest_disable_pthreads gtest_force_shared_crt gtest_hide_internal_symbols + BUILD_GMOCK BUILD_GTEST BUILD_SHARED_LIBS + gmock_build_tests gtest_build_samples gtest_build_tests + gtest_disable_pthreads gtest_force_shared_crt gtest_hide_internal_symbols ) set_target_properties(gtest PROPERTIES FOLDER lib) From 9816722b5e44058773246e1af34aada075159927 Mon Sep 17 00:00:00 2001 From: Felanbird <41973452+Felanbird@users.noreply.github.com> Date: Sun, 2 Oct 2022 07:25:10 -0400 Subject: [PATCH 040/946] Add `showInMentions` option for Badge Highlights (#4034) Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 1 + .../highlights/BadgeHighlightModel.cpp | 4 +- .../highlights/BadgeHighlightModel.hpp | 9 +++-- src/controllers/highlights/HighlightBadge.cpp | 31 +++++++++------ src/controllers/highlights/HighlightBadge.hpp | 20 ++++++---- .../highlights/HighlightController.cpp | 18 +++++---- .../settingspages/HighlightingPage.cpp | 13 ++++--- tests/src/HighlightController.cpp | 39 ++++++++++++++++++- 8 files changed, 97 insertions(+), 38 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62c460398..f8ff6b93d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - Minor: Added `subtier:` search option (e.g. `subtier:3` to find Tier 3 subs). (#4013) - Minor: Added `badge:` search option (e.g. `badge:mod` to users with the moderator badge). (#4013) - Minor: Added AutoMod message flag filter. (#3938) +- Minor: Added `showInMentions` toggle for Badge Highlights. (#4034) - Minor: Added chatter count for each category in viewer list. (#3683, #3719) - Minor: Added option to open a user's chat in a new tab from the usercard profile picture context menu. (#3625) - Minor: Added scrollbar to `Select filters` dialog. (#3737) diff --git a/src/controllers/highlights/BadgeHighlightModel.cpp b/src/controllers/highlights/BadgeHighlightModel.cpp index 9ea8a2f93..3e1c10a20 100644 --- a/src/controllers/highlights/BadgeHighlightModel.cpp +++ b/src/controllers/highlights/BadgeHighlightModel.cpp @@ -9,7 +9,7 @@ namespace chatterino { // commandmodel BadgeHighlightModel::BadgeHighlightModel(QObject *parent) - : SignalVectorModel(5, parent) + : SignalVectorModel(6, parent) { } @@ -28,6 +28,7 @@ HighlightBadge BadgeHighlightModel::getItemFromRow( return HighlightBadge{ original.badgeName(), row[Column::Badge]->data(Qt::DisplayRole).toString(), + row[Column::ShowInMentions]->data(Qt::CheckStateRole).toBool(), row[Column::FlashTaskbar]->data(Qt::CheckStateRole).toBool(), row[Column::PlaySound]->data(Qt::CheckStateRole).toBool(), row[Column::SoundPath]->data(Qt::UserRole).toString(), @@ -42,6 +43,7 @@ void BadgeHighlightModel::getRowFromItem(const HighlightBadge &item, using Column = BadgeHighlightModel::Column; setStringItem(row[Column::Badge], item.displayName(), false, true); + setBoolItem(row[Column::ShowInMentions], item.showInMentions()); setBoolItem(row[Column::FlashTaskbar], item.hasAlert()); setBoolItem(row[Column::PlaySound], item.hasSound()); setFilePathItem(row[Column::SoundPath], item.getSoundUrl()); diff --git a/src/controllers/highlights/BadgeHighlightModel.hpp b/src/controllers/highlights/BadgeHighlightModel.hpp index ffe77d310..3038b2eba 100644 --- a/src/controllers/highlights/BadgeHighlightModel.hpp +++ b/src/controllers/highlights/BadgeHighlightModel.hpp @@ -17,10 +17,11 @@ public: enum Column { Badge = 0, - FlashTaskbar = 1, - PlaySound = 2, - SoundPath = 3, - Color = 4 + ShowInMentions = 1, + FlashTaskbar = 2, + PlaySound = 3, + SoundPath = 4, + Color = 5 }; protected: diff --git a/src/controllers/highlights/HighlightBadge.cpp b/src/controllers/highlights/HighlightBadge.cpp index 8195cbd36..899b59c39 100644 --- a/src/controllers/highlights/HighlightBadge.cpp +++ b/src/controllers/highlights/HighlightBadge.cpp @@ -9,27 +9,31 @@ QColor HighlightBadge::FALLBACK_HIGHLIGHT_COLOR = QColor(127, 63, 73, 127); bool HighlightBadge::operator==(const HighlightBadge &other) const { - return std::tie(this->badgeName_, this->displayName_, this->hasSound_, - this->hasAlert_, this->soundUrl_, this->color_) == - std::tie(other.badgeName_, other.displayName_, other.hasSound_, - other.hasAlert_, other.soundUrl_, other.color_); + return std::tie(this->badgeName_, this->displayName_, this->showInMentions_, + this->hasSound_, this->hasAlert_, this->soundUrl_, + this->color_) == + std::tie(other.badgeName_, other.displayName_, other.showInMentions_, + other.hasSound_, other.hasAlert_, other.soundUrl_, + other.color_); } HighlightBadge::HighlightBadge(const QString &badgeName, - const QString &displayName, bool hasAlert, - bool hasSound, const QString &soundUrl, - QColor color) - : HighlightBadge(badgeName, displayName, hasAlert, hasSound, soundUrl, - std::make_shared(color)) + const QString &displayName, bool showInMentions, + bool hasAlert, bool hasSound, + const QString &soundUrl, QColor color) + : HighlightBadge(badgeName, displayName, showInMentions, hasAlert, hasSound, + soundUrl, std::make_shared(color)) { } HighlightBadge::HighlightBadge(const QString &badgeName, - const QString &displayName, bool hasAlert, - bool hasSound, const QString &soundUrl, + const QString &displayName, bool showInMentions, + bool hasAlert, bool hasSound, + const QString &soundUrl, std::shared_ptr color) : badgeName_(badgeName) , displayName_(displayName) + , showInMentions_(showInMentions) , hasAlert_(hasAlert) , hasSound_(hasSound) , soundUrl_(soundUrl) @@ -54,6 +58,11 @@ const QString &HighlightBadge::displayName() const return this->displayName_; } +bool HighlightBadge::showInMentions() const +{ + return this->showInMentions_; +} + bool HighlightBadge::hasAlert() const { return this->hasAlert_; diff --git a/src/controllers/highlights/HighlightBadge.hpp b/src/controllers/highlights/HighlightBadge.hpp index c3daf3045..d20cbe815 100644 --- a/src/controllers/highlights/HighlightBadge.hpp +++ b/src/controllers/highlights/HighlightBadge.hpp @@ -15,15 +15,16 @@ public: bool operator==(const HighlightBadge &other) const; HighlightBadge(const QString &badgeName, const QString &displayName, - bool hasAlert, bool hasSound, const QString &soundUrl, - QColor color); + bool showInMentions, bool hasAlert, bool hasSound, + const QString &soundUrl, QColor color); HighlightBadge(const QString &badgeName, const QString &displayName, - bool hasAlert, bool hasSound, const QString &soundUrl, - std::shared_ptr color); + bool showInMentions, bool hasAlert, bool hasSound, + const QString &soundUrl, std::shared_ptr color); const QString &badgeName() const; const QString &displayName() const; + bool showInMentions() const; bool hasAlert() const; bool hasSound() const; bool isMatch(const Badge &badge) const; @@ -53,6 +54,7 @@ private: QString badgeName_; QString displayName_; + bool showInMentions_; bool hasAlert_; bool hasSound_; QUrl soundUrl_; @@ -75,6 +77,7 @@ struct Serialize { chatterino::rj::set(ret, "name", value.badgeName(), a); chatterino::rj::set(ret, "displayName", value.displayName(), a); + chatterino::rj::set(ret, "showInMentions", value.showInMentions(), a); chatterino::rj::set(ret, "alert", value.hasAlert(), a); chatterino::rj::set(ret, "sound", value.hasSound(), a); chatterino::rj::set(ret, "soundUrl", value.getSoundUrl().toString(), a); @@ -94,11 +97,12 @@ struct Deserialize { { PAJLADA_REPORT_ERROR(error); return chatterino::HighlightBadge(QString(), QString(), false, - false, "", QColor()); + false, false, "", QColor()); } QString _name; QString _displayName; + bool _showInMentions = false; bool _hasAlert = true; bool _hasSound = false; QString _soundUrl; @@ -106,6 +110,7 @@ struct Deserialize { chatterino::rj::getSafe(value, "name", _name); chatterino::rj::getSafe(value, "displayName", _displayName); + chatterino::rj::getSafe(value, "showInMentions", _showInMentions); chatterino::rj::getSafe(value, "alert", _hasAlert); chatterino::rj::getSafe(value, "sound", _hasSound); chatterino::rj::getSafe(value, "soundUrl", _soundUrl); @@ -115,8 +120,9 @@ struct Deserialize { if (!_color.isValid()) _color = chatterino::HighlightBadge::FALLBACK_HIGHLIGHT_COLOR; - return chatterino::HighlightBadge(_name, _displayName, _hasAlert, - _hasSound, _soundUrl, _color); + return chatterino::HighlightBadge(_name, _displayName, _showInMentions, + _hasAlert, _hasSound, _soundUrl, + _color); } }; diff --git a/src/controllers/highlights/HighlightController.cpp b/src/controllers/highlights/HighlightController.cpp index 632f76cc4..a01e5439f 100644 --- a/src/controllers/highlights/HighlightController.cpp +++ b/src/controllers/highlights/HighlightController.cpp @@ -181,9 +181,11 @@ void rebuildUserHighlights(Settings &settings, } return HighlightResult{ - highlight.hasAlert(), highlight.hasSound(), - highlightSoundUrl, highlight.getColor(), - highlight.showInMentions(), + highlight.hasAlert(), // + highlight.hasSound(), // + highlightSoundUrl, // + highlight.getColor(), // + highlight.showInMentions(), // }; }}); } @@ -216,11 +218,11 @@ void rebuildBadgeHighlights(Settings &settings, } return HighlightResult{ - highlight.hasAlert(), - highlight.hasSound(), - highlightSoundUrl, - highlight.getColor(), - false, // showInMentions + highlight.hasAlert(), // + highlight.hasSound(), // + highlightSoundUrl, // + highlight.getColor(), // + highlight.showInMentions(), // }; } } diff --git a/src/widgets/settingspages/HighlightingPage.cpp b/src/widgets/settingspages/HighlightingPage.cpp index d75a247a4..02d45f2f3 100644 --- a/src/widgets/settingspages/HighlightingPage.cpp +++ b/src/widgets/settingspages/HighlightingPage.cpp @@ -169,8 +169,8 @@ HighlightingPage::HighlightingPage() ->initialized( &getSettings()->highlightedBadges)) .getElement(); - view->setTitles({"Name", "Flash\ntaskbar", "Play\nsound", - "Custom\nsound", "Color"}); + view->setTitles({"Name", "Show In\nMentions", "Flash\ntaskbar", + "Play\nsound", "Custom\nsound", "Color"}); view->getTableView()->horizontalHeader()->setSectionResizeMode( QHeaderView::Fixed); view->getTableView()->horizontalHeader()->setSectionResizeMode( @@ -195,10 +195,11 @@ HighlightingPage::HighlightingPage() { return; } - getSettings()->highlightedBadges.append(HighlightBadge{ - s->badgeName(), s->displayName(), false, false, "", - *ColorProvider::instance().color( - ColorType::SelfHighlight)}); + getSettings()->highlightedBadges.append( + HighlightBadge{s->badgeName(), s->displayName(), + false, false, false, "", + *ColorProvider::instance().color( + ColorType::SelfHighlight)}); } }); diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index f818ea7a3..4aef0dbf3 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -367,6 +367,15 @@ static QString DEFAULT_SETTINGS = R"!( "sound": false, "soundUrl": "", "color": "#7fe8b7eb" + }, + { + "name": "vip", + "displayName": "VIP", + "showInMentions": true, + "alert": false, + "sound": false, + "soundUrl": "", + "color": "#7fe8b7ec" } ], "subHighlightColor": "#64ffd641" @@ -530,6 +539,32 @@ TEST_F(HighlightControllerTest, A) }, }, }, + { + // Badge highlight with showInMentions only + { + // input + MessageParseArgs{}, // no special args + { + { + "vip", + "0", + }, + }, + "badge", // sender name + "show in mentions only", // original message + }, + { + // expected + true, // state + { + false, // alert + false, // playsound + boost::none, // custom sound url + std::make_shared("#7fe8b7ec"), // color + true, // showInMentions + }, + }, + }, { // User mention with showInMentions { @@ -602,6 +637,8 @@ TEST_F(HighlightControllerTest, A) EXPECT_EQ(isMatch, expected.state) << qUtf8Printable(input.senderName) << ": " << qUtf8Printable(input.originalMessage); - EXPECT_EQ(matchResult, expected.result); + EXPECT_EQ(matchResult, expected.result) + << qUtf8Printable(input.senderName) << ": " + << qUtf8Printable(input.originalMessage); } } From f8f99038927ce4ebe00b5866d5cedd6cd0897ed7 Mon Sep 17 00:00:00 2001 From: Marko Date: Sun, 2 Oct 2022 15:27:55 +0200 Subject: [PATCH 041/946] Migrate `/raid` to Helix. (#4029) Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 1 + src/common/QLogging.cpp | 3 +- src/common/QLogging.hpp | 3 +- .../commands/CommandController.cpp | 145 ++++++++++++++++++ src/providers/twitch/api/Helix.cpp | 83 ++++++++++ src/providers/twitch/api/Helix.hpp | 25 +++ src/singletons/Settings.hpp | 20 +++ src/widgets/settingspages/GeneralPage.cpp | 56 ++++++- tests/src/HighlightController.cpp | 8 + 9 files changed, 337 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f8ff6b93d..12b1f5983 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -49,6 +49,7 @@ - Minor: Migrated /unvip command to Helix API. (#4025) - Minor: Migrated /untimeout to Helix API. (#4026) - Minor: Migrated /unban to Helix API. (#4026) +- Minor: Migrated /raid command to Helix API. (#4029) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fixed `Smooth scrolling on new messages` setting sometimes hiding messages. (#4028) - Bugfix: Fixed a crash that can occur when closing and quickly reopening a split, then running a command. (#3852) diff --git a/src/common/QLogging.cpp b/src/common/QLogging.cpp index 3d343de3a..9f4346a39 100644 --- a/src/common/QLogging.cpp +++ b/src/common/QLogging.cpp @@ -33,9 +33,10 @@ Q_LOGGING_CATEGORY(chatterinoNuulsuploader, "chatterino.nuulsuploader", Q_LOGGING_CATEGORY(chatterinoPubSub, "chatterino.pubsub", logThreshold); Q_LOGGING_CATEGORY(chatterinoRecentMessages, "chatterino.recentmessages", logThreshold); -Q_LOGGING_CATEGORY(chatterinoStreamlink, "chatterino.streamlink", logThreshold); +Q_LOGGING_CATEGORY(chatterinoSettings, "chatterino.settings", logThreshold); Q_LOGGING_CATEGORY(chatterinoStreamerMode, "chatterino.streamermode", logThreshold); +Q_LOGGING_CATEGORY(chatterinoStreamlink, "chatterino.streamlink", 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 d9a0a0eee..79ef9d69c 100644 --- a/src/common/QLogging.hpp +++ b/src/common/QLogging.hpp @@ -25,8 +25,9 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoNotification); Q_DECLARE_LOGGING_CATEGORY(chatterinoNuulsuploader); Q_DECLARE_LOGGING_CATEGORY(chatterinoPubSub); Q_DECLARE_LOGGING_CATEGORY(chatterinoRecentMessages); -Q_DECLARE_LOGGING_CATEGORY(chatterinoStreamlink); +Q_DECLARE_LOGGING_CATEGORY(chatterinoSettings); Q_DECLARE_LOGGING_CATEGORY(chatterinoStreamerMode); +Q_DECLARE_LOGGING_CATEGORY(chatterinoStreamlink); Q_DECLARE_LOGGING_CATEGORY(chatterinoTokenizer); Q_DECLARE_LOGGING_CATEGORY(chatterinoTwitch); Q_DECLARE_LOGGING_CATEGORY(chatterinoUpdate); diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index ba1b87fad..f29684a60 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -38,8 +38,30 @@ #include namespace { + using namespace chatterino; +bool areIRCCommandsStillAvailable() +{ + // TODO: time-gate + return true; +} + +QString useIRCCommand(const QStringList &words) +{ + // Reform the original command + auto originalCommand = words.join(" "); + + // Replace the / with a . to pass it along to TMI + auto newCommand = originalCommand; + newCommand.replace(0, 1, "."); + + qCDebug(chatterinoTwitch) + << "Forwarding command" << originalCommand << "as" << newCommand; + + return newCommand; +} + void sendWhisperMessage(const QString &text) { // (hemirt) pajlada: "we should not be sending whispers through jtv, but @@ -1960,6 +1982,129 @@ void CommandController::initialize(Settings &, Paths &paths) // These changes are from the helix-command-migration/unban-untimeout branch // These changes are from the helix-command-migration/unban-untimeout branch // These changes are from the helix-command-migration/unban-untimeout branch + + this->registerCommand( // /raid + "/raid", [](const QStringList &words, auto channel) -> QString { + switch (getSettings()->helixTimegateRaid.getValue()) + { + case HelixTimegateOverride::Timegate: { + if (areIRCCommandsStillAvailable()) + { + return useIRCCommand(words); + } + + // fall through to Helix logic + } + break; + + case HelixTimegateOverride::AlwaysUseIRC: { + return useIRCCommand(words); + } + break; + + case HelixTimegateOverride::AlwaysUseHelix: { + // do nothing and fall through to Helix logic + } + break; + } + + if (words.size() < 2) + { + channel->addMessage(makeSystemMessage( + "Usage: \"/raid \" - Raid a user. " + "Only the broadcaster can start a raid.")); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage(makeSystemMessage( + "You must be logged in to start a raid!")); + return ""; + } + + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /raid command only works in Twitch channels")); + return ""; + } + + auto target = words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [twitchChannel, channel](const HelixUser &targetUser) { + getHelix()->startRaid( + twitchChannel->roomId(), targetUser.id, + [channel, targetUser] { + channel->addMessage(makeSystemMessage( + QString("You started to raid %1.") + .arg(targetUser.displayName))); + }, + [channel, targetUser](auto error, auto message) { + QString errorMessage = + QString("Failed to start a raid - "); + + using Error = HelixStartRaidError; + + switch (error) + { + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + errorMessage += + "You must be the broadcaster " + "to start a raid."; + } + break; + + case Error::CantRaidYourself: { + errorMessage += + "A channel cannot raid itself."; + } + break; + + case Error::Ratelimited: { + errorMessage += "You are being ratelimited " + "by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Unknown: + default: { + errorMessage += + "An unknown error has occurred."; + } + break; + } + channel->addMessage( + makeSystemMessage(errorMessage)); + }); + }, + [channel, target] { + // Equivalent error from IRC + channel->addMessage(makeSystemMessage( + QString("Invalid username: %1").arg(target))); + }); + + return ""; + }); // /raid } void CommandController::save() diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index e2c3999d3..f385be8be 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -1447,6 +1447,89 @@ void Helix::unbanUser( // These changes are from the helix-command-migration/unban-untimeout branch // These changes are from the helix-command-migration/unban-untimeout branch +void Helix::startRaid( + QString fromBroadcasterID, QString toBroadcasterID, + ResultCallback<> successCallback, + FailureCallback failureCallback) +{ + using Error = HelixStartRaidError; + + QUrlQuery urlQuery; + + urlQuery.addQueryItem("from_broadcaster_id", fromBroadcasterID); + urlQuery.addQueryItem("to_broadcaster_id", toBroadcasterID); + + this->makeRequest("raids", urlQuery) + .type(NetworkRequestType::Post) + .onSuccess( + [successCallback, failureCallback](auto /*result*/) -> Outcome { + successCallback(); + return Success; + }) + .onError([failureCallback](auto result) { + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (result.status()) + { + case 400: { + if (message.compare("The IDs in from_broadcaster_id and " + "to_broadcaster_id cannot be the same.", + Qt::CaseInsensitive) == 0) + { + failureCallback(Error::CantRaidYourself, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 401: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + failureCallback(Error::UserMissingScope, message); + } + else if (message.compare( + "The ID in broadcaster_id must match the user " + "ID " + "found in the request's OAuth token.", + Qt::CaseInsensitive) == 0) + { + // Must be the broadcaster. + failureCallback(Error::UserNotAuthorized, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 409: { + failureCallback(Error::Forwarded, message); + } + break; + + case 429: { + failureCallback(Error::Ratelimited, message); + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error while starting a raid:" + << 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 3bc6af36f..e6e742201 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -414,6 +414,17 @@ enum class HelixUnbanUserError { Forwarded, }; // These changes are from the helix-command-migration/unban-untimeout branch +enum class HelixStartRaidError { // /raid + Unknown, + UserMissingScope, + UserNotAuthorized, + CantRaidYourself, + Ratelimited, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; // /raid + class IHelix { public: @@ -589,6 +600,13 @@ public: FailureCallback failureCallback) = 0; // These changes are from the helix-command-migration/unban-untimeout branch + // https://dev.twitch.tv/docs/api/reference#start-a-raid + virtual void startRaid( + QString fromBroadcasterID, QString toBroadcasterID, + ResultCallback<> successCallback, + FailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#start-a-raid + virtual void update(QString clientId, QString oauthToken) = 0; }; @@ -758,6 +776,13 @@ public: FailureCallback failureCallback) final; // These changes are from the helix-command-migration/unban-untimeout branch + // https://dev.twitch.tv/docs/api/reference#start-a-raid + void startRaid( + QString fromBroadcasterID, QString toBroadcasterID, + ResultCallback<> successCallback, + FailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference#start-a-raid + void update(QString clientId, QString oauthToken) final; static void initialize(); diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index c21be1d69..4ca8e383e 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -58,6 +58,20 @@ enum UsernameDisplayMode : int { LocalizedName = 2, // Localized name UsernameAndLocalizedName = 3, // Username (Localized name) }; + +enum HelixTimegateOverride : int { + // Use the default timegated behaviour + // This means we use the old IRC command up until the migration date and + // switch over to the Helix API only after the migration date + Timegate = 1, + + // Ignore timegating and always force use the IRC command + AlwaysUseIRC = 2, + + // Ignore timegating and always force use the Helix API + AlwaysUseHelix = 3, +}; + /// 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 @@ -395,6 +409,12 @@ public: 800, }; + // Temporary time-gate-overrides + EnumSetting helixTimegateRaid = { + "/misc/twitch/helix-timegate/raid", + HelixTimegateOverride::Timegate, + }; + IntSetting emotesTooltipPreview = {"/misc/emotesTooltipPreview", 1}; BoolSetting openLinksIncognito = {"/misc/openLinksIncognito", 0}; diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index b3ed259ab..4358ceb94 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -1,10 +1,7 @@ -#include "GeneralPage.hpp" - -#include -#include -#include +#include "widgets/settingspages/GeneralPage.hpp" #include "Application.hpp" +#include "common/QLogging.hpp" #include "common/Version.hpp" #include "singletons/Fonts.hpp" #include "singletons/NativeMessaging.hpp" @@ -21,6 +18,9 @@ #include #include +#include +#include +#include #define CHROME_EXTENSION_LINK \ "https://chrome.google.com/webstore/detail/chatterino-native-host/" \ @@ -720,6 +720,52 @@ void GeneralPage::initLayout(GeneralPageView &layout) layout.addCheckbox("Messages in /mentions highlights tab", s.highlightMentions); + // Helix timegate settings + auto helixTimegateGetValue = [](auto val) { + switch (val) + { + case HelixTimegateOverride::Timegate: + return "Timegate"; + case HelixTimegateOverride::AlwaysUseIRC: + return "Always use IRC"; + case HelixTimegateOverride::AlwaysUseHelix: + return "Always use Helix"; + default: + return "Timegate"; + } + }; + + auto helixTimegateSetValue = [](auto args) { + const auto &v = args.value; + if (v == "Timegate") + { + return HelixTimegateOverride::Timegate; + } + if (v == "Always use IRC") + { + return HelixTimegateOverride::AlwaysUseIRC; + } + if (v == "Always use Helix") + { + return HelixTimegateOverride::AlwaysUseHelix; + } + + qCDebug(chatterinoSettings) << "Unknown Helix timegate override value" + << v << ", using default value Timegate"; + return HelixTimegateOverride::Timegate; + }; + + auto *helixTimegateRaid = + layout.addDropdown::type>( + "Helix timegate /raid behaviour", + {"Timegate", "Always use IRC", "Always use Helix"}, + s.helixTimegateRaid, + helixTimegateGetValue, // + helixTimegateSetValue, // + false); + helixTimegateRaid->setMinimumWidth( + helixTimegateRaid->minimumSizeHint().width()); + layout.addStretch(); // invisible element for width diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index 4aef0dbf3..0b329be86 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -273,6 +273,14 @@ public: (FailureCallback failureCallback)), (override)); + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD( // /raid + void, startRaid, + (QString fromBroadcasterID, QString toBroadcasterId, + ResultCallback<> successCallback, + (FailureCallback failureCallback)), + (override)); // /raid + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override)); }; From 54129f76a3c43a8a7d00dd0b8f31fb0aadf49cbe Mon Sep 17 00:00:00 2001 From: nerix Date: Sun, 2 Oct 2022 16:18:10 +0200 Subject: [PATCH 042/946] Migrate /emoteonly and /emoteonlyoff commands to the Helix API (#4015) Co-authored-by: Felanbird <41973452+Felanbird@users.noreply.github.com> Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 2 + .../commands/CommandController.cpp | 116 +++++++++++++ src/providers/twitch/api/Helix.cpp | 164 ++++++++++++++++++ src/providers/twitch/api/Helix.hpp | 157 +++++++++++++++++ src/widgets/splits/SplitHeader.cpp | 76 ++++---- tests/src/HighlightController.cpp | 64 +++++++ 6 files changed, 544 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 12b1f5983..91f6a2d27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,8 @@ - Minor: Migrated /clear command to Helix API. (#3994) - Minor: Migrated /color command to Helix API. (#3988) - Minor: Migrated /delete command to Helix API. (#3999) +- Minor: Migrated /emoteonly command to Helix API. (#4015) +- Minor: Migrated /emoteonlyoff command to Helix API. (#4015) - Minor: Migrated /mod command to Helix API. (#4000) - Minor: Migrated /unmod command to Helix API. (#4001) - Minor: Migrated /vip command to Helix API. (#4010) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index f29684a60..9c4298a55 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -2105,6 +2105,122 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); // /raid + + const auto formatChatSettingsError = + [](const HelixUpdateChatSettingsError error, const QString &message) { + QString errorMessage = QString("Failed to update - "); + using Error = HelixUpdateChatSettingsError; + switch (error) + { + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::Ratelimited: { + errorMessage += "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::Forwarded: { + errorMessage = message; + } + break; + + case Error::Unknown: + default: { + errorMessage += "An unknown error has occurred."; + } + break; + } + return errorMessage; + }; + + this->registerCommand("/emoteonly", [formatChatSettingsError]( + const QStringList & /* words */, + auto channel) { + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage(makeSystemMessage( + "You must be logged in to update chat settings!")); + return ""; + } + + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /emoteonly command only works in Twitch channels")); + return ""; + } + + if (twitchChannel->accessRoomModes()->emoteOnly) + { + channel->addMessage( + makeSystemMessage("This room is already in emote-only mode.")); + return ""; + } + + getHelix()->updateEmoteMode( + twitchChannel->roomId(), currentUser->getUserId(), true, + [](auto) { + //we'll get a message from irc + }, + [channel, formatChatSettingsError](auto error, auto message) { + channel->addMessage( + makeSystemMessage(formatChatSettingsError(error, message))); + }); + return ""; + }); + + this->registerCommand( + "/emoteonlyoff", [formatChatSettingsError]( + const QStringList & /* words */, auto channel) { + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage(makeSystemMessage( + "You must be logged in to update chat settings!")); + return ""; + } + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /emoteonlyoff command only works in Twitch channels")); + return ""; + } + + if (!twitchChannel->accessRoomModes()->emoteOnly) + { + channel->addMessage( + makeSystemMessage("This room is not in emote-only mode.")); + return ""; + } + + getHelix()->updateEmoteMode( + twitchChannel->roomId(), currentUser->getUserId(), false, + [](auto) { + // we'll get a message from irc + }, + [channel, formatChatSettingsError](auto error, auto message) { + channel->addMessage(makeSystemMessage( + formatChatSettingsError(error, message))); + }); + return ""; + }); } void CommandController::save() diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index f385be8be..53225c0b0 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -1530,6 +1530,170 @@ void Helix::startRaid( .execute(); } +void Helix::updateEmoteMode( + QString broadcasterID, QString moderatorID, bool emoteMode, + ResultCallback successCallback, + FailureCallback failureCallback) +{ + QJsonObject json; + json["emote_mode"] = emoteMode; + this->updateChatSettings(broadcasterID, moderatorID, json, successCallback, + failureCallback); +} + +void Helix::updateFollowerMode( + QString broadcasterID, QString moderatorID, + boost::optional followerModeDuration, + ResultCallback successCallback, + FailureCallback failureCallback) +{ + QJsonObject json; + json["follower_mode"] = followerModeDuration.has_value(); + if (followerModeDuration) + { + json["follower_mode_duration"] = *followerModeDuration; + } + + this->updateChatSettings(broadcasterID, moderatorID, json, successCallback, + failureCallback); +} + +void Helix::updateNonModeratorChatDelay( + QString broadcasterID, QString moderatorID, + boost::optional nonModeratorChatDelayDuration, + ResultCallback successCallback, + FailureCallback failureCallback) +{ + QJsonObject json; + json["non_moderator_chat_delay"] = + nonModeratorChatDelayDuration.has_value(); + if (nonModeratorChatDelayDuration) + { + json["non_moderator_chat_delay_duration"] = + *nonModeratorChatDelayDuration; + } + + this->updateChatSettings(broadcasterID, moderatorID, json, successCallback, + failureCallback); +} + +void Helix::updateSlowMode( + QString broadcasterID, QString moderatorID, + boost::optional slowModeWaitTime, + ResultCallback successCallback, + FailureCallback failureCallback) +{ + QJsonObject json; + json["slow_mode"] = slowModeWaitTime.has_value(); + if (slowModeWaitTime) + { + json["slow_mode_wait_time"] = *slowModeWaitTime; + } + + this->updateChatSettings(broadcasterID, moderatorID, json, successCallback, + failureCallback); +} + +void Helix::updateSubscriberMode( + QString broadcasterID, QString moderatorID, bool subscriberMode, + ResultCallback successCallback, + FailureCallback failureCallback) +{ + QJsonObject json; + json["subscriber_mode"] = subscriberMode; + this->updateChatSettings(broadcasterID, moderatorID, json, successCallback, + failureCallback); +} + +void Helix::updateUniqueChatMode( + QString broadcasterID, QString moderatorID, bool uniqueChatMode, + ResultCallback successCallback, + FailureCallback failureCallback) +{ + QJsonObject json; + json["unique_chat_mode"] = uniqueChatMode; + this->updateChatSettings(broadcasterID, moderatorID, json, successCallback, + failureCallback); +} + +void Helix::updateChatSettings( + QString broadcasterID, QString moderatorID, QJsonObject payload, + ResultCallback successCallback, + FailureCallback failureCallback) +{ + using Error = HelixUpdateChatSettingsError; + + QUrlQuery urlQuery; + + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + urlQuery.addQueryItem("moderator_id", moderatorID); + + this->makeRequest("chat/settings", urlQuery) + .type(NetworkRequestType::Patch) + .header("Content-Type", "application/json") + .payload(QJsonDocument(payload).toJson(QJsonDocument::Compact)) + .onSuccess([successCallback](auto result) -> Outcome { + if (result.status() != 200) + { + qCWarning(chatterinoTwitch) + << "Success result for updating chat settings was" + << result.status() << "but we expected it to be 200"; + } + auto response = result.parseJson(); + successCallback(HelixChatSettings( + response.value("data").toArray().first().toObject())); + return Success; + }) + .onError([failureCallback](auto result) { + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (result.status()) + { + case 400: + case 409: + case 422: + case 425: { + failureCallback(Error::Forwarded, message); + } + break; + + case 401: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + // Handle this error specifically because its API error is especially unfriendly + failureCallback(Error::UserMissingScope, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 403: { + failureCallback(Error::UserNotAuthorized, message); + } + break; + + case 429: { + failureCallback(Error::Ratelimited, message); + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error updating chat settings:" + << 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 e6e742201..9f9670800 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -300,6 +300,35 @@ struct HelixChannelEmote { } }; +struct HelixChatSettings { + const QString broadcasterId; + const bool emoteMode; + // boost::none if disabled + const boost::optional followerModeDuration; // time in minutes + const boost::optional + nonModeratorChatDelayDuration; // time in seconds + const boost::optional slowModeWaitTime; // time in seconds + const bool subscriberMode; + const bool uniqueChatMode; + + explicit HelixChatSettings(QJsonObject jsonObject) + : broadcasterId(jsonObject.value("broadcaster_id").toString()) + , emoteMode(jsonObject.value("emote_mode").toBool()) + , followerModeDuration(boost::make_optional( + jsonObject.value("follower_mode").toBool(), + jsonObject.value("follower_mode_duration").toInt())) + , nonModeratorChatDelayDuration(boost::make_optional( + jsonObject.value("non_moderator_chat_delay").toBool(), + jsonObject.value("non_moderator_chat_delay_duration").toInt())) + , slowModeWaitTime(boost::make_optional( + jsonObject.value("slow_mode").toBool(), + jsonObject.value("slow_mode_wait_time").toInt())) + , subscriberMode(jsonObject.value("subscriber_mode").toBool()) + , uniqueChatMode(jsonObject.value("unique_chat_mode").toBool()) + { + } +}; + enum class HelixAnnouncementColor { Blue, Green, @@ -425,6 +454,16 @@ enum class HelixStartRaidError { // /raid Forwarded, }; // /raid +enum class HelixUpdateChatSettingsError { // update chat settings + Unknown, + UserMissingScope, + UserNotAuthorized, + Ratelimited, + Forbidden, + // The error message is forwarded directly from the Twitch API + Forwarded, +}; // update chat settings + class IHelix { public: @@ -607,7 +646,67 @@ public: FailureCallback failureCallback) = 0; // https://dev.twitch.tv/docs/api/reference#start-a-raid + // Updates the emote mode using + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + virtual void updateEmoteMode( + QString broadcasterID, QString moderatorID, bool emoteMode, + ResultCallback successCallback, + FailureCallback + failureCallback) = 0; + + // Updates the follower mode using + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + virtual void updateFollowerMode( + QString broadcasterID, QString moderatorID, + boost::optional followerModeDuration, + ResultCallback successCallback, + FailureCallback + failureCallback) = 0; + + // Updates the non-moderator chat delay using + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + virtual void updateNonModeratorChatDelay( + QString broadcasterID, QString moderatorID, + boost::optional nonModeratorChatDelayDuration, + ResultCallback successCallback, + FailureCallback + failureCallback) = 0; + + // Updates the slow mode using + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + virtual void updateSlowMode( + QString broadcasterID, QString moderatorID, + boost::optional slowModeWaitTime, + ResultCallback successCallback, + FailureCallback + failureCallback) = 0; + + // Updates the subscriber mode using + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + virtual void updateSubscriberMode( + QString broadcasterID, QString moderatorID, bool subscriberMode, + ResultCallback successCallback, + FailureCallback + failureCallback) = 0; + + // Updates the unique chat mode using + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + virtual void updateUniqueChatMode( + QString broadcasterID, QString moderatorID, bool uniqueChatMode, + ResultCallback successCallback, + FailureCallback + failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + virtual void update(QString clientId, QString oauthToken) = 0; + +protected: + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + virtual void updateChatSettings( + QString broadcasterID, QString moderatorID, QJsonObject json, + ResultCallback successCallback, + FailureCallback + failureCallback) = 0; }; class Helix final : public IHelix @@ -783,10 +882,68 @@ public: FailureCallback failureCallback) final; // https://dev.twitch.tv/docs/api/reference#start-a-raid + // Updates the emote mode using + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + void updateEmoteMode(QString broadcasterID, QString moderatorID, + bool emoteMode, + ResultCallback successCallback, + FailureCallback + failureCallback) final; + + // Updates the follower mode using + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + void updateFollowerMode( + QString broadcasterID, QString moderatorID, + boost::optional followerModeDuration, + ResultCallback successCallback, + FailureCallback failureCallback) + final; + + // Updates the non-moderator chat delay using + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + void updateNonModeratorChatDelay( + QString broadcasterID, QString moderatorID, + boost::optional nonModeratorChatDelayDuration, + ResultCallback successCallback, + FailureCallback failureCallback) + final; + + // Updates the slow mode using + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + void updateSlowMode(QString broadcasterID, QString moderatorID, + boost::optional slowModeWaitTime, + ResultCallback successCallback, + FailureCallback + failureCallback) final; + + // Updates the subscriber mode using + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + void updateSubscriberMode( + QString broadcasterID, QString moderatorID, bool subscriberMode, + ResultCallback successCallback, + FailureCallback failureCallback) + final; + + // Updates the unique chat mode using + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + void updateUniqueChatMode( + QString broadcasterID, QString moderatorID, bool uniqueChatMode, + ResultCallback successCallback, + FailureCallback failureCallback) + final; + void update(QString clientId, QString oauthToken) final; static void initialize(); +protected: + // https://dev.twitch.tv/docs/api/reference#update-chat-settings + void updateChatSettings( + QString broadcasterID, QString moderatorID, QJsonObject json, + ResultCallback successCallback, + FailureCallback failureCallback) + final; + private: NetworkRequest makeRequest(QString url, QUrlQuery urlQuery); diff --git a/src/widgets/splits/SplitHeader.cpp b/src/widgets/splits/SplitHeader.cpp index d8c02530b..622218d3c 100644 --- a/src/widgets/splits/SplitHeader.cpp +++ b/src/widgets/splits/SplitHeader.cpp @@ -2,6 +2,7 @@ #include "Application.hpp" #include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandController.hpp" #include "controllers/notifications/NotificationController.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchIrcServer.hpp" @@ -540,9 +541,14 @@ std::unique_ptr SplitHeader::createChatModeMenu() setFollowers->setChecked(roomModes->followerOnly != -1); }); - auto toggle = [this](const QString &command, QAction *action) mutable { - this->split_->getChannel().get()->sendMessage( - command + (action->isChecked() ? "" : "off")); + auto execCommand = [this](const QString &command) { + auto text = getApp()->getCommands()->execCommand( + command, this->split_->getChannel(), false); + this->split_->getChannel()->sendMessage(text); + }; + auto toggle = [execCommand](const QString &command, + QAction *action) mutable { + execCommand(command + (action->isChecked() ? "" : "off")); action->setChecked(!action->isChecked()); }; @@ -556,51 +562,51 @@ std::unique_ptr SplitHeader::createChatModeMenu() toggle("/emoteonly", setEmote); }); - QObject::connect(setSlow, &QAction::triggered, this, [setSlow, this]() { - if (!setSlow->isChecked()) - { - this->split_->getChannel().get()->sendMessage("/slowoff"); - setSlow->setChecked(false); - return; - }; - auto ok = bool(); - auto seconds = QInputDialog::getInt(this, "", "Seconds:", 10, 0, 500, 1, - &ok, Qt::FramelessWindowHint); - if (ok) - { - this->split_->getChannel().get()->sendMessage( - QString("/slow %1").arg(seconds)); - } - else - { - setSlow->setChecked(false); - } - }); - QObject::connect( - setFollowers, &QAction::triggered, this, [setFollowers, this]() { - if (!setFollowers->isChecked()) + setSlow, &QAction::triggered, this, [setSlow, this, execCommand]() { + if (!setSlow->isChecked()) { - this->split_->getChannel().get()->sendMessage("/followersoff"); - setFollowers->setChecked(false); + execCommand("/slowoff"); + setSlow->setChecked(false); return; }; auto ok = bool(); - auto time = QInputDialog::getText( - this, "", "Time:", QLineEdit::Normal, "15m", &ok, - Qt::FramelessWindowHint, - Qt::ImhLowercaseOnly | Qt::ImhPreferNumbers); + auto seconds = + QInputDialog::getInt(this, "", "Seconds:", 10, 0, 500, 1, &ok, + Qt::FramelessWindowHint); if (ok) { - this->split_->getChannel().get()->sendMessage( - QString("/followers %1").arg(time)); + execCommand(QString("/slow %1").arg(seconds)); } else { - setFollowers->setChecked(false); + setSlow->setChecked(false); } }); + QObject::connect(setFollowers, &QAction::triggered, this, + [setFollowers, this, execCommand]() { + if (!setFollowers->isChecked()) + { + execCommand("/followersoff"); + setFollowers->setChecked(false); + return; + }; + auto ok = bool(); + auto time = QInputDialog::getText( + this, "", "Time:", QLineEdit::Normal, "15m", &ok, + Qt::FramelessWindowHint, + Qt::ImhLowercaseOnly | Qt::ImhPreferNumbers); + if (ok) + { + execCommand(QString("/followers %1").arg(time)); + } + else + { + setFollowers->setChecked(false); + } + }); + QObject::connect(setR9k, &QAction::triggered, this, [setR9k, toggle]() mutable { toggle("/r9kbeta", setR9k); diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index 0b329be86..1ad014c98 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -11,6 +11,7 @@ #include #include #include +#include using namespace chatterino; using ::testing::Exactly; @@ -281,8 +282,71 @@ public: (FailureCallback failureCallback)), (override)); // /raid + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, updateEmoteMode, + (QString broadcasterID, QString moderatorID, bool emoteMode, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, updateFollowerMode, + (QString broadcasterID, QString moderatorID, + boost::optional followerModeDuration, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, updateNonModeratorChatDelay, + (QString broadcasterID, QString moderatorID, + boost::optional nonModeratorChatDelayDuration, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, updateSlowMode, + (QString broadcasterID, QString moderatorID, + boost::optional slowModeWaitTime, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, updateSubscriberMode, + (QString broadcasterID, QString moderatorID, + bool subscriberMode, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, updateUniqueChatMode, + (QString broadcasterID, QString moderatorID, + bool uniqueChatMode, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); + // update chat settings + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override)); + +protected: + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, updateChatSettings, + (QString broadcasterID, QString moderatorID, QJsonObject json, + ResultCallback successCallback, + (FailureCallback + failureCallback)), + (override)); }; static QString DEFAULT_SETTINGS = R"!( From 766a30240d3aa82b154c256d0ab27e9b45e81258 Mon Sep 17 00:00:00 2001 From: Felanbird <41973452+Felanbird@users.noreply.github.com> Date: Sun, 2 Oct 2022 17:17:32 -0400 Subject: [PATCH 043/946] Add debug hotkey for test sub messages (#4037) --- src/controllers/hotkeys/ActionNames.hpp | 1 + src/widgets/Window.cpp | 8 ++++++++ 2 files changed, 9 insertions(+) diff --git a/src/controllers/hotkeys/ActionNames.hpp b/src/controllers/hotkeys/ActionNames.hpp index aa335a16c..538fc42e1 100644 --- a/src/controllers/hotkeys/ActionNames.hpp +++ b/src/controllers/hotkeys/ActionNames.hpp @@ -158,6 +158,7 @@ inline const std::map actionNames{ {"addMiscMessage", ActionDefinition{"Debug: Add misc test message"}}, {"addRewardMessage", ActionDefinition{"Debug: Add reward test message"}}, + {"addSubMessage", ActionDefinition{"Debug: Add sub test message"}}, #endif {"moveTab", ActionDefinition{ diff --git a/src/widgets/Window.cpp b/src/widgets/Window.cpp index 74f024993..f976b0c53 100644 --- a/src/widgets/Window.cpp +++ b/src/widgets/Window.cpp @@ -249,6 +249,14 @@ void Window::addDebugStuff(HotkeyController::HotkeyMap &actions) getApp()->twitch->addFakeMessage(msg); return ""; }); + + actions.emplace("addSubMessage", [=](std::vector) -> QString { + const auto &messages = getSampleSubMessages(); + static int index = 0; + const auto &msg = messages[index++ % messages.size()]; + getApp()->twitch->addFakeMessage(msg); + return ""; + }); #endif } From 4c2e97bea68f2c13031f5125f4c17f3e0142a8f9 Mon Sep 17 00:00:00 2001 From: pajlada Date: Sun, 2 Oct 2022 23:53:22 +0200 Subject: [PATCH 044/946] Enable Helix timegating (#4035) For commands affected by the timegating, they will continue to use their IRC command equivalent until the 11th of February, 2023. This is one week before the actual migration is supposed to start. The wording of the date is shaky, so we start a bit before to be sure. Any highly affected commands will have a temporary setting at the bottom of the General settings page to override the timegating functionality. Any commands that are affected will also have their changelog entry updated to notify of the timegating. As of this commit, this is only active for /raid --- CHANGELOG.md | 2 +- src/controllers/commands/CommandController.cpp | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91f6a2d27..f7d279ed2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,7 +51,7 @@ - Minor: Migrated /unvip command to Helix API. (#4025) - Minor: Migrated /untimeout to Helix API. (#4026) - Minor: Migrated /unban to Helix API. (#4026) -- Minor: Migrated /raid command to Helix API. (#4029) +- Minor: Migrated /raid command to Helix API. Chat command will continue to be used until February 11th 2023. (#4029) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fixed `Smooth scrolling on new messages` setting sometimes hiding messages. (#4028) - Bugfix: Fixed a crash that can occur when closing and quickly reopening a split, then running a command. (#3852) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 9c4298a55..41bbdaa18 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -43,8 +43,10 @@ using namespace chatterino; bool areIRCCommandsStillAvailable() { - // TODO: time-gate - return true; + // 11th of February 2023, 06:00am UTC + const QDateTime migrationTime(QDate(2023, 2, 11), QTime(6, 0), Qt::UTC); + auto now = QDateTime::currentDateTimeUtc(); + return now < migrationTime; } QString useIRCCommand(const QStringList &words) From 25bccc90b4f4209a61302c17250dbbe9496070ad Mon Sep 17 00:00:00 2001 From: nerix Date: Mon, 3 Oct 2022 19:42:02 +0200 Subject: [PATCH 045/946] Migrate Remaining Chat Settings Commands to Helix API (#4040) --- CHANGELOG.md | 6 + .../commands/CommandController.cpp | 364 ++++++++++++++++-- src/providers/twitch/api/Helix.cpp | 12 +- src/providers/twitch/api/Helix.hpp | 2 + src/util/Helpers.cpp | 176 +++++++++ src/util/Helpers.hpp | 74 ++++ tests/src/Helpers.cpp | 251 ++++++++++++ 7 files changed, 846 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7d279ed2..bfd7b6f6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,12 @@ - Minor: Migrated /unvip command to Helix API. (#4025) - Minor: Migrated /untimeout to Helix API. (#4026) - Minor: Migrated /unban to Helix API. (#4026) +- Minor: Migrated /subscribers to Helix API. (#4040) +- Minor: Migrated /subscribersoff to Helix API. (#4040) +- Minor: Migrated /slow to Helix API. (#4040) +- Minor: Migrated /slowoff to Helix API. (#4040) +- Minor: Migrated /followers to Helix API. (#4040) +- Minor: Migrated /followersoff to Helix API. (#4040) - Minor: Migrated /raid command to Helix API. Chat command will continue to be used until February 11th 2023. (#4029) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fixed `Smooth scrolling on new messages` setting sometimes hiding messages. (#4028) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 41bbdaa18..a3f23edf3 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -2108,46 +2108,74 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); // /raid - const auto formatChatSettingsError = - [](const HelixUpdateChatSettingsError error, const QString &message) { - QString errorMessage = QString("Failed to update - "); - using Error = HelixUpdateChatSettingsError; - switch (error) - { - case Error::UserMissingScope: { - // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE - errorMessage += "Missing required scope. " - "Re-login with your " - "account and try again."; - } - break; + const auto formatChatSettingsError = [](const HelixUpdateChatSettingsError + error, + const QString &message, + int durationUnitMultiplier = 1) { + static const QRegularExpression invalidRange("(\\d+) through (\\d+)"); - case Error::UserNotAuthorized: { - // TODO(pajlada): Phrase MISSING_PERMISSION - errorMessage += "You don't have permission to " - "perform that action."; - } - break; - - case Error::Ratelimited: { - errorMessage += "You are being ratelimited by Twitch. Try " - "again in a few seconds."; - } - break; - - case Error::Forwarded: { - errorMessage = message; - } - break; - - case Error::Unknown: - default: { - errorMessage += "An unknown error has occurred."; - } - break; + QString errorMessage = QString("Failed to update - "); + using Error = HelixUpdateChatSettingsError; + switch (error) + { + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; } - return errorMessage; - }; + break; + + case Error::UserNotAuthorized: + case Error::Forbidden: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::Ratelimited: { + errorMessage += "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::OutOfRange: { + QRegularExpressionMatch matched = invalidRange.match(message); + if (matched.hasMatch()) + { + auto from = matched.captured(1).toInt(); + auto to = matched.captured(2).toInt(); + errorMessage += + QString("The duration is out of the valid range: " + "%1 through %2.") + .arg(from == 0 ? "0s" + : formatTime(from * + durationUnitMultiplier), + to == 0 + ? "0s" + : formatTime(to * durationUnitMultiplier)); + } + else + { + errorMessage += message; + } + } + break; + + case Error::Forwarded: { + errorMessage = message; + } + break; + + case Error::Unknown: + default: { + errorMessage += "An unknown error has occurred."; + } + break; + } + return errorMessage; + }; this->registerCommand("/emoteonly", [formatChatSettingsError]( const QStringList & /* words */, @@ -2223,6 +2251,266 @@ void CommandController::initialize(Settings &, Paths &paths) }); return ""; }); + + this->registerCommand( + "/subscribers", [formatChatSettingsError]( + const QStringList & /* words */, auto channel) { + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage(makeSystemMessage( + "You must be logged in to update chat settings!")); + return ""; + } + + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /subscribers command only works in Twitch channels")); + return ""; + } + + if (twitchChannel->accessRoomModes()->submode) + { + channel->addMessage(makeSystemMessage( + "This room is already in subscribers-only mode.")); + return ""; + } + + getHelix()->updateSubscriberMode( + twitchChannel->roomId(), currentUser->getUserId(), true, + [](auto) { + //we'll get a message from irc + }, + [channel, formatChatSettingsError](auto error, auto message) { + channel->addMessage(makeSystemMessage( + formatChatSettingsError(error, message))); + }); + return ""; + }); + + this->registerCommand("/subscribersoff", [formatChatSettingsError]( + const QStringList + & /* words */, + auto channel) { + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage(makeSystemMessage( + "You must be logged in to update chat settings!")); + return ""; + } + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /subscribersoff command only works in Twitch channels")); + return ""; + } + + if (!twitchChannel->accessRoomModes()->submode) + { + channel->addMessage(makeSystemMessage( + "This room is not in subscribers-only mode.")); + return ""; + } + + getHelix()->updateSubscriberMode( + twitchChannel->roomId(), currentUser->getUserId(), false, + [](auto) { + // we'll get a message from irc + }, + [channel, formatChatSettingsError](auto error, auto message) { + channel->addMessage( + makeSystemMessage(formatChatSettingsError(error, message))); + }); + return ""; + }); + + this->registerCommand("/slow", [formatChatSettingsError]( + const QStringList &words, auto channel) { + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage(makeSystemMessage( + "You must be logged in to update chat settings!")); + return ""; + } + + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /slow command only works in Twitch channels")); + return ""; + } + + int duration = 30; + if (words.length() >= 2) + { + bool ok = false; + duration = words.at(1).toInt(&ok); + if (!ok || duration <= 0) + { + channel->addMessage(makeSystemMessage( + "Usage: \"/slow [duration]\" - Enables slow mode (limit " + "how often users may send messages). Duration (optional, " + "default=30) must be a positive number of seconds. Use " + "\"slowoff\" to disable. ")); + return ""; + } + } + + if (twitchChannel->accessRoomModes()->slowMode == duration) + { + channel->addMessage(makeSystemMessage( + QString("This room is already in %1-second slow mode.") + .arg(duration))); + return ""; + } + + getHelix()->updateSlowMode( + twitchChannel->roomId(), currentUser->getUserId(), duration, + [](auto) { + //we'll get a message from irc + }, + [channel, formatChatSettingsError](auto error, auto message) { + channel->addMessage( + makeSystemMessage(formatChatSettingsError(error, message))); + }); + return ""; + }); + + this->registerCommand( + "/slowoff", [formatChatSettingsError](const QStringList & /* words */, + auto channel) { + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage(makeSystemMessage( + "You must be logged in to update chat settings!")); + return ""; + } + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /slowoff command only works in Twitch channels")); + return ""; + } + + if (twitchChannel->accessRoomModes()->slowMode <= 0) + { + channel->addMessage( + makeSystemMessage("This room is not in slow mode.")); + return ""; + } + + getHelix()->updateSlowMode( + twitchChannel->roomId(), currentUser->getUserId(), boost::none, + [](auto) { + // we'll get a message from irc + }, + [channel, formatChatSettingsError](auto error, auto message) { + channel->addMessage(makeSystemMessage( + formatChatSettingsError(error, message))); + }); + return ""; + }); + + this->registerCommand("/followers", [formatChatSettingsError]( + const QStringList &words, + auto channel) { + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage(makeSystemMessage( + "You must be logged in to update chat settings!")); + return ""; + } + + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /followers command only works in Twitch channels")); + return ""; + } + + int duration = 0; + if (words.length() >= 2) + { + auto parsed = parseDurationToSeconds(words.mid(1).join(' '), 60); + duration = (int)(parsed / 60); + // -1 / 60 == 0 => use parsed + if (parsed < 0) + { + channel->addMessage(makeSystemMessage( + "Usage: \"/followers [duration]\" - Enables followers-only" + " mode (only users who have followed for 'duration' may " + "chat). Examples: \"30m\", \"1 week\", \"5 days 12 " + "hours\". Must be less than 3 months. ")); + return ""; + } + } + + if (twitchChannel->accessRoomModes()->followerOnly == duration) + { + channel->addMessage(makeSystemMessage( + QString("This room is already in %1 followers-only mode.") + .arg(formatTime(duration * 60)))); + return ""; + } + + getHelix()->updateFollowerMode( + twitchChannel->roomId(), currentUser->getUserId(), duration, + [](auto) { + //we'll get a message from irc + }, + [channel, formatChatSettingsError](auto error, auto message) { + channel->addMessage(makeSystemMessage( + formatChatSettingsError(error, message, 60))); + }); + return ""; + }); + + this->registerCommand("/followersoff", [formatChatSettingsError]( + const QStringList & /* words */, + auto channel) { + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage(makeSystemMessage( + "You must be logged in to update chat settings!")); + return ""; + } + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /followersoff command only works in Twitch channels")); + return ""; + } + + if (twitchChannel->accessRoomModes()->followerOnly < 0) + { + channel->addMessage( + makeSystemMessage("This room is not in followers-only mode. ")); + return ""; + } + + getHelix()->updateFollowerMode( + twitchChannel->roomId(), currentUser->getUserId(), boost::none, + [](auto) { + // we'll get a message from irc + }, + [channel, formatChatSettingsError](auto error, auto message) { + channel->addMessage( + makeSystemMessage(formatChatSettingsError(error, message))); + }); + return ""; + }); } void CommandController::save() diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 53225c0b0..44fb8d844 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -1650,7 +1650,17 @@ void Helix::updateChatSettings( switch (result.status()) { - case 400: + case 400: { + if (message.contains("must be in the range")) + { + failureCallback(Error::OutOfRange, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; case 409: case 422: case 425: { diff --git a/src/providers/twitch/api/Helix.hpp b/src/providers/twitch/api/Helix.hpp index 9f9670800..839953778 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -460,6 +460,8 @@ enum class HelixUpdateChatSettingsError { // update chat settings UserNotAuthorized, Ratelimited, Forbidden, + OutOfRange, + // The error message is forwarded directly from the Twitch API Forwarded, }; // update chat settings diff --git a/src/util/Helpers.cpp b/src/util/Helpers.cpp index 467b9ecb1..7df39196a 100644 --- a/src/util/Helpers.cpp +++ b/src/util/Helpers.cpp @@ -8,6 +8,110 @@ namespace chatterino { +namespace _helpers_internal { + + int skipSpace(const QStringRef &view, int startPos) + { + while (startPos < view.length() && view.at(startPos).isSpace()) + { + startPos++; + } + return startPos - 1; + } + + bool matchesIgnorePlural(const QStringRef &word, const QString &singular) + { + if (!word.startsWith(singular)) + { + return false; + } + if (word.length() == singular.length()) + { + return true; + } + return word.length() == singular.length() + 1 && + word.at(word.length() - 1).toLatin1() == 's'; + } + + std::pair findUnitMultiplierToSec(const QStringRef &view, + int &pos) + { + // Step 1. find end of unit + int startIdx = pos; + int endIdx = view.length(); + for (; pos < view.length(); pos++) + { + auto c = view.at(pos); + if (c.isSpace() || c.isDigit()) + { + endIdx = pos; + break; + } + } + pos--; + + // TODO(QT6): use sliced (more readable) + auto unit = view.mid(startIdx, endIdx - startIdx); + if (unit.isEmpty()) + { + return std::make_pair(0, false); + } + + auto first = unit.at(0).toLatin1(); + switch (first) + { + case 's': { + if (unit.length() == 1 || + matchesIgnorePlural(unit, QStringLiteral("second"))) + { + return std::make_pair(1, true); + } + } + break; + case 'm': { + if (unit.length() == 1 || + matchesIgnorePlural(unit, QStringLiteral("minute"))) + { + return std::make_pair(60, true); + } + if ((unit.length() == 2 && unit.at(1).toLatin1() == 'o') || + matchesIgnorePlural(unit, QStringLiteral("month"))) + { + return std::make_pair(60 * 60 * 24 * 30, true); + } + } + break; + case 'h': { + if (unit.length() == 1 || + matchesIgnorePlural(unit, QStringLiteral("hour"))) + { + return std::make_pair(60 * 60, true); + } + } + break; + case 'd': { + if (unit.length() == 1 || + matchesIgnorePlural(unit, QStringLiteral("day"))) + { + return std::make_pair(60 * 60 * 24, true); + } + } + break; + case 'w': { + if (unit.length() == 1 || + matchesIgnorePlural(unit, QStringLiteral("week"))) + { + return std::make_pair(60 * 60 * 24 * 7, true); + } + } + break; + } + return std::make_pair(0, false); + } + +} // namespace _helpers_internal +using namespace _helpers_internal; + bool startsWithOrContains(const QString &str1, const QString &str2, Qt::CaseSensitivity caseSensitivity, bool startsWith) { @@ -93,4 +197,76 @@ QString formatUserMention(const QString &userName, bool isFirstWord, return result; } +int64_t parseDurationToSeconds(const QString &inputString, + uint64_t noUnitMultiplier) +{ + if (inputString.length() == 0) + { + return -1; + } + + // TODO(QT6): use QStringView + QStringRef input(&inputString); + input = input.trimmed(); + + uint64_t currentValue = 0; + + bool visitingNumber = true; // input must start with a number + int numberStartIdx = 0; + + for (int pos = 0; pos < input.length(); pos++) + { + QChar c = input.at(pos); + + if (visitingNumber && !c.isDigit()) + { + uint64_t parsed = + (uint64_t)input.mid(numberStartIdx, pos - numberStartIdx) + .toUInt(); + + if (c.isSpace()) + { + pos = skipSpace(input, pos) + 1; + if (pos >= input.length()) + { + // input like "40 ", this shouldn't happen + // since we trimmed the view + return -1; + } + c = input.at(pos); + } + + auto result = findUnitMultiplierToSec(input, pos); + if (!result.second) + { + return -1; // invalid unit or leading spaces (shouldn't happen) + } + + currentValue += parsed * result.first; + visitingNumber = false; + } + else if (!visitingNumber && !c.isSpace()) + { + if (!c.isDigit()) + { + return -1; // expected a digit + } + visitingNumber = true; + numberStartIdx = pos; + } + // else: visitingNumber && isDigit || !visitingNumber && isSpace + } + + if (visitingNumber) + { + if (numberStartIdx != 0) + { + return -1; // input like "1w 3s 70", 70 what? apples? + } + currentValue += input.toUInt() * noUnitMultiplier; + } + + return (int64_t)currentValue; +} + } // namespace chatterino diff --git a/src/util/Helpers.hpp b/src/util/Helpers.hpp index 8e5bd6a9d..409089ed7 100644 --- a/src/util/Helpers.hpp +++ b/src/util/Helpers.hpp @@ -2,11 +2,54 @@ #include #include +#include #include namespace chatterino { +// only qualified for tests +namespace _helpers_internal { + + /** + * Skips all spaces. + * The caller must guarantee view.at(startPos).isSpace(). + * + * @param view The string to skip spaces in. + * @param startPos The starting position (there must be a space in the view). + * @return The position of the last space. + */ + int skipSpace(const QStringRef &view, int startPos); + + /** + * Checks if `word` equals `expected` (singular) or `expected` + 's' (plural). + * + * @param word Word to test. Must not be empty. + * @param expected Singular of the expected word. + * @return true if `word` is singular or plural of `expected`. + */ + bool matchesIgnorePlural(const QStringRef &word, const QString &expected); + + /** + * Tries to find the unit starting at `pos` and returns its multiplier so + * `valueInUnit * multiplier = valueInSeconds` (e.g. 60 for minutes). + * + * Supported units are + * 'w[eek(s)]', 'd[ay(s)]', + * 'h[our(s)]', 'm[inute(s)]', 's[econd(s)]'. + * The unit must be in lowercase. + * + * @param view A view into a string + * @param pos The starting position. + * This is set to the last position of the unit + * if it's a valid unit, undefined otherwise. + * @return (multiplier, ok) + */ + std::pair findUnitMultiplierToSec(const QStringRef &view, + int &pos); + +} // namespace _helpers_internal + /** * @brief startsWithOrContains is a wrapper for checking * whether str1 starts with or contains str2 within itself @@ -29,6 +72,37 @@ QString kFormatNumbers(const int &number); QColor getRandomColor(const QString &userId); +/** + * Parses a duration. + * Spaces are allowed before and after a unit but not mandatory. + * Supported units are + * 'w[eek(s)]', 'd[ay(s)]', + * 'h[our(s)]', 'm[inute(s)]', 's[econd(s)]'. + * Units must be lowercase. + * + * If the entire input string is a number (e.g. "12345"), + * then it's multiplied by noUnitMultiplier. + * + * Examples: + * + * - "1w 2h" + * - "1w 1w 0s 4d" (2weeks, 4days) + * - "5s3h4w" (4weeks, 3hours, 5seconds) + * - "30m" + * - "1 week" + * - "5 days 12 hours" + * - "10" (10 * noUnitMultiplier seconds) + * + * @param inputString A non-empty string to parse + * @param noUnitMultiplier A multiplier if the input string only contains one number. + * For example, if a number without a unit should be interpreted + * as a minute, set this to 60. If it should be interpreted + * as a second, set it to 1 (default). + * @return The parsed duration in seconds, -1 if the input is invalid. + */ +int64_t parseDurationToSeconds(const QString &inputString, + uint64_t noUnitMultiplier = 1); + /** * @brief Takes a user's name and some formatting parameter and spits out the standardized way to format it * diff --git a/tests/src/Helpers.cpp b/tests/src/Helpers.cpp index 7db1e1cd3..df987f9d5 100644 --- a/tests/src/Helpers.cpp +++ b/tests/src/Helpers.cpp @@ -3,6 +3,7 @@ #include using namespace chatterino; +using namespace _helpers_internal; TEST(Helpers, formatUserMention) { @@ -250,3 +251,253 @@ TEST(Helpers, BatchDifferentInputType) EXPECT_EQ(result, expectation); } + +TEST(Helpers, skipSpace) +{ + struct TestCase { + QString input; + int startIdx; + int expected; + }; + + std::vector tests{{"foo bar", 3, 6}, {"foo bar", 3, 3}, + {"foo ", 3, 3}, {"foo ", 3, 6}, + {" ", 0, 2}, {" ", 0, 0}}; + + for (const auto &c : tests) + { + const auto actual = skipSpace(&c.input, c.startIdx); + + EXPECT_EQ(actual, c.expected) + << actual << " (" << qUtf8Printable(c.input) + << ") did not match expected value " << c.expected; + } +} + +TEST(Helpers, findUnitMultiplierToSec) +{ + constexpr uint64_t sec = 1; + constexpr uint64_t min = 60; + constexpr uint64_t hour = min * 60; + constexpr uint64_t day = hour * 24; + constexpr uint64_t week = day * 7; + constexpr uint64_t month = day * 30; + constexpr uint64_t bad = 0; + + struct TestCase { + QString input; + int startPos; + int expectedEndPos; + uint64_t expectedMultiplier; + }; + + std::vector tests{ + {"s", 0, 0, sec}, + {"m", 0, 0, min}, + {"h", 0, 0, hour}, + {"d", 0, 0, day}, + {"w", 0, 0, week}, + {"mo", 0, 1, month}, + + {"s alienpls", 0, 0, sec}, + {"m alienpls", 0, 0, min}, + {"h alienpls", 0, 0, hour}, + {"d alienpls", 0, 0, day}, + {"w alienpls", 0, 0, week}, + {"mo alienpls", 0, 1, month}, + + {"alienpls s", 9, 9, sec}, + {"alienpls m", 9, 9, min}, + {"alienpls h", 9, 9, hour}, + {"alienpls d", 9, 9, day}, + {"alienpls w", 9, 9, week}, + {"alienpls mo", 9, 10, month}, + + {"alienpls s alienpls", 9, 9, sec}, + {"alienpls m alienpls", 9, 9, min}, + {"alienpls h alienpls", 9, 9, hour}, + {"alienpls d alienpls", 9, 9, day}, + {"alienpls w alienpls", 9, 9, week}, + {"alienpls mo alienpls", 9, 10, month}, + + {"second", 0, 5, sec}, + {"minute", 0, 5, min}, + {"hour", 0, 3, hour}, + {"day", 0, 2, day}, + {"week", 0, 3, week}, + {"month", 0, 4, month}, + + {"alienpls2 second", 10, 15, sec}, + {"alienpls2 minute", 10, 15, min}, + {"alienpls2 hour", 10, 13, hour}, + {"alienpls2 day", 10, 12, day}, + {"alienpls2 week", 10, 13, week}, + {"alienpls2 month", 10, 14, month}, + + {"alienpls2 second alienpls", 10, 15, sec}, + {"alienpls2 minute alienpls", 10, 15, min}, + {"alienpls2 hour alienpls", 10, 13, hour}, + {"alienpls2 day alienpls", 10, 12, day}, + {"alienpls2 week alienpls", 10, 13, week}, + {"alienpls2 month alienpls", 10, 14, month}, + + {"seconds", 0, 6, sec}, + {"minutes", 0, 6, min}, + {"hours", 0, 4, hour}, + {"days", 0, 3, day}, + {"weeks", 0, 4, week}, + {"months", 0, 5, month}, + + {"alienpls2 seconds", 10, 16, sec}, + {"alienpls2 minutes", 10, 16, min}, + {"alienpls2 hours", 10, 14, hour}, + {"alienpls2 days", 10, 13, day}, + {"alienpls2 weeks", 10, 14, week}, + {"alienpls2 months", 10, 15, month}, + + {"alienpls2 seconds alienpls", 10, 16, sec}, + {"alienpls2 minutes alienpls", 10, 16, min}, + {"alienpls2 hours alienpls", 10, 14, hour}, + {"alienpls2 days alienpls", 10, 13, day}, + {"alienpls2 weeks alienpls", 10, 14, week}, + {"alienpls2 months alienpls", 10, 15, month}, + + {"sec", 0, 0, bad}, + {"min", 0, 0, bad}, + {"ho", 0, 0, bad}, + {"da", 0, 0, bad}, + {"we", 0, 0, bad}, + {"mon", 0, 0, bad}, + {"foo", 0, 0, bad}, + {"S", 0, 0, bad}, + {"M", 0, 0, bad}, + {"H", 0, 0, bad}, + {"D", 0, 0, bad}, + {"W", 0, 0, bad}, + {"MO", 0, 1, bad}, + + {"alienpls2 sec", 10, 0, bad}, + {"alienpls2 min", 10, 0, bad}, + {"alienpls2 ho", 10, 0, bad}, + {"alienpls2 da", 10, 0, bad}, + {"alienpls2 we", 10, 0, bad}, + {"alienpls2 mon", 10, 0, bad}, + {"alienpls2 foo", 10, 0, bad}, + {"alienpls2 S", 10, 0, bad}, + {"alienpls2 M", 10, 0, bad}, + {"alienpls2 H", 10, 0, bad}, + {"alienpls2 D", 10, 0, bad}, + {"alienpls2 W", 10, 0, bad}, + {"alienpls2 MO", 10, 0, bad}, + + {"alienpls2 sec alienpls", 10, 0, bad}, + {"alienpls2 min alienpls", 10, 0, bad}, + {"alienpls2 ho alienpls", 10, 0, bad}, + {"alienpls2 da alienpls", 10, 0, bad}, + {"alienpls2 we alienpls", 10, 0, bad}, + {"alienpls2 mon alienpls", 10, 0, bad}, + {"alienpls2 foo alienpls", 10, 0, bad}, + {"alienpls2 S alienpls", 10, 0, bad}, + {"alienpls2 M alienpls", 10, 0, bad}, + {"alienpls2 H alienpls", 10, 0, bad}, + {"alienpls2 D alienpls", 10, 0, bad}, + {"alienpls2 W alienpls", 10, 0, bad}, + {"alienpls2 MO alienpls", 10, 0, bad}, + }; + + for (const auto &c : tests) + { + int pos = c.startPos; + const auto actual = findUnitMultiplierToSec(&c.input, pos); + + if (c.expectedMultiplier == bad) + { + EXPECT_FALSE(actual.second) << qUtf8Printable(c.input); + } + else + { + EXPECT_TRUE(pos == c.expectedEndPos && actual.second && + actual.first == c.expectedMultiplier) + << qUtf8Printable(c.input) + << ": Expected(end: " << c.expectedEndPos + << ", mult: " << c.expectedMultiplier << ") Actual(end: " << pos + << ", mult: " << actual.first << ")"; + } + } +} + +TEST(Helpers, parseDurationToSeconds) +{ + struct TestCase { + QString input; + int64_t output; + int64_t noUnitMultiplier = 1; + }; + + auto wrongInput = [](QString &&input) { + return TestCase{input, -1}; + }; + + std::vector tests{ + {"1 minutes 9s", 69}, + {"22 m 17 s", 1337}, + {"7d 5h 10m 52s", 623452}, + {"2h 19m 5s ", 8345}, + {"3d 15 h 13m 54s", 314034}, + {"27s", 27}, + { + "9h 36 m 29s", 34589, + 7, // should be unused + }, + {"1h 59s", 3659}, + {"12d2h22m25s", 1045345}, + {"2h22m25s12d", 1045345}, + {"1d32s", 86432}, + {"0", 0}, + {"0 s", 0}, + {"1weeks", 604800}, + {"2 day5days", 604800}, + {"1 day", 86400}, + {"4 hours 30m 19h 30 minute", 86400}, + {"3 months", 7776000}, + {"1 mo 2month", 7776000}, + // from documentation + {"1w 2h", 612000}, + {"1w 1w 0s 4d", 1555200}, + {"5s3h4w", 2430005}, + // from twitch response + {"30m", 1800}, + {"1 week", 604800}, + {"5 days 12 hours", 475200}, + // noUnitMultiplier + {"0", 0, 60}, + { + "60", 3600, + 60, // minute + }, + { + "1", + 86400, // 1d + 86400, + }, + // wrong input + wrongInput("1min"), + wrongInput(""), + wrongInput("1m5w+5"), + wrongInput("1h30"), + wrongInput("12 34w"), + wrongInput("4W"), + wrongInput("1min"), + wrongInput("4Min"), + wrongInput("4sec"), + }; + + for (const auto &c : tests) + { + const auto actual = parseDurationToSeconds(c.input, c.noUnitMultiplier); + + EXPECT_EQ(actual, c.output) + << actual << " (" << qUtf8Printable(c.input) + << ") did not match expected value " << c.output; + } +} From d5b8d89494141ff3627585495fa5754488830d3a Mon Sep 17 00:00:00 2001 From: nerix Date: Mon, 3 Oct 2022 20:05:42 +0200 Subject: [PATCH 046/946] fix: Double-space when using replies with an empty input box (#4041) --- CHANGELOG.md | 2 +- src/widgets/splits/SplitInput.cpp | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bfd7b6f6e..1516dca46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unversioned -- Major: Added support for Twitch's Chat Replies. [Wiki Page](https://wiki.chatterino.com/Features/#message-replies) (#3722, #3989) +- Major: Added support for Twitch's Chat Replies. [Wiki Page](https://wiki.chatterino.com/Features/#message-replies) (#3722, #3989, #4041) - Major: Added multi-channel searching to search dialog via keyboard shortcut. (Ctrl+Shift+F by default) (#3694, #3875) - Minor: Added highlights for `Elevated Messages`. (#4016) - Minor: Removed total views from the usercard, as Twitch no longer updates the number. (#3792) diff --git a/src/widgets/splits/SplitInput.cpp b/src/widgets/splits/SplitInput.cpp index f78ce2746..b45146466 100644 --- a/src/widgets/splits/SplitInput.cpp +++ b/src/widgets/splits/SplitInput.cpp @@ -977,10 +977,14 @@ void SplitInput::setReply(std::shared_ptr reply, if (this->enableInlineReplying_) { // Only enable reply label if inline replying - auto replyPrefix = "@" + this->replyThread_->root()->displayName + " "; + auto replyPrefix = "@" + this->replyThread_->root()->displayName; auto plainText = this->ui_.textEdit->toPlainText().trimmed(); if (!plainText.startsWith(replyPrefix)) { + if (!plainText.isEmpty()) + { + replyPrefix.append(' '); + } this->ui_.textEdit->setPlainText(replyPrefix + plainText + " "); this->ui_.textEdit->moveCursor(QTextCursor::EndOfBlock); } From 03051bf0bdd0b99f788aacd7fda7a3be6c4297db Mon Sep 17 00:00:00 2001 From: pajlada Date: Mon, 3 Oct 2022 20:55:46 +0200 Subject: [PATCH 047/946] Bump MessageFlag underlying type to be 64-bit (#4042) --- src/common/Channel.hpp | 2 +- src/messages/Message.hpp | 56 +++++++++---------- .../layouts/MessageLayoutContainer.hpp | 2 +- src/widgets/helper/ChannelView.hpp | 2 +- 4 files changed, 31 insertions(+), 31 deletions(-) diff --git a/src/common/Channel.hpp b/src/common/Channel.hpp index 8d6ef6557..ef8437ea3 100644 --- a/src/common/Channel.hpp +++ b/src/common/Channel.hpp @@ -16,7 +16,7 @@ namespace chatterino { struct Message; using MessagePtr = std::shared_ptr; -enum class MessageFlag : uint32_t; +enum class MessageFlag : int64_t; using MessageFlags = FlagsEnum; enum class TimeoutStackStyle : int { diff --git a/src/messages/Message.hpp b/src/messages/Message.hpp index 8097f9f24..6c7a75900 100644 --- a/src/messages/Message.hpp +++ b/src/messages/Message.hpp @@ -15,34 +15,34 @@ namespace chatterino { class MessageElement; class MessageThread; -enum class MessageFlag : uint32_t { - None = 0, - System = (1 << 0), - Timeout = (1 << 1), - Highlighted = (1 << 2), - DoNotTriggerNotification = (1 << 3), // disable notification sound - Centered = (1 << 4), - Disabled = (1 << 5), - DisableCompactEmotes = (1 << 6), - Collapsed = (1 << 7), - ConnectedMessage = (1 << 8), - DisconnectedMessage = (1 << 9), - Untimeout = (1 << 10), - PubSub = (1 << 11), - Subscription = (1 << 12), - DoNotLog = (1 << 13), - AutoMod = (1 << 14), - RecentMessage = (1 << 15), - Whisper = (1 << 16), - HighlightedWhisper = (1 << 17), - Debug = (1 << 18), - Similar = (1 << 19), - RedeemedHighlight = (1 << 20), - RedeemedChannelPointReward = (1 << 21), - ShowInMentions = (1 << 22), - FirstMessage = (1 << 23), - ReplyMessage = (1 << 24), - ElevatedMessage = (1 << 25), +enum class MessageFlag : int64_t { + None = 0LL, + System = (1LL << 0), + Timeout = (1LL << 1), + Highlighted = (1LL << 2), + DoNotTriggerNotification = (1LL << 3), // disable notification sound + Centered = (1LL << 4), + Disabled = (1LL << 5), + DisableCompactEmotes = (1LL << 6), + Collapsed = (1LL << 7), + ConnectedMessage = (1LL << 8), + DisconnectedMessage = (1LL << 9), + Untimeout = (1LL << 10), + PubSub = (1LL << 11), + Subscription = (1LL << 12), + DoNotLog = (1LL << 13), + AutoMod = (1LL << 14), + RecentMessage = (1LL << 15), + Whisper = (1LL << 16), + HighlightedWhisper = (1LL << 17), + Debug = (1LL << 18), + Similar = (1LL << 19), + RedeemedHighlight = (1LL << 20), + RedeemedChannelPointReward = (1LL << 21), + ShowInMentions = (1LL << 22), + FirstMessage = (1LL << 23), + ReplyMessage = (1LL << 24), + ElevatedMessage = (1LL << 25), }; using MessageFlags = FlagsEnum; diff --git a/src/messages/layouts/MessageLayoutContainer.hpp b/src/messages/layouts/MessageLayoutContainer.hpp index 26daba24f..b70ab9ec1 100644 --- a/src/messages/layouts/MessageLayoutContainer.hpp +++ b/src/messages/layouts/MessageLayoutContainer.hpp @@ -14,7 +14,7 @@ class QPainter; namespace chatterino { -enum class MessageFlag : uint32_t; +enum class MessageFlag : int64_t; using MessageFlags = FlagsEnum; struct Margin { diff --git a/src/widgets/helper/ChannelView.hpp b/src/widgets/helper/ChannelView.hpp index 123e5cc7c..8e5bfa536 100644 --- a/src/widgets/helper/ChannelView.hpp +++ b/src/widgets/helper/ChannelView.hpp @@ -27,7 +27,7 @@ using ChannelPtr = std::shared_ptr; struct Message; using MessagePtr = std::shared_ptr; -enum class MessageFlag : uint32_t; +enum class MessageFlag : int64_t; using MessageFlags = FlagsEnum; class MessageLayout; From 41581031b9a66a18caa015ddda92d520d9606dbd Mon Sep 17 00:00:00 2001 From: Felanbird <41973452+Felanbird@users.noreply.github.com> Date: Thu, 6 Oct 2022 14:30:40 -0400 Subject: [PATCH 048/946] Add missing 403 handling for /unban (#4050) --- CHANGELOG.md | 2 +- src/providers/twitch/api/Helix.cpp | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1516dca46..2576d254a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,7 +50,7 @@ - Minor: Migrated /vip command to Helix API. (#4010) - Minor: Migrated /unvip command to Helix API. (#4025) - Minor: Migrated /untimeout to Helix API. (#4026) -- Minor: Migrated /unban to Helix API. (#4026) +- Minor: Migrated /unban to Helix API. (#4026, #4050) - Minor: Migrated /subscribers to Helix API. (#4040) - Minor: Migrated /subscribersoff to Helix API. (#4040) - Minor: Migrated /slow to Helix API. (#4040) diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 44fb8d844..959c482f3 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -1419,6 +1419,11 @@ void Helix::unbanUser( } break; + case 403: { + failureCallback(Error::UserNotAuthorized, message); + } + break; + case 429: { failureCallback(Error::Ratelimited, message); } From 874ef642165ba06c42fe7c0c5bc4a0236dc74578 Mon Sep 17 00:00:00 2001 From: nerix Date: Thu, 6 Oct 2022 23:52:25 +0200 Subject: [PATCH 049/946] Migrate `/ban` and `/timeout` to Helix API (#4049) --- CHANGELOG.md | 2 + .../commands/CommandController.cpp | 189 ++++++++++++++++++ src/providers/twitch/api/Helix.cpp | 103 ++++++++++ src/providers/twitch/api/Helix.hpp | 29 ++- tests/src/HighlightController.cpp | 9 + 5 files changed, 331 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2576d254a..8fa1b7f78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,8 @@ - Minor: Migrated /followers to Helix API. (#4040) - Minor: Migrated /followersoff to Helix API. (#4040) - Minor: Migrated /raid command to Helix API. Chat command will continue to be used until February 11th 2023. (#4029) +- Minor: Migrated /ban to Helix API. (#4049) +- Minor: Migrated /timeout to Helix API. (#4049) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fixed `Smooth scrolling on new messages` setting sometimes hiding messages. (#4028) - Bugfix: Fixed a crash that can occur when closing and quickly reopening a split, then running a command. (#3852) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index a3f23edf3..0ca1cfb03 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -2511,6 +2511,195 @@ void CommandController::initialize(Settings &, Paths &paths) }); return ""; }); + + auto formatBanTimeoutError = + [](const char *operation, HelixBanUserError error, + const QString &message, const QString &userDisplayName) -> QString { + using Error = HelixBanUserError; + + QString errorMessage = QString("Failed to %1 user - ").arg(operation); + + switch (error) + { + case Error::ConflictingOperation: { + errorMessage += "There was a conflicting ban operation on " + "this user. Please try again."; + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Ratelimited: { + errorMessage += "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::TargetBanned: { + // Equivalent IRC error + errorMessage = QString("%1 is already banned in this channel.") + .arg(userDisplayName); + } + break; + + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + return errorMessage; + }; + + this->registerCommand("/timeout", [formatBanTimeoutError]( + const QStringList &words, + auto channel) { + const auto *usageStr = + "Usage: \"/timeout [duration][time unit] [reason]\" - " + "Temporarily prevent a user from chatting. Duration (optional, " + "default=10 minutes) must be a positive integer; time unit " + "(optional, default=s) must be one of s, m, h, d, w; maximum " + "duration is 2 weeks. Combinations like 1d2h are also allowed. " + "Reason is optional and will be shown to the target user and other " + "moderators. Use \"/untimeout\" to remove a timeout."; + if (words.size() < 2) + { + channel->addMessage(makeSystemMessage(usageStr)); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage( + makeSystemMessage("You must be logged in to timeout someone!")); + return ""; + } + + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + QString("The /timeout command only works in Twitch channels"))); + return ""; + } + + auto target = words.at(1); + stripChannelName(target); + + int duration = 10 * 60; // 10min + if (words.size() >= 3) + { + duration = (int)parseDurationToSeconds(words.at(2)); + if (duration <= 0) + { + channel->addMessage(makeSystemMessage(usageStr)); + return ""; + } + } + auto reason = words.mid(3).join(' '); + + getHelix()->getUserByName( + target, + [channel, currentUser, twitchChannel, target, duration, reason, + formatBanTimeoutError](const auto &targetUser) { + getHelix()->banUser( + twitchChannel->roomId(), currentUser->getUserId(), + targetUser.id, duration, reason, + [] { + // No response for timeouts, they're emitted over pubsub/IRC instead + }, + [channel, target, targetUser, formatBanTimeoutError]( + auto error, auto message) { + auto errorMessage = formatBanTimeoutError( + "timeout", error, message, targetUser.displayName); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel, target] { + // Equivalent error from IRC + channel->addMessage(makeSystemMessage( + QString("Invalid username: %1").arg(target))); + }); + + return ""; + }); + + this->registerCommand("/ban", [formatBanTimeoutError]( + const QStringList &words, auto channel) { + const auto *usageStr = + "Usage: \"/ban [reason]\" - Permanently prevent a user " + "from chatting. Reason is optional and will be shown to the target " + "user and other moderators. Use \"/unban\" to remove a ban."; + if (words.size() < 2) + { + channel->addMessage(makeSystemMessage(usageStr)); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage( + makeSystemMessage("You must be logged in to ban someone!")); + return ""; + } + + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + QString("The /ban command only works in Twitch channels"))); + return ""; + } + + auto target = words.at(1); + stripChannelName(target); + + auto reason = words.mid(2).join(' '); + + getHelix()->getUserByName( + target, + [channel, currentUser, twitchChannel, target, reason, + formatBanTimeoutError](const auto &targetUser) { + getHelix()->banUser( + twitchChannel->roomId(), currentUser->getUserId(), + targetUser.id, boost::none, reason, + [] { + // No response for bans, they're emitted over pubsub/IRC instead + }, + [channel, target, targetUser, formatBanTimeoutError]( + auto error, auto message) { + auto errorMessage = formatBanTimeoutError( + "ban", error, message, targetUser.displayName); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel, target] { + // Equivalent error from IRC + channel->addMessage(makeSystemMessage( + QString("Invalid username: %1").arg(target))); + }); + + return ""; + }); } void CommandController::save() diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 959c482f3..d01ea5524 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -1709,6 +1709,109 @@ void Helix::updateChatSettings( .execute(); } +// Ban/timeout a user +// https://dev.twitch.tv/docs/api/reference#ban-user +void Helix::banUser(QString broadcasterID, QString moderatorID, QString userID, + boost::optional duration, QString reason, + ResultCallback<> successCallback, + FailureCallback failureCallback) +{ + using Error = HelixBanUserError; + + QUrlQuery urlQuery; + + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + urlQuery.addQueryItem("moderator_id", moderatorID); + + QJsonObject payload; + { + QJsonObject data; + data["reason"] = reason; + data["user_id"] = userID; + if (duration) + { + data["duration"] = *duration; + } + + payload["data"] = data; + } + + this->makeRequest("moderation/bans", urlQuery) + .type(NetworkRequestType::Post) + .header("Content-Type", "application/json") + .payload(QJsonDocument(payload).toJson(QJsonDocument::Compact)) + .onSuccess([successCallback](auto result) -> Outcome { + if (result.status() != 200) + { + qCWarning(chatterinoTwitch) + << "Success result for banning a user was" + << result.status() << "but we expected it to be 200"; + } + // we don't care about the response + successCallback(); + return Success; + }) + .onError([failureCallback](auto result) { + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (result.status()) + { + case 400: { + if (message.startsWith("The user specified in the user_id " + "field is already banned", + Qt::CaseInsensitive)) + { + failureCallback(Error::TargetBanned, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 409: { + failureCallback(Error::ConflictingOperation, message); + } + break; + + case 401: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + // Handle this error specifically because its API error is especially unfriendly + failureCallback(Error::UserMissingScope, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 403: { + failureCallback(Error::UserNotAuthorized, message); + } + break; + + case 429: { + failureCallback(Error::Ratelimited, message); + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error banning user:" << 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 839953778..f26f667ea 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -466,6 +466,18 @@ enum class HelixUpdateChatSettingsError { // update chat settings Forwarded, }; // update chat settings +enum class HelixBanUserError { // /timeout, /ban + Unknown, + UserMissingScope, + UserNotAuthorized, + Ratelimited, + ConflictingOperation, + TargetBanned, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; // /timeout, /ban + class IHelix { public: @@ -698,7 +710,14 @@ public: ResultCallback successCallback, FailureCallback failureCallback) = 0; - // https://dev.twitch.tv/docs/api/reference#update-chat-settings + + // Ban/timeout a user + // https://dev.twitch.tv/docs/api/reference#ban-user + virtual void banUser( + QString broadcasterID, QString moderatorID, QString userID, + boost::optional duration, QString reason, + ResultCallback<> successCallback, + FailureCallback failureCallback) = 0; virtual void update(QString clientId, QString oauthToken) = 0; @@ -934,6 +953,14 @@ public: FailureCallback failureCallback) final; + // Ban/timeout a user + // https://dev.twitch.tv/docs/api/reference#ban-user + void banUser( + QString broadcasterID, QString moderatorID, QString userID, + boost::optional duration, QString reason, + ResultCallback<> successCallback, + FailureCallback failureCallback) final; + void update(QString clientId, QString oauthToken) final; static void initialize(); diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index 1ad014c98..9cb4a9fe4 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -336,6 +336,15 @@ public: (override)); // update chat settings + // /timeout, /ban + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, banUser, + (QString broadcasterID, QString moderatorID, QString userID, + boost::optional duration, QString reason, + ResultCallback<> successCallback, + (FailureCallback failureCallback)), + (override)); // /timeout, /ban + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override)); From 7f938855184fe7423a5a2b532277e6345af0b534 Mon Sep 17 00:00:00 2001 From: Felanbird <41973452+Felanbird@users.noreply.github.com> Date: Thu, 6 Oct 2022 18:42:41 -0400 Subject: [PATCH 050/946] Remove trailing whitespace from Usernames in User Highlights (#4051) --- CHANGELOG.md | 3 ++- src/controllers/highlights/UserHighlightModel.cpp | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fa1b7f78..3752b1d54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,7 +30,6 @@ - Minor: Added information about the user's operating system in the About page. (#3663) - Minor: Adjusted large stream thumbnail to 16:9 (#3655) - Minor: Prevented user from entering incorrect characters in Live Notifications channels list. (#3715, #3730) -- Minor: Added whitespace trim to username field in nicknames (#3946) - Minor: Sorted usernames in /vips message to be case-insensitive. (#3696) - Minor: Streamer mode now automatically detects if XSplit, PRISM Live Studio, Twitch Studio, or vMix are running. (#3740) - Minor: Fixed automod caught message notice appearing twice for mods. (#3717) @@ -86,6 +85,8 @@ - Bugfix: Fixed emoji popup not being shown in IRC channels (#4021) - Bugfix: Display sent IRC messages like received ones (#4027) - Bugfix: Fixed non-global FrankerFaceZ emotes from being loaded as global emotes. (#3921) +- Bugfix: Fixed trailing spaces from preventing Nicknames from working correctly. (#3946) +- Bugfix: Fixed trailing spaces from preventing User Highlights from working correctly. (#4051) - Dev: Removed official support for QMake. (#3839, #3883) - Dev: Rewrote LimitedQueue (#3798) - Dev: Overhauled highlight system by moving all checks into a Controller allowing for easier tests. (#3399, #3801, #3835) diff --git a/src/controllers/highlights/UserHighlightModel.cpp b/src/controllers/highlights/UserHighlightModel.cpp index cde9c4965..8a6596a74 100644 --- a/src/controllers/highlights/UserHighlightModel.cpp +++ b/src/controllers/highlights/UserHighlightModel.cpp @@ -26,7 +26,7 @@ HighlightPhrase UserHighlightModel::getItemFromRow( row[Column::Color]->data(Qt::DecorationRole).value(); return HighlightPhrase{ - row[Column::Pattern]->data(Qt::DisplayRole).toString(), + row[Column::Pattern]->data(Qt::DisplayRole).toString().trimmed(), row[Column::ShowInMentions]->data(Qt::CheckStateRole).toBool(), row[Column::FlashTaskbar]->data(Qt::CheckStateRole).toBool(), row[Column::PlaySound]->data(Qt::CheckStateRole).toBool(), From 974a8f11b7e0ce27421fbc3018dc5fa7bd37c576 Mon Sep 17 00:00:00 2001 From: nerix Date: Sat, 8 Oct 2022 13:11:55 +0200 Subject: [PATCH 051/946] Migrate `/w` to Helix API (#4052) --- CHANGELOG.md | 1 + .../commands/CommandController.cpp | 192 +++++++++++++----- src/providers/twitch/IrcMessageHandler.cpp | 16 +- src/providers/twitch/api/Helix.cpp | 106 ++++++++++ src/providers/twitch/api/Helix.hpp | 27 +++ src/singletons/Settings.hpp | 4 + src/widgets/settingspages/GeneralPage.cpp | 11 + tests/src/HighlightController.cpp | 8 + 8 files changed, 317 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3752b1d54..eab2696f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ - Minor: Migrated /raid command to Helix API. Chat command will continue to be used until February 11th 2023. (#4029) - Minor: Migrated /ban to Helix API. (#4049) - Minor: Migrated /timeout to Helix API. (#4049) +- Minor: Migrated /w to Helix API. Chat command will continue to be used until February 11th 2023. (#4052) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fixed `Smooth scrolling on new messages` setting sometimes hiding messages. (#4028) - Bugfix: Fixed a crash that can occur when closing and quickly reopening a split, then running a command. (#3852) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 0ca1cfb03..40de938fa 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -181,28 +181,151 @@ bool appendWhisperMessageWordsLocally(const QStringList &words) return true; } -bool appendWhisperMessageStringLocally(const QString &textNoEmoji) +bool useIrcForWhisperCommand() { - QString text = getApp()->emotes->emojis.replaceShortCodes(textNoEmoji); - QStringList words = text.split(' ', Qt::SkipEmptyParts); - - if (words.length() == 0) + switch (getSettings()->helixTimegateWhisper.getValue()) { - return false; - } + case HelixTimegateOverride::Timegate: { + if (areIRCCommandsStillAvailable()) + { + return true; + } - QString commandName = words[0]; - - if (TWITCH_WHISPER_COMMANDS.contains(commandName, Qt::CaseInsensitive)) - { - if (words.length() > 2) - { - return appendWhisperMessageWordsLocally(words); + // fall through to Helix logic } + break; + + case HelixTimegateOverride::AlwaysUseIRC: { + return true; + } + break; + + case HelixTimegateOverride::AlwaysUseHelix: { + // do nothing and fall through to Helix logic + } + break; } return false; } +QString runWhisperCommand(const QStringList &words, const ChannelPtr &channel) +{ + if (words.size() < 3) + { + channel->addMessage( + makeSystemMessage("Usage: /w ")); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage( + makeSystemMessage("You must be logged in to send a whisper!")); + return ""; + } + auto target = words.at(1); + stripChannelName(target); + auto message = words.mid(2).join(' '); + + if (useIrcForWhisperCommand()) + { + if (channel->isTwitchChannel()) + { + appendWhisperMessageWordsLocally(words); + sendWhisperMessage(words.join(' ')); + } + else + { + channel->addMessage(makeSystemMessage( + "You can only send whispers from Twitch channels.")); + } + return ""; + } + + getHelix()->getUserByName( + target, + [channel, currentUser, target, message, words](const auto &targetUser) { + getHelix()->sendWhisper( + currentUser->getUserId(), targetUser.id, message, + [words] { + appendWhisperMessageWordsLocally(words); + }, + [channel, target, targetUser](auto error, auto message) { + using Error = HelixWhisperError; + + QString errorMessage = "Failed to send whisper - "; + + switch (error) + { + case Error::NoVerifiedPhone: { + errorMessage += + "Due to Twitch restrictions, you are now " + "required to have a verified phone number " + "to send whispers. You can add a phone " + "number in Twitch settings. " + "https://www.twitch.tv/settings/security"; + }; + break; + + case Error::RecipientBlockedUser: { + errorMessage += + "The recipient doesn't allow whispers " + "from strangers or you directly."; + }; + break; + + case Error::WhisperSelf: { + errorMessage += "You cannot whisper yourself."; + }; + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Ratelimited: { + errorMessage += + "You may only whisper a maximum of 40 " + "unique recipients per day. Within the " + "per day limit, you may whisper a " + "maximum of 3 whispers per second and " + "a maximum of 100 whispers per minute."; + } + break; + + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel] { + channel->addMessage( + makeSystemMessage("No user matching that username.")); + }); + + return ""; +} + using VariableReplacer = std::function; @@ -2700,6 +2823,13 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); + + for (const auto &cmd : TWITCH_WHISPER_COMMANDS) + { + this->registerCommand(cmd, [](const QStringList &words, auto channel) { + return runWhisperCommand(words, channel); + }); + } } void CommandController::save() @@ -2728,26 +2858,6 @@ QString CommandController::execCommand(const QString &textNoEmoji, QString commandName = words[0]; - // works in a valid Twitch channel and /whispers, etc... - if (!dryRun && channel->isTwitchChannel()) - { - if (TWITCH_WHISPER_COMMANDS.contains(commandName, Qt::CaseInsensitive)) - { - if (words.length() > 2) - { - appendWhisperMessageWordsLocally(words); - sendWhisperMessage(text); - } - else - { - channel->addMessage( - makeSystemMessage("Usage: /w ")); - } - - return ""; - } - } - { // check if user command exists const auto it = this->userCommands_.find(commandName); @@ -2811,7 +2921,7 @@ void CommandController::registerCommand(QString commandName, } QString CommandController::execCustomCommand( - const QStringList &words, const Command &command, bool dryRun, + const QStringList &words, const Command &command, bool /* dryRun */, ChannelPtr channel, const Message *message, std::unordered_map context) { @@ -2906,17 +3016,7 @@ QString CommandController::execCustomCommand( result = result.mid(1); } - auto res = result.replace("{{", "{"); - - if (dryRun || !appendWhisperMessageStringLocally(res)) - { - return res; - } - else - { - sendWhisperMessage(res); - return ""; - } + return result.replace("{{", "{"); } QStringList CommandController::getDefaultChatterinoCommandList() diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp index 5c6013360..0afef8ccd 100644 --- a/src/providers/twitch/IrcMessageHandler.cpp +++ b/src/providers/twitch/IrcMessageHandler.cpp @@ -911,8 +911,20 @@ std::vector IrcMessageHandler::parseNoticeMessage( // default case std::vector builtMessages; - builtMessages.emplace_back(makeSystemMessage( - message->content(), calculateMessageTime(message).time())); + auto content = message->content(); + if (content.startsWith( + "Your settings prevent you from sending this whisper", + Qt::CaseInsensitive) && + getSettings()->helixTimegateWhisper.getValue() == + HelixTimegateOverride::Timegate) + { + content = + content + + " Consider setting the \"Helix timegate /w " + "behaviour\" to \"Always use Helix\" in your Chatterino settings."; + } + builtMessages.emplace_back( + makeSystemMessage(content, calculateMessageTime(message).time())); return builtMessages; } diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index d01ea5524..087febdfa 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -1812,6 +1812,112 @@ void Helix::banUser(QString broadcasterID, QString moderatorID, QString userID, .execute(); } +// https://dev.twitch.tv/docs/api/reference#send-whisper +void Helix::sendWhisper( + QString fromUserID, QString toUserID, QString message, + ResultCallback<> successCallback, + FailureCallback failureCallback) +{ + using Error = HelixWhisperError; + + QUrlQuery urlQuery; + + urlQuery.addQueryItem("from_user_id", fromUserID); + urlQuery.addQueryItem("to_user_id", toUserID); + + QJsonObject payload; + payload["message"] = message; + + this->makeRequest("whispers", urlQuery) + .type(NetworkRequestType::Post) + .header("Content-Type", "application/json") + .payload(QJsonDocument(payload).toJson(QJsonDocument::Compact)) + .onSuccess([successCallback](auto result) -> Outcome { + if (result.status() != 204) + { + qCWarning(chatterinoTwitch) + << "Success result for sending a whisper was" + << result.status() << "but we expected it to be 204"; + } + // we don't care about the response + successCallback(); + return Success; + }) + .onError([failureCallback](auto result) { + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (result.status()) + { + case 400: { + if (message.startsWith("A user cannot whisper themself", + Qt::CaseInsensitive)) + { + failureCallback(Error::WhisperSelf, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 401: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + // Handle this error specifically because its API error is especially unfriendly + failureCallback(Error::UserMissingScope, message); + } + else if (message.startsWith("the sender does not have a " + "verified phone number", + Qt::CaseInsensitive)) + { + failureCallback(Error::NoVerifiedPhone, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 403: { + if (message.startsWith("The recipient's settings prevent " + "this sender from whispering them", + Qt::CaseInsensitive)) + { + failureCallback(Error::RecipientBlockedUser, message); + } + else + { + failureCallback(Error::UserNotAuthorized, message); + } + } + break; + + case 404: { + failureCallback(Error::Forwarded, message); + } + break; + + case 429: { + failureCallback(Error::Ratelimited, message); + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error banning user:" << 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 f26f667ea..89a7ba689 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -478,6 +478,19 @@ enum class HelixBanUserError { // /timeout, /ban Forwarded, }; // /timeout, /ban +enum class HelixWhisperError { // /w + Unknown, + UserMissingScope, + UserNotAuthorized, + Ratelimited, + NoVerifiedPhone, + RecipientBlockedUser, + WhisperSelf, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; // /w + class IHelix { public: @@ -719,6 +732,13 @@ public: ResultCallback<> successCallback, FailureCallback failureCallback) = 0; + // Send a whisper + // https://dev.twitch.tv/docs/api/reference#send-whisper + virtual void sendWhisper( + QString fromUserID, QString toUserID, QString message, + ResultCallback<> successCallback, + FailureCallback failureCallback) = 0; + virtual void update(QString clientId, QString oauthToken) = 0; protected: @@ -961,6 +981,13 @@ public: ResultCallback<> successCallback, FailureCallback failureCallback) final; + // Send a whisper + // https://dev.twitch.tv/docs/api/reference#send-whisper + void sendWhisper( + QString fromUserID, QString toUserID, QString message, + ResultCallback<> successCallback, + FailureCallback failureCallback) final; + void update(QString clientId, QString oauthToken) final; static void initialize(); diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 4ca8e383e..82dc1fb68 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -414,6 +414,10 @@ public: "/misc/twitch/helix-timegate/raid", HelixTimegateOverride::Timegate, }; + EnumSetting helixTimegateWhisper = { + "/misc/twitch/helix-timegate/whisper", + HelixTimegateOverride::Timegate, + }; IntSetting emotesTooltipPreview = {"/misc/emotesTooltipPreview", 1}; BoolSetting openLinksIncognito = {"/misc/openLinksIncognito", 0}; diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index 4358ceb94..74a885b79 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -766,6 +766,17 @@ void GeneralPage::initLayout(GeneralPageView &layout) helixTimegateRaid->setMinimumWidth( helixTimegateRaid->minimumSizeHint().width()); + auto *helixTimegateWhisper = + layout.addDropdown::type>( + "Helix timegate /w behaviour", + {"Timegate", "Always use IRC", "Always use Helix"}, + s.helixTimegateWhisper, + helixTimegateGetValue, // + helixTimegateSetValue, // + false); + helixTimegateWhisper->setMinimumWidth( + helixTimegateWhisper->minimumSizeHint().width()); + layout.addStretch(); // invisible element for width diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index 9cb4a9fe4..0cf5d6094 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -345,6 +345,14 @@ public: (FailureCallback failureCallback)), (override)); // /timeout, /ban + // /w + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD(void, sendWhisper, + (QString fromUserID, QString toUserID, QString message, + ResultCallback<> successCallback, + (FailureCallback failureCallback)), + (override)); // /w + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override)); From 29272e130a6e83690a770c71151c54755292b378 Mon Sep 17 00:00:00 2001 From: Marko Date: Sat, 8 Oct 2022 14:10:38 +0200 Subject: [PATCH 052/946] Migrate `/unraid` to Helix. (#4030) Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 1 + .../commands/CommandController.cpp | 106 ++++++++++++++++++ src/providers/twitch/api/Helix.cpp | 73 ++++++++++++ src/providers/twitch/api/Helix.hpp | 23 ++++ tests/src/HighlightController.cpp | 7 ++ 5 files changed, 210 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eab2696f2..8fb9ed70f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -57,6 +57,7 @@ - Minor: Migrated /followers to Helix API. (#4040) - Minor: Migrated /followersoff to Helix API. (#4040) - Minor: Migrated /raid command to Helix API. Chat command will continue to be used until February 11th 2023. (#4029) +- Minor: Migrated /unraid command to Helix API. Chat command will continue to be used until February 11th 2023. (#4030) - Minor: Migrated /ban to Helix API. (#4049) - Minor: Migrated /timeout to Helix API. (#4049) - Minor: Migrated /w to Helix API. Chat command will continue to be used until February 11th 2023. (#4052) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 40de938fa..b76806468 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -2231,6 +2231,112 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); // /raid + this->registerCommand( // /unraid + "/unraid", [](const QStringList &words, auto channel) -> QString { + switch (getSettings()->helixTimegateRaid.getValue()) + { + case HelixTimegateOverride::Timegate: { + if (areIRCCommandsStillAvailable()) + { + return useIRCCommand(words); + } + + // fall through to Helix logic + } + break; + + case HelixTimegateOverride::AlwaysUseIRC: { + return useIRCCommand(words); + } + break; + + case HelixTimegateOverride::AlwaysUseHelix: { + // do nothing and fall through to Helix logic + } + break; + } + + if (words.size() != 1) + { + channel->addMessage(makeSystemMessage( + "Usage: \"/unraid\" - Cancel the current raid. " + "Only the broadcaster can cancel the raid.")); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage(makeSystemMessage( + "You must be logged in to cancel the raid!")); + return ""; + } + + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /unraid command only works in Twitch channels")); + return ""; + } + + getHelix()->cancelRaid( + twitchChannel->roomId(), + [channel] { + channel->addMessage( + makeSystemMessage(QString("You cancelled the raid."))); + }, + [channel](auto error, auto message) { + QString errorMessage = + QString("Failed to cancel the raid - "); + + using Error = HelixCancelRaidError; + + switch (error) + { + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + errorMessage += "You must be the broadcaster " + "to cancel the raid."; + } + break; + + case Error::NoRaidPending: { + errorMessage += "You don't have an active raid."; + } + break; + + case Error::Ratelimited: { + errorMessage += + "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Unknown: + default: { + errorMessage += "An unknown error has occurred."; + } + break; + } + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; + }); // unraid + const auto formatChatSettingsError = [](const HelixUpdateChatSettingsError error, const QString &message, diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 087febdfa..7684b2720 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -1535,6 +1535,79 @@ void Helix::startRaid( .execute(); } +void Helix::cancelRaid( + QString broadcasterID, ResultCallback<> successCallback, + FailureCallback failureCallback) +{ + using Error = HelixCancelRaidError; + + QUrlQuery urlQuery; + + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + + this->makeRequest("raids", urlQuery) + .type(NetworkRequestType::Delete) + .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + if (result.status() != 204) + { + qCWarning(chatterinoTwitch) + << "Success result for canceling the raid was" + << result.status() << "but we only expected it to be 204"; + } + + successCallback(); + return Success; + }) + .onError([failureCallback](auto result) { + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (result.status()) + { + case 401: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + failureCallback(Error::UserMissingScope, message); + } + else if (message.compare( + "The ID in broadcaster_id must match the user " + "ID " + "found in the request's OAuth token.", + Qt::CaseInsensitive) == 0) + { + // Must be the broadcaster. + failureCallback(Error::UserNotAuthorized, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 404: { + failureCallback(Error::NoRaidPending, message); + } + break; + + case 429: { + failureCallback(Error::Ratelimited, message); + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error while canceling the raid:" + << result.status() << result.getData() << obj; + failureCallback(Error::Unknown, message); + } + break; + } + }) + .execute(); +} // cancelRaid + void Helix::updateEmoteMode( QString broadcasterID, QString moderatorID, bool emoteMode, ResultCallback successCallback, diff --git a/src/providers/twitch/api/Helix.hpp b/src/providers/twitch/api/Helix.hpp index 89a7ba689..00f7037f8 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -454,6 +454,17 @@ enum class HelixStartRaidError { // /raid Forwarded, }; // /raid +enum class HelixCancelRaidError { // /unraid + Unknown, + UserMissingScope, + UserNotAuthorized, + NoRaidPending, + Ratelimited, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; // /unraid + enum class HelixUpdateChatSettingsError { // update chat settings Unknown, UserMissingScope, @@ -673,6 +684,12 @@ public: FailureCallback failureCallback) = 0; // https://dev.twitch.tv/docs/api/reference#start-a-raid + // https://dev.twitch.tv/docs/api/reference#cancel-a-raid + virtual void cancelRaid( + QString broadcasterID, ResultCallback<> successCallback, + FailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#cancel-a-raid + // Updates the emote mode using // https://dev.twitch.tv/docs/api/reference#update-chat-settings virtual void updateEmoteMode( @@ -923,6 +940,12 @@ public: FailureCallback failureCallback) final; // https://dev.twitch.tv/docs/api/reference#start-a-raid + // https://dev.twitch.tv/docs/api/reference#cancel-a-raid + void cancelRaid( + QString broadcasterID, ResultCallback<> successCallback, + FailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference#cancel-a-raid + // Updates the emote mode using // https://dev.twitch.tv/docs/api/reference#update-chat-settings void updateEmoteMode(QString broadcasterID, QString moderatorID, diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index 0cf5d6094..8e8b2ed74 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -282,6 +282,13 @@ public: (FailureCallback failureCallback)), (override)); // /raid + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD( // /unraid + void, cancelRaid, + (QString broadcasterID, ResultCallback<> successCallback, + (FailureCallback failureCallback)), + (override)); // /unraid + // The extra parenthesis around the failure callback is because its type contains a comma MOCK_METHOD(void, updateEmoteMode, (QString broadcasterID, QString moderatorID, bool emoteMode, From 4e2da540d26cb7a2ea1dba91063d0e5b144540dd Mon Sep 17 00:00:00 2001 From: nerix Date: Sat, 8 Oct 2022 16:25:32 +0200 Subject: [PATCH 053/946] refactor: Remove Leading Mention in Replies and Highlight Participated Threads (#4047) --- CHANGELOG.md | 2 +- .../highlights/HighlightController.cpp | 66 ++++++++++++-- .../highlights/HighlightController.hpp | 6 +- src/controllers/highlights/HighlightModel.cpp | 62 +++++++++++++ src/controllers/highlights/HighlightModel.hpp | 1 + .../highlights/HighlightPhrase.cpp | 2 + .../highlights/HighlightPhrase.hpp | 1 + src/messages/Message.hpp | 1 + src/messages/MessageThread.cpp | 10 +++ src/messages/MessageThread.hpp | 5 ++ src/messages/SharedMessageBuilder.cpp | 3 +- src/providers/colors/ColorProvider.cpp | 14 +++ src/providers/colors/ColorProvider.hpp | 1 + src/providers/twitch/IrcMessageHandler.cpp | 87 +++++++++++++++++-- src/providers/twitch/TwitchMessageBuilder.cpp | 11 ++- src/providers/twitch/TwitchMessageBuilder.hpp | 13 +++ src/singletons/Settings.hpp | 14 +++ src/widgets/settingspages/GeneralPage.cpp | 1 + tests/src/HighlightController.cpp | 6 +- 19 files changed, 286 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fb9ed70f..a1050b3e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unversioned -- Major: Added support for Twitch's Chat Replies. [Wiki Page](https://wiki.chatterino.com/Features/#message-replies) (#3722, #3989, #4041) +- Major: Added support for Twitch's Chat Replies. [Wiki Page](https://wiki.chatterino.com/Features/#message-replies) (#3722, #3989, #4041, #4047) - Major: Added multi-channel searching to search dialog via keyboard shortcut. (Ctrl+Shift+F by default) (#3694, #3875) - Minor: Added highlights for `Elevated Messages`. (#4016) - Minor: Removed total views from the usercard, as Twitch no longer updates the number. (#3792) diff --git a/src/controllers/highlights/HighlightController.cpp b/src/controllers/highlights/HighlightController.cpp index a01e5439f..912274dab 100644 --- a/src/controllers/highlights/HighlightController.cpp +++ b/src/controllers/highlights/HighlightController.cpp @@ -11,10 +11,12 @@ auto highlightPhraseCheck(const HighlightPhrase &highlight) -> HighlightCheck return HighlightCheck{ [highlight](const auto &args, const auto &badges, const auto &senderName, const auto &originalMessage, + const auto &flags, const auto self) -> boost::optional { (void)args; // unused (void)badges; // unused (void)senderName; // unused + (void)flags; // unused if (self) { @@ -60,11 +62,12 @@ void rebuildSubscriptionHighlights(Settings &settings, checks.emplace_back(HighlightCheck{ [=](const auto &args, const auto &badges, const auto &senderName, - const auto &originalMessage, + const auto &originalMessage, const auto &flags, const auto self) -> boost::optional { (void)badges; // unused (void)senderName; // unused (void)originalMessage; // unused + (void)flags; // unused (void)self; // unused if (!args.isSubscriptionMessage) @@ -105,11 +108,12 @@ void rebuildWhisperHighlights(Settings &settings, checks.emplace_back(HighlightCheck{ [=](const auto &args, const auto &badges, const auto &senderName, - const auto &originalMessage, + const auto &originalMessage, const auto &flags, const auto self) -> boost::optional { (void)badges; // unused (void)senderName; // unused (void)originalMessage; // unused + (void)flags; // unused (void)self; // unused if (!args.isReceivedWhisper) @@ -128,6 +132,44 @@ void rebuildWhisperHighlights(Settings &settings, } } +void rebuildReplyThreadHighlight(Settings &settings, + std::vector &checks) +{ + if (settings.enableThreadHighlight) + { + auto highlightSound = settings.enableThreadHighlightSound.getValue(); + auto highlightAlert = settings.enableThreadHighlightTaskbar.getValue(); + auto highlightSoundUrlValue = + settings.threadHighlightSoundUrl.getValue(); + boost::optional highlightSoundUrl; + if (!highlightSoundUrlValue.isEmpty()) + { + highlightSoundUrl = highlightSoundUrlValue; + } + auto highlightInMentions = + settings.showThreadHighlightInMentions.getValue(); + checks.emplace_back(HighlightCheck{ + [=](const auto & /*args*/, const auto & /*badges*/, + const auto & /*senderName*/, const auto & /*originalMessage*/, + const auto &flags, + const auto self) -> boost::optional { + if (flags.has(MessageFlag::ParticipatedThread) && !self) + { + return HighlightResult{ + highlightAlert, + highlightSound, + highlightSoundUrl, + ColorProvider::instance().color( + ColorType::ThreadMessageHighlight), + highlightInMentions, + }; + } + + return boost::none; + }}); + } +} + void rebuildMessageHighlights(Settings &settings, std::vector &checks) { @@ -163,10 +205,12 @@ void rebuildUserHighlights(Settings &settings, checks.emplace_back(HighlightCheck{ [highlight](const auto &args, const auto &badges, const auto &senderName, const auto &originalMessage, + const auto &flags, const auto self) -> boost::optional { (void)args; // unused (void)badges; // unused (void)originalMessage; // unused + (void)flags; // unused (void)self; // unused if (!highlight.isMatch(senderName)) @@ -201,10 +245,12 @@ void rebuildBadgeHighlights(Settings &settings, checks.emplace_back(HighlightCheck{ [highlight](const auto &args, const auto &badges, const auto &senderName, const auto &originalMessage, + const auto &flags, const auto self) -> boost::optional { (void)args; // unused (void)senderName; // unused (void)originalMessage; // unused + (void)flags; // unused (void)self; // unused for (const Badge &badge : badges) @@ -247,6 +293,11 @@ void HighlightController::initialize(Settings &settings, Paths & /*paths*/) this->rebuildListener_.addSetting(settings.enableSubHighlight); this->rebuildListener_.addSetting(settings.enableSubHighlightSound); this->rebuildListener_.addSetting(settings.enableSubHighlightTaskbar); + this->rebuildListener_.addSetting(settings.enableThreadHighlight); + this->rebuildListener_.addSetting(settings.enableThreadHighlightSound); + this->rebuildListener_.addSetting(settings.enableThreadHighlightTaskbar); + this->rebuildListener_.addSetting(settings.threadHighlightSoundUrl); + this->rebuildListener_.addSetting(settings.showThreadHighlightInMentions); this->rebuildListener_.setCB([this, &settings] { qCDebug(chatterinoHighlights) @@ -294,7 +345,7 @@ void HighlightController::rebuildChecks(Settings &settings) checks->clear(); // CURRENT ORDER: - // Subscription -> Whisper -> User -> Message -> Badge + // Subscription -> Whisper -> User -> Message -> Reply Threads -> Badge rebuildSubscriptionHighlights(settings, *checks); @@ -304,12 +355,15 @@ void HighlightController::rebuildChecks(Settings &settings) rebuildMessageHighlights(settings, *checks); + rebuildReplyThreadHighlight(settings, *checks); + rebuildBadgeHighlights(settings, *checks); } std::pair HighlightController::check( const MessageParseArgs &args, const std::vector &badges, - const QString &senderName, const QString &originalMessage) const + const QString &senderName, const QString &originalMessage, + const MessageFlags &messageFlags) const { bool highlighted = false; auto result = HighlightResult::emptyResult(); @@ -322,8 +376,8 @@ std::pair HighlightController::check( for (const auto &check : *checks) { - if (auto checkResult = - check.cb(args, badges, senderName, originalMessage, self); + if (auto checkResult = check.cb(args, badges, senderName, + originalMessage, messageFlags, self); checkResult) { highlighted = true; diff --git a/src/controllers/highlights/HighlightController.hpp b/src/controllers/highlights/HighlightController.hpp index 0a1ecb2a9..ad7cc7cec 100644 --- a/src/controllers/highlights/HighlightController.hpp +++ b/src/controllers/highlights/HighlightController.hpp @@ -142,7 +142,8 @@ struct HighlightResult { struct HighlightCheck { using Checker = std::function( const MessageParseArgs &args, const std::vector &badges, - const QString &senderName, const QString &originalMessage, bool self)>; + const QString &senderName, const QString &originalMessage, + const MessageFlags &messageFlags, bool self)>; Checker cb; }; @@ -156,7 +157,8 @@ public: **/ [[nodiscard]] std::pair check( const MessageParseArgs &args, const std::vector &badges, - const QString &senderName, const QString &originalMessage) const; + const QString &senderName, const QString &originalMessage, + const MessageFlags &messageFlags) const; private: /** diff --git a/src/controllers/highlights/HighlightModel.cpp b/src/controllers/highlights/HighlightModel.cpp index 3b5b30ccb..fdeb9acf5 100644 --- a/src/controllers/highlights/HighlightModel.cpp +++ b/src/controllers/highlights/HighlightModel.cpp @@ -213,6 +213,36 @@ void HighlightModel::afterInit() this->insertCustomRow(elevatedMessageRow, HighlightRowIndexes::ElevatedMessageRow); + + // Highlight settings for reply threads + std::vector threadMessageRow = this->createRow(); + setBoolItem(threadMessageRow[Column::Pattern], + getSettings()->enableThreadHighlight.getValue(), true, false); + threadMessageRow[Column::Pattern]->setData("Participated Reply Threads", + Qt::DisplayRole); + setBoolItem(threadMessageRow[Column::ShowInMentions], + getSettings()->showThreadHighlightInMentions.getValue(), true, + false); + setBoolItem(threadMessageRow[Column::FlashTaskbar], + getSettings()->enableThreadHighlightTaskbar.getValue(), true, + false); + setBoolItem(threadMessageRow[Column::PlaySound], + getSettings()->enableThreadHighlightSound.getValue(), true, + false); + threadMessageRow[Column::UseRegex]->setFlags({}); + threadMessageRow[Column::CaseSensitive]->setFlags({}); + + QUrl threadMessageSound = + QUrl(getSettings()->threadHighlightSoundUrl.getValue()); + setFilePathItem(threadMessageRow[Column::SoundPath], threadMessageSound, + false); + + auto threadMessageColor = + ColorProvider::instance().color(ColorType::ThreadMessageHighlight); + setColorItem(threadMessageRow[Column::Color], *threadMessageColor, false); + + this->insertCustomRow(threadMessageRow, + HighlightRowIndexes::ThreadMessageRow); } void HighlightModel::customRowSetData(const std::vector &row, @@ -252,6 +282,11 @@ void HighlightModel::customRowSetData(const std::vector &row, getSettings()->enableElevatedMessageHighlight.setValue( value.toBool()); } + else if (rowIndex == HighlightRowIndexes::ThreadMessageRow) + { + getSettings()->enableThreadHighlight.setValue( + value.toBool()); + } } } break; @@ -263,6 +298,11 @@ void HighlightModel::customRowSetData(const std::vector &row, getSettings()->showSelfHighlightInMentions.setValue( value.toBool()); } + else if (rowIndex == HighlightRowIndexes::ThreadMessageRow) + { + getSettings()->showThreadHighlightInMentions.setValue( + value.toBool()); + } } } break; @@ -300,6 +340,11 @@ void HighlightModel::customRowSetData(const std::vector &row, // ->enableElevatedMessageHighlightTaskbar.setvalue( // value.toBool()); } + else if (rowIndex == HighlightRowIndexes::ThreadMessageRow) + { + getSettings()->enableThreadHighlightTaskbar.setValue( + value.toBool()); + } } } break; @@ -336,6 +381,11 @@ void HighlightModel::customRowSetData(const std::vector &row, // getSettings()->enableElevatedMessageHighlightSound.setValue( // value.toBool()); } + else if (rowIndex == HighlightRowIndexes::ThreadMessageRow) + { + getSettings()->enableThreadHighlightSound.setValue( + value.toBool()); + } } } break; @@ -381,6 +431,11 @@ void HighlightModel::customRowSetData(const std::vector &row, getSettings()->elevatedMessageHighlightSoundUrl.setValue( value.toString()); } + else if (rowIndex == HighlightRowIndexes::ThreadMessageRow) + { + getSettings()->threadHighlightSoundUrl.setValue( + value.toString()); + } } } break; @@ -424,6 +479,13 @@ void HighlightModel::customRowSetData(const std::vector &row, .updateColor(ColorType::ElevatedMessageHighlight, QColor(colorName)); } + else if (rowIndex == HighlightRowIndexes::ThreadMessageRow) + { + getSettings()->threadHighlightColor.setValue(colorName); + const_cast(ColorProvider::instance()) + .updateColor(ColorType::ThreadMessageHighlight, + QColor(colorName)); + } } } break; diff --git a/src/controllers/highlights/HighlightModel.hpp b/src/controllers/highlights/HighlightModel.hpp index e18306fbc..f1407f18e 100644 --- a/src/controllers/highlights/HighlightModel.hpp +++ b/src/controllers/highlights/HighlightModel.hpp @@ -32,6 +32,7 @@ public: RedeemedRow = 3, FirstMessageRow = 4, ElevatedMessageRow = 5, + ThreadMessageRow = 6, }; protected: diff --git a/src/controllers/highlights/HighlightPhrase.cpp b/src/controllers/highlights/HighlightPhrase.cpp index 079d3686d..d7b20e16f 100644 --- a/src/controllers/highlights/HighlightPhrase.cpp +++ b/src/controllers/highlights/HighlightPhrase.cpp @@ -16,6 +16,8 @@ QColor HighlightPhrase::FALLBACK_FIRST_MESSAGE_HIGHLIGHT_COLOR = QColor(72, 127, 63, 60); QColor HighlightPhrase::FALLBACK_ELEVATED_MESSAGE_HIGHLIGHT_COLOR = QColor(255, 174, 66, 60); +QColor HighlightPhrase::FALLBACK_THREAD_HIGHLIGHT_COLOR = + QColor(143, 48, 24, 60); QColor HighlightPhrase::FALLBACK_SUB_COLOR = QColor(196, 102, 255, 100); bool HighlightPhrase::operator==(const HighlightPhrase &other) const diff --git a/src/controllers/highlights/HighlightPhrase.hpp b/src/controllers/highlights/HighlightPhrase.hpp index 99d3fe377..695fc93db 100644 --- a/src/controllers/highlights/HighlightPhrase.hpp +++ b/src/controllers/highlights/HighlightPhrase.hpp @@ -84,6 +84,7 @@ public: static QColor FALLBACK_SUB_COLOR; static QColor FALLBACK_FIRST_MESSAGE_HIGHLIGHT_COLOR; static QColor FALLBACK_ELEVATED_MESSAGE_HIGHLIGHT_COLOR; + static QColor FALLBACK_THREAD_HIGHLIGHT_COLOR; private: QString pattern_; diff --git a/src/messages/Message.hpp b/src/messages/Message.hpp index 6c7a75900..0468627a9 100644 --- a/src/messages/Message.hpp +++ b/src/messages/Message.hpp @@ -43,6 +43,7 @@ enum class MessageFlag : int64_t { FirstMessage = (1LL << 23), ReplyMessage = (1LL << 24), ElevatedMessage = (1LL << 25), + ParticipatedThread = (1LL << 26), }; using MessageFlags = FlagsEnum; diff --git a/src/messages/MessageThread.cpp b/src/messages/MessageThread.cpp index dc798d993..0ffdc3f18 100644 --- a/src/messages/MessageThread.cpp +++ b/src/messages/MessageThread.cpp @@ -58,4 +58,14 @@ size_t MessageThread::liveCount( return count; } +bool MessageThread::participated() const +{ + return this->participated_; +} + +void MessageThread::markParticipated() +{ + this->participated_ = true; +} + } // namespace chatterino diff --git a/src/messages/MessageThread.hpp b/src/messages/MessageThread.hpp index f2a8e57d5..ae0d24794 100644 --- a/src/messages/MessageThread.hpp +++ b/src/messages/MessageThread.hpp @@ -23,6 +23,10 @@ public: /// Returns the number of live reply references size_t liveCount(const std::shared_ptr &exclude) const; + bool participated() const; + + void markParticipated(); + const QString &rootId() const { return rootMessageId_; @@ -42,6 +46,7 @@ private: const QString rootMessageId_; const std::shared_ptr rootMessage_; std::vector> replies_; + bool participated_ = false; }; } // namespace chatterino diff --git a/src/messages/SharedMessageBuilder.cpp b/src/messages/SharedMessageBuilder.cpp index 49508c0b7..5fc76c870 100644 --- a/src/messages/SharedMessageBuilder.cpp +++ b/src/messages/SharedMessageBuilder.cpp @@ -149,7 +149,8 @@ void SharedMessageBuilder::parseHighlights() auto badges = SharedMessageBuilder::parseBadgeTag(this->tags); auto [highlighted, highlightResult] = getIApp()->getHighlights()->check( - this->args, badges, this->ircMessage->nick(), this->originalMessage_); + this->args, badges, this->ircMessage->nick(), this->originalMessage_, + this->message().flags); if (!highlighted) { diff --git a/src/providers/colors/ColorProvider.cpp b/src/providers/colors/ColorProvider.cpp index 39f30778d..342ce45ab 100644 --- a/src/providers/colors/ColorProvider.cpp +++ b/src/providers/colors/ColorProvider.cpp @@ -147,6 +147,20 @@ void ColorProvider::initTypeColorMap() std::make_shared( HighlightPhrase::FALLBACK_ELEVATED_MESSAGE_HIGHLIGHT_COLOR)}); } + + customColor = getSettings()->threadHighlightColor; + if (QColor(customColor).isValid()) + { + this->typeColorMap_.insert({ColorType::ThreadMessageHighlight, + std::make_shared(customColor)}); + } + else + { + this->typeColorMap_.insert( + {ColorType::ThreadMessageHighlight, + std::make_shared( + HighlightPhrase::FALLBACK_THREAD_HIGHLIGHT_COLOR)}); + } } void ColorProvider::initDefaultColors() diff --git a/src/providers/colors/ColorProvider.hpp b/src/providers/colors/ColorProvider.hpp index d7fe80f9a..b2143c4ca 100644 --- a/src/providers/colors/ColorProvider.hpp +++ b/src/providers/colors/ColorProvider.hpp @@ -14,6 +14,7 @@ enum class ColorType { RedeemedHighlight, FirstMessageHighlight, ElevatedMessageHighlight, + ThreadMessageHighlight, }; class ColorProvider diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp index 0afef8ccd..da32a6a8d 100644 --- a/src/providers/twitch/IrcMessageHandler.cpp +++ b/src/providers/twitch/IrcMessageHandler.cpp @@ -67,6 +67,65 @@ MessagePtr generateBannedMessage(bool confirmedBan) return builder.release(); } +int stripLeadingReplyMention(const QVariantMap &tags, QString &content) +{ + if (!getSettings()->stripReplyMention) + { + return 0; + } + + if (const auto it = tags.find("reply-parent-display-name"); + it != tags.end()) + { + auto displayName = it.value().toString(); + if (content.startsWith('@') && + content.at(1 + displayName.length()) == ' ' && + content.indexOf(displayName, 1) == 1) + { + int messageOffset = 1 + displayName.length() + 1; + content.remove(0, messageOffset); + return messageOffset; + } + } + return 0; +} + +void updateReplyParticipatedStatus(const QVariantMap &tags, + const QString &senderLogin, + TwitchMessageBuilder &builder, + std::shared_ptr &thread, + bool isNew) +{ + const auto ¤tLogin = + getApp()->accounts->twitch.getCurrent()->getUserName(); + if (thread->participated()) + { + builder.message().flags.set(MessageFlag::ParticipatedThread); + return; + } + + if (isNew) + { + if (const auto it = tags.find("reply-parent-user-login"); + it != tags.end()) + { + auto name = it.value().toString(); + if (name == currentLogin) + { + thread->markParticipated(); + builder.message().flags.set(MessageFlag::ParticipatedThread); + return; // already marked as participated + } + } + } + + if (senderLogin == currentLogin) + { + thread->markParticipated(); + // don't set the highlight here + } +} + } // namespace namespace chatterino { @@ -259,9 +318,12 @@ std::vector IrcMessageHandler::parseMessageWithReply( return this->parsePrivMessage(channel, privMsg); } + QString content = privMsg->content(); + int messageOffset = stripLeadingReplyMention(privMsg->tags(), content); MessageParseArgs args; - TwitchMessageBuilder builder(channel, message, args, privMsg->content(), + TwitchMessageBuilder builder(channel, message, args, content, privMsg->isAction()); + builder.setMessageOffset(messageOffset); this->populateReply(tc, message, otherLoaded, builder); @@ -295,10 +357,12 @@ void IrcMessageHandler::populateReply( auto threadIt = channel->threads_.find(replyID); if (threadIt != channel->threads_.end()) { - const auto owned = threadIt->second.lock(); + auto owned = threadIt->second.lock(); if (owned) { // Thread already exists (has a reply) + updateReplyParticipatedStatus(tags, message->nick(), builder, + owned, false); builder.setThread(owned); return; } @@ -331,6 +395,8 @@ void IrcMessageHandler::populateReply( { std::shared_ptr newThread = std::make_shared(foundMessage); + updateReplyParticipatedStatus(tags, message->nick(), builder, + newThread, true); builder.setThread(newThread); // Store weak reference to thread in channel @@ -341,7 +407,7 @@ void IrcMessageHandler::populateReply( void IrcMessageHandler::addMessage(Communi::IrcMessage *_message, const QString &target, - const QString &content, + const QString &content_, TwitchIrcServer &server, bool isSub, bool isAction) { @@ -384,7 +450,7 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *_message, [=, &server](ChannelPointReward reward) { if (reward.id == rewardId) { - this->addMessage(clone, target, content, server, isSub, + this->addMessage(clone, target, content_, server, isSub, isAction); clone->deleteLater(); return true; @@ -396,7 +462,11 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *_message, args.channelPointRewardId = rewardId; } + QString content = content_; + int messageOffset = stripLeadingReplyMention(tags, content); + TwitchMessageBuilder builder(chan.get(), _message, args, content, isAction); + builder.setMessageOffset(messageOffset); if (const auto it = tags.find("reply-parent-msg-id"); it != tags.end()) { @@ -405,7 +475,10 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *_message, if (threadIt != channel->threads_.end() && !threadIt->second.expired()) { // Thread already exists (has a reply) - builder.setThread(threadIt->second.lock()); + auto thread = threadIt->second.lock(); + updateReplyParticipatedStatus(tags, _message->nick(), builder, + thread, false); + builder.setThread(thread); } else { @@ -414,7 +487,9 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *_message, if (root) { // Found root reply message - const auto newThread = std::make_shared(root); + auto newThread = std::make_shared(root); + updateReplyParticipatedStatus(tags, _message->nick(), builder, + newThread, true); builder.setThread(newThread); // Store weak reference to thread in channel diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index d63b6b1fa..47d4009e6 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -999,8 +999,10 @@ void TwitchMessageBuilder::appendTwitchEmote( return; } - auto start = correctPositions[coords.at(0).toUInt()]; - auto end = correctPositions[coords.at(1).toUInt()]; + auto start = + correctPositions[coords.at(0).toUInt() - this->messageOffset_]; + auto end = + correctPositions[coords.at(1).toUInt() - this->messageOffset_]; if (start >= end || start < 0 || end > this->originalMessage_.length()) { @@ -1589,4 +1591,9 @@ void TwitchMessageBuilder::setThread(std::shared_ptr thread) this->thread_ = std::move(thread); } +void TwitchMessageBuilder::setMessageOffset(int offset) +{ + this->messageOffset_ = offset; +} + } // namespace chatterino diff --git a/src/providers/twitch/TwitchMessageBuilder.hpp b/src/providers/twitch/TwitchMessageBuilder.hpp index 130c89c3c..68da6a759 100644 --- a/src/providers/twitch/TwitchMessageBuilder.hpp +++ b/src/providers/twitch/TwitchMessageBuilder.hpp @@ -47,6 +47,7 @@ public: MessagePtr build() override; void setThread(std::shared_ptr thread); + void setMessageOffset(int offset); static void appendChannelPointRewardMessage( const ChannelPointReward &reward, MessageBuilder *builder, bool isMod, @@ -110,6 +111,18 @@ private: bool historicalMessage_ = false; std::shared_ptr thread_; + /** + * Starting offset to be used on index-based operations on `originalMessage_`. + * + * For example: + * originalMessage_ = "there" + * messageOffset_ = 4 + * (the irc message is "hey there") + * + * then the index 6 would resolve to 6 - 4 = 2 => 'e' + */ + int messageOffset_ = 0; + QString userId_; bool senderIsBroadcaster{}; }; diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 82dc1fb68..b341b9b34 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -118,6 +118,7 @@ public: // BoolSetting collapseLongMessages = // {"/appearance/messages/collapseLongMessages", false}; BoolSetting showReplyButton = {"/appearance/showReplyButton", false}; + BoolSetting stripReplyMention = {"/appearance/stripReplyMention", true}; IntSetting collpseMessagesMinLines = { "/appearance/messages/collapseMessagesMinLines", 0}; BoolSetting alternateMessages = { @@ -327,6 +328,19 @@ public: ""}; QStringSetting subHighlightColor = {"/highlighting/subHighlightColor", ""}; + BoolSetting enableThreadHighlight = { + "/highlighting/thread/nameIsHighlightKeyword", true}; + BoolSetting showThreadHighlightInMentions = { + "/highlighting/thread/showSelfHighlightInMentions", true}; + BoolSetting enableThreadHighlightSound = { + "/highlighting/thread/enableSound", true}; + BoolSetting enableThreadHighlightTaskbar = { + "/highlighting/thread/enableTaskbarFlashing", true}; + QStringSetting threadHighlightSoundUrl = { + "/highlighting/threadHighlightSoundUrl", ""}; + QStringSetting threadHighlightColor = {"/highlighting/threadHighlightColor", + ""}; + QStringSetting highlightColor = {"/highlighting/color", ""}; BoolSetting longAlerts = {"/highlighting/alerts", false}; diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index 74a885b79..1e704bb45 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -719,6 +719,7 @@ void GeneralPage::initLayout(GeneralPageView &layout) layout.addCheckbox("Combine multiple bit tips into one", s.stackBits); layout.addCheckbox("Messages in /mentions highlights tab", s.highlightMentions); + layout.addCheckbox("Strip leading mention in replies", s.stripReplyMention); // Helix timegate settings auto helixTimegateGetValue = [](auto val) { diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index 8e8b2ed74..e5bdb04be 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -485,6 +485,7 @@ struct TestCase { std::vector badges; QString senderName; QString originalMessage; + MessageFlags flags; } input; struct { @@ -727,8 +728,9 @@ TEST_F(HighlightControllerTest, A) for (const auto &[input, expected] : tests) { - auto [isMatch, matchResult] = this->controller->check( - input.args, input.badges, input.senderName, input.originalMessage); + auto [isMatch, matchResult] = + this->controller->check(input.args, input.badges, input.senderName, + input.originalMessage, input.flags); EXPECT_EQ(isMatch, expected.state) << qUtf8Printable(input.senderName) << ": " From e604a36777da4c34d4a9b8d1ce3b83515b17491e Mon Sep 17 00:00:00 2001 From: Mm2PL Date: Sun, 9 Oct 2022 17:20:44 +0200 Subject: [PATCH 054/946] Make menus and placeholders display appropriate custom key combos. (#4045) * Add initial support for finding hotkey display key sequences * Make neededArguments work * Implement displaying key combos in SplitHeader main menu * Make Settings search text dynamic * Make tab hide notice use a custom hotkeys key sequence * Make Notebook menus use custom hotkeys key combo lookup for hiding tabs * shut up changelog ci * Make NotebookTab menus show custom hotkeys. SCUFFED: this does not update dynamically! * Scuffed: Make the show prefs button setting show the key bind * Scuffed: Make the R9K description refer to hotkeys * @pajlada, is something like this ok? Co-authored-by: pajlada --- CHANGELOG.md | 1 + src/controllers/hotkeys/HotkeyController.cpp | 37 +++++++ src/controllers/hotkeys/HotkeyController.hpp | 23 ++++ src/widgets/Notebook.cpp | 66 ++++++++++-- src/widgets/Notebook.hpp | 2 + src/widgets/dialogs/SettingsDialog.cpp | 16 ++- src/widgets/dialogs/SettingsDialog.hpp | 1 + src/widgets/helper/NotebookTab.cpp | 10 +- src/widgets/settingspages/GeneralPage.cpp | 26 ++++- src/widgets/splits/SplitHeader.cpp | 107 ++++++++++++++----- 10 files changed, 248 insertions(+), 41 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a1050b3e5..0476967c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,7 @@ - Minor: Migrated /ban to Helix API. (#4049) - Minor: Migrated /timeout to Helix API. (#4049) - Minor: Migrated /w to Helix API. Chat command will continue to be used until February 11th 2023. (#4052) +- Minor: Make menus and placeholders display appropriate custom key combos. (#4045) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fixed `Smooth scrolling on new messages` setting sometimes hiding messages. (#4028) - Bugfix: Fixed a crash that can occur when closing and quickly reopening a split, then running a command. (#3852) diff --git a/src/controllers/hotkeys/HotkeyController.cpp b/src/controllers/hotkeys/HotkeyController.cpp index 757197f65..f45b98a61 100644 --- a/src/controllers/hotkeys/HotkeyController.cpp +++ b/src/controllers/hotkeys/HotkeyController.cpp @@ -1,6 +1,7 @@ #include "controllers/hotkeys/HotkeyController.hpp" #include "common/QLogging.hpp" +#include "controllers/hotkeys/HotkeyCategory.hpp" #include "controllers/hotkeys/HotkeyModel.hpp" #include "singletons/Settings.hpp" @@ -547,4 +548,40 @@ void HotkeyController::showHotkeyError(const std::shared_ptr &hotkey, msgBox->exec(); } +QKeySequence HotkeyController::getDisplaySequence( + HotkeyCategory category, const QString &action, + const std::optional> &arguments) const +{ + const auto &found = this->findLike(category, action, arguments); + if (found != nullptr) + { + return found->keySequence(); + } + return {}; +} + +std::shared_ptr HotkeyController::findLike( + HotkeyCategory category, const QString &action, + const std::optional> &arguments) const +{ + for (auto other : this->hotkeys_) + { + if (other->category() == category && other->action() == action) + { + if (arguments) + { + if (other->arguments() == *arguments) + { + return other; + } + } + else + { + return other; + } + } + } + return nullptr; +} + } // namespace chatterino diff --git a/src/controllers/hotkeys/HotkeyController.hpp b/src/controllers/hotkeys/HotkeyController.hpp index 069c14bf1..1ea485400 100644 --- a/src/controllers/hotkeys/HotkeyController.hpp +++ b/src/controllers/hotkeys/HotkeyController.hpp @@ -8,6 +8,7 @@ #include #include +#include #include class QShortcut; @@ -33,6 +34,17 @@ public: void save() override; std::shared_ptr getHotkeyByName(QString name); + /** + * @brief returns a QKeySequence that perfoms the actions requested. + * Accepted if and only if the category matches, the action matches and arguments match. + * When arguments is present, contents of arguments must match the checked hotkey, otherwise arguments are ignored. + * For example: + * - std::nullopt (or {}) will match any hotkey satisfying category, action values, + * - {{"foo", "bar"}} will only match a hotkey that has these arguments and these arguments only + */ + QKeySequence getDisplaySequence( + HotkeyCategory category, const QString &action, + const std::optional> &arguments = {}) const; /** * @brief removes the hotkey with the oldName and inserts newHotkey at the end @@ -114,6 +126,17 @@ private: **/ static void showHotkeyError(const std::shared_ptr &hotkey, QString warning); + /** + * @brief finds a Hotkey matching category, action and arguments. + * Accepted if and only if the category matches, the action matches and arguments match. + * When arguments is present, contents of arguments must match the checked hotkey, otherwise arguments are ignored. + * For example: + * - std::nullopt (or {}) will match any hotkey satisfying category, action values, + * - {{"foo", "bar"}} will only match a hotkey that has these arguments and these arguments only + */ + std::shared_ptr findLike( + HotkeyCategory category, const QString &action, + const std::optional> &arguments = {}) const; friend class KeyboardSettingsPage; diff --git a/src/widgets/Notebook.cpp b/src/widgets/Notebook.cpp index b1ccf4a74..d90043eee 100644 --- a/src/widgets/Notebook.cpp +++ b/src/widgets/Notebook.cpp @@ -2,6 +2,8 @@ #include "Application.hpp" #include "common/QLogging.hpp" +#include "controllers/hotkeys/HotkeyCategory.hpp" +#include "controllers/hotkeys/HotkeyController.hpp" #include "singletons/Settings.hpp" #include "singletons/Theme.hpp" #include "singletons/WindowManager.hpp" @@ -48,6 +50,11 @@ Notebook::Notebook(QWidget *parent) [this](bool value) { this->setLockNotebookLayout(value); }); + this->showTabsAction_ = new QAction("Toggle visibility of tabs"); + QObject::connect(this->showTabsAction_, &QAction::triggered, [this]() { + this->setShowTabs(!this->getShowTabs()); + }); + this->updateTabVisibilityMenuAction(); this->addNotebookActionsToMenu(&this->menu_); @@ -374,12 +381,32 @@ void Notebook::setShowTabs(bool value) // show a popup upon hiding tabs if (!value && getSettings()->informOnTabVisibilityToggle.getValue()) { + auto unhideSeq = getApp()->hotkeys->getDisplaySequence( + HotkeyCategory::Window, "setTabVisibility", {{}}); + if (unhideSeq.isEmpty()) + { + unhideSeq = getApp()->hotkeys->getDisplaySequence( + HotkeyCategory::Window, "setTabVisibility", {{"toggle"}}); + } + if (unhideSeq.isEmpty()) + { + unhideSeq = getApp()->hotkeys->getDisplaySequence( + HotkeyCategory::Window, "setTabVisibility", {{"on"}}); + } + QString hotkeyInfo = "(currently unbound)"; + if (!unhideSeq.isEmpty()) + { + hotkeyInfo = + "(" + + unhideSeq.toString(QKeySequence::SequenceFormat::NativeText) + + ")"; + } QMessageBox msgBox(this->window()); msgBox.window()->setWindowTitle("Chatterino - hidden tabs"); msgBox.setText("You've just hidden your tabs."); msgBox.setInformativeText( - "You can toggle tabs by using the keyboard shortcut (Ctrl+U by " - "default) or right-clicking the tab area and selecting \"Toggle " + "You can toggle tabs by using the keyboard shortcut " + hotkeyInfo + + " or right-clicking the tab area and selecting \"Toggle " "visibility of tabs\"."); msgBox.addButton(QMessageBox::Ok); auto *dsaButton = @@ -394,6 +421,34 @@ void Notebook::setShowTabs(bool value) getSettings()->informOnTabVisibilityToggle.setValue(false); } } + updateTabVisibilityMenuAction(); +} + +void Notebook::updateTabVisibilityMenuAction() +{ + auto toggleSeq = getApp()->hotkeys->getDisplaySequence( + HotkeyCategory::Window, "setTabVisibility", {{}}); + if (toggleSeq.isEmpty()) + { + toggleSeq = getApp()->hotkeys->getDisplaySequence( + HotkeyCategory::Window, "setTabVisibility", {{"toggle"}}); + } + + if (toggleSeq.isEmpty()) + { + // show contextual shortcuts + if (this->getShowTabs()) + { + toggleSeq = getApp()->hotkeys->getDisplaySequence( + HotkeyCategory::Window, "setTabVisibility", {{"off"}}); + } + else if (!this->getShowTabs()) + { + toggleSeq = getApp()->hotkeys->getDisplaySequence( + HotkeyCategory::Window, "setTabVisibility", {{"on"}}); + } + } + this->showTabsAction_->setShortcut(toggleSeq); } bool Notebook::getShowAddButton() const @@ -919,12 +974,7 @@ void Notebook::setLockNotebookLayout(bool value) void Notebook::addNotebookActionsToMenu(QMenu *menu) { - menu->addAction( - "Toggle visibility of tabs", - [this]() { - this->setShowTabs(!this->getShowTabs()); - }, - QKeySequence("Ctrl+U")); + menu->addAction(this->showTabsAction_); menu->addAction(this->lockNotebookLayoutAction_); } diff --git a/src/widgets/Notebook.hpp b/src/widgets/Notebook.hpp index f69b90b8a..743b33140 100644 --- a/src/widgets/Notebook.hpp +++ b/src/widgets/Notebook.hpp @@ -86,6 +86,7 @@ protected: } private: + void updateTabVisibilityMenuAction(); void resizeAddButton(); bool containsPage(QWidget *page); @@ -111,6 +112,7 @@ private: bool lockNotebookLayout_ = false; NotebookTabLocation tabLocation_ = NotebookTabLocation::Top; QAction *lockNotebookLayoutAction_; + QAction *showTabsAction_; }; class SplitNotebook : public Notebook diff --git a/src/widgets/dialogs/SettingsDialog.cpp b/src/widgets/dialogs/SettingsDialog.cpp index 00f2bd59e..d6969cbfc 100644 --- a/src/widgets/dialogs/SettingsDialog.cpp +++ b/src/widgets/dialogs/SettingsDialog.cpp @@ -54,6 +54,7 @@ SettingsDialog::SettingsDialog(QWidget *parent) void SettingsDialog::addShortcuts() { + this->setSearchPlaceholderText(); HotkeyController::HotkeyMap actions{ {"search", [this](std::vector) -> QString { @@ -71,6 +72,19 @@ void SettingsDialog::addShortcuts() this->shortcuts_ = getApp()->hotkeys->shortcutsForCategory( HotkeyCategory::PopupWindow, actions, this); } +void SettingsDialog::setSearchPlaceholderText() +{ + QString searchHotkey; + auto searchSeq = getApp()->hotkeys->getDisplaySequence( + HotkeyCategory::PopupWindow, "search"); + if (!searchSeq.isEmpty()) + { + searchHotkey = + "(" + searchSeq.toString(QKeySequence::SequenceFormat::NativeText) + + ")"; + } + this->ui_.search->setPlaceholderText("Find in settings... " + searchHotkey); +} void SettingsDialog::initUi() { @@ -85,7 +99,7 @@ void SettingsDialog::initUi() .withoutMargin() .emplace() .assign(&this->ui_.search); - edit->setPlaceholderText("Find in settings... (Ctrl+F by default)"); + this->setSearchPlaceholderText(); edit->setClearButtonEnabled(true); edit->findChild()->setIcon( QPixmap(":/buttons/clearSearch.png")); diff --git a/src/widgets/dialogs/SettingsDialog.hpp b/src/widgets/dialogs/SettingsDialog.hpp index 5176b76dd..b4705bc99 100644 --- a/src/widgets/dialogs/SettingsDialog.hpp +++ b/src/widgets/dialogs/SettingsDialog.hpp @@ -61,6 +61,7 @@ private: void onOkClicked(); void onCancelClicked(); void addShortcuts() override; + void setSearchPlaceholderText(); struct { QWidget *tabContainerContainer{}; diff --git a/src/widgets/helper/NotebookTab.cpp b/src/widgets/helper/NotebookTab.cpp index 8fdbe74ed..b92768898 100644 --- a/src/widgets/helper/NotebookTab.cpp +++ b/src/widgets/helper/NotebookTab.cpp @@ -2,6 +2,8 @@ #include "Application.hpp" #include "common/Common.hpp" +#include "controllers/hotkeys/HotkeyCategory.hpp" +#include "controllers/hotkeys/HotkeyController.hpp" #include "singletons/Fonts.hpp" #include "singletons/Settings.hpp" #include "singletons/Theme.hpp" @@ -60,12 +62,15 @@ NotebookTab::NotebookTab(Notebook *notebook) this->showRenameDialog(); }); + // XXX: this doesn't update after changing hotkeys + this->menu_.addAction( "Close Tab", [=]() { this->notebook_->removePage(this->page); }, - QKeySequence("Ctrl+Shift+W")); + getApp()->hotkeys->getDisplaySequence(HotkeyCategory::Window, + "removeTab")); this->menu_.addAction( "Popup Tab", @@ -75,7 +80,8 @@ NotebookTab::NotebookTab(Notebook *notebook) container->popup(); } }, - QKeySequence("Ctrl+Shift+N")); + getApp()->hotkeys->getDisplaySequence(HotkeyCategory::Window, "popup", + {{"window"}})); highlightNewMessagesAction_ = new QAction("Mark Tab as Unread on New Messages", &this->menu_); diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index 1e704bb45..221041293 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -3,6 +3,8 @@ #include "Application.hpp" #include "common/QLogging.hpp" #include "common/Version.hpp" +#include "controllers/hotkeys/HotkeyCategory.hpp" +#include "controllers/hotkeys/HotkeyController.hpp" #include "singletons/Fonts.hpp" #include "singletons/NativeMessaging.hpp" #include "singletons/Paths.hpp" @@ -193,7 +195,17 @@ void GeneralPage::initLayout(GeneralPageView &layout) #endif if (!BaseWindow::supportsCustomWindowFrame()) { - layout.addCheckbox("Show preferences button (Ctrl+P to show)", + auto settingsSeq = getApp()->hotkeys->getDisplaySequence( + HotkeyCategory::Window, "openSettings"); + QString shortcut = " (no key bound to open them otherwise)"; + // TODO: maybe prevent the user from locking themselves out of the settings? + if (!settingsSeq.isEmpty()) + { + shortcut = QStringLiteral(" (%1 to show)") + .arg(settingsSeq.toString( + QKeySequence::SequenceFormat::NativeText)); + } + layout.addCheckbox("Show preferences button" + shortcut, s.hidePreferencesButton, true); layout.addCheckbox("Show user button", s.hideUserButton, true); } @@ -559,8 +571,18 @@ void GeneralPage::initLayout(GeneralPageView &layout) layout.addCheckbox("Title", s.headerStreamTitle); layout.addSubtitle("R9K"); + auto toggleLocalr9kSeq = getApp()->hotkeys->getDisplaySequence( + HotkeyCategory::Window, "toggleLocalR9K"); + QString toggleLocalr9kShortcut = + "an assigned hotkey (Window -> Toggle local R9K)"; + if (!toggleLocalr9kSeq.isEmpty()) + { + toggleLocalr9kShortcut = toggleLocalr9kSeq.toString( + QKeySequence::SequenceFormat::NativeText); + } layout.addDescription("Hide similar messages. Toggle hidden " - "messages by pressing Ctrl+H."); + "messages by pressing " + + toggleLocalr9kShortcut + "."); layout.addCheckbox("Hide similar messages", s.similarityEnabled); //layout.addCheckbox("Gray out matches", s.colorSimilarDisabled); layout.addCheckbox("By the same user", s.hideSimilarBySameUser); diff --git a/src/widgets/splits/SplitHeader.cpp b/src/widgets/splits/SplitHeader.cpp index 622218d3c..a1ff94483 100644 --- a/src/widgets/splits/SplitHeader.cpp +++ b/src/widgets/splits/SplitHeader.cpp @@ -3,6 +3,9 @@ #include "Application.hpp" #include "controllers/accounts/AccountController.hpp" #include "controllers/commands/CommandController.hpp" +#include "controllers/hotkeys/Hotkey.hpp" +#include "controllers/hotkeys/HotkeyCategory.hpp" +#include "controllers/hotkeys/HotkeyController.hpp" #include "controllers/notifications/NotificationController.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchIrcServer.hpp" @@ -337,21 +340,27 @@ void SplitHeader::initializeLayout() std::unique_ptr SplitHeader::createMainMenu() { // top level menu + const auto &h = getApp()->hotkeys; auto menu = std::make_unique(); - menu->addAction("Change channel", this->split_, &Split::changeChannel, - QKeySequence("Ctrl+R")); + menu->addAction( + "Change channel", this->split_, &Split::changeChannel, + h->getDisplaySequence(HotkeyCategory::Split, "changeChannel")); menu->addAction("Close", this->split_, &Split::deleteFromContainer, - QKeySequence("Ctrl+W")); + h->getDisplaySequence(HotkeyCategory::Split, "delete")); menu->addSeparator(); - menu->addAction("Popup", this->split_, &Split::popup, - QKeySequence("Ctrl+N")); + menu->addAction( + "Popup", this->split_, &Split::popup, + h->getDisplaySequence(HotkeyCategory::Window, "popup", {{"split"}})); menu->addAction("Search", this->split_, &Split::showSearch, - QKeySequence("Ctrl+F")); - menu->addAction("Set filters", this->split_, &Split::setFiltersDialog); + h->getDisplaySequence(HotkeyCategory::Split, "showSearch")); + menu->addAction( + "Set filters", this->split_, &Split::setFiltersDialog, + h->getDisplaySequence(HotkeyCategory::Split, "pickFilters")); menu->addSeparator(); #ifdef USEWEBENGINE - this->dropdownMenu.addAction("Start watching", this->split_, - &Split::startWatching); + this->dropdownMenu.addAction( + "Start watching", this->split_, &Split::startWatching; + h->getDisplaySequence(HotkeyCategory::Split, "startWatching")); #endif auto *twitchChannel = @@ -359,24 +368,31 @@ std::unique_ptr SplitHeader::createMainMenu() if (twitchChannel) { - menu->addAction(OPEN_IN_BROWSER, this->split_, &Split::openInBrowser); + menu->addAction( + OPEN_IN_BROWSER, this->split_, &Split::openInBrowser, + h->getDisplaySequence(HotkeyCategory::Split, "openInBrowser")); #ifndef USEWEBENGINE menu->addAction(OPEN_PLAYER_IN_BROWSER, this->split_, &Split::openBrowserPlayer); #endif - menu->addAction(OPEN_IN_STREAMLINK, this->split_, - &Split::openInStreamlink); + menu->addAction( + OPEN_IN_STREAMLINK, this->split_, &Split::openInStreamlink, + h->getDisplaySequence(HotkeyCategory::Split, "openInStreamlink")); if (!getSettings()->customURIScheme.getValue().isEmpty()) { menu->addAction("Open in custom player", this->split_, - &Split::openWithCustomScheme); + &Split::openWithCustomScheme, + h->getDisplaySequence(HotkeyCategory::Split, + "openInCustomPlayer")); } if (this->split_->getChannel()->hasModRights()) { - menu->addAction(OPEN_MOD_VIEW_IN_BROWSER, this->split_, - &Split::openModViewInBrowser); + menu->addAction( + OPEN_MOD_VIEW_IN_BROWSER, this->split_, + &Split::openModViewInBrowser, + h->getDisplaySequence(HotkeyCategory::Split, "openModView")); } menu->addAction( @@ -384,7 +400,7 @@ std::unique_ptr SplitHeader::createMainMenu() [twitchChannel] { twitchChannel->createClip(); }, - QKeySequence("Alt+X")) + h->getDisplaySequence(HotkeyCategory::Split, "createClip")) ->setVisible(twitchChannel->isLive()); menu->addSeparator(); @@ -392,24 +408,35 @@ std::unique_ptr SplitHeader::createMainMenu() if (this->split_->getChannel()->getType() == Channel::Type::TwitchWhispers) { - menu->addAction(OPEN_WHISPERS_IN_BROWSER, this->split_, - &Split::openWhispersInBrowser); + menu->addAction( + OPEN_WHISPERS_IN_BROWSER, this->split_, + &Split::openWhispersInBrowser, + h->getDisplaySequence(HotkeyCategory::Split, "openInBrowser")); menu->addSeparator(); } // reload / reconnect if (this->split_->getChannel()->canReconnect()) { - menu->addAction("Reconnect", this, SLOT(reconnect()), - QKeySequence("Ctrl+F5")); + menu->addAction( + "Reconnect", this, SLOT(reconnect()), + h->getDisplaySequence(HotkeyCategory::Split, "reconnect")); } if (twitchChannel) { + auto bothSeq = + h->getDisplaySequence(HotkeyCategory::Split, "reloadEmotes", {{}}); + auto channelSeq = h->getDisplaySequence(HotkeyCategory::Split, + "reloadEmotes", {{"channel"}}); + auto subSeq = h->getDisplaySequence(HotkeyCategory::Split, + "reloadEmotes", {{"subscriber"}}); menu->addAction("Reload channel emotes", this, - SLOT(reloadChannelEmotes()), QKeySequence("F5")); + SLOT(reloadChannelEmotes()), + channelSeq.isEmpty() ? bothSeq : channelSeq); menu->addAction("Reload subscriber emotes", this, - SLOT(reloadSubscriberEmotes()), QKeySequence("F5")); + SLOT(reloadSubscriberEmotes()), + subSeq.isEmpty() ? bothSeq : subSeq); } menu->addSeparator(); @@ -427,9 +454,20 @@ std::unique_ptr SplitHeader::createMainMenu() // sub menu auto moreMenu = new QMenu("More", this); - moreMenu->addAction("Toggle moderation mode", this->split_, [this]() { - this->split_->setModerationMode(!this->split_->getModerationMode()); - }); + auto modModeSeq = h->getDisplaySequence(HotkeyCategory::Split, + "setModerationMode", {{"toggle"}}); + if (modModeSeq.isEmpty()) + { + modModeSeq = h->getDisplaySequence(HotkeyCategory::Split, + "setModerationMode", {{}}); + // this makes a full std::optional<> with an empty vector inside + } + moreMenu->addAction( + "Toggle moderation mode", this->split_, + [this]() { + this->split_->setModerationMode(!this->split_->getModerationMode()); + }, + modModeSeq); if (this->split_->getChannel()->getType() == Channel::Type::TwitchMentions) { @@ -450,8 +488,9 @@ std::unique_ptr SplitHeader::createMainMenu() if (twitchChannel) { - moreMenu->addAction("Show viewer list", this->split_, - &Split::showViewerList); + moreMenu->addAction( + "Show viewer list", this->split_, &Split::showViewerList, + h->getDisplaySequence(HotkeyCategory::Split, "openViewerList")); moreMenu->addAction("Subscribe", this->split_, &Split::openSubPage); @@ -459,6 +498,16 @@ std::unique_ptr SplitHeader::createMainMenu() action->setText("Notify when live"); action->setCheckable(true); + auto notifySeq = h->getDisplaySequence( + HotkeyCategory::Split, "setChannelNotification", {{"toggle"}}); + if (notifySeq.isEmpty()) + { + notifySeq = h->getDisplaySequence(HotkeyCategory::Split, + "setChannelNotification", {{}}); + // this makes a full std::optional<> with an empty vector inside + } + action->setShortcut(notifySeq); + QObject::connect(moreMenu, &QMenu::aboutToShow, this, [action, this]() { action->setChecked(getApp()->notifications->isChannelNotified( this->split_->getChannel()->getName(), Platform::Twitch)); @@ -490,7 +539,9 @@ std::unique_ptr SplitHeader::createMainMenu() } moreMenu->addSeparator(); - moreMenu->addAction("Clear messages", this->split_, &Split::clear); + moreMenu->addAction( + "Clear messages", this->split_, &Split::clear, + h->getDisplaySequence(HotkeyCategory::Split, "clearMessages")); // moreMenu->addSeparator(); // moreMenu->addAction("Show changelog", this, // SLOT(moreMenuShowChangelog())); From ceecc7ef91cd9494f27104ef8ce445ade5d258f9 Mon Sep 17 00:00:00 2001 From: James Upjohn Date: Mon, 10 Oct 2022 23:56:55 +1300 Subject: [PATCH 055/946] chore: migrate /vips command to Helix call (#4053) * feat(helix): create response model for VIP listing * feat(helix): stub out channel/vips request + handler * feat(helix): parse VIPs list from data and pass to callback * feat(helix): handle errors when getting VIP list then pass to callback * feat(command): add barebones handler for helix-based /vips * feat(command): provide better /vips output when user is not broadcaster * chore(format): bulk reformat with clang-format * chore(changelog): add entry for /vips Helix migration * fix(helix): use correct method when calling VIP list endpoint * fix(helix): use correct VIP list endpoint * chore(tidy): please clang-tidy by marking parameter as unused * feat(command): display unsorted VIP list returned from Helix API * feat(settings): clone raid timegate settings for /vips * feat(command): check /vips timegate setting before execution * feat(command): handle 0 VIPs from Helix response * feat(command): sort users alphabetically from Helix VIPs response * fix(command): highlight users in Helix /vips output to match IRC * fix(command): replace dynamic /vips error message with hardcoded string * chore(comment): remove TODO comment that was DONE * chore(format): bulk reformat using clang-format * fix(command): send 0 VIP message after creation * chore: apply suggestions from Felanbird * fix(helix): change mention of user ban to VIPs in VIP list error message * feat(helix): distinguish non-broadcaster auth error when getting VIPs * chore(command): move handling of non-broadcaster /vips usage to API response * chore(format): re-indent multiline string to get away from 80 char limit * reformat * fix tests Co-authored-by: pajlada --- CHANGELOG.md | 1 + .../commands/CommandController.cpp | 132 ++++++++++++++++++ src/providers/twitch/api/Helix.cpp | 93 ++++++++++++ src/providers/twitch/api/Helix.hpp | 40 ++++++ src/singletons/Settings.hpp | 4 + src/widgets/settingspages/GeneralPage.cpp | 11 ++ tests/src/HighlightController.cpp | 9 ++ 7 files changed, 290 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0476967c0..1566254fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,7 @@ - Minor: Migrated /ban to Helix API. (#4049) - Minor: Migrated /timeout to Helix API. (#4049) - Minor: Migrated /w to Helix API. Chat command will continue to be used until February 11th 2023. (#4052) +- Minor: Migrated /vips to Helix API. Chat command will continue to be used until February 11th 2023. (#4053) - Minor: Make menus and placeholders display appropriate custom key combos. (#4045) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fixed `Smooth scrolling on new messages` setting sometimes hiding messages. (#4028) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index b76806468..f8f1cb530 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -12,6 +12,7 @@ #include "messages/MessageElement.hpp" #include "providers/twitch/TwitchCommon.hpp" #include "providers/twitch/TwitchIrcServer.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" #include "providers/twitch/api/Helix.hpp" #include "singletons/Emotes.hpp" #include "singletons/Paths.hpp" @@ -2936,6 +2937,137 @@ void CommandController::initialize(Settings &, Paths &paths) return runWhisperCommand(words, channel); }); } + + auto formatVIPListError = [](HelixListVIPsError error, + const QString &message) -> QString { + using Error = HelixListVIPsError; + + QString errorMessage = QString("Failed to list VIPs - "); + + switch (error) + { + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Ratelimited: { + errorMessage += "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::UserNotBroadcaster: { + errorMessage += + "Due to Twitch restrictions, " + "this command can only be used by the broadcaster. " + "To see the list of VIPs you must use the Twitch website."; + } + break; + + case Error::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + return errorMessage; + }; + + this->registerCommand( + "/vips", + [formatVIPListError](const QStringList &words, + auto channel) -> QString { + switch (getSettings()->helixTimegateVIPs.getValue()) + { + case HelixTimegateOverride::Timegate: { + if (areIRCCommandsStillAvailable()) + { + return useIRCCommand(words); + } + + // fall through to Helix logic + } + break; + + case HelixTimegateOverride::AlwaysUseIRC: { + return useIRCCommand(words); + } + break; + + case HelixTimegateOverride::AlwaysUseHelix: { + // do nothing and fall through to Helix logic + } + break; + } + + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /vips command only works in Twitch channels")); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage(makeSystemMessage( + "Due to Twitch restrictions, " // + "this command can only be used by the broadcaster. " + "To see the list of VIPs you must use the " + "Twitch website.")); + return ""; + } + + getHelix()->getChannelVIPs( + twitchChannel->roomId(), + [channel, twitchChannel](const std::vector &vipList) { + if (vipList.empty()) + { + channel->addMessage(makeSystemMessage( + "This channel does not have any VIPs.")); + return; + } + + auto messagePrefix = + QString("The VIPs of this channel are"); + auto entries = QStringList(); + + for (const auto &vip : vipList) + { + entries.append(vip.userName); + } + + entries.sort(Qt::CaseInsensitive); + + MessageBuilder builder; + TwitchMessageBuilder::listOfUsersSystemMessage( + messagePrefix, entries, twitchChannel, &builder); + + channel->addMessage(builder.release()); + }, + [channel, formatVIPListError](auto error, auto message) { + auto errorMessage = formatVIPListError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; + }); } void CommandController::save() diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 7684b2720..b30229282 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -1991,6 +1991,99 @@ void Helix::sendWhisper( .execute(); } +// List the VIPs of a channel +// https://dev.twitch.tv/docs/api/reference#get-vips +void Helix::getChannelVIPs( + QString broadcasterID, + ResultCallback> successCallback, + FailureCallback failureCallback) +{ + using Error = HelixListVIPsError; + QUrlQuery urlQuery; + + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + + // No point pagi/pajanating, Twitch's max VIP count doesn't go over 100 + // TODO(jammehcow): probably still implement pagination + // as the mod list can go over 100 (I assume, I see no limit) + urlQuery.addQueryItem("first", "100"); + + this->makeRequest("channels/vips", urlQuery) + .type(NetworkRequestType::Get) + .header("Content-Type", "application/json") + .onSuccess([successCallback](auto result) -> Outcome { + if (result.status() != 200) + { + qCWarning(chatterinoTwitch) + << "Success result for getting VIPs was" << result.status() + << "but we expected it to be 200"; + } + + auto response = result.parseJson(); + + std::vector channelVips; + for (const auto &jsonStream : response.value("data").toArray()) + { + channelVips.emplace_back(jsonStream.toObject()); + } + + successCallback(channelVips); + return Success; + }) + .onError([failureCallback](auto result) { + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (result.status()) + { + case 400: { + failureCallback(Error::Forwarded, message); + } + break; + + case 401: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + failureCallback(Error::UserMissingScope, message); + } + else if (message.compare( + "The ID in broadcaster_id must match the user " + "ID found in the request's OAuth token.", + Qt::CaseInsensitive) == 0) + { + // Must be the broadcaster. + failureCallback(Error::UserNotBroadcaster, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 403: { + failureCallback(Error::UserNotAuthorized, message); + } + break; + + case 429: { + failureCallback(Error::Ratelimited, message); + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error listing VIPs:" << 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 00f7037f8..0e491f3c4 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -329,6 +329,23 @@ struct HelixChatSettings { } }; +struct HelixVip { + QString userId; + QString userName; + QString userLogin; + + explicit HelixVip(const QJsonObject &jsonObject) + : userId(jsonObject.value("user_id").toString()) + , userName(jsonObject.value("user_name").toString()) + , userLogin(jsonObject.value("user_login").toString()) + { + } +}; + +// TODO(jammehcow): when implementing mod list, just alias HelixVip to HelixMod +// as they share the same model. +// Alternatively, rename base struct to HelixUser or something and alias both + enum class HelixAnnouncementColor { Blue, Green, @@ -502,6 +519,17 @@ enum class HelixWhisperError { // /w Forwarded, }; // /w +enum class HelixListVIPsError { // /vips + Unknown, + UserMissingScope, + UserNotAuthorized, + UserNotBroadcaster, + Ratelimited, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; // /vips + class IHelix { public: @@ -756,6 +784,12 @@ public: ResultCallback<> successCallback, FailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#get-vips + virtual void getChannelVIPs( + QString broadcasterID, + ResultCallback> successCallback, + FailureCallback failureCallback) = 0; + virtual void update(QString clientId, QString oauthToken) = 0; protected: @@ -1011,6 +1045,12 @@ public: ResultCallback<> successCallback, FailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference#get-vips + void getChannelVIPs( + QString broadcasterID, + ResultCallback> successCallback, + FailureCallback failureCallback) final; + void update(QString clientId, QString oauthToken) final; static void initialize(); diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index b341b9b34..44a8d2367 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -432,6 +432,10 @@ public: "/misc/twitch/helix-timegate/whisper", HelixTimegateOverride::Timegate, }; + EnumSetting helixTimegateVIPs = { + "/misc/twitch/helix-timegate/vips", + HelixTimegateOverride::Timegate, + }; IntSetting emotesTooltipPreview = {"/misc/emotesTooltipPreview", 1}; BoolSetting openLinksIncognito = {"/misc/openLinksIncognito", 0}; diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index 221041293..e209c0f80 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -800,6 +800,17 @@ void GeneralPage::initLayout(GeneralPageView &layout) helixTimegateWhisper->setMinimumWidth( helixTimegateWhisper->minimumSizeHint().width()); + auto *helixTimegateVIPs = + layout.addDropdown::type>( + "Helix timegate /vips behaviour", + {"Timegate", "Always use IRC", "Always use Helix"}, + s.helixTimegateVIPs, + helixTimegateGetValue, // + helixTimegateSetValue, // + false); + helixTimegateVIPs->setMinimumWidth( + helixTimegateVIPs->minimumSizeHint().width()); + layout.addStretch(); // invisible element for width diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index e5bdb04be..982fc2800 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -360,6 +360,15 @@ public: (FailureCallback failureCallback)), (override)); // /w + // /vips + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD( + void, getChannelVIPs, + (QString broadcasterID, + ResultCallback> successCallback, + (FailureCallback failureCallback)), + (override)); // /vips + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override)); From 3e020b4891ee8b90b9b7f4948a7b9567ad9a7ed9 Mon Sep 17 00:00:00 2001 From: pajlada Date: Wed, 12 Oct 2022 11:59:52 +0200 Subject: [PATCH 056/946] Fix rare reply mention crash (#4055) * Fix potential out-of-range access of at when stripping reply mention if the message contained nothing other than the username * Update changelog entry --- CHANGELOG.md | 2 +- src/providers/twitch/IrcMessageHandler.cpp | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1566254fc..e28ec8628 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unversioned -- Major: Added support for Twitch's Chat Replies. [Wiki Page](https://wiki.chatterino.com/Features/#message-replies) (#3722, #3989, #4041, #4047) +- Major: Added support for Twitch's Chat Replies. [Wiki Page](https://wiki.chatterino.com/Features/#message-replies) (#3722, #3989, #4041, #4047, #4055) - Major: Added multi-channel searching to search dialog via keyboard shortcut. (Ctrl+Shift+F by default) (#3694, #3875) - Minor: Added highlights for `Elevated Messages`. (#4016) - Minor: Removed total views from the usercard, as Twitch no longer updates the number. (#3792) diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp index da32a6a8d..af39b3f47 100644 --- a/src/providers/twitch/IrcMessageHandler.cpp +++ b/src/providers/twitch/IrcMessageHandler.cpp @@ -78,6 +78,13 @@ int stripLeadingReplyMention(const QVariantMap &tags, QString &content) it != tags.end()) { auto displayName = it.value().toString(); + + if (content.length() <= 1 + displayName.length()) + { + // The reply contains no content + return 0; + } + if (content.startsWith('@') && content.at(1 + displayName.length()) == ' ' && content.indexOf(displayName, 1) == 1) From c71d3437f47c0c79526dfc6f374915db63a10b73 Mon Sep 17 00:00:00 2001 From: Aiden Date: Sat, 15 Oct 2022 11:36:49 +0100 Subject: [PATCH 057/946] Migrate /uniquechat and /uniquechatoff to Helix API (#4057) * Migrate /uniquechat and /uniquechatoff to Helix * Update CHANGELOG.md * Move & squash changelog entries Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 2 + .../commands/CommandController.cpp | 61 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e28ec8628..30b6908e2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,8 @@ - Minor: Migrated /timeout to Helix API. (#4049) - Minor: Migrated /w to Helix API. Chat command will continue to be used until February 11th 2023. (#4052) - Minor: Migrated /vips to Helix API. Chat command will continue to be used until February 11th 2023. (#4053) +- Minor: Migrated /uniquechat and /r9kbeta to Helix API. (#4057) +- Minor: Migrated /uniquechatoff and /r9kbetaoff to Helix API. (#4057) - Minor: Make menus and placeholders display appropriate custom key combos. (#4045) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fixed `Smooth scrolling on new messages` setting sometimes hiding messages. (#4028) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index f8f1cb530..a8ffba1a1 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -3068,6 +3068,67 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); + + auto uniqueChatLambda = [formatChatSettingsError](auto words, auto channel, + bool target) { + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + channel->addMessage(makeSystemMessage( + "You must be logged in to update chat settings!")); + return ""; + } + auto *twitchChannel = dynamic_cast(channel.get()); + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + QString("The /%1 command only works in Twitch channels") + .arg(target ? "uniquechat" : "uniquechatoff"))); + return ""; + } + + if (twitchChannel->accessRoomModes()->r9k == target) + { + channel->addMessage(makeSystemMessage( + target ? "This room is already in unique-chat mode." + : "This room is not in unique-chat mode.")); + return ""; + } + + getHelix()->updateUniqueChatMode( + twitchChannel->roomId(), currentUser->getUserId(), target, + [](auto) { + // we'll get a message from irc + }, + [channel, formatChatSettingsError](auto error, auto message) { + channel->addMessage( + makeSystemMessage(formatChatSettingsError(error, message))); + }); + return ""; + }; + + this->registerCommand( + "/uniquechatoff", + [uniqueChatLambda](const QStringList &words, auto channel) { + return uniqueChatLambda(words, channel, false); + }); + + this->registerCommand( + "/r9kbetaoff", + [uniqueChatLambda](const QStringList &words, auto channel) { + return uniqueChatLambda(words, channel, false); + }); + + this->registerCommand( + "/uniquechat", + [uniqueChatLambda](const QStringList &words, auto channel) { + return uniqueChatLambda(words, channel, true); + }); + + this->registerCommand( + "/r9kbeta", [uniqueChatLambda](const QStringList &words, auto channel) { + return uniqueChatLambda(words, channel, true); + }); } void CommandController::save() From 16034ababb435ff4c9d9f576b2a7466bdcbaf7cf Mon Sep 17 00:00:00 2001 From: pajlada Date: Sat, 15 Oct 2022 13:19:56 +0200 Subject: [PATCH 058/946] Fix FreeBSD Cirrus CI build (#4058) --- .cirrus.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.cirrus.yml b/.cirrus.yml index 94c6d2059..2eb42f697 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -3,7 +3,7 @@ freebsd_instance: task: install_script: - - pkg install -y boost-libs git qt5-buildtools qt5-concurrent qt5-core qt5-multimedia qt5-svg qtkeychain qt5-qmake cmake qt5-linguist + - pkg install -y boost-libs git qt5-buildtools qt5-concurrent qt5-core qt5-multimedia qt5-svg qtkeychain-qt5 qt5-qmake cmake qt5-linguist script: | git submodule init git submodule update From f6f0bc8ab5c727a7a0b6e04d6aebc97e83b71c8b Mon Sep 17 00:00:00 2001 From: pajlada Date: Sat, 15 Oct 2022 13:54:25 +0200 Subject: [PATCH 059/946] Swap back to main branch of ZedThree's clang-tidy-review (#4059) --- .github/workflows/build.yml | 2 +- .github/workflows/post-clang-tidy-review.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 252fb7e83..3b7135008 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -148,7 +148,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: pajlada/clang-tidy-review@feat/split-up-review-and-post-workflows + uses: ZedThree/clang-tidy-review@v0.10.0 id: review with: build_dir: build diff --git a/.github/workflows/post-clang-tidy-review.yml b/.github/workflows/post-clang-tidy-review.yml index 8a49322b9..6332e11bd 100644 --- a/.github/workflows/post-clang-tidy-review.yml +++ b/.github/workflows/post-clang-tidy-review.yml @@ -35,5 +35,5 @@ jobs: - name: 'Unzip artifact' run: unzip clang-tidy-review.zip - - uses: pajlada/clang-tidy-review/post@feat/split-up-review-and-post-workflows + - uses: ZedThree/clang-tidy-review/post@v0.10.0 id: review From 4152f0dccb68c926e9d85ecca27bdbd8255cf020 Mon Sep 17 00:00:00 2001 From: xel86 Date: Sun, 16 Oct 2022 02:51:20 -0400 Subject: [PATCH 060/946] Update 1xelerate contributors github link (#4061) --- resources/contributors.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/resources/contributors.txt b/resources/contributors.txt index a23002ad7..562d4c511 100644 --- a/resources/contributors.txt +++ b/resources/contributors.txt @@ -45,7 +45,7 @@ Talen | https://github.com/talneoran | | Contributor SLCH | https://github.com/SLCH | :/avatars/slch.png | Contributor ALazyMeme | https://github.com/alazymeme | :/avatars/alazymeme.png | Contributor xHeaveny_ | https://github.com/xHeaveny | :/avatars/xheaveny.png | Contributor -1xelerate | https://github.com/1xelerate | :/avatars/_1xelerate.png | Contributor +1xelerate | https://github.com/xel86 | :/avatars/_1xelerate.png | Contributor acdvs | https://github.com/acdvs | | Contributor karl-police | https://github.com/karl-police | :/avatars/karlpolice.png | Contributor brian6932 | https://github.com/brian6932 | :/avatars/brian6932.png | Contributor From e8fd49aadbf4600b11c33828d254f85c191d4420 Mon Sep 17 00:00:00 2001 From: xel86 Date: Sun, 16 Oct 2022 06:28:22 -0400 Subject: [PATCH 061/946] Fix channel-based popups rewriting messages to file log (#4060) Co-authored-by: pajlada --- CHANGELOG.md | 1 + src/widgets/dialogs/ReplyThreadPopup.cpp | 17 ++++++++++++++--- src/widgets/dialogs/UserInfoPopup.cpp | 6 +++++- src/widgets/helper/SearchPopup.cpp | 7 ++++++- 4 files changed, 26 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 30b6908e2..b62c42912 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -93,6 +93,7 @@ - Bugfix: Fixed non-global FrankerFaceZ emotes from being loaded as global emotes. (#3921) - Bugfix: Fixed trailing spaces from preventing Nicknames from working correctly. (#3946) - Bugfix: Fixed trailing spaces from preventing User Highlights from working correctly. (#4051) +- Bugfix: Fixed channel-based popups from rewriting messages to file log (#4060) - Dev: Removed official support for QMake. (#3839, #3883) - Dev: Rewrote LimitedQueue (#3798) - Dev: Overhauled highlight system by moving all checks into a Controller allowing for easier tests. (#3399, #3801, #3835) diff --git a/src/widgets/dialogs/ReplyThreadPopup.cpp b/src/widgets/dialogs/ReplyThreadPopup.cpp index c4771de03..6fc387dfa 100644 --- a/src/widgets/dialogs/ReplyThreadPopup.cpp +++ b/src/widgets/dialogs/ReplyThreadPopup.cpp @@ -130,12 +130,19 @@ void ReplyThreadPopup::addMessagesFromThread() this->ui_.threadView->setChannel(virtualChannel); this->ui_.threadView->setSourceChannel(sourceChannel); - virtualChannel->addMessage(this->thread_->root()); + auto overrideFlags = + boost::optional(this->thread_->root()->flags); + overrideFlags->set(MessageFlag::DoNotLog); + + virtualChannel->addMessage(this->thread_->root(), overrideFlags); for (const auto &msgRef : this->thread_->replies()) { if (auto msg = msgRef.lock()) { - virtualChannel->addMessage(msg); + auto overrideFlags = boost::optional(msg->flags); + overrideFlags->set(MessageFlag::DoNotLog); + + virtualChannel->addMessage(msg, overrideFlags); } } @@ -145,8 +152,12 @@ void ReplyThreadPopup::addMessagesFromThread() [this, virtualChannel](MessagePtr &message, auto) { if (message->replyThread == this->thread_) { + auto overrideFlags = + boost::optional(message->flags); + overrideFlags->set(MessageFlag::DoNotLog); + // same reply thread, add message - virtualChannel->addMessage(message); + virtualChannel->addMessage(message, overrideFlags); } })); } diff --git a/src/widgets/dialogs/UserInfoPopup.cpp b/src/widgets/dialogs/UserInfoPopup.cpp index a57fcedc5..2781672e9 100644 --- a/src/widgets/dialogs/UserInfoPopup.cpp +++ b/src/widgets/dialogs/UserInfoPopup.cpp @@ -105,9 +105,13 @@ namespace { for (size_t i = 0; i < snapshot.size(); i++) { MessagePtr message = snapshot[i]; + + auto overrideFlags = boost::optional(message->flags); + overrideFlags->set(MessageFlag::DoNotLog); + if (checkMessageUserName(userName, message)) { - channelPtr->addMessage(message); + channelPtr->addMessage(message, overrideFlags); } } diff --git a/src/widgets/helper/SearchPopup.cpp b/src/widgets/helper/SearchPopup.cpp index f09186165..8c4f24719 100644 --- a/src/widgets/helper/SearchPopup.cpp +++ b/src/widgets/helper/SearchPopup.cpp @@ -46,7 +46,12 @@ ChannelPtr SearchPopup::filter(const QString &text, const QString &channelName, // If all predicates match, add the message to the channel if (accept) - channel->addMessage(message); + { + auto overrideFlags = boost::optional(message->flags); + overrideFlags->set(MessageFlag::DoNotLog); + + channel->addMessage(message, overrideFlags); + } } return channel; From 3e41b84ed7d41ca8f8ec7b87b193efb64393fd34 Mon Sep 17 00:00:00 2001 From: nerix Date: Sun, 16 Oct 2022 13:22:17 +0200 Subject: [PATCH 062/946] feat: Add 7TV Emotes and Badges (#4002) Co-authored-by: pajlada --- CHANGELOG.md | 1 + src/Application.cpp | 13 + src/Application.hpp | 2 + src/CMakeLists.txt | 5 + src/common/CompletionModel.cpp | 10 + src/common/CompletionModel.hpp | 2 + src/common/QLogging.cpp | 1 + src/common/QLogging.hpp | 1 + src/messages/Emote.hpp | 1 + src/messages/MessageElement.hpp | 33 +- src/providers/seventv/SeventvBadges.cpp | 76 ++++ src/providers/seventv/SeventvBadges.hpp | 35 ++ src/providers/seventv/SeventvEmotes.cpp | 365 ++++++++++++++++++ src/providers/seventv/SeventvEmotes.hpp | 73 ++++ src/providers/twitch/TwitchChannel.cpp | 41 ++ src/providers/twitch/TwitchChannel.hpp | 5 + src/providers/twitch/TwitchIrcServer.cpp | 20 + src/providers/twitch/TwitchIrcServer.hpp | 5 + src/providers/twitch/TwitchMessageBuilder.cpp | 30 ++ src/providers/twitch/TwitchMessageBuilder.hpp | 1 + src/singletons/Emotes.hpp | 1 + src/singletons/Settings.hpp | 5 + src/singletons/WindowManager.cpp | 2 + src/widgets/dialogs/EmotePopup.cpp | 21 + src/widgets/helper/ChannelView.cpp | 4 + src/widgets/settingspages/GeneralPage.cpp | 21 + src/widgets/splits/InputCompletionPopup.cpp | 11 +- src/widgets/splits/Split.cpp | 1 + src/widgets/splits/SplitHeader.cpp | 1 + 29 files changed, 780 insertions(+), 7 deletions(-) create mode 100644 src/providers/seventv/SeventvBadges.cpp create mode 100644 src/providers/seventv/SeventvBadges.hpp create mode 100644 src/providers/seventv/SeventvEmotes.cpp create mode 100644 src/providers/seventv/SeventvEmotes.hpp diff --git a/CHANGELOG.md b/CHANGELOG.md index b62c42912..5499ebfe5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ - Major: Added support for Twitch's Chat Replies. [Wiki Page](https://wiki.chatterino.com/Features/#message-replies) (#3722, #3989, #4041, #4047, #4055) - Major: Added multi-channel searching to search dialog via keyboard shortcut. (Ctrl+Shift+F by default) (#3694, #3875) +- Major: Added support for emotes and badges from [7TV](https://7tv.app). [Wiki Page](https://wiki.chatterino.com/Third_party_services/#7tv) (#4002) - Minor: Added highlights for `Elevated Messages`. (#4016) - Minor: Removed total views from the usercard, as Twitch no longer updates the number. (#3792) - Minor: Load missing messages from Recent Messages API upon reconnecting (#3878, #3932) diff --git a/src/Application.cpp b/src/Application.cpp index bd37ce2e0..de2d49d84 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -18,6 +18,8 @@ #include "providers/ffz/FfzBadges.hpp" #include "providers/ffz/FfzEmotes.hpp" #include "providers/irc/Irc2.hpp" +#include "providers/seventv/SeventvBadges.hpp" +#include "providers/seventv/SeventvEmotes.hpp" #include "providers/twitch/PubSubManager.hpp" #include "providers/twitch/TwitchIrcServer.hpp" #include "providers/twitch/TwitchMessageBuilder.hpp" @@ -72,6 +74,7 @@ Application::Application(Settings &_settings, Paths &_paths) , twitch(&this->emplace()) , chatterinoBadges(&this->emplace()) , ffzBadges(&this->emplace()) + , seventvBadges(&this->emplace()) , logging(&this->emplace()) { this->instance = this; @@ -199,6 +202,16 @@ int Application::run(QApplication &qtApp) this->twitch->reloadAllFFZChannelEmotes(); }, false); + getSettings()->enableSevenTVGlobalEmotes.connect( + [this] { + this->twitch->reloadSevenTVGlobalEmotes(); + }, + false); + getSettings()->enableSevenTVChannelEmotes.connect( + [this] { + this->twitch->reloadAllSevenTVChannelEmotes(); + }, + false); return qtApp.exec(); } diff --git a/src/Application.hpp b/src/Application.hpp index ee0d1417c..4d5bc93a2 100644 --- a/src/Application.hpp +++ b/src/Application.hpp @@ -29,6 +29,7 @@ class Fonts; class Toasts; class ChatterinoBadges; class FfzBadges; +class SeventvBadges; class IApplication { @@ -86,6 +87,7 @@ public: TwitchIrcServer *const twitch{}; ChatterinoBadges *const chatterinoBadges{}; FfzBadges *const ffzBadges{}; + SeventvBadges *const seventvBadges{}; /*[[deprecated]]*/ Logging *const logging{}; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index c9618370e..ea627e44f 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -214,6 +214,11 @@ set(SOURCE_FILES providers/irc/IrcServer.cpp providers/irc/IrcServer.hpp + providers/seventv/SeventvBadges.cpp + providers/seventv/SeventvBadges.hpp + providers/seventv/SeventvEmotes.cpp + providers/seventv/SeventvEmotes.hpp + providers/twitch/ChannelPointReward.cpp providers/twitch/ChannelPointReward.hpp providers/twitch/IrcMessageHandler.cpp diff --git a/src/common/CompletionModel.cpp b/src/common/CompletionModel.cpp index c03fc830d..7a544b41d 100644 --- a/src/common/CompletionModel.cpp +++ b/src/common/CompletionModel.cpp @@ -141,6 +141,11 @@ void CompletionModel::refresh(const QString &prefix, bool isFirstWord) } } + // 7TV Global + for (auto &emote : *getApp()->twitch->getSeventvEmotes().globalEmotes()) + { + addString(emote.first.string, TaggedString::Type::SeventvGlobalEmote); + } // Bttv Global for (auto &emote : *getApp()->twitch->getBttvEmotes().emotes()) { @@ -198,6 +203,11 @@ void CompletionModel::refresh(const QString &prefix, bool isFirstWord) } } + // 7TV Channel + for (auto &emote : *tc->seventvEmotes()) + { + addString(emote.first.string, TaggedString::Type::SeventvChannelEmote); + } // Bttv Channel for (auto &emote : *tc->bttvEmotes()) { diff --git a/src/common/CompletionModel.hpp b/src/common/CompletionModel.hpp index ee810bbe6..0d80c4aa6 100644 --- a/src/common/CompletionModel.hpp +++ b/src/common/CompletionModel.hpp @@ -22,6 +22,8 @@ class CompletionModel : public QAbstractListModel FFZChannelEmote, BTTVGlobalEmote, BTTVChannelEmote, + SeventvGlobalEmote, + SeventvChannelEmote, TwitchGlobalEmote, TwitchLocalEmote, TwitchSubscriberEmote, diff --git a/src/common/QLogging.cpp b/src/common/QLogging.cpp index 9f4346a39..ede6a45bc 100644 --- a/src/common/QLogging.cpp +++ b/src/common/QLogging.cpp @@ -34,6 +34,7 @@ Q_LOGGING_CATEGORY(chatterinoPubSub, "chatterino.pubsub", logThreshold); Q_LOGGING_CATEGORY(chatterinoRecentMessages, "chatterino.recentmessages", logThreshold); Q_LOGGING_CATEGORY(chatterinoSettings, "chatterino.settings", logThreshold); +Q_LOGGING_CATEGORY(chatterinoSeventv, "chatterino.seventv", logThreshold); Q_LOGGING_CATEGORY(chatterinoStreamerMode, "chatterino.streamermode", logThreshold); Q_LOGGING_CATEGORY(chatterinoStreamlink, "chatterino.streamlink", logThreshold); diff --git a/src/common/QLogging.hpp b/src/common/QLogging.hpp index 79ef9d69c..4f27d0ea9 100644 --- a/src/common/QLogging.hpp +++ b/src/common/QLogging.hpp @@ -26,6 +26,7 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoNuulsuploader); Q_DECLARE_LOGGING_CATEGORY(chatterinoPubSub); Q_DECLARE_LOGGING_CATEGORY(chatterinoRecentMessages); Q_DECLARE_LOGGING_CATEGORY(chatterinoSettings); +Q_DECLARE_LOGGING_CATEGORY(chatterinoSeventv); Q_DECLARE_LOGGING_CATEGORY(chatterinoStreamerMode); Q_DECLARE_LOGGING_CATEGORY(chatterinoStreamlink); Q_DECLARE_LOGGING_CATEGORY(chatterinoTokenizer); diff --git a/src/messages/Emote.hpp b/src/messages/Emote.hpp index d7c19d238..380f57dcb 100644 --- a/src/messages/Emote.hpp +++ b/src/messages/Emote.hpp @@ -14,6 +14,7 @@ struct Emote { ImageSet images; Tooltip tooltip; Url homePage; + bool zeroWidth; // FOURTF: no solution yet, to be refactored later const QString &getCopyString() const diff --git a/src/messages/MessageElement.hpp b/src/messages/MessageElement.hpp index 4cc47873a..a61816c4e 100644 --- a/src/messages/MessageElement.hpp +++ b/src/messages/MessageElement.hpp @@ -37,6 +37,7 @@ enum class MessageElementFlag : int64_t { TwitchEmoteImage = (1LL << 4), TwitchEmoteText = (1LL << 5), TwitchEmote = TwitchEmoteImage | TwitchEmoteText, + BttvEmoteImage = (1LL << 6), BttvEmoteText = (1LL << 7), BttvEmote = BttvEmoteImage | BttvEmoteText, @@ -47,8 +48,15 @@ enum class MessageElementFlag : int64_t { FfzEmoteImage = (1LL << 9), FfzEmoteText = (1LL << 10), FfzEmote = FfzEmoteImage | FfzEmoteText, - EmoteImages = TwitchEmoteImage | BttvEmoteImage | FfzEmoteImage, - EmoteText = TwitchEmoteText | BttvEmoteText | FfzEmoteText, + + SevenTVEmoteImage = (1LL << 34), + SevenTVEmoteText = (1LL << 35), + SevenTVEmote = SevenTVEmoteImage | SevenTVEmoteText, + + EmoteImages = + TwitchEmoteImage | BttvEmoteImage | FfzEmoteImage | SevenTVEmoteImage, + EmoteText = + TwitchEmoteText | BttvEmoteText | FfzEmoteText | SevenTVEmoteText, BitsStatic = (1LL << 11), BitsAnimated = (1LL << 12), @@ -89,6 +97,15 @@ enum class MessageElementFlag : int64_t { // - Chatterino gnome badge BadgeChatterino = (1LL << 18), + // Slot 7: 7TV + // - 7TV Admin + // - 7TV Dungeon Mistress + // - 7TV Moderator + // - 7TV Subscriber + // - 7TV Translator + // - 7TV Contributor + BadgeSevenTV = (1LL << 36), + // Slot 7: FrankerFaceZ // - FFZ developer badge // - FFZ bot badge @@ -96,7 +113,8 @@ enum class MessageElementFlag : int64_t { BadgeFfz = (1LL << 19), Badges = BadgeGlobalAuthority | BadgePredictions | BadgeChannelAuthority | - BadgeSubscription | BadgeVanity | BadgeChatterino | BadgeFfz, + BadgeSubscription | BadgeVanity | BadgeChatterino | BadgeSevenTV | + BadgeFfz, ChannelName = (1LL << 20), @@ -123,7 +141,7 @@ enum class MessageElementFlag : int64_t { OriginalLink = (1LL << 30), // ZeroWidthEmotes are emotes that are supposed to overlay over any pre-existing emotes - // e.g. BTTV's SoSnowy during christmas season + // e.g. BTTV's SoSnowy during christmas season or 7TV's RainTime ZeroWidthEmote = (1LL << 31), // for elements of the message reply @@ -132,9 +150,12 @@ enum class MessageElementFlag : int64_t { // for the reply button element ReplyButton = (1LL << 33), + // (1LL << 34) through (1LL << 36) are occupied by + // SevenTVEmoteImage, SevenTVEmoteText, and BadgeSevenTV, + Default = Timestamp | Badges | Username | BitsStatic | FfzEmoteImage | - BttvEmoteImage | TwitchEmoteImage | BitsAmount | Text | - AlwaysShow, + BttvEmoteImage | SevenTVEmoteImage | TwitchEmoteImage | + BitsAmount | Text | AlwaysShow, }; using MessageElementFlags = FlagsEnum; diff --git a/src/providers/seventv/SeventvBadges.cpp b/src/providers/seventv/SeventvBadges.cpp new file mode 100644 index 000000000..2fb7f4ef5 --- /dev/null +++ b/src/providers/seventv/SeventvBadges.cpp @@ -0,0 +1,76 @@ +#include "providers/seventv/SeventvBadges.hpp" + +#include "common/NetworkRequest.hpp" +#include "common/Outcome.hpp" +#include "messages/Emote.hpp" + +#include +#include + +#include + +namespace chatterino { + +void SeventvBadges::initialize(Settings & /*settings*/, Paths & /*paths*/) +{ + this->loadSeventvBadges(); +} + +boost::optional SeventvBadges::getBadge(const UserId &id) +{ + std::shared_lock lock(this->mutex_); + + auto it = this->badgeMap_.find(id.string); + if (it != this->badgeMap_.end()) + { + return this->emotes_[it->second]; + } + return boost::none; +} + +void SeventvBadges::loadSeventvBadges() +{ + // Cosmetics will work differently in v3, until this is ready + // we'll use this endpoint. + static QUrl url("https://7tv.io/v2/cosmetics"); + + static QUrlQuery urlQuery; + // valid user_identifier values: "object_id", "twitch_id", "login" + urlQuery.addQueryItem("user_identifier", "twitch_id"); + + url.setQuery(urlQuery); + + NetworkRequest(url) + .onSuccess([this](const NetworkResult &result) -> Outcome { + auto root = result.parseJson(); + + std::shared_lock lock(this->mutex_); + + int index = 0; + for (const auto &jsonBadge : root.value("badges").toArray()) + { + auto badge = jsonBadge.toObject(); + auto urls = badge.value("urls").toArray(); + auto emote = + Emote{EmoteName{}, + ImageSet{Url{urls.at(0).toArray().at(1).toString()}, + Url{urls.at(1).toArray().at(1).toString()}, + Url{urls.at(2).toArray().at(1).toString()}}, + Tooltip{badge.value("tooltip").toString()}, Url{}}; + + this->emotes_.push_back( + std::make_shared(std::move(emote))); + + for (const auto &user : badge.value("users").toArray()) + { + this->badgeMap_[user.toString()] = index; + } + ++index; + } + + return Success; + }) + .execute(); +} + +} // namespace chatterino diff --git a/src/providers/seventv/SeventvBadges.hpp b/src/providers/seventv/SeventvBadges.hpp new file mode 100644 index 000000000..2d6d021a2 --- /dev/null +++ b/src/providers/seventv/SeventvBadges.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include "common/Aliases.hpp" +#include "util/QStringHash.hpp" + +#include +#include + +#include +#include +#include + +namespace chatterino { + +struct Emote; +using EmotePtr = std::shared_ptr; + +class SeventvBadges : public Singleton +{ +public: + void initialize(Settings &settings, Paths &paths) override; + + boost::optional getBadge(const UserId &id); + +private: + void loadSeventvBadges(); + + // Mutex for both `badgeMap_` and `emotes_` + std::shared_mutex mutex_; + + std::unordered_map badgeMap_; + std::vector emotes_; +}; + +} // namespace chatterino diff --git a/src/providers/seventv/SeventvEmotes.cpp b/src/providers/seventv/SeventvEmotes.cpp new file mode 100644 index 000000000..4bce11f38 --- /dev/null +++ b/src/providers/seventv/SeventvEmotes.cpp @@ -0,0 +1,365 @@ +#include "providers/seventv/SeventvEmotes.hpp" + +#include "common/Common.hpp" +#include "common/NetworkRequest.hpp" +#include "common/QLogging.hpp" +#include "messages/Emote.hpp" +#include "messages/Image.hpp" +#include "messages/ImageSet.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "singletons/Settings.hpp" + +#include +#include +#include +#include + +/** + * # References + * + * - EmoteSet: https://github.com/SevenTV/API/blob/a84e884b5590dbb5d91a5c6b3548afabb228f385/data/model/emote-set.model.go#L8-L18 + * - ActiveEmote: https://github.com/SevenTV/API/blob/a84e884b5590dbb5d91a5c6b3548afabb228f385/data/model/emote-set.model.go#L20-L27 + * - EmotePartial (emoteData): https://github.com/SevenTV/API/blob/a84e884b5590dbb5d91a5c6b3548afabb228f385/data/model/emote.model.go#L24-L34 + * - ImageHost: https://github.com/SevenTV/API/blob/a84e884b5590dbb5d91a5c6b3548afabb228f385/data/model/model.go#L36-L39 + * - ImageFile: https://github.com/SevenTV/API/blob/a84e884b5590dbb5d91a5c6b3548afabb228f385/data/model/model.go#L41-L48 + */ +namespace { + +using namespace chatterino; + +// These declarations won't throw an exception. +const QString CHANNEL_HAS_NO_EMOTES("This channel has no 7TV channel emotes."); +const QString EMOTE_LINK_FORMAT("https://7tv.app/emotes/%1"); + +// TODO(nerix): add links to documentation (7tv.io) +const QString API_URL_USER("https://7tv.io/v3/users/twitch/%1"); +const QString API_URL_GLOBAL_EMOTE_SET("https://7tv.io/v3/emote-sets/global"); + +struct CreateEmoteResult { + Emote emote; + EmoteId id; + EmoteName name; + bool hasImages; +}; + +EmotePtr cachedOrMake(Emote &&emote, const EmoteId &id) +{ + static std::unordered_map> cache; + static std::mutex mutex; + + return cachedOrMakeEmotePtr(std::move(emote), cache, mutex, id); +} + +/** + * This decides whether an emote should be displayed + * as zero-width + */ +bool isZeroWidthActive(const QJsonObject &activeEmote) +{ + auto flags = SeventvActiveEmoteFlags( + SeventvActiveEmoteFlag(activeEmote.value("flags").toInt())); + return flags.has(SeventvActiveEmoteFlag::ZeroWidth); +} + +/** + * This is only an indicator if an emote should be added + * as zero-width or not. The user can still overwrite this. + */ +bool isZeroWidthRecommended(const QJsonObject &emoteData) +{ + auto flags = + SeventvEmoteFlags(SeventvEmoteFlag(emoteData.value("flags").toInt())); + return flags.has(SeventvEmoteFlag::ZeroWidth); +} + +ImageSet makeImageSet(const QJsonObject &emoteData) +{ + auto host = emoteData["host"].toObject(); + // "//cdn.7tv[...]" + auto baseUrl = host["url"].toString(); + auto files = host["files"].toArray(); + + // TODO: emit four images + std::array sizes; + double baseWidth = 0.0; + int nextSize = 0; + + for (auto fileItem : files) + { + if (nextSize >= sizes.size()) + { + break; + } + + auto file = fileItem.toObject(); + if (file["format"].toString() != "WEBP") + { + continue; // We only use webp + } + + double width = file["width"].toDouble(); + double scale = 1.0; // in relation to first image + if (baseWidth > 0.0) + { + scale = baseWidth / width; + } + else + { + // => this is the first image + baseWidth = width; + } + + auto image = Image::fromUrl( + {QString("https:%1/%2").arg(baseUrl, file["name"].toString())}, + scale); + + sizes.at(nextSize) = image; + nextSize++; + } + + if (nextSize < sizes.size()) + { + // this should be really rare + // this means we didn't get all sizes of an emote + if (nextSize == 0) + { + qCDebug(chatterinoSeventv) + << "Got file list without any eligible files"; + // When this emote is typed, chatterino will crash. + return ImageSet{}; + } + for (; nextSize < sizes.size(); nextSize++) + { + sizes.at(nextSize) = Image::getEmpty(); + } + } + + return ImageSet{sizes[0], sizes[1], sizes[2]}; +} + +Tooltip createTooltip(const QString &name, const QString &author, bool isGlobal) +{ + return Tooltip{QString("%1
%2 7TV Emote
By: %3") + .arg(name, isGlobal ? "Global" : "Channel", + author.isEmpty() ? "" : author)}; +} + +Tooltip createAliasedTooltip(const QString &name, const QString &baseName, + const QString &author, bool isGlobal) +{ + return Tooltip{QString("%1
Alias to %2
%3 7TV Emote
By: %4") + .arg(name, baseName, isGlobal ? "Global" : "Channel", + author.isEmpty() ? "" : author)}; +} + +CreateEmoteResult createEmote(const QJsonObject &activeEmote, + const QJsonObject &emoteData, bool isGlobal) +{ + auto emoteId = EmoteId{activeEmote["id"].toString()}; + auto emoteName = EmoteName{activeEmote["name"].toString()}; + auto author = + EmoteAuthor{emoteData["owner"].toObject()["display_name"].toString()}; + auto baseEmoteName = emoteData["name"].toString(); + bool zeroWidth = isZeroWidthActive(activeEmote); + bool aliasedName = emoteName.string != baseEmoteName; + auto tooltip = + aliasedName ? createAliasedTooltip(emoteName.string, baseEmoteName, + author.string, isGlobal) + : createTooltip(emoteName.string, author.string, isGlobal); + auto imageSet = makeImageSet(emoteData); + + auto emote = Emote({emoteName, imageSet, tooltip, + Url{EMOTE_LINK_FORMAT.arg(emoteId.string)}, zeroWidth}); + + return {emote, emoteId, emoteName, !emote.images.getImage1()->isEmpty()}; +} + +bool checkEmoteVisibility(const QJsonObject &emoteData) +{ + if (!emoteData["listed"].toBool() && + !getSettings()->showUnlistedSevenTVEmotes) + { + return false; + } + auto flags = + SeventvEmoteFlags(SeventvEmoteFlag(emoteData["flags"].toInt())); + return !flags.has(SeventvEmoteFlag::ContentTwitchDisallowed); +} + +EmoteMap parseEmotes(const QJsonArray &emoteSetEmotes, bool isGlobal) +{ + auto emotes = EmoteMap(); + + for (const auto &activeEmoteJson : emoteSetEmotes) + { + auto activeEmote = activeEmoteJson.toObject(); + auto emoteData = activeEmote["data"].toObject(); + + if (emoteData.empty() || !checkEmoteVisibility(emoteData)) + { + continue; + } + + auto result = createEmote(activeEmote, emoteData, isGlobal); + if (!result.hasImages) + { + // this shouldn't happen but if it does, it will crash, + // so we don't add the emote + qCDebug(chatterinoSeventv) + << "Emote without images:" << activeEmote; + continue; + } + auto ptr = cachedOrMake(std::move(result.emote), result.id); + emotes[result.name] = ptr; + } + + return emotes; +} + +} // namespace + +namespace chatterino { + +SeventvEmotes::SeventvEmotes() + : global_(std::make_shared()) +{ +} + +std::shared_ptr SeventvEmotes::globalEmotes() const +{ + return this->global_.get(); +} + +boost::optional SeventvEmotes::globalEmote( + const EmoteName &name) const +{ + auto emotes = this->global_.get(); + auto it = emotes->find(name); + + if (it == emotes->end()) + { + return boost::none; + } + return it->second; +} + +void SeventvEmotes::loadGlobalEmotes() +{ + if (!Settings::instance().enableSevenTVGlobalEmotes) + { + this->global_.set(EMPTY_EMOTE_MAP); + return; + } + + qCDebug(chatterinoSeventv) << "Loading 7TV Global Emotes"; + + NetworkRequest(API_URL_GLOBAL_EMOTE_SET, NetworkRequestType::Get) + .timeout(30000) + .onSuccess([this](const NetworkResult &result) -> Outcome { + QJsonArray parsedEmotes = result.parseJson()["emotes"].toArray(); + + auto emoteMap = parseEmotes(parsedEmotes, true); + qCDebug(chatterinoSeventv) + << "Loaded" << emoteMap.size() << "7TV Global Emotes"; + this->global_.set(std::make_shared(std::move(emoteMap))); + + return Success; + }) + .onError([](const NetworkResult &result) { + qCWarning(chatterinoSeventv) + << "Couldn't load 7TV global emotes" << result.getData(); + }) + .execute(); +} + +void SeventvEmotes::loadChannelEmotes(const std::weak_ptr &channel, + const QString &channelId, + std::function callback, + bool manualRefresh) +{ + qCDebug(chatterinoSeventv) + << "Reloading 7TV Channel Emotes" << channelId << manualRefresh; + + NetworkRequest(API_URL_USER.arg(channelId), NetworkRequestType::Get) + .timeout(20000) + .onSuccess([callback = std::move(callback), channel, channelId, + manualRefresh](const NetworkResult &result) -> Outcome { + auto json = result.parseJson(); + auto emoteSet = json["emote_set"].toObject(); + auto parsedEmotes = emoteSet["emotes"].toArray(); + + auto emoteMap = parseEmotes(parsedEmotes, false); + bool hasEmotes = !emoteMap.empty(); + + qCDebug(chatterinoSeventv) + << "Loaded" << emoteMap.size() << "7TV Channel Emotes for" + << channelId << "manual refresh:" << manualRefresh; + + if (hasEmotes) + { + callback(std::move(emoteMap)); + } + + auto shared = channel.lock(); + if (!shared) + { + return Success; + } + + if (manualRefresh) + { + if (hasEmotes) + { + shared->addMessage( + makeSystemMessage("7TV channel emotes reloaded.")); + } + else + { + shared->addMessage( + makeSystemMessage(CHANNEL_HAS_NO_EMOTES)); + } + } + return Success; + }) + .onError( + [channelId, channel, manualRefresh](const NetworkResult &result) { + auto shared = channel.lock(); + if (!shared) + { + return; + } + if (result.status() == 404) + { + qCWarning(chatterinoSeventv) + << "Error occurred fetching 7TV emotes: " + << result.parseJson(); + if (manualRefresh) + { + shared->addMessage( + makeSystemMessage(CHANNEL_HAS_NO_EMOTES)); + } + } + else if (result.status() == NetworkResult::timedoutStatus) + { + // TODO: Auto retry in case of a timeout, with a delay + qCWarning(chatterinoSeventv) + << "Fetching 7TV emotes for channel" << channelId + << "failed due to timeout"; + shared->addMessage(makeSystemMessage( + "Failed to fetch 7TV channel emotes. (timed out)")); + } + else + { + qCWarning(chatterinoSeventv) + << "Error fetching 7TV emotes for channel" << channelId + << ", error" << result.status(); + shared->addMessage( + makeSystemMessage("Failed to fetch 7TV channel " + "emotes. (unknown error)")); + } + }) + .execute(); +} + +} // namespace chatterino diff --git a/src/providers/seventv/SeventvEmotes.hpp b/src/providers/seventv/SeventvEmotes.hpp new file mode 100644 index 000000000..1569eae12 --- /dev/null +++ b/src/providers/seventv/SeventvEmotes.hpp @@ -0,0 +1,73 @@ +#pragma once + +#include "boost/optional.hpp" +#include "common/Aliases.hpp" +#include "common/Atomic.hpp" +#include "providers/twitch/TwitchChannel.hpp" + +#include + +namespace chatterino { + +// https://github.com/SevenTV/API/blob/a84e884b5590dbb5d91a5c6b3548afabb228f385/data/model/emote-set.model.go#L29-L36 +enum class SeventvActiveEmoteFlag : int64_t { + None = 0LL, + + // Emote is zero-width + ZeroWidth = (1LL << 0), + + // Overrides Twitch Global emotes with the same name + OverrideTwitchGlobal = (1 << 16), + // Overrides Twitch Subscriber emotes with the same name + OverrideTwitchSubscriber = (1 << 17), + // Overrides BetterTTV emotes with the same name + OverrideBetterTTV = (1 << 18), +}; + +// https://github.com/SevenTV/API/blob/a84e884b5590dbb5d91a5c6b3548afabb228f385/data/model/emote.model.go#L57-L70 +enum class SeventvEmoteFlag : int64_t { + None = 0LL, + // The emote is private and can only be accessed by its owner, editors and moderators + Private = 1 << 0, + // The emote was verified to be an original creation by the uploader + Authentic = (1LL << 1), + // The emote is recommended to be enabled as Zero-Width + ZeroWidth = (1LL << 8), + + // Content Flags + + // Sexually Suggesive + ContentSexual = (1LL << 16), + // Rapid flashing + ContentEpilepsy = (1LL << 17), + // Edgy or distasteful, may be offensive to some users + ContentEdgy = (1 << 18), + // Not allowed specifically on the Twitch platform + ContentTwitchDisallowed = (1LL << 24), +}; + +using SeventvActiveEmoteFlags = FlagsEnum; +using SeventvEmoteFlags = FlagsEnum; + +struct Emote; +using EmotePtr = std::shared_ptr; +class EmoteMap; + +class SeventvEmotes final +{ +public: + SeventvEmotes(); + + std::shared_ptr globalEmotes() const; + boost::optional globalEmote(const EmoteName &name) const; + void loadGlobalEmotes(); + static void loadChannelEmotes(const std::weak_ptr &channel, + const QString &channelId, + std::function callback, + bool manualRefresh); + +private: + Atomic> global_; +}; + +} // namespace chatterino diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index 317a86978..be98364bd 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -10,6 +10,7 @@ #include "providers/RecentMessagesApi.hpp" #include "providers/bttv/BttvEmotes.hpp" #include "providers/bttv/LoadBttvChannelEmote.hpp" +#include "providers/seventv/SeventvEmotes.hpp" #include "providers/twitch/IrcMessageHandler.hpp" #include "providers/twitch/PubSubManager.hpp" #include "providers/twitch/TwitchCommon.hpp" @@ -83,6 +84,7 @@ TwitchChannel::TwitchChannel(const QString &name) name) , bttvEmotes_(std::make_shared()) , ffzEmotes_(std::make_shared()) + , seventvEmotes_(std::make_shared()) , mod_(false) { qCDebug(chatterinoTwitch) << "[TwitchChannel" << name << "] Opened"; @@ -107,6 +109,7 @@ TwitchChannel::TwitchChannel(const QString &name) this->refreshCheerEmotes(); this->refreshFFZChannelEmotes(false); this->refreshBTTVChannelEmotes(false); + this->refreshSevenTVChannelEmotes(false); }); this->connected.connect([this]() { @@ -243,6 +246,26 @@ void TwitchChannel::refreshFFZChannelEmotes(bool manualRefresh) manualRefresh); } +void TwitchChannel::refreshSevenTVChannelEmotes(bool manualRefresh) +{ + if (!Settings::instance().enableSevenTVChannelEmotes) + { + this->seventvEmotes_.set(EMPTY_EMOTE_MAP); + return; + } + + SeventvEmotes::loadChannelEmotes( + weakOf(this), this->roomId(), + [this, weak = weakOf(this)](auto &&emoteMap) { + if (auto shared = weak.lock()) + { + this->seventvEmotes_.set(std::make_shared( + std::forward(emoteMap))); + } + }, + manualRefresh); +} + void TwitchChannel::addChannelPointReward(const ChannelPointReward &reward) { assertInGuiThread(); @@ -553,6 +576,19 @@ boost::optional TwitchChannel::ffzEmote(const EmoteName &name) const return it->second; } +boost::optional TwitchChannel::seventvEmote( + const EmoteName &name) const +{ + auto emotes = this->seventvEmotes_.get(); + auto it = emotes->find(name); + + if (it == emotes->end()) + { + return boost::none; + } + return it->second; +} + std::shared_ptr TwitchChannel::bttvEmotes() const { return this->bttvEmotes_.get(); @@ -563,6 +599,11 @@ std::shared_ptr TwitchChannel::ffzEmotes() const return this->ffzEmotes_.get(); } +std::shared_ptr TwitchChannel::seventvEmotes() const +{ + return this->seventvEmotes_.get(); +} + const QString &TwitchChannel::subscriptionUrl() { return this->subscriptionUrl_; diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index eb475e3e1..8b5db0e32 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -52,6 +52,7 @@ class EmoteMap; class TwitchBadges; class FfzEmotes; class BttvEmotes; +class SeventvEmotes; class TwitchIrcServer; @@ -109,11 +110,14 @@ public: // Emotes boost::optional bttvEmote(const EmoteName &name) const; boost::optional ffzEmote(const EmoteName &name) const; + boost::optional seventvEmote(const EmoteName &name) const; std::shared_ptr bttvEmotes() const; std::shared_ptr ffzEmotes() const; + std::shared_ptr seventvEmotes() const; virtual void refreshBTTVChannelEmotes(bool manualRefresh); virtual void refreshFFZChannelEmotes(bool manualRefresh); + virtual void refreshSevenTVChannelEmotes(bool manualRefresh); // Badges boost::optional ffzCustomModBadge() const; @@ -196,6 +200,7 @@ private: protected: Atomic> bttvEmotes_; Atomic> ffzEmotes_; + Atomic> seventvEmotes_; Atomic> ffzCustomModBadge_; Atomic> ffzCustomVipBadge_; diff --git a/src/providers/twitch/TwitchIrcServer.cpp b/src/providers/twitch/TwitchIrcServer.cpp index 2526898ca..c10b788af 100644 --- a/src/providers/twitch/TwitchIrcServer.cpp +++ b/src/providers/twitch/TwitchIrcServer.cpp @@ -54,6 +54,7 @@ void TwitchIrcServer::initialize(Settings &settings, Paths &paths) this->reloadBTTVGlobalEmotes(); this->reloadFFZGlobalEmotes(); + this->reloadSevenTVGlobalEmotes(); /* Refresh all twitch channel's live status in bulk every 30 seconds after starting chatterino */ QObject::connect(&this->bulkLiveStatusTimer_, &QTimer::timeout, [=] { @@ -467,6 +468,10 @@ const FfzEmotes &TwitchIrcServer::getFfzEmotes() const { return this->ffz; } +const SeventvEmotes &TwitchIrcServer::getSeventvEmotes() const +{ + return this->seventv_; +} void TwitchIrcServer::reloadBTTVGlobalEmotes() { @@ -497,4 +502,19 @@ void TwitchIrcServer::reloadAllFFZChannelEmotes() } }); } + +void TwitchIrcServer::reloadSevenTVGlobalEmotes() +{ + this->seventv_.loadGlobalEmotes(); +} + +void TwitchIrcServer::reloadAllSevenTVChannelEmotes() +{ + this->forEachChannel([](const auto &chan) { + if (auto *channel = dynamic_cast(chan.get())) + { + channel->refreshSevenTVChannelEmotes(false); + } + }); +} } // namespace chatterino diff --git a/src/providers/twitch/TwitchIrcServer.hpp b/src/providers/twitch/TwitchIrcServer.hpp index 7aa4212a5..dc5667c5a 100644 --- a/src/providers/twitch/TwitchIrcServer.hpp +++ b/src/providers/twitch/TwitchIrcServer.hpp @@ -7,6 +7,7 @@ #include "providers/bttv/BttvEmotes.hpp" #include "providers/ffz/FfzEmotes.hpp" #include "providers/irc/AbstractIrcServer.hpp" +#include "providers/seventv/SeventvEmotes.hpp" #include #include @@ -37,6 +38,8 @@ public: void reloadAllBTTVChannelEmotes(); void reloadFFZGlobalEmotes(); void reloadAllFFZChannelEmotes(); + void reloadSevenTVGlobalEmotes(); + void reloadAllSevenTVChannelEmotes(); Atomic lastUserThatWhisperedMe; @@ -49,6 +52,7 @@ public: const BttvEmotes &getBttvEmotes() const; const FfzEmotes &getFfzEmotes() const; + const SeventvEmotes &getSeventvEmotes() const; protected: virtual void initializeConnection(IrcConnection *connection, @@ -85,6 +89,7 @@ private: BttvEmotes bttv; FfzEmotes ffz; + SeventvEmotes seventv_; QTimer bulkLiveStatusTimer_; pajlada::Signals::SignalHolder signalHolder_; diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index 47d4009e6..7b39698ab 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -7,6 +7,7 @@ #include "messages/Message.hpp" #include "providers/chatterino/ChatterinoBadges.hpp" #include "providers/ffz/FfzBadges.hpp" +#include "providers/seventv/SeventvBadges.hpp" #include "providers/twitch/TwitchBadge.hpp" #include "providers/twitch/TwitchBadges.hpp" #include "providers/twitch/TwitchChannel.hpp" @@ -280,6 +281,7 @@ MessagePtr TwitchMessageBuilder::build() this->appendChatterinoBadges(); this->appendFfzBadges(); + this->appendSeventvBadges(); this->appendUsername(); @@ -1028,6 +1030,7 @@ Outcome TwitchMessageBuilder::tryAppendEmote(const EmoteName &name) const auto &globalBttvEmotes = app->twitch->getBttvEmotes(); const auto &globalFfzEmotes = app->twitch->getFfzEmotes(); + const auto &globalSeventvEmotes = app->twitch->getSeventvEmotes(); auto flags = MessageElementFlags(); auto emote = boost::optional{}; @@ -1035,8 +1038,10 @@ Outcome TwitchMessageBuilder::tryAppendEmote(const EmoteName &name) // Emote order: // - FrankerFaceZ Channel // - BetterTTV Channel + // - 7TV Channel // - FrankerFaceZ Global // - BetterTTV Global + // - 7TV Global if (this->twitchChannel && (emote = this->twitchChannel->ffzEmote(name))) { flags = MessageElementFlag::FfzEmote; @@ -1046,6 +1051,15 @@ Outcome TwitchMessageBuilder::tryAppendEmote(const EmoteName &name) { flags = MessageElementFlag::BttvEmote; } + else if (this->twitchChannel != nullptr && + (emote = this->twitchChannel->seventvEmote(name))) + { + flags = MessageElementFlag::SevenTVEmote; + if (emote.value()->zeroWidth) + { + flags.set(MessageElementFlag::ZeroWidthEmote); + } + } else if ((emote = globalFfzEmotes.emote(name))) { flags = MessageElementFlag::FfzEmote; @@ -1059,6 +1073,14 @@ Outcome TwitchMessageBuilder::tryAppendEmote(const EmoteName &name) flags.set(MessageElementFlag::ZeroWidthEmote); } } + else if ((emote = globalSeventvEmotes.globalEmote(name))) + { + flags = MessageElementFlag::SevenTVEmote; + if (emote.value()->zeroWidth) + { + flags.set(MessageElementFlag::ZeroWidthEmote); + } + } if (emote) { @@ -1217,6 +1239,14 @@ void TwitchMessageBuilder::appendFfzBadges() } } +void TwitchMessageBuilder::appendSeventvBadges() +{ + if (auto badge = getApp()->seventvBadges->getBadge({this->userId_})) + { + this->emplace(*badge, MessageElementFlag::BadgeSevenTV); + } +} + Outcome TwitchMessageBuilder::tryParseCheermote(const QString &string) { if (this->bitsLeft == 0) diff --git a/src/providers/twitch/TwitchMessageBuilder.hpp b/src/providers/twitch/TwitchMessageBuilder.hpp index 68da6a759..58ef17713 100644 --- a/src/providers/twitch/TwitchMessageBuilder.hpp +++ b/src/providers/twitch/TwitchMessageBuilder.hpp @@ -99,6 +99,7 @@ private: void appendTwitchBadges(); void appendChatterinoBadges(); void appendFfzBadges(); + void appendSeventvBadges(); Outcome tryParseCheermote(const QString &string); bool shouldAddModerationElements() const; diff --git a/src/singletons/Emotes.hpp b/src/singletons/Emotes.hpp index be7fdc480..51faac660 100644 --- a/src/singletons/Emotes.hpp +++ b/src/singletons/Emotes.hpp @@ -5,6 +5,7 @@ #include "providers/bttv/BttvEmotes.hpp" #include "providers/emoji/Emojis.hpp" #include "providers/ffz/FfzEmotes.hpp" +#include "providers/seventv/SeventvEmotes.hpp" #include "providers/twitch/TwitchEmotes.hpp" #include "singletons/helper/GifTimer.hpp" diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 44a8d2367..7eaa6b543 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -164,6 +164,7 @@ public: "/appearance/badges/useCustomFfzModeratorBadges", true}; BoolSetting useCustomFfzVipBadges = { "/appearance/badges/useCustomFfzVipBadges", true}; + BoolSetting showBadgesSevenTV = {"/appearance/badges/seventv", true}; /// Behaviour BoolSetting allowDuplicateMessages = {"/behaviour/allowDuplicateMessages", @@ -209,6 +210,8 @@ public: BoolSetting enableEmoteImages = {"/emotes/enableEmoteImages", true}; BoolSetting animateEmotes = {"/emotes/enableGifAnimations", true}; FloatSetting emoteScale = {"/emotes/scale", 1.f}; + BoolSetting showUnlistedSevenTVEmotes = { + "/emotes/showUnlistedSevenTVEmotes", false}; QStringSetting emojiSet = {"/emotes/emojiSet", "Twitter"}; @@ -220,6 +223,8 @@ public: BoolSetting enableBTTVChannelEmotes = {"/emotes/bttv/channel", true}; BoolSetting enableFFZGlobalEmotes = {"/emotes/ffz/global", true}; BoolSetting enableFFZChannelEmotes = {"/emotes/ffz/channel", true}; + BoolSetting enableSevenTVGlobalEmotes = {"/emotes/seventv/global", true}; + BoolSetting enableSevenTVChannelEmotes = {"/emotes/seventv/channel", true}; /// Links BoolSetting linksDoubleClickOnly = {"/links/doubleClickToOpen", false}; diff --git a/src/singletons/WindowManager.cpp b/src/singletons/WindowManager.cpp index 64d6cf7f4..4d4b61ae3 100644 --- a/src/singletons/WindowManager.cpp +++ b/src/singletons/WindowManager.cpp @@ -110,6 +110,7 @@ WindowManager::WindowManager() this->wordFlagsListener_.addSetting(settings->showBadgesVanity); this->wordFlagsListener_.addSetting(settings->showBadgesChatterino); this->wordFlagsListener_.addSetting(settings->showBadgesFfz); + this->wordFlagsListener_.addSetting(settings->showBadgesSevenTV); this->wordFlagsListener_.addSetting(settings->enableEmoteImages); this->wordFlagsListener_.addSetting(settings->boldUsernames); this->wordFlagsListener_.addSetting(settings->lowercaseDomains); @@ -179,6 +180,7 @@ void WindowManager::updateWordTypeMask() flags.set(settings->showBadgesChatterino ? MEF::BadgeChatterino : MEF::None); flags.set(settings->showBadgesFfz ? MEF::BadgeFfz : MEF::None); + flags.set(settings->showBadgesSevenTV ? MEF::BadgeSevenTV : MEF::None); // username flags.set(MEF::Username); diff --git a/src/widgets/dialogs/EmotePopup.cpp b/src/widgets/dialogs/EmotePopup.cpp index 317607480..fb08ca627 100644 --- a/src/widgets/dialogs/EmotePopup.cpp +++ b/src/widgets/dialogs/EmotePopup.cpp @@ -359,6 +359,12 @@ void EmotePopup::loadChannel(ChannelPtr channel) addEmotes(*globalChannel, *getApp()->twitch->getFfzEmotes().emotes(), "FrankerFaceZ", MessageElementFlag::FfzEmote); } + if (Settings::instance().enableSevenTVGlobalEmotes) + { + addEmotes(*globalChannel, + *getApp()->twitch->getSeventvEmotes().globalEmotes(), "7TV", + MessageElementFlag::SevenTVEmote); + } // channel if (Settings::instance().enableBTTVChannelEmotes) @@ -371,6 +377,11 @@ void EmotePopup::loadChannel(ChannelPtr channel) addEmotes(*channelChannel, *this->twitchChannel_->ffzEmotes(), "FrankerFaceZ", MessageElementFlag::FfzEmote); } + if (Settings::instance().enableSevenTVChannelEmotes) + { + addEmotes(*channelChannel, *this->twitchChannel_->seventvEmotes(), + "7TV", MessageElementFlag::SevenTVEmote); + } this->globalEmotesView_->setChannel(globalChannel); this->subEmotesView_->setChannel(subChannel); @@ -429,6 +440,8 @@ void EmotePopup::filterTwitchEmotes(std::shared_ptr searchChannel, searchText, getApp()->twitch->getBttvEmotes().emotes()); auto ffzGlobalEmotes = this->filterEmoteMap( searchText, getApp()->twitch->getFfzEmotes().emotes()); + auto *seventvGlobalEmotes = this->filterEmoteMap( + searchText, getApp()->twitch->getSeventvEmotes().globalEmotes()); // twitch addEmoteSets(twitchGlobalEmotes, *searchChannel, *searchChannel, @@ -451,6 +464,9 @@ void EmotePopup::filterTwitchEmotes(std::shared_ptr searchChannel, this->filterEmoteMap(searchText, this->twitchChannel_->bttvEmotes()); auto ffzChannelEmotes = this->filterEmoteMap(searchText, this->twitchChannel_->ffzEmotes()); + auto *seventvChannelEmotes = + this->filterEmoteMap(searchText, this->twitchChannel_->seventvEmotes()); + // channel if (bttvChannelEmotes->size() > 0) addEmotes(*searchChannel, *bttvChannelEmotes, "BetterTTV (Channel)", @@ -458,6 +474,11 @@ void EmotePopup::filterTwitchEmotes(std::shared_ptr searchChannel, if (ffzChannelEmotes->size() > 0) addEmotes(*searchChannel, *ffzChannelEmotes, "FrankerFaceZ (Channel)", MessageElementFlag::FfzEmote); + if (!seventvChannelEmotes->empty()) + { + addEmotes(*searchChannel, *seventvChannelEmotes, "SevenTV (Channel)", + MessageElementFlag::SevenTVEmote); + } } void EmotePopup::filterEmotes(const QString &searchText) diff --git a/src/widgets/helper/ChannelView.cpp b/src/widgets/helper/ChannelView.cpp index 6042c18d6..94579bcc9 100644 --- a/src/widgets/helper/ChannelView.cpp +++ b/src/widgets/helper/ChannelView.cpp @@ -120,6 +120,10 @@ namespace { { addPageLink("FFZ"); } + else if (creatorFlags.has(MessageElementFlag::SevenTVEmote)) + { + addPageLink("7TV"); + } } // Current function: https://www.desmos.com/calculator/vdyamchjwh diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index e209c0f80..f8dbaccf2 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -5,6 +5,8 @@ #include "common/Version.hpp" #include "controllers/hotkeys/HotkeyCategory.hpp" #include "controllers/hotkeys/HotkeyController.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchIrcServer.hpp" #include "singletons/Fonts.hpp" #include "singletons/NativeMessaging.hpp" #include "singletons/Paths.hpp" @@ -340,6 +342,22 @@ void GeneralPage::initLayout(GeneralPageView &layout) layout.addCheckbox("Remove spaces between emotes", s.removeSpacesBetweenEmotes); + layout.addCheckbox("Show unlisted 7TV emotes", s.showUnlistedSevenTVEmotes); + s.showUnlistedSevenTVEmotes.connect( + []() { + getApp()->twitch->forEachChannelAndSpecialChannels( + [](const auto &c) { + if (c->isTwitchChannel()) + { + auto *channel = dynamic_cast(c.get()); + if (channel != nullptr) + { + channel->refreshSevenTVChannelEmotes(false); + } + } + }); + }, + false); layout.addDropdown( "Show info on hover", {"Don't show", "Always show", "Hold shift"}, s.emotesTooltipPreview, @@ -362,6 +380,8 @@ void GeneralPage::initLayout(GeneralPageView &layout) layout.addCheckbox("Show BTTV channel emotes", s.enableBTTVChannelEmotes); layout.addCheckbox("Show FFZ global emotes", s.enableFFZGlobalEmotes); layout.addCheckbox("Show FFZ channel emotes", s.enableFFZChannelEmotes); + layout.addCheckbox("Show 7TV global emotes", s.enableSevenTVGlobalEmotes); + layout.addCheckbox("Show 7TV channel emotes", s.enableSevenTVChannelEmotes); layout.addTitle("Streamer Mode"); layout.addDescription( @@ -631,6 +651,7 @@ void GeneralPage::initLayout(GeneralPageView &layout) layout.addCheckbox("Chatterino", s.showBadgesChatterino); layout.addCheckbox("FrankerFaceZ (Bot, FFZ Supporter, FFZ Developer)", s.showBadgesFfz); + layout.addCheckbox("7TV", s.showBadgesSevenTV); layout.addSeperator(); layout.addCheckbox("Use custom FrankerFaceZ moderator badges", s.useCustomFfzModeratorBadges); diff --git a/src/widgets/splits/InputCompletionPopup.cpp b/src/widgets/splits/InputCompletionPopup.cpp index d20b9f88d..b53b75780 100644 --- a/src/widgets/splits/InputCompletionPopup.cpp +++ b/src/widgets/splits/InputCompletionPopup.cpp @@ -5,6 +5,7 @@ #include "messages/Emote.hpp" #include "providers/bttv/BttvEmotes.hpp" #include "providers/ffz/FfzEmotes.hpp" +#include "providers/seventv/SeventvEmotes.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchIrcServer.hpp" #include "singletons/Emotes.hpp" @@ -99,17 +100,25 @@ void InputCompletionPopup::updateEmotes(const QString &text, ChannelPtr channel) if (tc) { - // TODO extract "Channel BetterTTV" text into a #define. + // TODO extract "Channel {BetterTTV,7TV,FrankerFaceZ}" text into a #define. if (auto bttv = tc->bttvEmotes()) addEmotes(emotes, *bttv, text, "Channel BetterTTV"); if (auto ffz = tc->ffzEmotes()) addEmotes(emotes, *ffz, text, "Channel FrankerFaceZ"); + if (auto seventv = tc->seventvEmotes()) + { + addEmotes(emotes, *seventv, text, "Channel 7TV"); + } } if (auto bttvG = getApp()->twitch->getBttvEmotes().emotes()) addEmotes(emotes, *bttvG, text, "Global BetterTTV"); if (auto ffzG = getApp()->twitch->getFfzEmotes().emotes()) addEmotes(emotes, *ffzG, text, "Global FrankerFaceZ"); + if (auto seventvG = getApp()->twitch->getSeventvEmotes().globalEmotes()) + { + addEmotes(emotes, *seventvG, text, "Global 7TV"); + } } addEmojis(emotes, getApp()->emotes->emojis.emojis, text); diff --git a/src/widgets/splits/Split.cpp b/src/widgets/splits/Split.cpp index c7c0d7579..889561914 100644 --- a/src/widgets/splits/Split.cpp +++ b/src/widgets/splits/Split.cpp @@ -1198,6 +1198,7 @@ void Split::reloadChannelAndSubscriberEmotes() { twitchChannel->refreshBTTVChannelEmotes(true); twitchChannel->refreshFFZChannelEmotes(true); + twitchChannel->refreshSevenTVChannelEmotes(true); } } diff --git a/src/widgets/splits/SplitHeader.cpp b/src/widgets/splits/SplitHeader.cpp index a1ff94483..fe95aca5a 100644 --- a/src/widgets/splits/SplitHeader.cpp +++ b/src/widgets/splits/SplitHeader.cpp @@ -999,6 +999,7 @@ void SplitHeader::reloadChannelEmotes() { twitchChannel->refreshFFZChannelEmotes(true); twitchChannel->refreshBTTVChannelEmotes(true); + twitchChannel->refreshSevenTVChannelEmotes(true); } } From 34b5fa661fb68581236b3a677295b68f5723c95e Mon Sep 17 00:00:00 2001 From: nerix Date: Sun, 16 Oct 2022 14:29:28 +0200 Subject: [PATCH 063/946] fix: missing global emotes in popup (#4062) --- CHANGELOG.md | 2 +- src/widgets/dialogs/EmotePopup.cpp | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5499ebfe5..7b17741ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ - Major: Added support for Twitch's Chat Replies. [Wiki Page](https://wiki.chatterino.com/Features/#message-replies) (#3722, #3989, #4041, #4047, #4055) - Major: Added multi-channel searching to search dialog via keyboard shortcut. (Ctrl+Shift+F by default) (#3694, #3875) -- Major: Added support for emotes and badges from [7TV](https://7tv.app). [Wiki Page](https://wiki.chatterino.com/Third_party_services/#7tv) (#4002) +- Major: Added support for emotes and badges from [7TV](https://7tv.app). [Wiki Page](https://wiki.chatterino.com/Third_party_services/#7tv) (#4002, #4062) - Minor: Added highlights for `Elevated Messages`. (#4016) - Minor: Removed total views from the usercard, as Twitch no longer updates the number. (#3792) - Minor: Load missing messages from Recent Messages API upon reconnecting (#3878, #3932) diff --git a/src/widgets/dialogs/EmotePopup.cpp b/src/widgets/dialogs/EmotePopup.cpp index fb08ca627..456b8c005 100644 --- a/src/widgets/dialogs/EmotePopup.cpp +++ b/src/widgets/dialogs/EmotePopup.cpp @@ -454,6 +454,11 @@ void EmotePopup::filterTwitchEmotes(std::shared_ptr searchChannel, if (ffzGlobalEmotes->size() > 0) addEmotes(*searchChannel, *ffzGlobalEmotes, "FrankerFaceZ (Global)", MessageElementFlag::FfzEmote); + if (!seventvGlobalEmotes->empty()) + { + addEmotes(*searchChannel, *seventvGlobalEmotes, "SevenTV (Global)", + MessageElementFlag::SevenTVEmote); + } if (!this->twitchChannel_) { From b232d16b5583a3dee6b36cba88db379d896823d4 Mon Sep 17 00:00:00 2001 From: Kasia Date: Sun, 16 Oct 2022 16:25:24 +0200 Subject: [PATCH 064/946] Prevent copying in a couple places (#4066) --- src/controllers/filters/parser/FilterParser.cpp | 2 +- src/widgets/helper/ResizingTextEdit.cpp | 5 +++-- src/widgets/settingspages/ModerationPage.cpp | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/controllers/filters/parser/FilterParser.cpp b/src/controllers/filters/parser/FilterParser.cpp index 6b74900c1..e198aaeb9 100644 --- a/src/controllers/filters/parser/FilterParser.cpp +++ b/src/controllers/filters/parser/FilterParser.cpp @@ -54,7 +54,7 @@ ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel) bool subscribed = false; int subLength = 0; - for (const QString &subBadge : {"subscriber", "founder"}) + for (const auto &subBadge : {"subscriber", "founder"}) { if (!badges.contains(subBadge)) { diff --git a/src/widgets/helper/ResizingTextEdit.cpp b/src/widgets/helper/ResizingTextEdit.cpp index 63444f5bf..2d967999e 100644 --- a/src/widgets/helper/ResizingTextEdit.cpp +++ b/src/widgets/helper/ResizingTextEdit.cpp @@ -286,11 +286,12 @@ void ResizingTextEdit::insertFromMimeData(const QMimeData *source) this->imagePasted.invoke(source); return; } - else if (source->hasUrls()) + + if (source->hasUrls()) { bool hasUploadable = false; auto mimeDb = QMimeDatabase(); - for (const QUrl url : source->urls()) + for (const QUrl &url : source->urls()) { QMimeType mime = mimeDb.mimeTypeForUrl(url); if (mime.name().startsWith("image")) diff --git a/src/widgets/settingspages/ModerationPage.cpp b/src/widgets/settingspages/ModerationPage.cpp index f21d9ce06..cb4b3e629 100644 --- a/src/widgets/settingspages/ModerationPage.cpp +++ b/src/widgets/settingspages/ModerationPage.cpp @@ -239,7 +239,7 @@ void ModerationPage::addModerationButtonSettings( // build one line for each customizable button auto i = 0; - for (const auto tButton : getSettings()->timeoutButtons.getValue()) + for (const auto &tButton : getSettings()->timeoutButtons.getValue()) { const auto buttonNumber = QString::number(i); auto timeout = timeoutLayout.emplace().withoutMargin(); From 62b689e7469d82e643ba2634166d92ffd028e5a3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 16 Oct 2022 15:55:14 +0000 Subject: [PATCH 065/946] Bump ilammy/msvc-dev-cmd from 1.11.0 to 1.12.0 (#4063) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: pajlada --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 3b7135008..5f01b5b26 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -78,7 +78,7 @@ jobs: - name: Enable Developer Command Prompt if: startsWith(matrix.os, 'windows') - uses: ilammy/msvc-dev-cmd@v1.11.0 + uses: ilammy/msvc-dev-cmd@v1.12.0 - name: Build (Windows) if: startsWith(matrix.os, 'windows') From dd6cb80ab945a4f0a40da9da8de83eea2de1ce08 Mon Sep 17 00:00:00 2001 From: Felanbird <41973452+Felanbird@users.noreply.github.com> Date: Tue, 18 Oct 2022 14:26:12 -0400 Subject: [PATCH 066/946] Add searching & filtering for bits (#4069) --- CHANGELOG.md | 2 ++ src/controllers/filters/parser/FilterParser.cpp | 2 ++ src/controllers/filters/parser/Tokenizer.hpp | 1 + src/messages/Message.hpp | 1 + src/messages/search/MessageFlagsPredicate.cpp | 4 ++++ src/providers/twitch/TwitchMessageBuilder.cpp | 5 +++++ 6 files changed, 15 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b17741ec..b1c47f1b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - Minor: Clicking `A message from x was deleted` messages will now jump to the message in question. (#3953) - Minor: Added `is:first-msg` search option. (#3700) - Minor: Added `is:elevated-msg` search option. (#4018) +- Minor: Added `is:cheer-msg` search option. (#4069) - Minor: Added `subtier:` search option (e.g. `subtier:3` to find Tier 3 subs). (#4013) - Minor: Added `badge:` search option (e.g. `badge:mod` to users with the moderator badge). (#4013) - Minor: Added AutoMod message flag filter. (#3938) @@ -129,6 +130,7 @@ - Minor: Strip leading @ and trailing , from username in `/popout` command. (#3217) - Minor: Added `flags.reward_message` filter variable (#3231) - Minor: Added `flags.elevated_message` filter variable. (#4017) +- Minor: Added `flags.cheer_message` filter variable. (#4069) - Minor: Added chatter count to viewer list popout (#3261) - Minor: Ignore out of bounds check for tiling wms (#3270) - Minor: Add clear cache button to cache settings section (#3277) diff --git a/src/controllers/filters/parser/FilterParser.cpp b/src/controllers/filters/parser/FilterParser.cpp index e198aaeb9..9e71a365b 100644 --- a/src/controllers/filters/parser/FilterParser.cpp +++ b/src/controllers/filters/parser/FilterParser.cpp @@ -30,6 +30,7 @@ ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel) * flags.reward_message * flags.first_message * flags.elevated_message + * flags.cheer_message * flags.whisper * flags.reply * flags.automod @@ -85,6 +86,7 @@ ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel) 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)}, diff --git a/src/controllers/filters/parser/Tokenizer.hpp b/src/controllers/filters/parser/Tokenizer.hpp index d0297545f..59f4b9cef 100644 --- a/src/controllers/filters/parser/Tokenizer.hpp +++ b/src/controllers/filters/parser/Tokenizer.hpp @@ -25,6 +25,7 @@ static const QMap validIdentifiersMap = { {"flags.reward_message", "channel point reward message?"}, {"flags.first_message", "first message?"}, {"flags.elevated_message", "elevated message?"}, + {"flags.cheer_message", "cheer message?"}, {"flags.whisper", "whisper message?"}, {"flags.reply", "reply message?"}, {"flags.automod", "automod message?"}, diff --git a/src/messages/Message.hpp b/src/messages/Message.hpp index 0468627a9..8e64c663a 100644 --- a/src/messages/Message.hpp +++ b/src/messages/Message.hpp @@ -44,6 +44,7 @@ enum class MessageFlag : int64_t { ReplyMessage = (1LL << 24), ElevatedMessage = (1LL << 25), ParticipatedThread = (1LL << 26), + CheerMessage = (1LL << 27), }; using MessageFlags = FlagsEnum; diff --git a/src/messages/search/MessageFlagsPredicate.cpp b/src/messages/search/MessageFlagsPredicate.cpp index 9f0d29d1b..3221da236 100644 --- a/src/messages/search/MessageFlagsPredicate.cpp +++ b/src/messages/search/MessageFlagsPredicate.cpp @@ -38,6 +38,10 @@ MessageFlagsPredicate::MessageFlagsPredicate(const QString &flags) { this->flags_.set(MessageFlag::ElevatedMessage); } + else if (flag == "cheer-msg") + { + this->flags_.set(MessageFlag::CheerMessage); + } } } diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index 7b39698ab..f284ba989 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -204,6 +204,11 @@ MessagePtr TwitchMessageBuilder::build() this->message().flags.set(MessageFlag::ElevatedMessage); } + if (this->tags.contains("bits")) + { + this->message().flags.set(MessageFlag::CheerMessage); + } + // reply threads if (this->thread_) { From 457c5725da277e353a724e4bfbf09ab3388c8d3f Mon Sep 17 00:00:00 2001 From: nerix Date: Sat, 22 Oct 2022 11:42:46 +0200 Subject: [PATCH 067/946] fix: Invalid/Dangling completion after updating input (#4072) --- CHANGELOG.md | 1 + src/widgets/helper/ResizingTextEdit.cpp | 5 +++++ src/widgets/helper/ResizingTextEdit.hpp | 23 +++++++++++++++++++++++ src/widgets/splits/SplitInput.cpp | 4 ++++ 4 files changed, 33 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1c47f1b3..92f0108f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -96,6 +96,7 @@ - Bugfix: Fixed trailing spaces from preventing Nicknames from working correctly. (#3946) - Bugfix: Fixed trailing spaces from preventing User Highlights from working correctly. (#4051) - Bugfix: Fixed channel-based popups from rewriting messages to file log (#4060) +- Bugfix: Fixed invalid/dangling completion when cycling through previous messages or replying (#4072) - Dev: Removed official support for QMake. (#3839, #3883) - Dev: Rewrote LimitedQueue (#3798) - Dev: Overhauled highlight system by moving all checks into a Controller allowing for easier tests. (#3399, #3801, #3835) diff --git a/src/widgets/helper/ResizingTextEdit.cpp b/src/widgets/helper/ResizingTextEdit.cpp index 2d967999e..c51458de2 100644 --- a/src/widgets/helper/ResizingTextEdit.cpp +++ b/src/widgets/helper/ResizingTextEdit.cpp @@ -246,6 +246,11 @@ void ResizingTextEdit::setCompleter(QCompleter *c) this, &ResizingTextEdit::insertCompletion); } +void ResizingTextEdit::resetCompletion() +{ + this->completionInProgress_ = false; +} + void ResizingTextEdit::insertCompletion(const QString &completion) { if (this->completer_->widget() != this) diff --git a/src/widgets/helper/ResizingTextEdit.hpp b/src/widgets/helper/ResizingTextEdit.hpp index 301c00a84..4b371e9a1 100644 --- a/src/widgets/helper/ResizingTextEdit.hpp +++ b/src/widgets/helper/ResizingTextEdit.hpp @@ -24,6 +24,11 @@ public: void setCompleter(QCompleter *c); QCompleter *getCompleter() const; + /** + * Resets a completion for this text if one was is progress. + * See `completionInProgress_`. + */ + void resetCompletion(); protected: int heightForWidth(int) const override; @@ -41,6 +46,24 @@ private: QString textUnderCursor(bool *hadSpace = nullptr) const; QCompleter *completer_ = nullptr; + /** + * This is true if a completion was done but the user didn't type yet, + * and might want to press `Tab` again to get the next completion + * on the original text. + * + * For example: + * + * input: "pog" + * `Tab` pressed: + * - complete to "PogBones" + * - retain "pog" for next completion + * - set `completionInProgress_ = true` + * `Tab` pressed again: + * - complete ["pog"] to "PogChamp" + * + * [other key] pressed - updating the input text: + * - set `completionInProgress_ = false` + */ bool completionInProgress_ = false; bool eventFilter(QObject *obj, QEvent *event) override; diff --git a/src/widgets/splits/SplitInput.cpp b/src/widgets/splits/SplitInput.cpp index b45146466..ccedbb86b 100644 --- a/src/widgets/splits/SplitInput.cpp +++ b/src/widgets/splits/SplitInput.cpp @@ -464,6 +464,7 @@ void SplitInput::addShortcuts() this->prevIndex_--; this->ui_.textEdit->setPlainText( this->prevMsg_.at(this->prevIndex_)); + this->ui_.textEdit->resetCompletion(); QTextCursor cursor = this->ui_.textEdit->textCursor(); cursor.movePosition(QTextCursor::End); @@ -487,6 +488,7 @@ void SplitInput::addShortcuts() this->prevIndex_++; this->ui_.textEdit->setPlainText( this->prevMsg_.at(this->prevIndex_)); + this->ui_.textEdit->resetCompletion(); } else { @@ -496,6 +498,7 @@ void SplitInput::addShortcuts() // If user has just come from a message history // Then simply get currMsg_. this->ui_.textEdit->setPlainText(this->currMsg_); + this->ui_.textEdit->resetCompletion(); } else if (message != this->currMsg_) { @@ -987,6 +990,7 @@ void SplitInput::setReply(std::shared_ptr reply, } this->ui_.textEdit->setPlainText(replyPrefix + plainText + " "); this->ui_.textEdit->moveCursor(QTextCursor::EndOfBlock); + this->ui_.textEdit->resetCompletion(); } this->ui_.replyLabel->setText("Replying to @" + this->replyThread_->root()->displayName); From 570746a8bd25371bcbaf22c5cd8566c1f37b8f38 Mon Sep 17 00:00:00 2001 From: 8thony <114905842+8thony@users.noreply.github.com> Date: Sat, 22 Oct 2022 12:04:51 +0200 Subject: [PATCH 068/946] Hide inline whispers in streamer mode (#4076) Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 1 + src/controllers/commands/CommandController.cpp | 5 ++++- src/providers/twitch/IrcMessageHandler.cpp | 5 ++++- src/singletons/Settings.hpp | 2 ++ src/widgets/settingspages/GeneralPage.cpp | 2 ++ 5 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 92f0108f4..229e845f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ - Minor: Added quotation marks in the permitted/blocked Automod messages for clarity. (#3654) - Minor: Added Quick Switcher item to open a channel in a new popup window. (#3828) - Minor: Added information about the user's operating system in the About page. (#3663) +- Minor: Added option to hide inline whispers in streamer mode (#4076) - Minor: Adjusted large stream thumbnail to 16:9 (#3655) - Minor: Prevented user from entering incorrect characters in Live Notifications channels list. (#3715, #3730) - Minor: Sorted usernames in /vips message to be case-insensitive. (#3696) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index a8ffba1a1..8a403810b 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -26,6 +26,7 @@ #include "util/IncognitoBrowser.hpp" #include "util/Qt.hpp" #include "util/StreamLink.hpp" +#include "util/StreamerMode.hpp" #include "util/Twitch.hpp" #include "widgets/Window.hpp" #include "widgets/dialogs/ReplyThreadPopup.hpp" @@ -171,7 +172,9 @@ bool appendWhisperMessageWordsLocally(const QStringList &words) auto overrideFlags = boost::optional(messagexD->flags); overrideFlags->set(MessageFlag::DoNotLog); - if (getSettings()->inlineWhispers) + if (getSettings()->inlineWhispers && + !(getSettings()->streamerModeSuppressInlineWhispers && + isInStreamerMode())) { app->twitch->forEachChannel( [&messagexD, overrideFlags](ChannelPtr _channel) { diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp index af39b3f47..e539cc2d6 100644 --- a/src/providers/twitch/IrcMessageHandler.cpp +++ b/src/providers/twitch/IrcMessageHandler.cpp @@ -15,6 +15,7 @@ #include "util/FormatTime.hpp" #include "util/Helpers.hpp" #include "util/IrcHelpers.hpp" +#include "util/StreamerMode.hpp" #include @@ -800,7 +801,9 @@ void IrcMessageHandler::handleWhisperMessage(Communi::IrcMessage *message) overrideFlags->set(MessageFlag::DoNotTriggerNotification); overrideFlags->set(MessageFlag::DoNotLog); - if (getSettings()->inlineWhispers) + if (getSettings()->inlineWhispers && + !(getSettings()->streamerModeSuppressInlineWhispers && + isInStreamerMode())) { getApp()->twitch->forEachChannel( [&_message, overrideFlags](ChannelPtr channel) { diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 7eaa6b543..2970cc8fa 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -246,6 +246,8 @@ public: BoolSetting streamerModeMuteMentions = {"/streamerMode/muteMentions", true}; BoolSetting streamerModeSuppressLiveNotifications = { "/streamerMode/supressLiveNotifications", false}; + BoolSetting streamerModeSuppressInlineWhispers = { + "/streamerMode/suppressInlineWhispers", true}; /// Ignored Phrases QStringSetting ignoredPhraseReplace = {"/ignore/ignoredPhraseReplace", diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index f8dbaccf2..e3e121925 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -413,6 +413,8 @@ void GeneralPage::initLayout(GeneralPageView &layout) layout.addCheckbox("Mute mention sounds", s.streamerModeMuteMentions); layout.addCheckbox("Suppress Live Notifications", s.streamerModeSuppressLiveNotifications); + layout.addCheckbox("Suppress Inline Whispers", + s.streamerModeSuppressInlineWhispers); layout.addTitle("Link Previews"); layout.addDescription( From 76530d061c0e10c4cad13afcf0d6064892cf09c0 Mon Sep 17 00:00:00 2001 From: xel86 Date: Sat, 22 Oct 2022 06:46:20 -0400 Subject: [PATCH 069/946] Make reply thread subtext easier to click (#4067) Co-authored-by: Daniel Sage <24928223+dnsge@users.noreply.github.com> Co-authored-by: pajlada --- CHANGELOG.md | 2 +- src/messages/MessageElement.cpp | 92 +++++++++++++++++++-------------- 2 files changed, 55 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 229e845f8..7f0b719dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unversioned -- Major: Added support for Twitch's Chat Replies. [Wiki Page](https://wiki.chatterino.com/Features/#message-replies) (#3722, #3989, #4041, #4047, #4055) +- Major: Added support for Twitch's Chat Replies. [Wiki Page](https://wiki.chatterino.com/Features/#message-replies) (#3722, #3989, #4041, #4047, #4055, #4067) - Major: Added multi-channel searching to search dialog via keyboard shortcut. (Ctrl+Shift+F by default) (#3694, #3875) - Major: Added support for emotes and badges from [7TV](https://7tv.app). [Wiki Page](https://wiki.chatterino.com/Third_party_services/#7tv) (#4002, #4062) - Minor: Added highlights for `Elevated Messages`. (#4016) diff --git a/src/messages/MessageElement.cpp b/src/messages/MessageElement.cpp index a266e5855..057c51474 100644 --- a/src/messages/MessageElement.cpp +++ b/src/messages/MessageElement.cpp @@ -468,11 +468,10 @@ void SingleLineTextElement::addToContainer(MessageLayoutContainer &container, }; static const auto ellipsis = QStringLiteral("..."); - auto addEllipsis = [&]() { - int ellipsisSize = metrics.horizontalAdvance(ellipsis); - container.addElementNoLineBreak( - getTextLayoutElement(ellipsis, ellipsisSize, false)); - }; + + // String to continuously append words onto until we place it in the container + // once we encounter an emote or reach the end of the message text. */ + QString currentText; for (Word &word : this->words_) { @@ -483,38 +482,15 @@ void SingleLineTextElement::addToContainer(MessageLayoutContainer &container, } auto &parsedWord = parsedWords[0]; - if (parsedWord.type() == typeid(EmotePtr)) + if (parsedWord.type() == typeid(QString)) { - auto emote = boost::get(parsedWord); - auto image = - emote->images.getImageOrLoaded(container.getScale()); - if (!image->isEmpty()) - { - auto emoteScale = getSettings()->emoteScale.getValue(); - - auto size = QSize(image->width(), image->height()) * - (emoteScale * container.getScale()); - - if (!container.fitsInLine(size.width())) - { - addEllipsis(); - break; - } - - container.addElementNoLineBreak( - (new ImageLayoutElement(*this, image, size)) - ->setLink(this->getLink())); - } - } - else if (parsedWord.type() == typeid(QString)) - { - word.width = metrics.horizontalAdvance(word.text); + int nextWidth = + metrics.horizontalAdvance(currentText + word.text); // see if the text fits in the current line - if (container.fitsInLine(word.width)) + if (container.fitsInLine(nextWidth)) { - container.addElementNoLineBreak(getTextLayoutElement( - word.text, word.width, this->hasTrailingSpace())); + currentText += (word.text + " "); } else { @@ -526,12 +502,12 @@ void SingleLineTextElement::addToContainer(MessageLayoutContainer &container, // Try removing characters one by one until the word fits. QString truncatedWord = word.text.chopped(cut) + ellipsis; - int newSize = metrics.horizontalAdvance(truncatedWord); + int newSize = metrics.horizontalAdvance(currentText + + truncatedWord); if (container.fitsInLine(newSize)) { - container.addElementNoLineBreak( - getTextLayoutElement(truncatedWord, newSize, - false)); + currentText += (truncatedWord); + cutSuccess = true; break; } @@ -541,12 +517,52 @@ void SingleLineTextElement::addToContainer(MessageLayoutContainer &container, { // We weren't able to show any part of the current word, so // just append the ellipsis. - addEllipsis(); + currentText += ellipsis; } break; } } + else if (parsedWord.type() == typeid(EmotePtr)) + { + auto emote = boost::get(parsedWord); + auto image = + emote->images.getImageOrLoaded(container.getScale()); + if (!image->isEmpty()) + { + auto emoteScale = getSettings()->emoteScale.getValue(); + + int currentWidth = metrics.horizontalAdvance(currentText); + auto emoteSize = QSize(image->width(), image->height()) * + (emoteScale * container.getScale()); + + if (!container.fitsInLine(currentWidth + emoteSize.width())) + { + currentText += ellipsis; + break; + } + + // Add currently pending text to container, then add the emote after. + container.addElementNoLineBreak( + getTextLayoutElement(currentText, currentWidth, false)); + currentText.clear(); + + container.addElementNoLineBreak( + (new ImageLayoutElement(*this, image, emoteSize)) + ->setLink(this->getLink())); + } + } + } + + // Add the last of the pending message text to the container. + if (!currentText.isEmpty()) + { + // Remove trailing space. + currentText = currentText.trimmed(); + + int width = metrics.horizontalAdvance(currentText); + container.addElementNoLineBreak( + getTextLayoutElement(currentText, width, false)); } container.breakLine(); From f7fcc90fe0f7858df1e053480e73f22787babb1b Mon Sep 17 00:00:00 2001 From: Kasia Date: Sat, 22 Oct 2022 13:36:18 +0200 Subject: [PATCH 070/946] Migrated `getSubage` to v2 version of the API (#4070) --- src/providers/IvrApi.cpp | 4 ++-- src/providers/IvrApi.hpp | 12 ++++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/providers/IvrApi.cpp b/src/providers/IvrApi.cpp index dff138ef9..6b19dc928 100644 --- a/src/providers/IvrApi.cpp +++ b/src/providers/IvrApi.cpp @@ -40,7 +40,7 @@ void IvrApi::getBulkEmoteSets(QString emoteSetList, QUrlQuery urlQuery; urlQuery.addQueryItem("set_id", emoteSetList); - this->makeRequest("v2/twitch/emotes/sets", urlQuery) + this->makeRequest("twitch/emotes/sets", urlQuery) .onSuccess([successCallback, failureCallback](auto result) -> Outcome { auto root = result.parseJsonArray(); @@ -61,7 +61,7 @@ NetworkRequest IvrApi::makeRequest(QString url, QUrlQuery urlQuery) { assert(!url.startsWith("/")); - const QString baseUrl("https://api.ivr.fi/"); + const QString baseUrl("https://api.ivr.fi/v2/"); QUrl fullUrl(baseUrl + url); fullUrl.setQuery(urlQuery); diff --git a/src/providers/IvrApi.hpp b/src/providers/IvrApi.hpp index 7008b240f..74d21e116 100644 --- a/src/providers/IvrApi.hpp +++ b/src/providers/IvrApi.hpp @@ -21,9 +21,9 @@ struct IvrSubage { const int totalSubMonths; const QString followingSince; - IvrSubage(QJsonObject root) - : isSubHidden(root.value("hidden").toBool()) - , isSubbed(root.value("subscribed").toBool()) + IvrSubage(const QJsonObject &root) + : isSubHidden(root.value("statusHidden").toBool()) + , isSubbed(!root.value("meta").isNull()) , subTier(root.value("meta").toObject().value("tier").toString()) , totalSubMonths( root.value("cumulative").toObject().value("months").toInt()) @@ -40,7 +40,7 @@ struct IvrEmoteSet { const QString tier; const QJsonArray emotes; - IvrEmoteSet(QJsonObject root) + IvrEmoteSet(const QJsonObject &root) : setId(root.value("setID").toString()) , displayName(root.value("channelName").toString()) , login(root.value("channelLogin").toString()) @@ -60,7 +60,7 @@ struct IvrEmote { const QString emoteType; const QString imageType; - explicit IvrEmote(QJsonObject root) + explicit IvrEmote(const QJsonObject &root) : code(root.value("code").toString()) , id(root.value("id").toString()) , setId(root.value("setID").toString()) @@ -76,7 +76,7 @@ struct IvrEmote { class IvrApi final : boost::noncopyable { public: - // https://api.ivr.fi/docs#tag/Twitch/paths/~1twitch~1subage~1{username}~1{channel}/get + // https://api.ivr.fi/v2/docs/static/index.html#/Twitch/get_twitch_subage__user___channel_ void getSubage(QString userName, QString channelName, ResultCallback resultCallback, IvrFailureCallback failureCallback); From 53ec66ff8e5d2117bd390132a63d993f18b34481 Mon Sep 17 00:00:00 2001 From: pajlada Date: Sat, 22 Oct 2022 17:22:34 +0200 Subject: [PATCH 071/946] Fix .desktop icon path (#4078) --- CHANGELOG.md | 1 + resources/com.chatterino.chatterino.desktop | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f0b719dc..8886b4ee8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -98,6 +98,7 @@ - Bugfix: Fixed trailing spaces from preventing User Highlights from working correctly. (#4051) - Bugfix: Fixed channel-based popups from rewriting messages to file log (#4060) - Bugfix: Fixed invalid/dangling completion when cycling through previous messages or replying (#4072) +- Bugfix: Fixed incorrect .desktop icon path. (#4078) - Dev: Removed official support for QMake. (#3839, #3883) - Dev: Rewrote LimitedQueue (#3798) - Dev: Overhauled highlight system by moving all checks into a Controller allowing for easier tests. (#3399, #3801, #3835) diff --git a/resources/com.chatterino.chatterino.desktop b/resources/com.chatterino.chatterino.desktop index 6ee453543..ece81f5c8 100644 --- a/resources/com.chatterino.chatterino.desktop +++ b/resources/com.chatterino.chatterino.desktop @@ -4,7 +4,7 @@ Version=1.0 Name=Chatterino Comment=Chat client for Twitch Exec=chatterino -Icon=chatterino +Icon=com.chatterino.chatterino Terminal=false Categories=Network;InstantMessaging; StartupWMClass=chatterino From e6e9b98f665825866c0fc719c85d24a0a89b9108 Mon Sep 17 00:00:00 2001 From: pajlada Date: Sun, 23 Oct 2022 10:31:38 +0200 Subject: [PATCH 072/946] Remove unused values from MessageLayoutContainer (#4081) --- .clang-tidy | 2 ++ src/messages/layouts/MessageLayoutContainer.hpp | 6 ++---- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.clang-tidy b/.clang-tidy index 4c3f4adb5..7ec4b791f 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -38,6 +38,8 @@ CheckOptions: value: camelBack - key: readability-identifier-naming.MemberCase value: camelBack + - key: readability-identifier-naming.PrivateMemberIgnoredRegexp + value: .* - key: readability-identifier-naming.PrivateMemberSuffix value: _ - key: readability-identifier-naming.UnionCase diff --git a/src/messages/layouts/MessageLayoutContainer.hpp b/src/messages/layouts/MessageLayoutContainer.hpp index b70ab9ec1..c990058a6 100644 --- a/src/messages/layouts/MessageLayoutContainer.hpp +++ b/src/messages/layouts/MessageLayoutContainer.hpp @@ -45,10 +45,6 @@ struct Margin { struct MessageLayoutContainer { MessageLayoutContainer() = default; - Margin margin = {4, 8, 4, 8}; - bool centered = false; - bool enableCompactEmotes = false; - int getHeight() const; int getWidth() const; float getScale() const; @@ -93,6 +89,8 @@ private: void _addElement(MessageLayoutElement *element, bool forceAdd = false); bool canCollapse(); + const Margin margin = {4, 8, 4, 8}; + // variables float scale_ = 1.f; int width_ = 0; From df2244191304a353957134e71453d54cdf5267f7 Mon Sep 17 00:00:00 2001 From: Wissididom <30803034+Wissididom@users.noreply.github.com> Date: Fri, 28 Oct 2022 12:17:51 +0200 Subject: [PATCH 073/946] Copied and adjusted BUILDING_ON_LINUX.md from SevenTV (#4085) Co-authored-by: pajlada --- BUILDING_ON_LINUX.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/BUILDING_ON_LINUX.md b/BUILDING_ON_LINUX.md index 5ee3f4d48..4a450fa93 100644 --- a/BUILDING_ON_LINUX.md +++ b/BUILDING_ON_LINUX.md @@ -8,11 +8,11 @@ Note on Qt version compatibility: If you are installing Qt from a package manage _Most likely works the same for other Debian-like distros_ -Install all of the dependencies using `sudo apt install qttools5-dev qtmultimedia5-dev libqt5svg5-dev libboost-dev libssl-dev libboost-system-dev libboost-filesystem-dev cmake g++` +Install all of the dependencies using `sudo apt install qttools5-dev qtmultimedia5-dev qt5-image-formats-plugins libqt5svg5-dev libboost-dev libssl-dev libboost-system-dev libboost-filesystem-dev cmake g++` ### Arch Linux -Install all of the dependencies using `sudo pacman -S --needed qt5-base qt5-multimedia qt5-svg qt5-tools gst-plugins-ugly gst-plugins-good boost rapidjson pkgconf openssl cmake` +Install all of the dependencies using `sudo pacman -S --needed qt5-base qt5-multimedia qt5-imageformats qt5-svg qt5-tools gst-plugins-ugly gst-plugins-good boost rapidjson pkgconf openssl cmake` Alternatively you can use the [chatterino2-git](https://aur.archlinux.org/packages/chatterino2-git/) package to build and install Chatterino for you. From b27d6334f3cc28a5fdf23b7ce2b5e9b8411684c2 Mon Sep 17 00:00:00 2001 From: Felanbird <41973452+Felanbird@users.noreply.github.com> Date: Fri, 28 Oct 2022 19:01:21 -0400 Subject: [PATCH 074/946] Update IRC whisper error (#4086) --- src/providers/twitch/IrcMessageHandler.cpp | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp index e539cc2d6..7b1246bb3 100644 --- a/src/providers/twitch/IrcMessageHandler.cpp +++ b/src/providers/twitch/IrcMessageHandler.cpp @@ -1003,10 +1003,9 @@ std::vector IrcMessageHandler::parseNoticeMessage( getSettings()->helixTimegateWhisper.getValue() == HelixTimegateOverride::Timegate) { - content = - content + - " Consider setting the \"Helix timegate /w " - "behaviour\" to \"Always use Helix\" in your Chatterino settings."; + content = content + + " Consider setting \"Helix timegate /w behaviour\" " + "to \"Always use Helix\" in your Chatterino settings."; } builtMessages.emplace_back( makeSystemMessage(content, calculateMessageTime(message).time())); From dd39bd66a00f14905aed1e3bb7adc87d2a5a4de7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 29 Oct 2022 11:47:27 +0200 Subject: [PATCH 075/946] Bump ZedThree/clang-tidy-review from 0.10.0 to 0.10.1 (#4083) Bumps [ZedThree/clang-tidy-review](https://github.com/ZedThree/clang-tidy-review) from 0.10.0 to 0.10.1. - [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.10.0...v0.10.1) --- updated-dependencies: - dependency-name: ZedThree/clang-tidy-review dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- .github/workflows/post-clang-tidy-review.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5f01b5b26..f05466b07 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -148,7 +148,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.10.0 + uses: ZedThree/clang-tidy-review@v0.10.1 id: review with: build_dir: build diff --git a/.github/workflows/post-clang-tidy-review.yml b/.github/workflows/post-clang-tidy-review.yml index 6332e11bd..a5f63b84a 100644 --- a/.github/workflows/post-clang-tidy-review.yml +++ b/.github/workflows/post-clang-tidy-review.yml @@ -35,5 +35,5 @@ jobs: - name: 'Unzip artifact' run: unzip clang-tidy-review.zip - - uses: ZedThree/clang-tidy-review/post@v0.10.0 + - uses: ZedThree/clang-tidy-review/post@v0.10.1 id: review From ff684fc7ed45c70c063ba8a448030859020965d9 Mon Sep 17 00:00:00 2001 From: nerix Date: Sat, 29 Oct 2022 14:01:01 +0200 Subject: [PATCH 076/946] feat: Basic PubSub Manager for Live Emote Updates (#4087) Co-authored-by: Rasmus Karlsson --- .clang-tidy | 2 + .github/workflows/test.yml | 2 +- src/CMakeLists.txt | 4 + src/common/QLogging.cpp | 2 + src/common/QLogging.hpp | 1 + .../liveupdates/BasicPubSubClient.hpp | 177 +++++++++ .../liveupdates/BasicPubSubManager.hpp | 370 ++++++++++++++++++ .../liveupdates/BasicPubSubWebsocket.hpp | 36 ++ tests/CMakeLists.txt | 1 + tests/src/BasicPubSub.cpp | 148 +++++++ 10 files changed, 742 insertions(+), 1 deletion(-) create mode 100644 src/providers/liveupdates/BasicPubSubClient.hpp create mode 100644 src/providers/liveupdates/BasicPubSubManager.hpp create mode 100644 src/providers/liveupdates/BasicPubSubWebsocket.hpp create mode 100644 tests/src/BasicPubSub.cpp diff --git a/.clang-tidy b/.clang-tidy index 7ec4b791f..b9cd999da 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -42,6 +42,8 @@ CheckOptions: value: .* - key: readability-identifier-naming.PrivateMemberSuffix value: _ + - key: readability-identifier-naming.ProtectedMemberSuffix + value: _ - key: readability-identifier-naming.UnionCase value: CamelCase - key: readability-identifier-naming.GlobalVariableCase diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7da3a0f7b..f0e388640 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,7 +6,7 @@ on: workflow_dispatch: env: - TWITCH_PUBSUB_SERVER_IMAGE: ghcr.io/chatterino/twitch-pubsub-server-test:v1.0.3 + TWITCH_PUBSUB_SERVER_IMAGE: ghcr.io/chatterino/twitch-pubsub-server-test:v1.0.4 concurrency: group: test-${{ github.ref }} diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index ea627e44f..8c5450d93 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -214,6 +214,10 @@ set(SOURCE_FILES providers/irc/IrcServer.cpp providers/irc/IrcServer.hpp + providers/liveupdates/BasicPubSubClient.hpp + providers/liveupdates/BasicPubSubManager.hpp + providers/liveupdates/BasicPubSubWebsocket.hpp + providers/seventv/SeventvBadges.cpp providers/seventv/SeventvBadges.hpp providers/seventv/SeventvEmotes.cpp diff --git a/src/common/QLogging.cpp b/src/common/QLogging.cpp index ede6a45bc..525f69b6c 100644 --- a/src/common/QLogging.cpp +++ b/src/common/QLogging.cpp @@ -22,6 +22,8 @@ Q_LOGGING_CATEGORY(chatterinoHTTP, "chatterino.http", logThreshold); Q_LOGGING_CATEGORY(chatterinoImage, "chatterino.image", logThreshold); Q_LOGGING_CATEGORY(chatterinoIrc, "chatterino.irc", logThreshold); Q_LOGGING_CATEGORY(chatterinoIvr, "chatterino.ivr", logThreshold); +Q_LOGGING_CATEGORY(chatterinoLiveupdates, "chatterino.liveupdates", + 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 4f27d0ea9..0739fbaec 100644 --- a/src/common/QLogging.hpp +++ b/src/common/QLogging.hpp @@ -18,6 +18,7 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoHTTP); Q_DECLARE_LOGGING_CATEGORY(chatterinoImage); Q_DECLARE_LOGGING_CATEGORY(chatterinoIrc); Q_DECLARE_LOGGING_CATEGORY(chatterinoIvr); +Q_DECLARE_LOGGING_CATEGORY(chatterinoLiveupdates); Q_DECLARE_LOGGING_CATEGORY(chatterinoMain); Q_DECLARE_LOGGING_CATEGORY(chatterinoMessage); Q_DECLARE_LOGGING_CATEGORY(chatterinoNativeMessage); diff --git a/src/providers/liveupdates/BasicPubSubClient.hpp b/src/providers/liveupdates/BasicPubSubClient.hpp new file mode 100644 index 000000000..d23ca6444 --- /dev/null +++ b/src/providers/liveupdates/BasicPubSubClient.hpp @@ -0,0 +1,177 @@ +#pragma once + +#include +#include +#include +#include + +#include "common/QLogging.hpp" +#include "providers/liveupdates/BasicPubSubWebsocket.hpp" +#include "singletons/Settings.hpp" +#include "util/DebugCount.hpp" +#include "util/Helpers.hpp" + +namespace chatterino { + +/** + * This class manages a single connection + * that has at most #maxSubscriptions subscriptions. + * + * You can safely overload the #onConnectionEstablished method + * and e.g. add additional heartbeat logic. + * + * You can use shared_from_this to get a shared_ptr of this client. + * + * @tparam Subscription see BasicPubSubManager + */ +template +class BasicPubSubClient + : public std::enable_shared_from_this> +{ +public: + // The maximum amount of subscriptions this connections can handle + const size_t maxSubscriptions; + + BasicPubSubClient(liveupdates::WebsocketClient &websocketClient, + liveupdates::WebsocketHandle handle, + size_t maxSubscriptions = 100) + : maxSubscriptions(maxSubscriptions) + , websocketClient_(websocketClient) + , handle_(std::move(handle)) + { + } + + virtual ~BasicPubSubClient() = default; + + BasicPubSubClient(const BasicPubSubClient &) = delete; + BasicPubSubClient(const BasicPubSubClient &&) = delete; + BasicPubSubClient &operator=(const BasicPubSubClient &) = delete; + BasicPubSubClient &operator=(const BasicPubSubClient &&) = delete; + +protected: + virtual void onConnectionEstablished() + { + } + + bool send(const char *payload) + { + liveupdates::WebsocketErrorCode ec; + this->websocketClient_.send(this->handle_, payload, + websocketpp::frame::opcode::text, ec); + + if (ec) + { + qCDebug(chatterinoLiveupdates) << "Error sending message" << payload + << ":" << ec.message().c_str(); + return false; + } + + return true; + } + + /** + * @return true if this client subscribed to this subscription + * and the current subscriptions don't exceed the maximum + * amount. + * It won't subscribe twice to the same subscription. + * Don't use this in place of subscription management + * in the BasicPubSubManager. + */ + bool subscribe(const Subscription &subscription) + { + if (this->subscriptions_.size() >= this->maxSubscriptions) + { + return false; + } + + if (!this->subscriptions_.emplace(subscription).second) + { + qCWarning(chatterinoLiveupdates) + << "Tried subscribing to" << subscription + << "but we're already subscribed!"; + return true; // true because the subscription already exists + } + + qCDebug(chatterinoLiveupdates) << "Subscribing to" << subscription; + DebugCount::increase("LiveUpdates subscriptions"); + + QByteArray encoded = subscription.encodeSubscribe(); + this->send(encoded); + + return true; + } + + /** + * @return true if this client previously subscribed + * and now unsubscribed from this subscription. + */ + bool unsubscribe(const Subscription &subscription) + { + if (this->subscriptions_.erase(subscription) <= 0) + { + return false; + } + + qCDebug(chatterinoLiveupdates) << "Unsubscribing from" << subscription; + DebugCount::decrease("LiveUpdates subscriptions"); + + QByteArray encoded = subscription.encodeUnsubscribe(); + this->send(encoded); + + return true; + } + + bool isStarted() const + { + return this->started_.load(std::memory_order_acquire); + } + + liveupdates::WebsocketClient &websocketClient_; + +private: + void start() + { + assert(!this->isStarted()); + this->started_.store(true, std::memory_order_release); + this->onConnectionEstablished(); + } + + void stop() + { + assert(this->isStarted()); + this->started_.store(false, std::memory_order_release); + } + + void close(const std::string &reason, + websocketpp::close::status::value code = + websocketpp::close::status::normal) + { + liveupdates::WebsocketErrorCode ec; + + auto conn = this->websocketClient_.get_con_from_hdl(this->handle_, ec); + if (ec) + { + qCDebug(chatterinoLiveupdates) + << "Error getting connection:" << ec.message().c_str(); + return; + } + + conn->close(code, reason, ec); + if (ec) + { + qCDebug(chatterinoLiveupdates) + << "Error closing:" << ec.message().c_str(); + return; + } + } + + liveupdates::WebsocketHandle handle_; + std::unordered_set subscriptions_; + + std::atomic started_{false}; + + template + friend class BasicPubSubManager; +}; + +} // namespace chatterino diff --git a/src/providers/liveupdates/BasicPubSubManager.hpp b/src/providers/liveupdates/BasicPubSubManager.hpp new file mode 100644 index 000000000..ced55070d --- /dev/null +++ b/src/providers/liveupdates/BasicPubSubManager.hpp @@ -0,0 +1,370 @@ +#pragma once + +#include "common/QLogging.hpp" +#include "providers/liveupdates/BasicPubSubClient.hpp" +#include "providers/liveupdates/BasicPubSubWebsocket.hpp" +#include "providers/twitch/PubSubHelpers.hpp" +#include "util/DebugCount.hpp" +#include "util/ExponentialBackoff.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace chatterino { + +/** + * This class is the basis for connecting and interacting with + * simple PubSub servers over the Websocket protocol. + * It acts as a pool for connections (see BasicPubSubClient). + * + * You can customize the clients, by creating your custom + * client in ::createClient. + * + * You **must** implement #onMessage. The method gets called for every + * received message on every connection. + * If you want to get the connection this message was received on, + * use #findClient. + * + * You must expose your own subscribe and unsubscribe methods + * (e.g. [un-]subscribeTopic). + * This manager does not keep track of the subscriptions. + * + * @tparam Subscription + * The subscription has the following requirements: + * It must have the methods QByteArray encodeSubscribe(), + * and QByteArray encodeUnsubscribe(). + * It must have an overload for + * QDebug &operator<< (see tests/src/BasicPubSub.cpp), + * a specialization for std::hash, + * and and overload for operator== and operator!=. + * + * @see BasicPubSubClient + */ +template +class BasicPubSubManager +{ +public: + BasicPubSubManager(QString host) + : host_(std::move(host)) + { + this->websocketClient_.set_access_channels( + websocketpp::log::alevel::all); + this->websocketClient_.clear_access_channels( + websocketpp::log::alevel::frame_payload | + websocketpp::log::alevel::frame_header); + + this->websocketClient_.init_asio(); + + // SSL Handshake + this->websocketClient_.set_tls_init_handler([this](auto hdl) { + return this->onTLSInit(hdl); + }); + + this->websocketClient_.set_message_handler([this](auto hdl, auto msg) { + this->onMessage(hdl, msg); + }); + this->websocketClient_.set_open_handler([this](auto hdl) { + this->onConnectionOpen(hdl); + }); + this->websocketClient_.set_close_handler([this](auto hdl) { + this->onConnectionClose(hdl); + }); + this->websocketClient_.set_fail_handler([this](auto hdl) { + this->onConnectionFail(hdl); + }); + } + + virtual ~BasicPubSubManager() = default; + + BasicPubSubManager(const BasicPubSubManager &) = delete; + BasicPubSubManager(const BasicPubSubManager &&) = delete; + BasicPubSubManager &operator=(const BasicPubSubManager &) = delete; + BasicPubSubManager &operator=(const BasicPubSubManager &&) = delete; + + /** This is only used for testing. */ + struct { + std::atomic connectionsClosed{0}; + std::atomic connectionsOpened{0}; + std::atomic connectionsFailed{0}; + } diag; + + void start() + { + this->work_ = std::make_shared( + this->websocketClient_.get_io_service()); + this->mainThread_.reset(new std::thread([this] { + runThread(); + })); + } + + void stop() + { + this->stopping_ = true; + + for (const auto &client : this->clients_) + { + client.second->close("Shutting down"); + } + + this->work_.reset(); + + if (this->mainThread_->joinable()) + { + this->mainThread_->join(); + } + + assert(this->clients_.empty()); + } + +protected: + using WebsocketMessagePtr = + websocketpp::config::asio_tls_client::message_type::ptr; + using WebsocketContextPtr = + websocketpp::lib::shared_ptr; + + virtual void onMessage(websocketpp::connection_hdl hdl, + WebsocketMessagePtr msg) = 0; + + virtual std::shared_ptr> createClient( + liveupdates::WebsocketClient &client, websocketpp::connection_hdl hdl) + { + return std::make_shared>(client, hdl); + } + + /** + * @param hdl The handle of the client. + * @return The client managing this connection, empty shared_ptr otherwise. + */ + std::shared_ptr> findClient( + websocketpp::connection_hdl hdl) + { + auto clientIt = this->clients_.find(hdl); + + if (clientIt == this->clients_.end()) + { + return {}; + } + + return clientIt->second; + } + + void unsubscribe(const Subscription &subscription) + { + for (auto &client : this->clients_) + { + if (client.second->unsubscribe(subscription)) + { + return; + } + } + } + + void subscribe(const Subscription &subscription) + { + if (this->trySubscribe(subscription)) + { + return; + } + + this->addClient(); + this->pendingSubscriptions_.emplace_back(subscription); + DebugCount::increase("LiveUpdates subscription backlog"); + } + +private: + void onConnectionOpen(websocketpp::connection_hdl hdl) + { + DebugCount::increase("LiveUpdates connections"); + this->addingClient_ = false; + this->diag.connectionsOpened.fetch_add(1, std::memory_order_acq_rel); + + this->connectBackoff_.reset(); + + auto client = this->createClient(this->websocketClient_, hdl); + + // We separate the starting from the constructor because we will want to use + // shared_from_this + client->start(); + + this->clients_.emplace(hdl, client); + + auto pendingSubsToTake = (std::min)(this->pendingSubscriptions_.size(), + client->maxSubscriptions); + + qCDebug(chatterinoLiveupdates) + << "LiveUpdate connection opened, subscribing to" + << pendingSubsToTake << "subscriptions!"; + + while (pendingSubsToTake > 0 && !this->pendingSubscriptions_.empty()) + { + const auto last = std::move(this->pendingSubscriptions_.back()); + this->pendingSubscriptions_.pop_back(); + if (!client->subscribe(last)) + { + qCDebug(chatterinoLiveupdates) + << "Failed to subscribe to" << last << "on new client."; + // TODO: should we try to add a new client here? + return; + } + DebugCount::decrease("LiveUpdates subscription backlog"); + pendingSubsToTake--; + } + + if (!this->pendingSubscriptions_.empty()) + { + this->addClient(); + } + } + + void onConnectionFail(websocketpp::connection_hdl hdl) + { + DebugCount::increase("LiveUpdates failed connections"); + this->diag.connectionsFailed.fetch_add(1, std::memory_order_acq_rel); + + if (auto conn = this->websocketClient_.get_con_from_hdl(std::move(hdl))) + { + qCDebug(chatterinoLiveupdates) + << "LiveUpdates connection attempt failed (error: " + << conn->get_ec().message().c_str() << ")"; + } + else + { + qCDebug(chatterinoLiveupdates) + << "LiveUpdates connection attempt failed but we can't get the " + "connection from a handle."; + } + this->addingClient_ = false; + if (!this->pendingSubscriptions_.empty()) + { + runAfter(this->websocketClient_.get_io_service(), + this->connectBackoff_.next(), [this](auto /*timer*/) { + this->addClient(); + }); + } + } + + void onConnectionClose(websocketpp::connection_hdl hdl) + { + qCDebug(chatterinoLiveupdates) << "Connection closed"; + DebugCount::decrease("LiveUpdates connections"); + this->diag.connectionsClosed.fetch_add(1, std::memory_order_acq_rel); + + auto clientIt = this->clients_.find(hdl); + + // If this assert goes off, there's something wrong with the connection + // creation/preserving code KKona + assert(clientIt != this->clients_.end()); + + auto client = clientIt->second; + + this->clients_.erase(clientIt); + + client->stop(); + + if (!this->stopping_) + { + for (const auto &sub : client->subscriptions_) + { + this->subscribe(sub); + } + } + } + + WebsocketContextPtr onTLSInit(const websocketpp::connection_hdl & /*hdl*/) + { + WebsocketContextPtr ctx( + new boost::asio::ssl::context(boost::asio::ssl::context::tlsv12)); + + try + { + ctx->set_options(boost::asio::ssl::context::default_workarounds | + boost::asio::ssl::context::no_sslv2 | + boost::asio::ssl::context::single_dh_use); + } + catch (const std::exception &e) + { + qCDebug(chatterinoLiveupdates) + << "Exception caught in OnTLSInit:" << e.what(); + } + + return ctx; + } + + void runThread() + { + qCDebug(chatterinoLiveupdates) << "Start LiveUpdates manager thread"; + this->websocketClient_.run(); + qCDebug(chatterinoLiveupdates) + << "Done with LiveUpdates manager thread"; + } + + void addClient() + { + if (this->addingClient_) + { + return; + } + + qCDebug(chatterinoLiveupdates) << "Adding an additional client"; + + this->addingClient_ = true; + + websocketpp::lib::error_code ec; + auto con = this->websocketClient_.get_connection( + this->host_.toStdString(), ec); + + if (ec) + { + qCDebug(chatterinoLiveupdates) + << "Unable to establish connection:" << ec.message().c_str(); + return; + } + + this->websocketClient_.connect(con); + } + + bool trySubscribe(const Subscription &subscription) + { + for (auto &client : this->clients_) + { + if (client.second->subscribe(subscription)) + { + return true; + } + } + return false; + } + + std::map>, + std::owner_less> + clients_; + + std::vector pendingSubscriptions_; + std::atomic addingClient_{false}; + ExponentialBackoff<5> connectBackoff_{std::chrono::milliseconds(1000)}; + + std::shared_ptr work_{nullptr}; + + liveupdates::WebsocketClient websocketClient_; + std::unique_ptr mainThread_; + + const QString host_; + + bool stopping_{false}; +}; + +} // namespace chatterino diff --git a/src/providers/liveupdates/BasicPubSubWebsocket.hpp b/src/providers/liveupdates/BasicPubSubWebsocket.hpp new file mode 100644 index 000000000..bf30c30ca --- /dev/null +++ b/src/providers/liveupdates/BasicPubSubWebsocket.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include "providers/twitch/ChatterinoWebSocketppLogger.hpp" + +#include +#include +#include +#include + +namespace chatterino { + +struct BasicPubSubConfig : public websocketpp::config::asio_tls_client { + // NOLINTBEGIN(modernize-use-using) + typedef websocketpp::log::chatterinowebsocketpplogger< + concurrency_type, websocketpp::log::elevel> + elog_type; + typedef websocketpp::log::chatterinowebsocketpplogger< + concurrency_type, websocketpp::log::alevel> + alog_type; + + struct PerMessageDeflateConfig { + }; + + typedef websocketpp::extensions::permessage_deflate::disabled< + PerMessageDeflateConfig> + permessage_deflate_type; + // NOLINTEND(modernize-use-using) +}; + +namespace liveupdates { + using WebsocketClient = websocketpp::client; + using WebsocketHandle = websocketpp::connection_hdl; + using WebsocketErrorCode = websocketpp::lib::error_code; +} // namespace liveupdates + +} // namespace chatterino diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 827fe67c2..e71dfd3ee 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -20,6 +20,7 @@ set(test_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/HighlightController.cpp ${CMAKE_CURRENT_LIST_DIR}/src/FormatTime.cpp ${CMAKE_CURRENT_LIST_DIR}/src/LimitedQueue.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/BasicPubSub.cpp # Add your new file above this line! ) diff --git a/tests/src/BasicPubSub.cpp b/tests/src/BasicPubSub.cpp new file mode 100644 index 000000000..d4fc0143b --- /dev/null +++ b/tests/src/BasicPubSub.cpp @@ -0,0 +1,148 @@ +#include "providers/liveupdates/BasicPubSubClient.hpp" +#include "providers/liveupdates/BasicPubSubManager.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include + +using namespace chatterino; +using namespace std::chrono_literals; + +struct DummySubscription { + int type; + QString condition; + + bool operator==(const DummySubscription &rhs) const + { + return std::tie(this->condition, this->type) == + std::tie(rhs.condition, rhs.type); + } + bool operator!=(const DummySubscription &rhs) const + { + return !(rhs == *this); + } + + QByteArray encodeSubscribe() const + { + QJsonObject root; + root["op"] = "sub"; + root["type"] = this->type; + root["condition"] = this->condition; + return QJsonDocument(root).toJson(); + } + QByteArray encodeUnsubscribe() const + { + QJsonObject root; + root["op"] = "unsub"; + root["type"] = this->type; + root["condition"] = this->condition; + return QJsonDocument(root).toJson(); + } + + friend QDebug &operator<<(QDebug &dbg, + const DummySubscription &subscription) + { + dbg << "DummySubscription{ condition:" << subscription.condition + << "type:" << (int)subscription.type << '}'; + return dbg; + } +}; + +namespace std { +template <> +struct hash { + size_t operator()(const DummySubscription &sub) const + { + return (size_t)qHash(sub.condition, qHash(sub.type)); + } +}; +} // namespace std + +class MyManager : public BasicPubSubManager +{ +public: + MyManager(QString host) + : BasicPubSubManager(std::move(host)) + { + } + + std::atomic messagesReceived{0}; + + std::optional popMessage() + { + std::lock_guard guard(this->messageMtx_); + if (this->messageQueue_.empty()) + { + return std::nullopt; + } + QString front = this->messageQueue_.front(); + this->messageQueue_.pop_front(); + return front; + } + + void sub(const DummySubscription &sub) + { + // We don't track subscriptions in this test + this->subscribe(sub); + } + + void unsub(const DummySubscription &sub) + { + this->unsubscribe(sub); + } + +protected: + void onMessage( + websocketpp::connection_hdl /*hdl*/, + BasicPubSubManager::WebsocketMessagePtr msg) override + { + std::lock_guard guard(this->messageMtx_); + this->messagesReceived.fetch_add(1, std::memory_order_acq_rel); + this->messageQueue_.emplace_back( + QString::fromStdString(msg->get_payload())); + } + +private: + std::mutex messageMtx_; + std::deque messageQueue_; +}; + +TEST(BasicPubSub, SubscriptionCycle) +{ + const QString host("wss://127.0.0.1:9050/liveupdates/sub-unsub"); + auto *manager = new MyManager(host); + manager->start(); + + std::this_thread::sleep_for(50ms); + manager->sub({1, "foo"}); + std::this_thread::sleep_for(500ms); + + ASSERT_EQ(manager->diag.connectionsOpened, 1); + ASSERT_EQ(manager->diag.connectionsClosed, 0); + ASSERT_EQ(manager->diag.connectionsFailed, 0); + ASSERT_EQ(manager->messagesReceived, 1); + + ASSERT_EQ(manager->popMessage(), QString("ack-sub-1-foo")); + + manager->unsub({1, "foo"}); + std::this_thread::sleep_for(50ms); + + ASSERT_EQ(manager->diag.connectionsOpened, 1); + ASSERT_EQ(manager->diag.connectionsClosed, 0); + ASSERT_EQ(manager->diag.connectionsFailed, 0); + ASSERT_EQ(manager->messagesReceived, 2); + ASSERT_EQ(manager->popMessage(), QString("ack-unsub-1-foo")); + + manager->stop(); + + ASSERT_EQ(manager->diag.connectionsOpened, 1); + ASSERT_EQ(manager->diag.connectionsClosed, 1); + ASSERT_EQ(manager->diag.connectionsFailed, 0); + ASSERT_EQ(manager->messagesReceived, 2); +} From fa93d633835766ccf3fdd063e43392a6ac4664fc Mon Sep 17 00:00:00 2001 From: Adam Davies <8650006+acdvs@users.noreply.github.com> Date: Sun, 30 Oct 2022 07:06:38 -0500 Subject: [PATCH 077/946] Add settings tooltips (#3437) Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 1 + src/widgets/settingspages/GeneralPage.cpp | 69 ++++++++++++------- src/widgets/settingspages/GeneralPageView.cpp | 68 +++++++++++++++--- src/widgets/settingspages/GeneralPageView.hpp | 18 +++-- 4 files changed, 116 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8886b4ee8..56daa4294 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -68,6 +68,7 @@ - Minor: Migrated /uniquechat and /r9kbeta to Helix API. (#4057) - Minor: Migrated /uniquechatoff and /r9kbetaoff to Helix API. (#4057) - Minor: Make menus and placeholders display appropriate custom key combos. (#4045) +- Minor: Add settings tooltips. (#3437) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fixed `Smooth scrolling on new messages` setting sometimes hiding messages. (#4028) - Bugfix: Fixed a crash that can occur when closing and quickly reopening a split, then running a command. (#3852) diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index e3e121925..3a3c6afe7 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -191,9 +191,11 @@ void GeneralPage::initLayout(GeneralPageView &layout) layout.addCheckbox("Show message reply button", s.showReplyButton); layout.addCheckbox("Show tab close button", s.showTabCloseButton); - layout.addCheckbox("Always on top", s.windowTopMost); + layout.addCheckbox("Always on top", s.windowTopMost, false, + "Always keep Chatterino as the top window."); #ifdef USEWINSDK - layout.addCheckbox("Start with Windows", s.autorun); + layout.addCheckbox("Start with Windows", s.autorun, false, + "Start Chatterino when your computer starts."); #endif if (!BaseWindow::supportsCustomWindowFrame()) { @@ -253,17 +255,19 @@ void GeneralPage::initLayout(GeneralPageView &layout) layout.addCheckbox("Smooth scrolling", s.enableSmoothScrolling); layout.addCheckbox("Smooth scrolling on new messages", s.enableSmoothScrollingNewMessages); - layout.addCheckbox("Show input when it's empty", s.showEmptyInput); + layout.addCheckbox("Show input when it's empty", s.showEmptyInput, false, + "Show the chat box even when there is nothing typed."); layout.addCheckbox("Show message length while typing", s.showMessageLength); - layout.addCheckbox("Allow sending duplicate messages", - s.allowDuplicateMessages); + layout.addCheckbox( + "Allow sending duplicate messages", s.allowDuplicateMessages, false, + "Allow a single message to be repeatedly sent without any changes."); layout.addTitle("Messages"); layout.addCheckbox("Separate with lines", s.separateMessages); layout.addCheckbox("Alternate background color", s.alternateMessages); layout.addCheckbox("Show deleted messages", s.hideModerated, true); layout.addDropdown( - "Timestamp format (a = am/pm, zzz = milliseconds)", + "Timestamp format", {"Disable", "h:mm", "hh:mm", "h:mm a", "hh:mm a", "h:mm:ss", "hh:mm:ss", "h:mm:ss a", "hh:mm:ss a", "h:mm:ss.zzz", "h:mm:ss.zzz a", "hh:mm:ss.zzz", "hh:mm:ss.zzz a"}, @@ -278,7 +282,8 @@ void GeneralPage::initLayout(GeneralPageView &layout) return args.index == 0 ? getSettings()->timestampFormat.getValue() : args.value; - }); + }, + true, "a = am/pm, zzz = milliseconds"); layout.addDropdown( "Limit message height", {"Never", "2 lines", "3 lines", "4 lines", "5 lines"}, @@ -367,7 +372,7 @@ void GeneralPage::initLayout(GeneralPageView &layout) [](auto args) { return args.index; }, - false); + false, "Show emote name, provider, and author on hover."); layout.addDropdown("Emoji style", { "Twitter", @@ -404,7 +409,8 @@ void GeneralPage::initLayout(GeneralPageView &layout) dankDropdown->setMinimumWidth(dankDropdown->minimumSizeHint().width() + 30); layout.addCheckbox("Hide usercard avatars", - s.streamerModeHideUsercardAvatars); + s.streamerModeHideUsercardAvatars, false, + "Prevent potentially explicit avatars from showing."); layout.addCheckbox("Hide link thumbnails", s.streamerModeHideLinkThumbnails); layout.addCheckbox( @@ -644,16 +650,19 @@ void GeneralPage::initLayout(GeneralPageView &layout) }); layout.addSubtitle("Visible badges"); - layout.addCheckbox("Authority (staff, admin)", s.showBadgesGlobalAuthority); + layout.addCheckbox("Authority", s.showBadgesGlobalAuthority, false, + "e.g., staff, admin"); layout.addCheckbox("Predictions", s.showBadgesPredictions); - layout.addCheckbox("Channel (broadcaster, moderator)", - s.showBadgesChannelAuthority); + layout.addCheckbox("Channel", s.showBadgesChannelAuthority, false, + "e.g., broadcaster, moderator"); layout.addCheckbox("Subscriber ", s.showBadgesSubscription); - layout.addCheckbox("Vanity (prime, bits, subgifter)", s.showBadgesVanity); + layout.addCheckbox("Vanity", s.showBadgesVanity, false, + "e.g., prime, bits, sub gifter"); layout.addCheckbox("Chatterino", s.showBadgesChatterino); - layout.addCheckbox("FrankerFaceZ (Bot, FFZ Supporter, FFZ Developer)", - s.showBadgesFfz); - layout.addCheckbox("7TV", s.showBadgesSevenTV); + layout.addCheckbox("FrankerFaceZ", s.showBadgesFfz, false, + "e.g., Bot, FFZ supporter, FFZ developer"); + layout.addCheckbox("7TV", s.showBadgesSevenTV, false, + "Badges for 7TV admins, developers, and supporters"); layout.addSeperator(); layout.addCheckbox("Use custom FrankerFaceZ moderator badges", s.useCustomFfzModeratorBadges); @@ -679,8 +688,9 @@ void GeneralPage::initLayout(GeneralPageView &layout) } #endif - layout.addCheckbox("Show moderation messages", s.hideModerationActions, - true); + layout.addCheckbox( + "Show moderation messages", s.hideModerationActions, true, + "Show messages for timeouts, bans, and other moderator actions."); layout.addCheckbox("Show deletions of single messages", s.hideDeletionActions, true); layout.addCheckbox("Colorize users without color set (gray names)", @@ -694,11 +704,15 @@ void GeneralPage::initLayout(GeneralPageView &layout) layout.addCheckbox( "Automatically close reply thread popup when it loses focus", s.autoCloseThreadPopup); - layout.addCheckbox("Lowercase domains (anti-phishing)", s.lowercaseDomains); + layout.addCheckbox("Lowercase domains (anti-phishing)", s.lowercaseDomains, + false, + "Make all clickable links lowercase to deter " + "phishing attempts."); layout.addCheckbox("Bold @usernames", s.boldUsernames); layout.addCheckbox("Color @usernames", s.colorUsernames); layout.addCheckbox("Try to find usernames without @ prefix", - s.findAllUsernames); + s.findAllUsernames, false, + "Find mentions of users in chat without the @ prefix."); layout.addCheckbox("Show username autocompletion popup menu", s.showUsernameCompletionMenu); const QStringList usernameDisplayModes = {"Username", "Localized name", @@ -734,11 +748,16 @@ void GeneralPage::initLayout(GeneralPageView &layout) layout.addCheckbox( "Only search for emote autocompletion at the start of emote names", - s.prefixOnlyEmoteCompletion); + s.prefixOnlyEmoteCompletion, false, + "When disabled, emote tab-completion will complete based on any part " + "of the name." + "\ne.g., sheffy -> DatSheffy"); layout.addCheckbox("Only search for username autocompletion with an @", s.userCompletionOnlyWithAt); - layout.addCheckbox("Show Twitch whispers inline", s.inlineWhispers); + layout.addCheckbox("Show Twitch whispers inline", s.inlineWhispers, false, + "Show whispers as messages in all splits instead " + "of just /whispers."); layout.addCheckbox("Highlight received inline whispers", s.highlightInlineWhispers); layout.addCheckbox("Load message history on connect", @@ -760,8 +779,10 @@ void GeneralPage::initLayout(GeneralPageView &layout) [](auto args) { return args.index; }, - false); - layout.addCheckbox("Combine multiple bit tips into one", s.stackBits); + false, "Combine consecutive timeout messages into a single message."); + layout.addCheckbox("Combine multiple bit tips into one", s.stackBits, false, + "Combine consecutive cheermotes (sent in a single " + "message) into one cheermote."); layout.addCheckbox("Messages in /mentions highlights tab", s.highlightMentions); layout.addCheckbox("Strip leading mention in replies", s.stripReplyMention); diff --git a/src/widgets/settingspages/GeneralPageView.cpp b/src/widgets/settingspages/GeneralPageView.cpp index f9accba3d..60c66b4bb 100644 --- a/src/widgets/settingspages/GeneralPageView.cpp +++ b/src/widgets/settingspages/GeneralPageView.cpp @@ -1,13 +1,33 @@ -#include "GeneralPageView.hpp" -#include +#include "widgets/settingspages/GeneralPageView.hpp" + #include "Application.hpp" -#include "singletons/Settings.hpp" #include "singletons/WindowManager.hpp" #include "util/LayoutHelper.hpp" +#include "util/RapidJsonSerializeQString.hpp" #include "widgets/dialogs/ColorPickerDialog.hpp" #include "widgets/helper/ColorButton.hpp" #include "widgets/helper/Line.hpp" +#include +#include + +namespace { + +constexpr int MAX_TOOLTIP_LINE_LENGTH = 50; +const auto MAX_TOOLTIP_LINE_LENGTH_PATTERN = + QStringLiteral(R"(.{%1}\S*\K(\s+))").arg(MAX_TOOLTIP_LINE_LENGTH); +const QRegularExpression MAX_TOOLTIP_LINE_LENGTH_REGEX( + MAX_TOOLTIP_LINE_LENGTH_PATTERN); + +const auto TOOLTIP_STYLE_SHEET = QStringLiteral(R"(QToolTip { +padding: 2px; +background-color: #333333; +border: 1px solid #545454; +} +)"); + +} // namespace + namespace chatterino { GeneralPageView::GeneralPageView(QWidget *parent) @@ -86,9 +106,11 @@ SubtitleLabel *GeneralPageView::addSubtitle(const QString &title) } QCheckBox *GeneralPageView::addCheckbox(const QString &text, - BoolSetting &setting, bool inverse) + BoolSetting &setting, bool inverse, + QString toolTipText) { auto check = new QCheckBox(text); + this->addToolTip(*check, toolTipText); // update when setting changes setting.connect( @@ -112,7 +134,8 @@ QCheckBox *GeneralPageView::addCheckbox(const QString &text, } ComboBox *GeneralPageView::addDropdown(const QString &text, - const QStringList &list) + const QStringList &list, + QString toolTipText) { auto layout = new QHBoxLayout; auto combo = new ComboBox; @@ -124,6 +147,7 @@ ComboBox *GeneralPageView::addDropdown(const QString &text, layout->addStretch(1); layout->addWidget(combo); + this->addToolTip(*label, toolTipText); this->addLayout(layout); // groups @@ -135,9 +159,10 @@ ComboBox *GeneralPageView::addDropdown(const QString &text, ComboBox *GeneralPageView::addDropdown( const QString &text, const QStringList &items, - pajlada::Settings::Setting &setting, bool editable) + pajlada::Settings::Setting &setting, bool editable, + QString toolTipText) { - auto combo = this->addDropdown(text, items); + auto combo = this->addDropdown(text, items, toolTipText); if (editable) combo->setEditable(true); @@ -160,15 +185,19 @@ ComboBox *GeneralPageView::addDropdown( ColorButton *GeneralPageView::addColorButton( const QString &text, const QColor &color, - pajlada::Settings::Setting &setting) + pajlada::Settings::Setting &setting, QString toolTipText) { auto colorButton = new ColorButton(color); auto layout = new QHBoxLayout(); auto label = new QLabel(text + ":"); + layout->addWidget(label); layout->addStretch(1); layout->addWidget(colorButton); + + this->addToolTip(*label, toolTipText); this->addLayout(layout); + QObject::connect( colorButton, &ColorButton::clicked, [this, &setting, colorButton]() { auto dialog = new ColorPickerDialog(QColor(setting), this); @@ -190,11 +219,13 @@ ColorButton *GeneralPageView::addColorButton( } QSpinBox *GeneralPageView::addIntInput(const QString &text, IntSetting &setting, - int min, int max, int step) + int min, int max, int step, + QString toolTipText) { auto layout = new QHBoxLayout; auto label = new QLabel(text + ":"); + this->addToolTip(*label, toolTipText); auto input = new QSpinBox; input->setMinimum(min); @@ -362,4 +393,23 @@ void GeneralPageView::updateNavigationHighlighting() } } +void GeneralPageView::addToolTip(QWidget &widget, QString text) const +{ + if (text.isEmpty()) + { + return; + } + + if (text.length() > MAX_TOOLTIP_LINE_LENGTH) + { + // match MAX_TOOLTIP_LINE_LENGTH characters, any remaining + // non-space, and then capture the following space for + // replacement with newline + text.replace(MAX_TOOLTIP_LINE_LENGTH_REGEX, "\n"); + } + + widget.setToolTip(text); + widget.setStyleSheet(TOOLTIP_STYLE_SHEET); +} + } // namespace chatterino diff --git a/src/widgets/settingspages/GeneralPageView.hpp b/src/widgets/settingspages/GeneralPageView.hpp index 0a4ff57b4..bbfded9f9 100644 --- a/src/widgets/settingspages/GeneralPageView.hpp +++ b/src/widgets/settingspages/GeneralPageView.hpp @@ -99,15 +99,17 @@ public: SubtitleLabel *addSubtitle(const QString &text); /// @param inverse Inverses true to false and vice versa QCheckBox *addCheckbox(const QString &text, BoolSetting &setting, - bool inverse = false); - ComboBox *addDropdown(const QString &text, const QStringList &items); + bool inverse = false, QString toolTipText = {}); + ComboBox *addDropdown(const QString &text, const QStringList &items, + QString toolTipText = {}); ComboBox *addDropdown(const QString &text, const QStringList &items, pajlada::Settings::Setting &setting, - bool editable = false); + bool editable = false, QString toolTipText = {}); ColorButton *addColorButton(const QString &text, const QColor &color, - pajlada::Settings::Setting &setting); + pajlada::Settings::Setting &setting, + QString toolTipText = {}); QSpinBox *addIntInput(const QString &text, IntSetting &setting, int min, - int max, int step); + int max, int step, QString toolTipText = {}); void addNavigationSpacing(); template @@ -135,7 +137,8 @@ public: const QString &text, const QStringList &items, pajlada::Settings::Setting &setting, std::function(T)> getValue, - std::function setValue, bool editable = true) + std::function setValue, bool editable = true, + QString toolTipText = {}) { auto items2 = items; auto selected = getValue(setting.getValue()); @@ -147,7 +150,7 @@ public: items2.insert(0, boost::get(selected)); } - auto combo = this->addDropdown(text, items2); + auto combo = this->addDropdown(text, items2, toolTipText); if (editable) combo->setEditable(true); @@ -200,6 +203,7 @@ protected: private: void updateNavigationHighlighting(); + void addToolTip(QWidget &widget, QString text) const; struct Widget { QWidget *element; From d23d5c142e5a1754da9b2e5c7836c686f03e2a33 Mon Sep 17 00:00:00 2001 From: yodax Date: Sun, 30 Oct 2022 08:29:43 -0400 Subject: [PATCH 078/946] Added stream titles to windows toast notifications (#2044) Co-authored-by: 23rd <23rd@vivaldi.net> Co-authored-by: David Myers Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 1 + .../notifications/NotificationController.cpp | 2 +- src/providers/twitch/TwitchChannel.cpp | 2 +- src/singletons/Toasts.cpp | 15 ++++++++++----- src/singletons/Toasts.hpp | 6 ++++-- 5 files changed, 17 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 56daa4294..c0ae71c16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ - Minor: Migrated /vips to Helix API. Chat command will continue to be used until February 11th 2023. (#4053) - Minor: Migrated /uniquechat and /r9kbeta to Helix API. (#4057) - Minor: Migrated /uniquechatoff and /r9kbetaoff to Helix API. (#4057) +- Minor: Added stream titles to windows live toast notifications. (#1297) - Minor: Make menus and placeholders display appropriate custom key combos. (#4045) - Minor: Add settings tooltips. (#3437) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) diff --git a/src/controllers/notifications/NotificationController.cpp b/src/controllers/notifications/NotificationController.cpp index 1eae5d9b3..d8215d64e 100644 --- a/src/controllers/notifications/NotificationController.cpp +++ b/src/controllers/notifications/NotificationController.cpp @@ -187,7 +187,7 @@ void NotificationController::checkStream(bool live, QString channelName) if (Toasts::isEnabled()) { - getApp()->toasts->sendChannelNotification(channelName, + getApp()->toasts->sendChannelNotification(channelName, QString(), Platform::Twitch); } if (getSettings()->notificationPlaySound && diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index be98364bd..81783e5f7 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -640,7 +640,7 @@ void TwitchChannel::setLive(bool newLiveStatus) if (Toasts::isEnabled()) { getApp()->toasts->sendChannelNotification( - this->getName(), Platform::Twitch); + this->getName(), guard->title, Platform::Twitch); } if (getSettings()->notificationPlaySound) { diff --git a/src/singletons/Toasts.cpp b/src/singletons/Toasts.cpp index c06df70a5..00a3285fe 100644 --- a/src/singletons/Toasts.cpp +++ b/src/singletons/Toasts.cpp @@ -66,11 +66,12 @@ QString Toasts::findStringFromReaction( return Toasts::findStringFromReaction(static_cast(i)); } -void Toasts::sendChannelNotification(const QString &channelName, Platform p) +void Toasts::sendChannelNotification(const QString &channelName, + const QString &channelTitle, Platform p) { #ifdef Q_OS_WIN - auto sendChannelNotification = [this, channelName, p] { - this->sendWindowsNotification(channelName, p); + auto sendChannelNotification = [this, channelName, channelTitle, p] { + this->sendWindowsNotification(channelName, channelTitle, p); }; #else auto sendChannelNotification = [] { @@ -164,7 +165,8 @@ public: } }; -void Toasts::sendWindowsNotification(const QString &channelName, Platform p) +void Toasts::sendWindowsNotification(const QString &channelName, + const QString &channelTitle, Platform p) { WinToastLib::WinToastTemplate templ = WinToastLib::WinToastTemplate( WinToastLib::WinToastTemplate::ImageAndText03); @@ -180,7 +182,10 @@ void Toasts::sendWindowsNotification(const QString &channelName, Platform p) Toasts::findStringFromReaction(getSettings()->openFromToast); mode = mode.toLower(); - templ.setTextField(L"Click here to " + mode.toStdWString(), + templ.setTextField(QString("%1 \nClick to %2") + .arg(channelTitle) + .arg(mode) + .toStdWString(), WinToastLib::WinToastTemplate::SecondLine); } diff --git a/src/singletons/Toasts.hpp b/src/singletons/Toasts.hpp index f32c0c4c4..b1a8eb44e 100644 --- a/src/singletons/Toasts.hpp +++ b/src/singletons/Toasts.hpp @@ -19,7 +19,8 @@ enum class ToastReaction { class Toasts final : public Singleton { public: - void sendChannelNotification(const QString &channelName, Platform p); + void sendChannelNotification(const QString &channelName, + const QString &channelTitle, Platform p); static QString findStringFromReaction(const ToastReaction &reaction); static QString findStringFromReaction( const pajlada::Settings::Setting &reaction); @@ -29,7 +30,8 @@ public: private: #ifdef Q_OS_WIN - void sendWindowsNotification(const QString &channelName, Platform p); + void sendWindowsNotification(const QString &channelName, + const QString &channelTitle, Platform p); #endif }; } // namespace chatterino From e3af865a700895a021c29e9914937607be5100c6 Mon Sep 17 00:00:00 2001 From: pajlada Date: Sun, 30 Oct 2022 14:01:54 +0100 Subject: [PATCH 079/946] Add helper function for ensuring a function is run in the GUI thread (#4091) --- src/messages/Image.cpp | 5 ++++- src/util/PostToThread.hpp | 16 +++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/src/messages/Image.cpp b/src/messages/Image.cpp index e189d54be..6f5171a24 100644 --- a/src/messages/Image.cpp +++ b/src/messages/Image.cpp @@ -278,7 +278,10 @@ Image::~Image() return; } - // run destructor of Frames in gui thread + // Ensure the destructor for our frames is called in the GUI thread + // If the Image destructor is called outside of the GUI thread, move the + // ownership of the frames to the GUI thread, otherwise the frames will be + // destructed as part as we go out of scope if (!isGuiThread()) { postToThread([frames = this->frames_.release()]() { diff --git a/src/util/PostToThread.hpp b/src/util/PostToThread.hpp index 5f90849d7..45d715a1d 100644 --- a/src/util/PostToThread.hpp +++ b/src/util/PostToThread.hpp @@ -1,7 +1,8 @@ #pragma once -#include +#include "debug/AssertInGuiThread.hpp" +#include #include #include @@ -29,6 +30,19 @@ private: std::function action_; }; +template +static void runInGuiThread(F &&fun) +{ + if (isGuiThread()) + { + fun(); + } + else + { + postToThread(fun); + } +} + // Taken from // https://stackoverflow.com/questions/21646467/how-to-execute-a-functor-or-a-lambda-in-a-given-thread-in-qt-gcd-style // Qt 5/4 - preferred, has least allocations From d3eed626ec9b66087f25ac29743c3b442c3efee3 Mon Sep 17 00:00:00 2001 From: mohad12211 <51754973+mohad12211@users.noreply.github.com> Date: Sun, 30 Oct 2022 23:09:40 +0300 Subject: [PATCH 080/946] Add missing Text tag to reply message text (#4092) --- src/providers/twitch/TwitchMessageBuilder.cpp | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index f284ba989..c00c5bcfc 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -238,7 +238,9 @@ MessagePtr TwitchMessageBuilder::build() ->setLink({Link::UserInfo, threadRoot->displayName}); this->emplace( - threadRoot->messageText, MessageElementFlag::RepliedMessage, + threadRoot->messageText, + MessageElementFlags({MessageElementFlag::RepliedMessage, + MessageElementFlag::Text}), this->textColor_, FontStyle::ChatMediumSmall) ->setLink({Link::ViewThread, this->thread_->rootId()}); } @@ -268,8 +270,10 @@ MessagePtr TwitchMessageBuilder::build() ->setLink({Link::UserInfo, name}); this->emplace( - body, MessageElementFlag::RepliedMessage, this->textColor_, - FontStyle::ChatMediumSmall); + body, + MessageElementFlags({MessageElementFlag::RepliedMessage, + MessageElementFlag::Text}), + this->textColor_, FontStyle::ChatMediumSmall); } } From a033dbc933ed87fc0d1ca4497c14401b9e16c423 Mon Sep 17 00:00:00 2001 From: Brian <18603393+brian6932@users.noreply.github.com> Date: Mon, 31 Oct 2022 19:18:38 -0400 Subject: [PATCH 081/946] Grammar: Alias to -> Alias of (#4093) --- src/providers/seventv/SeventvEmotes.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/providers/seventv/SeventvEmotes.cpp b/src/providers/seventv/SeventvEmotes.cpp index 4bce11f38..124cfb4db 100644 --- a/src/providers/seventv/SeventvEmotes.cpp +++ b/src/providers/seventv/SeventvEmotes.cpp @@ -148,7 +148,7 @@ Tooltip createTooltip(const QString &name, const QString &author, bool isGlobal) Tooltip createAliasedTooltip(const QString &name, const QString &baseName, const QString &author, bool isGlobal) { - return Tooltip{QString("%1
Alias to %2
%3 7TV Emote
By: %4") + return Tooltip{QString("%1
Alias of %2
%3 7TV Emote
By: %4") .arg(name, baseName, isGlobal ? "Global" : "Channel", author.isEmpty() ? "" : author)}; } From abb69f679412153fdbc8de1918e11749d4cafd7f Mon Sep 17 00:00:00 2001 From: pajlada Date: Tue, 1 Nov 2022 21:39:26 +0100 Subject: [PATCH 082/946] Include more error messaging for failed image uploads (#4096) --- src/util/NuulsUploader.cpp | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/util/NuulsUploader.cpp b/src/util/NuulsUploader.cpp index 606b38f14..f570023fd 100644 --- a/src/util/NuulsUploader.cpp +++ b/src/util/NuulsUploader.cpp @@ -204,9 +204,32 @@ void uploadImageToNuuls(RawImageData imageData, ChannelPtr channel, return Success; }) .onError([channel](NetworkResult result) -> bool { - channel->addMessage(makeSystemMessage( + auto errorMessage = QString("An error happened while uploading your image: %1") - .arg(result.status()))); + .arg(result.status()); + + // Try to read more information from the result body + auto obj = result.parseJson(); + if (!obj.isEmpty()) + { + auto apiCode = obj.value("code"); + if (!apiCode.isUndefined()) + { + auto codeString = apiCode.toVariant().toString(); + codeString.truncate(20); + errorMessage += QString(" - code: %1").arg(codeString); + } + + auto apiError = obj.value("error").toString(); + if (!apiError.isEmpty()) + { + apiError.truncate(300); + errorMessage += + QString(" - error: %1").arg(apiError.trimmed()); + } + } + + channel->addMessage(makeSystemMessage(errorMessage)); uploadMutex.unlock(); return true; }) From 495f3ed4a982e6d70da19d5ba651d636cccd4abf Mon Sep 17 00:00:00 2001 From: Colton Clemmer Date: Tue, 1 Nov 2022 17:18:57 -0500 Subject: [PATCH 083/946] Migrate /chatters commands to use Helix api (#4088) Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 1 + .../commands/CommandController.cpp | 64 ++++++++-- src/providers/twitch/TwitchChannel.cpp | 71 ++++------- src/providers/twitch/api/Helix.cpp | 114 ++++++++++++++++++ src/providers/twitch/api/Helix.hpp | 57 +++++++++ tests/src/HighlightController.cpp | 9 ++ 6 files changed, 257 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c0ae71c16..4182ce03d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,6 +69,7 @@ - Minor: Migrated /uniquechatoff and /r9kbetaoff to Helix API. (#4057) - Minor: Added stream titles to windows live toast notifications. (#1297) - Minor: Make menus and placeholders display appropriate custom key combos. (#4045) +- Minor: Migrated /chatters to Helix API. (#4088) - Minor: Add settings tooltips. (#3437) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fixed `Smooth scrolling on new messages` setting sometimes hiding messages. (#4028) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 8a403810b..1f8308aa9 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -878,23 +878,65 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); - this->registerCommand( - "/chatters", [](const auto & /*words*/, auto channel) { - auto twitchChannel = dynamic_cast(channel.get()); + this->registerCommand("/chatters", [](const auto &words, auto channel) { + auto formatError = [](HelixGetChattersError error, QString message) { + using Error = HelixGetChattersError; - if (twitchChannel == nullptr) + QString errorMessage = QString("Failed to get chatter count: "); + + switch (error) { - channel->addMessage(makeSystemMessage( - "The /chatters command only works in Twitch Channels")); - return ""; + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::UserMissingScope: { + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + errorMessage += "You must have moderator permissions to " + "use this command."; + } + break; + + case Error::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; } + return errorMessage; + }; + auto twitchChannel = dynamic_cast(channel.get()); + + if (twitchChannel == nullptr) + { channel->addMessage(makeSystemMessage( - QString("Chatter count: %1") - .arg(localizeNumbers(twitchChannel->chatterCount())))); - + "The /chatters command only works in Twitch Channels")); return ""; - }); + } + + // Refresh chatter list via helix api for mods + getHelix()->getChatters( + twitchChannel->roomId(), + getApp()->accounts->twitch.getCurrent()->getUserId(), 1, + [channel](auto result) { + channel->addMessage( + makeSystemMessage(QString("Chatter count: %1") + .arg(localizeNumbers(result.total)))); + }, + [channel, formatError](auto error, auto message) { + auto errorMessage = formatError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; + }); this->registerCommand("/clip", [](const auto & /*words*/, auto channel) { if (const auto type = channel->getType(); diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index 81783e5f7..e0cd7cf58 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -49,29 +49,8 @@ namespace { const QString LOGIN_PROMPT_TEXT("Click here to add your account again."); const Link ACCOUNTS_LINK(Link::OpenAccountsPage, QString()); - std::pair> parseChatters( - const QJsonObject &jsonRoot) - { - static QStringList categories = {"broadcaster", "vips", "moderators", - "staff", "admins", "global_mods", - "viewers"}; - - auto usernames = std::unordered_set(); - - // parse json - QJsonObject jsonCategories = jsonRoot.value("chatters").toObject(); - - for (const auto &category : categories) - { - for (auto jsonCategory : jsonCategories.value(category).toArray()) - { - usernames.insert(jsonCategory.toString()); - } - } - - return {Success, std::move(usernames)}; - } - + // Maximum number of chatters to fetch when refreshing chatters + constexpr auto MAX_CHATTERS_TO_FETCH = 5000; } // namespace TwitchChannel::TwitchChannel(const QString &name) @@ -136,9 +115,11 @@ TwitchChannel::TwitchChannel(const QString &name) }); // timers + QObject::connect(&this->chattersListTimer_, &QTimer::timeout, [=] { this->refreshChatters(); }); + this->chattersListTimer_.start(5 * 60 * 1000); QObject::connect(&this->threadClearTimer_, &QTimer::timeout, [=] { @@ -905,6 +886,12 @@ void TwitchChannel::refreshPubSub() void TwitchChannel::refreshChatters() { + // helix endpoint only works for mods + if (!this->hasModRights()) + { + return; + } + // setting? const auto streamStatus = this->accessStreamStatus(); const auto viewerCount = static_cast(streamStatus->viewerCount); @@ -917,31 +904,19 @@ void TwitchChannel::refreshChatters() } } - // get viewer list - NetworkRequest("https://tmi.twitch.tv/group/user/" + this->getName() + - "/chatters") - - .onSuccess( - [this, weak = weakOf(this)](auto result) -> Outcome { - // channel still exists? - auto shared = weak.lock(); - if (!shared) - { - return Failure; - } - - auto data = result.parseJson(); - this->chatterCount_ = data.value("chatter_count").toInt(); - - auto pair = parseChatters(std::move(data)); - if (pair.first) - { - this->updateOnlineChatters(pair.second); - } - - return pair.first; - }) - .execute(); + // Get chatter list via helix api + getHelix()->getChatters( + this->roomId(), getApp()->accounts->twitch.getCurrent()->getUserId(), + MAX_CHATTERS_TO_FETCH, + [this, weak = weakOf(this)](auto result) { + if (auto shared = weak.lock()) + { + this->updateOnlineChatters(result.chatters); + this->chatterCount_ = result.total; + } + }, + // Refresh chatters should only be used when failing silently is an option + [](auto error, auto message) {}); } void TwitchChannel::fetchDisplayName() diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index b30229282..7ed8794bd 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -1782,6 +1782,83 @@ void Helix::updateChatSettings( .execute(); } +// https://dev.twitch.tv/docs/api/reference#get-chatters +void Helix::fetchChatters( + QString broadcasterID, QString moderatorID, int first, QString after, + ResultCallback successCallback, + FailureCallback failureCallback) +{ + using Error = HelixGetChattersError; + + QUrlQuery urlQuery; + + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + urlQuery.addQueryItem("moderator_id", moderatorID); + urlQuery.addQueryItem("first", QString::number(first)); + + if (!after.isEmpty()) + { + urlQuery.addQueryItem("after", after); + } + + this->makeRequest("chat/chatters", urlQuery) + .onSuccess([successCallback](auto result) -> Outcome { + if (result.status() != 200) + { + qCWarning(chatterinoTwitch) + << "Success result for getting chatters was " + << result.status() << "but we expected it to be 200"; + } + + auto response = result.parseJson(); + successCallback(HelixChatters(response)); + return Success; + }) + .onError([failureCallback](auto result) { + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (result.status()) + { + case 400: { + failureCallback(Error::Forwarded, message); + } + break; + + case 401: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + failureCallback(Error::UserMissingScope, message); + } + else if (message.contains("OAuth token")) + { + failureCallback(Error::UserNotAuthorized, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 403: { + failureCallback(Error::UserNotAuthorized, message); + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error data:" << result.status() + << result.getData() << obj; + failureCallback(Error::Unknown, message); + } + break; + } + }) + .execute(); +} + // Ban/timeout a user // https://dev.twitch.tv/docs/api/reference#ban-user void Helix::banUser(QString broadcasterID, QString moderatorID, QString userID, @@ -1991,6 +2068,43 @@ void Helix::sendWhisper( .execute(); } +// https://dev.twitch.tv/docs/api/reference#get-chatters +void Helix::getChatters( + QString broadcasterID, QString moderatorID, int maxChattersToFetch, + ResultCallback successCallback, + FailureCallback failureCallback) +{ + static const auto NUM_CHATTERS_TO_FETCH = 1000; + + auto finalChatters = std::make_shared(); + + ResultCallback fetchSuccess; + + fetchSuccess = [this, broadcasterID, moderatorID, maxChattersToFetch, + finalChatters, &fetchSuccess, successCallback, + failureCallback](auto chatters) { + qCDebug(chatterinoTwitch) + << "Fetched" << chatters.chatters.size() << "chatters"; + finalChatters->chatters.merge(chatters.chatters); + finalChatters->total = chatters.total; + + if (chatters.cursor.isEmpty() || + finalChatters->chatters.size() >= maxChattersToFetch) + { + // Done paginating + successCallback(*finalChatters); + return; + } + + this->fetchChatters(broadcasterID, moderatorID, NUM_CHATTERS_TO_FETCH, + chatters.cursor, fetchSuccess, failureCallback); + }; + + // Initiate the recursive calls + this->fetchChatters(broadcasterID, moderatorID, NUM_CHATTERS_TO_FETCH, "", + fetchSuccess, failureCallback); +} + // List the VIPs of a channel // https://dev.twitch.tv/docs/api/reference#get-vips void Helix::getChannelVIPs( diff --git a/src/providers/twitch/api/Helix.hpp b/src/providers/twitch/api/Helix.hpp index 0e491f3c4..3ec4157f4 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -3,6 +3,7 @@ #include "common/Aliases.hpp" #include "common/NetworkRequest.hpp" #include "providers/twitch/TwitchEmotes.hpp" +#include "util/QStringHash.hpp" #include #include @@ -12,6 +13,7 @@ #include #include +#include #include namespace chatterino { @@ -342,6 +344,29 @@ struct HelixVip { } }; +struct HelixChatters { + std::unordered_set chatters; + int total; + QString cursor; + + HelixChatters() = default; + + explicit HelixChatters(const QJsonObject &jsonObject) + : total(jsonObject.value("total").toInt()) + , cursor(jsonObject.value("pagination") + .toObject() + .value("cursor") + .toString()) + { + const auto &data = jsonObject.value("data").toArray(); + for (const auto &chatter : data) + { + auto userLogin = chatter.toObject().value("user_login").toString(); + this->chatters.insert(userLogin); + } + } +}; + // TODO(jammehcow): when implementing mod list, just alias HelixVip to HelixMod // as they share the same model. // Alternatively, rename base struct to HelixUser or something and alias both @@ -519,6 +544,15 @@ enum class HelixWhisperError { // /w Forwarded, }; // /w +enum class HelixGetChattersError { + Unknown, + UserMissingScope, + UserNotAuthorized, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; + enum class HelixListVIPsError { // /vips Unknown, UserMissingScope, @@ -784,6 +818,14 @@ public: ResultCallback<> successCallback, FailureCallback failureCallback) = 0; + // Get Chatters from the `broadcasterID` channel + // This will follow the returned cursor and return up to `maxChattersToFetch` chatters + // https://dev.twitch.tv/docs/api/reference#get-chatters + virtual void getChatters( + QString broadcasterID, QString moderatorID, int maxChattersToFetch, + ResultCallback successCallback, + FailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#get-vips virtual void getChannelVIPs( QString broadcasterID, @@ -1045,6 +1087,14 @@ public: ResultCallback<> successCallback, FailureCallback failureCallback) final; + // Get Chatters from the `broadcasterID` channel + // This will follow the returned cursor and return up to `maxChattersToFetch` chatters + // https://dev.twitch.tv/docs/api/reference#get-chatters + void getChatters( + QString broadcasterID, QString moderatorID, int maxChattersToFetch, + ResultCallback successCallback, + FailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference#get-vips void getChannelVIPs( QString broadcasterID, @@ -1063,6 +1113,13 @@ protected: FailureCallback failureCallback) final; + // Get chatters list - This method is what actually runs the API request + // https://dev.twitch.tv/docs/api/reference#get-chatters + void fetchChatters( + QString broadcasterID, QString moderatorID, int first, QString after, + ResultCallback successCallback, + FailureCallback failureCallback); + private: NetworkRequest makeRequest(QString url, QUrlQuery urlQuery); diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index 982fc2800..36a49df74 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -360,6 +360,15 @@ public: (FailureCallback failureCallback)), (override)); // /w + // getChatters + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD( + void, getChatters, + (QString broadcasterID, QString moderatorID, int maxChattersToFetch, + ResultCallback successCallback, + (FailureCallback failureCallback)), + (override)); // getChatters + // /vips // The extra parenthesis around the failure callback is because its type contains a comma MOCK_METHOD( From 7640677a439273660ad87fc72931e7e7880ab7fd Mon Sep 17 00:00:00 2001 From: Daniel Sage <24928223+dnsge@users.noreply.github.com> Date: Wed, 2 Nov 2022 04:19:44 -0400 Subject: [PATCH 084/946] Improve Appearance of Reply Curve (#4077) --- CHANGELOG.md | 2 +- src/messages/MessageElement.cpp | 16 +++---- src/messages/MessageElement.hpp | 4 -- .../layouts/MessageLayoutContainer.cpp | 17 ++------ src/messages/layouts/MessageLayoutElement.cpp | 42 ++++++++++++------- src/messages/layouts/MessageLayoutElement.hpp | 5 ++- src/singletons/Settings.hpp | 1 - 7 files changed, 44 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4182ce03d..0e8e2bcab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unversioned -- Major: Added support for Twitch's Chat Replies. [Wiki Page](https://wiki.chatterino.com/Features/#message-replies) (#3722, #3989, #4041, #4047, #4055, #4067) +- Major: Added support for Twitch's Chat Replies. [Wiki Page](https://wiki.chatterino.com/Features/#message-replies) (#3722, #3989, #4041, #4047, #4055, #4067, #4077) - Major: Added multi-channel searching to search dialog via keyboard shortcut. (Ctrl+Shift+F by default) (#3694, #3875) - Major: Added support for emotes and badges from [7TV](https://7tv.app). [Wiki Page](https://wiki.chatterino.com/Third_party_services/#7tv) (#4002, #4062) - Minor: Added highlights for `Elevated Messages`. (#4016) diff --git a/src/messages/MessageElement.cpp b/src/messages/MessageElement.cpp index 057c51474..60b305738 100644 --- a/src/messages/MessageElement.cpp +++ b/src/messages/MessageElement.cpp @@ -678,21 +678,23 @@ void ScalingImageElement::addToContainer(MessageLayoutContainer &container, ReplyCurveElement::ReplyCurveElement() : MessageElement(MessageElementFlag::RepliedMessage) - // these values nicely align with a single badge - , neededMargin_(3) - , size_(18, 14) { } void ReplyCurveElement::addToContainer(MessageLayoutContainer &container, MessageElementFlags flags) { + static const int width = 18; // Overall width + static const float thickness = 1.5; // Pen width + static const int radius = 6; // Radius of the top left corner + static const int margin = 2; // Top/Left/Bottom margin + if (flags.hasAny(this->getFlags())) { - QSize boxSize = this->size_ * container.getScale(); - container.addElement(new ReplyCurveLayoutElement( - *this, boxSize, 1.5 * container.getScale(), - this->neededMargin_ * container.getScale())); + float scale = container.getScale(); + container.addElement( + new ReplyCurveLayoutElement(*this, width * scale, thickness * scale, + radius * scale, margin * scale)); } } diff --git a/src/messages/MessageElement.hpp b/src/messages/MessageElement.hpp index a61816c4e..9cc9d8bb6 100644 --- a/src/messages/MessageElement.hpp +++ b/src/messages/MessageElement.hpp @@ -429,10 +429,6 @@ public: void addToContainer(MessageLayoutContainer &container, MessageElementFlags flags) override; - -private: - int neededMargin_; - QSize size_; }; } // namespace chatterino diff --git a/src/messages/layouts/MessageLayoutContainer.cpp b/src/messages/layouts/MessageLayoutContainer.cpp index eefc88ae0..f7e3d9c47 100644 --- a/src/messages/layouts/MessageLayoutContainer.cpp +++ b/src/messages/layouts/MessageLayoutContainer.cpp @@ -133,21 +133,20 @@ void MessageLayoutContainer::_addElement(MessageLayoutElement *element, this->currentY_ = int(this->margin.top * this->scale_); } - int newLineHeight = element->getRect().height(); + int elementLineHeight = element->getRect().height(); // compact emote offset bool isCompactEmote = - getSettings()->compactEmotes && !this->flags_.has(MessageFlag::DisableCompactEmotes) && element->getCreator().getFlags().has(MessageElementFlag::EmoteImages); if (isCompactEmote) { - newLineHeight -= COMPACT_EMOTES_OFFSET * this->scale_; + elementLineHeight -= COMPACT_EMOTES_OFFSET * this->scale_; } // update line height - this->lineHeight_ = std::max(this->lineHeight_, newLineHeight); + this->lineHeight_ = std::max(this->lineHeight_, elementLineHeight); auto xOffset = 0; bool isZeroWidthEmote = element->getCreator().getFlags().has( @@ -218,7 +217,6 @@ void MessageLayoutContainer::breakLine() MessageLayoutElement *element = this->elements_.at(i).get(); bool isCompactEmote = - getSettings()->compactEmotes && !this->flags_.has(MessageFlag::DisableCompactEmotes) && element->getCreator().getFlags().has( MessageElementFlag::EmoteImages); @@ -229,15 +227,6 @@ void MessageLayoutContainer::breakLine() yExtra = (COMPACT_EMOTES_OFFSET / 2) * this->scale_; } - // if (element->getCreator().getFlags() & - // MessageElementFlag::Badges) - // { - if (element->getRect().height() < this->textLineHeight_) - { - // yExtra -= (this->textLineHeight_ - element->getRect().height()) / - // 2; - } - element->setPosition( QPoint(element->getRect().x() + xOffset + int(this->margin.left * this->scale_), diff --git a/src/messages/layouts/MessageLayoutElement.cpp b/src/messages/layouts/MessageLayoutElement.cpp index 492a4c8bb..1aa9a25ca 100644 --- a/src/messages/layouts/MessageLayoutElement.cpp +++ b/src/messages/layouts/MessageLayoutElement.cpp @@ -442,11 +442,12 @@ int TextIconLayoutElement::getXFromIndex(int index) } ReplyCurveLayoutElement::ReplyCurveLayoutElement(MessageElement &creator, - const QSize &size, - float thickness, + int width, float thickness, + float radius, float neededMargin) - : MessageLayoutElement(creator, size) + : MessageLayoutElement(creator, QSize(width, 0)) , pen_(QColor("#888"), thickness, Qt::SolidLine, Qt::RoundCap) + , radius_(radius) , neededMargin_(neededMargin) { } @@ -454,23 +455,36 @@ ReplyCurveLayoutElement::ReplyCurveLayoutElement(MessageElement &creator, void ReplyCurveLayoutElement::paint(QPainter &painter) { QRectF paintRect(this->getRect()); - QPainterPath bezierPath; + QPainterPath path; - qreal top = paintRect.top() + paintRect.height() * 0.25; // 25% from top - qreal left = paintRect.left() + this->neededMargin_; - qreal bottom = paintRect.bottom() - this->neededMargin_; - QPointF startPoint(left, bottom); - QPointF controlPoint(left, top); - QPointF endPoint(paintRect.right(), top); + QRectF curveRect = paintRect.marginsRemoved(QMarginsF( + this->neededMargin_, this->neededMargin_, 0, this->neededMargin_)); - // Create curve path - bezierPath.moveTo(startPoint); - bezierPath.quadTo(controlPoint, endPoint); + // Make sure that our curveRect can always fit the radius curve + if (curveRect.height() < this->radius_) + { + curveRect.setTop(curveRect.top() - + (this->radius_ - curveRect.height())); + } + + QPointF bStartPoint(curveRect.left(), curveRect.top() + this->radius_); + QPointF bEndPoint(curveRect.left() + this->radius_, curveRect.top()); + QPointF bControlPoint(curveRect.topLeft()); + + // Draw line from bottom left to curve + path.moveTo(curveRect.bottomLeft()); + path.lineTo(bStartPoint); + + // Draw curve path + path.quadTo(bControlPoint, bEndPoint); + + // Draw line from curve to top right + path.lineTo(curveRect.topRight()); // Render curve painter.setPen(this->pen_); painter.setRenderHint(QPainter::Antialiasing); - painter.drawPath(bezierPath); + painter.drawPath(path); } void ReplyCurveLayoutElement::paintAnimated(QPainter &painter, int yOffset) diff --git a/src/messages/layouts/MessageLayoutElement.hpp b/src/messages/layouts/MessageLayoutElement.hpp index 4bf26ad29..6684731ad 100644 --- a/src/messages/layouts/MessageLayoutElement.hpp +++ b/src/messages/layouts/MessageLayoutElement.hpp @@ -163,8 +163,8 @@ private: class ReplyCurveLayoutElement : public MessageLayoutElement { public: - ReplyCurveLayoutElement(MessageElement &creator, const QSize &size, - float thickness, float lMargin); + ReplyCurveLayoutElement(MessageElement &creator, int width, float thickness, + float radius, float neededMargin); protected: void paint(QPainter &painter) override; @@ -177,6 +177,7 @@ protected: private: const QPen pen_; + const float radius_; const float neededMargin_; }; diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 2970cc8fa..a56b79b2b 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -100,7 +100,6 @@ public: false}; BoolSetting separateMessages = {"/appearance/messages/separateMessages", false}; - BoolSetting compactEmotes = {"/appearance/messages/compactEmotes", true}; BoolSetting hideModerated = {"/appearance/messages/hideModerated", false}; BoolSetting hideModerationActions = { "/appearance/messages/hideModerationActions", false}; From 4196bba4adc012c52285212f6536e7426c4468f5 Mon Sep 17 00:00:00 2001 From: pajlada Date: Wed, 2 Nov 2022 10:31:28 +0100 Subject: [PATCH 085/946] Fix recursive fetchChatters call (#4097) --- CHANGELOG.md | 2 +- src/providers/twitch/api/Helix.cpp | 39 +++++++++++++++--------------- 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e8e2bcab..338f434b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -69,7 +69,7 @@ - Minor: Migrated /uniquechatoff and /r9kbetaoff to Helix API. (#4057) - Minor: Added stream titles to windows live toast notifications. (#1297) - Minor: Make menus and placeholders display appropriate custom key combos. (#4045) -- Minor: Migrated /chatters to Helix API. (#4088) +- Minor: Migrated /chatters to Helix API. (#4088, #4097) - Minor: Add settings tooltips. (#3437) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fixed `Smooth scrolling on new messages` setting sometimes hiding messages. (#4028) diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 7ed8794bd..3b0ba491d 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -2078,31 +2078,32 @@ void Helix::getChatters( auto finalChatters = std::make_shared(); - ResultCallback fetchSuccess; + auto fetchSuccess = [this, broadcasterID, moderatorID, maxChattersToFetch, + finalChatters, successCallback, + failureCallback](auto fs) { + return [=](auto chatters) { + qCDebug(chatterinoTwitch) + << "Fetched" << chatters.chatters.size() << "chatters"; + finalChatters->chatters.merge(chatters.chatters); + finalChatters->total = chatters.total; - fetchSuccess = [this, broadcasterID, moderatorID, maxChattersToFetch, - finalChatters, &fetchSuccess, successCallback, - failureCallback](auto chatters) { - qCDebug(chatterinoTwitch) - << "Fetched" << chatters.chatters.size() << "chatters"; - finalChatters->chatters.merge(chatters.chatters); - finalChatters->total = chatters.total; + if (chatters.cursor.isEmpty() || + finalChatters->chatters.size() >= maxChattersToFetch) + { + // Done paginating + successCallback(*finalChatters); + return; + } - if (chatters.cursor.isEmpty() || - finalChatters->chatters.size() >= maxChattersToFetch) - { - // Done paginating - successCallback(*finalChatters); - return; - } - - this->fetchChatters(broadcasterID, moderatorID, NUM_CHATTERS_TO_FETCH, - chatters.cursor, fetchSuccess, failureCallback); + this->fetchChatters(broadcasterID, moderatorID, + NUM_CHATTERS_TO_FETCH, chatters.cursor, fs, + failureCallback); + }; }; // Initiate the recursive calls this->fetchChatters(broadcasterID, moderatorID, NUM_CHATTERS_TO_FETCH, "", - fetchSuccess, failureCallback); + fetchSuccess(fetchSuccess), failureCallback); } // List the VIPs of a channel From 1098be128672b01a526b1478ce650d8e736549b7 Mon Sep 17 00:00:00 2001 From: Brian <18603393+brian6932@users.noreply.github.com> Date: Wed, 2 Nov 2022 16:15:51 -0400 Subject: [PATCH 086/946] Update OpenSSL convenience link (#4098) --- BUILDING_ON_WINDOWS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/BUILDING_ON_WINDOWS.md b/BUILDING_ON_WINDOWS.md index e0ddf877e..f0e983854 100644 --- a/BUILDING_ON_WINDOWS.md +++ b/BUILDING_ON_WINDOWS.md @@ -33,7 +33,7 @@ Note: This installation will take about 2.1 GB of disk space. ### For our websocket library, we need OpenSSL 1.1 -1. Download OpenSSL for windows, version `1.1.1q`: **[Download](https://slproweb.com/download/Win64OpenSSL-1_1_1q.exe)** +1. Download OpenSSL for windows, version `1.1.1s`: **[Download](https://slproweb.com/download/Win64OpenSSL-1_1_1s.exe)** 2. When prompted, install OpenSSL to `C:\local\openssl` 3. When prompted, copy the OpenSSL DLLs to "The OpenSSL binaries (/bin) directory". From 36402a2faff371cfeedfb5d9c9649fb5f0f772b9 Mon Sep 17 00:00:00 2001 From: Daniel Sage <24928223+dnsge@users.noreply.github.com> Date: Wed, 2 Nov 2022 19:20:37 -0400 Subject: [PATCH 087/946] Fix Reply Text Showing In Reply Thread Popup (#4101) --- src/messages/layouts/MessageLayout.cpp | 20 +++++++++----------- src/messages/layouts/MessageLayout.hpp | 2 -- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/messages/layouts/MessageLayout.cpp b/src/messages/layouts/MessageLayout.cpp index 07c2ddc0b..65bbf36ca 100644 --- a/src/messages/layouts/MessageLayout.cpp +++ b/src/messages/layouts/MessageLayout.cpp @@ -134,30 +134,33 @@ void MessageLayout::actuallyLayout(int width, MessageElementFlags flags) messageFlags.unset(MessageFlag::Collapsed); } + bool hideModerated = getSettings()->hideModerated; + bool hideModerationActions = getSettings()->hideModerationActions; + bool hideSimilar = getSettings()->hideSimilar; + bool hideReplies = !flags.has(MessageElementFlag::RepliedMessage); + this->container_->begin(width, this->scale_, messageFlags); for (const auto &element : this->message_->elements) { - if (getSettings()->hideModerated && - this->message_->flags.has(MessageFlag::Disabled)) + if (hideModerated && this->message_->flags.has(MessageFlag::Disabled)) { continue; } - if (getSettings()->hideModerationActions && + if (hideModerationActions && (this->message_->flags.has(MessageFlag::Timeout) || this->message_->flags.has(MessageFlag::Untimeout))) { continue; } - if (getSettings()->hideSimilar && - this->message_->flags.has(MessageFlag::Similar)) + if (hideSimilar && this->message_->flags.has(MessageFlag::Similar)) { continue; } - if (!this->renderReplies_ && + if (hideReplies && element->getFlags().has(MessageElementFlag::RepliedMessage)) { continue; @@ -455,9 +458,4 @@ bool MessageLayout::isReplyable() const return true; } -void MessageLayout::setRenderReplies(bool render) -{ - this->renderReplies_ = render; -} - } // namespace chatterino diff --git a/src/messages/layouts/MessageLayout.hpp b/src/messages/layouts/MessageLayout.hpp index 2de8ed987..a8fcf2290 100644 --- a/src/messages/layouts/MessageLayout.hpp +++ b/src/messages/layouts/MessageLayout.hpp @@ -65,7 +65,6 @@ public: // Misc bool isDisabled() const; bool isReplyable() const; - void setRenderReplies(bool render); private: // variables @@ -73,7 +72,6 @@ private: std::shared_ptr container_; std::shared_ptr buffer_{}; bool bufferValid_ = false; - bool renderReplies_ = true; int height_ = 0; From 270a3653b9d0b2b7cfcde415753fd1439ca6da9c Mon Sep 17 00:00:00 2001 From: nerix Date: Thu, 3 Nov 2022 09:21:32 +0100 Subject: [PATCH 088/946] deps[conan]: update openssl and boost on Windows (#4100) Co-authored-by: pajlada --- conanfile.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/conanfile.txt b/conanfile.txt index 3c3ff3169..2b96b3fac 100644 --- a/conanfile.txt +++ b/conanfile.txt @@ -1,6 +1,6 @@ [requires] -openssl/1.1.1m -boost/1.78.0 +openssl/1.1.1s +boost/1.80.0 [generators] cmake From fcf3f2d88be7e3d7e3dc4c144beea5b7f00fda86 Mon Sep 17 00:00:00 2001 From: pajlada Date: Thu, 3 Nov 2022 20:03:16 +0100 Subject: [PATCH 089/946] Update `jurplel/install-qt-action` GitHub Action from v2 to v3 (#4106) --- .github/workflows/build.yml | 12 +++--------- .github/workflows/test.yml | 6 +++--- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f05466b07..ede984e58 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -38,17 +38,11 @@ jobs: submodules: true fetch-depth: 0 # allows for tags access - - name: Cache Qt - id: cache-qt - uses: actions/cache@v3 - with: - path: "${{ github.workspace }}/qt/" - key: ${{ runner.os }}-QtCache-${{ matrix.qt-version }} - - name: Install Qt - uses: jurplel/install-qt-action@v2 + uses: jurplel/install-qt-action@v3.0.0 with: - cached: ${{ steps.cache-qt.outputs.cache-hit }} + cache: true + cache-key-prefix: ${{ runner.os }}-QtCache-${{ matrix.qt-version }} version: ${{ matrix.qt-version }} dir: "${{ github.workspace }}/qt/" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f0e388640..0c03940b5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -33,11 +33,11 @@ jobs: path: "${{ github.workspace }}/qt/" key: ${{ runner.os }}-QtCache-${{ matrix.qt-version }} - # LINUX - name: Install Qt - uses: jurplel/install-qt-action@v2 + uses: jurplel/install-qt-action@v3.0.0 with: - cached: ${{ steps.cache-qt.outputs.cache-hit }} + cache: true + cache-key-prefix: ${{ runner.os }}-QtCache-${{ matrix.qt-version }} version: ${{ matrix.qt-version }} dir: "${{ github.workspace }}/qt/" From 05008214fa4a39ed8cdeaab2962d590269caa2dd Mon Sep 17 00:00:00 2001 From: pajlada Date: Fri, 4 Nov 2022 09:22:12 +0100 Subject: [PATCH 090/946] Fix Twitch-specific filters not being applied (#4107) --- CHANGELOG.md | 1 + src/widgets/helper/ChannelView.cpp | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 338f434b4..de7cae77b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,6 +102,7 @@ - Bugfix: Fixed channel-based popups from rewriting messages to file log (#4060) - Bugfix: Fixed invalid/dangling completion when cycling through previous messages or replying (#4072) - Bugfix: Fixed incorrect .desktop icon path. (#4078) +- Bugfix: Fixed Twitch channel-specific filters not being applied correctly. (#4107) - Dev: Removed official support for QMake. (#3839, #3883) - Dev: Rewrote LimitedQueue (#3798) - Dev: Overhauled highlight system by moving all checks into a Controller allowing for easier tests. (#3399, #3801, #3835) diff --git a/src/widgets/helper/ChannelView.cpp b/src/widgets/helper/ChannelView.cpp index 94579bcc9..ee3da9695 100644 --- a/src/widgets/helper/ChannelView.cpp +++ b/src/widgets/helper/ChannelView.cpp @@ -809,7 +809,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 e3e1845262f9909d6f13585a7f5ef8d920cda6dc Mon Sep 17 00:00:00 2001 From: pajlada Date: Fri, 4 Nov 2022 19:59:03 +0100 Subject: [PATCH 091/946] Fix uninitialized read in `ChannelView`'s `highlightedMessage_` (#4109) --- src/widgets/helper/ChannelView.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/widgets/helper/ChannelView.hpp b/src/widgets/helper/ChannelView.hpp index 8e5bfa536..c27652f3f 100644 --- a/src/widgets/helper/ChannelView.hpp +++ b/src/widgets/helper/ChannelView.hpp @@ -301,7 +301,7 @@ private: QTimer scrollTimer_; // We're only interested in the pointer, not the contents - MessageLayout *highlightedMessage_; + MessageLayout *highlightedMessage_ = nullptr; QVariantAnimation highlightAnimation_; void setupHighlightAnimationColors(); From 84a6e724fa0c5cbfddf599502ababffa75435392 Mon Sep 17 00:00:00 2001 From: pajlada Date: Fri, 4 Nov 2022 22:32:11 +0100 Subject: [PATCH 092/946] Revert "Fix Twitch-specific filters not being applied (#4107)" (#4111) --- CHANGELOG.md | 1 - src/widgets/helper/ChannelView.cpp | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index de7cae77b..338f434b4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -102,7 +102,6 @@ - Bugfix: Fixed channel-based popups from rewriting messages to file log (#4060) - Bugfix: Fixed invalid/dangling completion when cycling through previous messages or replying (#4072) - Bugfix: Fixed incorrect .desktop icon path. (#4078) -- Bugfix: Fixed Twitch channel-specific filters not being applied correctly. (#4107) - Dev: Removed official support for QMake. (#3839, #3883) - Dev: Rewrote LimitedQueue (#3798) - Dev: Overhauled highlight system by moving all checks into a Controller allowing for easier tests. (#3399, #3801, #3835) diff --git a/src/widgets/helper/ChannelView.cpp b/src/widgets/helper/ChannelView.cpp index ee3da9695..94579bcc9 100644 --- a/src/widgets/helper/ChannelView.cpp +++ b/src/widgets/helper/ChannelView.cpp @@ -809,7 +809,7 @@ bool ChannelView::shouldIncludeMessage(const MessagePtr &m) const m->loginName, Qt::CaseInsensitive) == 0) return true; - return this->channelFilters_->filter(m, this->underlyingChannel_); + return this->channelFilters_->filter(m, this->channel_); } return true; From f0ad606d7a090928ec9244853f12e589a3527b34 Mon Sep 17 00:00:00 2001 From: Felanbird <41973452+Felanbird@users.noreply.github.com> Date: Sat, 5 Nov 2022 00:53:13 -0400 Subject: [PATCH 093/946] Fix RapidJSON link not being https (#4113) --- src/widgets/settingspages/AboutPage.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/widgets/settingspages/AboutPage.cpp b/src/widgets/settingspages/AboutPage.cpp index f4c5dbcec..da3ca2ee3 100644 --- a/src/widgets/settingspages/AboutPage.cpp +++ b/src/widgets/settingspages/AboutPage.cpp @@ -85,7 +85,7 @@ AboutPage::AboutPage() ":/licenses/libcommuni_BSD3.txt"); addLicense(form.getElement(), "OpenSSL", "https://www.openssl.org/", ":/licenses/openssl.txt"); - addLicense(form.getElement(), "RapidJson", "http://rapidjson.org/", + addLicense(form.getElement(), "RapidJson", "https://rapidjson.org/", ":/licenses/rapidjson.txt"); addLicense(form.getElement(), "Pajlada/Settings", "https://github.com/pajlada/settings", From f00f766eebdc8247cbc4a0e2a161a17e3031aba1 Mon Sep 17 00:00:00 2001 From: xel86 Date: Sat, 5 Nov 2022 05:43:31 -0400 Subject: [PATCH 094/946] Migrate /commercial command to the Helix API (#4094) Co-authored-by: pajlada --- CHANGELOG.md | 1 + .../commands/CommandController.cpp | 142 ++++++++++++++++++ src/providers/twitch/api/Helix.cpp | 92 ++++++++++++ src/providers/twitch/api/Helix.hpp | 42 ++++++ src/singletons/Settings.hpp | 5 + src/widgets/settingspages/GeneralPage.cpp | 11 ++ tests/src/HighlightController.cpp | 9 ++ 7 files changed, 302 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 338f434b4..459955d11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ - Minor: Migrated /vips to Helix API. Chat command will continue to be used until February 11th 2023. (#4053) - Minor: Migrated /uniquechat and /r9kbeta to Helix API. (#4057) - Minor: Migrated /uniquechatoff and /r9kbetaoff to Helix API. (#4057) +- Minor: Migrated /commercial to Helix API. (#4094) - Minor: Added stream titles to windows live toast notifications. (#1297) - Minor: Make menus and placeholders display appropriate custom key combos. (#4045) - Minor: Migrated /chatters to Helix API. (#4088, #4097) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 1f8308aa9..575272eec 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -3033,6 +3033,55 @@ void CommandController::initialize(Settings &, Paths &paths) return errorMessage; }; + auto formatStartCommercialError = [](HelixStartCommercialError error, + const QString &message) -> QString { + using Error = HelixStartCommercialError; + + QString errorMessage = "Failed to start commercial - "; + + switch (error) + { + case Error::UserMissingScope: { + errorMessage += "Missing required scope. Re-login with your " + "account and try again."; + } + break; + + case Error::TokenMustMatchBroadcaster: { + errorMessage += "Only the broadcaster of the channel can run " + "commercials."; + } + break; + + case Error::BroadcasterNotStreaming: { + errorMessage += "You must be streaming live to run " + "commercials."; + } + break; + + case Error::Ratelimited: { + errorMessage += "You must wait until your cooldown period " + "expires before you can run another " + "commercial."; + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Unknown: + default: { + errorMessage += + QString("An unknown error has occurred (%1).").arg(message); + } + break; + } + + return errorMessage; + }; + this->registerCommand( "/vips", [formatVIPListError](const QStringList &words, @@ -3174,6 +3223,99 @@ void CommandController::initialize(Settings &, Paths &paths) "/r9kbeta", [uniqueChatLambda](const QStringList &words, auto channel) { return uniqueChatLambda(words, channel, true); }); + + this->registerCommand( + "/commercial", + [formatStartCommercialError](const QStringList &words, + auto channel) -> QString { + const auto *usageStr = "Usage: \"/commercial \" - Starts a " + "commercial with the " + "specified duration for the current " + "channel. Valid length options " + "are 30, 60, 90, 120, 150, and 180 seconds."; + + switch (getSettings()->helixTimegateCommercial.getValue()) + { + case HelixTimegateOverride::Timegate: { + if (areIRCCommandsStillAvailable()) + { + return useIRCCommand(words); + } + + // fall through to Helix logic + } + break; + + case HelixTimegateOverride::AlwaysUseIRC: { + return useIRCCommand(words); + } + break; + + case HelixTimegateOverride::AlwaysUseHelix: { + // do nothing and fall through to Helix logic + } + break; + } + + if (words.size() < 2) + { + channel->addMessage(makeSystemMessage(usageStr)); + return ""; + } + + auto user = getApp()->accounts->twitch.getCurrent(); + + // Avoid Helix calls without Client ID and/or OAuth Token + if (user->isAnon()) + { + channel->addMessage(makeSystemMessage( + "You must be logged in to use the /commercial command")); + return ""; + } + + auto *tc = dynamic_cast(channel.get()); + if (tc == nullptr) + { + return ""; + } + + auto broadcasterID = tc->roomId(); + auto length = words.at(1).toInt(); + + // We would prefer not to early out here and rather handle the API error + // like the rest of them, but the API doesn't give us a proper length error. + // Valid lengths can be found in the length body parameter description + // https://dev.twitch.tv/docs/api/reference#start-commercial + const QList validLengths = {30, 60, 90, 120, 150, 180}; + if (!validLengths.contains(length)) + { + channel->addMessage(makeSystemMessage( + "Invalid commercial duration length specified. Valid " + "options " + "are 30, 60, 90, 120, 150, and 180 seconds")); + return ""; + } + + getHelix()->startCommercial( + broadcasterID, length, + [channel](auto response) { + channel->addMessage(makeSystemMessage( + QString("Starting commercial break. Keep in mind you " + "are still " + "live and not all viewers will receive a " + "commercial. " + "You may run another commercial in %1 seconds.") + .arg(response.retryAfter))); + }, + [channel, formatStartCommercialError](auto error, + auto message) { + auto errorMessage = + formatStartCommercialError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; + }); } void CommandController::save() diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 3b0ba491d..35a7ca5ce 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -2199,6 +2199,98 @@ void Helix::getChannelVIPs( .execute(); } +void Helix::startCommercial( + QString broadcasterID, int length, + ResultCallback successCallback, + FailureCallback failureCallback) +{ + using Error = HelixStartCommercialError; + + QJsonObject payload; + + payload.insert("broadcaster_id", QJsonValue(broadcasterID)); + payload.insert("length", QJsonValue(length)); + + this->makeRequest("channels/commercial", QUrlQuery()) + .type(NetworkRequestType::Post) + .header("Content-Type", "application/json") + .payload(QJsonDocument(payload).toJson(QJsonDocument::Compact)) + .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + auto obj = result.parseJson(); + if (obj.isEmpty()) + { + failureCallback( + Error::Unknown, + "Twitch didn't send any information about this error."); + return Failure; + } + + successCallback(HelixStartCommercialResponse(obj)); + return Success; + }) + .onError([failureCallback](auto result) { + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (result.status()) + { + case 400: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + failureCallback(Error::UserMissingScope, message); + } + else if (message.contains( + "To start a commercial, the broadcaster must " + "be streaming live.", + Qt::CaseInsensitive)) + { + failureCallback(Error::BroadcasterNotStreaming, + message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 401: { + if (message.contains( + "The ID in broadcaster_id must match the user ID " + "found in the request's OAuth token.", + Qt::CaseInsensitive)) + { + failureCallback(Error::TokenMustMatchBroadcaster, + message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 429: { + // The cooldown period is implied to be included + // in the error's "retry_after" response field but isn't. + // If this becomes available we should append that to the error message. + failureCallback(Error::Ratelimited, message); + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error starting commercial:" + << 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 3ec4157f4..ece503bc8 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -564,6 +564,34 @@ enum class HelixListVIPsError { // /vips Forwarded, }; // /vips +struct HelixStartCommercialResponse { + // Length of the triggered commercial + int length; + // Provides contextual information on why the request failed + QString message; + // Seconds until the next commercial can be served on this channel + int retryAfter; + + explicit HelixStartCommercialResponse(const QJsonObject &jsonObject) + { + auto jsonData = jsonObject.value("data").toArray().at(0).toObject(); + this->length = jsonData.value("length").toInt(); + this->message = jsonData.value("message").toString(); + this->retryAfter = jsonData.value("retry_after").toInt(); + } +}; + +enum class HelixStartCommercialError { + Unknown, + TokenMustMatchBroadcaster, + UserMissingScope, + BroadcasterNotStreaming, + Ratelimited, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; + class IHelix { public: @@ -832,6 +860,13 @@ public: ResultCallback> successCallback, FailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#start-commercial + virtual void startCommercial( + QString broadcasterID, int length, + ResultCallback successCallback, + FailureCallback + failureCallback) = 0; + virtual void update(QString clientId, QString oauthToken) = 0; protected: @@ -1101,6 +1136,13 @@ public: ResultCallback> successCallback, FailureCallback failureCallback) final; + // https://dev.twitch.tv/docs/api/reference#start-commercial + void startCommercial( + QString broadcasterID, int length, + ResultCallback successCallback, + FailureCallback failureCallback) + final; + void update(QString clientId, QString oauthToken) final; static void initialize(); diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index a56b79b2b..57920a250 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -443,6 +443,11 @@ public: HelixTimegateOverride::Timegate, }; + EnumSetting helixTimegateCommercial = { + "/misc/twitch/helix-timegate/commercial", + HelixTimegateOverride::Timegate, + }; + IntSetting emotesTooltipPreview = {"/misc/emotesTooltipPreview", 1}; BoolSetting openLinksIncognito = {"/misc/openLinksIncognito", 0}; diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index 3a3c6afe7..a626efc4a 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -855,6 +855,17 @@ void GeneralPage::initLayout(GeneralPageView &layout) helixTimegateVIPs->setMinimumWidth( helixTimegateVIPs->minimumSizeHint().width()); + auto *helixTimegateCommercial = + layout.addDropdown::type>( + "Helix timegate /commercial behaviour", + {"Timegate", "Always use IRC", "Always use Helix"}, + s.helixTimegateCommercial, + helixTimegateGetValue, // + helixTimegateSetValue, // + false); + helixTimegateCommercial->setMinimumWidth( + helixTimegateCommercial->minimumSizeHint().width()); + layout.addStretch(); // invisible element for width diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index 36a49df74..00870e6b7 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -378,6 +378,15 @@ public: (FailureCallback failureCallback)), (override)); // /vips + // /commercial + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD( + void, startCommercial, + (QString broadcasterID, int length, + ResultCallback successCallback, + (FailureCallback failureCallback)), + (override)); // /commercial + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override)); From aac9ea53d042e7d952171a8e03c4a91c98aadbda Mon Sep 17 00:00:00 2001 From: pajlada Date: Sat, 5 Nov 2022 11:04:35 +0100 Subject: [PATCH 095/946] Harden emote parsing (#3885) --- CMakeLists.txt | 1 + cmake/CodeCoverage.cmake | 719 ++++++++++++++++++ src/Application.cpp | 5 + src/Application.hpp | 8 +- src/CMakeLists.txt | 15 + src/common/SignalVector.hpp | 8 +- src/providers/twitch/TwitchEmotes.cpp | 7 - src/providers/twitch/TwitchEmotes.hpp | 16 +- src/providers/twitch/TwitchMessageBuilder.cpp | 188 +++-- src/providers/twitch/TwitchMessageBuilder.hpp | 19 +- src/singletons/Emotes.hpp | 15 +- .../dialogs/switcher/QuickSwitcherModel.hpp | 2 +- tests/src/HighlightController.cpp | 6 +- tests/src/TwitchMessageBuilder.cpp | 258 +++++++ 14 files changed, 1163 insertions(+), 104 deletions(-) create mode 100644 cmake/CodeCoverage.cmake diff --git a/CMakeLists.txt b/CMakeLists.txt index aef02de4e..309deb787 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -18,6 +18,7 @@ option(USE_SYSTEM_QTKEYCHAIN "Use system QtKeychain library" OFF) option(BUILD_WITH_QTKEYCHAIN "Build Chatterino with support for your system key chain" ON) option(USE_PRECOMPILED_HEADERS "Use precompiled headers" ON) option(BUILD_WITH_QT6 "Use Qt6 instead of default Qt5" OFF) +option(CHATTERINO_GENERATE_COVERAGE "Generate coverage files" OFF) option(USE_CONAN "Use conan" OFF) diff --git a/cmake/CodeCoverage.cmake b/cmake/CodeCoverage.cmake new file mode 100644 index 000000000..965337095 --- /dev/null +++ b/cmake/CodeCoverage.cmake @@ -0,0 +1,719 @@ +# Copyright (c) 2012 - 2017, Lars Bilke +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without modification, +# are permitted provided that the following conditions are met: +# +# 1. Redistributions of source code must retain the above copyright notice, this +# list of conditions and the following disclaimer. +# +# 2. Redistributions in binary form must reproduce the above copyright notice, +# this list of conditions and the following disclaimer in the documentation +# and/or other materials provided with the distribution. +# +# 3. Neither the name of the copyright holder nor the names of its contributors +# may be used to endorse or promote products derived from this software without +# specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +# ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +# ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +# ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +# +# CHANGES: +# +# 2012-01-31, Lars Bilke +# - Enable Code Coverage +# +# 2013-09-17, Joakim Söderberg +# - Added support for Clang. +# - Some additional usage instructions. +# +# 2016-02-03, Lars Bilke +# - Refactored functions to use named parameters +# +# 2017-06-02, Lars Bilke +# - Merged with modified version from github.com/ufz/ogs +# +# 2019-05-06, Anatolii Kurotych +# - Remove unnecessary --coverage flag +# +# 2019-12-13, FeRD (Frank Dana) +# - Deprecate COVERAGE_LCOVR_EXCLUDES and COVERAGE_GCOVR_EXCLUDES lists in favor +# of tool-agnostic COVERAGE_EXCLUDES variable, or EXCLUDE setup arguments. +# - CMake 3.4+: All excludes can be specified relative to BASE_DIRECTORY +# - All setup functions: accept BASE_DIRECTORY, EXCLUDE list +# - Set lcov basedir with -b argument +# - Add automatic --demangle-cpp in lcovr, if 'c++filt' is available (can be +# overridden with NO_DEMANGLE option in setup_target_for_coverage_lcovr().) +# - Delete output dir, .info file on 'make clean' +# - Remove Python detection, since version mismatches will break gcovr +# - Minor cleanup (lowercase function names, update examples...) +# +# 2019-12-19, FeRD (Frank Dana) +# - Rename Lcov outputs, make filtered file canonical, fix cleanup for targets +# +# 2020-01-19, Bob Apthorpe +# - Added gfortran support +# +# 2020-02-17, FeRD (Frank Dana) +# - Make all add_custom_target()s VERBATIM to auto-escape wildcard characters +# in EXCLUDEs, and remove manual escaping from gcovr targets +# +# 2021-01-19, Robin Mueller +# - Add CODE_COVERAGE_VERBOSE option which will allow to print out commands which are run +# - Added the option for users to set the GCOVR_ADDITIONAL_ARGS variable to supply additional +# flags to the gcovr command +# +# 2020-05-04, Mihchael Davis +# - Add -fprofile-abs-path to make gcno files contain absolute paths +# - Fix BASE_DIRECTORY not working when defined +# - Change BYPRODUCT from folder to index.html to stop ninja from complaining about double defines +# +# 2021-05-10, Martin Stump +# - Check if the generator is multi-config before warning about non-Debug builds +# +# 2022-02-22, Marko Wehle +# - Change gcovr output from -o for --xml and --html output respectively. +# This will allow for Multiple Output Formats at the same time by making use of GCOVR_ADDITIONAL_ARGS, e.g. GCOVR_ADDITIONAL_ARGS "--txt". +# +# USAGE: +# +# 1. Copy this file into your cmake modules path. +# +# 2. Add the following line to your CMakeLists.txt (best inside an if-condition +# using a CMake option() to enable it just optionally): +# include(CodeCoverage) +# +# 3. Append necessary compiler flags for all supported source files: +# append_coverage_compiler_flags() +# Or for specific target: +# append_coverage_compiler_flags_to_target(YOUR_TARGET_NAME) +# +# 3.a (OPTIONAL) Set appropriate optimization flags, e.g. -O0, -O1 or -Og +# +# 4. If you need to exclude additional directories from the report, specify them +# using full paths in the COVERAGE_EXCLUDES variable before calling +# setup_target_for_coverage_*(). +# Example: +# set(COVERAGE_EXCLUDES +# '${PROJECT_SOURCE_DIR}/src/dir1/*' +# '/path/to/my/src/dir2/*') +# Or, use the EXCLUDE argument to setup_target_for_coverage_*(). +# Example: +# setup_target_for_coverage_lcov( +# NAME coverage +# EXECUTABLE testrunner +# EXCLUDE "${PROJECT_SOURCE_DIR}/src/dir1/*" "/path/to/my/src/dir2/*") +# +# 4.a NOTE: With CMake 3.4+, COVERAGE_EXCLUDES or EXCLUDE can also be set +# relative to the BASE_DIRECTORY (default: PROJECT_SOURCE_DIR) +# Example: +# set(COVERAGE_EXCLUDES "dir1/*") +# setup_target_for_coverage_gcovr_html( +# NAME coverage +# EXECUTABLE testrunner +# BASE_DIRECTORY "${PROJECT_SOURCE_DIR}/src" +# EXCLUDE "dir2/*") +# +# 5. Use the functions described below to create a custom make target which +# runs your test executable and produces a code coverage report. +# +# 6. Build a Debug build: +# cmake -DCMAKE_BUILD_TYPE=Debug .. +# make +# make my_coverage_target +# + +include(CMakeParseArguments) + +option(CODE_COVERAGE_VERBOSE "Verbose information" FALSE) + +# Check prereqs +find_program( GCOV_PATH gcov ) +find_program( LCOV_PATH NAMES lcov lcov.bat lcov.exe lcov.perl) +find_program( FASTCOV_PATH NAMES fastcov fastcov.py ) +find_program( GENHTML_PATH NAMES genhtml genhtml.perl genhtml.bat ) +find_program( GCOVR_PATH gcovr PATHS ${CMAKE_SOURCE_DIR}/scripts/test) +find_program( CPPFILT_PATH NAMES c++filt ) + +if(NOT GCOV_PATH) + message(FATAL_ERROR "gcov not found! Aborting...") +endif() # NOT GCOV_PATH + +get_property(LANGUAGES GLOBAL PROPERTY ENABLED_LANGUAGES) +list(GET LANGUAGES 0 LANG) + +if("${CMAKE_${LANG}_COMPILER_ID}" MATCHES "(Apple)?[Cc]lang") + if("${CMAKE_${LANG}_COMPILER_VERSION}" VERSION_LESS 3) + message(FATAL_ERROR "Clang version must be 3.0.0 or greater! Aborting...") + endif() +elseif(NOT CMAKE_COMPILER_IS_GNUCXX) + if("${CMAKE_Fortran_COMPILER_ID}" MATCHES "[Ff]lang") + # Do nothing; exit conditional without error if true + elseif("${CMAKE_Fortran_COMPILER_ID}" MATCHES "GNU") + # Do nothing; exit conditional without error if true + else() + message(FATAL_ERROR "Compiler is not GNU gcc! Aborting...") + endif() +endif() + +set(COVERAGE_COMPILER_FLAGS "-g -fprofile-arcs -ftest-coverage" + CACHE INTERNAL "") +if(CMAKE_CXX_COMPILER_ID MATCHES "(GNU|Clang)") + include(CheckCXXCompilerFlag) + check_cxx_compiler_flag(-fprofile-abs-path HAVE_fprofile_abs_path) + if(HAVE_fprofile_abs_path) + set(COVERAGE_COMPILER_FLAGS "${COVERAGE_COMPILER_FLAGS} -fprofile-abs-path") + endif() +endif() + +set(CMAKE_Fortran_FLAGS_COVERAGE + ${COVERAGE_COMPILER_FLAGS} + CACHE STRING "Flags used by the Fortran compiler during coverage builds." + FORCE ) +set(CMAKE_CXX_FLAGS_COVERAGE + ${COVERAGE_COMPILER_FLAGS} + CACHE STRING "Flags used by the C++ compiler during coverage builds." + FORCE ) +set(CMAKE_C_FLAGS_COVERAGE + ${COVERAGE_COMPILER_FLAGS} + CACHE STRING "Flags used by the C compiler during coverage builds." + FORCE ) +set(CMAKE_EXE_LINKER_FLAGS_COVERAGE + "" + CACHE STRING "Flags used for linking binaries during coverage builds." + FORCE ) +set(CMAKE_SHARED_LINKER_FLAGS_COVERAGE + "" + CACHE STRING "Flags used by the shared libraries linker during coverage builds." + FORCE ) +mark_as_advanced( + CMAKE_Fortran_FLAGS_COVERAGE + CMAKE_CXX_FLAGS_COVERAGE + CMAKE_C_FLAGS_COVERAGE + CMAKE_EXE_LINKER_FLAGS_COVERAGE + CMAKE_SHARED_LINKER_FLAGS_COVERAGE ) + +get_property(GENERATOR_IS_MULTI_CONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(NOT (CMAKE_BUILD_TYPE STREQUAL "Debug" OR GENERATOR_IS_MULTI_CONFIG)) + message(WARNING "Code coverage results with an optimised (non-Debug) build may be misleading") +endif() # NOT (CMAKE_BUILD_TYPE STREQUAL "Debug" OR GENERATOR_IS_MULTI_CONFIG) + +if(CMAKE_C_COMPILER_ID STREQUAL "GNU" OR CMAKE_Fortran_COMPILER_ID STREQUAL "GNU") + link_libraries(gcov) +endif() + +# Defines a target for running and collection code coverage information +# Builds dependencies, runs the given executable and outputs reports. +# NOTE! The executable should always have a ZERO as exit code otherwise +# the coverage generation will not complete. +# +# setup_target_for_coverage_lcov( +# NAME testrunner_coverage # New target name +# EXECUTABLE testrunner -j ${PROCESSOR_COUNT} # Executable in PROJECT_BINARY_DIR +# DEPENDENCIES testrunner # Dependencies to build first +# BASE_DIRECTORY "../" # Base directory for report +# # (defaults to PROJECT_SOURCE_DIR) +# EXCLUDE "src/dir1/*" "src/dir2/*" # Patterns to exclude (can be relative +# # to BASE_DIRECTORY, with CMake 3.4+) +# NO_DEMANGLE # Don't demangle C++ symbols +# # even if c++filt is found +# ) +function(setup_target_for_coverage_lcov) + + set(options NO_DEMANGLE) + set(oneValueArgs BASE_DIRECTORY NAME) + set(multiValueArgs EXCLUDE EXECUTABLE EXECUTABLE_ARGS DEPENDENCIES LCOV_ARGS GENHTML_ARGS) + cmake_parse_arguments(Coverage "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) + + if(NOT LCOV_PATH) + message(FATAL_ERROR "lcov not found! Aborting...") + endif() # NOT LCOV_PATH + + if(NOT GENHTML_PATH) + message(FATAL_ERROR "genhtml not found! Aborting...") + endif() # NOT GENHTML_PATH + + # Set base directory (as absolute path), or default to PROJECT_SOURCE_DIR + if(DEFINED Coverage_BASE_DIRECTORY) + get_filename_component(BASEDIR ${Coverage_BASE_DIRECTORY} ABSOLUTE) + else() + set(BASEDIR ${PROJECT_SOURCE_DIR}) + endif() + + # Collect excludes (CMake 3.4+: Also compute absolute paths) + set(LCOV_EXCLUDES "") + foreach(EXCLUDE ${Coverage_EXCLUDE} ${COVERAGE_EXCLUDES} ${COVERAGE_LCOV_EXCLUDES}) + if(CMAKE_VERSION VERSION_GREATER 3.4) + get_filename_component(EXCLUDE ${EXCLUDE} ABSOLUTE BASE_DIR ${BASEDIR}) + endif() + list(APPEND LCOV_EXCLUDES "${EXCLUDE}") + endforeach() + list(REMOVE_DUPLICATES LCOV_EXCLUDES) + + # Conditional arguments + if(CPPFILT_PATH AND NOT ${Coverage_NO_DEMANGLE}) + set(GENHTML_EXTRA_ARGS "--demangle-cpp") + endif() + + # Setting up commands which will be run to generate coverage data. + # Cleanup lcov + set(LCOV_CLEAN_CMD + ${LCOV_PATH} ${Coverage_LCOV_ARGS} --gcov-tool ${GCOV_PATH} -directory . + -b ${BASEDIR} --zerocounters + ) + # Create baseline to make sure untouched files show up in the report + set(LCOV_BASELINE_CMD + ${LCOV_PATH} ${Coverage_LCOV_ARGS} --gcov-tool ${GCOV_PATH} -c -i -d . -b + ${BASEDIR} -o ${Coverage_NAME}.base + ) + # Run tests + set(LCOV_EXEC_TESTS_CMD + ${Coverage_EXECUTABLE} ${Coverage_EXECUTABLE_ARGS} + ) + # Capturing lcov counters and generating report + set(LCOV_CAPTURE_CMD + ${LCOV_PATH} ${Coverage_LCOV_ARGS} --gcov-tool ${GCOV_PATH} --directory . -b + ${BASEDIR} --capture --output-file ${Coverage_NAME}.capture + ) + # add baseline counters + set(LCOV_BASELINE_COUNT_CMD + ${LCOV_PATH} ${Coverage_LCOV_ARGS} --gcov-tool ${GCOV_PATH} -a ${Coverage_NAME}.base + -a ${Coverage_NAME}.capture --output-file ${Coverage_NAME}.total + ) + # filter collected data to final coverage report + set(LCOV_FILTER_CMD + ${LCOV_PATH} ${Coverage_LCOV_ARGS} --gcov-tool ${GCOV_PATH} --remove + ${Coverage_NAME}.total ${LCOV_EXCLUDES} --output-file ${Coverage_NAME}.info + ) + # Generate HTML output + set(LCOV_GEN_HTML_CMD + ${GENHTML_PATH} ${GENHTML_EXTRA_ARGS} ${Coverage_GENHTML_ARGS} -o + ${Coverage_NAME} ${Coverage_NAME}.info + ) + + + if(CODE_COVERAGE_VERBOSE) + message(STATUS "Executed command report") + message(STATUS "Command to clean up lcov: ") + string(REPLACE ";" " " LCOV_CLEAN_CMD_SPACED "${LCOV_CLEAN_CMD}") + message(STATUS "${LCOV_CLEAN_CMD_SPACED}") + + message(STATUS "Command to create baseline: ") + string(REPLACE ";" " " LCOV_BASELINE_CMD_SPACED "${LCOV_BASELINE_CMD}") + message(STATUS "${LCOV_BASELINE_CMD_SPACED}") + + message(STATUS "Command to run the tests: ") + string(REPLACE ";" " " LCOV_EXEC_TESTS_CMD_SPACED "${LCOV_EXEC_TESTS_CMD}") + message(STATUS "${LCOV_EXEC_TESTS_CMD_SPACED}") + + message(STATUS "Command to capture counters and generate report: ") + string(REPLACE ";" " " LCOV_CAPTURE_CMD_SPACED "${LCOV_CAPTURE_CMD}") + message(STATUS "${LCOV_CAPTURE_CMD_SPACED}") + + message(STATUS "Command to add baseline counters: ") + string(REPLACE ";" " " LCOV_BASELINE_COUNT_CMD_SPACED "${LCOV_BASELINE_COUNT_CMD}") + message(STATUS "${LCOV_BASELINE_COUNT_CMD_SPACED}") + + message(STATUS "Command to filter collected data: ") + string(REPLACE ";" " " LCOV_FILTER_CMD_SPACED "${LCOV_FILTER_CMD}") + message(STATUS "${LCOV_FILTER_CMD_SPACED}") + + message(STATUS "Command to generate lcov HTML output: ") + string(REPLACE ";" " " LCOV_GEN_HTML_CMD_SPACED "${LCOV_GEN_HTML_CMD}") + message(STATUS "${LCOV_GEN_HTML_CMD_SPACED}") + endif() + + # Setup target + add_custom_target(${Coverage_NAME} + COMMAND ${LCOV_CLEAN_CMD} + COMMAND ${LCOV_BASELINE_CMD} + COMMAND ${LCOV_EXEC_TESTS_CMD} + COMMAND ${LCOV_CAPTURE_CMD} + COMMAND ${LCOV_BASELINE_COUNT_CMD} + COMMAND ${LCOV_FILTER_CMD} + COMMAND ${LCOV_GEN_HTML_CMD} + + # Set output files as GENERATED (will be removed on 'make clean') + BYPRODUCTS + ${Coverage_NAME}.base + ${Coverage_NAME}.capture + ${Coverage_NAME}.total + ${Coverage_NAME}.info + ${Coverage_NAME}/index.html + WORKING_DIRECTORY ${PROJECT_BINARY_DIR} + DEPENDS ${Coverage_DEPENDENCIES} + VERBATIM # Protect arguments to commands + COMMENT "Resetting code coverage counters to zero.\nProcessing code coverage counters and generating report." + ) + + # Show where to find the lcov info report + add_custom_command(TARGET ${Coverage_NAME} POST_BUILD + COMMAND ; + COMMENT "Lcov code coverage info report saved in ${Coverage_NAME}.info." + ) + + # Show info where to find the report + add_custom_command(TARGET ${Coverage_NAME} POST_BUILD + COMMAND ; + COMMENT "Open ./${Coverage_NAME}/index.html in your browser to view the coverage report." + ) + +endfunction() # setup_target_for_coverage_lcov + +# Defines a target for running and collection code coverage information +# Builds dependencies, runs the given executable and outputs reports. +# NOTE! The executable should always have a ZERO as exit code otherwise +# the coverage generation will not complete. +# +# setup_target_for_coverage_gcovr_xml( +# NAME ctest_coverage # New target name +# EXECUTABLE ctest -j ${PROCESSOR_COUNT} # Executable in PROJECT_BINARY_DIR +# DEPENDENCIES executable_target # Dependencies to build first +# BASE_DIRECTORY "../" # Base directory for report +# # (defaults to PROJECT_SOURCE_DIR) +# EXCLUDE "src/dir1/*" "src/dir2/*" # Patterns to exclude (can be relative +# # to BASE_DIRECTORY, with CMake 3.4+) +# ) +# The user can set the variable GCOVR_ADDITIONAL_ARGS to supply additional flags to the +# GCVOR command. +function(setup_target_for_coverage_gcovr_xml) + + set(options NONE) + set(oneValueArgs BASE_DIRECTORY NAME) + set(multiValueArgs EXCLUDE EXECUTABLE EXECUTABLE_ARGS DEPENDENCIES) + cmake_parse_arguments(Coverage "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) + + if(NOT GCOVR_PATH) + message(FATAL_ERROR "gcovr not found! Aborting...") + endif() # NOT GCOVR_PATH + + # Set base directory (as absolute path), or default to PROJECT_SOURCE_DIR + if(DEFINED Coverage_BASE_DIRECTORY) + get_filename_component(BASEDIR ${Coverage_BASE_DIRECTORY} ABSOLUTE) + else() + set(BASEDIR ${PROJECT_SOURCE_DIR}) + endif() + + # Collect excludes (CMake 3.4+: Also compute absolute paths) + set(GCOVR_EXCLUDES "") + foreach(EXCLUDE ${Coverage_EXCLUDE} ${COVERAGE_EXCLUDES} ${COVERAGE_GCOVR_EXCLUDES}) + if(CMAKE_VERSION VERSION_GREATER 3.4) + get_filename_component(EXCLUDE ${EXCLUDE} ABSOLUTE BASE_DIR ${BASEDIR}) + endif() + list(APPEND GCOVR_EXCLUDES "${EXCLUDE}") + endforeach() + list(REMOVE_DUPLICATES GCOVR_EXCLUDES) + + # Combine excludes to several -e arguments + set(GCOVR_EXCLUDE_ARGS "") + foreach(EXCLUDE ${GCOVR_EXCLUDES}) + list(APPEND GCOVR_EXCLUDE_ARGS "-e") + list(APPEND GCOVR_EXCLUDE_ARGS "${EXCLUDE}") + endforeach() + + # Set up commands which will be run to generate coverage data + # Run tests + set(GCOVR_XML_EXEC_TESTS_CMD + ${Coverage_EXECUTABLE} ${Coverage_EXECUTABLE_ARGS} + ) + # Running gcovr + set(GCOVR_XML_CMD + ${GCOVR_PATH} --xml ${Coverage_NAME}.xml -r ${BASEDIR} ${GCOVR_ADDITIONAL_ARGS} + ${GCOVR_EXCLUDE_ARGS} --object-directory=${PROJECT_BINARY_DIR} + ) + + if(CODE_COVERAGE_VERBOSE) + message(STATUS "Executed command report") + + message(STATUS "Command to run tests: ") + string(REPLACE ";" " " GCOVR_XML_EXEC_TESTS_CMD_SPACED "${GCOVR_XML_EXEC_TESTS_CMD}") + message(STATUS "${GCOVR_XML_EXEC_TESTS_CMD_SPACED}") + + message(STATUS "Command to generate gcovr XML coverage data: ") + string(REPLACE ";" " " GCOVR_XML_CMD_SPACED "${GCOVR_XML_CMD}") + message(STATUS "${GCOVR_XML_CMD_SPACED}") + endif() + + add_custom_target(${Coverage_NAME} + COMMAND ${GCOVR_XML_EXEC_TESTS_CMD} + COMMAND ${GCOVR_XML_CMD} + + BYPRODUCTS ${Coverage_NAME}.xml + WORKING_DIRECTORY ${PROJECT_BINARY_DIR} + DEPENDS ${Coverage_DEPENDENCIES} + VERBATIM # Protect arguments to commands + COMMENT "Running gcovr to produce Cobertura code coverage report." + ) + + # Show info where to find the report + add_custom_command(TARGET ${Coverage_NAME} POST_BUILD + COMMAND ; + COMMENT "Cobertura code coverage report saved in ${Coverage_NAME}.xml." + ) +endfunction() # setup_target_for_coverage_gcovr_xml + +# Defines a target for running and collection code coverage information +# Builds dependencies, runs the given executable and outputs reports. +# NOTE! The executable should always have a ZERO as exit code otherwise +# the coverage generation will not complete. +# +# setup_target_for_coverage_gcovr_html( +# NAME ctest_coverage # New target name +# EXECUTABLE ctest -j ${PROCESSOR_COUNT} # Executable in PROJECT_BINARY_DIR +# DEPENDENCIES executable_target # Dependencies to build first +# BASE_DIRECTORY "../" # Base directory for report +# # (defaults to PROJECT_SOURCE_DIR) +# EXCLUDE "src/dir1/*" "src/dir2/*" # Patterns to exclude (can be relative +# # to BASE_DIRECTORY, with CMake 3.4+) +# ) +# The user can set the variable GCOVR_ADDITIONAL_ARGS to supply additional flags to the +# GCVOR command. +function(setup_target_for_coverage_gcovr_html) + + set(options NONE) + set(oneValueArgs BASE_DIRECTORY NAME) + set(multiValueArgs EXCLUDE EXECUTABLE EXECUTABLE_ARGS DEPENDENCIES) + cmake_parse_arguments(Coverage "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) + + if(NOT GCOVR_PATH) + message(FATAL_ERROR "gcovr not found! Aborting...") + endif() # NOT GCOVR_PATH + + # Set base directory (as absolute path), or default to PROJECT_SOURCE_DIR + if(DEFINED Coverage_BASE_DIRECTORY) + get_filename_component(BASEDIR ${Coverage_BASE_DIRECTORY} ABSOLUTE) + else() + set(BASEDIR ${PROJECT_SOURCE_DIR}) + endif() + + # Collect excludes (CMake 3.4+: Also compute absolute paths) + set(GCOVR_EXCLUDES "") + foreach(EXCLUDE ${Coverage_EXCLUDE} ${COVERAGE_EXCLUDES} ${COVERAGE_GCOVR_EXCLUDES}) + if(CMAKE_VERSION VERSION_GREATER 3.4) + get_filename_component(EXCLUDE ${EXCLUDE} ABSOLUTE BASE_DIR ${BASEDIR}) + endif() + list(APPEND GCOVR_EXCLUDES "${EXCLUDE}") + endforeach() + list(REMOVE_DUPLICATES GCOVR_EXCLUDES) + + # Combine excludes to several -e arguments + set(GCOVR_EXCLUDE_ARGS "") + foreach(EXCLUDE ${GCOVR_EXCLUDES}) + list(APPEND GCOVR_EXCLUDE_ARGS "-e") + list(APPEND GCOVR_EXCLUDE_ARGS "${EXCLUDE}") + endforeach() + + # Set up commands which will be run to generate coverage data + # Run tests + set(GCOVR_HTML_EXEC_TESTS_CMD + ${Coverage_EXECUTABLE} ${Coverage_EXECUTABLE_ARGS} + ) + # Create folder + set(GCOVR_HTML_FOLDER_CMD + ${CMAKE_COMMAND} -E make_directory ${PROJECT_BINARY_DIR}/${Coverage_NAME} + ) + # Running gcovr + set(GCOVR_HTML_CMD + ${GCOVR_PATH} --html ${Coverage_NAME}/index.html --html-details -r ${BASEDIR} ${GCOVR_ADDITIONAL_ARGS} + ${GCOVR_EXCLUDE_ARGS} --object-directory=${PROJECT_BINARY_DIR} + ) + + if(CODE_COVERAGE_VERBOSE) + message(STATUS "Executed command report") + + message(STATUS "Command to run tests: ") + string(REPLACE ";" " " GCOVR_HTML_EXEC_TESTS_CMD_SPACED "${GCOVR_HTML_EXEC_TESTS_CMD}") + message(STATUS "${GCOVR_HTML_EXEC_TESTS_CMD_SPACED}") + + message(STATUS "Command to create a folder: ") + string(REPLACE ";" " " GCOVR_HTML_FOLDER_CMD_SPACED "${GCOVR_HTML_FOLDER_CMD}") + message(STATUS "${GCOVR_HTML_FOLDER_CMD_SPACED}") + + message(STATUS "Command to generate gcovr HTML coverage data: ") + string(REPLACE ";" " " GCOVR_HTML_CMD_SPACED "${GCOVR_HTML_CMD}") + message(STATUS "${GCOVR_HTML_CMD_SPACED}") + endif() + + add_custom_target(${Coverage_NAME} + COMMAND ${GCOVR_HTML_EXEC_TESTS_CMD} + COMMAND ${GCOVR_HTML_FOLDER_CMD} + COMMAND ${GCOVR_HTML_CMD} + + BYPRODUCTS ${PROJECT_BINARY_DIR}/${Coverage_NAME}/index.html # report directory + WORKING_DIRECTORY ${PROJECT_BINARY_DIR} + DEPENDS ${Coverage_DEPENDENCIES} + VERBATIM # Protect arguments to commands + COMMENT "Running gcovr to produce HTML code coverage report." + ) + + # Show info where to find the report + add_custom_command(TARGET ${Coverage_NAME} POST_BUILD + COMMAND ; + COMMENT "Open ./${Coverage_NAME}/index.html in your browser to view the coverage report." + ) + +endfunction() # setup_target_for_coverage_gcovr_html + +# Defines a target for running and collection code coverage information +# Builds dependencies, runs the given executable and outputs reports. +# NOTE! The executable should always have a ZERO as exit code otherwise +# the coverage generation will not complete. +# +# setup_target_for_coverage_fastcov( +# NAME testrunner_coverage # New target name +# EXECUTABLE testrunner -j ${PROCESSOR_COUNT} # Executable in PROJECT_BINARY_DIR +# DEPENDENCIES testrunner # Dependencies to build first +# BASE_DIRECTORY "../" # Base directory for report +# # (defaults to PROJECT_SOURCE_DIR) +# EXCLUDE "src/dir1/" "src/dir2/" # Patterns to exclude. +# NO_DEMANGLE # Don't demangle C++ symbols +# # even if c++filt is found +# SKIP_HTML # Don't create html report +# POST_CMD perl -i -pe s!${PROJECT_SOURCE_DIR}/!!g ctest_coverage.json # E.g. for stripping source dir from file paths +# ) +function(setup_target_for_coverage_fastcov) + + set(options NO_DEMANGLE SKIP_HTML) + set(oneValueArgs BASE_DIRECTORY NAME) + set(multiValueArgs EXCLUDE EXECUTABLE EXECUTABLE_ARGS DEPENDENCIES FASTCOV_ARGS GENHTML_ARGS POST_CMD) + cmake_parse_arguments(Coverage "${options}" "${oneValueArgs}" "${multiValueArgs}" ${ARGN}) + + if(NOT FASTCOV_PATH) + message(FATAL_ERROR "fastcov not found! Aborting...") + endif() + + if(NOT Coverage_SKIP_HTML AND NOT GENHTML_PATH) + message(FATAL_ERROR "genhtml not found! Aborting...") + endif() + + # Set base directory (as absolute path), or default to PROJECT_SOURCE_DIR + if(Coverage_BASE_DIRECTORY) + get_filename_component(BASEDIR ${Coverage_BASE_DIRECTORY} ABSOLUTE) + else() + set(BASEDIR ${PROJECT_SOURCE_DIR}) + endif() + + # Collect excludes (Patterns, not paths, for fastcov) + set(FASTCOV_EXCLUDES "") + foreach(EXCLUDE ${Coverage_EXCLUDE} ${COVERAGE_EXCLUDES} ${COVERAGE_FASTCOV_EXCLUDES}) + list(APPEND FASTCOV_EXCLUDES "${EXCLUDE}") + endforeach() + list(REMOVE_DUPLICATES FASTCOV_EXCLUDES) + + # Conditional arguments + if(CPPFILT_PATH AND NOT ${Coverage_NO_DEMANGLE}) + set(GENHTML_EXTRA_ARGS "--demangle-cpp") + endif() + + # Set up commands which will be run to generate coverage data + set(FASTCOV_EXEC_TESTS_CMD ${Coverage_EXECUTABLE} ${Coverage_EXECUTABLE_ARGS}) + + set(FASTCOV_CAPTURE_CMD ${FASTCOV_PATH} ${Coverage_FASTCOV_ARGS} --gcov ${GCOV_PATH} + --search-directory ${BASEDIR} + --process-gcno + --output ${Coverage_NAME}.json + --exclude ${FASTCOV_EXCLUDES} + --exclude ${FASTCOV_EXCLUDES} + ) + + set(FASTCOV_CONVERT_CMD ${FASTCOV_PATH} + -C ${Coverage_NAME}.json --lcov --output ${Coverage_NAME}.info + ) + + if(Coverage_SKIP_HTML) + set(FASTCOV_HTML_CMD ";") + else() + set(FASTCOV_HTML_CMD ${GENHTML_PATH} ${GENHTML_EXTRA_ARGS} ${Coverage_GENHTML_ARGS} + -o ${Coverage_NAME} ${Coverage_NAME}.info + ) + endif() + + set(FASTCOV_POST_CMD ";") + if(Coverage_POST_CMD) + set(FASTCOV_POST_CMD ${Coverage_POST_CMD}) + endif() + + if(CODE_COVERAGE_VERBOSE) + message(STATUS "Code coverage commands for target ${Coverage_NAME} (fastcov):") + + message(" Running tests:") + string(REPLACE ";" " " FASTCOV_EXEC_TESTS_CMD_SPACED "${FASTCOV_EXEC_TESTS_CMD}") + message(" ${FASTCOV_EXEC_TESTS_CMD_SPACED}") + + message(" Capturing fastcov counters and generating report:") + string(REPLACE ";" " " FASTCOV_CAPTURE_CMD_SPACED "${FASTCOV_CAPTURE_CMD}") + message(" ${FASTCOV_CAPTURE_CMD_SPACED}") + + message(" Converting fastcov .json to lcov .info:") + string(REPLACE ";" " " FASTCOV_CONVERT_CMD_SPACED "${FASTCOV_CONVERT_CMD}") + message(" ${FASTCOV_CONVERT_CMD_SPACED}") + + if(NOT Coverage_SKIP_HTML) + message(" Generating HTML report: ") + string(REPLACE ";" " " FASTCOV_HTML_CMD_SPACED "${FASTCOV_HTML_CMD}") + message(" ${FASTCOV_HTML_CMD_SPACED}") + endif() + if(Coverage_POST_CMD) + message(" Running post command: ") + string(REPLACE ";" " " FASTCOV_POST_CMD_SPACED "${FASTCOV_POST_CMD}") + message(" ${FASTCOV_POST_CMD_SPACED}") + endif() + endif() + + # Setup target + add_custom_target(${Coverage_NAME} + + # Cleanup fastcov + COMMAND ${FASTCOV_PATH} ${Coverage_FASTCOV_ARGS} --gcov ${GCOV_PATH} + --search-directory ${BASEDIR} + --zerocounters + + COMMAND ${FASTCOV_EXEC_TESTS_CMD} + COMMAND ${FASTCOV_CAPTURE_CMD} + COMMAND ${FASTCOV_CONVERT_CMD} + COMMAND ${FASTCOV_HTML_CMD} + COMMAND ${FASTCOV_POST_CMD} + + # Set output files as GENERATED (will be removed on 'make clean') + BYPRODUCTS + ${Coverage_NAME}.info + ${Coverage_NAME}.json + ${Coverage_NAME}/index.html # report directory + + WORKING_DIRECTORY ${PROJECT_BINARY_DIR} + DEPENDS ${Coverage_DEPENDENCIES} + VERBATIM # Protect arguments to commands + COMMENT "Resetting code coverage counters to zero. Processing code coverage counters and generating report." + ) + + set(INFO_MSG "fastcov code coverage info report saved in ${Coverage_NAME}.info and ${Coverage_NAME}.json.") + if(NOT Coverage_SKIP_HTML) + string(APPEND INFO_MSG " Open ${PROJECT_BINARY_DIR}/${Coverage_NAME}/index.html in your browser to view the coverage report.") + endif() + # Show where to find the fastcov info report + add_custom_command(TARGET ${Coverage_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E echo ${INFO_MSG} + ) + +endfunction() # setup_target_for_coverage_fastcov + +function(append_coverage_compiler_flags) + set(CMAKE_C_FLAGS "${CMAKE_C_FLAGS} ${COVERAGE_COMPILER_FLAGS}" PARENT_SCOPE) + set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} ${COVERAGE_COMPILER_FLAGS}" PARENT_SCOPE) + set(CMAKE_Fortran_FLAGS "${CMAKE_Fortran_FLAGS} ${COVERAGE_COMPILER_FLAGS}" PARENT_SCOPE) + message(STATUS "Appending code coverage compiler flags: ${COVERAGE_COMPILER_FLAGS}") +endfunction() # append_coverage_compiler_flags + +# Setup coverage for specific library +function(append_coverage_compiler_flags_to_target name) + separate_arguments(_flag_list NATIVE_COMMAND "${COVERAGE_COMPILER_FLAGS}") + target_compile_options(${name} PRIVATE ${_flag_list}) +endfunction() diff --git a/src/Application.cpp b/src/Application.cpp index de2d49d84..a17c53b48 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -216,6 +216,11 @@ int Application::run(QApplication &qtApp) return qtApp.exec(); } +IEmotes *Application::getEmotes() +{ + return this->emotes; +} + void Application::save() { for (auto &singleton : this->singletons_) diff --git a/src/Application.hpp b/src/Application.hpp index 4d5bc93a2..86af27753 100644 --- a/src/Application.hpp +++ b/src/Application.hpp @@ -24,6 +24,7 @@ class Logging; class Paths; class AccountManager; class Emotes; +class IEmotes; class Settings; class Fonts; class Toasts; @@ -41,7 +42,7 @@ public: virtual Theme *getThemes() = 0; virtual Fonts *getFonts() = 0; - virtual Emotes *getEmotes() = 0; + virtual IEmotes *getEmotes() = 0; virtual AccountController *getAccounts() = 0; virtual HotkeyController *getHotkeys() = 0; virtual WindowManager *getWindows() = 0; @@ -99,10 +100,7 @@ public: { return this->fonts; } - Emotes *getEmotes() override - { - return this->emotes; - } + IEmotes *getEmotes() override; AccountController *getAccounts() override { return this->accounts; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 8c5450d93..48f9d4df7 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -542,6 +542,21 @@ source_group(TREE ${CMAKE_SOURCE_DIR} FILES ${SOURCE_FILES}) add_library(${LIBRARY_PROJECT} OBJECT ${SOURCE_FILES}) +if (CHATTERINO_GENERATE_COVERAGE) + include(CodeCoverage) + append_coverage_compiler_flags_to_target(${LIBRARY_PROJECT}) + target_link_libraries(${LIBRARY_PROJECT} PUBLIC gcov) + message(STATUS "project source dir: ${PROJECT_SOURCE_DIR}/src") + setup_target_for_coverage_lcov( + NAME coverage + EXECUTABLE ./bin/chatterino-test + BASE_DIRECTORY ${PROJECT_SOURCE_DIR}/src + EXCLUDE "/usr/include/*" + EXCLUDE "build-*/*" + EXCLUDE "lib/*" + ) +endif () + target_link_libraries(${LIBRARY_PROJECT} PUBLIC Qt${MAJOR_QT_VERSION}::Core diff --git a/src/common/SignalVector.hpp b/src/common/SignalVector.hpp index 96dfbe1c0..3eff6680a 100644 --- a/src/common/SignalVector.hpp +++ b/src/common/SignalVector.hpp @@ -38,10 +38,10 @@ public: SignalVector(std::function &&compare) : SignalVector() { - itemCompare_ = std::move(compare); + this->itemCompare_ = std::move(compare); } - virtual bool isSorted() const + bool isSorted() const { return bool(this->itemCompare_); } @@ -76,9 +76,13 @@ public: else { if (index == -1) + { index = this->items_.size(); + } else + { assert(index >= 0 && index <= this->items_.size()); + } this->items_.insert(this->items_.begin() + index, item); } diff --git a/src/providers/twitch/TwitchEmotes.cpp b/src/providers/twitch/TwitchEmotes.cpp index 9a84bcabf..890b19686 100644 --- a/src/providers/twitch/TwitchEmotes.cpp +++ b/src/providers/twitch/TwitchEmotes.cpp @@ -1,17 +1,10 @@ #include "providers/twitch/TwitchEmotes.hpp" -#include "common/NetworkRequest.hpp" -#include "debug/Benchmark.hpp" #include "messages/Emote.hpp" #include "messages/Image.hpp" -#include "util/RapidjsonHelpers.hpp" namespace chatterino { -TwitchEmotes::TwitchEmotes() -{ -} - QString TwitchEmotes::cleanUpEmoteCode(const QString &dirtyEmoteCode) { auto cleanCode = dirtyEmoteCode; diff --git a/src/providers/twitch/TwitchEmotes.hpp b/src/providers/twitch/TwitchEmotes.hpp index 437157d33..dca7e67f4 100644 --- a/src/providers/twitch/TwitchEmotes.hpp +++ b/src/providers/twitch/TwitchEmotes.hpp @@ -33,13 +33,23 @@ struct CheerEmoteSet { std::vector cheerEmotes; }; -class TwitchEmotes +class ITwitchEmotes +{ +public: + virtual ~ITwitchEmotes() = default; + + virtual EmotePtr getOrCreateEmote(const EmoteId &id, + const EmoteName &name) = 0; +}; + +class TwitchEmotes : public ITwitchEmotes { public: static QString cleanUpEmoteCode(const QString &dirtyEmoteCode); - TwitchEmotes(); + TwitchEmotes() = default; - EmotePtr getOrCreateEmote(const EmoteId &id, const EmoteName &name); + EmotePtr getOrCreateEmote(const EmoteId &id, + const EmoteName &name) override; private: Url getEmoteLink(const EmoteId &id, const QString &emoteScale); diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index c00c5bcfc..91e0c7535 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -107,6 +107,77 @@ namespace { return usernameText; } + void appendTwitchEmoteOccurrences(const QString &emote, + std::vector &vec, + const std::vector &correctPositions, + const QString &originalMessage, + int messageOffset) + { + auto *app = getIApp(); + if (!emote.contains(':')) + { + return; + } + + auto parameters = emote.split(':'); + + if (parameters.length() < 2) + { + return; + } + + auto id = EmoteId{parameters.at(0)}; + + auto occurrences = parameters.at(1).split(','); + + for (const QString &occurrence : occurrences) + { + auto coords = occurrence.split('-'); + + if (coords.length() < 2) + { + return; + } + + auto from = coords.at(0).toUInt() - messageOffset; + auto to = coords.at(1).toUInt() - messageOffset; + auto maxPositions = correctPositions.size(); + if (from > to || to >= maxPositions) + { + // Emote coords are out of range + qCDebug(chatterinoTwitch) + << "Emote coords" << from << "-" << to + << "are out of range (" << maxPositions << ")"; + return; + } + + auto start = correctPositions[from]; + auto end = correctPositions[to]; + if (start > end || start < 0 || end > originalMessage.length()) + { + // Emote coords are out of range from the modified character positions + qCDebug(chatterinoTwitch) << "Emote coords" << from << "-" << to + << "are out of range after offsets (" + << originalMessage.length() << ")"; + return; + } + + auto name = EmoteName{originalMessage.mid(start, end - start + 1)}; + TwitchEmoteOccurrence emoteOccurrence{ + start, + end, + app->getEmotes()->getTwitchEmotes()->getOrCreateEmote(id, name), + name, + }; + if (emoteOccurrence.ptr == nullptr) + { + qCDebug(chatterinoTwitch) + << "nullptr" << emoteOccurrence.name.string; + } + vec.push_back(std::move(emoteOccurrence)); + } + } + } // namespace TwitchMessageBuilder::TwitchMessageBuilder( @@ -304,25 +375,8 @@ MessagePtr TwitchMessageBuilder::build() } // Twitch emotes - std::vector twitchEmotes; - - iterator = this->tags.find("emotes"); - if (iterator != this->tags.end()) - { - QStringList emoteString = iterator.value().toString().split('/'); - std::vector correctPositions; - for (int i = 0; i < this->originalMessage_.size(); ++i) - { - if (!this->originalMessage_.at(i).isLowSurrogate()) - { - correctPositions.push_back(i); - } - } - for (QString emote : emoteString) - { - this->appendTwitchEmote(emote, twitchEmotes, correctPositions); - } - } + auto twitchEmotes = TwitchMessageBuilder::parseTwitchEmotes( + this->tags, this->originalMessage_, this->messageOffset_); // This runs through all ignored phrases and runs its replacements on this->originalMessage_ this->runIgnoreReplaces(twitchEmotes); @@ -379,8 +433,8 @@ MessagePtr TwitchMessageBuilder::build() bool doesWordContainATwitchEmote( int cursor, const QString &word, - const std::vector &twitchEmotes, - std::vector::const_iterator ¤tTwitchEmoteIt) + const std::vector &twitchEmotes, + std::vector::const_iterator ¤tTwitchEmoteIt) { if (currentTwitchEmoteIt == twitchEmotes.end()) { @@ -404,7 +458,7 @@ bool doesWordContainATwitchEmote( void TwitchMessageBuilder::addWords( const QStringList &words, - const std::vector &twitchEmotes) + const std::vector &twitchEmotes) { // cursor currently indicates what character index we're currently operating in the full list of words int cursor = 0; @@ -762,7 +816,7 @@ void TwitchMessageBuilder::appendUsername() } void TwitchMessageBuilder::runIgnoreReplaces( - std::vector &twitchEmotes) + std::vector &twitchEmotes) { auto phrases = getCSettings().ignoredMessages.readOnly(); auto removeEmotesInRange = [](int pos, int len, @@ -780,7 +834,7 @@ void TwitchMessageBuilder::runIgnoreReplaces( << "remem nullptr" << (*copy).name.string; } } - std::vector v(it, twitchEmotes.end()); + std::vector v(it, twitchEmotes.end()); twitchEmotes.erase(it, twitchEmotes.end()); return v; }; @@ -818,7 +872,7 @@ void TwitchMessageBuilder::runIgnoreReplaces( qCDebug(chatterinoTwitch) << "emote null" << emote.first.string; } - twitchEmotes.push_back(TwitchEmoteOccurence{ + twitchEmotes.push_back(TwitchEmoteOccurrence{ startIndex + pos, startIndex + pos + emote.first.string.length(), emote.second, @@ -980,59 +1034,6 @@ void TwitchMessageBuilder::runIgnoreReplaces( } } -void TwitchMessageBuilder::appendTwitchEmote( - const QString &emote, std::vector &vec, - std::vector &correctPositions) -{ - auto app = getApp(); - if (!emote.contains(':')) - { - return; - } - - auto parameters = emote.split(':'); - - if (parameters.length() < 2) - { - return; - } - - auto id = EmoteId{parameters.at(0)}; - - auto occurences = parameters.at(1).split(','); - - for (QString occurence : occurences) - { - auto coords = occurence.split('-'); - - if (coords.length() < 2) - { - return; - } - - auto start = - correctPositions[coords.at(0).toUInt() - this->messageOffset_]; - auto end = - correctPositions[coords.at(1).toUInt() - this->messageOffset_]; - - if (start >= end || start < 0 || end > this->originalMessage_.length()) - { - return; - } - - auto name = - EmoteName{this->originalMessage_.mid(start, end - start + 1)}; - TwitchEmoteOccurence emoteOccurence{ - start, end, app->emotes->twitch.getOrCreateEmote(id, name), name}; - if (emoteOccurence.ptr == nullptr) - { - qCDebug(chatterinoTwitch) - << "nullptr" << emoteOccurence.name.string; - } - vec.push_back(std::move(emoteOccurence)); - } -} - Outcome TwitchMessageBuilder::tryAppendEmote(const EmoteName &name) { auto *app = getApp(); @@ -1137,6 +1138,37 @@ std::unordered_map TwitchMessageBuilder::parseBadgeInfoTag( return infoMap; } +std::vector TwitchMessageBuilder::parseTwitchEmotes( + const QVariantMap &tags, const QString &originalMessage, int messageOffset) +{ + // Twitch emotes + std::vector twitchEmotes; + + auto emotesTag = tags.find("emotes"); + + if (emotesTag == tags.end()) + { + return twitchEmotes; + } + + QStringList emoteString = emotesTag.value().toString().split('/'); + std::vector correctPositions; + for (int i = 0; i < originalMessage.size(); ++i) + { + if (!originalMessage.at(i).isLowSurrogate()) + { + correctPositions.push_back(i); + } + } + for (const QString &emote : emoteString) + { + appendTwitchEmoteOccurrences(emote, twitchEmotes, correctPositions, + originalMessage, messageOffset); + } + + return twitchEmotes; +} + void TwitchMessageBuilder::appendTwitchBadges() { if (this->twitchChannel == nullptr) diff --git a/src/providers/twitch/TwitchMessageBuilder.hpp b/src/providers/twitch/TwitchMessageBuilder.hpp index 58ef17713..7e01f9004 100644 --- a/src/providers/twitch/TwitchMessageBuilder.hpp +++ b/src/providers/twitch/TwitchMessageBuilder.hpp @@ -20,11 +20,17 @@ using EmotePtr = std::shared_ptr; class Channel; class TwitchChannel; -struct TwitchEmoteOccurence { +struct TwitchEmoteOccurrence { int start; int end; EmotePtr ptr; EmoteName name; + + bool operator==(const TwitchEmoteOccurrence &other) const + { + return std::tie(this->start, this->end, this->ptr, this->name) == + std::tie(other.start, other.end, other.ptr, other.name); + } }; class TwitchMessageBuilder : public SharedMessageBuilder @@ -76,6 +82,10 @@ public: static std::unordered_map parseBadgeInfoTag( const QVariantMap &tags); + static std::vector parseTwitchEmotes( + const QVariantMap &tags, const QString &originalMessage, + int messageOffset); + private: void parseUsernameColor() override; void parseUsername() override; @@ -83,16 +93,13 @@ private: void parseRoomID(); void appendUsername(); - void runIgnoreReplaces(std::vector &twitchEmotes); + void runIgnoreReplaces(std::vector &twitchEmotes); boost::optional getTwitchBadge(const Badge &badge); - void appendTwitchEmote(const QString &emote, - std::vector &vec, - std::vector &correctPositions); Outcome tryAppendEmote(const EmoteName &name) override; void addWords(const QStringList &words, - const std::vector &twitchEmotes); + const std::vector &twitchEmotes); void addTextOrEmoji(EmotePtr emote) override; void addTextOrEmoji(const QString &value) override; diff --git a/src/singletons/Emotes.hpp b/src/singletons/Emotes.hpp index 51faac660..13a2046f5 100644 --- a/src/singletons/Emotes.hpp +++ b/src/singletons/Emotes.hpp @@ -14,7 +14,15 @@ namespace chatterino { class Settings; class Paths; -class Emotes final : public Singleton +class IEmotes +{ +public: + virtual ~IEmotes() = default; + + virtual ITwitchEmotes *getTwitchEmotes() = 0; +}; + +class Emotes final : public IEmotes, public Singleton { public: Emotes(); @@ -23,6 +31,11 @@ public: bool isIgnoredEmote(const QString &emote); + ITwitchEmotes *getTwitchEmotes() final + { + return &this->twitch; + } + TwitchEmotes twitch; Emojis emojis; diff --git a/src/widgets/dialogs/switcher/QuickSwitcherModel.hpp b/src/widgets/dialogs/switcher/QuickSwitcherModel.hpp index 86fc9f9ca..be1a9c4a5 100644 --- a/src/widgets/dialogs/switcher/QuickSwitcherModel.hpp +++ b/src/widgets/dialogs/switcher/QuickSwitcherModel.hpp @@ -7,4 +7,4 @@ namespace chatterino { using QuickSwitcherModel = GenericListModel; -} +} // namespace chatterino diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index 00870e6b7..7df5084d3 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -16,6 +16,8 @@ using namespace chatterino; using ::testing::Exactly; +namespace { + class MockApplication : IApplication { public: @@ -27,7 +29,7 @@ public: { return nullptr; } - Emotes *getEmotes() override + IEmotes *getEmotes() override { return nullptr; } @@ -77,6 +79,8 @@ public: // TODO: Figure this out }; +} // namespace + class MockHelix : public IHelix { public: diff --git a/tests/src/TwitchMessageBuilder.cpp b/tests/src/TwitchMessageBuilder.cpp index 24eb42641..4b534641e 100644 --- a/tests/src/TwitchMessageBuilder.cpp +++ b/tests/src/TwitchMessageBuilder.cpp @@ -1,8 +1,10 @@ #include "providers/twitch/TwitchMessageBuilder.hpp" +#include "Application.hpp" #include "common/Channel.hpp" #include "messages/MessageBuilder.hpp" #include "providers/twitch/TwitchBadge.hpp" +#include "singletons/Emotes.hpp" #include "ircconnection.h" @@ -14,6 +16,69 @@ using namespace chatterino; +namespace { + +class MockApplication : IApplication +{ +public: + Theme *getThemes() override + { + return nullptr; + } + Fonts *getFonts() override + { + return nullptr; + } + IEmotes *getEmotes() override + { + return &this->emotes; + } + AccountController *getAccounts() override + { + return nullptr; + } + HotkeyController *getHotkeys() override + { + return nullptr; + } + WindowManager *getWindows() override + { + return nullptr; + } + Toasts *getToasts() override + { + return nullptr; + } + CommandController *getCommands() override + { + return nullptr; + } + NotificationController *getNotifications() override + { + return nullptr; + } + HighlightController *getHighlights() override + { + return nullptr; + } + TwitchIrcServer *getTwitch() override + { + return nullptr; + } + ChatterinoBadges *getChatterinoBadges() override + { + return nullptr; + } + FfzBadges *getFfzBadges() override + { + return nullptr; + } + + Emotes emotes; +}; + +} // namespace + TEST(TwitchMessageBuilder, CommaSeparatedListTagParsing) { struct TestCase { @@ -57,6 +122,22 @@ TEST(TwitchMessageBuilder, CommaSeparatedListTagParsing) } } +class TestTwitchMessageBuilder : public ::testing::Test +{ +protected: + void SetUp() override + { + this->mockApplication = std::make_unique(); + } + + void TearDown() override + { + this->mockApplication.reset(); + } + + std::unique_ptr mockApplication; +}; + TEST(TwitchMessageBuilder, BadgeInfoParsing) { struct TestCase { @@ -128,3 +209,180 @@ TEST(TwitchMessageBuilder, BadgeInfoParsing) << "Input for badges " << test.input.toStdString() << " failed"; } } + +TEST_F(TestTwitchMessageBuilder, ParseTwitchEmotes) +{ + struct TestCase { + QByteArray input; + std::vector expectedTwitchEmotes; + }; + + auto *twitchEmotes = this->mockApplication->getEmotes()->getTwitchEmotes(); + + std::vector testCases{ + { + // action /me message + R"(@badge-info=subscriber/80;badges=broadcaster/1,subscriber/3072,partner/1;color=#CC44FF;display-name=pajlada;emote-only=1;emotes=25:0-4;first-msg=0;flags=;id=90ef1e46-8baa-4bf2-9c54-272f39d6fa11;mod=0;returning-chatter=0;room-id=11148817;subscriber=1;tmi-sent-ts=1662206235860;turbo=0;user-id=11148817;user-type= :pajlada!pajlada@pajlada.tmi.twitch.tv PRIVMSG #pajlada :ACTION Kappa)", + { + {{ + 0, // start + 4, // end + twitchEmotes->getOrCreateEmote(EmoteId{"25"}, + EmoteName{"Kappa"}), // ptr + EmoteName{"Kappa"}, // name + }}, + }, + }, + { + R"(@badge-info=subscriber/17;badges=subscriber/12,no_audio/1;color=#EBA2C0;display-name=jammehcow;emote-only=1;emotes=25:0-4;first-msg=0;flags=;id=9c2dd916-5a6d-4c1f-9fe7-a081b62a9c6b;mod=0;returning-chatter=0;room-id=11148817;subscriber=1;tmi-sent-ts=1662201093248;turbo=0;user-id=82674227;user-type= :jammehcow!jammehcow@jammehcow.tmi.twitch.tv PRIVMSG #pajlada :Kappa)", + { + {{ + 0, // start + 4, // end + twitchEmotes->getOrCreateEmote(EmoteId{"25"}, + EmoteName{"Kappa"}), // ptr + EmoteName{"Kappa"}, // name + }}, + }, + }, + { + R"(@badge-info=;badges=no_audio/1;color=#DAA520;display-name=Mm2PL;emote-only=1;emotes=1902:0-4;first-msg=0;flags=;id=9b1c3cb9-7817-47ea-add1-f9d4a9b4f846;mod=0;returning-chatter=0;room-id=11148817;subscriber=0;tmi-sent-ts=1662201095690;turbo=0;user-id=117691339;user-type= :mm2pl!mm2pl@mm2pl.tmi.twitch.tv PRIVMSG #pajlada :Keepo)", + { + {{ + 0, // start + 4, // end + twitchEmotes->getOrCreateEmote(EmoteId{"1902"}, + EmoteName{"Keepo"}), // ptr + EmoteName{"Keepo"}, // name + }}, + }, + }, + { + R"(@badge-info=;badges=no_audio/1;color=#DAA520;display-name=Mm2PL;emote-only=1;emotes=25:0-4/1902:6-10/305954156:12-19;first-msg=0;flags=;id=7be87072-bf24-4fa3-b3df-0ea6fa5f1474;mod=0;returning-chatter=0;room-id=11148817;subscriber=0;tmi-sent-ts=1662201102276;turbo=0;user-id=117691339;user-type= :mm2pl!mm2pl@mm2pl.tmi.twitch.tv PRIVMSG #pajlada :Kappa Keepo PogChamp)", + { + { + { + 0, // start + 4, // end + twitchEmotes->getOrCreateEmote( + EmoteId{"25"}, EmoteName{"Kappa"}), // ptr + EmoteName{"Kappa"}, // name + }, + { + 6, // start + 10, // end + twitchEmotes->getOrCreateEmote( + EmoteId{"1902"}, EmoteName{"Keepo"}), // ptr + EmoteName{"Keepo"}, // name + }, + { + 12, // start + 19, // end + twitchEmotes->getOrCreateEmote( + EmoteId{"305954156"}, + EmoteName{"PogChamp"}), // ptr + EmoteName{"PogChamp"}, // name + }, + }, + }, + }, + { + R"(@badge-info=subscriber/80;badges=broadcaster/1,subscriber/3072,partner/1;color=#CC44FF;display-name=pajlada;emote-only=1;emotes=25:0-4,6-10;first-msg=0;flags=;id=f7516287-e5d1-43ca-974e-fe0cff84400b;mod=0;returning-chatter=0;room-id=11148817;subscriber=1;tmi-sent-ts=1662204375009;turbo=0;user-id=11148817;user-type= :pajlada!pajlada@pajlada.tmi.twitch.tv PRIVMSG #pajlada :Kappa Kappa)", + { + { + { + 0, // start + 4, // end + twitchEmotes->getOrCreateEmote( + EmoteId{"25"}, EmoteName{"Kappa"}), // ptr + EmoteName{"Kappa"}, // name + }, + { + 6, // start + 10, // end + twitchEmotes->getOrCreateEmote( + EmoteId{"25"}, EmoteName{"Kappa"}), // ptr + EmoteName{"Kappa"}, // name + }, + }, + }, + }, + { + R"(@badge-info=subscriber/80;badges=broadcaster/1,subscriber/3072,partner/1;color=#CC44FF;display-name=pajlada;emotes=25:0-4,8-12;first-msg=0;flags=;id=44f85d39-b5fb-475d-8555-f4244f2f7e82;mod=0;returning-chatter=0;room-id=11148817;subscriber=1;tmi-sent-ts=1662204423418;turbo=0;user-id=11148817;user-type= :pajlada!pajlada@pajlada.tmi.twitch.tv PRIVMSG #pajlada :Kappa 😂 Kappa)", + { + { + { + 0, // start + 4, // end + twitchEmotes->getOrCreateEmote( + EmoteId{"25"}, EmoteName{"Kappa"}), // ptr + EmoteName{"Kappa"}, // name + }, + { + 9, // start - modified due to emoji + 13, // end - modified due to emoji + twitchEmotes->getOrCreateEmote( + EmoteId{"25"}, EmoteName{"Kappa"}), // ptr + EmoteName{"Kappa"}, // name + }, + }, + }, + }, + { + // start out of range + R"(@emotes=84608:9-10 :test!test@test.tmi.twitch.tv PRIVMSG #pajlada :foo bar)", + {}, + }, + { + // one character emote + R"(@emotes=84608:0-0 :test!test@test.tmi.twitch.tv PRIVMSG #pajlada :foo bar)", + { + { + 0, // start + 0, // end + twitchEmotes->getOrCreateEmote(EmoteId{"84608"}, + EmoteName{"f"}), // ptr + EmoteName{"f"}, // name + }, + }, + }, + { + // two character emote + R"(@emotes=84609:0-1 :test!test@test.tmi.twitch.tv PRIVMSG #pajlada :foo bar)", + { + { + 0, // start + 1, // end + twitchEmotes->getOrCreateEmote(EmoteId{"84609"}, + EmoteName{"fo"}), // ptr + EmoteName{"fo"}, // name + }, + }, + }, + { + // end out of range + R"(@emotes=84608:0-15 :test!test@test.tmi.twitch.tv PRIVMSG #pajlada :foo bar)", + {}, + }, + { + // range bad (end character before start) + R"(@emotes=84608:15-2 :test!test@test.tmi.twitch.tv PRIVMSG #pajlada :foo bar)", + {}, + }, + }; + + for (const auto &test : testCases) + { + auto *privmsg = static_cast( + Communi::IrcPrivateMessage::fromData(test.input, nullptr)); + QString originalMessage = privmsg->content(); + + // TODO: Add tests with replies + auto actualTwitchEmotes = TwitchMessageBuilder::parseTwitchEmotes( + privmsg->tags(), originalMessage, 0); + + EXPECT_EQ(actualTwitchEmotes, test.expectedTwitchEmotes) + << "Input for twitch emotes " << test.input.toStdString() + << " failed"; + } +} From e531161c7f845c77f23793ca2ef11d6b0bc828d4 Mon Sep 17 00:00:00 2001 From: Colton Clemmer Date: Sat, 5 Nov 2022 06:20:12 -0500 Subject: [PATCH 096/946] Migrate /mods command to helix API (#4103) Co-authored-by: Felanbird <41973452+Felanbird@users.noreply.github.com> Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 1 + .../commands/CommandController.cpp | 85 +++++++++++ src/providers/twitch/TwitchMessageBuilder.cpp | 56 ++++++++ src/providers/twitch/TwitchMessageBuilder.hpp | 4 + src/providers/twitch/api/Helix.cpp | 136 ++++++++++++++++++ src/providers/twitch/api/Helix.hpp | 72 +++++++++- src/singletons/Settings.hpp | 4 + src/widgets/settingspages/GeneralPage.cpp | 11 ++ tests/src/HighlightController.cpp | 9 ++ 9 files changed, 375 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 459955d11..ec0728ae9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,6 +71,7 @@ - Minor: Added stream titles to windows live toast notifications. (#1297) - Minor: Make menus and placeholders display appropriate custom key combos. (#4045) - Minor: Migrated /chatters to Helix API. (#4088, #4097) +- Minor: Migrated /mods to Helix API. (#4103) - Minor: Add settings tooltips. (#3437) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fixed `Smooth scrolling on new messages` setting sometimes hiding messages. (#4028) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 575272eec..3ea04cd68 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -938,6 +938,91 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); + auto formatModsError = [](HelixGetModeratorsError error, QString message) { + using Error = HelixGetModeratorsError; + + QString errorMessage = QString("Failed to get moderators: "); + + switch (error) + { + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::UserMissingScope: { + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + errorMessage += + "Due to Twitch restrictions, " + "this command can only be used by the broadcaster. " + "To see the list of mods you must use the Twitch website."; + } + break; + + case Error::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + return errorMessage; + }; + + this->registerCommand( + "/mods", + [formatModsError](const QStringList &words, auto channel) -> QString { + switch (getSettings()->helixTimegateModerators.getValue()) + { + case HelixTimegateOverride::Timegate: { + if (areIRCCommandsStillAvailable()) + { + return useIRCCommand(words); + } + } + break; + + case HelixTimegateOverride::AlwaysUseIRC: { + return useIRCCommand(words); + } + break; + case HelixTimegateOverride::AlwaysUseHelix: { + // Fall through to helix logic + } + break; + } + + auto twitchChannel = dynamic_cast(channel.get()); + + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /mods command only works in Twitch Channels")); + return ""; + } + + getHelix()->getModerators( + twitchChannel->roomId(), 500, + [channel, twitchChannel](auto result) { + // TODO: sort results? + + MessageBuilder builder; + TwitchMessageBuilder::listOfUsersSystemMessage( + "The moderators of this channel are", result, + twitchChannel, &builder); + channel->addMessage(builder.release()); + }, + [channel, formatModsError](auto error, auto message) { + auto errorMessage = formatModsError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + return ""; + }); + this->registerCommand("/clip", [](const auto & /*words*/, auto channel) { if (const auto type = channel->getType(); type != Channel::Type::Twitch && diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index 91e0c7535..d48f10fe0 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -1657,6 +1657,62 @@ void TwitchMessageBuilder::listOfUsersSystemMessage(QString prefix, } } +void TwitchMessageBuilder::listOfUsersSystemMessage( + QString prefix, const std::vector &users, Channel *channel, + MessageBuilder *builder) +{ + QString text = prefix; + + builder->emplace(); + builder->message().flags.set(MessageFlag::System); + builder->message().flags.set(MessageFlag::DoNotTriggerNotification); + builder->emplace(prefix, MessageElementFlag::Text, + MessageColor::System); + bool isFirst = true; + auto *tc = dynamic_cast(channel); + for (const auto &user : users) + { + if (!isFirst) + { + // this is used to add the ", " after each but the last entry + builder->emplace(",", MessageElementFlag::Text, + MessageColor::System); + text += QString(", %1").arg(user.userName); + } + else + { + text += user.userName; + } + isFirst = false; + + MessageColor color = MessageColor::System; + + if (tc && getSettings()->colorUsernames) + { + if (auto userColor = tc->getUserColor(user.userLogin); + userColor.isValid()) + { + color = MessageColor(userColor); + } + } + + builder + ->emplace(user.userName, + MessageElementFlag::BoldUsername, color, + FontStyle::ChatMediumBold) + ->setLink({Link::UserInfo, user.userLogin}) + ->setTrailingSpace(false); + builder + ->emplace(user.userName, + MessageElementFlag::NonBoldUsername, color) + ->setLink({Link::UserInfo, user.userLogin}) + ->setTrailingSpace(false); + } + + builder->message().messageText = text; + builder->message().searchText = text; +} + void TwitchMessageBuilder::setThread(std::shared_ptr thread) { this->thread_ = std::move(thread); diff --git a/src/providers/twitch/TwitchMessageBuilder.hpp b/src/providers/twitch/TwitchMessageBuilder.hpp index 7e01f9004..74bb00a8f 100644 --- a/src/providers/twitch/TwitchMessageBuilder.hpp +++ b/src/providers/twitch/TwitchMessageBuilder.hpp @@ -7,6 +7,7 @@ #include "providers/twitch/ChannelPointReward.hpp" #include "providers/twitch/PubSubActions.hpp" #include "providers/twitch/TwitchBadge.hpp" +#include "providers/twitch/api/Helix.hpp" #include #include @@ -77,6 +78,9 @@ public: static void listOfUsersSystemMessage(QString prefix, QStringList users, Channel *channel, MessageBuilder *builder); + static void listOfUsersSystemMessage( + QString prefix, const std::vector &users, + Channel *channel, MessageBuilder *builder); // Shares some common logic from SharedMessageBuilder::parseBadgeTag static std::unordered_map parseBadgeInfoTag( diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 35a7ca5ce..a35f5d007 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -6,6 +6,14 @@ #include #include +namespace { + +using namespace chatterino; + +static constexpr auto NUM_MODERATORS_TO_FETCH_PER_REQUEST = 100; + +} // namespace + namespace chatterino { static IHelix *instance = nullptr; @@ -1859,6 +1867,115 @@ void Helix::fetchChatters( .execute(); } +void Helix::onFetchModeratorsSuccess( + std::shared_ptr> finalModerators, + QString broadcasterID, int maxModeratorsToFetch, + ResultCallback> successCallback, + FailureCallback failureCallback, + HelixModerators moderators) +{ + qCDebug(chatterinoTwitch) + << "Fetched " << moderators.moderators.size() << " moderators"; + + std::for_each(moderators.moderators.begin(), moderators.moderators.end(), + [finalModerators](auto mod) { + finalModerators->push_back(mod); + }); + + if (moderators.cursor.isEmpty() || + finalModerators->size() >= maxModeratorsToFetch) + { + // Done paginating + successCallback(*finalModerators); + return; + } + + this->fetchModerators( + broadcasterID, NUM_MODERATORS_TO_FETCH_PER_REQUEST, moderators.cursor, + [=](auto moderators) { + this->onFetchModeratorsSuccess( + finalModerators, broadcasterID, maxModeratorsToFetch, + successCallback, failureCallback, moderators); + }, + failureCallback); +} + +// https://dev.twitch.tv/docs/api/reference#get-moderators +void Helix::fetchModerators( + QString broadcasterID, int first, QString after, + ResultCallback successCallback, + FailureCallback failureCallback) +{ + using Error = HelixGetModeratorsError; + + QUrlQuery urlQuery; + + urlQuery.addQueryItem("broadcaster_id", broadcasterID); + urlQuery.addQueryItem("first", QString::number(first)); + + if (!after.isEmpty()) + { + urlQuery.addQueryItem("after", after); + } + + this->makeRequest("moderation/moderators", urlQuery) + .onSuccess([successCallback](auto result) -> Outcome { + if (result.status() != 200) + { + qCWarning(chatterinoTwitch) + << "Success result for getting moderators was " + << result.status() << "but we expected it to be 200"; + } + + auto response = result.parseJson(); + successCallback(HelixModerators(response)); + return Success; + }) + .onError([failureCallback](auto result) { + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + + switch (result.status()) + { + case 400: { + failureCallback(Error::Forwarded, message); + } + break; + + case 401: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + failureCallback(Error::UserMissingScope, message); + } + else if (message.contains("OAuth token")) + { + failureCallback(Error::UserNotAuthorized, message); + } + else + { + failureCallback(Error::Forwarded, message); + } + } + break; + + case 403: { + failureCallback(Error::UserNotAuthorized, message); + } + break; + + default: { + qCDebug(chatterinoTwitch) + << "Unhandled error data:" << result.status() + << result.getData() << obj; + failureCallback(Error::Unknown, message); + } + break; + } + }) + .execute(); +} + // Ban/timeout a user // https://dev.twitch.tv/docs/api/reference#ban-user void Helix::banUser(QString broadcasterID, QString moderatorID, QString userID, @@ -2106,6 +2223,25 @@ void Helix::getChatters( fetchSuccess(fetchSuccess), failureCallback); } +// https://dev.twitch.tv/docs/api/reference#get-moderators +void Helix::getModerators( + QString broadcasterID, int maxModeratorsToFetch, + ResultCallback> successCallback, + FailureCallback failureCallback) +{ + auto finalModerators = std::make_shared>(); + + // Initiate the recursive calls + this->fetchModerators( + broadcasterID, NUM_MODERATORS_TO_FETCH_PER_REQUEST, "", + [=](auto moderators) { + this->onFetchModeratorsSuccess( + finalModerators, broadcasterID, maxModeratorsToFetch, + successCallback, failureCallback, moderators); + }, + failureCallback); +} + // List the VIPs of a channel // https://dev.twitch.tv/docs/api/reference#get-vips void Helix::getChannelVIPs( diff --git a/src/providers/twitch/api/Helix.hpp b/src/providers/twitch/api/Helix.hpp index ece503bc8..f056ccd6a 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -332,8 +332,13 @@ struct HelixChatSettings { }; struct HelixVip { + // Twitch ID of the user QString userId; + + // Display name of the user QString userName; + + // Login name of the user QString userLogin; explicit HelixVip(const QJsonObject &jsonObject) @@ -367,9 +372,29 @@ struct HelixChatters { } }; -// TODO(jammehcow): when implementing mod list, just alias HelixVip to HelixMod -// as they share the same model. -// Alternatively, rename base struct to HelixUser or something and alias both +using HelixModerator = HelixVip; + +struct HelixModerators { + std::vector moderators; + QString cursor; + + HelixModerators() = default; + + explicit HelixModerators(const QJsonObject &jsonObject) + : cursor(jsonObject.value("pagination") + .toObject() + .value("cursor") + .toString()) + { + const auto &data = jsonObject.value("data").toArray(); + for (const auto &mod : data) + { + HelixModerator moderator(mod.toObject()); + + this->moderators.push_back(moderator); + } + } +}; enum class HelixAnnouncementColor { Blue, @@ -553,6 +578,15 @@ enum class HelixGetChattersError { Forwarded, }; +enum class HelixGetModeratorsError { + Unknown, + UserMissingScope, + UserNotAuthorized, + + // The error message is forwarded directly from the Twitch API + Forwarded, +}; + enum class HelixListVIPsError { // /vips Unknown, UserMissingScope, @@ -854,6 +888,14 @@ public: ResultCallback successCallback, FailureCallback failureCallback) = 0; + // Get moderators from the `broadcasterID` channel + // This will follow the returned cursor + // https://dev.twitch.tv/docs/api/reference#get-moderators + virtual void getModerators( + QString broadcasterID, int maxModeratorsToFetch, + ResultCallback> successCallback, + FailureCallback failureCallback) = 0; + // https://dev.twitch.tv/docs/api/reference#get-vips virtual void getChannelVIPs( QString broadcasterID, @@ -1130,6 +1172,15 @@ public: ResultCallback successCallback, FailureCallback failureCallback) final; + // Get moderators from the `broadcasterID` channel + // This will follow the returned cursor + // https://dev.twitch.tv/docs/api/reference#get-moderators + void getModerators( + QString broadcasterID, int maxModeratorsToFetch, + ResultCallback> successCallback, + FailureCallback failureCallback) + final; + // https://dev.twitch.tv/docs/api/reference#get-vips void getChannelVIPs( QString broadcasterID, @@ -1162,6 +1213,21 @@ protected: ResultCallback successCallback, FailureCallback failureCallback); + // Recursive boy + void onFetchModeratorsSuccess( + std::shared_ptr> finalModerators, + QString broadcasterID, int maxModeratorsToFetch, + ResultCallback> successCallback, + FailureCallback failureCallback, + HelixModerators moderators); + + // Get moderator list - This method is what actually runs the API request + // https://dev.twitch.tv/docs/api/reference#get-moderators + void fetchModerators( + QString broadcasterID, int first, QString after, + ResultCallback successCallback, + FailureCallback failureCallback); + private: NetworkRequest makeRequest(QString url, QUrlQuery urlQuery); diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 57920a250..4fd2172bd 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -442,6 +442,10 @@ public: "/misc/twitch/helix-timegate/vips", HelixTimegateOverride::Timegate, }; + EnumSetting helixTimegateModerators = { + "/misc/twitch/helix-timegate/moderators", + HelixTimegateOverride::Timegate, + }; EnumSetting helixTimegateCommercial = { "/misc/twitch/helix-timegate/commercial", diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index a626efc4a..efeab9361 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -866,6 +866,17 @@ void GeneralPage::initLayout(GeneralPageView &layout) helixTimegateCommercial->setMinimumWidth( helixTimegateCommercial->minimumSizeHint().width()); + auto *helixTimegateModerators = + layout.addDropdown::type>( + "Helix timegate /mods behaviour", + {"Timegate", "Always use IRC", "Always use Helix"}, + s.helixTimegateModerators, + helixTimegateGetValue, // + helixTimegateSetValue, // + false); + helixTimegateModerators->setMinimumWidth( + helixTimegateModerators->minimumSizeHint().width()); + layout.addStretch(); // invisible element for width diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index 7df5084d3..6f05c59cb 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -391,6 +391,15 @@ public: (FailureCallback failureCallback)), (override)); // /commercial + // /mods + // The extra parenthesis around the failure callback is because its type contains a comma + MOCK_METHOD( + void, getModerators, + (QString broadcasterID, int maxModeratorsToFetch, + ResultCallback> successCallback, + (FailureCallback failureCallback)), + (override)); // /mods + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override)); From 2ec26f57ccf2a22bb477d3cc39edff89ec7a37a9 Mon Sep 17 00:00:00 2001 From: pajlada Date: Sat, 5 Nov 2022 12:56:17 +0100 Subject: [PATCH 097/946] Fix chatters recursion not working (#4114) --- CHANGELOG.md | 2 +- .../commands/CommandController.cpp | 129 ++++++++++++------ src/providers/twitch/api/Helix.cpp | 68 +++++---- src/providers/twitch/api/Helix.hpp | 8 ++ 4 files changed, 139 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec0728ae9..d190c35b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -70,7 +70,7 @@ - Minor: Migrated /commercial to Helix API. (#4094) - Minor: Added stream titles to windows live toast notifications. (#1297) - Minor: Make menus and placeholders display appropriate custom key combos. (#4045) -- Minor: Migrated /chatters to Helix API. (#4088, #4097) +- Minor: Migrated /chatters to Helix API. (#4088, #4097, #4114) - Minor: Migrated /mods to Helix API. (#4103) - Minor: Add settings tooltips. (#3437) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 3ea04cd68..05ab067ca 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -878,60 +878,109 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); - this->registerCommand("/chatters", [](const auto &words, auto channel) { - auto formatError = [](HelixGetChattersError error, QString message) { - using Error = HelixGetChattersError; + auto formatChattersError = [](HelixGetChattersError error, + QString message) { + using Error = HelixGetChattersError; - QString errorMessage = QString("Failed to get chatter count: "); + QString errorMessage = QString("Failed to get chatter count: "); - switch (error) - { - case Error::Forwarded: { - errorMessage += message; - } - break; - - case Error::UserMissingScope: { - errorMessage += "Missing required scope. " - "Re-login with your " - "account and try again."; - } - break; - - case Error::UserNotAuthorized: { - errorMessage += "You must have moderator permissions to " - "use this command."; - } - break; - - case Error::Unknown: { - errorMessage += "An unknown error has occurred."; - } - break; + switch (error) + { + case Error::Forwarded: { + errorMessage += message; } - return errorMessage; - }; + break; - auto twitchChannel = dynamic_cast(channel.get()); + case Error::UserMissingScope: { + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + errorMessage += "You must have moderator permissions to " + "use this command."; + } + break; + + case Error::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + return errorMessage; + }; + + this->registerCommand( + "/chatters", [formatChattersError](const auto &words, auto channel) { + auto *twitchChannel = dynamic_cast(channel.get()); + + if (twitchChannel == nullptr) + { + channel->addMessage(makeSystemMessage( + "The /chatters command only works in Twitch Channels")); + return ""; + } + + // Refresh chatter list via helix api for mods + getHelix()->getChatters( + twitchChannel->roomId(), + getApp()->accounts->twitch.getCurrent()->getUserId(), 1, + [channel](auto result) { + channel->addMessage(makeSystemMessage( + QString("Chatter count: %1") + .arg(localizeNumbers(result.total)))); + }, + [channel, formatChattersError](auto error, auto message) { + auto errorMessage = formatChattersError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; + }); + + this->registerCommand("/test-chatters", [formatChattersError]( + const auto & /*words*/, + auto channel) { + auto *twitchChannel = dynamic_cast(channel.get()); if (twitchChannel == nullptr) { channel->addMessage(makeSystemMessage( - "The /chatters command only works in Twitch Channels")); + "The /test-chatters command only works in Twitch Channels")); return ""; } - // Refresh chatter list via helix api for mods getHelix()->getChatters( twitchChannel->roomId(), - getApp()->accounts->twitch.getCurrent()->getUserId(), 1, - [channel](auto result) { - channel->addMessage( - makeSystemMessage(QString("Chatter count: %1") - .arg(localizeNumbers(result.total)))); + getApp()->accounts->twitch.getCurrent()->getUserId(), 5000, + [channel, twitchChannel](auto result) { + QStringList entries; + for (const auto &username : result.chatters) + { + entries << username; + } + + QString prefix = "Chatters "; + + if (result.total > 5000) + { + prefix += QString("(5000/%1):").arg(result.total); + } + else + { + prefix += QString("(%1):").arg(result.total); + } + + MessageBuilder builder; + TwitchMessageBuilder::listOfUsersSystemMessage( + prefix, entries, twitchChannel, &builder); + + channel->addMessage(builder.release()); }, - [channel, formatError](auto error, auto message) { - auto errorMessage = formatError(error, message); + [channel, formatChattersError](auto error, auto message) { + auto errorMessage = formatChattersError(error, message); channel->addMessage(makeSystemMessage(errorMessage)); }); diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index a35f5d007..1c6f717f3 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -12,6 +12,8 @@ using namespace chatterino; static constexpr auto NUM_MODERATORS_TO_FETCH_PER_REQUEST = 100; +static constexpr auto NUM_CHATTERS_TO_FETCH = 1000; + } // namespace namespace chatterino { @@ -1790,6 +1792,37 @@ void Helix::updateChatSettings( .execute(); } +void Helix::onFetchChattersSuccess( + std::shared_ptr finalChatters, QString broadcasterID, + QString moderatorID, int maxChattersToFetch, + ResultCallback successCallback, + FailureCallback failureCallback, + HelixChatters chatters) +{ + qCDebug(chatterinoTwitch) + << "Fetched" << chatters.chatters.size() << "chatters"; + + finalChatters->chatters.merge(chatters.chatters); + finalChatters->total = chatters.total; + + if (chatters.cursor.isEmpty() || + finalChatters->chatters.size() >= maxChattersToFetch) + { + // Done paginating + successCallback(*finalChatters); + return; + } + + this->fetchChatters( + broadcasterID, moderatorID, NUM_CHATTERS_TO_FETCH, chatters.cursor, + [=](auto chatters) { + this->onFetchChattersSuccess( + finalChatters, broadcasterID, moderatorID, maxChattersToFetch, + successCallback, failureCallback, chatters); + }, + failureCallback); +} + // https://dev.twitch.tv/docs/api/reference#get-chatters void Helix::fetchChatters( QString broadcasterID, QString moderatorID, int first, QString after, @@ -2191,36 +2224,17 @@ void Helix::getChatters( ResultCallback successCallback, FailureCallback failureCallback) { - static const auto NUM_CHATTERS_TO_FETCH = 1000; - auto finalChatters = std::make_shared(); - auto fetchSuccess = [this, broadcasterID, moderatorID, maxChattersToFetch, - finalChatters, successCallback, - failureCallback](auto fs) { - return [=](auto chatters) { - qCDebug(chatterinoTwitch) - << "Fetched" << chatters.chatters.size() << "chatters"; - finalChatters->chatters.merge(chatters.chatters); - finalChatters->total = chatters.total; - - if (chatters.cursor.isEmpty() || - finalChatters->chatters.size() >= maxChattersToFetch) - { - // Done paginating - successCallback(*finalChatters); - return; - } - - this->fetchChatters(broadcasterID, moderatorID, - NUM_CHATTERS_TO_FETCH, chatters.cursor, fs, - failureCallback); - }; - }; - // Initiate the recursive calls - this->fetchChatters(broadcasterID, moderatorID, NUM_CHATTERS_TO_FETCH, "", - fetchSuccess(fetchSuccess), failureCallback); + this->fetchChatters( + broadcasterID, moderatorID, NUM_CHATTERS_TO_FETCH, "", + [=](auto chatters) { + this->onFetchChattersSuccess( + finalChatters, broadcasterID, moderatorID, maxChattersToFetch, + successCallback, failureCallback, chatters); + }, + failureCallback); } // https://dev.twitch.tv/docs/api/reference#get-moderators diff --git a/src/providers/twitch/api/Helix.hpp b/src/providers/twitch/api/Helix.hpp index f056ccd6a..4ee3fee08 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -1206,6 +1206,14 @@ protected: FailureCallback failureCallback) final; + // Recursive boy + void onFetchChattersSuccess( + std::shared_ptr finalChatters, QString broadcasterID, + QString moderatorID, int maxChattersToFetch, + ResultCallback successCallback, + FailureCallback failureCallback, + HelixChatters chatters); + // Get chatters list - This method is what actually runs the API request // https://dev.twitch.tv/docs/api/reference#get-chatters void fetchChatters( From 6f88c1cc8a80d8ea5a0668d6e6279c42892e911e Mon Sep 17 00:00:00 2001 From: Mm2PL Date: Sat, 5 Nov 2022 13:40:15 +0100 Subject: [PATCH 098/946] Make opening threads from a usercard opened with /usercard not crash the client (#3905) Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 2 +- .../commands/CommandController.cpp | 42 ++++++++++++++++++- src/widgets/dialogs/UserInfoPopup.cpp | 2 + 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d190c35b7..d004752d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Unversioned -- Major: Added support for Twitch's Chat Replies. [Wiki Page](https://wiki.chatterino.com/Features/#message-replies) (#3722, #3989, #4041, #4047, #4055, #4067, #4077) +- Major: Added support for Twitch's Chat Replies. [Wiki Page](https://wiki.chatterino.com/Features/#message-replies) (#3722, #3989, #4041, #4047, #4055, #4067, #4077, #3905) - Major: Added multi-channel searching to search dialog via keyboard shortcut. (Ctrl+Shift+F by default) (#3694, #3875) - Major: Added support for emotes and badges from [7TV](https://7tv.app). [Wiki Page](https://wiki.chatterino.com/Third_party_services/#7tv) (#4002, #4062) - Minor: Added highlights for `Elevated Messages`. (#4016) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 05ab067ca..75b455c4f 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -32,6 +32,7 @@ #include "widgets/dialogs/ReplyThreadPopup.hpp" #include "widgets/dialogs/UserInfoPopup.hpp" #include "widgets/splits/Split.hpp" +#include "widgets/splits/SplitContainer.hpp" #include #include @@ -838,10 +839,49 @@ void CommandController::initialize(Settings &, Paths &paths) channel = channelTemp; } + // try to link to current split if possible + Split *currentSplit = nullptr; + auto *currentPage = dynamic_cast( + getApp()->windows->getMainWindow().getNotebook().getSelectedPage()); + if (currentPage != nullptr) + { + currentSplit = currentPage->getSelectedSplit(); + } + + auto differentChannel = + currentSplit != nullptr && currentSplit->getChannel() != channel; + if (differentChannel || currentSplit == nullptr) + { + // not possible to use current split, try searching for one + const auto ¬ebook = + getApp()->windows->getMainWindow().getNotebook(); + auto count = notebook.getPageCount(); + for (int i = 0; i < count; i++) + { + auto *page = notebook.getPageAt(i); + auto *container = dynamic_cast(page); + assert(container != nullptr); + for (auto *split : container->getSplits()) + { + if (split->getChannel() == channel) + { + currentSplit = split; + break; + } + } + } + + // This would have crashed either way. + assert(currentSplit != nullptr && + "something went HORRIBLY wrong with the /usercard " + "command. It couldn't find a split for a channel which " + "should be open."); + } + auto *userPopup = new UserInfoPopup( getSettings()->autoCloseUserPopup, static_cast(&(getApp()->windows->getMainWindow())), - nullptr); + currentSplit); userPopup->setData(userName, channel); userPopup->move(QCursor::pos()); userPopup->show(); diff --git a/src/widgets/dialogs/UserInfoPopup.cpp b/src/widgets/dialogs/UserInfoPopup.cpp index 2781672e9..478a0eead 100644 --- a/src/widgets/dialogs/UserInfoPopup.cpp +++ b/src/widgets/dialogs/UserInfoPopup.cpp @@ -135,6 +135,8 @@ UserInfoPopup::UserInfoPopup(bool closeAutomatically, QWidget *parent, : DraggablePopup(closeAutomatically, parent) , split_(split) { + assert(split != nullptr && + "split being nullptr causes lots of bugs down the road"); this->setWindowTitle("Usercard"); this->setStayInScreenRect(true); From 1e6e18f53aa69335b69bc0adcc555a1a797b5947 Mon Sep 17 00:00:00 2001 From: Felanbird <41973452+Felanbird@users.noreply.github.com> Date: Sun, 6 Nov 2022 06:14:27 -0500 Subject: [PATCH 099/946] Add `is:redemption` search predicate (#4118) --- CHANGELOG.md | 1 + src/messages/search/MessageFlagsPredicate.cpp | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d004752d8..25c7ed457 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ - Minor: Added `is:first-msg` search option. (#3700) - Minor: Added `is:elevated-msg` search option. (#4018) - Minor: Added `is:cheer-msg` search option. (#4069) +- Minor: Added `is:redemption` search option. (#4118) - Minor: Added `subtier:` search option (e.g. `subtier:3` to find Tier 3 subs). (#4013) - Minor: Added `badge:` search option (e.g. `badge:mod` to users with the moderator badge). (#4013) - Minor: Added AutoMod message flag filter. (#3938) diff --git a/src/messages/search/MessageFlagsPredicate.cpp b/src/messages/search/MessageFlagsPredicate.cpp index 3221da236..a659d6347 100644 --- a/src/messages/search/MessageFlagsPredicate.cpp +++ b/src/messages/search/MessageFlagsPredicate.cpp @@ -42,6 +42,11 @@ MessageFlagsPredicate::MessageFlagsPredicate(const QString &flags) { this->flags_.set(MessageFlag::CheerMessage); } + else if (flag == "redemption") + { + this->flags_.set(MessageFlag::RedeemedChannelPointReward); + this->flags_.set(MessageFlag::RedeemedHighlight); + } } } From ac7baf40736a9ff60b2a07d0310393f3aa33e610 Mon Sep 17 00:00:00 2001 From: Felanbird <41973452+Felanbird@users.noreply.github.com> Date: Sun, 6 Nov 2022 06:37:14 -0500 Subject: [PATCH 100/946] Add `is:reply` search predicate (#4119) Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 1 + src/messages/search/MessageFlagsPredicate.cpp | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 25c7ed457..05c302843 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,7 @@ - Minor: Added `is:elevated-msg` search option. (#4018) - Minor: Added `is:cheer-msg` search option. (#4069) - Minor: Added `is:redemption` search option. (#4118) +- Minor: Added `is:reply` search option. (#4119) - Minor: Added `subtier:` search option (e.g. `subtier:3` to find Tier 3 subs). (#4013) - Minor: Added `badge:` search option (e.g. `badge:mod` to users with the moderator badge). (#4013) - Minor: Added AutoMod message flag filter. (#3938) diff --git a/src/messages/search/MessageFlagsPredicate.cpp b/src/messages/search/MessageFlagsPredicate.cpp index a659d6347..84601a183 100644 --- a/src/messages/search/MessageFlagsPredicate.cpp +++ b/src/messages/search/MessageFlagsPredicate.cpp @@ -47,6 +47,10 @@ MessageFlagsPredicate::MessageFlagsPredicate(const QString &flags) this->flags_.set(MessageFlag::RedeemedChannelPointReward); this->flags_.set(MessageFlag::RedeemedHighlight); } + else if (flag == "reply") + { + this->flags_.set(MessageFlag::ReplyMessage); + } } } From c6a162c7ffb0c807472de47e4dbc06447c0dcb2d Mon Sep 17 00:00:00 2001 From: pajlada Date: Sun, 6 Nov 2022 13:07:54 +0100 Subject: [PATCH 101/946] Move ChatSettings commands to their own file (#4116) * Move ChatSettings commands to their own file * reformat error message strings * move ChatCommands together in CommandController.cpp * Allow CommandContext to be provided for builtin functions using variants MEGADANK * add missing include * rename to ComandFunctionVariants also include some move magic & const reffing --- src/CMakeLists.txt | 7 +- src/controllers/commands/CommandContext.hpp | 20 + .../commands/CommandController.cpp | 498 ++---------------- .../commands/CommandController.hpp | 12 +- .../commands/builtin/twitch/ChatSettings.cpp | 434 +++++++++++++++ .../commands/builtin/twitch/ChatSettings.hpp | 24 + 6 files changed, 526 insertions(+), 469 deletions(-) create mode 100644 src/controllers/commands/CommandContext.hpp create mode 100644 src/controllers/commands/builtin/twitch/ChatSettings.cpp create mode 100644 src/controllers/commands/builtin/twitch/ChatSettings.hpp diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 48f9d4df7..0e976dd47 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -59,10 +59,13 @@ set(SOURCE_FILES controllers/accounts/AccountModel.cpp controllers/accounts/AccountModel.hpp - controllers/commands/Command.cpp - controllers/commands/Command.hpp + controllers/commands/builtin/twitch/ChatSettings.cpp + controllers/commands/builtin/twitch/ChatSettings.hpp + controllers/commands/CommandContext.hpp controllers/commands/CommandController.cpp controllers/commands/CommandController.hpp + controllers/commands/Command.cpp + controllers/commands/Command.hpp controllers/commands/CommandModel.cpp controllers/commands/CommandModel.hpp diff --git a/src/controllers/commands/CommandContext.hpp b/src/controllers/commands/CommandContext.hpp new file mode 100644 index 000000000..ced3b36b3 --- /dev/null +++ b/src/controllers/commands/CommandContext.hpp @@ -0,0 +1,20 @@ +#pragma once + +#include "common/Channel.hpp" +#include "providers/twitch/TwitchChannel.hpp" + +#include + +namespace chatterino { + +struct CommandContext { + QStringList words; + + // Can be null + ChannelPtr channel; + + // Can be null if `channel` is null or if `channel` is not a Twitch channel + TwitchChannel *twitchChannel; +}; + +} // namespace chatterino diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 75b455c4f..9da458afa 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -7,6 +7,7 @@ #include "controllers/accounts/AccountController.hpp" #include "controllers/commands/Command.hpp" #include "controllers/commands/CommandModel.hpp" +#include "controllers/commands/builtin/twitch/ChatSettings.hpp" #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" #include "messages/MessageElement.hpp" @@ -2557,409 +2558,22 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); // unraid - const auto formatChatSettingsError = [](const HelixUpdateChatSettingsError - error, - const QString &message, - int durationUnitMultiplier = 1) { - static const QRegularExpression invalidRange("(\\d+) through (\\d+)"); + this->registerCommand("/emoteonly", &commands::emoteOnly); + this->registerCommand("/emoteonlyoff", &commands::emoteOnlyOff); - QString errorMessage = QString("Failed to update - "); - using Error = HelixUpdateChatSettingsError; - switch (error) - { - case Error::UserMissingScope: { - // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE - errorMessage += "Missing required scope. " - "Re-login with your " - "account and try again."; - } - break; + this->registerCommand("/subscribers", &commands::subscribers); + this->registerCommand("/subscribersoff", &commands::subscribersOff); - case Error::UserNotAuthorized: - case Error::Forbidden: { - // TODO(pajlada): Phrase MISSING_PERMISSION - errorMessage += "You don't have permission to " - "perform that action."; - } - break; + this->registerCommand("/slow", &commands::slow); + this->registerCommand("/slowoff", &commands::slowOff); - case Error::Ratelimited: { - errorMessage += "You are being ratelimited by Twitch. Try " - "again in a few seconds."; - } - break; + this->registerCommand("/followers", &commands::followers); + this->registerCommand("/followersoff", &commands::followersOff); - case Error::OutOfRange: { - QRegularExpressionMatch matched = invalidRange.match(message); - if (matched.hasMatch()) - { - auto from = matched.captured(1).toInt(); - auto to = matched.captured(2).toInt(); - errorMessage += - QString("The duration is out of the valid range: " - "%1 through %2.") - .arg(from == 0 ? "0s" - : formatTime(from * - durationUnitMultiplier), - to == 0 - ? "0s" - : formatTime(to * durationUnitMultiplier)); - } - else - { - errorMessage += message; - } - } - break; - - case Error::Forwarded: { - errorMessage = message; - } - break; - - case Error::Unknown: - default: { - errorMessage += "An unknown error has occurred."; - } - break; - } - return errorMessage; - }; - - this->registerCommand("/emoteonly", [formatChatSettingsError]( - const QStringList & /* words */, - auto channel) { - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage(makeSystemMessage( - "You must be logged in to update chat settings!")); - return ""; - } - - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /emoteonly command only works in Twitch channels")); - return ""; - } - - if (twitchChannel->accessRoomModes()->emoteOnly) - { - channel->addMessage( - makeSystemMessage("This room is already in emote-only mode.")); - return ""; - } - - getHelix()->updateEmoteMode( - twitchChannel->roomId(), currentUser->getUserId(), true, - [](auto) { - //we'll get a message from irc - }, - [channel, formatChatSettingsError](auto error, auto message) { - channel->addMessage( - makeSystemMessage(formatChatSettingsError(error, message))); - }); - return ""; - }); - - this->registerCommand( - "/emoteonlyoff", [formatChatSettingsError]( - const QStringList & /* words */, auto channel) { - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage(makeSystemMessage( - "You must be logged in to update chat settings!")); - return ""; - } - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /emoteonlyoff command only works in Twitch channels")); - return ""; - } - - if (!twitchChannel->accessRoomModes()->emoteOnly) - { - channel->addMessage( - makeSystemMessage("This room is not in emote-only mode.")); - return ""; - } - - getHelix()->updateEmoteMode( - twitchChannel->roomId(), currentUser->getUserId(), false, - [](auto) { - // we'll get a message from irc - }, - [channel, formatChatSettingsError](auto error, auto message) { - channel->addMessage(makeSystemMessage( - formatChatSettingsError(error, message))); - }); - return ""; - }); - - this->registerCommand( - "/subscribers", [formatChatSettingsError]( - const QStringList & /* words */, auto channel) { - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage(makeSystemMessage( - "You must be logged in to update chat settings!")); - return ""; - } - - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /subscribers command only works in Twitch channels")); - return ""; - } - - if (twitchChannel->accessRoomModes()->submode) - { - channel->addMessage(makeSystemMessage( - "This room is already in subscribers-only mode.")); - return ""; - } - - getHelix()->updateSubscriberMode( - twitchChannel->roomId(), currentUser->getUserId(), true, - [](auto) { - //we'll get a message from irc - }, - [channel, formatChatSettingsError](auto error, auto message) { - channel->addMessage(makeSystemMessage( - formatChatSettingsError(error, message))); - }); - return ""; - }); - - this->registerCommand("/subscribersoff", [formatChatSettingsError]( - const QStringList - & /* words */, - auto channel) { - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage(makeSystemMessage( - "You must be logged in to update chat settings!")); - return ""; - } - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /subscribersoff command only works in Twitch channels")); - return ""; - } - - if (!twitchChannel->accessRoomModes()->submode) - { - channel->addMessage(makeSystemMessage( - "This room is not in subscribers-only mode.")); - return ""; - } - - getHelix()->updateSubscriberMode( - twitchChannel->roomId(), currentUser->getUserId(), false, - [](auto) { - // we'll get a message from irc - }, - [channel, formatChatSettingsError](auto error, auto message) { - channel->addMessage( - makeSystemMessage(formatChatSettingsError(error, message))); - }); - return ""; - }); - - this->registerCommand("/slow", [formatChatSettingsError]( - const QStringList &words, auto channel) { - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage(makeSystemMessage( - "You must be logged in to update chat settings!")); - return ""; - } - - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /slow command only works in Twitch channels")); - return ""; - } - - int duration = 30; - if (words.length() >= 2) - { - bool ok = false; - duration = words.at(1).toInt(&ok); - if (!ok || duration <= 0) - { - channel->addMessage(makeSystemMessage( - "Usage: \"/slow [duration]\" - Enables slow mode (limit " - "how often users may send messages). Duration (optional, " - "default=30) must be a positive number of seconds. Use " - "\"slowoff\" to disable. ")); - return ""; - } - } - - if (twitchChannel->accessRoomModes()->slowMode == duration) - { - channel->addMessage(makeSystemMessage( - QString("This room is already in %1-second slow mode.") - .arg(duration))); - return ""; - } - - getHelix()->updateSlowMode( - twitchChannel->roomId(), currentUser->getUserId(), duration, - [](auto) { - //we'll get a message from irc - }, - [channel, formatChatSettingsError](auto error, auto message) { - channel->addMessage( - makeSystemMessage(formatChatSettingsError(error, message))); - }); - return ""; - }); - - this->registerCommand( - "/slowoff", [formatChatSettingsError](const QStringList & /* words */, - auto channel) { - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage(makeSystemMessage( - "You must be logged in to update chat settings!")); - return ""; - } - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /slowoff command only works in Twitch channels")); - return ""; - } - - if (twitchChannel->accessRoomModes()->slowMode <= 0) - { - channel->addMessage( - makeSystemMessage("This room is not in slow mode.")); - return ""; - } - - getHelix()->updateSlowMode( - twitchChannel->roomId(), currentUser->getUserId(), boost::none, - [](auto) { - // we'll get a message from irc - }, - [channel, formatChatSettingsError](auto error, auto message) { - channel->addMessage(makeSystemMessage( - formatChatSettingsError(error, message))); - }); - return ""; - }); - - this->registerCommand("/followers", [formatChatSettingsError]( - const QStringList &words, - auto channel) { - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage(makeSystemMessage( - "You must be logged in to update chat settings!")); - return ""; - } - - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /followers command only works in Twitch channels")); - return ""; - } - - int duration = 0; - if (words.length() >= 2) - { - auto parsed = parseDurationToSeconds(words.mid(1).join(' '), 60); - duration = (int)(parsed / 60); - // -1 / 60 == 0 => use parsed - if (parsed < 0) - { - channel->addMessage(makeSystemMessage( - "Usage: \"/followers [duration]\" - Enables followers-only" - " mode (only users who have followed for 'duration' may " - "chat). Examples: \"30m\", \"1 week\", \"5 days 12 " - "hours\". Must be less than 3 months. ")); - return ""; - } - } - - if (twitchChannel->accessRoomModes()->followerOnly == duration) - { - channel->addMessage(makeSystemMessage( - QString("This room is already in %1 followers-only mode.") - .arg(formatTime(duration * 60)))); - return ""; - } - - getHelix()->updateFollowerMode( - twitchChannel->roomId(), currentUser->getUserId(), duration, - [](auto) { - //we'll get a message from irc - }, - [channel, formatChatSettingsError](auto error, auto message) { - channel->addMessage(makeSystemMessage( - formatChatSettingsError(error, message, 60))); - }); - return ""; - }); - - this->registerCommand("/followersoff", [formatChatSettingsError]( - const QStringList & /* words */, - auto channel) { - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage(makeSystemMessage( - "You must be logged in to update chat settings!")); - return ""; - } - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /followersoff command only works in Twitch channels")); - return ""; - } - - if (twitchChannel->accessRoomModes()->followerOnly < 0) - { - channel->addMessage( - makeSystemMessage("This room is not in followers-only mode. ")); - return ""; - } - - getHelix()->updateFollowerMode( - twitchChannel->roomId(), currentUser->getUserId(), boost::none, - [](auto) { - // we'll get a message from irc - }, - [channel, formatChatSettingsError](auto error, auto message) { - channel->addMessage( - makeSystemMessage(formatChatSettingsError(error, message))); - }); - return ""; - }); + this->registerCommand("/uniquechat", &commands::uniqueChat); + this->registerCommand("/r9kbeta", &commands::uniqueChat); + this->registerCommand("/uniquechatoff", &commands::uniqueChatOff); + this->registerCommand("/r9kbetaoff", &commands::uniqueChatOff); auto formatBanTimeoutError = [](const char *operation, HelixBanUserError error, @@ -3337,67 +2951,6 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); - auto uniqueChatLambda = [formatChatSettingsError](auto words, auto channel, - bool target) { - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage(makeSystemMessage( - "You must be logged in to update chat settings!")); - return ""; - } - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - QString("The /%1 command only works in Twitch channels") - .arg(target ? "uniquechat" : "uniquechatoff"))); - return ""; - } - - if (twitchChannel->accessRoomModes()->r9k == target) - { - channel->addMessage(makeSystemMessage( - target ? "This room is already in unique-chat mode." - : "This room is not in unique-chat mode.")); - return ""; - } - - getHelix()->updateUniqueChatMode( - twitchChannel->roomId(), currentUser->getUserId(), target, - [](auto) { - // we'll get a message from irc - }, - [channel, formatChatSettingsError](auto error, auto message) { - channel->addMessage( - makeSystemMessage(formatChatSettingsError(error, message))); - }); - return ""; - }; - - this->registerCommand( - "/uniquechatoff", - [uniqueChatLambda](const QStringList &words, auto channel) { - return uniqueChatLambda(words, channel, false); - }); - - this->registerCommand( - "/r9kbetaoff", - [uniqueChatLambda](const QStringList &words, auto channel) { - return uniqueChatLambda(words, channel, false); - }); - - this->registerCommand( - "/uniquechat", - [uniqueChatLambda](const QStringList &words, auto channel) { - return uniqueChatLambda(words, channel, true); - }); - - this->registerCommand( - "/r9kbeta", [uniqueChatLambda](const QStringList &words, auto channel) { - return uniqueChatLambda(words, channel, true); - }); - this->registerCommand( "/commercial", [formatStartCommercialError](const QStringList &words, @@ -3544,7 +3097,22 @@ QString CommandController::execCommand(const QString &textNoEmoji, const auto it = this->commands_.find(commandName); if (it != this->commands_.end()) { - return it.value()(words, channel); + if (auto *command = std::get_if(&it->second)) + { + return (*command)(words, channel); + } + if (auto *command = + std::get_if(&it->second)) + { + CommandContext ctx{ + words, + channel, + dynamic_cast(channel.get()), + }; + return (*command)(ctx); + } + + return ""; } } @@ -3570,12 +3138,12 @@ QString CommandController::execCommand(const QString &textNoEmoji, return text; } -void CommandController::registerCommand(QString commandName, - CommandFunction commandFunction) +void CommandController::registerCommand(const QString &commandName, + CommandFunctionVariants commandFunction) { - assert(!this->commands_.contains(commandName)); + assert(this->commands_.count(commandName) == 0); - this->commands_[commandName] = commandFunction; + this->commands_[commandName] = std::move(commandFunction); this->defaultChatterinoCommandAutoCompletions_.append(commandName); } diff --git a/src/controllers/commands/CommandController.hpp b/src/controllers/commands/CommandController.hpp index f36ea3be6..64a05222e 100644 --- a/src/controllers/commands/CommandController.hpp +++ b/src/controllers/commands/CommandController.hpp @@ -4,6 +4,7 @@ #include "common/SignalVector.hpp" #include "common/Singleton.hpp" #include "controllers/commands/Command.hpp" +#include "controllers/commands/CommandContext.hpp" #include "providers/twitch/TwitchChannel.hpp" #include @@ -12,6 +13,7 @@ #include #include #include +#include namespace chatterino { @@ -46,10 +48,16 @@ private: using CommandFunction = std::function; - void registerCommand(QString commandName, CommandFunction commandFunction); + using CommandFunctionWithContext = std::function; + + using CommandFunctionVariants = + std::variant; + + void registerCommand(const QString &commandName, + CommandFunctionVariants commandFunction); // Chatterino commands - QMap commands_; + std::unordered_map commands_; // User-created commands QMap userCommands_; diff --git a/src/controllers/commands/builtin/twitch/ChatSettings.cpp b/src/controllers/commands/builtin/twitch/ChatSettings.cpp new file mode 100644 index 000000000..3c596cf13 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/ChatSettings.cpp @@ -0,0 +1,434 @@ +#include "controllers/commands/builtin/twitch/ChatSettings.hpp" + +#include "Application.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "util/FormatTime.hpp" +#include "util/Helpers.hpp" + +namespace { + +using namespace chatterino; + +QString formatError(const HelixUpdateChatSettingsError error, + const QString &message, int durationUnitMultiplier = 1) +{ + static const QRegularExpression invalidRange("(\\d+) through (\\d+)"); + + QString errorMessage = QString("Failed to update - "); + using Error = HelixUpdateChatSettingsError; + switch (error) + { + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: + case Error::Forbidden: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::Ratelimited: { + errorMessage += "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::OutOfRange: { + QRegularExpressionMatch matched = invalidRange.match(message); + if (matched.hasMatch()) + { + auto from = matched.captured(1).toInt(); + auto to = matched.captured(2).toInt(); + errorMessage += + QString("The duration is out of the valid range: " + "%1 through %2.") + .arg(from == 0 + ? "0s" + : formatTime(from * durationUnitMultiplier), + to == 0 ? "0s" + : formatTime(to * durationUnitMultiplier)); + } + else + { + errorMessage += message; + } + } + break; + + case Error::Forwarded: { + errorMessage = message; + } + break; + + case Error::Unknown: + default: { + errorMessage += "An unknown error has occurred."; + } + break; + } + return errorMessage; +} + +// Do nothing as we'll receive a message from IRC about updates +auto successCallback = [](auto result) {}; + +auto failureCallback = [](ChannelPtr channel, int durationUnitMultiplier = 1) { + return [channel, durationUnitMultiplier](const auto &error, + const QString &message) { + channel->addMessage(makeSystemMessage( + formatError(error, message, durationUnitMultiplier))); + }; +}; + +const auto P_NOT_LOGGED_IN = + QStringLiteral("You must be logged in to update chat settings!"); + +} // namespace + +namespace chatterino::commands { + +QString emoteOnly(const CommandContext &ctx) +{ + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage(makeSystemMessage(P_NOT_LOGGED_IN)); + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /emoteonly command only works in Twitch channels")); + return ""; + } + + if (ctx.twitchChannel->accessRoomModes()->emoteOnly) + { + ctx.channel->addMessage( + makeSystemMessage("This room is already in emote-only mode.")); + return ""; + } + + getHelix()->updateEmoteMode(ctx.twitchChannel->roomId(), + currentUser->getUserId(), true, successCallback, + failureCallback(ctx.channel)); + + return ""; +} + +QString emoteOnlyOff(const CommandContext &ctx) +{ + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage(makeSystemMessage(P_NOT_LOGGED_IN)); + return ""; + } + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /emoteonlyoff command only works in Twitch channels")); + return ""; + } + + if (!ctx.twitchChannel->accessRoomModes()->emoteOnly) + { + ctx.channel->addMessage( + makeSystemMessage("This room is not in emote-only mode.")); + return ""; + } + + getHelix()->updateEmoteMode(ctx.twitchChannel->roomId(), + currentUser->getUserId(), false, + successCallback, failureCallback(ctx.channel)); + + return ""; +} + +QString subscribers(const CommandContext &ctx) +{ + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage(makeSystemMessage(P_NOT_LOGGED_IN)); + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /subscribers command only works in Twitch channels")); + return ""; + } + + if (ctx.twitchChannel->accessRoomModes()->submode) + { + ctx.channel->addMessage(makeSystemMessage( + "This room is already in subscribers-only mode.")); + return ""; + } + + getHelix()->updateSubscriberMode( + ctx.twitchChannel->roomId(), currentUser->getUserId(), true, + successCallback, failureCallback(ctx.channel)); + + return ""; +} + +QString subscribersOff(const CommandContext &ctx) +{ + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage(makeSystemMessage(P_NOT_LOGGED_IN)); + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /subscribersoff command only works in Twitch channels")); + return ""; + } + + if (!ctx.twitchChannel->accessRoomModes()->submode) + { + ctx.channel->addMessage( + makeSystemMessage("This room is not in subscribers-only mode.")); + return ""; + } + + getHelix()->updateSubscriberMode( + ctx.twitchChannel->roomId(), currentUser->getUserId(), false, + successCallback, failureCallback(ctx.channel)); + + return ""; +} + +QString slow(const CommandContext &ctx) +{ + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage(makeSystemMessage(P_NOT_LOGGED_IN)); + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /slow command only works in Twitch channels")); + return ""; + } + + int duration = 30; + if (ctx.words.length() >= 2) + { + bool ok = false; + duration = ctx.words.at(1).toInt(&ok); + if (!ok || duration <= 0) + { + ctx.channel->addMessage(makeSystemMessage( + "Usage: \"/slow [duration]\" - Enables slow mode (limit how " + "often users may send messages). Duration (optional, " + "default=30) must be a positive number of seconds. Use " + "\"slowoff\" to disable.")); + return ""; + } + } + + if (ctx.twitchChannel->accessRoomModes()->slowMode == duration) + { + ctx.channel->addMessage(makeSystemMessage( + QString("This room is already in %1-second slow mode.") + .arg(duration))); + return ""; + } + + getHelix()->updateSlowMode(ctx.twitchChannel->roomId(), + currentUser->getUserId(), duration, + successCallback, failureCallback(ctx.channel)); + + return ""; +} + +QString slowOff(const CommandContext &ctx) +{ + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage(makeSystemMessage(P_NOT_LOGGED_IN)); + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /slowoff command only works in Twitch channels")); + return ""; + } + + if (ctx.twitchChannel->accessRoomModes()->slowMode <= 0) + { + ctx.channel->addMessage( + makeSystemMessage("This room is not in slow mode.")); + return ""; + } + + getHelix()->updateSlowMode(ctx.twitchChannel->roomId(), + currentUser->getUserId(), boost::none, + successCallback, failureCallback(ctx.channel)); + + return ""; +} + +QString followers(const CommandContext &ctx) +{ + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage(makeSystemMessage(P_NOT_LOGGED_IN)); + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /followers command only works in Twitch channels")); + return ""; + } + + int duration = 0; + if (ctx.words.length() >= 2) + { + auto parsed = parseDurationToSeconds(ctx.words.mid(1).join(' '), 60); + duration = (int)(parsed / 60); + // -1 / 60 == 0 => use parsed + if (parsed < 0) + { + ctx.channel->addMessage(makeSystemMessage( + "Usage: \"/followers [duration]\" - Enables followers-only " + "mode (only users who have followed for 'duration' may chat). " + "Examples: \"30m\", \"1 week\", \"5 days 12 hours\". Must be " + "less than 3 months.")); + return ""; + } + } + + if (ctx.twitchChannel->accessRoomModes()->followerOnly == duration) + { + ctx.channel->addMessage(makeSystemMessage( + QString("This room is already in %1 followers-only mode.") + .arg(formatTime(duration * 60)))); + return ""; + } + + getHelix()->updateFollowerMode( + ctx.twitchChannel->roomId(), currentUser->getUserId(), duration, + successCallback, failureCallback(ctx.channel, 60)); + + return ""; +} + +QString followersOff(const CommandContext &ctx) +{ + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage(makeSystemMessage(P_NOT_LOGGED_IN)); + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /followersoff command only works in Twitch channels")); + return ""; + } + + if (ctx.twitchChannel->accessRoomModes()->followerOnly < 0) + { + ctx.channel->addMessage( + makeSystemMessage("This room is not in followers-only mode. ")); + return ""; + } + + getHelix()->updateFollowerMode( + ctx.twitchChannel->roomId(), currentUser->getUserId(), boost::none, + successCallback, failureCallback(ctx.channel)); + + return ""; +} + +QString uniqueChat(const CommandContext &ctx) +{ + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage(makeSystemMessage(P_NOT_LOGGED_IN)); + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /uniquechat command only works in Twitch channels")); + return ""; + } + + if (ctx.twitchChannel->accessRoomModes()->r9k) + { + ctx.channel->addMessage( + makeSystemMessage("This room is already in unique-chat mode.")); + return ""; + } + + getHelix()->updateUniqueChatMode( + ctx.twitchChannel->roomId(), currentUser->getUserId(), true, + successCallback, failureCallback(ctx.channel)); + + return ""; +} + +QString uniqueChatOff(const CommandContext &ctx) +{ + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage(makeSystemMessage(P_NOT_LOGGED_IN)); + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /uniquechatoff command only works in Twitch channels")); + return ""; + } + + if (!ctx.twitchChannel->accessRoomModes()->r9k) + { + ctx.channel->addMessage( + makeSystemMessage("This room is not in unique-chat mode.")); + return ""; + } + + getHelix()->updateUniqueChatMode( + ctx.twitchChannel->roomId(), currentUser->getUserId(), false, + successCallback, failureCallback(ctx.channel)); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/ChatSettings.hpp b/src/controllers/commands/builtin/twitch/ChatSettings.hpp new file mode 100644 index 000000000..08932160e --- /dev/null +++ b/src/controllers/commands/builtin/twitch/ChatSettings.hpp @@ -0,0 +1,24 @@ +#pragma once + +#include "controllers/commands/CommandContext.hpp" + +#include + +namespace chatterino::commands { + +QString emoteOnly(const CommandContext &ctx); +QString emoteOnlyOff(const CommandContext &ctx); + +QString subscribers(const CommandContext &ctx); +QString subscribersOff(const CommandContext &ctx); + +QString slow(const CommandContext &ctx); +QString slowOff(const CommandContext &ctx); + +QString followers(const CommandContext &ctx); +QString followersOff(const CommandContext &ctx); + +QString uniqueChat(const CommandContext &ctx); +QString uniqueChatOff(const CommandContext &ctx); + +} // namespace chatterino::commands From df4c294875f9d08cd2434c97cd59918f72826ec4 Mon Sep 17 00:00:00 2001 From: Mm2PL Date: Sun, 6 Nov 2022 17:30:53 +0100 Subject: [PATCH 102/946] Allow hiding moderation actions in streamer mode (#3926) Co-authored-by: pajlada --- CHANGELOG.md | 1 + src/messages/layouts/MessageLayout.cpp | 15 +++++++++++---- src/singletons/Settings.hpp | 2 ++ src/widgets/settingspages/GeneralPage.cpp | 1 + 4 files changed, 15 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 05c302843..d313f52df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Major: Added support for Twitch's Chat Replies. [Wiki Page](https://wiki.chatterino.com/Features/#message-replies) (#3722, #3989, #4041, #4047, #4055, #4067, #4077, #3905) - Major: Added multi-channel searching to search dialog via keyboard shortcut. (Ctrl+Shift+F by default) (#3694, #3875) - Major: Added support for emotes and badges from [7TV](https://7tv.app). [Wiki Page](https://wiki.chatterino.com/Third_party_services/#7tv) (#4002, #4062) +- Minor: Allow hiding moderation actions in streamer mode. (#3926) - Minor: Added highlights for `Elevated Messages`. (#4016) - Minor: Removed total views from the usercard, as Twitch no longer updates the number. (#3792) - Minor: Load missing messages from Recent Messages API upon reconnecting (#3878, #3932) diff --git a/src/messages/layouts/MessageLayout.cpp b/src/messages/layouts/MessageLayout.cpp index 65bbf36ca..f24039596 100644 --- a/src/messages/layouts/MessageLayout.cpp +++ b/src/messages/layouts/MessageLayout.cpp @@ -10,6 +10,7 @@ #include "singletons/Theme.hpp" #include "singletons/WindowManager.hpp" #include "util/DebugCount.hpp" +#include "util/StreamerMode.hpp" #include #include @@ -148,11 +149,17 @@ void MessageLayout::actuallyLayout(int width, MessageElementFlags flags) continue; } - if (hideModerationActions && - (this->message_->flags.has(MessageFlag::Timeout) || - this->message_->flags.has(MessageFlag::Untimeout))) + if (this->message_->flags.has(MessageFlag::Timeout) || + this->message_->flags.has(MessageFlag::Untimeout)) { - continue; + // This condition has been set up to execute isInStreamerMode() as the last thing + // as it could end up being expensive. + if (hideModerationActions || + (getSettings()->streamerModeHideModActions && + isInStreamerMode())) + { + continue; + } } if (hideSimilar && this->message_->flags.has(MessageFlag::Similar)) diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 4fd2172bd..ef67ec0dc 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -242,6 +242,8 @@ public: "/streamerMode/hideLinkThumbnails", true}; BoolSetting streamerModeHideViewerCountAndDuration = { "/streamerMode/hideViewerCountAndDuration", false}; + BoolSetting streamerModeHideModActions = {"/streamerMode/hideModActions", + true}; BoolSetting streamerModeMuteMentions = {"/streamerMode/muteMentions", true}; BoolSetting streamerModeSuppressLiveNotifications = { "/streamerMode/supressLiveNotifications", false}; diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index efeab9361..fe036ea29 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -416,6 +416,7 @@ void GeneralPage::initLayout(GeneralPageView &layout) layout.addCheckbox( "Hide viewer count and stream length while hovering over split header", s.streamerModeHideViewerCountAndDuration); + layout.addCheckbox("Hide moderation actions", s.streamerModeHideModActions); layout.addCheckbox("Mute mention sounds", s.streamerModeMuteMentions); layout.addCheckbox("Suppress Live Notifications", s.streamerModeSuppressLiveNotifications); From 7714237531086b4a46766f9efc8a0199820a56d1 Mon Sep 17 00:00:00 2001 From: pajlada Date: Tue, 8 Nov 2022 17:41:24 +0100 Subject: [PATCH 103/946] Update `pajlada/create-release` action to v2.0.4 (#4123) --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ede984e58..548a8b4ae 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -231,7 +231,7 @@ jobs: steps: - name: Create release id: create_release - uses: pajlada/create-release@v2.0.3 + uses: pajlada/create-release@v2.0.4 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: From 1741ac74826882f0a215db100314bd4925b1ca64 Mon Sep 17 00:00:00 2001 From: Daniel Sage <24928223+dnsge@users.noreply.github.com> Date: Tue, 8 Nov 2022 16:46:43 -0500 Subject: [PATCH 104/946] Improve look of tabs when using a layout other than top (#3925) --- CHANGELOG.md | 1 + src/widgets/Notebook.cpp | 35 +++++-- src/widgets/helper/NotebookTab.cpp | 147 ++++++++++++++++++++++++----- src/widgets/helper/NotebookTab.hpp | 3 + 4 files changed, 151 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d313f52df..03c32a35b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ - Minor: Migrated /chatters to Helix API. (#4088, #4097, #4114) - Minor: Migrated /mods to Helix API. (#4103) - Minor: Add settings tooltips. (#3437) +- Minor: Improved look of tabs when using a layout other than top. (#3925) - Bugfix: Connection to Twitch PubSub now recovers more reliably. (#3643, #3716) - Bugfix: Fixed `Smooth scrolling on new messages` setting sometimes hiding messages. (#4028) - Bugfix: Fixed a crash that can occur when closing and quickly reopening a split, then running a command. (#3852) diff --git a/src/widgets/Notebook.cpp b/src/widgets/Notebook.cpp index d90043eee..df8ef5dea 100644 --- a/src/widgets/Notebook.cpp +++ b/src/widgets/Notebook.cpp @@ -73,6 +73,7 @@ NotebookTab *Notebook::addPage(QWidget *page, QString title, bool select) tab->page = page; tab->setCustomTitle(title); + tab->setTabLocation(this->tabLocation_); Item item; item.page = page; @@ -493,6 +494,7 @@ void Notebook::performLayout(bool animated) const auto minimumTabAreaSpace = int(tabHeight * 0.5); const auto addButtonWidth = this->showAddButton_ ? tabHeight : 0; const auto lineThickness = int(2 * scale); + const auto tabSpacer = std::max(1, int(scale * 1)); const auto buttonWidth = tabHeight; const auto buttonHeight = tabHeight - 1; @@ -544,7 +546,7 @@ void Notebook::performLayout(bool animated) /// Layout tab item.tab->growWidth(0); item.tab->moveAnimated(QPoint(x, y), animated); - x += item.tab->width() + std::max(1, int(scale * 1)); + x += item.tab->width() + tabSpacer; } /// Update which tabs are in the last row @@ -605,10 +607,12 @@ void Notebook::performLayout(bool animated) } if (this->visibleButtonCount() > 0) - y = tabHeight; + y = tabHeight + lineThickness; // account for divider line int totalButtonWidths = x; - int top = y; + const int top = y + tabSpacer; // add margin + + y = top; x = left; // zneix: if we were to remove buttons when tabs are hidden @@ -654,7 +658,8 @@ void Notebook::performLayout(bool animated) /// Layout tab item.tab->growWidth(largestWidth); item.tab->moveAnimated(QPoint(x, y), animated); - y += tabHeight; + item.tab->setInLastRow(isLastColumn); + y += tabHeight + tabSpacer; } if (isLastColumn && this->showAddButton_) @@ -704,10 +709,12 @@ void Notebook::performLayout(bool animated) } if (this->visibleButtonCount() > 0) - y = tabHeight; + y = tabHeight + lineThickness; // account for divider line int consumedButtonWidths = right - x; - int top = y; + const int top = y + tabSpacer; // add margin + + y = top; x = right; // zneix: if we were to remove buttons when tabs are hidden @@ -758,7 +765,8 @@ void Notebook::performLayout(bool animated) /// Layout tab item.tab->growWidth(largestWidth); item.tab->moveAnimated(QPoint(x, y), animated); - y += tabHeight; + item.tab->setInLastRow(isLastColumn); + y += tabHeight + tabSpacer; } if (isLastColumn && this->showAddButton_) @@ -817,7 +825,7 @@ void Notebook::performLayout(bool animated) if (this->showTabs_) { // reset vertical position regardless - y = bottom - tabHeight; + y = bottom - tabHeight - tabSpacer; // layout tabs /// Notebook tabs need to know if they are in the last row. @@ -843,7 +851,7 @@ void Notebook::performLayout(bool animated) /// Layout tab item.tab->growWidth(0); item.tab->moveAnimated(QPoint(x, y), animated); - x += item.tab->width() + std::max(1, int(scale * 1)); + x += item.tab->width() + tabSpacer; } /// Update which tabs are in the last row @@ -866,7 +874,7 @@ void Notebook::performLayout(bool animated) int consumedBottomSpace = std::max({bottom - y, consumedButtonHeights, minimumTabAreaSpace}); - int tabsStart = bottom - consumedBottomSpace; + int tabsStart = bottom - consumedBottomSpace - lineThickness; if (this->lineOffset_ != tabsStart) { @@ -917,6 +925,13 @@ void Notebook::setTabLocation(NotebookTabLocation location) if (location != this->tabLocation_) { this->tabLocation_ = location; + + // Update all tabs + for (const auto &item : this->items_) + { + item.tab->setTabLocation(location); + } + this->performLayout(); } } diff --git a/src/widgets/helper/NotebookTab.cpp b/src/widgets/helper/NotebookTab.cpp index b92768898..9aea1a18c 100644 --- a/src/widgets/helper/NotebookTab.cpp +++ b/src/widgets/helper/NotebookTab.cpp @@ -34,6 +34,29 @@ namespace { return 1.0; #endif } + + // Translates the given rectangle by an amount in the direction to appear like the tab is selected. + // For example, if location is Top, the rectangle will be translated in the negative Y direction, + // or "up" on the screen, by amount. + void translateRectForLocation(QRect &rect, NotebookTabLocation location, + int amount) + { + switch (location) + { + case NotebookTabLocation::Top: + rect.translate(0, -amount); + break; + case NotebookTabLocation::Left: + rect.translate(-amount, 0); + break; + case NotebookTabLocation::Right: + rect.translate(amount, 0); + break; + case NotebookTabLocation::Bottom: + rect.translate(0, amount); + break; + } + } } // namespace NotebookTab::NotebookTab(Notebook *notebook) @@ -294,6 +317,15 @@ void NotebookTab::setInLastRow(bool value) } } +void NotebookTab::setTabLocation(NotebookTabLocation location) +{ + if (this->tabLocation_ != location) + { + this->tabLocation_ = location; + this->update(); + } +} + void NotebookTab::setLive(bool isLive) { if (this->isLive_ != isLive) @@ -401,19 +433,56 @@ void NotebookTab::paintEvent(QPaintEvent *) (windowFocused ? colors.backgrounds.regular : colors.backgrounds.unfocused); + auto selectionOffset = ceil((this->selected_ ? 0.f : 1.f) * scale); + // fill the tab background auto bgRect = this->rect(); - bgRect.setTop(ceil((this->selected_ ? 0.f : 1.f) * scale)); + switch (this->tabLocation_) + { + case NotebookTabLocation::Top: + bgRect.setTop(selectionOffset); + break; + case NotebookTabLocation::Left: + bgRect.setLeft(selectionOffset); + break; + case NotebookTabLocation::Right: + bgRect.setRight(bgRect.width() - selectionOffset); + break; + case NotebookTabLocation::Bottom: + bgRect.setBottom(bgRect.height() - selectionOffset); + break; + } painter.fillRect(bgRect, tabBackground); - // top line - painter.fillRect( - QRectF(0, ceil((this->selected_ ? 0.f : 1.f) * scale), this->width(), - ceil((this->selected_ ? 2.f : 1.f) * scale)), - this->mouseOver_ - ? colors.line.hover - : (windowFocused ? colors.line.regular : colors.line.unfocused)); + // draw color indicator line + auto lineThickness = ceil((this->selected_ ? 2.f : 1.f) * scale); + auto lineColor = this->mouseOver_ ? colors.line.hover + : (windowFocused ? colors.line.regular + : colors.line.unfocused); + + QRect lineRect; + switch (this->tabLocation_) + { + case NotebookTabLocation::Top: + lineRect = + QRect(bgRect.left(), bgRect.y(), bgRect.width(), lineThickness); + break; + case NotebookTabLocation::Left: + lineRect = + QRect(bgRect.x(), bgRect.top(), lineThickness, bgRect.height()); + break; + case NotebookTabLocation::Right: + lineRect = QRect(bgRect.right() - lineThickness, bgRect.top(), + lineThickness, bgRect.height()); + break; + case NotebookTabLocation::Bottom: + lineRect = QRect(bgRect.left(), bgRect.bottom() - lineThickness, + bgRect.width(), lineThickness); + break; + } + + painter.fillRect(lineRect, lineColor); // draw live indicator if (this->isLive_ && getSettings()->showTabLive) @@ -426,9 +495,12 @@ void NotebookTab::paintEvent(QPaintEvent *) painter.setBrush(b); auto x = this->width() - (7 * scale); - auto y = 4 * scale + (this->isSelected() ? 0 : 1); + auto y = 4 * scale; auto diameter = 4 * scale; - painter.drawEllipse(QRectF(x, y, diameter, diameter)); + QRect liveIndicatorRect(x, y, diameter, diameter); + translateRectForLocation(liveIndicatorRect, this->tabLocation_, + this->selected_ ? 0 : -1); + painter.drawEllipse(liveIndicatorRect); } // set the pen color @@ -440,8 +512,9 @@ void NotebookTab::paintEvent(QPaintEvent *) // draw text int offset = int(scale * 8); - QRect textRect(offset, this->selected_ ? 1 : 2, - this->width() - offset - offset, height); + QRect textRect(offset, 0, this->width() - offset - offset, height); + translateRectForLocation(textRect, this->tabLocation_, + this->selected_ ? -1 : -2); if (this->shouldDrawXButton()) { @@ -465,9 +538,6 @@ void NotebookTab::paintEvent(QPaintEvent *) QRect xRect = this->getXRect(); if (!xRect.isNull()) { - if (this->selected_) - xRect.moveTop(xRect.top() - 1); - painter.setBrush(QColor("#fff")); if (this->mouseOverX_) @@ -495,11 +565,26 @@ void NotebookTab::paintEvent(QPaintEvent *) this->fancyPaint(painter); } - // draw line at bottom + // draw line at border if (!this->selected_ && this->isInLastRow_) { - painter.fillRect(0, this->height() - 1, this->width(), 1, - app->themes->window.background); + QRect borderRect; + switch (this->tabLocation_) + { + case NotebookTabLocation::Top: + borderRect = QRect(0, this->height() - 1, this->width(), 1); + break; + case NotebookTabLocation::Left: + borderRect = QRect(this->width() - 1, 0, 1, this->height()); + break; + case NotebookTabLocation::Right: + borderRect = QRect(0, 0, 1, this->height()); + break; + case NotebookTabLocation::Bottom: + borderRect = QRect(0, 0, this->width(), 1); + break; + } + painter.fillRect(borderRect, app->themes->window.background); } } @@ -683,14 +768,26 @@ void NotebookTab::wheelEvent(QWheelEvent *event) QRect NotebookTab::getXRect() { - // if (!this->notebook->getAllowUserTabManagement()) { - // return QRect(); - // } - + QRect rect = this->rect(); float s = this->scale(); - return QRect(this->width() - static_cast(20 * s), - static_cast(9 * s), static_cast(16 * s), - static_cast(16 * s)); + int size = static_cast(16 * s); + + int centerAdjustment = + this->tabLocation_ == + (NotebookTabLocation::Top || + this->tabLocation_ == NotebookTabLocation::Bottom) + ? (size / 3) // slightly off true center + : (size / 2); // true center + + QRect xRect(rect.right() - static_cast(20 * s), + rect.center().y() - centerAdjustment, size, size); + + if (this->selected_) + { + translateRectForLocation(xRect, this->tabLocation_, 1); + } + + return xRect; } } // namespace chatterino diff --git a/src/widgets/helper/NotebookTab.hpp b/src/widgets/helper/NotebookTab.hpp index 1bd893efb..6fd4db9e7 100644 --- a/src/widgets/helper/NotebookTab.hpp +++ b/src/widgets/helper/NotebookTab.hpp @@ -2,6 +2,7 @@ #include "common/Common.hpp" #include "widgets/BaseWidget.hpp" +#include "widgets/Notebook.hpp" #include "widgets/helper/Button.hpp" #include @@ -40,6 +41,7 @@ public: void setSelected(bool value); void setInLastRow(bool value); + void setTabLocation(NotebookTabLocation location); void setLive(bool isLive); void setHighlightState(HighlightState style); @@ -94,6 +96,7 @@ private: bool mouseDownX_{}; bool isInLastRow_{}; int mouseWheelDelta_ = 0; + NotebookTabLocation tabLocation_ = NotebookTabLocation::Top; HighlightState highlightState_ = HighlightState::None; bool highlightEnabled_ = true; From 42bca5f8c79476fa5a025a9caa27ac3b747609ff Mon Sep 17 00:00:00 2001 From: pajlada Date: Tue, 8 Nov 2022 23:07:44 +0100 Subject: [PATCH 105/946] Switch to ncipollo/release-action for generating our Nightly releases (#4125) --- .CI/CreateUbuntuDeb.sh | 11 ++++-- .github/workflows/build.yml | 79 +++++++------------------------------ 2 files changed, 23 insertions(+), 67 deletions(-) diff --git a/.CI/CreateUbuntuDeb.sh b/.CI/CreateUbuntuDeb.sh index 9a90ca32f..752f211b3 100755 --- a/.CI/CreateUbuntuDeb.sh +++ b/.CI/CreateUbuntuDeb.sh @@ -6,8 +6,13 @@ if [ ! -f ./bin/chatterino ] || [ ! -x ./bin/chatterino ]; then exit 1 fi -chatterino_version=$(git describe | cut -c 2-) -echo "Found Chatterino version $chatterino_version via git" +chatterino_version=$(git describe 2>/dev/null | cut -c 2-) || true +if [ -z "$chatterino_version" ]; then + chatterino_version="0.0.0-dev" + echo "Falling back to setting the version to '$chatterino_version'" +else + echo "Found Chatterino version $chatterino_version via git" +fi rm -vrf "./package" || true # delete any old packaging dir @@ -32,4 +37,4 @@ DESTDIR="$packaging_dir" make INSTALL_ROOT="$packaging_dir" -j"$(nproc)" install echo "" echo "Building package..." -dpkg-deb --build "$packaging_dir" "Chatterino.deb" +dpkg-deb --build "$packaging_dir" "Chatterino-x86_64.deb" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 548a8b4ae..1d86b1b75 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -183,7 +183,7 @@ jobs: uses: actions/upload-artifact@v3 with: name: Chatterino-${{ matrix.qt-version }}.deb - path: build/Chatterino.deb + path: build/Chatterino-x86_64.deb # MACOS - name: Install dependencies (MacOS) @@ -229,83 +229,34 @@ jobs: if: (github.event_name == 'push' && github.ref == 'refs/heads/master') steps: - - name: Create release - id: create_release - uses: pajlada/create-release@v2.0.4 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tag_name: nightly-build - backup_tag_name: backup-nightly-build - release_name: Nightly Release - body: | - Nightly Build - prerelease: true - - uses: actions/download-artifact@v3 with: name: chatterino-windows-x86-64-5.15.2.zip - path: windows/ + path: release-artifacts/ - uses: actions/download-artifact@v3 with: name: Chatterino-x86_64-5.15.2.AppImage - path: linux/ + path: release-artifacts/ - uses: actions/download-artifact@v3 with: name: Chatterino-5.15.2.deb - path: ubuntu/ + path: release-artifacts/ - uses: actions/download-artifact@v3 with: name: chatterino-osx-5.15.2.dmg - path: macos/ + path: release-artifacts/ - # TODO: Extract dmg and appimage - - # - name: Read upload URL into output - # id: upload_url - # run: | - # echo "::set-output name=upload_url::$(cat release-upload-url.txt/release-upload-url.txt)" - - - name: Upload release asset (Windows) - uses: actions/upload-release-asset@v1.0.2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + - name: Create release + uses: ncipollo/release-action@v1.11.1 with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./windows/chatterino-windows-x86-64.zip - asset_name: chatterino-windows-x86-64.zip - asset_content_type: application/zip - - - name: Upload release asset (Ubuntu) - uses: actions/upload-release-asset@v1.0.2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./linux/Chatterino-x86_64.AppImage - asset_name: Chatterino-x86_64.AppImage - asset_content_type: application/x-executable - - - name: Upload release asset (Ubuntu .deb) - uses: actions/upload-release-asset@v1.0.2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./ubuntu/Chatterino.deb - asset_name: Chatterino-x86_64.deb - asset_content_type: application/vnd.debian.binary-package - - - name: Upload release asset (MacOS) - uses: actions/upload-release-asset@v1.0.2 - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - upload_url: ${{ steps.create_release.outputs.upload_url }} - asset_path: ./macos/chatterino-osx.dmg - asset_name: chatterino-osx.dmg - asset_content_type: application/x-bzip2 - + removeArtifacts: true + allowUpdates: true + artifactErrorsFailBuild: true + artifacts: "release-artifacts/*" + generateReleaseNotes: true + prerelease: true + name: Nightly Release + tag: nightly-build From dd8383355cc01820b78b9cf7bf733750a454b334 Mon Sep 17 00:00:00 2001 From: kornes <28986062+kornes@users.noreply.github.com> Date: Wed, 9 Nov 2022 10:35:48 +0000 Subject: [PATCH 106/946] chore: update vcpkg baseline (#4126) --- vcpkg.json | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/vcpkg.json b/vcpkg.json index c515ff196..403ddcdbe 100644 --- a/vcpkg.json +++ b/vcpkg.json @@ -2,24 +2,25 @@ "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg/master/scripts/vcpkg.schema.json", "name": "chatterino", "version": "2.0.0", + "builtin-baseline": "5ba2b95aea2a39aa89444949c7a047af38c401c1", "dependencies": [ "benchmark", "boost-asio", - "boost-signals2", + "boost-circular-buffer", "boost-foreach", "boost-interprocess", "boost-random", + "boost-signals2", "boost-variant", - "boost-circular-buffer", "gtest", "openssl", "qt5-multimedia", "qt5-tools" ], - "builtin-baseline": "8da5d2b4503b3100b6b7bb26ffeeefd0e8a25799", "overrides": [ { - "name": "openssl", "version-string": "1.1.1n" + "name": "openssl", + "version-string": "1.1.1n" } ] } From 3c10fc12e6f91aa4e565b2250e3e5f6c5c25775a Mon Sep 17 00:00:00 2001 From: Wissididom <30803034+Wissididom@users.noreply.github.com> Date: Wed, 9 Nov 2022 15:47:42 +0100 Subject: [PATCH 107/946] Release with actual commit message (#4130) --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 1d86b1b75..459f349bf 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -256,7 +256,7 @@ jobs: allowUpdates: true artifactErrorsFailBuild: true artifacts: "release-artifacts/*" - generateReleaseNotes: true + body: ${{ github.event.head_commit.message }} prerelease: true name: Nightly Release tag: nightly-build From 3303cdc0cbe56382c2842688916975f8f9845729 Mon Sep 17 00:00:00 2001 From: Mm2PL Date: Thu, 10 Nov 2022 10:07:50 +0100 Subject: [PATCH 108/946] =?UTF-8?q?BaseTheme=20is=20no=20more=20?= =?UTF-8?q?=F0=9F=A6=80=20(#4132)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: pajlada --- CHANGELOG.md | 1 + src/BaseTheme.cpp | 216 -------------------------- src/BaseTheme.hpp | 121 --------------- src/CMakeLists.txt | 3 - src/singletons/Theme.cpp | 195 ++++++++++++++++++++++- src/singletons/Theme.hpp | 92 ++++++++++- src/widgets/BaseWidget.cpp | 2 +- src/widgets/BaseWindow.cpp | 2 +- src/widgets/TooltipWidget.cpp | 1 - src/widgets/helper/Button.cpp | 2 +- src/widgets/helper/TitlebarButton.cpp | 4 +- 11 files changed, 286 insertions(+), 353 deletions(-) delete mode 100644 src/BaseTheme.cpp delete mode 100644 src/BaseTheme.hpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 03c32a35b..404fa86d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,7 @@ - Bugfix: Fixed channel-based popups from rewriting messages to file log (#4060) - Bugfix: Fixed invalid/dangling completion when cycling through previous messages or replying (#4072) - Bugfix: Fixed incorrect .desktop icon path. (#4078) +- Dev: Got rid of BaseTheme (#4132) - Dev: Removed official support for QMake. (#3839, #3883) - Dev: Rewrote LimitedQueue (#3798) - Dev: Overhauled highlight system by moving all checks into a Controller allowing for easier tests. (#3399, #3801, #3835) diff --git a/src/BaseTheme.cpp b/src/BaseTheme.cpp deleted file mode 100644 index 98f8a2df1..000000000 --- a/src/BaseTheme.cpp +++ /dev/null @@ -1,216 +0,0 @@ -#include "BaseTheme.hpp" - -namespace chatterino { -namespace { - double getMultiplierByTheme(const QString &themeName) - { - if (themeName == "Light") - { - return 0.8; - } - else if (themeName == "White") - { - return 1.0; - } - else if (themeName == "Black") - { - return -1.0; - } - else if (themeName == "Dark") - { - return -0.8; - } - /* - else if (themeName == "Custom") - { - return getSettings()->customThemeMultiplier.getValue(); - } - */ - - return -0.8; - } -} // namespace - -bool AB_THEME_CLASS::isLightTheme() const -{ - return this->isLight_; -} - -void AB_THEME_CLASS::update() -{ - this->actuallyUpdate(this->themeHue, - getMultiplierByTheme(this->themeName.getValue())); - - this->updated.invoke(); -} - -void AB_THEME_CLASS::actuallyUpdate(double hue, double multiplier) -{ - this->isLight_ = multiplier > 0; - bool lightWin = isLight_; - - // QColor themeColor = QColor::fromHslF(hue, 0.43, 0.5); - QColor themeColor = QColor::fromHslF(hue, 0.8, 0.5); - QColor themeColorNoSat = QColor::fromHslF(hue, 0, 0.5); - - qreal sat = 0; - // 0.05; - - auto getColor = [multiplier](double h, double s, double l, double a = 1.0) { - return QColor::fromHslF(h, s, ((l - 0.5) * multiplier) + 0.5, a); - }; - - /// WINDOW - { -#ifdef Q_OS_LINUX - this->window.background = lightWin ? "#fff" : QColor(61, 60, 56); -#else - this->window.background = lightWin ? "#fff" : "#111"; -#endif - - QColor fg = this->window.text = lightWin ? "#000" : "#eee"; - this->window.borderFocused = lightWin ? "#ccc" : themeColor; - this->window.borderUnfocused = lightWin ? "#ccc" : themeColorNoSat; - - // Ubuntu style - // TODO: add setting for this - // TabText = QColor(210, 210, 210); - // TabBackground = QColor(61, 60, 56); - // TabHoverText = QColor(210, 210, 210); - // TabHoverBackground = QColor(73, 72, 68); - - // message (referenced later) - this->messages.textColors.caret = // - this->messages.textColors.regular = isLight_ ? "#000" : "#fff"; - - QColor highlighted = lightWin ? QColor("#ff0000") : QColor("#ee6166"); - - /// TABS - if (lightWin) - { - this->tabs.regular = { - QColor("#444"), - {QColor("#fff"), QColor("#eee"), QColor("#fff")}, - {QColor("#fff"), QColor("#fff"), QColor("#fff")}}; - this->tabs.newMessage = { - QColor("#222"), - {QColor("#fff"), QColor("#eee"), QColor("#fff")}, - {QColor("#bbb"), QColor("#bbb"), QColor("#bbb")}}; - this->tabs.highlighted = { - fg, - {QColor("#fff"), QColor("#eee"), QColor("#fff")}, - {highlighted, highlighted, highlighted}}; - this->tabs.selected = { - QColor("#000"), - {QColor("#b4d7ff"), QColor("#b4d7ff"), QColor("#b4d7ff")}, - {this->accent, this->accent, this->accent}}; - } - else - { - this->tabs.regular = { - QColor("#aaa"), - {QColor("#252525"), QColor("#252525"), QColor("#252525")}, - {QColor("#444"), QColor("#444"), QColor("#444")}}; - this->tabs.newMessage = { - fg, - {QColor("#252525"), QColor("#252525"), QColor("#252525")}, - {QColor("#888"), QColor("#888"), QColor("#888")}}; - this->tabs.highlighted = { - fg, - {QColor("#252525"), QColor("#252525"), QColor("#252525")}, - {highlighted, highlighted, highlighted}}; - - this->tabs.selected = { - QColor("#fff"), - {QColor("#555555"), QColor("#555555"), QColor("#555555")}, - {this->accent, this->accent, this->accent}}; - } - - // scrollbar - this->scrollbars.highlights.highlight = QColor("#ee6166"); - this->scrollbars.highlights.subscription = QColor("#C466FF"); - - // this->tabs.newMessage = { - // fg, - // {QBrush(blendColors(themeColor, "#ccc", 0.9), Qt::FDiagPattern), - // QBrush(blendColors(themeColor, "#ccc", 0.9), Qt::FDiagPattern), - // QBrush(blendColors(themeColorNoSat, "#ccc", 0.9), - // Qt::FDiagPattern)}}; - - // this->tabs.newMessage = { - // fg, - // {QBrush(blendColors(themeColor, "#666", 0.7), - // Qt::FDiagPattern), - // QBrush(blendColors(themeColor, "#666", 0.5), - // Qt::FDiagPattern), - // QBrush(blendColors(themeColorNoSat, "#666", 0.7), - // Qt::FDiagPattern)}}; - // this->tabs.highlighted = {fg, {QColor("#777"), - // QColor("#777"), QColor("#666")}}; - - this->tabs.dividerLine = - this->tabs.selected.backgrounds.regular.color(); - } - - // Message - this->messages.textColors.link = - isLight_ ? QColor(66, 134, 244) : 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 = getColor(0, sat, 1); - this->messages.backgrounds.alternate = getColor(0, sat, 0.96); - - // this->messages.backgrounds.resub - // this->messages.backgrounds.whisper - this->messages.disabled = getColor(0, sat, 1, 0.6); - // this->messages.seperator = - // this->messages.seperatorInner = - - int complementaryGray = this->isLightTheme() ? 20 : 230; - this->messages.highlightAnimationStart = - QColor(complementaryGray, complementaryGray, complementaryGray, 110); - this->messages.highlightAnimationEnd = - QColor(complementaryGray, complementaryGray, complementaryGray, 0); - - // Scrollbar - this->scrollbars.background = QColor(0, 0, 0, 0); - // this->scrollbars.background = splits.background; - // this->scrollbars.background.setAlphaF(qreal(0.2)); - this->scrollbars.thumb = getColor(0, sat, 0.70); - this->scrollbars.thumbSelected = getColor(0, sat, 0.65); - - // tooltip - this->tooltip.background = QColor(0, 0, 0); - this->tooltip.text = QColor(255, 255, 255); - - // Selection - this->messages.selection = - isLightTheme() ? QColor(0, 0, 0, 64) : QColor(255, 255, 255, 64); -} - -QColor AB_THEME_CLASS::blendColors(const QColor &color1, const QColor &color2, - qreal ratio) -{ - int r = int(color1.red() * (1 - ratio) + color2.red() * ratio); - int g = int(color1.green() * (1 - ratio) + color2.green() * ratio); - int b = int(color1.blue() * (1 - ratio) + color2.blue() * ratio); - - return QColor(r, g, b, 255); -} - -#ifndef AB_CUSTOM_THEME -Theme *getTheme() -{ - static auto theme = [] { - auto theme = new Theme(); - theme->update(); - return theme; - }(); - - return theme; -} -#endif - -} // namespace chatterino diff --git a/src/BaseTheme.hpp b/src/BaseTheme.hpp deleted file mode 100644 index e04fce285..000000000 --- a/src/BaseTheme.hpp +++ /dev/null @@ -1,121 +0,0 @@ -#ifndef AB_THEME_H -#define AB_THEME_H - -#include -#include -#include - -#ifdef AB_CUSTOM_THEME -# define AB_THEME_CLASS BaseTheme -#else -# define AB_THEME_CLASS Theme -#endif - -namespace chatterino { - -class Theme; - -class AB_THEME_CLASS -{ -public: - bool isLightTheme() const; - - struct TabColors { - QColor text; - struct { - QBrush regular; - QBrush hover; - QBrush unfocused; - } backgrounds; - struct { - QColor regular; - QColor hover; - QColor unfocused; - } line; - }; - - QColor accent{"#00aeef"}; - - /// WINDOW - struct { - QColor background; - QColor text; - QColor borderUnfocused; - QColor borderFocused; - } window; - - /// TABS - struct { - TabColors regular; - TabColors newMessage; - TabColors highlighted; - TabColors selected; - QColor border; - QColor dividerLine; - } tabs; - - /// MESSAGES - struct { - struct { - QColor regular; - QColor caret; - QColor link; - QColor system; - QColor chatPlaceholder; - } textColors; - - struct { - QColor regular; - QColor alternate; - // QColor whisper; - } backgrounds; - - QColor disabled; - // QColor seperator; - // QColor seperatorInner; - QColor selection; - - QColor highlightAnimationStart; - QColor highlightAnimationEnd; - } messages; - - /// SCROLLBAR - struct { - QColor background; - QColor thumb; - QColor thumbSelected; - struct { - QColor highlight; - QColor subscription; - } highlights; - } scrollbars; - - /// TOOLTIP - struct { - QColor text; - QColor background; - } tooltip; - - void update(); - virtual void actuallyUpdate(double hue, double multiplier); - QColor blendColors(const QColor &color1, const QColor &color2, qreal ratio); - - pajlada::Signals::NoArgSignal updated; - - QStringSetting themeName{"/appearance/theme/name", "Dark"}; - DoubleSetting themeHue{"/appearance/theme/hue", 0.0}; - -private: - bool isLight_ = false; -}; - -// Implemented in parent project if AB_CUSTOM_THEME is set. -// Otherwise implemented in BaseThemecpp -Theme *getTheme(); - -} // namespace chatterino - -#ifdef CHATTERINO -# include "singletons/Theme.hpp" -#endif -#endif diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 0e976dd47..c24e7d35b 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -6,8 +6,6 @@ set(SOURCE_FILES Application.hpp BaseSettings.cpp BaseSettings.hpp - BaseTheme.cpp - BaseTheme.hpp BrowserExtension.cpp BrowserExtension.hpp RunGui.cpp @@ -677,7 +675,6 @@ endif () target_compile_definitions(${LIBRARY_PROJECT} PUBLIC CHATTERINO UNICODE - AB_CUSTOM_THEME AB_CUSTOM_SETTINGS IRC_STATIC IRC_NAMESPACE=Communi diff --git a/src/singletons/Theme.cpp b/src/singletons/Theme.cpp index bc506f9a6..635f31b0b 100644 --- a/src/singletons/Theme.cpp +++ b/src/singletons/Theme.cpp @@ -10,8 +10,53 @@ #define LOOKUP_COLOR_COUNT 360 +namespace { +double getMultiplierByTheme(const QString &themeName) +{ + if (themeName == "Light") + { + return 0.8; + } + else if (themeName == "White") + { + return 1.0; + } + else if (themeName == "Black") + { + return -1.0; + } + else if (themeName == "Dark") + { + return -0.8; + } + /* + else if (themeName == "Custom") + { + return getSettings()->customThemeMultiplier.getValue(); + } + */ + + return -0.8; +} +} // namespace + namespace chatterino { +bool Theme::isLightTheme() const +{ + return this->isLight_; +} + +QColor Theme::blendColors(const QColor &color1, const QColor &color2, + qreal ratio) +{ + int r = int(color1.red() * (1 - ratio) + color2.red() * ratio); + int g = int(color1.green() * (1 - ratio) + color2.green() * ratio); + int b = int(color1.blue() * (1 - ratio) + color2.blue() * ratio); + + return QColor(r, g, b, 255); +} + Theme::Theme() { this->update(); @@ -28,19 +73,161 @@ Theme::Theme() false); } +void Theme::update() +{ + this->actuallyUpdate(this->themeHue, + getMultiplierByTheme(this->themeName.getValue())); + + this->updated.invoke(); +} + // hue: theme color (0 - 1) // multiplier: 1 = white, 0.8 = light, -0.8 dark, -1 black void Theme::actuallyUpdate(double hue, double multiplier) { - BaseTheme::actuallyUpdate(hue, multiplier); + this->isLight_ = multiplier > 0; + bool lightWin = isLight_; + + // QColor themeColor = QColor::fromHslF(hue, 0.43, 0.5); + QColor themeColor = QColor::fromHslF(hue, 0.8, 0.5); + QColor themeColorNoSat = QColor::fromHslF(hue, 0, 0.5); + + const auto sat = qreal(0); + const auto isLight = this->isLightTheme(); + const auto flat = isLight; auto getColor = [multiplier](double h, double s, double l, double a = 1.0) { return QColor::fromHslF(h, s, ((l - 0.5) * multiplier) + 0.5, a); }; - const auto sat = qreal(0); - const auto isLight = this->isLightTheme(); - const auto flat = isLight; + /// WINDOW + { +#ifdef Q_OS_LINUX + this->window.background = lightWin ? "#fff" : QColor(61, 60, 56); +#else + this->window.background = lightWin ? "#fff" : "#111"; +#endif + + QColor fg = this->window.text = lightWin ? "#000" : "#eee"; + this->window.borderFocused = lightWin ? "#ccc" : themeColor; + this->window.borderUnfocused = lightWin ? "#ccc" : themeColorNoSat; + + // Ubuntu style + // TODO: add setting for this + // TabText = QColor(210, 210, 210); + // TabBackground = QColor(61, 60, 56); + // TabHoverText = QColor(210, 210, 210); + // TabHoverBackground = QColor(73, 72, 68); + + // message (referenced later) + this->messages.textColors.caret = // + this->messages.textColors.regular = isLight_ ? "#000" : "#fff"; + + QColor highlighted = lightWin ? QColor("#ff0000") : QColor("#ee6166"); + + /// TABS + if (lightWin) + { + this->tabs.regular = { + QColor("#444"), + {QColor("#fff"), QColor("#eee"), QColor("#fff")}, + {QColor("#fff"), QColor("#fff"), QColor("#fff")}}; + this->tabs.newMessage = { + QColor("#222"), + {QColor("#fff"), QColor("#eee"), QColor("#fff")}, + {QColor("#bbb"), QColor("#bbb"), QColor("#bbb")}}; + this->tabs.highlighted = { + fg, + {QColor("#fff"), QColor("#eee"), QColor("#fff")}, + {highlighted, highlighted, highlighted}}; + this->tabs.selected = { + QColor("#000"), + {QColor("#b4d7ff"), QColor("#b4d7ff"), QColor("#b4d7ff")}, + {this->accent, this->accent, this->accent}}; + } + else + { + this->tabs.regular = { + QColor("#aaa"), + {QColor("#252525"), QColor("#252525"), QColor("#252525")}, + {QColor("#444"), QColor("#444"), QColor("#444")}}; + this->tabs.newMessage = { + fg, + {QColor("#252525"), QColor("#252525"), QColor("#252525")}, + {QColor("#888"), QColor("#888"), QColor("#888")}}; + this->tabs.highlighted = { + fg, + {QColor("#252525"), QColor("#252525"), QColor("#252525")}, + {highlighted, highlighted, highlighted}}; + + this->tabs.selected = { + QColor("#fff"), + {QColor("#555555"), QColor("#555555"), QColor("#555555")}, + {this->accent, this->accent, this->accent}}; + } + + // scrollbar + this->scrollbars.highlights.highlight = QColor("#ee6166"); + this->scrollbars.highlights.subscription = QColor("#C466FF"); + + // this->tabs.newMessage = { + // fg, + // {QBrush(blendColors(themeColor, "#ccc", 0.9), Qt::FDiagPattern), + // QBrush(blendColors(themeColor, "#ccc", 0.9), Qt::FDiagPattern), + // QBrush(blendColors(themeColorNoSat, "#ccc", 0.9), + // Qt::FDiagPattern)}}; + + // this->tabs.newMessage = { + // fg, + // {QBrush(blendColors(themeColor, "#666", 0.7), + // Qt::FDiagPattern), + // QBrush(blendColors(themeColor, "#666", 0.5), + // Qt::FDiagPattern), + // QBrush(blendColors(themeColorNoSat, "#666", 0.7), + // Qt::FDiagPattern)}}; + // this->tabs.highlighted = {fg, {QColor("#777"), + // QColor("#777"), QColor("#666")}}; + + this->tabs.dividerLine = + this->tabs.selected.backgrounds.regular.color(); + } + + // Message + this->messages.textColors.link = + isLight_ ? QColor(66, 134, 244) : 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 = getColor(0, sat, 1); + this->messages.backgrounds.alternate = getColor(0, sat, 0.96); + + // this->messages.backgrounds.resub + // this->messages.backgrounds.whisper + this->messages.disabled = getColor(0, sat, 1, 0.6); + // this->messages.seperator = + // this->messages.seperatorInner = + + int complementaryGray = this->isLightTheme() ? 20 : 230; + this->messages.highlightAnimationStart = + QColor(complementaryGray, complementaryGray, complementaryGray, 110); + this->messages.highlightAnimationEnd = + QColor(complementaryGray, complementaryGray, complementaryGray, 0); + + // Scrollbar + this->scrollbars.background = QColor(0, 0, 0, 0); + // this->scrollbars.background = splits.background; + // this->scrollbars.background.setAlphaF(qreal(0.2)); + this->scrollbars.thumb = getColor(0, sat, 0.70); + this->scrollbars.thumbSelected = getColor(0, sat, 0.65); + + // tooltip + this->tooltip.background = QColor(0, 0, 0); + this->tooltip.text = QColor(255, 255, 255); + + // Selection + this->messages.selection = + isLightTheme() ? QColor(0, 0, 0, 64) : QColor(255, 255, 255, 64); if (this->isLightTheme()) { diff --git a/src/singletons/Theme.hpp b/src/singletons/Theme.hpp index 9af05f543..ea49457e9 100644 --- a/src/singletons/Theme.hpp +++ b/src/singletons/Theme.hpp @@ -1,6 +1,5 @@ #pragma once -#include "BaseTheme.hpp" #include "common/Singleton.hpp" #include "util/RapidJsonSerializeQString.hpp" @@ -13,11 +12,89 @@ namespace chatterino { class WindowManager; -class Theme final : public Singleton, public BaseTheme +class Theme final : public Singleton { public: Theme(); + bool isLightTheme() const; + + struct TabColors { + QColor text; + struct { + QBrush regular; + QBrush hover; + QBrush unfocused; + } backgrounds; + struct { + QColor regular; + QColor hover; + QColor unfocused; + } line; + }; + + QColor accent{"#00aeef"}; + + /// WINDOW + struct { + QColor background; + QColor text; + QColor borderUnfocused; + QColor borderFocused; + } window; + + /// TABS + struct { + TabColors regular; + TabColors newMessage; + TabColors highlighted; + TabColors selected; + QColor border; + QColor dividerLine; + } tabs; + + /// MESSAGES + struct { + struct { + QColor regular; + QColor caret; + QColor link; + QColor system; + QColor chatPlaceholder; + } textColors; + + struct { + QColor regular; + QColor alternate; + // QColor whisper; + } backgrounds; + + QColor disabled; + // QColor seperator; + // QColor seperatorInner; + QColor selection; + + QColor highlightAnimationStart; + QColor highlightAnimationEnd; + } messages; + + /// SCROLLBAR + struct { + QColor background; + QColor thumb; + QColor thumbSelected; + struct { + QColor highlight; + QColor subscription; + } highlights; + } scrollbars; + + /// TOOLTIP + struct { + QColor text; + QColor background; + } tooltip; + /// SPLITS struct { QColor messageSeperator; @@ -55,13 +132,22 @@ public: } buttons; void normalizeColor(QColor &color); + void update(); + QColor blendColors(const QColor &color1, const QColor &color2, qreal ratio); + + pajlada::Signals::NoArgSignal updated; + + QStringSetting themeName{"/appearance/theme/name", "Dark"}; + DoubleSetting themeHue{"/appearance/theme/hue", 0.0}; private: - void actuallyUpdate(double hue, double multiplier) override; + bool isLight_ = false; + void actuallyUpdate(double hue, double multiplier); pajlada::Signals::NoArgSignal repaintVisibleChatWidgets_; friend class WindowManager; }; +Theme *getTheme(); } // namespace chatterino diff --git a/src/widgets/BaseWidget.cpp b/src/widgets/BaseWidget.cpp index 7a1654924..70aeb34e6 100644 --- a/src/widgets/BaseWidget.cpp +++ b/src/widgets/BaseWidget.cpp @@ -1,9 +1,9 @@ #include "widgets/BaseWidget.hpp" #include "BaseSettings.hpp" -#include "BaseTheme.hpp" #include "common/QLogging.hpp" #include "controllers/hotkeys/HotkeyController.hpp" +#include "singletons/Theme.hpp" #include "widgets/BaseWindow.hpp" #include diff --git a/src/widgets/BaseWindow.cpp b/src/widgets/BaseWindow.cpp index 008c55a69..0cbd80f0b 100644 --- a/src/widgets/BaseWindow.cpp +++ b/src/widgets/BaseWindow.cpp @@ -1,8 +1,8 @@ #include "BaseWindow.hpp" #include "BaseSettings.hpp" -#include "BaseTheme.hpp" #include "boost/algorithm/algorithm.hpp" +#include "singletons/Theme.hpp" #include "util/DebugCount.hpp" #include "util/PostToThread.hpp" #include "util/WindowsHelper.hpp" diff --git a/src/widgets/TooltipWidget.cpp b/src/widgets/TooltipWidget.cpp index 6aac584b5..ed82383e0 100644 --- a/src/widgets/TooltipWidget.cpp +++ b/src/widgets/TooltipWidget.cpp @@ -1,6 +1,5 @@ #include "TooltipWidget.hpp" -#include "BaseTheme.hpp" #include "singletons/Fonts.hpp" #include diff --git a/src/widgets/helper/Button.cpp b/src/widgets/helper/Button.cpp index 0d7067937..43c943665 100644 --- a/src/widgets/helper/Button.cpp +++ b/src/widgets/helper/Button.cpp @@ -5,7 +5,7 @@ #include #include -#include "BaseTheme.hpp" +#include "singletons/Theme.hpp" #include "util/FunctionEventFilter.hpp" namespace chatterino { diff --git a/src/widgets/helper/TitlebarButton.cpp b/src/widgets/helper/TitlebarButton.cpp index d946dae0b..1d9a2b81d 100644 --- a/src/widgets/helper/TitlebarButton.cpp +++ b/src/widgets/helper/TitlebarButton.cpp @@ -1,9 +1,9 @@ #include "TitlebarButton.hpp" -#include "BaseTheme.hpp" - #include +#include "singletons/Theme.hpp" + namespace chatterino { TitleBarButton::TitleBarButton() From fbfa5e0f4183f23f7603f23760eee049cb6e45e8 Mon Sep 17 00:00:00 2001 From: kornes <28986062+kornes@users.noreply.github.com> Date: Thu, 10 Nov 2022 19:11:40 +0000 Subject: [PATCH 109/946] Disable use of Qt APIs deprecated in 5.15.0 and earlier versions (#4133) --- CHANGELOG.md | 1 + src/CMakeLists.txt | 1 + src/singletons/NativeMessaging.cpp | 7 +++---- src/singletons/Updates.cpp | 6 +++--- src/singletons/helper/LoggingChannel.cpp | 4 ++-- src/util/IncognitoBrowser.cpp | 3 ++- src/widgets/AttachedWindow.cpp | 2 +- src/widgets/BaseWindow.cpp | 7 ++++--- src/widgets/StreamView.cpp | 2 +- src/widgets/Window.cpp | 2 +- src/widgets/dialogs/EmotePopup.cpp | 6 +++--- src/widgets/dialogs/NotificationPopup.cpp | 3 +-- src/widgets/dialogs/ReplyThreadPopup.cpp | 3 ++- src/widgets/dialogs/SettingsDialog.cpp | 4 +--- src/widgets/helper/EditableModelView.cpp | 2 +- src/widgets/helper/EffectLabel.cpp | 4 ++-- src/widgets/helper/SearchPopup.cpp | 4 ++-- src/widgets/splits/Split.cpp | 2 +- src/widgets/splits/SplitHeader.cpp | 2 +- src/widgets/splits/SplitInput.cpp | 5 ++--- src/widgets/splits/SplitOverlay.cpp | 2 +- 21 files changed, 36 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 404fa86d8..e948a12b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,6 +111,7 @@ - Dev: Got rid of BaseTheme (#4132) - Dev: Removed official support for QMake. (#3839, #3883) - Dev: Rewrote LimitedQueue (#3798) +- Dev: Set cmake `QT_DISABLE_DEPRECATED_BEFORE` to disable deprecated APIs up to Qt 5.15.0 (#4133) - Dev: Overhauled highlight system by moving all checks into a Controller allowing for easier tests. (#3399, #3801, #3835) - Dev: Use Game Name returned by Get Streams instead of querying it from the Get Games API. (#3662) - Dev: Batched checking live status for all channels after startup. (#3757, #3762, #3767) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index c24e7d35b..f2a75e773 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -1,5 +1,6 @@ set(LIBRARY_PROJECT "${PROJECT_NAME}-lib") set(EXECUTABLE_PROJECT "${PROJECT_NAME}") +add_compile_definitions(QT_DISABLE_DEPRECATED_BEFORE=0x050F00) set(SOURCE_FILES Application.cpp diff --git a/src/singletons/NativeMessaging.cpp b/src/singletons/NativeMessaging.cpp index f4dff7c0b..2f50c8272 100644 --- a/src/singletons/NativeMessaging.cpp +++ b/src/singletons/NativeMessaging.cpp @@ -19,7 +19,7 @@ namespace ipc = boost::interprocess; #ifdef Q_OS_WIN -# include +# include # include # include "singletons/WindowManager.hpp" @@ -98,9 +98,8 @@ void registerNmManifest(Paths &paths, const QString &manifestFilename, file.flush(); #ifdef Q_OS_WIN - // clang-format off - QProcess::execute("REG ADD \"" + registryKeyName + "\" /ve /t REG_SZ /d \"" + manifestPath + "\" /f"); -// clang-format on + QSettings registry(registryKeyName, QSettings::NativeFormat); + registry.setValue("Default", manifestPath); #endif } diff --git a/src/singletons/Updates.cpp b/src/singletons/Updates.cpp index cf32b30ff..ab50e55e4 100644 --- a/src/singletons/Updates.cpp +++ b/src/singletons/Updates.cpp @@ -179,10 +179,10 @@ void Updates::installUpdates() }) .onSuccess([this](auto result) -> Outcome { QByteArray object = result.getData(); - auto filename = + auto filePath = combinePath(getPaths()->miscDirectory, "Update.exe"); - QFile file(filename); + QFile file(filePath); file.open(QIODevice::Truncate | QIODevice::WriteOnly); if (file.write(object) == -1) @@ -203,7 +203,7 @@ void Updates::installUpdates() file.flush(); file.close(); - if (QProcess::startDetached(filename)) + if (QProcess::startDetached(filePath, {})) { QApplication::exit(0); } diff --git a/src/singletons/helper/LoggingChannel.cpp b/src/singletons/helper/LoggingChannel.cpp index 54dac7f78..13f40c06e 100644 --- a/src/singletons/helper/LoggingChannel.cpp +++ b/src/singletons/helper/LoggingChannel.cpp @@ -109,7 +109,7 @@ void LoggingChannel::addMessage(MessagePtr message) QString LoggingChannel::generateOpeningString(const QDateTime &now) const { - QString ret = QLatin1Literal("# Start logging at "); + QString ret("# Start logging at "); ret.append(now.toString("yyyy-MM-dd HH:mm:ss ")); ret.append(now.timeZoneAbbreviation()); @@ -120,7 +120,7 @@ QString LoggingChannel::generateOpeningString(const QDateTime &now) const QString LoggingChannel::generateClosingString(const QDateTime &now) const { - QString ret = QLatin1Literal("# Stop logging at "); + QString ret("# Stop logging at "); ret.append(now.toString("yyyy-MM-dd HH:mm:ss")); ret.append(now.timeZoneAbbreviation()); diff --git a/src/util/IncognitoBrowser.cpp b/src/util/IncognitoBrowser.cpp index 24e79a7e7..d4bf14fe1 100644 --- a/src/util/IncognitoBrowser.cpp +++ b/src/util/IncognitoBrowser.cpp @@ -90,7 +90,8 @@ bool openLinkIncognito(const QString &link) #ifdef Q_OS_WIN auto command = getCommand(link); - return QProcess::startDetached(command); + // TODO: split command into program path and incognito argument + return QProcess::startDetached(command, {}); #else return false; #endif diff --git a/src/widgets/AttachedWindow.cpp b/src/widgets/AttachedWindow.cpp index 9ac753345..ecae39ec4 100644 --- a/src/widgets/AttachedWindow.cpp +++ b/src/widgets/AttachedWindow.cpp @@ -48,7 +48,7 @@ AttachedWindow::AttachedWindow(void *_target, int _yOffset) , yOffset_(_yOffset) { QLayout *layout = new QVBoxLayout(this); - layout->setMargin(0); + layout->setContentsMargins(0, 0, 0, 0); this->setLayout(layout); auto *split = new Split(this); diff --git a/src/widgets/BaseWindow.cpp b/src/widgets/BaseWindow.cpp index 0cbd80f0b..06fa9c07d 100644 --- a/src/widgets/BaseWindow.cpp +++ b/src/widgets/BaseWindow.cpp @@ -144,7 +144,7 @@ void BaseWindow::init() { QHBoxLayout *buttonLayout = this->ui_.titlebarBox = new QHBoxLayout(); - buttonLayout->setMargin(0); + buttonLayout->setContentsMargins(0, 0, 0, 0); layout->addLayout(buttonLayout); // title @@ -343,14 +343,15 @@ bool BaseWindow::event(QEvent *event) void BaseWindow::wheelEvent(QWheelEvent *event) { - if (event->orientation() != Qt::Vertical) + // ignore horizontal mouse wheels + if (event->angleDelta().x() != 0) { return; } if (event->modifiers() & Qt::ControlModifier) { - if (event->delta() > 0) + if (event->angleDelta().y() > 0) { getSettings()->setClampedUiScale( getSettings()->getClampedUiScale() + 0.1); diff --git a/src/widgets/StreamView.cpp b/src/widgets/StreamView.cpp index a47450b44..8840e8838 100644 --- a/src/widgets/StreamView.cpp +++ b/src/widgets/StreamView.cpp @@ -29,7 +29,7 @@ StreamView::StreamView(ChannelPtr channel, const QUrl &url) chat->setChannel(std::move(channel)); this->layout()->setSpacing(0); - this->layout()->setMargin(0); + this->layout()->setContentsMargins(0, 0, 0, 0); } } // namespace chatterino diff --git a/src/widgets/Window.cpp b/src/widgets/Window.cpp index f976b0c53..610a5d2ea 100644 --- a/src/widgets/Window.cpp +++ b/src/widgets/Window.cpp @@ -153,7 +153,7 @@ void Window::addLayout() this->getLayoutContainer()->setLayout(layout); // set margin - layout->setMargin(0); + layout->setContentsMargins(0, 0, 0, 0); this->notebook_->setAllowUserTabManagement(true); this->notebook_->setShowAddButton(true); diff --git a/src/widgets/dialogs/EmotePopup.cpp b/src/widgets/dialogs/EmotePopup.cpp index 456b8c005..9645f8da1 100644 --- a/src/widgets/dialogs/EmotePopup.cpp +++ b/src/widgets/dialogs/EmotePopup.cpp @@ -167,11 +167,11 @@ EmotePopup::EmotePopup(QWidget *parent) QRegularExpression searchRegex("\\S*"); searchRegex.setPatternOptions(QRegularExpression::CaseInsensitiveOption); - layout->setMargin(0); + layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); QHBoxLayout *layout2 = new QHBoxLayout(this); - layout2->setMargin(8); + layout2->setContentsMargins(8, 8, 8, 8); layout2->setSpacing(8); this->search_ = new QLineEdit(); @@ -214,7 +214,7 @@ EmotePopup::EmotePopup(QWidget *parent) this->notebook_ = new Notebook(this); layout->addWidget(this->notebook_); - layout->setMargin(0); + layout->setContentsMargins(0, 0, 0, 0); this->subEmotesView_ = makeView("Subs"); this->channelEmotesView_ = makeView("Channel"); diff --git a/src/widgets/dialogs/NotificationPopup.cpp b/src/widgets/dialogs/NotificationPopup.cpp index 11e1b63a9..0e73969c5 100644 --- a/src/widgets/dialogs/NotificationPopup.cpp +++ b/src/widgets/dialogs/NotificationPopup.cpp @@ -32,8 +32,7 @@ void NotificationPopup::updatePosition() { Location location = BottomRight; - QDesktopWidget *desktop = QApplication::desktop(); - const QRect rect = desktop->availableGeometry(); + const QRect rect = QGuiApplication::primaryScreen()->availableGeometry(); switch (location) { diff --git a/src/widgets/dialogs/ReplyThreadPopup.cpp b/src/widgets/dialogs/ReplyThreadPopup.cpp index 6fc387dfa..2e3cf25bd 100644 --- a/src/widgets/dialogs/ReplyThreadPopup.cpp +++ b/src/widgets/dialogs/ReplyThreadPopup.cpp @@ -90,7 +90,8 @@ ReplyThreadPopup::ReplyThreadPopup(bool closeAutomatically, QWidget *parent, layout->setSpacing(0); // provide draggable margin if frameless - layout->setMargin(closeAutomatically ? 15 : 1); + auto marginPx = closeAutomatically ? 15 : 1; + layout->setContentsMargins(marginPx, marginPx, marginPx, marginPx); layout->addWidget(this->ui_.threadView, 1); layout->addWidget(this->ui_.replyInput); } diff --git a/src/widgets/dialogs/SettingsDialog.cpp b/src/widgets/dialogs/SettingsDialog.cpp index d6969cbfc..d2af987e0 100644 --- a/src/widgets/dialogs/SettingsDialog.cpp +++ b/src/widgets/dialogs/SettingsDialog.cpp @@ -123,7 +123,7 @@ void SettingsDialog::initUi() .assign(&this->ui_.pageStack) .withoutMargin(); - this->ui_.pageStack->setMargin(0); + this->ui_.pageStack->setContentsMargins(0, 0, 0, 0); outerBox->addSpacing(12); @@ -193,9 +193,7 @@ void SettingsDialog::filterElements(const QString &text) void SettingsDialog::addTabs() { - this->ui_.tabContainer->setMargin(0); this->ui_.tabContainer->setSpacing(0); - this->ui_.tabContainer->setContentsMargins(0, 20, 0, 20); // Constructors are wrapped in std::function to remove some strain from first time loading. diff --git a/src/widgets/helper/EditableModelView.cpp b/src/widgets/helper/EditableModelView.cpp index a002d8209..f331cdd17 100644 --- a/src/widgets/helper/EditableModelView.cpp +++ b/src/widgets/helper/EditableModelView.cpp @@ -29,7 +29,7 @@ EditableModelView::EditableModelView(QAbstractTableModel *model, bool movable) // create layout QVBoxLayout *vbox = new QVBoxLayout(this); - vbox->setMargin(0); + vbox->setContentsMargins(0, 0, 0, 0); // create button layout QHBoxLayout *buttons = new QHBoxLayout(this); diff --git a/src/widgets/helper/EffectLabel.cpp b/src/widgets/helper/EffectLabel.cpp index 1f2960d77..3e156ae5b 100644 --- a/src/widgets/helper/EffectLabel.cpp +++ b/src/widgets/helper/EffectLabel.cpp @@ -13,7 +13,7 @@ EffectLabel::EffectLabel(BaseWidget *parent, int spacing) this->label_.setAlignment(Qt::AlignCenter); - this->hbox_.setMargin(0); + this->hbox_.setContentsMargins(0, 0, 0, 0); this->hbox_.addSpacing(spacing); this->hbox_.addWidget(&this->label_); this->hbox_.addSpacing(spacing); @@ -29,7 +29,7 @@ EffectLabel2::EffectLabel2(BaseWidget *parent, int padding) // this->label_.setAlignment(Qt::AlignCenter); this->label_.setCentered(true); - hbox->setMargin(0); + hbox->setContentsMargins(0, 0, 0, 0); // hbox.addSpacing(spacing); hbox->addWidget(&this->label_); // hbox.addSpacing(spacing); diff --git a/src/widgets/helper/SearchPopup.cpp b/src/widgets/helper/SearchPopup.cpp index 8c4f24719..5c42d9209 100644 --- a/src/widgets/helper/SearchPopup.cpp +++ b/src/widgets/helper/SearchPopup.cpp @@ -248,13 +248,13 @@ void SearchPopup::initLayout() // VBOX { auto *layout1 = new QVBoxLayout(this); - layout1->setMargin(0); + layout1->setContentsMargins(0, 0, 0, 0); layout1->setSpacing(0); // HBOX { auto *layout2 = new QHBoxLayout(this); - layout2->setMargin(8); + layout2->setContentsMargins(8, 8, 8, 8); layout2->setSpacing(8); // SEARCH INPUT diff --git a/src/widgets/splits/Split.cpp b/src/widgets/splits/Split.cpp index 889561914..282590e96 100644 --- a/src/widgets/splits/Split.cpp +++ b/src/widgets/splits/Split.cpp @@ -96,7 +96,7 @@ Split::Split(QWidget *parent) this->setFocusProxy(this->input_->ui_.textEdit); this->vbox_->setSpacing(0); - this->vbox_->setMargin(1); + this->vbox_->setContentsMargins(1, 1, 1, 1); this->vbox_->addWidget(this->header_); this->vbox_->addWidget(this->view_, 1); diff --git a/src/widgets/splits/SplitHeader.cpp b/src/widgets/splits/SplitHeader.cpp index fe95aca5a..b782e72a0 100644 --- a/src/widgets/splits/SplitHeader.cpp +++ b/src/widgets/splits/SplitHeader.cpp @@ -330,7 +330,7 @@ void SplitHeader::initializeLayout() }, this->managedConnections_); - layout->setMargin(0); + layout->setContentsMargins(0, 0, 0, 0); layout->setSpacing(0); this->setLayout(layout); diff --git a/src/widgets/splits/SplitInput.cpp b/src/widgets/splits/SplitInput.cpp index ccedbb86b..3acec66de 100644 --- a/src/widgets/splits/SplitInput.cpp +++ b/src/widgets/splits/SplitInput.cpp @@ -209,9 +209,8 @@ void SplitInput::themeChangedEvent() #if (QT_VERSION >= QT_VERSION_CHECK(5, 12, 0)) this->ui_.textEdit->setPalette(placeholderPalette); #endif - - this->ui_.vbox->setMargin( - int((this->theme->isLightTheme() ? 4 : 2) * this->scale())); + auto marginPx = (this->theme->isLightTheme() ? 4 : 2) * this->scale(); + this->ui_.vbox->setContentsMargins(marginPx, marginPx, marginPx, marginPx); this->ui_.emoteButton->getLabel().setStyleSheet("color: #000"); diff --git a/src/widgets/splits/SplitOverlay.cpp b/src/widgets/splits/SplitOverlay.cpp index 30cd743d2..ca2a1c632 100644 --- a/src/widgets/splits/SplitOverlay.cpp +++ b/src/widgets/splits/SplitOverlay.cpp @@ -22,7 +22,7 @@ SplitOverlay::SplitOverlay(Split *parent) { QGridLayout *layout = new QGridLayout(this); this->layout_ = layout; - layout->setMargin(1); + layout->setContentsMargins(1, 1, 1, 1); layout->setSpacing(1); layout->setRowStretch(1, 1); From 3fcb7e17020672a09623b8ca1453c2d65d8ad5c2 Mon Sep 17 00:00:00 2001 From: mohad12211 <51754973+mohad12211@users.noreply.github.com> Date: Thu, 10 Nov 2022 23:36:19 +0300 Subject: [PATCH 110/946] Implement initial support for RTL languages (#3958) Co-authored-by: pajlada fix https://github.com/Chatterino/chatterino2/issues/720 --- CHANGELOG.md | 1 + src/messages/MessageElement.cpp | 1 + .../layouts/MessageLayoutContainer.cpp | 175 +++++++++++++++++- .../layouts/MessageLayoutContainer.hpp | 22 ++- src/messages/layouts/MessageLayoutElement.cpp | 16 +- src/messages/layouts/MessageLayoutElement.hpp | 2 + src/util/Helpers.cpp | 8 + src/util/Helpers.hpp | 5 + 8 files changed, 217 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e948a12b1..726f3d89a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ - Major: Added support for Twitch's Chat Replies. [Wiki Page](https://wiki.chatterino.com/Features/#message-replies) (#3722, #3989, #4041, #4047, #4055, #4067, #4077, #3905) - Major: Added multi-channel searching to search dialog via keyboard shortcut. (Ctrl+Shift+F by default) (#3694, #3875) - Major: Added support for emotes and badges from [7TV](https://7tv.app). [Wiki Page](https://wiki.chatterino.com/Third_party_services/#7tv) (#4002, #4062) +- Major: Added support for Right-to-Left Languages (#3958) - Minor: Allow hiding moderation actions in streamer mode. (#3926) - Minor: Added highlights for `Elevated Messages`. (#4016) - Minor: Removed total views from the usercard, as Twitch no longer updates the number. (#3792) diff --git a/src/messages/MessageElement.cpp b/src/messages/MessageElement.cpp index 60b305738..3dfc12077 100644 --- a/src/messages/MessageElement.cpp +++ b/src/messages/MessageElement.cpp @@ -473,6 +473,7 @@ void SingleLineTextElement::addToContainer(MessageLayoutContainer &container, // once we encounter an emote or reach the end of the message text. */ QString currentText; + container.first = FirstWord::Neutral; for (Word &word : this->words_) { auto parsedWords = app->emotes->emojis.parse(word.text); diff --git a/src/messages/layouts/MessageLayoutContainer.cpp b/src/messages/layouts/MessageLayoutContainer.cpp index f7e3d9c47..b15c979c7 100644 --- a/src/messages/layouts/MessageLayoutContainer.cpp +++ b/src/messages/layouts/MessageLayoutContainer.cpp @@ -8,6 +8,7 @@ #include "singletons/Fonts.hpp" #include "singletons/Settings.hpp" #include "singletons/Theme.hpp" +#include "util/Helpers.hpp" #include #include @@ -88,7 +89,7 @@ bool MessageLayoutContainer::canAddElements() } void MessageLayoutContainer::_addElement(MessageLayoutElement *element, - bool forceAdd) + bool forceAdd, int prevIndex) { if (!this->canAddElements() && !forceAdd) { @@ -96,15 +97,19 @@ void MessageLayoutContainer::_addElement(MessageLayoutElement *element, return; } + bool isRTLMode = this->first == FirstWord::RTL && prevIndex != -2; + bool isAddingMode = prevIndex == -2; + // This lambda contains the logic for when to step one 'space width' back for compact x emotes - auto shouldRemoveSpaceBetweenEmotes = [this]() -> bool { - if (this->elements_.empty()) + auto shouldRemoveSpaceBetweenEmotes = [this, prevIndex]() -> bool { + if (prevIndex == -1 || this->elements_.empty()) { // No previous element found return false; } - const auto &lastElement = this->elements_.back(); + const auto &lastElement = prevIndex == -2 ? this->elements_.back() + : this->elements_[prevIndex]; if (!lastElement) { @@ -127,6 +132,26 @@ void MessageLayoutContainer::_addElement(MessageLayoutElement *element, return lastElement->getFlags().has(MessageElementFlag::EmoteImages); }; + if (element->getText().isRightToLeft()) + { + this->containsRTL = true; + } + + // check the first non-neutral word to see if we should render RTL or LTR + if (isAddingMode && this->first == FirstWord::Neutral && + element->getFlags().has(MessageElementFlag::Text) && + !element->getFlags().has(MessageElementFlag::RepliedMessage)) + { + if (element->getText().isRightToLeft()) + { + this->first = FirstWord::RTL; + } + else if (!isNeutral(element->getText())) + { + this->first = FirstWord::LTR; + } + } + // top margin if (this->elements_.size() == 0) { @@ -152,7 +177,7 @@ void MessageLayoutContainer::_addElement(MessageLayoutElement *element, bool isZeroWidthEmote = element->getCreator().getFlags().has( MessageElementFlag::ZeroWidthEmote); - if (isZeroWidthEmote) + if (isZeroWidthEmote && !isRTLMode) { xOffset -= element->getRect().width() + this->spaceWidth_; } @@ -171,8 +196,22 @@ void MessageLayoutContainer::_addElement(MessageLayoutElement *element, element->getFlags().hasAny({MessageElementFlag::EmoteImages}) && !isZeroWidthEmote && shouldRemoveSpaceBetweenEmotes()) { - // Move cursor one 'space width' to the left to combine hug the previous emote - this->currentX_ -= this->spaceWidth_; + // Move cursor one 'space width' to the left (right in case of RTL) to combine hug the previous emote + if (isRTLMode) + { + this->currentX_ += this->spaceWidth_; + } + else + { + this->currentX_ -= this->spaceWidth_; + } + } + + if (isRTLMode) + { + // shift by width since we are calculating according to top right in RTL mode + // but setPosition wants top left + xOffset -= element->getRect().width(); } // set move element @@ -183,22 +222,138 @@ void MessageLayoutContainer::_addElement(MessageLayoutElement *element, element->setLine(this->line_); // add element - this->elements_.push_back(std::unique_ptr(element)); + if (isAddingMode) + { + this->elements_.push_back( + std::unique_ptr(element)); + } // set current x if (!isZeroWidthEmote) { - this->currentX_ += element->getRect().width(); + if (isRTLMode) + { + this->currentX_ -= element->getRect().width(); + } + else + { + this->currentX_ += element->getRect().width(); + } } if (element->hasTrailingSpace()) { - this->currentX_ += this->spaceWidth_; + if (isRTLMode) + { + this->currentX_ -= this->spaceWidth_; + } + else + { + this->currentX_ += this->spaceWidth_; + } + } +} + +void MessageLayoutContainer::reorderRTL(int firstTextIndex) +{ + if (this->elements_.empty()) + { + return; + } + + int startIndex = static_cast(this->lineStart_); + int endIndex = static_cast(this->elements_.size()) - 1; + + if (firstTextIndex >= endIndex) + { + return; + } + startIndex = std::max(startIndex, firstTextIndex); + + std::vector correctSequence; + std::stack swappedSequence; + bool wasPrevReversed = false; + + // we reverse a sequence of words if it's opposite to the text direction + // the second condition below covers the possible three cases: + // 1 - if we are in RTL mode (first non-neutral word is RTL) + // we render RTL, reversing LTR sequences, + // 2 - if we are in LTR mode (first non-neautral word is LTR or all wrods are neutral) + // we render LTR, reversing RTL sequences + // 3 - neutral words follow previous words, we reverse a neutral word when the previous word was reversed + + // the first condition checks if a neutral word is treated as a RTL word + // this is used later to add an invisible Arabic letter to fix orentation + // this can happen in two cases: + // 1 - in RTL mode, the previous word should be RTL (i.e. not reversed) + // 2 - in LTR mode, the previous word should be RTL (i.e. reversed) + for (int i = startIndex; i <= endIndex; i++) + { + if (isNeutral(this->elements_[i]->getText()) && + ((this->first == FirstWord::RTL && !wasPrevReversed) || + (this->first == FirstWord::LTR && wasPrevReversed))) + { + this->elements_[i]->reversedNeutral = true; + } + if (((this->elements_[i]->getText().isRightToLeft() != + (this->first == FirstWord::RTL)) && + !isNeutral(this->elements_[i]->getText())) || + (isNeutral(this->elements_[i]->getText()) && wasPrevReversed)) + { + swappedSequence.push(i); + wasPrevReversed = true; + } + else + { + while (!swappedSequence.empty()) + { + correctSequence.push_back(swappedSequence.top()); + swappedSequence.pop(); + } + correctSequence.push_back(i); + wasPrevReversed = false; + } + } + while (!swappedSequence.empty()) + { + correctSequence.push_back(swappedSequence.top()); + swappedSequence.pop(); + } + + // render right to left if we are in RTL mode, otherwise LTR + if (this->first == FirstWord::RTL) + { + this->currentX_ = this->elements_[endIndex]->getRect().right(); + } + else + { + this->currentX_ = this->elements_[startIndex]->getRect().left(); + } + // manually do the first call with -1 as previous index + this->_addElement(this->elements_[correctSequence[0]].get(), false, -1); + + for (int i = 1; i < correctSequence.size(); i++) + { + this->_addElement(this->elements_[correctSequence[i]].get(), false, + correctSequence[i - 1]); } } void MessageLayoutContainer::breakLine() { + if (this->containsRTL) + { + for (int i = 0; i < this->elements_.size(); i++) + { + if (this->elements_[i]->getFlags().has( + MessageElementFlag::Username)) + { + this->reorderRTL(i + 1); + break; + } + } + } + int xOffset = 0; if (this->flags_.has(MessageFlag::Centered) && this->elements_.size() > 0) diff --git a/src/messages/layouts/MessageLayoutContainer.hpp b/src/messages/layouts/MessageLayoutContainer.hpp index c990058a6..153e09794 100644 --- a/src/messages/layouts/MessageLayoutContainer.hpp +++ b/src/messages/layouts/MessageLayoutContainer.hpp @@ -15,6 +15,7 @@ class QPainter; namespace chatterino { enum class MessageFlag : int64_t; +enum class FirstWord { Neutral, RTL, LTR }; using MessageFlags = FlagsEnum; struct Margin { @@ -45,6 +46,9 @@ struct Margin { struct MessageLayoutContainer { MessageLayoutContainer() = default; + FirstWord first = FirstWord::Neutral; + bool containsRTL = false; + int getHeight() const; int getWidth() const; float getScale() const; @@ -60,6 +64,11 @@ struct MessageLayoutContainer { void breakLine(); bool atStartOfLine(); bool fitsInLine(int width_); + // this method is called when a message has an RTL word + // we need to reorder the words to be shown properly + // however we don't we to reorder non-text elements like badges, timestamps, username + // firstTextIndex is the index of the first text element that we need to start the reordering from + void reorderRTL(int firstTextIndex); MessageLayoutElement *getElementAt(QPoint point); // painting @@ -86,7 +95,18 @@ private: }; // helpers - void _addElement(MessageLayoutElement *element, bool forceAdd = false); + /* + _addElement is called at two stages. first stage is the normal one where we want to add message layout elements to the container. + If we detect an RTL word in the message, reorderRTL will be called, which is the second stage, where we call _addElement + again for each layout element, but in the correct order this time, without adding the elemnt to the this->element_ vector. + Due to compact emote logic, we need the previous element to check if we should change the spacing or not. + in stage one, this is simply elements_.back(), but in stage 2 that's not the case due to the reordering, and we need to pass the + index of the reordered previous element. + In stage one we don't need that and we pass -2 to indicate stage one (i.e. adding mode) + In stage two, we pass -1 for the first element, and the index of the oredered privous element for the rest. + */ + void _addElement(MessageLayoutElement *element, bool forceAdd = false, + int prevIndex = -2); bool canCollapse(); const Margin margin = {4, 8, 4, 8}; diff --git a/src/messages/layouts/MessageLayoutElement.cpp b/src/messages/layouts/MessageLayoutElement.cpp index 1aa9a25ca..7d736ede6 100644 --- a/src/messages/layouts/MessageLayoutElement.cpp +++ b/src/messages/layouts/MessageLayoutElement.cpp @@ -12,6 +12,12 @@ #include #include +namespace { + +const QChar RTL_MARK(0x200F); + +} // namespace + namespace chatterino { const QRect &MessageLayoutElement::getRect() const @@ -286,14 +292,20 @@ int TextLayoutElement::getSelectionIndexCount() const void TextLayoutElement::paint(QPainter &painter) { auto app = getApp(); + QString text = this->getText(); + if (text.isRightToLeft() || this->reversedNeutral) + { + text.prepend(RTL_MARK); + text.append(RTL_MARK); + } painter.setPen(this->color_); painter.setFont(app->fonts->getFont(this->style_, this->scale_)); painter.drawText( - QRectF(this->getRect().x(), this->getRect().y(), 10000, 10000), - this->getText(), QTextOption(Qt::AlignLeft | Qt::AlignTop)); + QRectF(this->getRect().x(), this->getRect().y(), 10000, 10000), text, + QTextOption(Qt::AlignLeft | Qt::AlignTop)); } void TextLayoutElement::paintAnimated(QPainter &, int) diff --git a/src/messages/layouts/MessageLayoutElement.hpp b/src/messages/layouts/MessageLayoutElement.hpp index 6684731ad..5dfec7f0c 100644 --- a/src/messages/layouts/MessageLayoutElement.hpp +++ b/src/messages/layouts/MessageLayoutElement.hpp @@ -28,6 +28,8 @@ public: MessageLayoutElement(MessageElement &creator_, const QSize &size); virtual ~MessageLayoutElement(); + bool reversedNeutral = false; + const QRect &getRect() const; MessageElement &getCreator() const; void setPosition(QPoint point); diff --git a/src/util/Helpers.cpp b/src/util/Helpers.cpp index 7df39196a..b145d3adb 100644 --- a/src/util/Helpers.cpp +++ b/src/util/Helpers.cpp @@ -4,6 +4,7 @@ #include #include +#include #include namespace chatterino { @@ -123,6 +124,13 @@ bool startsWithOrContains(const QString &str1, const QString &str2, return str1.contains(str2, caseSensitivity); } +bool isNeutral(const QString &s) +{ + static const QRegularExpression re("\\p{L}"); + const QRegularExpressionMatch match = re.match(s); + return !match.hasMatch(); +} + QString generateUuid() { auto uuid = QUuid::createUuid(); diff --git a/src/util/Helpers.hpp b/src/util/Helpers.hpp index 409089ed7..65d874423 100644 --- a/src/util/Helpers.hpp +++ b/src/util/Helpers.hpp @@ -57,6 +57,11 @@ namespace _helpers_internal { bool startsWithOrContains(const QString &str1, const QString &str2, Qt::CaseSensitivity caseSensitivity, bool startsWith); +/** + * @brief isNeutral checks if the string doesn't contain any character in the unicode "letter" category + * i.e. if the string contains only neutral characters. + **/ +bool isNeutral(const QString &s); QString generateUuid(); QString formatRichLink(const QString &url, bool file = false); From f86b5b90a848b597c43ae34122c98498d3673aa1 Mon Sep 17 00:00:00 2001 From: mohad12211 <51754973+mohad12211@users.noreply.github.com> Date: Fri, 11 Nov 2022 02:34:47 +0300 Subject: [PATCH 111/946] Add mohad12211 to contributors list (#4138) --- resources/avatars/mohad12211.png | Bin 0 -> 22195 bytes resources/contributors.txt | 1 + resources/resources_autogenerated.qrc | 1 + src/autogenerated/ResourcesAutogen.cpp | 1 + src/autogenerated/ResourcesAutogen.hpp | 1 + 5 files changed, 4 insertions(+) create mode 100644 resources/avatars/mohad12211.png diff --git a/resources/avatars/mohad12211.png b/resources/avatars/mohad12211.png new file mode 100644 index 0000000000000000000000000000000000000000..4308ffbd3c78b428b6577d578dd88a349377d8ca GIT binary patch literal 22195 zcmV)eK&HQmP)=UfbtcHR-9%SOmg_aqaMFC*T& z-@V^m;ze>xa%&S;vRjLTRI#b5h&N@&b5+GwUa_4|AI~s@R;F2$^$gYq3X|?+_F|%eq#N7psch0`||j)PO8z9ZsJ93--nYTiY)KyJte$(xO2kEnP+mfz^plaijYBRKgk**Gpb zN3~7}=_|5x#$*>p}Ud zB1U$67CsrK8n#QbBfC zZFH(_lKhl7|Msis70?TBU|5UQ22n*H)i@x`Pe^E`6?wE88&>F=yif8bih^zA$f^gl zi4QdM=#Qb(Ha>lU+}0A^N_J(ipr^JHu4dyto4} zY#0N?`5iTh^|2AR161yjX;JHTamPld`b^sAN(aBB3ZlkAQEphAh3eb+jg5>o10e)X z#5|}MDg`8M)OcR7;sF^36TD`r0~m@CUHoE?fW{5d!F;oUi7L&!MksDpnwb>m%Ix=Z z2+puWnjIE4a&2~}xD!fWW0<10X<_S{FH%iIEARWn%^kJvl_$r5?c#Rs7ja~#q;m|M z^Q(y1>n>+lSlBMhjEf3Af(ED#Tmt@pCJT%v+jw+*E;7M6%O6kiwl(o+@7dD4LMa)+ zL5wS|P!PVn(Zz5L8F%5# z0N}HwSiXROfwA>Gf1Jbskj)mBxzd7znH4%~;=TEj@Yy-( zXK;mG>#cueJuX_KI8k9&b%rbZfcaE-g$8}|9 zW4&{wo4*<=a{O_`01{2NG&1=qJSD{h;fAWuW6NNYN^e+{_CO0mYgr!5FjVpN-SX4~ zvy35%KF{b1sCctip59-RJXpI%&g-@WwQ94#LBjH%&40s)9Lea>^wolIRmWI!b)nIiZ z4GaYpnfF^(2UQ>V=%4q&9cS*mAfo2kT~Kr#yAhX(3}KMfrU- zNgU*!nKe%u5@E}{xRX$jYiI{N0GRERWhX@q6U;~!Ga3WtR1j!?71WjbnT%Xgq;|yWQPvnsu|bi-V&+!3f-A?>;qGL|SbdQY2=|MKtJ@`X zK*mQ{_7GDg0049UZcz5&y3{^Jc1+kfOju4zQ{s{$%~H^aun|WJxskI*FHShO68Xt> z*-*i96j#V(*gpwR?qeboKH4Fpu{lM&sthjkzTI6aC>%r8wGc$&FbJ>;j27tVzk4O^V+AgWOJe*s znyJ?hP}*R-vzsrS6a4FJdy;x0=SUQ5u$dUr@y*%Cg4uMl5>MNV&&q`nORY(~h!J1@&H z%B33;Ocq?grw~HQobS>DFqnJ_A*B}VVp5XZuZ&IU8^cXgW7ZZjOUQwmBuPDIrJ3Ee zi76%@9n&^hx_0)|wM~i|VgBb#{&U9QIc@%~JM@krOsxJi~cc0PF~@ z#4IbUld_h+No81ODTKS&`N6>TJbRUc^^xmt%LR+`lrD&@W?0)Y zGd6S8-wb*^R-b+l(w);4&g%+yeCFfz8R+q0rjrHWhRb!+md5*A9_v|M6a(gA~PgsPOy8e0P5J?190T@>-0Z9_miQ*tc?@&7134pH8-apnb>%Q|P+WbH&(r z)f&8L@j$=#(j5Tcczyb$CJF#fX;Sa#!&mLDlj;;!{*7HT@3r=07((=IukE2XeomKR zLPco;xu#Lz4aVoDYMTfQpV$){z_36V9F{iD#K;*@8`97{vW~;b!l4yxmK3y4EHPoS zq&J3whN%XeZ40@pM^lYuhFqg89p=seXBnYZ;|v*3mnRCiVM9ervXmgdabdN44<$OL zCfu#G#_i7g0rLY_?vg1ET8Hjn>3?zW&=q&nvMK`%?z&=N{A2a;6YAuhM9cS1Ok=61 zv=Mw4y1wpm0ID}i-K_8M(}ePxYD>O;bJY^Pz@N}i5S zvGNn@#v?V&bmKwNIVLP6*X$SHMuq|%z#!Q$-JRNld0BDv0AO&xL{Px_`>+V4q`zcL=Ih6yi?-)aeYm=SH zKD>4$`#y*2VFBPkgqcmo5ISPljsQtgJK(`~JLYNz=ITaBC9wtI>|@AbuVFj4wZvA2 zi*)$Sk~RaQ`gCQ3j6Oq6_E1d@a$etb-ArrhbF6UE-5TnjwM{XcqfDPQM6TG~4?{Y< zzvL>y5AcT8vJzU3AFofG^F#pvzK9Brj1 zxNh&E9hM?VOh4<~uqbFBr+wo{TBS9soWb^H2qn-iA|4-+PJ)_B01(-(nbc`-`VP**0}6(h4M??%Q57%o(l#b)PgQ*zsrFktYlQG&3+{b+;J2oCxA_=b5Yt zN6yW;kqOyj1bBm0pmgYpTnM3Fi5VpE`*JA>8k3P ztQ}cD4JFzK!w_oXmf$!D_osU}HFTnhb0KJR(;L}@_Pq&KyF9d7-FHbnlp~*y)N%DeY_F?$c+a7l~j<XP6P=of+`m2<#BO!e^FSx36&uA_ZT_;c@w~nPZ{E@7p`Eju zJRNMA?!s{<7@V@k-kovXYzUn5Md)B?ioG|M`rFqh{^^fz5qW>G^D<-m_y|t$bj)?D z7&yiRWXKsUB2(dpxry{Vz(4SVL;n*D{4M2KZRT}tDMoH#j{<;$|9o>I3C2Zz1Bw?2 zZA7Lfro%(WkHd`ZgWT`5d13OGL%F|xxy))G^54I6`Q48GyB*#Ky&e$JQEi+KC#Vlp zwAEiS&@>1*m+8Eg@wU3;!GCE0{7!A|pKC(LRFT_W6Xc9Ogev#0-+aUAzvc4X^_lS} zj$d=yiNPMx#7{Y*zj%Ixt9=%OdksZ+068bBx!XPy&T!4{yJ~meVNP{I6T574U9@^| zPP*aq&$QnAwdrTIB|_eUc~lBCLifltp>;oH1q03)y?8ShD(upg@astQ4f)##LY>9J zFoYZ*ceRo~iC_Q@a*s4Oj5HO7TN*~L3{HG)X6kD*lfTWpes$aAzu7iHSc}<{Dd9rCU&lR|+Z3kzB9)DJvL&3**haH@{n3N?!ZIUE}W7QEwZH z9mp|L{H!`WZEqrf62X9jOf}TGB3;S;?qq*Yy8lwg@W+Fr9}lg_AT76Z!DmI+lLi;~ z{a()V|9*OET>yS-DvJTEJZ*3LpnvEKgCide3?rw)dA=|>+{Ii4uMl=VJB$U)pS;J+ z$tuX7WKlRc6lvc`4=1#tliDCuvAM?9Rq6l47;vaIaZDLw<%-Ua*Tc1mvSC*ow)-LN zgOKi&HiB1Lg2uDj)J;p|mNk6M6ufQ@o=_z(>jRMMHsAe-`>&txUx$Leb>YL#)LozF zhS7K3;KSfksyN9$&+8)3q8i8rV;D8_Iin90go{GXYl16fMC`IIK;#5%f2GO)FC2sq z*Tv7M!&miwXoQsu+F&IHBzHW#lDwbO#V+b&mkjYM#v)Q9xXXT7ec@yP%2 zmlrfSGz?-6G(EE_9elc&?d>w=6Bs0=c`^ z$ZbpHrX_qU;QMHw?NQ!!+v~k%@ZIrxzBuLo+jkGM0DRbyg7$HVTXrv87va>) zXyTqdaMc(=0(!}u`na|DpRVot*{wY{O}Gk;;vJ9Y{V_Y&tB3%ACj}YLnEvcK<)FV&yo;~rz8@XZfv-5+T(}Yf~X#a*IdBG4RUVNFMCdO~V zXEjiF3^2T-iCi@XZ<_sAjNyMX(*H-try(B{a!@@1;9k)4a=?Du=_Mz>?eaX%xwsfS zZFFH6Hp{BQ{UY=^g5Tj@3!A}I-s>D-Y`CW zJ!!k+^0H?D+u#WMCr$b&z+v0s3FyM;MLa~oq8{rM#-8BR!z(=$jSp$zf zp;ysWFJyx-z5=;p4P(OZMG}ww>34LrX0EIRGculT8ELccXj99|D6N*d_;rS<2#Mj( zQ<^x=0KpkZa{~3?IslN9UepEtOdmV0j6z=oYStNXuFIT2N(hmB<-9h?PK*4BGmx)b zLTapzoi`;f84H&Td3*%9#C#EFz+|k#tJ3_fgZ4y^Bh-Z1*;$!RCMuTP!v|jE{&YwnxRBi$5)Q80RZs? z&T_am4iA9MvX{K3_YvPD+QbnIgJ5z;=_CGk)FE<&2>t|eL@t#AgAYOPl0F0jAs4%- zEdjuegb)a4vMsUkZ3C@6M*=A*@vhzZC}4P%@I4QJ*@EvTQa?C0{UjJA^zvRfcHd*Y z=d#`P*q%4m;_FW$>Sw;l14r-U-vf{JMb!EvXnp7k;mBZXZ^OV? zQ}4(o7_5*x)2dR3%8p~PN7n-YIs_d-Z&=ZBPMSoUmnKM5iuFRDYXHExnA0Ty05UCr z1{fhYNb*I%fb)_|vIC$*CfBCvo>({B0$9Rq;oW%UPMVCoyfz;ta?|8F<%xP{=6l$;zv11e{Z%aV(Cd2Sx4noZe{_EPlVA*Pao=rwH{yNZ4BT_NAGiY_j7UBl zrJf}0cO9;~b}uO9kJ9gItxJX!!S6t$>E4C}qfQwER z+HYB0>jJ>e`O4a9Utu%@K}JUgKwx}0Leq{aLbATAGzL*JlC6iJ$*TtX3@_?Zs{jB} z;!~AD_$XaB#fklrAVW->CLC>m*e||_ocfF=chQu(Zi_$lg<-R>wax8|$UT?kzQ;7& z+0H$9*J-_D^T2iR{-M|WelGi`r)L;W_uqHfpGO=|yrBmk+hf1|RVw-ACHcqOMPKOk zKk)}12Q2S~!cYC)hd%Qis|RlMo%zAv8SA=hcOW1Wc6#735fUTP-_rBIXTUTzf}u

lcsy_kVltO7qYM`OJMp=8)luC;ZeKe&{z503g%()`6Cu%JY}m%3*&>y|jNS*ZM|p zKuiMH;dP?{q~3)2l6#rAYz_BaQPzS%LGXYtsS<0lAP4~f;}D7~gU>H7cN{k8{h{B0 z?B$JvkN8vhfpNC@q5-f6UIZiL2rThMH2(YZeWVQj#jRZ*WYYlPt~Kz$8GPms-?h@w z2m&qRD}${)+X`)555PxYOG0#9Y3&(06ZZqKbN2A&Gkhb>Ue)`!Ny?=aQ-)&KNkh2+ zkf8c2RQ)Zp3xorzy=`;6&G?h^d_b^cW{d52Ek znNZ;fvak^$bU#^WEAs$w=8Isg3N=azc`PsKqm zA)JQ+-NxAXkQyS*U!^!tJT*MYe!8Indc$AqZT`Rh@b2&Z{=<6*cRUG{iU5+QvR~(( z0SX1fRGtb>14?0vM1PsfBD=*BDqZ%D$8*!-{oK0x)D-xRsg%zA$^iJ%0n6HCh9;Z` zUehLo^TZoLeM@&QI0sP>oOMLr6ia;foWUqfH;I?_y>e)A} z3~X%F%w!^ElcC}9n#e-92T^|C3wr*A0&vsfg5kU|x5H9lbY+ncy#JP$!xx`N6XU=6 z@K3KT$M>BA|GecF=aa?h_56TsDOO@YatE@gf1v=}aoN9o*m&O$OEDs|-h41&-n$hl zdz(aXRVilwV}JBuxc_WdPmi|IaNtnon7bRG0xm+B`5eoHTR#r!?)$7CZ8tv2ns1u_ z#Q^Z4-Snls)gO$j-=?CeOyEwq<6xf8meDc*EVp-m_xe(!KISP*SDpbdZ}j!kuIOlE z$LC5Y{I9z#UqS&muYQ2&ZTeLM@Wkl*_FE%y)9krrb-^eO+I;s~l(X8zTiKBUki#0rtMej)5J?;@#QN-ujj-ui5SzKIANKb3XG&P(W}3Pxy^9M2<^Z-|K&B z3%qRa{QWb#UrtWHOs4L8Y;W1jIK!!{epwG-8woIf{Q1Zm`FHaJ|Ks;A{ovpj3e?Y3 zA8ew1`UXoNvJsE(Oc#B=hN3>+t%m56Cf18|hk0M3h{INOwW)EOTzfB|IzB~NsK zA3O`kzjtHtH~ZR<3YV=2nbJnCz^e2;qkl61?i+ot^x^HYOywyag|$x;_3Kn6w!p`Y zg^{N>22LNl|8{*)%yM&kK~1}n4bqJYM^qYVQ&BFx$n6nEVg35>-&LlZALD}3W}r0lm`!QvOt{bpy- zGSZPfcw<&%=3Kd7rj}gS4=QD{&g;e%?!K(5&&?jI`<#V;h$O}pT`5> zj2~uluhZFeyMRr7BhZLn)jjQN+>Cjwu1#(LyuHmH4y188X|ScXHYD>F@kU`v3!8`j z^}_zor}_tBjID(7w0R2 zlJBA`cGezUH!T4GFbYt?p4!cVFud}n$+HRXzoov88T_@%2ybk=w<-lcAg%x~oT}Eh zZ)U6s_|1y9_XEkc0|+_c+K!g)q4G#u6FYBpO;;x?^OwD~iBq-7|LC)`G2pht@w%T{ z1>mpT&M*7SANx#5Ls`P&N_UNCn{s{iO)4Ui)`?(MuG`Ua!4_KY9!2m@#Z8!-7B@`b zbLh)0b{Y8rJ>O5XZ&-N~s>&ssMn3D=P8njie3>PEehmg2`+>(m|K_Se62)T6K6)LC zf4NxvIGbKK3%XjD{8P94zr-xxa5-KkvQGwOAB|OQAykEW>aV+*>aTO2>5ma~1a(*gXxONoJKEgV04ZP6S|(H)eh) zVeHx+Z>ap%p}GxS^W9MNi@Bz+7IS5{rXlN&ciINwc^X-`)7xHI_wCgt-V4Xp1>gfy z02Bld_@>*oYuog-ZR*#3f-Ur|61N5H3<~6Zkn6Vy&R_A&CA!sfFIKVI*8W;weh`*aw6MNPn&rq z0Q92L|1Zfj*cFxz^x0-u2j#`L2z&hlXCm$-7( zKH=WN9j#-VJT7ef|fy0@)#>zU|>jH2}6`rroT&hWN z0eICWe%UPDf;d3r?~Ygf)eP?&G1HITzGYSTO2s}Zgt&kZj8P5%B9fCA$ArkVDiPWU zfOjmxt~&o^lfP`^A9o8(c@d-xoA@DN+o(q1byme&sa%ALapM69x;xo*)lkmAxcZ?A z7i0x0gW}2#e$5m_L@Ya~UfPfA;_4~^eL46&QZjBVM0lt?|2vxCjHTgZH2ITly#KbH z_x&Bb|1c_fk*a->tSblL_a>{p*DHO;lFCEs{&eBSvE?4{bll&6iX-)8#2cS|ww$dX< zaUlyotP#+DBaO5jajZ6-7E~Uiu>3sd?BTAazQ)bITB!!vNaRa(+Tlf2vkFX{qwvTS7#j!E606#$ZPBH)us zn9YBP3({#Q1Hh;>z19FjZw~+lpJrOeUp584u+{@m?uZ)tOQ{zXLL4en2-ae#0H9^K ze`%-x{*>aI!~7q$$-(>I>y`b01>l2z0p!Dx5&++yqF-Z&M=$EiXXz4o)B;Z#0GKyx zfOKvk@E}PqHvw%B>tC**VDc?40N}md^5jMU0Bqv^zO88&FM-RgAs=2Gs`pi;J-lR* zUpS{-qq`|_?pS?k0?&;8+Z6ybRF|fWP;sn2T1i3seJNxabNND7gh=mma#ZLsX_AOWB<2H=YrzgxMwNGu-1)&*c!qS4YkgG*rX zc*RW0_y^t)-P(Xp;Rg|ETWh3iH3q7~+^qVmrr^U00H#)IOz;%}U|qtj%QMyw*~;n-}KggrC0dP2^s(}5~5An4D7rp ztZZ51Edv}mnU|j)uHKcG^bvjl>~9GGJZ7iTi-hkxrqJd9oOZ=(4jiglI3V}8A^-xp zG7lgWgoF4vFpdh?Qc!*5QaL=&6HVyzT>H-_3p$ILN05Dy%VA=8^8F5#)bUG+%B!4EQ1z0+3 zkx&IJ73V}fR5;N4ey0ew|1_$)=Qe-QZT*v|`5*eL|DaC-4|pC|KJXe#9`IhH>~ChI z-%BgM)h=BZfWcaK82~5Mbjm^2ujl){f(RMFpV@1QP404JHwU0a8np4ENGAXQ((SJ@ zOXULa?UluQXXeRdT6>0&Wm@?WPSkf$DvQB`wXu0w`uIu=_=YL?cr5^+ zFD?M{)#=MR|3(0`3OnHRMNtd+)1_`_y^T-;PPMG{mH_AyK17jZF27^P05am{04Vb zlBB~#>SR7?VaRRh%~drXt`C<1P?p!i>+xMP<=Lb1+-JJ5EC8p~^vEwT4cp0lTkU~E z$GwT(wqD`lQ9w=uAaIc^5W?P>A%f$zwIOb9P8vbGs#D7=0Q{jj{Jf$N;{cF{T+jxf z{jYE+h;%z}In;eQQMZGNk~vfYKw`jkx9h>A{N2WCTKjJE4U^~70pUN5NdKu({oR7{ zy9s&&)qS^#p`gZU@YLUpR=pk&Bw`VTWCjY+vN zh4ehOwXvvgh03_O4H%#-4aF?!?w-Vj%8qoYSOAc-vL3KI-as6@H{KCcbVRlmVDeZw zT&FAmaHT)C=C%aV%DsxjJDT7ol`veq)Y+BfAQvk5ik$ghFYITL#vJI$q$bt{0EYX) z0RKVI@F+;%M!e@Ye{+=gPo1^@)S&rR#Q41!?GtyM))fkBsQ$Y}0S&+y|L1KA}(09a`j^2xA#Wx2jX#bx= zu5U+;zaKM_<6B-NY9a4t6@Pn__ls7=chkzh%&OSj6!ig+c-1C>Hym!R>(YhV6(vil z(ZRT#zv>}t(yP<8)5FH1okL{Y!D`Vy&z9=gD?$MewJ-F zkxpbtT;!_WFQU7F-I5^$VJ{Wd)yA6AeeBnl>%#|XW9-%_E&yzHucO>Eb+o;o(S#6$ z*9CyubwsI*u?d)+7bASwFQaYcoZg#~l~NV9s(9!(0D~j`lzMT$?)1U`eXrr4cJjX! zF@MKSKZkKv9oq6dQVV%ESN%6#bzA^w?YBv2KVUGxpD3nXM`zdsqiX*$;twiMOaZAHuuRKvkL7;xZa#$^!hI9KLbaz_R=%4~5 z6-GH`cek)R4=?Hb!1b)AbV4}Xy^8XR1pvc#%Tt#s0B{e_`BmK{>q8DDom#nVb=&H^oUygc^}u{j?DBto!ubDX&ELzazh(}-qYj)=1-Cp2)B}Jgq55Cs z4L`B_zV34VWl~FfKvw?Y5bwPvDO;%lhQYlB^}c;<6BM)%-`Xe|TR`GbNeC!OH~{3! z?lS`bkXTJ|aIeb4rJ!TV$iA9*83nQTem4@}O>zLCAX`5L+sPU*jH z_I+C)JW?AzsSIqnVQ}1c8tyxdf0NVwW!Us@eU|?eu%U3EBXfbq0DeG(q((R$IWM%2 z)xs9Mx?a+YrxhW}$Hi?WT3AW!;A_*89HcS}B2#ApxUBadtc#Ib5nKQ^*FNzKwlT*F zVkWyU6Jg-o#sHZ4Ar=5sZKM&2S+T>|^|H&@V#BEMx^wkIv=Dg$6nnuU}B0Ancw06N-V69)@%0mu)G zbJZ$p9H8%n@IzdUBQTI=IJZ+qwT-aP#hu{%2tZ{JX4tF*04#v`3YUT~2X3avmVqu_ zg17gmVEM#+vd98}iS4S5u+6v&U1p>($Y?%EDSr?${=LKhzB>3NjqgmYb5ZKsa;4sW zRqeQ@b^T|r?JqpGZwC!O4x5p>g1lJrd9Nr#K3WC)og1wml-(mP<9}%XQW(D#y=l?jPG--}0IN z!f&Uait-sA2em8)BrAmkc5W*RUr0S=-XhTsvH~*|Y!$T`C@%{DGKxKlFG0?^f+5)FLNvCSZwceB2;KfceYAk3E>Cgve_a8bOf3FX` zs`GqY>pUql?-ts&>=Qed<<86X3;@R9@0h(mb-VAnEKegUeCKgUgLpvXp4QTLCMDO_4BIp8T3#SnC zpHM}hEO@kUdfwPQXI11>6a|n!+UuEZF9ZO( zA`#IQ{ymQw+5F>F{p?UWv}fwmZsGsdB>OK`$Lm_xmul?G64M^avSm@?U6T4w)Ovqz z_x#ut`qbk8vCE4XaMSEAd#D=asI6_(j?T3{y?kzSL9VnYl zA()ZeAdEVQj6{&I$uclx?m69p6soPV?Lg$t6p=tx#u6OYG|nMuK|xLcxTJ?~`LC=L zVjr6V_t{0M10G&d&-bwaoY4d!S63+LtRs^4<+*n~$!(bd+4e#4WT#{}FQBo(L5z$# zGTb`gw#@;8QxK1=?Sd1@={ke(q!d zF#AD4hymmgj5XUa`oV636%@T4%LP8prEC5v}PW7a_*=8i` z^+Y-pZWaJI{y9zXJmV_|HC~jRg1%`k@5gw>yrj=ag}58y%F-d|2nDv&Hs~?*CEYy? zuC#AmS+5c%k&Llu_jz@Y@D&6xfb5DmXby3!Uo4Q!Cn0EYQVEkVToCorsX>n3n~lPV z#DBIB8|rLd-0uHqwCbbbs^8AZf8f*ogT?--&hW9qc2HzKDl}|alKJ;Ts)Ik*1wlcd zT0K7rSrCD_0`f6!o2sk!*C_%7fI&&7O^_cH4fhHK(l{X zOQJ3jSGpiKra%Y;fDUOCih%PXkah{jn+5&T)%!i7EZ0(40D_8++Mb0%^YH2&61B%J za>@M7WH9&VxL~Ja`L+dSdm%{>%Nsr`I}*5e2icA+5m}-qSe3;D*gFc+BS)8*uIep| z>(71858YO>;isYAObCPpfSv>MYe)ouqiVmQqQYRsEAr}A-XC%%?XF>zgoPUWgP`F- zyRvVl<@B7H-hkC8dmPYw(P{o8m+2q$-cMBaj}^|t66*qG1qJ!QLg$r^pR4^pc38jR zu>OVD{>ZO;64AUY%22?vM1e7hYcx0>DXih#T!dfX$L#R6M1- zF(R)~;3brck)$zmJ|lm>Q}n30?!t_o>FV=e#OfXg)L*chf8YcFzK`nMA1hpYL=GB& zy<*Qksb@**{cES;XQA5P4cGo8Xt-r|+;v!C`1GEM6%3G8I~CSOjrp#}malPSsT89c zU%slbLr`d_DvnFWlA?Cx34T!l-oOHYnUP*5w?-$GFcEe~!kmJ>rEU=fw#yTZvJwE` z*=^N(XXN`vME#T1i*t&@C`8{TC3RNCQKbiVPj^`CxH`@(8k~pHxd1?R+H`apT4VoV zw*Icu$`bGII=o-Edk(}?ve8AojLsLblAa0KUYs@|NE$2ZGBz(?ty@9;BUSIF6;C5| zcWtJxxOM+Q>;0>`*jH+uAIsdkh4ux3WeaM7<1+h6x&5z9rayPr|E|C0XHn(zNX?6Q zX?_5coG!JkZC9U-+^A{N8;Ajh1u;mRm+cc22PNC{!XEVsKY*Zi)`uT>P3J65QZW^3 z?fCPa+foJq^U#uMT2-QyQN8J`$=xELGkUu)(I*)x@|zo|woc(lm!NZ7_3laeQW*xs zljtHy+{is1u7=O-+trt3kL-79xd7abDDz57OzaOyoi74HnJ*VNG!|+<;`VShPTCZ$g>Ga%f61QZN`IMl~L>< zDF>2t_?=1MPgLdm1T8@-&P_ubsr-$A?z~S;Si7h)bxT$wyTyFktS7Cfkitfe zTnefVTb$$$yF(DqSGAbjnJv$YZc<&pio@f}D&n2xE(UzP3=b|=K`q#1`mXr>|v z#gOX`d#_VX6-2y2S`zKpo5yc*({TEVhlt9)c}>!A|> z07|@Ix1Dkr$e1#(xAS5cutO8L7OMAE)7rO7?Z-W8U`95$(Rn=;L8Q@8?idwAb&V9z zPer0sIwR`Dd-^Us=N^4oX99UDWXZ>}p^}5b|UGKSJ zpo=nYPhz{8PH$pVGwHak?2w_iIFy=YqYKDDYMt~ZHS+X2StL>#Bv&}Yt%x#SVNSJAu?7UulIw3hN%n1=vRF(D`M3L@aQ$~{tRwI4w6Bng-_dtr5pFzc&Ifq77iH;Fp(R0@i= z@gj)5q$MD?WRaG2+K3+z7#;^TGxhXdAYy&ot}MyY*p>ik zgduh{_0bLCalHv{w5_3qq}~^8*06Mq9oa-Kax;$3d#O+#`rU%cR(b zI>Nk$l(4<*#w61#fy)%=0X z_*Y`{YoYF|BJC1Se@w6fz>+}!XA>jYr{HV5bS4CIy!jIQ>;A*9}9CAvdd5gHe78Oi+{#kvu+f__w=>5 z)XMi2ns13szYv+<7wSF{YL5yui@X&II!R+#wGuCKt{6R;;Jm-(rkSlJ&zpEmV|Eeh3$lAWWB|0XmX*Nz+yPmnVk{ z?7}Vw>m5=%qu-M4pR8pbw3Hy=*`Kw03l4N|*~EVYL*0R&{u z3Eg%feG>L|E_uv)M|V_=#-TYjMTZgw@KpY*DoGCO5&T_VmB z@cUxRd8uw$YCctM0f1NP%#)e?VQcVGt>w15PPv~3K-oTzsSvwkrtYbnhngu7SO8Mh zUV1l~zU@lP$aU~DKJ_uT_O8>G9~{G(k+hIza5$c#>r4XYS!N?(#|F39wd+jWYo)pe&N^_K*OFY?U`JpKCu{kj0queT}PABv2} zg!(fQ{ZX;$yv(rM?EkeR#|Klzi*o(#dc{efsitQaY@eiwM#^gzmNIdd!fIc+AM31& zkzI1gXBy2-NUMYLQk~_M(XP!EI@T48#@YE0b2HDU>LO0&(0A4zd$WzNEQJaSP|e@?7DBi5dm)zBK| z8PD>JAMngj`U}2cLjcb3jsIR~To&qp!4aY1v{=6=F{o_K|N8LBtBU%E8p%noNzyw< zv`p{n7DQUb-KC`1tgoCIqNwD=(Y?70+k!R;T?;Bt*-u0EB-3U^hUBImRVTEM0t&C> zQ6!RLwo@bP+rGGQM!s)awrf&4GbP)OVMSiCP0-aQ=){`_UU#E-fSpW_(sS6YX|HvX zZ4;~`asePE=TWbvay!gMds4|Z*|^wzxkg$L8)jwo7sU0agxW=cfoLDP<^pipn>ilV zUJl610XWGweT{EfrZo8G8IcC*sgrWR`kbn^drH|uwItL(Y^p7hLqyaZDbKA$S-B(DM@FSY#P#Cy^YOBhnPL?ywUB+&cQX0(P~Ri+Qg);NmkX{=J3{&!bv+{!zl8IqqSC^orS2XPuB!Yr?erpk50dl#j4z_85VtBXC ze$HheU66GF=$&Z0Jz2LL*O9cR>^1;#CblKsAQ@;8_gJaY+&n_-ZdvWDOnI|fc8t<8 z+AkS90l+derCV6qzCHVLoT7iK;FMW8SwWh!9c zDDG#Wu=*CkV7H){;$=}ew~9vaMWlvlULHzB!piQggP`O_ zZRL{DF<@6;5?5pacpTEUH)k>7t5fQojpo{wCqZn&xL1@TQWkj90=g|2cign1`4a6S1L|rZXLVRnXnO_X~CJ``x{ov86sh#Z4Ss}QQ zAH;a*H#`Yz*p3+Lol`AtPum!5A6XE>4B0k87}m7nThd4yyAcnG5J(@YZ=ibO{B)n7 z7@#6yQQj>`q^V>lmG=rFVI=8-4E%ttlL5f}sPdFoJvmg^dT=@A?mz+jNnHKMUI*%M zjlUNFTp8CQV+Kc|(#}bGdt@`C^4&!6CpQ5z@q#!r*GsZO^Jp>|ZR#llz%Vu&I5PQo zLdw0k9X?^IC>o%fQtX(6v+TIvEK~CA4LiQ6L1-a+Pe|jz z#*ZN_;re5AYXkC59iM)j50;B~09|15&XxN|d12GCE&d>EV0R9m$$51L_Yx#dIjl_f znPusf^?;4ULiwAhEVPU|AS!I?7te4lv20|Fd8Qt@z(Tx1y6-UZ-CWxU=0JWGg=`E^ zFYPzP#t&H>BO^uA;4#?yaP|CvWUNa(mZlOZemW)Yi0~UT{EmXSFDvOGTM^hOMQ&a~ zuPx z05dP_;U`)pz3@ktk^EaTbh69~^05FUB&9b1AZSh1Rr_nS z@uE67toB3uV0ij*$u|P<;b_%|L%e%qwMzrxngjbYo-E;_N&cFatfMNb7x?0WOs8~w z|Xd1-jui&rh0-+B2e40g)q!e< zRp|98TT;?Mrq1#b2Q7{WFM~59NQ8!W4OQ=K77q1E#)E>GLEwexkr4?3S$-Bu$1oE= zK$lAz?pNV1!tCzZFewFOiJEmHp)TBUbi&>yzUb~Xk?1A z5Bdd5Lt)|RW2@0v;Nm`#2d^iM$x=2(r8imsm_a|Tp>M)?sL&;+{$+<0m!oSpdLyyt zqR!td%7DH$Y`aUs#L&q70B+OGz--hB^3oZ8ny9!^FrM7KK=B2}JKtny?+bNlP3`rz z6IL^vV4K=QmV@JS%JC+ur+BH{W!MG=l4-Dk?$F+A1rt#O_dgP1Hx>ebNqMorqjNb8<#tj*3zLJkcNuiC%dHr;hMV%X@H2!<^>(t`F# zoGC#vNyRsrfL~nDuS7VRZ_U;;I6<>Fr^xo)?2(WMG+5?ge;1H>Mr>{Gn+w2u1^J6i z&0T+exT%l3J+JJ}-|FrXalc0pfyu11fytdcZuHgeJGjRkCjiXKZAEv{ z0zh^=kR8`tt0zlkHzFXUBUG^4BVY}{z{`&z*g_J*JhBw-w$V~FR?|On+}_;JRXs}a zvpR27>h5*YyP;kr>Yhhx4j7GYesr_Vufl*!l-8^?0?JzAQv%bn!akNLJ{(a%neXNl zgn5{!Q*C4m-g13(Ljc~*DoVGf(~4U|HQHU<;J-x3R=w2+qice4OO{ZrLzr~&VqPj# zwtM^6jvTMqfAFv+NVHF6o5E422+SFR2YhLLY@7w4q^X+1n$=PPb|b>JU_JLY8`kU% z5crYsK>UJ~RS+e$;Xw5cwiQ8ow2`{b?efIxW2z#$9}WzjcZq53M{A$?74urn=KhB) zoSTSi_+IiHC()98_Y#s?$gK$_!Y?$5*rVOQq&>LUG(PYoqCUa=Z;4xg29ZZW9oZgI zl5`DVU^fL3nO%tU&zvBwO8Vzgb&0Z#5g=Tb#y9=7ln>@l8~#exCu-;?nqj5pBqI9&*e5Bj?^F72RxujSoyL#bXpbOLhjR&#sIwISxmVK0OD}Fs4P#N+?{4GKz^&Q zY-SEoGA3#baAGpu%j3lr{?>fDYmdkY*(-J(lC6G){1%e1b>xjIMv=k3mqgn*PtL^F z9X}-MPa$s+M8Z_e&ySfnEJ6DFv!S}hWDT80*H~UQNS{X(PdxI+_Ug+XO)$}7uZr2$ ztL)6e2z;Gof4MCu=$kInzVy_QQ*&lo*DaDG?S93vL)FI*pXsyXPcYK0py3^D@C*PT zJJBI`?DqRiV)QIlPq>KCQ5gWQ#s$~5sq+W=$Yp;0z!9Y?0WM}TbdQGbQBiJ#Fo@_B zqM|-NQq>sJM4B!HE|)GoC-?3a+V=?U8Olj+gG|vUP<)oG zT{Jcz+ukdi4T>ANH-R@7EuDRmR+R_!ic1g*i<`=B*%O&C>33Rnr6yXn>9V@+TCFlX zF!mdZ2g}<14wU*-*v$!Vh)EFg^E?pLOelBnPStR=1Sfzi;M3h>Q8uvz-<(p>Pi?LN z0ErP)|1iJ+kSGB_UWt^&gU1unz>&$)wO1ZfHBzMqXz9gMZl5s4YR$^9hbhv!O&M%w z_9ga}03a)9&hZ<1#J#=Zz8>*llb{0tvg7XdKf%Ki~IQWuc8IEW};QOa8)M9?--O$7g3J# zT7f|?2wmJs5JgrR7Bx79sl2GoO|2%2m3ilyq#0Bx_SZ>Q9xtp5U(=Ycs*KBWqlw*x&B>jkBkl_jSIQpsn-6VID#*k{1-S@3l&I z4kiR=mK{gu9!$zn;lA#npnYUI*9_jvdf$oq@ELV*wH0w;Mi02!3q41b(UYpsS#^l~ z|Ip0;j$$Gqnog54HLv>wm0U!JP=SjyMpR7Aj9gGJ@Yw}xHgq6Z^;lxvwqz@Ft4Bxq zy;Rmr@5zag{u)*o6gH%V9dITOFP^7Tjl4pua0HArAluQvZ%c_f2nI-Yt^5#PTh?w= z#TnQ;=Ik9hHZatvB8rut8R3m>zbH5Evjh(4J?AC#t{h0l9AzI&Ln#N0L!x3xm_?;s z1}6$#EWBl3XQ_=gOh434sN!i#dBG;-Sr+9+y zO;T-OB(?=><_BFAEo-(6p&DNSfUOhPeV|Nk$z(bQ4(Qy8?!h*p`J6}tS>aDF@(4eb z^9gcZDs86dEg)>#!#*AilO?uKGD{kOqKtGJ?z&B7B54x);09!F zA-?tODQ=F;)`zSvOg#2+B4>eT0RX66Z2XXs@&bv{&oD7@!cO^f{2aK5m@r{s3oq0z z9%~T}H&cV%(rMBz?Ul}y?av`Mi0oDCWTj9a>Lw&G5af~M_a1&@>5yY$#yC5VYO#T` zI4L_Dz+8NU+>C~K2!%jWYW5M6(nJOp8fea8^zf=x@_si30G=i55Jc~|%y&J8D+Ukb z-A>W_1H5+|W$*V2p2q8LSnQYe?#nvYd5wEX5#B=o!N{X@{mU*f@uR&nv>zN*Mt}kQ z;G!;o$_-%v+9&@pEqQK$>pJW+Oy%*gqF&VtoO!-=oM+7Ph+gQQ0j4v>6}mtLSD?~H zW-~hdG(FYAJOb_qN0E+2vv7zw0YHLI*?loI-mdn_dNm+eD*n;;~loXja&j z6t?+9d8@KP)w-RxbPTCvwPW?64;9@>_G(Mw(Nlii%58UIQvNy%{Zm$*BJ$%_NZAo} zpQV%_>U(bE9k=m{(Mc40EvSFqD1F`_d(|U&605!I)m_%n04ytgivZw~-c5uaYPCJj zDF7x&=AA~zJ7yRB;G8A^I=X3bvK~M`L?6 zgD*y^8Vsep9`!-MN5z`!BXlZp-7p>aZD39R2Mm^3SQ9q?|TiX6%YwPaG*K%_bwr%YOCD7iPQ+GLhSG;E2vXMxn4v9 z{RX}Jby_aGNP*wnE13vT^b^%dUba#Q90+F2s)qwq~N#&YModqiF5pSnU%=4%b8b!GpCC#FJ|#=QX4IhS_z)?7nSvJ`QVN zb%-GM(h8s4wvq)o>#BX~?mnbnk0xYD0Ll_A)aa{KSL!m z{A_S*w!q7^Q+WsC@XBrpwnYKWLiz!%+>)|TMt8R+8YFQx-&j9Ui-M+U*6@4QBI?6-3{3xfvLJ3~F zNszM&g6!@f>q?U@A!>u(2W2}7g6<5z1H2Cm!dvs63#37>WHo_1C}NJ;Mo6?X%Q>vr`Ct>w7V_5TBAj-_t&?fkL;0000avatars/karlpolice.png avatars/matthewde.jpg avatars/mm2pl.png + avatars/mohad12211.png avatars/pajlada.png avatars/revolter.jpg avatars/slch.png diff --git a/src/autogenerated/ResourcesAutogen.cpp b/src/autogenerated/ResourcesAutogen.cpp index 43f5e7c4d..671376e6a 100644 --- a/src/autogenerated/ResourcesAutogen.cpp +++ b/src/autogenerated/ResourcesAutogen.cpp @@ -15,6 +15,7 @@ Resources2::Resources2() this->avatars.kararty = QPixmap(":/avatars/kararty.png"); this->avatars.karlpolice = QPixmap(":/avatars/karlpolice.png"); this->avatars.mm2pl = QPixmap(":/avatars/mm2pl.png"); + this->avatars.mohad12211 = QPixmap(":/avatars/mohad12211.png"); this->avatars.pajlada = QPixmap(":/avatars/pajlada.png"); this->avatars.slch = QPixmap(":/avatars/slch.png"); this->avatars.xheaveny = QPixmap(":/avatars/xheaveny.png"); diff --git a/src/autogenerated/ResourcesAutogen.hpp b/src/autogenerated/ResourcesAutogen.hpp index b1ea09813..218d59539 100644 --- a/src/autogenerated/ResourcesAutogen.hpp +++ b/src/autogenerated/ResourcesAutogen.hpp @@ -20,6 +20,7 @@ public: QPixmap kararty; QPixmap karlpolice; QPixmap mm2pl; + QPixmap mohad12211; QPixmap pajlada; QPixmap slch; QPixmap xheaveny; From 46cdb8949827026d119a425247b54f4986a4acba Mon Sep 17 00:00:00 2001 From: xel86 Date: Fri, 11 Nov 2022 18:17:50 -0500 Subject: [PATCH 112/946] Allow Commercial API endpoint to handle commercial lengths (#4141) --- CHANGELOG.md | 2 +- .../commands/CommandController.cpp | 28 ++++++++----------- src/providers/twitch/api/Helix.cpp | 5 ++++ src/providers/twitch/api/Helix.hpp | 1 + 4 files changed, 18 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 726f3d89a..ed1f4e8e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -71,7 +71,7 @@ - Minor: Migrated /vips to Helix API. Chat command will continue to be used until February 11th 2023. (#4053) - Minor: Migrated /uniquechat and /r9kbeta to Helix API. (#4057) - Minor: Migrated /uniquechatoff and /r9kbetaoff to Helix API. (#4057) -- Minor: Migrated /commercial to Helix API. (#4094) +- Minor: Migrated /commercial to Helix API. (#4094, #4141) - Minor: Added stream titles to windows live toast notifications. (#1297) - Minor: Make menus and placeholders display appropriate custom key combos. (#4045) - Minor: Migrated /chatters to Helix API. (#4088, #4097, #4114) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 9da458afa..9c276328e 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -2847,6 +2847,13 @@ void CommandController::initialize(Settings &, Paths &paths) } break; + case Error::MissingLengthParameter: { + errorMessage += + "Command must include a desired commercial break " + "length that is greater than zero."; + } + break; + case Error::Ratelimited: { errorMessage += "You must wait until your cooldown period " "expires before you can run another " @@ -3009,29 +3016,16 @@ void CommandController::initialize(Settings &, Paths &paths) auto broadcasterID = tc->roomId(); auto length = words.at(1).toInt(); - // We would prefer not to early out here and rather handle the API error - // like the rest of them, but the API doesn't give us a proper length error. - // Valid lengths can be found in the length body parameter description - // https://dev.twitch.tv/docs/api/reference#start-commercial - const QList validLengths = {30, 60, 90, 120, 150, 180}; - if (!validLengths.contains(length)) - { - channel->addMessage(makeSystemMessage( - "Invalid commercial duration length specified. Valid " - "options " - "are 30, 60, 90, 120, 150, and 180 seconds")); - return ""; - } - getHelix()->startCommercial( broadcasterID, length, [channel](auto response) { channel->addMessage(makeSystemMessage( - QString("Starting commercial break. Keep in mind you " - "are still " + QString("Starting %1 second long commercial break. " + "Keep in mind you are still " "live and not all viewers will receive a " "commercial. " - "You may run another commercial in %1 seconds.") + "You may run another commercial in %2 seconds.") + .arg(response.length) .arg(response.retryAfter))); }, [channel, formatStartCommercialError](auto error, diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 1c6f717f3..6a7ec76f5 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -2398,6 +2398,11 @@ void Helix::startCommercial( failureCallback(Error::BroadcasterNotStreaming, message); } + else if (message.startsWith("Missing required parameter", + Qt::CaseInsensitive)) + { + failureCallback(Error::MissingLengthParameter, message); + } else { failureCallback(Error::Forwarded, message); diff --git a/src/providers/twitch/api/Helix.hpp b/src/providers/twitch/api/Helix.hpp index 4ee3fee08..c5c0a768f 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -620,6 +620,7 @@ enum class HelixStartCommercialError { TokenMustMatchBroadcaster, UserMissingScope, BroadcasterNotStreaming, + MissingLengthParameter, Ratelimited, // The error message is forwarded directly from the Twitch API From c714f15ce9ad4b2dad92276a1928785b92c20b7b Mon Sep 17 00:00:00 2001 From: pajlada Date: Sat, 12 Nov 2022 00:49:44 +0100 Subject: [PATCH 113/946] Add debug output to channel point reward callbacks (#4142) --- src/providers/twitch/IrcMessageHandler.cpp | 6 ++++++ src/providers/twitch/TwitchChannel.cpp | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp index 7b1246bb3..922b8cf4e 100644 --- a/src/providers/twitch/IrcMessageHandler.cpp +++ b/src/providers/twitch/IrcMessageHandler.cpp @@ -454,8 +454,14 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *_message, { // Need to wait for pubsub reward notification auto clone = _message->clone(); + qCDebug(chatterinoTwitch) << "TwitchChannel reward added ADD " + "callback since reward is not known:" + << rewardId; channel->channelPointRewardAdded.connect( [=, &server](ChannelPointReward reward) { + qCDebug(chatterinoTwitch) + << "TwitchChannel reward added callback:" << reward.id + << "-" << rewardId; if (reward.id == rewardId) { this->addMessage(clone, target, content_, server, isSub, diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index e0cd7cf58..5b1a3a75c 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -267,6 +267,10 @@ void TwitchChannel::addChannelPointReward(const ChannelPointReward &reward) } if (result) { + qCDebug(chatterinoTwitch) + << "[TwitchChannel" << this->getName() + << "] Channel point reward added:" << reward.id << "," + << reward.title << "," << reward.isUserInputRequired; this->channelPointRewardAdded.invoke(reward); } } From 070151fbc8189ea4a6932d9cd5b41d4d33847b49 Mon Sep 17 00:00:00 2001 From: Salman Abuhaimed <85521119+BKSalman@users.noreply.github.com> Date: Sat, 12 Nov 2022 11:30:44 +0300 Subject: [PATCH 114/946] change unicode for better font support and fix some cases (#4139) Co-authored-by: Felanbird <41973452+Felanbird@users.noreply.github.com> Co-authored-by: pajlada --- CHANGELOG.md | 2 +- src/messages/layouts/MessageLayoutContainer.cpp | 5 +++-- src/messages/layouts/MessageLayoutElement.cpp | 6 ++---- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed1f4e8e9..e08c055f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ - Major: Added support for Twitch's Chat Replies. [Wiki Page](https://wiki.chatterino.com/Features/#message-replies) (#3722, #3989, #4041, #4047, #4055, #4067, #4077, #3905) - Major: Added multi-channel searching to search dialog via keyboard shortcut. (Ctrl+Shift+F by default) (#3694, #3875) - Major: Added support for emotes and badges from [7TV](https://7tv.app). [Wiki Page](https://wiki.chatterino.com/Third_party_services/#7tv) (#4002, #4062) -- Major: Added support for Right-to-Left Languages (#3958) +- Major: Added support for Right-to-Left Languages (#3958, #4139) - Minor: Allow hiding moderation actions in streamer mode. (#3926) - Minor: Added highlights for `Elevated Messages`. (#4016) - Minor: Removed total views from the usercard, as Twitch no longer updates the number. (#3792) diff --git a/src/messages/layouts/MessageLayoutContainer.cpp b/src/messages/layouts/MessageLayoutContainer.cpp index b15c979c7..02688edd2 100644 --- a/src/messages/layouts/MessageLayoutContainer.cpp +++ b/src/messages/layouts/MessageLayoutContainer.cpp @@ -278,12 +278,13 @@ void MessageLayoutContainer::reorderRTL(int firstTextIndex) // the second condition below covers the possible three cases: // 1 - if we are in RTL mode (first non-neutral word is RTL) // we render RTL, reversing LTR sequences, - // 2 - if we are in LTR mode (first non-neautral word is LTR or all wrods are neutral) + // 2 - if we are in LTR mode (first non-neutral word is LTR or all words are neutral) // we render LTR, reversing RTL sequences // 3 - neutral words follow previous words, we reverse a neutral word when the previous word was reversed // the first condition checks if a neutral word is treated as a RTL word - // this is used later to add an invisible Arabic letter to fix orentation + // this is used later to add U+202B (RTL embedding) character signal to + // fix punctuation marks and mixing embedding LTR in an RTL word // this can happen in two cases: // 1 - in RTL mode, the previous word should be RTL (i.e. not reversed) // 2 - in LTR mode, the previous word should be RTL (i.e. reversed) diff --git a/src/messages/layouts/MessageLayoutElement.cpp b/src/messages/layouts/MessageLayoutElement.cpp index 7d736ede6..96e4c73e3 100644 --- a/src/messages/layouts/MessageLayoutElement.cpp +++ b/src/messages/layouts/MessageLayoutElement.cpp @@ -14,8 +14,7 @@ namespace { -const QChar RTL_MARK(0x200F); - +const QChar RTL_EMBED(0x202B); } // namespace namespace chatterino { @@ -295,8 +294,7 @@ void TextLayoutElement::paint(QPainter &painter) QString text = this->getText(); if (text.isRightToLeft() || this->reversedNeutral) { - text.prepend(RTL_MARK); - text.append(RTL_MARK); + text.prepend(RTL_EMBED); } painter.setPen(this->color_); From 06b28ea0ab59a619605a95c0f8fce718d1fd275b Mon Sep 17 00:00:00 2001 From: Patrick Geneva Date: Sat, 12 Nov 2022 07:21:43 -0500 Subject: [PATCH 115/946] Add ability to pin Usercards to stay open even if it loses focus (#3884) Co-authored-by: pajlada Co-authored-by: Rasmus Karlsson Co-authored-by: James Upjohn --- CHANGELOG.md | 1 + resources/buttons/pinDisabledDark.png | Bin 0 -> 996 bytes resources/buttons/pinDisabledDark.svg | 23 +++++++++++++ resources/buttons/pinDisabledLight.png | Bin 0 -> 989 bytes resources/buttons/pinDisabledLight.svg | 23 +++++++++++++ resources/buttons/pinEnabled.png | Bin 0 -> 1233 bytes resources/buttons/pinEnabled.svg | 23 +++++++++++++ resources/resources_autogenerated.qrc | 6 ++++ src/autogenerated/ResourcesAutogen.cpp | 3 ++ src/autogenerated/ResourcesAutogen.hpp | 3 ++ src/singletons/Theme.cpp | 2 ++ src/singletons/Theme.hpp | 1 + src/widgets/dialogs/UserInfoPopup.cpp | 38 ++++++++++++++++++++++ src/widgets/dialogs/UserInfoPopup.hpp | 10 ++++++ src/widgets/settingspages/GeneralPage.cpp | 2 +- 15 files changed, 134 insertions(+), 1 deletion(-) create mode 100644 resources/buttons/pinDisabledDark.png create mode 100644 resources/buttons/pinDisabledDark.svg create mode 100644 resources/buttons/pinDisabledLight.png create mode 100644 resources/buttons/pinDisabledLight.svg create mode 100644 resources/buttons/pinEnabled.png create mode 100644 resources/buttons/pinEnabled.svg diff --git a/CHANGELOG.md b/CHANGELOG.md index e08c055f7..5f7b4f665 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Major: Added multi-channel searching to search dialog via keyboard shortcut. (Ctrl+Shift+F by default) (#3694, #3875) - Major: Added support for emotes and badges from [7TV](https://7tv.app). [Wiki Page](https://wiki.chatterino.com/Third_party_services/#7tv) (#4002, #4062) - Major: Added support for Right-to-Left Languages (#3958, #4139) +- Minor: Added ability to pin Usercards to stay open even if it loses focus. Only available if "Automatically close usercard when it loses focus" is enabled. (#3884) - Minor: Allow hiding moderation actions in streamer mode. (#3926) - Minor: Added highlights for `Elevated Messages`. (#4016) - Minor: Removed total views from the usercard, as Twitch no longer updates the number. (#3792) diff --git a/resources/buttons/pinDisabledDark.png b/resources/buttons/pinDisabledDark.png new file mode 100644 index 0000000000000000000000000000000000000000..f762f0adeb83f13454555ac0139d6c9cf7fc98c3 GIT binary patch literal 996 zcmVAs##`DENXNL_{7h z3Pr7cf(k136ZiqNlGLJj*qyrB=4E$wc4m_PA%|o(v-$nznPg_jgorRIQPsV`d*EpL z(kd_`B0p^VY`FMfK9YeylzI68@KIIw+4egU0#tPhSSEqrlWA%K`>7xydI2f?Ti`I= z@FtZcL@l5Iyot4hXa$sjH?fisr2q?flhP8R6Ho!(q?ClH1UP^xcm~~ z%RZkD@Yza03jYW=18f#}OEZ1Mk`C|%m@6$nRVRRZz&y!_Mpe=U(gD5#b0V^CQvuV! z0&$IK(U_5+6_ItYTL}HKd|&8ng9i%`5%~mMAz4g}O1jEE+h8@4B61(N#%M?|@UtTF zGv8=k$|E9SAUsr@%aKbM3J(sx%p#Rg2p$}Ln@1+0Fg!T?Ig3O>DR^-FdmdOqX?Un% zK@&)V6+G0mqVp`l8Xjs~(t46m2_9-*)7LDaGCX+L)YK%w2|Re()j5{n3?3W{P}PR2 z9`DtbO1J_1u5gPDk}d6w3wUrSAU!5~0xYZQ^wW);AE5@x<7Lb8I#s5>^CUA3rgcz31Kt%z4;7vwCg0+CmoEQ3^_cwvt zs(PYVV+<0k1mx!Ypkwhi@C0u4tSGL5(*2b z6W%0*ghB#*z?%$NF;1rUaF(G0e8QWgi>0e{S3(@7D%iGp6v6j=YFG z1TNDpVJzo-K3k=qN?0V>g_*J+yE&iFWUJNMdeUmOR@c_nzLSjp`Ef1#Z2ke)&&x%2 S!u^^60000 + + + diff --git a/resources/buttons/pinDisabledLight.png b/resources/buttons/pinDisabledLight.png new file mode 100644 index 0000000000000000000000000000000000000000..ed381899189bbe3d1eb826bc1fd917238429e648 GIT binary patch literal 989 zcmV<310wv1P)tSc=W}9qwXJ=<7$scmbZf5iQ%`@3&mNeS!Hlq@y)NbHCa74fL z378d;AGTvQU3@VgNx>gXy}cj!sFd1cJMKsbP)bb$Ds z-lUR*s09>&H?fuwt$-5nCRP%n6kq{wQd&ZE0xH0pl#&pY00;0Ug(XBIz#+UzAqi0k zr~%$&sD$7GYJ@k*l@L^b2Y8c#5`qcv2yfC?LJ$D~;7ztkX!za^2ZT>&9rz9$-RbkH zdP}=Xva6f&dF@PEz+T{kwlJY`O*TEQVrlPus|}SQI&Lobb)WcyojvXRKN_d zNL({oG-l@KL}U%@8lhj7p9`IB@L&NVBAreojPw zW)E7I@`y;tg@=l3IdTa@;laU=S)>vQ!Goh;^T;F=h6jhYvq&VAf(OU<^S}~H!$S=X zO&|$Y@K94n=UIX^Jk;3IdXi8H9%}CCYnD(M9z1MnYLegt9z5;p97}Kp4~_*WrJ72q zWBrb43D<$&6)v$(vZbAM0S^uZ=*MJ_ffc3HwSMP_$Q@uA`%2gVZfCc&RpLZ|hJON_ z1RB6irPOl2bG3xf9phFi3rN8W|DXD{fEzgyVrZLziUJ0}n~a16YXPY>-)8Xh{uXdc zDRsQx!5Ac13COJZyvO1l;3@EK&lol|g?9=H0FM&l7`I|RpNC~9`)B$f2N~raz4@o9#MSe7sd%55&^5^H$+|F6=PKP=`=;;KCndp|NC`-*NiDV=$RLh z2f#&oC5+{o&t|LiwS*;-U6?8Rv0L-m^sIfo94?V;JkE}5*{Aalm-x!!G-zjo00000 LNkvXXu0mjfx$3Tz literal 0 HcmV?d00001 diff --git a/resources/buttons/pinDisabledLight.svg b/resources/buttons/pinDisabledLight.svg new file mode 100644 index 000000000..eb086e434 --- /dev/null +++ b/resources/buttons/pinDisabledLight.svg @@ -0,0 +1,23 @@ + + + + diff --git a/resources/buttons/pinEnabled.png b/resources/buttons/pinEnabled.png new file mode 100644 index 0000000000000000000000000000000000000000..5a113da1c0aff52c70b27b291cfd4e42d0029f23 GIT binary patch literal 1233 zcmV;?1TOoDP)#YeV3(z z5QGHMMHO9$h!aML4DztN@%B6)U%xRMZQ0j=Rx z^d&@AK>P42dJ-Zjz&X5%a|w|XpbxL&OhTjtxQAD1UqWOAcn7c2o`gsU@E%^JwS>?G z_y@1jTtcV<{D)U*EFm-j!NIH4mk^48;NexyN(fv)cJihCtz&`RsmJ#`B#82;VVf9_-Y^mPB$|cLfJQt_7cdLrt$C2=KHq6zE)%N z!bOxjIZ=4?Dmg&@)Dn;}O=EZG(YGYtp|IF$ybXyHi0jnV*#1|%B>>sY|<2QybmT&G0pjkcTeo6lip@n|p$XzT-G zBF7@(pCL4&%6{gP;>E1`(J{!4rx&*ku|Efy_xAaGO}XrQi?h}6faXqc>3HBS?b1?* zNH|&Zg_*_w2ajqmYL{|{NN5TVxI7C)TtZ8Dz=Ok3L?yHZ4|sGMjF^PB;Q6C zoAiP=egxqc~!w7T_nmN*EH_5)cHuO0yj!!5C;;I`cM4O94T`tE3N8tA>Xq>Ngt+GNnABboEsUN{MhUH0f6=X zXDABrLFG&ZoF{moM!(~+1Asx{12owW5Xb@ib!c>|Ys$Vlv45_ZTXVhEe*(TmAXanT zqHtrlaOu5;{r#?k2TtC6Iy-QWus;=``i~yFa?RJKQu^uaz+15hAO%DxNNd~IUm v + + + diff --git a/resources/resources_autogenerated.qrc b/resources/resources_autogenerated.qrc index c7a02e624..4d84f4fd1 100644 --- a/resources/resources_autogenerated.qrc +++ b/resources/resources_autogenerated.qrc @@ -38,6 +38,12 @@ buttons/modModeDisabled2.png buttons/modModeEnabled.png buttons/modModeEnabled2.png + buttons/pinDisabledDark.png + buttons/pinDisabledDark.svg + buttons/pinDisabledLight.png + buttons/pinDisabledLight.svg + buttons/pinEnabled.png + buttons/pinEnabled.svg buttons/replyDark.png buttons/replyDark.svg buttons/replyThreadDark.png diff --git a/src/autogenerated/ResourcesAutogen.cpp b/src/autogenerated/ResourcesAutogen.cpp index 671376e6a..d5a718aff 100644 --- a/src/autogenerated/ResourcesAutogen.cpp +++ b/src/autogenerated/ResourcesAutogen.cpp @@ -34,6 +34,9 @@ Resources2::Resources2() this->buttons.modModeDisabled2 = QPixmap(":/buttons/modModeDisabled2.png"); this->buttons.modModeEnabled = QPixmap(":/buttons/modModeEnabled.png"); this->buttons.modModeEnabled2 = QPixmap(":/buttons/modModeEnabled2.png"); + this->buttons.pinDisabledDark = QPixmap(":/buttons/pinDisabledDark.png"); + this->buttons.pinDisabledLight = QPixmap(":/buttons/pinDisabledLight.png"); + this->buttons.pinEnabled = QPixmap(":/buttons/pinEnabled.png"); this->buttons.replyDark = QPixmap(":/buttons/replyDark.png"); this->buttons.replyThreadDark = QPixmap(":/buttons/replyThreadDark.png"); this->buttons.search = QPixmap(":/buttons/search.png"); diff --git a/src/autogenerated/ResourcesAutogen.hpp b/src/autogenerated/ResourcesAutogen.hpp index 218d59539..200a3fbd7 100644 --- a/src/autogenerated/ResourcesAutogen.hpp +++ b/src/autogenerated/ResourcesAutogen.hpp @@ -41,6 +41,9 @@ public: QPixmap modModeDisabled2; QPixmap modModeEnabled; QPixmap modModeEnabled2; + QPixmap pinDisabledDark; + QPixmap pinDisabledLight; + QPixmap pinEnabled; QPixmap replyDark; QPixmap replyThreadDark; QPixmap search; diff --git a/src/singletons/Theme.cpp b/src/singletons/Theme.cpp index 635f31b0b..e13df1cd2 100644 --- a/src/singletons/Theme.cpp +++ b/src/singletons/Theme.cpp @@ -278,10 +278,12 @@ void Theme::actuallyUpdate(double hue, double multiplier) 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; } } diff --git a/src/singletons/Theme.hpp b/src/singletons/Theme.hpp index ea49457e9..bc8b4a10d 100644 --- a/src/singletons/Theme.hpp +++ b/src/singletons/Theme.hpp @@ -129,6 +129,7 @@ public: struct { QPixmap copy; + QPixmap pin; } buttons; void normalizeColor(QColor &color); diff --git a/src/widgets/dialogs/UserInfoPopup.cpp b/src/widgets/dialogs/UserInfoPopup.cpp index 478a0eead..2dd28e294 100644 --- a/src/widgets/dialogs/UserInfoPopup.cpp +++ b/src/widgets/dialogs/UserInfoPopup.cpp @@ -134,11 +134,13 @@ UserInfoPopup::UserInfoPopup(bool closeAutomatically, QWidget *parent, Split *split) : DraggablePopup(closeAutomatically, parent) , split_(split) + , closeAutomatically_(closeAutomatically) { assert(split != nullptr && "split being nullptr causes lots of bugs down the road"); this->setWindowTitle("Usercard"); this->setStayInScreenRect(true); + this->updateFocusLoss(); HotkeyController::HotkeyMap actions{ {"delete", @@ -349,6 +351,22 @@ UserInfoPopup::UserInfoPopup(bool closeAutomatically, QWidget *parent, this->ui_.localizedNameLabel->setVisible(false); this->ui_.localizedNameCopyButton->setVisible(false); + + // button to pin the window (only if we close automatically) + if (this->closeAutomatically_) + { + this->ui_.pinButton = box.emplace