From d2d48110cb3bf08d5b522981fcf99680a84f5540 Mon Sep 17 00:00:00 2001 From: James Graham Date: Thu, 19 Feb 2026 16:08:35 +0000 Subject: [PATCH] Use the rich text char format to store mentions rather than a separate structure in ChatBarCache. This removes mentions from ChatBarCache and instead sets mentions as an anchor using the QTextCursor. Saving and restoring the chatbar text content is then done using QTextDocumentFragments which retain the rich text formatting. --- autotests/modeltest.cpp | 1 - src/chatbar/ChatBarCore.qml | 2 - src/libneochat/CMakeLists.txt | 1 + src/libneochat/blockcache.cpp | 25 +++++++ src/libneochat/blockcache.h | 28 ++++++++ src/libneochat/chatbarcache.cpp | 33 ++-------- src/libneochat/chatbarcache.h | 24 ++----- src/libneochat/chatbarsyntaxhighlighter.cpp | 38 ----------- src/libneochat/chatbarsyntaxhighlighter.h | 1 - src/libneochat/chattextitemhelper.cpp | 33 ++++++---- src/libneochat/chattextitemhelper.h | 19 ++++-- src/libneochat/models/completionmodel.cpp | 66 +++++-------------- src/libneochat/models/completionmodel.h | 22 ------- src/messagecontent/TextComponent.qml | 2 +- .../models/chatbarmessagecontentmodel.cpp | 29 ++++---- .../models/chatbarmessagecontentmodel.h | 2 +- 16 files changed, 128 insertions(+), 198 deletions(-) create mode 100644 src/libneochat/blockcache.cpp create mode 100644 src/libneochat/blockcache.h diff --git a/autotests/modeltest.cpp b/autotests/modeltest.cpp index 7c7424f07..2ca492ad8 100644 --- a/autotests/modeltest.cpp +++ b/autotests/modeltest.cpp @@ -400,7 +400,6 @@ void ModelTest::testCompletionModel() auto model = new CompletionModel(this); auto tester = new QAbstractItemModelTester(model, model); tester->setUseFetchMore(true); - model->setRoom(room); model->setAutoCompletionType(CompletionModel::Room); auto roomListModel = new RoomListModel(this); roomListModel->setConnection(connection); diff --git a/src/chatbar/ChatBarCore.qml b/src/chatbar/ChatBarCore.qml index dff408d4b..9eab4534c 100644 --- a/src/chatbar/ChatBarCore.qml +++ b/src/chatbar/ChatBarCore.qml @@ -30,8 +30,6 @@ QQC2.Control { } readonly property LibNeoChat.CompletionModel completionModel: LibNeoChat.CompletionModel { - room: root.room - type: root.chatBarType textItem: root.model.focusedTextItem roomListModel: RoomManager.roomListModel userListModel: RoomManager.userListModel diff --git a/src/libneochat/CMakeLists.txt b/src/libneochat/CMakeLists.txt index fac280dfd..228edc72d 100644 --- a/src/libneochat/CMakeLists.txt +++ b/src/libneochat/CMakeLists.txt @@ -9,6 +9,7 @@ target_sources(LibNeoChat PRIVATE neochatroommember.cpp accountmanager.cpp chatbarcache.cpp + blockcache.cpp chatbarsyntaxhighlighter.cpp chatkeyhelper.cpp chatmarkdownhelper.cpp diff --git a/src/libneochat/blockcache.cpp b/src/libneochat/blockcache.cpp new file mode 100644 index 000000000..68c671bae --- /dev/null +++ b/src/libneochat/blockcache.cpp @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2026 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#include "blockcache.h" + +#include "chattextitemhelper.h" + +using namespace Block; + +void Cache::fill(QList components) +{ + std::ranges::for_each(components, [this](const MessageComponent &component) { + if (!MessageComponentType::isTextType(component.type)) { + return; + } + const auto textItem = component.attributes["chatTextItemHelper"_L1].value(); + if (!textItem) { + return; + } + append(CacheItem{ + .type = component.type, + .content = textItem->toFragment(), + }); + }); +} diff --git a/src/libneochat/blockcache.h b/src/libneochat/blockcache.h new file mode 100644 index 000000000..e1a694f1d --- /dev/null +++ b/src/libneochat/blockcache.h @@ -0,0 +1,28 @@ +// SPDX-FileCopyrightText: 2026 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#pragma once + +#include +#include + +#include "enums/messagecomponenttype.h" +#include "messagecomponent.h" + +namespace Block +{ +struct CacheItem { + MessageComponentType::Type type = MessageComponentType::Other; + QTextDocumentFragment content; +}; + +class Cache : private QList +{ +public: + using QList::constBegin, QList::constEnd; + using QList::isEmpty; + using QList::clear; + + void fill(QList components); +}; +} diff --git a/src/libneochat/chatbarcache.cpp b/src/libneochat/chatbarcache.cpp index a7f1bf613..475f36497 100644 --- a/src/libneochat/chatbarcache.cpp +++ b/src/libneochat/chatbarcache.cpp @@ -33,6 +33,11 @@ ChatBarCache::ChatBarCache(QObject *parent) connect(this, &ChatBarCache::relationIdChanged, this, &ChatBarCache::relationAuthorIsPresentChanged); } +Block::Cache &ChatBarCache::cache() +{ + return m_cache; +} + QString ChatBarCache::text() const { return m_text; @@ -55,27 +60,7 @@ QString ChatBarCache::sendText() const return text().isEmpty() ? path.mid(path.lastIndexOf(u'/') + 1) : text(); } - return formatMentions(); -} - -QString ChatBarCache::formatMentions() const -{ - auto mentions = m_mentions; - std::sort(mentions.begin(), mentions.end(), [](const auto &a, const auto &b) { - return a.cursor.anchor() > b.cursor.anchor(); - }); - - auto formattedText = text(); - for (const auto &mention : mentions) { - if (mention.text.isEmpty() || mention.id.isEmpty()) { - continue; - } - formattedText = formattedText.replace(mention.cursor.anchor(), - mention.cursor.position() - mention.cursor.anchor(), - u"[%1](https://matrix.to/#/%2)"_s.arg(mention.text.toHtmlEscaped(), mention.id)); - } - - return formattedText; + return text(); } bool ChatBarCache::isReplying() const @@ -232,11 +217,6 @@ void ChatBarCache::clearRelations() Q_EMIT attachmentPathChanged(); } -QList *ChatBarCache::mentions() -{ - return &m_mentions; -} - QString ChatBarCache::savedText() const { return m_savedText; @@ -294,7 +274,6 @@ void ChatBarCache::postMessage() void ChatBarCache::clearCache() { setText({}); - m_mentions.clear(); m_savedText = QString(); clearRelations(); } diff --git a/src/libneochat/chatbarcache.h b/src/libneochat/chatbarcache.h index 22cd014dd..8e57c54e3 100644 --- a/src/libneochat/chatbarcache.h +++ b/src/libneochat/chatbarcache.h @@ -8,22 +8,13 @@ #include #include +#include "blockcache.h" + namespace Quotient { class RoomMember; } -/** - * @brief Defines a user mention in the current chat or edit text. - */ -struct Mention { - QTextCursor cursor; /**< Contains the mention's text and position in the text. */ - QString text; /**< The inserted text of the mention. */ - int start = 0; /**< Start position of the mention. */ - int position = 0; /**< End position of the mention. */ - QString id; /**< The id the mention (used to create link when sending the message). */ -}; - /** * @class ChatBarCache * @@ -147,6 +138,8 @@ public: explicit ChatBarCache(QObject *parent = nullptr); + Block::Cache &cache(); + QString text() const; QString sendText() const; void setText(const QString &text); @@ -178,11 +171,6 @@ public: */ Q_INVOKABLE void clearRelations(); - /** - * @brief Retrieve the mentions for the current chat bar text. - */ - QList *mentions(); - /** * @brief Get the saved chat bar text. */ @@ -209,14 +197,14 @@ Q_SIGNALS: void relationAuthorIsPresentChanged(); private: + Block::Cache m_cache; + QString m_text = QString(); - QString formatMentions() const; QString m_relationId = QString(); RelationType m_relationType = RelationType::None; QString m_threadId = QString(); QString m_attachmentPath = QString(); - QList m_mentions; QString m_savedText; void clearCache(); diff --git a/src/libneochat/chatbarsyntaxhighlighter.cpp b/src/libneochat/chatbarsyntaxhighlighter.cpp index 0933a7254..9dbc43dd9 100644 --- a/src/libneochat/chatbarsyntaxhighlighter.cpp +++ b/src/libneochat/chatbarsyntaxhighlighter.cpp @@ -4,22 +4,16 @@ #include "chatbarsyntaxhighlighter.h" -#include "chatbarcache.h" #include "chattextitemhelper.h" -#include "enums/chatbartype.h" ChatBarSyntaxHighlighter::ChatBarSyntaxHighlighter(QObject *parent) : QSyntaxHighlighter(parent) { m_theme = static_cast(qmlAttachedPropertiesObject(this, true)); connect(m_theme, &Kirigami::Platform::PlatformTheme::colorsChanged, this, [this]() { - m_mentionFormat.setForeground(m_theme->linkColor()); m_errorFormat.setForeground(m_theme->negativeTextColor()); }); - m_mentionFormat.setFontWeight(QFont::Bold); - m_mentionFormat.setForeground(m_theme->linkColor()); - m_errorFormat.setForeground(m_theme->negativeTextColor()); m_errorFormat.setUnderlineStyle(QTextCharFormat::SpellCheckUnderline); @@ -48,36 +42,4 @@ void ChatBarSyntaxHighlighter::highlightBlock(const QString &text) setFormat(error.first, error.second.size(), m_errorFormat); } } - - if (!room || type == ChatBarType::None) { - return; - } - auto mentions = room->cacheForType(type)->mentions(); - mentions->erase(std::remove_if(mentions->begin(), - mentions->end(), - [this](auto &mention) { - if (document()->toPlainText().isEmpty()) { - return false; - } - - if (mention.cursor.position() == 0 && mention.cursor.anchor() == 0) { - return true; - } - - if (mention.cursor.position() - mention.cursor.anchor() != mention.text.size()) { - mention.cursor.setPosition(mention.start); - mention.cursor.setPosition(mention.cursor.anchor() + mention.text.size(), QTextCursor::KeepAnchor); - } - - if (mention.cursor.selectedText() != mention.text) { - return true; - } - if (currentBlock() == mention.cursor.block()) { - mention.start = mention.cursor.anchor(); - mention.position = mention.cursor.position(); - setFormat(mention.cursor.selectionStart(), mention.cursor.selectedText().size(), m_mentionFormat); - } - return false; - }), - mentions->end()); } diff --git a/src/libneochat/chatbarsyntaxhighlighter.h b/src/libneochat/chatbarsyntaxhighlighter.h index 19acc60c9..2e60223eb 100644 --- a/src/libneochat/chatbarsyntaxhighlighter.h +++ b/src/libneochat/chatbarsyntaxhighlighter.h @@ -33,7 +33,6 @@ public: private: Kirigami::Platform::PlatformTheme *m_theme = nullptr; - QTextCharFormat m_mentionFormat; QTextCharFormat m_errorFormat; Sonnet::BackgroundChecker *m_checker = new Sonnet::BackgroundChecker(this); diff --git a/src/libneochat/chattextitemhelper.cpp b/src/libneochat/chattextitemhelper.cpp index 2cf0b0323..b80ca4304 100644 --- a/src/libneochat/chattextitemhelper.cpp +++ b/src/libneochat/chattextitemhelper.cpp @@ -92,7 +92,7 @@ void ChatTextItemHelper::setTextItem(QQuickItem *textItem) connect(doc, &QTextDocument::contentsChange, this, &ChatTextItemHelper::contentsChange); m_highlighter->setDocument(doc); } - initializeChars(); + initialize(); } Q_EMIT textItemChanged(); @@ -133,24 +133,21 @@ void ChatTextItemHelper::setFixedChars(const QString &startChars, const QString } m_fixedStartChars = startChars; m_fixedEndChars = endChars; - initializeChars(); + initialize(); } -QString ChatTextItemHelper::initialText() const +QTextDocumentFragment ChatTextItemHelper::initialFragment() const { - return m_initialText; + return m_initialFragment; } -void ChatTextItemHelper::setInitialText(const QString &text) +void ChatTextItemHelper::setInitialFragment(const QTextDocumentFragment &fragment) { - if (text == m_initialText) { - return; - } - m_initialText = text; - initializeChars(); + m_initialFragment = fragment; + initialize(); } -void ChatTextItemHelper::initializeChars() +void ChatTextItemHelper::initialize() { const auto doc = document(); if (!doc) { @@ -166,8 +163,8 @@ void ChatTextItemHelper::initializeChars() cursor.beginEditBlock(); int finalCursorPos = cursor.position(); - if (doc->isEmpty() && !m_initialText.isEmpty()) { - cursor.insertText(m_initialText); + if (doc->isEmpty() && !m_initialFragment.isEmpty()) { + cursor.insertFragment(m_initialFragment); finalCursorPos = cursor.position(); } @@ -618,6 +615,16 @@ QString ChatTextItemHelper::plainText() const return trim(doc->toPlainText()); } +QTextDocumentFragment ChatTextItemHelper::toFragment() const +{ + auto cursor = textCursor(); + if (cursor.isNull()) { + return {}; + } + cursor.select(QTextCursor::Document); + return cursor.selection(); +} + QString ChatTextItemHelper::trim(QString string) const { while (string.startsWith(u"\n"_s)) { diff --git a/src/libneochat/chattextitemhelper.h b/src/libneochat/chattextitemhelper.h index 3a1bee607..6c707de06 100644 --- a/src/libneochat/chattextitemhelper.h +++ b/src/libneochat/chattextitemhelper.h @@ -5,6 +5,8 @@ #include #include +#include +#include #include "enums/chatbartype.h" #include "enums/richformat.h" @@ -108,16 +110,16 @@ public: void setFixedChars(const QString &startChars, const QString &endChars); /** - * @brief Any text to initialise the text item with when set. + * @brief Any QTextDocumentFragment to initialise the text item with when set. */ - QString initialText() const; + QTextDocumentFragment initialFragment() const; /** - * @brief Set the text to initialise the text item with when set. + * @brief Set the QTextDocumentFragment to initialise the text item with when set. * * This text will only be set if the text item is empty when set. */ - void setInitialText(const QString &text); + void setInitialFragment(const QTextDocumentFragment &fragment); /** * @brief The underlying QTextDocument. @@ -248,6 +250,11 @@ public: */ QString plainText() const; + /** + * @brief Output the text in the text item as a QTextDocumentFragment. + */ + QTextDocumentFragment toFragment() const; + Q_SIGNALS: /** * @brief Emitted when the NeoChatRoom used by the syntax highlighter is changed. @@ -309,8 +316,8 @@ private: QString m_fixedStartChars = {}; QString m_fixedEndChars = {}; - QString m_initialText = {}; - void initializeChars(); + QTextDocumentFragment m_initialFragment = {}; + void initialize(); bool m_initializingChars = false; std::optional lineLength(int lineNumber) const; diff --git a/src/libneochat/models/completionmodel.cpp b/src/libneochat/models/completionmodel.cpp index ec88f07c8..cba2501a9 100644 --- a/src/libneochat/models/completionmodel.cpp +++ b/src/libneochat/models/completionmodel.cpp @@ -6,6 +6,8 @@ #include #include +#include + #include "chattextitemhelper.h" #include "completionproxymodel.h" #include "models/actionsmodel.h" @@ -24,35 +26,6 @@ CompletionModel::CompletionModel(QObject *parent) m_emojiModel->addSourceModel(&EmojiModel::instance()); } -NeoChatRoom *CompletionModel::room() const -{ - return m_room; -} - -void CompletionModel::setRoom(NeoChatRoom *room) -{ - if (m_room == room) { - return; - } - - m_room = room; - Q_EMIT roomChanged(); -} - -ChatBarType::Type CompletionModel::type() const -{ - return m_type; -} - -void CompletionModel::setType(ChatBarType::Type type) -{ - if (type == m_type) { - return; - } - m_type = type; - Q_EMIT typeChanged(); -} - ChatTextItemHelper *CompletionModel::textItem() const { return m_textItem; @@ -322,29 +295,22 @@ void CompletionModel::insertCompletion(const QString &text, const QUrl &link) } cursor.removeSelectedText(); - const int start = cursor.position(); - const auto insertString = u"%1 %2"_s.arg(text, link.isEmpty() ? QString() : u" "_s); - cursor.insertText(insertString); - cursor.setPosition(start); - cursor.setPosition(start + text.size(), QTextCursor::KeepAnchor); - cursor.setKeepPositionOnInsert(true); - cursor.endEditBlock(); - if (!link.isEmpty()) { - pushMention({ - .cursor = cursor, - .text = text, - .id = link.toString(), - }); + const auto previousFormat = cursor.charFormat(); + auto charFormat = previousFormat; + if (link.isValid()) { + const auto theme = static_cast(qmlAttachedPropertiesObject(this, true)); + charFormat = QTextCharFormat(); + charFormat.setForeground(theme->linkColor()); + charFormat.setFontWeight(QFont::Bold); + charFormat.setAnchor(true); + charFormat.setAnchorHref(link.toString()); } + cursor.insertText(text, charFormat); + if (!link.isEmpty()) { + cursor.insertText(u" "_s, previousFormat); + } + cursor.endEditBlock(); m_textItem->rehighlight(); } -void CompletionModel::pushMention(const Mention mention) const -{ - if (!m_room || m_type == ChatBarType::None) { - return; - } - m_room->cacheForType(m_type)->mentions()->push_back(mention); -} - #include "moc_completionmodel.cpp" diff --git a/src/libneochat/models/completionmodel.h b/src/libneochat/models/completionmodel.h index b6fc3e8d5..99a34d6ae 100644 --- a/src/libneochat/models/completionmodel.h +++ b/src/libneochat/models/completionmodel.h @@ -30,16 +30,6 @@ class CompletionModel : public QAbstractListModel Q_OBJECT QML_ELEMENT - /** - * @brief The current room that the text document is being handled for. - */ - Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged) - - /** - * @brief The ChatBarType::Type of the chat bar. - */ - Q_PROPERTY(ChatBarType::Type type READ type WRITE setType NOTIFY typeChanged) - /** * @brief The QML text Item that completions are being provided for. */ @@ -94,12 +84,6 @@ public: explicit CompletionModel(QObject *parent = nullptr); - NeoChatRoom *room() const; - void setRoom(NeoChatRoom *room); - - ChatBarType::Type type() const; - void setType(ChatBarType::Type type); - ChatTextItemHelper *textItem() const; void setTextItem(ChatTextItemHelper *textItem); @@ -140,8 +124,6 @@ public: Q_INVOKABLE void insertCompletion(const QString &text, const QUrl &link); Q_SIGNALS: - void roomChanged(); - void typeChanged(); void textItemChanged(); void autoCompletionTypeChanged(); void roomListModelChanged(); @@ -149,8 +131,6 @@ Q_SIGNALS: void isCompletingChanged(); private: - QPointer m_room; - ChatBarType::Type m_type = ChatBarType::None; QPointer m_textItem; bool m_ignoreCurrentCompletion = false; @@ -165,6 +145,4 @@ private: UserListModel *m_userListModel; RoomListModel *m_roomListModel; QConcatenateTablesProxyModel *m_emojiModel; - - void pushMention(const Mention mention) const; }; diff --git a/src/messagecontent/TextComponent.qml b/src/messagecontent/TextComponent.qml index 40ae4932e..5818f6f3b 100644 --- a/src/messagecontent/TextComponent.qml +++ b/src/messagecontent/TextComponent.qml @@ -73,7 +73,7 @@ QQC2.TextArea { */ property bool isReply: false - Layout.fillWidth: NeoChatConfig.compactLayout + Layout.fillWidth: true Layout.maximumWidth: Message.maxContentWidth Keys.onPressed: (event) => { diff --git a/src/messagecontent/models/chatbarmessagecontentmodel.cpp b/src/messagecontent/models/chatbarmessagecontentmodel.cpp index 8933cc0b1..814d9361a 100644 --- a/src/messagecontent/models/chatbarmessagecontentmodel.cpp +++ b/src/messagecontent/models/chatbarmessagecontentmodel.cpp @@ -104,7 +104,7 @@ void ChatBarMessageContentModel::initializeModel(const QString &initialText) const auto textItem = new ChatTextItemHelper(this); textItem->setRoom(m_room); textItem->setType(m_type); - textItem->setInitialText(initialText); + textItem->setInitialFragment(QTextDocumentFragment::fromPlainText(initialText)); connectTextItem(textItem); m_components += MessageComponent{ .type = MessageComponentType::Text, @@ -125,25 +125,17 @@ void ChatBarMessageContentModel::initializeFromCache() clearModel(); - const auto currentCache = m_room->cacheForType(m_type); - const auto textSections = (m_type == ChatBarType::Room ? currentCache->text() : currentCache->relationMessage()).split(u"\n\n"_s); - if (textSections.length() == 1 && textSections[0].isEmpty()) { + const auto ¤tCache = m_room->cacheForType(m_type); + const auto &blockCache = currentCache->cache(); + if (blockCache.isEmpty()) { initializeModel(); return; } beginResetModel(); - for (const auto §ion : textSections) { - const auto type = MessageComponentType::typeForString(section); - auto cleanText = section; - if (type == MessageComponentType::Code) { - cleanText.remove(0, 4); - cleanText.remove(cleanText.length() - 4, 4); - } else if (type == MessageComponentType::Quote) { - cleanText.remove(0, 2); - } - insertComponent(rowCount(), type, {}, cleanText); - } + std::ranges::for_each(blockCache.constBegin(), blockCache.constEnd(), [this](const Block::CacheItem &cacheItem) { + insertComponent(rowCount(), cacheItem.type, {}, cacheItem.content); + }); endResetModel(); if (currentCache->attachmentPath().length() > 0) { @@ -390,7 +382,7 @@ void ChatBarMessageContentModel::addAttachment(const QUrl &path) } ChatBarMessageContentModel::ComponentIt -ChatBarMessageContentModel::insertComponent(int row, MessageComponentType::Type type, QVariantMap attributes, const QString &intialText) +ChatBarMessageContentModel::insertComponent(int row, MessageComponentType::Type type, QVariantMap attributes, const QTextDocumentFragment &intialFragment) { if (row < 0 || row > rowCount()) { return m_components.end(); @@ -398,7 +390,7 @@ ChatBarMessageContentModel::insertComponent(int row, MessageComponentType::Type if (MessageComponentType::isTextType(type)) { const auto textItemWrapper = new ChatTextItemHelper(this); - textItemWrapper->setInitialText(intialText); + textItemWrapper->setInitialFragment(intialFragment); textItemWrapper->setRoom(m_room); textItemWrapper->setType(m_type); if (type == MessageComponentType::Quote) { @@ -600,7 +592,8 @@ void ChatBarMessageContentModel::updateCache() const return; } - m_room->cacheForType(m_type)->setText(messageText()); + m_room->cacheForType(m_type)->cache().clear(); + m_room->cacheForType(m_type)->cache().fill(m_components); } inline QString formatQuote(const QString &input) diff --git a/src/messagecontent/models/chatbarmessagecontentmodel.h b/src/messagecontent/models/chatbarmessagecontentmodel.h index 24f9f0480..99ee6f4bf 100644 --- a/src/messagecontent/models/chatbarmessagecontentmodel.h +++ b/src/messagecontent/models/chatbarmessagecontentmodel.h @@ -143,7 +143,7 @@ private: QPointer m_keyHelper; void connectKeyHelper(); - ComponentIt insertComponent(int row, MessageComponentType::Type type, QVariantMap attributes = {}, const QString &intialText = {}); + ComponentIt insertComponent(int row, MessageComponentType::Type type, QVariantMap attributes = {}, const QTextDocumentFragment &intialFragment = {}); ComponentIt removeComponent(ComponentIt it); void removeComponent(ChatTextItemHelper *textItem);