From 1eabda8668b09a9e89e2fb20947d20377a59f24b Mon Sep 17 00:00:00 2001 From: pajlada Date: Sun, 13 Nov 2022 18:21:21 +0100 Subject: [PATCH] Initial backend test for user-based data/customizations (#4144) Right now only support for colors and no real UX, idea is to test it & allow the idea to grow while figuring out the UX --- .clang-tidy | 1 + src/Application.cpp | 7 ++ src/Application.hpp | 5 + src/CMakeLists.txt | 6 ++ .../commands/CommandController.cpp | 17 +++ src/controllers/userdata/UserData.hpp | 71 +++++++++++++ .../userdata/UserDataController.cpp | 100 ++++++++++++++++++ .../userdata/UserDataController.hpp | 60 +++++++++++ src/providers/twitch/TwitchMessageBuilder.cpp | 13 +++ src/util/serialize/Container.hpp | 51 +++++++++ tests/src/HighlightController.cpp | 9 +- tests/src/TwitchMessageBuilder.cpp | 6 ++ tests/src/mocks/UserData.hpp | 27 +++++ 13 files changed, 372 insertions(+), 1 deletion(-) create mode 100644 src/controllers/userdata/UserData.hpp create mode 100644 src/controllers/userdata/UserDataController.cpp create mode 100644 src/controllers/userdata/UserDataController.hpp create mode 100644 src/util/serialize/Container.hpp create mode 100644 tests/src/mocks/UserData.hpp diff --git a/.clang-tidy b/.clang-tidy index b9cd999d..c97a71aa 100644 --- a/.clang-tidy +++ b/.clang-tidy @@ -22,6 +22,7 @@ Checks: '-*, -performance-noexcept-move-constructor, -misc-non-private-member-variables-in-classes, -cppcoreguidelines-non-private-member-variables-in-classes, + -cppcoreguidelines-special-member-functions, -modernize-use-nodiscard, -modernize-use-trailing-return-type, -readability-identifier-length, diff --git a/src/Application.cpp b/src/Application.cpp index 190cfb67..e50bfc4c 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -11,6 +11,7 @@ #include "controllers/hotkeys/HotkeyController.hpp" #include "controllers/ignores/IgnoreController.hpp" #include "controllers/notifications/NotificationController.hpp" +#include "controllers/userdata/UserDataController.hpp" #include "debug/AssertInGuiThread.hpp" #include "messages/MessageBuilder.hpp" #include "providers/bttv/BttvEmotes.hpp" @@ -76,6 +77,7 @@ Application::Application(Settings &_settings, Paths &_paths) , chatterinoBadges(&this->emplace()) , ffzBadges(&this->emplace()) , seventvBadges(&this->emplace()) + , userData(&this->emplace()) , logging(&this->emplace()) { this->instance = this; @@ -224,6 +226,11 @@ IEmotes *Application::getEmotes() return this->emotes; } +IUserDataController *Application::getUserData() +{ + return this->userData; +} + void Application::save() { for (auto &singleton : this->singletons_) diff --git a/src/Application.hpp b/src/Application.hpp index 7aaa2751..68c8bc59 100644 --- a/src/Application.hpp +++ b/src/Application.hpp @@ -17,6 +17,8 @@ class AccountController; class NotificationController; class HighlightController; class HotkeyController; +class IUserDataController; +class UserDataController; class Theme; class WindowManager; @@ -53,6 +55,7 @@ public: virtual TwitchIrcServer *getTwitch() = 0; virtual ChatterinoBadges *getChatterinoBadges() = 0; virtual FfzBadges *getFfzBadges() = 0; + virtual IUserDataController *getUserData() = 0; }; class Application : public IApplication @@ -89,6 +92,7 @@ public: ChatterinoBadges *const chatterinoBadges{}; FfzBadges *const ffzBadges{}; SeventvBadges *const seventvBadges{}; + UserDataController *const userData{}; /*[[deprecated]]*/ Logging *const logging{}; @@ -141,6 +145,7 @@ public: { return this->ffzBadges; } + IUserDataController *getUserData() override; private: void addSingleton(Singleton *singleton); diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 5055601a..79ef5e1e 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -125,6 +125,10 @@ set(SOURCE_FILES controllers/pings/MutedChannelModel.cpp controllers/pings/MutedChannelModel.hpp + controllers/userdata/UserDataController.cpp + controllers/userdata/UserDataController.hpp + controllers/userdata/UserData.hpp + debug/Benchmark.cpp debug/Benchmark.hpp @@ -364,6 +368,8 @@ set(SOURCE_FILES util/WindowsHelper.cpp util/WindowsHelper.hpp + util/serialize/Container.hpp + widgets/AccountSwitchPopup.cpp widgets/AccountSwitchPopup.hpp widgets/AccountSwitchWidget.cpp diff --git a/src/controllers/commands/CommandController.cpp b/src/controllers/commands/CommandController.cpp index 9c276328..3ca06b39 100644 --- a/src/controllers/commands/CommandController.cpp +++ b/src/controllers/commands/CommandController.cpp @@ -8,6 +8,7 @@ #include "controllers/commands/Command.hpp" #include "controllers/commands/CommandModel.hpp" #include "controllers/commands/builtin/twitch/ChatSettings.hpp" +#include "controllers/userdata/UserDataController.hpp" #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" #include "messages/MessageElement.hpp" @@ -3037,6 +3038,22 @@ void CommandController::initialize(Settings &, Paths &paths) return ""; }); + + this->registerCommand("/unstable-set-user-color", [](const auto &ctx) { + auto userID = ctx.words.at(1); + if (ctx.words.size() < 2) + { + ctx.channel->addMessage( + makeSystemMessage(QString("Usage: %1 [color]") + .arg(ctx.words.at(0)))); + } + + auto color = ctx.words.value(2); + + getIApp()->getUserData()->setUserColor(userID, color); + + return ""; + }); } void CommandController::save() diff --git a/src/controllers/userdata/UserData.hpp b/src/controllers/userdata/UserData.hpp new file mode 100644 index 00000000..ba3534e7 --- /dev/null +++ b/src/controllers/userdata/UserData.hpp @@ -0,0 +1,71 @@ +#pragma once + +#include "util/RapidJsonSerializeQString.hpp" +#include "util/RapidjsonHelpers.hpp" + +#include +#include +#include +#include + +namespace chatterino { + +// UserData defines a set of data that is defined for a unique user +// It can contain things like optional replacement color for the user, a unique alias +// or a user note that should be displayed with the user +// Replacement fields should be optional, where none denotes that the field should not be updated for the user +struct UserData { + boost::optional color{boost::none}; + + // TODO: User note? +}; + +} // namespace chatterino + +namespace pajlada { + +template <> +struct Serialize { + static rapidjson::Value get(const chatterino::UserData &value, + rapidjson::Document::AllocatorType &a) + { + rapidjson::Value obj; + obj.SetObject(); + if (value.color) + { + const auto &color = *value.color; + chatterino::rj::set(obj, "color", + color.name().toUtf8().toStdString(), a); + } + return obj; + } +}; + +template <> +struct Deserialize { + static chatterino::UserData get(const rapidjson::Value &value, + bool *error = nullptr) + { + if (!value.IsObject()) + { + PAJLADA_REPORT_ERROR(error) + return chatterino::UserData{}; + } + + chatterino::UserData user; + + QString colorString; + if (chatterino::rj::getSafe(value, "color", colorString)) + { + QColor color(colorString); + if (color.isValid()) + { + user.color = color; + } + } + + return user; + } +}; + +} // namespace pajlada diff --git a/src/controllers/userdata/UserDataController.cpp b/src/controllers/userdata/UserDataController.cpp new file mode 100644 index 00000000..001c24b8 --- /dev/null +++ b/src/controllers/userdata/UserDataController.cpp @@ -0,0 +1,100 @@ +#include "controllers/userdata/UserDataController.hpp" + +#include "singletons/Paths.hpp" +#include "util/CombinePath.hpp" + +namespace { + +using namespace chatterino; + +std::shared_ptr initSettingsInstance() +{ + auto sm = std::make_shared(); + + auto *paths = getPaths(); + + auto path = combinePath(paths->settingsDirectory, "user-data.json"); + + sm->setPath(path.toUtf8().toStdString()); + + sm->setBackupEnabled(true); + sm->setBackupSlots(9); + sm->saveMethod = + pajlada::Settings::SettingManager::SaveMethod::SaveAllTheTime; + + return sm; +} + +} // namespace + +namespace chatterino { + +UserDataController::UserDataController() + : sm(initSettingsInstance()) + , setting("/users", this->sm) +{ + this->sm->load(); + this->users = this->setting.getValue(); +} + +void UserDataController::save() +{ + this->sm->save(); +} + +boost::optional UserDataController::getUser( + const QString &userID) const +{ + std::shared_lock lock(this->usersMutex); + auto it = this->users.find(userID); + + if (it == this->users.end()) + { + return boost::none; + } + + return it->second; +} + +std::unordered_map UserDataController::getUsers() const +{ + std::shared_lock lock(this->usersMutex); + return this->users; +} + +void UserDataController::setUserColor(const QString &userID, + const QString &colorString) +{ + auto c = this->getUsers(); + auto it = c.find(userID); + boost::optional finalColor = + boost::make_optional(!colorString.isEmpty(), QColor(colorString)); + if (it == c.end()) + { + if (!finalColor) + { + // Early out - user is not configured and will not get a new color + return; + } + + UserData user; + user.color = finalColor; + c.insert({userID, user}); + } + else + { + it->second.color = finalColor; + } + + this->update(std::move(c)); +} + +void UserDataController::update( + std::unordered_map &&newUsers) +{ + std::unique_lock lock(this->usersMutex); + this->users = std::move(newUsers); + this->setting.setValue(this->users); +} + +} // namespace chatterino diff --git a/src/controllers/userdata/UserDataController.hpp b/src/controllers/userdata/UserDataController.hpp new file mode 100644 index 00000000..5ae0fe37 --- /dev/null +++ b/src/controllers/userdata/UserDataController.hpp @@ -0,0 +1,60 @@ +#pragma once + +#include "common/Singleton.hpp" +#include "controllers/userdata/UserData.hpp" +#include "util/QStringHash.hpp" +#include "util/RapidJsonSerializeQString.hpp" +#include "util/RapidjsonHelpers.hpp" +#include "util/serialize/Container.hpp" + +#include +#include +#include +#include + +#include +#include + +namespace chatterino { + +class IUserDataController +{ +public: + virtual ~IUserDataController() = default; + + virtual boost::optional getUser(const QString &userID) const = 0; + + virtual void setUserColor(const QString &userID, + const QString &colorString) = 0; +}; + +class UserDataController : public IUserDataController, public Singleton +{ +public: + UserDataController(); + + // Get extra data about a user + // If the user does not have any extra data, return none + boost::optional getUser(const QString &userID) const override; + + // Update or insert extra data for the user's color override + void setUserColor(const QString &userID, + const QString &colorString) override; + +protected: + void save() override; + +private: + void update(std::unordered_map &&newUsers); + + std::unordered_map getUsers() const; + + // Stores a real-time list of users & their customizations + std::unordered_map users; + mutable std::shared_mutex usersMutex; + + std::shared_ptr sm; + pajlada::Settings::Setting> setting; +}; + +} // namespace chatterino diff --git a/src/providers/twitch/TwitchMessageBuilder.cpp b/src/providers/twitch/TwitchMessageBuilder.cpp index d48f10fe..9c4b922a 100644 --- a/src/providers/twitch/TwitchMessageBuilder.cpp +++ b/src/providers/twitch/TwitchMessageBuilder.cpp @@ -4,6 +4,7 @@ #include "controllers/accounts/AccountController.hpp" #include "controllers/ignores/IgnoreController.hpp" #include "controllers/ignores/IgnorePhrase.hpp" +#include "controllers/userdata/UserDataController.hpp" #include "messages/Message.hpp" #include "providers/chatterino/ChatterinoBadges.hpp" #include "providers/ffz/FfzBadges.hpp" @@ -691,6 +692,18 @@ void TwitchMessageBuilder::parseRoomID() void TwitchMessageBuilder::parseUsernameColor() { + const auto *userData = getIApp()->getUserData(); + assert(userData != nullptr); + + if (const auto &user = userData->getUser(this->userId_)) + { + if (user->color) + { + this->usernameColor_ = user->color.value(); + return; + } + } + const auto iterator = this->tags.find("color"); if (iterator != this->tags.end()) { diff --git a/src/util/serialize/Container.hpp b/src/util/serialize/Container.hpp new file mode 100644 index 00000000..c9e59e5d --- /dev/null +++ b/src/util/serialize/Container.hpp @@ -0,0 +1,51 @@ +#pragma once + +#include +#include + +#include + +namespace pajlada { + +template +struct Serialize, RJValue> { + static RJValue get(const std::unordered_map &value, + typename RJValue::AllocatorType &a) + { + RJValue ret(rapidjson::kObjectType); + + for (auto it = value.begin(); it != value.end(); ++it) + { + detail::AddMember(ret, it->first.toUtf8(), + it->second, a); + } + + return ret; + } +}; + +template +struct Deserialize, RJValue> { + static std::unordered_map get(const RJValue &value, + bool *error = nullptr) + { + std::unordered_map ret; + + if (!value.IsObject()) + { + PAJLADA_REPORT_ERROR(error) + return ret; + } + + for (typename RJValue::ConstMemberIterator it = value.MemberBegin(); + it != value.MemberEnd(); ++it) + { + ret.emplace(it->name.GetString(), + Deserialize::get(it->value, error)); + } + + return ret; + } +}; + +} // namespace pajlada diff --git a/tests/src/HighlightController.cpp b/tests/src/HighlightController.cpp index 6f05c59c..077a981d 100644 --- a/tests/src/HighlightController.cpp +++ b/tests/src/HighlightController.cpp @@ -1,7 +1,9 @@ #include "controllers/highlights/HighlightController.hpp" + #include "Application.hpp" #include "BaseSettings.hpp" -#include "messages/MessageBuilder.hpp" // for MessageParseArgs +#include "messages/MessageBuilder.hpp" // for MessageParseArgs +#include "mocks/UserData.hpp" #include "providers/twitch/TwitchBadge.hpp" // for Badge #include "providers/twitch/api/Helix.hpp" @@ -73,9 +75,14 @@ public: { return nullptr; } + IUserDataController *getUserData() override + { + return &this->userData; + } AccountController accounts; HighlightController highlights; + mock::UserDataController userData; // TODO: Figure this out }; diff --git a/tests/src/TwitchMessageBuilder.cpp b/tests/src/TwitchMessageBuilder.cpp index 4b534641..408d2dff 100644 --- a/tests/src/TwitchMessageBuilder.cpp +++ b/tests/src/TwitchMessageBuilder.cpp @@ -3,6 +3,7 @@ #include "Application.hpp" #include "common/Channel.hpp" #include "messages/MessageBuilder.hpp" +#include "mocks/UserData.hpp" #include "providers/twitch/TwitchBadge.hpp" #include "singletons/Emotes.hpp" @@ -73,8 +74,13 @@ public: { return nullptr; } + IUserDataController *getUserData() override + { + return &this->userData; + } Emotes emotes; + mock::UserDataController userData; }; } // namespace diff --git a/tests/src/mocks/UserData.hpp b/tests/src/mocks/UserData.hpp new file mode 100644 index 00000000..df20eb82 --- /dev/null +++ b/tests/src/mocks/UserData.hpp @@ -0,0 +1,27 @@ +#pragma once + +#include "controllers/userdata/UserDataController.hpp" + +namespace chatterino::mock { + +class UserDataController : public IUserDataController +{ +public: + UserDataController() = default; + + // Get extra data about a user + // If the user does not have any extra data, return none + boost::optional getUser(const QString &userID) const override + { + return boost::none; + } + + // Update or insert extra data for the user's color override + void setUserColor(const QString &userID, + const QString &colorString) override + { + // do nothing + } +}; + +} // namespace chatterino::mock