Merge remote-tracking branch 'origin/master' into zneix/feature/qt6

This commit is contained in:
zneix 2021-09-13 21:58:15 +02:00
commit e21e340b98
No known key found for this signature in database
GPG key ID: 911916E0523B22F6
61 changed files with 826 additions and 455 deletions

View file

@ -1,5 +1,8 @@
blank_issues_enabled: false blank_issues_enabled: false
contact_links: contact_links:
- name: Issue about the Chatterino Browser Extension
url: https://github.com/Chatterino/chatterino-browser-ext/issues
about: Make a suggestion or report a bug about the Chatterino browser extension.
- name: Suggestions or feature request - name: Suggestions or feature request
url: https://github.com/chatterino/chatterino2/discussions/categories/ideas url: https://github.com/chatterino/chatterino2/discussions/categories/ideas
about: Got something you think should change or be added? Search for or start a new discussion! about: Got something you think should change or be added? Search for or start a new discussion!

View file

@ -1,7 +0,0 @@
---
name: Issue about the Chatterino Browser Extension
about: Make a suggestion or report a bug about the Chatterino browser extension.
---
Issues for the extension are tracked here: https://github.com/chatterino/chatterino-browser-ext

View file

@ -13,7 +13,7 @@ jobs:
# Gives an error if there's no change in the changelog (except using label) # Gives an error if there's no change in the changelog (except using label)
- name: Changelog check - name: Changelog check
uses: dangoslen/changelog-enforcer@v2.2.0 uses: dangoslen/changelog-enforcer@v2.3.1
with: with:
changeLogPath: 'CHANGELOG.md' changeLogPath: 'CHANGELOG.md'
skipLabels: 'no changelog entry needed, ci, submodules' skipLabels: 'no changelog entry needed, ci, submodules'

View file

