aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorKitsune Ral <Kitsune-Ral@users.sf.net>2019-07-13 22:43:47 +0900
committerKitsune Ral <Kitsune-Ral@users.sf.net>2019-07-31 11:16:22 +0900
commitb87097866f38b90f36fb216b7516a135227930a1 (patch)
tree338fcef74f834c70e849e5e605660af4a6a4bcd6
parent26bc529ec86dce5478ab37222a27902af7f0dd5a (diff)
downloadlibquotient-b87097866f38b90f36fb216b7516a135227930a1.tar.gz
libquotient-b87097866f38b90f36fb216b7516a135227930a1.zip
Initial support for edited messages (receive only)
-rw-r--r--lib/events/roomevent.cpp14
-rw-r--r--lib/events/roomevent.h2
-rw-r--r--lib/events/roommessageevent.cpp91
-rw-r--r--lib/events/roommessageevent.h3
-rw-r--r--lib/room.cpp96
5 files changed, 180 insertions, 26 deletions
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 8f4e0ebc..d4b0d812 100644
--- a/lib/events/roommessageevent.cpp
+++ b/lib/events/roommessageevent.cpp
@@ -36,6 +36,7 @@ static const auto BodyKey = "body"_ls;
static const auto FormattedBodyKey = "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");
@@ -62,7 +63,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> }
@@ -95,12 +96,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(RelatesToKey)) {
+ if (jsonMsgType != TextTypeKey && jsonMsgType != NoticeTypeKey
+ && jsonMsgType != EmoteTypeKey) {
+ json.remove(RelatesToKey);
+ qCWarning(EVENTS)
+ << RelatesToKey << "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[BodyKey] = "* " + plainBody;
+ }
+ }
}
json.insert(QStringLiteral("msgtype"), jsonMsgType);
json.insert(QStringLiteral("body"), plainBody);
@@ -223,6 +237,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();
@@ -251,41 +275,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[RelatesToKey]))
{
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[FormattedBodyKey].toString();
} else {
// Falling back to plain text, as there's no standard way to describe
// rich text in messages.
mimeType = PlainTextMimeType;
- body = json[BodyKey].toString();
+ body = actualJson[BodyKey].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 RichBodyKey = 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(RichBodyKey, 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(RichBodyKey, 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 cb368d9e..958991b7 100644
--- a/lib/room.cpp
+++ b/lib/room.cpp
@@ -307,14 +307,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;
@@ -2041,6 +2049,52 @@ bool Room::Private::processRedaction(const RedactionEvent& redaction)
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);
@@ -2052,10 +2106,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)
@@ -2067,18 +2127,19 @@ Room::Changes Room::Private::addNewMessageEvents(RoomEvents&& events)
// 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()))
+ 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(), redactionIt,
+ auto targetIt = std::find_if(events.begin(), it,
[id=r->redactedEvent()] (const RoomEventPtr& ep) {
return ep->id() == id;
});
- if (targetIt != redactionIt)
+ if (targetIt != it)
*targetIt = makeRedacted(**targetIt, *r);
else
qCDebug(MAIN) << "Redaction" << r->id()
@@ -2086,6 +2147,25 @@ Room::Changes Room::Private::addNewMessageEvents(RoomEvents&& events)
<< "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