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 | |
19 | using namespace Quotient; |
20 | using namespace EventContent; |
21 | |
22 | using MsgType = RoomMessageEvent::MsgType; |
23 | |
24 | namespace { // Supporting internal definitions |
25 | constexpr auto RelatesToKey = "m.relates_to"_ls ; |
26 | constexpr auto MsgTypeKey = "msgtype"_ls ; |
27 | constexpr auto FormattedBodyKey = "formatted_body"_ls ; |
28 | constexpr auto TextTypeKey = "m.text"_ls ; |
29 | constexpr auto EmoteTypeKey = "m.emote"_ls ; |
30 | constexpr auto NoticeTypeKey = "m.notice"_ls ; |
31 | constexpr auto HtmlContentTypeId = "org.matrix.custom.html"_ls ; |
32 | |
33 | template <typename ContentT> |
34 | TypedBase* make(const QJsonObject& json) |
35 | { |
36 | return new ContentT(json); |
37 | } |
38 | |
39 | template <> |
40 | TypedBase* make<TextContent>(const QJsonObject& json) |
41 | { |
42 | return json.contains(key: FormattedBodyKey) || json.contains(key: RelatesToKey) |
43 | ? new TextContent(json) |
44 | : nullptr; |
45 | } |
46 | |
47 | struct MsgTypeDesc { |
48 | QLatin1String matrixType; |
49 | MsgType enumType; |
50 | TypedBase* (*maker)(const QJsonObject&); |
51 | }; |
52 | |
53 | constexpr 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 | |
64 | QString 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 | |
76 | MsgType 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 | |
88 | inline bool isReplacement(const Omittable<EventRelation>& rel) |
89 | { |
90 | return rel && rel->type == EventRelation::ReplacementType; |
91 | } |
92 | |
93 | } // anonymous namespace |
94 | |
95 | QJsonObject 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 | |
129 | RoomMessageEvent::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 | |
137 | RoomMessageEvent::RoomMessageEvent(const QString& plainBody, MsgType msgType, |
138 | TypedBase* content) |
139 | : RoomMessageEvent(plainBody, msgTypeToJson(enumType: msgType), content) |
140 | {} |
141 | |
142 | #if QT_VERSION_MAJOR < 6 |
143 | TypedBase* 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 | |
169 | RoomMessageEvent::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 | |
178 | RoomMessageEvent::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 | |
204 | RoomMessageEvent::MsgType RoomMessageEvent::msgtype() const |
205 | { |
206 | return jsonToMsgType(matrixType: rawMsgtype()); |
207 | } |
208 | |
209 | QString RoomMessageEvent::rawMsgtype() const |
210 | { |
211 | return contentPart<QString>(key: MsgTypeKey); |
212 | } |
213 | |
214 | QString RoomMessageEvent::plainBody() const |
215 | { |
216 | return contentPart<QString>(key: BodyKey); |
217 | } |
218 | |
219 | QMimeType RoomMessageEvent::mimeType() const |
220 | { |
221 | static const auto PlainTextMimeType = |
222 | QMimeDatabase().mimeTypeForName(nameOrAlias: "text/plain"_ls ); |
223 | return _content ? _content->type() : PlainTextMimeType; |
224 | } |
225 | |
226 | bool RoomMessageEvent::hasTextContent() const |
227 | { |
228 | return !content() |
229 | || (msgtype() == MsgType::Text || msgtype() == MsgType::Emote |
230 | || msgtype() == MsgType::Notice); |
231 | } |
232 | |
233 | bool RoomMessageEvent::hasFileContent() const |
234 | { |
235 | return content() && content()->fileInfo(); |
236 | } |
237 | |
238 | bool RoomMessageEvent::hasThumbnail() const |
239 | { |
240 | return content() && content()->thumbnailInfo(); |
241 | } |
242 | |
243 | QString 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 | |
252 | bool RoomMessageEvent::isReplaced() const |
253 | { |
254 | return unsignedPart<QJsonObject>(key: "m.relations"_ls ).contains(key: "m.replace"_ls ); |
255 | } |
256 | |
257 | QString 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 | |
266 | QString 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 | |
277 | QString RoomMessageEvent::rawMsgTypeForUrl(const QUrl& url) |
278 | { |
279 | return rawMsgTypeForMimeType(mimeType: QMimeDatabase().mimeTypeForUrl(url)); |
280 | } |
281 | |
282 | QString RoomMessageEvent::rawMsgTypeForFile(const QFileInfo& fi) |
283 | { |
284 | return rawMsgTypeForMimeType(mimeType: QMimeDatabase().mimeTypeForFile(fileInfo: fi)); |
285 | } |
286 | |
287 | TextContent::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 | |
297 | TextContent::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 | |
320 | void 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 | |
348 | LocationContent::LocationContent(const QString& geoUri, |
349 | const Thumbnail& thumbnail) |
350 | : geoUri(geoUri), thumbnail(thumbnail) |
351 | {} |
352 | |
353 | LocationContent::LocationContent(const QJsonObject& json) |
354 | : TypedBase(json) |
355 | , geoUri(json["geo_uri" _ls].toString()) |
356 | , thumbnail(json["info" _ls].toObject()) |
357 | {} |
358 | |
359 | QMimeType LocationContent::type() const |
360 | { |
361 | return QMimeDatabase().mimeTypeForData(data: geoUri.toLatin1()); |
362 | } |
363 | |
364 | void 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 | |