diff --git a/CHANGELOG.md b/CHANGELOG.md index 94cf8615b..05b2312b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ - Major: Added Streamer Mode configuration (under `Settings -> General`), where you can select which features of Chatterino should behave differently when you are in Streamer Mode. (#2001, #2316, #2342, #2376) - Major: Color mentions to match the mentioned users. You can disable this by unchecking "Color @usernames" under `Settings -> General -> Advanced (misc.)`. (#1963, #2284) - Major: Commands `/ignore` and `/unignore` have been renamed to `/block` and `/unblock` in order to keep consistency with Twitch's terms. (#2370) +- Major: Added support for bit emotes - the ones you unlock after cheering to streamer. (#2550) - Minor: Added `/clearmessages` command - does what "Burger menu -> More -> Clear messages" does. (#2485) - Minor: Added `/marker` command - similar to webchat, it creates a stream marker. (#2360) - Minor: Added `/chatters` command showing chatter count. (#2344) diff --git a/src/providers/IvrApi.cpp b/src/providers/IvrApi.cpp index e30abdba5..4c042d718 100644 --- a/src/providers/IvrApi.cpp +++ b/src/providers/IvrApi.cpp @@ -15,7 +15,8 @@ void IvrApi::getSubage(QString userName, QString channelName, { assert(!userName.isEmpty() && !channelName.isEmpty()); - this->makeRequest("twitch/subage/" + userName + "/" + channelName, {}) + this->makeRequest( + QString("twitch/subage/%1/%2").arg(userName).arg(channelName), {}) .onSuccess([successCallback, failureCallback](auto result) -> Outcome { auto root = result.parseJson(); @@ -23,7 +24,33 @@ void IvrApi::getSubage(QString userName, QString channelName, return Success; }) - .onError([failureCallback](NetworkResult result) { + .onError([failureCallback](auto result) { + qCWarning(chatterinoIvr) + << "Failed IVR API Call!" << result.status() + << QString(result.getData()); + failureCallback(); + }) + .execute(); +} + +void IvrApi::getBulkEmoteSets(QString emoteSetList, + ResultCallback successCallback, + IvrFailureCallback failureCallback) +{ + assert(!emoteSetList.isEmpty()); + + QUrlQuery urlQuery; + urlQuery.addQueryItem("set_id", emoteSetList); + + this->makeRequest("twitch/emoteset", urlQuery) + .onSuccess([successCallback, failureCallback](auto result) -> Outcome { + auto root = result.parseJsonArray(); + + successCallback(root); + + return Success; + }) + .onError([failureCallback](auto result) { qCWarning(chatterinoIvr) << "Failed IVR API Call!" << result.status() << QString(result.getData()); diff --git a/src/providers/IvrApi.hpp b/src/providers/IvrApi.hpp index 7cf73e2ca..f82d2af96 100644 --- a/src/providers/IvrApi.hpp +++ b/src/providers/IvrApi.hpp @@ -29,6 +29,41 @@ struct IvrSubage { } }; +struct IvrEmoteSet { + const QString setId; + const QString displayName; + const QString login; + const QString id; + const QString tier; + const QJsonArray emotes; + + IvrEmoteSet(QJsonObject root) + : setId(root.value("setID").toString()) + , displayName(root.value("channelName").toString()) + , login(root.value("channelLogin").toString()) + , id(root.value("channelID").toString()) + , tier(root.value("tier").toString()) + , emotes(root.value("emotes").toArray()) + + { + } +}; + +struct IvrEmote { + const QString code; + const QString id; + const QString setId; + const QString url; + + IvrEmote(QJsonObject root) + : code(root.value("token").toString()) + , id(root.value("id").toString()) + , setId(root.value("setID").toString()) + , url(root.value("url_3x").toString()) + { + } +}; + class IvrApi final : boost::noncopyable { public: @@ -37,6 +72,12 @@ public: ResultCallback resultCallback, IvrFailureCallback failureCallback); + // https://api.ivr.fi/docs#tag/Twitch/paths/~1twitch~1emoteset~1{setid}/get + // however, we use undocumented endpoint, which takes ?set_id=1,2,3,4,... as query parameter + void getBulkEmoteSets(QString emoteSetList, + ResultCallback successCallback, + IvrFailureCallback failureCallback); + static void initialize(); private: diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp index 6b7eeb71a..71d84fb79 100644 --- a/src/providers/twitch/IrcMessageHandler.cpp +++ b/src/providers/twitch/IrcMessageHandler.cpp @@ -514,6 +514,10 @@ void IrcMessageHandler::handleUserStateMessage(Communi::IrcMessage *message) tc->setMod(_mod == "1"); } } + + // handle emotes + app->accounts->twitch.getCurrent()->loadUserstateEmotes( + message->tag("emote-sets").toString().split(",")); } void IrcMessageHandler::handleWhisperMessage(Communi::IrcMessage *message) diff --git a/src/providers/twitch/TwitchAccount.cpp b/src/providers/twitch/TwitchAccount.cpp index 3f889f1ce..316f1fc77 100644 --- a/src/providers/twitch/TwitchAccount.cpp +++ b/src/providers/twitch/TwitchAccount.cpp @@ -8,6 +8,7 @@ #include "common/Outcome.hpp" #include "common/QLogging.hpp" #include "controllers/accounts/AccountController.hpp" +#include "providers/IvrApi.hpp" #include "providers/twitch/TwitchCommon.hpp" #include "providers/twitch/TwitchUser.hpp" #include "providers/twitch/api/Helix.hpp" @@ -16,6 +17,9 @@ #include "util/RapidjsonHelpers.hpp" namespace chatterino { +namespace { + constexpr int USERSTATE_EMOTES_REFRESH_PERIOD = 10 * 60 * 1000; +} // namespace TwitchAccount::TwitchAccount(const QString &username, const QString &oauthToken, const QString &oauthClient, const QString &userID) @@ -243,6 +247,94 @@ void TwitchAccount::loadEmotes() }); } +void TwitchAccount::loadUserstateEmotes(QStringList emoteSetKeys) +{ + // do not attempt to load emotes too often + if (!this->userstateEmotesTimer_.isValid()) + { + this->userstateEmotesTimer_.start(); + } + else if (this->userstateEmotesTimer_.elapsed() < + USERSTATE_EMOTES_REFRESH_PERIOD) + { + return; + } + this->userstateEmotesTimer_.restart(); + + auto emoteData = this->emotes_.access(); + auto userEmoteSets = emoteData->emoteSets; + + QStringList newEmoteSetKeys, currentEmoteSetKeys; + // get list of already fetched emote sets + for (const auto &userEmoteSet : userEmoteSets) + { + currentEmoteSetKeys.push_back(userEmoteSet->key); + } + // filter out emote sets from userstate message, which are not in fetched emote set list + for (const auto &emoteSetKey : emoteSetKeys) + { + if (!currentEmoteSetKeys.contains(emoteSetKey)) + { + newEmoteSetKeys.push_back(emoteSetKey); + } + } + + // return if there are no new emote sets + if (newEmoteSetKeys.isEmpty()) + { + return; + } + + getIvr()->getBulkEmoteSets( + newEmoteSetKeys.join(","), + [this](QJsonArray emoteSetArray) { + auto emoteData = this->emotes_.access(); + for (auto emoteSet : emoteSetArray) + { + auto newUserEmoteSet = std::make_shared(); + + IvrEmoteSet ivrEmoteSet(emoteSet.toObject()); + + newUserEmoteSet->key = ivrEmoteSet.setId; + + auto name = ivrEmoteSet.login; + name.detach(); + name[0] = name[0].toUpper(); + + newUserEmoteSet->text = name; + newUserEmoteSet->type = QString(); + newUserEmoteSet->channelName = ivrEmoteSet.login; + + for (const auto &emote : ivrEmoteSet.emotes) + { + IvrEmote ivrEmote(emote.toObject()); + + auto id = EmoteId{ivrEmote.id}; + auto code = EmoteName{ivrEmote.code}; + auto cleanCode = + EmoteName{TwitchEmotes::cleanUpEmoteCode(code)}; + newUserEmoteSet->emotes.emplace_back( + TwitchEmote{id, cleanCode}); + + emoteData->allEmoteNames.push_back(cleanCode); + + auto twitchEmote = + getApp()->emotes->twitch.getOrCreateEmote(id, code); + emoteData->emotes.emplace(code, twitchEmote); + } + std::sort(newUserEmoteSet->emotes.begin(), + newUserEmoteSet->emotes.end(), + [](const TwitchEmote &l, const TwitchEmote &r) { + return l.name.string < r.name.string; + }); + emoteData->emoteSets.emplace_back(newUserEmoteSet); + } + }, + [] { + // fetching emotes failed, ivr API might be down + }); +} + AccessGuard TwitchAccount::accessEmotes() const { diff --git a/src/providers/twitch/TwitchAccount.hpp b/src/providers/twitch/TwitchAccount.hpp index 1524a05a0..3b2dab951 100644 --- a/src/providers/twitch/TwitchAccount.hpp +++ b/src/providers/twitch/TwitchAccount.hpp @@ -109,6 +109,7 @@ public: std::set getBlocks() const; void loadEmotes(); + void loadUserstateEmotes(QStringList emoteSetKeys); AccessGuard accessEmotes() const; // Automod actions @@ -126,6 +127,7 @@ private: Atomic color_; mutable std::mutex ignoresMutex_; + QElapsedTimer userstateEmotesTimer_; std::set ignores_; // std::map emotes;