diff --git a/autotests/texthandlertest.cpp b/autotests/texthandlertest.cpp
index 223f03c0e..8cfeef1d1 100644
--- a/autotests/texthandlertest.cpp
+++ b/autotests/texthandlertest.cpp
@@ -10,7 +10,9 @@
#include
#include
+#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("\nLorem Ispum
\n
");
- auto expectedOutput = QStringLiteral("");
-
- 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("* hueF()).name() + QStringLiteral("\">@example:example.org This is an emote.");
-
- TextHandler testTextHandler;
- testTextHandler.setData(testInputString);
-
- QCOMPARE(testTextHandler.handleRecieveRichText(Qt::RichText, room, event), testOutputString);
-}
-
void TextHandlerTest::receiveRichEdited_data()
{
QTest::addColumn("testInputString");
@@ -494,9 +471,6 @@ void TextHandlerTest::receiveRichEdited_data()
QTest::newRow("basic") << QStringLiteral("Edited") << QStringLiteral("Edited (edited)");
QTest::newRow("multiple paragraphs") << QStringLiteral("Edited
\nEdited
")
<< QStringLiteral("Edited
\nEdited (edited)
");
- QTest::newRow("blockquote")
- << QStringLiteral("Edited
")
- << QStringLiteral(" (edited)
");
}
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(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("testInputString");
+ QTest::addColumn>("testOutputComponents");
+
+ QTest::newRow("multiple paragraphs") << QStringLiteral("Text
\nText
")
+ << QList{MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}},
+ MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}}};
+ QTest::newRow("code") << QStringLiteral("Text
\nSome code\n
")
+ << QList{MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}},
+ MessageComponent{MessageComponentType::Code,
+ QStringLiteral("Some code"),
+ QVariantMap{{QStringLiteral("class"), QStringLiteral("HTML")}}}};
+ QTest::newRow("quote") << QStringLiteral("Text
\n\nblockquote
\n
")
+ << QList{MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}},
+ MessageComponent{MessageComponentType::Quote, QStringLiteral("\"blockquote\""), {}}};
+ QTest::newRow("no tag first paragraph") << QStringLiteral("Text\nText
")
+ << QList{MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}},
+ MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}}};
+ QTest::newRow("no tag last paragraph") << QStringLiteral("Text
\nText")
+ << QList{MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}},
+ MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}}};
+ QTest::newRow("inline code") << QStringLiteral("https://kde.org
\nText
")
+ << QList{MessageComponent{MessageComponentType::Text, QStringLiteral("https://kde.org"), {}},
+ MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}}};
+ QTest::newRow("inline code single block") << QStringLiteral("https://kde.org")
+ << QList{
+ MessageComponent{MessageComponentType::Text, QStringLiteral("https://kde.org"), {}}};
+}
+
+void TextHandlerTest::componentOutput()
+{
+ QFETCH(QString, testInputString);
+ QFETCH(QList, testOutputComponents);
+
+ TextHandler testTextHandler;
+ QCOMPARE(testTextHandler.textComponents(testInputString), testOutputComponents);
+}
+
QTEST_MAIN(TextHandlerTest)
#include "texthandlertest.moc"
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 30cd36dab..3c33f8ccb 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -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
diff --git a/src/enums/messagecomponenttype.h b/src/enums/messagecomponenttype.h
index a9e0702c1..fb86ed280 100644
--- a/src/enums/messagecomponenttype.h
+++ b/src/enums/messagecomponenttype.h
@@ -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;
+ }
};
diff --git a/src/eventhandler.cpp b/src/eventhandler.cpp
index e3cce7957..4353a15c0 100644
--- a/src/eventhandler.cpp
+++ b/src/eventhandler.cpp
@@ -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(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);
}
diff --git a/src/eventhandler.h b/src/eventhandler.h
index 9fc72000f..80f98e662 100644
--- a/src/eventhandler.h
+++ b/src/eventhandler.h
@@ -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.
*
diff --git a/src/models/messagecontentmodel.cpp b/src/models/messagecontentmodel.cpp
index 52a258a94..ee5815438 100644
--- a/src/models/messagecontentmodel.cpp
+++ b/src/models/messagecontentmodel.cpp
@@ -4,6 +4,7 @@
#include "messagecontentmodel.h"
#include
+#include
#include
#include
@@ -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("[This message was deleted]")
: i18n("[This message was deleted: %1]", 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 MessageContentModel::roleNames() const
QHash 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 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(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(), {}};
}
}
diff --git a/src/models/messagecontentmodel.h b/src/models/messagecontentmodel.h
index 7539824cc..7c22dd9d7 100644
--- a/src/models/messagecontentmodel.h
+++ b/src/models/messagecontentmodel.h
@@ -6,11 +6,22 @@
#include
#include
+#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 m_components;
- void updateComponents();
+ QList m_components;
+ void updateComponents(bool isEditing = false);
LinkPreviewer *m_linkPreviewer = nullptr;
};
diff --git a/src/qml/CodeComponent.qml b/src/qml/CodeComponent.qml
new file mode 100644
index 000000000..b5850bd28
--- /dev/null
+++ b/src/qml/CodeComponent.qml
@@ -0,0 +1,115 @@
+// SPDX-FileCopyrightText: 2024 James Graham
+// 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
+ }
+}
diff --git a/src/qml/MessageComponentChooser.qml b/src/qml/MessageComponentChooser.qml
index f345dc6f9..a58db64f9 100644
--- a/src/qml/MessageComponentChooser.qml
+++ b/src/qml/MessageComponentChooser.qml
@@ -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 {
diff --git a/src/qml/QuoteComponent.qml b/src/qml/QuoteComponent.qml
new file mode 100644
index 000000000..342ea18c1
--- /dev/null
+++ b/src/qml/QuoteComponent.qml
@@ -0,0 +1,67 @@
+// SPDX-FileCopyrightText: 2024 James Graham
+// 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
+ }
+}
diff --git a/src/texthandler.cpp b/src/texthandler.cpp
index 503e149bc..48394c9ca 100644
--- a/src/texthandler.cpp
+++ b/src/texthandler.cpp
@@ -6,16 +6,18 @@
#include
#include
#include
+#include
#include
#include
#include
-#include
#include
#include
+#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"()"));
-
- // If the message is an emote add the user pill to the front of the message.
- if (event != nullptr) {
- auto e = eventCast(event);
- if (e->msgtype() == Quotient::MessageEventType::Emote) {
- auto author = room->user(e->senderId());
- QString emoteString = QStringLiteral("* senderId() + QStringLiteral("\" style=\"color:")
- + Utils::getUserColor(author->hueF()).name() + QStringLiteral("\">") + author->displayname(room) + QStringLiteral(" ");
- if (outputString.startsWith(QStringLiteral(""))) {
- outputString.insert(3, emoteString);
- } else {
- outputString.prepend(emoteString);
- }
- }
- }
-
- if (auto e = eventCast(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(qmlAttachedPropertiesObject(this, true));
-
- QString editTextColor;
- if (theme != nullptr) {
- editTextColor = theme->disabledTextColor().name();
- } else {
- editTextColor = QStringLiteral("#000000");
- }
- QString editedString = QStringLiteral(" (edited)");
- if (outputString.endsWith(QStringLiteral("
"))) {
- outputString.insert(outputString.length() - 4, editedString);
- } else if (outputString.endsWith(QStringLiteral("")) || outputString.endsWith(QStringLiteral(""))
- || outputString.endsWith(QStringLiteral("")) || outputString.endsWith(QStringLiteral(""))
- || outputString.endsWith(QStringLiteral(""))) {
- outputString.append(QStringLiteral("%1
").arg(editedString));
- } else {
- outputString.append(editedString);
- }
+ if (isEdited) {
+ if (outputString.endsWith(QStringLiteral("
"))) {
+ outputString.insert(outputString.length() - 4, editString());
+ } else if (outputString.endsWith(QStringLiteral("")) || outputString.endsWith(QStringLiteral(""))
+ || outputString.endsWith(QStringLiteral("")) || outputString.endsWith(QStringLiteral(""))
+ || outputString.endsWith(QStringLiteral(""))) {
+ outputString.append(QStringLiteral("%1
").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(""), 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(""), 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 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("') + 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(""))) {
+ 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
+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 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(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(qmlAttachedPropertiesObject(this, true));
+
+ QString editTextColor;
+ if (theme != nullptr) {
+ editTextColor = theme->disabledTextColor().name();
+ } else {
+ editTextColor = QStringLiteral("#000000");
+ }
+ return QStringLiteral(" (edited)");
+}
+
+QString TextHandler::emoteString(const NeoChatRoom *room, const Quotient::RoomEvent *event) const
+{
+ if (room == nullptr || event == nullptr) {
+ return {};
+ }
+
+ auto e = eventCast(event);
+ auto author = room->user(e->senderId());
+ return QStringLiteral("* senderId() + QStringLiteral("\" style=\"color:") + Utils::getUserColor(author->hueF()).name()
+ + QStringLiteral("\">") + author->displayname(room) + QStringLiteral(" ");
+}
+
+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"
diff --git a/src/texthandler.h b/src/texthandler.h
index 45ccb111b..56ed46c31 100644
--- a/src/texthandler.h
+++ b/src/texthandler.h
@@ -9,6 +9,7 @@
#include
#include
+#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 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);
};