Create a space module

This commit is contained in:
James Graham
2025-04-16 18:30:52 +01:00
parent 4aec891b1f
commit e787eaabcd
10 changed files with 27 additions and 9 deletions

23
src/spaces/CMakeLists.txt Normal file
View File

@@ -0,0 +1,23 @@
# SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
# SPDX-License-Identifier: BSD-2-Clause
qt_add_library(Spaces STATIC)
ecm_add_qml_module(Spaces GENERATE_PLUGIN_SOURCE
URI org.kde.neochat.spaces
OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/src/org/kde/neochat/spaces
QML_FILES
SpaceHomePage.qml
SpaceHierarchyDelegate.qml
SOURCES
models/spacechildrenmodel.cpp
models/spacechildsortfiltermodel.cpp
models/spacetreeitem.cpp
)
target_include_directories(Spaces PRIVATE ${CMAKE_BINARY_DIR} ${CMAKE_CURRENT_SOURCE_DIR}/models)
target_link_libraries(Spaces PRIVATE
Qt::Core
Qt::Quick
KF6::Kirigami
LibNeoChat
)

View File

@@ -0,0 +1,219 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.kirigamiaddons.delegates as Delegates
import org.kde.kirigamiaddons.labs.components as Components
import org.kde.neochat
import org.kde.neochat.libneochat as LibNeoChat
Item {
id: root
required property int index
required property TreeView treeView
required property bool isTreeNode
required property bool expanded
required property int hasChildren
required property int depth
required property int row
required property string roomId
required property string displayName
required property url avatarUrl
required property bool isSpace
required property bool isSuggested
required property int memberCount
required property string topic
required property bool isJoined
required property bool canAddChildren
required property string parentDisplayName
required property bool canSetParent
required property bool isDeclaredParent
required property bool canRemove
required property NeoChatRoom parentRoom
signal createRoom
Delegates.RoundedItemDelegate {
id: mainDelegate
property int row: root.row
anchors.horizontalCenter: root.horizontalCenter
anchors.verticalCenter: root.verticalCenter
width: sizeHelper.availableWidth
highlighted: dropArea.containsDrag
contentItem: RowLayout {
z: 1
spacing: Kirigami.Units.largeSpacing
RowLayout {
spacing: 0
Item {
Layout.preferredWidth: Kirigami.Units.iconSizes.smallMedium * (root.depth + (root.isSpace ? 0 : 1))
}
Kirigami.Icon {
visible: root.isSpace
implicitWidth: Kirigami.Units.iconSizes.smallMedium
implicitHeight: Kirigami.Units.iconSizes.smallMedium
source: root.hasChildren ? (root.expanded ? "go-up" : "go-down") : "go-next"
}
}
Components.Avatar {
Layout.fillHeight: true
Layout.preferredWidth: height
implicitWidth: Kirigami.Units.gridUnit + Kirigami.Units.largeSpacing * 2
implicitHeight: Kirigami.Units.gridUnit + Kirigami.Units.largeSpacing * 2
source: root.avatarUrl
name: root.displayName
}
ColumnLayout {
spacing: 0
Layout.fillWidth: true
Layout.alignment: Qt.AlignVCenter
RowLayout {
Layout.fillWidth: true
Layout.alignment: Qt.AlignLeft | Qt.AlignBottom
spacing: Kirigami.Units.largeSpacing
QQC2.Label {
id: label
text: root.displayName
elide: Text.ElideRight
textFormat: Text.PlainText
}
QQC2.Label {
visible: root.isJoined || root.isSuggested
text: root.isJoined ? i18n("Joined") : i18n("Suggested")
color: root.isJoined ? Kirigami.Theme.linkColor : Kirigami.Theme.disabledTextColor
}
}
QQC2.Label {
id: subtitle
Layout.fillWidth: true
Layout.alignment: Qt.AlignLeft | Qt.AlignTop
text: root.topic.length > 0 ? i18ncp("number of room members", "%1 member - ", "%1 members - ", root.memberCount) + root.topic : i18ncp("number of room members", "%1 member", "%1 members", root.memberCount)
elide: Text.ElideRight
font: Kirigami.Theme.smallFont
textFormat: Text.PlainText
maximumLineCount: 1
}
}
QQC2.ToolButton {
visible: root.isSpace && root.canAddChildren
text: i18nc("@button", "Add new room")
icon.name: "list-add"
display: QQC2.AbstractButton.IconOnly
onClicked: root.createRoom()
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
QQC2.ToolButton {
visible: root.canRemove
text: i18nc("@button", "Remove")
icon.name: "list-remove"
display: QQC2.AbstractButton.IconOnly
onClicked: {
removeChildDialog.createObject(QQC2.Overlay.overlay, {
parentRoom: root.parentRoom,
roomId: root.roomId,
displayName: root.displayName,
parentDisplayName: root.parentDisplayName,
canSetParent: root.canSetParent,
isDeclaredParent: root.isDeclaredParent
}).open();
}
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
QQC2.ToolButton {
visible: root.parentRoom?.canSendState("m.space.child") ?? false
text: root.isSuggested ? i18nc("@button", "Don't Make Suggested") : i18nc("@button", "Make Suggested")
icon.name: root.isSuggested ? "edit-delete-remove" : "checkmark"
display: QQC2.AbstractButton.IconOnly
onClicked: root.parentRoom.toggleChildSuggested(root.roomId)
QQC2.ToolTip.text: text
QQC2.ToolTip.visible: hovered
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
}
MouseArea {
id: dragArea
anchors.fill: parent
drag.target: mainDelegate
drag.axis: Drag.YAxis
drag.onActiveChanged: {
if (!dragArea.drag.active) {
mainDelegate.Drag.drop();
}
}
onClicked: {
if (root.isSpace) {
root.treeView.toggleExpanded(root.row);
} else {
RoomManager.resolveResource(root.roomId, root.isJoined ? "" : "join");
}
}
}
states: [
State {
when: mainDelegate.Drag.active && root.parentRoom.canSendState("m.space.child")
ParentChange {
target: mainDelegate
parent: root.treeView
}
AnchorChanges {
target: mainDelegate
anchors.horizontalCenter: undefined
anchors.verticalCenter: undefined
}
}
]
Drag.active: dragArea.drag.active
Drag.hotSpot.x: mainDelegate.width / 2
Drag.hotSpot.y: Kirigami.Units.smallSpacing
}
DropArea {
id: dropArea
anchors.fill: parent
onDropped: (drag) => {
root.treeView.model.move(root.treeView.index(drag.source.row, 0), root.treeView.index(root.row, 0))
}
}
LibNeoChat.DelegateSizeHelper {
id: sizeHelper
parentItem: root
startBreakpoint: Kirigami.Units.gridUnit * 46
endBreakpoint: Kirigami.Units.gridUnit * 66
startPercentWidth: 100
endPercentWidth: 85
maxWidth: Kirigami.Units.gridUnit * 60
}
Component {
id: removeChildDialog
RemoveChildDialog {}
}
}

View File

@@ -0,0 +1,186 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
import org.kde.neochat
import org.kde.neochat.libneochat as LibNeoChat
import org.kde.neochat.settings
ColumnLayout {
id: root
readonly property NeoChatRoom currentRoom: RoomManager.currentRoom
anchors.fill: parent
spacing: 0
QQC2.Control {
id: headerItem
Layout.fillWidth: true
implicitHeight: headerColumn.implicitHeight
background: Rectangle {
color: Kirigami.Theme.backgroundColor
Kirigami.Theme.colorSet: Kirigami.Theme.View
Kirigami.Theme.inherit: false
}
ColumnLayout {
id: headerColumn
anchors.centerIn: headerItem
width: sizeHelper.currentWidth
spacing: Kirigami.Units.largeSpacing
GroupChatDrawerHeader {
id: header
Layout.fillWidth: true
room: root.currentRoom
}
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.rightMargin: Kirigami.Units.largeSpacing
QQC2.Button {
visible: root.currentRoom.canSendState("invite")
text: i18nc("@button", "Invite user to space")
icon.name: "list-add-user"
onClicked: applicationWindow().pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'InviteUserPage'), {
room: root.currentRoom
}, {
title: i18nc("@title", "Invite a User")
})
}
QQC2.Button {
visible: root.currentRoom.canSendState("m.space.child")
text: i18nc("@button", "Add new room")
icon.name: "list-add"
onClicked: _private.createRoom(root.currentRoom.id)
}
QQC2.Button {
text: i18nc("@action:button", "Leave this space")
icon.name: "go-previous"
onClicked: RoomManager.leaveRoom(root.currentRoom)
}
Item {
Layout.fillWidth: true
}
QQC2.Button {
id: settingsButton
display: QQC2.AbstractButton.IconOnly
text: i18nc("'Space' is a matrix space", "Space Settings")
onClicked: {
RoomSettingsView.openRoomSettings(root.currentRoom, RoomSettingsView.Space);
drawer.close();
}
icon.name: 'settings-configure-symbolic'
QQC2.ToolTip.text: text
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
QQC2.ToolTip.visible: hovered
}
}
Kirigami.SearchField {
Layout.fillWidth: true
Layout.leftMargin: Kirigami.Units.largeSpacing
Layout.rightMargin: Kirigami.Units.largeSpacing
Layout.bottomMargin: Kirigami.Units.largeSpacing
onTextChanged: spaceChildSortFilterModel.filterText = text
}
}
LibNeoChat.DelegateSizeHelper {
id: sizeHelper
parentItem: root
startBreakpoint: Kirigami.Units.gridUnit * 46
endBreakpoint: Kirigami.Units.gridUnit * 66
startPercentWidth: 100
endPercentWidth: 85
maxWidth: Kirigami.Units.gridUnit * 60
}
}
Kirigami.Separator {
Layout.fillWidth: true
}
QQC2.ScrollView {
id: hierarchyScrollView
Layout.fillWidth: true
Layout.fillHeight: true
visible: !spaceChildrenModel.loading
TreeView {
id: spaceTree
columnWidthProvider: function (column) {
return spaceTree.width;
}
clip: true
model: SpaceChildSortFilterModel {
id: spaceChildSortFilterModel
sourceModel: SpaceChildrenModel {
id: spaceChildrenModel
space: root.currentRoom
}
}
delegate: SpaceHierarchyDelegate {
onCreateRoom: _private.createRoom(roomId)
}
}
background: Rectangle {
color: Kirigami.Theme.backgroundColor
Kirigami.Theme.colorSet: Kirigami.Theme.View
}
}
QQC2.Control {
Layout.fillWidth: true
Layout.fillHeight: true
visible: spaceChildrenModel.loading
background: Rectangle {
color: Kirigami.Theme.backgroundColor
Kirigami.Theme.colorSet: Kirigami.Theme.View
Kirigami.Theme.inherit: false
}
Loader {
active: spaceChildrenModel.loading
anchors.centerIn: parent
sourceComponent: Kirigami.LoadingPlaceholder {}
}
}
QtObject {
id: _private
function createRoom(parentId) {
let dialog = applicationWindow().pageStack.pushDialogLayer(Qt.createComponent('org.kde.neochat', 'CreateRoomDialog'), {
title: i18nc("@title", "Create a Child"),
connection: root.currentRoom.connection,
parentId: parentId,
showChildType: true,
showCreateChoice: true
}, {
title: i18nc("@title", "Create a Child")
});
dialog.addChild.connect((childId, setChildParent, canonical) => {
// We have to get a room object from the connection as we may not
// be adding to the top level parent.
let parent = root.currentRoom.connection.room(parentId);
if (parent) {
parent.addChild(childId, setChildParent, canonical);
}
});
dialog.newChild.connect(childName => {
spaceChildrenModel.addPendingChild(childName);
});
}
}
}

