diff --git a/CHANGELOG.md b/CHANGELOG.md index b83d359c..7678c74f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unversioned +- Major: Newly uploaded Twitch emotes are once again present in emote picker and can be autocompleted with Tab as well. (#2992) - Minor: Added autocompletion in /whispers for Twitch emotes, Global Bttv/Ffz emotes and emojis. (#2999) - Bugfix: Fixed "smiley" emotes being unable to be "Tabbed" with autocompletion, introduced in v2.3.3. (#3010) - Dev: Ubuntu packages are now available (#2936) diff --git a/src/providers/IvrApi.cpp b/src/providers/IvrApi.cpp index dff138ef..d6a857d8 100644 --- a/src/providers/IvrApi.cpp +++ b/src/providers/IvrApi.cpp @@ -35,7 +35,8 @@ void IvrApi::getSubage(QString userName, QString channelName, void IvrApi::getBulkEmoteSets(QString emoteSetList, ResultCallback successCallback, - IvrFailureCallback failureCallback) + IvrFailureCallback failureCallback, + std::function finallyCallback) { QUrlQuery urlQuery; urlQuery.addQueryItem("set_id", emoteSetList); @@ -54,6 +55,7 @@ void IvrApi::getBulkEmoteSets(QString emoteSetList, << QString(result.getData()); failureCallback(); }) + .finally(std::move(finallyCallback)) .execute(); } diff --git a/src/providers/IvrApi.hpp b/src/providers/IvrApi.hpp index 095d86d1..581b0350 100644 --- a/src/providers/IvrApi.hpp +++ b/src/providers/IvrApi.hpp @@ -81,10 +81,11 @@ public: ResultCallback resultCallback, IvrFailureCallback failureCallback); - // https://api.ivr.fi/docs#tag/Twitch/paths/~1twitch~1emoteset/get + // https://api.ivr.fi/v2/docs/static/index.html#/Twitch/get_twitch_emotes_sets void getBulkEmoteSets(QString emoteSetList, ResultCallback successCallback, - IvrFailureCallback failureCallback); + IvrFailureCallback failureCallback, + std::function finallyCallback); static void initialize(); diff --git a/src/providers/twitch/IrcMessageHandler.cpp b/src/providers/twitch/IrcMessageHandler.cpp index 6d00163d..456d0522 100644 --- a/src/providers/twitch/IrcMessageHandler.cpp +++ b/src/providers/twitch/IrcMessageHandler.cpp @@ -497,7 +497,7 @@ void IrcMessageHandler::handleUserStateMessage(Communi::IrcMessage *message) if (emoteSetsChanged) { - currentUser->loadUserstateEmotes(); + currentUser->loadUserstateEmotes([] {}); } QString channelName; @@ -512,6 +512,7 @@ void IrcMessageHandler::handleUserStateMessage(Communi::IrcMessage *message) return; } + // Checking if currentUser is a VIP or staff member QVariant _badges = message->tag("badges"); if (_badges.isValid()) { @@ -524,6 +525,7 @@ void IrcMessageHandler::handleUserStateMessage(Communi::IrcMessage *message) } } + // Checking if currentUser is a moderator QVariant _mod = message->tag("mod"); if (_mod.isValid()) { @@ -535,6 +537,24 @@ 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()->accounts->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 *message) { MessageParseArgs args; diff --git a/src/providers/twitch/IrcMessageHandler.hpp b/src/providers/twitch/IrcMessageHandler.hpp index 9228054d..603b2e4b 100644 --- a/src/providers/twitch/IrcMessageHandler.hpp +++ b/src/providers/twitch/IrcMessageHandler.hpp @@ -30,6 +30,7 @@ public: void handleClearChatMessage(Communi::IrcMessage *message); void handleClearMessageMessage(Communi::IrcMessage *message); void handleUserStateMessage(Communi::IrcMessage *message); + void handleGlobalUserStateMessage(Communi::IrcMessage *message); void handleWhisperMessage(Communi::IrcMessage *message); // parseUserNoticeMessage parses a single IRC USERNOTICE message into 0+ diff --git a/src/providers/twitch/TwitchAccount.cpp b/src/providers/twitch/TwitchAccount.cpp index dc090989..af8627a6 100644 --- a/src/providers/twitch/TwitchAccount.cpp +++ b/src/providers/twitch/TwitchAccount.cpp @@ -21,6 +21,35 @@ #include "util/QStringHash.hpp" #include "util/RapidjsonHelpers.hpp" +namespace { + +std::vector getEmoteSetBatches(QStringList emoteSetKeys) +{ + // splitting emoteSetKeys to batches of 100, because Ivr API endpoint accepts a maximum of 100 emotesets at once + constexpr int batchSize = 100; + + int batchCount = (emoteSetKeys.size() / batchSize) + 1; + + std::vector batches; + batches.reserve(batchCount); + + for (int i = 0; i < batchCount; i++) + { + QStringList batch; + + int last = std::min(batchSize, emoteSetKeys.size() - batchSize * i); + for (int j = 0; j < last; j++) + { + batch.push_back(emoteSetKeys.at(j + (batchSize * i))); + } + batches.emplace_back(batch); + } + + return batches; +} + +} // namespace + namespace chatterino { TwitchAccount::TwitchAccount(const QString &username, const QString &oauthToken, const QString &oauthClient, const QString &userID) @@ -116,7 +145,7 @@ void TwitchAccount::loadBlocks() } }, [] { - qDebug() << "Fetching blocks failed!"; + qCWarning(chatterinoTwitch) << "Fetching blocks failed!"; }); } @@ -196,77 +225,25 @@ void TwitchAccount::loadEmotes() if (this->getOAuthClient().isEmpty() || this->getOAuthToken().isEmpty()) { - qCDebug(chatterinoTwitch) << "Missing Client ID and/or OAuth token"; + qCDebug(chatterinoTwitch) + << "Aborted loadEmotes due to missing Client ID and/or OAuth token"; return; } - // Getting subscription emotes from kraken - getKraken()->getUserEmotes( - this, - [this](KrakenEmoteSets data) { - // no emotes available - if (data.emoteSets.isEmpty()) - { - qCWarning(chatterinoTwitch) - << "\"emoticon_sets\" either empty or not present in " - "Kraken::getUserEmotes response"; - return; - } + { + auto emoteData = this->emotes_.access(); + emoteData->emoteSets.clear(); + emoteData->emotes.clear(); + qCDebug(chatterinoTwitch) << "Cleared emotes!"; + } - { - // Clearing emote data - auto emoteData = this->emotes_.access(); - emoteData->emoteSets.clear(); - emoteData->emotes.clear(); - - for (auto emoteSetIt = data.emoteSets.begin(); - emoteSetIt != data.emoteSets.end(); ++emoteSetIt) - { - auto emoteSet = std::make_shared(); - - emoteSet->key = emoteSetIt.key(); - this->loadEmoteSetData(emoteSet); - - for (const auto emoteArrObj : emoteSetIt.value().toArray()) - { - if (!emoteArrObj.isObject()) - { - qCWarning(chatterinoTwitch) - << QString( - "Emote value from set %1 was invalid") - .arg(emoteSet->key); - continue; - } - KrakenEmote krakenEmote(emoteArrObj.toObject()); - - auto id = EmoteId{krakenEmote.id}; - auto code = EmoteName{ - TwitchEmotes::cleanUpEmoteCode(krakenEmote.code)}; - - emoteSet->emotes.emplace_back(TwitchEmote{id, code}); - - if (!emoteSet->local) - { - auto emote = - getApp()->emotes->twitch.getOrCreateEmote(id, - code); - 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); - } - } - // Getting userstate emotes from Ivr - this->loadUserstateEmotes(); - }, - [] { - // kraken request failed - }); + // TODO(zneix): Once Helix adds Get User Emotes we could remove this hacky solution + // For now, this is necessary as Kraken's equivalent doesn't return all emotes + // See: https://twitch.uservoice.com/forums/310213-developers/suggestions/43599900 + this->loadUserstateEmotes([=] { + // Fill up emoteData with emote sets that were returned in a Kraken call, but aren't present in emoteData. + this->loadKrakenEmotes(); + }); } bool TwitchAccount::setUserstateEmoteSets(QStringList newEmoteSets) @@ -284,10 +261,11 @@ bool TwitchAccount::setUserstateEmoteSets(QStringList newEmoteSets) return true; } -void TwitchAccount::loadUserstateEmotes() +void TwitchAccount::loadUserstateEmotes(std::function callback) { if (this->userstateEmoteSets_.isEmpty()) { + callback(); return; } @@ -303,7 +281,7 @@ void TwitchAccount::loadUserstateEmotes() } // filter out emote sets from userstate message, which are not in fetched emote set list - for (const auto &emoteSetKey : this->userstateEmoteSets_) + for (const auto &emoteSetKey : qAsConst(this->userstateEmoteSets_)) { if (!krakenEmoteSetKeys.contains(emoteSetKey)) { @@ -314,54 +292,50 @@ void TwitchAccount::loadUserstateEmotes() // return if there are no new emote sets if (newEmoteSetKeys.isEmpty()) { + callback(); return; } - qCDebug(chatterinoTwitch) << QString("Loading %1 emotesets from IVR: %2") - .arg(newEmoteSetKeys.size()) - .arg(newEmoteSetKeys.join(", ")); - - // splitting newEmoteSetKeys to batches of 100, because Ivr API endpoint accepts a maximum of 100 emotesets at once - constexpr int batchSize = 100; - - std::vector batches; - int batchCount = (newEmoteSetKeys.size() / batchSize) + 1; - - batches.reserve(batchCount); - - for (int i = 0; i < batchCount; i++) - { - QStringList batch; - - int last = std::min(batchSize, newEmoteSetKeys.size() - batchSize * i); - for (int j = batchSize * i; j < last; j++) - { - batch.push_back(newEmoteSetKeys.at(j)); - } - batches.emplace_back(batch); - } // requesting emotes - for (const auto &batch : batches) + auto batches = getEmoteSetBatches(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( - batch.join(","), + batches.at(i).join(","), [this](QJsonArray emoteSetArray) { auto emoteData = this->emotes_.access(); auto localEmoteData = this->localEmotes_.access(); - for (auto emoteSet : emoteSetArray) + for (auto emoteSet_ : emoteSetArray) { - auto newUserEmoteSet = std::make_shared(); + auto emoteSet = std::make_shared(); - IvrEmoteSet ivrEmoteSet(emoteSet.toObject()); + IvrEmoteSet ivrEmoteSet(emoteSet_.toObject()); - newUserEmoteSet->key = ivrEmoteSet.setId; + QString setKey = ivrEmoteSet.setId; + emoteSet->key = setKey; - auto name = ivrEmoteSet.login; - name.detach(); - name[0] = name[0].toUpper(); + // 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; + } - newUserEmoteSet->text = name; - newUserEmoteSet->channelName = ivrEmoteSet.login; + emoteSet->channelName = ivrEmoteSet.login; + emoteSet->text = ivrEmoteSet.displayName; for (const auto &emoteObj : ivrEmoteSet.emotes) { @@ -371,8 +345,7 @@ void TwitchAccount::loadUserstateEmotes() auto code = EmoteName{ TwitchEmotes::cleanUpEmoteCode(ivrEmote.code)}; - newUserEmoteSet->emotes.push_back( - TwitchEmote{id, code}); + emoteSet->emotes.push_back(TwitchEmote{id, code}); auto emote = getApp()->emotes->twitch.getOrCreateEmote(id, code); @@ -380,7 +353,7 @@ void TwitchAccount::loadUserstateEmotes() // Follower emotes can be only used in their origin channel if (ivrEmote.emoteType == "FOLLOWER") { - newUserEmoteSet->local = true; + emoteSet->local = true; // EmoteMap for target channel wasn't initialized yet, doing it now if (localEmoteData->find(ivrEmoteSet.channelId) == @@ -398,16 +371,25 @@ void TwitchAccount::loadUserstateEmotes() emoteData->emotes.emplace(code, emote); } } - std::sort(newUserEmoteSet->emotes.begin(), - newUserEmoteSet->emotes.end(), + 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(newUserEmoteSet); + emoteData->emoteSets.emplace_back(emoteSet); } }, [] { // fetching emotes failed, ivr API might be down + }, + [=] { + // XXX(zneix): We check if this is the last iteration and if so, call the callback + if (i + 1 == batches.size()) + { + qCDebug(chatterinoTwitch) + << "Finished loading emotes from IVR, attempting to " + "load Kraken emotes now"; + callback(); + } }); }; } @@ -517,6 +499,79 @@ void TwitchAccount::autoModDeny(const QString msgID, ChannelPtr channel) }); } +void TwitchAccount::loadKrakenEmotes() +{ + getKraken()->getUserEmotes( + this, + [this](KrakenEmoteSets data) { + // no emotes available + if (data.emoteSets.isEmpty()) + { + qCWarning(chatterinoTwitch) + << "\"emoticon_sets\" either empty or not present in " + "Kraken::getUserEmotes response"; + return; + } + + auto emoteData = this->emotes_.access(); + + for (auto emoteSetIt = data.emoteSets.begin(); + emoteSetIt != data.emoteSets.end(); ++emoteSetIt) + { + auto emoteSet = std::make_shared(); + + QString setKey = emoteSetIt.key(); + emoteSet->key = setKey; + this->loadEmoteSetData(emoteSet); + + // 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; + } + + for (const auto emoteArrObj : emoteSetIt->toArray()) + { + if (!emoteArrObj.isObject()) + { + qCWarning(chatterinoTwitch) + << QString("Emote value from set %1 was invalid") + .arg(emoteSet->key); + continue; + } + KrakenEmote krakenEmote(emoteArrObj.toObject()); + + auto id = EmoteId{krakenEmote.id}; + auto code = EmoteName{ + TwitchEmotes::cleanUpEmoteCode(krakenEmote.code)}; + + emoteSet->emotes.emplace_back(TwitchEmote{id, code}); + + if (!emoteSet->local) + { + auto emote = + getApp()->emotes->twitch.getOrCreateEmote(id, code); + 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); + } + }, + [] { + // kraken request failed + }); +} + void TwitchAccount::loadEmoteSetData(std::shared_ptr emoteSet) { if (!emoteSet) @@ -546,7 +601,7 @@ void TwitchAccount::loadEmoteSetData(std::shared_ptr emoteSet) if (emoteSetData.ownerId.isEmpty() || emoteSetData.setId != emoteSet->key) { - qCWarning(chatterinoTwitch) + qCDebug(chatterinoTwitch) << QString("Failed to fetch emoteSetData for %1, assuming " "Twitch is the owner") .arg(emoteSet->key); diff --git a/src/providers/twitch/TwitchAccount.hpp b/src/providers/twitch/TwitchAccount.hpp index 89df716f..4c203dad 100644 --- a/src/providers/twitch/TwitchAccount.hpp +++ b/src/providers/twitch/TwitchAccount.hpp @@ -115,7 +115,7 @@ public: void loadEmotes(); // 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(); + void loadUserstateEmotes(std::function callback); // 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); @@ -128,6 +128,7 @@ public: void autoModDeny(const QString msgID, ChannelPtr channel); private: + void loadKrakenEmotes(); void loadEmoteSetData(std::shared_ptr emoteSet); QString oauthClient_; diff --git a/src/providers/twitch/TwitchIrcServer.cpp b/src/providers/twitch/TwitchIrcServer.cpp index 91f84f8c..3aede140 100644 --- a/src/providers/twitch/TwitchIrcServer.cpp +++ b/src/providers/twitch/TwitchIrcServer.cpp @@ -60,6 +60,19 @@ void TwitchIrcServer::initializeConnection(IrcConnection *connection, qCDebug(chatterinoTwitch) << "logging in as" << account->getUserName(); + // twitch.tv/tags enables IRCv3 tags on messages. See https://dev.twitch.tv/docs/irc/tags + // twitch.tv/commands enables a bunch of miscellaneous command capabilities. See https://dev.twitch.tv/docs/irc/commands + // twitch.tv/membership enables the JOIN/PART/NAMES commands. See https://dev.twitch.tv/docs/irc/membership + // This is enabled so we receive USERSTATE messages when joining channels / typing messages, along with the other command capabilities + QStringList caps{"twitch.tv/tags", "twitch.tv/commands"}; + if (type != ConnectionType::Write) + { + caps.push_back("twitch.tv/membership"); + } + + connection->network()->setSkipCapabilityValidation(true); + connection->network()->setRequestedCapabilities(caps); + QString username = account->getUserName(); QString oauthToken = account->getOAuthToken(); @@ -169,6 +182,10 @@ void TwitchIrcServer::readConnectionMessageReceived( "Twitch Servers requested us to reconnect, reconnecting"); this->connect(); } + else if (command == "GLOBALUSERSTATE") + { + handler.handleGlobalUserStateMessage(message); + } } void TwitchIrcServer::writeConnectionMessageReceived( @@ -219,28 +236,6 @@ void TwitchIrcServer::writeConnectionMessageReceived( } } -void TwitchIrcServer::onReadConnected(IrcConnection *connection) -{ - // twitch.tv/tags enables IRCv3 tags on messages. See https://dev.twitch.tv/docs/irc/tags/ - // twitch.tv/membership enables the JOIN/PART/MODE/NAMES commands. See https://dev.twitch.tv/docs/irc/membership/ - // twitch.tv/commands enables a bunch of miscellaneous command capabilities. See https://dev.twitch.tv/docs/irc/commands/ - // This is enabled here so we receive USERSTATE messages when joining channels - connection->sendRaw( - "CAP REQ :twitch.tv/tags twitch.tv/membership twitch.tv/commands"); - - AbstractIrcServer::onReadConnected(connection); -} - -void TwitchIrcServer::onWriteConnected(IrcConnection *connection) -{ - // twitch.tv/tags enables IRCv3 tags on messages. See https://dev.twitch.tv/docs/irc/tags/ - // twitch.tv/commands enables a bunch of miscellaneous command capabilities. See https://dev.twitch.tv/docs/irc/commands/ - // This is enabled here so we receive USERSTATE messages when typing messages, along with the other command capabilities - connection->sendRaw("CAP REQ :twitch.tv/tags twitch.tv/commands"); - - AbstractIrcServer::onWriteConnected(connection); -} - std::shared_ptr TwitchIrcServer::getCustomChannel( const QString &channelName) { diff --git a/src/providers/twitch/TwitchIrcServer.hpp b/src/providers/twitch/TwitchIrcServer.hpp index 8b76f090..8f519d4a 100644 --- a/src/providers/twitch/TwitchIrcServer.hpp +++ b/src/providers/twitch/TwitchIrcServer.hpp @@ -56,9 +56,6 @@ protected: virtual void writeConnectionMessageReceived( Communi::IrcMessage *message) override; - virtual void onReadConnected(IrcConnection *connection) override; - virtual void onWriteConnected(IrcConnection *connection) override; - virtual std::shared_ptr getCustomChannel( const QString &channelname) override; diff --git a/src/singletons/Emotes.cpp b/src/singletons/Emotes.cpp index 2f211856..c5d93e3b 100644 --- a/src/singletons/Emotes.cpp +++ b/src/singletons/Emotes.cpp @@ -11,10 +11,6 @@ Emotes::Emotes() void Emotes::initialize(Settings &settings, Paths &paths) { - getApp()->accounts->twitch.currentUserChanged.connect([] { - getApp()->accounts->twitch.getCurrent()->loadEmotes(); - }); - this->emojis.load(); this->gifTimer.initialize();