Advanced channel filters (#1748)

Adds custom channel filters complete with their own mini-language. Filters can be created in settings, and applied by clicking the three dots to open the Split menu and selecting "Set filters".
This commit is contained in:
dnsge 2020-10-18 15:16:56 +02:00 committed by Rasmus Karlsson
parent 812cbdf4f9
commit 4199a01b96
41 changed files with 2189 additions and 43 deletions

View file

@ -2,8 +2,9 @@
## Unversioned ## 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) - 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: 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: 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) - Minor: Added viewer list button to twitch channel header. (#1978)

View file

@ -131,6 +131,10 @@ SOURCES += \
src/controllers/commands/Command.cpp \ src/controllers/commands/Command.cpp \
src/controllers/commands/CommandController.cpp \ src/controllers/commands/CommandController.cpp \
src/controllers/commands/CommandModel.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/HighlightBlacklistModel.cpp \
src/controllers/highlights/HighlightModel.cpp \ src/controllers/highlights/HighlightModel.cpp \
src/controllers/highlights/HighlightPhrase.cpp \ src/controllers/highlights/HighlightPhrase.cpp \
@ -232,6 +236,7 @@ SOURCES += \
src/widgets/BasePopup.cpp \ src/widgets/BasePopup.cpp \
src/widgets/BaseWidget.cpp \ src/widgets/BaseWidget.cpp \
src/widgets/BaseWindow.cpp \ src/widgets/BaseWindow.cpp \
src/widgets/dialogs/ChannelFilterEditorDialog.cpp \
src/widgets/dialogs/ColorPickerDialog.cpp \ src/widgets/dialogs/ColorPickerDialog.cpp \
src/widgets/dialogs/EmotePopup.cpp \ src/widgets/dialogs/EmotePopup.cpp \
src/widgets/dialogs/IrcConnectionEditor.cpp \ src/widgets/dialogs/IrcConnectionEditor.cpp \
@ -240,6 +245,7 @@ SOURCES += \
src/widgets/dialogs/NotificationPopup.cpp \ src/widgets/dialogs/NotificationPopup.cpp \
src/widgets/dialogs/QualityPopup.cpp \ src/widgets/dialogs/QualityPopup.cpp \
src/widgets/dialogs/SelectChannelDialog.cpp \ src/widgets/dialogs/SelectChannelDialog.cpp \
src/widgets/dialogs/SelectChannelFiltersDialog.cpp \
src/widgets/dialogs/SettingsDialog.cpp \ src/widgets/dialogs/SettingsDialog.cpp \
src/widgets/listview/GenericItemDelegate.cpp \ src/widgets/listview/GenericItemDelegate.cpp \
src/widgets/dialogs/switcher/NewTabItem.cpp \ src/widgets/dialogs/switcher/NewTabItem.cpp \
@ -275,6 +281,7 @@ SOURCES += \
src/widgets/settingspages/AccountsPage.cpp \ src/widgets/settingspages/AccountsPage.cpp \
src/widgets/settingspages/CommandPage.cpp \ src/widgets/settingspages/CommandPage.cpp \
src/widgets/settingspages/ExternalToolsPage.cpp \ src/widgets/settingspages/ExternalToolsPage.cpp \
src/widgets/settingspages/FiltersPage.cpp \
src/widgets/settingspages/GeneralPage.cpp \ src/widgets/settingspages/GeneralPage.cpp \
src/widgets/settingspages/HighlightingPage.cpp \ src/widgets/settingspages/HighlightingPage.cpp \
src/widgets/settingspages/IgnoresPage.cpp \ src/widgets/settingspages/IgnoresPage.cpp \
@ -336,6 +343,12 @@ HEADERS += \
src/controllers/commands/Command.hpp \ src/controllers/commands/Command.hpp \
src/controllers/commands/CommandController.hpp \ src/controllers/commands/CommandController.hpp \
src/controllers/commands/CommandModel.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/HighlightBlacklistModel.hpp \
src/controllers/highlights/HighlightBlacklistUser.hpp \ src/controllers/highlights/HighlightBlacklistUser.hpp \
src/controllers/highlights/HighlightModel.hpp \ src/controllers/highlights/HighlightModel.hpp \
@ -470,6 +483,7 @@ HEADERS += \
src/widgets/BasePopup.hpp \ src/widgets/BasePopup.hpp \
src/widgets/BaseWidget.hpp \ src/widgets/BaseWidget.hpp \
src/widgets/BaseWindow.hpp \ src/widgets/BaseWindow.hpp \
src/widgets/dialogs/ChannelFilterEditorDialog.hpp \
src/widgets/dialogs/ColorPickerDialog.hpp \ src/widgets/dialogs/ColorPickerDialog.hpp \
src/widgets/dialogs/EmotePopup.hpp \ src/widgets/dialogs/EmotePopup.hpp \
src/widgets/dialogs/IrcConnectionEditor.hpp \ src/widgets/dialogs/IrcConnectionEditor.hpp \
@ -478,6 +492,7 @@ HEADERS += \
src/widgets/dialogs/NotificationPopup.hpp \ src/widgets/dialogs/NotificationPopup.hpp \
src/widgets/dialogs/QualityPopup.hpp \ src/widgets/dialogs/QualityPopup.hpp \
src/widgets/dialogs/SelectChannelDialog.hpp \ src/widgets/dialogs/SelectChannelDialog.hpp \
src/widgets/dialogs/SelectChannelFiltersDialog.hpp \
src/widgets/dialogs/SettingsDialog.hpp \ src/widgets/dialogs/SettingsDialog.hpp \
src/widgets/dialogs/switcher/AbstractSwitcherItem.hpp \ src/widgets/dialogs/switcher/AbstractSwitcherItem.hpp \
src/widgets/listview/GenericItemDelegate.hpp \ src/widgets/listview/GenericItemDelegate.hpp \
@ -517,6 +532,7 @@ HEADERS += \
src/widgets/settingspages/AccountsPage.hpp \ src/widgets/settingspages/AccountsPage.hpp \
src/widgets/settingspages/CommandPage.hpp \ src/widgets/settingspages/CommandPage.hpp \
src/widgets/settingspages/ExternalToolsPage.hpp \ src/widgets/settingspages/ExternalToolsPage.hpp \
src/widgets/settingspages/FiltersPage.hpp \
src/widgets/settingspages/GeneralPage.hpp \ src/widgets/settingspages/GeneralPage.hpp \
src/widgets/settingspages/HighlightingPage.hpp \ src/widgets/settingspages/HighlightingPage.hpp \
src/widgets/settingspages/IgnoresPage.hpp \ src/widgets/settingspages/IgnoresPage.hpp \

View file

@ -6,6 +6,8 @@ from _generate_resources import *
ignored_files = ['qt.conf', 'resources.qrc', 'resources_autogenerated.qrc', 'windows.rc', ignored_files = ['qt.conf', 'resources.qrc', 'resources_autogenerated.qrc', 'windows.rc',
'generate_resources.py', '_generate_resources.py'] 'generate_resources.py', '_generate_resources.py']
ignored_names = ['.gitignore', '.DS_Store']
# to ignore all files in a/b, add a/b to ignored_directories. # 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 # this will ignore a/b/c/d.txt and a/b/xd.txt
ignored_directories = ['__pycache__', 'linuxinstall'] ignored_directories = ['__pycache__', 'linuxinstall']
@ -16,7 +18,7 @@ def isNotIgnored(file):
if file.parent.as_posix().startswith(ignored_directory): if file.parent.as_posix().startswith(ignored_directory):
return False 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, \ all_files = sorted(list(filter(isNotIgnored, \
filter(Path.is_file, Path('.').glob('**/*'))))) filter(Path.is_file, Path('.').glob('**/*')))))
@ -24,7 +26,7 @@ image_files = sorted(list(filter(isNotIgnored, \
filter(Path.is_file, Path('.').glob('**/*.png'))))) filter(Path.is_file, Path('.').glob('**/*.png')))))
with open('./resources_autogenerated.qrc', 'w') as out: with open('./resources_autogenerated.qrc', 'w') as out:
out.write(resources_header) out.write(resources_header + '\n')
for file in all_files: for file in all_files:
out.write(f" <file>{file.as_posix()}</file>\n") out.write(f" <file>{file.as_posix()}</file>\n")
out.write(resources_footer) out.write(resources_footer)

