From 72de7c6cfb7bae28b065211f1a6e2e6a51b54ea7 Mon Sep 17 00:00:00 2001 From: James Graham Date: Mon, 8 May 2023 08:18:49 +0000 Subject: [PATCH] Link preview messageeventmodel parameters This move the finding of links and the creation of a `linkpreviewer` into c++. - The links are now extracted from the text in `texthandler` - The `messageeventmodel` now creates and stores `linkpreviewers` for events that have links in the current room. Two new model roles have been created to let a text delegate know when the link preview should be shown (`showLinkPreview`) and pass the link previewer (`linkPreviewer`). Empty link previewer are returned where link don't exist so the qml doesn't have to have checks for whether the parameters are undefined. --- autotests/texthandlertest.cpp | 52 ++++++++++++++ src/linkpreviewer.cpp | 33 ++++----- src/linkpreviewer.h | 21 ++---- src/models/messageeventmodel.cpp | 67 +++++++++++++++++-- src/models/messageeventmodel.h | 7 ++ .../Timeline/LinkPreviewDelegate.qml | 48 +++++-------- .../Component/Timeline/MessageDelegate.qml | 3 +- src/texthandler.cpp | 16 ++++- src/texthandler.h | 13 +++- 9 files changed, 187 insertions(+), 73 deletions(-) diff --git a/autotests/texthandlertest.cpp b/autotests/texthandlertest.cpp index 014a8c185..74d1578af 100644 --- a/autotests/texthandlertest.cpp +++ b/autotests/texthandlertest.cpp @@ -64,6 +64,11 @@ private Q_SLOTS: void receiveRichEdited_data(); void receiveRichEdited(); void receiveLineSeparator(); + + void linkPreviewsMatch_data(); + void linkPreviewsMatch(); + void linkPreviewsReject_data(); + void linkPreviewsReject(); }; #ifdef QUOTIENT_07 @@ -596,5 +601,52 @@ void TextHandlerTest::receiveLineSeparator() QCOMPARE(textHandler.handleRecievePlainText(Qt::PlainText, true), QStringLiteral("foo bar")); } +void TextHandlerTest::linkPreviewsMatch_data() +{ + QTest::addColumn("testInputString"); + QTest::addColumn>("testOutputLinks"); + + QTest::newRow("plainHttps") << QStringLiteral("https://kde.org") << QList({QUrl("https://kde.org")}); + QTest::newRow("richHttps") << QStringLiteral("Rich Link") << QList({QUrl("https://kde.org")}); + QTest::newRow("plainWww") << QStringLiteral("www.example.org") << QList({QUrl("www.example.org")}); + QTest::newRow("multipleHttps") << QStringLiteral("https://kde.org www.example.org") + << QList({ + QUrl("https://kde.org"), + QUrl("www.example.org"), + }); +} + +void TextHandlerTest::linkPreviewsMatch() +{ + QFETCH(QString, testInputString); + QFETCH(QList, testOutputLinks); + + TextHandler testTextHandler; + testTextHandler.setData(testInputString); + + QCOMPARE(testTextHandler.getLinkPreviews(), testOutputLinks); +} + +void TextHandlerTest::linkPreviewsReject_data() +{ + QTest::addColumn("testInputString"); + QTest::addColumn>("testOutputLinks"); + + QTest::newRow("mxc") << QStringLiteral("mxc://example.org/SEsfnsuifSDFSSEF") << QList(); + QTest::newRow("matrixTo") << QStringLiteral("https://matrix.to/#/@alice:example.org") << QList(); + QTest::newRow("noSpace") << QStringLiteral("testhttps://kde.org") << QList(); +} + +void TextHandlerTest::linkPreviewsReject() +{ + QFETCH(QString, testInputString); + QFETCH(QList, testOutputLinks); + + TextHandler testTextHandler; + testTextHandler.setData(testInputString); + + QCOMPARE(testTextHandler.getLinkPreviews(), testOutputLinks); +} + QTEST_GUILESS_MAIN(TextHandlerTest) #include "texthandlertest.moc" diff --git a/src/linkpreviewer.cpp b/src/linkpreviewer.cpp index 61aca7243..a990832ce 100644 --- a/src/linkpreviewer.cpp +++ b/src/linkpreviewer.cpp @@ -10,24 +10,13 @@ using namespace Quotient; -LinkPreviewer::LinkPreviewer(QObject *parent) +LinkPreviewer::LinkPreviewer(QObject *parent, NeoChatRoom *room, QUrl url) : QObject(parent) + , m_currentRoom(room) , m_loaded(false) + , m_url(url) { -} - -NeoChatRoom *LinkPreviewer::room() const -{ - return m_currentRoom; -} - -void LinkPreviewer::setRoom(NeoChatRoom *room) -{ - if (room == m_currentRoom) { - return; - } - m_currentRoom = room; - Q_EMIT roomChanged(); + loadUrlPreview(); } bool LinkPreviewer::loaded() const @@ -57,13 +46,19 @@ QUrl LinkPreviewer::url() const void LinkPreviewer::setUrl(QUrl url) { - if (url.scheme() == QStringLiteral("https")) { + if (url != m_url) { + m_url = url; + urlChanged(); + loadUrlPreview(); + } +} + +void LinkPreviewer::loadUrlPreview() +{ + if (m_url.scheme() == QStringLiteral("https")) { m_loaded = false; Q_EMIT loadedChanged(); - m_url = url; - Q_EMIT urlChanged(); - auto conn = m_currentRoom->connection(); GetUrlPreviewJob *job = conn->callApi(m_url.toString()); diff --git a/src/linkpreviewer.h b/src/linkpreviewer.h index 9ae63eec0..c545859ec 100644 --- a/src/linkpreviewer.h +++ b/src/linkpreviewer.h @@ -19,12 +19,6 @@ class NeoChatRoom; class LinkPreviewer : public QObject { Q_OBJECT - - /** - * @brief The current room that the URL is from. - */ - Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged) - /** * @brief The URL to get the preview for. */ @@ -51,10 +45,7 @@ class LinkPreviewer : public QObject Q_PROPERTY(QUrl imageSource READ imageSource NOTIFY imageSourceChanged) public: - explicit LinkPreviewer(QObject *parent = nullptr); - - [[nodiscard]] NeoChatRoom *room() const; - void setRoom(NeoChatRoom *room); + explicit LinkPreviewer(QObject *parent = nullptr, NeoChatRoom *room = nullptr, QUrl url = {}); [[nodiscard]] QUrl url() const; void setUrl(QUrl); @@ -67,16 +58,18 @@ private: NeoChatRoom *m_currentRoom = nullptr; bool m_loaded; - QString m_title; - QString m_description; - QUrl m_imageSource; + QString m_title = QString(); + QString m_description = QString(); + QUrl m_imageSource = QUrl(); QUrl m_url; + void loadUrlPreview(); + Q_SIGNALS: - void roomChanged(); void loadedChanged(); void titleChanged(); void descriptionChanged(); void imageSourceChanged(); void urlChanged(); }; +Q_DECLARE_METATYPE(LinkPreviewer *) diff --git a/src/models/messageeventmodel.cpp b/src/models/messageeventmodel.cpp index 1518659fc..b9fa81356 100644 --- a/src/models/messageeventmodel.cpp +++ b/src/models/messageeventmodel.cpp @@ -28,6 +28,7 @@ #include #include "neochatuser.h" +#include "texthandler.h" using namespace Quotient; @@ -45,6 +46,8 @@ QHash MessageEventModel::roleNames() const roles[SpecialMarksRole] = "marks"; roles[LongOperationRole] = "progressInfo"; roles[EventResolvedTypeRole] = "eventResolvedType"; + roles[ShowLinkPreviewRole] = "showLinkPreview"; + roles[LinkPreviewRole] = "linkPreview"; roles[MediaInfoRole] = "mediaInfo"; roles[IsReplyRole] = "isReply"; roles[ReplyAuthor] = "replyAuthor"; @@ -101,12 +104,20 @@ void MessageEventModel::setRoom(NeoChatRoom *room) beginResetModel(); if (m_currentRoom) { m_currentRoom->disconnect(this); + m_linkPreviewers.clear(); } m_currentRoom = room; if (room) { m_lastReadEventIndex = QPersistentModelIndex(QModelIndex()); room->setDisplayed(); + + for (auto event = m_currentRoom->messageEvents().begin(); event != m_currentRoom->messageEvents().end(); ++event) { + if (auto e = &*event->viewAs()) { + createLinkPreviewerForEvent(e); + } + } + if (m_currentRoom->timelineSize() < 10 && !room->allHistoryLoaded()) { room->getPreviousContent(50); } @@ -118,10 +129,12 @@ void MessageEventModel::setRoom(NeoChatRoom *room) using namespace Quotient; connect(m_currentRoom, &Room::aboutToAddNewMessages, this, [this](RoomEventsRange events) { - if (NeoChatConfig::self()->showFancyEffects()) { - for (auto &event : events) { - RoomMessageEvent *message = dynamic_cast(event.get()); - if (message) { + for (auto &&event : events) { + const RoomMessageEvent *message = dynamic_cast(event.get()); + if (message != nullptr) { + createLinkPreviewerForEvent(message); + + if (NeoChatConfig::self()->showFancyEffects()) { QString planBody = message->plainBody(); // snowflake const QString snowlakeEmoji = QString::fromUtf8("\xE2\x9D\x84"); @@ -155,6 +168,12 @@ void MessageEventModel::setRoom(NeoChatRoom *room) beginInsertRows({}, timelineBaseIndex(), timelineBaseIndex() + int(events.size()) - 1); }); connect(m_currentRoom, &Room::aboutToAddHistoricalMessages, this, [this](RoomEventsRange events) { + for (auto &event : events) { + RoomMessageEvent *message = dynamic_cast(event.get()); + if (message) { + createLinkPreviewerForEvent(message); + } + } if (rowCount() > 0) { rowBelowInserted = rowCount() - 1; // See #312 } @@ -455,6 +474,8 @@ inline QVariantMap userInContext(NeoChatUser *user, NeoChatRoom *room) }; } +static LinkPreviewer *emptyLinkPreview = new LinkPreviewer; + QVariant MessageEventModel::data(const QModelIndex &idx, int role) const { const auto row = idx.row(); @@ -684,6 +705,18 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const return role == TimeRole ? QVariant(ts) : renderDate(ts); } + if (role == ShowLinkPreviewRole) { + return m_linkPreviewers.contains(evt.id()); + } + + if (role == LinkPreviewRole) { + if (m_linkPreviewers.contains(evt.id())) { + return QVariant::fromValue(m_linkPreviewers[evt.id()]); + } else { + return QVariant::fromValue(emptyLinkPreview); + } + } + if (role == MediaInfoRole) { return getMediaInfoForEvent(evt); } @@ -1197,3 +1230,29 @@ QVariantMap MessageEventModel::getMediaInfoFromFileInfo(const EventContent::File return mediaInfo; } + +void MessageEventModel::createLinkPreviewerForEvent(const Quotient::RoomMessageEvent *event) +{ + if (m_linkPreviewers.contains(event->id())) { + return; + } else { + QString text; + if (event->hasTextContent()) { + auto textContent = static_cast(event->content()); + if (textContent) { + text = textContent->body; + } else { + text = event->plainBody(); + } + } else { + text = event->plainBody(); + } + TextHandler textHandler; + textHandler.setData(text); + + QList links = textHandler.getLinkPreviews(); + if (links.size() > 0) { + m_linkPreviewers[event->id()] = new LinkPreviewer(nullptr, m_currentRoom, links.size() > 0 ? links[0] : QUrl()); + } + } +} diff --git a/src/models/messageeventmodel.h b/src/models/messageeventmodel.h index 36bd60b7c..294688ea3 100644 --- a/src/models/messageeventmodel.h +++ b/src/models/messageeventmodel.h @@ -5,6 +5,7 @@ #include +#include "linkpreviewer.h" #include "neochatroom.h" /** @@ -70,6 +71,9 @@ public: FormattedBodyRole, /**< The formatted body of a rich message. */ GenericDisplayRole, /**< A generic string based upon the message type. */ + ShowLinkPreviewRole, /**< Whether a link preview should be shown. */ + LinkPreviewRole, /**< The link preview details. */ + MediaInfoRole, /**< The media info for the event. */ MimeTypeRole, /**< The mime type of the message's file or media. */ @@ -180,6 +184,8 @@ private: int rowBelowInserted = -1; bool movingEvent = false; + QMap m_linkPreviewers; + [[nodiscard]] int timelineBaseIndex() const; [[nodiscard]] QDateTime makeMessageTimestamp(const Quotient::Room::rev_iter_t &baseIt) const; [[nodiscard]] static QString renderDate(const QDateTime ×tamp); @@ -195,6 +201,7 @@ private: const Quotient::RoomEvent *getReplyForEvent(const Quotient::RoomEvent &event) const; QVariantMap getMediaInfoForEvent(const Quotient::RoomEvent &event) const; QVariantMap getMediaInfoFromFileInfo(const Quotient::EventContent::FileInfo *fileInfo, const QString &eventId, bool isThumbnail = false) const; + void createLinkPreviewerForEvent(const Quotient::RoomMessageEvent *event); std::vector> m_extraEvents; // Hack to ensure that we don't call endInsertRows when we haven't called beginInsertRows diff --git a/src/qml/Component/Timeline/LinkPreviewDelegate.qml b/src/qml/Component/Timeline/LinkPreviewDelegate.qml index 1dfa81a06..9fab299ec 100644 --- a/src/qml/Component/Timeline/LinkPreviewDelegate.qml +++ b/src/qml/Component/Timeline/LinkPreviewDelegate.qml @@ -14,33 +14,16 @@ Loader { id: root /** - * @brief The room that the component is created in. - */ - property var room - - /** - * @brief Get a list of hyperlinks in the text. + * @brief The link preview properties. * - * User links i.e. anything starting with https://matrix.to are ignored. + * This is a list or object containing the following: + * - url - The URL being previewed. + * - loaded - Whether the URL preview has been loaded. + * - title - the title of the URL preview. + * - description - the description of the URL preview. + * - imageSource - a source URL for the preview image. */ - property var links: { - let matches = model.display.match(/\bhttps?:\/\/[^\s\<\>\"\']+/g) - if (matches && matches.length > 0) { - // don't show previews for room links or user mentions or custom emojis - return matches.filter(link => !( - link.includes("https://matrix.to") || link.includes("/_matrix/media/r0/download/") - )) - // remove ending fullstops and commas - .map(link => (link.length && [".", ","].includes(link[link.length-1])) ? link.substring(0, link.length-1) : link) - } - return [] - - } - LinkPreviewer { - id: linkPreviewer - room: root.room - url: root.links && root.links.length > 0 ? root.links[0] : "" - } + property var linkPreviewer /** * @brief Standard height for the link preview. @@ -55,8 +38,7 @@ Loader { */ property bool indicatorEnabled: false - active: !currentRoom.usesEncryption && model.display && links && links.length > 0 && currentRoom.urlPreviewEnabled - visible: Config.showLinkPreview && active + visible: active sourceComponent: linkPreviewer.loaded ? linkPreviewComponent : loadingComponent Component { @@ -95,16 +77,16 @@ Loader { wrapMode: Text.Wrap textFormat: Text.RichText text: " - " + (maximizeButton.checked ? linkPreviewer.title : titleTextMetrics.elidedText).replace("–", "—") + "" + a { + text-decoration: none; + } + + " + (maximizeButton.checked ? root.linkPreviewer.title : titleTextMetrics.elidedText).replace("–", "—") + "" onLinkActivated: RoomManager.openResource(link) TextMetrics { id: titleTextMetrics - text: linkPreviewer.title + text: root.linkPreviewer.title font: linkPreviewTitle.font elide: Text.ElideRight elideWidth: (linkPreviewTitle.width - Kirigami.Units.largeSpacing * 2.5) * 3 diff --git a/src/qml/Component/Timeline/MessageDelegate.qml b/src/qml/Component/Timeline/MessageDelegate.qml index 242ae97c3..1384d90f3 100644 --- a/src/qml/Component/Timeline/MessageDelegate.qml +++ b/src/qml/Component/Timeline/MessageDelegate.qml @@ -35,7 +35,8 @@ TimelineContainer { } LinkPreviewDelegate { Layout.fillWidth: true - room: currentRoom + active: !currentRoom.usesEncryption && currentRoom.urlPreviewEnabled && Config.showLinkPreview && model.showLinkPreview + linkPreviewer: model.linkPreview indicatorEnabled: messageDelegate.isVisibleInTimeline() } } diff --git a/src/texthandler.cpp b/src/texthandler.cpp index 9ee893557..8ddd3cfc9 100644 --- a/src/texthandler.cpp +++ b/src/texthandler.cpp @@ -430,7 +430,21 @@ QString TextHandler::unescapeHtml(QString stringIn) QString TextHandler::linkifyUrls(QString stringIn) { stringIn = stringIn.replace(TextRegex::mxId, QStringLiteral(R"(\1\2)")); - stringIn.replace(TextRegex::fullUrl, QStringLiteral(R"(\1)")); + stringIn.replace(TextRegex::plainUrl, QStringLiteral(R"(\1)")); stringIn = stringIn.replace(TextRegex::emailAddress, QStringLiteral(R"(\1\2)")); return stringIn; } + +QList TextHandler::getLinkPreviews() +{ + auto data = m_data.remove(TextRegex::removeRichReply); + auto linksMatch = TextRegex::url.globalMatch(data); + QList links; + while (linksMatch.hasNext()) { + auto link = linksMatch.next().captured(); + if (!link.contains(QStringLiteral("matrix.to"))) { + links += QUrl(link); + } + } + return links; +} diff --git a/src/texthandler.h b/src/texthandler.h index b16e2cfa4..196d2deaf 100644 --- a/src/texthandler.h +++ b/src/texthandler.h @@ -21,10 +21,13 @@ static const QRegularExpression codePill{QStringLiteral("
]*>(.*?).*?)"), QRegularExpression::DotMatchesEverythingOption};
 static const QRegularExpression strikethrough{QStringLiteral("(.*?)"), QRegularExpression::DotMatchesEverythingOption};
 static const QRegularExpression mxcImage{QStringLiteral(R"AAA()AAA")};
