From 7180fa022b49319e31967eb1cac78743818da3cb Mon Sep 17 00:00:00 2001 From: James Graham Date: Mon, 2 Oct 2023 18:41:17 +0000 Subject: [PATCH] Room Settings Parents Add the ability to manage parent rooms from a child, this includes: - viewing parents - adding a new parent - removing an existing one Follows the rules from the matrix spec https://spec.matrix.org/v1.7/client-server-api/#mspaceparent-relationships --- src/neochatroom.cpp | 75 +++++++++++++++++++ src/neochatroom.h | 60 +++++++++++++++ src/qml/General.qml | 175 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 310 insertions(+) diff --git a/src/neochatroom.cpp b/src/neochatroom.cpp index 3d4968f9e..f2eac02c7 100644 --- a/src/neochatroom.cpp +++ b/src/neochatroom.cpp @@ -117,6 +117,7 @@ NeoChatRoom::NeoChatRoom(Connection *connection, QString roomId, JoinState joinS }); connect(this, &Room::changed, this, [this] { Q_EMIT canEncryptRoomChanged(); + Q_EMIT parentIdsChanged(); }); connect(connection, &Connection::capabilitiesLoaded, this, &NeoChatRoom::maxRoomVersionChanged); connect(this, &Room::changed, this, [this]() { @@ -1099,6 +1100,80 @@ void NeoChatRoom::clearInvitationNotification() NotificationsManager::instance().clearInvitationNotification(id()); } +bool NeoChatRoom::hasParent() const +{ + return currentState().eventsOfType("m.space.parent"_ls).size() > 0; +} + +QVector NeoChatRoom::parentIds() const +{ + auto parentEvents = currentState().eventsOfType("m.space.parent"_ls); + QVector parentIds; + for (const auto &parentEvent : parentEvents) { + if (parentEvent->contentJson().contains("via"_ls) && !parentEvent->contentPart("via"_ls).isEmpty()) { + parentIds += parentEvent->stateKey(); + } + } + return parentIds; +} + +bool NeoChatRoom::isCanonicalParent(const QString &parentId) const +{ + if (auto parentEvent = currentState().get("m.space.parent"_ls, parentId)) { + return parentEvent->contentPart("canonical"_ls); + } + return false; +} + +bool NeoChatRoom::canModifyParent(const QString &parentId) const +{ + if (!canSendState("m.space.parent"_ls)) { + return false; + } + // If we can't peek the parent we assume that we neither have permission nor is + // there an existing space child event for this room. + if (auto parent = static_cast(connection()->room(parentId))) { + if (!parent->isSpace()) { + return false; + } + // If the user is allowed to set space child events in the parent they are + // allowed to set the space as a parent (even if a space child event doesn't + // exist). + if (parent->canSendState("m.space.child"_ls)) { + return true; + } + // If the parent has a space child event the user can set as a parent (even + // if they don't have permission to set space child events in that parent). + if (parent->currentState().contains("m.space.child"_ls, id())) { + return true; + } + } + return false; +} + +void NeoChatRoom::addParent(const QString &parentId) +{ + if (!canModifyParent(parentId)) { + return; + } + if (auto parent = static_cast(connection()->room(parentId))) { + setState("m.space.parent"_ls, parentId, QJsonObject{{"canonical"_ls, true}, {"via"_ls, QJsonArray{connection()->domain()}}}); + } +} + +void NeoChatRoom::removeParent(const QString &parentId) +{ + if (!canModifyParent(parentId)) { + return; + } + if (!currentState().contains("m.space.parent"_ls, parentId)) { + return; + } + if (auto parent = static_cast(connection()->room(parentId))) { + setState("m.space.parent"_ls, parentId, {}); + } +} + bool NeoChatRoom::isSpace() { const auto creationEvent = this->creation(); diff --git a/src/neochatroom.h b/src/neochatroom.h index aafa2bb1c..7c871d4d7 100644 --- a/src/neochatroom.h +++ b/src/neochatroom.h @@ -126,6 +126,13 @@ class NeoChatRoom : public Quotient::Room */ Q_PROPERTY(Quotient::User *directChatRemoteUser READ directChatRemoteUser CONSTANT) + /** + * @brief The Matrix IDs of this room's parents. + * + * Empty if no parent space is set. + */ + Q_PROPERTY(QVector parentIds READ parentIds NOTIFY parentIdsChanged) + /** * @brief If the room is a space. */ @@ -587,10 +594,62 @@ public: Quotient::User *directChatRemoteUser() const; + /** + * @brief Whether this room has one or more parent spaces set. + */ + Q_INVOKABLE bool hasParent() const; + + QVector parentIds() const; + + /** + * @brief Whether the given parent is the canonical parent of the room. + */ + Q_INVOKABLE bool isCanonicalParent(const QString &parentId) const; + + /** + * @brief Whether the local user has permission to set the given space as a parent. + * + * @note This follows the rules determined in the Matrix spec + * https://spec.matrix.org/v1.7/client-server-api/#mspaceparent-relationships + */ + Q_INVOKABLE bool canModifyParent(const QString &parentId) const; + + /** + * @brief Add the given room as a parent. + * + * Will fail if the user doesn't have the required privileges (see + * canModifyParent()). + * + * @sa canModifyParent() + */ + Q_INVOKABLE void addParent(const QString &parentId); + + /** + * @brief Remove the given room as a parent. + * + * Will fail if the user doesn't have the required privileges (see + * canModifyParent()). + * + * @sa canModifyParent() + */ + Q_INVOKABLE void removeParent(const QString &parentId); + [[nodiscard]] bool isSpace(); + /** + * @brief Add the given room as a child. + * + * Will fail if the user doesn't have the required privileges or this room is + * not a space. + */ Q_INVOKABLE void addChild(const QString &childId, bool setChildParent = false); + /** + * @brief Remove the given room as a child. + * + * Will fail if the user doesn't have the required privileges or this room is + * not a space. + */ Q_INVOKABLE void removeChild(const QString &childId, bool unsetChildParent = false); bool isInvite() const; @@ -867,6 +926,7 @@ Q_SIGNALS: void fileUploadingProgressChanged(); void backgroundChanged(); void readMarkerLoadedChanged(); + void parentIdsChanged(); void lastActiveTimeChanged(); void isInviteChanged(); void displayNameChanged(); diff --git a/src/qml/General.qml b/src/qml/General.qml index 41a4d1ad6..1f064ca7f 100644 --- a/src/qml/General.qml +++ b/src/qml/General.qml @@ -278,6 +278,181 @@ FormCard.FormCardPage { } } } + FormCard.FormHeader { + title: i18n("Official Parent Spaces") + } + FormCard.FormCard { + Repeater { + id: officalParentRepeater + model: root.room.parentIds + + delegate: FormCard.FormTextDelegate { + id: officalParentDelegate + required property string modelData + property NeoChatRoom space: root.connection.room(modelData) + text: { + if (space) { + return space.displayName; + } else { + return modelData; + } + } + description: { + if (space) { + if (space.canonicalAlias.length > 0) { + return space.canonicalAlias; + } else { + return modelData; + } + } else { + return ""; + } + } + + contentItem.children: QQC2.ToolButton { + visible: officalParentDelegate?.space.canSendState("m.space.child") && root.room.canSendState("m.space.parent") + display: QQC2.AbstractButton.IconOnly + action: Kirigami.Action { + id: removeParentAction + text: i18n("Remove parent") + icon.name: "edit-delete-remove" + onTriggered: root.room.removeParent(officalParentDelegate.modelData) + } + QQC2.ToolTip { + text: removeParentAction.text + delay: Kirigami.Units.toolTipDelay + } + } + } + } + FormCard.FormTextDelegate { + visible: officalParentRepeater.count <= 0 + text: i18n("This room has no official parent spaces.") + } + } + FormCard.FormHeader { + visible: root.room.canSendState("m.space.parent") + title: i18n("Add Offical Parent Space") + } + FormCard.FormCard { + visible: root.room.canSendState("m.space.parent") + FormCard.FormButtonDelegate { + visible: !chosenRoomDelegate.visible + text: i18nc("@action:button", "Pick room") + onClicked: { + let dialog = pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/JoinRoomPage.qml", {connection: root.connection}, {title: i18nc("@title", "Explore Rooms")}) + dialog.roomSelected.connect((roomId, displayName, avatarUrl, alias, topic, memberCount, isJoined) => { + chosenRoomDelegate.roomId = roomId; + chosenRoomDelegate.displayName = displayName; + chosenRoomDelegate.avatarUrl = avatarUrl; + chosenRoomDelegate.alias = alias; + chosenRoomDelegate.topic = topic; + chosenRoomDelegate.memberCount = memberCount; + chosenRoomDelegate.isJoined = isJoined; + chosenRoomDelegate.visible = true; + }) + } + } + FormCard.AbstractFormDelegate { + id: chosenRoomDelegate + property string roomId + property string displayName + property url avatarUrl + property string alias + property string topic + property int memberCount + property bool isJoined + + visible: false + + contentItem: RowLayout { + KirigamiComponents.Avatar { + Layout.preferredWidth: Kirigami.Units.gridUnit * 2 + Layout.preferredHeight: Kirigami.Units.gridUnit * 2 + + source: chosenRoomDelegate.avatarUrl + name: chosenRoomDelegate.displayName + } + ColumnLayout { + Layout.fillWidth: true + RowLayout { + Layout.fillWidth: true + Kirigami.Heading { + Layout.fillWidth: true + level: 4 + text: chosenRoomDelegate.displayName + font.bold: true + textFormat: Text.PlainText + elide: Text.ElideRight + wrapMode: Text.NoWrap + } + QQC2.Label { + visible: chosenRoomDelegate.isJoined + text: i18n("Joined") + color: Kirigami.Theme.linkColor + } + } + QQC2.Label { + Layout.fillWidth: true + visible: text + text: chosenRoomDelegate.topic ? chosenRoomDelegate.topic.replace(/(\r\n\t|\n|\r\t)/gm," ") : "" + textFormat: Text.PlainText + elide: Text.ElideRight + wrapMode: Text.NoWrap + } + RowLayout { + Layout.fillWidth: true + Kirigami.Icon { + source: "user" + color: Kirigami.Theme.disabledTextColor + implicitHeight: Kirigami.Units.iconSizes.small + implicitWidth: Kirigami.Units.iconSizes.small + } + QQC2.Label { + text: chosenRoomDelegate.memberCount + " " + (chosenRoomDelegate.alias ?? chosenRoomDelegate.roomId) + color: Kirigami.Theme.disabledTextColor + elide: Text.ElideRight + Layout.fillWidth: true + } + } + } + } + + onClicked: { + let dialog = pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/JoinRoomPage.qml", {connection: root.connection}, {title: i18nc("@title", "Explore Rooms")}) + dialog.roomSelected.connect((roomId, displayName, avatarUrl, alias, topic, memberCount, isJoined) => { + chosenRoomDelegate.roomId = roomId; + chosenRoomDelegate.displayName = displayName; + chosenRoomDelegate.avatarUrl = avatarUrl; + chosenRoomDelegate.alias = alias; + chosenRoomDelegate.topic = topic; + chosenRoomDelegate.memberCount = memberCount; + chosenRoomDelegate.isJoined = isJoined; + chosenRoomDelegate.visible = true; + }) + } + } + FormCard.FormCheckDelegate { + id: existingOfficialCheck + property NeoChatRoom space: root.connection.room(chosenRoomDelegate.roomId) + text: i18n("Set this room as a child of the space %1", space?.displayName ?? "") + checked: enabled + + enabled: chosenRoomDelegate.visible && space && space.canSendState("m.space.child") + } + FormCard.FormTextDelegate { + visible: chosenRoomDelegate.visible && !root.room.canModifyParent(chosenRoomDelegate.roomId) + text: existingOfficialCheck.space ? (existingOfficialCheck.space.isSpace ? i18n("You do not have a high enough privilege level in the parent to set this state") : i18n("The selected room is not a space")) : i18n("You do not have the privileges to complete this action") + textItem.color: Kirigami.Theme.negativeTextColor + } + FormCard.FormButtonDelegate { + text: i18nc("@action:button", "Ok") + enabled: chosenRoomDelegate.visible && root.room.canModifyParent(chosenRoomDelegate.roomId) + onClicked: { + root.room.addParent(chosenRoomDelegate.roomId) + } + } + } Kirigami.InlineMessage { Layout.maximumWidth: Kirigami.Units.gridUnit * 30