From 051c25d5b87c2351df46173f19b907cea436fa3b Mon Sep 17 00:00:00 2001
From: Nicolas Werner <nicolas.werner@hotmail.de>
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 063284c14..dd1dfe1e0 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 bfb337ffe..4c23d9af6 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 000000000..83af00f73
--- /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 27d9c081e..7affe702f 100644
--- a/resources/res.qrc
+++ b/resources/res.qrc
@@ -162,6 +162,7 @@
         <file>qml/dialogs/LogoutDialog.qml</file>
         <file>qml/dialogs/PhoneNumberInputDialog.qml</file>
         <file>qml/dialogs/PowerLevelEditor.qml</file>
+        <file>qml/dialogs/PowerLevelSpacesApplyDialog.qml</file>
         <file>qml/dialogs/RawMessageDialog.qml</file>
         <file>qml/dialogs/ReadReceipts.qml</file>
         <file>qml/dialogs/RoomDirectory.qml</file>
diff --git a/src/PowerlevelsEditModels.cpp b/src/PowerlevelsEditModels.cpp
index fcfde26e8..09e5b05d5 100644
--- a/src/PowerlevelsEditModels.cpp
+++ b/src/PowerlevelsEditModels.cpp
@@ -4,8 +4,12 @@
 
 #include "PowerlevelsEditModels.h"
 
+#include <QCoreApplication>
+#include <QTimer>
+
 #include <algorithm>
 #include <set>
+#include <unordered_set>
 
 #include "Cache.h"
 #include "Cache_p.h"
@@ -76,7 +80,7 @@ PowerlevelsTypeListModel::PowerlevelsTypeListModel(const std::string &rid,
 }
 
 std::map<std::string, mtx::events::state::power_level_t, std::less<>>
-PowerlevelsTypeListModel::toEvents()
+PowerlevelsTypeListModel::toEvents() const
 {
     std::map<std::string, mtx::events::state::power_level_t, std::less<>> 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<std::string, mtx::events::state::power_level_t, std::less<>>
-PowerlevelsUserListModel::toUsers()
+PowerlevelsUserListModel::toUsers() const
 {
     std::map<std::string, mtx::events::state::power_level_t, std::less<>> 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<std::string> visited;
+
+    std::function<void(const std::string &)> 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<mtx::events::state::space::Parent>(s, space);
+            if (parent && parent->content.via && !parent->content.via->empty() &&
+                parent->content.canonical) {
+                auto parent = cache::client()->getStateEvent<mtx::events::state::PowerLevels>(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<std::string> 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<std::string> 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<int, QByteArray>
+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 9aa955d20..515fdb565 100644
--- a/src/PowerlevelsEditModels.h
+++ b/src/PowerlevelsEditModels.h
@@ -48,12 +48,12 @@ public:
                   const QModelIndex &destinationParent,
                   int destinationChild) override;
 
-    std::map<std::string, mtx::events::state::power_level_t, std::less<>> 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<std::string, mtx::events::state::power_level_t, std::less<>> 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<std::string, mtx::events::state::power_level_t, std::less<>> toUsers();
-    mtx::events::state::power_level_t usersDefault();
+    std::map<std::string, mtx::events::state::power_level_t, std::less<>> 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<int, QByteArray> roleNames() const override;
+    int rowCount(const QModelIndex &) const override { return static_cast<int>(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<Entry> 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_;
 };
-- 
GitLab