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;