Multiple Link Previews

- Show a preview for each link in the text below the block in which it appears.
- Allow link previews to be dismissed
This commit is contained in:
James Graham
2024-04-23 19:45:33 +00:00
parent 307536c6b6
commit de40701cf6
15 changed files with 201 additions and 200 deletions

View File

@@ -1,14 +0,0 @@
{
"content": {
"body": "https://matrix.to/#/@alice:example.org",
"msgtype": "m.text"
},
"event_id": "$validlink1:example.org",
"origin_server_ts": 1432735824654,
"room_id": "!test:example.org",
"sender": "@example:example.org",
"type": "m.room.message",
"unsigned": {
"age": 1234
}
}

View File

@@ -1,14 +0,0 @@
{
"content": {
"body": "mxc://example.org/SEsfnsuifSDFSSEF",
"msgtype": "m.text"
},
"event_id": "$validlink1:example.org",
"origin_server_ts": 1432735824654,
"room_id": "!test:example.org",
"sender": "@example:example.org",
"type": "m.room.message",
"unsigned": {
"age": 1234
}
}

View File

@@ -1,14 +0,0 @@
{
"content": {
"body": "testhttps://kde.org",
"msgtype": "m.text"
},
"event_id": "$validlink1:example.org",
"origin_server_ts": 1432735824654,
"room_id": "!test:example.org",
"sender": "@example:example.org",
"type": "m.room.message",
"unsigned": {
"age": 1234
}
}

View File

@@ -1,14 +0,0 @@
{
"content": {
"body": "https://kde.org",
"msgtype": "m.text"
},
"event_id": "$validlink1:example.org",
"origin_server_ts": 1432735824654,
"room_id": "!test:example.org",
"sender": "@example:example.org",
"type": "m.room.message",
"unsigned": {
"age": 1234
}
}

View File

@@ -1,14 +0,0 @@
{
"content": {
"body": "www.example.org",
"msgtype": "m.text"
},
"event_id": "$validlink1:example.org",
"origin_server_ts": 1432735824654,
"room_id": "!test:example.org",
"sender": "@example:example.org",
"type": "m.room.message",
"unsigned": {
"age": 1234
}
}

View File

@@ -1,16 +0,0 @@
{
"content": {
"body": "[Rich Link](https://kde.org)",
"format": "org.matrix.custom.html",
"formatted_body": "<a href=\"https://kde.org\">Rich Link</a>",
"msgtype": "m.text"
},
"event_id": "$validlink1:example.org",
"origin_server_ts": 1432735824654,
"room_id": "!test:example.org",
"sender": "@example:example.org",
"type": "m.room.message",
"unsigned": {
"age": 1234
}
}

View File

