From d106922cc000fa8d1deea66e439a099af84331d6 Mon Sep 17 00:00:00 2001 From: nerix Date: Thu, 25 Jan 2024 21:07:11 +0100 Subject: [PATCH] Correctly Display Personal Emotes (#199) --- src/providers/seventv/SeventvEventAPI.cpp | 59 +++++++- src/providers/seventv/SeventvEventAPI.hpp | 11 ++ src/providers/seventv/SeventvPaints.cpp | 1 - .../seventv/SeventvPersonalEmotes.cpp | 17 +++ .../seventv/SeventvPersonalEmotes.hpp | 3 + src/providers/twitch/TwitchChannel.cpp | 143 +++++++++++++----- 6 files changed, 191 insertions(+), 43 deletions(-) diff --git a/src/providers/seventv/SeventvEventAPI.cpp b/src/providers/seventv/SeventvEventAPI.cpp index a234d393a0e..c9656187d39 100644 --- a/src/providers/seventv/SeventvEventAPI.cpp +++ b/src/providers/seventv/SeventvEventAPI.cpp @@ -256,7 +256,16 @@ void SeventvEventAPI::onEmoteSetUpdate(const Dispatch &dispatch) // pulled: Array<{ key, old_value }>, // updated: Array<{ key, value, old_value }>, // } - for (const auto pushedRef : dispatch.body["pushed"].toArray()) + auto pushedArray = dispatch.body["pushed"].toArray(); + auto pulledArray = dispatch.body["pulled"].toArray(); + auto updatedArray = dispatch.body["updated"].toArray(); + qCDebug(chatterinoSeventvEventAPI).nospace() + << "Update emote set " << dispatch.id + << " added: " << pushedArray.count() + << ", removed: " << pulledArray.count() + << ", updated: " << updatedArray.count(); + + for (const auto pushedRef : pushedArray) { auto pushed = pushedRef.toObject(); if (pushed["key"].toString() != "emotes") @@ -276,7 +285,7 @@ void SeventvEventAPI::onEmoteSetUpdate(const Dispatch &dispatch) << "Invalid dispatch" << dispatch.body; } } - for (const auto updatedRef : dispatch.body["updated"].toArray()) + for (const auto updatedRef : updatedArray) { auto updated = updatedRef.toObject(); if (updated["key"].toString() != "emotes") @@ -298,7 +307,7 @@ void SeventvEventAPI::onEmoteSetUpdate(const Dispatch &dispatch) << "Invalid dispatch" << dispatch.body; } } - for (const auto pulledRef : dispatch.body["pulled"].toArray()) + for (const auto pulledRef : pulledArray) { auto pulled = pulledRef.toObject(); if (pulled["key"].toString() != "emotes") @@ -319,6 +328,26 @@ void SeventvEventAPI::onEmoteSetUpdate(const Dispatch &dispatch) << "Invalid dispatch" << dispatch.body; } } + + if (!this->lastPersonalEmoteAssignment_) + { + return; + } + + if (this->lastPersonalEmoteAssignment_->emoteSetID == dispatch.id) + { + auto emoteSet = + getIApp()->getSeventvPersonalEmotes()->getEmoteSetByID(dispatch.id); + if (emoteSet) + { + qCDebug(chatterinoSeventvEventAPI) << "Flushed last emote set"; + this->signals_.personalEmoteSetAdded.invoke({ + this->lastPersonalEmoteAssignment_->userName, + *emoteSet, + }); + } + } + this->lastPersonalEmoteAssignment_ = std::nullopt; } void SeventvEventAPI::onUserUpdate(const Dispatch &dispatch) @@ -393,12 +422,30 @@ void SeventvEventAPI::onEntitlementCreate( } break; case CosmeticKind::EmoteSet: { + qCDebug(chatterinoSeventvEventAPI) + << "Assign user" << entitlement.userID << "to emote set" + << entitlement.refID; if (auto set = getIApp()->getSeventvPersonalEmotes()->assignUserToEmoteSet( entitlement.refID, entitlement.userID)) { - this->signals_.personalEmoteSetAdded.invoke( - {entitlement.userName, *set}); + if ((*set)->empty()) + { + qCDebug(chatterinoSeventvEventAPI) + << "Saving emote set as it's empty to wait for further " + "updates"; + this->lastPersonalEmoteAssignment_ = + LastPersonalEmoteAssignment{ + .userName = entitlement.userName, + .emoteSetID = entitlement.refID, + }; + } + else + { + this->lastPersonalEmoteAssignment_ = std::nullopt; + this->signals_.personalEmoteSetAdded.invoke( + {entitlement.userName, *set}); + } } } break; @@ -442,6 +489,8 @@ void SeventvEventAPI::onEmoteSetCreate(const Dispatch &dispatch) if (createDispatch.isPersonal) { + qCDebug(chatterinoSeventvEventAPI) + << "Create emote set" << createDispatch.emoteSetID; getIApp()->getSeventvPersonalEmotes()->createEmoteSet( createDispatch.emoteSetID); } diff --git a/src/providers/seventv/SeventvEventAPI.hpp b/src/providers/seventv/SeventvEventAPI.hpp index 1409ff2b99c..084ad0de887 100644 --- a/src/providers/seventv/SeventvEventAPI.hpp +++ b/src/providers/seventv/SeventvEventAPI.hpp @@ -96,6 +96,17 @@ class SeventvEventAPI /** Twitch channel ids */ std::unordered_set subscribedTwitchChannels_; std::chrono::milliseconds heartbeatInterval_; + + struct LastPersonalEmoteAssignment { + QString userName; + QString emoteSetID; + std::shared_ptr emoteSet; + }; + + /// This is a workaround for 7TV sending `CreateEntitlement` before + /// `UpdateEmoteSet`. We only upsert emotes when a user gets assigned a + /// new emote set, but in this case, we're upserting after updating as well. + std::optional lastPersonalEmoteAssignment_; }; } // namespace chatterino diff --git a/src/providers/seventv/SeventvPaints.cpp b/src/providers/seventv/SeventvPaints.cpp index 36f496362eb..627436ef587 100644 --- a/src/providers/seventv/SeventvPaints.cpp +++ b/src/providers/seventv/SeventvPaints.cpp @@ -87,7 +87,6 @@ std::vector parseDropShadows(const QJsonArray &dropShadows) std::optional> parsePaint(const QJsonObject &paintJson) { - qDebug() << paintJson; const QString name = paintJson["name"].toString(); const QString id = paintJson["id"].toString(); diff --git a/src/providers/seventv/SeventvPersonalEmotes.cpp b/src/providers/seventv/SeventvPersonalEmotes.cpp index f5ff1b79d43..818577050f5 100644 --- a/src/providers/seventv/SeventvPersonalEmotes.cpp +++ b/src/providers/seventv/SeventvPersonalEmotes.cpp @@ -171,4 +171,21 @@ std::optional SeventvPersonalEmotes::getEmoteForUser( return std::nullopt; } +std::optional> + SeventvPersonalEmotes::getEmoteSetByID(const QString &emoteSetID) const +{ + std::shared_lock lock(this->mutex_); + if (!this->enabled_) + { + return std::nullopt; + } + + auto id = this->emoteSets_.find(emoteSetID); + if (id == this->emoteSets_.end()) + { + return std::nullopt; + } + return id->second.get(); +} + } // namespace chatterino diff --git a/src/providers/seventv/SeventvPersonalEmotes.hpp b/src/providers/seventv/SeventvPersonalEmotes.hpp index 0e5c4e343ef..5b35b00e607 100644 --- a/src/providers/seventv/SeventvPersonalEmotes.hpp +++ b/src/providers/seventv/SeventvPersonalEmotes.hpp @@ -44,6 +44,9 @@ class SeventvPersonalEmotes : public Singleton std::optional getEmoteForUser(const QString &userID, const EmoteName &emoteName) const; + std::optional> getEmoteSetByID( + const QString &emoteSetID) const; + private: // emoteSetID => emoteSet std::unordered_map>> diff --git a/src/providers/twitch/TwitchChannel.cpp b/src/providers/twitch/TwitchChannel.cpp index c80c96ae271..11161438fa8 100644 --- a/src/providers/twitch/TwitchChannel.cpp +++ b/src/providers/twitch/TwitchChannel.cpp @@ -1792,6 +1792,10 @@ void TwitchChannel::listenSevenTVCosmetics() const void TwitchChannel::upsertPersonalSeventvEmotes( const QString &userLogin, const std::shared_ptr &emoteMap) { + // This is attempting a (kind-of) surgical replacement of the users' last + // sent message. The the last message is essentially re-parsed and newly + // added emotes are inserted where appropriate. + assertInGuiThread(); auto snapshot = this->getMessageSnapshot(); if (snapshot.size() == 0) @@ -1799,11 +1803,14 @@ void TwitchChannel::upsertPersonalSeventvEmotes( return; } + /// Finds the last message of the user (searches the last five messages). + /// If no message is found, `std::nullopt` is returned. const auto findMessage = [&]() -> std::optional { - auto end = std::max(0, (ptrdiff_t)snapshot.size() - 5); + auto size = static_cast(snapshot.size()); + auto end = std::max(0, size - 5); // explicitly using signed integers here to represent '-1' - for (ptrdiff_t i = (ptrdiff_t)snapshot.size() - 1; i >= end; i--) + for (qsizetype i = size - 1; i >= end; i--) { const auto &message = snapshot[i]; if (message->loginName == userLogin) @@ -1821,11 +1828,105 @@ void TwitchChannel::upsertPersonalSeventvEmotes( return; } + using MessageElementVec = std::vector>; + + /// Tries to find words in the @a textElement that are emotes in the @a emoteMap + /// (i.e. newly added emotes) and converts these to an emote element + /// or, if they're zero-width, to a layered emote element. + const auto upsertWords = [&](MessageElementVec &elements, + TextElement *textElement) { + std::vector words; + + /// Appends a text element with the pending @a words + /// and clears the vector. + /// + /// @pre @a words must not be empty + const auto flush = [&]() { + elements.emplace_back(std::make_unique( + std::move(words), textElement->getFlags(), textElement->color(), + textElement->style())); + words.clear(); + }; + + /// Attempts to insert the emote as a zero-width emote. + /// If there are pending words to be inserted (i.e. @a words is not empty + /// and thus there's no previous emote to merge the @a emote with), + /// or there are no elements in the message yet, the insertion fails. + /// + /// @returns `true` iff the insertion succeeded. + const auto tryInsertZeroWidth = [&](const EmotePtr &emote) -> bool { + if (!words.empty() || elements.empty()) + { + // either the last element will be a TextElement _or_ + // there are no elements. + return false; + } + // [THIS IS LARGELY THE SAME AS IN TwitchMessageBuilder::tryAppendEmote] + // Attempt to merge current zero-width emote into any previous emotes + auto *asEmote = dynamic_cast(elements.back().get()); + if (asEmote) + { + // Make sure to access asEmote before taking ownership when releasing + auto baseEmote = asEmote->getEmote(); + // Need to remove EmoteElement and replace with LayeredEmoteElement + auto baseEmoteElement = std::move(elements.back()); + elements.pop_back(); + + std::vector layers{ + {baseEmote, baseEmoteElement->getFlags()}, + {emote, MessageElementFlag::SevenTVEmote}, + }; + elements.emplace_back(std::make_unique( + std::move(layers), + baseEmoteElement->getFlags() | + MessageElementFlag::SevenTVEmote, + textElement->color())); + return true; + } + + auto *asLayered = + dynamic_cast(elements.back().get()); + if (asLayered) + { + asLayered->addEmoteLayer( + {emote, MessageElementFlag::SevenTVEmote}); + asLayered->addFlags(MessageElementFlag::SevenTVEmote); + return true; + } + return false; + }; + + // Find all words that match a personal emote and replace them with emotes + for (const auto &word : textElement->words()) + { + auto emoteIt = emoteMap->find(EmoteName{word.text}); + if (emoteIt == emoteMap->end()) + { + words.emplace_back(word); + continue; + } + + if (emoteIt->second->zeroWidth) + { + if (tryInsertZeroWidth(emoteIt->second)) + { + continue; + } + } + + flush(); + + elements.emplace_back(std::make_unique( + emoteIt->second, MessageElementFlag::SevenTVEmote)); + } + flush(); + }; + auto cloned = message.value()->cloneWith([&](Message &message) { // We create a new vector of elements, // if we encounter a `TextElement` that contains any emote, - // we insert an `EmoteElement` at the position. - std::vector> elements; + // we insert an `EmoteElement` (or `LayeredEmoteElement`) at the position. + MessageElementVec elements; elements.reserve(message.elements.size()); std::for_each( @@ -1839,39 +1940,7 @@ void TwitchChannel::upsertPersonalSeventvEmotes( if (textElement != nullptr && textElement->getFlags().has(MessageElementFlag::Text)) { - std::vector words; - // Append the text element and clear the vector. - const auto flush = [&]() { - elements.emplace_back(std::make_unique( - std::move(words), textElement->getFlags(), - textElement->color(), textElement->style())); - words.clear(); - }; - - // Search for a word that matches any emote. - for (const auto &word : textElement->words()) - { - auto emoteIt = emoteMap->find(EmoteName{word.text}); - if (emoteIt != emoteMap->cend()) - { - MessageElementFlags emoteFlags( - MessageElementFlag::SevenTVEmote); - // TODO: This doesn't support zero-width emotes. - // To support these emotes, we'd now need to look back at the added elements - // and insert/update a LayeredEmoteElement. - // As of now, this requires too much effort. - - flush(); - elements.emplace_back( - std::make_unique(emoteIt->second, - emoteFlags)); - } - else - { - words.emplace_back(word); - } - } - flush(); + upsertWords(elements, textElement); } else {