mirror of
https://github.com/Chatterino/chatterino2.git
synced 2024-11-21 22:24:07 +01:00
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:
parent
812cbdf4f9
commit
4199a01b96
41 changed files with 2189 additions and 43 deletions
|
@ -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)
|
||||
|
|
|
@ -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 \
|
||||
|
|
|
@ -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>{file.as_posix()}</file>\n")
|
||||
out.write(resources_footer)
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<RCC>
|
||||
<qresource prefix="/"> <file>.gitignore</file>
|
||||
<qresource prefix="/">
|
||||
<file>avatars/fourtf.png</file>
|
||||
<file>avatars/pajlada.png</file>
|
||||
<file>buttons/addSplit.png</file>
|
||||
|
@ -70,6 +70,7 @@
|
|||
<file>settings/commands.svg</file>
|
||||
<file>settings/emote.svg</file>
|
||||
<file>settings/externaltools.svg</file>
|
||||
<file>settings/filters.svg</file>
|
||||
<file>settings/ignore.svg</file>
|
||||
<file>settings/keybinds.svg</file>
|
||||
<file>settings/moderation.svg</file>
|
||||
|
|
16
resources/settings/filters.svg
Normal file
16
resources/settings/filters.svg
Normal 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 |
|
@ -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<MessagePtr> snapshot = this->getMessageSnapshot();
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -68,6 +68,23 @@ namespace {
|
|||
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
|
||||
|
||||
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)
|
||||
|
|
|
@ -37,6 +37,8 @@ struct SplitDescriptor {
|
|||
// Whether "Moderation Mode" (the sword icon) is enabled in this split or not
|
||||
bool moderationMode_{false};
|
||||
|
||||
QList<QUuid> filters_;
|
||||
|
||||
static void loadFromJSON(SplitDescriptor &descriptor,
|
||||
const QJsonObject &root, const QJsonObject &data);
|
||||
};
|
||||
|
|
41
src/controllers/filters/FilterModel.cpp
Normal file
41
src/controllers/filters/FilterModel.cpp
Normal 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
|
26
src/controllers/filters/FilterModel.hpp
Normal file
26
src/controllers/filters/FilterModel.hpp
Normal 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
|
123
src/controllers/filters/FilterRecord.hpp
Normal file
123
src/controllers/filters/FilterRecord.hpp
Normal 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
|
85
src/controllers/filters/FilterSet.hpp
Normal file
85
src/controllers/filters/FilterSet.hpp
Normal 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
|
302
src/controllers/filters/parser/FilterParser.cpp
Normal file
302
src/controllers/filters/parser/FilterParser.cpp
Normal 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
|
39
src/controllers/filters/parser/FilterParser.hpp
Normal file
39
src/controllers/filters/parser/FilterParser.hpp
Normal 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
|
174
src/controllers/filters/parser/Tokenizer.cpp
Normal file
174
src/controllers/filters/parser/Tokenizer.cpp
Normal 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
|
65
src/controllers/filters/parser/Tokenizer.hpp
Normal file
65
src/controllers/filters/parser/Tokenizer.hpp
Normal 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
|
363
src/controllers/filters/parser/Types.cpp
Normal file
363
src/controllers/filters/parser/Types.cpp
Normal 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
|
125
src/controllers/filters/parser/Types.hpp
Normal file
125
src/controllers/filters/parser/Types.hpp
Normal 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
|
|
@ -1,6 +1,7 @@
|
|||
#pragma once
|
||||
|
||||
#include "common/FlagsEnum.hpp"
|
||||
#include "providers/twitch/TwitchBadge.hpp"
|
||||
#include "widgets/helper/ScrollbarHighlight.hpp"
|
||||
|
||||
#include <QTime>
|
||||
|
@ -58,6 +59,10 @@ struct Message : boost::noncopyable {
|
|||
QString displayName;
|
||||
QString localizedName;
|
||||
QString timeoutUser;
|
||||
QString channelName;
|
||||
QColor usernameColor;
|
||||
std::vector<Badge> badges;
|
||||
std::map<QString, QString> badgeInfos;
|
||||
std::shared_ptr<QColor> highlightColor;
|
||||
uint32_t count = 1;
|
||||
std::vector<std::unique_ptr<MessageElement>> elements;
|
||||
|
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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<BadgeElement>(badgeEmote.get(), badge.flag_)
|
||||
->setTooltip(tooltip);
|
||||
}
|
||||
|
||||
this->message().badges = badges;
|
||||
this->message().badgeInfos = badgeInfos;
|
||||
}
|
||||
|
||||
void TwitchMessageBuilder::appendChatterinoBadges()
|
||||
|
|
|
@ -21,6 +21,7 @@ ConcurrentSettings::ConcurrentSettings()
|
|||
, blacklistedUsers(*new SignalVector<HighlightBlacklistUser>())
|
||||
, ignoredMessages(*new SignalVector<IgnorePhrase>())
|
||||
, mutedChannels(*new SignalVector<QString>())
|
||||
, filterRecords(*new SignalVector<FilterRecordPtr>())
|
||||
, moderationActions(*new SignalVector<ModerationAction>)
|
||||
{
|
||||
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");
|
||||
}
|
||||
|
|
|
@ -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<HighlightBlacklistUser> &blacklistedUsers;
|
||||
SignalVector<IgnorePhrase> &ignoredMessages;
|
||||
SignalVector<QString> &mutedChannels;
|
||||
SignalVector<FilterRecordPtr> &filterRecords;
|
||||
//SignalVector<TaggedUser> &taggedUsers;
|
||||
SignalVector<ModerationAction> &moderationActions;
|
||||
|
||||
|
@ -255,6 +258,10 @@ public:
|
|||
|
||||
BoolSetting longAlerts = {"/highlighting/alerts", false};
|
||||
|
||||
/// Filtering
|
||||
BoolSetting excludeUserMessagesFromFilter = {
|
||||
"/filtering/excludeUserMessages", false};
|
||||
|
||||
/// Logging
|
||||
BoolSetting enableLogging = {"/logging/enabled", false};
|
||||
|
||||
|
|
|
@ -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<SplitNode> &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();
|
||||
|
|
|
@ -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;
|
||||
|
|
227
src/widgets/dialogs/ChannelFilterEditorDialog.cpp
Normal file
227
src/widgets/dialogs/ChannelFilterEditorDialog.cpp
Normal 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
|
61
src/widgets/dialogs/ChannelFilterEditorDialog.hpp
Normal file
61
src/widgets/dialogs/ChannelFilterEditorDialog.hpp
Normal 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
|
80
src/widgets/dialogs/SelectChannelFiltersDialog.cpp
Normal file
80
src/widgets/dialogs/SelectChannelFiltersDialog.cpp
Normal 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
|
19
src/widgets/dialogs/SelectChannelFiltersDialog.hpp
Normal file
19
src/widgets/dialogs/SelectChannelFiltersDialog.hpp
Normal 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
|
|
@ -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);
|
||||
|
|
|
@ -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<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
|
||||
this->channelConnections_.push_back(channel->messageAppended.connect(
|
||||
this->channelConnections_.push_back(this->channel_->messageAppended.connect(
|
||||
[this](MessagePtr &message,
|
||||
boost::optional<MessageFlags> overridingFlags) {
|
||||
this->messageAppended(message, overridingFlags);
|
||||
}));
|
||||
|
||||
this->channelConnections_.push_back(channel->messagesAddedAtStart.connect(
|
||||
[this](std::vector<MessagePtr> &messages) {
|
||||
this->messageAddedAtStart(messages);
|
||||
}));
|
||||
this->channelConnections_.push_back(
|
||||
this->channel_->messagesAddedAtStart.connect(
|
||||
[this](std::vector<MessagePtr> &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<TwitchChannel *>(channel.get()))
|
||||
if (auto tc = dynamic_cast<TwitchChannel *>(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<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
|
||||
{
|
||||
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<MessagePtr> &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<MessagePtr> &messages)
|
|||
this->scrollBar_->offset(qreal(messages.size()));
|
||||
}
|
||||
|
||||
/// Add highlights
|
||||
std::vector<ScrollbarHighlight> 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<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->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;
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
#include <unordered_set>
|
||||
|
||||
#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<QUuid> &ids);
|
||||
const QList<QUuid> getFilterIds() const;
|
||||
FilterSetPtr getFilterSet() const;
|
||||
|
||||
ChannelPtr sourceChannel() const;
|
||||
void setSourceChannel(ChannelPtr sourceChannel);
|
||||
bool hasSourceChannel() const;
|
||||
|
@ -178,11 +183,20 @@ private:
|
|||
LimitedQueueSnapshot<MessageLayoutPtr> 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;
|
||||
|
|
|
@ -16,7 +16,8 @@
|
|||
namespace chatterino {
|
||||
|
||||
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));
|
||||
|
||||
|
@ -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()
|
||||
|
|
|
@ -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<MessagePtr> &snapshot);
|
||||
const LimitedQueueSnapshot<MessagePtr> &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
|
||||
|
|
111
src/widgets/settingspages/FiltersPage.cpp
Normal file
111
src/widgets/settingspages/FiltersPage.cpp
Normal 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
|
25
src/widgets/settingspages/FiltersPage.hpp
Normal file
25
src/widgets/settingspages/FiltersPage.hpp
Normal 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
|
|
@ -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<QUuid> ids)
|
||||
{
|
||||
this->view_->setFilters(ids);
|
||||
this->header_->updateChannelText();
|
||||
}
|
||||
|
||||
const QList<QUuid> 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();
|
||||
|
|
|
@ -53,6 +53,9 @@ public:
|
|||
ChannelPtr getChannel();
|
||||
void setChannel(IndirectChannel newChannel);
|
||||
|
||||
void setFilters(const QList<QUuid> ids);
|
||||
const QList<QUuid> 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();
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -327,6 +327,7 @@ std::unique_ptr<QMenu> 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() ? "<empty>" : title);
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue