diff --git a/CHANGELOG.md b/CHANGELOG.md index 346590607..487893f32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,9 @@ ## Unversioned -- Minor: Improved viewer list window. +- Major: Added "Channel Filters". See https://wiki.chatterino.com/Filters/ for how they work or how to configure them. (#1748) - Major: Added Streamer Mode configuration (under `Settings -> General`), where you can select which features of Chatterino should behave differently when you are in Streamer Mode. (#2001) +- Minor: Improved viewer list window. - Minor: Added emote completion with `:` to the whispers channel (#2075) - Minor: Made the current channels emotes appear at the top of the emote picker popup. (#2057) - Minor: Added viewer list button to twitch channel header. (#1978) diff --git a/chatterino.pro b/chatterino.pro index cbd5c9879..dd4a2a670 100644 --- a/chatterino.pro +++ b/chatterino.pro @@ -131,6 +131,10 @@ SOURCES += \ src/controllers/commands/Command.cpp \ src/controllers/commands/CommandController.cpp \ src/controllers/commands/CommandModel.cpp \ + src/controllers/filters/FilterModel.cpp \ + src/controllers/filters/parser/FilterParser.cpp \ + src/controllers/filters/parser/Tokenizer.cpp \ + src/controllers/filters/parser/Types.cpp \ src/controllers/highlights/HighlightBlacklistModel.cpp \ src/controllers/highlights/HighlightModel.cpp \ src/controllers/highlights/HighlightPhrase.cpp \ @@ -232,6 +236,7 @@ SOURCES += \ src/widgets/BasePopup.cpp \ src/widgets/BaseWidget.cpp \ src/widgets/BaseWindow.cpp \ + src/widgets/dialogs/ChannelFilterEditorDialog.cpp \ src/widgets/dialogs/ColorPickerDialog.cpp \ src/widgets/dialogs/EmotePopup.cpp \ src/widgets/dialogs/IrcConnectionEditor.cpp \ @@ -240,6 +245,7 @@ SOURCES += \ src/widgets/dialogs/NotificationPopup.cpp \ src/widgets/dialogs/QualityPopup.cpp \ src/widgets/dialogs/SelectChannelDialog.cpp \ + src/widgets/dialogs/SelectChannelFiltersDialog.cpp \ src/widgets/dialogs/SettingsDialog.cpp \ src/widgets/listview/GenericItemDelegate.cpp \ src/widgets/dialogs/switcher/NewTabItem.cpp \ @@ -275,6 +281,7 @@ SOURCES += \ src/widgets/settingspages/AccountsPage.cpp \ src/widgets/settingspages/CommandPage.cpp \ src/widgets/settingspages/ExternalToolsPage.cpp \ + src/widgets/settingspages/FiltersPage.cpp \ src/widgets/settingspages/GeneralPage.cpp \ src/widgets/settingspages/HighlightingPage.cpp \ src/widgets/settingspages/IgnoresPage.cpp \ @@ -336,6 +343,12 @@ HEADERS += \ src/controllers/commands/Command.hpp \ src/controllers/commands/CommandController.hpp \ src/controllers/commands/CommandModel.hpp \ + src/controllers/filters/FilterModel.hpp \ + src/controllers/filters/FilterRecord.hpp \ + src/controllers/filters/FilterSet.hpp \ + src/controllers/filters/parser/FilterParser.hpp \ + src/controllers/filters/parser/Tokenizer.hpp \ + src/controllers/filters/parser/Types.hpp \ src/controllers/highlights/HighlightBlacklistModel.hpp \ src/controllers/highlights/HighlightBlacklistUser.hpp \ src/controllers/highlights/HighlightModel.hpp \ @@ -470,6 +483,7 @@ HEADERS += \ src/widgets/BasePopup.hpp \ src/widgets/BaseWidget.hpp \ src/widgets/BaseWindow.hpp \ + src/widgets/dialogs/ChannelFilterEditorDialog.hpp \ src/widgets/dialogs/ColorPickerDialog.hpp \ src/widgets/dialogs/EmotePopup.hpp \ src/widgets/dialogs/IrcConnectionEditor.hpp \ @@ -478,6 +492,7 @@ HEADERS += \ src/widgets/dialogs/NotificationPopup.hpp \ src/widgets/dialogs/QualityPopup.hpp \ src/widgets/dialogs/SelectChannelDialog.hpp \ + src/widgets/dialogs/SelectChannelFiltersDialog.hpp \ src/widgets/dialogs/SettingsDialog.hpp \ src/widgets/dialogs/switcher/AbstractSwitcherItem.hpp \ src/widgets/listview/GenericItemDelegate.hpp \ @@ -517,6 +532,7 @@ HEADERS += \ src/widgets/settingspages/AccountsPage.hpp \ src/widgets/settingspages/CommandPage.hpp \ src/widgets/settingspages/ExternalToolsPage.hpp \ + src/widgets/settingspages/FiltersPage.hpp \ src/widgets/settingspages/GeneralPage.hpp \ src/widgets/settingspages/HighlightingPage.hpp \ src/widgets/settingspages/IgnoresPage.hpp \ diff --git a/resources/generate_resources.py b/resources/generate_resources.py index dc0cf1844..732f7cc0a 100755 --- a/resources/generate_resources.py +++ b/resources/generate_resources.py @@ -6,6 +6,8 @@ from _generate_resources import * ignored_files = ['qt.conf', 'resources.qrc', 'resources_autogenerated.qrc', 'windows.rc', 'generate_resources.py', '_generate_resources.py'] +ignored_names = ['.gitignore', '.DS_Store'] + # to ignore all files in a/b, add a/b to ignored_directories. # this will ignore a/b/c/d.txt and a/b/xd.txt ignored_directories = ['__pycache__', 'linuxinstall'] @@ -16,7 +18,7 @@ def isNotIgnored(file): if file.parent.as_posix().startswith(ignored_directory): return False - return file.as_posix() not in ignored_files + return file.as_posix() not in ignored_files and file.name not in ignored_names all_files = sorted(list(filter(isNotIgnored, \ filter(Path.is_file, Path('.').glob('**/*'))))) @@ -24,7 +26,7 @@ image_files = sorted(list(filter(isNotIgnored, \ filter(Path.is_file, Path('.').glob('**/*.png'))))) with open('./resources_autogenerated.qrc', 'w') as out: - out.write(resources_header) + out.write(resources_header + '\n') for file in all_files: out.write(f" {file.as_posix()}\n") out.write(resources_footer) diff --git a/resources/resources_autogenerated.qrc b/resources/resources_autogenerated.qrc index 0e8c02ebf..2fdebb30c 100644 --- a/resources/resources_autogenerated.qrc +++ b/resources/resources_autogenerated.qrc @@ -1,5 +1,5 @@ - .gitignore + avatars/fourtf.png avatars/pajlada.png buttons/addSplit.png @@ -70,6 +70,7 @@ settings/commands.svg settings/emote.svg settings/externaltools.svg + settings/filters.svg settings/ignore.svg settings/keybinds.svg settings/moderation.svg diff --git a/resources/settings/filters.svg b/resources/settings/filters.svg new file mode 100644 index 000000000..efee0adc2 --- /dev/null +++ b/resources/settings/filters.svg @@ -0,0 +1,16 @@ + + + + + +image/svg+xml + + + + + + + + + + diff --git a/src/common/Channel.cpp b/src/common/Channel.cpp index 3ff35385b..8118e2e29 100644 --- a/src/common/Channel.cpp +++ b/src/common/Channel.cpp @@ -224,6 +224,14 @@ void Channel::replaceMessage(MessagePtr message, MessagePtr replacement) } } +void Channel::replaceMessage(size_t index, MessagePtr replacement) +{ + if (this->messages_.replaceItem(index, replacement)) + { + this->messageReplaced.invoke(index, replacement); + } +} + void Channel::deleteMessage(QString messageID) { LimitedQueueSnapshot snapshot = this->getMessageSnapshot(); diff --git a/src/common/Channel.hpp b/src/common/Channel.hpp index 71eac8cd4..7b2e545c3 100644 --- a/src/common/Channel.hpp +++ b/src/common/Channel.hpp @@ -72,6 +72,7 @@ public: void addOrReplaceTimeout(MessagePtr message); void disableAllMessages(); void replaceMessage(MessagePtr message, MessagePtr replacement); + void replaceMessage(size_t index, MessagePtr replacement); void deleteMessage(QString messageID); void clearMessages(); diff --git a/src/common/WindowDescriptors.cpp b/src/common/WindowDescriptors.cpp index f39a3266c..6d4585aff 100644 --- a/src/common/WindowDescriptors.cpp +++ b/src/common/WindowDescriptors.cpp @@ -68,6 +68,23 @@ namespace { return descriptor; } + const QList loadFilters(QJsonValue val) + { + QList filterIds; + + if (!val.isUndefined()) + { + const auto array = val.toArray(); + filterIds.reserve(array.size()); + for (const auto &id : array) + { + filterIds.append(QUuid::fromString(id.toString())); + } + } + + return filterIds; + } + } // namespace void SplitDescriptor::loadFromJSON(SplitDescriptor &descriptor, @@ -85,6 +102,7 @@ void SplitDescriptor::loadFromJSON(SplitDescriptor &descriptor, { descriptor.channelName_ = data.value("name").toString(); } + descriptor.filters_ = loadFilters(root.value("filters")); } WindowLayout WindowLayout::loadFromFile(const QString &path) diff --git a/src/common/WindowDescriptors.hpp b/src/common/WindowDescriptors.hpp index c0709ce08..b32d28dca 100644 --- a/src/common/WindowDescriptors.hpp +++ b/src/common/WindowDescriptors.hpp @@ -37,6 +37,8 @@ struct SplitDescriptor { // Whether "Moderation Mode" (the sword icon) is enabled in this split or not bool moderationMode_{false}; + QList filters_; + static void loadFromJSON(SplitDescriptor &descriptor, const QJsonObject &root, const QJsonObject &data); }; diff --git a/src/controllers/filters/FilterModel.cpp b/src/controllers/filters/FilterModel.cpp new file mode 100644 index 000000000..68d91ec4f --- /dev/null +++ b/src/controllers/filters/FilterModel.cpp @@ -0,0 +1,41 @@ +#include "FilterModel.hpp" + +#include "Application.hpp" +#include "singletons/Settings.hpp" +#include "util/StandardItemHelper.hpp" + +namespace chatterino { + +// commandmodel +FilterModel::FilterModel(QObject *parent) + : SignalVectorModel(3, parent) +{ +} + +// turn a vector item into a model row +FilterRecordPtr FilterModel::getItemFromRow(std::vector &row, + const FilterRecordPtr &original) +{ + auto item = + std::make_shared(row[0]->data(Qt::DisplayRole).toString(), + row[1]->data(Qt::DisplayRole).toString(), + original->getId()); // persist id + + // force 'valid' column to update + setBoolItem(row[2], item->valid(), false, false); + setStringItem(row[2], item->valid() ? "Valid" : "Show errors"); + + return item; +} + +// turns a row in the model into a vector item +void FilterModel::getRowFromItem(const FilterRecordPtr &item, + std::vector &row) +{ + setStringItem(row[0], item->getName()); + setStringItem(row[1], item->getFilter()); + setBoolItem(row[2], item->valid(), false, false); + setStringItem(row[2], item->valid() ? "Valid" : "Show errors"); +} + +} // namespace chatterino diff --git a/src/controllers/filters/FilterModel.hpp b/src/controllers/filters/FilterModel.hpp new file mode 100644 index 000000000..b900ffcc5 --- /dev/null +++ b/src/controllers/filters/FilterModel.hpp @@ -0,0 +1,26 @@ +#pragma once + +#include + +#include "common/SignalVectorModel.hpp" +#include "controllers/filters/FilterRecord.hpp" + +namespace chatterino { + +class FilterModel : public SignalVectorModel +{ +public: + explicit FilterModel(QObject *parent); + +protected: + // turn a vector item into a model row + virtual FilterRecordPtr getItemFromRow( + std::vector &row, + const FilterRecordPtr &original) override; + + // turns a row in the model into a vector item + virtual void getRowFromItem(const FilterRecordPtr &item, + std::vector &row) override; +}; + +} // namespace chatterino diff --git a/src/controllers/filters/FilterRecord.hpp b/src/controllers/filters/FilterRecord.hpp new file mode 100644 index 000000000..f0942df31 --- /dev/null +++ b/src/controllers/filters/FilterRecord.hpp @@ -0,0 +1,123 @@ +#pragma once + +#include "util/RapidJsonSerializeQString.hpp" +#include "util/RapidjsonHelpers.hpp" + +#include "controllers/filters/parser/FilterParser.hpp" +#include "controllers/filters/parser/Types.hpp" + +#include +#include +#include + +#include + +namespace chatterino { + +class FilterRecord +{ +public: + bool operator==(const FilterRecord &other) const + { + return std::tie(this->name_, this->filter_, this->id_) == + std::tie(other.name_, other.filter_, other.id_); + } + + FilterRecord(const QString &name, const QString &filter) + : name_(name) + , filter_(filter) + , id_(QUuid::createUuid()) + , parser_(std::make_unique(filter)) + { + } + + FilterRecord(const QString &name, const QString &filter, const QUuid &id) + : name_(name) + , filter_(filter) + , id_(id) + , parser_(std::make_unique(filter)) + { + } + + const QString &getName() const + { + return this->name_; + } + + const QString &getFilter() const + { + return this->filter_; + } + + const QUuid &getId() const + { + return this->id_; + } + + bool valid() const + { + return this->parser_->valid(); + } + + bool filter(const MessagePtr &message) const + { + return this->parser_->execute(message); + } + + bool filter(const filterparser::ContextMap &context) const + { + return this->parser_->execute(context); + } + +private: + QString name_; + QString filter_; + QUuid id_; + + std::unique_ptr parser_; +}; + +using FilterRecordPtr = std::shared_ptr; + +} // namespace chatterino + +namespace pajlada { + +template <> +struct Serialize { + static rapidjson::Value get(const chatterino::FilterRecordPtr &value, + rapidjson::Document::AllocatorType &a) + { + rapidjson::Value ret(rapidjson::kObjectType); + + chatterino::rj::set(ret, "name", value->getName(), a); + chatterino::rj::set(ret, "filter", value->getFilter(), a); + chatterino::rj::set(ret, "id", + value->getId().toString(QUuid::WithoutBraces), a); + + return ret; + } +}; + +template <> +struct Deserialize { + static chatterino::FilterRecordPtr get(const rapidjson::Value &value) + { + if (!value.IsObject()) + { + return std::make_shared(QString(), + QString()); + } + + QString _name, _filter, _id; + + chatterino::rj::getSafe(value, "name", _name); + chatterino::rj::getSafe(value, "filter", _filter); + chatterino::rj::getSafe(value, "id", _id); + + return std::make_shared( + _name, _filter, QUuid::fromString(_id)); + } +}; + +} // namespace pajlada diff --git a/src/controllers/filters/FilterSet.hpp b/src/controllers/filters/FilterSet.hpp new file mode 100644 index 000000000..2e4b2da9f --- /dev/null +++ b/src/controllers/filters/FilterSet.hpp @@ -0,0 +1,85 @@ +#pragma once + +#include "controllers/filters/FilterRecord.hpp" +#include "singletons/Settings.hpp" + +namespace chatterino { + +class FilterSet +{ +public: + FilterSet() + { + this->listener_ = + getCSettings().filterRecords.delayedItemsChanged.connect( + [this] { this->reloadFilters(); }); + } + + FilterSet(const QList &filterIds) + { + auto filters = getCSettings().filterRecords.readOnly(); + for (const auto &f : *filters) + { + if (filterIds.contains(f->getId())) + this->filters_.insert(f->getId(), f); + } + + this->listener_ = + getCSettings().filterRecords.delayedItemsChanged.connect( + [this] { this->reloadFilters(); }); + } + + ~FilterSet() + { + this->listener_.disconnect(); + } + + bool filter(const MessagePtr &m) const + { + if (this->filters_.size() == 0) + return true; + + filterparser::ContextMap context = filterparser::buildContextMap(m); + for (const auto &f : this->filters_.values()) + { + if (!f->valid() || !f->filter(context)) + return false; + } + + return true; + } + + const QList filterIds() const + { + return this->filters_.keys(); + } + +private: + QMap filters_; + pajlada::Signals::Connection listener_; + + void reloadFilters() + { + auto filters = getCSettings().filterRecords.readOnly(); + for (const auto &key : this->filters_.keys()) + { + bool found = false; + for (const auto &f : *filters) + { + if (f->getId() == key) + { + found = true; + this->filters_.insert(key, f); + } + } + if (!found) + { + this->filters_.remove(key); + } + } + } +}; + +using FilterSetPtr = std::shared_ptr; + +} // namespace chatterino diff --git a/src/controllers/filters/parser/FilterParser.cpp b/src/controllers/filters/parser/FilterParser.cpp new file mode 100644 index 000000000..815766279 --- /dev/null +++ b/src/controllers/filters/parser/FilterParser.cpp @@ -0,0 +1,302 @@ +#include "FilterParser.hpp" + +#include "Application.hpp" +#include "controllers/filters/parser/Types.hpp" +#include "providers/twitch/TwitchIrcServer.hpp" + +namespace filterparser { + +ContextMap buildContextMap(const MessagePtr &m) +{ + auto watchingChannel = + chatterino::getApp()->twitch.server->watchingChannel.get(); + + /* Known Identifiers + * + * author.badges + * author.color + * author.name + * author.no_color + * author.subbed + * author.sub_length + * + * channel.name + * channel.watching + * + * flags.highlighted + * flags.points_redeemed + * flags.sub_message + * flags.system_message + * flags.whisper + * + * message.content + * message.length + * + */ + + using MessageFlag = chatterino::MessageFlag; + + QStringList badges; + badges.reserve(m->badges.size()); + for (const auto &e : m->badges) + { + badges << e.key_; + } + + bool watching = !watchingChannel->getName().isEmpty() && + watchingChannel->getName().compare( + m->channelName, Qt::CaseInsensitive) == 0; + + bool subscribed = badges.contains("subscriber"); + int subLength = (subscribed && m->badgeInfos.count("subscriber") != 0) + ? m->badgeInfos.at("subscriber").toInt() + : 0; + + return { + {"author.badges", std::move(badges)}, + {"author.color", m->usernameColor}, + {"author.name", m->displayName}, + {"author.no_color", !m->usernameColor.isValid()}, + {"author.subbed", subscribed}, + {"author.sub_length", subLength}, + + {"channel.name", m->channelName}, + {"channel.watching", watching}, + + {"flags.highlighted", m->flags.has(MessageFlag::Highlighted)}, + {"flags.points_redeemed", m->flags.has(MessageFlag::RedeemedHighlight)}, + {"flags.sub_message", m->flags.has(MessageFlag::Subscription)}, + {"flags.system_message", m->flags.has(MessageFlag::System)}, + {"flags.whisper", m->flags.has(MessageFlag::Whisper)}, + + {"message.content", m->messageText}, + {"message.length", m->messageText.length()}, + }; +} + +FilterParser::FilterParser(const QString &text) + : text_(text) + , tokenizer_(Tokenizer(text)) + , builtExpression_(this->parseExpression(true)) +{ +} + +bool FilterParser::execute(const MessagePtr &message) const +{ + auto context = buildContextMap(message); + return this->execute(context); +} + +bool FilterParser::execute(const ContextMap &context) const +{ + return this->builtExpression_->execute(context).toBool(); +} + +bool FilterParser::valid() const +{ + return this->valid_; +} + +ExpressionPtr FilterParser::parseExpression(bool top) +{ + auto e = this->parseAnd(); + while (this->tokenizer_.hasNext() && + this->tokenizer_.nextTokenType() == TokenType::OR) + { + this->tokenizer_.next(); + auto nextAnd = this->parseAnd(); + e = std::make_unique(TokenType::OR, std::move(e), + std::move(nextAnd)); + } + + if (this->tokenizer_.hasNext() && top) + { + this->errorLog(QString("Unexpected token at end: %1") + .arg(this->tokenizer_.preview())); + } + + return e; +} + +ExpressionPtr FilterParser::parseAnd() +{ + auto e = this->parseUnary(); + while (this->tokenizer_.hasNext() && + this->tokenizer_.nextTokenType() == TokenType::AND) + { + this->tokenizer_.next(); + auto nextUnary = this->parseUnary(); + e = std::make_unique(TokenType::AND, std::move(e), + std::move(nextUnary)); + } + return e; +} + +ExpressionPtr FilterParser::parseUnary() +{ + if (this->tokenizer_.hasNext() && this->tokenizer_.nextTokenIsUnaryOp()) + { + this->tokenizer_.next(); + auto type = this->tokenizer_.tokenType(); + auto nextCondition = this->parseCondition(); + return std::make_unique(type, std::move(nextCondition)); + } + else + { + return this->parseCondition(); + } +} + +ExpressionPtr FilterParser::parseParentheses() +{ + // Don't call .next() before calling this method + assert(this->tokenizer_.nextTokenType() == TokenType::LP); + + this->tokenizer_.next(); + auto e = this->parseExpression(); + if (this->tokenizer_.hasNext() && + this->tokenizer_.nextTokenType() == TokenType::RP) + { + this->tokenizer_.next(); + return e; + } + else + { + const auto message = + this->tokenizer_.hasNext() + ? QString("Missing closing parentheses: got %1") + .arg(this->tokenizer_.preview()) + : "Missing closing parentheses at end of statement"; + this->errorLog(message); + + return e; + } +} + +ExpressionPtr FilterParser::parseCondition() +{ + ExpressionPtr value; + // parse expression wrapped in parentheses + if (this->tokenizer_.hasNext() && + this->tokenizer_.nextTokenType() == TokenType::LP) + { + // get value inside parentheses + value = this->parseParentheses(); + } + else + { + // get current value + value = this->parseValue(); + } + + // expecting an operator or nothing + while (this->tokenizer_.hasNext()) + { + if (this->tokenizer_.nextTokenIsBinaryOp()) + { + this->tokenizer_.next(); + auto type = this->tokenizer_.tokenType(); + auto nextValue = this->parseValue(); + return std::make_unique(type, std::move(value), + std::move(nextValue)); + } + else if (this->tokenizer_.nextTokenIsMathOp()) + { + this->tokenizer_.next(); + auto type = this->tokenizer_.tokenType(); + auto nextValue = this->parseValue(); + value = std::make_unique(type, std::move(value), + std::move(nextValue)); + } + else if (this->tokenizer_.nextTokenType() == TokenType::RP) + { + // RP, so move on + break; + } + else if (!this->tokenizer_.nextTokenIsOp()) + { + this->errorLog(QString("Expected an operator but got %1 %2") + .arg(this->tokenizer_.preview()) + .arg(tokenTypeToInfoString( + this->tokenizer_.nextTokenType()))); + break; + } + else + { + break; + } + } + + return value; +} + +ExpressionPtr FilterParser::parseValue() +{ + // parse a literal or an expression wrapped in parenthsis + if (this->tokenizer_.hasNext()) + { + auto type = this->tokenizer_.nextTokenType(); + if (type == TokenType::INT) + { + return std::make_unique( + this->tokenizer_.next().toInt(), type); + } + else if (type == TokenType::STRING) + { + auto before = this->tokenizer_.next(); + // remove quote marks + auto val = before.mid(1); + val.chop(1); + val = val.replace("\\\"", "\""); + return std::make_unique(val, type); + } + else if (type == TokenType::IDENTIFIER) + { + return std::make_unique(this->tokenizer_.next(), + type); + } + else if (type == TokenType::LP) + { + return this->parseParentheses(); + } + else + { + this->tokenizer_.next(); + this->errorLog(QString("Expected value but got %1 %2") + .arg(this->tokenizer_.current()) + .arg(tokenTypeToInfoString(type))); + } + } + else + { + this->errorLog("Unexpected end of statement"); + } + + return std::make_unique(0, TokenType::INT); +} + +void FilterParser::errorLog(const QString &text, bool expand) +{ + this->valid_ = false; + if (expand || this->parseLog_.size() == 0) + { + this->parseLog_ << text; + } +} + +const QStringList &FilterParser::errors() const +{ + return this->parseLog_; +} + +const QString FilterParser::debugString() const +{ + return this->builtExpression_->debug(); +} + +const QString FilterParser::filterString() const +{ + return this->builtExpression_->filterString(); +} + +} // namespace filterparser diff --git a/src/controllers/filters/parser/FilterParser.hpp b/src/controllers/filters/parser/FilterParser.hpp new file mode 100644 index 000000000..e292f93da --- /dev/null +++ b/src/controllers/filters/parser/FilterParser.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include "controllers/filters/parser/Tokenizer.hpp" +#include "controllers/filters/parser/Types.hpp" + +namespace filterparser { + +ContextMap buildContextMap(const MessagePtr &m); + +class FilterParser +{ +public: + FilterParser(const QString &text); + bool execute(const MessagePtr &message) const; + bool execute(const ContextMap &context) const; + bool valid() const; + + const QStringList &errors() const; + const QString debugString() const; + const QString filterString() const; + +private: + ExpressionPtr parseExpression(bool top = false); + ExpressionPtr parseAnd(); + ExpressionPtr parseUnary(); + ExpressionPtr parseParentheses(); + ExpressionPtr parseCondition(); + ExpressionPtr parseValue(); + + void errorLog(const QString &text, bool expand = false); + + QStringList parseLog_; + bool valid_ = true; + + QString text_; + Tokenizer tokenizer_; + ExpressionPtr builtExpression_; +}; +} // namespace filterparser diff --git a/src/controllers/filters/parser/Tokenizer.cpp b/src/controllers/filters/parser/Tokenizer.cpp new file mode 100644 index 000000000..14da2bb79 --- /dev/null +++ b/src/controllers/filters/parser/Tokenizer.cpp @@ -0,0 +1,174 @@ +#include "controllers/filters/parser/Tokenizer.hpp" + +namespace filterparser { + +Tokenizer::Tokenizer(const QString &text) +{ + QRegularExpressionMatchIterator i = tokenRegex.globalMatch(text); + while (i.hasNext()) + { + auto text = i.next().captured(); + this->tokens_ << text; + this->tokenTypes_ << this->tokenize(text); + } +} + +bool Tokenizer::hasNext() const +{ + return this->i_ < this->tokens_.length(); +} + +QString Tokenizer::next() +{ + this->i_++; + return this->tokens_.at(this->i_ - 1); +} + +QString Tokenizer::current() const +{ + return this->tokens_.at(this->i_ - 1); +} + +QString Tokenizer::preview() const +{ + if (this->hasNext()) + return this->tokens_.at(this->i_); + return ""; +} + +TokenType Tokenizer::nextTokenType() const +{ + return this->tokenTypes_.at(this->i_); +} + +TokenType Tokenizer::tokenType() const +{ + return this->tokenTypes_.at(this->i_ - 1); +} + +bool Tokenizer::nextTokenIsOp() const +{ + return this->typeIsOp(this->nextTokenType()); +} + +bool Tokenizer::nextTokenIsBinaryOp() const +{ + return this->typeIsBinaryOp(this->nextTokenType()); +} + +bool Tokenizer::nextTokenIsUnaryOp() const +{ + return this->typeIsUnaryOp(this->nextTokenType()); +} + +bool Tokenizer::nextTokenIsMathOp() const +{ + return this->typeIsMathOp(this->nextTokenType()); +} + +void Tokenizer::debug() +{ + if (this->i_ > 0) + { + qDebug() << "= current" << this->tokens_.at(this->i_ - 1); + qDebug() << "= current type" << this->tokenTypes_.at(this->i_ - 1); + } + else + { + qDebug() << "= no current"; + } + if (this->hasNext()) + { + qDebug() << "= next" << this->tokens_.at(this->i_); + qDebug() << "= next type" << this->tokenTypes_.at(this->i_); + } + else + { + qDebug() << "= no next"; + } +} + +const QStringList Tokenizer::allTokens() +{ + return this->tokens_; +} + +TokenType Tokenizer::tokenize(const QString &text) +{ + if (text == "&&") + return TokenType::AND; + else if (text == "||") + return TokenType::OR; + else if (text == "(") + return TokenType::LP; + else if (text == ")") + return TokenType::RP; + else if (text == "+") + return TokenType::PLUS; + else if (text == "-") + return TokenType::MINUS; + else if (text == "*") + return TokenType::MULTIPLY; + else if (text == "/") + return TokenType::DIVIDE; + else if (text == "==") + return TokenType::EQ; + else if (text == "!=") + return TokenType::NEQ; + else if (text == "%") + return TokenType::MOD; + else if (text == "<") + return TokenType::LT; + else if (text == ">") + return TokenType::GT; + else if (text == "<=") + return TokenType::LTE; + else if (text == ">=") + return TokenType::GTE; + else if (text == "contains") + return TokenType::CONTAINS; + else if (text == "startswith") + return TokenType::STARTS_WITH; + else if (text == "endswith") + return TokenType::ENDS_WITH; + else if (text == "!") + return TokenType::NOT; + else + { + if (text.front() == '"' && text.back() == '"') + return TokenType::STRING; + + if (validIdentifiersMap.keys().contains(text)) + return TokenType::IDENTIFIER; + + bool flag; + if (text.toInt(&flag); flag) + return TokenType::INT; + } + + return TokenType::NONE; +} + +bool Tokenizer::typeIsOp(TokenType token) +{ + return typeIsBinaryOp(token) || typeIsUnaryOp(token) || + typeIsMathOp(token) || token == TokenType::AND || + token == TokenType::OR; +} + +bool Tokenizer::typeIsBinaryOp(TokenType token) +{ + return token > TokenType::BINARY_START && token < TokenType::BINARY_END; +} + +bool Tokenizer::typeIsUnaryOp(TokenType token) +{ + return token > TokenType::UNARY_START && token < TokenType::UNARY_END; +} + +bool Tokenizer::typeIsMathOp(TokenType token) +{ + return token > TokenType::MATH_START && token < TokenType::MATH_END; +} + +} // namespace filterparser diff --git a/src/controllers/filters/parser/Tokenizer.hpp b/src/controllers/filters/parser/Tokenizer.hpp new file mode 100644 index 000000000..49aeff256 --- /dev/null +++ b/src/controllers/filters/parser/Tokenizer.hpp @@ -0,0 +1,65 @@ +#pragma once + +#include "controllers/filters/parser/Types.hpp" + +namespace filterparser { + +static const QMap validIdentifiersMap = { + {"author.badges", "author badges"}, + {"author.color", "author color"}, + {"author.name", "author name"}, + {"author.no_color", "author has no color?"}, + {"author.subbed", "author subscribed?"}, + {"author.sub_length", "author sub length"}, + {"channel.name", "channel name"}, + {"channel.watching", "/watching channel?"}, + {"flags.highlighted", "highlighted?"}, + {"flags.points_redeemed", "redeemed points?"}, + {"flags.sub_message", "sub/resub message?"}, + {"flags.system_message", "system message?"}, + {"flags.whisper", "whisper message?"}, + {"message.content", "message text"}, + {"message.length", "message length"}}; + +// clang-format off +static const QRegularExpression tokenRegex( + QString("\\\"((\\\\\")|[^\\\"])*\\\"|") + // String literal + QString("[\\w\\.]+|") + // Identifier or reserved keyword + QString("(<=?|>=?|!=?|==|\\|\\||&&|\\+|-|\\*|\\/|%)+|") + // Operator + QString("[\\(\\)]") // Parentheses +); +// clang-format on + +class Tokenizer +{ +public: + Tokenizer(const QString &text); + + bool hasNext() const; + QString next(); + QString current() const; + QString preview() const; + TokenType nextTokenType() const; + TokenType tokenType() const; + + bool nextTokenIsOp() const; + bool nextTokenIsBinaryOp() const; + bool nextTokenIsUnaryOp() const; + bool nextTokenIsMathOp() const; + + void debug(); + const QStringList allTokens(); + + static bool typeIsOp(TokenType token); + static bool typeIsBinaryOp(TokenType token); + static bool typeIsUnaryOp(TokenType token); + static bool typeIsMathOp(TokenType token); + +private: + int i_ = 0; + QStringList tokens_; + QList tokenTypes_; + + TokenType tokenize(const QString &text); +}; +} // namespace filterparser diff --git a/src/controllers/filters/parser/Types.cpp b/src/controllers/filters/parser/Types.cpp new file mode 100644 index 000000000..0eec5f3f8 --- /dev/null +++ b/src/controllers/filters/parser/Types.cpp @@ -0,0 +1,363 @@ +#include "controllers/filters/parser/Types.hpp" + +namespace filterparser { + +bool convertVariantTypes(QVariant &a, QVariant &b, int type) +{ + return a.convert(type) && b.convert(type); +} + +bool variantTypesMatch(QVariant &a, QVariant &b, QVariant::Type type) +{ + return a.type() == type && b.type() == type; +} + +QString tokenTypeToInfoString(TokenType type) +{ + switch (type) + { + case CONTROL_START: + case CONTROL_END: + case BINARY_START: + case BINARY_END: + case UNARY_START: + case UNARY_END: + case MATH_START: + case MATH_END: + case OTHER_START: + case NONE: + return ""; + case AND: + return ""; + case OR: + return ""; + case LP: + return ""; + case RP: + return ""; + case PLUS: + return ""; + case MINUS: + return ""; + case MULTIPLY: + return ""; + case DIVIDE: + return ""; + case MOD: + return ""; + case EQ: + return ""; + case NEQ: + return ""; + case LT: + return ""; + case GT: + return ""; + case LTE: + return ""; + case GTE: + return ""; + case CONTAINS: + return ""; + case STARTS_WITH: + return ""; + case ENDS_WITH: + return ""; + case NOT: + return ""; + case STRING: + return ""; + case INT: + return ""; + case IDENTIFIER: + return ""; + default: + return ""; + } +} + +// ValueExpression + +ValueExpression::ValueExpression(QVariant value, TokenType type) + : value_(value) + , type_(type){}; + +QVariant ValueExpression::execute(const ContextMap &context) const +{ + if (this->type_ == TokenType::IDENTIFIER) + { + return context.value(this->value_.toString()); + } + return this->value_; +} + +TokenType ValueExpression::type() +{ + return this->type_; +} + +QString ValueExpression::debug() const +{ + return this->value_.toString(); +} + +QString ValueExpression::filterString() const +{ + switch (this->type_) + { + case INT: + return QString::number(this->value_.toInt()); + case STRING: + return QString("\"%1\"").arg( + this->value_.toString().replace("\"", "\\\"")); + case IDENTIFIER: + return this->value_.toString(); + default: + return ""; + } +} + +// BinaryOperation + +BinaryOperation::BinaryOperation(TokenType op, ExpressionPtr left, + ExpressionPtr right) + : op_(op) + , left_(std::move(left)) + , right_(std::move(right)) +{ +} + +QVariant BinaryOperation::execute(const ContextMap &context) const +{ + auto left = this->left_->execute(context); + auto right = this->right_->execute(context); + switch (this->op_) + { + case PLUS: + if (left.type() == QVariant::Type::String && + right.canConvert(QMetaType::QString)) + { + return left.toString().append(right.toString()); + } + if (convertVariantTypes(left, right, QMetaType::Int)) + { + return left.toInt() + right.toInt(); + } + return 0; + case MINUS: + if (convertVariantTypes(left, right, QMetaType::Int)) + return left.toInt() - right.toInt(); + return 0; + case MULTIPLY: + if (convertVariantTypes(left, right, QMetaType::Int)) + return left.toInt() * right.toInt(); + return 0; + case DIVIDE: + if (convertVariantTypes(left, right, QMetaType::Int)) + return left.toInt() / right.toInt(); + return 0; + case MOD: + if (convertVariantTypes(left, right, QMetaType::Int)) + return left.toInt() % right.toInt(); + return 0; + case OR: + if (convertVariantTypes(left, right, QMetaType::Bool)) + return left.toBool() || right.toBool(); + return false; + case AND: + if (convertVariantTypes(left, right, QMetaType::Bool)) + return left.toBool() && right.toBool(); + return false; + case EQ: + if (variantTypesMatch(left, right, QVariant::Type::String)) + { + return left.toString().compare(right.toString(), + Qt::CaseInsensitive) == 0; + } + return left == right; + case NEQ: + if (variantTypesMatch(left, right, QVariant::Type::String)) + { + return left.toString().compare(right.toString(), + Qt::CaseInsensitive) != 0; + } + return left != right; + case LT: + if (convertVariantTypes(left, right, QMetaType::Int)) + return left.toInt() < right.toInt(); + return false; + case GT: + if (convertVariantTypes(left, right, QMetaType::Int)) + return left.toInt() > right.toInt(); + return false; + case LTE: + if (convertVariantTypes(left, right, QMetaType::Int)) + return left.toInt() <= right.toInt(); + return false; + case GTE: + if (convertVariantTypes(left, right, QMetaType::Int)) + return left.toInt() >= right.toInt(); + return false; + case CONTAINS: + if (left.type() == QVariant::Type::StringList && + right.canConvert(QMetaType::QString)) + { + return left.toStringList().contains(right.toString(), + Qt::CaseInsensitive); + } + + if (left.type() == QVariant::Type::Map && + right.canConvert(QMetaType::QString)) + { + return left.toMap().contains(right.toString()); + } + + if (left.canConvert(QMetaType::QString) && + right.canConvert(QMetaType::QString)) + { + return left.toString().contains(right.toString(), + Qt::CaseInsensitive); + } + + return false; + case STARTS_WITH: + if (left.type() == QVariant::Type::StringList && + right.canConvert(QMetaType::QString)) + { + auto list = left.toStringList(); + return !list.isEmpty() && + list.first().compare(right.toString(), + Qt::CaseInsensitive); + } + + if (left.canConvert(QMetaType::QString) && + right.canConvert(QMetaType::QString)) + { + return left.toString().startsWith(right.toString(), + Qt::CaseInsensitive); + } + + return false; + + case ENDS_WITH: + if (left.type() == QVariant::Type::StringList && + right.canConvert(QMetaType::QString)) + { + auto list = left.toStringList(); + return !list.isEmpty() && + list.last().compare(right.toString(), + Qt::CaseInsensitive); + } + + if (left.canConvert(QMetaType::QString) && + right.canConvert(QMetaType::QString)) + { + return left.toString().endsWith(right.toString(), + Qt::CaseInsensitive); + } + + return false; + default: + return false; + } +} + +QString BinaryOperation::debug() const +{ + return QString("(%1 %2 %3)") + .arg(this->left_->debug(), tokenTypeToInfoString(this->op_), + this->right_->debug()); +} + +QString BinaryOperation::filterString() const +{ + const auto opText = [&]() -> QString { + switch (this->op_) + { + case AND: + return "&&"; + case OR: + return "||"; + case PLUS: + return "+"; + case MINUS: + return "-"; + case MULTIPLY: + return "*"; + case DIVIDE: + return "/"; + case MOD: + return "%"; + case EQ: + return "=="; + case NEQ: + return "!="; + case LT: + return "<"; + case GT: + return ">"; + case LTE: + return "<="; + case GTE: + return ">="; + case CONTAINS: + return "contains"; + case STARTS_WITH: + return "startswith"; + case ENDS_WITH: + return "endswith"; + default: + return QString(); + } + }(); + + return QString("(%1) %2 (%3)") + .arg(this->left_->filterString()) + .arg(opText) + .arg(this->right_->filterString()); +} + +// UnaryOperation + +UnaryOperation::UnaryOperation(TokenType op, ExpressionPtr right) + : op_(op) + , right_(std::move(right)) +{ +} + +QVariant UnaryOperation::execute(const ContextMap &context) const +{ + auto right = this->right_->execute(context); + switch (this->op_) + { + case NOT: + if (right.canConvert()) + return !right.toBool(); + return false; + default: + return false; + } +} + +QString UnaryOperation::debug() const +{ + return QString("(%1 %2)").arg(tokenTypeToInfoString(this->op_), + this->right_->debug()); +} + +QString UnaryOperation::filterString() const +{ + const auto opText = [&]() -> QString { + switch (this->op_) + { + case NOT: + return "!"; + default: + return QString(); + } + }(); + + return QString("%1(%2)").arg(opText).arg(this->right_->filterString()); +} + +} // namespace filterparser diff --git a/src/controllers/filters/parser/Types.hpp b/src/controllers/filters/parser/Types.hpp new file mode 100644 index 000000000..ee2d481e0 --- /dev/null +++ b/src/controllers/filters/parser/Types.hpp @@ -0,0 +1,125 @@ +#pragma once + +#include "messages/Message.hpp" + +namespace filterparser { + +using MessagePtr = std::shared_ptr; +using ContextMap = QMap; + +enum TokenType { + // control + CONTROL_START = 0, + AND = 1, + OR = 2, + LP = 3, + RP = 4, + CONTROL_END = 9, + + // binary operator + BINARY_START = 10, + EQ = 11, + NEQ = 12, + LT = 13, + GT = 14, + LTE = 15, + GTE = 16, + CONTAINS = 17, + STARTS_WITH = 18, + ENDS_WITH = 19, + BINARY_END = 49, + + // unary operator + UNARY_START = 50, + NOT = 51, + UNARY_END = 99, + + // math operators + MATH_START = 100, + PLUS = 101, + MINUS = 102, + MULTIPLY = 103, + DIVIDE = 104, + MOD = 105, + MATH_END = 149, + + // other types + OTHER_START = 150, + STRING = 151, + INT = 152, + IDENTIFIER = 153, + + NONE = 200 +}; + +bool convertVariantTypes(QVariant &a, QVariant &b, int type); +QString tokenTypeToInfoString(TokenType type); + +class Expression +{ +public: + virtual ~Expression() = default; + + virtual QVariant execute(const ContextMap &) const + { + return false; + } + + virtual QString debug() const + { + return "(false)"; + } + + virtual QString filterString() const + { + return ""; + } +}; + +using ExpressionPtr = std::unique_ptr; + +class ValueExpression : public Expression +{ +public: + ValueExpression(QVariant value, TokenType type); + TokenType type(); + + QVariant execute(const ContextMap &context) const override; + QString debug() const override; + QString filterString() const override; + +private: + QVariant value_; + TokenType type_; +}; + +class BinaryOperation : public Expression +{ +public: + BinaryOperation(TokenType op, ExpressionPtr left, ExpressionPtr right); + + QVariant execute(const ContextMap &context) const override; + QString debug() const override; + QString filterString() const override; + +private: + TokenType op_; + ExpressionPtr left_; + ExpressionPtr right_; +}; + +class UnaryOperation : public Expression +{ +public: + UnaryOperation(TokenType op, ExpressionPtr right); + + QVariant execute(const ContextMap &context) const override; + QString debug() const override; + QString filterString() const override; + +private: + TokenType op_; + ExpressionPtr right_; +}; + +} // namespace filterparser diff --git a/src/messages/Message.hpp b/src/messages/Message.hpp index b5c97b553..9e780ee2f 100644 --- a/src/messages/Message.hpp +++ b/src/messages/Message.hpp @@ -1,6 +1,7 @@ #pragma once #include "common/FlagsEnum.hpp" +#include "providers/twitch/TwitchBadge.hpp" #include "widgets/helper/ScrollbarHighlight.hpp" #include @@ -58,6 +59,10 @@ struct Message : boost::noncopyable { QString displayName; QString localizedName; QString timeoutUser; + QString channelName; + QColor usernameColor; + std::vector badges; + std::map badgeInfos; std::shared_ptr highlightColor; uint32_t count = 1; std::vector> elements; diff --git a/src/messages/layouts/MessageLayout.cpp b/src/messages/layouts/MessageLayout.cpp index 9cb180155..9961e6c3d 100644 --- a/src/messages/layouts/MessageLayout.cpp +++ b/src/messages/layouts/MessageLayout.cpp @@ -115,7 +115,6 @@ bool MessageLayout::layout(int width, float scale, MessageElementFlags flags) void MessageLayout::actuallyLayout(int width, MessageElementFlags flags) { this->layoutCount_++; - auto messageFlags = this->message_->flags; if (this->flags.has(MessageLayoutFlag::Expanded) || @@ -272,6 +271,9 @@ void MessageLayout::paint(QPainter &painter, int width, int y, int messageIndex, void MessageLayout::updateBuffer(QPixmap *buffer, int /*messageIndex*/, Selection & /*selection*/) { + if (buffer->isNull()) + return; + auto app = getApp(); auto settings = getSettings(); diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index c37edd61d..722c6dd35 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -188,6 +188,8 @@ MessagePtr TwitchMessageBuilder::build() this->senderIsBroadcaster = true; } + this->message().channelName = this->channel->getName(); + this->parseMessageID(); this->parseRoomID(); @@ -531,6 +533,7 @@ void TwitchMessageBuilder::parseUsernameColor() if (const auto color = iterator.value().toString(); !color.isEmpty()) { this->usernameColor_ = QColor(color); + this->message().usernameColor = this->usernameColor_; return; } } @@ -538,6 +541,7 @@ void TwitchMessageBuilder::parseUsernameColor() if (getSettings()->colorizeNicknames && this->tags.contains("user-id")) { this->usernameColor_ = getRandomColor(this->tags.value("user-id")); + this->message().usernameColor = this->usernameColor_; } } @@ -1072,6 +1076,9 @@ void TwitchMessageBuilder::appendTwitchBadges() this->emplace(badgeEmote.get(), badge.flag_) ->setTooltip(tooltip); } + + this->message().badges = badges; + this->message().badgeInfos = badgeInfos; } void TwitchMessageBuilder::appendChatterinoBadges() diff --git a/src/singletons/Settings.cpp b/src/singletons/Settings.cpp index 2316eaef8..f860e06a0 100644 --- a/src/singletons/Settings.cpp +++ b/src/singletons/Settings.cpp @@ -21,6 +21,7 @@ ConcurrentSettings::ConcurrentSettings() , blacklistedUsers(*new SignalVector()) , ignoredMessages(*new SignalVector()) , mutedChannels(*new SignalVector()) + , filterRecords(*new SignalVector()) , moderationActions(*new SignalVector) { persist(this->highlightedMessages, "/highlighting/highlights"); @@ -28,6 +29,7 @@ ConcurrentSettings::ConcurrentSettings() persist(this->highlightedUsers, "/highlighting/users"); persist(this->ignoredMessages, "/ignore/phrases"); persist(this->mutedChannels, "/pings/muted"); + persist(this->filterRecords, "/filtering/filters"); // tagged users? persist(this->moderationActions, "/moderation/actions"); } diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index b3c1b5722..9469ed199 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -6,6 +6,7 @@ #include "BaseSettings.hpp" #include "common/Channel.hpp" #include "common/SignalVector.hpp" +#include "controllers/filters/FilterRecord.hpp" #include "controllers/highlights/HighlightPhrase.hpp" #include "controllers/moderationactions/ModerationAction.hpp" #include "singletons/Toasts.hpp" @@ -20,6 +21,7 @@ class HighlightPhrase; class HighlightBlacklistUser; class IgnorePhrase; class TaggedUser; +class FilterRecord; /// Settings which are availlable for reading on all threads. class ConcurrentSettings @@ -32,6 +34,7 @@ public: SignalVector &blacklistedUsers; SignalVector &ignoredMessages; SignalVector &mutedChannels; + SignalVector &filterRecords; //SignalVector &taggedUsers; SignalVector &moderationActions; @@ -255,6 +258,10 @@ public: BoolSetting longAlerts = {"/highlighting/alerts", false}; + /// Filtering + BoolSetting excludeUserMessagesFromFilter = { + "/filtering/excludeUserMessages", false}; + /// Logging BoolSetting enableLogging = {"/logging/enabled", false}; diff --git a/src/singletons/WindowManager.cpp b/src/singletons/WindowManager.cpp index aa9bb9ecc..adc9b8ed8 100644 --- a/src/singletons/WindowManager.cpp +++ b/src/singletons/WindowManager.cpp @@ -407,7 +407,7 @@ void WindowManager::save() // splits QJsonObject splits; - this->encodeNodeRecusively(tab->getBaseNode(), splits); + this->encodeNodeRecursively(tab->getBaseNode(), splits); tab_obj.insert("splits2", splits); tabs_arr.append(tab_obj); @@ -454,16 +454,22 @@ void WindowManager::queueSave() this->saveTimer->start(10s); } -void WindowManager::encodeNodeRecusively(SplitNode *node, QJsonObject &obj) +void WindowManager::encodeNodeRecursively(SplitNode *node, QJsonObject &obj) { switch (node->getType()) { case SplitNode::_Split: { obj.insert("type", "split"); obj.insert("moderationMode", node->getSplit()->getModerationMode()); + QJsonObject split; encodeChannel(node->getSplit()->getIndirectChannel(), split); obj.insert("data", split); + + QJsonArray filters; + encodeFilters(node->getSplit(), filters); + obj.insert("filters", filters); + obj.insert("flexh", node->getHorizontalFlex()); obj.insert("flexv", node->getVerticalFlex()); } @@ -478,7 +484,7 @@ void WindowManager::encodeNodeRecusively(SplitNode *node, QJsonObject &obj) for (const std::unique_ptr &n : node->getChildren()) { QJsonObject subObj; - this->encodeNodeRecusively(n.get(), subObj); + this->encodeNodeRecursively(n.get(), subObj); items_arr.append(subObj); } obj.insert("items", items_arr); @@ -526,6 +532,17 @@ void WindowManager::encodeChannel(IndirectChannel channel, QJsonObject &obj) } } +void WindowManager::encodeFilters(Split *split, QJsonArray &arr) +{ + assertInGuiThread(); + + auto filters = split->getFilters(); + for (const auto &f : filters) + { + arr.append(f.toString(QUuid::WithoutBraces)); + } +} + IndirectChannel WindowManager::decodeChannel(const SplitDescriptor &descriptor) { assertInGuiThread(); diff --git a/src/singletons/WindowManager.hpp b/src/singletons/WindowManager.hpp index e0c107462..e6eb63711 100644 --- a/src/singletons/WindowManager.hpp +++ b/src/singletons/WindowManager.hpp @@ -26,6 +26,7 @@ public: WindowManager(); static void encodeChannel(IndirectChannel channel, QJsonObject &obj); + static void encodeFilters(Split *split, QJsonArray &arr); static IndirectChannel decodeChannel(const SplitDescriptor &descriptor); void showSettingsDialog( @@ -89,7 +90,7 @@ public: pajlada::Signals::NoArgSignal miscUpdate; private: - void encodeNodeRecusively(SplitContainer::Node *node, QJsonObject &obj); + void encodeNodeRecursively(SplitContainer::Node *node, QJsonObject &obj); // Load window layout from the window-layout.json file WindowLayout loadWindowLayoutFromFile() const; diff --git a/src/widgets/dialogs/ChannelFilterEditorDialog.cpp b/src/widgets/dialogs/ChannelFilterEditorDialog.cpp new file mode 100644 index 000000000..98a1efbe9 --- /dev/null +++ b/src/widgets/dialogs/ChannelFilterEditorDialog.cpp @@ -0,0 +1,227 @@ +#include "ChannelFilterEditorDialog.hpp" + +#include "controllers/filters/parser/FilterParser.hpp" + +namespace chatterino { + +namespace { + const QStringList friendlyBinaryOps = { + "and", "or", "+", "-", "*", "/", + "%", "equals", "not equals", "<", ">", "<=", + ">=", "contains", "starts with", "ends with", "(nothing)"}; + const QStringList realBinaryOps = { + "&&", "||", "+", "-", "*", "/", + "%", "==", "!=", "<", ">", "<=", + ">=", "contains", "startswith", "endswith", ""}; +} // namespace + +ChannelFilterEditorDialog::ChannelFilterEditorDialog(QWidget *parent) + : QDialog(parent) +{ + auto vbox = new QVBoxLayout(this); + auto filterVbox = new QVBoxLayout; + auto buttonBox = new QHBoxLayout; + auto okButton = new QPushButton("OK"); + auto cancelButton = new QPushButton("Cancel"); + + okButton->setDefault(true); + cancelButton->setDefault(false); + + auto helpLabel = + new QLabel(QString("variable help") + .arg("https://github.com/Chatterino/chatterino2/blob/" + "master/docs/Filters.md#variables")); + helpLabel->setOpenExternalLinks(true); + + buttonBox->addWidget(helpLabel); + buttonBox->addStretch(1); + buttonBox->addWidget(cancelButton); + buttonBox->addWidget(okButton); + + QObject::connect(okButton, &QAbstractButton::clicked, [this] { + this->accept(); + this->close(); + }); + QObject::connect(cancelButton, &QAbstractButton::clicked, [this] { + this->reject(); + this->close(); + }); + + this->setWindowFlags( + (this->windowFlags() & ~(Qt::WindowContextHelpButtonHint)) | + Qt::Dialog | Qt::MSWindowsFixedSizeDialogHint); + this->setWindowTitle("Channel Filter Creator"); + + auto titleInput = new QLineEdit; + titleInput->setPlaceholderText("Filter name"); + titleInput->setText("My filter"); + + this->titleInput_ = titleInput; + filterVbox->addWidget(titleInput); + + auto left = new ChannelFilterEditorDialog::ValueSpecifier; + auto right = new ChannelFilterEditorDialog::ValueSpecifier; + auto exp = + new ChannelFilterEditorDialog::BinaryOperationSpecifier(left, right); + + this->expressionSpecifier_ = exp; + filterVbox->addLayout(exp->layout()); + vbox->addLayout(filterVbox); + vbox->addLayout(buttonBox); + + // setup default values + left->setType("Variable"); + left->setValue("message.content"); + exp->setOperation("contains"); + right->setType("Text"); + right->setValue("hello"); +} + +const QString ChannelFilterEditorDialog::getFilter() const +{ + return this->expressionSpecifier_->expressionText(); +} + +const QString ChannelFilterEditorDialog::getTitle() const +{ + return this->titleInput_->text(); +} + +ChannelFilterEditorDialog::ValueSpecifier::ValueSpecifier() +{ + this->typeCombo_ = new QComboBox; + this->varCombo_ = new QComboBox; + this->valueInput_ = new QLineEdit; + this->layout_ = new QHBoxLayout; + + this->typeCombo_->insertItems(0, {"Text", "Number", "Variable"}); + this->varCombo_->insertItems(0, filterparser::validIdentifiersMap.values()); + + this->layout_->addWidget(this->typeCombo_); + this->layout_->addWidget(this->varCombo_, 1); + this->layout_->addWidget(this->valueInput_, 1); + this->layout_->setContentsMargins(5, 5, 5, 5); + + QObject::connect( // + this->typeCombo_, QOverload::of(&QComboBox::currentIndexChanged), + [this](int index) { + const auto isNumber = (index == 1); + const auto isVariable = (index == 2); + + this->valueInput_->setVisible(!isVariable); + this->varCombo_->setVisible(isVariable); + this->valueInput_->setValidator( + isNumber ? (new QIntValidator(0, INT_MAX)) : nullptr); + + this->valueInput_->clear(); + }); + + this->varCombo_->hide(); + this->typeCombo_->setCurrentIndex(0); +} + +void ChannelFilterEditorDialog::ValueSpecifier::setEnabled(bool enabled) +{ + this->typeCombo_->setEnabled(enabled); + this->varCombo_->setEnabled(enabled); + this->valueInput_->setEnabled(enabled); +} + +void ChannelFilterEditorDialog::ValueSpecifier::setType(const QString &type) +{ + this->typeCombo_->setCurrentText(type); +} + +void ChannelFilterEditorDialog::ValueSpecifier::setValue(const QString &value) +{ + if (this->typeCombo_->currentIndex() == 2) + { + this->varCombo_->setCurrentText( + filterparser::validIdentifiersMap.value(value)); + } + else + { + this->valueInput_->setText(value); + } +} + +QLayout *ChannelFilterEditorDialog::ValueSpecifier::layout() const +{ + return this->layout_; +} + +QString ChannelFilterEditorDialog::ValueSpecifier::expressionText() +{ + switch (this->typeCombo_->currentIndex()) + { + case 0: // text + return QString("\"%1\"").arg( + this->valueInput_->text().replace("\"", "\\\"")); + case 1: // number + return this->valueInput_->text(); + case 2: // variable + return filterparser::validIdentifiersMap.key( + this->varCombo_->currentText()); + default: + return ""; + } +} + +ChannelFilterEditorDialog::BinaryOperationSpecifier::BinaryOperationSpecifier( + ExpressionSpecifier *left, ExpressionSpecifier *right) + : left_(left) + , right_(right) +{ + this->opCombo_ = new QComboBox; + this->layout_ = new QVBoxLayout; + + this->opCombo_->insertItems(0, friendlyBinaryOps); + + this->layout_->addLayout(this->left_->layout()); + this->layout_->addWidget(this->opCombo_); + this->layout_->addLayout(this->right_->layout()); + this->layout_->setContentsMargins(5, 5, 5, 5); + + QObject::connect( // + this->opCombo_, QOverload::of(&QComboBox::currentIndexChanged), + [this](int index) { + // disable if set to "(nothing)" + this->right_->setEnabled(!realBinaryOps.at(index).isEmpty()); + }); +} + +void ChannelFilterEditorDialog::BinaryOperationSpecifier::setEnabled( + bool enabled) +{ + this->opCombo_->setEnabled(enabled); + this->left_->setEnabled(enabled); + this->right_->setEnabled(enabled); +} + +void ChannelFilterEditorDialog::BinaryOperationSpecifier::setOperation( + const QString &op) +{ + this->opCombo_->setCurrentText(op); +} + +QLayout *ChannelFilterEditorDialog::BinaryOperationSpecifier::layout() const +{ + return this->layout_; +} + +QString ChannelFilterEditorDialog::BinaryOperationSpecifier::expressionText() +{ + QString opText = realBinaryOps.at(this->opCombo_->currentIndex()); + if (opText.isEmpty()) + { + return this->left_->expressionText(); + } + + return QString("(%1) %2 (%3)") + .arg(this->left_->expressionText()) + .arg(opText) + .arg(this->right_->expressionText()); +} + +} // namespace chatterino diff --git a/src/widgets/dialogs/ChannelFilterEditorDialog.hpp b/src/widgets/dialogs/ChannelFilterEditorDialog.hpp new file mode 100644 index 000000000..3fcfb6b76 --- /dev/null +++ b/src/widgets/dialogs/ChannelFilterEditorDialog.hpp @@ -0,0 +1,61 @@ +#pragma once + +namespace chatterino { +class ChannelFilterEditorDialog : public QDialog +{ +public: + ChannelFilterEditorDialog(QWidget *parent = nullptr); + + const QString getFilter() const; + const QString getTitle() const; + +private: + class ExpressionSpecifier + { + public: + virtual QLayout *layout() const = 0; + virtual QString expressionText() = 0; + virtual void setEnabled(bool enabled) = 0; + }; + + class ValueSpecifier : public ExpressionSpecifier + { + public: + ValueSpecifier(); + + QLayout *layout() const override; + QString expressionText() override; + void setEnabled(bool enabled) override; + + void setType(const QString &type); + void setValue(const QString &value); + + private: + QComboBox *typeCombo_, *varCombo_; + QHBoxLayout *layout_; + QLineEdit *valueInput_; + }; + + class BinaryOperationSpecifier : public ExpressionSpecifier + { + public: + BinaryOperationSpecifier(ExpressionSpecifier *left, + ExpressionSpecifier *right); + + QLayout *layout() const override; + QString expressionText() override; + void setEnabled(bool enabled) override; + + void setOperation(const QString &op); + + private: + QComboBox *opCombo_; + QVBoxLayout *layout_; + ExpressionSpecifier *left_, *right_; + }; + + QString startFilter_; + ExpressionSpecifier *expressionSpecifier_; + QLineEdit *titleInput_; +}; +} // namespace chatterino diff --git a/src/widgets/dialogs/SelectChannelFiltersDialog.cpp b/src/widgets/dialogs/SelectChannelFiltersDialog.cpp new file mode 100644 index 000000000..798ae6162 --- /dev/null +++ b/src/widgets/dialogs/SelectChannelFiltersDialog.cpp @@ -0,0 +1,80 @@ +#include "SelectChannelFiltersDialog.hpp" + +#include "singletons/Settings.hpp" + +namespace chatterino { + +SelectChannelFiltersDialog::SelectChannelFiltersDialog( + const QList &previousSelection, QWidget *parent) + : QDialog(parent) +{ + auto vbox = new QVBoxLayout(this); + auto itemVbox = new QVBoxLayout; + auto buttonBox = new QHBoxLayout; + auto okButton = new QPushButton("OK"); + auto cancelButton = new QPushButton("Cancel"); + + vbox->addLayout(itemVbox); + vbox->addLayout(buttonBox); + + buttonBox->addStretch(1); + buttonBox->addWidget(cancelButton); + buttonBox->addWidget(okButton); + + QObject::connect(okButton, &QAbstractButton::clicked, [this] { + this->accept(); + this->close(); + }); + QObject::connect(cancelButton, &QAbstractButton::clicked, [this] { + this->reject(); + this->close(); + }); + + this->setWindowFlags( + (this->windowFlags() & ~(Qt::WindowContextHelpButtonHint)) | + Qt::Dialog | Qt::MSWindowsFixedSizeDialogHint); + + auto availableFilters = getCSettings().filterRecords.readOnly(); + + if (availableFilters->size() == 0) + { + auto text = new QLabel("No filters defined"); + itemVbox->addWidget(text); + } + else + { + for (const auto &f : *availableFilters) + { + auto checkbox = new QCheckBox(f->getName(), this); + bool alreadySelected = previousSelection.contains(f->getId()); + checkbox->setCheckState(alreadySelected + ? Qt::CheckState::Checked + : Qt::CheckState::Unchecked); + if (alreadySelected) + { + this->currentSelection_.append(f->getId()); + } + + QObject::connect(checkbox, &QCheckBox::stateChanged, + [this, id = f->getId()](int state) { + if (state == 0) + { + this->currentSelection_.removeOne(id); + } + else + { + this->currentSelection_.append(id); + } + }); + + itemVbox->addWidget(checkbox); + } + } +} + +const QList &SelectChannelFiltersDialog::getSelection() const +{ + return this->currentSelection_; +} + +} // namespace chatterino diff --git a/src/widgets/dialogs/SelectChannelFiltersDialog.hpp b/src/widgets/dialogs/SelectChannelFiltersDialog.hpp new file mode 100644 index 000000000..7bc93ff65 --- /dev/null +++ b/src/widgets/dialogs/SelectChannelFiltersDialog.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include + +namespace chatterino { + +class SelectChannelFiltersDialog : public QDialog +{ +public: + SelectChannelFiltersDialog(const QList &previousSelection, + QWidget *parent = nullptr); + + const QList &getSelection() const; + +private: + QList currentSelection_; +}; + +} // namespace chatterino diff --git a/src/widgets/dialogs/SettingsDialog.cpp b/src/widgets/dialogs/SettingsDialog.cpp index 77475846b..89e5d3e7b 100644 --- a/src/widgets/dialogs/SettingsDialog.cpp +++ b/src/widgets/dialogs/SettingsDialog.cpp @@ -11,6 +11,7 @@ #include "widgets/settingspages/AccountsPage.hpp" #include "widgets/settingspages/CommandPage.hpp" #include "widgets/settingspages/ExternalToolsPage.hpp" +#include "widgets/settingspages/FiltersPage.hpp" #include "widgets/settingspages/GeneralPage.hpp" #include "widgets/settingspages/HighlightingPage.hpp" #include "widgets/settingspages/IgnoresPage.hpp" @@ -161,6 +162,7 @@ void SettingsDialog::addTabs() this->addTab([]{return new CommandPage;}, "Commands", ":/settings/commands.svg"); this->addTab([]{return new HighlightingPage;}, "Highlights", ":/settings/notifications.svg"); this->addTab([]{return new IgnoresPage;}, "Ignores", ":/settings/ignore.svg"); + this->addTab([]{return new FiltersPage;}, "Filters", ":/settings/filters.svg"); this->ui_.tabContainer->addSpacing(16); this->addTab([]{return new KeyboardSettingsPage;}, "Keybindings", ":/settings/keybinds.svg"); this->addTab([]{return new ModerationPage;}, "Moderation", ":/settings/moderation.svg", SettingsTabId::Moderation); diff --git a/src/widgets/helper/ChannelView.cpp b/src/widgets/helper/ChannelView.cpp index 889033269..d8f961d81 100644 --- a/src/widgets/helper/ChannelView.cpp +++ b/src/widgets/helper/ChannelView.cpp @@ -112,6 +112,7 @@ namespace { ChannelView::ChannelView(BaseWidget *parent) : BaseWidget(parent) , sourceChannel_(nullptr) + , underlyingChannel_(nullptr) , scrollBar_(new Scrollbar(this)) { this->setMouseTracking(true); @@ -557,7 +558,12 @@ ChannelPtr ChannelView::channel() return this->channel_; } -void ChannelView::setChannel(ChannelPtr channel) +bool ChannelView::showScrollbarHighlights() const +{ + return this->channel_->getType() != Channel::Type::TwitchMentions; +} + +void ChannelView::setChannel(ChannelPtr underlyingChannel) { /// Clear connections from the last channel this->channelConnections_.clear(); @@ -565,31 +571,88 @@ void ChannelView::setChannel(ChannelPtr channel) this->clearMessages(); this->scrollBar_->clearHighlights(); + /// make copy of channel and expose + this->channel_ = std::make_unique(underlyingChannel->getName(), + underlyingChannel->getType()); + + // + // Proxy channel connections + // Use a proxy channel to keep filtered messages past the time they are removed from their origin channel + // + + this->channelConnections_.push_back( + underlyingChannel->messageAppended.connect( + [this](MessagePtr &message, + boost::optional overridingFlags) { + if (this->shouldIncludeMessage(message)) + { + // When the message was received in the underlyingChannel, + // logging will be handled. Prevent duplications. + if (overridingFlags) + { + overridingFlags.get().set(MessageFlag::DoNotLog); + } + else + { + overridingFlags = MessageFlags(MessageFlag::DoNotLog); + } + + this->channel_->addMessage(message, overridingFlags); + } + })); + + this->channelConnections_.push_back( + underlyingChannel->messagesAddedAtStart.connect( + [this](std::vector &messages) { + std::vector filtered; + std::copy_if( // + messages.begin(), messages.end(), + std::back_inserter(filtered), [this](MessagePtr msg) { + return this->shouldIncludeMessage(msg); + }); + + if (!filtered.empty()) + this->channel_->addMessagesAtStart(filtered); + })); + + this->channelConnections_.push_back( + underlyingChannel->messageReplaced.connect( + [this](size_t index, MessagePtr replacement) { + if (this->shouldIncludeMessage(replacement)) + this->channel_->replaceMessage(index, replacement); + })); + + // + // Standard channel connections + // + // on new message - this->channelConnections_.push_back(channel->messageAppended.connect( + this->channelConnections_.push_back(this->channel_->messageAppended.connect( [this](MessagePtr &message, boost::optional overridingFlags) { this->messageAppended(message, overridingFlags); })); - this->channelConnections_.push_back(channel->messagesAddedAtStart.connect( - [this](std::vector &messages) { - this->messageAddedAtStart(messages); - })); + this->channelConnections_.push_back( + this->channel_->messagesAddedAtStart.connect( + [this](std::vector &messages) { + this->messageAddedAtStart(messages); + })); // on message removed this->channelConnections_.push_back( - channel->messageRemovedFromStart.connect([this](MessagePtr &message) { - this->messageRemoveFromStart(message); - })); + this->channel_->messageRemovedFromStart.connect( + [this](MessagePtr &message) { + this->messageRemoveFromStart(message); + })); // on message replaced - this->channelConnections_.push_back(channel->messageReplaced.connect( + this->channelConnections_.push_back(this->channel_->messageReplaced.connect( [this](size_t index, MessagePtr replacement) { this->messageReplaced(index, replacement); })); - auto snapshot = channel->getMessageSnapshot(); + auto snapshot = underlyingChannel->getMessageSnapshot(); for (size_t i = 0; i < snapshot.size(); i++) { @@ -604,22 +667,26 @@ void ChannelView::setChannel(ChannelPtr channel) this->lastMessageHasAlternateBackground_ = !this->lastMessageHasAlternateBackground_; - if (channel->shouldIgnoreHighlights()) + if (underlyingChannel->shouldIgnoreHighlights()) { messageLayout->flags.set(MessageLayoutFlag::IgnoreHighlights); } this->messages_.pushBack(MessageLayoutPtr(messageLayout), deleted); - this->scrollBar_->addHighlight(snapshot[i]->getScrollBarHighlight()); + if (this->showScrollbarHighlights()) + { + this->scrollBar_->addHighlight( + snapshot[i]->getScrollBarHighlight()); + } } - this->channel_ = channel; + this->underlyingChannel_ = underlyingChannel; this->queueLayout(); this->queueUpdate(); // Notifications - if (auto tc = dynamic_cast(channel.get())) + if (auto tc = dynamic_cast(underlyingChannel.get())) { this->connections_.push_back(tc->liveStatusChanged.connect([this]() { this->liveStatusChanged.invoke(); // @@ -627,6 +694,41 @@ void ChannelView::setChannel(ChannelPtr channel) } } +void ChannelView::setFilters(const QList &ids) +{ + this->channelFilters_ = std::make_shared(ids); +} + +const QList ChannelView::getFilterIds() const +{ + if (!this->channelFilters_) + { + return QList(); + } + + return this->channelFilters_->filterIds(); +} + +FilterSetPtr ChannelView::getFilterSet() const +{ + return this->channelFilters_; +} + +bool ChannelView::shouldIncludeMessage(const MessagePtr &m) const +{ + if (this->channelFilters_) + { + if (getSettings()->excludeUserMessagesFromFilter && + getApp()->accounts->twitch.getCurrent()->getUserName().compare( + m->loginName, Qt::CaseInsensitive) == 0) + return true; + + return this->channelFilters_->filter(m); + } + + return true; +} + ChannelPtr ChannelView::sourceChannel() const { return this->sourceChannel_; @@ -695,7 +797,7 @@ void ChannelView::messageAppended(MessagePtr &message, } } - if (this->channel_->getType() != Channel::Type::TwitchMentions) + if (this->showScrollbarHighlights()) { this->scrollBar_->addHighlight(message->getScrollBarHighlight()); } @@ -712,7 +814,8 @@ void ChannelView::messageAddedAtStart(std::vector &messages) /// Create message layouts for (size_t i = 0; i < messages.size(); i++) { - auto layout = new MessageLayout(messages.at(i)); + auto message = messages.at(i); + auto layout = new MessageLayout(message); // alternate color if (!this->lastMessageHasAlternateBackgroundReverse_) @@ -732,15 +835,17 @@ void ChannelView::messageAddedAtStart(std::vector &messages) this->scrollBar_->offset(qreal(messages.size())); } - /// Add highlights - std::vector highlights; - highlights.reserve(messages.size()); - for (size_t i = 0; i < messages.size(); i++) + if (this->showScrollbarHighlights()) { - highlights.push_back(messages.at(i)->getScrollBarHighlight()); - } + std::vector highlights; + highlights.reserve(messages.size()); + for (const auto &message : messages) + { + highlights.push_back(message->getScrollBarHighlight()); + } - this->scrollBar_->addHighlightsAtStart(highlights); + this->scrollBar_->addHighlightsAtStart(highlights); + } this->messageWasAdded_ = true; this->queueLayout(); @@ -855,7 +960,7 @@ MessageElementFlags ChannelView::getFlags() const { flags.set(MessageElementFlag::ModeratorTools); } - if (this->channel_ == app->twitch.server->mentionsChannel) + if (this->underlyingChannel_ == app->twitch.server->mentionsChannel) { flags.set(MessageElementFlag::ChannelName); flags.unset(MessageElementFlag::ChannelPointReward); @@ -906,7 +1011,8 @@ void ChannelView::drawMessages(QPainter &painter) bool windowFocused = this->window() == QApplication::activeWindow(); auto app = getApp(); - bool isMentions = this->channel_ == app->twitch.server->mentionsChannel; + bool isMentions = + this->underlyingChannel_ == app->twitch.server->mentionsChannel; for (size_t i = start; i < messagesSnapshot.size(); ++i) { @@ -1830,8 +1936,9 @@ void ChannelView::hideEvent(QHideEvent *) void ChannelView::showUserInfoPopup(const QString &userName) { auto *userPopup = new UserInfoPopup(getSettings()->autoCloseUserPopup); - userPopup->setData(userName, this->hasSourceChannel() ? this->sourceChannel_ - : this->channel_); + userPopup->setData(userName, this->hasSourceChannel() + ? this->sourceChannel_ + : this->underlyingChannel_); QPoint offset(int(150 * this->scale()), int(70 * this->scale())); userPopup->move(QCursor::pos() - offset); userPopup->show(); @@ -1871,9 +1978,9 @@ void ChannelView::handleLinkClick(QMouseEvent *event, const Link &link, .replace("{msg-id}", layout->getMessage()->id) .replace("{message}", layout->getMessage()->messageText); - value = - getApp()->commands->execCommand(value, this->channel_, false); - this->channel_->sendMessage(value); + value = getApp()->commands->execCommand( + value, this->underlyingChannel_, false); + this->underlyingChannel_->sendMessage(value); } break; diff --git a/src/widgets/helper/ChannelView.hpp b/src/widgets/helper/ChannelView.hpp index 1e4d764bd..951a9d264 100644 --- a/src/widgets/helper/ChannelView.hpp +++ b/src/widgets/helper/ChannelView.hpp @@ -10,6 +10,7 @@ #include #include "common/FlagsEnum.hpp" +#include "controllers/filters/FilterSet.hpp" #include "messages/Image.hpp" #include "messages/LimitedQueue.hpp" #include "messages/LimitedQueueSnapshot.hpp" @@ -76,6 +77,10 @@ public: ChannelPtr channel(); void setChannel(ChannelPtr channel_); + void setFilters(const QList &ids); + const QList getFilterIds() const; + FilterSetPtr getFilterSet() const; + ChannelPtr sourceChannel() const; void setSourceChannel(ChannelPtr sourceChannel); bool hasSourceChannel() const; @@ -178,11 +183,20 @@ private: LimitedQueueSnapshot snapshot_; ChannelPtr channel_; + ChannelPtr underlyingChannel_; ChannelPtr sourceChannel_; Scrollbar *scrollBar_; EffectLabel *goToBottom_; + FilterSetPtr channelFilters_; + + // Returns true if message should be included + bool shouldIncludeMessage(const MessagePtr &m) const; + + // Returns whether the scrollbar should have highlights + bool showScrollbarHighlights() const; + // This variable can be used to decide whether or not we should render the // "Show latest messages" button bool showingLatestMessages_ = true; diff --git a/src/widgets/helper/SearchPopup.cpp b/src/widgets/helper/SearchPopup.cpp index beaaf8057..85d94cea5 100644 --- a/src/widgets/helper/SearchPopup.cpp +++ b/src/widgets/helper/SearchPopup.cpp @@ -16,7 +16,8 @@ namespace chatterino { ChannelPtr SearchPopup::filter(const QString &text, const QString &channelName, - const LimitedQueueSnapshot &snapshot) + const LimitedQueueSnapshot &snapshot, + FilterSetPtr filterSet) { ChannelPtr channel(new Channel(channelName, Channel::Type::None)); @@ -40,6 +41,9 @@ ChannelPtr SearchPopup::filter(const QString &text, const QString &channelName, } } + if (accept && filterSet) + accept = filterSet->filter(message); + // If all predicates match, add the message to the channel if (accept) channel->addMessage(message); @@ -59,6 +63,11 @@ SearchPopup::SearchPopup() }); } +void SearchPopup::setChannelFilters(FilterSetPtr filters) +{ + this->channelFilters_ = filters; +} + void SearchPopup::setChannel(const ChannelPtr &channel) { this->channelName_ = channel->getName(); @@ -76,7 +85,8 @@ void SearchPopup::updateWindowTitle() void SearchPopup::search() { this->channelView_->setChannel(filter(this->searchInput_->text(), - this->channelName_, this->snapshot_)); + this->channelName_, this->snapshot_, + this->channelFilters_)); } void SearchPopup::initLayout() diff --git a/src/widgets/helper/SearchPopup.hpp b/src/widgets/helper/SearchPopup.hpp index bebf715af..2bc6f50c5 100644 --- a/src/widgets/helper/SearchPopup.hpp +++ b/src/widgets/helper/SearchPopup.hpp @@ -1,6 +1,7 @@ #pragma once #include "ForwardDecl.hpp" +#include "controllers/filters/FilterSet.hpp" #include "messages/LimitedQueueSnapshot.hpp" #include "messages/search/MessagePredicate.hpp" #include "widgets/BasePopup.hpp" @@ -17,6 +18,7 @@ public: SearchPopup(); virtual void setChannel(const ChannelPtr &channel); + virtual void setChannelFilters(FilterSetPtr filters); protected: virtual void updateWindowTitle(); @@ -32,12 +34,14 @@ private: * @param text the search query -- will be parsed for MessagePredicates * @param channelName name of the channel to be returned * @param snapshot list of messages to filter + * @param filterSet channel filter to apply * * @return a ChannelPtr with "channelName" and the filtered messages from * "snapshot" */ static ChannelPtr filter(const QString &text, const QString &channelName, - const LimitedQueueSnapshot &snapshot); + const LimitedQueueSnapshot &snapshot, + FilterSetPtr filterSet); /** * @brief Checks the input for tags and registers their corresponding @@ -53,6 +57,7 @@ private: QLineEdit *searchInput_{}; ChannelView *channelView_{}; QString channelName_{}; + FilterSetPtr channelFilters_; }; } // namespace chatterino diff --git a/src/widgets/settingspages/FiltersPage.cpp b/src/widgets/settingspages/FiltersPage.cpp new file mode 100644 index 000000000..769f94b5c --- /dev/null +++ b/src/widgets/settingspages/FiltersPage.cpp @@ -0,0 +1,111 @@ +#include "FiltersPage.hpp" + +#include "controllers/filters/FilterModel.hpp" +#include "singletons/Settings.hpp" +#include "util/LayoutCreator.hpp" +#include "widgets/dialogs/ChannelFilterEditorDialog.hpp" +#include "widgets/helper/EditableModelView.hpp" + +#include + +#define FILTERS_DOCUMENTATION "https://wiki.chatterino.com/Filters/" + +namespace chatterino { + +FiltersPage::FiltersPage() +{ + LayoutCreator layoutCreator(this); + auto layout = layoutCreator.setLayoutType(); + + layout.emplace( + "Selectively display messages in Splits using channel filters. Set " + "filters under a Split menu."); + EditableModelView *view = + layout + .emplace( + (new FilterModel(nullptr)) + ->initialized(&getSettings()->filterRecords)) + .getElement(); + + view->setTitles({"Name", "Filter", "Valid"}); + view->getTableView()->horizontalHeader()->setSectionResizeMode( + QHeaderView::Interactive); + view->getTableView()->horizontalHeader()->setSectionResizeMode( + 1, QHeaderView::Stretch); + + QTimer::singleShot(1, [view] { + view->getTableView()->resizeColumnsToContents(); + view->getTableView()->setColumnWidth(0, 150); + view->getTableView()->setColumnWidth(2, 125); + }); + + view->addButtonPressed.connect([] { + ChannelFilterEditorDialog d; + if (d.exec() == QDialog::Accepted) + { + getSettings()->filterRecords.append( + std::make_shared(d.getTitle(), d.getFilter())); + } + }); + + auto quickAddButton = new QPushButton("Quick Add"); + QObject::connect(quickAddButton, &QPushButton::pressed, [] { + getSettings()->filterRecords.append(std::make_shared( + "My filter", "message.content contains \"hello\"")); + }); + view->addCustomButton(quickAddButton); + + QObject::connect(view->getTableView(), &QTableView::clicked, + [this, view](const QModelIndex &clicked) { + this->tableCellClicked(clicked, view); + }); + + auto filterHelpLabel = + new QLabel(QString("filter info") + .arg(FILTERS_DOCUMENTATION)); + filterHelpLabel->setOpenExternalLinks(true); + view->addCustomButton(filterHelpLabel); + + layout.append( + this->createCheckBox("Do not filter my own messages", + getSettings()->excludeUserMessagesFromFilter)); +} + +void FiltersPage::onShow() +{ + return; +} + +void FiltersPage::tableCellClicked(const QModelIndex &clicked, + EditableModelView *view) +{ + // valid column + if (clicked.column() == 2) + { + QMessageBox popup; + + filterparser::FilterParser f( + view->getModel()->data(clicked.siblingAtColumn(1)).toString()); + + if (f.valid()) + { + popup.setIcon(QMessageBox::Icon::Information); + popup.setWindowTitle("Valid filter"); + popup.setText("Filter is valid"); + popup.setInformativeText( + QString("Parsed as:\n%1").arg(f.filterString())); + } + else + { + popup.setIcon(QMessageBox::Icon::Warning); + popup.setWindowTitle("Invalid filter"); + popup.setText(QString("Parsing errors occured:")); + popup.setInformativeText(f.errors().join("\n")); + } + + popup.exec(); + } +} + +} // namespace chatterino diff --git a/src/widgets/settingspages/FiltersPage.hpp b/src/widgets/settingspages/FiltersPage.hpp new file mode 100644 index 000000000..b6f95e01a --- /dev/null +++ b/src/widgets/settingspages/FiltersPage.hpp @@ -0,0 +1,25 @@ +#pragma once + +#include "widgets/helper/EditableModelView.hpp" +#include "widgets/settingspages/SettingsPage.hpp" + +#include + +class QVBoxLayout; + +namespace chatterino { + +class FiltersPage : public SettingsPage +{ +public: + FiltersPage(); + + void onShow() final; + +private: + void tableCellClicked(const QModelIndex &clicked, EditableModelView *view); + + QStringListModel userListModel_; +}; + +} // namespace chatterino diff --git a/src/widgets/splits/Split.cpp b/src/widgets/splits/Split.cpp index 0fd44f5a4..b9916dba2 100644 --- a/src/widgets/splits/Split.cpp +++ b/src/widgets/splits/Split.cpp @@ -21,6 +21,7 @@ #include "widgets/Window.hpp" #include "widgets/dialogs/QualityPopup.hpp" #include "widgets/dialogs/SelectChannelDialog.hpp" +#include "widgets/dialogs/SelectChannelFiltersDialog.hpp" #include "widgets/dialogs/TextInputDialog.hpp" #include "widgets/dialogs/UserInfoPopup.hpp" #include "widgets/helper/ChannelView.hpp" @@ -749,10 +750,33 @@ void Split::copyToClipboard() crossPlatformCopy(this->view_->getSelectedText()); } +void Split::setFiltersDialog() +{ + SelectChannelFiltersDialog d(this->getFilters(), this); + d.setWindowTitle("Select filters"); + + if (d.exec() == QDialog::Accepted) + { + this->setFilters(d.getSelection()); + } +} + +void Split::setFilters(const QList ids) +{ + this->view_->setFilters(ids); + this->header_->updateChannelText(); +} + +const QList Split::getFilters() const +{ + return this->view_->getFilterIds(); +} + void Split::showSearch() { SearchPopup *popup = new SearchPopup(); + popup->setChannelFilters(this->view_->getFilterSet()); popup->setAttribute(Qt::WA_DeleteOnClose); popup->setChannel(this->getChannel()); popup->show(); diff --git a/src/widgets/splits/Split.hpp b/src/widgets/splits/Split.hpp index ea2d740f5..de03fb2c8 100644 --- a/src/widgets/splits/Split.hpp +++ b/src/widgets/splits/Split.hpp @@ -53,6 +53,9 @@ public: ChannelPtr getChannel(); void setChannel(IndirectChannel newChannel); + void setFilters(const QList ids); + const QList getFilters() const; + void setModerationMode(bool value); bool getModerationMode() const; @@ -132,6 +135,7 @@ public slots: void openInStreamlink(); void openWithCustomScheme(); void copyToClipboard(); + void setFiltersDialog(); void showSearch(); void showViewerList(); void openSubPage(); diff --git a/src/widgets/splits/SplitContainer.cpp b/src/widgets/splits/SplitContainer.cpp index 1904af5ec..68e5c7c6f 100644 --- a/src/widgets/splits/SplitContainer.cpp +++ b/src/widgets/splits/SplitContainer.cpp @@ -716,6 +716,7 @@ void SplitContainer::applyFromDescriptorRecursively( auto *split = new Split(this); split->setChannel(WindowManager::decodeChannel(splitNode)); split->setModerationMode(splitNode.moderationMode_); + split->setFilters(splitNode.filters_); this->appendSplit(split); } @@ -748,6 +749,7 @@ void SplitContainer::applyFromDescriptorRecursively( auto *split = new Split(this); split->setChannel(WindowManager::decodeChannel(splitNode)); split->setModerationMode(splitNode.moderationMode_); + split->setFilters(splitNode.filters_); Node *_node = new Node(); _node->parent_ = node; diff --git a/src/widgets/splits/SplitHeader.cpp b/src/widgets/splits/SplitHeader.cpp index 4e1df165f..e9d24d407 100644 --- a/src/widgets/splits/SplitHeader.cpp +++ b/src/widgets/splits/SplitHeader.cpp @@ -327,6 +327,7 @@ std::unique_ptr SplitHeader::createMainMenu() QKeySequence("Ctrl+N")); menu->addAction("Search", this->split_, &Split::showSearch, QKeySequence("Ctrl+F")); + menu->addAction("Set filters", this->split_, &Split::setFiltersDialog); menu->addSeparator(); #ifdef USEWEBENGINE this->dropdownMenu.addAction("Start watching", this, [this] { @@ -700,6 +701,11 @@ void SplitHeader::updateChannelText() } } + if (!title.isEmpty() && this->split_->getFilters().size() != 0) + { + title += " - filtered"; + } + this->titleLabel_->setText(title.isEmpty() ? "" : title); }