diff --git a/src/enums/messagecomponenttype.h b/src/enums/messagecomponenttype.h index 754b0945a..7f4d82203 100644 --- a/src/enums/messagecomponenttype.h +++ b/src/enums/messagecomponenttype.h @@ -56,8 +56,10 @@ public: ThreadRoot, /**< The root message of the thread. */ ThreadBody, /**< The other messages in the thread. */ ReplyButton, /**< A button to reply in the current thread. */ + FetchButton, /**< A button to fetch more messages in the current thread. */ Verification, /**< A user verification session start message. */ Loading, /**< The component is loading. */ + Separator, /**< A horizontal separator. */ Other, /**< Anything that cannot be classified as another type. */ }; Q_ENUM(Type); diff --git a/src/models/messagecontentmodel.cpp b/src/models/messagecontentmodel.cpp index b0d4d06a0..60eaaa75b 100644 --- a/src/models/messagecontentmodel.cpp +++ b/src/models/messagecontentmodel.cpp @@ -486,6 +486,7 @@ QList MessageContentModel::messageContentComponents(bool isEdi #else if (isThreading && roomMessageEvent && roomMessageEvent->isThreaded() && roomMessageEvent->id() == roomMessageEvent->threadRootEventId()) { #endif + newComponents += MessageComponent{MessageComponentType::Separator, {}, {}}; newComponents += MessageComponent{MessageComponentType::ThreadBody, u"Thread Body"_s, {}}; } diff --git a/src/models/threadmodel.cpp b/src/models/threadmodel.cpp index a1134d028..84a3d74ec 100644 --- a/src/models/threadmodel.cpp +++ b/src/models/threadmodel.cpp @@ -17,6 +17,7 @@ ThreadModel::ThreadModel(const QString &threadRootId, NeoChatRoom *room) : QConcatenateTablesProxyModel(room) , m_threadRootId(threadRootId) + , m_threadFetchModel(new ThreadFetchModel(this)) , m_threadChatBarModel(new ThreadChatBarModel(this, room)) { Q_ASSERT(!m_threadRootId.isEmpty()); @@ -48,7 +49,7 @@ ThreadModel::ThreadModel(const QString &threadRootId, NeoChatRoom *room) // If the thread was created by the local user fetchMore() won't find the current // pending event. checkPending(); - fetchMore({}); + fetchMoreEvents(3); addModels(); } @@ -77,19 +78,19 @@ QHash ThreadModel::roleNames() const return MessageContentModel::roleNamesStatic(); } -bool ThreadModel::canFetchMore(const QModelIndex &parent) const +bool ThreadModel::moreEventsAvailable(const QModelIndex &parent) const { Q_UNUSED(parent); return !m_currentJob && m_nextBatch.has_value(); } -void ThreadModel::fetchMore(const QModelIndex &parent) +void ThreadModel::fetchMoreEvents(int max) { - Q_UNUSED(parent); if (!m_currentJob && m_nextBatch.has_value()) { const auto room = dynamic_cast(QObject::parent()); const auto connection = room->connection(); - m_currentJob = connection->callApi(room->id(), m_threadRootId, u"m.thread"_s, *m_nextBatch, QString(), 5); + m_currentJob = connection->callApi(room->id(), m_threadRootId, u"m.thread"_s, *m_nextBatch, QString(), max); + Q_EMIT moreEventsAvailableChanged(); connect(m_currentJob, &Quotient::BaseJob::success, this, [this]() { auto newEvents = m_currentJob->chunk(); for (auto &event : newEvents) { @@ -109,6 +110,7 @@ void ThreadModel::fetchMore(const QModelIndex &parent) } m_currentJob.clear(); + Q_EMIT moreEventsAvailableChanged(); }); } } @@ -132,6 +134,7 @@ void ThreadModel::addModels() if (room == nullptr) { return; } + addSourceModel(m_threadFetchModel); for (auto it = m_events.crbegin(); it != m_events.crend(); ++it) { const auto contentModel = room->contentModelForEvent(*it); if (contentModel != nullptr) { @@ -150,6 +153,7 @@ void ThreadModel::clearModels() if (room == nullptr) { return; } + removeSourceModel(m_threadFetchModel); for (const auto &model : m_events) { const auto contentModel = room->contentModelForEvent(model); if (sourceModels().contains(contentModel)) { @@ -188,6 +192,47 @@ void ThreadModel::closeLinkPreview(int row) } } +ThreadFetchModel::ThreadFetchModel(QObject *parent) + : QAbstractListModel(parent) +{ + const auto threadModel = dynamic_cast(parent); + Q_ASSERT(threadModel != nullptr); + connect(threadModel, &ThreadModel::moreEventsAvailableChanged, this, [this]() { + beginResetModel(); + endResetModel(); + }); +} + +QVariant ThreadFetchModel::data(const QModelIndex &idx, int role) const +{ + if (idx.row() < 0 || idx.row() > 1) { + return {}; + } + + if (role == ComponentTypeRole) { + return MessageComponentType::FetchButton; + } + return {}; +} + +int ThreadFetchModel::rowCount(const QModelIndex &parent) const +{ + Q_UNUSED(parent) + const auto threadModel = dynamic_cast(this->parent()); + if (threadModel == nullptr) { + qWarning() << "ThreadFetchModel created with incorrect parent, a ThreadModel must be set as the parent on creation."; + return {}; + } + return threadModel->moreEventsAvailable({}) ? 1 : 0; +} + +QHash ThreadFetchModel::roleNames() const +{ + return { + {ComponentTypeRole, "componentType"}, + }; +} + ThreadChatBarModel::ThreadChatBarModel(QObject *parent, NeoChatRoom *room) : QAbstractListModel(parent) , m_room(room) diff --git a/src/models/threadmodel.h b/src/models/threadmodel.h index c724c4b05..81997ff28 100644 --- a/src/models/threadmodel.h +++ b/src/models/threadmodel.h @@ -21,6 +21,52 @@ class NeoChatRoom; class ReactionModel; +/** + * @class ThreadFetchModel + * + * A model to provide a fetch more historical messages button in a thread. + */ +class ThreadFetchModel : public QAbstractListModel +{ + Q_OBJECT + +public: + /** + * @brief Defines the model roles. + * + * The role values need to match MessageContentModel not to blow up. + * + * @sa MessageContentModel + */ + enum Roles { + ComponentTypeRole = MessageContentModel::ComponentTypeRole, /**< The type of component to visualise the message. */ + }; + Q_ENUM(Roles) + + explicit ThreadFetchModel(QObject *parent); + + /** + * @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 or 0, depending on whether there are more messages to download. + * + * @sa QAbstractItemModel::rowCount + */ + [[nodiscard]] int rowCount(const QModelIndex &parent = QModelIndex()) const override; + + /** + * @brief Returns a map with ComponentTypeRole it's the only one. + * + * @sa Roles, QAbstractItemModel::roleNames() + */ + [[nodiscard]] QHash roleNames() const override; +}; + /** * @class ThreadChatBarModel * @@ -99,18 +145,14 @@ public: [[nodiscard]] QHash roleNames() const override; /** - * @brief Whether there is more data available for the model to fetch. - * - * @sa QAbstractItemModel::canFetchMore() + * @brief Whether there are more events for the model to fetch. */ - bool canFetchMore(const QModelIndex &parent) const override; + bool moreEventsAvailable(const QModelIndex &parent) const; /** - * @brief Fetches the next batch of model data if any is available. - * - * @sa QAbstractItemModel::fetchMore() + * @brief Fetches the next batch of events if any is available. */ - void fetchMore(const QModelIndex &parent) override; + Q_INVOKABLE void fetchMoreEvents(int max = 5); /** * @brief Close the link preview at the given index. @@ -119,11 +161,15 @@ public: */ Q_INVOKABLE void closeLinkPreview(int row); +Q_SIGNALS: + void moreEventsAvailableChanged(); + private: QString m_threadRootId; QPointer m_threadRootContentModel; std::deque m_events; + ThreadFetchModel *m_threadFetchModel; ThreadChatBarModel *m_threadChatBarModel; QMap> m_reactionModels; diff --git a/src/timeline/BaseMessageComponentChooser.qml b/src/timeline/BaseMessageComponentChooser.qml index af960ce49..195d4c8cc 100644 --- a/src/timeline/BaseMessageComponentChooser.qml +++ b/src/timeline/BaseMessageComponentChooser.qml @@ -2,8 +2,11 @@ // SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL import QtQuick +import QtQuick.Layouts import Qt.labs.qmlmodels +import org.kde.kirigami as Kirigami + import org.kde.neochat /** @@ -54,6 +57,11 @@ DelegateChooser { signal removeLinkPreview(int index) + /** + * @brief Request more events in the thread be loaded. + */ + signal fetchMoreEvents() + role: "componentType" DelegateChoice { @@ -218,6 +226,14 @@ DelegateChooser { } } + DelegateChoice { + roleValue: MessageComponentType.FetchButton + delegate: FetchButtonComponent { + maxContentWidth: root.maxContentWidth + onFetchMoreEvents: root.fetchMoreEvents() + } + } + DelegateChoice { roleValue: MessageComponentType.Verification delegate: MimeComponent { @@ -233,6 +249,14 @@ DelegateChooser { } } + DelegateChoice { + roleValue: MessageComponentType.Separator + delegate: Kirigami.Separator { + Layout.fillWidth: true + Layout.maximumWidth: root.maxContentWidth + } + } + DelegateChoice { roleValue: MessageComponentType.Other delegate: Item {} diff --git a/src/timeline/CMakeLists.txt b/src/timeline/CMakeLists.txt index 450865004..1c1fa8077 100644 --- a/src/timeline/CMakeLists.txt +++ b/src/timeline/CMakeLists.txt @@ -27,6 +27,7 @@ ecm_add_qml_module(timeline GENERATE_PLUGIN_SOURCE ChatBarComponent.qml CodeComponent.qml EncryptedComponent.qml + FetchButtonComponent.qml FileComponent.qml ImageComponent.qml ItineraryComponent.qml diff --git a/src/timeline/FetchButtonComponent.qml b/src/timeline/FetchButtonComponent.qml new file mode 100644 index 000000000..8996671d9 --- /dev/null +++ b/src/timeline/FetchButtonComponent.qml @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: 2025 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.neochat +import org.kde.neochat.chatbar + +/** + * @brief A component to show a reply button for threads in a message bubble. + */ +Delegates.RoundedItemDelegate { + id: root + + /** + * @brief The maximum width that the bubble's content can be. + */ + property real maxContentWidth: -1 + + /** + * @brief Request more events in the thread be loaded. + */ + signal fetchMoreEvents() + + Layout.fillWidth: true + Layout.maximumWidth: root.maxContentWidth + + leftInset: 0 + rightInset: 0 + + highlighted: true + + icon.name: "arrow-up" + icon.width: Kirigami.Units.iconSizes.sizeForLabels + icon.height: Kirigami.Units.iconSizes.sizeForLabels + text: i18nc("@action:button", "Fetch More Events") + + onClicked: { + root.fetchMoreEvents() + } + + contentItem: Kirigami.Icon { + implicitWidth: root.icon.width + implicitHeight: root.icon.height + source: root.icon.name + } +} diff --git a/src/timeline/ThreadBodyComponent.qml b/src/timeline/ThreadBodyComponent.qml index 7f23e0423..5c925ab80 100644 --- a/src/timeline/ThreadBodyComponent.qml +++ b/src/timeline/ThreadBodyComponent.qml @@ -87,6 +87,7 @@ ColumnLayout { } onShowMessageMenu: root.showMessageMenu() onRemoveLinkPreview: index => threadRepeater.model.closeLinkPreview(index) + onFetchMoreEvents: threadRepeater.model.fetchMoreEvents(5) } } }