diff options
Diffstat (limited to 'lib')
-rw-r--r-- | lib/connection.cpp | 256 | ||||
-rw-r--r-- | lib/connection.h | 92 | ||||
-rw-r--r-- | lib/converters.h | 4 | ||||
-rw-r--r-- | lib/events/accountdataevents.h | 1 | ||||
-rw-r--r-- | lib/events/reactionevent.cpp | 44 | ||||
-rw-r--r-- | lib/events/reactionevent.h | 81 | ||||
-rw-r--r-- | lib/events/roomevent.cpp | 14 | ||||
-rw-r--r-- | lib/events/roomevent.h | 2 | ||||
-rw-r--r-- | lib/events/roommessageevent.cpp | 108 | ||||
-rw-r--r-- | lib/events/roommessageevent.h | 3 | ||||
-rw-r--r-- | lib/jobs/basejob.cpp | 45 | ||||
-rw-r--r-- | lib/room.cpp | 298 | ||||
-rw-r--r-- | lib/room.h | 13 | ||||
-rw-r--r-- | lib/ssosession.cpp | 127 | ||||
-rw-r--r-- | lib/ssosession.h | 44 | ||||
-rw-r--r-- | lib/user.cpp | 29 | ||||
-rw-r--r-- | lib/util.cpp | 2 |
17 files changed, 978 insertions, 185 deletions
diff --git a/lib/connection.cpp b/lib/connection.cpp index ac69228b..0c98c383 100644 --- a/lib/connection.cpp +++ b/lib/connection.cpp @@ -32,12 +32,13 @@ #include "csapi/joining.h" #include "csapi/to_device.h" #include "csapi/room_send.h" +#include "csapi/wellknown.h" +#include "csapi/versions.h" #include "jobs/syncjob.h" #include "jobs/mediathumbnailjob.h" #include "jobs/downloadfilejob.h" #include "csapi/voip.h" -#include <QtNetwork/QDnsLookup> #include <QtCore/QFile> #include <QtCore/QDir> #include <QtCore/QStandardPaths> @@ -79,10 +80,10 @@ class Connection::Private std::unique_ptr<ConnectionData> data; // A complex key below is a pair of room name and whether its // state is Invited. The spec mandates to keep Invited room state - // separately so we should, e.g., keep objects for Invite and - // Leave state of the same room. + // separately; specifically, we should keep objects for Invite and + // Leave state of the same room if the two happen to co-exist. QHash<QPair<QString, bool>, Room*> roomMap; - // Mapping from aliases to room ids, as per the last sync + /// Mapping from aliases to room ids, as of the last sync QHash<QString, QString> roomAliasMap; QVector<QString> roomIdsToForget; QVector<Room*> firstTimeRooms; @@ -101,6 +102,8 @@ class Connection::Private GetCapabilitiesJob* capabilitiesJob = nullptr; GetCapabilitiesJob::Capabilities capabilities; + QVector<GetLoginFlowsJob::LoginFlow> loginFlows; + SyncJob* syncJob = nullptr; bool cacheState = true; @@ -108,8 +111,10 @@ class Connection::Private .value("cache_type").toString() != "json"; bool lazyLoading = false; - void connectWithToken(const QString& user, const QString& accessToken, - const QString& deviceId); + template <typename... LoginArgTs> + void loginToServer(LoginArgTs&&... loginArgs); + void assumeIdentity(const QString& newUserId, const QString& accessToken, + const QString& deviceId); template <typename EventT> EventT* unpackAccountData() const @@ -170,44 +175,73 @@ void Connection::resolveServer(const QString& mxidOrDomain) maybeBaseUrl.setScheme("https"); // Instead of the Qt-default "http" if (!match.hasMatch() || !maybeBaseUrl.isValid()) { - emit resolveError( - tr("%1 is not a valid homeserver address") - .arg(maybeBaseUrl.toString())); + emit resolveError(tr("%1 is not a valid homeserver address") + .arg(maybeBaseUrl.toString())); return; } - setHomeserver(maybeBaseUrl); - emit resolved(); - return; - - // FIXME, #178: The below code is incorrect and is no more executed. The - // correct server resolution should be done from .well-known/matrix/client auto domain = maybeBaseUrl.host(); qCDebug(MAIN) << "Finding the server" << domain; - // Check if the Matrix server has a dedicated service record. - auto* dns = new QDnsLookup(); - dns->setType(QDnsLookup::SRV); - dns->setName("_matrix._tcp." + domain); - - connect(dns, &QDnsLookup::finished, [this,dns,maybeBaseUrl]() { - QUrl baseUrl { maybeBaseUrl }; - if (dns->error() == QDnsLookup::NoError && - dns->serviceRecords().isEmpty()) - { - auto record = dns->serviceRecords().front(); - baseUrl.setHost(record.target()); - baseUrl.setPort(record.port()); - qCDebug(MAIN) << "SRV record for" << maybeBaseUrl.host() - << "is" << baseUrl.authority(); - } else { - qCDebug(MAIN) << baseUrl.host() << "doesn't have SRV record" - << dns->name() << "- using the hostname as is"; - } - setHomeserver(baseUrl); - emit resolved(); - dns->deleteLater(); - }); - dns->lookup(); + + d->data->setBaseUrl(maybeBaseUrl); // Just enough to check .well-known file + auto getWellKnownJob = callApi<GetWellknownJob>(); + // This is a workaround for 0.5.x; due to the way Quaternion's login dialog + // operates, Connection can disappear any moment during server resolution. + // Quotient 0.6 will reparent all jobs to enforce lifetimes. See also #398. + getWellKnownJob->setParent(this); + connect(getWellKnownJob, &BaseJob::finished, this, + [this, getWellKnownJob, maybeBaseUrl] { + if (getWellKnownJob->status() != BaseJob::NotFoundError) { + if (getWellKnownJob->status() != BaseJob::Success) { + qCWarning(MAIN) + << "Fetching .well-known file failed, FAIL_PROMPT"; + emit resolveError(tr("Failed resolving the homeserver")); + return; + } + QUrl baseUrl { getWellKnownJob->data().homeserver.baseUrl }; + if (baseUrl.isEmpty()) { + qCWarning(MAIN) << "base_url not provided, FAIL_PROMPT"; + emit resolveError( + tr("The homeserver base URL is not provided")); + return; + } + if (!baseUrl.isValid()) { + qCWarning(MAIN) << "base_url invalid, FAIL_ERROR"; + emit resolveError(tr("The homeserver base URL is invalid")); + return; + } + qCInfo(MAIN) << ".well-known URL for" << maybeBaseUrl.host() + << "is" << baseUrl.authority(); + setHomeserver(baseUrl); + } else { + qCInfo(MAIN) << "No .well-known file, using" << maybeBaseUrl + << "for base URL"; + setHomeserver(maybeBaseUrl); + } + + auto getVersionsJob = callApi<GetVersionsJob>(); + getVersionsJob->setParent(this); // Same workaround as above + connect(getVersionsJob, &BaseJob::success, this, + &Connection::resolved); + connect(getVersionsJob, &BaseJob::failure, this, [this] { + qCWarning(MAIN) << "Homeserver base URL invalid"; + emit resolveError(tr("The homeserver base URL " + "doesn't seem to work")); + }); + }); +} + +inline UserIdentifier makeUserIdentifier(const QString& id) +{ + return { QStringLiteral("m.id.user"), { { QStringLiteral("user"), id } } }; +} + +inline UserIdentifier make3rdPartyIdentifier(const QString& medium, + const QString& address) +{ + return { QStringLiteral("m.id.thirdparty"), + { { QStringLiteral("medium"), medium }, + { QStringLiteral("address"), address } } }; } void Connection::connectToServer(const QString& user, const QString& password, @@ -219,23 +253,28 @@ void Connection::connectToServer(const QString& user, const QString& password, doConnectToServer(user, password, initialDeviceName, deviceId); }); } + void Connection::doConnectToServer(const QString& user, const QString& password, const QString& initialDeviceName, const QString& deviceId) { - auto loginJob = callApi<LoginJob>(QStringLiteral("m.login.password"), - UserIdentifier { QStringLiteral("m.id.user"), - {{ QStringLiteral("user"), user }} }, - password, /*token*/ "", deviceId, initialDeviceName); - connect(loginJob, &BaseJob::success, this, - [this, loginJob] { - d->connectWithToken(loginJob->userId(), loginJob->accessToken(), - loginJob->deviceId()); - }); - connect(loginJob, &BaseJob::failure, this, - [this, loginJob] { - emit loginError(loginJob->errorString(), loginJob->rawDataSample()); - }); + d->loginToServer(LoginFlows::Password.type, makeUserIdentifier(user), + password, /*token*/ "", deviceId, initialDeviceName); +} + +SsoSession* Connection::prepareForSso(const QString& initialDeviceName, + const QString& deviceId) +{ + return new SsoSession(this, initialDeviceName, deviceId); +} + +void Connection::loginWithToken(const QByteArray& loginToken, + const QString& initialDeviceName, + const QString& deviceId) +{ + d->loginToServer(LoginFlows::Token.type, + makeUserIdentifier(/*user is encoded in loginToken*/ {}), + /*password*/ "", loginToken, deviceId, initialDeviceName); } void Connection::syncLoopIteration() @@ -247,8 +286,15 @@ void Connection::connectWithToken(const QString& userId, const QString& accessToken, const QString& deviceId) { + assumeIdentity(userId, accessToken, deviceId); +} + +void Connection::assumeIdentity(const QString& userId, + const QString& accessToken, + const QString& deviceId) +{ checkAndConnect(userId, - [=] { d->connectWithToken(userId, accessToken, deviceId); }); + [=] { d->assumeIdentity(userId, accessToken, deviceId); }); } void Connection::reloadCapabilities() @@ -283,11 +329,25 @@ bool Connection::loadingCapabilities() const return d->capabilities.roomVersions.omitted(); } -void Connection::Private::connectWithToken(const QString& user, - const QString& accessToken, - const QString& deviceId) +template <typename... LoginArgTs> +void Connection::Private::loginToServer(LoginArgTs&&... loginArgs) +{ + auto loginJob = + q->callApi<LoginJob>(std::forward<LoginArgTs>(loginArgs)...); + connect(loginJob, &BaseJob::success, q, [this, loginJob] { + assumeIdentity(loginJob->userId(), loginJob->accessToken(), + loginJob->deviceId()); + }); + connect(loginJob, &BaseJob::failure, q, [this, loginJob] { + emit q->loginError(loginJob->errorString(), loginJob->rawDataSample()); + }); +} + +void Connection::Private::assumeIdentity(const QString& newUserId, + const QString& accessToken, + const QString& deviceId) { - userId = user; + userId = newUserId; q->user(); // Creates a User object for the local user data->setToken(accessToken.toLatin1()); data->setDeviceId(deviceId); @@ -850,6 +910,21 @@ QString Connection::domain() const return d->userId.section(':', 1); } +QVector<GetLoginFlowsJob::LoginFlow> Connection::loginFlows() const +{ + return d->loginFlows; +} + +bool Connection::supportsPasswordAuth() const +{ + return d->loginFlows.contains(LoginFlows::Password); +} + +bool Connection::supportsSso() const +{ + return d->loginFlows.contains(LoginFlows::SSO); +} + Room* Connection::room(const QString& roomId, JoinStates states) const { Room* room = d->roomMap.value({roomId, false}, nullptr); @@ -979,6 +1054,33 @@ QHash< QPair<QString, bool>, Room* > Connection::roomMap() const return roomMap; } +QVector<Room*> Connection::allRooms() const +{ + QVector<Room*> result; + result.resize(d->roomMap.size()); + std::copy(d->roomMap.cbegin(), d->roomMap.cend(), result.begin()); + return result; +} + +QVector<Room*> Connection::rooms(JoinStates joinStates) const +{ + QVector<Room*> result; + for (auto* r: qAsConst(d->roomMap)) + if (joinStates.testFlag(r->joinState())) + result.push_back(r); + return result; +} + +int Connection::roomsCount(JoinStates joinStates) const +{ + // Using int to maintain compatibility with QML + // (consider also that QHash<>::size() returns int anyway). + return int(std::count_if(d->roomMap.begin(), d->roomMap.end(), + [joinStates](Room* r) { + return joinStates.testFlag(r->joinState()); + })); +} + bool Connection::hasAccountData(const QString& type) const { return d->accountData.find(type) != d->accountData.cend(); @@ -1244,11 +1346,21 @@ QByteArray Connection::generateTxnId() const void Connection::setHomeserver(const QUrl& url) { - if (homeserver() == url) - return; + if (homeserver() != url) { + d->data->setBaseUrl(url); + d->loginFlows.clear(); + emit homeserverChanged(homeserver()); + } - d->data->setBaseUrl(url); - emit homeserverChanged(homeserver()); + // Whenever a homeserver is updated, retrieve available login flows from it + auto* j = callApi<GetLoginFlowsJob>(BackgroundRequest); + connect(j, &BaseJob::finished, this, [this, j] { + if (j->status().good()) + d->loginFlows = j->flows(); + else + d->loginFlows.clear(); + emit loginFlowsChanged(); + }); } void Connection::saveRoomState(Room* r) const @@ -1294,18 +1406,20 @@ void Connection::saveState() const { QStringLiteral("minor"), SyncData::cacheVersion().second } }}}; { - QJsonObject rooms; - QJsonObject inviteRooms; - const auto& rs = roomMap(); // Pass on rooms in Leave state - for (const auto* i : rs) - (i->joinState() == JoinState::Invite ? inviteRooms : rooms) - .insert(i->id(), QJsonValue::Null); + QJsonObject roomsJson; + QJsonObject inviteRoomsJson; + for (const auto* r: qAsConst(d->roomMap)) { + if (r->joinState() == JoinState::Leave) + continue; + (r->joinState() == JoinState::Invite ? inviteRoomsJson : roomsJson) + .insert(r->id(), QJsonValue::Null); + } QJsonObject roomObj; - if (!rooms.isEmpty()) - roomObj.insert(QStringLiteral("join"), rooms); - if (!inviteRooms.isEmpty()) - roomObj.insert(QStringLiteral("invite"), inviteRooms); + if (!roomsJson.isEmpty()) + roomObj.insert(QStringLiteral("join"), roomsJson); + if (!inviteRoomsJson.isEmpty()) + roomObj.insert(QStringLiteral("invite"), inviteRoomsJson); rootObj.insert(QStringLiteral("next_batch"), d->data->lastEvent()); rootObj.insert(QStringLiteral("rooms"), roomObj); diff --git a/lib/connection.h b/lib/connection.h index ea5be51a..b0dfeb5e 100644 --- a/lib/connection.h +++ b/lib/connection.h @@ -18,6 +18,8 @@ #pragma once +#include "csapi/login.h" +#include "ssosession.h" #include "csapi/create_room.h" #include "joinstate.h" #include "events/accountdataevents.h" @@ -30,6 +32,8 @@ #include <functional> #include <memory> +Q_DECLARE_METATYPE(QMatrixClient::GetLoginFlowsJob::LoginFlow) + namespace QMatrixClient { class Room; @@ -51,6 +55,28 @@ namespace QMatrixClient class SendMessageJob; class LeaveRoomJob; + // To simplify comparisons of LoginFlows + + inline bool operator==(const GetLoginFlowsJob::LoginFlow& lhs, + const GetLoginFlowsJob::LoginFlow& rhs) + { + return lhs.type == rhs.type; + } + + inline bool operator!=(const GetLoginFlowsJob::LoginFlow& lhs, + const GetLoginFlowsJob::LoginFlow& rhs) + { + return !(lhs == rhs); + } + + /// Predefined login flows + namespace LoginFlows { + using LoginFlow = GetLoginFlowsJob::LoginFlow; + static const LoginFlow Password { "m.login.password" }; + static const LoginFlow SSO { "m.login.sso" }; + static const LoginFlow Token { "m.login.token" }; + } + class Connection; using room_factory_t = std::function<Room*(Connection*, const QString&, @@ -95,9 +121,6 @@ namespace QMatrixClient class Connection: public QObject { Q_OBJECT - /** Whether or not the rooms state should be cached locally - * \sa loadState(), saveState() - */ Q_PROPERTY(User* localUser READ user NOTIFY stateChanged) Q_PROPERTY(QString localUserId READ userId NOTIFY stateChanged) Q_PROPERTY(QString deviceId READ deviceId NOTIFY stateChanged) @@ -105,6 +128,9 @@ namespace QMatrixClient Q_PROPERTY(QString defaultRoomVersion READ defaultRoomVersion NOTIFY capabilitiesLoaded) Q_PROPERTY(QUrl homeserver READ homeserver WRITE setHomeserver NOTIFY homeserverChanged) Q_PROPERTY(QString domain READ domain NOTIFY homeserverChanged) + Q_PROPERTY(QVector<QMatrixClient::GetLoginFlowsJob::LoginFlow> loginFlows READ loginFlows NOTIFY loginFlowsChanged) + Q_PROPERTY(bool supportsSso READ supportsSso NOTIFY loginFlowsChanged) + Q_PROPERTY(bool supportsPasswordAuth READ supportsPasswordAuth NOTIFY loginFlowsChanged) Q_PROPERTY(bool cacheState READ cacheState WRITE setCacheState NOTIFY cacheStateChanged) Q_PROPERTY(bool lazyLoading READ lazyLoading WRITE setLazyLoading NOTIFY lazyLoadingChanged) @@ -128,11 +154,38 @@ namespace QMatrixClient virtual ~Connection(); /** Get all Invited and Joined rooms + * + * \deprecated + * Use allRooms(), roomsWithTag(), or rooms(JoinStates) instead * \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 rooms known within this Connection + * + * This includes Invite, Join and Leave rooms, in no particular order. + * \note Leave rooms will only show up in the list if they have been left + * in the same running session. The library doesn't cache left rooms + * between runs and it doesn't retrieve the full list of left rooms + * from the server. + * \sa rooms, room, roomsWithTag + */ + Q_INVOKABLE QVector<Room*> allRooms() const; + + /** Get rooms that have either of the given join state(s) + * + * This method returns, in no particular order, rooms which join state + * matches the mask passed in \p joinStates. + * \note Similar to allRooms(), this won't retrieve the full list of + * Leave rooms from the server. + * \sa allRooms, room, roomsWithTag + */ + Q_INVOKABLE QVector<Room*> rooms(JoinStates joinStates) const; + + /** Get the total number of rooms in the given join state(s) */ + Q_INVOKABLE int roomsCount(JoinStates joinStates) const; + /** Check whether the account has data of the given type * Direct chats map is not supported by this method _yet_. */ @@ -246,6 +299,12 @@ namespace QMatrixClient QUrl homeserver() const; /** Get the domain name used for ids/aliases on the server */ QString domain() const; + /** Get the list of supported login flows */ + QVector<GetLoginFlowsJob::LoginFlow> loginFlows() const; + /** Check whether the current homeserver supports password auth */ + bool supportsPasswordAuth() const; + /** Check whether the current homeserver supports SSO */ + bool supportsSso() const; /** Find a room by its id and a mask of applicable states */ Q_INVOKABLE Room* room(const QString& roomId, JoinStates states = JoinState::Invite|JoinState::Join) const; @@ -372,6 +431,21 @@ namespace QMatrixClient std::forward<JobArgTs>(jobArgs)...); } + /** Get a request URL for a job with specified type and arguments + * + * This calls JobT::makeRequestUrl() prepending the connection's + * homeserver to the list of arguments. + */ + template <typename JobT, typename... JobArgTs> + QUrl getUrlForApi(JobArgTs&&... jobArgs) const + { + return JobT::makeRequestUrl(homeserver(), + std::forward<JobArgTs>(jobArgs)...); + } + + Q_INVOKABLE SsoSession* prepareForSso( + const QString& initialDeviceName, const QString& deviceId = {}); + /** Generate a new transaction id. Transaction id's are unique within * a single Connection object */ @@ -407,7 +481,16 @@ namespace QMatrixClient void connectToServer(const QString& user, const QString& password, const QString& initialDeviceName, const QString& deviceId = {}); - void connectWithToken(const QString& userId, const QString& accessToken, + void loginWithToken(const QByteArray& loginToken, + const QString& initialDeviceName, + const QString& deviceId = {}); + void assumeIdentity(const QString& userId, const QString& accessToken, + const QString& deviceId); + /*! @deprecated + * Use assumeIdentity() if you have an access token or + * loginWithToken() if you have a login token. + */ + void connectWithToken(const QString& userId, const QString& accessToken, const QString& deviceId); /// Explicitly request capabilities from the server void reloadCapabilities(); @@ -550,6 +633,7 @@ namespace QMatrixClient void resolveError(QString error); void homeserverChanged(QUrl baseUrl); + void loginFlowsChanged(); void capabilitiesLoaded(); void connected(); diff --git a/lib/converters.h b/lib/converters.h index af2be645..5c31b93d 100644 --- a/lib/converters.h +++ b/lib/converters.h @@ -37,6 +37,7 @@ template <typename T> using optional = std::experimental::optional<T>; #endif +#if QT_VERSION < QT_VERSION_CHECK(5,14,0) // Enable std::unordered_map<QString, T> namespace std { @@ -51,7 +52,8 @@ namespace std ); } }; -} +} // namespace std +#endif class QVariant; diff --git a/lib/events/accountdataevents.h b/lib/events/accountdataevents.h index a99d85ac..a43e358c 100644 --- a/lib/events/accountdataevents.h +++ b/lib/events/accountdataevents.h @@ -28,6 +28,7 @@ namespace QMatrixClient { constexpr const char* FavouriteTag = "m.favourite"; constexpr const char* LowPriorityTag = "m.lowpriority"; + constexpr const char* ServerNoticeTag = "m.server_notice"; struct TagRecord { diff --git a/lib/events/reactionevent.cpp b/lib/events/reactionevent.cpp new file mode 100644 index 00000000..0081edc2 --- /dev/null +++ b/lib/events/reactionevent.cpp @@ -0,0 +1,44 @@ +/****************************************************************************** + * Copyright (C) 2019 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 "reactionevent.h" + +using namespace QMatrixClient; + +void QMatrixClient::JsonObjectConverter<EventRelation>::dumpTo( + QJsonObject& jo, const EventRelation& pod) +{ + if (pod.type.isEmpty()) { + qCWarning(MAIN) << "Empty relation type; won't dump to JSON"; + return; + } + jo.insert(QStringLiteral("rel_type"), pod.type); + jo.insert(EventIdKey, pod.eventId); + if (pod.type == EventRelation::Annotation()) + jo.insert(QStringLiteral("key"), pod.key); +} + +void QMatrixClient::JsonObjectConverter<EventRelation>::fillFrom( + const QJsonObject& jo, EventRelation& pod) +{ + // The experimental logic for generic relationships (MSC1849) + fromJson(jo["rel_type"_ls], pod.type); + fromJson(jo[EventIdKeyL], pod.eventId); + if (pod.type == EventRelation::Annotation()) + fromJson(jo["key"_ls], pod.key); +} diff --git a/lib/events/reactionevent.h b/lib/events/reactionevent.h new file mode 100644 index 00000000..7a4c9b5a --- /dev/null +++ b/lib/events/reactionevent.h @@ -0,0 +1,81 @@ +/****************************************************************************** + * Copyright (C) 2019 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 "roomevent.h" + +namespace QMatrixClient { + +struct EventRelation { + // To please MSVC 2015 that doesn't handle initialiser lists like proper + EventRelation(QString type = {}, QString eventId = {}, QString key = {}) + : type(std::move(type)), eventId(std::move(eventId)), key(std::move(key)) + {} + using reltypeid_t = const char*; + static constexpr reltypeid_t Reply() { return "m.in_reply_to"; } + static constexpr reltypeid_t Annotation() { return "m.annotation"; } + static constexpr reltypeid_t Replacement() { return "m.replace"; } + + QString type; + QString eventId; + QString key = {}; // Only used for m.annotation for now + + static EventRelation replyTo(QString eventId) + { + return EventRelation(Reply(), std::move(eventId)); + } + static EventRelation annotate(QString eventId, QString key) + { + return EventRelation(Annotation(), std::move(eventId), std::move(key)); + } + static EventRelation replace(QString eventId) + { + return EventRelation(Replacement(), std::move(eventId)); + } +}; +template <> +struct JsonObjectConverter<EventRelation> +{ + static void dumpTo(QJsonObject& jo, const EventRelation& pod); + static void fillFrom(const QJsonObject& jo, EventRelation& pod); +}; + +class ReactionEvent : public RoomEvent +{ +public: + DEFINE_EVENT_TYPEID("m.reaction", ReactionEvent) + + explicit ReactionEvent(const EventRelation& value) + : RoomEvent(typeId(), matrixTypeId(), + { { QStringLiteral("m.relates_to"), toJson(value) } }) + {} + explicit ReactionEvent(const QJsonObject& obj) + : RoomEvent(typeId(), obj) + {} + EventRelation relation() const + { + return content<EventRelation>(QStringLiteral("m.relates_to")); + } + +//private: +// EventRelation _relation; +}; +REGISTER_EVENT_TYPE(ReactionEvent) + +} // namespace QMatrixClient diff --git a/lib/events/roomevent.cpp b/lib/events/roomevent.cpp index 3d03509f..5e2d0b3c 100644 --- a/lib/events/roomevent.cpp +++ b/lib/events/roomevent.cpp @@ -66,6 +66,20 @@ QString RoomEvent::senderId() const return fullJson()["sender"_ls].toString(); } +bool RoomEvent::isReplaced() const +{ + return unsignedJson()["m.relations"_ls].toObject().contains("m.replace"); +} + +QString RoomEvent::replacedBy() const +{ + // clang-format off + return unsignedJson()["m.relations"_ls].toObject() + .value("m.replace").toObject() + .value(EventIdKeyL).toString(); + // clang-format on +} + QString RoomEvent::redactionReason() const { return isRedacted() ? _redactedBecause->reason() : QString{}; diff --git a/lib/events/roomevent.h b/lib/events/roomevent.h index ce96174e..8926ab0f 100644 --- a/lib/events/roomevent.h +++ b/lib/events/roomevent.h @@ -51,6 +51,8 @@ namespace QMatrixClient QDateTime timestamp() const; QString roomId() const; QString senderId() const; + bool isReplaced() const; + QString replacedBy() const; bool isRedacted() const { return bool(_redactedBecause); } const event_ptr_tt<RedactionEvent>& redactedBecause() const { diff --git a/lib/events/roommessageevent.cpp b/lib/events/roommessageevent.cpp index 8f4e0ebc..1edf82e4 100644 --- a/lib/events/roommessageevent.cpp +++ b/lib/events/roommessageevent.cpp @@ -30,12 +30,13 @@ using namespace EventContent; using MsgType = RoomMessageEvent::MsgType; -static const auto RelatesToKey = "m.relates_to"_ls; -static const auto MsgTypeKey = "msgtype"_ls; -static const auto BodyKey = "body"_ls; -static const auto FormattedBodyKey = "formatted_body"_ls; +static const auto RelatesToKeyL = "m.relates_to"_ls; +static const auto MsgTypeKeyL = "msgtype"_ls; +static const auto BodyKeyL = "body"_ls; +static const auto FormattedBodyKeyL = "formatted_body"_ls; static const auto TextTypeKey = "m.text"; +static const auto EmoteTypeKey = "m.emote"; static const auto NoticeTypeKey = "m.notice"; static const auto HtmlContentTypeId = QStringLiteral("org.matrix.custom.html"); @@ -49,7 +50,7 @@ TypedBase* make(const QJsonObject& json) template <> TypedBase* make<TextContent>(const QJsonObject& json) { - return json.contains(FormattedBodyKey) || json.contains(RelatesToKey) + return json.contains(FormattedBodyKeyL) || json.contains(RelatesToKeyL) ? new TextContent(json) : nullptr; } @@ -62,7 +63,7 @@ struct MsgTypeDesc const std::vector<MsgTypeDesc> msgTypes = { { TextTypeKey, MsgType::Text, make<TextContent> } - , { QStringLiteral("m.emote"), MsgType::Emote, make<TextContent> } + , { EmoteTypeKey, MsgType::Emote, make<TextContent> } , { NoticeTypeKey, MsgType::Notice, make<TextContent> } , { QStringLiteral("m.image"), MsgType::Image, make<ImageContent> } , { QStringLiteral("m.file"), MsgType::File, make<FileContent> } @@ -95,12 +96,27 @@ QJsonObject RoomMessageEvent::assembleContentJson(const QString& plainBody, const QString& jsonMsgType, TypedBase* content) { auto json = content ? content->toJson() : QJsonObject(); - if (jsonMsgType != TextTypeKey && jsonMsgType != NoticeTypeKey && - json.contains(RelatesToKey)) - { - json.remove(RelatesToKey); - qCWarning(EVENTS) << RelatesToKey << "cannot be used in" << jsonMsgType - << "messages; the relation has been stripped off"; + if (json.contains(RelatesToKeyL)) { + if (jsonMsgType != TextTypeKey && jsonMsgType != NoticeTypeKey + && jsonMsgType != EmoteTypeKey) { + json.remove(RelatesToKeyL); + qCWarning(EVENTS) + << RelatesToKeyL << "cannot be used in" << jsonMsgType + << "messages; the relation has been stripped off"; + } else { + // After the above, we know for sure that the content is TextContent + // and that its RelatesTo structure is not omitted + auto* textContent = static_cast<const TextContent*>(content); + if (textContent->relatesTo->type == RelatesTo::ReplacementTypeId()) { + auto newContentJson = json.take("m.new_content"_ls).toObject(); + newContentJson.insert(BodyKeyL, plainBody); + newContentJson.insert(MsgTypeKeyL, jsonMsgType); + json.insert(QStringLiteral("m.new_content"), newContentJson); + json[MsgTypeKeyL] = jsonMsgType; + json[BodyKeyL] = "* " + plainBody; + return json; + } + } } json.insert(QStringLiteral("msgtype"), jsonMsgType); json.insert(QStringLiteral("body"), plainBody); @@ -159,9 +175,9 @@ RoomMessageEvent::RoomMessageEvent(const QJsonObject& obj) if (isRedacted()) return; const QJsonObject content = contentJson(); - if ( content.contains(MsgTypeKey) && content.contains(BodyKey) ) + if ( content.contains(MsgTypeKeyL) && content.contains(BodyKeyL) ) { - auto msgtype = content[MsgTypeKey].toString(); + auto msgtype = content[MsgTypeKeyL].toString(); bool msgTypeFound = false; for (const auto& mt: msgTypes) if (mt.matrixType == msgtype) @@ -191,12 +207,12 @@ RoomMessageEvent::MsgType RoomMessageEvent::msgtype() const QString RoomMessageEvent::rawMsgtype() const { - return contentJson()[MsgTypeKey].toString(); + return contentJson()[MsgTypeKeyL].toString(); } QString RoomMessageEvent::plainBody() const { - return contentJson()[BodyKey].toString(); + return contentJson()[BodyKeyL].toString(); } QMimeType RoomMessageEvent::mimeType() const @@ -223,6 +239,16 @@ bool RoomMessageEvent::hasThumbnail() const return content() && content()->thumbnailInfo(); } +QString RoomMessageEvent::replacedEvent() const +{ + if (!content() || !hasTextContent()) + return {}; + + const auto& rel = static_cast<const TextContent*>(content())->relatesTo; + return !rel.omitted() && rel->type == RelatesTo::ReplacementTypeId() + ? rel->eventId : QString(); +} + QString rawMsgTypeForMimeType(const QMimeType& mimeType) { auto name = mimeType.name(); @@ -251,41 +277,69 @@ TextContent::TextContent(const QString& text, const QString& contentType, mimeType = QMimeDatabase().mimeTypeForName("text/html"); } +namespace QMatrixClient +{ +Omittable<RelatesTo> relationFromJson(const QJsonValue& jv) +{ + const auto jo = jv.toObject(); + if (jo.isEmpty()) + return none; + const auto replyJson = jo.value(RelatesTo::ReplyTypeId()).toObject(); + if (!replyJson.isEmpty()) + return replyTo(fromJson<QString>(replyJson[EventIdKeyL])); + + return RelatesTo { jo.value("rel_type"_ls).toString(), + jo.value(EventIdKeyL).toString() }; +} +} + TextContent::TextContent(const QJsonObject& json) + : relatesTo(relationFromJson(json[RelatesToKeyL])) { QMimeDatabase db; static const auto PlainTextMimeType = db.mimeTypeForName("text/plain"); static const auto HtmlMimeType = db.mimeTypeForName("text/html"); + const auto actualJson = + relatesTo.omitted() || relatesTo->type != RelatesTo::ReplacementTypeId() + ? json : json.value("m.new_content"_ls).toObject(); // Special-casing the custom matrix.org's (actually, Riot's) way // of sending HTML messages. - if (json["format"_ls].toString() == HtmlContentTypeId) + if (actualJson["format"_ls].toString() == HtmlContentTypeId) { mimeType = HtmlMimeType; - body = json[FormattedBodyKey].toString(); + body = actualJson[FormattedBodyKeyL].toString(); } else { // Falling back to plain text, as there's no standard way to describe // rich text in messages. mimeType = PlainTextMimeType; - body = json[BodyKey].toString(); + body = actualJson[BodyKeyL].toString(); } - const auto replyJson = json[RelatesToKey].toObject() - .value(RelatesTo::ReplyTypeId()).toObject(); - if (!replyJson.isEmpty()) - relatesTo = replyTo(fromJson<QString>(replyJson[EventIdKeyL])); } void TextContent::fillJson(QJsonObject* json) const { + static const auto FormatKey = QStringLiteral("format"); + static const auto RichBodyKey = QStringLiteral("formatted_body"); + Q_ASSERT(json); if (mimeType.inherits("text/html")) { - json->insert(QStringLiteral("format"), HtmlContentTypeId); - json->insert(QStringLiteral("formatted_body"), body); + json->insert(FormatKey, HtmlContentTypeId); + json->insert(RichBodyKey, body); } - if (!relatesTo.omitted()) + if (!relatesTo.omitted()) { json->insert(QStringLiteral("m.relates_to"), - QJsonObject { { relatesTo->type, relatesTo->eventId } }); + QJsonObject { { relatesTo->type, relatesTo->eventId } }); + if (relatesTo->type == RelatesTo::ReplacementTypeId()) { + QJsonObject newContentJson; + if (mimeType.inherits("text/html")) { + json->insert(FormatKey, HtmlContentTypeId); + json->insert(RichBodyKey, body); + } + json->insert(QStringLiteral("m.new_content"), newContentJson); + } + } } LocationContent::LocationContent(const QString& geoUri, diff --git a/lib/events/roommessageevent.h b/lib/events/roommessageevent.h index c2e075eb..7320e4ea 100644 --- a/lib/events/roommessageevent.h +++ b/lib/events/roommessageevent.h @@ -72,6 +72,7 @@ namespace QMatrixClient bool hasTextContent() const; bool hasFileContent() const; bool hasThumbnail() const; + QString replacedEvent() const; static QString rawMsgTypeForUrl(const QUrl& url); static QString rawMsgTypeForFile(const QFileInfo& fi); @@ -79,6 +80,7 @@ namespace QMatrixClient private: QScopedPointer<EventContent::TypedBase> _content; + // FIXME: should it really be static? static QJsonObject assembleContentJson(const QString& plainBody, const QString& jsonMsgType, EventContent::TypedBase* content); @@ -95,6 +97,7 @@ namespace QMatrixClient struct RelatesTo { static constexpr const char* ReplyTypeId() { return "m.in_reply_to"; } + static constexpr const char* ReplacementTypeId() { return "m.replace"; } QString type; // The only supported relation so far QString eventId; }; diff --git a/lib/jobs/basejob.cpp b/lib/jobs/basejob.cpp index 0d9b9f10..0e6a8403 100644 --- a/lib/jobs/basejob.cpp +++ b/lib/jobs/basejob.cpp @@ -104,6 +104,7 @@ BaseJob::BaseJob(HttpVerb verb, const QString& name, const QString& endpoint, BaseJob::~BaseJob() { stop(); + d->retryTimer.stop(); // See #398 qCDebug(d->logCat) << this << "destroyed"; } @@ -197,8 +198,9 @@ void BaseJob::Private::sendRequest(bool inBackground) { makeRequestUrl(connection->baseUrl(), apiEndpoint, requestQuery) }; if (!requestHeaders.contains("Content-Type")) req.setHeader(QNetworkRequest::ContentTypeHeader, "application/json"); - req.setRawHeader("Authorization", - QByteArray("Bearer ") + connection->accessToken()); + if (needsToken) + req.setRawHeader("Authorization", + QByteArray("Bearer ") + connection->accessToken()); req.setAttribute(QNetworkRequest::BackgroundRequestAttribute, inBackground); #if (QT_VERSION >= QT_VERSION_CHECK(5, 6, 0)) req.setAttribute(QNetworkRequest::FollowRedirectsAttribute, true); @@ -209,6 +211,7 @@ void BaseJob::Private::sendRequest(bool inBackground) // some sources claim that there are issues with QT 5.8 req.setAttribute(QNetworkRequest::HTTP2AllowedAttribute, true); #endif + Q_ASSERT(req.url().isValid()); for (auto it = requestHeaders.cbegin(); it != requestHeaders.cend(); ++it) req.setRawHeader(it.key(), it.value()); switch( verb ) @@ -239,16 +242,23 @@ void BaseJob::beforeAbandon(QNetworkReply*) void BaseJob::start(const ConnectionData* connData, bool inBackground) { - d->connection = connData; - d->retryTimer.setSingleShot(true); - connect (&d->retryTimer, &QTimer::timeout, - this, [this,inBackground] { sendRequest(inBackground); }); - - beforeStart(connData); - if (status().good()) - sendRequest(inBackground); - if (status().good()) - afterStart(connData, d->reply.data()); + if (connData && connData->baseUrl().isValid()) { + d->connection = connData; + d->retryTimer.setSingleShot(true); + connect(&d->retryTimer, &QTimer::timeout, this, + [this, inBackground] { sendRequest(inBackground); }); + + beforeStart(connData); + if (status().good()) + sendRequest(inBackground); + if (status().good()) + afterStart(connData, d->reply.data()); + } else { + qCCritical(d->logCat) + << "Developers, ensure the Connection is valid before using it"; + Q_ASSERT(false); + setStatus(IncorrectRequestError, tr("Invalid server connection")); + } if (!status().good()) QTimer::singleShot(0, this, &BaseJob::finishJob); } @@ -333,7 +343,14 @@ void BaseJob::gotReply() d->status.message = tr("Requested room version: %1") .arg(json.value("room_version").toString()); - } else if (!json.isEmpty()) // Not localisable on the client side + } + else if (errCode == "M_CANNOT_LEAVE_SERVER_NOTICE_ROOM") + setStatus(IncorrectRequestError, + tr("It's not allowed to leave a server notices room")); + else if (errCode == "M_USER_DEACTIVATED") + setStatus(ContentAccessError, + tr("The user has been deactivated")); + else if (!json.isEmpty()) // Not localisable on the client side setStatus(d->status.code, json.value("error"_ls).toString()); } } @@ -633,6 +650,8 @@ void BaseJob::setStatus(int code, QString message) void BaseJob::abandon() { beforeAbandon(d->reply ? d->reply.data() : nullptr); + d->timer.stop(); + d->retryTimer.stop(); // In case abandon() was called between retries setStatus(Abandoned); if (d->reply) d->reply->disconnect(this); diff --git a/lib/room.cpp b/lib/room.cpp index 9e7ff8d2..3cabe948 100644 --- a/lib/room.cpp +++ b/lib/room.cpp @@ -37,6 +37,7 @@ #include "events/roommemberevent.h" #include "events/typingevent.h" #include "events/receiptevent.h" +#include "events/reactionevent.h" #include "events/callinviteevent.h" #include "events/callcandidatesevent.h" #include "events/callanswerevent.h" @@ -98,6 +99,10 @@ class Room::Private Timeline timeline; PendingEvents unsyncedEvents; QHash<QString, TimelineItem::index_t> eventsIndex; + // A map from evtId to a map of relation type to a vector of event + // pointers. Not using QMultiHash, because we want to quickly return + // a number of relations for a given event without enumerating them. + QHash<QPair<QString, QString>, RelatedEvents> relations; QString displayname; Avatar avatar; int highlightCount = 0; @@ -183,6 +188,7 @@ class Room::Private rev_iter_t timelineBase() const { return q->findInTimeline(-1); } void getPreviousContent(int limit = 10); + bool allHistoryLoaded() const; template <typename EventT> const EventT* getCurrentState(const QString& stateKey = {}) const @@ -292,9 +298,18 @@ class Room::Private * * Tries to find an event in the timeline and redact it; deletes the * redaction event whether the redacted event was found or not. + * \return true if the event has been found and redacted; false otherwise */ bool processRedaction(const RedactionEvent& redaction); + /*! Apply a new revision of the event to the timeline + * + * Tries to find an event in the timeline and replace it with the new + * content passed in \p newMessage. + * \return true if the event has been found and replaced; false otherwise + */ + bool processReplacement(const RoomMessageEvent& newMessage); + void setTags(TagsMap newTags); QJsonObject toJson() const; @@ -370,6 +385,11 @@ const Room::PendingEvents& Room::pendingEvents() const return d->unsyncedEvents; } +bool Room::Private::allHistoryLoaded() const +{ + return !timeline.empty() && is<RoomCreateEvent>(*timeline.front()); +} + QString Room::name() const { return d->getCurrentState<RoomNameEvent>()->name(); @@ -377,7 +397,17 @@ QString Room::name() const QStringList Room::aliases() const { - return d->getCurrentState<RoomAliasesEvent>()->aliases(); + const auto* evt = d->getCurrentState<RoomCanonicalAliasEvent>(); + auto aliases = fromJson<QStringList>(evt->contentJson()["alt_aliases"]); + if (!evt->alias().isEmpty()) + aliases << evt->alias(); + return aliases; +} + +QStringList Room::altAliases() const +{ + const auto* evt = d->getCurrentState<RoomCanonicalAliasEvent>(); + return fromJson<QStringList>(evt->contentJson()["alt_aliases"]); } QString Room::canonicalAlias() const @@ -493,7 +523,9 @@ void Room::Private::updateUnreadCount(rev_iter_t from, rev_iter_t to) // that has just arrived. In this case we should recalculate // unreadMessages and might need to promote the read marker further // over local-origin messages. - const auto readMarker = q->readMarker(); + auto readMarker = q->readMarker(); + if (readMarker == timeline.crend() && allHistoryLoaded()) + --readMarker; // Read marker not found in the timeline, initialise it if (readMarker >= from && readMarker < to) { promoteReadMarker(q->localUser(), readMarker, true); @@ -680,10 +712,10 @@ Room::rev_iter_t Room::findInTimeline(const QString& evtId) const if (!d->timeline.empty() && d->eventsIndex.contains(evtId)) { auto it = findInTimeline(d->eventsIndex.value(evtId)); - Q_ASSERT((*it)->id() == evtId); + Q_ASSERT(it != historyEdge() && (*it)->id() == evtId); return it; } - return timelineEdge(); + return historyEdge(); } Room::PendingEvents::iterator Room::findPendingEvent(const QString& txnId) @@ -699,6 +731,18 @@ Room::findPendingEvent(const QString& txnId) const [txnId] (const auto& item) { return item->transactionId() == txnId; }); } +const Room::RelatedEvents Room::relatedEvents(const QString& evtId, + const char* relType) const +{ + return d->relations.value({ evtId, relType }); +} + +const Room::RelatedEvents Room::relatedEvents(const RoomEvent& evt, + const char* relType) const +{ + return relatedEvents(evt.id(), relType); +} + void Room::Private::getAllMembers() { // If already loaded or already loading, there's nothing to do here. @@ -961,6 +1005,11 @@ bool Room::isLowPriority() const return d->tags.contains(LowPriorityTag); } +bool Room::isServerNoticeRoom() const +{ + return d->tags.contains(ServerNoticeTag); +} + bool Room::isDirectChat() const { return connection()->isDirectChat(id()); @@ -971,6 +1020,11 @@ QList<User*> Room::directChatUsers() const return connection()->directChatUsers(this); } +QString safeFileName(QString rawName) +{ + return rawName.replace(QRegularExpression("[/\\<>|\"*?:]"), "_"); +} + const RoomMessageEvent* Room::Private::getEventWithFile(const QString& eventId) const { @@ -987,24 +1041,26 @@ Room::Private::getEventWithFile(const QString& eventId) const QString Room::Private::fileNameToDownload(const RoomMessageEvent* event) const { - Q_ASSERT(event->hasFileContent()); + Q_ASSERT(event && event->hasFileContent()); const auto* fileInfo = event->content()->fileInfo(); QString fileName; if (!fileInfo->originalName.isEmpty()) - { - fileName = QFileInfo(fileInfo->originalName).fileName(); - } - else if (!event->plainBody().isEmpty()) - { + fileName = QFileInfo(safeFileName(fileInfo->originalName)).fileName(); + else { // Having no better options, assume that the body has // the original file URL or at least the file name. QUrl u { event->plainBody() }; if (u.isValid()) - fileName = QFileInfo(u.path()).fileName(); + { + qDebug(MAIN) << event->id() + << "has no file name supplied but the event body " + "looks like a URL - using the file name from it"; + fileName = u.fileName(); + } } - // Check the file name for sanity - if (fileName.isEmpty() || !QTemporaryFile(fileName).open()) - return "file." % fileInfo->mimeType.preferredSuffix(); + if (fileName.isEmpty()) + return safeFileName(fileInfo->mediaId()).replace('.', '-') % '.' + % fileInfo->mimeType.preferredSuffix(); if (QSysInfo::productType() == "windows") { @@ -1331,7 +1387,7 @@ void Room::updateData(SyncRoomData&& data, bool fromCache) if (roomChanges&TopicChange) emit topicChanged(); - if (roomChanges&NameChange) + if (roomChanges&(NameChange|CanonicalAliasChange)) emit namesChanged(this); if (roomChanges&MembersChange) @@ -1347,17 +1403,20 @@ void Room::updateData(SyncRoomData&& data, bool fromCache) { qCDebug(MAIN) << "Setting unread_count to" << data.unreadCount; d->unreadMessages = data.unreadCount; + roomChanges |= Change::UnreadNotifsChange; emit unreadMessagesChanged(this); } if( data.highlightCount != d->highlightCount ) { d->highlightCount = data.highlightCount; + roomChanges |= Change::UnreadNotifsChange; emit highlightCountChanged(this); } if( data.notificationCount != d->notificationCount ) { d->notificationCount = data.notificationCount; + roomChanges |= Change::UnreadNotifsChange; emit notificationCountChanged(this); } if (roomChanges != Change::NoChange) @@ -1527,6 +1586,11 @@ QString Room::postHtmlText(const QString& plainText, const QString& html) return postHtmlMessage(plainText, html); } +QString Room::postReaction(const QString &eventId, const QString &key) +{ + return d->sendEvent<ReactionEvent>(EventRelation::annotate(eventId, key)); +} + QString Room::postFile(const QString& plainText, const QUrl& localPath, bool asGenericFile) { @@ -1608,12 +1672,18 @@ void Room::setName(const QString& newName) void Room::setCanonicalAlias(const QString& newAlias) { - d->requestSetState(RoomCanonicalAliasEvent(newAlias)); + connection()->callApi<SetRoomStateJob>( + id(), RoomCanonicalAliasEvent::matrixTypeId(), + QJsonObject { { "alias", newAlias }, + { "alt_aliases", QMatrixClient::toJson(altAliases()) } }); } void Room::setAliases(const QStringList& aliases) { - d->requestSetState(RoomAliasesEvent(aliases)); + connection()->callApi<SetRoomStateJob>( + id(), RoomCanonicalAliasEvent::matrixTypeId(), + QJsonObject { { "alias", canonicalAlias() }, + { "alt_aliases", QMatrixClient::toJson(aliases) } }); } void Room::setTopic(const QString& newTopic) @@ -1813,17 +1883,20 @@ void Room::downloadFile(const QString& eventId, const QUrl& localFilename) if (filePath.isEmpty()) { // Build our own file path, starting with temp directory and eventId. - filePath = eventId; - filePath = QDir::tempPath() % '/' % - filePath.replace(QRegularExpression("[/\\<>|\"*?:]"), "_") % - '#' % d->fileNameToDownload(event); + filePath = + fileInfo->url.path().mid(1) % '_' % d->fileNameToDownload(event); + + if (filePath.size() > 200) // If too long, elide in the middle + filePath.replace(128, filePath.size() - 192, "---"); + + filePath = QDir::tempPath() % '/' % filePath; + qDebug(MAIN) << "File path:" << filePath; } auto job = connection()->downloadFile(fileUrl, filePath); if (isJobRunning(job)) { - // If there was a previous transfer (completed or failed), remove it. - d->fileTransfers.remove(eventId); - d->fileTransfers.insert(eventId, { job, job->targetFileName() }); + // If there was a previous transfer (completed or failed), overwrite it. + d->fileTransfers[eventId] = { job, job->targetFileName() }; connect(job, &BaseJob::downloadProgress, this, [this,eventId] (qint64 received, qint64 total) { d->fileTransfers[eventId].update(received, total); @@ -1893,7 +1966,6 @@ RoomEventPtr makeRedacted(const RoomEvent& target, static const QStringList keepKeys { EventIdKey, TypeKey, QStringLiteral("room_id"), QStringLiteral("sender"), QStringLiteral("state_key"), - QStringLiteral("prev_content"), ContentKey, QStringLiteral("hashes"), QStringLiteral("signatures"), QStringLiteral("depth"), QStringLiteral("prev_events"), QStringLiteral("prev_state"), QStringLiteral("auth_events"), @@ -1910,7 +1982,6 @@ RoomEventPtr makeRedacted(const RoomEvent& target, // QStringLiteral("events_default"), QStringLiteral("kick"), // QStringLiteral("redact"), QStringLiteral("state_default"), // QStringLiteral("users"), QStringLiteral("users_default") } } - , { RoomAliasesEvent::typeId(), { QStringLiteral("aliases") } } // , { RoomHistoryVisibility::typeId(), // { QStringLiteral("history_visibility") } } }; @@ -1983,11 +2054,65 @@ bool Room::Private::processRedaction(const RedactionEvent& redaction) updateDisplayname(); } } + if (const auto* reaction = eventCast<ReactionEvent>(oldEvent)) { + const auto& targetEvtId = reaction->relation().eventId; + const auto lookupKey = qMakePair(targetEvtId, + EventRelation::Annotation()); + if (relations.contains(lookupKey)) { + relations[lookupKey].removeOne(reaction); + } + } q->onRedaction(*oldEvent, *ti); emit q->replacedEvent(ti.event(), rawPtr(oldEvent)); return true; } +/** Make a replaced event + * + * Takes \p target and returns a copy of it with content taken from + * \p replacement. Disposal of the original event after that is on the caller. + */ +RoomEventPtr makeReplaced(const RoomEvent& target, + const RoomMessageEvent& replacement) +{ + auto originalJson = target.originalJsonObject(); + originalJson[ContentKeyL] = replacement.contentJson().value("m.new_content"_ls); + + auto unsignedData = originalJson.take(UnsignedKeyL).toObject(); + auto relations = unsignedData.take("m.relations"_ls).toObject(); + relations["m.replace"_ls] = replacement.id(); + unsignedData.insert(QStringLiteral("m.relations"), relations); + originalJson.insert(UnsignedKey, unsignedData); + + return loadEvent<RoomEvent>(originalJson); +} + +bool Room::Private::processReplacement(const RoomMessageEvent& newEvent) +{ + // Can't use findInTimeline because it returns a const iterator, and + // we need to change the underlying TimelineItem. + const auto pIdx = eventsIndex.find(newEvent.replacedEvent()); + if (pIdx == eventsIndex.end()) + return false; + + Q_ASSERT(q->isValidIndex(*pIdx)); + + auto& ti = timeline[Timeline::size_type(*pIdx - q->minTimelineIndex())]; + if (ti->replacedBy() == newEvent.id()) + { + qCDebug(MAIN) << "Event" << ti->id() << "is already replaced with" + << newEvent.id(); + return true; + } + + // Make a new event from the redacted JSON and put it in the timeline + // instead of the redacted one. oldEvent will be deleted on return. + auto oldEvent = ti.replaceEvent(makeReplaced(*ti, newEvent)); + qCDebug(MAIN) << "Replaced" << oldEvent->id() << "with" << newEvent.id(); + emit q->replacedEvent(ti.event(), rawPtr(oldEvent)); + return true; +} + Connection* Room::connection() const { Q_ASSERT(d->connection); @@ -1999,10 +2124,16 @@ User* Room::localUser() const return connection()->user(); } -inline bool isRedaction(const RoomEventPtr& ep) +/// Whether the event is a redaction or a replacement +inline bool isEditing(const RoomEventPtr& ep) { Q_ASSERT(ep); - return is<RedactionEvent>(*ep); + if (is<RedactionEvent>(*ep)) + return true; + if (auto* msgEvent = eventCast<RoomMessageEvent>(ep)) + return msgEvent->replacedEvent().isEmpty(); + + return false; } Room::Changes Room::Private::addNewMessageEvents(RoomEvents&& events) @@ -2011,28 +2142,52 @@ Room::Changes Room::Private::addNewMessageEvents(RoomEvents&& events) if (events.empty()) return Change::NoChange; - // Pre-process redactions so that events that get redacted in the same - // batch landed in the timeline already redacted. - // NB: We have to store redaction events to the timeline too - see #220. - auto redactionIt = std::find_if(events.begin(), events.end(), isRedaction); - for(const auto& eptr: RoomEventsRange(redactionIt, events.end())) - if (auto* r = eventCast<RedactionEvent>(eptr)) - { - // Try to find the target in the timeline, then in the batch. - if (processRedaction(*r)) - continue; - auto targetIt = std::find_if(events.begin(), redactionIt, - [id=r->redactedEvent()] (const RoomEventPtr& ep) { - return ep->id() == id; - }); - if (targetIt != redactionIt) - *targetIt = makeRedacted(**targetIt, *r); - else - qCDebug(MAIN) << "Redaction" << r->id() - << "ignored: target event" << r->redactedEvent() - << "is not found"; - // If the target event comes later, it comes already redacted. + { + // Pre-process redactions and edits so that events that get + // redacted/replaced in the same batch landed in the timeline already + // treated. + // NB: We have to store redacting/replacing events to the timeline too - + // see #220. + auto it = std::find_if(events.begin(), events.end(), isEditing); + for (const auto& eptr: RoomEventsRange(it, events.end())) { + if (auto* r = eventCast<RedactionEvent>(eptr)) { + // Try to find the target in the timeline, then in the batch. + if (processRedaction(*r)) + continue; + auto targetIt = std::find_if(events.begin(), it, + [id = r->redactedEvent()]( + const RoomEventPtr& ep) { + return ep->id() == id; + }); + if (targetIt != it) + *targetIt = makeRedacted(**targetIt, *r); + else + qCDebug(MAIN) + << "Redaction" << r->id() << "ignored: target event" + << r->redactedEvent() << "is not found"; + // If the target event comes later, it comes already redacted. + } + if (auto* msg = eventCast<RoomMessageEvent>(eptr)) { + if (!msg->replacedEvent().isEmpty()) { + if (processReplacement(*msg)) + continue; + auto targetIt = std::find_if(events.begin(), it, + [id = msg->replacedEvent()]( + const RoomEventPtr& ep) { + return ep->id() == id; + }); + if (targetIt != it) + *targetIt = makeReplaced(**targetIt, *msg); + else // FIXME: don't ignore, just show it wherever it arrived + qCDebug(MAIN) << "Replacing event" << msg->id() + << "ignored: replaced event" + << msg->replacedEvent() << "is not found"; + // Same as with redactions above, the replaced event coming + // later will come already with the new content. + } + } } + } // State changes arrive as a part of timeline; the current room state gets // updated before merging events to the timeline because that's what @@ -2098,6 +2253,14 @@ Room::Changes Room::Private::addNewMessageEvents(RoomEvents&& events) if (totalInserted > 0) { + for (auto it = from; it != timeline.cend(); ++it) { + if (const auto* reaction = it->viewAs<ReactionEvent>()) { + const auto& relation = reaction->relation(); + relations[{ relation.eventId, relation.type }] << reaction; + emit q->updatedEvent(relation.eventId); + } + } + qCDebug(MAIN) << "Room" << q->objectName() << "received" << totalInserted << "new events; the last event is now" << timeline.back(); @@ -2156,6 +2319,13 @@ void Room::Private::addHistoricalMessageEvents(RoomEvents&& events) q->onAddHistoricalTimelineEvents(from); emit q->addedMessages(timeline.front().index(), from->index()); + for (auto it = from; it != timeline.crend(); ++it) { + if (const auto* reaction = it->viewAs<ReactionEvent>()) { + const auto& relation = reaction->relation(); + relations[{ relation.eventId, relation.type }] << reaction; + emit q->updatedEvent(relation.eventId); + } + } if (from <= q->readMarker()) updateUnreadCount(from, timeline.crend()); @@ -2183,15 +2353,31 @@ Room::Changes Room::processStateEvent(const RoomEvent& e) , [] (const RoomNameEvent&) { return NameChange; } - , [this,oldStateEvent] (const RoomAliasesEvent& ae) { - const auto previousAliases = oldStateEvent - ? static_cast<const RoomAliasesEvent*>(oldStateEvent)->aliases() - : QStringList(); - connection()->updateRoomAliases(id(), previousAliases, ae.aliases()); - return OtherChange; + , [] (const RoomAliasesEvent&) { + // This event has been removed by MSC-2432 + return NoChange; } - , [this] (const RoomCanonicalAliasEvent& evt) { + , [this, oldStateEvent] (const RoomCanonicalAliasEvent& evt) { setObjectName(evt.alias().isEmpty() ? d->id : evt.alias()); + + auto prevAliases = oldStateEvent ? fromJson<QStringList>( + oldStateEvent->contentJson()["alt_aliases"]) + : QStringList(); + if (oldStateEvent) { + const auto prevCanonicalAlias = + static_cast<const RoomCanonicalAliasEvent*>(oldStateEvent) + ->alias(); + if (!prevCanonicalAlias.isEmpty()) + prevAliases.push_back(prevCanonicalAlias); + } + + auto newAliases = + fromJson<QStringList>(evt.contentJson()["alt_aliases"]); + if (!evt.alias().isEmpty()) + newAliases.push_back(evt.alias()); + + connection()->updateRoomAliases(id(), prevAliases, newAliases); + return CanonicalAliasChange; } , [] (const RoomTopicEvent&) { @@ -117,6 +117,7 @@ namespace QMatrixClient public: using Timeline = std::deque<TimelineItem>; using PendingEvents = std::vector<PendingEventItem>; + using RelatedEvents = QVector<const RoomEvent*>; using rev_iter_t = Timeline::const_reverse_iterator; using timeline_iter_t = Timeline::const_iterator; @@ -154,6 +155,7 @@ namespace QMatrixClient QString successorId() const; QString name() const; QStringList aliases() const; + QStringList altAliases() const; QString canonicalAlias() const; QString displayName() const; QString topic() const; @@ -247,6 +249,11 @@ namespace QMatrixClient PendingEvents::iterator findPendingEvent(const QString & txnId); PendingEvents::const_iterator findPendingEvent(const QString & txnId) const; + const RelatedEvents relatedEvents(const QString& evtId, + const char* relType) const; + const RelatedEvents relatedEvents(const RoomEvent& evt, + const char* relType) const; + bool displayed() const; /// Mark the room as currently displayed to the user /** @@ -347,6 +354,8 @@ namespace QMatrixClient bool isFavourite() const; /// Check whether the list of tags has m.lowpriority bool isLowPriority() const; + /// Check whether this room is for server notices (MSC1452) + bool isServerNoticeRoom() const; /// Check whether this room is a direct chat Q_INVOKABLE bool isDirectChat() const; @@ -410,6 +419,9 @@ namespace QMatrixClient const QString& html, MessageEventType type = MessageEventType::Text); QString postHtmlText(const QString& plainText, const QString& html); + /** Send a reaction on a given event with a given key */ + QString postReaction(const QString& eventId, const QString& key); + QString postFile(const QString& plainText, const QUrl& localPath, bool asGenericFile = false); /** Post a pre-created room message event @@ -556,6 +568,7 @@ namespace QMatrixClient void tagsAboutToChange(); void tagsChanged(); + void updatedEvent(QString eventId); void replacedEvent(const RoomEvent* newEvent, const RoomEvent* oldEvent); diff --git a/lib/ssosession.cpp b/lib/ssosession.cpp new file mode 100644 index 00000000..6ea4a3f5 --- /dev/null +++ b/lib/ssosession.cpp @@ -0,0 +1,127 @@ +#include "ssosession.h" + +#include "connection.h" +#include "csapi/sso_login_redirect.h" + +#include <QtNetwork/QTcpServer> +#include <QtNetwork/QTcpSocket> +#include <QtCore/QCoreApplication> +#include <QtCore/QStringBuilder> + +using namespace QMatrixClient; + +struct SsoSession::Private { + Private(SsoSession* q, const QString& initialDeviceName = {}, + const QString& deviceId = {}, Connection* connection = nullptr) + : initialDeviceName(initialDeviceName) + , deviceId(deviceId) + , connection(connection) + { + auto* server = new QTcpServer(q); + server->listen(); + // The "/returnToApplication" part is just a hint for the end-user, + // the callback will work without it equally well. + callbackUrl = QStringLiteral("http://localhost:%1/returnToApplication") + .arg(server->serverPort()); + ssoUrl = connection->getUrlForApi<RedirectToSSOJob>(callbackUrl); + + QObject::connect(server, &QTcpServer::newConnection, q, [this, server] { + qCDebug(MAIN) << "SSO callback initiated"; + socket = server->nextPendingConnection(); + server->close(); + QObject::connect(socket, &QTcpSocket::readyRead, socket, [this] { + requestData.append(socket->readAll()); + if (!socket->atEnd() && !requestData.endsWith("\r\n\r\n")) { + qDebug(MAIN) << "Incomplete request, waiting for more data"; + return; + } + processCallback(); + }); + QObject::connect(socket, &QTcpSocket::disconnected, socket, + [this] { socket->deleteLater(); }); + }); + } + void processCallback(); + void sendHttpResponse(const QByteArray& code, const QByteArray& msg); + void onError(const QByteArray& code, const QString& errorMsg); + + QString initialDeviceName; + QString deviceId; + Connection* connection; + QString callbackUrl {}; + QUrl ssoUrl {}; + QTcpSocket* socket = nullptr; + QByteArray requestData {}; +}; + +SsoSession::SsoSession(Connection* connection, const QString& initialDeviceName, + const QString& deviceId) + : QObject(connection) + , d(std::make_unique<Private>(this, initialDeviceName, deviceId, connection)) +{ + qCDebug(MAIN) << "SSO session constructed"; +} + +SsoSession::~SsoSession() +{ + qCDebug(MAIN) << "SSO session deconstructed"; +} + +QUrl SsoSession::ssoUrl() const { return d->ssoUrl; } + +QUrl SsoSession::callbackUrl() const { return d->callbackUrl; } + +void SsoSession::Private::processCallback() +{ + // https://matrix.org/docs/guides/sso-for-client-developers + // Inspired by Clementine's src/internet/core/localredirectserver.cpp + // (see at https://github.com/clementine-player/Clementine/) + const auto& requestParts = requestData.split(' '); + if (requestParts.size() < 2 || requestParts[1].isEmpty()) { + onError("400 Bad Request", tr("No login token in SSO callback")); + return; + } + const auto& QueryItemName = QStringLiteral("loginToken"); + QUrlQuery query { QUrl(requestParts[1]).query() }; + if (!query.hasQueryItem(QueryItemName)) { + onError("400 Bad Request", tr("Malformed single sign-on callback")); + } + qCDebug(MAIN) << "Found the token in SSO callback, logging in"; + connection->loginWithToken(query.queryItemValue(QueryItemName).toLatin1(), + initialDeviceName, deviceId); + connect(connection, &Connection::connected, socket, [this] { + const QString msg = + "The application '" % QCoreApplication::applicationName() + % "' has successfully logged in as a user " % connection->userId() + % " with device id " % connection->deviceId() + % ". This window can be closed. Thank you.\r\n"; + sendHttpResponse("200 OK", msg.toHtmlEscaped().toUtf8()); + socket->disconnectFromHost(); + }); + connect(connection, &Connection::loginError, socket, [this] { + onError("401 Unauthorised", tr("Login failed")); + socket->disconnectFromHost(); + }); +} + +void SsoSession::Private::sendHttpResponse(const QByteArray& code, + const QByteArray& msg) +{ + socket->write("HTTP/1.0 "); + socket->write(code); + socket->write("\r\n" + "Content-type: text/html;charset=UTF-8\r\n" + "\r\n\r\n"); + socket->write(msg); + socket->write("\r\n"); +} + +void SsoSession::Private::onError(const QByteArray& code, + const QString& errorMsg) +{ + qCWarning(MAIN).nospace() << errorMsg; + sendHttpResponse(code, "<h3>" + errorMsg.toUtf8() + "</h3>"); + // [kitsune] Yeah, I know, dirty. Maybe the "right" way would be to have + // an intermediate signal but that seems just a fight for purity. + emit connection->loginError(errorMsg, requestData); +} diff --git a/lib/ssosession.h b/lib/ssosession.h new file mode 100644 index 00000000..af20c075 --- /dev/null +++ b/lib/ssosession.h @@ -0,0 +1,44 @@ +#pragma once + +#include <QtCore/QUrl> +#include <QtCore/QObject> + +#include <memory> + +class QTcpServer; +class QTcpSocket; + +namespace QMatrixClient { +class Connection; + +/*! Single sign-on (SSO) session encapsulation + * + * This class is responsible for setting up of a new SSO session, providing + * a URL to be opened (usually, in a web browser) and handling the callback + * response after completing the single sign-on, all the way to actually + * logging the user in. It does NOT open and render the SSO URL, it only does + * the necessary backstage work. + * + * Clients only need to open the URL; the rest is done for them. + * Client code can look something like: + * \code + * QDesktopServices::openUrl( + * connection->prepareForSso(initialDeviceName)->ssoUrl()); + * \endcode + */ +class SsoSession : public QObject { + Q_OBJECT + Q_PROPERTY(QUrl ssoUrl READ ssoUrl CONSTANT) + Q_PROPERTY(QUrl callbackUrl READ callbackUrl CONSTANT) +public: + SsoSession(Connection* connection, const QString& initialDeviceName, + const QString& deviceId = {}); + ~SsoSession() override; + QUrl ssoUrl() const; + QUrl callbackUrl() const; + +private: + class Private; + std::unique_ptr<Private> d; +}; +} // namespace QMatrixClient diff --git a/lib/user.cpp b/lib/user.cpp index 17db5760..c51354a0 100644 --- a/lib/user.cpp +++ b/lib/user.cpp @@ -118,8 +118,7 @@ void User::Private::setNameForRoom(const Room* r, QString newName, et.start(); } - const auto& roomMap = connection->roomMap(); - for (auto* r1: roomMap) + for (auto* r1: connection->allRooms()) if (nameForRoom(r1) == mostUsedName) otherNames.insert(mostUsedName, r1); @@ -165,22 +164,28 @@ void User::Private::setAvatarForRoom(const Room* r, const QUrl& newUrl, if (newUrl != mostUsedAvatar.url()) { // Check if the new avatar is about to become most used. - if (avatarsToRooms.count(newUrl) >= totalRooms - avatarsToRooms.size()) - { + const auto newUrlUsage = avatarsToRooms.count(newUrl); + if (newUrlUsage >= 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(); + if (totalRooms > MIN_JOINED_ROOMS_TO_LOG) { + qCInfo(MAIN) << "Switching the most used avatar of user" << userId + << "from" << mostUsedAvatar.url().toDisplayString() + << "to" << newUrl.toDisplayString(); et.start(); } avatarsToRooms.remove(newUrl); auto nextMostUsedIt = otherAvatar(newUrl); - Q_ASSERT(nextMostUsedIt != otherAvatars.end()); + if (nextMostUsedIt == otherAvatars.end()) { + qCCritical(MAIN) + << userId << "doesn't have" << newUrl.toDisplayString() + << "in otherAvatars though it seems to be used in" + << newUrlUsage << "rooms"; + Q_ASSERT(false); + otherAvatars.emplace_back(makeAvatar(newUrl)); + nextMostUsedIt = otherAvatars.end() - 1; + } std::swap(mostUsedAvatar, *nextMostUsedIt); - const auto& roomMap = connection->roomMap(); - for (const auto* r1: roomMap) + for (const auto* r1: connection->allRooms()) if (avatarUrlForRoom(r1) == nextMostUsedIt->url()) avatarsToRooms.insert(nextMostUsedIt->url(), r1); diff --git a/lib/util.cpp b/lib/util.cpp index 17674b84..81862ab6 100644 --- a/lib/util.cpp +++ b/lib/util.cpp @@ -50,7 +50,7 @@ static void linkifyUrls(QString& htmlEscapedText) // An interim liberal implementation of // https://matrix.org/docs/spec/appendices.html#identifier-grammar static const QRegularExpression MxIdRegExp(QStringLiteral( - R"((^|[^<>/])([!#@][-a-z0-9_=/.]{1,252}:[-.a-z0-9]+))" + R"((^|[^<>/])([!#@][-a-z0-9_=#/.]{1,252}:(?:\w|\.|-)+\.\w+(?::\d{1,5})?))" ), RegExpOptions); // NOTE: htmlEscapedText is already HTML-escaped! No literal <,>,&," |