View File

@@ -0,0 +1,374 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#include "spacechildrenmodel.h"
#include <Quotient/jobs/basejob.h>
#include <Quotient/room.h>
#include "neochatconnection.h"
SpaceChildrenModel::SpaceChildrenModel(QObject *parent)
: QAbstractItemModel(parent)
, m_rootItem(new SpaceTreeItem(nullptr))
{
}
SpaceChildrenModel::~SpaceChildrenModel()
{
delete m_rootItem;
}
NeoChatRoom *SpaceChildrenModel::space() const
{
return m_space;
}
void SpaceChildrenModel::setSpace(NeoChatRoom *space)
{
if (space == m_space) {
return;
}
// disconnect the new room signal from the old connection in case it is different.
if (m_space != nullptr) {
m_space->connection()->disconnect(this);
m_space->disconnect(this);
}
m_space = space;
Q_EMIT spaceChanged();
refreshModel();
if (!m_space) {
return;
}
auto connection = m_space->connection();
connect(connection, &NeoChatConnection::loadedRoomState, this, [this](Quotient::Room *room) {
if (m_pendingChildren.contains(room->name())) {
m_pendingChildren.removeAll(room->name());
refreshModel();
}
});
connect(m_space, &Quotient::Room::changed, this, [this]() {
refreshModel();
});
}
bool SpaceChildrenModel::loading() const
{
return m_loading;
}
void SpaceChildrenModel::refreshModel()
{
for (auto job : m_currentJobs) {
if (job) {
job->abandon();
}
}
m_currentJobs.clear();
if (m_space == nullptr) {
beginResetModel();
delete m_rootItem;
m_rootItem = nullptr;
endResetModel();
return;
}
beginResetModel();
m_replacedRooms.clear();
delete m_rootItem;
m_loading = true;
Q_EMIT loadingChanged();
m_rootItem =
new SpaceTreeItem(dynamic_cast<NeoChatConnection *>(m_space->connection()), nullptr, m_space->id(), m_space->displayName(), m_space->canonicalAlias());
endResetModel();
auto job = m_space->connection()->callApi<Quotient::GetSpaceHierarchyJob>(m_space->id(), std::nullopt, std::nullopt, 1);
m_currentJobs.append(job);
connect(job, &Quotient::BaseJob::success, this, [this, job]() {
insertChildren(job->rooms());
});
}
void SpaceChildrenModel::insertChildren(std::vector<Quotient::GetSpaceHierarchyJob::SpaceHierarchyRoomsChunk> children, const QModelIndex &parent)
{
SpaceTreeItem *parentItem = getItem(parent);
if (children[0].roomId == m_space->id() || children[0].roomId == parentItem->id()) {
parentItem->setChildStates(std::move(children[0].childrenState));
children.erase(children.begin());
}
// If this is the first set of children added to the root item then we need to
// set it so that we are no longer loading.
if (rowCount(QModelIndex()) == 0 && !children.empty()) {
m_loading = false;
Q_EMIT loadingChanged();
}
beginInsertRows(parent, parentItem->childCount(), parentItem->childCount() + children.size() - 1);
for (unsigned long i = 0; i < children.size(); ++i) {
if (children[i].roomId == m_space->id() || children[i].roomId == parentItem->id()) {
continue;
} else {
int insertRow = parentItem->childCount();
if (const auto room = m_space->connection()->room(children[i].roomId)) {
const auto predecessorId = room->predecessorId();
if (!predecessorId.isEmpty()) {
m_replacedRooms += predecessorId;
}
const auto successorId = room->successorId();
if (!successorId.isEmpty()) {
m_replacedRooms += successorId;
}
if (dynamic_cast<NeoChatRoom *>(room)->isSpace()) {
connect(room, &Quotient::Room::changed, this, [this]() {
refreshModel();
});
}
}
if (children[i].childrenState.size() > 0) {
auto job = m_space->connection()->callApi<Quotient::GetSpaceHierarchyJob>(children[i].roomId, std::nullopt, std::nullopt, 1);
m_currentJobs.append(job);
connect(job, &Quotient::BaseJob::success, this, [this, parent, insertRow, job]() {
insertChildren(job->rooms(), index(insertRow, 0, parent));
});
}
parentItem->insertChild(std::make_unique<SpaceTreeItem>(dynamic_cast<NeoChatConnection *>(m_space->connection()),
parentItem,
children[i].roomId,
children[i].name,
children[i].canonicalAlias,
children[i].topic,
children[i].numJoinedMembers,
children[i].avatarUrl,
children[i].guestCanJoin,
children[i].worldReadable,
children[i].roomType == u"m.space"_s,
std::move(children[i].childrenState)));
}
}
endInsertRows();
}
SpaceTreeItem *SpaceChildrenModel::getItem(const QModelIndex &index) const
{
if (index.isValid()) {
SpaceTreeItem *item = static_cast<SpaceTreeItem *>(index.internalPointer());
if (item) {
return item;
}
}
return m_rootItem;
}
QVariant SpaceChildrenModel::data(const QModelIndex &index, int role) const
{
if (!index.isValid()) {
return QVariant();
}
SpaceTreeItem *child = getItem(index);
if (role == DisplayNameRole) {
auto displayName = child->name();
if (!displayName.isEmpty()) {
return displayName;
}
displayName = child->canonicalAlias();
if (!displayName.isEmpty()) {
return displayName;
}
return child->id();
}
if (role == AvatarUrlRole) {
return child->avatarUrl();
}
if (role == TopicRole) {
return child->topic();
}
if (role == RoomIDRole) {
return child->id();
}
if (role == AliasRole) {
return child->canonicalAlias();
}
if (role == MemberCountRole) {
return child->memberCount();
}
if (role == AllowGuestsRole) {
return child->allowGuests();
}
if (role == WorldReadableRole) {
return child->worldReadable();
}
if (role == IsJoinedRole) {
return child->isJoined();
}
if (role == IsSpaceRole) {
return child->isSpace();
}
if (role == IsSuggestedRole) {
return child->isSuggested();
}
if (role == CanAddChildrenRole) {
if (const auto room = static_cast<NeoChatRoom *>(m_space->connection()->room(child->id()))) {
return room->canSendState(u"m.space.child"_s);
}
return false;
}
if (role == ParentDisplayNameRole) {
const auto parent = child->parentItem();
auto displayName = parent->name();
if (!displayName.isEmpty()) {
return displayName;
}
displayName = parent->canonicalAlias();
if (!displayName.isEmpty()) {
return displayName;
}
return parent->id();
}
if (role == CanSetParentRole) {
if (const auto room = static_cast<NeoChatRoom *>(m_space->connection()->room(child->id()))) {
return room->canSendState(u"m.space.parent"_s);
}
return false;
}
if (role == IsDeclaredParentRole) {
if (const auto room = static_cast<NeoChatRoom *>(m_space->connection()->room(child->id()))) {
return room->currentState().contains(u"m.space.parent"_s, child->parentItem()->id());
}
return false;
}
if (role == CanRemove) {
const auto parent = child->parentItem();
if (const auto room = static_cast<NeoChatRoom *>(m_space->connection()->room(parent->id()))) {
return room->canSendState(u"m.space.child"_s);
}
return false;
}
if (role == ParentRoomRole) {
if (const auto parentRoom = static_cast<NeoChatRoom *>(m_space->connection()->room(child->parentItem()->id()))) {
return QVariant::fromValue(parentRoom);
}
return QVariant::fromValue(nullptr);
}
if (role == OrderRole) {
if (child->parentItem() == nullptr) {
return QString();
}
const auto childState = child->parentItem()->childStateContent(child);
return childState["order"_L1].toString();
}
if (role == ChildTimestampRole) {
if (child->parentItem() == nullptr) {
return QString();
}
const auto childState = child->parentItem()->childState(child);
return childState["origin_server_ts"_L1].toString();
}
return {};
}
QModelIndex SpaceChildrenModel::index(int row, int column, const QModelIndex &parent) const
{
if (!hasIndex(row, column, parent)) {
return QModelIndex();
}
SpaceTreeItem *parentItem = getItem(parent);
if (!parentItem) {
return QModelIndex();
}
SpaceTreeItem *childItem = parentItem->child(row);
if (childItem) {
return createIndex(row, column, childItem);
}
return QModelIndex();
}
QModelIndex SpaceChildrenModel::parent(const QModelIndex &index) const
{
if (!index.isValid()) {
return QModelIndex();
}
SpaceTreeItem *childItem = static_cast<SpaceTreeItem *>(index.internalPointer());
SpaceTreeItem *parentItem = childItem->parentItem();
if (parentItem == m_rootItem) {
return QModelIndex();
}
return createIndex(parentItem->row(), 0, parentItem);
}
int SpaceChildrenModel::rowCount(const QModelIndex &parent) const
{
SpaceTreeItem *parentItem;
if (parent.column() > 0) {
return 0;
}
if (!parent.isValid()) {
parentItem = m_rootItem;
} else {
parentItem = static_cast<SpaceTreeItem *>(parent.internalPointer());
}
return parentItem->childCount();
}
int SpaceChildrenModel::columnCount(const QModelIndex &parent) const
{
Q_UNUSED(parent);
return 1;
}
QHash<int, QByteArray> SpaceChildrenModel::roleNames() const
{
QHash<int, QByteArray> roles;
roles[DisplayNameRole] = "displayName";
roles[AvatarUrlRole] = "avatarUrl";
roles[TopicRole] = "topic";
roles[RoomIDRole] = "roomId";
roles[MemberCountRole] = "memberCount";
roles[AllowGuestsRole] = "allowGuests";
roles[WorldReadableRole] = "worldReadable";
roles[IsJoinedRole] = "isJoined";
roles[AliasRole] = "alias";
roles[IsSpaceRole] = "isSpace";
roles[IsSuggestedRole] = "isSuggested";
roles[CanAddChildrenRole] = "canAddChildren";
roles[ParentDisplayNameRole] = "parentDisplayName";
roles[CanSetParentRole] = "canSetParent";
roles[IsDeclaredParentRole] = "isDeclaredParent";
roles[CanRemove] = "canRemove";
roles[ParentRoomRole] = "parentRoom";
roles[OrderRole] = "order";
roles[ChildTimestampRole] = "childTimestamp";
return roles;
}
bool SpaceChildrenModel::isRoomReplaced(const QString &roomId) const
{
return m_replacedRooms.contains(roomId);
}
void SpaceChildrenModel::addPendingChild(const QString &childName)
{
m_pendingChildren += childName;
}
#include "moc_spacechildrenmodel.cpp"

