| 1 | // SPDX-FileCopyrightText: 2016 Kitsune Ral <Kitsune-Ral@users.sf.net> |
| 2 | // SPDX-FileCopyrightText: 2017 Roman Plášil <me@rplasil.name> |
| 3 | // SPDX-FileCopyrightText: 2017 Marius Gripsgard <marius@ubports.com> |
| 4 | // SPDX-FileCopyrightText: 2018 Josip Delic <delijati@googlemail.com> |
| 5 | // SPDX-FileCopyrightText: 2018 Black Hat <bhat@encom.eu.org> |
| 6 | // SPDX-FileCopyrightText: 2019 Alexey Andreyev <aa13q@ya.ru> |
| 7 | // SPDX-FileCopyrightText: 2020 Ram Nad <ramnad1999@gmail.com> |
| 8 | // SPDX-License-Identifier: LGPL-2.1-or-later |
| 9 | |
| 10 | #include "room.h" |
| 11 | |
| 12 | #include "logging_categories_p.h" |
| 13 | |
| 14 | #include "avatar.h" |
| 15 | #include "connection.h" |
| 16 | #include "converters.h" |
| 17 | #include "eventstats.h" |
| 18 | #include "qt_connection_util.h" |
| 19 | #include "roomstateview.h" |
| 20 | #include "syncdata.h" |
| 21 | #include "user.h" |
| 22 | |
| 23 | // NB: since Qt 6, moc_room.cpp needs User fully defined |
| 24 | #include "moc_room.cpp" // NOLINT(bugprone-suspicious-include) |
| 25 | |
| 26 | #include "csapi/account-data.h" |
| 27 | #include "csapi/banning.h" |
| 28 | #include "csapi/inviting.h" |
| 29 | #include "csapi/kicking.h" |
| 30 | #include "csapi/leaving.h" |
| 31 | #include "csapi/read_markers.h" |
| 32 | #include "csapi/receipts.h" |
| 33 | #include "csapi/redaction.h" |
| 34 | #include "csapi/room_send.h" |
| 35 | #include "csapi/room_state.h" |
| 36 | #include "csapi/room_upgrades.h" |
| 37 | #include "csapi/rooms.h" |
| 38 | #include "csapi/tags.h" |
| 39 | |
| 40 | #include "events/callevents.h" |
| 41 | #include "events/encryptionevent.h" |
| 42 | #include "events/reactionevent.h" |
| 43 | #include "events/receiptevent.h" |
| 44 | #include "events/redactionevent.h" |
| 45 | #include "events/roomavatarevent.h" |
| 46 | #include "events/roomcanonicalaliasevent.h" |
| 47 | #include "events/roomcreateevent.h" |
| 48 | #include "events/roommemberevent.h" |
| 49 | #include "events/roompowerlevelsevent.h" |
| 50 | #include "events/roomtombstoneevent.h" |
| 51 | #include "events/simplestateevents.h" |
| 52 | #include "events/typingevent.h" |
| 53 | #include "jobs/downloadfilejob.h" |
| 54 | #include "jobs/mediathumbnailjob.h" |
| 55 | |
| 56 | #include <QtCore/QDir> |
| 57 | #include <QtCore/QHash> |
| 58 | #include <QtCore/QPointer> |
| 59 | #include <QtCore/QRegularExpression> |
| 60 | #include <QtCore/QStringBuilder> // for efficient string concats (operator%) |
| 61 | #include <QtCore/QTemporaryFile> |
| 62 | |
| 63 | #include <array> |
| 64 | #include <cmath> |
| 65 | #include <functional> |
| 66 | |
| 67 | #ifdef Quotient_E2EE_ENABLED |
| 68 | #include "e2ee/e2ee_common.h" |
| 69 | #include "e2ee/qolmaccount.h" |
| 70 | #include "e2ee/qolminboundsession.h" |
| 71 | #include "database.h" |
| 72 | #endif // Quotient_E2EE_ENABLED |
| 73 | |
| 74 | |
| 75 | using namespace Quotient; |
| 76 | using namespace std::placeholders; |
| 77 | #if !(defined __GLIBCXX__ && __GLIBCXX__ <= 20150123) |
| 78 | using std::llround; |
| 79 | #endif |
| 80 | |
| 81 | enum EventsPlacement : int { Older = -1, Newer = 1 }; |
| 82 | |
| 83 | class Q_DECL_HIDDEN Room::Private { |
| 84 | public: |
| 85 | /// Map of user names to users |
| 86 | /** User names potentially duplicate, hence QMultiHash. */ |
| 87 | using members_map_t = QMultiHash<QString, User*>; |
| 88 | |
| 89 | Private(Connection* c, QString id_, JoinState initialJoinState) |
| 90 | : connection(c) |
| 91 | , id(std::move(id_)) |
| 92 | , joinState(initialJoinState) |
| 93 | {} |
| 94 | |
| 95 | Room* q = nullptr; |
| 96 | |
| 97 | Connection* connection; |
| 98 | QString id; |
| 99 | JoinState joinState; |
| 100 | RoomSummary summary = { .joinedMemberCount: none, .invitedMemberCount: 0, .heroes: none }; |
| 101 | /// The state of the room at timeline position before-0 |
| 102 | UnorderedMap<StateEventKey, StateEventPtr> baseState; |
| 103 | /// State event stubs - events without content, just type and state key |
| 104 | static decltype(baseState) stubbedState; |
| 105 | /// The state of the room at syncEdge() |
| 106 | /// \sa syncEdge |
| 107 | RoomStateView currentState; |
| 108 | /// Servers with aliases for this room except the one of the local user |
| 109 | /// \sa Room::remoteAliases |
| 110 | QSet<QString> aliasServers; |
| 111 | |
| 112 | Timeline timeline; |
| 113 | PendingEvents unsyncedEvents; |
| 114 | QHash<QString, TimelineItem::index_t> eventsIndex; |
| 115 | // A map from evtId to a map of relation type to a vector of event |
| 116 | // pointers. Not using QMultiHash, because we want to quickly return |
| 117 | // a number of relations for a given event without enumerating them. |
| 118 | QHash<std::pair<QString, QString>, RelatedEvents> relations; |
| 119 | QString displayname; |
| 120 | Avatar avatar; |
| 121 | QHash<QString, Notification> notifications; |
| 122 | qsizetype serverHighlightCount = 0; |
| 123 | // Starting up with estimate event statistics as there's zero knowledge |
| 124 | // about the timeline. |
| 125 | EventStats partiallyReadStats {}, unreadStats {}; |
| 126 | members_map_t membersMap; |
| 127 | QList<User*> usersTyping; |
| 128 | QHash<QString, QSet<QString>> eventIdReadUsers; |
| 129 | QList<User*> usersInvited; |
| 130 | QList<User*> membersLeft; |
| 131 | bool displayed = false; |
| 132 | QString firstDisplayedEventId; |
| 133 | QString lastDisplayedEventId; |
| 134 | QHash<QString, ReadReceipt> lastReadReceipts; |
| 135 | QString fullyReadUntilEventId; |
| 136 | TagsMap tags; |
| 137 | UnorderedMap<QString, EventPtr> accountData; |
| 138 | //! \brief Previous (i.e. next towards the room beginning) batch token |
| 139 | //! |
| 140 | //! "Emptiness" of this can have two forms. If prevBatch.has_value() it |
| 141 | //! means the library assumes the previous batch to exist on the server, |
| 142 | //! even though it might not know the token (hence initialisation with |
| 143 | //! a null string). If prevBatch == none it means that the server previously |
| 144 | //! reported that all events have been loaded and there's no point in |
| 145 | //! requesting further historical batches. |
| 146 | Omittable<QString> prevBatch = QString(); |
| 147 | QPointer<GetRoomEventsJob> eventsHistoryJob; |
| 148 | QPointer<GetMembersByRoomJob> allMembersJob; |
| 149 | //! Map from megolm sessionId to set of eventIds |
| 150 | UnorderedMap<QString, QSet<QString>> undecryptedEvents; |
| 151 | |
| 152 | struct FileTransferPrivateInfo { |
| 153 | FileTransferPrivateInfo() = default; |
| 154 | FileTransferPrivateInfo(BaseJob* j, const QString& fileName, |
| 155 | bool isUploading = false) |
| 156 | : status(FileTransferInfo::Started) |
| 157 | , job(j) |
| 158 | , localFileInfo(fileName) |
| 159 | , isUpload(isUploading) |
| 160 | {} |
| 161 | |
| 162 | FileTransferInfo::Status status = FileTransferInfo::None; |
| 163 | QPointer<BaseJob> job = nullptr; |
| 164 | QFileInfo localFileInfo {}; |
| 165 | bool isUpload = false; |
| 166 | qint64 progress = 0; |
| 167 | qint64 total = -1; |
| 168 | |
| 169 | void update(qint64 p, qint64 t) |
| 170 | { |
| 171 | if (t == 0) { |
| 172 | t = -1; |
| 173 | if (p == 0) |
| 174 | p = -1; |
| 175 | } |
| 176 | if (p != -1) |
| 177 | qCDebug(PROFILER) << "Transfer progress:" << p << "/" << t |
| 178 | << "=" << llround(x: double(p) / t * 100) << "%" ; |
| 179 | progress = p; |
| 180 | total = t; |
| 181 | } |
| 182 | }; |
| 183 | void failedTransfer(const QString& tid, const QString& errorMessage = {}) |
| 184 | { |
| 185 | qCWarning(MAIN) << "File transfer failed for id" << tid; |
| 186 | if (!errorMessage.isEmpty()) |
| 187 | qCWarning(MAIN) << "Message:" << errorMessage; |
| 188 | fileTransfers[tid].status = FileTransferInfo::Failed; |
| 189 | emit q->fileTransferFailed(id: tid, errorMessage); |
| 190 | } |
| 191 | /// A map from event/txn ids to information about the long operation; |
| 192 | /// used for both download and upload operations |
| 193 | QHash<QString, FileTransferPrivateInfo> fileTransfers; |
| 194 | |
| 195 | const RoomMessageEvent* getEventWithFile(const QString& eventId) const; |
| 196 | QString fileNameToDownload(const RoomMessageEvent* event) const; |
| 197 | |
| 198 | Changes setSummary(RoomSummary&& newSummary); |
| 199 | |
| 200 | void preprocessStateEvent(const RoomEvent& newEvent, |
| 201 | const RoomEvent* curEvent); |
| 202 | Change processStateEvent(const RoomEvent& curEvent, |
| 203 | const RoomEvent* oldEvent); |
| 204 | |
| 205 | // void inviteUser(User* u); // We might get it at some point in time. |
| 206 | void insertMemberIntoMap(User* u); |
| 207 | void removeMemberFromMap(User* u); |
| 208 | |
| 209 | // This updates the room displayname field (which is the way a room |
| 210 | // should be shown in the room list); called whenever the list of |
| 211 | // members, the room name (m.room.name) or canonical alias change. |
| 212 | void updateDisplayname(); |
| 213 | // This is used by updateDisplayname() but only calculates the new name |
| 214 | // without any updates. |
| 215 | QString calculateDisplayname() const; |
| 216 | |
| 217 | rev_iter_t historyEdge() const { return timeline.crend(); } |
| 218 | Timeline::const_iterator syncEdge() const { return timeline.cend(); } |
| 219 | |
| 220 | void getPreviousContent(int limit = 10, const QString &filter = {}); |
| 221 | |
| 222 | const StateEvent* getCurrentState(const StateEventKey& evtKey) const |
| 223 | { |
| 224 | const auto* evt = currentState.value(key: evtKey, defaultValue: nullptr); |
| 225 | if (!evt) { |
| 226 | if (stubbedState.find(x: evtKey) == stubbedState.end()) { |
| 227 | // In the absence of a real event, make a stub as-if an event |
| 228 | // with empty content has been received. Event classes should be |
| 229 | // prepared for empty/invalid/malicious content anyway. |
| 230 | stubbedState.emplace( |
| 231 | args: evtKey, args: loadEvent<StateEvent>(matrixType: evtKey.first, otherBasicJsonParams: evtKey.second)); |
| 232 | qCDebug(STATE) << "A new stub event created for key {" |
| 233 | << evtKey.first << evtKey.second << "}" ; |
| 234 | qCDebug(STATE) << "Stubbed state size:" << stubbedState.size(); |
| 235 | } |
| 236 | evt = stubbedState[evtKey].get(); |
| 237 | Q_ASSERT(evt); |
| 238 | } |
| 239 | Q_ASSERT(evt->matrixType() == evtKey.first |
| 240 | && evt->stateKey() == evtKey.second); |
| 241 | return evt; |
| 242 | } |
| 243 | |
| 244 | Changes updateStateFrom(StateEvents&& events) |
| 245 | { |
| 246 | Changes changes {}; |
| 247 | if (!events.empty()) { |
| 248 | QElapsedTimer et; |
| 249 | et.start(); |
| 250 | for (auto&& eptr : std::move(events)) { |
| 251 | const auto& evt = *eptr; |
| 252 | Q_ASSERT(evt.isStateEvent()); |
| 253 | if (auto change = q->processStateEvent(e: evt); change) { |
| 254 | changes |= change; |
| 255 | baseState[{ evt.matrixType(), evt.stateKey() }] = |
| 256 | std::move(eptr); |
| 257 | } |
| 258 | } |
| 259 | if (events.size() > 9 || et.nsecsElapsed() >= ProfilerMinNsecs) |
| 260 | qCDebug(PROFILER) |
| 261 | << "Updated" << q->objectName() << "room state from" |
| 262 | << events.size() << "event(s) in" << et; |
| 263 | } |
| 264 | return changes; |
| 265 | } |
| 266 | void addRelation(const ReactionEvent& reactionEvt); |
| 267 | void addRelations(auto from, auto to) |
| 268 | { |
| 269 | for (auto it = from; it != to; ++it) |
| 270 | if (const auto* reaction = it->template viewAs<ReactionEvent>()) |
| 271 | addRelation(reactionEvt: *reaction); |
| 272 | } |
| 273 | |
| 274 | Changes addNewMessageEvents(RoomEvents&& events); |
| 275 | void addHistoricalMessageEvents(RoomEvents&& events); |
| 276 | |
| 277 | Changes updateStatsFromSyncData(const SyncRoomData &data, bool fromCache); |
| 278 | void postprocessChanges(Changes changes, bool saveState = true); |
| 279 | |
| 280 | /** Move events into the timeline |
| 281 | * |
| 282 | * Insert events into the timeline, either new or historical. |
| 283 | * Pointers in the original container become empty, the ownership |
| 284 | * is passed to the timeline container. |
| 285 | * @param events - the range of events to be inserted |
| 286 | * @param placement - position and direction of insertion: Older for |
| 287 | * historical messages, Newer for new ones |
| 288 | */ |
| 289 | Timeline::size_type moveEventsToTimeline(RoomEventsRange events, |
| 290 | EventsPlacement placement); |
| 291 | |
| 292 | /** |
| 293 | * Remove events from the passed container that are already in the timeline |
| 294 | */ |
| 295 | void dropExtraneousEvents(RoomEvents& events) const; |
| 296 | void decryptIncomingEvents(RoomEvents& events); |
| 297 | |
| 298 | //! \brief update last receipt record for a given user |
| 299 | //! |
| 300 | //! \return previous event id of the receipt if the new receipt changed |
| 301 | //! it, or `none` if no change took place |
| 302 | Omittable<QString> setLastReadReceipt(const QString& userId, rev_iter_t newMarker, |
| 303 | ReadReceipt newReceipt = {}); |
| 304 | Changes setLocalLastReadReceipt(const rev_iter_t& newMarker, |
| 305 | ReadReceipt newReceipt = {}, |
| 306 | bool deferStatsUpdate = false); |
| 307 | Changes setFullyReadMarker(const QString &eventId); |
| 308 | Changes updateStats(const rev_iter_t& from, const rev_iter_t& to); |
| 309 | bool markMessagesAsRead(const rev_iter_t& upToMarker); |
| 310 | |
| 311 | void getAllMembers(); |
| 312 | |
| 313 | QString sendEvent(RoomEventPtr&& event); |
| 314 | |
| 315 | template <typename EventT, typename... ArgTs> |
| 316 | QString sendEvent(ArgTs&&... eventArgs) |
| 317 | { |
| 318 | return sendEvent(makeEvent<EventT>(std::forward<ArgTs>(eventArgs)...)); |
| 319 | } |
| 320 | |
| 321 | QString doPostFile(RoomEventPtr &&msgEvent, const QUrl &localUrl); |
| 322 | |
| 323 | RoomEvent* addAsPending(RoomEventPtr&& event); |
| 324 | |
| 325 | QString doSendEvent(const RoomEvent* pEvent); |
| 326 | void onEventSendingFailure(const QString& txnId, BaseJob* call = nullptr); |
| 327 | |
| 328 | SetRoomStateWithKeyJob* requestSetState(const QString& evtType, |
| 329 | const QString& stateKey, |
| 330 | const QJsonObject& contentJson) |
| 331 | { |
| 332 | // if (event.roomId().isEmpty()) |
| 333 | // event.setRoomId(id); |
| 334 | // if (event.senderId().isEmpty()) |
| 335 | // event.setSender(connection->userId()); |
| 336 | // TODO: Queue up state events sending (see #133). |
| 337 | // TODO: Maybe addAsPending() as well, despite having no txnId |
| 338 | return connection->callApi<SetRoomStateWithKeyJob>(jobArgs&: id, jobArgs: evtType, jobArgs: stateKey, |
| 339 | jobArgs: contentJson); |
| 340 | } |
| 341 | |
| 342 | /*! Apply redaction to the timeline |
| 343 | * |
| 344 | * Tries to find an event in the timeline and redact it; deletes the |
| 345 | * redaction event whether the redacted event was found or not. |
| 346 | * \return true if the event has been found and redacted; false otherwise |
| 347 | */ |
| 348 | bool processRedaction(const RedactionEvent& redaction); |
| 349 | |
| 350 | /*! Apply a new revision of the event to the timeline |
| 351 | * |
| 352 | * Tries to find an event in the timeline and replace it with the new |
| 353 | * content passed in \p newMessage. |
| 354 | * \return true if the event has been found and replaced; false otherwise |
| 355 | */ |
| 356 | bool processReplacement(const RoomMessageEvent& newEvent); |
| 357 | |
| 358 | void setTags(TagsMap&& newTags); |
| 359 | |
| 360 | QJsonObject toJson() const; |
| 361 | |
| 362 | bool isLocalUser(const User* u) const { return u == q->localUser(); } |
| 363 | |
| 364 | #ifdef Quotient_E2EE_ENABLED |
| 365 | UnorderedMap<QByteArray, QOlmInboundGroupSession> groupSessions; |
| 366 | Omittable<QOlmOutboundGroupSession> currentOutboundMegolmSession = none; |
| 367 | |
| 368 | bool addInboundGroupSession(QByteArray sessionId, QByteArray sessionKey, |
| 369 | const QString& senderId, |
| 370 | const QByteArray& olmSessionId) |
| 371 | { |
| 372 | if (groupSessions.contains(sessionId)) { |
| 373 | qCWarning(E2EE) << "Inbound Megolm session" << sessionId << "already exists" ; |
| 374 | return false; |
| 375 | } |
| 376 | |
| 377 | auto expectedMegolmSession = QOlmInboundGroupSession::create(sessionKey); |
| 378 | Q_ASSERT(expectedMegolmSession.has_value()); |
| 379 | auto&& megolmSession = *expectedMegolmSession; |
| 380 | if (megolmSession.sessionId() != sessionId) { |
| 381 | qCWarning(E2EE) << "Session ID mismatch in m.room_key event" ; |
| 382 | return false; |
| 383 | } |
| 384 | megolmSession.setSenderId(senderId); |
| 385 | megolmSession.setOlmSessionId(olmSessionId); |
| 386 | qCWarning(E2EE) << "Adding inbound session" << sessionId; |
| 387 | connection->saveMegolmSession(q, megolmSession); |
| 388 | groupSessions.try_emplace(sessionId, std::move(megolmSession)); |
| 389 | return true; |
| 390 | } |
| 391 | |
| 392 | QString groupSessionDecryptMessage(const QByteArray& ciphertext, |
| 393 | const QByteArray& sessionId, |
| 394 | const QString& eventId, |
| 395 | const QDateTime& timestamp, |
| 396 | const QString& senderId) |
| 397 | { |
| 398 | auto groupSessionIt = groupSessions.find(sessionId); |
| 399 | if (groupSessionIt == groupSessions.end()) { |
| 400 | // qCWarning(E2EE) << "Unable to decrypt event" << eventId |
| 401 | // << "The sender's device has not sent us the keys for " |
| 402 | // "this message"; |
| 403 | // TODO: request the keys |
| 404 | return {}; |
| 405 | } |
| 406 | auto& senderSession = groupSessionIt->second; |
| 407 | if (senderSession.senderId() != senderId) { |
| 408 | qCWarning(E2EE) << "Sender from event does not match sender from session" ; |
| 409 | return {}; |
| 410 | } |
| 411 | auto decryptResult = senderSession.decrypt(ciphertext); |
| 412 | if(!decryptResult) { |
| 413 | qCWarning(E2EE) << "Unable to decrypt event" << eventId |
| 414 | << "with matching megolm session:" << decryptResult.error(); |
| 415 | return {}; |
| 416 | } |
| 417 | const auto& [content, index] = *decryptResult; |
| 418 | const auto& [recordEventId, ts] = |
| 419 | q->connection()->database()->groupSessionIndexRecord( |
| 420 | q->id(), QString::fromLatin1(senderSession.sessionId()), index); |
| 421 | if (recordEventId.isEmpty()) { |
| 422 | q->connection()->database()->addGroupSessionIndexRecord( |
| 423 | q->id(), QString::fromLatin1(senderSession.sessionId()), index, eventId, |
| 424 | timestamp.toMSecsSinceEpoch()); |
| 425 | } else { |
| 426 | if ((eventId != recordEventId) |
| 427 | || (ts != timestamp.toMSecsSinceEpoch())) { |
| 428 | qCWarning(E2EE) << "Detected a replay attack on event" << eventId; |
| 429 | return {}; |
| 430 | } |
| 431 | } |
| 432 | return QString::fromUtf8(content); |
| 433 | } |
| 434 | |
| 435 | bool shouldRotateMegolmSession() const |
| 436 | { |
| 437 | const auto* encryptionConfig = currentState.get<EncryptionEvent>(); |
| 438 | if (!encryptionConfig || !encryptionConfig->useEncryption()) |
| 439 | return false; |
| 440 | |
| 441 | const auto rotationInterval = encryptionConfig->rotationPeriodMs(); |
| 442 | const auto rotationMessageCount = encryptionConfig->rotationPeriodMsgs(); |
| 443 | return currentOutboundMegolmSession->messageCount() |
| 444 | >= rotationMessageCount |
| 445 | || currentOutboundMegolmSession->creationTime().addMSecs( |
| 446 | rotationInterval) |
| 447 | < QDateTime::currentDateTime(); |
| 448 | } |
| 449 | |
| 450 | bool hasValidMegolmSession() const |
| 451 | { |
| 452 | return q->usesEncryption() && currentOutboundMegolmSession.has_value(); |
| 453 | } |
| 454 | |
| 455 | void createMegolmSession() { |
| 456 | qCDebug(E2EE) << "Creating new outbound megolm session for room " |
| 457 | << q->objectName(); |
| 458 | currentOutboundMegolmSession.emplace(); |
| 459 | connection->database()->saveCurrentOutboundMegolmSession( |
| 460 | id, *currentOutboundMegolmSession); |
| 461 | |
| 462 | addInboundGroupSession(currentOutboundMegolmSession->sessionId(), |
| 463 | currentOutboundMegolmSession->sessionKey(), |
| 464 | q->localUser()->id(), QByteArrayLiteral("SELF" )); |
| 465 | } |
| 466 | |
| 467 | QMultiHash<QString, QString> getDevicesWithoutKey() const |
| 468 | { |
| 469 | QMultiHash<QString, QString> devices; |
| 470 | for (const auto& user : q->users() + usersInvited) |
| 471 | for (const auto& deviceId : connection->devicesForUser(user->id())) |
| 472 | devices.insert(user->id(), deviceId); |
| 473 | |
| 474 | return connection->database()->devicesWithoutKey( |
| 475 | id, devices, currentOutboundMegolmSession->sessionId()); |
| 476 | } |
| 477 | #endif // Quotient_E2EE_ENABLED |
| 478 | |
| 479 | private: |
| 480 | using users_shortlist_t = std::array<User*, 3>; |
| 481 | template <typename ContT> |
| 482 | users_shortlist_t buildShortlist(const ContT& users) const; |
| 483 | users_shortlist_t buildShortlist(const QStringList& userIds) const; |
| 484 | }; |
| 485 | |
| 486 | decltype(Room::Private::baseState) Room::Private::stubbedState {}; |
| 487 | |
| 488 | Room::Room(Connection* connection, QString id, JoinState initialJoinState) |
| 489 | : QObject(connection), d(new Private(connection, id, initialJoinState)) |
| 490 | { |
| 491 | setObjectName(id); |
| 492 | // See "Accessing the Public Class" section in |
| 493 | // https://marcmutz.wordpress.com/translated-articles/pimp-my-pimpl-%E2%80%94-reloaded/ |
| 494 | d->q = this; |
| 495 | d->displayname = d->calculateDisplayname(); // Set initial "Empty room" name |
| 496 | #ifdef Quotient_E2EE_ENABLED |
| 497 | if (connection->encryptionEnabled()) { |
| 498 | connectSingleShot(this, &Room::encryption, this, [this, connection] { |
| 499 | connection->encryptionUpdate(this); |
| 500 | }); |
| 501 | connect(this, &Room::memberListChanged, this, [this, connection] { |
| 502 | if(usesEncryption()) { |
| 503 | connection->encryptionUpdate(this, d->usersInvited); |
| 504 | } |
| 505 | }); |
| 506 | d->groupSessions = connection->loadRoomMegolmSessions(this); |
| 507 | d->currentOutboundMegolmSession = |
| 508 | connection->database()->loadCurrentOutboundMegolmSession(id); |
| 509 | if (d->currentOutboundMegolmSession |
| 510 | && d->shouldRotateMegolmSession()) { |
| 511 | d->currentOutboundMegolmSession.reset(); |
| 512 | } |
| 513 | connect(this, &Room::userRemoved, this, [this] { |
| 514 | if (d->hasValidMegolmSession()) { |
| 515 | qCDebug(E2EE) |
| 516 | << "Rotating the megolm session because a user left" ; |
| 517 | d->createMegolmSession(); |
| 518 | } |
| 519 | }); |
| 520 | |
| 521 | connect(this, &Room::beforeDestruction, this, [id, connection] { |
| 522 | connection->database()->clearRoomData(id); |
| 523 | }); |
| 524 | } |
| 525 | #endif |
| 526 | qCDebug(STATE) << "New" << terse << initialJoinState << "Room:" << id; |
| 527 | } |
| 528 | |
| 529 | Room::~Room() { delete d; } |
| 530 | |
| 531 | const QString& Room::id() const { return d->id; } |
| 532 | |
| 533 | QString Room::version() const |
| 534 | { |
| 535 | const auto v = currentState().query(fn: &RoomCreateEvent::version); |
| 536 | return v && !v->isEmpty() ? *v : QStringLiteral("1" ); |
| 537 | } |
| 538 | |
| 539 | bool Room::isUnstable() const |
| 540 | { |
| 541 | return !connection()->loadingCapabilities() |
| 542 | && !connection()->stableRoomVersions().contains(str: version()); |
| 543 | } |
| 544 | |
| 545 | QString Room::predecessorId() const |
| 546 | { |
| 547 | if (const auto* evt = currentState().get<RoomCreateEvent>()) |
| 548 | return evt->predecessor().roomId; |
| 549 | |
| 550 | return {}; |
| 551 | } |
| 552 | |
| 553 | Room* Room::predecessor(JoinStates statesFilter) const |
| 554 | { |
| 555 | if (const auto& predId = predecessorId(); !predId.isEmpty()) |
| 556 | if (auto* r = connection()->room(roomId: predId, states: statesFilter); |
| 557 | r && r->successorId() == id()) |
| 558 | return r; |
| 559 | |
| 560 | return nullptr; |
| 561 | } |
| 562 | |
| 563 | QString Room::successorId() const |
| 564 | { |
| 565 | return currentState().queryOr(fn: &RoomTombstoneEvent::successorRoomId, |
| 566 | fallback: QString()); |
| 567 | } |
| 568 | |
| 569 | Room* Room::successor(JoinStates statesFilter) const |
| 570 | { |
| 571 | if (const auto& succId = successorId(); !succId.isEmpty()) |
| 572 | if (auto* r = connection()->room(roomId: succId, states: statesFilter); |
| 573 | r && r->predecessorId() == id()) |
| 574 | return r; |
| 575 | |
| 576 | return nullptr; |
| 577 | } |
| 578 | |
| 579 | const Room::Timeline& Room::messageEvents() const { return d->timeline; } |
| 580 | |
| 581 | const Room::PendingEvents& Room::pendingEvents() const |
| 582 | { |
| 583 | return d->unsyncedEvents; |
| 584 | } |
| 585 | |
| 586 | bool Room::allHistoryLoaded() const |
| 587 | { |
| 588 | return !d->prevBatch; |
| 589 | } |
| 590 | |
| 591 | QString Room::name() const |
| 592 | { |
| 593 | return currentState().content<RoomNameEvent>().value; |
| 594 | } |
| 595 | |
| 596 | QStringList Room::aliases() const |
| 597 | { |
| 598 | if (const auto* evt = currentState().get<RoomCanonicalAliasEvent>()) { |
| 599 | auto result = evt->altAliases(); |
| 600 | if (!evt->alias().isEmpty()) |
| 601 | result << evt->alias(); |
| 602 | return result; |
| 603 | } |
| 604 | return {}; |
| 605 | } |
| 606 | |
| 607 | QStringList Room::altAliases() const |
| 608 | { |
| 609 | return currentState().content<RoomCanonicalAliasEvent>().altAliases; |
| 610 | } |
| 611 | |
| 612 | QString Room::canonicalAlias() const |
| 613 | { |
| 614 | return currentState().content<RoomCanonicalAliasEvent>().canonicalAlias; |
| 615 | } |
| 616 | |
| 617 | QString Room::displayName() const { return d->displayname; } |
| 618 | |
| 619 | QStringList Room::pinnedEventIds() const { |
| 620 | return currentState().content<RoomPinnedEventsEvent>().value; |
| 621 | } |
| 622 | |
| 623 | QVector<const Quotient::RoomEvent*> Quotient::Room::pinnedEvents() const |
| 624 | { |
| 625 | QVector<const RoomEvent*> pinnedEvents; |
| 626 | for (const auto eventIds = pinnedEventIds(); const auto& evtId : eventIds) |
| 627 | if (const auto& it = findInTimeline(evtId); it != historyEdge()) |
| 628 | pinnedEvents.append(t: it->event()); |
| 629 | |
| 630 | return pinnedEvents; |
| 631 | } |
| 632 | |
| 633 | QString Room::displayNameForHtml() const |
| 634 | { |
| 635 | return displayName().toHtmlEscaped(); |
| 636 | } |
| 637 | |
| 638 | void Room::refreshDisplayName() { d->updateDisplayname(); } |
| 639 | |
| 640 | QString Room::topic() const |
| 641 | { |
| 642 | return currentState().content<RoomTopicEvent>().value; |
| 643 | } |
| 644 | |
| 645 | QString Room::avatarMediaId() const { return d->avatar.mediaId(); } |
| 646 | |
| 647 | QUrl Room::avatarUrl() const { return d->avatar.url(); } |
| 648 | |
| 649 | const Avatar& Room::avatarObject() const { return d->avatar; } |
| 650 | |
| 651 | QImage Room::avatar(int dimension) { return avatar(width: dimension, height: dimension); } |
| 652 | |
| 653 | QImage Room::avatar(int width, int height) |
| 654 | { |
| 655 | if (!d->avatar.url().isEmpty()) |
| 656 | return d->avatar.get(connection: connection(), w: width, h: height, |
| 657 | callback: [this] { emit avatarChanged(); }); |
| 658 | |
| 659 | // Use the first (excluding self) user's avatar for direct chats |
| 660 | const auto dcUsers = directChatUsers(); |
| 661 | for (auto* u : dcUsers) |
| 662 | if (u != localUser()) |
| 663 | return u->avatar(width, height, room: this, callback: [this] { emit avatarChanged(); }); |
| 664 | |
| 665 | return {}; |
| 666 | } |
| 667 | |
| 668 | User* Room::user(const QString& userId) const |
| 669 | { |
| 670 | return connection()->user(uId: userId); |
| 671 | } |
| 672 | |
| 673 | JoinState Room::memberJoinState(User* user) const |
| 674 | { |
| 675 | return d->membersMap.contains(key: user->name(room: this), value: user) ? JoinState::Join |
| 676 | : JoinState::Leave; |
| 677 | } |
| 678 | |
| 679 | Membership Room::memberState(const QString& userId) const |
| 680 | { |
| 681 | return currentState().queryOr(stateKey: userId, fn: &RoomMemberEvent::membership, |
| 682 | fallback: Membership::Leave); |
| 683 | } |
| 684 | |
| 685 | bool Room::isMember(const QString& userId) const |
| 686 | { |
| 687 | return memberState(userId) == Membership::Join; |
| 688 | } |
| 689 | |
| 690 | JoinState Room::joinState() const { return d->joinState; } |
| 691 | |
| 692 | void Room::setJoinState(JoinState state) |
| 693 | { |
| 694 | JoinState oldState = d->joinState; |
| 695 | if (state == oldState) |
| 696 | return; |
| 697 | d->joinState = state; |
| 698 | qCDebug(STATE) << "Room" << id() << "changed state: " << terse << oldState |
| 699 | << "->" << state; |
| 700 | emit joinStateChanged(oldState, newState: state); |
| 701 | } |
| 702 | |
| 703 | Omittable<QString> Room::Private::setLastReadReceipt(const QString& userId, |
| 704 | rev_iter_t newMarker, |
| 705 | ReadReceipt newReceipt) |
| 706 | { |
| 707 | if (newMarker == historyEdge() && !newReceipt.eventId.isEmpty()) |
| 708 | newMarker = q->findInTimeline(evtId: newReceipt.eventId); |
| 709 | if (newMarker != historyEdge()) { |
| 710 | // Try to auto-promote the read marker over the user's own messages |
| 711 | // (switch to direct iterators for that). |
| 712 | const auto eagerMarker = find_if(first: newMarker.base(), last: syncEdge(), |
| 713 | pred: [=](const TimelineItem& ti) { |
| 714 | return ti->senderId() != userId; |
| 715 | }); |
| 716 | // eagerMarker is now just after the desired event for newMarker |
| 717 | if (eagerMarker != newMarker.base()) { |
| 718 | newMarker = rev_iter_t(eagerMarker); |
| 719 | qDebug(catFunc: EPHEMERAL) << "Auto-promoted read receipt for" << userId |
| 720 | << "to" << *newMarker; |
| 721 | } |
| 722 | // Fill newReceipt with the event (and, if needed, timestamp) from |
| 723 | // eagerMarker |
| 724 | newReceipt.eventId = (eagerMarker - 1)->event()->id(); |
| 725 | if (newReceipt.timestamp.isNull()) |
| 726 | newReceipt.timestamp = QDateTime::currentDateTime(); |
| 727 | } |
| 728 | auto& storedReceipt = |
| 729 | lastReadReceipts[userId]; // clazy:exclude=detaching-member |
| 730 | const auto prevEventId = storedReceipt.eventId; |
| 731 | // Check that either the new marker is actually "newer" than the current one |
| 732 | // or, if both markers are at historyEdge(), event ids are different. |
| 733 | // This logic tackles, in particular, the case when the new event is not |
| 734 | // found (most likely, because it's too old and hasn't been fetched from |
| 735 | // the server yet) but there is a previous marker for a user; in that case, |
| 736 | // the previous marker is kept because read receipts are not supposed |
| 737 | // to move backwards. If neither new nor old event is found, the new receipt |
| 738 | // is blindly stored, in a hope it's also "newer" in the timeline. |
| 739 | // NB: with reverse iterators, timeline history edge >= sync edge |
| 740 | if (prevEventId == newReceipt.eventId |
| 741 | || newMarker > q->findInTimeline(evtId: prevEventId)) |
| 742 | return {}; |
| 743 | |
| 744 | // Finally make the change |
| 745 | |
| 746 | auto oldEventReadUsersIt = |
| 747 | eventIdReadUsers.find(key: prevEventId); // clazy:exclude=detaching-member |
| 748 | if (oldEventReadUsersIt != eventIdReadUsers.end()) { |
| 749 | oldEventReadUsersIt->remove(value: userId); |
| 750 | if (oldEventReadUsersIt->isEmpty()) |
| 751 | eventIdReadUsers.erase(it: oldEventReadUsersIt); |
| 752 | } |
| 753 | eventIdReadUsers[newReceipt.eventId].insert(value: userId); |
| 754 | storedReceipt = std::move(newReceipt); |
| 755 | |
| 756 | { |
| 757 | auto dbg = qDebug(catFunc: EPHEMERAL); // NB: qCDebug can't be used like that |
| 758 | dbg << "The new read receipt for" << userId << "is now at" ; |
| 759 | if (newMarker == historyEdge()) |
| 760 | dbg << storedReceipt.eventId; |
| 761 | else |
| 762 | dbg << *newMarker; |
| 763 | } |
| 764 | |
| 765 | // NB: This method, unlike setLocalLastReadReceipt, doesn't emit |
| 766 | // lastReadEventChanged() to avoid numerous emissions when many read |
| 767 | // receipts arrive. It can be called thousands of times during an initial |
| 768 | // sync, e.g. |
| 769 | // TODO: remove in 0.8 |
| 770 | if (const auto member = q->user(userId); !isLocalUser(u: member)) |
| 771 | QT_IGNORE_DEPRECATIONS(emit q->readMarkerForUserMoved( |
| 772 | member, prevEventId, storedReceipt.eventId);) |
| 773 | return prevEventId; |
| 774 | } |
| 775 | |
| 776 | Room::Changes Room::Private::setLocalLastReadReceipt(const rev_iter_t& newMarker, |
| 777 | ReadReceipt newReceipt, |
| 778 | bool deferStatsUpdate) |
| 779 | { |
| 780 | auto prevEventId = setLastReadReceipt(userId: connection->userId(), newMarker, |
| 781 | newReceipt: std::move(newReceipt)); |
| 782 | if (!prevEventId) |
| 783 | return Change::None; |
| 784 | Changes changes = Change::Other; |
| 785 | if (!deferStatsUpdate) { |
| 786 | if (unreadStats.updateOnMarkerMove(room: q, oldMarker: q->findInTimeline(evtId: *prevEventId), |
| 787 | newMarker)) { |
| 788 | qDebug(catFunc: MESSAGES) |
| 789 | << "Updated unread event statistics in" << q->objectName() |
| 790 | << "after moving the local read receipt:" << unreadStats; |
| 791 | changes |= Change::UnreadStats; |
| 792 | } |
| 793 | Q_ASSERT(unreadStats.isValidFor(q, newMarker)); // post-check |
| 794 | } |
| 795 | emit q->lastReadEventChanged(userIds: { connection->userId() }); |
| 796 | return changes; |
| 797 | } |
| 798 | |
| 799 | Room::Changes Room::Private::updateStats(const rev_iter_t& from, |
| 800 | const rev_iter_t& to) |
| 801 | { |
| 802 | Q_ASSERT(from >= timeline.crbegin() && from <= timeline.crend()); |
| 803 | Q_ASSERT(to >= from && to <= timeline.crend()); |
| 804 | |
| 805 | const auto fullyReadMarker = q->fullyReadMarker(); |
| 806 | auto readReceiptMarker = q->localReadReceiptMarker(); |
| 807 | Changes changes = Change::None; |
| 808 | // Correct the read receipt to never be behind the fully read marker |
| 809 | if (readReceiptMarker > fullyReadMarker |
| 810 | && setLocalLastReadReceipt(newMarker: fullyReadMarker, newReceipt: {}, deferStatsUpdate: true)) { |
| 811 | changes |= Change::Other; |
| 812 | readReceiptMarker = q->localReadReceiptMarker(); |
| 813 | qCInfo(MESSAGES) << "The local m.read receipt was behind m.fully_read " |
| 814 | "marker - it's now corrected to be at index" |
| 815 | << readReceiptMarker->index(); |
| 816 | } |
| 817 | |
| 818 | if (fullyReadMarker < from) |
| 819 | return Change::None; // What's arrived is already fully read |
| 820 | |
| 821 | // If there's no read marker in the whole room, initialise it |
| 822 | if (fullyReadMarker == historyEdge() && q->allHistoryLoaded()) |
| 823 | return setFullyReadMarker(timeline.front()->id()); |
| 824 | |
| 825 | // Catch a case when the id in the last fully read marker or the local read |
| 826 | // receipt refers to an event that has just arrived. In this case either |
| 827 | // one (unreadStats) or both statistics should be recalculated to get |
| 828 | // an exact number instead of an estimation (see documentation on |
| 829 | // EventStats::isEstimate). For the same reason (switching from the |
| 830 | // estimate to the exact number) this branch forces returning |
| 831 | // Change::UnreadStats and also possibly Change::PartiallyReadStats, even if |
| 832 | // the estimation luckily matched the exact result. |
| 833 | if (readReceiptMarker < to || changes /*i.e. read receipt was corrected*/) { |
| 834 | unreadStats = EventStats::fromMarker(room: q, marker: readReceiptMarker); |
| 835 | Q_ASSERT(!unreadStats.isEstimate); |
| 836 | qCDebug(MESSAGES).nospace() |
| 837 | << "Recalculated unread event statistics in " << q->objectName() |
| 838 | << ": " << unreadStats; |
| 839 | changes |= Change::UnreadStats; |
| 840 | if (fullyReadMarker < to) { |
| 841 | // Add up to unreadStats instead of counting same events again |
| 842 | partiallyReadStats = EventStats::fromRange(room: q, from: readReceiptMarker, |
| 843 | to: q->fullyReadMarker(), |
| 844 | init: unreadStats); |
| 845 | Q_ASSERT(!partiallyReadStats.isEstimate); |
| 846 | |
| 847 | qCDebug(MESSAGES).nospace() |
| 848 | << "Recalculated partially read event statistics in " |
| 849 | << q->objectName() << ": " << partiallyReadStats; |
| 850 | return changes | Change::PartiallyReadStats; |
| 851 | } |
| 852 | } |
| 853 | |
| 854 | // As of here, at least the fully read marker (but maybe also read receipt) |
| 855 | // points to somewhere beyond the "oldest" message from the arrived batch - |
| 856 | // add up newly arrived messages to the current stats, instead of a complete |
| 857 | // recalculation. |
| 858 | Q_ASSERT(fullyReadMarker >= to); |
| 859 | |
| 860 | const auto newStats = EventStats::fromRange(room: q, from, to); |
| 861 | Q_ASSERT(!newStats.isEstimate); |
| 862 | if (newStats.empty()) |
| 863 | return changes; |
| 864 | |
| 865 | const auto doAddStats = [this, &changes, newStats](EventStats& s, |
| 866 | const rev_iter_t& marker, |
| 867 | Change c) { |
| 868 | s.notableCount += newStats.notableCount; |
| 869 | s.highlightCount += newStats.highlightCount; |
| 870 | if (!s.isEstimate) |
| 871 | s.isEstimate = marker == historyEdge(); |
| 872 | changes |= c; |
| 873 | }; |
| 874 | |
| 875 | doAddStats(partiallyReadStats, fullyReadMarker, Change::PartiallyReadStats); |
| 876 | if (readReceiptMarker >= to) { |
| 877 | // readReceiptMarker < to branch shouldn't have been entered |
| 878 | Q_ASSERT(!changes.testFlag(Change::UnreadStats)); |
| 879 | doAddStats(unreadStats, readReceiptMarker, Change::UnreadStats); |
| 880 | } |
| 881 | qCDebug(MESSAGES) << "Room" << q->objectName() << "has gained" << newStats |
| 882 | << "notable/highlighted event(s); total statistics:" |
| 883 | << partiallyReadStats << "since the fully read marker," |
| 884 | << unreadStats << "since read receipt" ; |
| 885 | |
| 886 | // Check invariants |
| 887 | Q_ASSERT(partiallyReadStats.isValidFor(q, fullyReadMarker)); |
| 888 | Q_ASSERT(unreadStats.isValidFor(q, readReceiptMarker)); |
| 889 | return changes; |
| 890 | } |
| 891 | |
| 892 | Room::Changes Room::Private::setFullyReadMarker(const QString& eventId) |
| 893 | { |
| 894 | if (fullyReadUntilEventId == eventId) |
| 895 | return Change::None; |
| 896 | |
| 897 | const auto prevReadMarker = q->fullyReadMarker(); |
| 898 | const auto newReadMarker = q->findInTimeline(evtId: eventId); |
| 899 | if (newReadMarker > prevReadMarker) |
| 900 | return Change::None; |
| 901 | |
| 902 | const auto prevFullyReadId = std::exchange(obj&: fullyReadUntilEventId, new_val: eventId); |
| 903 | qCDebug(MESSAGES) << "Fully read marker in" << q->objectName() // |
| 904 | << "set to" << fullyReadUntilEventId; |
| 905 | |
| 906 | QT_IGNORE_DEPRECATIONS(Changes changes = Change::ReadMarker|Change::Other;) |
| 907 | if (const auto rm = q->fullyReadMarker(); rm != historyEdge()) { |
| 908 | // Pull read receipt if it's behind, and update statistics |
| 909 | changes |= setLocalLastReadReceipt(newMarker: rm); |
| 910 | if (partiallyReadStats.updateOnMarkerMove(room: q, oldMarker: prevReadMarker, newMarker: rm)) { |
| 911 | changes |= Change::PartiallyReadStats; |
| 912 | qCDebug(MESSAGES) |
| 913 | << "Updated partially read event statistics in" |
| 914 | << q->objectName() |
| 915 | << "after moving m.fully_read marker: " << partiallyReadStats; |
| 916 | } |
| 917 | Q_ASSERT(partiallyReadStats.isValidFor(q, rm)); // post-check |
| 918 | } |
| 919 | emit q->fullyReadMarkerMoved(fromEventId: prevFullyReadId, toEventId: fullyReadUntilEventId); |
| 920 | // TODO: Remove in 0.8 |
| 921 | QT_IGNORE_DEPRECATIONS( |
| 922 | emit q->readMarkerMoved(prevFullyReadId, fullyReadUntilEventId);) |
| 923 | return changes; |
| 924 | } |
| 925 | |
| 926 | void Room::setReadReceipt(const QString& atEventId) |
| 927 | { |
| 928 | if (const auto changes = |
| 929 | d->setLocalLastReadReceipt(newMarker: historyEdge(), newReceipt: { .eventId: atEventId })) { |
| 930 | connection()->callApi<PostReceiptJob>(runningPolicy: BackgroundRequest, jobArgs: id(), |
| 931 | QStringLiteral("m.read" ), |
| 932 | jobArgs: QString::fromUtf8(ba: QUrl::toPercentEncoding(atEventId))); |
| 933 | d->postprocessChanges(changes); |
| 934 | } else |
| 935 | qCDebug(EPHEMERAL) << "The new read receipt for" << localUser()->id() |
| 936 | << "in" << objectName() |
| 937 | << "is at or behind the old one, skipping" ; |
| 938 | } |
| 939 | |
| 940 | bool Room::Private::markMessagesAsRead(const rev_iter_t &upToMarker) |
| 941 | { |
| 942 | if (upToMarker == q->historyEdge()) |
| 943 | qCWarning(MESSAGES) << "Cannot mark an unknown event in" |
| 944 | << q->objectName() << "as fully read" ; |
| 945 | else if (const auto changes = setFullyReadMarker(upToMarker->event()->id())) { |
| 946 | // The assumption below is that if a read receipt was sent on a newer |
| 947 | // event, the homeserver will keep it there instead of reverting to |
| 948 | // m.fully_read |
| 949 | connection->callApi<SetReadMarkerJob>(runningPolicy: BackgroundRequest, jobArgs&: id, |
| 950 | jobArgs&: fullyReadUntilEventId, |
| 951 | jobArgs&: fullyReadUntilEventId); |
| 952 | postprocessChanges(changes); |
| 953 | return true; |
| 954 | } else |
| 955 | qCDebug(MESSAGES) << "Event" << *upToMarker << "in" << q->objectName() |
| 956 | << "is behind the current fully read marker at" |
| 957 | << *q->fullyReadMarker() |
| 958 | << "- won't move fully read marker back in timeline" ; |
| 959 | return false; |
| 960 | } |
| 961 | |
| 962 | void Room::markMessagesAsRead(const QString& uptoEventId) |
| 963 | { |
| 964 | d->markMessagesAsRead(upToMarker: findInTimeline(evtId: uptoEventId)); |
| 965 | } |
| 966 | |
| 967 | void Room::markAllMessagesAsRead() |
| 968 | { |
| 969 | d->markMessagesAsRead(upToMarker: d->timeline.crbegin()); |
| 970 | } |
| 971 | |
| 972 | bool Room::canSwitchVersions() const |
| 973 | { |
| 974 | if (!successorId().isEmpty()) |
| 975 | return false; // No one can upgrade a room that's already upgraded |
| 976 | |
| 977 | if (const auto* plEvt = currentState().get<RoomPowerLevelsEvent>()) { |
| 978 | const auto currentUserLevel = |
| 979 | plEvt->powerLevelForUser(userId: localUser()->id()); |
| 980 | const auto tombstonePowerLevel = |
| 981 | plEvt->powerLevelForState(eventTypeId: "m.room.tombstone"_ls ); |
| 982 | return currentUserLevel >= tombstonePowerLevel; |
| 983 | } |
| 984 | return true; |
| 985 | } |
| 986 | |
| 987 | bool Room::isEventNotable(const TimelineItem &ti) const |
| 988 | { |
| 989 | const auto& evt = *ti; |
| 990 | const auto* rme = ti.viewAs<RoomMessageEvent>(); |
| 991 | return !evt.isRedacted() |
| 992 | && (is<RoomTopicEvent>(e: evt) || is<RoomNameEvent>(e: evt) |
| 993 | || is<RoomAvatarEvent>(e: evt) || is<RoomTombstoneEvent>(e: evt) |
| 994 | || (rme && rme->msgtype() != MessageEventType::Notice |
| 995 | && rme->replacedEvent().isEmpty())) |
| 996 | && evt.senderId() != localUser()->id(); |
| 997 | } |
| 998 | |
| 999 | Notification Room::notificationFor(const TimelineItem &ti) const |
| 1000 | { |
| 1001 | return d->notifications.value(key: ti->id()); |
| 1002 | } |
| 1003 | |
| 1004 | Notification Room::checkForNotifications(const TimelineItem &ti) |
| 1005 | { |
| 1006 | return { .type: Notification::None }; |
| 1007 | } |
| 1008 | |
| 1009 | bool Room::hasUnreadMessages() const { return !d->partiallyReadStats.empty(); } |
| 1010 | |
| 1011 | int countFromStats(const EventStats& s) |
| 1012 | { |
| 1013 | return s.empty() ? -1 : int(s.notableCount); |
| 1014 | } |
| 1015 | |
| 1016 | int Room::unreadCount() const { return countFromStats(s: partiallyReadStats()); } |
| 1017 | |
| 1018 | EventStats Room::partiallyReadStats() const { return d->partiallyReadStats; } |
| 1019 | |
| 1020 | EventStats Room::unreadStats() const { return d->unreadStats; } |
| 1021 | |
| 1022 | Room::rev_iter_t Room::historyEdge() const { return d->historyEdge(); } |
| 1023 | |
| 1024 | Room::Timeline::const_iterator Room::syncEdge() const { return d->syncEdge(); } |
| 1025 | |
| 1026 | TimelineItem::index_t Room::minTimelineIndex() const |
| 1027 | { |
| 1028 | return d->timeline.empty() ? 0 : d->timeline.front().index(); |
| 1029 | } |
| 1030 | |
| 1031 | TimelineItem::index_t Room::maxTimelineIndex() const |
| 1032 | { |
| 1033 | return d->timeline.empty() ? 0 : d->timeline.back().index(); |
| 1034 | } |
| 1035 | |
| 1036 | bool Room::isValidIndex(TimelineItem::index_t timelineIndex) const |
| 1037 | { |
| 1038 | return !d->timeline.empty() && timelineIndex >= minTimelineIndex() |
| 1039 | && timelineIndex <= maxTimelineIndex(); |
| 1040 | } |
| 1041 | |
| 1042 | Room::rev_iter_t Room::findInTimeline(TimelineItem::index_t index) const |
| 1043 | { |
| 1044 | return historyEdge() |
| 1045 | - (isValidIndex(timelineIndex: index) ? index - minTimelineIndex() + 1 : 0); |
| 1046 | } |
| 1047 | |
| 1048 | Room::rev_iter_t Room::findInTimeline(const QString& evtId) const |
| 1049 | { |
| 1050 | if (!d->timeline.empty() && d->eventsIndex.contains(key: evtId)) { |
| 1051 | auto it = findInTimeline(index: d->eventsIndex.value(key: evtId)); |
| 1052 | Q_ASSERT(it != historyEdge() && (*it)->id() == evtId); |
| 1053 | return it; |
| 1054 | } |
| 1055 | return historyEdge(); |
| 1056 | } |
| 1057 | |
| 1058 | Room::PendingEvents::iterator Room::findPendingEvent(const QString& txnId) |
| 1059 | { |
| 1060 | return std::find_if(first: d->unsyncedEvents.begin(), last: d->unsyncedEvents.end(), |
| 1061 | pred: [txnId](const auto& item) { |
| 1062 | return item->transactionId() == txnId; |
| 1063 | }); |
| 1064 | } |
| 1065 | |
| 1066 | Room::PendingEvents::const_iterator |
| 1067 | Room::findPendingEvent(const QString& txnId) const |
| 1068 | { |
| 1069 | return std::find_if(first: d->unsyncedEvents.cbegin(), last: d->unsyncedEvents.cend(), |
| 1070 | pred: [txnId](const auto& item) { |
| 1071 | return item->transactionId() == txnId; |
| 1072 | }); |
| 1073 | } |
| 1074 | |
| 1075 | const Room::RelatedEvents Room::relatedEvents( |
| 1076 | const QString& evtId, EventRelation::reltypeid_t relType) const |
| 1077 | { |
| 1078 | return d->relations.value(key: { evtId, relType }); |
| 1079 | } |
| 1080 | |
| 1081 | const Room::RelatedEvents Room::relatedEvents( |
| 1082 | const RoomEvent& evt, EventRelation::reltypeid_t relType) const |
| 1083 | { |
| 1084 | return relatedEvents(evtId: evt.id(), relType); |
| 1085 | } |
| 1086 | |
| 1087 | const RoomCreateEvent* Room::creation() const |
| 1088 | { |
| 1089 | return currentState().get<RoomCreateEvent>(); |
| 1090 | } |
| 1091 | |
| 1092 | const RoomTombstoneEvent* Room::tombstone() const |
| 1093 | { |
| 1094 | return currentState().get<RoomTombstoneEvent>(); |
| 1095 | } |
| 1096 | |
| 1097 | void Room::Private::getAllMembers() |
| 1098 | { |
| 1099 | // If already loaded or already loading, there's nothing to do here. |
| 1100 | if (q->joinedCount() <= membersMap.size() || isJobPending(job: allMembersJob)) |
| 1101 | return; |
| 1102 | |
| 1103 | allMembersJob = connection->callApi<GetMembersByRoomJob>( |
| 1104 | jobArgs&: id, jobArgs: connection->nextBatchToken(), jobArgs: "join"_ls ); |
| 1105 | auto nextIndex = timeline.empty() ? 0 : timeline.back().index() + 1; |
| 1106 | connect(sender: allMembersJob, signal: &BaseJob::success, context: q, slot: [this, nextIndex] { |
| 1107 | Q_ASSERT(timeline.empty() || nextIndex <= q->maxTimelineIndex() + 1); |
| 1108 | auto roomChanges = updateStateFrom(events: allMembersJob->chunk()); |
| 1109 | // Replay member events that arrived after the point for which |
| 1110 | // the full members list was requested. |
| 1111 | if (!timeline.empty()) |
| 1112 | for (auto it = q->findInTimeline(index: nextIndex).base(); |
| 1113 | it != syncEdge(); ++it) |
| 1114 | if (is<RoomMemberEvent>(e: **it)) |
| 1115 | roomChanges |= q->processStateEvent(e: **it); |
| 1116 | postprocessChanges(changes: roomChanges); |
| 1117 | emit q->allMembersLoaded(); |
| 1118 | }); |
| 1119 | } |
| 1120 | |
| 1121 | bool Room::displayed() const { return d->displayed; } |
| 1122 | |
| 1123 | void Room::setDisplayed(bool displayed) |
| 1124 | { |
| 1125 | if (d->displayed == displayed) |
| 1126 | return; |
| 1127 | |
| 1128 | d->displayed = displayed; |
| 1129 | emit displayedChanged(displayed); |
| 1130 | if (displayed) |
| 1131 | d->getAllMembers(); |
| 1132 | } |
| 1133 | |
| 1134 | QString Room::firstDisplayedEventId() const { return d->firstDisplayedEventId; } |
| 1135 | |
| 1136 | Room::rev_iter_t Room::firstDisplayedMarker() const |
| 1137 | { |
| 1138 | return findInTimeline(evtId: firstDisplayedEventId()); |
| 1139 | } |
| 1140 | |
| 1141 | void Room::setFirstDisplayedEventId(const QString& eventId) |
| 1142 | { |
| 1143 | if (d->firstDisplayedEventId == eventId) |
| 1144 | return; |
| 1145 | |
| 1146 | if (!eventId.isEmpty() && findInTimeline(evtId: eventId) == historyEdge()) |
| 1147 | qCWarning(MESSAGES) |
| 1148 | << eventId |
| 1149 | << "is marked as first displayed but doesn't seem to be loaded" ; |
| 1150 | |
| 1151 | d->firstDisplayedEventId = eventId; |
| 1152 | emit firstDisplayedEventChanged(); |
| 1153 | } |
| 1154 | |
| 1155 | void Room::setFirstDisplayedEvent(TimelineItem::index_t index) |
| 1156 | { |
| 1157 | Q_ASSERT(isValidIndex(index)); |
| 1158 | setFirstDisplayedEventId(findInTimeline(index)->event()->id()); |
| 1159 | } |
| 1160 | |
| 1161 | QString Room::lastDisplayedEventId() const { return d->lastDisplayedEventId; } |
| 1162 | |
| 1163 | Room::rev_iter_t Room::lastDisplayedMarker() const |
| 1164 | { |
| 1165 | return findInTimeline(evtId: lastDisplayedEventId()); |
| 1166 | } |
| 1167 | |
| 1168 | void Room::setLastDisplayedEventId(const QString& eventId) |
| 1169 | { |
| 1170 | if (d->lastDisplayedEventId == eventId) |
| 1171 | return; |
| 1172 | |
| 1173 | const auto marker = findInTimeline(evtId: eventId); |
| 1174 | if (!eventId.isEmpty() && marker == historyEdge()) |
| 1175 | qCWarning(MESSAGES) |
| 1176 | << eventId |
| 1177 | << "is marked as last displayed but doesn't seem to be loaded" ; |
| 1178 | |
| 1179 | d->lastDisplayedEventId = eventId; |
| 1180 | emit lastDisplayedEventChanged(); |
| 1181 | } |
| 1182 | |
| 1183 | void Room::setLastDisplayedEvent(TimelineItem::index_t index) |
| 1184 | { |
| 1185 | Q_ASSERT(isValidIndex(index)); |
| 1186 | setLastDisplayedEventId(findInTimeline(index)->event()->id()); |
| 1187 | } |
| 1188 | |
| 1189 | Room::rev_iter_t Room::readMarker(const User* user) const |
| 1190 | { |
| 1191 | Q_ASSERT(user); |
| 1192 | return findInTimeline(evtId: lastReadReceipt(userId: user->id()).eventId); |
| 1193 | } |
| 1194 | |
| 1195 | Room::rev_iter_t Room::readMarker() const { return fullyReadMarker(); } |
| 1196 | |
| 1197 | QString Room::readMarkerEventId() const { return lastFullyReadEventId(); } |
| 1198 | |
| 1199 | ReadReceipt Room::lastReadReceipt(const QString& userId) const |
| 1200 | { |
| 1201 | return d->lastReadReceipts.value(key: userId); |
| 1202 | } |
| 1203 | |
| 1204 | ReadReceipt Room::lastLocalReadReceipt() const |
| 1205 | { |
| 1206 | return d->lastReadReceipts.value(key: localUser()->id()); |
| 1207 | } |
| 1208 | |
| 1209 | Room::rev_iter_t Room::localReadReceiptMarker() const |
| 1210 | { |
| 1211 | return findInTimeline(evtId: lastLocalReadReceipt().eventId); |
| 1212 | } |
| 1213 | |
| 1214 | QString Room::lastFullyReadEventId() const { return d->fullyReadUntilEventId; } |
| 1215 | |
| 1216 | Room::rev_iter_t Room::fullyReadMarker() const |
| 1217 | { |
| 1218 | return findInTimeline(evtId: d->fullyReadUntilEventId); |
| 1219 | } |
| 1220 | |
| 1221 | QSet<QString> Room::userIdsAtEvent(const QString& eventId) const |
| 1222 | { |
| 1223 | return d->eventIdReadUsers.value(key: eventId); |
| 1224 | } |
| 1225 | |
| 1226 | QSet<QString> Room::userIdsAtEvent(const QString& eventId) |
| 1227 | { |
| 1228 | return d->eventIdReadUsers.value(key: eventId); |
| 1229 | } |
| 1230 | |
| 1231 | QSet<User*> Room::usersAtEventId(const QString& eventId) |
| 1232 | { |
| 1233 | const auto& userIds = d->eventIdReadUsers.value(key: eventId); |
| 1234 | QSet<User*> users; |
| 1235 | users.reserve(asize: userIds.size()); |
| 1236 | for (const auto& uId : userIds) |
| 1237 | users.insert(value: user(userId: uId)); |
| 1238 | return users; |
| 1239 | } |
| 1240 | |
| 1241 | qsizetype Room::notificationCount() const |
| 1242 | { |
| 1243 | return d->unreadStats.notableCount; |
| 1244 | } |
| 1245 | |
| 1246 | qsizetype Room::highlightCount() const { return d->serverHighlightCount; } |
| 1247 | |
| 1248 | void Room::switchVersion(QString newVersion) |
| 1249 | { |
| 1250 | if (!successorId().isEmpty()) { |
| 1251 | Q_ASSERT(!successorId().isEmpty()); |
| 1252 | emit upgradeFailed(errorMessage: tr(s: "The room is already upgraded" )); |
| 1253 | } |
| 1254 | if (auto* job = connection()->callApi<UpgradeRoomJob>(jobArgs: id(), jobArgs&: newVersion)) |
| 1255 | connect(sender: job, signal: &BaseJob::failure, context: this, |
| 1256 | slot: [this, job] { emit upgradeFailed(errorMessage: job->errorString()); }); |
| 1257 | else |
| 1258 | emit upgradeFailed(errorMessage: tr(s: "Couldn't initiate upgrade" )); |
| 1259 | } |
| 1260 | |
| 1261 | bool Room::hasAccountData(const QString& type) const |
| 1262 | { |
| 1263 | return d->accountData.find(x: type) != d->accountData.end(); |
| 1264 | } |
| 1265 | |
| 1266 | const EventPtr& Room::accountData(const QString& type) const |
| 1267 | { |
| 1268 | static EventPtr NoEventPtr {}; |
| 1269 | const auto it = d->accountData.find(x: type); |
| 1270 | return it != d->accountData.end() ? it->second : NoEventPtr; |
| 1271 | } |
| 1272 | |
| 1273 | QStringList Room::accountDataEventTypes() const |
| 1274 | { |
| 1275 | QStringList events; |
| 1276 | events.reserve(asize: d->accountData.size()); |
| 1277 | for (const auto& [key, value] : std::as_const(t&: d->accountData)) { |
| 1278 | events += key; |
| 1279 | } |
| 1280 | return events; |
| 1281 | } |
| 1282 | |
| 1283 | QStringList Room::tagNames() const { return d->tags.keys(); } |
| 1284 | |
| 1285 | TagsMap Room::tags() const { return d->tags; } |
| 1286 | |
| 1287 | TagRecord Room::tag(const QString& name) const { return d->tags.value(key: name); } |
| 1288 | |
| 1289 | std::pair<bool, QString> validatedTag(QString name) |
| 1290 | { |
| 1291 | if (name.isEmpty() || name.indexOf(ch: u'.', from: 1) != -1) |
| 1292 | return { false, name }; |
| 1293 | |
| 1294 | qCWarning(MAIN) << "The tag" << name |
| 1295 | << "doesn't follow the CS API conventions" ; |
| 1296 | name.prepend(s: "u."_ls ); |
| 1297 | qCWarning(MAIN) << "Using " << name << "instead" ; |
| 1298 | |
| 1299 | return { true, name }; |
| 1300 | } |
| 1301 | |
| 1302 | void Room::addTag(const QString& name, const TagRecord& record) |
| 1303 | { |
| 1304 | const auto& checkRes = validatedTag(name); |
| 1305 | if (d->tags.contains(key: name) |
| 1306 | || (checkRes.first && d->tags.contains(key: checkRes.second))) |
| 1307 | return; |
| 1308 | |
| 1309 | emit tagsAboutToChange(); |
| 1310 | d->tags.insert(key: checkRes.second, value: record); |
| 1311 | emit tagsChanged(); |
| 1312 | connection()->callApi<SetRoomTagJob>(jobArgs: localUser()->id(), jobArgs: id(), |
| 1313 | jobArgs: checkRes.second, jobArgs: record.order); |
| 1314 | } |
| 1315 | |
| 1316 | void Room::addTag(const QString& name, float order) |
| 1317 | { |
| 1318 | addTag(name, record: TagRecord { .order: order }); |
| 1319 | } |
| 1320 | |
| 1321 | void Room::removeTag(const QString& name) |
| 1322 | { |
| 1323 | if (d->tags.contains(key: name)) { |
| 1324 | emit tagsAboutToChange(); |
| 1325 | d->tags.remove(key: name); |
| 1326 | emit tagsChanged(); |
| 1327 | connection()->callApi<DeleteRoomTagJob>(jobArgs: localUser()->id(), jobArgs: id(), jobArgs: name); |
| 1328 | } else if (!name.startsWith(s: "u."_ls )) |
| 1329 | removeTag(name: "u."_ls + name); |
| 1330 | else |
| 1331 | qCWarning(MAIN) << "Tag" << name << "on room" << objectName() |
| 1332 | << "not found, nothing to remove" ; |
| 1333 | } |
| 1334 | |
| 1335 | void Room::setTags(TagsMap newTags, ActionScope applyOn) |
| 1336 | { |
| 1337 | bool propagate = applyOn != ActionScope::ThisRoomOnly; |
| 1338 | auto joinStates = |
| 1339 | applyOn == ActionScope::WithinSameState ? joinState() : |
| 1340 | applyOn == ActionScope::OmitLeftState ? JoinState::Join|JoinState::Invite : |
| 1341 | JoinState::Join|JoinState::Invite|JoinState::Leave; |
| 1342 | if (propagate) { |
| 1343 | for (auto* r = this; (r = r->predecessor(statesFilter: joinStates));) |
| 1344 | r->setTags(newTags, applyOn: ActionScope::ThisRoomOnly); |
| 1345 | } |
| 1346 | |
| 1347 | d->setTags(std::move(newTags)); |
| 1348 | connection()->callApi<SetAccountDataPerRoomJob>( |
| 1349 | jobArgs: localUser()->id(), jobArgs: id(), jobArgs: TagEvent::TypeId, |
| 1350 | jobArgs: Quotient::toJson(pod: TagEvent::content_type { d->tags })); |
| 1351 | |
| 1352 | if (propagate) { |
| 1353 | for (auto* r = this; (r = r->successor(statesFilter: joinStates));) |
| 1354 | r->setTags(newTags: d->tags, applyOn: ActionScope::ThisRoomOnly); |
| 1355 | } |
| 1356 | } |
| 1357 | |
| 1358 | void Room::Private::setTags(TagsMap&& newTags) |
| 1359 | { |
| 1360 | emit q->tagsAboutToChange(); |
| 1361 | const auto keys = newTags.keys(); |
| 1362 | for (const auto& k : keys) |
| 1363 | if (const auto& [adjusted, adjustedTag] = validatedTag(name: k); adjusted) { |
| 1364 | if (newTags.contains(key: adjustedTag)) |
| 1365 | newTags.remove(key: k); |
| 1366 | else |
| 1367 | newTags.insert(key: adjustedTag, value: newTags.take(key: k)); |
| 1368 | } |
| 1369 | |
| 1370 | tags = std::move(newTags); |
| 1371 | qCDebug(STATE) << "Room" << q->objectName() << "is tagged with" |
| 1372 | << q->tagNames().join(QStringLiteral(", " )); |
| 1373 | emit q->tagsChanged(); |
| 1374 | } |
| 1375 | |
| 1376 | bool Room::isFavourite() const { return d->tags.contains(key: FavouriteTag); } |
| 1377 | |
| 1378 | bool Room::isLowPriority() const { return d->tags.contains(key: LowPriorityTag); } |
| 1379 | |
| 1380 | bool Room::isServerNoticeRoom() const |
| 1381 | { |
| 1382 | return d->tags.contains(key: ServerNoticeTag); |
| 1383 | } |
| 1384 | |
| 1385 | bool Room::isDirectChat() const { return connection()->isDirectChat(roomId: id()); } |
| 1386 | |
| 1387 | QList<User*> Room::directChatUsers() const |
| 1388 | { |
| 1389 | return connection()->directChatUsers(room: this); |
| 1390 | } |
| 1391 | |
| 1392 | QUrl Room::makeMediaUrl(const QString& eventId, const QUrl& mxcUrl) const |
| 1393 | { |
| 1394 | auto url = connection()->makeMediaUrl(mxcUrl); |
| 1395 | QUrlQuery q(url.query()); |
| 1396 | Q_ASSERT(q.hasQueryItem("user_id"_ls )); |
| 1397 | q.addQueryItem(key: "room_id"_ls , value: id()); |
| 1398 | q.addQueryItem(key: "event_id"_ls , value: eventId); |
| 1399 | url.setQuery(q); |
| 1400 | return url; |
| 1401 | } |
| 1402 | |
| 1403 | QString safeFileName(QString rawName) |
| 1404 | { |
| 1405 | static auto safeFileNameRegex = QRegularExpression(R"([/\<>|"*?:])"_ls ); |
| 1406 | return rawName.replace(re: safeFileNameRegex, after: "_"_ls ); |
| 1407 | } |
| 1408 | |
| 1409 | const RoomMessageEvent* |
| 1410 | Room::Private::getEventWithFile(const QString& eventId) const |
| 1411 | { |
| 1412 | auto evtIt = q->findInTimeline(evtId: eventId); |
| 1413 | if (evtIt != timeline.rend() && is<RoomMessageEvent>(e: **evtIt)) { |
| 1414 | auto* event = evtIt->viewAs<RoomMessageEvent>(); |
| 1415 | if (event->hasFileContent()) |
| 1416 | return event; |
| 1417 | } |
| 1418 | qCWarning(MAIN) << "No files to download in event" << eventId; |
| 1419 | return nullptr; |
| 1420 | } |
| 1421 | |
| 1422 | QString Room::Private::fileNameToDownload(const RoomMessageEvent* event) const |
| 1423 | { |
| 1424 | Q_ASSERT(event && event->hasFileContent()); |
| 1425 | const auto* fileInfo = event->content()->fileInfo(); |
| 1426 | QString fileName; |
| 1427 | if (!fileInfo->originalName.isEmpty()) |
| 1428 | fileName = QFileInfo(safeFileName(rawName: fileInfo->originalName)).fileName(); |
| 1429 | else if (QUrl u { event->plainBody() }; u.isValid()) { |
| 1430 | qDebug(catFunc: MAIN) << event->id() |
| 1431 | << "has no file name supplied but the event body " |
| 1432 | "looks like a URL - using the file name from it" ; |
| 1433 | fileName = u.fileName(); |
| 1434 | } |
| 1435 | if (fileName.isEmpty()) |
| 1436 | return safeFileName(rawName: fileInfo->mediaId()).replace(before: u'.', after: u'-') % u'.' |
| 1437 | % fileInfo->mimeType.preferredSuffix(); |
| 1438 | |
| 1439 | if (QSysInfo::productType() == "windows"_ls ) { |
| 1440 | if (const auto& suffixes = fileInfo->mimeType.suffixes(); |
| 1441 | !suffixes.isEmpty() |
| 1442 | && std::none_of(first: suffixes.begin(), last: suffixes.end(), |
| 1443 | pred: [&fileName](const QString& s) { |
| 1444 | return fileName.endsWith(s); |
| 1445 | })) |
| 1446 | return fileName % u'.' % fileInfo->mimeType.preferredSuffix(); |
| 1447 | } |
| 1448 | return fileName; |
| 1449 | } |
| 1450 | |
| 1451 | QUrl Room::urlToThumbnail(const QString& eventId) const |
| 1452 | { |
| 1453 | if (auto* event = d->getEventWithFile(eventId)) |
| 1454 | if (event->hasThumbnail()) { |
| 1455 | auto* thumbnail = event->content()->thumbnailInfo(); |
| 1456 | Q_ASSERT(thumbnail != nullptr); |
| 1457 | return connection()->getUrlForApi<MediaThumbnailJob>( |
| 1458 | jobArgs: thumbnail->url(), jobArgs: thumbnail->imageSize); |
| 1459 | } |
| 1460 | qCDebug(MAIN) << "Event" << eventId << "has no thumbnail" ; |
| 1461 | return {}; |
| 1462 | } |
| 1463 | |
| 1464 | QUrl Room::urlToDownload(const QString& eventId) const |
| 1465 | { |
| 1466 | if (auto* event = d->getEventWithFile(eventId)) { |
| 1467 | auto* fileInfo = event->content()->fileInfo(); |
| 1468 | Q_ASSERT(fileInfo != nullptr); |
| 1469 | return connection()->getUrlForApi<DownloadFileJob>(jobArgs: fileInfo->url()); |
| 1470 | } |
| 1471 | return {}; |
| 1472 | } |
| 1473 | |
| 1474 | QString Room::fileNameToDownload(const QString& eventId) const |
| 1475 | { |
| 1476 | if (auto* event = d->getEventWithFile(eventId)) |
| 1477 | return d->fileNameToDownload(event); |
| 1478 | return {}; |
| 1479 | } |
| 1480 | |
| 1481 | FileTransferInfo Room::fileTransferInfo(const QString& id) const |
| 1482 | { |
| 1483 | const auto infoIt = d->fileTransfers.constFind(key: id); |
| 1484 | if (infoIt == d->fileTransfers.cend()) |
| 1485 | return {}; |
| 1486 | |
| 1487 | // FIXME: Add lib tests to make sure FileTransferInfo::status stays |
| 1488 | // consistent with FileTransferInfo::job |
| 1489 | |
| 1490 | qint64 progress = infoIt->progress; |
| 1491 | qint64 total = infoIt->total; |
| 1492 | if (total > INT_MAX) { |
| 1493 | // JavaScript doesn't deal with 64-bit integers; scale down if necessary |
| 1494 | progress = llround(x: double(progress) / total * INT_MAX); |
| 1495 | total = INT_MAX; |
| 1496 | } |
| 1497 | |
| 1498 | return { .status: infoIt->status, |
| 1499 | .isUpload: infoIt->isUpload, |
| 1500 | .progress: int(progress), |
| 1501 | .total: int(total), |
| 1502 | .localDir: QUrl::fromLocalFile(localfile: infoIt->localFileInfo.absolutePath()), |
| 1503 | .localPath: QUrl::fromLocalFile(localfile: infoIt->localFileInfo.absoluteFilePath()) }; |
| 1504 | } |
| 1505 | |
| 1506 | QUrl Room::fileSource(const QString& id) const |
| 1507 | { |
| 1508 | auto url = urlToDownload(eventId: id); |
| 1509 | if (url.isValid()) |
| 1510 | return url; |
| 1511 | |
| 1512 | // No urlToDownload means it's a pending or completed upload. |
| 1513 | auto infoIt = d->fileTransfers.constFind(key: id); |
| 1514 | if (infoIt != d->fileTransfers.cend()) |
| 1515 | return QUrl::fromLocalFile(localfile: infoIt->localFileInfo.absoluteFilePath()); |
| 1516 | |
| 1517 | qCWarning(MAIN) << "File source for identifier" << id << "not found" ; |
| 1518 | return {}; |
| 1519 | } |
| 1520 | |
| 1521 | QString Room::prettyPrint(const QString& plainText) const |
| 1522 | { |
| 1523 | return Quotient::prettyPrint(plainText); |
| 1524 | } |
| 1525 | |
| 1526 | QList<User*> Room::usersTyping() const { return d->usersTyping; } |
| 1527 | |
| 1528 | QList<User*> Room::membersLeft() const { return d->membersLeft; } |
| 1529 | |
| 1530 | QList<User*> Room::users() const { return d->membersMap.values(); } |
| 1531 | |
| 1532 | QStringList Room::memberNames() const |
| 1533 | { |
| 1534 | return safeMemberNames(); |
| 1535 | } |
| 1536 | |
| 1537 | QStringList Room::safeMemberNames() const |
| 1538 | { |
| 1539 | QStringList res; |
| 1540 | res.reserve(asize: d->membersMap.size()); |
| 1541 | for (const auto* u: std::as_const(t&: d->membersMap)) |
| 1542 | res.append(t: safeMemberName(userId: u->id())); |
| 1543 | |
| 1544 | return res; |
| 1545 | } |
| 1546 | |
| 1547 | QStringList Room::htmlSafeMemberNames() const |
| 1548 | { |
| 1549 | QStringList res; |
| 1550 | res.reserve(asize: d->membersMap.size()); |
| 1551 | for (const auto* u: std::as_const(t&: d->membersMap)) |
| 1552 | res.append(t: htmlSafeMemberName(userId: u->id())); |
| 1553 | |
| 1554 | return res; |
| 1555 | } |
| 1556 | |
| 1557 | int Room::timelineSize() const { return int(d->timeline.size()); } |
| 1558 | |
| 1559 | bool Room::usesEncryption() const |
| 1560 | { |
| 1561 | return !currentState() |
| 1562 | .queryOr(fn: &EncryptionEvent::algorithm, fallback: QString()) |
| 1563 | .isEmpty(); |
| 1564 | } |
| 1565 | |
| 1566 | const StateEvent* Room::getCurrentState(const QString& evtType, |
| 1567 | const QString& stateKey) const |
| 1568 | { |
| 1569 | return d->getCurrentState(evtKey: { evtType, stateKey }); |
| 1570 | } |
| 1571 | |
| 1572 | RoomStateView Room::currentState() const |
| 1573 | { |
| 1574 | return d->currentState; |
| 1575 | } |
| 1576 | |
| 1577 | RoomEventPtr Room::decryptMessage(const EncryptedEvent& encryptedEvent) |
| 1578 | { |
| 1579 | #ifndef Quotient_E2EE_ENABLED |
| 1580 | Q_UNUSED(encryptedEvent) |
| 1581 | qCWarning(E2EE) << "End-to-end encryption (E2EE) support is turned off." ; |
| 1582 | return {}; |
| 1583 | #else // Quotient_E2EE_ENABLED |
| 1584 | if (const auto algorithm = encryptedEvent.algorithm(); |
| 1585 | !isSupportedAlgorithm(algorithm)) // |
| 1586 | { |
| 1587 | qWarning(E2EE) << "Algorithm" << algorithm << "of encrypted event" |
| 1588 | << encryptedEvent.id() << "is not supported" ; |
| 1589 | return {}; |
| 1590 | } |
| 1591 | QString decrypted = d->groupSessionDecryptMessage( |
| 1592 | encryptedEvent.ciphertext(), encryptedEvent.sessionId().toLatin1(), |
| 1593 | encryptedEvent.id(), encryptedEvent.originTimestamp(), |
| 1594 | encryptedEvent.senderId()); |
| 1595 | if (decrypted.isEmpty()) { |
| 1596 | // qCWarning(E2EE) << "Encrypted message is empty"; |
| 1597 | return {}; |
| 1598 | } |
| 1599 | auto decryptedEvent = encryptedEvent.createDecrypted(decrypted); |
| 1600 | if (decryptedEvent->roomId() == id()) { |
| 1601 | return decryptedEvent; |
| 1602 | } |
| 1603 | qWarning(E2EE) << "Decrypted event" << encryptedEvent.id() |
| 1604 | << "not for this room; discarding" ; |
| 1605 | return {}; |
| 1606 | #endif // Quotient_E2EE_ENABLED |
| 1607 | } |
| 1608 | |
| 1609 | void Room::handleRoomKeyEvent(const RoomKeyEvent& roomKeyEvent, |
| 1610 | const QString& senderId, |
| 1611 | const QByteArray& olmSessionId) |
| 1612 | { |
| 1613 | #ifndef Quotient_E2EE_ENABLED |
| 1614 | Q_UNUSED(roomKeyEvent) |
| 1615 | Q_UNUSED(senderId) |
| 1616 | Q_UNUSED(olmSessionId) |
| 1617 | qCWarning(E2EE) << "End-to-end encryption (E2EE) support is turned off." ; |
| 1618 | #else // Quotient_E2EE_ENABLED |
| 1619 | if (roomKeyEvent.algorithm() != MegolmV1AesSha2AlgoKey) { |
| 1620 | qCWarning(E2EE) << "Ignoring unsupported algorithm" |
| 1621 | << roomKeyEvent.algorithm() << "in m.room_key event" ; |
| 1622 | } |
| 1623 | if (d->addInboundGroupSession(roomKeyEvent.sessionId().toLatin1(), |
| 1624 | roomKeyEvent.sessionKey(), senderId, |
| 1625 | olmSessionId)) { |
| 1626 | qCWarning(E2EE) << "added new inboundGroupSession:" |
| 1627 | << d->groupSessions.size(); |
| 1628 | const auto undecryptedEvents = |
| 1629 | d->undecryptedEvents[roomKeyEvent.sessionId()]; |
| 1630 | for (const auto& eventId : undecryptedEvents) { |
| 1631 | const auto pIdx = d->eventsIndex.constFind(eventId); |
| 1632 | if (pIdx == d->eventsIndex.cend()) |
| 1633 | continue; |
| 1634 | auto& ti = d->timeline[Timeline::size_type(*pIdx - minTimelineIndex())]; |
| 1635 | if (auto encryptedEvent = ti.viewAs<EncryptedEvent>()) { |
| 1636 | if (auto decrypted = decryptMessage(*encryptedEvent)) { |
| 1637 | auto&& oldEvent = eventCast<EncryptedEvent>( |
| 1638 | ti.replaceEvent(std::move(decrypted))); |
| 1639 | ti->setOriginalEvent(std::move(oldEvent)); |
| 1640 | emit replacedEvent(ti.event(), ti->originalEvent()); |
| 1641 | d->undecryptedEvents[roomKeyEvent.sessionId()] -= eventId; |
| 1642 | } |
| 1643 | } |
| 1644 | } |
| 1645 | } |
| 1646 | #endif // Quotient_E2EE_ENABLED |
| 1647 | } |
| 1648 | |
| 1649 | int Room::joinedCount() const |
| 1650 | { |
| 1651 | return d->summary.joinedMemberCount.value_or(u: d->membersMap.size()); |
| 1652 | } |
| 1653 | |
| 1654 | int Room::invitedCount() const |
| 1655 | { |
| 1656 | // TODO: Store invited users in Room too |
| 1657 | Q_ASSERT(d->summary.invitedMemberCount.has_value()); |
| 1658 | return d->summary.invitedMemberCount.value_or(u: 0); |
| 1659 | } |
| 1660 | |
| 1661 | int Room::totalMemberCount() const { return joinedCount() + invitedCount(); } |
| 1662 | |
| 1663 | GetRoomEventsJob* Room::eventsHistoryJob() const { return d->eventsHistoryJob; } |
| 1664 | |
| 1665 | Room::Changes Room::Private::setSummary(RoomSummary&& newSummary) |
| 1666 | { |
| 1667 | if (!summary.merge(other: newSummary)) |
| 1668 | return Change::None; |
| 1669 | qCDebug(STATE).nospace().noquote() |
| 1670 | << "Updated room summary for " << q->objectName() << ": " << summary; |
| 1671 | return Change::Summary; |
| 1672 | } |
| 1673 | |
| 1674 | void Room::Private::insertMemberIntoMap(User* u) |
| 1675 | { |
| 1676 | const auto maybeUserName = |
| 1677 | currentState.query(stateKey: u->id(), fn: &RoomMemberEvent::newDisplayName); |
| 1678 | if (!maybeUserName) |
| 1679 | qCDebug(MEMBERS) << "insertMemberIntoMap():" << u->id() |
| 1680 | << "has no name (even empty)" ; |
| 1681 | const auto userName = maybeUserName.value_or(u: QString()); |
| 1682 | const auto namesakes = membersMap.values(key: userName); |
| 1683 | qCDebug(MEMBERS) << "insertMemberIntoMap(), user" << u->id() |
| 1684 | << "with name" << userName << '-' |
| 1685 | << namesakes.size() << "namesake(s) found" ; |
| 1686 | |
| 1687 | // Callers should make sure they are not adding an existing user once more |
| 1688 | Q_ASSERT(!namesakes.contains(u)); |
| 1689 | if (namesakes.contains(t: u)) { // Release version whines but continues |
| 1690 | qCCritical(MEMBERS) << "Trying to add a user" << u->id() << "to room" |
| 1691 | << q->objectName() << "but that's already in it" ; |
| 1692 | return; |
| 1693 | } |
| 1694 | |
| 1695 | // If there is exactly one namesake of the added user, signal member |
| 1696 | // renaming for that other one because the two should be disambiguated now |
| 1697 | if (namesakes.size() == 1) |
| 1698 | emit q->memberAboutToRename(user: namesakes.front(), |
| 1699 | newName: namesakes.front()->fullName(room: q)); |
| 1700 | membersMap.insert(key: userName, value: u); |
| 1701 | if (namesakes.size() == 1) |
| 1702 | emit q->memberRenamed(user: namesakes.front()); |
| 1703 | } |
| 1704 | |
| 1705 | void Room::Private::removeMemberFromMap(User* u) |
| 1706 | { |
| 1707 | const auto userName = currentState.queryOr(stateKey: u->id(), |
| 1708 | fn: &RoomMemberEvent::newDisplayName, |
| 1709 | fallback: QString()); |
| 1710 | |
| 1711 | qCDebug(MEMBERS) << "removeMemberFromMap(), username" << userName |
| 1712 | << "for user" << u->id(); |
| 1713 | User* namesake = nullptr; |
| 1714 | auto namesakes = membersMap.values(key: userName); |
| 1715 | // If there was one namesake besides the removed user, signal member |
| 1716 | // renaming for it because it doesn't need to be disambiguated any more. |
| 1717 | if (namesakes.size() == 2) { |
| 1718 | namesake = |
| 1719 | namesakes.front() == u ? namesakes.back() : namesakes.front(); |
| 1720 | Q_ASSERT_X(namesake != u, __FUNCTION__, "Room members list is broken" ); |
| 1721 | emit q->memberAboutToRename(user: namesake, newName: userName); |
| 1722 | } |
| 1723 | if (membersMap.remove(key: userName, value: u) == 0) { |
| 1724 | qCDebug(MEMBERS) << "No entries removed; checking the whole list" ; |
| 1725 | // Unless at the stage of initial filling, this no removed entries |
| 1726 | // is suspicious; double-check that this user is not found in |
| 1727 | // the whole map, and stop (for debug builds) or shout in the logs |
| 1728 | // (for release builds) if there's one. That search is O(n), which |
| 1729 | // may come rather expensive for larger rooms. |
| 1730 | QElapsedTimer et; |
| 1731 | auto it = std::find(first: membersMap.cbegin(), last: membersMap.cend(), val: u); |
| 1732 | if (et.nsecsElapsed() > ProfilerMinNsecs / 10) |
| 1733 | qCDebug(MEMBERS) << "...done in" << et; |
| 1734 | if (it != membersMap.cend()) { |
| 1735 | // The assert (still) does more harm than good, it seems |
| 1736 | // Q_ASSERT_X(false, __FUNCTION__, |
| 1737 | // "Mismatched name in the room members list"); |
| 1738 | qCCritical(MEMBERS) << "Mismatched name in the room members list;" |
| 1739 | " avoiding the list corruption" ; |
| 1740 | membersMap.remove(key: it.key(), value: u); |
| 1741 | } |
| 1742 | } |
| 1743 | if (namesake) |
| 1744 | emit q->memberRenamed(user: namesake); |
| 1745 | } |
| 1746 | |
| 1747 | inline auto makeErrorStr(const Event& e, QByteArray msg) |
| 1748 | { |
| 1749 | return msg.append(s: "; event dump follows:\n" ) |
| 1750 | .append(a: QJsonDocument(e.fullJson()).toJson()) |
| 1751 | .constData(); |
| 1752 | } |
| 1753 | |
| 1754 | Room::Timeline::size_type |
| 1755 | Room::Private::moveEventsToTimeline(RoomEventsRange events, |
| 1756 | EventsPlacement placement) |
| 1757 | { |
| 1758 | Q_ASSERT(!events.empty()); |
| 1759 | |
| 1760 | const auto usesEncryption = q->usesEncryption(); |
| 1761 | |
| 1762 | // Historical messages arrive in newest-to-oldest order, so the process for |
| 1763 | // them is almost symmetric to the one for new messages. New messages get |
| 1764 | // appended from index 0; old messages go backwards from index -1. |
| 1765 | auto index = timeline.empty() |
| 1766 | ? -((placement + 1) / 2) /* 1 -> -1; -1 -> 0 */ |
| 1767 | : placement == Older ? timeline.front().index() |
| 1768 | : timeline.back().index(); |
| 1769 | auto baseIndex = index; |
| 1770 | for (auto&& e : events) { |
| 1771 | Q_ASSERT_X(e, __FUNCTION__, "Attempt to add nullptr to timeline" ); |
| 1772 | const auto eId = e->id(); |
| 1773 | Q_ASSERT_X( |
| 1774 | !eId.isEmpty(), __FUNCTION__, |
| 1775 | makeErrorStr(*e, "Event with empty id cannot be in the timeline" )); |
| 1776 | Q_ASSERT_X( |
| 1777 | !eventsIndex.contains(eId), __FUNCTION__, |
| 1778 | makeErrorStr(*e, "Event is already in the timeline; " |
| 1779 | "incoming events were not properly deduplicated" )); |
| 1780 | const auto& ti = placement == Older |
| 1781 | ? timeline.emplace_front(args: std::move(e), args&: --index) |
| 1782 | : timeline.emplace_back(args: std::move(e), args&: ++index); |
| 1783 | eventsIndex.insert(key: eId, value: index); |
| 1784 | if (usesEncryption) |
| 1785 | if (auto* const rme = ti.viewAs<RoomMessageEvent>()) |
| 1786 | if (auto* const content = rme->content()) |
| 1787 | if (auto* const fileInfo = content->fileInfo()) |
| 1788 | if (auto* const efm = std::get_if<EncryptedFileMetadata>( |
| 1789 | ptr: &fileInfo->source)) |
| 1790 | FileMetadataMap::add(roomId: id, eventId: eId, fileMetadata: *efm); |
| 1791 | |
| 1792 | if (auto n = q->checkForNotifications(ti); n.type != Notification::None) |
| 1793 | notifications.insert(key: eId, value: n); |
| 1794 | Q_ASSERT(q->findInTimeline(eId)->event()->id() == eId); |
| 1795 | } |
| 1796 | const auto insertedSize = (index - baseIndex) * placement; |
| 1797 | Q_ASSERT(insertedSize == int(events.size())); |
| 1798 | return Timeline::size_type(insertedSize); |
| 1799 | } |
| 1800 | |
| 1801 | QString Room::memberName(const QString& mxId) const |
| 1802 | { |
| 1803 | // See https://github.com/matrix-org/matrix-doc/issues/1375 |
| 1804 | if (const auto rme = currentState().get<RoomMemberEvent>(stateKey: mxId)) { |
| 1805 | if (rme->newDisplayName()) |
| 1806 | return *rme->newDisplayName(); |
| 1807 | if (rme->prevContent() && rme->prevContent()->displayName) |
| 1808 | return *rme->prevContent()->displayName; |
| 1809 | } |
| 1810 | return {}; |
| 1811 | } |
| 1812 | |
| 1813 | QString Room::roomMembername(const User* u) const |
| 1814 | { |
| 1815 | Q_ASSERT(u != nullptr); |
| 1816 | return disambiguatedMemberName(mxId: u->id()); |
| 1817 | } |
| 1818 | |
| 1819 | QString Room::roomMembername(const QString& userId) const |
| 1820 | { |
| 1821 | return disambiguatedMemberName(mxId: userId); |
| 1822 | } |
| 1823 | |
| 1824 | inline QString makeFullUserName(const QString& displayName, const QString& mxId) |
| 1825 | { |
| 1826 | return displayName % " ("_ls % mxId % u')'; |
| 1827 | } |
| 1828 | |
| 1829 | QString Room::disambiguatedMemberName(const QString& mxId) const |
| 1830 | { |
| 1831 | // See the CS spec, section 11.2.2.3 |
| 1832 | |
| 1833 | const auto username = memberName(mxId); |
| 1834 | if (username.isEmpty()) |
| 1835 | return mxId; |
| 1836 | |
| 1837 | auto namesakesIt = qAsConst(t&: d->membersMap).find(key: username); |
| 1838 | |
| 1839 | // We expect a user to be a member of the room - but technically it is |
| 1840 | // possible to invoke this function even for non-members. In such case |
| 1841 | // we return the full name, just in case. |
| 1842 | if (namesakesIt == d->membersMap.cend()) |
| 1843 | return makeFullUserName(displayName: username, mxId); |
| 1844 | |
| 1845 | auto nextUserIt = namesakesIt; |
| 1846 | if (++nextUserIt == d->membersMap.cend() || nextUserIt.key() != username) |
| 1847 | return username; // No disambiguation necessary |
| 1848 | |
| 1849 | return makeFullUserName(displayName: username, mxId); // Disambiguate fully |
| 1850 | } |
| 1851 | |
| 1852 | QString Room::safeMemberName(const QString& userId) const |
| 1853 | { |
| 1854 | return sanitized(plainText: disambiguatedMemberName(mxId: userId)); |
| 1855 | } |
| 1856 | |
| 1857 | QString Room::htmlSafeMemberName(const QString& userId) const |
| 1858 | { |
| 1859 | return safeMemberName(userId).toHtmlEscaped(); |
| 1860 | } |
| 1861 | |
| 1862 | QUrl Room::memberAvatarUrl(const QString &mxId) const |
| 1863 | { |
| 1864 | // See https://github.com/matrix-org/matrix-doc/issues/1375 |
| 1865 | if (const auto rme = currentState().get<RoomMemberEvent>(stateKey: mxId)) { |
| 1866 | if (rme->newAvatarUrl()) |
| 1867 | return *rme->newAvatarUrl(); |
| 1868 | if (rme->prevContent() && rme->prevContent()->avatarUrl) |
| 1869 | return *rme->prevContent()->avatarUrl; |
| 1870 | } |
| 1871 | return {}; |
| 1872 | } |
| 1873 | |
| 1874 | Room::Changes Room::Private::updateStatsFromSyncData(const SyncRoomData& data, |
| 1875 | bool fromCache) |
| 1876 | { |
| 1877 | Changes changes {}; |
| 1878 | if (fromCache) { |
| 1879 | // Initial load of cached statistics |
| 1880 | partiallyReadStats = |
| 1881 | EventStats::fromCachedCounters(notableCount: data.partiallyReadCount); |
| 1882 | unreadStats = EventStats::fromCachedCounters(notableCount: data.unreadCount, |
| 1883 | highlightCount: data.highlightCount); |
| 1884 | // Migrate from lib 0.6: -1 in the old unread counter overrides 0 |
| 1885 | // (which loads to an estimate) in notification_count. Next caching will |
| 1886 | // save -1 in both places, completing the migration. |
| 1887 | if (data.unreadCount == 0 && data.partiallyReadCount == -1) |
| 1888 | unreadStats.isEstimate = false; |
| 1889 | changes |= Change::PartiallyReadStats | Change::UnreadStats; |
| 1890 | qCDebug(MESSAGES) << "Loaded" << q->objectName() |
| 1891 | << "event statistics from cache:" << partiallyReadStats |
| 1892 | << "since m.fully_read," << unreadStats |
| 1893 | << "since m.read" ; |
| 1894 | } else if (timeline.empty()) { |
| 1895 | // In absence of actual events use statistics from the homeserver |
| 1896 | if (merge(lhs&: unreadStats.notableCount, rhs: data.unreadCount)) |
| 1897 | changes |= Change::PartiallyReadStats; |
| 1898 | if (merge(lhs&: unreadStats.highlightCount, rhs: data.highlightCount)) |
| 1899 | changes |= Change::UnreadStats; |
| 1900 | unreadStats.isEstimate = !data.unreadCount.has_value() |
| 1901 | || *data.unreadCount > 0; |
| 1902 | qCDebug(MESSAGES) |
| 1903 | << "Using server-side unread event statistics while the" |
| 1904 | << q->objectName() << "timeline is empty:" << unreadStats; |
| 1905 | } |
| 1906 | bool correctedStats = false; |
| 1907 | if (unreadStats.highlightCount > partiallyReadStats.highlightCount) { |
| 1908 | correctedStats = true; |
| 1909 | partiallyReadStats.highlightCount = unreadStats.highlightCount; |
| 1910 | partiallyReadStats.isEstimate |= unreadStats.isEstimate; |
| 1911 | } |
| 1912 | if (unreadStats.notableCount > partiallyReadStats.notableCount) { |
| 1913 | correctedStats = true; |
| 1914 | partiallyReadStats.notableCount = unreadStats.notableCount; |
| 1915 | partiallyReadStats.isEstimate |= unreadStats.isEstimate; |
| 1916 | } |
| 1917 | if (!unreadStats.isEstimate && partiallyReadStats.isEstimate) { |
| 1918 | correctedStats = true; |
| 1919 | partiallyReadStats.isEstimate = true; |
| 1920 | } |
| 1921 | if (correctedStats) |
| 1922 | qCDebug(MESSAGES) << "Partially read event statistics in" |
| 1923 | << q->objectName() << "were adjusted to" |
| 1924 | << partiallyReadStats |
| 1925 | << "to be consistent with the m.read receipt" ; |
| 1926 | Q_ASSERT(partiallyReadStats.isValidFor(q, q->fullyReadMarker())); |
| 1927 | Q_ASSERT(unreadStats.isValidFor(q, q->localReadReceiptMarker())); |
| 1928 | |
| 1929 | // TODO: Once the library learns to count highlights, drop |
| 1930 | // serverHighlightCount and only use the server-side counter when |
| 1931 | // the timeline is empty (see the code above). |
| 1932 | if (merge(lhs&: serverHighlightCount, rhs: data.highlightCount)) { |
| 1933 | qCDebug(MESSAGES) << "Updated highlights number in" << q->objectName() |
| 1934 | << "to" << serverHighlightCount; |
| 1935 | changes |= Change::Highlights; |
| 1936 | } |
| 1937 | return changes; |
| 1938 | } |
| 1939 | |
| 1940 | void Room::updateData(SyncRoomData&& data, bool fromCache) |
| 1941 | { |
| 1942 | qCDebug(MAIN) << "--- Updating room" << id() << "/" << objectName(); |
| 1943 | bool firstUpdate = d->baseState.empty(); |
| 1944 | |
| 1945 | if (d->prevBatch && d->prevBatch->isEmpty()) |
| 1946 | *d->prevBatch = data.timelinePrevBatch; |
| 1947 | setJoinState(data.joinState); |
| 1948 | |
| 1949 | Changes roomChanges {}; |
| 1950 | // The order of calculation is important - don't merge the lines! |
| 1951 | roomChanges |= d->updateStateFrom(events: std::move(data.state)); |
| 1952 | roomChanges |= d->setSummary(std::move(data.summary)); |
| 1953 | roomChanges |= d->addNewMessageEvents(events: std::move(data.timeline)); |
| 1954 | |
| 1955 | for (auto&& ephemeralEvent : data.ephemeral) |
| 1956 | roomChanges |= processEphemeralEvent(event: std::move(ephemeralEvent)); |
| 1957 | |
| 1958 | for (auto&& event : data.accountData) |
| 1959 | roomChanges |= processAccountDataEvent(event: std::move(event)); |
| 1960 | |
| 1961 | roomChanges |= d->updateStatsFromSyncData(data, fromCache); |
| 1962 | |
| 1963 | if (roomChanges != 0) { |
| 1964 | // First test for changes that can only come from /sync calls and not |
| 1965 | // other interactions (/members, /messages etc.) |
| 1966 | if ((roomChanges & Change::Topic) > 0) |
| 1967 | emit topicChanged(); |
| 1968 | |
| 1969 | if ((roomChanges & Change::RoomNames) > 0) |
| 1970 | emit namesChanged(room: this); |
| 1971 | |
| 1972 | // And now test for changes that can occur from /sync or otherwise |
| 1973 | d->postprocessChanges(changes: roomChanges, saveState: !fromCache); |
| 1974 | } |
| 1975 | if (firstUpdate) |
| 1976 | emit baseStateLoaded(); |
| 1977 | qCDebug(MAIN) << "--- Finished updating room" << id() << "/" << objectName(); |
| 1978 | } |
| 1979 | |
| 1980 | void Room::Private::postprocessChanges(Changes changes, bool saveState) |
| 1981 | { |
| 1982 | if (!changes) |
| 1983 | return; |
| 1984 | |
| 1985 | if ((changes & Change::Members) > 0) |
| 1986 | emit q->memberListChanged(); |
| 1987 | |
| 1988 | if ((changes & (Change::RoomNames | Change::Members | Change::Summary)) > 0) |
| 1989 | updateDisplayname(); |
| 1990 | |
| 1991 | if ((changes & Change::PartiallyReadStats) > 0) { |
| 1992 | QT_IGNORE_DEPRECATIONS( |
| 1993 | emit q->unreadMessagesChanged(q);) // TODO: remove in 0.8 |
| 1994 | emit q->partiallyReadStatsChanged(); |
| 1995 | } |
| 1996 | |
| 1997 | if ((changes & Change::UnreadStats) > 0) |
| 1998 | emit q->unreadStatsChanged(); |
| 1999 | |
| 2000 | if ((changes & Change::Highlights) > 0) |
| 2001 | emit q->highlightCountChanged(); |
| 2002 | |
| 2003 | qCDebug(MAIN).nospace() << terse << changes << " = 0x" << Qt::hex |
| 2004 | << uint(changes) << " in " << q->objectName(); |
| 2005 | emit q->changed(changes); |
| 2006 | if (saveState) |
| 2007 | connection->saveRoomState(r: q); |
| 2008 | } |
| 2009 | |
| 2010 | RoomEvent* Room::Private::addAsPending(RoomEventPtr&& event) |
| 2011 | { |
| 2012 | if (event->transactionId().isEmpty()) |
| 2013 | event->setTransactionId(connection->generateTxnId()); |
| 2014 | if (event->roomId().isEmpty()) |
| 2015 | event->setRoomId(id); |
| 2016 | if (event->senderId().isEmpty()) |
| 2017 | event->setSender(connection->userId()); |
| 2018 | auto* pEvent = std::to_address(ptr: event); |
| 2019 | emit q->pendingEventAboutToAdd(event: pEvent); |
| 2020 | unsyncedEvents.emplace_back(args: std::move(event)); |
| 2021 | emit q->pendingEventAdded(); |
| 2022 | return pEvent; |
| 2023 | } |
| 2024 | |
| 2025 | QString Room::Private::sendEvent(RoomEventPtr&& event) |
| 2026 | { |
| 2027 | if (!q->successorId().isEmpty()) { |
| 2028 | qCWarning(MAIN) << q << "has been upgraded, event won't be sent" ; |
| 2029 | return {}; |
| 2030 | } |
| 2031 | |
| 2032 | return doSendEvent(pEvent: addAsPending(event: std::move(event))); |
| 2033 | } |
| 2034 | |
| 2035 | QString Room::Private::doSendEvent(const RoomEvent* pEvent) |
| 2036 | { |
| 2037 | const auto txnId = pEvent->transactionId(); |
| 2038 | // TODO, #133: Enqueue the job rather than immediately trigger it. |
| 2039 | const RoomEvent* _event = pEvent; |
| 2040 | std::unique_ptr<EncryptedEvent> encryptedEvent; |
| 2041 | |
| 2042 | if (q->usesEncryption()) { |
| 2043 | if (!connection->encryptionEnabled()) { |
| 2044 | qWarning(catFunc: E2EE) << "Room" << q->objectName() |
| 2045 | << "uses encryption but E2EE is switched off for" |
| 2046 | << connection->objectName() |
| 2047 | << "- the message won't be sent" ; |
| 2048 | onEventSendingFailure(txnId); |
| 2049 | return txnId; |
| 2050 | } |
| 2051 | #ifdef Quotient_E2EE_ENABLED |
| 2052 | if (!hasValidMegolmSession() || shouldRotateMegolmSession()) { |
| 2053 | createMegolmSession(); |
| 2054 | } |
| 2055 | |
| 2056 | // Send the session to other people |
| 2057 | connection->sendSessionKeyToDevices(id, *currentOutboundMegolmSession, |
| 2058 | getDevicesWithoutKey()); |
| 2059 | |
| 2060 | const auto encrypted = currentOutboundMegolmSession->encrypt( |
| 2061 | QJsonDocument(pEvent->fullJson()).toJson()); |
| 2062 | currentOutboundMegolmSession->setMessageCount( |
| 2063 | currentOutboundMegolmSession->messageCount() + 1); |
| 2064 | connection->database()->saveCurrentOutboundMegolmSession( |
| 2065 | id, *currentOutboundMegolmSession); |
| 2066 | encryptedEvent = makeEvent<EncryptedEvent>( |
| 2067 | encrypted, connection->olmAccount()->identityKeys().curve25519, |
| 2068 | connection->deviceId(), QString::fromLatin1(currentOutboundMegolmSession->sessionId())); |
| 2069 | encryptedEvent->setTransactionId(connection->generateTxnId()); |
| 2070 | encryptedEvent->setRoomId(id); |
| 2071 | encryptedEvent->setSender(connection->userId()); |
| 2072 | if (pEvent->contentJson().contains("m.relates_to"_ls )) { |
| 2073 | encryptedEvent->setRelation( |
| 2074 | pEvent->contentJson()["m.relates_to"_ls ].toObject()); |
| 2075 | } |
| 2076 | // We show the unencrypted event locally while pending. The echo |
| 2077 | // check will throw the encrypted version out |
| 2078 | _event = encryptedEvent.get(); |
| 2079 | #endif |
| 2080 | } |
| 2081 | |
| 2082 | if (auto call = |
| 2083 | connection->callApi<SendMessageJob>(runningPolicy: BackgroundRequest, jobArgs&: id, |
| 2084 | jobArgs: _event->matrixType(), jobArgs: txnId, |
| 2085 | jobArgs: _event->contentJson())) { |
| 2086 | Room::connect(sender: call, signal: &BaseJob::sentRequest, context: q, slot: [this, txnId] { |
| 2087 | auto it = q->findPendingEvent(txnId); |
| 2088 | if (it == unsyncedEvents.end()) { |
| 2089 | qWarning(catFunc: EVENTS) << "Pending event for transaction" << txnId |
| 2090 | << "not found - got synced so soon?" ; |
| 2091 | return; |
| 2092 | } |
| 2093 | it->setDeparted(); |
| 2094 | emit q->pendingEventChanged(pendingEventIndex: int(it - unsyncedEvents.begin())); |
| 2095 | }); |
| 2096 | Room::connect(sender: call, signal: &BaseJob::result, context: q, slot: [this, txnId, call] { |
| 2097 | if (!call->status().good()) { |
| 2098 | onEventSendingFailure(txnId, call); |
| 2099 | return; |
| 2100 | } |
| 2101 | auto it = q->findPendingEvent(txnId); |
| 2102 | if (it != unsyncedEvents.end()) { |
| 2103 | if (it->deliveryStatus() != EventStatus::ReachedServer) { |
| 2104 | it->setReachedServer(call->eventId()); |
| 2105 | emit q->pendingEventChanged(pendingEventIndex: int(it - unsyncedEvents.begin())); |
| 2106 | } |
| 2107 | } else |
| 2108 | qDebug(catFunc: EVENTS) << "Pending event for transaction" << txnId |
| 2109 | << "already merged" ; |
| 2110 | |
| 2111 | emit q->messageSent(txnId, eventId: call->eventId()); |
| 2112 | }); |
| 2113 | } else |
| 2114 | onEventSendingFailure(txnId); |
| 2115 | return txnId; |
| 2116 | } |
| 2117 | |
| 2118 | void Room::Private::onEventSendingFailure(const QString& txnId, BaseJob* call) |
| 2119 | { |
| 2120 | auto it = q->findPendingEvent(txnId); |
| 2121 | if (it == unsyncedEvents.end()) { |
| 2122 | qCritical(catFunc: EVENTS) << "Pending event for transaction" << txnId |
| 2123 | << "could not be sent" ; |
| 2124 | return; |
| 2125 | } |
| 2126 | it->setSendingFailed(call ? call->statusCaption() % ": "_ls % call->errorString() |
| 2127 | : tr(s: "The call could not be started" )); |
| 2128 | emit q->pendingEventChanged(pendingEventIndex: int(it - unsyncedEvents.begin())); |
| 2129 | } |
| 2130 | |
| 2131 | QString Room::retryMessage(const QString& txnId) |
| 2132 | { |
| 2133 | const auto it = findPendingEvent(txnId); |
| 2134 | Q_ASSERT(it != d->unsyncedEvents.end()); |
| 2135 | qCDebug(EVENTS) << "Retrying transaction" << txnId; |
| 2136 | const auto& transferIt = d->fileTransfers.constFind(key: txnId); |
| 2137 | if (transferIt != d->fileTransfers.cend()) { |
| 2138 | Q_ASSERT(transferIt->isUpload); |
| 2139 | if (transferIt->status == FileTransferInfo::Completed) { |
| 2140 | qCDebug(MESSAGES) |
| 2141 | << "File for transaction" << txnId |
| 2142 | << "has already been uploaded, bypassing re-upload" ; |
| 2143 | } else { |
| 2144 | if (isJobPending(job: transferIt->job)) { |
| 2145 | qCDebug(MESSAGES) << "Abandoning the upload job for transaction" |
| 2146 | << txnId << "and starting again" ; |
| 2147 | transferIt->job->abandon(); |
| 2148 | emit fileTransferFailed(id: txnId, |
| 2149 | errorMessage: tr(s: "File upload will be retried" )); |
| 2150 | } |
| 2151 | uploadFile(id: txnId, localFilename: QUrl::fromLocalFile( |
| 2152 | localfile: transferIt->localFileInfo.absoluteFilePath())); |
| 2153 | // FIXME: Content type is no more passed here but it should |
| 2154 | } |
| 2155 | } |
| 2156 | if (it->deliveryStatus() == EventStatus::ReachedServer) { |
| 2157 | qCWarning(MAIN) |
| 2158 | << "The previous attempt has reached the server; two" |
| 2159 | " events are likely to be in the timeline after retry" ; |
| 2160 | } |
| 2161 | it->resetStatus(); |
| 2162 | emit pendingEventChanged(pendingEventIndex: int(it - d->unsyncedEvents.begin())); |
| 2163 | return d->doSendEvent(pEvent: it->event()); |
| 2164 | } |
| 2165 | |
| 2166 | // Using a function defers actual tr() invocation to the moment when |
| 2167 | // translations are initialised |
| 2168 | auto FileTransferCancelledMsg() { return Room::tr(s: "File transfer cancelled" ); } |
| 2169 | |
| 2170 | void Room::discardMessage(const QString& txnId) |
| 2171 | { |
| 2172 | auto it = std::find_if(first: d->unsyncedEvents.begin(), last: d->unsyncedEvents.end(), |
| 2173 | pred: [txnId](const auto& evt) { |
| 2174 | return evt->transactionId() == txnId; |
| 2175 | }); |
| 2176 | Q_ASSERT(it != d->unsyncedEvents.end()); |
| 2177 | qCDebug(EVENTS) << "Discarding transaction" << txnId; |
| 2178 | const auto& transferIt = d->fileTransfers.find(key: txnId); |
| 2179 | if (transferIt != d->fileTransfers.end()) { |
| 2180 | Q_ASSERT(transferIt->isUpload); |
| 2181 | if (isJobPending(job: transferIt->job)) { |
| 2182 | transferIt->status = FileTransferInfo::Cancelled; |
| 2183 | transferIt->job->abandon(); |
| 2184 | emit fileTransferFailed(id: txnId, errorMessage: FileTransferCancelledMsg()); |
| 2185 | } else if (transferIt->status == FileTransferInfo::Completed) { |
| 2186 | qCWarning(MAIN) |
| 2187 | << "File for transaction" << txnId |
| 2188 | << "has been uploaded but the message was discarded" ; |
| 2189 | } |
| 2190 | } |
| 2191 | emit pendingEventAboutToDiscard(pendingEventIndex: int(it - d->unsyncedEvents.begin())); |
| 2192 | d->unsyncedEvents.erase(position: it); |
| 2193 | emit pendingEventDiscarded(); |
| 2194 | } |
| 2195 | |
| 2196 | QString Room::postMessage(const QString& plainText, MessageEventType type) |
| 2197 | { |
| 2198 | return d->sendEvent<RoomMessageEvent>(eventArgs: plainText, eventArgs&: type); |
| 2199 | } |
| 2200 | |
| 2201 | QString Room::postPlainText(const QString& plainText) |
| 2202 | { |
| 2203 | return postMessage(plainText, type: MessageEventType::Text); |
| 2204 | } |
| 2205 | |
| 2206 | QString Room::postHtmlMessage(const QString& plainText, const QString& html, |
| 2207 | MessageEventType type) |
| 2208 | { |
| 2209 | return d->sendEvent<RoomMessageEvent>( |
| 2210 | eventArgs: plainText, eventArgs&: type, |
| 2211 | eventArgs: new EventContent::TextContent(html, QStringLiteral("text/html" ))); |
| 2212 | } |
| 2213 | |
| 2214 | QString Room::postHtmlText(const QString& plainText, const QString& html) |
| 2215 | { |
| 2216 | return postHtmlMessage(plainText, html); |
| 2217 | } |
| 2218 | |
| 2219 | QString Room::postReaction(const QString& eventId, const QString& key) |
| 2220 | { |
| 2221 | return d->sendEvent<ReactionEvent>(eventArgs: eventId, eventArgs: key); |
| 2222 | } |
| 2223 | |
| 2224 | QString Room::Private::doPostFile(RoomEventPtr&& msgEvent, const QUrl& localUrl) |
| 2225 | { |
| 2226 | const auto txnId = addAsPending(event: std::move(msgEvent))->transactionId(); |
| 2227 | // Remote URL will only be known after upload; fill in the local path |
| 2228 | // to enable the preview while the event is pending. |
| 2229 | q->uploadFile(id: txnId, localFilename: localUrl); |
| 2230 | // Below, the upload job is used as a context object to clean up connections |
| 2231 | const auto& transferJob = fileTransfers.value(key: txnId).job; |
| 2232 | connect(sender: q, signal: &Room::fileTransferCompleted, context: transferJob, |
| 2233 | slot: [this, txnId](const QString& tId, const QUrl&, |
| 2234 | const FileSourceInfo& fileMetadata) { |
| 2235 | if (tId != txnId) |
| 2236 | return; |
| 2237 | |
| 2238 | const auto it = q->findPendingEvent(txnId); |
| 2239 | if (it != unsyncedEvents.end()) { |
| 2240 | it->setFileUploaded(fileMetadata); |
| 2241 | emit q->pendingEventChanged(pendingEventIndex: int(it - unsyncedEvents.begin())); |
| 2242 | doSendEvent(pEvent: it->get()); |
| 2243 | } else { |
| 2244 | // Normally in this situation we should instruct |
| 2245 | // the media server to delete the file; alas, there's no |
| 2246 | // API specced for that. |
| 2247 | qCWarning(MAIN) |
| 2248 | << "File uploaded to" << getUrlFromSourceInfo(fsi: fileMetadata) |
| 2249 | << "but the event referring to it was " |
| 2250 | "cancelled" ; |
| 2251 | } |
| 2252 | }); |
| 2253 | connect(sender: q, signal: &Room::fileTransferFailed, context: transferJob, |
| 2254 | slot: [this, txnId](const QString& tId) { |
| 2255 | if (tId != txnId) |
| 2256 | return; |
| 2257 | |
| 2258 | const auto it = q->findPendingEvent(txnId); |
| 2259 | if (it == unsyncedEvents.end()) |
| 2260 | return; |
| 2261 | |
| 2262 | const auto idx = int(it - unsyncedEvents.begin()); |
| 2263 | emit q->pendingEventAboutToDiscard(pendingEventIndex: idx); |
| 2264 | // See #286 on why `it` may not be valid here. |
| 2265 | unsyncedEvents.erase(position: unsyncedEvents.begin() + idx); |
| 2266 | emit q->pendingEventDiscarded(); |
| 2267 | }); |
| 2268 | |
| 2269 | return txnId; |
| 2270 | } |
| 2271 | |
| 2272 | QString Room::postFile(const QString& plainText, |
| 2273 | EventContent::TypedBase* content) |
| 2274 | { |
| 2275 | Q_ASSERT(content != nullptr && content->fileInfo() != nullptr); |
| 2276 | const auto* const fileInfo = content->fileInfo(); |
| 2277 | Q_ASSERT(fileInfo != nullptr); |
| 2278 | // This is required because toLocalFile doesn't work on android and toString doesn't work on the desktop |
| 2279 | auto url = fileInfo->url().isLocalFile() ? fileInfo->url().toLocalFile() : fileInfo->url().toString(); |
| 2280 | QFileInfo localFile { url }; |
| 2281 | Q_ASSERT(localFile.isFile()); |
| 2282 | |
| 2283 | return d->doPostFile( |
| 2284 | msgEvent: makeEvent<RoomMessageEvent>( |
| 2285 | args: plainText, args: RoomMessageEvent::rawMsgTypeForFile(fi: localFile), args&: content), |
| 2286 | localUrl: fileInfo->url()); |
| 2287 | } |
| 2288 | |
| 2289 | #if QT_VERSION_MAJOR < 6 |
| 2290 | QString Room::postFile(const QString& plainText, const QUrl& localPath, |
| 2291 | bool asGenericFile) |
| 2292 | { |
| 2293 | QFileInfo localFile { localPath.toLocalFile() }; |
| 2294 | Q_ASSERT(localFile.isFile()); |
| 2295 | return d->doPostFile(makeEvent<RoomMessageEvent>(plainText, localFile, |
| 2296 | asGenericFile), |
| 2297 | localPath); |
| 2298 | } |
| 2299 | #endif |
| 2300 | |
| 2301 | QString Room::postEvent(RoomEvent* event) |
| 2302 | { |
| 2303 | return d->sendEvent(event: RoomEventPtr(event)); |
| 2304 | } |
| 2305 | |
| 2306 | QString Room::postJson(const QString& matrixType, |
| 2307 | const QJsonObject& eventContent) |
| 2308 | { |
| 2309 | return d->sendEvent(event: loadEvent<RoomEvent>(matrixType, otherBasicJsonParams: eventContent)); |
| 2310 | } |
| 2311 | |
| 2312 | SetRoomStateWithKeyJob* Room::setState(const StateEvent& evt) |
| 2313 | { |
| 2314 | return setState(evtType: evt.matrixType(), stateKey: evt.stateKey(), contentJson: evt.contentJson()); |
| 2315 | } |
| 2316 | |
| 2317 | SetRoomStateWithKeyJob* Room::setState(const QString& evtType, |
| 2318 | const QString& stateKey, |
| 2319 | const QJsonObject& contentJson) |
| 2320 | { |
| 2321 | return d->requestSetState(evtType, stateKey, contentJson); |
| 2322 | } |
| 2323 | |
| 2324 | void Room::setName(const QString& newName) |
| 2325 | { |
| 2326 | setState<RoomNameEvent>(newName); |
| 2327 | } |
| 2328 | |
| 2329 | void Room::setCanonicalAlias(const QString& newAlias) |
| 2330 | { |
| 2331 | setState<RoomCanonicalAliasEvent>(args: newAlias, args: altAliases()); |
| 2332 | } |
| 2333 | |
| 2334 | void Room::setPinnedEvents(const QStringList& events) |
| 2335 | { |
| 2336 | setState<RoomPinnedEventsEvent>(events); |
| 2337 | } |
| 2338 | void Room::setLocalAliases(const QStringList& aliases) |
| 2339 | { |
| 2340 | setState<RoomCanonicalAliasEvent>(args: canonicalAlias(), args: aliases); |
| 2341 | } |
| 2342 | |
| 2343 | void Room::setTopic(const QString& newTopic) |
| 2344 | { |
| 2345 | setState<RoomTopicEvent>(newTopic); |
| 2346 | } |
| 2347 | |
| 2348 | bool isEchoEvent(const RoomEventPtr& le, const PendingEventItem& re) |
| 2349 | { |
| 2350 | if (le->metaType() != re->metaType()) |
| 2351 | return false; |
| 2352 | |
| 2353 | if (!re->id().isEmpty()) |
| 2354 | return le->id() == re->id(); |
| 2355 | if (!re->transactionId().isEmpty()) |
| 2356 | return le->transactionId() == re->transactionId(); |
| 2357 | |
| 2358 | // This one is not reliable (there can be two unsynced |
| 2359 | // events with the same type, sender and state key) but |
| 2360 | // it's the best we have for state events. |
| 2361 | if (re->isStateEvent()) |
| 2362 | return le->stateKey() == re->stateKey(); |
| 2363 | |
| 2364 | // Empty id and no state key, hmm... (shrug) |
| 2365 | return le->contentJson() == re->contentJson(); |
| 2366 | } |
| 2367 | |
| 2368 | bool Room::supportsCalls() const { return joinedCount() == 2; } |
| 2369 | |
| 2370 | void Room::checkVersion() |
| 2371 | { |
| 2372 | const auto defaultVersion = connection()->defaultRoomVersion(); |
| 2373 | const auto stableVersions = connection()->stableRoomVersions(); |
| 2374 | Q_ASSERT(!defaultVersion.isEmpty()); |
| 2375 | // This method is only called after the base state has been loaded |
| 2376 | // or the server capabilities have been loaded. |
| 2377 | emit stabilityUpdated(recommendedDefault: defaultVersion, stableVersions); |
| 2378 | if (!stableVersions.contains(str: version())) { |
| 2379 | qCDebug(STATE) << this << "version is" << version() |
| 2380 | << "which the server doesn't count as stable" ; |
| 2381 | if (canSwitchVersions()) |
| 2382 | qCDebug(STATE) |
| 2383 | << "The current user has enough privileges to fix it" ; |
| 2384 | } |
| 2385 | } |
| 2386 | |
| 2387 | void Room::inviteCall(const QString& callId, const int lifetime, |
| 2388 | const QString& sdp) |
| 2389 | { |
| 2390 | Q_ASSERT(supportsCalls()); |
| 2391 | d->sendEvent<CallInviteEvent>(eventArgs: callId, eventArgs: lifetime, eventArgs: sdp); |
| 2392 | } |
| 2393 | |
| 2394 | void Room::sendCallCandidates(const QString& callId, |
| 2395 | const QJsonArray& candidates) |
| 2396 | { |
| 2397 | Q_ASSERT(supportsCalls()); |
| 2398 | d->sendEvent<CallCandidatesEvent>(eventArgs: callId, eventArgs: candidates); |
| 2399 | } |
| 2400 | |
| 2401 | void Room::answerCall(const QString& callId, [[maybe_unused]] int lifetime, |
| 2402 | const QString& sdp) |
| 2403 | { |
| 2404 | qCWarning(MAIN) << "To client developer: drop lifetime parameter from " |
| 2405 | "Room::answerCall(), it is no more accepted" ; |
| 2406 | answerCall(callId, sdp); |
| 2407 | } |
| 2408 | |
| 2409 | void Room::answerCall(const QString& callId, const QString& sdp) |
| 2410 | { |
| 2411 | Q_ASSERT(supportsCalls()); |
| 2412 | d->sendEvent<CallAnswerEvent>(eventArgs: callId, eventArgs: sdp); |
| 2413 | } |
| 2414 | |
| 2415 | void Room::hangupCall(const QString& callId) |
| 2416 | { |
| 2417 | Q_ASSERT(supportsCalls()); |
| 2418 | d->sendEvent<CallHangupEvent>(eventArgs: callId); |
| 2419 | } |
| 2420 | |
| 2421 | void Room::getPreviousContent(int limit, const QString& filter) |
| 2422 | { |
| 2423 | d->getPreviousContent(limit, filter); |
| 2424 | } |
| 2425 | |
| 2426 | void Room::Private::getPreviousContent(int limit, const QString& filter) |
| 2427 | { |
| 2428 | if (!prevBatch || isJobPending(job: eventsHistoryJob)) |
| 2429 | return; |
| 2430 | |
| 2431 | eventsHistoryJob = connection->callApi<GetRoomEventsJob>(jobArgs&: id, jobArgs: "b"_ls , jobArgs&: *prevBatch, |
| 2432 | jobArgs: QString(), jobArgs&: limit, jobArgs: filter); |
| 2433 | emit q->eventsHistoryJobChanged(); |
| 2434 | connect(sender: eventsHistoryJob, signal: &BaseJob::success, context: q, slot: [this] { |
| 2435 | if (const auto newPrevBatch = eventsHistoryJob->end(); |
| 2436 | !newPrevBatch.isEmpty() && *prevBatch != newPrevBatch) // |
| 2437 | { |
| 2438 | *prevBatch = newPrevBatch; |
| 2439 | } else { |
| 2440 | qInfo(catFunc: MESSAGES) |
| 2441 | << "Room" << q->objectName() << "has loaded all history" ; |
| 2442 | prevBatch.reset(); |
| 2443 | } |
| 2444 | |
| 2445 | addHistoricalMessageEvents(events: eventsHistoryJob->chunk()); |
| 2446 | }); |
| 2447 | connect(sender: eventsHistoryJob, signal: &QObject::destroyed, context: q, |
| 2448 | slot: &Room::eventsHistoryJobChanged); |
| 2449 | } |
| 2450 | |
| 2451 | void Room::inviteToRoom(const QString& memberId) |
| 2452 | { |
| 2453 | connection()->callApi<InviteUserJob>(jobArgs: id(), jobArgs: memberId); |
| 2454 | } |
| 2455 | |
| 2456 | LeaveRoomJob* Room::leaveRoom() |
| 2457 | { |
| 2458 | // FIXME, #63: It should be RoomManager, not Connection |
| 2459 | return connection()->leaveRoom(room: this); |
| 2460 | } |
| 2461 | |
| 2462 | void Room::kickMember(const QString& memberId, const QString& reason) |
| 2463 | { |
| 2464 | connection()->callApi<KickJob>(jobArgs: id(), jobArgs: memberId, jobArgs: reason); |
| 2465 | } |
| 2466 | |
| 2467 | void Room::ban(const QString& userId, const QString& reason) |
| 2468 | { |
| 2469 | connection()->callApi<BanJob>(jobArgs: id(), jobArgs: userId, jobArgs: reason); |
| 2470 | } |
| 2471 | |
| 2472 | void Room::unban(const QString& userId) |
| 2473 | { |
| 2474 | connection()->callApi<UnbanJob>(jobArgs: id(), jobArgs: userId); |
| 2475 | } |
| 2476 | |
| 2477 | void Room::redactEvent(const QString& eventId, const QString& reason) |
| 2478 | { |
| 2479 | connection()->callApi<RedactEventJob>(jobArgs: id(), jobArgs: eventId, |
| 2480 | jobArgs: connection()->generateTxnId(), jobArgs: reason); |
| 2481 | } |
| 2482 | |
| 2483 | void Room::uploadFile(const QString& id, const QUrl& localFilename, |
| 2484 | const QString& overrideContentType) |
| 2485 | { |
| 2486 | // This is required because toLocalFile doesn't work on android and toString doesn't work on the desktop |
| 2487 | auto fileName = localFilename.isLocalFile() ? localFilename.toLocalFile() : localFilename.toString(); |
| 2488 | FileSourceInfo fileMetadata; |
| 2489 | #ifdef Quotient_E2EE_ENABLED |
| 2490 | QTemporaryFile tempFile; |
| 2491 | if (usesEncryption()) { |
| 2492 | tempFile.open(); |
| 2493 | QFile file(fileName); |
| 2494 | file.open(QFile::ReadOnly); |
| 2495 | QByteArray data; |
| 2496 | std::tie(fileMetadata, data) = encryptFile(file.readAll()); |
| 2497 | tempFile.write(data); |
| 2498 | tempFile.close(); |
| 2499 | fileName = QFileInfo(tempFile).absoluteFilePath(); |
| 2500 | } |
| 2501 | #endif |
| 2502 | auto job = connection()->uploadFile(fileName, overrideContentType); |
| 2503 | if (isJobPending(job)) { |
| 2504 | d->fileTransfers[id] = { job, fileName, true }; |
| 2505 | connect(sender: job, signal: &BaseJob::uploadProgress, context: this, |
| 2506 | slot: [this, id](qint64 sent, qint64 total) { |
| 2507 | d->fileTransfers[id].update(p: sent, t: total); |
| 2508 | emit fileTransferProgress(id, progress: sent, total); |
| 2509 | }); |
| 2510 | connect(sender: job, signal: &BaseJob::success, context: this, |
| 2511 | slot: [this, id, localFilename, job, fileMetadata]() mutable { |
| 2512 | // The lambda is mutable to change encryptedFileMetadata |
| 2513 | d->fileTransfers[id].status = FileTransferInfo::Completed; |
| 2514 | setUrlInSourceInfo(fsi&: fileMetadata, newUrl: QUrl(job->contentUri())); |
| 2515 | emit fileTransferCompleted(id, localFile: localFilename, fileMetadata); |
| 2516 | }); |
| 2517 | connect(sender: job, signal: &BaseJob::failure, context: this, |
| 2518 | slot: std::bind(f: &Private::failedTransfer, args&: d, args: id, args: job->errorString())); |
| 2519 | emit newFileTransfer(id, localFile: localFilename); |
| 2520 | } else |
| 2521 | d->failedTransfer(tid: id); |
| 2522 | } |
| 2523 | |
| 2524 | void Room::downloadFile(const QString& eventId, const QUrl& localFilename) |
| 2525 | { |
| 2526 | if (auto ongoingTransfer = d->fileTransfers.constFind(key: eventId); |
| 2527 | ongoingTransfer != d->fileTransfers.cend() |
| 2528 | && ongoingTransfer->status == FileTransferInfo::Started) { |
| 2529 | qCWarning(MAIN) << "Transfer for" << eventId |
| 2530 | << "is ongoing; download won't start" ; |
| 2531 | return; |
| 2532 | } |
| 2533 | |
| 2534 | Q_ASSERT_X(localFilename.isEmpty() || localFilename.isLocalFile(), |
| 2535 | __FUNCTION__, "localFilename should point at a local file" ); |
| 2536 | const auto* event = d->getEventWithFile(eventId); |
| 2537 | if (!event) { |
| 2538 | qCCritical(MAIN) |
| 2539 | << eventId << "is not in the local timeline or has no file content" ; |
| 2540 | Q_ASSERT(false); |
| 2541 | return; |
| 2542 | } |
| 2543 | const auto* const fileInfo = event->content()->fileInfo(); |
| 2544 | if (!fileInfo->isValid()) { |
| 2545 | qCWarning(MAIN) << "Event" << eventId |
| 2546 | << "has an empty or malformed mxc URL; won't download" ; |
| 2547 | return; |
| 2548 | } |
| 2549 | const auto fileUrl = fileInfo->url(); |
| 2550 | auto filePath = localFilename.toLocalFile(); |
| 2551 | if (filePath.isEmpty()) { // Setup default file path |
| 2552 | filePath = |
| 2553 | fileInfo->url().path().mid(position: 1) % u'_' % d->fileNameToDownload(event); |
| 2554 | |
| 2555 | if (filePath.size() > 200) // If too long, elide in the middle |
| 2556 | filePath.replace(i: 128, len: filePath.size() - 192, after: "---"_ls ); |
| 2557 | |
| 2558 | filePath = QDir::tempPath() % u'/' % filePath; |
| 2559 | qDebug(catFunc: MAIN) << "File path:" << filePath; |
| 2560 | } |
| 2561 | DownloadFileJob *job = nullptr; |
| 2562 | #ifdef Quotient_E2EE_ENABLED |
| 2563 | if (auto* fileMetadata = |
| 2564 | std::get_if<EncryptedFileMetadata>(&fileInfo->source)) { |
| 2565 | job = connection()->downloadFile(fileUrl, *fileMetadata, filePath); |
| 2566 | } else { |
| 2567 | #endif |
| 2568 | job = connection()->downloadFile(url: fileUrl, localFilename: filePath); |
| 2569 | #ifdef Quotient_E2EE_ENABLED |
| 2570 | } |
| 2571 | #endif |
| 2572 | if (isJobPending(job)) { |
| 2573 | // If there was a previous transfer (completed or failed), overwrite it. |
| 2574 | d->fileTransfers[eventId] = { job, job->targetFileName() }; |
| 2575 | connect(sender: job, signal: &BaseJob::downloadProgress, context: this, |
| 2576 | slot: [this, eventId](qint64 received, qint64 total) { |
| 2577 | d->fileTransfers[eventId].update(p: received, t: total); |
| 2578 | emit fileTransferProgress(id: eventId, progress: received, total); |
| 2579 | }); |
| 2580 | connect(sender: job, signal: &BaseJob::success, context: this, slot: [this, eventId, fileUrl, job] { |
| 2581 | d->fileTransfers[eventId].status = FileTransferInfo::Completed; |
| 2582 | emit fileTransferCompleted( |
| 2583 | id: eventId, localFile: fileUrl, fileMetadata: QUrl::fromLocalFile(localfile: job->targetFileName())); |
| 2584 | }); |
| 2585 | connect(sender: job, signal: &BaseJob::failure, context: this, |
| 2586 | slot: std::bind(f: &Private::failedTransfer, args&: d, args: eventId, |
| 2587 | args: job->errorString())); |
| 2588 | emit newFileTransfer(id: eventId, localFile: localFilename); |
| 2589 | } else |
| 2590 | d->failedTransfer(tid: eventId); |
| 2591 | } |
| 2592 | |
| 2593 | void Room::cancelFileTransfer(const QString& id) |
| 2594 | { |
| 2595 | const auto it = d->fileTransfers.find(key: id); |
| 2596 | if (it == d->fileTransfers.end()) { |
| 2597 | qCWarning(MAIN) << "No information on file transfer" << id << "in room" |
| 2598 | << d->id; |
| 2599 | return; |
| 2600 | } |
| 2601 | if (isJobPending(job: it->job)) |
| 2602 | it->job->abandon(); |
| 2603 | it->status = FileTransferInfo::Cancelled; |
| 2604 | emit fileTransferFailed(id, errorMessage: FileTransferCancelledMsg()); |
| 2605 | } |
| 2606 | |
| 2607 | void Room::Private::(RoomEvents& events) const |
| 2608 | { |
| 2609 | if (events.empty()) |
| 2610 | return; |
| 2611 | |
| 2612 | // Multiple-remove (by different criteria), single-erase |
| 2613 | // 1. Check for duplicates against the timeline and for events from ignored |
| 2614 | // users |
| 2615 | auto newEnd = |
| 2616 | remove_if(first: events.begin(), last: events.end(), pred: [this](const RoomEventPtr& e) { |
| 2617 | return eventsIndex.contains(key: e->id()) |
| 2618 | || connection->isIgnored(userId: e->senderId()); |
| 2619 | }); |
| 2620 | |
| 2621 | // 2. Check for duplicates within the batch if there are still events. |
| 2622 | for (auto eIt = events.begin(); distance(first: eIt, last: newEnd) > 1; ++eIt) |
| 2623 | newEnd = remove_if(first: eIt + 1, last: newEnd, pred: [eIt](const RoomEventPtr& e) { |
| 2624 | return e->id() == (*eIt)->id(); |
| 2625 | }); |
| 2626 | |
| 2627 | if (newEnd == events.end()) |
| 2628 | return; |
| 2629 | |
| 2630 | qCDebug(EVENTS) << "Dropping" << distance(first: newEnd, last: events.end()) |
| 2631 | << "extraneous event(s)" ; |
| 2632 | events.erase(first: newEnd, last: events.end()); |
| 2633 | } |
| 2634 | |
| 2635 | void Room::Private::decryptIncomingEvents(RoomEvents& events) |
| 2636 | { |
| 2637 | #ifdef Quotient_E2EE_ENABLED |
| 2638 | if (!connection->encryptionEnabled()) |
| 2639 | return; |
| 2640 | if (!q->usesEncryption()) |
| 2641 | return; // If the room doesn't use encryption now, it never did |
| 2642 | |
| 2643 | QElapsedTimer et; |
| 2644 | et.start(); |
| 2645 | size_t totalDecrypted = 0; |
| 2646 | for (auto& eptr : events) { |
| 2647 | if (eptr->isRedacted()) |
| 2648 | continue; |
| 2649 | if (const auto& eeptr = eventCast<EncryptedEvent>(eptr)) { |
| 2650 | if (auto decrypted = q->decryptMessage(*eeptr)) { |
| 2651 | ++totalDecrypted; |
| 2652 | auto&& oldEvent = eventCast<EncryptedEvent>( |
| 2653 | std::exchange(eptr, std::move(decrypted))); |
| 2654 | eptr->setOriginalEvent(std::move(oldEvent)); |
| 2655 | } else |
| 2656 | undecryptedEvents[eeptr->sessionId()] += eeptr->id(); |
| 2657 | } |
| 2658 | } |
| 2659 | if (totalDecrypted > 5 || et.nsecsElapsed() >= ProfilerMinNsecs) |
| 2660 | qDebug(PROFILER) |
| 2661 | << "Decrypted" << totalDecrypted << "events in" << et; |
| 2662 | #endif |
| 2663 | } |
| 2664 | |
| 2665 | //! \brief Make a redacted event |
| 2666 | //! |
| 2667 | //! This applies the redaction procedure as defined by the CS API specification |
| 2668 | //! to the event's JSON and returns the resulting new event. It is |
| 2669 | //! the responsibility of the caller to dispose of the original event after that. |
| 2670 | RoomEventPtr makeRedacted(const RoomEvent& target, |
| 2671 | const RedactionEvent& redaction) |
| 2672 | { |
| 2673 | // The logic below faithfully follows the spec despite quite a few of |
| 2674 | // the preserved keys being only relevant for homeservers. Just in case. |
| 2675 | static const QStringList TopLevelKeysToKeep{ |
| 2676 | EventIdKey, TypeKey, RoomIdKey, SenderKey, |
| 2677 | StateKeyKey, ContentKey, "hashes"_ls , "signatures"_ls , |
| 2678 | "depth"_ls , "prev_events"_ls , "auth_events"_ls , "origin_server_ts"_ls |
| 2679 | }; |
| 2680 | |
| 2681 | auto originalJson = target.fullJson(); |
| 2682 | for (auto it = originalJson.begin(); it != originalJson.end();) { |
| 2683 | if (!TopLevelKeysToKeep.contains(str: it.key())) |
| 2684 | it = originalJson.erase(it); |
| 2685 | else |
| 2686 | ++it; |
| 2687 | } |
| 2688 | if (!target.is<RoomCreateEvent>()) { // See MSC2176 on create events |
| 2689 | static const QHash<QString, QStringList> ContentKeysToKeepPerType{ |
| 2690 | { RedactionEvent::TypeId, { "redacts"_ls } }, |
| 2691 | { RoomMemberEvent::TypeId, |
| 2692 | { "membership"_ls , "join_authorised_via_users_server"_ls } }, |
| 2693 | { RoomPowerLevelsEvent::TypeId, |
| 2694 | { "ban"_ls , "events"_ls , "events_default"_ls , "invite"_ls , |
| 2695 | "kick"_ls , "redact"_ls , "state_default"_ls , "users"_ls , |
| 2696 | "users_default"_ls } }, |
| 2697 | // TODO: Replace with RoomJoinRules::TypeId etc. once available |
| 2698 | { "m.room.join_rules"_ls , { "join_rule"_ls , "allow"_ls } }, |
| 2699 | { "m.room.history_visibility"_ls , { "history_visibility"_ls } } |
| 2700 | }; |
| 2701 | |
| 2702 | if (const auto contentKeysToKeep = |
| 2703 | ContentKeysToKeepPerType.value(key: target.matrixType()); |
| 2704 | !contentKeysToKeep.isEmpty()) // |
| 2705 | { |
| 2706 | auto content = originalJson.take(key: ContentKey).toObject(); |
| 2707 | for (auto it = content.begin(); it != content.end();) { |
| 2708 | if (!contentKeysToKeep.contains(str: it.key())) |
| 2709 | it = content.erase(it); |
| 2710 | else |
| 2711 | ++it; |
| 2712 | } |
| 2713 | originalJson.insert(key: ContentKey, value: content); |
| 2714 | } else { |
| 2715 | originalJson.remove(key: ContentKey); |
| 2716 | originalJson.remove(key: PrevContentKey); |
| 2717 | } |
| 2718 | } |
| 2719 | auto unsignedData = originalJson.take(key: UnsignedKey).toObject(); |
| 2720 | unsignedData[RedactedCauseKey] = redaction.fullJson(); |
| 2721 | originalJson.insert(QStringLiteral("unsigned" ), value: unsignedData); |
| 2722 | |
| 2723 | return loadEvent<RoomEvent>(fullJson: originalJson); |
| 2724 | } |
| 2725 | |
| 2726 | bool Room::Private::processRedaction(const RedactionEvent& redaction) |
| 2727 | { |
| 2728 | // Can't use findInTimeline because it returns a const iterator, and |
| 2729 | // we need to change the underlying TimelineItem. |
| 2730 | const auto pIdx = eventsIndex.constFind(key: redaction.redactedEvent()); |
| 2731 | if (pIdx == eventsIndex.cend()) |
| 2732 | return false; |
| 2733 | |
| 2734 | Q_ASSERT(q->isValidIndex(*pIdx)); |
| 2735 | |
| 2736 | auto& ti = timeline[Timeline::size_type(*pIdx - q->minTimelineIndex())]; |
| 2737 | if (ti->isRedacted() && ti->redactedBecause()->id() == redaction.id()) { |
| 2738 | qCDebug(EVENTS) << "Redaction" << redaction.id() << "of event" |
| 2739 | << ti->id() << "already done, skipping" ; |
| 2740 | return true; |
| 2741 | } |
| 2742 | if (ti->is<RoomMessageEvent>()) |
| 2743 | FileMetadataMap::remove(roomId: id, eventId: ti->id()); |
| 2744 | |
| 2745 | // Make a new event from the redacted JSON and put it in the timeline |
| 2746 | // instead of the redacted one. oldEvent will be deleted on return. |
| 2747 | auto oldEvent = ti.replaceEvent(other: makeRedacted(target: *ti, redaction)); |
| 2748 | qCDebug(EVENTS) << "Redacted" << oldEvent->id() << "with" << redaction.id(); |
| 2749 | if (oldEvent->isStateEvent()) { |
| 2750 | // Check whether the old event was a part of current state; if it was, |
| 2751 | // update the current state to the redacted event object. |
| 2752 | const auto currentStateEvt = |
| 2753 | currentState.get(evtType: oldEvent->matrixType(), stateKey: oldEvent->stateKey()); |
| 2754 | Q_ASSERT(currentStateEvt); |
| 2755 | if (currentStateEvt == oldEvent.get()) { |
| 2756 | // Historical states can't be in currentState |
| 2757 | Q_ASSERT(ti.index() >= 0); |
| 2758 | qCDebug(STATE).nospace() |
| 2759 | << "Redacting state " << oldEvent->matrixType() << "/" |
| 2760 | << oldEvent->stateKey(); |
| 2761 | // Retarget the current state to the newly made event. |
| 2762 | if (q->processStateEvent(e: *ti)) |
| 2763 | emit q->namesChanged(room: q); |
| 2764 | updateDisplayname(); |
| 2765 | } |
| 2766 | } |
| 2767 | if (const auto* reaction = eventCast<ReactionEvent>(eptr: oldEvent)) { |
| 2768 | const auto& content = reaction->content().value; |
| 2769 | const std::pair lookupKey { content.eventId, content.type }; |
| 2770 | if (relations.contains(key: lookupKey)) { |
| 2771 | relations[lookupKey].removeOne(t: reaction); |
| 2772 | emit q->updatedEvent(eventId: content.eventId); |
| 2773 | } |
| 2774 | } |
| 2775 | q->onRedaction(*oldEvent, *ti); |
| 2776 | emit q->replacedEvent(newEvent: ti.event(), oldEvent: std::to_address(ptr: oldEvent)); |
| 2777 | // By now, all references to oldEvent must have been updated to ti.event() |
| 2778 | return true; |
| 2779 | } |
| 2780 | |
| 2781 | /** Make a replaced event |
| 2782 | * |
| 2783 | * Takes \p target and returns a copy of it with content taken from |
| 2784 | * \p replacement. Disposal of the original event after that is on the caller. |
| 2785 | */ |
| 2786 | RoomEventPtr makeReplaced(const RoomEvent& target, |
| 2787 | const RoomMessageEvent& replacement) |
| 2788 | { |
| 2789 | const auto& targetReply = target.contentPart<QJsonObject>(key: "m.relates_to"_ls ); |
| 2790 | auto newContent = replacement.contentPart<QJsonObject>(key: "m.new_content"_ls ); |
| 2791 | if (!targetReply.empty()) { |
| 2792 | newContent["m.relates_to"_ls ] = targetReply; |
| 2793 | } |
| 2794 | auto originalJson = target.fullJson(); |
| 2795 | originalJson[ContentKey] = newContent; |
| 2796 | |
| 2797 | auto unsignedData = originalJson.take(key: UnsignedKey).toObject(); |
| 2798 | auto relations = unsignedData.take(key: "m.relations"_ls ).toObject(); |
| 2799 | relations["m.replace"_ls ] = replacement.id(); |
| 2800 | unsignedData.insert(key: "m.relations"_ls , value: relations); |
| 2801 | originalJson.insert(key: UnsignedKey, value: unsignedData); |
| 2802 | |
| 2803 | return loadEvent<RoomEvent>(fullJson: originalJson); |
| 2804 | } |
| 2805 | |
| 2806 | bool Room::Private::processReplacement(const RoomMessageEvent& newEvent) |
| 2807 | { |
| 2808 | // Can't use findInTimeline because it returns a const iterator, and |
| 2809 | // we need to change the underlying TimelineItem. |
| 2810 | const auto pIdx = eventsIndex.constFind(key: newEvent.replacedEvent()); |
| 2811 | if (pIdx == eventsIndex.cend()) |
| 2812 | return false; |
| 2813 | |
| 2814 | Q_ASSERT(q->isValidIndex(*pIdx)); |
| 2815 | |
| 2816 | auto& ti = timeline[Timeline::size_type(*pIdx - q->minTimelineIndex())]; |
| 2817 | const auto* const rme = ti.viewAs<RoomMessageEvent>(); |
| 2818 | if (!rme) { |
| 2819 | qCWarning(STATE) << "Ignoring attempt to replace a non-message event" |
| 2820 | << ti->id(); |
| 2821 | return false; |
| 2822 | } |
| 2823 | if (rme->replacedBy() == newEvent.id()) { |
| 2824 | qCDebug(STATE) << "Event" << ti->id() << "is already replaced with" |
| 2825 | << newEvent.id(); |
| 2826 | return true; |
| 2827 | } |
| 2828 | |
| 2829 | // Make a new event from the redacted JSON and put it in the timeline |
| 2830 | // instead of the redacted one. oldEvent will be deleted on return. |
| 2831 | auto oldEvent = ti.replaceEvent(other: makeReplaced(target: *ti, replacement: newEvent)); |
| 2832 | qCDebug(STATE) << "Replaced" << oldEvent->id() << "with" << newEvent.id(); |
| 2833 | emit q->replacedEvent(newEvent: ti.event(), oldEvent: std::to_address(ptr: oldEvent)); |
| 2834 | return true; |
| 2835 | } |
| 2836 | |
| 2837 | Connection* Room::connection() const |
| 2838 | { |
| 2839 | Q_ASSERT(d->connection); |
| 2840 | return d->connection; |
| 2841 | } |
| 2842 | |
| 2843 | User* Room::localUser() const { return connection()->user(); } |
| 2844 | |
| 2845 | void Room::Private::addRelation(const ReactionEvent& reactionEvt) |
| 2846 | { |
| 2847 | const auto& content = reactionEvt.content().value; |
| 2848 | // See ReactionEvent::isValid() |
| 2849 | Q_ASSERT(content.type == EventRelation::AnnotationType); |
| 2850 | const auto isSameReaction = [&reactionEvt](const RoomEvent* existingEvent) { |
| 2851 | const auto* reactionEvt2 = eventCast<const ReactionEvent>(eptr: existingEvent); |
| 2852 | const auto& r1 = reactionEvt.content().value; |
| 2853 | const auto& r2 = reactionEvt2->content().value; |
| 2854 | return reactionEvt2 != nullptr |
| 2855 | && reactionEvt.senderId() == reactionEvt2->senderId() |
| 2856 | && r1.eventId == r2.eventId && r1.key == r2.key; |
| 2857 | }; |
| 2858 | |
| 2859 | auto& thisEventReactions = relations[{ content.eventId, content.type }]; |
| 2860 | if (std::any_of(first: thisEventReactions.cbegin(), last: thisEventReactions.cend(), |
| 2861 | pred: isSameReaction)) { |
| 2862 | qDebug(catFunc: MESSAGES) << "Skipping a duplicate reaction from" |
| 2863 | << reactionEvt.senderId(); |
| 2864 | return; |
| 2865 | } |
| 2866 | thisEventReactions << &reactionEvt; |
| 2867 | emit q->updatedEvent(eventId: content.eventId); |
| 2868 | } |
| 2869 | |
| 2870 | /// Whether the event is a redaction or a replacement |
| 2871 | inline bool isEditing(const RoomEventPtr& ep) |
| 2872 | { |
| 2873 | Q_ASSERT(ep); |
| 2874 | return ep->switchOnType(visitors: [](const RedactionEvent&) { return true; }, |
| 2875 | visitors: [](const RoomMessageEvent& rme) { |
| 2876 | return !rme.replacedEvent().isEmpty(); |
| 2877 | }, |
| 2878 | visitors: false); |
| 2879 | } |
| 2880 | |
| 2881 | Room::Changes Room::Private::addNewMessageEvents(RoomEvents&& events) |
| 2882 | { |
| 2883 | dropExtraneousEvents(events); |
| 2884 | if (events.empty()) |
| 2885 | return Change::None; |
| 2886 | |
| 2887 | decryptIncomingEvents(events); |
| 2888 | |
| 2889 | QElapsedTimer et; |
| 2890 | et.start(); |
| 2891 | |
| 2892 | { |
| 2893 | // Pre-process redactions and edits so that events that get |
| 2894 | // redacted/replaced in the same batch landed in the timeline already |
| 2895 | // treated. |
| 2896 | // NB: We have to store redacting/replacing events to the timeline too - |
| 2897 | // see #220. |
| 2898 | auto it = std::find_if(first: events.begin(), last: events.end(), pred: isEditing); |
| 2899 | for (const auto& eptr : RoomEventsRange(it, events.end())) { |
| 2900 | if (auto* r = eventCast<RedactionEvent>(eptr)) { |
| 2901 | // Try to find the target in the timeline, then in the batch. |
| 2902 | if (processRedaction(redaction: *r)) |
| 2903 | continue; |
| 2904 | if (auto targetIt = std::find_if(first: events.begin(), last: events.end(), |
| 2905 | pred: [id = r->redactedEvent()](const RoomEventPtr& ep) { |
| 2906 | return ep->id() == id; |
| 2907 | }); targetIt != events.end()) |
| 2908 | *targetIt = makeRedacted(target: **targetIt, redaction: *r); |
| 2909 | else |
| 2910 | qCDebug(STATE) |
| 2911 | << "Redaction" << r->id() << "ignored: target event" |
| 2912 | << r->redactedEvent() << "is not found" ; |
| 2913 | // If the target event comes later, it comes already redacted. |
| 2914 | } |
| 2915 | if (auto* msg = eventCast<RoomMessageEvent>(eptr); |
| 2916 | msg && !msg->replacedEvent().isEmpty()) { |
| 2917 | if (processReplacement(newEvent: *msg)) |
| 2918 | continue; |
| 2919 | auto targetIt = std::find_if(first: events.begin(), last: it, |
| 2920 | pred: [id = msg->replacedEvent()](const RoomEventPtr& ep) { |
| 2921 | return ep->id() == id; |
| 2922 | }); |
| 2923 | if (targetIt != it) |
| 2924 | *targetIt = makeReplaced(target: **targetIt, replacement: *msg); |
| 2925 | else // FIXME: hide the replacing event when target arrives later |
| 2926 | qCDebug(EVENTS) |
| 2927 | << "Replacing event" << msg->id() |
| 2928 | << "ignored: target event" << msg->replacedEvent() |
| 2929 | << "is not found" ; |
| 2930 | // Same as with redactions above, the replaced event coming |
| 2931 | // later will come already with the new content. |
| 2932 | } |
| 2933 | } |
| 2934 | } |
| 2935 | |
| 2936 | // State changes arrive as a part of timeline; the current room state gets |
| 2937 | // updated before merging events to the timeline because that's what |
| 2938 | // clients historically expect. This may eventually change though if we |
| 2939 | // postulate that the current state is only current between syncs but not |
| 2940 | // within a sync. |
| 2941 | Changes roomChanges {}; |
| 2942 | for (const auto& eptr : events) |
| 2943 | roomChanges |= q->processStateEvent(e: *eptr); |
| 2944 | |
| 2945 | auto timelineSize = timeline.size(); |
| 2946 | size_t totalInserted = 0; |
| 2947 | for (auto it = events.begin(); it != events.end();) { |
| 2948 | auto nextPendingPair = |
| 2949 | findFirstOf(first: it, last: events.end(), sFirst: unsyncedEvents.begin(), |
| 2950 | sLast: unsyncedEvents.end(), pred: isEchoEvent); |
| 2951 | const auto& remoteEcho = nextPendingPair.first; |
| 2952 | const auto& localEcho = nextPendingPair.second; |
| 2953 | |
| 2954 | if (it != remoteEcho) { |
| 2955 | RoomEventsRange eventsSpan { it, remoteEcho }; |
| 2956 | emit q->aboutToAddNewMessages(events: eventsSpan); |
| 2957 | auto insertedSize = moveEventsToTimeline(events: eventsSpan, placement: Newer); |
| 2958 | totalInserted += insertedSize; |
| 2959 | auto firstInserted = syncEdge() - insertedSize; |
| 2960 | q->onAddNewTimelineEvents(firstInserted); |
| 2961 | emit q->addedMessages(fromIndex: firstInserted->index(), |
| 2962 | toIndex: timeline.back().index()); |
| 2963 | } |
| 2964 | if (remoteEcho == events.end()) |
| 2965 | break; |
| 2966 | |
| 2967 | it = remoteEcho + 1; |
| 2968 | auto* nextPendingEvt = remoteEcho->get(); |
| 2969 | const auto pendingEvtIdx = int(localEcho - unsyncedEvents.begin()); |
| 2970 | if (localEcho->deliveryStatus() != EventStatus::ReachedServer) { |
| 2971 | localEcho->setReachedServer(nextPendingEvt->id()); |
| 2972 | emit q->pendingEventChanged(pendingEventIndex: pendingEvtIdx); |
| 2973 | } |
| 2974 | emit q->pendingEventAboutToMerge(serverEvent: nextPendingEvt, pendingEventIndex: pendingEvtIdx); |
| 2975 | qCDebug(MESSAGES) << "Merging pending event from transaction" |
| 2976 | << nextPendingEvt->transactionId() << "into" |
| 2977 | << nextPendingEvt->id(); |
| 2978 | auto transfer = fileTransfers.take(key: nextPendingEvt->transactionId()); |
| 2979 | if (transfer.status != FileTransferInfo::None) |
| 2980 | fileTransfers.insert(key: nextPendingEvt->id(), value: transfer); |
| 2981 | // After emitting pendingEventAboutToMerge() above we cannot rely |
| 2982 | // on the previously obtained localEcho staying valid |
| 2983 | // because a signal handler may send another message, thereby altering |
| 2984 | // unsyncedEvents (see #286). Fortunately, unsyncedEvents only grows at |
| 2985 | // its back so we can rely on the index staying valid at least. |
| 2986 | unsyncedEvents.erase(position: unsyncedEvents.begin() + pendingEvtIdx); |
| 2987 | if (auto insertedSize = moveEventsToTimeline(events: { remoteEcho, it }, placement: Newer)) { |
| 2988 | totalInserted += insertedSize; |
| 2989 | q->onAddNewTimelineEvents(syncEdge() - insertedSize); |
| 2990 | } |
| 2991 | emit q->pendingEventMerged(); |
| 2992 | } |
| 2993 | // Events merged and transferred from `events` to `timeline` now. |
| 2994 | const auto from = syncEdge() - totalInserted; |
| 2995 | |
| 2996 | if (q->supportsCalls()) |
| 2997 | for (auto it = from; it != syncEdge(); ++it) |
| 2998 | if (const auto* evt = it->viewAs<CallEvent>()) |
| 2999 | emit q->callEvent(room: q, event: evt); |
| 3000 | |
| 3001 | if (totalInserted > 0) { |
| 3002 | addRelations(from, to: syncEdge()); |
| 3003 | |
| 3004 | qCDebug(MESSAGES) << "Room" << q->objectName() << "received" |
| 3005 | << totalInserted << "new events; the last event is now" |
| 3006 | << timeline.back(); |
| 3007 | |
| 3008 | roomChanges |= updateStats(from: timeline.crbegin(), to: rev_iter_t(from)); |
| 3009 | |
| 3010 | // If the local user's message(s) is/are first in the batch |
| 3011 | // and the fully read marker was right before it, promote |
| 3012 | // the fully read marker to the same event as the read receipt. |
| 3013 | const auto& firstWriterId = (*from)->senderId(); |
| 3014 | if (firstWriterId == connection->userId() |
| 3015 | && q->fullyReadMarker().base() == from) |
| 3016 | roomChanges |= |
| 3017 | setFullyReadMarker(q->lastReadReceipt(userId: firstWriterId).eventId); |
| 3018 | } |
| 3019 | |
| 3020 | Q_ASSERT(timeline.size() == timelineSize + totalInserted); |
| 3021 | if (totalInserted > 9 || et.nsecsElapsed() >= ProfilerMinNsecs) |
| 3022 | qCDebug(PROFILER) << "Added" << totalInserted << "new event(s) to" |
| 3023 | << q->objectName() << "in" << et; |
| 3024 | return roomChanges; |
| 3025 | } |
| 3026 | |
| 3027 | void Room::Private::addHistoricalMessageEvents(RoomEvents&& events) |
| 3028 | { |
| 3029 | const auto timelineSize = timeline.size(); |
| 3030 | |
| 3031 | dropExtraneousEvents(events); |
| 3032 | if (events.empty()) |
| 3033 | return; |
| 3034 | |
| 3035 | decryptIncomingEvents(events); |
| 3036 | |
| 3037 | QElapsedTimer et; |
| 3038 | et.start(); |
| 3039 | Changes changes {}; |
| 3040 | // In case of lazy-loading new members may be loaded with historical |
| 3041 | // messages. Also, the cache doesn't store events with empty content; |
| 3042 | // so when such events show up in the timeline they should be properly |
| 3043 | // incorporated. |
| 3044 | for (const auto& eptr : events) { |
| 3045 | const auto& e = *eptr; |
| 3046 | if (e.isStateEvent() |
| 3047 | && !currentState.contains(evtType: e.matrixType(), stateKey: e.stateKey())) { |
| 3048 | changes |= q->processStateEvent(e); |
| 3049 | } |
| 3050 | } |
| 3051 | |
| 3052 | emit q->aboutToAddHistoricalMessages(events); |
| 3053 | const auto insertedSize = moveEventsToTimeline(events, placement: Older); |
| 3054 | const auto from = historyEdge() - insertedSize; |
| 3055 | |
| 3056 | qCDebug(STATE) << "Room" << displayname << "received" << insertedSize |
| 3057 | << "past events; the oldest event is now" << timeline.front(); |
| 3058 | q->onAddHistoricalTimelineEvents(from); |
| 3059 | emit q->addedMessages(fromIndex: timeline.front().index(), toIndex: from->index()); |
| 3060 | |
| 3061 | addRelations(from, to: historyEdge()); |
| 3062 | Q_ASSERT(timeline.size() == timelineSize + insertedSize); |
| 3063 | if (insertedSize > 9 || et.nsecsElapsed() >= ProfilerMinNsecs) |
| 3064 | qCDebug(PROFILER) << "Added" << insertedSize << "historical event(s) to" |
| 3065 | << q->objectName() << "in" << et; |
| 3066 | |
| 3067 | changes |= updateStats(from, to: historyEdge()); |
| 3068 | if (changes) |
| 3069 | postprocessChanges(changes); |
| 3070 | } |
| 3071 | |
| 3072 | void Room::Private::preprocessStateEvent(const RoomEvent& newEvent, |
| 3073 | const RoomEvent* curEvent) |
| 3074 | { |
| 3075 | newEvent.switchOnType( |
| 3076 | visitors: [this, curEvent](const RoomMemberEvent& rme) { |
| 3077 | auto* u = q->user(userId: rme.userId()); |
| 3078 | if (!u) { // Some terribly malformed user id? |
| 3079 | qCCritical(MAIN) << "Could not get a user object for" |
| 3080 | << rme.userId(); |
| 3081 | return; // See also Room::Private::processStateEvent() |
| 3082 | } |
| 3083 | switch (const auto prevMembership = |
| 3084 | lift(fn: &RoomMemberEvent::membership, |
| 3085 | args: eventCast<const RoomMemberEvent>(eptr: curEvent)) |
| 3086 | .value_or(u: Membership::Leave)) { |
| 3087 | case Membership::Invite: |
| 3088 | if (rme.membership() != prevMembership) { |
| 3089 | usersInvited.removeOne(t: u); |
| 3090 | Q_ASSERT(!usersInvited.contains(u)); |
| 3091 | } |
| 3092 | break; |
| 3093 | case Membership::Join: |
| 3094 | if (rme.membership() == Membership::Join) { |
| 3095 | // rename/avatar change or no-op |
| 3096 | if (rme.newDisplayName()) { |
| 3097 | emit q->memberAboutToRename(user: u, newName: *rme.newDisplayName()); |
| 3098 | removeMemberFromMap(u); |
| 3099 | } |
| 3100 | if (!rme.newDisplayName() && !rme.newAvatarUrl()) |
| 3101 | qCDebug(MEMBERS).nospace().noquote() |
| 3102 | << "No-op membership event for " << rme.userId() |
| 3103 | << ": " << rme; |
| 3104 | } else { |
| 3105 | if (rme.membership() == Membership::Invite) |
| 3106 | qCWarning(MAIN) |
| 3107 | << "Membership change from Join to Invite:" << rme; |
| 3108 | // whatever the new membership, it's no more Join |
| 3109 | removeMemberFromMap(u); |
| 3110 | emit q->userRemoved(user: u); |
| 3111 | } |
| 3112 | break; |
| 3113 | case Membership::Ban: |
| 3114 | case Membership::Knock: |
| 3115 | case Membership::Leave: |
| 3116 | if (rme.membership() == Membership::Invite |
| 3117 | || rme.membership() == Membership::Join) { |
| 3118 | membersLeft.removeOne(t: u); |
| 3119 | Q_ASSERT(!membersLeft.contains(u)); |
| 3120 | } |
| 3121 | break; |
| 3122 | case Membership::Undefined: |
| 3123 | ; // A warning will be dropped in Room::P::processStateEvent() |
| 3124 | } |
| 3125 | }, |
| 3126 | visitors: [this, curEvent](const EncryptionEvent& ee) { |
| 3127 | if (curEvent) |
| 3128 | qCWarning(STATE) << "Room" << q->objectName() |
| 3129 | << "is already encrypted but a new room " |
| 3130 | "encryption event arrived" ; |
| 3131 | if (ee.algorithm().isEmpty()) |
| 3132 | qWarning(catFunc: STATE) |
| 3133 | << "The encryption event for room" << q->objectName() |
| 3134 | << "doesn't have 'algorithm' specified" ; |
| 3135 | |
| 3136 | }); |
| 3137 | } |
| 3138 | |
| 3139 | Room::Changes Room::processStateEvent(const RoomEvent& e) |
| 3140 | { |
| 3141 | if (!e.isStateEvent()) |
| 3142 | return Change::None; |
| 3143 | |
| 3144 | // Find a value (create an empty one if necessary) and get a reference |
| 3145 | // to it, anticipating a change further in the function. |
| 3146 | auto& curStateEvent = d->currentState[{ e.matrixType(), e.stateKey() }]; |
| 3147 | |
| 3148 | d->preprocessStateEvent(newEvent: e, curEvent: curStateEvent); |
| 3149 | |
| 3150 | // Change the state |
| 3151 | const auto* const oldStateEvent = |
| 3152 | std::exchange(obj&: curStateEvent, new_val: static_cast<const StateEvent*>(&e)); |
| 3153 | Q_ASSERT(!oldStateEvent |
| 3154 | || (oldStateEvent->matrixType() == e.matrixType() |
| 3155 | && oldStateEvent->stateKey() == e.stateKey())); |
| 3156 | if (is<RoomMemberEvent>(e)) |
| 3157 | qCDebug(MEMBERS) << "Updated room member state:" << e; |
| 3158 | else |
| 3159 | qCDebug(STATE) << "Updated room state:" << e; |
| 3160 | |
| 3161 | const auto result = d->processStateEvent(curEvent: *curStateEvent, oldEvent: oldStateEvent); |
| 3162 | |
| 3163 | Q_ASSERT(result != Change::None); |
| 3164 | // Whatever the outcome, the relevant piece of state should stay valid |
| 3165 | // (the absense of event is a valid state, too) |
| 3166 | Q_ASSERT(currentState().queryOr(e.matrixType(), e.stateKey(), |
| 3167 | &Event::isStateEvent, true)); |
| 3168 | return result; |
| 3169 | } |
| 3170 | |
| 3171 | //! Update internal structures as per the change and work out the return value |
| 3172 | Room::Change Room::Private::processStateEvent(const RoomEvent& curEvent, |
| 3173 | const RoomEvent* oldEvent) |
| 3174 | { |
| 3175 | return curEvent.switchOnType( |
| 3176 | visitors: [](const RoomNameEvent&) { return Change::RoomNames; }, |
| 3177 | visitors: [this, oldEvent](const RoomCanonicalAliasEvent& cae) { |
| 3178 | q->setObjectName(cae.alias().isEmpty() ? id : cae.alias()); |
| 3179 | QStringList previousAltAliases{}; |
| 3180 | if (const auto* oldCae = |
| 3181 | static_cast<const RoomCanonicalAliasEvent*>(oldEvent)) { |
| 3182 | previousAltAliases = oldCae->altAliases(); |
| 3183 | if (!oldCae->alias().isEmpty()) |
| 3184 | previousAltAliases.push_back(t: oldCae->alias()); |
| 3185 | } |
| 3186 | |
| 3187 | auto newAliases = cae.altAliases(); |
| 3188 | if (!cae.alias().isEmpty()) |
| 3189 | newAliases.push_front(t: cae.alias()); |
| 3190 | |
| 3191 | connection->updateRoomAliases(roomId: id, previousRoomAliases: previousAltAliases, roomAliases: newAliases); |
| 3192 | return Change::RoomNames; |
| 3193 | }, |
| 3194 | visitors: [this](const RoomPinnedEventsEvent&) { |
| 3195 | emit q->pinnedEventsChanged(); |
| 3196 | return Change::Other; |
| 3197 | }, |
| 3198 | visitors: [](const RoomTopicEvent&) { return Change::Topic; }, |
| 3199 | visitors: [this](const RoomAvatarEvent& evt) { |
| 3200 | if (avatar.updateUrl(newUrl: evt.url())) |
| 3201 | emit q->avatarChanged(); |
| 3202 | return Change::Avatar; |
| 3203 | }, |
| 3204 | visitors: [this, oldEvent](const RoomMemberEvent& evt) { |
| 3205 | // See also Room::P::preprocessStateEvent() |
| 3206 | if (auto* u = q->user(userId: evt.userId())) { |
| 3207 | const auto prevMembership = |
| 3208 | lift(fn: &RoomMemberEvent::membership, |
| 3209 | args: static_cast<const RoomMemberEvent*>(oldEvent)) |
| 3210 | .value_or(u: Membership::Leave); |
| 3211 | switch (evt.membership()) { |
| 3212 | case Membership::Join: |
| 3213 | if (prevMembership != Membership::Join) { |
| 3214 | insertMemberIntoMap(u); |
| 3215 | emit q->userAdded(user: u); |
| 3216 | } else { |
| 3217 | if (evt.newDisplayName()) { |
| 3218 | insertMemberIntoMap(u); |
| 3219 | emit q->memberRenamed(user: u); |
| 3220 | } |
| 3221 | if (evt.newAvatarUrl()) |
| 3222 | emit q->memberAvatarChanged(user: u); |
| 3223 | } |
| 3224 | break; |
| 3225 | case Membership::Invite: |
| 3226 | if (!usersInvited.contains(t: u)) |
| 3227 | usersInvited.push_back(t: u); |
| 3228 | if (u == q->localUser() && evt.isDirect()) |
| 3229 | connection->addToDirectChats(room: q, user: q->user(userId: evt.senderId())); |
| 3230 | break; |
| 3231 | case Membership::Knock: |
| 3232 | case Membership::Ban: |
| 3233 | case Membership::Leave: |
| 3234 | if (!membersLeft.contains(t: u)) |
| 3235 | membersLeft.append(t: u); |
| 3236 | break; |
| 3237 | case Membership::Undefined: |
| 3238 | qCWarning(MEMBERS) << "Ignored undefined membership type" ; |
| 3239 | } |
| 3240 | } |
| 3241 | return Change::Members; |
| 3242 | }, |
| 3243 | visitors: [this](const EncryptionEvent&) { |
| 3244 | // As encryption can only be switched on once, emit the signal here |
| 3245 | // instead of aggregating and emitting in updateData() |
| 3246 | emit q->encryption(); |
| 3247 | return Change::Other; |
| 3248 | }, |
| 3249 | visitors: [this](const RoomTombstoneEvent& evt) { |
| 3250 | const auto successorId = evt.successorRoomId(); |
| 3251 | if (auto* successor = connection->room(roomId: successorId)) |
| 3252 | emit q->upgraded(serverMessage: evt.serverMessage(), successor); |
| 3253 | else |
| 3254 | connectUntil(sender: connection, signal: &Connection::loadedRoomState, context: q, |
| 3255 | smartSlot: [this,successorId,serverMsg=evt.serverMessage()] |
| 3256 | (Room* newRoom) { |
| 3257 | if (newRoom->id() != successorId) |
| 3258 | return false; |
| 3259 | emit q->upgraded(serverMessage: serverMsg, successor: newRoom); |
| 3260 | return true; |
| 3261 | }); |
| 3262 | |
| 3263 | return Change::Other; |
| 3264 | }, |
| 3265 | visitors: Change::Other); |
| 3266 | } |
| 3267 | |
| 3268 | Room::Changes Room::processEphemeralEvent(EventPtr&& event) |
| 3269 | { |
| 3270 | Changes changes {}; |
| 3271 | QElapsedTimer et; |
| 3272 | et.start(); |
| 3273 | switchOnType(event: *event, |
| 3274 | fn1: [this, &et](const TypingEvent& evt) { |
| 3275 | const auto& users = evt.users(); |
| 3276 | d->usersTyping.clear(); |
| 3277 | d->usersTyping.reserve(asize: users.size()); // Assume all are members |
| 3278 | for (const auto& userId : users) |
| 3279 | if (isMember(userId)) |
| 3280 | d->usersTyping.append(t: user(userId)); |
| 3281 | |
| 3282 | if (d->usersTyping.size() > 3 |
| 3283 | || et.nsecsElapsed() >= ProfilerMinNsecs) |
| 3284 | qDebug(catFunc: PROFILER) |
| 3285 | << "Processing typing events from" << users.size() |
| 3286 | << "user(s) in" << objectName() << "took" << et; |
| 3287 | emit typingChanged(); |
| 3288 | }, |
| 3289 | fns: [this, &changes, &et](const ReceiptEvent& evt) { |
| 3290 | const auto& receiptsJson = evt.contentJson(); |
| 3291 | QVector<QString> updatedUserIds; |
| 3292 | // Most often (especially for bigger batches), receipts are |
| 3293 | // scattered across events (an anecdotal evidence showed 1.2-1.3 |
| 3294 | // receipts per event on average). |
| 3295 | updatedUserIds.reserve(asize: receiptsJson.size() * 2); |
| 3296 | for (auto eventIt = receiptsJson.begin(); |
| 3297 | eventIt != receiptsJson.end(); ++eventIt) { |
| 3298 | const auto evtId = eventIt.key(); |
| 3299 | const auto newMarker = findInTimeline(evtId); |
| 3300 | if (newMarker == historyEdge()) |
| 3301 | qDebug(catFunc: EPHEMERAL) |
| 3302 | << "Event" << evtId |
| 3303 | << "is not found; saving read receipt(s) anyway" ; |
| 3304 | const auto reads = |
| 3305 | eventIt.value().toObject().value(key: "m.read"_ls ).toObject(); |
| 3306 | for (auto userIt = reads.begin(); userIt != reads.end(); |
| 3307 | ++userIt) { |
| 3308 | ReadReceipt rr{ .eventId: evtId, |
| 3309 | .timestamp: fromJson<QDateTime>( |
| 3310 | jv: userIt->toObject().value(key: "ts"_ls )) }; |
| 3311 | const auto userId = userIt.key(); |
| 3312 | if (userId == connection()->userId()) { |
| 3313 | // Local user is special, and will get a signal about |
| 3314 | // its read receipt separately from (and before) a |
| 3315 | // signal on everybody else. No particular reason, just |
| 3316 | // less cumbersome code. |
| 3317 | changes |= d->setLocalLastReadReceipt(newMarker, newReceipt: rr); |
| 3318 | } else if (d->setLastReadReceipt(userId, newMarker, newReceipt: rr)) { |
| 3319 | changes |= Change::Other; |
| 3320 | updatedUserIds.push_back(t: userId); |
| 3321 | } |
| 3322 | } |
| 3323 | } |
| 3324 | if (updatedUserIds.size() > 10 |
| 3325 | || et.nsecsElapsed() >= ProfilerMinNsecs) |
| 3326 | qDebug(catFunc: PROFILER) |
| 3327 | << "Processing" << updatedUserIds.size() |
| 3328 | << "non-local receipt(s) on" << receiptsJson.size() |
| 3329 | << "event(s) in" << objectName() << "took" << et; |
| 3330 | if (!updatedUserIds.empty()) |
| 3331 | emit lastReadEventChanged(userIds: updatedUserIds); |
| 3332 | }); |
| 3333 | return changes; |
| 3334 | } |
| 3335 | |
| 3336 | Room::Changes Room::processAccountDataEvent(EventPtr&& event) |
| 3337 | { |
| 3338 | Changes changes {}; |
| 3339 | if (auto* evt = eventCast<TagEvent>(eptr: event)) { |
| 3340 | d->setTags(evt->tags()); |
| 3341 | changes |= Change::Tags; |
| 3342 | } |
| 3343 | |
| 3344 | if (auto* evt = eventCast<const ReadMarkerEvent>(eptr: event)) |
| 3345 | changes |= d->setFullyReadMarker(evt->eventId()); |
| 3346 | |
| 3347 | // For all account data events |
| 3348 | auto& currentData = d->accountData[event->matrixType()]; |
| 3349 | // A polymorphic event-specific comparison might be a bit more |
| 3350 | // efficient; maaybe do it another day |
| 3351 | if (!currentData || currentData->contentJson() != event->contentJson()) { |
| 3352 | emit accountDataAboutToChange(type: event->matrixType()); |
| 3353 | currentData = std::move(event); |
| 3354 | qCDebug(STATE) << "Updated account data of type" |
| 3355 | << currentData->matrixType(); |
| 3356 | emit accountDataChanged(type: currentData->matrixType()); |
| 3357 | // TODO: Drop AccountDataChange in 0.8 |
| 3358 | // NB: GCC (at least 10) only accepts QT_IGNORE_DEPRECATIONS around |
| 3359 | // a statement, not within a statement |
| 3360 | QT_IGNORE_DEPRECATIONS(changes |= Change::AccountData | Change::Other;) |
| 3361 | } |
| 3362 | return changes; |
| 3363 | } |
| 3364 | |
| 3365 | template <typename ContT> |
| 3366 | Room::Private::users_shortlist_t |
| 3367 | Room::Private::buildShortlist(const ContT& users) const |
| 3368 | { |
| 3369 | // To calculate room display name the spec requires to sort users |
| 3370 | // lexicographically by state_key (user id) and use disambiguated |
| 3371 | // display names of two topmost users excluding the current one to render |
| 3372 | // the name of the room. The below code selects 3 topmost users, |
| 3373 | // slightly extending the spec. |
| 3374 | users_shortlist_t shortlist {}; // Prefill with nullptrs |
| 3375 | std::partial_sort_copy( |
| 3376 | users.begin(), users.end(), shortlist.begin(), shortlist.end(), |
| 3377 | [this](const User* u1, const User* u2) { |
| 3378 | // localUser(), if it's in the list, is sorted |
| 3379 | // below all others |
| 3380 | return isLocalUser(u: u2) || (!isLocalUser(u: u1) && u1->id() < u2->id()); |
| 3381 | }); |
| 3382 | return shortlist; |
| 3383 | } |
| 3384 | |
| 3385 | Room::Private::users_shortlist_t |
| 3386 | Room::Private::buildShortlist(const QStringList& userIds) const |
| 3387 | { |
| 3388 | QList<User*> users; |
| 3389 | users.reserve(asize: userIds.size()); |
| 3390 | for (const auto& h : userIds) |
| 3391 | users.push_back(t: q->user(userId: h)); |
| 3392 | return buildShortlist(users); |
| 3393 | } |
| 3394 | |
| 3395 | QString Room::Private::calculateDisplayname() const |
| 3396 | { |
| 3397 | // CS spec, section 13.2.2.5 Calculating the display name for a room |
| 3398 | // Numbers below refer to respective parts in the spec. |
| 3399 | |
| 3400 | // 1. Name (from m.room.name) |
| 3401 | auto dispName = q->name(); |
| 3402 | if (!dispName.isEmpty()) { |
| 3403 | return dispName; |
| 3404 | } |
| 3405 | |
| 3406 | // 2. Canonical alias |
| 3407 | dispName = q->canonicalAlias(); |
| 3408 | if (!dispName.isEmpty()) |
| 3409 | return dispName; |
| 3410 | |
| 3411 | // 3. m.room.aliases - only local aliases, subject for further removal |
| 3412 | const auto aliases = q->aliases(); |
| 3413 | if (!aliases.isEmpty()) |
| 3414 | return aliases.front(); |
| 3415 | |
| 3416 | // 4. m.heroes and m.room.member |
| 3417 | // From here on, we use a more general algorithm than the spec describes |
| 3418 | // in order to provide back-compatibility with pre-MSC688 servers. |
| 3419 | |
| 3420 | // Supplementary code: build the shortlist of users whose names |
| 3421 | // will be used to construct the room name. Takes into account MSC688's |
| 3422 | // "heroes" if available. |
| 3423 | const bool localUserIsIn = joinState == JoinState::Join; |
| 3424 | const bool emptyRoom = |
| 3425 | membersMap.isEmpty() |
| 3426 | || (membersMap.size() == 1 && isLocalUser(u: *membersMap.cbegin())); |
| 3427 | const bool nonEmptySummary = summary.heroes && !summary.heroes->empty(); |
| 3428 | auto shortlist = nonEmptySummary ? buildShortlist(userIds: *summary.heroes) |
| 3429 | : !emptyRoom ? buildShortlist(users: membersMap) |
| 3430 | : users_shortlist_t {}; |
| 3431 | |
| 3432 | // When the heroes list is there, we can rely on it. If the heroes list is |
| 3433 | // missing, the below code gathers invited, or, if there are no invitees, |
| 3434 | // left members. |
| 3435 | if (!shortlist.front() && localUserIsIn) |
| 3436 | shortlist = buildShortlist(users: usersInvited); |
| 3437 | |
| 3438 | if (!shortlist.front()) |
| 3439 | shortlist = buildShortlist(users: membersLeft); |
| 3440 | |
| 3441 | QStringList names; |
| 3442 | for (const auto* u : shortlist) { |
| 3443 | if (u == nullptr || isLocalUser(u)) |
| 3444 | break; |
| 3445 | // Only disambiguate if the room is not empty |
| 3446 | names.push_back(t: u->displayname(room: emptyRoom ? nullptr : q)); |
| 3447 | } |
| 3448 | |
| 3449 | const auto usersCountExceptLocal = |
| 3450 | !emptyRoom |
| 3451 | ? q->joinedCount() - int(joinState == JoinState::Join) |
| 3452 | : !usersInvited.empty() |
| 3453 | ? usersInvited.count() |
| 3454 | : membersLeft.size() - int(joinState == JoinState::Leave); |
| 3455 | if (usersCountExceptLocal > int(shortlist.size())) |
| 3456 | names << tr( |
| 3457 | s: "%Ln other(s)" , |
| 3458 | c: "Used to make a room name from user names: A, B and _N others_" , |
| 3459 | n: static_cast<int>(usersCountExceptLocal - std::ssize(cont: shortlist))); |
| 3460 | const auto namesList = QLocale().createSeparatedList(strl: names); |
| 3461 | |
| 3462 | // Room members |
| 3463 | if (!emptyRoom) |
| 3464 | return namesList; |
| 3465 | |
| 3466 | // (Spec extension) Invited users |
| 3467 | if (!usersInvited.empty()) |
| 3468 | return tr(s: "Empty room (invited: %1)" ).arg(a: namesList); |
| 3469 | |
| 3470 | // Users that previously left the room |
| 3471 | if (!membersLeft.isEmpty()) |
| 3472 | return tr(s: "Empty room (was: %1)" ).arg(a: namesList); |
| 3473 | |
| 3474 | // Fail miserably |
| 3475 | return tr(s: "Empty room (%1)" ).arg(a: id); |
| 3476 | } |
| 3477 | |
| 3478 | void Room::Private::updateDisplayname() |
| 3479 | { |
| 3480 | auto swappedName = calculateDisplayname(); |
| 3481 | if (swappedName != displayname) { |
| 3482 | emit q->displaynameAboutToChange(room: q); |
| 3483 | swap(value1&: displayname, value2&: swappedName); |
| 3484 | qCDebug(MAIN) << q->objectName() << "has changed display name from" |
| 3485 | << swappedName << "to" << displayname; |
| 3486 | emit q->displaynameChanged(room: q, oldName: swappedName); |
| 3487 | } |
| 3488 | } |
| 3489 | |
| 3490 | QJsonObject Room::Private::toJson() const |
| 3491 | { |
| 3492 | QElapsedTimer et; |
| 3493 | et.start(); |
| 3494 | QJsonObject result; |
| 3495 | addParam<IfNotEmpty>(container&: result, QStringLiteral("summary" ), value: summary); |
| 3496 | { |
| 3497 | QJsonArray stateEvents; |
| 3498 | |
| 3499 | for (const auto* evt : currentState) { |
| 3500 | Q_ASSERT(evt->isStateEvent()); |
| 3501 | if ((evt->isRedacted() && !is<RoomMemberEvent>(e: *evt)) |
| 3502 | || evt->contentJson().isEmpty()) |
| 3503 | continue; |
| 3504 | |
| 3505 | auto json = evt->fullJson(); |
| 3506 | auto unsignedJson = evt->unsignedJson(); |
| 3507 | unsignedJson.remove(QStringLiteral("prev_content" )); |
| 3508 | json[UnsignedKey] = unsignedJson; |
| 3509 | stateEvents.append(value: json); |
| 3510 | } |
| 3511 | |
| 3512 | const auto stateObjName = joinState == JoinState::Invite |
| 3513 | ? QStringLiteral("invite_state" ) |
| 3514 | : QStringLiteral("state" ); |
| 3515 | result.insert(key: stateObjName, |
| 3516 | value: QJsonObject { { QStringLiteral("events" ), stateEvents } }); |
| 3517 | } |
| 3518 | |
| 3519 | if (!accountData.empty()) { |
| 3520 | QJsonArray accountDataEvents; |
| 3521 | for (const auto& e : accountData) { |
| 3522 | if (!e.second->contentJson().isEmpty()) |
| 3523 | accountDataEvents.append(value: e.second->fullJson()); |
| 3524 | } |
| 3525 | result.insert(QStringLiteral("account_data" ), |
| 3526 | value: QJsonObject { |
| 3527 | { QStringLiteral("events" ), accountDataEvents } }); |
| 3528 | } |
| 3529 | |
| 3530 | if (const auto& readReceipt = q->lastReadReceipt(userId: connection->userId()); |
| 3531 | !readReceipt.eventId.isEmpty()) // |
| 3532 | { |
| 3533 | result.insert( |
| 3534 | QStringLiteral("ephemeral" ), |
| 3535 | value: QJsonObject { |
| 3536 | { QStringLiteral("events" ), |
| 3537 | QJsonArray { ReceiptEvent({ { .evtId: readReceipt.eventId, |
| 3538 | .receipts: { { .userId: connection->userId(), |
| 3539 | .timestamp: readReceipt.timestamp } } } }) |
| 3540 | .fullJson() } } }); |
| 3541 | } |
| 3542 | |
| 3543 | result.insert(key: UnreadNotificationsKey, |
| 3544 | value: QJsonObject { { PartiallyReadCountKey, |
| 3545 | countFromStats(s: partiallyReadStats) }, |
| 3546 | { HighlightCountKey, serverHighlightCount } }); |
| 3547 | result.insert(key: NewUnreadCountKey, value: countFromStats(s: unreadStats)); |
| 3548 | |
| 3549 | if (et.elapsed() > 30) |
| 3550 | qCDebug(PROFILER) << "Room::toJson() for" << q->objectName() << "took" |
| 3551 | << et; |
| 3552 | |
| 3553 | return result; |
| 3554 | } |
| 3555 | |
| 3556 | QJsonObject Room::toJson() const { return d->toJson(); } |
| 3557 | |
| 3558 | MemberSorter Room::memberSorter() const { return MemberSorter(this); } |
| 3559 | |
| 3560 | bool MemberSorter::operator()(User* u1, User* u2) const |
| 3561 | { |
| 3562 | return operator()(u1, u2name: room->disambiguatedMemberName(mxId: u2->id())); |
| 3563 | } |
| 3564 | |
| 3565 | bool MemberSorter::operator()(User* u1, QStringView u2name) const |
| 3566 | { |
| 3567 | auto n1 = room->disambiguatedMemberName(mxId: u1->id()); |
| 3568 | if (n1.startsWith(c: u'@')) |
| 3569 | n1.remove(i: 0, len: 1); |
| 3570 | const auto n2 = u2name.mid(pos: u2name.startsWith(c: u'@') ? 1 : 0) |
| 3571 | #if QT_VERSION_MAJOR < 6 |
| 3572 | .toString() // Qt 5 doesn't have QStringView::localeAwareCompare |
| 3573 | #endif |
| 3574 | ; |
| 3575 | |
| 3576 | return n1.localeAwareCompare(s: n2) < 0; |
| 3577 | } |
| 3578 | |
| 3579 | void Room::activateEncryption() |
| 3580 | { |
| 3581 | if(usesEncryption()) { |
| 3582 | qCWarning(E2EE) << "Room" << objectName() << "is already encrypted" ; |
| 3583 | return; |
| 3584 | } |
| 3585 | setState<EncryptionEvent>(EncryptionType::MegolmV1AesSha2); |
| 3586 | } |
| 3587 | |