diff options
-rw-r--r-- | avatar.cpp | 12 | ||||
-rw-r--r-- | avatar.h | 1 | ||||
-rw-r--r-- | connection.cpp | 50 | ||||
-rw-r--r-- | events/event.h | 28 | ||||
-rw-r--r-- | events/eventcontent.cpp | 15 | ||||
-rw-r--r-- | events/eventcontent.h | 75 | ||||
-rw-r--r-- | events/roommessageevent.cpp | 24 | ||||
-rw-r--r-- | events/roommessageevent.h | 29 | ||||
-rw-r--r-- | jobs/basejob.cpp | 7 | ||||
-rw-r--r-- | jobs/downloadfilejob.cpp | 5 | ||||
-rw-r--r-- | room.cpp | 209 | ||||
-rw-r--r-- | room.h | 47 | ||||
-rw-r--r-- | user.cpp | 12 | ||||
-rw-r--r-- | user.h | 8 |
14 files changed, 406 insertions, 116 deletions
@@ -62,6 +62,11 @@ QImage Avatar::get(int width, int height, notifier_t notifier) const return d->get({width, height}, notifier); } +QString Avatar::mediaId() const +{ + return d->_url.authority() + d->_url.path(); +} + QImage Avatar::Private::get(QSize size, Avatar::notifier_t notifier) const { // FIXME: Alternating between longer-width and longer-height requests @@ -113,6 +118,13 @@ bool Avatar::updateUrl(const QUrl& newUrl) if (newUrl == d->_url) return false; + // FIXME: Make it a library-wide constant and maybe even make the URL checker + // a Connection(?) method. + if (newUrl.scheme() != "mxc" || newUrl.path().count('/') != 1) + { + qCWarning(MAIN) << "Malformed avatar URL:" << newUrl.toDisplayString(); + return false; + } d->_url = newUrl; d->_valid = false; return true; @@ -38,6 +38,7 @@ namespace QMatrixClient QImage get(int dimension, notifier_t notifier) const; QImage get(int w, int h, notifier_t notifier) const; + QString mediaId() const; QUrl url() const; bool updateUrl(const QUrl& newUrl); diff --git a/connection.cpp b/connection.cpp index f41f6c10..e46d08aa 100644 --- a/connection.cpp +++ b/connection.cpp @@ -59,6 +59,7 @@ class Connection::Private // separately so we should, e.g., keep objects for Invite and // Leave state of the same room. QHash<QPair<QString, bool>, Room*> roomMap; + QVector<QString> roomIdsToForget; QHash<QString, User*> userMap; QString userId; @@ -150,7 +151,7 @@ void Connection::connectToServer(const QString& user, const QString& password, const QString& deviceId) { checkAndConnect(user, - [&] { + [=] { doConnectToServer(user, password, initialDeviceName, deviceId); }); } @@ -177,7 +178,7 @@ void Connection::connectWithToken(const QString& userId, const QString& deviceId) { checkAndConnect(userId, - [&] { d->connectWithToken(userId, accessToken, deviceId); }); + [=] { d->connectWithToken(userId, accessToken, deviceId); }); } void Connection::Private::connectWithToken(const QString& user, @@ -255,6 +256,21 @@ void Connection::onSyncSuccess(SyncData &&data) { d->data->setLastEvent(data.nextBatch()); for( auto&& roomData: data.takeRoomData() ) { + const auto forgetIdx = d->roomIdsToForget.indexOf(roomData.roomId); + if (forgetIdx != -1) + { + d->roomIdsToForget.removeAt(forgetIdx); + if (roomData.joinState == JoinState::Leave) + { + qDebug(MAIN) << "Room" << roomData.roomId + << "has been forgotten, ignoring /sync response for it"; + continue; + } + qWarning(MAIN) << "Room" << roomData.roomId + << "has just been forgotten but /sync returned it in" + << toCString(roomData.joinState) + << "state - suspiciously fast turnaround"; + } if ( auto* r = provideRoom(roomData.roomId, roomData.joinState) ) r->updateData(std::move(roomData)); } @@ -376,20 +392,26 @@ ForgetRoomJob* Connection::forgetRoom(const QString& id) // a ForgetRoomJob is created in advance and can be returned in a probably // not-yet-started state (it will start once /leave completes). auto forgetJob = new ForgetRoomJob(id); - auto joinedRoom = d->roomMap.value({id, false}); - if (joinedRoom && joinedRoom->joinState() == JoinState::Join) + auto room = d->roomMap.value({id, false}); + if (!room) + room = d->roomMap.value({id, true}); + if (room && room->joinState() != JoinState::Leave) { - auto leaveJob = joinedRoom->leaveRoom(); - connect(leaveJob, &BaseJob::success, - this, [this, forgetJob] { forgetJob->start(connectionData()); }); + auto leaveJob = room->leaveRoom(); + connect(leaveJob, &BaseJob::success, this, [this, forgetJob, room] { + forgetJob->start(connectionData()); + // If the matching /sync response hasn't arrived yet, mark the room + // for explicit deletion + if (room->joinState() != JoinState::Leave) + d->roomIdsToForget.push_back(room->id()); + }); connect(leaveJob, &BaseJob::failure, forgetJob, &BaseJob::abandon); } else forgetJob->start(connectionData()); - connect(forgetJob, &BaseJob::success, this, [this, &id] + connect(forgetJob, &BaseJob::success, this, [this, id] { - // If the room happens to be in the map (possible in both forms), - // delete the found object(s). + // If the room is in the map (possibly in both forms), delete all forms. for (auto f: {false, true}) if (auto r = d->roomMap.take({ id, f })) { @@ -476,11 +498,7 @@ const ConnectionData* Connection::connectionData() const Room* Connection::provideRoom(const QString& id, JoinState joinState) { // TODO: This whole function is a strong case for a RoomManager class. - if (id.isEmpty()) - { - qCDebug(MAIN) << "Connection::provideRoom() with empty id, doing nothing"; - return nullptr; - } + Q_ASSERT_X(!id.isEmpty(), __FUNCTION__, "Empty room id"); const auto roomKey = qMakePair(id, joinState == JoinState::Invite); auto* room = d->roomMap.value(roomKey, nullptr); @@ -550,7 +568,7 @@ void Connection::setHomeserver(const QUrl& url) emit homeserverChanged(homeserver()); } -static constexpr int CACHE_VERSION_MAJOR = 1; +static constexpr int CACHE_VERSION_MAJOR = 2; static constexpr int CACHE_VERSION_MINOR = 0; void Connection::saveState(const QUrl &toFile) const diff --git a/events/event.h b/events/event.h index 6ed5ba49..b5a4d94e 100644 --- a/events/event.h +++ b/events/event.h @@ -256,6 +256,21 @@ namespace QMatrixClient }; template <typename ContentT> + struct Prev + { + template <typename... ContentParamTs> + explicit Prev(const QJsonObject& unsignedJson, + ContentParamTs&&... contentParams) + : senderId(unsignedJson.value("prev_sender").toString()) + , content(unsignedJson.value("prev_content").toObject(), + std::forward<ContentParamTs>(contentParams)...) + { } + + QString senderId; + ContentT content; + }; + + template <typename ContentT> class StateEvent: public StateEventBase { public: @@ -270,9 +285,8 @@ namespace QMatrixClient { auto unsignedData = obj.value("unsigned").toObject(); if (unsignedData.contains("prev_content")) - _prev.reset(new ContentT( - unsignedData.value("prev_content").toObject(), - std::forward<ContentParamTs>(contentParams)...)); + _prev = std::make_unique<Prev<ContentT>>(unsignedData, + std::forward<ContentParamTs>(contentParams)...); } template <typename... ContentParamTs> explicit StateEvent(Type type, ContentParamTs&&... contentParams) @@ -283,11 +297,15 @@ namespace QMatrixClient QJsonObject toJson() const { return _content.toJson(); } ContentT content() const { return _content; } - ContentT* prev_content() const { return _prev.data(); } + /** @deprecated Use prevContent instead */ + ContentT* prev_content() const { return prevContent(); } + ContentT* prevContent() const + { return _prev ? &_prev->content : nullptr; } + QString prevSenderId() const { return _prev ? _prev->senderId : ""; } protected: ContentT _content; - QScopedPointer<ContentT> _prev; + std::unique_ptr<Prev<ContentT>> _prev; }; } // namespace QMatrixClient Q_DECLARE_METATYPE(QMatrixClient::Event*) diff --git a/events/eventcontent.cpp b/events/eventcontent.cpp index 271669e2..f5974b46 100644 --- a/events/eventcontent.cpp +++ b/events/eventcontent.cpp @@ -44,7 +44,6 @@ FileInfo::FileInfo(const QUrl& u, const QJsonObject& infoJson, , payloadSize(infoJson["size"].toInt()) , originalName(originalFilename) { - originalInfoJson.insert("mediaId", url.authority() + url.path()); if (!mimeType.isValid()) mimeType = QMimeDatabase().mimeTypeForData(QByteArray()); } @@ -74,15 +73,13 @@ void ImageInfo::fillInfoJson(QJsonObject* infoJson) const infoJson->insert("h", imageSize.height()); } -WithThumbnail::WithThumbnail(const QJsonObject& infoJson) - : thumbnail(infoJson["thumbnail_url"].toString(), - infoJson["thumbnail_info"].toObject()) +Thumbnail::Thumbnail(const QJsonObject& infoJson) + : ImageInfo(infoJson["thumbnail_url"].toString(), + infoJson["thumbnail_info"].toObject()) { } -void WithThumbnail::fillInfoJson(QJsonObject* infoJson) const +void Thumbnail::fillInfoJson(QJsonObject* infoJson) const { - infoJson->insert("thumbnail_url", thumbnail.url.toString()); - QJsonObject thumbnailInfoJson; - thumbnail.fillInfoJson(&thumbnailInfoJson); - infoJson->insert("thumbnail_info", thumbnailInfoJson); + infoJson->insert("thumbnail_url", url.toString()); + infoJson->insert("thumbnail_info", toInfoJson<ImageInfo>(*this)); } diff --git a/events/eventcontent.h b/events/eventcontent.h index b37dc923..4afbaff3 100644 --- a/events/eventcontent.h +++ b/events/eventcontent.h @@ -27,6 +27,8 @@ #include <QtCore/QUrl> #include <QtCore/QSize> +#include <functional> + namespace QMatrixClient { namespace EventContent @@ -144,6 +146,14 @@ namespace QMatrixClient QString originalName; }; + template <typename InfoT> + QJsonObject toInfoJson(const InfoT& info) + { + QJsonObject infoJson; + info.fillInfoJson(&infoJson); + return infoJson; + } + /** * A content info class for image content types: image, thumbnail, video */ @@ -163,18 +173,18 @@ namespace QMatrixClient }; /** - * A mixin class for an info type that carries a thumbnail + * An auxiliary class for an info type that carries a thumbnail * * This class saves/loads a thumbnail to/from "info" subobject of * the JSON representation of event content; namely, * "info/thumbnail_url" and "info/thumbnail_info" fields are used. */ - class WithThumbnail + class Thumbnail : public ImageInfo { public: - WithThumbnail(const QJsonObject& infoJson); - WithThumbnail(const ImageInfo& info) - : thumbnail(info) + Thumbnail(const QJsonObject& infoJson); + Thumbnail(const ImageInfo& info) + : ImageInfo(info) { } /** @@ -182,9 +192,6 @@ namespace QMatrixClient * and thumbnail URL to "thumbnail_url" node inside "info". */ void fillInfoJson(QJsonObject* infoJson) const; - - public: - ImageInfo thumbnail; }; class TypedBase: public Base @@ -204,19 +211,22 @@ namespace QMatrixClient * the parameter type. * * \tparam InfoT base info class - * \tparam InfoMixinTs... additional info mixin classes (e.g. WithThumbnail) */ - template <class InfoT, class... InfoMixinTs> - class UrlBasedContent : - public TypedBase, public InfoT, public InfoMixinTs... + template <class InfoT> + class UrlBasedContent : public TypedBase, public InfoT { public: + UrlBasedContent(QUrl url, InfoT&& info, QString filename = {}) + : InfoT(url, std::forward<InfoT>(info), filename) + { } explicit UrlBasedContent(const QJsonObject& json) : TypedBase(json) , InfoT(json["url"].toString(), json["info"].toObject(), json["filename"].toString()) - , InfoMixinTs(InfoT::originalInfoJson)... - { } + { + // A small hack to facilitate links creation in QML. + originalJson.insert("mediaId", InfoT::mediaId()); + } QMimeType type() const override { return InfoT::mimeType; } const FileInfo* fileInfo() const override { return this; } @@ -228,12 +238,33 @@ namespace QMatrixClient json->insert("url", InfoT::url.toString()); if (!InfoT::originalName.isEmpty()) json->insert("filename", InfoT::originalName); - QJsonObject infoJson; - InfoT::fillInfoJson(&infoJson); - // http://en.cppreference.com/w/cpp/language/parameter_pack#Brace-enclosed_initializers - // Looking forward to C++17 and its folding awesomeness. - int d[] = { (InfoMixinTs::fillInfoJson(&infoJson), 0)... }; - Q_UNUSED(d); + json->insert("info", toInfoJson<InfoT>(*this)); + } + }; + + template <typename InfoT> + class UrlWithThumbnailContent : public UrlBasedContent<InfoT> + { + public: + // TODO: POD constructor + explicit UrlWithThumbnailContent(const QJsonObject& json) + : UrlBasedContent<InfoT>(json) + , thumbnail(InfoT::originalInfoJson) + { + // Another small hack, to simplify making a thumbnail link + UrlBasedContent<InfoT>::originalJson.insert( + "thumbnailMediaId", thumbnail.mediaId()); + } + + public: + Thumbnail thumbnail; + + protected: + void fillJson(QJsonObject* json) const override + { + UrlBasedContent<InfoT>::fillJson(json); + auto infoJson = json->take("info").toObject(); + thumbnail.fillInfoJson(&infoJson); json->insert("info", infoJson); } }; @@ -256,7 +287,7 @@ namespace QMatrixClient * - mimeType * - imageSize */ - using ImageContent = UrlBasedContent<ImageInfo, WithThumbnail>; + using ImageContent = UrlWithThumbnailContent<ImageInfo>; /** * Content class for m.file @@ -274,6 +305,6 @@ namespace QMatrixClient * - thumbnail.mimeType * - thumbnail.imageSize (QSize for "h" and "w" in JSON) */ - using FileContent = UrlBasedContent<FileInfo, WithThumbnail>; + using FileContent = UrlWithThumbnailContent<FileInfo>; } // namespace EventContent } // namespace QMatrixClient diff --git a/events/roommessageevent.cpp b/events/roommessageevent.cpp index 20e81564..8c088f21 100644 --- a/events/roommessageevent.cpp +++ b/events/roommessageevent.cpp @@ -116,6 +116,13 @@ QMimeType RoomMessageEvent::mimeType() const QMimeDatabase().mimeTypeForName("text/plain"); } +bool RoomMessageEvent::hasTextContent() const +{ + return content() && + (msgtype() == MsgType::Text || msgtype() == MsgType::Emote || + msgtype() == MsgType::Notice); // FIXME: Unbind from specific msgtypes +} + bool RoomMessageEvent::hasFileContent() const { return content() && content()->fileInfo(); @@ -159,13 +166,13 @@ void TextContent::fillJson(QJsonObject* json) const } LocationContent::LocationContent(const QString& geoUri, const ImageInfo& thumbnail) - : WithThumbnail(thumbnail), geoUri(geoUri) + : geoUri(geoUri), thumbnail(thumbnail) { } LocationContent::LocationContent(const QJsonObject& json) : TypedBase(json) - , WithThumbnail(json["info"].toObject()) , geoUri(json["geo_uri"].toString()) + , thumbnail(json["info"].toObject()) { } QMimeType LocationContent::type() const @@ -177,16 +184,5 @@ void LocationContent::fillJson(QJsonObject* o) const { Q_ASSERT(o); o->insert("geo_uri", geoUri); - QJsonObject infoJson; - WithThumbnail::fillInfoJson(&infoJson); - o->insert("info", infoJson); -} - -WithDuration::WithDuration(const QJsonObject& infoJson) - : duration(infoJson["duration"].toInt()) -{ } - -void WithDuration::fillInfoJson(QJsonObject* infoJson) const -{ - infoJson->insert("duration", duration); + o->insert("info", toInfoJson(thumbnail)); } diff --git a/events/roommessageevent.h b/events/roommessageevent.h index 6b551b76..2a5eeb7e 100644 --- a/events/roommessageevent.h +++ b/events/roommessageevent.h @@ -59,6 +59,7 @@ namespace QMatrixClient EventContent::TypedBase* content() const { return _content.data(); } QMimeType mimeType() const; + bool hasTextContent() const; bool hasFileContent() const; QJsonObject toJson() const; @@ -112,7 +113,7 @@ namespace QMatrixClient * - thumbnail.mimeType * - thumbnail.imageSize */ - class LocationContent: public TypedBase, public WithThumbnail + class LocationContent: public TypedBase { public: LocationContent(const QString& geoUri, @@ -123,21 +124,32 @@ namespace QMatrixClient public: QString geoUri; + Thumbnail thumbnail; protected: void fillJson(QJsonObject* o) const override; }; /** - * A mixin class for info types that include duration: audio and video + * A base class for info types that include duration: audio and video */ - class WithDuration + template <typename ContentT> + class PlayableContent : public ContentT { public: - explicit WithDuration(int duration) : duration(duration) { } - WithDuration(const QJsonObject& infoJson); + PlayableContent(const QJsonObject& json) + : ContentT(json) + , duration(ContentT::originalInfoJson["duration"].toInt()) + { } - void fillInfoJson(QJsonObject* infoJson) const; + protected: + void fillJson(QJsonObject* json) const override + { + ContentT::fillJson(json); + auto infoJson = json->take("info").toObject(); + infoJson.insert("duration", duration); + json->insert("info", infoJson); + } public: int duration; @@ -162,8 +174,7 @@ namespace QMatrixClient * - mimeType * - imageSize */ - using VideoContent = - UrlBasedContent<ImageInfo, WithThumbnail, WithDuration>; + using VideoContent = PlayableContent<UrlWithThumbnailContent<ImageInfo>>; /** * Content class for m.audio @@ -177,6 +188,6 @@ namespace QMatrixClient * - mimeType ("mimetype" in JSON) * - duration */ - using AudioContent = UrlBasedContent<FileInfo, WithDuration>; + using AudioContent = PlayableContent<UrlBasedContent<FileInfo>>; } // namespace EventContent } // namespace QMatrixClient diff --git a/jobs/basejob.cpp b/jobs/basejob.cpp index 7fc56287..22ce5bd5 100644 --- a/jobs/basejob.cpp +++ b/jobs/basejob.cpp @@ -290,9 +290,10 @@ bool checkContentType(const QByteArray& type, const QByteArrayList& patterns) BaseJob::Status BaseJob::checkReply(QNetworkReply* reply) const { - qCDebug(d->logCat) << this << "returned from" << reply->url().toDisplayString(); - if (reply->error() != QNetworkReply::NoError) - qCDebug(d->logCat) << this << "returned" << reply->error(); + qCDebug(d->logCat) << this << "returned" + << (reply->error() == QNetworkReply::NoError ? + "Success" : reply->errorString()) + << "from" << reply->url().toDisplayString(); switch( reply->error() ) { case QNetworkReply::NoError: diff --git a/jobs/downloadfilejob.cpp b/jobs/downloadfilejob.cpp index 2530e259..06fa3b48 100644 --- a/jobs/downloadfilejob.cpp +++ b/jobs/downloadfilejob.cpp @@ -36,14 +36,15 @@ QString DownloadFileJob::targetFileName() const void DownloadFileJob::beforeStart(const ConnectionData*) { - if (d->targetFile && !d->targetFile->open(QIODevice::WriteOnly)) + if (d->targetFile && !d->targetFile->isReadable() && + !d->targetFile->open(QIODevice::WriteOnly)) { qCWarning(JOBS) << "Couldn't open the file" << d->targetFile->fileName() << "for writing"; setStatus(FileError, "Could not open the target file for writing"); return; } - if (!d->tempFile->open(QIODevice::WriteOnly)) + if (!d->tempFile->isReadable() && !d->tempFile->open(QIODevice::WriteOnly)) { qCWarning(JOBS) << "Couldn't open the temporary file" << d->tempFile->fileName() << "for writing"; @@ -43,9 +43,11 @@ #include <QtCore/QElapsedTimer> #include <QtCore/QPointer> #include <QtCore/QDir> +#include <QtCore/QRegularExpression> #include <array> #include <functional> +#include <cmath> using namespace QMatrixClient; using namespace std::placeholders; @@ -61,8 +63,7 @@ class Room::Private Private(Connection* c, QString id_, JoinState initialJoinState) : q(nullptr), connection(c), id(std::move(id_)) - , avatar(c), joinState(initialJoinState), unreadMessages(false) - , highlightCount(0), notificationCount(0), roomMessagesJob(nullptr) + , avatar(c), joinState(initialJoinState) { } Room* q; @@ -83,18 +84,27 @@ class Room::Private QString topic; Avatar avatar; JoinState joinState; - bool unreadMessages; - int highlightCount; - int notificationCount; + int highlightCount = 0; + int notificationCount = 0; members_map_t membersMap; QList<User*> usersTyping; QList<User*> membersLeft; + bool unreadMessages = false; + bool displayed = false; + QString firstDisplayedEventId; + QString lastDisplayedEventId; QHash<const User*, QString> lastReadEventIds; QString prevBatch; - RoomMessagesJob* roomMessagesJob; + QPointer<RoomMessagesJob> roomMessagesJob; struct FileTransferPrivateInfo { +#if defined(_MSC_VER) && _MSC_VER < 1910 + FileTransferPrivateInfo() = default; + FileTransferPrivateInfo(BaseJob* j, QString fileName) + : job(j), localFileInfo(fileName) + { } +#endif QPointer<BaseJob> job = nullptr; QFileInfo localFileInfo { }; FileTransferInfo::Status status = FileTransferInfo::Started; @@ -103,13 +113,13 @@ class Room::Private void update(qint64 p, qint64 t) { - progress = p; total = t; if (t == 0) { t = -1; if (p == 0) p = -1; } + progress = p; total = t; } }; void failedTransfer(const QString& tid, const QString& errorMessage = {}) @@ -203,9 +213,13 @@ RoomEventPtr TimelineItem::replaceEvent(RoomEventPtr&& 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; } @@ -249,6 +263,16 @@ 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); @@ -265,8 +289,8 @@ QImage Room::avatar(int width, int height) auto theOtherOneIt = d->membersMap.begin(); if (theOtherOneIt.value() == localUser()) ++theOtherOneIt; - return theOtherOneIt.value()->avatarObject() - .get(width, height, [=] { emit avatarChanged(); }); + return (*theOtherOneIt)->avatarObject() + .get(width, height, [=] { emit avatarChanged(); }); } return {}; } @@ -289,7 +313,10 @@ void Room::setJoinState(JoinState state) void Room::Private::setLastReadEvent(User* u, const QString& eventId) { - lastReadEventIds.insert(u, eventId); + auto& storedId = lastReadEventIds[u]; + if (storedId == eventId) + return; + storedId = eventId; emit q->lastReadEventChanged(u); if (isLocalUser(u)) emit q->readMarkerMoved(); @@ -405,6 +432,75 @@ Room::rev_iter_t Room::findInTimeline(const QString& evtId) const 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); @@ -465,10 +561,64 @@ FileTransferInfo Room::fileTransferInfo(const QString& id) const total = INT_MAX; } +#if defined(_MSC_VER) && _MSC_VER < 1910 + // A workaround for MSVC 2015 that fails with "error C2440: 'return': + // cannot convert from 'initializer list' to 'QMatrixClient::FileTransferInfo'" + 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 @@ -613,7 +763,7 @@ void Room::Private::removeMember(User* u) } } -QString Room::roomMembername(User *u) const +QString Room::roomMembername(const User* u) const { // See the CS spec, section 11.2.2.3 @@ -725,17 +875,13 @@ void Room::getPreviousContent(int limit) void Room::Private::getPreviousContent(int limit) { - if( !roomMessagesJob ) + if( !isJobRunning(roomMessagesJob) ) { roomMessagesJob = connection->callApi<RoomMessagesJob>(id, prevBatch, limit); - connect( roomMessagesJob, &RoomMessagesJob::result, [=] { - if( !roomMessagesJob->error() ) - { - addHistoricalMessageEvents(roomMessagesJob->releaseEvents()); - prevBatch = roomMessagesJob->end(); - } - roomMessagesJob = nullptr; + connect( roomMessagesJob, &RoomMessagesJob::success, [=] { + prevBatch = roomMessagesJob->end(); + addHistoricalMessageEvents(roomMessagesJob->releaseEvents()); }); } } @@ -818,11 +964,14 @@ void Room::downloadFile(const QString& eventId, const QUrl& localFilename) return; } auto* fileInfo = event->content()->fileInfo(); + auto safeTempPrefix = eventId; + safeTempPrefix.replace(':', '_'); + safeTempPrefix = QDir::tempPath() + '/' + safeTempPrefix + '#'; auto fileName = !localFilename.isEmpty() ? localFilename.toLocalFile() : !fileInfo->originalName.isEmpty() ? - (QDir::tempPath() + '/' + fileInfo->originalName) : + (safeTempPrefix + fileInfo->originalName) : !event->plainBody().isEmpty() ? - (QDir::tempPath() + '/' + event->plainBody()) : QString(); + (safeTempPrefix + event->plainBody()) : QString(); auto job = connection()->downloadFile(fileInfo->url, fileName); if (isJobRunning(job)) { @@ -1003,8 +1152,8 @@ void Room::Private::addNewMessageEvents(RoomEvents&& events) processRedaction(move(r)); if (insertedSize > 0) { - checkUnreadMessages(timeline.cend() - insertedSize); emit q->addedMessages(); + checkUnreadMessages(timeline.cend() - insertedSize); } Q_ASSERT(timeline.size() == timelineSize + insertedSize); @@ -1016,7 +1165,7 @@ void Room::Private::checkUnreadMessages(timeline_iter_t from) const auto newUnreadMessages = count_if(from, timeline.cend(), std::bind(&Room::Private::isEventNotable, this, _1)); - // The first event in the just-added batch (referred to by upTo.base()) + // 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, @@ -1096,6 +1245,7 @@ void Room::processStateEvents(const RoomEvents& events) 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; @@ -1329,7 +1479,9 @@ QJsonObject Room::Private::toJson() const QJsonObject roomStateObj; roomStateObj.insert("events", stateEvents); - result.insert("state", roomStateObj); + result.insert( + joinState == JoinState::Invite ? "invite_state" : "state", + roomStateObj); } if (!q->readMarkerEventId().isEmpty()) @@ -1361,9 +1513,12 @@ QJsonObject Room::Private::toJson() const } QJsonObject unreadNotificationsObj; - unreadNotificationsObj.insert("highlight_count", highlightCount); - unreadNotificationsObj.insert("notification_count", notificationCount); - result.insert("unread_notifications", unreadNotificationsObj); + if (highlightCount > 0) + unreadNotificationsObj.insert("highlight_count", highlightCount); + if (notificationCount > 0) + unreadNotificationsObj.insert("notification_count", notificationCount); + if (!unreadNotificationsObj.isEmpty()) + result.insert("unread_notifications", unreadNotificationsObj); return result; } @@ -40,7 +40,6 @@ namespace QMatrixClient class MemberSorter; class LeaveRoomJob; class RedactEventJob; - class Room; class TimelineItem { @@ -105,6 +104,17 @@ namespace QMatrixClient Q_PROPERTY(QString canonicalAlias READ canonicalAlias NOTIFY namesChanged) Q_PROPERTY(QString displayName READ displayName NOTIFY namesChanged) Q_PROPERTY(QString topic READ topic NOTIFY topicChanged) + Q_PROPERTY(QString avatarMediaId READ avatarMediaId NOTIFY avatarChanged STORED false) + Q_PROPERTY(QUrl avatarUrl READ avatarUrl NOTIFY avatarChanged) + + Q_PROPERTY(int timelineSize READ timelineSize NOTIFY addedMessages) + Q_PROPERTY(QStringList memberNames READ memberNames NOTIFY memberListChanged) + Q_PROPERTY(int memberCount READ memberCount NOTIFY memberListChanged) + + Q_PROPERTY(bool displayed READ displayed WRITE setDisplayed NOTIFY displayedChanged) + Q_PROPERTY(QString firstDisplayedEventId READ firstDisplayedEventId WRITE setFirstDisplayedEventId NOTIFY firstDisplayedEventChanged) + Q_PROPERTY(QString lastDisplayedEventId READ lastDisplayedEventId WRITE setLastDisplayedEventId NOTIFY lastDisplayedEventChanged) + Q_PROPERTY(QString readMarkerEventId READ readMarkerEventId WRITE markMessagesAsRead NOTIFY readMarkerMoved) public: using Timeline = std::deque<TimelineItem>; @@ -114,6 +124,8 @@ namespace QMatrixClient Room(Connection* connection, QString id, JoinState initialJoinState); ~Room() override; + // Property accessors + Connection* connection() const; User* localUser() const; const QString& id() const; @@ -122,14 +134,16 @@ namespace QMatrixClient QString canonicalAlias() const; QString displayName() const; QString topic() const; + QString avatarMediaId() const; + QUrl avatarUrl() const; Q_INVOKABLE JoinState joinState() const; Q_INVOKABLE QList<User*> usersTyping() const; QList<User*> membersLeft() const; Q_INVOKABLE QList<User*> users() const; - Q_INVOKABLE QStringList memberNames() const; - Q_INVOKABLE int memberCount() const; - Q_INVOKABLE int timelineSize() const; + QStringList memberNames() const; + int memberCount() const; + int timelineSize() const; /** * Returns a square room avatar with the given size and requests it @@ -150,7 +164,7 @@ namespace QMatrixClient * @brief Produces a disambiguated name for a given user in * the context of the room */ - Q_INVOKABLE QString roomMembername(User* u) const; + Q_INVOKABLE QString roomMembername(const User* u) const; /** * @brief Produces a disambiguated name for a user with this id in * the context of the room @@ -170,6 +184,17 @@ namespace QMatrixClient rev_iter_t findInTimeline(TimelineItem::index_t index) const; rev_iter_t findInTimeline(const QString& evtId) const; + bool displayed() const; + void setDisplayed(bool displayed = true); + QString firstDisplayedEventId() const; + rev_iter_t firstDisplayedMarker() const; + void setFirstDisplayedEventId(const QString& eventId); + void setFirstDisplayedEvent(TimelineItem::index_t index); + QString lastDisplayedEventId() const; + rev_iter_t lastDisplayedMarker() const; + void setLastDisplayedEventId(const QString& eventId); + void setLastDisplayedEvent(TimelineItem::index_t index); + rev_iter_t readMarker(const User* user) const; rev_iter_t readMarker() const; QString readMarkerEventId() const; @@ -192,6 +217,11 @@ namespace QMatrixClient Q_INVOKABLE FileTransferInfo fileTransferInfo(const QString& id) const; + /** Pretty-prints plain text into HTML + * This includes HTML escaping of <,>,",& and URLs linkification. + */ + QString prettyPrint(const QString& plainText) const; + MemberSorter memberSorter() const; QJsonObject toJson() const; @@ -245,10 +275,17 @@ namespace QMatrixClient void userAdded(User* user); void userRemoved(User* user); void memberRenamed(User* user); + void memberListChanged(); + void joinStateChanged(JoinState oldState, JoinState newState); void typingChanged(); + void highlightCountChanged(Room* room); void notificationCountChanged(Room* room); + + void displayedChanged(bool displayed); + void firstDisplayedEventChanged(); + void lastDisplayedEventChanged(); void lastReadEventChanged(User* user); void readMarkerMoved(); void unreadMessagesChanged(Room* room); @@ -51,7 +51,9 @@ class User::Private User::User(QString userId, Connection* connection) : QObject(connection), d(new Private(std::move(userId), connection)) -{ } +{ + setObjectName(userId); +} User::~User() { @@ -74,6 +76,7 @@ void User::updateName(const QString& newName) if (oldName != newName) { d->name = newName; + setObjectName(displayname()); emit nameChanged(newName, oldName); } } @@ -127,7 +130,7 @@ QString User::bridged() const { return d->bridged; } -const Avatar& User::avatarObject() +const Avatar& User::avatarObject() const { return d->avatar; } @@ -142,6 +145,11 @@ QImage User::avatar(int width, int height) return d->avatar.get(width, height, [=] { emit avatarChanged(this); }); } +QString User::avatarMediaId() const +{ + return d->avatar.mediaId(); +} + QUrl User::avatarUrl() const { return d->avatar.url(); @@ -33,6 +33,8 @@ namespace QMatrixClient Q_PROPERTY(QString name READ name NOTIFY nameChanged) Q_PROPERTY(QString displayName READ displayname NOTIFY nameChanged STORED false) Q_PROPERTY(QString bridgeName READ bridged NOTIFY nameChanged STORED false) + Q_PROPERTY(QString avatarMediaId READ avatarMediaId NOTIFY avatarChanged STORED false) + Q_PROPERTY(QUrl avatarUrl READ avatarUrl NOTIFY avatarChanged) public: User(QString userId, Connection* connection); ~User() override; @@ -57,11 +59,12 @@ namespace QMatrixClient */ QString bridged() const; - const Avatar& avatarObject(); + const Avatar& avatarObject() const; Q_INVOKABLE QImage avatar(int dimension); Q_INVOKABLE QImage avatar(int requestedWidth, int requestedHeight); - Q_INVOKABLE QUrl avatarUrl() const; + QString avatarMediaId() const; + QUrl avatarUrl() const; void processEvent(Event* event); @@ -83,3 +86,4 @@ namespace QMatrixClient Private* d; }; } +Q_DECLARE_METATYPE(QMatrixClient::User*) |