@ -33,7 +33,7 @@ Note: This installation will take about 1.5 GB of disk space.
### For our websocket library, we need OpenSSL 1.1 ### For our websocket library, we need OpenSSL 1.1
1. Download OpenSSL for windows, version `1.1.1k`: **[Download](https://slproweb.com/download/Win64OpenSSL-1_1_1k.exe)** 1. Download OpenSSL for windows, version `1.1.1l`: **[Download](https://slproweb.com/download/Win64OpenSSL-1_1_1L.exe)**
2. When prompted, install OpenSSL to `C:\local\openssl` 2. When prompted, install OpenSSL to `C:\local\openssl`
3. When prompted, copy the OpenSSL DLLs to "The OpenSSL binaries (/bin) directory". 3. When prompted, copy the OpenSSL DLLs to "The OpenSSL binaries (/bin) directory".

View file

@ -2,11 +2,31 @@
## Unversioned ## Unversioned
- Minor: Add `{channel.name}`, `{channel.id}`, `{stream.game}`, `{stream.title}`, `{my.id}`, `{my.name}` placeholders for commands (#3155)
- Minor: Remove TwitchEmotes.com attribution and the open/copy options when right-clicking a Twitch Emote. (#2214, #3136) - Minor: Remove TwitchEmotes.com attribution and the open/copy options when right-clicking a Twitch Emote. (#2214, #3136)
- Minor: Strip leading @ and trailing , from username in /user and /usercard commands. (#3143) - Minor: Strip leading @ and trailing , from username in /user and /usercard commands. (#3143)
- Minor: Display a system message when reloading subscription emotes to match BTTV/FFZ behavior (#3135) - Minor: Display a system message when reloading subscription emotes to match BTTV/FFZ behavior (#3135)
- Minor: Allow resub messages to show in `/mentions` tab (#3148)
- Minor: Added a setting to hide similar messages by any user. (#2716)
- Minor: Duplicate spaces now count towards the display message length. (#3002)
- Minor: Commands are now backed up. (#3168)
- Minor: Added the ability to open an entire tab as a popup. (#3082)
- Minor: Added optional parameter to /usercard command for opening a usercard in a different channel context. (#3172)
- Minor: Added regex option to Nicknames. (#3146)
- Minor: Added `/raw` command. (#3189)
- Minor: Colorizing usernames on IRC, originally made for Mm2PL/dankerino (#3206)
- Minor: Fixed `/streamlink` command not stripping leading @'s or #'s (#3215)
- Minor: Strip leading @ and trailing , from username in `/popout` command. (#3217)
- Minor: Added `flags.reward_message` filter variable (#3231)
- Bugfix: Fixed colored usernames sometimes not working. (#3170)
- Bugfix: Restored ability to send duplicate `/me` messages. (#3166)
- Bugfix: Notifications for moderators about other moderators deleting messages can now be disabled. (#3121)
- Bugfix: Moderation mode and active filters are now preserved when opening a split as a popup. (#3113, #3130) - Bugfix: Moderation mode and active filters are now preserved when opening a split as a popup. (#3113, #3130)
- Bugfix: Fixed a bug that caused all badge highlights to use the same color. (#3132, #3134) - Bugfix: Fixed a bug that caused all badge highlights to use the same color. (#3132, #3134)
- Bugfix: Allow starting Streamlink from Chatterino when running as a Flatpak. (#3178)
- Bugfix: Fixed own IRC messages not having metadata and a link to a usercard. (#3203)
- Bugfix: Fixed some channels still not loading in rare cases. (#3219)
- Bugfix: Fixed a bug with usernames or emotes completing from the wrong position. (#3229)
- Dev: Renamed CMake's build option `USE_SYSTEM_QT5KEYCHAIN` to `USE_SYSTEM_QTKEYCHAIN`. (#3103) - Dev: Renamed CMake's build option `USE_SYSTEM_QT5KEYCHAIN` to `USE_SYSTEM_QTKEYCHAIN`. (#3103)
- Dev: Add benchmarks that can be compiled with the `BUILD_BENCHMARKS` CMake flag. Off by default. (#3038) - Dev: Add benchmarks that can be compiled with the `BUILD_BENCHMARKS` CMake flag. Off by default. (#3038)

View file

@ -215,3 +215,28 @@ Keep the element on the stack if possible. If you need a pointer or have complex
- Use the [object tree](https://doc.qt.io/qt-5/objecttrees.html#) to manage lifetimes where possible. Objects are destroyed when their parent object is destroyed. - Use the [object tree](https://doc.qt.io/qt-5/objecttrees.html#) to manage lifetimes where possible. Objects are destroyed when their parent object is destroyed.
- If you have to explicitly delete an object use `variable->deleteLater()` instead of `delete variable`. This ensures that it will be deleted on the correct thread. - If you have to explicitly delete an object use `variable->deleteLater()` instead of `delete variable`. This ensures that it will be deleted on the correct thread.
- If an object doesn't have a parent, consider using `std::unique_ptr<Type, DeleteLater>` with `DeleteLater` from "src/common/Common.hpp". This will call `deleteLater()` on the pointer once it goes out of scope, or the object is destroyed. - If an object doesn't have a parent, consider using `std::unique_ptr<Type, DeleteLater>` with `DeleteLater` from "src/common/Common.hpp". This will call `deleteLater()` on the pointer once it goes out of scope, or the object is destroyed.
## Conventions
#### Usage strings
When informing the user about how a command is supposed to be used, we aim to follow [this standard](https://pubs.opengroup.org/onlinepubs/9699919799/basedefs/V1_chap12.html) where possible.
- Square brackets are reserved for `[optional arguments]`.
- Angle brackets are reserved for `<required arguments>`.
- The word _Usage_ should be capitalized and must be followed by a colon.
- If the usage deserves a description, put a dot after all parameters and explain it briefly.
##### Good
- `Usage: /block <user>`
- `Usage: /unblock <user>. Unblocks a user.`
- `Usage: /streamlink <channel>`
- `Usage: /usercard <user> [channel]`
##### Bad
- `Usage /streamlink <channel>` - Missing colon after _Usage_.
- `usage: /streamlink <channel>` - _Usage_ must be capitalized.
- `Usage: /streamlink channel` - The required argument `channel` must be wrapped in angle brackets.
- `Usage: /streamlink <channel>.` - Don't put a dot after usage if it's not followed by a description.

View file

@ -167,8 +167,6 @@ SOURCES += \
src/controllers/notifications/NotificationController.cpp \ src/controllers/notifications/NotificationController.cpp \
src/controllers/notifications/NotificationModel.cpp \ src/controllers/notifications/NotificationModel.cpp \
src/controllers/pings/MutedChannelModel.cpp \ src/controllers/pings/MutedChannelModel.cpp \
src/controllers/taggedusers/TaggedUser.cpp \
src/controllers/taggedusers/TaggedUsersModel.cpp \
src/debug/Benchmark.cpp \ src/debug/Benchmark.cpp \
src/main.cpp \ src/main.cpp \
src/messages/Emote.cpp \ src/messages/Emote.cpp \
@ -400,8 +398,6 @@ HEADERS += \
src/controllers/notifications/NotificationController.hpp \ src/controllers/notifications/NotificationController.hpp \
src/controllers/notifications/NotificationModel.hpp \ src/controllers/notifications/NotificationModel.hpp \
src/controllers/pings/MutedChannelModel.hpp \ src/controllers/pings/MutedChannelModel.hpp \
src/controllers/taggedusers/TaggedUser.hpp \
src/controllers/taggedusers/TaggedUsersModel.hpp \
src/debug/AssertInGuiThread.hpp \ src/debug/AssertInGuiThread.hpp \
src/debug/Benchmark.hpp \ src/debug/Benchmark.hpp \
src/ForwardDecl.hpp \ src/ForwardDecl.hpp \

View file

@ -35,6 +35,7 @@ public:
lru_cache(lru_cache<key_t, value_t> &&other) lru_cache(lru_cache<key_t, value_t> &&other)
: _cache_items_list(std::move(other._cache_items_list)) : _cache_items_list(std::move(other._cache_items_list))
, _cache_items_map(std::move(other._cache_items_map)) , _cache_items_map(std::move(other._cache_items_map))
, _max_size(other._max_size)
{ {
other._cache_items_list.clear(); other._cache_items_list.clear();
other._cache_items_map.clear(); other._cache_items_map.clear();
@ -44,6 +45,7 @@ public:
{ {
_cache_items_list = std::move(other._cache_items_list); _cache_items_list = std::move(other._cache_items_list);
_cache_items_map = std::move(other._cache_items_map); _cache_items_map = std::move(other._cache_items_map);
_max_size = other._max_size;
other._cache_items_list.clear(); other._cache_items_list.clear();
other._cache_items_map.clear(); other._cache_items_map.clear();
return *this; return *this;

View file

@ -286,7 +286,7 @@ void Application::initPubsub()
auto chan = auto chan =
this->twitch.server->getChannelOrEmptyByID(action.roomID); this->twitch.server->getChannelOrEmptyByID(action.roomID);
if (chan->isEmpty()) if (chan->isEmpty() || getSettings()->hideDeletionActions)
{ {
return; return;
} }

View file

@ -110,11 +110,6 @@ set(SOURCE_FILES
controllers/pings/MutedChannelModel.cpp controllers/pings/MutedChannelModel.cpp
controllers/pings/MutedChannelModel.hpp controllers/pings/MutedChannelModel.hpp
controllers/taggedusers/TaggedUser.cpp
controllers/taggedusers/TaggedUser.hpp
controllers/taggedusers/TaggedUsersModel.cpp
controllers/taggedusers/TaggedUsersModel.hpp
debug/Benchmark.cpp debug/Benchmark.cpp
debug/Benchmark.hpp debug/Benchmark.hpp

View file

@ -74,6 +74,12 @@ void ChannelChatters::updateOnlineChatters(
chatters_->updateOnlineChatters(chatters); chatters_->updateOnlineChatters(chatters);
} }
size_t ChannelChatters::colorsSize() const
{
auto size = this->chatterColors_.access()->size();
return size;
}
const QColor ChannelChatters::getUserColor(const QString &user) const QColor ChannelChatters::getUserColor(const QString &user)
{ {
const auto chatterColors = this->chatterColors_.access(); const auto chatterColors = this->chatterColors_.access();

View file

@ -25,9 +25,13 @@ public:
void setUserColor(const QString &user, const QColor &color); void setUserColor(const QString &user, const QColor &color);
void updateOnlineChatters(const std::unordered_set<QString> &chatters); void updateOnlineChatters(const std::unordered_set<QString> &chatters);
private: // colorsSize returns the amount of colors stored in `chatterColors_`
// NOTE: This function is only meant to be used in tests and benchmarks
size_t colorsSize() const;
static constexpr int maxChatterColorCount = 5000; static constexpr int maxChatterColorCount = 5000;
private:
Channel &channel_; Channel &channel_;
// maps 2 char prefix to set of names // maps 2 char prefix to set of names

View file

@ -2,6 +2,8 @@
#include "common/Modes.hpp" #include "common/Modes.hpp"
#include <QFileInfo>
#define UGLYMACROHACK1(s) #s #define UGLYMACROHACK1(s) #s
#define FROM_EXTERNAL_DEFINE(s) UGLYMACROHACK1(s) #define FROM_EXTERNAL_DEFINE(s) UGLYMACROHACK1(s)
@ -71,4 +73,9 @@ const bool &Version::isSupportedOS() const
return this->isSupportedOS_; return this->isSupportedOS_;
} }
bool Version::isFlatpak() const
{
return QFileInfo::exists("/.flatpak-info");
}
} // namespace chatterino } // namespace chatterino

View file

@ -29,6 +29,7 @@ public:
const QString &dateOfBuild() const; const QString &dateOfBuild() const;
const QString &fullVersion() const; const QString &fullVersion() const;
const bool &isSupportedOS() const; const bool &isSupportedOS() const;
bool isFlatpak() const;
private: private:
Version(); Version();

View file

@ -110,6 +110,42 @@ void SplitDescriptor::loadFromJSON(SplitDescriptor &descriptor,
descriptor.filters_ = loadFilters(root.value("filters")); descriptor.filters_ = loadFilters(root.value("filters"));
} }
TabDescriptor TabDescriptor::loadFromJSON(const QJsonObject &tabObj)
{
TabDescriptor tab;
// Load tab custom title
QJsonValue titleVal = tabObj.value("title");
if (titleVal.isString())
{
tab.customTitle_ = titleVal.toString();
}
// Load tab selected state
tab.selected_ = tabObj.value("selected").toBool(false);
// Load tab "highlightsEnabled" state
tab.highlightsEnabled_ = tabObj.value("highlightsEnabled").toBool(true);
QJsonObject splitRoot = tabObj.value("splits2").toObject();
// Load tab splits
if (!splitRoot.isEmpty())
{
// root type
auto nodeType = splitRoot.value("type").toString();
if (nodeType == "split")
{
tab.rootNode_ = loadNodes<SplitNodeDescriptor>(splitRoot);
}
else if (nodeType == "horizontal" || nodeType == "vertical")
{
tab.rootNode_ = loadNodes<ContainerNodeDescriptor>(splitRoot);
}
}
return tab;
}
WindowLayout WindowLayout::loadFromFile(const QString &path) WindowLayout WindowLayout::loadFromFile(const QString &path)
{ {
WindowLayout layout; WindowLayout layout;
@ -117,15 +153,15 @@ WindowLayout WindowLayout::loadFromFile(const QString &path)
bool hasSetAMainWindow = false; bool hasSetAMainWindow = false;
// "deserialize" // "deserialize"
for (const QJsonValue &window_val : loadWindowArray(path)) for (const QJsonValue &windowVal : loadWindowArray(path))
{ {
QJsonObject window_obj = window_val.toObject(); QJsonObject windowObj = windowVal.toObject();
WindowDescriptor window; WindowDescriptor window;
// Load window type // Load window type
QString type_val = window_obj.value("type").toString(); QString typeVal = windowObj.value("type").toString();
auto type = type_val == "main" ? WindowType::Main : WindowType::Popup; auto type = typeVal == "main" ? WindowType::Main : WindowType::Popup;
if (type == WindowType::Main) if (type == WindowType::Main)
{ {
@ -142,21 +178,21 @@ WindowLayout WindowLayout::loadFromFile(const QString &path)
window.type_ = type; window.type_ = type;
// Load window state // Load window state
if (window_obj.value("state") == "minimized") if (windowObj.value("state") == "minimized")
{ {
window.state_ = WindowDescriptor::State::Minimized; window.state_ = WindowDescriptor::State::Minimized;
} }
else if (window_obj.value("state") == "maximized") else if (windowObj.value("state") == "maximized")
{ {
window.state_ = WindowDescriptor::State::Maximized; window.state_ = WindowDescriptor::State::Maximized;
} }
// Load window geometry // Load window geometry
{ {
int x = window_obj.value("x").toInt(-1); int x = windowObj.value("x").toInt(-1);
int y = window_obj.value("y").toInt(-1); int y = windowObj.value("y").toInt(-1);
int width = window_obj.value("width").toInt(-1); int width = windowObj.value("width").toInt(-1);
int height = window_obj.value("height").toInt(-1); int height = windowObj.value("height").toInt(-1);
window.geometry_ = QRect(x, y, width, height); window.geometry_ = QRect(x, y, width, height);
} }
@ -164,23 +200,10 @@ WindowLayout WindowLayout::loadFromFile(const QString &path)
bool hasSetASelectedTab = false; bool hasSetASelectedTab = false;
// Load window tabs // Load window tabs
QJsonArray tabs = window_obj.value("tabs").toArray(); QJsonArray tabs = windowObj.value("tabs").toArray();
for (QJsonValue tab_val : tabs) for (QJsonValue tabVal : tabs)
{ {
TabDescriptor tab; TabDescriptor tab = TabDescriptor::loadFromJSON(tabVal.toObject());
QJsonObject tab_obj = tab_val.toObject();
// Load tab custom title
QJsonValue title_val = tab_obj.value("title");
if (title_val.isString())
{
tab.customTitle_ = title_val.toString();
}
// Load tab selected state
tab.selected_ = tab_obj.value("selected").toBool(false);
if (tab.selected_) if (tab.selected_)
{ {
if (hasSetASelectedTab) if (hasSetASelectedTab)
@ -192,34 +215,11 @@ WindowLayout WindowLayout::loadFromFile(const QString &path)
} }
hasSetASelectedTab = true; hasSetASelectedTab = true;
} }
// Load tab "highlightsEnabled" state
tab.highlightsEnabled_ =
tab_obj.value("highlightsEnabled").toBool(true);
QJsonObject splitRoot = tab_obj.value("splits2").toObject();
// Load tab splits
if (!splitRoot.isEmpty())
{
// root type
auto nodeType = splitRoot.value("type").toString();
if (nodeType == "split")
{
tab.rootNode_ = loadNodes<SplitNodeDescriptor>(splitRoot);
}
else if (nodeType == "horizontal" || nodeType == "vertical")
{
tab.rootNode_ =
loadNodes<ContainerNodeDescriptor>(splitRoot);
}
}
window.tabs_.emplace_back(std::move(tab)); window.tabs_.emplace_back(std::move(tab));
} }
// Load emote popup position // Load emote popup position
QJsonObject emote_popup_obj = window_obj.value("emotePopup").toObject(); QJsonObject emote_popup_obj = windowObj.value("emotePopup").toObject();
layout.emotePopupPos_ = QPoint(emote_popup_obj.value("x").toInt(), layout.emotePopupPos_ = QPoint(emote_popup_obj.value("x").toInt(),
emote_popup_obj.value("y").toInt()); emote_popup_obj.value("y").toInt());

View file

@ -67,6 +67,8 @@ struct ContainerNodeDescriptor {
}; };
struct TabDescriptor { struct TabDescriptor {
static TabDescriptor loadFromJSON(const QJsonObject &root);
QString customTitle_; QString customTitle_;
bool selected_{false}; bool selected_{false};
bool highlightsEnabled_{true}; bool highlightsEnabled_{true};

View file

@ -226,6 +226,72 @@ bool appendWhisperMessageStringLocally(const QString &textNoEmoji)
} }
return false; return false;
} }
const std::map<QString,
std::function<QString(const QString &, const ChannelPtr &)>>
COMMAND_VARS{
{
"channel.name",
[](const auto &altText, const auto &channel) {
(void)(altText); //unused
return channel->getName();
},
},
{
"channel.id",
[](const auto &altText, const auto &channel) {
auto *tc = dynamic_cast<TwitchChannel *>(channel.get());
if (tc == nullptr)
{
return altText;
}
return tc->roomId();
},
},
{
"stream.game",
[](const auto &altText, const auto &channel) {
auto *tc = dynamic_cast<TwitchChannel *>(channel.get());
if (tc == nullptr)
{
return altText;
}
const auto &status = tc->accessStreamStatus();
return status->live ? status->game : altText;
},
},
{
"stream.title",
[](const auto &altText, const auto &channel) {
auto *tc = dynamic_cast<TwitchChannel *>(channel.get());
if (tc == nullptr)
{
return altText;
}
const auto &status = tc->accessStreamStatus();
return status->live ? status->title : altText;
},
},
{
"my.id",
[](const auto &altText, const auto &channel) {
(void)(channel); //unused
auto uid = getApp()->accounts->twitch.getCurrent()->getUserId();
return uid.isEmpty() ? altText : uid;
},
},
{
"my.name",
[](const auto &altText, const auto &channel) {
(void)(channel); //unused
auto name =
getApp()->accounts->twitch.getCurrent()->getUserName();
return name.isEmpty() ? altText : name;
},
},
};
} // namespace } // namespace
namespace chatterino { namespace chatterino {
@ -267,6 +333,8 @@ void CommandController::initialize(Settings &, Paths &paths)
auto path = combinePath(paths.settingsDirectory, "commands.json"); auto path = combinePath(paths.settingsDirectory, "commands.json");
this->sm_ = std::make_shared<pajlada::Settings::SettingManager>(); this->sm_ = std::make_shared<pajlada::Settings::SettingManager>();
this->sm_->setPath(path.toStdString()); this->sm_->setPath(path.toStdString());
this->sm_->setBackupEnabled(true);
this->sm_->setBackupSlots(9);
// Delayed initialization of the setting storing all commands // Delayed initialization of the setting storing all commands
this->commandsSetting_.reset( this->commandsSetting_.reset(
@ -294,7 +362,7 @@ void CommandController::initialize(Settings &, Paths &paths)
auto blockLambda = [](const auto &words, auto channel) { auto blockLambda = [](const auto &words, auto channel) {
if (words.size() < 2) if (words.size() < 2)
{ {
channel->addMessage(makeSystemMessage("Usage: /block [user]")); channel->addMessage(makeSystemMessage("Usage: /block <user>"));
return ""; return "";
} }
@ -339,7 +407,7 @@ void CommandController::initialize(Settings &, Paths &paths)
auto unblockLambda = [](const auto &words, auto channel) { auto unblockLambda = [](const auto &words, auto channel) {
if (words.size() < 2) if (words.size() < 2)
{ {
channel->addMessage(makeSystemMessage("Usage: /unblock [user]")); channel->addMessage(makeSystemMessage("Usage: /unblock <user>"));
return ""; return "";
} }
@ -453,7 +521,7 @@ void CommandController::initialize(Settings &, Paths &paths)
if (words.size() < 2) if (words.size() < 2)
{ {
channel->addMessage( channel->addMessage(
makeSystemMessage("Usage /user [user] (channel)")); makeSystemMessage("Usage: /user <user> [channel]"));
return ""; return "";
} }
QString userName = words[1]; QString userName = words[1];
@ -474,12 +542,33 @@ void CommandController::initialize(Settings &, Paths &paths)
this->registerCommand("/usercard", [](const auto &words, auto channel) { this->registerCommand("/usercard", [](const auto &words, auto channel) {
if (words.size() < 2) if (words.size() < 2)
{ {
channel->addMessage(makeSystemMessage("Usage /usercard [user]")); channel->addMessage(
makeSystemMessage("Usage: /usercard <user> [channel]"));
return ""; return "";
} }
QString userName = words[1]; QString userName = words[1];
stripUserName(userName); stripUserName(userName);
if (words.size() > 2)
{
QString channelName = words[2];
stripChannelName(channelName);
ChannelPtr channelTemp =
getApp()->twitch2->getChannelOrEmpty(channelName);
if (channelTemp->isEmpty())
{
channel->addMessage(makeSystemMessage(
"A usercard can only be displayed for a channel that is "
"currently opened in Chatterino."));
return "";
}
channel = channelTemp;
}
auto *userPopup = new UserInfoPopup( auto *userPopup = new UserInfoPopup(
getSettings()->autoCloseUserPopup, getSettings()->autoCloseUserPopup,
static_cast<QWidget *>(&(getApp()->windows->getMainWindow()))); static_cast<QWidget *>(&(getApp()->windows->getMainWindow())));
@ -602,12 +691,13 @@ void CommandController::initialize(Settings &, Paths &paths)
(!channel->isTwitchChannel() || channel->isEmpty())) (!channel->isTwitchChannel() || channel->isEmpty()))
{ {
channel->addMessage(makeSystemMessage( channel->addMessage(makeSystemMessage(
"Usage: /streamlink [channel]. You can also use the " "Usage: /streamlink <channel>. You can also use the "
"command without arguments in any Twitch channel to open " "command without arguments in any Twitch channel to open "
"it in streamlink.")); "it in streamlink."));
return ""; return "";
} }
stripChannelName(target);
channel->addMessage(makeSystemMessage( channel->addMessage(makeSystemMessage(
QString("Opening %1 in streamlink...").arg(target))); QString("Opening %1 in streamlink...").arg(target)));
openStreamlinkForChannel(target); openStreamlinkForChannel(target);
@ -623,12 +713,13 @@ void CommandController::initialize(Settings &, Paths &paths)
(!channel->isTwitchChannel() || channel->isEmpty())) (!channel->isTwitchChannel() || channel->isEmpty()))
{ {
channel->addMessage(makeSystemMessage( channel->addMessage(makeSystemMessage(
"Usage: /popout [channel]. You can also use the command " "Usage: /popout <channel>. You can also use the command "
"without arguments in any Twitch channel to open its " "without arguments in any Twitch channel to open its "
"popout chat.")); "popout chat."));
return ""; return "";
} }
stripChannelName(target);
QDesktopServices::openUrl( QDesktopServices::openUrl(
QUrl(QString("https://www.twitch.tv/popout/%1/chat?popout=") QUrl(QString("https://www.twitch.tv/popout/%1/chat?popout=")
.arg(target))); .arg(target)));
@ -650,7 +741,7 @@ void CommandController::initialize(Settings &, Paths &paths)
if (words.size() < 2) if (words.size() < 2)
{ {
channel->addMessage( channel->addMessage(
makeSystemMessage("Usage: /settitle <stream title>.")); makeSystemMessage("Usage: /settitle <stream title>"));
return ""; return "";
} }
if (auto twitchChannel = dynamic_cast<TwitchChannel *>(channel.get())) if (auto twitchChannel = dynamic_cast<TwitchChannel *>(channel.get()))
@ -681,7 +772,7 @@ void CommandController::initialize(Settings &, Paths &paths)
if (words.size() < 2) if (words.size() < 2)
{ {
channel->addMessage( channel->addMessage(
makeSystemMessage("Usage: /setgame <stream game>.")); makeSystemMessage("Usage: /setgame <stream game>"));
return ""; return "";
} }
if (auto twitchChannel = dynamic_cast<TwitchChannel *>(channel.get())) if (auto twitchChannel = dynamic_cast<TwitchChannel *>(channel.get()))
@ -746,7 +837,7 @@ void CommandController::initialize(Settings &, Paths &paths)
const ChannelPtr channel) { const ChannelPtr channel) {
if (words.size() < 2) if (words.size() < 2)
{ {
channel->addMessage(makeSystemMessage("Usage: /openurl <URL>.")); channel->addMessage(makeSystemMessage("Usage: /openurl <URL>"));
return ""; return "";
} }
@ -774,6 +865,11 @@ void CommandController::initialize(Settings &, Paths &paths)
return ""; return "";
}); });
this->registerCommand("/raw", [](const QStringList &words, ChannelPtr) {
getApp()->twitch2->sendRawMessage(words.mid(1).join(" "));
return "";
});
} }
void CommandController::save() void CommandController::save()
@ -825,7 +921,7 @@ QString CommandController::execCommand(const QString &textNoEmoji,
if (it != this->userCommands_.end()) if (it != this->userCommands_.end())
{ {
text = getApp()->emotes->emojis.replaceShortCodes( text = getApp()->emotes->emojis.replaceShortCodes(
this->execCustomCommand(words, it.value(), dryRun)); this->execCustomCommand(words, it.value(), dryRun, channel));
words = text.split(' ', Qt::SkipEmptyParts); words = text.split(' ', Qt::SkipEmptyParts);
@ -858,7 +954,7 @@ QString CommandController::execCommand(const QString &textNoEmoji,
const auto it = this->userCommands_.find(commandName); const auto it = this->userCommands_.find(commandName);
if (it != this->userCommands_.end()) if (it != this->userCommands_.end())
{ {
return this->execCustomCommand(words, it.value(), dryRun); return this->execCustomCommand(words, it.value(), dryRun, channel);
} }
} }
@ -877,11 +973,13 @@ void CommandController::registerCommand(QString commandName,
QString CommandController::execCustomCommand(const QStringList &words, QString CommandController::execCustomCommand(const QStringList &words,
const Command &command, const Command &command,
bool dryRun) bool dryRun, ChannelPtr channel,
std::map<QString, QString> context)
{ {
QString result; QString result;
static QRegularExpression parseCommand("(^|[^{])({{)*{(\\d+\\+?)}"); static QRegularExpression parseCommand(
R"((^|[^{])({{)*{(\d+\+?|([a-zA-Z.-]+)(?:;(.+?))?)})");
int lastCaptureEnd = 0; int lastCaptureEnd = 0;
@ -912,8 +1010,28 @@ QString CommandController::execCustomCommand(const QStringList &words,
bool ok; bool ok;
int wordIndex = wordIndexMatch.replace("=", "").toInt(&ok); int wordIndex = wordIndexMatch.replace("=", "").toInt(&ok);
if (!ok || wordIndex == 0) if (!ok || wordIndex == 0)
{
auto varName = match.captured(4);
auto altText = match.captured(5); // alt text or empty string
auto var = COMMAND_VARS.find(varName);
if (var != COMMAND_VARS.end())
{
result += var->second(altText, channel);
}
else
{
auto it = context.find(varName);
if (it != context.end())
{
result += it->second.isEmpty() ? altText : it->second;
}
else
{ {
result += "{" + match.captured(3) + "}"; result += "{" + match.captured(3) + "}";
}
}
continue; continue;
} }

View file

@ -34,6 +34,10 @@ public:
CommandModel *createModel(QObject *parent); CommandModel *createModel(QObject *parent);
QString execCustomCommand(const QStringList &words, const Command &command,
bool dryRun, ChannelPtr channel,
std::map<QString, QString> context = {});
private: private:
void load(Paths &paths); void load(Paths &paths);
@ -57,9 +61,6 @@ private:
std::unique_ptr<pajlada::Settings::Setting<std::vector<Command>>> std::unique_ptr<pajlada::Settings::Setting<std::vector<Command>>>
commandsSetting_; commandsSetting_;
QString execCustomCommand(const QStringList &words, const Command &command,
bool dryRun);
QStringList commandAutoCompletions_; QStringList commandAutoCompletions_;
}; };

View file

@ -28,6 +28,7 @@ ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel)
* flags.points_redeemed * flags.points_redeemed
* flags.sub_message * flags.sub_message
* flags.system_message * flags.system_message
* flags.reward_message
* flags.whisper * flags.whisper
* *
* message.content * message.content
@ -77,6 +78,8 @@ ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel)
{"flags.points_redeemed", m->flags.has(MessageFlag::RedeemedHighlight)}, {"flags.points_redeemed", m->flags.has(MessageFlag::RedeemedHighlight)},
{"flags.sub_message", m->flags.has(MessageFlag::Subscription)}, {"flags.sub_message", m->flags.has(MessageFlag::Subscription)},
{"flags.system_message", m->flags.has(MessageFlag::System)}, {"flags.system_message", m->flags.has(MessageFlag::System)},
{"flags.reward_message",
m->flags.has(MessageFlag::RedeemedChannelPointReward)},
{"flags.whisper", m->flags.has(MessageFlag::Whisper)}, {"flags.whisper", m->flags.has(MessageFlag::Whisper)},
{"message.content", m->messageText}, {"message.content", m->messageText},

View file

@ -22,6 +22,7 @@ static const QMap<QString, QString> validIdentifiersMap = {
{"flags.points_redeemed", "redeemed points?"}, {"flags.points_redeemed", "redeemed points?"},
{"flags.sub_message", "sub/resub message?"}, {"flags.sub_message", "sub/resub message?"},
{"flags.system_message", "system message?"}, {"flags.system_message", "system message?"},
{"flags.reward_message", "channel point reward message?"},
{"flags.whisper", "whisper message?"}, {"flags.whisper", "whisper message?"},
{"message.content", "message text"}, {"message.content", "message text"},
{"message.length", "message length"}}; {"message.length", "message length"}};

View file

@ -1,7 +1,6 @@
#pragma once #pragma once
#include "controllers/accounts/AccountController.hpp" #include "controllers/accounts/AccountController.hpp"
#include "util/RapidJsonSerializeQString.hpp" #include "util/RapidJsonSerializeQString.hpp"
#include "util/RapidjsonHelpers.hpp" #include "util/RapidjsonHelpers.hpp"
@ -15,24 +14,92 @@ namespace chatterino {
class Nickname class Nickname
{ {
public: public:
Nickname(const QString &name, const QString &replace) Nickname(const QString &name, const QString &replace, const bool isRegex,
const bool isCaseSensitive)
: name_(name) : name_(name)
, replace_(replace) , replace_(replace)
, isRegex_(isRegex)
, isCaseSensitive_(isCaseSensitive)
, caseSensitivity_(this->isCaseSensitive_ ? Qt::CaseSensitive
: Qt::CaseInsensitive)
{ {
if (this->isRegex())
{
this->regex_ = QRegularExpression(
name, QRegularExpression::UseUnicodePropertiesOption |
(this->isCaseSensitive()
? QRegularExpression::NoPatternOption
: QRegularExpression::CaseInsensitiveOption));
}
} }
const QString &name() const [[nodiscard]] const QString &name() const
{ {
return this->name_; return this->name_;
} }
const QString &replace() const
[[nodiscard]] const QString &replace() const
{ {
return this->replace_; return this->replace_;
} }
[[nodiscard]] bool isRegex() const
{
return this->isRegex_;
}
[[nodiscard]] Qt::CaseSensitivity caseSensitivity() const
{
return this->caseSensitivity_;
}
[[nodiscard]] const bool &isCaseSensitive() const
{
return this->isCaseSensitive_;
}
[[nodiscard]] bool match(QString &usernameText) const
{
if (this->isRegex())
{
if (!this->regex_.isValid())
{
return false;
}
if (this->name().isEmpty())
{
return false;
}
auto workingCopy = usernameText;
workingCopy.replace(this->regex_, this->replace());
if (workingCopy != usernameText)
{
usernameText = workingCopy;
return true;
}
}
else
{
auto res =
this->name().compare(usernameText, this->caseSensitivity());
if (res == 0)
{
usernameText = this->replace();
return true;
}
}
return false;
}
private: private:
QString name_; QString name_;
QString replace_; QString replace_;
bool isRegex_;
bool isCaseSensitive_;
Qt::CaseSensitivity caseSensitivity_;
QRegularExpression regex_{};
}; };
} // namespace chatterino } // namespace chatterino
@ -48,6 +115,8 @@ struct Serialize<chatterino::Nickname> {
chatterino::rj::set(ret, "name", value.name(), a); chatterino::rj::set(ret, "name", value.name(), a);
chatterino::rj::set(ret, "replace", value.replace(), a); chatterino::rj::set(ret, "replace", value.replace(), a);
chatterino::rj::set(ret, "isRegex", value.isRegex(), a);
chatterino::rj::set(ret, "isCaseSensitive", value.isCaseSensitive(), a);
return ret; return ret;
} }
@ -61,16 +130,21 @@ struct Deserialize<chatterino::Nickname> {
if (!value.IsObject()) if (!value.IsObject())
{ {
PAJLADA_REPORT_ERROR(error) PAJLADA_REPORT_ERROR(error)
return chatterino::Nickname(QString(), QString()); return chatterino::Nickname(QString(), QString(), false, false);
} }
QString _name; QString _name;
QString _replace; QString _replace;
bool _isRegex;
bool _isCaseSensitive;
chatterino::rj::getSafe(value, "name", _name); chatterino::rj::getSafe(value, "name", _name);
chatterino::rj::getSafe(value, "replace", _replace); chatterino::rj::getSafe(value, "replace", _replace);
chatterino::rj::getSafe(value, "isRegex", _isRegex);
chatterino::rj::getSafe(value, "isCaseSensitive", _isCaseSensitive);
return chatterino::Nickname(_name, _replace); return chatterino::Nickname(_name, _replace, _isRegex,
_isCaseSensitive);
} }
}; };

View file

@ -8,7 +8,7 @@
namespace chatterino { namespace chatterino {
NicknamesModel::NicknamesModel(QObject *parent) NicknamesModel::NicknamesModel(QObject *parent)
: SignalVectorModel<Nickname>(2, parent) : SignalVectorModel<Nickname>(4, parent)
{ {
} }
@ -17,7 +17,9 @@ Nickname NicknamesModel::getItemFromRow(std::vector<QStandardItem *> &row,
const Nickname &original) const Nickname &original)
{ {
return Nickname{row[0]->data(Qt::DisplayRole).toString(), return Nickname{row[0]->data(Qt::DisplayRole).toString(),
row[1]->data(Qt::DisplayRole).toString()}; row[1]->data(Qt::DisplayRole).toString(),
row[2]->data(Qt::CheckStateRole).toBool(),
row[3]->data(Qt::CheckStateRole).toBool()};
} }
// turns a row in the model into a vector item // turns a row in the model into a vector item
@ -26,6 +28,8 @@ void NicknamesModel::getRowFromItem(const Nickname &item,
{ {
setStringItem(row[0], item.name()); setStringItem(row[0], item.name());
setStringItem(row[1], item.replace()); setStringItem(row[1], item.replace());
setBoolItem(row[2], item.isRegex());
setBoolItem(row[3], item.isCaseSensitive());
} }
} // namespace chatterino } // namespace chatterino

View file

@ -1,36 +0,0 @@
#include "TaggedUser.hpp"
#include <tuple>
namespace chatterino {
TaggedUser::TaggedUser(ProviderId provider, const QString &name,
const QString &id)
: providerId_(provider)
, name_(name)
, id_(id)
{
}
bool TaggedUser::operator<(const TaggedUser &other) const
{
return std::tie(this->providerId_, this->name_, this->id_) <
std::tie(other.providerId_, other.name_, other.id_);
}
ProviderId TaggedUser::getProviderId() const
{
return this->providerId_;
}
QString TaggedUser::getName() const
{
return this->name_;
}
QString TaggedUser::getId() const
{
return this->id_;
}
} // namespace chatterino

View file

@ -1,26 +0,0 @@
#pragma once
#include "common/ProviderId.hpp"
#include <QString>
namespace chatterino {
class TaggedUser
{
public:
TaggedUser(ProviderId providerId, const QString &name, const QString &id);
bool operator<(const TaggedUser &other) const;
ProviderId getProviderId() const;
QString getName() const;
QString getId() const;
private:
ProviderId providerId_;
QString name_;
QString id_;
};
} // namespace chatterino

View file

@ -1,67 +0,0 @@
#include "TaggedUsersModel.hpp"
#include "Application.hpp"
#include "util/StandardItemHelper.hpp"
namespace chatterino {
// commandmodel
TaggedUsersModel::TaggedUsersModel(QObject *parent)
: SignalVectorModel<TaggedUser>(1, parent)
{
}
// turn a vector item into a model row
TaggedUser TaggedUsersModel::getItemFromRow(std::vector<QStandardItem *> &row,
const TaggedUser &original)
{
return original;
}
// turns a row in the model into a vector item
void TaggedUsersModel::getRowFromItem(const TaggedUser &item,
std::vector<QStandardItem *> &row)
{
setStringItem(row[0], item.getName());
}
void TaggedUsersModel::afterInit()
{
// std::vector<QStandardItem *> row = this->createRow();
// setBoolItem(row[0],
// getSettings()->enableHighlightsSelf.getValue(), true, false);
// row[0]->setData("Your username (automatic)", Qt::DisplayRole);
// setBoolItem(row[1],
// getSettings()->enableHighlightTaskbar.getValue(), true, false);
// setBoolItem(row[2],
// getSettings()->enableHighlightSound.getValue(), true, false);
// row[3]->setFlags(0); this->insertCustomRow(row, 0);
}
// void TaggedUserModel::customRowSetData(const std::vector<QStandardItem *>
// &row, int column,
// const QVariant &value, int role)
//{
// switch (column) {
// case 0: {
// if (role == Qt::CheckStateRole) {
// getSettings()->enableHighlightsSelf.setValue(value.toBool());
// }
// } break;
// case 1: {
// if (role == Qt::CheckStateRole) {
// getSettings()->enableHighlightTaskbar.setValue(value.toBool());
// }
// } break;
// case 2: {
// if (role == Qt::CheckStateRole) {
// getSettings()->enableHighlightSound.setValue(value.toBool());
// }
// } break;
// case 3: {
// // empty element
// } break;
// }
//}
} // namespace chatterino

View file

@ -1,33 +0,0 @@
#pragma once
#include "common/SignalVectorModel.hpp"
#include "controllers/taggedusers/TaggedUser.hpp"
namespace chatterino {
class TaggedUsersController;
class TaggedUsersModel : public SignalVectorModel<TaggedUser>
{
explicit TaggedUsersModel(QObject *parent);
protected:
// turn a vector item into a model row
virtual TaggedUser getItemFromRow(std::vector<QStandardItem *> &row,
const TaggedUser &original) override;
// turns a row in the model into a vector item
virtual void getRowFromItem(const TaggedUser &item,
std::vector<QStandardItem *> &row) override;
virtual void afterInit() override;
// virtual void customRowSetData(const std::vector<QStandardItem *> &row,
// int column,
// const QVariant &value, int role)
// override;
friend class TaggedUsersController;
};
} // namespace chatterino

View file

@ -156,10 +156,6 @@ void SharedMessageBuilder::parseHighlights()
this->message().flags.set(MessageFlag::Highlighted); this->message().flags.set(MessageFlag::Highlighted);
this->message().highlightColor = this->message().highlightColor =
ColorProvider::instance().color(ColorType::Subscription); ColorProvider::instance().color(ColorType::Subscription);
// This message was a subscription.
// Don't check for any other highlight phrases.
return;
} }
// XXX: Non-common term in SharedMessageBuilder // XXX: Non-common term in SharedMessageBuilder
@ -219,7 +215,10 @@ void SharedMessageBuilder::parseHighlights()
<< "sent a message"; << "sent a message";
this->message().flags.set(MessageFlag::Highlighted); this->message().flags.set(MessageFlag::Highlighted);
if (!this->message().flags.has(MessageFlag::Subscription))
{
this->message().highlightColor = userHighlight.getColor(); this->message().highlightColor = userHighlight.getColor();
}
if (userHighlight.showInMentions()) if (userHighlight.showInMentions())
{ {
@ -288,7 +287,10 @@ void SharedMessageBuilder::parseHighlights()
} }
this->message().flags.set(MessageFlag::Highlighted); this->message().flags.set(MessageFlag::Highlighted);
if (!this->message().flags.has(MessageFlag::Subscription))
{
this->message().highlightColor = highlight.getColor(); this->message().highlightColor = highlight.getColor();
}
if (highlight.showInMentions()) if (highlight.showInMentions())
{ {
@ -343,7 +345,11 @@ void SharedMessageBuilder::parseHighlights()
if (!badgeHighlightSet) if (!badgeHighlightSet)
{ {
this->message().flags.set(MessageFlag::Highlighted); this->message().flags.set(MessageFlag::Highlighted);
if (!this->message().flags.has(MessageFlag::Subscription))
{
this->message().highlightColor = highlight.getColor(); this->message().highlightColor = highlight.getColor();
}
badgeHighlightSet = true; badgeHighlightSet = true;
} }

View file

@ -17,7 +17,7 @@ const int MAX_FALLOFF_COUNTER = 60;
// Ratelimits for joinBucket_ // Ratelimits for joinBucket_
const int JOIN_RATELIMIT_BUDGET = 18; const int JOIN_RATELIMIT_BUDGET = 18;
const int JOIN_RATELIMIT_COOLDOWN = 10500; const int JOIN_RATELIMIT_COOLDOWN = 12500;
AbstractIrcServer::AbstractIrcServer() AbstractIrcServer::AbstractIrcServer()
{ {

View file

@ -1,6 +1,7 @@
#include "IrcChannel2.hpp" #include "IrcChannel2.hpp"
#include "debug/AssertInGuiThread.hpp" #include "debug/AssertInGuiThread.hpp"
#include "messages/Message.hpp"
#include "messages/MessageBuilder.hpp" #include "messages/MessageBuilder.hpp"
#include "providers/irc/IrcCommands.hpp" #include "providers/irc/IrcCommands.hpp"
#include "providers/irc/IrcServer.hpp" #include "providers/irc/IrcServer.hpp"
@ -33,9 +34,14 @@ void IrcChannel::sendMessage(const QString &message)
MessageBuilder builder; MessageBuilder builder;
builder.emplace<TimestampElement>(); builder.emplace<TimestampElement>();
builder.emplace<TextElement>(this->server()->nick() + ":", const auto &nick = this->server()->nick();
MessageElementFlag::Username); builder.emplace<TextElement>(nick + ":", MessageElementFlag::Username)
->setLink({Link::UserInfo, nick});
builder.emplace<TextElement>(message, MessageElementFlag::Text); builder.emplace<TextElement>(message, MessageElementFlag::Text);
builder.message().messageText = message;
builder.message().searchText = nick + ": " + message;
builder.message().loginName = nick;
builder.message().displayName = nick;
this->addMessage(builder.release()); this->addMessage(builder.release());
} }
} }

View file

@ -11,6 +11,7 @@
#include "singletons/Settings.hpp" #include "singletons/Settings.hpp"
#include "singletons/Theme.hpp" #include "singletons/Theme.hpp"
#include "singletons/WindowManager.hpp" #include "singletons/WindowManager.hpp"
#include "util/Helpers.hpp"
#include "util/IrcHelpers.hpp" #include "util/IrcHelpers.hpp"
#include "widgets/Window.hpp" #include "widgets/Window.hpp"
@ -36,6 +37,7 @@ MessagePtr IrcMessageBuilder::build()
{ {
// PARSE // PARSE
this->parse(); this->parse();
this->usernameColor_ = getRandomColor(this->ircMessage->nick());
// PUSH ELEMENTS // PUSH ELEMENTS
this->appendChannelName(); this->appendChannelName();

View file

@ -112,11 +112,10 @@ float IrcMessageHandler::similarity(
MessagePtr msg, const LimitedQueueSnapshot<MessagePtr> &messages) MessagePtr msg, const LimitedQueueSnapshot<MessagePtr> &messages)
{ {
float similarityPercent = 0.0f; float similarityPercent = 0.0f;
int bySameUser = 0; int checked = 0;
for (int i = 1; bySameUser < getSettings()->hideSimilarMaxMessagesToCheck; for (int i = 1; i <= messages.size(); ++i)
++i)
{ {
if (messages.size() < i) if (checked >= getSettings()->hideSimilarMaxMessagesToCheck)
{ {
break; break;
} }
@ -126,11 +125,12 @@ float IrcMessageHandler::similarity(
{ {
break; break;
} }
if (msg->loginName != prevMsg->loginName) if (getSettings()->hideSimilarBySameUser &&
msg->loginName != prevMsg->loginName)
{ {
continue; continue;
} }
++bySameUser; ++checked;
similarityPercent = std::max( similarityPercent = std::max(
similarityPercent, similarityPercent,
relativeSimilarity(msg->messageText, prevMsg->messageText)); relativeSimilarity(msg->messageText, prevMsg->messageText));
@ -313,13 +313,10 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *_message,
const auto highlighted = msg->flags.has(MessageFlag::Highlighted); const auto highlighted = msg->flags.has(MessageFlag::Highlighted);
const auto showInMentions = msg->flags.has(MessageFlag::ShowInMentions); const auto showInMentions = msg->flags.has(MessageFlag::ShowInMentions);
if (!isSub)
{
if (highlighted && showInMentions) if (highlighted && showInMentions)
{ {
server.mentionsChannel->addMessage(msg); server.mentionsChannel->addMessage(msg);
} }
}
chan->addMessage(msg); chan->addMessage(msg);
if (auto chatters = dynamic_cast<ChannelChatters *>(chan.get())) if (auto chatters = dynamic_cast<ChannelChatters *>(chan.get()))
@ -813,8 +810,7 @@ void IrcMessageHandler::handleNoticeMessage(Communi::IrcNoticeMessage *message)
if (tags == "bad_delete_message_error" || tags == "usage_delete") if (tags == "bad_delete_message_error" || tags == "usage_delete")
{ {
channel->addMessage(makeSystemMessage( channel->addMessage(makeSystemMessage(
"Usage: \"/delete <msg-id>\" - can't take more " "Usage: /delete <msg-id>. Can't take more than one argument"));
"than one argument"));
} }
else if (tags == "host_on" || tags == "host_target_went_offline") else if (tags == "host_on" || tags == "host_target_went_offline")
{ {

View file

@ -377,6 +377,16 @@ void TwitchChannel::sendMessage(const QString &message)
if (parsedMessage == this->lastSentMessage_) if (parsedMessage == this->lastSentMessage_)
{ {
auto spaceIndex = parsedMessage.indexOf(' '); auto spaceIndex = parsedMessage.indexOf(' ');
// If the message starts with either '/' or '.' Twitch will treat it as a command, omitting
// first space and only rest of the arguments treated as actual message content
// In cases when user sends a message like ". .a b" first character and first space are omitted as well
bool ignoreFirstSpace =
parsedMessage.at(0) == '/' || parsedMessage.at(0) == '.';
if (ignoreFirstSpace)
{
spaceIndex = parsedMessage.indexOf(' ', spaceIndex + 1);
}
if (spaceIndex == -1) if (spaceIndex == -1)
{ {
// no spaces found, fall back to old magic character // no spaces found, fall back to old magic character

View file

@ -183,29 +183,7 @@ MessagePtr TwitchMessageBuilder::build()
this->emplace<TimestampElement>( this->emplace<TimestampElement>(
calculateMessageTimestamp(this->ircMessage)); calculateMessageTimestamp(this->ircMessage));
bool addModerationElement = true; if (this->shouldAddModerationElements())
if (this->senderIsBroadcaster)
{
addModerationElement = false;
}
else
{
bool hasUserType = this->tags.contains("user-type");
if (hasUserType)
{
QString userType = this->tags.value("user-type").toString();
if (userType == "mod")
{
if (!args.isStaffOrBroadcaster)
{
addModerationElement = false;
}
}
}
}
if (addModerationElement)
{ {
this->emplace<TwitchModerationElement>(); this->emplace<TwitchModerationElement>();
} }
@ -660,13 +638,11 @@ void TwitchMessageBuilder::appendUsername()
} }
auto nicknames = getCSettings().nicknames.readOnly(); auto nicknames = getCSettings().nicknames.readOnly();
auto loginLower = this->message().loginName.toLower();
for (const auto &nickname : *nicknames) for (const auto &nickname : *nicknames)
{ {
if (nickname.name().toLower() == loginLower) if (nickname.match(usernameText))
{ {
usernameText = nickname.replace();
break; break;
} }
} }
@ -1230,6 +1206,24 @@ Outcome TwitchMessageBuilder::tryParseCheermote(const QString &string)
return Success; return Success;
} }
bool TwitchMessageBuilder::shouldAddModerationElements() const
{
if (this->senderIsBroadcaster)
{
// You cannot timeout the broadcaster
return false;
}
if (this->tags.value("user-type").toString() == "mod" &&
!this->args.isStaffOrBroadcaster)
{
// You cannot timeout moderators UNLESS you are Twitch Staff or the broadcaster of the channel
return false;
}
return true;
}
void TwitchMessageBuilder::appendChannelPointRewardMessage( void TwitchMessageBuilder::appendChannelPointRewardMessage(
const ChannelPointReward &reward, MessageBuilder *builder, bool isMod, const ChannelPointReward &reward, MessageBuilder *builder, bool isMod,
bool isBroadcaster) bool isBroadcaster)

View file

@ -90,6 +90,8 @@ private:
void appendFfzBadges(); void appendFfzBadges();
Outcome tryParseCheermote(const QString &string); Outcome tryParseCheermote(const QString &string);
bool shouldAddModerationElements() const;
QString roomID_; QString roomID_;
bool hasBits_ = false; bool hasBits_ = false;
QString bits; QString bits;

View file

@ -22,7 +22,6 @@ namespace chatterino {
class HighlightPhrase; class HighlightPhrase;
class HighlightBlacklistUser; class HighlightBlacklistUser;
class IgnorePhrase; class IgnorePhrase;
class TaggedUser;
class FilterRecord; class FilterRecord;
class Nickname; class Nickname;
@ -40,7 +39,6 @@ public:
SignalVector<QString> &mutedChannels; SignalVector<QString> &mutedChannels;
SignalVector<FilterRecordPtr> &filterRecords; SignalVector<FilterRecordPtr> &filterRecords;
SignalVector<Nickname> &nicknames; SignalVector<Nickname> &nicknames;
//SignalVector<TaggedUser> &taggedUsers;
SignalVector<ModerationAction> &moderationActions; SignalVector<ModerationAction> &moderationActions;
bool isHighlightedUser(const QString &username); bool isHighlightedUser(const QString &username);
@ -392,6 +390,8 @@ public:
BoolSetting colorSimilarDisabled = {"/similarity/colorSimilarDisabled", BoolSetting colorSimilarDisabled = {"/similarity/colorSimilarDisabled",
true}; true};
BoolSetting hideSimilar = {"/similarity/hideSimilar", false}; BoolSetting hideSimilar = {"/similarity/hideSimilar", false};
BoolSetting hideSimilarBySameUser = {"/similarity/hideSimilarBySameUser",
true};
BoolSetting hideSimilarMyself = {"/similarity/hideSimilarMyself", false}; BoolSetting hideSimilarMyself = {"/similarity/hideSimilarMyself", false};
BoolSetting shownSimilarTriggerHighlights = { BoolSetting shownSimilarTriggerHighlights = {
"/similarity/shownSimilarTriggerHighlights", false}; "/similarity/shownSimilarTriggerHighlights", false};

View file

@ -56,11 +56,6 @@ public:
private: private:
void actuallyUpdate(double hue, double multiplier) override; void actuallyUpdate(double hue, double multiplier) override;
void fillLookupTableValues(double (&array)[360], double from, double to,
double fromValue, double toValue);
double middleLookupTable_[360] = {};
double minLookupTable_[360] = {};
pajlada::Signals::NoArgSignal repaintVisibleChatWidgets_; pajlada::Signals::NoArgSignal repaintVisibleChatWidgets_;

View file

@ -232,7 +232,9 @@ void Updates::installUpdates()
void Updates::checkForUpdates() void Updates::checkForUpdates()
{ {
if (!Version::instance().isSupportedOS()) auto version = Version::instance();
if (!version.isSupportedOS())
{ {
qCDebug(chatterinoUpdate) qCDebug(chatterinoUpdate)
<< "Update checking disabled because OS doesn't appear to be one " << "Update checking disabled because OS doesn't appear to be one "
@ -241,7 +243,7 @@ void Updates::checkForUpdates()
} }
// Disable updates on Flatpak // Disable updates on Flatpak
if (QFileInfo::exists("/.flatpak-info")) if (version.isFlatpak())
{ {
return; return;
} }

View file

@ -378,20 +378,20 @@ void WindowManager::save()
QJsonDocument document; QJsonDocument document;
// "serialize" // "serialize"
QJsonArray window_arr; QJsonArray windowArr;
for (Window *window : this->windows_) for (Window *window : this->windows_)
{ {
QJsonObject window_obj; QJsonObject windowObj;
// window type // window type
switch (window->getType()) switch (window->getType())
{ {
case WindowType::Main: case WindowType::Main:
window_obj.insert("type", "main"); windowObj.insert("type", "main");
break; break;
case WindowType::Popup: case WindowType::Popup:
window_obj.insert("type", "popup"); windowObj.insert("type", "popup");
break; break;
case WindowType::Attached:; case WindowType::Attached:;
@ -399,68 +399,48 @@ void WindowManager::save()
if (window->isMaximized()) if (window->isMaximized())
{ {
window_obj.insert("state", "maximized"); windowObj.insert("state", "maximized");
} }
else if (window->isMinimized()) else if (window->isMinimized())
{ {
window_obj.insert("state", "minimized"); windowObj.insert("state", "minimized");
} }
// window geometry // window geometry
auto rect = window->getBounds(); auto rect = window->getBounds();
window_obj.insert("x", rect.x()); windowObj.insert("x", rect.x());
window_obj.insert("y", rect.y()); windowObj.insert("y", rect.y());
window_obj.insert("width", rect.width()); windowObj.insert("width", rect.width());
window_obj.insert("height", rect.height()); windowObj.insert("height", rect.height());
QJsonObject emote_popup_obj; QJsonObject emotePopupObj;
emote_popup_obj.insert("x", this->emotePopupPos_.x()); emotePopupObj.insert("x", this->emotePopupPos_.x());
emote_popup_obj.insert("y", this->emotePopupPos_.y()); emotePopupObj.insert("y", this->emotePopupPos_.y());
window_obj.insert("emotePopup", emote_popup_obj); windowObj.insert("emotePopup", emotePopupObj);
// window tabs // window tabs
QJsonArray tabs_arr; QJsonArray tabsArr;
for (int tab_i = 0; tab_i < window->getNotebook().getPageCount(); for (int tabIndex = 0; tabIndex < window->getNotebook().getPageCount();
tab_i++) tabIndex++)
{ {
QJsonObject tab_obj; QJsonObject tabObj;
SplitContainer *tab = dynamic_cast<SplitContainer *>( SplitContainer *tab = dynamic_cast<SplitContainer *>(
window->getNotebook().getPageAt(tab_i)); window->getNotebook().getPageAt(tabIndex));
assert(tab != nullptr); assert(tab != nullptr);
// custom tab title bool isSelected = window->getNotebook().getSelectedPage() == tab;
if (tab->getTab()->hasCustomTitle()) WindowManager::encodeTab(tab, isSelected, tabObj);
{ tabsArr.append(tabObj);
tab_obj.insert("title", tab->getTab()->getCustomTitle());
} }
// selected windowObj.insert("tabs", tabsArr);
if (window->getNotebook().getSelectedPage() == tab) windowArr.append(windowObj);
{
tab_obj.insert("selected", true);
}
// highlighting on new messages
tab_obj.insert("highlightsEnabled",
tab->getTab()->hasHighlightsEnabled());
// splits
QJsonObject splits;
this->encodeNodeRecursively(tab->getBaseNode(), splits);
tab_obj.insert("splits2", splits);
tabs_arr.append(tab_obj);
}
window_obj.insert("tabs", tabs_arr);
window_arr.append(window_obj);
} }
QJsonObject obj; QJsonObject obj;
obj.insert("windows", window_arr); obj.insert("windows", windowArr);
document.setObject(obj); document.setObject(obj);
// save file // save file
@ -496,6 +476,32 @@ void WindowManager::queueSave()
this->saveTimer->start(10s); this->saveTimer->start(10s);
} }
void WindowManager::encodeTab(SplitContainer *tab, bool isSelected,
QJsonObject &obj)
{
// custom tab title
if (tab->getTab()->hasCustomTitle())
{
obj.insert("title", tab->getTab()->getCustomTitle());
}
// selected
if (isSelected)
{
obj.insert("selected", true);
}
// highlighting on new messages
obj.insert("highlightsEnabled", tab->getTab()->hasHighlightsEnabled());
// splits
QJsonObject splits;
WindowManager::encodeNodeRecursively(tab->getBaseNode(), splits);
obj.insert("splits2", splits);
}
void WindowManager::encodeNodeRecursively(SplitNode *node, QJsonObject &obj) void WindowManager::encodeNodeRecursively(SplitNode *node, QJsonObject &obj)
{ {
switch (node->getType()) switch (node->getType())
@ -505,11 +511,12 @@ void WindowManager::encodeNodeRecursively(SplitNode *node, QJsonObject &obj)
obj.insert("moderationMode", node->getSplit()->getModerationMode()); obj.insert("moderationMode", node->getSplit()->getModerationMode());
QJsonObject split; QJsonObject split;
encodeChannel(node->getSplit()->getIndirectChannel(), split); WindowManager::encodeChannel(node->getSplit()->getIndirectChannel(),
split);
obj.insert("data", split); obj.insert("data", split);
QJsonArray filters; QJsonArray filters;
encodeFilters(node->getSplit(), filters); WindowManager::encodeFilters(node->getSplit(), filters);
obj.insert("filters", filters); obj.insert("filters", filters);
} }
break; break;
@ -519,14 +526,14 @@ void WindowManager::encodeNodeRecursively(SplitNode *node, QJsonObject &obj)
? "horizontal" ? "horizontal"
: "vertical"); : "vertical");
QJsonArray items_arr; QJsonArray itemsArr;
for (const std::unique_ptr<SplitNode> &n : node->getChildren()) for (const std::unique_ptr<SplitNode> &n : node->getChildren())
{ {
QJsonObject subObj; QJsonObject subObj;
this->encodeNodeRecursively(n.get(), subObj); WindowManager::encodeNodeRecursively(n.get(), subObj);
items_arr.append(subObj); itemsArr.append(subObj);
} }
obj.insert("items", items_arr); obj.insert("items", itemsArr);
} }
break; break;
} }

View file

@ -30,6 +30,8 @@ public:
WindowManager(); WindowManager();
~WindowManager() override; ~WindowManager() override;
static void encodeTab(SplitContainer *tab, bool isSelected,
QJsonObject &obj);
static void encodeChannel(IndirectChannel channel, QJsonObject &obj); static void encodeChannel(IndirectChannel channel, QJsonObject &obj);
static void encodeFilters(Split *split, QJsonArray &arr); static void encodeFilters(Split *split, QJsonArray &arr);
static IndirectChannel decodeChannel(const SplitDescriptor &descriptor); static IndirectChannel decodeChannel(const SplitDescriptor &descriptor);
@ -99,7 +101,8 @@ public:
pajlada::Signals::Signal<SplitContainer *> selectSplitContainer; pajlada::Signals::Signal<SplitContainer *> selectSplitContainer;
private: private:
void encodeNodeRecursively(SplitContainer::Node *node, QJsonObject &obj); static void encodeNodeRecursively(SplitContainer::Node *node,
QJsonObject &obj);
// Load window layout from the window-layout.json file // Load window layout from the window-layout.json file
WindowLayout loadWindowLayoutFromFile() const; WindowLayout loadWindowLayoutFromFile() const;

View file

@ -10,6 +10,7 @@
#include <QFileInfo> #include <QFileInfo>
#include <QProcess> #include <QProcess>
#include "common/QLogging.hpp" #include "common/QLogging.hpp"
#include "common/Version.hpp"
#include <functional> #include <functional>
@ -35,18 +36,6 @@ namespace {
#endif #endif
} }
QString getStreamlinkProgram()
{
if (getSettings()->streamlinkUseCustomPath)
{
return getSettings()->streamlinkPath + "/" + getBinaryName();
}
else
{
return getBinaryName();
}
}
bool checkStreamlinkPath(const QString &path) bool checkStreamlinkPath(const QString &path)
{ {
QFileInfo fileinfo(path); QFileInfo fileinfo(path);
@ -83,7 +72,27 @@ namespace {
QProcess *createStreamlinkProcess() QProcess *createStreamlinkProcess()
{ {
auto p = new QProcess; auto p = new QProcess;
p->setProgram(getStreamlinkProgram());
const QString path = [] {
if (getSettings()->streamlinkUseCustomPath)
{
return getSettings()->streamlinkPath + "/" + getBinaryName();
}
else
{
return QString{getBinaryName()};
}
}();
if (Version::instance().isFlatpak())
{
p->setProgram("flatpak-spawn");
p->setArguments({"--host", path});
}
else
{
p->setProgram(path);
}
QObject::connect(p, &QProcess::errorOccurred, [=](auto err) { QObject::connect(p, &QProcess::errorOccurred, [=](auto err) {
if (err == QProcess::FailedToStart) if (err == QProcess::FailedToStart)
@ -165,7 +174,8 @@ void getStreamQualities(const QString &channelURL,
} }
}); });
p->setArguments({channelURL, "--default-stream=KKona"}); p->setArguments(p->arguments() +
QStringList{channelURL, "--default-stream=KKona"});
p->start(); p->start();
} }
@ -173,7 +183,9 @@ void getStreamQualities(const QString &channelURL,
void openStreamlink(const QString &channelURL, const QString &quality, void openStreamlink(const QString &channelURL, const QString &quality,
QStringList extraArguments) QStringList extraArguments)
{ {
QStringList arguments = extraArguments << channelURL << quality; auto proc = createStreamlinkProcess();
auto arguments = proc->arguments()
<< extraArguments << channelURL << quality;
// Remove empty arguments before appending additional streamlink options // Remove empty arguments before appending additional streamlink options
// as the options might purposely contain empty arguments // as the options might purposely contain empty arguments
@ -182,7 +194,8 @@ void openStreamlink(const QString &channelURL, const QString &quality,
QString additionalOptions = getSettings()->streamlinkOpts.getValue(); QString additionalOptions = getSettings()->streamlinkOpts.getValue();
arguments << splitCommand(additionalOptions); arguments << splitCommand(additionalOptions);
bool res = QProcess::startDetached(getStreamlinkProgram(), arguments); proc->setArguments(std::move(arguments));
bool res = proc->startDetached();
if (!res) if (!res)
{ {
@ -200,7 +213,7 @@ void openStreamlinkForChannel(const QString &channel)
if (preferredQuality == "choose") if (preferredQuality == "choose")
{ {
getStreamQualities(channelURL, [=](QStringList qualityOptions) { getStreamQualities(channelURL, [=](QStringList qualityOptions) {
QualityPopup::showDialog(channel, qualityOptions); QualityPopup::showDialog(channelURL, qualityOptions);
}); });
return; return;

View file

@ -1,5 +1,7 @@
#include "widgets/BasePopup.hpp" #include "widgets/BasePopup.hpp"
#include <QAbstractButton>
#include <QDialogButtonBox>
#include <QKeyEvent> #include <QKeyEvent>
namespace chatterino { namespace chatterino {
@ -20,4 +22,66 @@ void BasePopup::keyPressEvent(QKeyEvent *e)
BaseWindow::keyPressEvent(e); BaseWindow::keyPressEvent(e);
} }
bool BasePopup::handleEscape(QKeyEvent *e, QDialogButtonBox *buttonBox)
{
assert(buttonBox != nullptr);
if (e->key() == Qt::Key_Escape)
{
auto buttons = buttonBox->buttons();
for (auto *button : buttons)
{
if (auto role = buttonBox->buttonRole(button);
role == QDialogButtonBox::ButtonRole::RejectRole)
{
button->click();
return true;
}
}
}
return false;
}
bool BasePopup::handleEnter(QKeyEvent *e, QDialogButtonBox *buttonBox)
{
assert(buttonBox != nullptr);
if (!e->modifiers() ||
(e->modifiers() & Qt::KeypadModifier && e->key() == Qt::Key_Enter))
{
switch (e->key())
{
case Qt::Key_Enter:
case Qt::Key_Return: {
auto buttons = buttonBox->buttons();
QAbstractButton *acceptButton = nullptr;
for (auto *button : buttons)
{
if (button->hasFocus())
{
button->click();
return true;
}
if (auto role = buttonBox->buttonRole(button);
role == QDialogButtonBox::ButtonRole::AcceptRole)
{
acceptButton = button;
}
}
if (acceptButton != nullptr)
{
acceptButton->click();
return true;
}
}
break;
}
}
return false;
}
} // namespace chatterino } // namespace chatterino

View file

@ -3,6 +3,8 @@
#include "common/FlagsEnum.hpp" #include "common/FlagsEnum.hpp"
#include "widgets/BaseWindow.hpp" #include "widgets/BaseWindow.hpp"
class QDialogButtonBox;
namespace chatterino { namespace chatterino {
class BasePopup : public BaseWindow class BasePopup : public BaseWindow
@ -13,6 +15,12 @@ public:
protected: protected:
void keyPressEvent(QKeyEvent *e) override; void keyPressEvent(QKeyEvent *e) override;
// handleEscape is a helper function for clicking the "Reject" role button of a button box when the Escape button is pressed
bool handleEscape(QKeyEvent *e, QDialogButtonBox *buttonBox);
// handleEnter is a helper function for clicking the "Accept" role button of a button box when Return or Enter is pressed
bool handleEnter(QKeyEvent *e, QDialogButtonBox *buttonBox);
}; };
} // namespace chatterino } // namespace chatterino

View file

@ -338,6 +338,14 @@ void Window::addShortcuts()
} }
}); });
createWindowShortcut(this, "CTRL+SHIFT+N", [this] {
if (auto page = dynamic_cast<SplitContainer *>(
this->notebook_->getSelectedPage()))
{
page->popup();
}
});
// Zoom in // Zoom in
{ {
auto s = new QShortcut(QKeySequence::ZoomIn, this); auto s = new QShortcut(QKeySequence::ZoomIn, this);

View file

@ -7,35 +7,32 @@
namespace chatterino { namespace chatterino {
QualityPopup::QualityPopup(const QString &_channelName, QStringList options) QualityPopup::QualityPopup(const QString &channelURL, QStringList options)
: BasePopup({}, : BasePopup({},
static_cast<QWidget *>(&(getApp()->windows->getMainWindow()))) static_cast<QWidget *>(&(getApp()->windows->getMainWindow())))
, channelName_(_channelName) , channelURL_(channelURL)
{ {
this->ui_.okButton.setText("OK"); this->ui_.selector = new QComboBox(this);
this->ui_.cancelButton.setText("Cancel"); this->ui_.vbox = new QVBoxLayout(this);
this->ui_.buttonBox = new QDialogButtonBox(
QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
QObject::connect(&this->ui_.okButton, &QPushButton::clicked, this, QObject::connect(this->ui_.buttonBox, &QDialogButtonBox::accepted, this,
&QualityPopup::okButtonClicked); &QualityPopup::okButtonClicked);
QObject::connect(&this->ui_.cancelButton, &QPushButton::clicked, this, QObject::connect(this->ui_.buttonBox, &QDialogButtonBox::rejected, this,
&QualityPopup::cancelButtonClicked); &QualityPopup::cancelButtonClicked);
this->ui_.buttonBox.addButton(&this->ui_.okButton, this->ui_.selector->addItems(options);
QDialogButtonBox::ButtonRole::AcceptRole);
this->ui_.buttonBox.addButton(&this->ui_.cancelButton,
QDialogButtonBox::ButtonRole::RejectRole);
this->ui_.selector.addItems(options); this->ui_.vbox->addWidget(this->ui_.selector);
this->ui_.vbox->addWidget(this->ui_.buttonBox);
this->ui_.vbox.addWidget(&this->ui_.selector); this->setLayout(this->ui_.vbox);
this->ui_.vbox.addWidget(&this->ui_.buttonBox);
this->setLayout(&this->ui_.vbox);
} }
void QualityPopup::showDialog(const QString &channelName, QStringList options) void QualityPopup::showDialog(const QString &channelURL, QStringList options)
{ {
QualityPopup *instance = new QualityPopup(channelName, options); QualityPopup *instance = new QualityPopup(channelURL, options);
instance->window()->setWindowTitle("Chatterino - select stream quality"); instance->window()->setWindowTitle("Chatterino - select stream quality");
instance->setAttribute(Qt::WA_DeleteOnClose, true); instance->setAttribute(Qt::WA_DeleteOnClose, true);
@ -43,16 +40,27 @@ void QualityPopup::showDialog(const QString &channelName, QStringList options)
instance->show(); instance->show();
instance->activateWindow(); instance->activateWindow();
instance->raise(); instance->raise();
instance->setFocus(); }
void QualityPopup::keyPressEvent(QKeyEvent *e)
{
if (this->handleEscape(e, this->ui_.buttonBox))
{
return;
}
if (this->handleEnter(e, this->ui_.buttonBox))
{
return;
}
BasePopup::keyPressEvent(e);
} }
void QualityPopup::okButtonClicked() void QualityPopup::okButtonClicked()
{ {
QString channelURL = "twitch.tv/" + this->channelName_;
try try
{ {
openStreamlink(channelURL, this->ui_.selector.currentText()); openStreamlink(this->channelURL_, this->ui_.selector->currentText());
} }
catch (const Exception &ex) catch (const Exception &ex)
{ {

View file

@ -4,7 +4,6 @@
#include <QComboBox> #include <QComboBox>
#include <QDialogButtonBox> #include <QDialogButtonBox>
#include <QPushButton>
#include <QVBoxLayout> #include <QVBoxLayout>
namespace chatterino { namespace chatterino {
@ -12,22 +11,23 @@ namespace chatterino {
class QualityPopup : public BasePopup class QualityPopup : public BasePopup
{ {
public: public:
QualityPopup(const QString &_channelName, QStringList options); QualityPopup(const QString &channelURL, QStringList options);
static void showDialog(const QString &_channelName, QStringList options); static void showDialog(const QString &channelURL, QStringList options);
protected:
void keyPressEvent(QKeyEvent *e) override;
private: private:
void okButtonClicked(); void okButtonClicked();
void cancelButtonClicked(); void cancelButtonClicked();
struct { struct {
QVBoxLayout vbox; QVBoxLayout *vbox;
QComboBox selector; QComboBox *selector;
QDialogButtonBox buttonBox; QDialogButtonBox *buttonBox;
QPushButton okButton;
QPushButton cancelButton;
} ui_; } ui_;
QString channelName_; QString channelURL_;
}; };
} // namespace chatterino } // namespace chatterino

View file

@ -33,7 +33,7 @@
const QString TEXT_VIEWS("Views: %1"); const QString TEXT_VIEWS("Views: %1");
const QString TEXT_FOLLOWERS("Followers: %1"); const QString TEXT_FOLLOWERS("Followers: %1");
const QString TEXT_CREATED("Created: %1"); const QString TEXT_CREATED("Created: %1");
const QString TEXT_TITLE("%1's Usercard"); const QString TEXT_TITLE("%1's Usercard - #%2");
#define TEXT_USER_ID "ID: " #define TEXT_USER_ID "ID: "
#define TEXT_UNAVAILABLE "(not available)" #define TEXT_UNAVAILABLE "(not available)"
@ -513,7 +513,7 @@ void UserInfoPopup::setData(const QString &name, const ChannelPtr &channel)
{ {
this->userName_ = name; this->userName_ = name;
this->channel_ = channel; this->channel_ = channel;
this->setWindowTitle(TEXT_TITLE.arg(name)); this->setWindowTitle(TEXT_TITLE.arg(name, channel->getName()));
this->ui_.nameLabel->setText(name); this->ui_.nameLabel->setText(name);
this->ui_.nameLabel->setProperty("copy-text", name); this->ui_.nameLabel->setProperty("copy-text", name);
@ -598,7 +598,8 @@ void UserInfoPopup::updateUserData()
this->avatarUrl_ = user.profileImageUrl; this->avatarUrl_ = user.profileImageUrl;
this->ui_.nameLabel->setText(user.displayName); this->ui_.nameLabel->setText(user.displayName);
this->setWindowTitle(TEXT_TITLE.arg(user.displayName)); this->setWindowTitle(
TEXT_TITLE.arg(user.displayName, this->channel_->getName()));
this->ui_.viewCountLabel->setText( this->ui_.viewCountLabel->setText(
TEXT_VIEWS.arg(localizeNumbers(user.viewCount))); TEXT_VIEWS.arg(localizeNumbers(user.viewCount)));
this->ui_.createdDateLabel->setText( this->ui_.createdDateLabel->setText(

View file

@ -2101,12 +2101,24 @@ void ChannelView::handleLinkClick(QMouseEvent *event, const Link &link,
} }
} }
value.replace("{user}", layout->getMessage()->loginName) value = getApp()->commands->execCustomCommand(
.replace("{channel}", this->channel_->getName()) QStringList(), Command{"(modaction)", value}, true, channel,
.replace("{msg-id}", layout->getMessage()->id) {
.replace("{message}", layout->getMessage()->messageText); {"user.name", layout->getMessage()->loginName},
{"msg.id", layout->getMessage()->id},
{"msg.text", layout->getMessage()->messageText},
// old placeholders
{"user", layout->getMessage()->loginName},
{"msg-id", layout->getMessage()->id},
{"message", layout->getMessage()->messageText},
// new version of this is inside execCustomCommand
{"channel", this->channel()->getName()},
});
value = getApp()->commands->execCommand(value, channel, false); value = getApp()->commands->execCommand(value, channel, false);
channel->sendMessage(value); channel->sendMessage(value);
} }
break; break;

View file

@ -64,6 +64,16 @@ NotebookTab::NotebookTab(Notebook *notebook)
this->notebook_->removePage(this->page); this->notebook_->removePage(this->page);
}); });
this->menu_.addAction(
"Popup Tab",
[=]() {
if (auto container = dynamic_cast<SplitContainer *>(this->page))
{
container->popup();
}
},
QKeySequence("Ctrl+Shift+N"));
highlightNewMessagesAction_ = highlightNewMessagesAction_ =
new QAction("Mark Tab as Unread on New Messages", &this->menu_); new QAction("Mark Tab as Unread on New Messages", &this->menu_);
highlightNewMessagesAction_->setCheckable(true); highlightNewMessagesAction_->setCheckable(true);

View file

@ -110,7 +110,6 @@ AboutPage::AboutPage()
// clang-format off // clang-format off
l.emplace<QLabel>("Chatterino Wiki can be found <a href=\"" LINK_CHATTERINO_WIKI "\">here</a>")->setOpenExternalLinks(true); l.emplace<QLabel>("Chatterino Wiki can be found <a href=\"" LINK_CHATTERINO_WIKI "\">here</a>")->setOpenExternalLinks(true);
l.emplace<QLabel>("Support <a href=\"" LINK_DONATE "\">Chatterino</a>")->setOpenExternalLinks(true);
l.emplace<QLabel>("All about Chatterino's <a href=\"" LINK_CHATTERINO_FEATURES "\">features</a>")->setOpenExternalLinks(true); l.emplace<QLabel>("All about Chatterino's <a href=\"" LINK_CHATTERINO_FEATURES "\">features</a>")->setOpenExternalLinks(true);
l.emplace<QLabel>("Join the official Chatterino <a href=\"" LINK_CHATTERINO_DISCORD "\">Discord</a>")->setOpenExternalLinks(true); l.emplace<QLabel>("Join the official Chatterino <a href=\"" LINK_CHATTERINO_DISCORD "\">Discord</a>")->setOpenExternalLinks(true);
// clang-format on // clang-format on

View file

@ -520,11 +520,11 @@ void GeneralPage::initLayout(GeneralPageView &layout)
layout.addCheckbox("Title", s.headerStreamTitle); layout.addCheckbox("Title", s.headerStreamTitle);
layout.addSubtitle("R9K"); layout.addSubtitle("R9K");
layout.addDescription( layout.addDescription("Hide similar messages. Toggle hidden "
"Hide similar messages by the same user. Toggle hidden "
"messages by pressing Ctrl+H."); "messages by pressing Ctrl+H.");
layout.addCheckbox("Hide similar messages", s.similarityEnabled); layout.addCheckbox("Hide similar messages", s.similarityEnabled);
//layout.addCheckbox("Gray out matches", s.colorSimilarDisabled); //layout.addCheckbox("Gray out matches", s.colorSimilarDisabled);
layout.addCheckbox("By the same user", s.hideSimilarBySameUser);
layout.addCheckbox("Hide my own messages", s.hideSimilarMyself); layout.addCheckbox("Hide my own messages", s.hideSimilarMyself);
layout.addCheckbox("Receive notification sounds from hidden messages", layout.addCheckbox("Receive notification sounds from hidden messages",
s.shownSimilarTriggerHighlights); s.shownSimilarTriggerHighlights);

View file

@ -49,7 +49,7 @@ void addPhrasesTab(LayoutCreator<QVBoxLayout> layout)
->initialized(&getSettings()->ignoredMessages)) ->initialized(&getSettings()->ignoredMessages))
.getElement(); .getElement();
view->setTitles( view->setTitles(
{"Pattern", "Regex", "Case Sensitive", "Block", "Replacement"}); {"Pattern", "Regex", "Case-sensitive", "Block", "Replacement"});
view->getTableView()->horizontalHeader()->setSectionResizeMode( view->getTableView()->horizontalHeader()->setSectionResizeMode(
QHeaderView::Fixed); QHeaderView::Fixed);
view->getTableView()->horizontalHeader()->setSectionResizeMode( view->getTableView()->horizontalHeader()->setSectionResizeMode(

View file

@ -44,6 +44,8 @@ KeyboardSettingsPage::KeyboardSettingsPage()
form->addRow(new QLabel("Ctrl + Shift + T"), new QLabel("Create new tab")); form->addRow(new QLabel("Ctrl + Shift + T"), new QLabel("Create new tab"));
form->addRow(new QLabel("Ctrl + Shift + W"), form->addRow(new QLabel("Ctrl + Shift + W"),
new QLabel("Close current tab")); new QLabel("Close current tab"));
form->addRow(new QLabel("Ctrl + Shift + N"),
new QLabel("Open current tab as a popup"));
form->addRow(new QLabel("Ctrl + H"), form->addRow(new QLabel("Ctrl + H"),
new QLabel("Hide/Show similar messages (See General->R9K)")); new QLabel("Hide/Show similar messages (See General->R9K)"));

View file

@ -2,7 +2,6 @@
#include "Application.hpp" #include "Application.hpp"
#include "controllers/moderationactions/ModerationActionModel.hpp" #include "controllers/moderationactions/ModerationActionModel.hpp"
#include "controllers/taggedusers/TaggedUsersModel.hpp"
#include "singletons/Logging.hpp" #include "singletons/Logging.hpp"
#include "singletons/Paths.hpp" #include "singletons/Paths.hpp"
#include "util/Helpers.hpp" #include "util/Helpers.hpp"
@ -159,8 +158,8 @@ ModerationPage::ModerationPage()
// clang-format off // clang-format off
auto label = modMode.emplace<QLabel>( auto label = modMode.emplace<QLabel>(
"Moderation mode is enabled by clicking <img width='18' height='18' src=':/buttons/modModeDisabled.png'> in a channel that you moderate.<br><br>" "Moderation mode is enabled by clicking <img width='18' height='18' src=':/buttons/modModeDisabled.png'> in a channel that you moderate.<br><br>"
"Moderation buttons can be bound to chat commands such as \"/ban {user}\", \"/timeout {user} 1000\", \"/w someusername !report {user} was bad in channel {channel}\" or any other custom text commands.<br>" "Moderation buttons can be bound to chat commands such as \"/ban {user.name}\", \"/timeout {user.name} 1000\", \"/w someusername !report {user.name} was bad in channel {channel.name}\" or any other custom text commands.<br>"
"For deleting messages use /delete {msg-id}.<br><br>" "For deleting messages use /delete {msg.id}.<br><br>"
"More information can be found <a href='https://wiki.chatterino.com/Moderation/#moderation-mode'>here</a>."); "More information can be found <a href='https://wiki.chatterino.com/Moderation/#moderation-mode'>here</a>.");
label->setOpenExternalLinks(true); label->setOpenExternalLinks(true);
label->setWordWrap(true); label->setWordWrap(true);
@ -190,22 +189,8 @@ ModerationPage::ModerationPage()
view->addButtonPressed.connect([] { view->addButtonPressed.connect([] {
getSettings()->moderationActions.append( getSettings()->moderationActions.append(
ModerationAction("/timeout {user} 300")); ModerationAction("/timeout {user.name} 300"));
}); });
/*auto taggedUsers = tabs.appendTab(new QVBoxLayout, "Tagged users");
{
EditableModelView *view = *taggedUsers.emplace<EditableModelView>(
app->taggedUsers->createModel(nullptr));
view->setTitles({"Name"});
view->getTableView()->horizontalHeader()->setStretchLastSection(true);
view->addButtonPressed.connect([] {
getApp()->taggedUsers->users.appendItem(
TaggedUser(ProviderId::Twitch, "example", "xD"));
});
}*/
} }
this->addModerationButtonSettings(tabs); this->addModerationButtonSettings(tabs);

View file

@ -29,19 +29,22 @@ NicknamesPage::NicknamesPage()
->initialized(&getSettings()->nicknames)) ->initialized(&getSettings()->nicknames))
.getElement(); .getElement();
view->setTitles({"Username", "Nickname"}); view->setTitles({"Username", "Nickname", "Enable regex", "Case-sensitive"});
view->getTableView()->horizontalHeader()->setSectionResizeMode( view->getTableView()->horizontalHeader()->setSectionResizeMode(
QHeaderView::Interactive); QHeaderView::Fixed);
view->getTableView()->horizontalHeader()->setSectionResizeMode(
0, QHeaderView::Stretch);
view->getTableView()->horizontalHeader()->setSectionResizeMode( view->getTableView()->horizontalHeader()->setSectionResizeMode(
1, QHeaderView::Stretch); 1, QHeaderView::Stretch);
view->addButtonPressed.connect([] { view->addButtonPressed.connect([] {
getSettings()->nicknames.append(Nickname{"Username", "Nickname"}); getSettings()->nicknames.append(
Nickname{"Username", "Nickname", false, false});
}); });
QTimer::singleShot(1, [view] { QTimer::singleShot(1, [view] {
view->getTableView()->resizeColumnsToContents(); view->getTableView()->resizeColumnsToContents();
view->getTableView()->setColumnWidth(0, 250); view->getTableView()->setColumnWidth(0, 200);
}); });
} }

View file

@ -262,10 +262,11 @@ Split::Split(QWidget *parent)
if (getSettings()->askOnImageUpload.getValue()) if (getSettings()->askOnImageUpload.getValue())
{ {
QMessageBox msgBox; QMessageBox msgBox;
msgBox.setWindowTitle("Chatterino");
msgBox.setText("Image upload"); msgBox.setText("Image upload");
msgBox.setInformativeText( msgBox.setInformativeText(
"You are uploading an image to a 3rd party service not in " "You are uploading an image to a 3rd party service not in "
"control of the chatterino team. You may not be able to " "control of the Chatterino team. You may not be able to "
"remove the image from the site. Are you okay with this?"); "remove the image from the site. Are you okay with this?");
msgBox.addButton(QMessageBox::Cancel); msgBox.addButton(QMessageBox::Cancel);
msgBox.addButton(QMessageBox::Yes); msgBox.addButton(QMessageBox::Yes);

View file

@ -9,6 +9,7 @@
#include "util/Helpers.hpp" #include "util/Helpers.hpp"
#include "util/LayoutCreator.hpp" #include "util/LayoutCreator.hpp"
#include "widgets/Notebook.hpp" #include "widgets/Notebook.hpp"
#include "widgets/Window.hpp"
#include "widgets/helper/ChannelView.hpp" #include "widgets/helper/ChannelView.hpp"
#include "widgets/helper/NotebookTab.hpp" #include "widgets/helper/NotebookTab.hpp"
#include "widgets/splits/ClosedSplits.hpp" #include "widgets/splits/ClosedSplits.hpp"
@ -761,6 +762,33 @@ void SplitContainer::applyFromDescriptor(const NodeDescriptor &rootNode)
this->layout(); this->layout();
} }
void SplitContainer::popup()
{
Window &window = getApp()->windows->createWindow(WindowType::Popup);
auto popupContainer = window.getNotebook().getOrAddSelectedPage();
QJsonObject encodedTab;
WindowManager::encodeTab(this, true, encodedTab);
TabDescriptor tab = TabDescriptor::loadFromJSON(encodedTab);
// custom title
if (!tab.customTitle_.isEmpty())
{
popupContainer->getTab()->setCustomTitle(tab.customTitle_);
}
// highlighting on new messages
popupContainer->getTab()->setHighlightsEnabled(tab.highlightsEnabled_);
// splits
if (tab.rootNode_)
{
popupContainer->applyFromDescriptor(*tab.rootNode_);
}
window.show();
}
void SplitContainer::applyFromDescriptorRecursively( void SplitContainer::applyFromDescriptorRecursively(
const NodeDescriptor &rootNode, Node *node) const NodeDescriptor &rootNode, Node *node)
{ {

View file

@ -202,6 +202,8 @@ public:
void applyFromDescriptor(const NodeDescriptor &rootNode); void applyFromDescriptor(const NodeDescriptor &rootNode);
void popup();
protected: protected:
void paintEvent(QPaintEvent *event) override; void paintEvent(QPaintEvent *event) override;

View file

@ -563,7 +563,7 @@ void SplitInput::insertCompletionText(const QString &input_)
auto input = input_ + ' '; auto input = input_ + ' ';
auto text = edit.toPlainText(); auto text = edit.toPlainText();
auto position = edit.textCursor().position(); auto position = edit.textCursor().position() - 1;
for (int i = clamp(position, 0, static_cast<int>(text.length() - 1)); for (int i = clamp(position, 0, static_cast<int>(text.length() - 1));
i >= 0; i--) i >= 0; i--)
@ -585,7 +585,7 @@ void SplitInput::insertCompletionText(const QString &input_)
if (done) if (done)
{ {
auto cursor = edit.textCursor(); auto cursor = edit.textCursor();
edit.setText(text.remove(i, position - i).insert(i, input)); edit.setText(text.remove(i, position - i + 1).insert(i, input));
cursor.setPosition(i + input.size()); cursor.setPosition(i + input.size());
edit.setTextCursor(cursor); edit.setTextCursor(cursor);
@ -641,9 +641,6 @@ void SplitInput::editTextChanged()
this->textChanged.invoke(text); this->textChanged.invoke(text);
text = text.trimmed(); text = text.trimmed();
static QRegularExpression spaceRegex("\\s\\s+");
text = text.replace(spaceRegex, " ");
text = text =
app->commands->execCommand(text, this->split_->getChannel(), true); app->commands->execCommand(text, this->split_->getChannel(), true);
} }

View file

@ -2,6 +2,7 @@ project(chatterino-test)
set(test_SOURCES set(test_SOURCES
${CMAKE_CURRENT_LIST_DIR}/src/main.cpp ${CMAKE_CURRENT_LIST_DIR}/src/main.cpp
${CMAKE_CURRENT_LIST_DIR}/src/ChannelChatters.cpp
${CMAKE_CURRENT_LIST_DIR}/src/AccessGuard.cpp ${CMAKE_CURRENT_LIST_DIR}/src/AccessGuard.cpp
${CMAKE_CURRENT_LIST_DIR}/src/NetworkCommon.cpp ${CMAKE_CURRENT_LIST_DIR}/src/NetworkCommon.cpp
${CMAKE_CURRENT_LIST_DIR}/src/NetworkRequest.cpp ${CMAKE_CURRENT_LIST_DIR}/src/NetworkRequest.cpp

View file

@ -0,0 +1,113 @@
#include "common/ChannelChatters.hpp"
#include <gtest/gtest.h>
#include <QColor>
#include <QStringList>
namespace chatterino {
class MockChannel : public Channel
{
public:
MockChannel(const QString &name)
: Channel(name, Channel::Type::Twitch)
{
}
};
} // namespace chatterino
using namespace chatterino;
// Ensure inserting the same user does not increase the size of the stored colors
TEST(ChatterChatters, insertSameUser)
{
MockChannel channel("test");
ChannelChatters chatters(channel);
EXPECT_EQ(chatters.colorsSize(), 0);
chatters.setUserColor("pajlada", QColor("#fff"));
EXPECT_EQ(chatters.colorsSize(), 1);
chatters.setUserColor("pajlada", QColor("#fff"));
EXPECT_EQ(chatters.colorsSize(), 1);
}
// Ensure we can update a chatters color
TEST(ChatterChatters, insertSameUserUpdatesColor)
{
MockChannel channel("test");
ChannelChatters chatters(channel);
chatters.setUserColor("pajlada", QColor("#fff"));
EXPECT_EQ(chatters.getUserColor("pajlada"), QColor("#fff"));
chatters.setUserColor("pajlada", QColor("#f0f"));
EXPECT_EQ(chatters.getUserColor("pajlada"), QColor("#f0f"));
}
// Ensure getting a non-existant users color returns an invalid QColor
TEST(ChatterChatters, getNonExistantUser)
{
MockChannel channel("test");
ChannelChatters chatters(channel);
EXPECT_EQ(chatters.getUserColor("nonexistantuser"), QColor());
}
// Ensure getting a user doesn't create an entry
TEST(ChatterChatters, getDoesNotCreate)
{
MockChannel channel("test");
ChannelChatters chatters(channel);
EXPECT_EQ(chatters.colorsSize(), 0);
chatters.getUserColor("nonexistantuser");
EXPECT_EQ(chatters.colorsSize(), 0);
}
// Ensure the least recently used entry is purged when we reach MAX_SIZE
TEST(ChatterChatters, insertMaxSize)
{
MockChannel channel("test");
ChannelChatters chatters(channel);
// Prime chatters with 2 control entries
chatters.setUserColor("pajlada", QColor("#f00"));
chatters.setUserColor("zneix", QColor("#f0f"));
EXPECT_EQ(chatters.getUserColor("pajlada"), QColor("#f00"));
EXPECT_EQ(chatters.getUserColor("zneix"), QColor("#f0f"));
EXPECT_EQ(chatters.getUserColor("nonexistantuser"), QColor());
EXPECT_EQ(chatters.colorsSize(), 2);
for (int i = 0; i < ChannelChatters::maxChatterColorCount - 1; ++i)
{
auto username = QString("user%1").arg(i);
chatters.setUserColor(username, QColor("#00f"));
}
// Should have bumped ONE entry out (pajlada)
EXPECT_EQ(chatters.getUserColor("pajlada"), QColor());
EXPECT_EQ(chatters.getUserColor("zneix"), QColor("#f0f"));
EXPECT_EQ(chatters.getUserColor("user1"), QColor("#00f"));
chatters.setUserColor("newuser", QColor("#00e"));
for (int i = 0; i < ChannelChatters::maxChatterColorCount; ++i)
{
auto username = QString("user%1").arg(i);
chatters.setUserColor(username, QColor("#00f"));
}
// One more entry should be bumped out (zneix)
EXPECT_EQ(chatters.getUserColor("pajlada"), QColor());
EXPECT_EQ(chatters.getUserColor("zneix"), QColor());
EXPECT_EQ(chatters.getUserColor("user1"), QColor("#00f"));
}