Add ability to highlight messages based on user badges (#1704)

Co-authored-by: Paweł <zneix@zneix.eu>
Co-authored-by: 23rd <23rd@vivaldi.net>
Co-authored-by: pajlada <rasmus.karlsson@pajlada.com>
This commit is contained in:
Daniel 2021-05-02 18:08:08 -04:00 committed by GitHub
parent 6ab5b13017
commit f6d9fb2aac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 956 additions and 115 deletions

View file

@ -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:<flags>` search filter to find messages of specific types. (#2653, #2671)
- Minor: Added image links to the badge context menu. (#2667)

View file

@ -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 \

Binary file not shown.

Before

Width:  |  Height:  |  Size: 439 B

After

Width:  |  Height:  |  Size: 525 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 305 B

After

Width:  |  Height:  |  Size: 402 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 B

After

Width:  |  Height:  |  Size: 320 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 397 B

After

Width:  |  Height:  |  Size: 396 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 376 B

After

Width:  |  Height:  |  Size: 420 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 B

After

Width:  |  Height:  |  Size: 317 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 269 B

After

Width:  |  Height:  |  Size: 290 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 257 B

After

Width:  |  Height:  |  Size: 367 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 476 B

After

Width:  |  Height:  |  Size: 326 B

View file

@ -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

View file

@ -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<HighlightBadge>(5, parent)
{
}
// turn vector item into model row
HighlightBadge BadgeHighlightModel::getItemFromRow(
std::vector<QStandardItem *> &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<QColor>();
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<QStandardItem *> &row)
{
using QIconPtr = std::shared_ptr<QIcon>;
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

View file

@ -0,0 +1,36 @@
#pragma once
#include <QObject>
#include "common/SignalVectorModel.hpp"
#include "controllers/highlights/HighlightBadge.hpp"
#include "providers/twitch/TwitchBadges.hpp"
namespace chatterino {
class HighlightController;
class BadgeHighlightModel : public SignalVectorModel<HighlightBadge>
{
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<QStandardItem *> &row,
const HighlightBadge &original) override;
virtual void getRowFromItem(const HighlightBadge &item,
std::vector<QStandardItem *> &row) override;
};
} // namespace chatterino

View file

@ -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<QColor>(color))
{
}
HighlightBadge::HighlightBadge(const QString &badgeName,
const QString &displayName, bool hasAlert,
bool hasSound, const QString &soundUrl,
std::shared_ptr<QColor> 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<QColor> HighlightBadge::getColor() const
{
return this->color_;
}
} // namespace chatterino

View file

@ -0,0 +1,123 @@
#pragma once
#include "providers/twitch/TwitchBadge.hpp"
#include "util/RapidJsonSerializeQString.hpp"
#include "util/RapidjsonHelpers.hpp"
#include <QString>
#include <QUrl>
#include <pajlada/serialize.hpp>
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<QColor> 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<QColor> 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<QColor> color_;
bool isMulti_;
bool hasVersions_;
QStringList badges_;
};
}; // namespace chatterino
namespace pajlada {
template <>
struct Serialize<chatterino::HighlightBadge> {
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<chatterino::HighlightBadge> {
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

View file

@ -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<Badge> parseBadges(const QVariantMap &tags)
{
std::vector<Badge> 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)

View file

@ -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>(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>(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<EmotePtr> TwitchBadges::badge(const QString &set,
const QString &version) const
{
@ -77,4 +112,117 @@ boost::optional<EmotePtr> TwitchBadges::badge(const QString &set,
return boost::none;
}
boost::optional<EmotePtr> 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<DisplayBadge> &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<QByteArray *>(&data));
buffer.open(QIODevice::ReadOnly);
QImageReader reader(&buffer);
QImage image;
if (reader.imageCount() == 0 || !reader.read(&image))
{
return Failure;
}
auto icon = std::make_shared<QIcon>(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

View file

@ -5,8 +5,14 @@
#include <unordered_map>
#include "common/UniqueAccess.hpp"
#include "messages/Image.hpp"
#include "util/DisplayBadge.hpp"
#include "util/QStringHash.hpp"
#include "pajlada/signals/signal.hpp"
#include <shared_mutex>
namespace chatterino {
struct Emote;
@ -17,13 +23,42 @@ class Paths;
class TwitchBadges
{
public:
void loadTwitchBadges();
using QIconPtr = std::shared_ptr<QIcon>;
using ImagePtr = std::shared_ptr<Image>;
using BadgeIconCallback = std::function<void(QString, const QIconPtr)>;
public:
static TwitchBadges *instance();
// Get badge from name and version
boost::optional<EmotePtr> badge(const QString &set,
const QString &version) const;
// Get first matching badge with name, regardless of version
boost::optional<EmotePtr> badge(const QString &set) const;
void getBadgeIcon(const QString &name, BadgeIconCallback callback);
void getBadgeIcon(const DisplayBadge &badge, BadgeIconCallback callback);
void getBadgeIcons(const QList<DisplayBadge> &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<QString, QIconPtr> badgesMap_;
std::mutex queueMutex_;
std::queue<QPair<QString, BadgeIconCallback>> callbackQueue_;
std::shared_mutex loadedMutex_;
bool loaded_ = false;
UniqueAccess<
std::unordered_map<QString, std::unordered_map<QString, EmotePtr>>>
badgeSets_; // "bits": { "100": ... "500": ...

View file

@ -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<Channel *>(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<EmoteMap>())
@ -478,11 +476,6 @@ SharedAccessGuard<const TwitchChannel::StreamStatus>
return this->streamStatus_.accessConst();
}
const TwitchBadges &TwitchChannel::globalTwitchBadges() const
{
return this->globalTwitchBadges_;
}
const BttvEmotes &TwitchChannel::globalBttv() const
{
return this->globalBttv_;

View file

@ -87,7 +87,6 @@ public:
SharedAccessGuard<const StreamStatus> accessStreamStatus() const;
// Emotes
const TwitchBadges &globalTwitchBadges() const;
const BttvEmotes &globalBttv() const;
const FfzEmotes &globalFfz() const;
boost::optional<EmotePtr> 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> streamStatus_;
UniqueAccess<RoomModes> roomModes_;
// Emotes
TwitchBadges &globalTwitchBadges_;
protected:
BttvEmotes &globalBttv_;
FfzEmotes &globalFfz_;

View file

@ -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<Channel> TwitchIrcServer::createChannel(
const QString &channelName)
{
auto channel = std::shared_ptr<TwitchChannel>(new TwitchChannel(
channelName, this->twitchBadges, this->bttv, this->ffz));
auto channel = std::shared_ptr<TwitchChannel>(
new TwitchChannel(channelName, this->bttv, this->ffz));
channel->initialize();
channel->sendMessageSignal.connect(

View file

@ -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 <chrono>
#include <memory>
@ -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;

View file

@ -1083,8 +1083,8 @@ boost::optional<EmotePtr> 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;
}

View file

@ -18,6 +18,7 @@ ConcurrentSettings::ConcurrentSettings()
// NOTE: these do not get deleted
: highlightedMessages(*new SignalVector<HighlightPhrase>())
, highlightedUsers(*new SignalVector<HighlightPhrase>())
, highlightedBadges(*new SignalVector<HighlightBadge>())
, blacklistedUsers(*new SignalVector<HighlightBlacklistUser>())
, ignoredMessages(*new SignalVector<IgnorePhrase>())
, mutedChannels(*new SignalVector<QString>())
@ -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");

View file

@ -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<HighlightPhrase> &highlightedMessages;
SignalVector<HighlightPhrase> &highlightedUsers;
SignalVector<HighlightBadge> &highlightedBadges;
SignalVector<HighlightBlacklistUser> &blacklistedUsers;
SignalVector<IgnorePhrase> &ignoredMessages;
SignalVector<QString> &mutedChannels;

20
src/util/DisplayBadge.cpp Normal file
View file

@ -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

17
src/util/DisplayBadge.hpp Normal file
View file

@ -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

View file

@ -0,0 +1,71 @@
#include "BadgePickerDialog.hpp"
#include <QSizePolicy>
#include "singletons/Resources.hpp"
#include "providers/twitch/TwitchBadges.hpp"
namespace chatterino {
BadgePickerDialog::BadgePickerDialog(QList<DisplayBadge> 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<int>::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

View file

@ -0,0 +1,29 @@
#pragma once
#include "util/DisplayBadge.hpp"
#include <QDialog>
namespace chatterino {
class BadgePickerDialog : public QDialog,
public std::enable_shared_from_this<BadgePickerDialog>
{
using QIconPtr = std::shared_ptr<QIcon>;
using BadgeOpt = boost::optional<DisplayBadge>;
public:
BadgePickerDialog(QList<DisplayBadge> badges, QWidget *parent = nullptr);
BadgeOpt getSelection() const
{
return this->currentBadge_;
}
private:
QComboBox *dropdown_;
BadgeOpt currentBadge_;
};
} // namespace chatterino

View file

@ -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 <QFileDialog>
@ -27,6 +29,20 @@
namespace chatterino {
namespace {
// Add additional badges for highlights here
QList<DisplayBadge> 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<HighlightingPage> layoutCreator(this);
@ -45,7 +61,9 @@ HighlightingPage::HighlightingPage()
{
highlights.emplace<QLabel>(
"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<QLabel>(
"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<QLabel>(
"Play notification sounds and highlight messages based on "
"user badges.\n"
"Badge highlights are prioritzed under user and message "
"highlights.");
auto view = badgeHighlights
.emplace<EditableModelView>(
(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<BadgePickerDialog>(
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<HighlightModel *>(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<QColor>();
auto initial =
view->getModel()->data(clicked, Qt::DecorationRole).value<QColor>();
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;
}
}

View file

@ -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