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) + } + } +}