View file

@ -1,5 +1,5 @@
<RCC> <RCC>
<qresource prefix="/"> <file>.gitignore</file> <qresource prefix="/">
<file>avatars/fourtf.png</file> <file>avatars/fourtf.png</file>
<file>avatars/pajlada.png</file> <file>avatars/pajlada.png</file>
<file>buttons/addSplit.png</file> <file>buttons/addSplit.png</file>
@ -70,6 +70,7 @@
<file>settings/commands.svg</file> <file>settings/commands.svg</file>
<file>settings/emote.svg</file> <file>settings/emote.svg</file>
<file>settings/externaltools.svg</file> <file>settings/externaltools.svg</file>
<file>settings/filters.svg</file>
<file>settings/ignore.svg</file> <file>settings/ignore.svg</file>
<file>settings/keybinds.svg</file> <file>settings/keybinds.svg</file>
<file>settings/moderation.svg</file> <file>settings/moderation.svg</file>

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg width="200" height="200" version="1.1" viewBox="0 0 52.916665 52.916668" xmlns="http://www.w3.org/2000/svg" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
<metadata>
<rdf:RDF>
<cc:Work rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type rdf:resource="http://purl.org/dc/dcmitype/StillImage"/>
<dc:title/>
</cc:Work>
</rdf:RDF>
</metadata>
<g transform="matrix(1.0131 0 0 1.1431 -.34702 -3.8339)" fill="none" shape-rendering="auto" stroke="#fff" stroke-linejoin="round">
<path d="m1.3325 8.3773 12.563 14.985 8.2998 9.8998v11.278l8.5255-3.7176v-7.56l8.2998-9.8998 12.563-14.985h-25.126z" color="#000000" color-rendering="auto" dominant-baseline="auto" image-rendering="auto" solid-color="#000000" stop-color="#000000" stroke-width="1.3229" style="font-feature-settings:normal;font-variant-alternates:normal;font-variant-caps:normal;font-variant-east-asian:normal;font-variant-ligatures:normal;font-variant-numeric:normal;font-variant-position:normal;font-variation-settings:normal;inline-size:0;isolation:auto;mix-blend-mode:normal;shape-margin:0;shape-padding:0;text-decoration-color:#000000;text-decoration-line:none;text-decoration-style:solid;text-indent:0;text-orientation:mixed;text-transform:none;white-space:normal"/>
<path d="m1.1964 6.1085 0.20934 0.25005 12.469 14.871 8.2012 9.7809v15.879l8.7652-3.8224v-12.057l8.2012-9.7809 12.677-15.121h-25.261zm0.65129 0.30432h49.221l-12.258 14.621-8.2729 9.8662v11.967l-8.1566 3.5569v-15.524l-8.2729-9.8662z" color="#000000" color-rendering="auto" dominant-baseline="auto" image-rendering="auto" solid-color="#000000" stop-color="#000000" stroke-width="1.708" style="font-feature-settings:normal;font-variant-alternates:normal;font-variant-caps:normal;font-variant-east-asian:normal;font-variant-ligatures:normal;font-variant-numeric:normal;font-variant-position:normal;font-variation-settings:normal;inline-size:0;isolation:auto;mix-blend-mode:normal;shape-margin:0;shape-padding:0;text-decoration-color:#000000;text-decoration-line:none;text-decoration-style:solid;text-indent:0;text-orientation:mixed;text-transform:none;white-space:normal"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.2 KiB

View file

