From f943f7063442054f7e4ccf7628d725e30d978ad0 Mon Sep 17 00:00:00 2001 From: Mm2PL Date: Mon, 6 Nov 2023 20:42:24 +0100 Subject: [PATCH 01/26] Add support for opening usercards by ID (#4934) Co-authored-by: nerix --- CHANGELOG.md | 1 + .../commands/CommandController.cpp | 3 +- src/widgets/dialogs/UserInfoPopup.cpp | 39 +++++++++++++++++-- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 83ba4bd89b3..63a699619d9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Minor: The account switcher is now styled to match your theme. (#4817) - Minor: Add an invisible resize handle to the bottom of frameless user info popups and reply thread popups. (#4795) - Minor: The installer now checks for the VC Runtime version and shows more info when it's outdated. (#4847) +- Minor: The `/usercard` command now accepts user ids. (#4934) - Minor: Add menu actions to reply directly to a message or the original thread root. (#4923) - Minor: The `/reply` command now replies to the latest message of the user. (#4919) - Bugfix: Fixed an issue where certain emojis did not send to Twitch chat correctly. (#4840) diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 2e38431afa8..de04141cbb1 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -856,7 +856,8 @@ void CommandController::initialize(Settings &, Paths &paths) if (words.size() < 2) { channel->addMessage( - makeSystemMessage("Usage: /usercard [channel]")); + makeSystemMessage("Usage: /usercard [channel] or " + "/usercard id: [channel]")); return ""; } diff --git a/src/widgets/dialogs/UserInfoPopup.cpp b/src/widgets/dialogs/UserInfoPopup.cpp index 2c48b134eef..4045ed33de2 100644 --- a/src/widgets/dialogs/UserInfoPopup.cpp +++ b/src/widgets/dialogs/UserInfoPopup.cpp @@ -701,7 +701,18 @@ void UserInfoPopup::setData(const QString &name, const ChannelPtr &contextChannel, const ChannelPtr &openingChannel) { - this->userName_ = name; + const QStringView idPrefix = u"id:"; + bool isId = name.startsWith(idPrefix); + if (isId) + { + this->userId_ = name.mid(idPrefix.size()); + this->userName_ = ""; + } + else + { + this->userName_ = name; + } + this->channel_ = openingChannel; if (!contextChannel->isEmpty()) @@ -723,7 +734,11 @@ void UserInfoPopup::setData(const QString &name, this->userStateChanged_.invoke(); - this->updateLatestMessages(); + if (!isId) + { + this->updateLatestMessages(); + } + // If we're opening by ID, this will be called as soon as we get the information from twitch } void UserInfoPopup::updateLatestMessages() @@ -792,6 +807,14 @@ void UserInfoPopup::updateUserData() return; } + // Correct for when being opened with ID + if (this->userName_.isEmpty()) + { + this->userName_ = user.login; + // Ensure recent messages are shown + this->updateLatestMessages(); + } + this->userId_ = user.id; this->avatarUrl_ = user.profileImageUrl; @@ -909,8 +932,16 @@ void UserInfoPopup::updateUserData() [] {}); }; - getHelix()->getUserByName(this->userName_, onUserFetched, - onUserFetchFailed); + if (!this->userId_.isEmpty()) + { + getHelix()->getUserById(this->userId_, onUserFetched, + onUserFetchFailed); + } + else + { + getHelix()->getUserByName(this->userName_, onUserFetched, + onUserFetchFailed); + } this->ui_.block->setEnabled(false); this->ui_.ignoreHighlights->setEnabled(false); From d40b0a6c1da6c837319133ef059a241ecf1e1b05 Mon Sep 17 00:00:00 2001 From: iProdigy <8106344+iProdigy@users.noreply.github.com> Date: Wed, 8 Nov 2023 09:14:48 -0800 Subject: [PATCH 02/26] fix: avoid reward redemption crash via buffer refactor (#4949) Co-authored-by: nerix Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 1 + src/providers/twitch/IrcMessageHandler.cpp | 30 +++++----------- src/providers/twitch/IrcMessageHandler.hpp | 8 ++--- src/providers/twitch/TwitchChannel.cpp | 42 ++++++++++++++-------- src/providers/twitch/TwitchChannel.hpp | 22 ++++++++++-- 5 files changed, 60 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 63a699619d9..1673fb09eba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ - Bugfix: Fixed tooltips appearing too large and/or away from the cursor. (#4920) - Bugfix: Fixed a crash when clicking `More messages below` button in a usercard and closing it quickly. (#4933) - Bugfix: Fixed thread popup window missing messages for nested threads. (#4923) +- Bugfix: Fixed an occasional crash for channel point redemptions with text input. (#4949) - Dev: Change clang-format from v14 to v16. (#4929) - Dev: Fixed UTF16 encoding of `modes` file for the installer. (#4791) - Dev: Temporarily disable High DPI scaling on Qt6 builds on Windows. (#4767) diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp index 10df4435991..2be9d9621c6 100644 --- a/src/providers/twitch/IrcMessageHandler.cpp +++ b/src/providers/twitch/IrcMessageHandler.cpp @@ -1,6 +1,7 @@ #include "providers/twitch/IrcMessageHandler.hpp" #include "Application.hpp" +#include "common/Common.hpp" #include "common/Literals.hpp" #include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" @@ -691,7 +692,7 @@ void IrcMessageHandler::handlePrivMessage(Communi::IrcPrivateMessage *message, // https://mm2pl.github.io/emoji_rfc.pdf for more details this->addMessage( - message, message->target(), + message, channelOrEmptyByTarget(message->target(), server), message->content().replace(COMBINED_FIXER, ZERO_WIDTH_JOINER), server, false, message->isAction()); @@ -1001,7 +1002,7 @@ void IrcMessageHandler::handleUserNoticeMessage(Communi::IrcMessage *message, // Messages are not required, so they might be empty if (!content.isEmpty()) { - this->addMessage(message, target, content, server, true, false); + this->addMessage(message, chn, content, server, true, false); } } @@ -1260,13 +1261,11 @@ void IrcMessageHandler::setSimilarityFlags(const MessagePtr &message, } void IrcMessageHandler::addMessage(Communi::IrcMessage *message, - const QString &target, + const ChannelPtr &chan, const QString &originalContent, TwitchIrcServer &server, bool isSub, bool isAction) { - auto chan = channelOrEmptyByTarget(target, server); - if (chan->isEmpty()) { return; @@ -1290,27 +1289,14 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *message, if (const auto it = tags.find("custom-reward-id"); it != tags.end()) { const auto rewardId = it.value().toString(); - if (!channel->isChannelPointRewardKnown(rewardId)) + if (!rewardId.isEmpty() && + !channel->isChannelPointRewardKnown(rewardId)) { // Need to wait for pubsub reward notification - auto *clone = message->clone(); qCDebug(chatterinoTwitch) << "TwitchChannel reward added ADD " "callback since reward is not known:" << rewardId; - channel->channelPointRewardAdded.connect( - [=, this, &server](ChannelPointReward reward) { - qCDebug(chatterinoTwitch) - << "TwitchChannel reward added callback:" << reward.id - << "-" << rewardId; - if (reward.id == rewardId) - { - this->addMessage(clone, target, originalContent, server, - isSub, isAction); - clone->deleteLater(); - return true; - } - return false; - }); + channel->addQueuedRedemption(rewardId, originalContent, message); return; } args.channelPointRewardId = rewardId; @@ -1319,7 +1305,7 @@ void IrcMessageHandler::addMessage(Communi::IrcMessage *message, QString content = originalContent; int messageOffset = stripLeadingReplyMention(tags, content); - TwitchMessageBuilder builder(chan.get(), message, args, content, isAction); + TwitchMessageBuilder builder(channel, message, args, content, isAction); builder.setMessageOffset(messageOffset); if (const auto it = tags.find("reply-thread-parent-msg-id"); diff --git a/src/providers/twitch/IrcMessageHandler.hpp b/src/providers/twitch/IrcMessageHandler.hpp index 44773b8f375..26c21f6da64 100644 --- a/src/providers/twitch/IrcMessageHandler.hpp +++ b/src/providers/twitch/IrcMessageHandler.hpp @@ -55,15 +55,15 @@ class IrcMessageHandler void handleJoinMessage(Communi::IrcMessage *message); void handlePartMessage(Communi::IrcMessage *message); + void addMessage(Communi::IrcMessage *message, const ChannelPtr &chan, + const QString &originalContent, TwitchIrcServer &server, + bool isSub, bool isAction); + private: static float similarity(const MessagePtr &msg, const LimitedQueueSnapshot &messages); static void setSimilarityFlags(const MessagePtr &message, const ChannelPtr &channel); - - void addMessage(Communi::IrcMessage *message, const QString &target, - const QString &originalContent, TwitchIrcServer &server, - bool isSub, bool isAction); }; } // namespace chatterino diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index 6dc19510bfb..bf5762574ae 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -348,6 +348,17 @@ void TwitchChannel::refreshSevenTVChannelEmotes(bool manualRefresh) manualRefresh); } +void TwitchChannel::addQueuedRedemption(const QString &rewardId, + const QString &originalContent, + Communi::IrcMessage *message) +{ + this->waitingRedemptions_.push_back({ + rewardId, + originalContent, + {message->clone(), {}}, + }); +} + void TwitchChannel::addChannelPointReward(const ChannelPointReward &reward) { assertInGuiThread(); @@ -368,25 +379,26 @@ void TwitchChannel::addChannelPointReward(const ChannelPointReward &reward) } if (result) { + const auto &channelName = this->getName(); qCDebug(chatterinoTwitch) - << "[TwitchChannel" << this->getName() + << "[TwitchChannel" << channelName << "] Channel point reward added:" << reward.id << "," << reward.title << "," << reward.isUserInputRequired; - // TODO: There's an underlying bug here. This bug should be fixed. - // This only attempts to prevent a crash when invoking the signal. - try - { - this->channelPointRewardAdded.invoke(reward); - } - catch (const std::bad_function_call &) - { - qCWarning(chatterinoTwitch).nospace() - << "[TwitchChannel " << this->getName() - << "] Caught std::bad_function_call when adding channel point " - "reward ChannelPointReward{ id: " - << reward.id << ", title: " << reward.title << " }."; - } + auto *server = getApp()->twitch; + auto it = std::remove_if( + this->waitingRedemptions_.begin(), this->waitingRedemptions_.end(), + [&](const QueuedRedemption &msg) { + if (reward.id == msg.rewardID) + { + IrcMessageHandler::instance().addMessage( + msg.message.get(), shared_from_this(), + msg.originalContent, *server, false, false); + return true; + } + return false; + }); + this->waitingRedemptions_.erase(it, this->waitingRedemptions_.end()); } } diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index 28b8c5d233a..d3636c9c96f 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -4,12 +4,15 @@ #include "common/Atomic.hpp" #include "common/Channel.hpp" #include "common/ChannelChatters.hpp" +#include "common/Common.hpp" #include "common/Outcome.hpp" #include "common/UniqueAccess.hpp" #include "providers/twitch/TwitchEmotes.hpp" #include "util/QStringHash.hpp" +#include #include +#include #include #include #include @@ -67,6 +70,8 @@ struct HelixStream; class TwitchIrcServer; +const int MAX_QUEUED_REDEMPTIONS = 16; + class TwitchChannel final : public Channel, public ChannelChatters { public: @@ -218,8 +223,13 @@ class TwitchChannel final : public Channel, public ChannelChatters pajlada::Signals::NoArgSignal roomModesChanged; // Channel point rewards - pajlada::Signals::SelfDisconnectingSignal - channelPointRewardAdded; + void addQueuedRedemption(const QString &rewardId, + const QString &originalContent, + Communi::IrcMessage *message); + /** + * A rich & hydrated redemption from PubSub has arrived, add it to the channel. + * This will look at queued up partial messages, and if one is found it will add the queued up partial messages fully hydrated. + **/ void addChannelPointReward(const ChannelPointReward &reward); bool isChannelPointRewardKnown(const QString &rewardId); std::optional channelPointReward( @@ -246,6 +256,12 @@ class TwitchChannel final : public Channel, public ChannelChatters QString actualDisplayName; } nameOptions; + struct QueuedRedemption { + QString rewardID; + QString originalContent; + QObjectPtr message; + }; + void refreshPubSub(); void refreshChatters(); void refreshBadges(); @@ -356,6 +372,8 @@ class TwitchChannel final : public Channel, public ChannelChatters badgeSets_; // "subscribers": { "0": ... "3": ... "6": ... UniqueAccess> cheerEmoteSets_; UniqueAccess> channelPointRewards_; + boost::circular_buffer_space_optimized + waitingRedemptions_{MAX_QUEUED_REDEMPTIONS}; bool mod_ = false; bool vip_ = false; From f89642ec660f0302e8f8d4be3186dc61d6fe850d Mon Sep 17 00:00:00 2001 From: pajlada Date: Wed, 8 Nov 2023 18:57:09 +0100 Subject: [PATCH 03/26] refactor: Move all commands to their own files (#4946) --- src/CMakeLists.txt | 40 +- .../commands/CommandController.cpp | 2713 +---------------- src/controllers/commands/builtin/Misc.cpp | 629 ++++ src/controllers/commands/builtin/Misc.hpp | 32 + .../commands/builtin/chatterino/Debugging.cpp | 71 + .../commands/builtin/chatterino/Debugging.hpp | 8 + .../commands/builtin/twitch/AddModerator.cpp | 131 + .../commands/builtin/twitch/AddModerator.hpp | 16 + .../commands/builtin/twitch/AddVIP.cpp | 112 + .../commands/builtin/twitch/AddVIP.hpp | 16 + .../commands/builtin/twitch/Announce.cpp | 81 + .../commands/builtin/twitch/Announce.hpp | 16 + .../commands/builtin/twitch/Block.cpp | 166 + .../commands/builtin/twitch/Block.hpp | 25 + .../commands/builtin/twitch/Chatters.cpp | 143 + .../commands/builtin/twitch/Chatters.hpp | 17 + .../builtin/twitch/DeleteMessages.cpp | 162 + .../builtin/twitch/DeleteMessages.hpp | 19 + .../commands/builtin/twitch/GetModerators.cpp | 94 + .../commands/builtin/twitch/GetModerators.hpp | 16 + .../commands/builtin/twitch/GetVIPs.cpp | 124 + .../commands/builtin/twitch/GetVIPs.hpp | 16 + .../commands/builtin/twitch/Raid.cpp | 220 ++ .../commands/builtin/twitch/Raid.hpp | 19 + .../builtin/twitch/RemoveModerator.cpp | 122 + .../builtin/twitch/RemoveModerator.hpp | 16 + .../commands/builtin/twitch/RemoveVIP.cpp | 112 + .../commands/builtin/twitch/RemoveVIP.hpp | 16 + .../commands/builtin/twitch/SendReply.cpp | 63 + .../commands/builtin/twitch/SendReply.hpp | 15 + .../commands/builtin/twitch/SendWhisper.cpp | 258 ++ .../commands/builtin/twitch/SendWhisper.hpp | 15 + .../builtin/twitch/StartCommercial.cpp | 136 + .../builtin/twitch/StartCommercial.hpp | 16 + .../commands/builtin/twitch/Unban.cpp | 124 + .../commands/builtin/twitch/Unban.hpp | 16 + .../commands/builtin/twitch/UpdateChannel.cpp | 121 + .../commands/builtin/twitch/UpdateChannel.hpp | 16 + .../commands/builtin/twitch/UpdateColor.cpp | 99 + .../commands/builtin/twitch/UpdateColor.hpp | 15 + src/messages/Image.hpp | 6 +- 41 files changed, 3426 insertions(+), 2626 deletions(-) create mode 100644 src/controllers/commands/builtin/Misc.cpp create mode 100644 src/controllers/commands/builtin/Misc.hpp create mode 100644 src/controllers/commands/builtin/twitch/AddModerator.cpp create mode 100644 src/controllers/commands/builtin/twitch/AddModerator.hpp create mode 100644 src/controllers/commands/builtin/twitch/AddVIP.cpp create mode 100644 src/controllers/commands/builtin/twitch/AddVIP.hpp create mode 100644 src/controllers/commands/builtin/twitch/Announce.cpp create mode 100644 src/controllers/commands/builtin/twitch/Announce.hpp create mode 100644 src/controllers/commands/builtin/twitch/Block.cpp create mode 100644 src/controllers/commands/builtin/twitch/Block.hpp create mode 100644 src/controllers/commands/builtin/twitch/Chatters.cpp create mode 100644 src/controllers/commands/builtin/twitch/Chatters.hpp create mode 100644 src/controllers/commands/builtin/twitch/DeleteMessages.cpp create mode 100644 src/controllers/commands/builtin/twitch/DeleteMessages.hpp create mode 100644 src/controllers/commands/builtin/twitch/GetModerators.cpp create mode 100644 src/controllers/commands/builtin/twitch/GetModerators.hpp create mode 100644 src/controllers/commands/builtin/twitch/GetVIPs.cpp create mode 100644 src/controllers/commands/builtin/twitch/GetVIPs.hpp create mode 100644 src/controllers/commands/builtin/twitch/Raid.cpp create mode 100644 src/controllers/commands/builtin/twitch/Raid.hpp create mode 100644 src/controllers/commands/builtin/twitch/RemoveModerator.cpp create mode 100644 src/controllers/commands/builtin/twitch/RemoveModerator.hpp create mode 100644 src/controllers/commands/builtin/twitch/RemoveVIP.cpp create mode 100644 src/controllers/commands/builtin/twitch/RemoveVIP.hpp create mode 100644 src/controllers/commands/builtin/twitch/SendReply.cpp create mode 100644 src/controllers/commands/builtin/twitch/SendReply.hpp create mode 100644 src/controllers/commands/builtin/twitch/SendWhisper.cpp create mode 100644 src/controllers/commands/builtin/twitch/SendWhisper.hpp create mode 100644 src/controllers/commands/builtin/twitch/StartCommercial.cpp create mode 100644 src/controllers/commands/builtin/twitch/StartCommercial.hpp create mode 100644 src/controllers/commands/builtin/twitch/Unban.cpp create mode 100644 src/controllers/commands/builtin/twitch/Unban.hpp create mode 100644 src/controllers/commands/builtin/twitch/UpdateChannel.cpp create mode 100644 src/controllers/commands/builtin/twitch/UpdateChannel.hpp create mode 100644 src/controllers/commands/builtin/twitch/UpdateColor.cpp create mode 100644 src/controllers/commands/builtin/twitch/UpdateColor.hpp diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 84a6df64ab5..d1754239a00 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -59,14 +59,50 @@ set(SOURCE_FILES controllers/commands/builtin/chatterino/Debugging.cpp controllers/commands/builtin/chatterino/Debugging.hpp + controllers/commands/builtin/Misc.cpp + controllers/commands/builtin/Misc.hpp + controllers/commands/builtin/twitch/AddModerator.cpp + controllers/commands/builtin/twitch/AddModerator.hpp + controllers/commands/builtin/twitch/AddVIP.cpp + controllers/commands/builtin/twitch/AddVIP.hpp + controllers/commands/builtin/twitch/Announce.cpp + controllers/commands/builtin/twitch/Announce.hpp + controllers/commands/builtin/twitch/Ban.cpp + controllers/commands/builtin/twitch/Ban.hpp + controllers/commands/builtin/twitch/Block.cpp + controllers/commands/builtin/twitch/Block.hpp controllers/commands/builtin/twitch/ChatSettings.cpp controllers/commands/builtin/twitch/ChatSettings.hpp + controllers/commands/builtin/twitch/Chatters.cpp + controllers/commands/builtin/twitch/Chatters.hpp + controllers/commands/builtin/twitch/DeleteMessages.cpp + controllers/commands/builtin/twitch/DeleteMessages.hpp + controllers/commands/builtin/twitch/GetModerators.cpp + controllers/commands/builtin/twitch/GetModerators.hpp + controllers/commands/builtin/twitch/GetVIPs.cpp + controllers/commands/builtin/twitch/GetVIPs.hpp + controllers/commands/builtin/twitch/Raid.cpp + controllers/commands/builtin/twitch/Raid.hpp + controllers/commands/builtin/twitch/RemoveModerator.cpp + controllers/commands/builtin/twitch/RemoveModerator.hpp + controllers/commands/builtin/twitch/RemoveVIP.cpp + controllers/commands/builtin/twitch/RemoveVIP.hpp + controllers/commands/builtin/twitch/SendReply.cpp + controllers/commands/builtin/twitch/SendReply.hpp + controllers/commands/builtin/twitch/SendWhisper.cpp + controllers/commands/builtin/twitch/SendWhisper.hpp controllers/commands/builtin/twitch/ShieldMode.cpp controllers/commands/builtin/twitch/ShieldMode.hpp controllers/commands/builtin/twitch/Shoutout.cpp controllers/commands/builtin/twitch/Shoutout.hpp - controllers/commands/builtin/twitch/Ban.cpp - controllers/commands/builtin/twitch/Ban.hpp + controllers/commands/builtin/twitch/StartCommercial.cpp + controllers/commands/builtin/twitch/StartCommercial.hpp + controllers/commands/builtin/twitch/Unban.cpp + controllers/commands/builtin/twitch/Unban.hpp + controllers/commands/builtin/twitch/UpdateChannel.cpp + controllers/commands/builtin/twitch/UpdateChannel.hpp + controllers/commands/builtin/twitch/UpdateColor.cpp + controllers/commands/builtin/twitch/UpdateColor.hpp controllers/commands/CommandContext.hpp controllers/commands/CommandController.cpp controllers/commands/CommandController.hpp diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index de04141cbb1..9d66afd4729 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -1,352 +1,53 @@ #include "controllers/commands/CommandController.hpp" #include "Application.hpp" -#include "common/Env.hpp" -#include "common/LinkParser.hpp" -#include "common/NetworkResult.hpp" -#include "common/QLogging.hpp" -#include "common/SignalVector.hpp" +#include "common/Channel.hpp" #include "controllers/accounts/AccountController.hpp" #include "controllers/commands/builtin/chatterino/Debugging.hpp" +#include "controllers/commands/builtin/Misc.hpp" +#include "controllers/commands/builtin/twitch/AddModerator.hpp" +#include "controllers/commands/builtin/twitch/AddVIP.hpp" +#include "controllers/commands/builtin/twitch/Announce.hpp" #include "controllers/commands/builtin/twitch/Ban.hpp" +#include "controllers/commands/builtin/twitch/Block.hpp" #include "controllers/commands/builtin/twitch/ChatSettings.hpp" +#include "controllers/commands/builtin/twitch/Chatters.hpp" +#include "controllers/commands/builtin/twitch/DeleteMessages.hpp" +#include "controllers/commands/builtin/twitch/GetModerators.hpp" +#include "controllers/commands/builtin/twitch/GetVIPs.hpp" +#include "controllers/commands/builtin/twitch/Raid.hpp" +#include "controllers/commands/builtin/twitch/RemoveModerator.hpp" +#include "controllers/commands/builtin/twitch/RemoveVIP.hpp" +#include "controllers/commands/builtin/twitch/SendReply.hpp" +#include "controllers/commands/builtin/twitch/SendWhisper.hpp" #include "controllers/commands/builtin/twitch/ShieldMode.hpp" #include "controllers/commands/builtin/twitch/Shoutout.hpp" +#include "controllers/commands/builtin/twitch/StartCommercial.hpp" +#include "controllers/commands/builtin/twitch/Unban.hpp" +#include "controllers/commands/builtin/twitch/UpdateChannel.hpp" +#include "controllers/commands/builtin/twitch/UpdateColor.hpp" #include "controllers/commands/Command.hpp" #include "controllers/commands/CommandContext.hpp" #include "controllers/commands/CommandModel.hpp" #include "controllers/plugins/PluginController.hpp" -#include "controllers/userdata/UserDataController.hpp" -#include "messages/Image.hpp" #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" -#include "messages/MessageElement.hpp" -#include "messages/MessageThread.hpp" -#include "providers/irc/IrcChannel2.hpp" -#include "providers/irc/IrcServer.hpp" -#include "providers/twitch/api/Helix.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchCommon.hpp" -#include "providers/twitch/TwitchIrcServer.hpp" -#include "providers/twitch/TwitchMessageBuilder.hpp" #include "singletons/Emotes.hpp" #include "singletons/Paths.hpp" -#include "singletons/Settings.hpp" -#include "singletons/Theme.hpp" -#include "singletons/WindowManager.hpp" -#include "util/Clipboard.hpp" #include "util/CombinePath.hpp" -#include "util/FormatTime.hpp" -#include "util/Helpers.hpp" -#include "util/IncognitoBrowser.hpp" -#include "util/PostToThread.hpp" +#include "util/QStringHash.hpp" #include "util/Qt.hpp" -#include "util/StreamerMode.hpp" -#include "util/StreamLink.hpp" -#include "util/Twitch.hpp" -#include "widgets/dialogs/ReplyThreadPopup.hpp" -#include "widgets/dialogs/UserInfoPopup.hpp" -#include "widgets/helper/ChannelView.hpp" -#include "widgets/splits/Split.hpp" -#include "widgets/splits/SplitContainer.hpp" -#include "widgets/Window.hpp" - -#include -#include -#include -#include -#include -namespace { - -using namespace chatterino; - -bool areIRCCommandsStillAvailable() -{ - // 11th of February 2023, 06:00am UTC - const QDateTime migrationTime(QDate(2023, 2, 11), QTime(6, 0), Qt::UTC); - auto now = QDateTime::currentDateTimeUtc(); - return now < migrationTime; -} - -QString useIRCCommand(const QStringList &words) -{ - // Reform the original command - auto originalCommand = words.join(" "); - - // Replace the / with a . to pass it along to TMI - auto newCommand = originalCommand; - newCommand.replace(0, 1, "."); - - qCDebug(chatterinoTwitch) - << "Forwarding command" << originalCommand << "as" << newCommand; - - return newCommand; -} - -void sendWhisperMessage(const QString &text) -{ - // (hemirt) pajlada: "we should not be sending whispers through jtv, but - // rather to your own username" - auto app = getApp(); - QString toSend = text.simplified(); - - app->twitch->sendMessage("jtv", toSend); -} - -bool appendWhisperMessageWordsLocally(const QStringList &words) -{ - auto app = getApp(); - - MessageBuilder b; - - b.emplace(); - b.emplace(app->accounts->twitch.getCurrent()->getUserName(), - MessageElementFlag::Text, MessageColor::Text, - FontStyle::ChatMediumBold); - b.emplace("->", MessageElementFlag::Text, - getApp()->themes->messages.textColors.system); - b.emplace(words[1] + ":", MessageElementFlag::Text, - MessageColor::Text, FontStyle::ChatMediumBold); - - const auto &acc = app->accounts->twitch.getCurrent(); - const auto &accemotes = *acc->accessEmotes(); - const auto &bttvemotes = app->twitch->getBttvEmotes(); - const auto &ffzemotes = app->twitch->getFfzEmotes(); - auto flags = MessageElementFlags(); - auto emote = std::optional{}; - for (int i = 2; i < words.length(); i++) - { - { // Twitch emote - auto it = accemotes.emotes.find({words[i]}); - if (it != accemotes.emotes.end()) - { - b.emplace(it->second, - MessageElementFlag::TwitchEmote); - continue; - } - } // Twitch emote - - { // bttv/ffz emote - if ((emote = bttvemotes.emote({words[i]}))) - { - flags = MessageElementFlag::BttvEmote; - } - else if ((emote = ffzemotes.emote({words[i]}))) - { - flags = MessageElementFlag::FfzEmote; - } - if (emote) - { - b.emplace(*emote, flags); - continue; - } - } // bttv/ffz emote - { // emoji/text - for (auto &variant : app->emotes->emojis.parse(words[i])) - { - constexpr const static struct { - void operator()(EmotePtr emote, MessageBuilder &b) const - { - b.emplace(emote, - MessageElementFlag::EmojiAll); - } - void operator()(const QString &string, - MessageBuilder &b) const - { - LinkParser parser(string); - if (parser.result()) - { - b.addLink(*parser.result()); - } - else - { - b.emplace(string, - MessageElementFlag::Text); - } - } - } visitor; - boost::apply_visitor( - [&b](auto &&arg) { - visitor(arg, b); - }, - variant); - } // emoji/text - } - } - - b->flags.set(MessageFlag::DoNotTriggerNotification); - b->flags.set(MessageFlag::Whisper); - auto messagexD = b.release(); - - app->twitch->whispersChannel->addMessage(messagexD); - - auto overrideFlags = std::optional(messagexD->flags); - overrideFlags->set(MessageFlag::DoNotLog); - - if (getSettings()->inlineWhispers && - !(getSettings()->streamerModeSuppressInlineWhispers && - isInStreamerMode())) - { - app->twitch->forEachChannel( - [&messagexD, overrideFlags](ChannelPtr _channel) { - _channel->addMessage(messagexD, overrideFlags); - }); - } - - return true; -} - -bool useIrcForWhisperCommand() -{ - switch (getSettings()->helixTimegateWhisper.getValue()) - { - case HelixTimegateOverride::Timegate: { - if (areIRCCommandsStillAvailable()) - { - return true; - } - - // fall through to Helix logic - } - break; - - case HelixTimegateOverride::AlwaysUseIRC: { - return true; - } - break; +#include - case HelixTimegateOverride::AlwaysUseHelix: { - // do nothing and fall through to Helix logic - } - break; - } - return false; -} +#include -QString runWhisperCommand(const QStringList &words, const ChannelPtr &channel) -{ - if (words.size() < 3) - { - channel->addMessage( - makeSystemMessage("Usage: /w ")); - return ""; - } +namespace { - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage( - makeSystemMessage("You must be logged in to send a whisper!")); - return ""; - } - auto target = words.at(1); - stripChannelName(target); - auto message = words.mid(2).join(' '); - if (channel->isTwitchChannel()) - { - // this covers all twitch channels and twitch-like channels - if (useIrcForWhisperCommand()) - { - appendWhisperMessageWordsLocally(words); - sendWhisperMessage(words.join(' ')); - return ""; - } - getHelix()->getUserByName( - target, - [channel, currentUser, target, message, - words](const auto &targetUser) { - getHelix()->sendWhisper( - currentUser->getUserId(), targetUser.id, message, - [words] { - appendWhisperMessageWordsLocally(words); - }, - [channel, target, targetUser](auto error, auto message) { - using Error = HelixWhisperError; - - QString errorMessage = "Failed to send whisper - "; - - switch (error) - { - case Error::NoVerifiedPhone: { - errorMessage += - "Due to Twitch restrictions, you are now " - "required to have a verified phone number " - "to send whispers. You can add a phone " - "number in Twitch settings. " - "https://www.twitch.tv/settings/security"; - }; - break; - - case Error::RecipientBlockedUser: { - errorMessage += - "The recipient doesn't allow whispers " - "from strangers or you directly."; - }; - break; - - case Error::WhisperSelf: { - errorMessage += "You cannot whisper yourself."; - }; - break; - - case Error::Forwarded: { - errorMessage += message; - } - break; - - case Error::Ratelimited: { - errorMessage += - "You may only whisper a maximum of 40 " - "unique recipients per day. Within the " - "per day limit, you may whisper a " - "maximum of 3 whispers per second and " - "a maximum of 100 whispers per minute."; - } - break; - - case Error::UserMissingScope: { - // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE - errorMessage += "Missing required scope. " - "Re-login with your " - "account and try again."; - } - break; - - case Error::UserNotAuthorized: { - // TODO(pajlada): Phrase MISSING_PERMISSION - errorMessage += "You don't have permission to " - "perform that action."; - } - break; - - case Error::Unknown: { - errorMessage += - "An unknown error has occurred."; - } - break; - } - channel->addMessage(makeSystemMessage(errorMessage)); - }); - }, - [channel] { - channel->addMessage( - makeSystemMessage("No user matching that username.")); - }); - return ""; - } - // we must be on IRC - auto *ircChannel = dynamic_cast(channel.get()); - if (ircChannel == nullptr) - { - // give up - return ""; - } - auto *server = ircChannel->server(); - server->sendWhisper(target, message); - return ""; -} +using namespace chatterino; using VariableReplacer = std::function; @@ -623,2356 +324,130 @@ void CommandController::initialize(Settings &, Paths &paths) /// Deprecated commands - auto blockLambda = [](const auto &words, auto channel) { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /block command only works in Twitch channels")); - return ""; - } - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage("Usage: /block ")); - return ""; - } - - auto currentUser = getApp()->accounts->twitch.getCurrent(); - - if (currentUser->isAnon()) - { - channel->addMessage( - makeSystemMessage("You must be logged in to block someone!")); - return ""; - } - - auto target = words.at(1); - stripChannelName(target); - - getHelix()->getUserByName( - target, - [currentUser, channel, target](const HelixUser &targetUser) { - getApp()->accounts->twitch.getCurrent()->blockUser( - targetUser.id, nullptr, - [channel, target, targetUser] { - channel->addMessage(makeSystemMessage( - QString("You successfully blocked user %1") - .arg(target))); - }, - [channel, target] { - channel->addMessage(makeSystemMessage( - QString("User %1 couldn't be blocked, an unknown " - "error occurred!") - .arg(target))); - }); - }, - [channel, target] { - channel->addMessage( - makeSystemMessage(QString("User %1 couldn't be blocked, no " - "user with that name found!") - .arg(target))); - }); - - return ""; - }; - - auto unblockLambda = [](const auto &words, auto channel) { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /unblock command only works in Twitch channels")); - return ""; - } - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage("Usage: /unblock ")); - return ""; - } - - auto currentUser = getApp()->accounts->twitch.getCurrent(); - - if (currentUser->isAnon()) - { - channel->addMessage( - makeSystemMessage("You must be logged in to unblock someone!")); - return ""; - } - - auto target = words.at(1); - stripChannelName(target); - - getHelix()->getUserByName( - target, - [currentUser, channel, target](const auto &targetUser) { - getApp()->accounts->twitch.getCurrent()->unblockUser( - targetUser.id, nullptr, - [channel, target, targetUser] { - channel->addMessage(makeSystemMessage( - QString("You successfully unblocked user %1") - .arg(target))); - }, - [channel, target] { - channel->addMessage(makeSystemMessage( - QString("User %1 couldn't be unblocked, an unknown " - "error occurred!") - .arg(target))); - }); - }, - [channel, target] { - channel->addMessage( - makeSystemMessage(QString("User %1 couldn't be unblocked, " - "no user with that name found!") - .arg(target))); - }); - - return ""; - }; + this->registerCommand("/ignore", &commands::ignoreUser); - this->registerCommand( - "/ignore", [blockLambda](const auto &words, auto channel) { - channel->addMessage(makeSystemMessage( - "Ignore command has been renamed to /block, please use it from " - "now on as /ignore is going to be removed soon.")); - blockLambda(words, channel); - return ""; - }); - - this->registerCommand( - "/unignore", [unblockLambda](const auto &words, auto channel) { - channel->addMessage(makeSystemMessage( - "Unignore command has been renamed to /unblock, please use it " - "from now on as /unignore is going to be removed soon.")); - unblockLambda(words, channel); - return ""; - }); + this->registerCommand("/unignore", &commands::unignoreUser); - this->registerCommand("/follow", [](const auto &words, auto channel) { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - return ""; - } - channel->addMessage(makeSystemMessage( - "Twitch has removed the ability to follow users through " - "third-party applications. For more information, see " - "https://github.com/Chatterino/chatterino2/issues/3076")); - return ""; - }); + this->registerCommand("/follow", &commands::follow); - this->registerCommand("/unfollow", [](const auto &words, auto channel) { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - return ""; - } - channel->addMessage(makeSystemMessage( - "Twitch has removed the ability to unfollow users through " - "third-party applications. For more information, see " - "https://github.com/Chatterino/chatterino2/issues/3076")); - return ""; - }); + this->registerCommand("/unfollow", &commands::unfollow); /// Supported commands - this->registerCommand( - "/debug-args", [](const auto & /*words*/, auto channel) { - QString msg = QApplication::instance()->arguments().join(' '); - - channel->addMessage(makeSystemMessage(msg)); - - return ""; - }); - - this->registerCommand("/debug-env", [](const auto & /*words*/, - ChannelPtr channel) { - auto env = Env::get(); - - QStringList debugMessages{ - "recentMessagesApiUrl: " + env.recentMessagesApiUrl, - "linkResolverUrl: " + env.linkResolverUrl, - "twitchServerHost: " + env.twitchServerHost, - "twitchServerPort: " + QString::number(env.twitchServerPort), - "twitchServerSecure: " + QString::number(env.twitchServerSecure), - }; - - for (QString &str : debugMessages) - { - MessageBuilder builder; - builder.emplace(QTime::currentTime()); - builder.emplace(str, MessageElementFlag::Text, - MessageColor::System); - channel->addMessage(builder.release()); - } - return ""; - }); - - this->registerCommand("/uptime", [](const auto & /*words*/, auto channel) { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /uptime command only works in Twitch Channels")); - return ""; - } - - const auto &streamStatus = twitchChannel->accessStreamStatus(); - - QString messageText = - streamStatus->live ? streamStatus->uptime : "Channel is not live."; - - channel->addMessage(makeSystemMessage(messageText)); - - return ""; - }); - - this->registerCommand("/block", blockLambda); - - this->registerCommand("/unblock", unblockLambda); - - this->registerCommand("/user", [](const auto &words, auto channel) { - if (words.size() < 2) - { - channel->addMessage( - makeSystemMessage("Usage: /user [channel]")); - return ""; - } - QString userName = words[1]; - stripUserName(userName); - - QString channelName = channel->getName(); - - if (words.size() > 2) - { - channelName = words[2]; - stripChannelName(channelName); - } - openTwitchUsercard(channelName, userName); - - return ""; - }); - - this->registerCommand("/usercard", [](const auto &words, auto channel) { - if (words.size() < 2) - { - channel->addMessage( - makeSystemMessage("Usage: /usercard [channel] or " - "/usercard id: [channel]")); - return ""; - } - - QString userName = words[1]; - stripUserName(userName); + this->registerCommand("/debug-args", &commands::listArgs); - if (words.size() > 2) - { - QString channelName = words[2]; - stripChannelName(channelName); + this->registerCommand("/debug-env", &commands::listEnvironmentVariables); - ChannelPtr channelTemp = - getApp()->twitch->getChannelOrEmpty(channelName); + this->registerCommand("/uptime", &commands::uptime); - if (channelTemp->isEmpty()) - { - channel->addMessage(makeSystemMessage( - "A usercard can only be displayed for a channel that is " - "currently opened in Chatterino.")); - return ""; - } + this->registerCommand("/block", &commands::blockUser); - channel = channelTemp; - } + this->registerCommand("/unblock", &commands::unblockUser); - // try to link to current split if possible - Split *currentSplit = nullptr; - auto *currentPage = dynamic_cast( - getApp()->windows->getMainWindow().getNotebook().getSelectedPage()); - if (currentPage != nullptr) - { - currentSplit = currentPage->getSelectedSplit(); - } + this->registerCommand("/user", &commands::user); - auto differentChannel = - currentSplit != nullptr && currentSplit->getChannel() != channel; - if (differentChannel || currentSplit == nullptr) - { - // not possible to use current split, try searching for one - const auto ¬ebook = - getApp()->windows->getMainWindow().getNotebook(); - auto count = notebook.getPageCount(); - for (int i = 0; i < count; i++) - { - auto *page = notebook.getPageAt(i); - auto *container = dynamic_cast(page); - assert(container != nullptr); - for (auto *split : container->getSplits()) - { - if (split->getChannel() == channel) - { - currentSplit = split; - break; - } - } - } + this->registerCommand("/usercard", &commands::openUsercard); - // This would have crashed either way. - assert(currentSplit != nullptr && - "something went HORRIBLY wrong with the /usercard " - "command. It couldn't find a split for a channel which " - "should be open."); - } + this->registerCommand("/requests", &commands::requests); - auto *userPopup = new UserInfoPopup( - getSettings()->autoCloseUserPopup, - static_cast(&(getApp()->windows->getMainWindow())), - currentSplit); - userPopup->setData(userName, channel); - userPopup->moveTo(QCursor::pos(), - widgets::BoundsChecking::CursorPosition); - userPopup->show(); - return ""; - }); + this->registerCommand("/lowtrust", &commands::lowtrust); - this->registerCommand("/requests", [](const QStringList &words, - ChannelPtr channel) { - QString target(words.value(1)); + this->registerCommand("/chatters", &commands::chatters); - if (target.isEmpty()) - { - if (channel->getType() == Channel::Type::Twitch && - !channel->isEmpty()) - { - target = channel->getName(); - } - else - { - channel->addMessage(makeSystemMessage( - "Usage: /requests [channel]. You can also use the command " - "without arguments in any Twitch channel to open its " - "channel points requests queue. Only the broadcaster and " - "moderators have permission to view the queue.")); - return ""; - } - } + this->registerCommand("/test-chatters", &commands::testChatters); - stripChannelName(target); - QDesktopServices::openUrl( - QUrl(QString("https://www.twitch.tv/popout/%1/reward-queue") - .arg(target))); + this->registerCommand("/mods", &commands::getModerators); - return ""; - }); + this->registerCommand("/clip", &commands::clip); - this->registerCommand("/lowtrust", [](const QStringList &words, - ChannelPtr channel) { - QString target(words.value(1)); + this->registerCommand("/marker", &commands::marker); - if (target.isEmpty()) - { - if (channel->getType() == Channel::Type::Twitch && - !channel->isEmpty()) - { - target = channel->getName(); - } - else - { - channel->addMessage(makeSystemMessage( - "Usage: /lowtrust [channel]. You can also use the command " - "without arguments in any Twitch channel to open its " - "suspicious user activity feed. Only the broadcaster and " - "moderators have permission to view this feed.")); - return ""; - } - } + this->registerCommand("/streamlink", &commands::streamlink); - stripChannelName(target); - QDesktopServices::openUrl(QUrl( - QString("https://www.twitch.tv/popout/moderator/%1/low-trust-users") - .arg(target))); + this->registerCommand("/popout", &commands::popout); - return ""; - }); + this->registerCommand("/popup", &commands::popup); - auto formatChattersError = [](HelixGetChattersError error, - QString message) { - using Error = HelixGetChattersError; + this->registerCommand("/clearmessages", &commands::clearmessages); - QString errorMessage = QString("Failed to get chatter count - "); + this->registerCommand("/settitle", &commands::setTitle); - switch (error) - { - case Error::Forwarded: { - errorMessage += message; - } - break; + this->registerCommand("/setgame", &commands::setGame); - case Error::UserMissingScope: { - errorMessage += "Missing required scope. " - "Re-login with your " - "account and try again."; - } - break; + this->registerCommand("/openurl", &commands::openURL); - case Error::UserNotAuthorized: { - errorMessage += "You must have moderator permissions to " - "use this command."; - } - break; + this->registerCommand("/raw", &commands::sendRawMessage); - case Error::Unknown: { - errorMessage += "An unknown error has occurred."; - } - break; - } - return errorMessage; - }; + this->registerCommand("/reply", &commands::sendReply); - this->registerCommand( - "/chatters", [formatChattersError](const auto &words, auto channel) { - auto *twitchChannel = dynamic_cast(channel.get()); +#ifndef NDEBUG + this->registerCommand("/fakemsg", &commands::injectFakeMessage); +#endif - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /chatters command only works in Twitch Channels")); - return ""; - } + this->registerCommand("/copy", &commands::copyToClipboard); - // Refresh chatter list via helix api for mods - getHelix()->getChatters( - twitchChannel->roomId(), - getApp()->accounts->twitch.getCurrent()->getUserId(), 1, - [channel](auto result) { - channel->addMessage(makeSystemMessage( - QString("Chatter count: %1") - .arg(localizeNumbers(result.total)))); - }, - [channel, formatChattersError](auto error, auto message) { - auto errorMessage = formatChattersError(error, message); - channel->addMessage(makeSystemMessage(errorMessage)); - }); + this->registerCommand("/color", &commands::updateUserColor); - return ""; - }); + this->registerCommand("/clear", &commands::deleteAllMessages); - this->registerCommand("/test-chatters", [formatChattersError]( - const auto & /*words*/, - auto channel) { - auto *twitchChannel = dynamic_cast(channel.get()); + this->registerCommand("/delete", &commands::deleteOneMessage); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /test-chatters command only works in Twitch Channels")); - return ""; - } + this->registerCommand("/mod", &commands::addModerator); - getHelix()->getChatters( - twitchChannel->roomId(), - getApp()->accounts->twitch.getCurrent()->getUserId(), 5000, - [channel, twitchChannel](auto result) { - QStringList entries; - for (const auto &username : result.chatters) - { - entries << username; - } + this->registerCommand("/unmod", &commands::removeModerator); - QString prefix = "Chatters "; + this->registerCommand("/announce", &commands::sendAnnouncement); - if (result.total > 5000) - { - prefix += QString("(5000/%1):").arg(result.total); - } - else - { - prefix += QString("(%1):").arg(result.total); - } + this->registerCommand("/vip", &commands::addVIP); - MessageBuilder builder; - TwitchMessageBuilder::listOfUsersSystemMessage( - prefix, entries, twitchChannel, &builder); + this->registerCommand("/unvip", &commands::removeVIP); - channel->addMessage(builder.release()); - }, - [channel, formatChattersError](auto error, auto message) { - auto errorMessage = formatChattersError(error, message); - channel->addMessage(makeSystemMessage(errorMessage)); - }); + this->registerCommand("/unban", &commands::unbanUser); + this->registerCommand("/untimeout", &commands::unbanUser); - return ""; - }); + this->registerCommand("/raid", &commands::startRaid); - auto formatModsError = [](HelixGetModeratorsError error, QString message) { - using Error = HelixGetModeratorsError; + this->registerCommand("/unraid", &commands::cancelRaid); - QString errorMessage = QString("Failed to get moderators - "); + this->registerCommand("/emoteonly", &commands::emoteOnly); + this->registerCommand("/emoteonlyoff", &commands::emoteOnlyOff); - switch (error) - { - case Error::Forwarded: { - errorMessage += message; - } - break; + this->registerCommand("/subscribers", &commands::subscribers); + this->registerCommand("/subscribersoff", &commands::subscribersOff); - case Error::UserMissingScope: { - errorMessage += "Missing required scope. " - "Re-login with your " - "account and try again."; - } - break; + this->registerCommand("/slow", &commands::slow); + this->registerCommand("/slowoff", &commands::slowOff); - case Error::UserNotAuthorized: { - errorMessage += - "Due to Twitch restrictions, " - "this command can only be used by the broadcaster. " - "To see the list of mods you must use the Twitch website."; - } - break; + this->registerCommand("/followers", &commands::followers); + this->registerCommand("/followersoff", &commands::followersOff); - case Error::Unknown: { - errorMessage += "An unknown error has occurred."; - } - break; - } - return errorMessage; - }; + this->registerCommand("/uniquechat", &commands::uniqueChat); + this->registerCommand("/r9kbeta", &commands::uniqueChat); + this->registerCommand("/uniquechatoff", &commands::uniqueChatOff); + this->registerCommand("/r9kbetaoff", &commands::uniqueChatOff); - this->registerCommand( - "/mods", - [formatModsError](const QStringList &words, auto channel) -> QString { - auto twitchChannel = dynamic_cast(channel.get()); + this->registerCommand("/timeout", &commands::sendTimeout); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /mods command only works in Twitch Channels")); - return ""; - } + this->registerCommand("/ban", &commands::sendBan); + this->registerCommand("/banid", &commands::sendBanById); - switch (getSettings()->helixTimegateModerators.getValue()) - { - case HelixTimegateOverride::Timegate: { - if (areIRCCommandsStillAvailable()) - { - return useIRCCommand(words); - } - } - break; + for (const auto &cmd : TWITCH_WHISPER_COMMANDS) + { + this->registerCommand(cmd, &commands::sendWhisper); + } - case HelixTimegateOverride::AlwaysUseIRC: { - return useIRCCommand(words); - } - break; - case HelixTimegateOverride::AlwaysUseHelix: { - // Fall through to helix logic - } - break; - } + this->registerCommand("/vips", &commands::getVIPs); - getHelix()->getModerators( - twitchChannel->roomId(), 500, - [channel, twitchChannel](auto result) { - if (result.empty()) - { - channel->addMessage(makeSystemMessage( - "This channel does not have any moderators.")); - return; - } - - // TODO: sort results? - - MessageBuilder builder; - TwitchMessageBuilder::listOfUsersSystemMessage( - "The moderators of this channel are", result, - twitchChannel, &builder); - channel->addMessage(builder.release()); - }, - [channel, formatModsError](auto error, auto message) { - auto errorMessage = formatModsError(error, message); - channel->addMessage(makeSystemMessage(errorMessage)); - }); - return ""; - }); + this->registerCommand("/commercial", &commands::startCommercial); - this->registerCommand("/clip", [](const auto & /*words*/, auto channel) { - if (const auto type = channel->getType(); - type != Channel::Type::Twitch && - type != Channel::Type::TwitchWatching) - { - channel->addMessage(makeSystemMessage( - "The /clip command only works in Twitch Channels")); - return ""; - } + this->registerCommand("/unstable-set-user-color", + &commands::unstableSetUserClientSideColor); - auto *twitchChannel = dynamic_cast(channel.get()); + this->registerCommand("/debug-force-image-gc", + &commands::forceImageGarbageCollection); - twitchChannel->createClip(); - - return ""; - }); - - this->registerCommand("/marker", [](const QStringList &words, - auto channel) { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /marker command only works in Twitch channels")); - return ""; - } - - // Avoid Helix calls without Client ID and/or OAuth Token - if (getApp()->accounts->twitch.getCurrent()->isAnon()) - { - channel->addMessage(makeSystemMessage( - "You need to be logged in to create stream markers!")); - return ""; - } - - // Exact same message as in webchat - if (!twitchChannel->isLive()) - { - channel->addMessage(makeSystemMessage( - "You can only add stream markers during live streams. Try " - "again when the channel is live streaming.")); - return ""; - } - - auto arguments = words; - arguments.removeFirst(); - - getHelix()->createStreamMarker( - // Limit for description is 140 characters, webchat just crops description - // if it's >140 characters, so we're doing the same thing - twitchChannel->roomId(), arguments.join(" ").left(140), - [channel, arguments](const HelixStreamMarker &streamMarker) { - channel->addMessage(makeSystemMessage( - QString("Successfully added a stream marker at %1%2") - .arg(formatTime(streamMarker.positionSeconds)) - .arg(streamMarker.description.isEmpty() - ? "" - : QString(": \"%1\"") - .arg(streamMarker.description)))); - }, - [channel](auto error) { - QString errorMessage("Failed to create stream marker - "); - - switch (error) - { - case HelixStreamMarkerError::UserNotAuthorized: { - errorMessage += - "you don't have permission to perform that action."; - } - break; - - case HelixStreamMarkerError::UserNotAuthenticated: { - errorMessage += "you need to re-authenticate."; - } - break; - - // This would most likely happen if the service is down, or if the JSON payload returned has changed format - case HelixStreamMarkerError::Unknown: - default: { - errorMessage += "an unknown error occurred."; - } - break; - } - - channel->addMessage(makeSystemMessage(errorMessage)); - }); - - return ""; - }); - - this->registerCommand("/streamlink", [](const QStringList &words, - ChannelPtr channel) { - QString target(words.value(1)); - - if (target.isEmpty()) - { - if (channel->getType() == Channel::Type::Twitch && - !channel->isEmpty()) - { - target = channel->getName(); - } - else - { - channel->addMessage(makeSystemMessage( - "/streamlink [channel]. Open specified Twitch channel in " - "streamlink. If no channel argument is specified, open the " - "current Twitch channel instead.")); - return ""; - } - } - - stripChannelName(target); - openStreamlinkForChannel(target); - - return ""; - }); - - this->registerCommand("/popout", [](const QStringList &words, - ChannelPtr channel) { - QString target(words.value(1)); - - if (target.isEmpty()) - { - if (channel->getType() == Channel::Type::Twitch && - !channel->isEmpty()) - { - target = channel->getName(); - } - else - { - channel->addMessage(makeSystemMessage( - "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))); - - return ""; - }); - - this->registerCommand("/popup", [](const QStringList &words, - ChannelPtr sourceChannel) { - static const auto *usageMessage = - "Usage: /popup [channel]. Open specified Twitch channel in " - "a new window. If no channel argument is specified, open " - "the currently selected split instead."; - - QString target(words.value(1)); - stripChannelName(target); - - // Popup the current split - if (target.isEmpty()) - { - auto *currentPage = - dynamic_cast(getApp() - ->windows->getMainWindow() - .getNotebook() - .getSelectedPage()); - if (currentPage != nullptr) - { - auto *currentSplit = currentPage->getSelectedSplit(); - if (currentSplit != nullptr) - { - currentSplit->popup(); - - return ""; - } - } - - sourceChannel->addMessage(makeSystemMessage(usageMessage)); - return ""; - } - - // Open channel passed as argument in a popup - auto *app = getApp(); - auto targetChannel = app->twitch->getOrAddChannel(target); - app->windows->openInPopup(targetChannel); - - return ""; - }); - - this->registerCommand("/clearmessages", [](const auto & /*words*/, - ChannelPtr channel) { - auto *currentPage = dynamic_cast( - getApp()->windows->getMainWindow().getNotebook().getSelectedPage()); - - if (auto split = currentPage->getSelectedSplit()) - { - split->getChannelView().clearMessages(); - } - - return ""; - }); - - this->registerCommand("/settitle", [](const QStringList &words, - ChannelPtr channel) { - if (words.size() < 2) - { - channel->addMessage( - makeSystemMessage("Usage: /settitle ")); - return ""; - } - if (auto twitchChannel = dynamic_cast(channel.get())) - { - auto status = twitchChannel->accessStreamStatus(); - auto title = words.mid(1).join(" "); - getHelix()->updateChannel( - twitchChannel->roomId(), "", "", title, - [channel, title](NetworkResult) { - channel->addMessage(makeSystemMessage( - QString("Updated title to %1").arg(title))); - }, - [channel] { - channel->addMessage( - makeSystemMessage("Title update failed! Are you " - "missing the required scope?")); - }); - } - else - { - channel->addMessage(makeSystemMessage( - "Unable to set title of non-Twitch channel.")); - } - return ""; - }); - - this->registerCommand("/setgame", [](const QStringList &words, - const ChannelPtr channel) { - if (words.size() < 2) - { - channel->addMessage( - makeSystemMessage("Usage: /setgame ")); - return ""; - } - if (auto twitchChannel = dynamic_cast(channel.get())) - { - const auto gameName = words.mid(1).join(" "); - - getHelix()->searchGames( - gameName, - [channel, twitchChannel, - gameName](const std::vector &games) { - if (games.empty()) - { - channel->addMessage( - makeSystemMessage("Game not found.")); - return; - } - - auto matchedGame = games.at(0); - - if (games.size() > 1) - { - // NOTE: Improvements could be made with 'fuzzy string matching' code here - // attempt to find the best looking game by comparing exactly with lowercase values - for (const auto &game : games) - { - if (game.name.toLower() == gameName.toLower()) - { - matchedGame = game; - break; - } - } - } - - auto status = twitchChannel->accessStreamStatus(); - getHelix()->updateChannel( - twitchChannel->roomId(), matchedGame.id, "", "", - [channel, games, matchedGame](const NetworkResult &) { - channel->addMessage( - makeSystemMessage(QString("Updated game to %1") - .arg(matchedGame.name))); - }, - [channel] { - channel->addMessage(makeSystemMessage( - "Game update failed! Are you " - "missing the required scope?")); - }); - }, - [channel] { - channel->addMessage( - makeSystemMessage("Failed to look up game.")); - }); - } - else - { - channel->addMessage( - makeSystemMessage("Unable to set game of non-Twitch channel.")); - } - return ""; - }); - - this->registerCommand("/openurl", [](const QStringList &words, - const ChannelPtr channel) { - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage("Usage: /openurl ")); - return ""; - } - - QUrl url = QUrl::fromUserInput(words.mid(1).join(" ")); - if (!url.isValid()) - { - channel->addMessage(makeSystemMessage("Invalid URL specified.")); - return ""; - } - - bool res = false; - if (supportsIncognitoLinks() && getSettings()->openLinksIncognito) - { - res = openLinkIncognito(url.toString(QUrl::FullyEncoded)); - } - else - { - res = QDesktopServices::openUrl(url); - } - - if (!res) - { - channel->addMessage(makeSystemMessage("Could not open URL.")); - } - - return ""; - }); - - this->registerCommand( - "/raw", [](const QStringList &words, ChannelPtr channel) -> QString { - if (channel->isTwitchChannel()) - { - getApp()->twitch->sendRawMessage(words.mid(1).join(" ")); - } - else - { - // other code down the road handles this for IRC - return words.join(" "); - } - return ""; - }); - - this->registerCommand( - "/reply", [](const QStringList &words, ChannelPtr channel) { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /reply command only works in Twitch channels")); - return ""; - } - - if (words.size() < 3) - { - channel->addMessage( - makeSystemMessage("Usage: /reply ")); - return ""; - } - - QString username = words[1]; - stripChannelName(username); - - auto snapshot = twitchChannel->getMessageSnapshot(); - for (auto it = snapshot.rbegin(); it != snapshot.rend(); ++it) - { - const auto &msg = *it; - if (msg->loginName.compare(username, Qt::CaseInsensitive) == 0) - { - // found most recent message by user - if (msg->replyThread == nullptr) - { - // prepare thread if one does not exist - auto thread = std::make_shared(msg); - twitchChannel->addReplyThread(thread); - } - - QString reply = words.mid(2).join(" "); - twitchChannel->sendReply(reply, msg->id); - return ""; - } - } - - channel->addMessage( - makeSystemMessage("A message from that user wasn't found")); - - return ""; - }); - -#ifndef NDEBUG - this->registerCommand( - "/fakemsg", - [](const QStringList &words, ChannelPtr channel) -> QString { - if (!channel->isTwitchChannel()) - { - channel->addMessage(makeSystemMessage( - "The /fakemsg command only works in Twitch channels.")); - return ""; - } - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage( - "Usage: /fakemsg (raw irc text) - injects raw irc text as " - "if it was a message received from TMI")); - return ""; - } - auto ircText = words.mid(1).join(" "); - getApp()->twitch->addFakeMessage(ircText); - return ""; - }); -#endif - - this->registerCommand( - "/copy", [](const QStringList &words, ChannelPtr channel) -> QString { - if (words.size() < 2) - { - channel->addMessage( - makeSystemMessage("Usage: /copy - copies provided " - "text to clipboard.")); - return ""; - } - crossPlatformCopy(words.mid(1).join(" ")); - return ""; - }); - - this->registerCommand("/color", [](const QStringList &words, auto channel) { - if (!channel->isTwitchChannel()) - { - channel->addMessage(makeSystemMessage( - "The /color command only works in Twitch channels")); - return ""; - } - auto user = getApp()->accounts->twitch.getCurrent(); - - // Avoid Helix calls without Client ID and/or OAuth Token - if (user->isAnon()) - { - channel->addMessage(makeSystemMessage( - "You must be logged in to use the /color command")); - return ""; - } - - auto colorString = words.value(1); - - if (colorString.isEmpty()) - { - channel->addMessage(makeSystemMessage( - QString("Usage: /color - Color must be one of Twitch's " - "supported colors (%1) or a hex code (#000000) if you " - "have Turbo or Prime.") - .arg(VALID_HELIX_COLORS.join(", ")))); - return ""; - } - - cleanHelixColorName(colorString); - - getHelix()->updateUserChatColor( - user->getUserId(), colorString, - [colorString, channel] { - QString successMessage = - QString("Your color has been changed to %1.") - .arg(colorString); - channel->addMessage(makeSystemMessage(successMessage)); - }, - [colorString, channel](auto error, auto message) { - QString errorMessage = - QString("Failed to change color to %1 - ").arg(colorString); - - switch (error) - { - case HelixUpdateUserChatColorError::UserMissingScope: { - errorMessage += - "Missing required scope. Re-login with your " - "account and try again."; - } - break; - - case HelixUpdateUserChatColorError::InvalidColor: { - errorMessage += QString("Color must be one of Twitch's " - "supported colors (%1) or a " - "hex code (#000000) if you " - "have Turbo or Prime.") - .arg(VALID_HELIX_COLORS.join(", ")); - } - break; - - case HelixUpdateUserChatColorError::Forwarded: { - errorMessage += message + "."; - } - break; - - case HelixUpdateUserChatColorError::Unknown: - default: { - errorMessage += "An unknown error has occurred."; - } - break; - } - - channel->addMessage(makeSystemMessage(errorMessage)); - }); - - return ""; - }); - - auto deleteMessages = [](TwitchChannel *twitchChannel, - const QString &messageID) { - const auto *commandName = messageID.isEmpty() ? "/clear" : "/delete"; - - auto user = getApp()->accounts->twitch.getCurrent(); - - // Avoid Helix calls without Client ID and/or OAuth Token - if (user->isAnon()) - { - twitchChannel->addMessage(makeSystemMessage( - QString("You must be logged in to use the %1 command.") - .arg(commandName))); - return ""; - } - - getHelix()->deleteChatMessages( - twitchChannel->roomId(), user->getUserId(), messageID, - []() { - // Success handling, we do nothing: IRC/pubsub-edge will dispatch the correct - // events to update state for us. - }, - [twitchChannel, messageID](auto error, auto message) { - QString errorMessage = - QString("Failed to delete chat messages - "); - - switch (error) - { - case HelixDeleteChatMessagesError::UserMissingScope: { - errorMessage += - "Missing required scope. Re-login with your " - "account and try again."; - } - break; - - case HelixDeleteChatMessagesError::UserNotAuthorized: { - errorMessage += - "you don't have permission to perform that action."; - } - break; - - case HelixDeleteChatMessagesError::MessageUnavailable: { - // Override default message prefix to match with IRC message format - errorMessage = - QString( - "The message %1 does not exist, was deleted, " - "or is too old to be deleted.") - .arg(messageID); - } - break; - - case HelixDeleteChatMessagesError::UserNotAuthenticated: { - errorMessage += "you need to re-authenticate."; - } - break; - - case HelixDeleteChatMessagesError::Forwarded: { - errorMessage += message; - } - break; - - case HelixDeleteChatMessagesError::Unknown: - default: { - errorMessage += "An unknown error has occurred."; - } - break; - } - - twitchChannel->addMessage(makeSystemMessage(errorMessage)); - }); - - return ""; - }; - - this->registerCommand( - "/clear", [deleteMessages](const QStringList &words, auto channel) { - (void)words; // unused - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /clear command only works in Twitch channels")); - return ""; - } - return deleteMessages(twitchChannel, QString()); - }); - - this->registerCommand("/delete", [deleteMessages](const QStringList &words, - auto channel) { - // This is a wrapper over the Helix delete messages endpoint - // We use this to ensure the user gets better error messages for missing or malformed arguments - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /delete command only works in Twitch channels")); - return ""; - } - if (words.size() < 2) - { - channel->addMessage( - makeSystemMessage("Usage: /delete - Deletes the " - "specified message.")); - return ""; - } - - auto messageID = words.at(1); - auto uuid = QUuid(messageID); - if (uuid.isNull()) - { - // The message id must be a valid UUID - channel->addMessage(makeSystemMessage( - QString("Invalid msg-id: \"%1\"").arg(messageID))); - return ""; - } - - auto msg = channel->findMessage(messageID); - if (msg != nullptr) - { - if (msg->loginName == channel->getName() && - !channel->isBroadcaster()) - { - channel->addMessage(makeSystemMessage( - "You cannot delete the broadcaster's messages unless " - "you are the broadcaster.")); - return ""; - } - } - - return deleteMessages(twitchChannel, messageID); - }); - - this->registerCommand("/mod", [](const QStringList &words, auto channel) { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /mod command only works in Twitch channels")); - return ""; - } - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage( - "Usage: \"/mod \" - Grant moderator status to a " - "user. Use \"/mods\" to list the moderators of this channel.")); - return ""; - } - - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage( - makeSystemMessage("You must be logged in to mod someone!")); - return ""; - } - - auto target = words.at(1); - stripChannelName(target); - - getHelix()->getUserByName( - target, - [twitchChannel, channel](const HelixUser &targetUser) { - getHelix()->addChannelModerator( - twitchChannel->roomId(), targetUser.id, - [channel, targetUser] { - channel->addMessage(makeSystemMessage( - QString("You have added %1 as a moderator of this " - "channel.") - .arg(targetUser.displayName))); - }, - [channel, targetUser](auto error, auto message) { - QString errorMessage = - QString("Failed to add channel moderator - "); - - using Error = HelixAddChannelModeratorError; - - switch (error) - { - case Error::UserMissingScope: { - // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE - errorMessage += "Missing required scope. " - "Re-login with your " - "account and try again."; - } - break; - - case Error::UserNotAuthorized: { - // TODO(pajlada): Phrase MISSING_PERMISSION - errorMessage += "You don't have permission to " - "perform that action."; - } - break; - - case Error::Ratelimited: { - errorMessage += - "You are being ratelimited by Twitch. Try " - "again in a few seconds."; - } - break; - - case Error::TargetIsVIP: { - errorMessage += - QString("%1 is currently a VIP, \"/unvip\" " - "them and " - "retry this command.") - .arg(targetUser.displayName); - } - break; - - case Error::TargetAlreadyModded: { - // Equivalent irc error - errorMessage = - QString("%1 is already a moderator of this " - "channel.") - .arg(targetUser.displayName); - } - break; - - case Error::Forwarded: { - errorMessage += message; - } - break; - - case Error::Unknown: - default: { - errorMessage += - "An unknown error has occurred."; - } - break; - } - channel->addMessage(makeSystemMessage(errorMessage)); - }); - }, - [channel, target] { - // Equivalent error from IRC - channel->addMessage(makeSystemMessage( - QString("Invalid username: %1").arg(target))); - }); - - return ""; - }); - - this->registerCommand("/unmod", [](const QStringList &words, auto channel) { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /unmod command only works in Twitch channels")); - return ""; - } - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage( - "Usage: \"/unmod \" - Revoke moderator status from a " - "user. Use \"/mods\" to list the moderators of this channel.")); - return ""; - } - - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage( - makeSystemMessage("You must be logged in to unmod someone!")); - return ""; - } - - auto target = words.at(1); - stripChannelName(target); - - getHelix()->getUserByName( - target, - [twitchChannel, channel](const HelixUser &targetUser) { - getHelix()->removeChannelModerator( - twitchChannel->roomId(), targetUser.id, - [channel, targetUser] { - channel->addMessage(makeSystemMessage( - QString("You have removed %1 as a moderator of " - "this channel.") - .arg(targetUser.displayName))); - }, - [channel, targetUser](auto error, auto message) { - QString errorMessage = - QString("Failed to remove channel moderator - "); - - using Error = HelixRemoveChannelModeratorError; - - switch (error) - { - case Error::UserMissingScope: { - // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE - errorMessage += "Missing required scope. " - "Re-login with your " - "account and try again."; - } - break; - - case Error::UserNotAuthorized: { - // TODO(pajlada): Phrase MISSING_PERMISSION - errorMessage += "You don't have permission to " - "perform that action."; - } - break; - - case Error::Ratelimited: { - errorMessage += - "You are being ratelimited by Twitch. Try " - "again in a few seconds."; - } - break; - - case Error::TargetNotModded: { - // Equivalent irc error - errorMessage += - QString("%1 is not a moderator of this " - "channel.") - .arg(targetUser.displayName); - } - break; - - case Error::Forwarded: { - errorMessage += message; - } - break; - - case Error::Unknown: - default: { - errorMessage += - "An unknown error has occurred."; - } - break; - } - channel->addMessage(makeSystemMessage(errorMessage)); - }); - }, - [channel, target] { - // Equivalent error from IRC - channel->addMessage(makeSystemMessage( - QString("Invalid username: %1").arg(target))); - }); - - return ""; - }); - - this->registerCommand( - "/announce", [](const QStringList &words, auto channel) -> QString { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "This command can only be used in Twitch channels.")); - return ""; - } - - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage( - "Usage: /announce - Call attention to your " - "message with a highlight.")); - return ""; - } - - auto user = getApp()->accounts->twitch.getCurrent(); - if (user->isAnon()) - { - channel->addMessage(makeSystemMessage( - "You must be logged in to use the /announce command")); - return ""; - } - - getHelix()->sendChatAnnouncement( - twitchChannel->roomId(), user->getUserId(), - words.mid(1).join(" "), HelixAnnouncementColor::Primary, - []() { - // do nothing. - }, - [channel](auto error, auto message) { - using Error = HelixSendChatAnnouncementError; - QString errorMessage = - QString("Failed to send announcement - "); - - switch (error) - { - case Error::UserMissingScope: { - // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE - errorMessage += - "Missing required scope. Re-login with your " - "account and try again."; - } - break; - - case Error::Forwarded: { - errorMessage += message; - } - break; - - case Error::Unknown: - default: { - errorMessage += "An unknown error has occurred."; - } - break; - } - - channel->addMessage(makeSystemMessage(errorMessage)); - }); - return ""; - }); - - this->registerCommand("/vip", [](const QStringList &words, auto channel) { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /vip command only works in Twitch channels")); - return ""; - } - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage( - "Usage: \"/vip \" - Grant VIP status to a user. Use " - "\"/vips\" to list the VIPs of this channel.")); - return ""; - } - - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage( - makeSystemMessage("You must be logged in to VIP someone!")); - return ""; - } - - auto target = words.at(1); - stripChannelName(target); - - getHelix()->getUserByName( - target, - [twitchChannel, channel](const HelixUser &targetUser) { - getHelix()->addChannelVIP( - twitchChannel->roomId(), targetUser.id, - [channel, targetUser] { - channel->addMessage(makeSystemMessage( - QString( - "You have added %1 as a VIP of this channel.") - .arg(targetUser.displayName))); - }, - [channel, targetUser](auto error, auto message) { - QString errorMessage = QString("Failed to add VIP - "); - - using Error = HelixAddChannelVIPError; - - switch (error) - { - case Error::UserMissingScope: { - // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE - errorMessage += "Missing required scope. " - "Re-login with your " - "account and try again."; - } - break; - - case Error::UserNotAuthorized: { - // TODO(pajlada): Phrase MISSING_PERMISSION - errorMessage += "You don't have permission to " - "perform that action."; - } - break; - - case Error::Ratelimited: { - errorMessage += - "You are being ratelimited by Twitch. Try " - "again in a few seconds."; - } - break; - - case Error::Forwarded: { - // These are actually the IRC equivalents, so we can ditch the prefix - errorMessage = message; - } - break; - - case Error::Unknown: - default: { - errorMessage += - "An unknown error has occurred."; - } - break; - } - channel->addMessage(makeSystemMessage(errorMessage)); - }); - }, - [channel, target] { - // Equivalent error from IRC - channel->addMessage(makeSystemMessage( - QString("Invalid username: %1").arg(target))); - }); - - return ""; - }); - - this->registerCommand("/unvip", [](const QStringList &words, auto channel) { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /unvip command only works in Twitch channels")); - return ""; - } - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage( - "Usage: \"/unvip \" - Revoke VIP status from a user. " - "Use \"/vips\" to list the VIPs of this channel.")); - return ""; - } - - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage( - makeSystemMessage("You must be logged in to UnVIP someone!")); - return ""; - } - - auto target = words.at(1); - stripChannelName(target); - - getHelix()->getUserByName( - target, - [twitchChannel, channel](const HelixUser &targetUser) { - getHelix()->removeChannelVIP( - twitchChannel->roomId(), targetUser.id, - [channel, targetUser] { - channel->addMessage(makeSystemMessage( - QString( - "You have removed %1 as a VIP of this channel.") - .arg(targetUser.displayName))); - }, - [channel, targetUser](auto error, auto message) { - QString errorMessage = - QString("Failed to remove VIP - "); - - using Error = HelixRemoveChannelVIPError; - - switch (error) - { - case Error::UserMissingScope: { - // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE - errorMessage += "Missing required scope. " - "Re-login with your " - "account and try again."; - } - break; - - case Error::UserNotAuthorized: { - // TODO(pajlada): Phrase MISSING_PERMISSION - errorMessage += "You don't have permission to " - "perform that action."; - } - break; - - case Error::Ratelimited: { - errorMessage += - "You are being ratelimited by Twitch. Try " - "again in a few seconds."; - } - break; - - case Error::Forwarded: { - // These are actually the IRC equivalents, so we can ditch the prefix - errorMessage = message; - } - break; - - case Error::Unknown: - default: { - errorMessage += - "An unknown error has occurred."; - } - break; - } - channel->addMessage(makeSystemMessage(errorMessage)); - }); - }, - [channel, target] { - // Equivalent error from IRC - channel->addMessage(makeSystemMessage( - QString("Invalid username: %1").arg(target))); - }); - - return ""; - }); - - auto unbanLambda = [](auto words, auto channel) { - auto commandName = words.at(0).toLower(); - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - QString("The %1 command only works in Twitch channels") - .arg(commandName))); - return ""; - } - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage( - QString("Usage: \"%1 \" - Removes a ban on a user.") - .arg(commandName))); - return ""; - } - - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage( - makeSystemMessage("You must be logged in to unban someone!")); - return ""; - } - - auto target = words.at(1); - stripChannelName(target); - - getHelix()->getUserByName( - target, - [channel, currentUser, twitchChannel, - target](const auto &targetUser) { - getHelix()->unbanUser( - twitchChannel->roomId(), currentUser->getUserId(), - targetUser.id, - [] { - // No response for unbans, they're emitted over pubsub/IRC instead - }, - [channel, target, targetUser](auto error, auto message) { - using Error = HelixUnbanUserError; - - QString errorMessage = - QString("Failed to unban user - "); - - switch (error) - { - case Error::ConflictingOperation: { - errorMessage += - "There was a conflicting ban operation on " - "this user. Please try again."; - } - break; - - case Error::Forwarded: { - errorMessage += message; - } - break; - - case Error::Ratelimited: { - errorMessage += - "You are being ratelimited by Twitch. Try " - "again in a few seconds."; - } - break; - - case Error::TargetNotBanned: { - // Equivalent IRC error - errorMessage = - QString( - "%1 is not banned from this channel.") - .arg(targetUser.displayName); - } - break; - - case Error::UserMissingScope: { - // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE - errorMessage += "Missing required scope. " - "Re-login with your " - "account and try again."; - } - break; - - case Error::UserNotAuthorized: { - // TODO(pajlada): Phrase MISSING_PERMISSION - errorMessage += "You don't have permission to " - "perform that action."; - } - break; - - case Error::Unknown: { - errorMessage += - "An unknown error has occurred."; - } - break; - } - - channel->addMessage(makeSystemMessage(errorMessage)); - }); - }, - [channel, target] { - // Equivalent error from IRC - channel->addMessage(makeSystemMessage( - QString("Invalid username: %1").arg(target))); - }); - - return ""; - }; - - this->registerCommand( - "/unban", [unbanLambda](const QStringList &words, auto channel) { - return unbanLambda(words, channel); - }); - - this->registerCommand( - "/untimeout", [unbanLambda](const QStringList &words, auto channel) { - return unbanLambda(words, channel); - }); - - this->registerCommand( // /raid - "/raid", [](const QStringList &words, auto channel) -> QString { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /raid command only works in Twitch channels")); - return ""; - } - switch (getSettings()->helixTimegateRaid.getValue()) - { - case HelixTimegateOverride::Timegate: { - if (areIRCCommandsStillAvailable()) - { - return useIRCCommand(words); - } - - // fall through to Helix logic - } - break; - - case HelixTimegateOverride::AlwaysUseIRC: { - return useIRCCommand(words); - } - break; - - case HelixTimegateOverride::AlwaysUseHelix: { - // do nothing and fall through to Helix logic - } - break; - } - - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage( - "Usage: \"/raid \" - Raid a user. " - "Only the broadcaster can start a raid.")); - return ""; - } - - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage(makeSystemMessage( - "You must be logged in to start a raid!")); - return ""; - } - - auto target = words.at(1); - stripChannelName(target); - - getHelix()->getUserByName( - target, - [twitchChannel, channel](const HelixUser &targetUser) { - getHelix()->startRaid( - twitchChannel->roomId(), targetUser.id, - [channel, targetUser] { - channel->addMessage(makeSystemMessage( - QString("You started to raid %1.") - .arg(targetUser.displayName))); - }, - [channel, targetUser](auto error, auto message) { - QString errorMessage = - QString("Failed to start a raid - "); - - using Error = HelixStartRaidError; - - switch (error) - { - case Error::UserMissingScope: { - // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE - errorMessage += "Missing required scope. " - "Re-login with your " - "account and try again."; - } - break; - - case Error::UserNotAuthorized: { - errorMessage += - "You must be the broadcaster " - "to start a raid."; - } - break; - - case Error::CantRaidYourself: { - errorMessage += - "A channel cannot raid itself."; - } - break; - - case Error::Ratelimited: { - errorMessage += "You are being ratelimited " - "by Twitch. Try " - "again in a few seconds."; - } - break; - - case Error::Forwarded: { - errorMessage += message; - } - break; - - case Error::Unknown: - default: { - errorMessage += - "An unknown error has occurred."; - } - break; - } - channel->addMessage( - makeSystemMessage(errorMessage)); - }); - }, - [channel, target] { - // Equivalent error from IRC - channel->addMessage(makeSystemMessage( - QString("Invalid username: %1").arg(target))); - }); - - return ""; - }); // /raid - - this->registerCommand( // /unraid - "/unraid", [](const QStringList &words, auto channel) -> QString { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /unraid command only works in Twitch channels")); - return ""; - } - switch (getSettings()->helixTimegateRaid.getValue()) - { - case HelixTimegateOverride::Timegate: { - if (areIRCCommandsStillAvailable()) - { - return useIRCCommand(words); - } - - // fall through to Helix logic - } - break; - - case HelixTimegateOverride::AlwaysUseIRC: { - return useIRCCommand(words); - } - break; - - case HelixTimegateOverride::AlwaysUseHelix: { - // do nothing and fall through to Helix logic - } - break; - } - - if (words.size() != 1) - { - channel->addMessage(makeSystemMessage( - "Usage: \"/unraid\" - Cancel the current raid. " - "Only the broadcaster can cancel the raid.")); - return ""; - } - - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage(makeSystemMessage( - "You must be logged in to cancel the raid!")); - return ""; - } - - getHelix()->cancelRaid( - twitchChannel->roomId(), - [channel] { - channel->addMessage( - makeSystemMessage(QString("You cancelled the raid."))); - }, - [channel](auto error, auto message) { - QString errorMessage = - QString("Failed to cancel the raid - "); - - using Error = HelixCancelRaidError; - - switch (error) - { - case Error::UserMissingScope: { - // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE - errorMessage += "Missing required scope. " - "Re-login with your " - "account and try again."; - } - break; - - case Error::UserNotAuthorized: { - errorMessage += "You must be the broadcaster " - "to cancel the raid."; - } - break; - - case Error::NoRaidPending: { - errorMessage += "You don't have an active raid."; - } - break; - - case Error::Ratelimited: { - errorMessage += - "You are being ratelimited by Twitch. Try " - "again in a few seconds."; - } - break; - - case Error::Forwarded: { - errorMessage += message; - } - break; - - case Error::Unknown: - default: { - errorMessage += "An unknown error has occurred."; - } - break; - } - channel->addMessage(makeSystemMessage(errorMessage)); - }); - - return ""; - }); // unraid - - this->registerCommand("/emoteonly", &commands::emoteOnly); - this->registerCommand("/emoteonlyoff", &commands::emoteOnlyOff); - - this->registerCommand("/subscribers", &commands::subscribers); - this->registerCommand("/subscribersoff", &commands::subscribersOff); - - this->registerCommand("/slow", &commands::slow); - this->registerCommand("/slowoff", &commands::slowOff); - - this->registerCommand("/followers", &commands::followers); - this->registerCommand("/followersoff", &commands::followersOff); - - this->registerCommand("/uniquechat", &commands::uniqueChat); - this->registerCommand("/r9kbeta", &commands::uniqueChat); - this->registerCommand("/uniquechatoff", &commands::uniqueChatOff); - this->registerCommand("/r9kbetaoff", &commands::uniqueChatOff); - - this->registerCommand("/timeout", &commands::sendTimeout); - - this->registerCommand("/ban", &commands::sendBan); - this->registerCommand("/banid", &commands::sendBanById); - - for (const auto &cmd : TWITCH_WHISPER_COMMANDS) - { - this->registerCommand(cmd, [](const QStringList &words, auto channel) { - return runWhisperCommand(words, channel); - }); - } - - auto formatVIPListError = [](HelixListVIPsError error, - const QString &message) -> QString { - using Error = HelixListVIPsError; - - QString errorMessage = QString("Failed to list VIPs - "); - - switch (error) - { - case Error::Forwarded: { - errorMessage += message; - } - break; - - case Error::Ratelimited: { - errorMessage += "You are being ratelimited by Twitch. Try " - "again in a few seconds."; - } - break; - - case Error::UserMissingScope: { - // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE - errorMessage += "Missing required scope. " - "Re-login with your " - "account and try again."; - } - break; - - case Error::UserNotAuthorized: { - // TODO(pajlada): Phrase MISSING_PERMISSION - errorMessage += "You don't have permission to " - "perform that action."; - } - break; - - case Error::UserNotBroadcaster: { - errorMessage += - "Due to Twitch restrictions, " - "this command can only be used by the broadcaster. " - "To see the list of VIPs you must use the Twitch website."; - } - break; - - case Error::Unknown: { - errorMessage += "An unknown error has occurred."; - } - break; - } - return errorMessage; - }; - - auto formatStartCommercialError = [](HelixStartCommercialError error, - const QString &message) -> QString { - using Error = HelixStartCommercialError; - - QString errorMessage = "Failed to start commercial - "; - - switch (error) - { - case Error::UserMissingScope: { - errorMessage += "Missing required scope. Re-login with your " - "account and try again."; - } - break; - - case Error::TokenMustMatchBroadcaster: { - errorMessage += "Only the broadcaster of the channel can run " - "commercials."; - } - break; - - case Error::BroadcasterNotStreaming: { - errorMessage += "You must be streaming live to run " - "commercials."; - } - break; - - case Error::MissingLengthParameter: { - errorMessage += - "Command must include a desired commercial break " - "length that is greater than zero."; - } - break; - - case Error::Ratelimited: { - errorMessage += "You must wait until your cooldown period " - "expires before you can run another " - "commercial."; - } - break; - - case Error::Forwarded: { - errorMessage += message; - } - break; - - case Error::Unknown: - default: { - errorMessage += - QString("An unknown error has occurred (%1).").arg(message); - } - break; - } - - return errorMessage; - }; - - this->registerCommand( - "/vips", - [formatVIPListError](const QStringList &words, - auto channel) -> QString { - auto *twitchChannel = dynamic_cast(channel.get()); - if (twitchChannel == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /vips command only works in Twitch channels")); - return ""; - } - - switch (getSettings()->helixTimegateVIPs.getValue()) - { - case HelixTimegateOverride::Timegate: { - if (areIRCCommandsStillAvailable()) - { - return useIRCCommand(words); - } - - // fall through to Helix logic - } - break; - - case HelixTimegateOverride::AlwaysUseIRC: { - return useIRCCommand(words); - } - break; - - case HelixTimegateOverride::AlwaysUseHelix: { - // do nothing and fall through to Helix logic - } - break; - } - auto currentUser = getApp()->accounts->twitch.getCurrent(); - if (currentUser->isAnon()) - { - channel->addMessage(makeSystemMessage( - "Due to Twitch restrictions, " // - "this command can only be used by the broadcaster. " - "To see the list of VIPs you must use the " - "Twitch website.")); - return ""; - } - - getHelix()->getChannelVIPs( - twitchChannel->roomId(), - [channel, twitchChannel](const std::vector &vipList) { - if (vipList.empty()) - { - channel->addMessage(makeSystemMessage( - "This channel does not have any VIPs.")); - return; - } - - auto messagePrefix = - QString("The VIPs of this channel are"); - - // TODO: sort results? - MessageBuilder builder; - TwitchMessageBuilder::listOfUsersSystemMessage( - messagePrefix, vipList, twitchChannel, &builder); - - channel->addMessage(builder.release()); - }, - [channel, formatVIPListError](auto error, auto message) { - auto errorMessage = formatVIPListError(error, message); - channel->addMessage(makeSystemMessage(errorMessage)); - }); - - return ""; - }); - - this->registerCommand( - "/commercial", - [formatStartCommercialError](const QStringList &words, - auto channel) -> QString { - auto *tc = dynamic_cast(channel.get()); - if (tc == nullptr) - { - channel->addMessage(makeSystemMessage( - "The /commercial command only works in Twitch channels")); - return ""; - } - - const auto *usageStr = "Usage: \"/commercial \" - Starts a " - "commercial with the " - "specified duration for the current " - "channel. Valid length options " - "are 30, 60, 90, 120, 150, and 180 seconds."; - - switch (getSettings()->helixTimegateCommercial.getValue()) - { - case HelixTimegateOverride::Timegate: { - if (areIRCCommandsStillAvailable()) - { - return useIRCCommand(words); - } - - // fall through to Helix logic - } - break; - - case HelixTimegateOverride::AlwaysUseIRC: { - return useIRCCommand(words); - } - break; - - case HelixTimegateOverride::AlwaysUseHelix: { - // do nothing and fall through to Helix logic - } - break; - } - - if (words.size() < 2) - { - channel->addMessage(makeSystemMessage(usageStr)); - return ""; - } - - auto user = getApp()->accounts->twitch.getCurrent(); - - // Avoid Helix calls without Client ID and/or OAuth Token - if (user->isAnon()) - { - channel->addMessage(makeSystemMessage( - "You must be logged in to use the /commercial command")); - return ""; - } - - auto broadcasterID = tc->roomId(); - auto length = words.at(1).toInt(); - - getHelix()->startCommercial( - broadcasterID, length, - [channel](auto response) { - channel->addMessage(makeSystemMessage( - QString("Starting %1 second long commercial break. " - "Keep in mind you are still " - "live and not all viewers will receive a " - "commercial. " - "You may run another commercial in %2 seconds.") - .arg(response.length) - .arg(response.retryAfter))); - }, - [channel, formatStartCommercialError](auto error, - auto message) { - auto errorMessage = - formatStartCommercialError(error, message); - channel->addMessage(makeSystemMessage(errorMessage)); - }); - - return ""; - }); - - this->registerCommand("/unstable-set-user-color", [](const auto &ctx) { - if (ctx.twitchChannel == nullptr) - { - ctx.channel->addMessage( - makeSystemMessage("The /unstable-set-user-color command only " - "works in Twitch channels")); - return ""; - } - if (ctx.words.size() < 2) - { - ctx.channel->addMessage( - makeSystemMessage(QString("Usage: %1 [color]") - .arg(ctx.words.at(0)))); - return ""; - } - - auto userID = ctx.words.at(1); - - auto color = ctx.words.value(2); - - getIApp()->getUserData()->setUserColor(userID, color); - - return ""; - }); - - this->registerCommand( - "/debug-force-image-gc", - [](const QStringList & /*words*/, auto /*channel*/) -> QString { - runInGuiThread([] { - using namespace chatterino::detail; - auto &iep = ImageExpirationPool::instance(); - iep.freeOld(); - }); - return ""; - }); - - this->registerCommand( - "/debug-force-image-unload", - [](const QStringList & /*words*/, auto /*channel*/) -> QString { - runInGuiThread([] { - using namespace chatterino::detail; - auto &iep = ImageExpirationPool::instance(); - iep.freeAll(); - }); - return ""; - }); + this->registerCommand("/debug-force-image-unload", + &commands::forceImageUnload); this->registerCommand("/shield", &commands::shieldModeOn); this->registerCommand("/shieldoff", &commands::shieldModeOff); diff --git a/src/controllers/commands/builtin/Misc.cpp b/src/controllers/commands/builtin/Misc.cpp new file mode 100644 index 00000000000..7100e7776f5 --- /dev/null +++ b/src/controllers/commands/builtin/Misc.cpp @@ -0,0 +1,629 @@ +#include "controllers/commands/builtin/Misc.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "controllers/userdata/UserDataController.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchIrcServer.hpp" +#include "singletons/Settings.hpp" +#include "singletons/WindowManager.hpp" +#include "util/Clipboard.hpp" +#include "util/FormatTime.hpp" +#include "util/IncognitoBrowser.hpp" +#include "util/StreamLink.hpp" +#include "util/Twitch.hpp" +#include "widgets/dialogs/UserInfoPopup.hpp" +#include "widgets/helper/ChannelView.hpp" +#include "widgets/Notebook.hpp" +#include "widgets/splits/Split.hpp" +#include "widgets/splits/SplitContainer.hpp" +#include "widgets/Window.hpp" + +#include +#include +#include + +namespace chatterino::commands { + +QString follow(const CommandContext &ctx) +{ + if (ctx.twitchChannel == nullptr) + { + return ""; + } + ctx.channel->addMessage(makeSystemMessage( + "Twitch has removed the ability to follow users through " + "third-party applications. For more information, see " + "https://github.com/Chatterino/chatterino2/issues/3076")); + return ""; +} + +QString unfollow(const CommandContext &ctx) +{ + if (ctx.twitchChannel == nullptr) + { + return ""; + } + ctx.channel->addMessage(makeSystemMessage( + "Twitch has removed the ability to unfollow users through " + "third-party applications. For more information, see " + "https://github.com/Chatterino/chatterino2/issues/3076")); + return ""; +} + +QString uptime(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /uptime command only works in Twitch Channels")); + return ""; + } + + const auto &streamStatus = ctx.twitchChannel->accessStreamStatus(); + + QString messageText = + streamStatus->live ? streamStatus->uptime : "Channel is not live."; + + ctx.channel->addMessage(makeSystemMessage(messageText)); + + return ""; +} + +QString user(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage( + makeSystemMessage("Usage: /user [channel]")); + return ""; + } + QString userName = ctx.words[1]; + stripUserName(userName); + + QString channelName = ctx.channel->getName(); + + if (ctx.words.size() > 2) + { + channelName = ctx.words[2]; + stripChannelName(channelName); + } + openTwitchUsercard(channelName, userName); + + return ""; +} + +QString requests(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + QString target(ctx.words.value(1)); + + if (target.isEmpty()) + { + if (ctx.channel->getType() == Channel::Type::Twitch && + !ctx.channel->isEmpty()) + { + target = ctx.channel->getName(); + } + else + { + ctx.channel->addMessage(makeSystemMessage( + "Usage: /requests [channel]. You can also use the command " + "without arguments in any Twitch channel to open its " + "channel points requests queue. Only the broadcaster and " + "moderators have permission to view the queue.")); + return ""; + } + } + + stripChannelName(target); + QDesktopServices::openUrl(QUrl( + QString("https://www.twitch.tv/popout/%1/reward-queue").arg(target))); + + return ""; +} + +QString lowtrust(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + QString target(ctx.words.value(1)); + + if (target.isEmpty()) + { + if (ctx.channel->getType() == Channel::Type::Twitch && + !ctx.channel->isEmpty()) + { + target = ctx.channel->getName(); + } + else + { + ctx.channel->addMessage(makeSystemMessage( + "Usage: /lowtrust [channel]. You can also use the command " + "without arguments in any Twitch channel to open its " + "suspicious user activity feed. Only the broadcaster and " + "moderators have permission to view this feed.")); + return ""; + } + } + + stripChannelName(target); + QDesktopServices::openUrl(QUrl( + QString("https://www.twitch.tv/popout/moderator/%1/low-trust-users") + .arg(target))); + + return ""; +} + +QString clip(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (const auto type = ctx.channel->getType(); + type != Channel::Type::Twitch && type != Channel::Type::TwitchWatching) + { + ctx.channel->addMessage(makeSystemMessage( + "The /clip command only works in Twitch Channels")); + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /clip command only works in Twitch Channels")); + return ""; + } + + ctx.twitchChannel->createClip(); + + return ""; +} + +QString marker(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /marker command only works in Twitch channels")); + return ""; + } + + // Avoid Helix calls without Client ID and/or OAuth Token + if (getApp()->accounts->twitch.getCurrent()->isAnon()) + { + ctx.channel->addMessage(makeSystemMessage( + "You need to be logged in to create stream markers!")); + return ""; + } + + // Exact same message as in webchat + if (!ctx.twitchChannel->isLive()) + { + ctx.channel->addMessage(makeSystemMessage( + "You can only add stream markers during live streams. Try " + "again when the channel is live streaming.")); + return ""; + } + + auto arguments = ctx.words; + arguments.removeFirst(); + + getHelix()->createStreamMarker( + // Limit for description is 140 characters, webchat just crops description + // if it's >140 characters, so we're doing the same thing + ctx.twitchChannel->roomId(), arguments.join(" ").left(140), + [channel{ctx.channel}, + arguments](const HelixStreamMarker &streamMarker) { + channel->addMessage(makeSystemMessage( + QString("Successfully added a stream marker at %1%2") + .arg(formatTime(streamMarker.positionSeconds)) + .arg(streamMarker.description.isEmpty() + ? "" + : QString(": \"%1\"") + .arg(streamMarker.description)))); + }, + [channel{ctx.channel}](auto error) { + QString errorMessage("Failed to create stream marker - "); + + switch (error) + { + case HelixStreamMarkerError::UserNotAuthorized: { + errorMessage += + "you don't have permission to perform that action."; + } + break; + + case HelixStreamMarkerError::UserNotAuthenticated: { + errorMessage += "you need to re-authenticate."; + } + break; + + // This would most likely happen if the service is down, or if the JSON payload returned has changed format + case HelixStreamMarkerError::Unknown: + default: { + errorMessage += "an unknown error occurred."; + } + break; + } + + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; +} + +QString streamlink(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + QString target(ctx.words.value(1)); + + if (target.isEmpty()) + { + if (ctx.channel->getType() == Channel::Type::Twitch && + !ctx.channel->isEmpty()) + { + target = ctx.channel->getName(); + } + else + { + ctx.channel->addMessage(makeSystemMessage( + "/streamlink [channel]. Open specified Twitch channel in " + "streamlink. If no channel argument is specified, open the " + "current Twitch channel instead.")); + return ""; + } + } + + stripChannelName(target); + openStreamlinkForChannel(target); + + return ""; +} + +QString popout(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + QString target(ctx.words.value(1)); + + if (target.isEmpty()) + { + if (ctx.channel->getType() == Channel::Type::Twitch && + !ctx.channel->isEmpty()) + { + target = ctx.channel->getName(); + } + else + { + ctx.channel->addMessage(makeSystemMessage( + "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))); + + return ""; +} + +QString popup(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + static const auto *usageMessage = + "Usage: /popup [channel]. Open specified Twitch channel in " + "a new window. If no channel argument is specified, open " + "the currently selected split instead."; + + QString target(ctx.words.value(1)); + stripChannelName(target); + + // Popup the current split + if (target.isEmpty()) + { + auto *currentPage = dynamic_cast( + getApp()->windows->getMainWindow().getNotebook().getSelectedPage()); + if (currentPage != nullptr) + { + auto *currentSplit = currentPage->getSelectedSplit(); + if (currentSplit != nullptr) + { + currentSplit->popup(); + + return ""; + } + } + + ctx.channel->addMessage(makeSystemMessage(usageMessage)); + return ""; + } + + // Open channel passed as argument in a popup + auto *app = getApp(); + auto targetChannel = app->twitch->getOrAddChannel(target); + app->windows->openInPopup(targetChannel); + + return ""; +} + +QString clearmessages(const CommandContext &ctx) +{ + (void)ctx; + + auto *currentPage = dynamic_cast( + getApp()->windows->getMainWindow().getNotebook().getSelectedPage()); + + if (auto *split = currentPage->getSelectedSplit()) + { + split->getChannelView().clearMessages(); + } + + return ""; +} + +QString openURL(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage("Usage: /openurl ")); + return ""; + } + + QUrl url = QUrl::fromUserInput(ctx.words.mid(1).join(" ")); + if (!url.isValid()) + { + ctx.channel->addMessage(makeSystemMessage("Invalid URL specified.")); + return ""; + } + + bool res = false; + if (supportsIncognitoLinks() && getSettings()->openLinksIncognito) + { + res = openLinkIncognito(url.toString(QUrl::FullyEncoded)); + } + else + { + res = QDesktopServices::openUrl(url); + } + + if (!res) + { + ctx.channel->addMessage(makeSystemMessage("Could not open URL.")); + } + + return ""; +} + +QString sendRawMessage(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.channel->isTwitchChannel()) + { + getApp()->twitch->sendRawMessage(ctx.words.mid(1).join(" ")); + } + else + { + // other code down the road handles this for IRC + return ctx.words.join(" "); + } + return ""; +} + +QString injectFakeMessage(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (!ctx.channel->isTwitchChannel()) + { + ctx.channel->addMessage(makeSystemMessage( + "The /fakemsg command only works in Twitch channels.")); + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage( + "Usage: /fakemsg (raw irc text) - injects raw irc text as " + "if it was a message received from TMI")); + return ""; + } + + auto ircText = ctx.words.mid(1).join(" "); + getApp()->twitch->addFakeMessage(ircText); + + return ""; +} + +QString copyToClipboard(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage( + makeSystemMessage("Usage: /copy - copies provided " + "text to clipboard.")); + return ""; + } + + crossPlatformCopy(ctx.words.mid(1).join(" ")); + return ""; +} + +QString unstableSetUserClientSideColor(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage( + makeSystemMessage("The /unstable-set-user-color command only " + "works in Twitch channels")); + return ""; + } + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage( + QString("Usage: %1 [color]").arg(ctx.words.at(0)))); + return ""; + } + + auto userID = ctx.words.at(1); + + auto color = ctx.words.value(2); + + getIApp()->getUserData()->setUserColor(userID, color); + + return ""; +} + +QString openUsercard(const CommandContext &ctx) +{ + auto channel = ctx.channel; + + if (channel == nullptr) + { + return ""; + } + + if (ctx.words.size() < 2) + { + channel->addMessage( + makeSystemMessage("Usage: /usercard [channel] or " + "/usercard id: [channel]")); + return ""; + } + + QString userName = ctx.words[1]; + stripUserName(userName); + + if (ctx.words.size() > 2) + { + QString channelName = ctx.words[2]; + stripChannelName(channelName); + + ChannelPtr channelTemp = + getApp()->twitch->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; + } + + // try to link to current split if possible + Split *currentSplit = nullptr; + auto *currentPage = dynamic_cast( + getApp()->windows->getMainWindow().getNotebook().getSelectedPage()); + if (currentPage != nullptr) + { + currentSplit = currentPage->getSelectedSplit(); + } + + auto differentChannel = + currentSplit != nullptr && currentSplit->getChannel() != channel; + if (differentChannel || currentSplit == nullptr) + { + // not possible to use current split, try searching for one + const auto ¬ebook = getApp()->windows->getMainWindow().getNotebook(); + auto count = notebook.getPageCount(); + for (int i = 0; i < count; i++) + { + auto *page = notebook.getPageAt(i); + auto *container = dynamic_cast(page); + assert(container != nullptr); + for (auto *split : container->getSplits()) + { + if (split->getChannel() == channel) + { + currentSplit = split; + break; + } + } + } + + // This would have crashed either way. + assert(currentSplit != nullptr && + "something went HORRIBLY wrong with the /usercard " + "command. It couldn't find a split for a channel which " + "should be open."); + } + + auto *userPopup = new UserInfoPopup( + getSettings()->autoCloseUserPopup, + static_cast(&(getApp()->windows->getMainWindow())), + currentSplit); + userPopup->setData(userName, channel); + userPopup->moveTo(QCursor::pos(), widgets::BoundsChecking::CursorPosition); + userPopup->show(); + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/Misc.hpp b/src/controllers/commands/builtin/Misc.hpp new file mode 100644 index 00000000000..7a8be28c798 --- /dev/null +++ b/src/controllers/commands/builtin/Misc.hpp @@ -0,0 +1,32 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +QString follow(const CommandContext &ctx); +QString unfollow(const CommandContext &ctx); +QString uptime(const CommandContext &ctx); +QString user(const CommandContext &ctx); +QString requests(const CommandContext &ctx); +QString lowtrust(const CommandContext &ctx); +QString clip(const CommandContext &ctx); +QString marker(const CommandContext &ctx); +QString streamlink(const CommandContext &ctx); +QString popout(const CommandContext &ctx); +QString popup(const CommandContext &ctx); +QString clearmessages(const CommandContext &ctx); +QString openURL(const CommandContext &ctx); +QString sendRawMessage(const CommandContext &ctx); +QString injectFakeMessage(const CommandContext &ctx); +QString copyToClipboard(const CommandContext &ctx); +QString unstableSetUserClientSideColor(const CommandContext &ctx); +QString openUsercard(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/chatterino/Debugging.cpp b/src/controllers/commands/builtin/chatterino/Debugging.cpp index 7ae1ce9476c..c72f0cde04c 100644 --- a/src/controllers/commands/builtin/chatterino/Debugging.cpp +++ b/src/controllers/commands/builtin/chatterino/Debugging.cpp @@ -1,11 +1,16 @@ #include "controllers/commands/builtin/chatterino/Debugging.hpp" #include "common/Channel.hpp" +#include "common/Env.hpp" #include "common/Literals.hpp" #include "controllers/commands/CommandContext.hpp" +#include "messages/Image.hpp" #include "messages/MessageBuilder.hpp" +#include "messages/MessageElement.hpp" #include "singletons/Theme.hpp" +#include "util/PostToThread.hpp" +#include #include #include @@ -63,4 +68,70 @@ QString toggleThemeReload(const CommandContext &ctx) return {}; } +QString listEnvironmentVariables(const CommandContext &ctx) +{ + const auto &channel = ctx.channel; + if (channel == nullptr) + { + return ""; + } + + auto env = Env::get(); + + QStringList debugMessages{ + "recentMessagesApiUrl: " + env.recentMessagesApiUrl, + "linkResolverUrl: " + env.linkResolverUrl, + "twitchServerHost: " + env.twitchServerHost, + "twitchServerPort: " + QString::number(env.twitchServerPort), + "twitchServerSecure: " + QString::number(env.twitchServerSecure), + }; + + for (QString &str : debugMessages) + { + MessageBuilder builder; + builder.emplace(QTime::currentTime()); + builder.emplace(str, MessageElementFlag::Text, + MessageColor::System); + channel->addMessage(builder.release()); + } + return ""; +} + +QString listArgs(const CommandContext &ctx) +{ + const auto &channel = ctx.channel; + if (channel == nullptr) + { + return ""; + } + + QString msg = QApplication::instance()->arguments().join(' '); + + channel->addMessage(makeSystemMessage(msg)); + + return ""; +} + +QString forceImageGarbageCollection(const CommandContext &ctx) +{ + (void)ctx; + + runInGuiThread([] { + auto &iep = ImageExpirationPool::instance(); + iep.freeOld(); + }); + return ""; +} + +QString forceImageUnload(const CommandContext &ctx) +{ + (void)ctx; + + runInGuiThread([] { + auto &iep = ImageExpirationPool::instance(); + iep.freeAll(); + }); + return ""; +} + } // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/chatterino/Debugging.hpp b/src/controllers/commands/builtin/chatterino/Debugging.hpp index 8b531455f5e..8d185737009 100644 --- a/src/controllers/commands/builtin/chatterino/Debugging.hpp +++ b/src/controllers/commands/builtin/chatterino/Debugging.hpp @@ -14,4 +14,12 @@ QString setLoggingRules(const CommandContext &ctx); QString toggleThemeReload(const CommandContext &ctx); +QString listEnvironmentVariables(const CommandContext &ctx); + +QString listArgs(const CommandContext &ctx); + +QString forceImageGarbageCollection(const CommandContext &ctx); + +QString forceImageUnload(const CommandContext &ctx); + } // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/AddModerator.cpp b/src/controllers/commands/builtin/twitch/AddModerator.cpp new file mode 100644 index 00000000000..5f244c0ac20 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/AddModerator.cpp @@ -0,0 +1,131 @@ +#include "controllers/commands/builtin/twitch/AddModerator.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" +#include "util/Twitch.hpp" + +namespace chatterino::commands { + +QString addModerator(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /mod command only works in Twitch channels")); + return ""; + } + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage( + "Usage: \"/mod \" - Grant moderator status to a " + "user. Use \"/mods\" to list the moderators of this channel.")); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to mod someone!")); + return ""; + } + + auto target = ctx.words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [twitchChannel{ctx.twitchChannel}, + channel{ctx.channel}](const HelixUser &targetUser) { + getHelix()->addChannelModerator( + twitchChannel->roomId(), targetUser.id, + [channel, targetUser] { + channel->addMessage(makeSystemMessage( + QString("You have added %1 as a moderator of this " + "channel.") + .arg(targetUser.displayName))); + }, + [channel, targetUser](auto error, auto message) { + QString errorMessage = + QString("Failed to add channel moderator - "); + + using Error = HelixAddChannelModeratorError; + + switch (error) + { + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::Ratelimited: { + errorMessage += + "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::TargetIsVIP: { + errorMessage += + QString("%1 is currently a VIP, \"/unvip\" " + "them and " + "retry this command.") + .arg(targetUser.displayName); + } + break; + + case Error::TargetAlreadyModded: { + // Equivalent irc error + errorMessage = + QString("%1 is already a moderator of this " + "channel.") + .arg(targetUser.displayName); + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Unknown: + default: { + errorMessage += "An unknown error has occurred."; + } + break; + } + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel{ctx.channel}, target] { + // Equivalent error from IRC + channel->addMessage( + makeSystemMessage(QString("Invalid username: %1").arg(target))); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/AddModerator.hpp b/src/controllers/commands/builtin/twitch/AddModerator.hpp new file mode 100644 index 00000000000..722ad724bb7 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/AddModerator.hpp @@ -0,0 +1,16 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /mod +QString addModerator(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/AddVIP.cpp b/src/controllers/commands/builtin/twitch/AddVIP.cpp new file mode 100644 index 00000000000..11ecaed49ac --- /dev/null +++ b/src/controllers/commands/builtin/twitch/AddVIP.cpp @@ -0,0 +1,112 @@ +#include "controllers/commands/builtin/twitch/AddVIP.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" +#include "util/Twitch.hpp" + +namespace chatterino::commands { + +QString addVIP(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /vip command only works in Twitch channels")); + return ""; + } + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage( + "Usage: \"/vip \" - Grant VIP status to a user. Use " + "\"/vips\" to list the VIPs of this channel.")); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to VIP someone!")); + return ""; + } + + auto target = ctx.words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [twitchChannel{ctx.twitchChannel}, + channel{ctx.channel}](const HelixUser &targetUser) { + getHelix()->addChannelVIP( + twitchChannel->roomId(), targetUser.id, + [channel, targetUser] { + channel->addMessage(makeSystemMessage( + QString("You have added %1 as a VIP of this channel.") + .arg(targetUser.displayName))); + }, + [channel, targetUser](auto error, auto message) { + QString errorMessage = QString("Failed to add VIP - "); + + using Error = HelixAddChannelVIPError; + + switch (error) + { + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::Ratelimited: { + errorMessage += + "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::Forwarded: { + // These are actually the IRC equivalents, so we can ditch the prefix + errorMessage = message; + } + break; + + case Error::Unknown: + default: { + errorMessage += "An unknown error has occurred."; + } + break; + } + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel{ctx.channel}, target] { + // Equivalent error from IRC + channel->addMessage( + makeSystemMessage(QString("Invalid username: %1").arg(target))); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/AddVIP.hpp b/src/controllers/commands/builtin/twitch/AddVIP.hpp new file mode 100644 index 00000000000..3d956cc42f4 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/AddVIP.hpp @@ -0,0 +1,16 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /vip +QString addVIP(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Announce.cpp b/src/controllers/commands/builtin/twitch/Announce.cpp new file mode 100644 index 00000000000..869f25addb9 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Announce.cpp @@ -0,0 +1,81 @@ +#include "controllers/commands/builtin/twitch/Announce.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" + +namespace chatterino::commands { + +QString sendAnnouncement(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "This command can only be used in Twitch channels.")); + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage( + "Usage: /announce - Call attention to your " + "message with a highlight.")); + return ""; + } + + auto user = getApp()->accounts->twitch.getCurrent(); + if (user->isAnon()) + { + ctx.channel->addMessage(makeSystemMessage( + "You must be logged in to use the /announce command")); + return ""; + } + + getHelix()->sendChatAnnouncement( + ctx.twitchChannel->roomId(), user->getUserId(), + ctx.words.mid(1).join(" "), HelixAnnouncementColor::Primary, + []() { + // do nothing. + }, + [channel{ctx.channel}](auto error, auto message) { + using Error = HelixSendChatAnnouncementError; + QString errorMessage = QString("Failed to send announcement - "); + + switch (error) + { + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += + "Missing required scope. Re-login with your " + "account and try again."; + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Unknown: + default: { + errorMessage += "An unknown error has occurred."; + } + break; + } + + channel->addMessage(makeSystemMessage(errorMessage)); + }); + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Announce.hpp b/src/controllers/commands/builtin/twitch/Announce.hpp new file mode 100644 index 00000000000..3904d1a203c --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Announce.hpp @@ -0,0 +1,16 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /announce +QString sendAnnouncement(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Block.cpp b/src/controllers/commands/builtin/twitch/Block.cpp new file mode 100644 index 00000000000..cb35c23b603 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Block.cpp @@ -0,0 +1,166 @@ +#include "controllers/commands/builtin/twitch/Block.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "util/Twitch.hpp" + +namespace { + +using namespace chatterino; + +} // namespace + +namespace chatterino::commands { + +QString blockUser(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /block command only works in Twitch channels")); + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage("Usage: /block ")); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + + if (currentUser->isAnon()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to block someone!")); + return ""; + } + + auto target = ctx.words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [currentUser, channel{ctx.channel}, + target](const HelixUser &targetUser) { + getApp()->accounts->twitch.getCurrent()->blockUser( + targetUser.id, nullptr, + [channel, target, targetUser] { + channel->addMessage(makeSystemMessage( + QString("You successfully blocked user %1") + .arg(target))); + }, + [channel, target] { + channel->addMessage(makeSystemMessage( + QString("User %1 couldn't be blocked, an unknown " + "error occurred!") + .arg(target))); + }); + }, + [channel{ctx.channel}, target] { + channel->addMessage( + makeSystemMessage(QString("User %1 couldn't be blocked, no " + "user with that name found!") + .arg(target))); + }); + + return ""; +} + +QString ignoreUser(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + ctx.channel->addMessage(makeSystemMessage( + "Ignore command has been renamed to /block, please use it from " + "now on as /ignore is going to be removed soon.")); + + return blockUser(ctx); +} + +QString unblockUser(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /unblock command only works in Twitch channels")); + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage("Usage: /unblock ")); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + + if (currentUser->isAnon()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to unblock someone!")); + return ""; + } + + auto target = ctx.words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [currentUser, channel{ctx.channel}, target](const auto &targetUser) { + getApp()->accounts->twitch.getCurrent()->unblockUser( + targetUser.id, nullptr, + [channel, target, targetUser] { + channel->addMessage(makeSystemMessage( + QString("You successfully unblocked user %1") + .arg(target))); + }, + [channel, target] { + channel->addMessage(makeSystemMessage( + QString("User %1 couldn't be unblocked, an unknown " + "error occurred!") + .arg(target))); + }); + }, + [channel{ctx.channel}, target] { + channel->addMessage( + makeSystemMessage(QString("User %1 couldn't be unblocked, " + "no user with that name found!") + .arg(target))); + }); + + return ""; +} + +QString unignoreUser(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + ctx.channel->addMessage(makeSystemMessage( + "Unignore command has been renamed to /unblock, please use it " + "from now on as /unignore is going to be removed soon.")); + return unblockUser(ctx); +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Block.hpp b/src/controllers/commands/builtin/twitch/Block.hpp new file mode 100644 index 00000000000..75ea3d0d42a --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Block.hpp @@ -0,0 +1,25 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /block +QString blockUser(const CommandContext &ctx); + +/// /ignore +QString ignoreUser(const CommandContext &ctx); + +/// /unblock +QString unblockUser(const CommandContext &ctx); + +/// /unignore +QString unignoreUser(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Chatters.cpp b/src/controllers/commands/builtin/twitch/Chatters.cpp new file mode 100644 index 00000000000..23ee43fdca4 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Chatters.cpp @@ -0,0 +1,143 @@ +#include "controllers/commands/builtin/twitch/Chatters.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "common/Env.hpp" +#include "common/Literals.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "messages/MessageElement.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" +#include "singletons/Theme.hpp" + +#include +#include +#include + +namespace { + +using namespace chatterino; + +QString formatChattersError(HelixGetChattersError error, const QString &message) +{ + using Error = HelixGetChattersError; + + QString errorMessage = QString("Failed to get chatter count - "); + + switch (error) + { + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::UserMissingScope: { + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + errorMessage += "You must have moderator permissions to " + "use this command."; + } + break; + + case Error::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + return errorMessage; +} + +} // namespace + +namespace chatterino::commands { + +QString chatters(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /chatters command only works in Twitch Channels")); + return ""; + } + + // Refresh chatter list via helix api for mods + getHelix()->getChatters( + ctx.twitchChannel->roomId(), + getApp()->accounts->twitch.getCurrent()->getUserId(), 1, + [channel{ctx.channel}](auto result) { + channel->addMessage( + makeSystemMessage(QString("Chatter count: %1") + .arg(localizeNumbers(result.total)))); + }, + [channel{ctx.channel}](auto error, auto message) { + auto errorMessage = formatChattersError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; +} + +QString testChatters(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /test-chatters command only works in Twitch Channels")); + return ""; + } + + getHelix()->getChatters( + ctx.twitchChannel->roomId(), + getApp()->accounts->twitch.getCurrent()->getUserId(), 5000, + [channel{ctx.channel}, twitchChannel{ctx.twitchChannel}](auto result) { + QStringList entries; + for (const auto &username : result.chatters) + { + entries << username; + } + + QString prefix = "Chatters "; + + if (result.total > 5000) + { + prefix += QString("(5000/%1):").arg(result.total); + } + else + { + prefix += QString("(%1):").arg(result.total); + } + + MessageBuilder builder; + TwitchMessageBuilder::listOfUsersSystemMessage( + prefix, entries, twitchChannel, &builder); + + channel->addMessage(builder.release()); + }, + [channel{ctx.channel}](auto error, auto message) { + auto errorMessage = formatChattersError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Chatters.hpp b/src/controllers/commands/builtin/twitch/Chatters.hpp new file mode 100644 index 00000000000..25b34bab9cb --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Chatters.hpp @@ -0,0 +1,17 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +QString chatters(const CommandContext &ctx); + +QString testChatters(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/DeleteMessages.cpp b/src/controllers/commands/builtin/twitch/DeleteMessages.cpp new file mode 100644 index 00000000000..917a4373087 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/DeleteMessages.cpp @@ -0,0 +1,162 @@ +#include "controllers/commands/builtin/twitch/DeleteMessages.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "common/NetworkResult.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/Message.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" + +#include + +namespace { + +using namespace chatterino; + +QString deleteMessages(TwitchChannel *twitchChannel, const QString &messageID) +{ + const auto *commandName = messageID.isEmpty() ? "/clear" : "/delete"; + + auto user = getApp()->accounts->twitch.getCurrent(); + + // Avoid Helix calls without Client ID and/or OAuth Token + if (user->isAnon()) + { + twitchChannel->addMessage(makeSystemMessage( + QString("You must be logged in to use the %1 command.") + .arg(commandName))); + return ""; + } + + getHelix()->deleteChatMessages( + twitchChannel->roomId(), user->getUserId(), messageID, + []() { + // Success handling, we do nothing: IRC/pubsub-edge will dispatch the correct + // events to update state for us. + }, + [twitchChannel, messageID](auto error, auto message) { + QString errorMessage = QString("Failed to delete chat messages - "); + + switch (error) + { + case HelixDeleteChatMessagesError::UserMissingScope: { + errorMessage += + "Missing required scope. Re-login with your " + "account and try again."; + } + break; + + case HelixDeleteChatMessagesError::UserNotAuthorized: { + errorMessage += + "you don't have permission to perform that action."; + } + break; + + case HelixDeleteChatMessagesError::MessageUnavailable: { + // Override default message prefix to match with IRC message format + errorMessage = + QString("The message %1 does not exist, was deleted, " + "or is too old to be deleted.") + .arg(messageID); + } + break; + + case HelixDeleteChatMessagesError::UserNotAuthenticated: { + errorMessage += "you need to re-authenticate."; + } + break; + + case HelixDeleteChatMessagesError::Forwarded: { + errorMessage += message; + } + break; + + case HelixDeleteChatMessagesError::Unknown: + default: { + errorMessage += "An unknown error has occurred."; + } + break; + } + + twitchChannel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; +} + +} // namespace + +namespace chatterino::commands { + +QString deleteAllMessages(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /clear command only works in Twitch channels")); + return ""; + } + + return deleteMessages(ctx.twitchChannel, QString()); +} + +QString deleteOneMessage(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + // This is a wrapper over the Helix delete messages endpoint + // We use this to ensure the user gets better error messages for missing or malformed arguments + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /delete command only works in Twitch channels")); + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage( + makeSystemMessage("Usage: /delete - Deletes the " + "specified message.")); + return ""; + } + + auto messageID = ctx.words.at(1); + auto uuid = QUuid(messageID); + if (uuid.isNull()) + { + // The message id must be a valid UUID + ctx.channel->addMessage(makeSystemMessage( + QString("Invalid msg-id: \"%1\"").arg(messageID))); + return ""; + } + + auto msg = ctx.channel->findMessage(messageID); + if (msg != nullptr) + { + if (msg->loginName == ctx.channel->getName() && + !ctx.channel->isBroadcaster()) + { + ctx.channel->addMessage(makeSystemMessage( + "You cannot delete the broadcaster's messages unless " + "you are the broadcaster.")); + return ""; + } + } + + return deleteMessages(ctx.twitchChannel, messageID); +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/DeleteMessages.hpp b/src/controllers/commands/builtin/twitch/DeleteMessages.hpp new file mode 100644 index 00000000000..24daae93083 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/DeleteMessages.hpp @@ -0,0 +1,19 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /clear +QString deleteAllMessages(const CommandContext &ctx); + +/// /delete +QString deleteOneMessage(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/GetModerators.cpp b/src/controllers/commands/builtin/twitch/GetModerators.cpp new file mode 100644 index 00000000000..cc3a97ee06b --- /dev/null +++ b/src/controllers/commands/builtin/twitch/GetModerators.cpp @@ -0,0 +1,94 @@ +#include "controllers/commands/builtin/twitch/GetModerators.hpp" + +#include "common/Channel.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" + +namespace { + +using namespace chatterino; + +QString formatModsError(HelixGetModeratorsError error, const QString &message) +{ + using Error = HelixGetModeratorsError; + + QString errorMessage = QString("Failed to get moderators - "); + + switch (error) + { + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::UserMissingScope: { + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + errorMessage += + "Due to Twitch restrictions, " + "this command can only be used by the broadcaster. " + "To see the list of mods you must use the Twitch website."; + } + break; + + case Error::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + return errorMessage; +} + +} // namespace + +namespace chatterino::commands { + +QString getModerators(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /mods command only works in Twitch Channels")); + return ""; + } + + getHelix()->getModerators( + ctx.twitchChannel->roomId(), 500, + [channel{ctx.channel}, twitchChannel{ctx.twitchChannel}](auto result) { + if (result.empty()) + { + channel->addMessage(makeSystemMessage( + "This channel does not have any moderators.")); + return; + } + + // TODO: sort results? + + MessageBuilder builder; + TwitchMessageBuilder::listOfUsersSystemMessage( + "The moderators of this channel are", result, twitchChannel, + &builder); + channel->addMessage(builder.release()); + }, + [channel{ctx.channel}](auto error, auto message) { + auto errorMessage = formatModsError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/GetModerators.hpp b/src/controllers/commands/builtin/twitch/GetModerators.hpp new file mode 100644 index 00000000000..517533246e9 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/GetModerators.hpp @@ -0,0 +1,16 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /mods +QString getModerators(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/GetVIPs.cpp b/src/controllers/commands/builtin/twitch/GetVIPs.cpp new file mode 100644 index 00000000000..74a59a35746 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/GetVIPs.cpp @@ -0,0 +1,124 @@ +#include "controllers/commands/builtin/twitch/GetVIPs.hpp" + +#include "Application.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" +#include "util/Twitch.hpp" + +namespace { + +using namespace chatterino; + +QString formatGetVIPsError(HelixListVIPsError error, const QString &message) +{ + using Error = HelixListVIPsError; + + QString errorMessage = QString("Failed to list VIPs - "); + + switch (error) + { + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Ratelimited: { + errorMessage += "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::UserNotBroadcaster: { + errorMessage += + "Due to Twitch restrictions, " + "this command can only be used by the broadcaster. " + "To see the list of VIPs you must use the Twitch website."; + } + break; + + case Error::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + return errorMessage; +} + +} // namespace + +namespace chatterino::commands { + +QString getVIPs(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /vips command only works in Twitch channels")); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage(makeSystemMessage( + "Due to Twitch restrictions, " // + "this command can only be used by the broadcaster. " + "To see the list of VIPs you must use the " + "Twitch website.")); + return ""; + } + + getHelix()->getChannelVIPs( + ctx.twitchChannel->roomId(), + [channel{ctx.channel}, twitchChannel{ctx.twitchChannel}]( + const std::vector &vipList) { + if (vipList.empty()) + { + channel->addMessage( + makeSystemMessage("This channel does not have any VIPs.")); + return; + } + + auto messagePrefix = QString("The VIPs of this channel are"); + + // TODO: sort results? + MessageBuilder builder; + TwitchMessageBuilder::listOfUsersSystemMessage( + messagePrefix, vipList, twitchChannel, &builder); + + channel->addMessage(builder.release()); + }, + [channel{ctx.channel}](auto error, auto message) { + auto errorMessage = formatGetVIPsError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/GetVIPs.hpp b/src/controllers/commands/builtin/twitch/GetVIPs.hpp new file mode 100644 index 00000000000..01853759024 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/GetVIPs.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /vips +QString getVIPs(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Raid.cpp b/src/controllers/commands/builtin/twitch/Raid.cpp new file mode 100644 index 00000000000..b1cf03014ed --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Raid.cpp @@ -0,0 +1,220 @@ +#include "controllers/commands/builtin/twitch/Raid.hpp" + +#include "Application.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "util/Twitch.hpp" + +namespace { + +using namespace chatterino; + +QString formatStartRaidError(HelixStartRaidError error, const QString &message) +{ + QString errorMessage = QString("Failed to start a raid - "); + + using Error = HelixStartRaidError; + + switch (error) + { + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + errorMessage += "You must be the broadcaster " + "to start a raid."; + } + break; + + case Error::CantRaidYourself: { + errorMessage += "A channel cannot raid itself."; + } + break; + + case Error::Ratelimited: { + errorMessage += "You are being ratelimited " + "by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Unknown: + default: { + errorMessage += "An unknown error has occurred."; + } + break; + } + + return errorMessage; +} + +QString formatCancelRaidError(HelixCancelRaidError error, + const QString &message) +{ + QString errorMessage = QString("Failed to cancel the raid - "); + + using Error = HelixCancelRaidError; + + switch (error) + { + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + errorMessage += "You must be the broadcaster " + "to cancel the raid."; + } + break; + + case Error::NoRaidPending: { + errorMessage += "You don't have an active raid."; + } + break; + + case Error::Ratelimited: { + errorMessage += "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Unknown: + default: { + errorMessage += "An unknown error has occurred."; + } + break; + } + + return errorMessage; +} + +} // namespace + +namespace chatterino::commands { + +QString startRaid(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /raid command only works in Twitch channels")); + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage( + makeSystemMessage("Usage: \"/raid \" - Raid a user. " + "Only the broadcaster can start a raid.")); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to start a raid!")); + return ""; + } + + auto target = ctx.words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [twitchChannel{ctx.twitchChannel}, + channel{ctx.channel}](const HelixUser &targetUser) { + getHelix()->startRaid( + twitchChannel->roomId(), targetUser.id, + [channel, targetUser] { + channel->addMessage( + makeSystemMessage(QString("You started to raid %1.") + .arg(targetUser.displayName))); + }, + [channel, targetUser](auto error, auto message) { + auto errorMessage = formatStartRaidError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel{ctx.channel}, target] { + // Equivalent error from IRC + channel->addMessage( + makeSystemMessage(QString("Invalid username: %1").arg(target))); + }); + + return ""; +} + +QString cancelRaid(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /unraid command only works in Twitch channels")); + return ""; + } + + if (ctx.words.size() != 1) + { + ctx.channel->addMessage( + makeSystemMessage("Usage: \"/unraid\" - Cancel the current raid. " + "Only the broadcaster can cancel the raid.")); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to cancel the raid!")); + return ""; + } + + getHelix()->cancelRaid( + ctx.twitchChannel->roomId(), + [channel{ctx.channel}] { + channel->addMessage( + makeSystemMessage(QString("You cancelled the raid."))); + }, + [channel{ctx.channel}](auto error, auto message) { + auto errorMessage = formatCancelRaidError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Raid.hpp b/src/controllers/commands/builtin/twitch/Raid.hpp new file mode 100644 index 00000000000..38d37644ded --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Raid.hpp @@ -0,0 +1,19 @@ +#pragma once + +#include + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /raid +QString startRaid(const CommandContext &ctx); + +/// /unraid +QString cancelRaid(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/RemoveModerator.cpp b/src/controllers/commands/builtin/twitch/RemoveModerator.cpp new file mode 100644 index 00000000000..c4ed62364e4 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/RemoveModerator.cpp @@ -0,0 +1,122 @@ +#include "controllers/commands/builtin/twitch/RemoveModerator.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" +#include "util/Twitch.hpp" + +namespace chatterino::commands { + +QString removeModerator(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /unmod command only works in Twitch channels")); + return ""; + } + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage( + "Usage: \"/unmod \" - Revoke moderator status from a " + "user. Use \"/mods\" to list the moderators of this channel.")); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to unmod someone!")); + return ""; + } + + auto target = ctx.words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [twitchChannel{ctx.twitchChannel}, + channel{ctx.channel}](const HelixUser &targetUser) { + getHelix()->removeChannelModerator( + twitchChannel->roomId(), targetUser.id, + [channel, targetUser] { + channel->addMessage(makeSystemMessage( + QString("You have removed %1 as a moderator of " + "this channel.") + .arg(targetUser.displayName))); + }, + [channel, targetUser](auto error, auto message) { + QString errorMessage = + QString("Failed to remove channel moderator - "); + + using Error = HelixRemoveChannelModeratorError; + + switch (error) + { + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::Ratelimited: { + errorMessage += + "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::TargetNotModded: { + // Equivalent irc error + errorMessage += + QString("%1 is not a moderator of this " + "channel.") + .arg(targetUser.displayName); + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Unknown: + default: { + errorMessage += "An unknown error has occurred."; + } + break; + } + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel{ctx.channel}, target] { + // Equivalent error from IRC + channel->addMessage( + makeSystemMessage(QString("Invalid username: %1").arg(target))); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/RemoveModerator.hpp b/src/controllers/commands/builtin/twitch/RemoveModerator.hpp new file mode 100644 index 00000000000..9b6894dc779 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/RemoveModerator.hpp @@ -0,0 +1,16 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /unmod +QString removeModerator(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/RemoveVIP.cpp b/src/controllers/commands/builtin/twitch/RemoveVIP.cpp new file mode 100644 index 00000000000..6922175ae72 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/RemoveVIP.cpp @@ -0,0 +1,112 @@ +#include "controllers/commands/builtin/twitch/RemoveVIP.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" +#include "util/Twitch.hpp" + +namespace chatterino::commands { + +QString removeVIP(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /unvip command only works in Twitch channels")); + return ""; + } + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage( + "Usage: \"/unvip \" - Revoke VIP status from a user. " + "Use \"/vips\" to list the VIPs of this channel.")); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to UnVIP someone!")); + return ""; + } + + auto target = ctx.words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [twitchChannel{ctx.twitchChannel}, + channel{ctx.channel}](const HelixUser &targetUser) { + getHelix()->removeChannelVIP( + twitchChannel->roomId(), targetUser.id, + [channel, targetUser] { + channel->addMessage(makeSystemMessage( + QString("You have removed %1 as a VIP of this channel.") + .arg(targetUser.displayName))); + }, + [channel, targetUser](auto error, auto message) { + QString errorMessage = QString("Failed to remove VIP - "); + + using Error = HelixRemoveChannelVIPError; + + switch (error) + { + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::Ratelimited: { + errorMessage += + "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::Forwarded: { + // These are actually the IRC equivalents, so we can ditch the prefix + errorMessage = message; + } + break; + + case Error::Unknown: + default: { + errorMessage += "An unknown error has occurred."; + } + break; + } + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel{ctx.channel}, target] { + // Equivalent error from IRC + channel->addMessage( + makeSystemMessage(QString("Invalid username: %1").arg(target))); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/RemoveVIP.hpp b/src/controllers/commands/builtin/twitch/RemoveVIP.hpp new file mode 100644 index 00000000000..ec66c62e729 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/RemoveVIP.hpp @@ -0,0 +1,16 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /unvip +QString removeVIP(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/SendReply.cpp b/src/controllers/commands/builtin/twitch/SendReply.cpp new file mode 100644 index 00000000000..a88fb0b5e83 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/SendReply.cpp @@ -0,0 +1,63 @@ +#include "controllers/commands/builtin/twitch/SendReply.hpp" + +#include "common/Channel.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/Message.hpp" +#include "messages/MessageBuilder.hpp" +#include "messages/MessageThread.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "util/Twitch.hpp" + +namespace chatterino::commands { + +QString sendReply(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /reply command only works in Twitch channels")); + return ""; + } + + if (ctx.words.size() < 3) + { + ctx.channel->addMessage( + makeSystemMessage("Usage: /reply ")); + return ""; + } + + QString username = ctx.words[1]; + stripChannelName(username); + + auto snapshot = ctx.twitchChannel->getMessageSnapshot(); + for (auto it = snapshot.rbegin(); it != snapshot.rend(); ++it) + { + const auto &msg = *it; + if (msg->loginName.compare(username, Qt::CaseInsensitive) == 0) + { + // found most recent message by user + if (msg->replyThread == nullptr) + { + // prepare thread if one does not exist + auto thread = std::make_shared(msg); + ctx.twitchChannel->addReplyThread(thread); + } + + QString reply = ctx.words.mid(2).join(" "); + ctx.twitchChannel->sendReply(reply, msg->id); + return ""; + } + } + + ctx.channel->addMessage( + makeSystemMessage("A message from that user wasn't found")); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/SendReply.hpp b/src/controllers/commands/builtin/twitch/SendReply.hpp new file mode 100644 index 00000000000..0909ae047c0 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/SendReply.hpp @@ -0,0 +1,15 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +QString sendReply(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/SendWhisper.cpp b/src/controllers/commands/builtin/twitch/SendWhisper.cpp new file mode 100644 index 00000000000..78efc60447d --- /dev/null +++ b/src/controllers/commands/builtin/twitch/SendWhisper.cpp @@ -0,0 +1,258 @@ +#include "controllers/commands/builtin/twitch/SendWhisper.hpp" + +#include "Application.hpp" +#include "common/LinkParser.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/Message.hpp" +#include "messages/MessageBuilder.hpp" +#include "messages/MessageElement.hpp" +#include "providers/irc/IrcChannel2.hpp" +#include "providers/irc/IrcServer.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchIrcServer.hpp" +#include "singletons/Emotes.hpp" +#include "singletons/Settings.hpp" +#include "singletons/Theme.hpp" +#include "util/Twitch.hpp" + +namespace { + +using namespace chatterino; + +QString formatWhisperError(HelixWhisperError error, const QString &message) +{ + using Error = HelixWhisperError; + + QString errorMessage = "Failed to send whisper - "; + + switch (error) + { + case Error::NoVerifiedPhone: { + errorMessage += "Due to Twitch restrictions, you are now " + "required to have a verified phone number " + "to send whispers. You can add a phone " + "number in Twitch settings. " + "https://www.twitch.tv/settings/security"; + }; + break; + + case Error::RecipientBlockedUser: { + errorMessage += "The recipient doesn't allow whispers " + "from strangers or you directly."; + }; + break; + + case Error::WhisperSelf: { + errorMessage += "You cannot whisper yourself."; + }; + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Ratelimited: { + errorMessage += "You may only whisper a maximum of 40 " + "unique recipients per day. Within the " + "per day limit, you may whisper a " + "maximum of 3 whispers per second and " + "a maximum of 100 whispers per minute."; + } + break; + + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + + return errorMessage; +} + +bool appendWhisperMessageWordsLocally(const QStringList &words) +{ + auto *app = getApp(); + + MessageBuilder b; + + b.emplace(); + b.emplace(app->accounts->twitch.getCurrent()->getUserName(), + MessageElementFlag::Text, MessageColor::Text, + FontStyle::ChatMediumBold); + b.emplace("->", MessageElementFlag::Text, + getApp()->themes->messages.textColors.system); + b.emplace(words[1] + ":", MessageElementFlag::Text, + MessageColor::Text, FontStyle::ChatMediumBold); + + const auto &acc = app->accounts->twitch.getCurrent(); + const auto &accemotes = *acc->accessEmotes(); + const auto &bttvemotes = app->twitch->getBttvEmotes(); + const auto &ffzemotes = app->twitch->getFfzEmotes(); + auto flags = MessageElementFlags(); + auto emote = std::optional{}; + for (int i = 2; i < words.length(); i++) + { + { // Twitch emote + auto it = accemotes.emotes.find({words[i]}); + if (it != accemotes.emotes.end()) + { + b.emplace(it->second, + MessageElementFlag::TwitchEmote); + continue; + } + } // Twitch emote + + { // bttv/ffz emote + if ((emote = bttvemotes.emote({words[i]}))) + { + flags = MessageElementFlag::BttvEmote; + } + else if ((emote = ffzemotes.emote({words[i]}))) + { + flags = MessageElementFlag::FfzEmote; + } + if (emote) + { + b.emplace(*emote, flags); + continue; + } + } // bttv/ffz emote + { // emoji/text + for (auto &variant : app->emotes->emojis.parse(words[i])) + { + constexpr const static struct { + void operator()(EmotePtr emote, MessageBuilder &b) const + { + b.emplace(emote, + MessageElementFlag::EmojiAll); + } + void operator()(const QString &string, + MessageBuilder &b) const + { + LinkParser parser(string); + if (parser.result()) + { + b.addLink(*parser.result()); + } + else + { + b.emplace(string, + MessageElementFlag::Text); + } + } + } visitor; + boost::apply_visitor( + [&b](auto &&arg) { + visitor(arg, b); + }, + variant); + } // emoji/text + } + } + + b->flags.set(MessageFlag::DoNotTriggerNotification); + b->flags.set(MessageFlag::Whisper); + auto messagexD = b.release(); + + app->twitch->whispersChannel->addMessage(messagexD); + + auto overrideFlags = std::optional(messagexD->flags); + overrideFlags->set(MessageFlag::DoNotLog); + + if (getSettings()->inlineWhispers && + !(getSettings()->streamerModeSuppressInlineWhispers && + isInStreamerMode())) + { + app->twitch->forEachChannel( + [&messagexD, overrideFlags](ChannelPtr _channel) { + _channel->addMessage(messagexD, overrideFlags); + }); + } + + return true; +} + +} // namespace + +namespace chatterino::commands { + +QString sendWhisper(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.words.size() < 3) + { + ctx.channel->addMessage( + makeSystemMessage("Usage: /w ")); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to send a whisper!")); + return ""; + } + auto target = ctx.words.at(1); + stripChannelName(target); + auto message = ctx.words.mid(2).join(' '); + if (ctx.channel->isTwitchChannel()) + { + getHelix()->getUserByName( + target, + [channel{ctx.channel}, currentUser, target, message, + words{ctx.words}](const auto &targetUser) { + getHelix()->sendWhisper( + currentUser->getUserId(), targetUser.id, message, + [words] { + appendWhisperMessageWordsLocally(words); + }, + [channel, target, targetUser](auto error, auto message) { + auto errorMessage = formatWhisperError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel{ctx.channel}] { + channel->addMessage( + makeSystemMessage("No user matching that username.")); + }); + return ""; + } + + // we must be on IRC + auto *ircChannel = dynamic_cast(ctx.channel.get()); + if (ircChannel == nullptr) + { + // give up + return ""; + } + + auto *server = ircChannel->server(); + server->sendWhisper(target, message); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/SendWhisper.hpp b/src/controllers/commands/builtin/twitch/SendWhisper.hpp new file mode 100644 index 00000000000..1e882a93667 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/SendWhisper.hpp @@ -0,0 +1,15 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +QString sendWhisper(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/StartCommercial.cpp b/src/controllers/commands/builtin/twitch/StartCommercial.cpp new file mode 100644 index 00000000000..545099a7f2c --- /dev/null +++ b/src/controllers/commands/builtin/twitch/StartCommercial.cpp @@ -0,0 +1,136 @@ +#include "controllers/commands/builtin/twitch/StartCommercial.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "providers/twitch/TwitchMessageBuilder.hpp" + +namespace { + +using namespace chatterino; + +QString formatStartCommercialError(HelixStartCommercialError error, + const QString &message) +{ + using Error = HelixStartCommercialError; + + QString errorMessage = "Failed to start commercial - "; + + switch (error) + { + case Error::UserMissingScope: { + errorMessage += "Missing required scope. Re-login with your " + "account and try again."; + } + break; + + case Error::TokenMustMatchBroadcaster: { + errorMessage += "Only the broadcaster of the channel can run " + "commercials."; + } + break; + + case Error::BroadcasterNotStreaming: { + errorMessage += "You must be streaming live to run " + "commercials."; + } + break; + + case Error::MissingLengthParameter: { + errorMessage += "Command must include a desired commercial break " + "length that is greater than zero."; + } + break; + + case Error::Ratelimited: { + errorMessage += "You must wait until your cooldown period " + "expires before you can run another " + "commercial."; + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Unknown: + default: { + errorMessage += + QString("An unknown error has occurred (%1).").arg(message); + } + break; + } + + return errorMessage; +} + +} // namespace + +namespace chatterino::commands { + +QString startCommercial(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + "The /commercial command only works in Twitch channels")); + return ""; + } + + const auto *usageStr = "Usage: \"/commercial \" - Starts a " + "commercial with the " + "specified duration for the current " + "channel. Valid length options " + "are 30, 60, 90, 120, 150, and 180 seconds."; + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage(usageStr)); + return ""; + } + + auto user = getApp()->accounts->twitch.getCurrent(); + + // Avoid Helix calls without Client ID and/or OAuth Token + if (user->isAnon()) + { + ctx.channel->addMessage(makeSystemMessage( + "You must be logged in to use the /commercial command")); + return ""; + } + + auto broadcasterID = ctx.twitchChannel->roomId(); + auto length = ctx.words.at(1).toInt(); + + getHelix()->startCommercial( + broadcasterID, length, + [channel{ctx.channel}](auto response) { + channel->addMessage(makeSystemMessage( + QString("Starting %1 second long commercial break. " + "Keep in mind you are still " + "live and not all viewers will receive a " + "commercial. " + "You may run another commercial in %2 seconds.") + .arg(response.length) + .arg(response.retryAfter))); + }, + [channel{ctx.channel}](auto error, auto message) { + auto errorMessage = formatStartCommercialError(error, message); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/StartCommercial.hpp b/src/controllers/commands/builtin/twitch/StartCommercial.hpp new file mode 100644 index 00000000000..3b1d550fc98 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/StartCommercial.hpp @@ -0,0 +1,16 @@ +#pragma once + +#include + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /commercial +QString startCommercial(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Unban.cpp b/src/controllers/commands/builtin/twitch/Unban.cpp new file mode 100644 index 00000000000..02b942494b7 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Unban.cpp @@ -0,0 +1,124 @@ +#include "Application.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/builtin/twitch/Ban.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "util/Twitch.hpp" + +namespace chatterino::commands { + +QString unbanUser(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + auto commandName = ctx.words.at(0).toLower(); + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage(makeSystemMessage( + QString("The %1 command only works in Twitch channels") + .arg(commandName))); + return ""; + } + if (ctx.words.size() < 2) + { + ctx.channel->addMessage(makeSystemMessage( + QString("Usage: \"%1 \" - Removes a ban on a user.") + .arg(commandName))); + return ""; + } + + auto currentUser = getApp()->accounts->twitch.getCurrent(); + if (currentUser->isAnon()) + { + ctx.channel->addMessage( + makeSystemMessage("You must be logged in to unban someone!")); + return ""; + } + + auto target = ctx.words.at(1); + stripChannelName(target); + + getHelix()->getUserByName( + target, + [channel{ctx.channel}, currentUser, twitchChannel{ctx.twitchChannel}, + target](const auto &targetUser) { + getHelix()->unbanUser( + twitchChannel->roomId(), currentUser->getUserId(), + targetUser.id, + [] { + // No response for unbans, they're emitted over pubsub/IRC instead + }, + [channel, target, targetUser](auto error, auto message) { + using Error = HelixUnbanUserError; + + QString errorMessage = QString("Failed to unban user - "); + + switch (error) + { + case Error::ConflictingOperation: { + errorMessage += + "There was a conflicting ban operation on " + "this user. Please try again."; + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Ratelimited: { + errorMessage += + "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::TargetNotBanned: { + // Equivalent IRC error + errorMessage = + QString("%1 is not banned from this channel.") + .arg(targetUser.displayName); + } + break; + + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + + channel->addMessage(makeSystemMessage(errorMessage)); + }); + }, + [channel{ctx.channel}, target] { + // Equivalent error from IRC + channel->addMessage( + makeSystemMessage(QString("Invalid username: %1").arg(target))); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/Unban.hpp b/src/controllers/commands/builtin/twitch/Unban.hpp new file mode 100644 index 00000000000..4c32f09b717 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/Unban.hpp @@ -0,0 +1,16 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +/// /unban +QString unbanUser(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/UpdateChannel.cpp b/src/controllers/commands/builtin/twitch/UpdateChannel.cpp new file mode 100644 index 00000000000..780be75e7e2 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/UpdateChannel.cpp @@ -0,0 +1,121 @@ +#include "controllers/commands/builtin/twitch/UpdateChannel.hpp" + +#include "common/Channel.hpp" +#include "common/NetworkResult.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchChannel.hpp" + +namespace chatterino::commands { + +QString setTitle(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage( + makeSystemMessage("Usage: /settitle ")); + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage( + makeSystemMessage("Unable to set title of non-Twitch channel.")); + return ""; + } + + auto status = ctx.twitchChannel->accessStreamStatus(); + auto title = ctx.words.mid(1).join(" "); + getHelix()->updateChannel( + ctx.twitchChannel->roomId(), "", "", title, + [channel{ctx.channel}, title](const auto &result) { + (void)result; + + channel->addMessage( + makeSystemMessage(QString("Updated title to %1").arg(title))); + }, + [channel{ctx.channel}] { + channel->addMessage( + makeSystemMessage("Title update failed! Are you " + "missing the required scope?")); + }); + + return ""; +} + +QString setGame(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (ctx.words.size() < 2) + { + ctx.channel->addMessage( + makeSystemMessage("Usage: /setgame ")); + return ""; + } + + if (ctx.twitchChannel == nullptr) + { + ctx.channel->addMessage( + makeSystemMessage("Unable to set game of non-Twitch channel.")); + return ""; + } + + const auto gameName = ctx.words.mid(1).join(" "); + + getHelix()->searchGames( + gameName, + [channel{ctx.channel}, twitchChannel{ctx.twitchChannel}, + gameName](const std::vector &games) { + if (games.empty()) + { + channel->addMessage(makeSystemMessage("Game not found.")); + return; + } + + auto matchedGame = games.at(0); + + if (games.size() > 1) + { + // NOTE: Improvements could be made with 'fuzzy string matching' code here + // attempt to find the best looking game by comparing exactly with lowercase values + for (const auto &game : games) + { + if (game.name.toLower() == gameName.toLower()) + { + matchedGame = game; + break; + } + } + } + + auto status = twitchChannel->accessStreamStatus(); + getHelix()->updateChannel( + twitchChannel->roomId(), matchedGame.id, "", "", + [channel, games, matchedGame](const NetworkResult &) { + channel->addMessage(makeSystemMessage( + QString("Updated game to %1").arg(matchedGame.name))); + }, + [channel] { + channel->addMessage( + makeSystemMessage("Game update failed! Are you " + "missing the required scope?")); + }); + }, + [channel{ctx.channel}] { + channel->addMessage(makeSystemMessage("Failed to look up game.")); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/UpdateChannel.hpp b/src/controllers/commands/builtin/twitch/UpdateChannel.hpp new file mode 100644 index 00000000000..2a085b49c0b --- /dev/null +++ b/src/controllers/commands/builtin/twitch/UpdateChannel.hpp @@ -0,0 +1,16 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +QString setTitle(const CommandContext &ctx); +QString setGame(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/UpdateColor.cpp b/src/controllers/commands/builtin/twitch/UpdateColor.cpp new file mode 100644 index 00000000000..8057daee62f --- /dev/null +++ b/src/controllers/commands/builtin/twitch/UpdateColor.cpp @@ -0,0 +1,99 @@ +#include "controllers/commands/builtin/twitch/UpdateColor.hpp" + +#include "Application.hpp" +#include "common/Channel.hpp" +#include "controllers/accounts/AccountController.hpp" +#include "controllers/commands/CommandContext.hpp" +#include "messages/MessageBuilder.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchAccount.hpp" +#include "providers/twitch/TwitchChannel.hpp" +#include "util/Twitch.hpp" + +namespace chatterino::commands { + +QString updateUserColor(const CommandContext &ctx) +{ + if (ctx.channel == nullptr) + { + return ""; + } + + if (!ctx.channel->isTwitchChannel()) + { + ctx.channel->addMessage(makeSystemMessage( + "The /color command only works in Twitch channels")); + return ""; + } + auto user = getApp()->accounts->twitch.getCurrent(); + + // Avoid Helix calls without Client ID and/or OAuth Token + if (user->isAnon()) + { + ctx.channel->addMessage(makeSystemMessage( + "You must be logged in to use the /color command")); + return ""; + } + + auto colorString = ctx.words.value(1); + + if (colorString.isEmpty()) + { + ctx.channel->addMessage(makeSystemMessage( + QString("Usage: /color - Color must be one of Twitch's " + "supported colors (%1) or a hex code (#000000) if you " + "have Turbo or Prime.") + .arg(VALID_HELIX_COLORS.join(", ")))); + return ""; + } + + cleanHelixColorName(colorString); + + getHelix()->updateUserChatColor( + user->getUserId(), colorString, + [colorString, channel{ctx.channel}] { + QString successMessage = + QString("Your color has been changed to %1.").arg(colorString); + channel->addMessage(makeSystemMessage(successMessage)); + }, + [colorString, channel{ctx.channel}](auto error, auto message) { + QString errorMessage = + QString("Failed to change color to %1 - ").arg(colorString); + + switch (error) + { + case HelixUpdateUserChatColorError::UserMissingScope: { + errorMessage += + "Missing required scope. Re-login with your " + "account and try again."; + } + break; + + case HelixUpdateUserChatColorError::InvalidColor: { + errorMessage += QString("Color must be one of Twitch's " + "supported colors (%1) or a " + "hex code (#000000) if you " + "have Turbo or Prime.") + .arg(VALID_HELIX_COLORS.join(", ")); + } + break; + + case HelixUpdateUserChatColorError::Forwarded: { + errorMessage += message + "."; + } + break; + + case HelixUpdateUserChatColorError::Unknown: + default: { + errorMessage += "An unknown error has occurred."; + } + break; + } + + channel->addMessage(makeSystemMessage(errorMessage)); + }); + + return ""; +} + +} // namespace chatterino::commands diff --git a/src/controllers/commands/builtin/twitch/UpdateColor.hpp b/src/controllers/commands/builtin/twitch/UpdateColor.hpp new file mode 100644 index 00000000000..c4c3bdaf038 --- /dev/null +++ b/src/controllers/commands/builtin/twitch/UpdateColor.hpp @@ -0,0 +1,15 @@ +#pragma once + +class QString; + +namespace chatterino { + +struct CommandContext; + +} // namespace chatterino + +namespace chatterino::commands { + +QString updateUserColor(const CommandContext &ctx); + +} // namespace chatterino::commands diff --git a/src/messages/Image.hpp b/src/messages/Image.hpp index a2f8d0d874c..6e1052a8a2e 100644 --- a/src/messages/Image.hpp +++ b/src/messages/Image.hpp @@ -121,10 +121,7 @@ ImagePtr getEmptyImagePtr(); class ImageExpirationPool { -private: - friend class Image; - friend class CommandController; - +public: ImageExpirationPool(); static ImageExpirationPool &instance(); @@ -145,7 +142,6 @@ class ImageExpirationPool */ void freeAll(); -private: // Timer to periodically run freeOld() QTimer *freeTimer_; std::map> allImages_; From 68817fa1a132a19d6d1ba34420f8ccc4fc931a94 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 8 Nov 2023 18:25:23 +0000 Subject: [PATCH 04/26] chore(deps): bump lib/miniaudio from `3898fff` to `b19cc09` (#4948) Bumps [lib/miniaudio](https://github.com/mackron/miniaudio) from `3898fff` to `b19cc09`. - [Commits](https://github.com/mackron/miniaudio/compare/3898fff8ed923e118326bf07822961d222cb2a9a...b19cc09fd06b80f370ca4385d260df7e31925b50) --- updated-dependencies: - dependency-name: lib/miniaudio dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- lib/miniaudio | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/miniaudio b/lib/miniaudio index 3898fff8ed9..b19cc09fd06 160000 --- a/lib/miniaudio +++ b/lib/miniaudio @@ -1 +1 @@ -Subproject commit 3898fff8ed923e118326bf07822961d222cb2a9a +Subproject commit b19cc09fd06b80f370ca4385d260df7e31925b50 From fcc5f4b3dffc2aa0467662a2ef995b9d0335f901 Mon Sep 17 00:00:00 2001 From: pajlada Date: Wed, 8 Nov 2023 21:42:06 +0100 Subject: [PATCH 05/26] feat: Allow id: prefix in /ban and /timeout (#4945) ban example: `/ban id:70948394`, equivalent to `/banid 70948394` timeout example: `/timeout id:70948394 10 xd` --- CHANGELOG.md | 1 + .../commands/builtin/twitch/Ban.cpp | 154 +++++++++++------- src/util/Twitch.cpp | 27 +++ src/util/Twitch.hpp | 10 ++ tests/src/UtilTwitch.cpp | 110 +++++++++++++ 5 files changed, 243 insertions(+), 59 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1673fb09eba..36d824972e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Minor: The account switcher is now styled to match your theme. (#4817) - Minor: Add an invisible resize handle to the bottom of frameless user info popups and reply thread popups. (#4795) - Minor: The installer now checks for the VC Runtime version and shows more info when it's outdated. (#4847) +- Minor: Allow running `/ban` and `/timeout` on User IDs by using the `id:123` syntax (e.g. `/timeout id:22484632 1m stop winning`). (#4945) - Minor: The `/usercard` command now accepts user ids. (#4934) - Minor: Add menu actions to reply directly to a message or the original thread root. (#4923) - Minor: The `/reply` command now replies to the latest message of the user. (#4919) diff --git a/src/controllers/commands/builtin/twitch/Ban.cpp b/src/controllers/commands/builtin/twitch/Ban.cpp index 8c438539e43..2ecec39b8d0 100644 --- a/src/controllers/commands/builtin/twitch/Ban.cpp +++ b/src/controllers/commands/builtin/twitch/Ban.cpp @@ -78,7 +78,42 @@ QString formatBanTimeoutError(const char *operation, HelixBanUserError error, break; } return errorMessage; -}; +} + +void banUserByID(const ChannelPtr &channel, const TwitchChannel *twitchChannel, + const QString &sourceUserID, const QString &targetUserID, + const QString &reason, const QString &displayName) +{ + getHelix()->banUser( + twitchChannel->roomId(), sourceUserID, targetUserID, std::nullopt, + reason, + [] { + // No response for bans, they're emitted over pubsub/IRC instead + }, + [channel, displayName](auto error, auto message) { + auto errorMessage = + formatBanTimeoutError("ban", error, message, displayName); + channel->addMessage(makeSystemMessage(errorMessage)); + }); +} + +void timeoutUserByID(const ChannelPtr &channel, + const TwitchChannel *twitchChannel, + const QString &sourceUserID, const QString &targetUserID, + int duration, const QString &reason, + const QString &displayName) +{ + getHelix()->banUser( + twitchChannel->roomId(), sourceUserID, targetUserID, duration, reason, + [] { + // No response for timeouts, they're emitted over pubsub/IRC instead + }, + [channel, displayName](auto error, auto message) { + auto errorMessage = + formatBanTimeoutError("timeout", error, message, displayName); + channel->addMessage(makeSystemMessage(errorMessage)); + }); +} } // namespace @@ -120,32 +155,41 @@ QString sendBan(const CommandContext &ctx) return ""; } - auto target = words.at(1); - stripChannelName(target); - + const auto &rawTarget = words.at(1); + auto [targetUserName, targetUserID] = parseUserNameOrID(rawTarget); auto reason = words.mid(2).join(' '); - getHelix()->getUserByName( - target, - [channel, currentUser, twitchChannel, target, - reason](const auto &targetUser) { - getHelix()->banUser( - twitchChannel->roomId(), currentUser->getUserId(), - targetUser.id, std::nullopt, reason, - [] { - // No response for bans, they're emitted over pubsub/IRC instead - }, - [channel, target, targetUser](auto error, auto message) { - auto errorMessage = formatBanTimeoutError( - "ban", error, message, targetUser.displayName); - channel->addMessage(makeSystemMessage(errorMessage)); - }); - }, - [channel, target] { - // Equivalent error from IRC - channel->addMessage( - makeSystemMessage(QString("Invalid username: %1").arg(target))); - }); + if (!targetUserID.isEmpty()) + { + banUserByID(channel, twitchChannel, currentUser->getUserId(), + targetUserID, reason, targetUserID); + getHelix()->banUser( + twitchChannel->roomId(), currentUser->getUserId(), targetUserID, + std::nullopt, reason, + [] { + // No response for bans, they're emitted over pubsub/IRC instead + }, + [channel, targetUserID{targetUserID}](auto error, auto message) { + auto errorMessage = + formatBanTimeoutError("ban", error, message, targetUserID); + channel->addMessage(makeSystemMessage(errorMessage)); + }); + } + else + { + getHelix()->getUserByName( + targetUserName, + [channel, currentUser, twitchChannel, + reason](const auto &targetUser) { + banUserByID(channel, twitchChannel, currentUser->getUserId(), + targetUser.id, reason, targetUser.displayName); + }, + [channel, targetUserName{targetUserName}] { + // Equivalent error from IRC + channel->addMessage(makeSystemMessage( + QString("Invalid username: %1").arg(targetUserName))); + }); + } return ""; } @@ -188,17 +232,8 @@ QString sendBanById(const CommandContext &ctx) auto target = words.at(1); auto reason = words.mid(2).join(' '); - getHelix()->banUser( - twitchChannel->roomId(), currentUser->getUserId(), target, std::nullopt, - reason, - [] { - // No response for bans, they're emitted over pubsub/IRC instead - }, - [channel, target](auto error, auto message) { - auto errorMessage = - formatBanTimeoutError("ban", error, message, "#" + target); - channel->addMessage(makeSystemMessage(errorMessage)); - }); + banUserByID(channel, twitchChannel, currentUser->getUserId(), target, + reason, target); return ""; } @@ -242,8 +277,8 @@ QString sendTimeout(const CommandContext &ctx) return ""; } - auto target = words.at(1); - stripChannelName(target); + const auto &rawTarget = words.at(1); + auto [targetUserName, targetUserID] = parseUserNameOrID(rawTarget); int duration = 10 * 60; // 10min if (words.size() >= 3) @@ -257,27 +292,28 @@ QString sendTimeout(const CommandContext &ctx) } auto reason = words.mid(3).join(' '); - getHelix()->getUserByName( - target, - [channel, currentUser, twitchChannel, target, duration, - reason](const auto &targetUser) { - getHelix()->banUser( - twitchChannel->roomId(), currentUser->getUserId(), - targetUser.id, duration, reason, - [] { - // No response for timeouts, they're emitted over pubsub/IRC instead - }, - [channel, target, targetUser](auto error, auto message) { - auto errorMessage = formatBanTimeoutError( - "timeout", error, message, targetUser.displayName); - channel->addMessage(makeSystemMessage(errorMessage)); - }); - }, - [channel, target] { - // Equivalent error from IRC - channel->addMessage( - makeSystemMessage(QString("Invalid username: %1").arg(target))); - }); + if (!targetUserID.isEmpty()) + { + timeoutUserByID(channel, twitchChannel, currentUser->getUserId(), + targetUserID, duration, reason, targetUserID); + } + else + { + getHelix()->getUserByName( + targetUserName, + [channel, currentUser, twitchChannel, + targetUserName{targetUserName}, duration, + reason](const auto &targetUser) { + timeoutUserByID(channel, twitchChannel, + currentUser->getUserId(), targetUser.id, + duration, reason, targetUser.displayName); + }, + [channel, targetUserName{targetUserName}] { + // Equivalent error from IRC + channel->addMessage(makeSystemMessage( + QString("Invalid username: %1").arg(targetUserName))); + }); + } return ""; } diff --git a/src/util/Twitch.cpp b/src/util/Twitch.cpp index 12e7519848a..fa21c8583c7 100644 --- a/src/util/Twitch.cpp +++ b/src/util/Twitch.cpp @@ -62,6 +62,33 @@ void stripChannelName(QString &channelName) } } +std::pair parseUserNameOrID(const QString &input) +{ + if (input.startsWith("id:")) + { + return { + {}, + input.mid(3), + }; + } + + QString userName = input; + + if (userName.startsWith('@') || userName.startsWith('#')) + { + userName.remove(0, 1); + } + if (userName.endsWith(',')) + { + userName.chop(1); + } + + return { + userName, + {}, + }; +} + QRegularExpression twitchUserNameRegexp() { static QRegularExpression re( diff --git a/src/util/Twitch.hpp b/src/util/Twitch.hpp index c3bb346a9d0..367b6cc98c5 100644 --- a/src/util/Twitch.hpp +++ b/src/util/Twitch.hpp @@ -16,6 +16,16 @@ void stripUserName(QString &userName); // stripChannelName removes any @ prefix or , suffix to make it more suitable for command use void stripChannelName(QString &channelName); +using ParsedUserName = QString; +using ParsedUserID = QString; + +/** + * Parse the given input into either a user name or a user ID + * + * User IDs take priority and are parsed if the input starts with `id:` + */ +std::pair parseUserNameOrID(const QString &input); + // Matches a strict Twitch user login. // May contain lowercase a-z, 0-9, and underscores // Must contain between 1 and 25 characters diff --git a/tests/src/UtilTwitch.cpp b/tests/src/UtilTwitch.cpp index c02753ca306..6a0b58d9fa6 100644 --- a/tests/src/UtilTwitch.cpp +++ b/tests/src/UtilTwitch.cpp @@ -160,6 +160,116 @@ TEST(UtilTwitch, StripChannelName) } } +TEST(UtilTwitch, ParseUserNameOrID) +{ + struct TestCase { + QString input; + QString expectedUserName; + QString expectedUserID; + }; + + std::vector tests{ + { + "pajlada", + "pajlada", + {}, + }, + { + "Pajlada", + "Pajlada", + {}, + }, + { + "@Pajlada", + "Pajlada", + {}, + }, + { + "#Pajlada", + "Pajlada", + {}, + }, + { + "#Pajlada,", + "Pajlada", + {}, + }, + { + "#Pajlada,", + "Pajlada", + {}, + }, + { + "@@Pajlada,", + "@Pajlada", + {}, + }, + { + // We only strip one character off the front + "#@Pajlada,", + "@Pajlada", + {}, + }, + { + "@@Pajlada,,", + "@Pajlada,", + {}, + }, + { + "", + "", + {}, + }, + { + "@", + "", + {}, + }, + { + ",", + "", + {}, + }, + { + // We purposefully don't handle spaces at the end, as all expected usages of this function split the message up by space and strip the parameters by themselves + ", ", + ", ", + {}, + }, + { + // We purposefully don't handle spaces at the start, as all expected usages of this function split the message up by space and strip the parameters by themselves + " #", + " #", + {}, + }, + { + "id:123", + {}, + "123", + }, + { + "id:", + {}, + "", + }, + }; + + for (const auto &[input, expectedUserName, expectedUserID] : tests) + { + auto [actualUserName, actualUserID] = parseUserNameOrID(input); + + EXPECT_EQ(actualUserName, expectedUserName) + << "name " << qUtf8Printable(actualUserName) << " (" + << qUtf8Printable(input) << ") did not match expected value " + << qUtf8Printable(expectedUserName); + + EXPECT_EQ(actualUserID, expectedUserID) + << "id " << qUtf8Printable(actualUserID) << " (" + << qUtf8Printable(input) << ") did not match expected value " + << qUtf8Printable(expectedUserID); + } +} + TEST(UtilTwitch, UserLoginRegexp) { struct TestCase { From c8e03b4ad7fbdc3a2c3ff8a2415bf1e13f61078d Mon Sep 17 00:00:00 2001 From: kornes <28986062+kornes@users.noreply.github.com> Date: Wed, 8 Nov 2023 21:19:18 +0000 Subject: [PATCH 06/26] Dont invalidate paint buffer when selecting (#4911) --- CHANGELOG.md | 2 +- src/messages/layouts/MessageLayout.cpp | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 36d824972e1..130cfb8cf20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,7 +52,7 @@ - Dev: Add a compile-time flag `USE_SYSTEM_MINIAUDIO` which can be turned on to use the system miniaudio. (#4867) - Dev: Update vcpkg to use Qt6. (#4872) - Dev: Replace `boost::optional` with `std::optional`. (#4877) -- Dev: Improve performance by reducing repaints caused by selections. (#4889) +- Dev: Improve performance of selecting text. (#4889, #4911) - Dev: Removed direct dependency on Qt 5 compatibility module. (#4906) - Dev: Refactor `DebugCount` and add copy button to debug popup. (#4921) - Dev: Changed lifetime of context menus. (#4924) diff --git a/src/messages/layouts/MessageLayout.cpp b/src/messages/layouts/MessageLayout.cpp index a2d961bdd0f..d80eba7d535 100644 --- a/src/messages/layouts/MessageLayout.cpp +++ b/src/messages/layouts/MessageLayout.cpp @@ -200,7 +200,7 @@ void MessageLayout::paint(const MessagePaintContext &ctx) { QPixmap *pixmap = this->ensureBuffer(ctx.painter, ctx.canvasWidth); - if (!this->bufferValid_ || !ctx.selection.isEmpty()) + if (!this->bufferValid_) { this->updateBuffer(pixmap, ctx); } From 8ca11ed6a5d9186e8d44324ca2c96ef1c6becc1a Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 10 Nov 2023 08:25:16 +0100 Subject: [PATCH 07/26] chore(deps): bump lib/miniaudio from `b19cc09` to `3b50a85` (#4955) Bumps [lib/miniaudio](https://github.com/mackron/miniaudio) from `b19cc09` to `3b50a85`. - [Commits](https://github.com/mackron/miniaudio/compare/b19cc09fd06b80f370ca4385d260df7e31925b50...3b50a854ec16c273a6bafd13cfd1ef159e48ce7e) --- updated-dependencies: - dependency-name: lib/miniaudio dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- lib/miniaudio | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/miniaudio b/lib/miniaudio index b19cc09fd06..3b50a854ec1 160000 --- a/lib/miniaudio +++ b/lib/miniaudio @@ -1 +1 @@ -Subproject commit b19cc09fd06b80f370ca4385d260df7e31925b50 +Subproject commit 3b50a854ec16c273a6bafd13cfd1ef159e48ce7e From 423829be43ebbe132760d23b468421c51ef6ceb0 Mon Sep 17 00:00:00 2001 From: pajlada Date: Fri, 10 Nov 2023 19:18:20 +0100 Subject: [PATCH 08/26] feat: `/unban` and `/untimeout` by id (#4956) --- CHANGELOG.md | 2 +- .../commands/builtin/twitch/Unban.cpp | 173 ++++++++++-------- 2 files changed, 98 insertions(+), 77 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 130cfb8cf20..17ed858d2e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - Minor: The account switcher is now styled to match your theme. (#4817) - Minor: Add an invisible resize handle to the bottom of frameless user info popups and reply thread popups. (#4795) - Minor: The installer now checks for the VC Runtime version and shows more info when it's outdated. (#4847) -- Minor: Allow running `/ban` and `/timeout` on User IDs by using the `id:123` syntax (e.g. `/timeout id:22484632 1m stop winning`). (#4945) +- Minor: Allow running `/ban`, `/timeout`, `/unban`, and `/untimeout` on User IDs by using the `id:123` syntax (e.g. `/timeout id:22484632 1m stop winning`). (#4945, #4956) - Minor: The `/usercard` command now accepts user ids. (#4934) - Minor: Add menu actions to reply directly to a message or the original thread root. (#4923) - Minor: The `/reply` command now replies to the latest message of the user. (#4919) diff --git a/src/controllers/commands/builtin/twitch/Unban.cpp b/src/controllers/commands/builtin/twitch/Unban.cpp index 02b942494b7..b43ad550058 100644 --- a/src/controllers/commands/builtin/twitch/Unban.cpp +++ b/src/controllers/commands/builtin/twitch/Unban.cpp @@ -8,6 +8,79 @@ #include "providers/twitch/TwitchChannel.hpp" #include "util/Twitch.hpp" +namespace { + +using namespace chatterino; + +void unbanUserByID(const ChannelPtr &channel, + const TwitchChannel *twitchChannel, + const QString &sourceUserID, const QString &targetUserID, + const QString &displayName) +{ + getHelix()->unbanUser( + twitchChannel->roomId(), sourceUserID, targetUserID, + [] { + // No response for unbans, they're emitted over pubsub/IRC instead + }, + [channel, displayName](auto error, auto message) { + using Error = HelixUnbanUserError; + + QString errorMessage = QString("Failed to unban user - "); + + switch (error) + { + case Error::ConflictingOperation: { + errorMessage += "There was a conflicting ban operation on " + "this user. Please try again."; + } + break; + + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Ratelimited: { + errorMessage += "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::TargetNotBanned: { + // Equivalent IRC error + errorMessage = + QString("%1 is not banned from this channel.") + .arg(displayName); + } + break; + + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + + channel->addMessage(makeSystemMessage(errorMessage)); + }); +} + +} // namespace + namespace chatterino::commands { QString unbanUser(const CommandContext &ctx) @@ -41,82 +114,30 @@ QString unbanUser(const CommandContext &ctx) return ""; } - auto target = ctx.words.at(1); - stripChannelName(target); - - getHelix()->getUserByName( - target, - [channel{ctx.channel}, currentUser, twitchChannel{ctx.twitchChannel}, - target](const auto &targetUser) { - getHelix()->unbanUser( - twitchChannel->roomId(), currentUser->getUserId(), - targetUser.id, - [] { - // No response for unbans, they're emitted over pubsub/IRC instead - }, - [channel, target, targetUser](auto error, auto message) { - using Error = HelixUnbanUserError; - - QString errorMessage = QString("Failed to unban user - "); - - switch (error) - { - case Error::ConflictingOperation: { - errorMessage += - "There was a conflicting ban operation on " - "this user. Please try again."; - } - break; - - case Error::Forwarded: { - errorMessage += message; - } - break; - - case Error::Ratelimited: { - errorMessage += - "You are being ratelimited by Twitch. Try " - "again in a few seconds."; - } - break; - - case Error::TargetNotBanned: { - // Equivalent IRC error - errorMessage = - QString("%1 is not banned from this channel.") - .arg(targetUser.displayName); - } - break; - - case Error::UserMissingScope: { - // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE - errorMessage += "Missing required scope. " - "Re-login with your " - "account and try again."; - } - break; - - case Error::UserNotAuthorized: { - // TODO(pajlada): Phrase MISSING_PERMISSION - errorMessage += "You don't have permission to " - "perform that action."; - } - break; - - case Error::Unknown: { - errorMessage += "An unknown error has occurred."; - } - break; - } - - channel->addMessage(makeSystemMessage(errorMessage)); - }); - }, - [channel{ctx.channel}, target] { - // Equivalent error from IRC - channel->addMessage( - makeSystemMessage(QString("Invalid username: %1").arg(target))); - }); + const auto &rawTarget = ctx.words.at(1); + auto [targetUserName, targetUserID] = parseUserNameOrID(rawTarget); + + if (!targetUserID.isEmpty()) + { + unbanUserByID(ctx.channel, ctx.twitchChannel, currentUser->getUserId(), + targetUserID, targetUserID); + } + else + { + getHelix()->getUserByName( + targetUserName, + [channel{ctx.channel}, currentUser, + twitchChannel{ctx.twitchChannel}, + targetUserName{targetUserName}](const auto &targetUser) { + unbanUserByID(channel, twitchChannel, currentUser->getUserId(), + targetUser.id, targetUser.displayName); + }, + [channel{ctx.channel}, targetUserName{targetUserName}] { + // Equivalent error from IRC + channel->addMessage(makeSystemMessage( + QString("Invalid username: %1").arg(targetUserName))); + }); + } return ""; } From 244efaa0a960857977bdfc0a303057d498700e24 Mon Sep 17 00:00:00 2001 From: pajlada Date: Fri, 10 Nov 2023 19:46:28 +0100 Subject: [PATCH 09/26] fix: /banid and /ban id: performing duplicate bans (#4957) --- CHANGELOG.md | 2 +- src/controllers/commands/builtin/twitch/Ban.cpp | 11 ----------- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 17ed858d2e3..b47137645e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ - Minor: The account switcher is now styled to match your theme. (#4817) - Minor: Add an invisible resize handle to the bottom of frameless user info popups and reply thread popups. (#4795) - Minor: The installer now checks for the VC Runtime version and shows more info when it's outdated. (#4847) -- Minor: Allow running `/ban`, `/timeout`, `/unban`, and `/untimeout` on User IDs by using the `id:123` syntax (e.g. `/timeout id:22484632 1m stop winning`). (#4945, #4956) +- Minor: Allow running `/ban`, `/timeout`, `/unban`, and `/untimeout` on User IDs by using the `id:123` syntax (e.g. `/timeout id:22484632 1m stop winning`). (#4945, #4956, #4957) - Minor: The `/usercard` command now accepts user ids. (#4934) - Minor: Add menu actions to reply directly to a message or the original thread root. (#4923) - Minor: The `/reply` command now replies to the latest message of the user. (#4919) diff --git a/src/controllers/commands/builtin/twitch/Ban.cpp b/src/controllers/commands/builtin/twitch/Ban.cpp index 2ecec39b8d0..b2589d79bca 100644 --- a/src/controllers/commands/builtin/twitch/Ban.cpp +++ b/src/controllers/commands/builtin/twitch/Ban.cpp @@ -163,17 +163,6 @@ QString sendBan(const CommandContext &ctx) { banUserByID(channel, twitchChannel, currentUser->getUserId(), targetUserID, reason, targetUserID); - getHelix()->banUser( - twitchChannel->roomId(), currentUser->getUserId(), targetUserID, - std::nullopt, reason, - [] { - // No response for bans, they're emitted over pubsub/IRC instead - }, - [channel, targetUserID{targetUserID}](auto error, auto message) { - auto errorMessage = - formatBanTimeoutError("ban", error, message, targetUserID); - channel->addMessage(makeSystemMessage(errorMessage)); - }); } else { From 95620e6e10b05816d3b8828590d19cd0a4a5b823 Mon Sep 17 00:00:00 2001 From: pajlada Date: Sat, 11 Nov 2023 11:58:20 +0100 Subject: [PATCH 10/26] fix: Split input sometimes not accepting focus (#4958) --- CHANGELOG.md | 1 + src/widgets/splits/SplitInput.cpp | 2 ++ 2 files changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b47137645e9..1ee48efd67e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ - Bugfix: Fixed capitalized channel names in log inclusion list not being logged. (#4848) - Bugfix: Trimmed custom streamlink paths on all platforms making sure you don't accidentally add spaces at the beginning or end of its path. (#4834) - Bugfix: Fixed a performance issue when displaying replies to certain messages. (#4807) +- Bugfix: Fixed an issue where certain parts of the split input wouldn't focus the split when clicked. (#4958) - Bugfix: Fixed a data race when disconnecting from Twitch PubSub. (#4771) - Bugfix: Fixed `/shoutout` command not working with usernames starting with @'s (e.g. `/shoutout @forsen`). (#4800) - Bugfix: Fixed Usercard popup not floating on tiling WMs on Linux when "Automatically close user popup when it loses focus" setting is enabled. (#3511) diff --git a/src/widgets/splits/SplitInput.cpp b/src/widgets/splits/SplitInput.cpp index 071b0076e3f..2d94314acd6 100644 --- a/src/widgets/splits/SplitInput.cpp +++ b/src/widgets/splits/SplitInput.cpp @@ -691,6 +691,8 @@ void SplitInput::installKeyPressedEvent() void SplitInput::mousePressEvent(QMouseEvent *event) { + this->giveFocus(Qt::MouseFocusReason); + if (this->hidden) { BaseWidget::mousePressEvent(event); From 6faf63c5c4bf368bb594688362afdda56c93f911 Mon Sep 17 00:00:00 2001 From: nerix Date: Sun, 12 Nov 2023 14:51:51 +0100 Subject: [PATCH 11/26] refactor: Remove `Outcome` from network requests (#4959) --- CHANGELOG.md | 1 + src/common/NetworkCommon.hpp | 3 +- src/common/NetworkPrivate.cpp | 1 - .../notifications/NotificationController.cpp | 2 - src/messages/Image.cpp | 15 +- src/providers/IvrApi.cpp | 9 +- src/providers/LinkResolver.cpp | 6 +- src/providers/bttv/BttvEmotes.cpp | 7 +- src/providers/chatterino/ChatterinoBadges.cpp | 5 +- src/providers/ffz/FfzBadges.cpp | 5 +- src/providers/ffz/FfzEmotes.cpp | 9 +- src/providers/recentmessages/Api.cpp | 6 +- src/providers/seventv/SeventvAPI.cpp | 26 ++- src/providers/twitch/TwitchAccount.cpp | 2 - src/providers/twitch/TwitchBadges.cpp | 11 +- src/providers/twitch/TwitchChannel.cpp | 10 +- src/providers/twitch/TwitchChannel.hpp | 1 - src/providers/twitch/api/Helix.cpp | 165 +++++++----------- src/singletons/Updates.cpp | 27 ++- src/util/NuulsUploader.cpp | 4 +- src/widgets/splits/SplitHeader.cpp | 3 +- tests/src/NetworkRequest.cpp | 32 ++-- 22 files changed, 122 insertions(+), 228 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ee48efd67e..dfcf3aa57d8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ - Dev: Refactor `ChannelView`, removing a bunch of clang-tidy warnings. (#4926) - Dev: Refactor `IrcMessageHandler`, removing a bunch of clang-tidy warnings & changing its public API. (#4927) - Dev: `Details` file properties tab is now populated on Windows. (#4912) +- Dev: Removed `Outcome` from network requests. (#4959) ## 2.4.6 diff --git a/src/common/NetworkCommon.hpp b/src/common/NetworkCommon.hpp index 40b034cbd99..41cf2e85e94 100644 --- a/src/common/NetworkCommon.hpp +++ b/src/common/NetworkCommon.hpp @@ -9,10 +9,9 @@ class QNetworkReply; namespace chatterino { -class Outcome; class NetworkResult; -using NetworkSuccessCallback = std::function; +using NetworkSuccessCallback = std::function; using NetworkErrorCallback = std::function; using NetworkReplyCreatedCallback = std::function; using NetworkFinallyCallback = std::function; diff --git a/src/common/NetworkPrivate.cpp b/src/common/NetworkPrivate.cpp index 661b2eccf47..04f0c4b0b87 100644 --- a/src/common/NetworkPrivate.cpp +++ b/src/common/NetworkPrivate.cpp @@ -2,7 +2,6 @@ #include "common/NetworkManager.hpp" #include "common/NetworkResult.hpp" -#include "common/Outcome.hpp" #include "common/QLogging.hpp" #include "debug/AssertInGuiThread.hpp" #include "singletons/Paths.hpp" diff --git a/src/controllers/notifications/NotificationController.cpp b/src/controllers/notifications/NotificationController.cpp index 1fb06b2bda4..e14d23e06d3 100644 --- a/src/controllers/notifications/NotificationController.cpp +++ b/src/controllers/notifications/NotificationController.cpp @@ -1,8 +1,6 @@ #include "controllers/notifications/NotificationController.hpp" #include "Application.hpp" -#include "common/NetworkRequest.hpp" -#include "common/Outcome.hpp" #include "common/QLogging.hpp" #include "controllers/notifications/NotificationModel.hpp" #include "controllers/sound/SoundController.hpp" diff --git a/src/messages/Image.cpp b/src/messages/Image.cpp index 3736250708c..da606ef6e74 100644 --- a/src/messages/Image.cpp +++ b/src/messages/Image.cpp @@ -4,7 +4,6 @@ #include "common/Common.hpp" #include "common/NetworkRequest.hpp" #include "common/NetworkResult.hpp" -#include "common/Outcome.hpp" #include "common/QLogging.hpp" #include "debug/AssertInGuiThread.hpp" #include "debug/Benchmark.hpp" @@ -502,11 +501,11 @@ void Image::actuallyLoad() NetworkRequest(this->url().string) .concurrent() .cache() - .onSuccess([weak](auto result) -> Outcome { + .onSuccess([weak](auto result) { auto shared = weak.lock(); if (!shared) { - return Failure; + return; } auto data = result.getData(); @@ -521,14 +520,14 @@ void Image::actuallyLoad() qCDebug(chatterinoImage) << "Error: image cant be read " << shared->url().string; shared->empty_ = true; - return Failure; + return; } const auto size = reader.size(); if (size.isEmpty()) { shared->empty_ = true; - return Failure; + return; } // returns 1 for non-animated formats @@ -538,7 +537,7 @@ void Image::actuallyLoad() << "Error: image has less than 1 frame " << shared->url().string << ": " << reader.errorString(); shared->empty_ = true; - return Failure; + return; } // use "double" to prevent int overflows @@ -549,7 +548,7 @@ void Image::actuallyLoad() qCDebug(chatterinoImage) << "image too large in RAM"; shared->empty_ = true; - return Failure; + return; } auto parsed = detail::readFrames(reader, shared->url()); @@ -562,8 +561,6 @@ void Image::actuallyLoad() std::forward(frames)); } })); - - return Success; }) .onError([weak](auto /*result*/) { auto shared = weak.lock(); diff --git a/src/providers/IvrApi.cpp b/src/providers/IvrApi.cpp index 868a9ff082b..6e2e1e7b69b 100644 --- a/src/providers/IvrApi.cpp +++ b/src/providers/IvrApi.cpp @@ -1,7 +1,6 @@ #include "IvrApi.hpp" #include "common/NetworkResult.hpp" -#include "common/Outcome.hpp" #include "common/QLogging.hpp" #include @@ -18,12 +17,10 @@ void IvrApi::getSubage(QString userName, QString channelName, this->makeRequest( QString("twitch/subage/%1/%2").arg(userName).arg(channelName), {}) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + .onSuccess([successCallback, failureCallback](auto result) { auto root = result.parseJson(); successCallback(root); - - return Success; }) .onError([failureCallback](auto result) { qCWarning(chatterinoIvr) @@ -42,12 +39,10 @@ void IvrApi::getBulkEmoteSets(QString emoteSetList, urlQuery.addQueryItem("set_id", emoteSetList); this->makeRequest("twitch/emotes/sets", urlQuery) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + .onSuccess([successCallback, failureCallback](auto result) { auto root = result.parseJsonArray(); successCallback(root); - - return Success; }) .onError([failureCallback](auto result) { qCWarning(chatterinoIvr) diff --git a/src/providers/LinkResolver.cpp b/src/providers/LinkResolver.cpp index 320fe569c4d..e5f43ede013 100644 --- a/src/providers/LinkResolver.cpp +++ b/src/providers/LinkResolver.cpp @@ -3,7 +3,6 @@ #include "common/Env.hpp" #include "common/NetworkRequest.hpp" #include "common/NetworkResult.hpp" -#include "common/Outcome.hpp" #include "messages/Image.hpp" #include "messages/Link.hpp" #include "singletons/Settings.hpp" @@ -27,8 +26,7 @@ void LinkResolver::getLinkInfo( QUrl::toPercentEncoding(url, "", "/:")))) .caller(caller) .timeout(30000) - .onSuccess([successCallback, - url](NetworkResult result) mutable -> Outcome { + .onSuccess([successCallback, url](NetworkResult result) mutable { auto root = result.parseJson(); auto statusCode = root.value("status").toInt(); QString response; @@ -54,8 +52,6 @@ void LinkResolver::getLinkInfo( } successCallback(QUrl::fromPercentEncoding(response.toUtf8()), Link(Link::Url, linkString), thumbnail); - - return Success; }) .onError([successCallback, url](auto /*result*/) { successCallback("No link info found", Link(Link::Url, url), diff --git a/src/providers/bttv/BttvEmotes.cpp b/src/providers/bttv/BttvEmotes.cpp index 80af67403f7..358da212446 100644 --- a/src/providers/bttv/BttvEmotes.cpp +++ b/src/providers/bttv/BttvEmotes.cpp @@ -2,6 +2,7 @@ #include "common/NetworkRequest.hpp" #include "common/NetworkResult.hpp" +#include "common/Outcome.hpp" #include "common/QLogging.hpp" #include "messages/Emote.hpp" #include "messages/Image.hpp" @@ -202,7 +203,7 @@ void BttvEmotes::loadEmotes() NetworkRequest(QString(globalEmoteApiUrl)) .timeout(30000) - .onSuccess([this](auto result) -> Outcome { + .onSuccess([this](auto result) { auto emotes = this->global_.get(); auto pair = parseGlobalEmotes(result.parseJsonArray(), *emotes); if (pair.first) @@ -210,7 +211,6 @@ void BttvEmotes::loadEmotes() this->setEmotes( std::make_shared(std::move(pair.second))); } - return pair.first; }) .execute(); } @@ -229,7 +229,7 @@ void BttvEmotes::loadChannel(std::weak_ptr channel, NetworkRequest(QString(bttvChannelEmoteApiUrl) + channelId) .timeout(20000) .onSuccess([callback = std::move(callback), channel, channelDisplayName, - manualRefresh](auto result) -> Outcome { + manualRefresh](auto result) { auto pair = parseChannelEmotes(result.parseJson(), channelDisplayName); bool hasEmotes = false; @@ -251,7 +251,6 @@ void BttvEmotes::loadChannel(std::weak_ptr channel, makeSystemMessage(CHANNEL_HAS_NO_EMOTES)); } } - return pair.first; }) .onError([channelId, channel, manualRefresh](auto result) { auto shared = channel.lock(); diff --git a/src/providers/chatterino/ChatterinoBadges.cpp b/src/providers/chatterino/ChatterinoBadges.cpp index 9ea1abeafe9..9e46873a996 100644 --- a/src/providers/chatterino/ChatterinoBadges.cpp +++ b/src/providers/chatterino/ChatterinoBadges.cpp @@ -2,7 +2,6 @@ #include "common/NetworkRequest.hpp" #include "common/NetworkResult.hpp" -#include "common/Outcome.hpp" #include "messages/Emote.hpp" #include @@ -39,7 +38,7 @@ void ChatterinoBadges::loadChatterinoBadges() NetworkRequest(url) .concurrent() - .onSuccess([this](auto result) -> Outcome { + .onSuccess([this](auto result) { auto jsonRoot = result.parseJson(); std::unique_lock lock(this->mutex_); @@ -64,8 +63,6 @@ void ChatterinoBadges::loadChatterinoBadges() } ++index; } - - return Success; }) .execute(); } diff --git a/src/providers/ffz/FfzBadges.cpp b/src/providers/ffz/FfzBadges.cpp index a70007ac253..e48e28f92ee 100644 --- a/src/providers/ffz/FfzBadges.cpp +++ b/src/providers/ffz/FfzBadges.cpp @@ -2,7 +2,6 @@ #include "common/NetworkRequest.hpp" #include "common/NetworkResult.hpp" -#include "common/Outcome.hpp" #include "messages/Emote.hpp" #include "providers/ffz/FfzUtil.hpp" @@ -59,7 +58,7 @@ void FfzBadges::load() static QUrl url("https://api.frankerfacez.com/v1/badges/ids"); NetworkRequest(url) - .onSuccess([this](auto result) -> Outcome { + .onSuccess([this](auto result) { std::unique_lock lock(this->mutex_); auto jsonRoot = result.parseJson(); @@ -103,8 +102,6 @@ void FfzBadges::load() } } } - - return Success; }) .execute(); } diff --git a/src/providers/ffz/FfzEmotes.cpp b/src/providers/ffz/FfzEmotes.cpp index 6595a3678b6..600c05ac193 100644 --- a/src/providers/ffz/FfzEmotes.cpp +++ b/src/providers/ffz/FfzEmotes.cpp @@ -2,7 +2,6 @@ #include "common/NetworkRequest.hpp" #include "common/NetworkResult.hpp" -#include "common/Outcome.hpp" #include "common/QLogging.hpp" #include "messages/Emote.hpp" #include "messages/Image.hpp" @@ -197,11 +196,9 @@ void FfzEmotes::loadEmotes() NetworkRequest(url) .timeout(30000) - .onSuccess([this](auto result) -> Outcome { + .onSuccess([this](auto result) { auto parsedSet = parseGlobalEmotes(result.parseJson()); this->setEmotes(std::make_shared(std::move(parsedSet))); - - return Success; }) .execute(); } @@ -227,7 +224,7 @@ void FfzEmotes::loadChannel( .onSuccess([emoteCallback = std::move(emoteCallback), modBadgeCallback = std::move(modBadgeCallback), vipBadgeCallback = std::move(vipBadgeCallback), channel, - manualRefresh](const auto &result) -> Outcome { + manualRefresh](const auto &result) { const auto json = result.parseJson(); auto emoteMap = parseChannelEmotes(json); @@ -254,8 +251,6 @@ void FfzEmotes::loadChannel( makeSystemMessage(CHANNEL_HAS_NO_EMOTES)); } } - - return Success; }) .onError([channelID, channel, manualRefresh](const auto &result) { auto shared = channel.lock(); diff --git a/src/providers/recentmessages/Api.cpp b/src/providers/recentmessages/Api.cpp index a667c0cbb6e..aad37234ee4 100644 --- a/src/providers/recentmessages/Api.cpp +++ b/src/providers/recentmessages/Api.cpp @@ -26,11 +26,11 @@ void load(const QString &channelName, std::weak_ptr channelPtr, const auto url = constructRecentMessagesUrl(channelName); NetworkRequest(url) - .onSuccess([channelPtr, onLoaded](const auto &result) -> Outcome { + .onSuccess([channelPtr, onLoaded](const auto &result) { auto shared = channelPtr.lock(); if (!shared) { - return Failure; + return; } qCDebug(LOG) << "Successfully loaded recent messages for" @@ -65,8 +65,6 @@ void load(const QString &channelName, std::weak_ptr channelPtr, onLoaded(messages); }); - - return Success; }) .onError([channelPtr, onError](const NetworkResult &result) { auto shared = channelPtr.lock(); diff --git a/src/providers/seventv/SeventvAPI.cpp b/src/providers/seventv/SeventvAPI.cpp index 265c420e103..67999a9928a 100644 --- a/src/providers/seventv/SeventvAPI.cpp +++ b/src/providers/seventv/SeventvAPI.cpp @@ -3,7 +3,6 @@ #include "common/Literals.hpp" #include "common/NetworkRequest.hpp" #include "common/NetworkResult.hpp" -#include "common/Outcome.hpp" namespace { @@ -24,12 +23,11 @@ void SeventvAPI::getUserByTwitchID( { NetworkRequest(API_URL_USER.arg(twitchID), NetworkRequestType::Get) .timeout(20000) - .onSuccess([callback = std::move(onSuccess)]( - const NetworkResult &result) -> Outcome { - auto json = result.parseJson(); - callback(json); - return Success; - }) + .onSuccess( + [callback = std::move(onSuccess)](const NetworkResult &result) { + auto json = result.parseJson(); + callback(json); + }) .onError([callback = std::move(onError)](const NetworkResult &result) { callback(result); }) @@ -42,12 +40,11 @@ void SeventvAPI::getEmoteSet(const QString &emoteSet, { NetworkRequest(API_URL_EMOTE_SET.arg(emoteSet), NetworkRequestType::Get) .timeout(25000) - .onSuccess([callback = std::move(onSuccess)]( - const NetworkResult &result) -> Outcome { - auto json = result.parseJson(); - callback(json); - return Success; - }) + .onSuccess( + [callback = std::move(onSuccess)](const NetworkResult &result) { + auto json = result.parseJson(); + callback(json); + }) .onError([callback = std::move(onError)](const NetworkResult &result) { callback(result); }) @@ -72,9 +69,8 @@ void SeventvAPI::updatePresence(const QString &twitchChannelID, NetworkRequestType::Post) .json(payload) .timeout(10000) - .onSuccess([callback = std::move(onSuccess)](const auto &) -> Outcome { + .onSuccess([callback = std::move(onSuccess)](const auto &) { callback(); - return Success; }) .onError([callback = std::move(onError)](const NetworkResult &result) { callback(result); diff --git a/src/providers/twitch/TwitchAccount.cpp b/src/providers/twitch/TwitchAccount.cpp index dc435fef47f..8e32d27b4a9 100644 --- a/src/providers/twitch/TwitchAccount.cpp +++ b/src/providers/twitch/TwitchAccount.cpp @@ -4,7 +4,6 @@ #include "common/Channel.hpp" #include "common/Env.hpp" #include "common/NetworkResult.hpp" -#include "common/Outcome.hpp" #include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" #include "debug/AssertInGuiThread.hpp" @@ -500,7 +499,6 @@ void TwitchAccount::loadSeventvUserID() { this->seventvUserID_ = id; } - return Success; }, [](const auto &result) { qCDebug(chatterinoSeventv) diff --git a/src/providers/twitch/TwitchBadges.cpp b/src/providers/twitch/TwitchBadges.cpp index 1637077e47c..14f0b758c31 100644 --- a/src/providers/twitch/TwitchBadges.cpp +++ b/src/providers/twitch/TwitchBadges.cpp @@ -2,7 +2,6 @@ #include "common/NetworkRequest.hpp" #include "common/NetworkResult.hpp" -#include "common/Outcome.hpp" #include "common/QLogging.hpp" #include "messages/Emote.hpp" #include "messages/Image.hpp" @@ -238,7 +237,7 @@ void TwitchBadges::loadEmoteImage(const QString &name, ImagePtr image, NetworkRequest(image->url().string) .concurrent() .cache() - .onSuccess([this, name, callback](auto result) -> Outcome { + .onSuccess([this, name, callback](auto result) { auto data = result.getData(); // const cast since we are only reading from it @@ -248,18 +247,18 @@ void TwitchBadges::loadEmoteImage(const QString &name, ImagePtr image, if (!reader.canRead() || reader.size().isEmpty()) { - return Failure; + return; } QImage image = reader.read(); if (image.isNull()) { - return Failure; + return; } if (reader.imageCount() <= 0) { - return Failure; + return; } auto icon = std::make_shared(QPixmap::fromImage(image)); @@ -270,8 +269,6 @@ void TwitchBadges::loadEmoteImage(const QString &name, ImagePtr image, } callback(name, icon); - - return Success; }) .execute(); } diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index bf5762574ae..72e84da045f 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -1381,11 +1381,11 @@ void TwitchChannel::refreshCheerEmotes() getHelix()->getCheermotes( this->roomId(), [this, weak = weakOf(this)]( - const std::vector &cheermoteSets) -> Outcome { + const std::vector &cheermoteSets) { auto shared = weak.lock(); if (!shared) { - return Failure; + return; } std::vector emoteSets; @@ -1444,12 +1444,9 @@ void TwitchChannel::refreshCheerEmotes() } *this->cheerEmoteSets_.access() = std::move(emoteSets); - - return Success; }, [] { // Failure - return Failure; }); } @@ -1656,11 +1653,10 @@ void TwitchChannel::updateSevenTVActivity() std::dynamic_pointer_cast(chan.lock()); if (!self) { - return Success; + return; } self->nextSeventvActivity_ = QDateTime::currentDateTimeUtc().addSecs(60); - return Success; }, [](const auto &result) { qCDebug(chatterinoSeventv) diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index d3636c9c96f..d9e5d3e4117 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -5,7 +5,6 @@ #include "common/Channel.hpp" #include "common/ChannelChatters.hpp" #include "common/Common.hpp" -#include "common/Outcome.hpp" #include "common/UniqueAccess.hpp" #include "providers/twitch/TwitchEmotes.hpp" #include "util/QStringHash.hpp" diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 893cdee10b0..758efe9ff12 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -3,7 +3,6 @@ #include "common/Literals.hpp" #include "common/NetworkRequest.hpp" #include "common/NetworkResult.hpp" -#include "common/Outcome.hpp" #include "common/QLogging.hpp" #include "util/CancellationToken.hpp" @@ -57,14 +56,14 @@ void Helix::fetchUsers(QStringList userIds, QStringList userLogins, // TODO: set on success and on error this->makeGet("users", urlQuery) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + .onSuccess([successCallback, failureCallback](auto result) { auto root = result.parseJson(); auto data = root.value("data"); if (!data.isArray()) { failureCallback(); - return Failure; + return; } std::vector users; @@ -75,8 +74,6 @@ void Helix::fetchUsers(QStringList userIds, QStringList userLogins, } successCallback(users); - - return Success; }) .onError([failureCallback](auto /*result*/) { // TODO: make better xd @@ -138,15 +135,14 @@ void Helix::getChannelFollowers( // TODO: set on success and on error this->makeGet("channels/followers", urlQuery) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + .onSuccess([successCallback, failureCallback](auto result) { auto root = result.parseJson(); if (root.empty()) { failureCallback("Bad JSON response"); - return Failure; + return; } successCallback(HelixGetChannelFollowersResponse(root)); - return Success; }) .onError([failureCallback](auto result) { auto root = result.parseJson(); @@ -182,14 +178,14 @@ void Helix::fetchStreams( // TODO: set on success and on error this->makeGet("streams", urlQuery) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + .onSuccess([successCallback, failureCallback](auto result) { auto root = result.parseJson(); auto data = root.value("data"); if (!data.isArray()) { failureCallback(); - return Failure; + return; } std::vector streams; @@ -200,8 +196,6 @@ void Helix::fetchStreams( } successCallback(streams); - - return Success; }) .onError([failureCallback](auto /*result*/) { // TODO: make better xd @@ -275,14 +269,14 @@ void Helix::fetchGames(QStringList gameIds, QStringList gameNames, // TODO: set on success and on error this->makeGet("games", urlQuery) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + .onSuccess([successCallback, failureCallback](auto result) { auto root = result.parseJson(); auto data = root.value("data"); if (!data.isArray()) { failureCallback(); - return Failure; + return; } std::vector games; @@ -293,8 +287,6 @@ void Helix::fetchGames(QStringList gameIds, QStringList gameNames, } successCallback(games); - - return Success; }) .onError([failureCallback](auto /*result*/) { // TODO: make better xd @@ -311,14 +303,14 @@ void Helix::searchGames(QString gameName, urlQuery.addQueryItem("query", gameName); this->makeGet("search/categories", urlQuery) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + .onSuccess([successCallback, failureCallback](auto result) { auto root = result.parseJson(); auto data = root.value("data"); if (!data.isArray()) { failureCallback(); - return Failure; + return; } std::vector games; @@ -329,8 +321,6 @@ void Helix::searchGames(QString gameName, } successCallback(games); - - return Success; }) .onError([failureCallback](auto /*result*/) { // TODO: make better xd @@ -369,20 +359,19 @@ void Helix::createClip(QString channelId, this->makePost("clips", urlQuery) .header("Content-Type", "application/json") - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + .onSuccess([successCallback, failureCallback](auto result) { auto root = result.parseJson(); auto data = root.value("data"); if (!data.isArray()) { failureCallback(HelixClipError::Unknown); - return Failure; + return; } HelixClip clip(data.toArray()[0].toObject()); successCallback(clip); - return Success; }) .onError([failureCallback](auto result) { switch (result.status().value_or(0)) @@ -425,14 +414,14 @@ void Helix::fetchChannels( } this->makeGet("channels", urlQuery) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + .onSuccess([successCallback, failureCallback](auto result) { auto root = result.parseJson(); auto data = root.value("data"); if (!data.isArray()) { failureCallback(); - return Failure; + return; } std::vector channels; @@ -443,7 +432,6 @@ void Helix::fetchChannels( } successCallback(channels); - return Success; }) .onError([failureCallback](auto /*result*/) { failureCallback(); @@ -459,20 +447,19 @@ void Helix::getChannel(QString broadcasterId, urlQuery.addQueryItem("broadcaster_id", broadcasterId); this->makeGet("channels", urlQuery) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + .onSuccess([successCallback, failureCallback](auto result) { auto root = result.parseJson(); auto data = root.value("data"); if (!data.isArray()) { failureCallback(); - return Failure; + return; } HelixChannel channel(data.toArray()[0].toObject()); successCallback(channel); - return Success; }) .onError([failureCallback](auto /*result*/) { failureCallback(); @@ -495,20 +482,19 @@ void Helix::createStreamMarker( this->makePost("streams/markers", QUrlQuery()) .json(payload) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + .onSuccess([successCallback, failureCallback](auto result) { auto root = result.parseJson(); auto data = root.value("data"); if (!data.isArray()) { failureCallback(HelixStreamMarkerError::Unknown); - return Failure; + return; } HelixStreamMarker streamMarker(data.toArray()[0].toObject()); successCallback(streamMarker); - return Success; }) .onError([failureCallback](NetworkResult result) { switch (result.status().value_or(0)) @@ -597,9 +583,8 @@ void Helix::blockUser(QString targetUserId, const QObject *caller, this->makePut("users/blocks", urlQuery) .caller(caller) - .onSuccess([successCallback](auto /*result*/) -> Outcome { + .onSuccess([successCallback](auto /*result*/) { successCallback(); - return Success; }) .onError([failureCallback](auto /*result*/) { // TODO: make better xd @@ -617,9 +602,8 @@ void Helix::unblockUser(QString targetUserId, const QObject *caller, this->makeDelete("users/blocks", urlQuery) .caller(caller) - .onSuccess([successCallback](auto /*result*/) -> Outcome { + .onSuccess([successCallback](auto /*result*/) { successCallback(); - return Success; }) .onError([failureCallback](auto /*result*/) { // TODO: make better xd @@ -657,9 +641,8 @@ void Helix::updateChannel(QString broadcasterId, QString gameId, urlQuery.addQueryItem("broadcaster_id", broadcasterId); this->makePatch("channels", urlQuery) .json(obj) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + .onSuccess([successCallback, failureCallback](auto result) { successCallback(result); - return Success; }) .onError([failureCallback](NetworkResult result) { failureCallback(); @@ -680,9 +663,8 @@ void Helix::manageAutoModMessages( this->makePost("moderation/automod/message", QUrlQuery()) .json(payload) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + .onSuccess([successCallback, failureCallback](auto result) { successCallback(); - return Success; }) .onError([failureCallback, msgID, action](NetworkResult result) { switch (result.status().value_or(0)) @@ -736,14 +718,14 @@ void Helix::getCheermotes( urlQuery.addQueryItem("broadcaster_id", broadcasterId); this->makeGet("bits/cheermotes", urlQuery) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + .onSuccess([successCallback, failureCallback](auto result) { auto root = result.parseJson(); auto data = root.value("data"); if (!data.isArray()) { failureCallback(); - return Failure; + return; } std::vector cheermoteSets; @@ -754,7 +736,6 @@ void Helix::getCheermotes( } successCallback(cheermoteSets); - return Success; }) .onError([broadcasterId, failureCallback](NetworkResult result) { qCDebug(chatterinoTwitch) @@ -774,21 +755,19 @@ void Helix::getEmoteSetData(QString emoteSetId, urlQuery.addQueryItem("emote_set_id", emoteSetId); this->makeGet("chat/emotes/set", urlQuery) - .onSuccess([successCallback, failureCallback, - emoteSetId](auto result) -> Outcome { + .onSuccess([successCallback, failureCallback, emoteSetId](auto result) { QJsonObject root = result.parseJson(); auto data = root.value("data"); if (!data.isArray() || data.toArray().isEmpty()) { failureCallback(); - return Failure; + return; } HelixEmoteSetData emoteSetData(data.toArray()[0].toObject()); successCallback(emoteSetData); - return Success; }) .onError([failureCallback](NetworkResult result) { // TODO: make better xd @@ -806,15 +785,14 @@ void Helix::getChannelEmotes( urlQuery.addQueryItem("broadcaster_id", broadcasterId); this->makeGet("chat/emotes", urlQuery) - .onSuccess([successCallback, - failureCallback](NetworkResult result) -> Outcome { + .onSuccess([successCallback, failureCallback](NetworkResult result) { QJsonObject root = result.parseJson(); auto data = root.value("data"); if (!data.isArray()) { failureCallback(); - return Failure; + return; } std::vector channelEmotes; @@ -825,7 +803,6 @@ void Helix::getChannelEmotes( } successCallback(channelEmotes); - return Success; }) .onError([failureCallback](auto result) { // TODO: make better xd @@ -847,7 +824,7 @@ void Helix::updateUserChatColor( this->makePut("chat/color", QUrlQuery()) .json(payload) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + .onSuccess([successCallback, failureCallback](auto result) { auto obj = result.parseJson(); if (result.status() != 204) { @@ -858,7 +835,6 @@ void Helix::updateUserChatColor( } successCallback(); - return Success; }) .onError([failureCallback](const auto &result) -> void { if (!result.status()) @@ -931,7 +907,7 @@ void Helix::deleteChatMessages( } this->makeDelete("moderation/chat", urlQuery) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + .onSuccess([successCallback, failureCallback](auto result) { if (result.status() != 204) { qCWarning(chatterinoTwitch) @@ -941,7 +917,6 @@ void Helix::deleteChatMessages( } successCallback(); - return Success; }) .onError([failureCallback](const auto &result) -> void { if (!result.status()) @@ -1016,7 +991,7 @@ void Helix::addChannelModerator( urlQuery.addQueryItem("user_id", userID); this->makePost("moderation/moderators", urlQuery) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + .onSuccess([successCallback, failureCallback](auto result) { if (result.status() != 204) { qCWarning(chatterinoTwitch) @@ -1026,7 +1001,6 @@ void Helix::addChannelModerator( } successCallback(); - return Success; }) .onError([failureCallback](const auto &result) -> void { if (!result.status()) @@ -1111,7 +1085,7 @@ void Helix::removeChannelModerator( urlQuery.addQueryItem("user_id", userID); this->makeDelete("moderation/moderators", urlQuery) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + .onSuccess([successCallback, failureCallback](auto result) { if (result.status() != 204) { qCWarning(chatterinoTwitch) @@ -1121,7 +1095,6 @@ void Helix::removeChannelModerator( } successCallback(); - return Success; }) .onError([failureCallback](const auto &result) -> void { if (!result.status()) @@ -1205,7 +1178,7 @@ void Helix::sendChatAnnouncement( this->makePost("chat/announcements", urlQuery) .json(body) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + .onSuccess([successCallback, failureCallback](auto result) { if (result.status() != 204) { qCWarning(chatterinoTwitch) @@ -1215,7 +1188,6 @@ void Helix::sendChatAnnouncement( } successCallback(); - return Success; }) .onError([failureCallback](const auto &result) -> void { if (!result.status()) @@ -1281,7 +1253,7 @@ void Helix::addChannelVIP( urlQuery.addQueryItem("user_id", userID); this->makePost("channels/vips", urlQuery) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + .onSuccess([successCallback, failureCallback](auto result) { if (result.status() != 204) { qCWarning(chatterinoTwitch) @@ -1291,7 +1263,6 @@ void Helix::addChannelVIP( } successCallback(); - return Success; }) .onError([failureCallback](const auto &result) -> void { if (!result.status()) @@ -1366,7 +1337,7 @@ void Helix::removeChannelVIP( urlQuery.addQueryItem("user_id", userID); this->makeDelete("channels/vips", urlQuery) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + .onSuccess([successCallback, failureCallback](auto result) { if (result.status() != 204) { qCWarning(chatterinoTwitch) @@ -1376,7 +1347,6 @@ void Helix::removeChannelVIP( } successCallback(); - return Success; }) .onError([failureCallback](const auto &result) -> void { if (!result.status()) @@ -1462,7 +1432,7 @@ void Helix::unbanUser( urlQuery.addQueryItem("user_id", userID); this->makeDelete("moderation/bans", urlQuery) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + .onSuccess([successCallback, failureCallback](auto result) { if (result.status() != 204) { qCWarning(chatterinoTwitch) @@ -1472,7 +1442,6 @@ void Helix::unbanUser( } successCallback(); - return Success; }) .onError([failureCallback](const auto &result) -> void { if (!result.status()) @@ -1574,11 +1543,9 @@ void Helix::startRaid( urlQuery.addQueryItem("to_broadcaster_id", toBroadcasterID); this->makePost("raids", urlQuery) - .onSuccess( - [successCallback, failureCallback](auto /*result*/) -> Outcome { - successCallback(); - return Success; - }) + .onSuccess([successCallback, failureCallback](auto /*result*/) { + successCallback(); + }) .onError([failureCallback](const auto &result) -> void { if (!result.status()) { @@ -1660,7 +1627,7 @@ void Helix::cancelRaid( urlQuery.addQueryItem("broadcaster_id", broadcasterID); this->makeDelete("raids", urlQuery) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + .onSuccess([successCallback, failureCallback](auto result) { if (result.status() != 204) { qCWarning(chatterinoTwitch) @@ -1670,7 +1637,6 @@ void Helix::cancelRaid( } successCallback(); - return Success; }) .onError([failureCallback](const auto &result) -> void { if (!result.status()) @@ -1828,7 +1794,7 @@ void Helix::updateChatSettings( this->makePatch("chat/settings", urlQuery) .json(payload) - .onSuccess([successCallback](auto result) -> Outcome { + .onSuccess([successCallback](auto result) { if (result.status() != 200) { qCWarning(chatterinoTwitch) @@ -1838,7 +1804,6 @@ void Helix::updateChatSettings( auto response = result.parseJson(); successCallback(HelixChatSettings( response.value("data").toArray().first().toObject())); - return Success; }) .onError([failureCallback](const auto &result) -> void { if (!result.status()) @@ -1957,7 +1922,7 @@ void Helix::fetchChatters( } this->makeGet("chat/chatters", urlQuery) - .onSuccess([successCallback](auto result) -> Outcome { + .onSuccess([successCallback](auto result) { if (result.status() != 200) { qCWarning(chatterinoTwitch) @@ -1967,7 +1932,6 @@ void Helix::fetchChatters( auto response = result.parseJson(); successCallback(HelixChatters(response)); - return Success; }) .onError([failureCallback](const auto &result) -> void { if (!result.status()) @@ -2072,7 +2036,7 @@ void Helix::fetchModerators( } this->makeGet("moderation/moderators", urlQuery) - .onSuccess([successCallback](auto result) -> Outcome { + .onSuccess([successCallback](auto result) { if (result.status() != 200) { qCWarning(chatterinoTwitch) @@ -2082,7 +2046,6 @@ void Helix::fetchModerators( auto response = result.parseJson(); successCallback(HelixModerators(response)); - return Success; }) .onError([failureCallback](const auto &result) -> void { if (!result.status()) @@ -2164,7 +2127,7 @@ void Helix::banUser(QString broadcasterID, QString moderatorID, QString userID, this->makePost("moderation/bans", urlQuery) .json(payload) - .onSuccess([successCallback](auto result) -> Outcome { + .onSuccess([successCallback](auto result) { if (result.status() != 200) { qCWarning(chatterinoTwitch) @@ -2173,7 +2136,6 @@ void Helix::banUser(QString broadcasterID, QString moderatorID, QString userID, } // we don't care about the response successCallback(); - return Success; }) .onError([failureCallback](const auto &result) -> void { if (!result.status()) @@ -2267,7 +2229,7 @@ void Helix::sendWhisper( this->makePost("whispers", urlQuery) .json(payload) - .onSuccess([successCallback](auto result) -> Outcome { + .onSuccess([successCallback](auto result) { if (result.status() != 204) { qCWarning(chatterinoTwitch) @@ -2276,7 +2238,6 @@ void Helix::sendWhisper( } // we don't care about the response successCallback(); - return Success; }) .onError([failureCallback](const auto &result) -> void { if (!result.status()) @@ -2416,7 +2377,7 @@ void Helix::getChannelVIPs( this->makeGet("channels/vips", urlQuery) .header("Content-Type", "application/json") - .onSuccess([successCallback](auto result) -> Outcome { + .onSuccess([successCallback](auto result) { if (result.status() != 200) { qCWarning(chatterinoTwitch) @@ -2433,7 +2394,6 @@ void Helix::getChannelVIPs( } successCallback(channelVips); - return Success; }) .onError([failureCallback](const auto &result) -> void { if (!result.status()) @@ -2509,18 +2469,17 @@ void Helix::startCommercial( this->makePost("channels/commercial", QUrlQuery()) .json(payload) - .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + .onSuccess([successCallback, failureCallback](auto result) { auto obj = result.parseJson(); if (obj.isEmpty()) { failureCallback( Error::Unknown, "Twitch didn't send any information about this error."); - return Failure; + return; } successCallback(HelixStartCommercialResponse(obj)); - return Success; }) .onError([failureCallback](const auto &result) -> void { if (!result.status()) @@ -2605,7 +2564,7 @@ void Helix::getGlobalBadges( using Error = HelixGetGlobalBadgesError; this->makeGet("chat/badges/global", QUrlQuery()) - .onSuccess([successCallback](auto result) -> Outcome { + .onSuccess([successCallback](auto result) { if (result.status() != 200) { qCWarning(chatterinoTwitch) @@ -2615,7 +2574,6 @@ void Helix::getGlobalBadges( auto response = result.parseJson(); successCallback(HelixGlobalBadges(response)); - return Success; }) .onError([failureCallback](const auto &result) -> void { if (!result.status()) @@ -2658,7 +2616,7 @@ void Helix::getChannelBadges( urlQuery.addQueryItem("broadcaster_id", broadcasterID); this->makeGet("chat/badges", urlQuery) - .onSuccess([successCallback](auto result) -> Outcome { + .onSuccess([successCallback](auto result) { if (result.status() != 200) { qCWarning(chatterinoTwitch) @@ -2668,7 +2626,6 @@ void Helix::getChannelBadges( auto response = result.parseJson(); successCallback(HelixChannelBadges(response)); - return Success; }) .onError([failureCallback](const auto &result) -> void { if (!result.status()) @@ -2717,7 +2674,7 @@ void Helix::updateShieldMode( this->makePut("moderation/shield_mode", urlQuery) .json(payload) - .onSuccess([successCallback](auto result) -> Outcome { + .onSuccess([successCallback](auto result) { if (result.status() != 200) { qCWarning(chatterinoTwitch) @@ -2728,7 +2685,6 @@ void Helix::updateShieldMode( const auto response = result.parseJson(); successCallback( HelixShieldModeStatus(response["data"][0].toObject())); - return Success; }) .onError([failureCallback](const auto &result) -> void { if (!result.status()) @@ -2790,7 +2746,7 @@ void Helix::sendShoutout( this->makePost("chat/shoutouts", urlQuery) .header("Content-Type", "application/json") - .onSuccess([successCallback](NetworkResult result) -> Outcome { + .onSuccess([successCallback](NetworkResult result) { if (result.status() != 204) { qCWarning(chatterinoTwitch) @@ -2799,7 +2755,6 @@ void Helix::sendShoutout( } successCallback(); - return Success; }) .onError([failureCallback](const NetworkResult &result) -> void { if (!result.status()) @@ -2944,33 +2899,33 @@ void Helix::paginate(const QString &url, const QUrlQuery &baseQuery, CancellationToken &&cancellationToken) { auto onSuccess = - std::make_shared>(nullptr); + std::make_shared>(nullptr); // This is the actual callback passed to NetworkRequest. // It wraps the shared-ptr. - auto onSuccessCb = [onSuccess](const auto &res) -> Outcome { + auto onSuccessCb = [onSuccess](const auto &res) { return (*onSuccess)(res); }; *onSuccess = [this, onPage = std::move(onPage), onError, onSuccessCb, url{url}, baseQuery{baseQuery}, - cancellationToken = std::move(cancellationToken)]( - const NetworkResult &res) -> Outcome { + cancellationToken = + std::move(cancellationToken)](const NetworkResult &res) { if (cancellationToken.isCancelled()) { - return Success; + return; } const auto json = res.parseJson(); if (!onPage(json)) { // The consumer doesn't want any more pages - return Success; + return; } auto cursor = json["pagination"_L1]["cursor"_L1].toString(); if (cursor.isEmpty()) { - return Success; + return; } auto query = baseQuery; @@ -2981,8 +2936,6 @@ void Helix::paginate(const QString &url, const QUrlQuery &baseQuery, .onSuccess(onSuccessCb) .onError(onError) .execute(); - - return Success; }; this->makeGet(url, baseQuery) diff --git a/src/singletons/Updates.cpp b/src/singletons/Updates.cpp index 54ba974d2f6..98610e575c9 100644 --- a/src/singletons/Updates.cpp +++ b/src/singletons/Updates.cpp @@ -3,7 +3,6 @@ #include "common/Modes.hpp" #include "common/NetworkRequest.hpp" #include "common/NetworkResult.hpp" -#include "common/Outcome.hpp" #include "common/QLogging.hpp" #include "common/Version.hpp" #include "Settings.hpp" @@ -122,7 +121,7 @@ void Updates::installUpdates() box->raise(); }); }) - .onSuccess([this](auto result) -> Outcome { + .onSuccess([this](auto result) { if (result.status() != 200) { auto *box = new QMessageBox( @@ -132,7 +131,7 @@ void Updates::installUpdates() .arg(result.formatError())); box->setAttribute(Qt::WA_DeleteOnClose); box->exec(); - return Failure; + return; } QByteArray object = result.getData(); @@ -145,7 +144,7 @@ void Updates::installUpdates() if (file.write(object) == -1) { this->setStatus_(WriteFileFailed); - return Failure; + return; } file.flush(); file.close(); @@ -156,7 +155,6 @@ void Updates::installUpdates() {filename, "restart"}); QApplication::exit(0); - return Success; }) .execute(); this->setStatus_(Downloading); @@ -183,7 +181,7 @@ void Updates::installUpdates() box->setAttribute(Qt::WA_DeleteOnClose); box->exec(); }) - .onSuccess([this](auto result) -> Outcome { + .onSuccess([this](auto result) { if (result.status() != 200) { auto *box = new QMessageBox( @@ -193,7 +191,7 @@ void Updates::installUpdates() .arg(result.formatError())); box->setAttribute(Qt::WA_DeleteOnClose); box->exec(); - return Failure; + return; } QByteArray object = result.getData(); @@ -216,7 +214,7 @@ void Updates::installUpdates() box->exec(); QDesktopServices::openUrl(this->updateExe_); - return Failure; + return; } file.flush(); file.close(); @@ -239,8 +237,6 @@ void Updates::installUpdates() QDesktopServices::openUrl(this->updateExe_); } - - return Success; }) .execute(); this->setStatus_(Downloading); @@ -279,7 +275,7 @@ void Updates::checkForUpdates() NetworkRequest(url) .timeout(60000) - .onSuccess([this](auto result) -> Outcome { + .onSuccess([this](auto result) { const auto object = result.parseJson(); /// Version available on every platform auto version = object["version"]; @@ -289,7 +285,7 @@ void Updates::checkForUpdates() this->setStatus_(SearchFailed); qCDebug(chatterinoUpdate) << "error checking version - missing 'version'" << object; - return Failure; + return; } # if defined Q_OS_WIN || defined Q_OS_MACOS @@ -300,7 +296,7 @@ void Updates::checkForUpdates() this->setStatus_(SearchFailed); qCDebug(chatterinoUpdate) << "error checking version - missing 'updateexe'" << object; - return Failure; + return; } this->updateExe_ = updateExeUrl.toString(); @@ -313,7 +309,7 @@ void Updates::checkForUpdates() qCDebug(chatterinoUpdate) << "error checking version - missing 'portable_download'" << object; - return Failure; + return; } this->updatePortable_ = portableUrl.toString(); # endif @@ -325,7 +321,7 @@ void Updates::checkForUpdates() this->updateGuideLink_ = updateGuide.toString(); } # else - return Failure; + return; # endif /// Current version @@ -342,7 +338,6 @@ void Updates::checkForUpdates() { this->setStatus_(NoUpdateAvailable); } - return Failure; }) .execute(); this->setStatus_(Searching); diff --git a/src/util/NuulsUploader.cpp b/src/util/NuulsUploader.cpp index a614c91bf2c..1caea7d5e93 100644 --- a/src/util/NuulsUploader.cpp +++ b/src/util/NuulsUploader.cpp @@ -156,7 +156,7 @@ void uploadImageToNuuls(RawImageData imageData, ChannelPtr channel, .headerList(extraHeaders) .multiPart(payload) .onSuccess([&textEdit, channel, - originalFilePath](NetworkResult result) -> Outcome { + originalFilePath](NetworkResult result) { QString link = getSettings()->imageUploaderLink.getValue().isEmpty() ? result.getData() : getLinkFromResponse( @@ -202,8 +202,6 @@ void uploadImageToNuuls(RawImageData imageData, ChannelPtr channel, } logToFile(originalFilePath, link, deletionLink, channel); - - return Success; }) .onError([channel](NetworkResult result) -> bool { auto errorMessage = diff --git a/src/widgets/splits/SplitHeader.cpp b/src/widgets/splits/SplitHeader.cpp index 8e5e3a0347c..887176d5c17 100644 --- a/src/widgets/splits/SplitHeader.cpp +++ b/src/widgets/splits/SplitHeader.cpp @@ -804,7 +804,7 @@ void SplitHeader::updateChannelText() { NetworkRequest(url, NetworkRequestType::Get) .caller(this) - .onSuccess([this](auto result) -> Outcome { + .onSuccess([this](auto result) { // NOTE: We do not follow the redirects, so we need to make sure we only treat code 200 as a valid image if (result.status() == 200) { @@ -816,7 +816,6 @@ void SplitHeader::updateChannelText() this->thumbnail_.clear(); } this->updateChannelText(); - return Success; }) .execute(); this->lastThumbnail_.restart(); diff --git a/tests/src/NetworkRequest.cpp b/tests/src/NetworkRequest.cpp index 048ed1c91f7..43d6a896458 100644 --- a/tests/src/NetworkRequest.cpp +++ b/tests/src/NetworkRequest.cpp @@ -2,7 +2,6 @@ #include "common/NetworkManager.hpp" #include "common/NetworkResult.hpp" -#include "common/Outcome.hpp" #include #include @@ -83,12 +82,10 @@ TEST(NetworkRequest, Success) RequestWaiter waiter; NetworkRequest(url) - .onSuccess( - [code, &waiter, url](const NetworkResult &result) -> Outcome { - EXPECT_EQ(result.status(), code); - waiter.requestDone(); - return Success; - }) + .onSuccess([code, &waiter, url](const NetworkResult &result) { + EXPECT_EQ(result.status(), code); + waiter.requestDone(); + }) .onError([&](const NetworkResult & /*result*/) { // The codes should *not* throw an error EXPECT_TRUE(false); @@ -143,13 +140,11 @@ TEST(NetworkRequest, Error) RequestWaiter waiter; NetworkRequest(url) - .onSuccess( - [&waiter, url](const NetworkResult & /*result*/) -> Outcome { - // The codes should throw an error - EXPECT_TRUE(false); - waiter.requestDone(); - return Success; - }) + .onSuccess([&waiter, url](const NetworkResult & /*result*/) { + // The codes should throw an error + EXPECT_TRUE(false); + waiter.requestDone(); + }) .onError([code, &waiter, url](const NetworkResult &result) { EXPECT_EQ(result.status(), code); @@ -201,11 +196,10 @@ TEST(NetworkRequest, TimeoutTimingOut) NetworkRequest(url) .timeout(1000) - .onSuccess([&waiter](const NetworkResult & /*result*/) -> Outcome { + .onSuccess([&waiter](const NetworkResult & /*result*/) { // The timeout should throw an error EXPECT_TRUE(false); waiter.requestDone(); - return Success; }) .onError([&waiter, url](const NetworkResult &result) { qDebug() << QTime::currentTime().toString() @@ -232,11 +226,10 @@ TEST(NetworkRequest, TimeoutNotTimingOut) NetworkRequest(url) .timeout(3000) - .onSuccess([&waiter, url](const NetworkResult &result) -> Outcome { + .onSuccess([&waiter, url](const NetworkResult &result) { EXPECT_EQ(result.status(), 200); waiter.requestDone(); - return Success; }) .onError([&waiter, url](const NetworkResult & /*result*/) { // The timeout should *not* throw an error @@ -263,9 +256,8 @@ TEST(NetworkRequest, FinallyCallbackOnTimeout) NetworkRequest(url) .timeout(1000) - .onSuccess([&](const NetworkResult & /*result*/) -> Outcome { + .onSuccess([&](const NetworkResult & /*result*/) { onSuccessCalled = true; - return Success; }) .onError([&](const NetworkResult &result) { onErrorCalled = true; From 49ae5a8ecb42095abe15d186052c94f8629b301f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 14 Nov 2023 12:53:10 +0100 Subject: [PATCH 12/26] chore(deps): bump actions/github-script from 6 to 7 (#4964) Bumps [actions/github-script](https://github.com/actions/github-script) from 6 to 7. - [Release notes](https://github.com/actions/github-script/releases) - [Commits](https://github.com/actions/github-script/compare/v6...v7) --- updated-dependencies: - dependency-name: actions/github-script dependency-type: direct:production update-type: version-update:semver-major ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/changelog-category-check.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/changelog-category-check.yml b/.github/workflows/changelog-category-check.yml index f8bacace78c..fb4bd9b23da 100644 --- a/.github/workflows/changelog-category-check.yml +++ b/.github/workflows/changelog-category-check.yml @@ -14,7 +14,7 @@ jobs: changelog-category-check: runs-on: ubuntu-latest steps: - - uses: actions/github-script@v6 + - uses: actions/github-script@v7 id: label-checker with: result-encoding: "string" From 5693927f4206cd709d10b91c3b17646eb7e7a56d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Nov 2023 08:53:44 +0100 Subject: [PATCH 13/26] chore(deps): bump lib/miniaudio from `3b50a85` to `4a5b74b` (#4967) Bumps [lib/miniaudio](https://github.com/mackron/miniaudio) from `3b50a85` to `4a5b74b`. - [Commits](https://github.com/mackron/miniaudio/compare/3b50a854ec16c273a6bafd13cfd1ef159e48ce7e...4a5b74bef029b3592c54b6048650ee5f972c1a48) --- updated-dependencies: - dependency-name: lib/miniaudio dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- lib/miniaudio | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/miniaudio b/lib/miniaudio index 3b50a854ec1..4a5b74bef02 160000 --- a/lib/miniaudio +++ b/lib/miniaudio @@ -1 +1 @@ -Subproject commit 3b50a854ec16c273a6bafd13cfd1ef159e48ce7e +Subproject commit 4a5b74bef029b3592c54b6048650ee5f972c1a48 From d9cdc88061f7cc03062817662bbf14683fa7db48 Mon Sep 17 00:00:00 2001 From: Mm2PL Date: Fri, 17 Nov 2023 14:46:35 +0100 Subject: [PATCH 14/26] Remove unused parseMessage function (#4968) --- src/providers/twitch/IrcMessageHandler.cpp | 33 ---------------------- 1 file changed, 33 deletions(-) diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp index 2be9d9621c6..0bfef74afb0 100644 --- a/src/providers/twitch/IrcMessageHandler.cpp +++ b/src/providers/twitch/IrcMessageHandler.cpp @@ -558,39 +558,6 @@ std::vector parsePrivMessage(Channel *channel, return builtMessages; } -/** - * Parse a single IRC message into 0 or more Chatterino messages - **/ -std::vector parseMessage(Channel *channel, - Communi::IrcMessage *message) -{ - assert(channel != nullptr); - assert(message != nullptr); - - std::vector builtMessages; - - auto command = message->command(); - - if (command == "PRIVMSG") - { - return parsePrivMessage( - channel, dynamic_cast(message)); - } - - if (command == "USERNOTICE") - { - return parseUserNoticeMessage(channel, message); - } - - if (command == "NOTICE") - { - return parseNoticeMessage( - dynamic_cast(message)); - } - - return builtMessages; -} - } // namespace namespace chatterino { From 3d9db1d5283f8847ba6312ceb9e5a0b94a0f01b2 Mon Sep 17 00:00:00 2001 From: nerix Date: Fri, 17 Nov 2023 17:39:45 +0100 Subject: [PATCH 15/26] refactor: Ignores and Replacements (#4965) Fixes a freeze from a bad regex in _Ignores_ Fixes some emotes not appearing when using _Ignores_ Fixes lookahead/-behind not working in _Ignores_ --- CHANGELOG.md | 3 + src/providers/twitch/TwitchMessageBuilder.cpp | 242 +++++++----------- 2 files changed, 99 insertions(+), 146 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfcf3aa57d8..2d9f7defe19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,9 @@ - Bugfix: Fixed a crash when clicking `More messages below` button in a usercard and closing it quickly. (#4933) - Bugfix: Fixed thread popup window missing messages for nested threads. (#4923) - Bugfix: Fixed an occasional crash for channel point redemptions with text input. (#4949) +- Bugfix: Fixed a freeze from a bad regex in _Ignores_. (#4965) +- Bugfix: Fixed some emotes not appearing when using _Ignores_. (#4965) +- Bugfix: Fixed lookahead/-behind not working in _Ignores_. (#4965) - Dev: Change clang-format from v14 to v16. (#4929) - Dev: Fixed UTF16 encoding of `modes` file for the installer. (#4791) - Dev: Temporarily disable High DPI scaling on Qt6 builds on Windows. (#4767) diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index 9605f490915..00f9bf68870 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -846,28 +846,25 @@ void TwitchMessageBuilder::appendUsername() void TwitchMessageBuilder::runIgnoreReplaces( std::vector &twitchEmotes) { + using SizeType = QString::size_type; + auto phrases = getSettings()->ignoredMessages.readOnly(); - auto removeEmotesInRange = [](int pos, int len, - auto &twitchEmotes) mutable { + auto removeEmotesInRange = [&twitchEmotes](SizeType pos, SizeType len) { + // all emotes outside the range come before `it` + // all emotes in the range start at `it` auto it = std::partition( twitchEmotes.begin(), twitchEmotes.end(), [pos, len](const auto &item) { + // returns true for emotes outside the range return !((item.start >= pos) && item.start < (pos + len)); }); - for (auto copy = it; copy != twitchEmotes.end(); ++copy) - { - if ((*copy).ptr == nullptr) - { - qCDebug(chatterinoTwitch) - << "remem nullptr" << (*copy).name.string; - } - } - std::vector v(it, twitchEmotes.end()); + std::vector emotesInRange(it, + twitchEmotes.end()); twitchEmotes.erase(it, twitchEmotes.end()); - return v; + return emotesInRange; }; - auto shiftIndicesAfter = [&twitchEmotes](int pos, int by) mutable { + auto shiftIndicesAfter = [&twitchEmotes](int pos, int by) { for (auto &item : twitchEmotes) { auto &index = item.start; @@ -881,14 +878,18 @@ void TwitchMessageBuilder::runIgnoreReplaces( auto addReplEmotes = [&twitchEmotes](const IgnorePhrase &phrase, const auto &midrepl, - int startIndex) mutable { + SizeType startIndex) { if (!phrase.containsEmote()) { return; } +#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) + auto words = midrepl.tokenize(u' '); +#else auto words = midrepl.split(' '); - int pos = 0; +#endif + SizeType pos = 0; for (const auto &word : words) { for (const auto &emote : phrase.getEmotes()) @@ -901,8 +902,9 @@ void TwitchMessageBuilder::runIgnoreReplaces( << "emote null" << emote.first.string; } twitchEmotes.push_back(TwitchEmoteOccurrence{ - startIndex + pos, - startIndex + pos + (int)emote.first.string.length(), + static_cast(startIndex + pos), + static_cast(startIndex + pos + + emote.first.string.length()), emote.second, emote.first, }); @@ -912,6 +914,63 @@ void TwitchMessageBuilder::runIgnoreReplaces( } }; + auto replaceMessageAt = [&](const IgnorePhrase &phrase, SizeType from, + SizeType length, const QString &replacement) { + auto removedEmotes = removeEmotesInRange(from, length); + this->originalMessage_.replace(from, length, replacement); + auto wordStart = from; + while (wordStart > 0) + { + if (this->originalMessage_[wordStart - 1] == ' ') + { + break; + } + --wordStart; + } + auto wordEnd = from + replacement.length(); + while (wordEnd < this->originalMessage_.length()) + { + if (this->originalMessage_[wordEnd] == ' ') + { + break; + } + ++wordEnd; + } + + shiftIndicesAfter(static_cast(from + length), + static_cast(replacement.length() - length)); + +#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) + auto midExtendedRef = QStringView{this->originalMessage_}.mid( + wordStart, wordEnd - wordStart); +#else + auto midExtendedRef = + this->originalMessage_.midRef(wordStart, wordEnd - wordStart); +#endif + + for (auto &emote : removedEmotes) + { + if (emote.ptr == nullptr) + { + qCDebug(chatterinoTwitch) + << "Invalid emote occurrence" << emote.name.string; + continue; + } + QRegularExpression emoteregex( + "\\b" + emote.name.string + "\\b", + QRegularExpression::UseUnicodePropertiesOption); + auto match = emoteregex.match(midExtendedRef); + if (match.hasMatch()) + { + emote.start = static_cast(from + match.capturedStart()); + emote.end = static_cast(from + match.capturedEnd()); + twitchEmotes.push_back(std::move(emote)); + } + } + + addReplEmotes(phrase, midExtendedRef, wordStart); + }; + for (const auto &phrase : *phrases) { if (phrase.isBlock()) @@ -930,144 +989,35 @@ void TwitchMessageBuilder::runIgnoreReplaces( { continue; } + QRegularExpressionMatch match; - int from = 0; + size_t iterations = 0; + SizeType from = 0; while ((from = this->originalMessage_.indexOf(regex, from, &match)) != -1) { - int len = match.capturedLength(); - auto vret = removeEmotesInRange(from, len, twitchEmotes); - auto mid = this->originalMessage_.mid(from, len); - mid.replace(regex, phrase.getReplace()); - - int midsize = mid.size(); - this->originalMessage_.replace(from, len, mid); - int pos1 = from; - while (pos1 > 0) - { - if (this->originalMessage_[pos1 - 1] == ' ') - { - break; - } - --pos1; - } - int pos2 = from + midsize; - while (pos2 < this->originalMessage_.length()) - { - if (this->originalMessage_[pos2] == ' ') - { - break; - } - ++pos2; - } - - shiftIndicesAfter(from + len, midsize - len); - -#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) - auto midExtendedRef = - QStringView{this->originalMessage_}.mid(pos1, pos2 - pos1); -#else - auto midExtendedRef = - this->originalMessage_.midRef(pos1, pos2 - pos1); -#endif - - for (auto &tup : vret) + replaceMessageAt(phrase, from, match.capturedLength(), + phrase.getReplace()); + from += phrase.getReplace().length(); + iterations++; + if (iterations >= 128) { - if (tup.ptr == nullptr) - { - qCDebug(chatterinoTwitch) - << "v nullptr" << tup.name.string; - continue; - } - QRegularExpression emoteregex( - "\\b" + tup.name.string + "\\b", - QRegularExpression::UseUnicodePropertiesOption); - auto _match = emoteregex.match(midExtendedRef); - if (_match.hasMatch()) - { - int last = _match.lastCapturedIndex(); - for (int i = 0; i <= last; ++i) - { - tup.start = from + _match.capturedStart(); - twitchEmotes.push_back(std::move(tup)); - } - } + this->originalMessage_ = + u"Too many replacements - check your ignores!"_s; + return; } - - addReplEmotes(phrase, midExtendedRef, pos1); - - from += midsize; } - } - else - { - int from = 0; - while ((from = this->originalMessage_.indexOf( - pattern, from, phrase.caseSensitivity())) != -1) - { - int len = pattern.size(); - auto vret = removeEmotesInRange(from, len, twitchEmotes); - auto replace = phrase.getReplace(); - - int replacesize = replace.size(); - this->originalMessage_.replace(from, len, replace); - int pos1 = from; - while (pos1 > 0) - { - if (this->originalMessage_[pos1 - 1] == ' ') - { - break; - } - --pos1; - } - int pos2 = from + replacesize; - while (pos2 < this->originalMessage_.length()) - { - if (this->originalMessage_[pos2] == ' ') - { - break; - } - ++pos2; - } - - shiftIndicesAfter(from + len, replacesize - len); - -#if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) - auto midExtendedRef = - QStringView{this->originalMessage_}.mid(pos1, pos2 - pos1); -#else - auto midExtendedRef = - this->originalMessage_.midRef(pos1, pos2 - pos1); -#endif - - for (auto &tup : vret) - { - if (tup.ptr == nullptr) - { - qCDebug(chatterinoTwitch) - << "v nullptr" << tup.name.string; - continue; - } - QRegularExpression emoteregex( - "\\b" + tup.name.string + "\\b", - QRegularExpression::UseUnicodePropertiesOption); - auto match = emoteregex.match(midExtendedRef); - if (match.hasMatch()) - { - int last = match.lastCapturedIndex(); - for (int i = 0; i <= last; ++i) - { - tup.start = from + match.capturedStart(); - twitchEmotes.push_back(std::move(tup)); - } - } - } - - addReplEmotes(phrase, midExtendedRef, pos1); + continue; + } - from += replacesize; - } + SizeType from = 0; + while ((from = this->originalMessage_.indexOf( + pattern, from, phrase.caseSensitivity())) != -1) + { + replaceMessageAt(phrase, from, pattern.length(), + phrase.getReplace()); + from += phrase.getReplace().length(); } } } From 0bdcaae5d13dbac4fa66864b91ab782502fe78eb Mon Sep 17 00:00:00 2001 From: kornes <28986062+kornes@users.noreply.github.com> Date: Sat, 18 Nov 2023 11:39:10 +0000 Subject: [PATCH 16/26] Fix: dont select mod buttons at triple click (#4961) --- CHANGELOG.md | 1 + src/messages/layouts/MessageLayoutContainer.cpp | 5 ++--- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d9f7defe19..16eab57eac0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,6 +35,7 @@ - Bugfix: Fixed a crash when clicking `More messages below` button in a usercard and closing it quickly. (#4933) - Bugfix: Fixed thread popup window missing messages for nested threads. (#4923) - Bugfix: Fixed an occasional crash for channel point redemptions with text input. (#4949) +- Bugfix: Fixed triple click on message also selecting moderation buttons. (#4961) - Bugfix: Fixed a freeze from a bad regex in _Ignores_. (#4965) - Bugfix: Fixed some emotes not appearing when using _Ignores_. (#4965) - Bugfix: Fixed lookahead/-behind not working in _Ignores_. (#4965) diff --git a/src/messages/layouts/MessageLayoutContainer.cpp b/src/messages/layouts/MessageLayoutContainer.cpp index ec2995b7d72..0b2b729b8b3 100644 --- a/src/messages/layouts/MessageLayoutContainer.cpp +++ b/src/messages/layouts/MessageLayoutContainer.cpp @@ -430,9 +430,8 @@ size_t MessageLayoutContainer::getSelectionIndex(QPoint point) const size_t MessageLayoutContainer::getFirstMessageCharacterIndex() const { static const FlagsEnum skippedFlags{ - MessageElementFlag::RepliedMessage, - MessageElementFlag::Timestamp, - MessageElementFlag::Badges, + MessageElementFlag::RepliedMessage, MessageElementFlag::Timestamp, + MessageElementFlag::ModeratorTools, MessageElementFlag::Badges, MessageElementFlag::Username, }; From 7898b97fc257f6ad9673472dfc5ebe530d759226 Mon Sep 17 00:00:00 2001 From: Wissididom <30803034+Wissididom@users.noreply.github.com> Date: Sat, 18 Nov 2023 15:13:27 +0100 Subject: [PATCH 17/26] feat: Run tests on Windows & macOS in CI (#4970) --- .github/workflows/test-macos.yml | 91 +++++++++++++++++ .github/workflows/test-windows.yml | 155 +++++++++++++++++++++++++++++ .github/workflows/test.yml | 2 +- CHANGELOG.md | 1 + 4 files changed, 248 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/test-macos.yml create mode 100644 .github/workflows/test-windows.yml diff --git a/.github/workflows/test-macos.yml b/.github/workflows/test-macos.yml new file mode 100644 index 00000000000..4d9b8c55af5 --- /dev/null +++ b/.github/workflows/test-macos.yml @@ -0,0 +1,91 @@ +--- +name: Test MacOS + +on: + pull_request: + workflow_dispatch: + merge_group: + +env: + TWITCH_PUBSUB_SERVER_IMAGE: ghcr.io/chatterino/twitch-pubsub-server-test:v1.0.6 + QT_QPA_PLATFORM: minimal + +concurrency: + group: test-macos-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-macos: + name: "Test ${{ matrix.os }}, Qt ${{ matrix.qt-version }}" + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [macos-13] + qt-version: [5.15.2, 6.5.0] + plugins: [false] + fail-fast: false + env: + C2_BUILD_WITH_QT6: ${{ startsWith(matrix.qt-version, '6.') && 'ON' || 'OFF' }} + QT_MODULES: ${{ startsWith(matrix.qt-version, '6.') && 'qt5compat qtimageformats' || '' }} + + steps: + - name: Enable plugin support + if: matrix.plugins + run: | + echo "C2_PLUGINS=ON" >> "$GITHUB_ENV" + + - name: Set BUILD_WITH_QT6 + if: startsWith(matrix.qt-version, '6.') + run: | + echo "C2_BUILD_WITH_QT6=ON" >> "$GITHUB_ENV" + + - uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 # allows for tags access + + - name: Install Qt + uses: jurplel/install-qt-action@v3.3.0 + with: + cache: true + cache-key-prefix: ${{ runner.os }}-QtCache-${{ matrix.qt-version }}-v2 + modules: ${{ env.QT_MODULES }} + version: ${{ matrix.qt-version }} + + - name: Install dependencies + run: | + brew install boost openssl rapidjson p7zip create-dmg cmake tree docker colima + + - name: Setup Colima + run: | + colima start + + - name: Build + run: | + mkdir build-test + cd build-test + cmake \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DBUILD_TESTS=On \ + -DBUILD_APP=OFF \ + -DUSE_PRECOMPILED_HEADERS=OFF \ + -DCHATTERINO_PLUGINS="$C2_PLUGINS" \ + -DBUILD_WITH_QT6="$C2_BUILD_WITH_QT6" \ + .. + make -j"$(sysctl -n hw.logicalcpu)" + + - name: Test + timeout-minutes: 30 + run: | + docker pull kennethreitz/httpbin + docker pull ${{ env.TWITCH_PUBSUB_SERVER_IMAGE }} + docker run --network=host --detach ${{ env.TWITCH_PUBSUB_SERVER_IMAGE }} + docker run -p 9051:80 --detach kennethreitz/httpbin + ctest --repeat until-pass:4 --output-on-failure --exclude-regex ClassicEmoteNameFiltering + working-directory: build-test + + - name: Post Setup Colima + if: always() + run: | + colima stop + working-directory: build-test diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml new file mode 100644 index 00000000000..bb543ff2371 --- /dev/null +++ b/.github/workflows/test-windows.yml @@ -0,0 +1,155 @@ +--- +name: Test Windows + +on: + pull_request: + workflow_dispatch: + merge_group: + +env: + TWITCH_PUBSUB_SERVER_TAG: v1.0.7 + QT_QPA_PLATFORM: minimal + # Last known good conan version + # 2.0.3 has a bug on Windows (conan-io/conan#13606) + CONAN_VERSION: 2.0.2 + +concurrency: + group: test-windows-${{ github.ref }} + cancel-in-progress: true + +jobs: + test-windows: + name: "Test ${{ matrix.os }}, Qt ${{ matrix.qt-version }}" + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [windows-latest] + qt-version: [5.15.2, 6.5.0] + plugins: [false] + skip-artifact: [false] + skip-crashpad: [false] + fail-fast: false + env: + C2_BUILD_WITH_QT6: ${{ startsWith(matrix.qt-version, '6.') && 'ON' || 'OFF' }} + QT_MODULES: ${{ startsWith(matrix.qt-version, '6.') && 'qt5compat qtimageformats' || '' }} + + steps: + - name: Enable plugin support + if: matrix.plugins + run: | + echo "C2_PLUGINS=ON" >> "$Env:GITHUB_ENV" + + - name: Set Crashpad + if: matrix.skip-crashpad == false + run: | + echo "C2_ENABLE_CRASHPAD=ON" >> "$Env:GITHUB_ENV" + + - name: Set BUILD_WITH_QT6 + if: startsWith(matrix.qt-version, '6.') + run: | + echo "C2_BUILD_WITH_QT6=ON" >> "$Env:GITHUB_ENV" + + - uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 0 # allows for tags access + + - name: Install Qt + uses: jurplel/install-qt-action@v3.3.0 + with: + cache: true + cache-key-prefix: ${{ runner.os }}-QtCache-${{ matrix.qt-version }}-v2 + modules: ${{ env.QT_MODULES }} + version: ${{ matrix.qt-version }} + + - name: Enable Developer Command Prompt + uses: ilammy/msvc-dev-cmd@v1.12.1 + + - name: Setup conan variables + if: startsWith(matrix.os, 'windows') + run: | + "C2_USE_OPENSSL3=$(if ($Env:C2_BUILD_WITH_QT6 -eq "on") { "True" } else { "False" })" >> "$Env:GITHUB_ENV" + "C2_CONAN_CACHE_SUFFIX=$(if ($Env:C2_BUILD_WITH_QT6 -eq "on") { "-QT6" } else { "`" })" >> "$Env:GITHUB_ENV" + + - name: Setup sccache + # sccache v0.5.3 + uses: nerixyz/ccache-action@9a7e8d00116ede600ee7717350c6594b8af6aaa5 + with: + variant: sccache + # only save on the default (master) branch + save: ${{ github.event_name == 'push' }} + key: sccache-test-${{ matrix.os }}-${{ matrix.qt-version }}-${{ matrix.skip-crashpad }} + restore-keys: | + sccache-test-${{ matrix.os }}-${{ matrix.qt-version }} + + - name: Cache conan packages + uses: actions/cache@v3 + with: + key: ${{ runner.os }}-conan-user-${{ hashFiles('**/conanfile.py') }}${{ env.C2_CONAN_CACHE_SUFFIX }} + path: ~/.conan2/ + + - name: Install Conan + run: | + python3 -c "import site; import sys; print(f'{site.USER_BASE}\\Python{sys.version_info.major}{sys.version_info.minor}\\Scripts')" >> "$Env:GITHUB_PATH" + pip3 install --user "conan==${{ env.CONAN_VERSION }}" + + - name: Setup Conan + run: | + conan --version + conan profile detect -f + + - name: Install dependencies + run: | + mkdir build-test + cd build-test + conan install .. ` + -s build_type=RelWithDebInfo ` + -c tools.cmake.cmaketoolchain:generator="NMake Makefiles" ` + -b missing ` + --output-folder=. ` + -o with_openssl3="$Env:C2_USE_OPENSSL3" + + - name: Build + run: | + cmake ` + -G"NMake Makefiles" ` + -DCMAKE_BUILD_TYPE=RelWithDebInfo ` + -DBUILD_TESTS=On ` + -DBUILD_APP=OFF ` + -DCMAKE_TOOLCHAIN_FILE="conan_toolchain.cmake" ` + -DUSE_PRECOMPILED_HEADERS=On ` + -DBUILD_WITH_CRASHPAD="$Env:C2_ENABLE_CRASHPAD" ` + -DCHATTERINO_PLUGINS="$Env:C2_PLUGINS" ` + -DBUILD_WITH_QT6="$Env:C2_BUILD_WITH_QT6" ` + .. + set cl=/MP + nmake /S /NOLOGO + working-directory: build-test + + - name: Download and extract Twitch PubSub Server Test + run: | + mkdir pubsub-server-test + Invoke-WebRequest -Uri "https://github.com/Chatterino/twitch-pubsub-server-test/releases/download/${{ env.TWITCH_PUBSUB_SERVER_TAG }}/server-${{ env.TWITCH_PUBSUB_SERVER_TAG }}-windows-amd64.zip" -outfile "pubsub-server.zip" + Expand-Archive pubsub-server.zip -DestinationPath pubsub-server-test + rm pubsub-server.zip + cd pubsub-server-test + Invoke-WebRequest -Uri "https://github.com/Chatterino/twitch-pubsub-server-test/raw/${{ env.TWITCH_PUBSUB_SERVER_TAG }}/cmd/server/server.crt" -outfile "server.crt" + Invoke-WebRequest -Uri "https://github.com/Chatterino/twitch-pubsub-server-test/raw/${{ env.TWITCH_PUBSUB_SERVER_TAG }}/cmd/server/server.key" -outfile "server.key" + cd .. + + - name: Cargo Install httpbox + run: | + cargo install --git https://github.com/kevinastone/httpbox --rev 89b971f + + - name: Test + timeout-minutes: 30 + run: | + httpbox --port 9051 & + cd ..\pubsub-server-test + .\server.exe 127.0.0.1:9050 & + cd ..\build-test + ctest --repeat until-pass:4 --output-on-failure --exclude-regex ClassicEmoteNameFiltering + working-directory: build-test + + - name: Clean Conan cache + run: conan cache clean --source --build --download "*" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b9e490390b2..b45ca108c52 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -99,6 +99,6 @@ jobs: docker pull ${{ env.TWITCH_PUBSUB_SERVER_IMAGE }} docker run --network=host --detach ${{ env.TWITCH_PUBSUB_SERVER_IMAGE }} docker run -p 9051:80 --detach kennethreitz/httpbin - ctest --repeat until-pass:4 + ctest --repeat until-pass:4 --output-on-failure working-directory: build-test shell: bash diff --git a/CHANGELOG.md b/CHANGELOG.md index 16eab57eac0..d51288a7c76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,7 @@ - Dev: Refactor `IrcMessageHandler`, removing a bunch of clang-tidy warnings & changing its public API. (#4927) - Dev: `Details` file properties tab is now populated on Windows. (#4912) - Dev: Removed `Outcome` from network requests. (#4959) +- Dev: Added Tests for Windows and MacOS in CI. (#4970) ## 2.4.6 From fbc8aacabec23dd425e45fdb82f1bddad10eff29 Mon Sep 17 00:00:00 2001 From: Mm2PL Date: Sun, 19 Nov 2023 12:05:30 +0100 Subject: [PATCH 18/26] Refactored the Image Uploader feature. (#4971) Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 3 + mocks/include/mocks/EmptyApplication.hpp | 5 + src/Application.cpp | 2 + src/Application.hpp | 7 + src/CMakeLists.txt | 4 +- src/common/QLogging.cpp | 2 +- src/common/QLogging.hpp | 2 +- src/messages/MessageBuilder.cpp | 53 +++++ src/messages/MessageBuilder.hpp | 17 ++ .../ImageUploader.cpp} | 225 ++++++++++-------- src/singletons/ImageUploader.hpp | 49 ++++ src/util/NuulsUploader.hpp | 27 --- src/widgets/splits/Split.cpp | 5 +- 13 files changed, 264 insertions(+), 137 deletions(-) rename src/{util/NuulsUploader.cpp => singletons/ImageUploader.cpp} (62%) create mode 100644 src/singletons/ImageUploader.hpp delete mode 100644 src/util/NuulsUploader.hpp diff --git a/CHANGELOG.md b/CHANGELOG.md index d51288a7c76..52df34528ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,8 @@ - Bugfix: Fixed a freeze from a bad regex in _Ignores_. (#4965) - Bugfix: Fixed some emotes not appearing when using _Ignores_. (#4965) - Bugfix: Fixed lookahead/-behind not working in _Ignores_. (#4965) +- Bugfix: Fixed Image Uploader accidentally deleting images with some hosts when link resolver was enabled. (#4971) +- Bugfix: Fixed rare crash with Image Uploader when closing a split right after starting an upload. (#4971) - Dev: Change clang-format from v14 to v16. (#4929) - Dev: Fixed UTF16 encoding of `modes` file for the installer. (#4791) - Dev: Temporarily disable High DPI scaling on Qt6 builds on Windows. (#4767) @@ -66,6 +68,7 @@ - Dev: `Details` file properties tab is now populated on Windows. (#4912) - Dev: Removed `Outcome` from network requests. (#4959) - Dev: Added Tests for Windows and MacOS in CI. (#4970) +- Dev: Refactored the Image Uploader feature. (#4971) ## 2.4.6 diff --git a/mocks/include/mocks/EmptyApplication.hpp b/mocks/include/mocks/EmptyApplication.hpp index 16a43db618e..70ce30706f1 100644 --- a/mocks/include/mocks/EmptyApplication.hpp +++ b/mocks/include/mocks/EmptyApplication.hpp @@ -89,6 +89,11 @@ class EmptyApplication : public IApplication { return nullptr; } + + ImageUploader *getImageUploader() override + { + return nullptr; + } }; } // namespace chatterino::mock diff --git a/src/Application.cpp b/src/Application.cpp index bd3b5f0f614..806d2155281 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -10,6 +10,7 @@ #include "controllers/hotkeys/HotkeyController.hpp" #include "controllers/ignores/IgnoreController.hpp" #include "controllers/notifications/NotificationController.hpp" +#include "singletons/ImageUploader.hpp" #ifdef CHATTERINO_HAVE_PLUGINS # include "controllers/plugins/PluginController.hpp" #endif @@ -79,6 +80,7 @@ Application::Application(Settings &_settings, Paths &_paths) , hotkeys(&this->emplace()) , windows(&this->emplace()) , toasts(&this->emplace()) + , imageUploader(&this->emplace()) , commands(&this->emplace()) , notifications(&this->emplace()) diff --git a/src/Application.hpp b/src/Application.hpp index af80308f180..3bedfd2cfbc 100644 --- a/src/Application.hpp +++ b/src/Application.hpp @@ -41,6 +41,7 @@ class Toasts; class ChatterinoBadges; class FfzBadges; class SeventvBadges; +class ImageUploader; class IApplication { @@ -66,6 +67,7 @@ class IApplication virtual SeventvBadges *getSeventvBadges() = 0; virtual IUserDataController *getUserData() = 0; virtual ITwitchLiveController *getTwitchLiveController() = 0; + virtual ImageUploader *getImageUploader() = 0; }; class Application : public IApplication @@ -94,6 +96,7 @@ class Application : public IApplication HotkeyController *const hotkeys{}; WindowManager *const windows{}; Toasts *const toasts{}; + ImageUploader *const imageUploader{}; CommandController *const commands{}; NotificationController *const notifications{}; @@ -167,6 +170,10 @@ class Application : public IApplication } IUserDataController *getUserData() override; ITwitchLiveController *getTwitchLiveController() override; + ImageUploader *getImageUploader() override + { + return this->imageUploader; + } pajlada::Signals::NoArgSignal streamerModeChanged; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index d1754239a00..7a9a5756569 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -424,6 +424,8 @@ set(SOURCE_FILES singletons/Emotes.hpp singletons/Fonts.cpp singletons/Fonts.hpp + singletons/ImageUploader.cpp + singletons/ImageUploader.hpp singletons/Logging.cpp singletons/Logging.hpp singletons/NativeMessaging.cpp @@ -475,8 +477,6 @@ set(SOURCE_FILES util/IpcQueue.hpp util/LayoutHelper.cpp util/LayoutHelper.hpp - util/NuulsUploader.cpp - util/NuulsUploader.hpp util/RapidjsonHelpers.cpp util/RapidjsonHelpers.hpp util/RatelimitBucket.cpp diff --git a/src/common/QLogging.cpp b/src/common/QLogging.cpp index e05ce240dcb..31c035d850d 100644 --- a/src/common/QLogging.cpp +++ b/src/common/QLogging.cpp @@ -32,7 +32,7 @@ Q_LOGGING_CATEGORY(chatterinoNativeMessage, "chatterino.nativemessage", Q_LOGGING_CATEGORY(chatterinoNetwork, "chatterino.network", logThreshold); Q_LOGGING_CATEGORY(chatterinoNotification, "chatterino.notification", logThreshold); -Q_LOGGING_CATEGORY(chatterinoNuulsuploader, "chatterino.nuulsuploader", +Q_LOGGING_CATEGORY(chatterinoImageuploader, "chatterino.imageuploader", logThreshold); Q_LOGGING_CATEGORY(chatterinoPubSub, "chatterino.pubsub", logThreshold); Q_LOGGING_CATEGORY(chatterinoRecentMessages, "chatterino.recentmessages", diff --git a/src/common/QLogging.hpp b/src/common/QLogging.hpp index bd6ba982273..01500f1daa2 100644 --- a/src/common/QLogging.hpp +++ b/src/common/QLogging.hpp @@ -25,7 +25,7 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoMessage); Q_DECLARE_LOGGING_CATEGORY(chatterinoNativeMessage); Q_DECLARE_LOGGING_CATEGORY(chatterinoNetwork); Q_DECLARE_LOGGING_CATEGORY(chatterinoNotification); -Q_DECLARE_LOGGING_CATEGORY(chatterinoNuulsuploader); +Q_DECLARE_LOGGING_CATEGORY(chatterinoImageuploader); Q_DECLARE_LOGGING_CATEGORY(chatterinoPubSub); Q_DECLARE_LOGGING_CATEGORY(chatterinoRecentMessages); Q_DECLARE_LOGGING_CATEGORY(chatterinoSettings); diff --git a/src/messages/MessageBuilder.cpp b/src/messages/MessageBuilder.cpp index 0eb80017c99..c34fe74e582 100644 --- a/src/messages/MessageBuilder.cpp +++ b/src/messages/MessageBuilder.cpp @@ -6,6 +6,7 @@ #include "controllers/accounts/AccountController.hpp" #include "messages/Image.hpp" #include "messages/Message.hpp" +#include "messages/MessageColor.hpp" #include "messages/MessageElement.hpp" #include "providers/LinkResolver.hpp" #include "providers/twitch/PubSubActions.hpp" @@ -660,6 +661,58 @@ MessageBuilder::MessageBuilder(LiveUpdatesUpdateEmoteSetMessageTag /*unused*/, this->message().flags.set(MessageFlag::DoNotTriggerNotification); } +MessageBuilder::MessageBuilder(ImageUploaderResultTag /*unused*/, + const QString &imageLink, + const QString &deletionLink, + size_t imagesStillQueued, size_t secondsLeft) + : MessageBuilder() +{ + this->message().flags.set(MessageFlag::System); + this->message().flags.set(MessageFlag::DoNotTriggerNotification); + + this->emplace(); + + using MEF = MessageElementFlag; + auto addText = [this](QString text, MessageElementFlags mefs = MEF::Text, + MessageColor color = + MessageColor::System) -> TextElement * { + this->message().searchText += text; + this->message().messageText += text; + return this->emplace(text, mefs, color); + }; + + addText("Your image has been uploaded to"); + + // ASSUMPTION: the user gave this uploader configuration to the program + // therefore they trust that the host is not wrong/malicious. This doesn't obey getSettings()->lowercaseDomains. + // This also ensures that the LinkResolver doesn't get these links. + addText(imageLink, {MEF::OriginalLink, MEF::LowercaseLink}, + MessageColor::Link) + ->setLink({Link::Url, imageLink}) + ->setTrailingSpace(false); + + if (!deletionLink.isEmpty()) + { + addText("(Deletion link:"); + addText(deletionLink, {MEF::OriginalLink, MEF::LowercaseLink}, + MessageColor::Link) + ->setLink({Link::Url, deletionLink}) + ->setTrailingSpace(false); + addText(")")->setTrailingSpace(false); + } + addText("."); + + if (imagesStillQueued == 0) + { + return; + } + + addText(QString("%1 left. Please wait until all of them are uploaded. " + "About %2 seconds left.") + .arg(imagesStillQueued) + .arg(secondsLeft)); +} + Message *MessageBuilder::operator->() { return this->message_.get(); diff --git a/src/messages/MessageBuilder.hpp b/src/messages/MessageBuilder.hpp index 5814b44a48f..28874439b51 100644 --- a/src/messages/MessageBuilder.hpp +++ b/src/messages/MessageBuilder.hpp @@ -37,6 +37,9 @@ struct LiveUpdatesAddEmoteMessageTag { }; struct LiveUpdatesUpdateEmoteSetMessageTag { }; +struct ImageUploaderResultTag { +}; + const SystemMessageTag systemMessage{}; const TimeoutMessageTag timeoutMessage{}; const LiveUpdatesUpdateEmoteMessageTag liveUpdatesUpdateEmoteMessage{}; @@ -44,6 +47,10 @@ const LiveUpdatesRemoveEmoteMessageTag liveUpdatesRemoveEmoteMessage{}; const LiveUpdatesAddEmoteMessageTag liveUpdatesAddEmoteMessage{}; const LiveUpdatesUpdateEmoteSetMessageTag liveUpdatesUpdateEmoteSetMessage{}; +// This signifies that you want to construct a message containing the result of +// a successful image upload. +const ImageUploaderResultTag imageUploaderResultMessage{}; + MessagePtr makeSystemMessage(const QString &text); MessagePtr makeSystemMessage(const QString &text, const QTime &time); std::pair makeAutomodMessage( @@ -88,6 +95,16 @@ class MessageBuilder MessageBuilder(LiveUpdatesUpdateEmoteSetMessageTag, const QString &platform, const QString &actor, const QString &emoteSetName); + /** + * "Your image has been uploaded to %1[ (Deletion link: %2)]." + * or "Your image has been uploaded to %1 %2. %3 left. " + * "Please wait until all of them are uploaded. " + * "About %4 seconds left." + */ + MessageBuilder(ImageUploaderResultTag, const QString &imageLink, + const QString &deletionLink, size_t imagesStillQueued = 0, + size_t secondsLeft = 0); + virtual ~MessageBuilder() = default; Message *operator->(); diff --git a/src/util/NuulsUploader.cpp b/src/singletons/ImageUploader.cpp similarity index 62% rename from src/util/NuulsUploader.cpp rename to src/singletons/ImageUploader.cpp index 1caea7d5e93..d08067e2621 100644 --- a/src/util/NuulsUploader.cpp +++ b/src/singletons/ImageUploader.cpp @@ -1,9 +1,10 @@ -#include "NuulsUploader.hpp" +#include "singletons/ImageUploader.hpp" #include "common/Env.hpp" #include "common/NetworkRequest.hpp" #include "common/NetworkResult.hpp" #include "common/QLogging.hpp" +#include "messages/MessageBuilder.hpp" #include "providers/twitch/TwitchMessageBuilder.hpp" #include "singletons/Paths.hpp" #include "singletons/Settings.hpp" @@ -16,6 +17,7 @@ #include #include #include +#include #include #define UPLOAD_DELAY 2000 @@ -41,13 +43,10 @@ std::optional convertToPng(const QImage &image) namespace chatterino { -// These variables are only used from the main thread. -static auto uploadMutex = QMutex(); -static std::queue uploadQueue; - // logging information on successful uploads to a json file -void logToFile(const QString originalFilePath, QString imageLink, - QString deletionLink, ChannelPtr channel) +void ImageUploader::logToFile(const QString &originalFilePath, + const QString &imageLink, + const QString &deletionLink, ChannelPtr channel) { const QString logFileName = combinePath((getSettings()->logPath.getValue().isEmpty() @@ -120,8 +119,13 @@ QString getLinkFromResponse(NetworkResult response, QString pattern) return pattern; } -void uploadImageToNuuls(RawImageData imageData, ChannelPtr channel, - ResizingTextEdit &textEdit) +void ImageUploader::save() +{ +} + +void ImageUploader::sendImageUploadRequest(RawImageData imageData, + ChannelPtr channel, + QPointer textEdit) { const static char *const boundary = "thisistheboudaryasd"; const static QString contentType = @@ -155,91 +159,103 @@ void uploadImageToNuuls(RawImageData imageData, ChannelPtr channel, .header("Content-Type", contentType) .headerList(extraHeaders) .multiPart(payload) - .onSuccess([&textEdit, channel, - originalFilePath](NetworkResult result) { - QString link = getSettings()->imageUploaderLink.getValue().isEmpty() - ? result.getData() - : getLinkFromResponse( - result, getSettings()->imageUploaderLink); - QString deletionLink = - getSettings()->imageUploaderDeletionLink.getValue().isEmpty() - ? "" - : getLinkFromResponse( - result, getSettings()->imageUploaderDeletionLink); - qCDebug(chatterinoNuulsuploader) << link << deletionLink; - textEdit.insertPlainText(link + " "); - if (uploadQueue.empty()) - { - channel->addMessage(makeSystemMessage( - QString("Your image has been uploaded to %1 %2.") - .arg(link) - .arg(deletionLink.isEmpty() - ? "" - : QString("(Deletion link: %1 )") - .arg(deletionLink)))); - uploadMutex.unlock(); - } - else - { - channel->addMessage(makeSystemMessage( - QString("Your image has been uploaded to %1 %2. %3 left. " - "Please wait until all of them are uploaded. " - "About %4 seconds left.") - .arg(link) - .arg(deletionLink.isEmpty() - ? "" - : QString("(Deletion link: %1 )") - .arg(deletionLink)) - .arg(uploadQueue.size()) - .arg(uploadQueue.size() * (UPLOAD_DELAY / 1000 + 1)))); - // 2 seconds for the timer that's there not to spam the remote server - // and 1 second of actual uploading. + .onSuccess( + [textEdit, channel, originalFilePath, this](NetworkResult result) { + this->handleSuccessfulUpload(result, originalFilePath, channel, + textEdit); + }) + .onError([channel, this](NetworkResult result) -> bool { + this->handleFailedUpload(result, channel); + return true; + }) + .execute(); +} - QTimer::singleShot(UPLOAD_DELAY, [channel, &textEdit]() { - uploadImageToNuuls(uploadQueue.front(), channel, textEdit); - uploadQueue.pop(); - }); - } +void ImageUploader::handleFailedUpload(const NetworkResult &result, + ChannelPtr channel) +{ + auto errorMessage = + QString("An error happened while uploading your image: %1") + .arg(result.formatError()); - logToFile(originalFilePath, link, deletionLink, channel); - }) - .onError([channel](NetworkResult result) -> bool { - auto errorMessage = - QString("An error happened while uploading your image: %1") - .arg(result.formatError()); + // Try to read more information from the result body + auto obj = result.parseJson(); + if (!obj.isEmpty()) + { + auto apiCode = obj.value("code"); + if (!apiCode.isUndefined()) + { + auto codeString = apiCode.toVariant().toString(); + codeString.truncate(20); + errorMessage += QString(" - code: %1").arg(codeString); + } - // Try to read more information from the result body - auto obj = result.parseJson(); - if (!obj.isEmpty()) - { - auto apiCode = obj.value("code"); - if (!apiCode.isUndefined()) - { - auto codeString = apiCode.toVariant().toString(); - codeString.truncate(20); - errorMessage += QString(" - code: %1").arg(codeString); - } + auto apiError = obj.value("error").toString(); + if (!apiError.isEmpty()) + { + apiError.truncate(300); + errorMessage += QString(" - error: %1").arg(apiError.trimmed()); + } + } - auto apiError = obj.value("error").toString(); - if (!apiError.isEmpty()) - { - apiError.truncate(300); - errorMessage += - QString(" - error: %1").arg(apiError.trimmed()); - } - } + channel->addMessage(makeSystemMessage(errorMessage)); + this->uploadMutex_.unlock(); +} - channel->addMessage(makeSystemMessage(errorMessage)); - uploadMutex.unlock(); - return true; - }) - .execute(); +void ImageUploader::handleSuccessfulUpload(const NetworkResult &result, + QString originalFilePath, + ChannelPtr channel, + QPointer textEdit) +{ + if (textEdit == nullptr) + { + // Split was destroyed abort further uploads + + while (!this->uploadQueue_.empty()) + { + this->uploadQueue_.pop(); + } + this->uploadMutex_.unlock(); + return; + } + QString link = + getSettings()->imageUploaderLink.getValue().isEmpty() + ? result.getData() + : getLinkFromResponse(result, getSettings()->imageUploaderLink); + QString deletionLink = + getSettings()->imageUploaderDeletionLink.getValue().isEmpty() + ? "" + : getLinkFromResponse(result, + getSettings()->imageUploaderDeletionLink); + qCDebug(chatterinoImageuploader) << link << deletionLink; + textEdit->insertPlainText(link + " "); + + // 2 seconds for the timer that's there not to spam the remote server + // and 1 second of actual uploading. + auto timeToUpload = this->uploadQueue_.size() * (UPLOAD_DELAY / 1000 + 1); + MessageBuilder builder(imageUploaderResultMessage, link, deletionLink, + this->uploadQueue_.size(), timeToUpload); + channel->addMessage(builder.release()); + if (this->uploadQueue_.empty()) + { + this->uploadMutex_.unlock(); + } + else + { + QTimer::singleShot(UPLOAD_DELAY, [channel, &textEdit, this]() { + this->sendImageUploadRequest(this->uploadQueue_.front(), channel, + textEdit); + this->uploadQueue_.pop(); + }); + } + + this->logToFile(originalFilePath, link, deletionLink, channel); } -void upload(const QMimeData *source, ChannelPtr channel, - ResizingTextEdit &outputTextEdit) +void ImageUploader::upload(const QMimeData *source, ChannelPtr channel, + QPointer outputTextEdit) { - if (!uploadMutex.tryLock()) + if (!this->uploadMutex_.tryLock()) { channel->addMessage(makeSystemMessage( QString("Please wait until the upload finishes."))); @@ -265,7 +281,7 @@ void upload(const QMimeData *source, ChannelPtr channel, { channel->addMessage( makeSystemMessage(QString("Couldn't load image :("))); - uploadMutex.unlock(); + this->uploadMutex_.unlock(); return; } @@ -273,7 +289,7 @@ void upload(const QMimeData *source, ChannelPtr channel, if (imageData) { RawImageData data = {*imageData, "png", localPath}; - uploadQueue.push(data); + this->uploadQueue_.push(data); } else { @@ -281,7 +297,7 @@ void upload(const QMimeData *source, ChannelPtr channel, QString("Cannot upload file: %1. Couldn't convert " "image to png.") .arg(localPath))); - uploadMutex.unlock(); + this->uploadMutex_.unlock(); return; } } @@ -295,11 +311,11 @@ void upload(const QMimeData *source, ChannelPtr channel, { channel->addMessage( makeSystemMessage(QString("Failed to open file. :("))); - uploadMutex.unlock(); + this->uploadMutex_.unlock(); return; } RawImageData data = {file.readAll(), "gif", localPath}; - uploadQueue.push(data); + this->uploadQueue_.push(data); file.close(); // file.readAll() => might be a bit big but it /should/ work } @@ -308,31 +324,32 @@ void upload(const QMimeData *source, ChannelPtr channel, channel->addMessage(makeSystemMessage( QString("Cannot upload file: %1. Not an image.") .arg(localPath))); - uploadMutex.unlock(); + this->uploadMutex_.unlock(); return; } } - if (!uploadQueue.empty()) + if (!this->uploadQueue_.empty()) { - uploadImageToNuuls(uploadQueue.front(), channel, outputTextEdit); - uploadQueue.pop(); + this->sendImageUploadRequest(this->uploadQueue_.front(), channel, + outputTextEdit); + this->uploadQueue_.pop(); } } else if (source->hasFormat("image/png")) { // the path to file is not present every time, thus the filePath is empty - uploadImageToNuuls({source->data("image/png"), "png", ""}, channel, - outputTextEdit); + this->sendImageUploadRequest({source->data("image/png"), "png", ""}, + channel, outputTextEdit); } else if (source->hasFormat("image/jpeg")) { - uploadImageToNuuls({source->data("image/jpeg"), "jpeg", ""}, channel, - outputTextEdit); + this->sendImageUploadRequest({source->data("image/jpeg"), "jpeg", ""}, + channel, outputTextEdit); } else if (source->hasFormat("image/gif")) { - uploadImageToNuuls({source->data("image/gif"), "gif", ""}, channel, - outputTextEdit); + this->sendImageUploadRequest({source->data("image/gif"), "gif", ""}, + channel, outputTextEdit); } else @@ -341,14 +358,14 @@ void upload(const QMimeData *source, ChannelPtr channel, auto imageData = convertToPng(image); if (imageData) { - uploadImageToNuuls({*imageData, "png", ""}, channel, - outputTextEdit); + sendImageUploadRequest({*imageData, "png", ""}, channel, + outputTextEdit); } else { channel->addMessage(makeSystemMessage( QString("Cannot upload file, failed to convert to png."))); - uploadMutex.unlock(); + this->uploadMutex_.unlock(); } } } diff --git a/src/singletons/ImageUploader.hpp b/src/singletons/ImageUploader.hpp new file mode 100644 index 00000000000..260180583d7 --- /dev/null +++ b/src/singletons/ImageUploader.hpp @@ -0,0 +1,49 @@ +#pragma once + +#include "common/Singleton.hpp" + +#include +#include +#include + +#include +#include + +namespace chatterino { + +class ResizingTextEdit; +class Channel; +class NetworkResult; +using ChannelPtr = std::shared_ptr; + +struct RawImageData { + QByteArray data; + QString format; + QString filePath; +}; + +class ImageUploader final : public Singleton +{ +public: + void save() override; + void upload(const QMimeData *source, ChannelPtr channel, + QPointer outputTextEdit); + +private: + void sendImageUploadRequest(RawImageData imageData, ChannelPtr channel, + QPointer textEdit); + + // This is called from the onSuccess handler of the NetworkRequest in sendImageUploadRequest + void handleSuccessfulUpload(const NetworkResult &result, + QString originalFilePath, ChannelPtr channel, + QPointer textEdit); + void handleFailedUpload(const NetworkResult &result, ChannelPtr channel); + + void logToFile(const QString &originalFilePath, const QString &imageLink, + const QString &deletionLink, ChannelPtr channel); + + // These variables are only used from the main thread. + QMutex uploadMutex_; + std::queue uploadQueue_; +}; +} // namespace chatterino diff --git a/src/util/NuulsUploader.hpp b/src/util/NuulsUploader.hpp deleted file mode 100644 index e830b45054f..00000000000 --- a/src/util/NuulsUploader.hpp +++ /dev/null @@ -1,27 +0,0 @@ -#pragma once - -#include -#include - -#include - -namespace chatterino { - -class ResizingTextEdit; -class Channel; -using ChannelPtr = std::shared_ptr; - -struct RawImageData { - QByteArray data; - QString format; - QString filePath; -}; - -void upload(QByteArray imageData, ChannelPtr channel, - ResizingTextEdit &textEdit, std::string format); -void upload(RawImageData imageData, ChannelPtr channel, - ResizingTextEdit &textEdit); -void upload(const QMimeData *source, ChannelPtr channel, - ResizingTextEdit &outputTextEdit); - -} // namespace chatterino diff --git a/src/widgets/splits/Split.cpp b/src/widgets/splits/Split.cpp index 527d559c86e..7565b61a20c 100644 --- a/src/widgets/splits/Split.cpp +++ b/src/widgets/splits/Split.cpp @@ -16,12 +16,12 @@ #include "providers/twitch/TwitchIrcServer.hpp" #include "providers/twitch/TwitchMessageBuilder.hpp" #include "singletons/Fonts.hpp" +#include "singletons/ImageUploader.hpp" #include "singletons/Settings.hpp" #include "singletons/Theme.hpp" #include "singletons/WindowManager.hpp" #include "util/Clipboard.hpp" #include "util/Helpers.hpp" -#include "util/NuulsUploader.hpp" #include "util/StreamLink.hpp" #include "widgets/dialogs/QualityPopup.hpp" #include "widgets/dialogs/SelectChannelDialog.hpp" @@ -405,7 +405,8 @@ Split::Split(QWidget *parent) getSettings()->askOnImageUpload.setValue(false); } } - upload(source, this->getChannel(), *this->input_->ui_.textEdit); + QPointer edit = this->input_->ui_.textEdit; + getApp()->imageUploader->upload(source, this->getChannel(), edit); }); getSettings()->imageUploaderEnabled.connect( From 1a685d7bd00ae8ac3fa0d3c5f584f1a2ec69da09 Mon Sep 17 00:00:00 2001 From: Mm2PL Date: Mon, 20 Nov 2023 18:59:04 +0100 Subject: [PATCH 19/26] Finish renaming Viewer list to Chatter list (#4974) --- .../{viewersDark.png => chattersDark.png} | Bin .../{viewersLight.png => chattersLight.png} | Bin src/providers/twitch/api/README.md | 2 +- src/widgets/splits/Split.cpp | 62 +++++++++--------- src/widgets/splits/Split.hpp | 2 +- src/widgets/splits/SplitHeader.cpp | 16 ++--- src/widgets/splits/SplitHeader.hpp | 4 +- 7 files changed, 43 insertions(+), 43 deletions(-) rename resources/buttons/{viewersDark.png => chattersDark.png} (100%) rename resources/buttons/{viewersLight.png => chattersLight.png} (100%) diff --git a/resources/buttons/viewersDark.png b/resources/buttons/chattersDark.png similarity index 100% rename from resources/buttons/viewersDark.png rename to resources/buttons/chattersDark.png diff --git a/resources/buttons/viewersLight.png b/resources/buttons/chattersLight.png similarity index 100% rename from resources/buttons/viewersLight.png rename to resources/buttons/chattersLight.png diff --git a/src/providers/twitch/api/README.md b/src/providers/twitch/api/README.md index e300e54401f..23509c94b8d 100644 --- a/src/providers/twitch/api/README.md +++ b/src/providers/twitch/api/README.md @@ -168,7 +168,7 @@ Not used anywhere at the moment. URL: https://dev.twitch.tv/docs/api/reference/#get-chatters -Used for the viewer list for moderators/broadcasters. +Used for the chatter list for moderators/broadcasters. ### Send Shoutout diff --git a/src/widgets/splits/Split.cpp b/src/widgets/splits/Split.cpp index 7565b61a20c..7d1a2b91e0d 100644 --- a/src/widgets/splits/Split.cpp +++ b/src/widgets/splits/Split.cpp @@ -652,7 +652,7 @@ void Split::addShortcuts() }}, {"openViewerList", [this](std::vector) -> QString { - this->showViewerList(); + this->showChatterList(); return ""; }}, {"clearMessages", @@ -852,11 +852,11 @@ void Split::setChannel(IndirectChannel newChannel) if (newChannel.getType() == Channel::Type::Twitch) { - this->header_->setViewersButtonVisible(true); + this->header_->setChattersButtonVisible(true); } else { - this->header_->setViewersButtonVisible(false); + this->header_->setChattersButtonVisible(false); } this->channelSignalHolder_.managedConnect( @@ -1028,7 +1028,7 @@ void Split::changeChannel() if (popup.size() && popup.at(0)->isVisible() && !popup.at(0)->isFloating()) { popup.at(0)->hide(); - showViewerList(); + showChatterList(); } } @@ -1122,31 +1122,31 @@ void Split::openWithCustomScheme() } } -void Split::showViewerList() +void Split::showChatterList() { - auto viewerDock = - new QDockWidget("Viewer List - " + this->getChannel()->getName(), this); - viewerDock->setAllowedAreas(Qt::LeftDockWidgetArea); - viewerDock->setFeatures(QDockWidget::DockWidgetVerticalTitleBar | - QDockWidget::DockWidgetClosable | - QDockWidget::DockWidgetFloatable); - viewerDock->resize( + auto *chatterDock = new QDockWidget( + "Chatter List - " + this->getChannel()->getName(), this); + chatterDock->setAllowedAreas(Qt::LeftDockWidgetArea); + chatterDock->setFeatures(QDockWidget::DockWidgetVerticalTitleBar | + QDockWidget::DockWidgetClosable | + QDockWidget::DockWidgetFloatable); + chatterDock->resize( 0.5 * this->width(), this->height() - this->header_->height() - this->input_->height()); - viewerDock->move(0, this->header_->height()); + chatterDock->move(0, this->header_->height()); - auto multiWidget = new QWidget(viewerDock); + auto *multiWidget = new QWidget(chatterDock); auto *dockVbox = new QVBoxLayout(); - auto searchBar = new QLineEdit(viewerDock); + auto *searchBar = new QLineEdit(chatterDock); - auto chattersList = new QListWidget(); - auto resultList = new QListWidget(); + auto *chattersList = new QListWidget(); + auto *resultList = new QListWidget(); auto channel = this->getChannel(); if (!channel) { qCWarning(chatterinoWidget) - << "Viewer list opened when no channel was defined"; + << "Chatter list opened when no channel was defined"; return; } @@ -1155,7 +1155,7 @@ void Split::showViewerList() if (twitchChannel == nullptr) { qCWarning(chatterinoWidget) - << "Viewer list opened in a non-Twitch channel"; + << "Chatter list opened in a non-Twitch channel"; return; } @@ -1163,14 +1163,14 @@ void Split::showViewerList() searchBar->setPlaceholderText("Search User..."); auto formatListItemText = [](QString text) { - auto item = new QListWidgetItem(); + auto *item = new QListWidgetItem(); item->setText(text); item->setFont(getApp()->fonts->getFont(FontStyle::ChatMedium, 1.0)); return item; }; auto addLabel = [this, formatListItemText, chattersList](QString label) { - auto formattedLabel = formatListItemText(label); + auto *formattedLabel = formatListItemText(label); formattedLabel->setForeground(this->theme->accent); chattersList->addItem(formattedLabel); }; @@ -1336,13 +1336,13 @@ void Split::showViewerList() formatListItemText("Due to Twitch restrictions, this feature is " "only \navailable for moderators.")); chattersList->addItem( - formatListItemText("If you would like to see the Viewer list, you " + formatListItemText("If you would like to see the Chatter list, you " "must \nuse the Twitch website.")); loadingLabel->hide(); } - QObject::connect(viewerDock, &QDockWidget::topLevelChanged, this, [=]() { - viewerDock->setMinimumWidth(300); + QObject::connect(chatterDock, &QDockWidget::topLevelChanged, this, [=]() { + chatterDock->setMinimumWidth(300); }); auto listDoubleClick = [this](const QModelIndex &index) { @@ -1364,8 +1364,8 @@ void Split::showViewerList() HotkeyController::HotkeyMap actions{ {"delete", - [viewerDock](std::vector) -> QString { - viewerDock->close(); + [chatterDock](std::vector) -> QString { + chatterDock->close(); return ""; }}, {"accept", nullptr}, @@ -1381,7 +1381,7 @@ void Split::showViewerList() }; getApp()->hotkeys->shortcutsForCategory(HotkeyCategory::PopupWindow, - actions, viewerDock); + actions, chatterDock); dockVbox->addWidget(searchBar); dockVbox->addWidget(loadingLabel); @@ -1391,10 +1391,10 @@ void Split::showViewerList() multiWidget->setStyleSheet(this->theme->splits.input.styleSheet); multiWidget->setLayout(dockVbox); - viewerDock->setWidget(multiWidget); - viewerDock->setFloating(true); - viewerDock->show(); - viewerDock->activateWindow(); + chatterDock->setWidget(multiWidget); + chatterDock->setFloating(true); + chatterDock->show(); + chatterDock->activateWindow(); } void Split::openSubPage() diff --git a/src/widgets/splits/Split.hpp b/src/widgets/splits/Split.hpp index 493cdf2f100..f0cc3de85c6 100644 --- a/src/widgets/splits/Split.hpp +++ b/src/widgets/splits/Split.hpp @@ -185,7 +185,7 @@ public slots: void openWithCustomScheme(); void setFiltersDialog(); void showSearch(bool singleChannel); - void showViewerList(); + void showChatterList(); void openSubPage(); void reloadChannelAndSubscriberEmotes(); void reconnect(); diff --git a/src/widgets/splits/SplitHeader.cpp b/src/widgets/splits/SplitHeader.cpp index 887176d5c17..19e34e8b3e1 100644 --- a/src/widgets/splits/SplitHeader.cpp +++ b/src/widgets/splits/SplitHeader.cpp @@ -320,9 +320,9 @@ void SplitHeader::initializeLayout() }); }), // chatter list - this->viewersButton_ = makeWidget