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