1// SPDX-FileCopyrightText: 2015 Felix Rohrbach <kde@fxrh.de>
2// SPDX-FileCopyrightText: 2016 Kitsune Ral <Kitsune-Ral@users.sf.net>
3// SPDX-FileCopyrightText: 2017 Roman Plášil <me@rplasil.name>
4// SPDX-License-Identifier: LGPL-2.1-or-later
5
6#include "roommessageevent.h"
7
8#include "../logging_categories_p.h"
9#include "eventrelation.h"
10
11#include <QtCore/QFileInfo>
12#include <QtCore/QMimeDatabase>
13#include <QtGui/QImageReader>
14
15#if QT_VERSION_MAJOR < 6
16# include <QtMultimedia/QMediaResource>
17#endif
18
19using namespace Quotient;
20using namespace EventContent;
21
22using MsgType = RoomMessageEvent::MsgType;
23
24namespace { // Supporting internal definitions
25constexpr auto RelatesToKey = "m.relates_to"_ls;
26constexpr auto MsgTypeKey = "msgtype"_ls;
27constexpr auto FormattedBodyKey = "formatted_body"_ls;
28constexpr auto TextTypeKey = "m.text"_ls;
29constexpr auto EmoteTypeKey = "m.emote"_ls;
30constexpr auto NoticeTypeKey = "m.notice"_ls;
31constexpr auto HtmlContentTypeId = "org.matrix.custom.html"_ls;
32
33template <typename ContentT>
34TypedBase* make(const QJsonObject& json)
35{
36 return new ContentT(json);
37}
38
39template <>
40TypedBase* make<TextContent>(const QJsonObject& json)
41{
42 return json.contains(key: FormattedBodyKey) || json.contains(key: RelatesToKey)
43 ? new TextContent(json)
44 : nullptr;
45}
46
47struct MsgTypeDesc {
48 QLatin1String matrixType;
49 MsgType enumType;
50 TypedBase* (*maker)(const QJsonObject&);
51};
52
53constexpr auto msgTypes = std::to_array<MsgTypeDesc>(a: {
54 { .matrixType: TextTypeKey, .enumType: MsgType::Text, .maker: make<TextContent> },
55 { .matrixType: EmoteTypeKey, .enumType: MsgType::Emote, .maker: make<TextContent> },
56 { .matrixType: NoticeTypeKey, .enumType: MsgType::Notice, .maker: make<TextContent> },
57 { .matrixType: "m.image"_ls, .enumType: MsgType::Image, .maker: make<ImageContent> },
58 { .matrixType: "m.file"_ls, .enumType: MsgType::File, .maker: make<FileContent> },
59 { .matrixType: "m.location"_ls, .enumType: MsgType::Location, .maker: make<LocationContent> },
60 { .matrixType: "m.video"_ls, .enumType: MsgType::Video, .maker: make<VideoContent> },
61 { .matrixType: "m.audio"_ls, .enumType: MsgType::Audio, .maker: make<AudioContent> }
62});
63
64QString msgTypeToJson(MsgType enumType)
65{
66 auto it = std::find_if(first: msgTypes.begin(), last: msgTypes.end(),
67 pred: [=](const MsgTypeDesc& mtd) {
68 return mtd.enumType == enumType;
69 });
70 if (it != msgTypes.end())
71 return it->matrixType;
72
73 return {};
74}
75
76MsgType jsonToMsgType(const QString& matrixType)
77{
78 auto it = std::find_if(first: msgTypes.begin(), last: msgTypes.end(),
79 pred: [=](const MsgTypeDesc& mtd) {
80 return mtd.matrixType == matrixType;
81 });
82 if (it != msgTypes.end())
83 return it->enumType;
84
85 return MsgType::Unknown;
86}
87
88inline bool isReplacement(const Omittable<EventRelation>& rel)
89{
90 return rel && rel->type == EventRelation::ReplacementType;
91}
92
93} // anonymous namespace
94
95QJsonObject RoomMessageEvent::assembleContentJson(const QString& plainBody,
96 const QString& jsonMsgType,
97 TypedBase* content)
98{
99 QJsonObject json;
100 if (content) {
101 // TODO: replace with content->fillJson(json) when it starts working
102 json = content->toJson();
103 if (jsonMsgType != TextTypeKey && jsonMsgType != NoticeTypeKey
104 && jsonMsgType != EmoteTypeKey) {
105 if (json.contains(key: RelatesToKey)) {
106 json.remove(key: RelatesToKey);
107 qCWarning(EVENTS)
108 << RelatesToKey << "cannot be used in" << jsonMsgType
109 << "messages; the relation has been stripped off";
110 }
111 } else if (auto* textContent = static_cast<const TextContent*>(content);
112 textContent->relatesTo
113 && textContent->relatesTo->type
114 == EventRelation::ReplacementType) {
115 auto newContentJson = json.take(key: "m.new_content"_ls).toObject();
116 newContentJson.insert(key: BodyKey, value: plainBody);
117 newContentJson.insert(key: MsgTypeKey, value: jsonMsgType);
118 json.insert(QStringLiteral("m.new_content"), value: newContentJson);
119 json[MsgTypeKey] = jsonMsgType;
120 json[BodyKey] = "* "_ls + plainBody;
121 return json;
122 }
123 }
124 json.insert(key: MsgTypeKey, value: jsonMsgType);
125 json.insert(key: BodyKey, value: plainBody);
126 return json;
127}
128
129RoomMessageEvent::RoomMessageEvent(const QString& plainBody,
130 const QString& jsonMsgType,
131 TypedBase* content)
132 : RoomEvent(
133 basicJson(matrixType: TypeId, content: assembleContentJson(plainBody, jsonMsgType, content)))
134 , _content(content)
135{}
136
137RoomMessageEvent::RoomMessageEvent(const QString& plainBody, MsgType msgType,
138 TypedBase* content)
139 : RoomMessageEvent(plainBody, msgTypeToJson(enumType: msgType), content)
140{}
141
142#if QT_VERSION_MAJOR < 6
143TypedBase* contentFromFile(const QFileInfo& file, bool asGenericFile)
144{
145 auto filePath = file.absoluteFilePath();
146 auto localUrl = QUrl::fromLocalFile(filePath);
147 auto mimeType = QMimeDatabase().mimeTypeForFile(file);
148 if (!asGenericFile) {
149 auto mimeTypeName = mimeType.name();
150 if (mimeTypeName.startsWith("image/"_ls))
151 return new ImageContent(localUrl, file.size(), mimeType,
152 QImageReader(filePath).size(),
153 file.fileName());
154
155 // duration can only be obtained asynchronously and can only be reliably
156 // done by starting to play the file. Left for a future implementation.
157 if (mimeTypeName.startsWith("video/"_ls))
158 return new VideoContent(localUrl, file.size(), mimeType,
159 QMediaResource(localUrl).resolution(),
160 file.fileName());
161
162 if (mimeTypeName.startsWith("audio/"_ls))
163 return new AudioContent(localUrl, file.size(), mimeType,
164 file.fileName());
165 }
166 return new FileContent(localUrl, file.size(), mimeType, file.fileName());
167}
168
169RoomMessageEvent::RoomMessageEvent(const QString& plainBody,
170 const QFileInfo& file, bool asGenericFile)
171 : RoomMessageEvent(plainBody,
172 asGenericFile ? QStringLiteral("m.file")
173 : rawMsgTypeForFile(file),
174 contentFromFile(file, asGenericFile))
175{}
176#endif
177
178RoomMessageEvent::RoomMessageEvent(const QJsonObject& obj)
179 : RoomEvent(obj), _content(nullptr)
180{
181 if (isRedacted())
182 return;
183 const QJsonObject content = contentJson();
184 if (content.contains(key: MsgTypeKey) && content.contains(key: BodyKey)) {
185 auto msgtype = content[MsgTypeKey].toString();
186 bool msgTypeFound = false;
187 for (const auto& mt : msgTypes)
188 if (mt.matrixType == msgtype) {
189 _content.reset(other: mt.maker(content));
190 msgTypeFound = true;
191 }
192
193 if (!msgTypeFound) {
194 qCWarning(EVENTS) << "RoomMessageEvent: unknown msg_type,"
195 << " full content dump follows";
196 qCWarning(EVENTS) << formatJson << content;
197 }
198 } else {
199 qCWarning(EVENTS) << "No body or msgtype in room message event";
200 qCWarning(EVENTS) << formatJson << obj;
201 }
202}
203
204RoomMessageEvent::MsgType RoomMessageEvent::msgtype() const
205{
206 return jsonToMsgType(matrixType: rawMsgtype());
207}
208
209QString RoomMessageEvent::rawMsgtype() const
210{
211 return contentPart<QString>(key: MsgTypeKey);
212}
213
214QString RoomMessageEvent::plainBody() const
215{
216 return contentPart<QString>(key: BodyKey);
217}
218
219QMimeType RoomMessageEvent::mimeType() const
220{
221 static const auto PlainTextMimeType =
222 QMimeDatabase().mimeTypeForName(nameOrAlias: "text/plain"_ls);
223 return _content ? _content->type() : PlainTextMimeType;
224}
225
226bool RoomMessageEvent::hasTextContent() const
227{
228 return !content()
229 || (msgtype() == MsgType::Text || msgtype() == MsgType::Emote
230 || msgtype() == MsgType::Notice);
231}
232
233bool RoomMessageEvent::hasFileContent() const
234{
235 return content() && content()->fileInfo();
236}
237
238bool RoomMessageEvent::hasThumbnail() const
239{
240 return content() && content()->thumbnailInfo();
241}
242
243QString RoomMessageEvent::replacedEvent() const
244{
245 if (!content() || !hasTextContent())
246 return {};
247
248 const auto& rel = static_cast<const TextContent*>(content())->relatesTo;
249 return isReplacement(rel) ? rel->eventId : QString();
250}
251
252bool RoomMessageEvent::isReplaced() const
253{
254 return unsignedPart<QJsonObject>(key: "m.relations"_ls).contains(key: "m.replace"_ls);
255}
256
257QString RoomMessageEvent::replacedBy() const
258{
259 // clang-format off
260 return unsignedPart<QJsonObject>(key: "m.relations"_ls)
261 .value(key: "m.replace"_ls).toObject()
262 .value(key: EventIdKey).toString();
263 // clang-format on
264}
265
266QString rawMsgTypeForMimeType(const QMimeType& mimeType)
267{
268 auto name = mimeType.name();
269 return name.startsWith(s: "image/"_ls)
270 ? QStringLiteral("m.image")
271 : name.startsWith(s: "video/"_ls)
272 ? QStringLiteral("m.video")
273 : name.startsWith(s: "audio/"_ls) ? QStringLiteral("m.audio")
274 : QStringLiteral("m.file");
275}
276
277QString RoomMessageEvent::rawMsgTypeForUrl(const QUrl& url)
278{
279 return rawMsgTypeForMimeType(mimeType: QMimeDatabase().mimeTypeForUrl(url));
280}
281
282QString RoomMessageEvent::rawMsgTypeForFile(const QFileInfo& fi)
283{
284 return rawMsgTypeForMimeType(mimeType: QMimeDatabase().mimeTypeForFile(fileInfo: fi));
285}
286
287TextContent::TextContent(QString text, const QString& contentType,
288 Omittable<EventRelation> relatesTo)
289 : mimeType(QMimeDatabase().mimeTypeForName(nameOrAlias: contentType))
290 , body(std::move(text))
291 , relatesTo(std::move(relatesTo))
292{
293 if (contentType == HtmlContentTypeId)
294 mimeType = QMimeDatabase().mimeTypeForName(nameOrAlias: "text/html"_ls);
295}
296
297TextContent::TextContent(const QJsonObject& json)
298 : relatesTo(fromJson<Omittable<EventRelation>>(json: json[RelatesToKey]))
299{
300 QMimeDatabase db;
301 static const auto PlainTextMimeType = db.mimeTypeForName(nameOrAlias: "text/plain"_ls);
302 static const auto HtmlMimeType = db.mimeTypeForName(nameOrAlias: "text/html"_ls);
303
304 const auto actualJson = isReplacement(rel: relatesTo)
305 ? json.value(key: "m.new_content"_ls).toObject()
306 : json;
307 // Special-casing the custom matrix.org's (actually, Element's) way
308 // of sending HTML messages.
309 if (actualJson["format"_ls].toString() == HtmlContentTypeId) {
310 mimeType = HtmlMimeType;
311 body = actualJson[FormattedBodyKey].toString();
312 } else {
313 // Falling back to plain text, as there's no standard way to describe
314 // rich text in messages.
315 mimeType = PlainTextMimeType;
316 body = actualJson[BodyKey].toString();
317 }
318}
319
320void TextContent::fillJson(QJsonObject &json) const
321{
322 static const auto FormatKey = QStringLiteral("format");
323
324 if (mimeType.inherits(mimeTypeName: "text/html"_ls)) {
325 json.insert(key: FormatKey, value: HtmlContentTypeId);
326 json.insert(key: FormattedBodyKey, value: body);
327 }
328 if (relatesTo) {
329 json.insert(
330 QStringLiteral("m.relates_to"),
331 value: relatesTo->type == EventRelation::ReplyType
332 ? QJsonObject { { relatesTo->type,
333 QJsonObject {
334 { EventIdKey, relatesTo->eventId } } } }
335 : QJsonObject { { RelTypeKey, relatesTo->type },
336 { EventIdKey, relatesTo->eventId } });
337 if (relatesTo->type == EventRelation::ReplacementType) {
338 QJsonObject newContentJson;
339 if (mimeType.inherits(mimeTypeName: "text/html"_ls)) {
340 newContentJson.insert(key: FormatKey, value: HtmlContentTypeId);
341 newContentJson.insert(key: FormattedBodyKey, value: body);
342 }
343 json.insert(QStringLiteral("m.new_content"), value: newContentJson);
344 }
345 }
346}
347
348LocationContent::LocationContent(const QString& geoUri,
349 const Thumbnail& thumbnail)
350 : geoUri(geoUri), thumbnail(thumbnail)
351{}
352
353LocationContent::LocationContent(const QJsonObject& json)
354 : TypedBase(json)
355 , geoUri(json["geo_uri"_ls].toString())
356 , thumbnail(json["info"_ls].toObject())
357{}
358
359QMimeType LocationContent::type() const
360{
361 return QMimeDatabase().mimeTypeForData(data: geoUri.toLatin1());
362}
363
364void LocationContent::fillJson(QJsonObject& o) const
365{
366 o.insert(QStringLiteral("geo_uri"), value: geoUri);
367 o.insert(QStringLiteral("info"), value: toInfoJson(info: thumbnail));
368}
369