@@ -6,12 +6,11 @@
#include "linkpreviewer.h" #include "linkpreviewer.h"
#include "utils.h"
#include <Quotient/events/roommessageevent.h> #include <Quotient/events/roommessageevent.h>
#include <Quotient/quotient_common.h> #include <Quotient/quotient_common.h>
#include <Quotient/syncdata.h> #include <Quotient/syncdata.h>
#include "utils.h"
#include "testutils.h" #include "testutils.h"
using namespace Quotient; using namespace Quotient;
@@ -30,6 +29,9 @@ private Q_SLOTS:
void linkPreviewsMatch_data(); void linkPreviewsMatch_data();
void linkPreviewsMatch(); void linkPreviewsMatch();
void multipleLinkPreviewsMatch_data();
void multipleLinkPreviewsMatch();
void linkPreviewsReject_data(); void linkPreviewsReject_data();
void linkPreviewsReject(); void linkPreviewsReject();
}; };
@@ -42,45 +44,59 @@ void LinkPreviewerTest::initTestCase()
void LinkPreviewerTest::linkPreviewsMatch_data() void LinkPreviewerTest::linkPreviewsMatch_data()
{ {
QTest::addColumn<QString>("eventSource"); QTest::addColumn<QString>("inputString");
QTest::addColumn<QUrl>("testOutputLink"); QTest::addColumn<QUrl>("testOutputLink");
QTest::newRow("plainHttps") << QStringLiteral("test-validplainlink-event.json") << QUrl("https://kde.org"_ls); QTest::newRow("plainHttps") << QStringLiteral("https://kde.org") << QUrl("https://kde.org"_ls);
QTest::newRow("richHttps") << QStringLiteral("test-validrichlink-event.json") << QUrl("https://kde.org"_ls); QTest::newRow("richHttps") << QStringLiteral("<a href=\"https://kde.org\">Rich Link</a>") << QUrl("https://kde.org"_ls);
QTest::newRow("plainWww") << QStringLiteral("test-validplainwwwlink-event.json") << QUrl("www.example.org"_ls); QTest::newRow("richHttpsLinkDescription") << QStringLiteral("<a href=\"https://kde.org\">https://kde.org</a>") << QUrl("https://kde.org"_ls);
QTest::newRow("multipleHttps") << QStringLiteral("test-multiplelink-event.json") << QUrl("www.example.org"_ls);
} }
void LinkPreviewerTest::linkPreviewsMatch() void LinkPreviewerTest::linkPreviewsMatch()
{ {
QFETCH(QString, eventSource); QFETCH(QString, inputString);
QFETCH(QUrl, testOutputLink); QFETCH(QUrl, testOutputLink);
auto event = TestUtils::loadEventFromFile<RoomMessageEvent>(eventSource); auto link = LinkPreviewer::linkPreviews(inputString)[0];
auto linkPreviewer = LinkPreviewer(LinkPreviewer::linkPreview(event.get()), connection);
QCOMPARE(linkPreviewer.empty(), false); QCOMPARE(link, testOutputLink);
QCOMPARE(linkPreviewer.url(), testOutputLink); }
void LinkPreviewerTest::multipleLinkPreviewsMatch_data()
{
QTest::addColumn<QString>("inputString");
QTest::addColumn<QList<QUrl>>("testOutputLinks");
QTest::newRow("multipleHttps") << QStringLiteral("www.example.org https://kde.org") << QList{QUrl("www.example.org"_ls), QUrl("https://kde.org"_ls)};
QTest::newRow("multipleHttps1Invalid") << QStringLiteral("www.example.org mxc://example.org/SEsfnsuifSDFSSEF") << QList{QUrl("www.example.org"_ls)};
}
void LinkPreviewerTest::multipleLinkPreviewsMatch()
{
QFETCH(QString, inputString);
QFETCH(QList<QUrl>, testOutputLinks);
auto links = LinkPreviewer::linkPreviews(inputString);
QCOMPARE(links, testOutputLinks);
} }
void LinkPreviewerTest::linkPreviewsReject_data() void LinkPreviewerTest::linkPreviewsReject_data()
{ {
QTest::addColumn<QString>("eventSource"); QTest::addColumn<QString>("inputString");
QTest::newRow("mxc") << QStringLiteral("test-invalidmxclink-event.json"); QTest::newRow("mxc") << QStringLiteral("mxc://example.org/SEsfnsuifSDFSSEF");
QTest::newRow("matrixTo") << QStringLiteral("test-invalidmatrixtolink-event.json"); QTest::newRow("matrixTo") << QStringLiteral("https://matrix.to/#/@alice:example.org");
QTest::newRow("noSpace") << QStringLiteral("test-invalidnospacelink-event.json"); QTest::newRow("noSpace") << QStringLiteral("testhttps://kde.org");
} }
void LinkPreviewerTest::linkPreviewsReject() void LinkPreviewerTest::linkPreviewsReject()
{ {
QFETCH(QString, eventSource); QFETCH(QString, inputString);
auto event = TestUtils::loadEventFromFile<RoomMessageEvent>(eventSource); auto links = LinkPreviewer::linkPreviews(inputString);
auto linkPreviewer = LinkPreviewer(LinkPreviewer::linkPreview(event.get()), connection);
QCOMPARE(linkPreviewer.empty(), true); QCOMPARE(links.empty(), true);
QCOMPARE(linkPreviewer.url(), QUrl());
} }
QTEST_MAIN(LinkPreviewerTest) QTEST_MAIN(LinkPreviewerTest)

View File