View File

@@ -0,0 +1,148 @@
// SPDX-FileCopyrightText: 2023 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 once
#include <QAbstractItemModel>
#include <QQmlEngine>
#include <Quotient/csapi/space_hierarchy.h>
#include <qtmetamacros.h>
#include "neochatroom.h"
#include "spacetreeitem.h"
/**
* @class SpaceChildrenModel
*
* Create a model that contains a list of the child rooms for any given space id.
*/
class SpaceChildrenModel : public QAbstractItemModel
{
Q_OBJECT
QML_ELEMENT
/**
* @brief The current space that the hierarchy is being generated for.
*/
Q_PROPERTY(NeoChatRoom *space READ space WRITE setSpace NOTIFY spaceChanged)
/**
* @brief Whether the model is loading the initial set of children.
*/
Q_PROPERTY(bool loading READ loading NOTIFY loadingChanged)
public:
enum Roles {
DisplayNameRole = Qt::DisplayRole,
AvatarUrlRole,
TopicRole,
RoomIDRole,
AliasRole,
MemberCountRole,
AllowGuestsRole,
WorldReadableRole,
IsJoinedRole,
IsSpaceRole,
IsSuggestedRole,
CanAddChildrenRole,
ParentDisplayNameRole,
CanSetParentRole,
IsDeclaredParentRole,
CanRemove,
ParentRoomRole,
OrderRole,
ChildTimestampRole,
};
explicit SpaceChildrenModel(QObject *parent = nullptr);
~SpaceChildrenModel();
NeoChatRoom *space() const;
void setSpace(NeoChatRoom *space);
bool loading() const;
/**
* @brief Get the given role value at the given index.
*
* @sa QAbstractItemModel::data
*/
QVariant data(const QModelIndex &index, int role = DisplayNameRole) const override;
/**
* @brief Returns the index of the item in the model specified by the given row, column and parent index.
*
* @sa QAbstractItemModel::index
*/
QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const override;
/**
* @brief Returns the parent of the model item with the given index.
*
* If the item has no parent, an invalid QModelIndex is returned.
*
* @sa QAbstractItemModel::parent
*/
QModelIndex parent(const QModelIndex &index) const override;
/**
* @brief Number of rows in the model.
*
* @sa QAbstractItemModel::rowCount
*/
int rowCount(const QModelIndex &parent = QModelIndex()) const override;
/**
* @brief Number of columns in the model.
*
* @sa QAbstractItemModel::columnCount
*/
int columnCount(const QModelIndex &parent = QModelIndex()) const override;
/**
* @brief Returns a mapping from Role enum values to role names.
*
* @sa Roles, QAbstractItemModel::roleNames()
*/
QHash<int, QByteArray> roleNames() const override;
/**
* @brief Whether the room has been replaced.
*
* @note This information is only available if the local user is either a member
* of the replaced room or is a member of the successor room as currently
* there is no other way to obtain the required information.
*/
bool isRoomReplaced(const QString &roomId) const;
/**
* @brief Add the name of new child room that is expected to be added soon.
*
* A pending child is one where Quotient::Connection::createRoom has been called
* but the room hasn't synced with the server yet. This list is used to check
* whether a new room loading should trigger a refresh of the model, as we only
* want to trigger a refresh if the loading room is part of this space.
*/
Q_INVOKABLE void addPendingChild(const QString &childName);
Q_SIGNALS:
void spaceChanged();
void loadingChanged();
private:
QPointer<NeoChatRoom> m_space;
SpaceTreeItem *m_rootItem;
bool m_loading = false;
QList<QPointer<Quotient::GetSpaceHierarchyJob>> m_currentJobs;
QList<QString> m_pendingChildren;
QList<QString> m_replacedRooms;
SpaceTreeItem *getItem(const QModelIndex &index) const;
void refreshModel();
void insertChildren(std::vector<Quotient::GetSpaceHierarchyJob::SpaceHierarchyRoomsChunk> children, const QModelIndex &parent = QModelIndex());
};

