Add custom image functionality for inline mod buttons. (#5369)

Co-authored-by: Rasmus Karlsson <rasmus.karlsson@pajlada.com>
This commit is contained in:
Mm2PL 2024-05-11 12:54:27 +02:00 committed by GitHub
parent 321d881bfe
commit c3b84cb4b6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 396 additions and 89 deletions

View file

@ -3,6 +3,7 @@
## Unversioned ## Unversioned
- Major: Release plugins alpha. (#5288) - 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) - 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: Add doxygen build target. (#5377)
- Dev: Make printing of strings in tests easier. (#5379) - Dev: Make printing of strings in tests easier. (#5379)

View file

@ -500,6 +500,8 @@ set(SOURCE_FILES
util/IpcQueue.hpp util/IpcQueue.hpp
util/LayoutHelper.cpp util/LayoutHelper.cpp
util/LayoutHelper.hpp util/LayoutHelper.hpp
util/LoadPixmap.cpp
util/LoadPixmap.hpp
util/RapidjsonHelpers.cpp util/RapidjsonHelpers.cpp
util/RapidjsonHelpers.hpp util/RapidjsonHelpers.hpp
util/RatelimitBucket.cpp util/RatelimitBucket.cpp
@ -631,6 +633,8 @@ set(SOURCE_FILES
widgets/helper/EditableModelView.hpp widgets/helper/EditableModelView.hpp
widgets/helper/EffectLabel.cpp widgets/helper/EffectLabel.cpp
widgets/helper/EffectLabel.hpp widgets/helper/EffectLabel.hpp
widgets/helper/IconDelegate.cpp
widgets/helper/IconDelegate.hpp
widgets/helper/InvisibleSizeGrip.cpp widgets/helper/InvisibleSizeGrip.cpp
widgets/helper/InvisibleSizeGrip.hpp widgets/helper/InvisibleSizeGrip.hpp
widgets/helper/NotebookButton.cpp widgets/helper/NotebookButton.cpp
@ -639,8 +643,6 @@ set(SOURCE_FILES
widgets/helper/NotebookTab.hpp widgets/helper/NotebookTab.hpp
widgets/helper/RegExpItemDelegate.cpp widgets/helper/RegExpItemDelegate.cpp
widgets/helper/RegExpItemDelegate.hpp widgets/helper/RegExpItemDelegate.hpp
widgets/helper/TrimRegExpValidator.cpp
widgets/helper/TrimRegExpValidator.hpp
widgets/helper/ResizingTextEdit.cpp widgets/helper/ResizingTextEdit.cpp
widgets/helper/ResizingTextEdit.hpp widgets/helper/ResizingTextEdit.hpp
widgets/helper/ScrollbarHighlight.cpp widgets/helper/ScrollbarHighlight.cpp
@ -655,6 +657,8 @@ set(SOURCE_FILES
widgets/helper/TitlebarButton.hpp widgets/helper/TitlebarButton.hpp
widgets/helper/TitlebarButtons.cpp widgets/helper/TitlebarButtons.cpp
widgets/helper/TitlebarButtons.hpp widgets/helper/TitlebarButtons.hpp
widgets/helper/TrimRegExpValidator.cpp
widgets/helper/TrimRegExpValidator.hpp
widgets/layout/FlowLayout.cpp widgets/layout/FlowLayout.cpp
widgets/layout/FlowLayout.hpp widgets/layout/FlowLayout.hpp

View file

@ -6,28 +6,11 @@
#include "singletons/Resources.hpp" #include "singletons/Resources.hpp"
#include <QRegularExpression> #include <QRegularExpression>
#include <QUrl>
namespace chatterino { namespace chatterino {
// ModerationAction::ModerationAction(Image *_image, const QString &_action) ModerationAction::ModerationAction(const QString &action, const QUrl &iconPath)
// : _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)
: action_(action) : action_(action)
{ {
static QRegularExpression replaceRegex("[!/.]"); static QRegularExpression replaceRegex("[!/.]");
@ -37,6 +20,8 @@ ModerationAction::ModerationAction(const QString &action)
if (timeoutMatch.hasMatch()) if (timeoutMatch.hasMatch())
{ {
this->type_ = Type::Timeout;
// if (multipleTimeouts > 1) { // if (multipleTimeouts > 1) {
// QString line1; // QString line1;
// QString line2; // QString line2;
@ -99,24 +84,19 @@ ModerationAction::ModerationAction(const QString &action)
} }
this->line2_ = "w"; this->line2_ = "w";
} }
// line1 = this->line1_;
// line2 = this->line2_;
// } else {
// this->_moderationActions.emplace_back(getResources().buttonTimeout,
// str);
// }
} }
else if (action.startsWith("/ban ")) else if (action.startsWith("/ban "))
{ {
this->imageToLoad_ = 1; this->type_ = Type::Ban;
} }
else if (action.startsWith("/delete ")) else if (action.startsWith("/delete "))
{ {
this->imageToLoad_ = 2; this->type_ = Type::Delete;
} }
else else
{ {
this->type_ = Type::Custom;
QString xD = action; QString xD = action;
xD.replace(replaceRegex, ""); xD.replace(replaceRegex, "");
@ -124,6 +104,11 @@ ModerationAction::ModerationAction(const QString &action)
this->line1_ = xD.mid(0, 2); this->line1_ = xD.mid(0, 2);
this->line2_ = xD.mid(2, 2); this->line2_ = xD.mid(2, 2);
} }
if (iconPath.isValid())
{
this->iconPath_ = iconPath;
}
} }
bool ModerationAction::operator==(const ModerationAction &other) const bool ModerationAction::operator==(const ModerationAction &other) const
@ -139,19 +124,23 @@ bool ModerationAction::isImage() const
const std::optional<ImagePtr> &ModerationAction::getImage() const const std::optional<ImagePtr> &ModerationAction::getImage() const
{ {
assertInGuiThread(); assertInGuiThread();
if (this->image_.has_value())
if (this->imageToLoad_ != 0)
{ {
if (this->imageToLoad_ == 1) return this->image_;
{ }
this->image_ =
Image::fromResourcePixmap(getResources().buttons.ban); if (this->iconPath_.isValid())
} {
else if (this->imageToLoad_ == 2) this->image_ = Image::fromUrl({this->iconPath_.toString()});
{ }
this->image_ = else if (this->type_ == Type::Ban)
Image::fromResourcePixmap(getResources().buttons.trashCan); {
} this->image_ = Image::fromResourcePixmap(getResources().buttons.ban);
}
else if (this->type_ == Type::Delete)
{
this->image_ =
Image::fromResourcePixmap(getResources().buttons.trashCan);
} }
return this->image_; return this->image_;
@ -172,4 +161,14 @@ const QString &ModerationAction::getAction() const
return this->action_; return this->action_;
} }
const QUrl &ModerationAction::iconPath() const
{
return this->iconPath_;
}
ModerationAction::Type ModerationAction::getType() const
{
return this->type_;
}
} // namespace chatterino } // namespace chatterino

View file

@ -4,6 +4,7 @@
#include <pajlada/serialize.hpp> #include <pajlada/serialize.hpp>
#include <QString> #include <QString>
#include <QUrl>
#include <memory> #include <memory>
#include <optional> #include <optional>
@ -16,7 +17,32 @@ using ImagePtr = std::shared_ptr<Image>;
class ModerationAction class ModerationAction
{ {
public: public:
ModerationAction(const QString &action); /**
* Type of the action, parsed from the input `action`
*/
enum class Type {
/**
* /ban <user>
*/
Ban,
/**
* /delete <msg-id>
*/
Delete,
/**
* /timeout <user> <duration>
*/
Timeout,
/**
* Anything not matching the action types above
*/
Custom,
};
ModerationAction(const QString &action, const QUrl &iconPath = {});
bool operator==(const ModerationAction &other) const; bool operator==(const ModerationAction &other) const;
@ -25,13 +51,18 @@ public:
const QString &getLine1() const; const QString &getLine1() const;
const QString &getLine2() const; const QString &getLine2() const;
const QString &getAction() const; const QString &getAction() const;
const QUrl &iconPath() const;
Type getType() const;
private: private:
mutable std::optional<ImagePtr> image_; mutable std::optional<ImagePtr> image_;
QString line1_; QString line1_;
QString line2_; QString line2_;
QString action_; QString action_;
int imageToLoad_{};
Type type_{};
QUrl iconPath_;
}; };
} // namespace chatterino } // namespace chatterino
@ -46,6 +77,7 @@ struct Serialize<chatterino::ModerationAction> {
rapidjson::Value ret(rapidjson::kObjectType); rapidjson::Value ret(rapidjson::kObjectType);
chatterino::rj::set(ret, "pattern", value.getAction(), a); chatterino::rj::set(ret, "pattern", value.getAction(), a);
chatterino::rj::set(ret, "icon", value.iconPath().toString(), a);
return ret; return ret;
} }
@ -63,10 +95,12 @@ struct Deserialize<chatterino::ModerationAction> {
} }
QString pattern; QString pattern;
chatterino::rj::getSafe(value, "pattern", 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));
} }
}; };

