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. */