From fc6ea0b7791a694e57abdab74f1a471739769631 Mon Sep 17 00:00:00 2001 From: Tobias Fella Date: Mon, 19 Feb 2024 20:09:43 +0000 Subject: [PATCH] Port RoomList to TreeView Use a tree model for the room list closes network/neochat#156 BUG: 456643 --- src/CMakeLists.txt | 6 + src/enums/neochatroomtype.h | 97 +++++++ src/models/roomlistmodel.cpp | 92 +------ src/models/roomlistmodel.h | 55 +--- src/models/roomtreemodel.cpp | 323 ++++++++++++++++++++++++ src/models/roomtreemodel.h | 94 +++++++ src/models/sortfilterroomlistmodel.cpp | 113 +-------- src/models/sortfilterroomlistmodel.h | 53 ---- src/models/sortfilterroomtreemodel.cpp | 161 ++++++++++++ src/models/sortfilterroomtreemodel.h | 111 ++++++++ src/neochatconnection.cpp | 4 +- src/neochatroom.cpp | 2 +- src/neochatroom.h | 2 +- src/qml/ChooseRoomDialog.qml | 3 +- src/qml/ExploreComponent.qml | 5 +- src/qml/QuickSwitcher.qml | 3 +- src/qml/RoomDelegate.qml | 38 +-- src/qml/RoomListPage.qml | 337 ++++++++++--------------- src/qml/RoomPage.qml | 7 +- src/qml/RoomTreeSection.qml | 83 ++++++ src/runner.cpp | 8 + src/runner.h | 2 +- src/spacehierarchycache.cpp | 3 +- 23 files changed, 1052 insertions(+), 550 deletions(-) create mode 100644 src/enums/neochatroomtype.h create mode 100644 src/models/roomtreemodel.cpp create mode 100644 src/models/roomtreemodel.h create mode 100644 src/models/sortfilterroomtreemodel.cpp create mode 100644 src/models/sortfilterroomtreemodel.h create mode 100644 src/qml/RoomTreeSection.qml diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index f7b8115af..6b136861c 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -56,6 +56,8 @@ add_library(neochat STATIC notificationsmanager.h models/sortfilterroomlistmodel.cpp models/sortfilterroomlistmodel.h + models/roomtreemodel.cpp + models/roomtreemodel.h chatdocumenthandler.cpp chatdocumenthandler.h models/devicesmodel.cpp @@ -156,6 +158,9 @@ add_library(neochat STATIC enums/messagecomponenttype.h models/messagecontentmodel.cpp models/messagecontentmodel.h + enums/neochatroomtype.h + models/sortfilterroomtreemodel.cpp + models/sortfilterroomtreemodel.h ) qt_add_qml_module(neochat URI org.kde.neochat NO_PLUGIN @@ -315,6 +320,7 @@ qt_add_qml_module(neochat URI org.kde.neochat NO_PLUGIN qml/LinkPreviewComponent.qml qml/LoadComponent.qml qml/RecommendedSpaceDialog.qml + qml/RoomTreeSection.qml RESOURCES qml/confetti.png qml/glowdot.png diff --git a/src/enums/neochatroomtype.h b/src/enums/neochatroomtype.h new file mode 100644 index 000000000..0dc9aa31d --- /dev/null +++ b/src/enums/neochatroomtype.h @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: 2023 Tobias Fella +// SPDX-License-Identifier: LGPL-2.0-or-later + +#pragma once + +#include + +#include "neochatroom.h" +#include + +#include + +class NeoChatRoomType : public QObject +{ + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("") + +public: + /** + * @brief Defines the room list categories a room can be assigned. + */ + enum Types { + Search = 0, /**< So we can show a search delegate if needed, e.g. collapsed mode. */ + Invited, /**< The user has been invited to the room. */ + Favorite, /**< The room is set as a favourite. */ + Direct, /**< The room is a direct chat. */ + Normal, /**< The default category for a joined room. */ + Deprioritized, /**< The room is set as low priority. */ + Space, /**< The room is a space. */ + AddDirect, /**< So we can show the add friend delegate. */ + }; + Q_ENUM(Types); + + static NeoChatRoomType::Types typeForRoom(const NeoChatRoom *room) + { + if (room->isSpace()) { + return NeoChatRoomType::Space; + } + if (room->joinState() == Quotient::JoinState::Invite) { + return NeoChatRoomType::Invited; + } + if (room->isFavourite()) { + return NeoChatRoomType::Favorite; + } + if (room->isLowPriority()) { + return NeoChatRoomType::Deprioritized; + } + if (room->isDirectChat()) { + return NeoChatRoomType::Direct; + } + return NeoChatRoomType::Normal; + } + + static QString typeName(int category) + { + switch (category) { + case NeoChatRoomType::Invited: + return i18n("Invited"); + case NeoChatRoomType::Favorite: + return i18n("Favorite"); + case NeoChatRoomType::Direct: + return i18n("Friends"); + case NeoChatRoomType::Normal: + return i18n("Normal"); + case NeoChatRoomType::Deprioritized: + return i18n("Low priority"); + case NeoChatRoomType::Space: + return i18n("Spaces"); + case NeoChatRoomType::Search: + return i18n("Search"); + default: + return {}; + } + } + static QString typeIconName(int category) + { + switch (category) { + case NeoChatRoomType::Invited: + return QStringLiteral("user-invisible"); + case NeoChatRoomType::Favorite: + return QStringLiteral("favorite"); + case NeoChatRoomType::Direct: + return QStringLiteral("dialog-messages"); + case NeoChatRoomType::Normal: + return QStringLiteral("group"); + case NeoChatRoomType::Deprioritized: + return QStringLiteral("object-order-lower"); + case NeoChatRoomType::Space: + return QStringLiteral("group"); + case NeoChatRoomType::Search: + return QStringLiteral("search"); + default: + return QStringLiteral("tools-report-bug"); + } + } +}; diff --git a/src/models/roomlistmodel.cpp b/src/models/roomlistmodel.cpp index 4aef2b8f3..c06d64aff 100644 --- a/src/models/roomlistmodel.cpp +++ b/src/models/roomlistmodel.cpp @@ -28,11 +28,6 @@ Q_DECLARE_METATYPE(Quotient::JoinState) RoomListModel::RoomListModel(QObject *parent) : QAbstractListModel(parent) { - const auto collapsedSections = NeoChatConfig::collapsedSections(); - for (auto collapsedSection : collapsedSections) { - m_categoryVisibility[collapsedSection] = false; - } - connect(this, &RoomListModel::highlightCountChanged, this, [this]() { #if QT_VERSION < QT_VERSION_CHECK(6, 6, 0) #ifndef Q_OS_ANDROID @@ -298,7 +293,7 @@ QVariant RoomListModel::data(const QModelIndex &index, int role) const return room->topic(); } if (role == CategoryRole) { - return category(room); + return NeoChatRoomType::typeForRoom(room); } if (role == NotificationCountRole) { return room->notificationCount(); @@ -318,9 +313,6 @@ QVariant RoomListModel::data(const QModelIndex &index, int role) const if (role == CurrentRoomRole) { return QVariant::fromValue(room); } - if (role == CategoryVisibleRole) { - return m_categoryVisibility.value(data(index, CategoryRole).toInt(), true); - } if (role == SubtitleTextRole) { if (room->lastEvent() == nullptr || room->lastEventIsSpoiler()) { return QString(); @@ -374,7 +366,6 @@ QHash RoomListModel::roleNames() const roles[LastActiveTimeRole] = "lastActiveTime"; roles[JoinStateRole] = "joinState"; roles[CurrentRoomRole] = "currentRoom"; - roles[CategoryVisibleRole] = "categoryVisible"; roles[SubtitleTextRole] = "subtitleText"; roles[IsSpaceRole] = "isSpace"; roles[RoomIdRole] = "roomId"; @@ -383,87 +374,6 @@ QHash RoomListModel::roleNames() const return roles; } -NeoChatRoomType::Types RoomListModel::category(NeoChatRoom *room) -{ - if (room->isSpace()) { - return NeoChatRoomType::Space; - } - if (room->joinState() == JoinState::Invite) { - return NeoChatRoomType::Invited; - } - if (room->isFavourite()) { - return NeoChatRoomType::Favorite; - } - if (room->isLowPriority()) { - return NeoChatRoomType::Deprioritized; - } - if (room->isDirectChat()) { - return NeoChatRoomType::Direct; - } - return NeoChatRoomType::Normal; -} - -QString RoomListModel::categoryName(int category) -{ - switch (category) { - case NeoChatRoomType::Invited: - return i18n("Invited"); - case NeoChatRoomType::Favorite: - return i18n("Favorite"); - case NeoChatRoomType::Direct: - return i18n("Friends"); - case NeoChatRoomType::Normal: - return i18n("Normal"); - case NeoChatRoomType::Deprioritized: - return i18n("Low priority"); - case NeoChatRoomType::Space: - return i18n("Spaces"); - default: - return {}; - } -} - -QString RoomListModel::categoryIconName(int category) -{ - switch (category) { - case NeoChatRoomType::Invited: - return QStringLiteral("user-invisible"); - case NeoChatRoomType::Favorite: - return QStringLiteral("favorite"); - case NeoChatRoomType::Direct: - return QStringLiteral("dialog-messages"); - case NeoChatRoomType::Normal: - return QStringLiteral("group"); - case NeoChatRoomType::Deprioritized: - return QStringLiteral("object-order-lower"); - case NeoChatRoomType::Space: - return QStringLiteral("group"); - default: - return QStringLiteral("tools-report-bug"); - } -} - -void RoomListModel::setCategoryVisible(int category, bool visible) -{ - beginResetModel(); - auto collapsedSections = NeoChatConfig::collapsedSections(); - if (visible) { - collapsedSections.removeAll(category); - } else { - collapsedSections.push_back(category); - } - NeoChatConfig::setCollapsedSections(collapsedSections); - NeoChatConfig::self()->save(); - - m_categoryVisibility[category] = visible; - endResetModel(); -} - -bool RoomListModel::categoryVisible(int category) const -{ - return m_categoryVisibility.value(category, true); -} - NeoChatRoom *RoomListModel::roomByAliasOrId(const QString &aliasOrId) { for (const auto &room : std::as_const(m_rooms)) { diff --git a/src/models/roomlistmodel.h b/src/models/roomlistmodel.h index 763d07e6b..46bcea09d 100644 --- a/src/models/roomlistmodel.h +++ b/src/models/roomlistmodel.h @@ -6,6 +6,8 @@ #include #include +#include "enums/neochatroomtype.h" + class NeoChatRoom; namespace Quotient @@ -14,27 +16,6 @@ class Connection; class Room; } -class NeoChatRoomType : public QObject -{ - Q_OBJECT - QML_ELEMENT - QML_UNCREATABLE("") - -public: - /** - * @brief Defines the room list categories a room can be assigned. - */ - enum Types { - Invited = 1, /**< The user has been invited to the room. */ - Favorite, /**< The room is set as a favourite. */ - Direct, /**< The room is a direct chat. */ - Normal, /**< The default category for a joined room. */ - Deprioritized, /**< The room is set as low priority. */ - Space, /**< The room is a space. */ - }; - Q_ENUM(Types) -}; - /** * @class RoomListModel * @@ -70,7 +51,6 @@ public: LastActiveTimeRole, /**< The timestamp of the last event sent in the room. */ JoinStateRole, /**< The local user's join state in the room. */ CurrentRoomRole, /**< The room object for the room. */ - CategoryVisibleRole, /**< If the room's category is visible. */ SubtitleTextRole, /**< The text to show as the room subtitle. */ AvatarImageRole, /**< The room avatar as an image. */ RoomIdRole, /**< The room matrix ID. */ @@ -116,35 +96,6 @@ public: */ Q_INVOKABLE [[nodiscard]] NeoChatRoom *roomAt(int row) const; - /** - * @brief The category for the given room. - */ - static NeoChatRoomType::Types category(NeoChatRoom *room); - - /** - * @brief Return a string to represent the given room category. - */ - Q_INVOKABLE [[nodiscard]] static QString categoryName(int category); - - /** - * @brief Return a string with the name of the given room category icon. - */ - Q_INVOKABLE [[nodiscard]] static QString categoryIconName(int category); - - /** - * @brief Set whether a given category should be visible or not. - * - * @param category the NeoChatRoomType::Types value for the category (it's an - * int due to the pain of Q_INVOKABLES and cpp enums). - * @param visible true if the category should be visible, false if not. - */ - Q_INVOKABLE void setCategoryVisible(int category, bool visible); - - /** - * @brief Return whether a room category is set to be visible. - */ - Q_INVOKABLE [[nodiscard]] bool categoryVisible(int category) const; - /** * @brief Return the model row for the given room. */ @@ -170,8 +121,6 @@ private: Quotient::Connection *m_connection = nullptr; QList m_rooms; - QMap m_categoryVisibility; - int m_notificationCount = 0; int m_highlightCount = 0; QString m_activeSpaceId; diff --git a/src/models/roomtreemodel.cpp b/src/models/roomtreemodel.cpp new file mode 100644 index 000000000..c8fb7d3e0 --- /dev/null +++ b/src/models/roomtreemodel.cpp @@ -0,0 +1,323 @@ +// SPDX-FileCopyrightText: 2023 Tobias Fella +// SPDX-License-Identifier: LGPL-2.0-or-later + +#include "roomtreemodel.h" + +#include +#include + +#include "eventhandler.h" +#include "neochatconnection.h" +#include "neochatroomtype.h" +#include "spacehierarchycache.h" + +using namespace Quotient; + +RoomTreeModel::RoomTreeModel(QObject *parent) + : QAbstractItemModel(parent) +{ + initializeCategories(); +} + +void RoomTreeModel::initializeCategories() +{ + for (const auto &key : m_rooms.keys()) { + for (const auto &room : m_rooms[key]) { + room->disconnect(this); + } + } + m_rooms.clear(); + for (int i = 0; i < 8; i++) { + m_rooms[NeoChatRoomType::Types(i)] = {}; + } +} + +void RoomTreeModel::setConnection(NeoChatConnection *connection) +{ + if (m_connection == connection) { + return; + } + disconnect(m_connection.get(), nullptr, this, nullptr); + m_connection = connection; + beginResetModel(); + initializeCategories(); + endResetModel(); + connect(connection, &Connection::newRoom, this, &RoomTreeModel::newRoom); + connect(connection, &Connection::leftRoom, this, &RoomTreeModel::leftRoom); + + for (const auto &room : m_connection->allRooms()) { + newRoom(dynamic_cast(room)); + } + Q_EMIT connectionChanged(); +} + +void RoomTreeModel::newRoom(Room *r) +{ + const auto room = dynamic_cast(r); + const auto type = NeoChatRoomType::typeForRoom(room); + beginInsertRows(index(type, 0), m_rooms[type].size(), m_rooms[type].size()); + m_rooms[type].append(room); + connectRoomSignals(room); + endInsertRows(); +} + +void RoomTreeModel::leftRoom(Room *r) +{ + const auto room = dynamic_cast(r); + const auto type = NeoChatRoomType::typeForRoom(room); + auto row = m_rooms[type].indexOf(room); + if (row == -1) { + return; + } + beginRemoveRows(index(type, 0), row, row); + m_rooms[type][row]->disconnect(this); + m_rooms[type].removeAt(row); + endRemoveRows(); +} + +void RoomTreeModel::moveRoom(Quotient::Room *room) +{ + // We can't assume the type as it has changed so currently the return of + // NeoChatRoomType::typeForRoom doesn't match it's current location. So find the room. + NeoChatRoomType::Types oldType; + int oldRow = -1; + for (const auto &key : m_rooms.keys()) { + if (m_rooms[key].contains(room)) { + oldType = key; + oldRow = m_rooms[key].indexOf(room); + } + } + + if (oldRow == -1) { + return; + } + const auto newType = NeoChatRoomType::typeForRoom(dynamic_cast(room)); + if (newType == oldType) { + return; + } + + const auto oldParent = index(oldType, 0, {}); + const auto newParent = index(newType, 0, {}); + // HACK: We're doing this as a remove then insert because moving doesn't work + // properly with DelegateChooser for whatever reason. + beginRemoveRows(oldParent, oldRow, oldRow); + m_rooms[oldType].removeAt(oldRow); + endRemoveRows(); + beginInsertRows(newParent, m_rooms[newType].size(), m_rooms[newType].size()); + m_rooms[newType].append(dynamic_cast(room)); + endInsertRows(); +} + +void RoomTreeModel::connectRoomSignals(NeoChatRoom *room) +{ + connect(room, &Room::displaynameChanged, this, [this, room] { + refreshRoomRoles(room, {DisplayNameRole}); + }); + connect(room, &Room::unreadStatsChanged, this, [this, room] { + refreshRoomRoles(room, {NotificationCountRole, HighlightCountRole}); + }); + connect(room, &Room::avatarChanged, this, [this, room] { + refreshRoomRoles(room, {AvatarRole}); + }); + connect(room, &Room::tagsChanged, this, [this, room] { + moveRoom(room); + }); + connect(room, &Room::joinStateChanged, this, [this, room] { + refreshRoomRoles(room); + }); + connect(room, &Room::addedMessages, this, [this, room] { + refreshRoomRoles(room, {SubtitleTextRole, LastActiveTimeRole}); + }); + connect(room, &Room::pendingEventMerged, this, [this, room] { + refreshRoomRoles(room, {SubtitleTextRole}); + }); +} + +void RoomTreeModel::refreshRoomRoles(NeoChatRoom *room, const QList &roles) +{ + const auto roomType = NeoChatRoomType::typeForRoom(room); + const auto it = std::find(m_rooms[roomType].begin(), m_rooms[roomType].end(), room); + if (it == m_rooms[roomType].end()) { + qCritical() << "Room" << room->id() << "not found in the room list"; + return; + } + const auto parentIndex = index(roomType, 0, {}); + const auto idx = index(it - m_rooms[roomType].begin(), 0, parentIndex); + Q_EMIT dataChanged(idx, idx, roles); +} + +NeoChatConnection *RoomTreeModel::connection() const +{ + return m_connection; +} + +int RoomTreeModel::columnCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent) + return 1; +} + +int RoomTreeModel::rowCount(const QModelIndex &parent) const +{ + if (!parent.isValid()) { + return m_rooms.keys().size(); + } + if (!parent.parent().isValid()) { + return m_rooms.values()[parent.row()].size(); + } + return 0; +} + +QModelIndex RoomTreeModel::parent(const QModelIndex &index) const +{ + if (!index.internalPointer()) { + return {}; + } + return this->index(NeoChatRoomType::typeForRoom(static_cast(index.internalPointer())), 0, QModelIndex()); +} + +QModelIndex RoomTreeModel::index(int row, int column, const QModelIndex &parent) const +{ + if (!parent.isValid()) { + return createIndex(row, column, nullptr); + } + if (row >= rowCount(parent)) { + return {}; + } + return createIndex(row, column, m_rooms[NeoChatRoomType::Types(parent.row())][row]); +} + +QHash RoomTreeModel::roleNames() const +{ + QHash roles; + roles[DisplayNameRole] = "displayName"; + roles[AvatarRole] = "avatar"; + roles[CanonicalAliasRole] = "canonicalAlias"; + roles[TopicRole] = "topic"; + roles[CategoryRole] = "category"; + roles[NotificationCountRole] = "notificationCount"; + roles[HighlightCountRole] = "highlightCount"; + roles[LastActiveTimeRole] = "lastActiveTime"; + roles[JoinStateRole] = "joinState"; + roles[CurrentRoomRole] = "currentRoom"; + roles[SubtitleTextRole] = "subtitleText"; + roles[IsSpaceRole] = "isSpace"; + roles[RoomIdRole] = "roomId"; + roles[IsChildSpaceRole] = "isChildSpace"; + roles[IsDirectChat] = "isDirectChat"; + roles[DelegateTypeRole] = "delegateType"; + roles[IconRole] = "icon"; + return roles; +} + +// TODO room type changes +QVariant RoomTreeModel::data(const QModelIndex &index, int role) const +{ + if (!index.isValid()) { + return QVariant(); + } + + if (!index.parent().isValid()) { + if (role == DisplayNameRole) { + return NeoChatRoomType::typeName(index.row()); + } + if (role == DelegateTypeRole) { + if (index.row() == NeoChatRoomType::Search) { + return QStringLiteral("search"); + } + if (index.row() == NeoChatRoomType::AddDirect) { + return QStringLiteral("addDirect"); + } + return QStringLiteral("section"); + } + if (role == IconRole) { + return NeoChatRoomType::typeIconName(index.row()); + } + if (role == CategoryRole) { + return index.row(); + } + return {}; + } + const auto room = m_rooms.values()[index.parent().row()][index.row()].get(); + Q_ASSERT(room); + + if (role == DisplayNameRole) { + return room->displayName(); + } + if (role == AvatarRole) { + return room->avatarMediaId(); + } + if (role == CanonicalAliasRole) { + return room->canonicalAlias(); + } + if (role == TopicRole) { + return room->topic(); + } + if (role == CategoryRole) { + return NeoChatRoomType::typeForRoom(room); + } + if (role == NotificationCountRole) { + return room->notificationCount(); + } + if (role == HighlightCountRole) { + return room->highlightCount(); + } + if (role == LastActiveTimeRole) { + return room->lastActiveTime(); + } + if (role == JoinStateRole) { + if (!room->successorId().isEmpty()) { + return QStringLiteral("upgraded"); + } + return QVariant::fromValue(room->joinState()); + } + if (role == CurrentRoomRole) { + return QVariant::fromValue(room); + } + if (role == SubtitleTextRole) { + if (room->lastEvent() == nullptr || room->lastEventIsSpoiler()) { + return QString(); + } + EventHandler eventHandler(room, room->lastEvent()); + return eventHandler.subtitleText(); + } + if (role == AvatarImageRole) { + return room->avatar(128); + } + if (role == RoomIdRole) { + return room->id(); + } + if (role == IsSpaceRole) { + return room->isSpace(); + } + if (role == IsChildSpaceRole) { + return SpaceHierarchyCache::instance().isChild(room->id()); + } + if (role == ReplacementIdRole) { + return room->successorId(); + } + if (role == IsDirectChat) { + return room->isDirectChat(); + } + if (role == DelegateTypeRole) { + return QStringLiteral("normal"); + } + + return {}; +} + +QModelIndex RoomTreeModel::indexForRoom(NeoChatRoom *room) const +{ + if (room == nullptr) { + return {}; + } + + const auto type = NeoChatRoomType::typeForRoom(room); + auto row = m_rooms[type].indexOf(room); + if (row >= 0) { + return index(row, 0, index(type, 0)); + } + return {}; +} + +#include "moc_roomtreemodel.cpp" diff --git a/src/models/roomtreemodel.h b/src/models/roomtreemodel.h new file mode 100644 index 000000000..52ac23d2e --- /dev/null +++ b/src/models/roomtreemodel.h @@ -0,0 +1,94 @@ +// SPDX-FileCopyrightText: 2023 Tobias Fella +// SPDX-License-Identifier: LGPL-2.0-or-later + +#pragma once + +#include +#include + +#include "enums/neochatroomtype.h" + +namespace Quotient +{ +class Room; +} + +class NeoChatConnection; +class NeoChatRoom; + +class RoomTreeModel : public QAbstractItemModel +{ + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(NeoChatConnection *connection READ connection WRITE setConnection NOTIFY connectionChanged) + +public: + /** + * @brief Defines the model roles. + */ + enum EventRoles { + DisplayNameRole = Qt::DisplayRole, /**< The display name of the room. */ + AvatarRole, /**< The source URL for the room's avatar. */ + CanonicalAliasRole, /**< The room canonical alias. */ + TopicRole, /**< The room topic. */ + CategoryRole, /**< The room category, e.g favourite. */ + NotificationCountRole, /**< The number of notifications in the room. */ + HighlightCountRole, /**< The number of highlighted messages in the room. */ + LastActiveTimeRole, /**< The timestamp of the last event sent in the room. */ + JoinStateRole, /**< The local user's join state in the room. */ + CurrentRoomRole, /**< The room object for the room. */ + SubtitleTextRole, /**< The text to show as the room subtitle. */ + AvatarImageRole, /**< The room avatar as an image. */ + RoomIdRole, /**< The room matrix ID. */ + IsSpaceRole, /**< Whether the room is a space. */ + IsChildSpaceRole, /**< Whether this space is a child of a different space. */ + ReplacementIdRole, /**< The room id of the room replacing this one, if any. */ + IsDirectChat, /**< Whether this room is a direct chat. */ + DelegateTypeRole, + IconRole, + }; + Q_ENUM(EventRoles) + explicit RoomTreeModel(QObject *parent = nullptr); + + void setConnection(NeoChatConnection *connection); + NeoChatConnection *connection() const; + + /** + * @brief Get the given role value at the given index. + * + * @sa QAbstractItemModel::data + */ + [[nodiscard]] QVariant data(const QModelIndex &index, int role = Qt::DisplayRole) const override; + + /** + * @brief Returns a mapping from Role enum values to role names. + * + * @sa EventRoles, QAbstractItemModel::roleNames() + */ + [[nodiscard]] QHash roleNames() const override; + + QModelIndex parent(const QModelIndex &index) const override; + QModelIndex index(int row, int column, const QModelIndex &parent = QModelIndex()) const override; + + int columnCount(const QModelIndex &parent = QModelIndex()) const override; + int rowCount(const QModelIndex &parent = QModelIndex()) const override; + + Q_INVOKABLE QModelIndex indexForRoom(NeoChatRoom *room) const; + +Q_SIGNALS: + void connectionChanged(); + +private: + QPointer m_connection = nullptr; + QMap>> m_rooms; + + void initializeCategories(); + void connectRoomSignals(NeoChatRoom *room); + + void newRoom(Quotient::Room *room); + void leftRoom(Quotient::Room *room); + void moveRoom(Quotient::Room *room); + + void refreshRoomRoles(NeoChatRoom *room, const QList &roles = {}); +}; diff --git a/src/models/sortfilterroomlistmodel.cpp b/src/models/sortfilterroomlistmodel.cpp index 97d6df43a..60efbbebe 100644 --- a/src/models/sortfilterroomlistmodel.cpp +++ b/src/models/sortfilterroomlistmodel.cpp @@ -3,9 +3,7 @@ #include "sortfilterroomlistmodel.h" -#include "neochatconnection.h" #include "roomlistmodel.h" -#include "spacehierarchycache.h" SortFilterRoomListModel::SortFilterRoomListModel(QObject *parent) : QSortFilterProxyModel(parent) @@ -21,53 +19,6 @@ SortFilterRoomListModel::SortFilterRoomListModel(QObject *parent) }); } -void SortFilterRoomListModel::setRoomSortOrder(SortFilterRoomListModel::RoomSortOrder sortOrder) -{ - m_sortOrder = sortOrder; - Q_EMIT roomSortOrderChanged(); - if (sortOrder == SortFilterRoomListModel::Alphabetical) { - setSortRole(RoomListModel::DisplayNameRole); - } else if (sortOrder == SortFilterRoomListModel::LastActivity) { - setSortRole(RoomListModel::LastActiveTimeRole); - } - invalidate(); -} - -SortFilterRoomListModel::RoomSortOrder SortFilterRoomListModel::roomSortOrder() const -{ - return m_sortOrder; -} - -bool SortFilterRoomListModel::lessThan(const QModelIndex &source_left, const QModelIndex &source_right) const -{ - if (m_sortOrder == SortFilterRoomListModel::LastActivity) { - // display favorite rooms always on top - const auto categoryLeft = static_cast(sourceModel()->data(source_left, RoomListModel::CategoryRole).toInt()); - const auto categoryRight = static_cast(sourceModel()->data(source_right, RoomListModel::CategoryRole).toInt()); - - if (categoryLeft == NeoChatRoomType::Types::Favorite && categoryRight == NeoChatRoomType::Types::Favorite) { - return sourceModel()->data(source_left, RoomListModel::LastActiveTimeRole).toDateTime() - > sourceModel()->data(source_right, RoomListModel::LastActiveTimeRole).toDateTime(); - } - if (categoryLeft == NeoChatRoomType::Types::Favorite) { - return true; - } else if (categoryRight == NeoChatRoomType::Types::Favorite) { - return false; - } - - return sourceModel()->data(source_left, RoomListModel::LastActiveTimeRole).toDateTime() - > sourceModel()->data(source_right, RoomListModel::LastActiveTimeRole).toDateTime(); - } - if (m_sortOrder != SortFilterRoomListModel::Categories) { - return QSortFilterProxyModel::lessThan(source_left, source_right); - } - if (sourceModel()->data(source_left, RoomListModel::CategoryRole) != sourceModel()->data(source_right, RoomListModel::CategoryRole)) { - return sourceModel()->data(source_left, RoomListModel::CategoryRole).toInt() < sourceModel()->data(source_right, RoomListModel::CategoryRole).toInt(); - } - return sourceModel()->data(source_left, RoomListModel::LastActiveTimeRole).toDateTime() - > sourceModel()->data(source_right, RoomListModel::LastActiveTimeRole).toDateTime(); -} - void SortFilterRoomListModel::setFilterText(const QString &text) { m_filterText = text; @@ -81,69 +32,15 @@ QString SortFilterRoomListModel::filterText() const bool SortFilterRoomListModel::filterAcceptsRow(int source_row, const QModelIndex &source_parent) const { - Q_UNUSED(source_parent); + QModelIndex index = sourceModel()->index(source_row, 0, source_parent); - bool acceptRoom = - sourceModel()->data(sourceModel()->index(source_row, 0), RoomListModel::DisplayNameRole).toString().contains(m_filterText, Qt::CaseInsensitive) - && sourceModel()->data(sourceModel()->index(source_row, 0), RoomListModel::IsSpaceRole).toBool() == false; - - bool isDirectChat = sourceModel()->data(sourceModel()->index(source_row, 0), RoomListModel::IsDirectChat).toBool(); - // In `show direct chats` mode we only care about whether or not it's a direct chat or if the filter string matches.' - if (m_mode == DirectChats) { - return isDirectChat && acceptRoom; - } - - // When not in `show direct chats` mode, filter them out. - if (isDirectChat && m_mode == Rooms) { + if (sourceModel()->data(index, RoomListModel::JoinStateRole).toString() == QStringLiteral("upgraded") + && dynamic_cast(sourceModel())->connection()->room(sourceModel()->data(index, RoomListModel::ReplacementIdRole).toString())) { return false; } - if (sourceModel()->data(sourceModel()->index(source_row, 0), RoomListModel::JoinStateRole).toString() == QStringLiteral("upgraded") - && dynamic_cast(sourceModel()) - ->connection() - ->room(sourceModel()->data(sourceModel()->index(source_row, 0), RoomListModel::ReplacementIdRole).toString())) { - return false; - } - - if (m_activeSpaceId.isEmpty()) { - if (!SpaceHierarchyCache::instance().isChild(sourceModel()->data(sourceModel()->index(source_row, 0), RoomListModel::RoomIdRole).toString())) { - return acceptRoom; - } - return false; - } else { - const auto &rooms = SpaceHierarchyCache::instance().getRoomListForSpace(m_activeSpaceId, false); - return std::find(rooms.begin(), rooms.end(), sourceModel()->data(sourceModel()->index(source_row, 0), RoomListModel::RoomIdRole).toString()) - != rooms.end() - && acceptRoom; - } -} - -QString SortFilterRoomListModel::activeSpaceId() const -{ - return m_activeSpaceId; -} - -void SortFilterRoomListModel::setActiveSpaceId(const QString &spaceId) -{ - m_activeSpaceId = spaceId; - Q_EMIT activeSpaceIdChanged(); - invalidate(); -} - -SortFilterRoomListModel::Mode SortFilterRoomListModel::mode() const -{ - return m_mode; -} - -void SortFilterRoomListModel::setMode(SortFilterRoomListModel::Mode mode) -{ - if (m_mode == mode) { - return; - } - - m_mode = mode; - Q_EMIT modeChanged(); - invalidate(); + return sourceModel()->data(index, RoomListModel::DisplayNameRole).toString().contains(m_filterText, Qt::CaseInsensitive) + && sourceModel()->data(index, RoomListModel::IsSpaceRole).toBool() == false; } #include "moc_sortfilterroomlistmodel.cpp" diff --git a/src/models/sortfilterroomlistmodel.h b/src/models/sortfilterroomlistmodel.h index 534ae6743..2289b4c70 100644 --- a/src/models/sortfilterroomlistmodel.h +++ b/src/models/sortfilterroomlistmodel.h @@ -30,65 +30,18 @@ class SortFilterRoomListModel : public QSortFilterProxyModel Q_OBJECT QML_ELEMENT - /** - * @brief The order by which the rooms will be sorted. - * - * @sa RoomSortOrder - */ - Q_PROPERTY(RoomSortOrder roomSortOrder READ roomSortOrder WRITE setRoomSortOrder NOTIFY roomSortOrderChanged) - /** * @brief The text to use to filter room names. */ Q_PROPERTY(QString filterText READ filterText READ filterText WRITE setFilterText NOTIFY filterTextChanged) - /** - * @brief Set the ID of the space to show rooms for. - */ - Q_PROPERTY(QString activeSpaceId READ activeSpaceId WRITE setActiveSpaceId NOTIFY activeSpaceIdChanged) - - /** - * @brief Whether only direct chats should be shown. - */ - Q_PROPERTY(Mode mode READ mode WRITE setMode NOTIFY modeChanged) - public: - enum RoomSortOrder { - Alphabetical, - LastActivity, - Categories, - }; - Q_ENUM(RoomSortOrder) - - enum Mode { - Rooms, - DirectChats, - All, - }; - Q_ENUM(Mode) - explicit SortFilterRoomListModel(QObject *parent = nullptr); - void setRoomSortOrder(RoomSortOrder sortOrder); - [[nodiscard]] RoomSortOrder roomSortOrder() const; - void setFilterText(const QString &text); [[nodiscard]] QString filterText() const; - QString activeSpaceId() const; - void setActiveSpaceId(const QString &spaceId); - - Mode mode() const; - void setMode(Mode mode); - protected: - /** - * @brief Returns true if the value of source_left is less than source_right. - * - * @sa QSortFilterProxyModel::lessThan - */ - [[nodiscard]] bool lessThan(const QModelIndex &source_left, const QModelIndex &source_right) const override; - /** * @brief Whether a row should be shown out or not. * @@ -97,14 +50,8 @@ protected: [[nodiscard]] bool filterAcceptsRow(int source_row, const QModelIndex &source_parent) const override; Q_SIGNALS: - void roomSortOrderChanged(); void filterTextChanged(); - void activeSpaceIdChanged(); - void modeChanged(); private: - RoomSortOrder m_sortOrder = Categories; - Mode m_mode = All; QString m_filterText; - QString m_activeSpaceId; }; diff --git a/src/models/sortfilterroomtreemodel.cpp b/src/models/sortfilterroomtreemodel.cpp new file mode 100644 index 000000000..b586583c9 --- /dev/null +++ b/src/models/sortfilterroomtreemodel.cpp @@ -0,0 +1,161 @@ +// SPDX-FileCopyrightText: 2020 Tobias Fella +// SPDX-FileCopyrightText: 2024 James Graham +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "sortfilterroomtreemodel.h" + +#include "neochatconfig.h" +#include "neochatconnection.h" +#include "neochatroomtype.h" +#include "roomtreemodel.h" +#include "spacehierarchycache.h" + +SortFilterRoomTreeModel::SortFilterRoomTreeModel(QObject *parent) + : QSortFilterProxyModel(parent) +{ + setRecursiveFilteringEnabled(true); + sort(0); + invalidateFilter(); + connect(this, &SortFilterRoomTreeModel::filterTextChanged, this, &SortFilterRoomTreeModel::invalidateFilter); + connect(this, &SortFilterRoomTreeModel::sourceModelChanged, this, [this]() { + sourceModel()->disconnect(this); + connect(sourceModel(), &QAbstractItemModel::rowsInserted, this, &SortFilterRoomTreeModel::invalidateFilter); + connect(sourceModel(), &QAbstractItemModel::rowsRemoved, this, &SortFilterRoomTreeModel::invalidateFilter); + }); + + connect(NeoChatConfig::self(), &NeoChatConfig::CollapsedChanged, this, &SortFilterRoomTreeModel::invalidateFilter); +} + +void SortFilterRoomTreeModel::setRoomSortOrder(SortFilterRoomTreeModel::RoomSortOrder sortOrder) +{ + m_sortOrder = sortOrder; + Q_EMIT roomSortOrderChanged(); + if (sortOrder == SortFilterRoomTreeModel::Alphabetical) { + setSortRole(RoomTreeModel::DisplayNameRole); + } else if (sortOrder == SortFilterRoomTreeModel::LastActivity) { + setSortRole(RoomTreeModel::LastActiveTimeRole); + } + invalidate(); +} + +SortFilterRoomTreeModel::RoomSortOrder SortFilterRoomTreeModel::roomSortOrder() const +{ + return m_sortOrder; +} + +bool SortFilterRoomTreeModel::lessThan(const QModelIndex &source_left, const QModelIndex &source_right) const +{ + if (m_sortOrder == SortFilterRoomTreeModel::LastActivity) { + // display favorite rooms always on top + const auto categoryLeft = static_cast(sourceModel()->data(source_left, RoomTreeModel::CategoryRole).toInt()); + const auto categoryRight = static_cast(sourceModel()->data(source_right, RoomTreeModel::CategoryRole).toInt()); + + if (categoryLeft == NeoChatRoomType::Types::Favorite && categoryRight == NeoChatRoomType::Types::Favorite) { + return sourceModel()->data(source_left, RoomTreeModel::LastActiveTimeRole).toDateTime() + > sourceModel()->data(source_right, RoomTreeModel::LastActiveTimeRole).toDateTime(); + } + if (categoryLeft == NeoChatRoomType::Types::Favorite) { + return true; + } else if (categoryRight == NeoChatRoomType::Types::Favorite) { + return false; + } + + return sourceModel()->data(source_left, RoomTreeModel::LastActiveTimeRole).toDateTime() + > sourceModel()->data(source_right, RoomTreeModel::LastActiveTimeRole).toDateTime(); + } + if (m_sortOrder != SortFilterRoomTreeModel::Categories) { + return QSortFilterProxyModel::lessThan(source_left, source_right); + } + if (sourceModel()->data(source_left, RoomTreeModel::CategoryRole) != sourceModel()->data(source_right, RoomTreeModel::CategoryRole)) { + return sourceModel()->data(source_left, RoomTreeModel::CategoryRole).toInt() < sourceModel()->data(source_right, RoomTreeModel::CategoryRole).toInt(); + } + return sourceModel()->data(source_left, RoomTreeModel::LastActiveTimeRole).toDateTime() + > sourceModel()->data(source_right, RoomTreeModel::LastActiveTimeRole).toDateTime(); +} + +void SortFilterRoomTreeModel::setFilterText(const QString &text) +{ + m_filterText = text; + Q_EMIT filterTextChanged(); +} + +QString SortFilterRoomTreeModel::filterText() const +{ + return m_filterText; +} + +bool SortFilterRoomTreeModel::filterAcceptsRow(int source_row, const QModelIndex &source_parent) const +{ + if (!source_parent.isValid()) { + if (sourceModel()->data(sourceModel()->index(source_row, 0), RoomTreeModel::CategoryRole).toInt() == NeoChatRoomType::Search + && NeoChatConfig::collapsed()) { + return true; + } + if (sourceModel()->data(sourceModel()->index(source_row, 0), RoomTreeModel::CategoryRole).toInt() == NeoChatRoomType::AddDirect + && m_mode == DirectChats) { + return true; + } + return false; + } + + QModelIndex index = sourceModel()->index(source_row, 0, source_parent); + + bool acceptRoom = sourceModel()->data(index, RoomTreeModel::DisplayNameRole).toString().contains(m_filterText, Qt::CaseInsensitive) + && sourceModel()->data(index, RoomTreeModel::IsSpaceRole).toBool() == false; + + bool isDirectChat = sourceModel()->data(index, RoomTreeModel::IsDirectChat).toBool(); + // In `show direct chats` mode we only care about whether or not it's a direct chat or if the filter string matches.' + if (m_mode == DirectChats) { + return isDirectChat && acceptRoom; + } + + // When not in `show direct chats` mode, filter them out. + if (isDirectChat && m_mode == Rooms) { + return false; + } + + if (sourceModel()->data(index, RoomTreeModel::JoinStateRole).toString() == QStringLiteral("upgraded") + && dynamic_cast(sourceModel())->connection()->room(sourceModel()->data(index, RoomTreeModel::ReplacementIdRole).toString())) { + return false; + } + + if (m_activeSpaceId.isEmpty()) { + if (!SpaceHierarchyCache::instance().isChild(sourceModel()->data(index, RoomTreeModel::RoomIdRole).toString())) { + return acceptRoom; + } + return false; + } else { + const auto &rooms = SpaceHierarchyCache::instance().getRoomListForSpace(m_activeSpaceId, false); + return std::find(rooms.begin(), rooms.end(), sourceModel()->data(index, RoomTreeModel::RoomIdRole).toString()) != rooms.end() && acceptRoom; + } +} + +QString SortFilterRoomTreeModel::activeSpaceId() const +{ + return m_activeSpaceId; +} + +void SortFilterRoomTreeModel::setActiveSpaceId(const QString &spaceId) +{ + m_activeSpaceId = spaceId; + Q_EMIT activeSpaceIdChanged(); + invalidate(); +} + +SortFilterRoomTreeModel::Mode SortFilterRoomTreeModel::mode() const +{ + return m_mode; +} + +void SortFilterRoomTreeModel::setMode(SortFilterRoomTreeModel::Mode mode) +{ + if (m_mode == mode) { + return; + } + + m_mode = mode; + Q_EMIT modeChanged(); + invalidate(); +} + +#include "moc_sortfilterroomtreemodel.cpp" diff --git a/src/models/sortfilterroomtreemodel.h b/src/models/sortfilterroomtreemodel.h new file mode 100644 index 000000000..19bf5d613 --- /dev/null +++ b/src/models/sortfilterroomtreemodel.h @@ -0,0 +1,111 @@ +// SPDX-FileCopyrightText: 2020 Tobias Fella +// SPDX-FileCopyrightText: 2024 James Graham +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include + +/** + * @class SortFilterRoomTreeModel + * + * This model sorts and filters the room list. + * + * There are numerous room sort orders available: + * - Categories - sort rooms by their NeoChatRoomType and then by last activty within + * each category. + * - LastActivity - sort rooms by the last active time in the room. + * - Alphabetical - sort the rooms alphabetically by room name. + * + * The model can be given a filter string that will only show rooms who's name includes + * the text. + * + * The model can also be given an active space ID and will only show rooms within + * that space. + * + * All space rooms and upgraded rooms will also be filtered out. + */ +class SortFilterRoomTreeModel : public QSortFilterProxyModel +{ + Q_OBJECT + QML_ELEMENT + + /** + * @brief The order by which the rooms will be sorted. + * + * @sa RoomSortOrder + */ + Q_PROPERTY(RoomSortOrder roomSortOrder READ roomSortOrder WRITE setRoomSortOrder NOTIFY roomSortOrderChanged) + + /** + * @brief The text to use to filter room names. + */ + Q_PROPERTY(QString filterText READ filterText READ filterText WRITE setFilterText NOTIFY filterTextChanged) + + /** + * @brief Set the ID of the space to show rooms for. + */ + Q_PROPERTY(QString activeSpaceId READ activeSpaceId WRITE setActiveSpaceId NOTIFY activeSpaceIdChanged) + + /** + * @brief Whether only direct chats should be shown. + */ + Q_PROPERTY(Mode mode READ mode WRITE setMode NOTIFY modeChanged) + +public: + enum RoomSortOrder { + Alphabetical, + LastActivity, + Categories, + }; + Q_ENUM(RoomSortOrder) + + enum Mode { + Rooms, + DirectChats, + All, + }; + Q_ENUM(Mode) + + explicit SortFilterRoomTreeModel(QObject *parent = nullptr); + + void setRoomSortOrder(RoomSortOrder sortOrder); + [[nodiscard]] RoomSortOrder roomSortOrder() const; + + void setFilterText(const QString &text); + [[nodiscard]] QString filterText() const; + + QString activeSpaceId() const; + void setActiveSpaceId(const QString &spaceId); + + Mode mode() const; + void setMode(Mode mode); + +protected: + /** + * @brief Returns true if the value of source_left is less than source_right. + * + * @sa QSortFilterProxyModel::lessThan + */ + [[nodiscard]] bool lessThan(const QModelIndex &source_left, const QModelIndex &source_right) const override; + + /** + * @brief Whether a row should be shown out or not. + * + * @sa QSortFilterProxyModel::filterAcceptsRow + */ + [[nodiscard]] bool filterAcceptsRow(int source_row, const QModelIndex &source_parent) const override; + +Q_SIGNALS: + void roomSortOrderChanged(); + void filterTextChanged(); + void activeSpaceIdChanged(); + void modeChanged(); + +private: + RoomSortOrder m_sortOrder = Categories; + Mode m_mode = All; + QString m_filterText; + QString m_activeSpaceId; +}; diff --git a/src/neochatconnection.cpp b/src/neochatconnection.cpp index e014e4818..6207f532d 100644 --- a/src/neochatconnection.cpp +++ b/src/neochatconnection.cpp @@ -106,7 +106,7 @@ void NeoChatConnection::connectSignals() for (const auto room : allRooms()) { connect(room, &NeoChatRoom::unreadStatsChanged, this, [this, room]() { if (room != nullptr) { - auto category = RoomListModel::category(static_cast(room)); + auto category = NeoChatRoomType::typeForRoom(static_cast(room)); if (!SpaceHierarchyCache::instance().isChild(room->id()) && (category == NeoChatRoomType::Normal || category == NeoChatRoomType::Favorite) && room->successorId().isEmpty()) { Q_EMIT homeNotificationsChanged(); @@ -342,7 +342,7 @@ qsizetype NeoChatConnection::homeNotifications() const QStringList added; const auto &spaceHierarchyCache = SpaceHierarchyCache::instance(); for (const auto &room : allRooms()) { - auto category = RoomListModel::category(static_cast(room)); + auto category = NeoChatRoomType::typeForRoom(static_cast(room)); if (!added.contains(room->id()) && room->joinState() == JoinState::Join && !room->isDirectChat() && !spaceHierarchyCache.isChild(room->id()) && room->successorId().isEmpty()) { switch (category) { diff --git a/src/neochatroom.cpp b/src/neochatroom.cpp index 2e0b7a4ef..4a95c08db 100644 --- a/src/neochatroom.cpp +++ b/src/neochatroom.cpp @@ -1279,7 +1279,7 @@ void NeoChatRoom::removeParent(const QString &parentId) } } -bool NeoChatRoom::isSpace() +bool NeoChatRoom::isSpace() const { const auto creationEvent = this->creation(); if (!creationEvent) { diff --git a/src/neochatroom.h b/src/neochatroom.h index afba805c2..989c60ac4 100644 --- a/src/neochatroom.h +++ b/src/neochatroom.h @@ -531,7 +531,7 @@ public: */ Q_INVOKABLE void removeParent(const QString &parentId); - [[nodiscard]] bool isSpace(); + [[nodiscard]] bool isSpace() const; qsizetype childrenNotificationCount(); diff --git a/src/qml/ChooseRoomDialog.qml b/src/qml/ChooseRoomDialog.qml index f3b85a62d..3aa998bc0 100644 --- a/src/qml/ChooseRoomDialog.qml +++ b/src/qml/ChooseRoomDialog.qml @@ -30,8 +30,7 @@ Kirigami.ScrollablePage { } delegate: RoomDelegate { id: roomDelegate - filterText: "" - onSelected: { + onClicked: { root.chosen(roomDelegate.currentRoom.id); } connection: root.connection diff --git a/src/qml/ExploreComponent.qml b/src/qml/ExploreComponent.qml index 38418d529..7e4a70c9e 100644 --- a/src/qml/ExploreComponent.qml +++ b/src/qml/ExploreComponent.qml @@ -17,6 +17,8 @@ RowLayout { property bool collapsed: false required property NeoChatConnection connection + property alias roomSearchFieldFocussed: roomSearchField.activeFocus + property Kirigami.Action exploreAction: Kirigami.Action { text: i18n("Explore rooms") icon.name: "compass" @@ -72,13 +74,14 @@ RowLayout { signal textChanged(string newText) Kirigami.SearchField { + id: roomSearchField Layout.topMargin: Kirigami.Units.smallSpacing Layout.bottomMargin: Kirigami.Units.smallSpacing Layout.fillWidth: true Layout.preferredWidth: root.desiredWidth ? root.desiredWidth - menuButton.width - root.spacing : -1 visible: !root.collapsed onTextChanged: root.textChanged(text) - KeyNavigation.tab: listView + KeyNavigation.tab: treeView } QQC2.ToolButton { diff --git a/src/qml/QuickSwitcher.qml b/src/qml/QuickSwitcher.qml index 2426022ff..c014e0339 100644 --- a/src/qml/QuickSwitcher.qml +++ b/src/qml/QuickSwitcher.qml @@ -90,9 +90,8 @@ QQC2.Dialog { } delegate: RoomDelegate { - filterText: searchField.text connection: root.connection - onSelected: root.close() + onClicked: root.close() } } } diff --git a/src/qml/RoomDelegate.qml b/src/qml/RoomDelegate.qml index cb0dfa279..69fa6f623 100644 --- a/src/qml/RoomDelegate.qml +++ b/src/qml/RoomDelegate.qml @@ -22,35 +22,27 @@ Delegates.RoundedItemDelegate { required property int highlightCount required property NeoChatRoom currentRoom required property NeoChatConnection connection - required property bool categoryVisible - required property string filterText required property string avatar required property string subtitleText - required property string displayName + property bool collapsed: false + readonly property bool hasNotifications: currentRoom.pushNotificationState === PushNotificationState.MentionKeyword || currentRoom.isLowPriority ? highlightCount > 0 : notificationCount > 0 - signal selected - Accessible.name: root.displayName - Accessible.onPressAction: select() + Accessible.onPressAction: clicked() + onClicked: RoomManager.resolveResource(currentRoom.id); onPressAndHold: createRoomListContextMenu() - Keys.onSpacePressed: select() - Keys.onEnterPressed: select() - Keys.onReturnPressed: select() + Keys.onSpacePressed: clicked() + Keys.onEnterPressed: clicked() + Keys.onReturnPressed: clicked() TapHandler { - acceptedButtons: Qt.RightButton | Qt.LeftButton - onTapped: (eventPoint, button) => { - if (button === Qt.RightButton) { - root.createRoomListContextMenu(); - } else { - select(); - } - } + acceptedButtons: Qt.RightButton + onTapped: (eventPoint, button) => root.createRoomListContextMenu(); } contentItem: RowLayout { @@ -72,6 +64,7 @@ Delegates.RoundedItemDelegate { Layout.fillWidth: true Layout.alignment: Qt.AlignVCenter + visible: !root.collapsed QQC2.Label { id: label @@ -105,7 +98,7 @@ Delegates.RoundedItemDelegate { enabled: false implicitWidth: Kirigami.Units.iconSizes.smallMedium implicitHeight: Kirigami.Units.iconSizes.smallMedium - visible: currentRoom.pushNotificationState === PushNotificationState.Mute && !configButton.visible + visible: currentRoom.pushNotificationState === PushNotificationState.Mute && !configButton.visible && !root.collapsed Accessible.name: i18n("Muted room") Layout.rightMargin: Kirigami.Units.smallSpacing } @@ -114,7 +107,7 @@ Delegates.RoundedItemDelegate { id: notificationCountLabel text: currentRoom.pushNotificationState === PushNotificationState.MentionKeyword || currentRoom.isLowPriority ? root.highlightCount : root.notificationCount - visible: root.hasNotifications && currentRoom.pushNotificationState !== PushNotificationState.Mute + visible: root.hasNotifications && currentRoom.pushNotificationState !== PushNotificationState.Mute && !root.collapsed color: Kirigami.Theme.textColor horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter @@ -138,7 +131,7 @@ Delegates.RoundedItemDelegate { QQC2.Button { id: configButton - visible: root.hovered && !Kirigami.Settings.isMobile && !Config.compactRoomList + visible: root.hovered && !Kirigami.Settings.isMobile && !Config.compactRoomList && !root.collapsed text: i18n("Configure room") display: QQC2.Button.IconOnly @@ -147,11 +140,6 @@ Delegates.RoundedItemDelegate { } } - function select() { - RoomManager.resolveResource(currentRoom.id); - root.selected(); - } - function createRoomListContextMenu() { const component = Qt.createComponent("qrc:/org/kde/neochat/qml/ContextMenu.qml"); if (component.status === Component.Error) { diff --git a/src/qml/RoomListPage.qml b/src/qml/RoomListPage.qml index 7f68bb7bf..af5511bb4 100644 --- a/src/qml/RoomListPage.qml +++ b/src/qml/RoomListPage.qml @@ -6,6 +6,7 @@ import QtQuick import QtQuick.Controls as QQC2 import QtQuick.Layouts import QtQml.Models +import Qt.labs.qmlmodels import org.kde.kirigami as Kirigami import org.kde.kirigamiaddons.components as KirigamiComponents @@ -24,50 +25,41 @@ Kirigami.Page { * @note Other objects can access the value but the private function makes sure * that only the internal members can modify it. */ - readonly property int currentWidth: _private.currentWidth + spaceListWidth + readonly property int currentWidth: _private.currentWidth + spaceListWidth + 1 readonly property alias spaceListWidth: spaceDrawer.width required property NeoChatConnection connection - readonly property RoomListModel roomListModel: RoomListModel { + readonly property RoomTreeModel roomTreeModel: RoomTreeModel { connection: root.connection } - property bool spaceChanging: false + property bool spaceChanging: true readonly property bool collapsed: Config.collapsed - property var enteredRoom: null - - onCollapsedChanged: if (collapsed) { - sortFilterRoomListModel.filterText = ""; - } - - Component.onCompleted: Runner.roomListModel = root.roomListModel - - Connections { - target: RoomManager - function onCurrentRoomChanged() { - itemSelection.setCurrentIndex(roomListModel.index(roomListModel.rowForRoom(RoomManager.currentRoom), 0), ItemSelectionModel.SelectCurrent); + onCollapsedChanged: { + if (collapsed) { + sortFilterRoomTreeModel.filterText = ""; } } function goToNextRoomFiltered(condition) { - let index = listView.currentIndex; - while (index++ !== listView.count - 1) { - if (condition(listView.itemAtIndex(index))) { - listView.currentIndex = index; - listView.currentItem.clicked(); + let index = treeView.currentIndex; + while (index++ !== treeView.count - 1) { + if (condition(treeView.itemAtIndex(index))) { + treeView.currentIndex = index; + treeView.currentItem.clicked(); return; } } } function goToPreviousRoomFiltered(condition) { - let index = listView.currentIndex; + let index = treeView.currentIndex; while (index-- !== 0) { - if (condition(listView.itemAtIndex(index))) { - listView.currentIndex = index; - listView.currentItem.clicked(); + if (condition(treeView.itemAtIndex(index))) { + treeView.currentIndex = index; + treeView.currentItem.clicked(); return; } } @@ -108,7 +100,7 @@ Kirigami.Page { connection: root.connection onSelectionChanged: root.spaceChanging = true - onSpacesUpdated: sortFilterRoomListModel.invalidate() + onSpacesUpdated: sortFilterRoomTreeModel.invalidate() } Kirigami.Separator { @@ -126,213 +118,151 @@ Kirigami.Page { Kirigami.Theme.colorSet: Kirigami.Theme.View } - ListView { - id: listView - - activeFocusOnTab: true - clip: true + Keys.onDownPressed: ; // Do not delete 🫠 + Keys.onUpPressed: ; // These make sure the scrollview doesn't also scroll while going through the roomlist using the arrow keys + contentItem: TreeView { + id: treeView topMargin: Math.round(Kirigami.Units.smallSpacing / 2) - header: QQC2.ItemDelegate { - width: visible ? ListView.view.width : 0 - height: visible ? Kirigami.Units.gridUnit * 2 : 0 + clip: true + reuseItems: false - visible: root.collapsed - - topPadding: Kirigami.Units.largeSpacing - leftPadding: Kirigami.Units.largeSpacing - rightPadding: Kirigami.Units.largeSpacing - bottomPadding: Kirigami.Units.largeSpacing - - onClicked: quickView.item.open() - - Kirigami.Icon { - anchors.centerIn: parent - width: Kirigami.Units.iconSizes.smallMedium - height: Kirigami.Units.iconSizes.smallMedium - source: "search" + onLayoutChanged: { + if (sortFilterRoomTreeModel.filterTextJustChanged) { + treeView.expandRecursively(); + sortFilterRoomTreeModel.filterTextJustChanged = false; } - - Kirigami.Separator { - width: parent.width - anchors.bottom: parent.bottom - } - } - - Kirigami.PlaceholderMessage { - anchors.centerIn: parent - width: parent.width - (Kirigami.Units.largeSpacing * 4) - visible: listView.count == 0 - text: if (sortFilterRoomListModel.filterText.length > 0) { - return spaceDrawer.showDirectChats ? i18n("No friends found") : i18n("No rooms found"); - } else { - return spaceDrawer.showDirectChats ? i18n("You haven't added any of your friends yet, click below to search for them.") : i18n("Join some rooms to get started"); - } - helpfulAction: spaceDrawer.showDirectChats ? userSearchAction : exploreRoomAction - - Kirigami.Action { - id: exploreRoomAction - icon.name: sortFilterRoomListModel.filterText.length > 0 ? "search" : "list-add" - text: sortFilterRoomListModel.filterText.length > 0 ? i18n("Search in room directory") : i18n("Explore rooms") - onTriggered: { - let dialog = pageStack.layers.push("qrc:/org/kde/neochat/qml/ExploreRoomsPage.qml", { - connection: root.connection, - keyword: sortFilterRoomListModel.filterText - }, { - title: i18nc("@title", "Explore Rooms") - }); - dialog.roomSelected.connect((roomId, displayName, avatarUrl, alias, topic, memberCount, isJoined) => { - RoomManager.resolveResource(roomId.length > 0 ? roomId : alias, isJoined ? "" : "join"); - }); + if (root.spaceChanging) { + treeView.expandRecursively(); + if (spaceDrawer.showDirectChats || spaceDrawer.selectedSpaceId.length < 1) { + RoomManager.resolveResource(treeView.itemAtIndex(treeView.index(1, 0)).currentRoom.id); } - } - - Kirigami.Action { - id: userSearchAction - icon.name: sortFilterRoomListModel.filterText.length > 0 ? "search" : "list-add" - text: sortFilterRoomListModel.filterText.length > 0 ? i18n("Search in friend directory") : i18n("Find your friends") - onTriggered: pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/UserSearchPage.qml", { - connection: root.connection - }, { - title: i18nc("@title", "Find your friends") - }) - } - } - - ItemSelectionModel { - id: itemSelection - model: root.roomListModel - onCurrentChanged: (current, previous) => listView.currentIndex = sortFilterRoomListModel.mapFromSource(current).row - } - - model: SortFilterRoomListModel { - id: sortFilterRoomListModel - - sourceModel: root.roomListModel - roomSortOrder: SortFilterRoomListModel.Categories - onLayoutChanged: { - layoutTimer.restart(); - listView.currentIndex = sortFilterRoomListModel.mapFromSource(itemSelection.currentIndex).row; - } - activeSpaceId: spaceDrawer.selectedSpaceId - mode: spaceDrawer.showDirectChats ? SortFilterRoomListModel.DirectChats : SortFilterRoomListModel.Rooms - } - - // HACK: This is the only way to guarantee the correct choice when - // there are multiple property changes that invalidate the filter. I.e. - // in this case activeSpaceId followed by mode. - Timer { - id: layoutTimer - interval: 300 - onTriggered: if ((spaceDrawer.showDirectChats || spaceDrawer.selectedSpaceId.length < 1) && root.spaceChanging) { - RoomManager.resolveResource(listView.itemAtIndex(0).currentRoom.id); root.spaceChanging = false; } } - section { - property: "category" - delegate: root.collapsed ? foldButton : sectionHeader + model: SortFilterRoomTreeModel { + id: sortFilterRoomTreeModel + + property bool filterTextJustChanged: false + + sourceModel: root.roomTreeModel + roomSortOrder: SortFilterRoomTreeModel.Categories + activeSpaceId: spaceDrawer.selectedSpaceId + mode: spaceDrawer.showDirectChats ? SortFilterRoomTreeModel.DirectChats : SortFilterRoomTreeModel.Rooms } - Component { - id: sectionHeader - Kirigami.ListSectionHeader { - height: implicitHeight - width: listView.width - label: roomListModel.categoryName(section) - action: Kirigami.Action { - onTriggered: roomListModel.setCategoryVisible(section, !roomListModel.categoryVisible(section)) - } + selectionModel: ItemSelectionModel {} - QQC2.ToolButton { - icon { - name: roomListModel.categoryVisible(section) ? "go-up" : "go-down" - width: Kirigami.Units.iconSizes.small - height: Kirigami.Units.iconSizes.small - } - text: roomListModel.categoryVisible(section) ? i18nc("Collapse
", "Collapse %1", roomListModel.categoryName(section)) : i18nc("Expand
0 - } - } - - footer: Delegates.RoundedItemDelegate { - visible: listView.view.count > 0 && spaceDrawer.showDirectChats - text: i18n("Find your friends") - icon.name: "list-add-user" - icon.width: Kirigami.Units.gridUnit * 2 - icon.height: Kirigami.Units.gridUnit * 2 - - onClicked: pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/UserSearchPage.qml", { - connection: root.connection - }, { - title: i18nc("@title", "Find your friends") - }) - } } } } + Kirigami.PlaceholderMessage { + anchors.centerIn: parent + anchors.horizontalCenterOffset: (spaceDrawer.width + 1) / 2 + width: scrollView.width - Kirigami.Units.largeSpacing * 4 + visible: treeView.rows == 0 + text: if (sortFilterRoomTreeModel.filterText.length > 0) { + return spaceDrawer.showDirectChats ? i18n("No friends found") : i18n("No rooms found"); + } else { + return spaceDrawer.showDirectChats ? i18n("You haven't added any of your friends yet, click below to search for them.") : i18n("Join some rooms to get started"); + } + helpfulAction: spaceDrawer.showDirectChats ? userSearchAction : exploreRoomAction + + Kirigami.Action { + id: exploreRoomAction + icon.name: sortFilterRoomTreeModel.filterText.length > 0 ? "search" : "list-add" + text: sortFilterRoomTreeModel.filterText.length > 0 ? i18n("Search in room directory") : i18n("Explore rooms") + onTriggered: { + let dialog = pageStack.layers.push("qrc:/org/kde/neochat/qml/ExploreRoomsPage.qml", { + connection: root.connection, + keyword: sortFilterRoomTreeModel.filterText + }, { + title: i18nc("@title", "Explore Rooms") + }); + dialog.roomSelected.connect((roomId, displayName, avatarUrl, alias, topic, memberCount, isJoined) => { + RoomManager.resolveResource(roomId.length > 0 ? roomId : alias, isJoined ? "" : "join"); + }); + } + } + + Kirigami.Action { + id: userSearchAction + icon.name: sortFilterRoomTreeModel.filterText.length > 0 ? "search" : "list-add" + text: sortFilterRoomTreeModel.filterText.length > 0 ? i18n("Search in friend directory") : i18n("Find your friends") + onTriggered: pageStack.pushDialogLayer("qrc:/org/kde/neochat/qml/UserSearchPage.qml", { + connection: root.connection + }, { + title: i18nc("@title", "Find your friends") + }) + } + } + footer: Loader { width: parent.width + active: !root.collapsed sourceComponent: Kirigami.Settings.isMobile ? exploreComponentMobile : userInfoDesktop } @@ -404,7 +334,8 @@ Kirigami.Page { connection: root.connection onTextChanged: newText => { - sortFilterRoomListModel.filterText = newText; + sortFilterRoomTreeModel.filterText = newText; + sortFilterRoomTreeModel.filterTextJustChanged = true; } } } @@ -415,7 +346,7 @@ Kirigami.Page { connection: root.connection onTextChanged: newText => { - sortFilterRoomListModel.filterText = newText; + sortFilterRoomTreeModel.filterText = newText; } } } @@ -429,6 +360,6 @@ Kirigami.Page { property int currentWidth: Config.collapsed ? collapsedSize : defaultWidth readonly property int defaultWidth: Kirigami.Units.gridUnit * 17 readonly property int collapseWidth: Kirigami.Units.gridUnit * 10 - readonly property int collapsedSize: Kirigami.Units.gridUnit * 3 - Kirigami.Units.smallSpacing * 3 + (scrollView.QQC2.ScrollBar.vertical.visible ? scrollView.QQC2.ScrollBar.vertical.width : 0) + readonly property int collapsedSize: Kirigami.Units.gridUnit + (Config.compactRoomList ? 0 : Kirigami.Units.largeSpacing * 2) + Kirigami.Units.largeSpacing * 2 + (scrollView.QQC2.ScrollBar.vertical.visible ? scrollView.QQC2.ScrollBar.vertical.width : 0) } } diff --git a/src/qml/RoomPage.qml b/src/qml/RoomPage.qml index dcec134ad..10a3acb52 100644 --- a/src/qml/RoomPage.qml +++ b/src/qml/RoomPage.qml @@ -212,12 +212,7 @@ Kirigami.Page { } Keys.onPressed: event => { - if (!(event.modifiers & Qt.ControlModifier) && event.key < Qt.Key_Escape) { - event.accepted = true; - chatBarLoader.item.insertText(event.text); - chatBarLoader.item.forceActiveFocus(); - return; - } else if (event.key === Qt.Key_PageUp) { + if (event.key === Qt.Key_PageUp) { event.accepted = true; timelineViewLoader.item.pageUp(); } else if (event.key === Qt.Key_PageDown) { diff --git a/src/qml/RoomTreeSection.qml b/src/qml/RoomTreeSection.qml new file mode 100644 index 000000000..5bea47a9e --- /dev/null +++ b/src/qml/RoomTreeSection.qml @@ -0,0 +1,83 @@ +// SPDX-FileCopyrightText: 2024 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 + +QQC2.ItemDelegate { + id: root + + required property TreeView treeView + required property bool isTreeNode + required property bool expanded + required property bool hasChildren + required property int depth + required property string displayName + required property int row + required property bool current + onCurrentChanged: if (current) { + collapseButton.forceActiveFocus(Qt.TabFocusReason) + } + required property bool selected + + property bool collapsed: false + + implicitWidth: treeView.width + + hoverEnabled: false + activeFocusOnTab: false + background: null + + onClicked: root.treeView.toggleExpanded(row) + + Keys.onEnterPressed: root.treeView.toggleExpanded(row) + Keys.onReturnPressed: root.treeView.toggleExpanded(row) + Keys.onSpacePressed: root.treeView.toggleExpanded(row) + + contentItem: RowLayout { + spacing: Kirigami.Units.largeSpacing + + Kirigami.Heading { + Layout.alignment: Qt.AlignVCenter + visible: !root.collapsed + + opacity: 0.7 + level: 5 + type: Kirigami.Heading.Primary + text: root.collapsed ? "" : model.displayName + elide: Text.ElideRight + + // we override the Primary type's font weight (DemiBold) for Bold for contrast with small text + font.weight: Font.Bold + } + + Kirigami.Separator { + Layout.fillWidth: true + visible: !root.collapsed + Layout.alignment: Qt.AlignVCenter + } + QQC2.ToolButton { + id: collapseButton + Layout.alignment: Qt.AlignHCenter + + icon { + name: root.expanded ? "go-up" : "go-down" + width: Kirigami.Units.iconSizes.small + height: Kirigami.Units.iconSizes.small + } + text: root.expanded ? i18nc("Collapse
", "Collapse %1", root.displayName) : i18nc("Expand
+#include "controller.h" #include "neochatroom.h" +#include "roomlistmodel.h" #include "roommanager.h" #include "windowcontroller.h" @@ -27,6 +29,12 @@ RemoteImage Runner::serializeImage(const QImage &image) Runner::Runner() : QObject() { + m_sourceModel = new RoomListModel(this); + m_model.setSourceModel(m_sourceModel); + connect(&Controller::instance(), &Controller::activeConnectionChanged, this, [this]() { + m_sourceModel->setConnection(Controller::instance().activeConnection()); + }); + qDBusRegisterMetaType(); qDBusRegisterMetaType(); qDBusRegisterMetaType(); diff --git a/src/runner.h b/src/runner.h index 2821a281a..aefa22de1 100644 --- a/src/runner.h +++ b/src/runner.h @@ -204,6 +204,6 @@ private: RemoteImage serializeImage(const QImage &image); SortFilterRoomListModel m_model; - RoomListModel m_sourceModel; + RoomListModel *m_sourceModel; Runner(); }; diff --git a/src/spacehierarchycache.cpp b/src/spacehierarchycache.cpp index 129a04d09..fec32a4b2 100644 --- a/src/spacehierarchycache.cpp +++ b/src/spacehierarchycache.cpp @@ -10,6 +10,7 @@ #include #include "neochatroom.h" +#include "neochatroomtype.h" #include "roomlistmodel.h" using namespace Quotient; @@ -120,7 +121,7 @@ qsizetype SpaceHierarchyCache::notificationCountForSpace(const QString &spaceId) for (const auto &childId : children) { if (const auto child = static_cast(m_connection->room(childId))) { - auto category = RoomListModel::category(child); + auto category = NeoChatRoomType::typeForRoom(child); if (!added.contains(child->id()) && child->successorId().isEmpty()) { switch (category) { case NeoChatRoomType::Normal: