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,
This commit is contained in:
James Graham
2025-08-01 12:15:51 +01:00
parent 501f14fead
commit b4e1740cad
24 changed files with 874 additions and 789 deletions

View File

@@ -10,7 +10,7 @@
#include <Quotient/roommember.h> #include <Quotient/roommember.h>
#include <Quotient/syncdata.h> #include <Quotient/syncdata.h>
#include "models/messagecontentmodel.h" #include "models/eventmessagecontentmodel.h"
#include "neochatconnection.h" #include "neochatconnection.h"
#include "testutils.h" #include "testutils.h"
@@ -39,13 +39,13 @@ void MessageContentModelTest::initTestCase()
void MessageContentModelTest::missingEvent() void MessageContentModelTest::missingEvent()
{ {
auto room = new TestUtils::TestRoom(connection, u"#firstRoom:kde.org"_s); 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.rowCount(), 1);
QCOMPARE(model1.data(model1.index(0), MessageContentModel::ComponentTypeRole), MessageComponentType::Loading); QCOMPARE(model1.data(model1.index(0), MessageContentModel::ComponentTypeRole), MessageComponentType::Loading);
QCOMPARE(model1.data(model1.index(0), MessageContentModel::DisplayRole), u"Loading"_s); 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.rowCount(), 1);
QCOMPARE(model2.data(model2.index(0), MessageContentModel::ComponentTypeRole), MessageComponentType::Loading); QCOMPARE(model2.data(model2.index(0), MessageContentModel::ComponentTypeRole), MessageComponentType::Loading);

View File