View file

@ -1,13 +1,19 @@
#include "controllers/moderationactions/ModerationActionModel.hpp" #include "controllers/moderationactions/ModerationActionModel.hpp"
#include "controllers/moderationactions/ModerationAction.hpp" #include "controllers/moderationactions/ModerationAction.hpp"
#include "messages/Image.hpp"
#include "util/LoadPixmap.hpp"
#include "util/PostToThread.hpp"
#include "util/StandardItemHelper.hpp" #include "util/StandardItemHelper.hpp"
#include <QIcon>
#include <QPixmap>
namespace chatterino { namespace chatterino {
// commandmodel // commandmodel
ModerationActionModel ::ModerationActionModel(QObject *parent) ModerationActionModel ::ModerationActionModel(QObject *parent)
: SignalVectorModel<ModerationAction>(1, parent) : SignalVectorModel<ModerationAction>(2, parent)
{ {
} }
@ -15,14 +21,31 @@ ModerationActionModel ::ModerationActionModel(QObject *parent)
ModerationAction ModerationActionModel::getItemFromRow( ModerationAction ModerationActionModel::getItemFromRow(
std::vector<QStandardItem *> &row, const ModerationAction &original) std::vector<QStandardItem *> &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 // turns a row in the model into a vector item
void ModerationActionModel::getRowFromItem(const ModerationAction &item, void ModerationActionModel::getRowFromItem(const ModerationAction &item,
std::vector<QStandardItem *> &row) std::vector<QStandardItem *> &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 } // namespace chatterino

View file

@ -13,6 +13,11 @@ class ModerationActionModel : public SignalVectorModel<ModerationAction>
public: public:
explicit ModerationActionModel(QObject *parent); explicit ModerationActionModel(QObject *parent);
enum Column {
Command = 0,
Icon = 1,
};
protected: protected:
// turn a vector item into a model row // turn a vector item into a model row
ModerationAction getItemFromRow(std::vector<QStandardItem *> &row, ModerationAction getItemFromRow(std::vector<QStandardItem *> &row,

View file

@ -7,6 +7,7 @@
#include "messages/Image.hpp" #include "messages/Image.hpp"
#include "providers/twitch/api/Helix.hpp" #include "providers/twitch/api/Helix.hpp"
#include "util/DisplayBadge.hpp" #include "util/DisplayBadge.hpp"
#include "util/LoadPixmap.hpp"
#include <QBuffer> #include <QBuffer>
#include <QFile> #include <QFile>
@ -239,48 +240,20 @@ void TwitchBadges::getBadgeIcons(const QList<DisplayBadge> &badges,
} }
} }
void TwitchBadges::loadEmoteImage(const QString &name, ImagePtr image, void TwitchBadges::loadEmoteImage(const QString &name, const ImagePtr &image,
BadgeIconCallback &&callback) BadgeIconCallback &&callback)
{ {
auto url = image->url().string; loadPixmapFromUrl(image->url(),
NetworkRequest(url) [this, name, callback{std::move(callback)}](auto pixmap) {
.concurrent() auto icon = std::make_shared<QIcon>(pixmap);
.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<QByteArray *>(&data)); std::unique_lock lock(this->badgesMutex_);
buffer.open(QIODevice::ReadOnly); this->badgesMap_[name] = icon;
QImageReader reader(&buffer); }
if (!reader.canRead() || reader.size().isEmpty()) callback(name, icon);
{ });
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;
}
auto icon = std::make_shared<QIcon>(QPixmap::fromImage(image));
{
std::unique_lock lock(this->badgesMutex_);
this->badgesMap_[name] = icon;
}
callback(name, icon);
})
.execute();
} }
} // namespace chatterino } // namespace chatterino

