aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKitsune Ral <Kitsune-Ral@users.sf.net>2019-08-01 12:16:30 +0900
committerGitHub <noreply@github.com>2019-08-01 12:16:30 +0900
commit5b236dfe895c7766002559570aa29c9033009228 (patch)
treea2729eaeaff2bf41ad3cbbe9d248a13d4bddd1e0
parentc81ce9d05dab090871f1a79ebdd54b6f88ccd1cc (diff)
parent405271605f334ad09c7dc638fc5d6ef11849cada (diff)
downloadlibquotient-5b236dfe895c7766002559570aa29c9033009228.tar.gz
libquotient-5b236dfe895c7766002559570aa29c9033009228.zip
Merge pull request #341 from quotient-im/kitsune-relations
Reactions and edited messages support
-rw-r--r--CMakeLists.txt1
-rw-r--r--examples/qmc-example.cpp46
-rw-r--r--lib/converters.h53
-rw-r--r--lib/events/event.h1
-rw-r--r--lib/events/reactionevent.cpp44
-rw-r--r--lib/events/reactionevent.h78
-rw-r--r--lib/events/roomevent.cpp14
-rw-r--r--lib/events/roomevent.h2
-rw-r--r--lib/events/roommessageevent.cpp105
-rw-r--r--lib/events/roommessageevent.h3
-rw-r--r--lib/room.cpp184
-rw-r--r--lib/room.h9
-rw-r--r--libqmatrixclient.pri2
13 files changed, 464 insertions, 78 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt
index 75314a91..e0003062 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -132,6 +132,7 @@ set(libqmatrixclient_SRCS
lib/events/roommemberevent.cpp
lib/events/typingevent.cpp
lib/events/receiptevent.cpp
+ lib/events/reactionevent.cpp
lib/events/callanswerevent.cpp
lib/events/callcandidatesevent.cpp
lib/events/callhangupevent.cpp
diff --git a/examples/qmc-example.cpp b/examples/qmc-example.cpp
index e97c7812..fc8ddb61 100644
--- a/examples/qmc-example.cpp
+++ b/examples/qmc-example.cpp
@@ -6,6 +6,7 @@
#include "csapi/joining.h"
#include "csapi/leaving.h"
#include "events/simplestateevents.h"
+#include "events/reactionevent.h"
#include <QtCore/QCoreApplication>
#include <QtCore/QStringBuilder>
@@ -26,12 +27,14 @@ class QMCTest : public QObject
QMCTest(Connection* conn, QString testRoomName, QString source);
private slots:
+ // clang-format off
void setupAndRun();
void onNewRoom(Room* r);
void run();
void doTests();
void loadMembers();
void sendMessage();
+ void sendReaction(const QString& targetEvtId);
void sendFile();
void checkFileSendingOutcome(const QString& txnId,
const QString& fileName);
@@ -44,6 +47,7 @@ class QMCTest : public QObject
const Connection::DirectChatsMap& added);
void conclude();
void finalize();
+ // clang-format on
private:
QScopedPointer<Connection, QScopedPointerDeleteLater> c;
@@ -228,8 +232,48 @@ void QMCTest::sendMessage()
is<RoomMessageEvent>(*evt) && !evt->id().isEmpty() &&
pendingEvents[size_t(pendingIdx)]->transactionId()
== evt->transactionId());
+ sendReaction(evt->id());
return true;
- });
+ });
+}
+
+void QMCTest::sendReaction(const QString& targetEvtId)
+{
+ running.push_back("Reaction sending");
+ cout << "Reacting to the newest message in the room" << endl;
+ Q_ASSERT(targetRoom->timelineSize() > 0);
+ const auto key = QStringLiteral("+1");
+ auto txnId = targetRoom->postReaction(targetEvtId, key);
+ if (!validatePendingEvent(txnId)) {
+ cout << "Invalid pending event right after submitting" << endl;
+ QMC_CHECK("Reaction sending", false);
+ return;
+ }
+
+ // TODO: Check that it came back as a reaction event and that it attached to
+ // the right event
+ connectUntil(targetRoom, &Room::updatedEvent, this,
+ [this, txnId, key,
+ targetEvtId](const QString& actualTargetEvtId) {
+ if (actualTargetEvtId != targetEvtId)
+ return false;
+ const auto reactions = targetRoom->relatedEvents(
+ targetEvtId, EventRelation::Annotation());
+ // It's a test room, assuming no interference there should
+ // be exactly one reaction
+ if (reactions.size() != 1) {
+ QMC_CHECK("Reaction sending", false);
+ } else {
+ const auto* evt =
+ eventCast<const ReactionEvent>(reactions.back());
+ QMC_CHECK("Reaction sending",
+ is<ReactionEvent>(*evt)
+ && !evt->id().isEmpty()
+ && evt->relation().key == key
+ && evt->transactionId() == txnId);
+ }
+ return true;
+ });
}
void QMCTest::sendFile()
diff --git a/lib/converters.h b/lib/converters.h
index 5f00dc43..36b7ff15 100644
--- a/lib/converters.h
+++ b/lib/converters.h
@@ -60,8 +60,8 @@ namespace QMatrixClient
template <typename T>
struct JsonObjectConverter
{
- static void dumpTo(QJsonObject& jo, const T& pod) { jo = pod; }
- static void fillFrom(const QJsonObject& jo, T& pod) { pod = jo; }
+ static void dumpTo(QJsonObject& jo, const T& pod) { jo = pod.toJson(); }
+ static void fillFrom(const QJsonObject& jo, T& pod) { pod = T(jo); }
};
template <typename T>
@@ -89,14 +89,16 @@ namespace QMatrixClient
return JsonConverter<T>::dump(pod);
}
+ inline auto toJson(const QJsonObject& jo) { return jo; }
+
template <typename T>
- inline auto fillJson(QJsonObject& json, const T& data)
+ inline void fillJson(QJsonObject& json, const T& data)
{
JsonObjectConverter<T>::dumpTo(json, data);
}
template <typename T>
- inline auto fromJson(const QJsonValue& jv)
+ inline T fromJson(const QJsonValue& jv)
{
return JsonConverter<T>::load(jv);
}
@@ -114,8 +116,7 @@ namespace QMatrixClient
template <typename T>
inline void fromJson(const QJsonValue& jv, T& pod)
{
- if (!jv.isUndefined())
- pod = fromJson<T>(jv);
+ pod = jv.isUndefined() ? T() : fromJson<T>(jv);
}
template <typename T>
@@ -124,21 +125,13 @@ namespace QMatrixClient
pod = fromJson<T>(jd);
}
- // Unfolds Omittable<>
- template <typename T>
- inline void fromJson(const QJsonValue& jv, Omittable<T>& pod)
- {
- if (jv.isUndefined())
- pod = none;
- else
- pod = fromJson<T>(jv);
- }
-
template <typename T>
inline void fillFromJson(const QJsonValue& jv, T& pod)
{
if (jv.isObject())
JsonObjectConverter<T>::fillFrom(jv.toObject(), pod);
+ else if (!jv.isUndefined())
+ pod = fromJson<T>(jv);
}
// JsonConverter<> specialisations
@@ -224,6 +217,21 @@ namespace QMatrixClient
static QVariant load(const QJsonValue& jv);
};
+ template <typename T>
+ struct JsonConverter<Omittable<T>>
+ {
+ static QJsonValue dump(const Omittable<T>& from)
+ {
+ return from.omitted() ? QJsonValue() : toJson(from.value());
+ }
+ static Omittable<T> load(const QJsonValue& jv)
+ {
+ if (jv.isUndefined())
+ return none;
+ return fromJson<T>(jv);
+ }
+ };
+
template <typename VectorT,
typename T = typename VectorT::value_type>
struct JsonArrayConverter
@@ -384,23 +392,20 @@ namespace QMatrixClient
ForwardedT&& value)
{
if (!value.isEmpty())
- AddNode<ValT>::impl(container,
- key, std::forward<ForwardedT>(value));
+ addTo(container, key, std::forward<ForwardedT>(value));
}
};
- // This is a special one that unfolds Omittable<>
- template <typename ValT, bool Force>
- struct AddNode<Omittable<ValT>, Force>
+ // This one unfolds Omittable<> (also only when Force is false)
+ template <typename ValT>
+ struct AddNode<Omittable<ValT>, false>
{
template <typename ContT, typename OmittableT>
static void impl(ContT& container,
const QString& key, const OmittableT& value)
{
if (!value.omitted())
- AddNode<ValT>::impl(container, key, value.value());
- else if (Force) // Edge case, no value but must put something
- AddNode<ValT>::impl(container, key, QString{});
+ addTo(container, key, value.value());
}
};
diff --git a/lib/events/event.h b/lib/events/event.h
index 6f28c4fa..dee1c44a 100644
--- a/lib/events/event.h
+++ b/lib/events/event.h
@@ -57,6 +57,7 @@ namespace QMatrixClient
// === Standard Matrix key names and basicEventJson() ===
static const auto TypeKey = QStringLiteral("type");
+ static const auto BodyKey = QStringLiteral("body");
static const auto ContentKey = QStringLiteral("content");
static const auto EventIdKey = QStringLiteral("event_id");
static const auto UnsignedKey = QStringLiteral("unsigned");
diff --git a/lib/events/reactionevent.cpp b/lib/events/reactionevent.cpp
new file mode 100644
index 00000000..0081edc2
--- /dev/null
+++ b/lib/events/reactionevent.cpp
@@ -0,0 +1,44 @@
+/******************************************************************************
+ * Copyright (C) 2019 Kitsune Ral <kitsune-ral@users.sf.net>
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+#include "reactionevent.h"
+
+using namespace QMatrixClient;
+
+void QMatrixClient::JsonObjectConverter<EventRelation>::dumpTo(
+ QJsonObject& jo, const EventRelation& pod)
+{
+ if (pod.type.isEmpty()) {
+ qCWarning(MAIN) << "Empty relation type; won't dump to JSON";
+ return;
+ }
+ jo.insert(QStringLiteral("rel_type"), pod.type);
+ jo.insert(EventIdKey, pod.eventId);
+ if (pod.type == EventRelation::Annotation())
+ jo.insert(QStringLiteral("key"), pod.key);
+}
+
+void QMatrixClient::JsonObjectConverter<EventRelation>::fillFrom(
+ const QJsonObject& jo, EventRelation& pod)
+{
+ // The experimental logic for generic relationships (MSC1849)
+ fromJson(jo["rel_type"_ls], pod.type);
+ fromJson(jo[EventIdKeyL], pod.eventId);
+ if (pod.type == EventRelation::Annotation())
+ fromJson(jo["key"_ls], pod.key);
+}
diff --git a/lib/events/reactionevent.h b/lib/events/reactionevent.h
new file mode 100644
index 00000000..9a6adbbd
--- /dev/null
+++ b/lib/events/reactionevent.h
@@ -0,0 +1,78 @@
+/******************************************************************************
+ * Copyright (C) 2019 Kitsune Ral <kitsune-ral@users.sf.net>
+ *
+ * This library is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 2.1 of the License, or (at your option) any later version.
+ *
+ * This library is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public
+ * License along with this library; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+ */
+
+#pragma once
+
+#include "roomevent.h"
+
+namespace QMatrixClient {
+
+struct EventRelation
+{
+ using reltypeid_t = const char*;
+ static constexpr reltypeid_t Reply() { return "m.in_reply_to"; }
+ static constexpr reltypeid_t Annotation() { return "m.annotation"; }
+ static constexpr reltypeid_t Replacement() { return "m.replace"; }
+
+ QString type;
+ QString eventId;
+ QString key = {}; // Only used for m.annotation for now
+
+ static EventRelation replyTo(QString eventId)
+ {
+ return { Reply(), std::move(eventId) };
+ }
+ static EventRelation annotate(QString eventId, QString key)
+ {
+ return { Annotation(), std::move(eventId), std::move(key) };
+ }
+ static EventRelation replace(QString eventId)
+ {
+ return { Replacement(), std::move(eventId) };
+ }
+};
+template <>
+struct JsonObjectConverter<EventRelation>
+{
+ static void dumpTo(QJsonObject& jo, const EventRelation& pod);
+ static void fillFrom(const QJsonObject& jo, EventRelation& pod);
+};
+
+class ReactionEvent : public RoomEvent
+{
+public:
+ DEFINE_EVENT_TYPEID("m.reaction", ReactionEvent)
+
+ explicit ReactionEvent(const EventRelation& value)
+ : RoomEvent(typeId(), matrixTypeId(),
+ { { QStringLiteral("m.relates_to"), toJson(value) } })
+ {}
+ explicit ReactionEvent(const QJsonObject& obj)
+ : RoomEvent(typeId(), obj)
+ {}
+ EventRelation relation() const
+ {
+ return content<EventRelation>(QStringLiteral("m.relates_to"));
+ }
+
+private:
+ EventRelation _relation;
+};
+REGISTER_EVENT_TYPE(ReactionEvent)
+
+} // namespace QMatrixClient
diff --git a/lib/events/roomevent.cpp b/lib/events/roomevent.cpp
index f1e563ff..0143826a 100644
--- a/lib/events/roomevent.cpp
+++ b/lib/events/roomevent.cpp
@@ -66,6 +66,20 @@ QString RoomEvent::senderId() const
return fullJson()["sender"_ls].toString();
}
+bool RoomEvent::isReplaced() const
+{
+ return unsignedJson()["m.relations"_ls].toObject().contains("m.replace");
+}
+
+QString RoomEvent::replacedBy() const
+{
+ // clang-format off
+ return unsignedJson()["m.relations"_ls].toObject()
+ .value("m.replace").toObject()
+ .value(EventIdKeyL).toString();
+ // clang-format on
+}
+
QString RoomEvent::redactionReason() const
{
return isRedacted() ? _redactedBecause->reason() : QString{};
diff --git a/lib/events/roomevent.h b/lib/events/roomevent.h
index e26a7135..0e6f730d 100644
--- a/lib/events/roomevent.h
+++ b/lib/events/roomevent.h
@@ -51,6 +51,8 @@ namespace QMatrixClient
QDateTime timestamp() const;
QString roomId() const;
QString senderId() const;
+ bool isReplaced() const;
+ QString replacedBy() const;
bool isRedacted() const { return bool(_redactedBecause); }
const event_ptr_tt<RedactionEvent>& redactedBecause() const
{
diff --git a/lib/events/roommessageevent.cpp b/lib/events/roommessageevent.cpp
index ec18e962..8b6cc730 100644
--- a/lib/events/roommessageevent.cpp
+++ b/lib/events/roommessageevent.cpp
@@ -30,11 +30,12 @@ using namespace EventContent;
using MsgType = RoomMessageEvent::MsgType;
-static const auto RelatesToKey = "m.relates_to"_ls;
-static const auto MsgTypeKey = "msgtype"_ls;
-static const auto FormattedBodyKey = "formatted_body"_ls;
+static const auto RelatesToKeyL = "m.relates_to"_ls;
+static const auto MsgTypeKeyL = "msgtype"_ls;
+static const auto FormattedBodyKeyL = "formatted_body"_ls;
static const auto TextTypeKey = "m.text";
+static const auto EmoteTypeKey = "m.emote";
static const auto NoticeTypeKey = "m.notice";
static const auto HtmlContentTypeId = QStringLiteral("org.matrix.custom.html");
@@ -48,7 +49,7 @@ TypedBase* make(const QJsonObject& json)
template <>
TypedBase* make<TextContent>(const QJsonObject& json)
{
- return json.contains(FormattedBodyKey) || json.contains(RelatesToKey)
+ return json.contains(FormattedBodyKeyL) || json.contains(RelatesToKeyL)
? new TextContent(json) : nullptr;
}
@@ -61,7 +62,7 @@ struct MsgTypeDesc
const std::vector<MsgTypeDesc> msgTypes =
{ { TextTypeKey, MsgType::Text, make<TextContent> }
- , { QStringLiteral("m.emote"), MsgType::Emote, make<TextContent> }
+ , { EmoteTypeKey, MsgType::Emote, make<TextContent> }
, { NoticeTypeKey, MsgType::Notice, make<TextContent> }
, { QStringLiteral("m.image"), MsgType::Image, make<ImageContent> }
, { QStringLiteral("m.file"), MsgType::File, make<FileContent> }
@@ -94,12 +95,25 @@ QJsonObject RoomMessageEvent::assembleContentJson(const QString& plainBody,
const QString& jsonMsgType, TypedBase* content)
{
auto json = content ? content->toJson() : QJsonObject();
- if (jsonMsgType != TextTypeKey && jsonMsgType != NoticeTypeKey &&
- json.contains(RelatesToKey))
- {
- json.remove(RelatesToKey);
- qCWarning(EVENTS) << RelatesToKey << "cannot be used in" << jsonMsgType
- << "messages; the relation has been stripped off";
+ if (json.contains(RelatesToKeyL)) {
+ if (jsonMsgType != TextTypeKey && jsonMsgType != NoticeTypeKey
+ && jsonMsgType != EmoteTypeKey) {
+ json.remove(RelatesToKeyL);
+ qCWarning(EVENTS)
+ << RelatesToKeyL << "cannot be used in" << jsonMsgType
+ << "messages; the relation has been stripped off";
+ } else {
+ // After the above, we know for sure that the content is TextContent
+ // and that its RelatesTo structure is not omitted
+ auto* textContent = static_cast<const TextContent*>(content);
+ if (textContent->relatesTo->type == RelatesTo::ReplacementTypeId()) {
+ auto newContentJson = json.take("m.new_content"_ls).toObject();
+ newContentJson.insert(BodyKey, plainBody);
+ newContentJson.insert(TypeKey, jsonMsgType);
+ json.insert(QStringLiteral("m.new_content"), newContentJson);
+ json[BodyKeyL] = "* " + plainBody;
+ }
+ }
}
json.insert(QStringLiteral("msgtype"), jsonMsgType);
json.insert(QStringLiteral("body"), plainBody);
@@ -158,9 +172,9 @@ RoomMessageEvent::RoomMessageEvent(const QJsonObject& obj)
if (isRedacted())
return;
const QJsonObject content = contentJson();
- if ( content.contains(MsgTypeKey) && content.contains(BodyKeyL) )
+ if ( content.contains(MsgTypeKeyL) && content.contains(BodyKeyL) )
{
- auto msgtype = content[MsgTypeKey].toString();
+ auto msgtype = content[MsgTypeKeyL].toString();
bool msgTypeFound = false;
for (const auto& mt: msgTypes)
if (mt.matrixType == msgtype)
@@ -190,7 +204,7 @@ RoomMessageEvent::MsgType RoomMessageEvent::msgtype() const
QString RoomMessageEvent::rawMsgtype() const
{
- return contentJson()[MsgTypeKey].toString();
+ return contentJson()[MsgTypeKeyL].toString();
}
QString RoomMessageEvent::plainBody() const
@@ -222,6 +236,16 @@ bool RoomMessageEvent::hasThumbnail() const
return content() && content()->thumbnailInfo();
}
+QString RoomMessageEvent::replacedEvent() const
+{
+ if (!content() || !hasTextContent())
+ return {};
+
+ const auto& rel = static_cast<const TextContent*>(content())->relatesTo;
+ return !rel.omitted() && rel->type == RelatesTo::ReplacementTypeId()
+ ? rel->eventId : QString();
+}
+
QString rawMsgTypeForMimeType(const QMimeType& mimeType)
{
auto name = mimeType.name();
@@ -250,41 +274,72 @@ TextContent::TextContent(const QString& text, const QString& contentType,
mimeType = QMimeDatabase().mimeTypeForName("text/html");
}
+namespace QMatrixClient
+{
+// Overload the default fromJson<> logic that defined in converters.h
+// as we want
+template <>
+Omittable<RelatesTo> fromJson(const QJsonValue& jv)
+{
+ const auto jo = jv.toObject();
+ if (jo.isEmpty())
+ return none;
+ const auto replyJson = jo.value(RelatesTo::ReplyTypeId()).toObject();
+ if (!replyJson.isEmpty())
+ return replyTo(fromJson<QString>(replyJson[EventIdKeyL]));
+
+ return RelatesTo { jo.value("rel_type"_ls).toString(),
+ jo.value(EventIdKeyL).toString() };
+}
+}
+
TextContent::TextContent(const QJsonObject& json)
+ : relatesTo(fromJson<Omittable<RelatesTo>>(json[RelatesToKeyL]))
{
QMimeDatabase db;
static const auto PlainTextMimeType = db.mimeTypeForName("text/plain");
static const auto HtmlMimeType = db.mimeTypeForName("text/html");
+ const auto actualJson =
+ relatesTo.omitted() || relatesTo->type != RelatesTo::ReplacementTypeId()
+ ? json : json.value("m.new_content"_ls).toObject();
// Special-casing the custom matrix.org's (actually, Riot's) way
// of sending HTML messages.
- if (json["format"_ls].toString() == HtmlContentTypeId)
+ if (actualJson["format"_ls].toString() == HtmlContentTypeId)
{
mimeType = HtmlMimeType;
- body = json[FormattedBodyKey].toString();
+ body = actualJson[FormattedBodyKeyL].toString();
} else {
// Falling back to plain text, as there's no standard way to describe
// rich text in messages.
mimeType = PlainTextMimeType;
- body = json[BodyKeyL].toString();
+ body = actualJson[BodyKeyL].toString();
}
- const auto replyJson = json[RelatesToKey].toObject()
- .value(RelatesTo::ReplyTypeId()).toObject();
- if (!replyJson.isEmpty())
- relatesTo = replyTo(fromJson<QString>(replyJson[EventIdKeyL]));
}
void TextContent::fillJson(QJsonObject* json) const
{
+ static const auto FormatKey = QStringLiteral("format");
+ static const auto FormattedBodyKey = QStringLiteral("formatted_body");
+
Q_ASSERT(json);
if (mimeType.inherits("text/html"))
{
- json->insert(QStringLiteral("format"), HtmlContentTypeId);
- json->insert(QStringLiteral("formatted_body"), body);
+ json->insert(FormatKey, HtmlContentTypeId);
+ json->insert(FormattedBodyKey, body);
}
- if (!relatesTo.omitted())
+ if (!relatesTo.omitted()) {
json->insert(QStringLiteral("m.relates_to"),
- QJsonObject { { relatesTo->type, relatesTo->eventId } });
+ QJsonObject { { relatesTo->type, relatesTo->eventId } });
+ if (relatesTo->type == RelatesTo::ReplacementTypeId()) {
+ QJsonObject newContentJson;
+ if (mimeType.inherits("text/html")) {
+ json->insert(FormatKey, HtmlContentTypeId);
+ json->insert(FormattedBodyKey, body);
+ }
+ json->insert(QStringLiteral("m.new_content"), newContentJson);
+ }
+ }
}
LocationContent::LocationContent(const QString& geoUri,
diff --git a/lib/events/roommessageevent.h b/lib/events/roommessageevent.h
index c2e075eb..7320e4ea 100644
--- a/lib/events/roommessageevent.h
+++ b/lib/events/roommessageevent.h
@@ -72,6 +72,7 @@ namespace QMatrixClient
bool hasTextContent() const;
bool hasFileContent() const;
bool hasThumbnail() const;
+ QString replacedEvent() const;
static QString rawMsgTypeForUrl(const QUrl& url);
static QString rawMsgTypeForFile(const QFileInfo& fi);
@@ -79,6 +80,7 @@ namespace QMatrixClient
private:
QScopedPointer<EventContent::TypedBase> _content;
+ // FIXME: should it really be static?
static QJsonObject assembleContentJson(const QString& plainBody,
const QString& jsonMsgType, EventContent::TypedBase* content);
@@ -95,6 +97,7 @@ namespace QMatrixClient
struct RelatesTo
{
static constexpr const char* ReplyTypeId() { return "m.in_reply_to"; }
+ static constexpr const char* ReplacementTypeId() { return "m.replace"; }
QString type; // The only supported relation so far
QString eventId;
};
diff --git a/lib/room.cpp b/lib/room.cpp
index 0402ce67..29a6ebe2 100644
--- a/lib/room.cpp
+++ b/lib/room.cpp
@@ -38,6 +38,7 @@
#include "events/roommemberevent.h"
#include "events/typingevent.h"
#include "events/receiptevent.h"
+#include "events/reactionevent.h"
#include "events/callinviteevent.h"
#include "events/callcandidatesevent.h"
#include "events/callanswerevent.h"
@@ -112,7 +113,10 @@ class Room::Private
Timeline timeline;
PendingEvents unsyncedEvents;
QHash<QString, TimelineItem::index_t> eventsIndex;
-
+ // A map from evtId to a map of relation type to a vector of event
+ // pointers. Not using QMultiHash, because we want to quickly return
+ // a number of relations for a given event without enumerating them.
+ QHash<QPair<QString, QString>, RelatedEvents> relations;
QString displayname;
Avatar avatar;
int highlightCount = 0;
@@ -313,14 +317,22 @@ class Room::Private
return requestSetState(EvT(std::forward<ArgTs>(args)...));
}
- /**
- * @brief Apply redaction to the timeline
+ /*! Apply redaction to the timeline
*
* Tries to find an event in the timeline and redact it; deletes the
* redaction event whether the redacted event was found or not.
+ * \return true if the event has been found and redacted; false otherwise
*/
bool processRedaction(const RedactionEvent& redaction);
+ /*! Apply a new revision of the event to the timeline
+ *
+ * Tries to find an event in the timeline and replace it with the new
+ * content passed in \p newMessage.
+ * \return true if the event has been found and replaced; false otherwise
+ */
+ bool processReplacement(const RoomMessageEvent& newMessage);
+
void setTags(TagsMap newTags);
QJsonObject toJson() const;
@@ -722,10 +734,10 @@ Room::rev_iter_t Room::findInTimeline(const QString& evtId) const
if (!d->timeline.empty() && d->eventsIndex.contains(evtId))
{
auto it = findInTimeline(d->eventsIndex.value(evtId));
- Q_ASSERT((*it)->id() == evtId);
+ Q_ASSERT(it != historyEdge() && (*it)->id() == evtId);
return it;
}
- return timelineEdge();
+ return historyEdge();
}
Room::PendingEvents::iterator Room::findPendingEvent(const QString& txnId)
@@ -741,6 +753,18 @@ Room::findPendingEvent(const QString& txnId) const
[txnId] (const auto& item) { return item->transactionId() == txnId; });
}
+const Room::RelatedEvents Room::relatedEvents(const QString& evtId,
+ const char* relType) const
+{
+ return d->relations.value({ evtId, relType });
+}
+
+const Room::RelatedEvents Room::relatedEvents(const RoomEvent& evt,
+ const char* relType) const
+{
+ return relatedEvents(evt.id(), relType);
+}
+
void Room::Private::getAllMembers()
{
// If already loaded or already loading, there's nothing to do here.
@@ -1666,6 +1690,11 @@ QString Room::postHtmlText(const QString& plainText, const QString& html)
return postHtmlMessage(plainText, html);
}
+QString Room::postReaction(const QString& eventId, const QString& key)
+{
+ return d->sendEvent<ReactionEvent>(EventRelation::annotate(eventId, key));
+}
+
QString Room::postFile(const QString& plainText, const QUrl& localPath,
bool asGenericFile)
{
@@ -2123,11 +2152,65 @@ bool Room::Private::processRedaction(const RedactionEvent& redaction)
updateDisplayname();
}
}
+ if (const auto* reaction = eventCast<ReactionEvent>(oldEvent)) {
+ const auto& targetEvtId = reaction->relation().eventId;
+ const auto lookupKey = qMakePair(targetEvtId,
+ EventRelation::Annotation());
+ if (relations.contains(lookupKey)) {
+ relations[lookupKey].removeOne(reaction);
+ }
+ }
q->onRedaction(*oldEvent, *ti);
emit q->replacedEvent(ti.event(), rawPtr(oldEvent));
return true;
}
+/** Make a replaced event
+ *
+ * Takes \p target and returns a copy of it with content taken from
+ * \p replacement. Disposal of the original event after that is on the caller.
+ */
+RoomEventPtr makeReplaced(const RoomEvent& target,
+ const RoomMessageEvent& replacement)
+{
+ auto originalJson = target.originalJsonObject();
+ originalJson[ContentKeyL] = replacement.contentJson();
+
+ auto unsignedData = originalJson.take(UnsignedKeyL).toObject();
+ auto relations = unsignedData.take("m.relations"_ls).toObject();
+ relations["m.replace"_ls] = replacement.id();
+ unsignedData.insert(QStringLiteral("m.relations"), relations);
+ originalJson.insert(UnsignedKey, unsignedData);
+
+ return loadEvent<RoomEvent>(originalJson);
+}
+
+bool Room::Private::processReplacement(const RoomMessageEvent& newEvent)
+{
+ // Can't use findInTimeline because it returns a const iterator, and
+ // we need to change the underlying TimelineItem.
+ const auto pIdx = eventsIndex.find(newEvent.replacedEvent());
+ if (pIdx == eventsIndex.end())
+ return false;
+
+ Q_ASSERT(q->isValidIndex(*pIdx));
+
+ auto& ti = timeline[Timeline::size_type(*pIdx - q->minTimelineIndex())];
+ if (ti->replacedBy() == newEvent.id())
+ {
+ qCDebug(MAIN) << "Event" << ti->id() << "is already replaced with"
+ << newEvent.id();
+ return true;
+ }
+
+ // Make a new event from the redacted JSON and put it in the timeline
+ // instead of the redacted one. oldEvent will be deleted on return.
+ auto oldEvent = ti.replaceEvent(makeReplaced(*ti, newEvent));
+ qCDebug(MAIN) << "Replaced" << oldEvent->id() << "with" << newEvent.id();
+ emit q->replacedEvent(ti.event(), rawPtr(oldEvent));
+ return true;
+}
+
Connection* Room::connection() const
{
Q_ASSERT(d->connection);
@@ -2139,10 +2222,16 @@ User* Room::localUser() const
return connection()->user();
}
-inline bool isRedaction(const RoomEventPtr& ep)
+/// Whether the event is a redaction or a replacement
+inline bool isEditing(const RoomEventPtr& ep)
{
Q_ASSERT(ep);
- return is<RedactionEvent>(*ep);
+ if (is<RedactionEvent>(*ep))
+ return true;
+ if (auto* msgEvent = eventCast<RoomMessageEvent>(ep))
+ return msgEvent->replacedEvent().isEmpty();
+
+ return false;
}
Room::Changes Room::Private::addNewMessageEvents(RoomEvents&& events)
@@ -2151,28 +2240,52 @@ Room::Changes Room::Private::addNewMessageEvents(RoomEvents&& events)
if (events.empty())
return Change::NoChange;
- // Pre-process redactions so that events that get redacted in the same
- // batch landed in the timeline already redacted.
- // NB: We have to store redaction events to the timeline too - see #220.
- auto redactionIt = std::find_if(events.begin(), events.end(), isRedaction);
- for(const auto& eptr: RoomEventsRange(redactionIt, events.end()))
- if (auto* r = eventCast<RedactionEvent>(eptr))
- {
- // Try to find the target in the timeline, then in the batch.
- if (processRedaction(*r))
- continue;
- auto targetIt = std::find_if(events.begin(), redactionIt,
- [id=r->redactedEvent()] (const RoomEventPtr& ep) {
- return ep->id() == id;
- });
- if (targetIt != redactionIt)
- *targetIt = makeRedacted(**targetIt, *r);
- else
- qCDebug(MAIN) << "Redaction" << r->id()
- << "ignored: target event" << r->redactedEvent()
- << "is not found";
- // If the target event comes later, it comes already redacted.
+ {
+ // Pre-process redactions and edits so that events that get
+ // redacted/replaced in the same batch landed in the timeline already
+ // treated.
+ // NB: We have to store redacting/replacing events to the timeline too -
+ // see #220.
+ auto it = std::find_if(events.begin(), events.end(), isEditing);
+ for (const auto& eptr : RoomEventsRange(it, events.end())) {
+ if (auto* r = eventCast<RedactionEvent>(eptr)) {
+ // Try to find the target in the timeline, then in the batch.
+ if (processRedaction(*r))
+ continue;
+ auto targetIt = std::find_if(events.begin(), it,
+ [id = r->redactedEvent()](
+ const RoomEventPtr& ep) {
+ return ep->id() == id;
+ });
+ if (targetIt != it)
+ *targetIt = makeRedacted(**targetIt, *r);
+ else
+ qCDebug(MAIN)
+ << "Redaction" << r->id() << "ignored: target event"
+ << r->redactedEvent() << "is not found";
+ // If the target event comes later, it comes already redacted.
+ }
+ if (auto* msg = eventCast<RoomMessageEvent>(eptr)) {
+ if (!msg->replacedEvent().isEmpty()) {
+ if (processReplacement(*msg))
+ continue;
+ auto targetIt = std::find_if(events.begin(), it,
+ [id = msg->replacedEvent()](
+ const RoomEventPtr& ep) {
+ return ep->id() == id;
+ });
+ if (targetIt != it)
+ *targetIt = makeReplaced(**targetIt, *msg);
+ else // FIXME: don't ignore, just show it wherever it arrived
+ qCDebug(MAIN) << "Replacing event" << msg->id()
+ << "ignored: replaced event"
+ << msg->replacedEvent() << "is not found";
+ // Same as with redactions above, the replaced event coming
+ // later will come already with the new content.
+ }
+ }
}
+ }
// State changes arrive as a part of timeline; the current room state gets
// updated before merging events to the timeline because that's what
@@ -2238,6 +2351,14 @@ Room::Changes Room::Private::addNewMessageEvents(RoomEvents&& events)
if (totalInserted > 0)
{
+ for (auto it = from; it != timeline.cend(); ++it) {
+ if (const auto* reaction = it->viewAs<ReactionEvent>()) {
+ const auto& relation = reaction->relation();
+ relations[{ relation.eventId, relation.type }] << reaction;
+ emit q->updatedEvent(relation.eventId);
+ }
+ }
+
qCDebug(MAIN)
<< "Room" << q->objectName() << "received" << totalInserted
<< "new events; the last event is now" << timeline.back();
@@ -2296,6 +2417,13 @@ void Room::Private::addHistoricalMessageEvents(RoomEvents&& events)
q->onAddHistoricalTimelineEvents(from);
emit q->addedMessages(timeline.front().index(), from->index());
+ for (auto it = from; it != timeline.crend(); ++it) {
+ if (const auto* reaction = it->viewAs<ReactionEvent>()) {
+ const auto& relation = reaction->relation();
+ relations[{ relation.eventId, relation.type }] << reaction;
+ emit q->updatedEvent(relation.eventId);
+ }
+ }
if (from <= q->readMarker())
updateUnreadCount(from, timeline.crend());
diff --git a/lib/room.h b/lib/room.h
index e09556b6..12fb012c 100644
--- a/lib/room.h
+++ b/lib/room.h
@@ -123,6 +123,7 @@ namespace QMatrixClient
public:
using Timeline = std::deque<TimelineItem>;
using PendingEvents = std::vector<PendingEventItem>;
+ using RelatedEvents = QVector<const RoomEvent*>;
using rev_iter_t = Timeline::const_reverse_iterator;
using timeline_iter_t = Timeline::const_iterator;
@@ -270,6 +271,11 @@ namespace QMatrixClient
PendingEvents::iterator findPendingEvent(const QString & txnId);
PendingEvents::const_iterator findPendingEvent(const QString & txnId) const;
+ const RelatedEvents relatedEvents(const QString& evtId,
+ const char* relType) const;
+ const RelatedEvents relatedEvents(const RoomEvent& evt,
+ const char* relType) const;
+
bool displayed() const;
/// Mark the room as currently displayed to the user
/**
@@ -430,6 +436,8 @@ namespace QMatrixClient
const QString& html,
MessageEventType type = MessageEventType::Text);
QString postHtmlText(const QString& plainText, const QString& html);
+ /// Send a reaction on a given event with a given key
+ QString postReaction(const QString& eventId, const QString& key);
QString postFile(const QString& plainText, const QUrl& localPath,
bool asGenericFile = false);
/** Post a pre-created room message event
@@ -590,6 +598,7 @@ namespace QMatrixClient
void tagsAboutToChange();
void tagsChanged();
+ void updatedEvent(QString eventId);
void replacedEvent(const RoomEvent* newEvent,
const RoomEvent* oldEvent);
diff --git a/libqmatrixclient.pri b/libqmatrixclient.pri
index 5a1a74a0..2ff14ce2 100644
--- a/libqmatrixclient.pri
+++ b/libqmatrixclient.pri
@@ -35,6 +35,7 @@ HEADERS += \
$$SRCPATH/events/roomavatarevent.h \
$$SRCPATH/events/typingevent.h \
$$SRCPATH/events/receiptevent.h \
+ $$SRCPATH/events/reactionevent.h \
$$SRCPATH/events/callanswerevent.h \
$$SRCPATH/events/callcandidatesevent.h \
$$SRCPATH/events/callhangupevent.h \
@@ -81,6 +82,7 @@ SOURCES += \
$$SRCPATH/events/roommessageevent.cpp \
$$SRCPATH/events/roommemberevent.cpp \
$$SRCPATH/events/typingevent.cpp \
+ $$SRCPATH/events/reactionevent.cpp \
$$SRCPATH/events/callanswerevent.cpp \
$$SRCPATH/events/callcandidatesevent.cpp \
$$SRCPATH/events/callhangupevent.cpp \