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
75using namespace Quotient;
76using namespace std::placeholders;
77#if !(defined __GLIBCXX__ && __GLIBCXX__ <= 20150123)
78using std::llround;
79#endif
80
81enum EventsPlacement : int { Older = -1, Newer = 1 };
82
83class Q_DECL_HIDDEN Room::Private {
84public:
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
479private:
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
486decltype(Room::Private::baseState) Room::Private::stubbedState {};
487
488Room::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
529Room::~Room() { delete d; }
530
531const QString& Room::id() const { return d->id; }
532
533QString Room::version() const
534{
535 const auto v = currentState().query(fn: &RoomCreateEvent::version);
536 return v && !v->isEmpty() ? *v : QStringLiteral("1");
537}
538
539bool Room::isUnstable() const
540{
541 return !connection()->loadingCapabilities()
542 && !connection()->stableRoomVersions().contains(str: version());
543}
544
545QString Room::predecessorId() const
546{
547 if (const auto* evt = currentState().get<RoomCreateEvent>())
548 return evt->predecessor().roomId;
549
550 return {};
551}
552
553Room* 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
563QString Room::successorId() const
564{
565 return currentState().queryOr(fn: &RoomTombstoneEvent::successorRoomId,
566 fallback: QString());
567}
568
569Room* 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
579const Room::Timeline& Room::messageEvents() const { return d->timeline; }
580
581const Room::PendingEvents& Room::pendingEvents() const
582{
583 return d->unsyncedEvents;
584}
585
586bool Room::allHistoryLoaded() const
587{
588 return !d->prevBatch;
589}
590
591QString Room::name() const
592{
593 return currentState().content<RoomNameEvent>().value;
594}
595
596QStringList 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
607QStringList Room::altAliases() const
608{
609 return currentState().content<RoomCanonicalAliasEvent>().altAliases;
610}
611
612QString Room::canonicalAlias() const
613{
614 return currentState().content<RoomCanonicalAliasEvent>().canonicalAlias;
615}
616
617QString Room::displayName() const { return d->displayname; }
618
619QStringList Room::pinnedEventIds() const {
620 return currentState().content<RoomPinnedEventsEvent>().value;
621}
622
623QVector<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
633QString Room::displayNameForHtml() const
634{
635 return displayName().toHtmlEscaped();
636}
637
638void Room::refreshDisplayName() { d->updateDisplayname(); }
639
640QString Room::topic() const
641{
642 return currentState().content<RoomTopicEvent>().value;
643}
644
645QString Room::avatarMediaId() const { return d->avatar.mediaId(); }
646
647QUrl Room::avatarUrl() const { return d->avatar.url(); }
648
649const Avatar& Room::avatarObject() const { return d->avatar; }
650
651QImage Room::avatar(int dimension) { return avatar(width: dimension, height: dimension); }
652
653QImage 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
668User* Room::user(const QString& userId) const
669{
670 return connection()->user(uId: userId);
671}
672
673JoinState 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
679Membership Room::memberState(const QString& userId) const
680{
681 return currentState().queryOr(stateKey: userId, fn: &RoomMemberEvent::membership,
682 fallback: Membership::Leave);
683}
684
685bool Room::isMember(const QString& userId) const
686{
687 return memberState(userId) == Membership::Join;
688}
689
690JoinState Room::joinState() const { return d->joinState; }
691
692void 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
703Omittable<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
776Room::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
799Room::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
892Room::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
926void 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
940bool 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
962void Room::markMessagesAsRead(const QString& uptoEventId)
963{
964 d->markMessagesAsRead(upToMarker: findInTimeline(evtId: uptoEventId));
965}
966
967void Room::markAllMessagesAsRead()
968{
969 d->markMessagesAsRead(upToMarker: d->timeline.crbegin());
970}
971
972bool 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
987bool 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
999Notification Room::notificationFor(const TimelineItem &ti) const
1000{
1001 return d->notifications.value(key: ti->id());
1002}
1003
1004Notification Room::checkForNotifications(const TimelineItem &ti)
1005{
1006 return { .type: Notification::None };
1007}
1008
1009bool Room::hasUnreadMessages() const { return !d->partiallyReadStats.empty(); }
1010
1011int countFromStats(const EventStats& s)
1012{
1013 return s.empty() ? -1 : int(s.notableCount);
1014}
1015
1016int Room::unreadCount() const { return countFromStats(s: partiallyReadStats()); }
1017
1018EventStats Room::partiallyReadStats() const { return d->partiallyReadStats; }
1019
1020EventStats Room::unreadStats() const { return d->unreadStats; }
1021
1022Room::rev_iter_t Room::historyEdge() const { return d->historyEdge(); }
1023
1024Room::Timeline::const_iterator Room::syncEdge() const { return d->syncEdge(); }
1025
1026TimelineItem::index_t Room::minTimelineIndex() const
1027{
1028 return d->timeline.empty() ? 0 : d->timeline.front().index();
1029}
1030
1031TimelineItem::index_t Room::maxTimelineIndex() const
1032{
1033 return d->timeline.empty() ? 0 : d->timeline.back().index();
1034}
1035
1036bool Room::isValidIndex(TimelineItem::index_t timelineIndex) const
1037{
1038 return !d->timeline.empty() && timelineIndex >= minTimelineIndex()
1039 && timelineIndex <= maxTimelineIndex();
1040}
1041
1042Room::rev_iter_t Room::findInTimeline(TimelineItem::index_t index) const
1043{
1044 return historyEdge()
1045 - (isValidIndex(timelineIndex: index) ? index - minTimelineIndex() + 1 : 0);
1046}
1047
1048Room::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
1058Room::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
1066Room::PendingEvents::const_iterator
1067Room::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
1075const Room::RelatedEvents Room::relatedEvents(
1076 const QString& evtId, EventRelation::reltypeid_t relType) const
1077{
1078 return d->relations.value(key: { evtId, relType });
1079}
1080
1081const Room::RelatedEvents Room::relatedEvents(
1082 const RoomEvent& evt, EventRelation::reltypeid_t relType) const
1083{
1084 return relatedEvents(evtId: evt.id(), relType);
1085}
1086
1087const RoomCreateEvent* Room::creation() const
1088{
1089 return currentState().get<RoomCreateEvent>();
1090}
1091
1092const RoomTombstoneEvent* Room::tombstone() const
1093{
1094 return currentState().get<RoomTombstoneEvent>();
1095}
1096
1097void 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
1121bool Room::displayed() const { return d->displayed; }
1122
1123void 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
1134QString Room::firstDisplayedEventId() const { return d->firstDisplayedEventId; }
1135
1136Room::rev_iter_t Room::firstDisplayedMarker() const
1137{
1138 return findInTimeline(evtId: firstDisplayedEventId());
1139}
1140
1141void 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
1155void Room::setFirstDisplayedEvent(TimelineItem::index_t index)
1156{
1157 Q_ASSERT(isValidIndex(index));
1158 setFirstDisplayedEventId(findInTimeline(index)->event()->id());
1159}
1160
1161QString Room::lastDisplayedEventId() const { return d->lastDisplayedEventId; }
1162
1163Room::rev_iter_t Room::lastDisplayedMarker() const
1164{
1165 return findInTimeline(evtId: lastDisplayedEventId());
1166}
1167
1168void 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
1183void Room::setLastDisplayedEvent(TimelineItem::index_t index)
1184{
1185 Q_ASSERT(isValidIndex(index));
1186 setLastDisplayedEventId(findInTimeline(index)->event()->id());
1187}
1188
1189Room::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
1195Room::rev_iter_t Room::readMarker() const { return fullyReadMarker(); }
1196
1197QString Room::readMarkerEventId() const { return lastFullyReadEventId(); }
1198
1199ReadReceipt Room::lastReadReceipt(const QString& userId) const
1200{
1201 return d->lastReadReceipts.value(key: userId);
1202}
1203
1204ReadReceipt Room::lastLocalReadReceipt() const
1205{
1206 return d->lastReadReceipts.value(key: localUser()->id());
1207}
1208
1209Room::rev_iter_t Room::localReadReceiptMarker() const
1210{
1211 return findInTimeline(evtId: lastLocalReadReceipt().eventId);
1212}
1213
1214QString Room::lastFullyReadEventId() const { return d->fullyReadUntilEventId; }
1215
1216Room::rev_iter_t Room::fullyReadMarker() const
1217{
1218 return findInTimeline(evtId: d->fullyReadUntilEventId);
1219}
1220
1221QSet<QString> Room::userIdsAtEvent(const QString& eventId) const
1222{
1223 return d->eventIdReadUsers.value(key: eventId);
1224}
1225
1226QSet<QString> Room::userIdsAtEvent(const QString& eventId)
1227{
1228 return d->eventIdReadUsers.value(key: eventId);
1229}
1230
1231QSet<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
1241qsizetype Room::notificationCount() const
1242{
1243 return d->unreadStats.notableCount;
1244}
1245
1246qsizetype Room::highlightCount() const { return d->serverHighlightCount; }
1247
1248void 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
1261bool Room::hasAccountData(const QString& type) const
1262{
1263 return d->accountData.find(x: type) != d->accountData.end();
1264}
1265
1266const 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
1273QStringList 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
1283QStringList Room::tagNames() const { return d->tags.keys(); }
1284
1285TagsMap Room::tags() const { return d->tags; }
1286
1287TagRecord Room::tag(const QString& name) const { return d->tags.value(key: name); }
1288
1289std::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
1302void 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
1316void Room::addTag(const QString& name, float order)
1317{
1318 addTag(name, record: TagRecord { .order: order });
1319}
1320
1321void 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
1335void 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
1358void 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
1376bool Room::isFavourite() const { return d->tags.contains(key: FavouriteTag); }
1377
1378bool Room::isLowPriority() const { return d->tags.contains(key: LowPriorityTag); }
1379
1380bool Room::isServerNoticeRoom() const
1381{
1382 return d->tags.contains(key: ServerNoticeTag);
1383}
1384
1385bool Room::isDirectChat() const { return connection()->isDirectChat(roomId: id()); }
1386
1387QList<User*> Room::directChatUsers() const
1388{
1389 return connection()->directChatUsers(room: this);
1390}
1391
1392QUrl 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
1403QString safeFileName(QString rawName)
1404{
1405 static auto safeFileNameRegex = QRegularExpression(R"([/\<>|"*?:])"_ls);
1406 return rawName.replace(re: safeFileNameRegex, after: "_"_ls);
1407}
1408
1409const RoomMessageEvent*
1410Room::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
1422QString 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
1451QUrl 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
1464QUrl 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
1474QString Room::fileNameToDownload(const QString& eventId) const
1475{
1476 if (auto* event = d->getEventWithFile(eventId))
1477 return d->fileNameToDownload(event);
1478 return {};
1479}
1480
1481FileTransferInfo 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
1506QUrl 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
1521QString Room::prettyPrint(const QString& plainText) const
1522{
1523 return Quotient::prettyPrint(plainText);
1524}
1525
1526QList<User*> Room::usersTyping() const { return d->usersTyping; }
1527
1528QList<User*> Room::membersLeft() const { return d->membersLeft; }
1529
1530QList<User*> Room::users() const { return d->membersMap.values(); }
1531
1532QStringList Room::memberNames() const
1533{
1534 return safeMemberNames();
1535}
1536
1537QStringList 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
1547QStringList 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
1557int Room::timelineSize() const { return int(d->timeline.size()); }
1558
1559bool Room::usesEncryption() const
1560{
1561 return !currentState()
1562 .queryOr(fn: &EncryptionEvent::algorithm, fallback: QString())
1563 .isEmpty();
1564}
1565
1566const StateEvent* Room::getCurrentState(const QString& evtType,
1567 const QString& stateKey) const
1568{
1569 return d->getCurrentState(evtKey: { evtType, stateKey });
1570}
1571
1572RoomStateView Room::currentState() const
1573{
1574 return d->currentState;
1575}
1576
1577RoomEventPtr 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
1609void 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
1649int Room::joinedCount() const
1650{
1651 return d->summary.joinedMemberCount.value_or(u: d->membersMap.size());
1652}
1653
1654int 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
1661int Room::totalMemberCount() const { return joinedCount() + invitedCount(); }
1662
1663GetRoomEventsJob* Room::eventsHistoryJob() const { return d->eventsHistoryJob; }
1664
1665Room::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
1674void 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
1705void 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
1747inline 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
1754Room::Timeline::size_type
1755Room::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
1801QString 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
1813QString Room::roomMembername(const User* u) const
1814{
1815 Q_ASSERT(u != nullptr);
1816 return disambiguatedMemberName(mxId: u->id());
1817}
1818
1819QString Room::roomMembername(const QString& userId) const
1820{
1821 return disambiguatedMemberName(mxId: userId);
1822}
1823
1824inline QString makeFullUserName(const QString& displayName, const QString& mxId)
1825{
1826 return displayName % " ("_ls % mxId % u')';
1827}
1828
1829QString 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
1852QString Room::safeMemberName(const QString& userId) const
1853{
1854 return sanitized(plainText: disambiguatedMemberName(mxId: userId));
1855}
1856
1857QString Room::htmlSafeMemberName(const QString& userId) const
1858{
1859 return safeMemberName(userId).toHtmlEscaped();
1860}
1861
1862QUrl 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
1874Room::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
1940void 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
1980void 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
2010RoomEvent* 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
2025QString 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
2035QString 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
2118void 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
2131QString 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
2168auto FileTransferCancelledMsg() { return Room::tr(s: "File transfer cancelled"); }
2169
2170void 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
2196QString Room::postMessage(const QString& plainText, MessageEventType type)
2197{
2198 return d->sendEvent<RoomMessageEvent>(eventArgs: plainText, eventArgs&: type);
2199}
2200
2201QString Room::postPlainText(const QString& plainText)
2202{
2203 return postMessage(plainText, type: MessageEventType::Text);
2204}
2205
2206QString 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
2214QString Room::postHtmlText(const QString& plainText, const QString& html)
2215{
2216 return postHtmlMessage(plainText, html);
2217}
2218
2219QString Room::postReaction(const QString& eventId, const QString& key)
2220{
2221 return d->sendEvent<ReactionEvent>(eventArgs: eventId, eventArgs: key);
2222}
2223
2224QString 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
2272QString 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
2290QString 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
2301QString Room::postEvent(RoomEvent* event)
2302{
2303 return d->sendEvent(event: RoomEventPtr(event));
2304}
2305
2306QString Room::postJson(const QString& matrixType,
2307 const QJsonObject& eventContent)
2308{
2309 return d->sendEvent(event: loadEvent<RoomEvent>(matrixType, otherBasicJsonParams: eventContent));
2310}
2311
2312SetRoomStateWithKeyJob* Room::setState(const StateEvent& evt)
2313{
2314 return setState(evtType: evt.matrixType(), stateKey: evt.stateKey(), contentJson: evt.contentJson());
2315}
2316
2317SetRoomStateWithKeyJob* Room::setState(const QString& evtType,
2318 const QString& stateKey,
2319 const QJsonObject& contentJson)
2320{
2321 return d->requestSetState(evtType, stateKey, contentJson);
2322}
2323
2324void Room::setName(const QString& newName)
2325{
2326 setState<RoomNameEvent>(newName);
2327}
2328
2329void Room::setCanonicalAlias(const QString& newAlias)
2330{
2331 setState<RoomCanonicalAliasEvent>(args: newAlias, args: altAliases());
2332}
2333
2334void Room::setPinnedEvents(const QStringList& events)
2335{
2336 setState<RoomPinnedEventsEvent>(events);
2337}
2338void Room::setLocalAliases(const QStringList& aliases)
2339{
2340 setState<RoomCanonicalAliasEvent>(args: canonicalAlias(), args: aliases);
2341}
2342
2343void Room::setTopic(const QString& newTopic)
2344{
2345 setState<RoomTopicEvent>(newTopic);
2346}
2347
2348bool 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
2368bool Room::supportsCalls() const { return joinedCount() == 2; }
2369
2370void 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
2387void 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
2394void Room::sendCallCandidates(const QString& callId,
2395 const QJsonArray& candidates)
2396{
2397 Q_ASSERT(supportsCalls());
2398 d->sendEvent<CallCandidatesEvent>(eventArgs: callId, eventArgs: candidates);
2399}
2400
2401void 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
2409void Room::answerCall(const QString& callId, const QString& sdp)
2410{
2411 Q_ASSERT(supportsCalls());
2412 d->sendEvent<CallAnswerEvent>(eventArgs: callId, eventArgs: sdp);
2413}
2414
2415void Room::hangupCall(const QString& callId)
2416{
2417 Q_ASSERT(supportsCalls());
2418 d->sendEvent<CallHangupEvent>(eventArgs: callId);
2419}
2420
2421void Room::getPreviousContent(int limit, const QString& filter)
2422{
2423 d->getPreviousContent(limit, filter);
2424}
2425
2426void 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
2451void Room::inviteToRoom(const QString& memberId)
2452{
2453 connection()->callApi<InviteUserJob>(jobArgs: id(), jobArgs: memberId);
2454}
2455
2456LeaveRoomJob* Room::leaveRoom()
2457{
2458 // FIXME, #63: It should be RoomManager, not Connection
2459 return connection()->leaveRoom(room: this);
2460}
2461
2462void Room::kickMember(const QString& memberId, const QString& reason)
2463{
2464 connection()->callApi<KickJob>(jobArgs: id(), jobArgs: memberId, jobArgs: reason);
2465}
2466
2467void Room::ban(const QString& userId, const QString& reason)
2468{
2469 connection()->callApi<BanJob>(jobArgs: id(), jobArgs: userId, jobArgs: reason);
2470}
2471
2472void Room::unban(const QString& userId)
2473{
2474 connection()->callApi<UnbanJob>(jobArgs: id(), jobArgs: userId);
2475}
2476
2477void 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
2483void 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
2524void 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
2593void 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
2607void Room::Private::dropExtraneousEvents(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
2635void 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.
2670RoomEventPtr 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: