aboutsummaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/connection.cpp256
-rw-r--r--lib/connection.h92
-rw-r--r--lib/converters.h4
-rw-r--r--lib/events/accountdataevents.h1
-rw-r--r--lib/events/reactionevent.cpp44
-rw-r--r--lib/events/reactionevent.h81
-rw-r--r--lib/events/roomevent.cpp14
-rw-r--r--lib/events/roomevent.h2
-rw-r--r--lib/events/roommessageevent.cpp108
-rw-r--r--lib/events/roommessageevent.h3
-rw-r--r--lib/jobs/basejob.cpp45
-rw-r--r--lib/room.cpp298
-rw-r--r--lib/room.h13
-rw-r--r--lib/ssosession.cpp127
-rw-r--r--lib/ssosession.h44
-rw-r--r--lib/user.cpp29
-rw-r--r--lib/util.cpp2
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&) {
diff --git a/lib/room.h b/lib/room.h
index 33d1f4ea..87ff3b5d 100644
--- a/lib/room.h
+++ b/lib/room.h
@@ -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 <,>,&,"