This is easy to reproduce in the following scenario with a bunch of half-completed emojis: ":a :a :a :a". Trying to complete anything but the last one starts replacing parts of the message because it only considers the last colon to the current completion identifier. This change fixes that and said scenario can no longer cause a message massacare. This bug doesn't seem to affect the other completions because their searching in the string was correct, but I made sure they all share the same index now. BUG: 479587 FIXED-IN: 24.12.3
360 lines
12 KiB
C++
360 lines
12 KiB
C++
// SPDX-FileCopyrightText: 2020 Carl Schwan <carlschwan@kde.org>
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
#include "chatdocumenthandler.h"
|
|
|
|
#include <QQmlFile>
|
|
#include <QQmlFileSelector>
|
|
#include <QStringBuilder>
|
|
#include <QSyntaxHighlighter>
|
|
#include <QTextBlock>
|
|
#include <QTextDocument>
|
|
#include <QTimer>
|
|
|
|
#include <Sonnet/BackgroundChecker>
|
|
#include <Sonnet/Settings>
|
|
|
|
#include "chatdocumenthandler_logging.h"
|
|
|
|
using namespace Qt::StringLiterals;
|
|
|
|
class SyntaxHighlighter : public QSyntaxHighlighter
|
|
{
|
|
public:
|
|
QTextCharFormat mentionFormat;
|
|
QTextCharFormat errorFormat;
|
|
Sonnet::BackgroundChecker *checker = new Sonnet::BackgroundChecker;
|
|
Sonnet::Settings settings;
|
|
QList<QPair<int, QString>> errors;
|
|
QString previousText;
|
|
QTimer rehighlightTimer;
|
|
SyntaxHighlighter(QObject *parent)
|
|
: QSyntaxHighlighter(parent)
|
|
{
|
|
mentionFormat.setFontWeight(QFont::Bold);
|
|
mentionFormat.setForeground(Qt::blue);
|
|
|
|
errorFormat.setForeground(Qt::red);
|
|
errorFormat.setUnderlineStyle(QTextCharFormat::SpellCheckUnderline);
|
|
|
|
connect(checker, &Sonnet::BackgroundChecker::misspelling, this, [this](const QString &word, int start) {
|
|
errors += {start, word};
|
|
checker->continueChecking();
|
|
});
|
|
connect(checker, &Sonnet::BackgroundChecker::done, this, [this]() {
|
|
rehighlightTimer.start();
|
|
});
|
|
rehighlightTimer.setInterval(100);
|
|
rehighlightTimer.setSingleShot(true);
|
|
rehighlightTimer.callOnTimeout(this, &QSyntaxHighlighter::rehighlight);
|
|
}
|
|
void highlightBlock(const QString &text) override
|
|
{
|
|
if (settings.checkerEnabledByDefault()) {
|
|
if (text != previousText) {
|
|
previousText = text;
|
|
checker->stop();
|
|
errors.clear();
|
|
checker->setText(text);
|
|
}
|
|
for (const auto &error : errors) {
|
|
setFormat(error.first, error.second.size(), errorFormat);
|
|
}
|
|
}
|
|
auto handler = dynamic_cast<ChatDocumentHandler *>(parent());
|
|
auto room = handler->room();
|
|
if (!room) {
|
|
return;
|
|
}
|
|
auto mentions = handler->chatBarCache()->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(), mentionFormat);
|
|
}
|
|
return false;
|
|
}),
|
|
mentions->end());
|
|
}
|
|
};
|
|
|
|
ChatDocumentHandler::ChatDocumentHandler(QObject *parent)
|
|
: QObject(parent)
|
|
, m_document(nullptr)
|
|
, m_cursorPosition(-1)
|
|
, m_highlighter(new SyntaxHighlighter(this))
|
|
, m_completionModel(new CompletionModel(this))
|
|
{
|
|
connect(this, &ChatDocumentHandler::roomChanged, this, [this]() {
|
|
m_completionModel->setRoom(m_room);
|
|
static QPointer<NeoChatRoom> previousRoom = nullptr;
|
|
if (previousRoom) {
|
|
disconnect(m_chatBarCache, &ChatBarCache::textChanged, this, nullptr);
|
|
}
|
|
previousRoom = m_room;
|
|
connect(m_chatBarCache, &ChatBarCache::textChanged, this, [this]() {
|
|
int start = completionStartIndex();
|
|
m_completionModel->setText(getText().mid(start, cursorPosition() - start), getText().mid(start));
|
|
});
|
|
});
|
|
connect(this, &ChatDocumentHandler::documentChanged, this, [this]() {
|
|
m_highlighter->setDocument(m_document->textDocument());
|
|
});
|
|
connect(this, &ChatDocumentHandler::cursorPositionChanged, this, [this]() {
|
|
if (!m_room) {
|
|
return;
|
|
}
|
|
int start = completionStartIndex();
|
|
m_completionModel->setText(getText().mid(start, cursorPosition() - start), getText().mid(start));
|
|
});
|
|
}
|
|
|
|
int ChatDocumentHandler::completionStartIndex() const
|
|
{
|
|
if (!m_room) {
|
|
return 0;
|
|
}
|
|
|
|
const qsizetype cursor = cursorPosition();
|
|
const auto &text = getText();
|
|
|
|
auto start = std::min(cursor, text.size()) - 1;
|
|
while (start > -1) {
|
|
if (text.at(start) == QLatin1Char(' ')) {
|
|
start++;
|
|
break;
|
|
}
|
|
start--;
|
|
}
|
|
return start;
|
|
}
|
|
|
|
QQuickTextDocument *ChatDocumentHandler::document() const
|
|
{
|
|
return m_document;
|
|
}
|
|
|
|
void ChatDocumentHandler::setDocument(QQuickTextDocument *document)
|
|
{
|
|
if (document == m_document) {
|
|
return;
|
|
}
|
|
|
|
if (m_document) {
|
|
m_document->textDocument()->disconnect(this);
|
|
}
|
|
m_document = document;
|
|
Q_EMIT documentChanged();
|
|
}
|
|
|
|
int ChatDocumentHandler::cursorPosition() const
|
|
{
|
|
return m_cursorPosition;
|
|
}
|
|
|
|
void ChatDocumentHandler::setCursorPosition(int position)
|
|
{
|
|
if (position == m_cursorPosition) {
|
|
return;
|
|
}
|
|
if (m_room) {
|
|
m_cursorPosition = position;
|
|
}
|
|
Q_EMIT cursorPositionChanged();
|
|
}
|
|
|
|
NeoChatRoom *ChatDocumentHandler::room() const
|
|
{
|
|
return m_room;
|
|
}
|
|
|
|
void ChatDocumentHandler::setRoom(NeoChatRoom *room)
|
|
{
|
|
if (m_room == room) {
|
|
return;
|
|
}
|
|
|
|
m_room = room;
|
|
Q_EMIT roomChanged();
|
|
}
|
|
|
|
ChatBarCache *ChatDocumentHandler::chatBarCache() const
|
|
{
|
|
return m_chatBarCache;
|
|
}
|
|
|
|
void ChatDocumentHandler::setChatBarCache(ChatBarCache *chatBarCache)
|
|
{
|
|
if (m_chatBarCache == chatBarCache) {
|
|
return;
|
|
}
|
|
m_chatBarCache = chatBarCache;
|
|
Q_EMIT chatBarCacheChanged();
|
|
}
|
|
|
|
void ChatDocumentHandler::complete(int index)
|
|
{
|
|
if (m_document == nullptr) {
|
|
qCWarning(ChatDocumentHandling) << "complete called with m_document set to nullptr.";
|
|
return;
|
|
}
|
|
if (m_completionModel->autoCompletionType() == CompletionModel::None) {
|
|
qCWarning(ChatDocumentHandling) << "complete called with m_completionModel->autoCompletionType() == CompletionModel::None.";
|
|
return;
|
|
}
|
|
|
|
// Ensure we only search for the beginning of the current completion identifier
|
|
const auto fromIndex = qMax(completionStartIndex(), 0);
|
|
|
|
if (m_completionModel->autoCompletionType() == CompletionModel::User) {
|
|
auto name = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::DisplayNameRole).toString();
|
|
auto id = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::SubtitleRole).toString();
|
|
auto text = getText();
|
|
auto at = text.indexOf(QLatin1Char('@'), fromIndex);
|
|
QTextCursor cursor(document()->textDocument());
|
|
cursor.setPosition(at);
|
|
cursor.setPosition(cursorPosition(), QTextCursor::KeepAnchor);
|
|
cursor.insertText(name + u" "_s);
|
|
cursor.setPosition(at);
|
|
cursor.setPosition(cursor.position() + name.size(), QTextCursor::KeepAnchor);
|
|
cursor.setKeepPositionOnInsert(true);
|
|
pushMention({cursor, name, 0, 0, id});
|
|
m_highlighter->rehighlight();
|
|
} else if (m_completionModel->autoCompletionType() == CompletionModel::Command) {
|
|
auto command = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::ReplacedTextRole).toString();
|
|
auto text = getText();
|
|
auto at = text.indexOf(QLatin1Char('/'), fromIndex);
|
|
QTextCursor cursor(document()->textDocument());
|
|
cursor.setPosition(at);
|
|
cursor.setPosition(cursorPosition(), QTextCursor::KeepAnchor);
|
|
cursor.insertText(u"/%1 "_s.arg(command));
|
|
} else if (m_completionModel->autoCompletionType() == CompletionModel::Room) {
|
|
auto alias = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::SubtitleRole).toString();
|
|
auto text = getText();
|
|
auto at = text.indexOf(QLatin1Char('#'), fromIndex);
|
|
QTextCursor cursor(document()->textDocument());
|
|
cursor.setPosition(at);
|
|
cursor.setPosition(cursorPosition(), QTextCursor::KeepAnchor);
|
|
cursor.insertText(alias + u" "_s);
|
|
cursor.setPosition(at);
|
|
cursor.setPosition(cursor.position() + alias.size(), QTextCursor::KeepAnchor);
|
|
cursor.setKeepPositionOnInsert(true);
|
|
pushMention({cursor, alias, 0, 0, alias});
|
|
m_highlighter->rehighlight();
|
|
} else if (m_completionModel->autoCompletionType() == CompletionModel::Emoji) {
|
|
auto shortcode = m_completionModel->data(m_completionModel->index(index, 0), CompletionModel::ReplacedTextRole).toString();
|
|
auto text = getText();
|
|
auto at = text.indexOf(QLatin1Char(':'), fromIndex);
|
|
QTextCursor cursor(document()->textDocument());
|
|
cursor.setPosition(at);
|
|
cursor.setPosition(cursorPosition(), QTextCursor::KeepAnchor);
|
|
cursor.insertText(shortcode);
|
|
}
|
|
}
|
|
|
|
CompletionModel *ChatDocumentHandler::completionModel() const
|
|
{
|
|
return m_completionModel;
|
|
}
|
|
|
|
int ChatDocumentHandler::selectionStart() const
|
|
{
|
|
return m_selectionStart;
|
|
}
|
|
|
|
void ChatDocumentHandler::setSelectionStart(int position)
|
|
{
|
|
if (position == m_selectionStart) {
|
|
return;
|
|
}
|
|
|
|
m_selectionStart = position;
|
|
Q_EMIT selectionStartChanged();
|
|
}
|
|
|
|
int ChatDocumentHandler::selectionEnd() const
|
|
{
|
|
return m_selectionEnd;
|
|
}
|
|
|
|
void ChatDocumentHandler::setSelectionEnd(int position)
|
|
{
|
|
if (position == m_selectionEnd) {
|
|
return;
|
|
}
|
|
|
|
m_selectionEnd = position;
|
|
Q_EMIT selectionEndChanged();
|
|
}
|
|
|
|
QString ChatDocumentHandler::getText() const
|
|
{
|
|
if (!m_chatBarCache) {
|
|
qCWarning(ChatDocumentHandling) << "getText called with m_chatBarCache set to nullptr.";
|
|
return {};
|
|
}
|
|
return m_chatBarCache->text();
|
|
}
|
|
|
|
void ChatDocumentHandler::pushMention(const Mention mention) const
|
|
{
|
|
if (!m_chatBarCache) {
|
|
qCWarning(ChatDocumentHandling) << "pushMention called with m_chatBarCache set to nullptr.";
|
|
return;
|
|
}
|
|
m_chatBarCache->mentions()->push_back(mention);
|
|
}
|
|
|
|
QColor ChatDocumentHandler::mentionColor() const
|
|
{
|
|
return m_mentionColor;
|
|
}
|
|
|
|
void ChatDocumentHandler::setMentionColor(const QColor &color)
|
|
{
|
|
if (m_mentionColor == color) {
|
|
return;
|
|
}
|
|
m_mentionColor = color;
|
|
m_highlighter->mentionFormat.setForeground(m_mentionColor);
|
|
m_highlighter->rehighlight();
|
|
Q_EMIT mentionColorChanged();
|
|
}
|
|
|
|
QColor ChatDocumentHandler::errorColor() const
|
|
{
|
|
return m_errorColor;
|
|
}
|
|
|
|
void ChatDocumentHandler::setErrorColor(const QColor &color)
|
|
{
|
|
if (m_errorColor == color) {
|
|
return;
|
|
}
|
|
m_errorColor = color;
|
|
m_highlighter->errorFormat.setForeground(m_errorColor);
|
|
m_highlighter->rehighlight();
|
|
Q_EMIT errorColorChanged();
|
|
}
|
|
|
|
#include "moc_chatdocumenthandler.cpp"
|