feat: user notes (#6122)

Fixes #2413
This commit is contained in:
fourtf
2025-04-13 13:38:56 +02:00
committed by GitHub
parent 3ba25dd33d
commit 37061dd3bd
12 changed files with 312 additions and 73 deletions
+1
View File
@@ -2,6 +2,7 @@
## Unversioned ## 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: Added cached emotes fallback when fetching from a provider fails. (#6125)
- Minor: Add an option for the reduced opacity of message history. (#6121) - 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) - Minor: Make paused chat indicator more visible, and fix its zoom behavior. (#6123)
+11 -8
View File
@@ -27,21 +27,24 @@ public:
void setUserColor(const QString &userID, void setUserColor(const QString &userID,
const QString &colorString) override const QString &colorString) override
{ {
auto it = this->userMap.find(userID); this->userMap[userID].color = QColor(colorString);
if (it != this->userMap.end()) this->userDataUpdated_.invoke();
{
it->second.color = QColor(colorString);
} }
else
void setUserNotes(const QString &userID, const QString &notes) override
{ {
this->userMap.emplace(userID, UserData{ this->userMap[userID].notes = notes;
.color = QColor(colorString), this->userDataUpdated_.invoke();
});
} }
pajlada::Signals::NoArgSignal &userDataUpdated() override
{
return this->userDataUpdated_;
} }
private: private:
std::unordered_map<QString, UserData> userMap; std::unordered_map<QString, UserData> userMap;
pajlada::Signals::NoArgSignal userDataUpdated_;
}; };
} // namespace chatterino::mock } // namespace chatterino::mock
+2
View File
@@ -617,6 +617,8 @@ set(SOURCE_FILES
widgets/dialogs/ColorPickerDialog.hpp widgets/dialogs/ColorPickerDialog.hpp
widgets/dialogs/EditHotkeyDialog.cpp widgets/dialogs/EditHotkeyDialog.cpp
widgets/dialogs/EditHotkeyDialog.hpp widgets/dialogs/EditHotkeyDialog.hpp
widgets/dialogs/EditUserNotesDialog.cpp
widgets/dialogs/EditUserNotesDialog.hpp
widgets/dialogs/EmotePopup.cpp widgets/dialogs/EmotePopup.cpp
widgets/dialogs/EmotePopup.hpp widgets/dialogs/EmotePopup.hpp
widgets/dialogs/LastRunCrashDialog.cpp widgets/dialogs/LastRunCrashDialog.cpp
+16 -1
View File
@@ -17,8 +17,12 @@ namespace chatterino {
// Replacement fields should be optional, where none denotes that the field should not be updated for the user // Replacement fields should be optional, where none denotes that the field should not be updated for the user
struct UserData { struct UserData {
std::optional<QColor> color{std::nullopt}; std::optional<QColor> color{std::nullopt};
QString notes;
// TODO: User note? bool isEmpty() const
{
return !color.has_value() && notes.isEmpty();
}
}; };
} // namespace chatterino } // namespace chatterino
@@ -38,6 +42,11 @@ struct Serialize<chatterino::UserData> {
chatterino::rj::set(obj, "color", chatterino::rj::set(obj, "color",
color.name().toUtf8().toStdString(), a); color.name().toUtf8().toStdString(), a);
} }
if (!value.notes.isEmpty())
{
chatterino::rj::set(obj, "notes",
value.notes.toUtf8().toStdString(), a);
}
return obj; return obj;
} }
}; };
@@ -65,6 +74,12 @@ struct Deserialize<chatterino::UserData> {
} }
} }
QString notes;
if (chatterino::rj::getSafe(value, "notes", notes))
{
user.notes = notes;
}
return user; return user;
} }
}; };
@@ -39,6 +39,11 @@ UserDataController::UserDataController(const Paths &paths)
std::optional<UserData> UserDataController::getUser(const QString &userID) const std::optional<UserData> UserDataController::getUser(const QString &userID) const
{ {
if (userID.isEmpty())
{
return std::nullopt;
}
std::shared_lock lock(this->usersMutex); std::shared_lock lock(this->usersMutex);
auto it = this->users.find(userID); auto it = this->users.find(userID);
@@ -59,7 +64,14 @@ std::unordered_map<QString, UserData> UserDataController::getUsers() const
void UserDataController::setUserColor(const QString &userID, void UserDataController::setUserColor(const QString &userID,
const QString &colorString) 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); auto it = c.find(userID);
std::optional<QColor> finalColor = std::optional<QColor> finalColor =
makeConditionedOptional(!colorString.isEmpty(), QColor(colorString)); makeConditionedOptional(!colorString.isEmpty(), QColor(colorString));
@@ -80,15 +92,46 @@ void UserDataController::setUserColor(const QString &userID,
it->second.color = finalColor; it->second.color = finalColor;
} }
this->update(std::move(c)); this->update(std::move(c), std::move(lock));
} }
void UserDataController::update( void UserDataController::update(
std::unordered_map<QString, UserData> &&newUsers) std::unordered_map<QString, UserData> &&newUsers,
std::unique_lock<std::shared_mutex> 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->users = std::move(newUsers);
this->setting.setValue(this->users); this->setting.setValue(this->users);
// unlock before invoking updated signal
usersLock.unlock();
this->userDataUpdated_.invoke();
}
void UserDataController::setUserNotes(const QString &userID,
const QString &notes)
{
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 } // namespace chatterino
@@ -7,6 +7,7 @@
#include "util/serialize/Container.hpp" #include "util/serialize/Container.hpp"
#include <pajlada/settings.hpp> #include <pajlada/settings.hpp>
#include <pajlada/signals/signal.hpp>
#include <QColor> #include <QColor>
#include <QString> #include <QString>
@@ -27,6 +28,9 @@ public:
virtual void setUserColor(const QString &userID, virtual void setUserColor(const QString &userID,
const QString &colorString) = 0; const QString &colorString) = 0;
virtual void setUserNotes(const QString &userID, const QString &notes) = 0;
virtual pajlada::Signals::NoArgSignal &userDataUpdated() = 0;
}; };
class UserDataController : public IUserDataController class UserDataController : public IUserDataController
@@ -42,8 +46,14 @@ public:
void setUserColor(const QString &userID, void setUserColor(const QString &userID,
const QString &colorString) override; const QString &colorString) override;
// Update or insert extra data for the notes about a user
void setUserNotes(const QString &userID, const QString &notes) override;
pajlada::Signals::NoArgSignal &userDataUpdated() override;
private: private:
void update(std::unordered_map<QString, UserData> &&newUsers); void update(std::unordered_map<QString, UserData> &&newUsers,
std::unique_lock<std::shared_mutex> usersLock);
std::unordered_map<QString, UserData> getUsers() const; std::unordered_map<QString, UserData> getUsers() const;
@@ -53,6 +63,7 @@ private:
std::shared_ptr<pajlada::Settings::SettingManager> sm; std::shared_ptr<pajlada::Settings::SettingManager> sm;
pajlada::Settings::Setting<std::unordered_map<QString, UserData>> setting; pajlada::Settings::Setting<std::unordered_map<QString, UserData>> setting;
pajlada::Signals::NoArgSignal userDataUpdated_;
}; };
} // namespace chatterino } // namespace chatterino
+23 -18
View File
@@ -484,6 +484,28 @@ bool BaseWindow::supportsCustomWindowFrame()
#endif #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() void BaseWindow::themeChangedEvent()
{ {
if (this->hasCustomWindowFrame()) if (this->hasCustomWindowFrame())
@@ -520,24 +542,7 @@ bool BaseWindow::event(QEvent *event)
{ {
if (event->type() == QEvent::WindowDeactivate) if (event->type() == QEvent::WindowDeactivate)
{ {
switch (this->windowDeactivateAction) this->windowDeactivationEvent();
{
case WindowDeactivateAction::Delete:
this->deleteLater();
break;
case WindowDeactivateAction::Close:
this->close();
break;
case WindowDeactivateAction::Hide:
this->hide();
break;
case WindowDeactivateAction::Nothing:
default:
break;
}
} }
#if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0) #if QT_VERSION >= QT_VERSION_CHECK(6, 7, 0)
+2
View File
@@ -110,6 +110,8 @@ protected:
WindowDeactivateAction windowDeactivateAction = WindowDeactivateAction windowDeactivateAction =
WindowDeactivateAction::Nothing; WindowDeactivateAction::Nothing;
virtual void windowDeactivationEvent();
#if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0) #if QT_VERSION >= QT_VERSION_CHECK(6, 0, 0)
bool nativeEvent(const QByteArray &eventType, void *message, bool nativeEvent(const QByteArray &eventType, void *message,
qintptr *result) override; qintptr *result) override;
@@ -0,0 +1,81 @@
#include "EditUserNotesDialog.hpp"
#include "singletons/Theme.hpp"
#include "util/LayoutCreator.hpp"
#include <QDialogButtonBox>
#include <QTextEdit>
namespace chatterino {
EditUserNotesDialog::EditUserNotesDialog(QWidget *parent)
: BasePopup(
{
BaseWindow::EnableCustomFrame,
BaseWindow::DisableLayoutSave,
BaseWindow::BoundsCheckOnShow,
},
parent)
{
this->setScaleIndependantSize(500, 350);
auto layout = LayoutCreator<QWidget>(this->getLayoutContainer())
.setLayoutType<QVBoxLayout>();
auto edit = layout.emplace<QTextEdit>().assign(&this->textEdit_);
layout
.emplace<QDialogButtonBox>(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 &notes)
{
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
@@ -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<const QString &> onOk;
protected:
void showEvent(QShowEvent *event) override;
void themeChangedEvent() override;
private:
QTextEdit *textEdit_{};
};
} // namespace chatterino
+74 -36
View File
@@ -9,6 +9,7 @@
#include "controllers/commands/CommandController.hpp" #include "controllers/commands/CommandController.hpp"
#include "controllers/highlights/HighlightBlacklistUser.hpp" #include "controllers/highlights/HighlightBlacklistUser.hpp"
#include "controllers/hotkeys/HotkeyController.hpp" #include "controllers/hotkeys/HotkeyController.hpp"
#include "controllers/userdata/UserDataController.hpp"
#include "messages/Message.hpp" #include "messages/Message.hpp"
#include "messages/MessageBuilder.hpp" #include "messages/MessageBuilder.hpp"
#include "providers/IvrApi.hpp" #include "providers/IvrApi.hpp"
@@ -27,6 +28,7 @@
#include "util/Helpers.hpp" #include "util/Helpers.hpp"
#include "util/LayoutCreator.hpp" #include "util/LayoutCreator.hpp"
#include "util/PostToThread.hpp" #include "util/PostToThread.hpp"
#include "widgets/dialogs/EditUserNotesDialog.hpp"
#include "widgets/helper/ChannelView.hpp" #include "widgets/helper/ChannelView.hpp"
#include "widgets/helper/EffectLabel.hpp" #include "widgets/helper/EffectLabel.hpp"
#include "widgets/helper/InvisibleSizeGrip.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_PRONOUNS = u"Pronouns: %1";
constexpr QStringView TEXT_UNSPECIFIED = u"(unspecified)"; constexpr QStringView TEXT_UNSPECIFIED = u"(unspecified)";
constexpr QStringView TEXT_LOADING = u"(loading...)"; constexpr QStringView TEXT_LOADING = u"(loading...)";
constexpr qsizetype NOTES_PREVIEW_LENGTH = 80;
using namespace chatterino; using namespace chatterino;
@@ -407,6 +410,9 @@ UserInfoPopup::UserInfoPopup(bool closeAutomatically, Split *split)
.assign(&this->ui_.ignoreHighlights); .assign(&this->ui_.ignoreHighlights);
// visibility of this is updated in setData // visibility of this is updated in setData
auto notesAdd =
user.emplace<EffectLabel2>(this).assign(&this->ui_.notesAdd);
notesAdd->getLabel().setText("Add notes");
auto usercard = auto usercard =
user.emplace<EffectLabel2>(this).assign(&this->ui_.usercardLabel); user.emplace<EffectLabel2>(this).assign(&this->ui_.usercardLabel);
usercard->getLabel().setText("Usercard"); usercard->getLabel().setText("Usercard");
@@ -485,6 +491,9 @@ UserInfoPopup::UserInfoPopup(bool closeAutomatically, Split *split)
}); });
} }
auto notesPreview = layout.emplace<Label>().assign(&ui_.notesPreview);
notesPreview->setVisible(false);
auto lineMod = layout.emplace<Line>(false); auto lineMod = layout.emplace<Line>(false);
// third line // third line
@@ -618,6 +627,15 @@ void UserInfoPopup::scaleChangedEvent(float /*scale*/)
}); });
} }
void UserInfoPopup::windowDeactivationEvent()
{
if (this->editUserNotesDialog_.isNull() ||
!this->editUserNotesDialog_->isVisible())
{
BaseWindow::windowDeactivationEvent();
}
}
void UserInfoPopup::installEvents() void UserInfoPopup::installEvents()
{ {
std::shared_ptr<bool> ignoreNext = std::make_shared<bool>(false); std::shared_ptr<bool> ignoreNext = std::make_shared<bool>(false);
@@ -742,6 +760,35 @@ void UserInfoPopup::installEvents()
} }
} }
}); });
// user notes
QObject::connect(
this->ui_.notesAdd, &EffectLabel2::clicked, [this]() mutable {
if (this->editUserNotesDialog_.isNull())
{
this->editUserNotesDialog_ = new EditUserNotesDialog(this);
// ignoring since it the dialog is only used in this instance
std::ignore = this->editUserNotesDialog_->onOk.connect(
[userId = this->userId_](const QString &newNotes) {
getApp()->getUserData()->setUserNotes(userId, newNotes);
});
}
auto userData = getApp()->getUserData()->getUser(this->userId_);
auto initialNotes =
userData.has_value() ? userData->notes : QString();
this->editUserNotesDialog_->setNotes(initialNotes);
this->editUserNotesDialog_->updateWindowTitle(this->userName_);
this->editUserNotesDialog_->show();
});
// user data updated
this->userDataUpdatedConnection_ =
std::make_unique<pajlada::Signals::ScopedConnection>(
getApp()->getUserData()->userDataUpdated().connect([this]() {
this->updateNotes();
}));
} }
void UserInfoPopup::setData(const QString &name, const ChannelPtr &channel) void UserInfoPopup::setData(const QString &name, const ChannelPtr &channel)
@@ -758,6 +805,7 @@ void UserInfoPopup::setData(const QString &name,
if (isId) if (isId)
{ {
this->userId_ = name.mid(idPrefix.size()); this->userId_ = name.mid(idPrefix.size());
updateNotes();
this->userName_ = ""; this->userName_ = "";
} }
else else
@@ -879,6 +927,7 @@ void UserInfoPopup::updateUserData()
} }
this->userId_ = user.id; this->userId_ = user.id;
this->updateNotes();
this->avatarUrl_ = user.profileImageUrl; this->avatarUrl_ = user.profileImageUrl;
// copyable button for login name of users with a localized username // copyable button for login name of users with a localized username
@@ -1081,6 +1130,31 @@ void UserInfoPopup::loadAvatar(const QUrl &url)
}); });
} }
void UserInfoPopup::updateNotes()
{
static QRegularExpression onlySpaceRegex{"^\\s*$"};
auto userData = getApp()->getUserData()->getUser(this->userId_);
if (!userData.has_value() ||
onlySpaceRegex.match(userData->notes).hasMatch())
{
this->ui_.notesPreview->setText("");
this->ui_.notesPreview->setVisible(false);
return;
}
static QRegularExpression spaceRegex{"\\s+"};
auto previewText = "Notes: " + userData->notes.replace(spaceRegex, " ");
if (previewText.length() > NOTES_PREVIEW_LENGTH)
{
previewText = previewText.left(NOTES_PREVIEW_LENGTH - 3) + "...";
}
this->ui_.notesPreview->setText(previewText);
this->ui_.notesPreview->setVisible(true);
}
// //
// TimeoutWidget // TimeoutWidget
// //
@@ -1141,42 +1215,6 @@ UserInfoPopup::TimeoutWidget::TimeoutWidget()
[this, pair] { [this, pair] {
this->buttonClicked.invoke(pair); this->buttonClicked.invoke(pair);
}); });
//auto addTimeouts = [&](const QString &title_,
// const std::vector<std::pair<QString, int>> &items) {
// auto vbox = layout.emplace<QVBoxLayout>().withoutMargin();
// {
// auto title = vbox.emplace<QHBoxLayout>().withoutMargin();
// title->addStretch(1);
// auto label = title.emplace<Label>(title_);
// label->setStyleSheet("color: #BBB");
// label->setHasOffset(false);
// title->addStretch(1);
// auto hbox = vbox.emplace<QHBoxLayout>().withoutMargin();
// hbox->setSpacing(0);
// for (const auto &item : items)
// {
// auto a = hbox.emplace<EffectLabel2>();
// a->getLabel().setText(std::get<0>(item));
// if (std::get<0>(item).length() > 1)
// {
// a->setScaleIndependantSize(buttonWidth2, buttonHeight);
// }
// else
// {
// a->setScaleIndependantSize(buttonWidth, buttonHeight);
// }
// a->setBorderColor(color1);
// QObject::connect(a.getElement(), &EffectLabel2::leftClicked,
// [this, timeout = std::get<1>(item)] {
// this->buttonClicked.invoke(std::make_pair(
// Action::Timeout, timeout));
// });
// }
} }
}; };
+10 -2
View File
@@ -5,8 +5,7 @@
#include <pajlada/signals/scoped-connection.hpp> #include <pajlada/signals/scoped-connection.hpp>
#include <pajlada/signals/signal.hpp> #include <pajlada/signals/signal.hpp>
#include <QPointer>
#include <chrono>
class QCheckBox; class QCheckBox;
@@ -15,6 +14,7 @@ namespace chatterino {
class Channel; class Channel;
using ChannelPtr = std::shared_ptr<Channel>; using ChannelPtr = std::shared_ptr<Channel>;
class Label; class Label;
class EditUserNotesDialog;
class ChannelView; class ChannelView;
class Split; class Split;
@@ -36,11 +36,13 @@ public:
protected: protected:
void themeChangedEvent() override; void themeChangedEvent() override;
void scaleChangedEvent(float scale) override; void scaleChangedEvent(float scale) override;
void windowDeactivationEvent() override;
private: private:
void installEvents(); void installEvents();
void updateUserData(); void updateUserData();
void updateLatestMessages(); void updateLatestMessages();
void updateNotes();
void loadAvatar(const QUrl &url); void loadAvatar(const QUrl &url);
bool isMod_{}; bool isMod_{};
@@ -61,6 +63,8 @@ private:
pajlada::Signals::NoArgSignal userStateChanged_; pajlada::Signals::NoArgSignal userStateChanged_;
std::unique_ptr<pajlada::Signals::ScopedConnection> refreshConnection_; std::unique_ptr<pajlada::Signals::ScopedConnection> refreshConnection_;
std::unique_ptr<pajlada::Signals::ScopedConnection>
userDataUpdatedConnection_;
// If we should close the dialog automatically if the user clicks out // If we should close the dialog automatically if the user clicks out
// Set based on the "Automatically close usercard when it loses focus" setting // Set based on the "Automatically close usercard when it loses focus" setting
@@ -82,6 +86,8 @@ private:
QCheckBox *block = nullptr; QCheckBox *block = nullptr;
QCheckBox *ignoreHighlights = nullptr; QCheckBox *ignoreHighlights = nullptr;
Label *notesPreview = nullptr;
EffectLabel2 *notesAdd = nullptr;
Label *noMessagesLabel = nullptr; Label *noMessagesLabel = nullptr;
ChannelView *latestMessages = nullptr; ChannelView *latestMessages = nullptr;
@@ -89,6 +95,8 @@ private:
EffectLabel2 *usercardLabel = nullptr; EffectLabel2 *usercardLabel = nullptr;
} ui_; } ui_;
QPointer<EditUserNotesDialog> editUserNotesDialog_;
class TimeoutWidget : public BaseWidget class TimeoutWidget : public BaseWidget
{ {
public: public: