diff --git a/.clang-tidy b/.clang-tidy index b5e8bef8ccb..434f04f7664 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -42,7 +42,7 @@ CheckOptions: - key: readability-identifier-naming.FunctionCase value: camelBack - key: readability-identifier-naming.FunctionIgnoredRegexp - value: ^TEST$ + value: ^(TEST|MOCK_METHOD)$ - key: readability-identifier-naming.MemberCase value: camelBack diff --git a/.github/workflows/clang-tidy.yml b/.github/workflows/clang-tidy.yml index 19052648b5e..44f9da5c1ff 100644 --- a/.github/workflows/clang-tidy.yml +++ b/.github/workflows/clang-tidy.yml @@ -45,7 +45,7 @@ jobs: build_dir: build-clang-tidy config_file: ".clang-tidy" split_workflow: true - exclude: "lib/*,tools/crash-handler/*" + exclude: "lib/*,tools/crash-handler/*,mocks/*" cmake_command: >- ./.CI/setup-clang-tidy.sh apt_packages: >- diff --git a/.github/workflows/test-macos.yml b/.github/workflows/test-macos.yml index b22766e0961..9e76429fe55 100644 --- a/.github/workflows/test-macos.yml +++ b/.github/workflows/test-macos.yml @@ -23,7 +23,7 @@ jobs: strategy: matrix: os: [macos-13] - qt-version: [5.15.2, 6.5.0] + qt-version: [5.15.2, 6.7.1] plugins: [false] fail-fast: false env: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index c79569ff32c..0451c605fda 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -28,7 +28,7 @@ jobs: include: - os: "ubuntu-22.04" container: ghcr.io/chatterino/chatterino2-build-ubuntu-22.04:latest - qt-version: 6.6.1 + qt-version: 6.7.1 plugins: true fail-fast: false env: diff --git a/CHANGELOG.md b/CHANGELOG.md index 56787a620ff..41358f6bc19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,8 @@ - Minor: Links can now have prefixes and suffixes such as parentheses. (#5486, #5515) - Minor: Added support for scrolling in splits with touchscreen panning gestures. (#5524) - Minor: Removed experimental IRC support. (#5547) +- Minor: Moderators can now see which mods start and cancel raids. (#5563) +- Minor: The emote popup now reloads when Twitch emotes are reloaded. (#5580) - Bugfix: Fixed tab move animation occasionally failing to start after closing a tab. (#5426) - Bugfix: If a network request errors with 200 OK, Qt's error code is now reported instead of the HTTP status. (#5378) - Bugfix: Fixed restricted users usernames not being clickable. (#5405) @@ -36,14 +38,16 @@ - Bugfix: Fixed a crash when tab completing while having an invalid plugin loaded. (#5401) - Bugfix: Fixed windows on Windows not saving correctly when snapping them to the edges. (#5478) - Bugfix: Fixed user info card popups adding duplicate line to log files. (#5499) -- Bugfix: Fixed tooltips and input completion popups not working after moving a split. (#5541) +- Bugfix: Fixed tooltips and input completion popups not working after moving a split. (#5541, #5576) - Bugfix: Fixed rare issue on shutdown where the client would hang. (#5557) - Bugfix: Fixed `/clearmessages` not working with more than one window. (#5489) - Bugfix: Fixed splits staying paused after unfocusing Chatterino in certain configurations. (#5504) - Bugfix: Links with invalid characters in the domain are no longer detected. (#5509) - Bugfix: Fixed janky selection for messages with RTL segments (selection is still wrong, but consistently wrong). (#5525) +- Bugfix: Fixed event emotes not showing up in autocomplete and popups. (#5239, #5580) - Bugfix: Fixed tab visibility being controllable in the emote popup. (#5530) - Bugfix: Fixed account switch not being saved if no other settings were changed. (#5558) +- Bugfix: Fixed some tooltips not being readable. (#5578) - Dev: Update Windows build from Qt 6.5.0 to Qt 6.7.1. (#5420) - Dev: Update vcpkg build Qt from 6.5.0 to 6.7.0, boost from 1.83.0 to 1.85.0, openssl from 3.1.3 to 3.3.0. (#5422) - Dev: Unsingletonize `ISoundController`. (#5462) diff --git a/mocks/include/mocks/EmptyApplication.hpp b/mocks/include/mocks/EmptyApplication.hpp index 4997bab9ae4..336e6a1a773 100644 --- a/mocks/include/mocks/EmptyApplication.hpp +++ b/mocks/include/mocks/EmptyApplication.hpp @@ -257,6 +257,13 @@ class EmptyApplication : public IApplication return nullptr; } + ITwitchUsers *getTwitchUsers() override + { + assert(false && "EmptyApplication::getTwitchUsers was called without " + "being initialized"); + return nullptr; + } + pronouns::Pronouns *getPronouns() override { assert(false && "EmptyApplication::getPronouns was called without " diff --git a/mocks/include/mocks/Helix.hpp b/mocks/include/mocks/Helix.hpp index 14ec3976eb8..5156de55750 100644 --- a/mocks/include/mocks/Helix.hpp +++ b/mocks/include/mocks/Helix.hpp @@ -410,6 +410,23 @@ class Helix : public IHelix (FailureCallback failureCallback)), (override)); + // get user emotes + MOCK_METHOD( + void, getUserEmotes, + (QString userID, QString broadcasterID, + (ResultCallback, HelixPaginationState> + successCallback), + FailureCallback failureCallback, CancellationToken &&token), + (override)); + + // get followed channel + MOCK_METHOD( + void, getFollowedChannel, + (QString userID, QString broadcasterID, + ResultCallback> successCallback, + FailureCallback failureCallback), + (override)); + MOCK_METHOD(void, update, (QString clientId, QString oauthToken), (override)); diff --git a/src/Application.cpp b/src/Application.cpp index 24dd346fd7a..7d26285f740 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -43,6 +43,7 @@ #include "providers/twitch/PubSubMessages.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchIrcServer.hpp" +#include "providers/twitch/TwitchUsers.hpp" #include "singletons/CrashHandler.hpp" #include "singletons/Emotes.hpp" #include "singletons/Fonts.hpp" @@ -177,6 +178,7 @@ Application::Application(Settings &_settings, const Paths &paths, , logging(new Logging(_settings)) , linkResolver(new LinkResolver) , streamerMode(new StreamerMode) + , twitchUsers(new TwitchUsers) , pronouns(std::make_shared()) #ifdef CHATTERINO_HAVE_PLUGINS , plugins(new PluginController(paths)) @@ -517,6 +519,14 @@ IStreamerMode *Application::getStreamerMode() return this->streamerMode.get(); } +ITwitchUsers *Application::getTwitchUsers() +{ + assertInGuiThread(); + assert(this->twitchUsers); + + return this->twitchUsers.get(); +} + BttvEmotes *Application::getBttvEmotes() { assertInGuiThread(); diff --git a/src/Application.hpp b/src/Application.hpp index b99bb9de9cd..92f0dd7526f 100644 --- a/src/Application.hpp +++ b/src/Application.hpp @@ -53,6 +53,7 @@ class SeventvEmotes; class SeventvEventAPI; class ILinkResolver; class IStreamerMode; +class ITwitchUsers; namespace pronouns { class Pronouns; } // namespace pronouns @@ -106,6 +107,7 @@ class IApplication virtual SeventvEventAPI *getSeventvEventAPI() = 0; virtual ILinkResolver *getLinkResolver() = 0; virtual IStreamerMode *getStreamerMode() = 0; + virtual ITwitchUsers *getTwitchUsers() = 0; virtual pronouns::Pronouns *getPronouns() = 0; }; @@ -170,6 +172,7 @@ class Application : public IApplication const std::unique_ptr logging; std::unique_ptr linkResolver; std::unique_ptr streamerMode; + std::unique_ptr twitchUsers; std::shared_ptr pronouns; #ifdef CHATTERINO_HAVE_PLUGINS std::unique_ptr plugins; @@ -221,6 +224,7 @@ class Application : public IApplication ILinkResolver *getLinkResolver() override; IStreamerMode *getStreamerMode() override; + ITwitchUsers *getTwitchUsers() override; private: void initBttvLiveUpdates(); diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 8aec2c5bb81..db6b1551dcb 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -411,6 +411,8 @@ set(SOURCE_FILES providers/twitch/TwitchIrcServer.hpp providers/twitch/TwitchUser.cpp providers/twitch/TwitchUser.hpp + providers/twitch/TwitchUsers.cpp + providers/twitch/TwitchUsers.hpp providers/twitch/pubsubmessages/AutoMod.cpp providers/twitch/pubsubmessages/AutoMod.hpp @@ -478,6 +480,7 @@ set(SOURCE_FILES util/DebugCount.hpp util/DisplayBadge.cpp util/DisplayBadge.hpp + util/Expected.hpp util/FormatTime.cpp util/FormatTime.hpp util/FunctionEventFilter.cpp diff --git a/src/PrecompiledHeader.hpp b/src/PrecompiledHeader.hpp index 5a66ec73f3e..d7c289bcaca 100644 --- a/src/PrecompiledHeader.hpp +++ b/src/PrecompiledHeader.hpp @@ -6,6 +6,7 @@ # include # include # include +# include # include # include # include diff --git a/src/RunGui.cpp b/src/RunGui.cpp index 92da3dbf451..ff77d6c470c 100644 --- a/src/RunGui.cpp +++ b/src/RunGui.cpp @@ -49,7 +49,7 @@ namespace { dark.setColor(QPalette::Base, QColor("#333")); dark.setColor(QPalette::AlternateBase, QColor("#444")); dark.setColor(QPalette::ToolTipBase, Qt::white); - dark.setColor(QPalette::ToolTipText, Qt::white); + dark.setColor(QPalette::ToolTipText, Qt::black); dark.setColor(QPalette::Dark, QColor(35, 35, 35)); dark.setColor(QPalette::Shadow, QColor(20, 20, 20)); dark.setColor(QPalette::Button, QColor(70, 70, 70)); diff --git a/src/common/Aliases.hpp b/src/common/Aliases.hpp index 847d807ac36..b524fc0d2c2 100644 --- a/src/common/Aliases.hpp +++ b/src/common/Aliases.hpp @@ -1,38 +1,50 @@ #pragma once +#include #include #include #include -#define QStringAlias(name) \ - namespace chatterino { \ - struct name { \ - QString string; \ - bool operator==(const name &other) const \ - { \ - return this->string == other.string; \ - } \ - bool operator!=(const name &other) const \ - { \ - return this->string != other.string; \ - } \ - }; \ - } /* namespace chatterino */ \ - namespace std { \ - template <> \ - struct hash { \ - size_t operator()(const chatterino::name &s) const \ - { \ - return qHash(s.string); \ - } \ - }; \ - } /* namespace std */ +// NOLINTNEXTLINE(cppcoreguidelines-macro-usage) +#define QStringAlias(name) \ + namespace chatterino { \ + struct name { \ + QString string; \ + bool operator==(const name &other) const \ + { \ + return this->string == other.string; \ + } \ + bool operator!=(const name &other) const \ + { \ + return this->string != other.string; \ + } \ + }; \ + } /* namespace chatterino */ \ + namespace std { \ + template <> \ + struct hash { \ + size_t operator()(const chatterino::name &s) const \ + { \ + return qHash(s.string); \ + } \ + }; \ + } /* namespace std */ \ + namespace boost { \ + template <> \ + struct hash { \ + std::size_t operator()(chatterino::name const &s) const \ + { \ + return qHash(s.string); \ + } \ + }; \ + } /* namespace boost */ QStringAlias(UserName); QStringAlias(UserId); QStringAlias(Url); QStringAlias(Tooltip); QStringAlias(EmoteId); +QStringAlias(EmoteSetId); QStringAlias(EmoteName); QStringAlias(EmoteAuthor); diff --git a/src/common/Channel.cpp b/src/common/Channel.cpp index b3372615187..a74aa32c49e 100644 --- a/src/common/Channel.cpp +++ b/src/common/Channel.cpp @@ -275,6 +275,12 @@ void Channel::deleteMessage(QString messageID) } } +void Channel::clearMessages() +{ + this->messages_.clear(); + this->messagesCleared.invoke(); +} + MessagePtr Channel::findMessage(QString messageID) { MessagePtr res; diff --git a/src/common/Channel.hpp b/src/common/Channel.hpp index 67e715af5d1..5543276227f 100644 --- a/src/common/Channel.hpp +++ b/src/common/Channel.hpp @@ -71,6 +71,7 @@ class Channel : public std::enable_shared_from_this pajlada::Signals::Signal &> filledInMessages; pajlada::Signals::NoArgSignal destroyed; pajlada::Signals::NoArgSignal displayNameChanged; + pajlada::Signals::NoArgSignal messagesCleared; Type getType() const; const QString &getName() const; @@ -99,6 +100,9 @@ class Channel : public std::enable_shared_from_this void replaceMessage(size_t index, MessagePtr replacement); void deleteMessage(QString messageID); + /// Removes all messages from this channel and invokes #messagesCleared + void clearMessages(); + MessagePtr findMessage(QString messageID); bool hasMessages() const; diff --git a/src/controllers/commands/builtin/twitch/Raid.cpp b/src/controllers/commands/builtin/twitch/Raid.cpp index 57fb3c011d0..1a54f17f3f6 100644 --- a/src/controllers/commands/builtin/twitch/Raid.cpp +++ b/src/controllers/commands/builtin/twitch/Raid.cpp @@ -152,9 +152,8 @@ QString startRaid(const CommandContext &ctx) channel{ctx.channel}](const HelixUser &targetUser) { getHelix()->startRaid( twitchChannel->roomId(), targetUser.id, - [channel, targetUser] { - channel->addSystemMessage(QString("You started to raid %1.") - .arg(targetUser.displayName)); + [] { + // do nothing }, [channel, targetUser](auto error, auto message) { auto errorMessage = formatStartRaidError(error, message); @@ -202,8 +201,8 @@ QString cancelRaid(const CommandContext &ctx) getHelix()->cancelRaid( ctx.twitchChannel->roomId(), - [channel{ctx.channel}] { - channel->addSystemMessage("You cancelled the raid."); + [] { + // do nothing }, [channel{ctx.channel}](auto error, auto message) { auto errorMessage = formatCancelRaidError(error, message); diff --git a/src/controllers/commands/builtin/twitch/SendWhisper.cpp b/src/controllers/commands/builtin/twitch/SendWhisper.cpp index cd099ac23a8..73c0ab73a14 100644 --- a/src/controllers/commands/builtin/twitch/SendWhisper.cpp +++ b/src/controllers/commands/builtin/twitch/SendWhisper.cpp @@ -113,8 +113,8 @@ bool appendWhisperMessageWordsLocally(const QStringList &words) for (int i = 2; i < words.length(); i++) { { // Twitch emote - auto it = accemotes.emotes.find({words[i]}); - if (it != accemotes.emotes.end()) + auto it = accemotes->find({words[i]}); + if (it != accemotes->end()) { b.emplace(it->second, MessageElementFlag::TwitchEmote); diff --git a/src/controllers/completion/sources/EmoteSource.cpp b/src/controllers/completion/sources/EmoteSource.cpp index f5d04c9e020..9aab51213cb 100644 --- a/src/controllers/completion/sources/EmoteSource.cpp +++ b/src/controllers/completion/sources/EmoteSource.cpp @@ -95,26 +95,16 @@ void EmoteSource::initializeFromChannel(const Channel *channel) // returns true also for special Twitch channels (/live, /mentions, /whispers, etc.) if (channel->isTwitchChannel()) { - if (auto user = app->getAccounts()->twitch.getCurrent()) + if (tc) { - // Twitch Emotes available globally - auto emoteData = user->accessEmotes(); - addEmotes(emotes, emoteData->emotes, "Twitch Emote"); - - // Twitch Emotes available locally - auto localEmoteData = user->accessLocalEmotes(); - if ((tc != nullptr) && - localEmoteData->find(tc->roomId()) != localEmoteData->end()) + if (auto twitch = tc->localTwitchEmotes()) { - if (const auto *localEmotes = &localEmoteData->at(tc->roomId())) - { - addEmotes(emotes, *localEmotes, "Local Twitch Emotes"); - } + addEmotes(emotes, *twitch, "Local Twitch Emotes"); } - } - if (tc) - { + auto user = getApp()->getAccounts()->twitch.getCurrent(); + addEmotes(emotes, **user->accessEmotes(), "Twitch Emote"); + // TODO extract "Channel {BetterTTV,7TV,FrankerFaceZ}" text into a #define. if (auto bttv = tc->bttvEmotes()) { diff --git a/src/controllers/ignores/IgnorePhrase.cpp b/src/controllers/ignores/IgnorePhrase.cpp index eacd7e4bd38..9f81fbc6ebb 100644 --- a/src/controllers/ignores/IgnorePhrase.cpp +++ b/src/controllers/ignores/IgnorePhrase.cpp @@ -99,7 +99,7 @@ bool IgnorePhrase::containsEmote() const for (const auto &acc : accvec) { const auto &accemotes = *acc->accessEmotes(); - for (const auto &emote : accemotes.emotes) + for (const auto &emote : *accemotes) { if (this->replace_.contains(emote.first.string, Qt::CaseSensitive)) diff --git a/src/messages/MessageBuilder.cpp b/src/messages/MessageBuilder.cpp index db3ecc13ba4..d3750ea6370 100644 --- a/src/messages/MessageBuilder.cpp +++ b/src/messages/MessageBuilder.cpp @@ -880,6 +880,40 @@ MessageBuilder::MessageBuilder(const WarnAction &action) this->message().searchText = text; } +MessageBuilder::MessageBuilder(const RaidAction &action) + : MessageBuilder() +{ + this->emplace(); + this->message().flags.set(MessageFlag::System); + + QString text; + + this->emplaceSystemTextAndUpdate(action.source.login, text) + ->setLink({Link::UserInfo, "id:" + action.source.id}); + this->emplaceSystemTextAndUpdate("initiated a raid to", text); + this->emplaceSystemTextAndUpdate(action.target + ".", text) + ->setLink({Link::UserInfo, action.target}); + + this->message().messageText = text; + this->message().searchText = text; +} + +MessageBuilder::MessageBuilder(const UnraidAction &action) + : MessageBuilder() +{ + this->emplace(); + this->message().flags.set(MessageFlag::System); + + QString text; + + this->emplaceSystemTextAndUpdate(action.source.login, text) + ->setLink({Link::UserInfo, "id:" + action.source.id}); + this->emplaceSystemTextAndUpdate("canceled the raid.", text); + + this->message().messageText = text; + this->message().searchText = text; +} + MessageBuilder::MessageBuilder(const AutomodUserAction &action) : MessageBuilder() { diff --git a/src/messages/MessageBuilder.hpp b/src/messages/MessageBuilder.hpp index 325abafbd8c..996595a8e8f 100644 --- a/src/messages/MessageBuilder.hpp +++ b/src/messages/MessageBuilder.hpp @@ -23,6 +23,8 @@ namespace chatterino { struct BanAction; struct UnbanAction; struct WarnAction; +struct RaidAction; +struct UnraidAction; struct AutomodAction; struct AutomodUserAction; struct AutomodInfoAction; @@ -127,6 +129,8 @@ class MessageBuilder MessageBuilder(const BanAction &action, uint32_t count = 1); MessageBuilder(const UnbanAction &action); MessageBuilder(const WarnAction &action); + MessageBuilder(const RaidAction &action); + MessageBuilder(const UnraidAction &action); MessageBuilder(const AutomodUserAction &action); MessageBuilder(LiveUpdatesAddEmoteMessageTag, const QString &platform, diff --git a/src/providers/IvrApi.cpp b/src/providers/IvrApi.cpp index 02d733b18bd..1635660acd8 100644 --- a/src/providers/IvrApi.cpp +++ b/src/providers/IvrApi.cpp @@ -31,28 +31,6 @@ void IvrApi::getSubage(QString userName, QString channelName, .execute(); } -void IvrApi::getBulkEmoteSets(QString emoteSetList, - ResultCallback successCallback, - IvrFailureCallback failureCallback) -{ - QUrlQuery urlQuery; - urlQuery.addQueryItem("set_id", emoteSetList); - - this->makeRequest("twitch/emotes/sets", urlQuery) - .onSuccess([successCallback, failureCallback](auto result) { - auto root = result.parseJsonArray(); - - successCallback(root); - }) - .onError([failureCallback](auto result) { - qCWarning(chatterinoIvr) - << "Failed IVR API Call!" << result.formatError() - << QString(result.getData()); - failureCallback(); - }) - .execute(); -} - NetworkRequest IvrApi::makeRequest(QString url, QUrlQuery urlQuery) { assert(!url.startsWith("/")); diff --git a/src/providers/IvrApi.hpp b/src/providers/IvrApi.hpp index 8084718971a..55fc80b0f32 100644 --- a/src/providers/IvrApi.hpp +++ b/src/providers/IvrApi.hpp @@ -1,7 +1,6 @@ #pragma once #include "common/network/NetworkRequest.hpp" -#include "providers/twitch/TwitchEmotes.hpp" #include #include @@ -32,45 +31,6 @@ struct IvrSubage { } }; -struct IvrEmoteSet { - const QString setId; - const QString displayName; - const QString login; - const QString channelId; - const QString tier; - const QJsonArray emotes; - - IvrEmoteSet(const QJsonObject &root) - : setId(root.value("setID").toString()) - , displayName(root.value("channelName").toString()) - , login(root.value("channelLogin").toString()) - , channelId(root.value("channelID").toString()) - , tier(root.value("tier").toString()) - , emotes(root.value("emoteList").toArray()) - - { - } -}; - -struct IvrEmote { - const QString code; - const QString id; - const QString setId; - const QString url; - const QString emoteType; - const QString imageType; - - explicit IvrEmote(const QJsonObject &root) - : code(root.value("code").toString()) - , id(root.value("id").toString()) - , setId(root.value("setID").toString()) - , url(TWITCH_EMOTE_TEMPLATE.arg(this->id, u"3.0")) - , emoteType(root.value("type").toString()) - , imageType(root.value("assetType").toString()) - { - } -}; - class IvrApi final { public: @@ -79,11 +39,6 @@ class IvrApi final ResultCallback resultCallback, IvrFailureCallback failureCallback); - // https://api.ivr.fi/v2/docs/static/index.html#/Twitch/get_twitch_emotes_sets - void getBulkEmoteSets(QString emoteSetList, - ResultCallback successCallback, - IvrFailureCallback failureCallback); - static void initialize(); IvrApi() = default; diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp index 35ec83761a2..0e4571cfff2 100644 --- a/src/providers/twitch/IrcMessageHandler.cpp +++ b/src/providers/twitch/IrcMessageHandler.cpp @@ -870,17 +870,6 @@ void IrcMessageHandler::handleClearMessageMessage(Communi::IrcMessage *message) void IrcMessageHandler::handleUserStateMessage(Communi::IrcMessage *message) { - auto currentUser = getApp()->getAccounts()->twitch.getCurrent(); - - // set received emote-sets, used in TwitchAccount::loadUserstateEmotes - bool emoteSetsChanged = currentUser->setUserstateEmoteSets( - message->tag("emote-sets").toString().split(",")); - - if (emoteSetsChanged) - { - currentUser->loadUserstateEmotes(); - } - QString channelName; if (!trimChannelName(message->parameter(0), channelName)) { @@ -918,24 +907,6 @@ void IrcMessageHandler::handleUserStateMessage(Communi::IrcMessage *message) } } -// This will emit only once and right after user logs in to IRC - reset emote data and reload emotes -void IrcMessageHandler::handleGlobalUserStateMessage( - Communi::IrcMessage *message) -{ - auto currentUser = getApp()->getAccounts()->twitch.getCurrent(); - - // set received emote-sets, this time used to initially load emotes - // NOTE: this should always return true unless we reconnect - auto emoteSetsChanged = currentUser->setUserstateEmoteSets( - message->tag("emote-sets").toString().split(",")); - - // We should always attempt to reload emotes even on reconnections where - // emoteSetsChanged, since we want to trigger emote reloads when - // "currentUserChanged" signal is emitted - qCDebug(chatterinoTwitch) << emoteSetsChanged << message->toData(); - currentUser->loadEmotes(); -} - void IrcMessageHandler::handleWhisperMessage(Communi::IrcMessage *ircMessage) { MessageParseArgs args; diff --git a/src/providers/twitch/IrcMessageHandler.hpp b/src/providers/twitch/IrcMessageHandler.hpp index 705f39ba383..ba6d2d9835b 100644 --- a/src/providers/twitch/IrcMessageHandler.hpp +++ b/src/providers/twitch/IrcMessageHandler.hpp @@ -44,7 +44,6 @@ class IrcMessageHandler void handleClearChatMessage(Communi::IrcMessage *message); void handleClearMessageMessage(Communi::IrcMessage *message); void handleUserStateMessage(Communi::IrcMessage *message); - void handleGlobalUserStateMessage(Communi::IrcMessage *message); void handleWhisperMessage(Communi::IrcMessage *ircMessage); void handleUserNoticeMessage(Communi::IrcMessage *message, diff --git a/src/providers/twitch/PubSubActions.hpp b/src/providers/twitch/PubSubActions.hpp index 82c94da3004..5f83b40515e 100644 --- a/src/providers/twitch/PubSubActions.hpp +++ b/src/providers/twitch/PubSubActions.hpp @@ -172,6 +172,16 @@ struct AutomodInfoAction : PubSubAction { } type; }; +struct RaidAction : PubSubAction { + using PubSubAction::PubSubAction; + + QString target; +}; + +struct UnraidAction : PubSubAction { + using PubSubAction::PubSubAction; +}; + struct WarnAction : PubSubAction { using PubSubAction::PubSubAction; diff --git a/src/providers/twitch/PubSubManager.cpp b/src/providers/twitch/PubSubManager.cpp index 010cac05224..89a08327b18 100644 --- a/src/providers/twitch/PubSubManager.cpp +++ b/src/providers/twitch/PubSubManager.cpp @@ -320,6 +320,35 @@ PubSub::PubSub(const QString &host, std::chrono::seconds pingInterval) this->moderation.userWarned.invoke(action); }; + this->moderationActionHandlers["raid"] = [this](const auto &data, + const auto &roomID) { + RaidAction action(data, roomID); + + action.source.id = data.value("created_by_user_id").toString(); + action.source.login = data.value("created_by").toString(); + + const auto args = data.value("args").toArray(); + + if (args.isEmpty()) + { + return; + } + + action.target = args[0].toString(); + + this->moderation.raidStarted.invoke(action); + }; + + this->moderationActionHandlers["unraid"] = [this](const auto &data, + const auto &roomID) { + UnraidAction action(data, roomID); + + action.source.id = data.value("created_by_user_id").toString(); + action.source.login = data.value("created_by").toString(); + + this->moderation.raidCanceled.invoke(action); + }; + /* // This handler is no longer required as we use the automod-queue topic now this->moderationActionHandlers["automod_rejected"] = diff --git a/src/providers/twitch/PubSubManager.hpp b/src/providers/twitch/PubSubManager.hpp index 186cd5c6ae3..f14eabf7741 100644 --- a/src/providers/twitch/PubSubManager.hpp +++ b/src/providers/twitch/PubSubManager.hpp @@ -44,6 +44,8 @@ struct PubSubAutoModQueueMessage; struct AutomodAction; struct AutomodUserAction; struct AutomodInfoAction; +struct RaidAction; +struct UnraidAction; struct WarnAction; struct PubSubLowTrustUsersMessage; struct PubSubWhisperMessage; @@ -104,6 +106,9 @@ class PubSub Signal modeChanged; Signal moderationStateChanged; + Signal raidStarted; + Signal raidCanceled; + Signal userBanned; Signal userUnbanned; Signal userWarned; diff --git a/src/providers/twitch/TwitchAccount.cpp b/src/providers/twitch/TwitchAccount.cpp index 363d313ec07..1cabd9e1e94 100644 --- a/src/providers/twitch/TwitchAccount.cpp +++ b/src/providers/twitch/TwitchAccount.cpp @@ -3,26 +3,33 @@ #include "Application.hpp" #include "common/Channel.hpp" #include "common/Env.hpp" +#include "common/Literals.hpp" #include "common/network/NetworkResult.hpp" #include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" #include "debug/AssertInGuiThread.hpp" +#include "messages/Emote.hpp" #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" #include "providers/IvrApi.hpp" #include "providers/seventv/SeventvAPI.hpp" #include "providers/twitch/api/Helix.hpp" #include "providers/twitch/TwitchCommon.hpp" +#include "providers/twitch/TwitchUsers.hpp" #include "singletons/Emotes.hpp" #include "util/CancellationToken.hpp" #include "util/Helpers.hpp" #include "util/QStringHash.hpp" #include "util/RapidjsonHelpers.hpp" +#include +#include #include namespace chatterino { +using namespace literals; + TwitchAccount::TwitchAccount(const QString &username, const QString &oauthToken, const QString &oauthClient, const QString &userID) : Account(ProviderId::Twitch) @@ -31,9 +38,13 @@ TwitchAccount::TwitchAccount(const QString &username, const QString &oauthToken, , userName_(username) , userId_(userID) , isAnon_(username == ANONYMOUS_USERNAME) + , emoteSets_(std::make_shared()) + , emotes_(std::make_shared()) { } +TwitchAccount::~TwitchAccount() = default; + QString TwitchAccount::toString() const { return this->getUserName(); @@ -175,214 +186,6 @@ const std::unordered_set &TwitchAccount::blockedUserIds() const return this->ignoresUserIds_; } -void TwitchAccount::loadEmotes(std::weak_ptr weakChannel) -{ - qCDebug(chatterinoTwitch) - << "Loading Twitch emotes for user" << this->getUserName(); - - if (this->getOAuthClient().isEmpty() || this->getOAuthToken().isEmpty()) - { - qCDebug(chatterinoTwitch) - << "Aborted loadEmotes due to missing Client ID and/or OAuth token"; - return; - } - - { - auto emoteData = this->emotes_.access(); - emoteData->emoteSets.clear(); - emoteData->emotes.clear(); - qCDebug(chatterinoTwitch) << "Cleared emotes!"; - } - - this->loadUserstateEmotes(weakChannel); -} - -bool TwitchAccount::setUserstateEmoteSets(QStringList newEmoteSets) -{ - newEmoteSets.sort(); - - if (this->userstateEmoteSets_ == newEmoteSets) - { - // Nothing has changed - return false; - } - - this->userstateEmoteSets_ = newEmoteSets; - - return true; -} - -void TwitchAccount::loadUserstateEmotes(std::weak_ptr weakChannel) -{ - if (this->userstateEmoteSets_.isEmpty()) - { - return; - } - - QStringList newEmoteSetKeys, existingEmoteSetKeys; - - auto emoteData = this->emotes_.access(); - auto userEmoteSets = emoteData->emoteSets; - - // get list of already fetched emote sets - for (const auto &userEmoteSet : userEmoteSets) - { - existingEmoteSetKeys.push_back(userEmoteSet->key); - } - - // filter out emote sets from userstate message, which are not in fetched emote set list - for (const auto &emoteSetKey : this->userstateEmoteSets_) - { - if (!existingEmoteSetKeys.contains(emoteSetKey)) - { - newEmoteSetKeys.push_back(emoteSetKey); - } - } - - // return if there are no new emote sets - if (newEmoteSetKeys.isEmpty()) - { - return; - } - - // requesting emotes - auto batches = splitListIntoBatches(newEmoteSetKeys); - for (int i = 0; i < batches.size(); i++) - { - qCDebug(chatterinoTwitch) - << QString( - "Loading %1 emotesets from IVR; batch %2/%3 (%4 sets): %5") - .arg(newEmoteSetKeys.size()) - .arg(i + 1) - .arg(batches.size()) - .arg(batches.at(i).size()) - .arg(batches.at(i).join(",")); - getIvr()->getBulkEmoteSets( - batches.at(i).join(","), - [this, weakChannel](QJsonArray emoteSetArray) { - auto emoteData = this->emotes_.access(); - auto localEmoteData = this->localEmotes_.access(); - - std::unordered_set subscriberChannelIDs; - std::vector ivrEmoteSets; - ivrEmoteSets.reserve(emoteSetArray.size()); - - for (auto emoteSet : emoteSetArray) - { - IvrEmoteSet ivrEmoteSet(emoteSet.toObject()); - if (!ivrEmoteSet.tier.isNull()) - { - subscriberChannelIDs.insert(ivrEmoteSet.channelId); - } - ivrEmoteSets.emplace_back(ivrEmoteSet); - } - - for (const auto &emoteSet : emoteData->emoteSets) - { - if (emoteSet->subscriber) - { - subscriberChannelIDs.insert(emoteSet->channelID); - } - } - - for (const auto &ivrEmoteSet : ivrEmoteSets) - { - auto emoteSet = std::make_shared(); - - QString setKey = ivrEmoteSet.setId; - emoteSet->key = setKey; - - // check if the emoteset is already in emoteData - auto isAlreadyFetched = - std::find_if(emoteData->emoteSets.begin(), - emoteData->emoteSets.end(), - [setKey](std::shared_ptr set) { - return (set->key == setKey); - }); - if (isAlreadyFetched != emoteData->emoteSets.end()) - { - continue; - } - - emoteSet->channelID = ivrEmoteSet.channelId; - emoteSet->channelName = ivrEmoteSet.login; - emoteSet->text = ivrEmoteSet.displayName; - emoteSet->subscriber = !ivrEmoteSet.tier.isNull(); - - // NOTE: If a user does not have a subscriber emote set, but a follower emote set, this logic will be wrong - // However, that's not a realistic problem. - bool haveSubscriberSetForChannel = - subscriberChannelIDs.contains(ivrEmoteSet.channelId); - - for (const auto &emoteObj : ivrEmoteSet.emotes) - { - IvrEmote ivrEmote(emoteObj.toObject()); - - auto id = EmoteId{ivrEmote.id}; - auto code = EmoteName{ - TwitchEmotes::cleanUpEmoteCode(ivrEmote.code)}; - - emoteSet->emotes.push_back(TwitchEmote{id, code}); - - auto emote = getApp() - ->getEmotes() - ->getTwitchEmotes() - ->getOrCreateEmote(id, code); - - // Follower emotes can be only used in their origin channel - // unless the user is subscribed, then they can be used anywhere. - if (ivrEmote.emoteType == "FOLLOWER" && - !haveSubscriberSetForChannel) - { - emoteSet->local = true; - - // EmoteMap for target channel wasn't initialized yet, doing it now - if (localEmoteData->find(ivrEmoteSet.channelId) == - localEmoteData->end()) - { - localEmoteData->emplace(ivrEmoteSet.channelId, - EmoteMap()); - } - - localEmoteData->at(ivrEmoteSet.channelId) - .emplace(code, emote); - } - else - { - emoteData->emotes.emplace(code, emote); - } - } - std::sort(emoteSet->emotes.begin(), emoteSet->emotes.end(), - [](const TwitchEmote &l, const TwitchEmote &r) { - return l.name.string < r.name.string; - }); - emoteData->emoteSets.emplace_back(emoteSet); - } - - if (auto channel = weakChannel.lock(); channel != nullptr) - { - channel->addSystemMessage( - "Twitch subscriber emotes reloaded."); - } - }, - [] { - // fetching emotes failed, ivr API might be down - }); - }; -} - -SharedAccessGuard - TwitchAccount::accessEmotes() const -{ - return this->emotes_.accessConst(); -} - -SharedAccessGuard> - TwitchAccount::accessLocalEmotes() const -{ - return this->localEmotes_.accessConst(); -} - // AutoModActions void TwitchAccount::autoModAllow(const QString msgID, ChannelPtr channel) { @@ -515,4 +318,140 @@ void TwitchAccount::loadSeventvUserID() }); } +bool TwitchAccount::hasEmoteSet(const EmoteSetId &id) const +{ + auto emotes = this->emoteSets_.accessConst(); + return emotes->get()->contains(id); +} + +SharedAccessGuard> + TwitchAccount::accessEmoteSets() const +{ + return this->emoteSets_.accessConst(); +} + +SharedAccessGuard> TwitchAccount::accessEmotes() + const +{ + return this->emotes_.accessConst(); +} + +std::optional TwitchAccount::twitchEmote(const EmoteName &name) const +{ + auto emotes = this->emotes_.accessConst(); + auto it = (*emotes)->find(name); + if (it != (*emotes)->end()) + { + return it->second; + } + return std::nullopt; +} + +void TwitchAccount::reloadEmotes(void *caller) +{ + if (this->isAnon() || getApp()->isTest()) + { + return; + } + + CancellationToken token(false); + this->emoteToken_ = token; + + auto sets = std::make_shared(); + auto emoteMap = std::make_shared(); + auto nCalls = std::make_shared(); + + auto *twitchEmotes = getApp()->getEmotes()->getTwitchEmotes(); + auto *twitchUsers = getApp()->getTwitchUsers(); + + auto addEmote = [sets, emoteMap, twitchEmotes, + twitchUsers](const HelixChannelEmote &emote) { + EmoteId id{emote.id}; + EmoteName name{emote.name}; + auto meta = getTwitchEmoteSetMeta(emote); + + auto emotePtr = twitchEmotes->getOrCreateEmote(id, name); + if (!emoteMap->try_emplace(name, emotePtr).second) + { + // if the emote already exists, we don't want to add it to a set as + // those are assumed to be disjoint + return; + } + + auto set = sets->find(EmoteSetId{meta.setID}); + if (set == sets->end()) + { + auto owner = [&]() { + if (meta.isSubLike) + { + return twitchUsers->resolveID({emote.ownerID}); + } + + return std::make_shared(TwitchUser{ + .id = u"[x-c2-global-owner]"_s, + .name = {}, + .displayName = {}, + }); + }(); + set = sets->emplace(EmoteSetId{meta.setID}, + TwitchEmoteSet{ + .owner = owner, + .emotes = {}, + .isBits = meta.isBits, + .isSubLike = meta.isSubLike, + }) + .first; + } + set->second.emotes.emplace_back(std::move(emotePtr)); + }; + + auto userID = this->getUserId(); + qDebug(chatterinoTwitch).nospace() + << "Loading Twitch emotes - userID: " << userID + << ", broadcasterID: none, manualRefresh: " << (caller != nullptr); + + getHelix()->getUserEmotes( + this->getUserId(), {}, + [this, caller, emoteMap, sets, addEmote, nCalls]( + const auto &emotes, const auto &state) mutable { + assert(emoteMap && sets); + (*nCalls)++; + qDebug(chatterinoTwitch).nospace() + << "Got " << emotes.size() << " more emote(s)"; + + emoteMap->reserve(emoteMap->size() + emotes.size()); + for (const auto &emote : emotes) + { + addEmote(emote); + } + + if (state.done) + { + qDebug(chatterinoTwitch).nospace() + << "Loaded " << emoteMap->size() << " Twitch emotes (" + << *nCalls << " requests)"; + + for (auto &[id, set] : *sets) + { + std::ranges::sort( + set.emotes, [](const auto &l, const auto &r) { + return l->name.string < r->name.string; + }); + } + + *this->emotes_.access() = std::move(emoteMap); + *this->emoteSets_.access() = std::move(sets); + getApp()->getAccounts()->twitch.emotesReloaded.invoke(caller, + {}); + } + }, + [caller](const auto &error) { + qDebug(chatterinoTwitch) + << "Failed to load Twitch emotes:" << error; + getApp()->getAccounts()->twitch.emotesReloaded.invoke( + caller, makeUnexpected(error)); + }, + std::move(token)); +} + } // namespace chatterino diff --git a/src/providers/twitch/TwitchAccount.hpp b/src/providers/twitch/TwitchAccount.hpp index 69032b80c9c..f77d60df1d3 100644 --- a/src/providers/twitch/TwitchAccount.hpp +++ b/src/providers/twitch/TwitchAccount.hpp @@ -1,14 +1,16 @@ #pragma once -#include "common/Aliases.hpp" #include "common/Atomic.hpp" #include "common/UniqueAccess.hpp" #include "controllers/accounts/Account.hpp" #include "messages/Emote.hpp" +#include "providers/twitch/TwitchEmotes.hpp" #include "providers/twitch/TwitchUser.hpp" #include "util/CancellationToken.hpp" #include "util/QStringHash.hpp" +#include +#include #include #include #include @@ -18,7 +20,6 @@ #include #include #include -#include namespace chatterino { @@ -28,31 +29,13 @@ using ChannelPtr = std::shared_ptr; class TwitchAccount : public Account { public: - struct TwitchEmote { - EmoteId id; - EmoteName name; - }; - - struct EmoteSet { - QString key; - QString channelName; - QString channelID; - QString text; - bool subscriber{false}; - bool local{false}; - std::vector emotes; - }; - - struct TwitchAccountEmoteData { - std::vector> emoteSets; - - // this EmoteMap should contain all emotes available globally - // excluding locally available emotes, such as follower ones - EmoteMap emotes; - }; - TwitchAccount(const QString &username, const QString &oauthToken_, const QString &oauthClient_, const QString &_userID); + ~TwitchAccount() override; + TwitchAccount(const TwitchAccount &) = delete; + TwitchAccount(TwitchAccount &&) = delete; + TwitchAccount &operator=(const TwitchAccount &) = delete; + TwitchAccount &operator=(TwitchAccount &&) = delete; QString toString() const override; @@ -91,23 +74,32 @@ class TwitchAccount : public Account [[nodiscard]] const std::unordered_set &blocks() const; [[nodiscard]] const std::unordered_set &blockedUserIds() const; - void loadEmotes(std::weak_ptr weakChannel = {}); - // loadUserstateEmotes loads emote sets that are part of the USERSTATE emote-sets key - // this function makes sure not to load emote sets that have already been loaded - void loadUserstateEmotes(std::weak_ptr weakChannel = {}); - // setUserStateEmoteSets sets the emote sets that were parsed from the USERSTATE emote-sets key - // Returns true if the newly inserted emote sets differ from the ones previously saved - [[nodiscard]] bool setUserstateEmoteSets(QStringList newEmoteSets); - SharedAccessGuard accessEmotes() const; - SharedAccessGuard> - accessLocalEmotes() const; - // Automod actions void autoModAllow(const QString msgID, ChannelPtr channel); void autoModDeny(const QString msgID, ChannelPtr channel); void loadSeventvUserID(); + /// Returns true if the account has access to the given emote set + bool hasEmoteSet(const EmoteSetId &id) const; + + /// Return a map of emote sets the account has access to + /// + /// Key being the emote set ID, and contents being information about the emote set + /// and the emotes contained in the emote set + SharedAccessGuard> + accessEmoteSets() const; + + /// Return a map of emotes the account has access to + SharedAccessGuard> accessEmotes() const; + + /// Return the emote by emote name if the account has access to the emote + std::optional twitchEmote(const EmoteName &name) const; + + /// Once emotes are reloaded, TwitchAccountManager::emotesReloaded is + /// invoked with @a caller and an optional error. + void reloadEmotes(void *caller = nullptr); + private: QString oauthClient_; QString oauthToken_; @@ -122,9 +114,9 @@ class TwitchAccount : public Account std::unordered_set ignores_; std::unordered_set ignoresUserIds_; - // std::map emotes; - UniqueAccess emotes_; - UniqueAccess> localEmotes_; + ScopedCancellationToken emoteToken_; + UniqueAccess> emoteSets_; + UniqueAccess> emotes_; QString seventvUserID_; }; diff --git a/src/providers/twitch/TwitchAccountManager.cpp b/src/providers/twitch/TwitchAccountManager.cpp index 7d1d302468a..3939bea1450 100644 --- a/src/providers/twitch/TwitchAccountManager.cpp +++ b/src/providers/twitch/TwitchAccountManager.cpp @@ -162,6 +162,7 @@ void TwitchAccountManager::load() } this->currentUserChanged(); + this->currentUser_->reloadEmotes(); }); } diff --git a/src/providers/twitch/TwitchAccountManager.hpp b/src/providers/twitch/TwitchAccountManager.hpp index d3bec563b24..d0e21111d5e 100644 --- a/src/providers/twitch/TwitchAccountManager.hpp +++ b/src/providers/twitch/TwitchAccountManager.hpp @@ -2,6 +2,7 @@ #include "common/ChatterinoSetting.hpp" #include "common/SignalVector.hpp" +#include "util/Expected.hpp" #include "util/QStringHash.hpp" #include "util/RapidJsonSerializeQString.hpp" @@ -58,6 +59,10 @@ class TwitchAccountManager SignalVector> accounts; + /// The signal is invoked with (caller, error) where caller is the argument + /// passed to reloadEmotes() and error. + pajlada::Signals::Signal> emotesReloaded; + private: enum class AddUserResponse { UserAlreadyExists, diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index ba020f529b2..c54e377413c 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -2,6 +2,8 @@ #include "Application.hpp" #include "common/Common.hpp" +#include "common/Env.hpp" +#include "common/Literals.hpp" #include "common/network/NetworkRequest.hpp" #include "common/network/NetworkResult.hpp" #include "common/QLogging.hpp" @@ -32,6 +34,7 @@ #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchCommon.hpp" #include "providers/twitch/TwitchIrcServer.hpp" +#include "providers/twitch/TwitchUsers.hpp" #include "singletons/Emotes.hpp" #include "singletons/Settings.hpp" #include "singletons/StreamerMode.hpp" @@ -47,11 +50,15 @@ #include #include #include +#include #include #include #include namespace chatterino { + +using namespace literals; + namespace { #if QT_VERSION < QT_VERSION_CHECK(6, 1, 0) const QString MAGIC_MESSAGE_SUFFIX = QString((const char *)u8" \U000E0000"); @@ -84,6 +91,7 @@ TwitchChannel::TwitchChannel(const QString &name) , subscriptionUrl_("https://www.twitch.tv/subs/" + name) , channelUrl_("https://twitch.tv/" + name) , popoutPlayerUrl_(TWITCH_PLAYER_URL.arg(name)) + , localTwitchEmotes_(std::make_shared()) , bttvEmotes_(std::make_shared()) , ffzEmotes_(std::make_shared()) , seventvEmotes_(std::make_shared()) @@ -94,6 +102,7 @@ TwitchChannel::TwitchChannel(const QString &name) getApp()->getAccounts()->twitch.currentUserChanged.connect([this] { this->setMod(false); this->refreshPubSub(); + this->refreshTwitchChannelEmotes(false); })); this->refreshPubSub(); @@ -133,6 +142,36 @@ TwitchChannel::TwitchChannel(const QString &name) }); this->threadClearTimer_.start(5 * 60 * 1000); + this->signalHolder_.managedConnect( + getApp()->getAccounts()->twitch.emotesReloaded, + [this](auto *caller, const auto &result) { + if (result) + { + // emotes were reloaded - clear follower emotes if the user is + // now subscribed to the streamer + if (!this->localTwitchEmotes_.get()->empty() && + getApp()->getAccounts()->twitch.getCurrent()->hasEmoteSet( + EmoteSetId{this->localTwitchEmoteSetID_.get()})) + { + this->localTwitchEmotes_.set(std::make_shared()); + } + + if (caller == this) + { + this->addSystemMessage( + "Twitch subscriber emotes reloaded."); + } + return; + } + + if (caller == this || caller == nullptr) + { + this->addSystemMessage( + u"Failed to load Twitch subscriber emotes: " % + result.error()); + } + }); + // debugging #if 0 for (int i = 0; i < 1000; i++) { @@ -194,6 +233,82 @@ void TwitchChannel::setLocalizedName(const QString &name) this->nameOptions.localizedName = name; } +void TwitchChannel::refreshTwitchChannelEmotes(bool manualRefresh) +{ + if (getApp()->isTest()) + { + return; + } + + if (manualRefresh) + { + getApp()->getAccounts()->twitch.getCurrent()->reloadEmotes(this); + } + + // Twitch's 'Get User Emotes' doesn't assigns a different set-ID to follower + // emotes compared to subscriber emotes. + QString setID = TWITCH_SUB_EMOTE_SET_PREFIX % this->roomId(); + this->localTwitchEmoteSetID_.set(setID); + if (getApp()->getAccounts()->twitch.getCurrent()->hasEmoteSet( + EmoteSetId{setID})) + { + this->localTwitchEmotes_.set(std::make_shared()); + return; + } + + auto makeEmotes = [](const auto &emotes) { + EmoteMap map; + for (const auto &emote : emotes) + { + if (emote.type != u"follower") + { + continue; + } + map.emplace( + EmoteName{emote.name}, + getApp()->getEmotes()->getTwitchEmotes()->getOrCreateEmote( + EmoteId{emote.id}, EmoteName{emote.name})); + } + return map; + }; + + getHelix()->getFollowedChannel( + getApp()->getAccounts()->twitch.getCurrent()->getUserId(), + this->roomId(), + [weak{this->weak_from_this()}, makeEmotes](const auto &chan) { + auto self = std::dynamic_pointer_cast(weak.lock()); + if (!self || !chan) + { + return; + } + getHelix()->getChannelEmotes( + self->roomId(), + [weak, makeEmotes](const auto &emotes) { + auto self = + std::dynamic_pointer_cast(weak.lock()); + if (!self) + { + return; + } + + self->localTwitchEmotes_.set( + std::make_shared(makeEmotes(emotes))); + }, + [weak] { + auto self = weak.lock(); + if (!self) + { + return; + } + self->addSystemMessage("Failed to load follower emotes."); + }); + }, + [](const auto &error) { + qCWarning(chatterinoTwitch) + << "Failed to get following status:" << error; + }); +} + void TwitchChannel::refreshBTTVChannelEmotes(bool manualRefresh) { if (!Settings::instance().enableBTTVChannelEmotes) @@ -536,6 +651,7 @@ void TwitchChannel::roomIdChanged() this->refreshPubSub(); this->refreshBadges(); this->refreshCheerEmotes(); + this->refreshTwitchChannelEmotes(false); this->refreshFFZChannelEmotes(false); this->refreshBTTVChannelEmotes(false); this->refreshSevenTVChannelEmotes(false); @@ -789,6 +905,18 @@ SharedAccessGuard return this->streamStatus_.accessConst(); } +std::optional TwitchChannel::twitchEmote(const EmoteName &name) const +{ + auto emotes = this->localTwitchEmotes(); + auto it = emotes->find(name); + + if (it == emotes->end()) + { + return getApp()->getAccounts()->twitch.getCurrent()->twitchEmote(name); + } + return it->second; +} + std::optional TwitchChannel::bttvEmote(const EmoteName &name) const { auto emotes = this->bttvEmotes_.get(); @@ -825,6 +953,11 @@ std::optional TwitchChannel::seventvEmote(const EmoteName &name) const return it->second; } +std::shared_ptr TwitchChannel::localTwitchEmotes() const +{ + return this->localTwitchEmotes_.get(); +} + std::shared_ptr TwitchChannel::bttvEmotes() const { return this->bttvEmotes_.get(); diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index d031ee5b6c8..37b6d363515 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -159,13 +159,17 @@ class TwitchChannel final : public Channel, public ChannelChatters void markConnected(); // Emotes + std::optional twitchEmote(const EmoteName &name) const; std::optional bttvEmote(const EmoteName &name) const; std::optional ffzEmote(const EmoteName &name) const; std::optional seventvEmote(const EmoteName &name) const; + + std::shared_ptr localTwitchEmotes() const; std::shared_ptr bttvEmotes() const; std::shared_ptr ffzEmotes() const; std::shared_ptr seventvEmotes() const; + void refreshTwitchChannelEmotes(bool manualRefresh); void refreshBTTVChannelEmotes(bool manualRefresh); void refreshFFZChannelEmotes(bool manualRefresh); void refreshSevenTVChannelEmotes(bool manualRefresh); @@ -391,6 +395,8 @@ class TwitchChannel final : public Channel, public ChannelChatters protected: void messageRemovedFromStart(const MessagePtr &msg) override; + Atomic> localTwitchEmotes_; + Atomic localTwitchEmoteSetID_; Atomic> bttvEmotes_; Atomic> ffzEmotes_; Atomic> seventvEmotes_; diff --git a/src/providers/twitch/TwitchEmotes.cpp b/src/providers/twitch/TwitchEmotes.cpp index b7ba9f54a00..3389a43ed2e 100644 --- a/src/providers/twitch/TwitchEmotes.cpp +++ b/src/providers/twitch/TwitchEmotes.cpp @@ -1,10 +1,15 @@ #include "providers/twitch/TwitchEmotes.hpp" +#include "common/Literals.hpp" #include "common/QLogging.hpp" +#include "common/UniqueAccess.hpp" #include "messages/Emote.hpp" #include "messages/Image.hpp" +#include "providers/twitch/api/Helix.hpp" #include "util/QStringHash.hpp" +#include + namespace { using namespace chatterino; @@ -399,6 +404,22 @@ qreal getEmote3xScaleFactor(const EmoteId &id) namespace chatterino { +using namespace literals; + +QString TwitchEmoteSet::title() const +{ + if (!this->owner || this->owner->name.isEmpty()) + { + return "Twitch"; + } + if (this->isBits) + { + return this->owner->name + " (Bits)"; + } + + return this->owner->name; +} + QString TwitchEmotes::cleanUpEmoteCode(const QString &dirtyEmoteCode) { auto cleanCode = dirtyEmoteCode; @@ -453,4 +474,44 @@ EmotePtr TwitchEmotes::getOrCreateEmote(const EmoteId &id, return shared; } +TwitchEmoteSetMeta getTwitchEmoteSetMeta(const HelixChannelEmote &emote) +{ + // follower emotes are treated as sub emotes + // A sub emote must have an owner or an emote-set id (otherwise it's a + // global like "BopBop") + bool isSub = + (emote.type == u"subscriptions" || emote.type == u"follower") && + !(emote.ownerID.isEmpty() && emote.setID.isEmpty()); + bool isBits = emote.type == u"bitstier"; + bool isSubLike = isSub || isBits; + + // A lot of emotes don't have their emote-set-id set, so we create a + // virtual emote set that groups emotes by the owner. + // Additionally, a lot of emote sets are small, so they're grouped together as globals. + auto actualSetID = [&]() -> QString { + if (!isSub && !isBits) + { + return u"x-c2-globals"_s; + } + + if (!emote.setID.isEmpty()) + { + return emote.setID; + } + + if (isSub) + { + return TWITCH_SUB_EMOTE_SET_PREFIX % emote.ownerID; + } + // isBits + return TWITCH_BIT_EMOTE_SET_PREFIX % emote.ownerID; + }(); + + return { + .setID = actualSetID, + .isBits = isBits, + .isSubLike = isSubLike, + }; +} + } // namespace chatterino diff --git a/src/providers/twitch/TwitchEmotes.hpp b/src/providers/twitch/TwitchEmotes.hpp index f7691131cbc..06fbbe95099 100644 --- a/src/providers/twitch/TwitchEmotes.hpp +++ b/src/providers/twitch/TwitchEmotes.hpp @@ -2,7 +2,9 @@ #include "common/Aliases.hpp" #include "common/UniqueAccess.hpp" +#include "providers/twitch/TwitchUser.hpp" +#include #include #include #include @@ -36,6 +38,45 @@ struct CheerEmoteSet { std::vector cheerEmotes; }; +struct TwitchEmoteSet { + /// @brief The owner of this set + /// + /// This owner might not be resolved yet + std::shared_ptr owner; + + std::vector emotes; + + /// If this is a bitstier emote set + bool isBits = false; + + /// @brief If this emote set is a subscriber or similar emote set + /// + /// This includes sub and bit emotes + bool isSubLike = false; + + /// @brief The title of this set + /// + /// We generate this based on the emote set's flags & owner + QString title() const; +}; +using TwitchEmoteSetMap = boost::unordered_flat_map; + +struct HelixChannelEmote; + +constexpr QStringView TWITCH_SUB_EMOTE_SET_PREFIX = u"x-c2-s-"; +constexpr QStringView TWITCH_BIT_EMOTE_SET_PREFIX = u"x-c2-b-"; + +struct TwitchEmoteSetMeta { + QString setID; + + /// See TwitchEmoteSet::isBits + bool isBits = false; + /// See TwitchEmoteSet::isSubLike + bool isSubLike = false; +}; + +TwitchEmoteSetMeta getTwitchEmoteSetMeta(const HelixChannelEmote &emote); + class ITwitchEmotes { public: diff --git a/src/providers/twitch/TwitchIrcServer.cpp b/src/providers/twitch/TwitchIrcServer.cpp index b0bfcf69dad..6c3e6ae50e9 100644 --- a/src/providers/twitch/TwitchIrcServer.cpp +++ b/src/providers/twitch/TwitchIrcServer.cpp @@ -648,6 +648,40 @@ void TwitchIrcServer::initialize() }); }); + this->connections_.managedConnect( + getApp()->getTwitchPubSub()->moderation.raidStarted, + [this](const auto &action) { + auto chan = this->getChannelOrEmptyByID(action.roomID); + + if (chan->isEmpty()) + { + return; + } + + auto msg = MessageBuilder(action).release(); + + postToThread([chan, msg] { + chan->addMessage(msg, MessageContext::Original); + }); + }); + + this->connections_.managedConnect( + getApp()->getTwitchPubSub()->moderation.raidCanceled, + [this](const auto &action) { + auto chan = this->getChannelOrEmptyByID(action.roomID); + + if (chan->isEmpty()) + { + return; + } + + auto msg = MessageBuilder(action).release(); + + postToThread([chan, msg] { + chan->addMessage(msg, MessageContext::Original); + }); + }); + this->connections_.managedConnect( getApp()->getTwitchPubSub()->pointReward.redeemed, [this](auto &data) { QString channelId = data.value("channel_id").toString(); @@ -819,10 +853,6 @@ void TwitchIrcServer::readConnectionMessageReceived( this->markChannelsConnected(); this->connect(); } - else if (command == "GLOBALUSERSTATE") - { - handler.handleGlobalUserStateMessage(message); - } } void TwitchIrcServer::writeConnectionMessageReceived( diff --git a/src/providers/twitch/TwitchUser.cpp b/src/providers/twitch/TwitchUser.cpp index f1b6255098d..655e206147d 100644 --- a/src/providers/twitch/TwitchUser.cpp +++ b/src/providers/twitch/TwitchUser.cpp @@ -1,7 +1,7 @@ #include "providers/twitch/TwitchUser.hpp" +#include "debug/AssertInGuiThread.hpp" #include "providers/twitch/api/Helix.hpp" -#include "util/RapidjsonHelpers.hpp" namespace chatterino { @@ -12,4 +12,12 @@ void TwitchUser::fromHelixBlock(const HelixBlock &ignore) this->displayName = ignore.displayName; } +void TwitchUser::update(const HelixUser &user) const +{ + assertInGuiThread(); + assert(this->id == user.id); + this->name = user.login; + this->displayName = user.displayName; +} + } // namespace chatterino diff --git a/src/providers/twitch/TwitchUser.hpp b/src/providers/twitch/TwitchUser.hpp index d615c95b011..bc1b0837020 100644 --- a/src/providers/twitch/TwitchUser.hpp +++ b/src/providers/twitch/TwitchUser.hpp @@ -12,6 +12,7 @@ namespace chatterino { struct HelixBlock; +struct HelixUser; struct TwitchUser { QString id; @@ -26,6 +27,8 @@ struct TwitchUser { this->displayName = other.displayName; } + void update(const HelixUser &user) const; + void fromHelixBlock(const HelixBlock &ignore); bool operator<(const TwitchUser &rhs) const diff --git a/src/providers/twitch/TwitchUsers.cpp b/src/providers/twitch/TwitchUsers.cpp new file mode 100644 index 00000000000..583d662c672 --- /dev/null +++ b/src/providers/twitch/TwitchUsers.cpp @@ -0,0 +1,147 @@ +#include "providers/twitch/TwitchUsers.hpp" + +#include "common/QLogging.hpp" +#include "providers/twitch/api/Helix.hpp" +#include "providers/twitch/TwitchUser.hpp" + +#include +#include +#include + +namespace { + +auto withSelf(auto *ptr, auto cb) +{ + return [weak{ptr->weak_from_this()}, cb = std::move(cb)](auto &&...args) { + auto self = weak.lock(); + if (!self) + { + return; + } + cb(std::move(self), std::forward(args)...); + }; +} + +} // namespace + +namespace chatterino { + +class TwitchUsersPrivate + : public std::enable_shared_from_this +{ +public: + TwitchUsersPrivate(); + +private: + boost::unordered_flat_map> cache; + QStringList unresolved; + QTimer nextBatchTimer; + bool isResolving = false; + + std::shared_ptr makeUnresolved(const UserId &id); + void makeNextRequest(); + void updateUsers(const std::vector &users); + + friend TwitchUsers; +}; + +TwitchUsers::TwitchUsers() + : private_(new TwitchUsersPrivate) +{ +} + +TwitchUsers::~TwitchUsers() = default; + +std::shared_ptr TwitchUsers::resolveID(const UserId &id) +{ + auto cached = this->private_->cache.find(id); + if (cached != this->private_->cache.end()) + { + return cached->second; + } + return this->private_->makeUnresolved(id); +} + +TwitchUsersPrivate::TwitchUsersPrivate() +{ + this->nextBatchTimer.setSingleShot(true); + // Wait for multiple request batches to come in before making a request + this->nextBatchTimer.setInterval(250); + + QObject::connect(&this->nextBatchTimer, &QTimer::timeout, [this] { + this->makeNextRequest(); + }); +} + +std::shared_ptr TwitchUsersPrivate::makeUnresolved(const UserId &id) +{ + // assumption: Cache entry is empty so neither a shared pointer was created + // nor an entry in the unresolved list was added. + auto ptr = this->cache + .emplace(id, std::make_shared(TwitchUser{ + .id = id.string, + .name = {}, + .displayName = {}, + })) + .first->second; + if (id.string.isEmpty()) + { + return ptr; + } + + this->unresolved.append(id.string); + if (!this->isResolving && !this->nextBatchTimer.isActive()) + { + this->nextBatchTimer.start(); + } + return ptr; +} + +void TwitchUsersPrivate::makeNextRequest() +{ + if (this->unresolved.empty()) + { + return; + } + + if (this->isResolving) + { + qCWarning(chatterinoTwitch) << "Tried to start request while resolving"; + return; + } + this->isResolving = true; + + auto ids = this->unresolved.mid( + 0, std::min(this->unresolved.size(), 100)); + this->unresolved = this->unresolved.mid(ids.length()); + getHelix()->fetchUsers(ids, {}, + withSelf(this, + [](auto self, const auto &users) { + self->updateUsers(users); + self->isResolving = false; + self->makeNextRequest(); + }), + withSelf(this, [](auto self) { + qCWarning(chatterinoTwitch) + << "Failed to load users"; + self->isResolving = false; + self->makeNextRequest(); + })); +} + +void TwitchUsersPrivate::updateUsers(const std::vector &users) +{ + for (const auto &user : users) + { + auto cached = this->cache.find(UserId{user.id}); + if (cached == this->cache.end()) + { + qCWarning(chatterinoTwitch) << "Couldn't find user" << user.login + << "with id" << user.id << "in cache"; + continue; + } + cached->second->update(user); + } +} + +} // namespace chatterino diff --git a/src/providers/twitch/TwitchUsers.hpp b/src/providers/twitch/TwitchUsers.hpp new file mode 100644 index 00000000000..c6b7b7b3a85 --- /dev/null +++ b/src/providers/twitch/TwitchUsers.hpp @@ -0,0 +1,55 @@ +#pragma once + +#include "common/Aliases.hpp" + +#include + +namespace chatterino { + +struct TwitchUser; +class TwitchChannel; + +class ITwitchUsers +{ +public: + ITwitchUsers() = default; + virtual ~ITwitchUsers() = default; + ITwitchUsers(const ITwitchUsers &) = delete; + ITwitchUsers(ITwitchUsers &&) = delete; + ITwitchUsers &operator=(const ITwitchUsers &) = delete; + ITwitchUsers &operator=(ITwitchUsers &&) = delete; + + /// @brief Resolve a TwitchUser by their ID + /// + /// Users are cached. If the user wasn't resolved yet, a request will be + /// scheduled. The returned shared pointer must only be used on the GUI + /// thread as it will be updated from there. + /// + /// @returns A shared reference to the TwitchUser. The `name` and + /// `displayName` might be empty if the user wasn't resolved yet or + /// they don't exist. + virtual std::shared_ptr resolveID(const UserId &id) = 0; +}; + +class TwitchUsersPrivate; +class TwitchUsers : public ITwitchUsers +{ +public: + TwitchUsers(); + ~TwitchUsers() override; + TwitchUsers(const TwitchUsers &) = delete; + TwitchUsers(TwitchUsers &&) = delete; + TwitchUsers &operator=(const TwitchUsers &) = delete; + TwitchUsers &operator=(TwitchUsers &&) = delete; + + /// @see ITwitchUsers::resolveID() + std::shared_ptr resolveID(const UserId &id) override; + +private: + // Using a shared_ptr to pass to network callbacks + std::shared_ptr private_; + + friend TwitchUsersPrivate; +}; + +} // namespace chatterino diff --git a/src/providers/twitch/api/Helix.cpp b/src/providers/twitch/api/Helix.cpp index 5b8c9fbfd09..6844a2dccbc 100644 --- a/src/providers/twitch/api/Helix.cpp +++ b/src/providers/twitch/api/Helix.cpp @@ -540,7 +540,8 @@ void Helix::loadBlocks(QString userId, size_t receivedItems = 0; this->paginate( u"users/blocks"_s, query, - [pageCallback, receivedItems](const QJsonObject &json) mutable { + [pageCallback, receivedItems](const QJsonObject &json, + const auto & /*state*/) mutable { const auto data = json["data"_L1].toArray(); if (data.isEmpty()) @@ -3063,6 +3064,123 @@ void Helix::sendChatMessage( .execute(); } +void Helix::getUserEmotes( + QString userID, QString broadcasterID, + ResultCallback, HelixPaginationState> + pageCallback, + FailureCallback failureCallback, CancellationToken &&token) +{ + QUrlQuery query{{u"user_id"_s, userID}}; + if (!broadcasterID.isEmpty()) + { + query.addQueryItem(u"broadcaster_id"_s, broadcasterID); + } + + this->paginate( + u"chat/emotes/user"_s, query, + [pageCallback](const QJsonObject &json, const auto &state) mutable { + const auto data = json["data"_L1].toArray(); + + if (data.isEmpty()) + { + return false; + } + + std::vector emotes; + emotes.reserve(data.count()); + + for (const auto &emote : data) + { + emotes.emplace_back(emote.toObject()); + } + + pageCallback(emotes, state); + + return true; + }, + [failureCallback](const NetworkResult &result) { + if (!result.status()) + { + failureCallback(result.formatError()); + return; + } + + const auto obj = result.parseJson(); + auto message = obj["message"].toString(); + + switch (*result.status()) + { + case 401: { + if (message.startsWith("Missing scope", + Qt::CaseInsensitive)) + { + failureCallback("Missing required scope. Re-login with " + "your account and try again."); + break; + } + [[fallthrough]]; + } + default: { + qCWarning(chatterinoTwitch) + << "Helix get user emotes, unhandled error data:" + << result.formatError() << result.getData() << obj; + failureCallback(message); + } + } + }, + std::move(token)); +} + +void Helix::getFollowedChannel( + QString userID, QString broadcasterID, + ResultCallback> successCallback, + FailureCallback failureCallback) +{ + this->makeGet("channels/followed", + { + {u"user_id"_s, userID}, + {u"broadcaster_id"_s, broadcasterID}, + }) + .onSuccess([successCallback](auto result) { + if (result.status() != 200) + { + qCWarning(chatterinoTwitch) + << "Success result for getting badges was " + << result.formatError() << "but we expected it to be 200"; + } + + const auto response = result.parseJson(); + const auto channel = response["data"_L1].toArray().at(0); + if (channel.isObject()) + { + successCallback(HelixFollowedChannel(channel.toObject())); + } + else + { + successCallback(std::nullopt); + } + }) + .onError([failureCallback](const auto &result) -> void { + if (!result.status()) + { + failureCallback(result.formatError()); + return; + } + + auto obj = result.parseJson(); + auto message = obj.value("message").toString(); + if (!message.isEmpty()) + { + failureCallback(message); + } + else + { + failureCallback(result.formatError()); + } + }) + .execute(); +} + NetworkRequest Helix::makeRequest(const QString &url, const QUrlQuery &urlQuery, NetworkRequestType type) { @@ -3120,10 +3238,12 @@ NetworkRequest Helix::makePatch(const QString &url, const QUrlQuery &urlQuery) return this->makeRequest(url, urlQuery, NetworkRequestType::Patch); } -void Helix::paginate(const QString &url, const QUrlQuery &baseQuery, - std::function onPage, - std::function onError, - CancellationToken &&cancellationToken) +void Helix::paginate( + const QString &url, const QUrlQuery &baseQuery, + std::function + onPage, + std::function onError, + CancellationToken &&cancellationToken) { auto onSuccess = std::make_shared>(nullptr); @@ -3143,14 +3263,19 @@ void Helix::paginate(const QString &url, const QUrlQuery &baseQuery, } const auto json = res.parseJson(); - if (!onPage(json)) + const auto pagination = json["pagination"_L1].toObject(); + + auto cursor = pagination["cursor"_L1].toString(); + HelixPaginationState state{.done = cursor.isEmpty()}; + + if (!onPage(json, state)) { // The consumer doesn't want any more pages return; } - auto cursor = json["pagination"_L1]["cursor"_L1].toString(); - if (cursor.isEmpty()) + // After done is set, onPage must never be called again + if (state.done) { return; } diff --git a/src/providers/twitch/api/Helix.hpp b/src/providers/twitch/api/Helix.hpp index 9fab2648e70..ec82f165441 100644 --- a/src/providers/twitch/api/Helix.hpp +++ b/src/providers/twitch/api/Helix.hpp @@ -266,18 +266,18 @@ struct HelixEmoteSetData { }; struct HelixChannelEmote { - const QString emoteId; + const QString id; const QString name; const QString type; - const QString setId; - const QString url; - - explicit HelixChannelEmote(QJsonObject jsonObject) - : emoteId(jsonObject.value("id").toString()) - , name(jsonObject.value("name").toString()) - , type(jsonObject.value("emote_type").toString()) - , setId(jsonObject.value("emote_set_id").toString()) - , url(TWITCH_EMOTE_TEMPLATE.arg(this->emoteId, u"3.0")) + const QString setID; + const QString ownerID; + + explicit HelixChannelEmote(const QJsonObject &jsonObject) + : id(jsonObject["id"].toString()) + , name(jsonObject["name"].toString()) + , type(jsonObject["emote_type"].toString()) + , setID(jsonObject["emote_set_id"].toString()) + , ownerID(jsonObject["owner_id"].toString()) { } }; @@ -438,6 +438,22 @@ struct HelixSentMessage { } }; +struct HelixFollowedChannel { + QString broadcasterID; + QString broadcasterLogin; + QString broadcasterName; + QDateTime followedAt; + + explicit HelixFollowedChannel(const QJsonObject &jsonObject) + : broadcasterID(jsonObject["broadcaster_id"].toString()) + , broadcasterLogin(jsonObject["broadcaster_login"].toString()) + , broadcasterName(jsonObject["broadcaster_name"].toString()) + , followedAt(QDateTime::fromString(jsonObject["followed_at"].toString(), + Qt::ISODate)) + { + } +}; + struct HelixSendMessageArgs { QString broadcasterID; QString senderID; @@ -786,6 +802,10 @@ struct HelixError { using HelixGetChannelBadgesError = HelixGetGlobalBadgesError; +struct HelixPaginationState { + bool done; +}; + class IHelix { public: @@ -1112,6 +1132,21 @@ class IHelix ResultCallback successCallback, FailureCallback failureCallback) = 0; + /// https://dev.twitch.tv/docs/api/reference/#get-user-emotes + virtual void getUserEmotes( + QString userID, QString broadcasterID, + ResultCallback, HelixPaginationState> + pageCallback, + FailureCallback failureCallback, + CancellationToken &&token) = 0; + + /// https://dev.twitch.tv/docs/api/reference/#get-followed-channels + /// (non paginated) + virtual void getFollowedChannel( + QString userID, QString broadcasterID, + ResultCallback> successCallback, + FailureCallback failureCallback) = 0; + virtual void update(QString clientId, QString oauthToken) = 0; protected: @@ -1440,6 +1475,21 @@ class Helix final : public IHelix ResultCallback successCallback, FailureCallback failureCallback) final; + /// https://dev.twitch.tv/docs/api/reference/#get-user-emotes + void getUserEmotes( + QString userID, QString broadcasterID, + ResultCallback, HelixPaginationState> + pageCallback, + FailureCallback failureCallback, + CancellationToken &&token) final; + + /// https://dev.twitch.tv/docs/api/reference/#get-followed-channels + /// (non paginated) + void getFollowedChannel( + QString userID, QString broadcasterID, + ResultCallback> successCallback, + FailureCallback failureCallback) final; + void update(QString clientId, QString oauthToken) final; static void initialize(); @@ -1494,7 +1544,9 @@ class Helix final : public IHelix /// Paginate the `url` endpoint and use `baseQuery` as the starting point for pagination. /// @param onPage returns true while a new page is expected. Once false is returned, pagination will stop. void paginate(const QString &url, const QUrlQuery &baseQuery, - std::function onPage, + std::function + onPage, std::function onError, CancellationToken &&token); diff --git a/src/util/CancellationToken.hpp b/src/util/CancellationToken.hpp index 12c26f6b1af..0da45601092 100644 --- a/src/util/CancellationToken.hpp +++ b/src/util/CancellationToken.hpp @@ -2,6 +2,7 @@ #include #include +#include namespace chatterino { @@ -10,24 +11,30 @@ namespace chatterino { class CancellationToken { public: - CancellationToken() = default; + CancellationToken() noexcept = default; explicit CancellationToken(bool isCancelled) : isCancelled_(new std::atomic(isCancelled)) { } CancellationToken(const CancellationToken &) = default; - CancellationToken(CancellationToken &&other) + CancellationToken(CancellationToken &&other) noexcept : isCancelled_(std::move(other.isCancelled_)){}; - CancellationToken &operator=(CancellationToken &&other) + /// @brief This destructor doesn't cancel the token + /// + /// @see ScopedCancellationToken + /// @see #cancel() + ~CancellationToken() noexcept = default; + + CancellationToken &operator=(CancellationToken &&other) noexcept { this->isCancelled_ = std::move(other.isCancelled_); return *this; } CancellationToken &operator=(const CancellationToken &) = default; - void cancel() + void cancel() noexcept { if (this->isCancelled_ != nullptr) { @@ -35,7 +42,7 @@ class CancellationToken } } - bool isCancelled() const + bool isCancelled() const noexcept { return this->isCancelled_ == nullptr || this->isCancelled_->load(std::memory_order_acquire); @@ -50,30 +57,35 @@ class ScopedCancellationToken { public: ScopedCancellationToken() = default; - ScopedCancellationToken(CancellationToken &&backingToken) - : backingToken_(std::move(backingToken)) - { - } - ScopedCancellationToken(CancellationToken backingToken) + explicit ScopedCancellationToken(CancellationToken backingToken) : backingToken_(std::move(backingToken)) { } + ScopedCancellationToken(const ScopedCancellationToken &) = delete; + ScopedCancellationToken(ScopedCancellationToken &&other) noexcept + : backingToken_(std::move(other.backingToken_)){}; + ~ScopedCancellationToken() { this->backingToken_.cancel(); } - ScopedCancellationToken(const ScopedCancellationToken &) = delete; - ScopedCancellationToken(ScopedCancellationToken &&other) - : backingToken_(std::move(other.backingToken_)){}; - ScopedCancellationToken &operator=(ScopedCancellationToken &&other) + ScopedCancellationToken &operator=(CancellationToken token) noexcept { - this->backingToken_ = std::move(other.backingToken_); + this->backingToken_.cancel(); + this->backingToken_ = std::move(token); return *this; } + ScopedCancellationToken &operator=(const ScopedCancellationToken &) = delete; + ScopedCancellationToken &operator=(ScopedCancellationToken &&other) noexcept + { + this->backingToken_.cancel(); + this->backingToken_ = std::move(other.backingToken_); + return *this; + } private: CancellationToken backingToken_; diff --git a/src/util/Expected.hpp b/src/util/Expected.hpp new file mode 100644 index 00000000000..87c46fa1ae2 --- /dev/null +++ b/src/util/Expected.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include + +class QString; + +namespace chatterino { + +template +using Expected = nonstd::expected_lite::expected; + +template +using ExpectedStr = Expected; + +// convenience function from nonstd/expected.hpp +template +constexpr nonstd::unexpected> makeUnexpected(E &&value) +{ + return nonstd::unexpected>(std::forward(value)); +} + +} // namespace chatterino diff --git a/src/widgets/BaseWindow.cpp b/src/widgets/BaseWindow.cpp index 7d24a85d4dd..48a8a7be95e 100644 --- a/src/widgets/BaseWindow.cpp +++ b/src/widgets/BaseWindow.cpp @@ -23,6 +23,7 @@ #ifdef USEWINSDK # include +# include # include # include # include @@ -32,7 +33,6 @@ # include # include # include -# include #endif #include "widgets/helper/TitlebarButton.hpp" @@ -505,7 +505,7 @@ bool BaseWindow::event(QEvent *event) } #if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0) - if (this->flags_.has(DontFocus) || this->flags_.has(Dialog)) + if (this->flags_.hasAny(DontFocus, Dialog, FramelessDraggable)) { // This certain windows (e.g. TooltipWidget, input completion widget, and the search popup) retains their nullptr parent // NOTE that this currently does not retain their original transient parent (which is the window it was created under) diff --git a/src/widgets/dialogs/EmotePopup.cpp b/src/widgets/dialogs/EmotePopup.cpp index 8936549e6eb..156d0882ddd 100644 --- a/src/widgets/dialogs/EmotePopup.cpp +++ b/src/widgets/dialogs/EmotePopup.cpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include @@ -42,13 +43,14 @@ auto makeTitleMessage(const QString &title) return builder.release(); } -auto makeEmoteMessage(const EmoteMap &map, const MessageElementFlag &emoteFlag) +auto makeEmoteMessage(std::vector emotes, + const MessageElementFlag &emoteFlag) { MessageBuilder builder; builder->flags.set(MessageFlag::Centered); builder->flags.set(MessageFlag::DisableCompactEmotes); - if (map.empty()) + if (emotes.empty()) { builder.emplace("no emotes available", MessageElementFlag::Text, @@ -56,24 +58,43 @@ auto makeEmoteMessage(const EmoteMap &map, const MessageElementFlag &emoteFlag) return builder.release(); } - std::vector> vec(map.begin(), map.end()); - std::sort(vec.begin(), vec.end(), - [](const std::pair &l, - const std::pair &r) { - return compareEmoteStrings(l.first.string, r.first.string); - }); - for (const auto &emote : vec) + std::sort(emotes.begin(), emotes.end(), [](const auto &l, const auto &r) { + return compareEmoteStrings(l->name.string, r->name.string); + }); + for (const auto &emote : emotes) { builder .emplace( - emote.second, + emote, MessageElementFlags{MessageElementFlag::AlwaysShow, emoteFlag}) - ->setLink(Link(Link::InsertText, emote.first.string)); + ->setLink(Link(Link::InsertText, emote->name.string)); } return builder.release(); } +auto makeEmoteMessage(const EmoteMap &map, const MessageElementFlag &emoteFlag) +{ + if (map.empty()) + { + MessageBuilder builder; + builder->flags.set(MessageFlag::Centered); + builder->flags.set(MessageFlag::DisableCompactEmotes); + builder.emplace("no emotes available", + MessageElementFlag::Text, + MessageColor::System); + return builder.release(); + } + + std::vector vec; + vec.reserve(map.size()); + for (const auto &[_name, ptr] : map) + { + vec.emplace_back(ptr); + } + return makeEmoteMessage(std::move(vec), emoteFlag); +} + auto makeEmojiMessage(const std::vector &emojiMap) { MessageBuilder builder; @@ -94,77 +115,46 @@ auto makeEmojiMessage(const std::vector &emojiMap) return builder.release(); } -void addTwitchEmoteSets( - std::vector> sets, - Channel &globalChannel, Channel &subChannel, QString currentChannelName) +void addEmotes(Channel &channel, auto emotes, const QString &title, + const MessageElementFlag &emoteFlag) { - QMap>> mapOfSets; + channel.addMessage(makeTitleMessage(title), MessageContext::Original); + channel.addMessage(makeEmoteMessage(emotes, emoteFlag), + MessageContext::Original); +} - for (const auto &set : sets) +void addTwitchEmoteSets(const std::shared_ptr &local, + const std::shared_ptr &sets, + Channel &globalChannel, Channel &subChannel, + const QString ¤tChannelID, + const QString &channelName) +{ + if (!local->empty()) { - // Some emotes (e.g. follower ones) are only available in their origin channel - if (set->local && currentChannelName != set->channelName) - { - continue; - } - - // TITLE - auto channelName = set->channelName; - auto text = set->text.isEmpty() ? "Twitch" : set->text; - - // EMOTES - MessageBuilder builder; - builder->flags.set(MessageFlag::Centered); - builder->flags.set(MessageFlag::DisableCompactEmotes); - - // If value of map is empty, create init pair and add title. - if (mapOfSets.find(channelName) == mapOfSets.end()) - { - std::vector b; - b.push_back(makeTitleMessage(text)); - mapOfSets[channelName] = qMakePair(set->key == "0", b); - } - - for (const auto &emote : set->emotes) - { - builder - .emplace( - getApp()->getEmotes()->getTwitchEmotes()->getOrCreateEmote( - emote.id, emote.name), - MessageElementFlags{MessageElementFlag::AlwaysShow, - MessageElementFlag::TwitchEmote}) - ->setLink(Link(Link::InsertText, emote.name.string)); - } - - mapOfSets[channelName].second.push_back(builder.release()); + addEmotes(subChannel, *local, channelName % u" (Follower)", + MessageElementFlag::TwitchEmote); } - // Output to channel all created messages, - // That contain title or emotes. // Put current channel emotes at the top - auto currentChannelPair = mapOfSets[currentChannelName]; - for (const auto &message : currentChannelPair.second) + for (const auto &[_id, set] : *sets) { - subChannel.addMessage(message, MessageContext::Original); + if (set.owner->id == currentChannelID) + { + addEmotes(subChannel, set.emotes, set.title(), + MessageElementFlag::TwitchEmote); + } } - mapOfSets.remove(currentChannelName); - for (const auto &pair : mapOfSets) + for (const auto &[id, set] : *sets) { - auto &channel = pair.first ? globalChannel : subChannel; - for (const auto &message : pair.second) + if (set.owner->id == currentChannelID) { - channel.addMessage(message, MessageContext::Original); + continue; } - } -} -void addEmotes(Channel &channel, const EmoteMap &map, const QString &title, - const MessageElementFlag &emoteFlag) -{ - channel.addMessage(makeTitleMessage(title), MessageContext::Original); - channel.addMessage(makeEmoteMessage(map, emoteFlag), - MessageContext::Original); + addEmotes(set.isSubLike ? subChannel : globalChannel, set.emotes, + set.title(), MessageElementFlag::TwitchEmote); + } } void loadEmojis(ChannelView &view, const std::vector &emojiMap) @@ -200,6 +190,22 @@ EmoteMap filterEmoteMap(const QString &text, return filteredMap; } +std::vector filterEmoteVec(const QString &text, + const std::vector &emotes) +{ + std::vector filtered; + + for (const auto &emote : emotes) + { + if (emote->name.string.contains(text, Qt::CaseInsensitive)) + { + filtered.emplace_back(emote); + } + } + + return filtered; +} + } // namespace namespace chatterino { @@ -289,6 +295,17 @@ EmotePopup::EmotePopup(QWidget *parent) }); this->search_->setFocus(); + + this->signalHolder_.managedConnect( + getApp()->getAccounts()->twitch.emotesReloaded, + [this](auto * /*caller*/, const auto &result) { + if (!result) + { + // some error occurred, no need to reload + return; + } + this->reloadEmotes(); + }); } void EmotePopup::addShortcuts() @@ -398,14 +415,37 @@ void EmotePopup::loadChannel(ChannelPtr channel) return; } - auto subChannel = std::make_shared("", Channel::Type::None); - auto globalChannel = std::make_shared("", Channel::Type::None); - auto channelChannel = std::make_shared("", Channel::Type::None); + this->globalEmotesView_->setChannel( + std::make_shared("", Channel::Type::None)); + this->subEmotesView_->setChannel( + std::make_shared("", Channel::Type::None)); + this->channelEmotesView_->setChannel( + std::make_shared("", Channel::Type::None)); + + this->reloadEmotes(); +} + +void EmotePopup::reloadEmotes() +{ + if (this->twitchChannel_ == nullptr) + { + return; + } + + auto subChannel = this->subEmotesView_->underlyingChannel(); + auto globalChannel = this->globalEmotesView_->underlyingChannel(); + auto channelChannel = this->channelEmotesView_->underlyingChannel(); + + subChannel->clearMessages(); + globalChannel->clearMessages(); + channelChannel->clearMessages(); // twitch addTwitchEmoteSets( - getApp()->getAccounts()->twitch.getCurrent()->accessEmotes()->emoteSets, - *globalChannel, *subChannel, this->channel_->getName()); + twitchChannel_->localTwitchEmotes(), + *getApp()->getAccounts()->twitch.getCurrent()->accessEmoteSets(), + *globalChannel, *subChannel, twitchChannel_->roomId(), + twitchChannel_->getName()); // global if (Settings::instance().enableBTTVGlobalEmotes) @@ -441,10 +481,6 @@ void EmotePopup::loadChannel(ChannelPtr channel) "7TV", MessageElementFlag::SevenTVEmote); } - this->globalEmotesView_->setChannel(globalChannel); - this->subEmotesView_->setChannel(subChannel); - this->channelEmotesView_->setChannel(channelChannel); - if (subChannel->getMessageSnapshot().size() == 0) { MessageBuilder builder; @@ -475,24 +511,22 @@ bool EmotePopup::eventFilter(QObject *object, QEvent *event) void EmotePopup::filterTwitchEmotes(std::shared_ptr searchChannel, const QString &searchText) { - auto twitchEmoteSets = - getApp()->getAccounts()->twitch.getCurrent()->accessEmotes()->emoteSets; - std::vector> twitchGlobalEmotes{}; - - for (const auto &set : twitchEmoteSets) - { - auto setCopy = std::make_shared(*set); - auto setIt = - std::remove_if(setCopy->emotes.begin(), setCopy->emotes.end(), - [searchText](auto &emote) { - return !emote.name.string.contains( - searchText, Qt::CaseInsensitive); - }); - setCopy->emotes.resize(std::distance(setCopy->emotes.begin(), setIt)); - - if (!setCopy->emotes.empty()) + if (this->twitchChannel_) + { + auto local = filterEmoteMap(searchText, + this->twitchChannel_->localTwitchEmotes()); + if (!local.empty()) { - twitchGlobalEmotes.push_back(setCopy); + addEmotes(*searchChannel, local, + this->twitchChannel_->getName() % u" (local)", + MessageElementFlag::TwitchEmote); + } + + for (const auto &[_id, set] : + **getApp()->getAccounts()->twitch.getCurrent()->accessEmoteSets()) + { + addEmotes(*searchChannel, filterEmoteVec(searchText, set.emotes), + set.title(), MessageElementFlag::TwitchEmote); } } @@ -503,10 +537,6 @@ void EmotePopup::filterTwitchEmotes(std::shared_ptr searchChannel, auto seventvGlobalEmotes = filterEmoteMap( searchText, getApp()->getSeventvEmotes()->globalEmotes()); - // twitch - addTwitchEmoteSets(twitchGlobalEmotes, *searchChannel, *searchChannel, - this->channel_->getName()); - // global if (!bttvGlobalEmotes.empty()) { diff --git a/src/widgets/dialogs/EmotePopup.hpp b/src/widgets/dialogs/EmotePopup.hpp index 85b5aa5c4f2..d93a4c9338b 100644 --- a/src/widgets/dialogs/EmotePopup.hpp +++ b/src/widgets/dialogs/EmotePopup.hpp @@ -52,6 +52,8 @@ class EmotePopup : public BasePopup void addShortcuts() override; bool eventFilter(QObject *object, QEvent *event) override; + void reloadEmotes(); + void saveBounds() const; }; diff --git a/src/widgets/helper/ChannelView.cpp b/src/widgets/helper/ChannelView.cpp index 441d5b3c69e..ab0e632260c 100644 --- a/src/widgets/helper/ChannelView.cpp +++ b/src/widgets/helper/ChannelView.cpp @@ -897,6 +897,11 @@ ChannelPtr ChannelView::channel() return this->channel_; } +ChannelPtr ChannelView::underlyingChannel() const +{ + return this->underlyingChannel_; +} + bool ChannelView::showScrollbarHighlights() const { return this->channel_->getType() != Channel::Type::TwitchMentions; @@ -976,6 +981,11 @@ void ChannelView::setChannel(const ChannelPtr &underlyingChannel) this->channel_->fillInMissingMessages(filtered); }); + this->channelConnections_.managedConnect(underlyingChannel->messagesCleared, + [this]() { + this->clearMessages(); + }); + // Copy over messages from the backing channel to the filtered one // and the ui. auto snapshot = underlyingChannel->getMessageSnapshot(); diff --git a/src/widgets/helper/ChannelView.hpp b/src/widgets/helper/ChannelView.hpp index f9f68bc2c1c..64683aca68c 100644 --- a/src/widgets/helper/ChannelView.hpp +++ b/src/widgets/helper/ChannelView.hpp @@ -144,9 +144,20 @@ class ChannelView final : public BaseWidget /// filter settings. It will always be of type Channel, not TwitchChannel /// nor IrcChannel. /// It's **not** equal to the channel passed in #setChannel(). + /// @see #underlyingChannel() ChannelPtr channel(); - /// Set the channel this view is displaying + /// @brief The channel this view displays messages for + /// + /// This channel potentially contains more messages than visible in this + /// view due to filter settings. + /// It's equal to the channel passed in #setChannel(). + /// @see #channel() + ChannelPtr underlyingChannel() const; + + /// @brief Set the channel this view is displaying + /// + /// @see #underlyingChannel() void setChannel(const ChannelPtr &underlyingChannel); void setFilters(const QList &ids); diff --git a/src/widgets/splits/Split.cpp b/src/widgets/splits/Split.cpp index 49057314066..641a87a1018 100644 --- a/src/widgets/splits/Split.cpp +++ b/src/widgets/splits/Split.cpp @@ -1508,10 +1508,10 @@ void Split::showSearch(bool singleChannel) void Split::reloadChannelAndSubscriberEmotes() { auto channel = this->getChannel(); - getApp()->getAccounts()->twitch.getCurrent()->loadEmotes(channel); if (auto *twitchChannel = dynamic_cast(channel.get())) { + twitchChannel->refreshTwitchChannelEmotes(true); twitchChannel->refreshBTTVChannelEmotes(true); twitchChannel->refreshFFZChannelEmotes(true); twitchChannel->refreshSevenTVChannelEmotes(true); diff --git a/src/widgets/splits/SplitHeader.cpp b/src/widgets/splits/SplitHeader.cpp index 4d784212502..09725879b23 100644 --- a/src/widgets/splits/SplitHeader.cpp +++ b/src/widgets/splits/SplitHeader.cpp @@ -1078,7 +1078,10 @@ void SplitHeader::reloadSubscriberEmotes() this->lastReloadedSubEmotes_ = now; auto channel = this->split_->getChannel(); - getApp()->getAccounts()->twitch.getCurrent()->loadEmotes(channel); + if (auto *twitchChannel = dynamic_cast(channel.get())) + { + twitchChannel->refreshTwitchChannelEmotes(true); + } } void SplitHeader::reconnect() diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 40c688621cc..41f90b1ffec 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -47,6 +47,7 @@ set(test_SOURCES ${CMAKE_CURRENT_LIST_DIR}/src/Commands.cpp ${CMAKE_CURRENT_LIST_DIR}/src/FlagsEnum.cpp ${CMAKE_CURRENT_LIST_DIR}/src/MessageLayoutContainer.cpp + ${CMAKE_CURRENT_LIST_DIR}/src/CancellationToken.cpp # Add your new file above this line! ) diff --git a/tests/src/CancellationToken.cpp b/tests/src/CancellationToken.cpp new file mode 100644 index 00000000000..bb76956befa --- /dev/null +++ b/tests/src/CancellationToken.cpp @@ -0,0 +1,173 @@ +#include "util/CancellationToken.hpp" + +#include + +using namespace chatterino; + +// CancellationToken + +TEST(CancellationToken, ctor) +{ + { + CancellationToken token; + ASSERT_TRUE(token.isCancelled()); + token.cancel(); + ASSERT_TRUE(token.isCancelled()); + } + { + CancellationToken token(false); + ASSERT_FALSE(token.isCancelled()); + token.cancel(); + ASSERT_TRUE(token.isCancelled()); + } + { + CancellationToken token(true); + ASSERT_TRUE(token.isCancelled()); + token.cancel(); + ASSERT_TRUE(token.isCancelled()); + } +} + +TEST(CancellationToken, moveCtor) +{ + CancellationToken token(false); + ASSERT_FALSE(token.isCancelled()); + CancellationToken token2(std::move(token)); + // NOLINTNEXTLINE(bugprone-use-after-move) + ASSERT_TRUE(token.isCancelled()); + ASSERT_FALSE(token2.isCancelled()); + + token.cancel(); + ASSERT_FALSE(token2.isCancelled()); + + token2.cancel(); + ASSERT_TRUE(token2.isCancelled()); +} + +TEST(CancellationToken, moveAssign) +{ + CancellationToken token(false); + CancellationToken token2; + ASSERT_FALSE(token.isCancelled()); + ASSERT_TRUE(token2.isCancelled()); + + token2 = std::move(token); + // NOLINTNEXTLINE(bugprone-use-after-move) + ASSERT_TRUE(token.isCancelled()); + ASSERT_FALSE(token2.isCancelled()); + token.cancel(); + ASSERT_FALSE(token2.isCancelled()); + + token2.cancel(); + ASSERT_TRUE(token2.isCancelled()); +} + +TEST(CancellationToken, copyCtor) +{ + CancellationToken token(false); + ASSERT_FALSE(token.isCancelled()); + CancellationToken token2(token); + ASSERT_FALSE(token.isCancelled()); + ASSERT_FALSE(token2.isCancelled()); + token2.cancel(); + ASSERT_TRUE(token2.isCancelled()); + ASSERT_TRUE(token.isCancelled()); +} + +TEST(CancellationToken, copyAssign) +{ + CancellationToken token(false); + CancellationToken token2; + ASSERT_FALSE(token.isCancelled()); + ASSERT_TRUE(token2.isCancelled()); + + token2 = token; + ASSERT_FALSE(token.isCancelled()); + ASSERT_FALSE(token2.isCancelled()); + token2.cancel(); + ASSERT_TRUE(token.isCancelled()); + ASSERT_TRUE(token2.isCancelled()); +} + +TEST(CancellationToken, dtor) +{ + CancellationToken token(false); + { + // NOLINTNEXTLINE(performance-unnecessary-copy-initialization) + CancellationToken token2 = token; + ASSERT_FALSE(token.isCancelled()); + ASSERT_FALSE(token2.isCancelled()); + } + + ASSERT_FALSE(token.isCancelled()); +} + +// ScopedCancellationToken + +TEST(ScopedCancellationToken, moveCancelCtor) +{ + CancellationToken token(false); + ASSERT_FALSE(token.isCancelled()); + { + ScopedCancellationToken scoped(std::move(token)); + // NOLINTNEXTLINE(bugprone-use-after-move) + ASSERT_TRUE(token.isCancelled()); + } +} + +TEST(ScopedCancellationToken, copyCancelCtor) +{ + CancellationToken token(false); + ASSERT_FALSE(token.isCancelled()); + { + ScopedCancellationToken scoped(token); + ASSERT_FALSE(token.isCancelled()); + } + ASSERT_TRUE(token.isCancelled()); +} + +TEST(ScopedCancellationToken, moveCtor) +{ + CancellationToken token(false); + ASSERT_FALSE(token.isCancelled()); + { + ScopedCancellationToken scoped(token); + { + ScopedCancellationToken inner(std::move(scoped)); + ASSERT_FALSE(token.isCancelled()); + } + ASSERT_TRUE(token.isCancelled()); + } +} + +TEST(ScopedCancellationToken, moveAssign) +{ + CancellationToken token(false); + CancellationToken token2(false); + { + ScopedCancellationToken scoped(token); + { + ScopedCancellationToken inner(token2); + ASSERT_FALSE(token.isCancelled()); + ASSERT_FALSE(token2.isCancelled()); + inner = std::move(scoped); + ASSERT_FALSE(token.isCancelled()); + ASSERT_TRUE(token2.isCancelled()); + } + ASSERT_TRUE(token.isCancelled()); + } +} + +TEST(ScopedCancellationToken, copyAssign) +{ + CancellationToken token(false); + CancellationToken token2(false); + { + ScopedCancellationToken scoped(token); + ASSERT_FALSE(token.isCancelled()); + scoped = token2; + ASSERT_FALSE(token2.isCancelled()); + ASSERT_TRUE(token.isCancelled()); + } + ASSERT_TRUE(token2.isCancelled()); +}