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

@@ -89,38 +89,23 @@ bool LinkPreviewer::empty() const
return m_url.isEmpty();
}
QUrl LinkPreviewer::linkPreview(const Quotient::RoomMessageEvent *event)
QList<QUrl> LinkPreviewer::linkPreviews(QString string)
{
if (event == nullptr) {
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 data = string.remove(TextRegex::removeRichReply);
auto linksMatch = TextRegex::url.globalMatch(data);
QList<QUrl> links;
while (linksMatch.hasNext()) {
auto link = linksMatch.next().captured();
if (!link.contains(QStringLiteral("matrix.to"))) {
return QUrl(link);
if (!link.contains(QStringLiteral("matrix.to")) && !links.contains(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"

View File

@@ -7,11 +7,6 @@
#include <QQmlEngine>
#include <QUrl>
namespace Quotient
{
class RoomMessageEvent;
}
class NeoChatRoom;
/**
@@ -71,19 +66,19 @@ public:
[[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.
*/
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
* 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:
bool m_loaded;

View File

@@ -11,6 +11,7 @@
#include <Quotient/events/stickerevent.h>
#include <KLocalizedString>
#include <qlist.h>
#ifndef Q_OS_ANDROID
#include <KSyntaxHighlighting/Definition>
@@ -110,11 +111,14 @@ MessageContentModel::MessageContentModel(const Quotient::RoomEvent *event, NeoCh
endResetModel();
}
});
connect(m_room, &NeoChatRoom::urlPreviewEnabledChanged, this, &MessageContentModel::updateLinkPreviewer);
connect(NeoChatConfig::self(), &NeoChatConfig::ShowLinkPreviewChanged, this, &MessageContentModel::updateLinkPreviewer);
connect(m_room, &NeoChatRoom::urlPreviewEnabledChanged, this, [this]() {
updateComponents();
});
connect(NeoChatConfig::self(), &NeoChatConfig::ShowLinkPreviewChanged, this, [this]() {
updateComponents();
});
}
updateLinkPreviewer();
updateComponents();
}
@@ -197,8 +201,9 @@ QVariant MessageContentModel::data(const QModelIndex &index, int role) const
return eventHandler.getReplyMediaInfo();
}
if (role == LinkPreviewerRole) {
if (m_linkPreviewer != nullptr) {
return QVariant::fromValue<LinkPreviewer *>(m_linkPreviewer);
if (component.type == MessageComponentType::LinkPreview) {
return QVariant::fromValue<LinkPreviewer *>(
dynamic_cast<NeoChatConnection *>(m_room->connection())->previewerForLink(component.attributes["link"_ls].toUrl()));
} else {
return QVariant::fromValue<LinkPreviewer *>(emptyLinkPreview);
}
@@ -272,13 +277,7 @@ void MessageContentModel::updateComponents(bool isEditing)
m_components.append(componentsForType(eventHandler.messageComponentType()));
}
if (m_linkPreviewer != nullptr) {
if (m_linkPreviewer->loaded()) {
m_components += MessageComponent{MessageComponentType::LinkPreview, QString(), {}};
} else {
m_components += MessageComponent{MessageComponentType::LinkPreviewLoad, QString(), {}};
}
}
addLinkPreviews();
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) {
if (m_linkPreviewer != nullptr) {
m_linkPreviewer->disconnect(this);
m_linkPreviewer = nullptr;
updateComponents();
}
return;
const auto linkPreviewer = dynamic_cast<NeoChatConnection *>(m_room->connection())->previewerForLink(link);
if (linkPreviewer == nullptr) {
return {};
}
if (!m_room->urlPreviewEnabled()) {
if (m_linkPreviewer != nullptr) {
m_linkPreviewer->disconnect(this);
m_linkPreviewer = nullptr;
updateComponents();
}
return;
}
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()) {
if (linkPreviewer->loaded()) {
return MessageComponent{MessageComponentType::LinkPreview, QString(), {{"link"_ls, link}}};
} else {
connect(linkPreviewer, &LinkPreviewer::loadedChanged, [this, link]() {
const auto linkPreviewer = dynamic_cast<NeoChatConnection *>(m_room->connection())->previewerForLink(link);
if (linkPreviewer != nullptr && linkPreviewer->loaded()) {
for (auto &component : m_components) {
if (component.attributes["link"_ls].toUrl() == link) {
// 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].type = MessageComponentType::LinkPreview;
component.type = MessageComponentType::LinkPreview;
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;
/**
* @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:
QPointer<NeoChatRoom> m_room;
const Quotient::RoomEvent *m_event = nullptr;
@@ -95,12 +102,14 @@ private:
QList<MessageComponent> m_components;
void updateComponents(bool isEditing = false);
QPointer<LinkPreviewer> m_linkPreviewer;
ItineraryModel *m_itineraryModel = nullptr;
QList<MessageComponent> componentsForType(MessageComponentType::Type type);
MessageComponent linkPreviewComponent(const QUrl &link);
void addLinkPreviews();
QList<QUrl> m_removedLinkPreviews;
void updateLinkPreviewer();
void updateItineraryModel();
bool m_emptyItinerary = false;

View File

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

View File

@@ -14,6 +14,11 @@ import org.kde.kirigami as Kirigami
QQC2.Control {
id: root
/**
* @brief The index of the delegate in the model.
*/
required property int index
/**
* @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
* 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
@@ -41,6 +46,11 @@ QQC2.Control {
*/
property real maxContentWidth: -1
/**
* @brief Request for this delegate to be removed.
*/
signal remove(int index)
Layout.fillWidth: true
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 {
id: maximizeButton
anchors.right: parent.right
@@ -122,7 +149,7 @@ QQC2.Control {
QQC2.ToolTip {
text: maximizeButton.text
visible: hovered
visible: maximizeButton.hovered
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.
*/
RowLayout {
QQC2.Control {
id: root
/**
* @brief The index of the delegate in the model.
*/
required property int index
required property int type
/**
@@ -28,6 +33,11 @@ RowLayout {
*/
property real maxContentWidth: -1
/**
* @brief Request for this delegate to be removed.
*/
signal remove(int index)
enum Type {
Reply,
LinkPreview
@@ -36,24 +46,46 @@ RowLayout {
Layout.fillWidth: true
Layout.maximumWidth: root.maxContentWidth
Rectangle {
Layout.fillHeight: true
width: Kirigami.Units.smallSpacing
color: Kirigami.Theme.highlightColor
}
QQC2.BusyIndicator {}
Kirigami.Heading {
Layout.fillWidth: true
Layout.minimumHeight: root.defaultHeight
verticalAlignment: Text.AlignVCenter
level: 2
text: {
switch (root.type) {
case LoadComponent.Reply:
return i18n("Loading reply");
case LoadComponent.LinkPreview:
return i18n("Loading URL preview");
contentItem : RowLayout {
spacing: Kirigami.Units.smallSpacing
Rectangle {
Layout.fillHeight: true
width: Kirigami.Units.smallSpacing
color: Kirigami.Theme.highlightColor
}
QQC2.BusyIndicator {}
Kirigami.Heading {
Layout.fillWidth: true
Layout.minimumHeight: root.defaultHeight
verticalAlignment: Text.AlignVCenter
level: 2
text: {
switch (root.type) {
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 removeLinkPreview(int index)
role: "componentType"
DelegateChoice {
@@ -199,6 +201,7 @@ DelegateChooser {
roleValue: MessageComponentType.LinkPreview
delegate: LinkPreviewComponent {
maxContentWidth: root.maxContentWidth
onRemove: index => root.removeLinkPreview(index)
}
}
@@ -207,6 +210,7 @@ DelegateChooser {
delegate: LoadComponent {
type: LoadComponent.LinkPreview
maxContentWidth: root.maxContentWidth
onRemove: index => root.removeLinkPreview(index)
}
}