From 37061dd3bde22363775a08ae06f74328b233179c Mon Sep 17 00:00:00 2001 From: fourtf Date: Sun, 13 Apr 2025 13:38:56 +0200 Subject: [PATCH] feat: user notes (#6122) Fixes #2413 --- CHANGELOG.md | 1 + mocks/include/mocks/UserData.hpp | 25 ++-- src/CMakeLists.txt | 2 + src/controllers/userdata/UserData.hpp | 17 ++- .../userdata/UserDataController.cpp | 51 +++++++- .../userdata/UserDataController.hpp | 13 ++- src/widgets/BaseWindow.cpp | 41 ++++--- src/widgets/BaseWindow.hpp | 2 + src/widgets/dialogs/EditUserNotesDialog.cpp | 81 +++++++++++++ src/widgets/dialogs/EditUserNotesDialog.hpp | 30 +++++ src/widgets/dialogs/UserInfoPopup.cpp | 110 ++++++++++++------ src/widgets/dialogs/UserInfoPopup.hpp | 12 +- 12 files changed, 312 insertions(+), 73 deletions(-) create mode 100644 src/widgets/dialogs/EditUserNotesDialog.cpp create mode 100644 src/widgets/dialogs/EditUserNotesDialog.hpp diff --git a/CHANGELOG.md b/CHANGELOG.md index 702187ea..79bca6a8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ## Unversioned +- Minor: Added user notes to the user info dialog (when clicking a username). (#6122) - Minor: Added cached emotes fallback when fetching from a provider fails. (#6125) - Minor: Add an option for the reduced opacity of message history. (#6121) - Minor: Make paused chat indicator more visible, and fix its zoom behavior. (#6123) diff --git a/mocks/include/mocks/UserData.hpp b/mocks/include/mocks/UserData.hpp index bf53ea4a..90497ec1 100644 --- a/mocks/include/mocks/UserData.hpp +++ b/mocks/include/mocks/UserData.hpp @@ -27,21 +27,24 @@ public: void setUserColor(const QString &userID, const QString &colorString) override { - auto it = this->userMap.find(userID); - if (it != this->userMap.end()) - { - it->second.color = QColor(colorString); - } - else - { - this->userMap.emplace(userID, UserData{ - .color = QColor(colorString), - }); - } + this->userMap[userID].color = QColor(colorString); + this->userDataUpdated_.invoke(); + } + + void setUserNotes(const QString &userID, const QString ¬es) override + { + this->userMap[userID].notes = notes; + this->userDataUpdated_.invoke(); + } + + pajlada::Signals::NoArgSignal &userDataUpdated() override + { + return this->userDataUpdated_; } private: std::unordered_map userMap; + pajlada::Signals::NoArgSignal userDataUpdated_; }; } // namespace chatterino::mock diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index ec134bea..24e516a2 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -617,6 +617,8 @@ set(SOURCE_FILES widgets/dialogs/ColorPickerDialog.hpp widgets/dialogs/EditHotkeyDialog.cpp widgets/dialogs/EditHotkeyDialog.hpp + widgets/dialogs/EditUserNotesDialog.cpp + widgets/dialogs/EditUserNotesDialog.hpp widgets/dialogs/EmotePopup.cpp widgets/dialogs/EmotePopup.hpp widgets/dialogs/LastRunCrashDialog.cpp diff --git a/src/controllers/userdata/UserData.hpp b/src/controllers/userdata/UserData.hpp index 37eb79aa..b26ac181 100644 --- a/src/controllers/userdata/UserData.hpp +++ b/src/controllers/userdata/UserData.hpp @@ -17,8 +17,12 @@ namespace chatterino { // Replacement fields should be optional, where none denotes that the field should not be updated for the user struct UserData { std::optional color{std::nullopt}; + QString notes; - // TODO: User note? + bool isEmpty() const + { + return !color.has_value() && notes.isEmpty(); + } }; } // namespace chatterino @@ -38,6 +42,11 @@ struct Serialize { chatterino::rj::set(obj, "color", color.name().toUtf8().toStdString(), a); } + if (!value.notes.isEmpty()) + { + chatterino::rj::set(obj, "notes", + value.notes.toUtf8().toStdString(), a); + } return obj; } }; @@ -65,6 +74,12 @@ struct Deserialize { } } + QString notes; + if (chatterino::rj::getSafe(value, "notes", notes)) + { + user.notes = notes; + } + return user; } }; diff --git a/src/controllers/userdata/UserDataController.cpp b/src/controllers/userdata/UserDataController.cpp index dc79f16a..39c706c0 100644 --- a/src/controllers/userdata/UserDataController.cpp +++ b/src/controllers/userdata/UserDataController.cpp @@ -39,6 +39,11 @@ UserDataController::UserDataController(const Paths &paths) std::optional UserDataController::getUser(const QString &userID) const { + if (userID.isEmpty()) + { + return std::nullopt; + } + std::shared_lock lock(this->usersMutex); auto it = this->users.find(userID); @@ -59,7 +64,14 @@ std::unordered_map UserDataController::getUsers() const void UserDataController::setUserColor(const QString &userID, const QString &colorString) { - auto c = this->getUsers(); + if (userID.isEmpty()) + { + return; + } + + std::unique_lock lock(this->usersMutex); + + auto c = this->users; auto it = c.find(userID); std::optional finalColor = makeConditionedOptional(!colorString.isEmpty(), QColor(colorString)); @@ -80,15 +92,46 @@ void UserDataController::setUserColor(const QString &userID, it->second.color = finalColor; } - this->update(std::move(c)); + this->update(std::move(c), std::move(lock)); } void UserDataController::update( - std::unordered_map &&newUsers) + std::unordered_map &&newUsers, + std::unique_lock usersLock) { - std::unique_lock lock(this->usersMutex); + // Remove empty user data items + std::erase_if(newUsers, [](const auto &pair) { + return pair.second.isEmpty(); + }); + this->users = std::move(newUsers); this->setting.setValue(this->users); + + // unlock before invoking updated signal + usersLock.unlock(); + + this->userDataUpdated_.invoke(); +} + +void UserDataController::setUserNotes(const QString &userID, + const QString ¬es) +{ + if (userID.isEmpty()) + { + return; + } + + std::unique_lock lock(this->usersMutex); + + auto users = this->users; + users[userID].notes = notes; + + this->update(std::move(users), std::move(lock)); +} + +pajlada::Signals::NoArgSignal &UserDataController::userDataUpdated() +{ + return this->userDataUpdated_; } } // namespace chatterino diff --git a/src/controllers/userdata/UserDataController.hpp b/src/controllers/userdata/UserDataController.hpp index 49edbde7..67554ac6 100644 --- a/src/controllers/userdata/UserDataController.hpp +++ b/src/controllers/userdata/UserDataController.hpp @@ -7,6 +7,7 @@ #include "util/serialize/Container.hpp" #include +#include #include #include @@ -27,6 +28,9 @@ public: virtual void setUserColor(const QString &userID, const QString &colorString) = 0; + virtual void setUserNotes(const QString &userID, const QString ¬es) = 0; + + virtual pajlada::Signals::NoArgSignal &userDataUpdated() = 0; }; class UserDataController : public IUserDataController @@ -42,8 +46,14 @@ public: void setUserColor(const QString &userID, const QString &colorString) override; + // Update or insert extra data for the notes about a user + void setUserNotes(const QString &userID, const QString ¬es) override; + + pajlada::Signals::NoArgSignal &userDataUpdated() override; + private: - void update(std::unordered_map &&newUsers); + void update(std::unordered_map &&newUsers, + std::unique_lock usersLock); std::unordered_map getUsers() const; @@ -53,6 +63,7 @@ private: std::shared_ptr sm; pajlada::Settings::Setting> setting; + pajlada::Signals::NoArgSignal userDataUpdated_; }; } // namespace chatterino diff --git a/src/widgets/BaseWindow.cpp b/src/widgets/BaseWindow.cpp index 787b0690..3b8b8c3f 100644 --- a/src/widgets/BaseWindow.cpp +++ b/src/widgets/BaseWindow.cpp @@ -484,6 +484,28 @@ bool BaseWindow::supportsCustomWindowFrame() #endif } +void BaseWindow::windowDeactivationEvent() +{ + switch (this->windowDeactivateAction) + { + case WindowDeactivateAction::Delete: + this->deleteLater(); + break; + + case WindowDeactivateAction::Close: + this->close(); + break; + + case WindowDeactivateAction::Hide: + this->hide(); + break; + + case WindowDeactivateAction::Nothing: + default: + break; + } +} + void BaseWindow::themeChangedEvent() { if (this->hasCustomWindowFrame()) @@ -520,24 +542,7 @@ bool BaseWindow::event(QEvent *event) { if (event->type() == QEvent::WindowDeactivate) { - switch (this->windowDeactivateAction) - { - case WindowDeactivateAction::Delete: - this->deleteLater(); - break; - - case WindowDeactivateAction::Close: - this->close(); - break; - - case WindowDeactivateAction::Hide: - this->hide(); - break; - - case WindowDeactivateAction::Nothing: - default: - break; - } + this->windowDeactivationEvent(); } #if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0) diff --git a/src/widgets/BaseWindow.hpp b/src/widgets/BaseWindow.hpp index 8b8d01b4..6cba9d82 100644 --- a/src/widgets/BaseWindow.hpp +++ b/src/widgets/BaseWindow.hpp @@ -110,6 +110,8 @@ protected: WindowDeactivateAction windowDeactivateAction = WindowDeactivateAction::Nothing; + virtual void windowDeactivationEvent(); + #if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) bool nativeEvent(const QByteArray &eventType, void *message, qintptr *result) override; diff --git a/src/widgets/dialogs/EditUserNotesDialog.cpp b/src/widgets/dialogs/EditUserNotesDialog.cpp new file mode 100644 index 00000000..c742286d --- /dev/null +++ b/src/widgets/dialogs/EditUserNotesDialog.cpp @@ -0,0 +1,81 @@ +#include "EditUserNotesDialog.hpp" + +#include "singletons/Theme.hpp" +#include "util/LayoutCreator.hpp" + +#include +#include + +namespace chatterino { + +EditUserNotesDialog::EditUserNotesDialog(QWidget *parent) + : BasePopup( + { + BaseWindow::EnableCustomFrame, + BaseWindow::DisableLayoutSave, + BaseWindow::BoundsCheckOnShow, + }, + parent) +{ + this->setScaleIndependantSize(500, 350); + + auto layout = LayoutCreator(this->getLayoutContainer()) + .setLayoutType(); + + auto edit = layout.emplace().assign(&this->textEdit_); + + layout + .emplace(QDialogButtonBox::Ok | + QDialogButtonBox::Cancel) + .connect(&QDialogButtonBox::accepted, this, + [this, edit = edit.getElement()] { + this->onOk.invoke(edit->toPlainText()); + this->close(); + }) + .connect(&QDialogButtonBox::rejected, this, [this] { + this->close(); + }); + + this->themeChangedEvent(); +} + +void EditUserNotesDialog::setNotes(const QString ¬es) +{ + this->textEdit_->setPlainText(notes); +} + +void EditUserNotesDialog::updateWindowTitle(const QString &displayUsername) +{ + this->setWindowTitle("Editing notes for " + displayUsername); +} + +void EditUserNotesDialog::showEvent(QShowEvent *event) +{ + this->textEdit_->setFocus(Qt::FocusReason::ActiveWindowFocusReason); + + BasePopup::showEvent(event); +} + +void EditUserNotesDialog::themeChangedEvent() +{ + if (!this->theme) + { + return; + } + + auto palette = this->palette(); + + palette.setColor(QPalette::Window, + this->theme->tabs.selected.backgrounds.regular); + palette.setColor(QPalette::Base, getTheme()->splits.background); + palette.setColor(QPalette::Text, getTheme()->window.text); + + this->setPalette(palette); + + if (this->textEdit_) + { + this->textEdit_->setPalette(palette); + } +} + +} // namespace chatterino diff --git a/src/widgets/dialogs/EditUserNotesDialog.hpp b/src/widgets/dialogs/EditUserNotesDialog.hpp new file mode 100644 index 00000000..877787b7 --- /dev/null +++ b/src/widgets/dialogs/EditUserNotesDialog.hpp @@ -0,0 +1,30 @@ +#pragma once + +#include "pajlada/signals/signal.hpp" +#include "widgets/BasePopup.hpp" + +class QTextEdit; + +namespace chatterino { + +class EditUserNotesDialog : public BasePopup +{ + Q_OBJECT + +public: + EditUserNotesDialog(QWidget *parent = nullptr); + + void setNotes(const QString &initialNotes); + void updateWindowTitle(const QString &displayUsername); + + pajlada::Signals::Signal onOk; + +protected: + void showEvent(QShowEvent *event) override; + void themeChangedEvent() override; + +private: + QTextEdit *textEdit_{}; +}; + +} // namespace chatterino diff --git a/src/widgets/dialogs/UserInfoPopup.cpp b/src/widgets/dialogs/UserInfoPopup.cpp index 2a828c21..b39e0d1d 100644 --- a/src/widgets/dialogs/UserInfoPopup.cpp +++ b/src/widgets/dialogs/UserInfoPopup.cpp @@ -9,6 +9,7 @@ #include "controllers/commands/CommandController.hpp" #include "controllers/highlights/HighlightBlacklistUser.hpp" #include "controllers/hotkeys/HotkeyController.hpp" +#include "controllers/userdata/UserDataController.hpp" #include "messages/Message.hpp" #include "messages/MessageBuilder.hpp" #include "providers/IvrApi.hpp" @@ -27,6 +28,7 @@ #include "util/Helpers.hpp" #include "util/LayoutCreator.hpp" #include "util/PostToThread.hpp" +#include "widgets/dialogs/EditUserNotesDialog.hpp" #include "widgets/helper/ChannelView.hpp" #include "widgets/helper/EffectLabel.hpp" #include "widgets/helper/InvisibleSizeGrip.hpp" @@ -55,6 +57,7 @@ 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...)"; +constexpr qsizetype NOTES_PREVIEW_LENGTH = 80; using namespace chatterino; @@ -407,6 +410,9 @@ UserInfoPopup::UserInfoPopup(bool closeAutomatically, Split *split) .assign(&this->ui_.ignoreHighlights); // visibility of this is updated in setData + auto notesAdd = + user.emplace(this).assign(&this->ui_.notesAdd); + notesAdd->getLabel().setText("Add notes"); auto usercard = user.emplace(this).assign(&this->ui_.usercardLabel); usercard->getLabel().setText("Usercard"); @@ -485,6 +491,9 @@ UserInfoPopup::UserInfoPopup(bool closeAutomatically, Split *split) }); } + auto notesPreview = layout.emplace