Use MessageContentModel for replies

This allows code and other components to be displayed nicely.
This commit is contained in:
James Graham
2024-05-27 14:54:42 +00:00
parent 3615c3e8e5
commit efb72652ce
14 changed files with 536 additions and 301 deletions

View File

@@ -47,11 +47,11 @@ public:
LiveLocation, /**< The initial event of a shared live location (i.e., the place where this is supposed to be shown in the timeline). */
Encrypted, /**< An encrypted message that cannot be decrypted. */
Reply, /**< A component to show a replied-to message. */
ReplyLoad, /**< A loading dialog for a reply. */
LinkPreview, /**< A preview of a URL in the message. */
LinkPreviewLoad, /**< A loading dialog for a link preview. */
Edit, /**< A text edit for editing a message. */
Verification, /**< A user verification session start message. */
Loading, /**< The component is loading. */
Other, /**< Anything that cannot be classified as another type. */
};
Q_ENUM(Type);

View File

@@ -11,6 +11,7 @@
#include <Quotient/events/stickerevent.h>
#include <KLocalizedString>
#include <Quotient/qt_connection_util.h>
#ifndef Q_OS_ANDROID
#include <KSyntaxHighlighting/Definition>
@@ -29,95 +30,124 @@
using namespace Quotient;
MessageContentModel::MessageContentModel(const Quotient::RoomEvent *event, NeoChatRoom *room)
MessageContentModel::MessageContentModel(NeoChatRoom *room, const Quotient::RoomEvent *event, bool isReply)
: QAbstractListModel(nullptr)
, m_room(room)
, m_eventId(event != nullptr ? event->id() : QString())
, m_event(event)
, m_isReply(isReply)
{
if (m_room != nullptr) {
connect(m_room, &NeoChatRoom::pendingEventAboutToMerge, this, [this](Quotient::RoomEvent *serverEvent) {
if (m_room != nullptr && m_event != nullptr) {
if (m_event->id() == serverEvent->id()) {
beginResetModel();
m_event = serverEvent;
endResetModel();
}
}
});
connect(m_room, &NeoChatRoom::replacedEvent, this, [this](const Quotient::RoomEvent *newEvent) {
if (m_room != nullptr && m_event != nullptr) {
if (m_event->id() == newEvent->id()) {
beginResetModel();
m_event = newEvent;
endResetModel();
}
}
});
connect(m_room, &NeoChatRoom::replyLoaded, this, [this](const QString &eventId, const QString &replyId) {
Q_UNUSED(eventId)
if (m_event != nullptr && m_room != nullptr) {
const auto eventHandler = EventHandler(m_room, m_event);
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].type = MessageComponentType::Reply;
endResetModel();
}
}
});
connect(m_room, &NeoChatRoom::newFileTransfer, this, [this](const QString &eventId) {
if (m_event != nullptr && eventId == m_event->id()) {
Q_EMIT dataChanged(index(0), index(rowCount() - 1), {FileTransferInfoRole});
}
});
connect(m_room, &NeoChatRoom::fileTransferProgress, this, [this](const QString &eventId) {
if (m_event != nullptr && eventId == m_event->id()) {
Q_EMIT dataChanged(index(0), index(rowCount() - 1), {FileTransferInfoRole});
}
});
connect(m_room, &NeoChatRoom::fileTransferCompleted, this, [this](const QString &eventId) {
if (m_event != nullptr && eventId == m_event->id()) {
updateComponents();
Q_EMIT dataChanged(index(0), index(rowCount() - 1), {FileTransferInfoRole});
initializeModel();
}
QString mxcUrl;
if (auto event = eventCast<const Quotient::RoomMessageEvent>(m_event)) {
if (event->hasFileContent()) {
mxcUrl = event->content()->fileInfo()->url().toString();
}
} else if (auto event = eventCast<const Quotient::StickerEvent>(m_event)) {
mxcUrl = event->image().fileInfo()->url().toString();
}
if (mxcUrl.isEmpty()) {
return;
}
auto localPath = m_room->fileTransferInfo(m_event->id()).localPath.toLocalFile();
auto config = KSharedConfig::openStateConfig(QStringLiteral("neochatdownloads"))->group(QStringLiteral("downloads"));
config.writePathEntry(mxcUrl.mid(6), localPath);
}
});
connect(m_room, &NeoChatRoom::fileTransferFailed, this, [this](const QString &eventId) {
if (m_event != nullptr && eventId == m_event->id()) {
MessageContentModel::MessageContentModel(NeoChatRoom *room, const QString &eventId, bool isReply)
: QAbstractListModel(nullptr)
, m_room(room)
, m_eventId(eventId)
, m_isReply(isReply)
{
initializeModel();
}
void MessageContentModel::initializeModel()
{
Q_ASSERT(m_room != nullptr);
// Allow making a model for an event that is being downloaded but will appear later
// e.g. a reply, but we need an ID to know when it has arrived.
Q_ASSERT(!m_eventId.isEmpty());
Quotient::connectUntil(m_room.get(), &NeoChatRoom::extraEventLoaded, this, [this](const QString &eventId) {
if (m_room != nullptr) {
if (eventId == m_eventId) {
m_event = m_room->getEvent(eventId);
Q_EMIT eventUpdated();
updateReplyModel();
updateComponents();
Q_EMIT dataChanged(index(0), index(rowCount() - 1), {FileTransferInfoRole});
return true;
}
});
connect(m_room->editCache(), &ChatBarCache::relationIdChanged, this, [this](const QString &oldEventId, const QString &newEventId) {
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();
}
});
connect(m_room, &NeoChatRoom::urlPreviewEnabledChanged, this, [this]() {
updateComponents();
});
connect(NeoChatConfig::self(), &NeoChatConfig::ShowLinkPreviewChanged, this, [this]() {
updateComponents();
});
}
return false;
});
if (m_event == nullptr) {
m_room->downloadEventFromServer(m_eventId);
}
connect(m_room, &NeoChatRoom::pendingEventAboutToMerge, this, [this](Quotient::RoomEvent *serverEvent) {
if (m_room != nullptr && m_event != nullptr) {
if (m_event->id() == serverEvent->id()) {
beginResetModel();
m_event = serverEvent;
Q_EMIT eventUpdated();
endResetModel();
}
}
});
connect(m_room, &NeoChatRoom::replacedEvent, this, [this](const Quotient::RoomEvent *newEvent) {
if (m_room != nullptr && m_event != nullptr) {
if (m_event->id() == newEvent->id()) {
beginResetModel();
m_event = newEvent;
Q_EMIT eventUpdated();
endResetModel();
}
}
});
connect(m_room, &NeoChatRoom::newFileTransfer, this, [this](const QString &eventId) {
if (m_event != nullptr && eventId == m_event->id()) {
Q_EMIT dataChanged(index(0), index(rowCount() - 1), {FileTransferInfoRole});
}
});
connect(m_room, &NeoChatRoom::fileTransferProgress, this, [this](const QString &eventId) {
if (m_event != nullptr && eventId == m_event->id()) {
Q_EMIT dataChanged(index(0), index(rowCount() - 1), {FileTransferInfoRole});
}
});
connect(m_room, &NeoChatRoom::fileTransferCompleted, this, [this](const QString &eventId) {
if (m_event != nullptr && eventId == m_event->id()) {
updateComponents();
Q_EMIT dataChanged(index(0), index(rowCount() - 1), {FileTransferInfoRole});
QString mxcUrl;
if (auto event = eventCast<const Quotient::RoomMessageEvent>(m_event)) {
if (event->hasFileContent()) {
mxcUrl = event->content()->fileInfo()->url().toString();
}
} else if (auto event = eventCast<const Quotient::StickerEvent>(m_event)) {
mxcUrl = event->image().fileInfo()->url().toString();
}
if (mxcUrl.isEmpty()) {
return;
}
auto localPath = m_room->fileTransferInfo(m_event->id()).localPath.toLocalFile();
auto config = KSharedConfig::openStateConfig(QStringLiteral("neochatdownloads"))->group(QStringLiteral("downloads"));
config.writePathEntry(mxcUrl.mid(6), localPath);
}
});
connect(m_room, &NeoChatRoom::fileTransferFailed, this, [this](const QString &eventId) {
if (m_event != nullptr && eventId == m_event->id()) {
updateComponents();
Q_EMIT dataChanged(index(0), index(rowCount() - 1), {FileTransferInfoRole});
}
});
connect(m_room->editCache(), &ChatBarCache::relationIdChanged, this, [this](const QString &oldEventId, const QString &newEventId) {
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();
}
});
connect(m_room, &NeoChatRoom::urlPreviewEnabledChanged, this, [this]() {
updateComponents();
});
connect(NeoChatConfig::self(), &NeoChatConfig::ShowLinkPreviewChanged, this, [this]() {
updateComponents();
});
if (m_event != nullptr) {
updateReplyModel();
}
updateComponents();
}
@@ -138,6 +168,12 @@ QVariant MessageContentModel::data(const QModelIndex &index, int role) const
const auto component = m_components[index.row()];
if (role == DisplayRole) {
if (component.type == MessageComponentType::Loading && m_isReply) {
return i18n("Loading reply");
}
if (m_event == nullptr) {
return QString();
}
if (m_event->isRedacted()) {
auto reason = m_event->redactedBecause()->reason();
return (reason.isEmpty()) ? i18n("<i>[This message was deleted]</i>")
@@ -184,20 +220,14 @@ QVariant MessageContentModel::data(const QModelIndex &index, int role) const
if (role == IsReplyRole) {
return eventHandler.hasReply();
}
if (role == ReplyComponentType) {
return eventHandler.replyMessageComponentType();
}
if (role == ReplyEventIdRole) {
return eventHandler.getReplyId();
}
if (role == ReplyAuthorRole) {
return eventHandler.getReplyAuthor();
}
if (role == ReplyDisplayRole) {
return eventHandler.getReplyRichBody();
}
if (role == ReplyMediaInfoRole) {
return eventHandler.getReplyMediaInfo();
if (role == ReplyContentModelRole) {
return QVariant::fromValue<MessageContentModel *>(m_replyModel);
}
if (role == LinkPreviewerRole) {
if (component.type == MessageComponentType::LinkPreview) {
@@ -233,11 +263,9 @@ QHash<int, QByteArray> MessageContentModel::roleNames() const
roles[AssetRole] = "asset";
roles[PollHandlerRole] = "pollHandler";
roles[IsReplyRole] = "isReply";
roles[ReplyComponentType] = "replyComponentType";
roles[ReplyEventIdRole] = "replyEventId";
roles[ReplyAuthorRole] = "replyAuthor";
roles[ReplyDisplayRole] = "replyDisplay";
roles[ReplyMediaInfoRole] = "replyMediaInfo";
roles[ReplyContentModelRole] = "replyContentModel";
roles[LinkPreviewerRole] = "linkPreviewer";
return roles;
}
@@ -247,6 +275,12 @@ void MessageContentModel::updateComponents(bool isEditing)
beginResetModel();
m_components.clear();
if (m_event == nullptr) {
m_components += MessageComponent{MessageComponentType::Loading, QString(), {}};
endResetModel();
return;
}
if (eventCast<const Quotient::RoomMessageEvent>(m_event)
&& eventCast<const Quotient::RoomMessageEvent>(m_event)->rawMsgtype() == QStringLiteral("m.key.verification.request")) {
m_components += MessageComponent{MessageComponentType::Verification, QString(), {}};
@@ -260,19 +294,14 @@ void MessageContentModel::updateComponents(bool isEditing)
return;
}
EventHandler eventHandler(m_room, m_event);
if (eventHandler.hasReply()) {
if (m_room->findInTimeline(eventHandler.getReplyId()) == m_room->historyEdge()) {
m_components += MessageComponent{MessageComponentType::ReplyLoad, QString(), {}};
m_room->loadReply(m_event->id(), eventHandler.getReplyId());
} else {
m_components += MessageComponent{MessageComponentType::Reply, QString(), {}};
}
if (m_replyModel != nullptr) {
m_components += MessageComponent{MessageComponentType::Reply, QString(), {}};
}
if (isEditing) {
m_components += MessageComponent{MessageComponentType::Edit, QString(), {}};
} else {
EventHandler eventHandler(m_room, m_event);
m_components.append(componentsForType(eventHandler.messageComponentType()));
}
@@ -283,6 +312,29 @@ void MessageContentModel::updateComponents(bool isEditing)
endResetModel();
}
void MessageContentModel::updateReplyModel()
{
if (m_event == nullptr || m_replyModel != nullptr || m_isReply) {
return;
}
EventHandler eventHandler(m_room, m_event);
if (!eventHandler.hasReply()) {
return;
}
const auto replyEvent = m_room->findInTimeline(eventHandler.getReplyId());
if (replyEvent == m_room->historyEdge()) {
m_replyModel = new MessageContentModel(m_room, eventHandler.getReplyId(), true);
} else {
m_replyModel = new MessageContentModel(m_room, replyEvent->get(), true);
}
connect(m_replyModel, &MessageContentModel::eventUpdated, this, [this]() {
Q_EMIT dataChanged(index(0), index(0), {ReplyAuthorRole});
});
}
QList<MessageComponent> MessageContentModel::componentsForType(MessageComponentType::Type type)
{
switch (type) {

View File

@@ -58,17 +58,16 @@ public:
PollHandlerRole, /**< The PollHandler for the event, if any. */
IsReplyRole, /**< Is the message a reply to another event. */
ReplyComponentType, /**< The type of component to visualise the reply message. */
ReplyEventIdRole, /**< The matrix ID of the message that was replied to. */
ReplyAuthorRole, /**< The author of the event that was replied to. */
ReplyDisplayRole, /**< The body of the message that was replied to. */
ReplyMediaInfoRole, /**< The media info of the message that was replied to. */
ReplyContentModelRole, /**< The MessageContentModel for the reply event. */
LinkPreviewerRole, /**< The link preview details. */
};
Q_ENUM(Roles)
explicit MessageContentModel(const Quotient::RoomEvent *event, NeoChatRoom *room);
explicit MessageContentModel(NeoChatRoom *room, const Quotient::RoomEvent *event, bool isReply = false);
MessageContentModel(NeoChatRoom *room, const QString &eventId, bool isReply = false);
/**
* @brief Get the given role value at the given index.
@@ -98,13 +97,24 @@ public:
*/
Q_INVOKABLE void closeLinkPreview(int row);
Q_SIGNALS:
void eventUpdated();
private:
QPointer<NeoChatRoom> m_room;
QString m_eventId;
const Quotient::RoomEvent *m_event = nullptr;
bool m_isReply;
void initializeModel();
QList<MessageComponent> m_components;
void updateComponents(bool isEditing = false);
QPointer<MessageContentModel> m_replyModel;
void updateReplyModel();
ItineraryModel *m_itineraryModel = nullptr;
QList<MessageComponent> componentsForType(MessageComponentType::Type type);

View File

@@ -441,11 +441,11 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const
if (role == ContentModelRole) {
if (!evt.isStateEvent()) {
return QVariant::fromValue<MessageContentModel *>(new MessageContentModel(&evt, m_currentRoom));
return QVariant::fromValue<MessageContentModel *>(new MessageContentModel(m_currentRoom, &evt));
}
if (evt.isStateEvent()) {
if (evt.matrixType() == QStringLiteral("org.matrix.msc3672.beacon_info")) {
return QVariant::fromValue<MessageContentModel *>(new MessageContentModel(&evt, m_currentRoom));
return QVariant::fromValue<MessageContentModel *>(new MessageContentModel(m_currentRoom, &evt));
}
}
return {};

View File

@@ -113,11 +113,11 @@ QVariant SearchModel::data(const QModelIndex &index, int role) const
return eventHandler.threadRoot();
case ContentModelRole: {
if (!event.isStateEvent()) {
return QVariant::fromValue<MessageContentModel *>(new MessageContentModel(&event, m_room));
return QVariant::fromValue<MessageContentModel *>(new MessageContentModel(m_room, &event));
}
if (event.isStateEvent()) {
if (event.matrixType() == QStringLiteral("org.matrix.msc3672.beacon_info")) {
return QVariant::fromValue<MessageContentModel *>(new MessageContentModel(&event, m_room));
return QVariant::fromValue<MessageContentModel *>(new MessageContentModel(m_room, &event));
}
}
return {};

View File

@@ -70,6 +70,8 @@ NeoChatRoom::NeoChatRoom(Connection *connection, QString roomId, JoinState joinS
});
connect(this, &Room::addedMessages, this, &NeoChatRoom::readMarkerLoadedChanged);
connect(this, &Room::aboutToAddHistoricalMessages, this, &NeoChatRoom::cleanupExtraEventRange);
connect(this, &Room::aboutToAddNewMessages, this, &NeoChatRoom::cleanupExtraEventRange);
const auto &roomLastMessageProvider = RoomLastMessageProvider::self();
@@ -1701,6 +1703,40 @@ QUrl NeoChatRoom::avatarForMember(Quotient::User *user) const
}
}
void NeoChatRoom::downloadEventFromServer(const QString &eventId)
{
if (findInTimeline(eventId) != historyEdge()) {
return;
}
auto job = connection()->callApi<GetOneRoomEventJob>(id(), eventId);
connect(job, &BaseJob::success, this, [this, job, eventId] {
// The event may have arrived in the meantime so check it's not in the timeline.
if (findInTimeline(eventId) != historyEdge()) {
return;
}
event_ptr_tt<RoomEvent> event = fromJson<event_ptr_tt<RoomEvent>>(job->jsonData());
m_extraEvents.push_back(std::move(event));
Q_EMIT extraEventLoaded(eventId);
});
}
const RoomEvent *NeoChatRoom::getEvent(const QString &eventId) const
{
if (eventId.isEmpty()) {
return nullptr;
}
const auto timelineIt = findInTimeline(eventId);
if (timelineIt != historyEdge()) {
return timelineIt->get();
}
auto extraIt = std::find_if(m_extraEvents.begin(), m_extraEvents.end(), [eventId](const Quotient::event_ptr_tt<Quotient::RoomEvent> &event) {
return event->id() == eventId;
});
return extraIt != m_extraEvents.end() ? extraIt->get() : nullptr;
}
const RoomEvent *NeoChatRoom::getReplyForEvent(const RoomEvent &event) const
{
const QString &replyEventId = event.contentJson()["m.relates_to"_ls].toObject()["m.in_reply_to"_ls].toObject()["event_id"_ls].toString();
@@ -1721,13 +1757,22 @@ const RoomEvent *NeoChatRoom::getReplyForEvent(const RoomEvent &event) const
return replyPtr;
}
void NeoChatRoom::loadReply(const QString &eventId, const QString &replyId)
void NeoChatRoom::cleanupExtraEventRange(Quotient::RoomEventsRange events)
{
auto job = connection()->callApi<GetOneRoomEventJob>(id(), replyId);
connect(job, &BaseJob::success, this, [this, job, eventId, replyId] {
m_extraEvents.push_back(fromJson<event_ptr_tt<RoomEvent>>(job->jsonData()));
Q_EMIT replyLoaded(eventId, replyId);
for (auto &&event : events) {
cleanupExtraEvent(event->id());
}
}
void NeoChatRoom::cleanupExtraEvent(const QString &eventId)
{
auto it = std::find_if(m_extraEvents.begin(), m_extraEvents.end(), [eventId](Quotient::event_ptr_tt<Quotient::RoomEvent> &event) {
return event->id() == eventId;
});
if (it != m_extraEvents.end()) {
m_extraEvents.erase(it);
}
}
User *NeoChatRoom::invitingUser() const

View File

@@ -632,18 +632,31 @@ public:
Q_INVOKABLE [[nodiscard]] QUrl avatarForMember(Quotient::User *user) const;
/**
* @brief Loads the event with the given id from the server and saves it locally.
*
* Intended to retrieve events that are needed, e.g. replied to events that are
* not currently in the timeline.
*
* If the event is already in the timeline nothing will happen.
*/
void downloadEventFromServer(const QString &eventId);
/**
* @brief Returns the event with the given ID if available.
*
* This function will check both the timeline and extra events and return a
* non-nullptr value if it is found in either.
*
* The result will be nullptr if not found so needs to be managed.
*/
const Quotient::RoomEvent *getEvent(const QString &eventId) const;
/**
* @brief Returns the event that is being replied to. This includes events that were manually loaded using NeoChatRoom::loadReply.
*/
const Quotient::RoomEvent *getReplyForEvent(const Quotient::RoomEvent &event) const;
/**
* Loads the event replyId with the given id from the server and saves it locally.
* For models to update correctly, eventId must be the event that is replying to replyId.
* Intended to load the replied-to event when it isn't available locally.
*/
Q_INVOKABLE void loadReply(const QString &eventId, const QString &replyId);
/**
* If we're invited to this room, the user that invited us. Undefined in other cases.
*/
@@ -674,6 +687,8 @@ private:
QCache<QString, PollHandler> m_polls;
std::vector<Quotient::event_ptr_tt<Quotient::RoomEvent>> m_extraEvents;
void cleanupExtraEventRange(Quotient::RoomEventsRange events);
void cleanupExtraEvent(const QString &eventId);
private Q_SLOTS:
void updatePushNotificationState(QString type);
@@ -703,7 +718,7 @@ Q_SIGNALS:
void defaultUrlPreviewStateChanged();
void urlPreviewEnabledChanged();
void maxRoomVersionChanged();
void replyLoaded(const QString &eventId, const QString &replyId);
void extraEventLoaded(const QString &eventId);
public Q_SLOTS:
/**

View File

@@ -18,6 +18,7 @@ qt_add_qml_module(timeline
ReactionDelegate.qml
SectionDelegate.qml
MessageComponentChooser.qml
ReplyMessageComponentChooser.qml
AudioComponent.qml
CodeComponent.qml
EncryptedComponent.qml
@@ -32,6 +33,7 @@ qt_add_qml_module(timeline
FlightReservationComponent.qml
HotelReservationComponent.qml
LinkPreviewComponent.qml
LinkPreviewLoadComponent.qml
LiveLocationComponent.qml
LoadComponent.qml
LocationComponent.qml

View File

@@ -34,7 +34,7 @@ QQC2.Control {
/**
* @brief The timestamp of the message.
*/
required property var time
property date time
/**
* @brief The display text of the message.
@@ -135,6 +135,7 @@ QQC2.Control {
}
TapHandler {
enabled: root.time.toString() !== "Invalid Date"
acceptedButtons: Qt.LeftButton
onTapped: RoomManager.maximizeCode(root.author, root.time, root.display, root.componentAttributes.class)
onLongPressed: root.showMessageMenu()
@@ -174,6 +175,7 @@ QQC2.Control {
QQC2.ToolTip.delay: Kirigami.Units.toolTipDelay
}
QQC2.Button {
visible: root.time.toString() !== "Invalid Date"
icon.name: "view-fullscreen"
text: i18nc("@action:button", "Maximize")
display: QQC2.AbstractButton.IconOnly

View File

@@ -0,0 +1,91 @@
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-or-later OR LicenseRef-KDE-Accepted-GPL
import QtQuick
import QtQuick.Controls as QQC2
import QtQuick.Layouts
import org.kde.kirigami as Kirigami
/**
* @brief A component to show a link preview loading from a message.
*/
QQC2.Control {
id: root
/**
* @brief The index of the delegate in the model.
*/
required property int index
required property int type
/**
* @brief Standard height for the link preview.
*
* 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
/**
* @brief The maximum width that the bubble's content can be.
*/
property real maxContentWidth: -1
/**
* @brief Request for this delegate to be removed.
*/
signal remove(int index)
enum Type {
Reply,
LinkPreview
}
Layout.fillWidth: true
Layout.maximumWidth: root.maxContentWidth
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 LinkPreviewLoadComponent.Reply:
return i18n("Loading reply");
case LinkPreviewLoadComponent.LinkPreview:
return i18n("Loading URL preview");
}
}
}
}
QQC2.Button {
id: closeButton
anchors.right: parent.right
anchors.top: parent.top
visible: root.hovered && root.type === LinkPreviewLoadComponent.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

@@ -8,84 +8,29 @@ import QtQuick.Layouts
import org.kde.kirigami as Kirigami
/**
* @brief A component to show a link preview loading from a message.
* @brief A component to show that part of a message is loading.
*/
QQC2.Control {
RowLayout {
id: root
/**
* @brief The index of the delegate in the model.
*/
required property int index
required property int type
/**
* @brief Standard height for the link preview.
*
* 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
required property string display
/**
* @brief The maximum width that the bubble's content can be.
*/
property real maxContentWidth: -1
/**
* @brief Request for this delegate to be removed.
*/
signal remove(int index)
enum Type {
Reply,
LinkPreview
}
Layout.fillWidth: true
Layout.maximumWidth: root.maxContentWidth
spacing: Kirigami.Units.smallSpacing
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
}
QQC2.BusyIndicator {}
Kirigami.Heading {
id: loadingText
Layout.fillWidth: true
verticalAlignment: Text.AlignVCenter
level: 2
text: root.display.length > 0 ? root.display : i18n("Loading")
}
}

View File

@@ -183,17 +183,6 @@ DelegateChooser {
onReplyClicked: eventId => {
root.replyClicked(eventId);
}
onSelectedTextChanged: selectedText => {
root.selectedTextChanged(selectedText);
}
}
}
DelegateChoice {
roleValue: MessageComponentType.ReplyLoad
delegate: LoadComponent {
type: LoadComponent.Reply
maxContentWidth: root.maxContentWidth
}
}
@@ -207,8 +196,8 @@ DelegateChooser {
DelegateChoice {
roleValue: MessageComponentType.LinkPreviewLoad
delegate: LoadComponent {
type: LoadComponent.LinkPreview
delegate: LinkPreviewLoadComponent {
type: LinkPreviewLoadComponent.LinkPreview
maxContentWidth: root.maxContentWidth
onRemove: index => root.removeLinkPreview(index)
}
@@ -231,6 +220,13 @@ DelegateChooser {
}
}
DelegateChoice {
roleValue: MessageComponentType.Loading
delegate: LoadComponent {
maxContentWidth: root.maxContentWidth
}
}
DelegateChoice {
roleValue: MessageComponentType.Other
delegate: Item {}

View File

@@ -24,11 +24,6 @@ import org.kde.neochat
RowLayout {
id: root
/**
* @brief The matrix ID of the reply event.
*/
required property var replyComponentType
/**
* @brief The matrix ID of the reply event.
*/
@@ -53,26 +48,9 @@ RowLayout {
required property var replyAuthor
/**
* @brief The display text of the message replied to.
* @brief The model to visualise the content of the message replied to.
*/
required property string replyDisplay
/**
* @brief The media info for the reply event.
*
* This could be an image, audio, video or file.
*
* This should consist of the following:
* - source - The mxc URL for the media.
* - mimeType - The MIME type of the media.
* - mimeIcon - The MIME icon name.
* - size - The file size in bytes.
* - duration - The length in seconds of the audio media (audio/video only).
* - width - The width in pixels of the audio media (image/video only).
* - height - The height in pixels of the audio media (image/video only).
* - tempInfo - mediaInfo (with the same properties as this except no tempInfo) for a temporary image while the file downloads (image/video only).
*/
required property var replyMediaInfo
required property var replyContentModel
/**
* @brief The maximum width that the bubble's content can be.
@@ -84,12 +62,6 @@ RowLayout {
*/
signal replyClicked(string eventID)
/**
* @brief The user selected text has changed.
*/
signal selectedTextChanged(string selectedText)
implicitHeight: contentColumn.implicitHeight
spacing: Kirigami.Units.largeSpacing
Rectangle {
@@ -101,7 +73,6 @@ RowLayout {
}
ColumnLayout {
id: contentColumn
implicitHeight: headerRow.implicitHeight + (root.replyComponentType != MessageComponentType.Other ? contentRepeater.itemAt(0).implicitHeight + spacing : 0)
spacing: Kirigami.Units.smallSpacing
RowLayout {
@@ -131,75 +102,11 @@ RowLayout {
}
Repeater {
id: contentRepeater
model: [root.replyComponentType]
delegate: DelegateChooser {
role: "modelData"
model: root.replyContentModel
delegate: ReplyMessageComponentChooser {
maxContentWidth: _private.availableContentWidth
DelegateChoice {
roleValue: MessageComponentType.Text
delegate: TextComponent {
display: root.replyDisplay
maxContentWidth: _private.availableContentWidth
onSelectedTextChanged: root.selectedTextChanged(selectedText)
HoverHandler {
enabled: !hoveredLink
cursorShape: Qt.PointingHandCursor
}
TapHandler {
enabled: !hoveredLink
acceptedButtons: Qt.LeftButton
onTapped: root.replyClicked(root.replyEventId)
}
}
}
DelegateChoice {
roleValue: MessageComponentType.Image
delegate: Image {
id: image
Layout.maximumWidth: mediaSizeHelper.currentSize.width
Layout.maximumHeight: mediaSizeHelper.currentSize.height
source: root?.replyMediaInfo.source ?? ""
MediaSizeHelper {
id: mediaSizeHelper
contentMaxWidth: _private.availableContentWidth
mediaWidth: root?.replyMediaInfo.width ?? -1
mediaHeight: root?.replyMediaInfo.height ?? -1
}
}
}
DelegateChoice {
roleValue: MessageComponentType.File
delegate: MimeComponent {
mimeIconSource: root.replyMediaInfo.mimeIcon
label: root.replyDisplay
subLabel: root.replyComponentType === DelegateType.File ? Format.formatByteSize(root.replyMediaInfo.size) : Format.formatDuration(root.replyMediaInfo.duration)
}
}
DelegateChoice {
roleValue: MessageComponentType.Video
delegate: MimeComponent {
mimeIconSource: root.replyMediaInfo.mimeIcon
label: root.replyDisplay
subLabel: root.replyComponentType === DelegateType.File ? Format.formatByteSize(root.replyMediaInfo.size) : Format.formatDuration(root.replyMediaInfo.duration)
}
}
DelegateChoice {
roleValue: MessageComponentType.Audio
delegate: MimeComponent {
mimeIconSource: root.replyMediaInfo.mimeIcon
label: root.replyDisplay
subLabel: root.replyComponentType === DelegateType.File ? Format.formatByteSize(root.replyMediaInfo.size) : Format.formatDuration(root.replyMediaInfo.duration)
}
}
DelegateChoice {
roleValue: MessageComponentType.Encrypted
delegate: TextComponent {
display: i18n("This message is encrypted and the sender has not shared the key with this device.")
}
}
onReplyClicked: root.replyClicked(root.replyEventId)
}
}
}

View File

@@ -0,0 +1,170 @@
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
import QtQuick
import QtQuick.Layouts
import Qt.labs.qmlmodels
import org.kde.neochat
/**
* @brief Select a message component based on a MessageComponentType.
*/
DelegateChooser {
id: root
/**
* @brief The maximum width that the bubble's content can be.
*/
property real maxContentWidth: -1
/**
* @brief The reply has been clicked.
*/
signal replyClicked()
role: "componentType"
DelegateChoice {
roleValue: MessageComponentType.Text
delegate: TextComponent {
maxContentWidth: root.maxContentWidth
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton
cursorShape: Qt.PointingHandCursor
onClicked: root.replyClicked()
}
}
}
DelegateChoice {
roleValue: MessageComponentType.Image
delegate: Image {
id: image
required property var mediaInfo
Layout.maximumWidth: mediaSizeHelper.currentSize.width
Layout.maximumHeight: mediaSizeHelper.currentSize.height
source: image.mediaInfo.source
MediaSizeHelper {
id: mediaSizeHelper
contentMaxWidth: root.maxContentWidth
mediaWidth: image.mediaInfo.width ?? 0
mediaHeight: image.mediaInfo.height ?? 0
}
}
}
DelegateChoice {
roleValue: MessageComponentType.Video
delegate: MimeComponent {
required property string display
required property var mediaInfo
required property int componentType
mimeIconSource: mediaInfo.mimeIcon
label: display
subLabel: componentType === MessageComponentType.File ? Format.formatByteSize(mediaInfo.size) : Format.formatDuration(mediaInfo.duration)
}
}
DelegateChoice {
roleValue: MessageComponentType.Code
delegate: CodeComponent {
maxContentWidth: root.maxContentWidth
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton
cursorShape: Qt.PointingHandCursor
onClicked: root.replyClicked()
}
}
}
DelegateChoice {
roleValue: MessageComponentType.Quote
delegate: QuoteComponent {
maxContentWidth: root.maxContentWidth
MouseArea {
anchors.fill: parent
acceptedButtons: Qt.LeftButton
cursorShape: Qt.PointingHandCursor
onClicked: root.replyClicked()
}
}
}
DelegateChoice {
roleValue: MessageComponentType.Audio
delegate: MimeComponent {
required property string display
required property var mediaInfo
required property int componentType
mimeIconSource: mediaInfo.mimeIcon
label: display
subLabel: componentType === MessageComponentType.File ? Format.formatByteSize(mediaInfo.size) : Format.formatDuration(mediaInfo.duration)
}
}
DelegateChoice {
roleValue: MessageComponentType.File
delegate: MimeComponent {
required property string display
required property var mediaInfo
required property int componentType
mimeIconSource: mediaInfo.mimeIcon
label: display
subLabel: componentType === MessageComponentType.File ? Format.formatByteSize(mediaInfo.size) : Format.formatDuration(mediaInfo.duration)
}
}
DelegateChoice {
roleValue: MessageComponentType.Poll
delegate: PollComponent {
room: root.room
maxContentWidth: root.maxContentWidth
}
}
DelegateChoice {
roleValue: MessageComponentType.Location
delegate: LocationComponent {
maxContentWidth: root.maxContentWidth
}
}
DelegateChoice {
roleValue: MessageComponentType.LiveLocation
delegate: LiveLocationComponent {
room: root.room
maxContentWidth: root.maxContentWidth
}
}
DelegateChoice {
roleValue: MessageComponentType.Encrypted
delegate: EncryptedComponent {
maxContentWidth: root.maxContentWidth
}
}
DelegateChoice {
roleValue: MessageComponentType.Loading
delegate: LoadComponent {
maxContentWidth: root.maxContentWidth
}
}
DelegateChoice {
roleValue: MessageComponentType.Other
delegate: Item {}
}
}