diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 8ac1ca177..47dbbb2a4 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -1,5 +1,8 @@ blank_issues_enabled: false 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 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! diff --git a/.github/ISSUE_TEMPLATE/z_browser_extension.md b/.github/ISSUE_TEMPLATE/z_browser_extension.md deleted file mode 100644 index 2ae9cdf67..000000000 --- a/.github/ISSUE_TEMPLATE/z_browser_extension.md +++ /dev/null @@ -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 diff --git a/.github/workflows/changelog-check.yml b/.github/workflows/changelog-check.yml index 7fbceea74..05a2454e8 100644 --- a/.github/workflows/changelog-check.yml +++ b/.github/workflows/changelog-check.yml @@ -13,7 +13,7 @@ jobs: # Gives an error if there's no change in the changelog (except using label) - name: Changelog check - uses: dangoslen/changelog-enforcer@v2.2.0 + uses: dangoslen/changelog-enforcer@v2.3.1 with: changeLogPath: 'CHANGELOG.md' skipLabels: 'no changelog entry needed, ci, submodules' diff --git a/BUILDING_ON_WINDOWS.md b/BUILDING_ON_WINDOWS.md index a075761f8..da19515ed 100644 --- a/BUILDING_ON_WINDOWS.md +++ b/BUILDING_ON_WINDOWS.md @@ -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 -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` 3. When prompted, copy the OpenSSL DLLs to "The OpenSSL binaries (/bin) directory". diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c62bf8bd..b971a6597 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,11 +2,31 @@ ## 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: 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: 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: 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: Add benchmarks that can be compiled with the `BUILD_BENCHMARKS` CMake flag. Off by default. (#3038) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7135e908b..094d60bb4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. - 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` 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 ``. +- 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 ` +- `Usage: /unblock . Unblocks a user.` +- `Usage: /streamlink ` +- `Usage: /usercard [channel]` + +##### Bad + +- `Usage /streamlink ` - Missing colon after _Usage_. +- `usage: /streamlink ` - _Usage_ must be capitalized. +- `Usage: /streamlink channel` - The required argument `channel` must be wrapped in angle brackets. +- `Usage: /streamlink .` - Don't put a dot after usage if it's not followed by a description. diff --git a/chatterino.pro b/chatterino.pro index 2586ac790..33fdfedee 100644 --- a/chatterino.pro +++ b/chatterino.pro @@ -167,8 +167,6 @@ SOURCES += \ src/controllers/notifications/NotificationController.cpp \ src/controllers/notifications/NotificationModel.cpp \ src/controllers/pings/MutedChannelModel.cpp \ - src/controllers/taggedusers/TaggedUser.cpp \ - src/controllers/taggedusers/TaggedUsersModel.cpp \ src/debug/Benchmark.cpp \ src/main.cpp \ src/messages/Emote.cpp \ @@ -400,8 +398,6 @@ HEADERS += \ src/controllers/notifications/NotificationController.hpp \ src/controllers/notifications/NotificationModel.hpp \ src/controllers/pings/MutedChannelModel.hpp \ - src/controllers/taggedusers/TaggedUser.hpp \ - src/controllers/taggedusers/TaggedUsersModel.hpp \ src/debug/AssertInGuiThread.hpp \ src/debug/Benchmark.hpp \ src/ForwardDecl.hpp \ diff --git a/lib/lrucache/lrucache/lrucache.hpp b/lib/lrucache/lrucache/lrucache.hpp index aa2c6c511..ef7d031db 100644 --- a/lib/lrucache/lrucache/lrucache.hpp +++ b/lib/lrucache/lrucache/lrucache.hpp @@ -35,6 +35,7 @@ public: lru_cache(lru_cache &&other) : _cache_items_list(std::move(other._cache_items_list)) , _cache_items_map(std::move(other._cache_items_map)) + , _max_size(other._max_size) { other._cache_items_list.clear(); other._cache_items_map.clear(); @@ -44,6 +45,7 @@ public: { _cache_items_list = std::move(other._cache_items_list); _cache_items_map = std::move(other._cache_items_map); + _max_size = other._max_size; other._cache_items_list.clear(); other._cache_items_map.clear(); return *this; diff --git a/src/Application.cpp b/src/Application.cpp index f0f79c42f..4de581187 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -286,7 +286,7 @@ void Application::initPubsub() auto chan = this->twitch.server->getChannelOrEmptyByID(action.roomID); - if (chan->isEmpty()) + if (chan->isEmpty() || getSettings()->hideDeletionActions) { return; } diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 78015123f..e3ac9790b 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -110,11 +110,6 @@ set(SOURCE_FILES controllers/pings/MutedChannelModel.cpp 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.hpp diff --git a/src/common/ChannelChatters.cpp b/src/common/ChannelChatters.cpp index 8b72f7b17..f9eb1ea65 100644 --- a/src/common/ChannelChatters.cpp +++ b/src/common/ChannelChatters.cpp @@ -74,6 +74,12 @@ void ChannelChatters::updateOnlineChatters( chatters_->updateOnlineChatters(chatters); } +size_t ChannelChatters::colorsSize() const +{ + auto size = this->chatterColors_.access()->size(); + return size; +} + const QColor ChannelChatters::getUserColor(const QString &user) { const auto chatterColors = this->chatterColors_.access(); diff --git a/src/common/ChannelChatters.hpp b/src/common/ChannelChatters.hpp index 3a2c4e49a..70ca53924 100644 --- a/src/common/ChannelChatters.hpp +++ b/src/common/ChannelChatters.hpp @@ -25,9 +25,13 @@ public: void setUserColor(const QString &user, const QColor &color); void updateOnlineChatters(const std::unordered_set &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; +private: Channel &channel_; // maps 2 char prefix to set of names diff --git a/src/common/Version.cpp b/src/common/Version.cpp index aa8639dce..b678fe48a 100644 --- a/src/common/Version.cpp +++ b/src/common/Version.cpp @@ -2,6 +2,8 @@ #include "common/Modes.hpp" +#include + #define UGLYMACROHACK1(s) #s #define FROM_EXTERNAL_DEFINE(s) UGLYMACROHACK1(s) @@ -71,4 +73,9 @@ const bool &Version::isSupportedOS() const return this->isSupportedOS_; } +bool Version::isFlatpak() const +{ + return QFileInfo::exists("/.flatpak-info"); +} + } // namespace chatterino diff --git a/src/common/Version.hpp b/src/common/Version.hpp index 1a858c0ea..967363358 100644 --- a/src/common/Version.hpp +++ b/src/common/Version.hpp @@ -29,6 +29,7 @@ public: const QString &dateOfBuild() const; const QString &fullVersion() const; const bool &isSupportedOS() const; + bool isFlatpak() const; private: Version(); diff --git a/src/common/WindowDescriptors.cpp b/src/common/WindowDescriptors.cpp index 7a82596f3..902de665f 100644 --- a/src/common/WindowDescriptors.cpp +++ b/src/common/WindowDescriptors.cpp @@ -110,6 +110,42 @@ void SplitDescriptor::loadFromJSON(SplitDescriptor &descriptor, 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(splitRoot); + } + else if (nodeType == "horizontal" || nodeType == "vertical") + { + tab.rootNode_ = loadNodes(splitRoot); + } + } + + return tab; +} + WindowLayout WindowLayout::loadFromFile(const QString &path) { WindowLayout layout; @@ -117,15 +153,15 @@ WindowLayout WindowLayout::loadFromFile(const QString &path) bool hasSetAMainWindow = false; // "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; // Load window type - QString type_val = window_obj.value("type").toString(); - auto type = type_val == "main" ? WindowType::Main : WindowType::Popup; + QString typeVal = windowObj.value("type").toString(); + auto type = typeVal == "main" ? WindowType::Main : WindowType::Popup; if (type == WindowType::Main) { @@ -142,21 +178,21 @@ WindowLayout WindowLayout::loadFromFile(const QString &path) window.type_ = type; // Load window state - if (window_obj.value("state") == "minimized") + if (windowObj.value("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; } // Load window geometry { - int x = window_obj.value("x").toInt(-1); - int y = window_obj.value("y").toInt(-1); - int width = window_obj.value("width").toInt(-1); - int height = window_obj.value("height").toInt(-1); + int x = windowObj.value("x").toInt(-1); + int y = windowObj.value("y").toInt(-1); + int width = windowObj.value("width").toInt(-1); + int height = windowObj.value("height").toInt(-1); window.geometry_ = QRect(x, y, width, height); } @@ -164,23 +200,10 @@ WindowLayout WindowLayout::loadFromFile(const QString &path) bool hasSetASelectedTab = false; // Load window tabs - QJsonArray tabs = window_obj.value("tabs").toArray(); - for (QJsonValue tab_val : tabs) + QJsonArray tabs = windowObj.value("tabs").toArray(); + for (QJsonValue tabVal : tabs) { - TabDescriptor tab; - - 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); - + TabDescriptor tab = TabDescriptor::loadFromJSON(tabVal.toObject()); if (tab.selected_) { if (hasSetASelectedTab) @@ -192,34 +215,11 @@ WindowLayout WindowLayout::loadFromFile(const QString &path) } 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(splitRoot); - } - else if (nodeType == "horizontal" || nodeType == "vertical") - { - tab.rootNode_ = - loadNodes(splitRoot); - } - } - window.tabs_.emplace_back(std::move(tab)); } // 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(), emote_popup_obj.value("y").toInt()); diff --git a/src/common/WindowDescriptors.hpp b/src/common/WindowDescriptors.hpp index 9c2aa2888..4740bc919 100644 --- a/src/common/WindowDescriptors.hpp +++ b/src/common/WindowDescriptors.hpp @@ -67,6 +67,8 @@ struct ContainerNodeDescriptor { }; struct TabDescriptor { + static TabDescriptor loadFromJSON(const QJsonObject &root); + QString customTitle_; bool selected_{false}; bool highlightsEnabled_{true}; diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 716d9f088..61ebfbb54 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -226,6 +226,72 @@ bool appendWhisperMessageStringLocally(const QString &textNoEmoji) } return false; } + +const std::map> + 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(channel.get()); + if (tc == nullptr) + { + return altText; + } + + return tc->roomId(); + }, + }, + { + "stream.game", + [](const auto &altText, const auto &channel) { + auto *tc = dynamic_cast(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(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 chatterino { @@ -267,6 +333,8 @@ void CommandController::initialize(Settings &, Paths &paths) auto path = combinePath(paths.settingsDirectory, "commands.json"); this->sm_ = std::make_shared(); this->sm_->setPath(path.toStdString()); + this->sm_->setBackupEnabled(true); + this->sm_->setBackupSlots(9); // Delayed initialization of the setting storing all commands this->commandsSetting_.reset( @@ -294,7 +362,7 @@ void CommandController::initialize(Settings &, Paths &paths) auto blockLambda = [](const auto &words, auto channel) { if (words.size() < 2) { - channel->addMessage(makeSystemMessage("Usage: /block [user]")); + channel->addMessage(makeSystemMessage("Usage: /block ")); return ""; } @@ -339,7 +407,7 @@ void CommandController::initialize(Settings &, Paths &paths) auto unblockLambda = [](const auto &words, auto channel) { if (words.size() < 2) { - channel->addMessage(makeSystemMessage("Usage: /unblock [user]")); + channel->addMessage(makeSystemMessage("Usage: /unblock ")); return ""; } @@ -453,7 +521,7 @@ void CommandController::initialize(Settings &, Paths &paths) if (words.size() < 2) { channel->addMessage( - makeSystemMessage("Usage /user [user] (channel)")); + makeSystemMessage("Usage: /user [channel]")); return ""; } QString userName = words[1]; @@ -474,12 +542,33 @@ void CommandController::initialize(Settings &, Paths &paths) this->registerCommand("/usercard", [](const auto &words, auto channel) { if (words.size() < 2) { - channel->addMessage(makeSystemMessage("Usage /usercard [user]")); + channel->addMessage( + makeSystemMessage("Usage: /usercard [channel]")); return ""; } QString userName = words[1]; 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( getSettings()->autoCloseUserPopup, static_cast(&(getApp()->windows->getMainWindow()))); @@ -602,12 +691,13 @@ void CommandController::initialize(Settings &, Paths &paths) (!channel->isTwitchChannel() || channel->isEmpty())) { channel->addMessage(makeSystemMessage( - "Usage: /streamlink [channel]. You can also use the " + "Usage: /streamlink . You can also use the " "command without arguments in any Twitch channel to open " "it in streamlink.")); return ""; } + stripChannelName(target); channel->addMessage(makeSystemMessage( QString("Opening %1 in streamlink...").arg(target))); openStreamlinkForChannel(target); @@ -623,12 +713,13 @@ void CommandController::initialize(Settings &, Paths &paths) (!channel->isTwitchChannel() || channel->isEmpty())) { channel->addMessage(makeSystemMessage( - "Usage: /popout [channel]. You can also use the command " + "Usage: /popout . You can also use the command " "without arguments in any Twitch channel to open its " "popout chat.")); return ""; } + stripChannelName(target); QDesktopServices::openUrl( QUrl(QString("https://www.twitch.tv/popout/%1/chat?popout=") .arg(target))); @@ -650,7 +741,7 @@ void CommandController::initialize(Settings &, Paths &paths) if (words.size() < 2) { channel->addMessage( - makeSystemMessage("Usage: /settitle .")); + makeSystemMessage("Usage: /settitle ")); return ""; } if (auto twitchChannel = dynamic_cast(channel.get())) @@ -681,7 +772,7 @@ void CommandController::initialize(Settings &, Paths &paths) if (words.size() < 2) { channel->addMessage( - makeSystemMessage("Usage: /setgame .")); + makeSystemMessage("Usage: /setgame ")); return ""; } if (auto twitchChannel = dynamic_cast(channel.get())) @@ -746,7 +837,7 @@ void CommandController::initialize(Settings &, Paths &paths) const ChannelPtr channel) { if (words.size() < 2) { - channel->addMessage(makeSystemMessage("Usage: /openurl .")); + channel->addMessage(makeSystemMessage("Usage: /openurl ")); return ""; } @@ -774,6 +865,11 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); + + this->registerCommand("/raw", [](const QStringList &words, ChannelPtr) { + getApp()->twitch2->sendRawMessage(words.mid(1).join(" ")); + return ""; + }); } void CommandController::save() @@ -825,7 +921,7 @@ QString CommandController::execCommand(const QString &textNoEmoji, if (it != this->userCommands_.end()) { text = getApp()->emotes->emojis.replaceShortCodes( - this->execCustomCommand(words, it.value(), dryRun)); + this->execCustomCommand(words, it.value(), dryRun, channel)); words = text.split(' ', Qt::SkipEmptyParts); @@ -858,7 +954,7 @@ QString CommandController::execCommand(const QString &textNoEmoji, const auto it = this->userCommands_.find(commandName); 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, const Command &command, - bool dryRun) + bool dryRun, ChannelPtr channel, + std::map context) { QString result; - static QRegularExpression parseCommand("(^|[^{])({{)*{(\\d+\\+?)}"); + static QRegularExpression parseCommand( + R"((^|[^{])({{)*{(\d+\+?|([a-zA-Z.-]+)(?:;(.+?))?)})"); int lastCaptureEnd = 0; @@ -913,7 +1011,27 @@ QString CommandController::execCustomCommand(const QStringList &words, int wordIndex = wordIndexMatch.replace("=", "").toInt(&ok); if (!ok || wordIndex == 0) { - result += "{" + match.captured(3) + "}"; + 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) + "}"; + } + } continue; } diff --git a/src/controllers/commands/CommandController.hpp b/src/controllers/commands/CommandController.hpp index 1d3c25557..427c146e8 100644 --- a/src/controllers/commands/CommandController.hpp +++ b/src/controllers/commands/CommandController.hpp @@ -34,6 +34,10 @@ public: CommandModel *createModel(QObject *parent); + QString execCustomCommand(const QStringList &words, const Command &command, + bool dryRun, ChannelPtr channel, + std::map context = {}); + private: void load(Paths &paths); @@ -57,9 +61,6 @@ private: std::unique_ptr>> commandsSetting_; - QString execCustomCommand(const QStringList &words, const Command &command, - bool dryRun); - QStringList commandAutoCompletions_; }; diff --git a/src/controllers/filters/parser/FilterParser.cpp b/src/controllers/filters/parser/FilterParser.cpp index c4dca050a..e0307329e 100644 --- a/src/controllers/filters/parser/FilterParser.cpp +++ b/src/controllers/filters/parser/FilterParser.cpp @@ -28,6 +28,7 @@ ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel) * flags.points_redeemed * flags.sub_message * flags.system_message + * flags.reward_message * flags.whisper * * message.content @@ -77,6 +78,8 @@ ContextMap buildContextMap(const MessagePtr &m, chatterino::Channel *channel) {"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.reward_message", + m->flags.has(MessageFlag::RedeemedChannelPointReward)}, {"flags.whisper", m->flags.has(MessageFlag::Whisper)}, {"message.content", m->messageText}, diff --git a/src/controllers/filters/parser/Tokenizer.hpp b/src/controllers/filters/parser/Tokenizer.hpp index 8f9b5824b..cc2e9dce1 100644 --- a/src/controllers/filters/parser/Tokenizer.hpp +++ b/src/controllers/filters/parser/Tokenizer.hpp @@ -22,6 +22,7 @@ static const QMap validIdentifiersMap = { {"flags.points_redeemed", "redeemed points?"}, {"flags.sub_message", "sub/resub message?"}, {"flags.system_message", "system message?"}, + {"flags.reward_message", "channel point reward message?"}, {"flags.whisper", "whisper message?"}, {"message.content", "message text"}, {"message.length", "message length"}}; diff --git a/src/controllers/nicknames/Nickname.hpp b/src/controllers/nicknames/Nickname.hpp index 67e7d3f40..3fb0b23b9 100644 --- a/src/controllers/nicknames/Nickname.hpp +++ b/src/controllers/nicknames/Nickname.hpp @@ -1,7 +1,6 @@ #pragma once #include "controllers/accounts/AccountController.hpp" - #include "util/RapidJsonSerializeQString.hpp" #include "util/RapidjsonHelpers.hpp" @@ -15,24 +14,92 @@ namespace chatterino { class Nickname { public: - Nickname(const QString &name, const QString &replace) + Nickname(const QString &name, const QString &replace, const bool isRegex, + const bool isCaseSensitive) : name_(name) , 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_; } - const QString &replace() const + + [[nodiscard]] const QString &replace() const { 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: QString name_; QString replace_; + bool isRegex_; + bool isCaseSensitive_; + Qt::CaseSensitivity caseSensitivity_; + QRegularExpression regex_{}; }; } // namespace chatterino @@ -48,6 +115,8 @@ struct Serialize { chatterino::rj::set(ret, "name", value.name(), 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; } @@ -61,16 +130,21 @@ struct Deserialize { if (!value.IsObject()) { PAJLADA_REPORT_ERROR(error) - return chatterino::Nickname(QString(), QString()); + return chatterino::Nickname(QString(), QString(), false, false); } QString _name; QString _replace; + bool _isRegex; + bool _isCaseSensitive; chatterino::rj::getSafe(value, "name", _name); 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); } }; diff --git a/src/controllers/nicknames/NicknamesModel.cpp b/src/controllers/nicknames/NicknamesModel.cpp index 703c6661b..2748f49b6 100644 --- a/src/controllers/nicknames/NicknamesModel.cpp +++ b/src/controllers/nicknames/NicknamesModel.cpp @@ -8,7 +8,7 @@ namespace chatterino { NicknamesModel::NicknamesModel(QObject *parent) - : SignalVectorModel(2, parent) + : SignalVectorModel(4, parent) { } @@ -17,7 +17,9 @@ Nickname NicknamesModel::getItemFromRow(std::vector &row, const Nickname &original) { 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 @@ -26,6 +28,8 @@ void NicknamesModel::getRowFromItem(const Nickname &item, { setStringItem(row[0], item.name()); setStringItem(row[1], item.replace()); + setBoolItem(row[2], item.isRegex()); + setBoolItem(row[3], item.isCaseSensitive()); } } // namespace chatterino diff --git a/src/controllers/taggedusers/TaggedUser.cpp b/src/controllers/taggedusers/TaggedUser.cpp deleted file mode 100644 index 7fbe3f997..000000000 --- a/src/controllers/taggedusers/TaggedUser.cpp +++ /dev/null @@ -1,36 +0,0 @@ -#include "TaggedUser.hpp" - -#include - -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 diff --git a/src/controllers/taggedusers/TaggedUser.hpp b/src/controllers/taggedusers/TaggedUser.hpp deleted file mode 100644 index 6270c8a6e..000000000 --- a/src/controllers/taggedusers/TaggedUser.hpp +++ /dev/null @@ -1,26 +0,0 @@ -#pragma once - -#include "common/ProviderId.hpp" - -#include - -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 diff --git a/src/controllers/taggedusers/TaggedUsersModel.cpp b/src/controllers/taggedusers/TaggedUsersModel.cpp deleted file mode 100644 index 372e35320..000000000 --- a/src/controllers/taggedusers/TaggedUsersModel.cpp +++ /dev/null @@ -1,67 +0,0 @@ -#include "TaggedUsersModel.hpp" - -#include "Application.hpp" -#include "util/StandardItemHelper.hpp" - -namespace chatterino { - -// commandmodel -TaggedUsersModel::TaggedUsersModel(QObject *parent) - : SignalVectorModel(1, parent) -{ -} - -// turn a vector item into a model row -TaggedUser TaggedUsersModel::getItemFromRow(std::vector &row, - const TaggedUser &original) -{ - return original; -} - -// turns a row in the model into a vector item -void TaggedUsersModel::getRowFromItem(const TaggedUser &item, - std::vector &row) -{ - setStringItem(row[0], item.getName()); -} - -void TaggedUsersModel::afterInit() -{ - // std::vector 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 -// &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 diff --git a/src/controllers/taggedusers/TaggedUsersModel.hpp b/src/controllers/taggedusers/TaggedUsersModel.hpp deleted file mode 100644 index cc945f88e..000000000 --- a/src/controllers/taggedusers/TaggedUsersModel.hpp +++ /dev/null @@ -1,33 +0,0 @@ -#pragma once - -#include "common/SignalVectorModel.hpp" -#include "controllers/taggedusers/TaggedUser.hpp" - -namespace chatterino { - -class TaggedUsersController; - -class TaggedUsersModel : public SignalVectorModel -{ - explicit TaggedUsersModel(QObject *parent); - -protected: - // turn a vector item into a model row - virtual TaggedUser getItemFromRow(std::vector &row, - const TaggedUser &original) override; - - // turns a row in the model into a vector item - virtual void getRowFromItem(const TaggedUser &item, - std::vector &row) override; - - virtual void afterInit() override; - - // virtual void customRowSetData(const std::vector &row, - // int column, - // const QVariant &value, int role) - // override; - - friend class TaggedUsersController; -}; - -} // namespace chatterino diff --git a/src/messages/SharedMessageBuilder.cpp b/src/messages/SharedMessageBuilder.cpp index ceced523c..19bd2a05d 100644 --- a/src/messages/SharedMessageBuilder.cpp +++ b/src/messages/SharedMessageBuilder.cpp @@ -156,10 +156,6 @@ void SharedMessageBuilder::parseHighlights() this->message().flags.set(MessageFlag::Highlighted); this->message().highlightColor = 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 @@ -219,7 +215,10 @@ void SharedMessageBuilder::parseHighlights() << "sent a message"; this->message().flags.set(MessageFlag::Highlighted); - this->message().highlightColor = userHighlight.getColor(); + if (!this->message().flags.has(MessageFlag::Subscription)) + { + this->message().highlightColor = userHighlight.getColor(); + } if (userHighlight.showInMentions()) { @@ -288,7 +287,10 @@ void SharedMessageBuilder::parseHighlights() } this->message().flags.set(MessageFlag::Highlighted); - this->message().highlightColor = highlight.getColor(); + if (!this->message().flags.has(MessageFlag::Subscription)) + { + this->message().highlightColor = highlight.getColor(); + } if (highlight.showInMentions()) { @@ -343,7 +345,11 @@ void SharedMessageBuilder::parseHighlights() if (!badgeHighlightSet) { this->message().flags.set(MessageFlag::Highlighted); - this->message().highlightColor = highlight.getColor(); + if (!this->message().flags.has(MessageFlag::Subscription)) + { + this->message().highlightColor = highlight.getColor(); + } + badgeHighlightSet = true; } diff --git a/src/providers/irc/AbstractIrcServer.cpp b/src/providers/irc/AbstractIrcServer.cpp index 0ccebdec9..352af378d 100644 --- a/src/providers/irc/AbstractIrcServer.cpp +++ b/src/providers/irc/AbstractIrcServer.cpp @@ -17,7 +17,7 @@ const int MAX_FALLOFF_COUNTER = 60; // Ratelimits for joinBucket_ const int JOIN_RATELIMIT_BUDGET = 18; -const int JOIN_RATELIMIT_COOLDOWN = 10500; +const int JOIN_RATELIMIT_COOLDOWN = 12500; AbstractIrcServer::AbstractIrcServer() { diff --git a/src/providers/irc/IrcChannel2.cpp b/src/providers/irc/IrcChannel2.cpp index b7b17fc72..940a74410 100644 --- a/src/providers/irc/IrcChannel2.cpp +++ b/src/providers/irc/IrcChannel2.cpp @@ -1,6 +1,7 @@ #include "IrcChannel2.hpp" #include "debug/AssertInGuiThread.hpp" +#include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" #include "providers/irc/IrcCommands.hpp" #include "providers/irc/IrcServer.hpp" @@ -33,9 +34,14 @@ void IrcChannel::sendMessage(const QString &message) MessageBuilder builder; builder.emplace(); - builder.emplace(this->server()->nick() + ":", - MessageElementFlag::Username); + const auto &nick = this->server()->nick(); + builder.emplace(nick + ":", MessageElementFlag::Username) + ->setLink({Link::UserInfo, nick}); builder.emplace(message, MessageElementFlag::Text); + builder.message().messageText = message; + builder.message().searchText = nick + ": " + message; + builder.message().loginName = nick; + builder.message().displayName = nick; this->addMessage(builder.release()); } } diff --git a/src/providers/irc/IrcCommands.cpp b/src/providers/irc/IrcCommands.cpp index 4fbaeb59d..e6f5e2382 100644 --- a/src/providers/irc/IrcCommands.cpp +++ b/src/providers/irc/IrcCommands.cpp @@ -47,7 +47,7 @@ Outcome invokeIrcCommand(const QString &commandName, const QString &allParams, } else if (cmd == "away") { - sendRaw("AWAY" + params[0] + " :" + paramsAfter(0)); + sendRaw("AWAY " + params[0] + " :" + paramsAfter(0)); } else if (cmd == "knock") { diff --git a/src/providers/irc/IrcMessageBuilder.cpp b/src/providers/irc/IrcMessageBuilder.cpp index fab99afb1..574e2aca9 100644 --- a/src/providers/irc/IrcMessageBuilder.cpp +++ b/src/providers/irc/IrcMessageBuilder.cpp @@ -11,6 +11,7 @@ #include "singletons/Settings.hpp" #include "singletons/Theme.hpp" #include "singletons/WindowManager.hpp" +#include "util/Helpers.hpp" #include "util/IrcHelpers.hpp" #include "widgets/Window.hpp" @@ -36,6 +37,7 @@ MessagePtr IrcMessageBuilder::build() { // PARSE this->parse(); + this->usernameColor_ = getRandomColor(this->ircMessage->nick()); // PUSH ELEMENTS this->appendChannelName(); diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp index 456d05223..e15362a5e 100644 --- a/src/providers/twitch/IrcMessageHandler.cpp +++ b/src/providers/twitch/IrcMessageHandler.cpp @@ -112,11 +112,10 @@ float IrcMessageHandler::similarity( MessagePtr msg, const LimitedQueueSnapshot &messages) { float similarityPercent = 0.0f; - int bySameUser = 0; - for (int i = 1; bySameUser < getSettings()->hideSimilarMaxMessagesToCheck; - ++i) + int checked = 0; + for (int i = 1; i <= messages.size(); ++i) { - if (messages.size() < i) + if (checked >= getSettings()->hideSimilarMaxMessagesToCheck) { break; } @@ -126,11 +125,12 @@ float IrcMessageHandler::similarity( { break; } - if (msg->loginName != prevMsg->loginName) + if (getSettings()->hideSimilarBySameUser && + msg->loginName != prevMsg->loginName) { continue; } - ++bySameUser; + ++checked; similarityPercent = std::max( similarityPercent, relativeSimilarity(msg->messageText, prevMsg->messageText)); @@ -313,12 +313,9 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *_message, const auto highlighted = msg->flags.has(MessageFlag::Highlighted); 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); @@ -813,8 +810,7 @@ void IrcMessageHandler::handleNoticeMessage(Communi::IrcNoticeMessage *message) if (tags == "bad_delete_message_error" || tags == "usage_delete") { channel->addMessage(makeSystemMessage( - "Usage: \"/delete \" - can't take more " - "than one argument")); + "Usage: /delete . Can't take more than one argument")); } else if (tags == "host_on" || tags == "host_target_went_offline") { diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index 94c458a55..db35de7b2 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -377,6 +377,16 @@ void TwitchChannel::sendMessage(const QString &message) if (parsedMessage == this->lastSentMessage_) { 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) { // no spaces found, fall back to old magic character diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index cb2d67e5a..b77166099 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -183,29 +183,7 @@ MessagePtr TwitchMessageBuilder::build() this->emplace( calculateMessageTimestamp(this->ircMessage)); - bool addModerationElement = true; - 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) + if (this->shouldAddModerationElements()) { this->emplace(); } @@ -660,13 +638,11 @@ void TwitchMessageBuilder::appendUsername() } auto nicknames = getCSettings().nicknames.readOnly(); - auto loginLower = this->message().loginName.toLower(); for (const auto &nickname : *nicknames) { - if (nickname.name().toLower() == loginLower) + if (nickname.match(usernameText)) { - usernameText = nickname.replace(); break; } } @@ -1230,6 +1206,24 @@ Outcome TwitchMessageBuilder::tryParseCheermote(const QString &string) 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( const ChannelPointReward &reward, MessageBuilder *builder, bool isMod, bool isBroadcaster) diff --git a/src/providers/twitch/TwitchMessageBuilder.hpp b/src/providers/twitch/TwitchMessageBuilder.hpp index 7cf68494a..8f1217c8e 100644 --- a/src/providers/twitch/TwitchMessageBuilder.hpp +++ b/src/providers/twitch/TwitchMessageBuilder.hpp @@ -90,6 +90,8 @@ private: void appendFfzBadges(); Outcome tryParseCheermote(const QString &string); + bool shouldAddModerationElements() const; + QString roomID_; bool hasBits_ = false; QString bits; diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 703fefbde..73c603453 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -22,7 +22,6 @@ namespace chatterino { class HighlightPhrase; class HighlightBlacklistUser; class IgnorePhrase; -class TaggedUser; class FilterRecord; class Nickname; @@ -40,7 +39,6 @@ public: SignalVector &mutedChannels; SignalVector &filterRecords; SignalVector &nicknames; - //SignalVector &taggedUsers; SignalVector &moderationActions; bool isHighlightedUser(const QString &username); @@ -392,6 +390,8 @@ public: BoolSetting colorSimilarDisabled = {"/similarity/colorSimilarDisabled", true}; BoolSetting hideSimilar = {"/similarity/hideSimilar", false}; + BoolSetting hideSimilarBySameUser = {"/similarity/hideSimilarBySameUser", + true}; BoolSetting hideSimilarMyself = {"/similarity/hideSimilarMyself", false}; BoolSetting shownSimilarTriggerHighlights = { "/similarity/shownSimilarTriggerHighlights", false}; diff --git a/src/singletons/Theme.hpp b/src/singletons/Theme.hpp index 8b55dbab2..f2f8621b4 100644 --- a/src/singletons/Theme.hpp +++ b/src/singletons/Theme.hpp @@ -56,11 +56,6 @@ public: private: 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_; diff --git a/src/singletons/Updates.cpp b/src/singletons/Updates.cpp index 5751b59cc..cf32b30ff 100644 --- a/src/singletons/Updates.cpp +++ b/src/singletons/Updates.cpp @@ -232,7 +232,9 @@ void Updates::installUpdates() void Updates::checkForUpdates() { - if (!Version::instance().isSupportedOS()) + auto version = Version::instance(); + + if (!version.isSupportedOS()) { qCDebug(chatterinoUpdate) << "Update checking disabled because OS doesn't appear to be one " @@ -241,7 +243,7 @@ void Updates::checkForUpdates() } // Disable updates on Flatpak - if (QFileInfo::exists("/.flatpak-info")) + if (version.isFlatpak()) { return; } diff --git a/src/singletons/WindowManager.cpp b/src/singletons/WindowManager.cpp index ac01f2dbe..82017bd8b 100644 --- a/src/singletons/WindowManager.cpp +++ b/src/singletons/WindowManager.cpp @@ -378,20 +378,20 @@ void WindowManager::save() QJsonDocument document; // "serialize" - QJsonArray window_arr; + QJsonArray windowArr; for (Window *window : this->windows_) { - QJsonObject window_obj; + QJsonObject windowObj; // window type switch (window->getType()) { case WindowType::Main: - window_obj.insert("type", "main"); + windowObj.insert("type", "main"); break; case WindowType::Popup: - window_obj.insert("type", "popup"); + windowObj.insert("type", "popup"); break; case WindowType::Attached:; @@ -399,68 +399,48 @@ void WindowManager::save() if (window->isMaximized()) { - window_obj.insert("state", "maximized"); + windowObj.insert("state", "maximized"); } else if (window->isMinimized()) { - window_obj.insert("state", "minimized"); + windowObj.insert("state", "minimized"); } // window geometry auto rect = window->getBounds(); - window_obj.insert("x", rect.x()); - window_obj.insert("y", rect.y()); - window_obj.insert("width", rect.width()); - window_obj.insert("height", rect.height()); + windowObj.insert("x", rect.x()); + windowObj.insert("y", rect.y()); + windowObj.insert("width", rect.width()); + windowObj.insert("height", rect.height()); - QJsonObject emote_popup_obj; - emote_popup_obj.insert("x", this->emotePopupPos_.x()); - emote_popup_obj.insert("y", this->emotePopupPos_.y()); - window_obj.insert("emotePopup", emote_popup_obj); + QJsonObject emotePopupObj; + emotePopupObj.insert("x", this->emotePopupPos_.x()); + emotePopupObj.insert("y", this->emotePopupPos_.y()); + windowObj.insert("emotePopup", emotePopupObj); // window tabs - QJsonArray tabs_arr; + QJsonArray tabsArr; - for (int tab_i = 0; tab_i < window->getNotebook().getPageCount(); - tab_i++) + for (int tabIndex = 0; tabIndex < window->getNotebook().getPageCount(); + tabIndex++) { - QJsonObject tab_obj; + QJsonObject tabObj; SplitContainer *tab = dynamic_cast( - window->getNotebook().getPageAt(tab_i)); + window->getNotebook().getPageAt(tabIndex)); assert(tab != nullptr); - // custom tab title - if (tab->getTab()->hasCustomTitle()) - { - tab_obj.insert("title", tab->getTab()->getCustomTitle()); - } - - // selected - if (window->getNotebook().getSelectedPage() == tab) - { - 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); + bool isSelected = window->getNotebook().getSelectedPage() == tab; + WindowManager::encodeTab(tab, isSelected, tabObj); + tabsArr.append(tabObj); } - window_obj.insert("tabs", tabs_arr); - window_arr.append(window_obj); + windowObj.insert("tabs", tabsArr); + windowArr.append(windowObj); } QJsonObject obj; - obj.insert("windows", window_arr); + obj.insert("windows", windowArr); document.setObject(obj); // save file @@ -496,6 +476,32 @@ void WindowManager::queueSave() 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) { switch (node->getType()) @@ -505,11 +511,12 @@ void WindowManager::encodeNodeRecursively(SplitNode *node, QJsonObject &obj) obj.insert("moderationMode", node->getSplit()->getModerationMode()); QJsonObject split; - encodeChannel(node->getSplit()->getIndirectChannel(), split); + WindowManager::encodeChannel(node->getSplit()->getIndirectChannel(), + split); obj.insert("data", split); QJsonArray filters; - encodeFilters(node->getSplit(), filters); + WindowManager::encodeFilters(node->getSplit(), filters); obj.insert("filters", filters); } break; @@ -519,14 +526,14 @@ void WindowManager::encodeNodeRecursively(SplitNode *node, QJsonObject &obj) ? "horizontal" : "vertical"); - QJsonArray items_arr; + QJsonArray itemsArr; for (const std::unique_ptr &n : node->getChildren()) { QJsonObject subObj; - this->encodeNodeRecursively(n.get(), subObj); - items_arr.append(subObj); + WindowManager::encodeNodeRecursively(n.get(), subObj); + itemsArr.append(subObj); } - obj.insert("items", items_arr); + obj.insert("items", itemsArr); } break; } diff --git a/src/singletons/WindowManager.hpp b/src/singletons/WindowManager.hpp index 42a6f5d51..87151d204 100644 --- a/src/singletons/WindowManager.hpp +++ b/src/singletons/WindowManager.hpp @@ -30,6 +30,8 @@ public: WindowManager(); ~WindowManager() override; + static void encodeTab(SplitContainer *tab, bool isSelected, + QJsonObject &obj); static void encodeChannel(IndirectChannel channel, QJsonObject &obj); static void encodeFilters(Split *split, QJsonArray &arr); static IndirectChannel decodeChannel(const SplitDescriptor &descriptor); @@ -99,7 +101,8 @@ public: pajlada::Signals::Signal selectSplitContainer; 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 WindowLayout loadWindowLayoutFromFile() const; diff --git a/src/util/StreamLink.cpp b/src/util/StreamLink.cpp index 2ac42cfa6..8b61ca9d7 100644 --- a/src/util/StreamLink.cpp +++ b/src/util/StreamLink.cpp @@ -10,6 +10,7 @@ #include #include #include "common/QLogging.hpp" +#include "common/Version.hpp" #include @@ -35,18 +36,6 @@ namespace { #endif } - QString getStreamlinkProgram() - { - if (getSettings()->streamlinkUseCustomPath) - { - return getSettings()->streamlinkPath + "/" + getBinaryName(); - } - else - { - return getBinaryName(); - } - } - bool checkStreamlinkPath(const QString &path) { QFileInfo fileinfo(path); @@ -83,7 +72,27 @@ namespace { QProcess *createStreamlinkProcess() { 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) { 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(); } @@ -173,7 +183,9 @@ void getStreamQualities(const QString &channelURL, void openStreamlink(const QString &channelURL, const QString &quality, 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 // 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(); arguments << splitCommand(additionalOptions); - bool res = QProcess::startDetached(getStreamlinkProgram(), arguments); + proc->setArguments(std::move(arguments)); + bool res = proc->startDetached(); if (!res) { @@ -200,7 +213,7 @@ void openStreamlinkForChannel(const QString &channel) if (preferredQuality == "choose") { getStreamQualities(channelURL, [=](QStringList qualityOptions) { - QualityPopup::showDialog(channel, qualityOptions); + QualityPopup::showDialog(channelURL, qualityOptions); }); return; diff --git a/src/widgets/BasePopup.cpp b/src/widgets/BasePopup.cpp index 0f3ed3c1d..afae740b6 100644 --- a/src/widgets/BasePopup.cpp +++ b/src/widgets/BasePopup.cpp @@ -1,5 +1,7 @@ #include "widgets/BasePopup.hpp" +#include +#include #include namespace chatterino { @@ -20,4 +22,66 @@ void BasePopup::keyPressEvent(QKeyEvent *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 diff --git a/src/widgets/BasePopup.hpp b/src/widgets/BasePopup.hpp index 5741b0006..d7942f2fc 100644 --- a/src/widgets/BasePopup.hpp +++ b/src/widgets/BasePopup.hpp @@ -3,6 +3,8 @@ #include "common/FlagsEnum.hpp" #include "widgets/BaseWindow.hpp" +class QDialogButtonBox; + namespace chatterino { class BasePopup : public BaseWindow @@ -13,6 +15,12 @@ public: protected: 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 diff --git a/src/widgets/Window.cpp b/src/widgets/Window.cpp index fe04da29d..af436aad3 100644 --- a/src/widgets/Window.cpp +++ b/src/widgets/Window.cpp @@ -338,6 +338,14 @@ void Window::addShortcuts() } }); + createWindowShortcut(this, "CTRL+SHIFT+N", [this] { + if (auto page = dynamic_cast( + this->notebook_->getSelectedPage())) + { + page->popup(); + } + }); + // Zoom in { auto s = new QShortcut(QKeySequence::ZoomIn, this); diff --git a/src/widgets/dialogs/QualityPopup.cpp b/src/widgets/dialogs/QualityPopup.cpp index 414ab2548..52f7dba47 100644 --- a/src/widgets/dialogs/QualityPopup.cpp +++ b/src/widgets/dialogs/QualityPopup.cpp @@ -7,35 +7,32 @@ namespace chatterino { -QualityPopup::QualityPopup(const QString &_channelName, QStringList options) +QualityPopup::QualityPopup(const QString &channelURL, QStringList options) : BasePopup({}, static_cast(&(getApp()->windows->getMainWindow()))) - , channelName_(_channelName) + , channelURL_(channelURL) { - this->ui_.okButton.setText("OK"); - this->ui_.cancelButton.setText("Cancel"); + this->ui_.selector = new QComboBox(this); + 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); - QObject::connect(&this->ui_.cancelButton, &QPushButton::clicked, this, + QObject::connect(this->ui_.buttonBox, &QDialogButtonBox::rejected, this, &QualityPopup::cancelButtonClicked); - this->ui_.buttonBox.addButton(&this->ui_.okButton, - QDialogButtonBox::ButtonRole::AcceptRole); - this->ui_.buttonBox.addButton(&this->ui_.cancelButton, - QDialogButtonBox::ButtonRole::RejectRole); + this->ui_.selector->addItems(options); - 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->ui_.vbox.addWidget(&this->ui_.buttonBox); - - this->setLayout(&this->ui_.vbox); + 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->setAttribute(Qt::WA_DeleteOnClose, true); @@ -43,16 +40,27 @@ void QualityPopup::showDialog(const QString &channelName, QStringList options) instance->show(); instance->activateWindow(); 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() { - QString channelURL = "twitch.tv/" + this->channelName_; - try { - openStreamlink(channelURL, this->ui_.selector.currentText()); + openStreamlink(this->channelURL_, this->ui_.selector->currentText()); } catch (const Exception &ex) { diff --git a/src/widgets/dialogs/QualityPopup.hpp b/src/widgets/dialogs/QualityPopup.hpp index cb5f06205..f0820218d 100644 --- a/src/widgets/dialogs/QualityPopup.hpp +++ b/src/widgets/dialogs/QualityPopup.hpp @@ -4,7 +4,6 @@ #include #include -#include #include namespace chatterino { @@ -12,22 +11,23 @@ namespace chatterino { class QualityPopup : public BasePopup { public: - QualityPopup(const QString &_channelName, QStringList options); - static void showDialog(const QString &_channelName, QStringList options); + QualityPopup(const QString &channelURL, QStringList options); + static void showDialog(const QString &channelURL, QStringList options); + +protected: + void keyPressEvent(QKeyEvent *e) override; private: void okButtonClicked(); void cancelButtonClicked(); struct { - QVBoxLayout vbox; - QComboBox selector; - QDialogButtonBox buttonBox; - QPushButton okButton; - QPushButton cancelButton; + QVBoxLayout *vbox; + QComboBox *selector; + QDialogButtonBox *buttonBox; } ui_; - QString channelName_; + QString channelURL_; }; } // namespace chatterino diff --git a/src/widgets/dialogs/UserInfoPopup.cpp b/src/widgets/dialogs/UserInfoPopup.cpp index 706ca839d..6138962e2 100644 --- a/src/widgets/dialogs/UserInfoPopup.cpp +++ b/src/widgets/dialogs/UserInfoPopup.cpp @@ -33,7 +33,7 @@ const QString TEXT_VIEWS("Views: %1"); const QString TEXT_FOLLOWERS("Followers: %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_UNAVAILABLE "(not available)" @@ -513,7 +513,7 @@ void UserInfoPopup::setData(const QString &name, const ChannelPtr &channel) { this->userName_ = name; 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->setProperty("copy-text", name); @@ -598,7 +598,8 @@ void UserInfoPopup::updateUserData() this->avatarUrl_ = user.profileImageUrl; 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( TEXT_VIEWS.arg(localizeNumbers(user.viewCount))); this->ui_.createdDateLabel->setText( diff --git a/src/widgets/helper/ChannelView.cpp b/src/widgets/helper/ChannelView.cpp index 406dcb023..3d5679e4f 100644 --- a/src/widgets/helper/ChannelView.cpp +++ b/src/widgets/helper/ChannelView.cpp @@ -2101,12 +2101,24 @@ void ChannelView::handleLinkClick(QMouseEvent *event, const Link &link, } } - value.replace("{user}", layout->getMessage()->loginName) - .replace("{channel}", this->channel_->getName()) - .replace("{msg-id}", layout->getMessage()->id) - .replace("{message}", layout->getMessage()->messageText); + value = getApp()->commands->execCustomCommand( + QStringList(), Command{"(modaction)", value}, true, channel, + { + {"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); + channel->sendMessage(value); } break; diff --git a/src/widgets/helper/NotebookTab.cpp b/src/widgets/helper/NotebookTab.cpp index ba914e41a..3d38ab4d2 100644 --- a/src/widgets/helper/NotebookTab.cpp +++ b/src/widgets/helper/NotebookTab.cpp @@ -64,6 +64,16 @@ NotebookTab::NotebookTab(Notebook *notebook) this->notebook_->removePage(this->page); }); + this->menu_.addAction( + "Popup Tab", + [=]() { + if (auto container = dynamic_cast(this->page)) + { + container->popup(); + } + }, + QKeySequence("Ctrl+Shift+N")); + highlightNewMessagesAction_ = new QAction("Mark Tab as Unread on New Messages", &this->menu_); highlightNewMessagesAction_->setCheckable(true); diff --git a/src/widgets/settingspages/AboutPage.cpp b/src/widgets/settingspages/AboutPage.cpp index 74ce32425..1380a71f6 100644 --- a/src/widgets/settingspages/AboutPage.cpp +++ b/src/widgets/settingspages/AboutPage.cpp @@ -110,7 +110,6 @@ AboutPage::AboutPage() // clang-format off l.emplace("Chatterino Wiki can be found here")->setOpenExternalLinks(true); - l.emplace("Support Chatterino")->setOpenExternalLinks(true); l.emplace("All about Chatterino's features")->setOpenExternalLinks(true); l.emplace("Join the official Chatterino Discord")->setOpenExternalLinks(true); // clang-format on diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index 0254c1360..24780ed41 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -520,11 +520,11 @@ void GeneralPage::initLayout(GeneralPageView &layout) layout.addCheckbox("Title", s.headerStreamTitle); layout.addSubtitle("R9K"); - layout.addDescription( - "Hide similar messages by the same user. Toggle hidden " - "messages by pressing Ctrl+H."); + layout.addDescription("Hide similar messages. Toggle hidden " + "messages by pressing Ctrl+H."); layout.addCheckbox("Hide similar messages", s.similarityEnabled); //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("Receive notification sounds from hidden messages", s.shownSimilarTriggerHighlights); diff --git a/src/widgets/settingspages/IgnoresPage.cpp b/src/widgets/settingspages/IgnoresPage.cpp index ccb494d17..77b27d372 100644 --- a/src/widgets/settingspages/IgnoresPage.cpp +++ b/src/widgets/settingspages/IgnoresPage.cpp @@ -49,7 +49,7 @@ void addPhrasesTab(LayoutCreator layout) ->initialized(&getSettings()->ignoredMessages)) .getElement(); view->setTitles( - {"Pattern", "Regex", "Case Sensitive", "Block", "Replacement"}); + {"Pattern", "Regex", "Case-sensitive", "Block", "Replacement"}); view->getTableView()->horizontalHeader()->setSectionResizeMode( QHeaderView::Fixed); view->getTableView()->horizontalHeader()->setSectionResizeMode( diff --git a/src/widgets/settingspages/KeyboardSettingsPage.cpp b/src/widgets/settingspages/KeyboardSettingsPage.cpp index 7cad92a5f..fcdc69a00 100644 --- a/src/widgets/settingspages/KeyboardSettingsPage.cpp +++ b/src/widgets/settingspages/KeyboardSettingsPage.cpp @@ -44,6 +44,8 @@ KeyboardSettingsPage::KeyboardSettingsPage() form->addRow(new QLabel("Ctrl + Shift + T"), new QLabel("Create new tab")); form->addRow(new QLabel("Ctrl + Shift + W"), 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"), new QLabel("Hide/Show similar messages (See General->R9K)")); diff --git a/src/widgets/settingspages/ModerationPage.cpp b/src/widgets/settingspages/ModerationPage.cpp index 4ef03bede..e656465d5 100644 --- a/src/widgets/settingspages/ModerationPage.cpp +++ b/src/widgets/settingspages/ModerationPage.cpp @@ -2,7 +2,6 @@ #include "Application.hpp" #include "controllers/moderationactions/ModerationActionModel.hpp" -#include "controllers/taggedusers/TaggedUsersModel.hpp" #include "singletons/Logging.hpp" #include "singletons/Paths.hpp" #include "util/Helpers.hpp" @@ -159,8 +158,8 @@ ModerationPage::ModerationPage() // clang-format off auto label = modMode.emplace( "Moderation mode is enabled by clicking in a channel that you moderate.