View File

@@ -0,0 +1,124 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#include "spacechildsortfiltermodel.h"
#include "spacechildrenmodel.h"
SpaceChildSortFilterModel::SpaceChildSortFilterModel(QObject *parent)
: QSortFilterProxyModel(parent)
{
setRecursiveFilteringEnabled(true);
sort(0);
}
void SpaceChildSortFilterModel::setFilterText(const QString &filterText)
{
m_filterText = filterText;
Q_EMIT filterTextChanged();
invalidateFilter();
}
QString SpaceChildSortFilterModel::filterText() const
{
return m_filterText;
}
bool SpaceChildSortFilterModel::lessThan(const QModelIndex &source_left, const QModelIndex &source_right) const
{
if (source_left.data(SpaceChildrenModel::IsSpaceRole).toBool() && source_right.data(SpaceChildrenModel::IsSpaceRole).toBool()) {
if (!source_left.data(SpaceChildrenModel::OrderRole).toString().isEmpty() && !source_right.data(SpaceChildrenModel::OrderRole).toString().isEmpty()) {
return QString::compare(source_left.data(SpaceChildrenModel::OrderRole).toString(), source_right.data(SpaceChildrenModel::OrderRole).toString())
< 0;
}
return source_left.data(SpaceChildrenModel::ChildTimestampRole).toDateTime() > source_right.data(SpaceChildrenModel::ChildTimestampRole).toDateTime();
}
if (source_left.data(SpaceChildrenModel::IsSpaceRole).toBool()) {
return true;
} else if (source_right.data(SpaceChildrenModel::IsSpaceRole).toBool()) {
return false;
}
if (!source_left.data(SpaceChildrenModel::OrderRole).toString().isEmpty() && !source_right.data(SpaceChildrenModel::OrderRole).toString().isEmpty()) {
return QString::compare(source_left.data(SpaceChildrenModel::OrderRole).toString(), source_right.data(SpaceChildrenModel::OrderRole).toString()) < 0;
}
if (!source_left.data(SpaceChildrenModel::OrderRole).toString().isEmpty()) {
return true;
} else if (!source_right.data(SpaceChildrenModel::OrderRole).toString().isEmpty()) {
return false;
}
return source_left.data(SpaceChildrenModel::ChildTimestampRole).toDateTime() > source_right.data(SpaceChildrenModel::ChildTimestampRole).toDateTime();
}
bool SpaceChildSortFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const
{
QModelIndex index = sourceModel()->index(sourceRow, 0, sourceParent);
if (auto sourceModel = static_cast<SpaceChildrenModel *>(this->sourceModel())) {
bool isReplaced = sourceModel->isRoomReplaced(index.data(SpaceChildrenModel::RoomIDRole).toString());
bool acceptRoom = index.data(SpaceChildrenModel::DisplayNameRole).toString().contains(m_filterText, Qt::CaseInsensitive);
return !isReplaced && acceptRoom;
}
return true;
}
void SpaceChildSortFilterModel::move(const QModelIndex &currentIndex, const QModelIndex &targetIndex)
{
const auto rootSpace = dynamic_cast<SpaceChildrenModel *>(sourceModel())->space();
if (rootSpace == nullptr) {
return;
}
const auto connection = rootSpace->connection();
const auto currentParent = currentIndex.parent();
auto targetParent = targetIndex.parent();
NeoChatRoom *currentParentSpace = nullptr;
if (!currentParent.isValid()) {
currentParentSpace = rootSpace;
} else {
currentParentSpace = static_cast<NeoChatRoom *>(connection->room(currentParent.data(SpaceChildrenModel::RoomIDRole).toString()));
}
NeoChatRoom *targetParentSpace = nullptr;
if (!targetParent.isValid()) {
targetParentSpace = rootSpace;
} else {
targetParentSpace = static_cast<NeoChatRoom *>(connection->room(targetParent.data(SpaceChildrenModel::RoomIDRole).toString()));
}
// If both parents are not resolvable to a room object we don't have the permissions
// required for this action.
if (currentParentSpace == nullptr || targetParentSpace == nullptr) {
return;
}
const auto currentRow = currentIndex.row();
auto targetRow = targetIndex.row();
const auto moveRoomId = currentIndex.data(SpaceChildrenModel::RoomIDRole).toString();
auto targetRoom = static_cast<NeoChatRoom *>(connection->room(targetIndex.data(SpaceChildrenModel::RoomIDRole).toString()));
// If the target room is a space, assume we want to drop the room into it.
if (targetRoom != nullptr && targetRoom->isSpace()) {
targetParent = targetIndex;
targetParentSpace = targetRoom;
targetRow = rowCount(targetParent);
}
const auto newRowCount = rowCount(targetParent) + (currentParentSpace != targetParentSpace ? 1 : 0);
for (int i = 0; i < newRowCount; i++) {
if (currentParentSpace == targetParentSpace && i == currentRow) {
continue;
}
targetParentSpace->setChildOrder(index(i, 0, targetParent).data(SpaceChildrenModel::RoomIDRole).toString(),
QString::number(i > targetRow ? i + 1 : i, 36));
if (i == targetRow) {
if (currentParentSpace != targetParentSpace) {
currentParentSpace->removeChild(moveRoomId, true);
targetParentSpace->addChild(moveRoomId, true, false, false, QString::number(i + 1, 36));
} else {
targetParentSpace->setChildOrder(currentIndex.data(SpaceChildrenModel::RoomIDRole).toString(), QString::number(i + 1, 36));
}
}
}
}
#include "moc_spacechildsortfiltermodel.cpp"

