| 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 | |