From 7ccf60111d5e8ba54c070fe09d926bdff5290da5 Mon Sep 17 00:00:00 2001 From: pajlada Date: Sun, 5 Jun 2022 17:40:57 +0200 Subject: [PATCH] Overhaul highlight system (#3399) Checks have been moved into a Controller allowing for easier tests. --- CHANGELOG.md | 1 + benchmarks/CMakeLists.txt | 1 + benchmarks/src/Highlights.cpp | 83 ++++ chatterino.pro | 2 + src/Application.cpp | 2 + src/Application.hpp | 2 + src/BaseSettings.cpp | 2 +- src/BaseSettings.hpp | 5 +- src/CMakeLists.txt | 2 + src/common/QLogging.cpp | 1 + src/common/QLogging.hpp | 1 + .../highlights/HighlightController.cpp | 358 ++++++++++++++ .../highlights/HighlightController.hpp | 159 ++++++ src/messages/MessageBuilder.hpp | 1 + src/messages/SharedMessageBuilder.cpp | 262 +--------- src/providers/twitch/IrcMessageHandler.cpp | 1 + src/providers/twitch/TwitchAccount.cpp | 2 +- src/widgets/helper/ScrollbarHighlight.cpp | 2 +- tests/CMakeLists.txt | 1 + tests/src/HighlightController.cpp | 464 ++++++++++++++++++ 20 files changed, 1112 insertions(+), 240 deletions(-) create mode 100644 benchmarks/src/Highlights.cpp create mode 100644 src/controllers/highlights/HighlightController.cpp create mode 100644 src/controllers/highlights/HighlightController.hpp create mode 100644 tests/src/HighlightController.cpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 137d231f3..ccd667591 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ - Bugfix: Fixed automod queue pubsub topic persisting after user change. (#3718) - Bugfix: Fixed viewer list not closing after pressing escape key. (#3734) - Bugfix: Fixed links with no thumbnail having previous link's thumbnail. (#3720) +- Dev: Overhaul highlight system by moving all checks into a Controller allowing for easier tests. (#3399) - 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) diff --git a/benchmarks/CMakeLists.txt b/benchmarks/CMakeLists.txt index b9fd4a9f9..e8a8d0af7 100644 --- a/benchmarks/CMakeLists.txt +++ b/benchmarks/CMakeLists.txt @@ -3,6 +3,7 @@ project(chatterino-benchmark) set(benchmark_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/main.cpp ${CMAKE_CURRENT_LIST_DIR}/src/Emojis.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/Highlights.cpp ${CMAKE_CURRENT_LIST_DIR}/src/FormatTime.cpp # Add your new file above this line! ) diff --git a/benchmarks/src/Highlights.cpp b/benchmarks/src/Highlights.cpp new file mode 100644 index 000000000..80061b468 --- /dev/null +++ b/benchmarks/src/Highlights.cpp @@ -0,0 +1,83 @@ +#include "Application.hpp" +#include "BaseSettings.hpp" +#include "common/Channel.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/highlights/HighlightPhrase.hpp" +#include "messages/Message.hpp" +#include "messages/SharedMessageBuilder.hpp" +#include "util/Helpers.hpp" + +#include +#include +#include + +using namespace chatterino; + +class BenchmarkMessageBuilder : public SharedMessageBuilder +{ +public: + explicit BenchmarkMessageBuilder( + Channel *_channel, const Communi::IrcPrivateMessage *_ircMessage, + const MessageParseArgs &_args) + : SharedMessageBuilder(_channel, _ircMessage, _args) + { + } + virtual MessagePtr build() + { + // PARSE + this->parse(); + this->usernameColor_ = getRandomColor(this->ircMessage->nick()); + + // words + // this->addWords(this->originalMessage_.split(' ')); + + this->message().messageText = this->originalMessage_; + this->message().searchText = this->message().localizedName + " " + + this->userName + ": " + + this->originalMessage_; + return nullptr; + } + + void bench() + { + this->parseHighlights(); + } +}; + +class MockApplication : BaseApplication +{ + AccountController *const getAccounts() override + { + return &this->accounts; + } + + AccountController accounts; + // TODO: Figure this out +}; + +static void BM_HighlightTest(benchmark::State &state) +{ + MockApplication mockApplication; + Settings settings("/tmp/c2-mock"); + + std::string message = + R"(@badge-info=subscriber/34;badges=moderator/1,subscriber/24;color=#FF0000;display-name=테스트계정420;emotes=41:6-13,15-22;flags=;id=a3196c7e-be4c-4b49-9c5a-8b8302b50c2a;mod=1;room-id=11148817;subscriber=1;tmi-sent-ts=1590922213730;turbo=0;user-id=117166826;user-type=mod :testaccount_420!testaccount_420@testaccount_420.tmi.twitch.tv PRIVMSG #pajlada :-tags Kreygasm,Kreygasm (no space))"; + auto ircMessage = Communi::IrcMessage::fromData(message.c_str(), nullptr); + auto privMsg = dynamic_cast(ircMessage); + assert(privMsg != nullptr); + MessageParseArgs args; + auto emptyChannel = Channel::getEmpty(); + + for (auto _ : state) + { + state.PauseTiming(); + BenchmarkMessageBuilder b(emptyChannel.get(), privMsg, args); + + b.build(); + state.ResumeTiming(); + + b.bench(); + } +} + +BENCHMARK(BM_HighlightTest); diff --git a/chatterino.pro b/chatterino.pro index 03380b719..0d14dbc5c 100644 --- a/chatterino.pro +++ b/chatterino.pro @@ -157,6 +157,7 @@ SOURCES += \ src/controllers/highlights/BadgeHighlightModel.cpp \ src/controllers/highlights/HighlightBadge.cpp \ src/controllers/highlights/HighlightBlacklistModel.cpp \ + src/controllers/highlights/HighlightController.cpp \ src/controllers/highlights/HighlightModel.cpp \ src/controllers/highlights/HighlightPhrase.cpp \ src/controllers/highlights/UserHighlightModel.cpp \ @@ -401,6 +402,7 @@ HEADERS += \ src/controllers/highlights/HighlightBadge.hpp \ src/controllers/highlights/HighlightBlacklistModel.hpp \ src/controllers/highlights/HighlightBlacklistUser.hpp \ + src/controllers/highlights/HighlightController.hpp \ src/controllers/highlights/HighlightModel.hpp \ src/controllers/highlights/HighlightPhrase.hpp \ src/controllers/highlights/UserHighlightModel.hpp \ diff --git a/src/Application.cpp b/src/Application.cpp index 47d56f3c4..f3889a712 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -7,6 +7,7 @@ #include "common/Version.hpp" #include "controllers/accounts/AccountController.hpp" #include "controllers/commands/CommandController.hpp" +#include "controllers/highlights/HighlightController.hpp" #include "controllers/hotkeys/HotkeyController.hpp" #include "controllers/ignores/IgnoreController.hpp" #include "controllers/notifications/NotificationController.hpp" @@ -67,6 +68,7 @@ Application::Application(Settings &_settings, Paths &_paths) , commands(&this->emplace()) , notifications(&this->emplace()) + , highlights(&this->emplace()) , twitch(&this->emplace()) , chatterinoBadges(&this->emplace()) , ffzBadges(&this->emplace()) diff --git a/src/Application.hpp b/src/Application.hpp index f0dca9874..2260dbe28 100644 --- a/src/Application.hpp +++ b/src/Application.hpp @@ -15,6 +15,7 @@ class PubSub; class CommandController; class AccountController; class NotificationController; +class HighlightController; class HotkeyController; class Theme; @@ -80,6 +81,7 @@ public: CommandController *const commands{}; NotificationController *const notifications{}; + HighlightController *const highlights{}; TwitchIrcServer *const twitch{}; ChatterinoBadges *const chatterinoBadges{}; FfzBadges *const ffzBadges{}; diff --git a/src/BaseSettings.cpp b/src/BaseSettings.cpp index 4343e6306..141d66997 100644 --- a/src/BaseSettings.cpp +++ b/src/BaseSettings.cpp @@ -112,7 +112,7 @@ Settings *getSettings() static_assert(std::is_same_v, "`AB_SETTINGS_CLASS` must be the same as `Settings`"); - assert(AB_SETTINGS_CLASS::instance); + assert(AB_SETTINGS_CLASS::instance != nullptr); return AB_SETTINGS_CLASS::instance; } diff --git a/src/BaseSettings.hpp b/src/BaseSettings.hpp index 0e1ff58a2..a747b67c6 100644 --- a/src/BaseSettings.hpp +++ b/src/BaseSettings.hpp @@ -1,12 +1,13 @@ #ifndef AB_SETTINGS_H #define AB_SETTINGS_H +#include "common/ChatterinoSetting.hpp" + #include #include -#include #include -#include "common/ChatterinoSetting.hpp" +#include #ifdef AB_CUSTOM_SETTINGS # define AB_SETTINGS_CLASS ABSettings diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 32f38dc1c..5a0ab8cf2 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -81,6 +81,8 @@ set(SOURCE_FILES controllers/highlights/HighlightBadge.hpp controllers/highlights/HighlightBlacklistModel.cpp controllers/highlights/HighlightBlacklistModel.hpp + controllers/highlights/HighlightController.cpp + controllers/highlights/HighlightController.hpp controllers/highlights/HighlightModel.cpp controllers/highlights/HighlightModel.hpp controllers/highlights/HighlightPhrase.cpp diff --git a/src/common/QLogging.cpp b/src/common/QLogging.cpp index 0ebb74cd5..302883c6e 100644 --- a/src/common/QLogging.cpp +++ b/src/common/QLogging.cpp @@ -39,3 +39,4 @@ Q_LOGGING_CATEGORY(chatterinoWebsocket, "chatterino.websocket", logThreshold); Q_LOGGING_CATEGORY(chatterinoWidget, "chatterino.widget", logThreshold); Q_LOGGING_CATEGORY(chatterinoWindowmanager, "chatterino.windowmanager", logThreshold); +Q_LOGGING_CATEGORY(chatterinoHighlights, "chatterino.highlights", logThreshold); diff --git a/src/common/QLogging.hpp b/src/common/QLogging.hpp index 3c7b90937..ba18b84d7 100644 --- a/src/common/QLogging.hpp +++ b/src/common/QLogging.hpp @@ -30,3 +30,4 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoUpdate); Q_DECLARE_LOGGING_CATEGORY(chatterinoWebsocket); Q_DECLARE_LOGGING_CATEGORY(chatterinoWidget); Q_DECLARE_LOGGING_CATEGORY(chatterinoWindowmanager); +Q_DECLARE_LOGGING_CATEGORY(chatterinoHighlights); diff --git a/src/controllers/highlights/HighlightController.cpp b/src/controllers/highlights/HighlightController.cpp new file mode 100644 index 000000000..379a2b604 --- /dev/null +++ b/src/controllers/highlights/HighlightController.cpp @@ -0,0 +1,358 @@ +#include "controllers/highlights/HighlightController.hpp" + +#include "common/QLogging.hpp" + +namespace { + +using namespace chatterino; + +auto highlightPhraseCheck(HighlightPhrase highlight) -> HighlightCheck +{ + return HighlightCheck{ + [highlight]( + const auto &args, const auto &badges, const auto &senderName, + const auto &originalMessage) -> boost::optional { + (void)args; // unused + (void)badges; // unused + (void)originalMessage; // unused + + if (!highlight.isMatch(originalMessage)) + { + return boost::none; + } + + boost::optional highlightSoundUrl; + if (highlight.hasCustomSound()) + { + highlightSoundUrl = highlight.getSoundUrl(); + } + + return HighlightResult{ + highlight.hasAlert(), highlight.hasSound(), + highlightSoundUrl, highlight.getColor(), + highlight.showInMentions(), + }; + }}; +} + +void rebuildSubscriptionHighlights(Settings &settings, + std::vector &checks) +{ + if (settings.enableSubHighlight) + { + auto highlightSound = settings.enableSubHighlightSound.getValue(); + auto highlightAlert = settings.enableSubHighlightTaskbar.getValue(); + auto highlightSoundUrlValue = + settings.whisperHighlightSoundUrl.getValue(); + boost::optional highlightSoundUrl; + if (!highlightSoundUrlValue.isEmpty()) + { + highlightSoundUrl = highlightSoundUrlValue; + } + + // The custom sub highlight color is handled in ColorProvider + + checks.emplace_back(HighlightCheck{ + [=](const auto &args, const auto &badges, const auto &senderName, + const auto &originalMessage) + -> boost::optional { + (void)badges; // unused + (void)senderName; // unused + (void)originalMessage; // unused + if (!args.isSubscriptionMessage) + { + return boost::none; + } + + auto highlightColor = + ColorProvider::instance().color(ColorType::Subscription); + + return HighlightResult{ + highlightAlert, // alert + highlightSound, // playSound + highlightSoundUrl, // customSoundUrl + highlightColor, // color + false, // showInMentions + }; + }}); + } +} + +void rebuildWhisperHighlights(Settings &settings, + std::vector &checks) +{ + if (settings.enableWhisperHighlight) + { + auto highlightSound = settings.enableWhisperHighlightSound.getValue(); + auto highlightAlert = settings.enableWhisperHighlightTaskbar.getValue(); + auto highlightSoundUrlValue = + settings.whisperHighlightSoundUrl.getValue(); + boost::optional highlightSoundUrl; + if (!highlightSoundUrlValue.isEmpty()) + { + highlightSoundUrl = highlightSoundUrlValue; + } + + // The custom whisper highlight color is handled in ColorProvider + + checks.emplace_back(HighlightCheck{ + [=](const auto &args, const auto &badges, const auto &senderName, + const auto &originalMessage) + -> boost::optional { + (void)badges; // unused + (void)senderName; // unused + (void)originalMessage; // unused + if (!args.isReceivedWhisper) + { + return boost::none; + } + + return HighlightResult{ + highlightAlert, + highlightSound, + highlightSoundUrl, + ColorProvider::instance().color(ColorType::Whisper), + false, + }; + }}); + } +} + +void rebuildMessageHighlights(Settings &settings, + std::vector &checks) +{ + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); + QString currentUsername = currentUser->getUserName(); + + if (settings.enableSelfHighlight && !currentUsername.isEmpty()) + { + HighlightPhrase highlight( + currentUsername, settings.showSelfHighlightInMentions, + settings.enableSelfHighlightTaskbar, + settings.enableSelfHighlightSound, false, false, + settings.selfHighlightSoundUrl.getValue(), + ColorProvider::instance().color(ColorType::SelfHighlight)); + + checks.emplace_back(highlightPhraseCheck(highlight)); + } + + auto messageHighlights = settings.highlightedMessages.readOnly(); + for (const auto &highlight : *messageHighlights) + { + checks.emplace_back(highlightPhraseCheck(highlight)); + } +} + +void rebuildUserHighlights(Settings &settings, + std::vector &checks) +{ + auto userHighlights = settings.highlightedUsers.readOnly(); + + for (const auto &highlight : *userHighlights) + { + checks.emplace_back(HighlightCheck{ + [highlight](const auto &args, const auto &badges, + const auto &senderName, const auto &originalMessage) + -> boost::optional { + (void)args; // unused + (void)badges; // unused + (void)originalMessage; // unused + + if (!highlight.isMatch(senderName)) + { + return boost::none; + } + + boost::optional highlightSoundUrl; + if (highlight.hasCustomSound()) + { + highlightSoundUrl = highlight.getSoundUrl(); + } + + return HighlightResult{ + highlight.hasAlert(), + highlight.hasSound(), + highlightSoundUrl, + highlight.getColor(), + false, // showInMentions + }; + }}); + } +} + +void rebuildBadgeHighlights(Settings &settings, + std::vector &checks) +{ + auto badgeHighlights = settings.highlightedBadges.readOnly(); + + for (const auto &highlight : *badgeHighlights) + { + checks.emplace_back(HighlightCheck{ + [highlight](const auto &args, const auto &badges, + const auto &senderName, const auto &originalMessage) + -> boost::optional { + (void)args; // unused + (void)senderName; // unused + (void)originalMessage; // unused + for (const Badge &badge : badges) + { + if (highlight.isMatch(badge)) + { + boost::optional highlightSoundUrl; + if (highlight.hasCustomSound()) + { + highlightSoundUrl = highlight.getSoundUrl(); + } + + return HighlightResult{ + highlight.hasAlert(), + highlight.hasSound(), + highlightSoundUrl, + highlight.getColor(), + false, // showInMentions + }; + } + } + + return boost::none; + }}); + } +} + +} // namespace + +namespace chatterino { + +void HighlightController::initialize(Settings &settings, Paths & /*paths*/) +{ + this->rebuildListener_.addSetting(settings.enableWhisperHighlight); + this->rebuildListener_.addSetting(settings.enableWhisperHighlightSound); + this->rebuildListener_.addSetting(settings.enableWhisperHighlightTaskbar); + this->rebuildListener_.addSetting(settings.whisperHighlightSoundUrl); + this->rebuildListener_.addSetting(settings.whisperHighlightColor); + this->rebuildListener_.addSetting(settings.enableSelfHighlight); + this->rebuildListener_.addSetting(settings.enableSubHighlight); + this->rebuildListener_.addSetting(settings.enableSubHighlightSound); + this->rebuildListener_.addSetting(settings.enableSubHighlightTaskbar); + + this->rebuildListener_.setCB([this, &settings] { + qCDebug(chatterinoHighlights) + << "Rebuild checks because a setting changed"; + this->rebuildChecks(settings); + }); + + this->signalHolder_.managedConnect( + getCSettings().highlightedBadges.delayedItemsChanged, + [this, &settings] { + qCDebug(chatterinoHighlights) + << "Rebuild checks because highlight badges changed"; + this->rebuildChecks(settings); + }); + + this->signalHolder_.managedConnect( + getCSettings().highlightedUsers.delayedItemsChanged, [this, &settings] { + qCDebug(chatterinoHighlights) + << "Rebuild checks because highlight users changed"; + this->rebuildChecks(settings); + }); + + this->signalHolder_.managedConnect( + getCSettings().highlightedMessages.delayedItemsChanged, + [this, &settings] { + qCDebug(chatterinoHighlights) + << "Rebuild checks because highlight messages changed"; + this->rebuildChecks(settings); + }); + + getIApp()->getAccounts()->twitch.currentUserChanged.connect( + [this, &settings] { + qCDebug(chatterinoHighlights) + << "Rebuild checks because user swapped accounts"; + this->rebuildChecks(settings); + }); + + this->rebuildChecks(settings); +} + +void HighlightController::rebuildChecks(Settings &settings) +{ + // Access checks for modification + auto checks = this->checks_.access(); + checks->clear(); + + // CURRENT ORDER: + // Subscription -> Whisper -> User -> Message -> Badge + + rebuildSubscriptionHighlights(settings, *checks); + + rebuildWhisperHighlights(settings, *checks); + + rebuildUserHighlights(settings, *checks); + + rebuildMessageHighlights(settings, *checks); + + rebuildBadgeHighlights(settings, *checks); +} + +std::pair HighlightController::check( + const MessageParseArgs &args, const std::vector &badges, + const QString &senderName, const QString &originalMessage) const +{ + bool highlighted = false; + auto result = HighlightResult::emptyResult(); + + // Access for checking + const auto checks = this->checks_.accessConst(); + + for (const auto &check : *checks) + { + if (auto checkResult = + check.cb(args, badges, senderName, originalMessage); + checkResult) + { + highlighted = true; + + if (checkResult->alert) + { + if (!result.alert) + { + result.alert = checkResult->alert; + } + } + + if (checkResult->playSound) + { + if (!result.playSound) + { + result.playSound = checkResult->playSound; + } + } + + if (checkResult->customSoundUrl) + { + if (!result.customSoundUrl) + { + result.customSoundUrl = checkResult->customSoundUrl; + } + } + + if (checkResult->color) + { + if (!result.color) + { + result.color = checkResult->color; + } + } + + if (result.full()) + { + // The final highlight result does not have room to add any more parameters, early out + break; + } + } + } + + return {highlighted, result}; +} + +} // namespace chatterino diff --git a/src/controllers/highlights/HighlightController.hpp b/src/controllers/highlights/HighlightController.hpp new file mode 100644 index 000000000..7d761ce1b --- /dev/null +++ b/src/controllers/highlights/HighlightController.hpp @@ -0,0 +1,159 @@ +#pragma once + +#include "common/Singleton.hpp" +#include "common/UniqueAccess.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/TwitchBadge.hpp" +#include "singletons/Paths.hpp" +#include "singletons/Settings.hpp" + +#include +#include +#include + +#include +#include +#include + +namespace chatterino { + +struct HighlightResult { + HighlightResult(bool _alert, bool _playSound, + boost::optional _customSoundUrl, + std::shared_ptr _color, bool _showInMentions) + : alert(_alert) + , playSound(_playSound) + , customSoundUrl(std::move(_customSoundUrl)) + , color(std::move(_color)) + , showInMentions(_showInMentions) + { + } + + /** + * @brief Construct an empty HighlightResult with all side-effects disabled + **/ + static HighlightResult emptyResult() + { + return { + false, false, boost::none, nullptr, false, + }; + } + + /** + * @brief true if highlight should trigger the taskbar to flash + **/ + bool alert{false}; + + /** + * @brief true if highlight should play a notification sound + **/ + bool playSound{false}; + + /** + * @brief Can be set to a different sound that should play when this highlight is activated + * + * May only be set if playSound is true + **/ + boost::optional customSoundUrl{}; + + /** + * @brief set if highlight should set a background color + **/ + std::shared_ptr color{}; + + /** + * @brief true if highlight should show message in the /mentions split + **/ + bool showInMentions{false}; + + bool operator==(const HighlightResult &other) const + { + if (this->alert != other.alert) + { + return false; + } + if (this->playSound != other.playSound) + { + return false; + } + if (this->customSoundUrl != other.customSoundUrl) + { + return false; + } + + if (this->color && other.color) + { + if (*this->color != *other.color) + { + return false; + } + } + + if (this->showInMentions != other.showInMentions) + { + return false; + } + + return true; + } + + bool operator!=(const HighlightResult &other) const + { + return !(*this == other); + } + + /** + * @brief Returns true if no side-effect has been enabled + **/ + [[nodiscard]] bool empty() const + { + return !this->alert && !this->playSound && + !this->customSoundUrl.has_value() && !this->color && + !this->showInMentions; + } + + /** + * @brief Returns true if all side-effects have been enabled + **/ + [[nodiscard]] bool full() const + { + return this->alert && this->playSound && + this->customSoundUrl.has_value() && this->color && + this->showInMentions; + } +}; + +struct HighlightCheck { + using Checker = std::function( + const MessageParseArgs &args, const std::vector &badges, + const QString &senderName, const QString &originalMessage)>; + Checker cb; +}; + +class HighlightController final : public Singleton +{ +public: + void initialize(Settings &settings, Paths &paths) override; + + /** + * @brief Checks the given message parameters if it matches our internal checks, and returns a result + **/ + [[nodiscard]] std::pair check( + const MessageParseArgs &args, const std::vector &badges, + const QString &senderName, const QString &originalMessage) const; + +private: + /** + * @brief rebuildChecks is called whenever some outside variable has been changed and our checks need to be updated + * + * rebuilds are always full, so if something changes we throw away all checks and build them all up from scratch + **/ + void rebuildChecks(Settings &settings); + + UniqueAccess> checks_; + + pajlada::SettingListener rebuildListener_; + pajlada::Signals::SignalHolder signalHolder_; +}; + +} // namespace chatterino diff --git a/src/messages/MessageBuilder.hpp b/src/messages/MessageBuilder.hpp index 365049d40..ebbb4943f 100644 --- a/src/messages/MessageBuilder.hpp +++ b/src/messages/MessageBuilder.hpp @@ -34,6 +34,7 @@ struct MessageParseArgs { bool isSentWhisper = false; bool trimSubscriberUsername = false; bool isStaffOrBroadcaster = false; + bool isSubscriptionMessage = false; QString channelPointRewardId = ""; }; diff --git a/src/messages/SharedMessageBuilder.cpp b/src/messages/SharedMessageBuilder.cpp index 88f140953..2f96e8571 100644 --- a/src/messages/SharedMessageBuilder.cpp +++ b/src/messages/SharedMessageBuilder.cpp @@ -2,6 +2,7 @@ #include "Application.hpp" #include "common/QLogging.hpp" +#include "controllers/highlights/HighlightController.hpp" #include "controllers/ignores/IgnoreController.hpp" #include "controllers/ignores/IgnorePhrase.hpp" #include "messages/MessageElement.hpp" @@ -140,259 +141,50 @@ void SharedMessageBuilder::parseUsername() void SharedMessageBuilder::parseHighlights() { - auto app = getApp(); - if (getCSettings().isBlacklistedUser(this->ircMessage->nick())) { // Do nothing. We ignore highlights from this user. return; } - // Highlight because it's a whisper - if (this->args.isReceivedWhisper && getSettings()->enableWhisperHighlight) + auto currentUser = getIApp()->getAccounts()->twitch.getCurrent(); + if (this->ircMessage->nick() == currentUser->getUserName()) { - if (getSettings()->enableWhisperHighlightTaskbar) - { - this->highlightAlert_ = true; - } - - if (getSettings()->enableWhisperHighlightSound) - { - this->highlightSound_ = true; - - // Use custom sound if set, otherwise use fallback - if (!getSettings()->whisperHighlightSoundUrl.getValue().isEmpty()) - { - this->highlightSoundUrl_ = - QUrl(getSettings()->whisperHighlightSoundUrl.getValue()); - } - else - { - this->highlightSoundUrl_ = getFallbackHighlightSound(); - } - } - - this->message().highlightColor = - ColorProvider::instance().color(ColorType::Whisper); - - /* - * Do _NOT_ return yet, we might want to apply phrase/user name - * highlights (which override whisper color/sound). - */ - } - - // Highlight because of sender - auto userHighlights = getCSettings().highlightedUsers.readOnly(); - for (const HighlightPhrase &userHighlight : *userHighlights) - { - if (!userHighlight.isMatch(this->ircMessage->nick())) - { - continue; - } - qCDebug(chatterinoMessage) - << "Highlight because user" << this->ircMessage->nick() - << "sent a message"; - - this->message().flags.set(MessageFlag::Highlighted); - if (!(this->message().flags.has(MessageFlag::Subscription) && - getSettings()->enableSubHighlight)) - { - this->message().highlightColor = userHighlight.getColor(); - } - - if (userHighlight.showInMentions()) - { - this->message().flags.set(MessageFlag::ShowInMentions); - } - - if (userHighlight.hasAlert()) - { - this->highlightAlert_ = true; - } - - if (userHighlight.hasSound()) - { - this->highlightSound_ = true; - // Use custom sound if set, otherwise use the fallback sound - if (userHighlight.hasCustomSound()) - { - this->highlightSoundUrl_ = userHighlight.getSoundUrl(); - } - else - { - this->highlightSoundUrl_ = getFallbackHighlightSound(); - } - } - - if (this->highlightAlert_ && this->highlightSound_) - { - /* - * User name highlights "beat" highlight phrases: If a message has - * all attributes (color, taskbar flashing, sound) set, highlight - * phrases will not be checked. - */ - return; - } - } - - auto currentUser = app->accounts->twitch.getCurrent(); - QString currentUsername = currentUser->getUserName(); - - if (this->ircMessage->nick() == currentUsername) - { - // Do nothing. Highlights cannot be triggered by yourself + // Do nothing. We ignore any potential highlights from the logged in user return; } - // Highlight because it's a subscription - if (this->message().flags.has(MessageFlag::Subscription) && - getSettings()->enableSubHighlight) + auto badges = SharedMessageBuilder::parseBadgeTag(this->tags); + auto [highlighted, highlightResult] = getApp()->highlights->check( + this->args, badges, this->ircMessage->nick(), this->originalMessage_); + + if (!highlighted) { - if (getSettings()->enableSubHighlightTaskbar) - { - this->highlightAlert_ = true; - } - - if (getSettings()->enableSubHighlightSound) - { - this->highlightSound_ = true; - - // Use custom sound if set, otherwise use fallback - if (!getSettings()->subHighlightSoundUrl.getValue().isEmpty()) - { - this->highlightSoundUrl_ = - QUrl(getSettings()->subHighlightSoundUrl.getValue()); - } - else - { - this->highlightSoundUrl_ = getFallbackHighlightSound(); - } - } - - this->message().flags.set(MessageFlag::Highlighted); - this->message().highlightColor = - ColorProvider::instance().color(ColorType::Subscription); + return; } - // TODO: This vector should only be rebuilt upon highlights being changed - // fourtf: should be implemented in the HighlightsController - std::vector activeHighlights = - getSettings()->highlightedMessages.cloneVector(); + // This message triggered one or more highlights, act upon the highlight result - if (!currentUser->isAnon() && getSettings()->enableSelfHighlight && - currentUsername.size() > 0) + this->message().flags.set(MessageFlag::Highlighted); + + this->highlightAlert_ = highlightResult.alert; + + this->highlightSound_ = highlightResult.playSound; + + this->message().highlightColor = highlightResult.color; + + if (highlightResult.customSoundUrl) { - HighlightPhrase selfHighlight( - currentUsername, getSettings()->showSelfHighlightInMentions, - getSettings()->enableSelfHighlightTaskbar, - getSettings()->enableSelfHighlightSound, false, false, - getSettings()->selfHighlightSoundUrl.getValue(), - ColorProvider::instance().color(ColorType::SelfHighlight)); - activeHighlights.emplace_back(std::move(selfHighlight)); + this->highlightSoundUrl_ = highlightResult.customSoundUrl.get(); + } + else + { + this->highlightSoundUrl_ = getFallbackHighlightSound(); } - // Highlight because of message - for (const HighlightPhrase &highlight : activeHighlights) + if (highlightResult.showInMentions) { - if (!highlight.isMatch(this->originalMessage_)) - { - continue; - } - - this->message().flags.set(MessageFlag::Highlighted); - if (!(this->message().flags.has(MessageFlag::Subscription) && - getSettings()->enableSubHighlight)) - { - this->message().highlightColor = highlight.getColor(); - } - - if (highlight.showInMentions()) - { - this->message().flags.set(MessageFlag::ShowInMentions); - } - - if (highlight.hasAlert()) - { - this->highlightAlert_ = true; - } - - // Only set highlightSound_ if it hasn't been set by username - // highlights already. - if (highlight.hasSound() && !this->highlightSound_) - { - this->highlightSound_ = true; - - // Use custom sound if set, otherwise use fallback sound - if (highlight.hasCustomSound()) - { - this->highlightSoundUrl_ = highlight.getSoundUrl(); - } - else - { - this->highlightSoundUrl_ = getFallbackHighlightSound(); - } - } - - if (this->highlightAlert_ && this->highlightSound_) - { - /* - * Break once no further attributes (taskbar, sound) can be - * applied. - */ - break; - } - } - - // Highlight because of badge - auto badges = this->parseBadgeTag(this->tags); - auto badgeHighlights = getCSettings().highlightedBadges.readOnly(); - bool badgeHighlightSet = false; - for (const HighlightBadge &highlight : *badgeHighlights) - { - for (const Badge &badge : badges) - { - if (!highlight.isMatch(badge)) - { - continue; - } - - if (!badgeHighlightSet) - { - this->message().flags.set(MessageFlag::Highlighted); - if (!(this->message().flags.has(MessageFlag::Subscription) && - getSettings()->enableSubHighlight)) - { - this->message().highlightColor = highlight.getColor(); - } - - badgeHighlightSet = true; - } - - if (highlight.hasAlert()) - { - this->highlightAlert_ = true; - } - - // Only set highlightSound_ if it hasn't been set by badge - // highlights already. - if (highlight.hasSound() && !this->highlightSound_) - { - this->highlightSound_ = true; - // Use custom sound if set, otherwise use fallback sound - this->highlightSoundUrl_ = highlight.hasCustomSound() - ? highlight.getSoundUrl() - : getFallbackHighlightSound(); - } - - if (this->highlightAlert_ && this->highlightSound_) - { - /* - * Break once no further attributes (taskbar, sound) can be - * applied. - */ - break; - } - } + this->message().flags.set(MessageFlag::ShowInMentions); } } diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp index d1a9d6406..5f26ca47c 100644 --- a/src/providers/twitch/IrcMessageHandler.cpp +++ b/src/providers/twitch/IrcMessageHandler.cpp @@ -264,6 +264,7 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *_message, MessageParseArgs args; if (isSub) { + args.isSubscriptionMessage = true; args.trimSubscriberUsername = true; } diff --git a/src/providers/twitch/TwitchAccount.cpp b/src/providers/twitch/TwitchAccount.cpp index 3d5c6d4df..31378d06f 100644 --- a/src/providers/twitch/TwitchAccount.cpp +++ b/src/providers/twitch/TwitchAccount.cpp @@ -125,7 +125,7 @@ bool TwitchAccount::isAnon() const void TwitchAccount::loadBlocks() { getHelix()->loadBlocks( - getApp()->accounts->twitch.getCurrent()->userId_, + getIApp()->getAccounts()->twitch.getCurrent()->userId_, [this](std::vector blocks) { auto ignores = this->ignores_.access(); auto userIds = this->ignoresUserIds_.access(); diff --git a/src/widgets/helper/ScrollbarHighlight.cpp b/src/widgets/helper/ScrollbarHighlight.cpp index f733b89d8..256e2c185 100644 --- a/src/widgets/helper/ScrollbarHighlight.cpp +++ b/src/widgets/helper/ScrollbarHighlight.cpp @@ -44,7 +44,7 @@ bool ScrollbarHighlight::isFirstMessageHighlight() const bool ScrollbarHighlight::isNull() const { - return this->style_ == None; + return this->style_ == None || !this->color_; } } // namespace chatterino diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e065dd3af..6528a07bc 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -18,6 +18,7 @@ set(test_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/IrcHelpers.cpp ${CMAKE_CURRENT_LIST_DIR}/src/TwitchPubSubClient.cpp ${CMAKE_CURRENT_LIST_DIR}/src/TwitchMessageBuilder.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/HighlightController.cpp ${CMAKE_CURRENT_LIST_DIR}/src/FormatTime.cpp # Add your new file above this line! ) diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp new file mode 100644 index 000000000..61ac23880 --- /dev/null +++ b/tests/src/HighlightController.cpp @@ -0,0 +1,464 @@ +#include "controllers/highlights/HighlightController.hpp" +#include "Application.hpp" +#include "BaseSettings.hpp" +#include "messages/MessageBuilder.hpp" // for MessageParseArgs +#include "providers/twitch/TwitchBadge.hpp" // for Badge +#include "providers/twitch/api/Helix.hpp" + +#include +#include +#include +#include +#include +#include + +using namespace chatterino; +using ::testing::Exactly; + +class MockApplication : IApplication +{ +public: + Theme *getThemes() override + { + return nullptr; + } + Fonts *getFonts() override + { + return nullptr; + } + Emotes *getEmotes() override + { + return nullptr; + } + AccountController *getAccounts() override + { + return &this->accounts; + } + 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; + } + TwitchIrcServer *getTwitch() override + { + return nullptr; + } + ChatterinoBadges *getChatterinoBadges() override + { + return nullptr; + } + FfzBadges *getFfzBadges() override + { + return nullptr; + } + + AccountController accounts; + // TODO: Figure this out +}; + +class MockHelix : public IHelix +{ +public: + MOCK_METHOD(void, fetchUsers, + (QStringList userIds, QStringList userLogins, + ResultCallback> successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, getUserByName, + (QString userName, ResultCallback successCallback, + HelixFailureCallback failureCallback), + (override)); + MOCK_METHOD(void, getUserById, + (QString userId, ResultCallback successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, fetchUsersFollows, + (QString fromId, QString toId, + ResultCallback successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, getUserFollowers, + (QString userId, + ResultCallback successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, fetchStreams, + (QStringList userIds, QStringList userLogins, + ResultCallback> successCallback, + HelixFailureCallback failureCallback, + std::function finallyCallback), + (override)); + + MOCK_METHOD(void, getStreamById, + (QString userId, + (ResultCallback successCallback), + HelixFailureCallback failureCallback, + std::function finallyCallback), + (override)); + + MOCK_METHOD(void, getStreamByName, + (QString userName, + (ResultCallback successCallback), + HelixFailureCallback failureCallback, + std::function finallyCallback), + (override)); + + MOCK_METHOD(void, fetchGames, + (QStringList gameIds, QStringList gameNames, + (ResultCallback> successCallback), + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, searchGames, + (QString gameName, + ResultCallback> successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, getGameById, + (QString gameId, ResultCallback successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, createClip, + (QString channelId, ResultCallback successCallback, + std::function failureCallback, + std::function finallyCallback), + (override)); + + MOCK_METHOD(void, getChannel, + (QString broadcasterId, + ResultCallback successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, createStreamMarker, + (QString broadcasterId, QString description, + ResultCallback successCallback, + std::function failureCallback), + (override)); + + MOCK_METHOD(void, loadBlocks, + (QString userId, + ResultCallback> successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, blockUser, + (QString targetUserId, std::function successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, unblockUser, + (QString targetUserId, std::function successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, updateChannel, + (QString broadcasterId, QString gameId, QString language, + QString title, + std::function successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, manageAutoModMessages, + (QString userID, QString msgID, QString action, + std::function successCallback, + std::function failureCallback), + (override)); + + MOCK_METHOD(void, getCheermotes, + (QString broadcasterId, + ResultCallback> successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, getEmoteSetData, + (QString emoteSetId, + ResultCallback successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, getChannelEmotes, + (QString broadcasterId, + ResultCallback> successCallback, + HelixFailureCallback failureCallback), + (override)); + + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), + (override)); +}; + +static QString DEFAULT_SETTINGS = R"!( +{ + "accounts": { + "uid117166826": { + "username": "testaccount_420", + "userID": "117166826", + "clientID": "abc", + "oauthToken": "def" + }, + "current": "testaccount_420" + }, + "highlighting": { + "selfHighlight": { + "enableSound": true + }, + "blacklist": [ + { + "pattern": "zenix", + "regex": false + } + ], + "users": [ + { + "pattern": "pajlada", + "showInMentions": false, + "alert": false, + "sound": false, + "regex": false, + "case": false, + "soundUrl": "", + "color": "#7fffffff" + }, + { + "pattern": "gempir", + "showInMentions": true, + "alert": true, + "sound": false, + "regex": false, + "case": false, + "soundUrl": "", + "color": "#7ff19900" + } + ], + "alwaysPlaySound": true, + "highlights": [ + { + "pattern": "!testmanxd", + "showInMentions": true, + "alert": true, + "sound": true, + "regex": false, + "case": false, + "soundUrl": "", + "color": "#7f7f3f49" + } + ], + "badges": [ + { + "name": "broadcaster", + "displayName": "Broadcaster", + "alert": false, + "sound": false, + "soundUrl": "", + "color": "#7f427f00" + }, + { + "name": "subscriber", + "displayName": "Subscriber", + "alert": false, + "sound": false, + "soundUrl": "", + "color": "#7f7f3f49" + }, + { + "name": "founder", + "displayName": "Founder", + "alert": true, + "sound": false, + "soundUrl": "", + "color": "#7fe8b7eb" + } + ], + "subHighlightColor": "#64ffd641" + } +})!"; + +struct TestCase { + // TODO: create one of these from a raw irc message? hmm xD + struct { + MessageParseArgs args; + std::vector badges; + QString senderName; + QString originalMessage; + } input; + + struct { + bool state; + HighlightResult result; + } expected; +}; + +class HighlightControllerTest : public ::testing::Test +{ +protected: + void SetUp() override + { + { + // Write default settings to the mock settings json file + QDir().mkpath("/tmp/c2-tests"); + QFile settingsFile("/tmp/c2-tests/settings.json"); + assert(settingsFile.open(QIODevice::WriteOnly | QIODevice::Text)); + QTextStream out(&settingsFile); + out << DEFAULT_SETTINGS; + } + + this->mockHelix = new MockHelix; + + initializeHelix(this->mockHelix); + + EXPECT_CALL(*this->mockHelix, loadBlocks).Times(Exactly(1)); + EXPECT_CALL(*this->mockHelix, update).Times(Exactly(1)); + + this->mockApplication = std::make_unique(); + this->settings = std::make_unique("/tmp/c2-tests"); + this->paths = std::make_unique(); + + this->controller = std::make_unique(); + + this->mockApplication->accounts.initialize(*this->settings, + *this->paths); + this->controller->initialize(*this->settings, *this->paths); + } + + void TearDown() override + { + QDir().rmdir("/tmp/c2-tests"); + this->mockApplication.reset(); + this->settings.reset(); + this->paths.reset(); + + this->controller.reset(); + + delete this->mockHelix; + } + + std::unique_ptr mockApplication; + std::unique_ptr settings; + std::unique_ptr paths; + + std::unique_ptr controller; + + MockHelix *mockHelix; +}; + +TEST_F(HighlightControllerTest, A) +{ + auto currentUser = + this->mockApplication->getAccounts()->twitch.getCurrent(); + std::vector tests{ + { + { + // input + MessageParseArgs{}, // no special args + {}, // no badges + "pajlada", // sender name + "hello!", // original message + }, + { + // expected + true, // state + { + false, // alert + false, // playsound + boost::none, // custom sound url + std::make_shared("#7fffffff"), // color + false, + }, + }, + }, + { + { + // input + MessageParseArgs{}, // no special args + {}, // no badges + "pajlada2", // sender name + "hello!", // original message + }, + { + // expected + false, // state + HighlightResult::emptyResult(), // result + }, + }, + { + { + // input + MessageParseArgs{}, // no special args + { + { + "founder", + "0", + }, // founder badge + }, + "pajlada22", // sender name + "hello!", // original message + }, + { + // expected + true, // state + { + true, // alert + false, // playsound + boost::none, // custom sound url + std::make_shared("#7fe8b7eb"), // color + false, //showInMentions + }, + }, + }, + { + { + // input + MessageParseArgs{}, // no special args + { + { + "founder", + "0", + }, // founder badge + }, + "pajlada", // sender name + "hello!", // original message + }, + { + // expected + true, // state + { + true, // alert + false, // playsound + boost::none, // custom sound url + std::make_shared("#7fffffff"), // color + false, //showInMentions + }, + }, + }, + }; + + for (const auto &[input, expected] : tests) + { + auto [isMatch, matchResult] = this->controller->check( + input.args, input.badges, input.senderName, input.originalMessage); + + EXPECT_EQ(isMatch, expected.state); + EXPECT_EQ(matchResult, expected.result); + } +}