View File

@@ -0,0 +1,56 @@
// SPDX-FileCopyrightText: 2023 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 once
#include <QQmlEngine>
#include <QSortFilterProxyModel>
/**
* @class SpaceChildSortFilterModel
*
* This class creates a custom QSortFilterProxyModel for filtering and sorting spaces
* in a SpaceChildrenModel.
*
* @sa SpaceChildrenModel
*/
class SpaceChildSortFilterModel : public QSortFilterProxyModel
{
Q_OBJECT
QML_ELEMENT
/**
* @brief The text to use to filter room names.
*/
Q_PROPERTY(QString filterText READ filterText READ filterText WRITE setFilterText NOTIFY filterTextChanged)
public:
SpaceChildSortFilterModel(QObject *parent = nullptr);
void setFilterText(const QString &filterText);
[[nodiscard]] QString filterText() const;
protected:
/**
* @brief Returns true if the value of source_left is less than source_right.
*
* @sa QSortFilterProxyModel::lessThan
*/
bool lessThan(const QModelIndex &source_left, const QModelIndex &source_right) const override;
/**
* @brief Custom filter function checking if an event type has been filtered out.
*
* The filter rejects a row if the room is known been replaced or if a search
* string is set it will only return rooms that match.
*/
bool filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const override;
Q_INVOKABLE void move(const QModelIndex &currentIndex, const QModelIndex &targetIndex);
Q_SIGNALS:
void filterTextChanged();
private:
QString m_filterText;
};

