From f6ba4f2ecd470487f1133a570459086059750775 Mon Sep 17 00:00:00 2001 From: James Graham Date: Mon, 13 Mar 2023 17:28:56 +0000 Subject: [PATCH] Improve Text Handling Improve the handling of text both when sending and receiving. The main feature is to fix the linked bug (and a host of others that are unreported but similar) which is caused by the fact that we don't properly clean html. This mr does that as per the matrix spec https://spec.matrix.org/v1.5/client-server-api/#mroommessage-msgtypes. So any disallowed tags or attributes are removed and it does the special handling for certain attributes. Additionally the functions are also designed to cover any other text formatting required, particularly fro received strings. The receive side is covered by 2 functions `handleRecieveRichText` and `handleRecievePlainText`. The rich/plain in the function name refers to the output type not the input type (both can take plain and rich input), so `handleRecieveRichText` is called to get a string suitable to go in a rich text control and `handleRecievePlainText` for a plain control. The functions also handle the following some of which was previously handled by `eventToString` in `NeoChatRoom`: - Strip and reply from the string - Format any user mentions - Linkify links in plain strings - Handle mxc urls in rich text (uses the new `room->makeMediaUrl` functionality from libQuotient) - `handleRecievePlainText` also deals with markup making `NeoChatRoom->subtitle` redundant There is also an extensive test suite which defines the behaviour and the best way to review this is probably to look at the tests and decide whether you agree with the expected output given the inputs and/or if there is any missing behaviour. The final aim especially with the test suite is to give us a framework to make further updates in the future easier and hopefully prevent a new feature breaking old behaviour with the tests. BUG: 463932 \ BUG: 466330 \ BUG: 466930 --- autotests/CMakeLists.txt | 6 + autotests/neochatroomtest.cpp | 2 +- autotests/texthandlertest.cpp | 482 +++++++++++++++++++++++ src/CMakeLists.txt | 2 +- src/actionshandler.cpp | 22 +- src/actionshandler.h | 2 - src/models/messageeventmodel.cpp | 1 - src/models/roomlistmodel.cpp | 2 +- src/neochatroom.cpp | 104 ++--- src/neochatroom.h | 10 +- src/notificationsmanager.cpp | 6 +- src/qml/Component/Timeline/RichLabel.qml | 21 +- src/texthandler.cpp | 378 ++++++++++++++++++ src/texthandler.h | 131 ++++++ src/utils.cpp | 4 - src/utils.h | 16 - 16 files changed, 1041 insertions(+), 148 deletions(-) create mode 100644 autotests/texthandlertest.cpp create mode 100644 src/texthandler.cpp create mode 100644 src/texthandler.h delete mode 100644 src/utils.cpp delete mode 100644 src/utils.h diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt index 6f7ecd0fe..c8625b0b4 100644 --- a/autotests/CMakeLists.txt +++ b/autotests/CMakeLists.txt @@ -8,3 +8,9 @@ ecm_add_test( LINK_LIBRARIES neochat Qt::Test Quotient TEST_NAME neochatroomtest ) + +ecm_add_test( + texthandlertest.cpp + LINK_LIBRARIES neochat Qt::Test + TEST_NAME texthandlertest +) diff --git a/autotests/neochatroomtest.cpp b/autotests/neochatroomtest.cpp index 66e281604..e9edad2ca 100644 --- a/autotests/neochatroomtest.cpp +++ b/autotests/neochatroomtest.cpp @@ -136,7 +136,7 @@ void NeoChatRoomTest::initTestCase() void NeoChatRoomTest::subtitleTextTest() { QCOMPARE(room->timelineSize(), 1); - QCOMPARE(room->subtitleText(), QStringLiteral("@example:example.org: This is an example text message")); + QCOMPARE(room->lastEventToString(), QStringLiteral("@example:example.org: This is an example text message")); } void NeoChatRoomTest::eventTest() diff --git a/autotests/texthandlertest.cpp b/autotests/texthandlertest.cpp new file mode 100644 index 000000000..aa3c46a0d --- /dev/null +++ b/autotests/texthandlertest.cpp @@ -0,0 +1,482 @@ +// SPDX-FileCopyrightText: 2023 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#include +#include + +#include "texthandler.h" + +#include +#include +#include + +using namespace Quotient; + +class TestRoom : public NeoChatRoom +{ +public: + using NeoChatRoom::NeoChatRoom; + + void update(SyncRoomData &&data, bool fromCache = false) + { + Room::updateData(std::move(data), fromCache); + } +}; + +class TextHandlerTest : public QObject +{ + Q_OBJECT + +private: + Connection *connection = nullptr; + TestRoom *room = nullptr; + +private Q_SLOTS: + void initTestCase(); + + void allowedAttributes(); + void stripDisallowedTags(); + void stripDisallowedAttributes(); + void emptyCodeTags(); + + void sendSimpleStringCase(); + void sendSingleParaMarkup(); + void sendMultipleSectionMarkup(); + void sendBadLinks(); + void sendEscapeCode(); + void sendCodeClass(); + + void receiveStripReply(); + void receivePlainTextIn(); + + void recieveRichInPlainOut(); + void receivePlainStripHtml(); + void receivePlainStripMarkup(); + void receiveStripNewlines(); + + void receiveRichUserPill(); + void receiveRichStrikethrough(); + void receiveRichtextIn(); + void receiveRichMxcUrl(); + void receiveRichPlainUrl(); +}; + +#ifdef QUOTIENT_07 +void TextHandlerTest::initTestCase() +{ + connection = Connection::makeMockConnection(QStringLiteral("@bob:kde.org")); + room = new TestRoom(connection, QStringLiteral("#myroom:kde.org"), JoinState::Join); + + const auto json = QJsonDocument::fromJson(R"EVENT({ + "account_data": { + "events": [ + { + "content": { + "tags": { + "u.work": { + "order": 0.9 + } + } + }, + "type": "m.tag" + }, + { + "content": { + "custom_config_key": "custom_config_value" + }, + "type": "org.example.custom.room.config" + } + ] + }, + "ephemeral": { + "events": [ + { + "content": { + "user_ids": [ + "@alice:matrix.org", + "@bob:example.com" + ] + }, + "room_id": "!jEsUZKDJdhlrceRyVU:example.org", + "type": "m.typing" + } + ] + }, + "state": { + "events": [ + { + "content": { + "avatar_url": "mxc://example.org/SEsfnsuifSDFSSEF", + "displayname": "Alice Margatroid", + "membership": "join", + "reason": "Looking for support" + }, + "event_id": "$143273582443PhrSn:example.org", + "origin_server_ts": 1432735824653, + "room_id": "!jEsUZKDJdhlrceRyVU:example.org", + "sender": "@example:example.org", + "state_key": "@alice:example.org", + "type": "m.room.member", + "unsigned": { + "age": 1234 + } + } + ] + }, + "summary": { + "m.heroes": [ + "@alice:example.com", + "@bob:example.com" + ], + "m.invited_member_count": 0, + "m.joined_member_count": 2 + }, + "timeline": { + "events": [ + { + "content": { + "body": "This is an **example** text message", + "format": "org.matrix.custom.html", + "formatted_body": "This is an example text message", + "msgtype": "m.text" + }, + "event_id": "$143273582443PhrSn:example.org", + "origin_server_ts": 1432735824654, + "room_id": "!jEsUZKDJdhlrceRyVU:example.org", + "sender": "@example:example.org", + "type": "m.room.message", + "unsigned": { + "age": 1235 + } + } + ], + "limited": true, + "prev_batch": "t34-23535_0_0" + } +})EVENT"); + SyncRoomData roomData(QStringLiteral("@bob:kde.org"), JoinState::Join, json.object()); + room->update(std::move(roomData)); +} +#endif + +void TextHandlerTest::allowedAttributes() +{ + const QString testInputString = QStringLiteral("

