From 5efd17d370eee22e11d98a08f2fb2d67fded14c6 Mon Sep 17 00:00:00 2001 From: James Graham Date: Mon, 20 Nov 2023 17:10:56 +0000 Subject: [PATCH] Loading and End of Timeline Delegates Add delegate for showing the user a loading indicator and for the beginning of the timeline. BUG: 455045 BUG: 465285 --- src/CMakeLists.txt | 4 ++ src/enums/delegatetype.h | 2 + src/models/messageeventmodel.cpp | 11 +-- src/models/messagefiltermodel.cpp | 7 +- src/models/messagefiltermodel.h | 3 +- src/models/timelinemodel.cpp | 95 +++++++++++++++++++++++++ src/models/timelinemodel.h | 112 ++++++++++++++++++++++++++++++ src/qml/EventDelegate.qml | 13 ++++ src/qml/LoadingDelegate.qml | 15 ++++ src/qml/RoomPage.qml | 16 ++--- src/qml/RoomWindow.qml | 2 +- src/qml/TimelineEndDelegate.qml | 90 ++++++++++++++++++++++++ src/qml/TimelineView.qml | 20 +++--- src/roommanager.cpp | 12 ++-- src/roommanager.h | 9 +-- 15 files changed, 370 insertions(+), 41 deletions(-) create mode 100644 src/models/timelinemodel.cpp create mode 100644 src/models/timelinemodel.h create mode 100644 src/qml/LoadingDelegate.qml create mode 100644 src/qml/TimelineEndDelegate.qml diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index bd4d5086c..df0b74666 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -141,6 +141,8 @@ add_library(neochat STATIC colorschemer.h models/notificationsmodel.cpp models/notificationsmodel.h + models/timelinemodel.cpp + models/timelinemodel.h ) qt_add_qml_module(neochat URI org.kde.neochat NO_PLUGIN @@ -293,6 +295,8 @@ qt_add_qml_module(neochat URI org.kde.neochat NO_PLUGIN qml/SelectSpacesDialog.qml qml/AttachDialog.qml qml/NotificationsView.qml + qml/LoadingDelegate.qml + qml/TimelineEndDelegate.qml RESOURCES qml/confetti.png qml/glowdot.png diff --git a/src/enums/delegatetype.h b/src/enums/delegatetype.h index d1e63030d..f1095f5d3 100644 --- a/src/enums/delegatetype.h +++ b/src/enums/delegatetype.h @@ -40,6 +40,8 @@ public: Poll, /**< The initial event for a poll. */ Location, /**< A location event. */ LiveLocation, /**< The initial event of a shared live location (i.e., the place where this is supposed to be shown in the timeline). */ + Loading, /**< A delegate to tell the user more messages are being loaded. */ + TimelineEnd, /**< A delegate to inform that all messages are loaded. */ Other, /**< Anything that cannot be classified as another type. */ }; Q_ENUM(Type); diff --git a/src/models/messageeventmodel.cpp b/src/models/messageeventmodel.cpp index 95809fc71..d464395d0 100644 --- a/src/models/messageeventmodel.cpp +++ b/src/models/messageeventmodel.cpp @@ -389,13 +389,7 @@ int MessageEventModel::rowCount(const QModelIndex &parent) const return 0; } - const auto firstIt = m_currentRoom->messageEvents().crbegin(); - if (firstIt != m_currentRoom->messageEvents().crend()) { - const auto &firstEvt = **firstIt; - return m_currentRoom->timelineSize() + (lastReadEventId != firstEvt.id() ? 1 : 0); - } else { - return m_currentRoom->timelineSize(); - } + return int(m_currentRoom->pendingEvents().size()) + m_currentRoom->timelineSize() + (m_lastReadEventIndex.isValid() ? 1 : 0); } bool MessageEventModel::canFetchMore(const QModelIndex &parent) const @@ -422,7 +416,7 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const } const auto row = idx.row(); - if (!m_currentRoom || row < 0 || row >= int(m_currentRoom->pendingEvents().size()) + m_currentRoom->timelineSize()) { + if (!m_currentRoom || row < 0 || row >= rowCount()) { return {}; }; @@ -465,7 +459,6 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const return (reason.isEmpty()) ? i18n("[This message was deleted]") : i18n("[This message was deleted: %1]", evt.redactedBecause()->reason()); } - return eventHandler.getRichBody(); } diff --git a/src/models/messagefiltermodel.cpp b/src/models/messagefiltermodel.cpp index 2935ccc60..9d84235ee 100644 --- a/src/models/messagefiltermodel.cpp +++ b/src/models/messagefiltermodel.cpp @@ -8,14 +8,15 @@ #include "enums/delegatetype.h" #include "messageeventmodel.h" #include "neochatconfig.h" +#include "timelinemodel.h" using namespace Quotient; -MessageFilterModel::MessageFilterModel(QObject *parent, MessageEventModel *sourceMessageModel) +MessageFilterModel::MessageFilterModel(QObject *parent, TimelineModel *sourceModel) : QSortFilterProxyModel(parent) { - Q_ASSERT(sourceMessageModel); - setSourceModel(sourceMessageModel); + Q_ASSERT(sourceModel); + setSourceModel(sourceModel); connect(NeoChatConfig::self(), &NeoChatConfig::ShowStateEventChanged, this, [this] { invalidateFilter(); diff --git a/src/models/messagefiltermodel.h b/src/models/messagefiltermodel.h index c50e4c174..768eb95bb 100644 --- a/src/models/messagefiltermodel.h +++ b/src/models/messagefiltermodel.h @@ -7,6 +7,7 @@ #include #include "messageeventmodel.h" +#include "timelinemodel.h" /** * @class MessageFilterModel @@ -36,7 +37,7 @@ public: LastRole, // Keep this last }; - explicit MessageFilterModel(QObject *parent = nullptr, MessageEventModel *sourceMessageModel = nullptr); + explicit MessageFilterModel(QObject *parent = nullptr, TimelineModel *sourceModel = nullptr); /** * @brief Custom filter function to remove hidden messages. diff --git a/src/models/timelinemodel.cpp b/src/models/timelinemodel.cpp new file mode 100644 index 000000000..a8918d448 --- /dev/null +++ b/src/models/timelinemodel.cpp @@ -0,0 +1,95 @@ +// SPDX-FileCopyrightText: 2022 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#include "timelinemodel.h" + +#include "delegatetype.h" + +TimelineModel::TimelineModel(QObject *parent) + : QConcatenateTablesProxyModel(parent) +{ + m_messageEventModel = new MessageEventModel(this); + addSourceModel(m_messageEventModel); + m_timelineEndModel = new TimelineEndModel(this); + addSourceModel(m_timelineEndModel); +} + +NeoChatRoom *TimelineModel::room() const +{ + return m_messageEventModel->room(); +} + +void TimelineModel::setRoom(NeoChatRoom *room) +{ + // Both models do their own null checking so just pass along. + m_messageEventModel->setRoom(room); + m_timelineEndModel->setRoom(room); +} + +MessageEventModel *TimelineModel::messageEventModel() const +{ + return m_messageEventModel; +} + +QHash TimelineModel::roleNames() const +{ + return m_messageEventModel->roleNames(); +} + +TimelineEndModel::TimelineEndModel(QObject *parent) + : QAbstractListModel(parent) +{ +} + +void TimelineEndModel::setRoom(NeoChatRoom *room) +{ + if (room == m_room) { + return; + } + + beginResetModel(); + + if (m_room != nullptr) { + m_room->disconnect(this); + } + + m_room = room; + + if (m_room != nullptr) { + connect(m_room, &Quotient::Room::eventsHistoryJobChanged, this, [this]() { + if (m_room->allHistoryLoaded()) { + // HACK: We have to do it this way because DelegateChooser doesn't update dynamically. + beginRemoveRows({}, 0, 0); + endRemoveRows(); + beginInsertRows({}, 0, 0); + endInsertRows(); + } + }); + } + + endResetModel(); +} + +QVariant TimelineEndModel::data(const QModelIndex &idx, int role) const +{ + Q_UNUSED(idx) + if (m_room == nullptr) { + return {}; + } + + if (role == DelegateTypeRole) { + return m_room->allHistoryLoaded() ? DelegateType::TimelineEnd : DelegateType::Loading; + } + return {}; +} + +int TimelineEndModel::rowCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent) + return 1; +} + +QHash TimelineEndModel::roleNames() const +{ + return {{DelegateTypeRole, "delegateType"}}; +} diff --git a/src/models/timelinemodel.h b/src/models/timelinemodel.h new file mode 100644 index 000000000..1c8f993ba --- /dev/null +++ b/src/models/timelinemodel.h @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: 2022 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 "messageeventmodel.h" +#include "neochatroom.h" + +/** + * @class TimelineEndModel + * + * A model to provide a single delegate to mark the end of the timeline. + * + * The delegate will either be a loading delegate if more events are being loaded + * or a timeline end delegate if all history is loaded. + */ +class TimelineEndModel : public QAbstractListModel +{ + Q_OBJECT + QML_ELEMENT + +public: + /** + * @brief Defines the model roles. + */ + enum Roles { + DelegateTypeRole = MessageEventModel::DelegateTypeRole, /**< The delegate type of the message. */ + }; + Q_ENUM(Roles) + + explicit TimelineEndModel(QObject *parent = nullptr); + + /** + * @brief Set the room for the timeline. + */ + void setRoom(NeoChatRoom *room); + + /** + * @brief Get the given role value at the given index. + * + * @sa QAbstractItemModel::data + */ + [[nodiscard]] QVariant data(const QModelIndex &idx, int role = Qt::DisplayRole) const override; + + /** + * @brief 1, the answer is always 1. + * + * @sa QAbstractItemModel::rowCount + */ + [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; + + /** + * @brief Returns a map with DelegateTypeRole it's the only one. + * + * @sa Roles, QAbstractItemModel::roleNames() + */ + [[nodiscard]] QHash roleNames() const override; + +private: + NeoChatRoom *m_room = nullptr; +}; + +/** + * @class TimelineModel + * + * A model to visualise a room timeline. + * + * This model combines a MessageEventModel with a TimelineEndModel. + * + * @sa MessageEventModel, TimelineEndModel + */ +class TimelineModel : public QConcatenateTablesProxyModel +{ + Q_OBJECT + QML_ELEMENT + + /** + * @brief The current room that the model is getting its messages from. + */ + Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged) + + /** + * @brief The MessageEventModel for the timeline. + */ + Q_PROPERTY(MessageEventModel *messageEventModel READ messageEventModel CONSTANT) + +public: + TimelineModel(QObject *parent = nullptr); + + [[nodiscard]] NeoChatRoom *room() const; + void setRoom(NeoChatRoom *room); + + MessageEventModel *messageEventModel() const; + + /** + * @brief Returns a mapping from Role enum values to role names. + * + * @sa Roles, QAbstractProxyModel::roleNames() + */ + [[nodiscard]] QHash roleNames() const override; + +Q_SIGNALS: + void roomChanged(); + +private: + MessageEventModel *m_messageEventModel = nullptr; + TimelineEndModel *m_timelineEndModel = nullptr; +}; diff --git a/src/qml/EventDelegate.qml b/src/qml/EventDelegate.qml index cc0965f17..65f04b554 100644 --- a/src/qml/EventDelegate.qml +++ b/src/qml/EventDelegate.qml @@ -99,6 +99,7 @@ DelegateChooser { connection: root.connection } } + DelegateChoice { roleValue: DelegateType.LiveLocation delegate: LiveLocationDelegate { @@ -107,6 +108,18 @@ DelegateChooser { } } + DelegateChoice { + roleValue: DelegateType.Loading + delegate: LoadingDelegate {} + } + + DelegateChoice { + roleValue: DelegateType.TimelineEnd + delegate: TimelineEndDelegate { + room: root.room + } + } + DelegateChoice { roleValue: DelegateType.Other delegate: Item {} diff --git a/src/qml/LoadingDelegate.qml b/src/qml/LoadingDelegate.qml new file mode 100644 index 000000000..2bb94631a --- /dev/null +++ b/src/qml/LoadingDelegate.qml @@ -0,0 +1,15 @@ +// 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 org.kde.kirigami as Kirigami + +import org.kde.neochat + +TimelineDelegate { + id: root + contentItem: Kirigami.PlaceholderMessage { + text: i18n("Loading…") + } +} diff --git a/src/qml/RoomPage.qml b/src/qml/RoomPage.qml index fe06245d5..413c587f5 100644 --- a/src/qml/RoomPage.qml +++ b/src/qml/RoomPage.qml @@ -23,17 +23,17 @@ Kirigami.Page { required property NeoChatConnection connection /** - * @brief The MessageEventModel to use. + * @brief The TimelineModel to use. * * Required so that new events can be requested when the end of the current * local timeline is reached. * * @note For loading a room in a different window, override this with a new - * MessageEventModel set with the room to be shown. + * TimelineModel set with the room to be shown. * - * @sa MessageEventModel + * @sa TimelineModel */ - property MessageEventModel messageEventModel: RoomManager.messageEventModel + property TimelineModel timelineModel: RoomManager.timelineModel /** * @brief The MessageFilterModel to use. @@ -41,9 +41,9 @@ Kirigami.Page { * This model has the filtered list of events that should be shown in the timeline. * * @note For loading a room in a different window, override this with a new - * MessageFilterModel with the new MessageEventModel as the source model. + * MessageFilterModel with the new TimelineModel as the source model. * - * @sa MessageEventModel, MessageFilterModel + * @sa TimelineModel, MessageFilterModel */ property MessageFilterModel messageFilterModel: RoomManager.messageFilterModel @@ -56,7 +56,7 @@ Kirigami.Page { * @note For loading a room in a different window, override this with a new * MediaMessageFilterModel with the new MessageFilterModel as the source model. * - * @sa MessageEventModel, MessageFilterModel + * @sa TimelineModel, MessageFilterModel */ property MediaMessageFilterModel mediaMessageFilterModel: RoomManager.mediaMessageFilterModel @@ -120,7 +120,7 @@ Kirigami.Page { sourceComponent: TimelineView { id: timelineView currentRoom: root.currentRoom - messageEventModel: root.messageEventModel + timelineModel: root.timelineModel messageFilterModel: root.messageFilterModel actionsHandler: root.actionsHandler onFocusChatBar: { diff --git a/src/qml/RoomWindow.qml b/src/qml/RoomWindow.qml index 974da13b0..ab880fb5b 100644 --- a/src/qml/RoomWindow.qml +++ b/src/qml/RoomWindow.qml @@ -29,7 +29,7 @@ Kirigami.ApplicationWindow { disableCancelShortcut: true connection: root.connection - messageEventModel: MessageEventModel { + timelineModel: TimelineModel { room: currentRoom } messageFilterModel: MessageFilterModel { diff --git a/src/qml/TimelineEndDelegate.qml b/src/qml/TimelineEndDelegate.qml new file mode 100644 index 000000000..82e98873f --- /dev/null +++ b/src/qml/TimelineEndDelegate.qml @@ -0,0 +1,90 @@ +// 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.Layouts + +import org.kde.kirigami as Kirigami +import org.kde.kirigamiaddons.labs.components as KirigamiComponents + +import org.kde.neochat + +TimelineDelegate { + id: root + + /** + * @brief The current room that user is viewing. + */ + required property NeoChatRoom room + + contentItem: ColumnLayout { + RowLayout { + Layout.topMargin: Kirigami.Units.largeSpacing + Layout.bottomMargin: Kirigami.Units.largeSpacing + Layout.leftMargin: Kirigami.Units.largeSpacing + Layout.rightMargin: Kirigami.Units.largeSpacing + spacing: Kirigami.Units.largeSpacing + + KirigamiComponents.Avatar { + Layout.preferredWidth: Kirigami.Units.iconSizes.large + Layout.preferredHeight: Kirigami.Units.iconSizes.large + + name: root.room ? root.room.displayName : "" + source: root.room && root.room.avatarMediaId ? ("image://mxc/" + root.room.avatarMediaId) : "" + + Rectangle { + visible: room.usesEncryption + color: Kirigami.Theme.backgroundColor + + width: Kirigami.Units.gridUnit + height: Kirigami.Units.gridUnit + + anchors { + bottom: parent.bottom + right: parent.right + } + + radius: Math.round(width / 2) + + Kirigami.Icon { + source: "channel-secure-symbolic" + anchors.fill: parent + } + } + } + + + ColumnLayout { + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + spacing: 0 + + Kirigami.Heading { + Layout.fillWidth: true + text: root.room ? root.room.displayName : i18n("No name") + textFormat: Text.PlainText + wrapMode: Text.Wrap + } + + Kirigami.SelectableLabel { + Layout.fillWidth: true + font: Kirigami.Theme.smallFont + textFormat: TextEdit.PlainText + visible: root.room && root.room.canonicalAlias + text: root.room && root.room.canonicalAlias ? root.room.canonicalAlias : "" + } + } + } + + Kirigami.SelectableLabel { + Layout.fillWidth: true + Layout.leftMargin: Kirigami.Units.largeSpacing + Layout.rightMargin: Kirigami.Units.largeSpacing + Layout.bottomMargin: Kirigami.Units.largeSpacing + + text: i18n("This is the beginning of the chat. There are no historical messages beyond this point.") + wrapMode: Text.Wrap + onLinkActivated: link => UrlHelper.openUrl(link) + } + } +} diff --git a/src/qml/TimelineView.qml b/src/qml/TimelineView.qml index 4ad5c570a..239bb228a 100644 --- a/src/qml/TimelineView.qml +++ b/src/qml/TimelineView.qml @@ -28,12 +28,12 @@ QQC2.ScrollView { property bool roomChanging: false /** - * @brief The MessageEventModel to use. + * @brief The TimelineModel to use. * * Required so that new events can be requested when the end of the current * local timeline is reached. */ - required property MessageEventModel messageEventModel + required property TimelineModel timelineModel /** * @brief The MessageFilterModel to use. @@ -85,16 +85,16 @@ QQC2.ScrollView { running: messageListView.atYBeginning triggeredOnStart: true onTriggered: { - if (messageListView.atYBeginning && root.messageEventModel.canFetchMore(root.messageEventModel.index(0, 0))) { - root.messageEventModel.fetchMore(root.messageEventModel.index(0, 0)); + if (messageListView.atYBeginning && root.timelineModel.messageEventModel.canFetchMore(root.timelineModel.index(0, 0))) { + root.timelineModel.messageEventModel.fetchMore(root.timelineModel.index(0, 0)); } } repeat: true } // HACK: The view should do this automatically but doesn't. - onAtYBeginningChanged: if (atYBeginning && root.messageEventModel.canFetchMore(root.messageEventModel.index(0, 0))) { - root.messageEventModel.fetchMore(root.messageEventModel.index(0, 0)); + onAtYBeginningChanged: if (atYBeginning && root.timelineModel.messageEventModel.canFetchMore(root.timelineModel.index(0, 0))) { + root.timelineModel.messageEventModel.fetchMore(root.timelineModel.index(0, 0)); } Timer { @@ -270,7 +270,7 @@ QQC2.ScrollView { } Connections { - target: root.messageEventModel + target: root.timelineModel function onRowsInserted() { markReadIfVisibleTimer.restart() @@ -311,7 +311,7 @@ QQC2.ScrollView { Connections { //enabled: Config.showFancyEffects - target: root.messageEventModel + target: root.timelineModel.messageEventModel function onFancyEffectsReasonFound(fancyEffect) { fancyEffectsContainer.processFancyEffectsReason(fancyEffect) @@ -336,10 +336,10 @@ QQC2.ScrollView { } function eventToIndex(eventID) { - const index = root.messageEventModel.eventIdToRow(eventID) + const index = root.timelineModel.messageEventModel.eventIdToRow(eventID) if (index === -1) return -1 - return root.messageFilterModel.mapFromSource(root.messageEventModel.index(index, 0)).row + return root.messageFilterModel.mapFromSource(root.timelineModel.index(index, 0)).row } function firstVisibleIndex() { diff --git a/src/roommanager.cpp b/src/roommanager.cpp index c867ac482..a875f820e 100644 --- a/src/roommanager.cpp +++ b/src/roommanager.cpp @@ -8,8 +8,10 @@ #include "controller.h" #include "enums/delegatetype.h" #include "models/messageeventmodel.h" +#include "models/timelinemodel.h" #include "neochatconfig.h" #include "neochatroom.h" + #include #include #include @@ -29,14 +31,14 @@ RoomManager::RoomManager(QObject *parent) , m_currentRoom(nullptr) , m_lastCurrentRoom(nullptr) , m_config(KSharedConfig::openStateConfig()) - , m_messageEventModel(new MessageEventModel(this)) - , m_messageFilterModel(new MessageFilterModel(this, m_messageEventModel)) + , m_timelineModel(new TimelineModel(this)) + , m_messageFilterModel(new MessageFilterModel(this, m_timelineModel)) , m_mediaMessageFilterModel(new MediaMessageFilterModel(this, m_messageFilterModel)) { m_lastRoomConfig = m_config->group(QStringLiteral("LastOpenRoom")); connect(this, &RoomManager::currentRoomChanged, this, [this]() { - m_messageEventModel->setRoom(m_currentRoom); + m_timelineModel->setRoom(m_currentRoom); }); } @@ -55,9 +57,9 @@ NeoChatRoom *RoomManager::currentRoom() const return m_currentRoom; } -MessageEventModel *RoomManager::messageEventModel() const +TimelineModel *RoomManager::timelineModel() const { - return m_messageEventModel; + return m_timelineModel; } MessageFilterModel *RoomManager::messageFilterModel() const diff --git a/src/roommanager.h b/src/roommanager.h index 55c9b8d76..ff53b4de1 100644 --- a/src/roommanager.h +++ b/src/roommanager.h @@ -15,6 +15,7 @@ #include "models/mediamessagefiltermodel.h" #include "models/messageeventmodel.h" #include "models/messagefiltermodel.h" +#include "models/timelinemodel.h" class NeoChatRoom; class NeoChatConnection; @@ -48,7 +49,7 @@ class RoomManager : public QObject, public UriResolverBase Q_PROPERTY(NeoChatRoom *currentRoom READ currentRoom NOTIFY currentRoomChanged) /** - * @brief The MessageEventModel that should be used for room message visualisation. + * @brief The TimelineModel that should be used for room message visualisation. * * The room object the model uses to get the data will be updated by this class * so there is no need to do this manually or replace the model when a room @@ -57,7 +58,7 @@ class RoomManager : public QObject, public UriResolverBase * @note Available here so that the room page and drawer both have access to the * same model. */ - Q_PROPERTY(MessageEventModel *messageEventModel READ messageEventModel CONSTANT) + Q_PROPERTY(TimelineModel *timelineModel READ timelineModel CONSTANT) /** * @brief The MessageFilterModel that should be used for room message visualisation. @@ -101,7 +102,7 @@ public: NeoChatRoom *currentRoom() const; - MessageEventModel *messageEventModel() const; + TimelineModel *timelineModel() const; MessageFilterModel *messageFilterModel() const; MediaMessageFilterModel *mediaMessageFilterModel() const; @@ -383,7 +384,7 @@ private: KConfigGroup m_lastRoomConfig; QPointer m_chatDocumentHandler; - MessageEventModel *m_messageEventModel; + TimelineModel *m_timelineModel; MessageFilterModel *m_messageFilterModel; MediaMessageFilterModel *m_mediaMessageFilterModel; NeoChatConnection *m_connection;