Thread View

So at the moment this remains behind the feature flag as this only adds a threadmodel and a basic visualisation. There is much more to come to get it ready for full release.
This commit is contained in:
James Graham
2024-08-18 15:19:03 +00:00
parent 149013d2ff
commit 56d790dda9
17 changed files with 347 additions and 23 deletions

View File

@@ -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

View File

@@ -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<Mention> *mentions)

View File

@@ -396,6 +396,7 @@ QQC2.Control {
root.currentRoom.markAllMessagesAsRead();
textField.clear();
_private.chatBarCache.replyId = "";
_private.chatBarCache.threadId = "";
messageSent();
}

View File

@@ -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<QJsonObject>("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

View File

@@ -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.

View File

@@ -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<MessageComponent> 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;
}

View File

@@ -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<ThreadModel>(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"

View File

@@ -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<QString, std::unique_ptr<NeochatRoomMember>> m_memberObjects;
std::map<QString, std::unique_ptr<MessageContentModel>> m_contentModels;
QMap<QString, QSharedPointer<ReadMarkerModel>> m_readMarkerModels;
QMap<QString, QSharedPointer<ThreadModel>> m_threadModels;
QMap<QString, QSharedPointer<ReactionModel>> m_reactionModels;
[[nodiscard]] int timelineBaseIndex() const;

129
src/models/threadmodel.cpp Normal file
View File

@@ -0,0 +1,129 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#include "threadmodel.h"
#include <Quotient/csapi/relations.h>
#include <Quotient/events/event.h>
#include <Quotient/events/stickerevent.h>
#include <Quotient/jobs/basejob.h>
#include <Quotient/omittable.h>
#include <memory>
#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<MessageContentModel>(new MessageContentModel(room, threadRootId));
connect(room, &Quotient::Room::pendingEventAboutToAdd, this, [this](Quotient::RoomEvent *event) {
if (auto roomEvent = eventCast<const Quotient::RoomMessageEvent>(event)) {
EventHandler eventHandler(dynamic_cast<NeoChatRoom *>(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<const Quotient::RoomMessageEvent>(event)) {
EventHandler eventHandler(dynamic_cast<NeoChatRoom *>(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<int, QByteArray> 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<NeoChatRoom *>(QObject::parent());
const auto connection = room->connection();
m_currentJob =
connection->callApi<Quotient::GetRelatingEventsWithRelTypeJob>(room->id(), m_threadRootId, QLatin1String("m.thread"), *m_nextBatch, QString(), 5);
connect(m_currentJob, &Quotient::BaseJob::success, this, [this]() {
const auto room = dynamic_cast<NeoChatRoom *>(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<NeoChatRoom *>(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"

88
src/models/threadmodel.h Normal file
View File

@@ -0,0 +1,88 @@
// SPDX-FileCopyrightText: 2023 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <Quotient/util.h>
#include <QConcatenateTablesProxyModel>
#include <QQmlEngine>
#include <QPointer>
#include <Quotient/csapi/relations.h>
#include <Quotient/events/roomevent.h>
#include <Quotient/events/roommessageevent.h>
#include <deque>
#include <optional>
#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<int, QByteArray> 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<MessageContentModel> m_threadRootContentModel;
std::deque<MessageContentModel *> m_contentModels;
QList<QString> m_events;
QList<QString> m_pendingEvents;
std::unordered_map<QString, std::unique_ptr<Quotient::RoomEvent>> m_unloadedEvents;
QMap<QString, QSharedPointer<ReactionModel>> m_reactionModels;
QPointer<Quotient::GetRelatingEventsWithRelTypeJob> m_currentJob = nullptr;
std::optional<QString> m_nextBatch = QString();
bool m_addingPending = false;
void addNewEvent(const Quotient::RoomEvent *event);
void addModels();
void clearModels();
};

View File

@@ -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();

View File

@@ -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<Quotient::RoomMember> 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<QString, PollHandler> m_polls;
std::vector<Quotient::event_ptr_tt<Quotient::RoomEvent>> 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.

View File

@@ -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();

View File

@@ -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: {

View File

@@ -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();
}

View File

@@ -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.

View File

@@ -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