diff --git a/autotests/CMakeLists.txt b/autotests/CMakeLists.txt
index c2efaf2e7..0a52904af 100644
--- a/autotests/CMakeLists.txt
+++ b/autotests/CMakeLists.txt
@@ -70,3 +70,9 @@ ecm_add_test(
LINK_LIBRARIES neochat Qt::Test
TEST_NAME pollhandlertest
)
+
+ecm_add_test(
+ reactionmodeltest.cpp
+ LINK_LIBRARIES neochat Qt::Test
+ TEST_NAME reactionmodeltest
+)
diff --git a/autotests/data/test-reactionmodel-extra-sync.json b/autotests/data/test-reactionmodel-extra-sync.json
new file mode 100644
index 000000000..8158ca03d
--- /dev/null
+++ b/autotests/data/test-reactionmodel-extra-sync.json
@@ -0,0 +1,44 @@
+{
+ "timeline": {
+ "events": [
+ {
+ "content": {
+ "m.relates_to": {
+ "event_id": "$153456789:example.org",
+ "key": "👍",
+ "rel_type": "m.annotation"
+ }
+ },
+ "origin_server_ts": 1690322545183,
+ "room_id": "!jEsUZKDJdhlrceRyVU:example.org",
+ "sender": "@bob:example.org",
+ "type": "m.reaction",
+ "unsigned": {
+ "age": 390159121
+ },
+ "event_id": "$163456790:example.org",
+ "age": 390159121
+ },
+ {
+ "content": {
+ "m.relates_to": {
+ "event_id": "$153456789:example.org",
+ "key": "😆",
+ "rel_type": "m.annotation"
+ }
+ },
+ "origin_server_ts": 1690322545184,
+ "room_id": "!jEsUZKDJdhlrceRyVU:example.org",
+ "sender": "@bob:example.org",
+ "type": "m.reaction",
+ "unsigned": {
+ "age": 390159122
+ },
+ "event_id": "$163456791:example.org",
+ "age": 390159122
+ }
+ ],
+ "limited": true,
+ "prev_batch": "t34-23535_0_0"
+ }
+}
diff --git a/autotests/data/test-reactionmodel-sync.json b/autotests/data/test-reactionmodel-sync.json
new file mode 100644
index 000000000..5d592b839
--- /dev/null
+++ b/autotests/data/test-reactionmodel-sync.json
@@ -0,0 +1,204 @@
+{
+ "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
+ }
+ },
+ {
+ "content": {
+ "displayname": "Look\nat\nme\nI\nput\nnewlines\nin\nmy\ndisplay name",
+ "membership": "join"
+ },
+ "event_id": "$143273582443PhrSh:example.org",
+ "origin_server_ts": 1432735824659,
+ "room_id": "!jEsUZKDJdhlrceRyVU:example.org",
+ "sender": "@newline:example.org",
+ "state_key": "@newline:example.org",
+ "type": "m.room.member",
+ "unsigned": {
+ "age": 12345
+ }
+ }
+ ]
+ },
+ "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": {
+ "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
+ }
+ ],
+ "limited": true,
+ "prev_batch": "t34-23535_0_0"
+ }
+}
diff --git a/autotests/eventhandlertest.cpp b/autotests/eventhandlertest.cpp
index 940c7e396..43c58e9d7 100644
--- a/autotests/eventhandlertest.cpp
+++ b/autotests/eventhandlertest.cpp
@@ -67,8 +67,6 @@ private Q_SLOTS:
void nullMediaInfo();
void linkPreviewer();
void nullLinkPreviewer();
- void reactions();
- void nullReactions();
void hasReply();
void nullHasReply();
void replyId();
@@ -423,23 +421,6 @@ void EventHandlerTest::nullLinkPreviewer()
QCOMPARE(noEventHandler.getLinkPreviewer(), nullptr);
}
-void EventHandlerTest::reactions()
-{
- auto event = room->messageEvents().at(0).get();
- eventHandler.setEvent(event);
-
- QCOMPARE(eventHandler.getReactions()->rowCount(), 1);
-}
-
-void EventHandlerTest::nullReactions()
-{
- QTest::ignoreMessage(QtWarningMsg, "getReactions called with m_room set to nullptr.");
- QCOMPARE(emptyHandler.getReactions(), nullptr);
-
- QTest::ignoreMessage(QtWarningMsg, "getReactions called with m_event set to nullptr.");
- QCOMPARE(noEventHandler.getReactions(), nullptr);
-}
-
void EventHandlerTest::hasReply()
{
auto event = room->messageEvents().at(5).get();
diff --git a/autotests/reactionmodeltest.cpp b/autotests/reactionmodeltest.cpp
new file mode 100644
index 000000000..e807ae783
--- /dev/null
+++ b/autotests/reactionmodeltest.cpp
@@ -0,0 +1,83 @@
+// 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
+
+#include "models/reactionmodel.h"
+
+#include
+
+#include "testutils.h"
+
+using namespace Quotient;
+
+class ReactionModelTest : public QObject
+{
+ Q_OBJECT
+
+private:
+ Connection *connection = nullptr;
+ TestUtils::TestRoom *room = nullptr;
+
+private Q_SLOTS:
+ void initTestCase();
+
+ void nullModel();
+ void basicReaction();
+ void newReaction();
+};
+
+void ReactionModelTest::initTestCase()
+{
+ connection = Connection::makeMockConnection(QStringLiteral("@bob:kde.org"));
+ room = new TestUtils::TestRoom(connection, QStringLiteral("#myroom:kde.org"), QLatin1String("test-reactionmodel-sync.json"));
+}
+
+void ReactionModelTest::nullModel()
+{
+ auto model = ReactionModel(nullptr, nullptr);
+
+ QCOMPARE(model.rowCount(), 0);
+ QCOMPARE(model.data(model.index(0), ReactionModel::TextContentRole), QVariant());
+}
+
+void ReactionModelTest::basicReaction()
+{
+ auto event = eventCast(room->messageEvents().at(0).get());
+ auto model = ReactionModel(event, room);
+
+ QCOMPARE(model.rowCount(), 1);
+ QCOMPARE(model.data(model.index(0), ReactionModel::TextContentRole), QStringLiteral("👍"));
+ QCOMPARE(model.data(model.index(0), ReactionModel::ReactionRole), QStringLiteral("👍"));
+ QCOMPARE(model.data(model.index(0), ReactionModel::ToolTipRole),
+ QStringLiteral("@alice:matrix.org reacted with 👍"));
+ auto authorList = QVariantList{room->getUser(room->user(QStringLiteral("@alice:matrix.org")))};
+ QCOMPARE(model.data(model.index(0), ReactionModel::AuthorsRole), authorList);
+ QCOMPARE(model.data(model.index(0), ReactionModel::HasLocalUser), false);
+}
+
+void ReactionModelTest::newReaction()
+{
+ auto event = eventCast(room->messageEvents().at(0).get());
+ auto model = new ReactionModel(event, room);
+
+ QCOMPARE(model->rowCount(), 1);
+ QCOMPARE(model->data(model->index(0), ReactionModel::ToolTipRole),
+ QStringLiteral("@alice:matrix.org reacted with 👍"));
+
+ QSignalSpy spy(model, SIGNAL(modelReset()));
+
+ room->syncNewEvents(QLatin1String("test-reactionmodel-extra-sync.json"));
+ QCOMPARE(model->rowCount(), 2);
+ QCOMPARE(spy.count(), 2); // Once for each of the 2 new reactions.
+ QCOMPARE(model->data(model->index(1), ReactionModel::ReactionRole), QStringLiteral("😆"));
+ QCOMPARE(model->data(model->index(0), ReactionModel::ToolTipRole),
+ QStringLiteral("@alice:matrix.org and @bob:example.org reacted with 👍"));
+
+ delete model;
+}
+
+QTEST_MAIN(ReactionModelTest)
+#include "reactionmodeltest.moc"
diff --git a/src/eventhandler.cpp b/src/eventhandler.cpp
index 2ab0898e0..9fcfdea43 100644
--- a/src/eventhandler.cpp
+++ b/src/eventhandler.cpp
@@ -811,60 +811,6 @@ QSharedPointer EventHandler::getLinkPreviewer() const
}
}
-QSharedPointer EventHandler::getReactions() const
-{
- if (m_room == nullptr) {
- qCWarning(EventHandling) << "getReactions called with m_room set to nullptr.";
- return nullptr;
- }
- 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
{
if (m_event == nullptr) {
diff --git a/src/eventhandler.h b/src/eventhandler.h
index 5206edff4..93aa792da 100644
--- a/src/eventhandler.h
+++ b/src/eventhandler.h
@@ -241,15 +241,6 @@ public:
*/
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.
*/
diff --git a/src/models/messageeventmodel.cpp b/src/models/messageeventmodel.cpp
index 2dbe85b56..0e6e48f31 100644
--- a/src/models/messageeventmodel.cpp
+++ b/src/models/messageeventmodel.cpp
@@ -72,6 +72,12 @@ QHash MessageEventModel::roleNames() const
MessageEventModel::MessageEventModel(QObject *parent)
: QAbstractListModel(parent)
{
+ connect(this, &MessageEventModel::modelAboutToBeReset, this, [this]() {
+ resetting = true;
+ });
+ connect(this, &MessageEventModel::modelReset, this, [this]() {
+ resetting = false;
+ });
}
NeoChatRoom *MessageEventModel::room() const
@@ -245,7 +251,7 @@ void MessageEventModel::setRoom(NeoChatRoom *room)
m_currentRoom->createPollHandler(eventCast(eventIt->event()));
}
}
- refreshEventRoles(eventId, {ReactionRole, ShowReactionsRole, Qt::DisplayRole});
+ refreshEventRoles(eventId, {Qt::DisplayRole});
});
connect(m_currentRoom, &Room::changed, this, [this]() {
for (auto it = m_currentRoom->messageEvents().rbegin(); it != m_currentRoom->messageEvents().rend(); ++it) {
@@ -723,10 +729,28 @@ void MessageEventModel::createEventObjects(const Quotient::RoomMessageEvent *eve
} else {
m_linkPreviewers.remove(eventId);
}
- if (auto reactionModel = eventHandler.getReactions()) {
- m_reactionModels[eventId] = reactionModel;
+
+ // ReactionModel handles updates to add and remove reactions, we only need to
+ // handle adding and removing whole models here.
+ if (m_reactionModels.contains(eventId)) {
+ // If a model already exists but now has no reactions remove it
+ if (m_reactionModels[eventId]->rowCount() <= 0) {
+ m_reactionModels.remove(eventId);
+ if (!resetting) {
+ refreshEventRoles(eventId, {ReactionRole, ShowReactionsRole});
+ }
+ }
} else {
- m_reactionModels.remove(eventId);
+ if (m_currentRoom->relatedEvents(*event, Quotient::EventRelation::AnnotationType).count() > 0) {
+ // If a model doesn't exist and there are reactions add it.
+ auto reactionModel = QSharedPointer(new ReactionModel(event, m_currentRoom));
+ if (reactionModel->rowCount() > 0) {
+ m_reactionModels[eventId] = reactionModel;
+ if (!resetting) {
+ refreshEventRoles(eventId, {ReactionRole, ShowReactionsRole});
+ }
+ }
+ }
}
}
diff --git a/src/models/messageeventmodel.h b/src/models/messageeventmodel.h
index 2a0ef9808..2b0c72e65 100644
--- a/src/models/messageeventmodel.h
+++ b/src/models/messageeventmodel.h
@@ -131,6 +131,7 @@ private:
QString lastReadEventId;
QPersistentModelIndex m_lastReadEventIndex;
int rowBelowInserted = -1;
+ bool resetting = false;
bool movingEvent = false;
KFormat m_format;
diff --git a/src/models/reactionmodel.cpp b/src/models/reactionmodel.cpp
index 30bc0287e..87107d3ce 100644
--- a/src/models/reactionmodel.cpp
+++ b/src/models/reactionmodel.cpp
@@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-only OR GPL-3.0-only OR LicenseRef-KDE-Accepted-GPL
#include "reactionmodel.h"
+#include "neochatroom.h"
#include
#ifdef HAVE_ICU
@@ -15,11 +16,20 @@
#include
-ReactionModel::ReactionModel(QObject *parent, QList reactions, Quotient::User *localUser)
- : QAbstractListModel(parent)
- , m_localUser(localUser)
+ReactionModel::ReactionModel(const Quotient::RoomMessageEvent *event, const NeoChatRoom *room)
+ : QAbstractListModel(nullptr)
+ , m_room(room)
+ , m_event(event)
{
- setReactions(reactions);
+ if (m_event != nullptr && m_room != nullptr) {
+ connect(m_room, &NeoChatRoom::updatedEvent, this, [this](const QString &eventId) {
+ if (m_event->id() == eventId) {
+ updateReactions();
+ }
+ });
+
+ updateReactions();
+ }
}
QVariant ReactionModel::data(const QModelIndex &index, int role) const
@@ -35,38 +45,11 @@ QVariant ReactionModel::data(const QModelIndex &index, int role) const
const auto &reaction = m_reactions.at(index.row());
- const auto isEmoji = [](const QString &text) {
-#ifdef HAVE_ICU
- QTextBoundaryFinder finder(QTextBoundaryFinder::Grapheme, text);
- int from = 0;
- while (finder.toNextBoundary() != -1) {
- auto to = finder.position();
- if (text[from].isSpace()) {
- from = to;
- continue;
- }
-
- auto first = text.mid(from, to - from).toUcs4()[0];
- if (!u_hasBinaryProperty(first, UCHAR_EMOJI_PRESENTATION)) {
- return false;
- }
- from = to;
- }
- return true;
-#else
- return false;
-#endif
- };
-
- const auto reactionText = isEmoji(reaction.reaction)
- ? QStringLiteral("") + reaction.reaction + QStringLiteral("")
- : reaction.reaction;
-
if (role == TextContentRole) {
if (reaction.authors.count() > 1) {
- return QStringLiteral("%1 %2").arg(reactionText, QString::number(reaction.authors.count()));
+ return QStringLiteral("%1 %2").arg(reactionText(reaction.reaction), QString::number(reaction.authors.count()));
} else {
- return reactionText;
+ return reactionText(reaction.reaction);
}
}
@@ -97,7 +80,7 @@ QVariant ReactionModel::data(const QModelIndex &index, int role) const
"%2 reacted with %3",
reaction.authors.count(),
text,
- reactionText);
+ reactionText(reaction.reaction));
return text;
}
@@ -107,7 +90,7 @@ QVariant ReactionModel::data(const QModelIndex &index, int role) const
if (role == HasLocalUser) {
for (auto author : reaction.authors) {
- if (author.toMap()[QStringLiteral("id")] == m_localUser->id()) {
+ if (author.toMap()[QStringLiteral("id")] == m_room->localUser()->id()) {
return true;
}
}
@@ -123,11 +106,44 @@ int ReactionModel::rowCount(const QModelIndex &parent) const
return m_reactions.count();
}
-void ReactionModel::setReactions(QList reactions)
+void ReactionModel::updateReactions()
{
beginResetModel();
+
m_reactions.clear();
- m_reactions = reactions;
+
+ const auto &annotations = m_room->relatedEvents(*m_event, Quotient::EventRelation::AnnotationType);
+ if (annotations.isEmpty()) {
+ endResetModel();
+ 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_room->user(e->senderId()));
+ }
+ }
+
+ if (reactions.isEmpty()) {
+ endResetModel();
+ return;
+ }
+
+ auto i = reactions.constBegin();
+ while (i != reactions.constEnd()) {
+ QVariantList authors;
+ for (const auto &author : i.value()) {
+ authors.append(m_room->getUser(author));
+ }
+
+ m_reactions.append(ReactionModel::Reaction{i.key(), authors});
+ ++i;
+ }
+
endResetModel();
}
@@ -142,4 +158,32 @@ QHash ReactionModel::roleNames() const
};
}
+QString ReactionModel::reactionText(const QString &text)
+{
+ const auto isEmoji = [](const QString &text) {
+#ifdef HAVE_ICU
+ QTextBoundaryFinder finder(QTextBoundaryFinder::Grapheme, text);
+ int from = 0;
+ while (finder.toNextBoundary() != -1) {
+ auto to = finder.position();
+ if (text[from].isSpace()) {
+ from = to;
+ continue;
+ }
+
+ auto first = text.mid(from, to - from).toUcs4()[0];
+ if (!u_hasBinaryProperty(first, UCHAR_EMOJI_PRESENTATION)) {
+ return false;
+ }
+ from = to;
+ }
+ return true;
+#else
+ return false;
+#endif
+ };
+
+ return isEmoji(text) ? QStringLiteral("") + text + QStringLiteral("") : text;
+}
+
#include "moc_reactionmodel.cpp"
diff --git a/src/models/reactionmodel.h b/src/models/reactionmodel.h
index 20b1d0780..52b3c13f6 100644
--- a/src/models/reactionmodel.h
+++ b/src/models/reactionmodel.h
@@ -3,8 +3,10 @@
#pragma once
+#include "neochatroom.h"
#include
#include
+#include
namespace Quotient
{
@@ -41,7 +43,7 @@ public:
HasLocalUser, /**< Whether the local user is in the list of authors. */
};
- explicit ReactionModel(QObject *parent = nullptr, QList reactions = {}, Quotient::User *localUser = nullptr);
+ explicit ReactionModel(const Quotient::RoomMessageEvent *event, const NeoChatRoom *room);
/**
* @brief Get the given role value at the given index.
@@ -64,14 +66,12 @@ public:
*/
[[nodiscard]] QHash roleNames() const override;
- /**
- * @brief Set the reactions data in the model.
- */
- void setReactions(QList reactions);
-
private:
+ const NeoChatRoom *m_room;
+ const Quotient::RoomMessageEvent *m_event;
QList m_reactions;
- Quotient::User *m_localUser;
+ void updateReactions();
+ static QString reactionText(const QString &text);
};
Q_DECLARE_METATYPE(ReactionModel *)