@@ -89,38 +89,23 @@ bool LinkPreviewer::empty() const
return m_url.isEmpty(); return m_url.isEmpty();
} }
QUrl LinkPreviewer::linkPreview(const Quotient::RoomMessageEvent *event) QList<QUrl> LinkPreviewer::linkPreviews(QString string)
{ {
if (event == nullptr) { auto data = string.remove(TextRegex::removeRichReply);
return {};
}
QString text;
if (event->hasTextContent()) {
auto textContent = static_cast<const Quotient::EventContent::TextContent *>(event->content());
if (textContent) {
text = textContent->body;
} else {
text = event->plainBody();
}
} else {
text = event->plainBody();
}
auto data = text.remove(TextRegex::removeRichReply);
auto linksMatch = TextRegex::url.globalMatch(data); auto linksMatch = TextRegex::url.globalMatch(data);
QList<QUrl> links;
while (linksMatch.hasNext()) { while (linksMatch.hasNext()) {
auto link = linksMatch.next().captured(); auto link = linksMatch.next().captured();
if (!link.contains(QStringLiteral("matrix.to"))) { if (!link.contains(QStringLiteral("matrix.to")) && !links.contains(QUrl(link))) {
return QUrl(link); links += QUrl(link);
} }
} }
return {}; return links;
} }
bool LinkPreviewer::hasPreviewableLinks(const Quotient::RoomMessageEvent *event) bool LinkPreviewer::hasPreviewableLinks(const QString &string)
{ {
return !linkPreview(event).isEmpty(); return !linkPreviews(string).isEmpty();
} }
#include "moc_linkpreviewer.cpp" #include "moc_linkpreviewer.cpp"

View File

@@ -7,11 +7,6 @@
#include <QQmlEngine> #include <QQmlEngine>
#include <QUrl> #include <QUrl>
namespace Quotient
{
class RoomMessageEvent;
}
class NeoChatRoom; class NeoChatRoom;
/** /**
@@ -71,19 +66,19 @@ public:
[[nodiscard]] bool empty() const; [[nodiscard]] bool empty() const;
/** /**
* @brief Whether the given event has at least 1 pre-viewable link. * @brief Whether the given string has at least 1 pre-viewable link.
* *
* A link is only pre-viewable if it is http, https or something starting with www. * A link is only pre-viewable if it is http, https or something starting with www.
*/ */
static bool hasPreviewableLinks(const Quotient::RoomMessageEvent *event); static bool hasPreviewableLinks(const QString &string);
/** /**
* @brief Return the link to be previewed from the given event. * @brief Return previewable links from the given string.
* *
* This function is designed to give only links that should be previewed so * This function is designed to give only links that should be previewed so
* http, https or something starting with www. The first valid link is returned. * http, https or something starting with www. The first valid link is returned.
*/ */
static QUrl linkPreview(const Quotient::RoomMessageEvent *event); static QList<QUrl> linkPreviews(QString string);
private: private:
bool m_loaded; bool m_loaded;

View File

@@ -11,6 +11,7 @@
#include <Quotient/events/stickerevent.h> #include <Quotient/events/stickerevent.h>
#include <KLocalizedString> #include <KLocalizedString>
#include <qlist.h>
#ifndef Q_OS_ANDROID #ifndef Q_OS_ANDROID
#include <KSyntaxHighlighting/Definition> #include <KSyntaxHighlighting/Definition>
@@ -110,11 +111,14 @@ MessageContentModel::MessageContentModel(const Quotient::RoomEvent *event, NeoCh
endResetModel(); endResetModel();
} }
}); });
connect(m_room, &NeoChatRoom::urlPreviewEnabledChanged, this, &MessageContentModel::updateLinkPreviewer); connect(m_room, &NeoChatRoom::urlPreviewEnabledChanged, this, [this]() {
connect(NeoChatConfig::self(), &NeoChatConfig::ShowLinkPreviewChanged, this, &MessageContentModel::updateLinkPreviewer); updateComponents();
});
connect(NeoChatConfig::self(), &NeoChatConfig::ShowLinkPreviewChanged, this, [this]() {
updateComponents();
});
} }
updateLinkPreviewer();
updateComponents(); updateComponents();
} }
@@ -197,8 +201,9 @@ QVariant MessageContentModel::data(const QModelIndex &index, int role) const
return eventHandler.getReplyMediaInfo(); return eventHandler.getReplyMediaInfo();
} }
if (role == LinkPreviewerRole) { if (role == LinkPreviewerRole) {
if (m_linkPreviewer != nullptr) { if (component.type == MessageComponentType::LinkPreview) {
return QVariant::fromValue<LinkPreviewer *>(m_linkPreviewer); return QVariant::fromValue<LinkPreviewer *>(
dynamic_cast<NeoChatConnection *>(m_room->connection())->previewerForLink(component.attributes["link"_ls].toUrl()));
} else { } else {
return QVariant::fromValue<LinkPreviewer *>(emptyLinkPreview); return QVariant::fromValue<LinkPreviewer *>(emptyLinkPreview);
} }
@@ -272,13 +277,7 @@ void MessageContentModel::updateComponents(bool isEditing)
m_components.append(componentsForType(eventHandler.messageComponentType())); m_components.append(componentsForType(eventHandler.messageComponentType()));
} }
if (m_linkPreviewer != nullptr) { addLinkPreviews();
if (m_linkPreviewer->loaded()) {
m_components += MessageComponent{MessageComponentType::LinkPreview, QString(), {}};
} else {
m_components += MessageComponent{MessageComponentType::LinkPreviewLoad, QString(), {}};
}
}
endResetModel(); endResetModel();
} }
@@ -326,41 +325,60 @@ QList<MessageComponent> MessageContentModel::componentsForType(MessageComponentT
} }
} }
void MessageContentModel::updateLinkPreviewer() MessageComponent MessageContentModel::linkPreviewComponent(const QUrl &link)
{ {
if (m_room == nullptr || m_event == nullptr) { const auto linkPreviewer = dynamic_cast<NeoChatConnection *>(m_room->connection())->previewerForLink(link);
if (m_linkPreviewer != nullptr) { if (linkPreviewer == nullptr) {
m_linkPreviewer->disconnect(this); return {};
m_linkPreviewer = nullptr;
updateComponents();
}
return;
} }
if (!m_room->urlPreviewEnabled()) { if (linkPreviewer->loaded()) {
if (m_linkPreviewer != nullptr) { return MessageComponent{MessageComponentType::LinkPreview, QString(), {{"link"_ls, link}}};
m_linkPreviewer->disconnect(this); } else {
m_linkPreviewer = nullptr; connect(linkPreviewer, &LinkPreviewer::loadedChanged, [this, link]() {
updateComponents(); const auto linkPreviewer = dynamic_cast<NeoChatConnection *>(m_room->connection())->previewerForLink(link);
} if (linkPreviewer != nullptr && linkPreviewer->loaded()) {
return; for (auto &component : m_components) {
} if (component.attributes["link"_ls].toUrl() == link) {
if (const auto event = eventCast<const Quotient::RoomMessageEvent>(m_event)) {
if (LinkPreviewer::hasPreviewableLinks(event)) {
m_linkPreviewer = dynamic_cast<NeoChatConnection *>(m_room->connection())->previewerForLink(LinkPreviewer::linkPreview(event));
updateComponents();
if (m_linkPreviewer != nullptr) {
connect(m_linkPreviewer, &LinkPreviewer::loadedChanged, [this]() {
if (m_linkPreviewer != nullptr && m_linkPreviewer->loaded()) {
// HACK: Because DelegateChooser can't switch the delegate on dataChanged it has to think there is a new delegate. // HACK: Because DelegateChooser can't switch the delegate on dataChanged it has to think there is a new delegate.
beginResetModel(); beginResetModel();
m_components[m_components.size() - 1].type = MessageComponentType::LinkPreview; component.type = MessageComponentType::LinkPreview;
endResetModel(); endResetModel();
} }
}); }
}
});
return MessageComponent{MessageComponentType::LinkPreviewLoad, QString(), {{"link"_ls, link}}};
}
}
void MessageContentModel::addLinkPreviews()
{
int i = 0;
while (i < m_components.size()) {
const auto component = m_components.at(i);
if (component.type == MessageComponentType::Text || component.type == MessageComponentType::Quote) {
if (LinkPreviewer::hasPreviewableLinks(component.content)) {
const auto links = LinkPreviewer::linkPreviews(component.content);
for (qsizetype j = 0; j < links.size(); ++j) {
if (!m_removedLinkPreviews.contains(links[j])) {
m_components.insert(i + j + 1, linkPreviewComponent(links[j]));
}
};
} }
} }
i++;
}
}
void MessageContentModel::closeLinkPreview(int row)
{
if (m_components[row].type == MessageComponentType::LinkPreview || m_components[row].type == MessageComponentType::LinkPreviewLoad) {
beginResetModel();
m_removedLinkPreviews += m_components[row].attributes["link"_ls].toUrl();
m_components.remove(row);
m_components.squeeze();
updateComponents();
endResetModel();
} }
} }