View File

@@ -0,0 +1,205 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#include "spacetreeitem.h"
#include "neochatconnection.h"
using namespace Qt::StringLiterals;
SpaceTreeItem::SpaceTreeItem(NeoChatConnection *connection,
SpaceTreeItem *parent,
const QString &id,
const QString &name,
const QString &canonicalAlias,
const QString &topic,
int memberCount,
const QUrl &avatarUrl,
bool allowGuests,
bool worldReadable,
bool isSpace,
Quotient::StateEvents childStates)
: m_connection(connection)
, m_parentItem(parent)
, m_id(id)
, m_name(name)
, m_canonicalAlias(canonicalAlias)
, m_topic(topic)
, m_memberCount(memberCount)
, m_avatarUrl(avatarUrl)
, m_allowGuests(allowGuests)
, m_worldReadable(worldReadable)
, m_isSpace(isSpace)
, m_childStates(std::move(childStates))
{
}
bool SpaceTreeItem::operator==(const SpaceTreeItem &other) const
{
return m_id == other.id();
}
SpaceTreeItem *SpaceTreeItem::child(int row)
{
return row >= 0 && row < childCount() ? m_children.at(row).get() : nullptr;
}
int SpaceTreeItem::childCount() const
{
return int(m_children.size());
}
bool SpaceTreeItem::insertChild(std::unique_ptr<SpaceTreeItem> newChild)
{
if (newChild == nullptr) {
return false;
}
for (auto it = m_children.begin(), end = m_children.end(); it != end; ++it) {
if (*it == newChild) {
*it = std::move(newChild);
return true;
}
}
m_children.push_back(std::move(newChild));
return true;
}
bool SpaceTreeItem::removeChild(int row)
{
if (row < 0 || row >= childCount()) {
return false;
}
m_children.erase(m_children.begin() + row);
return true;
}
int SpaceTreeItem::row() const
{
if (m_parentItem == nullptr) {
return 0;
}
const auto it = std::find_if(m_parentItem->m_children.cbegin(), m_parentItem->m_children.cend(), [this](const std::unique_ptr<SpaceTreeItem> &treeItem) {
return treeItem.get() == this;
});
if (it != m_parentItem->m_children.cend()) {
return std::distance(m_parentItem->m_children.cbegin(), it);
}
Q_ASSERT(false); // should not happen
return -1;
}
SpaceTreeItem *SpaceTreeItem::parentItem() const
{
return m_parentItem;
}
QString SpaceTreeItem::id() const
{
return m_id;
}
QString SpaceTreeItem::name() const
{
return m_name;
}
QString SpaceTreeItem::canonicalAlias() const
{
return m_canonicalAlias;
}
QString SpaceTreeItem::topic() const
{
return m_topic;
}
int SpaceTreeItem::memberCount() const
{
return m_memberCount;
}
QUrl SpaceTreeItem::avatarUrl() const
{
if (m_avatarUrl.isEmpty() || m_avatarUrl.scheme() != u"mxc"_s) {
return {};
}
auto url = m_connection->makeMediaUrl(m_avatarUrl);
if (url.scheme() == u"mxc"_s) {
return url;
}
return {};
}
bool SpaceTreeItem::allowGuests() const
{
return m_allowGuests;
}
bool SpaceTreeItem::worldReadable() const
{
return m_worldReadable;
}
bool SpaceTreeItem::isJoined() const
{
if (!m_connection) {
return false;
}
return m_connection->room(id(), Quotient::JoinState::Join) != nullptr;
}
bool SpaceTreeItem::isSpace() const
{
return m_isSpace;
}
QJsonObject SpaceTreeItem::childState(const SpaceTreeItem *child) const
{
if (child == nullptr) {
return {};
}
if (child->parentItem() != this) {
return {};
}
for (const auto &childState : m_childStates) {
if (childState->stateKey() == child->id()) {
return childState->fullJson();
}
}
return {};
}
QJsonObject SpaceTreeItem::childStateContent(const SpaceTreeItem *child) const
{
if (child == nullptr) {
return {};
}
if (child->parentItem() != this) {
return {};
}
for (const auto &childState : m_childStates) {
if (childState->stateKey() == child->id()) {
return childState->contentJson();
}
}
return {};
}
void SpaceTreeItem::setChildStates(Quotient::StateEvents childStates)
{
m_childStates.clear();
m_childStates = std::move(childStates);
}
bool SpaceTreeItem::isSuggested() const
{
if (m_parentItem == nullptr) {
return false;
}
const auto childStateContent = m_parentItem->childStateContent(this);
return childStateContent.value("suggested"_L1).toBool();
}