@@ -9,7 +9,7 @@
#include <Quotient/events/roommessageevent.h> #include <Quotient/events/roommessageevent.h>
#include "models/messagecontentmodel.h" #include "models/eventmessagecontentmodel.h"
#include "testutils.h" #include "testutils.h"
using namespace Quotient; using namespace Quotient;
@@ -21,7 +21,7 @@ class ReactionModelTest : public QObject
private: private:
Connection *connection = nullptr; Connection *connection = nullptr;
TestUtils::TestRoom *room = nullptr; TestUtils::TestRoom *room = nullptr;
MessageContentModel *parentModel; EventMessageContentModel *parentModel;
private Q_SLOTS: private Q_SLOTS:
void initTestCase(); void initTestCase();
@@ -34,7 +34,7 @@ void ReactionModelTest::initTestCase()
{ {
connection = Connection::makeMockConnection(u"@bob:kde.org"_s); connection = Connection::makeMockConnection(u"@bob:kde.org"_s);
room = new TestUtils::TestRoom(connection, u"#myroom:kde.org"_s, u"test-reactionmodel-sync.json"_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() void ReactionModelTest::basicReaction()

View File

@@ -382,8 +382,6 @@ QQC2.Control {
implicitHeight: replyComponent.implicitHeight implicitHeight: replyComponent.implicitHeight
ReplyComponent { ReplyComponent {
id: replyComponent id: replyComponent
replyEventId: _private.chatBarCache.replyId
replyAuthor: _private.chatBarCache.relationAuthor
replyContentModel: ContentProvider.contentModelForEvent(root.currentRoom, _private.chatBarCache.replyId, true) replyContentModel: ContentProvider.contentModelForEvent(root.currentRoom, _private.chatBarCache.replyId, true)
Message.maxContentWidth: replyLoader.item.width Message.maxContentWidth: replyLoader.item.width

View File

@@ -198,6 +198,10 @@ bool EventHandler::isHidden(const NeoChatRoom *room, const Quotient::RoomEvent *
Qt::TextFormat EventHandler::messageBodyInputFormat(const Quotient::RoomMessageEvent &event) Qt::TextFormat EventHandler::messageBodyInputFormat(const Quotient::RoomMessageEvent &event)
{ {
if (event.isRedacted() && !event.isStateEvent()) {
return Qt::RichText;
}
if (event.mimeType().name() == "text/plain"_L1) { if (event.mimeType().name() == "text/plain"_L1) {
return Qt::PlainText; return Qt::PlainText;
} else { } else {
@@ -207,6 +211,11 @@ Qt::TextFormat EventHandler::messageBodyInputFormat(const Quotient::RoomMessageE
QString EventHandler::rawMessageBody(const Quotient::RoomMessageEvent &event) QString EventHandler::rawMessageBody(const Quotient::RoomMessageEvent &event)
{ {
if (event.isRedacted() && !event.isStateEvent()) {
auto reason = event.redactedBecause()->reason();
return (reason.isEmpty()) ? i18n("<i>[This message was deleted]</i>") : i18n("<i>[This message was deleted: %1]</i>", reason.toHtmlEscaped());
}
QString body; QString body;
if (event.has<EventContent::FileContent>()) { if (event.has<EventContent::FileContent>()) {

View File

@@ -7,12 +7,12 @@
struct MessageComponent { struct MessageComponent {
MessageComponentType::Type type = MessageComponentType::Other; MessageComponentType::Type type = MessageComponentType::Other;
QString content; QString display;
QVariantMap attributes; QVariantMap attributes;
bool operator==(const MessageComponent &right) const 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 bool isEmpty() const

View File

@@ -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 FileTransferInfo NeoChatRoom::cachedFileTransferInfo(const Quotient::RoomEvent *event) const
{ {
QString mxcUrl; QString mxcUrl;

View File

@@ -544,7 +544,15 @@ public:
* @brief Return the cached file transfer information for the event. * @brief Return the cached file transfer information for the event.
* *
* If we downloaded the file previously, return a struct with Completed status * 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; Quotient::FileTransferInfo cachedFileTransferInfo(const Quotient::RoomEvent *event) const;

View File

@@ -592,7 +592,7 @@ TextHandler::textComponents(QString string, Qt::TextFormat inputFormat, const Ne
if (event != nullptr && room != nullptr) { if (event != nullptr && room != nullptr) {
if (auto e = eventCast<const Quotient::RoomMessageEvent>(event); e && e->msgtype() == Quotient::MessageEventType::Emote && components.size() == 1) { if (auto e = eventCast<const Quotient::RoomMessageEvent>(event); e && e->msgtype() == Quotient::MessageEventType::Emote && components.size() == 1) {
if (components[0].type == MessageComponentType::Text) { 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 { } else {
components.prepend(MessageComponent{MessageComponentType::Text, emoteString(room, event), {}}); components.prepend(MessageComponent{MessageComponentType::Text, emoteString(room, event), {}});
} }

View File

@@ -24,19 +24,9 @@ ColumnLayout {
required property string eventId required property string eventId
/** /**
* @brief The media info for the event. * @brief The attributes of the component.
*
* 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
*/ */
required property var mediaInfo required property var componentAttributes
/** /**
* @brief FileTransferInfo for any downloading files. * @brief FileTransferInfo for any downloading files.
@@ -130,12 +120,12 @@ ColumnLayout {
spacing: 0 spacing: 0
QQC2.Label { QQC2.Label {
text: root.mediaInfo.filename text: root.componentAttributes.filename
wrapMode: Text.Wrap wrapMode: Text.Wrap
Layout.fillWidth: true Layout.fillWidth: true
} }
QQC2.Label { QQC2.Label {
text: Format.formatDuration(root.mediaInfo.duration) text: Format.formatDuration(root.componentAttributes.duration)
color: Kirigami.Theme.disabledTextColor color: Kirigami.Theme.disabledTextColor
visible: !audio.hasAudio visible: !audio.hasAudio
Layout.fillWidth: true Layout.fillWidth: true
@@ -147,7 +137,7 @@ ColumnLayout {
visible: false visible: false
Layout.fillWidth: true Layout.fillWidth: true
from: 0 from: 0
to: root.mediaInfo.size to: root.componentAttributes.size
value: root.fileTransferInfo.progress value: root.fileTransferInfo.progress
} }
RowLayout { RowLayout {

View File

@@ -52,6 +52,7 @@ ecm_add_qml_module(MessageContent GENERATE_PLUGIN_SOURCE
models/pollanswermodel.cpp models/pollanswermodel.cpp
models/reactionmodel.cpp models/reactionmodel.cpp
models/threadmodel.cpp models/threadmodel.cpp
models/eventmessagecontentmodel.cpp
RESOURCES RESOURCES
images/bike.svg images/bike.svg
images/bus.svg images/bus.svg

View File

@@ -222,8 +222,6 @@ QQC2.Control {
implicitHeight: replyComponent.implicitHeight implicitHeight: replyComponent.implicitHeight
ReplyComponent { ReplyComponent {
id: replyComponent id: replyComponent
replyEventId: root.chatBarCache.replyId
replyAuthor: root.chatBarCache.relationAuthor
replyContentModel: ContentProvider.contentModelForEvent(root.Message.room, root.chatBarCache.replyId, true) replyContentModel: ContentProvider.contentModelForEvent(root.Message.room, root.chatBarCache.replyId, true)
Message.maxContentWidth: paneLoader.item.width Message.maxContentWidth: paneLoader.item.width
} }

View File

@@ -26,19 +26,9 @@ ColumnLayout {
required property string eventId required property string eventId
/** /**
* @brief The media info for the event. * @brief The attributes of the component.
*
* 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
*/ */
required property var mediaInfo required property var componentAttributes
/** /**
* @brief FileTransferInfo for any downloading files. * @brief FileTransferInfo for any downloading files.
@@ -134,7 +124,7 @@ ColumnLayout {
] ]
Kirigami.Icon { Kirigami.Icon {
source: root.mediaInfo.mimeIcon source: root.componentAttributes.mimeIcon
fallback: "unknown" fallback: "unknown"
} }
@@ -142,14 +132,14 @@ ColumnLayout {
spacing: 0 spacing: 0
QQC2.Label { QQC2.Label {
Layout.fillWidth: true Layout.fillWidth: true
text: root.mediaInfo.filename text: root.componentAttributes.filename
wrapMode: Text.Wrap wrapMode: Text.Wrap
elide: Text.ElideRight elide: Text.ElideRight
} }
QQC2.Label { QQC2.Label {
id: sizeLabel id: sizeLabel
Layout.fillWidth: true Layout.fillWidth: true
text: Format.formatByteSize(root.mediaInfo.size) text: Format.formatByteSize(root.componentAttributes.size)
opacity: 0.7 opacity: 0.7
elide: Text.ElideRight elide: Text.ElideRight
maximumLineCount: 1 maximumLineCount: 1

View File

@@ -27,19 +27,9 @@ Item {
required property string display required property string display
/** /**
* @brief The media info for the event. * @brief The attributes of the component.
*
* 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
*/ */
required property var mediaInfo required property var componentAttributes
/** /**
* @brief FileTransferInfo for any downloading files. * @brief FileTransferInfo for any downloading files.
@@ -77,9 +67,9 @@ Item {
anchors.fill: parent anchors.fill: parent
active: !root.mediaInfo.animated && !_private.hideImage active: !root.componentAttributes.animated && !_private.hideImage
sourceComponent: Image { sourceComponent: Image {
source: root.mediaInfo.source source: root.componentAttributes.source
sourceSize.width: mediaSizeHelper.currentSize.width * Screen.devicePixelRatio sourceSize.width: mediaSizeHelper.currentSize.width * Screen.devicePixelRatio
sourceSize.height: mediaSizeHelper.currentSize.height * Screen.devicePixelRatio sourceSize.height: mediaSizeHelper.currentSize.height * Screen.devicePixelRatio
@@ -93,9 +83,9 @@ Item {
anchors.fill: parent anchors.fill: parent
active: (root?.mediaInfo.animated ?? false) && !_private.hideImage active: (root?.componentAttributes.animated ?? false) && !_private.hideImage
sourceComponent: AnimatedImage { sourceComponent: AnimatedImage {
source: root.mediaInfo.source source: root.componentAttributes.source
fillMode: Image.PreserveAspectFit fillMode: Image.PreserveAspectFit
autoTransform: true autoTransform: true
@@ -106,7 +96,7 @@ Item {
Image { Image {
anchors.fill: parent 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 visible: _private.imageItem && _private.imageItem.status !== Image.Ready && !_private.hideImage
} }
@@ -153,11 +143,11 @@ Item {
gesturePolicy: TapHandler.ReleaseWithinBounds | TapHandler.WithinBounds gesturePolicy: TapHandler.ReleaseWithinBounds | TapHandler.WithinBounds
onTapped: { onTapped: {
root.QQC2.ToolTip.hide(); root.QQC2.ToolTip.hide();
if (root.mediaInfo.animated) { if (root.componentAttributes.animated) {
_private.imageItem.paused = true; _private.imageItem.paused = true;
} }
root.Message.timeline.interactive = false; root.Message.timeline.interactive = false;
if (!root.mediaInfo.isSticker) { if (!root.componentAttributes.isSticker) {
RoomManager.maximizeMedia(root.eventId); RoomManager.maximizeMedia(root.eventId);
} }
} }
@@ -183,13 +173,13 @@ Item {
id: mediaSizeHelper id: mediaSizeHelper
contentMaxWidth: root.Message.maxContentWidth contentMaxWidth: root.Message.maxContentWidth
contentMaxHeight: root.contentMaxHeight ?? -1 contentMaxHeight: root.contentMaxHeight ?? -1
mediaWidth: root?.mediaInfo.isSticker ? 256 : (root?.mediaInfo.width ?? 0) mediaWidth: root?.componentAttributes.isSticker ? 256 : (root?.componentAttributes.width ?? 0)
mediaHeight: root?.mediaInfo.isSticker ? 256 : (root?.mediaInfo.height ?? 0) mediaHeight: root?.componentAttributes.isSticker ? 256 : (root?.componentAttributes.height ?? 0)
} }
QtObject { QtObject {
id: _private 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 // The space available for the component after taking away the border
readonly property real downloaded: root.fileTransferInfo && root.fileTransferInfo.completed readonly property real downloaded: root.fileTransferInfo && root.fileTransferInfo.completed

View File

@@ -37,22 +37,9 @@ ColumnLayout {
required property string display required property string display
/** /**
* @brief The latitude of the location marker in the message. * @brief The attributes of the component.
*/ */
required property real latitude required property var componentAttributes
/**
* @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
Layout.fillWidth: true Layout.fillWidth: true
Layout.maximumWidth: Message.maxContentWidth Layout.maximumWidth: Message.maxContentWidth
@@ -63,15 +50,15 @@ ColumnLayout {
Layout.preferredWidth: root.Message.maxContentWidth Layout.preferredWidth: root.Message.maxContentWidth
Layout.preferredHeight: root.Message.maxContentWidth / 16 * 9 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.zoomLevel: 15
map.plugin: OsmLocationPlugin.plugin map.plugin: OsmLocationPlugin.plugin
readonly property LocationMapItem locationMapItem: LocationMapItem { readonly property LocationMapItem locationMapItem: LocationMapItem {
latitude: root.latitude latitude: root.componentAttributes.latitude
longitude: root.longitude longitude: root.componentAttributes.longitude
asset: root.asset asset: root.componentAttributes.asset
author: root.author author: root.author
isLive: true isLive: true
heading: NaN heading: NaN
@@ -100,7 +87,7 @@ ColumnLayout {
icon.name: "open-link-symbolic" icon.name: "open-link-symbolic"
display: AbstractButton.IconOnly 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.text: text
ToolTip.visible: hovered ToolTip.visible: hovered
@@ -114,12 +101,11 @@ ColumnLayout {
onClicked: { onClicked: {
let map = fullScreenMap.createObject(parent, { let map = fullScreenMap.createObject(parent, {
latitude: root.latitude, latitude: root.componentAttributes.latitude,
longitude: root.longitude, longitude: root.componentAttributes.longitude,
asset: root.asset, asset: root.componentAttributes.asset,
author: root.author author: root.author
}); });
map.open();
} }
ToolTip.text: text ToolTip.text: text

View File

@@ -24,20 +24,6 @@ import org.kde.neochat
RowLayout { RowLayout {
id: root 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. * @brief The model to visualise the content of the message replied to.
*/ */
@@ -52,7 +38,7 @@ RowLayout {
Layout.fillHeight: true Layout.fillHeight: true
implicitWidth: Kirigami.Units.smallSpacing implicitWidth: Kirigami.Units.smallSpacing
color: root.replyAuthor.color color: root.replyContentModel.author.color
radius: Kirigami.Units.cornerRadius radius: Kirigami.Units.cornerRadius
} }
ColumnLayout { ColumnLayout {
@@ -65,7 +51,7 @@ RowLayout {
id: contentRepeater id: contentRepeater
model: root.replyContentModel model: root.replyContentModel
delegate: ReplyMessageComponentChooser { delegate: ReplyMessageComponentChooser {
onReplyClicked: RoomManager.goToEvent(root.replyEventId) onReplyClicked: RoomManager.goToEvent(root.replyContentModel.eventId)
} }
} }
} }
@@ -74,7 +60,7 @@ RowLayout {
} }
TapHandler { TapHandler {
acceptedButtons: Qt.LeftButton acceptedButtons: Qt.LeftButton
onTapped: RoomManager.goToEvent(root.replyEventId) onTapped: RoomManager.goToEvent(root.replyContentModel.eventId)
} }
QtObject { QtObject {
id: _private id: _private

View File

@@ -49,12 +49,12 @@ DelegateChooser {
DelegateChoice { DelegateChoice {
roleValue: MessageComponentType.Video roleValue: MessageComponentType.Video
delegate: MimeComponent { delegate: MimeComponent {
required property var mediaInfo required property var componentAttributes
mimeIconSource: mediaInfo.mimeIcon mimeIconSource: componentAttributes.mimeIcon
size: mediaInfo.size size: componentAttributes.size
duration: mediaInfo.duration duration: componentAttributes.duration
label: mediaInfo.filename label: componentAttributes.filename
} }
} }
@@ -88,12 +88,12 @@ DelegateChooser {
roleValue: MessageComponentType.Audio roleValue: MessageComponentType.Audio
delegate: MimeComponent { delegate: MimeComponent {
required property string display required property string display
required property var mediaInfo required property var componentAttributes
mimeIconSource: mediaInfo.mimeIcon mimeIconSource: componentAttributes.mimeIcon
size: mediaInfo.size size: componentAttributes.size
duration: mediaInfo.duration duration: componentAttributes.duration
label: mediaInfo.filename label: componentAttributes.filename
} }
} }
@@ -101,11 +101,11 @@ DelegateChooser {
roleValue: MessageComponentType.File roleValue: MessageComponentType.File
delegate: MimeComponent { delegate: MimeComponent {
required property string display required property string display
required property var mediaInfo required property var componentAttributes
mimeIconSource: mediaInfo.mimeIcon mimeIconSource: componentAttributes.mimeIcon
size: mediaInfo.size size: componentAttributes.size
label: mediaInfo.filename label: componentAttributes.filename
} }
} }

View File

@@ -30,18 +30,9 @@ Video {
required property string display required property string display
/** /**
* @brief The media info for the event. * @brief The attributes of the component.
*
* 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.
*/ */
required property var mediaInfo required property var componentAttributes
/** /**
* @brief FileTransferInfo for any downloading files. * @brief FileTransferInfo for any downloading files.
@@ -206,7 +197,7 @@ Video {
anchors.fill: parent anchors.fill: parent
visible: false visible: false
source: visible ? root.mediaInfo.tempInfo.source : "" source: visible ? root.componentAttributes.tempInfo.source : ""
fillMode: Image.PreserveAspectFit fillMode: Image.PreserveAspectFit
} }
@@ -437,8 +428,8 @@ Video {
MediaSizeHelper { MediaSizeHelper {
id: mediaSizeHelper id: mediaSizeHelper
contentMaxWidth: root.Message.maxContentWidth contentMaxWidth: root.Message.maxContentWidth
mediaWidth: root.mediaInfo.width mediaWidth: root.componentAttributes.width
mediaHeight: root.mediaInfo.height mediaHeight: root.componentAttributes.height
} }
function downloadAndPlay() { function downloadAndPlay() {

View File

@@ -14,14 +14,14 @@ ContentProvider &ContentProvider::self()
return instance; 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()) { if (!room || evtOrTxnId.isEmpty()) {
return nullptr; return nullptr;
} }
if (!m_eventContentModels.contains(evtOrTxnId)) { if (!m_eventContentModels.contains(evtOrTxnId)) {
auto model = new MessageContentModel(room, evtOrTxnId, isReply); auto model = new EventMessageContentModel(room, evtOrTxnId, isReply);
QQmlEngine::setObjectOwnership(model, QQmlEngine::CppOwnership); QQmlEngine::setObjectOwnership(model, QQmlEngine::CppOwnership);
m_eventContentModels.insert(evtOrTxnId, model); m_eventContentModels.insert(evtOrTxnId, model);
} }
@@ -29,7 +29,7 @@ MessageContentModel *ContentProvider::contentModelForEvent(NeoChatRoom *room, co
return m_eventContentModels.object(evtOrTxnId); 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) { if (!room) {
return nullptr; return nullptr;
@@ -53,7 +53,7 @@ MessageContentModel *ContentProvider::contentModelForEvent(NeoChatRoom *room, co
auto eventId = event->id(); auto eventId = event->id();
const auto txnId = event->transactionId(); const auto txnId = event->transactionId();
if (!m_eventContentModels.contains(eventId) && !m_eventContentModels.contains(txnId)) { 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); QQmlEngine::setObjectOwnership(model, QQmlEngine::CppOwnership);
m_eventContentModels.insert(eventId.isEmpty() ? txnId : eventId, model); 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) void ContentProvider::setThreadsEnabled(bool enableThreads)
{ {
MessageContentModel::setThreadsEnabled(enableThreads); EventMessageContentModel::setThreadsEnabled(enableThreads);
for (const auto &key : m_eventContentModels.keys()) { for (const auto &key : m_eventContentModels.keys()) {
m_eventContentModels.object(key)->threadsEnabledChanged(); m_eventContentModels.object(key)->threadsEnabledChanged();

View File

@@ -7,8 +7,8 @@
#include <QObject> #include <QObject>
#include <QQmlEngine> #include <QQmlEngine>
#include "models/messagecontentmodel.h"
#include "models/threadmodel.h" #include "models/threadmodel.h"
#include "models/eventmessagecontentmodel.h"
#include "neochatroom.h" #include "neochatroom.h"
#include "pollhandler.h" #include "pollhandler.h"
@@ -46,7 +46,7 @@ public:
* *
* @warning Do NOT use for pending events as this function has no way to differentiate. * @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. * @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. * @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. * @brief Returns the thread model for the given thread root event ID.
@@ -86,7 +86,7 @@ public:
private: private:
explicit ContentProvider(QObject *parent = nullptr); explicit ContentProvider(QObject *parent = nullptr);
QCache<QString, MessageContentModel> m_eventContentModels; QCache<QString, EventMessageContentModel> m_eventContentModels;
QCache<QString, ThreadModel> m_threadModels; QCache<QString, ThreadModel> m_threadModels;
QCache<QString, PollHandler> m_pollHandlers; QCache<QString, PollHandler> m_pollHandlers;
}; };

View File

@@ -0,0 +1,525 @@
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#include "eventmessagecontentmodel.h"
#include <Quotient/events/eventcontent.h>
#include <Quotient/events/roommessageevent.h>
#include <Quotient/events/stickerevent.h>
#include <Quotient/qt_connection_util.h>
#if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1)
#include <Quotient/thread.h>
#endif
#include <KLocalizedString>
#include <Kirigami/Platform/PlatformTheme>
#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<const RoomMessageEvent>(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<Kirigami::Platform::PlatformTheme *>(qmlAttachedPropertiesObject<Kirigami::Platform::PlatformTheme>(this, true));
QString disabledTextColor;
if (theme != nullptr) {
disabledTextColor = theme->disabledTextColor().name();
} else {
disabledTextColor = u"#000000"_s;
}
return MessageComponent{
.type = MessageComponentType::Text,
.display = u"<span style=\"color:%1\">"_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"</span>"_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<MessageComponent> EventMessageContentModel::messageContentComponents(bool isEditing, bool isThreading)
{
const auto event = m_room->getEvent(m_eventId);
if (event.first == nullptr) {
return {};
}
QList<MessageComponent> newComponents;
if (isEditing) {
newComponents += MessageComponent{MessageComponentType::ChatBar, QString(), {}};
} else {
newComponents.append(componentsForType(MessageComponentType::typeForEvent(*event.first, m_isReply)));
}
const auto roomMessageEvent = eventCast<const Quotient::RoomMessageEvent>(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<const Quotient::RoomMessageEvent>(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<MessageComponent> 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<const Quotient::RoomMessageEvent>(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<const Quotient::RoomMessageEvent>(event.first);
return TextHandler().textComponents(EventHandler::rawMessageBody(*roomMessageEvent),
EventHandler::messageBodyInputFormat(*roomMessageEvent),
m_room,
roomMessageEvent,
roomMessageEvent->isReplaced());
}
case MessageComponentType::File: {
QList<MessageComponent> components;
components += MessageComponent{MessageComponentType::File, {}, EventHandler::mediaInfo(m_room, event.first)};
const auto roomMessageEvent = eventCast<const Quotient::RoomMessageEvent>(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<MessageComponent> components = {
MessageComponent{type, EventHandler::richBody(m_room, event.first), EventHandler::mediaInfo(m_room, event.first)}};
if (!event.first->is<StickerEvent>()) {
const auto roomMessageEvent = eventCast<const Quotient::RoomMessageEvent>(event.first);
const auto fileContent = roomMessageEvent->get<EventContent::FileContentBase>();
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<const Quotient::RoomMessageEvent>(m_room->getEvent(m_eventId).first);
if (!roomMessageEvent || !roomMessageEvent->has<EventContent::FileContent>()) {
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"

View File

@@ -0,0 +1,80 @@
// SPDX-FileCopyrightText: 2024 James Graham <james.h.graham@protonmail.com>
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#pragma once
#include <QAbstractListModel>
#include <QQmlEngine>
#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<MessageComponent> messageContentComponents(bool isEditing = false, bool isThreading = false);
void updateReplyModel();
QList<MessageComponent> componentsForType(MessageComponentType::Type type);
void updateItineraryModel();
void updateReactionModel();
static bool m_threadsEnabled;
};

View File

@@ -2,48 +2,20 @@
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL // SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#include "messagecontentmodel.h" #include "messagecontentmodel.h"
#include "contentprovider.h"
#include "enums/messagecomponenttype.h"
#include "eventhandler.h"
#include "messagecomponent.h"
#include <QImageReader>
#include <Quotient/events/eventcontent.h>
#include <Quotient/events/redactionevent.h>
#include <Quotient/events/roommessageevent.h>
#include <Quotient/events/stickerevent.h>
#include <Quotient/qt_connection_util.h>
#if Quotient_VERSION_MINOR > 9 || (Quotient_VERSION_MINOR == 9 && Quotient_VERSION_PATCH > 1)
#include <Quotient/thread.h>
#endif
#include <KLocalizedString> #include <KLocalizedString>
#include <Kirigami/Platform/PlatformTheme>
#ifndef Q_OS_ANDROID
#include <KSyntaxHighlighting/Definition>
#include <KSyntaxHighlighting/Repository>
#endif
#include "chatbarcache.h" #include "chatbarcache.h"
#include "contentprovider.h" #include "contentprovider.h"
#include "filetype.h" #include "enums/messagecomponenttype.h"
#include "models/reactionmodel.h"
#include "neochatconnection.h" #include "neochatconnection.h"
#include "neochatroom.h"
#include "texthandler.h"
using namespace Quotient; using namespace Quotient;
bool MessageContentModel::m_threadsEnabled = false; MessageContentModel::MessageContentModel(NeoChatRoom *room, MessageContentModel *parent, const QString &eventId)
MessageContentModel::MessageContentModel(NeoChatRoom *room, const QString &eventId, bool isReply, bool isPending, MessageContentModel *parent)
: QAbstractListModel(parent) : QAbstractListModel(parent)
, m_room(room) , m_room(room)
, m_eventId(eventId) , m_eventId(eventId)
, m_currentState(isPending ? Pending : Unknown)
, m_isReply(isReply)
{ {
initializeModel(); initializeModel();
} }
@@ -51,43 +23,18 @@ MessageContentModel::MessageContentModel(NeoChatRoom *room, const QString &event
void MessageContentModel::initializeModel() void MessageContentModel::initializeModel()
{ {
Q_ASSERT(m_room != nullptr); Q_ASSERT(m_room != nullptr);
Q_ASSERT(!m_eventId.isEmpty());
connect(m_room, &NeoChatRoom::pendingEventAdded, this, [this]() { connect(this, &MessageContentModel::componentsUpdated, this, [this]() {
if (m_room != nullptr && m_currentState == Unknown) { if (m_room->urlPreviewEnabled()) {
initializeEvent(); forEachComponentOfType({MessageComponentType::Text, MessageComponentType::Quote}, m_linkPreviewAddFunction);
resetModel(); } else {
forEachComponentOfType({MessageComponentType::LinkPreview, MessageComponentType::LinkPreviewLoad}, m_linkPreviewRemoveFunction);
} }
m_components.squeeze();
}); });
connect(m_room, &NeoChatRoom::pendingEventAboutToMerge, this, [this](Quotient::RoomEvent *serverEvent) { connect(this, &MessageContentModel::itineraryUpdated, this, [this]() {
if (m_room != nullptr) { if (hasComponentType(MessageComponentType::File)) {
if (m_eventId == serverEvent->id() || m_eventId == serverEvent->transactionId()) { forEachComponentOfType(MessageComponentType::File, m_fileFunction);
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(m_room, &NeoChatRoom::newFileTransfer, this, [this](const QString &eventId) { 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) { connect(m_room, &NeoChatRoom::urlPreviewEnabledChanged, this, &MessageContentModel::componentsUpdated);
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();
} }
void MessageContentModel::initializeEvent() QString MessageContentModel::eventId() const
{ {
if (m_currentState == UnAvailable) { return m_eventId;
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 MessageContentModel::getEvent() QDateTime MessageContentModel::time() const
{ {
Quotient::connectUntil(m_room.get(), &NeoChatRoom::extraEventLoaded, this, [this](const QString &eventId) { return QDateTime::currentDateTime();
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);
} }
QString MessageContentModel::senderId() const QString MessageContentModel::timeString() const
{ {
const auto eventResult = m_room->getEvent(m_eventId); return time().toLocalTime().toString(u"hh:mm"_s);
if (eventResult.first == nullptr) { ;
return {};
}
auto senderId = eventResult.first->senderId();
if (senderId.isEmpty()) {
senderId = m_room->localMember().id();
}
return senderId;
} }
NeochatRoomMember *MessageContentModel::senderObject() const QString MessageContentModel::authorId() const
{ {
const auto eventResult = m_room->getEvent(m_eventId); return m_room->localMember().id();
if (eventResult.first == nullptr) { }
return nullptr;
} NeochatRoomMember *MessageContentModel::author() const
if (eventResult.first->senderId().isEmpty()) { {
return m_room->qmlSafeMember(m_room->localMember().id()); return m_room->qmlSafeMember(authorId());
} }
return m_room->qmlSafeMember(eventResult.first->senderId());
QString MessageContentModel::threadRootId() const
{
return {};
} }
static LinkPreviewer *emptyLinkPreview = new LinkPreviewer; 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 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 (role == DisplayRole) {
if (m_currentState == UnAvailable || m_room->connection()->isIgnored(senderId())) { return component.display;
Kirigami::Platform::PlatformTheme *theme =
static_cast<Kirigami::Platform::PlatformTheme *>(qmlAttachedPropertiesObject<Kirigami::Platform::PlatformTheme>(this, true));
QString disabledTextColor;
if (theme != nullptr) {
disabledTextColor = theme->disabledTextColor().name();
} else {
disabledTextColor = u"#000000"_s;
}
return QString(u"<span style=\"color:%1\">"_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"</span>"_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);
} }
if (role == ComponentTypeRole) { if (role == ComponentTypeRole) {
return component.type; return component.type;
@@ -297,63 +126,34 @@ QVariant MessageContentModel::data(const QModelIndex &index, int role) const
return component.attributes; return component.attributes;
} }
if (role == EventIdRole) { if (role == EventIdRole) {
return event.first->displayId(); return eventId();
} }
if (role == TimeRole) { if (role == TimeRole) {
return EventHandler::time(m_room, event.first, m_currentState == Pending); return time();
} }
if (role == TimeStringRole) { if (role == TimeStringRole) {
return EventHandler::timeString(m_room, event.first, u"hh:mm"_s, m_currentState == Pending); return timeString();
} }
if (role == AuthorRole) { if (role == AuthorRole) {
return QVariant::fromValue<NeochatRoomMember *>(senderObject()); return QVariant::fromValue<NeochatRoomMember *>(author());
}
if (role == MediaInfoRole) {
return EventHandler::mediaInfo(m_room, event.first);
} }
if (role == FileTransferInfoRole) { if (role == FileTransferInfoRole) {
return QVariant::fromValue(m_room->cachedFileTransferInfo(event.first)); return QVariant::fromValue(m_room->cachedFileTransferInfo(m_eventId));
} }
if (role == ItineraryModelRole) { if (role == ItineraryModelRole) {
return QVariant::fromValue<ItineraryModel *>(m_itineraryModel); return QVariant::fromValue<ItineraryModel *>(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) { if (role == PollHandlerRole) {
return QVariant::fromValue<PollHandler *>(ContentProvider::self().handlerForPoll(m_room, m_eventId)); return QVariant::fromValue<PollHandler *>(ContentProvider::self().handlerForPoll(m_room, m_eventId));
} }
if (role == ReplyEventIdRole) {
if (const auto roomMessageEvent = eventCast<const RoomMessageEvent>(event.first)) {
return roomMessageEvent->replyEventId();
}
}
if (role == ReplyAuthorRole) {
return QVariant::fromValue(EventHandler::replyAuthor(m_room, event.first));
}
if (role == ReplyContentModelRole) { if (role == ReplyContentModelRole) {
return QVariant::fromValue<MessageContentModel *>(m_replyModel); return QVariant::fromValue<MessageContentModel *>(m_replyModel);
} }
if (role == ReactionModelRole) { if (role == ReactionModelRole) {
return QVariant::fromValue<ReactionModel *>(m_reactionModel); return QVariant::fromValue<ReactionModel *>(m_reactionModel);
;
} }
if (role == ThreadRootRole) { if (role == ThreadRootRole) {
auto roomMessageEvent = eventCast<const RoomMessageEvent>(event.first); return threadRootId();
#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 {};
} }
if (role == LinkPreviewerRole) { if (role == LinkPreviewerRole) {
if (component.type == MessageComponentType::LinkPreview) { if (component.type == MessageComponentType::LinkPreview) {
@@ -394,15 +194,9 @@ QHash<int, QByteArray> MessageContentModel::roleNamesStatic()
roles[MessageContentModel::TimeRole] = "time"; roles[MessageContentModel::TimeRole] = "time";
roles[MessageContentModel::TimeStringRole] = "timeString"; roles[MessageContentModel::TimeStringRole] = "timeString";
roles[MessageContentModel::AuthorRole] = "author"; roles[MessageContentModel::AuthorRole] = "author";
roles[MessageContentModel::MediaInfoRole] = "mediaInfo";
roles[MessageContentModel::FileTransferInfoRole] = "fileTransferInfo"; roles[MessageContentModel::FileTransferInfoRole] = "fileTransferInfo";
roles[MessageContentModel::ItineraryModelRole] = "itineraryModel"; roles[MessageContentModel::ItineraryModelRole] = "itineraryModel";
roles[MessageContentModel::LatitudeRole] = "latitude";
roles[MessageContentModel::LongitudeRole] = "longitude";
roles[MessageContentModel::AssetRole] = "asset";
roles[MessageContentModel::PollHandlerRole] = "pollHandler"; roles[MessageContentModel::PollHandlerRole] = "pollHandler";
roles[MessageContentModel::ReplyEventIdRole] = "replyEventId";
roles[MessageContentModel::ReplyAuthorRole] = "replyAuthor";
roles[MessageContentModel::ReplyContentModelRole] = "replyContentModel"; roles[MessageContentModel::ReplyContentModelRole] = "replyContentModel";
roles[MessageContentModel::ReactionModelRole] = "reactionModel"; roles[MessageContentModel::ReactionModelRole] = "reactionModel";
roles[MessageContentModel::ThreadRootRole] = "threadRoot"; roles[MessageContentModel::ThreadRootRole] = "threadRoot";
@@ -443,256 +237,6 @@ void MessageContentModel::forEachComponentOfType(QList<MessageComponentType::Typ
} }
} }
void MessageContentModel::resetModel()
{
beginResetModel();
m_components.clear();
if (m_room->connection()->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<MessageComponent> MessageContentModel::messageContentComponents(bool isEditing, bool isThreading)
{
const auto event = m_room->getEvent(m_eventId);
if (event.first == nullptr) {
return {};
}
QList<MessageComponent> newComponents;
if (isEditing) {
newComponents += MessageComponent{MessageComponentType::ChatBar, QString(), {}};
} else {
newComponents.append(componentsForType(MessageComponentType::typeForEvent(*event.first, m_isReply)));
}
const auto roomMessageEvent = eventCast<const Quotient::RoomMessageEvent>(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<const Quotient::RoomMessageEvent>(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<MessageComponent> 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<const Quotient::RoomMessageEvent>(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<const Quotient::RoomMessageEvent>(event.first);
return TextHandler().textComponents(EventHandler::rawMessageBody(*roomMessageEvent),
EventHandler::messageBodyInputFormat(*roomMessageEvent),
m_room,
roomMessageEvent,
roomMessageEvent->isReplaced());
}
case MessageComponentType::File: {
QList<MessageComponent> components;
components += MessageComponent{MessageComponentType::File, QString(), {}};
const auto roomMessageEvent = eventCast<const Quotient::RoomMessageEvent>(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<EventContent::FileContent>());
const QMimeType mimeType = roomMessageEvent->get<EventContent::FileContent>()->mimeType;
if (mimeType.name() == u"text/plain"_s || mimeType.parentMimeTypes().contains(u"text/plain"_s)) {
QString originalName = roomMessageEvent->get<EventContent::FileContent>()->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<StickerEvent>()) {
const auto roomMessageEvent = eventCast<const Quotient::RoomMessageEvent>(event.first);
const auto fileContent = roomMessageEvent->get<EventContent::FileContentBase>();
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<MessageComponent> 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) MessageComponent MessageContentModel::linkPreviewComponent(const QUrl &link)
{ {
const auto linkPreviewer = dynamic_cast<NeoChatConnection *>(m_room->connection())->previewerForLink(link); const auto linkPreviewer = dynamic_cast<NeoChatConnection *>(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<const Quotient::RoomMessageEvent>(event.first)) {
if (roomMessageEvent->has<EventContent::FileContent>()) {
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" #include "moc_messagecontentmodel.cpp"

View File

@@ -5,22 +5,29 @@
#include <QAbstractListModel> #include <QAbstractListModel>
#include <QQmlEngine> #include <QQmlEngine>
#include <QImageReader>
#include <Quotient/events/roomevent.h> #ifndef Q_OS_ANDROID
#include <KSyntaxHighlighting/Definition>
#include <KSyntaxHighlighting/Repository>
#endif
#include "enums/messagecomponenttype.h" #include "enums/messagecomponenttype.h"
#include "filetype.h"
#include "linkpreviewer.h" #include "linkpreviewer.h"
#include "messagecomponent.h" #include "messagecomponent.h"
#include "models/itinerarymodel.h" #include "models/itinerarymodel.h"
#include "models/reactionmodel.h" #include "models/reactionmodel.h"
#include "neochatroom.h"
#include "neochatroommember.h" #include "neochatroommember.h"
class ThreadModel;
/** /**
* @class MessageContentModel * @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 class MessageContentModel : public QAbstractListModel
{ {
@@ -28,15 +35,9 @@ class MessageContentModel : public QAbstractListModel
QML_ELEMENT QML_ELEMENT
QML_UNCREATABLE("") QML_UNCREATABLE("")
public: Q_PROPERTY(NeochatRoomMember *author READ author NOTIFY authorChanged)
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)
public:
/** /**
* @brief Defines the model roles. * @brief Defines the model roles.
*/ */
@@ -48,32 +49,18 @@ public:
TimeRole, /**< The timestamp for when the event was sent (as a QDateTime). */ 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). */ TimeStringRole, /**< The timestamp for when the event was sent as a string (in QLocale::ShortFormat). */
AuthorRole, /**< The author of the event. */ AuthorRole, /**< The author of the event. */
MediaInfoRole, /**< The media info for the event. */
FileTransferInfoRole, /**< FileTransferInfo for any downloading files. */ FileTransferInfoRole, /**< FileTransferInfo for any downloading files. */
ItineraryModelRole, /**< The itinerary model for a file. */ 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. */ 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. */ ReplyContentModelRole, /**< The MessageContentModel for the reply event. */
ReactionModelRole, /**< Reaction model for this event. */ ReactionModelRole, /**< Reaction model for this event. */
ThreadRootRole, /**< The thread root event ID for the event. */ ThreadRootRole, /**< The thread root event ID for the event. */
LinkPreviewerRole, /**< The link preview details. */ LinkPreviewerRole, /**< The link preview details. */
ChatBarCacheRole, /**< The ChatBarCache to use. */ ChatBarCacheRole, /**< The ChatBarCache to use. */
}; };
Q_ENUM(Roles) Q_ENUM(Roles)
explicit MessageContentModel(NeoChatRoom *room, explicit MessageContentModel(NeoChatRoom *room, MessageContentModel *parent = nullptr, const QString &eventId = {});
const QString &eventId,
bool isReply = false,
bool isPending = false,
MessageContentModel *parent = nullptr);
/** /**
* @brief Get the given role value at the given index. * @brief Get the given role value at the given index.
@@ -95,9 +82,18 @@ public:
* @sa Roles, QAbstractItemModel::roleNames() * @sa Roles, QAbstractItemModel::roleNames()
*/ */
[[nodiscard]] QHash<int, QByteArray> roleNames() const override; [[nodiscard]] QHash<int, QByteArray> roleNames() const override;
static QHash<int, QByteArray> roleNamesStatic(); static QHash<int, QByteArray> 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. * @brief Close the link preview at the given index.
* *
@@ -105,34 +101,50 @@ public:
*/ */
Q_INVOKABLE void closeLinkPreview(int row); 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: Q_SIGNALS:
void showAuthorChanged(); void authorChanged();
void eventUpdated();
void threadsEnabledChanged(); /**
* @brief Emit whenever new components are added.
*/
void componentsUpdated();
private: /**
* @brief Emit whenever itinerary model is updated.
*/
void itineraryUpdated();
protected:
QPointer<NeoChatRoom> m_room; QPointer<NeoChatRoom> m_room;
QString m_eventId; 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(); * @brief Time for the message as a string in the from "hh:mm".
void getEvent(); *
* 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<MessageComponent>::iterator; using ComponentIt = QList<MessageComponent>::iterator;
@@ -141,14 +153,61 @@ private:
void forEachComponentOfType(MessageComponentType::Type type, std::function<ComponentIt(ComponentIt)> function); void forEachComponentOfType(MessageComponentType::Type type, std::function<ComponentIt(ComponentIt)> function);
void forEachComponentOfType(QList<MessageComponentType::Type> types, std::function<ComponentIt(ComponentIt)> function); void forEachComponentOfType(QList<MessageComponentType::Type> types, std::function<ComponentIt(ComponentIt)> function);
QPointer<MessageContentModel> m_replyModel;
QPointer<ReactionModel> m_reactionModel = nullptr;
QPointer<ItineraryModel> m_itineraryModel = nullptr;
bool m_emptyItinerary = false;
private:
void initializeModel();
std::function<ComponentIt(const ComponentIt &)> m_fileInfoFunction = [this](ComponentIt it) { std::function<ComponentIt(const ComponentIt &)> m_fileInfoFunction = [this](ComponentIt it) {
Q_EMIT dataChanged(index(it - m_components.begin()), index(it - m_components.begin()), {MessageContentModel::FileTransferInfoRole}); Q_EMIT dataChanged(index(it - m_components.begin()), index(it - m_components.begin()), {MessageContentModel::FileTransferInfoRole});
return ++it; return ++it;
}; };
std::function<ComponentIt(const ComponentIt &)> m_linkPreviewFunction = [this](ComponentIt it) { std::function<ComponentIt(const ComponentIt &)> 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<ComponentIt(const ComponentIt &)> m_linkPreviewAddFunction = [this](ComponentIt it) {
if (!m_room->urlPreviewEnabled()) {
return it;
}
bool previewAdded = false; bool previewAdded = false;
if (LinkPreviewer::hasPreviewableLinks(it->content)) { if (LinkPreviewer::hasPreviewableLinks(it->display)) {
const auto links = LinkPreviewer::linkPreviews(it->content); const auto links = LinkPreviewer::linkPreviews(it->display);
for (qsizetype j = 0; j < links.size(); ++j) { for (qsizetype j = 0; j < links.size(); ++j) {
const auto linkPreview = linkPreviewComponent(links[j]); const auto linkPreview = linkPreviewComponent(links[j]);
if (!m_removedLinkPreviews.contains(links[j]) && !linkPreview.isEmpty()) { if (!m_removedLinkPreviews.contains(links[j]) && !linkPreview.isEmpty()) {
@@ -161,26 +220,16 @@ private:
} }
return previewAdded ? it : ++it; return previewAdded ? it : ++it;
}; };
std::function<ComponentIt(const ComponentIt &)> m_linkPreviewRemoveFunction = [this](ComponentIt it) {
void resetModel(); if (m_room->urlPreviewEnabled()) {
void resetContent(bool isEditing = false, bool isThreading = false); return it;
QList<MessageComponent> messageContentComponents(bool isEditing = false, bool isThreading = false); }
beginRemoveRows({}, std::distance(m_components.begin(), it), std::distance(m_components.begin(), it));
QPointer<MessageContentModel> m_replyModel; it = m_components.erase(it);
void updateReplyModel(); endRemoveRows();
return it;
ReactionModel *m_reactionModel = nullptr; };
ItineraryModel *m_itineraryModel = nullptr;
QList<MessageComponent> componentsForType(MessageComponentType::Type type);
MessageComponent linkPreviewComponent(const QUrl &link);
QList<QUrl> m_removedLinkPreviews; QList<QUrl> m_removedLinkPreviews;
MessageComponent linkPreviewComponent(const QUrl &link);
void updateItineraryModel();
bool m_emptyItinerary = false;
void updateReactionModel();
static bool m_threadsEnabled;
}; };

View File

@@ -20,6 +20,7 @@
#include "eventhandler.h" #include "eventhandler.h"
#include "events/pollevent.h" #include "events/pollevent.h"
#include "models/reactionmodel.h" #include "models/reactionmodel.h"
#include "models/eventmessagecontentmodel.h"
#include "neochatroommember.h" #include "neochatroommember.h"
using namespace Quotient; using namespace Quotient;
@@ -42,7 +43,7 @@ MessageModel::MessageModel(QObject *parent)
}); });
connect(this, &MessageModel::threadsEnabledChanged, this, [this]() { 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 (role == ContentModelRole) {
if (event->get().is<EncryptedEvent>() || event->get().is<PollStartEvent>() || event->get().is<StickerEvent>()) { if (event->get().is<EncryptedEvent>() || event->get().is<PollStartEvent>() || event->get().is<StickerEvent>()) {
return QVariant::fromValue<MessageContentModel *>(ContentProvider::self().contentModelForEvent(m_room, event->get().id())); return QVariant::fromValue<EventMessageContentModel *>(ContentProvider::self().contentModelForEvent(m_room, event->get().id()));
} }
auto roomMessageEvent = eventCast<const RoomMessageEvent>(&event.value().get()); auto roomMessageEvent = eventCast<const RoomMessageEvent>(&event.value().get());
if (m_threadsEnabled && roomMessageEvent && roomMessageEvent->isThreaded()) { if (m_threadsEnabled && roomMessageEvent && roomMessageEvent->isThreaded()) {
return QVariant::fromValue<MessageContentModel *>(ContentProvider::self().contentModelForEvent(m_room, roomMessageEvent->threadRootEventId())); return QVariant::fromValue<EventMessageContentModel *>(
ContentProvider::self().contentModelForEvent(m_room, roomMessageEvent->threadRootEventId()));
} }
return QVariant::fromValue<MessageContentModel *>(ContentProvider::self().contentModelForEvent(m_room, &event->get())); return QVariant::fromValue<EventMessageContentModel *>(ContentProvider::self().contentModelForEvent(m_room, &event->get()));
} }
if (role == GenericDisplayRole) { if (role == GenericDisplayRole) {