View File

@@ -88,6 +88,13 @@ public:
*/ */
[[nodiscard]] QHash<int, QByteArray> roleNames() const override; [[nodiscard]] QHash<int, QByteArray> roleNames() const override;
/**
* @brief Close the link preview at the given index.
*
* If the given index is not a link preview component, nothing happens.
*/
Q_INVOKABLE void closeLinkPreview(int row);
private: private:
QPointer<NeoChatRoom> m_room; QPointer<NeoChatRoom> m_room;
const Quotient::RoomEvent *m_event = nullptr; const Quotient::RoomEvent *m_event = nullptr;
@@ -95,12 +102,14 @@ private:
QList<MessageComponent> m_components; QList<MessageComponent> m_components;
void updateComponents(bool isEditing = false); void updateComponents(bool isEditing = false);
QPointer<LinkPreviewer> m_linkPreviewer;
ItineraryModel *m_itineraryModel = nullptr; ItineraryModel *m_itineraryModel = nullptr;
QList<MessageComponent> componentsForType(MessageComponentType::Type type); QList<MessageComponent> componentsForType(MessageComponentType::Type type);
MessageComponent linkPreviewComponent(const QUrl &link);
void addLinkPreviews();
QList<QUrl> m_removedLinkPreviews;
void updateLinkPreviewer();
void updateItineraryModel(); void updateItineraryModel();
bool m_emptyItinerary = false; bool m_emptyItinerary = false;

View File

@@ -166,6 +166,7 @@ QQC2.Control {
root.selectedTextChanged(selectedText); root.selectedTextChanged(selectedText);
} }
onShowMessageMenu: root.showMessageMenu() onShowMessageMenu: root.showMessageMenu()
onRemoveLinkPreview: index => root.contentModel.closeLinkPreview(index)
} }
} }
} }

View File