View file

@ -48,7 +48,7 @@ public:
private: private:
void parseTwitchBadges(QJsonObject root); void parseTwitchBadges(QJsonObject root);
void loaded(); void loaded();
void loadEmoteImage(const QString &name, ImagePtr image, void loadEmoteImage(const QString &name, const ImagePtr &image,
BadgeIconCallback &&callback); BadgeIconCallback &&callback);
std::shared_mutex badgesMutex_; std::shared_mutex badgesMutex_;

48
src/util/LoadPixmap.cpp Normal file
View file

@ -0,0 +1,48 @@
#include "util/LoadPixmap.hpp"
#include "common/network/NetworkRequest.hpp"
#include "common/network/NetworkResult.hpp"
#include "common/QLogging.hpp"
#include <QBuffer>
#include <QImageReader>
#include <QLoggingCategory>
#include <QPixmap>
namespace chatterino {
void loadPixmapFromUrl(const Url &url, std::function<void(QPixmap)> &&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

15
src/util/LoadPixmap.hpp Normal file
View file

@ -0,0 +1,15 @@
#pragma once
#include "common/Aliases.hpp"
#include <QPixmap>
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<void(QPixmap)> &&callback);
} // namespace chatterino

View file

@ -0,0 +1,29 @@
#include "widgets/helper/IconDelegate.hpp"
#include <QPainter>
#include <QVariant>
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<QPixmap>());
}
} // namespace chatterino

