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();