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 @@
+
+
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);
}