Separate priviled members list, and more useful permissions

Having both the member list and permission controls is troublesome, and
scales the larger your moderation team is. We eventually may want to
manage banned/muted users too so I think it warrants having a new page.
I also moved the search field to the top so it's more accessible.

As for permissions, I tried to improve the UX generally while not
changing it too heavily. First the easy change is to the text, hopefully
the sections should be clearer (especially for "state" events.) The
bigger change here is the new sections, I tried to make it more useful
and organized. Additionally, I added more permissions like sharing live
locations and polls so they're more easily configurable.

One other change is that permissions are visible regardless of whether
you can set them or not, matching Element's behavior.
This commit is contained in:
Joshua Goins
2026-01-09 17:54:03 -05:00
parent e54955ec0c
commit 1a500a087b
6 changed files with 411 additions and 215 deletions

View File

@@ -51,6 +51,7 @@ ecm_add_qml_module(Settings GENERATE_PLUGIN_SOURCE
RoomProfile.qml
RoomAdvancedPage.qml
KeyboardShortcutsPage.qml
Members.qml
SOURCES
colorschemer.cpp
threepidaddhelper.cpp

247
src/settings/Members.qml Normal file
View File

@@ -0,0 +1,247 @@
// SPDX-FileCopyrightText: 2022 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
pragma ComponentBehavior: Bound
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.delegates as Delegates
import org.kde.kirigamiaddons.labs.components as KirigamiComponents
import org.kde.kitemmodels
import org.kde.neochat
FormCard.FormCardPage {
id: root
property NeoChatRoom room
title: i18nc("@title:window", "Members")
readonly property bool loading: permissions.count === 0 && !root.room.roomCreatorHasUltimatePowerLevel()
readonly property PowerLevelModel powerLevelModel: PowerLevelModel {
showMute: false
}
FormCard.FormHeader {
title: i18nc("@title", "Privileged Members")
visible: !root.loading
}
FormCard.FormCard {
visible: !root.loading
FormCard.AbstractFormDelegate {
id: userListSearchCard
visible: root.room.canSendState("m.room.power_levels")
contentItem: Kirigami.SearchField {
id: userListSearchField
autoAccept: false
Layout.fillWidth: true
Keys.onUpPressed: userListView.decrementCurrentIndex()
Keys.onDownPressed: userListView.incrementCurrentIndex()
onAccepted: (userListView.itemAtIndex(userListView.currentIndex) as Delegates.RoundedItemDelegate).action.trigger()
}
QQC2.Popup {
id: userListSearchPopup
x: userListSearchField.x
y: userListSearchField.y - height
width: userListSearchField.width
height: {
let maxHeight = userListSearchField.mapToGlobal(userListSearchField.x, userListSearchField.y).y - Kirigami.Units.largeSpacing * 3;
let minHeight = Kirigami.Units.gridUnit * 2 + userListSearchPopup.padding * 2;
let filterContentHeight = userListView.contentHeight + userListSearchPopup.padding * 2;
return Math.max(Math.min(filterContentHeight, maxHeight), minHeight);
}
padding: Kirigami.Units.smallSpacing
leftPadding: Kirigami.Units.smallSpacing / 2
rightPadding: Kirigami.Units.smallSpacing / 2
modal: false
onClosed: userListSearchField.text = ""
background: Kirigami.ShadowedRectangle {
property color borderColor: Kirigami.Theme.textColor
Kirigami.Theme.colorSet: Kirigami.Theme.View
Kirigami.Theme.inherit: false
radius: Kirigami.Units.cornerRadius
color: Kirigami.Theme.backgroundColor
border {
color: Qt.rgba(borderColor.r, borderColor.g, borderColor.b, 0.3)
width: 1
}
shadow {
xOffset: 0
yOffset: 4
color: Qt.rgba(0, 0, 0, 0.3)
size: 8
}
}
contentItem: QQC2.ScrollView {
// HACK: Hide unnecessary horizontal scrollbar (https://bugreports.qt.io/browse/QTBUG-83890)
QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff
ListView {
id: userListView
clip: true
model: UserFilterModel {
id: userListFilterModel
sourceModel: RoomManager.userListModel
filterText: userListSearchField.text
onFilterTextChanged: {
if (filterText.length > 0 && !userListSearchPopup.visible) {
userListSearchPopup.open();
} else if (filterText.length <= 0 && userListSearchPopup.visible) {
userListSearchPopup.close();
}
}
}
delegate: Delegates.RoundedItemDelegate {
id: userListItem
required property string userId
required property url avatar
required property string name
required property int powerLevel
required property string powerLevelString
text: name
contentItem: RowLayout {
KirigamiComponents.Avatar {
Layout.preferredWidth: Kirigami.Units.iconSizes.medium
Layout.preferredHeight: Kirigami.Units.iconSizes.medium
source: userListItem.avatar
name: userListItem.name
}
Delegates.SubtitleContentItem {
itemDelegate: userListItem
subtitle: userListItem.userId
labelItem.textFormat: Text.PlainText
subtitleItem.textFormat: Text.PlainText
Layout.fillWidth: true
}
QQC2.Label {
visible: userListItem.powerLevel > 0
text: userListItem.powerLevelString
color: Kirigami.Theme.disabledTextColor
textFormat: Text.PlainText
wrapMode: Text.NoWrap
}
}
onClicked: {
userListSearchPopup.close();
(powerLevelDialog.createObject(root.QQC2.Overlay.overlay, {
room: root.room,
userId: userListItem.userId,
powerLevel: userListItem.powerLevel
}) as PowerLevelDialog).open();
}
Component {
id: powerLevelDialog
PowerLevelDialog {}
}
}
QQC2.Label {
text: i18nc("@info", "No users found.")
visible: userListView.count === 0
anchors {
left: parent.left
leftMargin: Kirigami.Units.mediumSpacing
verticalCenter: parent.verticalCenter
}
}
}
}
}
}
FormCard.FormDelegateSeparator {
above: userListSearchCard
}
Repeater {
id: permissions
model: KSortFilterProxyModel {
sourceModel: RoomManager.userListModel
sortRoleName: "powerLevel"
sortOrder: Qt.DescendingOrder
filterRowCallback: function (source_row, source_parent) {
let powerLevelRole = sourceModel.data(sourceModel.index(source_row, 0, source_parent), UserListModel.PowerLevelRole);
return powerLevelRole != 0;
}
}
delegate: FormCard.FormTextDelegate {
id: privilegedUserDelegate
required property string userId
required property string name
required property int powerLevel
required property string powerLevelString
required property bool isCreator
text: name
textItem.textFormat: Text.PlainText
description: userId
contentItem.children: RowLayout {
spacing: Kirigami.Units.largeSpacing
QQC2.Label {
id: powerLevelLabel
text: privilegedUserDelegate.powerLevelString
visible: (!root.room.canSendState("m.room.power_levels") || (root.room.memberEffectivePowerLevel(root.room.localMember.id) <= privilegedUserDelegate.powerLevel && privilegedUserDelegate.userId != root.room.localMember.id)) || privilegedUserDelegate.isCreator
color: Kirigami.Theme.disabledTextColor
}
QQC2.ComboBox {
focusPolicy: Qt.NoFocus // provided by parent
model: PowerLevelModel {}
textRole: "name"
valueRole: "value"
visible: !powerLevelLabel.visible
Component.onCompleted: {
let index = indexOfValue(privilegedUserDelegate.powerLevel)
if (index === -1) {
displayText = privilegedUserDelegate.powerLevelString;
} else {
currentIndex = index;
}
}
onActivated: {
root.room.setUserPowerLevel(privilegedUserDelegate.userId, currentValue);
}
}
}
}
}
}
Item {
visible: root.loading
Layout.fillWidth: true
implicitHeight: root.height * 0.9
Kirigami.LoadingPlaceholder {
anchors.centerIn: parent
text: i18nc("@placeholder", "Loading…")
}
}
}

View File

@@ -33,207 +33,10 @@ FormCard.FormCardPage {
}
FormCard.FormHeader {
title: i18nc("@title", "Privileged Users")
visible: !root.loading
title: i18nc("@title", "Power Levels")
}
FormCard.FormCard {
visible: !root.loading
Repeater {
id: permissions
model: KSortFilterProxyModel {
sourceModel: RoomManager.userListModel
sortRoleName: "powerLevel"
sortOrder: Qt.DescendingOrder
filterRowCallback: function (source_row, source_parent) {
let powerLevelRole = sourceModel.data(sourceModel.index(source_row, 0, source_parent), UserListModel.PowerLevelRole);
return powerLevelRole != 0;
}
}
delegate: FormCard.FormTextDelegate {
id: privilegedUserDelegate
required property string userId
required property string name
required property int powerLevel
required property string powerLevelString
required property bool isCreator
text: name
textItem.textFormat: Text.PlainText
description: userId
contentItem.children: RowLayout {
spacing: Kirigami.Units.largeSpacing
QQC2.Label {
id: powerLevelLabel
text: privilegedUserDelegate.powerLevelString
visible: (!root.room.canSendState("m.room.power_levels") || (root.room.memberEffectivePowerLevel(root.room.localMember.id) <= privilegedUserDelegate.powerLevel && privilegedUserDelegate.userId != root.room.localMember.id)) || privilegedUserDelegate.isCreator
color: Kirigami.Theme.disabledTextColor
}
QQC2.ComboBox {
focusPolicy: Qt.NoFocus // provided by parent
model: PowerLevelModel {}
textRole: "name"
valueRole: "value"
visible: !powerLevelLabel.visible
Component.onCompleted: {
let index = indexOfValue(privilegedUserDelegate.powerLevel)
if (index === -1) {
displayText = privilegedUserDelegate.powerLevelString;
} else {
currentIndex = index;
}
}
onActivated: {
root.room.setUserPowerLevel(privilegedUserDelegate.userId, currentValue);
}
}
}
}
}
FormCard.FormDelegateSeparator {
below: userListSearchCard
}
FormCard.AbstractFormDelegate {
id: userListSearchCard
visible: root.room.canSendState("m.room.power_levels")
contentItem: Kirigami.SearchField {
id: userListSearchField
autoAccept: false
Layout.fillWidth: true
Keys.onUpPressed: userListView.decrementCurrentIndex()
Keys.onDownPressed: userListView.incrementCurrentIndex()
onAccepted: (userListView.itemAtIndex(userListView.currentIndex) as Delegates.RoundedItemDelegate).action.trigger()
}
QQC2.Popup {
id: userListSearchPopup
x: userListSearchField.x
y: userListSearchField.y - height
width: userListSearchField.width
height: {
let maxHeight = userListSearchField.mapToGlobal(userListSearchField.x, userListSearchField.y).y - Kirigami.Units.largeSpacing * 3;
let minHeight = Kirigami.Units.gridUnit * 2 + userListSearchPopup.padding * 2;
let filterContentHeight = userListView.contentHeight + userListSearchPopup.padding * 2;
return Math.max(Math.min(filterContentHeight, maxHeight), minHeight);
}
padding: Kirigami.Units.smallSpacing
leftPadding: Kirigami.Units.smallSpacing / 2
rightPadding: Kirigami.Units.smallSpacing / 2
modal: false
onClosed: userListSearchField.text = ""
background: Kirigami.ShadowedRectangle {
property color borderColor: Kirigami.Theme.textColor
Kirigami.Theme.colorSet: Kirigami.Theme.View
Kirigami.Theme.inherit: false
radius: Kirigami.Units.cornerRadius
color: Kirigami.Theme.backgroundColor
border {
color: Qt.rgba(borderColor.r, borderColor.g, borderColor.b, 0.3)
width: 1
}
shadow {
xOffset: 0
yOffset: 4
color: Qt.rgba(0, 0, 0, 0.3)
size: 8
}
}
contentItem: QQC2.ScrollView {
// HACK: Hide unnecessary horizontal scrollbar (https://bugreports.qt.io/browse/QTBUG-83890)
QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff
ListView {
id: userListView
clip: true
model: UserFilterModel {
id: userListFilterModel
sourceModel: RoomManager.userListModel
filterText: userListSearchField.text
onFilterTextChanged: {
if (filterText.length > 0 && !userListSearchPopup.visible) {
userListSearchPopup.open();
} else if (filterText.length <= 0 && userListSearchPopup.visible) {
userListSearchPopup.close();
}
}
}
delegate: Delegates.RoundedItemDelegate {
id: userListItem
required property string userId
required property url avatar
required property string name
required property int powerLevel
required property string powerLevelString
text: name
contentItem: RowLayout {
KirigamiComponents.Avatar {
Layout.preferredWidth: Kirigami.Units.iconSizes.medium
Layout.preferredHeight: Kirigami.Units.iconSizes.medium
source: userListItem.avatar
name: userListItem.name
}
Delegates.SubtitleContentItem {
itemDelegate: userListItem
subtitle: userListItem.userId
labelItem.textFormat: Text.PlainText
subtitleItem.textFormat: Text.PlainText
Layout.fillWidth: true
}
QQC2.Label {
visible: userListItem.powerLevel > 0
text: userListItem.powerLevelString
color: Kirigami.Theme.disabledTextColor
textFormat: Text.PlainText
wrapMode: Text.NoWrap
}
}
onClicked: {
userListSearchPopup.close();
(powerLevelDialog.createObject(root.QQC2.Overlay.overlay, {
room: root.room,
userId: userListItem.userId,
powerLevel: userListItem.powerLevel
}) as PowerLevelDialog).open();
}
Component {
id: powerLevelDialog
PowerLevelDialog {}
}
}
}
}
}
}
}
FormCard.FormHeader {
visible: root.room.canSendState("m.room.power_levels")
title: i18nc("@title", "Default permissions")
}
FormCard.FormCard {
visible: root.room.canSendState("m.room.power_levels")
enabled: root.room.canSendState("m.room.power_levels")
Repeater {
model: KSortFilterProxyModel {
sourceModel: root.permissionsModel
@@ -269,11 +72,49 @@ FormCard.FormCardPage {
}
FormCard.FormHeader {
visible: root.room.canSendState("m.room.power_levels")
title: i18nc("@title", "Basic permissions")
title: i18nc("@title", "Messages")
}
FormCard.FormCard {
visible: root.room.canSendState("m.room.power_levels")
enabled: root.room.canSendState("m.room.power_levels")
Repeater {
model: KSortFilterProxyModel {
sourceModel: root.permissionsModel
filterRowCallback: function (source_row, source_parent) {
return sourceModel.data(sourceModel.index(source_row, 0, source_parent), PermissionsModel.IsMessagePermissionRole);
}
}
delegate: FormCard.FormComboBoxDelegate {
required property string name
required property string subtitle
required property string type
required property int level
required property string levelName
text: name
description: subtitle
textRole: "name"
valueRole: "value"
model: root.powerLevelModel
Component.onCompleted: {
let index = indexOfValue(level)
if (index === -1) {
displayText = levelName;
} else {
currentIndex = index;
}
}
onCurrentValueChanged: if (root.room.canSendState("m.room.power_levels")) {
root.permissionsModel.setPowerLevel(type, currentValue);
}
}
}
}
FormCard.FormHeader {
title: i18nc("@title", "Moderation")
}
FormCard.FormCard {
enabled: root.room.canSendState("m.room.power_levels")
Repeater {
model: KSortFilterProxyModel {
sourceModel: root.permissionsModel
@@ -309,18 +150,15 @@ FormCard.FormCardPage {
}
FormCard.FormHeader {
visible: root.room.canSendState("m.room.power_levels")
title: i18nc("@title", "Event permissions")
title: i18nc("@title", "General")
}
FormCard.FormCard {
visible: root.room.canSendState("m.room.power_levels")
enabled: root.room.canSendState("m.room.power_levels")
Repeater {
model: KSortFilterProxyModel {
sourceModel: root.permissionsModel
filterRowCallback: function (source_row, source_parent) {
let isBasicPermissionRole = sourceModel.data(sourceModel.index(source_row, 0, source_parent), PermissionsModel.IsBasicPermissionRole);
let isDefaultValueRole = sourceModel.data(sourceModel.index(source_row, 0, source_parent), PermissionsModel.IsDefaultValueRole);
return !isBasicPermissionRole && !isDefaultValueRole;
return sourceModel.data(sourceModel.index(source_row, 0, source_parent), PermissionsModel.IsGeneralPermissionRole);
}
}
delegate: FormCard.FormComboBoxDelegate {
@@ -348,7 +186,59 @@ FormCard.FormCardPage {
}
}
}
}
FormCard.FormHeader {
title: i18nc("@title", "Other Events")
}
FormCard.FormCard {
enabled: root.room.canSendState("m.room.power_levels")
Repeater {
id: otherEventsRepeater
model: KSortFilterProxyModel {
sourceModel: root.permissionsModel
filterRowCallback: function (source_row, source_parent) {
let isBasicPermissionRole = sourceModel.data(sourceModel.index(source_row, 0, source_parent), PermissionsModel.IsBasicPermissionRole);
let isDefaultValueRole = sourceModel.data(sourceModel.index(source_row, 0, source_parent), PermissionsModel.IsDefaultValueRole);
let isMessagePermissionRole = sourceModel.data(sourceModel.index(source_row, 0, source_parent), PermissionsModel.IsMessagePermissionRole);
let isGeneralPermissionRole = sourceModel.data(sourceModel.index(source_row, 0, source_parent), PermissionsModel.IsGeneralPermissionRole);
return !isBasicPermissionRole && !isDefaultValueRole && !isMessagePermissionRole && !isGeneralPermissionRole;
}
}
delegate: FormCard.FormComboBoxDelegate {
required property string name
required property string subtitle
required property string type
required property int level
required property string levelName
text: name
description: subtitle
textRole: "name"
valueRole: "value"
model: root.powerLevelModel
Component.onCompleted: {
let index = indexOfValue(level)
if (index === -1) {
displayText = levelName;
} else {
currentIndex = index;
}
}
onCurrentValueChanged: if (root.room.canSendState("m.room.power_levels")) {
root.permissionsModel.setPowerLevel(type, currentValue);
}
}
}
FormCard.FormDelegateSeparator {
below: addNewEventDelegate
visible: otherEventsRepeater.count > 0
}
FormCard.AbstractFormDelegate {
id: addNewEventDelegate
Layout.fillWidth: true
contentItem: RowLayout {

View File

@@ -55,6 +55,17 @@ KirigamiSettings.ConfigurationView {
};
}
},
KirigamiSettings.ConfigurationModule {
moduleId: "members"
text: i18nc("@title", "Members")
icon.name: "system-users-symbolic"
page: () => Qt.createComponent("org.kde.neochat.settings", "Members")
initialProperties: () => {
return {
room: root._room
};
}
},
KirigamiSettings.ConfigurationModule {
moduleId: "permissions"
text: i18nc("@title", "Permissions")

View File

@@ -50,12 +50,16 @@ static const QStringList knownPermissions = {
u"m.room.server_acl"_s,
u"m.space.child"_s,
u"m.space.parent"_s,
u"org.matrix.msc3672.beacon_info"_s,
u"org.matrix.msc3381.poll.start"_s,
u"org.matrix.msc3381.poll.response"_s,
u"org.matrix.msc3381.poll.end"_s,
};
// Alternate name text for default permissions.
static const QHash<QString, KLazyLocalizedString> permissionNames = {
{UsersDefaultKey, kli18nc("Room permission type", "Default user power level")},
{StateDefaultKey, kli18nc("Room permission type", "Default power level to set the room state")},
{UsersDefaultKey, kli18nc("Room permission type", "Default power level")},
{StateDefaultKey, kli18nc("Room permission type", "Default power level to change room state")},
{EventsDefaultKey, kli18nc("Room permission type", "Default power level to send messages")},
{InviteKey, kli18nc("Room permission type", "Invite users")},
{KickKey, kli18nc("Room permission type", "Kick users")},
@@ -70,25 +74,58 @@ static const QHash<QString, KLazyLocalizedString> permissionNames = {
{u"m.room.topic"_s, kli18nc("Room permission type", "Change the room topic")},
{u"m.room.encryption"_s, kli18nc("Room permission type", "Enable encryption for the room")},
{u"m.room.history_visibility"_s, kli18nc("Room permission type", "Change the room history visibility")},
{u"m.room.pinned_events"_s, kli18nc("Room permission type", "Set pinned events")},
{u"m.room.pinned_events"_s, kli18nc("Room permission type", "Pin and unpin messages")},
{u"m.room.tombstone"_s, kli18nc("Room permission type", "Upgrade the room")},
{u"m.room.server_acl"_s, kli18nc("Room permission type", "Set the room server access control list (ACL)")},
{u"m.space.child"_s, kli18nc("Room permission type", "Set the children of this space")},
{u"m.space.parent"_s, kli18nc("Room permission type", "Set the parent space of this room")},
{u"org.matrix.msc3672.beacon_info"_s, kli18nc("Room permission type", "Send live location updates")},
{u"org.matrix.msc3381.poll.start"_s, kli18nc("Room permission type", "Start polls")},
{u"org.matrix.msc3381.poll.response"_s, kli18nc("Room permission type", "Vote in polls")},
{u"org.matrix.msc3381.poll.end"_s, kli18nc("Room permission type", "Close polls")},
};
// Subtitles for the default values.
static const QHash<QString, KLazyLocalizedString> permissionSubtitles = {
{UsersDefaultKey, kli18nc("Room permission type", "This is the power level for all new users when joining the room")},
{StateDefaultKey, kli18nc("Room permission type", "This is used for all state events that do not have their own entry here")},
{EventsDefaultKey, kli18nc("Room permission type", "This is used for all message events that do not have their own entry here")},
{UsersDefaultKey, kli18nc("Room permission type", "This is the power level for all new users when joining the room.")},
{StateDefaultKey, kli18nc("Room permission type", "This is used for all state-type events that do not have their own entry.")},
{EventsDefaultKey, kli18nc("Room permission type", "This is used for all message-type events that do not have their own entry.")},
};
// Permissions that should use the event default.
// Permissions that should use the message event default.
static const QStringList eventPermissions = {
u"m.room.message"_s,
u"m.reaction"_s,
u"m.room.redaction"_s,
u"org.matrix.msc3381.poll.start"_s,
u"org.matrix.msc3381.poll.response"_s,
u"org.matrix.msc3381.poll.end"_s,
};
// Permissions related to messaging.
static const QStringList messagingPermissions = {
u"m.reaction"_s,
u"m.room.redaction"_s,
u"org.matrix.msc3672.beacon_info"_s,
u"org.matrix.msc3381.poll.start"_s,
u"org.matrix.msc3381.poll.response"_s,
u"org.matrix.msc3381.poll.end"_s,
};
// Permissions related to general room management.
static const QStringList generalPermissions = {
u"m.room.power_levels"_s,
u"m.room.name"_s,
u"m.room.avatar"_s,
u"m.room.canonical_alias"_s,
u"m.room.topic"_s,
u"m.room.encryption"_s,
u"m.room.history_visibility"_s,
u"m.room.pinned_events"_s,
u"m.room.tombstone"_s,
u"m.room.server_acl"_s,
u"m.space.child"_s,
u"m.space.parent"_s,
};
};
@@ -194,6 +231,12 @@ QVariant PermissionsModel::data(const QModelIndex &index, int role) const
if (role == IsBasicPermissionRole) {
return basicPermissions.contains(permission);
}
if (role == IsMessagePermissionRole) {
return messagingPermissions.contains(permission);
}
if (role == IsGeneralPermissionRole) {
return generalPermissions.contains(permission);
}
return {};
}
@@ -213,6 +256,8 @@ QHash<int, QByteArray> PermissionsModel::roleNames() const
roles[LevelNameRole] = "levelName";
roles[IsDefaultValueRole] = "isDefaultValue";
roles[IsBasicPermissionRole] = "isBasicPermission";
roles[IsMessagePermissionRole] = "isMessagePermission";
roles[IsGeneralPermissionRole] = "isGeneralPermission";
return roles;
}

View File

@@ -36,6 +36,8 @@ public:
LevelNameRole, /**< The current power level for the permission as a string. */
IsDefaultValueRole, /**< Whether the permission is a default value, e.g. for users. */
IsBasicPermissionRole, /**< Whether the permission is one of the basic ones, e.g. kick, ban, etc. */
IsMessagePermissionRole, /** Permissions related to messaging. */
IsGeneralPermissionRole, /** Permissions related to general room management. */
};
Q_ENUM(Roles)