View File

@@ -0,0 +1,168 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#include <QPointer>
#include <Quotient/csapi/space_hierarchy.h>
#include <Quotient/events/stateevent.h>
class NeoChatConnection;
/**
* @class SpaceTreeItem
*
* This class defines an item in the space tree hierarchy model.
*
* @note This is separate from Quotient::Room and NeoChatRoom because we don't have
* full room information for any room/space the user hasn't joined and we
* don't want to create one for ever possible child in a space as that would
* be expensive.
*
* @sa Quotient::Room, NeoChatRoom
*/
class SpaceTreeItem
{
public:
explicit SpaceTreeItem(NeoChatConnection *connection,
SpaceTreeItem *parent = nullptr,
const QString &id = {},
const QString &name = {},
const QString &canonicalAlias = {},
const QString &topic = {},
int memberCount = {},
const QUrl &avatarUrl = {},
bool allowGuests = {},
bool worldReadable = {},
bool isSpace = {},
Quotient::StateEvents childStates = {});
bool operator==(const SpaceTreeItem &other) const;
/**
* @brief Return the child at the given row number.
*
* Nullptr is returned if there is no child at the given row number.
*/
SpaceTreeItem *child(int row);
/**
* @brief The number of children this item has.
*/
int childCount() const;
/**
* @brief Insert the given child.
*/
bool insertChild(std::unique_ptr<SpaceTreeItem> newChild);
/**
* @brief Remove the child at the given row number.
*
* @return True if a child was removed, false if the given row isn't valid.
*/
bool removeChild(int row);
/**
* @brief Return this item's parent.
*/
SpaceTreeItem *parentItem() const;
/**
* @brief Return the row number for this child relative to the parent.
*
* @return The row value if the child has a parent, 0 otherwise.
*/
int row() const;
/**
* @brief The ID of the room.
*/
QString id() const;
/**
* @brief The name of the room, if any.
*/
QString name() const;
/**
* @brief The canonical alias of the room, if any.
*/
QString canonicalAlias() const;
/**
* @brief The topic of the room, if any.
*/
QString topic() const;
/**
* @brief The number of members joined to the room.
*/
int memberCount() const;
/**
* @brief The URL for the room's avatar, if one is set.
*
* @return A CS API QUrl.
*/
QUrl avatarUrl() const;
/**
* @brief Whether guest users may join the room and participate in it.
*
* If they can, they will be subject to ordinary power level rules like any other users.
*/
bool allowGuests() const;
/**
* @brief Whether the room may be viewed by guest users without joining.
*/
bool worldReadable() const;
/**
* @brief Whether the local user is a member of the rooom.
*/
bool isJoined() const;
/**
* @brief Whether the room is a space.
*/
bool isSpace() const;
/**
* @brief Return the m.space.child stripped state Json for the given child.
*/
QJsonObject childState(const SpaceTreeItem *child) const;
/**
* @brief Return the m.space.child state event content for the given child.
*/
QJsonObject childStateContent(const SpaceTreeItem *child) const;
/**
* @brief Set the list of m.space.child events.
*
* Overwrites existing states. Calling with no input will clear the existing states.
*/
void setChildStates(Quotient::StateEvents childStates = {});
/**
* @brief Whether the room is suggested in the parent space.
*/
bool isSuggested() const;
private:
QPointer<NeoChatConnection> m_connection;
std::vector<std::unique_ptr<SpaceTreeItem>> m_children;
SpaceTreeItem *m_parentItem;
QString m_id;
QString m_name;
QString m_canonicalAlias;
QString m_topic;
int m_memberCount;
QUrl m_avatarUrl;
bool m_allowGuests;
bool m_worldReadable;
bool m_isSpace;
Quotient::StateEvents m_childStates;
};