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("
\n

Lorem Ispum

\n
"); - auto expectedOutput = QStringLiteral("
\u201CLorem Ispum\u201D
"); - - 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

\n

Edited

") << QStringLiteral("

Edited

\n

Edited (edited)

"); - QTest::newRow("blockquote") - << QStringLiteral("
Edited
") - << QStringLiteral("
\u201CEdited\u201D

(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

\n

Text

") + << QList{MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}}, + MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}}}; + QTest::newRow("code") << QStringLiteral("

Text

\n
Some 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
\n

blockquote

\n
") + << QList{MessageComponent{MessageComponentType::Text, QStringLiteral("Text"), {}}, + MessageComponent{MessageComponentType::Quote, QStringLiteral("\"blockquote\""), {}}}; + QTest::newRow("no tag first paragraph") << QStringLiteral("Text\n

Text

") + << 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

\n

Text

") + << 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"(
“\1”
)")); - - // 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("").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("").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); };