diff options
-rw-r--r-- | .travis.yml | 6 | ||||
-rw-r--r-- | CMakeLists.txt | 1 | ||||
-rw-r--r-- | README.md | 1 | ||||
-rw-r--r-- | avatar.cpp | 84 | ||||
-rw-r--r-- | avatar.h | 22 | ||||
-rw-r--r-- | connection.cpp | 28 | ||||
-rw-r--r-- | connection.h | 15 | ||||
-rw-r--r-- | events/event.cpp | 38 | ||||
-rw-r--r-- | events/event.h | 26 | ||||
-rw-r--r-- | events/receiptevent.cpp | 4 | ||||
-rw-r--r-- | events/receiptevent.h | 2 | ||||
-rw-r--r-- | events/roommemberevent.cpp | 10 | ||||
-rw-r--r-- | events/roommemberevent.h | 18 | ||||
-rw-r--r-- | events/tagevent.cpp | 50 | ||||
-rw-r--r-- | events/tagevent.h | 51 | ||||
-rw-r--r-- | examples/qmc-example.cpp | 85 | ||||
-rw-r--r-- | jobs/basejob.cpp | 7 | ||||
-rw-r--r-- | jobs/basejob.h | 3 | ||||
-rw-r--r-- | jobs/mediathumbnailjob.cpp | 14 | ||||
-rw-r--r-- | jobs/syncjob.cpp | 11 | ||||
-rw-r--r-- | jobs/syncjob.h | 36 | ||||
-rw-r--r-- | libqmatrixclient.pri | 3 | ||||
-rw-r--r-- | networkaccessmanager.cpp | 7 | ||||
-rw-r--r-- | networkaccessmanager.h | 2 | ||||
-rw-r--r-- | room.cpp | 275 | ||||
-rw-r--r-- | room.h | 48 | ||||
-rw-r--r-- | user.cpp | 331 | ||||
-rw-r--r-- | user.h | 42 |
28 files changed, 921 insertions, 299 deletions
diff --git a/.travis.yml b/.travis.yml index 9c7d8a7d..79d7720e 100644 --- a/.travis.yml +++ b/.travis.yml @@ -8,6 +8,7 @@ addons: packages: - g++-5 - qt56base + - valgrind matrix: include: @@ -17,7 +18,7 @@ matrix: - os: linux compiler: clang - os: osx - env: [ ENV_EVAL="brew update && brew install qt5 && PATH=/usr/local/opt/qt/bin" ] + env: [ 'ENV_EVAL="brew update && brew install qt5 && PATH=/usr/local/opt/qt/bin:$PATH"' ] before_install: - eval "${ENV_EVAL}" @@ -39,7 +40,8 @@ before_script: script: - cmake --build . --target all - cd .. -- qmake qmc-example.pro "QMAKE_CC = $CC" "QMAKE_CXX = $CXX" && make all +- qmake qmc-example.pro "CONFIG += debug" "QMAKE_CC = $CC" "QMAKE_CXX = $CXX" && make all +- if [ "$TRAVIS_OS_NAME" = "linux" ]; then valgrind --tool=memcheck --leak-check=yes --show-reachable=yes ./qmc-example "$QMC_TEST_USER" "$QMC_TEST_PWD" '#qmc-test:matrix.org'; fi notifications: webhooks: diff --git a/CMakeLists.txt b/CMakeLists.txt index c0ffc0b0..13afab21 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -65,6 +65,7 @@ set(libqmatrixclient_SRCS events/roomavatarevent.cpp events/typingevent.cpp events/receiptevent.cpp + events/tagevent.cpp jobs/requestdata.cpp jobs/basejob.cpp jobs/checkauthmethods.cpp @@ -3,6 +3,7 @@ [![license](https://img.shields.io/github/license/QMatrixClient/libqmatrixclient.svg)](https://github.com/QMatrixClient/libqmatrixclient/blob/master/COPYING) ![status](https://img.shields.io/badge/status-beta-yellow.svg) [![release](https://img.shields.io/github/release/QMatrixClient/libqmatrixclient/all.svg)](https://github.com/QMatrixClient/libqmatrixclient/releases/latest) +[![CII Best Practices](https://bestpractices.coreinfrastructure.org/projects/1023/badge)](https://bestpractices.coreinfrastructure.org/projects/1023) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat-square)](http://makeapullrequest.com) libqmatrixclient is a Qt5-based library to make IM clients for the [Matrix](https://matrix.org) protocol. It is the backbone of [Quaternion](https://github.com/QMatrixClient/Quaternion), [Tensor](https://matrix.org/docs/projects/client/tensor.html) and some other projects. @@ -30,10 +30,13 @@ using namespace QMatrixClient; class Avatar::Private { public: - Private(Connection* c, QIcon di) : _connection(c), _defaultIcon(di) { } - QImage get(QSize size, Avatar::notifier_t notifier) const; + explicit Private(QIcon di, QUrl url = {}) + : _defaultIcon(di), _url(url) + { } + QImage get(Connection* connection, QSize size, + get_callback_t callback) const; + bool upload(UploadContentJob* job, upload_callback_t callback); - Connection* _connection; const QIcon _defaultIcon; QUrl _url; @@ -42,24 +45,52 @@ class Avatar::Private mutable std::vector<QPair<QSize, QImage>> _scaledImages; mutable QSize _requestedSize; mutable bool _valid = false; - mutable QPointer<MediaThumbnailJob> _ongoingRequest = nullptr; - mutable std::vector<notifier_t> notifiers; + mutable QPointer<MediaThumbnailJob> _thumbnailRequest = nullptr; + mutable QPointer<BaseJob> _uploadRequest = nullptr; + mutable std::vector<get_callback_t> callbacks; + mutable get_callback_t uploadCallback; }; -Avatar::Avatar(Connection* connection, QIcon defaultIcon) - : d(new Private { connection, std::move(defaultIcon) }) +Avatar::Avatar(QIcon defaultIcon) + : d(std::make_unique<Private>(std::move(defaultIcon))) { } +Avatar::Avatar(QUrl url, QIcon defaultIcon) + : d(std::make_unique<Private>(std::move(defaultIcon), std::move(url))) +{ } + +Avatar::Avatar(Avatar&&) = default; + Avatar::~Avatar() = default; -QImage Avatar::get(int dimension, notifier_t notifier) const +Avatar& Avatar::operator=(Avatar&&) = default; + +QImage Avatar::get(Connection* connection, int dimension, + get_callback_t callback) const { - return d->get({dimension, dimension}, notifier); + return d->get(connection, {dimension, dimension}, callback); } -QImage Avatar::get(int width, int height, notifier_t notifier) const +QImage Avatar::get(Connection* connection, int width, int height, + get_callback_t callback) const { - return d->get({width, height}, notifier); + return d->get(connection, {width, height}, callback); +} + +bool Avatar::upload(Connection* connection, const QString& fileName, + upload_callback_t callback) const +{ + if (isJobRunning(d->_uploadRequest)) + return false; + return d->upload(connection->uploadFile(fileName), callback); +} + +bool Avatar::upload(Connection* connection, QIODevice* source, + upload_callback_t callback) const +{ + if (isJobRunning(d->_uploadRequest) || !source->isReadable()) + return false; + return d->upload(connection->uploadContent(source), callback); } QString Avatar::mediaId() const @@ -67,28 +98,29 @@ QString Avatar::mediaId() const return d->_url.authority() + d->_url.path(); } -QImage Avatar::Private::get(QSize size, Avatar::notifier_t notifier) const +QImage Avatar::Private::get(Connection* connection, QSize size, + get_callback_t callback) const { // 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( ( !(_valid || _ongoingRequest) + if( ( !(_valid || _thumbnailRequest) || size.width() > _requestedSize.width() || size.height() > _requestedSize.height() ) && _url.isValid() ) { qCDebug(MAIN) << "Getting avatar from" << _url.toString(); _requestedSize = size; - if (isJobRunning(_ongoingRequest)) - _ongoingRequest->abandon(); - notifiers.emplace_back(std::move(notifier)); - _ongoingRequest = _connection->getThumbnail(_url, size); - QObject::connect( _ongoingRequest, &MediaThumbnailJob::success, [this] + if (isJobRunning(_thumbnailRequest)) + _thumbnailRequest->abandon(); + callbacks.emplace_back(std::move(callback)); + _thumbnailRequest = connection->getThumbnail(_url, size); + QObject::connect( _thumbnailRequest, &MediaThumbnailJob::success, [this] { _valid = true; - _originalImage = _ongoingRequest->scaledThumbnail(_requestedSize); + _originalImage = _thumbnailRequest->scaledThumbnail(_requestedSize); _scaledImages.clear(); - for (auto n: notifiers) + for (auto n: callbacks) n(); }); } @@ -111,6 +143,16 @@ QImage Avatar::Private::get(QSize size, Avatar::notifier_t notifier) const return result; } +bool Avatar::Private::upload(UploadContentJob* job, upload_callback_t callback) +{ + _uploadRequest = job; + if (!isJobRunning(_uploadRequest)) + return false; + _uploadRequest->connect(_uploadRequest, &BaseJob::success, + [job,callback] { callback(job->contentUri()); }); + return true; +} + QUrl Avatar::url() const { return d->_url; } bool Avatar::updateUrl(const QUrl& newUrl) @@ -127,6 +169,8 @@ bool Avatar::updateUrl(const QUrl& newUrl) } d->_url = newUrl; d->_valid = false; + if (isJobRunning(d->_thumbnailRequest)) + d->_thumbnailRequest->abandon(); return true; } @@ -22,6 +22,7 @@ #include <QtCore/QUrl> #include <functional> +#include <memory> namespace QMatrixClient { @@ -30,13 +31,24 @@ namespace QMatrixClient class Avatar { public: - explicit Avatar(Connection* connection, QIcon defaultIcon = {}); + explicit Avatar(QIcon defaultIcon = {}); + explicit Avatar(QUrl url, QIcon defaultIcon = {}); + Avatar(Avatar&&); ~Avatar(); + Avatar& operator=(Avatar&&); - using notifier_t = std::function<void()>; + using get_callback_t = std::function<void()>; + using upload_callback_t = std::function<void(QString)>; - QImage get(int dimension, notifier_t notifier) const; - QImage get(int w, int h, notifier_t notifier) const; + QImage get(Connection* connection, int dimension, + get_callback_t callback) const; + QImage get(Connection* connection, int w, int h, + get_callback_t callback) const; + + bool upload(Connection* connection, const QString& fileName, + upload_callback_t callback) const; + bool upload(Connection* connection, QIODevice* source, + upload_callback_t callback) const; QString mediaId() const; QUrl url() const; @@ -44,6 +56,6 @@ namespace QMatrixClient private: class Private; - QScopedPointer<Private> d; + std::unique_ptr<Private> d; }; } // namespace QMatrixClient diff --git a/connection.cpp b/connection.cpp index 6a3cd957..98e534b8 100644 --- a/connection.cpp +++ b/connection.cpp @@ -519,6 +519,30 @@ QHash< QPair<QString, bool>, Room* > Connection::roomMap() const return roomMap; } +QHash<QString, QVector<Room*>> Connection::tagsToRooms() const +{ + QHash<QString, QVector<Room*>> result; + for (auto* r: d->roomMap) + { + for (const auto& tagName: r->tagNames()) + result[tagName].push_back(r); + } + for (auto it = result.begin(); it != result.end(); ++it) + std::sort(it->begin(), it->end(), + [t=it.key()] (Room* r1, Room* r2) { + return r1->tags().value(t).order < r2->tags().value(t).order; + }); + return result; +} + +QVector<Room*> Connection::roomsWithTag(const QString& tagName) const +{ + QVector<Room*> rooms; + std::copy_if(d->roomMap.begin(), d->roomMap.end(), std::back_inserter(rooms), + [&tagName] (Room* r) { return r->tags().contains(tagName); }); + return rooms; +} + QMap<QString, User*> Connection::users() const { return d->userMap; @@ -574,7 +598,7 @@ Room* Connection::provideRoom(const QString& id, JoinState joinState) { qCDebug(MAIN) << "Deleting Invite state for room" << prevInvite->id(); emit aboutToDeleteRoom(prevInvite); - delete prevInvite; + prevInvite->deleteLater(); } } @@ -602,7 +626,7 @@ void Connection::setHomeserver(const QUrl& url) emit homeserverChanged(homeserver()); } -static constexpr int CACHE_VERSION_MAJOR = 2; +static constexpr int CACHE_VERSION_MAJOR = 3; static constexpr int CACHE_VERSION_MINOR = 0; void Connection::saveState(const QUrl &toFile) const diff --git a/connection.h b/connection.h index 3ec4fd9d..b45a171d 100644 --- a/connection.h +++ b/connection.h @@ -70,7 +70,22 @@ namespace QMatrixClient explicit Connection(const QUrl& server, QObject* parent = nullptr); virtual ~Connection(); + /** Get all Invited and Joined rooms + * \return a hashmap from a composite key - room name and whether + * it's an Invite rather than Join - to room pointers + */ QHash<QPair<QString, bool>, Room*> roomMap() const; + + /** Get all Invited and Joined rooms grouped by tag + * \return a hashmap from tag name to a vector of room pointers, + * sorted by their order in the tag - details are at + * https://matrix.org/speculator/spec/drafts%2Fe2e/client_server/unstable.html#id95 + */ + QHash<QString, QVector<Room*>> tagsToRooms() const; + + /** Get the list of rooms with the specified tag */ + QVector<Room*> roomsWithTag(const QString& tagName) const; + QMap<QString, User*> users() const; // FIXME: Convert Q_INVOKABLEs to Q_PROPERTIES diff --git a/events/event.cpp b/events/event.cpp index c7345a13..74a2c3d7 100644 --- a/events/event.cpp +++ b/events/event.cpp @@ -24,6 +24,7 @@ #include "roomavatarevent.h" #include "typingevent.h" #include "receiptevent.h" +#include "tagevent.h" #include "redactionevent.h" #include "logging.h" @@ -44,6 +45,11 @@ Event::Event(Type type, const QJsonObject& rep) Event::~Event() = default; +QString Event::jsonType() const +{ + return originalJsonObject().value("type").toString(); +} + QByteArray Event::originalJson() const { return QJsonDocument(_originalJson).toJson(); @@ -82,17 +88,18 @@ EventPtr _impl::doMakeEvent<Event>(const QJsonObject& obj) return EventPtr(move(e)); return EventPtr { makeIfMatches<Event, - TypingEvent, ReceiptEvent>(obj, obj["type"].toString()) }; + TypingEvent, ReceiptEvent, TagEvent>(obj, obj["type"].toString()) }; } RoomEvent::RoomEvent(Event::Type type) : Event(type) { } RoomEvent::RoomEvent(Type type, const QJsonObject& rep) - : Event(type, rep), _id(rep["event_id"].toString()) - , _roomId(rep["room_id"].toString()) - , _senderId(rep["sender"].toString()) - , _serverTimestamp( - QMatrixClient::fromJson<QDateTime>(rep["origin_server_ts"])) + : Event(type, rep) + , _id(rep["event_id"].toString()) +// , _roomId(rep["room_id"].toString()) +// , _senderId(rep["sender"].toString()) +// , _serverTimestamp( +// QMatrixClient::fromJson<QDateTime>(rep["origin_server_ts"])) { // if (_id.isEmpty()) // { @@ -113,7 +120,8 @@ RoomEvent::RoomEvent(Type type, const QJsonObject& rep) auto redaction = unsignedData.value("redacted_because"); if (redaction.isObject()) { - _redactedBecause.reset(new RedactionEvent(redaction.toObject())); + _redactedBecause = + std::make_unique<RedactionEvent>(redaction.toObject()); return; } @@ -124,6 +132,22 @@ RoomEvent::RoomEvent(Type type, const QJsonObject& rep) RoomEvent::~RoomEvent() = default; // Let the smart pointer do its job +QDateTime RoomEvent::timestamp() const +{ + return QMatrixClient::fromJson<QDateTime>( + originalJsonObject().value("origin_server_ts")); +} + +QString RoomEvent::roomId() const +{ + return originalJsonObject().value("room_id").toString(); +} + +QString RoomEvent::senderId() const +{ + return originalJsonObject().value("sender").toString(); +} + QString RoomEvent::redactionReason() const { return isRedacted() ? _redactedBecause->reason() : QString{}; diff --git a/events/event.h b/events/event.h index b5a4d94e..f0ca2d15 100644 --- a/events/event.h +++ b/events/event.h @@ -45,7 +45,7 @@ namespace QMatrixClient enum class Type : quint16 { Unknown = 0, - Typing, Receipt, + Typing, Receipt, Tag, DirectChat, RoomEventBase = 0x1000, RoomMessage = RoomEventBase + 1, RoomEncryptedMessage, Redaction, @@ -63,6 +63,7 @@ namespace QMatrixClient virtual ~Event(); Type type() const { return _type; } + QString jsonType() const; bool isStateEvent() const { return (quint16(_type) & 0x1800) == 0x1800; @@ -76,7 +77,6 @@ namespace QMatrixClient // (and in most cases it will be a combination of other fields // instead of "content" field). - protected: const QJsonObject contentJson() const; private: @@ -100,7 +100,7 @@ namespace QMatrixClient { auto e = _impl::doMakeEvent<EventT>(obj); if (!e) - e.reset(new EventT(EventType::Unknown, obj)); + e = std::make_unique<EventT>(EventType::Unknown, obj); return e; } @@ -167,10 +167,10 @@ namespace QMatrixClient RoomEvent(Type type, const QJsonObject& rep); ~RoomEvent(); - const QString& id() const { return _id; } - const QDateTime& timestamp() const { return _serverTimestamp; } - const QString& roomId() const { return _roomId; } - const QString& senderId() const { return _senderId; } + QString id() const { return _id; } + QDateTime timestamp() const; + QString roomId() const; + QString senderId() const; bool isRedacted() const { return bool(_redactedBecause); } const RedactionEvent* redactedBecause() const { @@ -202,9 +202,9 @@ namespace QMatrixClient private: QString _id; - QString _roomId; - QString _senderId; - QDateTime _serverTimestamp; +// QString _roomId; +// QString _senderId; +// QDateTime _serverTimestamp; event_ptr_tt<RedactionEvent> _redactedBecause; QString _txnId; }; @@ -296,10 +296,10 @@ namespace QMatrixClient QJsonObject toJson() const { return _content.toJson(); } - ContentT content() const { return _content; } + const ContentT& content() const { return _content; } /** @deprecated Use prevContent instead */ - ContentT* prev_content() const { return prevContent(); } - ContentT* prevContent() const + const ContentT* prev_content() const { return prevContent(); } + const ContentT* prevContent() const { return _prev ? &_prev->content : nullptr; } QString prevSenderId() const { return _prev ? _prev->senderId : ""; } diff --git a/events/receiptevent.cpp b/events/receiptevent.cpp index e30fe4e4..3c4d34ee 100644 --- a/events/receiptevent.cpp +++ b/events/receiptevent.cpp @@ -43,10 +43,10 @@ using namespace QMatrixClient; ReceiptEvent::ReceiptEvent(const QJsonObject& obj) : Event(Type::Receipt, obj) { - Q_ASSERT(obj["type"].toString() == jsonType); + Q_ASSERT(obj["type"].toString() == TypeId); const QJsonObject contents = contentJson(); - _eventsWithReceipts.reserve(static_cast<size_t>(contents.size())); + _eventsWithReceipts.reserve(contents.size()); for( auto eventIt = contents.begin(); eventIt != contents.end(); ++eventIt ) { if (eventIt.key().isEmpty()) diff --git a/events/receiptevent.h b/events/receiptevent.h index 9494c7c6..92dace82 100644 --- a/events/receiptevent.h +++ b/events/receiptevent.h @@ -48,7 +48,5 @@ namespace QMatrixClient private: EventsWithReceipts _eventsWithReceipts; bool _unreadMessages; // Spec extension for caching purposes - - static constexpr const char * jsonType = "m.receipt"; }; } // namespace QMatrixClient diff --git a/events/roommemberevent.cpp b/events/roommemberevent.cpp index 76df5f2e..a9e301a4 100644 --- a/events/roommemberevent.cpp +++ b/events/roommemberevent.cpp @@ -44,7 +44,7 @@ namespace QMatrixClient return MembershipType(it - membershipStrings.begin()); qCWarning(EVENTS) << "Unknown MembershipType: " << membershipString; - return MembershipType::Join; + return MembershipType::Undefined; } }; } @@ -58,7 +58,11 @@ MemberEventContent::MemberEventContent(const QJsonObject& json) void MemberEventContent::fillJson(QJsonObject* o) const { Q_ASSERT(o); - o->insert("membership", membershipStrings[membership]); + Q_ASSERT_X(membership != MembershipType::Undefined, __FUNCTION__, + "The key 'membership' must be explicit in MemberEventContent"); + if (membership != MembershipType::Undefined) + o->insert("membership", membershipStrings[membership]); o->insert("displayname", displayName); - o->insert("avatar_url", avatarUrl.toString()); + if (avatarUrl.isValid()) + o->insert("avatar_url", avatarUrl.toString()); } diff --git a/events/roommemberevent.h b/events/roommemberevent.h index d0c63f15..b9ff0d70 100644 --- a/events/roommemberevent.h +++ b/events/roommemberevent.h @@ -29,9 +29,13 @@ namespace QMatrixClient class MemberEventContent: public EventContent::Base { public: - enum MembershipType : size_t {Invite = 0, Join, Knock, Leave, Ban}; + enum MembershipType : size_t { Invite = 0, Join, Knock, Leave, Ban, + Undefined }; - MemberEventContent(const QJsonObject& json); + explicit MemberEventContent(MembershipType mt = MembershipType::Join) + : membership(mt) + { } + explicit MemberEventContent(const QJsonObject& json); MembershipType membership; QString displayName; @@ -51,18 +55,22 @@ namespace QMatrixClient using MembershipType = MemberEventContent::MembershipType; + RoomMemberEvent(MemberEventContent&& c) + : StateEvent(Type::RoomMember, c) + { } explicit RoomMemberEvent(const QJsonObject& obj) : StateEvent(Type::RoomMember, obj) - , _userId(obj["state_key"].toString()) +// , _userId(obj["state_key"].toString()) { } MembershipType membership() const { return content().membership; } - QString userId() const { return _userId; } + QString userId() const + { return originalJsonObject().value("state_key").toString(); } QString displayName() const { return content().displayName; } QUrl avatarUrl() const { return content().avatarUrl; } private: - QString _userId; +// QString _userId; REGISTER_ENUM(MembershipType) }; } // namespace QMatrixClient diff --git a/events/tagevent.cpp b/events/tagevent.cpp new file mode 100644 index 00000000..c6297003 --- /dev/null +++ b/events/tagevent.cpp @@ -0,0 +1,50 @@ +/****************************************************************************** + * Copyright (C) 2018 Kitsune Ral <kitsune-ral@users.sf.net> + * + * 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 "tagevent.h" + +using namespace QMatrixClient; + +TagRecord::TagRecord(const QJsonObject& json) + : order(json.value("order").toString()) +{ } + +TagEvent::TagEvent(const QJsonObject& obj) + : Event(Type::Tag, obj) +{ + Q_ASSERT(obj["type"].toString() == TypeId); +} + +QStringList TagEvent::tagNames() const +{ + return tagsObject().keys(); +} + +QHash<QString, TagRecord> TagEvent::tags() const +{ + QHash<QString, TagRecord> result; + auto allTags { tagsObject() }; + for (auto it = allTags.begin(); it != allTags.end(); ++ it) + result.insert(it.key(), TagRecord(it.value().toObject())); + return result; +} + +QJsonObject TagEvent::tagsObject() const +{ + return contentJson().value("tags").toObject(); +} diff --git a/events/tagevent.h b/events/tagevent.h new file mode 100644 index 00000000..44a7e49a --- /dev/null +++ b/events/tagevent.h @@ -0,0 +1,51 @@ +/****************************************************************************** + * Copyright (C) 2018 Kitsune Ral <kitsune-ral@users.sf.net> + * + * 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 "event.h" + +namespace QMatrixClient +{ + static constexpr const char* FavouriteTag = "m.favourite"; + static constexpr const char* LowPriorityTag = "m.lowpriority"; + + struct TagRecord + { + explicit TagRecord(const QJsonObject& json = {}); + + QString order; + }; + + class TagEvent : public Event + { + public: + explicit TagEvent(const QJsonObject& obj); + + /** Get the list of tag names */ + QStringList tagNames() const; + + /** Get the list of tags along with information on each */ + QHash<QString, TagRecord> tags() const; + + static constexpr const char * TypeId = "m.tag"; + + protected: + QJsonObject tagsObject() const; + }; +} diff --git a/examples/qmc-example.cpp b/examples/qmc-example.cpp index dbb9912b..e0aabca9 100644 --- a/examples/qmc-example.cpp +++ b/examples/qmc-example.cpp @@ -1,55 +1,92 @@ #include "connection.h" #include "room.h" +#include "user.h" -#include <QCoreApplication> +#include <QtCore/QCoreApplication> +#include <QtCore/QStringBuilder> #include <iostream> -#include <string> using namespace QMatrixClient; using std::cout; using std::endl; -using std::string; +using std::bind; +using namespace std::placeholders; -void onNewRoom(Room* r) +void onNewRoom(Room* r, const char* targetRoomName) { cout << "New room: " << r->id().toStdString() << endl; QObject::connect(r, &Room::namesChanged, [=] { cout << "Room " << r->id().toStdString() << ", name(s) changed:" << endl << " Name: " << r->name().toStdString() << endl - << " Canonical alias: " << r->canonicalAlias().toStdString() + << " Canonical alias: " << r->canonicalAlias().toStdString() << endl << endl << endl; + if (targetRoomName && (r->name() == targetRoomName || + r->canonicalAlias() == targetRoomName)) + { + r->postMessage( + "This is a test message from an example application\n" + "The current user is " % r->localUser()->fullName(r) % "\n" % + QStringLiteral("This room has %1 member(s)") + .arg(r->memberCount()) % "\n" % +// "The room is " % +// (r->isDirectChat() ? "" : "not ") % "a direct chat\n" % + "Have a good day", + MessageEventType::Notice + ); + } + }); + QObject::connect(r, &Room::tagsChanged, [=] { + cout << "Room " << r->id().toStdString() << ", tag(s) changed:" << endl + << " " << r->tagNames().join(", ").toStdString() << endl << endl; }); QObject::connect(r, &Room::aboutToAddNewMessages, [=] (RoomEventsRange timeline) { cout << timeline.size() << " new event(s) in room " - << r->id().toStdString() << ":" << endl; - for (const auto& item: timeline) - { - cout << "From: " - << r->roomMembername(item->senderId()).toStdString() - << endl << "Timestamp:" - << item->timestamp().toString().toStdString() << endl - << "JSON:" << endl << string(item->originalJson()) << endl; - } + << r->id().toStdString() << endl; +// for (const auto& item: timeline) +// { +// cout << "From: " +// << r->roomMembername(item->senderId()).toStdString() +// << endl << "Timestamp:" +// << item->timestamp().toString().toStdString() << endl +// << "JSON:" << endl << item->originalJson().toStdString() << endl; +// } }); } +void finalize(Connection* conn) +{ + cout << "Logging out" << endl; + conn->logout(); + QObject::connect(conn, &Connection::loggedOut, QCoreApplication::instance(), + [conn] { + conn->deleteLater(); + QCoreApplication::instance()->processEvents(); + QCoreApplication::instance()->quit(); + }); +} + int main(int argc, char* argv[]) { QCoreApplication app(argc, argv); - if (argc < 2) + if (argc < 3) return -1; - auto conn = new Connection(QUrl("https://matrix.org")); + cout << "Connecting to the server as " << argv[1] << endl; + auto conn = new Connection; conn->connectToServer(argv[1], argv[2], "QMatrixClient example application"); - auto c = QObject::connect(conn, &Connection::connected, [=] { - cout << "Connected" << endl; - conn->sync(); - }); - QObject::connect(conn, &Connection::syncDone, [=] { - cout << "Sync done" << endl; - conn->sync(30000); + QObject::connect(conn, &Connection::connected, [=] { + cout << "Connected, server: " + << conn->homeserver().toDisplayString().toStdString() << endl; + cout << "Access token: " << conn->accessToken().toStdString() << endl; + conn->sync(); }); - QObject::connect(conn, &Connection::newRoom, onNewRoom); + const char* targetRoomName = argc >= 4 ? argv[3] : nullptr; + if (targetRoomName) + cout << "Target room name: " << targetRoomName; + QObject::connect(conn, &Connection::newRoom, + bind(onNewRoom, _1, targetRoomName)); + QObject::connect(conn, &Connection::syncDone, + bind(finalize, conn)); return app.exec(); } diff --git a/jobs/basejob.cpp b/jobs/basejob.cpp index 0e3e59d0..486956e1 100644 --- a/jobs/basejob.cpp +++ b/jobs/basejob.cpp @@ -266,6 +266,12 @@ void BaseJob::gotReply() setStatus(checkReply(d->reply.data())); if (status().good()) setStatus(parseReply(d->reply.data())); + else + { + auto json = QJsonDocument::fromJson(d->reply->readAll()).object(); + if (!json.isEmpty()) + setStatus(IncorrectRequestError, json.value("error").toString()); + } finishJob(); } @@ -447,6 +453,7 @@ void BaseJob::setStatus(int code, QString message) void BaseJob::abandon() { beforeAbandon(d->reply.data()); + setStatus(Abandoned); this->disconnect(); if (d->reply) d->reply->disconnect(this); diff --git a/jobs/basejob.h b/jobs/basejob.h index c03c914f..a5b457c5 100644 --- a/jobs/basejob.h +++ b/jobs/basejob.h @@ -53,7 +53,8 @@ namespace QMatrixClient enum StatusCode { NoError = 0 // To be compatible with Qt conventions , Success = 0 , Pending = 1 - , ErrorLevel = 100 // Errors have codes starting from this + , Abandoned = 50 //< A very brief period between abandoning and object deletion + , ErrorLevel = 100 //< Errors have codes starting from this , NetworkError = 100 , JsonParseError , TimeoutError diff --git a/jobs/mediathumbnailjob.cpp b/jobs/mediathumbnailjob.cpp index 261978ec..dda1cdb4 100644 --- a/jobs/mediathumbnailjob.cpp +++ b/jobs/mediathumbnailjob.cpp @@ -52,10 +52,12 @@ QImage MediaThumbnailJob::scaledThumbnail(QSize toSize) const BaseJob::Status MediaThumbnailJob::parseReply(QNetworkReply* reply) { - GetContentThumbnailJob::parseReply(reply); - if( !_thumbnail.loadFromData(content()->readAll()) ) - { - qCDebug(JOBS) << "MediaThumbnailJob: could not read image data"; - } - return Success; + auto result = GetContentThumbnailJob::parseReply(reply); + if (!result.good()) + return result; + + if( _thumbnail.loadFromData(content()->readAll()) ) + return Success; + + return { IncorrectResponseError, "Could not read image data" }; } diff --git a/jobs/syncjob.cpp b/jobs/syncjob.cpp index ce5dd894..7b066f4f 100644 --- a/jobs/syncjob.cpp +++ b/jobs/syncjob.cpp @@ -54,6 +54,11 @@ SyncDataList&& SyncData::takeRoomData() return std::move(roomData); } +SyncBatch<Event>&& SyncData::takeAccountData() +{ + return std::move(accountData); +} + BaseJob::Status SyncJob::parseJson(const QJsonDocument& data) { return d.parseJson(data); @@ -63,12 +68,12 @@ BaseJob::Status SyncData::parseJson(const QJsonDocument &data) { QElapsedTimer et; et.start(); - QJsonObject json = data.object(); + auto json { data.object() }; nextBatch_ = json.value("next_batch").toString(); // TODO: presence - // TODO: account_data - QJsonObject rooms = json.value("rooms").toObject(); + accountData.fromJson(json); + QJsonObject rooms = json.value("rooms").toObject(); for (size_t i = 0; i < JoinStateStrings.size(); ++i) { const auto rs = rooms.value(JoinStateStrings[i]).toObject(); diff --git a/jobs/syncjob.h b/jobs/syncjob.h index aed36e0b..5956e73b 100644 --- a/jobs/syncjob.h +++ b/jobs/syncjob.h @@ -26,30 +26,30 @@ namespace QMatrixClient { - class SyncRoomData + template <typename EventT> + class SyncBatch : public EventsBatch<EventT> { public: - template <typename EventT> - class Batch : public EventsBatch<EventT> + explicit SyncBatch(QString k) : jsonKey(std::move(k)) { } + void fromJson(const QJsonObject& roomContents) { - public: - explicit Batch(QString k) : jsonKey(std::move(k)) { } - void fromJson(const QJsonObject& roomContents) - { - EventsBatch<EventT>::fromJson( - roomContents[jsonKey].toObject(), "events"); - } + EventsBatch<EventT>::fromJson( + roomContents[jsonKey].toObject(), "events"); + } - private: - QString jsonKey; - }; + private: + QString jsonKey; + }; + class SyncRoomData + { + public: QString roomId; JoinState joinState; - Batch<RoomEvent> state; - Batch<RoomEvent> timeline; - Batch<Event> ephemeral; - Batch<Event> accountData; + SyncBatch<RoomEvent> state; + SyncBatch<RoomEvent> timeline; + SyncBatch<Event> ephemeral; + SyncBatch<Event> accountData; bool timelineLimited; QString timelinePrevBatch; @@ -68,11 +68,13 @@ namespace QMatrixClient { public: BaseJob::Status parseJson(const QJsonDocument &data); + SyncBatch<Event>&& takeAccountData(); SyncDataList&& takeRoomData(); QString nextBatch() const; private: QString nextBatch_; + SyncBatch<Event> accountData { "account_data" }; SyncDataList roomData; }; diff --git a/libqmatrixclient.pri b/libqmatrixclient.pri index 72637caf..7cfa94a1 100644 --- a/libqmatrixclient.pri +++ b/libqmatrixclient.pri @@ -24,6 +24,7 @@ HEADERS += \ $$PWD/events/roomavatarevent.h \ $$PWD/events/typingevent.h \ $$PWD/events/receiptevent.h \ + $$PWD/events/tagevent.h \ $$PWD/events/redactionevent.h \ $$PWD/jobs/requestdata.h \ $$PWD/jobs/basejob.h \ @@ -55,7 +56,7 @@ SOURCES += \ $$PWD/events/roommemberevent.cpp \ $$PWD/events/typingevent.cpp \ $$PWD/events/receiptevent.cpp \ - $$PWD/events/redactionevent.cpp \ + $$PWD/events/tagevent.cpp \ $$PWD/jobs/requestdata.cpp \ $$PWD/jobs/basejob.cpp \ $$PWD/jobs/checkauthmethods.cpp \ diff --git a/networkaccessmanager.cpp b/networkaccessmanager.cpp index 7fb2f602..89967a8a 100644 --- a/networkaccessmanager.cpp +++ b/networkaccessmanager.cpp @@ -19,6 +19,7 @@ #include "networkaccessmanager.h" #include <QtNetwork/QNetworkReply> +#include <QtCore/QCoreApplication> using namespace QMatrixClient; @@ -28,7 +29,7 @@ class NetworkAccessManager::Private QList<QSslError> ignoredSslErrors; }; -NetworkAccessManager::NetworkAccessManager() : d(std::make_unique<Private>()) +NetworkAccessManager::NetworkAccessManager(QObject* parent) : d(std::make_unique<Private>()) { } QList<QSslError> NetworkAccessManager::ignoredSslErrors() const @@ -48,7 +49,7 @@ void NetworkAccessManager::clearIgnoredSslErrors() static NetworkAccessManager* createNam() { - auto nam = new NetworkAccessManager; + auto nam = new NetworkAccessManager(QCoreApplication::instance()); // See #109. Once Qt bearer management gets better, this workaround // should become unnecessary. nam->connect(nam, &QNetworkAccessManager::networkAccessibleChanged, @@ -56,7 +57,7 @@ static NetworkAccessManager* createNam() return nam; } -NetworkAccessManager*NetworkAccessManager::instance() +NetworkAccessManager* NetworkAccessManager::instance() { static auto* nam = createNam(); return nam; diff --git a/networkaccessmanager.h b/networkaccessmanager.h index ea08c591..ae847582 100644 --- a/networkaccessmanager.h +++ b/networkaccessmanager.h @@ -28,7 +28,7 @@ namespace QMatrixClient { Q_OBJECT public: - NetworkAccessManager(); + NetworkAccessManager(QObject* parent = nullptr); ~NetworkAccessManager() override; QList<QSslError> ignoredSslErrors() const; @@ -64,7 +64,7 @@ class Room::Private Private(Connection* c, QString id_, JoinState initialJoinState) : q(nullptr), connection(c), id(std::move(id_)) - , avatar(c), joinState(initialJoinState) + , joinState(initialJoinState) { } Room* q; @@ -95,6 +95,8 @@ class Room::Private QString firstDisplayedEventId; QString lastDisplayedEventId; QHash<const User*, QString> lastReadEventIds; + QHash<QString, TagRecord> tags; + QHash<QString, QJsonObject> accountData; QString prevBatch; QPointer<RoomMessagesJob> roomMessagesJob; @@ -140,16 +142,10 @@ class Room::Private const RoomMessageEvent* getEventWithFile(const QString& eventId) const; - // Convenience methods to work with the membersMap and usersLeft. - // addMember() and removeMember() emit respective Room:: signals - // after a succesful operation. //void inviteUser(User* u); // We might get it at some point in time. - void addMember(User* u); - bool hasMember(User* u) const; - // You can't identify a single user by displayname, only by id - User* member(const QString& id) const; + void insertMemberIntoMap(User* u); void renameMember(User* u, QString oldName); - void removeMember(User* u); + void removeMemberFromMap(const QString& username, User* u); void getPreviousContent(int limit = 10); @@ -202,12 +198,9 @@ class Room::Private QString calculateDisplayname() const; QString roomNameFromMemberNames(const QList<User*>& userlist) const; - void insertMemberIntoMap(User* u); - void removeMemberFromMap(const QString& username, User* u); - bool isLocalUser(const User* u) const { - return u == connection->user(); + return u == q->localUser(); } }; @@ -287,7 +280,7 @@ QImage Room::avatar(int dimension) QImage Room::avatar(int width, int height) { if (!d->avatar.url().isEmpty()) - return d->avatar.get(width, height, [=] { emit avatarChanged(); }); + return d->avatar.get(connection(), width, height, [=] { emit avatarChanged(); }); // Use the other side's avatar for 1:1's if (d->membersMap.size() == 2) @@ -295,12 +288,24 @@ QImage Room::avatar(int width, int height) auto theOtherOneIt = d->membersMap.begin(); if (theOtherOneIt.value() == localUser()) ++theOtherOneIt; - return (*theOtherOneIt)->avatarObject() - .get(width, height, [=] { emit avatarChanged(); }); + 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; @@ -549,6 +554,31 @@ void Room::resetHighlightCount() emit highlightCountChanged(this); } +QStringList Room::tagNames() const +{ + return d->tags.keys(); +} + +const QHash<QString, TagRecord>& Room::tags() const +{ + return d->tags; +} + +TagRecord Room::tag(const QString& name) const +{ + return d->tags.value(name); +} + +bool Room::isFavourite() const +{ + return d->tags.contains(FavouriteTag); +} + +bool Room::isLowPriority() const +{ + return d->tags.contains(LowPriorityTag); +} + const RoomMessageEvent* Room::Private::getEventWithFile(const QString& eventId) const { @@ -700,7 +730,7 @@ QStringList Room::memberNames() const { QStringList res; for (auto u : d->membersMap) - res.append( this->roomMembername(u) ); + res.append( roomMembername(u) ); return res; } @@ -717,23 +747,50 @@ int Room::timelineSize() const void Room::Private::insertMemberIntoMap(User *u) { - auto namesakes = membersMap.values(u->name()); - membersMap.insert(u->name(), 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[0]); + 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. - auto formerNamesakes = membersMap.values(username); - if (formerNamesakes.size() == 1) - emit q->memberRenamed(formerNamesakes[0]); + if (namesake) + emit q->memberRenamed(namesake); } inline auto makeErrorStr(const Event& e, QByteArray msg) @@ -772,69 +829,17 @@ Room::Timeline::size_type Room::Private::insertEvents(RoomEventsRange&& events, return events.size(); } -void Room::Private::addMember(User *u) -{ - if (!hasMember(u)) - { - insertMemberIntoMap(u); - connect(u, &User::nameChanged, q, - std::bind(&Private::renameMember, this, u, _2)); - emit q->userAdded(u); - } -} - -bool Room::Private::hasMember(User* u) const -{ - return membersMap.values(u->name()).contains(u); -} - -User* Room::Private::member(const QString& id) const -{ - auto u = connection->user(id); - return hasMember(u) ? u : nullptr; -} - -void Room::Private::renameMember(User* u, QString oldName) -{ - if (hasMember(u)) - { - qCWarning(MAIN) << "Room::Private::renameMember(): the user " - << u->name() - << "is already known in the room under a new name."; - return; - } - - if (membersMap.values(oldName).contains(u)) - { - removeMemberFromMap(oldName, u); - insertMemberIntoMap(u); - emit q->memberRenamed(u); - } -} - -void Room::Private::removeMember(User* u) -{ - if (hasMember(u)) - { - if ( !membersLeft.contains(u) ) - membersLeft.append(u); - removeMemberFromMap(u->name(), u); - emit q->userRemoved(u); - } -} - QString Room::roomMembername(const User* u) const { // See the CS spec, section 11.2.2.3 - QString username = u->name(); + 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. - auto namesakes = d->membersMap.values(username); - if (namesakes.size() == 1) + if (d->membersMap.count(username) == 1) return username; // We expect a user to be a member of the room - but technically it is @@ -851,12 +856,12 @@ QString Room::roomMembername(const User* u) const // } // In case of more than one namesake, use the full name to disambiguate - return u->fullName(); + return u->fullName(this); } QString Room::roomMembername(const QString& userId) const { - return roomMembername(connection()->user(userId)); + return roomMembername(user(userId)); } void Room::updateData(SyncRoomData&& data) @@ -895,6 +900,15 @@ void Room::updateData(SyncRoomData&& data) << et.elapsed() << "ms"; } + if (!data.accountData.empty()) + { + et.restart(); + for (auto&& event: data.accountData) + processAccountDataEvent(move(event)); + qCDebug(PROFILER) << "*** Room::processAccountData():" + << et.elapsed() << "ms"; + } + if( data.highlightCount != d->highlightCount ) { d->highlightCount = data.highlightCount; @@ -1250,7 +1264,7 @@ void Room::Private::checkUnreadMessages(timeline_iter_t from) // read receipts from the server (or, for the local user, // markMessagesAsRead() invocation) to promote their read markers over // the new message events. - auto firstWriter = connection->user((*from)->senderId()); + auto firstWriter = q->user((*from)->senderId()); if (q->readMarker(firstWriter) != timeline.crend()) { promoteReadMarker(firstWriter, q->findInTimeline((*from)->id())); @@ -1349,16 +1363,35 @@ void Room::processStateEvents(const RoomEvents& events) } case EventType::RoomMember: { auto memberEvent = static_cast<RoomMemberEvent*>(event); - // Can't use d->member() below because the user may be not a member (yet) - auto u = d->connection->user(memberEvent->userId()); - u->processEvent(event); + auto u = user(memberEvent->userId()); + u->processEvent(memberEvent, this); if( memberEvent->membership() == MembershipType::Join ) { - d->addMember(u); + 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 ) { - d->removeMember(u); + if (memberJoinState(u) == JoinState::Join) + { + if (!d->membersLeft.contains(u)) + d->membersLeft.append(u); + d->removeMemberFromMap(u->name(this), u); + emit userRemoved(u); + } } break; } @@ -1380,8 +1413,9 @@ void Room::processEphemeralEvent(EventPtr event) d->usersTyping.clear(); for( const QString& userId: typingEvent->users() ) { - if (auto m = d->member(userId)) - d->usersTyping.append(m); + auto u = user(userId); + if (memberJoinState(u) == JoinState::Join) + d->usersTyping.append(u); } emit typingChanged(); break; @@ -1403,8 +1437,11 @@ void Room::processEphemeralEvent(EventPtr event) { const auto newMarker = findInTimeline(p.evtId); for( const Receipt& r: p.receipts ) - if (auto m = d->member(r.userId)) - d->promoteReadMarker(m, newMarker); + { + auto u = user(r.userId); + if (memberJoinState(u) == JoinState::Join) + d->promoteReadMarker(u, newMarker); + } } else { qCDebug(EPHEMERAL) << "Event" << p.evtId @@ -1414,9 +1451,12 @@ void Room::processEphemeralEvent(EventPtr event) // 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 (auto m = d->member(r.userId)) - if (readMarker(m) == timelineEdge()) - d->setLastReadEvent(m, p.evtId); + { + auto u = user(r.userId); + if (memberJoinState(u) == JoinState::Join && + readMarker(u) == timelineEdge()) + d->setLastReadEvent(u, p.evtId); + } } } if (receiptEvent->unreadMessages()) @@ -1429,6 +1469,19 @@ void Room::processEphemeralEvent(EventPtr event) } } +void Room::processAccountDataEvent(EventPtr event) +{ + switch (event->type()) + { + case EventType::Tag: + d->tags = static_cast<TagEvent*>(event.get())->tags(); + emit tagsChanged(); + break; + default: + d->accountData[event->jsonType()] = event->contentJson(); + } +} + QString Room::Private::roomNameFromMemberNames(const QList<User *> &userlist) const { // This is part 3(i,ii,iii) in the room displayname algorithm described @@ -1448,6 +1501,12 @@ QString Room::Private::roomNameFromMemberNames(const QList<User *> &userlist) co } ); + // 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]); @@ -1541,16 +1600,16 @@ QJsonObject Room::Private::toJson() const appendStateEvent(stateEvents, "m.room.canonical_alias", "alias", canonicalAlias); - for (const auto &i : membersMap) + for (const auto *m : membersMap) { QJsonObject content; content.insert("membership", QStringLiteral("join")); - content.insert("displayname", i->name()); - content.insert("avatar_url", i->avatarUrl().toString()); + content.insert("displayname", m->name(q)); + content.insert("avatar_url", m->avatarUrl(q).toString()); QJsonObject memberEvent; memberEvent.insert("type", QStringLiteral("m.room.member")); - memberEvent.insert("state_key", i->id()); + memberEvent.insert("state_key", m->id()); memberEvent.insert("content", content); stateEvents.append(memberEvent); } @@ -1591,6 +1650,20 @@ QJsonObject Room::Private::toJson() const result.insert("ephemeral", ephemeralObj); } + { + QJsonObject accountDataObj; + if (!tags.empty()) + { + QJsonObject tagsObj; + for (auto it = tags.begin(); it != tags.end(); ++it) + tagsObj.insert(it.key(), { {"order", it->order} }); + if (!tagsObj.empty()) + accountDataObj.insert("m.tag", tagsObj); + } + if (!accountDataObj.empty()) + result.insert("account_data", accountDataObj); + } + QJsonObject unreadNotificationsObj; if (highlightCount > 0) unreadNotificationsObj.insert("highlight_count", highlightCount); @@ -1614,11 +1687,15 @@ MemberSorter Room::memberSorter() const 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); - auto n2 = room->roomMembername(u2); if (n1.startsWith('@')) n1.remove(0, 1); - if (n2.startsWith('@')) - n2.remove(0, 1); + auto n2 = u2name.midRef(u2name.startsWith('@') ? 1 : 0); + return n1.localeAwareCompare(n2) < 0; } @@ -20,6 +20,7 @@ #include "jobs/syncjob.h" #include "events/roommessageevent.h" +#include "events/tagevent.h" #include "joinstate.h" #include <QtCore/QList> @@ -116,6 +117,8 @@ namespace QMatrixClient Q_PROPERTY(QString lastDisplayedEventId READ lastDisplayedEventId WRITE setLastDisplayedEventId NOTIFY lastDisplayedEventChanged) Q_PROPERTY(QString readMarkerEventId READ readMarkerEventId WRITE markMessagesAsRead NOTIFY readMarkerMoved) + Q_PROPERTY(QStringList tagNames READ tagNames NOTIFY tagsChanged) + public: using Timeline = std::deque<TimelineItem>; using rev_iter_t = Timeline::const_reverse_iterator; @@ -161,6 +164,28 @@ namespace QMatrixClient Q_INVOKABLE QImage avatar(int width, int height); /** + * @brief Get a user object for a given user id + * This is the recommended way to get a user object in a room + * context. The actual object type may be changed in further + * versions to provide room-specific user information (display name, + * avatar etc.). + * \note The method will return a valid user regardless of + * the membership. + */ + Q_INVOKABLE User* user(const QString& userId) const; + + /** + * \brief Check the join state of a given user in this room + * + * \note Banned and invited users are not tracked for now (Leave + * will be returned for them). + * + * \return either of Join, Leave, depending on the given + * user's state in the room + */ + Q_INVOKABLE JoinState memberJoinState(User* user) const; + + /** * @brief Produces a disambiguated name for a given user in * the context of the room */ @@ -215,6 +240,18 @@ namespace QMatrixClient Q_INVOKABLE int highlightCount() const; Q_INVOKABLE void resetHighlightCount(); + QStringList tagNames() const; + const QHash<QString, TagRecord>& tags() const; + TagRecord tag(const QString& name) const; + + /** Check whether the list of tags has m.favourite */ + bool isFavourite() const; + /** Check whether the list of tags has m.lowpriority */ + bool isLowPriority() const; + + /** Check whether this room is a direct chat */ + bool isDirectChat() const; + Q_INVOKABLE QUrl urlToThumbnail(const QString& eventId); Q_INVOKABLE QUrl urlToDownload(const QString& eventId); Q_INVOKABLE QString fileNameToDownload(const QString& eventId); @@ -279,6 +316,7 @@ namespace QMatrixClient void avatarChanged(); void userAdded(User* user); void userRemoved(User* user); + void memberAboutToRename(User* user, QString newName); void memberRenamed(User* user); void memberListChanged(); @@ -295,6 +333,8 @@ namespace QMatrixClient void readMarkerMoved(); void unreadMessagesChanged(Room* room); + void tagsChanged(); + void replacedEvent(const RoomEvent* newEvent, const RoomEvent* oldEvent); @@ -307,6 +347,7 @@ namespace QMatrixClient protected: virtual void processStateEvents(const RoomEvents& events); virtual void processEphemeralEvent(EventPtr event); + virtual void processAccountDataEvent(EventPtr event); virtual void onAddNewTimelineEvents(timeline_iter_t from) { } virtual void onAddHistoricalTimelineEvents(rev_iter_t from) { } virtual void onRedaction(const RoomEvent* prevEvent, @@ -323,12 +364,13 @@ namespace QMatrixClient explicit MemberSorter(const Room* r) : room(r) { } bool operator()(User* u1, User* u2) const; + bool operator()(User* u1, const QString& u2name) const; - template <typename ContT> + template <typename ContT, typename ValT> typename ContT::size_type lowerBoundIndex(const ContT& c, - typename ContT::value_type v) const + const ValT& v) const { - return std::lower_bound(c.begin(), c.end(), v, *this) - c.begin(); + return std::lower_bound(c.begin(), c.end(), v, *this) - c.begin(); } private: @@ -19,9 +19,11 @@ #include "user.h" #include "connection.h" +#include "room.h" #include "avatar.h" #include "events/event.h" #include "events/roommemberevent.h" +#include "jobs/setroomstatejob.h" #include "jobs/generated/profile.h" #include "jobs/generated/content-repo.h" @@ -29,40 +31,178 @@ #include <QtCore/QRegularExpression> #include <QtCore/QPointer> #include <QtCore/QStringBuilder> +#include <QtCore/QElapsedTimer> #include <functional> +#include <unordered_set> using namespace QMatrixClient; +using namespace std::placeholders; +using std::move; class User::Private { public: + static Avatar* makeAvatar(QUrl url); + Private(QString userId, Connection* connection) - : userId(std::move(userId)), connection(connection) - , avatar(connection, QIcon::fromTheme(QStringLiteral("user-available"))) + : userId(move(userId)), connection(connection) { } + ~Private() + { + for (auto a: otherAvatars) + delete a; + } QString userId; - QString name; - QString bridged; Connection* connection; - Avatar avatar; - QPointer<UploadContentJob> avatarUploadJob = nullptr; - void setAvatar(UploadContentJob* job, User* q); + QString mostUsedName; + QString bridged; + const QScopedPointer<Avatar> mostUsedAvatar { makeAvatar({}) }; + QMultiHash<QString, const Room*> otherNames; + QHash<QUrl, Avatar*> otherAvatars; + QMultiHash<QUrl, const Room*> avatarsToRooms; + + mutable int totalRooms = 0; + + QString nameForRoom(const Room* r, const QString& hint = {}) const; + void setNameForRoom(const Room* r, QString newName, QString oldName); + QUrl avatarUrlForRoom(const Room* r, const QUrl& hint = {}) const; + void setAvatarForRoom(const Room* r, const QUrl& newUrl, + const QUrl& oldUrl); + + void setAvatarOnServer(QString contentUri, User* q); + }; -User::User(QString userId, Connection* connection) - : QObject(connection), d(new Private(std::move(userId), connection)) +Avatar* User::Private::makeAvatar(QUrl url) { - setObjectName(userId); + static const QIcon icon + { QIcon::fromTheme(QStringLiteral("user-available")) }; + return new Avatar(url, icon); +} + +QString User::Private::nameForRoom(const Room* r, const QString& hint) const +{ + // If the hint is accurate, this function is O(1) instead of O(n) + if (hint == mostUsedName || otherNames.contains(hint, r)) + return hint; + return otherNames.key(r, mostUsedName); +} + +static constexpr int MIN_JOINED_ROOMS_TO_LOG = 20; + +void User::Private::setNameForRoom(const Room* r, QString newName, + QString oldName) +{ + Q_ASSERT(oldName != newName); + Q_ASSERT(oldName == mostUsedName || otherNames.contains(oldName, r)); + if (totalRooms < 2) + { + Q_ASSERT_X(totalRooms > 0 && otherNames.empty(), __FUNCTION__, + "Internal structures inconsistency"); + mostUsedName = move(newName); + return; + } + otherNames.remove(oldName, r); + if (newName != mostUsedName) + { + // Check if the newName is about to become most used. + if (otherNames.count(newName) >= totalRooms - otherNames.size()) + { + Q_ASSERT(totalRooms > 1); + QElapsedTimer et; + if (totalRooms > MIN_JOINED_ROOMS_TO_LOG) + { + qCDebug(MAIN) << "Switching the most used name of user" << userId + << "from" << mostUsedName << "to" << newName; + qCDebug(MAIN) << "The user is in" << totalRooms << "rooms"; + et.start(); + } + + for (auto* r1: connection->roomMap()) + if (nameForRoom(r1) == mostUsedName) + otherNames.insert(mostUsedName, r1); + + mostUsedName = newName; + otherNames.remove(newName); + if (totalRooms > MIN_JOINED_ROOMS_TO_LOG) + qCDebug(PROFILER) << et.elapsed() + << "ms to switch the most used name"; + } + else + otherNames.insert(newName, r); + } } -User::~User() +QUrl User::Private::avatarUrlForRoom(const Room* r, const QUrl& hint) const { - delete d; + // If the hint is accurate, this function is O(1) instead of O(n) + if (hint == mostUsedAvatar->url() || avatarsToRooms.contains(hint, r)) + return hint; + auto it = std::find(avatarsToRooms.begin(), avatarsToRooms.end(), r); + return it == avatarsToRooms.end() ? mostUsedAvatar->url() : it.key(); } +void User::Private::setAvatarForRoom(const Room* r, const QUrl& newUrl, + const QUrl& oldUrl) +{ + Q_ASSERT(oldUrl != newUrl); + Q_ASSERT(oldUrl == mostUsedAvatar->url() || + avatarsToRooms.contains(oldUrl, r)); + if (totalRooms < 2) + { + Q_ASSERT_X(totalRooms > 0 && otherAvatars.empty(), __FUNCTION__, + "Internal structures inconsistency"); + mostUsedAvatar->updateUrl(newUrl); + return; + } + avatarsToRooms.remove(oldUrl, r); + if (!avatarsToRooms.contains(oldUrl)) + { + delete otherAvatars.value(oldUrl); + otherAvatars.remove(oldUrl); + } + if (newUrl != mostUsedAvatar->url()) + { + // Check if the new avatar is about to become most used. + if (avatarsToRooms.count(newUrl) >= totalRooms - avatarsToRooms.size()) + { + QElapsedTimer et; + if (totalRooms > MIN_JOINED_ROOMS_TO_LOG) + { + qCDebug(MAIN) << "Switching the most used avatar of user" << userId + << "from" << mostUsedAvatar->url().toDisplayString() + << "to" << newUrl.toDisplayString(); + et.start(); + } + avatarsToRooms.remove(newUrl); + auto* nextMostUsed = otherAvatars.take(newUrl); + std::swap(*mostUsedAvatar, *nextMostUsed); + otherAvatars.insert(nextMostUsed->url(), nextMostUsed); + for (const auto* r1: connection->roomMap()) + if (avatarUrlForRoom(r1) == nextMostUsed->url()) + avatarsToRooms.insert(nextMostUsed->url(), r1); + + if (totalRooms > MIN_JOINED_ROOMS_TO_LOG) + qCDebug(PROFILER) << et.elapsed() + << "ms to switch the most used avatar"; + } else { + otherAvatars.insert(newUrl, makeAvatar(newUrl)); + avatarsToRooms.insert(newUrl, r); + } + } +} + +User::User(QString userId, Connection* connection) + : QObject(connection), d(new Private(move(userId), connection)) +{ + setObjectName(userId); +} + +User::~User() = default; + QString User::id() const { return d->userId; @@ -77,26 +217,41 @@ bool User::isGuest() const return *it == ':'; } -QString User::name() const +QString User::name(const Room* room) const { - return d->name; + return d->nameForRoom(room); } -void User::updateName(const QString& newName) +void User::updateName(const QString& newName, const Room* room) { - const auto oldName = name(); - if (oldName != newName) + updateName(newName, d->nameForRoom(room), room); +} + +void User::updateName(const QString& newName, const QString& oldName, + const Room* room) +{ + Q_ASSERT(oldName == d->mostUsedName || d->otherNames.contains(oldName, room)); + if (newName != oldName) { - d->name = newName; + emit nameAboutToChange(newName, oldName, room); + d->setNameForRoom(room, newName, oldName); setObjectName(displayname()); - emit nameChanged(newName, oldName); + emit nameChanged(newName, oldName, room); } } -void User::updateAvatarUrl(const QUrl& newUrl) +void User::updateAvatarUrl(const QUrl& newUrl, const QUrl& oldUrl, + const Room* room) { - if (d->avatar.updateUrl(newUrl)) - emit avatarChanged(this); + Q_ASSERT(oldUrl == d->mostUsedAvatar->url() || + d->avatarsToRooms.contains(oldUrl, room)); + if (newUrl != oldUrl) + { + d->setAvatarForRoom(room, newUrl, oldUrl); + setObjectName(displayname()); + emit avatarChanged(this, room); + } + } void User::rename(const QString& newName) @@ -105,41 +260,53 @@ void User::rename(const QString& newName) connect(job, &BaseJob::success, this, [=] { updateName(newName); }); } +void User::rename(const QString& newName, const Room* r) +{ + if (!r) + { + qCWarning(MAIN) << "Passing a null room to two-argument User::rename()" + "is incorrect; client developer, please fix it"; + rename(newName); + } + Q_ASSERT_X(r->memberJoinState(this) == JoinState::Join, __FUNCTION__, + "Attempt to rename a user that's not a room member"); + MemberEventContent evtC; + evtC.displayName = newName; + auto job = d->connection->callApi<SetRoomStateJob>( + r->id(), id(), RoomMemberEvent(move(evtC))); + connect(job, &BaseJob::success, this, [=] { updateName(newName, r); }); +} + bool User::setAvatar(const QString& fileName) { - if (isJobRunning(d->avatarUploadJob)) - return false; - d->setAvatar(d->connection->uploadFile(fileName), this); - return true; + return avatarObject().upload(d->connection, fileName, + std::bind(&Private::setAvatarOnServer, d.data(), _1, this)); } bool User::setAvatar(QIODevice* source) { - if (isJobRunning(d->avatarUploadJob) || !source->isReadable()) - return false; - d->setAvatar(d->connection->uploadContent(source), this); - return true; + return avatarObject().upload(d->connection, source, + std::bind(&Private::setAvatarOnServer, d.data(), _1, this)); } -void User::Private::setAvatar(UploadContentJob* job, User* q) +void User::Private::setAvatarOnServer(QString contentUri, User* q) { - avatarUploadJob = job; - connect(job, &BaseJob::success, q, [this,q] { - auto* j = connection->callApi<SetAvatarUrlJob>( - userId, avatarUploadJob->contentUri()); - connect(j, &BaseJob::success, q, [q] { emit q->avatarChanged(q); }); - }); + auto* j = connection->callApi<SetAvatarUrlJob>(userId, contentUri); + connect(j, &BaseJob::success, q, + [=] { q->updateAvatarUrl(contentUri, avatarUrlForRoom(nullptr)); }); } -QString User::displayname() const +QString User::displayname(const Room* room) const { - return d->name.isEmpty() ? d->userId : d->name; + auto name = d->nameForRoom(room); + return name.isEmpty() ? d->userId : + room ? room->roomMembername(name) : name; } -QString User::fullName() const +QString User::fullName(const Room* room) const { - return d->name.isEmpty() ? d->userId : - d->name % " (" % d->userId % ')'; + auto name = d->nameForRoom(room); + return name.isEmpty() ? d->userId : name % " (" % d->userId % ')'; } QString User::bridged() const @@ -147,48 +314,82 @@ QString User::bridged() const return d->bridged; } -const Avatar& User::avatarObject() const +const Avatar& User::avatarObject(const Room* room) const { - return d->avatar; + return *d->otherAvatars.value(d->avatarUrlForRoom(room), + d->mostUsedAvatar.data()); } -QImage User::avatar(int dimension) +QImage User::avatar(int dimension, const Room* room) { - return avatar(dimension, dimension); + return avatar(dimension, dimension, room); } -QImage User::avatar(int width, int height) +QImage User::avatar(int width, int height, const Room* room) { - return d->avatar.get(width, height, [=] { emit avatarChanged(this); }); + return avatar(width, height, room, []{}); } -QString User::avatarMediaId() const +QImage User::avatar(int width, int height, const Room* room, + Avatar::get_callback_t callback) { - return d->avatar.mediaId(); + return avatarObject(room).get(d->connection, width, height, + [=] { emit avatarChanged(this, room); callback(); }); } -QUrl User::avatarUrl() const +QString User::avatarMediaId(const Room* room) const { - return d->avatar.url(); + return avatarObject(room).mediaId(); } -void User::processEvent(Event* event) +QUrl User::avatarUrl(const Room* room) const { - if( event->type() == EventType::RoomMember ) + return avatarObject(room).url(); +} + +void User::processEvent(RoomMemberEvent* event, const Room* room) +{ + if (event->membership() != MembershipType::Invite && + event->membership() != MembershipType::Join) + return; + + auto aboutToEnter = room->memberJoinState(this) == JoinState::Leave && + (event->membership() == MembershipType::Join || + event->membership() == MembershipType::Invite); + if (aboutToEnter) + ++d->totalRooms; + + auto newName = event->displayName(); + // `bridged` value uses the same notification signal as the name; + // it is assumed that first setting of the bridge occurs together with + // the first setting of the name, and further bridge updates are + // exceptionally rare (the only reasonable case being that the bridge + // changes the naming convention). For the same reason room-specific + // bridge tags are not supported at all. + QRegularExpression reSuffix(" \\((IRC|Gitter|Telegram)\\)$"); + auto match = reSuffix.match(newName); + if (match.hasMatch()) { - auto e = static_cast<RoomMemberEvent*>(event); - if (e->membership() == MembershipType::Leave) - return; - - auto newName = e->displayName(); - QRegularExpression reSuffix(" \\((IRC|Gitter|Telegram)\\)$"); - auto match = reSuffix.match(newName); - if (match.hasMatch()) + if (d->bridged != match.captured(1)) { + if (!d->bridged.isEmpty()) + qCWarning(MAIN) << "Bridge for user" << id() << "changed:" + << d->bridged << "->" << match.captured(1); d->bridged = match.captured(1); - newName.truncate(match.capturedStart(0)); } - updateName(newName); - updateAvatarUrl(e->avatarUrl()); + newName.truncate(match.capturedStart(0)); + } + if (event->prevContent()) + { + // FIXME: the hint doesn't work for bridged users + auto oldNameHint = + d->nameForRoom(room, event->prevContent()->displayName); + updateName(event->displayName(), oldNameHint, room); + updateAvatarUrl(event->avatarUrl(), + d->avatarUrlForRoom(room, event->prevContent()->avatarUrl), + room); + } else { + updateName(event->displayName(), room); + updateAvatarUrl(event->avatarUrl(), d->avatarUrlForRoom(room), room); } } @@ -24,8 +24,10 @@ namespace QMatrixClient { - class Event; class Connection; + class Room; + class RoomMemberEvent; + class User: public QObject { Q_OBJECT @@ -51,7 +53,7 @@ namespace QMatrixClient * it. * \sa displayName */ - QString name() const; + QString name(const Room* room = nullptr) const; /** Get the displayed user name * This method returns the result of name() if its non-empty; @@ -60,7 +62,7 @@ namespace QMatrixClient * should be disambiguated. * \sa name, id, fullName Room::roomMembername */ - QString displayname() const; + QString displayname(const Room* room = nullptr) const; /** Get user name and id in one string * The constructed string follows the format 'name (id)' @@ -68,7 +70,7 @@ namespace QMatrixClient * places. * \sa displayName, Room::roomMembername */ - QString fullName() const; + QString fullName(const Room* room = nullptr) const; /** * Returns the name of bridge the user is connected from or empty. @@ -82,31 +84,41 @@ namespace QMatrixClient */ bool isGuest() const; - const Avatar& avatarObject() const; - Q_INVOKABLE QImage avatar(int dimension); - Q_INVOKABLE QImage avatar(int requestedWidth, int requestedHeight); + const Avatar& avatarObject(const Room* room = nullptr) const; + Q_INVOKABLE QImage avatar(int dimension, const Room* room = nullptr); + Q_INVOKABLE QImage avatar(int requestedWidth, int requestedHeight, + const Room* room = nullptr); + QImage avatar(int width, int height, const Room* room, + Avatar::get_callback_t callback); - QString avatarMediaId() const; - QUrl avatarUrl() const; + QString avatarMediaId(const Room* room = nullptr) const; + QUrl avatarUrl(const Room* room = nullptr) const; - void processEvent(Event* event); + void processEvent(RoomMemberEvent* event, const Room* r = nullptr); public slots: void rename(const QString& newName); + void rename(const QString& newName, const Room* r); bool setAvatar(const QString& fileName); bool setAvatar(QIODevice* source); signals: - void nameChanged(QString newName, QString oldName); - void avatarChanged(User* user); + void nameAboutToChange(QString newName, QString oldName, + const Room* roomContext); + void nameChanged(QString newName, QString oldName, + const Room* roomContext); + void avatarChanged(User* user, const Room* roomContext); private slots: - void updateName(const QString& newName); - void updateAvatarUrl(const QUrl& newUrl); + void updateName(const QString& newName, const Room* room = nullptr); + void updateName(const QString& newName, const QString& oldName, + const Room* room = nullptr); + void updateAvatarUrl(const QUrl& newUrl, const QUrl& oldUrl, + const Room* room = nullptr); private: class Private; - Private* d; + QScopedPointer<Private> d; }; } Q_DECLARE_METATYPE(QMatrixClient::User*) |