Test

"); + const QString testOutputString = QStringLiteral("

Test

"); + + TextHandler testTextHandler; + testTextHandler.setData(testInputString); + + QCOMPARE(testTextHandler.handleSendText(), testOutputString); + QCOMPARE(testTextHandler.handleRecieveRichText(), testOutputString); +} + +void TextHandlerTest::stripDisallowedTags() +{ + const QString testInputString = QStringLiteral("

Allowed

Allowed Disallowed"); + const QString testOutputString = QStringLiteral("

Allowed

Allowed Disallowed"); + + TextHandler testTextHandler; + testTextHandler.setData(testInputString); + + QCOMPARE(testTextHandler.handleSendText(), testOutputString); + QCOMPARE(testTextHandler.handleRecieveRichText(), testOutputString); +} + +void TextHandlerTest::stripDisallowedAttributes() +{ + const QString testInputString = QStringLiteral("

Test

"); + const QString testOutputString = QStringLiteral("

Test

"); + + TextHandler testTextHandler; + testTextHandler.setData(testInputString); + + QCOMPARE(testTextHandler.handleSendText(), testOutputString); + QCOMPARE(testTextHandler.handleRecieveRichText(), testOutputString); +} + +/** + * Make sure that empty code tags are handled. + * (this was a bug during development hence the test) + */ +void TextHandlerTest::emptyCodeTags() +{ + const QString testInputString = QStringLiteral("
"); + const QString testOutputString = QStringLiteral("
"); + + TextHandler testTextHandler; + testTextHandler.setData(testInputString); + + QCOMPARE(testTextHandler.handleSendText(), testOutputString); + QCOMPARE(testTextHandler.handleRecieveRichText(), testOutputString); +} + +void TextHandlerTest::sendSimpleStringCase() +{ + const QString testInputString = QStringLiteral("This data should just be put in a paragraph."); + const QString testOutputString = QStringLiteral("

