From 29a2e4eb99184a8359acbf3101e118f0ce86e483 Mon Sep 17 00:00:00 2001 From: James Graham Date: Thu, 5 Jan 2023 00:36:13 +0000 Subject: [PATCH] Room Settings - Permissions Work to add the ability to set user power levels and modify the power levels required for certain actions. Updated ![image](/uploads/50bce18f5eb31bb0c3508e03a39e7589/image.png) --- src/CMakeLists.txt | 2 + src/main.cpp | 2 + src/neochatroom.cpp | 302 ++++++++++++++++++ src/neochatroom.h | 104 ++++++ src/qml/RoomSettings/Categories.qml | 10 + src/qml/RoomSettings/Permissions.qml | 453 +++++++++++++++++++++++++++ src/res.qrc | 1 + src/userfiltermodel.cpp | 28 ++ src/userfiltermodel.h | 42 +++ src/userlistmodel.cpp | 34 ++ src/userlistmodel.h | 7 + 11 files changed, 985 insertions(+) create mode 100644 src/qml/RoomSettings/Permissions.qml create mode 100644 src/userfiltermodel.cpp create mode 100644 src/userfiltermodel.h diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 401d8af93..7e92c2dfb 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -20,6 +20,7 @@ add_library(neochat STATIC neochatroom.cpp neochatuser.cpp userlistmodel.cpp + userfiltermodel.cpp publicroomlistmodel.cpp userdirectorylistmodel.cpp keywordnotificationrulemodel.cpp @@ -169,6 +170,7 @@ if(ANDROID) "favorite" "window-new" "globe" + "visibility" ) else() target_link_libraries(neochat PUBLIC Qt::Widgets KF5::KIOWidgets) diff --git a/src/main.cpp b/src/main.cpp index ab4aa66d4..372cc09a4 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -72,6 +72,7 @@ #include "spacehierarchycache.h" #include "urlhelper.h" #include "userdirectorylistmodel.h" +#include "userfiltermodel.h" #include "userlistmodel.h" #include "webshortcutmodel.h" #include "windowcontroller.h" @@ -220,6 +221,7 @@ int main(int argc, char *argv[]) qmlRegisterType("org.kde.neochat", 1, 0, "MessageEventModel"); qmlRegisterType("org.kde.neochat", 1, 0, "CollapseStateProxyModel"); qmlRegisterType("org.kde.neochat", 1, 0, "MessageFilterModel"); + qmlRegisterType("org.kde.neochat", 1, 0, "UserFilterModel"); qmlRegisterType("org.kde.neochat", 1, 0, "PublicRoomListModel"); qmlRegisterType("org.kde.neochat", 1, 0, "UserDirectoryListModel"); qmlRegisterType("org.kde.neochat", 1, 0, "ServerListModel"); diff --git a/src/neochatroom.cpp b/src/neochatroom.cpp index 36d9fd498..c6c11d79e 100644 --- a/src/neochatroom.cpp +++ b/src/neochatroom.cpp @@ -31,6 +31,9 @@ #include #include #include +#ifndef QUOTIENT_07 +#include +#endif #include #include "controller.h" @@ -943,6 +946,305 @@ void NeoChatRoom::setHistoryVisibility(const QString &historyVisibilityRule) // Not emitting historyVisibilityChanged() here, since that would override the change in the UI with the *current* value, which is not the *new* value. } +void NeoChatRoom::setUserPowerLevel(const QString &userID, const int &powerLevel) +{ + if (joinedCount() <= 1) { + qWarning() << "Cannot modify the power level of the only user"; + return; + } + if (!canSendState("m.room.power_levels")) { + qWarning() << "Power level too low to set user power levels"; + return; + } +#ifdef QUOTIENT_07 + if (!isMember(userID)) { +#else + if (memberJoinState(user(userID)) == JoinState::Join) { +#endif + qWarning() << "User is not a member of this room so power level cannot be set"; + return; + } + int clampPowerLevel = std::clamp(powerLevel, 0, 100); + +#ifdef QUOTIENT_07 + auto powerLevelContent = currentState().get("m.room.power_levels")->contentJson(); +#else + auto powerLevelContent = getCurrentState()->contentJson(); +#endif + auto powerLevelUserOverrides = powerLevelContent["users"].toObject(); + + if (powerLevelUserOverrides[userID] != clampPowerLevel) { + powerLevelUserOverrides[userID] = clampPowerLevel; + powerLevelContent["users"] = powerLevelUserOverrides; + +#ifdef QUOTIENT_07 + setState("m.room.power_levels", "", powerLevelContent); +#else + setState(QJsonObject{{"type", "m.room.power_levels"}, {"state_key", ""}, {"content", powerLevelContent}}); +#endif + } +} + +int NeoChatRoom::powerLevel(const QString &eventName, const bool &isStateEvent) const +{ +#ifdef QUOTIENT_07 + const auto powerLevelEvent = currentState().get(); +#else + const auto powerLevelEvent = getCurrentState(); +#endif + if (eventName == "ban") { + return powerLevelEvent->ban(); + } else if (eventName == "kick") { + return powerLevelEvent->kick(); + } else if (eventName == "invite") { + return powerLevelEvent->invite(); + } else if (eventName == "redact") { + return powerLevelEvent->redact(); + } else if (eventName == "users_default") { + return powerLevelEvent->usersDefault(); + } else if (eventName == "state_default") { + return powerLevelEvent->stateDefault(); + } else if (eventName == "events_default") { + return powerLevelEvent->eventsDefault(); + } else if (isStateEvent) { + return powerLevelEvent->powerLevelForState(eventName); + } else { + return powerLevelEvent->powerLevelForEvent(eventName); + } +} + +void NeoChatRoom::setPowerLevel(const QString &eventName, const int &newPowerLevel, const bool &isStateEvent) +{ +#ifdef QUOTIENT_07 + auto powerLevelContent = currentState().get("m.room.power_levels")->contentJson(); +#else + auto powerLevelContent = getCurrentState()->contentJson(); +#endif + int clampPowerLevel = std::clamp(newPowerLevel, 0, 100); + int powerLevel = 0; + + if (powerLevelContent.contains(eventName)) { + powerLevel = powerLevelContent[eventName].toInt(); + + if (powerLevel != clampPowerLevel) { + powerLevelContent[eventName] = clampPowerLevel; + } + } else { + auto eventPowerLevels = powerLevelContent["events"].toObject(); + + if (eventPowerLevels.contains(eventName)) { + powerLevel = eventPowerLevels[eventName].toInt(); + } else { + if (isStateEvent) { + powerLevel = powerLevelContent["state_default"].toInt(); + } else { + powerLevel = powerLevelContent["events_default"].toInt(); + } + } + + if (powerLevel != clampPowerLevel) { + eventPowerLevels[eventName] = clampPowerLevel; + powerLevelContent["events"] = eventPowerLevels; + } + } + +#ifdef QUOTIENT_07 + setState("m.room.power_levels", "", powerLevelContent); +#else + setState(QJsonObject{{"type", "m.room.power_levels"}, {"state_key", ""}, {"content", powerLevelContent}}); +#endif +} + +int NeoChatRoom::defaultUserPowerLevel() const +{ + return powerLevel("users_default"); +} + +void NeoChatRoom::setDefaultUserPowerLevel(const int &newPowerLevel) +{ + setPowerLevel("users_default", newPowerLevel); +} + +int NeoChatRoom::invitePowerLevel() const +{ + return powerLevel("invite"); +} + +void NeoChatRoom::setInvitePowerLevel(const int &newPowerLevel) +{ + setPowerLevel("invite", newPowerLevel); +} + +int NeoChatRoom::kickPowerLevel() const +{ + return powerLevel("kick"); +} + +void NeoChatRoom::setKickPowerLevel(const int &newPowerLevel) +{ + setPowerLevel("kick", newPowerLevel); +} + +int NeoChatRoom::banPowerLevel() const +{ + return powerLevel("ban"); +} + +void NeoChatRoom::setBanPowerLevel(const int &newPowerLevel) +{ + setPowerLevel("ban", newPowerLevel); +} + +int NeoChatRoom::redactPowerLevel() const +{ + return powerLevel("redact"); +} + +void NeoChatRoom::setRedactPowerLevel(const int &newPowerLevel) +{ + setPowerLevel("redact", newPowerLevel); +} + +int NeoChatRoom::statePowerLevel() const +{ + return powerLevel("state_default"); +} + +void NeoChatRoom::setStatePowerLevel(const int &newPowerLevel) +{ + setPowerLevel("state_default", newPowerLevel); +} + +int NeoChatRoom::defaultEventPowerLevel() const +{ + return powerLevel("events_default"); +} + +void NeoChatRoom::setDefaultEventPowerLevel(const int &newPowerLevel) +{ + setPowerLevel("events_default", newPowerLevel); +} + +int NeoChatRoom::powerLevelPowerLevel() const +{ + return powerLevel("m.room.power_levels", true); +} + +void NeoChatRoom::setPowerLevelPowerLevel(const int &newPowerLevel) +{ + setPowerLevel("m.room.power_levels", newPowerLevel, true); +} + +int NeoChatRoom::namePowerLevel() const +{ + return powerLevel("m.room.name", true); +} + +void NeoChatRoom::setNamePowerLevel(const int &newPowerLevel) +{ + setPowerLevel("m.room.name", newPowerLevel, true); +} + +int NeoChatRoom::avatarPowerLevel() const +{ + return powerLevel("m.room.avatar", true); +} + +void NeoChatRoom::setAvatarPowerLevel(const int &newPowerLevel) +{ + setPowerLevel("m.room.avatar", newPowerLevel, true); +} + +int NeoChatRoom::canonicalAliasPowerLevel() const +{ + return powerLevel("m.room.canonical_alias", true); +} + +void NeoChatRoom::setCanonicalAliasPowerLevel(const int &newPowerLevel) +{ + setPowerLevel("m.room.canonical_alias", newPowerLevel, true); +} + +int NeoChatRoom::topicPowerLevel() const +{ + return powerLevel("m.room.topic", true); +} + +void NeoChatRoom::setTopicPowerLevel(const int &newPowerLevel) +{ + setPowerLevel("m.room.topic", newPowerLevel, true); +} + +int NeoChatRoom::encryptionPowerLevel() const +{ + return powerLevel("m.room.encryption", true); +} + +void NeoChatRoom::setEncryptionPowerLevel(const int &newPowerLevel) +{ + setPowerLevel("m.room.encryption", newPowerLevel, true); +} + +int NeoChatRoom::historyVisibilityPowerLevel() const +{ + return powerLevel("m.room.history_visibility", true); +} + +void NeoChatRoom::setHistoryVisibilityPowerLevel(const int &newPowerLevel) +{ + setPowerLevel("m.room.history_visibility", newPowerLevel, true); +} + +int NeoChatRoom::pinnedEventsPowerLevel() const +{ + return powerLevel("m.room.pinned_events", true); +} + +void NeoChatRoom::setPinnedEventsPowerLevel(const int &newPowerLevel) +{ + setPowerLevel("m.room.pinned_events", newPowerLevel, true); +} + +int NeoChatRoom::tombstonePowerLevel() const +{ + return powerLevel("m.room.tombstone", true); +} + +void NeoChatRoom::setTombstonePowerLevel(const int &newPowerLevel) +{ + setPowerLevel("m.room.tombstone", newPowerLevel, true); +} + +int NeoChatRoom::serverAclPowerLevel() const +{ + return powerLevel("m.room.server_acl", true); +} + +void NeoChatRoom::setServerAclPowerLevel(const int &newPowerLevel) +{ + setPowerLevel("m.room.server_acl", newPowerLevel, true); +} + +int NeoChatRoom::spaceChildPowerLevel() const +{ + return powerLevel("m.space.child", true); +} + +void NeoChatRoom::setSpaceChildPowerLevel(const int &newPowerLevel) +{ + setPowerLevel("m.space.child", newPowerLevel, true); +} + +int NeoChatRoom::spaceParentPowerLevel() const +{ + return powerLevel("m.space.parent", true); +} + +void NeoChatRoom::setSpaceParentPowerLevel(const int &newPowerLevel) +{ + setPowerLevel("m.space.parent", newPowerLevel, true); +} + QCoro::Task NeoChatRoom::doDeleteMessagesByUser(const QString &user, QString reason) { QStringList events; diff --git a/src/neochatroom.h b/src/neochatroom.h index 2092af15a..7c408c655 100644 --- a/src/neochatroom.h +++ b/src/neochatroom.h @@ -47,9 +47,32 @@ class NeoChatRoom : public Quotient::Room Q_PROPERTY(QString avatarMediaId READ avatarMediaId NOTIFY avatarChanged STORED false) Q_PROPERTY(bool readMarkerLoaded READ readMarkerLoaded NOTIFY readMarkerLoadedChanged) Q_PROPERTY(QDateTime lastActiveTime READ lastActiveTime NOTIFY lastActiveTimeChanged) + Q_PROPERTY(bool isSpace READ isSpace CONSTANT) Q_PROPERTY(bool isInvite READ isInvite NOTIFY isInviteChanged) Q_PROPERTY(QString joinRule READ joinRule WRITE setJoinRule NOTIFY joinRuleChanged) Q_PROPERTY(QString historyVisibility READ historyVisibility WRITE setHistoryVisibility NOTIFY historyVisibilityChanged) + + // Properties for the various permission levels for the room + Q_PROPERTY(int defaultUserPowerLevel READ defaultUserPowerLevel WRITE setDefaultUserPowerLevel NOTIFY defaultUserPowerLevelChanged) + Q_PROPERTY(int invitePowerLevel READ invitePowerLevel WRITE setInvitePowerLevel NOTIFY invitePowerLevelChanged) + Q_PROPERTY(int kickPowerLevel READ kickPowerLevel WRITE setKickPowerLevel NOTIFY kickPowerLevelChanged) + Q_PROPERTY(int banPowerLevel READ banPowerLevel WRITE setBanPowerLevel NOTIFY banPowerLevelChanged) + Q_PROPERTY(int redactPowerLevel READ redactPowerLevel WRITE setRedactPowerLevel NOTIFY redactPowerLevelChanged) + Q_PROPERTY(int statePowerLevel READ statePowerLevel WRITE setStatePowerLevel NOTIFY statePowerLevelChanged) + Q_PROPERTY(int defaultEventPowerLevel READ defaultEventPowerLevel WRITE setDefaultEventPowerLevel NOTIFY defaultEventPowerLevelChanged) + Q_PROPERTY(int powerLevelPowerLevel READ powerLevelPowerLevel WRITE setPowerLevelPowerLevel NOTIFY powerLevelPowerLevelChanged) + Q_PROPERTY(int namePowerLevel READ namePowerLevel WRITE setNamePowerLevel NOTIFY namePowerLevelChanged) + Q_PROPERTY(int avatarPowerLevel READ avatarPowerLevel WRITE setAvatarPowerLevel NOTIFY avatarPowerLevelChanged) + Q_PROPERTY(int canonicalAliasPowerLevel READ canonicalAliasPowerLevel WRITE setCanonicalAliasPowerLevel NOTIFY canonicalAliasPowerLevelChanged) + Q_PROPERTY(int topicPowerLevel READ topicPowerLevel WRITE setTopicPowerLevel NOTIFY topicPowerLevelChanged) + Q_PROPERTY(int encryptionPowerLevel READ encryptionPowerLevel WRITE setEncryptionPowerLevel NOTIFY encryptionPowerLevelChanged) + Q_PROPERTY(int historyVisibilityPowerLevel READ historyVisibilityPowerLevel WRITE setHistoryVisibilityPowerLevel NOTIFY historyVisibilityPowerLevelChanged) + Q_PROPERTY(int pinnedEventsPowerLevel READ pinnedEventsPowerLevel WRITE setPinnedEventsPowerLevel NOTIFY pinnedEventsPowerLevelChanged) + Q_PROPERTY(int tombstonePowerLevel READ tombstonePowerLevel WRITE setTombstonePowerLevel NOTIFY tombstonePowerLevelChanged) + Q_PROPERTY(int serverAclPowerLevel READ serverAclPowerLevel WRITE setServerAclPowerLevel NOTIFY serverAclPowerLevelChanged) + Q_PROPERTY(int spaceChildPowerLevel READ spaceChildPowerLevel WRITE setSpaceChildPowerLevel NOTIFY spaceChildPowerLevelChanged) + Q_PROPERTY(int spaceParentPowerLevel READ spaceParentPowerLevel WRITE setSpaceParentPowerLevel NOTIFY spaceParentPowerLevelChanged) + Q_PROPERTY(QString htmlSafeDisplayName READ htmlSafeDisplayName NOTIFY displayNameChanged) Q_PROPERTY(PushNotificationState::State pushNotificationState MEMBER m_currentPushNotificationState WRITE setPushNotificationState NOTIFY pushNotificationStateChanged) @@ -124,6 +147,68 @@ public: [[nodiscard]] QString historyVisibility() const; void setHistoryVisibility(const QString &historyVisibilityRule); + Q_INVOKABLE void setUserPowerLevel(const QString &userID, const int &powerLevel); + + [[nodiscard]] int powerLevel(const QString &eventName, const bool &isStateEvent = false) const; + void setPowerLevel(const QString &eventName, const int &newPowerLevel, const bool &isStateEvent = false); + + [[nodiscard]] int defaultUserPowerLevel() const; + void setDefaultUserPowerLevel(const int &newPowerLevel); + + [[nodiscard]] int invitePowerLevel() const; + void setInvitePowerLevel(const int &newPowerLevel); + + [[nodiscard]] int kickPowerLevel() const; + void setKickPowerLevel(const int &newPowerLevel); + + [[nodiscard]] int banPowerLevel() const; + void setBanPowerLevel(const int &newPowerLevel); + + [[nodiscard]] int redactPowerLevel() const; + void setRedactPowerLevel(const int &newPowerLevel); + + [[nodiscard]] int statePowerLevel() const; + void setStatePowerLevel(const int &newPowerLevel); + + [[nodiscard]] int defaultEventPowerLevel() const; + void setDefaultEventPowerLevel(const int &newPowerLevel); + + [[nodiscard]] int powerLevelPowerLevel() const; + void setPowerLevelPowerLevel(const int &newPowerLevel); + + [[nodiscard]] int namePowerLevel() const; + void setNamePowerLevel(const int &newPowerLevel); + + [[nodiscard]] int avatarPowerLevel() const; + void setAvatarPowerLevel(const int &newPowerLevel); + + [[nodiscard]] int canonicalAliasPowerLevel() const; + void setCanonicalAliasPowerLevel(const int &newPowerLevel); + + [[nodiscard]] int topicPowerLevel() const; + void setTopicPowerLevel(const int &newPowerLevel); + + [[nodiscard]] int encryptionPowerLevel() const; + void setEncryptionPowerLevel(const int &newPowerLevel); + + [[nodiscard]] int historyVisibilityPowerLevel() const; + void setHistoryVisibilityPowerLevel(const int &newPowerLevel); + + [[nodiscard]] int pinnedEventsPowerLevel() const; + void setPinnedEventsPowerLevel(const int &newPowerLevel); + + [[nodiscard]] int tombstonePowerLevel() const; + void setTombstonePowerLevel(const int &newPowerLevel); + + [[nodiscard]] int serverAclPowerLevel() const; + void setServerAclPowerLevel(const int &newPowerLevel); + + [[nodiscard]] int spaceChildPowerLevel() const; + void setSpaceChildPowerLevel(const int &newPowerLevel); + + [[nodiscard]] int spaceParentPowerLevel() const; + void setSpaceParentPowerLevel(const int &newPowerLevel); + [[nodiscard]] bool hasFileUploading() const { return m_hasFileUploading; @@ -284,6 +369,25 @@ Q_SIGNALS: void joinRuleChanged(); void historyVisibilityChanged(); void maxRoomVersionChanged(); + void defaultUserPowerLevelChanged(); + void invitePowerLevelChanged(); + void kickPowerLevelChanged(); + void banPowerLevelChanged(); + void redactPowerLevelChanged(); + void statePowerLevelChanged(); + void defaultEventPowerLevelChanged(); + void powerLevelPowerLevelChanged(); + void namePowerLevelChanged(); + void avatarPowerLevelChanged(); + void canonicalAliasPowerLevelChanged(); + void topicPowerLevelChanged(); + void encryptionPowerLevelChanged(); + void historyVisibilityPowerLevelChanged(); + void pinnedEventsPowerLevelChanged(); + void tombstonePowerLevelChanged(); + void serverAclPowerLevelChanged(); + void spaceChildPowerLevelChanged(); + void spaceParentPowerLevelChanged(); public Q_SLOTS: void uploadFile(const QUrl &url, const QString &body = QString()); diff --git a/src/qml/RoomSettings/Categories.qml b/src/qml/RoomSettings/Categories.qml index 6148fdd0a..fb7208bfb 100644 --- a/src/qml/RoomSettings/Categories.qml +++ b/src/qml/RoomSettings/Categories.qml @@ -31,6 +31,16 @@ Kirigami.CategorizedSettings { } } }, + Kirigami.SettingAction { + text: i18n("Permissions") + icon.name: "visibility" + page: Qt.resolvedUrl("Permissions.qml") + initialProperties: { + return { + room: root.room + } + } + }, Kirigami.SettingAction { text: i18n("Notifications") icon.name: "notifications" diff --git a/src/qml/RoomSettings/Permissions.qml b/src/qml/RoomSettings/Permissions.qml new file mode 100644 index 000000000..05b9eb9ac --- /dev/null +++ b/src/qml/RoomSettings/Permissions.qml @@ -0,0 +1,453 @@ +// SPDX-FileCopyrightText: 2022 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +import QtQuick 2.15 +import QtQuick.Controls 2.15 as QQC2 +import QtQuick.Layouts 1.15 + +import org.kde.kirigami 2.15 as Kirigami +import org.kde.kirigamiaddons.labs.mobileform 0.1 as MobileForm +import org.kde.kitemmodels 1.0 + +import org.kde.neochat 1.0 + +Kirigami.ScrollablePage { + id: root + + property var room + + title: i18nc('@title:window', 'Permissions') + + leftPadding: 0 + rightPadding: 0 + + UserListModel { + id: userListModel + room: root.room + } + + ListModel { + id: powerLevelModel + ListElement {text: "Member (0)"; powerLevel: 0} + ListElement {text: "Moderator (50)"; powerLevel: 50} + ListElement {text: "Admin (100)"; powerLevel: 100} + } + + ColumnLayout { + MobileForm.FormCard { + Layout.fillWidth: true + contentItem: ColumnLayout { + spacing: 0 + MobileForm.FormCardHeader { + title: i18n("Privileged Users") + } + Repeater { + model: KSortFilterProxyModel { + sourceModel: userListModel + sortRole: "perm" + filterRowCallback: function(source_row, source_parent) { + let permRole = sourceModel.data(sourceModel.index(source_row, 0, source_parent), Qt.UserRole + 5) + return permRole != UserType.Muted && permRole != UserType.Member; + } + } + delegate: MobileForm.FormTextDelegate { + text: name + description: userId + contentItem.children: RowLayout { + spacing: Kirigami.Units.largeSpacing + QQC2.Label { + visible: !room.canSendState("m.room.power_levels") + text: { + switch (perm) { + case UserType.Owner: + return i18n("Owner"); + case UserType.Admin: + return i18n("Admin"); + case UserType.Moderator: + return i18n("Mod"); + case UserType.Muted: + return i18n("Muted"); + case UserType.Member: + return i18n("Member"); + default: + return "" + } + } + color: Kirigami.Theme.disabledTextColor + } + QQC2.ComboBox { + focusPolicy: Qt.NoFocus // provided by parent + model: powerLevelModel + textRole: "text" + valueRole: "powerLevel" + visible: room.canSendState("m.room.power_levels") + Component.onCompleted: currentIndex = indexOfValue(powerLevel) + onActivated: { + room.setUserPowerLevel(userId, currentValue) + } + } + } + } + } + MobileForm.FormDelegateSeparator { below: userListSearchCard } + MobileForm.AbstractFormDelegate { + id: userListSearchCard + Layout.fillWidth: true + visible: room.canSendState("m.room.power_levels") + + contentItem: Kirigami.SearchField { + id: userListSearchField + Layout.fillWidth: true + autoAccept: false + + Keys.onUpPressed: userListView.decrementCurrentIndex() + Keys.onDownPressed: userListView.incrementCurrentIndex() + + onAccepted: { + let currentUser = userListView.itemAtIndex(userListView.currentIndex); + currentUser.action.trigger(); + } + } + QQC2.Popup { + id: userListSearchPopup + + x: userListSearchField.x + y: userListSearchField.y - height + width: userListSearchField.width + height: { + let maxHeight = userListSearchField.mapToGlobal(userListSearchField.x, userListSearchField.y).y - Kirigami.Units.largeSpacing * 3; + let minHeight = Kirigami.Units.gridUnit * 2 + userListSearchPopup.padding * 2; + let filterContentHeight = userListView.contentHeight + userListSearchPopup.padding * 2; + + return Math.max(Math.min(filterContentHeight, maxHeight), minHeight); + } + padding: Kirigami.Units.smallSpacing + modal: false + onClosed: userListSearchField.text = "" + + background: Kirigami.ShadowedRectangle { + radius: 4 + color: Kirigami.Theme.backgroundColor + + property color borderColor: Kirigami.Theme.textColor + border.color: Qt.rgba(borderColor.r, borderColor.g, borderColor.b, 0.3) + border.width: 1 + + shadow.xOffset: 0 + shadow.yOffset: 4 + shadow.color: Qt.rgba(0, 0, 0, 0.3) + shadow.size: 8 + } + + contentItem: QQC2.ScrollView { + // HACK: Hide unnecessary horizontal scrollbar (https://bugreports.qt.io/browse/QTBUG-83890) + QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff + + ListView { + id: userListView + clip: true + + model: UserFilterModel { + id: userListFilterModel + sourceModel: userListModel + filterText: userListSearchField.text + + onFilterTextChanged: { + if (filterText.length > 0 && !userListSearchPopup.visible) { + userListSearchPopup.open() + } else if (filterText.length <= 0 && userListSearchPopup.visible) { + userListSearchPopup.close() + } + } + } + + delegate: Kirigami.BasicListItem { + id: userListItem + + implicitHeight: Kirigami.Units.gridUnit * 2 + leftPadding: Kirigami.Units.largeSpacing + Kirigami.Units.smallSpacing + + label: name + labelItem.textFormat: Text.PlainText + subtitle: userId + subtitleItem.textFormat: Text.PlainText + + action: Kirigami.Action { + id: editPowerLevelAction + onTriggered: { + userListSearchPopup.close() + powerLevelSheet.userId = userId + powerLevelSheet.powerLevel = powerLevel + powerLevelSheet.open() + } + } + + leading: Kirigami.Avatar { + implicitWidth: height + sourceSize.height: Kirigami.Units.gridUnit + Kirigami.Units.smallSpacing * 2.5 + sourceSize.width: Kirigami.Units.gridUnit + Kirigami.Units.smallSpacing * 2.5 + source: avatar ? ("image://mxc/" + avatar) : "" + name: model.userId + } + + trailing: QQC2.Label { + visible: perm != UserType.Member + + text: { + switch (perm) { + case UserType.Owner: + return i18n("Owner"); + case UserType.Admin: + return i18n("Admin"); + case UserType.Moderator: + return i18n("Mod"); + case UserType.Muted: + return i18n("Muted"); + default: + return ""; + } + } + color: Kirigami.Theme.disabledTextColor + textFormat: Text.PlainText + wrapMode: Text.NoWrap + } + } + } + } + } + } + } + } + MobileForm.FormCard { + Layout.fillWidth: true + Layout.topMargin: Kirigami.Units.largeSpacing + visible: room.canSendState("m.room.power_levels") + contentItem: ColumnLayout { + spacing: 0 + MobileForm.FormCardHeader { + title: i18n("Default permissions") + } + MobileForm.FormComboBoxDelegate { + text: i18n("Default user power level") + description: i18n("This is power level for all new users when joining the room") + textRole: "text" + valueRole: "powerLevel" + model: powerLevelModel + Component.onCompleted: currentIndex = indexOfValue(room.defaultUserPowerLevel) + onCurrentValueChanged: if(room.canSendState("m.room.power_levels")) room.defaultUserPowerLevel = currentValue + } + MobileForm.FormComboBoxDelegate { + text: i18n("Default power level to set the room state") + description: i18n("This is used for all state events that do not have their own entry here") + textRole: "text" + valueRole: "powerLevel" + model: powerLevelModel + Component.onCompleted: currentIndex = indexOfValue(room.statePowerLevel) + onCurrentValueChanged: if(room.canSendState("m.room.power_levels")) room.statePowerLevel = currentValue + } + MobileForm.FormComboBoxDelegate { + text: i18n("Default power level to send messages") + description: i18n("This is used for all message events that do not have their own entry here") + textRole: "text" + valueRole: "powerLevel" + model: powerLevelModel + Component.onCompleted: currentIndex = indexOfValue(room.defaultEventPowerLevel) + onCurrentValueChanged: if(room.canSendState("m.room.power_levels")) room.defaultEventPowerLevel = currentValue + } + } + } + MobileForm.FormCard { + Layout.fillWidth: true + Layout.topMargin: Kirigami.Units.largeSpacing + visible: room.canSendState("m.room.power_levels") + contentItem: ColumnLayout { + spacing: 0 + MobileForm.FormCardHeader { + title: i18n("Basic permissions") + } + MobileForm.FormComboBoxDelegate { + text: i18n("Invite users") + textRole: "text" + valueRole: "powerLevel" + model: powerLevelModel + Component.onCompleted: currentIndex = indexOfValue(room.invitePowerLevel) + onCurrentValueChanged: if(room.canSendState("m.room.power_levels")) room.invitePowerLevel = currentValue + } + MobileForm.FormComboBoxDelegate { + text: i18n("Kick users") + textRole: "text" + valueRole: "powerLevel" + model: powerLevelModel + Component.onCompleted: currentIndex = indexOfValue(room.kickPowerLevel) + onCurrentValueChanged: if(room.canSendState("m.room.power_levels")) room.kickPowerLevel = currentValue + } + MobileForm.FormComboBoxDelegate { + text: i18n("Ban users") + textRole: "text" + valueRole: "powerLevel" + model: powerLevelModel + Component.onCompleted: currentIndex = indexOfValue(room.banPowerLevel) + onCurrentValueChanged: if(room.canSendState("m.room.power_levels")) room.banPowerLevel = currentValue + } + MobileForm.FormComboBoxDelegate { + text: i18n("Remove message sent by other users") + textRole: "text" + valueRole: "powerLevel" + model: powerLevelModel + Component.onCompleted: currentIndex = indexOfValue(room.redactPowerLevel) + onCurrentValueChanged: if(room.canSendState("m.room.power_levels")) room.redactPowerLevel = currentValue + } + } + } + MobileForm.FormCard { + Layout.fillWidth: true + Layout.topMargin: Kirigami.Units.largeSpacing + visible: room.canSendState("m.room.power_levels") + contentItem: ColumnLayout { + spacing: 0 + MobileForm.FormCardHeader { + title: i18n("Event permissions") + } + MobileForm.FormComboBoxDelegate { + text: i18n("Change user permissions") + description: "m.room.power_levels" + textRole: "text" + valueRole: "powerLevel" + model: powerLevelModel + Component.onCompleted: currentIndex = indexOfValue(room.powerLevelPowerLevel) + onCurrentValueChanged: if(room.canSendState("m.room.power_levels")) room.powerLevelPowerLevel = currentValue + } + MobileForm.FormComboBoxDelegate { + text: i18n("Change the room name") + description: "m.room.name" + textRole: "text" + valueRole: "powerLevel" + model: powerLevelModel + Component.onCompleted: currentIndex = indexOfValue(room.namePowerLevel) + onCurrentValueChanged: if(room.canSendState("m.room.power_levels")) room.namePowerLevel = currentValue + } + MobileForm.FormComboBoxDelegate { + text: i18n("Change the room avatar") + description: "m.room.avatar" + textRole: "text" + valueRole: "powerLevel" + model: powerLevelModel + Component.onCompleted: currentIndex = indexOfValue(room.avatarPowerLevel) + onCurrentValueChanged: if(room.canSendState("m.room.power_levels")) room.avatarPowerLevel = currentValue + } + MobileForm.FormComboBoxDelegate { + text: i18n("Change the room canonical alias") + description: "m.room.canonical_alias" + textRole: "text" + valueRole: "powerLevel" + model: powerLevelModel + Component.onCompleted: currentIndex = indexOfValue(room.canonicalAliasPowerLevel) + onCurrentValueChanged: if(room.canSendState("m.room.power_levels")) room.canonicalAliasPowerLevel = currentValue + } + MobileForm.FormComboBoxDelegate { + text: i18n("Change the room topic") + description: "m.room.topic" + textRole: "text" + valueRole: "powerLevel" + model: powerLevelModel + Component.onCompleted: currentIndex = indexOfValue(room.topicPowerLevel) + onCurrentValueChanged: if(room.canSendState("m.room.power_levels")) room.topicPowerLevel = currentValue + } + MobileForm.FormComboBoxDelegate { + text: i18n("Enable encryption for the room") + description: "m.room.encryption" + textRole: "text" + valueRole: "powerLevel" + model: powerLevelModel + Component.onCompleted: currentIndex = indexOfValue(room.encryptionPowerLevel) + onCurrentValueChanged: if(room.canSendState("m.room.power_levels")) room.encryptionPowerLevel = currentValue + } + MobileForm.FormComboBoxDelegate { + text: i18n("Change the room history visibility") + description: "m.room.history_visibility" + textRole: "text" + valueRole: "powerLevel" + model: powerLevelModel + Component.onCompleted: currentIndex = indexOfValue(room.historyVisibilityPowerLevel) + onCurrentValueChanged: if(room.canSendState("m.room.power_levels")) room.historyVisibilityPowerLevel = currentValue + } + MobileForm.FormComboBoxDelegate { + text: i18n("Set pinned events") + description: "m.room.pinned_events" + textRole: "text" + valueRole: "powerLevel" + model: powerLevelModel + Component.onCompleted: currentIndex = indexOfValue(room.pinnedEventsPowerLevel) + onCurrentValueChanged: if(room.canSendState("m.room.power_levels")) room.pinnedEventsPowerLevel = currentValue + } + MobileForm.FormComboBoxDelegate { + text: i18n("Upgrade the room") + description: "m.room.tombstone" + textRole: "text" + valueRole: "powerLevel" + model: powerLevelModel + Component.onCompleted: currentIndex = indexOfValue(room.tombstonePowerLevel) + onCurrentValueChanged: if(room.canSendState("m.room.power_levels")) room.tombstonePowerLevel = currentValue + } + MobileForm.FormComboBoxDelegate { + text: i18n("Set the room server access control list (ACL)") + description: "m.room.server_acl" + textRole: "text" + valueRole: "powerLevel" + model: powerLevelModel + Component.onCompleted: currentIndex = indexOfValue(room.serverAclPowerLevel) + onCurrentValueChanged: if(room.canSendState("m.room.power_levels")) room.serverAclPowerLevel = currentValue + } + MobileForm.FormComboBoxDelegate { + visible: room.isSpace + text: i18n("Set the children of this space") + description: "m.space.child" + textRole: "text" + valueRole: "powerLevel" + model: powerLevelModel + Component.onCompleted: currentIndex = indexOfValue(room.spaceChildPowerLevel) + onCurrentValueChanged: if(room.canSendState("m.room.power_levels")) room.spaceChildPowerLevel = currentValue + } + MobileForm.FormComboBoxDelegate { + text: i18n("Set the parent space of this room") + description: "m.space.parent" + textRole: "text" + valueRole: "powerLevel" + model: powerLevelModel + Component.onCompleted: currentIndex = indexOfValue(room.spaceChildPowerLevel) + onCurrentValueChanged: if(room.canSendState("m.room.power_levels")) room.spaceParentPowerLevel = currentValue + } + } + } + } + Kirigami.OverlaySheet { + id: powerLevelSheet + title: i18n("Edit user power level") + + property var userId + property int powerLevel + + onSheetOpenChanged: { + if (sheetOpen) { + powerLevelComboBox.currentIndex = powerLevelComboBox.indexOfValue(powerLevelSheet.powerLevel) + } + } + Kirigami.FormLayout { + QQC2.ComboBox { + id: powerLevelComboBox + focusPolicy: Qt.NoFocus // provided by parent + model: powerLevelModel + textRole: "text" + valueRole: "powerLevel" + visible: room.canSendState("m.room.power_levels") + } + QQC2.Button { + text: i18n("Confirm") + onClicked: { + room.setUserPowerLevel(powerLevelSheet.userId, powerLevelComboBox.currentValue) + powerLevelSheet.close() + } + } + } + } +} diff --git a/src/res.qrc b/src/res.qrc index 0bcaf6bbd..05dcddac0 100644 --- a/src/res.qrc +++ b/src/res.qrc @@ -16,6 +16,7 @@ qml/RoomSettings/Security.qml qml/RoomSettings/PushNotification.qml qml/RoomSettings/Categories.qml + qml/RoomSettings/Permissions.qml qml/Component/FullScreenImage.qml qml/Component/UserInfo.qml qml/Component/FancyEffectsContainer.qml diff --git a/src/userfiltermodel.cpp b/src/userfiltermodel.cpp new file mode 100644 index 000000000..042c4cf42 --- /dev/null +++ b/src/userfiltermodel.cpp @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2022 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#include "userfiltermodel.h" + +#include "userlistmodel.h" + +bool UserFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const +{ + Q_UNUSED(sourceParent); + if (m_filterText.length() < 1) { + return false; + } + return sourceModel()->data(sourceModel()->index(sourceRow, 0), UserListModel::NameRole).toString().contains(m_filterText, Qt::CaseInsensitive) + || sourceModel()->data(sourceModel()->index(sourceRow, 0), UserListModel::UserIdRole).toString().contains(m_filterText, Qt::CaseInsensitive); +} + +QString UserFilterModel::filterText() const +{ + return m_filterText; +} + +void UserFilterModel::setFilterText(const QString &filterText) +{ + m_filterText = filterText; + Q_EMIT filterTextChanged(); + invalidateFilter(); +} diff --git a/src/userfiltermodel.h b/src/userfiltermodel.h new file mode 100644 index 000000000..a54b26dc1 --- /dev/null +++ b/src/userfiltermodel.h @@ -0,0 +1,42 @@ +// SPDX-FileCopyrightText: 2022 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#pragma once + +#include + +/** + * @class UserFilterModel + * + * This class creates a custom QSortFilterProxyModel for filtering a users by either + * display name or matrix ID. The filter can accept a full matrix id i.e. example:kde.org + * to separate between accounts on different servers with similar names. + */ +class UserFilterModel : public QSortFilterProxyModel +{ + Q_OBJECT + + /** + * @brief This property hold the text of the filter. + * + * The text is either a desired display name or matrix id. + */ + Q_PROPERTY(QString filterText READ filterText WRITE setFilterText NOTIFY filterTextChanged) + +public: + /** + * @brief Custom filter function checking boith the display name and matrix ID. + * + * @note The filter cannot be modified and will always use the same filter properties. + */ + bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override; + + QString filterText() const; + void setFilterText(const QString &filterText); + +Q_SIGNALS: + void filterTextChanged(); + +private: + QString m_filterText; +}; diff --git a/src/userlistmodel.cpp b/src/userlistmodel.cpp index 46ef68784..fa436d50f 100644 --- a/src/userlistmodel.cpp +++ b/src/userlistmodel.cpp @@ -38,6 +38,7 @@ void UserListModel::setRoom(NeoChatRoom *room) connect(m_currentRoom, &Room::userRemoved, this, &UserListModel::userRemoved); connect(m_currentRoom, &Room::memberAboutToRename, this, &UserListModel::userRemoved); connect(m_currentRoom, &Room::memberRenamed, this, &UserListModel::userAdded); + connect(m_currentRoom, &Room::changed, this, &UserListModel::refreshAll); { m_users = m_currentRoom->users(); std::sort(m_users.begin(), m_users.end(), room->memberSorter()); @@ -132,6 +133,10 @@ QVariant UserListModel::data(const QModelIndex &index, int role) const return UserType::Member; } + if (role == PowerLevelRole) { + auto pl = m_currentRoom->getCurrentState(); + return pl->powerLevelForUser(user->id()); + } return {}; } @@ -183,6 +188,34 @@ void UserListModel::refresh(Quotient::User *user, const QVector &roles) } } +void UserListModel::refreshAll() +{ + beginResetModel(); + for (User *user : std::as_const(m_users)) { + user->disconnect(this); + } + m_users.clear(); + + { + m_users = m_currentRoom->users(); + std::sort(m_users.begin(), m_users.end(), m_currentRoom->memberSorter()); + } + for (User *user : std::as_const(m_users)) { +#ifdef QUOTIENT_07 + connect(user, &User::defaultAvatarChanged, this, [this, user]() { + avatarChanged(user, m_currentRoom); + }); +#else + connect(user, &User::avatarChanged, this, &UserListModel::avatarChanged); +#endif + } + connect(m_currentRoom->connection(), &Connection::loggedOut, this, [this]() { + setRoom(nullptr); + }); + endResetModel(); + Q_EMIT usersRefreshed(); +} + void UserListModel::avatarChanged(Quotient::User *user, const Quotient::Room *context) { if (context == m_currentRoom) { @@ -209,6 +242,7 @@ QHash UserListModel::roleNames() const roles[AvatarRole] = "avatar"; roles[ObjectRole] = "user"; roles[PermRole] = "perm"; + roles[PowerLevelRole] = "powerLevel"; return roles; } diff --git a/src/userlistmodel.h b/src/userlistmodel.h index e7a7cc877..baead6960 100644 --- a/src/userlistmodel.h +++ b/src/userlistmodel.h @@ -3,6 +3,8 @@ #pragma once +#include + #include #include @@ -40,6 +42,7 @@ public: AvatarRole, ObjectRole, PermRole, + PowerLevelRole, }; UserListModel(QObject *parent = nullptr); @@ -54,13 +57,17 @@ public: [[nodiscard]] QHash roleNames() const override; + // Q_INVOKABLE + Q_SIGNALS: void roomChanged(); + void usersRefreshed(); private Q_SLOTS: void userAdded(Quotient::User *user); void userRemoved(Quotient::User *user); void refresh(Quotient::User *user, const QVector &roles = {}); + void refreshAll(); void avatarChanged(Quotient::User *user, const Quotient::Room *context); private: