From bf0b5d08d8f25765107f2714107d012609d3fa88 Mon Sep 17 00:00:00 2001 From: Rasmus Karlsson Date: Sat, 12 May 2018 20:34:13 +0200 Subject: [PATCH] Implement /ignore and /unignore commands Simplify authorized network requests for Twitch V5 api add onShow virtual function to settings pages if they need to be refreshed when shown Actually ignoring messages from ignored users is still not implemented Working on #247 --- chatterino.pro | 2 + .../commands/commandcontroller.cpp | 42 ++-- src/providers/twitch/twitchaccount.cpp | 144 ++++++++++++ src/providers/twitch/twitchaccount.hpp | 14 +- src/providers/twitch/twitchaccountmanager.cpp | 4 + src/providers/twitch/twitchuser.cpp | 34 +++ src/providers/twitch/twitchuser.hpp | 35 +++ src/util/networkrequest.hpp | 220 +++++++++++++++++- src/widgets/settingsdialog.cpp | 4 + src/widgets/settingspages/ignoreuserspage.cpp | 21 +- src/widgets/settingspages/ignoreuserspage.hpp | 7 + src/widgets/settingspages/settingspage.hpp | 4 + 12 files changed, 512 insertions(+), 19 deletions(-) create mode 100644 src/providers/twitch/twitchuser.cpp create mode 100644 src/providers/twitch/twitchuser.hpp diff --git a/chatterino.pro b/chatterino.pro index 6800fb8a2..b9267284e 100644 --- a/chatterino.pro +++ b/chatterino.pro @@ -179,6 +179,7 @@ SOURCES += \ src/util/rapidjson-helpers.cpp \ src/providers/twitch/pubsubhelpers.cpp \ src/providers/twitch/pubsubactions.cpp \ + src/providers/twitch/twitchuser.cpp \ src/widgets/selectchanneldialog.cpp \ src/singletons/updatemanager.cpp \ src/widgets/lastruncrashdialog.cpp \ @@ -316,6 +317,7 @@ HEADERS += \ src/util/rapidjson-helpers.hpp \ src/providers/twitch/pubsubhelpers.hpp \ src/providers/twitch/pubsubactions.hpp \ + src/providers/twitch/twitchuser.hpp \ src/widgets/selectchanneldialog.hpp \ src/singletons/updatemanager.hpp \ src/widgets/lastruncrashdialog.hpp \ diff --git a/src/controllers/commands/commandcontroller.cpp b/src/controllers/commands/commandcontroller.cpp index 3368a0a0c..ef83da322 100644 --- a/src/controllers/commands/commandcontroller.cpp +++ b/src/controllers/commands/commandcontroller.cpp @@ -119,26 +119,38 @@ QString CommandController::execCommand(const QString &text, ChannelPtr channel, return ""; } else if (commandName == "/ignore" && words.size() >= 2) { - // fourtf: ignore user - // QString messageText; + auto app = getApp(); - // if (IrcManager::getInstance().tryAddIgnoredUser(words.at(1), - // messageText)) { - // messageText = "Ignored user \"" + words.at(1) + "\"."; - // } + auto user = app->accounts->Twitch.getCurrent(); + auto target = words.at(1); + + if (user->isAnon()) { + channel->addMessage(messages::Message::createSystemMessage( + "You must be logged in to ignore someone")); + return ""; + } + + user->ignore(target, [channel](const QString &message) { + channel->addMessage(messages::Message::createSystemMessage(message)); + }); - // channel->addMessage(messages::Message::createSystemMessage(messageText)); return ""; - } else if (commandName == "/unignore") { - // fourtf: ignore user - // QString messageText; + } else if (commandName == "/unignore" && words.size() >= 2) { + auto app = getApp(); - // if (IrcManager::getInstance().tryRemoveIgnoredUser(words.at(1), - // messageText)) { - // messageText = "Ignored user \"" + words.at(1) + "\"."; - // } + auto user = app->accounts->Twitch.getCurrent(); + auto target = words.at(1); + + if (user->isAnon()) { + channel->addMessage(messages::Message::createSystemMessage( + "You must be logged in to ignore someone")); + return ""; + } + + user->unignore(target, [channel](const QString &message) { + channel->addMessage(messages::Message::createSystemMessage(message)); + }); - // channel->addMessage(messages::Message::createSystemMessage(messageText)); return ""; } else if (commandName == "/w") { if (words.length() <= 2) { diff --git a/src/providers/twitch/twitchaccount.cpp b/src/providers/twitch/twitchaccount.cpp index 801d499f1..f87aa0e08 100644 --- a/src/providers/twitch/twitchaccount.cpp +++ b/src/providers/twitch/twitchaccount.cpp @@ -1,5 +1,9 @@ #include "providers/twitch/twitchaccount.hpp" + #include "const.hpp" +#include "debug/log.hpp" +#include "util/networkrequest.hpp" +#include "util/urlfetch.hpp" namespace chatterino { namespace providers { @@ -68,6 +72,146 @@ bool TwitchAccount::isAnon() const return this->_isAnon; } +void TwitchAccount::loadIgnores() +{ + QString url("https://api.twitch.tv/kraken/users/" + this->getUserId() + "/blocks"); + + util::NetworkRequest req(url); + req.setRequestType(util::NetworkRequest::GET); + req.setCaller(QThread::currentThread()); + req.makeAuthorizedV5(this->getOAuthClient(), this->getOAuthToken()); + req.onSuccess([=](const rapidjson::Document &document) { + if (!document.IsObject()) { + return false; + } + + auto blocksIt = document.FindMember("blocks"); + if (blocksIt == document.MemberEnd()) { + return false; + } + const auto &blocks = blocksIt->value; + + if (!blocks.IsArray()) { + return false; + } + + { + std::lock_guard lock(this->ignoresMutex); + this->ignores.clear(); + + for (const auto &block : blocks.GetArray()) { + if (!block.IsObject()) { + continue; + } + auto userIt = block.FindMember("user"); + if (userIt == block.MemberEnd()) { + continue; + } + this->ignores.insert(TwitchUser::fromJSON(userIt->value)); + } + } + + return true; + }); + + req.execute(); +} + +void TwitchAccount::ignore(const QString &targetName, + std::function onFinished) +{ + util::twitch::getUserID(targetName, QThread::currentThread(), [=](QString targetUserID) { + QString url("https://api.twitch.tv/kraken/users/" + this->getUserId() + "/blocks/" + + targetUserID); + + util::NetworkRequest req(url); + req.setRequestType(util::NetworkRequest::PUT); + req.setCaller(QThread::currentThread()); + req.makeAuthorizedV5(this->getOAuthClient(), this->getOAuthToken()); + + req.onError([=](int errorCode) { + onFinished("An unknown error occured while trying to ignore user " + targetName + " (" + + QString::number(errorCode) + ")"); + + return true; + }); + + req.onSuccess([=](const rapidjson::Document &document) { + if (!document.IsObject()) { + onFinished("Bad JSON data while ignoring user " + targetName); + return false; + } + + auto userIt = document.FindMember("user"); + if (userIt == document.MemberEnd()) { + onFinished("Bad JSON data while ignoring user (missing user) " + targetName); + return false; + } + + auto ignoredUser = TwitchUser::fromJSON(userIt->value); + { + std::lock_guard lock(this->ignoresMutex); + + auto res = this->ignores.insert(ignoredUser); + if (!res.second) { + const TwitchUser &existingUser = *(res.first); + existingUser.update(ignoredUser); + onFinished("User " + targetName + " is already ignored"); + return false; + } + } + onFinished("Successfully ignored user " + targetName); + + return true; + }); + + req.execute(); + }); +} + +void TwitchAccount::unignore(const QString &targetName, + std::function onFinished) +{ + util::twitch::getUserID(targetName, QThread::currentThread(), [=](QString targetUserID) { + QString url("https://api.twitch.tv/kraken/users/" + this->getUserId() + "/blocks/" + + targetUserID); + + util::NetworkRequest req(url); + req.setRequestType(util::NetworkRequest::DELETE); + req.setCaller(QThread::currentThread()); + req.makeAuthorizedV5(this->getOAuthClient(), this->getOAuthToken()); + + req.onError([=](int errorCode) { + onFinished("An unknown error occured while trying to unignore user " + targetName + + " (" + QString::number(errorCode) + ")"); + + return true; + }); + + req.onSuccess([=](const rapidjson::Document &document) { + TwitchUser ignoredUser; + ignoredUser.id = targetUserID; + { + std::lock_guard lock(this->ignoresMutex); + + this->ignores.erase(ignoredUser); + } + onFinished("Successfully unignored user " + targetName); + + return true; + }); + + req.execute(); + }); +} + +std::set TwitchAccount::getIgnores() const +{ + std::lock_guard lock(this->ignoresMutex); + + return this->ignores; +} + } // namespace twitch } // namespace providers } // namespace chatterino diff --git a/src/providers/twitch/twitchaccount.hpp b/src/providers/twitch/twitchaccount.hpp index dde6855c8..2f510c519 100644 --- a/src/providers/twitch/twitchaccount.hpp +++ b/src/providers/twitch/twitchaccount.hpp @@ -1,9 +1,12 @@ #pragma once +#include "controllers/accounts/account.hpp" +#include "providers/twitch/twitchuser.hpp" + #include #include -#include "controllers/accounts/account.hpp" +#include namespace chatterino { namespace providers { @@ -33,6 +36,12 @@ public: bool isAnon() const; + void loadIgnores(); + void ignore(const QString &targetName, std::function onFinished); + void unignore(const QString &targetName, std::function onFinished); + + std::set getIgnores() const; + QColor color; private: @@ -41,6 +50,9 @@ private: QString userName; QString userId; const bool _isAnon; + + mutable std::mutex ignoresMutex; + std::set ignores; }; } // namespace twitch diff --git a/src/providers/twitch/twitchaccountmanager.cpp b/src/providers/twitch/twitchaccountmanager.cpp index b13ef4b53..dcfd9bb4d 100644 --- a/src/providers/twitch/twitchaccountmanager.cpp +++ b/src/providers/twitch/twitchaccountmanager.cpp @@ -11,6 +11,10 @@ namespace twitch { TwitchAccountManager::TwitchAccountManager() : anonymousUser(new TwitchAccount(ANONYMOUS_USERNAME, "", "", "")) { + this->currentUserChanged.connect([this] { + auto currentUser = this->getCurrent(); + currentUser->loadIgnores(); + }); } std::shared_ptr TwitchAccountManager::getCurrent() diff --git a/src/providers/twitch/twitchuser.cpp b/src/providers/twitch/twitchuser.cpp new file mode 100644 index 000000000..43d5f322e --- /dev/null +++ b/src/providers/twitch/twitchuser.cpp @@ -0,0 +1,34 @@ +#include "providers/twitch/twitchuser.hpp" + +#include "util/rapidjson-helpers.hpp" + +namespace chatterino { +namespace providers { +namespace twitch { + +TwitchUser TwitchUser::fromJSON(const rapidjson::Value &value) +{ + TwitchUser user; + + if (!value.IsObject()) { + throw std::runtime_error("JSON value is not an object"); + } + + if (!rj::getSafe(value, "_id", user.id)) { + throw std::runtime_error("Missing ID key"); + } + + if (!rj::getSafe(value, "name", user.name)) { + throw std::runtime_error("Missing name key"); + } + + if (!rj::getSafe(value, "display_name", user.displayName)) { + throw std::runtime_error("Missing display name key"); + } + + return user; +} + +} // namespace twitch +} // namespace providers +} // namespace chatterino diff --git a/src/providers/twitch/twitchuser.hpp b/src/providers/twitch/twitchuser.hpp new file mode 100644 index 000000000..50aed9f57 --- /dev/null +++ b/src/providers/twitch/twitchuser.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include +#include + +#include + +namespace chatterino { +namespace providers { +namespace twitch { + +struct TwitchUser { + QString id; + mutable QString name; + mutable QString displayName; + + void update(const TwitchUser &other) const + { + assert(this->id == other.id); + + this->name = other.name; + this->displayName = other.displayName; + } + + static TwitchUser fromJSON(const rapidjson::Value &value); + + bool operator<(const TwitchUser &rhs) const + { + return this->id < rhs.id; + } +}; + +} // namespace twitch +} // namespace providers +} // namespace chatterino diff --git a/src/util/networkrequest.hpp b/src/util/networkrequest.hpp index 247fe0c17..aac4276e1 100644 --- a/src/util/networkrequest.hpp +++ b/src/util/networkrequest.hpp @@ -62,6 +62,15 @@ static rapidjson::Document parseJSONFromReply2(QNetworkReply *reply) class NetworkRequest { +public: + enum RequestType { + GET, + POST, + PUT, + DELETE, + }; + +private: struct Data { QNetworkRequest request; const QObject *caller = nullptr; @@ -69,6 +78,13 @@ class NetworkRequest int timeoutMS = -1; bool useQuickLoadCache = false; + std::function onError; + std::function onSuccess; + + NetworkRequest::RequestType requestType; + + QByteArray payload; + QString getHash() { if (this->hash.isEmpty()) { @@ -100,6 +116,28 @@ public: explicit NetworkRequest(const std::string &url); explicit NetworkRequest(const QString &url); + void setRequestType(RequestType newRequestType) + { + this->data.requestType = newRequestType; + } + + template + void onError(Func cb) + { + this->data.onError = cb; + } + + template + void onSuccess(Func cb) + { + this->data.onSuccess = cb; + } + + void setPayload(const QByteArray &payload) + { + this->data.payload = payload; + } + void setUseQuickLoadCache(bool value); void setCaller(const QObject *_caller) @@ -112,16 +150,33 @@ public: this->data.onReplyCreated = f; } - void setRawHeader(const QByteArray &headerName, const QByteArray &value) + void setRawHeader(const char *headerName, const char *value) { this->data.request.setRawHeader(headerName, value); } + void setRawHeader(const char *headerName, const QByteArray &value) + { + this->data.request.setRawHeader(headerName, value); + } + + void setRawHeader(const char *headerName, const QString &value) + { + this->data.request.setRawHeader(headerName, value.toUtf8()); + } + void setTimeout(int ms) { this->data.timeoutMS = ms; } + void makeAuthorizedV5(const QString &clientID, const QString &oauthToken) + { + this->setRawHeader("Client-ID", clientID); + this->setRawHeader("Accept", "application/vnd.twitchtv.v5+json"); + this->setRawHeader("Authorization", "OAuth " + oauthToken); + } + template void get(FinishedCallback onFinished) { @@ -243,6 +298,169 @@ public: return true; }); } + + void execute() + { + switch (this->data.requestType) { + case GET: { + this->executeGet(); + } break; + + case PUT: { + debug::Log("Call PUT request!"); + this->executePut(); + } break; + + case DELETE: { + debug::Log("Call DELETE request!"); + this->executeDelete(); + } break; + + default: { + debug::Log("Unhandled request type {}", (int)this->data.requestType); + } break; + } + } + +private: + void useCache() + { + if (this->data.useQuickLoadCache) { + auto app = getApp(); + + QFile cachedFile(app->paths->cacheFolderPath + "/" + this->data.getHash()); + + if (cachedFile.exists()) { + if (cachedFile.open(QIODevice::ReadOnly)) { + QByteArray bytes = cachedFile.readAll(); + + // qDebug() << "Loaded cached resource" << this->data.request.url(); + + auto document = parseJSONFromData2(bytes); + + bool success = false; + + if (!document.IsNull()) { + success = this->data.onSuccess(document); + } + + cachedFile.close(); + + if (!success) { + // The images were not successfully loaded from the file + // XXX: Invalidate the cache file so we don't attempt to load it again next + // time + } + } + } + } + } + + void doRequest() + { + QTimer *timer = nullptr; + if (this->data.timeoutMS > 0) { + timer = new QTimer; + } + + NetworkRequester requester; + NetworkWorker *worker = new NetworkWorker; + + worker->moveToThread(&NetworkManager::workerThread); + + if (this->data.caller != nullptr) { + QObject::connect(worker, &NetworkWorker::doneUrl, this->data.caller, + [data = this->data](auto reply) mutable { + if (reply->error() != QNetworkReply::NetworkError::NoError) { + // TODO: We might want to call an onError callback here + return; + } + + QByteArray readBytes = reply->readAll(); + QByteArray bytes; + bytes.setRawData(readBytes.data(), readBytes.size()); + data.writeToCache(bytes); + data.onSuccess(parseJSONFromData2(bytes)); + + reply->deleteLater(); + }); + } + + if (timer != nullptr) { + timer->start(this->data.timeoutMS); + } + + QObject::connect(&requester, &NetworkRequester::requestUrl, worker, + [timer, data = std::move(this->data), worker]() { + QNetworkReply *reply; + switch (data.requestType) { + case GET: { + reply = NetworkManager::NaM.get(data.request); + } break; + + case PUT: { + reply = NetworkManager::NaM.put(data.request, data.payload); + } break; + + case DELETE: { + reply = NetworkManager::NaM.deleteResource(data.request); + } break; + } + + if (reply == nullptr) { + debug::Log("Unhandled request type {}", (int)data.requestType); + return; + } + + if (timer != nullptr) { + QObject::connect(timer, &QTimer::timeout, worker, + [reply, timer, data]() { + debug::Log("Aborted!"); + reply->abort(); + timer->deleteLater(); + data.onError(-2); + }); + } + + if (data.onReplyCreated) { + data.onReplyCreated(reply); + } + + QObject::connect(reply, &QNetworkReply::finished, worker, + [data = std::move(data), worker, reply]() mutable { + if (data.caller == nullptr) { + QByteArray bytes = reply->readAll(); + data.writeToCache(bytes); + data.onSuccess(parseJSONFromData2(bytes)); + + reply->deleteLater(); + } else { + emit worker->doneUrl(reply); + } + + delete worker; + }); + }); + + emit requester.requestUrl(); + } + + void executeGet() + { + this->useCache(); + + this->doRequest(); + } + + void executePut() + { + this->doRequest(); + } + + void executeDelete() + { + this->doRequest(); + } }; } // namespace util diff --git a/src/widgets/settingsdialog.cpp b/src/widgets/settingsdialog.cpp index 29fb8ba83..de603045c 100644 --- a/src/widgets/settingsdialog.cpp +++ b/src/widgets/settingsdialog.cpp @@ -153,6 +153,10 @@ void SettingsDialog::refresh() // this->ui.accountSwitchWidget->refresh(); getApp()->settings->saveSnapshot(); + + for (auto *tab : this->tabs) { + tab->getSettingsPage()->onShow(); + } } void SettingsDialog::scaleChangedEvent(float newDpi) diff --git a/src/widgets/settingspages/ignoreuserspage.cpp b/src/widgets/settingspages/ignoreuserspage.cpp index 15bc06349..830ad0d39 100644 --- a/src/widgets/settingspages/ignoreuserspage.cpp +++ b/src/widgets/settingspages/ignoreuserspage.cpp @@ -1,6 +1,7 @@ #include "ignoreuserspage.hpp" #include "application.hpp" +#include "singletons/accountmanager.hpp" #include "singletons/settingsmanager.hpp" #include "util/layoutcreator.hpp" @@ -53,8 +54,7 @@ IgnoreUsersPage::IgnoreUsersPage() addremove->addStretch(1); } - auto userList = users.emplace(); - UNUSED(userList); // TODO: Fill this list in with ignored users + users.emplace()->setModel(&this->userListModel); } // messages @@ -68,6 +68,23 @@ IgnoreUsersPage::IgnoreUsersPage() label->setStyleSheet("color: #BBB"); } +void IgnoreUsersPage::onShow() +{ + auto app = getApp(); + + auto user = app->accounts->Twitch.getCurrent(); + + if (user->isAnon()) { + return; + } + + QStringList users; + for (const auto &ignoredUser : user->getIgnores()) { + users << ignoredUser.name; + } + this->userListModel.setStringList(users); +} + } // namespace settingspages } // namespace widgets } // namespace chatterino diff --git a/src/widgets/settingspages/ignoreuserspage.hpp b/src/widgets/settingspages/ignoreuserspage.hpp index 747730897..b9ffdd6c0 100644 --- a/src/widgets/settingspages/ignoreuserspage.hpp +++ b/src/widgets/settingspages/ignoreuserspage.hpp @@ -2,6 +2,8 @@ #include "widgets/settingspages/settingspage.hpp" +#include + namespace chatterino { namespace widgets { namespace settingspages { @@ -10,6 +12,11 @@ class IgnoreUsersPage : public SettingsPage { public: IgnoreUsersPage(); + + void onShow() final; + +private: + QStringListModel userListModel; }; } // namespace settingspages diff --git a/src/widgets/settingspages/settingspage.hpp b/src/widgets/settingspages/settingspage.hpp index 1e800878a..0adb335b2 100644 --- a/src/widgets/settingspages/settingspage.hpp +++ b/src/widgets/settingspages/settingspage.hpp @@ -28,6 +28,10 @@ public: QLineEdit *createLineEdit(pajlada::Settings::Setting &setting); QSpinBox *createSpinBox(pajlada::Settings::Setting &setting, int min = 0, int max = 2500); + virtual void onShow() + { + } + protected: QString name; QString iconResource;