This data should just be put in a paragraph.

"); + + TextHandler testTextHandler; + testTextHandler.setData(testInputString); + + QCOMPARE(testTextHandler.handleSendText(), testOutputString); +} + +void TextHandlerTest::sendSingleParaMarkup() +{ + const QString testInputString = QStringLiteral( + "Text para with **bold**, *italic*, [link](https://kde.org), ![image](mxc://kde.org/aebd3ffd40503e1ef0525bf8f0d60282fec6183e), `inline code`."); + const QString testOutputString = QStringLiteral( + "

Text para with bold, italic, link, \"image\", inline code.

"); + + TextHandler testTextHandler; + testTextHandler.setData(testInputString); + + QCOMPARE(testTextHandler.handleSendText(), testOutputString); +} + +void TextHandlerTest::sendMultipleSectionMarkup() +{ + const QString testInputString = + QStringLiteral("Text para\n> blockquote\n* List 1\n* List 2\n1. one\n2. two\n# Heading 1\n## Heading 2\nhorizontal rule\n\n---\n```\ncodeblock\n```"); + const QString testOutputString = QStringLiteral( + "

Text para

\n
\n

blockquote

\n
\n
    \n
  • List 1
  • \n
  • List " + "2
  • \n
\n
    \n
  1. one
  2. \n
  3. two
  4. \n
\n

Heading 1

\n

Heading 2

\n

horizontal " + "rule

\n
\n
codeblock\n
"); + + TextHandler testTextHandler; + testTextHandler.setData(testInputString); + + QCOMPARE(testTextHandler.handleSendText(), testOutputString); +} + +void TextHandlerTest::sendBadLinks() +{ + const QString testInputString = QStringLiteral("[link](kde.org), ![image](https://kde.org/aebd3ffd40503e1ef0525bf8f0d60282fec6183e)"); + const QString testOutputString = QStringLiteral("

link, \"image\"

"); + + TextHandler testTextHandler; + testTextHandler.setData(testInputString); + + QCOMPARE(testTextHandler.handleSendText(), testOutputString); +} + +/** + * All text between code tags is treated as plain so it should get escaped. + */ +void TextHandlerTest::sendEscapeCode() +{ + const QString testInputString = QStringLiteral("```\n

Test some code

\n```"); + const QString testOutputString = + QStringLiteral("
<p>Test <span style="font-size:50px;">some</span> code</p>\n
"); + + TextHandler testTextHandler; + testTextHandler.setData(testInputString); + + QCOMPARE(testTextHandler.handleSendText(), testOutputString); +} + +void TextHandlerTest::sendCodeClass() +{ + const QString testInputString = QStringLiteral("```html\nsome code\n```\n
some more code
"); + const QString testOutputString = QStringLiteral("
some code\n
\n
some more code
"); + + TextHandler testTextHandler; + testTextHandler.setData(testInputString); + + QCOMPARE(testTextHandler.handleSendText(), testOutputString); +} + +void TextHandlerTest::receiveStripReply() +{ + const QString testInputString = QStringLiteral( + "
In reply to@alice:example.org
Message replied to.
Reply message."); + const QString testOutputString = QStringLiteral("Reply message."); + + TextHandler testTextHandler; + testTextHandler.setData(testInputString); + + QCOMPARE(testTextHandler.handleRecieveRichText(), testOutputString); + QCOMPARE(testTextHandler.handleRecievePlainText(), testOutputString); +} + +void TextHandlerTest::recieveRichInPlainOut() +{ + const QString testInputString = QStringLiteral("a & b"); + const QString testOutputString = QStringLiteral("a & b"); + + TextHandler testTextHandler; + testTextHandler.setData(testInputString); + + QCOMPARE(testTextHandler.handleRecievePlainText(), testOutputString); +} + +void TextHandlerTest::receivePlainTextIn() +{ + const QString testInputString = QStringLiteral("\nTest link https://kde.org."); + const QString testOutputStringRich = QStringLiteral("<plain text in tag bracket>
Test link https://kde.org."); + QString testOutputStringPlain = QStringLiteral("\nTest link https://kde.org."); + + TextHandler testTextHandler; + testTextHandler.setData(testInputString); + + QCOMPARE(testTextHandler.handleRecieveRichText(Qt::PlainText), testOutputStringRich); + QCOMPARE(testTextHandler.handleRecievePlainText(), testOutputStringPlain); +} + +void TextHandlerTest::receiveStripNewlines() +{ + const QString testInputStringPlain = QStringLiteral("Test\nmany\nnew\nlines."); + const QString testInputStringRich = QStringLiteral("Test
many
new
lines."); + const QString testOutputString = QStringLiteral("Test many new lines."); + + TextHandler testTextHandler; + testTextHandler.setData(testInputStringPlain); + + QCOMPARE(testTextHandler.handleRecievePlainText(Qt::PlainText, true), testOutputString); + QCOMPARE(testTextHandler.handleRecieveRichText(Qt::PlainText, nullptr, nullptr, true), testOutputString); + + testTextHandler.setData(testInputStringRich); + + QCOMPARE(testTextHandler.handleRecievePlainText(Qt::RichText, true), testOutputString); + QCOMPARE(testTextHandler.handleRecieveRichText(Qt::RichText, nullptr, nullptr, true), testOutputString); +} + +/** + * For a plain text output of a received string all html is stripped except for + * code which is unescaped if it's html. + */ +void TextHandlerTest::receivePlainStripHtml() +{ + const QString testInputString = QStringLiteral("

Test

Some code with tags
"); + const QString testOutputString = QStringLiteral("Test Some code with tags"); + + TextHandler testTextHandler; + testTextHandler.setData(testInputString); + + QCOMPARE(testTextHandler.handleRecievePlainText(Qt::RichText), testOutputString); +} + +void TextHandlerTest::receivePlainStripMarkup() +{ + const QString testInputString = QStringLiteral("**bold** `

inline code

` *italic*"); + const QString testOutputString = QStringLiteral("bold

inline code

italic"); + + TextHandler testTextHandler; + testTextHandler.setData(testInputString); + + QCOMPARE(testTextHandler.handleRecievePlainText(), testOutputString); +} + +void TextHandlerTest::receiveRichUserPill() +{ + const QString testInputString = QStringLiteral("

@alice:example.org

"); + const QString testOutputString = QStringLiteral("

@alice:example.org

"); + + TextHandler testTextHandler; + testTextHandler.setData(testInputString); + + QCOMPARE(testTextHandler.handleRecieveRichText(), testOutputString); +} + +void TextHandlerTest::receiveRichStrikethrough() +{ + const QString testInputString = QStringLiteral("

Test

"); + const QString testOutputString = QStringLiteral("

Test

"); + + TextHandler testTextHandler; + testTextHandler.setData(testInputString); + + QCOMPARE(testTextHandler.handleRecieveRichText(), testOutputString); +} + +void TextHandlerTest::receiveRichtextIn() +{ + const QString testInputString = QStringLiteral("

Test

Some code with tags
"); + const QString testOutputString = QStringLiteral("

Test

Some code <strong>with tags</strong>
"); + + TextHandler testTextHandler; + testTextHandler.setData(testInputString); + + QCOMPARE(testTextHandler.handleRecieveRichText(), testOutputString); +} + +#ifdef QUOTIENT_07 +void TextHandlerTest::receiveRichMxcUrl() +{ + const QString testInputString = QStringLiteral( + "\"image\""); + const QString testOutputString = QStringLiteral( + ""); + + TextHandler testTextHandler; + testTextHandler.setData(testInputString); + + QCOMPARE(testTextHandler.handleRecieveRichText(Qt::RichText, room, room->messageEvents().back().get()), testOutputString); +} +#endif + +/** + * For when your rich input string has a plain text url left in. + * + * This test is to show that a url that is already rich will be left alone but a + * plain one will be linkified. + */ +void TextHandlerTest::receiveRichPlainUrl() +{ + // This is an actual link that caused trouble which is why it's so long. Keeping + // so we can confirm consistent behaviour for complex urls. + const QString testInputStringLink1 = QStringLiteral( + "https://matrix.to/#/!RvzunyTWZGfNxJVQqv:matrix.org/$-9TJVTh5PvW6MvIhFDwteiyLBVGriinueO5eeIazQS8?via=libera.chat&via=matrix.org&via=fedora.im " + "Link already rich"); + const QString testOutputStringLink1 = QStringLiteral( + "https://matrix.to/#/" + "!RvzunyTWZGfNxJVQqv:matrix.org/$-9TJVTh5PvW6MvIhFDwteiyLBVGriinueO5eeIazQS8?via=libera.chat&via=matrix.org&via=fedora.im Link already rich"); + + // Another real case. The linkification wasn't handling it when a single link + // contains what looks like and email. It was been broken into 3 but needs to + // be just single link. + const QString testInputStringLink2 = QStringLiteral("https://lore.kernel.org/lkml/CAHk-=wio46vC4t6xXD-sFqjoPwFm_u515jm3suzmkGxQTeA1_A@mail.gmail.com/"); + const QString testOutputStringLink2 = QStringLiteral( + "https://lore.kernel.org/lkml/" + "CAHk-=wio46vC4t6xXD-sFqjoPwFm_u515jm3suzmkGxQTeA1_A@mail.gmail.com/"); + + QString testInputStringEmail = QStringLiteral(R"(email@example.com Link already rich)"); + QString testOutputStringEmail = + QStringLiteral(R"(email@example.com Link already rich)"); + + QString testInputStringMxId = QStringLiteral("@user:kde.org Link already rich"); + QString testOutputStringMxId = QStringLiteral( + "@user:kde.org Link already rich"); + + TextHandler testTextHandler; + testTextHandler.setData(testInputStringLink1); + + QCOMPARE(testTextHandler.handleRecieveRichText(Qt::RichText), testOutputStringLink1); + + testTextHandler.setData(testInputStringLink2); + QCOMPARE(testTextHandler.handleRecieveRichText(Qt::RichText), testOutputStringLink2); + + testTextHandler.setData(testInputStringEmail); + QCOMPARE(testTextHandler.handleRecieveRichText(Qt::RichText), testOutputStringEmail); + + testTextHandler.setData(testInputStringMxId); + QCOMPARE(testTextHandler.handleRecieveRichText(Qt::RichText), testOutputStringMxId); +} + +QTEST_MAIN(TextHandlerTest) +#include "texthandlertest.moc" diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index c6481abec..719505e98 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -24,7 +24,6 @@ add_library(neochat STATIC models/publicroomlistmodel.cpp models/userdirectorylistmodel.cpp models/keywordnotificationrulemodel.cpp - utils.cpp notificationsmanager.cpp models/sortfilterroomlistmodel.cpp chatdocumenthandler.cpp @@ -47,6 +46,7 @@ add_library(neochat STATIC models/statemodel.cpp filetransferpseudojob.cpp models/searchmodel.cpp + texthandler.cpp ) add_executable(neochat-app diff --git a/src/actionshandler.cpp b/src/actionshandler.cpp index f735f5cc0..9433e2d29 100644 --- a/src/actionshandler.cpp +++ b/src/actionshandler.cpp @@ -20,25 +20,10 @@ #include "neochatroom.h" #include "neochatuser.h" #include "roommanager.h" +#include "texthandler.h" using namespace Quotient; -QString markdownToHTML(const QString &markdown) -{ - const auto str = markdown.toUtf8(); - char *tmp_buf = cmark_markdown_to_html(str.constData(), str.size(), CMARK_OPT_HARDBREAKS | CMARK_OPT_UNSAFE); - - const std::string html(tmp_buf); - - free(tmp_buf); - - auto result = QString::fromStdString(html).trimmed(); - - result.replace("", ""); - - return result; -} - ActionsHandler::ActionsHandler(QObject *parent) : QObject(parent) { @@ -169,7 +154,10 @@ void ActionsHandler::handleMessage(const QString &text, QString handledText, con } handledText = CustomEmojiModel::instance().preprocessText(handledText); - handledText = markdownToHTML(handledText); + TextHandler textHandler; + textHandler.setData(handledText); + handledText = textHandler.handleSendText(); + if (handledText.count("