@@ -14,6 +14,11 @@ import org.kde.kirigami as Kirigami
QQC2.Control { QQC2.Control {
id: root id: root
/**
* @brief The index of the delegate in the model.
*/
required property int index
/** /**
* @brief The link preview properties. * @brief The link preview properties.
* *
@@ -32,7 +37,7 @@ QQC2.Control {
* When the content of the link preview is larger than this it will be * When the content of the link preview is larger than this it will be
* elided/hidden until maximized. * elided/hidden until maximized.
*/ */
property var defaultHeight: Kirigami.Units.gridUnit * 3 + Kirigami.Units.smallSpacing * 2 property var defaultHeight: Kirigami.Units.gridUnit * 3 + Kirigami.Units.largeSpacing * 2
property bool truncated: linkPreviewDescription.truncated || !linkPreviewDescription.visible property bool truncated: linkPreviewDescription.truncated || !linkPreviewDescription.visible
@@ -41,6 +46,11 @@ QQC2.Control {
*/ */
property real maxContentWidth: -1 property real maxContentWidth: -1
/**
* @brief Request for this delegate to be removed.
*/
signal remove(int index)
Layout.fillWidth: true Layout.fillWidth: true
Layout.maximumWidth: root.maxContentWidth Layout.maximumWidth: root.maxContentWidth
@@ -110,6 +120,23 @@ QQC2.Control {
} }
} }
QQC2.Button {
id: closeButton
anchors.right: parent.right
anchors.top: parent.top
visible: root.hovered
text: i18nc("As in remove the link preview so it's no longer shown", "Remove preview")
icon.name: "dialog-close"
display: QQC2.AbstractButton.IconOnly
onClicked: root.remove(root.index)
QQC2.ToolTip {
text: closeButton.text
visible: closeButton.hovered
delay: Kirigami.Units.toolTipDelay
}
}
QQC2.Button { QQC2.Button {
id: maximizeButton id: maximizeButton
anchors.right: parent.right anchors.right: parent.right
@@ -122,7 +149,7 @@ QQC2.Control {
QQC2.ToolTip { QQC2.ToolTip {
text: maximizeButton.text text: maximizeButton.text
visible: hovered visible: maximizeButton.hovered
delay: Kirigami.Units.toolTipDelay delay: Kirigami.Units.toolTipDelay
} }
} }

View File

@@ -10,9 +10,14 @@ import org.kde.kirigami as Kirigami
/** /**
* @brief A component to show a link preview loading from a message. * @brief A component to show a link preview loading from a message.
*/ */
RowLayout { QQC2.Control {
id: root id: root
/**
* @brief The index of the delegate in the model.
*/
required property int index
required property int type required property int type
/** /**
@@ -28,6 +33,11 @@ RowLayout {
*/ */
property real maxContentWidth: -1 property real maxContentWidth: -1
/**
* @brief Request for this delegate to be removed.
*/
signal remove(int index)
enum Type { enum Type {
Reply, Reply,
LinkPreview LinkPreview
@@ -36,24 +46,46 @@ RowLayout {
Layout.fillWidth: true Layout.fillWidth: true
Layout.maximumWidth: root.maxContentWidth Layout.maximumWidth: root.maxContentWidth
Rectangle { contentItem : RowLayout {
Layout.fillHeight: true spacing: Kirigami.Units.smallSpacing
width: Kirigami.Units.smallSpacing
color: Kirigami.Theme.highlightColor Rectangle {
} Layout.fillHeight: true
QQC2.BusyIndicator {} width: Kirigami.Units.smallSpacing
Kirigami.Heading { color: Kirigami.Theme.highlightColor
Layout.fillWidth: true }
Layout.minimumHeight: root.defaultHeight QQC2.BusyIndicator {}
verticalAlignment: Text.AlignVCenter Kirigami.Heading {
level: 2 Layout.fillWidth: true
text: { Layout.minimumHeight: root.defaultHeight
switch (root.type) { verticalAlignment: Text.AlignVCenter
case LoadComponent.Reply: level: 2
return i18n("Loading reply"); text: {
case LoadComponent.LinkPreview: switch (root.type) {
return i18n("Loading URL preview"); case LoadComponent.Reply:
return i18n("Loading reply");
case LoadComponent.LinkPreview:
return i18n("Loading URL preview");
}
} }
} }
} }
QQC2.Button {
id: closeButton
anchors.right: parent.right
anchors.top: parent.top
visible: root.hovered && root.type === LoadComponent.LinkPreview
text: i18nc("As in remove the link preview so it's no longer shown", "Remove preview")
icon.name: "dialog-close"
display: QQC2.AbstractButton.IconOnly
onClicked: root.remove(root.index)
QQC2.ToolTip {
text: closeButton.text
visible: closeButton.hovered
delay: Kirigami.Units.toolTipDelay
}
}
} }

View File

@@ -60,6 +60,8 @@ DelegateChooser {
*/ */
signal showMessageMenu signal showMessageMenu
signal removeLinkPreview(int index)
role: "componentType" role: "componentType"
DelegateChoice { DelegateChoice {
@@ -199,6 +201,7 @@ DelegateChooser {
roleValue: MessageComponentType.LinkPreview roleValue: MessageComponentType.LinkPreview
delegate: LinkPreviewComponent { delegate: LinkPreviewComponent {
maxContentWidth: root.maxContentWidth maxContentWidth: root.maxContentWidth
onRemove: index => root.removeLinkPreview(index)
} }
} }
@@ -207,6 +210,7 @@ DelegateChooser {
delegate: LoadComponent { delegate: LoadComponent {
type: LoadComponent.LinkPreview type: LoadComponent.LinkPreview
maxContentWidth: root.maxContentWidth maxContentWidth: root.maxContentWidth
onRemove: index => root.removeLinkPreview(index)
} }
} }