From 3d4985c88f21cb65f6b0cc4f9dc670cd552b96fb Mon Sep 17 00:00:00 2001 From: Colton Clemmer Date: Mon, 27 Mar 2023 11:26:08 -0500 Subject: [PATCH] Migrate viewer list to Helix (#4117) Co-authored-by: Rasmus Karlsson --- CHANGELOG.md | 1 + src/widgets/splits/Split.cpp | 333 ++++++++++++++++++++++++++++++----- 2 files changed, 292 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 315e1312e..4b7b879fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unversioned +- Minor: Migrated viewer list to Helix API. (#4117) - Minor: Include normally-stripped mention in replies in logs. (#4420) - Minor: Added support for FrankerFaceZ animated emotes. (#4434) - Minor: Added a local backup of the Twitch Badges API in case the request fails. (#4463) diff --git a/src/widgets/splits/Split.cpp b/src/widgets/splits/Split.cpp index 0fd258025..a6ae50dae 100644 --- a/src/widgets/splits/Split.cpp +++ b/src/widgets/splits/Split.cpp @@ -10,6 +10,7 @@ #include "controllers/hotkeys/HotkeyController.hpp" #include "controllers/notifications/NotificationController.hpp" #include "messages/MessageThread.hpp" +#include "providers/twitch/api/Helix.hpp" #include "providers/twitch/TwitchAccount.hpp" #include "providers/twitch/TwitchChannel.hpp" #include "providers/twitch/TwitchIrcServer.hpp" @@ -52,11 +53,140 @@ #include #include #include +#include #include #include #include +namespace { + +using namespace chatterino; + +QString formatVIPListError(HelixListVIPsError error, const QString &message) +{ + using Error = HelixListVIPsError; + + QString errorMessage = QString("Failed to list VIPs - "); + + switch (error) + { + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::Ratelimited: { + errorMessage += "You are being ratelimited by Twitch. Try " + "again in a few seconds."; + } + break; + + case Error::UserMissingScope: { + // TODO(pajlada): Phrase MISSING_REQUIRED_SCOPE + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + // TODO(pajlada): Phrase MISSING_PERMISSION + errorMessage += "You don't have permission to " + "perform that action."; + } + break; + + case Error::UserNotBroadcaster: { + errorMessage += + "Due to Twitch restrictions, " + "this command can only be used by the broadcaster. " + "To see the list of VIPs you must use the Twitch website."; + } + break; + + case Error::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + return errorMessage; +} + +QString formatModsError(HelixGetModeratorsError error, QString message) +{ + using Error = HelixGetModeratorsError; + + QString errorMessage = QString("Failed to get moderators: "); + + switch (error) + { + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::UserMissingScope: { + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + errorMessage += + "Due to Twitch restrictions, " + "this command can only be used by the broadcaster. " + "To see the list of mods you must use the Twitch website."; + } + break; + + case Error::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + return errorMessage; +} + +QString formatChattersError(HelixGetChattersError error, QString message) +{ + using Error = HelixGetChattersError; + + QString errorMessage = QString("Failed to get chatters: "); + + switch (error) + { + case Error::Forwarded: { + errorMessage += message; + } + break; + + case Error::UserMissingScope: { + errorMessage += "Missing required scope. " + "Re-login with your " + "account and try again."; + } + break; + + case Error::UserNotAuthorized: { + errorMessage += + "Due to Twitch restrictions, " + "this command can only be used by moderators. " + "To see the list of chatters you must use the Twitch website."; + } + break; + + case Error::Unknown: { + errorMessage += "An unknown error has occurred."; + } + break; + } + return errorMessage; +} + +} // namespace + namespace chatterino { namespace { void showTutorialVideo(QWidget *parent, const QString &source, @@ -1000,6 +1130,26 @@ void Split::showViewerList() auto chattersList = new QListWidget(); auto resultList = new QListWidget(); + auto channel = this->getChannel(); + if (!channel) + { + qCWarning(chatterinoWidget) + << "Viewer list opened when no channel was defined"; + return; + } + + auto *twitchChannel = dynamic_cast(channel.get()); + + if (twitchChannel == nullptr) + { + qCWarning(chatterinoWidget) + << "Viewer list opened in a non-Twitch channel"; + return; + } + + auto *loadingLabel = new QLabel("Loading..."); + searchBar->setPlaceholderText("Search User..."); + auto formatListItemText = [](QString text) { auto item = new QListWidgetItem(); item->setText(text); @@ -1007,15 +1157,26 @@ void Split::showViewerList() return item; }; - static QStringList labels = { - "Broadcaster", "Moderators", "VIPs", "Staff", - "Admins", "Global Moderators", "Viewers"}; - static QStringList jsonLabels = {"broadcaster", "moderators", "vips", - "staff", "admins", "global_mods", - "viewers"}; - auto loadingLabel = new QLabel("Loading..."); + auto addLabel = [this, formatListItemText, chattersList](QString label) { + auto formattedLabel = formatListItemText(label); + formattedLabel->setForeground(this->theme->accent); + chattersList->addItem(formattedLabel); + }; - searchBar->setPlaceholderText("Search User..."); + auto addUserList = [=](QStringList users, QString label) { + if (users.isEmpty()) + { + return; + } + + addLabel(QString("%1 (%2)").arg(label, localizeNumbers(users.size()))); + + for (const auto &user : users) + { + chattersList->addItem(formatListItemText(user)); + } + chattersList->addItem(new QListWidgetItem()); + }; auto performListSearch = [=]() { auto query = searchBar->text(); @@ -1039,46 +1200,134 @@ void Split::showViewerList() resultList->show(); }; + auto loadChatters = [=](auto modList, auto vipList, bool isBroadcaster) { + getHelix()->getChatters( + twitchChannel->roomId(), + getApp()->accounts->twitch.getCurrent()->getUserId(), 50000, + [=](auto chatters) { + auto broadcaster = channel->getName().toLower(); + QStringList chatterList; + QStringList modChatters; + QStringList vipChatters; + + bool addedBroadcaster = false; + for (auto chatter : chatters.chatters) + { + chatter = chatter.toLower(); + + if (!addedBroadcaster && chatter == broadcaster) + { + addedBroadcaster = true; + addLabel("Broadcaster"); + chattersList->addItem(broadcaster); + chattersList->addItem(new QListWidgetItem()); + continue; + } + + if (modList.contains(chatter)) + { + modChatters.append(chatter); + continue; + } + + if (vipList.contains(chatter)) + { + vipChatters.append(chatter); + continue; + } + + chatterList.append(chatter); + } + + modChatters.sort(); + vipChatters.sort(); + chatterList.sort(); + + if (isBroadcaster) + { + addUserList(modChatters, QString("Moderators")); + addUserList(vipChatters, QString("VIPs")); + } + else + { + addLabel("Moderators"); + chattersList->addItem( + "Moderators cannot check who is a moderator"); + chattersList->addItem(new QListWidgetItem()); + + addLabel("VIPs"); + chattersList->addItem( + "Moderators cannot check who is a VIP"); + chattersList->addItem(new QListWidgetItem()); + } + + addUserList(chatterList, QString("Chatters")); + + loadingLabel->hide(); + performListSearch(); + }, + [chattersList, formatListItemText](auto error, auto message) { + auto errorMessage = formatChattersError(error, message); + chattersList->addItem(formatListItemText(errorMessage)); + }); + }; + QObject::connect(searchBar, &QLineEdit::textEdited, this, performListSearch); - NetworkRequest::twitchRequest("https://tmi.twitch.tv/group/user/" + - this->getChannel()->getName() + "/chatters") - .caller(this) - .onSuccess([=, this](auto result) -> Outcome { - auto obj = result.parseJson(); - QJsonObject chattersObj = obj.value("chatters").toObject(); - - viewerDock->setWindowTitle( - QString("Viewer List - %1 (%2 chatters)") - .arg(this->getChannel()->getName()) - .arg(localizeNumbers(obj.value("chatter_count").toInt()))); - - loadingLabel->hide(); - for (int i = 0; i < jsonLabels.size(); i++) - { - auto currentCategory = - chattersObj.value(jsonLabels.at(i)).toArray(); - // If current category of chatters is empty, dont show this - // category. - if (currentCategory.empty()) - continue; - - auto label = formatListItemText(QString("%1 (%2)").arg( - labels.at(i), localizeNumbers(currentCategory.size()))); - label->setForeground(this->theme->accent); - chattersList->addItem(label); - foreach (const QJsonValue &v, currentCategory) + // Only broadcaster can get vips, mods can get chatters + if (channel->isBroadcaster()) + { + // Add moderators + getHelix()->getModerators( + twitchChannel->roomId(), 1000, + [=](auto mods) { + QSet modList; + for (const auto &mod : mods) { - chattersList->addItem(formatListItemText(v.toString())); + modList.insert(mod.userName.toLower()); } - chattersList->addItem(new QListWidgetItem()); - } - performListSearch(); - return Success; - }) - .execute(); + // Add vips + getHelix()->getChannelVIPs( + twitchChannel->roomId(), + [=](auto vips) { + QSet vipList; + for (const auto &vip : vips) + { + vipList.insert(vip.userName.toLower()); + } + + // Add chatters + loadChatters(modList, vipList, true); + }, + [chattersList, formatListItemText](auto error, + auto message) { + auto errorMessage = formatVIPListError(error, message); + chattersList->addItem(formatListItemText(errorMessage)); + }); + }, + [chattersList, formatListItemText](auto error, auto message) { + auto errorMessage = formatModsError(error, message); + chattersList->addItem(formatListItemText(errorMessage)); + }); + } + else if (channel->hasModRights()) + { + QSet modList; + QSet vipList; + loadChatters(modList, vipList, false); + } + else + { + chattersList->addItem( + formatListItemText("Due to Twitch restrictions, this feature is " + "only \navailable for moderators.")); + chattersList->addItem( + formatListItemText("If you would like to see the Viewer list, you " + "must \nuse the Twitch website.")); + loadingLabel->hide(); + } QObject::connect(viewerDock, &QDockWidget::topLevelChanged, this, [=]() { viewerDock->setMinimumWidth(300);