diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt index 76c5582d5..74a041508 100644 --- a/autotests/CMakeLists.txt +++ b/autotests/CMakeLists.txt @@ -26,3 +26,9 @@ ecm_add_test( LINK_LIBRARIES neochat Qt::Test TEST_NAME mediasizehelpertest ) + +ecm_add_test( + eventhandlertest.cpp + LINK_LIBRARIES neochat Qt::Test + TEST_NAME eventhandlertest +) diff --git a/autotests/eventhandlertest.cpp b/autotests/eventhandlertest.cpp new file mode 100644 index 000000000..eda47b4c3 --- /dev/null +++ b/autotests/eventhandlertest.cpp @@ -0,0 +1,729 @@ +// SPDX-FileCopyrightText: 2023 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#include +#include + +#include "eventhandler.h" + +#include + +#include +#include +#include + +#include "enums/delegatetype.h" +#include "linkpreviewer.h" +#include "models/reactionmodel.h" +#include "neochatroom.h" +#include "utils.h" + +using namespace Quotient; + +class TestRoom : public NeoChatRoom +{ +public: + using NeoChatRoom::NeoChatRoom; + + void update(SyncRoomData &&data, bool fromCache = false) + { + Room::updateData(std::move(data), fromCache); + } +}; + +class EventHandlerTest : public QObject +{ + Q_OBJECT + +private: + Connection *connection = nullptr; + TestRoom *room = nullptr; + EventHandler eventHandler; + +private Q_SLOTS: + void initTestCase(); + + void eventId(); + void delegateType_data(); + void delegateType(); + void author(); + void authorDisplayName(); + void time(); + void timeString(); + void highlighted(); + void hidden(); + void body(); + void genericBody_data(); + void genericBody(); + void mediaInfo(); + void linkPreviewer(); + void reactions(); + void hasReply(); + void replyId(); + void replyDelegateType(); + void replyAuthor(); + void replyBody(); + void replyMediaInfo(); + void location(); + void readMarkers(); +}; + +void EventHandlerTest::initTestCase() +{ + connection = Connection::makeMockConnection(QStringLiteral("@bob:kde.org")); + room = new TestRoom(connection, QStringLiteral("#myroom:kde.org"), JoinState::Join); + + const auto json = QJsonDocument::fromJson(R"EVENT({ + "account_data": { + "events": [ + { + "content": { + "tags": { + "u.work": { + "order": 0.9 + } + } + }, + "type": "m.tag" + }, + { + "content": { + "custom_config_key": "custom_config_value" + }, + "type": "org.example.custom.room.config" + } + ] + }, + "ephemeral": { + "events": [ + { + "content": { + "user_ids": [ + "@alice:matrix.org", + "@bob:example.com" + ] + }, + "room_id": "!jEsUZKDJdhlrceRyVU:example.org", + "type": "m.typing" + }, + { + "content": { + "$153456789:example.org": { + "m.read": { + "@alice:matrix.org": { + "ts": 1436451550453 + } + } + } + }, + "type": "m.receipt" + }, + { + "content": { + "$1532735824654:example.org": { + "m.read": { + "@bob:example.com": { + "ts": 1436451550453 + } + } + } + }, + "type": "m.receipt" + }, + { + "content": { + "$1532735824654:example.org": { + "m.read": { + "@tim:example.com": { + "ts": 1436451550454 + } + } + } + }, + "type": "m.receipt" + }, + { + "content": { + "$1532735824654:example.org": { + "m.read": { + "@jeff:example.com": { + "ts": 1436451550455 + } + } + } + }, + "type": "m.receipt" + }, + { + "content": { + "$1532735824654:example.org": { + "m.read": { + "@tina:example.com": { + "ts": 1436451550456 + } + } + } + }, + "type": "m.receipt" + }, + { + "content": { + "$1532735824654:example.org": { + "m.read": { + "@sally:example.com": { + "ts": 1436451550457 + } + } + } + }, + "type": "m.receipt" + }, + { + "content": { + "$1532735824654:example.org": { + "m.read": { + "@fred:example.com": { + "ts": 1436451550458 + } + } + } + }, + "type": "m.receipt" + } + ] + }, + "state": { + "events": [ + { + "content": { + "avatar_url": "mxc://example.org/SEsfnsuifSDFSSEF", + "displayname": "Alice Margatroid", + "membership": "join", + "reason": "Looking for support" + }, + "event_id": "$143273582443PhrSn:example.org", + "origin_server_ts": 1432735824653, + "room_id": "!jEsUZKDJdhlrceRyVU:example.org", + "sender": "@example:example.org", + "state_key": "@alice:example.org", + "type": "m.room.member", + "unsigned": { + "age": 1234 + } + } + ] + }, + "summary": { + "m.heroes": [ + "@alice:example.com", + "@bob:example.com" + ], + "m.invited_member_count": 0, + "m.joined_member_count": 2 + }, + "timeline": { + "events": [ + { + "content": { + "body": "This is an example\ntext message", + "format": "org.matrix.custom.html", + "formatted_body": "This is an example
text message
", + "msgtype": "m.text" + }, + "event_id": "$153456789:example.org", + "origin_server_ts": 1432735824654, + "room_id": "!jEsUZKDJdhlrceRyVU:example.org", + "sender": "@example:example.org", + "type": "m.room.message", + "unsigned": { + "age": 1232 + } + }, + { + "content": { + "avatar_url": "mxc://kde.org/123456", + "displayname": "after", + "membership": "join" + }, + "origin_server_ts": 1690651134736, + "sender": "@example:example.org", + "state_key": "@example:example.org", + "type": "m.room.member", + "unsigned": { + "replaces_state": "$1234567890:example.org", + "prev_content": { + "avatar_url": "mxc://kde.org/12345", + "displayname": "before", + "membership": "join" + }, + "prev_sender": "@example:example.orgg", + "age": 1234 + }, + "event_id": "$143273583553PhrSn:example.org", + "room_id": "!jEsUZKDJdhlrceRyVU:example.org" + }, + { + "content": { + "body": "This is a highlight @bob:kde.org and this is a link https://kde.org", + "format": "org.matrix.custom.html", + "msgtype": "m.text" + }, + "event_id": "$1532735824654:example.org", + "origin_server_ts": 1532735824654, + "room_id": "!jEsUZKDJdhlrceRyVU:example.org", + "sender": "@example:example.org", + "type": "m.room.message", + "unsigned": { + "age": 1233 + } + }, + { + "content": { + "m.relates_to": { + "event_id": "$153456789:example.org", + "key": "👍", + "rel_type": "m.annotation" + } + }, + "origin_server_ts": 1690322545182, + "room_id": "!jEsUZKDJdhlrceRyVU:example.org", + "sender": "@alice:matrix.org", + "type": "m.reaction", + "unsigned": { + "age": 390159120 + }, + "event_id": "$163456789:example.org", + "age": 390159120 + }, + { + "age": 4926305285, + "content": { + "body": "video caption", + "filename": "video.mp4", + "info": { + "duration": 10, + "h": 1080, + "mimetype": "video/mp4", + "size": 62650636, + "w": 1920, + "thumbnail_info": { + "h": 450, + "mimetype": "image/jpeg", + "size": 382249, + "w": 800 + }, + "thumbnail_url": "mxc://kde.org/2234567" + }, + "msgtype": "m.video", + "url": "mxc://kde.org/1234567" + }, + "event_id": "$263456789:example.org", + "origin_server_ts": 1685793783330, + "room_id": "!jEsUZKDJdhlrceRyVU:example.org", + "sender": "@example:example.org", + "type": "m.room.message", + "unsigned": { + "age": 4926305285 + }, + "user_id": "@example:example.org" + }, + { + "content": { + "body": "> <@example:example.org> This is an example\ntext message\n\nreply", + "format": "org.matrix.custom.html", + "formatted_body": "
In reply to @example:example.org
This is an example
text message
reply", + "m.relates_to": { + "m.in_reply_to": { + "event_id": "$153456789:example.org" + } + }, + "msgtype": "m.text" + }, + "origin_server_ts": 1690725965572, + "sender": "@alice:matrix.org", + "type": "m.room.message", + "unsigned": { + "age": 98 + }, + "event_id": "$154456789:example.org", + "room_id": "!jEsUZKDJdhlrceRyVU:example.org" + }, + { + "content": { + "body": "> <@example:example.org> video caption\n\nreply", + "m.relates_to": { + "m.in_reply_to": { + "event_id": "$263456789:example.org" + } + }, + "msgtype": "m.text" + }, + "origin_server_ts": 1690725965573, + "sender": "@alice:matrix.org", + "type": "m.room.message", + "unsigned": { + "age": 98 + }, + "event_id": "$154456799:example.org", + "room_id": "!jEsUZKDJdhlrceRyVU:example.org" + }, + { + "age": 96845207, + "content": { + "body": "Lat: 51.7035, Lon: -1.14394", + "geo_uri": "geo:51.7035,-1.14394", + "msgtype": "m.location", + "org.matrix.msc1767.text": "Lat: 51.7035, Lon: -1.14394", + "org.matrix.msc3488.asset": { + "type": "m.pin" + }, + "org.matrix.msc3488.location": { + "uri": "geo:51.7035,-1.14394" + } + }, + "event_id": "$1544567999:example.org", + "origin_server_ts": 1690821582876, + "room_id": "!jEsUZKDJdhlrceRyVU:example.org", + "sender": "@example:example.org", + "type": "m.room.message", + "unsigned": { + "age": 96845207 + } + } + ], + "limited": true, + "prev_batch": "t34-23535_0_0" + } +})EVENT"); + SyncRoomData roomData(QStringLiteral("@bob:kde.org"), JoinState::Join, json.object()); + room->update(std::move(roomData)); + + eventHandler.setRoom(room); +} + +void EventHandlerTest::eventId() +{ + eventHandler.setEvent(room->messageEvents().at(0).get()); + + QCOMPARE(eventHandler.getId(), QStringLiteral("$153456789:example.org")); +} + +void EventHandlerTest::delegateType_data() +{ + QTest::addColumn("eventNum"); + QTest::addColumn("delegateType"); + + QTest::newRow("message") << 0 << DelegateType::Message; + QTest::newRow("state") << 1 << DelegateType::State; + QTest::newRow("message 2") << 2 << DelegateType::Message; + QTest::newRow("reaction") << 3 << DelegateType::Other; + QTest::newRow("video") << 4 << DelegateType::Video; + QTest::newRow("location") << 7 << DelegateType::Location; +} + +void EventHandlerTest::delegateType() +{ + QFETCH(int, eventNum); + QFETCH(DelegateType::Type, delegateType); + + eventHandler.setEvent(room->messageEvents().at(eventNum).get()); + + QCOMPARE(eventHandler.getDelegateType(), delegateType); +} + +void EventHandlerTest::author() +{ + auto event = room->messageEvents().at(0).get(); + auto author = room->user(event->senderId()); + eventHandler.setEvent(event); + + auto eventHandlerAuthor = eventHandler.getAuthor(); + + QCOMPARE(eventHandlerAuthor["isLocalUser"_ls], author->id() == room->localUser()->id()); + QCOMPARE(eventHandlerAuthor["id"_ls], author->id()); + QCOMPARE(eventHandlerAuthor["displayName"_ls], author->displayname(room)); + QCOMPARE(eventHandlerAuthor["avatarSource"_ls], room->avatarForMember(author)); + QCOMPARE(eventHandlerAuthor["avatarMediaId"_ls], author->avatarMediaId(room)); + QCOMPARE(eventHandlerAuthor["color"_ls], Utils::getUserColor(author->hueF())); + QCOMPARE(eventHandlerAuthor["object"_ls], QVariant::fromValue(author)); +} + +void EventHandlerTest::authorDisplayName() +{ + auto event = room->messageEvents().at(1).get(); + eventHandler.setEvent(event); + + QCOMPARE(eventHandler.getAuthorDisplayName(), QStringLiteral("before")); +} + +void EventHandlerTest::time() +{ + auto event = room->messageEvents().at(0).get(); + eventHandler.setEvent(event); + + QCOMPARE(eventHandler.getTime(), QDateTime::fromMSecsSinceEpoch(1432735824654, Qt::UTC)); + QCOMPARE(eventHandler.getTime(true, QDateTime::fromMSecsSinceEpoch(1234, Qt::UTC)), QDateTime::fromMSecsSinceEpoch(1234, Qt::UTC)); +} + +void EventHandlerTest::timeString() +{ + auto event = room->messageEvents().at(0).get(); + eventHandler.setEvent(event); + + KFormat format; + + QCOMPARE(eventHandler.getTimeString(false), + QLocale().toString(QDateTime::fromMSecsSinceEpoch(1432735824654, Qt::UTC).toLocalTime().time(), QLocale::ShortFormat)); + QCOMPARE(eventHandler.getTimeString(true), + format.formatRelativeDate(QDateTime::fromMSecsSinceEpoch(1432735824654, Qt::UTC).toLocalTime().date(), QLocale::ShortFormat)); + QCOMPARE(eventHandler.getTimeString(false, QLocale::ShortFormat, true, QDateTime::fromMSecsSinceEpoch(1690699214545, Qt::UTC)), + QLocale().toString(QDateTime::fromMSecsSinceEpoch(1690699214545, Qt::UTC).toLocalTime().time(), QLocale::ShortFormat)); + QCOMPARE(eventHandler.getTimeString(true, QLocale::ShortFormat, true, QDateTime::fromMSecsSinceEpoch(1690699214545, Qt::UTC)), + format.formatRelativeDate(QDateTime::fromMSecsSinceEpoch(1690699214545, Qt::UTC).toLocalTime().date(), QLocale::ShortFormat)); + QCOMPARE(eventHandler.getTimeString(false, QLocale::LongFormat, true, QDateTime::fromMSecsSinceEpoch(1690699214545, Qt::UTC)), + QLocale().toString(QDateTime::fromMSecsSinceEpoch(1690699214545, Qt::UTC).toLocalTime().time(), QLocale::LongFormat)); + QCOMPARE(eventHandler.getTimeString(true, QLocale::LongFormat, true, QDateTime::fromMSecsSinceEpoch(1690699214545, Qt::UTC)), + format.formatRelativeDate(QDateTime::fromMSecsSinceEpoch(1690699214545, Qt::UTC).toLocalTime().date(), QLocale::LongFormat)); +} + +void EventHandlerTest::highlighted() +{ + auto event = room->messageEvents().at(2).get(); + eventHandler.setEvent(event); + + QCOMPARE(eventHandler.isHighlighted(), true); + + event = room->messageEvents().at(0).get(); + eventHandler.setEvent(event); + + QCOMPARE(eventHandler.isHighlighted(), false); +} + +void EventHandlerTest::hidden() +{ + auto event = room->messageEvents().at(3).get(); + eventHandler.setEvent(event); + + QCOMPARE(eventHandler.isHidden(), true); + + event = room->messageEvents().at(0).get(); + eventHandler.setEvent(event); + + QCOMPARE(eventHandler.isHidden(), false); +} + +void EventHandlerTest::body() +{ + auto event = room->messageEvents().at(0).get(); + eventHandler.setEvent(event); + + QCOMPARE(eventHandler.getRichBody(), QStringLiteral("This is an example
text message
")); + QCOMPARE(eventHandler.getRichBody(true), QStringLiteral("This is an example text message")); + QCOMPARE(eventHandler.getPlainBody(), QStringLiteral("This is an example\ntext message")); + QCOMPARE(eventHandler.getPlainBody(true), QStringLiteral("This is an example text message")); +} + +void EventHandlerTest::genericBody_data() +{ + QTest::addColumn("eventNum"); + QTest::addColumn("output"); + + QTest::newRow("message") << 0 << QStringLiteral("sent a message"); + QTest::newRow("member") << 1 << QStringLiteral("changed their display name and updated their avatar"); + QTest::newRow("message 2") << 2 << QStringLiteral("sent a message"); + QTest::newRow("reaction") << 3 << QStringLiteral("Unknown event"); + QTest::newRow("video") << 4 << QStringLiteral("sent a message"); +} + +void EventHandlerTest::genericBody() +{ + QFETCH(int, eventNum); + QFETCH(QString, output); + + eventHandler.setEvent(room->messageEvents().at(eventNum).get()); + + QCOMPARE(eventHandler.getGenericBody(), output); +} + +void EventHandlerTest::mediaInfo() +{ + auto event = room->messageEvents().at(4).get(); + eventHandler.setEvent(event); + + auto mediaInfo = eventHandler.getMediaInfo(); + auto thumbnailInfo = mediaInfo["tempInfo"_ls].toMap(); + + QCOMPARE(mediaInfo["source"_ls], room->makeMediaUrl(event->id(), QUrl("mxc://kde.org/1234567"_ls))); + QCOMPARE(mediaInfo["mimeType"_ls], QStringLiteral("video/mp4")); + QCOMPARE(mediaInfo["mimeIcon"_ls], QStringLiteral("video-mp4")); + QCOMPARE(mediaInfo["size"_ls], 62650636); + QCOMPARE(mediaInfo["duration"_ls], 10); + QCOMPARE(mediaInfo["width"_ls], 1920); + QCOMPARE(mediaInfo["height"_ls], 1080); + QCOMPARE(thumbnailInfo["source"_ls], room->makeMediaUrl(event->id(), QUrl("mxc://kde.org/2234567"_ls))); + QCOMPARE(thumbnailInfo["mimeType"_ls], QStringLiteral("image/jpeg")); + QCOMPARE(thumbnailInfo["mimeIcon"_ls], QStringLiteral("image-jpeg")); + QCOMPARE(thumbnailInfo["size"_ls], 382249); + QCOMPARE(thumbnailInfo["width"_ls], 800); + QCOMPARE(thumbnailInfo["height"_ls], 450); +} + +void EventHandlerTest::linkPreviewer() +{ + auto event = room->messageEvents().at(2).get(); + eventHandler.setEvent(event); + + QCOMPARE(eventHandler.getLinkPreviewer()->url(), QUrl("https://kde.org"_ls)); + + event = room->messageEvents().at(0).get(); + eventHandler.setEvent(event); + + QCOMPARE(eventHandler.getLinkPreviewer(), nullptr); +} + +void EventHandlerTest::reactions() +{ + auto event = room->messageEvents().at(0).get(); + eventHandler.setEvent(event); + + QCOMPARE(eventHandler.getReactions()->rowCount(), 1); +} + +void EventHandlerTest::hasReply() +{ + auto event = room->messageEvents().at(5).get(); + eventHandler.setEvent(event); + + QCOMPARE(eventHandler.hasReply(), true); + + event = room->messageEvents().at(0).get(); + eventHandler.setEvent(event); + + QCOMPARE(eventHandler.hasReply(), false); +} + +void EventHandlerTest::replyId() +{ + auto event = room->messageEvents().at(5).get(); + eventHandler.setEvent(event); + + QCOMPARE(eventHandler.getReplyId(), QStringLiteral("$153456789:example.org")); + + event = room->messageEvents().at(0).get(); + eventHandler.setEvent(event); + + QCOMPARE(eventHandler.getReplyId(), QStringLiteral("")); +} + +void EventHandlerTest::replyDelegateType() +{ + auto event = room->messageEvents().at(5).get(); + eventHandler.setEvent(event); + + QCOMPARE(eventHandler.getReplyDelegateType(), DelegateType::Message); + + event = room->messageEvents().at(0).get(); + eventHandler.setEvent(event); + + QCOMPARE(eventHandler.getReplyDelegateType(), DelegateType::Other); +} + +void EventHandlerTest::replyAuthor() +{ + auto event = room->messageEvents().at(5).get(); + auto replyEvent = room->messageEvents().at(0).get(); + auto replyAuthor = room->user(replyEvent->senderId()); + eventHandler.setEvent(event); + + auto eventHandlerReplyAuthor = eventHandler.getReplyAuthor(); + + QCOMPARE(eventHandlerReplyAuthor["isLocalUser"_ls], replyAuthor->id() == room->localUser()->id()); + QCOMPARE(eventHandlerReplyAuthor["id"_ls], replyAuthor->id()); + QCOMPARE(eventHandlerReplyAuthor["displayName"_ls], replyAuthor->displayname(room)); + QCOMPARE(eventHandlerReplyAuthor["avatarSource"_ls], room->avatarForMember(replyAuthor)); + QCOMPARE(eventHandlerReplyAuthor["avatarMediaId"_ls], replyAuthor->avatarMediaId(room)); + QCOMPARE(eventHandlerReplyAuthor["color"_ls], Utils::getUserColor(replyAuthor->hueF())); + QCOMPARE(eventHandlerReplyAuthor["object"_ls], QVariant::fromValue(replyAuthor)); + + event = room->messageEvents().at(0).get(); + eventHandler.setEvent(event); + + QCOMPARE(eventHandler.getReplyAuthor(), room->getUser(nullptr)); +} + +void EventHandlerTest::replyBody() +{ + auto event = room->messageEvents().at(5).get(); + eventHandler.setEvent(event); + + QCOMPARE(eventHandler.getReplyRichBody(), QStringLiteral("This is an example
text message
")); + QCOMPARE(eventHandler.getReplyRichBody(true), QStringLiteral("This is an example text message")); + QCOMPARE(eventHandler.getReplyPlainBody(), QStringLiteral("This is an example\ntext message")); + QCOMPARE(eventHandler.getReplyPlainBody(true), QStringLiteral("This is an example text message")); +} + +void EventHandlerTest::replyMediaInfo() +{ + auto event = room->messageEvents().at(6).get(); + auto replyEvent = room->messageEvents().at(4).get(); + eventHandler.setEvent(event); + + auto mediaInfo = eventHandler.getReplyMediaInfo(); + auto thumbnailInfo = mediaInfo["tempInfo"_ls].toMap(); + + QCOMPARE(mediaInfo["source"_ls], room->makeMediaUrl(replyEvent->id(), QUrl("mxc://kde.org/1234567"_ls))); + QCOMPARE(mediaInfo["mimeType"_ls], QStringLiteral("video/mp4")); + QCOMPARE(mediaInfo["mimeIcon"_ls], QStringLiteral("video-mp4")); + QCOMPARE(mediaInfo["size"_ls], 62650636); + QCOMPARE(mediaInfo["duration"_ls], 10); + QCOMPARE(mediaInfo["width"_ls], 1920); + QCOMPARE(mediaInfo["height"_ls], 1080); + QCOMPARE(thumbnailInfo["source"_ls], room->makeMediaUrl(replyEvent->id(), QUrl("mxc://kde.org/2234567"_ls))); + QCOMPARE(thumbnailInfo["mimeType"_ls], QStringLiteral("image/jpeg")); + QCOMPARE(thumbnailInfo["mimeIcon"_ls], QStringLiteral("image-jpeg")); + QCOMPARE(thumbnailInfo["size"_ls], 382249); + QCOMPARE(thumbnailInfo["width"_ls], 800); + QCOMPARE(thumbnailInfo["height"_ls], 450); +} + +void EventHandlerTest::location() +{ + auto event = room->messageEvents().at(7).get(); + eventHandler.setEvent(event); + + QCOMPARE(eventHandler.getLatitude(), QStringLiteral("51.7035").toFloat()); + QCOMPARE(eventHandler.getLongitude(), QStringLiteral("-1.14394").toFloat()); + QCOMPARE(eventHandler.getLocationAssetType(), QStringLiteral("m.pin")); +} + +void EventHandlerTest::readMarkers() +{ + auto event = room->messageEvents().at(0).get(); + eventHandler.setEvent(event); + + QCOMPARE(eventHandler.hasReadMarkers(), true); + + auto readMarkers = eventHandler.getReadMarkers(); + + QCOMPARE(readMarkers.size(), 1); + QCOMPARE(readMarkers[0].toMap()["id"_ls], QStringLiteral("@alice:matrix.org")); + + QCOMPARE(eventHandler.getNumberExcessReadMarkers(), QString()); + QCOMPARE(eventHandler.getReadMarkersString(), QStringLiteral("1 user: @alice:matrix.org")); + + event = room->messageEvents().at(2).get(); + eventHandler.setEvent(event); + + QCOMPARE(eventHandler.hasReadMarkers(), true); + + readMarkers = eventHandler.getReadMarkers(); + + QCOMPARE(readMarkers.size(), 5); + + QCOMPARE(eventHandler.getNumberExcessReadMarkers(), QStringLiteral("+ 1")); + // There are no guarantees on the order of the users it will be different every time so don't match the whole string. + QCOMPARE(eventHandler.getReadMarkersString().startsWith(QStringLiteral("6 users:")), true); +} + +QTEST_MAIN(EventHandlerTest) +#include "eventhandlertest.moc" diff --git a/autotests/texthandlertest.cpp b/autotests/texthandlertest.cpp index 86f066c02..d60d1f53d 100644 --- a/autotests/texthandlertest.cpp +++ b/autotests/texthandlertest.cpp @@ -363,6 +363,7 @@ void TextHandlerTest::receiveRichInPlainOut_data() QTest::newRow("ampersand") << QStringLiteral("a & b") << QStringLiteral("a & b"); QTest::newRow("quote") << QStringLiteral(""a and b"") << QStringLiteral("\"a and b\""); + QTest::newRow("new line") << QStringLiteral("new
line") << QStringLiteral("new\nline"); } void TextHandlerTest::receiveRichInPlainOut() diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 76259f187..1c1742d98 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -134,6 +134,8 @@ add_library(neochat STATIC jobs/neochatchangepasswordjob.h mediasizehelper.cpp mediasizehelper.h + eventhandler.cpp + enums/delegatetype.h ) ecm_qt_declare_logging_category(neochat @@ -145,6 +147,13 @@ ecm_qt_declare_logging_category(neochat EXPORT NEOCHAT ) +ecm_qt_declare_logging_category(neochat + HEADER "eventhandler_logging.h" + IDENTIFIER "EventHandling" + CATEGORY_NAME "org.kde.neochat.eventhandler" + DEFAULT_SEVERITY Info +) + add_executable(neochat-app main.cpp ${CMAKE_CURRENT_SOURCE_DIR}/res.generated.qrc diff --git a/src/enums/delegatetype.h b/src/enums/delegatetype.h new file mode 100644 index 000000000..a6b9a6498 --- /dev/null +++ b/src/enums/delegatetype.h @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: 2023 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#pragma once + +#include + +/** + * @class DelegateType + * + * This class is designed to define the DelegateType enumeration. + */ +class DelegateType +{ + Q_GADGET + +public: + /** + * @brief The type of delegate that is needed for the event. + * + * @note While similar this is not the matrix event or message type. This is + * to tell a QML ListView what delegate to show for each event. So while + * similar to the spec it is not the same. + */ + enum Type { + Emote, /**< A message that begins with /me. */ + Notice, /**< A notice event. */ + Image, /**< A message that is an image. */ + Audio, /**< A message that is an audio recording. */ + Video, /**< A message that is a video. */ + File, /**< A message that is a file. */ + Message, /**< A text message. */ + Sticker, /**< A message that is a sticker. */ + State, /**< A state event in the room. */ + Encrypted, /**< An encrypted message that cannot be decrypted. */ + ReadMarker, /**< The local user read marker. */ + Poll, /**< The initial event for a poll. */ + Location, /**< A location event. */ + LiveLocation, /**< The initial event of a shared live location (i.e., the place where this is supposed to be shown in the timeline). */ + Other, /**< Anything that cannot be classified as another type. */ + }; + Q_ENUM(Type); +}; diff --git a/src/eventhandler.cpp b/src/eventhandler.cpp new file mode 100644 index 000000000..af7af654d --- /dev/null +++ b/src/eventhandler.cpp @@ -0,0 +1,983 @@ +// SPDX-FileCopyrightText: 2023 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#include "eventhandler.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "eventhandler_logging.h" +#include "events/pollevent.h" +#include "linkpreviewer.h" +#include "models/reactionmodel.h" +#include "neochatconfig.h" +#include "neochatroom.h" +#include "texthandler.h" +#include "utils.h" + +using namespace Quotient; + +const NeoChatRoom *EventHandler::getRoom() const +{ + return m_room; +} + +void EventHandler::setRoom(const NeoChatRoom *room) +{ + if (room == m_room) { + return; + } + m_room = room; +} + +const Quotient::Event *EventHandler::getEvent() const +{ + return m_event; +} + +void EventHandler::setEvent(const Quotient::RoomEvent *event) +{ + if (event == m_event) { + return; + } + m_event = event; +} + +QString EventHandler::getId() const +{ + if (m_event == nullptr) { + qCWarning(EventHandling) << "getId called with m_event set to nullptr."; + return {}; + } + + return !m_event->id().isEmpty() ? m_event->id() : m_event->transactionId(); +} + +DelegateType::Type EventHandler::getDelegateTypeForEvent(const Quotient::RoomEvent *event) const +{ + if (auto e = eventCast(event)) { + switch (e->msgtype()) { + case MessageEventType::Emote: + return DelegateType::Emote; + case MessageEventType::Notice: + return DelegateType::Notice; + case MessageEventType::Image: + return DelegateType::Image; + case MessageEventType::Audio: + return DelegateType::Audio; + case MessageEventType::Video: + return DelegateType::Video; + case MessageEventType::Location: + return DelegateType::Location; + default: + break; + } + if (e->hasFileContent()) { + return DelegateType::File; + } + + return DelegateType::Message; + } + if (is(*event)) { + return DelegateType::Sticker; + } + if (event->isStateEvent()) { + if (event->matrixType() == QStringLiteral("org.matrix.msc3672.beacon_info")) { + return DelegateType::LiveLocation; + } + return DelegateType::State; + } + if (is(*event)) { + return DelegateType::Encrypted; + } + if (is(*event)) { + const auto pollEvent = eventCast(event); + if (pollEvent->isRedacted()) { + return DelegateType::Message; + } + return DelegateType::Poll; + } + + return DelegateType::Other; +} + +DelegateType::Type EventHandler::getDelegateType() const +{ + if (m_event == nullptr) { + qCWarning(EventHandling) << "getDelegateType called with m_event set to nullptr."; + return DelegateType::Other; + } + + return getDelegateTypeForEvent(m_event); +} + +QVariantMap EventHandler::getAuthor(bool isPending) const +{ + if (m_room == nullptr) { + qCWarning(EventHandling) << "getAuthor called with m_room set to nullptr."; + return {}; + } + // If we have a room we can return an empty user by handing nullptr to m_room->getUser. + if (m_event == nullptr) { + qCWarning(EventHandling) << "getAuthor called with m_event set to nullptr. Returning empty user."; + return m_room->getUser(nullptr); + } + + const auto author = isPending ? m_room->localUser() : m_room->user(m_event->senderId()); + return m_room->getUser(author); +} + +QString EventHandler::getAuthorDisplayName(bool isPending) const +{ + if (m_room == nullptr) { + qCWarning(EventHandling) << "getAuthorDisplayName called with m_room set to nullptr."; + return {}; + } + if (m_event == nullptr) { + qCWarning(EventHandling) << "getAuthorDisplayName called with m_event set to nullptr."; + return {}; + } + + if (is(*m_event) && !m_event->unsignedJson()[QStringLiteral("prev_content")][QStringLiteral("displayname")].isNull() + && m_event->stateKey() == m_event->senderId()) { + auto previousDisplayName = m_event->unsignedJson()[QStringLiteral("prev_content")][QStringLiteral("displayname")].toString().toHtmlEscaped(); + if (previousDisplayName.isEmpty()) { + previousDisplayName = m_event->senderId(); + } + return previousDisplayName; + } else { + const auto author = isPending ? m_room->localUser() : m_room->user(m_event->senderId()); + return m_room->htmlSafeMemberName(author->id()); + } +} + +QDateTime EventHandler::getTime(bool isPending, QDateTime lastUpdated) const +{ + if (m_event == nullptr) { + qCWarning(EventHandling) << "getTime called with m_event set to nullptr."; + return {}; + } + if (isPending && lastUpdated == QDateTime()) { + qCWarning(EventHandling) << "a value must be provided for lastUpdated for a pending event."; + return {}; + } + + return isPending ? lastUpdated : m_event->originTimestamp(); +} + +QString EventHandler::getTimeString(bool relative, QLocale::FormatType format, bool isPending, QDateTime lastUpdated) const +{ + if (m_event == nullptr) { + qCWarning(EventHandling) << "getTime called with m_event set to nullptr."; + return {}; + } + if (isPending && lastUpdated == QDateTime()) { + qCWarning(EventHandling) << "a value must be provided for lastUpdated for a pending event."; + return {}; + } + + auto ts = getTime(isPending, lastUpdated); + if (ts.isValid()) { + if (relative) { + return m_format.formatRelativeDate(ts.toLocalTime().date(), format); + } else { + return QLocale().toString(ts.toLocalTime().time(), format); + } + } + return {}; +} + +bool EventHandler::isHighlighted() +{ + if (m_room == nullptr) { + qCWarning(EventHandling) << "isHighlighted called with m_room set to nullptr."; + return false; + } + if (m_event == nullptr) { + qCWarning(EventHandling) << "isHighlighted called with m_event set to nullptr."; + return false; + } + + return !m_room->isDirectChat() && m_room->isEventHighlighted(m_event); +} + +bool EventHandler::isHidden() +{ + if (m_event->isStateEvent() && !NeoChatConfig::self()->showStateEvent()) { + return true; + } + + if (auto roomMemberEvent = eventCast(m_event)) { + if ((roomMemberEvent->isJoin() || roomMemberEvent->isLeave()) && !NeoChatConfig::self()->showLeaveJoinEvent()) { + return true; + } else if (roomMemberEvent->isRename() && !roomMemberEvent->isJoin() && !roomMemberEvent->isLeave() && !NeoChatConfig::self()->showRename()) { + return true; + } else if (roomMemberEvent->isAvatarUpdate() && !roomMemberEvent->isJoin() && !roomMemberEvent->isLeave() + && !NeoChatConfig::self()->showAvatarUpdate()) { + return true; + } + } + + if (m_event->isStateEvent() && eventCast(m_event)->repeatsState()) { + return true; + } + + // isReplacement? + if (auto e = eventCast(m_event)) { + if (!e->replacedEvent().isEmpty()) { + return true; + } + } + + if (is(*m_event) || is(*m_event)) { + return true; + } + + if (auto e = eventCast(m_event)) { + if (!e->replacedEvent().isEmpty() && e->replacedEvent() != e->id()) { + return true; + } + } + + if (m_room->connection()->isIgnored(m_room->user(m_event->senderId()))) { + return true; + } + + // hide ending live location beacons + if (m_event->isStateEvent() && m_event->matrixType() == "org.matrix.msc3672.beacon_info"_ls && !m_event->contentJson()["live"_ls].toBool()) { + return true; + } + + return false; +} + +QString EventHandler::getRichBody(bool stripNewlines) const +{ + if (m_event == nullptr) { + qCWarning(EventHandling) << "getRichBody called with m_event set to nullptr."; + return {}; + } + return getBody(m_event, Qt::RichText, stripNewlines); +} + +QString EventHandler::getPlainBody(bool stripNewlines) const +{ + if (m_event == nullptr) { + qCWarning(EventHandling) << "getPlainBody called with m_event set to nullptr."; + return {}; + } + return getBody(m_event, Qt::PlainText, stripNewlines); +} + +QString EventHandler::getBody(const Quotient::RoomEvent *event, Qt::TextFormat format, bool stripNewlines) const +{ + if (event->isRedacted()) { + auto reason = event->redactedBecause()->reason(); + return (reason.isEmpty()) ? i18n("[This message was deleted]") : i18n("[This message was deleted: %1]", reason); + } + + const bool prettyPrint = (format == Qt::RichText); + + return switchOnType( + *event, + [this, format, stripNewlines](const RoomMessageEvent &event) { + return getMessageBody(event, format, stripNewlines); + }, + [](const StickerEvent &e) { + return e.body(); + }, + [this, prettyPrint](const RoomMemberEvent &e) { + // FIXME: Rewind to the name that was at the time of this event + auto subjectName = m_room->htmlSafeMemberName(e.userId()); + if (e.membership() == Membership::Leave) { + if (e.prevContent() && e.prevContent()->displayName) { + subjectName = sanitized(*e.prevContent()->displayName).toHtmlEscaped(); + } + } + + if (prettyPrint) { + subjectName = QStringLiteral("%3") + .arg(e.userId(), Utils::getUserColor(m_room->user(e.userId())->hueF()).name(), subjectName); + } + + // The below code assumes senderName output in AuthorRole + switch (e.membership()) { + case Membership::Invite: + if (e.repeatsState()) { + auto text = i18n("reinvited %1 to the room", subjectName); + if (!e.reason().isEmpty()) { + text += i18nc("Optional reason for an invitation", ": %1") + e.reason().toHtmlEscaped(); + } + return text; + } + Q_FALLTHROUGH(); + case Membership::Join: { + QString text{}; + // Part 1: invites and joins + if (e.repeatsState()) { + text = i18n("joined the room (repeated)"); + } else if (e.changesMembership()) { + text = e.membership() == Membership::Invite ? i18n("invited %1 to the room", subjectName) : i18n("joined the room"); + } + if (!text.isEmpty()) { + if (!e.reason().isEmpty()) { + text += i18n(": %1", e.reason().toHtmlEscaped()); + } + return text; + } + // Part 2: profile changes of joined members + if (e.isRename()) { + if (!e.newDisplayName()) { + text = i18nc("their refers to a singular user", "cleared their display name"); + } else { + text = i18nc("their refers to a singular user", "changed their display name to %1", e.newDisplayName()->toHtmlEscaped()); + } + } + if (e.isAvatarUpdate()) { + if (!text.isEmpty()) { + text += i18n(" and "); + } + if (!e.newAvatarUrl()) { + text += i18nc("their refers to a singular user", "cleared their avatar"); + } else if (!e.prevContent()->avatarUrl) { + text += i18n("set an avatar"); + } else { + text += i18nc("their refers to a singular user", "updated their avatar"); + } + } + if (text.isEmpty()) { + text = i18nc(" changed nothing", "changed nothing"); + } + return text; + } + case Membership::Leave: + if (e.prevContent() && e.prevContent()->membership == Membership::Invite) { + return (e.senderId() != e.userId()) ? i18n("withdrew %1's invitation", subjectName) : i18n("rejected the invitation"); + } + + if (e.prevContent() && e.prevContent()->membership == Membership::Ban) { + return (e.senderId() != e.userId()) ? i18n("unbanned %1", subjectName) : i18n("self-unbanned"); + } + return (e.senderId() != e.userId()) + ? i18n("has put %1 out of the room: %2", subjectName, e.contentJson()["reason"_ls].toString().toHtmlEscaped()) + : i18n("left the room"); + case Membership::Ban: + if (e.senderId() != e.userId()) { + if (e.reason().isEmpty()) { + return i18n("banned %1 from the room", subjectName); + } else { + return i18n("banned %1 from the room: %2", subjectName, e.reason().toHtmlEscaped()); + } + } else { + return i18n("self-banned from the room"); + } + case Membership::Knock: { + QString reason(e.contentJson()["reason"_ls].toString().toHtmlEscaped()); + return reason.isEmpty() ? i18n("requested an invite") : i18n("requested an invite with reason: %1", reason); + } + default:; + } + return i18n("made something unknown"); + }, + [](const RoomCanonicalAliasEvent &e) { + return (e.alias().isEmpty()) ? i18n("cleared the room main alias") : i18n("set the room main alias to: %1", e.alias()); + }, + [](const RoomNameEvent &e) { + return (e.name().isEmpty()) ? i18n("cleared the room name") : i18n("set the room name to: %1", e.name().toHtmlEscaped()); + }, + [prettyPrint, stripNewlines](const RoomTopicEvent &e) { + return (e.topic().isEmpty()) ? i18n("cleared the topic") + : i18n("set the topic to: %1", + prettyPrint ? Quotient::prettyPrint(e.topic()) + : stripNewlines ? e.topic().replace(u'\n', u' ') + : e.topic()); + }, + [](const RoomAvatarEvent &) { + return i18n("changed the room avatar"); + }, + [](const EncryptionEvent &) { + return i18n("activated End-to-End Encryption"); + }, + [](const RoomCreateEvent &e) { + return e.isUpgrade() ? i18n("upgraded the room to version %1", e.version().isEmpty() ? "1"_ls : e.version().toHtmlEscaped()) + : i18n("created the room, version %1", e.version().isEmpty() ? "1"_ls : e.version().toHtmlEscaped()); + }, + [](const RoomPowerLevelsEvent &) { + return i18nc("'power level' means permission level", "changed the power levels for this room"); + }, + [](const StateEvent &e) { + if (e.matrixType() == QLatin1String("m.room.server_acl")) { + return i18n("changed the server access control lists for this room"); + } + if (e.matrixType() == QLatin1String("im.vector.modular.widgets")) { + if (e.fullJson()["unsigned"_ls]["prev_content"_ls].toObject().isEmpty()) { + return i18nc("[User] added widget", "added %1 widget", e.contentJson()["name"_ls].toString()); + } + if (e.contentJson().isEmpty()) { + return i18nc("[User] removed widget", "removed %1 widget", e.fullJson()["unsigned"_ls]["prev_content"_ls]["name"_ls].toString()); + } + return i18nc("[User] configured widget", "configured %1 widget", e.contentJson()["name"_ls].toString()); + } + if (e.matrixType() == "org.matrix.msc3672.beacon_info"_ls) { + return e.contentJson()["description"_ls].toString(); + } + return e.stateKey().isEmpty() ? i18n("updated %1 state", e.matrixType()) + : i18n("updated %1 state for %2", e.matrixType(), e.stateKey().toHtmlEscaped()); + }, + [](const PollStartEvent &e) { + return e.question(); + }, + i18n("Unknown event")); +} + +QString EventHandler::getMessageBody(const RoomMessageEvent &event, Qt::TextFormat format, bool stripNewlines) const +{ + TextHandler textHandler; + + if (event.hasFileContent()) { + auto fileCaption = event.content()->fileInfo()->originalName; + if (fileCaption.isEmpty()) { + fileCaption = event.plainBody(); + } else if (event.content()->fileInfo()->originalName != event.plainBody()) { + fileCaption = event.plainBody() + " | "_ls + fileCaption; + } + textHandler.setData(fileCaption); + return !fileCaption.isEmpty() ? textHandler.handleRecievePlainText(Qt::PlainText, stripNewlines) : i18n("a file"); + } + + QString body; + if (event.hasTextContent() && event.content()) { + body = static_cast(event.content())->body; + } else { + body = event.plainBody(); + } + + textHandler.setData(body); + + Qt::TextFormat inputFormat; + if (event.mimeType().name() == "text/plain"_ls) { + inputFormat = Qt::PlainText; + } else { + inputFormat = Qt::RichText; + } + + if (format == Qt::RichText) { + return textHandler.handleRecieveRichText(inputFormat, m_room, &event, stripNewlines); + } else { + return textHandler.handleRecievePlainText(inputFormat, stripNewlines); + } +} + +QString EventHandler::getGenericBody() const +{ + if (m_event == nullptr) { + qCWarning(EventHandling) << "getGenericBody called with m_event set to nullptr."; + return {}; + } + if (m_event->isRedacted()) { + return i18n("[This message was deleted]"); + } + + return switchOnType( + *m_event, + [](const RoomMessageEvent &e) { + Q_UNUSED(e) + return i18n("sent a message"); + }, + [](const StickerEvent &e) { + Q_UNUSED(e) + return i18n("sent a sticker"); + }, + [](const RoomMemberEvent &e) { + switch (e.membership()) { + case Membership::Invite: + if (e.repeatsState()) { + return i18n("reinvited someone to the room"); + } + Q_FALLTHROUGH(); + case Membership::Join: { + QString text{}; + // Part 1: invites and joins + if (e.repeatsState()) { + text = i18n("joined the room (repeated)"); + } else if (e.changesMembership()) { + text = e.membership() == Membership::Invite ? i18n("invited someone to the room") : i18n("joined the room"); + } + if (!text.isEmpty()) { + return text; + } + // Part 2: profile changes of joined members + if (e.isRename()) { + if (!e.newDisplayName()) { + text = i18nc("their refers to a singular user", "cleared their display name"); + } else { + text = i18nc("their refers to a singular user", "changed their display name"); + } + } + if (e.isAvatarUpdate()) { + if (!text.isEmpty()) { + text += i18n(" and "); + } + if (!e.newAvatarUrl()) { + text += i18nc("their refers to a singular user", "cleared their avatar"); + } else if (!e.prevContent()->avatarUrl) { + text += i18n("set an avatar"); + } else { + text += i18nc("their refers to a singular user", "updated their avatar"); + } + } + if (text.isEmpty()) { + text = i18nc(" changed nothing", "changed nothing"); + } + return text; + } + case Membership::Leave: + if (e.prevContent() && e.prevContent()->membership == Membership::Invite) { + return (e.senderId() != e.userId()) ? i18n("withdrew a user's invitation") : i18n("rejected the invitation"); + } + + if (e.prevContent() && e.prevContent()->membership == Membership::Ban) { + return (e.senderId() != e.userId()) ? i18n("unbanned a user") : i18n("self-unbanned"); + } + return (e.senderId() != e.userId()) ? i18n("put a user out of the room") : i18n("left the room"); + case Membership::Ban: + if (e.senderId() != e.userId()) { + return i18n("banned a user from the room"); + } else { + return i18n("self-banned from the room"); + } + case Membership::Knock: { + return i18n("requested an invite"); + } + default:; + } + return i18n("made something unknown"); + }, + [](const RoomCanonicalAliasEvent &e) { + return (e.alias().isEmpty()) ? i18n("cleared the room main alias") : i18n("set the room main alias"); + }, + [](const RoomNameEvent &e) { + return (e.name().isEmpty()) ? i18n("cleared the room name") : i18n("set the room name"); + }, + [](const RoomTopicEvent &e) { + return (e.topic().isEmpty()) ? i18n("cleared the topic") : i18n("set the topic"); + }, + [](const RoomAvatarEvent &) { + return i18n("changed the room avatar"); + }, + [](const EncryptionEvent &) { + return i18n("activated End-to-End Encryption"); + }, + [](const RoomCreateEvent &e) { + return e.isUpgrade() ? i18n("upgraded the room version") : i18n("created the room"); + }, + [](const RoomPowerLevelsEvent &) { + return i18nc("'power level' means permission level", "changed the power levels for this room"); + }, + [](const StateEvent &e) { + if (e.matrixType() == QLatin1String("m.room.server_acl")) { + return i18n("changed the server access control lists for this room"); + } + if (e.matrixType() == QLatin1String("im.vector.modular.widgets")) { + if (e.fullJson()["unsigned"_ls]["prev_content"_ls].toObject().isEmpty()) { + return i18n("added a widget"); + } + if (e.contentJson().isEmpty()) { + return i18n("removed a widget"); + } + return i18n("configured a widget"); + } + return i18n("updated the state"); + }, + [](const PollStartEvent &e) { + Q_UNUSED(e); + return i18n("started a poll"); + }, + i18n("Unknown event")); +} + +QVariantMap EventHandler::getMediaInfo() const +{ + return getMediaInfoForEvent(m_event); +} + +QVariantMap EventHandler::getMediaInfoForEvent(const Quotient::RoomEvent *event) const +{ + QString eventId = event->id(); + + // Get the file info for the event. + const EventContent::FileInfo *fileInfo; + if (event->is()) { + auto roomMessageEvent = eventCast(event); + if (!roomMessageEvent->hasFileContent()) { + return {}; + } + fileInfo = roomMessageEvent->content()->fileInfo(); + } else if (event->is()) { + auto stickerEvent = eventCast(event); + fileInfo = &stickerEvent->image(); + } else { + return {}; + } + + return getMediaInfoFromFileInfo(fileInfo, eventId); +} + +QVariantMap EventHandler::getMediaInfoFromFileInfo(const EventContent::FileInfo *fileInfo, const QString &eventId, bool isThumbnail) const +{ + QVariantMap mediaInfo; + + // Get the mxc URL for the media. + if (!fileInfo->url().isValid() || eventId.isEmpty()) { + mediaInfo["source"_ls] = QUrl(); + } else { + QUrl source = m_room->makeMediaUrl(eventId, fileInfo->url()); + + if (source.isValid() && source.scheme() == QStringLiteral("mxc")) { + mediaInfo["source"_ls] = source; + } else { + mediaInfo["source"_ls] = QUrl(); + } + } + + auto mimeType = fileInfo->mimeType; + // Add the MIME type for the media if available. + mediaInfo["mimeType"_ls] = mimeType.name(); + + // Add the MIME type icon if available. + mediaInfo["mimeIcon"_ls] = mimeType.iconName(); + + // Add media size if available. + mediaInfo["size"_ls] = fileInfo->payloadSize; + + // Add parameter depending on media type. + if (mimeType.name().contains(QStringLiteral("image"))) { + if (auto castInfo = static_cast(fileInfo)) { + mediaInfo["width"_ls] = castInfo->imageSize.width(); + mediaInfo["height"_ls] = castInfo->imageSize.height(); + + if (!isThumbnail) { + QVariantMap tempInfo; + auto thumbnailInfo = getMediaInfoFromFileInfo(castInfo->thumbnailInfo(), eventId, true); + if (thumbnailInfo["source"_ls].toUrl().scheme() == "mxc"_ls) { + tempInfo = thumbnailInfo; + } else { + QString blurhash = castInfo->originalInfoJson["xyz.amorgan.blurhash"_ls].toString(); + if (blurhash.isEmpty()) { + tempInfo["source"_ls] = QUrl(); + } else { + tempInfo["source"_ls] = QUrl("image://blurhash/"_ls + blurhash); + } + } + mediaInfo["tempInfo"_ls] = tempInfo; + } + } + } + if (mimeType.name().contains(QStringLiteral("video"))) { + if (auto castInfo = static_cast(fileInfo)) { + mediaInfo["width"_ls] = castInfo->imageSize.width(); + mediaInfo["height"_ls] = castInfo->imageSize.height(); + mediaInfo["duration"_ls] = castInfo->duration; + + if (!isThumbnail) { + QVariantMap tempInfo; + auto thumbnailInfo = getMediaInfoFromFileInfo(castInfo->thumbnailInfo(), eventId, true); + if (thumbnailInfo["source"_ls].toUrl().scheme() == "mxc"_ls) { + tempInfo = thumbnailInfo; + } else { + QString blurhash = castInfo->originalInfoJson["xyz.amorgan.blurhash"_ls].toString(); + if (blurhash.isEmpty()) { + tempInfo["source"_ls] = QUrl(); + } else { + tempInfo["source"_ls] = QUrl("image://blurhash/"_ls + blurhash); + } + } + mediaInfo["tempInfo"_ls] = tempInfo; + } + } + } + if (mimeType.name().contains(QStringLiteral("audio"))) { + if (auto castInfo = static_cast(fileInfo)) { + mediaInfo["duration"_ls] = castInfo->duration; + } + } + + return mediaInfo; +} + +QSharedPointer EventHandler::getLinkPreviewer() const +{ + if (!m_event->is()) { + return nullptr; + } + + QString text; + auto event = eventCast(m_event); + if (event->hasTextContent()) { + auto textContent = static_cast(event->content()); + if (textContent) { + text = textContent->body; + } else { + text = event->plainBody(); + } + } else { + text = event->plainBody(); + } + TextHandler textHandler; + textHandler.setData(text); + + QList links = textHandler.getLinkPreviews(); + if (links.size() > 0) { + return QSharedPointer(new LinkPreviewer(nullptr, m_room, links.size() > 0 ? links[0] : QUrl())); + } else { + return nullptr; + } +} + +QSharedPointer EventHandler::getReactions() const +{ + if (m_room == nullptr) { + qCWarning(EventHandling) << "getReactions called with m_room set to nullptr."; + return {}; + } + if (m_event == nullptr) { + qCWarning(EventHandling) << "getReactions called with m_event set to nullptr."; + return nullptr; + } + if (!m_event->is()) { + qCWarning(EventHandling) << "getReactions called with on a non-message event."; + return nullptr; + } + + auto eventId = m_event->id(); + const auto &annotations = m_room->relatedEvents(eventId, EventRelation::AnnotationType); + if (annotations.isEmpty()) { + return nullptr; + }; + + QMap> reactions = {}; + for (const auto &a : annotations) { + if (a->isRedacted()) { // Just in case? + continue; + } + if (const auto &e = eventCast(a)) { + reactions[e->key()].append(m_room->user(e->senderId())); + } + } + + if (reactions.isEmpty()) { + return nullptr; + } + + QList res; + auto i = reactions.constBegin(); + while (i != reactions.constEnd()) { + QVariantList authors; + for (const auto &author : i.value()) { + authors.append(m_room->getUser(author)); + } + + res.append(ReactionModel::Reaction{i.key(), authors}); + ++i; + } + + if (res.size() > 0) { + return QSharedPointer(new ReactionModel(nullptr, res, m_room->localUser())); + } else { + return nullptr; + } +} + +bool EventHandler::hasReply() const +{ + return !m_event->contentJson()["m.relates_to"_ls].toObject()["m.in_reply_to"_ls].toObject()["event_id"_ls].toString().isEmpty(); +} + +QString EventHandler::getReplyId() const +{ + return m_event->contentJson()["m.relates_to"_ls].toObject()["m.in_reply_to"_ls].toObject()["event_id"_ls].toString(); +} + +DelegateType::Type EventHandler::getReplyDelegateType() const +{ + auto replyEvent = m_room->getReplyForEvent(*m_event); + if (replyEvent == nullptr) { + return DelegateType::Other; + } + return getDelegateTypeForEvent(replyEvent); +} + +QVariantMap EventHandler::getReplyAuthor() const +{ + if (m_room == nullptr) { + qCWarning(EventHandling) << "getReplyAuthor called with m_room set to nullptr."; + return {}; + } + // If we have a room we can return an empty user by handing nullptr to m_room->getUser. + if (m_event == nullptr) { + qCWarning(EventHandling) << "getReplyAuthor called with m_event set to nullptr. Returning empty user."; + return m_room->getUser(nullptr); + } + + auto replyPtr = m_room->getReplyForEvent(*m_event); + + if (replyPtr) { + auto replyUser = m_room->user(replyPtr->senderId()); + return m_room->getUser(replyUser); + } else { + return m_room->getUser(nullptr); + } +} + +QString EventHandler::getReplyRichBody(bool stripNewlines) const +{ + if (m_room == nullptr) { + qCWarning(EventHandling) << "getReplyRichBody called with m_room set to nullptr."; + return {}; + } + if (m_event == nullptr) { + qCWarning(EventHandling) << "getReplyRichBody called with m_event set to nullptr."; + return {}; + } + + auto replyEvent = m_room->getReplyForEvent(*m_event); + if (replyEvent == nullptr) { + return {}; + } + + return getBody(replyEvent, Qt::RichText, stripNewlines); +} + +QString EventHandler::getReplyPlainBody(bool stripNewlines) const +{ + if (m_room == nullptr) { + qCWarning(EventHandling) << "getReplyPlainBody called with m_room set to nullptr."; + return {}; + } + if (m_event == nullptr) { + qCWarning(EventHandling) << "getReplyPlainBody called with m_event set to nullptr."; + return {}; + } + + auto replyEvent = m_room->getReplyForEvent(*m_event); + if (replyEvent == nullptr) { + return {}; + } + + return getBody(replyEvent, Qt::PlainText, stripNewlines); +} + +QVariantMap EventHandler::getReplyMediaInfo() const +{ + if (m_room == nullptr) { + qCWarning(EventHandling) << "getReplyMediaInfo called with m_room set to nullptr."; + return {}; + } + if (m_event == nullptr) { + qCWarning(EventHandling) << "getReplyMediaInfo called with m_event set to nullptr."; + return {}; + } + + auto replyPtr = m_room->getReplyForEvent(*m_event); + if (!replyPtr) { + return {}; + } + return getMediaInfoForEvent(replyPtr); +} + +float EventHandler::getLatitude() const +{ + const auto geoUri = m_event->contentJson()["geo_uri"_ls].toString(); + if (geoUri.isEmpty()) { + return -100.0; // latitude runs from -90deg to +90deg so -100 is out of range. + } + const auto latitude = geoUri.split(u';')[0].split(u':')[1].split(u',')[0]; + return latitude.toFloat(); +} + +float EventHandler::getLongitude() const +{ + const auto geoUri = m_event->contentJson()["geo_uri"_ls].toString(); + if (geoUri.isEmpty()) { + return -200.0; // longitude runs from -180deg to +180deg so -200 is out of range. + } + const auto latitude = geoUri.split(u';')[0].split(u':')[1].split(u',')[1]; + return latitude.toFloat(); +} + +QString EventHandler::getLocationAssetType() const +{ + const auto assetType = m_event->contentJson()["org.matrix.msc3488.asset"_ls].toObject()["type"_ls].toString(); + if (assetType.isEmpty()) { + return {}; + } + return assetType; +} + +bool EventHandler::hasReadMarkers() const +{ + auto userIds = m_room->userIdsAtEvent(m_event->id()); + userIds.remove(m_room->localUser()->id()); + return userIds.size() > 0; +} + +QVariantList EventHandler::getReadMarkers(int maxMarkers) const +{ + auto userIds_temp = m_room->userIdsAtEvent(m_event->id()); + userIds_temp.remove(m_room->localUser()->id()); + + auto userIds = userIds_temp.values(); + if (userIds.count() > maxMarkers) { + userIds = userIds.mid(0, maxMarkers); + } + + QVariantList users; + users.reserve(userIds.size()); + for (const auto &userId : userIds) { + auto user = m_room->user(userId); + users += m_room->getUser(user); + } + + return users; +} + +QString EventHandler::getNumberExcessReadMarkers(int maxMarkers) const +{ + auto userIds = m_room->userIdsAtEvent(m_event->id()); + userIds.remove(m_room->localUser()->id()); + + if (userIds.count() > maxMarkers) { + return QStringLiteral("+ ") + QString::number(userIds.count() - maxMarkers); + } else { + return QString(); + } +} + +QString EventHandler::getReadMarkersString() const +{ + auto userIds = m_room->userIdsAtEvent(m_event->id()); + userIds.remove(m_room->localUser()->id()); + + /** + * The string ends up in the form + * "x users: user1DisplayName, user2DisplayName, etc." + */ + QString readMarkersString = i18np("1 user: ", "%1 users: ", userIds.size()); + for (const auto &userId : userIds) { + auto user = m_room->user(userId); + readMarkersString += user->displayname(m_room) + i18nc("list separator", ", "); + } + readMarkersString.chop(2); + return readMarkersString; +} diff --git a/src/eventhandler.h b/src/eventhandler.h new file mode 100644 index 000000000..ee492685e --- /dev/null +++ b/src/eventhandler.h @@ -0,0 +1,400 @@ +// SPDX-FileCopyrightText: 2023 James Graham +// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL + +#pragma once + +#include + +#include + +#include +#include +#include + +#include "enums/delegatetype.h" + +class LinkPreviewer; +class NeoChatRoom; +class ReactionModel; + +/** + * @class EventHandler + * + * This class is designed to handle a Quotient::RoomEvent allowing data to be extracted + * in a form ready for the NeoChat UI. + * + * To use this properly both the room and the event should be set (and the event should + * be from the given room). + * + * @note EventHandler will always try to return something even when not properly + * initialised, this is usually the best empty value it can create with available + * information. This is to minimize warnings from QML especially during startup + * and room changes. + */ +class EventHandler : public QObject +{ + Q_OBJECT + +public: + /** + * @brief Return the current room the EventHandler is using. + */ + const NeoChatRoom *getRoom() const; + + /** + * @brief Set the current room the EventHandler to using. + */ + void setRoom(const NeoChatRoom *room); + + /** + * @brief Return the current event the EventHandler is using. + */ + const Quotient::Event *getEvent() const; + + /** + * @brief Set the current event the EventHandler to using. + */ + void setEvent(const Quotient::RoomEvent *event); + + /** + * @brief Return the Matrix ID of the event. + */ + QString getId() const; + + /** + * @brief Return the DelegateType of the event. + * + * @note While similar this is not the matrix event or message type. This is + * to tell a QML ListView what delegate to show for each event. So while + * similar to the spec it is not the same. + */ + DelegateType::Type getDelegateType() const; + + /** + * @brief Get the author of the event in context of the room. + * + * This is different to getting a Quotient::User object + * as neither of those can provide details like the displayName or avatarMediaId + * without the room context as these can vary from room to room. This function + * uses the room context and outputs the result as QVariantMap. + * + * An empty QVariantMap will be returned if the EventHandler hasn't had the room + * intialised. An empty user (i.e. a QVariantMap with all the correct keys + * but empty values) will be returned if the room has been set but not an event. + * + * @param isPending if the event is pending, i.e. has not been confirmed by + * the server. + * + * @return a QVariantMap for the user with the following properties: + * - isLocalUser - Whether the user is the local user. + * - id - The matrix ID of the user. + * - displayName - Display name in the context of this room. + * - avatarSource - The mxc URL for the user's avatar in the current room. + * - avatarMediaId - Avatar id in the context of this room. + * - color - Color for the user. + * - object - The Quotient::User object for the user. + * + * @sa Quotient::User + */ + QVariantMap getAuthor(bool isPending = false) const; + + /** + * @brief Get the display name of the event author. + * + * This method is separate from getAuthor() and special in that it will return + * the old display name of the author if the current event is one that caused it + * to change. This allows for scenarios where the UI wishes to notify that a + * user's display name has changed and what it changed from. + * + * @param isPending whether the event is pending as this cannot be derived from + * just the event object. + */ + QString getAuthorDisplayName(bool isPending = false) const; + + /** + * @brief Return a QDateTime object for the event timestamp. + */ + QDateTime getTime(bool isPending = false, QDateTime lastUpdated = {}) const; + + /** + * @brief Return a QString for the event timestamp. + * + * This is intended to return a string that is read for display in the UI without + * any further manipulation required. + * + * @param relative whether the string is realtive to the current date, i.e. + * Yesterday or Wednesday, etc. + * @param format the QLocale::FormatType to use. + * @param isPending whether the event is pending as this cannot be derived from + * just the event object. + * @param lastUpdated the time the event was last updated locally as this cannot be + * obtained from the event. + */ + QString getTimeString(bool relative, QLocale::FormatType format = QLocale::ShortFormat, bool isPending = false, QDateTime lastUpdated = {}) const; + + /** + * @brief Whether the event should be highlighted in the timeline. + * + * @note Messages in direct chats are never highlighted. + */ + bool isHighlighted(); + + /** + * @brief Whether the event should be hidden in the timeline. + * + * This could be for numerous reasons, e.g. if it's a replacement event, if the + * user has hidden all state events or if the sender has been ignored by the local + * user. + */ + bool isHidden(); + + /** + * @brief Output a string for the message content ready for display in a rich text field. + * + * The output string is dependant upon the event type and the desired output format. + * + * For most messages this is the body content of the message. For media messages + * this will be the caption and for state events it will be a string specific + * to that event with some dynamic details about the event added. + * + * E.g. For a room topic state event the text will be: + * "set the topic to: " + * + * @param stripNewlines whether the output should have new lines in it. + */ + QString getRichBody(bool stripNewlines = false) const; + + /** + * @brief Output a string for the message content ready for display in a plain text field. + * + * The output string is dependant upon the event type and the desired output format. + * + * For most messages this is the body content of the message. For media messages + * this will be the caption and for state events it will be a string specific + * to that event with some dynamic details about the event added. + * + * E.g. For a room topic state event the text will be: + * "set the topic to: " + * + * @param stripNewlines whether the output should have new lines in it. + */ + QString getPlainBody(bool stripNewlines = false) const; + + /** + * @brief Output a generic string for the message content ready for display. + * + * The output string is dependant upon the event type. + * + * Unlike EventHandler::getRichBody or EventHandler::getPlainBody the string + * is the same for all events of the same type. + * + * E.g. For a message the text will be: + * "sent a message" + * + * @sa getRichBody(), getPlainBody() + */ + QString getGenericBody() const; + + /** + * @brief Return the media info for the event. + * + * An empty QVariantMap will be returned for any event that doesn't have any + * media info. + * + * @return 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. + */ + QVariantMap getMediaInfo() const; + + /** + * @brief Return a LinkPreviewer object for the event. + * + * A nullptr will be returned for any event that doesn't have any links so the + * return should be null checked and an empty LinkPreviewer provided if null. + * + * @sa LinkPreviewer + */ + QSharedPointer getLinkPreviewer() const; + + /** + * @brief Return a ReactionModel object for the event. + * + * A nullptr will be returned for any event that doesn't have any links so the + * return should be null checked and an empty QVariantList (or other suitable + * empty mode) provided if null. + */ + QSharedPointer getReactions() const; + + /** + * @brief Whether the event is a reply to another in the timeline. + */ + bool hasReply() const; + + /** + * @brief Return the Matrix ID of the event replied to. + */ + QString getReplyId() const; + + /** + * @brief Return the DelegateType of the event replied to. + * + * @note While similar this is not the matrix event or message type. This is + * to tell a QML ListView what delegate to show for each event. So while + * similar to the spec it is not the same. + */ + DelegateType::Type getReplyDelegateType() const; + + /** + * @brief Get the author of the event replied to in context of the room. + * + * This is different to getting a Quotient::User object + * as neither of those can provide details like the displayName or avatarMediaId + * without the room context as these can vary from room to room. This function + * uses the room context and outputs the result as QVariantMap. + * + * An empty QVariantMap will be returned if the EventHandler hasn't had the room + * intialised. An empty user (i.e. a QVariantMap with all the correct keys + * but empty values) will be returned if the room has been set but not an event. + * + * @return a QVariantMap for the user with the following properties: + * - isLocalUser - Whether the user is the local user. + * - id - The matrix ID of the user. + * - displayName - Display name in the context of this room. + * - avatarSource - The mxc URL for the user's avatar in the current room. + * - avatarMediaId - Avatar id in the context of this room. + * - color - Color for the user. + * - object - The Quotient::User object for the user. + * + * @sa Quotient::User + */ + QVariantMap getReplyAuthor() const; + + /** + * @brief Output a string for the message content of the event replied to ready + * for display in a rich text field. + * + * The output string is dependant upon the event type and the desired output format. + * + * For most messages this is the body content of the message. For media messages + * this will be the caption and for state events it will be a string specific + * to that event with some dynamic details about the event added. + * + * E.g. For a room topic state event the text will be: + * "set the topic to: " + * + * @param stripNewlines whether the output should have new lines in it. + */ + QString getReplyRichBody(bool stripNewlines = false) const; + + /** + * @brief Output a string for the message content of the event replied to ready + * for display in a plain text field. + * + * The output string is dependant upon the event type and the desired output format. + * + * For most messages this is the body content of the message. For media messages + * this will be the caption and for state events it will be a string specific + * to that event with some dynamic details about the event added. + * + * E.g. For a room topic state event the text will be: + * "set the topic to: " + * + * @param stripNewlines whether the output should have new lines in it. + */ + QString getReplyPlainBody(bool stripNewlines = false) const; + + /** + * @brief Return the media info for the event replied to. + * + * An empty QVariantMap will be returned for any event that doesn't have any + * media info. + * + * @return 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. + */ + QVariantMap getReplyMediaInfo() const; + + /** + * @brief Return the latitude for the event. + * + * Returns -100.0 if the event doesn't have a location (latitudes are in the + * range -90deg to +90deg so -100 is out of range). + */ + float getLatitude() const; + + /** + * @brief Return the longitude for the event. + * + * Returns -200.0 if the event doesn't have a location (latitudes are in the + * range -180deg to +180deg so -200 is out of range). + */ + float getLongitude() const; + + /** + * @brief Return the type of location marker for the event. + */ + QString getLocationAssetType() const; + + /** + * @brief Whether the event has any read marker for other users. + */ + bool hasReadMarkers() const; + + /** + * @brief Returns a list of user read marker for the event. + * + * @param maxMarkers the maximum number of users to return. Usually the number + * of user read makers shown is limited to not clutter the UI. + * This needs to be the same as used in getNumberExcessReadMarkers + * so that the markers line up with the number displayed, i.e. + * the number of users shown plus the excess number will be + * the total number of other user read markers at an event. + */ + QVariantList getReadMarkers(int maxMarkers = 5) const; + + /** + * @brief Returns the number of excess user read markers for the event. + * + * This returns a string in the form "+ x" ready for use in the UI. + * + * @param maxMarkers the maximum number of markers shown in the UI. This needs to + * be the same as used in getReadMarkers so that the value lines + * up with the number displayed, i.e. the number of users shown + * plus the excess number will be the total number of other user + * read markers at an event. + */ + QString getNumberExcessReadMarkers(int maxMarkers = 5) const; + + /** + * @brief Returns a string with the names of the read markers at the event. + * + * This is in the form "x users: name 1, name 2, ...". + */ + QString getReadMarkersString() const; + +private: + const NeoChatRoom *m_room = nullptr; + const Quotient::RoomEvent *m_event = nullptr; + + KFormat m_format; + + DelegateType::Type getDelegateTypeForEvent(const Quotient::RoomEvent *event) const; + + QString getBody(const Quotient::RoomEvent *event, Qt::TextFormat format, bool stripNewlines) const; + QString getMessageBody(const Quotient::RoomMessageEvent &event, Qt::TextFormat format, bool stripNewlines) const; + + QVariantMap getMediaInfoForEvent(const Quotient::RoomEvent *event) const; + QVariantMap getMediaInfoFromFileInfo(const Quotient::EventContent::FileInfo *fileInfo, const QString &eventId, bool isThumbnail = false) const; +}; diff --git a/src/linkpreviewer.cpp b/src/linkpreviewer.cpp index ae731a787..cf7b85301 100644 --- a/src/linkpreviewer.cpp +++ b/src/linkpreviewer.cpp @@ -13,7 +13,7 @@ using namespace Quotient; -LinkPreviewer::LinkPreviewer(QObject *parent, NeoChatRoom *room, const QUrl &url) +LinkPreviewer::LinkPreviewer(QObject *parent, const NeoChatRoom *room, const QUrl &url) : QObject(parent) , m_currentRoom(room) , m_loaded(false) diff --git a/src/linkpreviewer.h b/src/linkpreviewer.h index 52c387d66..31e8bda62 100644 --- a/src/linkpreviewer.h +++ b/src/linkpreviewer.h @@ -45,7 +45,7 @@ class LinkPreviewer : public QObject Q_PROPERTY(QUrl imageSource READ imageSource NOTIFY imageSourceChanged) public: - explicit LinkPreviewer(QObject *parent = nullptr, NeoChatRoom *room = nullptr, const QUrl &url = {}); + explicit LinkPreviewer(QObject *parent = nullptr, const NeoChatRoom *room = nullptr, const QUrl &url = {}); [[nodiscard]] QUrl url() const; void setUrl(QUrl); @@ -55,7 +55,7 @@ class LinkPreviewer : public QObject [[nodiscard]] QUrl imageSource() const; private: - NeoChatRoom *m_currentRoom = nullptr; + const NeoChatRoom *m_currentRoom = nullptr; bool m_loaded; QString m_title = QString(); diff --git a/src/main.cpp b/src/main.cpp index d78058262..0005d910e 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -47,6 +47,7 @@ #include "clipboard.h" #include "controller.h" #include "delegatesizehelper.h" +#include "enums/delegatetype.h" #include "filetypesingleton.h" #include "linkpreviewer.h" #include "locationhelper.h" @@ -280,6 +281,7 @@ int main(int argc, char *argv[]) qmlRegisterType("org.kde.neochat", 1, 0, "EmoticonFilterModel"); qmlRegisterType("org.kde.neochat", 1, 0, "DelegateSizeHelper"); qmlRegisterType("org.kde.neochat", 1, 0, "MediaSizeHelper"); + qmlRegisterUncreatableType("org.kde.neochat", 1, 0, "DelegateType", "ENUM"_ls); qmlRegisterUncreatableType("org.kde.neochat", 1, 0, "PushNotificationKind", "ENUM"_ls); qmlRegisterUncreatableType("org.kde.neochat", 1, 0, "PushNotificationSection", "ENUM"_ls); qmlRegisterUncreatableType("org.kde.neochat", 1, 0, "PushNotificationState", "ENUM"_ls); diff --git a/src/models/mediamessagefiltermodel.cpp b/src/models/mediamessagefiltermodel.cpp index 3b7e28d3f..bf7cef982 100644 --- a/src/models/mediamessagefiltermodel.cpp +++ b/src/models/mediamessagefiltermodel.cpp @@ -5,6 +5,7 @@ #include +#include "enums/delegatetype.h" #include "messageeventmodel.h" #include "messagefiltermodel.h" @@ -19,8 +20,8 @@ bool MediaMessageFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex { const QModelIndex index = sourceModel()->index(sourceRow, 0, sourceParent); - if (index.data(MessageEventModel::DelegateTypeRole).toInt() == MessageEventModel::DelegateType::Image - || index.data(MessageEventModel::DelegateTypeRole).toInt() == MessageEventModel::DelegateType::Video) { + if (index.data(MessageEventModel::DelegateTypeRole).toInt() == DelegateType::Image + || index.data(MessageEventModel::DelegateTypeRole).toInt() == DelegateType::Video) { return true; } return false; @@ -29,9 +30,9 @@ bool MediaMessageFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex QVariant MediaMessageFilterModel::data(const QModelIndex &index, int role) const { if (role == SourceRole) { - if (mapToSource(index).data(MessageEventModel::DelegateTypeRole).toInt() == MessageEventModel::DelegateType::Image) { + if (mapToSource(index).data(MessageEventModel::DelegateTypeRole).toInt() == DelegateType::Image) { return mapToSource(index).data(MessageEventModel::MediaInfoRole).toMap()[QStringLiteral("source")].toUrl(); - } else if (mapToSource(index).data(MessageEventModel::DelegateTypeRole).toInt() == MessageEventModel::DelegateType::Video) { + } else if (mapToSource(index).data(MessageEventModel::DelegateTypeRole).toInt() == DelegateType::Video) { auto progressInfo = mapToSource(index).data(MessageEventModel::ProgressInfoRole).value(); if (progressInfo.completed()) { @@ -47,7 +48,7 @@ QVariant MediaMessageFilterModel::data(const QModelIndex &index, int role) const return mapToSource(index).data(MessageEventModel::MediaInfoRole).toMap()[QStringLiteral("tempInfo")].toMap()[QStringLiteral("source")].toUrl(); } if (role == TypeRole) { - if (mapToSource(index).data(MessageEventModel::DelegateTypeRole).toInt() == MessageEventModel::DelegateType::Image) { + if (mapToSource(index).data(MessageEventModel::DelegateTypeRole).toInt() == DelegateType::Image) { return MediaType::Image; } else { return MediaType::Video; diff --git a/src/models/messageeventmodel.cpp b/src/models/messageeventmodel.cpp index d1480bbb2..116f29403 100644 --- a/src/models/messageeventmodel.cpp +++ b/src/models/messageeventmodel.cpp @@ -8,15 +8,9 @@ #include #include -#include #include -#include -#include -#include -#include - -#include "events/pollevent.h" #include +#include #include #include @@ -25,8 +19,9 @@ #include +#include "enums/delegatetype.h" +#include "eventhandler.h" #include "models/reactionmodel.h" -#include "texthandler.h" using namespace Quotient; @@ -37,6 +32,7 @@ QHash MessageEventModel::roleNames() const roles[PlainText] = "plainText"; roles[EventIdRole] = "eventId"; roles[TimeRole] = "time"; + roles[TimeStringRole] = "timeString"; roles[SectionRole] = "section"; roles[AuthorRole] = "author"; roles[ContentRole] = "content"; @@ -48,8 +44,9 @@ QHash MessageEventModel::roleNames() const roles[MediaInfoRole] = "mediaInfo"; roles[IsReplyRole] = "isReply"; roles[ReplyAuthor] = "replyAuthor"; - roles[ReplyRole] = "reply"; roles[ReplyIdRole] = "replyId"; + roles[ReplyDelegateTypeRole] = "replyDelegateType"; + roles[ReplyDisplayRole] = "replyDisplay"; roles[ReplyMediaInfoRole] = "replyMediaInfo"; roles[ShowAuthorRole] = "showAuthor"; roles[ShowSectionRole] = "showSection"; @@ -60,10 +57,8 @@ QHash MessageEventModel::roleNames() const roles[ReactionRole] = "reaction"; roles[ShowReactionsRole] = "showReactions"; roles[SourceRole] = "jsonSource"; - roles[MimeTypeRole] = "mimeType"; roles[AuthorIdRole] = "authorId"; roles[VerifiedRole] = "verified"; - roles[DisplayNameForInitialsRole] = "displayNameForInitials"; roles[AuthorDisplayNameRole] = "authorDisplayName"; roles[IsRedactedRole] = "isRedacted"; roles[GenericDisplayRole] = "genericDisplay"; @@ -97,7 +92,6 @@ void MessageEventModel::setRoom(NeoChatRoom *room) if (m_currentRoom) { m_currentRoom->disconnect(this); m_linkPreviewers.clear(); - qDeleteAll(m_reactionModels); m_reactionModels.clear(); } @@ -107,10 +101,7 @@ void MessageEventModel::setRoom(NeoChatRoom *room) room->setDisplayed(); for (auto event = m_currentRoom->messageEvents().begin(); event != m_currentRoom->messageEvents().end(); ++event) { - if (auto e = &*event->viewAs()) { - createLinkPreviewerForEvent(e); - createReactionModelForEvent(e); - } + createEventObjects(&*event->viewAs()); } if (m_currentRoom->timelineSize() < 10 && !room->allHistoryLoaded()) { @@ -123,16 +114,16 @@ void MessageEventModel::setRoom(NeoChatRoom *room) if (row == -1) { return; } - Q_EMIT dataChanged(index(row, 0), index(row, 0), {ReplyRole, ReplyMediaInfoRole, ReplyAuthor}); + Q_EMIT dataChanged(index(row, 0), index(row, 0), {ReplyDelegateTypeRole, ReplyDisplayRole, ReplyMediaInfoRole, ReplyAuthor}); }); connect(m_currentRoom, &Room::aboutToAddNewMessages, this, [this](RoomEventsRange events) { for (auto &&event : events) { const RoomMessageEvent *message = dynamic_cast(event.get()); - if (message != nullptr) { - createLinkPreviewerForEvent(message); - createReactionModelForEvent(message); + createEventObjects(message); + + if (message != nullptr) { if (NeoChatConfig::self()->showFancyEffects()) { QString planBody = message->plainBody(); // snowflake @@ -169,10 +160,7 @@ void MessageEventModel::setRoom(NeoChatRoom *room) connect(m_currentRoom, &Room::aboutToAddHistoricalMessages, this, [this](RoomEventsRange events) { for (auto &event : events) { RoomMessageEvent *message = dynamic_cast(event.get()); - if (message) { - createLinkPreviewerForEvent(message); - createReactionModelForEvent(message); - } + createEventObjects(message); } if (rowCount() > 0) { rowBelowInserted = rowCount() - 1; // See #312 @@ -241,7 +229,7 @@ void MessageEventModel::setRoom(NeoChatRoom *room) } const auto eventIt = m_currentRoom->findInTimeline(eventId); if (eventIt != m_currentRoom->historyEdge()) { - createReactionModelForEvent(static_cast(&**eventIt)); + createEventObjects(static_cast(&**eventIt)); } refreshEventRoles(eventId, {ReactionRole, ShowReactionsRole, Qt::DisplayRole}); }); @@ -340,7 +328,7 @@ int MessageEventModel::refreshEventRoles(const QString &id, const QVector & return -1; } row = int(timelineIt - m_currentRoom->messageEvents().rbegin()) + timelineBaseIndex(); - if (data(index(row, 0), DelegateTypeRole).toInt() == ReadMarker || data(index(row, 0), DelegateTypeRole).toInt() == Other) { + if (data(index(row, 0), DelegateTypeRole).toInt() == DelegateType::ReadMarker || data(index(row, 0), DelegateTypeRole).toInt() == DelegateType::Other) { row++; } } @@ -458,6 +446,10 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const const auto pendingIt = m_currentRoom->pendingEvents().crbegin() + std::min(row, timelineBaseIndex()); const auto &evt = isPending ? **pendingIt : **timelineIt; + EventHandler eventHandler; + eventHandler.setRoom(m_currentRoom); + eventHandler.setEvent(&evt); + if (role == Qt::DisplayRole) { if (evt.isRedacted()) { auto reason = evt.redactedBecause()->reason(); @@ -465,19 +457,15 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const : i18n("[This message was deleted: %1]", evt.redactedBecause()->reason()); } - return m_currentRoom->eventToString(evt, Qt::RichText); + return eventHandler.getRichBody(); } if (role == GenericDisplayRole) { - if (evt.isRedacted()) { - return i18n("[This message was deleted]"); - } - - return m_currentRoom->eventToGenericString(evt); + return eventHandler.getGenericBody(); } if (role == PlainText) { - return m_currentRoom->eventToString(evt); + return eventHandler.getPlainBody(); } if (role == SourceRole) { @@ -485,54 +473,11 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const } if (role == DelegateTypeRole) { - if (auto e = eventCast(&evt)) { - switch (e->msgtype()) { - case MessageEventType::Emote: - return DelegateType::Emote; - case MessageEventType::Notice: - return DelegateType::Notice; - case MessageEventType::Image: - return DelegateType::Image; - case MessageEventType::Audio: - return DelegateType::Audio; - case MessageEventType::Video: - return DelegateType::Video; - case MessageEventType::Location: - return DelegateType::Location; - default: - break; - } - if (e->hasFileContent()) { - return DelegateType::File; - } - - return DelegateType::Message; - } - if (is(evt)) { - return DelegateType::Sticker; - } - if (evt.isStateEvent()) { - if (evt.matrixType() == "org.matrix.msc3672.beacon_info"_ls) { - return DelegateType::LiveLocation; - } - return DelegateType::State; - } - if (is(evt)) { - return DelegateType::Encrypted; - } - if (is(evt)) { - if (evt.isRedacted()) { - return DelegateType::Message; - } - return DelegateType::Poll; - } - - return DelegateType::Other; + return eventHandler.getDelegateType(); } if (role == AuthorRole) { - auto author = isPending ? m_currentRoom->localUser() : m_currentRoom->user(evt.senderId()); - return m_currentRoom->getUser(author); + return eventHandler.getAuthor(isPending); } if (role == ContentRole) { @@ -558,21 +503,7 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const } if (role == HighlightRole) { - return !m_currentRoom->isDirectChat() && m_currentRoom->isEventHighlighted(&evt); - } - - if (role == MimeTypeRole) { - if (auto e = eventCast(&evt)) { - if (!e || !e->hasFileContent()) { - return QVariant(); - } - - return e->content()->fileInfo()->mimeType.name(); - } - - if (auto e = eventCast(&evt)) { - return e->image().mimeType.name(); - } + return eventHandler.isHighlighted(); } if (role == SpecialMarksRole) { @@ -585,46 +516,7 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const return pendingIt->deliveryStatus(); } - if (evt.isStateEvent() && !NeoChatConfig::self()->showStateEvent()) { - return EventStatus::Hidden; - } - - if (auto roomMemberEvent = eventCast(&evt)) { - if ((roomMemberEvent->isJoin() || roomMemberEvent->isLeave()) && !NeoChatConfig::self()->showLeaveJoinEvent()) { - return EventStatus::Hidden; - } else if (roomMemberEvent->isRename() && !roomMemberEvent->isJoin() && !roomMemberEvent->isLeave() && !NeoChatConfig::self()->showRename()) { - return EventStatus::Hidden; - } else if (roomMemberEvent->isAvatarUpdate() && !roomMemberEvent->isJoin() && !roomMemberEvent->isLeave() - && !NeoChatConfig::self()->showAvatarUpdate()) { - return EventStatus::Hidden; - } - } - - // isReplacement? - if (auto e = eventCast(&evt)) - if (!e->replacedEvent().isEmpty()) - return EventStatus::Hidden; - - if (is(evt) || is(evt)) { - return EventStatus::Hidden; - } - - if (evt.isStateEvent() && static_cast(evt).repeatsState()) { - return EventStatus::Hidden; - } - - if (auto e = eventCast(&evt)) { - if (!e->replacedEvent().isEmpty() && e->replacedEvent() != e->id()) { - return EventStatus::Hidden; - } - } - - if (m_currentRoom->connection()->isIgnored(m_currentRoom->user(evt.senderId()))) { - return EventStatus::Hidden; - } - - // hide ending live location beacons - if (evt.isStateEvent() && evt.matrixType() == "org.matrix.msc3672.beacon_info"_ls && !evt.contentJson()["live"_ls].toBool()) { + if (eventHandler.isHidden()) { return EventStatus::Hidden; } @@ -632,7 +524,7 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const } if (role == EventIdRole) { - return !evt.id().isEmpty() ? evt.id() : evt.transactionId(); + return eventHandler.getId(); } if (role == ProgressInfoRole) { @@ -646,9 +538,19 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const } } - if (role == TimeRole || role == SectionRole) { - auto ts = isPending ? pendingIt->lastUpdated() : makeMessageTimestamp(timelineIt); - return role == TimeRole ? QVariant(ts) : m_format.formatRelativeDate(ts.toLocalTime().date(), QLocale::ShortFormat); + if (role == TimeRole) { + auto lastUpdated = isPending ? pendingIt->lastUpdated() : QDateTime(); + return eventHandler.getTime(isPending, lastUpdated); + } + + if (role == TimeStringRole) { + auto lastUpdated = isPending ? pendingIt->lastUpdated() : QDateTime(); + return eventHandler.getTimeString(false, QLocale::ShortFormat, isPending, lastUpdated); + } + + if (role == SectionRole) { + auto lastUpdated = isPending ? pendingIt->lastUpdated() : QDateTime(); + return eventHandler.getTimeString(true, QLocale::ShortFormat, isPending, lastUpdated); } if (role == ShowLinkPreviewRole) { @@ -657,85 +559,38 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const if (role == LinkPreviewRole) { if (m_linkPreviewers.contains(evt.id())) { - return QVariant::fromValue(m_linkPreviewers[evt.id()]); + return QVariant::fromValue(m_linkPreviewers[evt.id()].data()); } else { return QVariant::fromValue(emptyLinkPreview); } } if (role == MediaInfoRole) { - return getMediaInfoForEvent(evt); + return eventHandler.getMediaInfo(); } if (role == IsReplyRole) { - return !evt.contentJson()["m.relates_to"_ls].toObject()["m.in_reply_to"_ls].toObject()["event_id"_ls].toString().isEmpty(); + return eventHandler.hasReply(); } if (role == ReplyIdRole) { - return evt.contentJson()["m.relates_to"_ls].toObject()["m.in_reply_to"_ls].toObject()["event_id"_ls].toString(); + return eventHandler.getReplyId(); } - if (role == ReplyAuthor) { - auto replyPtr = m_currentRoom->getReplyForEvent(evt); - - if (replyPtr) { - auto replyUser = m_currentRoom->user(replyPtr->senderId()); - return m_currentRoom->getUser(replyUser); - } else { - return m_currentRoom->getUser(nullptr); - } + if (role == ReplyDelegateTypeRole) { + return eventHandler.getReplyDelegateType(); } - if (role == ReplyMediaInfoRole) { - auto replyPtr = m_currentRoom->getReplyForEvent(evt); - if (!replyPtr) { - return {}; - } - return getMediaInfoForEvent(*replyPtr); + if (role == ReplyAuthor) { + return eventHandler.getReplyAuthor(); } - if (role == ReplyRole) { - auto replyPtr = m_currentRoom->getReplyForEvent(evt); - if (!replyPtr) { - return {}; - } - - DelegateType type; - if (auto e = eventCast(replyPtr)) { - switch (e->msgtype()) { - case MessageEventType::Emote: - type = DelegateType::Emote; - break; - case MessageEventType::Notice: - type = DelegateType::Notice; - break; - case MessageEventType::Image: - type = DelegateType::Image; - break; - case MessageEventType::Audio: - type = DelegateType::Audio; - break; - case MessageEventType::Video: - type = DelegateType::Video; - break; - default: - if (e->hasFileContent()) { - type = DelegateType::File; - break; - } - type = DelegateType::Message; - } - - } else if (is(*replyPtr)) { - type = DelegateType::Sticker; - } else { - type = DelegateType::Other; - } + if (role == ReplyDisplayRole) { + return eventHandler.getReplyRichBody(); + } - return QVariantMap{ - {"display"_ls, m_currentRoom->eventToString(*replyPtr, Qt::RichText)}, - {"type"_ls, type}, - }; + if (role == ReplyMediaInfoRole) { + return eventHandler.getReplyMediaInfo(); } if (role == ShowAuthorRole) { @@ -745,7 +600,7 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const // While the row is removed the subsequent row indexes are not changed so we need to skip over the removed index. // See - https://doc.qt.io/qt-5/qabstractitemmodel.html#beginRemoveRows if (data(i, SpecialMarksRole) != EventStatus::Hidden && !itemData(i).empty()) { - return data(i, AuthorRole) != data(idx, AuthorRole) || data(i, DelegateTypeRole) == MessageEventModel::State + return data(i, AuthorRole) != data(idx, AuthorRole) || data(i, DelegateTypeRole) == DelegateType::State || data(i, TimeRole).toDateTime().msecsTo(data(idx, TimeRole).toDateTime()) > 600000 || data(i, TimeRole).toDateTime().toLocalTime().date().day() != data(idx, TimeRole).toDateTime().toLocalTime().date().day(); } @@ -771,87 +626,36 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const } if (role == LatitudeRole) { - const auto geoUri = evt.contentJson()["geo_uri"_ls].toString(); - if (geoUri.isEmpty()) { - return {}; - } - const auto latitude = geoUri.split(u';')[0].split(u':')[1].split(u',')[0]; - return latitude.toFloat(); + return eventHandler.getLatitude(); } if (role == LongitudeRole) { - const auto geoUri = evt.contentJson()["geo_uri"_ls].toString(); - if (geoUri.isEmpty()) { - return {}; - } - const auto latitude = geoUri.split(u';')[0].split(u':')[1].split(u',')[1]; - return latitude.toFloat(); + return eventHandler.getLongitude(); } if (role == AssetRole) { - const auto assetType = evt.contentJson()["org.matrix.msc3488.asset"_ls].toObject()["type"_ls].toString(); - if (assetType.isEmpty()) { - return {}; - } - return assetType; + return eventHandler.getLocationAssetType(); } if (role == ReadMarkersRole) { - auto userIds_temp = room()->userIdsAtEvent(evt.id()); - userIds_temp.remove(m_currentRoom->localUser()->id()); - - auto userIds = userIds_temp.values(); - if (userIds.count() > 5) { - userIds = userIds.mid(0, 5); - } - - QVariantList users; - users.reserve(userIds.size()); - for (const auto &userId : userIds) { - auto user = m_currentRoom->user(userId); - users += m_currentRoom->getUser(user); - } - - return users; + return eventHandler.getReadMarkers(); } if (role == ExcessReadMarkersRole) { - auto userIds = room()->userIdsAtEvent(evt.id()); - userIds.remove(m_currentRoom->localUser()->id()); - - if (userIds.count() > 5) { - return QStringLiteral("+ %1").arg(userIds.count() - 5); - } else { - return QString(); - } + return eventHandler.getNumberExcessReadMarkers(); } if (role == ReadMarkersStringRole) { - auto userIds = room()->userIdsAtEvent(evt.id()); - userIds.remove(m_currentRoom->localUser()->id()); - - /** - * The string ends up in the form - * "x users: user1DisplayName, user2DisplayName, etc." - */ - QString readMarkersString = i18np("1 user: ", "%1 users: ", userIds.size()); - for (const auto &userId : userIds) { - auto user = m_currentRoom->user(userId); - readMarkersString += user->displayname(m_currentRoom) + i18nc("list separator", ", "); - } - readMarkersString.chop(2); - return readMarkersString; + return eventHandler.getReadMarkersString(); } if (role == ShowReadMarkersRole) { - auto userIds = room()->userIdsAtEvent(evt.id()); - userIds.remove(m_currentRoom->localUser()->id()); - return userIds.size() > 0; + return eventHandler.hasReadMarkers(); } if (role == ReactionRole) { if (m_reactionModels.contains(evt.id())) { - return QVariant::fromValue(m_reactionModels[evt.id()]); + return QVariant::fromValue(m_reactionModels[evt.id()].data()); } else { return QVariantList(); } @@ -874,22 +678,8 @@ QVariant MessageEventModel::data(const QModelIndex &idx, int role) const return false; } - if (role == DisplayNameForInitialsRole) { - auto user = isPending ? m_currentRoom->localUser() : m_currentRoom->user(evt.senderId()); - return user->displayname(m_currentRoom).remove(QStringLiteral(" (%1)").arg(user->id())); - } - if (role == AuthorDisplayNameRole) { - if (is(evt) && !evt.unsignedJson()["prev_content"_ls]["displayname"_ls].isNull() && evt.stateKey() == evt.senderId()) { - auto previousDisplayName = evt.unsignedJson()["prev_content"_ls]["displayname"_ls].toString().toHtmlEscaped(); - if (previousDisplayName.isEmpty()) { - previousDisplayName = evt.senderId(); - } - return previousDisplayName; - } else { - auto author = isPending ? m_currentRoom->localUser() : m_currentRoom->user(evt.senderId()); - return m_currentRoom->htmlSafeMemberName(author->id()); - } + return eventHandler.getAuthorDisplayName(isPending); } if (role == IsRedactedRole) { @@ -913,195 +703,27 @@ int MessageEventModel::eventIdToRow(const QString &eventID) const return it - m_currentRoom->messageEvents().rbegin() + timelineBaseIndex(); } -QVariantMap MessageEventModel::getMediaInfoForEvent(const RoomEvent &event) const -{ - QVariantMap mediaInfo; - - QString eventId = event.id(); - - // Get the file info for the event. - const EventContent::FileInfo *fileInfo; - if (event.is()) { - auto roomMessageEvent = eventCast(&event); - if (!roomMessageEvent->hasFileContent()) { - return {}; - } - fileInfo = roomMessageEvent->content()->fileInfo(); - } else if (event.is()) { - auto stickerEvent = eventCast(&event); - fileInfo = &stickerEvent->image(); - } else { - return {}; - } - - return getMediaInfoFromFileInfo(fileInfo, eventId); -} - -QVariantMap MessageEventModel::getMediaInfoFromFileInfo(const EventContent::FileInfo *fileInfo, const QString &eventId, bool isThumbnail) const -{ - QVariantMap mediaInfo; - - // Get the mxc URL for the media. - if (!fileInfo->url().isValid() || eventId.isEmpty()) { - mediaInfo["source"_ls] = QUrl(); - } else { - QUrl source = m_currentRoom->makeMediaUrl(eventId, fileInfo->url()); - - if (source.isValid() && source.scheme() == QStringLiteral("mxc")) { - mediaInfo["source"_ls] = source; - } else { - mediaInfo["source"_ls] = QUrl(); - } - } - - auto mimeType = fileInfo->mimeType; - // Add the MIME type for the media if available. - mediaInfo["mimeType"_ls] = mimeType.name(); - - // Add the MIME type icon if available. - mediaInfo["mimeIcon"_ls] = mimeType.iconName(); - - // Add media size if available. - mediaInfo["size"_ls] = fileInfo->payloadSize; - - // Add parameter depending on media type. - if (mimeType.name().contains(QStringLiteral("image"))) { - if (auto castInfo = static_cast(fileInfo)) { - mediaInfo["width"_ls] = castInfo->imageSize.width(); - mediaInfo["height"_ls] = castInfo->imageSize.height(); - - // TODO: Images in certain formats (e.g. WebP) will be erroneously marked as animated, even if they are static. - mediaInfo["animated"_ls] = QMovie::supportedFormats().contains(mimeType.preferredSuffix().toUtf8()); - - if (!isThumbnail) { - QVariantMap tempInfo; - auto thumbnailInfo = getMediaInfoFromFileInfo(castInfo->thumbnailInfo(), eventId, true); - if (thumbnailInfo["source"_ls].toUrl().scheme() == "mxc"_ls) { - tempInfo = thumbnailInfo; - } else { - QString blurhash = castInfo->originalInfoJson["xyz.amorgan.blurhash"_ls].toString(); - if (blurhash.isEmpty()) { - tempInfo["source"_ls] = QUrl(); - } else { - tempInfo["source"_ls] = QUrl("image://blurhash/"_ls + blurhash); - } - } - mediaInfo["tempInfo"_ls] = tempInfo; - } - } - } - if (mimeType.name().contains(QStringLiteral("video"))) { - if (auto castInfo = static_cast(fileInfo)) { - mediaInfo["width"_ls] = castInfo->imageSize.width(); - mediaInfo["height"_ls] = castInfo->imageSize.height(); - mediaInfo["duration"_ls] = castInfo->duration; - - if (!isThumbnail) { - QVariantMap tempInfo; - auto thumbnailInfo = getMediaInfoFromFileInfo(castInfo->thumbnailInfo(), eventId, true); - if (thumbnailInfo["source"_ls].toUrl().scheme() == "mxc"_ls) { - tempInfo = thumbnailInfo; - } else { - QString blurhash = castInfo->originalInfoJson["xyz.amorgan.blurhash"_ls].toString(); - if (blurhash.isEmpty()) { - tempInfo["source"_ls] = QUrl(); - } else { - tempInfo["source"_ls] = QUrl("image://blurhash/"_ls + blurhash); - } - } - mediaInfo["tempInfo"_ls] = tempInfo; - } - } - } - if (mimeType.name().contains(QStringLiteral("audio"))) { - if (auto castInfo = static_cast(fileInfo)) { - mediaInfo["duration"_ls] = castInfo->duration; - } - } - - return mediaInfo; -} - -void MessageEventModel::createLinkPreviewerForEvent(const Quotient::RoomMessageEvent *event) -{ - if (m_linkPreviewers.contains(event->id())) { - return; - } else { - QString text; - if (event->hasTextContent()) { - auto textContent = static_cast(event->content()); - if (textContent) { - text = textContent->body; - } else { - text = event->plainBody(); - } - } else { - text = event->plainBody(); - } - TextHandler textHandler; - textHandler.setData(text); - - QList links = textHandler.getLinkPreviews(); - if (links.size() > 0) { - m_linkPreviewers[event->id()] = new LinkPreviewer(nullptr, m_currentRoom, links.size() > 0 ? links[0] : QUrl()); - } - } -} - -void MessageEventModel::createReactionModelForEvent(const Quotient::RoomMessageEvent *event) +void MessageEventModel::createEventObjects(const Quotient::RoomMessageEvent *event) { if (event == nullptr) { return; } - auto eventId = event->id(); - const auto &annotations = m_currentRoom->relatedEvents(eventId, EventRelation::AnnotationType); - if (annotations.isEmpty()) { - if (m_reactionModels.contains(eventId)) { - delete m_reactionModels[eventId]; - m_reactionModels.remove(eventId); - } - return; - }; - QMap> reactions = {}; - for (const auto &a : annotations) { - if (a->isRedacted()) { // Just in case? - continue; - } - if (const auto &e = eventCast(a)) { - reactions[e->key()].append(m_currentRoom->user(e->senderId())); - } - } + auto eventId = event->id(); - if (reactions.isEmpty()) { - if (m_reactionModels.contains(eventId)) { - delete m_reactionModels[eventId]; - m_reactionModels.remove(eventId); - } - return; - } + EventHandler eventHandler; + eventHandler.setRoom(m_currentRoom); + eventHandler.setEvent(event); - QList res; - auto i = reactions.constBegin(); - while (i != reactions.constEnd()) { - QVariantList authors; - for (const auto &author : i.value()) { - authors.append(m_currentRoom->getUser(author)); - } - - res.append(ReactionModel::Reaction{i.key(), authors}); - ++i; + if (auto linkPreviewer = eventHandler.getLinkPreviewer()) { + m_linkPreviewers[eventId] = linkPreviewer; + } else { + m_linkPreviewers.remove(eventId); } - - if (m_reactionModels.contains(eventId)) { - m_reactionModels[eventId]->setReactions(res); - } else if (res.size() > 0) { - m_reactionModels[eventId] = new ReactionModel(this, res, m_currentRoom->localUser()); + if (auto reactionModel = eventHandler.getReactions()) { + m_reactionModels[eventId] = reactionModel; } else { - if (m_reactionModels.contains(eventId)) { - delete m_reactionModels[eventId]; - m_reactionModels.remove(eventId); - } + m_reactionModels.remove(eventId); } } diff --git a/src/models/messageeventmodel.h b/src/models/messageeventmodel.h index 522518edc..54a11c95a 100644 --- a/src/models/messageeventmodel.h +++ b/src/models/messageeventmodel.h @@ -32,32 +32,6 @@ class MessageEventModel : public QAbstractListModel Q_PROPERTY(NeoChatRoom *room READ room WRITE setRoom NOTIFY roomChanged) public: - /** - * @brief The type of delegate that is needed for the event. - * - * @note While similar this is not the matrix event or message type. This is - * to tell a QML ListView what delegate to show for each event. So while - * similar to the spec it is not the same. - */ - enum DelegateType { - Emote, /**< A message that begins with /me. */ - Notice, /**< A notice event. */ - Image, /**< A message that is an image. */ - Audio, /**< A message that is an audio recording. */ - Video, /**< A message that is a video. */ - File, /**< A message that is a file. */ - Message, /**< A text message. */ - Sticker, /**< A message that is a sticker. */ - State, /**< A state event in the room. */ - Encrypted, /**< An encrypted message that cannot be decrypted. */ - ReadMarker, /**< The local user read marker. */ - Poll, /**< The initial event for a poll. */ - Location, /**< A location event. */ - LiveLocation, /**< The initial event of a shared live location (i.e., the place where this is supposed to be shown in the timeline). */ - Other, /**< Anything that cannot be classified as another type. */ - }; - Q_ENUM(DelegateType) - /** * @brief Defines the model roles. */ @@ -65,7 +39,8 @@ class MessageEventModel : public QAbstractListModel DelegateTypeRole = Qt::UserRole + 1, /**< The delegate type of the message. */ PlainText, /**< Plain text representation of the message. */ EventIdRole, /**< The matrix event ID of the event. */ - TimeRole, /**< The timestamp for when the event was sent. */ + 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). */ SectionRole, /**< The date of the event as a string. */ AuthorRole, /**< The author of the event. */ ContentRole, /**< The full message content. */ @@ -78,13 +53,13 @@ class MessageEventModel : public QAbstractListModel LinkPreviewRole, /**< The link preview details. */ MediaInfoRole, /**< The media info for the event. */ - MimeTypeRole, /**< The mime type of the message's file or media. */ IsReplyRole, /**< Is the message a reply to another event. */ ReplyAuthor, /**< The author of the event that was replied to. */ ReplyIdRole, /**< The matrix ID of the message that was replied to. */ + ReplyDelegateTypeRole, /**< The delegate type of the message that was replied to. */ + ReplyDisplayRole, /**< The body of the message that was replied to. */ ReplyMediaInfoRole, /**< The media info of the message that was replied to. */ - ReplyRole, /**< The content data of the message that was replied to. */ ShowAuthorRole, /**< Whether the author's name should be shown. */ ShowSectionRole, /**< Whether the section header should be shown. */ @@ -100,7 +75,6 @@ class MessageEventModel : public QAbstractListModel AuthorIdRole, /**< Matrix ID of the message author. */ VerifiedRole, /**< Whether an encrypted message is sent in a verified session. */ - DisplayNameForInitialsRole, /**< Sender's displayname, always without the matrix id. */ AuthorDisplayNameRole, /**< The displayname for the event's sender; for name change events, the old displayname. */ IsRedactedRole, /**< Whether an event has been deleted. */ IsPendingRole, /**< Whether an event is waiting to be accepted by the server. */ @@ -154,8 +128,8 @@ private Q_SLOTS: bool movingEvent = false; KFormat m_format; - QMap m_linkPreviewers; - QMap m_reactionModels; + QMap> m_linkPreviewers; + QMap> m_reactionModels; [[nodiscard]] int timelineBaseIndex() const; [[nodiscard]] QDateTime makeMessageTimestamp(const Quotient::Room::rev_iter_t &baseIt) const; @@ -168,10 +142,7 @@ private Q_SLOTS: int refreshEventRoles(const QString &eventId, const QVector &roles = {}); void moveReadMarker(const QString &toEventId); - QVariantMap getMediaInfoForEvent(const Quotient::RoomEvent &event) const; - QVariantMap getMediaInfoFromFileInfo(const Quotient::EventContent::FileInfo *fileInfo, const QString &eventId, bool isThumbnail = false) const; - void createLinkPreviewerForEvent(const Quotient::RoomMessageEvent *event); - void createReactionModelForEvent(const Quotient::RoomMessageEvent *event); + void createEventObjects(const Quotient::RoomMessageEvent *event); // Hack to ensure that we don't call endInsertRows when we haven't called beginInsertRows bool m_initialized = false; diff --git a/src/models/messagefiltermodel.cpp b/src/models/messagefiltermodel.cpp index 12e7feb21..471b680b1 100644 --- a/src/models/messagefiltermodel.cpp +++ b/src/models/messagefiltermodel.cpp @@ -5,6 +5,7 @@ #include +#include "enums/delegatetype.h" #include "messageeventmodel.h" #include "neochatconfig.h" @@ -50,7 +51,7 @@ bool MessageFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex &sour // Don't show events with an unknown type. const auto eventType = index.data(MessageEventModel::DelegateTypeRole).toInt(); - if (eventType == MessageEventModel::Other) { + if (eventType == DelegateType::Other) { return false; } @@ -58,10 +59,10 @@ bool MessageFilterModel::filterAcceptsRow(int sourceRow, const QModelIndex &sour // same day as they will be grouped as a single delegate. const bool notLastRow = sourceRow < sourceModel()->rowCount() - 1; const bool previousEventIsState = notLastRow - ? sourceModel()->data(sourceModel()->index(sourceRow + 1, 0), MessageEventModel::DelegateTypeRole) == MessageEventModel::DelegateType::State + ? sourceModel()->data(sourceModel()->index(sourceRow + 1, 0), MessageEventModel::DelegateTypeRole) == DelegateType::State : false; const bool newDay = sourceModel()->data(sourceModel()->index(sourceRow, 0), MessageEventModel::ShowSectionRole).toBool(); - if (eventType == MessageEventModel::State && notLastRow && previousEventIsState && !newDay) { + if (eventType == DelegateType::State && notLastRow && previousEventIsState && !newDay) { return false; } @@ -103,8 +104,7 @@ QString MessageFilterModel::aggregateEventToString(int sourceRow) const uniqueAuthors.append(nextAuthor); } if (i > 0 - && (sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::DelegateTypeRole) - != MessageEventModel::DelegateType::State // If it's not a state event + && (sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::DelegateTypeRole) != DelegateType::State // If it's not a state event || sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::ShowSectionRole).toBool() // or the section needs to be visible )) { break; @@ -162,8 +162,7 @@ QVariantList MessageFilterModel::stateEventsList(int sourceRow) const }; stateEvents.append(nextState); if (i > 0 - && (sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::DelegateTypeRole) - != MessageEventModel::DelegateType::State // If it's not a state event + && (sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::DelegateTypeRole) != DelegateType::State // If it's not a state event || sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::ShowSectionRole).toBool() // or the section needs to be visible )) { break; @@ -181,8 +180,7 @@ QVariantList MessageFilterModel::authorList(int sourceRow) const uniqueAuthors.append(nextAvatar); } if (i > 0 - && (sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::DelegateTypeRole) - != MessageEventModel::DelegateType::State // If it's not a state event + && (sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::DelegateTypeRole) != DelegateType::State // If it's not a state event || sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::ShowSectionRole).toBool() // or the section needs to be visible )) { break; @@ -204,8 +202,7 @@ QString MessageFilterModel::excessAuthors(int row) const uniqueAuthors.append(nextAvatar); } if (i > 0 - && (sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::DelegateTypeRole) - != MessageEventModel::DelegateType::State // If it's not a state event + && (sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::DelegateTypeRole) != DelegateType::State // If it's not a state event || sourceModel()->data(sourceModel()->index(i - 1, 0), MessageEventModel::ShowSectionRole).toBool() // or the section needs to be visible )) { break; diff --git a/src/models/searchmodel.cpp b/src/models/searchmodel.cpp index 0335b8b85..bc137c92b 100644 --- a/src/models/searchmodel.cpp +++ b/src/models/searchmodel.cpp @@ -3,6 +3,7 @@ #include "searchmodel.h" +#include "eventhandler.h" #include "messageeventmodel.h" #include "neochatroom.h" @@ -91,85 +92,55 @@ QVariant SearchModel::data(const QModelIndex &index, int role) const { auto row = index.row(); const auto &event = *m_result->results[row].result; + + EventHandler eventHandler; + eventHandler.setRoom(m_room); + eventHandler.setEvent(&event); + switch (role) { case DisplayRole: - return m_room->eventToString(*m_result->results[row].result); + return eventHandler.getRichBody(); case ShowAuthorRole: return true; case AuthorRole: - return m_room->getUser(event.senderId()); + return eventHandler.getAuthor(); case ShowSectionRole: if (row == 0) { return true; } return event.originTimestamp().date() != m_result->results[row - 1].result->originTimestamp().date(); case SectionRole: - return renderDate(event.originTimestamp()); + return eventHandler.getTimeString(true); case TimeRole: - return event.originTimestamp(); + return eventHandler.getTime(); + case TimeStringRole: + return eventHandler.getTimeString(false); case ShowReactionsRole: return false; case ShowReadMarkersRole: return false; + case IsReplyRole: + return eventHandler.hasReply(); + case ReplyIdRole: + return eventHandler.hasReply(); case ReplyAuthorRole: - if (const auto &replyPtr = m_room->getReplyForEvent(event)) { - return m_room->getUser(m_room->user(replyPtr->senderId())); - } else { - return m_room->getUser(nullptr); - } - case ReplyRole: - if (auto replyPtr = m_room->getReplyForEvent(event)) { - MessageEventModel::DelegateType type; - if (auto e = eventCast(replyPtr)) { - switch (e->msgtype()) { - case MessageEventType::Emote: - type = MessageEventModel::DelegateType::Emote; - break; - case MessageEventType::Notice: - type = MessageEventModel::DelegateType::Notice; - break; - case MessageEventType::Image: - type = MessageEventModel::DelegateType::Image; - break; - case MessageEventType::Audio: - type = MessageEventModel::DelegateType::Audio; - break; - case MessageEventType::Video: - type = MessageEventModel::DelegateType::Video; - break; - default: - if (e->hasFileContent()) { - type = MessageEventModel::DelegateType::File; - break; - } - type = MessageEventModel::DelegateType::Message; - } - - } else if (is(*replyPtr)) { - type = MessageEventModel::DelegateType::Sticker; - } else { - type = MessageEventModel::DelegateType::Other; - } - return QVariantMap{ - {"display"_ls, m_room->eventToString(*replyPtr, Qt::RichText)}, - {"type"_ls, type}, - }; - } - break; + return eventHandler.getReplyAuthor(); + case ReplyDelegateTypeRole: + return eventHandler.getReplyDelegateType(); + case ReplyDisplayRole: + return eventHandler.getReplyRichBody(); + case ReplyMediaInfoRole: + return eventHandler.getReplyMediaInfo(); case IsPendingRole: return false; case ShowLinkPreviewRole: return false; - case IsReplyRole: - return !event.contentJson()["m.relates_to"_ls].toObject()["m.in_reply_to"_ls].toObject()["event_id"_ls].toString().isEmpty(); case HighlightRole: - return !m_room->isDirectChat() && m_room->isEventHighlighted(&event); + return eventHandler.isHighlighted(); case EventIdRole: - return event.id(); - case ReplyIdRole: - return event.contentJson()["m.relates_to"_ls].toObject()["m.in_reply_to"_ls].toObject()["event_id"_ls].toString(); + return eventHandler.getId(); } - return MessageEventModel::DelegateType::Message; + return DelegateType::Message; } int SearchModel::rowCount(const QModelIndex &parent) const @@ -190,6 +161,7 @@ QHash SearchModel::roleNames() const {ShowSectionRole, "showSection"}, {SectionRole, "section"}, {TimeRole, "time"}, + {TimeStringRole, "timeString"}, {ShowAuthorRole, "showAuthor"}, {EventIdRole, "eventId"}, {ExcessReadMarkersRole, "excessReadMarkers"}, @@ -197,21 +169,22 @@ QHash SearchModel::roleNames() const {ReadMarkersString, "readMarkersString"}, {PlainTextRole, "plainText"}, {VerifiedRole, "verified"}, - {ReplyAuthorRole, "replyAuthor"}, {ProgressInfoRole, "progressInfo"}, - {IsReplyRole, "isReply"}, {ShowReactionsRole, "showReactions"}, - {ReplyRole, "reply"}, - {ReactionRole, "reaction"}, + {IsReplyRole, "isReply"}, + {ReplyAuthorRole, "replyAuthor"}, + {ReplyIdRole, "replyId"}, + {ReplyDelegateTypeRole, "replyDelegateType"}, + {ReplyDisplayRole, "replyDisplay"}, {ReplyMediaInfoRole, "replyMediaInfo"}, + {ReactionRole, "reaction"}, {ReadMarkersRole, "readMarkers"}, {IsPendingRole, "isPending"}, {ShowReadMarkersRole, "showReadMarkers"}, - {ReplyIdRole, "replyId"}, {MimeTypeRole, "mimeType"}, {ShowLinkPreviewRole, "showLinkPreview"}, {LinkPreviewRole, "linkPreview"}, - {SourceRole, "source"}, + {SourceRole, "jsonSource"}, }; } @@ -238,30 +211,10 @@ void SearchModel::setRoom(NeoChatRoom *room) return; } auto row = it - results.begin(); - Q_EMIT dataChanged(index(row, 0), index(row, 0), {ReplyRole, ReplyMediaInfoRole, ReplyAuthorRole}); + Q_EMIT dataChanged(index(row, 0), index(row, 0), {ReplyDelegateTypeRole, ReplyDisplayRole, ReplyMediaInfoRole, ReplyAuthorRole}); }); } -// TODO deduplicate with messageeventmodel -QString renderDate(const QDateTime ×tamp) -{ - auto date = timestamp.toLocalTime().date(); - if (date == QDate::currentDate()) { - return i18n("Today"); - } - if (date == QDate::currentDate().addDays(-1)) { - return i18n("Yesterday"); - } - if (date == QDate::currentDate().addDays(-2)) { - return i18n("The day before yesterday"); - } - if (date > QDate::currentDate().addDays(-7)) { - return date.toString("dddd"_ls); - } - - return QLocale::system().toString(date, QLocale::ShortFormat); -} - bool SearchModel::searching() const { return m_searching; diff --git a/src/models/searchmodel.h b/src/models/searchmodel.h index 624ad348e..74c110895 100644 --- a/src/models/searchmodel.h +++ b/src/models/searchmodel.h @@ -61,23 +61,25 @@ class SearchModel : public QAbstractListModel ShowSectionRole, SectionRole, TimeRole, + TimeStringRole, EventIdRole, ExcessReadMarkersRole, HighlightRole, ReadMarkersString, PlainTextRole, VerifiedRole, - ReplyAuthorRole, ProgressInfoRole, - IsReplyRole, ShowReactionsRole, - ReplyRole, - ReactionRole, + IsReplyRole, + ReplyAuthorRole, + ReplyIdRole, + ReplyDelegateTypeRole, + ReplyDisplayRole, ReplyMediaInfoRole, + ReactionRole, ReadMarkersRole, IsPendingRole, ShowReadMarkersRole, - ReplyIdRole, MimeTypeRole, ShowLinkPreviewRole, LinkPreviewRole, @@ -139,5 +141,3 @@ class SearchModel : public QAbstractListModel Quotient::SearchJob *m_job = nullptr; bool m_searching = false; }; - -QString renderDate(const QDateTime &dateTime); diff --git a/src/neochatroom.cpp b/src/neochatroom.cpp index db8722690..046fc91f5 100644 --- a/src/neochatroom.cpp +++ b/src/neochatroom.cpp @@ -40,6 +40,7 @@ #include #include "controller.h" +#include "eventhandler.h" #include "events/joinrulesevent.h" #include "events/pollevent.h" #include "filetransferpseudojob.h" @@ -344,8 +345,18 @@ bool NeoChatRoom::lastEventIsSpoiler() const QString NeoChatRoom::lastEventToString(Qt::TextFormat format, bool stripNewlines) const { if (auto event = lastEvent()) { - return safeMemberName(event->senderId()) + (event->isStateEvent() ? QLatin1String(" ") : QLatin1String(": ")) - + eventToString(*event, format, stripNewlines); + EventHandler eventHandler; + eventHandler.setRoom(this); + eventHandler.setEvent(event); + + QString body; + if (format == Qt::TextFormat::RichText) { + body = eventHandler.getRichBody(stripNewlines); + } else { + body = eventHandler.getPlainBody(stripNewlines); + } + + return safeMemberName(event->senderId()) + (event->isStateEvent() ? QLatin1String(" ") : QLatin1String(": ")) + body; } return {}; } @@ -483,318 +494,6 @@ QString NeoChatRoom::avatarMediaId() const return {}; } -QString NeoChatRoom::eventToString(const RoomEvent &evt, Qt::TextFormat format, bool stripNewlines) const -{ - const bool prettyPrint = (format == Qt::RichText); - - using namespace Quotient; - return switchOnType( - evt, - [this, format, stripNewlines](const RoomMessageEvent &e) { - using namespace MessageEventContent; - - TextHandler textHandler; - - if (e.hasFileContent()) { - auto fileCaption = e.content()->fileInfo()->originalName; - if (fileCaption.isEmpty()) { - fileCaption = e.plainBody(); - } else if (e.content()->fileInfo()->originalName != e.plainBody()) { - fileCaption = e.plainBody() + " | "_ls + fileCaption; - } - textHandler.setData(fileCaption); - return !fileCaption.isEmpty() ? textHandler.handleRecievePlainText(Qt::PlainText, stripNewlines) : i18n("a file"); - } - - QString body; - if (e.hasTextContent() && e.content()) { - body = static_cast(e.content())->body; - } else { - body = e.plainBody(); - } - - textHandler.setData(body); - - Qt::TextFormat inputFormat; - if (e.mimeType().name() == "text/plain"_ls) { - inputFormat = Qt::PlainText; - } else { - inputFormat = Qt::RichText; - } - - if (format == Qt::RichText) { - return textHandler.handleRecieveRichText(inputFormat, this, &e, stripNewlines); - } else { - return textHandler.handleRecievePlainText(inputFormat, stripNewlines); - } - }, - [](const StickerEvent &e) { - return e.body(); - }, - [this, prettyPrint](const RoomMemberEvent &e) { - // FIXME: Rewind to the name that was at the time of this event - auto subjectName = this->htmlSafeMemberName(e.userId()); - if (e.membership() == Membership::Leave) { - if (e.prevContent() && e.prevContent()->displayName) { - subjectName = sanitized(*e.prevContent()->displayName).toHtmlEscaped(); - } - } - - if (prettyPrint) { - subjectName = QStringLiteral("%3") - .arg(e.userId(), Utils::getUserColor(user(e.userId())->hueF()).name(), subjectName); - } - - // The below code assumes senderName output in AuthorRole - switch (e.membership()) { - case Membership::Invite: - if (e.repeatsState()) { - auto text = i18n("reinvited %1 to the room", subjectName); - if (!e.reason().isEmpty()) { - text += i18nc("Optional reason for an invitation", ": %1") + e.reason().toHtmlEscaped(); - } - return text; - } - Q_FALLTHROUGH(); - case Membership::Join: { - QString text{}; - // Part 1: invites and joins - if (e.repeatsState()) { - text = i18n("joined the room (repeated)"); - } else if (e.changesMembership()) { - text = e.membership() == Membership::Invite ? i18n("invited %1 to the room", subjectName) : i18n("joined the room"); - } - if (!text.isEmpty()) { - if (!e.reason().isEmpty()) { - text += i18n(": %1", e.reason().toHtmlEscaped()); - } - return text; - } - // Part 2: profile changes of joined members - if (e.isRename()) { - if (!e.newDisplayName()) { - text = i18nc("their refers to a singular user", "cleared their display name"); - } else { - text = i18nc("their refers to a singular user", "changed their display name to %1", e.newDisplayName()->toHtmlEscaped()); - } - } - if (e.isAvatarUpdate()) { - if (!text.isEmpty()) { - text += i18n(" and "); - } - if (!e.newAvatarUrl()) { - text += i18nc("their refers to a singular user", "cleared their avatar"); - } else if (!e.prevContent()->avatarUrl) { - text += i18n("set an avatar"); - } else { - text += i18nc("their refers to a singular user", "updated their avatar"); - } - } - if (text.isEmpty()) { - text = i18nc(" changed nothing", "changed nothing"); - } - return text; - } - case Membership::Leave: - if (e.prevContent() && e.prevContent()->membership == Membership::Invite) { - return (e.senderId() != e.userId()) ? i18n("withdrew %1's invitation", subjectName) : i18n("rejected the invitation"); - } - - if (e.prevContent() && e.prevContent()->membership == Membership::Ban) { - return (e.senderId() != e.userId()) ? i18n("unbanned %1", subjectName) : i18n("self-unbanned"); - } - return (e.senderId() != e.userId()) - ? i18n("has put %1 out of the room: %2", subjectName, e.contentJson()["reason"_ls].toString().toHtmlEscaped()) - : i18n("left the room"); - case Membership::Ban: - if (e.senderId() != e.userId()) { - if (e.reason().isEmpty()) { - return i18n("banned %1 from the room", subjectName); - } else { - return i18n("banned %1 from the room: %2", subjectName, e.reason().toHtmlEscaped()); - } - } else { - return i18n("self-banned from the room"); - } - case Membership::Knock: { - QString reason(e.contentJson()["reason"_ls].toString().toHtmlEscaped()); - return reason.isEmpty() ? i18n("requested an invite") : i18n("requested an invite with reason: %1", reason); - } - default:; - } - return i18n("made something unknown"); - }, - [](const RoomCanonicalAliasEvent &e) { - return (e.alias().isEmpty()) ? i18n("cleared the room main alias") : i18n("set the room main alias to: %1", e.alias()); - }, - [](const RoomNameEvent &e) { - return (e.name().isEmpty()) ? i18n("cleared the room name") : i18n("set the room name to: %1", e.name().toHtmlEscaped()); - }, - [prettyPrint, stripNewlines](const RoomTopicEvent &e) { - return (e.topic().isEmpty()) ? i18n("cleared the topic") - : i18n("set the topic to: %1", - prettyPrint ? Quotient::prettyPrint(e.topic()) - : stripNewlines ? e.topic().replace(u'\n', u' ') - : e.topic()); - }, - [](const RoomAvatarEvent &) { - return i18n("changed the room avatar"); - }, - [](const EncryptionEvent &) { - return i18n("activated End-to-End Encryption"); - }, - [](const RoomCreateEvent &e) { - return e.isUpgrade() ? i18n("upgraded the room to version %1", e.version().isEmpty() ? "1"_ls : e.version().toHtmlEscaped()) - : i18n("created the room, version %1", e.version().isEmpty() ? "1"_ls : e.version().toHtmlEscaped()); - }, - [](const RoomPowerLevelsEvent &) { - return i18nc("'power level' means permission level", "changed the power levels for this room"); - }, - [](const StateEvent &e) { - if (e.matrixType() == QLatin1String("m.room.server_acl")) { - return i18n("changed the server access control lists for this room"); - } - if (e.matrixType() == QLatin1String("im.vector.modular.widgets")) { - if (e.fullJson()["unsigned"_ls]["prev_content"_ls].toObject().isEmpty()) { - return i18nc("[User] added widget", "added %1 widget", e.contentJson()["name"_ls].toString()); - } - if (e.contentJson().isEmpty()) { - return i18nc("[User] removed widget", "removed %1 widget", e.fullJson()["unsigned"_ls]["prev_content"_ls]["name"_ls].toString()); - } - return i18nc("[User] configured widget", "configured %1 widget", e.contentJson()["name"_ls].toString()); - } - if (e.matrixType() == "org.matrix.msc3672.beacon_info"_ls) { - return e.contentJson()["description"_ls].toString(); - } - return e.stateKey().isEmpty() ? i18n("updated %1 state", e.matrixType()) - : i18n("updated %1 state for %2", e.matrixType(), e.stateKey().toHtmlEscaped()); - }, - [](const PollStartEvent &e) { - return e.question(); - }, - i18n("Unknown event")); -} - -QString NeoChatRoom::eventToGenericString(const RoomEvent &evt) const -{ - return switchOnType( - evt, - [](const RoomMessageEvent &e) { - Q_UNUSED(e) - return i18n("sent a message"); - }, - [](const StickerEvent &e) { - Q_UNUSED(e) - return i18n("sent a sticker"); - }, - [](const RoomMemberEvent &e) { - switch (e.membership()) { - case Membership::Invite: - if (e.repeatsState()) { - return i18n("reinvited someone to the room"); - } - Q_FALLTHROUGH(); - case Membership::Join: { - QString text{}; - // Part 1: invites and joins - if (e.repeatsState()) { - text = i18n("joined the room (repeated)"); - } else if (e.changesMembership()) { - text = e.membership() == Membership::Invite ? i18n("invited someone to the room") : i18n("joined the room"); - } - if (!text.isEmpty()) { - return text; - } - // Part 2: profile changes of joined members - if (e.isRename()) { - if (!e.newDisplayName()) { - text = i18nc("their refers to a singular user", "cleared their display name"); - } else { - text = i18nc("their refers to a singular user", "changed their display name"); - } - } - if (e.isAvatarUpdate()) { - if (!text.isEmpty()) { - text += i18n(" and "); - } - if (!e.newAvatarUrl()) { - text += i18nc("their refers to a singular user", "cleared their avatar"); - } else if (!e.prevContent()->avatarUrl) { - text += i18n("set an avatar"); - } else { - text += i18nc("their refers to a singular user", "updated their avatar"); - } - } - if (text.isEmpty()) { - text = i18nc(" changed nothing", "changed nothing"); - } - return text; - } - case Membership::Leave: - if (e.prevContent() && e.prevContent()->membership == Membership::Invite) { - return (e.senderId() != e.userId()) ? i18n("withdrew a user's invitation") : i18n("rejected the invitation"); - } - - if (e.prevContent() && e.prevContent()->membership == Membership::Ban) { - return (e.senderId() != e.userId()) ? i18n("unbanned a user") : i18n("self-unbanned"); - } - return (e.senderId() != e.userId()) ? i18n("put a user out of the room") : i18n("left the room"); - case Membership::Ban: - if (e.senderId() != e.userId()) { - return i18n("banned a user from the room"); - } else { - return i18n("self-banned from the room"); - } - case Membership::Knock: { - return i18n("requested an invite"); - } - default:; - } - return i18n("made something unknown"); - }, - [](const RoomCanonicalAliasEvent &e) { - return (e.alias().isEmpty()) ? i18n("cleared the room main alias") : i18n("set the room main alias"); - }, - [](const RoomNameEvent &e) { - return (e.name().isEmpty()) ? i18n("cleared the room name") : i18n("set the room name"); - }, - [](const RoomTopicEvent &e) { - return (e.topic().isEmpty()) ? i18n("cleared the topic") : i18n("set the topic"); - }, - [](const RoomAvatarEvent &) { - return i18n("changed the room avatar"); - }, - [](const EncryptionEvent &) { - return i18n("activated End-to-End Encryption"); - }, - [](const RoomCreateEvent &e) { - return e.isUpgrade() ? i18n("upgraded the room version") : i18n("created the room"); - }, - [](const RoomPowerLevelsEvent &) { - return i18nc("'power level' means permission level", "changed the power levels for this room"); - }, - [](const StateEvent &e) { - if (e.matrixType() == QLatin1String("m.room.server_acl")) { - return i18n("changed the server access control lists for this room"); - } - if (e.matrixType() == QLatin1String("im.vector.modular.widgets")) { - if (e.fullJson()["unsigned"_ls]["prev_content"_ls].toObject().isEmpty()) { - return i18n("added a widget"); - } - if (e.contentJson().isEmpty()) { - return i18n("removed a widget"); - } - return i18n("configured a widget"); - } - return i18n("updated the state"); - }, - [](const PollStartEvent &e) { - Q_UNUSED(e); - return i18n("started a poll"); - }, - i18n("Unknown event")); -} - void NeoChatRoom::changeAvatar(const QUrl &localFile) { const auto job = connection()->uploadFile(localFile.toLocalFile()); @@ -861,10 +560,14 @@ void NeoChatRoom::postHtmlMessage(const QString &text, const QString &html, Mess if (isReply) { const auto &replyEvt = **replyIt; + EventHandler eventHandler; + eventHandler.setRoom(this); + eventHandler.setEvent(&**replyIt); + // clang-format off QJsonObject json{ {"msgtype"_ls, msgTypeToString(type)}, - {"body"_ls, "> <%1> %2\n\n%3"_ls.arg(replyEvt.senderId(), eventToString(replyEvt), text)}, + {"body"_ls, "> <%1> %2\n\n%3"_ls.arg(replyEvt.senderId(), eventHandler.getPlainBody(), text)}, {"format"_ls, "org.matrix.custom.html"_ls}, {"m.relates_to"_ls, QJsonObject { @@ -876,7 +579,7 @@ void NeoChatRoom::postHtmlMessage(const QString &text, const QString &html, Mess } }, {"formatted_body"_ls, - "
In reply to %4
%5
%6"_ls.arg(id(), replyEventId, replyEvt.senderId(), replyEvt.senderId(), eventToString(replyEvt, Qt::RichText), html) + "
In reply to %4
%5
%6"_ls.arg(id(), replyEventId, replyEvt.senderId(), replyEvt.senderId(), eventHandler.getRichBody(), html) } }; // clang-format on @@ -1701,7 +1404,11 @@ QString NeoChatRoom::chatBoxReplyMessage() const if (m_chatBoxReplyId.isEmpty()) { return {}; } - return eventToString(*static_cast(&**findInTimeline(m_chatBoxReplyId))); + + EventHandler eventhandler; + eventhandler.setRoom(this); + eventhandler.setEvent(&**findInTimeline(m_chatBoxReplyId)); + return eventhandler.getPlainBody(); } QVariantMap NeoChatRoom::chatBoxEditUser() const @@ -1717,7 +1424,11 @@ QString NeoChatRoom::chatBoxEditMessage() const if (m_chatBoxEditId.isEmpty()) { return {}; } - return eventToString(*static_cast(&**findInTimeline(m_chatBoxEditId))); + + EventHandler eventhandler; + eventhandler.setRoom(this); + eventhandler.setEvent(&**findInTimeline(m_chatBoxEditId)); + return eventhandler.getPlainBody(); } QString NeoChatRoom::chatBoxAttachmentPath() const diff --git a/src/neochatroom.h b/src/neochatroom.h index b60a68519..32e42fee6 100644 --- a/src/neochatroom.h +++ b/src/neochatroom.h @@ -475,39 +475,6 @@ class NeoChatRoom : public Quotient::Room */ [[nodiscard]] const Quotient::RoomEvent *lastEvent() const; - /** - * @brief Output a string for the message content ready for display. - * - * The output string is dependant upon the event type and the desired output format. - * - * For most messages this is the body content of the message. For media messages - * This will be the caption and for state events it will be a string specific - * to that event with some dynamic details about the event added. - * - * E.g. For a room topic state event the text will be: - * "set the topic to: " - * - * @param evt the event for which a string is desired. - * @param format the output format, usually Qt::PlainText or Qt::RichText. - * @param stripNewlines whether the output should have new lines in it. - */ - [[nodiscard]] QString eventToString(const Quotient::RoomEvent &evt, Qt::TextFormat format = Qt::PlainText, bool stripNewlines = false) const; - - /** - * @brief Output a generic string for the message content ready for display. - * - * The output string is dependant upon the event type. - * - * Unlike NeoChatRoom::eventToString the string is the same for all events of - * the same type - * - * E.g. For a message the text will be: - * "sent a message" - * - * @sa eventToString() - */ - [[nodiscard]] QString eventToGenericString(const Quotient::RoomEvent &evt) const; - /** * @brief Convenient way to call eventToString on the last event. * diff --git a/src/qml/Component/NeochatMaximizeComponent.qml b/src/qml/Component/NeochatMaximizeComponent.qml index e85ac3c4d..4714d1cbc 100644 --- a/src/qml/Component/NeochatMaximizeComponent.qml +++ b/src/qml/Component/NeochatMaximizeComponent.qml @@ -87,7 +87,6 @@ Components.AlbumMaximizeComponent { eventId: root.currentEventId, source: root.currentJsonSource, file: parent, - mimeType: root.currentMimeType, progressInfo: root.currentProgressInfo, plainText: root.currentPlainText }); diff --git a/src/qml/Component/Timeline/EventDelegate.qml b/src/qml/Component/Timeline/EventDelegate.qml index 86ddbdbfd..5be57ce8a 100644 --- a/src/qml/Component/Timeline/EventDelegate.qml +++ b/src/qml/Component/Timeline/EventDelegate.qml @@ -14,78 +14,78 @@ DelegateChooser { property var room DelegateChoice { - roleValue: MessageEventModel.State + roleValue: DelegateType.State delegate: StateDelegate {} } DelegateChoice { - roleValue: MessageEventModel.Emote + roleValue: DelegateType.Emote delegate: MessageDelegate {} } DelegateChoice { - roleValue: MessageEventModel.Message + roleValue: DelegateType.Message delegate: MessageDelegate {} } DelegateChoice { - roleValue: MessageEventModel.Notice + roleValue: DelegateType.Notice delegate: MessageDelegate {} } DelegateChoice { - roleValue: MessageEventModel.Image + roleValue: DelegateType.Image delegate: ImageDelegate {} } DelegateChoice { - roleValue: MessageEventModel.Sticker + roleValue: DelegateType.Sticker delegate: ImageDelegate {} } DelegateChoice { - roleValue: MessageEventModel.Audio + roleValue: DelegateType.Audio delegate: AudioDelegate {} } DelegateChoice { - roleValue: MessageEventModel.Video + roleValue: DelegateType.Video delegate: VideoDelegate {} } DelegateChoice { - roleValue: MessageEventModel.File + roleValue: DelegateType.File delegate: FileDelegate {} } DelegateChoice { - roleValue: MessageEventModel.Encrypted + roleValue: DelegateType.Encrypted delegate: EncryptedDelegate {} } DelegateChoice { - roleValue: MessageEventModel.ReadMarker + roleValue: DelegateType.ReadMarker delegate: ReadMarkerDelegate {} } DelegateChoice { - roleValue: MessageEventModel.Poll + roleValue: DelegateType.Poll delegate: PollDelegate {} } DelegateChoice { - roleValue: MessageEventModel.Location + roleValue: DelegateType.Location delegate: LocationDelegate {} } DelegateChoice { - roleValue: MessageEventModel.LiveLocation + roleValue: DelegateType.LiveLocation delegate: LiveLocationDelegate { room: root.room } } DelegateChoice { - roleValue: MessageEventModel.Other + roleValue: DelegateType.Other delegate: Item {} } } diff --git a/src/qml/Component/Timeline/ReplyComponent.qml b/src/qml/Component/Timeline/ReplyComponent.qml index 6944f8396..2a68490d7 100644 --- a/src/qml/Component/Timeline/ReplyComponent.qml +++ b/src/qml/Component/Timeline/ReplyComponent.qml @@ -117,22 +117,22 @@ Item { id: loader Layout.fillWidth: true - Layout.maximumHeight: loader.item && (root.type == MessageEventModel.Image || root.type == MessageEventModel.Sticker) ? loader.item.height : -1 + Layout.maximumHeight: loader.item && (root.type == DelegateType.Image || root.type == DelegateType.Sticker) ? loader.item.height : -1 Layout.columnSpan: 2 sourceComponent: { switch (root.type) { - case MessageEventModel.Image: - case MessageEventModel.Sticker: + case DelegateType.Image: + case DelegateType.Sticker: return imageComponent; - case MessageEventModel.Message: - case MessageEventModel.Notice: + case DelegateType.Message: + case DelegateType.Notice: return textComponent; - case MessageEventModel.File: - case MessageEventModel.Video: - case MessageEventModel.Audio: + case DelegateType.File: + case DelegateType.Video: + case DelegateType.Audio: return mimeComponent; - case MessageEventModel.Encrypted: + case DelegateType.Encrypted: return encryptedComponent; default: return textComponent; @@ -187,7 +187,7 @@ Item { MimeComponent { mimeIconSource: root.mediaInfo.mimeIcon label: root.display - subLabel: root.type === MessageEventModel.File ? Controller.formatByteSize(root.mediaInfo.size) : Controller.formatDuration(root.mediaInfo.duration) + subLabel: root.type === DelegateType.File ? Controller.formatByteSize(root.mediaInfo.size) : Controller.formatDuration(root.mediaInfo.duration) } } Component { diff --git a/src/qml/Component/Timeline/TimelineContainer.qml b/src/qml/Component/Timeline/TimelineContainer.qml index 3abed8784..232e21c14 100644 --- a/src/qml/Component/Timeline/TimelineContainer.qml +++ b/src/qml/Component/Timeline/TimelineContainer.qml @@ -42,6 +42,11 @@ ColumnLayout { */ required property var time + /** + * @brief The timestamp of the message as a string. + */ + required property string timeString + /** * @brief The message author. * @@ -154,13 +159,14 @@ ColumnLayout { required property var replyAuthor /** - * @brief The reply content. - * - * This should consist of the following: - * - display - The display text of the reply. - * - type - The delegate type of the reply. + * @brief The delegate type of the message replied to. */ - required property var reply + required property int replyDelegateType + + /** + * @brief The display text of the message replied to. + */ + required property string replyDisplay /** * @brief The media info for the reply event. @@ -206,11 +212,6 @@ ColumnLayout { */ required property bool verified - /** - * @brief The mime type of the message's file or media. - */ - required property var mimeType - /** * @brief The full message source JSON. */ @@ -357,7 +358,7 @@ ColumnLayout { implicitHeight: Math.max(root.showAuthor || root.alwaysShowAuthor ? avatar.implicitHeight : 0, bubble.height) Component.onCompleted: { - if (root.isReply && root.reply === undefined) { + if (root.isReply && root.replyDelegateType === DelegateType.Other) { currentRoom.loadReply(root.eventId, root.replyId) } } @@ -478,7 +479,7 @@ ColumnLayout { QQC2.Label { id: timeLabel - text: visible ? root.time.toLocaleTimeString(Qt.locale(), Locale.ShortFormat) : "" + text: root.timeString color: Kirigami.Theme.disabledTextColor QQC2.ToolTip.visible: hoverHandler.hovered QQC2.ToolTip.text: root.time.toLocaleString(Qt.locale(), Locale.LongFormat) @@ -494,13 +495,13 @@ ColumnLayout { Layout.maximumWidth: contentMaxWidth - active: root.isReply && root.reply + active: root.isReply && root.replyDelegateType !== DelegateType.Other visible: active sourceComponent: ReplyComponent { author: root.replyAuthor - type: root.reply.type - display: root.reply.display + type: root.replyDelegateType + display: root.replyDisplay mediaInfo: root.replyMediaInfo contentMaxWidth: bubbleSizeHelper.currentWidth } @@ -609,7 +610,6 @@ ColumnLayout { eventId: root.eventId, source: root.jsonSource, file: file, - mimeType: root.mimeType, progressInfo: root.progressInfo, plainText: root.plainText, }); diff --git a/src/qml/Component/TimelineView.qml b/src/qml/Component/TimelineView.qml index 7f031dc60..001101707 100644 --- a/src/qml/Component/TimelineView.qml +++ b/src/qml/Component/TimelineView.qml @@ -240,7 +240,7 @@ QQC2.ScrollView { currentRoom: root.currentRoom showActions: delegate && delegate.hovered verified: delegate && delegate.verified - editable: delegate && delegate.author.isLocalUser && (delegate.delegateType === MessageEventModel.Emote || delegate.delegateType === MessageEventModel.Message) + editable: delegate && delegate.author.isLocalUser && (delegate.delegateType === DelegateType.Emote || delegate.delegateType === DelegateType.Message) onReactClicked: (emoji) => { root.currentRoom.toggleReaction(delegate.eventId, emoji); diff --git a/src/qml/Menu/Timeline/FileDelegateContextMenu.qml b/src/qml/Menu/Timeline/FileDelegateContextMenu.qml index d59b0c5c1..d7f30630d 100644 --- a/src/qml/Menu/Timeline/FileDelegateContextMenu.qml +++ b/src/qml/Menu/Timeline/FileDelegateContextMenu.qml @@ -16,7 +16,6 @@ MessageDelegateContextMenu { required property var file required property var progressInfo - required property string mimeType property list actions: [ Kirigami.Action { @@ -108,7 +107,7 @@ MessageDelegateContextMenu { id: shareAction inputData: { 'urls': [], - 'mimeType': [mimeType] + 'mimeType': [root.file.mediaInfo.mimeType] } property string filename: StandardPaths.writableLocation(StandardPaths.CacheLocation) + "/" + eventId.replace(":", "_").replace("/", "_").replace("+", "_") + currentRoom.fileNameToDownload(eventId); @@ -118,7 +117,7 @@ MessageDelegateContextMenu { Component.onCompleted: { shareAction.inputData = { urls: [filename], - mimeType: [mimeType] + mimeType: [root.file.mediaInfo.mimeType] }; } } diff --git a/src/qml/Menu/Timeline/MessageDelegateContextMenu.qml b/src/qml/Menu/Timeline/MessageDelegateContextMenu.qml index cfd652a68..69847188b 100644 --- a/src/qml/Menu/Timeline/MessageDelegateContextMenu.qml +++ b/src/qml/Menu/Timeline/MessageDelegateContextMenu.qml @@ -31,7 +31,7 @@ Loader { currentRoom.chatBoxEditId = eventId; currentRoom.chatBoxReplyId = ""; } - visible: author.id === Controller.activeConnection.localUserId && (loadRoot.eventType === MessageEventModel.Emote || loadRoot.eventType === MessageEventModel.Message) + visible: author.id === Controller.activeConnection.localUserId && (loadRoot.eventType === DelegateType.Emote || loadRoot.eventType === DelegateType.Message) }, Kirigami.Action { text: i18n("Reply") diff --git a/src/texthandler.cpp b/src/texthandler.cpp index d52418844..040a09bfd 100644 --- a/src/texthandler.cpp +++ b/src/texthandler.cpp @@ -202,6 +202,9 @@ QString TextHandler::handleRecievePlainText(Qt::TextFormat inputFormat, const bo * arrive (e.g. in a caption body) it can then be stripped by the same code. */ m_dataBuffer = markdownToHTML(m_dataBuffer); + // This is how \n is converted and for plain text we need it to just be
+ // to prevent extra newlines being inserted. + m_dataBuffer.replace(QStringLiteral("
\n"), QStringLiteral("
")); if (stripNewlines) { m_dataBuffer.replace(QStringLiteral("
\n"), QStringLiteral(" ")); @@ -222,7 +225,11 @@ QString TextHandler::handleRecievePlainText(Qt::TextFormat inputFormat, const bo if (m_nextTokenType == Type::TextCode) { nextTokenBuffer = unescapeHtml(nextTokenBuffer); } else if (m_nextTokenType == Type::Tag) { - nextTokenBuffer = QString(); + if (getTagType() == QStringLiteral("br") && !stripNewlines) { + nextTokenBuffer = u'\n'; + } else { + nextTokenBuffer = QString(); + } } outputString.append(nextTokenBuffer);