diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bd5f9a4731..ea438bd6136 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Unversioned - Major: Release plugins alpha. (#5288) +- Minor: Add option to customise Moderation buttons with images. (#5369) - Bugfix: If a network request errors with 200 OK, Qt's error code is now reported instead of the HTTP status. (#5378) - Dev: Add doxygen build target. (#5377) - Dev: Make printing of strings in tests easier. (#5379) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 301808906cb..15806aae102 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -500,6 +500,8 @@ set(SOURCE_FILES util/IpcQueue.hpp util/LayoutHelper.cpp util/LayoutHelper.hpp + util/LoadPixmap.cpp + util/LoadPixmap.hpp util/RapidjsonHelpers.cpp util/RapidjsonHelpers.hpp util/RatelimitBucket.cpp @@ -631,6 +633,8 @@ set(SOURCE_FILES widgets/helper/EditableModelView.hpp widgets/helper/EffectLabel.cpp widgets/helper/EffectLabel.hpp + widgets/helper/IconDelegate.cpp + widgets/helper/IconDelegate.hpp widgets/helper/InvisibleSizeGrip.cpp widgets/helper/InvisibleSizeGrip.hpp widgets/helper/NotebookButton.cpp @@ -639,8 +643,6 @@ set(SOURCE_FILES widgets/helper/NotebookTab.hpp widgets/helper/RegExpItemDelegate.cpp widgets/helper/RegExpItemDelegate.hpp - widgets/helper/TrimRegExpValidator.cpp - widgets/helper/TrimRegExpValidator.hpp widgets/helper/ResizingTextEdit.cpp widgets/helper/ResizingTextEdit.hpp widgets/helper/ScrollbarHighlight.cpp @@ -655,6 +657,8 @@ set(SOURCE_FILES widgets/helper/TitlebarButton.hpp widgets/helper/TitlebarButtons.cpp widgets/helper/TitlebarButtons.hpp + widgets/helper/TrimRegExpValidator.cpp + widgets/helper/TrimRegExpValidator.hpp widgets/layout/FlowLayout.cpp widgets/layout/FlowLayout.hpp diff --git a/src/controllers/moderationactions/ModerationAction.cpp b/src/controllers/moderationactions/ModerationAction.cpp index 2b3a95b0642..a82d1848ccb 100644 --- a/src/controllers/moderationactions/ModerationAction.cpp +++ b/src/controllers/moderationactions/ModerationAction.cpp @@ -6,28 +6,11 @@ #include "singletons/Resources.hpp" #include +#include namespace chatterino { -// ModerationAction::ModerationAction(Image *_image, const QString &_action) -// : _isImage(true) -// , image(_image) -// , action(_action) -//{ -//} - -// ModerationAction::ModerationAction(const QString &_line1, const QString -// &_line2, -// const QString &_action) -// : _isImage(false) -// , image(nullptr) -// , line1(_line1) -// , line2(_line2) -// , action(_action) -//{ -//} - -ModerationAction::ModerationAction(const QString &action) +ModerationAction::ModerationAction(const QString &action, const QUrl &iconPath) : action_(action) { static QRegularExpression replaceRegex("[!/.]"); @@ -37,6 +20,8 @@ ModerationAction::ModerationAction(const QString &action) if (timeoutMatch.hasMatch()) { + this->type_ = Type::Timeout; + // if (multipleTimeouts > 1) { // QString line1; // QString line2; @@ -99,24 +84,19 @@ ModerationAction::ModerationAction(const QString &action) } this->line2_ = "w"; } - - // line1 = this->line1_; - // line2 = this->line2_; - // } else { - // this->_moderationActions.emplace_back(getResources().buttonTimeout, - // str); - // } } else if (action.startsWith("/ban ")) { - this->imageToLoad_ = 1; + this->type_ = Type::Ban; } else if (action.startsWith("/delete ")) { - this->imageToLoad_ = 2; + this->type_ = Type::Delete; } else { + this->type_ = Type::Custom; + QString xD = action; xD.replace(replaceRegex, ""); @@ -124,6 +104,11 @@ ModerationAction::ModerationAction(const QString &action) this->line1_ = xD.mid(0, 2); this->line2_ = xD.mid(2, 2); } + + if (iconPath.isValid()) + { + this->iconPath_ = iconPath; + } } bool ModerationAction::operator==(const ModerationAction &other) const @@ -139,19 +124,23 @@ bool ModerationAction::isImage() const const std::optional &ModerationAction::getImage() const { assertInGuiThread(); + if (this->image_.has_value()) + { + return this->image_; + } - if (this->imageToLoad_ != 0) + if (this->iconPath_.isValid()) { - if (this->imageToLoad_ == 1) - { - this->image_ = - Image::fromResourcePixmap(getResources().buttons.ban); - } - else if (this->imageToLoad_ == 2) - { - this->image_ = - Image::fromResourcePixmap(getResources().buttons.trashCan); - } + this->image_ = Image::fromUrl({this->iconPath_.toString()}); + } + else if (this->type_ == Type::Ban) + { + this->image_ = Image::fromResourcePixmap(getResources().buttons.ban); + } + else if (this->type_ == Type::Delete) + { + this->image_ = + Image::fromResourcePixmap(getResources().buttons.trashCan); } return this->image_; @@ -172,4 +161,14 @@ const QString &ModerationAction::getAction() const return this->action_; } +const QUrl &ModerationAction::iconPath() const +{ + return this->iconPath_; +} + +ModerationAction::Type ModerationAction::getType() const +{ + return this->type_; +} + } // namespace chatterino diff --git a/src/controllers/moderationactions/ModerationAction.hpp b/src/controllers/moderationactions/ModerationAction.hpp index 8fa4c9be8a2..643eaf06d62 100644 --- a/src/controllers/moderationactions/ModerationAction.hpp +++ b/src/controllers/moderationactions/ModerationAction.hpp @@ -4,6 +4,7 @@ #include #include +#include #include #include @@ -16,7 +17,32 @@ using ImagePtr = std::shared_ptr; class ModerationAction { public: - ModerationAction(const QString &action); + /** + * Type of the action, parsed from the input `action` + */ + enum class Type { + /** + * /ban + */ + Ban, + + /** + * /delete + */ + Delete, + + /** + * /timeout + */ + Timeout, + + /** + * Anything not matching the action types above + */ + Custom, + }; + + ModerationAction(const QString &action, const QUrl &iconPath = {}); bool operator==(const ModerationAction &other) const; @@ -25,13 +51,18 @@ class ModerationAction const QString &getLine1() const; const QString &getLine2() const; const QString &getAction() const; + const QUrl &iconPath() const; + Type getType() const; private: mutable std::optional image_; QString line1_; QString line2_; QString action_; - int imageToLoad_{}; + + Type type_{}; + + QUrl iconPath_; }; } // namespace chatterino @@ -46,6 +77,7 @@ struct Serialize { rapidjson::Value ret(rapidjson::kObjectType); chatterino::rj::set(ret, "pattern", value.getAction(), a); + chatterino::rj::set(ret, "icon", value.iconPath().toString(), a); return ret; } @@ -63,10 +95,12 @@ struct Deserialize { } QString pattern; - chatterino::rj::getSafe(value, "pattern", pattern); - return chatterino::ModerationAction(pattern); + QString icon; + chatterino::rj::getSafe(value, "icon", icon); + + return chatterino::ModerationAction(pattern, QUrl(icon)); } }; diff --git a/src/controllers/moderationactions/ModerationActionModel.cpp b/src/controllers/moderationactions/ModerationActionModel.cpp index d6595556d51..f7160b5896a 100644 --- a/src/controllers/moderationactions/ModerationActionModel.cpp +++ b/src/controllers/moderationactions/ModerationActionModel.cpp @@ -1,13 +1,19 @@ #include "controllers/moderationactions/ModerationActionModel.hpp" #include "controllers/moderationactions/ModerationAction.hpp" +#include "messages/Image.hpp" +#include "util/LoadPixmap.hpp" +#include "util/PostToThread.hpp" #include "util/StandardItemHelper.hpp" +#include +#include + namespace chatterino { // commandmodel ModerationActionModel ::ModerationActionModel(QObject *parent) - : SignalVectorModel(1, parent) + : SignalVectorModel(2, parent) { } @@ -15,14 +21,31 @@ ModerationActionModel ::ModerationActionModel(QObject *parent) ModerationAction ModerationActionModel::getItemFromRow( std::vector &row, const ModerationAction &original) { - return ModerationAction(row[0]->data(Qt::DisplayRole).toString()); + return ModerationAction( + row[Column::Command]->data(Qt::DisplayRole).toString(), + row[Column::Icon]->data(Qt::UserRole).toString()); } // turns a row in the model into a vector item void ModerationActionModel::getRowFromItem(const ModerationAction &item, std::vector &row) { - setStringItem(row[0], item.getAction()); + setStringItem(row[Column::Command], item.getAction()); + setFilePathItem(row[Column::Icon], item.iconPath()); + if (!item.iconPath().isEmpty()) + { + auto oImage = item.getImage(); + assert(oImage.has_value()); + if (oImage.has_value()) + { + auto url = oImage->get()->url(); + loadPixmapFromUrl(url, [row](const QPixmap &pixmap) { + postToThread([row, pixmap]() { + row[Column::Icon]->setData(pixmap, Qt::DecorationRole); + }); + }); + } + } } } // namespace chatterino diff --git a/src/controllers/moderationactions/ModerationActionModel.hpp b/src/controllers/moderationactions/ModerationActionModel.hpp index e8e51db037c..3382b437803 100644 --- a/src/controllers/moderationactions/ModerationActionModel.hpp +++ b/src/controllers/moderationactions/ModerationActionModel.hpp @@ -13,6 +13,11 @@ class ModerationActionModel : public SignalVectorModel public: explicit ModerationActionModel(QObject *parent); + enum Column { + Command = 0, + Icon = 1, + }; + protected: // turn a vector item into a model row ModerationAction getItemFromRow(std::vector &row, diff --git a/src/providers/twitch/TwitchBadges.cpp b/src/providers/twitch/TwitchBadges.cpp index 14c7475e0b9..6e2b4c4aad8 100644 --- a/src/providers/twitch/TwitchBadges.cpp +++ b/src/providers/twitch/TwitchBadges.cpp @@ -7,6 +7,7 @@ #include "messages/Image.hpp" #include "providers/twitch/api/Helix.hpp" #include "util/DisplayBadge.hpp" +#include "util/LoadPixmap.hpp" #include #include @@ -239,48 +240,20 @@ void TwitchBadges::getBadgeIcons(const QList &badges, } } -void TwitchBadges::loadEmoteImage(const QString &name, ImagePtr image, +void TwitchBadges::loadEmoteImage(const QString &name, const ImagePtr &image, BadgeIconCallback &&callback) { - auto url = image->url().string; - NetworkRequest(url) - .concurrent() - .cache() - .onSuccess([this, name, callback, url](auto result) { - 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); - - if (!reader.canRead() || reader.size().isEmpty()) - { - qCWarning(chatterinoTwitch) - << "Can't read badge image at" << url << "for" << name - << reader.errorString(); - return; - } - - QImage image = reader.read(); - if (image.isNull()) - { - qCWarning(chatterinoTwitch) - << "Failed reading badge image at" << url << "for" << name - << reader.errorString(); - return; - } + loadPixmapFromUrl(image->url(), + [this, name, callback{std::move(callback)}](auto pixmap) { + auto icon = std::make_shared(pixmap); - auto icon = std::make_shared(QPixmap::fromImage(image)); - - { - std::unique_lock lock(this->badgesMutex_); - this->badgesMap_[name] = icon; - } + { + std::unique_lock lock(this->badgesMutex_); + this->badgesMap_[name] = icon; + } - callback(name, icon); - }) - .execute(); + callback(name, icon); + }); } } // namespace chatterino diff --git a/src/providers/twitch/TwitchBadges.hpp b/src/providers/twitch/TwitchBadges.hpp index 9964030f079..fff0f5aff0b 100644 --- a/src/providers/twitch/TwitchBadges.hpp +++ b/src/providers/twitch/TwitchBadges.hpp @@ -48,7 +48,7 @@ class TwitchBadges private: void parseTwitchBadges(QJsonObject root); void loaded(); - void loadEmoteImage(const QString &name, ImagePtr image, + void loadEmoteImage(const QString &name, const ImagePtr &image, BadgeIconCallback &&callback); std::shared_mutex badgesMutex_; diff --git a/src/util/LoadPixmap.cpp b/src/util/LoadPixmap.cpp new file mode 100644 index 00000000000..99fdf95f369 --- /dev/null +++ b/src/util/LoadPixmap.cpp @@ -0,0 +1,48 @@ +#include "util/LoadPixmap.hpp" + +#include "common/network/NetworkRequest.hpp" +#include "common/network/NetworkResult.hpp" +#include "common/QLogging.hpp" + +#include +#include +#include +#include + +namespace chatterino { + +void loadPixmapFromUrl(const Url &url, std::function &&callback) +{ + NetworkRequest(url.string) + .concurrent() + .cache() + .onSuccess( + [callback = std::move(callback), url](const NetworkResult &result) { + auto data = result.getData(); + QBuffer buffer(&data); + buffer.open(QIODevice::ReadOnly); + QImageReader reader(&buffer); + + if (!reader.canRead() || reader.size().isEmpty()) + { + qCWarning(chatterinoImage) + << "Can't read image file at" << url.string << ":" + << reader.errorString(); + return; + } + + QImage image = reader.read(); + if (image.isNull()) + { + qCWarning(chatterinoImage) + << "Failed reading image at" << url.string << ":" + << reader.errorString(); + return; + } + + callback(QPixmap::fromImage(image)); + }) + .execute(); +} + +} // namespace chatterino diff --git a/src/util/LoadPixmap.hpp b/src/util/LoadPixmap.hpp new file mode 100644 index 00000000000..81fb1192144 --- /dev/null +++ b/src/util/LoadPixmap.hpp @@ -0,0 +1,15 @@ +#pragma once +#include "common/Aliases.hpp" + +#include + +namespace chatterino { + +/** + * Loads an image from url into a QPixmap. Allows for file:// protocol links. Uses cacheing. + * + * @param callback The callback you will get the pixmap by. It will be invoked concurrently with no guarantees on which thread. + */ +void loadPixmapFromUrl(const Url &url, std::function &&callback); + +} // namespace chatterino diff --git a/src/widgets/helper/IconDelegate.cpp b/src/widgets/helper/IconDelegate.cpp new file mode 100644 index 00000000000..c89037eea68 --- /dev/null +++ b/src/widgets/helper/IconDelegate.cpp @@ -0,0 +1,29 @@ +#include "widgets/helper/IconDelegate.hpp" + +#include +#include + +namespace chatterino { + +IconDelegate::IconDelegate(QObject *parent) + : QStyledItemDelegate(parent) +{ +} + +void IconDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const +{ + auto data = index.data(Qt::DecorationRole); + + if (data.type() != QVariant::Pixmap) + { + return QStyledItemDelegate::paint(painter, option, index); + } + + auto scaledRect = option.rect; + scaledRect.setWidth(scaledRect.height()); + + painter->drawPixmap(scaledRect, data.value()); +} + +} // namespace chatterino diff --git a/src/widgets/helper/IconDelegate.hpp b/src/widgets/helper/IconDelegate.hpp new file mode 100644 index 00000000000..6afd5183ae6 --- /dev/null +++ b/src/widgets/helper/IconDelegate.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include + +namespace chatterino { + +/** + * IconDelegate draws the decoration role pixmap scaled down to a square icon + */ +class IconDelegate : public QStyledItemDelegate +{ +public: + explicit IconDelegate(QObject *parent = nullptr); + + void paint(QPainter *painter, const QStyleOptionViewItem &option, + const QModelIndex &index) const override; +}; + +} // namespace chatterino diff --git a/src/widgets/settingspages/ModerationPage.cpp b/src/widgets/settingspages/ModerationPage.cpp index fce69eff00e..65ba577b1ae 100644 --- a/src/widgets/settingspages/ModerationPage.cpp +++ b/src/widgets/settingspages/ModerationPage.cpp @@ -9,12 +9,16 @@ #include "singletons/Settings.hpp" #include "util/Helpers.hpp" #include "util/LayoutCreator.hpp" +#include "util/LoadPixmap.hpp" +#include "util/PostToThread.hpp" #include "widgets/helper/EditableModelView.hpp" +#include "widgets/helper/IconDelegate.hpp" #include #include #include #include +#include #include #include #include @@ -207,11 +211,51 @@ ModerationPage::ModerationPage() ->initialized(&getSettings()->moderationActions)) .getElement(); - view->setTitles({"Actions"}); + view->setTitles({"Action", "Icon"}); view->getTableView()->horizontalHeader()->setSectionResizeMode( QHeaderView::Fixed); view->getTableView()->horizontalHeader()->setSectionResizeMode( 0, QHeaderView::Stretch); + view->getTableView()->setItemDelegateForColumn( + ModerationActionModel::Column::Icon, new IconDelegate(view)); + QObject::connect( + view->getTableView(), &QTableView::clicked, + [this, view](const QModelIndex &clicked) { + if (clicked.column() == ModerationActionModel::Column::Icon) + { + auto fileUrl = QFileDialog::getOpenFileUrl( + this, "Open Image", QUrl(), + "Image Files (*.png *.jpg *.jpeg)"); + view->getModel()->setData(clicked, fileUrl, Qt::UserRole); + view->getModel()->setData(clicked, fileUrl.fileName(), + Qt::DisplayRole); + // Clear the icon if the user canceled the dialog + if (fileUrl.isEmpty()) + { + view->getModel()->setData(clicked, QVariant(), + Qt::DecorationRole); + } + else + { + // QPointer will be cleared when view is destroyed + QPointer viewtemp = view; + + loadPixmapFromUrl( + {fileUrl.toString()}, + [clicked, view = viewtemp](const QPixmap &pixmap) { + postToThread([clicked, view, pixmap]() { + if (view.isNull()) + { + return; + } + + view->getModel()->setData( + clicked, pixmap, Qt::DecorationRole); + }); + }); + } + } + }); // We can safely ignore this signal connection since we own the view std::ignore = view->addButtonPressed.connect([] { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index bd35b79dee9..8ea086b13c7 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -42,6 +42,7 @@ set(test_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/LinkInfo.cpp ${CMAKE_CURRENT_LIST_DIR}/src/MessageLayout.cpp ${CMAKE_CURRENT_LIST_DIR}/src/QMagicEnum.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/ModerationAction.cpp # Add your new file above this line! ) diff --git a/tests/src/ModerationAction.cpp b/tests/src/ModerationAction.cpp new file mode 100644 index 00000000000..ce32fb39d02 --- /dev/null +++ b/tests/src/ModerationAction.cpp @@ -0,0 +1,112 @@ +#include "controllers/moderationactions/ModerationAction.hpp" + +#include "messages/Image.hpp" +#include "mocks/EmptyApplication.hpp" +#include "singletons/Emotes.hpp" +#include "singletons/Resources.hpp" +#include "singletons/Settings.hpp" + +#include +#include + +using namespace chatterino; + +using namespace std::chrono_literals; + +namespace { + +class MockApplication : mock::EmptyApplication +{ +public: + MockApplication() + : settings(this->settingsDir.filePath("settings.json")) + { + } + + IEmotes *getEmotes() override + { + return &this->emotes; + } + + Settings settings; + Emotes emotes; +}; + +class ModerationActionTest : public ::testing::Test +{ +public: + MockApplication mockApplication; +}; + +} // namespace + +TEST_F(ModerationActionTest, Parse) +{ + struct TestCase { + QString action; + QString iconPath; + + QString expectedLine1; + QString expectedLine2; + + std::optional expectedImage; + + ModerationAction::Type expectedType; + }; + + std::vector tests{ + { + .action = "/ban forsen", + .expectedImage = + Image::fromResourcePixmap(getResources().buttons.ban), + .expectedType = ModerationAction::Type::Ban, + }, + { + .action = "/delete {message.id}", + .expectedImage = + Image::fromResourcePixmap(getResources().buttons.trashCan), + .expectedType = ModerationAction::Type::Delete, + }, + { + .action = "/timeout {user.name} 1d", + .expectedLine1 = "1", + .expectedLine2 = "d", + .expectedType = ModerationAction::Type::Timeout, + }, + { + .action = ".timeout {user.name} 300", + .expectedLine1 = "5", + .expectedLine2 = "m", + .expectedType = ModerationAction::Type::Timeout, + }, + { + .action = "forsen", + .expectedLine1 = "fo", + .expectedLine2 = "rs", + .expectedType = ModerationAction::Type::Custom, + }, + { + .action = "forsen", + .iconPath = "file:///this-is-the-path-to-the-icon.png", + .expectedLine1 = "fo", + .expectedLine2 = "rs", + .expectedImage = + Image::fromUrl(Url{"file:///this-is-the-path-to-the-icon.png"}), + .expectedType = ModerationAction::Type::Custom, + }, + }; + + for (const auto &test : tests) + { + ModerationAction moderationAction(test.action, test.iconPath); + + EXPECT_EQ(moderationAction.getAction(), test.action); + + EXPECT_EQ(moderationAction.getLine1(), test.expectedLine1); + EXPECT_EQ(moderationAction.getLine2(), test.expectedLine2); + + EXPECT_EQ(moderationAction.getImage(), test.expectedImage); + + EXPECT_EQ(moderationAction.getType(), test.expectedType); + } +}