diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 05fb27785..6ebb90c8c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -289,6 +289,7 @@ qt_add_qml_module(neochat URI org.kde.neochat NO_PLUGIN qml/SelectParentDialog.qml qml/Security.qml qml/QrCodeMaximizeComponent.qml + qml/SelectSpacesDialog.qml RESOURCES qml/confetti.png qml/glowdot.png diff --git a/src/neochatroom.cpp b/src/neochatroom.cpp index 4ffa54660..f9c9f4191 100644 --- a/src/neochatroom.cpp +++ b/src/neochatroom.cpp @@ -123,6 +123,7 @@ NeoChatRoom::NeoChatRoom(Connection *connection, QString roomId, JoinState joinS Q_EMIT canEncryptRoomChanged(); Q_EMIT parentIdsChanged(); Q_EMIT canonicalParentChanged(); + Q_EMIT joinRuleChanged(); }); connect(connection, &Connection::capabilitiesLoaded, this, &NeoChatRoom::maxRoomVersionChanged); connect(this, &Room::changed, this, [this]() { @@ -712,16 +713,51 @@ QString NeoChatRoom::joinRule() const return joinRulesEvent->joinRule(); } -void NeoChatRoom::setJoinRule(const QString &joinRule) +void NeoChatRoom::setJoinRule(const QString &joinRule, const QList &allowedSpaces) { if (!canSendState("m.room.join_rules"_ls)) { qWarning() << "Power level too low to set join rules"; return; } - setState("m.room.join_rules"_ls, {}, QJsonObject{{"join_rule"_ls, joinRule}}); + auto actualRule = joinRule; + if (joinRule == "restricted"_ls && allowedSpaces.isEmpty()) { + actualRule = "private"_ls; + } + + QJsonArray allowConditions; + if (actualRule == "restricted"_ls) { + for (auto allowedSpace : allowedSpaces) { + allowConditions += QJsonObject{{"type"_ls, "m.room_membership"_ls}, {"room_id"_ls, allowedSpace}}; + } + } + + QJsonObject content; + content.insert("join_rule"_ls, joinRule); + if (!allowConditions.isEmpty()) { + content.insert("allow"_ls, allowConditions); + } + qWarning() << content; + setState("m.room.join_rules"_ls, {}, content); // Not emitting joinRuleChanged() here, since that would override the change in the UI with the *current* value, which is not the *new* value. } +QList NeoChatRoom::restrictedIds() const +{ + auto joinRulesEvent = currentState().get(); + if (!joinRulesEvent) { + return {}; + } + if (joinRulesEvent->joinRule() != "restricted"_ls) { + return {}; + } + + QList roomIds; + for (auto allow : joinRulesEvent->allow()) { + roomIds += allow.toObject().value("room_id"_ls).toString(); + } + return roomIds; +} + QString NeoChatRoom::historyVisibility() const { return currentState().get("m.room.history_visibility"_ls)->contentJson()["history_visibility"_ls].toString(); @@ -1141,6 +1177,21 @@ QList NeoChatRoom::parentIds() const return parentIds; } +QList NeoChatRoom::parentObjects(bool multiLevel) const +{ + QList parentObjects; + QList parentIds = this->parentIds(); + for (const auto &parentId : parentIds) { + if (auto parentObject = static_cast(connection()->room(parentId))) { + parentObjects += parentObject; + if (multiLevel) { + parentObjects += parentObject->parentObjects(true); + } + } + } + return parentObjects; +} + QString NeoChatRoom::canonicalParent() const { auto parentEvents = currentState().eventsOfType("m.space.parent"_ls); diff --git a/src/neochatroom.h b/src/neochatroom.h index 9ae470c76..360a8c76e 100644 --- a/src/neochatroom.h +++ b/src/neochatroom.h @@ -160,6 +160,13 @@ class NeoChatRoom : public Quotient::Room */ Q_PROPERTY(QString joinRule READ joinRule WRITE setJoinRule NOTIFY joinRuleChanged) + /** + * @brief The space IDs that members of can join this room. + * + * Empty if the join rule is not restricted. + */ + Q_PROPERTY(QList restrictedIds READ restrictedIds NOTIFY joinRuleChanged) + /** * @brief Get the maximum room version that the server supports. * @@ -505,6 +512,17 @@ public: QList parentIds() const; + /** + * @brief Get a list of parent space objects for this room. + * + * Will only return retrun spaces that are know, i.e. the user has joined and + * a valid NeoChatRoom is available. + * + * @param multiLevel whether the function should recursively gather all levels + * of parents + */ + Q_INVOKABLE QList parentObjects(bool multiLevel = false) const; + QString canonicalParent() const; void setCanonicalParent(const QString &parentId); @@ -559,7 +577,23 @@ public: Q_INVOKABLE void clearInvitationNotification(); [[nodiscard]] QString joinRule() const; - void setJoinRule(const QString &joinRule); + + /** + * @brief Set the join rule for the room. + * + * Will fail if the user doesn't have the required privileges. + * + * @param joinRule the join rule [public, knock, invite, private, restricted]. + * @param allowedSpaces only used when the join rule is restricted. This is a + * list of space Matrix IDs that members of can join without an invite. + * If the rule is restricted and this list is empty it is treated as a join + * rule of private instead. + * + * @sa https://spec.matrix.org/latest/client-server-api/#mroomjoin_rules + */ + Q_INVOKABLE void setJoinRule(const QString &joinRule, const QList &allowedSpaces = {}); + + QList restrictedIds() const; int maxRoomVersion() const; diff --git a/src/qml/RoomSecurity.qml b/src/qml/RoomSecurity.qml index 065d8898b..5eb2718d5 100644 --- a/src/qml/RoomSecurity.qml +++ b/src/qml/RoomSecurity.qml @@ -3,7 +3,10 @@ // SPDX-License-Identifier: GPL-3.0-only import QtQuick +import QtQuick.Controls as QQC2 import QtQuick.Layouts + +import org.kde.kirigami as Kirigami import org.kde.kirigamiaddons.formcard as FormCard import org.kde.neochat @@ -42,18 +45,37 @@ FormCard.FormCardPage { description: i18n("Only invited people can join.") checked: room.joinRule === "invite" enabled: room.canSendState("m.room.join_rules") - onCheckedChanged: if (checked) { - room.joinRule = "invite"; + onCheckedChanged: if (checked && room.joinRule != "invite") { + root.room.joinRule = "invite"; } } FormCard.FormRadioDelegate { text: i18nc("@option:check", "Space members") - description: i18n("Anyone in a space can find and join.") + + description: i18n("Anyone in the selected spaces can find and join.") + (!["8", "9", "10"].includes(room.version) ? `\n${needUpgradeRoom}` : "") checked: room.joinRule === "restricted" - enabled: room.canSendState("m.room.join_rules") && ["8", "9", "10"].includes(room.version) && false - onCheckedChanged: if (checked) { - room.joinRule = "restricted"; + enabled: room.canSendState("m.room.join_rules") && ["8", "9", "10"].includes(room.version) + onCheckedChanged: if (checked && room.joinRule != "restricted") { + selectSpacesDialog.createObject(applicationWindow().overlay).open(); + } + + contentItem.children: QQC2.Button { + visible: root.room.joinRule === "restricted" + text: i18n("Select spaces") + icon.name: "list-add" + + onClicked: selectSpacesDialog.createObject(applicationWindow().overlay).open(); + + QQC2.ToolTip.text: text + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + QQC2.ToolTip.visible: hovered + } + + Component { + id: selectSpacesDialog + SelectSpacesDialog { + room: root.room + } } } FormCard.FormRadioDelegate { @@ -63,8 +85,8 @@ FormCard.FormCardPage { checked: room.joinRule === "knock" // https://spec.matrix.org/v1.4/rooms/#feature-matrix enabled: room.canSendState("m.room.join_rules") && ["7", "8", "9", "10"].includes(room.version) - onCheckedChanged: if (checked) { - room.joinRule = "knock"; + onCheckedChanged: if (checked && room.joinRule != "knock") { + root.room.joinRule = "knock"; } } FormCard.FormRadioDelegate { @@ -72,8 +94,8 @@ FormCard.FormCardPage { description: i18nc("@option:check", "Anyone can find and join.") checked: room.joinRule === "public" enabled: room.canSendState("m.room.join_rules") - onCheckedChanged: if (checked) { - room.joinRule = "public"; + onCheckedChanged: if (checked && root.room.joinRule != "public") { + root.room.joinRule = "public"; } } } diff --git a/src/qml/SelectSpacesDialog.qml b/src/qml/SelectSpacesDialog.qml new file mode 100644 index 000000000..eac9dc535 --- /dev/null +++ b/src/qml/SelectSpacesDialog.qml @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: 2023 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +import QtQuick +import QtQuick.Controls as QQC2 +import QtQuick.Layouts + +import org.kde.kirigami as Kirigami +import org.kde.kirigamiaddons.formcard as FormCard +import org.kde.kirigamiaddons.labs.components as Components + +import org.kde.neochat + +Kirigami.Dialog { + id: root + + /** + * @brief The current room this dialog is opened for. + */ + required property NeoChatRoom room + + /** + * @brief The current list of space IDs that members of can join this room. + */ + property list restrictedIds: room.restrictedIds + + parent: applicationWindow().overlay + + leftPadding: 0 + rightPadding: 0 + topPadding: 0 + bottomPadding: 0 + + width: Math.min(applicationWindow().width, Kirigami.Units.gridUnit * 24) + title: i18nc("@title", "Select Spaces") + + standardButtons: Kirigami.Dialog.Ok | Kirigami.Dialog.Cancel + onAccepted: { + let ids = []; + for (var i in spaceGroup.buttons) { + if (spaceGroup.buttons[i].checked) { + ids.push(spaceGroup.buttons[i].modelData.id); + } + } + root.room.setJoinRule("restricted", ids) + console.warn(ids) + } + + QQC2.ButtonGroup { + id: spaceGroup + exclusive: false + } + + contentItem: ColumnLayout { + spacing: 0 + Repeater { + model: root.room.parentObjects(true) + + delegate: FormCard.FormCheckDelegate { + required property var modelData + + text: modelData.displayName + description: modelData.canonicalAlias + checked: root.restrictedIds.includes(modelData.id) + QQC2.ButtonGroup.group: spaceGroup + + leading: Components.Avatar { + Layout.preferredWidth: Kirigami.Units.gridUnit * 2 + Layout.preferredHeight: Kirigami.Units.gridUnit * 2 + + source: modelData.avatarUrl.toString().length > 0 ? connection.makeMediaUrl(modelData.avatarUrl) : "" + name: modelData.displayName + } + } + } + } +}