@ -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) void Channel::deleteMessage(QString messageID)
{ {
LimitedQueueSnapshot<MessagePtr> snapshot = this->getMessageSnapshot(); LimitedQueueSnapshot<MessagePtr> snapshot = this->getMessageSnapshot();

View file

@ -72,6 +72,7 @@ public:
void addOrReplaceTimeout(MessagePtr message); void addOrReplaceTimeout(MessagePtr message);
void disableAllMessages(); void disableAllMessages();
void replaceMessage(MessagePtr message, MessagePtr replacement); void replaceMessage(MessagePtr message, MessagePtr replacement);
void replaceMessage(size_t index, MessagePtr replacement);
void deleteMessage(QString messageID); void deleteMessage(QString messageID);
void clearMessages(); void clearMessages();

View file

@ -68,6 +68,23 @@ namespace {
return descriptor; return descriptor;
} }
const QList<QUuid> loadFilters(QJsonValue val)
{
QList<QUuid> 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 } // namespace
void SplitDescriptor::loadFromJSON(SplitDescriptor &descriptor, void SplitDescriptor::loadFromJSON(SplitDescriptor &descriptor,
@ -85,6 +102,7 @@ void SplitDescriptor::loadFromJSON(SplitDescriptor &descriptor,
{ {
descriptor.channelName_ = data.value("name").toString(); descriptor.channelName_ = data.value("name").toString();
} }
descriptor.filters_ = loadFilters(root.value("filters"));
} }
WindowLayout WindowLayout::loadFromFile(const QString &path) WindowLayout WindowLayout::loadFromFile(const QString &path)

View file

@ -37,6 +37,8 @@ struct SplitDescriptor {
// Whether "Moderation Mode" (the sword icon) is enabled in this split or not // Whether "Moderation Mode" (the sword icon) is enabled in this split or not
bool moderationMode_{false}; bool moderationMode_{false};
QList<QUuid> filters_;
static void loadFromJSON(SplitDescriptor &descriptor, static void loadFromJSON(SplitDescriptor &descriptor,
const QJsonObject &root, const QJsonObject &data); const QJsonObject &root, const QJsonObject &data);
}; };

View file

@ -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<FilterRecordPtr>(3, parent)
{
}
// turn a vector item into a model row
FilterRecordPtr FilterModel::getItemFromRow(std::vector<QStandardItem *> &row,
const FilterRecordPtr &original)
{
auto item =
std::make_shared<FilterRecord>(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<QStandardItem *> &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

View file

@ -0,0 +1,26 @@
#pragma once
#include <QObject>
#include "common/SignalVectorModel.hpp"
#include "controllers/filters/FilterRecord.hpp"
namespace chatterino {
class FilterModel : public SignalVectorModel<FilterRecordPtr>
{
public:
explicit FilterModel(QObject *parent);
protected:
// turn a vector item into a model row
virtual FilterRecordPtr getItemFromRow(
std::vector<QStandardItem *> &row,
const FilterRecordPtr &original) override;
// turns a row in the model into a vector item
virtual void getRowFromItem(const FilterRecordPtr &item,
std::vector<QStandardItem *> &row) override;
};
} // namespace chatterino

View file

@ -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 <QRegularExpression>
#include <QString>
#include <pajlada/serialize.hpp>
#include <memory>
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<filterparser::FilterParser>(filter))
{
}
FilterRecord(const QString &name, const QString &filter, const QUuid &id)
: name_(name)
, filter_(filter)
, id_(id)
, parser_(std::make_unique<filterparser::FilterParser>(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<filterparser::FilterParser> parser_;
};
using FilterRecordPtr = std::shared_ptr<FilterRecord>;
} // namespace chatterino
namespace pajlada {
template <>
struct Serialize<chatterino::FilterRecordPtr> {
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<chatterino::FilterRecordPtr> {
static chatterino::FilterRecordPtr get(const rapidjson::Value &value)
{
if (!value.IsObject())
{
return std::make_shared<chatterino::FilterRecord>(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<chatterino::FilterRecord>(
_name, _filter, QUuid::fromString(_id));
}
};
} // namespace pajlada

View file

@ -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<QUuid> &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<QUuid> filterIds() const
{
return this->filters_.keys();
}
private:
QMap<QUuid, FilterRecordPtr> 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<FilterSet>;
} // namespace chatterino

View file

@ -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<BinaryOperation>(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<BinaryOperation>(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<UnaryOperation>(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<BinaryOperation>(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<BinaryOperation>(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<ValueExpression>(
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<ValueExpression>(val, type);
}
else if (type == TokenType::IDENTIFIER)
{
return std::make_unique<ValueExpression>(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<ValueExpression>(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

View file

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

View file

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

View file

@ -0,0 +1,65 @@
#pragma once
#include "controllers/filters/parser/Types.hpp"
namespace filterparser {
static const QMap<QString, QString> 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<TokenType> tokenTypes_;
TokenType tokenize(const QString &text);
};
} // namespace filterparser

View file

@ -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 "<unknown>";
case AND:
return "<and>";
case OR:
return "<or>";
case LP:
return "<left parenthesis>";
case RP:
return "<right parenthesis>";
case PLUS:
return "<plus>";
case MINUS:
return "<minus>";
case MULTIPLY:
return "<multiply>";
case DIVIDE:
return "<divide>";
case MOD:
return "<modulus>";
case EQ:
return "<equals>";
case NEQ:
return "<not equals>";
case LT:
return "<less than>";
case GT:
return "<greater than>";
case LTE:
return "<less than equal>";
case GTE:
return "<greater than equal>";
case CONTAINS:
return "<contains>";
case STARTS_WITH:
return "<starts with>";
case ENDS_WITH:
return "<ends with>";
case NOT:
return "<not>";
case STRING:
return "<string>";
case INT:
return "<int>";
case IDENTIFIER:
return "<identifier>";
default:
return "<unknown>";
}
}
// 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<bool>())
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

View file

@ -0,0 +1,125 @@
#pragma once
#include "messages/Message.hpp"
namespace filterparser {
using MessagePtr = std::shared_ptr<const chatterino::Message>;
using ContextMap = QMap<QString, QVariant>;
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<Expression>;
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

View file

@ -1,6 +1,7 @@
#pragma once #pragma once
#include "common/FlagsEnum.hpp" #include "common/FlagsEnum.hpp"
#include "providers/twitch/TwitchBadge.hpp"
#include "widgets/helper/ScrollbarHighlight.hpp" #include "widgets/helper/ScrollbarHighlight.hpp"
#include <QTime> #include <QTime>
@ -58,6 +59,10 @@ struct Message : boost::noncopyable {
QString displayName; QString displayName;
QString localizedName; QString localizedName;
QString timeoutUser; QString timeoutUser;
QString channelName;
QColor usernameColor;
std::vector<Badge> badges;
std::map<QString, QString> badgeInfos;
std::shared_ptr<QColor> highlightColor; std::shared_ptr<QColor> highlightColor;
uint32_t count = 1; uint32_t count = 1;
std::vector<std::unique_ptr<MessageElement>> elements; std::vector<std::unique_ptr<MessageElement>> elements;

View file

@ -115,7 +115,6 @@ bool MessageLayout::layout(int width, float scale, MessageElementFlags flags)
void MessageLayout::actuallyLayout(int width, MessageElementFlags flags) void MessageLayout::actuallyLayout(int width, MessageElementFlags flags)
{ {
this->layoutCount_++; this->layoutCount_++;
auto messageFlags = this->message_->flags; auto messageFlags = this->message_->flags;
if (this->flags.has(MessageLayoutFlag::Expanded) || 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*/, void MessageLayout::updateBuffer(QPixmap *buffer, int /*messageIndex*/,
Selection & /*selection*/) Selection & /*selection*/)
{ {
if (buffer->isNull())
return;
auto app = getApp(); auto app = getApp();
auto settings = getSettings(); auto settings = getSettings();

View file

@ -188,6 +188,8 @@ MessagePtr TwitchMessageBuilder::build()
this->senderIsBroadcaster = true; this->senderIsBroadcaster = true;
} }
this->message().channelName = this->channel->getName();
this->parseMessageID(); this->parseMessageID();
this->parseRoomID(); this->parseRoomID();
@ -531,6 +533,7 @@ void TwitchMessageBuilder::parseUsernameColor()
if (const auto color = iterator.value().toString(); !color.isEmpty()) if (const auto color = iterator.value().toString(); !color.isEmpty())
{ {
this->usernameColor_ = QColor(color); this->usernameColor_ = QColor(color);
this->message().usernameColor = this->usernameColor_;
return; return;
} }
} }
@ -538,6 +541,7 @@ void TwitchMessageBuilder::parseUsernameColor()
if (getSettings()->colorizeNicknames && this->tags.contains("user-id")) if (getSettings()->colorizeNicknames && this->tags.contains("user-id"))
{ {
this->usernameColor_ = getRandomColor(this->tags.value("user-id")); this->usernameColor_ = getRandomColor(this->tags.value("user-id"));
this->message().usernameColor = this->usernameColor_;
} }
} }
@ -1072,6 +1076,9 @@ void TwitchMessageBuilder::appendTwitchBadges()
this->emplace<BadgeElement>(badgeEmote.get(), badge.flag_) this->emplace<BadgeElement>(badgeEmote.get(), badge.flag_)
->setTooltip(tooltip); ->setTooltip(tooltip);
} }
this->message().badges = badges;
this->message().badgeInfos = badgeInfos;
} }
void TwitchMessageBuilder::appendChatterinoBadges() void TwitchMessageBuilder::appendChatterinoBadges()

View file

@ -21,6 +21,7 @@ ConcurrentSettings::ConcurrentSettings()
, blacklistedUsers(*new SignalVector<HighlightBlacklistUser>()) , blacklistedUsers(*new SignalVector<HighlightBlacklistUser>())
, ignoredMessages(*new SignalVector<IgnorePhrase>()) , ignoredMessages(*new SignalVector<IgnorePhrase>())
, mutedChannels(*new SignalVector<QString>()) , mutedChannels(*new SignalVector<QString>())
, filterRecords(*new SignalVector<FilterRecordPtr>())
, moderationActions(*new SignalVector<ModerationAction>) , moderationActions(*new SignalVector<ModerationAction>)
{ {
persist(this->highlightedMessages, "/highlighting/highlights"); persist(this->highlightedMessages, "/highlighting/highlights");
@ -28,6 +29,7 @@ ConcurrentSettings::ConcurrentSettings()
persist(this->highlightedUsers, "/highlighting/users"); persist(this->highlightedUsers, "/highlighting/users");
persist(this->ignoredMessages, "/ignore/phrases"); persist(this->ignoredMessages, "/ignore/phrases");
persist(this->mutedChannels, "/pings/muted"); persist(this->mutedChannels, "/pings/muted");
persist(this->filterRecords, "/filtering/filters");
// tagged users? // tagged users?
persist(this->moderationActions, "/moderation/actions"); persist(this->moderationActions, "/moderation/actions");
} }

View file

@ -6,6 +6,7 @@
#include "BaseSettings.hpp" #include "BaseSettings.hpp"
#include "common/Channel.hpp" #include "common/Channel.hpp"
#include "common/SignalVector.hpp" #include "common/SignalVector.hpp"
#include "controllers/filters/FilterRecord.hpp"
#include "controllers/highlights/HighlightPhrase.hpp" #include "controllers/highlights/HighlightPhrase.hpp"
#include "controllers/moderationactions/ModerationAction.hpp" #include "controllers/moderationactions/ModerationAction.hpp"
#include "singletons/Toasts.hpp" #include "singletons/Toasts.hpp"
@ -20,6 +21,7 @@ class HighlightPhrase;
class HighlightBlacklistUser; class HighlightBlacklistUser;
class IgnorePhrase; class IgnorePhrase;
class TaggedUser; class TaggedUser;
class FilterRecord;
/// Settings which are availlable for reading on all threads. /// Settings which are availlable for reading on all threads.
class ConcurrentSettings class ConcurrentSettings
@ -32,6 +34,7 @@ public:
SignalVector<HighlightBlacklistUser> &blacklistedUsers; SignalVector<HighlightBlacklistUser> &blacklistedUsers;
SignalVector<IgnorePhrase> &ignoredMessages; SignalVector<IgnorePhrase> &ignoredMessages;
SignalVector<QString> &mutedChannels; SignalVector<QString> &mutedChannels;
SignalVector<FilterRecordPtr> &filterRecords;
//SignalVector<TaggedUser> &taggedUsers; //SignalVector<TaggedUser> &taggedUsers;
SignalVector<ModerationAction> &moderationActions; SignalVector<ModerationAction> &moderationActions;
@ -255,6 +258,10 @@ public:
BoolSetting longAlerts = {"/highlighting/alerts", false}; BoolSetting longAlerts = {"/highlighting/alerts", false};
/// Filtering
BoolSetting excludeUserMessagesFromFilter = {
"/filtering/excludeUserMessages", false};
/// Logging /// Logging
BoolSetting enableLogging = {"/logging/enabled", false}; BoolSetting enableLogging = {"/logging/enabled", false};

View file

@ -407,7 +407,7 @@ void WindowManager::save()
// splits // splits
QJsonObject splits; QJsonObject splits;
this->encodeNodeRecusively(tab->getBaseNode(), splits); this->encodeNodeRecursively(tab->getBaseNode(), splits);
tab_obj.insert("splits2", splits); tab_obj.insert("splits2", splits);
tabs_arr.append(tab_obj); tabs_arr.append(tab_obj);
@ -454,16 +454,22 @@ void WindowManager::queueSave()
this->saveTimer->start(10s); this->saveTimer->start(10s);
} }
void WindowManager::encodeNodeRecusively(SplitNode *node, QJsonObject &obj) void WindowManager::encodeNodeRecursively(SplitNode *node, QJsonObject &obj)
{ {
switch (node->getType()) switch (node->getType())
{ {
case SplitNode::_Split: { case SplitNode::_Split: {
obj.insert("type", "split"); obj.insert("type", "split");
obj.insert("moderationMode", node->getSplit()->getModerationMode()); obj.insert("moderationMode", node->getSplit()->getModerationMode());
QJsonObject split; QJsonObject split;
encodeChannel(node->getSplit()->getIndirectChannel(), split); encodeChannel(node->getSplit()->getIndirectChannel(), split);
obj.insert("data", split); obj.insert("data", split);
QJsonArray filters;
encodeFilters(node->getSplit(), filters);
obj.insert("filters", filters);
obj.insert("flexh", node->getHorizontalFlex()); obj.insert("flexh", node->getHorizontalFlex());
obj.insert("flexv", node->getVerticalFlex()); obj.insert("flexv", node->getVerticalFlex());
} }
@ -478,7 +484,7 @@ void WindowManager::encodeNodeRecusively(SplitNode *node, QJsonObject &obj)
for (const std::unique_ptr<SplitNode> &n : node->getChildren()) for (const std::unique_ptr<SplitNode> &n : node->getChildren())
{ {
QJsonObject subObj; QJsonObject subObj;
this->encodeNodeRecusively(n.get(), subObj); this->encodeNodeRecursively(n.get(), subObj);
items_arr.append(subObj); items_arr.append(subObj);
} }
obj.insert("items", items_arr); 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) IndirectChannel WindowManager::decodeChannel(const SplitDescriptor &descriptor)
{ {
assertInGuiThread(); assertInGuiThread();

View file

@ -26,6 +26,7 @@ public:
WindowManager(); WindowManager();
static void encodeChannel(IndirectChannel channel, QJsonObject &obj); static void encodeChannel(IndirectChannel channel, QJsonObject &obj);
static void encodeFilters(Split *split, QJsonArray &arr);
static IndirectChannel decodeChannel(const SplitDescriptor &descriptor); static IndirectChannel decodeChannel(const SplitDescriptor &descriptor);
void showSettingsDialog( void showSettingsDialog(
@ -89,7 +90,7 @@ public:
pajlada::Signals::NoArgSignal miscUpdate; pajlada::Signals::NoArgSignal miscUpdate;
private: private:
void encodeNodeRecusively(SplitContainer::Node *node, QJsonObject &obj); void encodeNodeRecursively(SplitContainer::Node *node, QJsonObject &obj);
// Load window layout from the window-layout.json file // Load window layout from the window-layout.json file
WindowLayout loadWindowLayoutFromFile() const; WindowLayout loadWindowLayoutFromFile() const;

View file

@ -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("<a href='%1'><span "
"style='color:#99f'>variable help</span></a>")
.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<int>::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<int>::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

View file

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

View file

@ -0,0 +1,80 @@
#include "SelectChannelFiltersDialog.hpp"
#include "singletons/Settings.hpp"
namespace chatterino {
SelectChannelFiltersDialog::SelectChannelFiltersDialog(
const QList<QUuid> &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<QUuid> &SelectChannelFiltersDialog::getSelection() const
{
return this->currentSelection_;
}
} // namespace chatterino

View file

@ -0,0 +1,19 @@
#pragma once
#include <QDialog>
namespace chatterino {
class SelectChannelFiltersDialog : public QDialog
{
public:
SelectChannelFiltersDialog(const QList<QUuid> &previousSelection,
QWidget *parent = nullptr);
const QList<QUuid> &getSelection() const;
private:
QList<QUuid> currentSelection_;
};
} // namespace chatterino

View file

@ -11,6 +11,7 @@
#include "widgets/settingspages/AccountsPage.hpp" #include "widgets/settingspages/AccountsPage.hpp"
#include "widgets/settingspages/CommandPage.hpp" #include "widgets/settingspages/CommandPage.hpp"
#include "widgets/settingspages/ExternalToolsPage.hpp" #include "widgets/settingspages/ExternalToolsPage.hpp"
#include "widgets/settingspages/FiltersPage.hpp"
#include "widgets/settingspages/GeneralPage.hpp" #include "widgets/settingspages/GeneralPage.hpp"
#include "widgets/settingspages/HighlightingPage.hpp" #include "widgets/settingspages/HighlightingPage.hpp"
#include "widgets/settingspages/IgnoresPage.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 CommandPage;}, "Commands", ":/settings/commands.svg");
this->addTab([]{return new HighlightingPage;}, "Highlights", ":/settings/notifications.svg"); this->addTab([]{return new HighlightingPage;}, "Highlights", ":/settings/notifications.svg");
this->addTab([]{return new IgnoresPage;}, "Ignores", ":/settings/ignore.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->ui_.tabContainer->addSpacing(16);
this->addTab([]{return new KeyboardSettingsPage;}, "Keybindings", ":/settings/keybinds.svg"); this->addTab([]{return new KeyboardSettingsPage;}, "Keybindings", ":/settings/keybinds.svg");
this->addTab([]{return new ModerationPage;}, "Moderation", ":/settings/moderation.svg", SettingsTabId::Moderation); this->addTab([]{return new ModerationPage;}, "Moderation", ":/settings/moderation.svg", SettingsTabId::Moderation);

View file

@ -112,6 +112,7 @@ namespace {
ChannelView::ChannelView(BaseWidget *parent) ChannelView::ChannelView(BaseWidget *parent)
: BaseWidget(parent) : BaseWidget(parent)
, sourceChannel_(nullptr) , sourceChannel_(nullptr)
, underlyingChannel_(nullptr)
, scrollBar_(new Scrollbar(this)) , scrollBar_(new Scrollbar(this))
{ {
this->setMouseTracking(true); this->setMouseTracking(true);
@ -557,7 +558,12 @@ ChannelPtr ChannelView::channel()
return this->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 /// Clear connections from the last channel
this->channelConnections_.clear(); this->channelConnections_.clear();
@ -565,31 +571,88 @@ void ChannelView::setChannel(ChannelPtr channel)
this->clearMessages(); this->clearMessages();
this->scrollBar_->clearHighlights(); this->scrollBar_->clearHighlights();
/// make copy of channel and expose
this->channel_ = std::make_unique<Channel>(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<MessageFlags> 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<MessagePtr> &messages) {
std::vector<MessagePtr> 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 // on new message
this->channelConnections_.push_back(channel->messageAppended.connect( this->channelConnections_.push_back(this->channel_->messageAppended.connect(
[this](MessagePtr &message, [this](MessagePtr &message,
boost::optional<MessageFlags> overridingFlags) { boost::optional<MessageFlags> overridingFlags) {
this->messageAppended(message, overridingFlags); this->messageAppended(message, overridingFlags);
})); }));
this->channelConnections_.push_back(channel->messagesAddedAtStart.connect( this->channelConnections_.push_back(
[this](std::vector<MessagePtr> &messages) { this->channel_->messagesAddedAtStart.connect(
this->messageAddedAtStart(messages); [this](std::vector<MessagePtr> &messages) {
})); this->messageAddedAtStart(messages);
}));
// on message removed // on message removed
this->channelConnections_.push_back( this->channelConnections_.push_back(
channel->messageRemovedFromStart.connect([this](MessagePtr &message) { this->channel_->messageRemovedFromStart.connect(
this->messageRemoveFromStart(message); [this](MessagePtr &message) {
})); this->messageRemoveFromStart(message);
}));
// on message replaced // 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](size_t index, MessagePtr replacement) {
this->messageReplaced(index, replacement); this->messageReplaced(index, replacement);
})); }));
auto snapshot = channel->getMessageSnapshot(); auto snapshot = underlyingChannel->getMessageSnapshot();
for (size_t i = 0; i < snapshot.size(); i++) for (size_t i = 0; i < snapshot.size(); i++)
{ {
@ -604,22 +667,26 @@ void ChannelView::setChannel(ChannelPtr channel)
this->lastMessageHasAlternateBackground_ = this->lastMessageHasAlternateBackground_ =
!this->lastMessageHasAlternateBackground_; !this->lastMessageHasAlternateBackground_;
if (channel->shouldIgnoreHighlights()) if (underlyingChannel->shouldIgnoreHighlights())
{ {
messageLayout->flags.set(MessageLayoutFlag::IgnoreHighlights); messageLayout->flags.set(MessageLayoutFlag::IgnoreHighlights);
} }
this->messages_.pushBack(MessageLayoutPtr(messageLayout), deleted); 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->queueLayout();
this->queueUpdate(); this->queueUpdate();
// Notifications // Notifications
if (auto tc = dynamic_cast<TwitchChannel *>(channel.get())) if (auto tc = dynamic_cast<TwitchChannel *>(underlyingChannel.get()))
{ {
this->connections_.push_back(tc->liveStatusChanged.connect([this]() { this->connections_.push_back(tc->liveStatusChanged.connect([this]() {
this->liveStatusChanged.invoke(); // this->liveStatusChanged.invoke(); //
@ -627,6 +694,41 @@ void ChannelView::setChannel(ChannelPtr channel)
} }
} }
void ChannelView::setFilters(const QList<QUuid> &ids)
{
this->channelFilters_ = std::make_shared<FilterSet>(ids);
}
const QList<QUuid> ChannelView::getFilterIds() const
{
if (!this->channelFilters_)
{
return QList<QUuid>();
}
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 ChannelPtr ChannelView::sourceChannel() const
{ {
return this->sourceChannel_; 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()); this->scrollBar_->addHighlight(message->getScrollBarHighlight());
} }
@ -712,7 +814,8 @@ void ChannelView::messageAddedAtStart(std::vector<MessagePtr> &messages)
/// Create message layouts /// Create message layouts
for (size_t i = 0; i < messages.size(); i++) 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 // alternate color
if (!this->lastMessageHasAlternateBackgroundReverse_) if (!this->lastMessageHasAlternateBackgroundReverse_)
@ -732,15 +835,17 @@ void ChannelView::messageAddedAtStart(std::vector<MessagePtr> &messages)
this->scrollBar_->offset(qreal(messages.size())); this->scrollBar_->offset(qreal(messages.size()));
} }
/// Add highlights if (this->showScrollbarHighlights())
std::vector<ScrollbarHighlight> highlights;
highlights.reserve(messages.size());
for (size_t i = 0; i < messages.size(); i++)
{ {
highlights.push_back(messages.at(i)->getScrollBarHighlight()); std::vector<ScrollbarHighlight> 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->messageWasAdded_ = true;
this->queueLayout(); this->queueLayout();
@ -855,7 +960,7 @@ MessageElementFlags ChannelView::getFlags() const
{ {
flags.set(MessageElementFlag::ModeratorTools); flags.set(MessageElementFlag::ModeratorTools);
} }
if (this->channel_ == app->twitch.server->mentionsChannel) if (this->underlyingChannel_ == app->twitch.server->mentionsChannel)
{ {
flags.set(MessageElementFlag::ChannelName); flags.set(MessageElementFlag::ChannelName);
flags.unset(MessageElementFlag::ChannelPointReward); flags.unset(MessageElementFlag::ChannelPointReward);
@ -906,7 +1011,8 @@ void ChannelView::drawMessages(QPainter &painter)
bool windowFocused = this->window() == QApplication::activeWindow(); bool windowFocused = this->window() == QApplication::activeWindow();
auto app = getApp(); 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) for (size_t i = start; i < messagesSnapshot.size(); ++i)
{ {
@ -1830,8 +1936,9 @@ void ChannelView::hideEvent(QHideEvent *)
void ChannelView::showUserInfoPopup(const QString &userName) void ChannelView::showUserInfoPopup(const QString &userName)
{ {
auto *userPopup = new UserInfoPopup(getSettings()->autoCloseUserPopup); auto *userPopup = new UserInfoPopup(getSettings()->autoCloseUserPopup);
userPopup->setData(userName, this->hasSourceChannel() ? this->sourceChannel_ userPopup->setData(userName, this->hasSourceChannel()
: this->channel_); ? this->sourceChannel_
: this->underlyingChannel_);
QPoint offset(int(150 * this->scale()), int(70 * this->scale())); QPoint offset(int(150 * this->scale()), int(70 * this->scale()));
userPopup->move(QCursor::pos() - offset); userPopup->move(QCursor::pos() - offset);
userPopup->show(); userPopup->show();
@ -1871,9 +1978,9 @@ void ChannelView::handleLinkClick(QMouseEvent *event, const Link &link,
.replace("{msg-id}", layout->getMessage()->id) .replace("{msg-id}", layout->getMessage()->id)
.replace("{message}", layout->getMessage()->messageText); .replace("{message}", layout->getMessage()->messageText);
value = value = getApp()->commands->execCommand(
getApp()->commands->execCommand(value, this->channel_, false); value, this->underlyingChannel_, false);
this->channel_->sendMessage(value); this->underlyingChannel_->sendMessage(value);
} }
break; break;

View file

@ -10,6 +10,7 @@
#include <unordered_set> #include <unordered_set>
#include "common/FlagsEnum.hpp" #include "common/FlagsEnum.hpp"
#include "controllers/filters/FilterSet.hpp"
#include "messages/Image.hpp" #include "messages/Image.hpp"
#include "messages/LimitedQueue.hpp" #include "messages/LimitedQueue.hpp"
#include "messages/LimitedQueueSnapshot.hpp" #include "messages/LimitedQueueSnapshot.hpp"
@ -76,6 +77,10 @@ public:
ChannelPtr channel(); ChannelPtr channel();
void setChannel(ChannelPtr channel_); void setChannel(ChannelPtr channel_);
void setFilters(const QList<QUuid> &ids);
const QList<QUuid> getFilterIds() const;
FilterSetPtr getFilterSet() const;
ChannelPtr sourceChannel() const; ChannelPtr sourceChannel() const;
void setSourceChannel(ChannelPtr sourceChannel); void setSourceChannel(ChannelPtr sourceChannel);
bool hasSourceChannel() const; bool hasSourceChannel() const;
@ -178,11 +183,20 @@ private:
LimitedQueueSnapshot<MessageLayoutPtr> snapshot_; LimitedQueueSnapshot<MessageLayoutPtr> snapshot_;
ChannelPtr channel_; ChannelPtr channel_;
ChannelPtr underlyingChannel_;
ChannelPtr sourceChannel_; ChannelPtr sourceChannel_;
Scrollbar *scrollBar_; Scrollbar *scrollBar_;
EffectLabel *goToBottom_; 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 // This variable can be used to decide whether or not we should render the
// "Show latest messages" button // "Show latest messages" button
bool showingLatestMessages_ = true; bool showingLatestMessages_ = true;

View file

@ -16,7 +16,8 @@
namespace chatterino { namespace chatterino {
ChannelPtr SearchPopup::filter(const QString &text, const QString &channelName, ChannelPtr SearchPopup::filter(const QString &text, const QString &channelName,
const LimitedQueueSnapshot<MessagePtr> &snapshot) const LimitedQueueSnapshot<MessagePtr> &snapshot,
FilterSetPtr filterSet)
{ {
ChannelPtr channel(new Channel(channelName, Channel::Type::None)); 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 all predicates match, add the message to the channel
if (accept) if (accept)
channel->addMessage(message); channel->addMessage(message);
@ -59,6 +63,11 @@ SearchPopup::SearchPopup()
}); });
} }
void SearchPopup::setChannelFilters(FilterSetPtr filters)
{
this->channelFilters_ = filters;
}
void SearchPopup::setChannel(const ChannelPtr &channel) void SearchPopup::setChannel(const ChannelPtr &channel)
{ {
this->channelName_ = channel->getName(); this->channelName_ = channel->getName();
@ -76,7 +85,8 @@ void SearchPopup::updateWindowTitle()
void SearchPopup::search() void SearchPopup::search()
{ {
this->channelView_->setChannel(filter(this->searchInput_->text(), this->channelView_->setChannel(filter(this->searchInput_->text(),
this->channelName_, this->snapshot_)); this->channelName_, this->snapshot_,
this->channelFilters_));
} }
void SearchPopup::initLayout() void SearchPopup::initLayout()

View file

@ -1,6 +1,7 @@
#pragma once #pragma once
#include "ForwardDecl.hpp" #include "ForwardDecl.hpp"
#include "controllers/filters/FilterSet.hpp"
#include "messages/LimitedQueueSnapshot.hpp" #include "messages/LimitedQueueSnapshot.hpp"
#include "messages/search/MessagePredicate.hpp" #include "messages/search/MessagePredicate.hpp"
#include "widgets/BasePopup.hpp" #include "widgets/BasePopup.hpp"
@ -17,6 +18,7 @@ public:
SearchPopup(); SearchPopup();
virtual void setChannel(const ChannelPtr &channel); virtual void setChannel(const ChannelPtr &channel);
virtual void setChannelFilters(FilterSetPtr filters);
protected: protected:
virtual void updateWindowTitle(); virtual void updateWindowTitle();
@ -32,12 +34,14 @@ private:
* @param text the search query -- will be parsed for MessagePredicates * @param text the search query -- will be parsed for MessagePredicates
* @param channelName name of the channel to be returned * @param channelName name of the channel to be returned
* @param snapshot list of messages to filter * @param snapshot list of messages to filter
* @param filterSet channel filter to apply
* *
* @return a ChannelPtr with "channelName" and the filtered messages from * @return a ChannelPtr with "channelName" and the filtered messages from
* "snapshot" * "snapshot"
*/ */
static ChannelPtr filter(const QString &text, const QString &channelName, static ChannelPtr filter(const QString &text, const QString &channelName,
const LimitedQueueSnapshot<MessagePtr> &snapshot); const LimitedQueueSnapshot<MessagePtr> &snapshot,
FilterSetPtr filterSet);
/** /**
* @brief Checks the input for tags and registers their corresponding * @brief Checks the input for tags and registers their corresponding
@ -53,6 +57,7 @@ private:
QLineEdit *searchInput_{}; QLineEdit *searchInput_{};
ChannelView *channelView_{}; ChannelView *channelView_{};
QString channelName_{}; QString channelName_{};
FilterSetPtr channelFilters_;
}; };
} // namespace chatterino } // namespace chatterino

View file

@ -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 <QTableView>
#define FILTERS_DOCUMENTATION "https://wiki.chatterino.com/Filters/"
namespace chatterino {
FiltersPage::FiltersPage()
{
LayoutCreator<FiltersPage> layoutCreator(this);
auto layout = layoutCreator.setLayoutType<QVBoxLayout>();
layout.emplace<QLabel>(
"Selectively display messages in Splits using channel filters. Set "
"filters under a Split menu.");
EditableModelView *view =
layout
.emplace<EditableModelView>(
(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<FilterRecord>(d.getTitle(), d.getFilter()));
}
});
auto quickAddButton = new QPushButton("Quick Add");
QObject::connect(quickAddButton, &QPushButton::pressed, [] {
getSettings()->filterRecords.append(std::make_shared<FilterRecord>(
"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("<a href='%1'><span "
"style='color:#99f'>filter info</span></a>")
.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

View file

@ -0,0 +1,25 @@
#pragma once
#include "widgets/helper/EditableModelView.hpp"
#include "widgets/settingspages/SettingsPage.hpp"
#include <QStringListModel>
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

View file

@ -21,6 +21,7 @@
#include "widgets/Window.hpp" #include "widgets/Window.hpp"
#include "widgets/dialogs/QualityPopup.hpp" #include "widgets/dialogs/QualityPopup.hpp"
#include "widgets/dialogs/SelectChannelDialog.hpp" #include "widgets/dialogs/SelectChannelDialog.hpp"
#include "widgets/dialogs/SelectChannelFiltersDialog.hpp"
#include "widgets/dialogs/TextInputDialog.hpp" #include "widgets/dialogs/TextInputDialog.hpp"
#include "widgets/dialogs/UserInfoPopup.hpp" #include "widgets/dialogs/UserInfoPopup.hpp"
#include "widgets/helper/ChannelView.hpp" #include "widgets/helper/ChannelView.hpp"
@ -749,10 +750,33 @@ void Split::copyToClipboard()
crossPlatformCopy(this->view_->getSelectedText()); 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<QUuid> ids)
{
this->view_->setFilters(ids);
this->header_->updateChannelText();
}
const QList<QUuid> Split::getFilters() const
{
return this->view_->getFilterIds();
}
void Split::showSearch() void Split::showSearch()
{ {
SearchPopup *popup = new SearchPopup(); SearchPopup *popup = new SearchPopup();
popup->setChannelFilters(this->view_->getFilterSet());
popup->setAttribute(Qt::WA_DeleteOnClose); popup->setAttribute(Qt::WA_DeleteOnClose);
popup->setChannel(this->getChannel()); popup->setChannel(this->getChannel());
popup->show(); popup->show();

View file

@ -53,6 +53,9 @@ public:
ChannelPtr getChannel(); ChannelPtr getChannel();
void setChannel(IndirectChannel newChannel); void setChannel(IndirectChannel newChannel);
void setFilters(const QList<QUuid> ids);
const QList<QUuid> getFilters() const;
void setModerationMode(bool value); void setModerationMode(bool value);
bool getModerationMode() const; bool getModerationMode() const;
@ -132,6 +135,7 @@ public slots:
void openInStreamlink(); void openInStreamlink();
void openWithCustomScheme(); void openWithCustomScheme();
void copyToClipboard(); void copyToClipboard();
void setFiltersDialog();
void showSearch(); void showSearch();
void showViewerList(); void showViewerList();
void openSubPage(); void openSubPage();

View file

@ -716,6 +716,7 @@ void SplitContainer::applyFromDescriptorRecursively(
auto *split = new Split(this); auto *split = new Split(this);
split->setChannel(WindowManager::decodeChannel(splitNode)); split->setChannel(WindowManager::decodeChannel(splitNode));
split->setModerationMode(splitNode.moderationMode_); split->setModerationMode(splitNode.moderationMode_);
split->setFilters(splitNode.filters_);
this->appendSplit(split); this->appendSplit(split);
} }
@ -748,6 +749,7 @@ void SplitContainer::applyFromDescriptorRecursively(
auto *split = new Split(this); auto *split = new Split(this);
split->setChannel(WindowManager::decodeChannel(splitNode)); split->setChannel(WindowManager::decodeChannel(splitNode));
split->setModerationMode(splitNode.moderationMode_); split->setModerationMode(splitNode.moderationMode_);
split->setFilters(splitNode.filters_);
Node *_node = new Node(); Node *_node = new Node();
_node->parent_ = node; _node->parent_ = node;

View file

@ -327,6 +327,7 @@ std::unique_ptr<QMenu> SplitHeader::createMainMenu()
QKeySequence("Ctrl+N")); QKeySequence("Ctrl+N"));
menu->addAction("Search", this->split_, &Split::showSearch, menu->addAction("Search", this->split_, &Split::showSearch,
QKeySequence("Ctrl+F")); QKeySequence("Ctrl+F"));
menu->addAction("Set filters", this->split_, &Split::setFiltersDialog);
menu->addSeparator(); menu->addSeparator();
#ifdef USEWEBENGINE #ifdef USEWEBENGINE
this->dropdownMenu.addAction("Start watching", this, [this] { 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() ? "<empty>" : title); this->titleLabel_->setText(title.isEmpty() ? "<empty>" : title);
} }