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(c: 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: |
---|