diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 328c4d6bd68..242e8ad9d4b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -87,6 +87,7 @@ jobs: - name: Test (Ubuntu) if: startsWith(matrix.os, 'ubuntu') + timeout-minutes: 30 run: | docker pull kennethreitz/httpbin docker pull ${{ env.TWITCH_PUBSUB_SERVER_IMAGE }} diff --git a/CHANGELOG.md b/CHANGELOG.md index f47f7281786..56e33c0443e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - Minor: Added a message for when Chatterino joins a channel (#4616) - Minor: Add accelerators to the right click menu for messages (#4705) - Minor: Add pin action to usercards and reply threads. (#4692) +- Minor: 7TV badges now automatically update upon changing. (#4512) - Minor: Stream status requests are now batched. (#4713) - Minor: Added `/c2-theme-autoreload` command to automatically reload a custom theme. This is useful for when you're developing your own theme. (#4718) - Bugfix: Increased amount of blocked users loaded from 100 to 1,000. (#4721) diff --git a/benchmarks/src/main.cpp b/benchmarks/src/main.cpp index 5f8f678709f..32e0636faec 100644 --- a/benchmarks/src/main.cpp +++ b/benchmarks/src/main.cpp @@ -22,6 +22,15 @@ int main(int argc, char **argv) ::benchmark::RunSpecifiedBenchmarks(); settingsDir.remove(); + + // Pick up the last events from the eventloop + // Using a loop to catch events queueing other events (e.g. deletions) + for (size_t i = 0; i < 32; i++) + { + QCoreApplication::processEvents(QEventLoop::ExcludeUserInputEvents); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + } + QApplication::exit(0); }); diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 89b05e4291d..b1df0023ae8 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -283,8 +283,11 @@ set(SOURCE_FILES providers/liveupdates/BasicPubSubManager.hpp providers/liveupdates/BasicPubSubWebsocket.hpp + providers/seventv/SeventvAPI.cpp + providers/seventv/SeventvAPI.hpp providers/seventv/SeventvBadges.cpp providers/seventv/SeventvBadges.hpp + providers/seventv/SeventvCosmetics.hpp providers/seventv/SeventvEmotes.cpp providers/seventv/SeventvEmotes.hpp providers/seventv/SeventvEventAPI.cpp diff --git a/src/providers/seventv/SeventvAPI.cpp b/src/providers/seventv/SeventvAPI.cpp new file mode 100644 index 00000000000..265c420e103 --- /dev/null +++ b/src/providers/seventv/SeventvAPI.cpp @@ -0,0 +1,92 @@ +#include "providers/seventv/SeventvAPI.hpp" + +#include "common/Literals.hpp" +#include "common/NetworkRequest.hpp" +#include "common/NetworkResult.hpp" +#include "common/Outcome.hpp" + +namespace { + +using namespace chatterino::literals; + +const QString API_URL_USER = u"https://7tv.io/v3/users/twitch/%1"_s; +const QString API_URL_EMOTE_SET = u"https://7tv.io/v3/emote-sets/%1"_s; +const QString API_URL_PRESENCES = u"https://7tv.io/v3/users/%1/presences"_s; + +} // namespace + +// NOLINTBEGIN(readability-convert-member-functions-to-static) +namespace chatterino { + +void SeventvAPI::getUserByTwitchID( + const QString &twitchID, SuccessCallback &&onSuccess, + ErrorCallback &&onError) +{ + 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; + }) + .onError([callback = std::move(onError)](const NetworkResult &result) { + callback(result); + }) + .execute(); +} + +void SeventvAPI::getEmoteSet(const QString &emoteSet, + SuccessCallback &&onSuccess, + ErrorCallback &&onError) +{ + 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; + }) + .onError([callback = std::move(onError)](const NetworkResult &result) { + callback(result); + }) + .execute(); +} + +void SeventvAPI::updatePresence(const QString &twitchChannelID, + const QString &seventvUserID, + SuccessCallback<> &&onSuccess, + ErrorCallback &&onError) +{ + QJsonObject payload{ + {u"kind"_s, 1}, // UserPresenceKindChannel + {u"data"_s, + QJsonObject{ + {u"id"_s, twitchChannelID}, + {u"platform"_s, u"TWITCH"_s}, + }}, + }; + + NetworkRequest(API_URL_PRESENCES.arg(seventvUserID), + NetworkRequestType::Post) + .json(payload) + .timeout(10000) + .onSuccess([callback = std::move(onSuccess)](const auto &) -> Outcome { + callback(); + return Success; + }) + .onError([callback = std::move(onError)](const NetworkResult &result) { + callback(result); + }) + .execute(); +} + +SeventvAPI &getSeventvAPI() +{ + static SeventvAPI instance; + return instance; +} + +} // namespace chatterino +// NOLINTEND(readability-convert-member-functions-to-static) diff --git a/src/providers/seventv/SeventvAPI.hpp b/src/providers/seventv/SeventvAPI.hpp new file mode 100644 index 00000000000..fd75345f525 --- /dev/null +++ b/src/providers/seventv/SeventvAPI.hpp @@ -0,0 +1,33 @@ +#pragma once + +#include + +class QString; +class QJsonObject; + +namespace chatterino { + +class NetworkResult; + +class SeventvAPI +{ + using ErrorCallback = std::function; + template + using SuccessCallback = std::function; + +public: + void getUserByTwitchID(const QString &twitchID, + SuccessCallback &&onSuccess, + ErrorCallback &&onError); + void getEmoteSet(const QString &emoteSet, + SuccessCallback &&onSuccess, + ErrorCallback &&onError); + + void updatePresence(const QString &twitchChannelID, + const QString &seventvUserID, + SuccessCallback<> &&onSuccess, ErrorCallback &&onError); +}; + +SeventvAPI &getSeventvAPI(); + +} // namespace chatterino diff --git a/src/providers/seventv/SeventvBadges.cpp b/src/providers/seventv/SeventvBadges.cpp index 1216d0863e2..e54682baa75 100644 --- a/src/providers/seventv/SeventvBadges.cpp +++ b/src/providers/seventv/SeventvBadges.cpp @@ -1,10 +1,11 @@ #include "providers/seventv/SeventvBadges.hpp" -#include "common/NetworkRequest.hpp" -#include "common/NetworkResult.hpp" -#include "common/Outcome.hpp" #include "messages/Emote.hpp" +#include "messages/Image.hpp" +#include "providers/seventv/SeventvAPI.hpp" +#include "providers/seventv/SeventvEmotes.hpp" +#include #include #include @@ -12,66 +13,68 @@ namespace chatterino { -void SeventvBadges::initialize(Settings & /*settings*/, Paths & /*paths*/) -{ - this->loadSeventvBadges(); -} - -boost::optional SeventvBadges::getBadge(const UserId &id) +boost::optional SeventvBadges::getBadge(const UserId &id) const { std::shared_lock lock(this->mutex_); auto it = this->badgeMap_.find(id.string); if (it != this->badgeMap_.end()) { - return this->emotes_[it->second]; + return it->second; } return boost::none; } -void SeventvBadges::loadSeventvBadges() +void SeventvBadges::assignBadgeToUser(const QString &badgeID, + const UserId &userID) +{ + const std::unique_lock lock(this->mutex_); + + const auto badgeIt = this->knownBadges_.find(badgeID); + if (badgeIt != this->knownBadges_.end()) + { + this->badgeMap_[userID.string] = badgeIt->second; + } +} + +void SeventvBadges::clearBadgeFromUser(const QString &badgeID, + const UserId &userID) +{ + const std::unique_lock lock(this->mutex_); + + const auto it = this->badgeMap_.find(userID.string); + if (it != this->badgeMap_.end() && it->second->id.string == badgeID) + { + this->badgeMap_.erase(userID.string); + } +} + +void SeventvBadges::registerBadge(const QJsonObject &badgeJson) { - // Cosmetics will work differently in v3, until this is ready - // we'll use this endpoint. - static QUrl url("https://7tv.io/v2/cosmetics"); - - static QUrlQuery urlQuery; - // valid user_identifier values: "object_id", "twitch_id", "login" - urlQuery.addQueryItem("user_identifier", "twitch_id"); - - url.setQuery(urlQuery); - - NetworkRequest(url) - .onSuccess([this](const NetworkResult &result) -> Outcome { - auto root = result.parseJson(); - - std::unique_lock lock(this->mutex_); - - int index = 0; - for (const auto &jsonBadge : root.value("badges").toArray()) - { - auto badge = jsonBadge.toObject(); - auto urls = badge.value("urls").toArray(); - auto emote = - Emote{EmoteName{}, - ImageSet{Url{urls.at(0).toArray().at(1).toString()}, - Url{urls.at(1).toArray().at(1).toString()}, - Url{urls.at(2).toArray().at(1).toString()}}, - Tooltip{badge.value("tooltip").toString()}, Url{}}; - - this->emotes_.push_back( - std::make_shared(std::move(emote))); - - for (const auto &user : badge.value("users").toArray()) - { - this->badgeMap_[user.toString()] = index; - } - ++index; - } - - return Success; - }) - .execute(); + const auto badgeID = badgeJson["id"].toString(); + + const std::unique_lock lock(this->mutex_); + + if (this->knownBadges_.find(badgeID) != this->knownBadges_.end()) + { + return; + } + + auto emote = Emote{ + .name = EmoteName{}, + .images = SeventvEmotes::createImageSet(badgeJson), + .tooltip = Tooltip{badgeJson["tooltip"].toString()}, + .homePage = Url{}, + .id = EmoteId{badgeID}, + }; + + if (emote.images.getImage1()->isEmpty()) + { + return; // Bad images + } + + this->knownBadges_[badgeID] = + std::make_shared(std::move(emote)); } } // namespace chatterino diff --git a/src/providers/seventv/SeventvBadges.hpp b/src/providers/seventv/SeventvBadges.hpp index 98725d179cb..a6ed981ee87 100644 --- a/src/providers/seventv/SeventvBadges.hpp +++ b/src/providers/seventv/SeventvBadges.hpp @@ -5,11 +5,11 @@ #include "util/QStringHash.hpp" #include +#include #include #include #include -#include namespace chatterino { @@ -19,18 +19,27 @@ using EmotePtr = std::shared_ptr; class SeventvBadges : public Singleton { public: - void initialize(Settings &settings, Paths &paths) override; + // Return the badge, if any, that is assigned to the user + boost::optional getBadge(const UserId &id) const; - boost::optional getBadge(const UserId &id); + // Assign the given badge to the user + void assignBadgeToUser(const QString &badgeID, const UserId &userID); -private: - void loadSeventvBadges(); + // Remove the given badge from the user + void clearBadgeFromUser(const QString &badgeID, const UserId &userID); + + // Register a new known badge + // The json object will contain all information about the badge, like its ID & its images + void registerBadge(const QJsonObject &badgeJson); - // Mutex for both `badgeMap_` and `emotes_` - std::shared_mutex mutex_; +private: + // Mutex for both `badgeMap_` and `knownBadges_` + mutable std::shared_mutex mutex_; - std::unordered_map badgeMap_; - std::vector emotes_; + // user-id => badge + std::unordered_map badgeMap_; + // badge-id => badge + std::unordered_map knownBadges_; }; } // namespace chatterino diff --git a/src/providers/seventv/SeventvCosmetics.hpp b/src/providers/seventv/SeventvCosmetics.hpp new file mode 100644 index 00000000000..0d521ac03a8 --- /dev/null +++ b/src/providers/seventv/SeventvCosmetics.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include + +namespace chatterino::seventv { + +enum class CosmeticKind { + Badge, + Paint, + EmoteSet, + + INVALID, +}; + +} // namespace chatterino::seventv + +template <> +constexpr magic_enum::customize::customize_t + magic_enum::customize::enum_name( + chatterino::seventv::CosmeticKind value) noexcept +{ + using chatterino::seventv::CosmeticKind; + switch (value) + { + case CosmeticKind::Badge: + return "BADGE"; + case CosmeticKind::Paint: + return "PAINT"; + case CosmeticKind::EmoteSet: + return "EMOTE_SET"; + + default: + return default_tag; + } +} diff --git a/src/providers/seventv/SeventvEmotes.cpp b/src/providers/seventv/SeventvEmotes.cpp index a384189fac3..6d64cd37a58 100644 --- a/src/providers/seventv/SeventvEmotes.cpp +++ b/src/providers/seventv/SeventvEmotes.cpp @@ -1,6 +1,6 @@ #include "providers/seventv/SeventvEmotes.hpp" -#include "common/NetworkRequest.hpp" +#include "common/Literals.hpp" #include "common/NetworkResult.hpp" #include "common/QLogging.hpp" #include "messages/Emote.hpp" @@ -8,6 +8,7 @@ #include "messages/ImageSet.hpp" #include "messages/MessageBuilder.hpp" #include "providers/seventv/eventapi/Dispatch.hpp" +#include "providers/seventv/SeventvAPI.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "singletons/Settings.hpp" @@ -36,10 +37,6 @@ using namespace seventv::eventapi; const QString CHANNEL_HAS_NO_EMOTES("This channel has no 7TV channel emotes."); const QString EMOTE_LINK_FORMAT("https://7tv.app/emotes/%1"); -const QString API_URL_USER("https://7tv.io/v3/users/twitch/%1"); -const QString API_URL_GLOBAL_EMOTE_SET("https://7tv.io/v3/emote-sets/global"); -const QString API_URL_EMOTE_SET("https://7tv.io/v3/emote-sets/%1"); - struct CreateEmoteResult { Emote emote; EmoteId id; @@ -77,71 +74,6 @@ bool isZeroWidthRecommended(const QJsonObject &emoteData) return flags.has(SeventvEmoteFlag::ZeroWidth); } -ImageSet makeImageSet(const QJsonObject &emoteData) -{ - auto host = emoteData["host"].toObject(); - // "//cdn.7tv[...]" - auto baseUrl = host["url"].toString(); - auto files = host["files"].toArray(); - - // TODO: emit four images - std::array sizes; - double baseWidth = 0.0; - int nextSize = 0; - - for (auto fileItem : files) - { - if (nextSize >= sizes.size()) - { - break; - } - - auto file = fileItem.toObject(); - if (file["format"].toString() != "WEBP") - { - continue; // We only use webp - } - - double width = file["width"].toDouble(); - double scale = 1.0; // in relation to first image - if (baseWidth > 0.0) - { - scale = baseWidth / width; - } - else - { - // => this is the first image - baseWidth = width; - } - - auto image = Image::fromUrl( - {QString("https:%1/%2").arg(baseUrl, file["name"].toString())}, - scale); - - sizes.at(nextSize) = image; - nextSize++; - } - - if (nextSize < sizes.size()) - { - // this should be really rare - // this means we didn't get all sizes of an emote - if (nextSize == 0) - { - qCDebug(chatterinoSeventv) - << "Got file list without any eligible files"; - // When this emote is typed, chatterino will crash. - return ImageSet{}; - } - for (; nextSize < sizes.size(); nextSize++) - { - sizes.at(nextSize) = Image::getEmpty(); - } - } - - return ImageSet{sizes[0], sizes[1], sizes[2]}; -} - Tooltip createTooltip(const QString &name, const QString &author, bool isGlobal) { return Tooltip{QString("%1
%2 7TV Emote
By: %3") @@ -172,7 +104,7 @@ CreateEmoteResult createEmote(const QJsonObject &activeEmote, ? createAliasedTooltip(emoteName.string, baseEmoteName.string, author.string, isGlobal) : createTooltip(emoteName.string, author.string, isGlobal); - auto imageSet = makeImageSet(emoteData); + auto imageSet = SeventvEmotes::createImageSet(emoteData); auto emote = Emote({emoteName, imageSet, tooltip, @@ -247,6 +179,7 @@ EmotePtr createUpdatedEmote(const EmotePtr &oldEmote, namespace chatterino { using namespace seventv::eventapi; +using namespace literals; SeventvEmotes::SeventvEmotes() : global_(std::make_shared()) @@ -281,24 +214,21 @@ void SeventvEmotes::loadGlobalEmotes() qCDebug(chatterinoSeventv) << "Loading 7TV Global Emotes"; - NetworkRequest(API_URL_GLOBAL_EMOTE_SET, NetworkRequestType::Get) - .timeout(30000) - .onSuccess([this](const NetworkResult &result) -> Outcome { - QJsonArray parsedEmotes = result.parseJson()["emotes"].toArray(); + getSeventvAPI().getEmoteSet( + u"global"_s, + [this](const auto &json) { + QJsonArray parsedEmotes = json["emotes"].toArray(); auto emoteMap = parseEmotes(parsedEmotes, true); qCDebug(chatterinoSeventv) << "Loaded" << emoteMap.size() << "7TV Global Emotes"; this->setGlobalEmotes( std::make_shared(std::move(emoteMap))); - - return Success; - }) - .onError([](const NetworkResult &result) { + }, + [](const auto &result) { qCWarning(chatterinoSeventv) << "Couldn't load 7TV global emotes" << result.getData(); - }) - .execute(); + }); } void SeventvEmotes::setGlobalEmotes(std::shared_ptr emotes) @@ -313,13 +243,12 @@ void SeventvEmotes::loadChannelEmotes( qCDebug(chatterinoSeventv) << "Reloading 7TV Channel Emotes" << channelId << manualRefresh; - NetworkRequest(API_URL_USER.arg(channelId), NetworkRequestType::Get) - .timeout(20000) - .onSuccess([callback = std::move(callback), channel, channelId, - manualRefresh](const NetworkResult &result) -> Outcome { - auto json = result.parseJson(); - auto emoteSet = json["emote_set"].toObject(); - auto parsedEmotes = emoteSet["emotes"].toArray(); + getSeventvAPI().getUserByTwitchID( + channelId, + [callback = std::move(callback), channel, channelId, + manualRefresh](const auto &json) { + const auto emoteSet = json["emote_set"].toObject(); + const auto parsedEmotes = emoteSet["emotes"].toArray(); auto emoteMap = parseEmotes(parsedEmotes, false); bool hasEmotes = !emoteMap.empty(); @@ -350,7 +279,7 @@ void SeventvEmotes::loadChannelEmotes( auto shared = channel.lock(); if (!shared) { - return Success; + return; } if (manualRefresh) @@ -366,40 +295,37 @@ void SeventvEmotes::loadChannelEmotes( makeSystemMessage(CHANNEL_HAS_NO_EMOTES)); } } - return Success; - }) - .onError( - [channelId, channel, manualRefresh](const NetworkResult &result) { - auto shared = channel.lock(); - if (!shared) - { - return; - } - if (result.status() == 404) - { - qCWarning(chatterinoSeventv) - << "Error occurred fetching 7TV emotes: " - << result.parseJson(); - if (manualRefresh) - { - shared->addMessage( - makeSystemMessage(CHANNEL_HAS_NO_EMOTES)); - } - } - else + }, + [channelId, channel, manualRefresh](const auto &result) { + auto shared = channel.lock(); + if (!shared) + { + return; + } + if (result.status() == 404) + { + qCWarning(chatterinoSeventv) + << "Error occurred fetching 7TV emotes: " + << result.parseJson(); + if (manualRefresh) { - // TODO: Auto retry in case of a timeout, with a delay - auto errorString = result.formatError(); - qCWarning(chatterinoSeventv) - << "Error fetching 7TV emotes for channel" << channelId - << ", error" << errorString; - shared->addMessage(makeSystemMessage( - QStringLiteral("Failed to fetch 7TV channel " - "emotes. (Error: %1)") - .arg(errorString))); + shared->addMessage( + makeSystemMessage(CHANNEL_HAS_NO_EMOTES)); } - }) - .execute(); + } + else + { + // TODO: Auto retry in case of a timeout, with a delay + auto errorString = result.formatError(); + qCWarning(chatterinoSeventv) + << "Error fetching 7TV emotes for channel" << channelId + << ", error" << errorString; + shared->addMessage(makeSystemMessage( + QStringLiteral("Failed to fetch 7TV channel " + "emotes. (Error: %1)") + .arg(errorString))); + } + }); } boost::optional SeventvEmotes::addEmote( @@ -479,11 +405,9 @@ void SeventvEmotes::getEmoteSet( { qCDebug(chatterinoSeventv) << "Loading 7TV Emote Set" << emoteSetId; - NetworkRequest(API_URL_EMOTE_SET.arg(emoteSetId), NetworkRequestType::Get) - .timeout(20000) - .onSuccess([callback = std::move(successCallback), - emoteSetId](const NetworkResult &result) -> Outcome { - auto json = result.parseJson(); + getSeventvAPI().getEmoteSet( + emoteSetId, + [callback = std::move(successCallback), emoteSetId](const auto &json) { auto parsedEmotes = json["emotes"].toArray(); auto emoteMap = parseEmotes(parsedEmotes, false); @@ -492,13 +416,74 @@ void SeventvEmotes::getEmoteSet( << "7TV Emotes from" << emoteSetId; callback(std::move(emoteMap), json["name"].toString()); - return Success; - }) - .onError([emoteSetId, callback = std::move(errorCallback)]( - const NetworkResult &result) { + }, + [emoteSetId, callback = std::move(errorCallback)](const auto &result) { callback(result.formatError()); - }) - .execute(); + }); +} + +ImageSet SeventvEmotes::createImageSet(const QJsonObject &emoteData) +{ + auto host = emoteData["host"].toObject(); + // "//cdn.7tv[...]" + auto baseUrl = host["url"].toString(); + auto files = host["files"].toArray(); + + std::array sizes; + double baseWidth = 0.0; + size_t nextSize = 0; + + for (auto fileItem : files) + { + if (nextSize >= sizes.size()) + { + break; + } + + auto file = fileItem.toObject(); + if (file["format"].toString() != "WEBP") + { + continue; // We only use webp + } + + double width = file["width"].toDouble(); + double scale = 1.0; // in relation to first image + if (baseWidth > 0.0) + { + scale = baseWidth / width; + } + else + { + // => this is the first image + baseWidth = width; + } + + auto image = Image::fromUrl( + {QString("https:%1/%2").arg(baseUrl, file["name"].toString())}, + scale); + + sizes.at(nextSize) = image; + nextSize++; + } + + if (nextSize < sizes.size()) + { + // this should be really rare + // this means we didn't get all sizes of an emote + if (nextSize == 0) + { + qCDebug(chatterinoSeventv) + << "Got file list without any eligible files"; + // When this emote is typed, chatterino will crash. + return ImageSet{}; + } + for (; nextSize < sizes.size(); nextSize++) + { + sizes.at(nextSize) = Image::getEmpty(); + } + } + + return ImageSet{sizes[0], sizes[1], sizes[2]}; } } // namespace chatterino diff --git a/src/providers/seventv/SeventvEmotes.hpp b/src/providers/seventv/SeventvEmotes.hpp index 7fea024bb19..e8a59f13bfd 100644 --- a/src/providers/seventv/SeventvEmotes.hpp +++ b/src/providers/seventv/SeventvEmotes.hpp @@ -5,12 +5,14 @@ #include "common/Atomic.hpp" #include "common/FlagsEnum.hpp" +#include + #include namespace chatterino { +class ImageSet; class Channel; - namespace seventv::eventapi { struct EmoteAddDispatch; struct EmoteUpdateDispatch; @@ -61,6 +63,20 @@ struct Emote; using EmotePtr = std::shared_ptr; class EmoteMap; +enum class SeventvEmoteSetKind : uint8_t { + Global, + Personal, + Channel, +}; + +enum class SeventvEmoteSetFlag : uint32_t { + Immutable = (1 << 0), + Privileged = (1 << 1), + Personal = (1 << 2), + Commercial = (1 << 3), +}; +using SeventvEmoteSetFlags = FlagsEnum; + class SeventvEmotes final { public: @@ -120,6 +136,13 @@ class SeventvEmotes final std::function successCallback, std::function errorCallback); + /** + * Creates an image set from a 7TV emote or badge. + * + * @param emoteData { host: { files: [], url } } + */ + static ImageSet createImageSet(const QJsonObject &emoteData); + private: Atomic> global_; }; diff --git a/src/providers/seventv/SeventvEventAPI.cpp b/src/providers/seventv/SeventvEventAPI.cpp index 5cec6ed30a3..5b1ebaf77ab 100644 --- a/src/providers/seventv/SeventvEventAPI.cpp +++ b/src/providers/seventv/SeventvEventAPI.cpp @@ -1,8 +1,11 @@ #include "providers/seventv/SeventvEventAPI.hpp" +#include "Application.hpp" #include "providers/seventv/eventapi/Client.hpp" #include "providers/seventv/eventapi/Dispatch.hpp" #include "providers/seventv/eventapi/Message.hpp" +#include "providers/seventv/SeventvBadges.hpp" +#include "providers/seventv/SeventvCosmetics.hpp" #include @@ -10,6 +13,7 @@ namespace chatterino { +using namespace seventv; using namespace seventv::eventapi; SeventvEventAPI::SeventvEventAPI( @@ -35,6 +39,25 @@ void SeventvEventAPI::subscribeUser(const QString &userID, } } +void SeventvEventAPI::subscribeTwitchChannel(const QString &id) +{ + if (this->subscribedTwitchChannels_.insert(id).second) + { + this->subscribe({ + ChannelCondition{id}, + SubscriptionType::CreateCosmetic, + }); + this->subscribe({ + ChannelCondition{id}, + SubscriptionType::CreateEntitlement, + }); + this->subscribe({ + ChannelCondition{id}, + SubscriptionType::DeleteEntitlement, + }); + } +} + void SeventvEventAPI::unsubscribeEmoteSet(const QString &id) { if (this->subscribedEmoteSets_.erase(id) > 0) @@ -53,6 +76,25 @@ void SeventvEventAPI::unsubscribeUser(const QString &id) } } +void SeventvEventAPI::unsubscribeTwitchChannel(const QString &id) +{ + if (this->subscribedTwitchChannels_.erase(id) > 0) + { + this->unsubscribe({ + ChannelCondition{id}, + SubscriptionType::CreateCosmetic, + }); + this->unsubscribe({ + ChannelCondition{id}, + SubscriptionType::CreateEntitlement, + }); + this->unsubscribe({ + ChannelCondition{id}, + SubscriptionType::DeleteEntitlement, + }); + } +} + std::shared_ptr> SeventvEventAPI::createClient( liveupdates::WebsocketClient &client, websocketpp::connection_hdl hdl) { @@ -144,9 +186,49 @@ void SeventvEventAPI::handleDispatch(const Dispatch &dispatch) this->onUserUpdate(dispatch); } break; + case SubscriptionType::CreateCosmetic: { + const CosmeticCreateDispatch cosmetic(dispatch); + if (cosmetic.validate()) + { + this->onCosmeticCreate(cosmetic); + } + else + { + qCDebug(chatterinoSeventvEventAPI) + << "Invalid cosmetic dispatch" << dispatch.body; + } + } + break; + case SubscriptionType::CreateEntitlement: { + const EntitlementCreateDeleteDispatch entitlement(dispatch); + if (entitlement.validate()) + { + this->onEntitlementCreate(entitlement); + } + else + { + qCDebug(chatterinoSeventvEventAPI) + << "Invalid entitlement create dispatch" << dispatch.body; + } + } + break; + case SubscriptionType::DeleteEntitlement: { + const EntitlementCreateDeleteDispatch entitlement(dispatch); + if (entitlement.validate()) + { + this->onEntitlementDelete(entitlement); + } + else + { + qCDebug(chatterinoSeventvEventAPI) + << "Invalid entitlement delete dispatch" << dispatch.body; + } + } + break; default: { qCDebug(chatterinoSeventvEventAPI) - << "Unknown subscription type:" << (int)dispatch.type + << "Unknown subscription type:" + << magic_enum::enum_name(dispatch.type).data() << "body:" << dispatch.body; } break; @@ -261,4 +343,59 @@ void SeventvEventAPI::onUserUpdate(const Dispatch &dispatch) } } +// NOLINTBEGIN(readability-convert-member-functions-to-static) + +void SeventvEventAPI::onCosmeticCreate(const CosmeticCreateDispatch &cosmetic) +{ + // We're using `Application::instance` instead of getApp(), because we're not in the GUI thread. + // `seventvBadges` does its own locking. + auto *badges = Application::instance->seventvBadges; + switch (cosmetic.kind) + { + case CosmeticKind::Badge: { + badges->registerBadge(cosmetic.data); + } + break; + default: + break; + } +} + +void SeventvEventAPI::onEntitlementCreate( + const EntitlementCreateDeleteDispatch &entitlement) +{ + // We're using `Application::instance` instead of getApp(), because we're not in the GUI thread. + // `seventvBadges` does its own locking. + auto *badges = Application::instance->seventvBadges; + switch (entitlement.kind) + { + case CosmeticKind::Badge: { + badges->assignBadgeToUser(entitlement.refID, + UserId{entitlement.userID}); + } + break; + default: + break; + } +} + +void SeventvEventAPI::onEntitlementDelete( + const EntitlementCreateDeleteDispatch &entitlement) +{ + // We're using `Application::instance` instead of getApp(), because we're not in the GUI thread. + // `seventvBadges` does its own locking. + auto *badges = Application::instance->seventvBadges; + switch (entitlement.kind) + { + case CosmeticKind::Badge: { + badges->clearBadgeFromUser(entitlement.refID, + UserId{entitlement.userID}); + } + break; + default: + break; + } +} +// NOLINTEND(readability-convert-member-functions-to-static) + } // namespace chatterino diff --git a/src/providers/seventv/SeventvEventAPI.hpp b/src/providers/seventv/SeventvEventAPI.hpp index 5672e59b8d7..6a482731897 100644 --- a/src/providers/seventv/SeventvEventAPI.hpp +++ b/src/providers/seventv/SeventvEventAPI.hpp @@ -15,8 +15,12 @@ namespace seventv::eventapi { struct EmoteUpdateDispatch; struct EmoteRemoveDispatch; struct UserConnectionUpdateDispatch; + struct CosmeticCreateDispatch; + struct EntitlementCreateDeleteDispatch; } // namespace seventv::eventapi +class SeventvBadges; + class SeventvEventAPI : public BasicPubSubManager { @@ -44,11 +48,20 @@ class SeventvEventAPI * @param emoteSetID 7TV emote-set-id, may be empty. */ void subscribeUser(const QString &userID, const QString &emoteSetID); + /** + * Subscribes to cosmetics and entitlements in a Twitch channel + * if not already subscribed. + * + * @param id Twitch channel id + */ + void subscribeTwitchChannel(const QString &id); /** Unsubscribes from a user by its 7TV user id */ void unsubscribeUser(const QString &id); /** Unsubscribes from an emote-set by its id */ void unsubscribeEmoteSet(const QString &id); + /** Unsubscribes from cosmetics and entitlements in a Twitch channel */ + void unsubscribeTwitchChannel(const QString &id); protected: std::shared_ptr> @@ -64,11 +77,19 @@ class SeventvEventAPI void onEmoteSetUpdate(const seventv::eventapi::Dispatch &dispatch); void onUserUpdate(const seventv::eventapi::Dispatch &dispatch); + void onCosmeticCreate( + const seventv::eventapi::CosmeticCreateDispatch &cosmetic); + void onEntitlementCreate( + const seventv::eventapi::EntitlementCreateDeleteDispatch &entitlement); + void onEntitlementDelete( + const seventv::eventapi::EntitlementCreateDeleteDispatch &entitlement); /** emote-set ids */ std::unordered_set subscribedEmoteSets_; /** user ids */ std::unordered_set subscribedUsers_; + /** Twitch channel ids */ + std::unordered_set subscribedTwitchChannels_; std::chrono::milliseconds heartbeatInterval_; }; diff --git a/src/providers/seventv/eventapi/Dispatch.cpp b/src/providers/seventv/eventapi/Dispatch.cpp index bb4b4fa1da9..03fbdac970c 100644 --- a/src/providers/seventv/eventapi/Dispatch.cpp +++ b/src/providers/seventv/eventapi/Dispatch.cpp @@ -1,5 +1,7 @@ #include "providers/seventv/eventapi/Dispatch.hpp" +#include + #include namespace chatterino::seventv::eventapi { @@ -91,4 +93,45 @@ bool UserConnectionUpdateDispatch::validate() const !this->emoteSetID.isEmpty(); } +CosmeticCreateDispatch::CosmeticCreateDispatch(const Dispatch &dispatch) + : data(dispatch.body["object"]["data"].toObject()) + , kind(magic_enum::enum_cast( + dispatch.body["object"]["kind"].toString().toStdString()) + .value_or(CosmeticKind::INVALID)) +{ +} + +bool CosmeticCreateDispatch::validate() const +{ + return !this->data.empty() && this->kind != CosmeticKind::INVALID; +} + +EntitlementCreateDeleteDispatch::EntitlementCreateDeleteDispatch( + const Dispatch &dispatch) +{ + const auto obj = dispatch.body["object"].toObject(); + this->refID = obj["ref_id"].toString(); + this->kind = magic_enum::enum_cast( + obj["kind"].toString().toStdString()) + .value_or(CosmeticKind::INVALID); + + const auto userConnections = obj["user"]["connections"].toArray(); + for (const auto &connectionJson : userConnections) + { + const auto connection = connectionJson.toObject(); + if (connection["platform"].toString() == "TWITCH") + { + this->userID = connection["id"].toString(); + this->userName = connection["username"].toString(); + break; + } + } +} + +bool EntitlementCreateDeleteDispatch::validate() const +{ + return !this->userID.isEmpty() && !this->userName.isEmpty() && + !this->refID.isEmpty() && this->kind != CosmeticKind::INVALID; +} + } // namespace chatterino::seventv::eventapi diff --git a/src/providers/seventv/eventapi/Dispatch.hpp b/src/providers/seventv/eventapi/Dispatch.hpp index 666f5c28ac6..04bad159bec 100644 --- a/src/providers/seventv/eventapi/Dispatch.hpp +++ b/src/providers/seventv/eventapi/Dispatch.hpp @@ -1,6 +1,7 @@ #pragma once #include "providers/seventv/eventapi/Subscription.hpp" +#include "providers/seventv/SeventvCosmetics.hpp" #include #include @@ -67,4 +68,26 @@ struct UserConnectionUpdateDispatch { bool validate() const; }; +struct CosmeticCreateDispatch { + QJsonObject data; + CosmeticKind kind; + + CosmeticCreateDispatch(const Dispatch &dispatch); + + bool validate() const; +}; + +struct EntitlementCreateDeleteDispatch { + /** id of the user */ + QString userID; + QString userName; + /** id of the entitlement */ + QString refID; + CosmeticKind kind; + + EntitlementCreateDeleteDispatch(const Dispatch &dispatch); + + bool validate() const; +}; + } // namespace chatterino::seventv::eventapi diff --git a/src/providers/seventv/eventapi/Subscription.cpp b/src/providers/seventv/eventapi/Subscription.cpp index 1de1f667e7f..91d330c5e61 100644 --- a/src/providers/seventv/eventapi/Subscription.cpp +++ b/src/providers/seventv/eventapi/Subscription.cpp @@ -102,4 +102,34 @@ QDebug &operator<<(QDebug &dbg, const ObjectIDCondition &condition) return dbg; } +ChannelCondition::ChannelCondition(QString twitchID) + : twitchID(std::move(twitchID)) +{ +} + +QJsonObject ChannelCondition::encode() const +{ + QJsonObject obj; + obj["ctx"] = "channel"; + obj["platform"] = "TWITCH"; + obj["id"] = this->twitchID; + return obj; +} + +QDebug &operator<<(QDebug &dbg, const ChannelCondition &condition) +{ + dbg << "{ twitchID:" << condition.twitchID << '}'; + return dbg; +} + +bool ChannelCondition::operator==(const ChannelCondition &rhs) const +{ + return this->twitchID == rhs.twitchID; +} + +bool ChannelCondition::operator!=(const ChannelCondition &rhs) const +{ + return !(*this == rhs); +} + } // namespace chatterino::seventv::eventapi diff --git a/src/providers/seventv/eventapi/Subscription.hpp b/src/providers/seventv/eventapi/Subscription.hpp index 53143fbd861..1a36811a56b 100644 --- a/src/providers/seventv/eventapi/Subscription.hpp +++ b/src/providers/seventv/eventapi/Subscription.hpp @@ -12,9 +12,22 @@ namespace chatterino::seventv::eventapi { // https://github.com/SevenTV/EventAPI/tree/ca4ff15cc42b89560fa661a76c5849047763d334#subscription-types enum class SubscriptionType { + AnyEmoteSet, + CreateEmoteSet, UpdateEmoteSet, + UpdateUser, + AnyCosmetic, + CreateCosmetic, + UpdateCosmetic, + DeleteCosmetic, + + AnyEntitlement, + CreateEntitlement, + UpdateEntitlement, + DeleteEntitlement, + INVALID, }; @@ -46,7 +59,19 @@ struct ObjectIDCondition { bool operator!=(const ObjectIDCondition &rhs) const; }; -using Condition = std::variant; +struct ChannelCondition { + ChannelCondition(QString twitchID); + + QString twitchID; + + QJsonObject encode() const; + + friend QDebug &operator<<(QDebug &dbg, const ChannelCondition &condition); + bool operator==(const ChannelCondition &rhs) const; + bool operator!=(const ChannelCondition &rhs) const; +}; + +using Condition = std::variant; struct Subscription { bool operator==(const Subscription &rhs) const; @@ -70,10 +95,30 @@ constexpr magic_enum::customize::customize_t magic_enum::customize::enum_name< using chatterino::seventv::eventapi::SubscriptionType; switch (value) { + case SubscriptionType::AnyEmoteSet: + return "emote_set.*"; + case SubscriptionType::CreateEmoteSet: + return "emote_set.create"; case SubscriptionType::UpdateEmoteSet: return "emote_set.update"; case SubscriptionType::UpdateUser: return "user.update"; + case SubscriptionType::AnyCosmetic: + return "cosmetic.*"; + case SubscriptionType::CreateCosmetic: + return "cosmetic.create"; + case SubscriptionType::UpdateCosmetic: + return "cosmetic.update"; + case SubscriptionType::DeleteCosmetic: + return "cosmetic.delete"; + case SubscriptionType::AnyEntitlement: + return "entitlement.*"; + case SubscriptionType::CreateEntitlement: + return "entitlement.create"; + case SubscriptionType::UpdateEntitlement: + return "entitlement.update"; + case SubscriptionType::DeleteEntitlement: + return "entitlement.delete"; default: return default_tag; @@ -91,6 +136,15 @@ struct hash { } }; +template <> +struct hash { + size_t operator()( + const chatterino::seventv::eventapi::ChannelCondition &c) const + { + return qHash(c.twitchID); + } +}; + template <> struct hash { size_t operator()( diff --git a/src/providers/twitch/TwitchAccount.cpp b/src/providers/twitch/TwitchAccount.cpp index 2ff1fe6fe03..6f597c46664 100644 --- a/src/providers/twitch/TwitchAccount.cpp +++ b/src/providers/twitch/TwitchAccount.cpp @@ -3,7 +3,7 @@ #include "Application.hpp" #include "common/Channel.hpp" #include "common/Env.hpp" -#include "common/NetworkRequest.hpp" +#include "common/NetworkResult.hpp" #include "common/Outcome.hpp" #include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" @@ -12,6 +12,7 @@ #include "messages/MessageBuilder.hpp" #include "providers/irc/IrcMessageBuilder.hpp" #include "providers/IvrApi.hpp" +#include "providers/seventv/SeventvAPI.hpp" #include "providers/twitch/api/Helix.hpp" #include "providers/twitch/TwitchCommon.hpp" #include "singletons/Emotes.hpp" @@ -445,4 +446,36 @@ void TwitchAccount::autoModDeny(const QString msgID, ChannelPtr channel) }); } +const QString &TwitchAccount::getSeventvUserID() const +{ + return this->seventvUserID_; +} + +void TwitchAccount::loadSeventvUserID() +{ + if (this->isAnon()) + { + return; + } + if (!this->seventvUserID_.isEmpty()) + { + return; + } + + getSeventvAPI().getUserByTwitchID( + this->getUserId(), + [this](const auto &json) { + const auto id = json["user"]["id"].toString(); + if (!id.isEmpty()) + { + this->seventvUserID_ = id; + } + return Success; + }, + [](const auto &result) { + qCDebug(chatterinoSeventv) + << "Failed to load 7TV user-id:" << result.formatError(); + }); +} + } // namespace chatterino diff --git a/src/providers/twitch/TwitchAccount.hpp b/src/providers/twitch/TwitchAccount.hpp index f92a44279a9..00e220ca834 100644 --- a/src/providers/twitch/TwitchAccount.hpp +++ b/src/providers/twitch/TwitchAccount.hpp @@ -59,6 +59,12 @@ class TwitchAccount : public Account const QString &getOAuthClient() const; const QString &getUserId() const; + /** + * The Seventv user-id of the current user. + * Empty if there's no associated Seventv user with this twitch user. + */ + const QString &getSeventvUserID() const; + QColor color(); void setColor(QColor color); @@ -98,6 +104,8 @@ class TwitchAccount : public Account void autoModAllow(const QString msgID, ChannelPtr channel); void autoModDeny(const QString msgID, ChannelPtr channel); + void loadSeventvUserID(); + private: QString oauthClient_; QString oauthToken_; @@ -115,6 +123,8 @@ class TwitchAccount : public Account // std::map emotes; UniqueAccess emotes_; UniqueAccess> localEmotes_; + + QString seventvUserID_; }; } // namespace chatterino diff --git a/src/providers/twitch/TwitchAccountManager.cpp b/src/providers/twitch/TwitchAccountManager.cpp index 6b02d3bb245..65a5f3a39d8 100644 --- a/src/providers/twitch/TwitchAccountManager.cpp +++ b/src/providers/twitch/TwitchAccountManager.cpp @@ -17,6 +17,7 @@ TwitchAccountManager::TwitchAccountManager() this->currentUserChanged.connect([this] { auto currentUser = this->getCurrent(); currentUser->loadBlocks(); + currentUser->loadSeventvUserID(); }); this->accounts.itemRemoved.connect([this](const auto &acc) { diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index 26f1547ad81..03e22282616 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -20,6 +20,7 @@ #include "providers/bttv/liveupdates/BttvLiveUpdateMessages.hpp" #include "providers/RecentMessagesApi.hpp" #include "providers/seventv/eventapi/Dispatch.hpp" +#include "providers/seventv/SeventvAPI.hpp" #include "providers/seventv/SeventvEmotes.hpp" #include "providers/seventv/SeventvEventAPI.hpp" #include "providers/twitch/api/Helix.hpp" @@ -40,6 +41,7 @@ #include #include +#include #include #include #include @@ -105,6 +107,7 @@ TwitchChannel::TwitchChannel(const QString &name) this->refreshBTTVChannelEmotes(false); this->refreshSevenTVChannelEmotes(false); this->joinBttvChannel(); + this->listenSevenTVCosmetics(); getIApp()->getTwitchLiveController()->add( std::dynamic_pointer_cast(shared_from_this())); }); @@ -248,6 +251,12 @@ TwitchChannel::~TwitchChannel() { getApp()->twitch->bttvLiveUpdates->partChannel(this->roomId()); } + + if (getApp()->twitch->seventvEventAPI) + { + getApp()->twitch->seventvEventAPI->unsubscribeTwitchChannel( + this->roomId()); + } } void TwitchChannel::initialize() @@ -600,6 +609,7 @@ void TwitchChannel::sendMessage(const QString &message) bool messageSent = false; this->sendMessageSignal.invoke(this->getName(), parsedMessage, messageSent); + this->updateSevenTVActivity(); if (messageSent) { @@ -1564,4 +1574,60 @@ boost::optional TwitchChannel::cheerEmote(const QString &string) return boost::none; } +void TwitchChannel::updateSevenTVActivity() +{ + static const QString seventvActivityUrl = + QStringLiteral("https://7tv.io/v3/users/%1/presences"); + + const auto currentSeventvUserID = + getApp()->accounts->twitch.getCurrent()->getSeventvUserID(); + if (currentSeventvUserID.isEmpty()) + { + return; + } + + if (!getSettings()->enableSevenTVEventAPI || + !getSettings()->sendSevenTVActivity) + { + return; + } + + if (this->nextSeventvActivity_.isValid() && + QDateTime::currentDateTimeUtc() < this->nextSeventvActivity_) + { + return; + } + // Make sure to not send activity again before receiving the response + this->nextSeventvActivity_ = this->nextSeventvActivity_.addSecs(300); + + qCDebug(chatterinoSeventv) << "Sending activity in" << this->getName(); + + getSeventvAPI().updatePresence( + this->roomId(), currentSeventvUserID, + [chan = weakOf(this)]() { + const auto self = + std::dynamic_pointer_cast(chan.lock()); + if (!self) + { + return Success; + } + self->nextSeventvActivity_ = + QDateTime::currentDateTimeUtc().addSecs(60); + return Success; + }, + [](const auto &result) { + qCDebug(chatterinoSeventv) + << "Failed to update 7TV activity:" << result.formatError(); + }); +} + +void TwitchChannel::listenSevenTVCosmetics() +{ + if (getApp()->twitch->seventvEventAPI) + { + getApp()->twitch->seventvEventAPI->subscribeTwitchChannel( + this->roomId()); + } +} + } // namespace chatterino diff --git a/src/providers/twitch/TwitchChannel.hpp b/src/providers/twitch/TwitchChannel.hpp index 35de959caa8..9d3e3e49a2d 100644 --- a/src/providers/twitch/TwitchChannel.hpp +++ b/src/providers/twitch/TwitchChannel.hpp @@ -254,6 +254,12 @@ class TwitchChannel : public Channel, public ChannelChatters void showLoginMessage(); /** Joins (subscribes to) a Twitch channel for updates on BTTV. */ void joinBttvChannel() const; + /** + * Indicates an activity to 7TV in this channel for this user. + * This is done at most once every 60s. + */ + void updateSevenTVActivity(); + void listenSevenTVCosmetics(); /** * @brief Sets the live status of this Twitch channel @@ -372,6 +378,12 @@ class TwitchChannel : public Channel, public ChannelChatters */ size_t seventvUserTwitchConnectionIndex_; + /** + * The next moment in time to signal activity in this channel to 7TV. + * Or: Up until this moment we don't need to send activity. + */ + QDateTime nextSeventvActivity_; + /** The platform of the last live emote update ("7TV", "BTTV", "FFZ"). */ QString lastLiveUpdateEmotePlatform_; /** The actor name of the last live emote update. */ diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 3ef633f1e12..d3a1e3894c2 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -268,6 +268,7 @@ class Settings : public ABSettings, public ConcurrentSettings BoolSetting enableSevenTVGlobalEmotes = {"/emotes/seventv/global", true}; BoolSetting enableSevenTVChannelEmotes = {"/emotes/seventv/channel", true}; BoolSetting enableSevenTVEventAPI = {"/emotes/seventv/eventapi", true}; + BoolSetting sendSevenTVActivity = {"/emotes/seventv/sendActivity", true}; /// Links BoolSetting linksDoubleClickOnly = {"/links/doubleClickToOpen", false}; diff --git a/src/widgets/settingspages/GeneralPage.cpp b/src/widgets/settingspages/GeneralPage.cpp index d55dc1f4d27..c6f78ac1ef8 100644 --- a/src/widgets/settingspages/GeneralPage.cpp +++ b/src/widgets/settingspages/GeneralPage.cpp @@ -560,6 +560,11 @@ void GeneralPage::initLayout(GeneralPageView &layout) layout.addCheckbox("Show 7TV channel emotes", s.enableSevenTVChannelEmotes); layout.addCheckbox("Enable 7TV live emote updates (requires restart)", s.enableSevenTVEventAPI); + layout.addCheckbox("Send activity to 7TV", s.sendSevenTVActivity, false, + "When enabled, Chatterino will signal an activity to " + "7TV when you send a chat mesage. This is used for " + "badges, paints, and personal emotes. When disabled, no " + "activity is sent and others won't see your cosmetics."); layout.addTitle("Streamer Mode"); layout.addDescription( diff --git a/tests/src/main.cpp b/tests/src/main.cpp index c54ea158f5e..91f4b8b8862 100644 --- a/tests/src/main.cpp +++ b/tests/src/main.cpp @@ -38,6 +38,15 @@ int main(int argc, char **argv) chatterino::NetworkManager::deinit(); settingsDir.remove(); + + // Pick up the last events from the eventloop + // Using a loop to catch events queueing other events (e.g. deletions) + for (size_t i = 0; i < 32; i++) + { + QCoreApplication::processEvents(QEventLoop::ExcludeUserInputEvents); + QCoreApplication::sendPostedEvents(nullptr, QEvent::DeferredDelete); + } + QApplication::exit(res); });