From b4e1740cad43158b93eabc044d018f543c83a51c Mon Sep 17 00:00:00 2001 From: James Graham Date: Fri, 1 Aug 2025 12:15:51 +0100 Subject: [PATCH] Separate out a base MessageContentModel. Separate out a base `MessageContentModel` that can be extended to get the component types from different places. This is used currently for `EventMessageContentModel` but will be used later as part of the rich chat bar. All display text is now in the text component so it never needs special casing. This also cleans up some of the model parameters so more things come from attributes including location and file data (which was already a qvariantmap anyway). Also cleaned up the itinerary and file enhancement views, --- autotests/messagecontentmodeltest.cpp | 6 +- autotests/reactionmodeltest.cpp | 6 +- src/chatbar/ChatBar.qml | 2 - src/libneochat/eventhandler.cpp | 9 + src/libneochat/messagecomponent.h | 4 +- src/libneochat/neochatroom.cpp | 13 + src/libneochat/neochatroom.h | 10 +- src/libneochat/texthandler.cpp | 2 +- src/messagecontent/AudioComponent.qml | 20 +- src/messagecontent/CMakeLists.txt | 1 + src/messagecontent/ChatBarComponent.qml | 2 - src/messagecontent/FileComponent.qml | 20 +- src/messagecontent/ImageComponent.qml | 34 +- src/messagecontent/LocationComponent.qml | 34 +- src/messagecontent/ReplyComponent.qml | 20 +- .../ReplyMessageComponentChooser.qml | 28 +- src/messagecontent/VideoComponent.qml | 19 +- src/messagecontent/contentprovider.cpp | 10 +- src/messagecontent/contentprovider.h | 8 +- .../models/eventmessagecontentmodel.cpp | 525 +++++++++++++++ .../models/eventmessagecontentmodel.h | 80 +++ .../models/messagecontentmodel.cpp | 607 ++---------------- .../models/messagecontentmodel.h | 193 +++--- src/timeline/models/messagemodel.cpp | 10 +- 24 files changed, 874 insertions(+), 789 deletions(-) create mode 100644 src/messagecontent/models/eventmessagecontentmodel.cpp create mode 100644 src/messagecontent/models/eventmessagecontentmodel.h diff --git a/autotests/messagecontentmodeltest.cpp b/autotests/messagecontentmodeltest.cpp index f78365bc2..b6970a31f 100644 --- a/autotests/messagecontentmodeltest.cpp +++ b/autotests/messagecontentmodeltest.cpp @@ -10,7 +10,7 @@ #include #include -#include "models/messagecontentmodel.h" +#include "models/eventmessagecontentmodel.h" #include "neochatconnection.h" #include "testutils.h" @@ -39,13 +39,13 @@ void MessageContentModelTest::initTestCase() void MessageContentModelTest::missingEvent() { auto room = new TestUtils::TestRoom(connection, u"#firstRoom:kde.org"_s); - auto model1 = MessageContentModel(room, u"$153456789:example.org"_s); + auto model1 = EventMessageContentModel(room, u"$153456789:example.org"_s); QCOMPARE(model1.rowCount(), 1); QCOMPARE(model1.data(model1.index(0), MessageContentModel::ComponentTypeRole), MessageComponentType::Loading); QCOMPARE(model1.data(model1.index(0), MessageContentModel::DisplayRole), u"Loading"_s); - auto model2 = MessageContentModel(room, u"$153456789:example.org"_s, true); + auto model2 = EventMessageContentModel(room, u"$153456789:example.org"_s, true); QCOMPARE(model2.rowCount(), 1); QCOMPARE(model2.data(model2.index(0), MessageContentModel::ComponentTypeRole), MessageComponentType::Loading); diff --git a/autotests/reactionmodeltest.cpp b/autotests/reactionmodeltest.cpp index caf38814d..0eae44d83 100644 --- a/autotests/reactionmodeltest.cpp +++ b/autotests/reactionmodeltest.cpp @@ -9,7 +9,7 @@ #include -#include "models/messagecontentmodel.h" +#include "models/eventmessagecontentmodel.h" #include "testutils.h" using namespace Quotient; @@ -21,7 +21,7 @@ class ReactionModelTest : public QObject private: Connection *connection = nullptr; TestUtils::TestRoom *room = nullptr; - MessageContentModel *parentModel; + EventMessageContentModel *parentModel; private Q_SLOTS: void initTestCase(); @@ -34,7 +34,7 @@ void ReactionModelTest::initTestCase() { connection = Connection::makeMockConnection(u"@bob:kde.org"_s); room = new TestUtils::TestRoom(connection, u"#myroom:kde.org"_s, u"test-reactionmodel-sync.json"_s); - parentModel = new MessageContentModel(room, "123456"_L1); + parentModel = new EventMessageContentModel(room, "123456"_L1); } void ReactionModelTest::basicReaction() diff --git a/src/chatbar/ChatBar.qml b/src/chatbar/ChatBar.qml index 7540aa1e0..8da5d8668 100644 --- a/src/chatbar/ChatBar.qml +++ b/src/chatbar/ChatBar.qml @@ -382,8 +382,6 @@ QQC2.Control { implicitHeight: replyComponent.implicitHeight ReplyComponent { id: replyComponent - replyEventId: _private.chatBarCache.replyId - replyAuthor: _private.chatBarCache.relationAuthor replyContentModel: ContentProvider.contentModelForEvent(root.currentRoom, _private.chatBarCache.replyId, true) Message.maxContentWidth: replyLoader.item.width diff --git a/src/libneochat/eventhandler.cpp b/src/libneochat/eventhandler.cpp index dedb62559..f6330a37f 100644 --- a/src/libneochat/eventhandler.cpp +++ b/src/libneochat/eventhandler.cpp @@ -198,6 +198,10 @@ bool EventHandler::isHidden(const NeoChatRoom *room, const Quotient::RoomEvent * Qt::TextFormat EventHandler::messageBodyInputFormat(const Quotient::RoomMessageEvent &event) { + if (event.isRedacted() && !event.isStateEvent()) { + return Qt::RichText; + } + if (event.mimeType().name() == "text/plain"_L1) { return Qt::PlainText; } else { @@ -207,6 +211,11 @@ Qt::TextFormat EventHandler::messageBodyInputFormat(const Quotient::RoomMessageE QString EventHandler::rawMessageBody(const Quotient::RoomMessageEvent &event) { + if (event.isRedacted() && !event.isStateEvent()) { + auto reason = event.redactedBecause()->reason(); + return (reason.isEmpty()) ? i18n("[This message was deleted]") : i18n("[This message was deleted: %1]", reason.toHtmlEscaped()); + } + QString body; if (event.has()) { diff --git a/src/libneochat/messagecomponent.h b/src/libneochat/messagecomponent.h index b643b295c..c84e7083a 100644 --- a/src/libneochat/messagecomponent.h +++ b/src/libneochat/messagecomponent.h @@ -7,12 +7,12 @@ struct MessageComponent { MessageComponentType::Type type = MessageComponentType::Other; - QString content; + QString display; QVariantMap attributes; bool operator==(const MessageComponent &right) const { - return type == right.type && content == right.content && attributes == right.attributes; + return type == right.type && display == right.display && attributes == right.attributes; } bool isEmpty() const diff --git a/src/libneochat/neochatroom.cpp b/src/libneochat/neochatroom.cpp index 9f5b962a5..a188a4c59 100644 --- a/src/libneochat/neochatroom.cpp +++ b/src/libneochat/neochatroom.cpp @@ -1319,6 +1319,19 @@ void NeoChatRoom::copyEventMedia(const QString &eventId) } } +FileTransferInfo NeoChatRoom::cachedFileTransferInfo(const QString &eventId) const +{ + if (eventId.isEmpty()) { + return {}; + } + + const auto eventResult = getEvent(eventId); + if (!eventResult.first) { + return {}; + } + return cachedFileTransferInfo(eventResult.first); +} + FileTransferInfo NeoChatRoom::cachedFileTransferInfo(const Quotient::RoomEvent *event) const { QString mxcUrl; diff --git a/src/libneochat/neochatroom.h b/src/libneochat/neochatroom.h index 5bd789ef6..d385e7ec1 100644 --- a/src/libneochat/neochatroom.h +++ b/src/libneochat/neochatroom.h @@ -544,7 +544,15 @@ public: * @brief Return the cached file transfer information for the event. * * If we downloaded the file previously, return a struct with Completed status - * and the local file path stored in KSharedCOnfig + * and the local file path stored in KSharedConfig + */ + Quotient::FileTransferInfo cachedFileTransferInfo(const QString &eventId) const; + + /** + * @brief Return the cached file transfer information for the event. + * + * If we downloaded the file previously, return a struct with Completed status + * and the local file path stored in KSharedConfig */ Quotient::FileTransferInfo cachedFileTransferInfo(const Quotient::RoomEvent *event) const; diff --git a/src/libneochat/texthandler.cpp b/src/libneochat/texthandler.cpp index f0b942edb..e93ecf848 100644 --- a/src/libneochat/texthandler.cpp +++ b/src/libneochat/texthandler.cpp @@ -592,7 +592,7 @@ TextHandler::textComponents(QString string, Qt::TextFormat inputFormat, const Ne if (event != nullptr && room != nullptr) { if (auto e = eventCast(event); e && e->msgtype() == Quotient::MessageEventType::Emote && components.size() == 1) { if (components[0].type == MessageComponentType::Text) { - components[0].content = emoteString(room, event) + components[0].content; + components[0].display = emoteString(room, event) + components[0].display; } else { components.prepend(MessageComponent{MessageComponentType::Text, emoteString(room, event), {}}); } diff --git a/src/messagecontent/AudioComponent.qml b/src/messagecontent/AudioComponent.qml index f3b846d8e..7c5cd614b 100644 --- a/src/messagecontent/AudioComponent.qml +++ b/src/messagecontent/AudioComponent.qml @@ -24,19 +24,9 @@ ColumnLayout { required property string eventId /** - * @brief The media info for the event. - * - * This should consist of the following: - * - source - The mxc URL for the media. - * - mimeType - The MIME type of the media (should be image/xxx for this delegate). - * - mimeIcon - The MIME icon name (should be image-xxx). - * - size - The file size in bytes. - * - width - The width in pixels of the audio media. - * - height - The height in pixels of the audio media. - * - tempInfo - mediaInfo (with the same properties as this except no tempInfo) for a temporary image while the file downloads. - * - filename - original filename of the media + * @brief The attributes of the component. */ - required property var mediaInfo + required property var componentAttributes /** * @brief FileTransferInfo for any downloading files. @@ -130,12 +120,12 @@ ColumnLayout { spacing: 0 QQC2.Label { - text: root.mediaInfo.filename + text: root.componentAttributes.filename wrapMode: Text.Wrap Layout.fillWidth: true } QQC2.Label { - text: Format.formatDuration(root.mediaInfo.duration) + text: Format.formatDuration(root.componentAttributes.duration) color: Kirigami.Theme.disabledTextColor visible: !audio.hasAudio Layout.fillWidth: true @@ -147,7 +137,7 @@ ColumnLayout { visible: false Layout.fillWidth: true from: 0 - to: root.mediaInfo.size + to: root.componentAttributes.size value: root.fileTransferInfo.progress } RowLayout { diff --git a/src/messagecontent/CMakeLists.txt b/src/messagecontent/CMakeLists.txt index bd93c6449..ebb56633c 100644 --- a/src/messagecontent/CMakeLists.txt +++ b/src/messagecontent/CMakeLists.txt @@ -52,6 +52,7 @@ ecm_add_qml_module(MessageContent GENERATE_PLUGIN_SOURCE models/pollanswermodel.cpp models/reactionmodel.cpp models/threadmodel.cpp + models/eventmessagecontentmodel.cpp RESOURCES images/bike.svg images/bus.svg diff --git a/src/messagecontent/ChatBarComponent.qml b/src/messagecontent/ChatBarComponent.qml index fb2d8742a..3529d82c4 100644 --- a/src/messagecontent/ChatBarComponent.qml +++ b/src/messagecontent/ChatBarComponent.qml @@ -222,8 +222,6 @@ QQC2.Control { implicitHeight: replyComponent.implicitHeight ReplyComponent { id: replyComponent - replyEventId: root.chatBarCache.replyId - replyAuthor: root.chatBarCache.relationAuthor replyContentModel: ContentProvider.contentModelForEvent(root.Message.room, root.chatBarCache.replyId, true) Message.maxContentWidth: paneLoader.item.width } diff --git a/src/messagecontent/FileComponent.qml b/src/messagecontent/FileComponent.qml index f642b51cb..413b24769 100644 --- a/src/messagecontent/FileComponent.qml +++ b/src/messagecontent/FileComponent.qml @@ -26,19 +26,9 @@ ColumnLayout { required property string eventId /** - * @brief The media info for the event. - * - * This should consist of the following: - * - source - The mxc URL for the media. - * - mimeType - The MIME type of the media (should be image/xxx for this delegate). - * - mimeIcon - The MIME icon name (should be image-xxx). - * - size - The file size in bytes. - * - width - The width in pixels of the audio media. - * - height - The height in pixels of the audio media. - * - tempInfo - mediaInfo (with the same properties as this except no tempInfo) for a temporary image while the file downloads. - * - filename - original filename of the media + * @brief The attributes of the component. */ - required property var mediaInfo + required property var componentAttributes /** * @brief FileTransferInfo for any downloading files. @@ -134,7 +124,7 @@ ColumnLayout { ] Kirigami.Icon { - source: root.mediaInfo.mimeIcon + source: root.componentAttributes.mimeIcon fallback: "unknown" } @@ -142,14 +132,14 @@ ColumnLayout { spacing: 0 QQC2.Label { Layout.fillWidth: true - text: root.mediaInfo.filename + text: root.componentAttributes.filename wrapMode: Text.Wrap elide: Text.ElideRight } QQC2.Label { id: sizeLabel Layout.fillWidth: true - text: Format.formatByteSize(root.mediaInfo.size) + text: Format.formatByteSize(root.componentAttributes.size) opacity: 0.7 elide: Text.ElideRight maximumLineCount: 1 diff --git a/src/messagecontent/ImageComponent.qml b/src/messagecontent/ImageComponent.qml index 5175f00c9..8023edd80 100644 --- a/src/messagecontent/ImageComponent.qml +++ b/src/messagecontent/ImageComponent.qml @@ -27,19 +27,9 @@ Item { required property string display /** - * @brief The media info for the event. - * - * This should consist of the following: - * - source - The mxc URL for the media. - * - mimeType - The MIME type of the media (should be image/xxx for this delegate). - * - mimeIcon - The MIME icon name (should be image-xxx). - * - size - The file size in bytes. - * - width - The width in pixels of the audio media. - * - height - The height in pixels of the audio media. - * - tempInfo - mediaInfo (with the same properties as this except no tempInfo) for a temporary image while the file downloads. - * - isSticker - Whether the image is a sticker or not + * @brief The attributes of the component. */ - required property var mediaInfo + required property var componentAttributes /** * @brief FileTransferInfo for any downloading files. @@ -77,9 +67,9 @@ Item { anchors.fill: parent - active: !root.mediaInfo.animated && !_private.hideImage + active: !root.componentAttributes.animated && !_private.hideImage sourceComponent: Image { - source: root.mediaInfo.source + source: root.componentAttributes.source sourceSize.width: mediaSizeHelper.currentSize.width * Screen.devicePixelRatio sourceSize.height: mediaSizeHelper.currentSize.height * Screen.devicePixelRatio @@ -93,9 +83,9 @@ Item { anchors.fill: parent - active: (root?.mediaInfo.animated ?? false) && !_private.hideImage + active: (root?.componentAttributes.animated ?? false) && !_private.hideImage sourceComponent: AnimatedImage { - source: root.mediaInfo.source + source: root.componentAttributes.source fillMode: Image.PreserveAspectFit autoTransform: true @@ -106,7 +96,7 @@ Item { Image { anchors.fill: parent - source: visible ? (root?.mediaInfo.tempInfo?.source ?? "") : "" + source: visible ? (root?.componentAttributes.tempInfo?.source ?? "") : "" visible: _private.imageItem && _private.imageItem.status !== Image.Ready && !_private.hideImage } @@ -153,11 +143,11 @@ Item { gesturePolicy: TapHandler.ReleaseWithinBounds | TapHandler.WithinBounds onTapped: { root.QQC2.ToolTip.hide(); - if (root.mediaInfo.animated) { + if (root.componentAttributes.animated) { _private.imageItem.paused = true; } root.Message.timeline.interactive = false; - if (!root.mediaInfo.isSticker) { + if (!root.componentAttributes.isSticker) { RoomManager.maximizeMedia(root.eventId); } } @@ -183,13 +173,13 @@ Item { id: mediaSizeHelper contentMaxWidth: root.Message.maxContentWidth contentMaxHeight: root.contentMaxHeight ?? -1 - mediaWidth: root?.mediaInfo.isSticker ? 256 : (root?.mediaInfo.width ?? 0) - mediaHeight: root?.mediaInfo.isSticker ? 256 : (root?.mediaInfo.height ?? 0) + mediaWidth: root?.componentAttributes.isSticker ? 256 : (root?.componentAttributes.width ?? 0) + mediaHeight: root?.componentAttributes.isSticker ? 256 : (root?.componentAttributes.height ?? 0) } QtObject { id: _private - readonly property var imageItem: root.mediaInfo.animated ? animatedImageLoader.item : imageLoader.item + readonly property var imageItem: root.componentAttributes.animated ? animatedImageLoader.item : imageLoader.item // The space available for the component after taking away the border readonly property real downloaded: root.fileTransferInfo && root.fileTransferInfo.completed diff --git a/src/messagecontent/LocationComponent.qml b/src/messagecontent/LocationComponent.qml index bdf854a7f..89f9d6bb1 100644 --- a/src/messagecontent/LocationComponent.qml +++ b/src/messagecontent/LocationComponent.qml @@ -37,22 +37,9 @@ ColumnLayout { required property string display /** - * @brief The latitude of the location marker in the message. + * @brief The attributes of the component. */ - required property real latitude - - /** - * @brief The longitude of the location marker in the message. - */ - required property real longitude - - /** - * @brief What type of marker the location message is. - * - * The main options are m.pin for a general location or m.self for a pin to show - * a user's location. - */ - required property string asset + required property var componentAttributes Layout.fillWidth: true Layout.maximumWidth: Message.maxContentWidth @@ -63,15 +50,15 @@ ColumnLayout { Layout.preferredWidth: root.Message.maxContentWidth Layout.preferredHeight: root.Message.maxContentWidth / 16 * 9 - map.center: QtPositioning.coordinate(root.latitude, root.longitude) + map.center: QtPositioning.coordinate(root.componentAttributes.latitude, root.componentAttributes.longitude) map.zoomLevel: 15 map.plugin: OsmLocationPlugin.plugin readonly property LocationMapItem locationMapItem: LocationMapItem { - latitude: root.latitude - longitude: root.longitude - asset: root.asset + latitude: root.componentAttributes.latitude + longitude: root.componentAttributes.longitude + asset: root.componentAttributes.asset author: root.author isLive: true heading: NaN @@ -100,7 +87,7 @@ ColumnLayout { icon.name: "open-link-symbolic" display: AbstractButton.IconOnly - onClicked: Qt.openUrlExternally("geo:" + root.latitude + "," + root.longitude) + onClicked: Qt.openUrlExternally("geo:" + root.componentAttributes.latitude + "," + root.componentAttributes.longitude) ToolTip.text: text ToolTip.visible: hovered @@ -114,12 +101,11 @@ ColumnLayout { onClicked: { let map = fullScreenMap.createObject(parent, { - latitude: root.latitude, - longitude: root.longitude, - asset: root.asset, + latitude: root.componentAttributes.latitude, + longitude: root.componentAttributes.longitude, + asset: root.componentAttributes.asset, author: root.author }); - map.open(); } ToolTip.text: text diff --git a/src/messagecontent/ReplyComponent.qml b/src/messagecontent/ReplyComponent.qml index 5ded94f6c..894f5cff6 100644 --- a/src/messagecontent/ReplyComponent.qml +++ b/src/messagecontent/ReplyComponent.qml @@ -24,20 +24,6 @@ import org.kde.neochat RowLayout { id: root - /** - * @brief The matrix ID of the reply event. - */ - required property var replyEventId - - /** - * @brief The reply author. - * - * A Quotient::RoomMember object. - * - * @sa Quotient::RoomMember - */ - required property var replyAuthor - /** * @brief The model to visualise the content of the message replied to. */ @@ -52,7 +38,7 @@ RowLayout { Layout.fillHeight: true implicitWidth: Kirigami.Units.smallSpacing - color: root.replyAuthor.color + color: root.replyContentModel.author.color radius: Kirigami.Units.cornerRadius } ColumnLayout { @@ -65,7 +51,7 @@ RowLayout { id: contentRepeater model: root.replyContentModel delegate: ReplyMessageComponentChooser { - onReplyClicked: RoomManager.goToEvent(root.replyEventId) + onReplyClicked: RoomManager.goToEvent(root.replyContentModel.eventId) } } } @@ -74,7 +60,7 @@ RowLayout { } TapHandler { acceptedButtons: Qt.LeftButton - onTapped: RoomManager.goToEvent(root.replyEventId) + onTapped: RoomManager.goToEvent(root.replyContentModel.eventId) } QtObject { id: _private diff --git a/src/messagecontent/ReplyMessageComponentChooser.qml b/src/messagecontent/ReplyMessageComponentChooser.qml index be1020419..0b1874d6f 100644 --- a/src/messagecontent/ReplyMessageComponentChooser.qml +++ b/src/messagecontent/ReplyMessageComponentChooser.qml @@ -49,12 +49,12 @@ DelegateChooser { DelegateChoice { roleValue: MessageComponentType.Video delegate: MimeComponent { - required property var mediaInfo + required property var componentAttributes - mimeIconSource: mediaInfo.mimeIcon - size: mediaInfo.size - duration: mediaInfo.duration - label: mediaInfo.filename + mimeIconSource: componentAttributes.mimeIcon + size: componentAttributes.size + duration: componentAttributes.duration + label: componentAttributes.filename } } @@ -88,12 +88,12 @@ DelegateChooser { roleValue: MessageComponentType.Audio delegate: MimeComponent { required property string display - required property var mediaInfo + required property var componentAttributes - mimeIconSource: mediaInfo.mimeIcon - size: mediaInfo.size - duration: mediaInfo.duration - label: mediaInfo.filename + mimeIconSource: componentAttributes.mimeIcon + size: componentAttributes.size + duration: componentAttributes.duration + label: componentAttributes.filename } } @@ -101,11 +101,11 @@ DelegateChooser { roleValue: MessageComponentType.File delegate: MimeComponent { required property string display - required property var mediaInfo + required property var componentAttributes - mimeIconSource: mediaInfo.mimeIcon - size: mediaInfo.size - label: mediaInfo.filename + mimeIconSource: componentAttributes.mimeIcon + size: componentAttributes.size + label: componentAttributes.filename } } diff --git a/src/messagecontent/VideoComponent.qml b/src/messagecontent/VideoComponent.qml index 3e5ff4434..7d6f39a58 100644 --- a/src/messagecontent/VideoComponent.qml +++ b/src/messagecontent/VideoComponent.qml @@ -30,18 +30,9 @@ Video { required property string display /** - * @brief The media info for the event. - * - * This should consist of the following: - * - source - The mxc URL for the media. - * - mimeType - The MIME type of the media (should be image/xxx for this delegate). - * - mimeIcon - The MIME icon name (should be image-xxx). - * - size - The file size in bytes. - * - width - The width in pixels of the audio media. - * - height - The height in pixels of the audio media. - * - tempInfo - mediaInfo (with the same properties as this except no tempInfo) for a temporary image while the file downloads. + * @brief The attributes of the component. */ - required property var mediaInfo + required property var componentAttributes /** * @brief FileTransferInfo for any downloading files. @@ -206,7 +197,7 @@ Video { anchors.fill: parent visible: false - source: visible ? root.mediaInfo.tempInfo.source : "" + source: visible ? root.componentAttributes.tempInfo.source : "" fillMode: Image.PreserveAspectFit } @@ -437,8 +428,8 @@ Video { MediaSizeHelper { id: mediaSizeHelper contentMaxWidth: root.Message.maxContentWidth - mediaWidth: root.mediaInfo.width - mediaHeight: root.mediaInfo.height + mediaWidth: root.componentAttributes.width + mediaHeight: root.componentAttributes.height } function downloadAndPlay() { diff --git a/src/messagecontent/contentprovider.cpp b/src/messagecontent/contentprovider.cpp index 216d0543b..71631cced 100644 --- a/src/messagecontent/contentprovider.cpp +++ b/src/messagecontent/contentprovider.cpp @@ -14,14 +14,14 @@ ContentProvider &ContentProvider::self() return instance; } -MessageContentModel *ContentProvider::contentModelForEvent(NeoChatRoom *room, const QString &evtOrTxnId, bool isReply) +EventMessageContentModel *ContentProvider::contentModelForEvent(NeoChatRoom *room, const QString &evtOrTxnId, bool isReply) { if (!room || evtOrTxnId.isEmpty()) { return nullptr; } if (!m_eventContentModels.contains(evtOrTxnId)) { - auto model = new MessageContentModel(room, evtOrTxnId, isReply); + auto model = new EventMessageContentModel(room, evtOrTxnId, isReply); QQmlEngine::setObjectOwnership(model, QQmlEngine::CppOwnership); m_eventContentModels.insert(evtOrTxnId, model); } @@ -29,7 +29,7 @@ MessageContentModel *ContentProvider::contentModelForEvent(NeoChatRoom *room, co return m_eventContentModels.object(evtOrTxnId); } -MessageContentModel *ContentProvider::contentModelForEvent(NeoChatRoom *room, const Quotient::RoomEvent *event, bool isReply) +EventMessageContentModel *ContentProvider::contentModelForEvent(NeoChatRoom *room, const Quotient::RoomEvent *event, bool isReply) { if (!room) { return nullptr; @@ -53,7 +53,7 @@ MessageContentModel *ContentProvider::contentModelForEvent(NeoChatRoom *room, co auto eventId = event->id(); const auto txnId = event->transactionId(); if (!m_eventContentModels.contains(eventId) && !m_eventContentModels.contains(txnId)) { - auto model = new MessageContentModel(room, eventId.isEmpty() ? txnId : eventId, isReply, eventId.isEmpty()); + auto model = new EventMessageContentModel(room, eventId.isEmpty() ? txnId : eventId, isReply, eventId.isEmpty()); QQmlEngine::setObjectOwnership(model, QQmlEngine::CppOwnership); m_eventContentModels.insert(eventId.isEmpty() ? txnId : eventId, model); } @@ -115,7 +115,7 @@ PollHandler *ContentProvider::handlerForPoll(NeoChatRoom *room, const QString &e void ContentProvider::setThreadsEnabled(bool enableThreads) { - MessageContentModel::setThreadsEnabled(enableThreads); + EventMessageContentModel::setThreadsEnabled(enableThreads); for (const auto &key : m_eventContentModels.keys()) { m_eventContentModels.object(key)->threadsEnabledChanged(); diff --git a/src/messagecontent/contentprovider.h b/src/messagecontent/contentprovider.h index bbf02224e..012641979 100644 --- a/src/messagecontent/contentprovider.h +++ b/src/messagecontent/contentprovider.h @@ -7,8 +7,8 @@ #include #include -#include "models/messagecontentmodel.h" #include "models/threadmodel.h" +#include "models/eventmessagecontentmodel.h" #include "neochatroom.h" #include "pollhandler.h" @@ -46,7 +46,7 @@ public: * * @warning Do NOT use for pending events as this function has no way to differentiate. */ - Q_INVOKABLE MessageContentModel *contentModelForEvent(NeoChatRoom *room, const QString &evtOrTxnId, bool isReply = false); + Q_INVOKABLE EventMessageContentModel *contentModelForEvent(NeoChatRoom *room, const QString &evtOrTxnId, bool isReply = false); /** * @brief Returns the content model for the given event. @@ -61,7 +61,7 @@ public: * * @note This version must be used for pending events as it can differentiate. */ - MessageContentModel *contentModelForEvent(NeoChatRoom *room, const Quotient::RoomEvent *event, bool isReply = false); + EventMessageContentModel *contentModelForEvent(NeoChatRoom *room, const Quotient::RoomEvent *event, bool isReply = false); /** * @brief Returns the thread model for the given thread root event ID. @@ -86,7 +86,7 @@ public: private: explicit ContentProvider(QObject *parent = nullptr); - QCache m_eventContentModels; + QCache m_eventContentModels; QCache m_threadModels; QCache m_pollHandlers; }; diff --git a/src/messagecontent/models/eventmessagecontentmodel.cpp b/src/messagecontent/models/eventmessagecontentmodel.cpp new file mode 100644 index 000000000..b0dc8d791 --- /dev/null +++ b/src/messagecontent/models/eventmessagecontentmodel.cpp @@ -0,0 +1,525 @@ +// SPDX-FileCopyrightText: 2024 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#include "eventmessagecontentmodel.h" + +#include +#include +#include +#include +#if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1) +#include +#endif + +#include +#include + +#include "chatbarcache.h" +#include "contentprovider.h" +#include "eventhandler.h" +#include "models/reactionmodel.h" +#include "neochatroom.h" +#include "texthandler.h" + +using namespace Quotient; + +bool EventMessageContentModel::m_threadsEnabled = false; + +EventMessageContentModel::EventMessageContentModel(NeoChatRoom *room, const QString &eventId, bool isReply, bool isPending, MessageContentModel *parent) + : MessageContentModel(room, parent, eventId) + , m_currentState(isPending ? Pending : Unknown) + , m_isReply(isReply) +{ + initializeModel(); +} + +void EventMessageContentModel::initializeModel() +{ + Q_ASSERT(m_room != nullptr); + Q_ASSERT(!m_eventId.isEmpty()); + + connect(m_room, &NeoChatRoom::pendingEventAdded, this, [this]() { + if (m_room != nullptr && m_currentState == Unknown) { + initializeEvent(); + resetModel(); + } + }); + connect(m_room, &NeoChatRoom::pendingEventAboutToMerge, this, [this](Quotient::RoomEvent *serverEvent) { + if (m_room != nullptr) { + if (m_eventId == serverEvent->id() || m_eventId == serverEvent->transactionId()) { + m_eventId = serverEvent->id(); + } + } + }); + connect(m_room, &NeoChatRoom::pendingEventMerged, this, [this]() { + if (m_room != nullptr && m_currentState == Pending) { + initializeEvent(); + resetModel(); + } + }); + connect(m_room, &NeoChatRoom::addedMessages, this, [this](int fromIndex, int toIndex) { + if (!m_room) { + return; + } + for (int i = fromIndex; i <= toIndex; i++) { + if (m_room->findInTimeline(i)->event()->id() == m_eventId) { + initializeEvent(); + resetModel(); + } + } + }); + connect(m_room, &NeoChatRoom::replacedEvent, this, [this](const Quotient::RoomEvent *newEvent) { + if (m_room != nullptr) { + if (m_eventId == newEvent->id()) { + initializeEvent(); + resetContent(); + } + } + }); + connect(m_room->editCache(), &ChatBarCache::relationIdChanged, this, [this](const QString &oldEventId, const QString &newEventId) { + if (oldEventId == m_eventId || newEventId == m_eventId) { + resetContent(newEventId == m_eventId); + } + }); + connect(m_room->threadCache(), &ChatBarCache::threadIdChanged, this, [this](const QString &oldThreadId, const QString &newThreadId) { + if (oldThreadId == m_eventId || newThreadId == m_eventId) { + resetContent(false, newThreadId == m_eventId); + } + }); + connect(m_room, &NeoChatRoom::urlPreviewEnabledChanged, this, [this]() { + resetContent(); + }); + connect(m_room, &Room::memberNameUpdated, this, [this](RoomMember member) { + if (m_room != nullptr) { + if (authorId().isEmpty() || authorId() == member.id()) { + Q_EMIT dataChanged(index(0, 0), index(rowCount() - 1, 0), {AuthorRole}); + Q_EMIT authorChanged(); + } + } + }); + connect(m_room, &Room::memberAvatarUpdated, this, [this](RoomMember member) { + if (m_room != nullptr) { + if (authorId().isEmpty() || authorId() == member.id()) { + Q_EMIT dataChanged(index(0, 0), index(rowCount() - 1, 0), {AuthorRole}); + Q_EMIT authorChanged(); + } + } + }); + connect(this, &EventMessageContentModel::threadsEnabledChanged, this, [this]() { + resetModel(); + }); + connect(m_room, &Room::updatedEvent, this, [this](const QString &eventId) { + if (eventId == m_eventId) { + updateReactionModel(); + } + }); + + initializeEvent(); + resetModel(); +} + +QDateTime EventMessageContentModel::time() const +{ + const auto event = m_room->getEvent(m_eventId); + if (event.first == nullptr) { + return MessageContentModel::time(); + }; + return EventHandler::time(m_room, event.first, m_currentState == Pending); +} + +QString EventMessageContentModel::timeString() const +{ + const auto event = m_room->getEvent(m_eventId); + if (event.first == nullptr) { + return MessageContentModel::timeString(); + }; + return EventHandler::timeString(m_room, event.first, u"hh:mm"_s, m_currentState == Pending); +} + +QString EventMessageContentModel::authorId() const +{ + const auto eventResult = m_room->getEvent(m_eventId); + if (eventResult.first == nullptr) { + return {}; + } + auto authorId = eventResult.first->senderId(); + if (authorId.isEmpty()) { + return MessageContentModel::authorId(); + } + return authorId; +} + +QString EventMessageContentModel::threadRootId() const +{ + const auto event = m_room->getEvent(m_eventId); + if (event.first == nullptr) { + return {}; + } + auto roomMessageEvent = eventCast(event.first); +#if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1) + if (roomMessageEvent && (roomMessageEvent->isThreaded() || m_room->threads().contains(roomMessageEvent->id()))) { +#else + if (roomMessageEvent && roomMessageEvent->isThreaded()) { +#endif + return roomMessageEvent->threadRootEventId(); + } + return {}; +} + +void EventMessageContentModel::initializeEvent() +{ + if (m_currentState == UnAvailable) { + return; + } + + const auto eventResult = m_room->getEvent(m_eventId); + if (eventResult.first == nullptr) { + if (m_currentState != Pending) { + getEvent(); + } + return; + } + if (eventResult.second) { + m_currentState = Pending; + } else { + m_currentState = Available; + } + Q_EMIT eventUpdated(); +} + +void EventMessageContentModel::getEvent() +{ + Quotient::connectUntil(m_room.get(), &NeoChatRoom::extraEventLoaded, this, [this](const QString &eventId) { + if (m_room != nullptr) { + if (eventId == m_eventId) { + initializeEvent(); + resetModel(); + return true; + } + } + return false; + }); + Quotient::connectUntil(m_room.get(), &NeoChatRoom::extraEventNotFound, this, [this](const QString &eventId) { + if (m_room != nullptr) { + if (eventId == m_eventId) { + m_currentState = UnAvailable; + resetModel(); + return true; + } + } + return false; + }); + + m_room->downloadEventFromServer(m_eventId); +} + +MessageComponent EventMessageContentModel::unavailableMessageComponent() const +{ + const auto theme = static_cast(qmlAttachedPropertiesObject(this, true)); + + QString disabledTextColor; + if (theme != nullptr) { + disabledTextColor = theme->disabledTextColor().name(); + } else { + disabledTextColor = u"#000000"_s; + } + + return MessageComponent{ + .type = MessageComponentType::Text, + .display = u""_s.arg(disabledTextColor) + + i18nc("@info", "This message was either not found, you do not have permission to view it, or it was sent by an ignored user") + u""_s, + .attributes = {}, + }; +} + +void EventMessageContentModel::resetModel() +{ + beginResetModel(); + m_components.clear(); + + if (m_room->connection()->isIgnored(authorId()) || m_currentState == UnAvailable) { + m_components += unavailableMessageComponent(); + endResetModel(); + return; + } + + const auto event = m_room->getEvent(m_eventId); + if (event.first == nullptr) { + m_components += MessageComponent{MessageComponentType::Loading, m_isReply ? i18n("Loading reply") : i18n("Loading"), {}}; + endResetModel(); + return; + } + + m_components += MessageComponent{MessageComponentType::Author, + QString(), + { + {u"time"_s, EventHandler::time(m_room, event.first, m_currentState == Pending)}, + {u"timeString"_s, EventHandler::timeString(m_room, event.first, u"hh:mm"_s, m_currentState == Pending)}, + }}; + + m_components += messageContentComponents(); + endResetModel(); + + updateReplyModel(); + updateReactionModel(); + updateItineraryModel(); + + Q_EMIT componentsUpdated(); +} + +void EventMessageContentModel::resetContent(bool isEditing, bool isThreading) +{ + const auto startRow = m_components[0].type == MessageComponentType::Author ? 1 : 0; + beginRemoveRows({}, startRow, rowCount() - 1); + m_components.remove(startRow, rowCount() - startRow); + endRemoveRows(); + + const auto newComponents = messageContentComponents(isEditing, isThreading); + if (newComponents.size() == 0) { + return; + } + beginInsertRows({}, startRow, startRow + newComponents.size() - 1); + m_components += newComponents; + endInsertRows(); + + updateReplyModel(); + updateReactionModel(); + updateItineraryModel(); + + Q_EMIT componentsUpdated(); +} + +QList EventMessageContentModel::messageContentComponents(bool isEditing, bool isThreading) +{ + const auto event = m_room->getEvent(m_eventId); + if (event.first == nullptr) { + return {}; + } + + QList newComponents; + + if (isEditing) { + newComponents += MessageComponent{MessageComponentType::ChatBar, QString(), {}}; + } else { + newComponents.append(componentsForType(MessageComponentType::typeForEvent(*event.first, m_isReply))); + } + + const auto roomMessageEvent = eventCast(event.first); +#if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1) + if (m_threadsEnabled && roomMessageEvent && (roomMessageEvent->isThreaded() || m_room->threads().contains(roomMessageEvent->id())) + && roomMessageEvent->id() == roomMessageEvent->threadRootEventId()) { +#else + if (m_threadsEnabled && roomMessageEvent && roomMessageEvent->isThreaded() && roomMessageEvent->id() == roomMessageEvent->threadRootEventId()) { +#endif + newComponents += MessageComponent{MessageComponentType::Separator, {}, {}}; + newComponents += MessageComponent{MessageComponentType::ThreadBody, u"Thread Body"_s, {}}; + } + + // If the event is already threaded the ThreadModel will handle displaying a chat bar. +#if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1) + if (isThreading && roomMessageEvent && !(roomMessageEvent->isThreaded() || m_room->threads().contains(roomMessageEvent->id()))) { +#else + if (isThreading && roomMessageEvent && roomMessageEvent->isThreaded()) { +#endif + newComponents += MessageComponent{MessageComponentType::ChatBar, QString(), {}}; + } + + return newComponents; +} + +void EventMessageContentModel::updateReplyModel() +{ + const auto event = m_room->getEvent(m_eventId); + if (event.first == nullptr || m_isReply) { + return; + } + + const auto roomMessageEvent = eventCast(event.first); + if (roomMessageEvent == nullptr) { + return; + } + if (!roomMessageEvent->isReply(m_threadsEnabled) || (roomMessageEvent->isThreaded() && m_threadsEnabled)) { + if (m_replyModel) { + m_replyModel->disconnect(this); + m_replyModel->deleteLater(); + } + return; + } + + m_replyModel = new EventMessageContentModel(m_room, roomMessageEvent->replyEventId(!m_threadsEnabled), true, false, this); + + bool hasModel = hasComponentType(MessageComponentType::Reply); + if (m_replyModel && !hasModel) { + int insertRow = 0; + if (m_components.first().type == MessageComponentType::Author) { + insertRow = 1; + } + beginInsertRows({}, insertRow, insertRow); + m_components.insert(insertRow, MessageComponent{MessageComponentType::Reply, QString(), {}}); + } else if (!m_replyModel && hasModel) { + int removeRow = 0; + if (m_components.first().type == MessageComponentType::Author) { + removeRow = 1; + } + beginRemoveRows({}, removeRow, removeRow); + m_components.removeAt(removeRow); + endRemoveRows(); + } +} + +QList EventMessageContentModel::componentsForType(MessageComponentType::Type type) +{ + const auto event = m_room->getEvent(m_eventId); + if (event.first == nullptr) { + return {}; + } + + switch (type) { + case MessageComponentType::Verification: { + return {MessageComponent{MessageComponentType::Verification, QString(), {}}}; + } + case MessageComponentType::Text: { + if (const auto roomMessageEvent = eventCast(event.first)) { + return TextHandler().textComponents(EventHandler::rawMessageBody(*roomMessageEvent), + EventHandler::messageBodyInputFormat(*roomMessageEvent), + m_room, + roomMessageEvent, + roomMessageEvent->isReplaced()); + } else { + return TextHandler().textComponents(EventHandler::plainBody(m_room, event.first), Qt::TextFormat::PlainText, m_room, event.first, false); + } + + const auto roomMessageEvent = eventCast(event.first); + return TextHandler().textComponents(EventHandler::rawMessageBody(*roomMessageEvent), + EventHandler::messageBodyInputFormat(*roomMessageEvent), + m_room, + roomMessageEvent, + roomMessageEvent->isReplaced()); + } + case MessageComponentType::File: { + QList components; + components += MessageComponent{MessageComponentType::File, {}, EventHandler::mediaInfo(m_room, event.first)}; + const auto roomMessageEvent = eventCast(event.first); + auto body = EventHandler::rawMessageBody(*roomMessageEvent); + if (!body.isEmpty()) { + components += TextHandler().textComponents(body, + EventHandler::messageBodyInputFormat(*roomMessageEvent), + m_room, + roomMessageEvent, + roomMessageEvent->isReplaced()); + } + return components; + } + case MessageComponentType::Image: + case MessageComponentType::Audio: + case MessageComponentType::Video: { + QList components = { + MessageComponent{type, EventHandler::richBody(m_room, event.first), EventHandler::mediaInfo(m_room, event.first)}}; + + if (!event.first->is()) { + const auto roomMessageEvent = eventCast(event.first); + const auto fileContent = roomMessageEvent->get(); + if (fileContent != nullptr) { + const auto fileInfo = fileContent->commonInfo(); + const auto body = EventHandler::rawMessageBody(*roomMessageEvent); + // Do not attach the description to the image, if it's the same as the original filename. + if (fileInfo.originalName != body) { + components += TextHandler().textComponents(body, + EventHandler::messageBodyInputFormat(*roomMessageEvent), + m_room, + roomMessageEvent, + roomMessageEvent->isReplaced()); + } + } + } + return components; + } + case MessageComponentType::Location: + return {MessageComponent{type, + QString(), + { + {u"latitude"_s, EventHandler::latitude(event.first)}, + {u"longitude"_s, EventHandler::longitude(event.first)}, + {u"asset"_s, EventHandler::locationAssetType(event.first)}, + }}}; + default: + return {MessageComponent{type, QString(), {}}}; + } +} + +void EventMessageContentModel::updateItineraryModel() +{ + if (!hasComponentType(MessageComponentType::File) || !m_room) { + return; + } + + const auto roomMessageEvent = eventCast(m_room->getEvent(m_eventId).first); + if (!roomMessageEvent || !roomMessageEvent->has()) { + return; + } + + auto filePath = m_room->cachedFileTransferInfo(roomMessageEvent).localPath; + if (filePath.isEmpty() && m_itineraryModel != nullptr) { + delete m_itineraryModel; + m_itineraryModel = nullptr; + } else if (!filePath.isEmpty()) { + if (m_itineraryModel == nullptr) { + m_itineraryModel = new ItineraryModel(this); + connect(m_itineraryModel, &ItineraryModel::loaded, this, [this]() { + if (m_itineraryModel->rowCount() == 0) { + m_emptyItinerary = true; + m_itineraryModel->deleteLater(); + m_itineraryModel = nullptr; + } + Q_EMIT itineraryUpdated(); + }); + connect(m_itineraryModel, &ItineraryModel::loadErrorOccurred, this, [this]() { + m_emptyItinerary = true; + m_itineraryModel->deleteLater(); + m_itineraryModel = nullptr; + Q_EMIT itineraryUpdated(); + }); + } + m_itineraryModel->setPath(filePath.toString()); + } +} + +void EventMessageContentModel::updateReactionModel() +{ + if (m_reactionModel && m_reactionModel->rowCount() > 0) { + return; + } + + if (m_reactionModel == nullptr) { + m_reactionModel = new ReactionModel(this, m_eventId, m_room); + connect(m_reactionModel, &ReactionModel::reactionsUpdated, this, &EventMessageContentModel::updateReactionModel); + } + + if (m_reactionModel->rowCount() <= 0) { + m_reactionModel->disconnect(this); + m_reactionModel->deleteLater(); + m_reactionModel = nullptr; + } + + if (m_reactionModel && m_components.last().type != MessageComponentType::Reaction) { + beginInsertRows({}, rowCount(), rowCount()); + m_components += MessageComponent{MessageComponentType::Reaction, QString(), {}}; + endInsertRows(); + } else if (rowCount() > 0 && m_components.last().type == MessageComponentType::Reaction) { + beginRemoveRows({}, rowCount() - 1, rowCount() - 1); + m_components.removeLast(); + endRemoveRows(); + } +} + +ThreadModel *EventMessageContentModel::modelForThread(const QString &threadRootId) +{ + return ContentProvider::self().modelForThread(m_room, threadRootId); +} + +void EventMessageContentModel::setThreadsEnabled(bool enableThreads) +{ + m_threadsEnabled = enableThreads; +} + +#include "moc_eventmessagecontentmodel.cpp" diff --git a/src/messagecontent/models/eventmessagecontentmodel.h b/src/messagecontent/models/eventmessagecontentmodel.h new file mode 100644 index 000000000..666e41a6c --- /dev/null +++ b/src/messagecontent/models/eventmessagecontentmodel.h @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: 2024 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#pragma once + +#include +#include + +#include "models/messagecontentmodel.h" +#include "models/threadmodel.h" + +/** + * @class EventMessageContentModel + * + * Inherited from MessageContentModel this visulaises the content of a Quotient::RoomMessageEvent. + */ +class EventMessageContentModel : public MessageContentModel +{ + Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE("") + +public: + enum MessageState { + Unknown, /**< The message state is unknown. */ + Pending, /**< The message is a new pending message which the server has not yet acknowledged. */ + Available, /**< The message is available and acknowledged by the server. */ + UnAvailable, /**< The message can't be retrieved either because it doesn't exist or is blocked. */ + }; + Q_ENUM(MessageState) + + explicit EventMessageContentModel(NeoChatRoom *room, + const QString &eventId, + bool isReply = false, + bool isPending = false, + MessageContentModel *parent = nullptr); + + /** + * @brief Returns the thread model for the given thread root event ID. + * + * A model is created if one doesn't exist. Will return nullptr if threadRootId + * is empty. + */ + Q_INVOKABLE ThreadModel *modelForThread(const QString &threadRootId); + + static void setThreadsEnabled(bool enableThreads); + +Q_SIGNALS: + void eventUpdated(); + void threadsEnabledChanged(); + +private: + void initializeModel(); + + QDateTime time() const override; + QString timeString() const override; + QString authorId() const override; + QString threadRootId() const override; + + MessageState m_currentState = Unknown; + bool m_isReply; + + void initializeEvent(); + void getEvent(); + + MessageComponent unavailableMessageComponent() const; + void resetModel(); + void resetContent(bool isEditing = false, bool isThreading = false); + QList messageContentComponents(bool isEditing = false, bool isThreading = false); + + void updateReplyModel(); + + QList componentsForType(MessageComponentType::Type type); + + void updateItineraryModel(); + + void updateReactionModel(); + + static bool m_threadsEnabled; +}; diff --git a/src/messagecontent/models/messagecontentmodel.cpp b/src/messagecontent/models/messagecontentmodel.cpp index b49a512a7..47462ee87 100644 --- a/src/messagecontent/models/messagecontentmodel.cpp +++ b/src/messagecontent/models/messagecontentmodel.cpp @@ -2,48 +2,20 @@ // SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL #include "messagecontentmodel.h" -#include "contentprovider.h" -#include "enums/messagecomponenttype.h" -#include "eventhandler.h" -#include "messagecomponent.h" - -#include - -#include -#include -#include -#include -#include -#if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1) -#include -#endif #include -#include - -#ifndef Q_OS_ANDROID -#include -#include -#endif #include "chatbarcache.h" #include "contentprovider.h" -#include "filetype.h" -#include "models/reactionmodel.h" +#include "enums/messagecomponenttype.h" #include "neochatconnection.h" -#include "neochatroom.h" -#include "texthandler.h" using namespace Quotient; -bool MessageContentModel::m_threadsEnabled = false; - -MessageContentModel::MessageContentModel(NeoChatRoom *room, const QString &eventId, bool isReply, bool isPending, MessageContentModel *parent) +MessageContentModel::MessageContentModel(NeoChatRoom *room, MessageContentModel *parent, const QString &eventId) : QAbstractListModel(parent) , m_room(room) , m_eventId(eventId) - , m_currentState(isPending ? Pending : Unknown) - , m_isReply(isReply) { initializeModel(); } @@ -51,43 +23,18 @@ MessageContentModel::MessageContentModel(NeoChatRoom *room, const QString &event void MessageContentModel::initializeModel() { Q_ASSERT(m_room != nullptr); - Q_ASSERT(!m_eventId.isEmpty()); - connect(m_room, &NeoChatRoom::pendingEventAdded, this, [this]() { - if (m_room != nullptr && m_currentState == Unknown) { - initializeEvent(); - resetModel(); + connect(this, &MessageContentModel::componentsUpdated, this, [this]() { + if (m_room->urlPreviewEnabled()) { + forEachComponentOfType({MessageComponentType::Text, MessageComponentType::Quote}, m_linkPreviewAddFunction); + } else { + forEachComponentOfType({MessageComponentType::LinkPreview, MessageComponentType::LinkPreviewLoad}, m_linkPreviewRemoveFunction); } + m_components.squeeze(); }); - connect(m_room, &NeoChatRoom::pendingEventAboutToMerge, this, [this](Quotient::RoomEvent *serverEvent) { - if (m_room != nullptr) { - if (m_eventId == serverEvent->id() || m_eventId == serverEvent->transactionId()) { - m_eventId = serverEvent->id(); - } - } - }); - connect(m_room, &NeoChatRoom::pendingEventMerged, this, [this]() { - if (m_room != nullptr && m_currentState == Pending) { - initializeEvent(); - resetModel(); - } - }); - connect(m_room, &NeoChatRoom::addedMessages, this, [this](int fromIndex, int toIndex) { - if (m_room != nullptr) { - for (int i = fromIndex; i <= toIndex; i++) { - if (m_room->findInTimeline(i)->event()->id() == m_eventId) { - initializeEvent(); - resetModel(); - } - } - } - }); - connect(m_room, &NeoChatRoom::replacedEvent, this, [this](const Quotient::RoomEvent *newEvent) { - if (m_room != nullptr) { - if (m_eventId == newEvent->id()) { - initializeEvent(); - resetContent(); - } + connect(this, &MessageContentModel::itineraryUpdated, this, [this]() { + if (hasComponentType(MessageComponentType::File)) { + forEachComponentOfType(MessageComponentType::File, m_fileFunction); } }); connect(m_room, &NeoChatRoom::newFileTransfer, this, [this](const QString &eventId) { @@ -120,117 +67,38 @@ void MessageContentModel::initializeModel() } } }); - connect(m_room->editCache(), &ChatBarCache::relationIdChanged, this, [this](const QString &oldEventId, const QString &newEventId) { - if (oldEventId == m_eventId || newEventId == m_eventId) { - resetContent(newEventId == m_eventId); - } - }); - connect(m_room->threadCache(), &ChatBarCache::threadIdChanged, this, [this](const QString &oldThreadId, const QString &newThreadId) { - if (oldThreadId == m_eventId || newThreadId == m_eventId) { - resetContent(false, newThreadId == m_eventId); - } - }); - connect(m_room, &NeoChatRoom::urlPreviewEnabledChanged, this, [this]() { - resetContent(); - }); - connect(m_room, &Room::memberNameUpdated, this, [this](RoomMember member) { - if (m_room != nullptr) { - if (senderId().isEmpty() || senderId() == member.id()) { - Q_EMIT dataChanged(index(0, 0), index(rowCount() - 1, 0), {AuthorRole}); - } - } - }); - connect(m_room, &Room::memberAvatarUpdated, this, [this](RoomMember member) { - if (m_room != nullptr) { - if (senderId().isEmpty() || senderId() == member.id()) { - Q_EMIT dataChanged(index(0, 0), index(rowCount() - 1, 0), {AuthorRole}); - } - } - }); - - connect(this, &MessageContentModel::threadsEnabledChanged, this, [this]() { - resetModel(); - }); - connect(m_room, &Room::updatedEvent, this, [this](const QString &eventId) { - if (eventId == m_eventId) { - updateReactionModel(); - } - }); - - initializeEvent(); - resetModel(); + connect(m_room, &NeoChatRoom::urlPreviewEnabledChanged, this, &MessageContentModel::componentsUpdated); } -void MessageContentModel::initializeEvent() +QString MessageContentModel::eventId() const { - if (m_currentState == UnAvailable) { - return; - } - - const auto eventResult = m_room->getEvent(m_eventId); - if (eventResult.first == nullptr) { - if (m_currentState != Pending) { - getEvent(); - } - return; - } - if (eventResult.second) { - m_currentState = Pending; - } else { - m_currentState = Available; - } - Q_EMIT eventUpdated(); + return m_eventId; } -void MessageContentModel::getEvent() +QDateTime MessageContentModel::time() const { - Quotient::connectUntil(m_room.get(), &NeoChatRoom::extraEventLoaded, this, [this](const QString &eventId) { - if (m_room != nullptr) { - if (eventId == m_eventId) { - initializeEvent(); - resetModel(); - return true; - } - } - return false; - }); - Quotient::connectUntil(m_room.get(), &NeoChatRoom::extraEventNotFound, this, [this](const QString &eventId) { - if (m_room != nullptr) { - if (eventId == m_eventId) { - m_currentState = UnAvailable; - resetModel(); - return true; - } - } - return false; - }); - - m_room->downloadEventFromServer(m_eventId); + return QDateTime::currentDateTime(); } -QString MessageContentModel::senderId() const +QString MessageContentModel::timeString() const { - const auto eventResult = m_room->getEvent(m_eventId); - if (eventResult.first == nullptr) { - return {}; - } - auto senderId = eventResult.first->senderId(); - if (senderId.isEmpty()) { - senderId = m_room->localMember().id(); - } - return senderId; + return time().toLocalTime().toString(u"hh:mm"_s); + ; } -NeochatRoomMember *MessageContentModel::senderObject() const +QString MessageContentModel::authorId() const { - const auto eventResult = m_room->getEvent(m_eventId); - if (eventResult.first == nullptr) { - return nullptr; - } - if (eventResult.first->senderId().isEmpty()) { - return m_room->qmlSafeMember(m_room->localMember().id()); - } - return m_room->qmlSafeMember(eventResult.first->senderId()); + return m_room->localMember().id(); +} + +NeochatRoomMember *MessageContentModel::author() const +{ + return m_room->qmlSafeMember(authorId()); +} + +QString MessageContentModel::threadRootId() const +{ + return {}; } static LinkPreviewer *emptyLinkPreview = new LinkPreviewer; @@ -248,47 +116,8 @@ QVariant MessageContentModel::data(const QModelIndex &index, int role) const const auto component = m_components[index.row()]; - const auto event = m_room->getEvent(m_eventId); - if (event.first == nullptr) { - if (role == DisplayRole) { - if (m_isReply) { - return i18n("Loading reply"); - } else { - return i18n("Loading"); - } - } - if (role == ComponentTypeRole) { - return component.type; - } - return {}; - } - if (role == DisplayRole) { - if (m_currentState == UnAvailable || m_room->connection()->isIgnored(senderId())) { - Kirigami::Platform::PlatformTheme *theme = - static_cast(qmlAttachedPropertiesObject(this, true)); - - QString disabledTextColor; - if (theme != nullptr) { - disabledTextColor = theme->disabledTextColor().name(); - } else { - disabledTextColor = u"#000000"_s; - } - return QString(u""_s.arg(disabledTextColor) - + i18nc("@info", "This message was either not found, you do not have permission to view it, or it was sent by an ignored user") - + u""_s); - } - if (component.type == MessageComponentType::Loading) { - if (m_isReply) { - return i18n("Loading reply"); - } else { - return i18n("Loading"); - } - } - if (!component.content.isEmpty()) { - return component.content; - } - return EventHandler::richBody(m_room, event.first); + return component.display; } if (role == ComponentTypeRole) { return component.type; @@ -297,63 +126,34 @@ QVariant MessageContentModel::data(const QModelIndex &index, int role) const return component.attributes; } if (role == EventIdRole) { - return event.first->displayId(); + return eventId(); } if (role == TimeRole) { - return EventHandler::time(m_room, event.first, m_currentState == Pending); + return time(); } if (role == TimeStringRole) { - return EventHandler::timeString(m_room, event.first, u"hh:mm"_s, m_currentState == Pending); + return timeString(); } if (role == AuthorRole) { - return QVariant::fromValue(senderObject()); - } - if (role == MediaInfoRole) { - return EventHandler::mediaInfo(m_room, event.first); + return QVariant::fromValue(author()); } if (role == FileTransferInfoRole) { - return QVariant::fromValue(m_room->cachedFileTransferInfo(event.first)); + return QVariant::fromValue(m_room->cachedFileTransferInfo(m_eventId)); } if (role == ItineraryModelRole) { return QVariant::fromValue(m_itineraryModel); } - if (role == LatitudeRole) { - return EventHandler::latitude(event.first); - } - if (role == LongitudeRole) { - return EventHandler::longitude(event.first); - } - if (role == AssetRole) { - return EventHandler::locationAssetType(event.first); - } if (role == PollHandlerRole) { return QVariant::fromValue(ContentProvider::self().handlerForPoll(m_room, m_eventId)); } - if (role == ReplyEventIdRole) { - if (const auto roomMessageEvent = eventCast(event.first)) { - return roomMessageEvent->replyEventId(); - } - } - if (role == ReplyAuthorRole) { - return QVariant::fromValue(EventHandler::replyAuthor(m_room, event.first)); - } if (role == ReplyContentModelRole) { return QVariant::fromValue(m_replyModel); } if (role == ReactionModelRole) { return QVariant::fromValue(m_reactionModel); - ; } if (role == ThreadRootRole) { - auto roomMessageEvent = eventCast(event.first); -#if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1) - if (roomMessageEvent && (roomMessageEvent->isThreaded() || m_room->threads().contains(roomMessageEvent->id()))) { -#else - if (roomMessageEvent && roomMessageEvent->isThreaded()) { -#endif - return roomMessageEvent->threadRootEventId(); - } - return {}; + return threadRootId(); } if (role == LinkPreviewerRole) { if (component.type == MessageComponentType::LinkPreview) { @@ -394,15 +194,9 @@ QHash MessageContentModel::roleNamesStatic() roles[MessageContentModel::TimeRole] = "time"; roles[MessageContentModel::TimeStringRole] = "timeString"; roles[MessageContentModel::AuthorRole] = "author"; - roles[MessageContentModel::MediaInfoRole] = "mediaInfo"; roles[MessageContentModel::FileTransferInfoRole] = "fileTransferInfo"; roles[MessageContentModel::ItineraryModelRole] = "itineraryModel"; - roles[MessageContentModel::LatitudeRole] = "latitude"; - roles[MessageContentModel::LongitudeRole] = "longitude"; - roles[MessageContentModel::AssetRole] = "asset"; roles[MessageContentModel::PollHandlerRole] = "pollHandler"; - roles[MessageContentModel::ReplyEventIdRole] = "replyEventId"; - roles[MessageContentModel::ReplyAuthorRole] = "replyAuthor"; roles[MessageContentModel::ReplyContentModelRole] = "replyContentModel"; roles[MessageContentModel::ReactionModelRole] = "reactionModel"; roles[MessageContentModel::ThreadRootRole] = "threadRoot"; @@ -443,256 +237,6 @@ void MessageContentModel::forEachComponentOfType(QListconnection()->isIgnored(senderId()) || m_currentState == UnAvailable) { - m_components += MessageComponent{MessageComponentType::Text, QString(), {}}; - endResetModel(); - return; - } - - const auto event = m_room->getEvent(m_eventId); - if (event.first == nullptr) { - m_components += MessageComponent{MessageComponentType::Loading, QString(), {}}; - endResetModel(); - return; - } - - m_components += MessageComponent{MessageComponentType::Author, QString(), {}}; - - m_components += messageContentComponents(); - endResetModel(); - - if (m_room->urlPreviewEnabled()) { - forEachComponentOfType({MessageComponentType::Text, MessageComponentType::Quote}, m_linkPreviewFunction); - } - - updateReplyModel(); - updateReactionModel(); -} - -void MessageContentModel::resetContent(bool isEditing, bool isThreading) -{ - const auto startRow = m_components[0].type == MessageComponentType::Author ? 1 : 0; - beginRemoveRows({}, startRow, rowCount() - 1); - m_components.remove(startRow, rowCount() - startRow); - endRemoveRows(); - - const auto newComponents = messageContentComponents(isEditing, isThreading); - if (newComponents.size() == 0) { - return; - } - beginInsertRows({}, startRow, startRow + newComponents.size() - 1); - m_components += newComponents; - endInsertRows(); - - if (m_room->urlPreviewEnabled()) { - forEachComponentOfType({MessageComponentType::Text, MessageComponentType::Quote}, m_linkPreviewFunction); - } - - updateReplyModel(); - updateReactionModel(); -} - -QList MessageContentModel::messageContentComponents(bool isEditing, bool isThreading) -{ - const auto event = m_room->getEvent(m_eventId); - if (event.first == nullptr) { - return {}; - } - - QList newComponents; - - if (isEditing) { - newComponents += MessageComponent{MessageComponentType::ChatBar, QString(), {}}; - } else { - newComponents.append(componentsForType(MessageComponentType::typeForEvent(*event.first, m_isReply))); - } - - const auto roomMessageEvent = eventCast(event.first); -#if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1) - if (m_threadsEnabled && roomMessageEvent && (roomMessageEvent->isThreaded() || m_room->threads().contains(roomMessageEvent->id())) - && roomMessageEvent->id() == roomMessageEvent->threadRootEventId()) { -#else - if (m_threadsEnabled && roomMessageEvent && roomMessageEvent->isThreaded() && roomMessageEvent->id() == roomMessageEvent->threadRootEventId()) { -#endif - newComponents += MessageComponent{MessageComponentType::Separator, {}, {}}; - newComponents += MessageComponent{MessageComponentType::ThreadBody, u"Thread Body"_s, {}}; - } - - // If the event is already threaded the ThreadModel will handle displaying a chat bar. -#if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1) - if (isThreading && roomMessageEvent && !(roomMessageEvent->isThreaded() || m_room->threads().contains(roomMessageEvent->id()))) { -#else - if (isThreading && roomMessageEvent && roomMessageEvent->isThreaded()) { -#endif - newComponents += MessageComponent{MessageComponentType::ChatBar, QString(), {}}; - } - - return newComponents; -} - -void MessageContentModel::updateReplyModel() -{ - const auto event = m_room->getEvent(m_eventId); - if (event.first == nullptr || m_isReply) { - return; - } - - const auto roomMessageEvent = eventCast(event.first); - if (roomMessageEvent == nullptr) { - return; - } - if (!roomMessageEvent->isReply(m_threadsEnabled) || (roomMessageEvent->isThreaded() && m_threadsEnabled)) { - if (m_replyModel) { - m_replyModel->disconnect(this); - m_replyModel->deleteLater(); - } - return; - } - - if (m_replyModel != nullptr) { - return; - } - - m_replyModel = new MessageContentModel(m_room, roomMessageEvent->replyEventId(!m_threadsEnabled), true, false, this); - - connect(m_replyModel, &MessageContentModel::eventUpdated, this, [this]() { - Q_EMIT dataChanged(index(0), index(0), {ReplyAuthorRole}); - }); - - bool hasModel = hasComponentType(MessageComponentType::Reply); - if (m_replyModel && !hasModel) { - int insertRow = 0; - if (m_components.first().type == MessageComponentType::Author) { - insertRow = 1; - } - beginInsertRows({}, insertRow, insertRow); - m_components.insert(insertRow, MessageComponent{MessageComponentType::Reply, QString(), {}}); - } else if (!m_replyModel && hasModel) { - int removeRow = 0; - if (m_components.first().type == MessageComponentType::Author) { - removeRow = 1; - } - beginRemoveRows({}, removeRow, removeRow); - m_components.removeAt(removeRow); - endRemoveRows(); - } -} - -QList MessageContentModel::componentsForType(MessageComponentType::Type type) -{ - const auto event = m_room->getEvent(m_eventId); - if (event.first == nullptr) { - return {}; - } - - switch (type) { - case MessageComponentType::Verification: { - return {MessageComponent{MessageComponentType::Verification, QString(), {}}}; - } - case MessageComponentType::Text: { - if (const auto roomMessageEvent = eventCast(event.first)) { - return TextHandler().textComponents(EventHandler::rawMessageBody(*roomMessageEvent), - EventHandler::messageBodyInputFormat(*roomMessageEvent), - m_room, - roomMessageEvent, - roomMessageEvent->isReplaced()); - } else { - return TextHandler().textComponents(EventHandler::plainBody(m_room, event.first), Qt::TextFormat::PlainText, m_room, event.first, false); - } - - const auto roomMessageEvent = eventCast(event.first); - return TextHandler().textComponents(EventHandler::rawMessageBody(*roomMessageEvent), - EventHandler::messageBodyInputFormat(*roomMessageEvent), - m_room, - roomMessageEvent, - roomMessageEvent->isReplaced()); - } - case MessageComponentType::File: { - QList components; - components += MessageComponent{MessageComponentType::File, QString(), {}}; - const auto roomMessageEvent = eventCast(event.first); - - if (m_emptyItinerary) { - if (!m_isReply) { - auto fileTransferInfo = m_room->cachedFileTransferInfo(event.first); - -#ifndef Q_OS_ANDROID - Q_ASSERT(roomMessageEvent->content() != nullptr && roomMessageEvent->has()); - const QMimeType mimeType = roomMessageEvent->get()->mimeType; - if (mimeType.name() == u"text/plain"_s || mimeType.parentMimeTypes().contains(u"text/plain"_s)) { - QString originalName = roomMessageEvent->get()->originalName; - if (originalName.isEmpty()) { - originalName = roomMessageEvent->plainBody(); - } - KSyntaxHighlighting::Repository repository; - KSyntaxHighlighting::Definition definitionForFile = repository.definitionForFileName(originalName); - if (!definitionForFile.isValid()) { - definitionForFile = repository.definitionForMimeType(mimeType.name()); - } - - QFile file(fileTransferInfo.localPath.path()); - file.open(QIODevice::ReadOnly); - components += MessageComponent{MessageComponentType::Code, - QString::fromStdString(file.readAll().toStdString()), - {{u"class"_s, definitionForFile.name()}}}; - } -#endif - - if (FileType::instance().fileHasImage(fileTransferInfo.localPath)) { - QImageReader reader(fileTransferInfo.localPath.path()); - components += MessageComponent{MessageComponentType::Pdf, QString(), {{u"size"_s, reader.size()}}}; - } - } - } else if (m_itineraryModel != nullptr) { - components += MessageComponent{MessageComponentType::Itinerary, QString(), {}}; - if (m_itineraryModel->rowCount() > 0) { - updateItineraryModel(); - } - } else { - updateItineraryModel(); - } - auto body = EventHandler::rawMessageBody(*roomMessageEvent); - components += TextHandler().textComponents(body, - EventHandler::messageBodyInputFormat(*roomMessageEvent), - m_room, - roomMessageEvent, - roomMessageEvent->isReplaced()); - return components; - } - case MessageComponentType::Image: - case MessageComponentType::Audio: - case MessageComponentType::Video: { - if (!event.first->is()) { - const auto roomMessageEvent = eventCast(event.first); - const auto fileContent = roomMessageEvent->get(); - if (fileContent != nullptr) { - const auto fileInfo = fileContent->commonInfo(); - const auto body = EventHandler::rawMessageBody(*roomMessageEvent); - // Do not attach the description to the image, if it's the same as the original filename. - if (fileInfo.originalName != body) { - QList components; - components += MessageComponent{type, QString(), {}}; - components += TextHandler().textComponents(body, - EventHandler::messageBodyInputFormat(*roomMessageEvent), - m_room, - roomMessageEvent, - roomMessageEvent->isReplaced()); - return components; - } - } - } - } - [[fallthrough]]; - default: - return {MessageComponent{type, QString(), {}}}; - } -} - MessageComponent MessageContentModel::linkPreviewComponent(const QUrl &link) { const auto linkPreviewer = dynamic_cast(m_room->connection())->previewerForLink(link); @@ -733,79 +277,4 @@ void MessageContentModel::closeLinkPreview(int row) } } -void MessageContentModel::updateItineraryModel() -{ - const auto event = m_room->getEvent(m_eventId); - if (m_room == nullptr || event.first == nullptr) { - return; - } - - if (auto roomMessageEvent = eventCast(event.first)) { - if (roomMessageEvent->has()) { - auto filePath = m_room->cachedFileTransferInfo(event.first).localPath; - if (filePath.isEmpty() && m_itineraryModel != nullptr) { - delete m_itineraryModel; - m_itineraryModel = nullptr; - } else if (!filePath.isEmpty()) { - if (m_itineraryModel == nullptr) { - m_itineraryModel = new ItineraryModel(this); - connect(m_itineraryModel, &ItineraryModel::loaded, this, [this]() { - if (m_itineraryModel->rowCount() == 0) { - m_emptyItinerary = true; - m_itineraryModel->deleteLater(); - m_itineraryModel = nullptr; - resetContent(); - } - }); - connect(m_itineraryModel, &ItineraryModel::loadErrorOccurred, this, [this]() { - m_emptyItinerary = true; - m_itineraryModel->deleteLater(); - m_itineraryModel = nullptr; - resetContent(); - }); - } - m_itineraryModel->setPath(filePath.toString()); - } - } - } -} - -void MessageContentModel::updateReactionModel() -{ - if (m_reactionModel && m_reactionModel->rowCount() > 0) { - return; - } - - if (m_reactionModel == nullptr) { - m_reactionModel = new ReactionModel(this, m_eventId, m_room); - connect(m_reactionModel, &ReactionModel::reactionsUpdated, this, &MessageContentModel::updateReactionModel); - } - - if (m_reactionModel->rowCount() <= 0) { - m_reactionModel->disconnect(this); - m_reactionModel->deleteLater(); - m_reactionModel = nullptr; - } - - if (m_reactionModel && m_components.last().type != MessageComponentType::Reaction) { - beginInsertRows({}, rowCount(), rowCount()); - m_components += MessageComponent{MessageComponentType::Reaction, QString(), {}}; - endInsertRows(); - } else if (rowCount() > 0 && m_components.last().type == MessageComponentType::Reaction) { - beginRemoveRows({}, rowCount() - 1, rowCount() - 1); - m_components.removeLast(); - endRemoveRows(); - } -} - -ThreadModel *MessageContentModel::modelForThread(const QString &threadRootId) -{ - return ContentProvider::self().modelForThread(m_room, threadRootId); -} - -void MessageContentModel::setThreadsEnabled(bool enableThreads) -{ - m_threadsEnabled = enableThreads; -} - #include "moc_messagecontentmodel.cpp" diff --git a/src/messagecontent/models/messagecontentmodel.h b/src/messagecontent/models/messagecontentmodel.h index 153d93a46..e2dc5f7e3 100644 --- a/src/messagecontent/models/messagecontentmodel.h +++ b/src/messagecontent/models/messagecontentmodel.h @@ -5,22 +5,29 @@ #include #include +#include -#include +#ifndef Q_OS_ANDROID +#include +#include +#endif #include "enums/messagecomponenttype.h" +#include "filetype.h" #include "linkpreviewer.h" #include "messagecomponent.h" #include "models/itinerarymodel.h" #include "models/reactionmodel.h" +#include "neochatroom.h" #include "neochatroommember.h" -class ThreadModel; - /** * @class MessageContentModel * - * A model to visualise the components of a single RoomMessageEvent. + * A model to visualise the content of a message. + * + * This is a base model designed to be extended. The inherited class needs to define + * how the MessageComponents are added. */ class MessageContentModel : public QAbstractListModel { @@ -28,15 +35,9 @@ class MessageContentModel : public QAbstractListModel QML_ELEMENT QML_UNCREATABLE("") -public: - enum MessageState { - Unknown, /**< The message state is unknown. */ - Pending, /**< The message is a new pending message which the server has not yet acknowledged. */ - Available, /**< The message is available and acknowledged by the server. */ - UnAvailable, /**< The message can't be retrieved either because it doesn't exist or is blocked. */ - }; - Q_ENUM(MessageState) + Q_PROPERTY(NeochatRoomMember *author READ author NOTIFY authorChanged) +public: /** * @brief Defines the model roles. */ @@ -48,32 +49,18 @@ public: TimeRole, /**< The timestamp for when the event was sent (as a QDateTime). */ TimeStringRole, /**< The timestamp for when the event was sent as a string (in QLocale::ShortFormat). */ AuthorRole, /**< The author of the event. */ - MediaInfoRole, /**< The media info for the event. */ FileTransferInfoRole, /**< FileTransferInfo for any downloading files. */ ItineraryModelRole, /**< The itinerary model for a file. */ - LatitudeRole, /**< Latitude for a location event. */ - LongitudeRole, /**< Longitude for a location event. */ - AssetRole, /**< Type of location event, e.g. self pin of the user location. */ PollHandlerRole, /**< The PollHandler for the event, if any. */ - - ReplyEventIdRole, /**< The matrix ID of the message that was replied to. */ - ReplyAuthorRole, /**< The author of the event that was replied to. */ ReplyContentModelRole, /**< The MessageContentModel for the reply event. */ - ReactionModelRole, /**< Reaction model for this event. */ - ThreadRootRole, /**< The thread root event ID for the event. */ - LinkPreviewerRole, /**< The link preview details. */ ChatBarCacheRole, /**< The ChatBarCache to use. */ }; Q_ENUM(Roles) - explicit MessageContentModel(NeoChatRoom *room, - const QString &eventId, - bool isReply = false, - bool isPending = false, - MessageContentModel *parent = nullptr); + explicit MessageContentModel(NeoChatRoom *room, MessageContentModel *parent = nullptr, const QString &eventId = {}); /** * @brief Get the given role value at the given index. @@ -95,9 +82,18 @@ public: * @sa Roles, QAbstractItemModel::roleNames() */ [[nodiscard]] QHash roleNames() const override; - static QHash roleNamesStatic(); + /** + * @brief The Matrix event ID of the message. + */ + Q_INVOKABLE QString eventId() const; + + /** + * @brief The author of the message. + */ + Q_INVOKABLE NeochatRoomMember *author() const; + /** * @brief Close the link preview at the given index. * @@ -105,34 +101,50 @@ public: */ Q_INVOKABLE void closeLinkPreview(int row); - /** - * @brief Returns the thread model for the given thread root event ID. - * - * A model is created is one doesn't exist. Will return nullptr if threadRootId - * is empty. - */ - Q_INVOKABLE ThreadModel *modelForThread(const QString &threadRootId); - - static void setThreadsEnabled(bool enableThreads); - Q_SIGNALS: - void showAuthorChanged(); - void eventUpdated(); + void authorChanged(); - void threadsEnabledChanged(); + /** + * @brief Emit whenever new components are added. + */ + void componentsUpdated(); -private: + /** + * @brief Emit whenever itinerary model is updated. + */ + void itineraryUpdated(); + +protected: QPointer m_room; QString m_eventId; - QString senderId() const; - NeochatRoomMember *senderObject() const; - MessageState m_currentState = Unknown; - bool m_isReply; + /** + * @brief QDateTime for the message. + * + * The default implementation returns the current time. + */ + virtual QDateTime time() const; - void initializeModel(); - void initializeEvent(); - void getEvent(); + /** + * @brief Time for the message as a string in the from "hh:mm". + * + * The default implementation returns the current time. + */ + virtual QString timeString() const; + + /** + * @brief The author of the message. + * + * The default implementation returns the local user. + */ + virtual QString authorId() const; + + /** + * @brief Thread root ID for the message if in a thread. + * + * The default implementation returns an empty string. + */ + virtual QString threadRootId() const; using ComponentIt = QList::iterator; @@ -141,14 +153,61 @@ private: void forEachComponentOfType(MessageComponentType::Type type, std::function function); void forEachComponentOfType(QList types, std::function function); + QPointer m_replyModel; + QPointer m_reactionModel = nullptr; + QPointer m_itineraryModel = nullptr; + bool m_emptyItinerary = false; + +private: + void initializeModel(); + std::function m_fileInfoFunction = [this](ComponentIt it) { Q_EMIT dataChanged(index(it - m_components.begin()), index(it - m_components.begin()), {MessageContentModel::FileTransferInfoRole}); return ++it; }; - std::function m_linkPreviewFunction = [this](ComponentIt it) { + std::function m_fileFunction = [this](ComponentIt it) { + if (m_itineraryModel && m_itineraryModel->rowCount() > 0) { + beginInsertRows({}, std::distance(m_components.begin(), it) + 1, std::distance(m_components.begin(), it) + 1); + it = m_components.insert(it + 1, MessageComponent{MessageComponentType::Itinerary, QString(), {}}); + endInsertRows(); + return it; + } else if (m_emptyItinerary) { + auto fileTransferInfo = m_room->cachedFileTransferInfo(m_eventId); +#ifndef Q_OS_ANDROID + const QMimeType mimeType = FileType::instance().mimeTypeForFile(fileTransferInfo.localPath.toString()); + if (mimeType.inherits(u"text/plain"_s)) { + KSyntaxHighlighting::Repository repository; + KSyntaxHighlighting::Definition definitionForFile = repository.definitionForFileName(fileTransferInfo.localPath.toString()); + if (!definitionForFile.isValid()) { + definitionForFile = repository.definitionForMimeType(mimeType.name()); + } + + QFile file(fileTransferInfo.localPath.path()); + file.open(QIODevice::ReadOnly); + beginInsertRows({}, std::distance(m_components.begin(), it) + 1, std::distance(m_components.begin(), it) + 1); + it = m_components.insert(it + 1, MessageComponent{MessageComponentType::Code, QString::fromStdString(file.readAll().toStdString()), {{u"class"_s, definitionForFile.name()}}}); + endInsertRows(); + return it; + } +#endif + + if (FileType::instance().fileHasImage(fileTransferInfo.localPath)) { + QImageReader reader(fileTransferInfo.localPath.path()); + beginInsertRows({}, std::distance(m_components.begin(), it) + 1, std::distance(m_components.begin(), it) + 1); + it = m_components.insert(it + 1, MessageComponent{MessageComponentType::Pdf, QString(), {{u"size"_s, reader.size()}}}); + endInsertRows(); + } + } + return ++it; + }; + std::function m_linkPreviewAddFunction = [this](ComponentIt it) { + if (!m_room->urlPreviewEnabled()) { + return it; + } + bool previewAdded = false; - if (LinkPreviewer::hasPreviewableLinks(it->content)) { - const auto links = LinkPreviewer::linkPreviews(it->content); + if (LinkPreviewer::hasPreviewableLinks(it->display)) { + const auto links = LinkPreviewer::linkPreviews(it->display); for (qsizetype j = 0; j < links.size(); ++j) { const auto linkPreview = linkPreviewComponent(links[j]); if (!m_removedLinkPreviews.contains(links[j]) && !linkPreview.isEmpty()) { @@ -161,26 +220,16 @@ private: } return previewAdded ? it : ++it; }; - - void resetModel(); - void resetContent(bool isEditing = false, bool isThreading = false); - QList messageContentComponents(bool isEditing = false, bool isThreading = false); - - QPointer m_replyModel; - void updateReplyModel(); - - ReactionModel *m_reactionModel = nullptr; - ItineraryModel *m_itineraryModel = nullptr; - - QList componentsForType(MessageComponentType::Type type); - MessageComponent linkPreviewComponent(const QUrl &link); + std::function m_linkPreviewRemoveFunction = [this](ComponentIt it) { + if (m_room->urlPreviewEnabled()) { + return it; + } + beginRemoveRows({}, std::distance(m_components.begin(), it), std::distance(m_components.begin(), it)); + it = m_components.erase(it); + endRemoveRows(); + return it; + }; QList m_removedLinkPreviews; - - void updateItineraryModel(); - bool m_emptyItinerary = false; - - void updateReactionModel(); - - static bool m_threadsEnabled; + MessageComponent linkPreviewComponent(const QUrl &link); }; diff --git a/src/timeline/models/messagemodel.cpp b/src/timeline/models/messagemodel.cpp index b80eea6bd..9ed037417 100644 --- a/src/timeline/models/messagemodel.cpp +++ b/src/timeline/models/messagemodel.cpp @@ -20,6 +20,7 @@ #include "eventhandler.h" #include "events/pollevent.h" #include "models/reactionmodel.h" +#include "models/eventmessagecontentmodel.h" #include "neochatroommember.h" using namespace Quotient; @@ -42,7 +43,7 @@ MessageModel::MessageModel(QObject *parent) }); connect(this, &MessageModel::threadsEnabledChanged, this, [this]() { - Q_EMIT dataChanged(index(0), index(rowCount() - 1), {IsThreadedRole}); + Q_EMIT dataChanged(index(0), index(rowCount() - 1), {ContentModelRole, IsThreadedRole}); }); } @@ -142,14 +143,15 @@ QVariant MessageModel::data(const QModelIndex &idx, int role) const if (role == ContentModelRole) { if (event->get().is() || event->get().is() || event->get().is()) { - return QVariant::fromValue(ContentProvider::self().contentModelForEvent(m_room, event->get().id())); + return QVariant::fromValue(ContentProvider::self().contentModelForEvent(m_room, event->get().id())); } auto roomMessageEvent = eventCast(&event.value().get()); if (m_threadsEnabled && roomMessageEvent && roomMessageEvent->isThreaded()) { - return QVariant::fromValue(ContentProvider::self().contentModelForEvent(m_room, roomMessageEvent->threadRootEventId())); + return QVariant::fromValue( + ContentProvider::self().contentModelForEvent(m_room, roomMessageEvent->threadRootEventId())); } - return QVariant::fromValue(ContentProvider::self().contentModelForEvent(m_room, &event->get())); + return QVariant::fromValue(ContentProvider::self().contentModelForEvent(m_room, &event->get())); } if (role == GenericDisplayRole) {