-static const QRegularExpression fullUrl(
+static const QRegularExpression plainUrl(
     QStringLiteral(
         R"((*SKIP)(*F)|\b((www\.(?!\.)(?!(\w|\.|-)+@)|(https?|ftp):(//)?\w|(magnet|matrix):)(&(?![lg]t;)|[^&\s<>'"])+(&(?![lg]t;)|[^&!,.\s<>'"\]):])))"),
     QRegularExpression::CaseInsensitiveOption | QRegularExpression::UseUnicodePropertiesOption);
+static const QRegularExpression
+    url(QStringLiteral(R"(\b((www\.(?!\.)(?!(\w|\.|-)+@)|https?:(//)?\w)(&(?![lg]t;)|[^&\s<>'"])+(&(?![lg]t;)|[^&!,.\s<>'"\]):])))"),
+        QRegularExpression::CaseInsensitiveOption | QRegularExpression::UseUnicodePropertiesOption);
 static const QRegularExpression emailAddress(QStringLiteral(R"((*SKIP)(*F)|\b(mailto:)?((\w|\.|-)+@(\w|\.|-)+\.\w+\b))"),
                                              QRegularExpression::CaseInsensitiveOption | QRegularExpression::UseUnicodePropertiesOption);
 static const QRegularExpression mxId(QStringLiteral(R"((^|[][[:space:](){}`'";])([!#@][-a-z0-9_=#/.]{1,252}:\w(?:\w|\.|-)*\.\w+(?::\d{1,5})?))"),
@@ -109,6 +112,14 @@ public:
      */
     QString handleRecievePlainText(Qt::TextFormat inputFormat = Qt::PlainText, const bool &stripNewlines = false);
 
+    /**
+     * @brief Return a list of links that can be previewed.
+     *
+     * This function is designed to give only links that should be previewed so
+     * http, https or something starting with www.
+     */
+    QList getLinkPreviews();
+
 private:
     QString m_data;