diff --git a/src/enums/messagecomponenttype.h b/src/enums/messagecomponenttype.h index 0ebde696b..d7aca7185 100644 --- a/src/enums/messagecomponenttype.h +++ b/src/enums/messagecomponenttype.h @@ -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); diff --git a/src/models/messagecontentmodel.cpp b/src/models/messagecontentmodel.cpp index 059e18535..41707e7bb 100644 --- a/src/models/messagecontentmodel.cpp +++ b/src/models/messagecontentmodel.cpp @@ -11,6 +11,7 @@ #include #include +#include #ifndef Q_OS_ANDROID #include @@ -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(m_event)) { - if (event->hasFileContent()) { - mxcUrl = event->content()->fileInfo()->url().toString(); - } - } else if (auto event = eventCast(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(m_event)) { + if (event->hasFileContent()) { + mxcUrl = event->content()->fileInfo()->url().toString(); + } + } else if (auto event = eventCast(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("[This message was deleted]") @@ -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(m_replyModel); } if (role == LinkPreviewerRole) { if (component.type == MessageComponentType::LinkPreview) { @@ -233,11 +263,9 @@ QHash 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(m_event) && eventCast(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 MessageContentModel::componentsForType(MessageComponentType::Type type) { switch (type) { diff --git a/src/models/messagecontentmodel.h b/src/models/messagecontentmodel.h index 8f942ffc5..575c50656 100644 --- a/src/models/messagecontentmodel.h +++ b/src/models/messagecontentmodel.h @@ -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 m_room; + QString m_eventId; const Quotient::RoomEvent *m_event = nullptr; + bool m_isReply; + + void initializeModel(); + QList m_components; void updateComponents(bool isEditing = false); + QPointer m_replyModel; + void updateReplyModel(); + ItineraryModel *m_itineraryModel = nullptr; QList componentsForType(MessageComponentType::Type type); diff --git a/src/models/messageeventmodel.cpp b/src/models/messageeventmodel.cpp index e23eb4cc3..ebb63f9d6 100644 --- a/src/models/messageeventmodel.cpp +++ b/src/models/messageeventmodel.cpp @@ -441,11 +441,11 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const if (role == ContentModelRole) { if (!evt.isStateEvent()) { - return QVariant::fromValue(new MessageContentModel(&evt, m_currentRoom)); + return QVariant::fromValue(new MessageContentModel(m_currentRoom, &evt)); } if (evt.isStateEvent()) { if (evt.matrixType() == QStringLiteral("org.matrix.msc3672.beacon_info")) { - return QVariant::fromValue(new MessageContentModel(&evt, m_currentRoom)); + return QVariant::fromValue(new MessageContentModel(m_currentRoom, &evt)); } } return {}; diff --git a/src/models/searchmodel.cpp b/src/models/searchmodel.cpp index 40e387ad6..b92a68faf 100644 --- a/src/models/searchmodel.cpp +++ b/src/models/searchmodel.cpp @@ -113,11 +113,11 @@ QVariant SearchModel::data(const QModelIndex &index, int role) const return eventHandler.threadRoot(); case ContentModelRole: { if (!event.isStateEvent()) { - return QVariant::fromValue(new MessageContentModel(&event, m_room)); + return QVariant::fromValue(new MessageContentModel(m_room, &event)); } if (event.isStateEvent()) { if (event.matrixType() == QStringLiteral("org.matrix.msc3672.beacon_info")) { - return QVariant::fromValue(new MessageContentModel(&event, m_room)); + return QVariant::fromValue(new MessageContentModel(m_room, &event)); } } return {}; diff --git a/src/neochatroom.cpp b/src/neochatroom.cpp index e3af3d78c..f2805e9f0 100644 --- a/src/neochatroom.cpp +++ b/src/neochatroom.cpp @@ -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(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 event = fromJson>(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 &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(id(), replyId); - connect(job, &BaseJob::success, this, [this, job, eventId, replyId] { - m_extraEvents.push_back(fromJson>(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 &event) { + return event->id() == eventId; }); + + if (it != m_extraEvents.end()) { + m_extraEvents.erase(it); + } } User *NeoChatRoom::invitingUser() const diff --git a/src/neochatroom.h b/src/neochatroom.h index b0e8625b1..417741a6e 100644 --- a/src/neochatroom.h +++ b/src/neochatroom.h @@ -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 m_polls; std::vector> 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: /** diff --git a/src/timeline/CMakeLists.txt b/src/timeline/CMakeLists.txt index c5bf07d9a..610ac6bcb 100644 --- a/src/timeline/CMakeLists.txt +++ b/src/timeline/CMakeLists.txt @@ -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 diff --git a/src/timeline/CodeComponent.qml b/src/timeline/CodeComponent.qml index 80f95a96a..6f07041e3 100644 --- a/src/timeline/CodeComponent.qml +++ b/src/timeline/CodeComponent.qml @@ -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 diff --git a/src/timeline/LinkPreviewLoadComponent.qml b/src/timeline/LinkPreviewLoadComponent.qml new file mode 100644 index 000000000..2f60bcf5f --- /dev/null +++ b/src/timeline/LinkPreviewLoadComponent.qml @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: 2024 James Graham +// 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 + } + } +} diff --git a/src/timeline/LoadComponent.qml b/src/timeline/LoadComponent.qml index f4102cb14..b23b739c3 100644 --- a/src/timeline/LoadComponent.qml +++ b/src/timeline/LoadComponent.qml @@ -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") } } + diff --git a/src/timeline/MessageComponentChooser.qml b/src/timeline/MessageComponentChooser.qml index f4b3159cb..392b5cf82 100644 --- a/src/timeline/MessageComponentChooser.qml +++ b/src/timeline/MessageComponentChooser.qml @@ -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 {} diff --git a/src/timeline/ReplyComponent.qml b/src/timeline/ReplyComponent.qml index 1589f02c5..c5e2b3905 100644 --- a/src/timeline/ReplyComponent.qml +++ b/src/timeline/ReplyComponent.qml @@ -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) } } } diff --git a/src/timeline/ReplyMessageComponentChooser.qml b/src/timeline/ReplyMessageComponentChooser.qml new file mode 100644 index 000000000..d1f6af510 --- /dev/null +++ b/src/timeline/ReplyMessageComponentChooser.qml @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: 2024 James Graham +// 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 {} + } +}