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.
This commit is contained in:
James Graham
2025-01-17 19:18:44 +00:00
parent 111a45ab38
commit 7a949dccbb
12 changed files with 434 additions and 268 deletions

View File

@@ -53,6 +53,8 @@ public:
LinkPreview, /**< A preview of a URL in the message. */ LinkPreview, /**< A preview of a URL in the message. */
LinkPreviewLoad, /**< A loading dialog for a link preview. */ LinkPreviewLoad, /**< A loading dialog for a link preview. */
ChatBar, /**< A text edit for editing a message. */ 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. */ ReplyButton, /**< A button to reply in the current thread. */
Verification, /**< A user verification session start message. */ Verification, /**< A user verification session start message. */
Loading, /**< The component is loading. */ Loading, /**< The component is loading. */

View File

@@ -340,6 +340,17 @@ QVariant MessageContentModel::data(const QModelIndex &index, int role) const
if (role == ReplyContentModelRole) { if (role == ReplyContentModelRole) {
return QVariant::fromValue<MessageContentModel *>(m_replyModel); return QVariant::fromValue<MessageContentModel *>(m_replyModel);
} }
if (role == ThreadRootRole) {
auto roomMessageEvent = eventCast<const RoomMessageEvent>(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 (role == LinkPreviewerRole) {
if (component.type == MessageComponentType::LinkPreview) { if (component.type == MessageComponentType::LinkPreview) {
return QVariant::fromValue<LinkPreviewer *>( return QVariant::fromValue<LinkPreviewer *>(
@@ -366,27 +377,32 @@ int MessageContentModel::rowCount(const QModelIndex &parent) const
QHash<int, QByteArray> MessageContentModel::roleNames() const QHash<int, QByteArray> MessageContentModel::roleNames() const
{ {
QHash<int, QByteArray> roles = QAbstractItemModel::roleNames(); return roleNamesStatic();
roles[DisplayRole] = "display"; }
roles[ComponentTypeRole] = "componentType";
roles[ComponentAttributesRole] = "componentAttributes"; QHash<int, QByteArray> MessageContentModel::roleNamesStatic()
roles[EventIdRole] = "eventId"; {
roles[TimeRole] = "time"; QHash<int, QByteArray> roles;
roles[TimeStringRole] = "timeString"; roles[MessageContentModel::DisplayRole] = "display";
roles[AuthorRole] = "author"; roles[MessageContentModel::ComponentTypeRole] = "componentType";
roles[MediaInfoRole] = "mediaInfo"; roles[MessageContentModel::ComponentAttributesRole] = "componentAttributes";
roles[FileTransferInfoRole] = "fileTransferInfo"; roles[MessageContentModel::EventIdRole] = "eventId";
roles[ItineraryModelRole] = "itineraryModel"; roles[MessageContentModel::TimeRole] = "time";
roles[LatitudeRole] = "latitude"; roles[MessageContentModel::TimeStringRole] = "timeString";
roles[LongitudeRole] = "longitude"; roles[MessageContentModel::AuthorRole] = "author";
roles[AssetRole] = "asset"; roles[MessageContentModel::MediaInfoRole] = "mediaInfo";
roles[PollHandlerRole] = "pollHandler"; roles[MessageContentModel::FileTransferInfoRole] = "fileTransferInfo";
roles[ReplyEventIdRole] = "replyEventId"; roles[MessageContentModel::ItineraryModelRole] = "itineraryModel";
roles[ReplyAuthorRole] = "replyAuthor"; roles[MessageContentModel::LatitudeRole] = "latitude";
roles[ReplyContentModelRole] = "replyContentModel"; roles[MessageContentModel::LongitudeRole] = "longitude";
roles[ThreadRootRole] = "threadRoot"; roles[MessageContentModel::AssetRole] = "asset";
roles[LinkPreviewerRole] = "linkPreviewer"; roles[MessageContentModel::PollHandlerRole] = "pollHandler";
roles[ChatBarCacheRole] = "chatBarCache"; 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; return roles;
} }
@@ -464,6 +480,15 @@ QList<MessageComponent> MessageContentModel::messageContentComponents(bool isEdi
newComponents = addLinkPreviews(newComponents); 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 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 Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1)
if (isThreading && roomMessageEvent && !(roomMessageEvent->isThreaded() || m_room->threads().contains(roomMessageEvent->id()))) { if (isThreading && roomMessageEvent && !(roomMessageEvent->isThreaded() || m_room->threads().contains(roomMessageEvent->id()))) {

View File

@@ -91,6 +91,8 @@ public:
*/ */
[[nodiscard]] QHash<int, QByteArray> roleNames() const override; [[nodiscard]] QHash<int, QByteArray> roleNames() const override;
static QHash<int, QByteArray> roleNamesStatic();
/** /**
* @brief Close the link preview at the given index. * @brief Close the link preview at the given index.
* *

View File

@@ -7,7 +7,7 @@
#include <Quotient/events/roommessageevent.h> #include <Quotient/events/roommessageevent.h>
#include <Quotient/events/stickerevent.h> #include <Quotient/events/stickerevent.h>
#if Quotient_VERSION_MINOR > 9 #if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1)
#include <Quotient/thread.h> #include <Quotient/thread.h>
#endif #endif
@@ -120,6 +120,10 @@ QVariant MessageModel::data(const QModelIndex &idx, int role) const
} }
if (role == ContentModelRole) { if (role == ContentModelRole) {
auto roomMessageEvent = eventCast<const RoomMessageEvent>(&event.value().get());
if (roomMessageEvent && roomMessageEvent->isThreaded()) {
return QVariant::fromValue<MessageContentModel *>(m_room->contentModelForEvent(roomMessageEvent->threadRootEventId()));
}
return QVariant::fromValue<MessageContentModel *>(m_room->contentModelForEvent(&event->get())); return QVariant::fromValue<MessageContentModel *>(m_room->contentModelForEvent(&event->get()));
} }
@@ -169,7 +173,7 @@ QVariant MessageModel::data(const QModelIndex &idx, int role) const
} }
auto roomMessageEvent = eventCast<const RoomMessageEvent>(&event.value().get()); auto roomMessageEvent = eventCast<const RoomMessageEvent>(&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()))) { 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()); const auto &thread = m_room->threads().value(roomMessageEvent->isThreaded() ? roomMessageEvent->threadRootEventId() : event.value().get().id());
if (thread.latestEventId != event.value().get().id()) { if (thread.latestEventId != event.value().get().id()) {

View File

@@ -11,6 +11,7 @@
#include "chatbarcache.h" #include "chatbarcache.h"
#include "eventhandler.h" #include "eventhandler.h"
#include "messagecomponenttype.h" #include "messagecomponenttype.h"
#include "messagecontentmodel.h"
#include "neochatroom.h" #include "neochatroom.h"
ThreadModel::ThreadModel(const QString &threadRootId, NeoChatRoom *room) 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(!m_threadRootId.isEmpty());
Q_ASSERT(room); Q_ASSERT(room);
m_threadRootContentModel = std::unique_ptr<MessageContentModel>(new MessageContentModel(room, threadRootId));
#if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 0) #if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 0)
connect(room, &Quotient::Room::pendingEventAdded, this, [this](const Quotient::RoomEvent *event) { connect(room, &Quotient::Room::pendingEventAdded, this, [this](const Quotient::RoomEvent *event) {
#else #else
@@ -73,14 +72,9 @@ QString ThreadModel::threadRootId() const
return m_threadRootId; return m_threadRootId;
} }
MessageContentModel *ThreadModel::threadRootContentModel() const
{
return m_threadRootContentModel.get();
}
QHash<int, QByteArray> ThreadModel::roleNames() const QHash<int, QByteArray> ThreadModel::roleNames() const
{ {
return m_threadRootContentModel->roleNames(); return MessageContentModel::roleNamesStatic();
} }
bool ThreadModel::canFetchMore(const QModelIndex &parent) const bool ThreadModel::canFetchMore(const QModelIndex &parent) const
@@ -134,7 +128,6 @@ void ThreadModel::addModels()
clearModels(); clearModels();
} }
addSourceModel(m_threadRootContentModel.get());
const auto room = dynamic_cast<NeoChatRoom *>(QObject::parent()); const auto room = dynamic_cast<NeoChatRoom *>(QObject::parent());
if (room == nullptr) { if (room == nullptr) {
return; return;
@@ -153,8 +146,6 @@ void ThreadModel::addModels()
void ThreadModel::clearModels() void ThreadModel::clearModels()
{ {
removeSourceModel(m_threadRootContentModel.get());
const auto room = dynamic_cast<NeoChatRoom *>(QObject::parent()); const auto room = dynamic_cast<NeoChatRoom *>(QObject::parent());
if (room == nullptr) { if (room == nullptr) {
return; return;
@@ -168,6 +159,35 @@ void ThreadModel::clearModels()
removeSourceModel(m_threadChatBarModel); 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<MessageContentModel *>(model);
if (sourceContentModel == nullptr) {
return;
}
sourceContentModel->closeLinkPreview(sourceIndex.row());
}
}
}
ThreadChatBarModel::ThreadChatBarModel(QObject *parent, NeoChatRoom *room) ThreadChatBarModel::ThreadChatBarModel(QObject *parent, NeoChatRoom *room)
: QAbstractListModel(parent) : QAbstractListModel(parent)
, m_room(room) , m_room(room)

View File

@@ -91,11 +91,6 @@ public:
QString threadRootId() const; 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. * @brief Returns a mapping from Role enum values to role names.
* *
@@ -117,10 +112,16 @@ public:
*/ */
void fetchMore(const QModelIndex &parent) override; 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: private:
QString m_threadRootId; QString m_threadRootId;
QPointer<MessageContentModel> m_threadRootContentModel;
std::unique_ptr<MessageContentModel> m_threadRootContentModel;
std::deque<QString> m_events; std::deque<QString> m_events;
ThreadChatBarModel *m_threadChatBarModel; ThreadChatBarModel *m_threadChatBarModel;

View File

@@ -634,7 +634,7 @@ public:
* A model is created is one doesn't exist. Will return nullptr if threadRootId * A model is created is one doesn't exist. Will return nullptr if threadRootId
* is empty. * is empty.
*/ */
ThreadModel *modelForThread(const QString &threadRootId); Q_INVOKABLE ThreadModel *modelForThread(const QString &threadRootId);
private: private:
bool m_visible = false; bool m_visible = false;

View File

@@ -0,0 +1,240 @@
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// 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 {}
}
}

View File

@@ -19,6 +19,7 @@ ecm_add_qml_module(timeline GENERATE_PLUGIN_SOURCE
AvatarFlow.qml AvatarFlow.qml
ReactionDelegate.qml ReactionDelegate.qml
SectionDelegate.qml SectionDelegate.qml
BaseMessageComponentChooser.qml
MessageComponentChooser.qml MessageComponentChooser.qml
ReplyMessageComponentChooser.qml ReplyMessageComponentChooser.qml
AuthorComponent.qml AuthorComponent.qml
@@ -50,6 +51,7 @@ ecm_add_qml_module(timeline GENERATE_PLUGIN_SOURCE
ReplyComponent.qml ReplyComponent.qml
StateComponent.qml StateComponent.qml
TextComponent.qml TextComponent.qml
ThreadBodyComponent.qml
VideoComponent.qml VideoComponent.qml
SOURCES SOURCES
timelinedelegate.cpp timelinedelegate.cpp

View File

@@ -9,232 +9,16 @@ import org.kde.neochat
/** /**
* @brief Select a message component based on a MessageComponentType. * @brief Select a message component based on a MessageComponentType.
*/ */
DelegateChooser { BaseMessageComponentChooser {
id: root 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 { DelegateChoice {
roleValue: MessageComponentType.Author roleValue: MessageComponentType.ThreadBody
delegate: AuthorComponent { delegate: ThreadBodyComponent {
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 room: root.room
index: root.index index: root.index
timeline: root.timeline timeline: root.timeline
maxContentWidth: root.maxContentWidth 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 {}
}
} }

View File

@@ -300,13 +300,7 @@ TimelineDelegate {
showAuthor: root.showAuthor showAuthor: root.showAuthor
isThreaded: root.isThreaded isThreaded: root.isThreaded
// HACK: This is stupid but seemingly QConcatenateTablesProxyModel contentModel: root.contentModel
// 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;
}
timeline: root.ListView.view timeline: root.ListView.view
showHighlight: root.showHighlight showHighlight: root.showHighlight

View File

@@ -0,0 +1,92 @@
// SPDX-FileCopyrightText: 2025 James Graham <james.h.graham@protonmail.com>
// 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)
}
}
}