View file

@ -0,0 +1,19 @@
#pragma once
#include <QStyledItemDelegate>
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

View file

@ -9,12 +9,16 @@
#include "singletons/Settings.hpp" #include "singletons/Settings.hpp"
#include "util/Helpers.hpp" #include "util/Helpers.hpp"
#include "util/LayoutCreator.hpp" #include "util/LayoutCreator.hpp"
#include "util/LoadPixmap.hpp"
#include "util/PostToThread.hpp"
#include "widgets/helper/EditableModelView.hpp" #include "widgets/helper/EditableModelView.hpp"
#include "widgets/helper/IconDelegate.hpp"
#include <QFileDialog> #include <QFileDialog>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QHeaderView> #include <QHeaderView>
#include <QLabel> #include <QLabel>
#include <QPixmap>
#include <QPushButton> #include <QPushButton>
#include <QTableView> #include <QTableView>
#include <QtConcurrent/QtConcurrent> #include <QtConcurrent/QtConcurrent>
@ -207,11 +211,51 @@ ModerationPage::ModerationPage()
->initialized(&getSettings()->moderationActions)) ->initialized(&getSettings()->moderationActions))
.getElement(); .getElement();
view->setTitles({"Actions"}); view->setTitles({"Action", "Icon"});
view->getTableView()->horizontalHeader()->setSectionResizeMode( view->getTableView()->horizontalHeader()->setSectionResizeMode(
QHeaderView::Fixed); QHeaderView::Fixed);
view->getTableView()->horizontalHeader()->setSectionResizeMode( view->getTableView()->horizontalHeader()->setSectionResizeMode(
0, QHeaderView::Stretch); 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<EditableModelView> 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 // We can safely ignore this signal connection since we own the view
std::ignore = view->addButtonPressed.connect([] { std::ignore = view->addButtonPressed.connect([] {

View file

@ -42,6 +42,7 @@ set(test_SOURCES
${CMAKE_CURRENT_LIST_DIR}/src/LinkInfo.cpp ${CMAKE_CURRENT_LIST_DIR}/src/LinkInfo.cpp
${CMAKE_CURRENT_LIST_DIR}/src/MessageLayout.cpp ${CMAKE_CURRENT_LIST_DIR}/src/MessageLayout.cpp
${CMAKE_CURRENT_LIST_DIR}/src/QMagicEnum.cpp ${CMAKE_CURRENT_LIST_DIR}/src/QMagicEnum.cpp
${CMAKE_CURRENT_LIST_DIR}/src/ModerationAction.cpp
# Add your new file above this line! # Add your new file above this line!
) )

View file

@ -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 <gtest/gtest.h>
#include <QString>
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<ImagePtr> expectedImage;
ModerationAction::Type expectedType;
};
std::vector<TestCase> 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);
}
}