" - "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.
" - "For deleting messages use /delete {msg-id}.

" + "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.
" + "For deleting messages use /delete {msg.id}.

" "More information can be found here."); label->setOpenExternalLinks(true); label->setWordWrap(true); @@ -190,22 +189,8 @@ ModerationPage::ModerationPage() view->addButtonPressed.connect([] { getSettings()->moderationActions.append( - ModerationAction("/timeout {user} 300")); + ModerationAction("/timeout {user.name} 300")); }); - - /*auto taggedUsers = tabs.appendTab(new QVBoxLayout, "Tagged users"); - { - EditableModelView *view = *taggedUsers.emplace( - 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); diff --git a/src/widgets/settingspages/NicknamesPage.cpp b/src/widgets/settingspages/NicknamesPage.cpp index 2a464d7c0..d328ac4bf 100644 --- a/src/widgets/settingspages/NicknamesPage.cpp +++ b/src/widgets/settingspages/NicknamesPage.cpp @@ -29,19 +29,22 @@ NicknamesPage::NicknamesPage() ->initialized(&getSettings()->nicknames)) .getElement(); - view->setTitles({"Username", "Nickname"}); + view->setTitles({"Username", "Nickname", "Enable regex", "Case-sensitive"}); view->getTableView()->horizontalHeader()->setSectionResizeMode( - QHeaderView::Interactive); + QHeaderView::Fixed); + view->getTableView()->horizontalHeader()->setSectionResizeMode( + 0, QHeaderView::Stretch); view->getTableView()->horizontalHeader()->setSectionResizeMode( 1, QHeaderView::Stretch); view->addButtonPressed.connect([] { - getSettings()->nicknames.append(Nickname{"Username", "Nickname"}); + getSettings()->nicknames.append( + Nickname{"Username", "Nickname", false, false}); }); QTimer::singleShot(1, [view] { view->getTableView()->resizeColumnsToContents(); - view->getTableView()->setColumnWidth(0, 250); + view->getTableView()->setColumnWidth(0, 200); }); } diff --git a/src/widgets/splits/Split.cpp b/src/widgets/splits/Split.cpp index 63293b248..b7edad652 100644 --- a/src/widgets/splits/Split.cpp +++ b/src/widgets/splits/Split.cpp @@ -262,10 +262,11 @@ Split::Split(QWidget *parent) if (getSettings()->askOnImageUpload.getValue()) { QMessageBox msgBox; + msgBox.setWindowTitle("Chatterino"); msgBox.setText("Image upload"); msgBox.setInformativeText( "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?"); msgBox.addButton(QMessageBox::Cancel); msgBox.addButton(QMessageBox::Yes); diff --git a/src/widgets/splits/SplitContainer.cpp b/src/widgets/splits/SplitContainer.cpp index 4a04ba199..5e1543c95 100644 --- a/src/widgets/splits/SplitContainer.cpp +++ b/src/widgets/splits/SplitContainer.cpp @@ -9,6 +9,7 @@ #include "util/Helpers.hpp" #include "util/LayoutCreator.hpp" #include "widgets/Notebook.hpp" +#include "widgets/Window.hpp" #include "widgets/helper/ChannelView.hpp" #include "widgets/helper/NotebookTab.hpp" #include "widgets/splits/ClosedSplits.hpp" @@ -761,6 +762,33 @@ void SplitContainer::applyFromDescriptor(const NodeDescriptor &rootNode) 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( const NodeDescriptor &rootNode, Node *node) { diff --git a/src/widgets/splits/SplitContainer.hpp b/src/widgets/splits/SplitContainer.hpp index bb26e43dc..1662f0b68 100644 --- a/src/widgets/splits/SplitContainer.hpp +++ b/src/widgets/splits/SplitContainer.hpp @@ -202,6 +202,8 @@ public: void applyFromDescriptor(const NodeDescriptor &rootNode); + void popup(); + protected: void paintEvent(QPaintEvent *event) override; diff --git a/src/widgets/splits/SplitInput.cpp b/src/widgets/splits/SplitInput.cpp index e8a79620d..666c1a3f5 100644 --- a/src/widgets/splits/SplitInput.cpp +++ b/src/widgets/splits/SplitInput.cpp @@ -563,7 +563,7 @@ void SplitInput::insertCompletionText(const QString &input_) auto input = input_ + ' '; auto text = edit.toPlainText(); - auto position = edit.textCursor().position(); + auto position = edit.textCursor().position() - 1; for (int i = clamp(position, 0, static_cast(text.length() - 1)); i >= 0; i--) @@ -585,7 +585,7 @@ void SplitInput::insertCompletionText(const QString &input_) if (done) { 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()); edit.setTextCursor(cursor); @@ -641,9 +641,6 @@ void SplitInput::editTextChanged() this->textChanged.invoke(text); text = text.trimmed(); - static QRegularExpression spaceRegex("\\s\\s+"); - text = text.replace(spaceRegex, " "); - text = app->commands->execCommand(text, this->split_->getChannel(), true); } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8b1fafe99..4c15a4902 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -2,6 +2,7 @@ project(chatterino-test) set(test_SOURCES ${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/NetworkCommon.cpp ${CMAKE_CURRENT_LIST_DIR}/src/NetworkRequest.cpp diff --git a/tests/src/ChannelChatters.cpp b/tests/src/ChannelChatters.cpp new file mode 100644 index 000000000..e0006a205 --- /dev/null +++ b/tests/src/ChannelChatters.cpp @@ -0,0 +1,113 @@ +#include "common/ChannelChatters.hpp" + +#include +#include +#include + +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")); +}