refactor: load Twitch emotes from Helix (#5239)

This commit is contained in:
nerix 2024-09-01 11:22:54 +02:00 committed by GitHub
parent 03b0e4881f
commit 820aa12af6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 1251 additions and 528 deletions

View file

@ -42,7 +42,7 @@ CheckOptions:
- key: readability-identifier-naming.FunctionCase - key: readability-identifier-naming.FunctionCase
value: camelBack value: camelBack
- key: readability-identifier-naming.FunctionIgnoredRegexp - key: readability-identifier-naming.FunctionIgnoredRegexp
value: ^TEST$ value: ^(TEST|MOCK_METHOD)$
- key: readability-identifier-naming.MemberCase - key: readability-identifier-naming.MemberCase
value: camelBack value: camelBack

View file

@ -45,7 +45,7 @@ jobs:
build_dir: build-clang-tidy build_dir: build-clang-tidy
config_file: ".clang-tidy" config_file: ".clang-tidy"
split_workflow: true split_workflow: true
exclude: "lib/*,tools/crash-handler/*" exclude: "lib/*,tools/crash-handler/*,mocks/*"
cmake_command: >- cmake_command: >-
./.CI/setup-clang-tidy.sh ./.CI/setup-clang-tidy.sh
apt_packages: >- apt_packages: >-

View file

@ -42,6 +42,7 @@
- Bugfix: Fixed splits staying paused after unfocusing Chatterino in certain configurations. (#5504) - Bugfix: Fixed splits staying paused after unfocusing Chatterino in certain configurations. (#5504)
- Bugfix: Links with invalid characters in the domain are no longer detected. (#5509) - Bugfix: Links with invalid characters in the domain are no longer detected. (#5509)
- Bugfix: Fixed janky selection for messages with RTL segments (selection is still wrong, but consistently wrong). (#5525) - Bugfix: Fixed janky selection for messages with RTL segments (selection is still wrong, but consistently wrong). (#5525)
- Bugfix: Fixed event emotes not showing up in autocomplete and popups. (#5239)
- Bugfix: Fixed tab visibility being controllable in the emote popup. (#5530) - Bugfix: Fixed tab visibility being controllable in the emote popup. (#5530)
- Bugfix: Fixed account switch not being saved if no other settings were changed. (#5558) - Bugfix: Fixed account switch not being saved if no other settings were changed. (#5558)
- Bugfix: Fixed some tooltips not being readable. (#5578) - Bugfix: Fixed some tooltips not being readable. (#5578)

View file

@ -257,6 +257,13 @@ public:
return nullptr; return nullptr;
} }
ITwitchUsers *getTwitchUsers() override
{
assert(false && "EmptyApplication::getTwitchUsers was called without "
"being initialized");
return nullptr;
}
QTemporaryDir settingsDir; QTemporaryDir settingsDir;
Paths paths_; Paths paths_;
Args args_; Args args_;

View file

@ -410,6 +410,23 @@ public:
(FailureCallback<HelixSendMessageError, QString> failureCallback)), (FailureCallback<HelixSendMessageError, QString> failureCallback)),
(override)); (override));
// get user emotes
MOCK_METHOD(
void, getUserEmotes,
(QString userID, QString broadcasterID,
(ResultCallback<std::vector<HelixChannelEmote>, HelixPaginationState>
successCallback),
FailureCallback<QString> failureCallback, CancellationToken &&token),
(override));
// get followed channel
MOCK_METHOD(
void, getFollowedChannel,
(QString userID, QString broadcasterID,
ResultCallback<std::optional<HelixFollowedChannel>> successCallback,
FailureCallback<QString> failureCallback),
(override));
MOCK_METHOD(void, update, (QString clientId, QString oauthToken), MOCK_METHOD(void, update, (QString clientId, QString oauthToken),
(override)); (override));

View file

@ -42,6 +42,7 @@
#include "providers/twitch/PubSubMessages.hpp" #include "providers/twitch/PubSubMessages.hpp"
#include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchChannel.hpp"
#include "providers/twitch/TwitchIrcServer.hpp" #include "providers/twitch/TwitchIrcServer.hpp"
#include "providers/twitch/TwitchUsers.hpp"
#include "singletons/CrashHandler.hpp" #include "singletons/CrashHandler.hpp"
#include "singletons/Emotes.hpp" #include "singletons/Emotes.hpp"
#include "singletons/Fonts.hpp" #include "singletons/Fonts.hpp"
@ -176,6 +177,7 @@ Application::Application(Settings &_settings, const Paths &paths,
, logging(new Logging(_settings)) , logging(new Logging(_settings))
, linkResolver(new LinkResolver) , linkResolver(new LinkResolver)
, streamerMode(new StreamerMode) , streamerMode(new StreamerMode)
, twitchUsers(new TwitchUsers)
#ifdef CHATTERINO_HAVE_PLUGINS #ifdef CHATTERINO_HAVE_PLUGINS
, plugins(new PluginController(paths)) , plugins(new PluginController(paths))
#endif #endif
@ -515,6 +517,14 @@ IStreamerMode *Application::getStreamerMode()
return this->streamerMode.get(); return this->streamerMode.get();
} }
ITwitchUsers *Application::getTwitchUsers()
{
assertInGuiThread();
assert(this->twitchUsers);
return this->twitchUsers.get();
}
BttvEmotes *Application::getBttvEmotes() BttvEmotes *Application::getBttvEmotes()
{ {
assertInGuiThread(); assertInGuiThread();

View file

@ -53,6 +53,7 @@ class SeventvEmotes;
class SeventvEventAPI; class SeventvEventAPI;
class ILinkResolver; class ILinkResolver;
class IStreamerMode; class IStreamerMode;
class ITwitchUsers;
class IApplication class IApplication
{ {
@ -103,6 +104,7 @@ public:
virtual SeventvEventAPI *getSeventvEventAPI() = 0; virtual SeventvEventAPI *getSeventvEventAPI() = 0;
virtual ILinkResolver *getLinkResolver() = 0; virtual ILinkResolver *getLinkResolver() = 0;
virtual IStreamerMode *getStreamerMode() = 0; virtual IStreamerMode *getStreamerMode() = 0;
virtual ITwitchUsers *getTwitchUsers() = 0;
}; };
class Application : public IApplication class Application : public IApplication
@ -166,6 +168,7 @@ private:
const std::unique_ptr<Logging> logging; const std::unique_ptr<Logging> logging;
std::unique_ptr<ILinkResolver> linkResolver; std::unique_ptr<ILinkResolver> linkResolver;
std::unique_ptr<IStreamerMode> streamerMode; std::unique_ptr<IStreamerMode> streamerMode;
std::unique_ptr<ITwitchUsers> twitchUsers;
#ifdef CHATTERINO_HAVE_PLUGINS #ifdef CHATTERINO_HAVE_PLUGINS
std::unique_ptr<PluginController> plugins; std::unique_ptr<PluginController> plugins;
#endif #endif
@ -215,6 +218,7 @@ public:
ILinkResolver *getLinkResolver() override; ILinkResolver *getLinkResolver() override;
IStreamerMode *getStreamerMode() override; IStreamerMode *getStreamerMode() override;
ITwitchUsers *getTwitchUsers() override;
private: private:
void initBttvLiveUpdates(); void initBttvLiveUpdates();

View file

@ -404,6 +404,8 @@ set(SOURCE_FILES
providers/twitch/TwitchIrcServer.hpp providers/twitch/TwitchIrcServer.hpp
providers/twitch/TwitchUser.cpp providers/twitch/TwitchUser.cpp
providers/twitch/TwitchUser.hpp providers/twitch/TwitchUser.hpp
providers/twitch/TwitchUsers.cpp
providers/twitch/TwitchUsers.hpp
providers/twitch/pubsubmessages/AutoMod.cpp providers/twitch/pubsubmessages/AutoMod.cpp
providers/twitch/pubsubmessages/AutoMod.hpp providers/twitch/pubsubmessages/AutoMod.hpp
@ -471,6 +473,7 @@ set(SOURCE_FILES
util/DebugCount.hpp util/DebugCount.hpp
util/DisplayBadge.cpp util/DisplayBadge.cpp
util/DisplayBadge.hpp util/DisplayBadge.hpp
util/Expected.hpp
util/FormatTime.cpp util/FormatTime.cpp
util/FormatTime.hpp util/FormatTime.hpp
util/FunctionEventFilter.cpp util/FunctionEventFilter.cpp

View file

@ -6,6 +6,7 @@
# include <IrcCommand> # include <IrcCommand>
# include <IrcConnection> # include <IrcConnection>
# include <IrcMessage> # include <IrcMessage>
# include <nonstd/expected.hpp>
# include <pajlada/serialize.hpp> # include <pajlada/serialize.hpp>
# include <pajlada/settings/setting.hpp> # include <pajlada/settings/setting.hpp>
# include <pajlada/settings/settinglistener.hpp> # include <pajlada/settings/settinglistener.hpp>

View file

@ -1,10 +1,12 @@
#pragma once #pragma once
#include <boost/container_hash/hash_fwd.hpp>
#include <QHash> #include <QHash>
#include <QString> #include <QString>
#include <functional> #include <functional>
// NOLINTNEXTLINE(cppcoreguidelines-macro-usage)
#define QStringAlias(name) \ #define QStringAlias(name) \
namespace chatterino { \ namespace chatterino { \
struct name { \ struct name { \
@ -27,12 +29,22 @@
return qHash(s.string); \ return qHash(s.string); \
} \ } \
}; \ }; \
} /* namespace std */ } /* namespace std */ \
namespace boost { \
template <> \
struct hash<chatterino::name> { \
std::size_t operator()(chatterino::name const &s) const \
{ \
return qHash(s.string); \
} \
}; \
} /* namespace boost */
QStringAlias(UserName); QStringAlias(UserName);
QStringAlias(UserId); QStringAlias(UserId);
QStringAlias(Url); QStringAlias(Url);
QStringAlias(Tooltip); QStringAlias(Tooltip);
QStringAlias(EmoteId); QStringAlias(EmoteId);
QStringAlias(EmoteSetId);
QStringAlias(EmoteName); QStringAlias(EmoteName);
QStringAlias(EmoteAuthor); QStringAlias(EmoteAuthor);

View file

@ -113,8 +113,8 @@ bool appendWhisperMessageWordsLocally(const QStringList &words)
for (int i = 2; i < words.length(); i++) for (int i = 2; i < words.length(); i++)
{ {
{ // Twitch emote { // Twitch emote
auto it = accemotes.emotes.find({words[i]}); auto it = accemotes->find({words[i]});
if (it != accemotes.emotes.end()) if (it != accemotes->end())
{ {
b.emplace<EmoteElement>(it->second, b.emplace<EmoteElement>(it->second,
MessageElementFlag::TwitchEmote); MessageElementFlag::TwitchEmote);

View file

@ -95,26 +95,16 @@ void EmoteSource::initializeFromChannel(const Channel *channel)
// returns true also for special Twitch channels (/live, /mentions, /whispers, etc.) // returns true also for special Twitch channels (/live, /mentions, /whispers, etc.)
if (channel->isTwitchChannel()) if (channel->isTwitchChannel())
{ {
if (auto user = app->getAccounts()->twitch.getCurrent())
{
// Twitch Emotes available globally
auto emoteData = user->accessEmotes();
addEmotes(emotes, emoteData->emotes, "Twitch Emote");
// Twitch Emotes available locally
auto localEmoteData = user->accessLocalEmotes();
if ((tc != nullptr) &&
localEmoteData->find(tc->roomId()) != localEmoteData->end())
{
if (const auto *localEmotes = &localEmoteData->at(tc->roomId()))
{
addEmotes(emotes, *localEmotes, "Local Twitch Emotes");
}
}
}
if (tc) if (tc)
{ {
if (auto twitch = tc->localTwitchEmotes())
{
addEmotes(emotes, *twitch, "Local Twitch Emotes");
}
auto user = getApp()->getAccounts()->twitch.getCurrent();
addEmotes(emotes, **user->accessEmotes(), "Twitch Emote");
// TODO extract "Channel {BetterTTV,7TV,FrankerFaceZ}" text into a #define. // TODO extract "Channel {BetterTTV,7TV,FrankerFaceZ}" text into a #define.
if (auto bttv = tc->bttvEmotes()) if (auto bttv = tc->bttvEmotes())
{ {

View file

@ -99,7 +99,7 @@ bool IgnorePhrase::containsEmote() const
for (const auto &acc : accvec) for (const auto &acc : accvec)
{ {
const auto &accemotes = *acc->accessEmotes(); const auto &accemotes = *acc->accessEmotes();
for (const auto &emote : accemotes.emotes) for (const auto &emote : *accemotes)
{ {
if (this->replace_.contains(emote.first.string, if (this->replace_.contains(emote.first.string,
Qt::CaseSensitive)) Qt::CaseSensitive))

View file

@ -31,28 +31,6 @@ void IvrApi::getSubage(QString userName, QString channelName,
.execute(); .execute();
} }
void IvrApi::getBulkEmoteSets(QString emoteSetList,
ResultCallback<QJsonArray> successCallback,
IvrFailureCallback failureCallback)
{
QUrlQuery urlQuery;
urlQuery.addQueryItem("set_id", emoteSetList);
this->makeRequest("twitch/emotes/sets", urlQuery)
.onSuccess([successCallback, failureCallback](auto result) {
auto root = result.parseJsonArray();
successCallback(root);
})
.onError([failureCallback](auto result) {
qCWarning(chatterinoIvr)
<< "Failed IVR API Call!" << result.formatError()
<< QString(result.getData());
failureCallback();
})
.execute();
}
NetworkRequest IvrApi::makeRequest(QString url, QUrlQuery urlQuery) NetworkRequest IvrApi::makeRequest(QString url, QUrlQuery urlQuery)
{ {
assert(!url.startsWith("/")); assert(!url.startsWith("/"));

View file

@ -1,7 +1,6 @@
#pragma once #pragma once
#include "common/network/NetworkRequest.hpp" #include "common/network/NetworkRequest.hpp"
#include "providers/twitch/TwitchEmotes.hpp"
#include <QJsonArray> #include <QJsonArray>
#include <QJsonObject> #include <QJsonObject>
@ -32,45 +31,6 @@ struct IvrSubage {
} }
}; };
struct IvrEmoteSet {
const QString setId;
const QString displayName;
const QString login;
const QString channelId;
const QString tier;
const QJsonArray emotes;
IvrEmoteSet(const QJsonObject &root)
: setId(root.value("setID").toString())
, displayName(root.value("channelName").toString())
, login(root.value("channelLogin").toString())
, channelId(root.value("channelID").toString())
, tier(root.value("tier").toString())
, emotes(root.value("emoteList").toArray())
{
}
};
struct IvrEmote {
const QString code;
const QString id;
const QString setId;
const QString url;
const QString emoteType;
const QString imageType;
explicit IvrEmote(const QJsonObject &root)
: code(root.value("code").toString())
, id(root.value("id").toString())
, setId(root.value("setID").toString())
, url(TWITCH_EMOTE_TEMPLATE.arg(this->id, u"3.0"))
, emoteType(root.value("type").toString())
, imageType(root.value("assetType").toString())
{
}
};
class IvrApi final class IvrApi final
{ {
public: public:
@ -79,11 +39,6 @@ public:
ResultCallback<IvrSubage> resultCallback, ResultCallback<IvrSubage> resultCallback,
IvrFailureCallback failureCallback); IvrFailureCallback failureCallback);
// https://api.ivr.fi/v2/docs/static/index.html#/Twitch/get_twitch_emotes_sets
void getBulkEmoteSets(QString emoteSetList,
ResultCallback<QJsonArray> successCallback,
IvrFailureCallback failureCallback);
static void initialize(); static void initialize();
IvrApi() = default; IvrApi() = default;

View file

@ -870,17 +870,6 @@ void IrcMessageHandler::handleClearMessageMessage(Communi::IrcMessage *message)
void IrcMessageHandler::handleUserStateMessage(Communi::IrcMessage *message) void IrcMessageHandler::handleUserStateMessage(Communi::IrcMessage *message)
{ {
auto currentUser = getApp()->getAccounts()->twitch.getCurrent();
// set received emote-sets, used in TwitchAccount::loadUserstateEmotes
bool emoteSetsChanged = currentUser->setUserstateEmoteSets(
message->tag("emote-sets").toString().split(","));
if (emoteSetsChanged)
{
currentUser->loadUserstateEmotes();
}
QString channelName; QString channelName;
if (!trimChannelName(message->parameter(0), channelName)) if (!trimChannelName(message->parameter(0), channelName))
{ {
@ -918,24 +907,6 @@ 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()->getAccounts()->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 *ircMessage) void IrcMessageHandler::handleWhisperMessage(Communi::IrcMessage *ircMessage)
{ {
MessageParseArgs args; MessageParseArgs args;

View file

@ -44,7 +44,6 @@ public:
void handleClearChatMessage(Communi::IrcMessage *message); void handleClearChatMessage(Communi::IrcMessage *message);
void handleClearMessageMessage(Communi::IrcMessage *message); void handleClearMessageMessage(Communi::IrcMessage *message);
void handleUserStateMessage(Communi::IrcMessage *message); void handleUserStateMessage(Communi::IrcMessage *message);
void handleGlobalUserStateMessage(Communi::IrcMessage *message);
void handleWhisperMessage(Communi::IrcMessage *ircMessage); void handleWhisperMessage(Communi::IrcMessage *ircMessage);
void handleUserNoticeMessage(Communi::IrcMessage *message, void handleUserNoticeMessage(Communi::IrcMessage *message,

View file

@ -3,26 +3,33 @@
#include "Application.hpp" #include "Application.hpp"
#include "common/Channel.hpp" #include "common/Channel.hpp"
#include "common/Env.hpp" #include "common/Env.hpp"
#include "common/Literals.hpp"
#include "common/network/NetworkResult.hpp" #include "common/network/NetworkResult.hpp"
#include "common/QLogging.hpp" #include "common/QLogging.hpp"
#include "controllers/accounts/AccountController.hpp" #include "controllers/accounts/AccountController.hpp"
#include "debug/AssertInGuiThread.hpp" #include "debug/AssertInGuiThread.hpp"
#include "messages/Emote.hpp"
#include "messages/Message.hpp" #include "messages/Message.hpp"
#include "messages/MessageBuilder.hpp" #include "messages/MessageBuilder.hpp"
#include "providers/IvrApi.hpp" #include "providers/IvrApi.hpp"
#include "providers/seventv/SeventvAPI.hpp" #include "providers/seventv/SeventvAPI.hpp"
#include "providers/twitch/api/Helix.hpp" #include "providers/twitch/api/Helix.hpp"
#include "providers/twitch/TwitchCommon.hpp" #include "providers/twitch/TwitchCommon.hpp"
#include "providers/twitch/TwitchUsers.hpp"
#include "singletons/Emotes.hpp" #include "singletons/Emotes.hpp"
#include "util/CancellationToken.hpp" #include "util/CancellationToken.hpp"
#include "util/Helpers.hpp" #include "util/Helpers.hpp"
#include "util/QStringHash.hpp" #include "util/QStringHash.hpp"
#include "util/RapidjsonHelpers.hpp" #include "util/RapidjsonHelpers.hpp"
#include <boost/unordered/unordered_flat_map.hpp>
#include <QStringBuilder>
#include <QThread> #include <QThread>
namespace chatterino { namespace chatterino {
using namespace literals;
TwitchAccount::TwitchAccount(const QString &username, const QString &oauthToken, TwitchAccount::TwitchAccount(const QString &username, const QString &oauthToken,
const QString &oauthClient, const QString &userID) const QString &oauthClient, const QString &userID)
: Account(ProviderId::Twitch) : Account(ProviderId::Twitch)
@ -31,9 +38,13 @@ TwitchAccount::TwitchAccount(const QString &username, const QString &oauthToken,
, userName_(username) , userName_(username)
, userId_(userID) , userId_(userID)
, isAnon_(username == ANONYMOUS_USERNAME) , isAnon_(username == ANONYMOUS_USERNAME)
, emoteSets_(std::make_shared<TwitchEmoteSetMap>())
, emotes_(std::make_shared<EmoteMap>())
{ {
} }
TwitchAccount::~TwitchAccount() = default;
QString TwitchAccount::toString() const QString TwitchAccount::toString() const
{ {
return this->getUserName(); return this->getUserName();
@ -175,214 +186,6 @@ const std::unordered_set<QString> &TwitchAccount::blockedUserIds() const
return this->ignoresUserIds_; return this->ignoresUserIds_;
} }
void TwitchAccount::loadEmotes(std::weak_ptr<Channel> weakChannel)
{
qCDebug(chatterinoTwitch)
<< "Loading Twitch emotes for user" << this->getUserName();
if (this->getOAuthClient().isEmpty() || this->getOAuthToken().isEmpty())
{
qCDebug(chatterinoTwitch)
<< "Aborted loadEmotes due to missing Client ID and/or OAuth token";
return;
}
{
auto emoteData = this->emotes_.access();
emoteData->emoteSets.clear();
emoteData->emotes.clear();
qCDebug(chatterinoTwitch) << "Cleared emotes!";
}
this->loadUserstateEmotes(weakChannel);
}
bool TwitchAccount::setUserstateEmoteSets(QStringList newEmoteSets)
{
newEmoteSets.sort();
if (this->userstateEmoteSets_ == newEmoteSets)
{
// Nothing has changed
return false;
}
this->userstateEmoteSets_ = newEmoteSets;
return true;
}
void TwitchAccount::loadUserstateEmotes(std::weak_ptr<Channel> weakChannel)
{
if (this->userstateEmoteSets_.isEmpty())
{
return;
}
QStringList newEmoteSetKeys, existingEmoteSetKeys;
auto emoteData = this->emotes_.access();
auto userEmoteSets = emoteData->emoteSets;
// get list of already fetched emote sets
for (const auto &userEmoteSet : userEmoteSets)
{
existingEmoteSetKeys.push_back(userEmoteSet->key);
}
// filter out emote sets from userstate message, which are not in fetched emote set list
for (const auto &emoteSetKey : this->userstateEmoteSets_)
{
if (!existingEmoteSetKeys.contains(emoteSetKey))
{
newEmoteSetKeys.push_back(emoteSetKey);
}
}
// return if there are no new emote sets
if (newEmoteSetKeys.isEmpty())
{
return;
}
// requesting emotes
auto batches = splitListIntoBatches(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(
batches.at(i).join(","),
[this, weakChannel](QJsonArray emoteSetArray) {
auto emoteData = this->emotes_.access();
auto localEmoteData = this->localEmotes_.access();
std::unordered_set<QString> subscriberChannelIDs;
std::vector<IvrEmoteSet> ivrEmoteSets;
ivrEmoteSets.reserve(emoteSetArray.size());
for (auto emoteSet : emoteSetArray)
{
IvrEmoteSet ivrEmoteSet(emoteSet.toObject());
if (!ivrEmoteSet.tier.isNull())
{
subscriberChannelIDs.insert(ivrEmoteSet.channelId);
}
ivrEmoteSets.emplace_back(ivrEmoteSet);
}
for (const auto &emoteSet : emoteData->emoteSets)
{
if (emoteSet->subscriber)
{
subscriberChannelIDs.insert(emoteSet->channelID);
}
}
for (const auto &ivrEmoteSet : ivrEmoteSets)
{
auto emoteSet = std::make_shared<EmoteSet>();
QString setKey = ivrEmoteSet.setId;
emoteSet->key = setKey;
// check if the emoteset is already in emoteData
auto isAlreadyFetched =
std::find_if(emoteData->emoteSets.begin(),
emoteData->emoteSets.end(),
[setKey](std::shared_ptr<EmoteSet> set) {
return (set->key == setKey);
});
if (isAlreadyFetched != emoteData->emoteSets.end())
{
continue;
}
emoteSet->channelID = ivrEmoteSet.channelId;
emoteSet->channelName = ivrEmoteSet.login;
emoteSet->text = ivrEmoteSet.displayName;
emoteSet->subscriber = !ivrEmoteSet.tier.isNull();
// NOTE: If a user does not have a subscriber emote set, but a follower emote set, this logic will be wrong
// However, that's not a realistic problem.
bool haveSubscriberSetForChannel =
subscriberChannelIDs.contains(ivrEmoteSet.channelId);
for (const auto &emoteObj : ivrEmoteSet.emotes)
{
IvrEmote ivrEmote(emoteObj.toObject());
auto id = EmoteId{ivrEmote.id};
auto code = EmoteName{
TwitchEmotes::cleanUpEmoteCode(ivrEmote.code)};
emoteSet->emotes.push_back(TwitchEmote{id, code});
auto emote = getApp()
->getEmotes()
->getTwitchEmotes()
->getOrCreateEmote(id, code);
// Follower emotes can be only used in their origin channel
// unless the user is subscribed, then they can be used anywhere.
if (ivrEmote.emoteType == "FOLLOWER" &&
!haveSubscriberSetForChannel)
{
emoteSet->local = true;
// EmoteMap for target channel wasn't initialized yet, doing it now
if (localEmoteData->find(ivrEmoteSet.channelId) ==
localEmoteData->end())
{
localEmoteData->emplace(ivrEmoteSet.channelId,
EmoteMap());
}
localEmoteData->at(ivrEmoteSet.channelId)
.emplace(code, emote);
}
else
{
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);
}
if (auto channel = weakChannel.lock(); channel != nullptr)
{
channel->addSystemMessage(
"Twitch subscriber emotes reloaded.");
}
},
[] {
// fetching emotes failed, ivr API might be down
});
};
}
SharedAccessGuard<const TwitchAccount::TwitchAccountEmoteData>
TwitchAccount::accessEmotes() const
{
return this->emotes_.accessConst();
}
SharedAccessGuard<const std::unordered_map<QString, EmoteMap>>
TwitchAccount::accessLocalEmotes() const
{
return this->localEmotes_.accessConst();
}
// AutoModActions // AutoModActions
void TwitchAccount::autoModAllow(const QString msgID, ChannelPtr channel) void TwitchAccount::autoModAllow(const QString msgID, ChannelPtr channel)
{ {
@ -515,4 +318,140 @@ void TwitchAccount::loadSeventvUserID()
}); });
} }
bool TwitchAccount::hasEmoteSet(const EmoteSetId &id) const
{
auto emotes = this->emoteSets_.accessConst();
return emotes->get()->contains(id);
}
SharedAccessGuard<std::shared_ptr<const TwitchEmoteSetMap>>
TwitchAccount::accessEmoteSets() const
{
return this->emoteSets_.accessConst();
}
SharedAccessGuard<std::shared_ptr<const EmoteMap>> TwitchAccount::accessEmotes()
const
{
return this->emotes_.accessConst();
}
std::optional<EmotePtr> TwitchAccount::twitchEmote(const EmoteName &name) const
{
auto emotes = this->emotes_.accessConst();
auto it = (*emotes)->find(name);
if (it != (*emotes)->end())
{
return it->second;
}
return std::nullopt;
}
void TwitchAccount::reloadEmotes(void *caller)
{
if (this->isAnon() || getApp()->isTest())
{
return;
}
CancellationToken token(false);
this->emoteToken_ = token;
auto sets = std::make_shared<TwitchEmoteSetMap>();
auto emoteMap = std::make_shared<EmoteMap>();
auto nCalls = std::make_shared<size_t>();
auto *twitchEmotes = getApp()->getEmotes()->getTwitchEmotes();
auto *twitchUsers = getApp()->getTwitchUsers();
auto addEmote = [sets, emoteMap, twitchEmotes,
twitchUsers](const HelixChannelEmote &emote) {
EmoteId id{emote.id};
EmoteName name{emote.name};
auto meta = getTwitchEmoteSetMeta(emote);
auto emotePtr = twitchEmotes->getOrCreateEmote(id, name);
if (!emoteMap->try_emplace(name, emotePtr).second)
{
// if the emote already exists, we don't want to add it to a set as
// those are assumed to be disjoint
return;
}
auto set = sets->find(EmoteSetId{meta.setID});
if (set == sets->end())
{
auto owner = [&]() {
if (meta.isSubLike)
{
return twitchUsers->resolveID({emote.ownerID});
}
return std::make_shared<TwitchUser>(TwitchUser{
.id = u"[x-c2-global-owner]"_s,
.name = {},
.displayName = {},
});
}();
set = sets->emplace(EmoteSetId{meta.setID},
TwitchEmoteSet{
.owner = owner,
.emotes = {},
.isBits = meta.isBits,
.isSubLike = meta.isSubLike,
})
.first;
}
set->second.emotes.emplace_back(std::move(emotePtr));
};
auto userID = this->getUserId();
qDebug(chatterinoTwitch).nospace()
<< "Loading Twitch emotes - userID: " << userID
<< ", broadcasterID: none, manualRefresh: " << (caller != nullptr);
getHelix()->getUserEmotes(
this->getUserId(), {},
[this, caller, emoteMap, sets, addEmote, nCalls](
const auto &emotes, const auto &state) mutable {
assert(emoteMap && sets);
(*nCalls)++;
qDebug(chatterinoTwitch).nospace()
<< "Got " << emotes.size() << " more emote(s)";
emoteMap->reserve(emoteMap->size() + emotes.size());
for (const auto &emote : emotes)
{
addEmote(emote);
}
if (state.done)
{
qDebug(chatterinoTwitch).nospace()
<< "Loaded " << emoteMap->size() << " Twitch emotes ("
<< *nCalls << " requests)";
for (auto &[id, set] : *sets)
{
std::ranges::sort(
set.emotes, [](const auto &l, const auto &r) {
return l->name.string < r->name.string;
});
}
*this->emotes_.access() = std::move(emoteMap);
*this->emoteSets_.access() = std::move(sets);
getApp()->getAccounts()->twitch.emotesReloaded.invoke(caller,
{});
}
},
[caller](const auto &error) {
qDebug(chatterinoTwitch)
<< "Failed to load Twitch emotes:" << error;
getApp()->getAccounts()->twitch.emotesReloaded.invoke(
caller, makeUnexpected(error));
},
std::move(token));
}
} // namespace chatterino } // namespace chatterino

View file

@ -1,14 +1,16 @@
#pragma once #pragma once
#include "common/Aliases.hpp"
#include "common/Atomic.hpp" #include "common/Atomic.hpp"
#include "common/UniqueAccess.hpp" #include "common/UniqueAccess.hpp"
#include "controllers/accounts/Account.hpp" #include "controllers/accounts/Account.hpp"
#include "messages/Emote.hpp" #include "messages/Emote.hpp"
#include "providers/twitch/TwitchEmotes.hpp"
#include "providers/twitch/TwitchUser.hpp" #include "providers/twitch/TwitchUser.hpp"
#include "util/CancellationToken.hpp" #include "util/CancellationToken.hpp"
#include "util/QStringHash.hpp" #include "util/QStringHash.hpp"
#include <boost/unordered/unordered_flat_map_fwd.hpp>
#include <pajlada/signals.hpp>
#include <QColor> #include <QColor>
#include <QElapsedTimer> #include <QElapsedTimer>
#include <QObject> #include <QObject>
@ -18,7 +20,6 @@
#include <functional> #include <functional>
#include <mutex> #include <mutex>
#include <unordered_set> #include <unordered_set>
#include <vector>
namespace chatterino { namespace chatterino {
@ -28,31 +29,13 @@ using ChannelPtr = std::shared_ptr<Channel>;
class TwitchAccount : public Account class TwitchAccount : public Account
{ {
public: public:
struct TwitchEmote {
EmoteId id;
EmoteName name;
};
struct EmoteSet {
QString key;
QString channelName;
QString channelID;
QString text;
bool subscriber{false};
bool local{false};
std::vector<TwitchEmote> emotes;
};
struct TwitchAccountEmoteData {
std::vector<std::shared_ptr<EmoteSet>> emoteSets;
// this EmoteMap should contain all emotes available globally
// excluding locally available emotes, such as follower ones
EmoteMap emotes;
};
TwitchAccount(const QString &username, const QString &oauthToken_, TwitchAccount(const QString &username, const QString &oauthToken_,
const QString &oauthClient_, const QString &_userID); const QString &oauthClient_, const QString &_userID);
~TwitchAccount() override;
TwitchAccount(const TwitchAccount &) = delete;
TwitchAccount(TwitchAccount &&) = delete;
TwitchAccount &operator=(const TwitchAccount &) = delete;
TwitchAccount &operator=(TwitchAccount &&) = delete;
QString toString() const override; QString toString() const override;
@ -91,23 +74,32 @@ public:
[[nodiscard]] const std::unordered_set<TwitchUser> &blocks() const; [[nodiscard]] const std::unordered_set<TwitchUser> &blocks() const;
[[nodiscard]] const std::unordered_set<QString> &blockedUserIds() const; [[nodiscard]] const std::unordered_set<QString> &blockedUserIds() const;
void loadEmotes(std::weak_ptr<Channel> weakChannel = {});
// 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(std::weak_ptr<Channel> weakChannel = {});
// 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);
SharedAccessGuard<const TwitchAccountEmoteData> accessEmotes() const;
SharedAccessGuard<const std::unordered_map<QString, EmoteMap>>
accessLocalEmotes() const;
// Automod actions // Automod actions
void autoModAllow(const QString msgID, ChannelPtr channel); void autoModAllow(const QString msgID, ChannelPtr channel);
void autoModDeny(const QString msgID, ChannelPtr channel); void autoModDeny(const QString msgID, ChannelPtr channel);
void loadSeventvUserID(); void loadSeventvUserID();
/// Returns true if the account has access to the given emote set
bool hasEmoteSet(const EmoteSetId &id) const;
/// Return a map of emote sets the account has access to
///
/// Key being the emote set ID, and contents being information about the emote set
/// and the emotes contained in the emote set
SharedAccessGuard<std::shared_ptr<const TwitchEmoteSetMap>>
accessEmoteSets() const;
/// Return a map of emotes the account has access to
SharedAccessGuard<std::shared_ptr<const EmoteMap>> accessEmotes() const;
/// Return the emote by emote name if the account has access to the emote
std::optional<EmotePtr> twitchEmote(const EmoteName &name) const;
/// Once emotes are reloaded, TwitchAccountManager::emotesReloaded is
/// invoked with @a caller and an optional error.
void reloadEmotes(void *caller = nullptr);
private: private:
QString oauthClient_; QString oauthClient_;
QString oauthToken_; QString oauthToken_;
@ -122,9 +114,9 @@ private:
std::unordered_set<TwitchUser> ignores_; std::unordered_set<TwitchUser> ignores_;
std::unordered_set<QString> ignoresUserIds_; std::unordered_set<QString> ignoresUserIds_;
// std::map<UserId, TwitchAccountEmoteData> emotes; ScopedCancellationToken emoteToken_;
UniqueAccess<TwitchAccountEmoteData> emotes_; UniqueAccess<std::shared_ptr<const TwitchEmoteSetMap>> emoteSets_;
UniqueAccess<std::unordered_map<QString, EmoteMap>> localEmotes_; UniqueAccess<std::shared_ptr<const EmoteMap>> emotes_;
QString seventvUserID_; QString seventvUserID_;
}; };

View file

@ -162,6 +162,7 @@ void TwitchAccountManager::load()
} }
this->currentUserChanged(); this->currentUserChanged();
this->currentUser_->reloadEmotes();
}); });
} }

View file

@ -2,6 +2,7 @@
#include "common/ChatterinoSetting.hpp" #include "common/ChatterinoSetting.hpp"
#include "common/SignalVector.hpp" #include "common/SignalVector.hpp"
#include "util/Expected.hpp"
#include "util/QStringHash.hpp" #include "util/QStringHash.hpp"
#include "util/RapidJsonSerializeQString.hpp" #include "util/RapidJsonSerializeQString.hpp"
@ -58,6 +59,10 @@ public:
SignalVector<std::shared_ptr<TwitchAccount>> accounts; SignalVector<std::shared_ptr<TwitchAccount>> accounts;
/// The signal is invoked with (caller, error) where caller is the argument
/// passed to reloadEmotes() and error.
pajlada::Signals::Signal<void *, ExpectedStr<void>> emotesReloaded;
private: private:
enum class AddUserResponse { enum class AddUserResponse {
UserAlreadyExists, UserAlreadyExists,

View file

@ -2,6 +2,8 @@
#include "Application.hpp" #include "Application.hpp"
#include "common/Common.hpp" #include "common/Common.hpp"
#include "common/Env.hpp"
#include "common/Literals.hpp"
#include "common/network/NetworkRequest.hpp" #include "common/network/NetworkRequest.hpp"
#include "common/network/NetworkResult.hpp" #include "common/network/NetworkResult.hpp"
#include "common/QLogging.hpp" #include "common/QLogging.hpp"
@ -32,6 +34,7 @@
#include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchAccount.hpp"
#include "providers/twitch/TwitchCommon.hpp" #include "providers/twitch/TwitchCommon.hpp"
#include "providers/twitch/TwitchIrcServer.hpp" #include "providers/twitch/TwitchIrcServer.hpp"
#include "providers/twitch/TwitchUsers.hpp"
#include "singletons/Emotes.hpp" #include "singletons/Emotes.hpp"
#include "singletons/Settings.hpp" #include "singletons/Settings.hpp"
#include "singletons/StreamerMode.hpp" #include "singletons/StreamerMode.hpp"
@ -47,11 +50,15 @@
#include <QJsonDocument> #include <QJsonDocument>
#include <QJsonObject> #include <QJsonObject>
#include <QJsonValue> #include <QJsonValue>
#include <QStringBuilder>
#include <QThread> #include <QThread>
#include <QTimer> #include <QTimer>
#include <rapidjson/document.h> #include <rapidjson/document.h>
namespace chatterino { namespace chatterino {
using namespace literals;
namespace { namespace {
#if QT_VERSION < QT_VERSION_CHECK(6, 1, 0) #if QT_VERSION < QT_VERSION_CHECK(6, 1, 0)
const QString MAGIC_MESSAGE_SUFFIX = QString((const char *)u8" \U000E0000"); const QString MAGIC_MESSAGE_SUFFIX = QString((const char *)u8" \U000E0000");
@ -84,6 +91,7 @@ TwitchChannel::TwitchChannel(const QString &name)
, subscriptionUrl_("https://www.twitch.tv/subs/" + name) , subscriptionUrl_("https://www.twitch.tv/subs/" + name)
, channelUrl_("https://twitch.tv/" + name) , channelUrl_("https://twitch.tv/" + name)
, popoutPlayerUrl_(TWITCH_PLAYER_URL.arg(name)) , popoutPlayerUrl_(TWITCH_PLAYER_URL.arg(name))
, localTwitchEmotes_(std::make_shared<EmoteMap>())
, bttvEmotes_(std::make_shared<EmoteMap>()) , bttvEmotes_(std::make_shared<EmoteMap>())
, ffzEmotes_(std::make_shared<EmoteMap>()) , ffzEmotes_(std::make_shared<EmoteMap>())
, seventvEmotes_(std::make_shared<EmoteMap>()) , seventvEmotes_(std::make_shared<EmoteMap>())
@ -94,6 +102,7 @@ TwitchChannel::TwitchChannel(const QString &name)
getApp()->getAccounts()->twitch.currentUserChanged.connect([this] { getApp()->getAccounts()->twitch.currentUserChanged.connect([this] {
this->setMod(false); this->setMod(false);
this->refreshPubSub(); this->refreshPubSub();
this->refreshTwitchChannelEmotes(false);
})); }));
this->refreshPubSub(); this->refreshPubSub();
@ -133,6 +142,36 @@ TwitchChannel::TwitchChannel(const QString &name)
}); });
this->threadClearTimer_.start(5 * 60 * 1000); this->threadClearTimer_.start(5 * 60 * 1000);
this->signalHolder_.managedConnect(
getApp()->getAccounts()->twitch.emotesReloaded,
[this](auto *caller, const auto &result) {
if (result)
{
// emotes were reloaded - clear follower emotes if the user is
// now subscribed to the streamer
if (!this->localTwitchEmotes_.get()->empty() &&
getApp()->getAccounts()->twitch.getCurrent()->hasEmoteSet(
EmoteSetId{this->localTwitchEmoteSetID_.get()}))
{
this->localTwitchEmotes_.set(std::make_shared<EmoteMap>());
}
if (caller == this)
{
this->addSystemMessage(
"Twitch subscriber emotes reloaded.");
}
return;
}
if (caller == this || caller == nullptr)
{
this->addSystemMessage(
u"Failed to load Twitch subscriber emotes: " %
result.error());
}
});
// debugging // debugging
#if 0 #if 0
for (int i = 0; i < 1000; i++) { for (int i = 0; i < 1000; i++) {
@ -194,6 +233,82 @@ void TwitchChannel::setLocalizedName(const QString &name)
this->nameOptions.localizedName = name; this->nameOptions.localizedName = name;
} }
void TwitchChannel::refreshTwitchChannelEmotes(bool manualRefresh)
{
if (getApp()->isTest())
{
return;
}
// Twitch's 'Get User Emotes' doesn't assigns a different set-ID to follower
// emotes compared to subscriber emotes.
QString setID = TWITCH_SUB_EMOTE_SET_PREFIX % this->roomId();
this->localTwitchEmoteSetID_.set(setID);
if (getApp()->getAccounts()->twitch.getCurrent()->hasEmoteSet(
EmoteSetId{setID}))
{
this->localTwitchEmotes_.set(std::make_shared<EmoteMap>());
return;
}
auto makeEmotes = [](const auto &emotes) {
EmoteMap map;
for (const auto &emote : emotes)
{
if (emote.type != u"follower")
{
continue;
}
map.emplace(
EmoteName{emote.name},
getApp()->getEmotes()->getTwitchEmotes()->getOrCreateEmote(
EmoteId{emote.id}, EmoteName{emote.name}));
}
return map;
};
getHelix()->getFollowedChannel(
getApp()->getAccounts()->twitch.getCurrent()->getUserId(),
this->roomId(),
[weak{this->weak_from_this()}, makeEmotes](const auto &chan) {
auto self = std::dynamic_pointer_cast<TwitchChannel>(weak.lock());
if (!self || !chan)
{
return;
}
getHelix()->getChannelEmotes(
self->roomId(),
[weak, makeEmotes](const auto &emotes) {
auto self =
std::dynamic_pointer_cast<TwitchChannel>(weak.lock());
if (!self)
{
return;
}
self->localTwitchEmotes_.set(
std::make_shared<EmoteMap>(makeEmotes(emotes)));
},
[weak] {
auto self = weak.lock();
if (!self)
{
return;
}
self->addSystemMessage("Failed to load follower emotes.");
});
},
[](const auto &error) {
qCWarning(chatterinoTwitch)
<< "Failed to get following status:" << error;
});
if (manualRefresh)
{
getApp()->getAccounts()->twitch.getCurrent()->reloadEmotes(this);
}
}
void TwitchChannel::refreshBTTVChannelEmotes(bool manualRefresh) void TwitchChannel::refreshBTTVChannelEmotes(bool manualRefresh)
{ {
if (!Settings::instance().enableBTTVChannelEmotes) if (!Settings::instance().enableBTTVChannelEmotes)
@ -536,6 +651,7 @@ void TwitchChannel::roomIdChanged()
this->refreshPubSub(); this->refreshPubSub();
this->refreshBadges(); this->refreshBadges();
this->refreshCheerEmotes(); this->refreshCheerEmotes();
this->refreshTwitchChannelEmotes(false);
this->refreshFFZChannelEmotes(false); this->refreshFFZChannelEmotes(false);
this->refreshBTTVChannelEmotes(false); this->refreshBTTVChannelEmotes(false);
this->refreshSevenTVChannelEmotes(false); this->refreshSevenTVChannelEmotes(false);
@ -789,6 +905,18 @@ SharedAccessGuard<const TwitchChannel::StreamStatus>
return this->streamStatus_.accessConst(); return this->streamStatus_.accessConst();
} }
std::optional<EmotePtr> TwitchChannel::twitchEmote(const EmoteName &name) const
{
auto emotes = this->localTwitchEmotes();
auto it = emotes->find(name);
if (it == emotes->end())
{
return getApp()->getAccounts()->twitch.getCurrent()->twitchEmote(name);
}
return it->second;
}
std::optional<EmotePtr> TwitchChannel::bttvEmote(const EmoteName &name) const std::optional<EmotePtr> TwitchChannel::bttvEmote(const EmoteName &name) const
{ {
auto emotes = this->bttvEmotes_.get(); auto emotes = this->bttvEmotes_.get();
@ -825,6 +953,11 @@ std::optional<EmotePtr> TwitchChannel::seventvEmote(const EmoteName &name) const
return it->second; return it->second;
} }
std::shared_ptr<const EmoteMap> TwitchChannel::localTwitchEmotes() const
{
return this->localTwitchEmotes_.get();
}
std::shared_ptr<const EmoteMap> TwitchChannel::bttvEmotes() const std::shared_ptr<const EmoteMap> TwitchChannel::bttvEmotes() const
{ {
return this->bttvEmotes_.get(); return this->bttvEmotes_.get();

View file

@ -159,13 +159,17 @@ public:
void markConnected(); void markConnected();
// Emotes // Emotes
std::optional<EmotePtr> twitchEmote(const EmoteName &name) const;
std::optional<EmotePtr> bttvEmote(const EmoteName &name) const; std::optional<EmotePtr> bttvEmote(const EmoteName &name) const;
std::optional<EmotePtr> ffzEmote(const EmoteName &name) const; std::optional<EmotePtr> ffzEmote(const EmoteName &name) const;
std::optional<EmotePtr> seventvEmote(const EmoteName &name) const; std::optional<EmotePtr> seventvEmote(const EmoteName &name) const;
std::shared_ptr<const EmoteMap> localTwitchEmotes() const;
std::shared_ptr<const EmoteMap> bttvEmotes() const; std::shared_ptr<const EmoteMap> bttvEmotes() const;
std::shared_ptr<const EmoteMap> ffzEmotes() const; std::shared_ptr<const EmoteMap> ffzEmotes() const;
std::shared_ptr<const EmoteMap> seventvEmotes() const; std::shared_ptr<const EmoteMap> seventvEmotes() const;
void refreshTwitchChannelEmotes(bool manualRefresh);
void refreshBTTVChannelEmotes(bool manualRefresh); void refreshBTTVChannelEmotes(bool manualRefresh);
void refreshFFZChannelEmotes(bool manualRefresh); void refreshFFZChannelEmotes(bool manualRefresh);
void refreshSevenTVChannelEmotes(bool manualRefresh); void refreshSevenTVChannelEmotes(bool manualRefresh);
@ -391,6 +395,8 @@ private:
protected: protected:
void messageRemovedFromStart(const MessagePtr &msg) override; void messageRemovedFromStart(const MessagePtr &msg) override;
Atomic<std::shared_ptr<const EmoteMap>> localTwitchEmotes_;
Atomic<QString> localTwitchEmoteSetID_;
Atomic<std::shared_ptr<const EmoteMap>> bttvEmotes_; Atomic<std::shared_ptr<const EmoteMap>> bttvEmotes_;
Atomic<std::shared_ptr<const EmoteMap>> ffzEmotes_; Atomic<std::shared_ptr<const EmoteMap>> ffzEmotes_;
Atomic<std::shared_ptr<const EmoteMap>> seventvEmotes_; Atomic<std::shared_ptr<const EmoteMap>> seventvEmotes_;

View file

@ -1,10 +1,15 @@
#include "providers/twitch/TwitchEmotes.hpp" #include "providers/twitch/TwitchEmotes.hpp"
#include "common/Literals.hpp"
#include "common/QLogging.hpp" #include "common/QLogging.hpp"
#include "common/UniqueAccess.hpp"
#include "messages/Emote.hpp" #include "messages/Emote.hpp"
#include "messages/Image.hpp" #include "messages/Image.hpp"
#include "providers/twitch/api/Helix.hpp"
#include "util/QStringHash.hpp" #include "util/QStringHash.hpp"
#include <QStringBuilder>
namespace { namespace {
using namespace chatterino; using namespace chatterino;
@ -399,6 +404,22 @@ qreal getEmote3xScaleFactor(const EmoteId &id)
namespace chatterino { namespace chatterino {
using namespace literals;
QString TwitchEmoteSet::title() const
{
if (!this->owner || this->owner->name.isEmpty())
{
return "Twitch";
}
if (this->isBits)
{
return this->owner->name + " (Bits)";
}
return this->owner->name;
}
QString TwitchEmotes::cleanUpEmoteCode(const QString &dirtyEmoteCode) QString TwitchEmotes::cleanUpEmoteCode(const QString &dirtyEmoteCode)
{ {
auto cleanCode = dirtyEmoteCode; auto cleanCode = dirtyEmoteCode;
@ -453,4 +474,44 @@ EmotePtr TwitchEmotes::getOrCreateEmote(const EmoteId &id,
return shared; return shared;
} }
TwitchEmoteSetMeta getTwitchEmoteSetMeta(const HelixChannelEmote &emote)
{
// follower emotes are treated as sub emotes
// A sub emote must have an owner or an emote-set id (otherwise it's a
// global like "BopBop")
bool isSub =
(emote.type == u"subscriptions" || emote.type == u"follower") &&
!(emote.ownerID.isEmpty() && emote.setID.isEmpty());
bool isBits = emote.type == u"bitstier";
bool isSubLike = isSub || isBits;
// A lot of emotes don't have their emote-set-id set, so we create a
// virtual emote set that groups emotes by the owner.
// Additionally, a lot of emote sets are small, so they're grouped together as globals.
auto actualSetID = [&]() -> QString {
if (!isSub && !isBits)
{
return u"x-c2-globals"_s;
}
if (!emote.setID.isEmpty())
{
return emote.setID;
}
if (isSub)
{
return TWITCH_SUB_EMOTE_SET_PREFIX % emote.ownerID;
}
// isBits
return TWITCH_BIT_EMOTE_SET_PREFIX % emote.ownerID;
}();
return {
.setID = actualSetID,
.isBits = isBits,
.isSubLike = isSubLike,
};
}
} // namespace chatterino } // namespace chatterino

View file

@ -2,7 +2,9 @@
#include "common/Aliases.hpp" #include "common/Aliases.hpp"
#include "common/UniqueAccess.hpp" #include "common/UniqueAccess.hpp"
#include "providers/twitch/TwitchUser.hpp"
#include <boost/unordered/unordered_flat_map_fwd.hpp>
#include <QColor> #include <QColor>
#include <QRegularExpression> #include <QRegularExpression>
#include <QString> #include <QString>
@ -36,6 +38,45 @@ struct CheerEmoteSet {
std::vector<CheerEmote> cheerEmotes; std::vector<CheerEmote> cheerEmotes;
}; };
struct TwitchEmoteSet {
/// @brief The owner of this set
///
/// This owner might not be resolved yet
std::shared_ptr<TwitchUser> owner;
std::vector<EmotePtr> emotes;
/// If this is a bitstier emote set
bool isBits = false;
/// @brief If this emote set is a subscriber or similar emote set
///
/// This includes sub and bit emotes
bool isSubLike = false;
/// @brief The title of this set
///
/// We generate this based on the emote set's flags & owner
QString title() const;
};
using TwitchEmoteSetMap = boost::unordered_flat_map<EmoteSetId, TwitchEmoteSet>;
struct HelixChannelEmote;
constexpr QStringView TWITCH_SUB_EMOTE_SET_PREFIX = u"x-c2-s-";
constexpr QStringView TWITCH_BIT_EMOTE_SET_PREFIX = u"x-c2-b-";
struct TwitchEmoteSetMeta {
QString setID;
/// See TwitchEmoteSet::isBits
bool isBits = false;
/// See TwitchEmoteSet::isSubLike
bool isSubLike = false;
};
TwitchEmoteSetMeta getTwitchEmoteSetMeta(const HelixChannelEmote &emote);
class ITwitchEmotes class ITwitchEmotes
{ {
public: public:

View file

@ -853,10 +853,6 @@ void TwitchIrcServer::readConnectionMessageReceived(
this->markChannelsConnected(); this->markChannelsConnected();
this->connect(); this->connect();
} }
else if (command == "GLOBALUSERSTATE")
{
handler.handleGlobalUserStateMessage(message);
}
} }
void TwitchIrcServer::writeConnectionMessageReceived( void TwitchIrcServer::writeConnectionMessageReceived(

View file

@ -1,7 +1,7 @@
#include "providers/twitch/TwitchUser.hpp" #include "providers/twitch/TwitchUser.hpp"
#include "debug/AssertInGuiThread.hpp"
#include "providers/twitch/api/Helix.hpp" #include "providers/twitch/api/Helix.hpp"
#include "util/RapidjsonHelpers.hpp"
namespace chatterino { namespace chatterino {
@ -12,4 +12,12 @@ void TwitchUser::fromHelixBlock(const HelixBlock &ignore)
this->displayName = ignore.displayName; this->displayName = ignore.displayName;
} }
void TwitchUser::update(const HelixUser &user) const
{
assertInGuiThread();
assert(this->id == user.id);
this->name = user.login;
this->displayName = user.displayName;
}
} // namespace chatterino } // namespace chatterino

View file

@ -12,6 +12,7 @@
namespace chatterino { namespace chatterino {
struct HelixBlock; struct HelixBlock;
struct HelixUser;
struct TwitchUser { struct TwitchUser {
QString id; QString id;
@ -26,6 +27,8 @@ struct TwitchUser {
this->displayName = other.displayName; this->displayName = other.displayName;
} }
void update(const HelixUser &user) const;
void fromHelixBlock(const HelixBlock &ignore); void fromHelixBlock(const HelixBlock &ignore);
bool operator<(const TwitchUser &rhs) const bool operator<(const TwitchUser &rhs) const

View file

@ -0,0 +1,147 @@
#include "providers/twitch/TwitchUsers.hpp"
#include "common/QLogging.hpp"
#include "providers/twitch/api/Helix.hpp"
#include "providers/twitch/TwitchUser.hpp"
#include <boost/unordered/unordered_flat_map.hpp>
#include <QStringList>
#include <QTimer>
namespace {
auto withSelf(auto *ptr, auto cb)
{
return [weak{ptr->weak_from_this()}, cb = std::move(cb)](auto &&...args) {
auto self = weak.lock();
if (!self)
{
return;
}
cb(std::move(self), std::forward<decltype(args)>(args)...);
};
}
} // namespace
namespace chatterino {
class TwitchUsersPrivate
: public std::enable_shared_from_this<TwitchUsersPrivate>
{
public:
TwitchUsersPrivate();
private:
boost::unordered_flat_map<UserId, std::shared_ptr<TwitchUser>> cache;
QStringList unresolved;
QTimer nextBatchTimer;
bool isResolving = false;
std::shared_ptr<TwitchUser> makeUnresolved(const UserId &id);
void makeNextRequest();
void updateUsers(const std::vector<HelixUser> &users);
friend TwitchUsers;
};
TwitchUsers::TwitchUsers()
: private_(new TwitchUsersPrivate)
{
}
TwitchUsers::~TwitchUsers() = default;
std::shared_ptr<TwitchUser> TwitchUsers::resolveID(const UserId &id)
{
auto cached = this->private_->cache.find(id);
if (cached != this->private_->cache.end())
{
return cached->second;
}
return this->private_->makeUnresolved(id);
}
TwitchUsersPrivate::TwitchUsersPrivate()
{
this->nextBatchTimer.setSingleShot(true);
// Wait for multiple request batches to come in before making a request
this->nextBatchTimer.setInterval(250);
QObject::connect(&this->nextBatchTimer, &QTimer::timeout, [this] {
this->makeNextRequest();
});
}
std::shared_ptr<TwitchUser> TwitchUsersPrivate::makeUnresolved(const UserId &id)
{
// assumption: Cache entry is empty so neither a shared pointer was created
// nor an entry in the unresolved list was added.
auto ptr = this->cache
.emplace(id, std::make_shared<TwitchUser>(TwitchUser{
.id = id.string,
.name = {},
.displayName = {},
}))
.first->second;
if (id.string.isEmpty())
{
return ptr;
}
this->unresolved.append(id.string);
if (!this->isResolving && !this->nextBatchTimer.isActive())
{
this->nextBatchTimer.start();
}
return ptr;
}
void TwitchUsersPrivate::makeNextRequest()
{
if (this->unresolved.empty())
{
return;
}
if (this->isResolving)
{
qCWarning(chatterinoTwitch) << "Tried to start request while resolving";
return;
}
this->isResolving = true;
auto ids = this->unresolved.mid(
0, std::min<qsizetype>(this->unresolved.size(), 100));
this->unresolved = this->unresolved.mid(ids.length());
getHelix()->fetchUsers(ids, {},
withSelf(this,
[](auto self, const auto &users) {
self->updateUsers(users);
self->isResolving = false;
self->makeNextRequest();
}),
withSelf(this, [](auto self) {
qCWarning(chatterinoTwitch)
<< "Failed to load users";
self->isResolving = false;
self->makeNextRequest();
}));
}
void TwitchUsersPrivate::updateUsers(const std::vector<HelixUser> &users)
{
for (const auto &user : users)
{
auto cached = this->cache.find(UserId{user.id});
if (cached == this->cache.end())
{
qCWarning(chatterinoTwitch) << "Couldn't find user" << user.login
<< "with id" << user.id << "in cache";
continue;
}
cached->second->update(user);
}
}
} // namespace chatterino

View file

@ -0,0 +1,55 @@
#pragma once
#include "common/Aliases.hpp"
#include <memory>
namespace chatterino {
struct TwitchUser;
class TwitchChannel;
class ITwitchUsers
{
public:
ITwitchUsers() = default;
virtual ~ITwitchUsers() = default;
ITwitchUsers(const ITwitchUsers &) = delete;
ITwitchUsers(ITwitchUsers &&) = delete;
ITwitchUsers &operator=(const ITwitchUsers &) = delete;
ITwitchUsers &operator=(ITwitchUsers &&) = delete;
/// @brief Resolve a TwitchUser by their ID
///
/// Users are cached. If the user wasn't resolved yet, a request will be
/// scheduled. The returned shared pointer must only be used on the GUI
/// thread as it will be updated from there.
///
/// @returns A shared reference to the TwitchUser. The `name` and
/// `displayName` might be empty if the user wasn't resolved yet or
/// they don't exist.
virtual std::shared_ptr<TwitchUser> resolveID(const UserId &id) = 0;
};
class TwitchUsersPrivate;
class TwitchUsers : public ITwitchUsers
{
public:
TwitchUsers();
~TwitchUsers() override;
TwitchUsers(const TwitchUsers &) = delete;
TwitchUsers(TwitchUsers &&) = delete;
TwitchUsers &operator=(const TwitchUsers &) = delete;
TwitchUsers &operator=(TwitchUsers &&) = delete;
/// @see ITwitchUsers::resolveID()
std::shared_ptr<TwitchUser> resolveID(const UserId &id) override;
private:
// Using a shared_ptr to pass to network callbacks
std::shared_ptr<TwitchUsersPrivate> private_;
friend TwitchUsersPrivate;
};
} // namespace chatterino

View file

@ -540,7 +540,8 @@ void Helix::loadBlocks(QString userId,
size_t receivedItems = 0; size_t receivedItems = 0;
this->paginate( this->paginate(
u"users/blocks"_s, query, u"users/blocks"_s, query,
[pageCallback, receivedItems](const QJsonObject &json) mutable { [pageCallback, receivedItems](const QJsonObject &json,
const auto & /*state*/) mutable {
const auto data = json["data"_L1].toArray(); const auto data = json["data"_L1].toArray();
if (data.isEmpty()) if (data.isEmpty())
@ -3063,6 +3064,123 @@ void Helix::sendChatMessage(
.execute(); .execute();
} }
void Helix::getUserEmotes(
QString userID, QString broadcasterID,
ResultCallback<std::vector<HelixChannelEmote>, HelixPaginationState>
pageCallback,
FailureCallback<QString> failureCallback, CancellationToken &&token)
{
QUrlQuery query{{u"user_id"_s, userID}};
if (!broadcasterID.isEmpty())
{
query.addQueryItem(u"broadcaster_id"_s, broadcasterID);
}
this->paginate(
u"chat/emotes/user"_s, query,
[pageCallback](const QJsonObject &json, const auto &state) mutable {
const auto data = json["data"_L1].toArray();
if (data.isEmpty())
{
return false;
}
std::vector<HelixChannelEmote> emotes;
emotes.reserve(data.count());
for (const auto &emote : data)
{
emotes.emplace_back(emote.toObject());
}
pageCallback(emotes, state);
return true;
},
[failureCallback](const NetworkResult &result) {
if (!result.status())
{
failureCallback(result.formatError());
return;
}
const auto obj = result.parseJson();
auto message = obj["message"].toString();
switch (*result.status())
{
case 401: {
if (message.startsWith("Missing scope",
Qt::CaseInsensitive))
{
failureCallback("Missing required scope. Re-login with "
"your account and try again.");
break;
}
[[fallthrough]];
}
default: {
qCWarning(chatterinoTwitch)
<< "Helix get user emotes, unhandled error data:"
<< result.formatError() << result.getData() << obj;
failureCallback(message);
}
}
},
std::move(token));
}
void Helix::getFollowedChannel(
QString userID, QString broadcasterID,
ResultCallback<std::optional<HelixFollowedChannel>> successCallback,
FailureCallback<QString> failureCallback)
{
this->makeGet("channels/followed",
{
{u"user_id"_s, userID},
{u"broadcaster_id"_s, broadcasterID},
})
.onSuccess([successCallback](auto result) {
if (result.status() != 200)
{
qCWarning(chatterinoTwitch)
<< "Success result for getting badges was "
<< result.formatError() << "but we expected it to be 200";
}
const auto response = result.parseJson();
const auto channel = response["data"_L1].toArray().at(0);
if (channel.isObject())
{
successCallback(HelixFollowedChannel(channel.toObject()));
}
else
{
successCallback(std::nullopt);
}
})
.onError([failureCallback](const auto &result) -> void {
if (!result.status())
{
failureCallback(result.formatError());
return;
}
auto obj = result.parseJson();
auto message = obj.value("message").toString();
if (!message.isEmpty())
{
failureCallback(message);
}
else
{
failureCallback(result.formatError());
}
})
.execute();
}
NetworkRequest Helix::makeRequest(const QString &url, const QUrlQuery &urlQuery, NetworkRequest Helix::makeRequest(const QString &url, const QUrlQuery &urlQuery,
NetworkRequestType type) NetworkRequestType type)
{ {
@ -3120,8 +3238,10 @@ NetworkRequest Helix::makePatch(const QString &url, const QUrlQuery &urlQuery)
return this->makeRequest(url, urlQuery, NetworkRequestType::Patch); return this->makeRequest(url, urlQuery, NetworkRequestType::Patch);
} }
void Helix::paginate(const QString &url, const QUrlQuery &baseQuery, void Helix::paginate(
std::function<bool(const QJsonObject &)> onPage, const QString &url, const QUrlQuery &baseQuery,
std::function<bool(const QJsonObject &, const HelixPaginationState &state)>
onPage,
std::function<void(NetworkResult)> onError, std::function<void(NetworkResult)> onError,
CancellationToken &&cancellationToken) CancellationToken &&cancellationToken)
{ {
@ -3143,14 +3263,19 @@ void Helix::paginate(const QString &url, const QUrlQuery &baseQuery,
} }
const auto json = res.parseJson(); const auto json = res.parseJson();
if (!onPage(json)) const auto pagination = json["pagination"_L1].toObject();
auto cursor = pagination["cursor"_L1].toString();
HelixPaginationState state{.done = cursor.isEmpty()};
if (!onPage(json, state))
{ {
// The consumer doesn't want any more pages // The consumer doesn't want any more pages
return; return;
} }
auto cursor = json["pagination"_L1]["cursor"_L1].toString(); // After done is set, onPage must never be called again
if (cursor.isEmpty()) if (state.done)
{ {
return; return;
} }

View file

@ -266,18 +266,18 @@ struct HelixEmoteSetData {
}; };
struct HelixChannelEmote { struct HelixChannelEmote {
const QString emoteId; const QString id;
const QString name; const QString name;
const QString type; const QString type;
const QString setId; const QString setID;
const QString url; const QString ownerID;
explicit HelixChannelEmote(QJsonObject jsonObject) explicit HelixChannelEmote(const QJsonObject &jsonObject)
: emoteId(jsonObject.value("id").toString()) : id(jsonObject["id"].toString())
, name(jsonObject.value("name").toString()) , name(jsonObject["name"].toString())
, type(jsonObject.value("emote_type").toString()) , type(jsonObject["emote_type"].toString())
, setId(jsonObject.value("emote_set_id").toString()) , setID(jsonObject["emote_set_id"].toString())
, url(TWITCH_EMOTE_TEMPLATE.arg(this->emoteId, u"3.0")) , ownerID(jsonObject["owner_id"].toString())
{ {
} }
}; };
@ -438,6 +438,22 @@ struct HelixSentMessage {
} }
}; };
struct HelixFollowedChannel {
QString broadcasterID;
QString broadcasterLogin;
QString broadcasterName;
QDateTime followedAt;
explicit HelixFollowedChannel(const QJsonObject &jsonObject)
: broadcasterID(jsonObject["broadcaster_id"].toString())
, broadcasterLogin(jsonObject["broadcaster_login"].toString())
, broadcasterName(jsonObject["broadcaster_name"].toString())
, followedAt(QDateTime::fromString(jsonObject["followed_at"].toString(),
Qt::ISODate))
{
}
};
struct HelixSendMessageArgs { struct HelixSendMessageArgs {
QString broadcasterID; QString broadcasterID;
QString senderID; QString senderID;
@ -786,6 +802,10 @@ struct HelixError {
using HelixGetChannelBadgesError = HelixGetGlobalBadgesError; using HelixGetChannelBadgesError = HelixGetGlobalBadgesError;
struct HelixPaginationState {
bool done;
};
class IHelix class IHelix
{ {
public: public:
@ -1112,6 +1132,21 @@ public:
ResultCallback<HelixSentMessage> successCallback, ResultCallback<HelixSentMessage> successCallback,
FailureCallback<HelixSendMessageError, QString> failureCallback) = 0; FailureCallback<HelixSendMessageError, QString> failureCallback) = 0;
/// https://dev.twitch.tv/docs/api/reference/#get-user-emotes
virtual void getUserEmotes(
QString userID, QString broadcasterID,
ResultCallback<std::vector<HelixChannelEmote>, HelixPaginationState>
pageCallback,
FailureCallback<QString> failureCallback,
CancellationToken &&token) = 0;
/// https://dev.twitch.tv/docs/api/reference/#get-followed-channels
/// (non paginated)
virtual void getFollowedChannel(
QString userID, QString broadcasterID,
ResultCallback<std::optional<HelixFollowedChannel>> successCallback,
FailureCallback<QString> failureCallback) = 0;
virtual void update(QString clientId, QString oauthToken) = 0; virtual void update(QString clientId, QString oauthToken) = 0;
protected: protected:
@ -1440,6 +1475,21 @@ public:
ResultCallback<HelixSentMessage> successCallback, ResultCallback<HelixSentMessage> successCallback,
FailureCallback<HelixSendMessageError, QString> failureCallback) final; FailureCallback<HelixSendMessageError, QString> failureCallback) final;
/// https://dev.twitch.tv/docs/api/reference/#get-user-emotes
void getUserEmotes(
QString userID, QString broadcasterID,
ResultCallback<std::vector<HelixChannelEmote>, HelixPaginationState>
pageCallback,
FailureCallback<QString> failureCallback,
CancellationToken &&token) final;
/// https://dev.twitch.tv/docs/api/reference/#get-followed-channels
/// (non paginated)
void getFollowedChannel(
QString userID, QString broadcasterID,
ResultCallback<std::optional<HelixFollowedChannel>> successCallback,
FailureCallback<QString> failureCallback) final;
void update(QString clientId, QString oauthToken) final; void update(QString clientId, QString oauthToken) final;
static void initialize(); static void initialize();
@ -1494,7 +1544,9 @@ private:
/// Paginate the `url` endpoint and use `baseQuery` as the starting point for pagination. /// Paginate the `url` endpoint and use `baseQuery` as the starting point for pagination.
/// @param onPage returns true while a new page is expected. Once false is returned, pagination will stop. /// @param onPage returns true while a new page is expected. Once false is returned, pagination will stop.
void paginate(const QString &url, const QUrlQuery &baseQuery, void paginate(const QString &url, const QUrlQuery &baseQuery,
std::function<bool(const QJsonObject &)> onPage, std::function<bool(const QJsonObject &,
const HelixPaginationState &state)>
onPage,
std::function<void(NetworkResult)> onError, std::function<void(NetworkResult)> onError,
CancellationToken &&token); CancellationToken &&token);

View file

@ -2,6 +2,7 @@
#include <atomic> #include <atomic>
#include <memory> #include <memory>
#include <utility>
namespace chatterino { namespace chatterino {
@ -10,24 +11,30 @@ namespace chatterino {
class CancellationToken class CancellationToken
{ {
public: public:
CancellationToken() = default; CancellationToken() noexcept = default;
explicit CancellationToken(bool isCancelled) explicit CancellationToken(bool isCancelled)
: isCancelled_(new std::atomic<bool>(isCancelled)) : isCancelled_(new std::atomic<bool>(isCancelled))
{ {
} }
CancellationToken(const CancellationToken &) = default; CancellationToken(const CancellationToken &) = default;
CancellationToken(CancellationToken &&other) CancellationToken(CancellationToken &&other) noexcept
: isCancelled_(std::move(other.isCancelled_)){}; : isCancelled_(std::move(other.isCancelled_)){};
CancellationToken &operator=(CancellationToken &&other) /// @brief This destructor doesn't cancel the token
///
/// @see ScopedCancellationToken
/// @see #cancel()
~CancellationToken() noexcept = default;
CancellationToken &operator=(CancellationToken &&other) noexcept
{ {
this->isCancelled_ = std::move(other.isCancelled_); this->isCancelled_ = std::move(other.isCancelled_);
return *this; return *this;
} }
CancellationToken &operator=(const CancellationToken &) = default; CancellationToken &operator=(const CancellationToken &) = default;
void cancel() void cancel() noexcept
{ {
if (this->isCancelled_ != nullptr) if (this->isCancelled_ != nullptr)
{ {
@ -35,7 +42,7 @@ public:
} }
} }
bool isCancelled() const bool isCancelled() const noexcept
{ {
return this->isCancelled_ == nullptr || return this->isCancelled_ == nullptr ||
this->isCancelled_->load(std::memory_order_acquire); this->isCancelled_->load(std::memory_order_acquire);
@ -50,30 +57,35 @@ class ScopedCancellationToken
{ {
public: public:
ScopedCancellationToken() = default; ScopedCancellationToken() = default;
ScopedCancellationToken(CancellationToken &&backingToken) explicit ScopedCancellationToken(CancellationToken backingToken)
: backingToken_(std::move(backingToken))
{
}
ScopedCancellationToken(CancellationToken backingToken)
: backingToken_(std::move(backingToken)) : backingToken_(std::move(backingToken))
{ {
} }
ScopedCancellationToken(const ScopedCancellationToken &) = delete;
ScopedCancellationToken(ScopedCancellationToken &&other) noexcept
: backingToken_(std::move(other.backingToken_)){};
~ScopedCancellationToken() ~ScopedCancellationToken()
{ {
this->backingToken_.cancel(); this->backingToken_.cancel();
} }
ScopedCancellationToken(const ScopedCancellationToken &) = delete; ScopedCancellationToken &operator=(CancellationToken token) noexcept
ScopedCancellationToken(ScopedCancellationToken &&other)
: backingToken_(std::move(other.backingToken_)){};
ScopedCancellationToken &operator=(ScopedCancellationToken &&other)
{ {
this->backingToken_.cancel();
this->backingToken_ = std::move(token);
return *this;
}
ScopedCancellationToken &operator=(const ScopedCancellationToken &) =
delete;
ScopedCancellationToken &operator=(ScopedCancellationToken &&other) noexcept
{
this->backingToken_.cancel();
this->backingToken_ = std::move(other.backingToken_); this->backingToken_ = std::move(other.backingToken_);
return *this; return *this;
} }
ScopedCancellationToken &operator=(const ScopedCancellationToken &) =
delete;
private: private:
CancellationToken backingToken_; CancellationToken backingToken_;

22
src/util/Expected.hpp Normal file
View file

@ -0,0 +1,22 @@
#pragma once
#include <nonstd/expected.hpp>
class QString;
namespace chatterino {
template <typename T, typename E>
using Expected = nonstd::expected_lite::expected<T, E>;
template <typename T>
using ExpectedStr = Expected<T, QString>;
// convenience function from nonstd/expected.hpp
template <typename E>
constexpr nonstd::unexpected<std::decay_t<E>> makeUnexpected(E &&value)
{
return nonstd::unexpected<std::decay_t<E>>(std::forward<E>(value));
}
} // namespace chatterino

View file

@ -23,6 +23,7 @@
#ifdef USEWINSDK #ifdef USEWINSDK
# include <dwmapi.h> # include <dwmapi.h>
# include <shellapi.h>
# include <VersionHelpers.h> # include <VersionHelpers.h>
# include <Windows.h> # include <Windows.h>
# include <windowsx.h> # include <windowsx.h>
@ -32,7 +33,6 @@
# include <QHBoxLayout> # include <QHBoxLayout>
# include <QMargins> # include <QMargins>
# include <QOperatingSystemVersion> # include <QOperatingSystemVersion>
# include <QWindow>
#endif #endif
#include "widgets/helper/TitlebarButton.hpp" #include "widgets/helper/TitlebarButton.hpp"

View file

@ -26,6 +26,7 @@
#include <QAbstractButton> #include <QAbstractButton>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QRegularExpression> #include <QRegularExpression>
#include <QStringBuilder>
#include <QTabWidget> #include <QTabWidget>
#include <utility> #include <utility>
@ -42,13 +43,14 @@ auto makeTitleMessage(const QString &title)
return builder.release(); return builder.release();
} }
auto makeEmoteMessage(const EmoteMap &map, const MessageElementFlag &emoteFlag) auto makeEmoteMessage(std::vector<EmotePtr> emotes,
const MessageElementFlag &emoteFlag)
{ {
MessageBuilder builder; MessageBuilder builder;
builder->flags.set(MessageFlag::Centered); builder->flags.set(MessageFlag::Centered);
builder->flags.set(MessageFlag::DisableCompactEmotes); builder->flags.set(MessageFlag::DisableCompactEmotes);
if (map.empty()) if (emotes.empty())
{ {
builder.emplace<TextElement>("no emotes available", builder.emplace<TextElement>("no emotes available",
MessageElementFlag::Text, MessageElementFlag::Text,
@ -56,24 +58,41 @@ auto makeEmoteMessage(const EmoteMap &map, const MessageElementFlag &emoteFlag)
return builder.release(); return builder.release();
} }
std::vector<std::pair<EmoteName, EmotePtr>> vec(map.begin(), map.end()); std::sort(emotes.begin(), emotes.end(), [](const auto &l, const auto &r) {
std::sort(vec.begin(), vec.end(), return compareEmoteStrings(l->name.string, r->name.string);
[](const std::pair<EmoteName, EmotePtr> &l,
const std::pair<EmoteName, EmotePtr> &r) {
return compareEmoteStrings(l.first.string, r.first.string);
}); });
for (const auto &emote : vec) for (const auto &emote : emotes)
{ {
builder builder
.emplace<EmoteElement>( .emplace<EmoteElement>(
emote.second, emote,
MessageElementFlags{MessageElementFlag::AlwaysShow, emoteFlag}) MessageElementFlags{MessageElementFlag::AlwaysShow, emoteFlag})
->setLink(Link(Link::InsertText, emote.first.string)); ->setLink(Link(Link::InsertText, emote->name.string));
} }
return builder.release(); return builder.release();
} }
auto makeEmoteMessage(const EmoteMap &map, const MessageElementFlag &emoteFlag)
{
if (map.empty())
{
MessageBuilder builder;
builder.emplace<TextElement>("no emotes available",
MessageElementFlag::Text,
MessageColor::System);
return builder.release();
}
std::vector<EmotePtr> vec;
vec.reserve(map.size());
for (const auto &[_name, ptr] : map)
{
vec.emplace_back(ptr);
}
return makeEmoteMessage(std::move(vec), emoteFlag);
}
auto makeEmojiMessage(const std::vector<EmojiPtr> &emojiMap) auto makeEmojiMessage(const std::vector<EmojiPtr> &emojiMap)
{ {
MessageBuilder builder; MessageBuilder builder;
@ -94,77 +113,46 @@ auto makeEmojiMessage(const std::vector<EmojiPtr> &emojiMap)
return builder.release(); return builder.release();
} }
void addTwitchEmoteSets( void addEmotes(Channel &channel, auto emotes, const QString &title,
std::vector<std::shared_ptr<TwitchAccount::EmoteSet>> sets, const MessageElementFlag &emoteFlag)
Channel &globalChannel, Channel &subChannel, QString currentChannelName)
{ {
QMap<QString, QPair<bool, std::vector<MessagePtr>>> mapOfSets; channel.addMessage(makeTitleMessage(title), MessageContext::Original);
channel.addMessage(makeEmoteMessage(emotes, emoteFlag),
MessageContext::Original);
}
for (const auto &set : sets) void addTwitchEmoteSets(const std::shared_ptr<const EmoteMap> &local,
const std::shared_ptr<const TwitchEmoteSetMap> &sets,
Channel &globalChannel, Channel &subChannel,
const QString &currentChannelID,
const QString &channelName)
{ {
// Some emotes (e.g. follower ones) are only available in their origin channel if (!local->empty())
if (set->local && currentChannelName != set->channelName) {
addEmotes(subChannel, *local, channelName % u" (follower)",
MessageElementFlag::TwitchEmote);
}
// Put current channel emotes at the top
for (const auto &[_id, set] : *sets)
{
if (set.owner->id == currentChannelID)
{
addEmotes(subChannel, set.emotes, set.title(),
MessageElementFlag::TwitchEmote);
}
}
for (const auto &[id, set] : *sets)
{
if (set.owner->id == currentChannelID)
{ {
continue; continue;
} }
// TITLE addEmotes(set.isSubLike ? subChannel : globalChannel, set.emotes,
auto channelName = set->channelName; set.title(), MessageElementFlag::TwitchEmote);
auto text = set->text.isEmpty() ? "Twitch" : set->text;
// EMOTES
MessageBuilder builder;
builder->flags.set(MessageFlag::Centered);
builder->flags.set(MessageFlag::DisableCompactEmotes);
// If value of map is empty, create init pair and add title.
if (mapOfSets.find(channelName) == mapOfSets.end())
{
std::vector<MessagePtr> b;
b.push_back(makeTitleMessage(text));
mapOfSets[channelName] = qMakePair(set->key == "0", b);
} }
for (const auto &emote : set->emotes)
{
builder
.emplace<EmoteElement>(
getApp()->getEmotes()->getTwitchEmotes()->getOrCreateEmote(
emote.id, emote.name),
MessageElementFlags{MessageElementFlag::AlwaysShow,
MessageElementFlag::TwitchEmote})
->setLink(Link(Link::InsertText, emote.name.string));
}
mapOfSets[channelName].second.push_back(builder.release());
}
// Output to channel all created messages,
// That contain title or emotes.
// Put current channel emotes at the top
auto currentChannelPair = mapOfSets[currentChannelName];
for (const auto &message : currentChannelPair.second)
{
subChannel.addMessage(message, MessageContext::Original);
}
mapOfSets.remove(currentChannelName);
for (const auto &pair : mapOfSets)
{
auto &channel = pair.first ? globalChannel : subChannel;
for (const auto &message : pair.second)
{
channel.addMessage(message, MessageContext::Original);
}
}
}
void addEmotes(Channel &channel, const EmoteMap &map, const QString &title,
const MessageElementFlag &emoteFlag)
{
channel.addMessage(makeTitleMessage(title), MessageContext::Original);
channel.addMessage(makeEmoteMessage(map, emoteFlag),
MessageContext::Original);
} }
void loadEmojis(ChannelView &view, const std::vector<EmojiPtr> &emojiMap) void loadEmojis(ChannelView &view, const std::vector<EmojiPtr> &emojiMap)
@ -200,6 +188,22 @@ EmoteMap filterEmoteMap(const QString &text,
return filteredMap; return filteredMap;
} }
std::vector<EmotePtr> filterEmoteVec(const QString &text,
const std::vector<EmotePtr> &emotes)
{
std::vector<EmotePtr> filtered;
for (const auto &emote : emotes)
{
if (emote->name.string.contains(text, Qt::CaseInsensitive))
{
filtered.emplace_back(emote);
}
}
return filtered;
}
} // namespace } // namespace
namespace chatterino { namespace chatterino {
@ -404,8 +408,10 @@ void EmotePopup::loadChannel(ChannelPtr channel)
// twitch // twitch
addTwitchEmoteSets( addTwitchEmoteSets(
getApp()->getAccounts()->twitch.getCurrent()->accessEmotes()->emoteSets, twitchChannel_->localTwitchEmotes(),
*globalChannel, *subChannel, this->channel_->getName()); *getApp()->getAccounts()->twitch.getCurrent()->accessEmoteSets(),
*globalChannel, *subChannel, twitchChannel_->roomId(),
twitchChannel_->getName());
// global // global
if (Settings::instance().enableBTTVGlobalEmotes) if (Settings::instance().enableBTTVGlobalEmotes)
@ -475,24 +481,22 @@ bool EmotePopup::eventFilter(QObject *object, QEvent *event)
void EmotePopup::filterTwitchEmotes(std::shared_ptr<Channel> searchChannel, void EmotePopup::filterTwitchEmotes(std::shared_ptr<Channel> searchChannel,
const QString &searchText) const QString &searchText)
{ {
auto twitchEmoteSets = if (this->twitchChannel_)
getApp()->getAccounts()->twitch.getCurrent()->accessEmotes()->emoteSets;
std::vector<std::shared_ptr<TwitchAccount::EmoteSet>> twitchGlobalEmotes{};
for (const auto &set : twitchEmoteSets)
{ {
auto setCopy = std::make_shared<TwitchAccount::EmoteSet>(*set); auto local = filterEmoteMap(searchText,
auto setIt = this->twitchChannel_->localTwitchEmotes());
std::remove_if(setCopy->emotes.begin(), setCopy->emotes.end(), if (!local.empty())
[searchText](auto &emote) {
return !emote.name.string.contains(
searchText, Qt::CaseInsensitive);
});
setCopy->emotes.resize(std::distance(setCopy->emotes.begin(), setIt));
if (!setCopy->emotes.empty())
{ {
twitchGlobalEmotes.push_back(setCopy); addEmotes(*searchChannel, local,
this->twitchChannel_->getName() % u" (local)",
MessageElementFlag::TwitchEmote);
}
for (const auto &[_id, set] :
**getApp()->getAccounts()->twitch.getCurrent()->accessEmoteSets())
{
addEmotes(*searchChannel, filterEmoteVec(searchText, set.emotes),
set.title(), MessageElementFlag::TwitchEmote);
} }
} }
@ -503,10 +507,6 @@ void EmotePopup::filterTwitchEmotes(std::shared_ptr<Channel> searchChannel,
auto seventvGlobalEmotes = filterEmoteMap( auto seventvGlobalEmotes = filterEmoteMap(
searchText, getApp()->getSeventvEmotes()->globalEmotes()); searchText, getApp()->getSeventvEmotes()->globalEmotes());
// twitch
addTwitchEmoteSets(twitchGlobalEmotes, *searchChannel, *searchChannel,
this->channel_->getName());
// global // global
if (!bttvGlobalEmotes.empty()) if (!bttvGlobalEmotes.empty())
{ {

View file

@ -1508,10 +1508,10 @@ void Split::showSearch(bool singleChannel)
void Split::reloadChannelAndSubscriberEmotes() void Split::reloadChannelAndSubscriberEmotes()
{ {
auto channel = this->getChannel(); auto channel = this->getChannel();
getApp()->getAccounts()->twitch.getCurrent()->loadEmotes(channel);
if (auto *twitchChannel = dynamic_cast<TwitchChannel *>(channel.get())) if (auto *twitchChannel = dynamic_cast<TwitchChannel *>(channel.get()))
{ {
twitchChannel->refreshTwitchChannelEmotes(true);
twitchChannel->refreshBTTVChannelEmotes(true); twitchChannel->refreshBTTVChannelEmotes(true);
twitchChannel->refreshFFZChannelEmotes(true); twitchChannel->refreshFFZChannelEmotes(true);
twitchChannel->refreshSevenTVChannelEmotes(true); twitchChannel->refreshSevenTVChannelEmotes(true);

View file

@ -1078,7 +1078,10 @@ void SplitHeader::reloadSubscriberEmotes()
this->lastReloadedSubEmotes_ = now; this->lastReloadedSubEmotes_ = now;
auto channel = this->split_->getChannel(); auto channel = this->split_->getChannel();
getApp()->getAccounts()->twitch.getCurrent()->loadEmotes(channel); if (auto *twitchChannel = dynamic_cast<TwitchChannel *>(channel.get()))
{
twitchChannel->refreshTwitchChannelEmotes(true);
}
} }
void SplitHeader::reconnect() void SplitHeader::reconnect()

View file

@ -47,6 +47,7 @@ set(test_SOURCES
${CMAKE_CURRENT_LIST_DIR}/src/Commands.cpp ${CMAKE_CURRENT_LIST_DIR}/src/Commands.cpp
${CMAKE_CURRENT_LIST_DIR}/src/FlagsEnum.cpp ${CMAKE_CURRENT_LIST_DIR}/src/FlagsEnum.cpp
${CMAKE_CURRENT_LIST_DIR}/src/MessageLayoutContainer.cpp ${CMAKE_CURRENT_LIST_DIR}/src/MessageLayoutContainer.cpp
${CMAKE_CURRENT_LIST_DIR}/src/CancellationToken.cpp
# Add your new file above this line! # Add your new file above this line!
) )

View file

@ -0,0 +1,173 @@
#include "util/CancellationToken.hpp"
#include <gtest/gtest.h>
using namespace chatterino;
// CancellationToken
TEST(CancellationToken, ctor)
{
{
CancellationToken token;
ASSERT_TRUE(token.isCancelled());
token.cancel();
ASSERT_TRUE(token.isCancelled());
}
{
CancellationToken token(false);
ASSERT_FALSE(token.isCancelled());
token.cancel();
ASSERT_TRUE(token.isCancelled());
}
{
CancellationToken token(true);
ASSERT_TRUE(token.isCancelled());
token.cancel();
ASSERT_TRUE(token.isCancelled());
}
}
TEST(CancellationToken, moveCtor)
{
CancellationToken token(false);
ASSERT_FALSE(token.isCancelled());
CancellationToken token2(std::move(token));
// NOLINTNEXTLINE(bugprone-use-after-move)
ASSERT_TRUE(token.isCancelled());
ASSERT_FALSE(token2.isCancelled());
token.cancel();
ASSERT_FALSE(token2.isCancelled());
token2.cancel();
ASSERT_TRUE(token2.isCancelled());
}
TEST(CancellationToken, moveAssign)
{
CancellationToken token(false);
CancellationToken token2;
ASSERT_FALSE(token.isCancelled());
ASSERT_TRUE(token2.isCancelled());
token2 = std::move(token);
// NOLINTNEXTLINE(bugprone-use-after-move)
ASSERT_TRUE(token.isCancelled());
ASSERT_FALSE(token2.isCancelled());
token.cancel();
ASSERT_FALSE(token2.isCancelled());
token2.cancel();
ASSERT_TRUE(token2.isCancelled());
}
TEST(CancellationToken, copyCtor)
{
CancellationToken token(false);
ASSERT_FALSE(token.isCancelled());
CancellationToken token2(token);
ASSERT_FALSE(token.isCancelled());
ASSERT_FALSE(token2.isCancelled());
token2.cancel();
ASSERT_TRUE(token2.isCancelled());
ASSERT_TRUE(token.isCancelled());
}
TEST(CancellationToken, copyAssign)
{
CancellationToken token(false);
CancellationToken token2;
ASSERT_FALSE(token.isCancelled());
ASSERT_TRUE(token2.isCancelled());
token2 = token;
ASSERT_FALSE(token.isCancelled());
ASSERT_FALSE(token2.isCancelled());
token2.cancel();
ASSERT_TRUE(token.isCancelled());
ASSERT_TRUE(token2.isCancelled());
}
TEST(CancellationToken, dtor)
{
CancellationToken token(false);
{
// NOLINTNEXTLINE(performance-unnecessary-copy-initialization)
CancellationToken token2 = token;
ASSERT_FALSE(token.isCancelled());
ASSERT_FALSE(token2.isCancelled());
}
ASSERT_FALSE(token.isCancelled());
}
// ScopedCancellationToken
TEST(ScopedCancellationToken, moveCancelCtor)
{
CancellationToken token(false);
ASSERT_FALSE(token.isCancelled());
{
ScopedCancellationToken scoped(std::move(token));
// NOLINTNEXTLINE(bugprone-use-after-move)
ASSERT_TRUE(token.isCancelled());
}
}
TEST(ScopedCancellationToken, copyCancelCtor)
{
CancellationToken token(false);
ASSERT_FALSE(token.isCancelled());
{
ScopedCancellationToken scoped(token);
ASSERT_FALSE(token.isCancelled());
}
ASSERT_TRUE(token.isCancelled());
}
TEST(ScopedCancellationToken, moveCtor)
{
CancellationToken token(false);
ASSERT_FALSE(token.isCancelled());
{
ScopedCancellationToken scoped(token);
{
ScopedCancellationToken inner(std::move(scoped));
ASSERT_FALSE(token.isCancelled());
}
ASSERT_TRUE(token.isCancelled());
}
}
TEST(ScopedCancellationToken, moveAssign)
{
CancellationToken token(false);
CancellationToken token2(false);
{
ScopedCancellationToken scoped(token);
{
ScopedCancellationToken inner(token2);
ASSERT_FALSE(token.isCancelled());
ASSERT_FALSE(token2.isCancelled());
inner = std::move(scoped);
ASSERT_FALSE(token.isCancelled());
ASSERT_TRUE(token2.isCancelled());
}
ASSERT_TRUE(token.isCancelled());
}
}
TEST(ScopedCancellationToken, copyAssign)
{
CancellationToken token(false);
CancellationToken token2(false);
{
ScopedCancellationToken scoped(token);
ASSERT_FALSE(token.isCancelled());
scoped = token2;
ASSERT_FALSE(token2.isCancelled());
ASSERT_TRUE(token.isCancelled());
}
ASSERT_TRUE(token2.isCancelled());
}