aboutsummaryrefslogtreecommitdiff
path: root/lib/room.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'lib/room.cpp')
-rw-r--r--lib/room.cpp3794
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);
+}