diff --git a/CMakeLists.txt b/CMakeLists.txt index 57bb74b6..c8ccfe7f 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -375,6 +375,7 @@ set(SRC_FILES src/SSOHandler.cpp src/CombinedImagePackModel.cpp src/SingleImagePackModel.cpp + src/SpaceChildrenModel.cpp src/ImagePackListModel.cpp src/TrayIcon.cpp src/UserSettingsPage.cpp @@ -570,6 +571,7 @@ qt5_wrap_cpp(MOC_HEADERS src/RoomsModel.h src/SSOHandler.h src/SingleImagePackModel.h + src/SpaceChildrenModel.h src/TrayIcon.h src/UserSettingsPage.h src/UsersModel.h diff --git a/resources/qml/Root.qml b/resources/qml/Root.qml index 72f30b7a..71b29bd1 100644 --- a/resources/qml/Root.qml +++ b/resources/qml/Root.qml @@ -59,6 +59,14 @@ Pane { } + Component { + id: spaceChildrenComponent + + SpaceChildren { + } + + } + Component { id: roomMembersComponent @@ -265,6 +273,15 @@ Pane { destroyOnClose(roomSettings); } + function onOpenSpaceChildrenDialog(spaceChildren, nonChildren) { + var spaceChildrenDialog = spaceChildrenComponent.createObject(timelineRoot, { + "spaceChildren": spaceChildren, + "nonChildren": nonChildren + }); + spaceChildrenDialog.show(); + destroyOnClose(spaceChildrenDialog); + } + function onOpenInviteUsersDialog(invitees) { var dialog = inviteDialog.createObject(timelineRoot, { "roomId": Rooms.currentRoom.roomId, diff --git a/resources/qml/dialogs/RoomSettings.qml b/resources/qml/dialogs/RoomSettings.qml index 110475c7..e00b91a5 100644 --- a/resources/qml/dialogs/RoomSettings.qml +++ b/resources/qml/dialogs/RoomSettings.qml @@ -362,6 +362,18 @@ ApplicationWindow { Layout.alignment: Qt.AlignRight } + Item { + Layout.fillWidth: true + visible: roomSettings.isSpace + } + + Button { + visible: roomSettings.isSpace + text: qsTr("Manage space children") + Layout.alignment: Qt.AlignRight + onClicked: TimelineManager.openSpaceChildren(roomSettings.roomId) + } + Item { // for adding extra space between sections Layout.fillWidth: true diff --git a/resources/qml/dialogs/SpaceChildren.qml b/resources/qml/dialogs/SpaceChildren.qml new file mode 100644 index 00000000..63fda3e4 --- /dev/null +++ b/resources/qml/dialogs/SpaceChildren.qml @@ -0,0 +1,188 @@ +// SPDX-FileCopyrightText: 2021 Nheko Contributors +// SPDX-FileCopyrightText: 2022 Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +import ".." +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: spaceChildrenDialog + + property SpaceChildrenModel spaceChildren + property NonSpaceChildrenModel nonChildren + + 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("Space children") + + Shortcut { + sequence: StandardKey.Cancel + onActivated: spaceChildrenDialog.close() + } + + ScrollHelper { + flickable: flickable + anchors.fill: flickable + } + + ColumnLayout { + id: contentLayout + anchors.fill: parent + anchors.margins: Nheko.paddingMedium + spacing: Nheko.paddingMedium + + Label { + color: Nheko.colors.text + horizontalAlignment: Label.AlignHCenter + Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true + text: qsTr("Children of %1").arg(spaceChildren.space.roomName) + wrapMode: Text.Wrap + font.pointSize: fontMetrics.font.pointSize * 1.5 + } + + ListView { + id: childrenList + + Layout.fillWidth: true + Layout.fillHeight: true + model: spaceChildren + spacing: Nheko.paddingMedium + clip: true + + ScrollHelper { + flickable: parent + anchors.fill: parent + } + + delegate: RowLayout { + id: childDel + + required property string id + required property string roomName + required property string avatarUrl + required property string alias + + spacing: Nheko.paddingMedium + width: ListView.view.width + + Avatar { + Layout.alignment: Qt.AlignVCenter + Layout.rightMargin: Nheko.paddingMedium + width: Nheko.avatarSize + height: Nheko.avatarSize + url: childDel.avatarUrl.replace("mxc://", "image://MxcImage/") + roomid: childDel.id + displayName: childDel.roomName + } + + ColumnLayout { + spacing: Nheko.paddingMedium + + Label { + font.bold: true + text: childDel.roomName + } + + Label { + text: childDel.alias + visible: childDel.alias + color: Nheko.inactiveColors.text + } + } + + Item { Layout.fillWidth: true } + + ImageButton { + image: ":/icons/icons/ui/delete.svg" + onClicked: Nheko.removeRoomFromSpace(childDel.id, spaceChildren.space.roomId) + } + } + } + + Label { + color: Nheko.colors.text + horizontalAlignment: Label.AlignHCenter + Layout.alignment: Qt.AlignHCenter + Layout.fillWidth: true + text: qsTr("Add rooms to %1").arg(spaceChildren.space.roomName) + wrapMode: Text.Wrap + font.pointSize: fontMetrics.font.pointSize * 1.5 + } + + ListView { + id: nonChildrenList + + Layout.fillWidth: true + Layout.fillHeight: true + model: nonChildren + spacing: Nheko.paddingMedium + clip: true + + ScrollHelper { + flickable: parent + anchors.fill: parent + } + + delegate: RowLayout { + id: nonChildDel + + required property string id + required property string roomName + required property string avatarUrl + required property string alias + + spacing: Nheko.paddingMedium + width: ListView.view.width + + Avatar { + Layout.alignment: Qt.AlignVCenter + Layout.rightMargin: Nheko.paddingMedium + width: Nheko.avatarSize + height: Nheko.avatarSize + url: nonChildDel.avatarUrl.replace("mxc://", "image://MxcImage/") + roomid: nonChildDel.id + displayName: nonChildDel.roomName + } + + ColumnLayout { + spacing: Nheko.paddingMedium + + Label { + font.bold: true + text: nonChildDel.roomName + } + + Label { + text: nonChildDel.alias + visible: nonChildDel.alias + color: Nheko.inactiveColors.text + } + } + + Item { Layout.fillWidth: true } + + ImageButton { + image: ":/icons/icons/ui/add-square-button.svg" + onClicked: Nheko.addRoomToSpace(nonChildDel.id, spaceChildren.space.roomId) + } + } + } + } + + footer: DialogButtonBox { + standardButtons: DialogButtonBox.Ok + onAccepted: close() + } +} diff --git a/resources/res.qrc b/resources/res.qrc index 3ce63f42..0ccaad8c 100644 --- a/resources/res.qrc +++ b/resources/res.qrc @@ -177,6 +177,7 @@ qml/voip/PlaceCall.qml qml/voip/ScreenShare.qml qml/voip/VideoCall.qml + qml/dialogs/SpaceChildren.qml media/ring.ogg diff --git a/src/ChatPage.cpp b/src/ChatPage.cpp index ccaf2926..d41972d6 100644 --- a/src/ChatPage.cpp +++ b/src/ChatPage.cpp @@ -747,6 +747,45 @@ ChatPage::joinRoomVia(const std::string &room_id, reason.toStdString()); } +void +ChatPage::addRoomToSpace(const QString &roomId, const QString &spaceId) +{ + // Make sure that the user isn't trying to create an infinite loop. Granted, this is not + // perfect, since you could have a loop of the form "a => b => a => ...", but this is sufficient + // for now. + if (roomId == spaceId) + return; + + mtx::events::state::space::Child child; + child.via = {roomId.splitRef(QStringLiteral(":")).last().toString().toStdString()}; + http::client()->send_state_event( + roomId.toStdString(), + child, + [this](const mtx::responses::EventId &, mtx::http::RequestErr err) { + if (err) { + emit showNotification(tr("Failed to add room to space: %1") + .arg(QString::fromStdString(err->matrix_error.error))); + } + }); +} + +void +ChatPage::removeRoomFromSpace(const QString &roomId, const QString &spaceId) +{ + auto childEvent = cache::client()->getStateEvent( + spaceId.toStdString(), "m.space.child"); + if (childEvent.has_value()) + http::client()->redact_event( + roomId.toStdString(), + childEvent->event_id, + [this](const mtx::responses::EventId &, mtx::http::RequestErr err) { + if (err) { + emit showNotification(tr("Failed to remove room from space: %1") + .arg(QString::fromStdString(err->matrix_error.error))); + } + }); +} + void ChatPage::createRoom(const mtx::requests::CreateRoom &req) { diff --git a/src/ChatPage.h b/src/ChatPage.h index 2673a3de..131db288 100644 --- a/src/ChatPage.h +++ b/src/ChatPage.h @@ -94,6 +94,9 @@ public slots: bool promptForConfirmation = true, const QString &reason = ""); + void addRoomToSpace(const QString &roomId, const QString &spaceId); + void removeRoomFromSpace(const QString &roomId, const QString &spaceId); + void inviteUser(QString userid, QString reason); void kickUser(QString userid, QString reason); void banUser(QString userid, QString reason); diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index ffc3c6c1..9f9dfa91 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -34,6 +34,7 @@ #include "RoomDirectoryModel.h" #include "RoomsModel.h" #include "SingleImagePackModel.h" +#include "SpaceChildrenModel.h" #include "TrayIcon.h" #include "UserSettingsPage.h" #include "UsersModel.h" @@ -212,6 +213,18 @@ MainWindow::registerQmlTypes() 0, "SingleImagePackModel", QStringLiteral("SingleImagePackModel needs to be instantiated on the C++ side")); + qmlRegisterUncreatableType( + "im.nheko", + 1, + 0, + "SpaceChildrenModel", + "SpaceChildrenModel needs to be instantiated on the C++ side"); + qmlRegisterUncreatableType( + "im.nheko", + 1, + 0, + "NonSpaceChildrenModel", + "NonSpaceChildrenModel needs to be instantiated on the C++ side"); qmlRegisterUncreatableType( "im.nheko", 1, diff --git a/src/SpaceChildrenModel.cpp b/src/SpaceChildrenModel.cpp new file mode 100644 index 00000000..f71aa0dd --- /dev/null +++ b/src/SpaceChildrenModel.cpp @@ -0,0 +1,106 @@ +// SPDX-FileCopyrightText: 2022 Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "SpaceChildrenModel.h" + +#include "Cache.h" +#include "Cache_p.h" + +SpaceChildrenModel::SpaceChildrenModel(QSharedPointer space, QObject *parent) + : QAbstractListModel{parent} + , m_space{space} +{ + auto joinedRooms = cache::joinedRooms(); + for (const auto &child : cache::client()->getChildRoomIds(m_space->roomId().toStdString())) { + m_childIds.push_back(QString::fromStdString(child)); + + if (std::find(std::begin(joinedRooms), std::end(joinedRooms), child) != + std::end(joinedRooms)) + m_childInfos.push_back(cache::singleRoomInfo(child)); + else // TODO: replace with code to fetch a non-joined room + m_childInfos.push_back(cache::singleRoomInfo(child)); + } +} + +QHash +SpaceChildrenModel::roleNames() const +{ + return {{Roles::Id, "id"}, + {Roles::RoomName, "roomName"}, + {Roles::AvatarUrl, "avatarUrl"}, + {Roles::Alias, "alias"}}; +} + +QVariant +SpaceChildrenModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() >= (int)m_childIds.size() || index.row() < 0) + return {}; + + switch (role) { + case Roles::Id: + return m_childIds[index.row()]; + case Roles::RoomName: + return QString::fromStdString(m_childInfos[index.row()].name); + case Roles::AvatarUrl: + return QString::fromStdString(m_childInfos[index.row()].avatar_url); + case Roles::Alias: { + auto aliases = cache::client()->getRoomAliases(m_childIds[index.row()].toStdString()); + if (aliases.has_value()) + return QString::fromStdString(aliases->alias); + else + return {}; + } + default: + return {}; + } +} + +NonSpaceChildrenModel::NonSpaceChildrenModel(QSharedPointer space, QObject *parent) + : QAbstractListModel{parent} + , m_space{space} +{ + auto children = cache::client()->getChildRoomIds(m_space->roomId().toStdString()); + for (const auto &room : cache::joinedRooms()) { + if (room == space->roomId().toStdString() || + std::find(std::begin(children), std::end(children), room) == std::end(children)) { + m_roomIds.push_back(QString::fromStdString(room)); + m_roomInfos.push_back(cache::singleRoomInfo(room)); + } + } +} + +QHash +NonSpaceChildrenModel::roleNames() const +{ + return {{Roles::Id, "id"}, + {Roles::RoomName, "roomName"}, + {Roles::AvatarUrl, "avatarUrl"}, + {Roles::Alias, "alias"}}; +} + +QVariant +NonSpaceChildrenModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() >= (int)m_roomIds.size() || index.row() < 0) + return {}; + + switch (role) { + case Roles::Id: + return m_roomIds[index.row()]; + case Roles::RoomName: + return QString::fromStdString(m_roomInfos[index.row()].name); + case Roles::AvatarUrl: + return QString::fromStdString(m_roomInfos[index.row()].avatar_url); + case Roles::Alias: { + auto aliases = cache::client()->getRoomAliases(m_roomIds[index.row()].toStdString()); + if (aliases.has_value()) + return QString::fromStdString(aliases->alias); + else + return {}; + } + default: + return {}; + } +} diff --git a/src/SpaceChildrenModel.h b/src/SpaceChildrenModel.h new file mode 100644 index 00000000..1fce5aa4 --- /dev/null +++ b/src/SpaceChildrenModel.h @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: 2022 Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#ifndef SPACECHILDRENMODEL_H +#define SPACECHILDRENMODEL_H + +#include +#include + +#include "timeline/TimelineModel.h" + +class SpaceChildrenModel : public QAbstractListModel +{ + Q_OBJECT + + Q_PROPERTY(TimelineModel *space READ space CONSTANT) + +public: + enum Roles + { + Id, + RoomName, + AvatarUrl, + Alias, + }; + + explicit SpaceChildrenModel(QSharedPointer space, QObject *parent = nullptr); + + TimelineModel *space() const { return m_space.data(); } + + int rowCount(const QModelIndex &parent) const override + { + Q_UNUSED(parent); + return m_childIds.size(); + } + QHash roleNames() const override; + QVariant data(const QModelIndex &index, int role) const override; + +private: + QSharedPointer m_space; + QStringList m_childIds; + QList m_childInfos; +}; + +class NonSpaceChildrenModel : public QAbstractListModel +{ + Q_OBJECT + + Q_PROPERTY(TimelineModel *space READ space CONSTANT) + +public: + enum Roles + { + Id, + RoomName, + AvatarUrl, + Alias, + }; + + explicit NonSpaceChildrenModel(QSharedPointer space, QObject *parent = nullptr); + + TimelineModel *space() const { return m_space.data(); } + + int rowCount(const QModelIndex &parent) const override + { + Q_UNUSED(parent); + return m_roomIds.size(); + } + QHash roleNames() const override; + QVariant data(const QModelIndex &index, int role) const override; + +private: + QSharedPointer m_space; + QStringList m_roomIds; + QList m_roomInfos; +}; + +#endif // SPACECHILDRENMODEL_H diff --git a/src/timeline/TimelineViewManager.cpp b/src/timeline/TimelineViewManager.cpp index 3bccd8f3..4e2401e8 100644 --- a/src/timeline/TimelineViewManager.cpp +++ b/src/timeline/TimelineViewManager.cpp @@ -180,6 +180,16 @@ TimelineViewManager::openRoomSettings(QString room_id) emit openRoomSettingsDialog(settings); } +void +TimelineViewManager::openSpaceChildren(const QString &spaceId) +{ + auto children = new SpaceChildrenModel{rooms_->getRoomById(spaceId)}; + auto nonChildren = new NonSpaceChildrenModel{rooms_->getRoomById(spaceId)}; + QQmlEngine::setObjectOwnership(children, QQmlEngine::JavaScriptOwnership); + QQmlEngine::setObjectOwnership(nonChildren, QQmlEngine::JavaScriptOwnership); + emit openSpaceChildrenDialog(children, nonChildren); +} + void TimelineViewManager::openInviteUsers(QString roomId) { diff --git a/src/timeline/TimelineViewManager.h b/src/timeline/TimelineViewManager.h index 08943e8c..207824ec 100644 --- a/src/timeline/TimelineViewManager.h +++ b/src/timeline/TimelineViewManager.h @@ -17,6 +17,7 @@ #include "Cache.h" #include "JdenticonProvider.h" #include "Logging.h" +#include "SpaceChildrenModel.h" #include "TimelineModel.h" #include "Utils.h" #include "emoji/EmojiModel.h" @@ -65,6 +66,7 @@ public: Q_INVOKABLE void openRoomMembers(TimelineModel *room); Q_INVOKABLE void openRoomSettings(QString room_id); + Q_INVOKABLE void openSpaceChildren(const QString &spaceId); Q_INVOKABLE void openInviteUsers(QString roomId); Q_INVOKABLE void openGlobalUserProfile(QString userId); Q_INVOKABLE UserProfile *getGlobalUserProfile(QString userId); @@ -86,6 +88,7 @@ signals: void focusInput(); void openRoomMembersDialog(MemberList *members, TimelineModel *room); void openRoomSettingsDialog(RoomSettings *settings); + void openSpaceChildrenDialog(SpaceChildrenModel *children, NonSpaceChildrenModel *nonChildren); void openInviteUsersDialog(InviteesModel *invitees); void openProfile(UserProfile *profile); void showImagePackSettings(TimelineModel *room, ImagePackListModel *packlist); diff --git a/src/ui/NhekoGlobalObject.cpp b/src/ui/NhekoGlobalObject.cpp index 9ffbb666..25d6465e 100644 --- a/src/ui/NhekoGlobalObject.cpp +++ b/src/ui/NhekoGlobalObject.cpp @@ -128,7 +128,12 @@ Nheko::logout() const } void -Nheko::createRoom(QString name, QString topic, QString aliasLocalpart, bool isEncrypted, bool isSpace, int preset) +Nheko::createRoom(QString name, + QString topic, + QString aliasLocalpart, + bool isEncrypted, + bool isSpace, + int preset) { mtx::requests::CreateRoom req; @@ -156,9 +161,21 @@ Nheko::createRoom(QString name, QString topic, QString aliasLocalpart, bool isEn } if (isSpace) { - req.creation_content = mtx::events::state::Create{}; + req.creation_content = mtx::events::state::Create{}; req.creation_content->type = "m.space"; } emit ChatPage::instance()->createRoom(req); } + +void +Nheko::addRoomToSpace(const QString &roomId, const QString &spaceId) +{ + ChatPage::instance()->addRoomToSpace(roomId, spaceId); +} + +void +Nheko::removeRoomFromSpace(const QString &roomId, const QString &spaceId) +{ + ChatPage::instance()->removeRoomFromSpace(roomId, spaceId); +} diff --git a/src/ui/NhekoGlobalObject.h b/src/ui/NhekoGlobalObject.h index 9e1cbd7e..742a5d93 100644 --- a/src/ui/NhekoGlobalObject.h +++ b/src/ui/NhekoGlobalObject.h @@ -52,8 +52,14 @@ public: Q_INVOKABLE void setStatusMessage(QString msg) const; Q_INVOKABLE void showUserSettingsPage() const; Q_INVOKABLE void logout() const; - Q_INVOKABLE void - createRoom(QString name, QString topic, QString aliasLocalpart, bool isEncrypted, bool isSpace, int preset); + Q_INVOKABLE void createRoom(QString name, + QString topic, + QString aliasLocalpart, + bool isEncrypted, + bool isSpace, + int preset); + Q_INVOKABLE void addRoomToSpace(const QString &roomId, const QString &spaceId); + Q_INVOKABLE void removeRoomFromSpace(const QString &roomId, const QString &spaceId); public slots: void updateUserProfile(); diff --git a/src/ui/RoomSettings.cpp b/src/ui/RoomSettings.cpp index 42db1955..12dc48fa 100644 --- a/src/ui/RoomSettings.cpp +++ b/src/ui/RoomSettings.cpp @@ -243,6 +243,19 @@ RoomSettings::canChangeAvatar() const return false; } +bool +RoomSettings::canSetSpaceChild() const +{ + try { + return cache::hasEnoughPowerLevel( + {EventType::SpaceChild}, roomid_.toStdString(), utils::localUser().toStdString()); + } catch (const lmdb::error &e) { + nhlog::db()->warn("lmdb error: {}", e.what()); + } + + return false; +} + bool RoomSettings::isEncryptionEnabled() const { diff --git a/src/ui/RoomSettings.h b/src/ui/RoomSettings.h index 9912cfd6..7710941a 100644 --- a/src/ui/RoomSettings.h +++ b/src/ui/RoomSettings.h @@ -37,6 +37,7 @@ class RoomSettings : public QObject Q_PROPERTY(QString plainRoomName READ plainRoomName NOTIFY roomNameChanged) Q_PROPERTY(QString plainRoomTopic READ plainRoomTopic NOTIFY roomTopicChanged) Q_PROPERTY(QString roomAvatarUrl READ roomAvatarUrl NOTIFY avatarUrlChanged) + Q_PROPERTY(bool isSpace READ isSpace CONSTANT) Q_PROPERTY(int memberCount READ memberCount CONSTANT) Q_PROPERTY(int notifications READ notifications NOTIFY notificationsChanged) Q_PROPERTY(int accessJoinRules READ accessJoinRules NOTIFY accessJoinRulesChanged) @@ -45,6 +46,7 @@ class RoomSettings : public QObject Q_PROPERTY(bool canChangeJoinRules READ canChangeJoinRules CONSTANT) Q_PROPERTY(bool canChangeName READ canChangeName CONSTANT) Q_PROPERTY(bool canChangeTopic READ canChangeTopic CONSTANT) + Q_PROPERTY(bool canSetSpaceChild READ canSetSpaceChild CONSTANT) Q_PROPERTY(bool isEncryptionEnabled READ isEncryptionEnabled NOTIFY encryptionChanged) Q_PROPERTY(bool supportsKnocking READ supportsKnocking CONSTANT) Q_PROPERTY(bool supportsRestricted READ supportsRestricted CONSTANT) @@ -59,6 +61,7 @@ public: QString plainRoomTopic() const; QString roomVersion() const; QString roomAvatarUrl(); + bool isSpace() const { return info_.is_space; } int memberCount() const; int notifications(); int accessJoinRules(); @@ -71,6 +74,8 @@ public: bool canChangeTopic() const; //! Whether the user has enough power level to send m.room.avatar event. bool canChangeAvatar() const; + //! Whether the user has enough power level to add children to a space. + bool canSetSpaceChild() const; bool isEncryptionEnabled() const; bool supportsKnocking() const; bool supportsRestricted() const;