diff --git a/CHANGELOG.md b/CHANGELOG.md index 59bb8e8cd..ec354bb05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unversioned +- Major: Added the ability to highlight messages based on user badges. (#1704) - Minor: Added visual indicator to message length if over 500 characters long (#2659) - Minor: Added `is:` search filter to find messages of specific types. (#2653, #2671) - Minor: Added image links to the badge context menu. (#2667) diff --git a/chatterino.pro b/chatterino.pro index 778399a0a..20e3f0e0f 100644 --- a/chatterino.pro +++ b/chatterino.pro @@ -149,6 +149,8 @@ SOURCES += \ src/controllers/commands/Command.cpp \ src/controllers/commands/CommandController.cpp \ src/controllers/commands/CommandModel.cpp \ + src/controllers/highlights/BadgeHighlightModel.cpp \ + src/controllers/highlights/HighlightBadge.cpp \ src/controllers/filters/FilterModel.cpp \ src/controllers/filters/parser/FilterParser.cpp \ src/controllers/filters/parser/Tokenizer.cpp \ @@ -239,6 +241,7 @@ SOURCES += \ src/util/AttachToConsole.cpp \ src/util/Clipboard.cpp \ src/util/DebugCount.cpp \ + src/util/DisplayBadge.cpp \ src/util/FormatTime.cpp \ src/util/FunctionEventFilter.cpp \ src/util/FuzzyConvert.cpp \ @@ -261,6 +264,7 @@ SOURCES += \ src/widgets/BaseWidget.cpp \ src/widgets/BaseWindow.cpp \ src/widgets/FramelessEmbedWindow.cpp \ + src/widgets/dialogs/BadgePickerDialog.cpp \ src/widgets/dialogs/ChannelFilterEditorDialog.cpp \ src/widgets/dialogs/ColorPickerDialog.cpp \ src/widgets/dialogs/EmotePopup.cpp \ @@ -369,6 +373,8 @@ HEADERS += \ src/controllers/commands/Command.hpp \ src/controllers/commands/CommandController.hpp \ src/controllers/commands/CommandModel.hpp \ + src/controllers/highlights/BadgeHighlightModel.hpp \ + src/controllers/highlights/HighlightBadge.hpp \ src/controllers/filters/FilterModel.hpp \ src/controllers/filters/FilterRecord.hpp \ src/controllers/filters/FilterSet.hpp \ @@ -477,6 +483,7 @@ HEADERS += \ src/util/CombinePath.hpp \ src/util/ConcurrentMap.hpp \ src/util/DebugCount.hpp \ + src/util/DisplayBadge.hpp \ src/util/DistanceBetweenPoints.hpp \ src/util/FormatTime.hpp \ src/util/FunctionEventFilter.hpp \ @@ -515,6 +522,7 @@ HEADERS += \ src/widgets/BaseWidget.hpp \ src/widgets/BaseWindow.hpp \ src/widgets/FramelessEmbedWindow.hpp \ + src/widgets/dialogs/BadgePickerDialog.hpp \ src/widgets/dialogs/ChannelFilterEditorDialog.hpp \ src/widgets/dialogs/ColorPickerDialog.hpp \ src/widgets/dialogs/EmotePopup.hpp \ diff --git a/resources/twitch/admin.png b/resources/twitch/admin.png index 3c6a5e4f4..a5dcb13b5 100644 Binary files a/resources/twitch/admin.png and b/resources/twitch/admin.png differ diff --git a/resources/twitch/automod.png b/resources/twitch/automod.png index 01174644f..32eabac97 100644 Binary files a/resources/twitch/automod.png and b/resources/twitch/automod.png differ diff --git a/resources/twitch/broadcaster.png b/resources/twitch/broadcaster.png index a4278aba9..ee1c18c73 100644 Binary files a/resources/twitch/broadcaster.png and b/resources/twitch/broadcaster.png differ diff --git a/resources/twitch/globalmod.png b/resources/twitch/globalmod.png index 10d69fb98..9353fc8a2 100644 Binary files a/resources/twitch/globalmod.png and b/resources/twitch/globalmod.png differ diff --git a/resources/twitch/moderator.png b/resources/twitch/moderator.png index 6d92034b7..b418088d1 100644 Binary files a/resources/twitch/moderator.png and b/resources/twitch/moderator.png differ diff --git a/resources/twitch/prime.png b/resources/twitch/prime.png index 4038afb01..21e442bbf 100644 Binary files a/resources/twitch/prime.png and b/resources/twitch/prime.png differ diff --git a/resources/twitch/staff.png b/resources/twitch/staff.png index c973739a6..d2d257428 100644 Binary files a/resources/twitch/staff.png and b/resources/twitch/staff.png differ diff --git a/resources/twitch/subscriber.png b/resources/twitch/subscriber.png index bfdd39b84..9054ef08a 100644 Binary files a/resources/twitch/subscriber.png and b/resources/twitch/subscriber.png differ diff --git a/resources/twitch/turbo.png b/resources/twitch/turbo.png index 7e40bfcc0..12bc1bdb3 100644 Binary files a/resources/twitch/turbo.png and b/resources/twitch/turbo.png differ diff --git a/resources/twitch/verified.png b/resources/twitch/verified.png index b34330b34..0b7019471 100644 Binary files a/resources/twitch/verified.png and b/resources/twitch/verified.png differ diff --git a/resources/twitch/vip.png b/resources/twitch/vip.png index 50518a1de..768e59493 100644 Binary files a/resources/twitch/vip.png and b/resources/twitch/vip.png differ diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 39d9a0bf6..60edfe660 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -72,6 +72,10 @@ set(SOURCE_FILES main.cpp controllers/filters/parser/Types.cpp controllers/filters/parser/Types.hpp + controllers/highlights/BadgeHighlightModel.cpp + controllers/highlights/BadgeHighlightModel.hpp + controllers/highlights/HighlightBadge.cpp + controllers/highlights/HighlightBadge.hpp controllers/highlights/HighlightBlacklistModel.cpp controllers/highlights/HighlightBlacklistModel.hpp controllers/highlights/HighlightModel.cpp @@ -261,6 +265,8 @@ set(SOURCE_FILES main.cpp util/Clipboard.hpp util/DebugCount.cpp util/DebugCount.hpp + util/DisplayBadge.cpp + util/DisplayBadge.hpp util/FormatTime.cpp util/FormatTime.hpp util/FunctionEventFilter.cpp @@ -319,6 +325,8 @@ set(SOURCE_FILES main.cpp widgets/Window.cpp widgets/Window.hpp + widgets/dialogs/BadgePickerDialog.cpp + widgets/dialogs/BadgePickerDialog.hpp widgets/dialogs/ChannelFilterEditorDialog.cpp widgets/dialogs/ChannelFilterEditorDialog.hpp widgets/dialogs/ColorPickerDialog.cpp diff --git a/src/controllers/highlights/BadgeHighlightModel.cpp b/src/controllers/highlights/BadgeHighlightModel.cpp new file mode 100644 index 000000000..9ea8a2f93 --- /dev/null +++ b/src/controllers/highlights/BadgeHighlightModel.cpp @@ -0,0 +1,56 @@ +#include "BadgeHighlightModel.hpp" + +#include "Application.hpp" +#include "messages/Emote.hpp" +#include "singletons/Settings.hpp" +#include "util/StandardItemHelper.hpp" + +namespace chatterino { + +// commandmodel +BadgeHighlightModel::BadgeHighlightModel(QObject *parent) + : SignalVectorModel(5, parent) +{ +} + +// turn vector item into model row +HighlightBadge BadgeHighlightModel::getItemFromRow( + std::vector &row, const HighlightBadge &original) +{ + using Column = BadgeHighlightModel::Column; + + // In order for old messages to update their highlight color, we need to + // update the highlight color here. + auto highlightColor = original.getColor(); + *highlightColor = + row[Column::Color]->data(Qt::DecorationRole).value(); + + return HighlightBadge{ + original.badgeName(), + row[Column::Badge]->data(Qt::DisplayRole).toString(), + row[Column::FlashTaskbar]->data(Qt::CheckStateRole).toBool(), + row[Column::PlaySound]->data(Qt::CheckStateRole).toBool(), + row[Column::SoundPath]->data(Qt::UserRole).toString(), + highlightColor}; +} + +// row into vector item +void BadgeHighlightModel::getRowFromItem(const HighlightBadge &item, + std::vector &row) +{ + using QIconPtr = std::shared_ptr; + using Column = BadgeHighlightModel::Column; + + setStringItem(row[Column::Badge], item.displayName(), false, true); + setBoolItem(row[Column::FlashTaskbar], item.hasAlert()); + setBoolItem(row[Column::PlaySound], item.hasSound()); + setFilePathItem(row[Column::SoundPath], item.getSoundUrl()); + setColorItem(row[Column::Color], *item.getColor()); + + TwitchBadges::instance()->getBadgeIcon( + item.badgeName(), [item, row](QString /*name*/, const QIconPtr pixmap) { + row[Column::Badge]->setData(QVariant(*pixmap), Qt::DecorationRole); + }); +} + +} // namespace chatterino diff --git a/src/controllers/highlights/BadgeHighlightModel.hpp b/src/controllers/highlights/BadgeHighlightModel.hpp new file mode 100644 index 000000000..ffe77d310 --- /dev/null +++ b/src/controllers/highlights/BadgeHighlightModel.hpp @@ -0,0 +1,36 @@ +#pragma once + +#include + +#include "common/SignalVectorModel.hpp" +#include "controllers/highlights/HighlightBadge.hpp" +#include "providers/twitch/TwitchBadges.hpp" + +namespace chatterino { + +class HighlightController; + +class BadgeHighlightModel : public SignalVectorModel +{ +public: + explicit BadgeHighlightModel(QObject *parent); + + enum Column { + Badge = 0, + FlashTaskbar = 1, + PlaySound = 2, + SoundPath = 3, + Color = 4 + }; + +protected: + // vector into model row + virtual HighlightBadge getItemFromRow( + std::vector &row, + const HighlightBadge &original) override; + + virtual void getRowFromItem(const HighlightBadge &item, + std::vector &row) override; +}; + +} // namespace chatterino diff --git a/src/controllers/highlights/HighlightBadge.cpp b/src/controllers/highlights/HighlightBadge.cpp new file mode 100644 index 000000000..d00feacc7 --- /dev/null +++ b/src/controllers/highlights/HighlightBadge.cpp @@ -0,0 +1,121 @@ +#include "HighlightBadge.hpp" + +#include "singletons/Resources.hpp" + +namespace chatterino { + +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_); +} + +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)) +{ +} + +HighlightBadge::HighlightBadge(const QString &badgeName, + const QString &displayName, bool hasAlert, + bool hasSound, const QString &soundUrl, + std::shared_ptr color) + : badgeName_(badgeName) + , displayName_(displayName) + , hasAlert_(hasAlert) + , hasSound_(hasSound) + , soundUrl_(soundUrl) + , color_(color) +{ + // check badgeName at initialization to reduce cost per isMatch call + this->hasVersions_ = badgeName.contains("/"); + this->isMulti_ = badgeName.contains(","); + if (this->isMulti_) + { + this->badges_ = badgeName.split(","); + } +} + +const QString &HighlightBadge::badgeName() const +{ + return this->badgeName_; +} + +const QString &HighlightBadge::displayName() const +{ + return this->displayName_; +} + +bool HighlightBadge::hasAlert() const +{ + return this->hasAlert_; +} + +bool HighlightBadge::hasSound() const +{ + return this->hasSound_; +} + +bool HighlightBadge::isMatch(const Badge &badge) const +{ + if (this->isMulti_) + { + for (const auto &id : this->badges_) + { + if (this->compare(id, badge)) + { + return true; + } + } + return false; + } + else + { + return this->compare(this->badgeName_, badge); + } +} + +bool HighlightBadge::compare(const QString &id, const Badge &badge) const +{ + if (this->hasVersions_) + { + auto parts = id.split("/"); + if (parts.size() == 2) + { + return parts.at(0).compare(badge.key_, Qt::CaseInsensitive) == 0 && + parts.at(1).compare(badge.value_, Qt::CaseInsensitive) == 0; + } + else + { + return parts.at(0).compare(badge.key_, Qt::CaseInsensitive) == 0; + } + } + else + { + return id.compare(badge.key_, Qt::CaseInsensitive) == 0; + } +} + +bool HighlightBadge::hasCustomSound() const +{ + return !this->soundUrl_.isEmpty(); +} + +const QUrl &HighlightBadge::getSoundUrl() const +{ + return this->soundUrl_; +} + +const std::shared_ptr HighlightBadge::getColor() const +{ + return this->color_; +} + +} // namespace chatterino diff --git a/src/controllers/highlights/HighlightBadge.hpp b/src/controllers/highlights/HighlightBadge.hpp new file mode 100644 index 000000000..c3daf3045 --- /dev/null +++ b/src/controllers/highlights/HighlightBadge.hpp @@ -0,0 +1,123 @@ +#pragma once + +#include "providers/twitch/TwitchBadge.hpp" +#include "util/RapidJsonSerializeQString.hpp" +#include "util/RapidjsonHelpers.hpp" + +#include +#include +#include + +namespace chatterino { +class HighlightBadge +{ +public: + bool operator==(const HighlightBadge &other) const; + + HighlightBadge(const QString &badgeName, const QString &displayName, + 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); + + const QString &badgeName() const; + const QString &displayName() const; + bool hasAlert() const; + bool hasSound() const; + bool isMatch(const Badge &badge) const; + + /** + * @brief Check if this highlight phrase has a custom sound set. + * + * Note that this method only checks whether the path to the custom sound + * is not empty. It does not check whether the file still exists, is a + * sound file, or anything else. + * + * @return true, if the custom sound file path is not empty, false otherwise + */ + bool hasCustomSound() const; + + const QUrl &getSoundUrl() const; + const std::shared_ptr getColor() const; + + /* + * XXX: Use the constexpr constructor here once we are building with + * Qt>=5.13. + */ + static QColor FALLBACK_HIGHLIGHT_COLOR; + +private: + bool compare(const QString &id, const Badge &badge) const; + + QString badgeName_; + QString displayName_; + bool hasAlert_; + bool hasSound_; + QUrl soundUrl_; + std::shared_ptr color_; + + bool isMulti_; + bool hasVersions_; + QStringList badges_; +}; +}; // namespace chatterino + +namespace pajlada { + +template <> +struct Serialize { + static rapidjson::Value get(const chatterino::HighlightBadge &value, + rapidjson::Document::AllocatorType &a) + { + rapidjson::Value ret(rapidjson::kObjectType); + + chatterino::rj::set(ret, "name", value.badgeName(), a); + chatterino::rj::set(ret, "displayName", value.displayName(), 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); + chatterino::rj::set(ret, "color", + value.getColor()->name(QColor::HexArgb), a); + + return ret; + } +}; + +template <> +struct Deserialize { + static chatterino::HighlightBadge get(const rapidjson::Value &value, + bool *error) + { + if (!value.IsObject()) + { + PAJLADA_REPORT_ERROR(error); + return chatterino::HighlightBadge(QString(), QString(), false, + false, "", QColor()); + } + + QString _name; + QString _displayName; + bool _hasAlert = true; + bool _hasSound = false; + QString _soundUrl; + QString encodedColor; + + chatterino::rj::getSafe(value, "name", _name); + chatterino::rj::getSafe(value, "displayName", _displayName); + chatterino::rj::getSafe(value, "alert", _hasAlert); + chatterino::rj::getSafe(value, "sound", _hasSound); + chatterino::rj::getSafe(value, "soundUrl", _soundUrl); + chatterino::rj::getSafe(value, "color", encodedColor); + + auto _color = QColor(encodedColor); + if (!_color.isValid()) + _color = chatterino::HighlightBadge::FALLBACK_HIGHLIGHT_COLOR; + + return chatterino::HighlightBadge(_name, _displayName, _hasAlert, + _hasSound, _soundUrl, _color); + } +}; + +} // namespace pajlada diff --git a/src/messages/SharedMessageBuilder.cpp b/src/messages/SharedMessageBuilder.cpp index ba5a4f613..0fcf94502 100644 --- a/src/messages/SharedMessageBuilder.cpp +++ b/src/messages/SharedMessageBuilder.cpp @@ -31,6 +31,34 @@ namespace { } } + QStringList parseTagList(const QVariantMap &tags, const QString &key) + { + auto iterator = tags.find(key); + if (iterator == tags.end()) + return QStringList{}; + + return iterator.value().toString().split( + ',', QString::SplitBehavior::SkipEmptyParts); + } + + std::vector parseBadges(const QVariantMap &tags) + { + std::vector badges; + + for (QString badge : parseTagList(tags, "badges")) + { + QStringList parts = badge.split('/'); + if (parts.size() != 2) + { + continue; + } + + badges.emplace_back(parts[0], parts[1]); + } + + return badges; + } + } // namespace SharedMessageBuilder::SharedMessageBuilder( @@ -316,6 +344,53 @@ void SharedMessageBuilder::parseHighlights() break; } } + + // Highlight because of badge + auto badges = parseBadges(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); + 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; + } + } + } } void SharedMessageBuilder::addTextOrEmoji(EmotePtr emote) diff --git a/src/providers/twitch/TwitchBadges.cpp b/src/providers/twitch/TwitchBadges.cpp index b41762bc6..f3455c71a 100644 --- a/src/providers/twitch/TwitchBadges.cpp +++ b/src/providers/twitch/TwitchBadges.cpp @@ -7,60 +7,95 @@ #include "common/NetworkRequest.hpp" #include "common/Outcome.hpp" +#include "common/QLogging.hpp" #include "messages/Emote.hpp" namespace chatterino { +TwitchBadges::TwitchBadges() +{ + this->loadTwitchBadges(); +} + void TwitchBadges::loadTwitchBadges() { + assert(this->loaded_ == false); + static QString url( "https://badges.twitch.tv/v1/badges/global/display?language=en"); NetworkRequest(url) - .onSuccess([this](auto result) -> Outcome { - auto root = result.parseJson(); - auto badgeSets = this->badgeSets_.access(); - - auto jsonSets = root.value("badge_sets").toObject(); - for (auto sIt = jsonSets.begin(); sIt != jsonSets.end(); ++sIt) { - auto key = sIt.key(); - auto versions = - sIt.value().toObject().value("versions").toObject(); + auto root = result.parseJson(); + auto badgeSets = this->badgeSets_.access(); - for (auto vIt = versions.begin(); vIt != versions.end(); ++vIt) + auto jsonSets = root.value("badge_sets").toObject(); + for (auto sIt = jsonSets.begin(); sIt != jsonSets.end(); ++sIt) { - auto versionObj = vIt.value().toObject(); + auto key = sIt.key(); + auto versions = + sIt.value().toObject().value("versions").toObject(); - auto emote = Emote{ - {""}, - ImageSet{ - Image::fromUrl( - {versionObj.value("image_url_1x").toString()}, - 1), - Image::fromUrl( - {versionObj.value("image_url_2x").toString()}, - .5), - Image::fromUrl( - {versionObj.value("image_url_4x").toString()}, - .25), - }, - Tooltip{versionObj.value("title").toString()}, - Url{versionObj.value("click_url").toString()}}; - // "title" - // "clickAction" + for (auto vIt = versions.begin(); vIt != versions.end(); + ++vIt) + { + auto versionObj = vIt.value().toObject(); - (*badgeSets)[key][vIt.key()] = - std::make_shared(emote); + auto emote = Emote{ + {""}, + ImageSet{ + Image::fromUrl({versionObj.value("image_url_1x") + .toString()}, + 1), + Image::fromUrl({versionObj.value("image_url_2x") + .toString()}, + .5), + Image::fromUrl({versionObj.value("image_url_4x") + .toString()}, + .25), + }, + Tooltip{versionObj.value("title").toString()}, + Url{versionObj.value("click_url").toString()}}; + // "title" + // "clickAction" + + (*badgeSets)[key][vIt.key()] = + std::make_shared(emote); + } } } - + this->loaded(); return Success; }) + .onError([this](auto res) { + qCDebug(chatterinoTwitch) + << "Error loading Twitch Badges:" << res.status(); + // Despite erroring out, we still want to reach the same point + // Loaded should still be set to true to not build up an endless queue, and the quuee should still be flushed. + this->loaded(); + }) .execute(); } +void TwitchBadges::loaded() +{ + std::unique_lock loadedLock(this->loadedMutex_); + + assert(this->loaded_ == false); + + this->loaded_ = true; + + // Flush callback queue + std::unique_lock queueLock(this->queueMutex_); + while (!this->callbackQueue_.empty()) + { + auto callback = this->callbackQueue_.front(); + this->callbackQueue_.pop(); + this->getBadgeIcon(callback.first, callback.second); + } +} + boost::optional TwitchBadges::badge(const QString &set, const QString &version) const { @@ -77,4 +112,117 @@ boost::optional TwitchBadges::badge(const QString &set, return boost::none; } +boost::optional TwitchBadges::badge(const QString &set) const +{ + auto badgeSets = this->badgeSets_.access(); + auto it = badgeSets->find(set); + if (it != badgeSets->end()) + { + if (it->second.size() > 0) + { + return it->second.begin()->second; + } + } + return boost::none; +} + +void TwitchBadges::getBadgeIcon(const QString &name, BadgeIconCallback callback) +{ + { + std::shared_lock loadedLock(this->loadedMutex_); + + if (!this->loaded_) + { + // Badges have not been loaded yet, store callback in a queue + std::unique_lock queueLock(this->queueMutex_); + this->callbackQueue_.push({name, std::move(callback)}); + return; + } + } + + { + std::shared_lock badgeLock(this->badgesMutex_); + if (this->badgesMap_.contains(name)) + { + callback(name, this->badgesMap_[name]); + return; + } + } + + // Split string in format "name1/version1,name2/version2" to "name1", "version1" + // If not in list+version form, name will remain the same + auto targetBadge = name.split(",").at(0).split("/"); + + const auto badge = targetBadge.size() == 2 + ? this->badge(targetBadge.at(0), targetBadge.at(1)) + : this->badge(targetBadge.at(0)); + + if (badge) + { + this->loadEmoteImage(name, (*badge)->images.getImage3(), + std::move(callback)); + } +} + +void TwitchBadges::getBadgeIcon(const DisplayBadge &badge, + BadgeIconCallback callback) +{ + this->getBadgeIcon(badge.badgeName(), std::move(callback)); +} + +void TwitchBadges::getBadgeIcons(const QList &badges, + BadgeIconCallback callback) +{ + for (const auto &item : badges) + { + this->getBadgeIcon(item, callback); + } +} + +void TwitchBadges::loadEmoteImage(const QString &name, ImagePtr image, + BadgeIconCallback &&callback) +{ + NetworkRequest(image->url().string) + .concurrent() + .cache() + .onSuccess([this, name, callback](auto result) -> Outcome { + auto data = result.getData(); + + // const cast since we are only reading from it + QBuffer buffer(const_cast(&data)); + buffer.open(QIODevice::ReadOnly); + QImageReader reader(&buffer); + + QImage image; + if (reader.imageCount() == 0 || !reader.read(&image)) + { + return Failure; + } + + auto icon = std::make_shared(QPixmap::fromImage(image)); + + { + std::unique_lock lock(this->badgesMutex_); + this->badgesMap_[name] = icon; + } + + callback(name, icon); + + return Success; + }) + .execute(); +} + +TwitchBadges *TwitchBadges::instance_; + +TwitchBadges *TwitchBadges::instance() +{ + if (TwitchBadges::instance_ == nullptr) + { + TwitchBadges::instance_ = new TwitchBadges(); + } + + return TwitchBadges::instance_; +} + } // namespace chatterino diff --git a/src/providers/twitch/TwitchBadges.hpp b/src/providers/twitch/TwitchBadges.hpp index d9023855d..c13e3a079 100644 --- a/src/providers/twitch/TwitchBadges.hpp +++ b/src/providers/twitch/TwitchBadges.hpp @@ -5,8 +5,14 @@ #include #include "common/UniqueAccess.hpp" +#include "messages/Image.hpp" +#include "util/DisplayBadge.hpp" #include "util/QStringHash.hpp" +#include "pajlada/signals/signal.hpp" + +#include + namespace chatterino { struct Emote; @@ -17,13 +23,42 @@ class Paths; class TwitchBadges { -public: - void loadTwitchBadges(); + using QIconPtr = std::shared_ptr; + using ImagePtr = std::shared_ptr; + using BadgeIconCallback = std::function; +public: + static TwitchBadges *instance(); + + // Get badge from name and version boost::optional badge(const QString &set, const QString &version) const; + // Get first matching badge with name, regardless of version + boost::optional badge(const QString &set) const; + + void getBadgeIcon(const QString &name, BadgeIconCallback callback); + void getBadgeIcon(const DisplayBadge &badge, BadgeIconCallback callback); + void getBadgeIcons(const QList &badges, + BadgeIconCallback callback); private: + static TwitchBadges *instance_; + + TwitchBadges(); + void loadTwitchBadges(); + void loaded(); + void loadEmoteImage(const QString &name, ImagePtr image, + BadgeIconCallback &&callback); + + std::shared_mutex badgesMutex_; + QMap badgesMap_; + + std::mutex queueMutex_; + std::queue> callbackQueue_; + + std::shared_mutex loadedMutex_; + bool loaded_ = false; + UniqueAccess< std::unordered_map>> badgeSets_; // "bits": { "100": ... "500": ... diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index 4bb312adb..7d623fcc7 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -143,8 +143,7 @@ namespace { } } // namespace -TwitchChannel::TwitchChannel(const QString &name, - TwitchBadges &globalTwitchBadges, BttvEmotes &bttv, +TwitchChannel::TwitchChannel(const QString &name, BttvEmotes &bttv, FfzEmotes &ffz) : Channel(name, Channel::Type::Twitch) , ChannelChatters(*static_cast(this)) @@ -153,7 +152,6 @@ TwitchChannel::TwitchChannel(const QString &name, , channelUrl_("https://twitch.tv/" + name) , popoutPlayerUrl_("https://player.twitch.tv/?parent=twitch.tv&channel=" + name) - , globalTwitchBadges_(globalTwitchBadges) , globalBttv_(bttv) , globalFfz_(ffz) , bttvEmotes_(std::make_shared()) @@ -478,11 +476,6 @@ SharedAccessGuard return this->streamStatus_.accessConst(); } -const TwitchBadges &TwitchChannel::globalTwitchBadges() const -{ - return this->globalTwitchBadges_; -} - const BttvEmotes &TwitchChannel::globalBttv() const { return this->globalBttv_; diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index 06f26567d..df3290ea4 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -87,7 +87,6 @@ public: SharedAccessGuard accessStreamStatus() const; // Emotes - const TwitchBadges &globalTwitchBadges() const; const BttvEmotes &globalBttv() const; const FfzEmotes &globalFfz() const; boost::optional bttvEmote(const EmoteName &name) const; @@ -128,9 +127,8 @@ private: } nameOptions; protected: - explicit TwitchChannel(const QString &channelName, - TwitchBadges &globalTwitchBadges, - BttvEmotes &globalBttv, FfzEmotes &globalFfz); + explicit TwitchChannel(const QString &channelName, BttvEmotes &globalBttv, + FfzEmotes &globalFfz); private: // Methods @@ -163,9 +161,6 @@ private: UniqueAccess streamStatus_; UniqueAccess roomModes_; - // Emotes - TwitchBadges &globalTwitchBadges_; - protected: BttvEmotes &globalBttv_; FfzEmotes &globalFfz_; diff --git a/src/providers/twitch/TwitchIrcServer.cpp b/src/providers/twitch/TwitchIrcServer.cpp index 02562ad91..724d6747e 100644 --- a/src/providers/twitch/TwitchIrcServer.cpp +++ b/src/providers/twitch/TwitchIrcServer.cpp @@ -45,7 +45,6 @@ void TwitchIrcServer::initialize(Settings &settings, Paths &paths) }); }); - this->twitchBadges.loadTwitchBadges(); this->bttv.loadEmotes(); this->ffz.loadEmotes(); } @@ -88,8 +87,8 @@ void TwitchIrcServer::initializeConnection(IrcConnection *connection, std::shared_ptr TwitchIrcServer::createChannel( const QString &channelName) { - auto channel = std::shared_ptr(new TwitchChannel( - channelName, this->twitchBadges, this->bttv, this->ffz)); + auto channel = std::shared_ptr( + new TwitchChannel(channelName, this->bttv, this->ffz)); channel->initialize(); channel->sendMessageSignal.connect( diff --git a/src/providers/twitch/TwitchIrcServer.hpp b/src/providers/twitch/TwitchIrcServer.hpp index b33f9a330..17203c272 100644 --- a/src/providers/twitch/TwitchIrcServer.hpp +++ b/src/providers/twitch/TwitchIrcServer.hpp @@ -7,7 +7,6 @@ #include "providers/bttv/BttvEmotes.hpp" #include "providers/ffz/FfzEmotes.hpp" #include "providers/irc/AbstractIrcServer.hpp" -#include "providers/twitch/TwitchBadges.hpp" #include #include @@ -75,7 +74,6 @@ private: std::chrono::steady_clock::time_point lastErrorTimeSpeed_; std::chrono::steady_clock::time_point lastErrorTimeAmount_; - TwitchBadges twitchBadges; BttvEmotes bttv; FfzEmotes ffz; diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index 182e88720..3918e0c45 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -1083,8 +1083,8 @@ boost::optional TwitchMessageBuilder::getTwitchBadge( return channelBadge; } - if (auto globalBadge = this->twitchChannel->globalTwitchBadges().badge( - badge.key_, badge.value_)) + if (auto globalBadge = + TwitchBadges::instance()->badge(badge.key_, badge.value_)) { return globalBadge; } diff --git a/src/singletons/Settings.cpp b/src/singletons/Settings.cpp index 71c152732..c7acb3319 100644 --- a/src/singletons/Settings.cpp +++ b/src/singletons/Settings.cpp @@ -18,6 +18,7 @@ ConcurrentSettings::ConcurrentSettings() // NOTE: these do not get deleted : highlightedMessages(*new SignalVector()) , highlightedUsers(*new SignalVector()) + , highlightedBadges(*new SignalVector()) , blacklistedUsers(*new SignalVector()) , ignoredMessages(*new SignalVector()) , mutedChannels(*new SignalVector()) @@ -26,6 +27,7 @@ ConcurrentSettings::ConcurrentSettings() { persist(this->highlightedMessages, "/highlighting/highlights"); persist(this->blacklistedUsers, "/highlighting/blacklist"); + persist(this->highlightedBadges, "/highlighting/badges"); persist(this->highlightedUsers, "/highlighting/users"); persist(this->ignoredMessages, "/ignore/phrases"); persist(this->mutedChannels, "/pings/muted"); diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index bd636848e..687238f74 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -7,6 +7,7 @@ #include "common/Channel.hpp" #include "common/SignalVector.hpp" #include "controllers/filters/FilterRecord.hpp" +#include "controllers/highlights/HighlightBadge.hpp" #include "controllers/highlights/HighlightPhrase.hpp" #include "controllers/moderationactions/ModerationAction.hpp" #include "singletons/Toasts.hpp" @@ -31,6 +32,7 @@ public: SignalVector &highlightedMessages; SignalVector &highlightedUsers; + SignalVector &highlightedBadges; SignalVector &blacklistedUsers; SignalVector &ignoredMessages; SignalVector &mutedChannels; diff --git a/src/util/DisplayBadge.cpp b/src/util/DisplayBadge.cpp new file mode 100644 index 000000000..cf56634fa --- /dev/null +++ b/src/util/DisplayBadge.cpp @@ -0,0 +1,20 @@ +#include "DisplayBadge.hpp" + +namespace chatterino { +DisplayBadge::DisplayBadge(QString displayName, QString badgeName) + : displayName_(displayName) + , badgeName_(badgeName) +{ +} + +QString DisplayBadge::displayName() const +{ + return this->displayName_; +} + +QString DisplayBadge::badgeName() const +{ + return this->badgeName_; +} + +} // namespace chatterino diff --git a/src/util/DisplayBadge.hpp b/src/util/DisplayBadge.hpp new file mode 100644 index 000000000..add8594a8 --- /dev/null +++ b/src/util/DisplayBadge.hpp @@ -0,0 +1,17 @@ +#pragma once + +namespace chatterino { +class DisplayBadge +{ +public: + DisplayBadge(QString displayName, QString badgeName); + + QString displayName() const; + QString badgeName() const; + +private: + QString displayName_; + QString badgeName_; +}; + +} // namespace chatterino diff --git a/src/widgets/dialogs/BadgePickerDialog.cpp b/src/widgets/dialogs/BadgePickerDialog.cpp new file mode 100644 index 000000000..95e08db2d --- /dev/null +++ b/src/widgets/dialogs/BadgePickerDialog.cpp @@ -0,0 +1,71 @@ +#include "BadgePickerDialog.hpp" +#include +#include "singletons/Resources.hpp" + +#include "providers/twitch/TwitchBadges.hpp" + +namespace chatterino { + +BadgePickerDialog::BadgePickerDialog(QList badges, + QWidget *parent) + : QDialog(parent) +{ + this->dropdown_ = new QComboBox; + auto vbox = new QVBoxLayout(this); + auto buttonBox = + new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel); + + vbox->addWidget(this->dropdown_); + vbox->addWidget(buttonBox); + + QObject::connect(buttonBox, &QDialogButtonBox::accepted, [this] { + this->accept(); + this->close(); + }); + QObject::connect(buttonBox, &QDialogButtonBox::rejected, [this] { + this->reject(); + this->close(); + }); + + this->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed); + + this->setWindowFlags( + (this->windowFlags() & ~(Qt::WindowContextHelpButtonHint)) | + Qt::Dialog | Qt::MSWindowsFixedSizeDialogHint); + + // Add items. + for (const auto &item : badges) + { + this->dropdown_->addItem(item.displayName(), item.badgeName()); + } + + const auto updateBadge = [=](int index) { + BadgeOpt badge; + if (index >= 0 && index < badges.size()) + { + badge = badges[index]; + } + this->currentBadge_ = badge; + }; + + QObject::connect(this->dropdown_, + QOverload::of(&QComboBox::currentIndexChanged), + updateBadge); + updateBadge(0); + + // Set icons. + TwitchBadges::instance()->getBadgeIcons( + badges, + [&dropdown = this->dropdown_](QString identifier, const QIconPtr icon) { + if (!dropdown) + return; + + int index = dropdown->findData(identifier); + if (index != -1) + { + dropdown->setItemIcon(index, *icon); + } + }); +} + +} // namespace chatterino diff --git a/src/widgets/dialogs/BadgePickerDialog.hpp b/src/widgets/dialogs/BadgePickerDialog.hpp new file mode 100644 index 000000000..2df901c90 --- /dev/null +++ b/src/widgets/dialogs/BadgePickerDialog.hpp @@ -0,0 +1,29 @@ +#pragma once + +#include "util/DisplayBadge.hpp" + +#include + +namespace chatterino { + +class BadgePickerDialog : public QDialog, + public std::enable_shared_from_this +{ + using QIconPtr = std::shared_ptr; + using BadgeOpt = boost::optional; + +public: + BadgePickerDialog(QList badges, QWidget *parent = nullptr); + + BadgeOpt getSelection() const + { + return this->currentBadge_; + } + +private: + QComboBox *dropdown_; + + BadgeOpt currentBadge_; +}; + +} // namespace chatterino diff --git a/src/widgets/settingspages/HighlightingPage.cpp b/src/widgets/settingspages/HighlightingPage.cpp index e83e98873..20a8c4a73 100644 --- a/src/widgets/settingspages/HighlightingPage.cpp +++ b/src/widgets/settingspages/HighlightingPage.cpp @@ -1,6 +1,7 @@ #include "HighlightingPage.hpp" #include "Application.hpp" +#include "controllers/highlights/BadgeHighlightModel.hpp" #include "controllers/highlights/HighlightBlacklistModel.hpp" #include "controllers/highlights/HighlightModel.hpp" #include "controllers/highlights/UserHighlightModel.hpp" @@ -8,6 +9,7 @@ #include "singletons/Theme.hpp" #include "util/LayoutCreator.hpp" #include "util/StandardItemHelper.hpp" +#include "widgets/dialogs/BadgePickerDialog.hpp" #include "widgets/dialogs/ColorPickerDialog.hpp" #include @@ -27,6 +29,20 @@ namespace chatterino { +namespace { + // Add additional badges for highlights here + QList availableBadges = { + {"Broadcaster", "broadcaster"}, + {"Admin", "admin"}, + {"Staff", "staff"}, + {"Moderator", "moderator"}, + {"Verified", "partner"}, + {"VIP", "vip"}, + {"Predicted Blue", "predictions/blue-1,predictions/blue-2"}, + {"Predicted Pink", "predictions/pink-2,predictions/pink-1"}, + }; +} // namespace + HighlightingPage::HighlightingPage() { LayoutCreator layoutCreator(this); @@ -45,7 +61,9 @@ HighlightingPage::HighlightingPage() { highlights.emplace( "Play notification sounds and highlight messages based on " - "certain patterns."); + "certain patterns.\n" + "Message highlights are prioritized over badge highlights, " + "but under user highlights"); auto view = highlights @@ -80,7 +98,8 @@ HighlightingPage::HighlightingPage() QObject::connect(view->getTableView(), &QTableView::clicked, [this, view](const QModelIndex &clicked) { - this->tableCellClicked(clicked, view); + this->tableCellClicked( + clicked, view, HighlightTab::Messages); }); } @@ -89,7 +108,7 @@ HighlightingPage::HighlightingPage() pingUsers.emplace( "Play notification sounds and highlight messages from " "certain users.\n" - "User highlights are prioritized over message " + "User highlights are prioritized over message and badge " "highlights."); EditableModelView *view = pingUsers @@ -130,7 +149,61 @@ HighlightingPage::HighlightingPage() QObject::connect(view->getTableView(), &QTableView::clicked, [this, view](const QModelIndex &clicked) { - this->tableCellClicked(clicked, view); + this->tableCellClicked( + clicked, view, HighlightTab::Users); + }); + } + + auto badgeHighlights = tabs.appendTab(new QVBoxLayout, "Badges"); + { + badgeHighlights.emplace( + "Play notification sounds and highlight messages based on " + "user badges.\n" + "Badge highlights are prioritzed under user and message " + "highlights."); + auto view = badgeHighlights + .emplace( + (new BadgeHighlightModel(nullptr)) + ->initialized( + &getSettings()->highlightedBadges)) + .getElement(); + view->setTitles({"Name", "Flash\ntaskbar", "Play\nsound", + "Custom\nsound", "Color"}); + view->getTableView()->horizontalHeader()->setSectionResizeMode( + QHeaderView::Fixed); + view->getTableView()->horizontalHeader()->setSectionResizeMode( + 0, QHeaderView::Stretch); + + // fourtf: make class extrend BaseWidget and add this to + // dpiChanged + QTimer::singleShot(1, [view] { + view->getTableView()->resizeColumnsToContents(); + view->getTableView()->setColumnWidth(0, 200); + }); + + view->addButtonPressed.connect([this] { + auto d = std::make_shared( + availableBadges, this); + + d->setWindowTitle("Choose badge"); + if (d->exec() == QDialog::Accepted) + { + auto s = d->getSelection(); + if (!s) + { + return; + } + getSettings()->highlightedBadges.append(HighlightBadge{ + s->badgeName(), s->displayName(), false, false, "", + ColorProvider::instance().color( + ColorType::SelfHighlight)}); + } + }); + + QObject::connect(view->getTableView(), &QTableView::clicked, + [this, view](const QModelIndex &clicked) { + this->tableCellClicked( + clicked, view, HighlightTab::Badges); }); } @@ -209,73 +282,97 @@ HighlightingPage::HighlightingPage() // ---- misc this->disabledUsersChangedTimer_.setSingleShot(true); -} // namespace chatterino +} -void HighlightingPage::tableCellClicked(const QModelIndex &clicked, - EditableModelView *view) +void HighlightingPage::openSoundDialog(const QModelIndex &clicked, + EditableModelView *view, int soundColumn) { - using Column = HighlightModel::Column; + auto fileUrl = QFileDialog::getOpenFileUrl(this, tr("Open Sound"), QUrl(), + tr("Audio Files (*.mp3 *.wav)")); + view->getModel()->setData(clicked, fileUrl, Qt::UserRole); + view->getModel()->setData(clicked, fileUrl.fileName(), Qt::DisplayRole); - if (clicked.column() == Column::SoundPath) + // Enable custom sound check box if user set a sound + if (!fileUrl.isEmpty()) { - auto fileUrl = QFileDialog::getOpenFileUrl( - this, tr("Open Sound"), QUrl(), tr("Audio Files (*.mp3 *.wav)")); - view->getModel()->setData(clicked, fileUrl, Qt::UserRole); - view->getModel()->setData(clicked, fileUrl.fileName(), Qt::DisplayRole); - - // Enable custom sound check box if user set a sound - if (!fileUrl.isEmpty()) - { - QModelIndex checkBox = clicked.siblingAtColumn(Column::PlaySound); - view->getModel()->setData(checkBox, Qt::Checked, - Qt::CheckStateRole); - } + QModelIndex checkBox = clicked.siblingAtColumn(soundColumn); + view->getModel()->setData(checkBox, Qt::Checked, Qt::CheckStateRole); } - else if (clicked.column() == Column::Color) - { - // Hacky (?) way to figure out what tab the cell was clicked in - const bool fromMessagesTab = - (dynamic_cast(view->getModel()) != nullptr); +} - if (fromMessagesTab && clicked.row() == HighlightModel::WHISPER_ROW) - return; +void HighlightingPage::openColorDialog(const QModelIndex &clicked, + EditableModelView *view, + HighlightTab tab) +{ + auto initial = + view->getModel()->data(clicked, Qt::DecorationRole).value(); - auto initial = - view->getModel()->data(clicked, Qt::DecorationRole).value(); + auto dialog = new ColorPickerDialog(initial, this); + dialog->setAttribute(Qt::WA_DeleteOnClose); + dialog->show(); + dialog->closed.connect([=](auto selected) { + if (selected.isValid()) + { + view->getModel()->setData(clicked, selected, Qt::DecorationRole); - auto dialog = new ColorPickerDialog(initial, this); - dialog->setAttribute(Qt::WA_DeleteOnClose); - dialog->show(); - dialog->closed.connect([=](QColor selected) { - if (selected.isValid()) + if (tab == HighlightTab::Messages) { - view->getModel()->setData(clicked, selected, - Qt::DecorationRole); - - if (fromMessagesTab) + /* + * For preset highlights in the "Messages" tab, we need to + * manually update the color map. + */ + auto instance = ColorProvider::instance(); + switch (clicked.row()) { - /* - * For preset highlights in the "Messages" tab, we need to - * manually update the color map. - */ - auto instance = ColorProvider::instance(); - switch (clicked.row()) - { - case 0: - instance.updateColor(ColorType::SelfHighlight, - selected); - break; - case 1: - instance.updateColor(ColorType::Whisper, selected); - break; - case 2: - instance.updateColor(ColorType::Subscription, - selected); - break; - } + case 0: + instance.updateColor(ColorType::SelfHighlight, + selected); + break; + case 1: + instance.updateColor(ColorType::Whisper, selected); + break; + case 2: + instance.updateColor(ColorType::Subscription, selected); + break; } } - }); + } + }); +} + +void HighlightingPage::tableCellClicked(const QModelIndex &clicked, + EditableModelView *view, + HighlightTab tab) +{ + switch (tab) + { + case HighlightTab::Messages: + case HighlightTab::Users: { + using Column = HighlightModel::Column; + if (clicked.column() == Column::SoundPath) + { + this->openSoundDialog(clicked, view, Column::SoundPath); + } + else if (clicked.column() == Column::Color && + clicked.row() != HighlightModel::WHISPER_ROW) + { + this->openColorDialog(clicked, view, tab); + } + } + break; + + case HighlightTab::Badges: { + using Column = BadgeHighlightModel::Column; + if (clicked.column() == Column::SoundPath) + { + this->openSoundDialog(clicked, view, Column::SoundPath); + } + else if (clicked.column() == Column::Color) + { + this->openColorDialog(clicked, view, tab); + } + } + break; } } diff --git a/src/widgets/settingspages/HighlightingPage.hpp b/src/widgets/settingspages/HighlightingPage.hpp index 031eece3f..487bf9f72 100644 --- a/src/widgets/settingspages/HighlightingPage.hpp +++ b/src/widgets/settingspages/HighlightingPage.hpp @@ -17,9 +17,16 @@ public: HighlightingPage(); private: + enum HighlightTab { Messages = 0, Users = 1, Badges = 2, Blacklist = 3 }; + QTimer disabledUsersChangedTimer_; - void tableCellClicked(const QModelIndex &clicked, EditableModelView *view); + void tableCellClicked(const QModelIndex &clicked, EditableModelView *view, + HighlightTab tab); + void openSoundDialog(const QModelIndex &clicked, EditableModelView *view, + int soundColumn); + void openColorDialog(const QModelIndex &clicked, EditableModelView *view, + HighlightTab tab); }; } // namespace chatterino