diff options
-rw-r--r-- | CMakeLists.txt | 5 | ||||
-rw-r--r-- | connection.cpp | 192 | ||||
-rw-r--r-- | connection.h | 63 | ||||
-rw-r--r-- | connectiondata.cpp | 3 | ||||
-rw-r--r-- | events/event.cpp | 35 | ||||
-rw-r--r-- | events/event.h | 1 | ||||
-rw-r--r-- | events/receiptevent.cpp | 3 | ||||
-rw-r--r-- | events/receiptevent.h | 2 | ||||
-rw-r--r-- | events/roomtopicevent.h | 12 | ||||
-rw-r--r-- | jobs/basejob.cpp | 35 | ||||
-rw-r--r-- | jobs/converters.h | 6 | ||||
-rw-r--r-- | jobs/mediathumbnailjob.cpp | 17 | ||||
-rw-r--r-- | jobs/mediathumbnailjob.h | 5 | ||||
-rw-r--r-- | jobs/setroomstatejob.cpp | 32 | ||||
-rw-r--r-- | jobs/setroomstatejob.h | 65 | ||||
-rw-r--r-- | jobs/syncjob.cpp | 41 | ||||
-rw-r--r-- | jobs/syncjob.h | 20 | ||||
-rw-r--r-- | room.cpp | 173 | ||||
-rw-r--r-- | room.h | 3 | ||||
-rw-r--r-- | user.cpp | 54 | ||||
-rw-r--r-- | user.h | 3 |
21 files changed, 610 insertions, 160 deletions
diff --git a/CMakeLists.txt b/CMakeLists.txt index 257c5ee5..f4358521 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -74,6 +74,7 @@ set(libqmatrixclient_SRCS jobs/checkauthmethods.cpp jobs/passwordlogin.cpp jobs/sendeventjob.cpp + jobs/setroomstatejob.cpp jobs/postreceiptjob.cpp jobs/joinroomjob.cpp jobs/leaveroomjob.cpp @@ -81,7 +82,9 @@ set(libqmatrixclient_SRCS jobs/syncjob.cpp jobs/mediathumbnailjob.cpp jobs/logoutjob.cpp - ) +) + +aux_source_directory(jobs/generated libqmatrixclient_job_SRCS) if (MATRIX_DOC_PATH AND APIGEN_PATH) add_custom_target(update-api diff --git a/connection.cpp b/connection.cpp index 5d8a42e3..e20f843f 100644 --- a/connection.cpp +++ b/connection.cpp @@ -32,6 +32,12 @@ #include "jobs/mediathumbnailjob.h" #include <QtNetwork/QDnsLookup> +#include <QtCore/QFile> +#include <QtCore/QDir> +#include <QtCore/QFileInfo> +#include <QtCore/QStandardPaths> +#include <QtCore/QStringBuilder> +#include <QtCore/QElapsedTimer> using namespace QMatrixClient; @@ -61,6 +67,8 @@ class Connection::Private QString userId; SyncJob* syncJob; + + bool cacheState = true; }; Connection::Connection(const QUrl& server, QObject* parent) @@ -161,12 +169,7 @@ void Connection::sync(int timeout) auto job = d->syncJob = callApi<SyncJob>(d->data->lastEvent(), filter, timeout); connect( job, &SyncJob::success, [=] () { - d->data->setLastEvent(job->nextBatch()); - for( auto&& roomData: job->takeRoomData() ) - { - if ( auto* r = provideRoom(roomData.roomId, roomData.joinState) ) - r->updateData(std::move(roomData)); - } + onSyncSuccess(job->takeData()); d->syncJob = nullptr; emit syncDone(); }); @@ -180,6 +183,16 @@ void Connection::sync(int timeout) }); } +void Connection::onSyncSuccess(SyncData &&data) { + d->data->setLastEvent(data.nextBatch()); + for( auto&& roomData: data.takeRoomData() ) + { + if ( auto* r = provideRoom(roomData.roomId, roomData.joinState) ) + r->updateData(std::move(roomData)); + } + +} + void Connection::stopSync() { if (d->syncJob) @@ -271,9 +284,18 @@ int Connection::millisToReconnect() const return d->syncJob ? d->syncJob->millisToRetry() : 0; } -const QHash< QPair<QString, bool>, Room* >& Connection::roomMap() const +QHash< QPair<QString, bool>, Room* > Connection::roomMap() const { - return d->roomMap; + // Copy-on-write-and-remove-elements is faster than copying elements one by one. + QHash< QPair<QString, bool>, Room* > roomMap = d->roomMap; + for (auto it = roomMap.begin(); it != roomMap.end(); ) + { + if (it.value()->joinState() == JoinState::Leave) + it = roomMap.erase(it); + else + ++it; + } + return roomMap; } const ConnectionData* Connection::connectionData() const @@ -290,35 +312,60 @@ Room* Connection::provideRoom(const QString& id, JoinState joinState) return nullptr; } + // Room transitions: + // 1. none -> Invite: r=createRoom, emit invitedRoom(r,null) + // 2. none -> Join: r=createRoom, emit joinedRoom(r,null) + // 3. none -> Leave: r=createRoom, emit leftRoom(r,null) + // 4. inv=Invite -> Join: r=createRoom, emit joinedRoom(r,inv), delete Invite + // 4a. Leave, inv=Invite -> Join: change state, emit joinedRoom(r,inv), delete Invite + // 5. inv=Invite -> Leave: r=createRoom, emit leftRoom(r,inv), delete Invite + // 5a. r=Leave, inv=Invite -> Leave: emit leftRoom(r,inv), delete Invite + // 6. Join -> Leave: change state + // 7. r=Leave -> Invite: inv=createRoom, emit invitedRoom(inv,r) + // 8. Leave -> (changes to) Join const auto roomKey = qMakePair(id, joinState == JoinState::Invite); auto* room = d->roomMap.value(roomKey, nullptr); - if (!room) + if (room) + { + // Leave is a special case because in transition (5a) above + // joinState == room->joinState but we still have to preempt the Invite + // and emit a signal. For Invite and Join, there's no such problem. + if (room->joinState() == joinState && joinState != JoinState::Leave) + return room; + } + else { room = createRoom(this, id, joinState); if (!room) { - qCritical() << "Failed to create a room!!!" << id; + qCCritical(MAIN) << "Failed to create a room" << id; return nullptr; } - qCDebug(MAIN) << "Created Room" << id << ", invited:" << roomKey.second; - d->roomMap.insert(roomKey, room); + qCDebug(MAIN) << "Created Room" << id << ", invited:" << roomKey.second; emit newRoom(room); } - else if (room->joinState() != joinState) + if (joinState == JoinState::Invite) { - room->setJoinState(joinState); - if (joinState == JoinState::Leave) - emit leftRoom(room); - else if (joinState == JoinState::Join) - emit joinedRoom(room); + // prev is either Leave or nullptr + auto* prev = d->roomMap.value({id, false}, nullptr); + emit invitedRoom(room, prev); } - - if (joinState != JoinState::Invite && d->roomMap.contains({id, true})) + else { - // Preempt the Invite room after it's been acted upon (joined or left). - qCDebug(MAIN) << "Deleting invited state"; - delete d->roomMap.take({id, true}); + room->setJoinState(joinState); + // Preempt the Invite room (if any) with a room in Join/Leave state. + auto* prevInvite = d->roomMap.take({id, true}); + if (joinState == JoinState::Join) + emit joinedRoom(room, prevInvite); + else if (joinState == JoinState::Leave) + emit leftRoom(room, prevInvite); + if (prevInvite) + { + qCDebug(MAIN) << "Deleting Invite state for room" << prevInvite->id(); + emit aboutToDeleteRoom(prevInvite); + delete prevInvite; + } } return room; @@ -334,3 +381,102 @@ QByteArray Connection::generateTxnId() { return d->data->generateTxnId(); } + +void Connection::saveState(const QUrl &toFile) const +{ + if (!d->cacheState) + return; + + QElapsedTimer et; et.start(); + + QFileInfo stateFile { + toFile.isEmpty() ? stateCachePath() : toFile.toLocalFile() + }; + if (!stateFile.dir().exists()) + stateFile.dir().mkpath("."); + + QFile outfile { stateFile.absoluteFilePath() }; + if (!outfile.open(QFile::WriteOnly)) + { + qCWarning(MAIN) << "Error opening" << stateFile.absoluteFilePath() + << ":" << outfile.errorString(); + qCWarning(MAIN) << "Caching the rooms state disabled"; + d->cacheState = false; + return; + } + + QJsonObject roomObj; + { + QJsonObject rooms; + QJsonObject inviteRooms; + for (auto i : roomMap()) // Pass on rooms in Leave state + { + if (i->joinState() == JoinState::Invite) + inviteRooms.insert(i->id(), i->toJson()); + else + rooms.insert(i->id(), i->toJson()); + } + + if (!rooms.isEmpty()) + roomObj.insert("join", rooms); + if (!inviteRooms.isEmpty()) + roomObj.insert("invite", inviteRooms); + } + + QJsonObject rootObj; + rootObj.insert("next_batch", d->data->lastEvent()); + rootObj.insert("rooms", roomObj); + + QByteArray data = QJsonDocument(rootObj).toJson(QJsonDocument::Compact); + + qCDebug(MAIN) << "Writing state to file" << outfile.fileName(); + outfile.write(data.data(), data.size()); + qCDebug(PROFILER) << "*** Cached state for" << userId() + << "saved in" << et.elapsed() << "ms"; +} + +void Connection::loadState(const QUrl &fromFile) +{ + if (!d->cacheState) + return; + + QElapsedTimer et; et.start(); + QFile file { + fromFile.isEmpty() ? stateCachePath() : fromFile.toLocalFile() + }; + if (!file.exists()) + { + qCDebug(MAIN) << "No state cache file found"; + return; + } + file.open(QFile::ReadOnly); + QByteArray data = file.readAll(); + + SyncData sync; + sync.parseJson(QJsonDocument::fromJson(data)); + onSyncSuccess(std::move(sync)); + qCDebug(PROFILER) << "*** Cached state for" << userId() + << "loaded in" << et.elapsed() << "ms"; +} + +QString Connection::stateCachePath() const +{ + auto safeUserId = userId(); + safeUserId.replace(':', '_'); + return QStandardPaths::writableLocation(QStandardPaths::CacheLocation) + % '/' % safeUserId % "_state.json"; +} + +bool Connection::cacheState() const +{ + return d->cacheState; +} + +void Connection::setCacheState(bool newValue) +{ + if (d->cacheState != newValue) + { + d->cacheState = newValue; + emit cacheStateChanged(); + } +} diff --git a/connection.h b/connection.h index b118ffb0..4ca6fbc5 100644 --- a/connection.h +++ b/connection.h @@ -35,6 +35,7 @@ namespace QMatrixClient class ConnectionData; class SyncJob; + class SyncData; class RoomMessagesJob; class PostReceiptJob; class MediaThumbnailJob; @@ -42,6 +43,11 @@ namespace QMatrixClient class Connection: public QObject { Q_OBJECT + + /** Whether or not the rooms state should be cached locally + * \sa loadState(), saveState() + */ + Q_PROPERTY(bool cacheState READ cacheState WRITE setCacheState NOTIFY cacheStateChanged) public: using room_factory_t = std::function<Room*(Connection*, const QString&, JoinState joinState)>; @@ -52,7 +58,7 @@ namespace QMatrixClient Connection(); virtual ~Connection(); - const QHash<QPair<QString, bool>, Room*>& roomMap() const; + QHash<QPair<QString, bool>, Room*> roomMap() const; Q_INVOKABLE virtual void resolveServer(const QString& domain); Q_INVOKABLE virtual void connectToServer(const QString& user, @@ -72,13 +78,16 @@ namespace QMatrixClient /** @deprecated Use callApi<PostReceiptJob>() or Room::postReceipt() instead */ Q_INVOKABLE virtual PostReceiptJob* postReceipt(Room* room, RoomEvent* event) const; + /** @deprecated Use callApi<JoinRoomJob>() instead */ Q_INVOKABLE virtual JoinRoomJob* joinRoom(const QString& roomAlias); /** @deprecated Use callApi<LeaveRoomJob>() or Room::leaveRoom() instead */ Q_INVOKABLE virtual void leaveRoom( Room* room ); Q_INVOKABLE virtual RoomMessagesJob* getMessages(Room* room, const QString& from) const; + /** @deprecated Use callApi<MediaThumbnailJob>() instead */ virtual MediaThumbnailJob* getThumbnail(const QUrl& url, QSize requestedSize) const; + /** @deprecated Use callApi<MediaThumbnailJob>() instead */ MediaThumbnailJob* getThumbnail(const QUrl& url, int requestedWidth, int requestedHeight) const; @@ -92,6 +101,44 @@ namespace QMatrixClient Q_INVOKABLE SyncJob* syncJob() const; Q_INVOKABLE int millisToReconnect() const; + /** + * Call this before first sync to load from previously saved file. + * + * \param fromFile A local path to read the state from. Uses QUrl + * to be QML-friendly. Empty parameter means using a path + * defined by stateCachePath(). + */ + Q_INVOKABLE void loadState(const QUrl &fromFile = {}); + /** + * This method saves the current state of rooms (but not messages + * in them) to a local cache file, so that it could be loaded by + * loadState() on a next run of the client. + * + * \param toFile A local path to save the state to. Uses QUrl to be + * QML-friendly. Empty parameter means using a path defined by + * stateCachePath(). + */ + Q_INVOKABLE void saveState(const QUrl &toFile = {}) const; + + /** + * The default path to store the cached room state, defined as + * follows: + * QStandardPaths::writeableLocation(QStandardPaths::CacheLocation) + _safeUserId + "_state.json" + * where `_safeUserId` is userId() with `:` (colon) replaced with + * `_` (underscore) + * /see loadState(), saveState() + */ + Q_INVOKABLE QString stateCachePath() const; + + bool cacheState() const; + void setCacheState(bool newValue); + + /** + * This is a universal method to start a job of a type passed + * as a template parameter. Arguments to callApi() are arguments + * to the job constructor _except_ the first ConnectionData* + * argument - callApi() will pass it automatically. + */ template <typename JobT, typename... JobArgTs> JobT* callApi(JobArgTs... jobArgs) const { @@ -128,8 +175,10 @@ namespace QMatrixClient void syncDone(); void newRoom(Room* room); - void joinedRoom(Room* room); - void leftRoom(Room* room); + void invitedRoom(Room* room, Room* prev); + void joinedRoom(Room* room, Room* prev); + void leftRoom(Room* room, Room* prev); + void aboutToDeleteRoom(Room* room); void loginError(QString error); void networkError(size_t nextAttempt, int inMilliseconds); @@ -137,6 +186,8 @@ namespace QMatrixClient void syncError(QString error); //void jobError(BaseJob* job); + void cacheStateChanged(); + protected: /** * @brief Access the underlying ConnectionData class @@ -154,6 +205,12 @@ namespace QMatrixClient */ Room* provideRoom(const QString& roomId, JoinState joinState); + + /** + * Completes loading sync data. + */ + void onSyncSuccess(SyncData &&data); + private: class Private; Private* d; diff --git a/connectiondata.cpp b/connectiondata.cpp index cd91ef27..6f15577e 100644 --- a/connectiondata.cpp +++ b/connectiondata.cpp @@ -21,7 +21,6 @@ #include "logging.h" #include <QtNetwork/QNetworkAccessManager> -#include <cstdlib> using namespace QMatrixClient; @@ -38,7 +37,7 @@ struct ConnectionData::Private QString lastEvent; mutable unsigned int txnCounter = 0; - const int id = std::rand(); // We don't really care about pure randomness + const qint64 id = QDateTime::currentMSecsSinceEpoch(); }; ConnectionData::ConnectionData(QUrl baseUrl) diff --git a/events/event.cpp b/events/event.cpp index 8a6de822..d718306d 100644 --- a/events/event.cpp +++ b/events/event.cpp @@ -48,6 +48,11 @@ QByteArray Event::originalJson() const return QJsonDocument(_originalJson).toJson(); } +QJsonObject Event::originalJsonObject() const +{ + return _originalJson; +} + QDateTime Event::toTimestamp(const QJsonValue& v) { Q_ASSERT(v.isDouble() || v.isNull() || v.isUndefined()); @@ -97,21 +102,21 @@ RoomEvent::RoomEvent(Type type, const QJsonObject& rep) , _senderId(rep["sender"].toString()) , _txnId(rep["unsigned"].toObject().value("transactionId").toString()) { - if (_id.isEmpty()) - { - qCWarning(EVENTS) << "Can't find event_id in a room event"; - qCWarning(EVENTS) << formatJson << rep; - } - if (!rep.contains("origin_server_ts")) - { - qCWarning(EVENTS) << "Can't find server timestamp in a room event"; - qCWarning(EVENTS) << formatJson << rep; - } - if (_senderId.isEmpty()) - { - qCWarning(EVENTS) << "Can't find sender in a room event"; - qCWarning(EVENTS) << formatJson << rep; - } +// if (_id.isEmpty()) +// { +// qCWarning(EVENTS) << "Can't find event_id in a room event"; +// qCWarning(EVENTS) << formatJson << rep; +// } +// if (!rep.contains("origin_server_ts")) +// { +// qCWarning(EVENTS) << "Can't find server timestamp in a room event"; +// qCWarning(EVENTS) << formatJson << rep; +// } +// if (_senderId.isEmpty()) +// { +// qCWarning(EVENTS) << "Can't find sender in a room event"; +// qCWarning(EVENTS) << formatJson << rep; +// } if (!_txnId.isEmpty()) qCDebug(EVENTS) << "Event transactionId:" << _txnId; } diff --git a/events/event.h b/events/event.h index 8760aa28..7db14100 100644 --- a/events/event.h +++ b/events/event.h @@ -43,6 +43,7 @@ namespace QMatrixClient Type type() const { return _type; } QByteArray originalJson() const; + QJsonObject originalJsonObject() const; // According to the CS API spec, every event also has // a "content" object; but since its structure is different for diff --git a/events/receiptevent.cpp b/events/receiptevent.cpp index e3478cf1..3d6be9f1 100644 --- a/events/receiptevent.cpp +++ b/events/receiptevent.cpp @@ -46,7 +46,7 @@ ReceiptEvent::ReceiptEvent(const QJsonObject& obj) { Q_ASSERT(obj["type"].toString() == jsonType); - const QJsonObject contents = obj["content"].toObject(); + const QJsonObject contents = contentJson(); _eventsWithReceipts.reserve(static_cast<size_t>(contents.size())); for( auto eventIt = contents.begin(); eventIt != contents.end(); ++eventIt ) { @@ -66,5 +66,6 @@ ReceiptEvent::ReceiptEvent(const QJsonObject& obj) } _eventsWithReceipts.push_back({eventIt.key(), receipts}); } + _unreadMessages = obj["x-qmatrixclient.unread_messages"].toBool(); } diff --git a/events/receiptevent.h b/events/receiptevent.h index 1d280822..cbe36b10 100644 --- a/events/receiptevent.h +++ b/events/receiptevent.h @@ -41,9 +41,11 @@ namespace QMatrixClient EventsWithReceipts eventsWithReceipts() const { return _eventsWithReceipts; } + bool unreadMessages() const { return _unreadMessages; } private: EventsWithReceipts _eventsWithReceipts; + bool _unreadMessages; // Spec extension for caching purposes static constexpr const char * jsonType = "m.receipt"; }; diff --git a/events/roomtopicevent.h b/events/roomtopicevent.h index fb849afe..95ad0e04 100644 --- a/events/roomtopicevent.h +++ b/events/roomtopicevent.h @@ -25,6 +25,9 @@ namespace QMatrixClient class RoomTopicEvent: public RoomEvent { public: + explicit RoomTopicEvent(const QString& topic) + : RoomEvent(Type::RoomTopic), _topic(topic) + { } explicit RoomTopicEvent(const QJsonObject& obj) : RoomEvent(Type::RoomTopic, obj) , _topic(contentJson()["topic"].toString()) @@ -32,6 +35,15 @@ namespace QMatrixClient QString topic() const { return _topic; } + QJsonObject toJson() const + { + QJsonObject obj; + obj.insert("topic", _topic); + return obj; + } + + static constexpr const char* TypeId = "m.room.topic"; + private: QString _topic; }; diff --git a/jobs/basejob.cpp b/jobs/basejob.cpp index 26ceb268..ea1a7158 100644 --- a/jobs/basejob.cpp +++ b/jobs/basejob.cpp @@ -24,6 +24,7 @@ #include <QtNetwork/QNetworkRequest> #include <QtNetwork/QNetworkReply> #include <QtCore/QTimer> +#include <QtCore/QStringBuilder> #include <array> @@ -76,7 +77,7 @@ class BaseJob::Private inline QDebug operator<<(QDebug dbg, const BaseJob* j) { - return dbg << "Job" << j->objectName(); + return dbg << j->objectName(); } BaseJob::BaseJob(const ConnectionData* connection, HttpVerb verb, @@ -89,7 +90,6 @@ BaseJob::BaseJob(const ConnectionData* connection, HttpVerb verb, connect (&d->timer, &QTimer::timeout, this, &BaseJob::timeout); d->retryTimer.setSingleShot(true); connect (&d->retryTimer, &QTimer::timeout, this, &BaseJob::start); - qCDebug(d->logCat) << this << "created"; } BaseJob::~BaseJob() @@ -159,11 +159,20 @@ void BaseJob::start() { emit aboutToStart(); d->retryTimer.stop(); // In case we were counting down at the moment + qCDebug(d->logCat) << this << "sending request to" << d->apiEndpoint; + if (!d->requestQuery.isEmpty()) + qCDebug(d->logCat) << " query:" << d->requestQuery.toString(); d->sendRequest(); connect( d->reply.data(), &QNetworkReply::sslErrors, this, &BaseJob::sslErrors ); connect( d->reply.data(), &QNetworkReply::finished, this, &BaseJob::gotReply ); - d->timer.start(getCurrentTimeout()); - emit started(); + if (d->reply->isRunning()) + { + d->timer.start(getCurrentTimeout()); + qCDebug(d->logCat) << this << "request has been sent"; + emit started(); + } + else + qCWarning(d->logCat) << this << "request could not start"; } void BaseJob::gotReply() @@ -219,16 +228,17 @@ BaseJob::Status BaseJob::parseJson(const QJsonDocument&) void BaseJob::stop() { d->timer.stop(); - if (!d->reply) - { - qCWarning(d->logCat) << this << "stopped with empty network reply"; - } - else if (d->reply->isRunning()) + if (d->reply) { - qCWarning(d->logCat) << this << "stopped without ready network reply"; d->reply->disconnect(this); // Ignore whatever comes from the reply - d->reply->abort(); + if (d->reply->isRunning()) + { + qCWarning(d->logCat) << this << "stopped without ready network reply"; + d->reply->abort(); + } } + else + qCWarning(d->logCat) << this << "stopped with empty network reply"; } void BaseJob::finishJob() @@ -320,6 +330,9 @@ void BaseJob::setStatus(int code, QString message) void BaseJob::abandon() { + this->disconnect(); + if (d->reply) + d->reply->disconnect(this); deleteLater(); } diff --git a/jobs/converters.h b/jobs/converters.h index 376dfeab..f9ab0269 100644 --- a/jobs/converters.h +++ b/jobs/converters.h @@ -21,6 +21,7 @@ #include <QtCore/QJsonValue> #include <QtCore/QJsonArray> #include <QtCore/QDate> +#include <QtCore/QVariant> namespace QMatrixClient { @@ -83,7 +84,6 @@ namespace QMatrixClient template <> inline QDate fromJson<QDate>(const QJsonValue& jv) { - return QDateTime::fromMSecsSinceEpoch( - fromJson<qint64>(jv), Qt::UTC).date(); + return fromJson<QDateTime>(jv).date(); } -} // namespace QMatrixClient
\ No newline at end of file +} // namespace QMatrixClient diff --git a/jobs/mediathumbnailjob.cpp b/jobs/mediathumbnailjob.cpp index 9bb731b9..9579f6b2 100644 --- a/jobs/mediathumbnailjob.cpp +++ b/jobs/mediathumbnailjob.cpp @@ -23,12 +23,6 @@ using namespace QMatrixClient; -class MediaThumbnailJob::Private -{ - public: - QPixmap thumbnail; -}; - MediaThumbnailJob::MediaThumbnailJob(const ConnectionData* data, QUrl url, QSize requestedSize, ThumbnailType thumbnailType) : BaseJob(data, HttpVerb::Get, "MediaThumbnailJob", @@ -39,22 +33,21 @@ MediaThumbnailJob::MediaThumbnailJob(const ConnectionData* data, QUrl url, QSize , { "method", thumbnailType == ThumbnailType::Scale ? "scale" : "crop" } })) - , d(new Private) { } -MediaThumbnailJob::~MediaThumbnailJob() +QPixmap MediaThumbnailJob::thumbnail() { - delete d; + return pixmap; } -QPixmap MediaThumbnailJob::thumbnail() +QPixmap MediaThumbnailJob::scaledThumbnail(QSize toSize) { - return d->thumbnail; + return pixmap.scaled(toSize, Qt::KeepAspectRatio, Qt::SmoothTransformation); } BaseJob::Status MediaThumbnailJob::parseReply(QByteArray data) { - if( !d->thumbnail.loadFromData(data) ) + if( !pixmap.loadFromData(data) ) { qCDebug(JOBS) << "MediaThumbnailJob: could not read image data"; } diff --git a/jobs/mediathumbnailjob.h b/jobs/mediathumbnailjob.h index 307d0a99..186da829 100644 --- a/jobs/mediathumbnailjob.h +++ b/jobs/mediathumbnailjob.h @@ -31,15 +31,14 @@ namespace QMatrixClient public: MediaThumbnailJob(const ConnectionData* data, QUrl url, QSize requestedSize, ThumbnailType thumbnailType=ThumbnailType::Scale); - virtual ~MediaThumbnailJob(); QPixmap thumbnail(); + QPixmap scaledThumbnail(QSize toSize); protected: Status parseReply(QByteArray data) override; private: - class Private; - Private* d; + QPixmap pixmap; }; } diff --git a/jobs/setroomstatejob.cpp b/jobs/setroomstatejob.cpp new file mode 100644 index 00000000..c2beb87b --- /dev/null +++ b/jobs/setroomstatejob.cpp @@ -0,0 +1,32 @@ +/****************************************************************************** + * 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 "setroomstatejob.h" + +using namespace QMatrixClient; + +BaseJob::Status SetRoomStateJob::parseJson(const QJsonDocument& data) +{ + _eventId = data.object().value("event_id").toString(); + if (!_eventId.isEmpty()) + return Success; + + qCDebug(JOBS) << data; + return { UserDefinedError, "No event_id in the JSON response" }; +} + diff --git a/jobs/setroomstatejob.h b/jobs/setroomstatejob.h new file mode 100644 index 00000000..1c72f31c --- /dev/null +++ b/jobs/setroomstatejob.h @@ -0,0 +1,65 @@ +/****************************************************************************** + * 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 + */ + +#pragma once + +#include "basejob.h" + +#include "connectiondata.h" + +namespace QMatrixClient +{ + class SetRoomStateJob: public BaseJob + { + public: + /** + * Constructs a job that sets a state using an arbitrary room event + * with a state key. + */ + template <typename EvT> + SetRoomStateJob(const ConnectionData* connection, const QString& roomId, + const EvT* event, const QString& stateKey) + : BaseJob(connection, HttpVerb::Put, "SetRoomStateJob", + QStringLiteral("_matrix/client/r0/rooms/%1/state/%2/%3") + .arg(roomId, EvT::TypeId, stateKey), + Query(), + Data(event->toJson())) + { } + /** + * Constructs a job that sets a state using an arbitrary room event + * without a state key. + */ + template <typename EvT> + SetRoomStateJob(const ConnectionData* connection, const QString& roomId, + const EvT* event) + : BaseJob(connection, HttpVerb::Put, "SetRoomStateJob", + QStringLiteral("_matrix/client/r0/rooms/%1/state/%2") + .arg(roomId, EvT::TypeId), + Query(), + Data(event->toJson())) + { } + + QString eventId() const { return _eventId; } + + protected: + Status parseJson(const QJsonDocument& data) override; + + private: + QString _eventId; + }; +} // namespace QMatrixClient diff --git a/jobs/syncjob.cpp b/jobs/syncjob.cpp index 38cfcb2a..f679e6f4 100644 --- a/jobs/syncjob.cpp +++ b/jobs/syncjob.cpp @@ -22,20 +22,12 @@ using namespace QMatrixClient; -class SyncJob::Private -{ - public: - QString nextBatch; - SyncData roomData; -}; - static size_t jobId = 0; SyncJob::SyncJob(const ConnectionData* connection, const QString& since, const QString& filter, int timeout, const QString& presence) : BaseJob(connection, HttpVerb::Get, QString("SyncJob-%1").arg(++jobId), "_matrix/client/r0/sync") - , d(new Private) { setLoggingCategory(SYNCJOB); QUrlQuery query; @@ -52,47 +44,48 @@ SyncJob::SyncJob(const ConnectionData* connection, const QString& since, setMaxRetries(std::numeric_limits<int>::max()); } -SyncJob::~SyncJob() +QString SyncData::nextBatch() const { - delete d; + return nextBatch_; } -QString SyncJob::nextBatch() const +SyncDataList&& SyncData::takeRoomData() { - return d->nextBatch; + return std::move(roomData); } -SyncData&& SyncJob::takeRoomData() +BaseJob::Status SyncJob::parseJson(const QJsonDocument& data) { - return std::move(d->roomData); + return d.parseJson(data); } -BaseJob::Status SyncJob::parseJson(const QJsonDocument& data) +BaseJob::Status SyncData::parseJson(const QJsonDocument &data) { QElapsedTimer et; et.start(); + QJsonObject json = data.object(); - d->nextBatch = json.value("next_batch").toString(); + nextBatch_ = json.value("next_batch").toString(); // TODO: presence // TODO: account_data QJsonObject rooms = json.value("rooms").toObject(); - const struct { QString jsonKey; JoinState enumVal; } roomStates[] + static const struct { QString jsonKey; JoinState enumVal; } roomStates[] { { "join", JoinState::Join }, { "invite", JoinState::Invite }, { "leave", JoinState::Leave } }; - for (auto roomState: roomStates) + for (const auto& roomState: roomStates) { const QJsonObject rs = rooms.value(roomState.jsonKey).toObject(); // We have a Qt container on the right and an STL one on the left - d->roomData.reserve(static_cast<size_t>(rs.size())); - for( auto rkey: rs.keys() ) - d->roomData.emplace_back(rkey, roomState.enumVal, rs[rkey].toObject()); + roomData.reserve(static_cast<size_t>(rs.size())); + for(auto roomIt = rs.begin(); roomIt != rs.end(); ++roomIt) + roomData.emplace_back(roomIt.key(), roomState.enumVal, + roomIt.value().toObject()); } - qCDebug(PROFILER) << "*** SyncJob::parseJson():" << et.elapsed() << "ms"; - - return Success; + qCDebug(PROFILER) << "*** SyncData::parseJson():" << et.elapsed() << "ms"; + return BaseJob::Success; } SyncRoomData::SyncRoomData(const QString& roomId_, JoinState joinState_, diff --git a/jobs/syncjob.h b/jobs/syncjob.h index 57a87c9f..6697a265 100644 --- a/jobs/syncjob.h +++ b/jobs/syncjob.h @@ -66,7 +66,18 @@ Q_DECLARE_TYPEINFO(QMatrixClient::SyncRoomData, Q_MOVABLE_TYPE); namespace QMatrixClient { // QVector cannot work with non-copiable objects, std::vector can. - using SyncData = std::vector<SyncRoomData>; + using SyncDataList = std::vector<SyncRoomData>; + + class SyncData { + public: + BaseJob::Status parseJson(const QJsonDocument &data); + SyncDataList&& takeRoomData(); + QString nextBatch() const; + + private: + QString nextBatch_; + SyncDataList roomData; + }; class SyncJob: public BaseJob { @@ -74,16 +85,13 @@ namespace QMatrixClient explicit SyncJob(const ConnectionData* connection, const QString& since = {}, const QString& filter = {}, int timeout = -1, const QString& presence = {}); - virtual ~SyncJob(); - SyncData&& takeRoomData(); - QString nextBatch() const; + SyncData &&takeData() { return std::move(d); } protected: Status parseJson(const QJsonDocument& data) override; private: - class Private; - Private* d; + SyncData d; }; } // namespace QMatrixClient @@ -26,6 +26,7 @@ #include <QtCore/QHash> #include <QtCore/QStringBuilder> // for efficient string concats (operator%) #include <QtCore/QElapsedTimer> +#include <jobs/setroomstatejob.h> #include "connection.h" #include "state.h" @@ -120,7 +121,10 @@ class Room::Private void dropDuplicateEvents(RoomEvents* events) const; void setLastReadEvent(User* u, const QString& eventId); - rev_iter_pair_t promoteReadMarker(User* u, rev_iter_t newMarker); + rev_iter_pair_t promoteReadMarker(User* u, rev_iter_t newMarker, + bool force = false); + + QJsonObject toJson() const; private: QString calculateDisplayname() const; @@ -211,13 +215,14 @@ void Room::Private::setLastReadEvent(User* u, const QString& eventId) } Room::Private::rev_iter_pair_t -Room::Private::promoteReadMarker(User* u, Room::rev_iter_t newMarker) +Room::Private::promoteReadMarker(User* u, Room::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 (prevMarker <= newMarker) // Remember, we deal with reverse iterators + if (!force && prevMarker <= newMarker) // Remember, we deal with reverse iterators return { prevMarker, prevMarker }; Q_ASSERT(newMarker < timeline.crend()); @@ -411,10 +416,9 @@ void Room::Private::removeMemberFromMap(const QString& username, User* u) emit q->memberRenamed(formerNamesakes[0]); } -inline QByteArray makeErrorStr(const Event* e, const char* msg) +inline QByteArray makeErrorStr(const Event* e, QByteArray msg) { - return QString("%1; event dump follows:\n%2") - .arg(msg, QString(e->originalJson())).toUtf8(); + return msg.append("; event dump follows:\n").append(e->originalJson()); } void Room::Private::insertEvent(RoomEvent* e, Timeline::iterator where, @@ -534,27 +538,35 @@ void Room::updateData(SyncRoomData&& data) d->prevBatch = data.timelinePrevBatch; setJoinState(data.joinState); - QElapsedTimer et; et.start(); - - processStateEvents(data.state); - qCDebug(PROFILER) << "*** Room::processStateEvents(state):" - << et.elapsed() << "ms," << data.state.size() << "events"; - - et.restart(); - // State changes can arrive in a timeline event; so check those. - processStateEvents(data.timeline); - qCDebug(PROFILER) << "*** Room::processStateEvents(timeline):" - << et.elapsed() << "ms," << data.timeline.size() << "events"; - et.restart(); - addNewMessageEvents(data.timeline.release()); - qCDebug(PROFILER) << "*** Room::addNewMessageEvents():" << et.elapsed() << "ms"; - - et.restart(); - for( auto ephemeralEvent: data.ephemeral ) + QElapsedTimer et; + if (!data.state.empty()) + { + et.start(); + processStateEvents(data.state); + qCDebug(PROFILER) << "*** Room::processStateEvents(state):" + << et.elapsed() << "ms," << data.state.size() << "events"; + } + if (!data.timeline.empty()) { - processEphemeralEvent(ephemeralEvent); + et.restart(); + // State changes can arrive in a timeline event; so check those. + processStateEvents(data.timeline); + qCDebug(PROFILER) << "*** Room::processStateEvents(timeline):" + << et.elapsed() << "ms," << data.timeline.size() << "events"; + + et.restart(); + addNewMessageEvents(data.timeline.release()); + qCDebug(PROFILER) << "*** Room::addNewMessageEvents():" + << et.elapsed() << "ms"; + } + if (!data.ephemeral.empty()) + { + et.restart(); + for( auto ephemeralEvent: data.ephemeral ) + processEphemeralEvent(ephemeralEvent); + qCDebug(PROFILER) << "*** Room::processEphemeralEvents():" + << et.elapsed() << "ms"; } - qCDebug(PROFILER) << "*** Room::processEphemeralEvents():" << et.elapsed() << "ms"; if( data.highlightCount != d->highlightCount ) { @@ -584,6 +596,12 @@ void Room::postMessage(RoomMessageEvent* event) connection()->callApi<SendEventJob>(id(), event); } +void Room::setTopic(const QString& newTopic) +{ + RoomTopicEvent evt(newTopic); + connection()->callApi<SetRoomStateJob>(id(), &evt); +} + void Room::getPreviousContent(int limit) { d->getPreviousContent(limit); @@ -701,9 +719,22 @@ void Room::addHistoricalMessageEvents(RoomEvents events) void Room::doAddHistoricalMessageEvents(const RoomEvents& events) { Q_ASSERT(!events.empty()); + + const bool thereWasNoReadMarker = readMarker() == timelineEdge(); // Historical messages arrive in newest-to-oldest order for (auto e: events) d->prependEvent(e); + + // Catch a special case when the last read event id refers to an event + // that was outside the loaded timeline and has just arrived. Depending on + // other messages next to the last read one, we might need to promote + // the read marker and update unreadMessages flag. + const auto curReadMarker = readMarker(); + if (thereWasNoReadMarker && curReadMarker != timelineEdge()) + { + qCDebug(MAIN) << "Discovered last read event in a historical batch"; + d->promoteReadMarker(localUser(), curReadMarker, true); + } qCDebug(MAIN) << "Room" << displayName() << "received" << events.size() << "past events; the oldest event is now" << d->timeline.front(); } @@ -815,6 +846,8 @@ void Room::processEphemeralEvent(Event* event) d->setLastReadEvent(m, p.evtId); } } + if (receiptEvent->unreadMessages()) + d->unreadMessages = true; break; } default: @@ -902,6 +935,95 @@ void Room::Private::updateDisplayname() emit q->displaynameChanged(q); } +QJsonObject stateEventToJson(const QString& type, const QString& name, + const QJsonValue& content) +{ + QJsonObject contentObj; + contentObj.insert(name, content); + + QJsonObject eventObj; + eventObj.insert("type", type); + eventObj.insert("content", contentObj); + + return eventObj; +} + +QJsonObject Room::Private::toJson() const +{ + QJsonObject result; + { + QJsonArray stateEvents; + + stateEvents.append(stateEventToJson("m.room.name", "name", name)); + stateEvents.append(stateEventToJson("m.room.topic", "topic", topic)); + stateEvents.append(stateEventToJson("m.room.aliases", "aliases", + QJsonArray::fromStringList(aliases))); + stateEvents.append(stateEventToJson("m.room.canonical_alias", "alias", + canonicalAlias)); + + for (const auto &i : membersMap) + { + QJsonObject content; + content.insert("membership", QStringLiteral("join")); + content.insert("displayname", i->displayname()); + content.insert("avatar_url", i->avatarUrl().toString()); + + QJsonObject memberEvent; + memberEvent.insert("type", QStringLiteral("m.room.member")); + memberEvent.insert("state_key", i->id()); + memberEvent.insert("content", content); + stateEvents.append(memberEvent); + } + + QJsonObject roomStateObj; + roomStateObj.insert("events", stateEvents); + + result.insert("state", roomStateObj); + } + + if (!q->readMarkerEventId().isEmpty()) + { + QJsonArray ephemeralEvents; + { + // Don't dump the timestamp because it's useless in the cache. + QJsonObject user; + user.insert(connection->userId(), {}); + + QJsonObject receipt; + receipt.insert("m.read", user); + + QJsonObject lastReadEvent; + lastReadEvent.insert(q->readMarkerEventId(), receipt); + + QJsonObject receiptsObj; + receiptsObj.insert("type", QStringLiteral("m.receipt")); + receiptsObj.insert("content", lastReadEvent); + // In extension of the spec we add a hint to the receipt event + // to allow setting the unread indicator without downloading + // and analysing the timeline. + receiptsObj.insert("x-qmatrixclient.unread_messages", unreadMessages); + ephemeralEvents.append(receiptsObj); + } + + QJsonObject ephemeralObj; + ephemeralObj.insert("events", ephemeralEvents); + + result.insert("ephemeral", ephemeralObj); + } + + QJsonObject unreadNotificationsObj; + unreadNotificationsObj.insert("highlight_count", highlightCount); + unreadNotificationsObj.insert("notification_count", notificationCount); + result.insert("unread_notifications", unreadNotificationsObj); + + return result; +} + +QJsonObject Room::toJson() const +{ + return d->toJson(); +} + MemberSorter Room::memberSorter() const { return MemberSorter(this); @@ -917,4 +1039,3 @@ bool MemberSorter::operator()(User *u1, User *u2) const n2.remove(0, 1); return n1.localeAwareCompare(n2) < 0; } - @@ -145,12 +145,15 @@ namespace QMatrixClient MemberSorter memberSorter() const; + QJsonObject toJson() const; + public slots: void postMessage(const QString& plainText, MessageEventType type = MessageEventType::Text); void postMessage(RoomMessageEvent* event); /** @deprecated */ void postMessage(const QString& type, const QString& plainText); + void setTopic(const QString& newTopic); void getPreviousContent(int limit = 10); @@ -28,7 +28,6 @@ #include <QtCore/QDebug> #include <QtGui/QIcon> #include <QtCore/QRegularExpression> -#include <algorithm> using namespace QMatrixClient; @@ -52,7 +51,9 @@ class User::Private QSize requestedSize; bool avatarValid; bool avatarOngoingRequest; - QVector<QPixmap> scaledAvatars; + /// Map of requested size to the actual pixmap used for it + /// (it's a shame that QSize has no predefined qHash()). + QHash<QPair<int,int>, QPixmap> scaledAvatars; QString bridged; void requestAvatar(); @@ -92,24 +93,21 @@ QString User::bridged() const { QPixmap User::avatar(int width, int height) { - return croppedAvatar(width, height); // FIXME: Return an uncropped avatar; -} - -QPixmap User::croppedAvatar(int width, int height) -{ QSize size(width, height); - if( !d->avatarValid + // FIXME: Alternating between longer-width and longer-height requests + // is a sure way to trick the below code into constantly getting another + // image from the server because the existing one is alleged unsatisfactory. + // This is plain abuse by the client, though; so not critical for now. + if( (!d->avatarValid && d->avatarUrl.isValid() && !d->avatarOngoingRequest) || width > d->requestedSize.width() || height > d->requestedSize.height() ) { - if( !d->avatarOngoingRequest && d->avatarUrl.isValid() ) - { - qCDebug(MAIN) << "Getting avatar for" << id(); - d->requestedSize = size; - d->avatarOngoingRequest = true; - QTimer::singleShot(0, this, SLOT(requestAvatar())); - } + qCDebug(MAIN) << "Getting avatar for" << id() + << "from" << d->avatarUrl.toString(); + d->requestedSize = size; + d->avatarOngoingRequest = true; + QTimer::singleShot(0, this, SLOT(requestAvatar())); } if( d->avatar.isNull() ) @@ -120,19 +118,18 @@ QPixmap User::croppedAvatar(int width, int height) d->avatar = d->defaultIcon.pixmap(size); } - for (const QPixmap& p: d->scaledAvatars) + auto& pixmap = d->scaledAvatars[{width, height}]; // Create the entry if needed + if (pixmap.isNull()) { - if (p.size() == size) - return p; + pixmap = d->avatar.scaled(width, height, + Qt::KeepAspectRatio, Qt::SmoothTransformation); } - QPixmap newlyScaled = d->avatar.scaled(size, - Qt::KeepAspectRatioByExpanding, Qt::SmoothTransformation); - QPixmap scaledAndCroped = newlyScaled.copy( - std::max((newlyScaled.width() - width)/2, 0), - std::max((newlyScaled.height() - height)/2, 0), - width, height); - d->scaledAvatars.push_back(scaledAndCroped); - return scaledAndCroped; + return pixmap; +} + +const QUrl& User::avatarUrl() const +{ + return d->avatarUrl; } void User::processEvent(Event* event) @@ -170,12 +167,11 @@ void User::requestAvatar() void User::Private::requestAvatar() { - MediaThumbnailJob* job = connection->getThumbnail(avatarUrl, requestedSize); + auto* job = connection->callApi<MediaThumbnailJob>(avatarUrl, requestedSize); connect( job, &MediaThumbnailJob::success, [=]() { avatarOngoingRequest = false; avatarValid = true; - avatar = job->thumbnail().scaled(requestedSize, - Qt::KeepAspectRatio, Qt::SmoothTransformation); + avatar = job->scaledThumbnail(requestedSize); scaledAvatars.clear(); emit q->avatarChanged(q); }); @@ -53,7 +53,8 @@ namespace QMatrixClient Q_INVOKABLE QString bridged() const; QPixmap avatar(int requestedWidth, int requestedHeight); - QPixmap croppedAvatar(int requestedWidth, int requestedHeight); + + const QUrl& avatarUrl() const; void processEvent(Event* event); |