From 6c6d43691d98aa02513350b52fe736fff6d6071d Mon Sep 17 00:00:00 2001 From: Nicolas Werner Date: Fri, 27 May 2022 16:31:54 +0200 Subject: [PATCH] Add basic powerlevel editor --- CMakeLists.txt | 2 + resources/qml/Completer.qml | 28 +- resources/qml/Root.qml | 16 + .../qml/components/ReorderableListview.qml | 126 +++++ resources/qml/dialogs/PowerLevelEditor.qml | 347 ++++++++++++ resources/qml/dialogs/RoomSettings.qml | 12 + resources/res.qrc | 6 +- src/MainWindow.cpp | 7 + src/PowerlevelsEditModels.cpp | 534 ++++++++++++++++++ src/PowerlevelsEditModels.h | 140 +++++ src/timeline/TimelineModel.h | 1 + src/ui/NhekoGlobalObject.h | 5 + 12 files changed, 1212 insertions(+), 12 deletions(-) create mode 100644 resources/qml/components/ReorderableListview.qml create mode 100644 resources/qml/dialogs/PowerLevelEditor.qml create mode 100644 src/PowerlevelsEditModels.cpp create mode 100644 src/PowerlevelsEditModels.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 1b9a7581..bd854cab 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -370,6 +370,7 @@ set(SRC_FILES src/MatrixClient.cpp src/MemberList.cpp src/MxcImageProvider.cpp + src/PowerlevelsEditModels.cpp src/ReadReceiptsModel.cpp src/RegisterPage.cpp src/SSOHandler.cpp @@ -573,6 +574,7 @@ qt5_wrap_cpp(MOC_HEADERS src/TrayIcon.h src/UserSettingsPage.h src/UsersModel.h + src/PowerlevelsEditModels.h src/RoomDirectoryModel.h src/RoomsModel.h src/ReadReceiptsModel.h diff --git a/resources/qml/Completer.qml b/resources/qml/Completer.qml index a16ffa65..7e14e734 100644 --- a/resources/qml/Completer.qml +++ b/resources/qml/Completer.qml @@ -13,6 +13,7 @@ Control { id: popup property alias currentIndex: listView.currentIndex + property string roomId property string completerName property var completer property bool bottomToTop: true @@ -24,6 +25,10 @@ Control { property int rowSpacing: 5 property alias count: listView.count + Component.onCompleted: { + console.log("RRRRRRRRRR: " + roomId); + } + signal completionClicked(string completion) signal completionSelected(string id) @@ -65,18 +70,22 @@ Control { function finishCompletion() { if (popup.completerName == "room") popup.completionSelected(listView.itemAtIndex(currentIndex).modelData.roomid); + else if (popup.completerName == "user") + popup.completionSelected(listView.itemAtIndex(currentIndex).modelData.userid); } - onCompleterNameChanged: { + function changeCompleter() { if (completerName) { - completer = TimelineManager.completerFor(completerName, completerName == "room" ? "" : room.roomId); + completer = TimelineManager.completerFor(completerName, completerName == "room" ? "" : (popup.roomId != "" ? popup.roomId : room.roomId)); completer.setSearchString(""); } else { completer = undefined; } currentIndex = -1 } + onCompleterNameChanged: changeCompleter() + onRoomIdChanged: changeCompleter() bottomPadding: 1 leftPadding: 1 @@ -131,6 +140,8 @@ Control { popup.completionClicked(completer.completionAt(model.index)); if (popup.completerName == "room") popup.completionSelected(model.roomid); + else if (popup.completerName == "user") + popup.completionSelected(model.userid); } } Ripple { @@ -151,7 +162,7 @@ Control { RowLayout { id: del - anchors.centerIn: parent + anchors.centerIn: centerRowContent ? parent : undefined spacing: rowSpacing Avatar { @@ -160,7 +171,7 @@ Control { displayName: model.displayName userid: model.userid url: model.avatarUrl.replace("mxc://", "image://MxcImage/") - onClicked: popup.completionClicked(completer.completionAt(model.index)) + enabled: false } Label { @@ -216,7 +227,7 @@ Control { displayName: model.shortcode //userid: model.shortcode url: model.url.replace("mxc://", "image://MxcImage/") - onClicked: popup.completionClicked(completer.completionAt(model.index)) + enabled: false crop: false } @@ -249,10 +260,7 @@ Control { displayName: model.roomName roomid: model.roomid url: model.avatarUrl.replace("mxc://", "image://MxcImage/") - onClicked: { - popup.completionClicked(completer.completionAt(model.index)); - popup.completionSelected(model.roomid); - } + enabled: false } Label { @@ -281,7 +289,7 @@ Control { displayName: model.roomName roomid: model.roomid url: model.avatarUrl.replace("mxc://", "image://MxcImage/") - onClicked: popup.completionClicked(completer.completionAt(model.index)) + enabled: false } Label { diff --git a/resources/qml/Root.qml b/resources/qml/Root.qml index 00600508..86ddc649 100644 --- a/resources/qml/Root.qml +++ b/resources/qml/Root.qml @@ -51,6 +51,22 @@ Pane { } + function showPLEditor(settings) { + var dialog = plEditor.createObject(timelineRoot, { + "roomSettings": settings + }); + dialog.show(); + destroyOnClose(dialog); + } + + Component { + id: plEditor + + PowerLevelEditor { + } + } + + Component { id: roomSettingsComponent diff --git a/resources/qml/components/ReorderableListview.qml b/resources/qml/components/ReorderableListview.qml new file mode 100644 index 00000000..7e9ae05d --- /dev/null +++ b/resources/qml/components/ReorderableListview.qml @@ -0,0 +1,126 @@ +// SPDX-FileCopyrightText: 2022 Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +import QtQuick 2.15 +import QtQml.Models 2.1 +import im.nheko 1.0 +import ".." + +Item { + id: root + + property alias model: visualModel.model + property Component delegate + + Component { + id: dragDelegate + + MouseArea { + id: dragArea + + required property var model + required property int index + + enabled: model.moveable == undefined || model.moveable + + property bool held: false + + anchors { left: parent.left; right: parent.right } + height: content.height + + drag.target: held ? content : undefined + drag.axis: Drag.YAxis + + onPressAndHold: held = true + onPressed: if (mouse.source !== Qt.MouseEventNotSynthesized) { held = true } + onReleased: held = false + onHeldChanged: if (held) ListView.view.currentIndex = dragArea.index; else ListView.view.currentIndex = -1 + + Rectangle { + id: content + + anchors { + horizontalCenter: parent.horizontalCenter + verticalCenter: parent.verticalCenter + } + width: dragArea.width; height: actualDelegate.implicitHeight + 4 + + border.width: dragArea.enabled ? 1 : 0 + border.color: Nheko.colors.highlight + + color: dragArea.held ? Nheko.colors.highlight : Nheko.colors.base + Behavior on color { ColorAnimation { duration: 100 } } + + radius: 2 + + Drag.active: dragArea.held + Drag.source: dragArea + Drag.hotSpot.x: width / 2 + Drag.hotSpot.y: height / 2 + + states: State { + when: dragArea.held + + ParentChange { target: content; parent: root } + AnchorChanges { + target: content + anchors { horizontalCenter: undefined; verticalCenter: undefined } + } + } + + Loader { + id: actualDelegate + sourceComponent: root.delegate + property var model: dragArea.model + property int index: dragArea.index + property int offset: -view.contentY + dragArea.y + anchors { fill: parent; margins: 2 } + } + + } + + DropArea { + enabled: index != 0 || model.moveable == undefined || model.moveable + anchors { fill: parent; margins: 8 } + + onEntered: (drag)=> { + visualModel.model.move(drag.source.index, dragArea.index) + } + } + + } + } + + + DelegateModel { + id: visualModel + + delegate: dragDelegate + } + + ListView { + id: view + + clip: true + + anchors { fill: parent; margins: 2 } + ScrollHelper { + flickable: parent + anchors.fill: parent + } + + model: visualModel + + highlightRangeMode: ListView.ApplyRange + preferredHighlightBegin: 0.2 * height + preferredHighlightEnd: 0.8 * height + + spacing: 4 + cacheBuffer: 50 + } + + + } + + diff --git a/resources/qml/dialogs/PowerLevelEditor.qml b/resources/qml/dialogs/PowerLevelEditor.qml new file mode 100644 index 00000000..241585f9 --- /dev/null +++ b/resources/qml/dialogs/PowerLevelEditor.qml @@ -0,0 +1,347 @@ +// SPDX-FileCopyrightText: 2022 Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +import ".." +import "../components" +import QtQuick 2.12 +import QtQuick.Controls 2.5 +import QtQuick.Layouts 1.3 +import im.nheko 1.0 + + +ApplicationWindow { + id: plEditorW + + property var roomSettings + property var editingModel: Nheko.editPowerlevels(roomSettings.roomId) + + modality: Qt.NonModal + flags: Qt.Dialog | Qt.WindowCloseButtonHint | Qt.WindowTitleHint + minimumWidth: 300 + minimumHeight: 400 + + title: qsTr("Permissions in %1").arg(roomSettings.roomName); + +// Shortcut { +// sequence: StandardKey.Cancel +// onActivated: dbb.rejected() +// } + + ColumnLayout { + anchors.margins: Nheko.paddingMedium + anchors.fill: parent + spacing: 0 + + + MatrixText { + text: qsTr("Be careful when editing permissions. You can't lower the permissions of people with a same or higher level than you. Be careful when promoting others.") + font.pixelSize: Math.floor(fontMetrics.font.pixelSize * 1.1) + Layout.fillWidth: true + Layout.fillHeight: false + color: Nheko.colors.text + Layout.bottomMargin: Nheko.paddingMedium + } + + TabBar { + id: bar + width: parent.width + palette: Nheko.colors + + component TabB : TabButton { + id: control + + contentItem: Text { + text: control.text + font: control.font + opacity: enabled ? 1.0 : 0.3 + color: control.down ? Nheko.colors.highlightedText : Nheko.colors.text + horizontalAlignment: Text.AlignHCenter + verticalAlignment: Text.AlignVCenter + elide: Text.ElideRight + } + + background: Rectangle { + border.color: control.down ? Nheko.colors.highlight : Nheko.theme.separator + color: control.checked ? Nheko.colors.highlight : Nheko.colors.base + border.width: 1 + radius: 2 + } + } + TabB { + text: qsTr("Roles") + } + TabB { + text: qsTr("Users") + } + } + Rectangle { + Layout.fillWidth: true + Layout.fillHeight: true + color: Nheko.colors.alternateBase + border.width: 1 + border.color: Nheko.theme.separator + + StackLayout { + anchors.fill: parent + anchors.margins: Nheko.paddingMedium + currentIndex: bar.currentIndex + + + ColumnLayout { + spacing: Nheko.paddingMedium + + MatrixText { + text: qsTr("Move permissions between roles to change them") + font.pixelSize: Math.floor(fontMetrics.font.pixelSize * 1.1) + Layout.fillWidth: true + Layout.fillHeight: false + color: Nheko.colors.text + } + + ReorderableListview { + Layout.fillWidth: true + Layout.fillHeight: true + + model: editingModel.types + + delegate: RowLayout { + Column { + Layout.fillWidth: true + + Text { visible: model.isType; text: model.displayName; color: Nheko.colors.text} + Text { + visible: !model.isType; + text: { + if (editingModel.adminLevel == model.powerlevel) + return qsTr("Administrator (%1)").arg(model.powerlevel) + else if (editingModel.moderatorLevel == model.powerlevel) + return qsTr("Moderator (%1)").arg(model.powerlevel) + else + return qsTr("Custom (%1)").arg(model.powerlevel) + } + color: Nheko.colors.text + } + } + + ImageButton { + Layout.alignment: Qt.AlignRight + Layout.rightMargin: 2 + image: model.isType ? ":/icons/icons/ui/dismiss.svg" : ":/icons/icons/ui/add-square-button.svg" + visible: !model.isType || model.removeable + hoverEnabled: true + ToolTip.visible: hovered + ToolTip.text: model.isType ? qsTr("Remove event type") : qsTr("Add event type") + onClicked: { + if (model.isType) { + editingModel.types.remove(index); + } else { + typeEntry.y = offset + typeEntry.visible = true + typeEntry.index = index; + typeEntry.forceActiveFocus() + } + } + } + } + MatrixTextField { + id: typeEntry + + property int index + + width: parent.width + z: 5 + visible: false + + color: Nheko.colors.text + + Keys.onPressed: { + if (typeEntry.text.includes('.') && event.matches(StandardKey.InsertParagraphSeparator)) { + editingModel.types.add(typeEntry.index, typeEntry.text) + typeEntry.visible = false; + typeEntry.clear(); + event.accepted = true; + } + else if (event.matches(StandardKey.Cancel)) { + typeEntry.visible = false; + typeEntry.clear(); + event.accepted = true; + } + } + } + } + + } + ColumnLayout { + spacing: Nheko.paddingMedium + + MatrixText { + text: qsTr("Move users up or down to change their permissions") + font.pixelSize: Math.floor(fontMetrics.font.pixelSize * 1.1) + Layout.fillWidth: true + Layout.fillHeight: false + } + + ReorderableListview { + Layout.fillWidth: true + Layout.fillHeight: true + + model: editingModel.users + + Column{ + id: userEntryCompleter + + property int index: 0 + + visible: false + + width: parent.width + spacing: 1 + z: 5 + MatrixTextField { + id: userEntry + + width: parent.width + //font.pixelSize: Math.ceil(quickSwitcher.textHeight * 0.6) + color: Nheko.colors.text + onTextEdited: { + userCompleter.completer.searchString = text; + } + Keys.onPressed: { + if (event.key == Qt.Key_Up || event.key == Qt.Key_Backtab) { + event.accepted = true; + userCompleter.up(); + } else if (event.key == Qt.Key_Down || event.key == Qt.Key_Tab) { + event.accepted = true; + if (event.key == Qt.Key_Tab && (event.modifiers & Qt.ShiftModifier)) + userCompleter.up(); + else + userCompleter.down(); + } else if (event.matches(StandardKey.InsertParagraphSeparator)) { + userCompleter.finishCompletion(); + event.accepted = true; + } else if (event.matches(StandardKey.Cancel)) { + typeEntry.visible = false; + typeEntry.clear(); + event.accepted = true; + } + } + } + + + Completer { + id: userCompleter + + visible: userEntry.text.length > 0 + width: parent.width + roomId: plEditorW.roomSettings.roomId + completerName: "user" + bottomToTop: false + fullWidth: true + avatarHeight: Nheko.avatarSize / 2 + avatarWidth: Nheko.avatarSize / 2 + centerRowContent: false + rowMargin: 2 + rowSpacing: 2 + } + } + + Connections { + function onCompletionSelected(id) { + console.log("selected: " + id); + editingModel.users.add(userEntryCompleter.index, id); + userEntry.clear(); + userEntryCompleter.visible = false; + } + + function onCountChanged() { + if (userCompleter.count > 0 && (userCompleter.currentIndex < 0 || userCompleter.currentIndex >= userCompleter.count)) + userCompleter.currentIndex = 0; + + } + + target: userCompleter + } + + delegate: RowLayout { + //anchors { fill: parent; margins: 2 } + id: row + + Avatar { + id: avatar + + Layout.preferredHeight: Nheko.avatarSize / 2 + Layout.preferredWidth: Nheko.avatarSize / 2 + Layout.leftMargin: 2 + userid: model.mxid + url: { + if (model.isUser) + return model.avatarUrl.replace("mxc://", "image://MxcImage/") + else if (editingModel.adminLevel >= model.powerlevel) + return "image://colorimage/:/icons/icons/ui/ribbon_star.svg?" + Nheko.colors.buttonText; + else if (editingModel.moderatorLevel >= model.powerlevel) + return "image://colorimage/:/icons/icons/ui/ribbon.svg?" + Nheko.colors.buttonText; + else + return "image://colorimage/:/icons/icons/ui/person.svg?" + Nheko.colors.buttonText; + } + displayName: model.displayName + enabled: false + } + Column { + Layout.fillWidth: true + + Text { visible: model.isUser; text: model.displayName; color: Nheko.colors.text} + Text { visible: model.isUser; text: model.mxid; color: Nheko.colors.text} + Text { + visible: !model.isUser; + text: { + if (editingModel.adminLevel == model.powerlevel) + return qsTr("Administrator (%1)").arg(model.powerlevel) + else if (editingModel.moderatorLevel == model.powerlevel) + return qsTr("Moderator (%1)").arg(model.powerlevel) + else + return qsTr("Custom (%1)").arg(model.powerlevel) + } + color: Nheko.colors.text + } + } + + ImageButton { + Layout.alignment: Qt.AlignRight + Layout.rightMargin: 2 + image: model.isUser ? ":/icons/icons/ui/dismiss.svg" : ":/icons/icons/ui/add-square-button.svg" + visible: !model.isUser || model.removeable + hoverEnabled: true + ToolTip.visible: hovered + ToolTip.text: model.isUser ? qsTr("Remove user") : qsTr("Add user") + onClicked: { + if (model.isUser) { + editingModel.users.remove(index); + } else { + userEntryCompleter.y = offset + userEntryCompleter.visible = true + userEntryCompleter.index = index; + userEntry.forceActiveFocus() + } + } + } + } + } + + } + } + } + } + + footer: DialogButtonBox { + id: dbb + + standardButtons: DialogButtonBox.Ok | DialogButtonBox.Cancel + onAccepted: { + editingModel.commit(); + plEditorW.close(); + } + onRejected: plEditorW.close(); + } + + } diff --git a/resources/qml/dialogs/RoomSettings.qml b/resources/qml/dialogs/RoomSettings.qml index 4a7b24fe..332a7b09 100644 --- a/resources/qml/dialogs/RoomSettings.qml +++ b/resources/qml/dialogs/RoomSettings.qml @@ -335,6 +335,18 @@ ApplicationWindow { buttons: Platform.MessageDialog.Ok | Platform.MessageDialog.Cancel } + Label { + text: qsTr("Permission") + color: Nheko.colors.text + } + + Button { + text: qsTr("Configure") + ToolTip.text: qsTr("View and change the permissions in this room") + onClicked: timelineRoot.showPLEditor(roomSettings) + Layout.alignment: Qt.AlignRight + } + Label { text: qsTr("Sticker & Emote Settings") color: Nheko.colors.text diff --git a/resources/res.qrc b/resources/res.qrc index 35b06704..6e3023ea 100644 --- a/resources/res.qrc +++ b/resources/res.qrc @@ -127,6 +127,7 @@ qml/components/AvatarListTile.qml qml/components/FlatButton.qml qml/components/MainWindowDialog.qml + qml/components/ReorderableListview.qml qml/components/TextButton.qml qml/delegates/Encrypted.qml qml/delegates/FileMessage.qml @@ -148,22 +149,23 @@ qml/device-verification/Waiting.qml qml/dialogs/CreateDirect.qml qml/dialogs/CreateRoom.qml + qml/dialogs/HiddenEventsDialog.qml qml/dialogs/ImageOverlay.qml qml/dialogs/ImagePackEditorDialog.qml qml/dialogs/ImagePackSettingsDialog.qml - qml/dialogs/PhoneNumberInputDialog.qml qml/dialogs/InputDialog.qml qml/dialogs/InviteDialog.qml qml/dialogs/JoinRoomDialog.qml qml/dialogs/LeaveRoomDialog.qml qml/dialogs/LogoutDialog.qml + qml/dialogs/PhoneNumberInputDialog.qml + qml/dialogs/PowerLevelEditor.qml qml/dialogs/RawMessageDialog.qml qml/dialogs/ReadReceipts.qml qml/dialogs/RoomDirectory.qml qml/dialogs/RoomMembers.qml qml/dialogs/RoomSettings.qml qml/dialogs/UserProfile.qml - qml/dialogs/HiddenEventsDialog.qml qml/emoji/EmojiPicker.qml qml/emoji/StickerPicker.qml qml/ui/NhekoSlider.qml diff --git a/src/MainWindow.cpp b/src/MainWindow.cpp index 6cae64b2..c700294c 100644 --- a/src/MainWindow.cpp +++ b/src/MainWindow.cpp @@ -29,6 +29,7 @@ #include "MatrixClient.h" #include "MemberList.h" #include "MxcImageProvider.h" +#include "PowerlevelsEditModels.h" #include "ReadReceiptsModel.h" #include "RegisterPage.h" #include "RoomDirectoryModel.h" @@ -174,6 +175,12 @@ MainWindow::registerQmlTypes() qmlRegisterType("im.nheko", 1, 0, "Login"); qmlRegisterType("im.nheko", 1, 0, "Registration"); qmlRegisterType("im.nheko", 1, 0, "HiddenEvents"); + qmlRegisterUncreatableType( + "im.nheko", + 1, + 0, + "PowerlevelEditingModels", + QStringLiteral("Please use editPowerlevels to create the models")); qmlRegisterUncreatableType( "im.nheko", 1, diff --git a/src/PowerlevelsEditModels.cpp b/src/PowerlevelsEditModels.cpp new file mode 100644 index 00000000..b0244b08 --- /dev/null +++ b/src/PowerlevelsEditModels.cpp @@ -0,0 +1,534 @@ +// SPDX-FileCopyrightText: 2022 Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "PowerlevelsEditModels.h" + +#include +#include + +#include "Cache.h" +#include "Cache_p.h" +#include "ChatPage.h" +#include "Logging.h" +#include "MatrixClient.h" + +PowerlevelsTypeListModel::PowerlevelsTypeListModel(const std::string &rid, + const mtx::events::state::PowerLevels &pl, + QObject *parent) + : QAbstractListModel(parent) + , room_id(rid) + , powerLevels_(pl) +{ + std::set seen_levels; + for (const auto &[type, level] : powerLevels_.events) { + if (!seen_levels.count(level)) { + types.push_back(Entry{"", level}); + seen_levels.insert(level); + } + types.push_back(Entry{type, level}); + } + + for (const auto &[user, level] : powerLevels_.users) { + (void)user; + if (!seen_levels.count(level)) { + types.push_back(Entry{"", level}); + seen_levels.insert(level); + } + } + + for (const auto &level : { + powerLevels_.events_default, + powerLevels_.state_default, + powerLevels_.users_default, + powerLevels_.ban, + powerLevels_.kick, + powerLevels_.invite, + powerLevels_.redact, + }) { + if (!seen_levels.count(level)) { + types.push_back(Entry{"", level}); + seen_levels.insert(level); + } + } + + types.push_back(Entry{"zdefault_states", powerLevels_.state_default}); + types.push_back(Entry{"zdefault_events", powerLevels_.events_default}); + types.push_back(Entry{"ban", powerLevels_.ban}); + types.push_back(Entry{"kick", powerLevels_.kick}); + types.push_back(Entry{"invite", powerLevels_.invite}); + types.push_back(Entry{"redact", powerLevels_.redact}); + + std::sort(types.begin(), types.end(), [](const Entry &a, const Entry &b) { + if (a.pl != b.pl) // sort by PL + return a.pl > b.pl; + else if (a.type.empty() != b.type.empty()) // empty types are headers + return a.type.empty() > b.type.empty(); + else { + bool a_contains_dot = a.type.find('.') != std::string::npos; + bool b_contains_dot = b.type.find('.') != std::string::npos; + if (a_contains_dot != b_contains_dot) // sort stuff like "invite" or "default" last + return a_contains_dot > b_contains_dot; + else // rest is sorted alphabetical + return a.type < b.type; + } + }); +} + +std::map> +PowerlevelsTypeListModel::toEvents() +{ + std::map> m; + for (const auto &[key, pl] : types) + if (key.find('.') != std::string::npos) + m[key] = pl; + return m; +} +mtx::events::state::power_level_t +PowerlevelsTypeListModel::kick() +{ + for (const auto &[key, pl] : types) + if (key == "kick") + return pl; + return powerLevels_.users_default; +} +mtx::events::state::power_level_t +PowerlevelsTypeListModel::invite() +{ + for (const auto &[key, pl] : types) + if (key == "invite") + return pl; + return powerLevels_.users_default; +} +mtx::events::state::power_level_t +PowerlevelsTypeListModel::ban() +{ + for (const auto &[key, pl] : types) + if (key == "ban") + return pl; + return powerLevels_.users_default; +} +mtx::events::state::power_level_t +PowerlevelsTypeListModel::eventsDefault() +{ + for (const auto &[key, pl] : types) + if (key == "zdefault_events") + return pl; + return powerLevels_.users_default; +} +mtx::events::state::power_level_t +PowerlevelsTypeListModel::stateDefault() +{ + for (const auto &[key, pl] : types) + if (key == "zdefault_states") + return pl; + return powerLevels_.users_default; +} + +QHash +PowerlevelsTypeListModel::roleNames() const +{ + return { + {DisplayName, "displayName"}, + {Powerlevel, "powerlevel"}, + {IsType, "isType"}, + {Moveable, "moveable"}, + {Removeable, "removeable"}, + }; +} + +QVariant +PowerlevelsTypeListModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() >= types.size()) + return {}; + + const auto &type = types.at(index.row()); + + switch (static_cast(role)) { + case DisplayName: + if (type.type == "zdefault_events") + return tr("Other events"); + else if (type.type == "zdefault_states") + return tr("Other state events"); + else if (type.type == "kick") + return tr("Remove other users"); + else if (type.type == "ban") + return tr("Ban other users"); + else if (type.type == "invite") + return tr("Invite other users"); + else if (type.type == "redact") + return tr("Redact events sent by others"); + else if (type.type == "m.reaction") + return tr("Reactions"); + else if (type.type == "m.room.aliases") + return tr("Deprecated aliases events"); + else if (type.type == "m.room.avatar") + return tr("Change the room avatar"); + else if (type.type == "m.room.canonical_alias") + return tr("Change the room addresses"); + else if (type.type == "m.room.encrypted") + return tr("Send encrypted messages"); + else if (type.type == "m.room.encryption") + return tr("Enable encryption"); + else if (type.type == "m.room.guest_access") + return tr("Change guest access"); + else if (type.type == "m.room.history_visibility") + return tr("Change history visibility"); + else if (type.type == "m.room.join_rules") + return tr("Change who can join"); + else if (type.type == "m.room.message") + return tr("Send messages"); + else if (type.type == "m.room.name") + return tr("Change the room name"); + else if (type.type == "m.room.power_levels") + return tr("Change the room permissions"); + else if (type.type == "m.room.topic") + return tr("Change the rooms topic"); + else if (type.type == "m.widget") + return tr("Change the widgets"); + else if (type.type == "im.vector.modular.widgets") + return tr("Change the widgets (experimental)"); + else if (type.type == "m.room.redaction") + return tr("Redact own events"); + else if (type.type == "m.room.pinned_events") + return tr("Change the pinned events"); + else if (type.type == "m.room.tombstone") + return tr("Upgrade the room"); + else if (type.type == "m.sticker") + return tr("Send stickers"); + + else if (type.type == "m.space.child") + return tr("Edit child rooms"); + else if (type.type == "m.space.parent") + return tr("Change parent spaces"); + + else if (type.type == "m.call.invite") + return tr("Start a call"); + else if (type.type == "m.call.candidates") + return tr("Negotiate a call"); + else if (type.type == "m.call.answer") + return tr("Answer a call"); + else if (type.type == "m.call.hangup") + return tr("Hang up a call"); + else if (type.type == "im.ponies.room_emotes") + return tr("Change the room emotes"); + return QString::fromStdString(type.type); + case Powerlevel: + return static_cast(type.pl); + case IsType: + return !type.type.empty(); + case Moveable: + return !type.type.empty(); + case Removeable: + return !type.type.empty() && type.type.find('.') != std::string::npos; + } + + return {}; +} + +bool +PowerlevelsTypeListModel::remove(int row) +{ + if (row < 0 || row >= types.size() || types.at(row).type.empty()) + return false; + + beginRemoveRows(QModelIndex(), row, row); + types.remove(row); + endRemoveRows(); + + return true; +} +void +PowerlevelsTypeListModel::add(int row, QString type) +{ + if (row < 0 || row > types.size()) + return; + + const auto typeStr = type.toStdString(); + for (int i = 0; i < types.size(); i++) { + if (types[i].type == typeStr) { + if (i > row) + move(i, row + 1); + else + move(i, row); + return; + } + } + + beginInsertRows(QModelIndex(), row + 1, row + 1); + types.insert(row + 1, Entry{type.toStdString(), types.at(row).pl}); + endInsertRows(); +} + +bool +PowerlevelsTypeListModel::move(int from, int to) +{ + if (from == to) + return false; + if (from < to) + to += 1; + + beginMoveRows(QModelIndex(), from, from, QModelIndex(), to); + auto ret = moveRow(QModelIndex(), from, QModelIndex(), to); + endMoveRows(); + return ret; +} + +bool +PowerlevelsTypeListModel::moveRows(const QModelIndex &, + int sourceRow, + int count, + const QModelIndex &, + int destinationChild) +{ + if (sourceRow == destinationChild) + return true; + + if (count != 1) + return false; + + if (sourceRow < 0 || sourceRow >= types.size()) + return false; + if (destinationChild < 0 || destinationChild > types.size()) + return false; + + if (types.at(sourceRow).type.empty()) + return false; + + auto pl = types.at(destinationChild > 0 ? destinationChild - 1 : 0).pl; + auto sourceItem = types.takeAt(sourceRow); + sourceItem.pl = pl; + if (destinationChild < sourceRow) + types.insert(destinationChild, std::move(sourceItem)); + else + types.insert(destinationChild - 1, std::move(sourceItem)); + return true; +} + +PowerlevelsUserListModel::PowerlevelsUserListModel(const std::string &rid, + const mtx::events::state::PowerLevels &pl, + QObject *parent) + : QAbstractListModel(parent) + , room_id(rid) + , powerLevels_(pl) +{ + std::set seen_levels; + for (const auto &[user, level] : powerLevels_.users) { + if (!seen_levels.count(level)) { + users.push_back(Entry{"", level}); + seen_levels.insert(level); + } + users.push_back(Entry{user, level}); + } + + for (const auto &[type, level] : powerLevels_.events) { + (void)type; + if (!seen_levels.count(level)) { + users.push_back(Entry{"", level}); + seen_levels.insert(level); + } + } + + for (const auto &level : { + powerLevels_.events_default, + powerLevels_.state_default, + powerLevels_.users_default, + powerLevels_.ban, + powerLevels_.kick, + powerLevels_.invite, + powerLevels_.redact, + }) { + if (!seen_levels.count(level)) { + users.push_back(Entry{"", level}); + seen_levels.insert(level); + } + } + + users.push_back(Entry{"default", powerLevels_.users_default}); + + std::sort(users.begin(), users.end(), [](const Entry &a, const Entry &b) { + if (a.pl != b.pl) + return a.pl > b.pl; + else + return a.mxid < b.mxid; + }); +} + +std::map> +PowerlevelsUserListModel::toUsers() +{ + std::map> m; + for (const auto &[key, pl] : users) + if (key.size() > 0 && key.at(0) == '@') + m[key] = pl; + return m; +} +mtx::events::state::power_level_t +PowerlevelsUserListModel::usersDefault() +{ + for (const auto &[key, pl] : users) + if (key == "default") + return pl; + return powerLevels_.users_default; +} + +QHash +PowerlevelsUserListModel::roleNames() const +{ + return { + {Mxid, "mxid"}, + {DisplayName, "displayName"}, + {AvatarUrl, "avatarUrl"}, + {Powerlevel, "powerlevel"}, + {IsUser, "isUser"}, + {Moveable, "moveable"}, + {Removeable, "removeable"}, + }; +} + +QVariant +PowerlevelsUserListModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid() || index.row() >= users.size()) + return {}; + + const auto &user = users.at(index.row()); + + switch (static_cast(role)) { + case Mxid: + if ("default" == user.mxid) + return QStringLiteral("*"); + return QString::fromStdString(user.mxid); + case DisplayName: + if (user.mxid == "default") + return tr("Other users"); + return QString::fromStdString(cache::displayName(room_id, user.mxid)); + case AvatarUrl: + return cache::avatarUrl(QString::fromStdString(room_id), QString::fromStdString(user.mxid)); + case Powerlevel: + return static_cast(user.pl); + case IsUser: + return !user.mxid.empty(); + case Moveable: + return !user.mxid.empty(); + case Removeable: + return !user.mxid.empty() && user.mxid.find('.') != std::string::npos; + } + + return {}; +} + +bool +PowerlevelsUserListModel::remove(int row) +{ + if (row < 0 || row >= users.size() || users.at(row).mxid.empty()) + return false; + + beginRemoveRows(QModelIndex(), row, row); + users.remove(row); + endRemoveRows(); + + return true; +} + +void +PowerlevelsUserListModel::add(int row, QString user) +{ + if (row < 0 || row > users.size()) + return; + + const auto userStr = user.toStdString(); + for (int i = 0; i < users.size(); i++) { + if (users[i].mxid == userStr) { + if (i > row) + move(i, row + 1); + else + move(i, row); + return; + } + } + + beginInsertRows(QModelIndex(), row + 1, row + 1); + users.insert(row + 1, Entry{user.toStdString(), users.at(row).pl}); + endInsertRows(); +} + +bool +PowerlevelsUserListModel::move(int from, int to) +{ + if (from == to) + return false; + if (from < to) + to += 1; + + beginMoveRows(QModelIndex(), from, from, QModelIndex(), to); + auto ret = moveRow(QModelIndex(), from, QModelIndex(), to); + endMoveRows(); + return ret; +} + +bool +PowerlevelsUserListModel::moveRows(const QModelIndex &, + int sourceRow, + int count, + const QModelIndex &, + int destinationChild) +{ + if (sourceRow == destinationChild) + return true; + + if (count != 1) + return false; + + if (sourceRow < 0 || sourceRow >= users.size()) + return false; + if (destinationChild < 0 || destinationChild > users.size()) + return false; + + if (users.at(sourceRow).mxid.empty()) + return false; + + auto pl = users.at(destinationChild > 0 ? destinationChild - 1 : 0).pl; + auto sourceItem = users.takeAt(sourceRow); + sourceItem.pl = pl; + if (destinationChild < sourceRow) + users.insert(destinationChild, std::move(sourceItem)); + else + users.insert(destinationChild - 1, std::move(sourceItem)); + return true; +} + +PowerlevelEditingModels::PowerlevelEditingModels(QString room_id, QObject *parent) + : QObject(parent) + , powerLevels_(cache::client() + ->getStateEvent(room_id.toStdString()) + .value_or(mtx::events::StateEvent{}) + .content) + , types_(room_id.toStdString(), powerLevels_, this) + , users_(room_id.toStdString(), powerLevels_, this) + , room_id_(room_id.toStdString()) +{} + +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(); + + http::client()->send_state_event( + room_id_, powerLevels_, [](const mtx::responses::EventId &, mtx::http::RequestErr e) { + if (e) { + nhlog::net()->error("Failed to send PL event: {}", *e); + ChatPage::instance()->showNotification( + tr("Failed to update powerlevel: %1") + .arg(QString::fromStdString(e->matrix_error.error))); + } + }); +} diff --git a/src/PowerlevelsEditModels.h b/src/PowerlevelsEditModels.h new file mode 100644 index 00000000..7bc797ea --- /dev/null +++ b/src/PowerlevelsEditModels.h @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: 2022 Nheko Contributors +// +// SPDX-License-Identifier: GPL-3.0-or-later + +#pragma once + +#include +#include + +#include + +#include "CacheStructs.h" + +class PowerlevelsTypeListModel : public QAbstractListModel +{ + Q_OBJECT + +public: + enum Roles + { + DisplayName, + Powerlevel, + IsType, + Moveable, + Removeable, + }; + + explicit PowerlevelsTypeListModel(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(types.size()); } + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + Q_INVOKABLE bool remove(int row); + Q_INVOKABLE bool move(int from, int to); + Q_INVOKABLE void add(int index, QString type); + + bool moveRows(const QModelIndex &sourceParent, + int sourceRow, + int count, + 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(); + +private: + struct Entry + { + std::string type; + mtx::events::state::power_level_t pl; + }; + + std::string room_id; + QVector types; + mtx::events::state::PowerLevels powerLevels_; +}; + +class PowerlevelsUserListModel : public QAbstractListModel +{ + Q_OBJECT + +public: + enum Roles + { + Mxid, + DisplayName, + AvatarUrl, + Powerlevel, + IsUser, + Moveable, + Removeable, + }; + + explicit PowerlevelsUserListModel(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(users.size()); } + QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + Q_INVOKABLE bool remove(int row); + Q_INVOKABLE bool move(int from, int to); + Q_INVOKABLE void add(int index, QString user); + + bool moveRows(const QModelIndex &sourceParent, + int sourceRow, + int count, + const QModelIndex &destinationParent, + int destinationChild) override; + + std::map> toUsers(); + mtx::events::state::power_level_t usersDefault(); + +private: + struct Entry + { + std::string mxid; + mtx::events::state::power_level_t pl; + }; + + std::string room_id; + QVector users; + mtx::events::state::PowerLevels powerLevels_; +}; + +class PowerlevelEditingModels : public QObject +{ + Q_OBJECT + + Q_PROPERTY(PowerlevelsUserListModel *users READ users CONSTANT) + Q_PROPERTY(PowerlevelsTypeListModel *types READ types CONSTANT) + Q_PROPERTY(qlonglong adminLevel READ adminLevel CONSTANT) + Q_PROPERTY(qlonglong moderatorLevel READ moderatorLevel CONSTANT) + +public: + explicit PowerlevelEditingModels(QString room_id, QObject *parent = nullptr); + + PowerlevelsUserListModel *users() { return &users_; } + PowerlevelsTypeListModel *types() { return &types_; } + qlonglong adminLevel() const + { + return powerLevels_.state_level(to_string(mtx::events::EventType::RoomPowerLevels)); + } + qlonglong moderatorLevel() const { return powerLevels_.redact; } + + Q_INVOKABLE void commit(); + + mtx::events::state::PowerLevels powerLevels_; + PowerlevelsTypeListModel types_; + PowerlevelsUserListModel users_; + std::string room_id_; +}; diff --git a/src/timeline/TimelineModel.h b/src/timeline/TimelineModel.h index c52473b1..dae64094 100644 --- a/src/timeline/TimelineModel.h +++ b/src/timeline/TimelineModel.h @@ -282,6 +282,7 @@ public: Q_INVOKABLE bool saveMedia(const QString &eventId) const; Q_INVOKABLE void showEvent(QString eventId); Q_INVOKABLE void copyLinkToEvent(const QString &eventId) const; + void cacheMedia(const QString &eventId, const std::function &callback); Q_INVOKABLE void sendReset() diff --git a/src/ui/NhekoGlobalObject.h b/src/ui/NhekoGlobalObject.h index cfcf31fb..bd141f35 100644 --- a/src/ui/NhekoGlobalObject.h +++ b/src/ui/NhekoGlobalObject.h @@ -9,6 +9,7 @@ #include #include +#include "PowerlevelsEditModels.h" #include "Theme.h" #include "UserProfile.h" @@ -54,6 +55,10 @@ public: Q_INVOKABLE void logout() const; Q_INVOKABLE void createRoom(QString name, QString topic, QString aliasLocalpart, bool isEncrypted, int preset); + Q_INVOKABLE PowerlevelEditingModels *editPowerlevels(QString room_id_) const + { + return new PowerlevelEditingModels(room_id_); + } public slots: void updateUserProfile();