diff options
Diffstat (limited to 'lib/room.cpp')
-rw-r--r-- | lib/room.cpp | 3794 |
1 files changed, 2589 insertions, 1205 deletions
diff --git a/lib/room.cpp b/lib/room.cpp index ea771f17..0cf818ce 100644 --- a/lib/room.cpp +++ b/lib/room.cpp @@ -1,64 +1,77 @@ -/****************************************************************************** - * Copyright (C) 2015 Felix Rohrbach <kde@fxrh.de> - * - * This library is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 2.1 of the License, or (at your option) any later version. - * - * This library is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public - * License along with this library; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - */ +// SPDX-FileCopyrightText: 2016 Kitsune Ral <Kitsune-Ral@users.sf.net> +// SPDX-FileCopyrightText: 2017 Roman Plášil <me@rplasil.name> +// SPDX-FileCopyrightText: 2017 Marius Gripsgard <marius@ubports.com> +// SPDX-FileCopyrightText: 2018 Josip Delic <delijati@googlemail.com> +// SPDX-FileCopyrightText: 2018 Black Hat <bhat@encom.eu.org> +// SPDX-FileCopyrightText: 2019 Alexey Andreyev <aa13q@ya.ru> +// SPDX-FileCopyrightText: 2020 Ram Nad <ramnad1999@gmail.com> +// SPDX-License-Identifier: LGPL-2.1-or-later #include "room.h" -#include "csapi/kicking.h" -#include "csapi/inviting.h" +#include "avatar.h" +#include "connection.h" +#include "converters.h" +#include "syncdata.h" +#include "user.h" +#include "eventstats.h" +#include "roomstateview.h" +#include "qt_connection_util.h" + +// NB: since Qt 6, moc_room.cpp needs User fully defined +#include "moc_room.cpp" + +#include "csapi/account-data.h" #include "csapi/banning.h" +#include "csapi/inviting.h" +#include "csapi/kicking.h" #include "csapi/leaving.h" +#include "csapi/read_markers.h" #include "csapi/receipts.h" #include "csapi/redaction.h" -#include "csapi/account-data.h" -#include "csapi/message_pagination.h" -#include "csapi/room_state.h" #include "csapi/room_send.h" +#include "csapi/room_state.h" +#include "csapi/room_upgrades.h" +#include "csapi/rooms.h" #include "csapi/tags.h" -#include "events/simplestateevents.h" + +#include "events/callevents.h" +#include "events/encryptionevent.h" +#include "events/reactionevent.h" +#include "events/receiptevent.h" +#include "events/redactionevent.h" #include "events/roomavatarevent.h" +#include "events/roomcanonicalaliasevent.h" +#include "events/roomcreateevent.h" #include "events/roommemberevent.h" +#include "events/roompowerlevelsevent.h" +#include "events/roomtombstoneevent.h" +#include "events/simplestateevents.h" #include "events/typingevent.h" -#include "events/receiptevent.h" -#include "events/callinviteevent.h" -#include "events/callcandidatesevent.h" -#include "events/callanswerevent.h" -#include "events/callhangupevent.h" -#include "events/redactionevent.h" -#include "jobs/mediathumbnailjob.h" #include "jobs/downloadfilejob.h" -#include "jobs/postreadmarkersjob.h" -#include "avatar.h" -#include "connection.h" -#include "user.h" -#include "converters.h" +#include "jobs/mediathumbnailjob.h" +#include <QtCore/QDir> #include <QtCore/QHash> -#include <QtCore/QStringBuilder> // for efficient string concats (operator%) -#include <QtCore/QElapsedTimer> #include <QtCore/QPointer> -#include <QtCore/QDir> +#include <QtCore/QRegularExpression> +#include <QtCore/QStringBuilder> // for efficient string concats (operator%) #include <QtCore/QTemporaryFile> #include <array> -#include <functional> #include <cmath> +#include <functional> + +#ifdef Quotient_E2EE_ENABLED +#include "e2ee/e2ee.h" +#include "e2ee/qolmaccount.h" +#include "e2ee/qolminboundsession.h" +#include "e2ee/qolmutility.h" +#include "database.h" +#endif // Quotient_E2EE_ENABLED -using namespace QMatrixClient; + +using namespace Quotient; using namespace std::placeholders; using std::move; #if !(defined __GLIBCXX__ && __GLIBCXX__ <= 20150123) @@ -67,194 +80,396 @@ using std::llround; enum EventsPlacement : int { Older = -1, Newer = 1 }; -// A workaround for MSVC 2015 that fails with "error C2440: 'return': -// cannot convert from 'initializer list' to 'QMatrixClient::FileTransferInfo'" -#if (defined(_MSC_VER) && _MSC_VER < 1910) || (defined(__GNUC__) && __GNUC__ <= 4) -# define WORKAROUND_EXTENDED_INITIALIZER_LIST -#endif - -class Room::Private -{ - public: - /** Map of user names to users. User names potentially duplicate, hence a multi-hashmap. */ - using members_map_t = QMultiHash<QString, User*>; - - Private(Connection* c, QString id_, JoinState initialJoinState) - : q(nullptr), connection(c), id(move(id_)) - , joinState(initialJoinState) - { } - - Room* q; - - // This updates the room displayname field (which is the way a room - // should be shown in the room list) It should be called whenever the - // list of members or the room name (m.room.name) or canonical alias change. - void updateDisplayname(); - - Connection* connection; - Timeline timeline; - PendingEvents unsyncedEvents; - QHash<QString, TimelineItem::index_t> eventsIndex; - QString id; - QStringList aliases; - QString canonicalAlias; - QString name; - QString displayname; - QString topic; - QString encryptionAlgorithm; - Avatar avatar; - JoinState joinState; - int highlightCount = 0; - int notificationCount = 0; - members_map_t membersMap; - QList<User*> usersTyping; - QMultiHash<QString, User*> eventIdReadUsers; - QList<User*> membersLeft; - int unreadMessages = 0; - bool displayed = false; - QString firstDisplayedEventId; - QString lastDisplayedEventId; - QHash<const User*, QString> lastReadEventIds; - QString serverReadMarker; - TagsMap tags; - std::unordered_map<QString, EventPtr> accountData; - QString prevBatch; - QPointer<GetRoomEventsJob> eventsHistoryJob; - - struct FileTransferPrivateInfo +class Room::Private { +public: + /// Map of user names to users + /** User names potentially duplicate, hence QMultiHash. */ + using members_map_t = QMultiHash<QString, User*>; + + Private(Connection* c, QString id_, JoinState initialJoinState) + : q(nullptr), connection(c), id(move(id_)), joinState(initialJoinState) + {} + + Room* q; + + Connection* connection; + QString id; + JoinState joinState; + RoomSummary summary = { none, 0, none }; + /// The state of the room at timeline position before-0 + /// \sa timelineBase + UnorderedMap<StateEventKey, StateEventPtr> baseState; + /// State event stubs - events without content, just type and state key + static decltype(baseState) stubbedState; + /// The state of the room at syncEdge() + /// \sa syncEdge + RoomStateView currentState; + /// Servers with aliases for this room except the one of the local user + /// \sa Room::remoteAliases + QSet<QString> aliasServers; + + Timeline timeline; + PendingEvents unsyncedEvents; + QHash<QString, TimelineItem::index_t> eventsIndex; + // A map from evtId to a map of relation type to a vector of event + // pointers. Not using QMultiHash, because we want to quickly return + // a number of relations for a given event without enumerating them. + QHash<std::pair<QString, QString>, RelatedEvents> relations; + QString displayname; + Avatar avatar; + QHash<QString, Notification> notifications; + qsizetype serverHighlightCount = 0; + // Starting up with estimate event statistics as there's zero knowledge + // about the timeline. + EventStats partiallyReadStats {}, unreadStats {}; + members_map_t membersMap; + QList<User*> usersTyping; + QHash<QString, QSet<QString>> eventIdReadUsers; + QList<User*> usersInvited; + QList<User*> membersLeft; + bool displayed = false; + QString firstDisplayedEventId; + QString lastDisplayedEventId; + QHash<QString, ReadReceipt> lastReadReceipts; + QString fullyReadUntilEventId; + TagsMap tags; + UnorderedMap<QString, EventPtr> accountData; + QString prevBatch; + QPointer<GetRoomEventsJob> eventsHistoryJob; + QPointer<GetMembersByRoomJob> allMembersJob; + // Map from megolm sessionId to set of eventIds + UnorderedMap<QString, QSet<QString>> undecryptedEvents; + + struct FileTransferPrivateInfo { + FileTransferPrivateInfo() = default; + FileTransferPrivateInfo(BaseJob* j, const QString& fileName, + bool isUploading = false) + : status(FileTransferInfo::Started) + , job(j) + , localFileInfo(fileName) + , isUpload(isUploading) + {} + + FileTransferInfo::Status status = FileTransferInfo::None; + QPointer<BaseJob> job = nullptr; + QFileInfo localFileInfo {}; + bool isUpload = false; + qint64 progress = 0; + qint64 total = -1; + + void update(qint64 p, qint64 t) { -#ifdef WORKAROUND_EXTENDED_INITIALIZER_LIST - FileTransferPrivateInfo() = default; - FileTransferPrivateInfo(BaseJob* j, QString fileName) - : job(j), localFileInfo(fileName) - { } -#endif - QPointer<BaseJob> job = nullptr; - QFileInfo localFileInfo { }; - FileTransferInfo::Status status = FileTransferInfo::Started; - qint64 progress = 0; - qint64 total = -1; - - void update(qint64 p, qint64 t) - { - if (t == 0) - { - t = -1; - if (p == 0) - p = -1; - } - if (p != -1) - qCDebug(PROFILER) << "Transfer progress:" << p << "/" << t - << "=" << llround(double(p) / t * 100) << "%"; - progress = p; total = t; + if (t == 0) { + t = -1; + if (p == 0) + p = -1; } - }; - void failedTransfer(const QString& tid, const QString& errorMessage = {}) - { - qCWarning(MAIN) << "File transfer failed for id" << tid; - if (!errorMessage.isEmpty()) - qCWarning(MAIN) << "Message:" << errorMessage; - fileTransfers[tid].status = FileTransferInfo::Failed; - emit q->fileTransferFailed(tid, errorMessage); + if (p != -1) + qCDebug(PROFILER) << "Transfer progress:" << p << "/" << t + << "=" << llround(double(p) / t * 100) << "%"; + progress = p; + total = t; } - /// A map from event/txn ids to information about the long operation; - /// used for both download and upload operations - QHash<QString, FileTransferPrivateInfo> fileTransfers; + }; + void failedTransfer(const QString& tid, const QString& errorMessage = {}) + { + qCWarning(MAIN) << "File transfer failed for id" << tid; + if (!errorMessage.isEmpty()) + qCWarning(MAIN) << "Message:" << errorMessage; + fileTransfers[tid].status = FileTransferInfo::Failed; + emit q->fileTransferFailed(tid, errorMessage); + } + /// A map from event/txn ids to information about the long operation; + /// used for both download and upload operations + QHash<QString, FileTransferPrivateInfo> fileTransfers; - const RoomMessageEvent* getEventWithFile(const QString& eventId) const; - QString fileNameToDownload(const RoomMessageEvent* event) const; + const RoomMessageEvent* getEventWithFile(const QString& eventId) const; + QString fileNameToDownload(const RoomMessageEvent* event) const; - //void inviteUser(User* u); // We might get it at some point in time. - void insertMemberIntoMap(User* u); - void renameMember(User* u, QString oldName); - void removeMemberFromMap(const QString& username, User* u); + Changes setSummary(RoomSummary&& newSummary); - void getPreviousContent(int limit = 10); + // void inviteUser(User* u); // We might get it at some point in time. + void insertMemberIntoMap(User* u); + void removeMemberFromMap(User* u); - bool isEventNotable(const TimelineItem& ti) const - { - return !ti->isRedacted() && - ti->senderId() != connection->userId() && - is<RoomMessageEvent>(*ti); + // This updates the room displayname field (which is the way a room + // should be shown in the room list); called whenever the list of + // members, the room name (m.room.name) or canonical alias change. + void updateDisplayname(); + // This is used by updateDisplayname() but only calculates the new name + // without any updates. + QString calculateDisplayname() const; + + /// A point in the timeline corresponding to baseState + rev_iter_t timelineBase() const { return q->findInTimeline(-1); } + rev_iter_t historyEdge() const { return timeline.crend(); } + Timeline::const_iterator syncEdge() const { return timeline.cend(); } + + void getPreviousContent(int limit = 10, const QString &filter = {}); + + const StateEvent* getCurrentState(const StateEventKey& evtKey) const + { + const auto* evt = currentState.value(evtKey, nullptr); + if (!evt) { + if (stubbedState.find(evtKey) == stubbedState.end()) { + // In the absence of a real event, make a stub as-if an event + // with empty content has been received. Event classes should be + // prepared for empty/invalid/malicious content anyway. + stubbedState.emplace( + evtKey, loadEvent<StateEvent>(evtKey.first, evtKey.second)); + qCDebug(STATE) << "A new stub event created for key {" + << evtKey.first << evtKey.second << "}"; + qCDebug(STATE) << "Stubbed state size:" << stubbedState.size(); + } + evt = stubbedState[evtKey].get(); + Q_ASSERT(evt); } + Q_ASSERT(evt->matrixType() == evtKey.first + && evt->stateKey() == evtKey.second); + return evt; + } - void addNewMessageEvents(RoomEvents&& events); - void addHistoricalMessageEvents(RoomEvents&& events); - - /** Move events into the timeline - * - * Insert events into the timeline, either new or historical. - * Pointers in the original container become empty, the ownership - * is passed to the timeline container. - * @param events - the range of events to be inserted - * @param placement - position and direction of insertion: Older for - * historical messages, Newer for new ones - */ - Timeline::difference_type moveEventsToTimeline(RoomEventsRange events, - EventsPlacement placement); - - /** - * Remove events from the passed container that are already in the timeline - */ - void dropDuplicateEvents(RoomEvents& events) const; - - void setLastReadEvent(User* u, QString eventId); - void updateUnreadCount(rev_iter_t from, rev_iter_t to); - void promoteReadMarker(User* u, rev_iter_t newMarker, - bool force = false); - - void markMessagesAsRead(rev_iter_t upToMarker); - - QString sendEvent(RoomEventPtr&& event); - - template <typename EventT, typename... ArgTs> - QString sendEvent(ArgTs&&... eventArgs) - { - return sendEvent(makeEvent<EventT>(std::forward<ArgTs>(eventArgs)...)); + template <typename EventArrayT> + Changes updateStateFrom(EventArrayT&& events) + { + Changes changes {}; + if (!events.empty()) { + QElapsedTimer et; + et.start(); + for (auto&& eptr : events) { + const auto& evt = *eptr; + Q_ASSERT(evt.isStateEvent()); + if (auto change = q->processStateEvent(evt); change) { + changes |= change; + baseState[{ evt.matrixType(), evt.stateKey() }] = move(eptr); + } + } + if (events.size() > 9 || et.nsecsElapsed() >= profilerMinNsecs()) + qCDebug(PROFILER) + << "Updated" << q->objectName() << "room state from" + << events.size() << "event(s) in" << et; } + return changes; + } + Changes addNewMessageEvents(RoomEvents&& events); + void addHistoricalMessageEvents(RoomEvents&& events); + + Changes updateStatsFromSyncData(const SyncRoomData &data, bool fromCache); + void postprocessChanges(Changes changes, bool saveState = true); + + /** Move events into the timeline + * + * Insert events into the timeline, either new or historical. + * Pointers in the original container become empty, the ownership + * is passed to the timeline container. + * @param events - the range of events to be inserted + * @param placement - position and direction of insertion: Older for + * historical messages, Newer for new ones + */ + Timeline::size_type moveEventsToTimeline(RoomEventsRange events, + EventsPlacement placement); + + /** + * Remove events from the passed container that are already in the timeline + */ + void dropDuplicateEvents(RoomEvents& events) const; + void decryptIncomingEvents(RoomEvents& events); + + //! \brief update last receipt record for a given user + //! + //! \return previous event id of the receipt if the new receipt changed + //! it, or `none` if no change took place + Omittable<QString> setLastReadReceipt(const QString& userId, rev_iter_t newMarker, + ReadReceipt newReceipt = {}); + Changes setLocalLastReadReceipt(const rev_iter_t& newMarker, + ReadReceipt newReceipt = {}, + bool deferStatsUpdate = false); + Changes setFullyReadMarker(const QString &eventId); + Changes updateStats(const rev_iter_t& from, const rev_iter_t& to); + bool markMessagesAsRead(const rev_iter_t& upToMarker); + + void getAllMembers(); + + QString sendEvent(RoomEventPtr&& event); + + template <typename EventT, typename... ArgTs> + QString sendEvent(ArgTs&&... eventArgs) + { + return sendEvent(makeEvent<EventT>(std::forward<ArgTs>(eventArgs)...)); + } - QString doSendEvent(const RoomEvent* pEvent); - PendingEvents::iterator findAsPending(const RoomEvent* rawEvtPtr); - void onEventSendingFailure(const RoomEvent* pEvent, - const QString& txnId, BaseJob* call = nullptr); + QString doPostFile(RoomEventPtr &&msgEvent, const QUrl &localUrl); - template <typename EvT> - auto requestSetState(const QString& stateKey, const EvT& event) - { - // TODO: Queue up state events sending (see #133). - return connection->callApi<SetRoomStateWithKeyJob>( - id, EvT::matrixTypeId(), stateKey, event.contentJson()); - } + RoomEvent* addAsPending(RoomEventPtr&& event); - template <typename EvT> - auto requestSetState(const EvT& event) - { - return connection->callApi<SetRoomStateJob>( - id, EvT::matrixTypeId(), event.contentJson()); + QString doSendEvent(const RoomEvent* pEvent); + void onEventSendingFailure(const QString& txnId, BaseJob* call = nullptr); + + SetRoomStateWithKeyJob* requestSetState(const QString& evtType, + const QString& stateKey, + const QJsonObject& contentJson) + { + // if (event.roomId().isEmpty()) + // event.setRoomId(id); + // if (event.senderId().isEmpty()) + // event.setSender(connection->userId()); + // TODO: Queue up state events sending (see #133). + // TODO: Maybe addAsPending() as well, despite having no txnId + return connection->callApi<SetRoomStateWithKeyJob>(id, evtType, stateKey, + contentJson); + } + + /*! Apply redaction to the timeline + * + * Tries to find an event in the timeline and redact it; deletes the + * redaction event whether the redacted event was found or not. + * \return true if the event has been found and redacted; false otherwise + */ + bool processRedaction(const RedactionEvent& redaction); + + /*! Apply a new revision of the event to the timeline + * + * Tries to find an event in the timeline and replace it with the new + * content passed in \p newMessage. + * \return true if the event has been found and replaced; false otherwise + */ + bool processReplacement(const RoomMessageEvent& newEvent); + + void setTags(TagsMap&& newTags); + + QJsonObject toJson() const; + + bool isLocalUser(const User* u) const { return u == q->localUser(); } + +#ifdef Quotient_E2EE_ENABLED + UnorderedMap<QString, QOlmInboundGroupSessionPtr> groupSessions; + int currentMegolmSessionMessageCount = 0; + //TODO save this to database + unsigned long long currentMegolmSessionCreationTimestamp = 0; + QOlmOutboundGroupSessionPtr currentOutboundMegolmSession = nullptr; + + bool addInboundGroupSession(QString sessionId, QByteArray sessionKey, + const QString& senderId, + const QString& olmSessionId) + { + if (groupSessions.contains(sessionId)) { + qCWarning(E2EE) << "Inbound Megolm session" << sessionId << "already exists"; + return false; } - /** - * @brief Apply redaction to the timeline - * - * Tries to find an event in the timeline and redact it; deletes the - * redaction event whether the redacted event was found or not. - */ - bool processRedaction(const RedactionEvent& redaction); + auto expectedMegolmSession = QOlmInboundGroupSession::create(sessionKey); + Q_ASSERT(expectedMegolmSession.has_value()); + auto&& megolmSession = *expectedMegolmSession; + if (megolmSession->sessionId() != sessionId) { + qCWarning(E2EE) << "Session ID mismatch in m.room_key event"; + return false; + } + megolmSession->setSenderId(senderId); + megolmSession->setOlmSessionId(olmSessionId); + qCWarning(E2EE) << "Adding inbound session"; + connection->saveMegolmSession(q, *megolmSession); + groupSessions[sessionId] = std::move(megolmSession); + return true; + } - void setTags(TagsMap newTags); + QString groupSessionDecryptMessage(QByteArray cipher, + const QString& sessionId, + const QString& eventId, + QDateTime timestamp, + const QString& senderId) + { + auto groupSessionIt = groupSessions.find(sessionId); + if (groupSessionIt == groupSessions.end()) { + // qCWarning(E2EE) << "Unable to decrypt event" << eventId + // << "The sender's device has not sent us the keys for " + // "this message"; + return {}; + } + auto& senderSession = groupSessionIt->second; + if (senderSession->senderId() != senderId) { + qCWarning(E2EE) << "Sender from event does not match sender from session"; + return {}; + } + auto decryptResult = senderSession->decrypt(cipher); + if(!decryptResult) { + qCWarning(E2EE) << "Unable to decrypt event" << eventId + << "with matching megolm session:" << decryptResult.error(); + return {}; + } + const auto& [content, index] = *decryptResult; + const auto& [recordEventId, ts] = + q->connection()->database()->groupSessionIndexRecord( + q->id(), senderSession->sessionId(), index); + if (recordEventId.isEmpty()) { + q->connection()->database()->addGroupSessionIndexRecord( + q->id(), senderSession->sessionId(), index, eventId, + timestamp.toMSecsSinceEpoch()); + } else { + if ((eventId != recordEventId) + || (ts != timestamp.toMSecsSinceEpoch())) { + qCWarning(E2EE) << "Detected a replay attack on event" << eventId; + return {}; + } + } + return content; + } - QJsonObject toJson() const; + bool shouldRotateMegolmSession() const + { + const auto* encryptionConfig = currentState.get<EncryptionEvent>(); + if (!encryptionConfig || !encryptionConfig->useEncryption()) + return false; - private: - QString calculateDisplayname() const; - QString roomNameFromMemberNames(const QList<User*>& userlist) const; + const auto rotationInterval = encryptionConfig->rotationPeriodMs(); + const auto rotationMessageCount = encryptionConfig->rotationPeriodMsgs(); + return currentOutboundMegolmSession->messageCount() + >= rotationMessageCount + || currentOutboundMegolmSession->creationTime().addMSecs( + rotationInterval) + < QDateTime::currentDateTime(); + } - bool isLocalUser(const User* u) const - { - return u == q->localUser(); + bool hasValidMegolmSession() const + { + if (!q->usesEncryption()) { + return false; } + return currentOutboundMegolmSession != nullptr; + } + + void createMegolmSession() { + qCDebug(E2EE) << "Creating new outbound megolm session for room " + << q->objectName(); + currentOutboundMegolmSession = QOlmOutboundGroupSession::create(); + connection->saveCurrentOutboundMegolmSession( + id, *currentOutboundMegolmSession); + + addInboundGroupSession(currentOutboundMegolmSession->sessionId(), + currentOutboundMegolmSession->sessionKey(), + q->localUser()->id(), "SELF"_ls); + } + + QMultiHash<QString, QString> getDevicesWithoutKey() const + { + QMultiHash<QString, QString> devices; + for (const auto& user : q->users()) + for (const auto& deviceId : connection->devicesForUser(user->id())) + devices.insert(user->id(), deviceId); + + return connection->database()->devicesWithoutKey( + id, devices, currentOutboundMegolmSession->sessionId()); + } +#endif // Quotient_E2EE_ENABLED + +private: + using users_shortlist_t = std::array<User*, 3>; + template <typename ContT> + users_shortlist_t buildShortlist(const ContT& users) const; + users_shortlist_t buildShortlist(const QStringList& userIds) const; }; +decltype(Room::Private::baseState) Room::Private::stubbedState {}; + Room::Room(Connection* connection, QString id, JoinState initialJoinState) : QObject(connection), d(new Private(connection, id, initialJoinState)) { @@ -262,83 +477,175 @@ Room::Room(Connection* connection, QString id, JoinState initialJoinState) // See "Accessing the Public Class" section in // https://marcmutz.wordpress.com/translated-articles/pimp-my-pimpl-%E2%80%94-reloaded/ d->q = this; - connect(this, &Room::userAdded, this, &Room::memberListChanged); - connect(this, &Room::userRemoved, this, &Room::memberListChanged); - connect(this, &Room::memberRenamed, this, &Room::memberListChanged); - qCDebug(MAIN) << "New" << toCString(initialJoinState) << "Room:" << id; + d->displayname = d->calculateDisplayname(); // Set initial "Empty room" name +#ifdef Quotient_E2EE_ENABLED + connectSingleShot(this, &Room::encryption, this, [this, connection](){ + connection->encryptionUpdate(this); + }); + connect(this, &Room::userAdded, this, [this, connection](){ + if(usesEncryption()) { + connection->encryptionUpdate(this); + } + }); + d->groupSessions = connection->loadRoomMegolmSessions(this); + d->currentOutboundMegolmSession = + connection->loadCurrentOutboundMegolmSession(this->id()); + if (d->shouldRotateMegolmSession()) { + d->currentOutboundMegolmSession = nullptr; + } + connect(this, &Room::userRemoved, this, [this](){ + if (!usesEncryption()) { + return; + } + if (d->hasValidMegolmSession()) { + d->createMegolmSession(); + } + qCDebug(E2EE) << "Invalidating current megolm session because user left"; + + }); + + connect(this, &Room::beforeDestruction, this, [=](){ + connection->database()->clearRoomData(id); + }); +#endif + qCDebug(STATE) << "New" << terse << initialJoinState << "Room:" << id; } -Room::~Room() +Room::~Room() { delete d; } + +const QString& Room::id() const { return d->id; } + +QString Room::version() const { - delete d; + const auto v = currentState().query(&RoomCreateEvent::version); + return v && !v->isEmpty() ? *v : QStringLiteral("1"); } -const QString& Room::id() const +bool Room::isUnstable() const { - return d->id; + return !connection()->loadingCapabilities() + && !connection()->stableRoomVersions().contains(version()); } -const Room::Timeline& Room::messageEvents() const +QString Room::predecessorId() const { - return d->timeline; + if (const auto* evt = currentState().get<RoomCreateEvent>()) + return evt->predecessor().roomId; + + return {}; } +Room* Room::predecessor(JoinStates statesFilter) const +{ + if (const auto& predId = predecessorId(); !predId.isEmpty()) + if (auto* r = connection()->room(predId, statesFilter); + r && r->successorId() == id()) + return r; + + return nullptr; +} + +QString Room::successorId() const +{ + return currentState().queryOr(&RoomTombstoneEvent::successorRoomId, + QString()); +} + +Room* Room::successor(JoinStates statesFilter) const +{ + if (const auto& succId = successorId(); !succId.isEmpty()) + if (auto* r = connection()->room(succId, statesFilter); + r && r->predecessorId() == id()) + return r; + + return nullptr; +} + +const Room::Timeline& Room::messageEvents() const { return d->timeline; } + const Room::PendingEvents& Room::pendingEvents() const { return d->unsyncedEvents; } +bool Room::allHistoryLoaded() const +{ + return !d->timeline.empty() && is<RoomCreateEvent>(*d->timeline.front()); +} + QString Room::name() const { - return d->name; + return currentState().content<RoomNameEvent>().value; } QStringList Room::aliases() const { - return d->aliases; + if (const auto* evt = currentState().get<RoomCanonicalAliasEvent>()) { + auto result = evt->altAliases(); + if (!evt->alias().isEmpty()) + result << evt->alias(); + return result; + } + return {}; } -QString Room::canonicalAlias() const +QStringList Room::altAliases() const { - return d->canonicalAlias; + return currentState().content<RoomCanonicalAliasEvent>().altAliases; } -QString Room::displayName() const +QString Room::canonicalAlias() const { - return d->displayname; + return currentState().queryOr(&RoomCanonicalAliasEvent::alias, QString()); } -QString Room::topic() const -{ - return d->topic; +QString Room::displayName() const { return d->displayname; } + +QStringList Room::pinnedEventIds() const { + return currentState().queryOr(&RoomPinnedEvent::pinnedEvents, QStringList()); } -QString Room::avatarMediaId() const +QVector<const Quotient::RoomEvent*> Quotient::Room::pinnedEvents() const { - return d->avatar.mediaId(); + QVector<const RoomEvent*> pinnedEvents; + for (const auto& evtId : pinnedEventIds()) + if (const auto& it = findInTimeline(evtId); it != historyEdge()) + pinnedEvents.append(it->event()); + + return pinnedEvents; } -QUrl Room::avatarUrl() const +QString Room::displayNameForHtml() const { - return d->avatar.url(); + return displayName().toHtmlEscaped(); } -QImage Room::avatar(int dimension) +void Room::refreshDisplayName() { d->updateDisplayname(); } + +QString Room::topic() const { - return avatar(dimension, dimension); + return currentState().queryOr(&RoomTopicEvent::topic, QString()); } +QString Room::avatarMediaId() const { return d->avatar.mediaId(); } + +QUrl Room::avatarUrl() const { return d->avatar.url(); } + +const Avatar& Room::avatarObject() const { return d->avatar; } + +QImage Room::avatar(int dimension) { return avatar(dimension, dimension); } + QImage Room::avatar(int width, int height) { if (!d->avatar.url().isEmpty()) return d->avatar.get(connection(), width, height, - [=] { emit avatarChanged(); }); + [this] { emit avatarChanged(); }); // Use the first (excluding self) user's avatar for direct chats const auto dcUsers = directChatUsers(); - for (auto* u: dcUsers) + for (auto* u : dcUsers) if (u != localUser()) - return u->avatar(width, height, this, [=] { emit avatarChanged(); }); + return u->avatar(width, height, this, [this] { emit avatarChanged(); }); return {}; } @@ -350,176 +657,356 @@ User* Room::user(const QString& userId) const JoinState Room::memberJoinState(User* user) const { - return - d->membersMap.contains(user->name(this), user) ? JoinState::Join : - JoinState::Leave; + return d->membersMap.contains(user->name(this), user) ? JoinState::Join + : JoinState::Leave; } -JoinState Room::joinState() const +Membership Room::memberState(const QString& userId) const { - return d->joinState; + return currentState().queryOr(userId, &RoomMemberEvent::membership, + Membership::Leave); } +bool Room::isMember(const QString& userId) const +{ + return memberState(userId) == Membership::Join; +} + +JoinState Room::joinState() const { return d->joinState; } + void Room::setJoinState(JoinState state) { JoinState oldState = d->joinState; - if( state == oldState ) + if (state == oldState) return; d->joinState = state; - qCDebug(MAIN) << "Room" << id() << "changed state: " - << int(oldState) << "->" << int(state); + qCDebug(STATE) << "Room" << id() << "changed state: " << terse << oldState + << "->" << state; emit joinStateChanged(oldState, state); } -void Room::Private::setLastReadEvent(User* u, QString eventId) -{ - auto& storedId = lastReadEventIds[u]; - if (storedId == eventId) - return; - eventIdReadUsers.remove(storedId, u); - eventIdReadUsers.insert(eventId, u); - swap(storedId, eventId); - emit q->lastReadEventChanged(u); - emit q->readMarkerForUserMoved(u, eventId, storedId); - if (isLocalUser(u)) - { - if (storedId != serverReadMarker) - connection->callApi<PostReadMarkersJob>(id, storedId); - emit q->readMarkerMoved(eventId, storedId); +Omittable<QString> Room::Private::setLastReadReceipt(const QString& userId, + rev_iter_t newMarker, + ReadReceipt newReceipt) +{ + if (newMarker == historyEdge() && !newReceipt.eventId.isEmpty()) + newMarker = q->findInTimeline(newReceipt.eventId); + if (newMarker != historyEdge()) { + // Try to auto-promote the read marker over the user's own messages + // (switch to direct iterators for that). + const auto eagerMarker = find_if(newMarker.base(), syncEdge(), + [=](const TimelineItem& ti) { + return ti->senderId() != userId; + }); + // eagerMarker is now just after the desired event for newMarker + if (eagerMarker != newMarker.base()) { + newMarker = rev_iter_t(eagerMarker); + qDebug(EPHEMERAL) << "Auto-promoted read receipt for" << userId + << "to" << *newMarker; + } + // Fill newReceipt with the event (and, if needed, timestamp) from + // eagerMarker + newReceipt.eventId = (eagerMarker - 1)->event()->id(); + if (newReceipt.timestamp.isNull()) + newReceipt.timestamp = QDateTime::currentDateTime(); } -} + auto& storedReceipt = + lastReadReceipts[userId]; // clazy:exclude=detaching-member + const auto prevEventId = storedReceipt.eventId; + // Check that either the new marker is actually "newer" than the current one + // or, if both markers are at historyEdge(), event ids are different. + // This logic tackles, in particular, the case when the new event is not + // found (most likely, because it's too old and hasn't been fetched from + // the server yet) but there is a previous marker for a user; in that case, + // the previous marker is kept because read receipts are not supposed + // to move backwards. If neither new nor old event is found, the new receipt + // is blindly stored, in a hope it's also "newer" in the timeline. + // NB: with reverse iterators, timeline history edge >= sync edge + if (prevEventId == newReceipt.eventId + || newMarker > q->findInTimeline(prevEventId)) + return {}; -void Room::Private::updateUnreadCount(rev_iter_t from, rev_iter_t to) -{ - Q_ASSERT(from >= timeline.crbegin() && from <= timeline.crend()); - Q_ASSERT(to >= from && to <= timeline.crend()); + // Finally make the change - // Catch a special case when the last read event id refers to an event - // that has just arrived. In this case we should recalculate - // unreadMessages and might need to promote the read marker further - // over local-origin messages. - const auto readMarker = q->readMarker(); - if (readMarker >= from && readMarker < to) - { - promoteReadMarker(q->localUser(), readMarker, true); - return; + auto oldEventReadUsersIt = + eventIdReadUsers.find(prevEventId); // clazy:exclude=detaching-member + if (oldEventReadUsersIt != eventIdReadUsers.end()) { + oldEventReadUsersIt->remove(userId); + if (oldEventReadUsersIt->isEmpty()) + eventIdReadUsers.erase(oldEventReadUsersIt); } + eventIdReadUsers[newReceipt.eventId].insert(userId); + storedReceipt = move(newReceipt); - Q_ASSERT(to <= readMarker); - - QElapsedTimer et; et.start(); - const auto newUnreadMessages = count_if(from, to, - std::bind(&Room::Private::isEventNotable, this, _1)); - if (et.nsecsElapsed() > profilerMinNsecs() / 10) - qCDebug(PROFILER) << "Counting gained unread messages took" << et; - - if(newUnreadMessages > 0) { - // See https://github.com/QMatrixClient/libqmatrixclient/wiki/unread_count - if (unreadMessages < 0) - unreadMessages = 0; + auto dbg = qDebug(EPHEMERAL); // NB: qCDebug can't be used like that + dbg << "The new read receipt for" << userId << "is now at"; + if (newMarker == historyEdge()) + dbg << storedReceipt.eventId; + else + dbg << *newMarker; + } - unreadMessages += newUnreadMessages; - qCDebug(MAIN) << "Room" << q->objectName() << "has gained" - << newUnreadMessages << "unread message(s)," - << (q->readMarker() == timeline.crend() ? - "in total at least" : "in total") - << unreadMessages << "unread message(s)"; - emit q->unreadMessagesChanged(q); + // NB: This method, unlike setLocalLastReadReceipt, doesn't emit + // lastReadEventChanged() to avoid numerous emissions when many read + // receipts arrive. It can be called thousands of times during an initial + // sync, e.g. + // TODO: remove in 0.8 + if (const auto member = q->user(userId); !isLocalUser(member)) + QT_IGNORE_DEPRECATIONS(emit q->readMarkerForUserMoved( + member, prevEventId, storedReceipt.eventId);) + return prevEventId; +} + +Room::Changes Room::Private::setLocalLastReadReceipt(const rev_iter_t& newMarker, + ReadReceipt newReceipt, + bool deferStatsUpdate) +{ + auto prevEventId = + setLastReadReceipt(connection->userId(), newMarker, move(newReceipt)); + if (!prevEventId) + return Change::None; + Changes changes = Change::Other; + if (!deferStatsUpdate) { + if (unreadStats.updateOnMarkerMove(q, q->findInTimeline(*prevEventId), + newMarker)) { + qDebug(MESSAGES) + << "Updated unread event statistics in" << q->objectName() + << "after moving the local read receipt:" << unreadStats; + changes |= Change::UnreadStats; + } + Q_ASSERT(unreadStats.isValidFor(q, newMarker)); // post-check } + emit q->lastReadEventChanged({ connection->userId() }); + return changes; } -void Room::Private::promoteReadMarker(User* u, rev_iter_t newMarker, bool force) +Room::Changes Room::Private::updateStats(const rev_iter_t& from, + const rev_iter_t& to) { - Q_ASSERT_X(u, __FUNCTION__, "User* should not be nullptr"); - Q_ASSERT(newMarker >= timeline.crbegin() && newMarker <= timeline.crend()); - - const auto prevMarker = q->readMarker(u); - if (!force && prevMarker <= newMarker) // Remember, we deal with reverse iterators - return; - - Q_ASSERT(newMarker < timeline.crend()); + Q_ASSERT(from >= timeline.crbegin() && from <= timeline.crend()); + Q_ASSERT(to >= from && to <= timeline.crend()); - // Try to auto-promote the read marker over the user's own messages - // (switch to direct iterators for that). - auto eagerMarker = find_if(newMarker.base(), timeline.cend(), - [=](const TimelineItem& ti) { return ti->senderId() != u->id(); }); + const auto fullyReadMarker = q->fullyReadMarker(); + auto readReceiptMarker = q->localReadReceiptMarker(); + Changes changes = Change::None; + // Correct the read receipt to never be behind the fully read marker + if (readReceiptMarker > fullyReadMarker + && setLocalLastReadReceipt(fullyReadMarker, {}, true)) { + changes |= Change::Other; + readReceiptMarker = q->localReadReceiptMarker(); + qCInfo(MESSAGES) << "The local m.read receipt was behind m.fully_read " + "marker - it's now corrected to be at index" + << readReceiptMarker->index(); + } - setLastReadEvent(u, (*(eagerMarker - 1))->id()); - if (isLocalUser(u)) - { - const auto oldUnreadCount = unreadMessages; - QElapsedTimer et; et.start(); - unreadMessages = count_if(eagerMarker, timeline.cend(), - std::bind(&Room::Private::isEventNotable, this, _1)); - if (et.nsecsElapsed() > profilerMinNsecs() / 10) - qCDebug(PROFILER) << "Recounting unread messages took" << et; + if (fullyReadMarker < from) + return Change::None; // What's arrived is already fully read + + // If there's no read marker in the whole room, initialise it + if (fullyReadMarker == historyEdge() && q->allHistoryLoaded()) + return setFullyReadMarker(timeline.front()->id()); + + // Catch a case when the id in the last fully read marker or the local read + // receipt refers to an event that has just arrived. In this case either + // one (unreadStats) or both statistics should be recalculated to get + // an exact number instead of an estimation (see documentation on + // EventStats::isEstimate). For the same reason (switching from the + // estimate to the exact number) this branch forces returning + // Change::UnreadStats and also possibly Change::PartiallyReadStats, even if + // the estimation luckily matched the exact result. + if (readReceiptMarker < to || changes /*i.e. read receipt was corrected*/) { + unreadStats = EventStats::fromMarker(q, readReceiptMarker); + Q_ASSERT(!unreadStats.isEstimate); + qCDebug(MESSAGES).nospace() << "Recalculated unread event statistics in" + << q->objectName() << ": " << unreadStats; + changes |= Change::UnreadStats; + if (fullyReadMarker < to) { + // Add up to unreadStats instead of counting same events again + partiallyReadStats = EventStats::fromRange(q, readReceiptMarker, + q->fullyReadMarker(), + unreadStats); + Q_ASSERT(!partiallyReadStats.isEstimate); + + qCDebug(MESSAGES).nospace() + << "Recalculated partially read event statistics in " + << q->objectName() << ": " << partiallyReadStats; + return changes | Change::PartiallyReadStats; + } + } - // See https://github.com/QMatrixClient/libqmatrixclient/wiki/unread_count - if (unreadMessages == 0) - unreadMessages = -1; + // As of here, at least the fully read marker (but maybe also read receipt) + // points to somewhere beyond the "oldest" message from the arrived batch - + // add up newly arrived messages to the current stats, instead of a complete + // recalculation. + Q_ASSERT(fullyReadMarker >= to); + + const auto newStats = EventStats::fromRange(q, from, to); + Q_ASSERT(!newStats.isEstimate); + if (newStats.empty()) + return changes; + + const auto doAddStats = [this, &changes, newStats](EventStats& s, + const rev_iter_t& marker, + Change c) { + s.notableCount += newStats.notableCount; + s.highlightCount += newStats.highlightCount; + if (!s.isEstimate) + s.isEstimate = marker == historyEdge(); + changes |= c; + }; - if (force || unreadMessages != oldUnreadCount) - { - if (unreadMessages == -1) - { - qCDebug(MAIN) << "Room" << displayname - << "has no more unread messages"; - } else - qCDebug(MAIN) << "Room" << displayname << "still has" - << unreadMessages << "unread message(s)"; - emit q->unreadMessagesChanged(q); + doAddStats(partiallyReadStats, fullyReadMarker, Change::PartiallyReadStats); + if (readReceiptMarker >= to) { + // readReceiptMarker < to branch shouldn't have been entered + Q_ASSERT(!changes.testFlag(Change::UnreadStats)); + doAddStats(unreadStats, readReceiptMarker, Change::UnreadStats); + } + qCDebug(MESSAGES) << "Room" << q->objectName() << "has gained" << newStats + << "notable/highlighted event(s); total statistics:" + << partiallyReadStats << "since the fully read marker," + << unreadStats << "since read receipt"; + + // Check invariants + Q_ASSERT(partiallyReadStats.isValidFor(q, fullyReadMarker)); + Q_ASSERT(unreadStats.isValidFor(q, readReceiptMarker)); + return changes; +} + +Room::Changes Room::Private::setFullyReadMarker(const QString& eventId) +{ + if (fullyReadUntilEventId == eventId) + return Change::None; + + const auto prevReadMarker = q->fullyReadMarker(); + const auto newReadMarker = q->findInTimeline(eventId); + if (newReadMarker > prevReadMarker) + return Change::None; + + const auto prevFullyReadId = std::exchange(fullyReadUntilEventId, eventId); + qCDebug(MESSAGES) << "Fully read marker in" << q->objectName() // + << "set to" << fullyReadUntilEventId; + + QT_IGNORE_DEPRECATIONS(Changes changes = Change::ReadMarker|Change::Other;) + if (const auto rm = q->fullyReadMarker(); rm != historyEdge()) { + // Pull read receipt if it's behind, and update statistics + changes |= setLocalLastReadReceipt(rm); + if (partiallyReadStats.updateOnMarkerMove(q, prevReadMarker, rm)) { + changes |= Change::PartiallyReadStats; + qCDebug(MESSAGES) + << "Updated partially read event statistics in" + << q->objectName() + << "after moving m.fully_read marker: " << partiallyReadStats; } + Q_ASSERT(partiallyReadStats.isValidFor(q, rm)); // post-check } + emit q->fullyReadMarkerMoved(prevFullyReadId, fullyReadUntilEventId); + // TODO: Remove in 0.8 + QT_IGNORE_DEPRECATIONS( + emit q->readMarkerMoved(prevFullyReadId, fullyReadUntilEventId);) + return changes; } -void Room::Private::markMessagesAsRead(rev_iter_t upToMarker) +void Room::setReadReceipt(const QString& atEventId) { - const auto prevMarker = q->readMarker(); - promoteReadMarker(q->localUser(), upToMarker); - if (prevMarker != upToMarker) - qCDebug(MAIN) << "Marked messages as read until" << *q->readMarker(); - - // We shouldn't send read receipts for the local user's own messages - so - // search earlier messages for the latest message not from the local user - // until the previous last-read message, whichever comes first. - for (; upToMarker < prevMarker; ++upToMarker) - { - if ((*upToMarker)->senderId() != q->localUser()->id()) - { - connection->callApi<PostReceiptJob>(id, "m.read", - (*upToMarker)->id()); - break; - } - } + if (const auto changes = + d->setLocalLastReadReceipt(historyEdge(), { atEventId })) { + connection()->callApi<PostReceiptJob>(BackgroundRequest, id(), + QStringLiteral("m.read"), + QUrl::toPercentEncoding(atEventId)); + d->postprocessChanges(changes); + } else + qCDebug(EPHEMERAL) << "The new read receipt for" << localUser()->id() + << "in" << objectName() + << "is at or behind the old one, skipping"; +} + +bool Room::Private::markMessagesAsRead(const rev_iter_t &upToMarker) +{ + if (upToMarker == q->historyEdge()) + qCWarning(MESSAGES) << "Cannot mark an unknown event in" + << q->objectName() << "as fully read"; + else if (const auto changes = setFullyReadMarker(upToMarker->event()->id())) { + // The assumption below is that if a read receipt was sent on a newer + // event, the homeserver will keep it there instead of reverting to + // m.fully_read + connection->callApi<SetReadMarkerJob>(BackgroundRequest, id, + fullyReadUntilEventId, + fullyReadUntilEventId); + postprocessChanges(changes); + return true; + } else + qCDebug(MESSAGES) << "Event" << *upToMarker << "in" << q->objectName() + << "is behind the current fully read marker at" + << *q->fullyReadMarker() + << "- won't move fully read marker back in timeline"; + return false; } -void Room::markMessagesAsRead(QString uptoEventId) +void Room::markMessagesAsRead(const QString& uptoEventId) { d->markMessagesAsRead(findInTimeline(uptoEventId)); } void Room::markAllMessagesAsRead() { - if (!d->timeline.empty()) - d->markMessagesAsRead(d->timeline.crbegin()); + d->markMessagesAsRead(d->timeline.crbegin()); } -bool Room::hasUnreadMessages() const +bool Room::canSwitchVersions() const { - return unreadCount() >= 0; + if (!successorId().isEmpty()) + return false; // No one can upgrade a room that's already upgraded + + if (const auto* plEvt = currentState().get<RoomPowerLevelsEvent>()) { + const auto currentUserLevel = + plEvt->powerLevelForUser(localUser()->id()); + const auto tombstonePowerLevel = + plEvt->powerLevelForState("m.room.tombstone"_ls); + return currentUserLevel >= tombstonePowerLevel; + } + return true; +} + +bool Room::isEventNotable(const TimelineItem &ti) const +{ + const auto& evt = *ti; + const auto* rme = ti.viewAs<RoomMessageEvent>(); + return !evt.isRedacted() + && (is<RoomTopicEvent>(evt) || is<RoomNameEvent>(evt) + || is<RoomAvatarEvent>(evt) || is<RoomTombstoneEvent>(evt) + || (rme && rme->msgtype() != MessageEventType::Notice + && rme->replacedEvent().isEmpty())) + && evt.senderId() != localUser()->id(); } -int Room::unreadCount() const +Notification Room::notificationFor(const TimelineItem &ti) const { - return d->unreadMessages; + return d->notifications.value(ti->id()); } -Room::rev_iter_t Room::timelineEdge() const +Notification Room::checkForNotifications(const TimelineItem &ti) { - return d->timeline.crend(); + return { Notification::None }; } +bool Room::hasUnreadMessages() const { return !d->partiallyReadStats.empty(); } + +int countFromStats(const EventStats& s) +{ + return s.empty() ? -1 : int(s.notableCount); +} + +int Room::unreadCount() const { return countFromStats(partiallyReadStats()); } + +EventStats Room::partiallyReadStats() const { return d->partiallyReadStats; } + +EventStats Room::unreadStats() const { return d->unreadStats; } + +Room::rev_iter_t Room::historyEdge() const { return d->historyEdge(); } + +Room::Timeline::const_iterator Room::syncEdge() const { return d->syncEdge(); } + TimelineItem::index_t Room::minTimelineIndex() const { return d->timeline.empty() ? 0 : d->timeline.front().index(); @@ -532,33 +1019,91 @@ TimelineItem::index_t Room::maxTimelineIndex() const bool Room::isValidIndex(TimelineItem::index_t timelineIndex) const { - return !d->timeline.empty() && - timelineIndex >= minTimelineIndex() && - timelineIndex <= maxTimelineIndex(); + return !d->timeline.empty() && timelineIndex >= minTimelineIndex() + && timelineIndex <= maxTimelineIndex(); } Room::rev_iter_t Room::findInTimeline(TimelineItem::index_t index) const { - return timelineEdge() - - (isValidIndex(index) ? index - minTimelineIndex() + 1 : 0); + return historyEdge() + - (isValidIndex(index) ? index - minTimelineIndex() + 1 : 0); } Room::rev_iter_t Room::findInTimeline(const QString& evtId) const { - if (!d->timeline.empty() && d->eventsIndex.contains(evtId)) - { + if (!d->timeline.empty() && d->eventsIndex.contains(evtId)) { auto it = findInTimeline(d->eventsIndex.value(evtId)); - Q_ASSERT((*it)->id() == evtId); + Q_ASSERT(it != historyEdge() && (*it)->id() == evtId); return it; } - return timelineEdge(); + return historyEdge(); +} + +Room::PendingEvents::iterator Room::findPendingEvent(const QString& txnId) +{ + return std::find_if(d->unsyncedEvents.begin(), d->unsyncedEvents.end(), + [txnId](const auto& item) { + return item->transactionId() == txnId; + }); +} + +Room::PendingEvents::const_iterator +Room::findPendingEvent(const QString& txnId) const +{ + return std::find_if(d->unsyncedEvents.cbegin(), d->unsyncedEvents.cend(), + [txnId](const auto& item) { + return item->transactionId() == txnId; + }); +} + +const Room::RelatedEvents Room::relatedEvents( + const QString& evtId, EventRelation::reltypeid_t relType) const +{ + return d->relations.value({ evtId, relType }); +} + +const Room::RelatedEvents Room::relatedEvents( + const RoomEvent& evt, EventRelation::reltypeid_t relType) const +{ + return relatedEvents(evt.id(), relType); +} + +const RoomCreateEvent* Room::creation() const +{ + return currentState().get<RoomCreateEvent>(); } -bool Room::displayed() const +const RoomTombstoneEvent *Room::tombstone() const { - return d->displayed; + return currentState().get<RoomTombstoneEvent>(); } +void Room::Private::getAllMembers() +{ + // If already loaded or already loading, there's nothing to do here. + if (q->joinedCount() <= membersMap.size() || isJobPending(allMembersJob)) + return; + + allMembersJob = connection->callApi<GetMembersByRoomJob>( + id, connection->nextBatchToken(), "join"); + auto nextIndex = timeline.empty() ? 0 : timeline.back().index() + 1; + connect(allMembersJob, &BaseJob::success, q, [this, nextIndex] { + Q_ASSERT(timeline.empty() || nextIndex <= q->maxTimelineIndex() + 1); + auto roomChanges = updateStateFrom(allMembersJob->chunk()); + // Replay member events that arrived after the point for which + // the full members list was requested. + if (!timeline.empty()) + for (auto it = q->findInTimeline(nextIndex).base(); + it != syncEdge(); ++it) + if (is<RoomMemberEvent>(**it)) + roomChanges |= q->processStateEvent(**it); + postprocessChanges(roomChanges); + emit q->allMembersLoaded(); + }); +} + +bool Room::displayed() const { return d->displayed; } + void Room::setDisplayed(bool displayed) { if (d->displayed == displayed) @@ -566,17 +1111,11 @@ void Room::setDisplayed(bool displayed) d->displayed = displayed; emit displayedChanged(displayed); - if( displayed ) - { - resetHighlightCount(); - resetNotificationCount(); - } + if (displayed) + d->getAllMembers(); } -QString Room::firstDisplayedEventId() const -{ - return d->firstDisplayedEventId; -} +QString Room::firstDisplayedEventId() const { return d->firstDisplayedEventId; } Room::rev_iter_t Room::firstDisplayedMarker() const { @@ -588,6 +1127,11 @@ void Room::setFirstDisplayedEventId(const QString& eventId) if (d->firstDisplayedEventId == eventId) return; + if (!eventId.isEmpty() && findInTimeline(eventId) == historyEdge()) + qCWarning(MESSAGES) + << eventId + << "is marked as first displayed but doesn't seem to be loaded"; + d->firstDisplayedEventId = eventId; emit firstDisplayedEventChanged(); } @@ -598,10 +1142,7 @@ void Room::setFirstDisplayedEvent(TimelineItem::index_t index) setFirstDisplayedEventId(findInTimeline(index)->event()->id()); } -QString Room::lastDisplayedEventId() const -{ - return d->lastDisplayedEventId; -} +QString Room::lastDisplayedEventId() const { return d->lastDisplayedEventId; } Room::rev_iter_t Room::lastDisplayedMarker() const { @@ -613,6 +1154,12 @@ void Room::setLastDisplayedEventId(const QString& eventId) if (d->lastDisplayedEventId == eventId) return; + const auto marker = findInTimeline(eventId); + if (!eventId.isEmpty() && marker == historyEdge()) + qCWarning(MESSAGES) + << eventId + << "is marked as last displayed but doesn't seem to be loaded"; + d->lastDisplayedEventId = eventId; emit lastDisplayedEventChanged(); } @@ -626,47 +1173,84 @@ void Room::setLastDisplayedEvent(TimelineItem::index_t index) Room::rev_iter_t Room::readMarker(const User* user) const { Q_ASSERT(user); - return findInTimeline(d->lastReadEventIds.value(user)); + return findInTimeline(lastReadReceipt(user->id()).eventId); } -Room::rev_iter_t Room::readMarker() const +Room::rev_iter_t Room::readMarker() const { return fullyReadMarker(); } + +QString Room::readMarkerEventId() const { return lastFullyReadEventId(); } + +ReadReceipt Room::lastReadReceipt(const QString& userId) const { - return readMarker(localUser()); + return d->lastReadReceipts.value(userId); } -QString Room::readMarkerEventId() const +ReadReceipt Room::lastLocalReadReceipt() const { - return d->lastReadEventIds.value(localUser()); + return d->lastReadReceipts.value(localUser()->id()); } -QList<User*> Room::usersAtEventId(const QString& eventId) { - return d->eventIdReadUsers.values(eventId); +Room::rev_iter_t Room::localReadReceiptMarker() const +{ + return findInTimeline(lastLocalReadReceipt().eventId); } -int Room::notificationCount() const +QString Room::lastFullyReadEventId() const { return d->fullyReadUntilEventId; } + +Room::rev_iter_t Room::fullyReadMarker() const { - return d->notificationCount; + return findInTimeline(d->fullyReadUntilEventId); } -void Room::resetNotificationCount() +QSet<QString> Room::userIdsAtEvent(const QString& eventId) { - if( d->notificationCount == 0 ) - return; - d->notificationCount = 0; - emit notificationCountChanged(this); + return d->eventIdReadUsers.value(eventId); +} + +QSet<User*> Room::usersAtEventId(const QString& eventId) +{ + const auto& userIds = d->eventIdReadUsers.value(eventId); + QSet<User*> users; + users.reserve(userIds.size()); + for (const auto& uId : userIds) + users.insert(user(uId)); + return users; } -int Room::highlightCount() const +qsizetype Room::notificationCount() const { - return d->highlightCount; + return d->unreadStats.notableCount; } +void Room::resetNotificationCount() +{ + if (d->unreadStats.notableCount == 0) + return; + d->unreadStats.notableCount = 0; + emit notificationCountChanged(); +} + +qsizetype Room::highlightCount() const { return d->serverHighlightCount; } + void Room::resetHighlightCount() { - if( d->highlightCount == 0 ) + if (d->serverHighlightCount == 0) return; - d->highlightCount = 0; - emit highlightCountChanged(this); + d->serverHighlightCount = 0; + emit highlightCountChanged(); +} + +void Room::switchVersion(QString newVersion) +{ + if (!successorId().isEmpty()) { + Q_ASSERT(!successorId().isEmpty()); + emit upgradeFailed(tr("The room is already upgraded")); + } + if (auto* job = connection()->callApi<UpgradeRoomJob>(id(), newVersion)) + connect(job, &BaseJob::failure, this, + [this, job] { emit upgradeFailed(job->errorString()); }); + else + emit upgradeFailed(tr("Couldn't initiate upgrade")); } bool Room::hasAccountData(const QString& type) const @@ -681,30 +1265,21 @@ const EventPtr& Room::accountData(const QString& type) const return it != d->accountData.end() ? it->second : NoEventPtr; } -QStringList Room::tagNames() const -{ - return d->tags.keys(); -} +QStringList Room::tagNames() const { return d->tags.keys(); } -TagsMap Room::tags() const -{ - return d->tags; -} +TagsMap Room::tags() const { return d->tags; } -TagRecord Room::tag(const QString& name) const -{ - return d->tags.value(name); -} +TagRecord Room::tag(const QString& name) const { return d->tags.value(name); } std::pair<bool, QString> validatedTag(QString name) { - if (name.contains('.')) + if (name.isEmpty() || name.indexOf('.', 1) != -1) return { false, name }; - qWarning(MAIN) << "The tag" << name - << "doesn't follow the CS API conventions"; + qCWarning(MAIN) << "The tag" << name + << "doesn't follow the CS API conventions"; name.prepend("u."); - qWarning(MAIN) << "Using " << name << "instead"; + qCWarning(MAIN) << "Using " << name << "instead"; return { true, name }; } @@ -712,8 +1287,8 @@ std::pair<bool, QString> validatedTag(QString name) void Room::addTag(const QString& name, const TagRecord& record) { const auto& checkRes = validatedTag(name); - if (d->tags.contains(name) || - (checkRes.first && d->tags.contains(checkRes.second))) + if (d->tags.contains(name) + || (checkRes.first && d->tags.contains(checkRes.second))) return; emit tagsAboutToChange(); @@ -725,13 +1300,12 @@ void Room::addTag(const QString& name, const TagRecord& record) void Room::addTag(const QString& name, float order) { - addTag(name, TagRecord{order}); + addTag(name, TagRecord { order }); } void Room::removeTag(const QString& name) { - if (d->tags.contains(name)) - { + if (d->tags.contains(name)) { emit tagsAboutToChange(); d->tags.remove(name); emit tagsChanged(); @@ -739,133 +1313,149 @@ void Room::removeTag(const QString& name) } else if (!name.startsWith("u.")) removeTag("u." + name); else - qWarning(MAIN) << "Tag" << name << "on room" << objectName() + qCWarning(MAIN) << "Tag" << name << "on room" << objectName() << "not found, nothing to remove"; } -void Room::setTags(TagsMap newTags) +void Room::setTags(TagsMap newTags, ActionScope applyOn) { + bool propagate = applyOn != ActionScope::ThisRoomOnly; + auto joinStates = + applyOn == ActionScope::WithinSameState ? joinState() : + applyOn == ActionScope::OmitLeftState ? JoinState::Join|JoinState::Invite : + JoinState::Join|JoinState::Invite|JoinState::Leave; + if (propagate) { + for (auto* r = this; (r = r->predecessor(joinStates));) + r->setTags(newTags, ActionScope::ThisRoomOnly); + } + d->setTags(move(newTags)); connection()->callApi<SetAccountDataPerRoomJob>( - localUser()->id(), id(), TagEvent::matrixTypeId(), - TagEvent(d->tags).contentJson()); + localUser()->id(), id(), TagEvent::TypeId, + Quotient::toJson(TagEvent::content_type { d->tags })); + + if (propagate) { + for (auto* r = this; (r = r->successor(joinStates));) + r->setTags(d->tags, ActionScope::ThisRoomOnly); + } } -void Room::Private::setTags(TagsMap newTags) +void Room::Private::setTags(TagsMap&& newTags) { emit q->tagsAboutToChange(); const auto keys = newTags.keys(); - for (const auto& k: keys) - { - const auto& checkRes = validatedTag(k); - if (checkRes.first) - { - if (newTags.contains(checkRes.second)) + for (const auto& k : keys) + if (const auto& [adjusted, adjustedTag] = validatedTag(k); adjusted) { + if (newTags.contains(adjustedTag)) newTags.remove(k); else - newTags.insert(checkRes.second, newTags.take(k)); + newTags.insert(adjustedTag, newTags.take(k)); } - } + tags = move(newTags); - qCDebug(MAIN) << "Room" << q->objectName() << "is tagged with" - << q->tagNames().join(", "); + qCDebug(STATE) << "Room" << q->objectName() << "is tagged with" + << q->tagNames().join(QStringLiteral(", ")); emit q->tagsChanged(); } -bool Room::isFavourite() const +bool Room::isFavourite() const { return d->tags.contains(FavouriteTag); } + +bool Room::isLowPriority() const { return d->tags.contains(LowPriorityTag); } + +bool Room::isServerNoticeRoom() const { - return d->tags.contains(FavouriteTag); + return d->tags.contains(ServerNoticeTag); } -bool Room::isLowPriority() const +bool Room::isDirectChat() const { return connection()->isDirectChat(id()); } + +QList<User*> Room::directChatUsers() const { - return d->tags.contains(LowPriorityTag); + return connection()->directChatUsers(this); } -bool Room::isDirectChat() const +QUrl Room::makeMediaUrl(const QString& eventId, const QUrl& mxcUrl) const { - return connection()->isDirectChat(id()); + auto url = connection()->makeMediaUrl(mxcUrl); + QUrlQuery q(url.query()); + Q_ASSERT(q.hasQueryItem("user_id")); + q.addQueryItem("room_id", id()); + q.addQueryItem("event_id", eventId); + url.setQuery(q); + return url; } -QList<User*> Room::directChatUsers() const +QString safeFileName(QString rawName) { - return connection()->directChatUsers(this); + return rawName.replace(QRegularExpression("[/\\<>|\"*?:]"), "_"); } const RoomMessageEvent* Room::Private::getEventWithFile(const QString& eventId) const { auto evtIt = q->findInTimeline(eventId); - if (evtIt != timeline.rend() && is<RoomMessageEvent>(**evtIt)) - { + if (evtIt != timeline.rend() && is<RoomMessageEvent>(**evtIt)) { auto* event = evtIt->viewAs<RoomMessageEvent>(); if (event->hasFileContent()) return event; } - qWarning() << "No files to download in event" << eventId; + qCWarning(MAIN) << "No files to download in event" << eventId; return nullptr; } QString Room::Private::fileNameToDownload(const RoomMessageEvent* event) const { - Q_ASSERT(event->hasFileContent()); + Q_ASSERT(event && event->hasFileContent()); const auto* fileInfo = event->content()->fileInfo(); QString fileName; if (!fileInfo->originalName.isEmpty()) - { - fileName = QFileInfo(fileInfo->originalName).fileName(); - } - else if (!event->plainBody().isEmpty()) - { - // Having no better options, assume that the body has - // the original file URL or at least the file name. - QUrl u { event->plainBody() }; - if (u.isValid()) - fileName = QFileInfo(u.path()).fileName(); + fileName = QFileInfo(safeFileName(fileInfo->originalName)).fileName(); + else if (QUrl u { event->plainBody() }; u.isValid()) { + qDebug(MAIN) << event->id() + << "has no file name supplied but the event body " + "looks like a URL - using the file name from it"; + fileName = u.fileName(); } - // Check the file name for sanity - if (fileName.isEmpty() || !QTemporaryFile(fileName).open()) - return "file." % fileInfo->mimeType.preferredSuffix(); - - if (QSysInfo::productType() == "windows") - { - const auto& suffixes = fileInfo->mimeType.suffixes(); - if (!suffixes.isEmpty() && - std::none_of(suffixes.begin(), suffixes.end(), - [&fileName] (const QString& s) { - return fileName.endsWith(s); })) + if (fileName.isEmpty()) + return safeFileName(fileInfo->mediaId()).replace('.', '-') % '.' + % fileInfo->mimeType.preferredSuffix(); + + if (QSysInfo::productType() == "windows") { + if (const auto& suffixes = fileInfo->mimeType.suffixes(); + !suffixes.isEmpty() + && std::none_of(suffixes.begin(), suffixes.end(), + [&fileName](const QString& s) { + return fileName.endsWith(s); + })) return fileName % '.' % fileInfo->mimeType.preferredSuffix(); } return fileName; } -QUrl Room::urlToThumbnail(const QString& eventId) +QUrl Room::urlToThumbnail(const QString& eventId) const { if (auto* event = d->getEventWithFile(eventId)) - if (event->hasThumbnail()) - { + if (event->hasThumbnail()) { auto* thumbnail = event->content()->thumbnailInfo(); Q_ASSERT(thumbnail != nullptr); - return MediaThumbnailJob::makeRequestUrl(connection()->homeserver(), - thumbnail->url, thumbnail->imageSize); + return connection()->getUrlForApi<MediaThumbnailJob>( + thumbnail->url(), thumbnail->imageSize); } - qDebug() << "Event" << eventId << "has no thumbnail"; + qCDebug(MAIN) << "Event" << eventId << "has no thumbnail"; return {}; } -QUrl Room::urlToDownload(const QString& eventId) +QUrl Room::urlToDownload(const QString& eventId) const { - if (auto* event = d->getEventWithFile(eventId)) - { + if (auto* event = d->getEventWithFile(eventId)) { auto* fileInfo = event->content()->fileInfo(); Q_ASSERT(fileInfo != nullptr); - return DownloadFileJob::makeRequestUrl(connection()->homeserver(), - fileInfo->url); + return connection()->getUrlForApi<DownloadFileJob>(fileInfo->url()); } return {}; } -QString Room::fileNameToDownload(const QString& eventId) +QString Room::fileNameToDownload(const QString& eventId) const { if (auto* event = d->getEventWithFile(eventId)) return d->fileNameToDownload(event); @@ -874,8 +1464,8 @@ QString Room::fileNameToDownload(const QString& eventId) FileTransferInfo Room::fileTransferInfo(const QString& id) const { - auto infoIt = d->fileTransfers.find(id); - if (infoIt == d->fileTransfers.end()) + const auto infoIt = d->fileTransfers.constFind(id); + if (infoIt == d->fileTransfers.cend()) return {}; // FIXME: Add lib tests to make sure FileTransferInfo::status stays @@ -883,79 +1473,208 @@ FileTransferInfo Room::fileTransferInfo(const QString& id) const qint64 progress = infoIt->progress; qint64 total = infoIt->total; - if (total > INT_MAX) - { + if (total > INT_MAX) { // JavaScript doesn't deal with 64-bit integers; scale down if necessary progress = llround(double(progress) / total * INT_MAX); total = INT_MAX; } -#ifdef WORKAROUND_EXTENDED_INITIALIZER_LIST - FileTransferInfo fti; - fti.status = infoIt->status; - fti.progress = int(progress); - fti.total = int(total); - fti.localDir = QUrl::fromLocalFile(infoIt->localFileInfo.absolutePath()); - fti.localPath = QUrl::fromLocalFile(infoIt->localFileInfo.absoluteFilePath()); - return fti; -#else - return { infoIt->status, int(progress), int(total), - QUrl::fromLocalFile(infoIt->localFileInfo.absolutePath()), - QUrl::fromLocalFile(infoIt->localFileInfo.absoluteFilePath()) - }; -#endif + return { infoIt->status, + infoIt->isUpload, + int(progress), + int(total), + QUrl::fromLocalFile(infoIt->localFileInfo.absolutePath()), + QUrl::fromLocalFile(infoIt->localFileInfo.absoluteFilePath()) }; } -QString Room::prettyPrint(const QString& plainText) const +QUrl Room::fileSource(const QString& id) const { - return QMatrixClient::prettyPrint(plainText); + auto url = urlToDownload(id); + if (url.isValid()) + return url; + + // No urlToDownload means it's a pending or completed upload. + auto infoIt = d->fileTransfers.constFind(id); + if (infoIt != d->fileTransfers.cend()) + return QUrl::fromLocalFile(infoIt->localFileInfo.absoluteFilePath()); + + qCWarning(MAIN) << "File source for identifier" << id << "not found"; + return {}; } -QList< User* > Room::usersTyping() const +QString Room::prettyPrint(const QString& plainText) const { - return d->usersTyping; + return Quotient::prettyPrint(plainText); } -QList< User* > Room::membersLeft() const +QList<User*> Room::usersTyping() const { return d->usersTyping; } + +QList<User*> Room::membersLeft() const { return d->membersLeft; } + +QList<User*> Room::users() const { return d->membersMap.values(); } + +QStringList Room::memberNames() const { - return d->membersLeft; + return safeMemberNames(); } -QList< User* > Room::users() const +QStringList Room::safeMemberNames() const { - return d->membersMap.values(); + QStringList res; + res.reserve(d->membersMap.size()); + for (const auto* u: std::as_const(d->membersMap)) + res.append(safeMemberName(u->id())); + + return res; } -QStringList Room::memberNames() const +QStringList Room::htmlSafeMemberNames() const { QStringList res; - for (auto u : qAsConst(d->membersMap)) - res.append( roomMembername(u) ); + res.reserve(d->membersMap.size()); + for (const auto* u: std::as_const(d->membersMap)) + res.append(htmlSafeMemberName(u->id())); return res; } -int Room::memberCount() const +int Room::timelineSize() const { return int(d->timeline.size()); } + +bool Room::usesEncryption() const { - return d->membersMap.size(); + return !currentState() + .queryOr(&EncryptionEvent::algorithm, QString()) + .isEmpty(); } -int Room::timelineSize() const +const StateEvent* Room::getCurrentState(const QString& evtType, + const QString& stateKey) const { - return int(d->timeline.size()); + return d->getCurrentState({ evtType, stateKey }); } -bool Room::usesEncryption() const +RoomStateView Room::currentState() const { - return !d->encryptionAlgorithm.isEmpty(); + return d->currentState; } -void Room::Private::insertMemberIntoMap(User *u) +RoomEventPtr Room::decryptMessage(const EncryptedEvent& encryptedEvent) { - const auto userName = u->name(q); - // If there is exactly one namesake of the added user, signal member renaming - // for that other one because the two should be disambiguated now. - auto namesakes = membersMap.values(userName); +#ifndef Quotient_E2EE_ENABLED + Q_UNUSED(encryptedEvent) + qCWarning(E2EE) << "End-to-end encryption (E2EE) support is turned off."; + return {}; +#else // Quotient_E2EE_ENABLED + if (encryptedEvent.algorithm() != MegolmV1AesSha2AlgoKey) { + qWarning(E2EE) << "Algorithm of the encrypted event with id" + << encryptedEvent.id() << "is not decryptable by the current device"; + return {}; + } + QString decrypted = d->groupSessionDecryptMessage( + encryptedEvent.ciphertext(), encryptedEvent.sessionId(), + encryptedEvent.id(), encryptedEvent.originTimestamp(), + encryptedEvent.senderId()); + if (decrypted.isEmpty()) { + // qCWarning(E2EE) << "Encrypted message is empty"; + return {}; + } + auto decryptedEvent = encryptedEvent.createDecrypted(decrypted); + if (decryptedEvent->roomId() == id()) { + return decryptedEvent; + } + qCWarning(E2EE) << "Decrypted event" << encryptedEvent.id() << "not for this room; discarding."; + return {}; +#endif // Quotient_E2EE_ENABLED +} + +void Room::handleRoomKeyEvent(const RoomKeyEvent& roomKeyEvent, + const QString& senderId, + const QString& olmSessionId) +{ +#ifndef Quotient_E2EE_ENABLED + Q_UNUSED(roomKeyEvent) + Q_UNUSED(senderId) + Q_UNUSED(olmSessionId) + qCWarning(E2EE) << "End-to-end encryption (E2EE) support is turned off."; +#else // Quotient_E2EE_ENABLED + if (roomKeyEvent.algorithm() != MegolmV1AesSha2AlgoKey) { + qCWarning(E2EE) << "Ignoring unsupported algorithm" + << roomKeyEvent.algorithm() << "in m.room_key event"; + } + if (d->addInboundGroupSession(roomKeyEvent.sessionId(), + roomKeyEvent.sessionKey(), senderId, + olmSessionId)) { + qCWarning(E2EE) << "added new inboundGroupSession:" + << d->groupSessions.size(); + auto undecryptedEvents = d->undecryptedEvents[roomKeyEvent.sessionId()]; + for (const auto& eventId : undecryptedEvents) { + const auto pIdx = d->eventsIndex.constFind(eventId); + if (pIdx == d->eventsIndex.cend()) + continue; + auto& ti = d->timeline[Timeline::size_type(*pIdx - minTimelineIndex())]; + if (auto encryptedEvent = ti.viewAs<EncryptedEvent>()) { + if (auto decrypted = decryptMessage(*encryptedEvent)) { + // The reference will survive the pointer being moved + auto& decryptedEvent = *decrypted; + auto oldEvent = ti.replaceEvent(std::move(decrypted)); + decryptedEvent.setOriginalEvent(std::move(oldEvent)); + emit replacedEvent(ti.event(), decryptedEvent.originalEvent()); + d->undecryptedEvents[roomKeyEvent.sessionId()] -= eventId; + } + } + } + } +#endif // Quotient_E2EE_ENABLED +} + +int Room::joinedCount() const +{ + return d->summary.joinedMemberCount.value_or(d->membersMap.size()); +} + +int Room::invitedCount() const +{ + // TODO: Store invited users in Room too + Q_ASSERT(d->summary.invitedMemberCount.has_value()); + return d->summary.invitedMemberCount.value_or(0); +} + +int Room::totalMemberCount() const { return joinedCount() + invitedCount(); } + +GetRoomEventsJob* Room::eventsHistoryJob() const { return d->eventsHistoryJob; } + +Room::Changes Room::Private::setSummary(RoomSummary&& newSummary) +{ + if (!summary.merge(newSummary)) + return Change::None; + qCDebug(STATE).nospace().noquote() + << "Updated room summary for " << q->objectName() << ": " << summary; + return Change::Summary; +} + +void Room::Private::insertMemberIntoMap(User* u) +{ + const auto maybeUserName = + currentState.query(u->id(), &RoomMemberEvent::newDisplayName); + if (!maybeUserName) + qCWarning(MEMBERS) << "insertMemberIntoMap():" << u->id() + << "has no name (even empty)"; + const auto userName = maybeUserName.value_or(QString()); + const auto namesakes = membersMap.values(userName); + qCDebug(MEMBERS) << "insertMemberIntoMap(), user" << u->id() + << "with name" << userName << '-' + << namesakes.size() << "namesake(s) found"; + + // Callers should make sure they are not adding an existing user once more + Q_ASSERT(!namesakes.contains(u)); + if (namesakes.contains(u)) { // Release version whines but continues + qCCritical(MEMBERS) << "Trying to add a user" << u->id() << "to room" + << q->objectName() << "but that's already in it"; + return; + } + + // If there is exactly one namesake of the added user, signal member + // renaming for that other one because the two should be disambiguated now if (namesakes.size() == 1) emit q->memberAboutToRename(namesakes.front(), namesakes.front()->fullName(q)); @@ -964,278 +1683,470 @@ void Room::Private::insertMemberIntoMap(User *u) emit q->memberRenamed(namesakes.front()); } -void Room::Private::renameMember(User* u, QString oldName) +void Room::Private::removeMemberFromMap(User* u) { - if (u->name(q) == oldName) - { - qCWarning(MAIN) << "Room::Private::renameMember(): the user " - << u->fullName(q) - << "is already known in the room under a new name."; - } - else if (membersMap.contains(oldName, u)) - { - removeMemberFromMap(oldName, u); - insertMemberIntoMap(u); - } - emit q->memberRenamed(u); -} + const auto userName = currentState.queryOr(u->id(), + &RoomMemberEvent::newDisplayName, + QString()); -void Room::Private::removeMemberFromMap(const QString& username, User* u) -{ + qCDebug(MEMBERS) << "removeMemberFromMap(), username" << userName + << "for user" << u->id(); User* namesake = nullptr; - auto namesakes = membersMap.values(username); - if (namesakes.size() == 2) - { - namesake = namesakes.front() == u ? namesakes.back() : namesakes.front(); + auto namesakes = membersMap.values(userName); + // If there was one namesake besides the removed user, signal member + // renaming for it because it doesn't need to be disambiguated any more. + if (namesakes.size() == 2) { + namesake = + namesakes.front() == u ? namesakes.back() : namesakes.front(); Q_ASSERT_X(namesake != u, __FUNCTION__, "Room members list is broken"); - emit q->memberAboutToRename(namesake, username); + emit q->memberAboutToRename(namesake, userName); + } + if (membersMap.remove(userName, u) == 0) { + qCDebug(MEMBERS) << "No entries removed; checking the whole list"; + // Unless at the stage of initial filling, this no removed entries + // is suspicious; double-check that this user is not found in + // the whole map, and stop (for debug builds) or shout in the logs + // (for release builds) if there's one. That search is O(n), which + // may come rather expensive for larger rooms. + QElapsedTimer et; + auto it = std::find(membersMap.cbegin(), membersMap.cend(), u); + if (et.nsecsElapsed() > profilerMinNsecs() / 10) + qCDebug(MEMBERS) << "...done in" << et; + if (it != membersMap.cend()) { + // The assert (still) does more harm than good, it seems +// Q_ASSERT_X(false, __FUNCTION__, +// "Mismatched name in the room members list"); + qCCritical(MEMBERS) << "Mismatched name in the room members list;" + " avoiding the list corruption"; + membersMap.remove(it.key(), u); + } } - membersMap.remove(username, u); - // If there was one namesake besides the removed user, signal member renaming - // for it because it doesn't need to be disambiguated anymore. - // TODO: Think about left users. if (namesake) emit q->memberRenamed(namesake); } inline auto makeErrorStr(const Event& e, QByteArray msg) { - return msg.append("; event dump follows:\n").append(e.originalJson()); + return msg.append("; event dump follows:\n") + .append(QJsonDocument(e.fullJson()).toJson()); } -Room::Timeline::difference_type Room::Private::moveEventsToTimeline( - RoomEventsRange events, EventsPlacement placement) +Room::Timeline::size_type +Room::Private::moveEventsToTimeline(RoomEventsRange events, + EventsPlacement placement) { Q_ASSERT(!events.empty()); // Historical messages arrive in newest-to-oldest order, so the process for - // them is symmetric to the one for new messages. - auto index = timeline.empty() ? -int(placement) : - placement == Older ? timeline.front().index() : - timeline.back().index(); + // them is almost symmetric to the one for new messages. New messages get + // appended from index 0; old messages go backwards from index -1. + auto index = timeline.empty() + ? -((placement + 1) / 2) /* 1 -> -1; -1 -> 0 */ + : placement == Older ? timeline.front().index() + : timeline.back().index(); auto baseIndex = index; - for (auto&& e: events) - { + for (auto&& e : events) { const auto eId = e->id(); Q_ASSERT_X(e, __FUNCTION__, "Attempt to add nullptr to timeline"); - Q_ASSERT_X(!eId.isEmpty(), __FUNCTION__, - makeErrorStr(*e, - "Event with empty id cannot be in the timeline")); - Q_ASSERT_X(!eventsIndex.contains(eId), __FUNCTION__, - makeErrorStr(*e, "Event is already in the timeline; " - "incoming events were not properly deduplicated")); - if (placement == Older) - timeline.emplace_front(move(e), --index); - else - timeline.emplace_back(move(e), ++index); + Q_ASSERT_X( + !eId.isEmpty(), __FUNCTION__, + makeErrorStr(*e, "Event with empty id cannot be in the timeline")); + Q_ASSERT_X( + !eventsIndex.contains(eId), __FUNCTION__, + makeErrorStr(*e, "Event is already in the timeline; " + "incoming events were not properly deduplicated")); + const auto& ti = placement == Older + ? timeline.emplace_front(move(e), --index) + : timeline.emplace_back(move(e), ++index); eventsIndex.insert(eId, index); + if (auto n = q->checkForNotifications(ti); n.type != Notification::None) + notifications.insert(e->id(), n); Q_ASSERT(q->findInTimeline(eId)->event()->id() == eId); } - const auto insertedSize = (index - baseIndex) * int(placement); + const auto insertedSize = (index - baseIndex) * placement; Q_ASSERT(insertedSize == int(events.size())); - return insertedSize; + return Timeline::size_type(insertedSize); +} + +QString Room::memberName(const QString& mxId) const +{ + // See https://github.com/matrix-org/matrix-doc/issues/1375 + if (const auto rme = currentState().get<RoomMemberEvent>(mxId)) { + if (rme->newDisplayName()) + return *rme->newDisplayName(); + if (rme->prevContent() && rme->prevContent()->displayName) + return *rme->prevContent()->displayName; + } + return {}; } QString Room::roomMembername(const User* u) const { + Q_ASSERT(u != nullptr); + return disambiguatedMemberName(u->id()); +} + +QString Room::roomMembername(const QString& userId) const +{ + return disambiguatedMemberName(userId); +} + +inline QString makeFullUserName(const QString& displayName, const QString& mxId) +{ + return displayName % " (" % mxId % ')'; +} + +QString Room::disambiguatedMemberName(const QString& mxId) const +{ // See the CS spec, section 11.2.2.3 - const auto username = u->name(this); + const auto username = memberName(mxId); if (username.isEmpty()) - return u->id(); + return mxId; auto namesakesIt = qAsConst(d->membersMap).find(username); // We expect a user to be a member of the room - but technically it is - // possible to invoke roomMemberName() even for non-members. In such case + // possible to invoke this function even for non-members. In such case // we return the full name, just in case. if (namesakesIt == d->membersMap.cend()) - return u->fullName(this); + return makeFullUserName(username, mxId); - auto nextUserIt = namesakesIt + 1; - if (nextUserIt == d->membersMap.cend() || nextUserIt.key() != username) + auto nextUserIt = namesakesIt; + if (++nextUserIt == d->membersMap.cend() || nextUserIt.key() != username) return username; // No disambiguation necessary - // Check if we can get away just attaching the bridge postfix - // (extension to the spec) - QVector<QString> bridges; - for (; namesakesIt != d->membersMap.cend() && namesakesIt.key() == username; - ++namesakesIt) - { - const auto bridgeName = (*namesakesIt)->bridged(); - if (bridges.contains(bridgeName)) // Two accounts on the same bridge - return u->fullName(this); // Disambiguate fully - // Don't bother sorting, not so many bridges out there - bridges.push_back(bridgeName); - } + return makeFullUserName(username, mxId); // Disambiguate fully +} - return u->rawName(this); // Disambiguate using the bridge postfix only +QString Room::safeMemberName(const QString& userId) const +{ + return sanitized(disambiguatedMemberName(userId)); } -QString Room::roomMembername(const QString& userId) const +QString Room::htmlSafeMemberName(const QString& userId) const { - return roomMembername(user(userId)); + return safeMemberName(userId).toHtmlEscaped(); } -void Room::updateData(SyncRoomData&& data) +QUrl Room::memberAvatarUrl(const QString &mxId) const { - if( d->prevBatch.isEmpty() ) + // See https://github.com/matrix-org/matrix-doc/issues/1375 + if (const auto rme = currentState().get<RoomMemberEvent>(mxId)) { + if (rme->newAvatarUrl()) + return *rme->newAvatarUrl(); + if (rme->prevContent() && rme->prevContent()->avatarUrl) + return *rme->prevContent()->avatarUrl; + } + return {}; +} + +Room::Changes Room::Private::updateStatsFromSyncData(const SyncRoomData& data, + bool fromCache) +{ + Changes changes {}; + if (fromCache) { + // Initial load of cached statistics + partiallyReadStats = + EventStats::fromCachedCounters(data.partiallyReadCount); + unreadStats = EventStats::fromCachedCounters(data.unreadCount, + data.highlightCount); + // Migrate from lib 0.6: -1 in the old unread counter overrides 0 + // (which loads to an estimate) in notification_count. Next caching will + // save -1 in both places, completing the migration. + if (data.unreadCount == 0 && data.partiallyReadCount == -1) + unreadStats.isEstimate = false; + changes |= Change::PartiallyReadStats | Change::UnreadStats; + qCDebug(MESSAGES) << "Loaded" << q->objectName() + << "event statistics from cache:" << partiallyReadStats + << "since m.fully_read," << unreadStats + << "since m.read"; + } else if (timeline.empty()) { + // In absence of actual events use statistics from the homeserver + if (merge(unreadStats.notableCount, data.unreadCount)) + changes |= Change::PartiallyReadStats; + if (merge(unreadStats.highlightCount, data.highlightCount)) + changes |= Change::UnreadStats; + unreadStats.isEstimate = !data.unreadCount.has_value() + || *data.unreadCount > 0; + qCDebug(MESSAGES) + << "Using server-side unread event statistics while the" + << q->objectName() << "timeline is empty:" << unreadStats; + } + bool correctedStats = false; + if (unreadStats.highlightCount > partiallyReadStats.highlightCount) { + correctedStats = true; + partiallyReadStats.highlightCount = unreadStats.highlightCount; + partiallyReadStats.isEstimate |= unreadStats.isEstimate; + } + if (unreadStats.notableCount > partiallyReadStats.notableCount) { + correctedStats = true; + partiallyReadStats.notableCount = unreadStats.notableCount; + partiallyReadStats.isEstimate |= unreadStats.isEstimate; + } + if (!unreadStats.isEstimate && partiallyReadStats.isEstimate) { + correctedStats = true; + partiallyReadStats.isEstimate = true; + } + if (correctedStats) + qCDebug(MESSAGES) << "Partially read event statistics in" + << q->objectName() << "were adjusted to" + << partiallyReadStats + << "to be consistent with the m.read receipt"; + Q_ASSERT(partiallyReadStats.isValidFor(q, q->fullyReadMarker())); + Q_ASSERT(unreadStats.isValidFor(q, q->localReadReceiptMarker())); + + // TODO: Once the library learns to count highlights, drop + // serverHighlightCount and only use the server-side counter when + // the timeline is empty (see the code above). + if (merge(serverHighlightCount, data.highlightCount)) { + qCDebug(MESSAGES) << "Updated highlights number in" << q->objectName() + << "to" << serverHighlightCount; + changes |= Change::Highlights; + } + return changes; +} + +void Room::updateData(SyncRoomData&& data, bool fromCache) +{ + qCDebug(MAIN) << "--- Updating room" << id() << "/" << objectName(); + bool firstUpdate = d->baseState.empty(); + + if (d->prevBatch.isEmpty()) d->prevBatch = data.timelinePrevBatch; setJoinState(data.joinState); - QElapsedTimer et; et.start(); - for (auto&& event: data.accountData) - processAccountDataEvent(move(event)); + Changes roomChanges {}; + // The order of calculation is important - don't merge the lines! + roomChanges |= d->updateStateFrom(data.state); + roomChanges |= d->setSummary(move(data.summary)); + roomChanges |= d->addNewMessageEvents(move(data.timeline)); - bool emitNamesChanged = false; - if (!data.state.empty()) - { - et.restart(); - for (const auto& e: data.state) - emitNamesChanged |= processStateEvent(*e); + for (auto&& ephemeralEvent : data.ephemeral) + roomChanges |= processEphemeralEvent(move(ephemeralEvent)); - if (data.state.size() > 9 || et.nsecsElapsed() >= profilerMinNsecs()) - qCDebug(PROFILER) << "*** Room::processStateEvents():" - << data.state.size() << "event(s)," << et; - } - if (!data.timeline.empty()) - { - et.restart(); - // State changes can arrive in a timeline event; so check those. - for (const auto& e: data.timeline) - emitNamesChanged |= processStateEvent(*e); - if (data.timeline.size() > 9 || et.nsecsElapsed() >= profilerMinNsecs()) - qCDebug(PROFILER) << "*** Room::processStateEvents(timeline):" - << data.timeline.size() << "event(s)," << et; - } - if (emitNamesChanged) + for (auto&& event : data.accountData) + roomChanges |= processAccountDataEvent(move(event)); + + roomChanges |= d->updateStatsFromSyncData(data, fromCache); + + if (roomChanges & Change::Topic) + emit topicChanged(); + + if (roomChanges & (Change::Name | Change::Aliases)) emit namesChanged(this); - d->updateDisplayname(); - if (!data.timeline.empty()) - { - et.restart(); - d->addNewMessageEvents(move(data.timeline)); - if (data.timeline.size() > 9 || et.nsecsElapsed() >= profilerMinNsecs()) - qCDebug(PROFILER) << "*** Room::addNewMessageEvents():" << et; - } - for( auto&& ephemeralEvent: data.ephemeral ) - processEphemeralEvent(move(ephemeralEvent)); + d->postprocessChanges(roomChanges, !fromCache); + if (firstUpdate) + emit baseStateLoaded(); + qCDebug(MAIN) << "--- Finished updating room" << id() << "/" << objectName(); +} - // See https://github.com/QMatrixClient/libqmatrixclient/wiki/unread_count - if (data.unreadCount != -2 && data.unreadCount != d->unreadMessages) - { - qCDebug(MAIN) << "Setting unread_count to" << data.unreadCount; - d->unreadMessages = data.unreadCount; - emit unreadMessagesChanged(this); - } +void Room::Private::postprocessChanges(Changes changes, bool saveState) +{ + if (!changes) + return; - if( data.highlightCount != d->highlightCount ) - { - d->highlightCount = data.highlightCount; - emit highlightCountChanged(this); - } - if( data.notificationCount != d->notificationCount ) - { - d->notificationCount = data.notificationCount; - emit notificationCountChanged(this); + if (changes & Change::Members) + emit q->memberListChanged(); + + if (changes + & (Change::Name | Change::Aliases | Change::Members | Change::Summary)) + updateDisplayname(); + + if (changes & Change::PartiallyReadStats) { + QT_IGNORE_DEPRECATIONS( + emit q->unreadMessagesChanged(q);) // TODO: remove in 0.8 + emit q->partiallyReadStatsChanged(); } + + if (changes & Change::UnreadStats) + emit q->unreadStatsChanged(); + + if (changes & Change::Highlights) + emit q->highlightCountChanged(); + + qCDebug(MAIN) << terse << changes << "= hex" << Qt::hex << uint(changes) + << "in" << q->objectName(); + emit q->changed(changes); + if (saveState) + connection->saveRoomState(q); } -QString Room::Private::sendEvent(RoomEventPtr&& event) +RoomEvent* Room::Private::addAsPending(RoomEventPtr&& event) { if (event->transactionId().isEmpty()) event->setTransactionId(connection->generateTxnId()); + if (event->roomId().isEmpty()) + event->setRoomId(id); + if (event->senderId().isEmpty()) + event->setSender(connection->userId()); auto* pEvent = rawPtr(event); - emit q->pendingEventAboutToAdd(); + emit q->pendingEventAboutToAdd(pEvent); unsyncedEvents.emplace_back(move(event)); emit q->pendingEventAdded(); - return doSendEvent(pEvent); + return pEvent; +} + +QString Room::Private::sendEvent(RoomEventPtr&& event) +{ + if (!q->successorId().isEmpty()) { + qCWarning(MAIN) << q << "has been upgraded, event won't be sent"; + return {}; + } + + return doSendEvent(addAsPending(std::move(event))); } QString Room::Private::doSendEvent(const RoomEvent* pEvent) { - auto txnId = pEvent->transactionId(); + const auto txnId = pEvent->transactionId(); // TODO, #133: Enqueue the job rather than immediately trigger it. - if (auto call = connection->callApi<SendMessageJob>(BackgroundRequest, - id, pEvent->matrixType(), txnId, pEvent->contentJson())) - { - Room::connect(call, &BaseJob::started, q, - [this,pEvent,txnId] { - auto it = findAsPending(pEvent); - if (it == unsyncedEvents.end()) - { - qWarning(EVENTS) << "Pending event for transaction" << txnId - << "not found - got synced so soon?"; - return; - } - it->setDeparted(); - emit q->pendingEventChanged(it - unsyncedEvents.begin()); - }); - Room::connect(call, &BaseJob::failure, q, - std::bind(&Room::Private::onEventSendingFailure, - this, pEvent, txnId, call)); - Room::connect(call, &BaseJob::success, q, - [this,call,pEvent,txnId] { - // Find an event by the pointer saved in the lambda (the pointer - // may be dangling by now but we can still search by it). - auto it = findAsPending(pEvent); - if (it == unsyncedEvents.end()) - { - qDebug(EVENTS) << "Pending event for transaction" << txnId - << "already merged"; - return; + const RoomEvent* _event = pEvent; + std::unique_ptr<EncryptedEvent> encryptedEvent; + + if (q->usesEncryption()) { +#ifndef Quotient_E2EE_ENABLED + qWarning() << "This build of libQuotient does not support E2EE."; + return {}; +#else + if (!hasValidMegolmSession() || shouldRotateMegolmSession()) { + createMegolmSession(); + } + // Send the session to other people + connection->sendSessionKeyToDevices( + id, currentOutboundMegolmSession->sessionId(), + currentOutboundMegolmSession->sessionKey(), getDevicesWithoutKey(), + currentOutboundMegolmSession->sessionMessageIndex()); + + const auto encrypted = currentOutboundMegolmSession->encrypt(QJsonDocument(pEvent->fullJson()).toJson()); + currentOutboundMegolmSession->setMessageCount(currentOutboundMegolmSession->messageCount() + 1); + connection->saveCurrentOutboundMegolmSession( + id, *currentOutboundMegolmSession); + encryptedEvent = makeEvent<EncryptedEvent>( + encrypted, q->connection()->olmAccount()->identityKeys().curve25519, + q->connection()->deviceId(), + currentOutboundMegolmSession->sessionId()); + encryptedEvent->setTransactionId(connection->generateTxnId()); + encryptedEvent->setRoomId(id); + encryptedEvent->setSender(connection->userId()); + if(pEvent->contentJson().contains("m.relates_to"_ls)) { + encryptedEvent->setRelation(pEvent->contentJson()["m.relates_to"_ls].toObject()); + } + // We show the unencrypted event locally while pending. The echo check will throw the encrypted version out + _event = encryptedEvent.get(); +#endif + } + + if (auto call = + connection->callApi<SendMessageJob>(BackgroundRequest, id, + _event->matrixType(), txnId, + _event->contentJson())) { + Room::connect(call, &BaseJob::sentRequest, q, [this, txnId] { + auto it = q->findPendingEvent(txnId); + if (it == unsyncedEvents.end()) { + qWarning(EVENTS) << "Pending event for transaction" << txnId + << "not found - got synced so soon?"; + return; + } + it->setDeparted(); + emit q->pendingEventChanged(int(it - unsyncedEvents.begin())); + }); + Room::connect(call, &BaseJob::result, q, [this, txnId, call] { + if (!call->status().good()) { + onEventSendingFailure(txnId, call); + return; + } + auto it = q->findPendingEvent(txnId); + if (it != unsyncedEvents.end()) { + if (it->deliveryStatus() != EventStatus::ReachedServer) { + it->setReachedServer(call->eventId()); + emit q->pendingEventChanged(int(it - unsyncedEvents.begin())); } + } else + qDebug(EVENTS) << "Pending event for transaction" << txnId + << "already merged"; - it->setReachedServer(call->eventId()); - emit q->pendingEventChanged(it - unsyncedEvents.begin()); - }); + emit q->messageSent(txnId, call->eventId()); + }); } else - onEventSendingFailure(pEvent, txnId); + onEventSendingFailure(txnId); return txnId; } -Room::PendingEvents::iterator Room::Private::findAsPending( - const RoomEvent* rawEvtPtr) +void Room::Private::onEventSendingFailure(const QString& txnId, BaseJob* call) { - const auto comp = - [rawEvtPtr] (const auto& pe) { return pe.event() == rawEvtPtr; }; - - return std::find_if(unsyncedEvents.begin(), unsyncedEvents.end(), comp); -} - -void Room::Private::onEventSendingFailure(const RoomEvent* pEvent, - const QString& txnId, BaseJob* call) -{ - auto it = findAsPending(pEvent); - if (it == unsyncedEvents.end()) - { + auto it = q->findPendingEvent(txnId); + if (it == unsyncedEvents.end()) { qCritical(EVENTS) << "Pending event for transaction" << txnId << "could not be sent"; return; } - it->setSendingFailed(call - ? call->statusCaption() % ": " % call->errorString() - : tr("The call could not be started")); - emit q->pendingEventChanged(it - unsyncedEvents.begin()); + it->setSendingFailed(call ? call->statusCaption() % ": " % call->errorString() + : tr("The call could not be started")); + emit q->pendingEventChanged(int(it - unsyncedEvents.begin())); } QString Room::retryMessage(const QString& txnId) { - auto it = std::find_if(d->unsyncedEvents.begin(), d->unsyncedEvents.end(), - [txnId] (const auto& evt) { return evt->transactionId() == txnId; }); + const auto it = findPendingEvent(txnId); Q_ASSERT(it != d->unsyncedEvents.end()); - qDebug(EVENTS) << "Retrying transaction" << txnId; + qCDebug(EVENTS) << "Retrying transaction" << txnId; + const auto& transferIt = d->fileTransfers.constFind(txnId); + if (transferIt != d->fileTransfers.cend()) { + Q_ASSERT(transferIt->isUpload); + if (transferIt->status == FileTransferInfo::Completed) { + qCDebug(MESSAGES) + << "File for transaction" << txnId + << "has already been uploaded, bypassing re-upload"; + } else { + if (isJobPending(transferIt->job)) { + qCDebug(MESSAGES) << "Abandoning the upload job for transaction" + << txnId << "and starting again"; + transferIt->job->abandon(); + emit fileTransferFailed(txnId, + tr("File upload will be retried")); + } + uploadFile(txnId, QUrl::fromLocalFile( + transferIt->localFileInfo.absoluteFilePath())); + // FIXME: Content type is no more passed here but it should + } + } + if (it->deliveryStatus() == EventStatus::ReachedServer) { + qCWarning(MAIN) + << "The previous attempt has reached the server; two" + " events are likely to be in the timeline after retry"; + } it->resetStatus(); + emit pendingEventChanged(int(it - d->unsyncedEvents.begin())); return d->doSendEvent(it->event()); } +// Using a function defers actual tr() invocation to the moment when +// translations are initialised +auto FileTransferCancelledMsg() { return Room::tr("File transfer cancelled"); } + void Room::discardMessage(const QString& txnId) { auto it = std::find_if(d->unsyncedEvents.begin(), d->unsyncedEvents.end(), - [txnId] (const auto& evt) { return evt->transactionId() == txnId; }); + [txnId](const auto& evt) { + return evt->transactionId() == txnId; + }); Q_ASSERT(it != d->unsyncedEvents.end()); - qDebug(EVENTS) << "Discarding transaction" << txnId; - emit pendingEventAboutToDiscard(it - d->unsyncedEvents.begin()); + qCDebug(EVENTS) << "Discarding transaction" << txnId; + const auto& transferIt = d->fileTransfers.find(txnId); + if (transferIt != d->fileTransfers.end()) { + Q_ASSERT(transferIt->isUpload); + if (isJobPending(transferIt->job)) { + transferIt->status = FileTransferInfo::Cancelled; + transferIt->job->abandon(); + emit fileTransferFailed(txnId, FileTransferCancelledMsg()); + } else if (transferIt->status == FileTransferInfo::Completed) { + qCWarning(MAIN) + << "File for transaction" << txnId + << "has been uploaded but the message was discarded"; + } + } + emit pendingEventAboutToDiscard(int(it - d->unsyncedEvents.begin())); d->unsyncedEvents.erase(it); emit pendingEventDiscarded(); } @@ -1251,51 +2162,148 @@ QString Room::postPlainText(const QString& plainText) } QString Room::postHtmlMessage(const QString& plainText, const QString& html, - MessageEventType type) + MessageEventType type) { - return d->sendEvent<RoomMessageEvent>(plainText, type, - new EventContent::TextContent(html, QStringLiteral("text/html"))); + return d->sendEvent<RoomMessageEvent>( + plainText, type, + new EventContent::TextContent(html, QStringLiteral("text/html"))); } QString Room::postHtmlText(const QString& plainText, const QString& html) { - return postHtmlMessage(plainText, html, MessageEventType::Text); + return postHtmlMessage(plainText, html); +} + +QString Room::postReaction(const QString& eventId, const QString& key) +{ + return d->sendEvent<ReactionEvent>(EventRelation::annotate(eventId, key)); +} + +QString Room::Private::doPostFile(RoomEventPtr&& msgEvent, const QUrl& localUrl) +{ + const auto txnId = addAsPending(move(msgEvent))->transactionId(); + // Remote URL will only be known after upload; fill in the local path + // to enable the preview while the event is pending. + q->uploadFile(txnId, localUrl); + // Below, the upload job is used as a context object to clean up connections + const auto& transferJob = fileTransfers.value(txnId).job; + connect(q, &Room::fileTransferCompleted, transferJob, + [this, txnId](const QString& tId, const QUrl&, + const FileSourceInfo& fileMetadata) { + if (tId != txnId) + return; + + const auto it = q->findPendingEvent(txnId); + if (it != unsyncedEvents.end()) { + it->setFileUploaded(fileMetadata); + emit q->pendingEventChanged(int(it - unsyncedEvents.begin())); + doSendEvent(it->get()); + } else { + // Normally in this situation we should instruct + // the media server to delete the file; alas, there's no + // API specced for that. + qCWarning(MAIN) + << "File uploaded to" << getUrlFromSourceInfo(fileMetadata) + << "but the event referring to it was " + "cancelled"; + } + }); + connect(q, &Room::fileTransferFailed, transferJob, + [this, txnId](const QString& tId) { + if (tId != txnId) + return; + + const auto it = q->findPendingEvent(txnId); + if (it == unsyncedEvents.end()) + return; + + const auto idx = int(it - unsyncedEvents.begin()); + emit q->pendingEventAboutToDiscard(idx); + // See #286 on why `it` may not be valid here. + unsyncedEvents.erase(unsyncedEvents.begin() + idx); + emit q->pendingEventDiscarded(); + }); + + return txnId; } +QString Room::postFile(const QString& plainText, + EventContent::TypedBase* content) +{ + Q_ASSERT(content != nullptr && content->fileInfo() != nullptr); + const auto* const fileInfo = content->fileInfo(); + Q_ASSERT(fileInfo != nullptr); + QFileInfo localFile { fileInfo->url().toLocalFile() }; + Q_ASSERT(localFile.isFile()); + + return d->doPostFile( + makeEvent<RoomMessageEvent>( + plainText, RoomMessageEvent::rawMsgTypeForFile(localFile), content), + fileInfo->url()); +} + +#if QT_VERSION_MAJOR < 6 +QString Room::postFile(const QString& plainText, const QUrl& localPath, + bool asGenericFile) +{ + QFileInfo localFile { localPath.toLocalFile() }; + Q_ASSERT(localFile.isFile()); + return d->doPostFile(makeEvent<RoomMessageEvent>(plainText, localFile, + asGenericFile), + localPath); +} +#endif + QString Room::postEvent(RoomEvent* event) { - if (usesEncryption()) - { - qCCritical(MAIN) << "Room" << displayName() - << "enforces encryption; sending encrypted messages is not supported yet"; - } return d->sendEvent(RoomEventPtr(event)); } QString Room::postJson(const QString& matrixType, const QJsonObject& eventContent) { - return d->sendEvent(loadEvent<RoomEvent>(basicEventJson(matrixType, eventContent))); + return d->sendEvent(loadEvent<RoomEvent>(matrixType, eventContent)); +} + +SetRoomStateWithKeyJob* Room::setState(const StateEvent& evt) +{ + return setState(evt.matrixType(), evt.stateKey(), evt.contentJson()); +} + +SetRoomStateWithKeyJob* Room::setState(const QString& evtType, + const QString& stateKey, + const QJsonObject& contentJson) +{ + return d->requestSetState(evtType, stateKey, contentJson); } void Room::setName(const QString& newName) { - d->requestSetState(RoomNameEvent(newName)); + setState<RoomNameEvent>(newName); } void Room::setCanonicalAlias(const QString& newAlias) { - d->requestSetState(RoomCanonicalAliasEvent(newAlias)); + setState<RoomCanonicalAliasEvent>(newAlias, altAliases()); +} + +void Room::setPinnedEvents(const QStringList& events) +{ + setState<RoomPinnedEvent>(events); +} +void Room::setLocalAliases(const QStringList& aliases) +{ + setState<RoomCanonicalAliasEvent>(canonicalAlias(), aliases); } void Room::setTopic(const QString& newTopic) { - d->requestSetState(RoomTopicEvent(newTopic)); + setState<RoomTopicEvent>(newTopic); } bool isEchoEvent(const RoomEventPtr& le, const PendingEventItem& re) { - if (le->type() != re->type()) + if (le->metaType() != re->metaType()) return false; if (!re->id().isEmpty()) @@ -1313,60 +2321,78 @@ bool isEchoEvent(const RoomEventPtr& le, const PendingEventItem& re) return le->contentJson() == re->contentJson(); } -bool Room::supportsCalls() const +bool Room::supportsCalls() const { return joinedCount() == 2; } + +void Room::checkVersion() { - return d->membersMap.size() == 2; + const auto defaultVersion = connection()->defaultRoomVersion(); + const auto stableVersions = connection()->stableRoomVersions(); + Q_ASSERT(!defaultVersion.isEmpty()); + // This method is only called after the base state has been loaded + // or the server capabilities have been loaded. + emit stabilityUpdated(defaultVersion, stableVersions); + if (!stableVersions.contains(version())) { + qCDebug(STATE) << this << "version is" << version() + << "which the server doesn't count as stable"; + if (canSwitchVersions()) + qCDebug(STATE) + << "The current user has enough privileges to fix it"; + } } void Room::inviteCall(const QString& callId, const int lifetime, const QString& sdp) { Q_ASSERT(supportsCalls()); - postEvent(new CallInviteEvent(callId, lifetime, sdp)); + d->sendEvent<CallInviteEvent>(callId, lifetime, sdp); } void Room::sendCallCandidates(const QString& callId, const QJsonArray& candidates) { Q_ASSERT(supportsCalls()); - postEvent(new CallCandidatesEvent(callId, candidates)); + d->sendEvent<CallCandidatesEvent>(callId, candidates); } -void Room::answerCall(const QString& callId, const int lifetime, +void Room::answerCall(const QString& callId, [[maybe_unused]] int lifetime, const QString& sdp) { - Q_ASSERT(supportsCalls()); - postEvent(new CallAnswerEvent(callId, lifetime, sdp)); + qCWarning(MAIN) << "To client developer: drop lifetime parameter from " + "Room::answerCall(), it is no more accepted"; + answerCall(callId, sdp); } void Room::answerCall(const QString& callId, const QString& sdp) { Q_ASSERT(supportsCalls()); - postEvent(new CallAnswerEvent(callId, sdp)); + d->sendEvent<CallAnswerEvent>(callId, sdp); } void Room::hangupCall(const QString& callId) { Q_ASSERT(supportsCalls()); - postEvent(new CallHangupEvent(callId)); + d->sendEvent<CallHangupEvent>(callId); } -void Room::getPreviousContent(int limit) +void Room::getPreviousContent(int limit, const QString& filter) { - d->getPreviousContent(limit); + d->getPreviousContent(limit, filter); } -void Room::Private::getPreviousContent(int limit) +void Room::Private::getPreviousContent(int limit, const QString &filter) { - if( !isJobRunning(eventsHistoryJob) ) - { - eventsHistoryJob = - connection->callApi<GetRoomEventsJob>(id, prevBatch, "b", "", limit); - connect( eventsHistoryJob, &BaseJob::success, q, [=] { - prevBatch = eventsHistoryJob->end(); - addHistoricalMessageEvents(eventsHistoryJob->chunk()); - }); - } + if (isJobPending(eventsHistoryJob)) + return; + + eventsHistoryJob = connection->callApi<GetRoomEventsJob>(id, "b", prevBatch, + "", limit, filter); + emit q->eventsHistoryJobChanged(); + connect(eventsHistoryJob, &BaseJob::success, q, [this] { + prevBatch = eventsHistoryJob->end(); + addHistoricalMessageEvents(eventsHistoryJob->chunk()); + }); + connect(eventsHistoryJob, &QObject::destroyed, q, + &Room::eventsHistoryJobChanged); } void Room::inviteToRoom(const QString& memberId) @@ -1376,12 +2402,8 @@ void Room::inviteToRoom(const QString& memberId) LeaveRoomJob* Room::leaveRoom() { - return connection()->callApi<LeaveRoomJob>(id()); -} - -SetRoomStateWithKeyJob*Room::setMemberState(const QString& memberId, const RoomMemberEvent& event) const -{ - return d->requestSetState(memberId, event); + // FIXME, #63: It should be RoomManager, not Connection + return connection()->leaveRoom(this); } void Room::kickMember(const QString& memberId, const QString& reason) @@ -1401,8 +2423,8 @@ void Room::unban(const QString& userId) void Room::redactEvent(const QString& eventId, const QString& reason) { - connection()->callApi<RedactEventJob>( - id(), eventId, connection()->generateTxnId(), reason); + connection()->callApi<RedactEventJob>(id(), QUrl::toPercentEncoding(eventId), + connection()->generateTxnId(), reason); } void Room::uploadFile(const QString& id, const QUrl& localFilename, @@ -1411,19 +2433,35 @@ void Room::uploadFile(const QString& id, const QUrl& localFilename, Q_ASSERT_X(localFilename.isLocalFile(), __FUNCTION__, "localFilename should point at a local file"); auto fileName = localFilename.toLocalFile(); + FileSourceInfo fileMetadata; +#ifdef Quotient_E2EE_ENABLED + QTemporaryFile tempFile; + if (usesEncryption()) { + tempFile.open(); + QFile file(localFilename.toLocalFile()); + file.open(QFile::ReadOnly); + QByteArray data; + std::tie(fileMetadata, data) = encryptFile(file.readAll()); + tempFile.write(data); + tempFile.close(); + fileName = QFileInfo(tempFile).absoluteFilePath(); + } +#endif auto job = connection()->uploadFile(fileName, overrideContentType); - if (isJobRunning(job)) - { - d->fileTransfers.insert(id, { job, fileName }); + if (isJobPending(job)) { + d->fileTransfers[id] = { job, fileName, true }; connect(job, &BaseJob::uploadProgress, this, - [this,id] (qint64 sent, qint64 total) { + [this, id](qint64 sent, qint64 total) { d->fileTransfers[id].update(sent, total); emit fileTransferProgress(id, sent, total); }); - connect(job, &BaseJob::success, this, [this,id,localFilename,job] { - d->fileTransfers[id].status = FileTransferInfo::Completed; - emit fileTransferCompleted(id, localFilename, job->contentUri()); - }); + connect(job, &BaseJob::success, this, + [this, id, localFilename, job, fileMetadata]() mutable { + // The lambda is mutable to change encryptedFileMetadata + d->fileTransfers[id].status = FileTransferInfo::Completed; + setUrlInSourceInfo(fileMetadata, QUrl(job->contentUri())); + emit fileTransferCompleted(id, localFilename, fileMetadata); + }); connect(job, &BaseJob::failure, this, std::bind(&Private::failedTransfer, d, id, job->errorString())); emit newFileTransfer(id, localFilename); @@ -1433,70 +2471,85 @@ void Room::uploadFile(const QString& id, const QUrl& localFilename, void Room::downloadFile(const QString& eventId, const QUrl& localFilename) { - auto ongoingTransfer = d->fileTransfers.find(eventId); - if (ongoingTransfer != d->fileTransfers.end() && - ongoingTransfer->status == FileTransferInfo::Started) - { - qCWarning(MAIN) << "Download for" << eventId - << "already started; to restart, cancel it first"; + if (auto ongoingTransfer = d->fileTransfers.constFind(eventId); + ongoingTransfer != d->fileTransfers.cend() + && ongoingTransfer->status == FileTransferInfo::Started) { + qCWarning(MAIN) << "Transfer for" << eventId + << "is ongoing; download won't start"; return; } Q_ASSERT_X(localFilename.isEmpty() || localFilename.isLocalFile(), __FUNCTION__, "localFilename should point at a local file"); const auto* event = d->getEventWithFile(eventId); - if (!event) - { + if (!event) { qCCritical(MAIN) << eventId << "is not in the local timeline or has no file content"; Q_ASSERT(false); return; } - const auto fileUrl = event->content()->fileInfo()->url; + const auto* const fileInfo = event->content()->fileInfo(); + if (!fileInfo->isValid()) { + qCWarning(MAIN) << "Event" << eventId + << "has an empty or malformed mxc URL; won't download"; + return; + } + const auto fileUrl = fileInfo->url(); auto filePath = localFilename.toLocalFile(); - if (filePath.isEmpty()) - { - // Build our own file path, starting with temp directory and eventId. - filePath = eventId; - filePath = QDir::tempPath() % '/' % filePath.replace(':', '_') % - '#' % d->fileNameToDownload(event); + if (filePath.isEmpty()) { // Setup default file path + filePath = + fileInfo->url().path().mid(1) % '_' % d->fileNameToDownload(event); + + if (filePath.size() > 200) // If too long, elide in the middle + filePath.replace(128, filePath.size() - 192, "---"); + + filePath = QDir::tempPath() % '/' % filePath; + qDebug(MAIN) << "File path:" << filePath; } - auto job = connection()->downloadFile(fileUrl, filePath); - if (isJobRunning(job)) - { - // If there was a previous transfer (completed or failed), remove it. - d->fileTransfers.remove(eventId); - d->fileTransfers.insert(eventId, { job, job->targetFileName() }); + DownloadFileJob *job = nullptr; +#ifdef Quotient_E2EE_ENABLED + if (auto* fileMetadata = + std::get_if<EncryptedFileMetadata>(&fileInfo->source)) { + job = connection()->downloadFile(fileUrl, *fileMetadata, filePath); + } else { +#endif + job = connection()->downloadFile(fileUrl, filePath); +#ifdef Quotient_E2EE_ENABLED + } +#endif + if (isJobPending(job)) { + // If there was a previous transfer (completed or failed), overwrite it. + d->fileTransfers[eventId] = { job, job->targetFileName() }; connect(job, &BaseJob::downloadProgress, this, - [this,eventId] (qint64 received, qint64 total) { - d->fileTransfers[eventId].update(received, total); - emit fileTransferProgress(eventId, received, total); - }); - connect(job, &BaseJob::success, this, [this,eventId,fileUrl,job] { - d->fileTransfers[eventId].status = FileTransferInfo::Completed; - emit fileTransferCompleted(eventId, fileUrl, - QUrl::fromLocalFile(job->targetFileName())); - }); + [this, eventId](qint64 received, qint64 total) { + d->fileTransfers[eventId].update(received, total); + emit fileTransferProgress(eventId, received, total); + }); + connect(job, &BaseJob::success, this, [this, eventId, fileUrl, job] { + d->fileTransfers[eventId].status = FileTransferInfo::Completed; + emit fileTransferCompleted( + eventId, fileUrl, QUrl::fromLocalFile(job->targetFileName())); + }); connect(job, &BaseJob::failure, this, - std::bind(&Private::failedTransfer, d, - eventId, job->errorString())); + std::bind(&Private::failedTransfer, d, eventId, + job->errorString())); + emit newFileTransfer(eventId, localFilename); } else d->failedTransfer(eventId); } void Room::cancelFileTransfer(const QString& id) { - auto it = d->fileTransfers.find(id); - if (it == d->fileTransfers.end()) - { - qCWarning(MAIN) << "No information on file transfer" << id - << "in room" << d->id; + const auto it = d->fileTransfers.find(id); + if (it == d->fileTransfers.end()) { + qCWarning(MAIN) << "No information on file transfer" << id << "in room" + << d->id; return; } - if (isJobRunning(it->job)) + if (isJobPending(it->job)) it->job->abandon(); - d->fileTransfers.remove(id); - emit fileTransferCancelled(id); + it->status = FileTransferInfo::Cancelled; + emit fileTransferFailed(id, FileTransferCancelledMsg()); } void Room::Private::dropDuplicateEvents(RoomEvents& events) const @@ -1506,15 +2559,16 @@ void Room::Private::dropDuplicateEvents(RoomEvents& events) const // Multiple-remove (by different criteria), single-erase // 1. Check for duplicates against the timeline. - auto dupsBegin = remove_if(events.begin(), events.end(), - [&] (const RoomEventPtr& e) - { return eventsIndex.contains(e->id()); }); + auto dupsBegin = + remove_if(events.begin(), events.end(), [&](const RoomEventPtr& e) { + return eventsIndex.contains(e->id()); + }); // 2. Check for duplicates within the batch if there are still events. for (auto eIt = events.begin(); distance(eIt, dupsBegin) > 1; ++eIt) - dupsBegin = remove_if(eIt + 1, dupsBegin, - [&] (const RoomEventPtr& e) - { return e->id() == (*eIt)->id(); }); + dupsBegin = remove_if(eIt + 1, dupsBegin, [&](const RoomEventPtr& e) { + return e->id() == (*eIt)->id(); + }); if (dupsBegin == events.end()) return; @@ -1523,6 +2577,26 @@ void Room::Private::dropDuplicateEvents(RoomEvents& events) const events.erase(dupsBegin, events.end()); } +void Room::Private::decryptIncomingEvents(RoomEvents& events) +{ +#ifdef Quotient_E2EE_ENABLED + QElapsedTimer et; + et.start(); + size_t totalDecrypted = 0; + for (auto& eptr : events) + if (const auto& eeptr = eventCast<EncryptedEvent>(eptr)) { + if (auto decrypted = q->decryptMessage(*eeptr)) { + ++totalDecrypted; + auto&& oldEvent = exchange(eptr, move(decrypted)); + eptr->setOriginalEvent(::move(oldEvent)); + } else + undecryptedEvents[eeptr->sessionId()] += eeptr->id(); + } + if (totalDecrypted > 5 || et.nsecsElapsed() >= profilerMinNsecs()) + qDebug(PROFILER) << "Decrypted" << totalDecrypted << "events in" << et; +#endif +} + /** Make a redacted event * * This applies the redaction procedure as defined by the CS API specification @@ -1532,42 +2606,45 @@ void Room::Private::dropDuplicateEvents(RoomEvents& events) const RoomEventPtr makeRedacted(const RoomEvent& target, const RedactionEvent& redaction) { - auto originalJson = target.originalJsonObject(); - static const QStringList keepKeys = - { EventIdKey, TypeKey, QStringLiteral("room_id"), - QStringLiteral("sender"), QStringLiteral("state_key"), - QStringLiteral("prev_content"), ContentKey, - QStringLiteral("origin_server_ts") }; - - std::vector<std::pair<Event::Type, QStringList>> keepContentKeysMap - { { RoomMemberEvent::typeId(), { QStringLiteral("membership") } } -// , { RoomCreateEvent::typeId(), { QStringLiteral("creator") } } -// , { RoomJoinRules::typeId(), { QStringLiteral("join_rule") } } -// , { RoomPowerLevels::typeId(), -// { QStringLiteral("ban"), QStringLiteral("events"), -// QStringLiteral("events_default"), QStringLiteral("kick"), -// QStringLiteral("redact"), QStringLiteral("state_default"), -// QStringLiteral("users"), QStringLiteral("users_default") } } - , { RoomAliasesEvent::typeId(), { QStringLiteral("alias") } } - }; - for (auto it = originalJson.begin(); it != originalJson.end();) - { + auto originalJson = target.fullJson(); + // clang-format off + static const QStringList keepKeys { + EventIdKey, TypeKey, RoomIdKey, SenderKey, StateKeyKey, + QStringLiteral("hashes"), QStringLiteral("signatures"), + QStringLiteral("depth"), QStringLiteral("prev_events"), + QStringLiteral("prev_state"), QStringLiteral("auth_events"), + QStringLiteral("origin"), QStringLiteral("origin_server_ts"), + QStringLiteral("membership") }; + // clang-format on + + static const std::pair<event_type_t, QStringList> keepContentKeysMap[]{ + { RoomMemberEvent::TypeId, { QStringLiteral("membership") } }, + { RoomCreateEvent::TypeId, { QStringLiteral("creator") } }, + { RoomPowerLevelsEvent::TypeId, + { QStringLiteral("ban"), QStringLiteral("events"), + QStringLiteral("events_default"), QStringLiteral("kick"), + QStringLiteral("redact"), QStringLiteral("state_default"), + QStringLiteral("users"), QStringLiteral("users_default") } }, + // TODO: Replace with RoomJoinRules::TypeId etc. once available + { "m.room.join_rules"_ls, { QStringLiteral("join_rule") } }, + { "m.room.history_visibility"_ls, + { QStringLiteral("history_visibility") } } + }; + for (auto it = originalJson.begin(); it != originalJson.end();) { if (!keepKeys.contains(it.key())) it = originalJson.erase(it); // TODO: shred the value else ++it; } auto keepContentKeys = - find_if(keepContentKeysMap.begin(), keepContentKeysMap.end(), - [&target](const auto& t) { return target.type() == t.first; } ); - if (keepContentKeys == keepContentKeysMap.end()) - { + find_if(begin(keepContentKeysMap), end(keepContentKeysMap), + [&target](const auto& t) { return target.type() == t.first; }); + if (keepContentKeys == end(keepContentKeysMap)) { originalJson.remove(ContentKeyL); originalJson.remove(PrevContentKeyL); } else { auto content = originalJson.take(ContentKeyL).toObject(); - for (auto it = content.begin(); it != content.end(); ) - { + for (auto it = content.begin(); it != content.end();) { if (!keepContentKeys->second.contains(it.key())) it = content.erase(it); else @@ -1576,7 +2653,7 @@ RoomEventPtr makeRedacted(const RoomEvent& target, originalJson.insert(ContentKey, content); } auto unsignedData = originalJson.take(UnsignedKeyL).toObject(); - unsignedData[RedactedCauseKeyL] = redaction.originalJsonObject(); + unsignedData[RedactedCauseKeyL] = redaction.fullJson(); originalJson.insert(QStringLiteral("unsigned"), unsignedData); return loadEvent<RoomEvent>(originalJson); @@ -1586,25 +2663,101 @@ bool Room::Private::processRedaction(const RedactionEvent& redaction) { // Can't use findInTimeline because it returns a const iterator, and // we need to change the underlying TimelineItem. - const auto pIdx = eventsIndex.find(redaction.redactedEvent()); - if (pIdx == eventsIndex.end()) + const auto pIdx = eventsIndex.constFind(redaction.redactedEvent()); + if (pIdx == eventsIndex.cend()) return false; Q_ASSERT(q->isValidIndex(*pIdx)); auto& ti = timeline[Timeline::size_type(*pIdx - q->minTimelineIndex())]; - if (ti->isRedacted() && ti->redactedBecause()->id() == redaction.id()) - { - qCDebug(MAIN) << "Redaction" << redaction.id() - << "of event" << ti->id() << "already done, skipping"; + if (ti->isRedacted() && ti->redactedBecause()->id() == redaction.id()) { + qCDebug(EVENTS) << "Redaction" << redaction.id() << "of event" + << ti->id() << "already done, skipping"; return true; } - // Make a new event from the redacted JSON, exchange events, - // notify everyone and delete the old event + // Make a new event from the redacted JSON and put it in the timeline + // instead of the redacted one. oldEvent will be deleted on return. auto oldEvent = ti.replaceEvent(makeRedacted(*ti, redaction)); - q->onRedaction(*oldEvent, *ti.event()); - qCDebug(MAIN) << "Redacted" << oldEvent->id() << "with" << redaction.id(); + qCDebug(EVENTS) << "Redacted" << oldEvent->id() << "with" << redaction.id(); + if (oldEvent->isStateEvent()) { + // Check whether the old event was a part of current state; if it was, + // update the current state to the redacted event object. + const auto currentStateEvt = + currentState.get(oldEvent->matrixType(), oldEvent->stateKey()); + Q_ASSERT(currentStateEvt); + if (currentStateEvt == oldEvent.get()) { + // Historical states can't be in currentState + Q_ASSERT(ti.index() >= 0); + qCDebug(STATE).nospace() + << "Redacting state " << oldEvent->matrixType() << "/" + << oldEvent->stateKey(); + // Retarget the current state to the newly made event. + if (q->processStateEvent(*ti)) + emit q->namesChanged(q); + updateDisplayname(); + } + } + if (const auto* reaction = eventCast<ReactionEvent>(oldEvent)) { + const auto& targetEvtId = reaction->relation().eventId; + const std::pair lookupKey { targetEvtId, EventRelation::AnnotationType }; + if (relations.contains(lookupKey)) { + relations[lookupKey].removeOne(reaction); + emit q->updatedEvent(targetEvtId); + } + } + q->onRedaction(*oldEvent, *ti); + emit q->replacedEvent(ti.event(), rawPtr(oldEvent)); + // By now, all references to oldEvent must have been updated to ti.event() + return true; +} + +/** Make a replaced event + * + * Takes \p target and returns a copy of it with content taken from + * \p replacement. Disposal of the original event after that is on the caller. + */ +RoomEventPtr makeReplaced(const RoomEvent& target, + const RoomMessageEvent& replacement) +{ + const auto& targetReply = target.contentPart<QJsonObject>("m.relates_to"); + auto newContent = replacement.contentPart<QJsonObject>("m.new_content"_ls); + if (!targetReply.empty()) { + newContent["m.relates_to"] = targetReply; + } + auto originalJson = target.fullJson(); + originalJson[ContentKeyL] = newContent; + + auto unsignedData = originalJson.take(UnsignedKeyL).toObject(); + auto relations = unsignedData.take("m.relations"_ls).toObject(); + relations["m.replace"_ls] = replacement.id(); + unsignedData.insert(QStringLiteral("m.relations"), relations); + originalJson.insert(UnsignedKey, unsignedData); + + return loadEvent<RoomEvent>(originalJson); +} + +bool Room::Private::processReplacement(const RoomMessageEvent& newEvent) +{ + // Can't use findInTimeline because it returns a const iterator, and + // we need to change the underlying TimelineItem. + const auto pIdx = eventsIndex.constFind(newEvent.replacedEvent()); + if (pIdx == eventsIndex.cend()) + return false; + + Q_ASSERT(q->isValidIndex(*pIdx)); + + auto& ti = timeline[Timeline::size_type(*pIdx - q->minTimelineIndex())]; + if (ti->replacedBy() == newEvent.id()) { + qCDebug(STATE) << "Event" << ti->id() << "is already replaced with" + << newEvent.id(); + return true; + } + + // Make a new event from the redacted JSON and put it in the timeline + // instead of the redacted one. oldEvent will be deleted on return. + auto oldEvent = ti.replaceEvent(makeReplaced(*ti, newEvent)); + qCDebug(STATE) << "Replaced" << oldEvent->id() << "with" << newEvent.id(); emit q->replacedEvent(ti.event(), rawPtr(oldEvent)); return true; } @@ -1615,522 +2768,753 @@ Connection* Room::connection() const return d->connection; } -User* Room::localUser() const -{ - return connection()->user(); -} +User* Room::localUser() const { return connection()->user(); } -inline bool isRedaction(const RoomEventPtr& ep) +/// Whether the event is a redaction or a replacement +inline bool isEditing(const RoomEventPtr& ep) { Q_ASSERT(ep); - return is<RedactionEvent>(*ep); + if (is<RedactionEvent>(*ep)) + return true; + if (auto* msgEvent = eventCast<RoomMessageEvent>(ep)) + return !msgEvent->replacedEvent().isEmpty(); + + return false; } -void Room::Private::addNewMessageEvents(RoomEvents&& events) +Room::Changes Room::Private::addNewMessageEvents(RoomEvents&& events) { dropDuplicateEvents(events); if (events.empty()) - return; + return Change::None; - // Pre-process redactions so that events that get redacted in the same - // batch landed in the timeline already redacted. - // NB: We have to store redaction events to the timeline too - see #220. - auto redactionIt = std::find_if(events.begin(), events.end(), isRedaction); - for(const auto& eptr: RoomEventsRange(redactionIt, events.end())) - if (auto* r = eventCast<RedactionEvent>(eptr)) - { - // Try to find the target in the timeline, then in the batch. - if (processRedaction(*r)) - continue; - auto targetIt = std::find_if(events.begin(), redactionIt, - [id=r->redactedEvent()] (const RoomEventPtr& ep) { - return ep->id() == id; - }); - if (targetIt != redactionIt) - *targetIt = makeRedacted(**targetIt, *r); - else - qCDebug(MAIN) << "Redaction" << r->id() - << "ignored: target event" << r->redactedEvent() - << "is not found"; - // If the target event comes later, it comes already redacted. - } + decryptIncomingEvents(events); + + QElapsedTimer et; + et.start(); - auto timelineSize = timeline.size(); - auto totalInserted = 0; - for (auto it = events.begin(); it != events.end();) { - auto nextPendingPair = findFirstOf(it, events.end(), - unsyncedEvents.begin(), unsyncedEvents.end(), isEchoEvent); - auto nextPending = nextPendingPair.first; + // Pre-process redactions and edits so that events that get + // redacted/replaced in the same batch landed in the timeline already + // treated. + // NB: We have to store redacting/replacing events to the timeline too - + // see #220. + auto it = std::find_if(events.begin(), events.end(), isEditing); + for (const auto& eptr : RoomEventsRange(it, events.end())) { + if (auto* r = eventCast<RedactionEvent>(eptr)) { + // Try to find the target in the timeline, then in the batch. + if (processRedaction(*r)) + continue; + if (auto targetIt = std::find_if(events.begin(), events.end(), + [id = r->redactedEvent()](const RoomEventPtr& ep) { + return ep->id() == id; + }); targetIt != events.end()) + *targetIt = makeRedacted(**targetIt, *r); + else + qCDebug(STATE) + << "Redaction" << r->id() << "ignored: target event" + << r->redactedEvent() << "is not found"; + // If the target event comes later, it comes already redacted. + } + if (auto* msg = eventCast<RoomMessageEvent>(eptr); + msg && !msg->replacedEvent().isEmpty()) { + if (processReplacement(*msg)) + continue; + auto targetIt = std::find_if(events.begin(), it, + [id = msg->replacedEvent()](const RoomEventPtr& ep) { + return ep->id() == id; + }); + if (targetIt != it) + *targetIt = makeReplaced(**targetIt, *msg); + else // FIXME: hide the replacing event when target arrives later + qCDebug(EVENTS) + << "Replacing event" << msg->id() + << "ignored: target event" << msg->replacedEvent() + << "is not found"; + // Same as with redactions above, the replaced event coming + // later will come already with the new content. + } + } + } - if (it != nextPending) - { - RoomEventsRange eventsSpan { it, nextPending }; + // State changes arrive as a part of timeline; the current room state gets + // updated before merging events to the timeline because that's what + // clients historically expect. This may eventually change though if we + // postulate that the current state is only current between syncs but not + // within a sync. + Changes roomChanges {}; + for (const auto& eptr : events) + roomChanges |= q->processStateEvent(*eptr); + + auto timelineSize = timeline.size(); + size_t totalInserted = 0; + for (auto it = events.begin(); it != events.end();) { + auto nextPendingPair = + findFirstOf(it, events.end(), unsyncedEvents.begin(), + unsyncedEvents.end(), isEchoEvent); + const auto& remoteEcho = nextPendingPair.first; + const auto& localEcho = nextPendingPair.second; + + if (it != remoteEcho) { + RoomEventsRange eventsSpan { it, remoteEcho }; emit q->aboutToAddNewMessages(eventsSpan); auto insertedSize = moveEventsToTimeline(eventsSpan, Newer); totalInserted += insertedSize; - auto firstInserted = timeline.cend() - insertedSize; + auto firstInserted = syncEdge() - insertedSize; q->onAddNewTimelineEvents(firstInserted); - emit q->addedMessages(firstInserted->index(), timeline.back().index()); + emit q->addedMessages(firstInserted->index(), + timeline.back().index()); } - if (nextPending == events.end()) + if (remoteEcho == events.end()) break; - it = nextPending + 1; - emit q->pendingEventAboutToMerge(nextPending->get(), - nextPendingPair.second - unsyncedEvents.begin()); - qDebug(EVENTS) << "Merging pending event from transaction" - << (*nextPending)->transactionId() << "into" - << (*nextPending)->id(); - unsyncedEvents.erase(nextPendingPair.second); - if (auto insertedSize = moveEventsToTimeline({nextPending, it}, Newer)) - { + it = remoteEcho + 1; + auto* nextPendingEvt = remoteEcho->get(); + const auto pendingEvtIdx = int(localEcho - unsyncedEvents.begin()); + if (localEcho->deliveryStatus() != EventStatus::ReachedServer) { + localEcho->setReachedServer(nextPendingEvt->id()); + emit q->pendingEventChanged(pendingEvtIdx); + } + emit q->pendingEventAboutToMerge(nextPendingEvt, pendingEvtIdx); + qCDebug(MESSAGES) << "Merging pending event from transaction" + << nextPendingEvt->transactionId() << "into" + << nextPendingEvt->id(); + auto transfer = fileTransfers.take(nextPendingEvt->transactionId()); + if (transfer.status != FileTransferInfo::None) + fileTransfers.insert(nextPendingEvt->id(), transfer); + // After emitting pendingEventAboutToMerge() above we cannot rely + // on the previously obtained localEcho staying valid + // because a signal handler may send another message, thereby altering + // unsyncedEvents (see #286). Fortunately, unsyncedEvents only grows at + // its back so we can rely on the index staying valid at least. + unsyncedEvents.erase(unsyncedEvents.begin() + pendingEvtIdx); + if (auto insertedSize = moveEventsToTimeline({ remoteEcho, it }, Newer)) { totalInserted += insertedSize; - q->onAddNewTimelineEvents(timeline.cend() - insertedSize); + q->onAddNewTimelineEvents(syncEdge() - insertedSize); } emit q->pendingEventMerged(); } // Events merged and transferred from `events` to `timeline` now. - const auto from = timeline.cend() - totalInserted; + const auto from = syncEdge() - totalInserted; if (q->supportsCalls()) - for (auto it = from; it != timeline.cend(); ++it) - if (auto* evt = it->viewAs<CallEventBase>()) + for (auto it = from; it != syncEdge(); ++it) + if (const auto* evt = it->viewAs<CallEvent>()) emit q->callEvent(q, evt); - if (totalInserted > 0) - { - qCDebug(MAIN) - << "Room" << q->objectName() << "received" << totalInserted - << "new events; the last event is now" << timeline.back(); - - // The first event in the just-added batch (referred to by `from`) - // defines whose read marker can possibly be promoted any further over - // the same author's events newly arrived. Others will need explicit - // read receipts from the server (or, for the local user, - // markMessagesAsRead() invocation) to promote their read markers over - // the new message events. - auto firstWriter = q->user((*from)->senderId()); - if (q->readMarker(firstWriter) != timeline.crend()) - { - promoteReadMarker(firstWriter, rev_iter_t(from) - 1); - qCDebug(MAIN) << "Auto-promoted read marker for" << firstWriter->id() - << "to" << *q->readMarker(firstWriter); + if (totalInserted > 0) { + for (auto it = from; it != syncEdge(); ++it) { + if (const auto* reaction = it->viewAs<ReactionEvent>()) { + const auto& relation = reaction->relation(); + relations[{ relation.eventId, relation.type }] << reaction; + emit q->updatedEvent(relation.eventId); + } } - updateUnreadCount(timeline.crbegin(), rev_iter_t(from)); + qCDebug(MESSAGES) << "Room" << q->objectName() << "received" + << totalInserted << "new events; the last event is now" + << timeline.back(); + + roomChanges |= updateStats(timeline.crbegin(), rev_iter_t(from)); + + // If the local user's message(s) is/are first in the batch + // and the fully read marker was right before it, promote + // the fully read marker to the same event as the read receipt. + const auto& firstWriterId = (*from)->senderId(); + if (firstWriterId == connection->userId() + && q->fullyReadMarker().base() == from) + roomChanges |= + setFullyReadMarker(q->lastReadReceipt(firstWriterId).eventId); } Q_ASSERT(timeline.size() == timelineSize + totalInserted); + if (totalInserted > 9 || et.nsecsElapsed() >= profilerMinNsecs()) + qCDebug(PROFILER) << "Added" << totalInserted << "new event(s) to" + << q->objectName() << "in" << et; + return roomChanges; } void Room::Private::addHistoricalMessageEvents(RoomEvents&& events) { - QElapsedTimer et; et.start(); const auto timelineSize = timeline.size(); dropDuplicateEvents(events); - RoomEventsRange normalEvents { - events.begin(), events.end() //remove_if(events.begin(), events.end(), isRedaction) - }; - if (normalEvents.empty()) + if (events.empty()) return; - emit q->aboutToAddHistoricalMessages(normalEvents); - const auto insertedSize = moveEventsToTimeline(normalEvents, Older); - const auto from = timeline.crend() - insertedSize; + decryptIncomingEvents(events); + + QElapsedTimer et; + et.start(); + Changes changes {}; + // In case of lazy-loading new members may be loaded with historical + // messages. Also, the cache doesn't store events with empty content; + // so when such events show up in the timeline they should be properly + // incorporated. + for (const auto& eptr : events) { + const auto& e = *eptr; + if (e.isStateEvent() + && !currentState.contains(e.matrixType(), e.stateKey())) { + changes |= q->processStateEvent(e); + } + } + + emit q->aboutToAddHistoricalMessages(events); + const auto insertedSize = moveEventsToTimeline(events, Older); + const auto from = historyEdge() - insertedSize; - qCDebug(MAIN) << "Room" << displayname << "received" << insertedSize - << "past events; the oldest event is now" << timeline.front(); + qCDebug(STATE) << "Room" << displayname << "received" << insertedSize + << "past events; the oldest event is now" << timeline.front(); q->onAddHistoricalTimelineEvents(from); emit q->addedMessages(timeline.front().index(), from->index()); - if (from <= q->readMarker()) - updateUnreadCount(from, timeline.crend()); - + for (auto it = from; it != historyEdge(); ++it) { + if (const auto* reaction = it->viewAs<ReactionEvent>()) { + const auto& relation = reaction->relation(); + relations[{ relation.eventId, relation.type }] << reaction; + emit q->updatedEvent(relation.eventId); + } + } Q_ASSERT(timeline.size() == timelineSize + insertedSize); if (insertedSize > 9 || et.nsecsElapsed() >= profilerMinNsecs()) - qCDebug(PROFILER) << "*** Room::addHistoricalMessageEvents():" - << insertedSize << "event(s)," << et; -} - -bool Room::processStateEvent(const RoomEvent& e) -{ - return visit(e - , [this] (const RoomNameEvent& evt) { - d->name = evt.name(); - qCDebug(MAIN) << "Room name updated:" << d->name; + qCDebug(PROFILER) << "Added" << insertedSize << "historical event(s) to" + << q->objectName() << "in" << et; + + changes |= updateStats(from, historyEdge()); + if (changes) + postprocessChanges(changes); +} + +Room::Changes Room::processStateEvent(const RoomEvent& e) +{ + if (!e.isStateEvent()) + return Change::None; + + // Find a value (create an empty one if necessary) and get a reference + // to it, anticipating a change further in the function. + auto& curStateEvent = d->currentState[{ e.matrixType(), e.stateKey() }]; + // Prepare for the state change + // clang-format off + const bool proceed = switchOnType(e + , [this, curStateEvent](const RoomMemberEvent& rme) { + // clang-format on + auto* oldRme = static_cast<const RoomMemberEvent*>(curStateEvent); + auto* u = user(rme.userId()); + if (!u) { // Some terribly malformed user id? + qCCritical(MAIN) << "Could not get a user object for" + << rme.userId(); + return false; // Stay low and hope for the best... + } + const auto prevMembership = oldRme ? oldRme->membership() + : Membership::Leave; + switch (prevMembership) { + case Membership::Invite: + if (rme.membership() != prevMembership) { + d->usersInvited.removeOne(u); + Q_ASSERT(!d->usersInvited.contains(u)); + } + break; + case Membership::Join: + if (rme.membership() == Membership::Join) { + // rename/avatar change or no-op + if (rme.newDisplayName()) { + emit memberAboutToRename(u, *rme.newDisplayName()); + d->removeMemberFromMap(u); + } + if (!rme.newDisplayName() && !rme.newAvatarUrl()) { + qCWarning(MEMBERS) + << "No-op membership event for" << rme.userId() + << "- retaining the state"; + qCWarning(MEMBERS) << "The event dump:" << rme; + return false; + } + } else { + if (rme.membership() == Membership::Invite) + qCWarning(MAIN) + << "Membership change from Join to Invite:" << rme; + // whatever the new membership, it's no more Join + d->removeMemberFromMap(u); + emit userRemoved(u); + } + break; + case Membership::Ban: + case Membership::Knock: + case Membership::Leave: + if (rme.membership() == Membership::Invite + || rme.membership() == Membership::Join) { + d->membersLeft.removeOne(u); + Q_ASSERT(!d->membersLeft.contains(u)); + } + break; + case Membership::Undefined: + ; // A warning will be dropped in the post-processing block below + } return true; + // clang-format off } - , [this] (const RoomAliasesEvent& evt) { - d->aliases = evt.aliases(); - qCDebug(MAIN) << "Room aliases updated:" << d->aliases; + , [this, curStateEvent]( const EncryptionEvent& ee) { + // clang-format on + auto* oldEncEvt = + static_cast<const EncryptionEvent*>(curStateEvent); + if (ee.algorithm().isEmpty()) { + qWarning(STATE) + << "The encryption event for room" << objectName() + << "doesn't have 'algorithm' specified - ignoring"; + return false; + } + if (oldEncEvt + && oldEncEvt->encryption() != EncryptionType::Undefined) { + qCWarning(STATE) << "The room is already encrypted but a new" + " room encryption event arrived - ignoring"; + return false; + } return true; + // clang-format off } - , [this] (const RoomCanonicalAliasEvent& evt) { - d->canonicalAlias = evt.alias(); - if (!d->canonicalAlias.isEmpty()) - setObjectName(d->canonicalAlias); - qCDebug(MAIN) << "Room canonical alias updated:" - << d->canonicalAlias; - return true; + , true); // By default, go forward with the state change + // clang-format on + if (!proceed) { + if (!curStateEvent) // Remove the empty placeholder if one was created + d->currentState.remove({ e.matrixType(), e.stateKey() }); + return Change::None; + } + + // Change the state + const auto* const oldStateEvent = + std::exchange(curStateEvent, static_cast<const StateEvent*>(&e)); + Q_ASSERT(!oldStateEvent + || (oldStateEvent->matrixType() == e.matrixType() + && oldStateEvent->stateKey() == e.stateKey())); + if (is<RoomMemberEvent>(e)) + qCDebug(MEMBERS) << "Updated room member state:" << e; + else + qCDebug(STATE) << "Updated room state:" << e; + + // Update internal structures as per the change and work out the return value + + // clang-format off + const auto result = switchOnType(e + , [] (const RoomNameEvent&) { + return Change::Name; } - , [this] (const RoomTopicEvent& evt) { - d->topic = evt.topic(); - qCDebug(MAIN) << "Room topic updated:" << d->topic; - emit topicChanged(); - return false; + , [this, oldStateEvent] (const RoomCanonicalAliasEvent& cae) { + // clang-format on + setObjectName(cae.alias().isEmpty() ? d->id : cae.alias()); + const auto* oldCae = + static_cast<const RoomCanonicalAliasEvent*>(oldStateEvent); + QStringList previousAltAliases {}; + if (oldCae) { + previousAltAliases = oldCae->altAliases(); + if (!oldCae->alias().isEmpty()) + previousAltAliases.push_back(oldCae->alias()); + } + + auto newAliases = cae.altAliases(); + if (!cae.alias().isEmpty()) + newAliases.push_front(cae.alias()); + + connection()->updateRoomAliases(id(), previousAltAliases, + newAliases); + return Change::Aliases; + // clang-format off + } + , [this] (const RoomPinnedEvent&) { + emit pinnedEventsChanged(); + return Change::Other; + } + , [] (const RoomTopicEvent&) { + return Change::Topic; } , [this] (const RoomAvatarEvent& evt) { if (d->avatar.updateUrl(evt.url())) - { - qCDebug(MAIN) << "Room avatar URL updated:" - << evt.url().toString(); emit avatarChanged(); - } - return false; + return Change::Avatar; } - , [this] (const RoomMemberEvent& evt) { + , [this,oldStateEvent] (const RoomMemberEvent& evt) { + // clang-format on auto* u = user(evt.userId()); - u->processEvent(evt, this); - if (u == localUser() && memberJoinState(u) == JoinState::Invite - && evt.isDirect()) - connection()->addToDirectChats(this, user(evt.senderId())); - - if( evt.membership() == MembershipType::Join ) - { - if (memberJoinState(u) != JoinState::Join) - { + const auto* oldMemberEvent = + static_cast<const RoomMemberEvent*>(oldStateEvent); + const auto prevMembership = oldMemberEvent + ? oldMemberEvent->membership() + : Membership::Leave; + switch (evt.membership()) { + case Membership::Join: + if (prevMembership != Membership::Join) { d->insertMemberIntoMap(u); - connect(u, &User::nameAboutToChange, this, - [=] (QString newName, QString, const Room* context) { - if (context == this) - emit memberAboutToRename(u, newName); - }); - connect(u, &User::nameChanged, this, - [=] (QString, QString oldName, const Room* context) { - if (context == this) - d->renameMember(u, oldName); - }); emit userAdded(u); + } else { + if (evt.newDisplayName()) { + d->insertMemberIntoMap(u); + emit memberRenamed(u); + } + if (evt.newAvatarUrl()) + emit memberAvatarChanged(u); } + break; + case Membership::Invite: + if (!d->usersInvited.contains(u)) + d->usersInvited.push_back(u); + if (u == localUser() && evt.isDirect()) + connection()->addToDirectChats(this, user(evt.senderId())); + break; + case Membership::Knock: + case Membership::Ban: + case Membership::Leave: + if (!d->membersLeft.contains(u)) + d->membersLeft.append(u); + break; + case Membership::Undefined: + qCWarning(MEMBERS) << "Ignored undefined membership type"; } - else if( evt.membership() == MembershipType::Leave ) - { - if (memberJoinState(u) == JoinState::Join) - { - if (!d->membersLeft.contains(u)) - d->membersLeft.append(u); - d->removeMemberFromMap(u->name(this), u); - emit userRemoved(u); - } - } - return false; + return Change::Members; + // clang-format off } - , [this] (const EncryptionEvent& evt) { - d->encryptionAlgorithm = evt.algorithm(); - qCDebug(MAIN) << "Encryption switched on in room" << id() - << "with algorithm" << d->encryptionAlgorithm; + , [this] (const EncryptionEvent&) { + // As encryption can only be switched on once, emit the signal here + // instead of aggregating and emitting in updateData() emit encryption(); - return false; + return Change::Other; } - ); + , [this] (const RoomTombstoneEvent& evt) { + const auto successorId = evt.successorRoomId(); + if (auto* successor = connection()->room(successorId)) + emit upgraded(evt.serverMessage(), successor); + else + connectUntil(connection(), &Connection::loadedRoomState, this, + [this,successorId,serverMsg=evt.serverMessage()] + (Room* newRoom) { + if (newRoom->id() != successorId) + return false; + emit upgraded(serverMsg, newRoom); + return true; + }); + + return Change::Other; + // clang-format off + } + , Change::Other); + // clang-format on + Q_ASSERT(result != Change::None); + return result; } -void Room::processEphemeralEvent(EventPtr&& event) -{ - QElapsedTimer et; et.start(); - if (auto* evt = eventCast<TypingEvent>(event)) - { - d->usersTyping.clear(); - for( const QString& userId: qAsConst(evt->users()) ) - { - auto u = user(userId); - if (memberJoinState(u) == JoinState::Join) - d->usersTyping.append(u); - } - if (evt->users().size() > 3 || et.nsecsElapsed() >= profilerMinNsecs()) - qCDebug(PROFILER) << "*** Room::processEphemeralEvent(typing):" - << evt->users().size() << "users," << et; - emit typingChanged(); - } - if (auto* evt = eventCast<ReceiptEvent>(event)) - { - int totalReceipts = 0; - for( const auto &p: qAsConst(evt->eventsWithReceipts()) ) - { - totalReceipts += p.receipts.size(); - { - if (p.receipts.size() == 1) - qCDebug(EPHEMERAL) << "Marking" << p.evtId - << "as read for" << p.receipts[0].userId; - else - qCDebug(EPHEMERAL) << "Marking" << p.evtId - << "as read for" << p.receipts.size() << "users"; - } - const auto newMarker = findInTimeline(p.evtId); - if (newMarker != timelineEdge()) - { - for( const Receipt& r: p.receipts ) - { - if (r.userId == connection()->userId()) - continue; // FIXME, #185 - auto u = user(r.userId); - if (memberJoinState(u) == JoinState::Join) - d->promoteReadMarker(u, newMarker); - } - } else - { - qCDebug(EPHEMERAL) << "Event" << p.evtId - << "not found; saving read receipts anyway"; - // If the event is not found (most likely, because it's too old - // and hasn't been fetched from the server yet), but there is - // a previous marker for a user, keep the previous marker. - // Otherwise, blindly store the event id for this user. - for( const Receipt& r: p.receipts ) - { - if (r.userId == connection()->userId()) - continue; // FIXME, #185 - auto u = user(r.userId); - if (memberJoinState(u) == JoinState::Join && - readMarker(u) == timelineEdge()) - d->setLastReadEvent(u, p.evtId); +Room::Changes Room::processEphemeralEvent(EventPtr&& event) +{ + Changes changes {}; + QElapsedTimer et; + et.start(); + switchOnType(*event, + [this, &et](const TypingEvent& evt) { + const auto& users = evt.users(); + d->usersTyping.clear(); + d->usersTyping.reserve(users.size()); // Assume all are members + for (const auto& userId : users) + if (isMember(userId)) + d->usersTyping.append(user(userId)); + + if (d->usersTyping.size() > 3 + || et.nsecsElapsed() >= profilerMinNsecs()) + qDebug(PROFILER) + << "Processing typing events from" << users.size() + << "user(s) in" << objectName() << "took" << et; + emit typingChanged(); + }, + [this, &changes, &et](const ReceiptEvent& evt) { + const auto& receiptsJson = evt.contentJson(); + QVector<QString> updatedUserIds; + // Most often (especially for bigger batches), receipts are + // scattered across events (an anecdotal evidence showed 1.2-1.3 + // receipts per event on average). + updatedUserIds.reserve(receiptsJson.size() * 2); + for (auto eventIt = receiptsJson.begin(); + eventIt != receiptsJson.end(); ++eventIt) { + const auto evtId = eventIt.key(); + const auto newMarker = findInTimeline(evtId); + if (newMarker == historyEdge()) + qDebug(EPHEMERAL) + << "Event" << evtId + << "is not found; saving read receipt(s) anyway"; + const auto reads = + eventIt.value().toObject().value("m.read"_ls).toObject(); + for (auto userIt = reads.begin(); userIt != reads.end(); + ++userIt) { + ReadReceipt rr{ evtId, + fromJson<QDateTime>( + userIt->toObject().value("ts"_ls)) }; + const auto userId = userIt.key(); + if (userId == connection()->userId()) { + // Local user is special, and will get a signal about + // its read receipt separately from (and before) a + // signal on everybody else. No particular reason, just + // less cumbersome code. + changes |= d->setLocalLastReadReceipt(newMarker, rr); + } else if (d->setLastReadReceipt(userId, newMarker, rr)) { + changes |= Change::Other; + updatedUserIds.push_back(userId); + } } } - } - if (evt->eventsWithReceipts().size() > 3 || totalReceipts > 10 || - et.nsecsElapsed() >= profilerMinNsecs()) - qCDebug(PROFILER) << "*** Room::processEphemeralEvent(receipts):" - << evt->eventsWithReceipts().size() - << "event(s) with" << totalReceipts << "receipt(s)," << et; - } + if (updatedUserIds.size() > 10 + || et.nsecsElapsed() >= profilerMinNsecs()) + qDebug(PROFILER) + << "Processing" << updatedUserIds.size() + << "non-local receipt(s) on" << receiptsJson.size() + << "event(s) in" << objectName() << "took" << et; + if (!updatedUserIds.empty()) + emit lastReadEventChanged(updatedUserIds); + }); + return changes; } -void Room::processAccountDataEvent(EventPtr&& event) +Room::Changes Room::processAccountDataEvent(EventPtr&& event) { - if (auto* evt = eventCast<TagEvent>(event)) + Changes changes {}; + if (auto* evt = eventCast<TagEvent>(event)) { d->setTags(evt->tags()); - - if (auto* evt = eventCast<ReadMarkerEvent>(event)) - { - auto readEventId = evt->event_id(); - qCDebug(MAIN) << "Server-side read marker at" << readEventId; - d->serverReadMarker = readEventId; - const auto newMarker = findInTimeline(readEventId); - if (newMarker != timelineEdge()) - d->markMessagesAsRead(newMarker); - else - d->setLastReadEvent(localUser(), readEventId); + changes |= Change::Tags; } + + if (auto* evt = eventCast<const ReadMarkerEvent>(event)) + changes |= d->setFullyReadMarker(evt->eventId()); + // For all account data events auto& currentData = d->accountData[event->matrixType()]; // A polymorphic event-specific comparison might be a bit more // efficient; maaybe do it another day - if (!currentData || currentData->contentJson() != event->contentJson()) - { + if (!currentData || currentData->contentJson() != event->contentJson()) { emit accountDataAboutToChange(event->matrixType()); currentData = move(event); - qCDebug(MAIN) << "Updated account data of type" - << currentData->matrixType(); + qCDebug(STATE) << "Updated account data of type" + << currentData->matrixType(); emit accountDataChanged(currentData->matrixType()); + // TODO: Drop AccountDataChange in 0.8 + // NB: GCC (at least 10) only accepts QT_IGNORE_DEPRECATIONS around + // a statement, not within a statement + QT_IGNORE_DEPRECATIONS(changes |= Change::AccountData | Change::Other;) } + return changes; } -QString Room::Private::roomNameFromMemberNames(const QList<User *> &userlist) const +template <typename ContT> +Room::Private::users_shortlist_t +Room::Private::buildShortlist(const ContT& users) const { - // This is part 3(i,ii,iii) in the room displayname algorithm described - // in the CS spec (see also Room::Private::updateDisplayname() ). - // The spec requires to sort users lexicographically by state_key (user id) - // and use disambiguated display names of two topmost users excluding - // the current one to render the name of the room. - - // std::array is the leanest C++ container - std::array<User*, 2> first_two = { {nullptr, nullptr} }; + // To calculate room display name the spec requires to sort users + // lexicographically by state_key (user id) and use disambiguated + // display names of two topmost users excluding the current one to render + // the name of the room. The below code selects 3 topmost users, + // slightly extending the spec. + users_shortlist_t shortlist {}; // Prefill with nullptrs std::partial_sort_copy( - userlist.begin(), userlist.end(), - first_two.begin(), first_two.end(), + users.begin(), users.end(), shortlist.begin(), shortlist.end(), [this](const User* u1, const User* u2) { - // Filter out the "me" user so that it never hits the room name + // localUser(), if it's in the list, is sorted + // below all others return isLocalUser(u2) || (!isLocalUser(u1) && u1->id() < u2->id()); - } - ); - - // Spec extension. A single person in the chat but not the local user - // (the local user is invited). - if (userlist.size() == 1 && !isLocalUser(first_two.front()) && - joinState == JoinState::Invite) - return tr("Invitation from %1") - .arg(q->roomMembername(first_two.front())); - - // i. One-on-one chat. first_two[1] == localUser() in this case. - if (userlist.size() == 2) - return q->roomMembername(first_two[0]); - - // ii. Two users besides the current one. - if (userlist.size() == 3) - return tr("%1 and %2") - .arg(q->roomMembername(first_two[0]), - q->roomMembername(first_two[1])); - - // iii. More users. - if (userlist.size() > 3) - return tr("%1 and %Ln other(s)", "", userlist.size() - 3) - .arg(q->roomMembername(first_two[0])); + }); + return shortlist; +} - // userlist.size() < 2 - apparently, there's only current user in the room - return QString(); +Room::Private::users_shortlist_t +Room::Private::buildShortlist(const QStringList& userIds) const +{ + QList<User*> users; + users.reserve(userIds.size()); + for (const auto& h : userIds) + users.push_back(q->user(h)); + return buildShortlist(users); } QString Room::Private::calculateDisplayname() const { - // CS spec, section 11.2.2.5 Calculating the display name for a room + // CS spec, section 13.2.2.5 Calculating the display name for a room // Numbers below refer to respective parts in the spec. // 1. Name (from m.room.name) - if (!name.isEmpty()) { - return name; + auto dispName = q->name(); + if (!dispName.isEmpty()) { + return dispName; } // 2. Canonical alias - if (!canonicalAlias.isEmpty()) - return canonicalAlias; - - // Using m.room.aliases in naming is explicitly discouraged by the spec - //if (!aliases.empty() && !aliases.at(0).isEmpty()) - // return aliases.at(0); - - // 3. Room members - QString topMemberNames = roomNameFromMemberNames(membersMap.values()); - if (!topMemberNames.isEmpty()) - return topMemberNames; - - // 4. Users that previously left the room - topMemberNames = roomNameFromMemberNames(membersLeft); - if (!topMemberNames.isEmpty()) - return tr("Empty room (was: %1)").arg(topMemberNames); + dispName = q->canonicalAlias(); + if (!dispName.isEmpty()) + return dispName; + + // 3. m.room.aliases - only local aliases, subject for further removal + const auto aliases = q->aliases(); + if (!aliases.isEmpty()) + return aliases.front(); + + // 4. m.heroes and m.room.member + // From here on, we use a more general algorithm than the spec describes + // in order to provide back-compatibility with pre-MSC688 servers. + + // Supplementary code: build the shortlist of users whose names + // will be used to construct the room name. Takes into account MSC688's + // "heroes" if available. + const bool localUserIsIn = joinState == JoinState::Join; + const bool emptyRoom = + membersMap.isEmpty() + || (membersMap.size() == 1 && isLocalUser(*membersMap.cbegin())); + const bool nonEmptySummary = summary.heroes && !summary.heroes->empty(); + auto shortlist = nonEmptySummary ? buildShortlist(*summary.heroes) + : !emptyRoom ? buildShortlist(membersMap) + : users_shortlist_t {}; + + // When the heroes list is there, we can rely on it. If the heroes list is + // missing, the below code gathers invited, or, if there are no invitees, + // left members. + if (!shortlist.front() && localUserIsIn) + shortlist = buildShortlist(usersInvited); + + if (!shortlist.front()) + shortlist = buildShortlist(membersLeft); + + QStringList names; + for (const auto* u : shortlist) { + if (u == nullptr || isLocalUser(u)) + break; + // Only disambiguate if the room is not empty + names.push_back(u->displayname(emptyRoom ? nullptr : q)); + } - // 5. Fail miserably + const auto usersCountExceptLocal = + !emptyRoom + ? q->joinedCount() - int(joinState == JoinState::Join) + : !usersInvited.empty() + ? usersInvited.count() + : membersLeft.size() - int(joinState == JoinState::Leave); + if (usersCountExceptLocal > int(shortlist.size())) + names << tr( + "%Ln other(s)", + "Used to make a room name from user names: A, B and _N others_", + usersCountExceptLocal - int(shortlist.size())); + const auto namesList = QLocale().createSeparatedList(names); + + // Room members + if (!emptyRoom) + return namesList; + + // (Spec extension) Invited users + if (!usersInvited.empty()) + return tr("Empty room (invited: %1)").arg(namesList); + + // Users that previously left the room + if (!membersLeft.isEmpty()) + return tr("Empty room (was: %1)").arg(namesList); + + // Fail miserably return tr("Empty room (%1)").arg(id); } void Room::Private::updateDisplayname() { auto swappedName = calculateDisplayname(); - if (swappedName != displayname) - { + if (swappedName != displayname) { emit q->displaynameAboutToChange(q); swap(displayname, swappedName); - qDebug(MAIN) << q->objectName() << "has changed display name from" + qCDebug(MAIN) << q->objectName() << "has changed display name from" << swappedName << "to" << displayname; emit q->displaynameChanged(q, swappedName); } } -void appendStateEvent(QJsonArray& events, const QString& type, - const QJsonObject& content, const QString& stateKey = {}) -{ - if (!content.isEmpty() || !stateKey.isEmpty()) - { - auto json = basicEventJson(type, content); - json.insert(QStringLiteral("state_key"), stateKey); - events.append(json); - } -} - -#define ADD_STATE_EVENT(events, type, name, content) \ - appendStateEvent((events), QStringLiteral(type), \ - {{ QStringLiteral(name), content }}); - -void appendEvent(QJsonArray& events, const QString& type, - const QJsonObject& content) -{ - if (!content.isEmpty()) - events.append(basicEventJson(type, content)); -} - -template <typename EvtT> -void appendEvent(QJsonArray& events, const EvtT& event) -{ - appendEvent(events, EvtT::matrixTypeId(), event.toJson()); -} - QJsonObject Room::Private::toJson() const { - QElapsedTimer et; et.start(); + QElapsedTimer et; + et.start(); QJsonObject result; + addParam<IfNotEmpty>(result, QStringLiteral("summary"), summary); { QJsonArray stateEvents; - ADD_STATE_EVENT(stateEvents, "m.room.name", "name", name); - ADD_STATE_EVENT(stateEvents, "m.room.topic", "topic", topic); - ADD_STATE_EVENT(stateEvents, "m.room.avatar", "url", - avatar.url().toString()); - ADD_STATE_EVENT(stateEvents, "m.room.aliases", "aliases", - QJsonArray::fromStringList(aliases)); - ADD_STATE_EVENT(stateEvents, "m.room.canonical_alias", "alias", - canonicalAlias); - ADD_STATE_EVENT(stateEvents, "m.room.encryption", "algorithm", - encryptionAlgorithm); - - for (const auto *m : membersMap) - appendStateEvent(stateEvents, QStringLiteral("m.room.member"), - { { QStringLiteral("membership"), QStringLiteral("join") } - , { QStringLiteral("displayname"), m->rawName(q) } - , { QStringLiteral("avatar_url"), m->avatarUrl(q).toString() } - }, m->id()); - - const auto stateObjName = joinState == JoinState::Invite ? - QStringLiteral("invite_state") : QStringLiteral("state"); + for (const auto* evt : currentState) { + Q_ASSERT(evt->isStateEvent()); + if ((evt->isRedacted() && !is<RoomMemberEvent>(*evt)) + || evt->contentJson().isEmpty()) + continue; + + auto json = evt->fullJson(); + auto unsignedJson = evt->unsignedJson(); + unsignedJson.remove(QStringLiteral("prev_content")); + json[UnsignedKeyL] = unsignedJson; + stateEvents.append(json); + } + + const auto stateObjName = joinState == JoinState::Invite + ? QStringLiteral("invite_state") + : QStringLiteral("state"); result.insert(stateObjName, - QJsonObject {{ QStringLiteral("events"), stateEvents }}); + QJsonObject { { QStringLiteral("events"), stateEvents } }); } - QJsonArray accountDataEvents; - if (!accountData.empty()) - { - for (const auto& e: accountData) - appendEvent(accountDataEvents, e.first, e.second->contentJson()); + if (!accountData.empty()) { + QJsonArray accountDataEvents; + for (const auto& e : accountData) { + if (!e.second->contentJson().isEmpty()) + accountDataEvents.append(e.second->fullJson()); + } + result.insert(QStringLiteral("account_data"), + QJsonObject { + { QStringLiteral("events"), accountDataEvents } }); } - result.insert(QStringLiteral("account_data"), - QJsonObject {{ QStringLiteral("events"), accountDataEvents }}); - - QJsonObject unreadNotifObj - { { SyncRoomData::UnreadCountKey, unreadMessages } }; - if (highlightCount > 0) - unreadNotifObj.insert(QStringLiteral("highlight_count"), highlightCount); - if (notificationCount > 0) - unreadNotifObj.insert(QStringLiteral("notification_count"), notificationCount); + if (const auto& readReceipt = q->lastReadReceipt(connection->userId()); + !readReceipt.eventId.isEmpty()) // + { + result.insert( + QStringLiteral("ephemeral"), + QJsonObject { + { QStringLiteral("events"), + QJsonArray { ReceiptEvent({ { readReceipt.eventId, + { { connection->userId(), + readReceipt.timestamp } } } }) + .fullJson() } } }); + } - result.insert(QStringLiteral("unread_notifications"), unreadNotifObj); + result.insert(UnreadNotificationsKey, + QJsonObject { { PartiallyReadCountKey, + countFromStats(partiallyReadStats) }, + { HighlightCountKey, serverHighlightCount } }); + result.insert(NewUnreadCountKey, countFromStats(unreadStats)); if (et.elapsed() > 30) - qCDebug(PROFILER) << "Room::toJson() for" << displayname << "took" << et; + qCDebug(PROFILER) << "Room::toJson() for" << q->objectName() << "took" + << et; return result; } -QJsonObject Room::toJson() const -{ - return d->toJson(); -} +QJsonObject Room::toJson() const { return d->toJson(); } -MemberSorter Room::memberSorter() const -{ - return MemberSorter(this); -} +MemberSorter Room::memberSorter() const { return MemberSorter(this); } -bool MemberSorter::operator()(User *u1, User *u2) const +bool MemberSorter::operator()(User* u1, User* u2) const { - return operator()(u1, room->roomMembername(u2)); + return operator()(u1, room->disambiguatedMemberName(u2->id())); } -bool MemberSorter::operator ()(User* u1, const QString& u2name) const +bool MemberSorter::operator()(User* u1, QStringView u2name) const { - auto n1 = room->roomMembername(u1); + auto n1 = room->disambiguatedMemberName(u1->id()); if (n1.startsWith('@')) n1.remove(0, 1); - auto n2 = u2name.midRef(u2name.startsWith('@') ? 1 : 0); + const auto n2 = u2name.mid(u2name.startsWith('@') ? 1 : 0) +#if QT_VERSION_MAJOR < 6 + .toString() // Qt 5 doesn't have QStringView::localeAwareCompare +#endif + ; return n1.localeAwareCompare(n2) < 0; } + +void Room::activateEncryption() +{ + if(usesEncryption()) { + qCWarning(E2EE) << "Room" << objectName() << "is already encrypted"; + return; + } + setState<EncryptionEvent>(EncryptionType::MegolmV1AesSha2); +} |