aboutsummaryrefslogtreecommitdiff
path: root/lib/room.cpp
diff options
context:
space:
mode:
Diffstat (limited to 'lib/room.cpp')
-rw-r--r--lib/room.cpp1851
1 files changed, 1851 insertions, 0 deletions
diff --git a/lib/room.cpp b/lib/room.cpp
new file mode 100644
index 00000000..25669889
--- /dev/null
+++ b/lib/room.cpp
@@ -0,0 +1,1851 @@
+/******************************************************************************
+ * 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
+ */
+
+#include "room.h"
+
+#include "jobs/generated/kicking.h"
+#include "jobs/generated/inviting.h"
+#include "jobs/generated/banning.h"
+#include "jobs/generated/leaving.h"
+#include "jobs/generated/receipts.h"
+#include "jobs/generated/redaction.h"
+#include "jobs/generated/account-data.h"
+#include "jobs/setroomstatejob.h"
+#include "events/simplestateevents.h"
+#include "events/roomavatarevent.h"
+#include "events/roommemberevent.h"
+#include "events/typingevent.h"
+#include "events/receiptevent.h"
+#include "events/redactionevent.h"
+#include "jobs/sendeventjob.h"
+#include "jobs/roommessagesjob.h"
+#include "jobs/mediathumbnailjob.h"
+#include "jobs/downloadfilejob.h"
+#include "jobs/postreadmarkersjob.h"
+#include "avatar.h"
+#include "connection.h"
+#include "user.h"
+
+#include <QtCore/QHash>
+#include <QtCore/QStringBuilder> // for efficient string concats (operator%)
+#include <QtCore/QElapsedTimer>
+#include <QtCore/QPointer>
+#include <QtCore/QDir>
+#include <QtCore/QTemporaryFile>
+#include <QtCore/QRegularExpression>
+
+#include <array>
+#include <functional>
+#include <cmath>
+
+using namespace QMatrixClient;
+using namespace std::placeholders;
+#if !(defined __GLIBCXX__ && __GLIBCXX__ <= 20150123)
+using std::llround;
+#endif
+
+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. */
+ typedef QMultiHash<QString, User*> members_map_t;
+
+ Private(Connection* c, QString id_, JoinState initialJoinState)
+ : q(nullptr), connection(c), id(std::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;
+ 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;
+ QList<User*> membersLeft;
+ int unreadMessages = 0;
+ bool displayed = false;
+ QString firstDisplayedEventId;
+ QString lastDisplayedEventId;
+ QHash<const User*, QString> lastReadEventIds;
+ QString serverReadMarker;
+ TagsMap tags;
+ QHash<QString, QVariantHash> accountData;
+ QString prevBatch;
+ QPointer<RoomMessagesJob> roomMessagesJob;
+
+ struct FileTransferPrivateInfo
+ {
+#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;
+ }
+ };
+ 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;
+
+ //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);
+
+ void getPreviousContent(int limit = 10);
+
+ bool isEventNotable(const TimelineItem& ti) const
+ {
+ return !ti->isRedacted() &&
+ ti->senderId() != connection->userId() &&
+ ti->type() == EventType::RoomMessage;
+ }
+
+ void addNewMessageEvents(RoomEvents&& events);
+ void addHistoricalMessageEvents(RoomEvents&& events);
+
+ /**
+ * @brief 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 insertEvents(RoomEventsRange&& events,
+ EventsPlacement placement);
+
+ /**
+ * Removes events from the passed container that are already in the timeline
+ */
+ void dropDuplicateEvents(RoomEvents* events) const;
+
+ void setLastReadEvent(User* u, const 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);
+
+ /**
+ * @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.
+ */
+ void processRedaction(RoomEventPtr redactionEvent);
+
+ void broadcastTagUpdates()
+ {
+ connection->callApi<SetAccountDataPerRoomJob>(
+ connection->userId(), id, TagEvent::typeId(),
+ TagEvent(tags).toJson());
+ emit q->tagsChanged();
+ }
+
+ QJsonObject toJson() const;
+
+ private:
+ QString calculateDisplayname() const;
+ QString roomNameFromMemberNames(const QList<User*>& userlist) const;
+
+ bool isLocalUser(const User* u) const
+ {
+ return u == q->localUser();
+ }
+};
+
+RoomEventPtr TimelineItem::replaceEvent(RoomEventPtr&& other)
+{
+ return std::exchange(evt, std::move(other));
+}
+
+Room::Room(Connection* connection, QString id, JoinState initialJoinState)
+ : QObject(connection), d(new Private(connection, id, initialJoinState))
+{
+ setObjectName(id);
+ // 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;
+}
+
+Room::~Room()
+{
+ delete d;
+}
+
+const QString& Room::id() const
+{
+ return d->id;
+}
+
+const Room::Timeline& Room::messageEvents() const
+{
+ return d->timeline;
+}
+
+QString Room::name() const
+{
+ return d->name;
+}
+
+QStringList Room::aliases() const
+{
+ return d->aliases;
+}
+
+QString Room::canonicalAlias() const
+{
+ return d->canonicalAlias;
+}
+
+QString Room::displayName() const
+{
+ return d->displayname;
+}
+
+QString Room::topic() const
+{
+ return d->topic;
+}
+
+QString Room::avatarMediaId() const
+{
+ return d->avatar.mediaId();
+}
+
+QUrl Room::avatarUrl() const
+{
+ return d->avatar.url();
+}
+
+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(); });
+
+ // Use the other side's avatar for 1:1's
+ if (d->membersMap.size() == 2)
+ {
+ auto theOtherOneIt = d->membersMap.begin();
+ if (theOtherOneIt.value() == localUser())
+ ++theOtherOneIt;
+ return (*theOtherOneIt)->avatar(width, height, this,
+ [=] { emit avatarChanged(); });
+ }
+ return {};
+}
+
+User* Room::user(const QString& userId) const
+{
+ return connection()->user(userId);
+}
+
+JoinState Room::memberJoinState(User* user) const
+{
+ return
+ d->membersMap.contains(user->name(this), user) ? JoinState::Join :
+ JoinState::Leave;
+}
+
+JoinState Room::joinState() const
+{
+ return d->joinState;
+}
+
+void Room::setJoinState(JoinState state)
+{
+ JoinState oldState = d->joinState;
+ if( state == oldState )
+ return;
+ d->joinState = state;
+ qCDebug(MAIN) << "Room" << id() << "changed state: "
+ << int(oldState) << "->" << int(state);
+ emit joinStateChanged(oldState, state);
+}
+
+void Room::Private::setLastReadEvent(User* u, const QString& eventId)
+{
+ auto& storedId = lastReadEventIds[u];
+ if (storedId == eventId)
+ return;
+ storedId = eventId;
+ emit q->lastReadEventChanged(u);
+ if (isLocalUser(u))
+ {
+ if (eventId != serverReadMarker)
+ connection->callApi<PostReadMarkersJob>(id, eventId);
+ emit q->readMarkerMoved();
+ }
+}
+
+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());
+
+ // 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)
+ {
+ qCDebug(MAIN) << "Discovered last read event in room" << displayname;
+ promoteReadMarker(q->localUser(), readMarker, true);
+ return;
+ }
+
+ 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() > 10000)
+ 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;
+
+ unreadMessages += newUnreadMessages;
+ qCDebug(MAIN) << "Room" << displayname << "has gained"
+ << newUnreadMessages << "unread message(s),"
+ << (q->readMarker() == timeline.crend() ?
+ "in total at least" : "in total")
+ << unreadMessages << "unread message(s)";
+ emit q->unreadMessagesChanged(q);
+ }
+}
+
+void Room::Private::promoteReadMarker(User* u, rev_iter_t newMarker, bool force)
+{
+ 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());
+
+ // 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(); });
+
+ 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() > 10000)
+ qCDebug(PROFILER) << "Recounting unread messages took" << et;
+
+ // See https://github.com/QMatrixClient/libqmatrixclient/wiki/unread_count
+ if (unreadMessages == 0)
+ unreadMessages = -1;
+
+ 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);
+ }
+ }
+}
+
+void Room::Private::markMessagesAsRead(rev_iter_t upToMarker)
+{
+ 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;
+ }
+ }
+}
+
+void Room::markMessagesAsRead(QString uptoEventId)
+{
+ d->markMessagesAsRead(findInTimeline(uptoEventId));
+}
+
+void Room::markAllMessagesAsRead()
+{
+ if (!d->timeline.empty())
+ d->markMessagesAsRead(d->timeline.crbegin());
+}
+
+bool Room::hasUnreadMessages() const
+{
+ return unreadCount() >= 0;
+}
+
+int Room::unreadCount() const
+{
+ return d->unreadMessages;
+}
+
+Room::rev_iter_t Room::timelineEdge() const
+{
+ return d->timeline.crend();
+}
+
+TimelineItem::index_t Room::minTimelineIndex() const
+{
+ return d->timeline.empty() ? 0 : d->timeline.front().index();
+}
+
+TimelineItem::index_t Room::maxTimelineIndex() const
+{
+ return d->timeline.empty() ? 0 : d->timeline.back().index();
+}
+
+bool Room::isValidIndex(TimelineItem::index_t timelineIndex) const
+{
+ 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);
+}
+
+Room::rev_iter_t Room::findInTimeline(const QString& evtId) const
+{
+ if (!d->timeline.empty() && d->eventsIndex.contains(evtId))
+ return findInTimeline(d->eventsIndex.value(evtId));
+ return timelineEdge();
+}
+
+bool Room::displayed() const
+{
+ return d->displayed;
+}
+
+void Room::setDisplayed(bool displayed)
+{
+ if (d->displayed == displayed)
+ return;
+
+ d->displayed = displayed;
+ emit displayedChanged(displayed);
+ if( displayed )
+ {
+ resetHighlightCount();
+ resetNotificationCount();
+ }
+}
+
+QString Room::firstDisplayedEventId() const
+{
+ return d->firstDisplayedEventId;
+}
+
+Room::rev_iter_t Room::firstDisplayedMarker() const
+{
+ return findInTimeline(firstDisplayedEventId());
+}
+
+void Room::setFirstDisplayedEventId(const QString& eventId)
+{
+ if (d->firstDisplayedEventId == eventId)
+ return;
+
+ d->firstDisplayedEventId = eventId;
+ emit firstDisplayedEventChanged();
+}
+
+void Room::setFirstDisplayedEvent(TimelineItem::index_t index)
+{
+ Q_ASSERT(isValidIndex(index));
+ setFirstDisplayedEventId(findInTimeline(index)->event()->id());
+}
+
+QString Room::lastDisplayedEventId() const
+{
+ return d->lastDisplayedEventId;
+}
+
+Room::rev_iter_t Room::lastDisplayedMarker() const
+{
+ return findInTimeline(lastDisplayedEventId());
+}
+
+void Room::setLastDisplayedEventId(const QString& eventId)
+{
+ if (d->lastDisplayedEventId == eventId)
+ return;
+
+ d->lastDisplayedEventId = eventId;
+ emit lastDisplayedEventChanged();
+}
+
+void Room::setLastDisplayedEvent(TimelineItem::index_t index)
+{
+ Q_ASSERT(isValidIndex(index));
+ setLastDisplayedEventId(findInTimeline(index)->event()->id());
+}
+
+Room::rev_iter_t Room::readMarker(const User* user) const
+{
+ Q_ASSERT(user);
+ return findInTimeline(d->lastReadEventIds.value(user));
+}
+
+Room::rev_iter_t Room::readMarker() const
+{
+ return readMarker(localUser());
+}
+
+QString Room::readMarkerEventId() const
+{
+ return d->lastReadEventIds.value(localUser());
+}
+
+int Room::notificationCount() const
+{
+ return d->notificationCount;
+}
+
+void Room::resetNotificationCount()
+{
+ if( d->notificationCount == 0 )
+ return;
+ d->notificationCount = 0;
+ emit notificationCountChanged(this);
+}
+
+int Room::highlightCount() const
+{
+ return d->highlightCount;
+}
+
+void Room::resetHighlightCount()
+{
+ if( d->highlightCount == 0 )
+ return;
+ d->highlightCount = 0;
+ emit highlightCountChanged(this);
+}
+
+QStringList Room::tagNames() const
+{
+ return d->tags.keys();
+}
+
+TagsMap Room::tags() const
+{
+ return d->tags;
+}
+
+TagRecord Room::tag(const QString& name) const
+{
+ return d->tags.value(name);
+}
+
+void Room::addTag(const QString& name, const TagRecord& record)
+{
+ if (d->tags.contains(name))
+ return;
+
+ d->tags.insert(name, record);
+ d->broadcastTagUpdates();
+}
+
+void Room::removeTag(const QString& name)
+{
+ if (!d->tags.contains(name))
+ return;
+
+ d->tags.remove(name);
+ d->broadcastTagUpdates();
+}
+
+void Room::setTags(const TagsMap& newTags)
+{
+ if (newTags == d->tags)
+ return;
+ d->tags = newTags;
+ d->broadcastTagUpdates();
+}
+
+bool Room::isFavourite() const
+{
+ return d->tags.contains(FavouriteTag);
+}
+
+bool Room::isLowPriority() const
+{
+ return d->tags.contains(LowPriorityTag);
+}
+
+bool Room::isDirectChat() const
+{
+ return connection()->isDirectChat(id());
+}
+
+QList<const User*> Room::directChatUsers() const
+{
+ return connection()->directChatUsers(this);
+}
+
+const RoomMessageEvent*
+Room::Private::getEventWithFile(const QString& eventId) const
+{
+ auto evtIt = q->findInTimeline(eventId);
+ if (evtIt != timeline.rend() &&
+ evtIt->event()->type() == EventType::RoomMessage)
+ {
+ auto* event = static_cast<const RoomMessageEvent*>(evtIt->event());
+ if (event->hasFileContent())
+ return event;
+ }
+ qWarning() << "No files to download in event" << eventId;
+ return nullptr;
+}
+
+QString Room::Private::fileNameToDownload(const RoomMessageEvent* event) const
+{
+ Q_ASSERT(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();
+ }
+ // 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); }))
+ return fileName % '.' % fileInfo->mimeType.preferredSuffix();
+ }
+ return fileName;
+}
+
+QUrl Room::urlToThumbnail(const QString& eventId)
+{
+ if (auto* event = d->getEventWithFile(eventId))
+ if (event->hasThumbnail())
+ {
+ auto* thumbnail = event->content()->thumbnailInfo();
+ Q_ASSERT(thumbnail != nullptr);
+ return MediaThumbnailJob::makeRequestUrl(connection()->homeserver(),
+ thumbnail->url, thumbnail->imageSize);
+ }
+ qDebug() << "Event" << eventId << "has no thumbnail";
+ return {};
+}
+
+QUrl Room::urlToDownload(const QString& eventId)
+{
+ if (auto* event = d->getEventWithFile(eventId))
+ {
+ auto* fileInfo = event->content()->fileInfo();
+ Q_ASSERT(fileInfo != nullptr);
+ return DownloadFileJob::makeRequestUrl(connection()->homeserver(),
+ fileInfo->url);
+ }
+ return {};
+}
+
+QString Room::fileNameToDownload(const QString& eventId)
+{
+ if (auto* event = d->getEventWithFile(eventId))
+ return d->fileNameToDownload(event);
+ return {};
+}
+
+FileTransferInfo Room::fileTransferInfo(const QString& id) const
+{
+ auto infoIt = d->fileTransfers.find(id);
+ if (infoIt == d->fileTransfers.end())
+ return {};
+
+ // FIXME: Add lib tests to make sure FileTransferInfo::status stays
+ // consistent with FileTransferInfo::job
+
+ qint64 progress = infoIt->progress;
+ qint64 total = infoIt->total;
+ 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
+}
+
+static const auto RegExpOptions =
+ QRegularExpression::CaseInsensitiveOption
+ | QRegularExpression::OptimizeOnFirstUsageOption
+ | QRegularExpression::UseUnicodePropertiesOption;
+
+// regexp is originally taken from Konsole (https://github.com/KDE/konsole)
+// full url:
+// protocolname:// or www. followed by anything other than whitespaces,
+// <, >, ' or ", and ends before whitespaces, <, >, ', ", ], !, ), :,
+// comma or dot
+// Note: outer parentheses are a part of C++ raw string delimiters, not of
+// the regex (see http://en.cppreference.com/w/cpp/language/string_literal).
+static const QRegularExpression FullUrlRegExp(QStringLiteral(
+ R"(((www\.(?!\.)|[a-z][a-z0-9+.-]*://)(&(?![lg]t;)|[^&\s<>'"])+(&(?![lg]t;)|[^&!,.\s<>'"\]):])))"
+ ), RegExpOptions);
+// email address:
+// [word chars, dots or dashes]@[word chars, dots or dashes].[word chars]
+static const QRegularExpression EmailAddressRegExp(QStringLiteral(
+ R"((mailto:)?(\b(\w|\.|-)+@(\w|\.|-)+\.\w+\b))"
+ ), RegExpOptions);
+
+/** Converts all that looks like a URL into HTML links */
+static void linkifyUrls(QString& htmlEscapedText)
+{
+ // NOTE: htmlEscapedText is already HTML-escaped (no literal <,>,&)!
+
+ htmlEscapedText.replace(EmailAddressRegExp,
+ QStringLiteral(R"(<a href="mailto:\2">\1\2</a>)"));
+ htmlEscapedText.replace(FullUrlRegExp,
+ QStringLiteral(R"(<a href="\1">\1</a>)"));
+}
+
+QString Room::prettyPrint(const QString& plainText) const
+{
+ auto pt = QStringLiteral("<span style='white-space:pre-wrap'>") +
+ plainText.toHtmlEscaped() + QStringLiteral("</span>");
+ pt.replace('\n', "<br/>");
+
+ linkifyUrls(pt);
+ return pt;
+}
+
+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
+{
+ QStringList res;
+ for (auto u : d->membersMap)
+ res.append( roomMembername(u) );
+
+ return res;
+}
+
+int Room::memberCount() const
+{
+ return d->membersMap.size();
+}
+
+int Room::timelineSize() const
+{
+ return int(d->timeline.size());
+}
+
+bool Room::usesEncryption() const
+{
+ return !d->encryptionAlgorithm.isEmpty();
+}
+
+void Room::Private::insertMemberIntoMap(User *u)
+{
+ 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);
+ if (namesakes.size() == 1)
+ emit q->memberAboutToRename(namesakes.front(),
+ namesakes.front()->fullName(q));
+ membersMap.insert(userName, u);
+ if (namesakes.size() == 1)
+ emit q->memberRenamed(namesakes.front());
+}
+
+void Room::Private::renameMember(User* u, QString oldName)
+{
+ 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);
+}
+
+void Room::Private::removeMemberFromMap(const QString& username, User* u)
+{
+ User* namesake = nullptr;
+ auto namesakes = membersMap.values(username);
+ 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);
+ }
+ 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());
+}
+
+Room::Timeline::size_type Room::Private::insertEvents(RoomEventsRange&& events,
+ EventsPlacement placement)
+{
+ // 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();
+ auto baseIndex = index;
+ 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);
+ eventsIndex.insert(eId, index);
+ Q_ASSERT(q->findInTimeline(eId)->event()->id() == eId);
+ }
+ // Pointers in "events" are empty now, but events.size() didn't change
+ Q_ASSERT(int(events.size()) == (index - baseIndex) * int(placement));
+ return events.size();
+}
+
+QString Room::roomMembername(const User* u) const
+{
+ // See the CS spec, section 11.2.2.3
+
+ const auto username = u->name(this);
+ if (username.isEmpty())
+ return u->id();
+
+ // Get the list of users with the same display name. Most likely,
+ // there'll be one, but there's a chance there are more.
+ if (d->membersMap.count(username) == 1)
+ return 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
+ // we return the name _with_ id, to stay on a safe side.
+ // XXX: Causes a storm of false alarms when scrolling through older events
+ // with left users; commented out until we have a proper backtracking of
+ // room state ("room time machine").
+// if ( !namesakes.contains(u) )
+// {
+// qCWarning()
+// << "Room::roomMemberName(): user" << u->id()
+// << "is not a member of the room" << id();
+// }
+
+ // In case of more than one namesake, use the full name to disambiguate
+ return u->fullName(this);
+}
+
+QString Room::roomMembername(const QString& userId) const
+{
+ return roomMembername(user(userId));
+}
+
+void Room::updateData(SyncRoomData&& data)
+{
+ if( d->prevBatch.isEmpty() )
+ d->prevBatch = data.timelinePrevBatch;
+ setJoinState(data.joinState);
+
+ QElapsedTimer et; et.start();
+ for (auto&& event: data.accountData)
+ processAccountDataEvent(move(event));
+
+ if (!data.state.empty())
+ {
+ et.restart();
+ processStateEvents(data.state);
+ qCDebug(PROFILER) << "*** Room::processStateEvents(state):"
+ << data.state.size() << "event(s)," << et;
+ }
+ if (!data.timeline.empty())
+ {
+ et.restart();
+ // State changes can arrive in a timeline event; so check those.
+ processStateEvents(data.timeline);
+ qCDebug(PROFILER) << "*** Room::processStateEvents(timeline):"
+ << data.timeline.size() << "event(s)," << et;
+
+ et.restart();
+ d->addNewMessageEvents(move(data.timeline));
+ qCDebug(PROFILER) << "*** Room::addNewMessageEvents():" << et;
+ }
+ for( auto&& ephemeralEvent: data.ephemeral )
+ processEphemeralEvent(move(ephemeralEvent));
+
+ // 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);
+ }
+
+ if( data.highlightCount != d->highlightCount )
+ {
+ d->highlightCount = data.highlightCount;
+ emit highlightCountChanged(this);
+ }
+ if( data.notificationCount != d->notificationCount )
+ {
+ d->notificationCount = data.notificationCount;
+ emit notificationCountChanged(this);
+ }
+}
+
+void Room::postMessage(const QString& type, const QString& plainText)
+{
+ postMessage(RoomMessageEvent { plainText, type });
+}
+
+void Room::postMessage(const QString& plainText, MessageEventType type)
+{
+ postMessage(RoomMessageEvent { plainText, type });
+}
+
+void Room::postMessage(const RoomMessageEvent& event)
+{
+ if (usesEncryption())
+ {
+ qCCritical(MAIN) << "Room" << displayName()
+ << "enforces encryption; sending encrypted messages is not supported yet";
+ }
+ connection()->callApi<SendEventJob>(id(), event);
+}
+
+void Room::setName(const QString& newName)
+{
+ connection()->callApi<SetRoomStateJob>(id(), RoomNameEvent(newName));
+}
+
+void Room::setCanonicalAlias(const QString& newAlias)
+{
+ connection()->callApi<SetRoomStateJob>(id(),
+ RoomCanonicalAliasEvent(newAlias));
+}
+
+void Room::setTopic(const QString& newTopic)
+{
+ RoomTopicEvent evt(newTopic);
+ connection()->callApi<SetRoomStateJob>(id(), evt);
+}
+
+void Room::getPreviousContent(int limit)
+{
+ d->getPreviousContent(limit);
+}
+
+void Room::Private::getPreviousContent(int limit)
+{
+ if( !isJobRunning(roomMessagesJob) )
+ {
+ roomMessagesJob =
+ connection->callApi<RoomMessagesJob>(id, prevBatch, limit);
+ connect( roomMessagesJob, &RoomMessagesJob::success, [=] {
+ prevBatch = roomMessagesJob->end();
+ addHistoricalMessageEvents(roomMessagesJob->releaseEvents());
+ });
+ }
+}
+
+void Room::inviteToRoom(const QString& memberId)
+{
+ connection()->callApi<InviteUserJob>(id(), memberId);
+}
+
+LeaveRoomJob* Room::leaveRoom()
+{
+ return connection()->callApi<LeaveRoomJob>(id());
+}
+
+void Room::kickMember(const QString& memberId, const QString& reason)
+{
+ connection()->callApi<KickJob>(id(), memberId, reason);
+}
+
+void Room::ban(const QString& userId, const QString& reason)
+{
+ connection()->callApi<BanJob>(id(), userId, reason);
+}
+
+void Room::unban(const QString& userId)
+{
+ connection()->callApi<UnbanJob>(id(), userId);
+}
+
+void Room::redactEvent(const QString& eventId, const QString& reason)
+{
+ connection()->callApi<RedactEventJob>(
+ id(), eventId, connection()->generateTxnId(), reason);
+}
+
+void Room::uploadFile(const QString& id, const QUrl& localFilename,
+ const QString& overrideContentType)
+{
+ Q_ASSERT_X(localFilename.isLocalFile(), __FUNCTION__,
+ "localFilename should point at a local file");
+ auto fileName = localFilename.toLocalFile();
+ auto job = connection()->uploadFile(fileName, overrideContentType);
+ if (isJobRunning(job))
+ {
+ d->fileTransfers.insert(id, { job, fileName });
+ connect(job, &BaseJob::uploadProgress, this,
+ [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::failure, this,
+ std::bind(&Private::failedTransfer, d, id, job->errorString()));
+ emit newFileTransfer(id, localFilename);
+ } else
+ d->failedTransfer(id);
+}
+
+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";
+ return;
+ }
+
+ Q_ASSERT_X(localFilename.isEmpty() || localFilename.isLocalFile(),
+ __FUNCTION__, "localFilename should point at a local file");
+ const auto* event = d->getEventWithFile(eventId);
+ 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;
+ 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);
+ }
+ 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() });
+ 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()));
+ });
+ connect(job, &BaseJob::failure, this,
+ std::bind(&Private::failedTransfer, d,
+ eventId, job->errorString()));
+ } 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;
+ return;
+ }
+ if (isJobRunning(it->job))
+ it->job->abandon();
+ d->fileTransfers.remove(id);
+ emit fileTransferCancelled(id);
+}
+
+void Room::Private::dropDuplicateEvents(RoomEvents* events) const
+{
+ if (events->empty())
+ return;
+
+ // 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()); });
+
+ // 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(); });
+ if (dupsBegin == events->end())
+ return;
+
+ qCDebug(EVENTS) << "Dropping" << distance(dupsBegin, events->end())
+ << "duplicate event(s)";
+ events->erase(dupsBegin, events->end());
+}
+
+inline bool isRedaction(const RoomEventPtr& e)
+{
+ return e->type() == EventType::Redaction;
+}
+
+void Room::Private::processRedaction(RoomEventPtr redactionEvent)
+{
+ Q_ASSERT(redactionEvent && isRedaction(redactionEvent));
+ const auto& redaction =
+ static_cast<const RedactionEvent*>(redactionEvent.get());
+
+ const auto pIdx = eventsIndex.find(redaction->redactedEvent());
+ if (pIdx == eventsIndex.end())
+ {
+ qCDebug(MAIN) << "Redaction" << redaction->id()
+ << "ignored: target event not found";
+ return; // If the target events comes later, it comes already redacted.
+ }
+ Q_ASSERT(q->isValidIndex(*pIdx));
+
+ auto& ti = timeline[Timeline::size_type(*pIdx - q->minTimelineIndex())];
+
+ // Apply the redaction procedure from chapter 6.5 of The Spec
+ auto originalJson = ti->originalJsonObject();
+ if (originalJson.value("unsigned").toObject()
+ .value("redacted_because").toObject()
+ .value("event_id") == redaction->id())
+ {
+ qCDebug(MAIN) << "Redaction" << redaction->id()
+ << "of event" << ti.event()->id() << "already done, skipping";
+ return;
+ }
+ static const QStringList keepKeys =
+ { "event_id", "type", "room_id", "sender", "state_key",
+ "prev_content", "content", "origin_server_ts" };
+ static const
+ std::vector<std::pair<EventType, QStringList>> keepContentKeysMap
+ { { Event::Type::RoomMember, { "membership" } }
+ , { Event::Type::RoomCreate, { "creator" } }
+ , { Event::Type::RoomJoinRules, { "join_rule" } }
+ , { Event::Type::RoomPowerLevels,
+ { "ban", "events", "events_default", "kick", "redact",
+ "state_default", "users", "users_default" } }
+ , { Event::Type::RoomAliases, { "alias" } }
+ };
+ 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(),
+ [&ti](const auto& t) { return ti->type() == t.first; } );
+ if (keepContentKeys == keepContentKeysMap.end())
+ {
+ originalJson.remove("content");
+ originalJson.remove("prev_content");
+ } else {
+ auto content = originalJson.take("content").toObject();
+ for (auto it = content.begin(); it != content.end(); )
+ {
+ if (!keepContentKeys->second.contains(it.key()))
+ it = content.erase(it);
+ else
+ ++it;
+ }
+ originalJson.insert("content", content);
+ }
+ auto unsignedData = originalJson.take("unsigned").toObject();
+ unsignedData["redacted_because"] = redaction->originalJsonObject();
+ originalJson.insert("unsigned", unsignedData);
+
+ // Make a new event from the redacted JSON, exchange events,
+ // notify everyone and delete the old event
+ RoomEventPtr oldEvent
+ { ti.replaceEvent(makeEvent<RoomEvent>(originalJson)) };
+ q->onRedaction(oldEvent.get(), ti.event());
+ qCDebug(MAIN) << "Redacted" << oldEvent->id() << "with" << redaction->id();
+ emit q->replacedEvent(ti.event(), oldEvent.get());
+}
+
+Connection* Room::connection() const
+{
+ Q_ASSERT(d->connection);
+ return d->connection;
+}
+
+User* Room::localUser() const
+{
+ return connection()->user();
+}
+
+void Room::Private::addNewMessageEvents(RoomEvents&& events)
+{
+ auto timelineSize = timeline.size();
+
+ dropDuplicateEvents(&events);
+ // We want to process redactions in the order of arrival (covering the
+ // case of one redaction superseding another one), hence stable partition.
+ const auto normalsBegin =
+ stable_partition(events.begin(), events.end(), isRedaction);
+ RoomEventsRange redactions { events.begin(), normalsBegin },
+ normalEvents { normalsBegin, events.end() };
+
+ if (!normalEvents.empty())
+ emit q->aboutToAddNewMessages(normalEvents);
+ const auto insertedSize = insertEvents(std::move(normalEvents), Newer);
+ const auto from = timeline.cend() - insertedSize;
+ if (insertedSize > 0)
+ {
+ qCDebug(MAIN)
+ << "Room" << displayname << "received" << insertedSize
+ << "new events; the last event is now" << timeline.back();
+ q->onAddNewTimelineEvents(from);
+ }
+ for (auto&& r: redactions)
+ processRedaction(move(r));
+ if (insertedSize > 0)
+ {
+ emit q->addedMessages();
+
+ // 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);
+ }
+
+ updateUnreadCount(timeline.crbegin(), rev_iter_t(from));
+ }
+
+ Q_ASSERT(timeline.size() == timelineSize + insertedSize);
+}
+
+void Room::Private::addHistoricalMessageEvents(RoomEvents&& events)
+{
+ const auto timelineSize = timeline.size();
+
+ dropDuplicateEvents(&events);
+ const auto redactionsBegin =
+ remove_if(events.begin(), events.end(), isRedaction);
+ RoomEventsRange normalEvents { events.begin(), redactionsBegin };
+ if (normalEvents.empty())
+ return;
+
+ emit q->aboutToAddHistoricalMessages(normalEvents);
+ const auto insertedSize = insertEvents(std::move(normalEvents), Older);
+ const auto from = timeline.crend() - insertedSize;
+
+ qCDebug(MAIN) << "Room" << displayname << "received" << insertedSize
+ << "past events; the oldest event is now" << timeline.front();
+ q->onAddHistoricalTimelineEvents(from);
+ emit q->addedMessages();
+
+ if (from <= q->readMarker())
+ updateUnreadCount(from, timeline.crend());
+
+ Q_ASSERT(timeline.size() == timelineSize + insertedSize);
+}
+
+void Room::processStateEvents(const RoomEvents& events)
+{
+ bool emitNamesChanged = false;
+ for (const auto& e: events)
+ {
+ auto* event = e.get();
+ switch (event->type())
+ {
+ case EventType::RoomName: {
+ auto nameEvent = static_cast<RoomNameEvent*>(event);
+ d->name = nameEvent->name();
+ qCDebug(MAIN) << "Room name updated:" << d->name;
+ emitNamesChanged = true;
+ break;
+ }
+ case EventType::RoomAliases: {
+ auto aliasesEvent = static_cast<RoomAliasesEvent*>(event);
+ d->aliases = aliasesEvent->aliases();
+ qCDebug(MAIN) << "Room aliases updated:" << d->aliases;
+ emitNamesChanged = true;
+ break;
+ }
+ case EventType::RoomCanonicalAlias: {
+ auto aliasEvent = static_cast<RoomCanonicalAliasEvent*>(event);
+ d->canonicalAlias = aliasEvent->alias();
+ setObjectName(d->canonicalAlias);
+ qCDebug(MAIN) << "Room canonical alias updated:" << d->canonicalAlias;
+ emitNamesChanged = true;
+ break;
+ }
+ case EventType::RoomTopic: {
+ auto topicEvent = static_cast<RoomTopicEvent*>(event);
+ d->topic = topicEvent->topic();
+ qCDebug(MAIN) << "Room topic updated:" << d->topic;
+ emit topicChanged();
+ break;
+ }
+ case EventType::RoomAvatar: {
+ const auto& avatarEventContent =
+ static_cast<RoomAvatarEvent*>(event)->content();
+ if (d->avatar.updateUrl(avatarEventContent.url))
+ {
+ qCDebug(MAIN) << "Room avatar URL updated:"
+ << avatarEventContent.url.toString();
+ emit avatarChanged();
+ }
+ break;
+ }
+ case EventType::RoomMember: {
+ auto memberEvent = static_cast<RoomMemberEvent*>(event);
+ auto u = user(memberEvent->userId());
+ u->processEvent(memberEvent, this);
+ if (u == localUser() && memberJoinState(u) == JoinState::Invite
+ && memberEvent->isDirect())
+ connection()->addToDirectChats(this,
+ user(memberEvent->senderId()));
+
+ if( memberEvent->membership() == MembershipType::Join )
+ {
+ if (memberJoinState(u) != JoinState::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( memberEvent->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);
+ }
+ }
+ break;
+ }
+ case EventType::RoomEncryption:
+ {
+ d->encryptionAlgorithm =
+ static_cast<EncryptionEvent*>(event)->algorithm();
+ qCDebug(MAIN) << "Encryption switched on in" << displayName();
+ emit encryption();
+ break;
+ }
+ default: /* Ignore events of other types */;
+ }
+ }
+ if (emitNamesChanged) {
+ emit namesChanged(this);
+ }
+ d->updateDisplayname();
+}
+
+void Room::processEphemeralEvent(EventPtr event)
+{
+ QElapsedTimer et; et.start();
+ switch (event->type())
+ {
+ case EventType::Typing: {
+ auto typingEvent = static_cast<TypingEvent*>(event.get());
+ d->usersTyping.clear();
+ for( const QString& userId: typingEvent->users() )
+ {
+ auto u = user(userId);
+ if (memberJoinState(u) == JoinState::Join)
+ d->usersTyping.append(u);
+ }
+ if (!typingEvent->users().isEmpty())
+ qCDebug(PROFILER) << "*** Room::processEphemeralEvent(typing):"
+ << typingEvent->users().size() << "users," << et;
+ emit typingChanged();
+ break;
+ }
+ case EventType::Receipt: {
+ auto receiptEvent = static_cast<ReceiptEvent*>(event.get());
+ for( const auto &p: receiptEvent->eventsWithReceipts() )
+ {
+ {
+ 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);
+ }
+ }
+ }
+ if (!receiptEvent->eventsWithReceipts().isEmpty())
+ qCDebug(PROFILER) << "*** Room::processEphemeralEvent(receipts):"
+ << receiptEvent->eventsWithReceipts().size()
+ << "events with receipts," << et;
+ break;
+ }
+ default:
+ qCWarning(EPHEMERAL) << "Unexpected event type in 'ephemeral' batch:"
+ << event->jsonType();
+ }
+}
+
+void Room::processAccountDataEvent(EventPtr event)
+{
+ switch (event->type())
+ {
+ case EventType::Tag:
+ {
+ auto newTags = static_cast<TagEvent*>(event.get())->tags();
+ if (newTags == d->tags)
+ break;
+ d->tags = newTags;
+ qCDebug(MAIN) << "Room" << id() << "is tagged with:"
+ << tagNames().join(", ");
+ emit tagsChanged();
+ break;
+ }
+ case EventType::ReadMarker:
+ {
+ const auto* rmEvent = static_cast<ReadMarkerEvent*>(event.get());
+ const auto& readEventId = rmEvent->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);
+ }
+ break;
+ }
+ default:
+ d->accountData[event->jsonType()] =
+ event->contentJson().toVariantHash();
+ }
+}
+
+QString Room::Private::roomNameFromMemberNames(const QList<User *> &userlist) 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} };
+ std::partial_sort_copy(
+ userlist.begin(), userlist.end(),
+ first_two.begin(), first_two.end(),
+ [this](const User* u1, const User* u2) {
+ // Filter out the "me" user so that it never hits the room name
+ 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 apparently invited).
+ if (userlist.size() == 1 && !isLocalUser(first_two.front()))
+ 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]))
+ .arg(q->roomMembername(first_two[1]));
+
+ // iii. More users.
+ if (userlist.size() > 3)
+ return tr("%1 and %L2 others")
+ .arg(q->roomMembername(first_two[0]))
+ .arg(userlist.size() - 3);
+
+ // userlist.size() < 2 - apparently, there's only current user in the room
+ return QString();
+}
+
+QString Room::Private::calculateDisplayname() const
+{
+ // CS spec, section 11.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;
+ }
+
+ // 2. Canonical alias
+ if (!canonicalAlias.isEmpty())
+ return canonicalAlias;
+
+ // 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);
+
+ // 5. Fail miserably
+ return tr("Empty room (%1)").arg(id);
+
+ // Using m.room.aliases is explicitly discouraged by the spec
+ //if (!aliases.empty() && !aliases.at(0).isEmpty())
+ // displayname = aliases.at(0);
+}
+
+void Room::Private::updateDisplayname()
+{
+ const QString old_name = displayname;
+ displayname = calculateDisplayname();
+ if (old_name != displayname)
+ emit q->displaynameChanged(q);
+}
+
+void appendStateEvent(QJsonArray& events, const QString& type,
+ const QJsonObject& content, const QString& stateKey = {})
+{
+ if (!content.isEmpty() || !stateKey.isEmpty())
+ events.append(QJsonObject
+ { { QStringLiteral("type"), type }
+ , { QStringLiteral("content"), content }
+ , { QStringLiteral("state_key"), stateKey }
+ });
+}
+
+#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(QJsonObject
+ { { QStringLiteral("type"), type }
+ , { QStringLiteral("content"), content }
+ });
+}
+
+template <typename EvtT>
+void appendEvent(QJsonArray& events, const EvtT& event)
+{
+ appendEvent(events, EvtT::TypeId, event.toJson());
+}
+
+QJsonObject Room::Private::toJson() const
+{
+ QElapsedTimer et; et.start();
+ QJsonObject result;
+ {
+ 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->name(q) }
+ , { QStringLiteral("avatar_url"), m->avatarUrl(q).toString() }
+ }, m->id());
+
+ const auto stateObjName = joinState == JoinState::Invite ?
+ QStringLiteral("invite_state") : QStringLiteral("state");
+ result.insert(stateObjName,
+ QJsonObject {{ QStringLiteral("events"), stateEvents }});
+ }
+
+ QJsonArray accountDataEvents;
+ if (!tags.empty())
+ appendEvent(accountDataEvents, TagEvent(tags));
+
+ if (!serverReadMarker.isEmpty())
+ appendEvent(accountDataEvents, ReadMarkerEvent(serverReadMarker));
+
+ if (!accountData.empty())
+ {
+ for (auto it = accountData.begin(); it != accountData.end(); ++it)
+ appendEvent(accountDataEvents, it.key(),
+ QJsonObject::fromVariantHash(it.value()));
+ }
+ result.insert("account_data", QJsonObject {{ "events", accountDataEvents }});
+
+ QJsonObject unreadNotificationsObj;
+
+ unreadNotificationsObj.insert(SyncRoomData::UnreadCountKey, unreadMessages);
+ if (highlightCount > 0)
+ unreadNotificationsObj.insert("highlight_count", highlightCount);
+ if (notificationCount > 0)
+ unreadNotificationsObj.insert("notification_count", notificationCount);
+
+ result.insert("unread_notifications", unreadNotificationsObj);
+
+ if (et.elapsed() > 50)
+ qCDebug(PROFILER) << "Room::toJson() for" << displayname << "took" << et;
+
+ return result;
+}
+
+QJsonObject Room::toJson() const
+{
+ return d->toJson();
+}
+
+MemberSorter Room::memberSorter() const
+{
+ return MemberSorter(this);
+}
+
+bool MemberSorter::operator()(User *u1, User *u2) const
+{
+ return operator()(u1, room->roomMembername(u2));
+}
+
+bool MemberSorter::operator ()(User* u1, const QString& u2name) const
+{
+ auto n1 = room->roomMembername(u1);
+ if (n1.startsWith('@'))
+ n1.remove(0, 1);
+ auto n2 = u2name.midRef(u2name.startsWith('@') ? 1 : 0);
+
+ return n1.localeAwareCompare(n2) < 0;
+}