diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 752e92549..bbe2c1a83 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -192,6 +192,8 @@ add_library(neochat STATIC models/readmarkermodel.h neochatroommember.cpp neochatroommember.h + models/threadmodel.cpp + models/threadmodel.h ) set_source_files_properties(qml/OsmLocationPlugin.qml PROPERTIES diff --git a/src/actionshandler.cpp b/src/actionshandler.cpp index e5ddaadbd..8f140b0b5 100644 --- a/src/actionshandler.cpp +++ b/src/actionshandler.cpp @@ -56,7 +56,7 @@ void ActionsHandler::handleMessageEvent(ChatBarCache *chatBarCache) QString handledText = chatBarCache->text(); handledText = handleMentions(handledText, chatBarCache->mentions()); - handleMessage(m_room->mainCache()->text(), handledText, chatBarCache); + handleMessage(chatBarCache->text(), handledText, chatBarCache); } QString ActionsHandler::handleMentions(QString handledText, QList *mentions) diff --git a/src/chatbar/ChatBar.qml b/src/chatbar/ChatBar.qml index 02fe20e1e..10e002557 100644 --- a/src/chatbar/ChatBar.qml +++ b/src/chatbar/ChatBar.qml @@ -396,6 +396,7 @@ QQC2.Control { root.currentRoom.markAllMessagesAsRead(); textField.clear(); _private.chatBarCache.replyId = ""; + _private.chatBarCache.threadId = ""; messageSent(); } diff --git a/src/eventhandler.cpp b/src/eventhandler.cpp index 4d062994a..1d67653ec 100644 --- a/src/eventhandler.cpp +++ b/src/eventhandler.cpp @@ -766,13 +766,20 @@ QVariantMap EventHandler::getMediaInfoFromFileInfo(const EventContent::FileInfo return mediaInfo; } -bool EventHandler::hasReply() const +bool EventHandler::hasReply(bool showFallbacks) const { if (m_event == nullptr) { qCWarning(EventHandling) << "hasReply called with m_event set to nullptr."; return false; } - return !m_event->contentJson()["m.relates_to"_ls].toObject()["m.in_reply_to"_ls].toObject()["event_id"_ls].toString().isEmpty(); + + const auto relations = m_event->contentPart("m.relates_to"_ls); + if (!relations.isEmpty()) { + const bool hasReplyRelation = relations.contains("m.in_reply_to"_ls); + bool isFallingBack = relations["is_falling_back"_ls].toBool(); + return hasReplyRelation && (showFallbacks ? true : !isFallingBack); + } + return false; } QString EventHandler::getReplyId() const diff --git a/src/eventhandler.h b/src/eventhandler.h index 277273c9e..6ec089391 100644 --- a/src/eventhandler.h +++ b/src/eventhandler.h @@ -214,8 +214,12 @@ public: /** * @brief Whether the event is a reply to another in the timeline. + * + * @param showFallbacks whether message that have is_falling_back set true should + * show the fallback reply. Leave true for non-threaded + * timelines. */ - bool hasReply() const; + bool hasReply(bool showFallbacks = true) const; /** * @brief Return the Matrix ID of the event replied to. diff --git a/src/models/messagecontentmodel.cpp b/src/models/messagecontentmodel.cpp index 468a68640..b99c77cb1 100644 --- a/src/models/messagecontentmodel.cpp +++ b/src/models/messagecontentmodel.cpp @@ -75,7 +75,10 @@ void MessageContentModel::initializeModel() }); if (m_event == nullptr) { - m_room->downloadEventFromServer(m_eventId); + m_room->getEvent(m_eventId); + if (m_event == nullptr) { + m_room->downloadEventFromServer(m_eventId); + } } connect(m_room, &NeoChatRoom::pendingEventAboutToMerge, this, [this](Quotient::RoomEvent *serverEvent) { @@ -148,6 +151,11 @@ void MessageContentModel::initializeModel() } }); + connect(NeoChatConfig::self(), &NeoChatConfig::ThreadsChanged, this, [this]() { + updateReplyModel(); + resetModel(); + }); + if (m_event != nullptr) { updateReplyModel(); } @@ -168,6 +176,9 @@ void MessageContentModel::intiializeEvent(const Quotient::RoomEvent *event) // a pending event may not previously have had an event ID so update. if (m_eventId.isEmpty()) { m_eventId = m_event->id(); + if (m_eventId.isEmpty()) { + m_eventId = m_event->transactionId(); + } } auto senderId = m_event->senderId(); @@ -417,12 +428,19 @@ QList MessageContentModel::messageContentComponents(bool isEdi void MessageContentModel::updateReplyModel() { - if (m_event == nullptr || m_replyModel != nullptr || m_isReply) { + if (m_event == nullptr || m_isReply) { return; } EventHandler eventHandler(m_room, m_event.get()); - if (!eventHandler.hasReply()) { + if (!eventHandler.hasReply() || (eventHandler.isThreaded() && NeoChatConfig::self()->threads())) { + if (m_replyModel) { + delete m_replyModel; + } + return; + } + + if (m_replyModel != nullptr) { return; } diff --git a/src/models/messageeventmodel.cpp b/src/models/messageeventmodel.cpp index 9b15d8892..e5327cab4 100644 --- a/src/models/messageeventmodel.cpp +++ b/src/models/messageeventmodel.cpp @@ -30,6 +30,7 @@ #include "neochatroommember.h" #include "readmarkermodel.h" #include "texthandler.h" +#include "threadmodel.h" using namespace Quotient; @@ -71,6 +72,11 @@ MessageEventModel::MessageEventModel(QObject *parent) connect(this, &MessageEventModel::modelReset, this, [this]() { resetting = false; }); + + connect(NeoChatConfig::self(), &NeoChatConfig::ThreadsChanged, this, [this]() { + beginResetModel(); + endResetModel(); + }); } NeoChatRoom *MessageEventModel::room() const @@ -497,6 +503,10 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const return EventStatus::Hidden; } + if (eventHandler.isThreaded() && eventHandler.threadRoot() != eventHandler.getId() && NeoChatConfig::threads()) { + return EventStatus::Hidden; + } + return EventStatus::Normal; } @@ -646,6 +656,11 @@ void MessageEventModel::createEventObjects(const Quotient::RoomEvent *event) } } + const auto eventHandler = EventHandler(m_currentRoom, event); + if (eventHandler.isThreaded() && !m_threadModels.contains(eventHandler.threadRoot())) { + m_threadModels[eventHandler.threadRoot()] = QSharedPointer(new ThreadModel(eventHandler.threadRoot(), m_currentRoom)); + } + // ReadMarkerModel handles updates to add and remove markers, we only need to // handle adding and removing whole models here. if (m_readMarkerModels.contains(eventId)) { @@ -705,4 +720,9 @@ bool MessageEventModel::event(QEvent *event) return QObject::event(event); } +ThreadModel *MessageEventModel::threadModelForRootId(const QString &threadRootId) const +{ + return m_threadModels[threadRootId].data(); +} + #include "moc_messageeventmodel.cpp" diff --git a/src/models/messageeventmodel.h b/src/models/messageeventmodel.h index 6fa197c0e..a1c16182b 100644 --- a/src/models/messageeventmodel.h +++ b/src/models/messageeventmodel.h @@ -13,6 +13,7 @@ #include "neochatroommember.h" #include "pollhandler.h" #include "readmarkermodel.h" +#include "threadmodel.h" class ReactionModel; @@ -55,8 +56,8 @@ public: ContentModelRole, /**< The MessageContentModel for the event. */ - IsThreadedRole, - ThreadRootRole, + IsThreadedRole, /**< Whether the message is in a thread. */ + ThreadRootRole, /**< The Matrix ID of the thread root message, if any . */ ShowSectionRole, /**< Whether the section header should be shown. */ @@ -105,6 +106,8 @@ public: */ Q_INVOKABLE [[nodiscard]] int eventIdToRow(const QString &eventID) const; + Q_INVOKABLE ThreadModel *threadModelForRootId(const QString &threadRootId) const; + protected: bool event(QEvent *event) override; @@ -120,6 +123,7 @@ private: std::map> m_memberObjects; std::map> m_contentModels; QMap> m_readMarkerModels; + QMap> m_threadModels; QMap> m_reactionModels; [[nodiscard]] int timelineBaseIndex() const; diff --git a/src/models/threadmodel.cpp b/src/models/threadmodel.cpp new file mode 100644 index 000000000..6f80d88bb --- /dev/null +++ b/src/models/threadmodel.cpp @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: 2023 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#include "threadmodel.h" + +#include +#include +#include +#include +#include +#include + +#include "eventhandler.h" +#include "messagecontentmodel.h" +#include "neochatroom.h" + +ThreadModel::ThreadModel(const QString &threadRootId, NeoChatRoom *room) + : QConcatenateTablesProxyModel(room) + , m_threadRootId(threadRootId) +{ + Q_ASSERT(!m_threadRootId.isEmpty()); + Q_ASSERT(room); + + m_threadRootContentModel = std::unique_ptr(new MessageContentModel(room, threadRootId)); + + connect(room, &Quotient::Room::pendingEventAboutToAdd, this, [this](Quotient::RoomEvent *event) { + if (auto roomEvent = eventCast(event)) { + EventHandler eventHandler(dynamic_cast(QObject::parent()), roomEvent); + if (eventHandler.isThreaded() && eventHandler.threadRoot() == m_threadRootId) { + addNewEvent(event); + clearModels(); + addModels(); + } + } + }); + connect(room, &Quotient::Room::aboutToAddNewMessages, this, [this](Quotient::RoomEventsRange events) { + for (const auto &event : events) { + if (auto roomEvent = eventCast(event)) { + EventHandler eventHandler(dynamic_cast(QObject::parent()), roomEvent); + if (eventHandler.isThreaded() && eventHandler.threadRoot() == m_threadRootId) { + addNewEvent(roomEvent); + } + } + } + clearModels(); + addModels(); + }); + + fetchMore({}); + addModels(); +} + +MessageContentModel *ThreadModel::threadRootContentModel() const +{ + return m_threadRootContentModel.get(); +} + +QHash ThreadModel::roleNames() const +{ + return m_threadRootContentModel->roleNames(); +} + +bool ThreadModel::canFetchMore(const QModelIndex &parent) const +{ + Q_UNUSED(parent); + return !m_currentJob && m_nextBatch.has_value(); +} + +void ThreadModel::fetchMore(const QModelIndex &parent) +{ + 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, QLatin1String("m.thread"), *m_nextBatch, QString(), 5); + connect(m_currentJob, &Quotient::BaseJob::success, this, [this]() { + const auto room = dynamic_cast(QObject::parent()); + auto newEvents = m_currentJob->chunk(); + for (auto &event : newEvents) { + m_contentModels.push_back(new MessageContentModel(room, event.get())); + } + + clearModels(); + addModels(); + + const auto newNextBatch = m_currentJob->nextBatch(); + if (!newNextBatch.isEmpty() && *m_nextBatch != newNextBatch) { + *m_nextBatch = newNextBatch; + } else { + // Insert the thread root at the end. + beginInsertRows({}, rowCount(), rowCount()); + endInsertRows(); + m_nextBatch.reset(); + } + + m_currentJob.clear(); + }); + } +} + +void ThreadModel::addNewEvent(const Quotient::RoomEvent *event) +{ + const auto room = dynamic_cast(QObject::parent()); + m_contentModels.push_front(new MessageContentModel(room, event)); +} + +void ThreadModel::addModels() +{ + addSourceModel(m_threadRootContentModel.get()); + for (auto it = m_contentModels.crbegin(); it != m_contentModels.crend(); ++it) { + addSourceModel(*it); + } + + beginResetModel(); + endResetModel(); +} + +void ThreadModel::clearModels() +{ + removeSourceModel(m_threadRootContentModel.get()); + for (const auto &model : m_contentModels) { + if (sourceModels().contains(model)) { + removeSourceModel(model); + } + } +} + +#include "moc_threadmodel.cpp" diff --git a/src/models/threadmodel.h b/src/models/threadmodel.h new file mode 100644 index 000000000..7ff4f95a8 --- /dev/null +++ b/src/models/threadmodel.h @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: 2023 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#pragma once + +#include + +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "linkpreviewer.h" +#include "messagecontentmodel.h" + +class NeoChatRoom; +class ReactionModel; + +/** + * @class ThreadModel + * + * This class defines the model for visualising a thread. + * + * The class also provides functions to access the data of the root event, typically + * used to visualise the thread in a list of room threads. + */ +class ThreadModel : public QConcatenateTablesProxyModel +{ + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("") + +public: + explicit ThreadModel(const QString &threadRootId, NeoChatRoom *room); + + /** + * @brief The content model for the thread root event. + */ + MessageContentModel *threadRootContentModel() const; + + /** + * @brief Returns a mapping from Role enum values to role names. + * + * @sa Roles, QAbstractItemModel::roleNames() + */ + [[nodiscard]] QHash roleNames() const override; + + /** + * @brief Whether there is more data available for the model to fetch. + * + * @sa QAbstractItemModel::canFetchMore() + */ + bool canFetchMore(const QModelIndex &parent) const override; + + /** + * @brief Fetches the next batch of model data if any is available. + * + * @sa QAbstractItemModel::fetchMore() + */ + void fetchMore(const QModelIndex &parent) override; + +private: + QString m_threadRootId; + + std::unique_ptr m_threadRootContentModel; + + std::deque m_contentModels; + + QList m_events; + QList m_pendingEvents; + + std::unordered_map> m_unloadedEvents; + + QMap> m_reactionModels; + + QPointer m_currentJob = nullptr; + std::optional m_nextBatch = QString(); + bool m_addingPending = false; + + void addNewEvent(const Quotient::RoomEvent *event); + void addModels(); + void clearModels(); +}; diff --git a/src/neochatroom.cpp b/src/neochatroom.cpp index 748802a12..bd3cd6fe3 100644 --- a/src/neochatroom.cpp +++ b/src/neochatroom.cpp @@ -64,6 +64,7 @@ NeoChatRoom::NeoChatRoom(Connection *connection, QString roomId, JoinState joinS { m_mainCache = new ChatBarCache(this); m_editCache = new ChatBarCache(this); + m_threadCache = new ChatBarCache(this); connect(connection, &Connection::accountDataChanged, this, &NeoChatRoom::updatePushNotificationState); connect(this, &Room::fileTransferCompleted, this, [this] { @@ -516,9 +517,10 @@ void NeoChatRoom::postMessage(const QString &rawText, MessageEventType type, const QString &replyEventId, const QString &relateToEventId, - const QString &threadRootId) + const QString &threadRootId, + const QString &fallbackId) { - postHtmlMessage(rawText, text, type, replyEventId, relateToEventId, threadRootId); + postHtmlMessage(rawText, text, type, replyEventId, relateToEventId, threadRootId, fallbackId); } void NeoChatRoom::postHtmlMessage(const QString &text, @@ -526,7 +528,8 @@ void NeoChatRoom::postHtmlMessage(const QString &text, MessageEventType type, const QString &replyEventId, const QString &relateToEventId, - const QString &threadRootId) + const QString &threadRootId, + const QString &fallbackId) { bool isReply = !replyEventId.isEmpty(); bool isEdit = !relateToEventId.isEmpty(); @@ -537,9 +540,21 @@ void NeoChatRoom::postHtmlMessage(const QString &text, } if (isThread) { - EventHandler eventHandler(this, &**replyIt); + bool isFallingBack = !fallbackId.isEmpty(); + QString replyEventId = isFallingBack ? fallbackId : QString(); + if (isReply) { + EventHandler eventHandler(this, &**replyIt); - const bool isFallingBack = !eventHandler.isThreaded(); + isFallingBack = false; + replyEventId = eventHandler.getId(); + } + + // If we are not replying and there is no fallback ID it means a new thread + // is being created. + if (!isFallingBack && !isReply) { + isFallingBack = true; + replyEventId = threadRootId; + } // clang-format off QJsonObject json{ @@ -1532,6 +1547,11 @@ ChatBarCache *NeoChatRoom::editCache() const return m_editCache; } +ChatBarCache *NeoChatRoom::threadCache() const +{ + return m_threadCache; +} + void NeoChatRoom::replyLastMessage() { const auto &timelineBottom = messageEvents().rbegin(); diff --git a/src/neochatroom.h b/src/neochatroom.h index 92481440a..987a83e61 100644 --- a/src/neochatroom.h +++ b/src/neochatroom.h @@ -201,6 +201,11 @@ class NeoChatRoom : public Quotient::Room */ Q_PROPERTY(ChatBarCache *editCache READ editCache CONSTANT) + /** + * @brief The cache for the thread chat bar in the room. + */ + Q_PROPERTY(ChatBarCache *threadCache READ threadCache CONSTANT) + #if Quotient_VERSION_MINOR == 8 Q_PROPERTY(QList otherMembersTyping READ otherMembersTyping NOTIFY typingChanged) #endif @@ -511,6 +516,8 @@ public: ChatBarCache *editCache() const; + ChatBarCache *threadCache() const; + /** * @brief Reply to the last message sent in the timeline. * @@ -609,6 +616,7 @@ private: ChatBarCache *m_mainCache; ChatBarCache *m_editCache; + ChatBarCache *m_threadCache; QCache m_polls; std::vector> m_extraEvents; @@ -691,7 +699,8 @@ public Q_SLOTS: Quotient::MessageEventType type = Quotient::MessageEventType::Text, const QString &replyEventId = QString(), const QString &relateToEventId = QString(), - const QString &threadRootId = QString()); + const QString &threadRootId = QString(), + const QString &fallbackId = QString()); /** * @brief Send an html message to the room. @@ -707,7 +716,8 @@ public Q_SLOTS: Quotient::MessageEventType type = Quotient::MessageEventType::Text, const QString &replyEventId = QString(), const QString &relateToEventId = QString(), - const QString &threadRootId = QString()); + const QString &threadRootId = QString(), + const QString &fallbackId = QString()); /** * @brief Set the room avatar. diff --git a/src/qml/HoverActions.qml b/src/qml/HoverActions.qml index 7d965bdf1..80accbb83 100644 --- a/src/qml/HoverActions.qml +++ b/src/qml/HoverActions.qml @@ -104,6 +104,7 @@ QQC2.Control { onTriggered: { root.currentRoom.editCache.editId = root.delegate.eventId; root.currentRoom.mainCache.replyId = ""; + root.currentRoom.mainCache.threadId = ""; } }, Kirigami.Action { @@ -113,6 +114,7 @@ QQC2.Control { onTriggered: { root.currentRoom.mainCache.replyId = root.delegate.eventId; root.currentRoom.editCache.editId = ""; + root.currentRoom.mainCache.threadId = ""; root.focusChatBar(); } }, @@ -121,7 +123,7 @@ QQC2.Control { text: i18n("Reply in Thread") icon.name: "dialog-messages" onTriggered: { - root.currentRoom.mainCache.replyId = root.delegate.eventId; + root.currentRoom.mainCache.replyId = ""; root.currentRoom.mainCache.threadId = root.delegate.isThreaded ? root.delegate.threadRoot : root.delegate.eventId; root.currentRoom.editCache.editId = ""; root.focusChatBar(); diff --git a/src/qml/NeochatMaximizeComponent.qml b/src/qml/NeochatMaximizeComponent.qml index 844e90141..097b79154 100644 --- a/src/qml/NeochatMaximizeComponent.qml +++ b/src/qml/NeochatMaximizeComponent.qml @@ -28,6 +28,11 @@ Components.AlbumMaximizeComponent { readonly property var currentProgressInfo: model.data(model.index(content.currentIndex, 0), MessageEventModel.ProgressInfoRole) + /** + * @brief Whether the delegate is part of a thread timeline. + */ + property bool isThread: false + downloadAction: Components.DownloadAction { id: downloadAction onTriggered: { diff --git a/src/qml/RoomPage.qml b/src/qml/RoomPage.qml index 91d2980bf..16bf96010 100644 --- a/src/qml/RoomPage.qml +++ b/src/qml/RoomPage.qml @@ -267,25 +267,27 @@ Kirigami.Page { }); } - function onShowMessageMenu(eventId, author, messageComponentType, plainText, htmlText, selectedText) { + function onShowMessageMenu(eventId, author, messageComponentType, plainText, htmlText, selectedText, isThread) { const contextMenu = messageDelegateContextMenu.createObject(root, { selectedText: selectedText, author: author, eventId: eventId, messageComponentType: messageComponentType, plainText: plainText, - htmlText: htmlText + htmlText: htmlText, + isThread: isThread }); contextMenu.open(); } - function onShowFileMenu(eventId, author, messageComponentType, plainText, mimeType, progressInfo) { + function onShowFileMenu(eventId, author, messageComponentType, plainText, mimeType, progressInfo, isThread) { const contextMenu = fileDelegateContextMenu.createObject(root, { author: author, eventId: eventId, plainText: plainText, mimeType: mimeType, - progressInfo: progressInfo + progressInfo: progressInfo, + isThread: isThread }); contextMenu.open(); } diff --git a/src/timeline/Bubble.qml b/src/timeline/Bubble.qml index ddcd69963..55cc3f4e6 100644 --- a/src/timeline/Bubble.qml +++ b/src/timeline/Bubble.qml @@ -49,7 +49,7 @@ QQC2.Control { /** * @brief The model to visualise the content of the message. */ - required property MessageContentModel contentModel + required property var contentModel /** * @brief The ActionsHandler object to use. diff --git a/src/timeline/MessageDelegate.qml b/src/timeline/MessageDelegate.qml index 022bc329c..4886fd421 100644 --- a/src/timeline/MessageDelegate.qml +++ b/src/timeline/MessageDelegate.qml @@ -87,8 +87,14 @@ TimelineDelegate { */ required property bool showReadMarkers + /** + * @brief Whether the message in a thread. + */ required property bool isThreaded + /** + * @brief The Matrix ID of the root message in the thread, if any. + */ required property string threadRoot /** @@ -282,7 +288,13 @@ TimelineDelegate { author: root.author - contentModel: root.contentModel + // HACK: This is stupid but seemingly QConcatenateTablesProxyModel + // can't be passed as a model role, always returning null. + contentModel: if (root.isThreaded && NeoChatConfig.threads) { + return RoomManager.timelineModel.messageEventModel.threadModelForRootId(root.threadRoot); + } else { + return root.contentModel; + } actionsHandler: root.ListView.view?.actionsHandler ?? null timeline: root.ListView.view