From 7a949dccbb93393f97bf5c4e82618dad9456b894 Mon Sep 17 00:00:00 2001 From: James Graham Date: Fri, 17 Jan 2025 19:18:44 +0000 Subject: [PATCH] Refactor threads The focus here is to make threads use the standard message content system rather than having a special implementation. To achieve this the threadroot content model will now get a thread body component which will visualise the thread model with all the other messages. The latest message in the thread will then just ask for the thread root content model and show that. Note: in order to stop a cyclical dependency with MessageComponentChooser and new base version has been added which is just missing ThreadBodyComponent and and the main version is now inherited from that with ThreadBodyComponent added. --- src/enums/messagecomponenttype.h | 2 + src/models/messagecontentmodel.cpp | 67 ++++-- src/models/messagecontentmodel.h | 2 + src/models/messagemodel.cpp | 8 +- src/models/threadmodel.cpp | 42 +++- src/models/threadmodel.h | 15 +- src/neochatroom.h | 2 +- src/timeline/BaseMessageComponentChooser.qml | 240 +++++++++++++++++++ src/timeline/CMakeLists.txt | 2 + src/timeline/MessageComponentChooser.qml | 222 +---------------- src/timeline/MessageDelegate.qml | 8 +- src/timeline/ThreadBodyComponent.qml | 92 +++++++ 12 files changed, 434 insertions(+), 268 deletions(-) create mode 100644 src/timeline/BaseMessageComponentChooser.qml create mode 100644 src/timeline/ThreadBodyComponent.qml diff --git a/src/enums/messagecomponenttype.h b/src/enums/messagecomponenttype.h index 9c49aaeb4..754b0945a 100644 --- a/src/enums/messagecomponenttype.h +++ b/src/enums/messagecomponenttype.h @@ -53,6 +53,8 @@ public: LinkPreview, /**< A preview of a URL in the message. */ LinkPreviewLoad, /**< A loading dialog for a link preview. */ ChatBar, /**< A text edit for editing a message. */ + ThreadRoot, /**< The root message of the thread. */ + ThreadBody, /**< The other messages in the thread. */ ReplyButton, /**< A button to reply in the current thread. */ Verification, /**< A user verification session start message. */ Loading, /**< The component is loading. */ diff --git a/src/models/messagecontentmodel.cpp b/src/models/messagecontentmodel.cpp index a55caed7a..b0d4d06a0 100644 --- a/src/models/messagecontentmodel.cpp +++ b/src/models/messagecontentmodel.cpp @@ -340,6 +340,17 @@ QVariant MessageContentModel::data(const QModelIndex &index, int role) const if (role == ReplyContentModelRole) { return QVariant::fromValue(m_replyModel); } + if (role == ThreadRootRole) { + auto roomMessageEvent = eventCast(event.first); +#if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1) + if (roomMessageEvent && (roomMessageEvent->isThreaded() || m_room->threads().contains(roomMessageEvent->id()))) { +#else + if (roomMessageEvent && roomMessageEvent->isThreaded()) { +#endif + return roomMessageEvent->threadRootEventId(); + } + return {}; + } if (role == LinkPreviewerRole) { if (component.type == MessageComponentType::LinkPreview) { return QVariant::fromValue( @@ -366,27 +377,32 @@ int MessageContentModel::rowCount(const QModelIndex &parent) const QHash MessageContentModel::roleNames() const { - QHash roles = QAbstractItemModel::roleNames(); - roles[DisplayRole] = "display"; - roles[ComponentTypeRole] = "componentType"; - roles[ComponentAttributesRole] = "componentAttributes"; - roles[EventIdRole] = "eventId"; - roles[TimeRole] = "time"; - roles[TimeStringRole] = "timeString"; - roles[AuthorRole] = "author"; - roles[MediaInfoRole] = "mediaInfo"; - roles[FileTransferInfoRole] = "fileTransferInfo"; - roles[ItineraryModelRole] = "itineraryModel"; - roles[LatitudeRole] = "latitude"; - roles[LongitudeRole] = "longitude"; - roles[AssetRole] = "asset"; - roles[PollHandlerRole] = "pollHandler"; - roles[ReplyEventIdRole] = "replyEventId"; - roles[ReplyAuthorRole] = "replyAuthor"; - roles[ReplyContentModelRole] = "replyContentModel"; - roles[ThreadRootRole] = "threadRoot"; - roles[LinkPreviewerRole] = "linkPreviewer"; - roles[ChatBarCacheRole] = "chatBarCache"; + return roleNamesStatic(); +} + +QHash MessageContentModel::roleNamesStatic() +{ + QHash roles; + roles[MessageContentModel::DisplayRole] = "display"; + roles[MessageContentModel::ComponentTypeRole] = "componentType"; + roles[MessageContentModel::ComponentAttributesRole] = "componentAttributes"; + roles[MessageContentModel::EventIdRole] = "eventId"; + roles[MessageContentModel::TimeRole] = "time"; + roles[MessageContentModel::TimeStringRole] = "timeString"; + roles[MessageContentModel::AuthorRole] = "author"; + roles[MessageContentModel::MediaInfoRole] = "mediaInfo"; + roles[MessageContentModel::FileTransferInfoRole] = "fileTransferInfo"; + roles[MessageContentModel::ItineraryModelRole] = "itineraryModel"; + roles[MessageContentModel::LatitudeRole] = "latitude"; + roles[MessageContentModel::LongitudeRole] = "longitude"; + roles[MessageContentModel::AssetRole] = "asset"; + roles[MessageContentModel::PollHandlerRole] = "pollHandler"; + roles[MessageContentModel::ReplyEventIdRole] = "replyEventId"; + roles[MessageContentModel::ReplyAuthorRole] = "replyAuthor"; + roles[MessageContentModel::ReplyContentModelRole] = "replyContentModel"; + roles[MessageContentModel::ThreadRootRole] = "threadRoot"; + roles[MessageContentModel::LinkPreviewerRole] = "linkPreviewer"; + roles[MessageContentModel::ChatBarCacheRole] = "chatBarCache"; return roles; } @@ -464,6 +480,15 @@ QList MessageContentModel::messageContentComponents(bool isEdi newComponents = addLinkPreviews(newComponents); } +#if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1) + if (roomMessageEvent && (roomMessageEvent->isThreaded() || m_room->threads().contains(roomMessageEvent->id())) + && roomMessageEvent->id() == roomMessageEvent->threadRootEventId()) { +#else + if (isThreading && roomMessageEvent && roomMessageEvent->isThreaded() && roomMessageEvent->id() == roomMessageEvent->threadRootEventId()) { +#endif + newComponents += MessageComponent{MessageComponentType::ThreadBody, u"Thread Body"_s, {}}; + } + // If the event is already threaded the ThreadModel will handle displaying a chat bar. #if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1) if (isThreading && roomMessageEvent && !(roomMessageEvent->isThreaded() || m_room->threads().contains(roomMessageEvent->id()))) { diff --git a/src/models/messagecontentmodel.h b/src/models/messagecontentmodel.h index a33257775..6eb645126 100644 --- a/src/models/messagecontentmodel.h +++ b/src/models/messagecontentmodel.h @@ -91,6 +91,8 @@ public: */ [[nodiscard]] QHash roleNames() const override; + static QHash roleNamesStatic(); + /** * @brief Close the link preview at the given index. * diff --git a/src/models/messagemodel.cpp b/src/models/messagemodel.cpp index 566e310d0..223caaa80 100644 --- a/src/models/messagemodel.cpp +++ b/src/models/messagemodel.cpp @@ -7,7 +7,7 @@ #include #include -#if Quotient_VERSION_MINOR > 9 +#if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1) #include #endif @@ -120,6 +120,10 @@ QVariant MessageModel::data(const QModelIndex &idx, int role) const } if (role == ContentModelRole) { + auto roomMessageEvent = eventCast(&event.value().get()); + if (roomMessageEvent && roomMessageEvent->isThreaded()) { + return QVariant::fromValue(m_room->contentModelForEvent(roomMessageEvent->threadRootEventId())); + } return QVariant::fromValue(m_room->contentModelForEvent(&event->get())); } @@ -169,7 +173,7 @@ QVariant MessageModel::data(const QModelIndex &idx, int role) const } auto roomMessageEvent = eventCast(&event.value().get()); -#if Quotient_VERSION_MINOR > 9 +#if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1) if (roomMessageEvent && (roomMessageEvent->isThreaded() || m_room->threads().contains(event.value().get().id()))) { const auto &thread = m_room->threads().value(roomMessageEvent->isThreaded() ? roomMessageEvent->threadRootEventId() : event.value().get().id()); if (thread.latestEventId != event.value().get().id()) { diff --git a/src/models/threadmodel.cpp b/src/models/threadmodel.cpp index 23ae4e8b2..a1134d028 100644 --- a/src/models/threadmodel.cpp +++ b/src/models/threadmodel.cpp @@ -11,6 +11,7 @@ #include "chatbarcache.h" #include "eventhandler.h" #include "messagecomponenttype.h" +#include "messagecontentmodel.h" #include "neochatroom.h" ThreadModel::ThreadModel(const QString &threadRootId, NeoChatRoom *room) @@ -21,8 +22,6 @@ ThreadModel::ThreadModel(const QString &threadRootId, NeoChatRoom *room) Q_ASSERT(!m_threadRootId.isEmpty()); Q_ASSERT(room); - m_threadRootContentModel = std::unique_ptr(new MessageContentModel(room, threadRootId)); - #if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 0) connect(room, &Quotient::Room::pendingEventAdded, this, [this](const Quotient::RoomEvent *event) { #else @@ -73,14 +72,9 @@ QString ThreadModel::threadRootId() const return m_threadRootId; } -MessageContentModel *ThreadModel::threadRootContentModel() const -{ - return m_threadRootContentModel.get(); -} - QHash ThreadModel::roleNames() const { - return m_threadRootContentModel->roleNames(); + return MessageContentModel::roleNamesStatic(); } bool ThreadModel::canFetchMore(const QModelIndex &parent) const @@ -134,7 +128,6 @@ void ThreadModel::addModels() clearModels(); } - addSourceModel(m_threadRootContentModel.get()); const auto room = dynamic_cast(QObject::parent()); if (room == nullptr) { return; @@ -153,8 +146,6 @@ void ThreadModel::addModels() void ThreadModel::clearModels() { - removeSourceModel(m_threadRootContentModel.get()); - const auto room = dynamic_cast(QObject::parent()); if (room == nullptr) { return; @@ -168,6 +159,35 @@ void ThreadModel::clearModels() removeSourceModel(m_threadChatBarModel); } +void ThreadModel::closeLinkPreview(int row) +{ + if (row < 0 || row >= rowCount()) { + return; + } + + const auto index = this->index(row, 0); + if (!index.isValid()) { + return; + } + + const auto sourceIndex = mapToSource(index); + const auto sourceModel = sourceIndex.model(); + if (sourceModel == nullptr) { + return; + } + // This is a bit silly but we can only get a const reference to the model from the + // index so we need to search the source models. + for (const auto &model : sourceModels()) { + if (model == sourceModel) { + const auto sourceContentModel = dynamic_cast(model); + if (sourceContentModel == nullptr) { + return; + } + sourceContentModel->closeLinkPreview(sourceIndex.row()); + } + } +} + ThreadChatBarModel::ThreadChatBarModel(QObject *parent, NeoChatRoom *room) : QAbstractListModel(parent) , m_room(room) diff --git a/src/models/threadmodel.h b/src/models/threadmodel.h index 9f844357f..c724c4b05 100644 --- a/src/models/threadmodel.h +++ b/src/models/threadmodel.h @@ -91,11 +91,6 @@ public: QString threadRootId() const; - /** - * @brief The content model for the thread root event. - */ - MessageContentModel *threadRootContentModel() const; - /** * @brief Returns a mapping from Role enum values to role names. * @@ -117,10 +112,16 @@ public: */ void fetchMore(const QModelIndex &parent) override; + /** + * @brief Close the link preview at the given index. + * + * If the given index is not a link preview component, nothing happens. + */ + Q_INVOKABLE void closeLinkPreview(int row); + private: QString m_threadRootId; - - std::unique_ptr m_threadRootContentModel; + QPointer m_threadRootContentModel; std::deque m_events; ThreadChatBarModel *m_threadChatBarModel; diff --git a/src/neochatroom.h b/src/neochatroom.h index d8bcbb6d2..864448802 100644 --- a/src/neochatroom.h +++ b/src/neochatroom.h @@ -634,7 +634,7 @@ public: * A model is created is one doesn't exist. Will return nullptr if threadRootId * is empty. */ - ThreadModel *modelForThread(const QString &threadRootId); + Q_INVOKABLE ThreadModel *modelForThread(const QString &threadRootId); private: bool m_visible = false; diff --git a/src/timeline/BaseMessageComponentChooser.qml b/src/timeline/BaseMessageComponentChooser.qml new file mode 100644 index 000000000..af960ce49 --- /dev/null +++ b/src/timeline/BaseMessageComponentChooser.qml @@ -0,0 +1,240 @@ +// 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 Qt.labs.qmlmodels + +import org.kde.neochat + +/** + * @brief Select a message component based on a MessageComponentType. + */ +DelegateChooser { + id: root + + /** + * @brief The NeoChatRoom the delegate is being displayed in. + */ + required property NeoChatRoom room + + /** + * @brief The index of the delegate in the model. + */ + required property var index + + /** + * @brief The timeline ListView this component is being used in. + */ + required property ListView timeline + + /** + * @brief The maximum width that the bubble's content can be. + */ + property real maxContentWidth: -1 + + /** + * @brief The reply has been clicked. + */ + signal replyClicked(string eventID) + + /** + * @brief The user selected text has changed. + */ + signal selectedTextChanged(string selectedText) + + /** + * @brief The user hovered link has changed. + */ + signal hoveredLinkChanged(string hoveredLink) + + /** + * @brief Request a context menu be show for the message. + */ + signal showMessageMenu + + signal removeLinkPreview(int index) + + role: "componentType" + + DelegateChoice { + roleValue: MessageComponentType.Author + delegate: AuthorComponent { + maxContentWidth: root.maxContentWidth + } + } + + DelegateChoice { + roleValue: MessageComponentType.Text + delegate: TextComponent { + maxContentWidth: root.maxContentWidth + onSelectedTextChanged: root.selectedTextChanged(selectedText) + onHoveredLinkChanged: root.hoveredLinkChanged(hoveredLink) + onShowMessageMenu: root.showMessageMenu() + } + } + + DelegateChoice { + roleValue: MessageComponentType.Image + delegate: ImageComponent { + room: root.room + index: root.index + timeline: root.timeline + maxContentWidth: root.maxContentWidth + } + } + + DelegateChoice { + roleValue: MessageComponentType.Video + delegate: VideoComponent { + room: root.room + index: root.index + timeline: root.timeline + maxContentWidth: root.maxContentWidth + } + } + + DelegateChoice { + roleValue: MessageComponentType.Code + delegate: CodeComponent { + maxContentWidth: root.maxContentWidth + onSelectedTextChanged: selectedText => { + root.selectedTextChanged(selectedText); + } + onShowMessageMenu: root.showMessageMenu() + } + } + + DelegateChoice { + roleValue: MessageComponentType.Quote + delegate: QuoteComponent { + maxContentWidth: root.maxContentWidth + onSelectedTextChanged: selectedText => { + root.selectedTextChanged(selectedText); + } + onShowMessageMenu: root.showMessageMenu() + } + } + + DelegateChoice { + roleValue: MessageComponentType.Audio + delegate: AudioComponent { + room: root.room + maxContentWidth: root.maxContentWidth + } + } + + DelegateChoice { + roleValue: MessageComponentType.File + delegate: FileComponent { + room: root.room + maxContentWidth: root.maxContentWidth + } + } + + DelegateChoice { + roleValue: MessageComponentType.Itinerary + delegate: ItineraryComponent { + maxContentWidth: root.maxContentWidth + } + } + + DelegateChoice { + roleValue: MessageComponentType.Pdf + delegate: PdfPreviewComponent { + maxContentWidth: root.maxContentWidth + } + } + + DelegateChoice { + roleValue: MessageComponentType.Poll + delegate: PollComponent { + room: root.room + maxContentWidth: root.maxContentWidth + } + } + + DelegateChoice { + roleValue: MessageComponentType.Location + delegate: LocationComponent { + maxContentWidth: root.maxContentWidth + } + } + + DelegateChoice { + roleValue: MessageComponentType.LiveLocation + delegate: LiveLocationComponent { + room: root.room + maxContentWidth: root.maxContentWidth + } + } + + DelegateChoice { + roleValue: MessageComponentType.Encrypted + delegate: EncryptedComponent { + maxContentWidth: root.maxContentWidth + } + } + + DelegateChoice { + roleValue: MessageComponentType.Reply + delegate: ReplyComponent { + maxContentWidth: root.maxContentWidth + onReplyClicked: eventId => { + root.replyClicked(eventId); + } + } + } + + DelegateChoice { + roleValue: MessageComponentType.LinkPreview + delegate: LinkPreviewComponent { + maxContentWidth: root.maxContentWidth + onRemove: index => root.removeLinkPreview(index) + } + } + + DelegateChoice { + roleValue: MessageComponentType.LinkPreviewLoad + delegate: LinkPreviewLoadComponent { + type: LinkPreviewLoadComponent.LinkPreview + maxContentWidth: root.maxContentWidth + onRemove: index => root.removeLinkPreview(index) + } + } + + DelegateChoice { + roleValue: MessageComponentType.ChatBar + delegate: ChatBarComponent { + room: root.room + maxContentWidth: root.maxContentWidth + } + } + + DelegateChoice { + roleValue: MessageComponentType.ReplyButton + delegate: ReplyButtonComponent { + room: root.room + maxContentWidth: root.maxContentWidth + } + } + + DelegateChoice { + roleValue: MessageComponentType.Verification + delegate: MimeComponent { + mimeIconSource: "security-high" + label: i18n("%1 started a user verification", model.author.htmlSafeDisplayName) + } + } + + DelegateChoice { + roleValue: MessageComponentType.Loading + delegate: LoadComponent { + maxContentWidth: root.maxContentWidth + } + } + + DelegateChoice { + roleValue: MessageComponentType.Other + delegate: Item {} + } +} diff --git a/src/timeline/CMakeLists.txt b/src/timeline/CMakeLists.txt index 57db5717c..450865004 100644 --- a/src/timeline/CMakeLists.txt +++ b/src/timeline/CMakeLists.txt @@ -19,6 +19,7 @@ ecm_add_qml_module(timeline GENERATE_PLUGIN_SOURCE AvatarFlow.qml ReactionDelegate.qml SectionDelegate.qml + BaseMessageComponentChooser.qml MessageComponentChooser.qml ReplyMessageComponentChooser.qml AuthorComponent.qml @@ -50,6 +51,7 @@ ecm_add_qml_module(timeline GENERATE_PLUGIN_SOURCE ReplyComponent.qml StateComponent.qml TextComponent.qml + ThreadBodyComponent.qml VideoComponent.qml SOURCES timelinedelegate.cpp diff --git a/src/timeline/MessageComponentChooser.qml b/src/timeline/MessageComponentChooser.qml index af960ce49..686f64180 100644 --- a/src/timeline/MessageComponentChooser.qml +++ b/src/timeline/MessageComponentChooser.qml @@ -9,232 +9,16 @@ import org.kde.neochat /** * @brief Select a message component based on a MessageComponentType. */ -DelegateChooser { +BaseMessageComponentChooser { id: root - /** - * @brief The NeoChatRoom the delegate is being displayed in. - */ - required property NeoChatRoom room - - /** - * @brief The index of the delegate in the model. - */ - required property var index - - /** - * @brief The timeline ListView this component is being used in. - */ - required property ListView timeline - - /** - * @brief The maximum width that the bubble's content can be. - */ - property real maxContentWidth: -1 - - /** - * @brief The reply has been clicked. - */ - signal replyClicked(string eventID) - - /** - * @brief The user selected text has changed. - */ - signal selectedTextChanged(string selectedText) - - /** - * @brief The user hovered link has changed. - */ - signal hoveredLinkChanged(string hoveredLink) - - /** - * @brief Request a context menu be show for the message. - */ - signal showMessageMenu - - signal removeLinkPreview(int index) - - role: "componentType" - DelegateChoice { - roleValue: MessageComponentType.Author - delegate: AuthorComponent { - maxContentWidth: root.maxContentWidth - } - } - - DelegateChoice { - roleValue: MessageComponentType.Text - delegate: TextComponent { - maxContentWidth: root.maxContentWidth - onSelectedTextChanged: root.selectedTextChanged(selectedText) - onHoveredLinkChanged: root.hoveredLinkChanged(hoveredLink) - onShowMessageMenu: root.showMessageMenu() - } - } - - DelegateChoice { - roleValue: MessageComponentType.Image - delegate: ImageComponent { + roleValue: MessageComponentType.ThreadBody + delegate: ThreadBodyComponent { room: root.room index: root.index timeline: root.timeline maxContentWidth: root.maxContentWidth } } - - DelegateChoice { - roleValue: MessageComponentType.Video - delegate: VideoComponent { - room: root.room - index: root.index - timeline: root.timeline - maxContentWidth: root.maxContentWidth - } - } - - DelegateChoice { - roleValue: MessageComponentType.Code - delegate: CodeComponent { - maxContentWidth: root.maxContentWidth - onSelectedTextChanged: selectedText => { - root.selectedTextChanged(selectedText); - } - onShowMessageMenu: root.showMessageMenu() - } - } - - DelegateChoice { - roleValue: MessageComponentType.Quote - delegate: QuoteComponent { - maxContentWidth: root.maxContentWidth - onSelectedTextChanged: selectedText => { - root.selectedTextChanged(selectedText); - } - onShowMessageMenu: root.showMessageMenu() - } - } - - DelegateChoice { - roleValue: MessageComponentType.Audio - delegate: AudioComponent { - room: root.room - maxContentWidth: root.maxContentWidth - } - } - - DelegateChoice { - roleValue: MessageComponentType.File - delegate: FileComponent { - room: root.room - maxContentWidth: root.maxContentWidth - } - } - - DelegateChoice { - roleValue: MessageComponentType.Itinerary - delegate: ItineraryComponent { - maxContentWidth: root.maxContentWidth - } - } - - DelegateChoice { - roleValue: MessageComponentType.Pdf - delegate: PdfPreviewComponent { - maxContentWidth: root.maxContentWidth - } - } - - DelegateChoice { - roleValue: MessageComponentType.Poll - delegate: PollComponent { - room: root.room - maxContentWidth: root.maxContentWidth - } - } - - DelegateChoice { - roleValue: MessageComponentType.Location - delegate: LocationComponent { - maxContentWidth: root.maxContentWidth - } - } - - DelegateChoice { - roleValue: MessageComponentType.LiveLocation - delegate: LiveLocationComponent { - room: root.room - maxContentWidth: root.maxContentWidth - } - } - - DelegateChoice { - roleValue: MessageComponentType.Encrypted - delegate: EncryptedComponent { - maxContentWidth: root.maxContentWidth - } - } - - DelegateChoice { - roleValue: MessageComponentType.Reply - delegate: ReplyComponent { - maxContentWidth: root.maxContentWidth - onReplyClicked: eventId => { - root.replyClicked(eventId); - } - } - } - - DelegateChoice { - roleValue: MessageComponentType.LinkPreview - delegate: LinkPreviewComponent { - maxContentWidth: root.maxContentWidth - onRemove: index => root.removeLinkPreview(index) - } - } - - DelegateChoice { - roleValue: MessageComponentType.LinkPreviewLoad - delegate: LinkPreviewLoadComponent { - type: LinkPreviewLoadComponent.LinkPreview - maxContentWidth: root.maxContentWidth - onRemove: index => root.removeLinkPreview(index) - } - } - - DelegateChoice { - roleValue: MessageComponentType.ChatBar - delegate: ChatBarComponent { - room: root.room - maxContentWidth: root.maxContentWidth - } - } - - DelegateChoice { - roleValue: MessageComponentType.ReplyButton - delegate: ReplyButtonComponent { - room: root.room - maxContentWidth: root.maxContentWidth - } - } - - DelegateChoice { - roleValue: MessageComponentType.Verification - delegate: MimeComponent { - mimeIconSource: "security-high" - label: i18n("%1 started a user verification", model.author.htmlSafeDisplayName) - } - } - - DelegateChoice { - roleValue: MessageComponentType.Loading - delegate: LoadComponent { - maxContentWidth: root.maxContentWidth - } - } - - DelegateChoice { - roleValue: MessageComponentType.Other - delegate: Item {} - } } diff --git a/src/timeline/MessageDelegate.qml b/src/timeline/MessageDelegate.qml index 36775f67b..1d5d1a320 100644 --- a/src/timeline/MessageDelegate.qml +++ b/src/timeline/MessageDelegate.qml @@ -300,13 +300,7 @@ TimelineDelegate { showAuthor: root.showAuthor isThreaded: root.isThreaded - // 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.timelineMessageModel.threadModelForRootId(root.threadRoot); - } else { - return root.contentModel; - } + contentModel: root.contentModel timeline: root.ListView.view showHighlight: root.showHighlight diff --git a/src/timeline/ThreadBodyComponent.qml b/src/timeline/ThreadBodyComponent.qml new file mode 100644 index 000000000..7f23e0423 --- /dev/null +++ b/src/timeline/ThreadBodyComponent.qml @@ -0,0 +1,92 @@ +// 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.Layouts + +import org.kde.kirigami as Kirigami + +import org.kde.neochat + +/** + * @brief A component to visualize a ThreadModel. + * + * @sa ThreadModel + */ +ColumnLayout { + id: root + + /** + * @brief The NeoChatRoom the delegate is being displayed in. + */ + required property NeoChatRoom room + + /** + * @brief The index of the delegate in the model. + */ + required property var index + + /** + * @brief The Matrix ID of the root message in the thread, if any. + */ + required property string threadRoot + + /** + * @brief The timeline ListView this component is being used in. + */ + required property ListView timeline + + /** + * @brief The maximum width that the bubble's content can be. + */ + property real maxContentWidth: -1 + + /** + * @brief The reply has been clicked. + */ + signal replyClicked(string eventID) + + /** + * @brief The user selected text has changed. + */ + signal selectedTextChanged(string selectedText) + + /** + * @brief The user hovered link has changed. + */ + signal hoveredLinkChanged(string hoveredLink) + + /** + * @brief Request a context menu be show for the message. + */ + signal showMessageMenu + + Layout.fillWidth: true + Layout.fillHeight: true + Layout.maximumWidth: root.maxContentWidth + spacing: Kirigami.Units.smallSpacing + + Repeater { + id: threadRepeater + model: root.room.modelForThread(root.threadRoot); + + delegate: BaseMessageComponentChooser { + room: root.room + index: root.index + timeline: root.timeline + maxContentWidth: root.maxContentWidth + + onReplyClicked: eventId => { + root.replyClicked(eventId); + } + onSelectedTextChanged: selectedText => { + root.selectedTextChanged(selectedText); + } + onHoveredLinkChanged: hoveredLink => { + root.hoveredLinkChanged(hoveredLink); + } + onShowMessageMenu: root.showMessageMenu() + onRemoveLinkPreview: index => threadRepeater.model.closeLinkPreview(index) + } + } +}