diff --git a/CHANGELOG.md b/CHANGELOG.md index f7fff5f035a..e4f6391de73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unversioned +- Major: Add option to show pronouns in user card. (#5442) - Major: Release plugins alpha. (#5288) - Major: Improve high-DPI support on Windows. (#4868, #5391) - Minor: Removed the Ctrl+Shift+L hotkey for toggling the "live only" tab visibility state. (#5530) diff --git a/benchmarks/src/RecentMessages.cpp b/benchmarks/src/RecentMessages.cpp index 24ebd127f82..994c8885857 100644 --- a/benchmarks/src/RecentMessages.cpp +++ b/benchmarks/src/RecentMessages.cpp @@ -11,6 +11,7 @@ #include "providers/chatterino/ChatterinoBadges.hpp" #include "providers/ffz/FfzBadges.hpp" #include "providers/ffz/FfzEmotes.hpp" +#include "providers/pronouns/Pronouns.hpp" #include "providers/recentmessages/Impl.hpp" #include "providers/seventv/SeventvBadges.hpp" #include "providers/seventv/SeventvEmotes.hpp" @@ -110,6 +111,11 @@ class MockApplication : public mock::BaseApplication return &this->linkResolver; } + pronouns::Pronouns *getPronouns() override + { + return &this->pronouns; + } + AccountController accounts; Emotes emotes; mock::UserDataController userData; @@ -124,6 +130,7 @@ class MockApplication : public mock::BaseApplication FfzEmotes ffzEmotes; SeventvEmotes seventvEmotes; DisabledStreamerMode streamerMode; + pronouns::Pronouns pronouns; }; std::optional tryReadJsonFile(const QString &path) diff --git a/mocks/include/mocks/EmptyApplication.hpp b/mocks/include/mocks/EmptyApplication.hpp index dae2314be9b..336e6a1a773 100644 --- a/mocks/include/mocks/EmptyApplication.hpp +++ b/mocks/include/mocks/EmptyApplication.hpp @@ -264,6 +264,13 @@ class EmptyApplication : public IApplication return nullptr; } + pronouns::Pronouns *getPronouns() override + { + assert(false && "EmptyApplication::getPronouns was called without " + "being initialized"); + return nullptr; + } + QTemporaryDir settingsDir; Paths paths_; Args args_; diff --git a/src/Application.cpp b/src/Application.cpp index 45abc56baf7..7d26285f740 100644 --- a/src/Application.cpp +++ b/src/Application.cpp @@ -15,6 +15,7 @@ #include "providers/bttv/BttvEmotes.hpp" #include "providers/ffz/FfzEmotes.hpp" #include "providers/links/LinkResolver.hpp" +#include "providers/pronouns/Pronouns.hpp" #include "providers/seventv/SeventvAPI.hpp" #include "providers/seventv/SeventvEmotes.hpp" #include "providers/twitch/TwitchBadges.hpp" @@ -178,6 +179,7 @@ Application::Application(Settings &_settings, const Paths &paths, , linkResolver(new LinkResolver) , streamerMode(new StreamerMode) , twitchUsers(new TwitchUsers) + , pronouns(std::make_shared()) #ifdef CHATTERINO_HAVE_PLUGINS , plugins(new PluginController(paths)) #endif @@ -565,6 +567,14 @@ SeventvEventAPI *Application::getSeventvEventAPI() return this->seventvEventAPI.get(); } +pronouns::Pronouns *Application::getPronouns() +{ + // pronouns::Pronouns handles its own locks, so we don't need to assert that this is called in the GUI thread + assert(this->pronouns); + + return this->pronouns.get(); +} + void Application::save() { this->commands->save(); diff --git a/src/Application.hpp b/src/Application.hpp index 614d72db6a2..92f0dd7526f 100644 --- a/src/Application.hpp +++ b/src/Application.hpp @@ -54,6 +54,9 @@ class SeventvEventAPI; class ILinkResolver; class IStreamerMode; class ITwitchUsers; +namespace pronouns { + class Pronouns; +} // namespace pronouns class IApplication { @@ -105,6 +108,7 @@ class IApplication virtual ILinkResolver *getLinkResolver() = 0; virtual IStreamerMode *getStreamerMode() = 0; virtual ITwitchUsers *getTwitchUsers() = 0; + virtual pronouns::Pronouns *getPronouns() = 0; }; class Application : public IApplication @@ -169,6 +173,7 @@ class Application : public IApplication std::unique_ptr linkResolver; std::unique_ptr streamerMode; std::unique_ptr twitchUsers; + std::shared_ptr pronouns; #ifdef CHATTERINO_HAVE_PLUGINS std::unique_ptr plugins; #endif @@ -215,6 +220,7 @@ class Application : public IApplication FfzEmotes *getFfzEmotes() override; SeventvEmotes *getSeventvEmotes() override; SeventvEventAPI *getSeventvEventAPI() override; + pronouns::Pronouns *getPronouns() override; ILinkResolver *getLinkResolver() override; IStreamerMode *getStreamerMode() override; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 9757ed3e1cc..db6b1551dcb 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -348,6 +348,13 @@ set(SOURCE_FILES providers/liveupdates/BasicPubSubManager.hpp providers/liveupdates/BasicPubSubWebsocket.hpp + providers/pronouns/Pronouns.cpp + providers/pronouns/Pronouns.hpp + providers/pronouns/UserPronouns.cpp + providers/pronouns/UserPronouns.hpp + providers/pronouns/alejo/PronounsAlejoApi.cpp + providers/pronouns/alejo/PronounsAlejoApi.hpp + providers/recentmessages/Api.cpp providers/recentmessages/Api.hpp providers/recentmessages/Impl.cpp diff --git a/src/common/QLogging.cpp b/src/common/QLogging.cpp index a8cd8285d55..554fc48a607 100644 --- a/src/common/QLogging.cpp +++ b/src/common/QLogging.cpp @@ -37,6 +37,7 @@ Q_LOGGING_CATEGORY(chatterinoNotification, "chatterino.notification", logThreshold); Q_LOGGING_CATEGORY(chatterinoImageuploader, "chatterino.imageuploader", logThreshold); +Q_LOGGING_CATEGORY(chatterinoPronouns, "chatterino.pronouns", logThreshold); Q_LOGGING_CATEGORY(chatterinoPubSub, "chatterino.pubsub", logThreshold); Q_LOGGING_CATEGORY(chatterinoRecentMessages, "chatterino.recentmessages", logThreshold); diff --git a/src/common/QLogging.hpp b/src/common/QLogging.hpp index b814bb33246..f4da615cdd9 100644 --- a/src/common/QLogging.hpp +++ b/src/common/QLogging.hpp @@ -28,6 +28,7 @@ Q_DECLARE_LOGGING_CATEGORY(chatterinoMessage); Q_DECLARE_LOGGING_CATEGORY(chatterinoNativeMessage); Q_DECLARE_LOGGING_CATEGORY(chatterinoNetwork); Q_DECLARE_LOGGING_CATEGORY(chatterinoNotification); +Q_DECLARE_LOGGING_CATEGORY(chatterinoPronouns); Q_DECLARE_LOGGING_CATEGORY(chatterinoPubSub); Q_DECLARE_LOGGING_CATEGORY(chatterinoRecentMessages); Q_DECLARE_LOGGING_CATEGORY(chatterinoSettings); diff --git a/src/providers/pronouns/Pronouns.cpp b/src/providers/pronouns/Pronouns.cpp new file mode 100644 index 00000000000..8b37303dc19 --- /dev/null +++ b/src/providers/pronouns/Pronouns.cpp @@ -0,0 +1,64 @@ +#include "providers/pronouns/Pronouns.hpp" + +#include "Application.hpp" +#include "common/QLogging.hpp" +#include "providers/pronouns/alejo/PronounsAlejoApi.hpp" +#include "providers/pronouns/UserPronouns.hpp" + +#include +#include +#include + +namespace chatterino::pronouns { + +void Pronouns::fetch(const QString &username, + const std::function &callbackSuccess, + const std::function &callbackFail) +{ + // Only fetch pronouns if we haven't fetched before. + { + std::shared_lock lock(this->mutex); + + auto iter = this->saved.find(username); + if (iter != this->saved.end()) + { + callbackSuccess(iter->second); + return; + } + } // unlock mutex + + qCDebug(chatterinoPronouns) + << "Fetching pronouns from alejo.io for " << username; + + alejoApi.fetch(username, [this, callbackSuccess, callbackFail, + username](std::optional result) { + if (result.has_value()) + { + { + std::unique_lock lock(this->mutex); + this->saved[username] = *result; + } // unlock mutex + qCDebug(chatterinoPronouns) + << "Adding pronouns " << result->format() << " for user " + << username; + callbackSuccess(*result); + } + else + { + callbackFail(); + } + }); +} + +std::optional Pronouns::getForUsername(const QString &username) +{ + std::shared_lock lock(this->mutex); + auto it = this->saved.find(username); + if (it != this->saved.end()) + { + return {it->second}; + } + return {}; +} + +} // namespace chatterino::pronouns diff --git a/src/providers/pronouns/Pronouns.hpp b/src/providers/pronouns/Pronouns.hpp new file mode 100644 index 00000000000..adf2439e2eb --- /dev/null +++ b/src/providers/pronouns/Pronouns.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include "providers/pronouns/alejo/PronounsAlejoApi.hpp" +#include "providers/pronouns/UserPronouns.hpp" + +#include +#include +#include + +namespace chatterino::pronouns { + +class Pronouns +{ +public: + Pronouns() = default; + + void fetch(const QString &username, + const std::function &callbackSuccess, + const std::function &callbackFail); + + // Retrieve cached pronouns for user. + std::optional getForUsername(const QString &username); + +private: + // mutex for editing the saved map. + std::shared_mutex mutex; + // Login name -> Pronouns + std::unordered_map saved; + AlejoApi alejoApi; +}; +} // namespace chatterino::pronouns diff --git a/src/providers/pronouns/UserPronouns.cpp b/src/providers/pronouns/UserPronouns.cpp new file mode 100644 index 00000000000..6c093a723ef --- /dev/null +++ b/src/providers/pronouns/UserPronouns.cpp @@ -0,0 +1,24 @@ +#include "providers/pronouns/UserPronouns.hpp" + +#include + +#include + +namespace chatterino::pronouns { + +UserPronouns::UserPronouns(QString pronouns) + : representation{!pronouns.isEmpty() ? std::move(pronouns) : QString()} +{ +} + +bool UserPronouns::isUnspecified() const +{ + return this->representation.isEmpty(); +} + +UserPronouns::operator bool() const +{ + return !isUnspecified(); +} + +} // namespace chatterino::pronouns diff --git a/src/providers/pronouns/UserPronouns.hpp b/src/providers/pronouns/UserPronouns.hpp new file mode 100644 index 00000000000..5ce5b9c88b2 --- /dev/null +++ b/src/providers/pronouns/UserPronouns.hpp @@ -0,0 +1,31 @@ +#pragma once + +#include + +namespace chatterino::pronouns { + +class UserPronouns +{ +public: + UserPronouns() = default; + UserPronouns(QString pronouns); + + QString format() const + { + if (isUnspecified()) + { + return "unspecified"; + } + return this->representation; + } + + bool isUnspecified() const; + + /// True, iff the pronouns are not unspecified. + operator bool() const; + +private: + QString representation; +}; + +} // namespace chatterino::pronouns diff --git a/src/providers/pronouns/alejo/PronounsAlejoApi.cpp b/src/providers/pronouns/alejo/PronounsAlejoApi.cpp new file mode 100644 index 00000000000..31e606016e0 --- /dev/null +++ b/src/providers/pronouns/alejo/PronounsAlejoApi.cpp @@ -0,0 +1,124 @@ +#include "providers/pronouns/alejo/PronounsAlejoApi.hpp" + +#include "common/network/NetworkRequest.hpp" +#include "common/network/NetworkResult.hpp" +#include "common/QLogging.hpp" +#include "providers/pronouns/UserPronouns.hpp" + +namespace chatterino::pronouns { + +UserPronouns AlejoApi::parse(const QJsonObject &object) +{ + if (!this->pronounsFromId.has_value()) + { + return {}; + } + + auto pronoun = object["pronoun_id"]; + + if (!pronoun.isString()) + { + return {}; + } + + auto pronounStr = pronoun.toString(); + std::shared_lock lock(this->mutex); + auto iter = this->pronounsFromId->find(pronounStr); + if (iter != this->pronounsFromId->end()) + { + return {iter->second}; + } + return {}; +} + +AlejoApi::AlejoApi() +{ + std::shared_lock lock(this->mutex); + if (this->pronounsFromId) + { + return; + } + + qCDebug(chatterinoPronouns) << "Fetching available pronouns for alejo.io"; + NetworkRequest(AlejoApi::API_URL + AlejoApi::API_PRONOUNS) + .concurrent() + .onSuccess([this](const auto &result) { + auto object = result.parseJson(); + if (object.isEmpty()) + { + return; + } + + std::unique_lock lock(this->mutex); + this->pronounsFromId = {std::unordered_map()}; + for (auto const &pronounId : object.keys()) + { + if (!object[pronounId].isObject()) + { + continue; + }; + + const auto pronounObj = object[pronounId].toObject(); + + if (!pronounObj["subject"].isString()) + { + continue; + } + + QString pronouns = pronounObj["subject"].toString(); + + auto singular = pronounObj["singular"]; + if (singular.isBool() && !singular.toBool() && + pronounObj["object"].isString()) + { + pronouns += "/" + pronounObj["object"].toString(); + } + + this->pronounsFromId->insert_or_assign(pronounId, + pronouns.toLower()); + } + }) + .execute(); +} + +void AlejoApi::fetch(const QString &username, + std::function)> onDone) +{ + bool havePronounList{true}; + { + std::shared_lock lock(this->mutex); + havePronounList = this->pronounsFromId.has_value(); + } // unlock mutex + + if (!havePronounList) + { + // Pronoun list not available yet, just fail and try again next time. + onDone({}); + return; + } + + NetworkRequest(AlejoApi::API_URL + AlejoApi::API_USERS + "/" + username) + .concurrent() + .onSuccess([this, username, onDone](const auto &result) { + auto object = result.parseJson(); + auto parsed = this->parse(object); + onDone({parsed}); + }) + .onError([onDone, username](auto result) { + auto status = result.status(); + if (status.has_value() && status == 404) + { + // Alejo returns 404 if the user has no pronouns set. + // Return unspecified. + onDone({UserPronouns()}); + return; + } + qCWarning(chatterinoPronouns) + << "alejo.io returned " << status.value_or(-1) + << " when fetching pronouns for " << username; + onDone({}); + }) + .execute(); +} + +} // namespace chatterino::pronouns diff --git a/src/providers/pronouns/alejo/PronounsAlejoApi.hpp b/src/providers/pronouns/alejo/PronounsAlejoApi.hpp new file mode 100644 index 00000000000..a6e82c9bfac --- /dev/null +++ b/src/providers/pronouns/alejo/PronounsAlejoApi.hpp @@ -0,0 +1,35 @@ +#pragma once + +#include "providers/pronouns/UserPronouns.hpp" + +#include +#include + +#include +#include +#include + +namespace chatterino::pronouns { + +class AlejoApi +{ +public: + explicit AlejoApi(); + /** Fetches pronouns from the alejo.io API for a username and calls onDone when done. + onDone can be invoked from any thread. The argument is std::nullopt if and only if the request failed. + */ + void fetch(const QString &username, + std::function)> onDone); + +private: + std::shared_mutex mutex; + /** A map from alejo.io ids to human readable representation like theythem -> they/them, other -> other. */ + std::optional> pronounsFromId = + std::nullopt; + UserPronouns parse(const QJsonObject &object); + inline static const QString API_URL = "https://api.pronouns.alejo.io/v1"; + inline static const QString API_USERS = "/users"; + inline static const QString API_PRONOUNS = "/pronouns"; +}; + +} // namespace chatterino::pronouns diff --git a/src/singletons/Settings.hpp b/src/singletons/Settings.hpp index 39769ad9bf5..e787dcb0fc2 100644 --- a/src/singletons/Settings.hpp +++ b/src/singletons/Settings.hpp @@ -555,6 +555,7 @@ class Settings BoolSetting informOnTabVisibilityToggle = {"/misc/askOnTabVisibilityToggle", true}; BoolSetting lockNotebookLayout = {"/misc/lockNotebookLayout", false}; + BoolSetting showPronouns = {"/misc/showPronouns", false}; /// UI diff --git a/src/widgets/dialogs/UserInfoPopup.cpp b/src/widgets/dialogs/UserInfoPopup.cpp index 4703cac763a..6aac79d03b3 100644 --- a/src/widgets/dialogs/UserInfoPopup.cpp +++ b/src/widgets/dialogs/UserInfoPopup.cpp @@ -11,6 +11,7 @@ #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" #include "providers/IvrApi.hpp" +#include "providers/pronouns/Pronouns.hpp" #include "providers/twitch/api/Helix.hpp" #include "providers/twitch/ChannelPointReward.hpp" #include "providers/twitch/TwitchAccount.hpp" @@ -24,6 +25,7 @@ #include "util/Clipboard.hpp" #include "util/Helpers.hpp" #include "util/LayoutCreator.hpp" +#include "util/PostToThread.hpp" #include "widgets/helper/ChannelView.hpp" #include "widgets/helper/EffectLabel.hpp" #include "widgets/helper/InvisibleSizeGrip.hpp" @@ -47,6 +49,9 @@ constexpr QStringView TEXT_CREATED = u"Created: %1"; constexpr QStringView TEXT_TITLE = u"%1's Usercard - #%2"; constexpr QStringView TEXT_USER_ID = u"ID: "; constexpr QStringView TEXT_UNAVAILABLE = u"(not available)"; +constexpr QStringView TEXT_PRONOUNS = u"Pronouns: %1"; +constexpr QStringView TEXT_UNSPECIFIED = u"(unspecified)"; +constexpr QStringView TEXT_LOADING = u"(loading...)"; using namespace chatterino; @@ -369,6 +374,11 @@ UserInfoPopup::UserInfoPopup(bool closeAutomatically, Split *split) } // items on the left + if (getSettings()->showPronouns) + { + vbox.emplace