") == 1 && handledText.count("

") == 1) { handledText.remove("

"); handledText.remove("

"); diff --git a/src/actionshandler.h b/src/actionshandler.h index f03071b1b..85e30e4d6 100644 --- a/src/actionshandler.h +++ b/src/actionshandler.h @@ -50,5 +50,3 @@ private: QString handleMentions(QString handledText, const bool &isEdit = false); void handleMessage(const QString &text, QString handledText, const bool &isEdit = false); }; - -QString markdownToHTML(const QString &markdown); diff --git a/src/models/messageeventmodel.cpp b/src/models/messageeventmodel.cpp index d0f6a459b..b9ccb37a2 100644 --- a/src/models/messageeventmodel.cpp +++ b/src/models/messageeventmodel.cpp @@ -27,7 +27,6 @@ #include #include "neochatuser.h" -#include "utils.h" using namespace Quotient; diff --git a/src/models/roomlistmodel.cpp b/src/models/roomlistmodel.cpp index 161eb6ed6..094e75a09 100644 --- a/src/models/roomlistmodel.cpp +++ b/src/models/roomlistmodel.cpp @@ -417,7 +417,7 @@ QVariant RoomListModel::data(const QModelIndex &index, int role) const return m_categoryVisibility.value(data(index, CategoryRole).toInt(), true); } if (role == SubtitleTextRole) { - return room->subtitleText(); + return room->lastEventToString(Qt::PlainText, true); } if (role == AvatarImageRole) { return room->avatar(128); diff --git a/src/neochatroom.cpp b/src/neochatroom.cpp index c6b1bfb9a..8a79a42fa 100644 --- a/src/neochatroom.cpp +++ b/src/neochatroom.cpp @@ -47,7 +47,7 @@ #endif #include "filetransferpseudojob.h" #include "stickerevent.h" -#include "utils.h" +#include "texthandler.h" #ifndef Q_OS_ANDROID #include @@ -257,10 +257,11 @@ bool NeoChatRoom::lastEventIsSpoiler() const return false; } -QString NeoChatRoom::lastEventToString() const +QString NeoChatRoom::lastEventToString(Qt::TextFormat format, bool stripNewlines) const { if (auto event = lastEvent()) { - return roomMembername(event->senderId()) + (event->isStateEvent() ? " " : ": ") + eventToString(*event); + return roomMembername(event->senderId()) + (event->isStateEvent() ? QLatin1String(" ") : QLatin1String(": ")) + + eventToString(*event, format, stripNewlines); } return QLatin1String(""); } @@ -329,45 +330,6 @@ QDateTime NeoChatRoom::lastActiveTime() return messageEvents().rbegin()->get()->originTimestamp(); } -QString NeoChatRoom::subtitleText() -{ - static const QRegularExpression blockquote("(\r\n\t|\n|\r\t|)> "); - static const QRegularExpression heading("(\r\n\t|\n|\r\t|)\\#{1,6} "); - static const QRegularExpression newlines("(\r\n\t|\n|\r\t|\r\n)"); - static const QRegularExpression bold1("(\\*\\*|__)(?=\\S)([^\\r]*\\S)\\1"); - static const QRegularExpression bold2("(\\*|_)(?=\\S)([^\\r]*\\S)\\1"); - static const QRegularExpression strike1("~~(.*)~~"); - static const QRegularExpression strike2("~(.*)~"); - static const QRegularExpression del("(.*)"); - static const QRegularExpression multileLineCode("```([^```]+)```"); - static const QRegularExpression singleLinecode("`([^`]+)`"); - QString subtitle = lastEventToString().size() == 0 ? topic() : lastEventToString(); - - subtitle - // replace blockquote, i.e. '> text' - .replace(blockquote, " ") - // replace headings, i.e. "# text" - .replace(heading, " ") - // replace newlines - .replace(newlines, " ") - // replace '**text**' and '__text__' - .replace(bold1, "\\2") - // replace '*text*' and '_text_' - .replace(bold2, "\\2") - // replace '~~text~~' - .replace(strike1, "\\1") - // replace '~text~' - .replace(strike2, "\\1") - // replace 'text' - .replace(del, "\\1") - // replace '```code```' - .replace(multileLineCode, "\\1") - // replace '`code`' - .replace(singleLinecode, "\\1"); - - return subtitle.size() > 0 ? subtitle : QStringLiteral(" "); -} - int NeoChatRoom::savedTopVisibleIndex() const { return firstDisplayedMarker() == historyEdge() ? 0 : int(firstDisplayedMarker() - messageEvents().rbegin()); @@ -451,7 +413,7 @@ QString NeoChatRoom::avatarMediaId() const return {}; } -QString NeoChatRoom::eventToString(const RoomEvent &evt, Qt::TextFormat format, bool removeReply) const +QString NeoChatRoom::eventToString(const RoomEvent &evt, Qt::TextFormat format, bool stripNewlines) const { const bool prettyPrint = (format == Qt::RichText); @@ -462,53 +424,43 @@ QString NeoChatRoom::eventToString(const RoomEvent &evt, Qt::TextFormat format, return visit( #endif evt, - [this, prettyPrint, removeReply](const RoomMessageEvent &e) { + [this, format, stripNewlines](const RoomMessageEvent &e) { using namespace MessageEventContent; - // 1. prettyPrint/HTML - if (prettyPrint && e.hasTextContent() && e.mimeType().name() != "text/plain") { - auto htmlBody = static_cast(e.content())->body; - if (removeReply) { - htmlBody.remove(utils::removeRichReplyRegex); - } - htmlBody.replace(utils::userPillRegExp, R"(\1)"); - htmlBody.replace(utils::strikethroughRegExp, "\\1"); - - auto url = connection()->homeserver(); - auto base = url.scheme() + QStringLiteral("://") + url.host() + (url.port() != -1 ? ':' + QString::number(url.port()) : QString()); - htmlBody.replace(utils::mxcImageRegExp, QStringLiteral(R"( )").arg(base)); - - return htmlBody; - } + TextHandler textHandler; if (e.hasFileContent()) { - auto fileCaption = e.content()->fileInfo()->originalName.toHtmlEscaped(); + auto fileCaption = e.content()->fileInfo()->originalName; if (fileCaption.isEmpty()) { - fileCaption = prettyPrint ? Quotient::prettyPrint(e.plainBody()) : e.plainBody(); + fileCaption = e.plainBody(); } else if (e.content()->fileInfo()->originalName != e.plainBody()) { fileCaption = e.plainBody() + " | " + fileCaption; } - return !fileCaption.isEmpty() ? fileCaption : i18n("a file"); + textHandler.setData(fileCaption); + return !fileCaption.isEmpty() ? textHandler.handleRecievePlainText() : i18n("a file"); } - // 2. prettyPrint/text 3. plainText/HTML 4. plainText/text - QString plainBody; - if (e.hasTextContent() && e.content() && e.mimeType().name() == "text/plain") { // 2/4 - plainBody = static_cast(e.content())->body; - } else { // 3 - plainBody = e.plainBody(); + QString body; + if (e.hasTextContent() && e.content()) { + body = static_cast(e.content())->body; + } else { + body = e.plainBody(); } - if (prettyPrint) { - if (removeReply) { - plainBody.remove(utils::removeReplyRegex); - } - return Quotient::prettyPrint(plainBody); + textHandler.setData(body); + + Qt::TextFormat inputFormat; + if (e.mimeType().name() == "text/plain") { + inputFormat = Qt::PlainText; + } else { + inputFormat = Qt::RichText; } - if (removeReply) { - return plainBody.remove(utils::removeReplyRegex); + + if (format == Qt::RichText) { + return textHandler.handleRecieveRichText(inputFormat, this, &e, stripNewlines); + } else { + return textHandler.handleRecievePlainText(inputFormat, stripNewlines); } - return plainBody; }, [](const StickerEvent &e) { return e.body(); diff --git a/src/neochatroom.h b/src/neochatroom.h index 3320d96fd..abc84389d 100644 --- a/src/neochatroom.h +++ b/src/neochatroom.h @@ -124,7 +124,7 @@ public: /// /// \see lastEvent /// \see lastEventIsSpoiler - [[nodiscard]] QString lastEventToString() const; + [[nodiscard]] QString lastEventToString(Qt::TextFormat format = Qt::PlainText, bool stripNewlines = false) const; /// Convenient way to check if the last event looks like it has spoilers. /// @@ -137,12 +137,6 @@ public: /// \see lastEvent [[nodiscard]] QDateTime lastActiveTime(); - /// Get subtitle text for room - /// - /// Fetches last event and removes markdown formatting - /// \see lastEventToString - [[nodiscard]] QString subtitleText(); - [[nodiscard]] bool isSpace(); bool isEventHighlighted(const Quotient::RoomEvent *e) const; @@ -262,7 +256,7 @@ public: [[nodiscard]] QString avatarMediaId() const; - [[nodiscard]] QString eventToString(const Quotient::RoomEvent &evt, Qt::TextFormat format = Qt::PlainText, bool removeReply = true) const; + [[nodiscard]] QString eventToString(const Quotient::RoomEvent &evt, Qt::TextFormat format = Qt::PlainText, bool stripNewlines = false) const; [[nodiscard]] QString eventToGenericString(const Quotient::RoomEvent &evt) const; Q_INVOKABLE [[nodiscard]] bool containsUser(const QString &userID) const; diff --git a/src/notificationsmanager.cpp b/src/notificationsmanager.cpp index c0373b58a..cffed6a1a 100644 --- a/src/notificationsmanager.cpp +++ b/src/notificationsmanager.cpp @@ -22,11 +22,11 @@ #include #include -#include "actionshandler.h" #include "controller.h" #include "neochatconfig.h" #include "neochatroom.h" #include "roommanager.h" +#include "texthandler.h" #include "windowcontroller.h" using namespace Quotient; @@ -85,7 +85,9 @@ void NotificationsManager::postNotification(NeoChatRoom *room, std::unique_ptr replyAction(new KNotificationReplyAction(i18n("Reply"))); replyAction->setPlaceholderText(i18n("Reply...")); connect(replyAction.get(), &KNotificationReplyAction::replied, this, [room, replyEventId](const QString &text) { - room->postMessage(text, markdownToHTML(text), RoomMessageEvent::MsgType::Text, replyEventId, QString()); + TextHandler textHandler; + textHandler.setData(text); + room->postMessage(text, textHandler.handleSendText(), RoomMessageEvent::MsgType::Text, replyEventId, QString()); }); notification->setReplyAction(std::move(replyAction)); } diff --git a/src/qml/Component/Timeline/RichLabel.qml b/src/qml/Component/Timeline/RichLabel.qml index 6a623c40a..3f2537bfb 100644 --- a/src/qml/Component/Timeline/RichLabel.qml +++ b/src/qml/Component/Timeline/RichLabel.qml @@ -16,25 +16,7 @@ TextEdit { property bool isEmote: false property bool isReplyLabel: false - - readonly property var linkRegex: /(href=["'])?(\b(https?):\/\/[^\s\<\>\"\'\\\?\:\)\(]+(\(.*?\))*(\?(?=[a-z])[^\s\\\)]+|$)?)/g - property string textMessage: model.display.includes("http") - ? model.display.replace(linkRegex, function() { - if (arguments[0].includes("/_matrix/media/r0/download/")) { - return arguments[0]; - } - if (arguments[1]) { - return arguments[0]; - } - const l = arguments[2]; - if ([".", ","].includes(l[l.length-1])) { - const link = l.substring(0, l.length-1); - const leftover = l[l.length-1]; - return `${link}${leftover}`; - } - return `${l}`; - }) - : model.display + property string textMessage: model.display property bool spoilerRevealed: !hasSpoiler.test(textMessage) ListView.onReused: Qt.binding(() => !hasSpoiler.test(textMessage)) @@ -46,6 +28,7 @@ TextEdit { Controller.forceRefreshTextDocument(contentLabel.textDocument, contentLabel) } + onTextChanged: console.log(text) text: "