diff --git a/CMakeLists.txt b/CMakeLists.txt index 996d3aba..67eff75e 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -777,6 +777,7 @@ set(QML_SOURCES resources/qml/dialogs/AllowedRoomsSettingsDialog.qml resources/qml/dialogs/RoomSettings.qml resources/qml/dialogs/UserProfile.qml + resources/qml/dialogs/IgnoredUsers.qml resources/qml/emoji/StickerPicker.qml resources/qml/pages/LoginPage.qml resources/qml/pages/RegisterPage.qml diff --git a/resources/qml/dialogs/IgnoredUsers.qml b/resources/qml/dialogs/IgnoredUsers.qml new file mode 100644 index 00000000..2d8cc920 --- /dev/null +++ b/resources/qml/dialogs/IgnoredUsers.qml @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQml 2.15 +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 2.15 +import QtQuick.Window 2.15 +import im.nheko 1.0 + +Window { + id: ignoredUsers + + title: qsTr("Ignored users") + flags: Qt.WindowCloseButtonHint | Qt.WindowTitleHint + height: 650 + width: 420 + minimumHeight: 420 + color: palette.window + + ListView { + id: view + anchors.fill: parent + spacing: Nheko.paddingMedium + footerPositioning: ListView.OverlayFooter + + model: TimelineManager.ignoredUsers + header: ColumnLayout { + Text { + Layout.fillWidth: true + Layout.maximumWidth: view.width + wrapMode: Text.Wrap + color: palette.text + text: qsTr("Ignoring a user hides their messages (they can still see yours!).") + } + + Item { Layout.preferredHeight: Nheko.paddingLarge } + } + delegate: RowLayout { + property var profile: TimelineManager.getGlobalUserProfile(modelData) + + width: view.width + + Avatar { + enabled: false + displayName: profile.displayName + userid: profile.userid + url: profile.avatarUrl.replace("mxc://", "image://MxcImage/") + } + + Text { + Layout.fillWidth: true + Layout.alignment: Qt.AlignLeft + elide: Text.ElideRight + color: palette.text + text: modelData + } + + ImageButton { + Layout.preferredHeight: 24 + Layout.preferredWidth: 24 + image: ":/icons/icons/ui/dismiss.svg" + hoverEnabled: true + ToolTip.visible: hovered + ToolTip.text: qsTr("Stop Ignoring.") + onClicked: profile.ignored = false + } + } + footer: DialogButtonBox { + z: 2 + width: view.width + alignment: Qt.AlignRight + standardButtons: DialogButtonBox.Ok + onAccepted: ignoredUsers.close() + + background: Rectangle { + anchors.fill: parent + color: palette.window + } + } + } +} diff --git a/resources/qml/dialogs/UserProfile.qml b/resources/qml/dialogs/UserProfile.qml index b54b52a4..6cf747e3 100644 --- a/resources/qml/dialogs/UserProfile.qml +++ b/resources/qml/dialogs/UserProfile.qml @@ -289,6 +289,18 @@ ApplicationWindow { visible: !profile.isGlobalUserProfile && profile.room.permissions.canBan() } + ImageButton { + Layout.preferredHeight: 24 + Layout.preferredWidth: 24 + image: ":/icons/icons/ui/volume-off-indicator.svg" + hoverEnabled: true + ToolTip.visible: hovered + ToolTip.text: profile.ignored ? qsTr("Unignore the user.") : qsTr("Ignore the user.") + buttonTextColor: profile.ignored ? Nheko.theme.red : palette.buttonText + onClicked: profile.ignored = !profile.ignored + visible: !profile.isSelf + } + ImageButton { Layout.preferredHeight: 24 Layout.preferredWidth: 24 @@ -298,7 +310,6 @@ ApplicationWindow { ToolTip.text: qsTr("Refresh device list.") onClicked: profile.refreshDevices() } - } TabBar { diff --git a/resources/qml/pages/UserSettingsPage.qml b/resources/qml/pages/UserSettingsPage.qml index 7159a2f6..2dc4684d 100644 --- a/resources/qml/pages/UserSettingsPage.qml +++ b/resources/qml/pages/UserSettingsPage.qml @@ -233,6 +233,24 @@ Rectangle { } } + DelegateChoice { + roleValue: UserSettingsModel.ManageIgnoredUsers + Button { + text: qsTr("MANAGE") + onClicked: { + var dialog = ignoredUsersDialog.createObject(); + dialog.show(); + destroyOnClose(dialog); + } + + Component { + id: ignoredUsersDialog + + IgnoredUsers {} + } + } + } + DelegateChoice { Text { text: model.value diff --git a/src/ChatPage.cpp b/src/ChatPage.cpp index 25af8974..db5cbbe8 100644 --- a/src/ChatPage.cpp +++ b/src/ChatPage.cpp @@ -6,6 +6,9 @@ #include #include +#include +#include + #include #include "AvatarProvider.h" @@ -775,6 +778,23 @@ ChatPage::handleSyncResponse(const mtx::responses::Sync &res, const std::string // Ensure that we have enough one-time keys available. ensureOneTimeKeyCount(res.device_one_time_keys_count, res.device_unused_fallback_key_types); + std::optional oldIgnoredUsers; + if (auto ignoreEv = std::ranges::find_if( + res.account_data.events, + [](const mtx::events::collections::RoomAccountDataEvents &e) { + return std::holds_alternative< + mtx::events::AccountDataEvent>(e); + }); + ignoreEv != res.account_data.events.end()) { + if (auto oldEv = cache::client()->getAccountData(mtx::events::EventType::IgnoredUsers)) + oldIgnoredUsers = + std::get>( + *oldEv) + .content; + else + oldIgnoredUsers = mtx::events::account_data::IgnoredUsers{}; + } + // TODO: fine grained error handling try { cache::client()->saveState(res); @@ -783,6 +803,36 @@ ChatPage::handleSyncResponse(const mtx::responses::Sync &res, const std::string auto updates = cache::getRoomInfo(cache::client()->roomsWithStateUpdates(res)); emit syncUI(std::move(res)); + + // if the ignored users changed, clear timeline of all affected rooms. + if (oldIgnoredUsers) { + if (auto newEv = + cache::client()->getAccountData(mtx::events::EventType::IgnoredUsers)) { + std::vector changedUsers{}; + std::ranges::set_symmetric_difference( + oldIgnoredUsers->users, + std::get>( + *newEv) + .content.users, + std::back_inserter(changedUsers), + {}, + &mtx::events::account_data::IgnoredUser::id, + &mtx::events::account_data::IgnoredUser::id); + + std::unordered_set roomsToReload; + for (const auto &user : changedUsers) { + auto commonRooms = cache::client()->getCommonRooms(user.id); + for (const auto &room : commonRooms) + roomsToReload.insert(room.first); + } + + for (const auto &room : roomsToReload) { + if (auto model = + view_manager_->rooms()->getRoomById(QString::fromStdString(room))) + model->clearTimeline(); + } + } + } } catch (const lmdb::map_full_error &e) { nhlog::db()->error("lmdb is full: {}", e.what()); cache::deleteOldData(); diff --git a/src/UserSettingsPage.cpp b/src/UserSettingsPage.cpp index c9c878d0..3bc2f161 100644 --- a/src/UserSettingsPage.cpp +++ b/src/UserSettingsPage.cpp @@ -1042,6 +1042,8 @@ UserSettingsModel::data(const QModelIndex &index, int role) const return tr("Read receipts"); case HiddenTimelineEvents: return tr("Hidden events"); + case IgnoredUsers: + return tr("Ignored users"); case DesktopNotifications: return tr("Desktop notifications"); case AlertOnNotification: @@ -1485,6 +1487,8 @@ UserSettingsModel::data(const QModelIndex &index, int role) const return tr("Regularly redact expired events as specified in the event expiration " "configuration. Since this is currently not executed server side, you need " "to have one client running this regularly."); + case IgnoredUsers: + return tr("Manage your ignored users."); } } else if (role == Type) { switch (index.row()) { @@ -1571,6 +1575,8 @@ UserSettingsModel::data(const QModelIndex &index, int role) const return KeyStatus; case HiddenTimelineEvents: return ConfigureHiddenEvents; + case IgnoredUsers: + return ManageIgnoredUsers; } } else if (role == ValueLowerBound) { switch (index.row()) { diff --git a/src/UserSettingsPage.h b/src/UserSettingsPage.h index 2bae068a..2cf8e5ab 100644 --- a/src/UserSettingsPage.h +++ b/src/UserSettingsPage.h @@ -508,6 +508,7 @@ class UserSettingsModel : public QAbstractListModel MessageVisibilitySection, ExpireEvents, HiddenTimelineEvents, + IgnoredUsers, NotificationsSection, DesktopNotifications, @@ -566,6 +567,7 @@ public: SessionKeyImportExport, XSignKeysRequestDownload, ConfigureHiddenEvents, + ManageIgnoredUsers, }; Q_ENUM(Types); diff --git a/src/timeline/TimelineModel.h b/src/timeline/TimelineModel.h index 23c3c802..4ffd61ec 100644 --- a/src/timeline/TimelineModel.h +++ b/src/timeline/TimelineModel.h @@ -18,8 +18,6 @@ #include "CacheStructs.h" #include "EventStore.h" #include "InputBar.h" -#include "InviteesModel.h" -#include "MemberList.h" #include "Permissions.h" #include "ReadReceiptsModel.h" #include "ui/RoomSummary.h" diff --git a/src/timeline/TimelineViewManager.cpp b/src/timeline/TimelineViewManager.cpp index b8bd679b..e2616c14 100644 --- a/src/timeline/TimelineViewManager.cpp +++ b/src/timeline/TimelineViewManager.cpp @@ -12,6 +12,7 @@ #include #include "Cache.h" +#include "Cache_p.h" #include "ChatPage.h" #include "CombinedImagePackModel.h" #include "CommandCompleter.h" @@ -210,6 +211,7 @@ TimelineViewManager::sync(const mtx::responses::Sync &sync_) this->rooms_->sync(sync_); this->communities_->sync(sync_); this->presenceEmitter->sync(sync_.presence); + this->processIgnoredUsers(sync_.account_data); if (isInitialSync_) { this->isInitialSync_ = false; @@ -560,3 +562,41 @@ TimelineViewManager::fixImageRendering(QQuickTextDocument *t, QQuickItem *i) QObject::connect(t->textDocument(), SIGNAL(imagesLoaded()), i, SLOT(updateWholeDocument())); } } + +using IgnoredUsers = mtx::events::EphemeralEvent; + +static QVector +convertIgnoredToQt(const IgnoredUsers &ev) +{ + QVector users; + for (const mtx::events::account_data::IgnoredUser &user : ev.content.users) { + users.push_back(QString::fromStdString(user.id)); + } + + return users; +} + +QVector +TimelineViewManager::getIgnoredUsers() +{ + const auto cache = cache::client()->getAccountData(mtx::events::EventType::IgnoredUsers); + if (!cache) { + return {}; + } + + return convertIgnoredToQt(std::get(*cache)); +} + +void +TimelineViewManager::processIgnoredUsers(const mtx::responses::AccountData &data) +{ + for (const mtx::events::collections::RoomAccountDataEvents::variant &ev : data.events) { + if (!std::holds_alternative(ev)) { + continue; + } + const auto &ignoredEv = std::get(ev); + + emit this->ignoredUsersChanged(convertIgnoredToQt(ignoredEv)); + break; + } +} \ No newline at end of file diff --git a/src/timeline/TimelineViewManager.h b/src/timeline/TimelineViewManager.h index f3bd04a2..b4e176cd 100644 --- a/src/timeline/TimelineViewManager.h +++ b/src/timeline/TimelineViewManager.h @@ -11,7 +11,8 @@ #include #include -#include "ReadReceiptsModel.h" +#include "InviteesModel.h" +#include "MemberList.h" #include "timeline/CommunitiesModel.h" #include "timeline/PresenceEmitter.h" #include "timeline/RoomlistModel.h" @@ -39,6 +40,7 @@ class TimelineViewManager final : public QObject Q_PROPERTY( bool isInitialSync MEMBER isInitialSync_ READ isInitialSync NOTIFY initialSyncChanged) Q_PROPERTY(bool isConnected READ isConnected NOTIFY isConnectedChanged) + Q_PROPERTY(QVector ignoredUsers READ getIgnoredUsers NOTIFY ignoredUsersChanged) public: TimelineViewManager(CallManager *callManager, ChatPage *parent = nullptr); @@ -62,6 +64,10 @@ public: return instance_; } + static TimelineViewManager *instance() { return TimelineViewManager::instance_; } + + QVector getIgnoredUsers(); + void sync(const mtx::responses::Sync &sync_); VerificationManager *verificationManager() { return verificationManager_; } @@ -113,6 +119,7 @@ signals: QString url, double originalWidth, double proportionalHeight); + void ignoredUsersChanged(const QVector &ignoredUsers); public slots: void updateReadReceipts(const QString &room_id, const std::vector &event_ids); @@ -154,4 +161,6 @@ private: QHash, QColor> userColors; inline static TimelineViewManager *instance_ = nullptr; + + void processIgnoredUsers(const mtx::responses::AccountData &data); }; diff --git a/src/ui/UserProfile.cpp b/src/ui/UserProfile.cpp index 80def409..1b66a97d 100644 --- a/src/ui/UserProfile.cpp +++ b/src/ui/UserProfile.cpp @@ -11,11 +11,11 @@ #include "Cache_p.h" #include "ChatPage.h" #include "Logging.h" +#include "MainWindow.h" +#include "MatrixClient.h" #include "UserProfile.h" #include "Utils.h" -#include "encryption/DeviceVerificationFlow.h" #include "encryption/VerificationManager.h" -#include "mtx/responses/crypto.hpp" #include "timeline/TimelineModel.h" #include "timeline/TimelineViewManager.h" #include "ui/UIA.h" @@ -64,6 +64,19 @@ UserProfile::UserProfile(const QString &roomid, new RoomInfoModel(cache::client()->getCommonRooms(userid.toStdString()), this); else sharedRooms_ = new RoomInfoModel({}, this); + + connect(ChatPage::instance(), &ChatPage::syncUI, this, [this](const mtx::responses::Sync &res) { + if (auto ignoreEv = std::ranges::find_if( + res.account_data.events, + [](const mtx::events::collections::RoomAccountDataEvents &e) { + return std::holds_alternative< + mtx::events::AccountDataEvent>(e); + }); + ignoreEv != res.account_data.events.end()) { + // doesn't matter much if it was actually us + emit ignoredChanged(); + } + }); } QHash @@ -224,6 +237,49 @@ UserProfile::refreshDevices() fetchDeviceList(this->userid_); } +bool +UserProfile::ignored() const +{ + auto old = TimelineViewManager::instance()->getIgnoredUsers(); + return old.contains(userid_); +} + +void +UserProfile::setIgnored(bool ignore) +{ + auto old = TimelineViewManager::instance()->getIgnoredUsers(); + if (ignore) { + if (old.contains(userid_)) { + emit ignoredChanged(); + return; + } + old.append(userid_); + } else { + if (!old.contains(userid_)) { + emit ignoredChanged(); + return; + } + old.removeAll(userid_); + } + + std::vector content; + for (const QString &item : std::as_const(old)) { + content.emplace_back(item.toStdString()); + } + + mtx::events::account_data::IgnoredUsers payload{.users{content}}; + + auto userid = userid_; + + http::client()->put_account_data(payload, [userid](mtx::http::RequestErr e) { + if (e) { + MainWindow::instance()->showNotification( + tr("Failed to ignore \"%1\": %2") + .arg(userid, QString::fromStdString(e->matrix_error.error))); + } + }); +} + void UserProfile::fetchDeviceList(const QString &userID) { @@ -345,10 +401,6 @@ UserProfile::banUser() ChatPage::instance()->banUser(roomid_, this->userid_, QLatin1String("")); } -// void ignoreUser(){ - -// } - void UserProfile::kickUser() { diff --git a/src/ui/UserProfile.h b/src/ui/UserProfile.h index d8e06aa1..bc5b6a35 100644 --- a/src/ui/UserProfile.h +++ b/src/ui/UserProfile.h @@ -157,6 +157,7 @@ class UserProfile final : public QObject Q_PROPERTY(int userVerified READ getUserStatus NOTIFY userStatusChanged) Q_PROPERTY(bool isLoading READ isLoading NOTIFY loadingChanged) Q_PROPERTY(bool userVerificationEnabled READ userVerificationEnabled NOTIFY userStatusChanged) + Q_PROPERTY(bool ignored READ ignored WRITE setIgnored NOTIFY ignoredChanged) Q_PROPERTY(bool isSelf READ isSelf CONSTANT) Q_PROPERTY(TimelineModel *room READ room CONSTANT) public: @@ -184,7 +185,6 @@ public: Q_INVOKABLE void refreshDevices(); Q_INVOKABLE void banUser(); Q_INVOKABLE void signOutDevice(const QString &deviceID); - // Q_INVOKABLE void ignoreUser(); Q_INVOKABLE void kickUser(); Q_INVOKABLE void startChat(); Q_INVOKABLE void startChat(bool encryptionEnabled); @@ -193,6 +193,9 @@ public: Q_INVOKABLE void changeAvatar(); Q_INVOKABLE void openGlobalProfile(); + void setIgnored(bool ignored); + bool ignored() const; + signals: void userStatusChanged(); void loadingChanged(); @@ -201,6 +204,7 @@ signals: void displayError(const QString &errorMessage); void globalUsernameRetrieved(const QString &globalUser); void devicesChanged(); + void ignoredChanged(); // internal void verificationStatiChanged();