Split text section into blocks
The aim is to be able to use separate delegate for things like codeblocks and quotes so that they can be styled differently. 
This commit is contained in:
@@ -10,7 +10,9 @@
|
||||
#include <Quotient/syncdata.h>
|
||||
#include <qnamespace.h>
|
||||
|
||||
#include "enums/messagecomponenttype.h"
|
||||
#include "models/customemojimodel.h"
|
||||
#include "models/messagecontentmodel.h"
|
||||
#include "neochatconnection.h"
|
||||
#include "utils.h"
|
||||
|
||||
@@ -33,7 +35,6 @@ private Q_SLOTS:
|
||||
void stripDisallowedTags();
|
||||
void stripDisallowedAttributes();
|
||||
void emptyCodeTags();
|
||||
void formatBlockQuote();
|
||||
|
||||
void sendSimpleStringCase();
|
||||
void sendSingleParaMarkup();
|
||||
@@ -59,11 +60,13 @@ private Q_SLOTS:
|
||||
void receiveRichtextIn();
|
||||
void receiveRichMxcUrl();
|
||||
void receiveRichPlainUrl();
|
||||
void receiveRichEmote();
|
||||
void receiveRichEdited_data();
|
||||
void receiveRichEdited();
|
||||
void receiveLineSeparator();
|
||||
void receiveRichCodeUrl();
|
||||
|
||||
void componentOutput_data();
|
||||
void componentOutput();
|
||||
};
|
||||
|
||||
void TextHandlerTest::initTestCase()
|
||||
@@ -139,16 +142,6 @@ void TextHandlerTest::emptyCodeTags()
|
||||
QCOMPARE(testTextHandler.handleRecieveRichText(), testOutputString);
|
||||
}
|
||||
|
||||
void TextHandlerTest::formatBlockQuote()
|
||||
{
|
||||
auto input = QStringLiteral("<blockquote>\n<p>Lorem Ispum</p>\n</blockquote>");
|
||||
auto expectedOutput = QStringLiteral("<blockquote><table><tr><td>\u201CLorem Ispum\u201D</td></tr></table></blockquote>");
|
||||
|
||||
TextHandler testTextHandler;
|
||||
testTextHandler.setData(input);
|
||||
QCOMPARE(testTextHandler.handleRecieveRichText(), expectedOutput);
|
||||
}
|
||||
|
||||
void TextHandlerTest::sendSimpleStringCase()
|
||||
{
|
||||
const QString testInputString = QStringLiteral("This data should just be put in a paragraph.");
|
||||
@@ -470,22 +463,6 @@ void TextHandlerTest::receiveRichPlainUrl()
|
||||
QCOMPARE(testTextHandler.handleRecieveRichText(Qt::RichText), testOutputStringMxId);
|
||||
}
|
||||
|
||||
// Test that user pill is add to an emote message.
|
||||
// N.B. The second message in the test timeline is marked as an emote.
|
||||
void TextHandlerTest::receiveRichEmote()
|
||||
{
|
||||
auto event = room->messageEvents().at(1).get();
|
||||
auto author = room->user(event->senderId());
|
||||
const QString testInputString = QStringLiteral("This is an emote.");
|
||||
const QString testOutputString = QStringLiteral("* <a href=\"https://matrix.to/#/@example:example.org\" style=\"color:")
|
||||
+ Utils::getUserColor(author->hueF()).name() + QStringLiteral("\">@example:example.org</a> This is an emote.");
|
||||
|
||||
TextHandler testTextHandler;
|
||||
testTextHandler.setData(testInputString);
|
||||
|
||||
QCOMPARE(testTextHandler.handleRecieveRichText(Qt::RichText, room, event), testOutputString);
|
||||
}
|
||||
|
||||
void TextHandlerTest::receiveRichEdited_data()
|
||||
{
|
||||
QTest::addColumn<QString>("testInputString");
|
||||
@@ -494,9 +471,6 @@ void TextHandlerTest::receiveRichEdited_data()
|
||||
QTest::newRow("basic") << QStringLiteral("Edited") << QStringLiteral("Edited <span style=\"color:#000000\">(edited)</span>");
|
||||
QTest::newRow("multiple paragraphs") << QStringLiteral("<p>Edited</p>\n<p>Edited</p>")
|
||||
<< QStringLiteral("<p>Edited</p>\n<p>Edited <span style=\"color:#000000\">(edited)</span></p>");
|
||||
QTest::newRow("blockquote")
|
||||
<< QStringLiteral("<blockquote>Edited</blockquote>")
|
||||
<< QStringLiteral("<blockquote><table><tr><td>\u201CEdited\u201D</td></tr></table></blockquote><p> <span style=\"color:#000000\">(edited)</span></p>");
|
||||
}
|
||||
|
||||
void TextHandlerTest::receiveRichEdited()
|
||||
@@ -507,7 +481,8 @@ void TextHandlerTest::receiveRichEdited()
|
||||
TextHandler testTextHandler;
|
||||
testTextHandler.setData(testInputString);
|
||||
|
||||
QCOMPARE(testTextHandler.handleRecieveRichText(Qt::RichText, room, room->messageEvents().at(2).get()), testOutputString);
|
||||
const auto event = eventCast<const Quotient::RoomMessageEvent>(room->messageEvents().at(2).get());
|
||||
QCOMPARE(testTextHandler.handleRecieveRichText(Qt::RichText, room, event, false, event->isReplaced()), testOutputString);
|
||||
}
|
||||
|
||||
void TextHandlerTest::receiveLineSeparator()
|
||||
@@ -526,5 +501,44 @@ void TextHandlerTest::receiveRichCodeUrl()
|
||||
QCOMPARE(testTextHandler.handleRecieveRichText(), input);
|
||||
}
|
||||
|
||||
void TextHandlerTest::componentOutput_data()
|
||||
{
|
||||
QTest::addColumn<QString>("testInputString");
|
||||
QTest::addColumn<QList<MessageComponent>>("testOutputComponents");
|
||||
|
||||
QTest::newRow("multiple paragraphs") << QStringLiteral("<p>Text</p>\n<p>Text</p>")
|
||||
<< QList<MessageComponent>{MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}},
|
||||
MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}}};
|
||||
QTest::newRow("code") << QStringLiteral("<p>Text</p>\n<pre><code class=\"language-html\">Some code\n</code></pre>")
|
||||
<< QList<MessageComponent>{MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}},
|
||||
MessageComponent{MessageComponentType::Code,
|
||||
QStringLiteral("Some code"),
|
||||
QVariantMap{{QStringLiteral("class"), QStringLiteral("HTML")}}}};
|
||||
QTest::newRow("quote") << QStringLiteral("<p>Text</p>\n<blockquote>\n<p>blockquote</p>\n</blockquote>")
|
||||
<< QList<MessageComponent>{MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}},
|
||||
MessageComponent{MessageComponentType::Quote, QStringLiteral("\"blockquote\""), {}}};
|
||||
QTest::newRow("no tag first paragraph") << QStringLiteral("Text\n<p>Text</p>")
|
||||
<< QList<MessageComponent>{MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}},
|
||||
MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}}};
|
||||
QTest::newRow("no tag last paragraph") << QStringLiteral("<p>Text</p>\nText")
|
||||
<< QList<MessageComponent>{MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}},
|
||||
MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}}};
|
||||
QTest::newRow("inline code") << QStringLiteral("<p><code>https://kde.org</code></p>\n<p>Text</p>")
|
||||
<< QList<MessageComponent>{MessageComponent{MessageComponentType::Text, QStringLiteral("<code>https://kde.org</code>"), {}},
|
||||
MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}}};
|
||||
QTest::newRow("inline code single block") << QStringLiteral("<code>https://kde.org</code>")
|
||||
<< QList<MessageComponent>{
|
||||
MessageComponent{MessageComponentType::Text, QStringLiteral("<code>https://kde.org</code>"), {}}};
|
||||
}
|
||||
|
||||
void TextHandlerTest::componentOutput()
|
||||
{
|
||||
QFETCH(QString, testInputString);
|
||||
QFETCH(QList<MessageComponent>, testOutputComponents);
|
||||
|
||||
TextHandler testTextHandler;
|
||||
QCOMPARE(testTextHandler.textComponents(testInputString), testOutputComponents);
|
||||
}
|
||||
|
||||
QTEST_MAIN(TextHandlerTest)
|
||||
#include "texthandlertest.moc"
|
||||
|
||||
@@ -330,6 +330,8 @@ qt_add_qml_module(neochat URI org.kde.neochat NO_PLUGIN
|
||||
qml/IgnoredUsersDialog.qml
|
||||
qml/AccountData.qml
|
||||
qml/StateKeys.qml
|
||||
qml/CodeComponent.qml
|
||||
qml/QuoteComponent.qml
|
||||
RESOURCES
|
||||
qml/confetti.png
|
||||
qml/glowdot.png
|
||||
|
||||
@@ -37,6 +37,8 @@ public:
|
||||
Image, /**< A message that is an image. */
|
||||
Audio, /**< A message that is an audio recording. */
|
||||
Video, /**< A message that is a video. */
|
||||
Code, /**< A code section. */
|
||||
Quote, /**< A quote section. */
|
||||
File, /**< A message that is a file. */
|
||||
Poll, /**< The initial event for a poll. */
|
||||
Location, /**< A location event. */
|
||||
@@ -104,4 +106,22 @@ public:
|
||||
|
||||
return MessageComponentType::Other;
|
||||
}
|
||||
|
||||
/**
|
||||
* @brief Return MessageComponentType for the given html tag.
|
||||
*
|
||||
* @param tag the tag name to return a type for.
|
||||
*
|
||||
* @sa Type
|
||||
*/
|
||||
static Type typeForTag(const QString &tag)
|
||||
{
|
||||
if (tag == QLatin1String("pre") || tag == QLatin1String("pre")) {
|
||||
return Code;
|
||||
}
|
||||
if (tag == QLatin1String("blockquote")) {
|
||||
return Quote;
|
||||
}
|
||||
return Text;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -232,6 +232,36 @@ bool EventHandler::isHidden()
|
||||
return false;
|
||||
}
|
||||
|
||||
Qt::TextFormat EventHandler::messageBodyInputFormat(const Quotient::RoomMessageEvent &event)
|
||||
{
|
||||
if (event.mimeType().name() == "text/plain"_ls) {
|
||||
return Qt::PlainText;
|
||||
} else {
|
||||
return Qt::RichText;
|
||||
}
|
||||
}
|
||||
|
||||
QString EventHandler::rawMessageBody(const Quotient::RoomMessageEvent &event)
|
||||
{
|
||||
if (event.hasFileContent()) {
|
||||
auto fileCaption = event.content()->fileInfo()->originalName;
|
||||
if (fileCaption.isEmpty()) {
|
||||
fileCaption = event.plainBody();
|
||||
} else if (event.content()->fileInfo()->originalName != event.plainBody()) {
|
||||
fileCaption = event.plainBody() + " | "_ls + fileCaption;
|
||||
}
|
||||
return fileCaption;
|
||||
}
|
||||
|
||||
QString body;
|
||||
if (event.hasTextContent() && event.content()) {
|
||||
body = static_cast<const MessageEventContent::TextContent *>(event.content())->body;
|
||||
} else {
|
||||
body = event.plainBody();
|
||||
}
|
||||
return body;
|
||||
}
|
||||
|
||||
QString EventHandler::getRichBody(bool stripNewlines) const
|
||||
{
|
||||
if (m_event == nullptr) {
|
||||
@@ -445,7 +475,7 @@ QString EventHandler::getMessageBody(const RoomMessageEvent &event, Qt::TextForm
|
||||
}
|
||||
|
||||
if (format == Qt::RichText) {
|
||||
return textHandler.handleRecieveRichText(inputFormat, m_room, &event, stripNewlines);
|
||||
return textHandler.handleRecieveRichText(inputFormat, m_room, &event, stripNewlines, event.isReplaced());
|
||||
} else {
|
||||
return textHandler.handleRecievePlainText(inputFormat, stripNewlines);
|
||||
}
|
||||
|
||||
@@ -137,6 +137,22 @@ public:
|
||||
*/
|
||||
bool isHidden();
|
||||
|
||||
/**
|
||||
* @brief The input format of the body in the message.
|
||||
*
|
||||
* I.e. if the message has only a body the format will be Qt::PlainText, if it
|
||||
* has a formatted body it will be Qt::RichText.
|
||||
*/
|
||||
static Qt::TextFormat messageBodyInputFormat(const Quotient::RoomMessageEvent &event);
|
||||
|
||||
/**
|
||||
* @brief Output a string for the room message content without any formatting.
|
||||
*
|
||||
* This is the content of the formatted_body key if present or the body key if
|
||||
* not.
|
||||
*/
|
||||
static QString rawMessageBody(const Quotient::RoomMessageEvent &event);
|
||||
|
||||
/**
|
||||
* @brief Output a string for the message content ready for display in a rich text field.
|
||||
*
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
#include "messagecontentmodel.h"
|
||||
|
||||
#include <Quotient/events/redactionevent.h>
|
||||
#include <Quotient/events/roommessageevent.h>
|
||||
#include <Quotient/events/stickerevent.h>
|
||||
|
||||
#include <KLocalizedString>
|
||||
@@ -13,6 +14,7 @@
|
||||
#include "eventhandler.h"
|
||||
#include "linkpreviewer.h"
|
||||
#include "neochatroom.h"
|
||||
#include "texthandler.h"
|
||||
|
||||
MessageContentModel::MessageContentModel(const Quotient::RoomEvent *event, NeoChatRoom *room)
|
||||
: QAbstractListModel(nullptr)
|
||||
@@ -45,7 +47,7 @@ MessageContentModel::MessageContentModel(const Quotient::RoomEvent *event, NeoCh
|
||||
if (replyId == eventHandler.getReplyId()) {
|
||||
// HACK: Because DelegateChooser can't switch the delegate on dataChanged it has to think there is a new delegate.
|
||||
beginResetModel();
|
||||
m_components[0] = MessageComponentType::Reply;
|
||||
m_components[0].type = MessageComponentType::Reply;
|
||||
endResetModel();
|
||||
}
|
||||
}
|
||||
@@ -74,6 +76,7 @@ MessageContentModel::MessageContentModel(const Quotient::RoomEvent *event, NeoCh
|
||||
if (m_event != nullptr && (oldEventId == m_event->id() || newEventId == m_event->id())) {
|
||||
// HACK: Because DelegateChooser can't switch the delegate on dataChanged it has to think there is a new delegate.
|
||||
beginResetModel();
|
||||
updateComponents(newEventId == m_event->id());
|
||||
endResetModel();
|
||||
}
|
||||
});
|
||||
@@ -87,7 +90,7 @@ MessageContentModel::MessageContentModel(const Quotient::RoomEvent *event, NeoCh
|
||||
if (m_linkPreviewer->loaded()) {
|
||||
// HACK: Because DelegateChooser can't switch the delegate on dataChanged it has to think there is a new delegate.
|
||||
beginResetModel();
|
||||
m_components[m_components.size() - 1] = MessageComponentType::LinkPreview;
|
||||
m_components[m_components.size() - 1].type = MessageComponentType::LinkPreview;
|
||||
endResetModel();
|
||||
}
|
||||
});
|
||||
@@ -111,6 +114,7 @@ QVariant MessageContentModel::data(const QModelIndex &index, int role) const
|
||||
}
|
||||
|
||||
EventHandler eventHandler(m_room, m_event);
|
||||
const auto component = m_components[index.row()];
|
||||
|
||||
if (role == DisplayRole) {
|
||||
if (m_event->isRedacted()) {
|
||||
@@ -118,14 +122,16 @@ QVariant MessageContentModel::data(const QModelIndex &index, int role) const
|
||||
return (reason.isEmpty()) ? i18n("<i>[This message was deleted]</i>")
|
||||
: i18n("<i>[This message was deleted: %1]</i>", m_event->redactedBecause()->reason());
|
||||
}
|
||||
if (!component.content.isEmpty()) {
|
||||
return component.content;
|
||||
}
|
||||
return eventHandler.getRichBody();
|
||||
}
|
||||
if (role == ComponentTypeRole) {
|
||||
const auto component = m_components[index.row()];
|
||||
if (component == MessageComponentType::Text && !m_event->id().isEmpty() && m_room->editCache()->editId() == m_event->id()) {
|
||||
return MessageComponentType::Edit;
|
||||
}
|
||||
return component;
|
||||
return component.type;
|
||||
}
|
||||
if (role == ComponentAttributesRole) {
|
||||
return component.attributes;
|
||||
}
|
||||
if (role == EventIdRole) {
|
||||
return eventHandler.getId();
|
||||
@@ -198,6 +204,7 @@ QHash<int, QByteArray> MessageContentModel::roleNames() const
|
||||
QHash<int, QByteArray> roles = QAbstractItemModel::roleNames();
|
||||
roles[DisplayRole] = "display";
|
||||
roles[ComponentTypeRole] = "componentType";
|
||||
roles[ComponentAttributesRole] = "componentAttributes";
|
||||
roles[EventIdRole] = "eventId";
|
||||
roles[AuthorRole] = "author";
|
||||
roles[MediaInfoRole] = "mediaInfo";
|
||||
@@ -216,7 +223,7 @@ QHash<int, QByteArray> MessageContentModel::roleNames() const
|
||||
return roles;
|
||||
}
|
||||
|
||||
void MessageContentModel::updateComponents()
|
||||
void MessageContentModel::updateComponents(bool isEditing)
|
||||
{
|
||||
beginResetModel();
|
||||
m_components.clear();
|
||||
@@ -224,20 +231,30 @@ void MessageContentModel::updateComponents()
|
||||
EventHandler eventHandler(m_room, m_event);
|
||||
if (eventHandler.hasReply()) {
|
||||
if (m_room->findInTimeline(eventHandler.getReplyId()) == m_room->historyEdge()) {
|
||||
m_components += MessageComponentType::ReplyLoad;
|
||||
m_components += MessageComponent{MessageComponentType::ReplyLoad, QString(), {}};
|
||||
m_room->loadReply(m_event->id(), eventHandler.getReplyId());
|
||||
} else {
|
||||
m_components += MessageComponentType::Reply;
|
||||
m_components += MessageComponent{MessageComponentType::Reply, QString(), {}};
|
||||
}
|
||||
}
|
||||
|
||||
m_components += eventHandler.messageComponentType();
|
||||
if (isEditing) {
|
||||
m_components += MessageComponent{MessageComponentType::Edit, QString(), {}};
|
||||
} else {
|
||||
if (eventHandler.messageComponentType() == MessageComponentType::Text) {
|
||||
const auto event = eventCast<const Quotient::RoomMessageEvent>(m_event);
|
||||
auto body = EventHandler::rawMessageBody(*event);
|
||||
m_components.append(TextHandler().textComponents(body, EventHandler::messageBodyInputFormat(*event), m_room, event, event->isReplaced()));
|
||||
} else {
|
||||
m_components += MessageComponent{eventHandler.messageComponentType(), QString(), {}};
|
||||
}
|
||||
}
|
||||
|
||||
if (m_linkPreviewer != nullptr) {
|
||||
if (m_linkPreviewer->loaded()) {
|
||||
m_components += MessageComponentType::LinkPreview;
|
||||
m_components += MessageComponent{MessageComponentType::LinkPreview, QString(), {}};
|
||||
} else {
|
||||
m_components += MessageComponentType::LinkPreviewLoad;
|
||||
m_components += MessageComponent{MessageComponentType::LinkPreviewLoad, QString(), {}};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,22 @@
|
||||
#include <QAbstractListModel>
|
||||
#include <QQmlEngine>
|
||||
|
||||
#include "enums/messagecomponenttype.h"
|
||||
#include "eventhandler.h"
|
||||
#include "linkpreviewer.h"
|
||||
#include "messagecomponenttype.h"
|
||||
#include "neochatroom.h"
|
||||
|
||||
struct MessageComponent {
|
||||
MessageComponentType::Type type;
|
||||
QString content;
|
||||
QVariantMap attributes;
|
||||
|
||||
int operator==(const MessageComponent &right) const
|
||||
{
|
||||
return type == right.type && content == right.content && attributes == right.attributes;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @class MessageContentModel
|
||||
*
|
||||
@@ -29,6 +40,7 @@ public:
|
||||
enum Roles {
|
||||
DisplayRole = Qt::DisplayRole, /**< The display text for the message. */
|
||||
ComponentTypeRole, /**< The type of component to visualise the message. */
|
||||
ComponentAttributesRole, /**< The attributes of the component. */
|
||||
EventIdRole, /**< The matrix event ID of the event. */
|
||||
AuthorRole, /**< The author of the event. */
|
||||
MediaInfoRole, /**< The media info for the event. */
|
||||
@@ -76,8 +88,8 @@ private:
|
||||
NeoChatRoom *m_room = nullptr;
|
||||
const Quotient::RoomEvent *m_event = nullptr;
|
||||
|
||||
QVector<MessageComponentType::Type> m_components;
|
||||
void updateComponents();
|
||||
QList<MessageComponent> m_components;
|
||||
void updateComponents(bool isEditing = false);
|
||||
|
||||
LinkPreviewer *m_linkPreviewer = nullptr;
|
||||
};
|
||||
|
||||
115
src/qml/CodeComponent.qml
Normal file
115
src/qml/CodeComponent.qml
Normal file
@@ -0,0 +1,115 @@
|
||||
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
|
||||
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls as QQC2
|
||||
import QtQuick.Layouts
|
||||
|
||||
import org.kde.kirigami as Kirigami
|
||||
import org.kde.syntaxhighlighting
|
||||
|
||||
import org.kde.neochat
|
||||
|
||||
QQC2.Control {
|
||||
id: root
|
||||
|
||||
/**
|
||||
* @brief The display text of the message.
|
||||
*/
|
||||
required property string display
|
||||
|
||||
/**
|
||||
* @brief The attributes of the component.
|
||||
*/
|
||||
required property var componentAttributes
|
||||
|
||||
/**
|
||||
* @brief The maximum width that the bubble's content can be.
|
||||
*/
|
||||
property real maxContentWidth: -1
|
||||
|
||||
/**
|
||||
* @brief The user selected text has changed.
|
||||
*/
|
||||
signal selectedTextChanged(string selectedText)
|
||||
|
||||
/**
|
||||
* @brief Request a context menu be show for the message.
|
||||
*/
|
||||
signal showMessageMenu
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.maximumWidth: root.maxContentWidth
|
||||
|
||||
topPadding: 0
|
||||
bottomPadding: 0
|
||||
|
||||
contentItem: RowLayout {
|
||||
spacing: Kirigami.Units.smallSpacing
|
||||
|
||||
ColumnLayout {
|
||||
id: lineNumberColumn
|
||||
spacing: 0
|
||||
Repeater {
|
||||
id: repeater
|
||||
model: LineModel {
|
||||
id: lineModel
|
||||
document: codeText.textDocument
|
||||
}
|
||||
delegate: QQC2.Label {
|
||||
id: label
|
||||
required property int index
|
||||
required property int docLineHeight
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: docLineHeight
|
||||
horizontalAlignment: Text.AlignRight
|
||||
text: index + 1
|
||||
color: Kirigami.Theme.disabledTextColor
|
||||
|
||||
font.family: "monospace"
|
||||
}
|
||||
}
|
||||
}
|
||||
Kirigami.Separator {
|
||||
Layout.fillHeight: true
|
||||
}
|
||||
TextEdit {
|
||||
id: codeText
|
||||
Layout.fillWidth: true
|
||||
topPadding: Kirigami.Units.smallSpacing
|
||||
bottomPadding: Kirigami.Units.smallSpacing
|
||||
|
||||
text: root.display
|
||||
readOnly: true
|
||||
textFormat: TextEdit.PlainText
|
||||
wrapMode: TextEdit.Wrap
|
||||
color: Kirigami.Theme.textColor
|
||||
|
||||
font.family: "monospace"
|
||||
|
||||
Kirigami.SpellCheck.enabled: false
|
||||
|
||||
onWidthChanged: lineModel.resetModel()
|
||||
onHeightChanged: lineModel.resetModel()
|
||||
|
||||
onSelectedTextChanged: root.selectedTextChanged(selectedText)
|
||||
|
||||
SyntaxHighlighter {
|
||||
property string definitionName: Repository.definitionForName(root.componentAttributes.class).name
|
||||
textEdit: definitionName == "None" ? null : codeText
|
||||
definition: definitionName
|
||||
}
|
||||
|
||||
TapHandler {
|
||||
acceptedButtons: Qt.LeftButton
|
||||
onLongPressed: root.showMessageMenu()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
color: Kirigami.Theme.backgroundColor
|
||||
radius: Kirigami.Units.smallSpacing
|
||||
}
|
||||
}
|
||||
@@ -79,6 +79,28 @@ DelegateChooser {
|
||||
}
|
||||
}
|
||||
|
||||
DelegateChoice {
|
||||
roleValue: MessageComponentType.Code
|
||||
delegate: CodeComponent {
|
||||
maxContentWidth: root.maxContentWidth
|
||||
onSelectedTextChanged: selectedText => {
|
||||
root.selectedTextChanged(selectedText);
|
||||
}
|
||||
onShowMessageMenu: root.showMessageMenu()
|
||||
}
|
||||
}
|
||||
|
||||
DelegateChoice {
|
||||
roleValue: MessageComponentType.Quote
|
||||
delegate: QuoteComponent {
|
||||
maxContentWidth: root.maxContentWidth
|
||||
onSelectedTextChanged: selectedText => {
|
||||
root.selectedTextChanged(selectedText);
|
||||
}
|
||||
onShowMessageMenu: root.showMessageMenu()
|
||||
}
|
||||
}
|
||||
|
||||
DelegateChoice {
|
||||
roleValue: MessageComponentType.Audio
|
||||
delegate: AudioComponent {
|
||||
|
||||
67
src/qml/QuoteComponent.qml
Normal file
67
src/qml/QuoteComponent.qml
Normal file
@@ -0,0 +1,67 @@
|
||||
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
|
||||
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
|
||||
|
||||
import QtQuick
|
||||
import QtQuick.Controls as QQC2
|
||||
import QtQuick.Layouts
|
||||
|
||||
import org.kde.kirigami as Kirigami
|
||||
|
||||
QQC2.Control {
|
||||
id: root
|
||||
|
||||
/**
|
||||
* @brief The display text of the message.
|
||||
*/
|
||||
required property string display
|
||||
|
||||
/**
|
||||
* @brief The maximum width that the bubble's content can be.
|
||||
*/
|
||||
property real maxContentWidth: -1
|
||||
|
||||
/**
|
||||
* @brief The user selected text has changed.
|
||||
*/
|
||||
signal selectedTextChanged(string selectedText)
|
||||
|
||||
/**
|
||||
* @brief Request a context menu be show for the message.
|
||||
*/
|
||||
signal showMessageMenu
|
||||
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.maximumWidth: root.maxContentWidth
|
||||
|
||||
topPadding: 0
|
||||
bottomPadding: 0
|
||||
|
||||
contentItem: TextEdit {
|
||||
id: quoteText
|
||||
Layout.fillWidth: true
|
||||
topPadding: Kirigami.Units.smallSpacing
|
||||
bottomPadding: Kirigami.Units.smallSpacing
|
||||
|
||||
text: root.display
|
||||
readOnly: true
|
||||
textFormat: TextEdit.RichText
|
||||
wrapMode: TextEdit.Wrap
|
||||
color: Kirigami.Theme.textColor
|
||||
|
||||
font.italic: true
|
||||
|
||||
onSelectedTextChanged: root.selectedTextChanged(selectedText)
|
||||
|
||||
TapHandler {
|
||||
enabled: !quoteText.hoveredLink
|
||||
acceptedButtons: Qt.LeftButton
|
||||
onLongPressed: root.showMessageMenu()
|
||||
}
|
||||
}
|
||||
|
||||
background: Rectangle {
|
||||
color: Kirigami.Theme.backgroundColor
|
||||
radius: Kirigami.Units.smallSpacing
|
||||
}
|
||||
}
|
||||
@@ -6,16 +6,18 @@
|
||||
#include <QDebug>
|
||||
#include <QGuiApplication>
|
||||
#include <QStringLiteral>
|
||||
#include <QTextBlock>
|
||||
#include <QUrl>
|
||||
|
||||
#include <Quotient/events/roommessageevent.h>
|
||||
#include <Quotient/util.h>
|
||||
#include <qstringliteral.h>
|
||||
|
||||
#include <cmark.h>
|
||||
|
||||
#include <Kirigami/Platform/PlatformTheme>
|
||||
|
||||
#include "messagecomponenttype.h"
|
||||
#include "messagecontentmodel.h"
|
||||
#include "models/customemojimodel.h"
|
||||
#include "utils.h"
|
||||
|
||||
@@ -39,6 +41,13 @@ static const QStringList allowedLinkSchemes = {QStringLiteral("https"),
|
||||
QStringLiteral("ftp"),
|
||||
QStringLiteral("mailto"),
|
||||
QStringLiteral("magnet")};
|
||||
static const QStringList blockTags = {QStringLiteral("blockquote"),
|
||||
QStringLiteral("p"),
|
||||
QStringLiteral("ul"),
|
||||
QStringLiteral("ol"),
|
||||
QStringLiteral("div"),
|
||||
QStringLiteral("table"),
|
||||
QStringLiteral("pre")};
|
||||
|
||||
QString TextHandler::data() const
|
||||
{
|
||||
@@ -56,7 +65,7 @@ QString TextHandler::handleSendText()
|
||||
m_pos = 0;
|
||||
m_dataBuffer = markdownToHTML(m_data);
|
||||
|
||||
nextTokenType();
|
||||
m_nextTokenType = nextTokenType(m_dataBuffer, m_pos, m_nextToken, m_nextTokenType);
|
||||
|
||||
// Strip any disallowed tags/attributes.
|
||||
QString outputString;
|
||||
@@ -73,22 +82,23 @@ QString TextHandler::handleSendText()
|
||||
nextTokenBuffer = escapeHtml(nextTokenBuffer);
|
||||
break;
|
||||
case Tag:
|
||||
if (!isAllowedTag(getTagType())) {
|
||||
if (!isAllowedTag(getTagType(m_nextToken))) {
|
||||
nextTokenBuffer = QString();
|
||||
}
|
||||
nextTokenBuffer = cleanAttributes(getTagType(), nextTokenBuffer);
|
||||
nextTokenBuffer = cleanAttributes(getTagType(m_nextToken), nextTokenBuffer);
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
outputString.append(nextTokenBuffer);
|
||||
|
||||
nextTokenType();
|
||||
m_nextTokenType = nextTokenType(m_dataBuffer, m_pos, m_nextToken, m_nextTokenType);
|
||||
}
|
||||
return outputString;
|
||||
}
|
||||
|
||||
QString TextHandler::handleRecieveRichText(Qt::TextFormat inputFormat, const NeoChatRoom *room, const Quotient::RoomEvent *event, bool stripNewlines)
|
||||
QString
|
||||
TextHandler::handleRecieveRichText(Qt::TextFormat inputFormat, const NeoChatRoom *room, const Quotient::RoomEvent *event, bool stripNewlines, bool isEdited)
|
||||
{
|
||||
m_pos = 0;
|
||||
m_dataBuffer = m_data;
|
||||
@@ -122,7 +132,7 @@ QString TextHandler::handleRecieveRichText(Qt::TextFormat inputFormat, const Neo
|
||||
|
||||
// Strip any disallowed tags/attributes.
|
||||
QString outputString;
|
||||
nextTokenType();
|
||||
m_nextTokenType = nextTokenType(m_dataBuffer, m_pos, m_nextToken, m_nextTokenType);
|
||||
while (m_pos < m_dataBuffer.length()) {
|
||||
next();
|
||||
|
||||
@@ -130,61 +140,28 @@ QString TextHandler::handleRecieveRichText(Qt::TextFormat inputFormat, const Neo
|
||||
if (m_nextTokenType == Type::Text || m_nextTokenType == Type::TextCode) {
|
||||
nextTokenBuffer = escapeHtml(nextTokenBuffer);
|
||||
} else if (m_nextTokenType == Type::Tag) {
|
||||
if (!isAllowedTag(getTagType())) {
|
||||
if (!isAllowedTag(getTagType(m_nextToken))) {
|
||||
nextTokenBuffer = QString();
|
||||
} else if ((getTagType() == QStringLiteral("br") && stripNewlines)) {
|
||||
} else if ((getTagType(m_nextToken) == QStringLiteral("br") && stripNewlines)) {
|
||||
nextTokenBuffer = u' ';
|
||||
}
|
||||
nextTokenBuffer = cleanAttributes(getTagType(), nextTokenBuffer);
|
||||
nextTokenBuffer = cleanAttributes(getTagType(m_nextToken), nextTokenBuffer);
|
||||
}
|
||||
|
||||
outputString.append(nextTokenBuffer);
|
||||
|
||||
nextTokenType();
|
||||
m_nextTokenType = nextTokenType(m_dataBuffer, m_pos, m_nextToken, m_nextTokenType);
|
||||
}
|
||||
|
||||
// Apply user style to blockquotes
|
||||
// Unfortunately some attributes can be only be used on table cells, so we need to wrap the content in one.
|
||||
outputString.replace(TextRegex::blockQuote, QStringLiteral(R"(<blockquote><table><tr><td>“\1”</td></tr></table></blockquote>)"));
|
||||
|
||||
// If the message is an emote add the user pill to the front of the message.
|
||||
if (event != nullptr) {
|
||||
auto e = eventCast<const Quotient::RoomMessageEvent>(event);
|
||||
if (e->msgtype() == Quotient::MessageEventType::Emote) {
|
||||
auto author = room->user(e->senderId());
|
||||
QString emoteString = QStringLiteral("* <a href=\"https://matrix.to/#/") + e->senderId() + QStringLiteral("\" style=\"color:")
|
||||
+ Utils::getUserColor(author->hueF()).name() + QStringLiteral("\">") + author->displayname(room) + QStringLiteral("</a> ");
|
||||
if (outputString.startsWith(QStringLiteral("<p>"))) {
|
||||
outputString.insert(3, emoteString);
|
||||
} else {
|
||||
outputString.prepend(emoteString);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (auto e = eventCast<const Quotient::RoomMessageEvent>(event)) {
|
||||
bool isEdited = !e->unsignedJson().isEmpty() && e->unsignedJson().contains(QStringLiteral("m.relations"))
|
||||
&& e->unsignedJson()[QStringLiteral("m.relations")].toObject().contains(QStringLiteral("m.replace"));
|
||||
if (isEdited) {
|
||||
Kirigami::Platform::PlatformTheme *theme =
|
||||
static_cast<Kirigami::Platform::PlatformTheme *>(qmlAttachedPropertiesObject<Kirigami::Platform::PlatformTheme>(this, true));
|
||||
|
||||
QString editTextColor;
|
||||
if (theme != nullptr) {
|
||||
editTextColor = theme->disabledTextColor().name();
|
||||
} else {
|
||||
editTextColor = QStringLiteral("#000000");
|
||||
}
|
||||
QString editedString = QStringLiteral(" <span style=\"color:") + editTextColor + QStringLiteral("\">(edited)</span>");
|
||||
if (outputString.endsWith(QStringLiteral("</p>"))) {
|
||||
outputString.insert(outputString.length() - 4, editedString);
|
||||
} else if (outputString.endsWith(QStringLiteral("</pre>")) || outputString.endsWith(QStringLiteral("</blockquote>"))
|
||||
|| outputString.endsWith(QStringLiteral("</table>")) || outputString.endsWith(QStringLiteral("</ol>"))
|
||||
|| outputString.endsWith(QStringLiteral("</ul>"))) {
|
||||
outputString.append(QStringLiteral("<p>%1</p>").arg(editedString));
|
||||
} else {
|
||||
outputString.append(editedString);
|
||||
}
|
||||
if (isEdited) {
|
||||
if (outputString.endsWith(QStringLiteral("</p>"))) {
|
||||
outputString.insert(outputString.length() - 4, editString());
|
||||
} else if (outputString.endsWith(QStringLiteral("</pre>")) || outputString.endsWith(QStringLiteral("</blockquote>"))
|
||||
|| outputString.endsWith(QStringLiteral("</table>")) || outputString.endsWith(QStringLiteral("</ol>"))
|
||||
|| outputString.endsWith(QStringLiteral("</ul>"))) {
|
||||
outputString.append(QStringLiteral("<p>%1</p>").arg(editString()));
|
||||
} else {
|
||||
outputString.append(editString());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,7 +208,7 @@ QString TextHandler::handleRecievePlainText(Qt::TextFormat inputFormat, const bo
|
||||
|
||||
// Strip all tags/attributes except code blocks which will be escaped.
|
||||
QString outputString;
|
||||
nextTokenType();
|
||||
m_nextTokenType = nextTokenType(m_dataBuffer, m_pos, m_nextToken, m_nextTokenType);
|
||||
while (m_pos < m_dataBuffer.length()) {
|
||||
next();
|
||||
|
||||
@@ -239,7 +216,7 @@ QString TextHandler::handleRecievePlainText(Qt::TextFormat inputFormat, const bo
|
||||
if (m_nextTokenType == Type::TextCode) {
|
||||
nextTokenBuffer = unescapeHtml(nextTokenBuffer);
|
||||
} else if (m_nextTokenType == Type::Tag) {
|
||||
if (getTagType() == QStringLiteral("br") && !stripNewlines) {
|
||||
if (getTagType(m_nextToken) == QStringLiteral("br") && !stripNewlines) {
|
||||
nextTokenBuffer = u'\n';
|
||||
} else {
|
||||
nextTokenBuffer = QString();
|
||||
@@ -248,7 +225,7 @@ QString TextHandler::handleRecievePlainText(Qt::TextFormat inputFormat, const bo
|
||||
|
||||
outputString.append(nextTokenBuffer);
|
||||
|
||||
nextTokenType();
|
||||
m_nextTokenType = nextTokenType(m_dataBuffer, m_pos, m_nextToken, m_nextTokenType);
|
||||
}
|
||||
|
||||
// Escaping then unescaping allows < and > to be maintained in a plain text string
|
||||
@@ -280,38 +257,150 @@ void TextHandler::next()
|
||||
m_pos = tokenEnd + (m_nextTokenType == Type::Tag ? 1 : 0);
|
||||
}
|
||||
|
||||
void TextHandler::nextTokenType()
|
||||
TextHandler::Type TextHandler::nextTokenType(const QString &string, int currentPos, const QString ¤tToken, Type currentTokenType) const
|
||||
{
|
||||
if (m_pos >= m_dataBuffer.length()) {
|
||||
if (currentPos >= string.length()) {
|
||||
// This is to stop the function accessing an index outside the length of
|
||||
// m_dataBuffer during the final loop.
|
||||
m_nextTokenType = Type::End;
|
||||
} else if (m_nextTokenType == Type::Tag && getTagType() == QStringLiteral("code") && !isCloseTag()
|
||||
&& m_dataBuffer.indexOf(QStringLiteral("</code>"), m_pos) != m_pos) {
|
||||
m_nextTokenType = Type::TextCode;
|
||||
} else if (m_dataBuffer[m_pos] == u'<' && m_dataBuffer[m_pos + 1] != u' ') {
|
||||
m_nextTokenType = Type::Tag;
|
||||
// string during the final loop.
|
||||
return Type::End;
|
||||
} else if (currentTokenType == Type::Tag && getTagType(currentToken) == QStringLiteral("code") && !isCloseTag(currentToken)
|
||||
&& string.indexOf(QStringLiteral("</code>"), currentPos) != currentPos) {
|
||||
return Type::TextCode;
|
||||
} else if (string[currentPos] == u'<' && string[currentPos + 1] != u' ') {
|
||||
return Type::Tag;
|
||||
} else {
|
||||
m_nextTokenType = Type::Text;
|
||||
return Type::Text;
|
||||
}
|
||||
}
|
||||
|
||||
QString TextHandler::getTagType() const
|
||||
int TextHandler::nextBlockPos(const QString &string)
|
||||
{
|
||||
if (m_nextToken.isEmpty()) {
|
||||
if (string.isEmpty()) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
const auto nextTokenType = this->nextTokenType(string, 0, {}, Text);
|
||||
// If there is no tag at the start we need to handle potentially having some
|
||||
// text with no <p> tag.
|
||||
if (nextTokenType == Text) {
|
||||
int pos = 0;
|
||||
while (pos < string.size()) {
|
||||
pos = string.indexOf(u'<', pos);
|
||||
if (pos == -1) {
|
||||
pos = string.size();
|
||||
} else {
|
||||
const auto tagType = getTagType(string.mid(pos, string.indexOf(u'>', pos) - pos));
|
||||
if (blockTags.contains(tagType)) {
|
||||
return pos;
|
||||
}
|
||||
}
|
||||
pos++;
|
||||
}
|
||||
return string.size();
|
||||
}
|
||||
|
||||
int tagEndPos = string.indexOf(u'>');
|
||||
QString tag = string.first(tagEndPos + 1);
|
||||
QString tagType = getTagType(tag);
|
||||
// If the start tag is not a block tag there can be only 1 block.
|
||||
if (!blockTags.contains(tagType)) {
|
||||
return string.size();
|
||||
}
|
||||
|
||||
int closeTagPos = string.indexOf(QStringLiteral("</%1>").arg(tagType));
|
||||
// If the close tag can't be found assume malformed html and process as single block.
|
||||
if (closeTagPos == -1) {
|
||||
return string.size();
|
||||
}
|
||||
|
||||
return closeTagPos + tag.size() + 1;
|
||||
}
|
||||
|
||||
MessageComponent TextHandler::nextBlock(const QString &string,
|
||||
int nextBlockPos,
|
||||
Qt::TextFormat inputFormat,
|
||||
const NeoChatRoom *room,
|
||||
const Quotient::RoomEvent *event,
|
||||
bool isEdited)
|
||||
{
|
||||
if (string.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
int tagEndPos = string.indexOf(u'>');
|
||||
QString tag = string.first(tagEndPos + 1);
|
||||
QString tagType = getTagType(tag);
|
||||
const auto messageComponentType = MessageComponentType::typeForTag(tagType);
|
||||
QVariantMap attributes;
|
||||
if (messageComponentType == MessageComponentType::Code) {
|
||||
attributes = getAttributes(QStringLiteral("code"), string.mid(tagEndPos + 1, string.indexOf(u'>', tagEndPos + 1) - tagEndPos));
|
||||
}
|
||||
|
||||
auto content = stripBlockTags(string.first(nextBlockPos), tagType);
|
||||
setData(content);
|
||||
switch (messageComponentType) {
|
||||
case MessageComponentType::Code:
|
||||
content = unescapeHtml(content);
|
||||
break;
|
||||
default:
|
||||
content = handleRecieveRichText(inputFormat, room, event, false, isEdited);
|
||||
}
|
||||
return MessageComponent{messageComponentType, content, attributes};
|
||||
}
|
||||
|
||||
QString TextHandler::stripBlockTags(QString string, const QString &tagType) const
|
||||
{
|
||||
if (blockTags.contains(tagType) && tagType != QStringLiteral("ol") && tagType != QStringLiteral("ul") && tagType != QStringLiteral("table")) {
|
||||
string.replace(QLatin1String("<%1>").arg(tagType), QString()).replace(QLatin1String("</%1>").arg(tagType), QString());
|
||||
}
|
||||
|
||||
if (string.startsWith(QStringLiteral("\n"))) {
|
||||
string.remove(0, 1);
|
||||
}
|
||||
if (string.endsWith(QStringLiteral("\n"))) {
|
||||
string.remove(string.size() - 1, string.size());
|
||||
}
|
||||
if (tagType == QStringLiteral("pre")) {
|
||||
if (string.startsWith(QStringLiteral("<code"))) {
|
||||
string.remove(0, string.indexOf(u'>') + 1);
|
||||
string.remove(string.size() - 7, string.size());
|
||||
}
|
||||
if (string.endsWith(QStringLiteral("\n"))) {
|
||||
string.remove(string.size() - 1, string.size());
|
||||
}
|
||||
}
|
||||
if (tagType == QStringLiteral("blockquote")) {
|
||||
if (string.startsWith(QStringLiteral("<p>"))) {
|
||||
string.remove(0, 3);
|
||||
string.remove(string.size() - 4, string.size());
|
||||
}
|
||||
if (!string.startsWith(u'"')) {
|
||||
string.prepend(u'"');
|
||||
}
|
||||
if (!string.endsWith(u'"')) {
|
||||
string.append(u'"');
|
||||
}
|
||||
}
|
||||
|
||||
return string;
|
||||
}
|
||||
|
||||
QString TextHandler::getTagType(const QString &tagToken) const
|
||||
{
|
||||
if (tagToken.isEmpty()) {
|
||||
return QString();
|
||||
}
|
||||
const int tagTypeStart = m_nextToken[1] == u'/' ? 2 : 1;
|
||||
const int tagTypeEnd = m_nextToken.indexOf(TextRegex::endTagType, tagTypeStart);
|
||||
return m_nextToken.mid(tagTypeStart, tagTypeEnd - tagTypeStart);
|
||||
const int tagTypeStart = tagToken[1] == u'/' ? 2 : 1;
|
||||
const int tagTypeEnd = tagToken.indexOf(TextRegex::endTagType, tagTypeStart);
|
||||
return tagToken.mid(tagTypeStart, tagTypeEnd - tagTypeStart);
|
||||
}
|
||||
|
||||
bool TextHandler::isCloseTag() const
|
||||
bool TextHandler::isCloseTag(const QString &tagToken) const
|
||||
{
|
||||
if (m_nextToken.isEmpty()) {
|
||||
if (tagToken.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
return m_nextToken[1] == u'/';
|
||||
return tagToken[1] == u'/';
|
||||
}
|
||||
|
||||
QString TextHandler::getAttributeType(const QString &string)
|
||||
@@ -323,13 +412,17 @@ QString TextHandler::getAttributeType(const QString &string)
|
||||
return string.left(equalsPos);
|
||||
}
|
||||
|
||||
QString TextHandler::getAttributeData(const QString &string)
|
||||
QString TextHandler::getAttributeData(const QString &string, bool stripQuotes)
|
||||
{
|
||||
if (!string.contains(u'=')) {
|
||||
return QStringLiteral();
|
||||
}
|
||||
const int equalsPos = string.indexOf(u'=');
|
||||
return string.right(string.length() - equalsPos - 1);
|
||||
auto data = string.right(string.length() - equalsPos - 1);
|
||||
if (stripQuotes) {
|
||||
data = TextRegex::attributeData.match(data).captured(1);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
bool TextHandler::isAllowedTag(const QString &type)
|
||||
@@ -399,6 +492,88 @@ QString TextHandler::cleanAttributes(const QString &tag, const QString &tagStrin
|
||||
return tagString;
|
||||
}
|
||||
|
||||
QVariantMap TextHandler::getAttributes(const QString &tag, const QString &tagString)
|
||||
{
|
||||
QVariantMap attributes;
|
||||
int nextAttributeIndex = tagString.indexOf(u' ', 1);
|
||||
|
||||
if (nextAttributeIndex != -1) {
|
||||
QString nextAttribute;
|
||||
int nextSpaceIndex;
|
||||
nextAttributeIndex += 1;
|
||||
|
||||
while (nextAttributeIndex < tagString.length()) {
|
||||
nextSpaceIndex = tagString.indexOf(TextRegex::endTagType, nextAttributeIndex);
|
||||
if (nextSpaceIndex == -1) {
|
||||
nextSpaceIndex = tagString.length();
|
||||
}
|
||||
nextAttribute = tagString.mid(nextAttributeIndex, nextSpaceIndex - nextAttributeIndex);
|
||||
|
||||
if (isAllowedAttribute(tag, getAttributeType(nextAttribute))) {
|
||||
if (tag == QStringLiteral("img") && getAttributeType(nextAttribute) == QStringLiteral("src")) {
|
||||
QString attributeData = TextRegex::attributeData.match(getAttributeData(nextAttribute)).captured(1);
|
||||
if (isAllowedLink(attributeData, true)) {
|
||||
attributes[getAttributeType(nextAttribute)] = getAttributeData(nextAttribute, true);
|
||||
}
|
||||
} else if (tag == u'a' && getAttributeType(nextAttribute) == QStringLiteral("href")) {
|
||||
QString attributeData = TextRegex::attributeData.match(getAttributeData(nextAttribute)).captured(1);
|
||||
if (isAllowedLink(attributeData)) {
|
||||
attributes[getAttributeType(nextAttribute)] = getAttributeData(nextAttribute, true);
|
||||
}
|
||||
} else if (tag == QStringLiteral("code") && getAttributeType(nextAttribute) == QStringLiteral("class")) {
|
||||
if (getAttributeData(nextAttribute).remove(u'"').startsWith(QStringLiteral("language-"))) {
|
||||
attributes[getAttributeType(nextAttribute)] = convertCodeLanguageString(getAttributeData(nextAttribute, true));
|
||||
}
|
||||
} else {
|
||||
attributes[getAttributeType(nextAttribute)] = getAttributeData(nextAttribute, true);
|
||||
}
|
||||
}
|
||||
nextAttributeIndex = nextSpaceIndex + 1;
|
||||
}
|
||||
}
|
||||
return attributes;
|
||||
}
|
||||
|
||||
QList<MessageComponent>
|
||||
TextHandler::textComponents(QString string, Qt::TextFormat inputFormat, const NeoChatRoom *room, const Quotient::RoomEvent *event, bool isEdited)
|
||||
{
|
||||
if (string.isEmpty()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Strip mx-reply if present.
|
||||
string.remove(TextRegex::removeRichReply);
|
||||
|
||||
QList<MessageComponent> components;
|
||||
while (!string.isEmpty()) {
|
||||
const auto nextBlockPos = this->nextBlockPos(string);
|
||||
const auto nextBlock = this->nextBlock(string, nextBlockPos, inputFormat, room, event, nextBlockPos == string.size() ? isEdited : false);
|
||||
components += nextBlock;
|
||||
string.remove(0, nextBlockPos);
|
||||
|
||||
if (string.startsWith(QStringLiteral("\n"))) {
|
||||
string.remove(0, 1);
|
||||
}
|
||||
string = string.trimmed();
|
||||
|
||||
if (event != nullptr && room != nullptr) {
|
||||
if (auto e = eventCast<const Quotient::RoomMessageEvent>(event); e->msgtype() == Quotient::MessageEventType::Emote && components.size() == 1) {
|
||||
if (components[0].type == MessageComponentType::Text) {
|
||||
components[0].content = emoteString(room, event) + components[0].content;
|
||||
} else {
|
||||
components.prepend(MessageComponent{MessageComponentType::Text, emoteString(room, event), {}});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isEdited && components.last().type != MessageComponentType::Text) {
|
||||
components += MessageComponent{MessageComponentType::Text, editString(), {}};
|
||||
}
|
||||
|
||||
return components;
|
||||
}
|
||||
|
||||
QString TextHandler::markdownToHTML(const QString &markdown)
|
||||
{
|
||||
const auto str = markdown.toUtf8();
|
||||
@@ -493,4 +668,57 @@ QString TextHandler::linkifyUrls(QString stringIn)
|
||||
return stringIn;
|
||||
}
|
||||
|
||||
QString TextHandler::editString() const
|
||||
{
|
||||
Kirigami::Platform::PlatformTheme *theme =
|
||||
static_cast<Kirigami::Platform::PlatformTheme *>(qmlAttachedPropertiesObject<Kirigami::Platform::PlatformTheme>(this, true));
|
||||
|
||||
QString editTextColor;
|
||||
if (theme != nullptr) {
|
||||
editTextColor = theme->disabledTextColor().name();
|
||||
} else {
|
||||
editTextColor = QStringLiteral("#000000");
|
||||
}
|
||||
return QStringLiteral(" <span style=\"color:") + editTextColor + QStringLiteral("\">(edited)</span>");
|
||||
}
|
||||
|
||||
QString TextHandler::emoteString(const NeoChatRoom *room, const Quotient::RoomEvent *event) const
|
||||
{
|
||||
if (room == nullptr || event == nullptr) {
|
||||
return {};
|
||||
}
|
||||
|
||||
auto e = eventCast<const Quotient::RoomMessageEvent>(event);
|
||||
auto author = room->user(e->senderId());
|
||||
return QStringLiteral("* <a href=\"https://matrix.to/#/") + e->senderId() + QStringLiteral("\" style=\"color:") + Utils::getUserColor(author->hueF()).name()
|
||||
+ QStringLiteral("\">") + author->displayname(room) + QStringLiteral("</a> ");
|
||||
}
|
||||
|
||||
QString TextHandler::convertCodeLanguageString(const QString &languageString)
|
||||
{
|
||||
const int equalsPos = languageString.indexOf(u'-');
|
||||
auto data = languageString.right(languageString.length() - equalsPos - 1);
|
||||
|
||||
// The standard markdown syntax uses lower case. This will get a subgroup of
|
||||
// single word languages to work.
|
||||
if (data.first(1).isLower()) {
|
||||
data[0] = data[0].toUpper();
|
||||
}
|
||||
|
||||
if (data == QStringLiteral("Cpp")) {
|
||||
data = QStringLiteral("C++");
|
||||
}
|
||||
if (data == QStringLiteral("Json")) {
|
||||
data = QStringLiteral("JSON");
|
||||
}
|
||||
if (data == QStringLiteral("Html")) {
|
||||
data = QStringLiteral("HTML");
|
||||
}
|
||||
if (data == QStringLiteral("Qml")) {
|
||||
data = QStringLiteral("QML");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
#include "moc_texthandler.cpp"
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
#include "models/messagecontentmodel.h"
|
||||
#include "neochatroom.h"
|
||||
|
||||
namespace Quotient
|
||||
@@ -75,7 +76,8 @@ public:
|
||||
QString handleRecieveRichText(Qt::TextFormat inputFormat = Qt::RichText,
|
||||
const NeoChatRoom *room = nullptr,
|
||||
const Quotient::RoomEvent *event = nullptr,
|
||||
bool stripNewlines = false);
|
||||
bool stripNewlines = false,
|
||||
bool isEdited = false);
|
||||
|
||||
/**
|
||||
* @brief Handle the text as a plain output for a message being received.
|
||||
@@ -94,6 +96,18 @@ public:
|
||||
*/
|
||||
QString handleRecievePlainText(Qt::TextFormat inputFormat = Qt::PlainText, const bool &stripNewlines = false);
|
||||
|
||||
/**
|
||||
* @brief Split the given string into MessageComponent blocks.
|
||||
*
|
||||
* Separate blocks are used for thing like paragraphs, codeblocks and quotes.
|
||||
* Each block will have handleRecieveRichText() called on it.
|
||||
*/
|
||||
QList<MessageComponent> textComponents(QString string,
|
||||
Qt::TextFormat inputFormat = Qt::RichText,
|
||||
const NeoChatRoom *room = nullptr,
|
||||
const Quotient::RoomEvent *event = nullptr,
|
||||
bool isEdited = false);
|
||||
|
||||
private:
|
||||
QString m_data;
|
||||
|
||||
@@ -103,19 +117,34 @@ private:
|
||||
QString m_nextToken;
|
||||
|
||||
void next();
|
||||
void nextTokenType();
|
||||
Type nextTokenType(const QString &string, int currentPos, const QString ¤tToken, Type currentTokenType) const;
|
||||
|
||||
QString getTagType() const;
|
||||
bool isCloseTag() const;
|
||||
int nextBlockPos(const QString &string);
|
||||
MessageComponent nextBlock(const QString &string,
|
||||
int nextBlockPos,
|
||||
Qt::TextFormat inputFormat = Qt::RichText,
|
||||
const NeoChatRoom *room = nullptr,
|
||||
const Quotient::RoomEvent *event = nullptr,
|
||||
bool isEdited = false);
|
||||
QString stripBlockTags(QString string, const QString &tagType) const;
|
||||
|
||||
QString getTagType(const QString &tagToken) const;
|
||||
bool isCloseTag(const QString &tagToken) const;
|
||||
QString getAttributeType(const QString &string);
|
||||
QString getAttributeData(const QString &string);
|
||||
QString getAttributeData(const QString &string, bool stripQuotes = false);
|
||||
bool isAllowedTag(const QString &type);
|
||||
bool isAllowedAttribute(const QString &tag, const QString &attribute);
|
||||
bool isAllowedLink(const QString &link, bool isImg = false);
|
||||
QString cleanAttributes(const QString &tag, const QString &tagString);
|
||||
QVariantMap getAttributes(const QString &tag, const QString &tagString);
|
||||
|
||||
QString markdownToHTML(const QString &markdown);
|
||||
QString escapeHtml(QString stringIn);
|
||||
QString unescapeHtml(QString stringIn);
|
||||
QString linkifyUrls(QString stringIn);
|
||||
|
||||
QString editString() const;
|
||||
QString emoteString(const NeoChatRoom *room = nullptr, const Quotient::RoomEvent *event = nullptr) const;
|
||||
|
||||
static QString convertCodeLanguageString(const QString &languageString);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user