feat: Add Live Emote Updates for BTTV (#4147)

This feature is enabled by default and can be disabled in settings with the "Enable BTTV live emotes updates" setting.

Co-authored-by: Felanbird <41973452+Felanbird@users.noreply.github.com>
Co-authored-by: pajlada <rasmus.karlsson@pajlada.com>
This commit is contained in:
nerix
2023-01-21 15:06:55 +01:00
committed by GitHub
parent 56f7c91a64
commit 904749cf62
22 changed files with 856 additions and 33 deletions
+147 -26
View File
@@ -7,6 +7,7 @@
#include "messages/Image.hpp"
#include "messages/ImageSet.hpp"
#include "messages/MessageBuilder.hpp"
#include "providers/bttv/liveupdates/BttvLiveUpdateMessages.hpp"
#include "providers/twitch/TwitchChannel.hpp"
#include "singletons/Settings.hpp"
@@ -21,6 +22,12 @@ namespace {
QString emoteLinkFormat("https://betterttv.com/emotes/%1");
struct CreateEmoteResult {
EmoteId id;
EmoteName name;
Emote emote;
};
Url getEmoteLink(QString urlTemplate, const EmoteId &id,
const QString &emoteScale)
{
@@ -70,6 +77,70 @@ namespace {
return {Success, std::move(emotes)};
}
CreateEmoteResult createChannelEmote(const QString &channelDisplayName,
const QJsonObject &jsonEmote)
{
auto id = EmoteId{jsonEmote.value("id").toString()};
auto name = EmoteName{jsonEmote.value("code").toString()};
auto author = EmoteAuthor{
jsonEmote.value("user").toObject().value("displayName").toString()};
auto emote = Emote({
name,
ImageSet{
Image::fromUrl(getEmoteLinkV3(id, "1x"), 1),
Image::fromUrl(getEmoteLinkV3(id, "2x"), 0.5),
Image::fromUrl(getEmoteLinkV3(id, "3x"), 0.25),
},
Tooltip{
QString("%1<br>%2 BetterTTV Emote<br>By: %3")
.arg(name.string)
// when author is empty, it is a channel emote created by the broadcaster
.arg(author.string.isEmpty() ? "Channel" : "Shared")
.arg(author.string.isEmpty() ? channelDisplayName
: author.string)},
Url{emoteLinkFormat.arg(id.string)},
false,
id,
});
return {id, name, emote};
}
bool updateChannelEmote(Emote &emote, const QString &channelDisplayName,
const QJsonObject &jsonEmote)
{
bool anyModifications = false;
if (jsonEmote.contains("code"))
{
emote.name = EmoteName{jsonEmote.value("code").toString()};
anyModifications = true;
}
if (jsonEmote.contains("user"))
{
emote.author = EmoteAuthor{jsonEmote.value("user")
.toObject()
.value("displayName")
.toString()};
anyModifications = true;
}
if (anyModifications)
{
emote.tooltip = Tooltip{
QString("%1<br>%2 BetterTTV Emote<br>By: %3")
.arg(emote.name.string)
// when author is empty, it is a channel emote created by the broadcaster
.arg(emote.author.string.isEmpty() ? "Channel" : "Shared")
.arg(emote.author.string.isEmpty() ? channelDisplayName
: emote.author.string)};
}
return anyModifications;
}
std::pair<Outcome, EmoteMap> parseChannelEmotes(
const QJsonObject &jsonRoot, const QString &channelDisplayName)
{
@@ -80,33 +151,11 @@ namespace {
auto jsonEmotes = jsonRoot.value(key).toArray();
for (auto jsonEmote_ : jsonEmotes)
{
auto jsonEmote = jsonEmote_.toObject();
auto emote = createChannelEmote(channelDisplayName,
jsonEmote_.toObject());
auto id = EmoteId{jsonEmote.value("id").toString()};
auto name = EmoteName{jsonEmote.value("code").toString()};
auto author = EmoteAuthor{jsonEmote.value("user")
.toObject()
.value("displayName")
.toString()};
auto emote = Emote({
name,
ImageSet{
Image::fromUrl(getEmoteLinkV3(id, "1x"), 1),
Image::fromUrl(getEmoteLinkV3(id, "2x"), 0.5),
Image::fromUrl(getEmoteLinkV3(id, "3x"), 0.25),
},
Tooltip{
QString("%1<br>%2 BetterTTV Emote<br>By: %3")
.arg(name.string)
// when author is empty, it is a channel emote created by the broadcaster
.arg(author.string.isEmpty() ? "Channel" : "Shared")
.arg(author.string.isEmpty() ? channelDisplayName
: author.string)},
Url{emoteLinkFormat.arg(id.string)},
});
emotes[name] = cachedOrMake(std::move(emote), id);
emotes[emote.name] =
cachedOrMake(std::move(emote.emote), emote.id);
}
};
@@ -227,6 +276,78 @@ void BttvEmotes::loadChannel(std::weak_ptr<Channel> channel,
.execute();
}
EmotePtr BttvEmotes::addEmote(
const QString &channelDisplayName,
Atomic<std::shared_ptr<const EmoteMap>> &channelEmoteMap,
const BttvLiveUpdateEmoteUpdateAddMessage &message)
{
// This copies the map.
EmoteMap updatedMap = *channelEmoteMap.get();
auto result = createChannelEmote(channelDisplayName, message.jsonEmote);
auto emote = std::make_shared<const Emote>(std::move(result.emote));
updatedMap[result.name] = emote;
channelEmoteMap.set(std::make_shared<EmoteMap>(std::move(updatedMap)));
return emote;
}
boost::optional<std::pair<EmotePtr, EmotePtr>> BttvEmotes::updateEmote(
const QString &channelDisplayName,
Atomic<std::shared_ptr<const EmoteMap>> &channelEmoteMap,
const BttvLiveUpdateEmoteUpdateAddMessage &message)
{
// This copies the map.
EmoteMap updatedMap = *channelEmoteMap.get();
// Step 1: remove the existing emote
auto it = updatedMap.findEmote(QString(), message.emoteID);
if (it == updatedMap.end())
{
// We already copied the map at this point and are now discarding the copy.
// This is fine, because this case should be really rare.
return boost::none;
}
auto oldEmotePtr = it->second;
// copy the existing emote, to not change the original one
auto emote = *oldEmotePtr;
updatedMap.erase(it);
// Step 2: update the emote
if (!updateChannelEmote(emote, channelDisplayName, message.jsonEmote))
{
// The emote wasn't actually updated
return boost::none;
}
auto name = emote.name;
auto emotePtr = std::make_shared<const Emote>(std::move(emote));
updatedMap[name] = emotePtr;
channelEmoteMap.set(std::make_shared<EmoteMap>(std::move(updatedMap)));
return std::make_pair(oldEmotePtr, emotePtr);
}
boost::optional<EmotePtr> BttvEmotes::removeEmote(
Atomic<std::shared_ptr<const EmoteMap>> &channelEmoteMap,
const BttvLiveUpdateEmoteRemoveMessage &message)
{
// This copies the map.
EmoteMap updatedMap = *channelEmoteMap.get();
auto it = updatedMap.findEmote(QString(), message.emoteID);
if (it == updatedMap.end())
{
// We already copied the map at this point and are now discarding the copy.
// This is fine, because this case should be really rare.
return boost::none;
}
auto emote = it->second;
updatedMap.erase(it);
channelEmoteMap.set(std::make_shared<EmoteMap>(std::move(updatedMap)));
return emote;
}
/*
static Url getEmoteLink(QString urlTemplate, const EmoteId &id,
const QString &emoteScale)
+37
View File
@@ -13,6 +13,8 @@ struct Emote;
using EmotePtr = std::shared_ptr<const Emote>;
class EmoteMap;
class Channel;
struct BttvLiveUpdateEmoteUpdateAddMessage;
struct BttvLiveUpdateEmoteRemoveMessage;
class BttvEmotes final
{
@@ -33,6 +35,41 @@ public:
std::function<void(EmoteMap &&)> callback,
bool manualRefresh);
/**
* Adds an emote to the `channelEmoteMap`.
* This will _copy_ the emote map and
* update the `Atomic`.
*
* @return The added emote.
*/
static EmotePtr addEmote(
const QString &channelDisplayName,
Atomic<std::shared_ptr<const EmoteMap>> &channelEmoteMap,
const BttvLiveUpdateEmoteUpdateAddMessage &message);
/**
* Updates an emote in this `channelEmoteMap`.
* This will _copy_ the emote map and
* update the `Atomic`.
*
* @return pair<old emote, new emote> if any emote was updated.
*/
static boost::optional<std::pair<EmotePtr, EmotePtr>> updateEmote(
const QString &channelDisplayName,
Atomic<std::shared_ptr<const EmoteMap>> &channelEmoteMap,
const BttvLiveUpdateEmoteUpdateAddMessage &message);
/**
* Removes an emote from this `channelEmoteMap`.
* This will _copy_ the emote map and
* update the `Atomic`.
*
* @return The removed emote if any emote was removed.
*/
static boost::optional<EmotePtr> removeEmote(
Atomic<std::shared_ptr<const EmoteMap>> &channelEmoteMap,
const BttvLiveUpdateEmoteRemoveMessage &message);
private:
Atomic<std::shared_ptr<const EmoteMap>> global_;
};
+92
View File
@@ -0,0 +1,92 @@
#include "providers/bttv/BttvLiveUpdates.hpp"
#include <QJsonDocument>
#include <utility>
namespace chatterino {
BttvLiveUpdates::BttvLiveUpdates(QString host)
: BasicPubSubManager(std::move(host))
{
}
void BttvLiveUpdates::joinChannel(const QString &channelID,
const QString &userName)
{
if (this->joinedChannels_.insert(channelID).second)
{
this->subscribe({BttvLiveUpdateSubscriptionChannel{channelID}});
this->subscribe({BttvLiveUpdateBroadcastMe{.twitchID = channelID,
.userName = userName}});
}
}
void BttvLiveUpdates::partChannel(const QString &id)
{
if (this->joinedChannels_.erase(id) > 0)
{
this->unsubscribe({BttvLiveUpdateSubscriptionChannel{id}});
}
}
void BttvLiveUpdates::onMessage(
websocketpp::connection_hdl /*hdl*/,
BasicPubSubManager<BttvLiveUpdateSubscription>::WebsocketMessagePtr msg)
{
const auto &payload = QString::fromStdString(msg->get_payload());
QJsonDocument jsonDoc(QJsonDocument::fromJson(payload.toUtf8()));
if (jsonDoc.isNull())
{
qCDebug(chatterinoBttv) << "Failed to parse live update JSON";
return;
}
auto json = jsonDoc.object();
auto eventType = json["name"].toString();
auto eventData = json["data"].toObject();
if (eventType == "emote_create")
{
auto message = BttvLiveUpdateEmoteUpdateAddMessage(eventData);
if (!message.validate())
{
qCDebug(chatterinoBttv) << "Invalid add message" << json;
return;
}
this->signals_.emoteAdded.invoke(message);
}
else if (eventType == "emote_update")
{
auto message = BttvLiveUpdateEmoteUpdateAddMessage(eventData);
if (!message.validate())
{
qCDebug(chatterinoBttv) << "Invalid update message" << json;
return;
}
this->signals_.emoteUpdated.invoke(message);
}
else if (eventType == "emote_delete")
{
auto message = BttvLiveUpdateEmoteRemoveMessage(eventData);
if (!message.validate())
{
qCDebug(chatterinoBttv) << "Invalid deletion message" << json;
return;
}
this->signals_.emoteRemoved.invoke(message);
}
else
{
qCDebug(chatterinoBttv) << "Unhandled event:" << json;
}
}
} // namespace chatterino
+57
View File
@@ -0,0 +1,57 @@
#pragma once
#include "providers/bttv/liveupdates/BttvLiveUpdateMessages.hpp"
#include "providers/bttv/liveupdates/BttvLiveUpdateSubscription.hpp"
#include "providers/liveupdates/BasicPubSubManager.hpp"
#include "util/QStringHash.hpp"
#include <pajlada/signals/signal.hpp>
#include <unordered_set>
namespace chatterino {
class BttvLiveUpdates : public BasicPubSubManager<BttvLiveUpdateSubscription>
{
template <typename T>
using Signal =
pajlada::Signals::Signal<T>; // type-id is vector<T, Alloc<T>>
public:
BttvLiveUpdates(QString host);
struct {
Signal<BttvLiveUpdateEmoteUpdateAddMessage> emoteAdded;
Signal<BttvLiveUpdateEmoteUpdateAddMessage> emoteUpdated;
Signal<BttvLiveUpdateEmoteRemoveMessage> emoteRemoved;
} signals_; // NOLINT(readability-identifier-naming)
/**
* Joins a Twitch channel by its id (without any prefix like 'twitch:')
* if it's not already joined.
*
* @param channelID the Twitch channel-id of the broadcaster.
* @param userName the Twitch username of the current user.
*/
void joinChannel(const QString &channelID, const QString &userName);
/**
* Parts a twitch channel by its id (without any prefix like 'twitch:')
* if it's joined.
*
* @param id the Twitch channel-id of the broadcaster.
*/
void partChannel(const QString &id);
protected:
void onMessage(
websocketpp::connection_hdl hdl,
BasicPubSubManager<BttvLiveUpdateSubscription>::WebsocketMessagePtr msg)
override;
private:
// Contains all joined Twitch channel-ids
std::unordered_set<QString> joinedChannels_;
};
} // namespace chatterino
@@ -0,0 +1,52 @@
#include "providers/bttv/liveupdates/BttvLiveUpdateMessages.hpp"
namespace {
bool tryParseChannelId(QString &channelId)
{
if (!channelId.startsWith("twitch:"))
{
return false;
}
channelId.remove(0, 7); // "twitch:"
return true;
}
} // namespace
namespace chatterino {
BttvLiveUpdateEmoteUpdateAddMessage::BttvLiveUpdateEmoteUpdateAddMessage(
const QJsonObject &json)
: channelID(json["channel"].toString())
, jsonEmote(json["emote"].toObject())
, emoteName(this->jsonEmote["code"].toString())
, emoteID(this->jsonEmote["id"].toString())
, badChannelID_(!tryParseChannelId(this->channelID))
{
}
bool BttvLiveUpdateEmoteUpdateAddMessage::validate() const
{
// We don't need to check for jsonEmote["code"]/["id"],
// because these are this->emoteID and this->emoteName.
return !this->badChannelID_ && !this->channelID.isEmpty() &&
!this->emoteID.isEmpty() && !this->emoteName.isEmpty();
}
BttvLiveUpdateEmoteRemoveMessage::BttvLiveUpdateEmoteRemoveMessage(
const QJsonObject &json)
: channelID(json["channel"].toString())
, emoteID(json["emoteId"].toString())
, badChannelID_(!tryParseChannelId(this->channelID))
{
}
bool BttvLiveUpdateEmoteRemoveMessage::validate() const
{
return !this->badChannelID_ && !this->emoteID.isEmpty() &&
!this->channelID.isEmpty();
}
} // namespace chatterino
@@ -0,0 +1,38 @@
#pragma once
#include <QJsonObject>
namespace chatterino {
struct BttvLiveUpdateEmoteUpdateAddMessage {
BttvLiveUpdateEmoteUpdateAddMessage(const QJsonObject &json);
QString channelID;
QJsonObject jsonEmote;
QString emoteName;
QString emoteID;
bool validate() const;
private:
// true if the channel id is malformed
// (e.g. doesn't start with "twitch:")
bool badChannelID_;
};
struct BttvLiveUpdateEmoteRemoveMessage {
BttvLiveUpdateEmoteRemoveMessage(const QJsonObject &json);
QString channelID;
QString emoteID;
bool validate() const;
private:
// true if the channel id is malformed
// (e.g. doesn't start with "twitch:")
bool badChannelID_;
};
} // namespace chatterino
@@ -0,0 +1,107 @@
#include "providers/bttv/liveupdates/BttvLiveUpdateSubscription.hpp"
#include <QJsonDocument>
namespace chatterino {
QByteArray BttvLiveUpdateSubscription::encodeSubscribe() const
{
return QJsonDocument(std::visit(
[](const auto &d) {
return d.encode(true);
},
this->data))
.toJson();
}
QByteArray BttvLiveUpdateSubscription::encodeUnsubscribe() const
{
return QJsonDocument(std::visit(
[](const auto &d) {
return d.encode(false);
},
this->data))
.toJson();
}
QDebug &operator<<(QDebug &dbg, const BttvLiveUpdateSubscription &subscription)
{
std::visit(
[&](const auto &data) {
dbg << data;
},
subscription.data);
return dbg;
}
QJsonObject BttvLiveUpdateSubscriptionChannel::encode(bool isSubscribe) const
{
QJsonObject root;
if (isSubscribe)
{
root["name"] = "join_channel";
}
else
{
root["name"] = "part_channel";
}
QJsonObject data;
data["name"] = QString("twitch:%1").arg(this->twitchID);
root["data"] = data;
return root;
}
bool BttvLiveUpdateSubscriptionChannel::operator==(
const BttvLiveUpdateSubscriptionChannel &rhs) const
{
return this->twitchID == rhs.twitchID;
}
bool BttvLiveUpdateSubscriptionChannel::operator!=(
const BttvLiveUpdateSubscriptionChannel &rhs) const
{
return !(*this == rhs);
}
QDebug &operator<<(QDebug &dbg, const BttvLiveUpdateSubscriptionChannel &data)
{
dbg << "BttvLiveUpdateSubscriptionChannel{ twitchID:" << data.twitchID
<< '}';
return dbg;
}
QJsonObject BttvLiveUpdateBroadcastMe::encode(bool /*isSubscribe*/) const
{
QJsonObject root;
root["name"] = "broadcast_me";
QJsonObject data;
data["name"] = this->userName;
data["channel"] = QString("twitch:%1").arg(this->twitchID);
root["data"] = data;
return root;
}
bool BttvLiveUpdateBroadcastMe::operator==(
const BttvLiveUpdateBroadcastMe &rhs) const
{
return this->twitchID == rhs.twitchID && this->userName == rhs.userName;
}
bool BttvLiveUpdateBroadcastMe::operator!=(
const BttvLiveUpdateBroadcastMe &rhs) const
{
return !(*this == rhs);
}
QDebug &operator<<(QDebug &dbg, const BttvLiveUpdateBroadcastMe &data)
{
dbg << "BttvLiveUpdateBroadcastMe{ twitchID:" << data.twitchID
<< "userName:" << data.userName << '}';
return dbg;
}
} // namespace chatterino
@@ -0,0 +1,89 @@
#pragma once
#include <boost/functional/hash.hpp>
#include <QByteArray>
#include <QHash>
#include <QJsonObject>
#include <QString>
#include <variant>
namespace chatterino {
struct BttvLiveUpdateSubscriptionChannel {
QString twitchID;
QJsonObject encode(bool isSubscribe) const;
bool operator==(const BttvLiveUpdateSubscriptionChannel &rhs) const;
bool operator!=(const BttvLiveUpdateSubscriptionChannel &rhs) const;
friend QDebug &operator<<(QDebug &dbg,
const BttvLiveUpdateSubscriptionChannel &data);
};
struct BttvLiveUpdateBroadcastMe {
QString twitchID;
QString userName;
QJsonObject encode(bool isSubscribe) const;
bool operator==(const BttvLiveUpdateBroadcastMe &rhs) const;
bool operator!=(const BttvLiveUpdateBroadcastMe &rhs) const;
friend QDebug &operator<<(QDebug &dbg,
const BttvLiveUpdateBroadcastMe &data);
};
using BttvLiveUpdateSubscriptionData =
std::variant<BttvLiveUpdateSubscriptionChannel, BttvLiveUpdateBroadcastMe>;
struct BttvLiveUpdateSubscription {
BttvLiveUpdateSubscriptionData data;
QByteArray encodeSubscribe() const;
QByteArray encodeUnsubscribe() const;
bool operator==(const BttvLiveUpdateSubscription &rhs) const
{
return this->data == rhs.data;
}
bool operator!=(const BttvLiveUpdateSubscription &rhs) const
{
return !(*this == rhs);
}
friend QDebug &operator<<(QDebug &dbg,
const BttvLiveUpdateSubscription &subscription);
};
} // namespace chatterino
namespace std {
template <>
struct hash<chatterino::BttvLiveUpdateSubscriptionChannel> {
size_t operator()(
const chatterino::BttvLiveUpdateSubscriptionChannel &data) const
{
return qHash(data.twitchID);
}
};
template <>
struct hash<chatterino::BttvLiveUpdateBroadcastMe> {
size_t operator()(const chatterino::BttvLiveUpdateBroadcastMe &data) const
{
size_t seed = 0;
boost::hash_combine(seed, qHash(data.twitchID));
boost::hash_combine(seed, qHash(data.userName));
return seed;
}
};
template <>
struct hash<chatterino::BttvLiveUpdateSubscription> {
size_t operator()(const chatterino::BttvLiveUpdateSubscription &sub) const
{
return std::hash<chatterino::BttvLiveUpdateSubscriptionData>{}(
sub.data);
}
};
} // namespace std