From 051c25d5b87c2351df46173f19b907cea436fa3b Mon Sep 17 00:00:00 2001 From: Nicolas Werner Date: Wed, 28 Sep 2022 02:09:04 +0200 Subject: [PATCH] Allow editing permissions in spaces recursively --- resources/qml/Root.qml | 16 ++ resources/qml/dialogs/PowerLevelEditor.qml | 11 +- .../dialogs/PowerLevelSpacesApplyDialog.qml | 148 +++++++++++ resources/res.qrc | 1 + src/PowerlevelsEditModels.cpp | 239 ++++++++++++++++-- src/PowerlevelsEditModels.h | 95 ++++++- 6 files changed, 484 insertions(+), 26 deletions(-) create mode 100644 resources/qml/dialogs/PowerLevelSpacesApplyDialog.qml diff --git a/resources/qml/Root.qml b/resources/qml/Root.qml index 063284c1..dd1dfe1e 100644 --- a/resources/qml/Root.qml +++ b/resources/qml/Root.qml @@ -78,6 +78,22 @@ Pane { destroyOnClose(dialog); } + Component { + id: plApplyPrompt + + PowerLevelSpacesApplyDialog { + } + } + + function showSpacePLApplyPrompt(settings, editingModel) { + var dialog = plApplyPrompt.createObject(timelineRoot, { + "roomSettings": settings, + "editingModel": editingModel + }); + dialog.show(); + destroyOnClose(dialog); + } + Component { id: plEditor diff --git a/resources/qml/dialogs/PowerLevelEditor.qml b/resources/qml/dialogs/PowerLevelEditor.qml index bfb337ff..4c23d9af 100644 --- a/resources/qml/dialogs/PowerLevelEditor.qml +++ b/resources/qml/dialogs/PowerLevelEditor.qml @@ -397,8 +397,15 @@ ApplicationWindow { standardButtons: DialogButtonBox.Ok | DialogButtonBox.Cancel onAccepted: { - editingModel.commit(); - plEditorW.close(); + if (editingModel.isSpace) { + // TODO(Nico): Replace with showing a list of spaces to apply to + editingModel.updateSpacesModel(); + plEditorW.close(); + timelineRoot.showSpacePLApplyPrompt(roomSettings, editingModel) + } else { + editingModel.commit(); + plEditorW.close(); + } } onRejected: plEditorW.close(); } diff --git a/resources/qml/dialogs/PowerLevelSpacesApplyDialog.qml b/resources/qml/dialogs/PowerLevelSpacesApplyDialog.qml new file mode 100644 index 00000000..83af00f7 --- /dev/null +++ b/resources/qml/dialogs/PowerLevelSpacesApplyDialog.qml @@ -0,0 +1,148 @@ +// SPDX-FileCopyrightText: 2022 Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +import ".." +import "../ui" +import Qt.labs.platform 1.1 as Platform +import QtQuick 2.15 +import QtQuick.Controls 2.3 +import QtQuick.Layouts 1.2 +import QtQuick.Window 2.13 +import im.nheko 1.0 + +ApplicationWindow { + id: applyDialog + + property RoomSettings roomSettings + property PowerlevelEditingModels editingModel + + minimumWidth: 340 + minimumHeight: 450 + width: 450 + height: 680 + palette: Nheko.colors + color: Nheko.colors.window + modality: Qt.NonModal + flags: Qt.Dialog | Qt.WindowCloseButtonHint | Qt.WindowTitleHint + title: qsTr("Apply permission changes") + + Shortcut { + sequence: StandardKey.Cancel + onActivated: roomSettingsDialog.close() + } + + ColumnLayout { + anchors.margins: Nheko.paddingMedium + anchors.fill: parent + spacing: Nheko.paddingLarge + + + MatrixText { + text: qsTr("Which of the subcommunities and rooms should these permissions be applied to?") + font.pixelSize: Math.floor(fontMetrics.font.pixelSize * 1.1) + Layout.fillWidth: true + Layout.fillHeight: false + color: Nheko.colors.text + Layout.bottomMargin: Nheko.paddingMedium + } + + GridLayout { + Layout.fillWidth: true + Layout.fillHeight: false + columns: 2 + + Label { + text: qsTr("Apply permissions recursively") + Layout.fillWidth: true + color: Nheko.colors.text + } + + ToggleButton { + checked: editingModel.spaces.applyToChildren + Layout.alignment: Qt.AlignRight + onCheckedChanged: editingModel.spaces.applyToChildren = checked + } + + Label { + text: qsTr("Overwrite exisiting modifications in rooms") + Layout.fillWidth: true + color: Nheko.colors.text + } + + ToggleButton { + checked: editingModel.spaces.overwriteDiverged + Layout.alignment: Qt.AlignRight + onCheckedChanged: editingModel.spaces.overwriteDiverged = checked + } + } + + ListView { + Layout.fillWidth: true + Layout.fillHeight: true + + id: view + + clip: true + + ScrollHelper { + flickable: parent + anchors.fill: parent + } + + model: editingModel.spaces + spacing: 4 + cacheBuffer: 50 + + delegate: RowLayout { + anchors.left: parent.left + anchors.right: parent.right + + ColumnLayout { + Layout.fillWidth: true + Text { + Layout.fillWidth: true + text: model.displayName + color: Nheko.colors.text + textFormat: Text.PlainText + elide: Text.ElideRight + } + + Text { + Layout.fillWidth: true + text: { + if (!model.isEditable) return qsTr("No permissions to apply the new permissions here"); + if (model.isAlreadyUpToDate) return qsTr("No changes needed"); + if (model.isDifferentFromBase) return qsTr("Existing modifications to the permissions in this room will be overwritten"); + return qsTr("Permissions synchronized with community") + } + elide: Text.ElideRight + color: Nheko.colors.buttonText + textFormat: Text.PlainText + } + } + + ToggleButton { + checked: model.applyPermissions + Layout.alignment: Qt.AlignRight + onCheckedChanged: model.applyPermissions = checked + enabled: model.isEditable + } + } + } + + + } + + footer: DialogButtonBox { + id: dbb + + standardButtons: DialogButtonBox.Ok | DialogButtonBox.Cancel + onAccepted: { + editingModel.spaces.commit(); + applyDialog.close(); + } + onRejected: applyDialog.close() + } + +} diff --git a/resources/res.qrc b/resources/res.qrc index 27d9c081..7affe702 100644 --- a/resources/res.qrc +++ b/resources/res.qrc @@ -162,6 +162,7 @@ qml/dialogs/LogoutDialog.qml qml/dialogs/PhoneNumberInputDialog.qml qml/dialogs/PowerLevelEditor.qml + qml/dialogs/PowerLevelSpacesApplyDialog.qml qml/dialogs/RawMessageDialog.qml qml/dialogs/ReadReceipts.qml qml/dialogs/RoomDirectory.qml diff --git a/src/PowerlevelsEditModels.cpp b/src/PowerlevelsEditModels.cpp index fcfde26e..09e5b05d 100644 --- a/src/PowerlevelsEditModels.cpp +++ b/src/PowerlevelsEditModels.cpp @@ -4,8 +4,12 @@ #include "PowerlevelsEditModels.h" +#include +#include + #include #include +#include #include "Cache.h" #include "Cache_p.h" @@ -76,7 +80,7 @@ PowerlevelsTypeListModel::PowerlevelsTypeListModel(const std::string &rid, } std::map> -PowerlevelsTypeListModel::toEvents() +PowerlevelsTypeListModel::toEvents() const { std::map> m; for (const auto &[key, pl] : qAsConst(types)) @@ -85,7 +89,7 @@ PowerlevelsTypeListModel::toEvents() return m; } mtx::events::state::power_level_t -PowerlevelsTypeListModel::kick() +PowerlevelsTypeListModel::kick() const { for (const auto &[key, pl] : qAsConst(types)) if (key == "kick") @@ -93,7 +97,7 @@ PowerlevelsTypeListModel::kick() return powerLevels_.users_default; } mtx::events::state::power_level_t -PowerlevelsTypeListModel::invite() +PowerlevelsTypeListModel::invite() const { for (const auto &[key, pl] : qAsConst(types)) if (key == "invite") @@ -101,7 +105,7 @@ PowerlevelsTypeListModel::invite() return powerLevels_.users_default; } mtx::events::state::power_level_t -PowerlevelsTypeListModel::ban() +PowerlevelsTypeListModel::ban() const { for (const auto &[key, pl] : qAsConst(types)) if (key == "ban") @@ -109,7 +113,7 @@ PowerlevelsTypeListModel::ban() return powerLevels_.users_default; } mtx::events::state::power_level_t -PowerlevelsTypeListModel::eventsDefault() +PowerlevelsTypeListModel::eventsDefault() const { for (const auto &[key, pl] : qAsConst(types)) if (key == "zdefault_events") @@ -117,7 +121,7 @@ PowerlevelsTypeListModel::eventsDefault() return powerLevels_.users_default; } mtx::events::state::power_level_t -PowerlevelsTypeListModel::stateDefault() +PowerlevelsTypeListModel::stateDefault() const { for (const auto &[key, pl] : qAsConst(types)) if (key == "zdefault_states") @@ -390,7 +394,7 @@ PowerlevelsUserListModel::PowerlevelsUserListModel(const std::string &rid, } std::map> -PowerlevelsUserListModel::toUsers() +PowerlevelsUserListModel::toUsers() const { std::map> m; for (const auto &[key, pl] : qAsConst(users)) @@ -399,7 +403,7 @@ PowerlevelsUserListModel::toUsers() return m; } mtx::events::state::power_level_t -PowerlevelsUserListModel::usersDefault() +PowerlevelsUserListModel::usersDefault() const { for (const auto &[key, pl] : qAsConst(users)) if (key == "default") @@ -565,6 +569,7 @@ PowerlevelEditingModels::PowerlevelEditingModels(QString room_id, QObject *paren .content) , types_(room_id.toStdString(), powerLevels_, this) , users_(room_id.toStdString(), powerLevels_, this) + , spaces_(room_id.toStdString(), powerLevels_, this) , room_id_(room_id.toStdString()) { connect(&types_, @@ -581,17 +586,31 @@ PowerlevelEditingModels::PowerlevelEditingModels(QString room_id, QObject *paren &PowerlevelEditingModels::defaultUserLevelChanged); } +bool +PowerlevelEditingModels::isSpace() const +{ + return cache::singleRoomInfo(room_id_).is_space; +} + +mtx::events::state::PowerLevels +PowerlevelEditingModels::calculateNewPowerlevel() const +{ + auto newPl = powerLevels_; + newPl.events = types_.toEvents(); + newPl.kick = types_.kick(); + newPl.invite = types_.invite(); + newPl.ban = types_.ban(); + newPl.events_default = types_.eventsDefault(); + newPl.state_default = types_.stateDefault(); + newPl.users = users_.toUsers(); + newPl.users_default = users_.usersDefault(); + return newPl; +} + void PowerlevelEditingModels::commit() { - powerLevels_.events = types_.toEvents(); - powerLevels_.kick = types_.kick(); - powerLevels_.invite = types_.invite(); - powerLevels_.ban = types_.ban(); - powerLevels_.events_default = types_.eventsDefault(); - powerLevels_.state_default = types_.stateDefault(); - powerLevels_.users = users_.toUsers(); - powerLevels_.users_default = users_.usersDefault(); + powerLevels_ = calculateNewPowerlevel(); http::client()->send_state_event( room_id_, powerLevels_, [](const mtx::responses::EventId &, mtx::http::RequestErr e) { @@ -604,6 +623,13 @@ PowerlevelEditingModels::commit() }); } +void +PowerlevelEditingModels::updateSpacesModel() +{ + powerLevels_ = calculateNewPowerlevel(); + spaces_.newPowerlevels_ = powerLevels_; +} + void PowerlevelEditingModels::addRole(int pl) { @@ -614,3 +640,184 @@ PowerlevelEditingModels::addRole(int pl) types_.addRole(pl); users_.addRole(pl); } + +static bool +samePl(const mtx::events::state::PowerLevels &a, const mtx::events::state::PowerLevels &b) +{ + return std::tie(a.events, + a.users_default, + a.users, + a.state_default, + a.users_default, + a.events_default, + a.ban, + a.kick, + a.invite, + a.notifications, + a.redact) == std::tie(b.events, + b.users_default, + b.users, + b.state_default, + b.users_default, + b.events_default, + b.ban, + b.kick, + b.invite, + b.notifications, + b.redact); +} + +PowerlevelsSpacesListModel::PowerlevelsSpacesListModel(const std::string &room_id_, + const mtx::events::state::PowerLevels &pl, + QObject *parent) + : QAbstractListModel(parent) + , room_id(std::move(room_id_)) + , oldPowerLevels_(std::move(pl)) +{ + beginResetModel(); + + spaces.push_back(Entry{room_id, oldPowerLevels_, true}); + + std::unordered_set visited; + + std::function addChildren; + addChildren = [this, &addChildren, &visited](const std::string &space) { + if (visited.count(space)) + return; + else + visited.insert(space); + + for (const auto &s : cache::client()->getChildRoomIds(space)) { + auto parent = + cache::client()->getStateEvent(s, space); + if (parent && parent->content.via && !parent->content.via->empty() && + parent->content.canonical) { + auto parent = cache::client()->getStateEvent(s); + + spaces.push_back( + Entry{s, parent ? parent->content : mtx::events::state::PowerLevels{}, false}); + addChildren(s); + } + } + }; + + addChildren(room_id); + + endResetModel(); + + updateToDefaults(); +} + +struct PowerLevelApplier +{ + std::vector spaces; + mtx::events::state::PowerLevels pl; + + void next() + { + if (spaces.empty()) + return; + + auto room_id_ = spaces.back(); + http::client()->send_state_event( + room_id_, + pl, + [self = *this](const mtx::responses::EventId &, mtx::http::RequestErr e) mutable { + if (e) { + if (e->status_code == 429 && e->matrix_error.retry_after.count() != 0) { + QTimer::singleShot(e->matrix_error.retry_after, + [self = std::move(self)]() mutable { self.next(); }); + return; + } + + nhlog::net()->error("Failed to send PL event: {}", *e); + ChatPage::instance()->showNotification( + QCoreApplication::translate("PowerLevels", "Failed to update powerlevel: %1") + .arg(QString::fromStdString(e->matrix_error.error))); + } + self.spaces.pop_back(); + self.next(); + }); + } +}; + +void +PowerlevelsSpacesListModel::commit() +{ + std::vector spacesToApplyTo; + + for (const auto &s : spaces) + if (s.apply) + spacesToApplyTo.push_back(s.roomid); + + PowerLevelApplier context{std::move(spacesToApplyTo), newPowerlevels_}; + context.next(); +} + +void +PowerlevelsSpacesListModel::updateToDefaults() +{ + for (int i = 1; i < spaces.size(); i++) { + spaces[i].apply = + applyToChildren_ && data(index(i), Roles::IsEditable).toBool() && + !data(index(i), Roles::IsAlreadyUpToDate).toBool() && + (overwriteDiverged_ || !data(index(i), Roles::IsDifferentFromBase).toBool()); + } + + if (spaces.size() > 1) + emit dataChanged(index(1), index(spaces.size() - 1), {Roles::ApplyPermissions}); +} + +bool +PowerlevelsSpacesListModel::setData(const QModelIndex &index, const QVariant &value, int role) +{ + if (role != Roles::ApplyPermissions || index.row() < 0 || index.row() >= spaces.size()) + return false; + + spaces[index.row()].apply = value.toBool(); + return true; +} + +QVariant +PowerlevelsSpacesListModel::data(QModelIndex const &index, int role) const +{ + auto row = index.row(); + if (row >= spaces.size() && row < 0) + return {}; + + if (role == Roles::DisplayName || role == Roles::AvatarUrl || role == Roles::IsSpace) { + auto info = cache::singleRoomInfo(spaces.at(row).roomid); + if (role == Roles::DisplayName) + return QString::fromStdString(info.name); + else if (role == Roles::AvatarUrl) + return QString::fromStdString(info.avatar_url); + else + return info.is_space; + } + + auto entry = spaces.at(row); + switch (role) { + case Roles::IsEditable: + return entry.pl.user_level(http::client()->user_id().to_string()) >= + entry.pl.state_level(to_string(mtx::events::EventType::RoomPowerLevels)); + case Roles::IsDifferentFromBase: + return !samePl(entry.pl, oldPowerLevels_); + case Roles::IsAlreadyUpToDate: + return samePl(entry.pl, newPowerlevels_); + case Roles::ApplyPermissions: + return entry.apply; + } + return {}; +} +QHash +PowerlevelsSpacesListModel::roleNames() const +{ + return { + {DisplayName, "displayName"}, + {AvatarUrl, "avatarUrl"}, + {IsEditable, "isEditable"}, + {IsDifferentFromBase, "isDifferentFromBase"}, + {IsAlreadyUpToDate, "isAlreadyUpToDate"}, + {ApplyPermissions, "applyPermissions"}, + }; +} diff --git a/src/PowerlevelsEditModels.h b/src/PowerlevelsEditModels.h index 9aa955d2..515fdb56 100644 --- a/src/PowerlevelsEditModels.h +++ b/src/PowerlevelsEditModels.h @@ -48,12 +48,12 @@ public: const QModelIndex &destinationParent, int destinationChild) override; - std::map> toEvents(); - mtx::events::state::power_level_t kick(); - mtx::events::state::power_level_t invite(); - mtx::events::state::power_level_t ban(); - mtx::events::state::power_level_t eventsDefault(); - mtx::events::state::power_level_t stateDefault(); + std::map> toEvents() const; + mtx::events::state::power_level_t kick() const; + mtx::events::state::power_level_t invite() const; + mtx::events::state::power_level_t ban() const; + mtx::events::state::power_level_t eventsDefault() const; + mtx::events::state::power_level_t stateDefault() const; struct Entry { @@ -106,8 +106,8 @@ public: const QModelIndex &destinationParent, int destinationChild) override; - std::map> toUsers(); - mtx::events::state::power_level_t usersDefault(); + std::map> toUsers() const; + mtx::events::state::power_level_t usersDefault() const; struct Entry { @@ -122,38 +122,117 @@ public: mtx::events::state::PowerLevels powerLevels_; }; +class PowerlevelsSpacesListModel : public QAbstractListModel +{ + Q_OBJECT + Q_PROPERTY(bool applyToChildren READ applyToChildren WRITE setApplyToChildren NOTIFY + applyToChildrenChanged) + Q_PROPERTY(bool overwriteDiverged READ overwriteDiverged WRITE setOverwriteDiverged NOTIFY + overwriteDivergedChanged) + +signals: + void applyToChildrenChanged(); + void overwriteDivergedChanged(); + +public: + enum Roles + { + DisplayName, + AvatarUrl, + IsSpace, + IsEditable, + IsDifferentFromBase, + IsAlreadyUpToDate, + ApplyPermissions, + }; + + explicit PowerlevelsSpacesListModel(const std::string &room_id_, + const mtx::events::state::PowerLevels &pl, + QObject *parent = nullptr); + + QHash roleNames() const override; + int rowCount(const QModelIndex &) const override { return static_cast(spaces.size()); } + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + bool + setData(const QModelIndex &index, const QVariant &value, int role = Qt::DisplayRole) override; + + bool applyToChildren() const { return applyToChildren_; } + bool overwriteDiverged() const { return overwriteDiverged_; } + + void setApplyToChildren(bool val) + { + applyToChildren_ = val; + emit applyToChildrenChanged(); + updateToDefaults(); + } + void setOverwriteDiverged(bool val) + { + overwriteDiverged_ = val; + emit overwriteDivergedChanged(); + updateToDefaults(); + } + + void updateToDefaults(); + + Q_INVOKABLE void commit(); + + struct Entry + { + ~Entry() = default; + + std::string roomid; + mtx::events::state::PowerLevels pl; + bool apply = false; + }; + + std::string room_id; + QVector spaces; + mtx::events::state::PowerLevels oldPowerLevels_, newPowerlevels_; + + bool applyToChildren_ = true, overwriteDiverged_ = false; +}; + class PowerlevelEditingModels : public QObject { Q_OBJECT Q_PROPERTY(PowerlevelsUserListModel *users READ users CONSTANT) Q_PROPERTY(PowerlevelsTypeListModel *types READ types CONSTANT) + Q_PROPERTY(PowerlevelsSpacesListModel *spaces READ spaces CONSTANT) Q_PROPERTY(qlonglong adminLevel READ adminLevel NOTIFY adminLevelChanged) Q_PROPERTY(qlonglong moderatorLevel READ moderatorLevel NOTIFY moderatorLevelChanged) Q_PROPERTY(qlonglong defaultUserLevel READ defaultUserLevel NOTIFY defaultUserLevelChanged) + Q_PROPERTY(bool isSpace READ isSpace CONSTANT) signals: void adminLevelChanged(); void moderatorLevelChanged(); void defaultUserLevelChanged(); +private: + mtx::events::state::PowerLevels calculateNewPowerlevel() const; + public: explicit PowerlevelEditingModels(QString room_id, QObject *parent = nullptr); PowerlevelsUserListModel *users() { return &users_; } PowerlevelsTypeListModel *types() { return &types_; } + PowerlevelsSpacesListModel *spaces() { return &spaces_; } qlonglong adminLevel() const { return powerLevels_.state_level(to_string(mtx::events::EventType::RoomPowerLevels)); } qlonglong moderatorLevel() const { return powerLevels_.redact; } qlonglong defaultUserLevel() const { return powerLevels_.users_default; } + bool isSpace() const; Q_INVOKABLE void commit(); + Q_INVOKABLE void updateSpacesModel(); Q_INVOKABLE void addRole(int pl); mtx::events::state::PowerLevels powerLevels_; PowerlevelsTypeListModel types_; PowerlevelsUserListModel users_; + PowerlevelsSpacesListModel spaces_; std::string room_id_; };