From ecdad9f9653e798671d15d50b6c6fca78e807e77 Mon Sep 17 00:00:00 2001 From: James Graham Date: Thu, 28 Sep 2023 17:36:23 +0000 Subject: [PATCH] Space Home Page Add a space homepage with the ability to both create new room and add existing rooms to the space. This uses a tree model for the space hierarchy and will go to any number of levels. The user should only see the add options if they have appropriate permissions. This MR also combines the create space and room pages and adds a lot of optional functionality for managing space children. ![image](/uploads/1764b0319241ff870dc39b18b39f5d51/image.png) --- src/CMakeLists.txt | 9 +- src/models/spacechildrenmodel.cpp | 289 +++++++++++++++++++++++ src/models/spacechildrenmodel.h | 139 +++++++++++ src/models/spacechildsortfiltermodel.cpp | 46 ++++ src/models/spacechildsortfiltermodel.h | 54 +++++ src/models/spacetreeitem.cpp | 140 +++++++++++ src/models/spacetreeitem.h | 136 +++++++++++ src/neochatconnection.cpp | 50 +++- src/neochatconnection.h | 4 +- src/neochatroom.cpp | 19 ++ src/neochatroom.h | 2 + src/qml/CreateRoomDialog.qml | 193 ++++++++++++++- src/qml/CreateSpaceDialog.qml | 42 ---- src/qml/ExploreComponent.qml | 2 +- src/qml/GroupChatDrawerHeader.qml | 16 +- src/qml/RoomDrawer.qml | 1 + src/qml/RoomDrawerPage.qml | 1 + src/qml/RoomInformation.qml | 16 +- src/qml/SpaceHierarchyDelegate.qml | 131 ++++++++++ src/qml/SpaceHomePage.qml | 178 ++++++++++++++ src/qml/SpaceListContextMenu.qml | 2 +- src/qml/main.qml | 35 ++- src/roommanager.cpp | 34 ++- src/roommanager.h | 27 +++ 24 files changed, 1486 insertions(+), 80 deletions(-) create mode 100644 src/models/spacechildrenmodel.cpp create mode 100644 src/models/spacechildrenmodel.h create mode 100644 src/models/spacechildsortfiltermodel.cpp create mode 100644 src/models/spacechildsortfiltermodel.h create mode 100644 src/models/spacetreeitem.cpp create mode 100644 src/models/spacetreeitem.h delete mode 100644 src/qml/CreateSpaceDialog.qml create mode 100644 src/qml/SpaceHierarchyDelegate.qml create mode 100644 src/qml/SpaceHomePage.qml diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index e848fc8e7..9d363e044 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -40,6 +40,12 @@ add_library(neochat STATIC models/userfiltermodel.h models/publicroomlistmodel.cpp models/publicroomlistmodel.h + models/spacechildrenmodel.cpp + models/spacechildrenmodel.h + models/spacechildsortfiltermodel.cpp + models/spacechildsortfiltermodel.h + models/spacetreeitem.cpp + models/spacetreeitem.h models/userdirectorylistmodel.cpp models/userdirectorylistmodel.h models/pushrulemodel.cpp @@ -209,7 +215,6 @@ qt_add_qml_module(neochat URI org.kde.neochat NO_PLUGIN qml/Sso.qml qml/UserDetailDialog.qml qml/CreateRoomDialog.qml - qml/CreateSpaceDialog.qml qml/EmojiDialog.qml qml/OpenFileDialog.qml qml/KeyVerificationDialog.qml @@ -273,6 +278,8 @@ qt_add_qml_module(neochat URI org.kde.neochat NO_PLUGIN qml/RoomMedia.qml qml/ChooseRoomDialog.qml qml/ShareAction.qml + qml/SpaceHomePage.qml + qml/SpaceHierarchyDelegate.qml RESOURCES qml/confetti.png qml/glowdot.png diff --git a/src/models/spacechildrenmodel.cpp b/src/models/spacechildrenmodel.cpp new file mode 100644 index 000000000..859d40c56 --- /dev/null +++ b/src/models/spacechildrenmodel.cpp @@ -0,0 +1,289 @@ +// SPDX-FileCopyrightText: 2023 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#include "spacechildrenmodel.h" + +#include +#include +#include + +#include "controller.h" + +SpaceChildrenModel::SpaceChildrenModel(QObject *parent) + : QAbstractItemModel(parent) +{ + m_rootItem = new SpaceTreeItem(); +} + +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) { + disconnect(m_space->connection(), &Quotient::Connection::loadedRoomState, this, nullptr); + } + + m_space = space; + Q_EMIT spaceChanged(); + + for (auto job : m_currentJobs) { + if (job) { + job->abandon(); + } + } + m_currentJobs.clear(); + + auto connection = m_space->connection(); + connect(connection, &Quotient::Connection::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(); + }); + + refreshModel(); +} + +bool SpaceChildrenModel::loading() const +{ + return m_loading; +} + +void SpaceChildrenModel::refreshModel() +{ + beginResetModel(); + m_replacedRooms.clear(); + delete m_rootItem; + m_loading = true; + Q_EMIT loadingChanged(); + m_rootItem = new SpaceTreeItem(); + endResetModel(); + auto job = m_space->connection()->callApi(m_space->id(), Quotient::none, Quotient::none, 1); + m_currentJobs.append(job); + connect(job, &Quotient::BaseJob::success, this, [this, job]() { + insertChildren(job->rooms()); + }); +} + +void SpaceChildrenModel::insertChildren(std::vector children, const QModelIndex &parent) +{ + SpaceTreeItem *parentItem = getItem(parent); + + if (children[0].roomId == m_space->id() || children[0].roomId == parentItem->id()) { + 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; + } + } + parentItem->insertChild(insertRow, + new SpaceTreeItem(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 == QLatin1String("m.space"))); + if (children[i].childrenState.size() > 0) { + auto job = m_space->connection()->callApi(children[i].roomId, Quotient::none, Quotient::none, 1); + m_currentJobs.append(job); + connect(job, &Quotient::BaseJob::success, this, [this, parent, insertRow, job]() { + insertChildren(job->rooms(), index(insertRow, 0, parent)); + }); + } + } + } + endInsertRows(); +} + +SpaceTreeItem *SpaceChildrenModel::getItem(const QModelIndex &index) const +{ + if (index.isValid()) { + SpaceTreeItem *item = static_cast(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 == CanAddChildrenRole) { + auto connection = Controller::instance().activeConnection(); + if (const auto room = static_cast(connection->room(child->id()))) { + return room->canSendState(QLatin1String("m.space.child")); + } + return false; + } + + 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(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(parent.internalPointer()); + } + + return parentItem->childCount(); +} + +int SpaceChildrenModel::columnCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent); + return 1; +} + +QHash SpaceChildrenModel::roleNames() const +{ + QHash 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[CanAddChildrenRole] = "canAddChildren"; + + return roles; +} + +bool SpaceChildrenModel::isRoomReplaced(const QString &roomId) const +{ + return m_replacedRooms.contains(roomId); +} + +void SpaceChildrenModel::addPendingChild(const QString &childName) +{ + m_pendingChildren += childName; +} diff --git a/src/models/spacechildrenmodel.h b/src/models/spacechildrenmodel.h new file mode 100644 index 000000000..2afd1da28 --- /dev/null +++ b/src/models/spacechildrenmodel.h @@ -0,0 +1,139 @@ +// SPDX-FileCopyrightText: 2023 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#pragma once + +#include +#include + +#include +#include + +#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, + CanAddChildrenRole, + }; + + 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 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: + NeoChatRoom *m_space = nullptr; + SpaceTreeItem *m_rootItem; + + bool m_loading = false; + QList> m_currentJobs; + QList m_pendingChildren; + + QList m_replacedRooms; + + SpaceTreeItem *getItem(const QModelIndex &index) const; + + void refreshModel(); + void insertChildren(std::vector children, const QModelIndex &parent = QModelIndex()); +}; diff --git a/src/models/spacechildsortfiltermodel.cpp b/src/models/spacechildsortfiltermodel.cpp new file mode 100644 index 000000000..d5a5e445e --- /dev/null +++ b/src/models/spacechildsortfiltermodel.cpp @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: 2023 James Graham +// 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()) { + return false; + } + return true; +} + +bool SpaceChildSortFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex &sourceParent) const +{ + QModelIndex index = sourceModel()->index(sourceRow, 0, sourceParent); + if (auto sourceModel = static_cast(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; +} + +#include "moc_spacechildsortfiltermodel.cpp" diff --git a/src/models/spacechildsortfiltermodel.h b/src/models/spacechildsortfiltermodel.h new file mode 100644 index 000000000..fff837b89 --- /dev/null +++ b/src/models/spacechildsortfiltermodel.h @@ -0,0 +1,54 @@ +// SPDX-FileCopyrightText: 2023 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#pragma once + +#include +#include + +/** + * @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_SIGNALS: + void filterTextChanged(); + +private: + QString m_filterText; +}; diff --git a/src/models/spacetreeitem.cpp b/src/models/spacetreeitem.cpp new file mode 100644 index 000000000..2bd904549 --- /dev/null +++ b/src/models/spacetreeitem.cpp @@ -0,0 +1,140 @@ +// SPDX-FileCopyrightText: 2023 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#include "spacetreeitem.h" + +#include "controller.h" + +SpaceTreeItem::SpaceTreeItem(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) + : 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) +{ +} + +SpaceTreeItem::~SpaceTreeItem() +{ + qDeleteAll(m_children); +} + +SpaceTreeItem *SpaceTreeItem::child(int number) +{ + if (number < 0 || number >= m_children.size()) { + return nullptr; + } + return m_children[number]; +} + +int SpaceTreeItem::childCount() const +{ + return m_children.count(); +} + +bool SpaceTreeItem::insertChild(int row, SpaceTreeItem *newChild) +{ + if (row < 0 || row > m_children.size()) { + return false; + } + m_children.insert(row, newChild); + return true; +} + +bool SpaceTreeItem::removeChild(int row) +{ + if (row < 0 || row >= m_children.size()) { + return false; + } + delete m_children.takeAt(row); + return true; +} + +int SpaceTreeItem::row() const +{ + if (m_parentItem) { + return m_parentItem->m_children.indexOf(const_cast(this)); + } + return 0; +} + +SpaceTreeItem *SpaceTreeItem::parentItem() +{ + 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() != QLatin1String("mxc")) { + return {}; + } + auto connection = Controller::instance().activeConnection(); + auto url = connection->makeMediaUrl(m_avatarUrl); + if (url.scheme() == QLatin1String("mxc")) { + return url; + } + return {}; +} + +bool SpaceTreeItem::allowGuests() const +{ + return m_allowGuests; +} + +bool SpaceTreeItem::worldReadable() const +{ + return m_worldReadable; +} + +bool SpaceTreeItem::isJoined() const +{ + auto connection = Controller::instance().activeConnection(); + if (!connection) { + return false; + } + return connection->room(id(), Quotient::JoinState::Join) != nullptr; +} + +bool SpaceTreeItem::isSpace() const +{ + return m_isSpace; +} diff --git a/src/models/spacetreeitem.h b/src/models/spacetreeitem.h new file mode 100644 index 000000000..32ea87a20 --- /dev/null +++ b/src/models/spacetreeitem.h @@ -0,0 +1,136 @@ +// SPDX-FileCopyrightText: 2023 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#include + +/** + * @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(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 = {}); + ~SpaceTreeItem(); + + /** + * @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 number); + + /** + * @brief The number of children this item has. + */ + int childCount() const; + + /** + * @brief Insert the given child at the given row number. + */ + bool insertChild(int row, 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(); + + /** + * @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; + +private: + QList 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; +}; diff --git a/src/neochatconnection.cpp b/src/neochatconnection.cpp index e98871677..0f02a0bb0 100644 --- a/src/neochatconnection.cpp +++ b/src/neochatconnection.cpp @@ -156,9 +156,30 @@ void NeoChatConnection::deactivateAccount(const QString &password) }); } -void NeoChatConnection::createRoom(const QString &name, const QString &topic) +void NeoChatConnection::createRoom(const QString &name, const QString &topic, const QString &parent, bool setChildParent) { - const auto job = Connection::createRoom(Connection::PublishRoom, {}, name, topic, {}); + QVector initialStateEvents; + if (!parent.isEmpty()) { + initialStateEvents.append(CreateRoomJob::StateEvent{ + "m.space.parent"_ls, + QJsonObject{ + {"canonical"_ls, true}, + {"via"_ls, QJsonArray{domain()}}, + }, + parent, + }); + } + + const auto job = Connection::createRoom(Connection::PublishRoom, QString(), name, topic, QStringList(), {}, {}, {}, initialStateEvents); + if (!parent.isEmpty()) { + connect(job, &Quotient::CreateRoomJob::success, this, [this, parent, setChildParent, job]() { + if (setChildParent) { + if (auto parentRoom = room(parent)) { + parentRoom->setState(QLatin1String("m.space.child"), job->roomId(), QJsonObject{{QLatin1String("via"), QJsonArray{domain()}}}); + } + } + }); + } connect(job, &CreateRoomJob::failure, this, [this, job] { Q_EMIT Controller::instance().errorOccured(i18n("Room creation failed: %1", job->errorString())); }); @@ -167,9 +188,30 @@ void NeoChatConnection::createRoom(const QString &name, const QString &topic) }); } -void NeoChatConnection::createSpace(const QString &name, const QString &topic) +void NeoChatConnection::createSpace(const QString &name, const QString &topic, const QString &parent, bool setChildParent) { - const auto job = Connection::createRoom(Connection::UnpublishRoom, {}, name, topic, {}, {}, {}, false, {}, {}, QJsonObject{{"type"_ls, "m.space"_ls}}); + QVector initialStateEvents; + if (!parent.isEmpty()) { + initialStateEvents.append(CreateRoomJob::StateEvent{ + "m.space.parent"_ls, + QJsonObject{ + {"canonical"_ls, true}, + {"via"_ls, QJsonArray{domain()}}, + }, + parent, + }); + } + + const auto job = Connection::createRoom(Connection::UnpublishRoom, {}, name, topic, {}, {}, {}, false, initialStateEvents, {}, QJsonObject{{"type"_ls, "m.space"_ls}}); + if (!parent.isEmpty()) { + connect(job, &Quotient::CreateRoomJob::success, this, [this, parent, setChildParent, job]() { + if (setChildParent) { + if (auto parentRoom = room(parent)) { + parentRoom->setState(QLatin1String("m.space.child"), job->roomId(), QJsonObject{{QLatin1String("via"), QJsonArray{domain()}}}); + } + } + }); + } connect(job, &CreateRoomJob::failure, this, [this, job] { Q_EMIT Controller::instance().errorOccured(i18n("Space creation failed: %1", job->errorString())); }); diff --git a/src/neochatconnection.h b/src/neochatconnection.h index 81491df74..82a8ecae0 100644 --- a/src/neochatconnection.h +++ b/src/neochatconnection.h @@ -54,12 +54,12 @@ public: /** * @brief Create new room for a group chat. */ - Q_INVOKABLE void createRoom(const QString &name, const QString &topic); + Q_INVOKABLE void createRoom(const QString &name, const QString &topic, const QString &parent = {}, bool setChildParent = false); /** * @brief Create new space. */ - Q_INVOKABLE void createSpace(const QString &name, const QString &topic); + Q_INVOKABLE void createSpace(const QString &name, const QString &topic, const QString &parent = {}, bool setChildParent = false); Q_SIGNALS: void labelChanged(); diff --git a/src/neochatroom.cpp b/src/neochatroom.cpp index 779765da7..aceec0348 100644 --- a/src/neochatroom.cpp +++ b/src/neochatroom.cpp @@ -1109,6 +1109,25 @@ bool NeoChatRoom::isSpace() return creationEvent->roomType() == RoomType::Space; } +void NeoChatRoom::addChild(const QString &childId, bool setChildParent) +{ + if (!isSpace()) { + return; + } + if (!canSendEvent("m.space.child"_ls)) { + return; + } + setState("m.space.child"_ls, childId, QJsonObject{{QLatin1String("via"), QJsonArray{connection()->domain()}}}); + + if (setChildParent) { + if (auto child = static_cast(connection()->room(childId))) { + if (child->canSendState("m.space.parent"_ls)) { + child->setState("m.space.parent"_ls, id(), QJsonObject{{"canonical"_ls, true}, {"via"_ls, QJsonArray{connection()->domain()}}}); + } + } + } +} + PushNotificationState::State NeoChatRoom::pushNotificationState() const { return m_currentPushNotificationState; diff --git a/src/neochatroom.h b/src/neochatroom.h index df1e5b320..94becb318 100644 --- a/src/neochatroom.h +++ b/src/neochatroom.h @@ -589,6 +589,8 @@ public: [[nodiscard]] bool isSpace(); + Q_INVOKABLE void addChild(const QString &childId, bool setChildParent = false); + bool isInvite() const; Q_INVOKABLE void clearInvitationNotification(); diff --git a/src/qml/CreateRoomDialog.qml b/src/qml/CreateRoomDialog.qml index b213db6cd..a26155091 100644 --- a/src/qml/CreateRoomDialog.qml +++ b/src/qml/CreateRoomDialog.qml @@ -2,44 +2,225 @@ // SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-or-later OR LicenseRef-KDE-Accepted-GPL import QtQuick +import QtQuick.Controls as QQC2 +import QtQuick.Layouts +import org.kde.kirigami as Kirigami import org.kde.kirigamiaddons.formcard as FormCard +import org.kde.kirigamiaddons.labs.components as Components import org.kde.neochat FormCard.FormCardPage { id: root - title: i18nc("@title", "Create a Room") + property string parentId: "" + + property bool isSpace: false + + property bool showChildType: false + + property bool showCreateChoice: false required property NeoChatConnection connection + signal addChild(string childId, bool setChildParent) + signal newChild(string childName) + + title: isSpace ? i18nc("@title", "Create a Space") : i18nc("@title", "Create a Room") + Component.onCompleted: roomNameField.forceActiveFocus() FormCard.FormHeader { - title: i18nc("@title", "Room Information") + title: root.isSpace ? i18n("New Space Information") : i18n("New Room Information") } FormCard.FormCard { + FormCard.FormComboBoxDelegate { + id: roomTypeCombo + property bool isInitialising: true + + visible: root.showChildType + + text: i18n("Select type") + model: ListModel { + id: roomTypeModel + } + textRole: "text" + valueRole: "isSpace" + + Component.onCompleted: { + currentIndex = indexOfValue(root.isSpace) + roomTypeModel.append({"text": i18n("Room"), "isSpace": false}); + roomTypeModel.append({"text": i18n("Space"), "isSpace": true}); + roomTypeCombo.currentIndex = 0 + roomTypeCombo.isInitialising = false + } + onCurrentValueChanged: { + if (!isInitialising) { + root.isSpace = currentValue + } + } + } FormCard.FormTextFieldDelegate { id: roomNameField - label: i18n("Room name:") + label: i18n("Name:") onAccepted: if (roomNameField.text.length > 0) roomTopicField.forceActiveFocus(); } FormCard.FormTextFieldDelegate { id: roomTopicField - label: i18n("Room topic:") + label: i18n("Topic:") onAccepted: ok.clicked() } - + FormCard.FormCheckDelegate { + id: newOfficialCheck + visible: root.parentId.length > 0 + text: i18n("Make this parent official") + checked: true + } FormCard.FormButtonDelegate { id: ok text: i18nc("@action:button", "Ok") enabled: roomNameField.text.length > 0 onClicked: { - root.connection.createRoom(roomNameField.text, roomTopicField.text); + if (root.isSpace) { + root.connection.createSpace(roomNameField.text, roomTopicField.text, root.parentId, newOfficialCheck.checked); + } else { + root.connection.createRoom(roomNameField.text, roomTopicField.text, root.parentId, newOfficialCheck.checked); + } + root.newChild(roomNameField.text) root.closeDialog() } } } + FormCard.FormHeader { + visible: root.showChildType + title: i18n("Select Exisiting Room") + } + FormCard.FormCard { + visible: root.showChildType + FormCard.FormButtonDelegate { + visible: !chosenRoomDelegate.visible + text: i18nc("@action:button", "Pick room") + onClicked: { + let dialog = pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/JoinRoomPage.qml", {connection: root.connection}, {title: i18nc("@title", "Explore Rooms")}) + dialog.roomSelected.connect((roomId, displayName, avatarUrl, alias, topic, memberCount, isJoined) => { + chosenRoomDelegate.roomId = roomId; + chosenRoomDelegate.displayName = displayName; + chosenRoomDelegate.avatarUrl = avatarUrl; + chosenRoomDelegate.alias = alias; + chosenRoomDelegate.topic = topic; + chosenRoomDelegate.memberCount = memberCount; + chosenRoomDelegate.isJoined = isJoined; + chosenRoomDelegate.visible = true; + }) + } + } + FormCard.AbstractFormDelegate { + id: chosenRoomDelegate + property string roomId + property string displayName + property url avatarUrl + property string alias + property string topic + property int memberCount + property bool isJoined + + visible: false + + contentItem: RowLayout { + Components.Avatar { + Layout.preferredWidth: Kirigami.Units.gridUnit * 2 + Layout.preferredHeight: Kirigami.Units.gridUnit * 2 + + source: chosenRoomDelegate.avatarUrl + name: chosenRoomDelegate.displayName + } + ColumnLayout { + Layout.fillWidth: true + RowLayout { + Layout.fillWidth: true + Kirigami.Heading { + Layout.fillWidth: true + level: 4 + text: chosenRoomDelegate.displayName + font.bold: true + textFormat: Text.PlainText + elide: Text.ElideRight + wrapMode: Text.NoWrap + } + QQC2.Label { + visible: chosenRoomDelegate.isJoined + text: i18n("Joined") + color: Kirigami.Theme.linkColor + } + } + QQC2.Label { + Layout.fillWidth: true + visible: text + text: chosenRoomDelegate.topic ? chosenRoomDelegate.topic.replace(/(\r\n\t|\n|\r\t)/gm," ") : "" + textFormat: Text.PlainText + elide: Text.ElideRight + wrapMode: Text.NoWrap + } + RowLayout { + Layout.fillWidth: true + Kirigami.Icon { + source: "user" + color: Kirigami.Theme.disabledTextColor + implicitHeight: Kirigami.Units.iconSizes.small + implicitWidth: Kirigami.Units.iconSizes.small + } + QQC2.Label { + text: chosenRoomDelegate.memberCount + " " + (chosenRoomDelegate.alias ?? chosenRoomDelegate.roomId) + color: Kirigami.Theme.disabledTextColor + elide: Text.ElideRight + Layout.fillWidth: true + } + } + } + } + + onClicked: { + let dialog = pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/JoinRoomPage.qml", {connection: root.connection}, {title: i18nc("@title", "Explore Rooms")}) + dialog.roomSelected.connect((roomId, displayName, avatarUrl, alias, topic, memberCount, isJoined) => { + chosenRoomDelegate.roomId = roomId; + chosenRoomDelegate.displayName = displayName; + chosenRoomDelegate.avatarUrl = avatarUrl; + chosenRoomDelegate.alias = alias; + chosenRoomDelegate.topic = topic; + chosenRoomDelegate.memberCount = memberCount; + chosenRoomDelegate.isJoined = isJoined; + chosenRoomDelegate.visible = true; + }) + } + } + FormCard.FormCheckDelegate { + id: exisitingOfficialCheck + visible: root.parentId.length > 0 + text: i18n("Make this parent official") + description: enabled ? i18n("You have the required privilege level in the child to set this state") : i18n("You do not have a high enough privilege level in the child to set this state") + checked: enabled + + enabled: { + if (chosenRoomDelegate.visible) { + let room = root.connection.room(chosenRoomDelegate.roomId); + if (room) { + if (room.canSendState("m.space.parent")) { + return true; + } + } + } + return false; + } + } + FormCard.FormButtonDelegate { + text: i18nc("@action:button", "Ok") + enabled: chosenRoomDelegate.visible + onClicked: { + root.addChild(chosenRoomDelegate.roomId, exisitingOfficialCheck.checked); + root.closeDialog(); + } + } + } } diff --git a/src/qml/CreateSpaceDialog.qml b/src/qml/CreateSpaceDialog.qml deleted file mode 100644 index 8b3d3bbb9..000000000 --- a/src/qml/CreateSpaceDialog.qml +++ /dev/null @@ -1,42 +0,0 @@ -// SPDX-FileCopyrightText: 2023 Tobias Fella -// SPDX-License-Identifier: GPL-2.0-or-later - -import QtQuick -import QtQuick.Layouts -import org.kde.kirigami as Kirigami - -import org.kde.neochat -import org.kde.kirigamiaddons.formcard as FormCard - -FormCard.FormCardPage { - id: root - - required property NeoChatConnection connection - - title: i18n("Create a Space") - - Kirigami.Theme.colorSet: Kirigami.Theme.Window - - FormCard.FormHeader { - title: i18nc("@title", "Create a Space") - } - FormCard.FormCard { - FormCard.FormTextFieldDelegate { - id: nameDelegate - label: i18n("Space name") - } - FormCard.FormTextFieldDelegate { - id: topicDelegate - label: i18n("Space topic (optional)") - } - FormCard.FormButtonDelegate { - text: i18n("Create space") - onClicked: { - root.connection.createSpace(nameDelegate.text, topicDelegate.text) - root.close() - root.destroy() - } - enabled: nameDelegate.text.length > 0 - } - } -} diff --git a/src/qml/ExploreComponent.qml b/src/qml/ExploreComponent.qml index 19e6f6c25..73f9c5d1e 100644 --- a/src/qml/ExploreComponent.qml +++ b/src/qml/ExploreComponent.qml @@ -48,7 +48,7 @@ RowLayout { text: i18n("Create a Space") icon.name: "list-add" onTriggered: { - pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/CreateSpaceDialog.qml", {connection: root.connection}, {title: i18nc("@title", "Create a Space")}) + pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/CreateRoomDialog.qml", {connection: root.connection, isSpace: true, title: i18nc("@title", "Create a Space")}, {title: i18nc("@title", "Create a Space")}) } } diff --git a/src/qml/GroupChatDrawerHeader.qml b/src/qml/GroupChatDrawerHeader.qml index 7f423dc49..b564a2def 100644 --- a/src/qml/GroupChatDrawerHeader.qml +++ b/src/qml/GroupChatDrawerHeader.qml @@ -11,6 +11,10 @@ import org.kde.neochat ColumnLayout { id: root + /** + * @brief The current room that user is viewing. + */ + required property NeoChatRoom room Layout.fillWidth: true @@ -26,8 +30,8 @@ ColumnLayout { Layout.preferredWidth: Kirigami.Units.iconSizes.large Layout.preferredHeight: Kirigami.Units.iconSizes.large - name: room ? room.displayName : "" - source: room && room.avatarMediaId ? ("image://mxc/" + room.avatarMediaId) : "" + name: root.room ? root.room.displayName : "" + source: root.room && root.room.avatarMediaId ? ("image://mxc/" + root.room.avatarMediaId) : "" Rectangle { visible: room.usesEncryption @@ -58,7 +62,7 @@ ColumnLayout { Kirigami.Heading { Layout.fillWidth: true - text: room ? room.displayName : i18n("No name") + text: root.room ? root.room.displayName : i18n("No name") textFormat: Text.PlainText wrapMode: Text.Wrap } @@ -67,8 +71,8 @@ ColumnLayout { Layout.fillWidth: true font: Kirigami.Theme.smallFont textFormat: TextEdit.PlainText - visible: room && room.canonicalAlias - text: room && room.canonicalAlias ? room.canonicalAlias : "" + visible: root.room && root.room.canonicalAlias + text: root.room && root.room.canonicalAlias ? root.room.canonicalAlias : "" } } } @@ -78,7 +82,7 @@ ColumnLayout { Layout.leftMargin: Kirigami.Units.largeSpacing Layout.rightMargin: Kirigami.Units.largeSpacing - text: room && room.topic ? room.topic.replace(replaceLinks, "$1") : i18n("No Topic") + text: root.room && root.room.topic ? root.room.topic.replace(replaceLinks, "$1") : i18n("No Topic") readonly property var replaceLinks: /(http[s]?:\/\/[^ \r\n]*)/g textFormat: TextEdit.MarkdownText wrapMode: Text.Wrap diff --git a/src/qml/RoomDrawer.qml b/src/qml/RoomDrawer.qml index 826aa376e..ae347689b 100644 --- a/src/qml/RoomDrawer.qml +++ b/src/qml/RoomDrawer.qml @@ -136,6 +136,7 @@ Kirigami.OverlayDrawer { Kirigami.NavigationTabBar { id: navigationBar Layout.fillWidth: true + visible: !root.room.isSpace Kirigami.Theme.colorSet: Kirigami.Theme.Window Kirigami.Theme.inherit: false diff --git a/src/qml/RoomDrawerPage.qml b/src/qml/RoomDrawerPage.qml index d14710f47..f0381d52b 100644 --- a/src/qml/RoomDrawerPage.qml +++ b/src/qml/RoomDrawerPage.qml @@ -73,6 +73,7 @@ Kirigami.Page { footer: Kirigami.NavigationTabBar { id: navigationBar + visible: !root.room.isSpace Kirigami.Theme.colorSet: Kirigami.Theme.Window Kirigami.Theme.inherit: false diff --git a/src/qml/RoomInformation.qml b/src/qml/RoomInformation.qml index 3515537f5..3f94c41ab 100644 --- a/src/qml/RoomInformation.qml +++ b/src/qml/RoomInformation.qml @@ -38,7 +38,7 @@ QQC2.ScrollView { /** * @brief The title that should be displayed for this component if available. */ - readonly property string title: i18nc("@action:title", "Room information") + readonly property string title: root.room.isSpace ? i18nc("@action:title", "Space Members") : i18nc("@action:title", "Room information") // HACK: Hide unnecessary horizontal scrollbar (https://bugreports.qt.io/browse/QTBUG-83890) QQC2.ScrollBar.horizontal.policy: QQC2.ScrollBar.AlwaysOff @@ -57,6 +57,7 @@ QQC2.ScrollView { active: true Layout.fillWidth: true Layout.topMargin: Kirigami.Units.smallSpacing + visible: !root.room.isSpace sourceComponent: root.room.isDirectChat() ? directChatDrawerHeader : groupChatDrawerHeader onItemChanged: if (item) { userList.positionViewAtBeginning(); @@ -64,6 +65,7 @@ QQC2.ScrollView { } Kirigami.ListSectionHeader { + visible: !root.room.isSpace label: i18n("Options") activeFocusOnTab: false @@ -75,7 +77,7 @@ QQC2.ScrollView { icon.name: "tools" text: i18n("Open developer tools") - visible: Config.developerTools + visible: Config.developerTools && !root.room.isSpace Layout.fillWidth: true @@ -86,7 +88,7 @@ QQC2.ScrollView { Delegates.RoundedItemDelegate { id: searchButton - + visible: !root.room.isSpace icon.name: "search" text: i18n("Search in this room") @@ -104,7 +106,7 @@ QQC2.ScrollView { Delegates.RoundedItemDelegate { id: favouriteButton - + visible: !root.room.isSpace icon.name: root.room && root.room.isFavourite ? "rating" : "rating-unrated" text: root.room && root.room.isFavourite ? i18n("Remove room from favorites") : i18n("Make room favorite") @@ -115,7 +117,7 @@ QQC2.ScrollView { Delegates.RoundedItemDelegate { id: locationsButton - + visible: !root.room.isSpace icon.name: "map-flat" text: i18n("Show locations for this room") @@ -240,7 +242,9 @@ QQC2.ScrollView { Component { id: groupChatDrawerHeader - GroupChatDrawerHeader {} + GroupChatDrawerHeader { + room: root.room + } } Component { diff --git a/src/qml/SpaceHierarchyDelegate.qml b/src/qml/SpaceHierarchyDelegate.qml new file mode 100644 index 000000000..6989d94f7 --- /dev/null +++ b/src/qml/SpaceHierarchyDelegate.qml @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: 2023 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +import QtQuick +import QtQuick.Controls as QQC2 +import QtQuick.Layouts + +import org.kde.kirigami as Kirigami +import org.kde.kirigamiaddons.delegates as Delegates +import org.kde.kirigamiaddons.labs.components as Components + +import org.kde.neochat + +Item { + id: root + required property TreeView treeView + required property bool isTreeNode + required property bool expanded + required property int hasChildren + required property int depth + required property string roomId + required property string displayName + required property url avatarUrl + required property bool isSpace + required property int memberCount + required property string topic + required property bool isJoined + required property bool canAddChildren + + signal createRoom() + signal enterRoom() + + Delegates.RoundedItemDelegate { + anchors.centerIn: root + width: sizeHelper.currentWidth + + contentItem: RowLayout { + 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 + text: i18n("Joined") + color: Kirigami.Theme.linkColor + } + } + QQC2.Label { + id: subtitle + Layout.fillWidth: true + Layout.alignment: Qt.AlignLeft | Qt.AlignTop + + text: root.memberCount + (root.topic !== "" ? i18nc("number of room members", " members - ") + root.topic : i18nc("number of room members", " members")) + elide: Text.ElideRight + font: Kirigami.Theme.smallFont + textFormat: Text.PlainText + maximumLineCount: 1 + } + } + QQC2.ToolButton { + visible: root.isSpace && root.canAddChildren + text: i18nc("@button", "Add new child") + icon.name: "list-add" + onClicked: root.createRoom() + + QQC2.ToolTip.text: text + QQC2.ToolTip.visible: hovered + QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay + } + } + + TapHandler { + onTapped: { + if (root.isSpace) { + root.treeView.toggleExpanded(row) + } else { + if (root.isJoined) { + root.enterRoom() + } else { + Controller.joinRoom(root.roomId) + } + } + } + } + } + + DelegateSizeHelper { + id: sizeHelper + startBreakpoint: Kirigami.Units.gridUnit * 46 + endBreakpoint: Kirigami.Units.gridUnit * 66 + startPercentWidth: 100 + endPercentWidth: 85 + maxWidth: Kirigami.Units.gridUnit * 60 + + parentWidth: root.treeView ? root.treeView.width : 0 + } +} diff --git a/src/qml/SpaceHomePage.qml b/src/qml/SpaceHomePage.qml new file mode 100644 index 000000000..62399d4a7 --- /dev/null +++ b/src/qml/SpaceHomePage.qml @@ -0,0 +1,178 @@ +// SPDX-FileCopyrightText: 2023 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +import QtQuick +import QtQuick.Controls as QQC2 +import QtQuick.Layouts + +import org.kde.kirigami as Kirigami + +import org.kde.neochat + +Kirigami.Page { + id: root + + readonly property NeoChatRoom currentRoom: RoomManager.currentRoom + + padding: 0 + + ColumnLayout { + id: columnLayout + anchors.fill: parent + spacing: 0 + + Item { + id: headerItem + Layout.fillWidth: true + Layout.topMargin: Kirigami.Units.smallSpacing + implicitHeight: headerColumn.implicitHeight + + 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("qrc:/org/kde/neochat/qml/InviteUserPage.qml", {room: root.currentRoom}, {title: i18nc("@title", "Invite a User")}) + } + QQC2.Button { + visible: root.currentRoom.canSendState("m.space.child") + text: i18nc("@button", "Add new child") + icon.name: "list-add" + onClicked: _private.createRoom(root.currentRoom.id) + } + QQC2.Button { + text: i18nc("@button", "Leave the space") + icon.name: "go-previous" + onClicked: RoomManager.leaveRoom(root.currentRoom) + } + Item { + Layout.fillWidth: true + } + QQC2.Button { + text: i18nc("@button", "Space settings") + icon.name: "settings-configure" + display: QQC2.AbstractButton.IconOnly + onClicked: applicationWindow().pageStack.pushDialogLayer('qrc:/org/kde/neochat/qml/Categories.qml', {room: root.currentRoom, connection: root.currentRoom.connection}, { title: i18n("Room Settings") }) + + 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 + } + } + DelegateSizeHelper { + id: sizeHelper + startBreakpoint: Kirigami.Units.gridUnit * 46 + endBreakpoint: Kirigami.Units.gridUnit * 66 + startPercentWidth: 100 + endPercentWidth: 85 + maxWidth: Kirigami.Units.gridUnit * 60 + + parentWidth: columnLayout.width + } + } + 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) + onEnterRoom: _private.enterRoom(roomId) + } + } + + background: Rectangle { + color: Kirigami.Theme.backgroundColor + Kirigami.Theme.colorSet: Kirigami.Theme.View + } + } + Item { + Layout.fillWidth: true + Layout.fillHeight: true + visible: spaceChildrenModel.loading + + Loader { + active: spaceChildrenModel.loading + anchors.centerIn: parent + sourceComponent: Kirigami.LoadingPlaceholder {} + } + } + } + + background: Rectangle { + color: Kirigami.Theme.backgroundColor + Kirigami.Theme.inherit: false + Kirigami.Theme.colorSet: Kirigami.Theme.View + } + + QtObject { + id: _private + function createRoom(parentId) { + let dialog = applicationWindow().pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/CreateRoomDialog.qml", { + 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) => { + // 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) + } + }) + dialog.newChild.connect(childName => {spaceChildrenModel.addPendingChild(childName)}) + } + + function enterRoom(roomId) { + let room = root.currentRoom.connection.room(roomId) + if (room) { + RoomManager.enterRoom(room) + } + } + } +} diff --git a/src/qml/SpaceListContextMenu.qml b/src/qml/SpaceListContextMenu.qml index 8c1a58803..0df44c1d3 100644 --- a/src/qml/SpaceListContextMenu.qml +++ b/src/qml/SpaceListContextMenu.qml @@ -29,7 +29,7 @@ Loader { QQC2.MenuItem { text: i18nc("'Space' is a matrix space", "View Space") icon.name: "view-list-details" - onTriggered: RoomManager.enterRoom(room); + onTriggered: RoomManager.enterSpaceHome(room); } QQC2.MenuItem { diff --git a/src/qml/main.qml b/src/qml/main.qml index 9a4513f98..c363f35a2 100644 --- a/src/qml/main.qml +++ b/src/qml/main.qml @@ -21,6 +21,7 @@ Kirigami.ApplicationWindow { property bool roomListLoaded: false property RoomPage roomPage + property SpaceHomePage spaceHomePage property NeoChatConnection connection: Controller.activeConnection @@ -96,15 +97,36 @@ Kirigami.ApplicationWindow { } } + function onPushSpaceHome(room) { + root.spaceHomePage = pageStack.push("qrc:/org/kde/neochat/qml/SpaceHomePage.qml"); + root.spaceHomePage.forceActiveFocus(); + } + function onReplaceRoom(room, event) { - const roomItem = pageStack.get(pageStack.depth - 1); - pageStack.currentIndex = pageStack.depth - 1; + if (root.roomPage) { + pageStack.currentIndex = pageStack.depth - 1; + } else { + pageStack.pop(); + root.roomPage = pageStack.push("qrc:/org/kde/neochat/qml/RoomPage.qml", {connection: root.connection}); + root.spaceHomePage = null; + } root.roomPage.forceActiveFocus(); if (event.length > 0) { - roomItem.goToEvent(event); + root.roomPage.goToEvent(event); } } + function onReplaceSpaceHome(room) { + if (root.spaceHomePage) { + pageStack.currentIndex = pageStack.depth - 1; + } else { + pageStack.pop(); + root.spaceHomePage = pageStack.push("qrc:/org/kde/neochat/qml/SpaceHomePage.qml"); + root.roomPage = null; + } + root.spaceHomePage.forceActiveFocus(); + } + function goToEvent(event) { if (event.length > 0) { roomItem.goToEvent(event); @@ -335,13 +357,6 @@ Kirigami.ApplicationWindow { } } - Component { - id: createSpaceDialog - CreateSpaceDialog { - connection: root.connection - } - } - Component { id: roomWindow RoomWindow {} diff --git a/src/roommanager.cpp b/src/roommanager.cpp index 89bc58342..e532b2c5d 100644 --- a/src/roommanager.cpp +++ b/src/roommanager.cpp @@ -179,7 +179,11 @@ void RoomManager::openRoomForActiveConnection() const auto room = qobject_cast(Controller::instance().activeConnection()->room(roomId)); if (room) { - enterRoom(room); + if (room->isSpace()) { + enterSpaceHome(room); + } else { + enterRoom(room); + } } } } @@ -222,6 +226,34 @@ void RoomManager::openWindow(NeoChatRoom *room) Q_EMIT openRoomInNewWindow(room); } +void RoomManager::enterSpaceHome(NeoChatRoom *spaceRoom) +{ + if (!spaceRoom->isSpace()) { + return; + } + // If replacing a normal room message timeline make sure any edit is cancelled. + if (m_currentRoom && !m_currentRoom->chatBoxEditId().isEmpty()) { + m_currentRoom->setChatBoxEditId({}); + } + // Save the chatbar text for the current room if any before switching + if (m_currentRoom && m_chatDocumentHandler) { + if (m_chatDocumentHandler->document()) { + m_currentRoom->setSavedText(m_chatDocumentHandler->document()->textDocument()->toPlainText()); + } + } + m_lastCurrentRoom = std::exchange(m_currentRoom, spaceRoom); + Q_EMIT currentRoomChanged(); + + if (!m_lastCurrentRoom) { + Q_EMIT pushSpaceHome(spaceRoom); + } else { + Q_EMIT replaceSpaceHome(m_currentRoom); + } + + // Save last open room + m_lastRoomConfig.writeEntry(Controller::instance().activeConnection()->userId(), spaceRoom->id()); +} + UriResolveResult RoomManager::visitUser(User *user, const QString &action) { if (action == "mention"_ls || action.isEmpty()) { diff --git a/src/roommanager.h b/src/roommanager.h index 8b52bd9cf..9c81da06c 100644 --- a/src/roommanager.h +++ b/src/roommanager.h @@ -127,6 +127,13 @@ public: */ Q_INVOKABLE void leaveRoom(NeoChatRoom *room); + /** + * @brief Enter the home page of the given space. + * + * This method will tell NeoChat to open the home page for the given space. + */ + Q_INVOKABLE void enterSpaceHome(NeoChatRoom *spaceRoom); + // Overrided methods from UriResolverBase /** * @brief Resolve a user URI. @@ -263,6 +270,26 @@ Q_SIGNALS: */ void replaceRoom(NeoChatRoom *room, const QString &event); + /** + * @brief Push a new space home page. + * + * Signal triggered when the main window pageStack should push a new page with + * the space home for the given space room. + * + * @param spaceRoom the space room to be shown on the new page. + */ + void pushSpaceHome(NeoChatRoom *spaceRoom); + + /** + * @brief Replace the existing space home. + * + * Signal triggered when the currently displayed room page should be changed + * to the space home for the given space room. + * + * @param spaceRoom the space room to be shown on the new page. + */ + void replaceSpaceHome(NeoChatRoom *spaceRoom); + /** * @brief Go to the specified event in the current room. */