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.
This commit is contained in:
James Graham
2026-02-19 16:08:35 +00:00
parent a235f39c84
commit d2d48110cb
16 changed files with 128 additions and 198 deletions

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ target_sources(LibNeoChat PRIVATE
neochatroommember.cpp
accountmanager.cpp
chatbarcache.cpp
blockcache.cpp
chatbarsyntaxhighlighter.cpp
chatkeyhelper.cpp
chatmarkdownhelper.cpp

View File

@@ -0,0 +1,25 @@
// SPDX-FileCopyrightText: 2026 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 "blockcache.h"
#include "chattextitemhelper.h"
using namespace Block;
void Cache::fill(QList<MessageComponent> components)
{
std::ranges::for_each(components, [this](const MessageComponent &component) {
if (!MessageComponentType::isTextType(component.type)) {
return;
}
const auto textItem = component.attributes["chatTextItemHelper"_L1].value<ChatTextItemHelper *>();
if (!textItem) {
return;
}
append(CacheItem{
.type = component.type,
.content = textItem->toFragment(),
});
});
}

View File

@@ -0,0 +1,28 @@
// SPDX-FileCopyrightText: 2026 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 <QList>
#include <QTextDocumentFragment>
#include "enums/messagecomponenttype.h"
#include "messagecomponent.h"
namespace Block
{
struct CacheItem {
MessageComponentType::Type type = MessageComponentType::Other;
QTextDocumentFragment content;
};
class Cache : private QList<CacheItem>
{
public:
using QList<CacheItem>::constBegin, QList<CacheItem>::constEnd;
using QList<CacheItem>::isEmpty;
using QList<CacheItem>::clear;
void fill(QList<MessageComponent> components);
};
}

View File

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

View File

@@ -8,22 +8,13 @@
#include <QQuickTextDocument>
#include <QTextCursor>
#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<Mention> *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<Mention> m_mentions;
QString m_savedText;
void clearCache();

View File

@@ -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<Kirigami::Platform::PlatformTheme *>(qmlAttachedPropertiesObject<Kirigami::Platform::PlatformTheme>(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());
}

View File

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

View File

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

View File

@@ -5,6 +5,8 @@
#include <QObject>
#include <QQuickItem>
#include <QTextDocumentFragment>
#include <qtextdocumentfragment.h>
#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<int> lineLength(int lineNumber) const;

View File

@@ -6,6 +6,8 @@
#include <QDebug>
#include <QTextCursor>
#include <Kirigami/Platform/PlatformTheme>
#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<Kirigami::Platform::PlatformTheme *>(qmlAttachedPropertiesObject<Kirigami::Platform::PlatformTheme>(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"

View File

@@ -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<NeoChatRoom> m_room;
ChatBarType::Type m_type = ChatBarType::None;
QPointer<ChatTextItemHelper> 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;
};

View File

@@ -73,7 +73,7 @@ QQC2.TextArea {
*/
property bool isReply: false
Layout.fillWidth: NeoChatConfig.compactLayout
Layout.fillWidth: true
Layout.maximumWidth: Message.maxContentWidth
Keys.onPressed: (event) => {

View File

@@ -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 &currentCache = m_room->cacheForType(m_type);
const auto &blockCache = currentCache->cache();
if (blockCache.isEmpty()) {
initializeModel();
return;
}
beginResetModel();
for (const auto &section : 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)

View File

@@ -143,7 +143,7 @@ private:
QPointer<ChatKeyHelper> 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);