nheko/src/timeline/TimelineModel.cpp
Nicolas Werner ef9ebe3fd3
Speedup startup by caching last message timestamp
The cache is only approximate, i.e. it doesn't skip edits and similar,
but this might be good enough? Also no migration right now.

Speeds up startup by about 5x on my system.

Half the startup time is now loading the powerlevels for each room. We
can probably lazily load those too in the future.
2022-06-15 02:13:17 +02:00

2720 lines
98 KiB
C++

// SPDX-FileCopyrightText: 2021 Nheko Contributors
// SPDX-FileCopyrightText: 2022 Nheko Contributors
//
// SPDX-License-Identifier: GPL-3.0-or-later
#include "TimelineModel.h"
#include <algorithm>
#include <thread>
#include <type_traits>
#include <QCache>
#include <QClipboard>
#include <QDesktopServices>
#include <QFileDialog>
#include <QGuiApplication>
#include <QMimeDatabase>
#include <QRegularExpression>
#include <QStandardPaths>
#include <QVariant>
#include <utility>
#include "Cache_p.h"
#include "ChatPage.h"
#include "Config.h"
#include "EventAccessors.h"
#include "Logging.h"
#include "MainWindow.h"
#include "MatrixClient.h"
#include "MemberList.h"
#include "MxcImageProvider.h"
#include "ReadReceiptsModel.h"
#include "TimelineViewManager.h"
#include "Utils.h"
#include "encryption/Olm.h"
Q_DECLARE_METATYPE(QModelIndex)
namespace std {
inline uint // clazy:exclude=qhash-namespace
qHash(const std::string &key, uint seed = 0)
{
return qHash(QByteArray::fromRawData(key.data(), (int)key.length()), seed);
}
}
namespace {
struct RoomEventType
{
template<class T>
qml_mtx_events::EventType operator()(const mtx::events::Event<T> &e)
{
return qml_mtx_events::toRoomEventType(e.type);
}
qml_mtx_events::EventType operator()(const mtx::events::Event<mtx::events::msg::Audio> &)
{
return qml_mtx_events::EventType::AudioMessage;
}
qml_mtx_events::EventType operator()(const mtx::events::Event<mtx::events::msg::Emote> &)
{
return qml_mtx_events::EventType::EmoteMessage;
}
qml_mtx_events::EventType operator()(const mtx::events::Event<mtx::events::msg::File> &)
{
return qml_mtx_events::EventType::FileMessage;
}
qml_mtx_events::EventType operator()(const mtx::events::Event<mtx::events::msg::Image> &)
{
return qml_mtx_events::EventType::ImageMessage;
}
qml_mtx_events::EventType operator()(const mtx::events::Event<mtx::events::msg::Notice> &)
{
return qml_mtx_events::EventType::NoticeMessage;
}
qml_mtx_events::EventType operator()(const mtx::events::Event<mtx::events::msg::Text> &)
{
return qml_mtx_events::EventType::TextMessage;
}
qml_mtx_events::EventType operator()(const mtx::events::Event<mtx::events::msg::Video> &)
{
return qml_mtx_events::EventType::VideoMessage;
}
qml_mtx_events::EventType
operator()(const mtx::events::Event<mtx::events::msg::KeyVerificationRequest> &)
{
return qml_mtx_events::EventType::KeyVerificationRequest;
}
qml_mtx_events::EventType
operator()(const mtx::events::Event<mtx::events::msg::KeyVerificationStart> &)
{
return qml_mtx_events::EventType::KeyVerificationStart;
}
qml_mtx_events::EventType
operator()(const mtx::events::Event<mtx::events::msg::KeyVerificationMac> &)
{
return qml_mtx_events::EventType::KeyVerificationMac;
}
qml_mtx_events::EventType
operator()(const mtx::events::Event<mtx::events::msg::KeyVerificationAccept> &)
{
return qml_mtx_events::EventType::KeyVerificationAccept;
}
qml_mtx_events::EventType
operator()(const mtx::events::Event<mtx::events::msg::KeyVerificationReady> &)
{
return qml_mtx_events::EventType::KeyVerificationReady;
}
qml_mtx_events::EventType
operator()(const mtx::events::Event<mtx::events::msg::KeyVerificationCancel> &)
{
return qml_mtx_events::EventType::KeyVerificationCancel;
}
qml_mtx_events::EventType
operator()(const mtx::events::Event<mtx::events::msg::KeyVerificationKey> &)
{
return qml_mtx_events::EventType::KeyVerificationKey;
}
qml_mtx_events::EventType
operator()(const mtx::events::Event<mtx::events::msg::KeyVerificationDone> &)
{
return qml_mtx_events::EventType::KeyVerificationDone;
}
qml_mtx_events::EventType operator()(const mtx::events::Event<mtx::events::msg::Redacted> &)
{
return qml_mtx_events::EventType::Redacted;
}
qml_mtx_events::EventType operator()(const mtx::events::Event<mtx::events::msg::CallInvite> &)
{
return qml_mtx_events::EventType::CallInvite;
}
qml_mtx_events::EventType operator()(const mtx::events::Event<mtx::events::msg::CallAnswer> &)
{
return qml_mtx_events::EventType::CallAnswer;
}
qml_mtx_events::EventType operator()(const mtx::events::Event<mtx::events::msg::CallHangUp> &)
{
return qml_mtx_events::EventType::CallHangUp;
}
qml_mtx_events::EventType
operator()(const mtx::events::Event<mtx::events::msg::CallCandidates> &)
{
return qml_mtx_events::EventType::CallCandidates;
}
// ::EventType::Type operator()(const Event<mtx::events::msg::Location> &e) { return
// ::EventType::LocationMessage; }
};
}
qml_mtx_events::EventType
qml_mtx_events::toRoomEventType(mtx::events::EventType e)
{
using mtx::events::EventType;
switch (e) {
case EventType::RoomKeyRequest:
return qml_mtx_events::EventType::KeyRequest;
case EventType::Reaction:
return qml_mtx_events::EventType::Reaction;
case EventType::RoomAliases:
return qml_mtx_events::EventType::Aliases;
case EventType::RoomAvatar:
return qml_mtx_events::EventType::Avatar;
case EventType::RoomCanonicalAlias:
return qml_mtx_events::EventType::CanonicalAlias;
case EventType::RoomCreate:
return qml_mtx_events::EventType::RoomCreate;
case EventType::RoomEncrypted:
return qml_mtx_events::EventType::Encrypted;
case EventType::RoomEncryption:
return qml_mtx_events::EventType::Encryption;
case EventType::RoomGuestAccess:
return qml_mtx_events::EventType::RoomGuestAccess;
case EventType::RoomHistoryVisibility:
return qml_mtx_events::EventType::RoomHistoryVisibility;
case EventType::RoomJoinRules:
return qml_mtx_events::EventType::RoomJoinRules;
case EventType::RoomMember:
return qml_mtx_events::EventType::Member;
case EventType::RoomMessage:
return qml_mtx_events::EventType::UnknownMessage;
case EventType::RoomName:
return qml_mtx_events::EventType::Name;
case EventType::RoomPowerLevels:
return qml_mtx_events::EventType::PowerLevels;
case EventType::RoomTopic:
return qml_mtx_events::EventType::Topic;
case EventType::RoomTombstone:
return qml_mtx_events::EventType::Tombstone;
case EventType::RoomRedaction:
return qml_mtx_events::EventType::Redaction;
case EventType::RoomPinnedEvents:
return qml_mtx_events::EventType::PinnedEvents;
case EventType::Sticker:
return qml_mtx_events::EventType::Sticker;
case EventType::Tag:
return qml_mtx_events::EventType::Tag;
case EventType::SpaceParent:
return qml_mtx_events::EventType::SpaceParent;
case EventType::SpaceChild:
return qml_mtx_events::EventType::SpaceChild;
case EventType::ImagePackInRoom:
return qml_mtx_events::ImagePackInRoom;
case EventType::ImagePackInAccountData:
return qml_mtx_events::ImagePackInAccountData;
case EventType::ImagePackRooms:
return qml_mtx_events::ImagePackRooms;
case EventType::Unsupported:
return qml_mtx_events::EventType::Unsupported;
default:
return qml_mtx_events::EventType::UnknownMessage;
}
}
qml_mtx_events::EventType
toRoomEventType(const mtx::events::collections::TimelineEvents &event)
{
return std::visit(RoomEventType{}, event);
}
QString
toRoomEventTypeString(const mtx::events::collections::TimelineEvents &event)
{
return std::visit([](const auto &e) { return QString::fromStdString(to_string(e.type)); },
event);
}
mtx::events::EventType
qml_mtx_events::fromRoomEventType(qml_mtx_events::EventType t)
{
switch (t) {
// Unsupported event
case qml_mtx_events::Unsupported:
return mtx::events::EventType::Unsupported;
/// m.room_key_request
case qml_mtx_events::KeyRequest:
return mtx::events::EventType::RoomKeyRequest;
/// m.reaction:
case qml_mtx_events::Reaction:
return mtx::events::EventType::Reaction;
/// m.room.aliases
case qml_mtx_events::Aliases:
return mtx::events::EventType::RoomAliases;
/// m.room.avatar
case qml_mtx_events::Avatar:
return mtx::events::EventType::RoomAvatar;
/// m.call.invite
case qml_mtx_events::CallInvite:
return mtx::events::EventType::CallInvite;
/// m.call.answer
case qml_mtx_events::CallAnswer:
return mtx::events::EventType::CallAnswer;
/// m.call.hangup
case qml_mtx_events::CallHangUp:
return mtx::events::EventType::CallHangUp;
/// m.call.candidates
case qml_mtx_events::CallCandidates:
return mtx::events::EventType::CallCandidates;
/// m.room.canonical_alias
case qml_mtx_events::CanonicalAlias:
return mtx::events::EventType::RoomCanonicalAlias;
/// m.room.create
case qml_mtx_events::RoomCreate:
return mtx::events::EventType::RoomCreate;
/// m.room.encrypted.
case qml_mtx_events::Encrypted:
return mtx::events::EventType::RoomEncrypted;
/// m.room.encryption.
case qml_mtx_events::Encryption:
return mtx::events::EventType::RoomEncryption;
/// m.room.guest_access
case qml_mtx_events::RoomGuestAccess:
return mtx::events::EventType::RoomGuestAccess;
/// m.room.history_visibility
case qml_mtx_events::RoomHistoryVisibility:
return mtx::events::EventType::RoomHistoryVisibility;
/// m.room.join_rules
case qml_mtx_events::RoomJoinRules:
return mtx::events::EventType::RoomJoinRules;
/// m.room.member
case qml_mtx_events::Member:
return mtx::events::EventType::RoomMember;
/// m.room.name
case qml_mtx_events::Name:
return mtx::events::EventType::RoomName;
/// m.room.power_levels
case qml_mtx_events::PowerLevels:
return mtx::events::EventType::RoomPowerLevels;
/// m.room.tombstone
case qml_mtx_events::Tombstone:
return mtx::events::EventType::RoomTombstone;
/// m.room.topic
case qml_mtx_events::Topic:
return mtx::events::EventType::RoomTopic;
/// m.room.redaction
case qml_mtx_events::Redaction:
return mtx::events::EventType::RoomRedaction;
/// m.room.pinned_events
case qml_mtx_events::PinnedEvents:
return mtx::events::EventType::RoomPinnedEvents;
/// m.widget
case qml_mtx_events::Widget:
return mtx::events::EventType::Widget;
// m.sticker
case qml_mtx_events::Sticker:
return mtx::events::EventType::Sticker;
// m.tag
case qml_mtx_events::Tag:
return mtx::events::EventType::Tag;
// m.space.parent
case qml_mtx_events::SpaceParent:
return mtx::events::EventType::SpaceParent;
// m.space.child
case qml_mtx_events::SpaceChild:
return mtx::events::EventType::SpaceChild;
/// m.room.message
case qml_mtx_events::AudioMessage:
case qml_mtx_events::EmoteMessage:
case qml_mtx_events::FileMessage:
case qml_mtx_events::ImageMessage:
case qml_mtx_events::LocationMessage:
case qml_mtx_events::NoticeMessage:
case qml_mtx_events::TextMessage:
case qml_mtx_events::VideoMessage:
case qml_mtx_events::Redacted:
case qml_mtx_events::UnknownMessage:
case qml_mtx_events::KeyVerificationRequest:
case qml_mtx_events::KeyVerificationStart:
case qml_mtx_events::KeyVerificationMac:
case qml_mtx_events::KeyVerificationAccept:
case qml_mtx_events::KeyVerificationCancel:
case qml_mtx_events::KeyVerificationKey:
case qml_mtx_events::KeyVerificationDone:
case qml_mtx_events::KeyVerificationReady:
return mtx::events::EventType::RoomMessage;
//! m.image_pack, currently im.ponies.room_emotes
case qml_mtx_events::ImagePackInRoom:
return mtx::events::EventType::ImagePackInRoom;
//! m.image_pack, currently im.ponies.user_emotes
case qml_mtx_events::ImagePackInAccountData:
return mtx::events::EventType::ImagePackInAccountData;
//! m.image_pack.rooms, currently im.ponies.emote_rooms
case qml_mtx_events::ImagePackRooms:
return mtx::events::EventType::ImagePackRooms;
default:
return mtx::events::EventType::Unsupported;
};
}
TimelineModel::TimelineModel(TimelineViewManager *manager, QString room_id, QObject *parent)
: QAbstractListModel(parent)
, room_id_(std::move(room_id))
, events(room_id_.toStdString(), this)
, manager_(manager)
, permissions_{room_id_}
{
this->isEncrypted_ = cache::isRoomEncrypted(room_id_.toStdString());
auto roomInfo = cache::singleRoomInfo(room_id_.toStdString());
this->isSpace_ = roomInfo.is_space;
this->notification_count = roomInfo.notification_count;
this->highlight_count = roomInfo.highlight_count;
lastMessage_.timestamp = roomInfo.approximate_last_modification_ts;
// this connection will simplify adding the plainRoomNameChanged() signal everywhere that it
// needs to be
connect(this, &TimelineModel::roomNameChanged, this, &TimelineModel::plainRoomNameChanged);
connect(
this,
&TimelineModel::redactionFailed,
this,
[](const QString &msg) { emit ChatPage::instance()->showNotification(msg); },
Qt::QueuedConnection);
connect(this, &TimelineModel::dataAtIdChanged, this, [this](const QString &id) {
relatedEventCacheBuster++;
auto idx = idToIndex(id);
if (idx != -1) {
auto pos = index(idx);
nhlog::ui()->debug("data changed at {}", id.toStdString());
emit dataChanged(pos, pos);
} else {
nhlog::ui()->debug("id not found {}", id.toStdString());
}
});
connect(this,
&TimelineModel::newMessageToSend,
this,
&TimelineModel::addPendingMessage,
Qt::QueuedConnection);
connect(this, &TimelineModel::addPendingMessageToStore, &events, &EventStore::addPending);
connect(&events, &EventStore::dataChanged, this, [this](int from, int to) {
relatedEventCacheBuster++;
nhlog::ui()->debug(
"data changed {} to {}", events.size() - to - 1, events.size() - from - 1);
emit dataChanged(index(events.size() - to - 1, 0), index(events.size() - from - 1, 0));
});
connect(&events, &EventStore::beginInsertRows, this, [this](int from, int to) {
int first = events.size() - to;
int last = events.size() - from;
if (from >= events.size()) {
int batch_size = to - from;
first += batch_size;
last += batch_size;
} else {
first -= 1;
last -= 1;
}
nhlog::ui()->debug("begin insert from {} to {}", first, last);
beginInsertRows(QModelIndex(), first, last);
});
connect(&events, &EventStore::endInsertRows, this, [this]() { endInsertRows(); });
connect(&events, &EventStore::beginResetModel, this, [this]() { beginResetModel(); });
connect(&events, &EventStore::endResetModel, this, [this]() { endResetModel(); });
connect(&events, &EventStore::newEncryptedImage, this, &TimelineModel::newEncryptedImage);
connect(&events, &EventStore::fetchedMore, this, [this]() { setPaginationInProgress(false); });
connect(&events,
&EventStore::startDMVerification,
this,
[this](const mtx::events::RoomEvent<mtx::events::msg::KeyVerificationRequest> &msg) {
ChatPage::instance()->receivedRoomDeviceVerificationRequest(msg, this);
});
connect(&events, &EventStore::updateFlowEventId, this, [this](std::string event_id) {
this->updateFlowEventId(std::move(event_id));
});
// When a message is sent, check if the current edit/reply relates to that message,
// and update the event_id so that it points to the sent message and not the pending one.
connect(
&events,
&EventStore::messageSent,
this,
[this](const std::string &txn_id, const std::string &event_id) {
if (edit_.toStdString() == txn_id) {
edit_ = QString::fromStdString(event_id);
emit editChanged(edit_);
}
if (reply_.toStdString() == txn_id) {
reply_ = QString::fromStdString(event_id);
emit replyChanged(reply_);
}
},
Qt::QueuedConnection);
connect(
manager_, &TimelineViewManager::initialSyncChanged, &events, &EventStore::enableKeyRequests);
connect(this, &TimelineModel::encryptionChanged, this, &TimelineModel::trustlevelChanged);
connect(this, &TimelineModel::roomMemberCountChanged, this, &TimelineModel::trustlevelChanged);
connect(
cache::client(), &Cache::verificationStatusChanged, this, &TimelineModel::trustlevelChanged);
showEventTimer.callOnTimeout(this, &TimelineModel::scrollTimerEvent);
connect(this, &TimelineModel::newState, this, [this](mtx::responses::StateEvents events_) {
cache::client()->updateState(room_id_.toStdString(), events_);
this->syncState({std::move(events_.events)});
});
}
QHash<int, QByteArray>
TimelineModel::roleNames() const
{
static QHash<int, QByteArray> roles{
{Type, "type"},
{TypeString, "typeString"},
{IsOnlyEmoji, "isOnlyEmoji"},
{Body, "body"},
{FormattedBody, "formattedBody"},
{PreviousMessageUserId, "previousMessageUserId"},
{IsSender, "isSender"},
{UserId, "userId"},
{UserName, "userName"},
{PreviousMessageDay, "previousMessageDay"},
{PreviousMessageIsStateEvent, "previousMessageIsStateEvent"},
{Day, "day"},
{Timestamp, "timestamp"},
{Url, "url"},
{ThumbnailUrl, "thumbnailUrl"},
{Duration, "duration"},
{Blurhash, "blurhash"},
{Filename, "filename"},
{Filesize, "filesize"},
{MimeType, "mimetype"},
{OriginalHeight, "originalHeight"},
{OriginalWidth, "originalWidth"},
{ProportionalHeight, "proportionalHeight"},
{EventId, "eventId"},
{State, "status"},
{IsEdited, "isEdited"},
{IsEditable, "isEditable"},
{IsEncrypted, "isEncrypted"},
{IsStateEvent, "isStateEvent"},
{Trustlevel, "trustlevel"},
{EncryptionError, "encryptionError"},
{ReplyTo, "replyTo"},
{Reactions, "reactions"},
{RoomId, "roomId"},
{RoomName, "roomName"},
{RoomTopic, "roomTopic"},
{CallType, "callType"},
{Dump, "dump"},
{RelatedEventCacheBuster, "relatedEventCacheBuster"},
};
return roles;
}
int
TimelineModel::rowCount(const QModelIndex &parent) const
{
Q_UNUSED(parent);
return this->events.size();
}
QVariantMap
TimelineModel::getDump(const QString &eventId, const QString &relatedTo) const
{
if (auto event = events.get(eventId.toStdString(), relatedTo.toStdString()))
return data(*event, Dump).toMap();
return {};
}
QVariant
TimelineModel::data(const mtx::events::collections::TimelineEvents &event, int role) const
{
using namespace mtx::accessors;
namespace acc = mtx::accessors;
switch (role) {
case IsSender:
return {acc::sender(event) == http::client()->user_id().to_string()};
case UserId:
return QVariant(QString::fromStdString(acc::sender(event)));
case UserName:
return QVariant(displayName(QString::fromStdString(acc::sender(event))));
case Day: {
QDateTime prevDate = origin_server_ts(event);
prevDate.setTime(QTime());
return {prevDate.toMSecsSinceEpoch()};
}
case Timestamp:
return QVariant(origin_server_ts(event));
case Type:
return {toRoomEventType(event)};
case TypeString:
return QVariant(toRoomEventTypeString(event));
case IsOnlyEmoji: {
QString qBody = QString::fromStdString(body(event));
QVector<uint> utf32_string = qBody.toUcs4();
int emojiCount = 0;
for (auto &code : utf32_string) {
if (utils::codepointIsEmoji(code)) {
emojiCount++;
} else {
return {0};
}
}
return {emojiCount};
}
case Body:
return QVariant(utils::replaceEmoji(QString::fromStdString(body(event)).toHtmlEscaped()));
case FormattedBody: {
const static QRegularExpression replyFallback(
QStringLiteral("<mx-reply>.*</mx-reply>"),
QRegularExpression::DotMatchesEverythingOption);
auto ascent = QFontMetrics(UserSettings::instance()->font()).ascent();
bool isReply = utils::isReply(event);
auto formattedBody_ = QString::fromStdString(formatted_body(event));
if (formattedBody_.isEmpty()) {
auto body_ = QString::fromStdString(body(event));
if (isReply) {
while (body_.startsWith(QLatin1String("> ")))
body_ = body_.right(body_.size() - body_.indexOf('\n') - 1);
if (body_.startsWith('\n'))
body_ = body_.right(body_.size() - 1);
}
formattedBody_ = body_.toHtmlEscaped().replace('\n', QLatin1String("<br>"));
} else {
if (isReply)
formattedBody_ = formattedBody_.remove(replyFallback);
}
// TODO(Nico): Don't parse html with a regex
const static QRegularExpression matchIsImg(QStringLiteral("<img [^>]+>"));
auto itIsImg = matchIsImg.globalMatch(formattedBody_);
while (itIsImg.hasNext()) {
// The current <img> tag.
const QString curImg = itIsImg.next().captured(0);
// The replacement for the current <img>.
auto imgReplacement = curImg;
// Construct image parameters later used by MxcImageProvider.
QString imgParams;
if (curImg.contains(QLatin1String("height"))) {
const static QRegularExpression matchImgHeight(
QStringLiteral("height=([\"\']?)(\\d+)([\"\']?)"));
// Make emoticons twice as high as the font.
if (curImg.contains(QLatin1String("data-mx-emoticon"))) {
imgReplacement =
imgReplacement.replace(matchImgHeight, "height=\\1%1\\3").arg(ascent * 2);
}
const auto height = matchImgHeight.match(imgReplacement).captured(2).toInt();
imgParams = QStringLiteral("?scale&height=%1").arg(height);
}
// Replace src in current <img>.
const static QRegularExpression matchImgUri(QStringLiteral("src=\"mxc://([^\"]*)\""));
imgReplacement.replace(matchImgUri,
QStringLiteral(R"(src="image://mxcImage/\1%1")").arg(imgParams));
// Same regex but for single quotes around the src
const static QRegularExpression matchImgUri2(QStringLiteral("src=\'mxc://([^\']*)\'"));
imgReplacement.replace(matchImgUri2,
QStringLiteral("src=\'image://mxcImage/\\1%1\'").arg(imgParams));
// Replace <img> in formattedBody_ with our new <img>.
formattedBody_.replace(curImg, imgReplacement);
}
return QVariant(
utils::replaceEmoji(utils::linkifyMessage(utils::escapeBlacklistedHtml(formattedBody_))));
}
case Url:
return QVariant(QString::fromStdString(url(event)));
case ThumbnailUrl:
return QVariant(QString::fromStdString(thumbnail_url(event)));
case Duration:
return QVariant(static_cast<qulonglong>(duration(event)));
case Blurhash:
return QVariant(QString::fromStdString(blurhash(event)));
case Filename:
return QVariant(QString::fromStdString(filename(event)));
case Filesize:
return QVariant(utils::humanReadableFileSize(filesize(event)));
case MimeType:
return QVariant(QString::fromStdString(mimetype(event)));
case OriginalHeight:
return QVariant(qulonglong{media_height(event)});
case OriginalWidth:
return QVariant(qulonglong{media_width(event)});
case ProportionalHeight: {
auto w = media_width(event);
if (w == 0)
w = 1;
double prop = media_height(event) / (double)w;
return {prop > 0 ? prop : 1.};
}
case EventId: {
if (auto replaces = relations(event).replaces())
return QVariant(QString::fromStdString(replaces.value()));
else
return QVariant(QString::fromStdString(event_id(event)));
}
case State: {
auto id = QString::fromStdString(event_id(event));
auto containsOthers = [](const auto &vec) {
for (const auto &e : vec)
if (e.second != http::client()->user_id().to_string())
return true;
return false;
};
// only show read receipts for messages not from us
if (acc::sender(event) != http::client()->user_id().to_string())
return qml_mtx_events::Empty;
else if (!id.isEmpty() && id[0] == 'm')
return qml_mtx_events::Sent;
else if (read.contains(id) || containsOthers(cache::readReceipts(id, room_id_)))
return qml_mtx_events::Read;
else
return qml_mtx_events::Received;
}
case IsEdited:
return {relations(event).replaces().has_value()};
case IsEditable:
return {!is_state_event(event) &&
mtx::accessors::sender(event) == http::client()->user_id().to_string()};
case IsEncrypted: {
auto encrypted_event = events.get(event_id(event), "", false);
return encrypted_event &&
std::holds_alternative<mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>(
*encrypted_event);
}
case IsStateEvent: {
return is_state_event(event);
}
case Trustlevel: {
auto encrypted_event = events.get(event_id(event), "", false);
if (encrypted_event) {
if (auto encrypted =
std::get_if<mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>(
&*encrypted_event)) {
return olm::calculate_trust(
encrypted->sender,
MegolmSessionIndex(room_id_.toStdString(), encrypted->content));
}
}
return crypto::Trust::Unverified;
}
case EncryptionError:
return events.decryptionError(event_id(event));
case ReplyTo:
return QVariant(QString::fromStdString(relations(event).reply_to().value_or("")));
case Reactions: {
auto id = relations(event).replaces().value_or(event_id(event));
return QVariant::fromValue(events.reactions(id));
}
case RoomId:
return QVariant(room_id_);
case RoomName:
return QVariant(
utils::replaceEmoji(QString::fromStdString(room_name(event)).toHtmlEscaped()));
case RoomTopic:
return QVariant(utils::replaceEmoji(
utils::linkifyMessage(QString::fromStdString(room_topic(event))
.toHtmlEscaped()
.replace(QLatin1String("\n"), QLatin1String("<br>")))));
case CallType:
return QVariant(QString::fromStdString(call_type(event)));
case Dump: {
QVariantMap m;
auto names = roleNames();
m.insert(names[Type], data(event, static_cast<int>(Type)));
m.insert(names[TypeString], data(event, static_cast<int>(TypeString)));
m.insert(names[IsOnlyEmoji], data(event, static_cast<int>(IsOnlyEmoji)));
m.insert(names[Body], data(event, static_cast<int>(Body)));
m.insert(names[FormattedBody], data(event, static_cast<int>(FormattedBody)));
m.insert(names[IsSender], data(event, static_cast<int>(IsSender)));
m.insert(names[UserId], data(event, static_cast<int>(UserId)));
m.insert(names[UserName], data(event, static_cast<int>(UserName)));
m.insert(names[Day], data(event, static_cast<int>(Day)));
m.insert(names[Timestamp], data(event, static_cast<int>(Timestamp)));
m.insert(names[Url], data(event, static_cast<int>(Url)));
m.insert(names[ThumbnailUrl], data(event, static_cast<int>(ThumbnailUrl)));
m.insert(names[Duration], data(event, static_cast<int>(Duration)));
m.insert(names[Blurhash], data(event, static_cast<int>(Blurhash)));
m.insert(names[Filename], data(event, static_cast<int>(Filename)));
m.insert(names[Filesize], data(event, static_cast<int>(Filesize)));
m.insert(names[MimeType], data(event, static_cast<int>(MimeType)));
m.insert(names[OriginalHeight], data(event, static_cast<int>(OriginalHeight)));
m.insert(names[OriginalWidth], data(event, static_cast<int>(OriginalWidth)));
m.insert(names[ProportionalHeight], data(event, static_cast<int>(ProportionalHeight)));
m.insert(names[EventId], data(event, static_cast<int>(EventId)));
m.insert(names[State], data(event, static_cast<int>(State)));
m.insert(names[IsEdited], data(event, static_cast<int>(IsEdited)));
m.insert(names[IsEditable], data(event, static_cast<int>(IsEditable)));
m.insert(names[IsEncrypted], data(event, static_cast<int>(IsEncrypted)));
m.insert(names[IsStateEvent], data(event, static_cast<int>(IsStateEvent)));
m.insert(names[ReplyTo], data(event, static_cast<int>(ReplyTo)));
m.insert(names[RoomName], data(event, static_cast<int>(RoomName)));
m.insert(names[RoomTopic], data(event, static_cast<int>(RoomTopic)));
m.insert(names[CallType], data(event, static_cast<int>(CallType)));
m.insert(names[EncryptionError], data(event, static_cast<int>(EncryptionError)));
return QVariant(m);
}
case RelatedEventCacheBuster:
return relatedEventCacheBuster;
default:
return {};
}
}
QVariant
TimelineModel::data(const QModelIndex &index, int role) const
{
using namespace mtx::accessors;
namespace acc = mtx::accessors;
if (index.row() < 0 && index.row() >= rowCount())
return {};
// HACK(Nico): fetchMore likes to break with dynamically sized delegates and reuseItems
if (index.row() + 1 == rowCount() && !m_paginationInProgress)
const_cast<TimelineModel *>(this)->fetchMore(index);
auto event = events.get(rowCount() - index.row() - 1);
if (!event)
return "";
if (role == PreviousMessageDay || role == PreviousMessageUserId ||
role == PreviousMessageIsStateEvent) {
int prevIdx = rowCount() - index.row() - 2;
if (prevIdx < 0)
return {};
auto tempEv = events.get(prevIdx);
if (!tempEv)
return {};
if (role == PreviousMessageUserId)
return data(*tempEv, UserId);
else if (role == PreviousMessageDay)
return data(*tempEv, Day);
else
return data(*tempEv, IsStateEvent);
}
return data(*event, role);
}
QVariant
TimelineModel::dataById(const QString &id, int role, const QString &relatedTo)
{
if (auto event = events.get(id.toStdString(), relatedTo.toStdString()))
return data(*event, role);
return {};
}
bool
TimelineModel::canFetchMore(const QModelIndex &) const
{
if (!events.size())
return true;
if (auto first = events.get(0);
first &&
!std::holds_alternative<mtx::events::StateEvent<mtx::events::state::Create>>(*first))
return true;
else
return false;
}
void
TimelineModel::setPaginationInProgress(const bool paginationInProgress)
{
if (m_paginationInProgress == paginationInProgress) {
return;
}
m_paginationInProgress = paginationInProgress;
emit paginationInProgressChanged(m_paginationInProgress);
}
void
TimelineModel::fetchMore(const QModelIndex &)
{
if (m_paginationInProgress) {
nhlog::ui()->warn("Already loading older messages");
return;
}
setPaginationInProgress(true);
events.fetchMore();
}
void
TimelineModel::sync(const mtx::responses::JoinedRoom &room)
{
this->syncState(room.state);
this->addEvents(room.timeline);
if (room.unread_notifications.highlight_count != highlight_count ||
room.unread_notifications.notification_count != notification_count) {
notification_count = room.unread_notifications.notification_count;
highlight_count = room.unread_notifications.highlight_count;
emit notificationsChanged();
}
}
void
TimelineModel::syncState(const mtx::responses::State &s)
{
using namespace mtx::events;
for (const auto &e : s.events) {
if (std::holds_alternative<StateEvent<state::Avatar>>(e))
emit roomAvatarUrlChanged();
else if (std::holds_alternative<StateEvent<state::Name>>(e))
emit roomNameChanged();
else if (std::holds_alternative<StateEvent<state::Topic>>(e))
emit roomTopicChanged();
else if (std::holds_alternative<StateEvent<state::PinnedEvents>>(e))
emit pinnedMessagesChanged();
else if (std::holds_alternative<StateEvent<state::Widget>>(e))
emit widgetLinksChanged();
else if (std::holds_alternative<StateEvent<state::PowerLevels>>(e)) {
permissions_.invalidate();
emit permissionsChanged();
} else if (std::holds_alternative<StateEvent<state::Member>>(e)) {
emit roomAvatarUrlChanged();
emit roomNameChanged();
emit roomMemberCountChanged();
if (roomMemberCount() <= 2) {
emit isDirectChanged();
emit directChatOtherUserIdChanged();
}
} else if (std::holds_alternative<StateEvent<state::Encryption>>(e)) {
this->isEncrypted_ = cache::isRoomEncrypted(room_id_.toStdString());
emit encryptionChanged();
}
}
}
void
TimelineModel::addEvents(const mtx::responses::Timeline &timeline)
{
if (timeline.events.empty())
return;
events.handleSync(timeline);
using namespace mtx::events;
for (auto e : timeline.events) {
if (auto encryptedEvent = std::get_if<EncryptedEvent<msg::Encrypted>>(&e)) {
MegolmSessionIndex index(room_id_.toStdString(), encryptedEvent->content);
auto result = olm::decryptEvent(index, *encryptedEvent);
if (result.event)
e = result.event.value();
}
if (std::holds_alternative<RoomEvent<msg::CallCandidates>>(e) ||
std::holds_alternative<RoomEvent<msg::CallInvite>>(e) ||
std::holds_alternative<RoomEvent<msg::CallAnswer>>(e) ||
std::holds_alternative<RoomEvent<msg::CallHangUp>>(e))
std::visit(
[this](auto &event) {
event.room_id = room_id_.toStdString();
if constexpr (std::is_same_v<std::decay_t<decltype(event)>,
RoomEvent<msg::CallAnswer>> ||
std::is_same_v<std::decay_t<decltype(event)>,
RoomEvent<msg::CallHangUp>>)
emit newCallEvent(event);
else {
if (event.sender != http::client()->user_id().to_string())
emit newCallEvent(event);
}
},
e);
else if (std::holds_alternative<StateEvent<state::Avatar>>(e))
emit roomAvatarUrlChanged();
else if (std::holds_alternative<StateEvent<state::Name>>(e))
emit roomNameChanged();
else if (std::holds_alternative<StateEvent<state::Topic>>(e))
emit roomTopicChanged();
else if (std::holds_alternative<StateEvent<state::PinnedEvents>>(e))
emit pinnedMessagesChanged();
else if (std::holds_alternative<StateEvent<state::Widget>>(e))
emit widgetLinksChanged();
else if (std::holds_alternative<StateEvent<state::PowerLevels>>(e)) {
permissions_.invalidate();
emit permissionsChanged();
} else if (std::holds_alternative<StateEvent<state::Member>>(e)) {
emit roomAvatarUrlChanged();
emit roomNameChanged();
emit roomMemberCountChanged();
} else if (std::holds_alternative<StateEvent<state::Encryption>>(e)) {
this->isEncrypted_ = cache::isRoomEncrypted(room_id_.toStdString());
emit encryptionChanged();
}
}
updateLastMessage();
}
template<typename T>
auto
isMessage(const mtx::events::RoomEvent<T> &e)
-> std::enable_if_t<std::is_same<decltype(e.content.msgtype), std::string>::value, bool>
{
return true;
}
template<typename T>
auto
isMessage(const mtx::events::Event<T> &)
{
return false;
}
template<typename T>
auto
isMessage(const mtx::events::EncryptedEvent<T> &)
{
return true;
}
auto
isMessage(const mtx::events::RoomEvent<mtx::events::msg::CallInvite> &)
{
return true;
}
auto
isMessage(const mtx::events::RoomEvent<mtx::events::msg::CallAnswer> &)
{
return true;
}
auto
isMessage(const mtx::events::RoomEvent<mtx::events::msg::CallHangUp> &)
{
return true;
}
// Workaround. We also want to see a room at the top, if we just joined it
auto
isYourJoin(const mtx::events::StateEvent<mtx::events::state::Member> &e)
{
return e.content.membership == mtx::events::state::Membership::Join &&
e.state_key == http::client()->user_id().to_string();
}
template<typename T>
auto
isYourJoin(const mtx::events::Event<T> &)
{
return false;
}
DescInfo
TimelineModel::lastMessage() const
{
if (lastMessage_.event_id.isEmpty())
QTimer::singleShot(0, this, &TimelineModel::updateLastMessage);
return lastMessage_;
}
void
TimelineModel::updateLastMessage()
{
// only try to generate a preview for the last 1000 messages
auto end = std::max(events.size() - 1001, 0);
for (auto it = events.size() - 1; it >= end; --it) {
auto event = events.get(it, decryptDescription);
if (!event)
continue;
if (std::visit([](const auto &e) -> bool { return isYourJoin(e); }, *event)) {
auto time = mtx::accessors::origin_server_ts(*event);
uint64_t ts = time.toMSecsSinceEpoch();
auto description =
DescInfo{QString::fromStdString(mtx::accessors::event_id(*event)),
QString::fromStdString(http::client()->user_id().to_string()),
tr("You joined this room."),
utils::descriptiveTime(time),
ts,
time};
if (description != lastMessage_) {
lastMessage_ = description;
emit lastMessageChanged();
}
return;
}
if (!std::visit([](const auto &e) -> bool { return isMessage(e); }, *event))
continue;
auto description = utils::getMessageDescription(
*event,
QString::fromStdString(http::client()->user_id().to_string()),
cache::displayName(room_id_, QString::fromStdString(mtx::accessors::sender(*event))));
if (description != lastMessage_) {
lastMessage_ = description;
emit lastMessageChanged();
}
return;
}
}
void
TimelineModel::setCurrentIndex(int index)
{
auto oldIndex = idToIndex(currentId);
currentId = indexToId(index);
if (index != oldIndex)
emit currentIndexChanged(index);
if (!QGuiApplication::focusWindow() || !QGuiApplication::focusWindow()->isActive() ||
MainWindow::instance()->windowForRoom(roomId()) != QGuiApplication::focusWindow())
return;
if (!currentId.startsWith('m')) {
auto oldReadIndex =
cache::getEventIndex(roomId().toStdString(), currentReadId.toStdString());
auto nextEventIndexAndId =
cache::lastInvisibleEventAfter(roomId().toStdString(), currentId.toStdString());
if (nextEventIndexAndId && (!oldReadIndex || *oldReadIndex < nextEventIndexAndId->first)) {
readEvent(nextEventIndexAndId->second);
currentReadId = QString::fromStdString(nextEventIndexAndId->second);
}
}
}
void
TimelineModel::readEvent(const std::string &id)
{
http::client()->read_event(
room_id_.toStdString(),
id,
[this](mtx::http::RequestErr err) {
if (err) {
nhlog::net()->warn(
"failed to read_event ({}, {})", room_id_.toStdString(), currentId.toStdString());
}
},
!UserSettings::instance()->readReceipts());
}
QString
TimelineModel::displayName(const QString &id) const
{
return cache::displayName(room_id_, id).toHtmlEscaped();
}
QString
TimelineModel::avatarUrl(const QString &id) const
{
return cache::avatarUrl(room_id_, id);
}
QString
TimelineModel::formatDateSeparator(QDate date) const
{
auto now = QDateTime::currentDateTime();
QString fmt = QLocale::system().dateFormat(QLocale::LongFormat);
if (now.date().year() == date.year()) {
QRegularExpression rx(QStringLiteral("[^a-zA-Z]*y+[^a-zA-Z]*"));
fmt = fmt.remove(rx);
}
return date.toString(fmt);
}
void
TimelineModel::viewRawMessage(const QString &id)
{
auto e = events.get(id.toStdString(), "", false);
if (!e)
return;
std::string ev = mtx::accessors::serialize_event(*e).dump(4);
emit showRawMessageDialog(QString::fromStdString(ev));
}
void
TimelineModel::forwardMessage(const QString &eventId, QString roomId)
{
auto e = events.get(eventId.toStdString(), "");
if (!e)
return;
emit forwardToRoom(e, std::move(roomId));
}
void
TimelineModel::viewDecryptedRawMessage(const QString &id)
{
auto e = events.get(id.toStdString(), "");
if (!e)
return;
std::string ev = mtx::accessors::serialize_event(*e).dump(4);
emit showRawMessageDialog(QString::fromStdString(ev));
}
void
TimelineModel::openUserProfile(QString userid)
{
UserProfile *userProfile = new UserProfile(room_id_, std::move(userid), manager_, this);
connect(this, &TimelineModel::roomAvatarUrlChanged, userProfile, &UserProfile::updateAvatarUrl);
emit manager_->openProfile(userProfile);
}
void
TimelineModel::replyAction(const QString &id)
{
setReply(id);
}
void
TimelineModel::unpin(const QString &id)
{
auto pinned =
cache::client()->getStateEvent<mtx::events::state::PinnedEvents>(room_id_.toStdString());
mtx::events::state::PinnedEvents content{};
if (pinned)
content = pinned->content;
auto idStr = id.toStdString();
for (auto it = content.pinned.begin(); it != content.pinned.end(); ++it) {
if (*it == idStr) {
content.pinned.erase(it);
break;
}
}
http::client()->send_state_event(
room_id_.toStdString(),
content,
[idStr](const mtx::responses::EventId &, mtx::http::RequestErr err) {
if (err)
nhlog::net()->error("Failed to unpin {}: {}", idStr, *err);
else
nhlog::net()->debug("Unpinned {}", idStr);
});
}
void
TimelineModel::pin(const QString &id)
{
auto pinned =
cache::client()->getStateEvent<mtx::events::state::PinnedEvents>(room_id_.toStdString());
mtx::events::state::PinnedEvents content{};
if (pinned)
content = pinned->content;
auto idStr = id.toStdString();
content.pinned.push_back(idStr);
http::client()->send_state_event(
room_id_.toStdString(),
content,
[idStr](const mtx::responses::EventId &, mtx::http::RequestErr err) {
if (err)
nhlog::net()->error("Failed to pin {}: {}", idStr, *err);
else
nhlog::net()->debug("Pinned {}", idStr);
});
}
void
TimelineModel::editAction(QString id)
{
setEdit(id);
}
RelatedInfo
TimelineModel::relatedInfo(const QString &id)
{
auto event = events.get(id.toStdString(), "");
if (!event)
return {};
return utils::stripReplyFallbacks(*event, id.toStdString(), room_id_);
}
void
TimelineModel::showReadReceipts(QString id)
{
emit openReadReceiptsDialog(new ReadReceiptsProxy{id, roomId(), this});
}
void
TimelineModel::redactEvent(const QString &id, const QString &reason)
{
if (!id.isEmpty()) {
auto edits = events.edits(id.toStdString());
http::client()->redact_event(
room_id_.toStdString(),
id.toStdString(),
[this, id](const mtx::responses::EventId &, mtx::http::RequestErr err) {
if (err) {
emit redactionFailed(tr("Message redaction failed: %1")
.arg(QString::fromStdString(err->matrix_error.error)));
return;
}
emit dataAtIdChanged(id);
},
reason.toStdString());
// redact all edits to prevent leaks
for (const auto &e : edits) {
const auto &id_ = mtx::accessors::event_id(e);
http::client()->redact_event(
room_id_.toStdString(),
id_,
[this, id, id_](const mtx::responses::EventId &, mtx::http::RequestErr err) {
if (err) {
emit redactionFailed(tr("Message redaction failed: %1")
.arg(QString::fromStdString(err->matrix_error.error)));
return;
}
emit dataAtIdChanged(id);
},
reason.toStdString());
}
}
}
int
TimelineModel::idToIndex(const QString &id) const
{
if (id.isEmpty())
return -1;
auto idx = events.idToIndex(id.toStdString());
if (idx)
return events.size() - *idx - 1;
else
return -1;
}
QString
TimelineModel::indexToId(int index) const
{
auto id = events.indexToId(events.size() - index - 1);
return id ? QString::fromStdString(*id) : QLatin1String("");
}
// Note: this will only be called for our messages
void
TimelineModel::markEventsAsRead(const std::vector<QString> &event_ids)
{
for (const auto &id : event_ids) {
read.insert(id);
int idx = idToIndex(id);
if (idx < 0) {
return;
}
emit dataChanged(index(idx, 0), index(idx, 0));
}
}
template<typename T>
void
TimelineModel::sendEncryptedMessage(mtx::events::RoomEvent<T> msg, mtx::events::EventType eventType)
{
const auto room_id = room_id_.toStdString();
using namespace mtx::events;
using namespace mtx::identifiers;
json doc = {{"type", mtx::events::to_string(eventType)},
{"content", json(msg.content)},
{"room_id", room_id}};
try {
mtx::events::EncryptedEvent<mtx::events::msg::Encrypted> event;
event.content = olm::encrypt_group_message(room_id, http::client()->device_id(), doc);
event.event_id = msg.event_id;
event.room_id = room_id;
event.sender = http::client()->user_id().to_string();
event.type = mtx::events::EventType::RoomEncrypted;
event.origin_server_ts = QDateTime::currentMSecsSinceEpoch();
emit this->addPendingMessageToStore(event);
// TODO: Let the user know about the errors.
} catch (const lmdb::error &e) {
nhlog::db()->critical("failed to open outbound megolm session ({}): {}", room_id, e.what());
emit ChatPage::instance()->showNotification(
tr("Failed to encrypt event, sending aborted!"));
} catch (const mtx::crypto::olm_exception &e) {
nhlog::crypto()->critical(
"failed to open outbound megolm session ({}): {}", room_id, e.what());
emit ChatPage::instance()->showNotification(
tr("Failed to encrypt event, sending aborted!"));
}
}
struct SendMessageVisitor
{
explicit SendMessageVisitor(TimelineModel *model)
: model_(model)
{}
template<typename T, mtx::events::EventType Event>
void sendRoomEvent(mtx::events::RoomEvent<T> msg)
{
if (cache::isRoomEncrypted(model_->room_id_.toStdString())) {
auto encInfo = mtx::accessors::file(msg);
if (encInfo)
emit model_->newEncryptedImage(encInfo.value());
encInfo = mtx::accessors::thumbnail_file(msg);
if (encInfo)
emit model_->newEncryptedImage(encInfo.value());
model_->sendEncryptedMessage(msg, Event);
} else {
msg.type = Event;
emit model_->addPendingMessageToStore(msg);
}
}
// Do-nothing operator for all unhandled events
template<typename T>
void operator()(const mtx::events::Event<T> &)
{}
// Operator for m.room.message events that contain a msgtype in their content
template<typename T,
std::enable_if_t<std::is_same<decltype(T::msgtype), std::string>::value, int> = 0>
void operator()(mtx::events::RoomEvent<T> msg)
{
sendRoomEvent<T, mtx::events::EventType::RoomMessage>(msg);
}
// Special operator for reactions, which are a type of m.room.message, but need to be
// handled distinctly for their differences from normal room messages. Specifically,
// reactions need to have the relation outside of ciphertext, or synapse / the homeserver
// cannot handle it correctly. See the MSC for more details:
// https://github.com/matrix-org/matrix-doc/blob/matthew/msc1849/proposals/1849-aggregations.md#end-to-end-encryption
void operator()(mtx::events::RoomEvent<mtx::events::msg::Reaction> msg)
{
msg.type = mtx::events::EventType::Reaction;
emit model_->addPendingMessageToStore(msg);
}
void operator()(const mtx::events::RoomEvent<mtx::events::msg::CallInvite> &event)
{
sendRoomEvent<mtx::events::msg::CallInvite, mtx::events::EventType::CallInvite>(event);
}
void operator()(const mtx::events::RoomEvent<mtx::events::msg::CallCandidates> &event)
{
sendRoomEvent<mtx::events::msg::CallCandidates, mtx::events::EventType::CallCandidates>(
event);
}
void operator()(const mtx::events::RoomEvent<mtx::events::msg::CallAnswer> &event)
{
sendRoomEvent<mtx::events::msg::CallAnswer, mtx::events::EventType::CallAnswer>(event);
}
void operator()(const mtx::events::RoomEvent<mtx::events::msg::CallHangUp> &event)
{
sendRoomEvent<mtx::events::msg::CallHangUp, mtx::events::EventType::CallHangUp>(event);
}
void operator()(const mtx::events::RoomEvent<mtx::events::msg::KeyVerificationRequest> &msg)
{
sendRoomEvent<mtx::events::msg::KeyVerificationRequest,
mtx::events::EventType::RoomMessage>(msg);
}
void operator()(const mtx::events::RoomEvent<mtx::events::msg::KeyVerificationReady> &msg)
{
sendRoomEvent<mtx::events::msg::KeyVerificationReady,
mtx::events::EventType::KeyVerificationReady>(msg);
}
void operator()(const mtx::events::RoomEvent<mtx::events::msg::KeyVerificationStart> &msg)
{
sendRoomEvent<mtx::events::msg::KeyVerificationStart,
mtx::events::EventType::KeyVerificationStart>(msg);
}
void operator()(const mtx::events::RoomEvent<mtx::events::msg::KeyVerificationAccept> &msg)
{
sendRoomEvent<mtx::events::msg::KeyVerificationAccept,
mtx::events::EventType::KeyVerificationAccept>(msg);
}
void operator()(const mtx::events::RoomEvent<mtx::events::msg::KeyVerificationMac> &msg)
{
sendRoomEvent<mtx::events::msg::KeyVerificationMac,
mtx::events::EventType::KeyVerificationMac>(msg);
}
void operator()(const mtx::events::RoomEvent<mtx::events::msg::KeyVerificationKey> &msg)
{
sendRoomEvent<mtx::events::msg::KeyVerificationKey,
mtx::events::EventType::KeyVerificationKey>(msg);
}
void operator()(const mtx::events::RoomEvent<mtx::events::msg::KeyVerificationDone> &msg)
{
sendRoomEvent<mtx::events::msg::KeyVerificationDone,
mtx::events::EventType::KeyVerificationDone>(msg);
}
void operator()(const mtx::events::RoomEvent<mtx::events::msg::KeyVerificationCancel> &msg)
{
sendRoomEvent<mtx::events::msg::KeyVerificationCancel,
mtx::events::EventType::KeyVerificationCancel>(msg);
}
void operator()(mtx::events::Sticker msg)
{
msg.type = mtx::events::EventType::Sticker;
if (cache::isRoomEncrypted(model_->room_id_.toStdString())) {
model_->sendEncryptedMessage(msg, mtx::events::EventType::Sticker);
} else
emit model_->addPendingMessageToStore(msg);
}
TimelineModel *model_;
};
void
TimelineModel::addPendingMessage(mtx::events::collections::TimelineEvents event)
{
std::visit(
[](auto &msg) {
// gets overwritten for reactions and stickers in SendMessageVisitor
msg.type = mtx::events::EventType::RoomMessage;
msg.event_id = "m" + http::client()->generate_txn_id();
msg.sender = http::client()->user_id().to_string();
msg.origin_server_ts = QDateTime::currentMSecsSinceEpoch();
},
event);
std::visit(SendMessageVisitor{this}, event);
}
void
TimelineModel::openMedia(const QString &eventId)
{
cacheMedia(eventId, [](const QString &filename) {
QDesktopServices::openUrl(QUrl::fromLocalFile(filename));
});
}
bool
TimelineModel::saveMedia(const QString &eventId) const
{
mtx::events::collections::TimelineEvents *event = events.get(eventId.toStdString(), "");
if (!event)
return false;
QString mxcUrl = QString::fromStdString(mtx::accessors::url(*event));
QString originalFilename = QString::fromStdString(mtx::accessors::filename(*event));
QString mimeType = QString::fromStdString(mtx::accessors::mimetype(*event));
auto encryptionInfo = mtx::accessors::file(*event);
qml_mtx_events::EventType eventType = toRoomEventType(*event);
QString dialogTitle;
if (eventType == qml_mtx_events::EventType::ImageMessage) {
dialogTitle = tr("Save image");
} else if (eventType == qml_mtx_events::EventType::VideoMessage) {
dialogTitle = tr("Save video");
} else if (eventType == qml_mtx_events::EventType::AudioMessage) {
dialogTitle = tr("Save audio");
} else {
dialogTitle = tr("Save file");
}
const QString filterString = QMimeDatabase().mimeTypeForName(mimeType).filterString();
const QString downloadsFolder =
QStandardPaths::writableLocation(QStandardPaths::DownloadLocation);
const QString openLocation = downloadsFolder + "/" + originalFilename;
const QString filename =
QFileDialog::getSaveFileName(nullptr, dialogTitle, openLocation, filterString);
if (filename.isEmpty())
return false;
const auto url = mxcUrl.toStdString();
http::client()->download(url,
[filename, url, encryptionInfo](const std::string &data,
const std::string &,
const std::string &,
mtx::http::RequestErr err) {
if (err) {
nhlog::net()->warn("failed to retrieve image {}: {} {}",
url,
err->matrix_error.error,
static_cast<int>(err->status_code));
return;
}
try {
auto temp = data;
if (encryptionInfo)
temp = mtx::crypto::to_string(
mtx::crypto::decrypt_file(temp, encryptionInfo.value()));
QFile file(filename);
if (!file.open(QIODevice::WriteOnly))
return;
file.write(QByteArray(temp.data(), (int)temp.size()));
file.close();
return;
} catch (const std::exception &e) {
nhlog::ui()->warn("Error while saving file to: {}", e.what());
}
});
return true;
}
void
TimelineModel::cacheMedia(const QString &eventId,
const std::function<void(const QString)> &callback)
{
mtx::events::collections::TimelineEvents *event = events.get(eventId.toStdString(), "");
if (!event)
return;
QString mxcUrl = QString::fromStdString(mtx::accessors::url(*event));
QString mimeType = QString::fromStdString(mtx::accessors::mimetype(*event));
auto encryptionInfo = mtx::accessors::file(*event);
// If the message is a link to a non mxcUrl, don't download it
if (!mxcUrl.startsWith(QLatin1String("mxc://"))) {
emit mediaCached(mxcUrl, mxcUrl);
return;
}
QString suffix = QMimeDatabase().mimeTypeForName(mimeType).preferredSuffix();
const auto url = mxcUrl.toStdString();
const auto name = QString(mxcUrl).remove(QStringLiteral("mxc://"));
QFileInfo filename(
QStringLiteral("%1/media_cache/%2.%3")
.arg(QStandardPaths::writableLocation(QStandardPaths::CacheLocation), name, suffix));
if (QDir::cleanPath(name) != name) {
nhlog::net()->warn("mxcUrl '{}' is not safe, not downloading file", url);
return;
}
QDir().mkpath(filename.path());
if (filename.isReadable()) {
#if defined(Q_OS_WIN)
emit mediaCached(mxcUrl, filename.filePath());
#else
emit mediaCached(mxcUrl, "file://" + filename.filePath());
#endif
if (callback) {
callback(filename.filePath());
}
return;
}
http::client()->download(
url,
[this, callback, mxcUrl, filename, url, encryptionInfo](const std::string &data,
const std::string &,
const std::string &,
mtx::http::RequestErr err) {
if (err) {
nhlog::net()->warn("failed to retrieve image {}: {} {}",
url,
err->matrix_error.error,
static_cast<int>(err->status_code));
return;
}
try {
auto temp = data;
if (encryptionInfo)
temp =
mtx::crypto::to_string(mtx::crypto::decrypt_file(temp, encryptionInfo.value()));
QFile file(filename.filePath());
if (!file.open(QIODevice::WriteOnly))
return;
file.write(QByteArray(temp.data(), (int)temp.size()));
file.close();
if (callback) {
callback(filename.filePath());
}
} catch (const std::exception &e) {
nhlog::ui()->warn("Error while saving file to: {}", e.what());
}
#if defined(Q_OS_WIN)
emit mediaCached(mxcUrl, filename.filePath());
#else
emit mediaCached(mxcUrl, "file://" + filename.filePath());
#endif
});
}
void
TimelineModel::cacheMedia(const QString &eventId)
{
cacheMedia(eventId, nullptr);
}
void
TimelineModel::showEvent(QString eventId)
{
using namespace std::chrono_literals;
// Direct to eventId
if (eventId[0] == '$') {
int idx = idToIndex(eventId);
if (idx == -1) {
nhlog::ui()->warn("Scrolling to event id {}, failed - no known index",
eventId.toStdString());
return;
}
eventIdToShow = eventId;
emit scrollTargetChanged();
showEventTimer.start(50ms);
return;
}
// to message index
eventId = indexToId(eventId.toInt());
eventIdToShow = eventId;
emit scrollTargetChanged();
showEventTimer.start(50ms);
return;
}
void
TimelineModel::eventShown()
{
eventIdToShow.clear();
emit scrollTargetChanged();
}
QString
TimelineModel::scrollTarget() const
{
return eventIdToShow;
}
void
TimelineModel::scrollTimerEvent()
{
if (eventIdToShow.isEmpty() || showEventTimerCounter > 3) {
showEventTimer.stop();
showEventTimerCounter = 0;
} else {
emit scrollToIndex(idToIndex(eventIdToShow));
showEventTimerCounter++;
}
}
void
TimelineModel::requestKeyForEvent(const QString &id)
{
auto encrypted_event = events.get(id.toStdString(), "", false);
if (encrypted_event) {
if (auto ev = std::get_if<mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>>(
encrypted_event))
events.requestSession(*ev, true);
}
}
void
TimelineModel::copyLinkToEvent(const QString &eventId) const
{
QStringList vias;
auto alias = cache::client()->getRoomAliases(room_id_.toStdString());
QString room;
if (alias) {
room = QString::fromStdString(alias->alias);
if (room.isEmpty() && !alias->alt_aliases.empty()) {
room = QString::fromStdString(alias->alt_aliases.front());
}
}
if (room.isEmpty())
room = room_id_;
vias.push_back(QStringLiteral("via=%1").arg(QString(
QUrl::toPercentEncoding(QString::fromStdString(http::client()->user_id().hostname())))));
auto members = cache::getMembers(room_id_.toStdString(), 0, 100);
for (const auto &m : members) {
if (vias.size() >= 4)
break;
auto user_id = mtx::identifiers::parse<mtx::identifiers::User>(m.user_id.toStdString());
QString server = QStringLiteral("via=%1").arg(
QString(QUrl::toPercentEncoding(QString::fromStdString(user_id.hostname()))));
if (!vias.contains(server))
vias.push_back(server);
}
auto link = QStringLiteral("https://matrix.to/#/%1/%2?%3")
.arg(QString(QUrl::toPercentEncoding(room)),
QString(QUrl::toPercentEncoding(eventId)),
vias.join('&'));
QGuiApplication::clipboard()->setText(link);
}
QString
TimelineModel::formatTypingUsers(const std::vector<QString> &users, const QColor &bg)
{
QString temp =
tr("%1 and %2 are typing.",
"Multiple users are typing. First argument is a comma separated list of potentially "
"multiple users. Second argument is the last user of that list. (If only one user is "
"typing, %1 is empty. You should still use it in your string though to silence Qt "
"warnings.)",
(int)users.size());
if (users.empty()) {
return {};
}
QStringList uidWithoutLast;
auto formatUser = [this, bg](const QString &user_id) -> QString {
auto uncoloredUsername = utils::replaceEmoji(displayName(user_id));
QString prefix =
QStringLiteral("<font color=\"%1\">").arg(manager_->userColor(user_id, bg).name());
// color only parts that don't have a font already specified
QString coloredUsername;
int index = 0;
do {
auto startIndex = uncoloredUsername.indexOf(QLatin1String("<font"), index);
if (startIndex - index != 0)
coloredUsername +=
prefix + uncoloredUsername.mid(index, startIndex > 0 ? startIndex - index : -1) +
QStringLiteral("</font>");
auto endIndex = uncoloredUsername.indexOf(QLatin1String("</font>"), startIndex);
if (endIndex > 0)
endIndex += sizeof("</font>") - 1;
if (endIndex - startIndex != 0)
coloredUsername +=
QStringView(uncoloredUsername).mid(startIndex, endIndex - startIndex);
index = endIndex;
} while (index > 0 && index < uncoloredUsername.size());
return coloredUsername;
};
uidWithoutLast.reserve(static_cast<int>(users.size()));
for (size_t i = 0; i + 1 < users.size(); i++) {
uidWithoutLast.append(formatUser(users[i]));
}
return temp.arg(uidWithoutLast.join(QStringLiteral(", ")), formatUser(users.back()));
}
QString
TimelineModel::formatJoinRuleEvent(const QString &id)
{
mtx::events::collections::TimelineEvents *e = events.get(id.toStdString(), "");
if (!e)
return {};
auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::JoinRules>>(e);
if (!event)
return {};
QString user = QString::fromStdString(event->sender);
QString name = utils::replaceEmoji(displayName(user));
switch (event->content.join_rule) {
case mtx::events::state::JoinRule::Public:
return tr("%1 opened the room to the public.").arg(name);
case mtx::events::state::JoinRule::Invite:
return tr("%1 made this room require and invitation to join.").arg(name);
case mtx::events::state::JoinRule::Knock:
return tr("%1 allowed to join this room by knocking.").arg(name);
case mtx::events::state::JoinRule::Restricted: {
QStringList rooms;
for (const auto &r : event->content.allow) {
if (r.type == mtx::events::state::JoinAllowanceType::RoomMembership)
rooms.push_back(QString::fromStdString(r.room_id));
}
return tr("%1 allowed members of the following rooms to automatically join this "
"room: %2")
.arg(name, rooms.join(QStringLiteral(", ")));
}
default:
// Currently, knock and private are reserved keywords and not implemented in Matrix.
return {};
}
}
QString
TimelineModel::formatGuestAccessEvent(const QString &id)
{
mtx::events::collections::TimelineEvents *e = events.get(id.toStdString(), "");
if (!e)
return {};
auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::GuestAccess>>(e);
if (!event)
return {};
QString user = QString::fromStdString(event->sender);
QString name = utils::replaceEmoji(displayName(user));
switch (event->content.guest_access) {
case mtx::events::state::AccessState::CanJoin:
return tr("%1 made the room open to guests.").arg(name);
case mtx::events::state::AccessState::Forbidden:
return tr("%1 has closed the room to guest access.").arg(name);
default:
return {};
}
}
QString
TimelineModel::formatHistoryVisibilityEvent(const QString &id)
{
mtx::events::collections::TimelineEvents *e = events.get(id.toStdString(), "");
if (!e)
return {};
auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::HistoryVisibility>>(e);
if (!event)
return {};
QString user = QString::fromStdString(event->sender);
QString name = utils::replaceEmoji(displayName(user));
switch (event->content.history_visibility) {
case mtx::events::state::Visibility::WorldReadable:
return tr("%1 made the room history world readable. Events may be now read by "
"non-joined people.")
.arg(name);
case mtx::events::state::Visibility::Shared:
return tr("%1 set the room history visible to members from this point on.").arg(name);
case mtx::events::state::Visibility::Invited:
return tr("%1 set the room history visible to members since they were invited.").arg(name);
case mtx::events::state::Visibility::Joined:
return tr("%1 set the room history visible to members since they joined the room.")
.arg(name);
default:
return {};
}
}
QString
TimelineModel::formatPowerLevelEvent(const QString &id)
{
mtx::events::collections::TimelineEvents *e = events.get(id.toStdString(), "");
if (!e)
return {};
auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::PowerLevels>>(e);
if (!event)
return QString();
mtx::events::StateEvent<mtx::events::state::PowerLevels> *prevEvent = nullptr;
if (!event->unsigned_data.replaces_state.empty()) {
auto tempPrevEvent = events.get(event->unsigned_data.replaces_state, event->event_id);
if (tempPrevEvent) {
prevEvent =
std::get_if<mtx::events::StateEvent<mtx::events::state::PowerLevels>>(tempPrevEvent);
}
}
QString user = QString::fromStdString(event->sender);
QString sender_name = utils::replaceEmoji(displayName(user));
// Get the rooms levels for redactions and powerlevel changes to determine "Administrator" and
// "Moderator" powerlevels.
auto administrator_power_level = event->content.state_level("m.room.power_levels");
auto moderator_power_level = event->content.redact;
auto default_powerlevel = event->content.users_default;
if (!prevEvent)
return tr("%1 has changed the room's permissions.").arg(sender_name);
auto calc_affected = [&event,
&prevEvent](int64_t newPowerlevelSetting) -> std::pair<QStringList, int> {
QStringList affected{};
auto numberOfAffected = 0;
// We do only compare to people with explicit PL. Usually others are not going to be
// affected either way and this is cheaper to iterate over.
for (auto const &[mxid, currentPowerlevel] : event->content.users) {
if (currentPowerlevel == newPowerlevelSetting &&
prevEvent->content.user_level(mxid) < newPowerlevelSetting) {
numberOfAffected++;
if (numberOfAffected <= 2) {
affected.push_back(QString::fromStdString(mxid));
}
}
}
return {affected, numberOfAffected};
};
QStringList resultingMessage{};
// These affect only a few people. Therefor we can print who is affected.
if (event->content.kick != prevEvent->content.kick) {
auto default_message = tr("%1 has changed the room's kick powerlevel from %2 to %3.")
.arg(sender_name)
.arg(prevEvent->content.kick)
.arg(event->content.kick);
// We only calculate affected users if we change to a level above the default users PL
// to not accidentally have a DoS vector
if (event->content.kick > default_powerlevel) {
auto [affected, number_of_affected] = calc_affected(event->content.kick);
if (number_of_affected != 0) {
auto true_affected_rest = number_of_affected - affected.size();
if (number_of_affected > 1) {
resultingMessage.append(
default_message + QStringLiteral(" ") +
tr("%n member(s) can now kick room members.", nullptr, true_affected_rest));
} else if (number_of_affected == 1) {
resultingMessage.append(
default_message + QStringLiteral(" ") +
tr("%1 can now kick room members.")
.arg(utils::replaceEmoji(displayName(affected.at(0)))));
}
} else {
resultingMessage.append(default_message);
}
} else {
resultingMessage.append(default_message);
}
}
if (event->content.redact != prevEvent->content.redact) {
auto default_message = tr("%1 has changed the room's redact powerlevel from %2 to %3.")
.arg(sender_name)
.arg(prevEvent->content.redact)
.arg(event->content.redact);
// We only calculate affected users if we change to a level above the default users PL
// to not accidentally have a DoS vector
if (event->content.redact > default_powerlevel) {
auto [affected, number_of_affected] = calc_affected(event->content.redact);
if (number_of_affected != 0) {
auto true_affected_rest = number_of_affected - affected.size();
if (number_of_affected > 1) {
resultingMessage.append(default_message + QStringLiteral(" ") +
tr("%n member(s) can now redact room messages.",
nullptr,
true_affected_rest));
} else if (number_of_affected == 1) {
resultingMessage.append(
default_message + QStringLiteral(" ") +
tr("%1 can now redact room messages.")
.arg(utils::replaceEmoji(displayName(affected.at(0)))));
}
} else {
resultingMessage.append(default_message);
}
} else {
resultingMessage.append(default_message);
}
}
if (event->content.ban != prevEvent->content.ban) {
auto default_message = tr("%1 has changed the room's ban powerlevel from %2 to %3.")
.arg(sender_name)
.arg(prevEvent->content.ban)
.arg(event->content.ban);
// We only calculate affected users if we change to a level above the default users PL
// to not accidentally have a DoS vector
if (event->content.ban > default_powerlevel) {
auto [affected, number_of_affected] = calc_affected(event->content.ban);
if (number_of_affected != 0) {
auto true_affected_rest = number_of_affected - affected.size();
if (number_of_affected > 1) {
resultingMessage.append(
default_message + QStringLiteral(" ") +
tr("%n member(s) can now ban room members.", nullptr, true_affected_rest));
} else if (number_of_affected == 1) {
resultingMessage.append(
default_message + QStringLiteral(" ") +
tr("%1 can now ban room members.")
.arg(utils::replaceEmoji(displayName(affected.at(0)))));
}
} else {
resultingMessage.append(default_message);
}
} else {
resultingMessage.append(default_message);
}
}
if (event->content.state_default != prevEvent->content.state_default) {
auto default_message =
tr("%1 has changed the room's state_default powerlevel from %2 to %3.")
.arg(sender_name)
.arg(prevEvent->content.state_default)
.arg(event->content.state_default);
// We only calculate affected users if we change to a level above the default users PL
// to not accidentally have a DoS vector
if (event->content.state_default > default_powerlevel) {
auto [affected, number_of_affected] = calc_affected(event->content.kick);
if (number_of_affected != 0) {
auto true_affected_rest = number_of_affected - affected.size();
if (number_of_affected > 1) {
resultingMessage.append(
default_message + QStringLiteral(" ") +
tr("%n member(s) can now send state events.", nullptr, true_affected_rest));
} else if (number_of_affected == 1) {
resultingMessage.append(
default_message + QStringLiteral(" ") +
tr("%1 can now send state events.")
.arg(utils::replaceEmoji(displayName(affected.at(0)))));
}
} else {
resultingMessage.append(default_message);
}
} else {
resultingMessage.append(default_message);
}
}
// These affect potentially the whole room. We there for do not calculate who gets affected
// by this to prevent huge lists of people.
if (event->content.invite != prevEvent->content.invite) {
resultingMessage.append(tr("%1 has changed the room's invite powerlevel from %2 to %3.")
.arg(sender_name,
QString::number(prevEvent->content.invite),
QString::number(event->content.invite)));
}
if (event->content.events_default != prevEvent->content.events_default) {
if ((event->content.events_default > default_powerlevel) &&
prevEvent->content.events_default <= default_powerlevel) {
resultingMessage.append(
tr("%1 has changed the room's events_default powerlevel from %2 to %3. New "
"users can now not send any events.")
.arg(sender_name,
QString::number(prevEvent->content.events_default),
QString::number(event->content.events_default)));
} else if ((event->content.events_default < prevEvent->content.events_default) &&
(event->content.events_default < default_powerlevel) &&
(prevEvent->content.events_default > default_powerlevel)) {
resultingMessage.append(
tr("%1 has changed the room's events_default powerlevel from %2 to %3. New "
"users can now send events that are not otherwise restricted.")
.arg(sender_name,
QString::number(prevEvent->content.events_default),
QString::number(event->content.events_default)));
} else {
resultingMessage.append(
tr("%1 has changed the room's events_default powerlevel from %2 to %3.")
.arg(sender_name,
QString::number(prevEvent->content.events_default),
QString::number(event->content.events_default)));
}
}
// Compare if a Powerlevel of a user changed
for (auto const &[mxid, powerlevel] : event->content.users) {
auto nameOfChangedUser = utils::replaceEmoji(displayName(QString::fromStdString(mxid)));
if (prevEvent->content.user_level(mxid) != powerlevel) {
if (powerlevel >= administrator_power_level) {
resultingMessage.append(tr("%1 has made %2 an administrator of this room.")
.arg(sender_name, nameOfChangedUser));
} else if (powerlevel >= moderator_power_level &&
powerlevel > prevEvent->content.user_level(mxid)) {
resultingMessage.append(tr("%1 has made %2 a moderator of this room.")
.arg(sender_name, nameOfChangedUser));
} else if (powerlevel >= moderator_power_level &&
powerlevel < prevEvent->content.user_level(mxid)) {
resultingMessage.append(tr("%1 has downgraded %2 to moderator of this room.")
.arg(sender_name, nameOfChangedUser));
} else {
resultingMessage.append(tr("%1 has changed the powerlevel of %2 from %3 to %4.")
.arg(sender_name,
nameOfChangedUser,
QString::number(prevEvent->content.user_level(mxid)),
QString::number(powerlevel)));
}
}
}
// Handle added/removed/changed event type
for (auto const &[event_type, powerlevel] : event->content.events) {
auto prev_not_present =
prevEvent->content.events.find(event_type) == prevEvent->content.events.end();
if (prev_not_present || prevEvent->content.events.at(event_type) != powerlevel) {
if (powerlevel >= administrator_power_level) {
resultingMessage.append(tr("%1 allowed only administrators to send \"%2\".")
.arg(sender_name, QString::fromStdString(event_type)));
} else if (powerlevel >= moderator_power_level) {
resultingMessage.append(tr("%1 allowed only moderators to send \"%2\".")
.arg(sender_name, QString::fromStdString(event_type)));
} else if (powerlevel == default_powerlevel) {
resultingMessage.append(tr("%1 allowed everyone to send \"%2\".")
.arg(sender_name, QString::fromStdString(event_type)));
} else if (prev_not_present) {
resultingMessage.append(
tr("%1 has changed the powerlevel of event type \"%2\" from the default to %3.")
.arg(sender_name,
QString::fromStdString(event_type),
QString::number(powerlevel)));
} else {
resultingMessage.append(
tr("%1 has changed the powerlevel of event type \"%2\" from %3 to %4.")
.arg(sender_name,
QString::fromStdString(event_type),
QString::number(prevEvent->content.events.at(event_type)),
QString::number(powerlevel)));
}
}
}
if (!resultingMessage.isEmpty()) {
return resultingMessage.join("<br/>");
} else {
return tr("%1 has changed the room's permissions.").arg(sender_name);
}
}
QString
TimelineModel::formatImagePackEvent(const QString &id)
{
mtx::events::collections::TimelineEvents *e = events.get(id.toStdString(), "");
if (!e)
return {};
auto event = std::get_if<mtx::events::StateEvent<mtx::events::msc2545::ImagePack>>(e);
if (!event)
return {};
mtx::events::StateEvent<mtx::events::msc2545::ImagePack> *prevEvent = nullptr;
if (!event->unsigned_data.replaces_state.empty()) {
auto tempPrevEvent = events.get(event->unsigned_data.replaces_state, event->event_id);
if (tempPrevEvent) {
prevEvent =
std::get_if<mtx::events::StateEvent<mtx::events::msc2545::ImagePack>>(tempPrevEvent);
}
}
const auto &newImages = event->content.images;
const auto oldImages = prevEvent ? prevEvent->content.images : decltype(newImages){};
auto ascent = QFontMetrics(UserSettings::instance()->font()).ascent();
auto calcChange = [ascent](const std::map<std::string, mtx::events::msc2545::PackImage> &newI,
const std::map<std::string, mtx::events::msc2545::PackImage> &oldI) {
QStringList added;
for (const auto &[shortcode, img] : newI) {
if (!oldI.count(shortcode))
added.push_back(QStringLiteral("<img data-mx-emoticon height=%1 src=\"%2\"> (~%3)")
.arg(ascent)
.arg(QString::fromStdString(img.url)
.replace("mxc://", "image://mxcImage/")
.toHtmlEscaped(),
QString::fromStdString(shortcode)));
}
return added;
};
auto added = calcChange(newImages, oldImages);
auto removed = calcChange(oldImages, newImages);
auto sender = utils::replaceEmoji(displayName(QString::fromStdString(event->sender)));
const auto packId = [&event]() -> QString {
if (!event->content.pack->display_name.empty()) {
return event->content.pack->display_name.c_str();
} else if (!event->state_key.empty()) {
return event->state_key.c_str();
}
return tr("(empty)");
}();
QString msg;
if (!removed.isEmpty()) {
msg = tr("%1 removed the following images from the pack %2:<br>%3")
.arg(sender, packId, removed.join(", "));
}
if (!added.isEmpty()) {
if (!msg.isEmpty())
msg += "<br>";
msg += tr("%1 added the following images to the pack %2:<br>%3")
.arg(sender, packId, added.join(", "));
}
if (msg.isEmpty())
return tr("%1 changed the sticker and emotes in this room.").arg(sender);
else
return msg;
}
QVariantMap
TimelineModel::formatRedactedEvent(const QString &id)
{
QVariantMap pair{{"first", ""}, {"second", ""}};
mtx::events::collections::TimelineEvents *e = events.get(id.toStdString(), "");
if (!e)
return pair;
auto event = std::get_if<mtx::events::RoomEvent<mtx::events::msg::Redacted>>(e);
if (!event)
return pair;
QString dateTime = QDateTime::fromMSecsSinceEpoch(event->origin_server_ts).toString();
QString reason = QLatin1String("");
auto because = event->unsigned_data.redacted_because;
// User info about who actually sent the redacted event.
QString redactedUser;
QString redactedName;
if (because.has_value()) {
redactedUser = QString::fromStdString(because->sender).toHtmlEscaped();
redactedName = utils::replaceEmoji(displayName(redactedUser));
reason = QString::fromStdString(because->content.reason).toHtmlEscaped();
}
if (reason.isEmpty()) {
pair[QStringLiteral("first")] = tr("Removed by %1").arg(redactedName);
pair[QStringLiteral("second")] =
tr("%1 (%2) removed this message at %3").arg(redactedName, redactedUser, dateTime);
} else {
pair[QStringLiteral("first")] = tr("Removed by %1 because: %2").arg(redactedName, reason);
pair[QStringLiteral("second")] = tr("%1 (%2) removed this message at %3\nReason: %4")
.arg(redactedName, redactedUser, dateTime, reason);
}
return pair;
}
void
TimelineModel::acceptKnock(const QString &id)
{
mtx::events::collections::TimelineEvents *e = events.get(id.toStdString(), "");
if (!e)
return;
auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::Member>>(e);
if (!event)
return;
if (!permissions_.canInvite())
return;
if (cache::isRoomMember(event->state_key, room_id_.toStdString()))
return;
using namespace mtx::events::state;
if (event->content.membership != Membership::Knock)
return;
ChatPage::instance()->inviteUser(
room_id_, QString::fromStdString(event->state_key), QLatin1String(""));
}
bool
TimelineModel::showAcceptKnockButton(const QString &id)
{
mtx::events::collections::TimelineEvents *e = events.get(id.toStdString(), "");
if (!e)
return false;
auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::Member>>(e);
if (!event)
return false;
if (!permissions_.canInvite())
return false;
if (cache::isRoomMember(event->state_key, room_id_.toStdString()))
return false;
using namespace mtx::events::state;
return event->content.membership == Membership::Knock;
}
QString
TimelineModel::formatMemberEvent(const QString &id)
{
mtx::events::collections::TimelineEvents *e = events.get(id.toStdString(), "");
if (!e)
return {};
auto event = std::get_if<mtx::events::StateEvent<mtx::events::state::Member>>(e);
if (!event)
return {};
mtx::events::StateEvent<mtx::events::state::Member> *prevEvent = nullptr;
if (!event->unsigned_data.replaces_state.empty()) {
auto tempPrevEvent = events.get(event->unsigned_data.replaces_state, event->event_id);
if (tempPrevEvent) {
prevEvent =
std::get_if<mtx::events::StateEvent<mtx::events::state::Member>>(tempPrevEvent);
}
}
QString user = QString::fromStdString(event->state_key);
QString name = utils::replaceEmoji(displayName(user));
QString rendered;
QString sender = QString::fromStdString(event->sender);
QString senderName = utils::replaceEmoji(displayName(sender));
// see table https://matrix.org/docs/spec/client_server/latest#m-room-member
using namespace mtx::events::state;
switch (event->content.membership) {
case Membership::Invite:
rendered = tr("%1 invited %2.").arg(senderName, name);
break;
case Membership::Join:
if (prevEvent && prevEvent->content.membership == Membership::Join) {
QString oldName = utils::replaceEmoji(
QString::fromStdString(prevEvent->content.display_name).toHtmlEscaped());
bool displayNameChanged =
prevEvent->content.display_name != event->content.display_name;
bool avatarChanged = prevEvent->content.avatar_url != event->content.avatar_url;
if (displayNameChanged && avatarChanged)
rendered = tr("%1 has changed their avatar and changed their "
"display name to %2.")
.arg(oldName, name);
else if (displayNameChanged)
rendered = tr("%1 has changed their display name to %2.").arg(oldName, name);
else if (avatarChanged)
rendered = tr("%1 changed their avatar.").arg(name);
else
rendered = tr("%1 changed some profile info.").arg(name);
// the case of nothing changed but join follows join shouldn't happen, so
// just show it as join
} else {
if (event->content.join_authorised_via_users_server.empty())
rendered = tr("%1 joined.").arg(name);
else
rendered =
tr("%1 joined via authorisation from %2's server.")
.arg(name,
QString::fromStdString(event->content.join_authorised_via_users_server));
}
break;
case Membership::Leave:
if (!prevEvent || prevEvent->content.membership == Membership::Join) {
if (event->state_key == event->sender)
rendered = tr("%1 left the room.").arg(name);
else
rendered = tr("%2 kicked %1.").arg(name, senderName);
} else if (prevEvent->content.membership == Membership::Invite) {
if (event->state_key == event->sender)
rendered = tr("%1 rejected their invite.").arg(name);
else
rendered = tr("%2 revoked the invite to %1.").arg(name, senderName);
} else if (prevEvent->content.membership == Membership::Ban) {
rendered = tr("%2 unbanned %1.").arg(name, senderName);
} else if (prevEvent->content.membership == Membership::Knock) {
if (event->state_key == event->sender)
rendered = tr("%1 redacted their knock.").arg(name);
else
rendered = tr("%2 rejected the knock from %1.").arg(name, senderName);
} else
return tr("%1 left after having already left!",
"This is a leave event after the user already left and shouldn't "
"happen apart from state resets")
.arg(name);
break;
case Membership::Ban:
rendered = tr("%1 banned %2").arg(senderName, name);
break;
case Membership::Knock:
rendered = tr("%1 knocked.").arg(name);
break;
}
if (event->content.reason != "") {
rendered += " " + tr("Reason: %1").arg(QString::fromStdString(event->content.reason));
}
return rendered;
}
void
TimelineModel::setEdit(const QString &newEdit)
{
if (newEdit.isEmpty()) {
resetEdit();
return;
}
if (edit_.isEmpty()) {
this->textBeforeEdit = input()->text();
this->replyBeforeEdit = reply_;
nhlog::ui()->debug("Stored: {}", textBeforeEdit.toStdString());
}
if (edit_ != newEdit) {
auto ev = events.get(newEdit.toStdString(), "");
if (ev && mtx::accessors::sender(*ev) == http::client()->user_id().to_string()) {
auto e = *ev;
setReply(QString::fromStdString(mtx::accessors::relations(e).reply_to().value_or("")));
auto msgType = mtx::accessors::msg_type(e);
if (msgType == mtx::events::MessageType::Text ||
msgType == mtx::events::MessageType::Notice ||
msgType == mtx::events::MessageType::Emote) {
auto relInfo = relatedInfo(newEdit);
auto editText = relInfo.quoted_body;
if (!relInfo.quoted_formatted_body.isEmpty()) {
auto matches =
conf::strings::matrixToLink.globalMatch(relInfo.quoted_formatted_body);
std::map<QString, QString> reverseNameMapping;
while (matches.hasNext()) {
auto m = matches.next();
reverseNameMapping[m.captured(2)] = m.captured(1);
}
for (const auto &[user, link] : reverseNameMapping) {
// TODO(Nico): html unescape the user name
editText.replace(user, QStringLiteral("[%1](%2)").arg(user, link));
}
}
if (msgType == mtx::events::MessageType::Emote)
input()->setText("/me " + editText);
else
input()->setText(editText);
} else {
input()->setText(QLatin1String(""));
}
edit_ = newEdit;
} else {
resetReply();
input()->setText(QLatin1String(""));
edit_ = QLatin1String("");
}
emit editChanged(edit_);
}
}
void
TimelineModel::resetEdit()
{
if (!edit_.isEmpty()) {
edit_ = QLatin1String("");
emit editChanged(edit_);
nhlog::ui()->debug("Restoring: {}", textBeforeEdit.toStdString());
input()->setText(textBeforeEdit);
textBeforeEdit.clear();
if (replyBeforeEdit.isEmpty())
resetReply();
else
setReply(replyBeforeEdit);
replyBeforeEdit.clear();
}
}
void
TimelineModel::resetState()
{
http::client()->get_state(
room_id_.toStdString(),
[this](const mtx::responses::StateEvents &events_, mtx::http::RequestErr e) {
if (e) {
nhlog::net()->error("Failed to retrieve current room state: {}", *e);
return;
}
emit newState(events_);
});
}
QString
TimelineModel::roomName() const
{
auto info = cache::getRoomInfo({room_id_.toStdString()});
if (!info.count(room_id_))
return {};
else
return utils::replaceEmoji(QString::fromStdString(info[room_id_].name).toHtmlEscaped());
}
QString
TimelineModel::plainRoomName() const
{
auto info = cache::getRoomInfo({room_id_.toStdString()});
if (!info.count(room_id_))
return {};
else
return QString::fromStdString(info[room_id_].name);
}
QString
TimelineModel::roomAvatarUrl() const
{
auto info = cache::getRoomInfo({room_id_.toStdString()});
if (!info.count(room_id_))
return {};
else
return QString::fromStdString(info[room_id_].avatar_url);
}
QString
TimelineModel::roomTopic() const
{
auto info = cache::getRoomInfo({room_id_.toStdString()});
if (!info.count(room_id_))
return {};
else
return utils::replaceEmoji(
utils::linkifyMessage(QString::fromStdString(info[room_id_].topic).toHtmlEscaped()));
}
QStringList
TimelineModel::pinnedMessages() const
{
auto pinned =
cache::client()->getStateEvent<mtx::events::state::PinnedEvents>(room_id_.toStdString());
if (!pinned || pinned->content.pinned.empty())
return {};
QStringList list;
list.reserve(pinned->content.pinned.size());
for (const auto &p : pinned->content.pinned)
list.push_back(QString::fromStdString(p));
return list;
}
QStringList
TimelineModel::widgetLinks() const
{
auto evs =
cache::client()->getStateEventsWithType<mtx::events::state::Widget>(room_id_.toStdString());
auto evs2 = cache::client()->getStateEventsWithType<mtx::events::state::Widget>(
room_id_.toStdString(), mtx::events::EventType::Widget);
evs.insert(
evs.end(), std::make_move_iterator(evs2.begin()), std::make_move_iterator(evs2.end()));
if (evs.empty())
return {};
QStringList list;
auto user = utils::localUser();
auto av = QUrl::toPercentEncoding(
QString::fromStdString(http::client()->mxc_to_download_url(avatarUrl(user).toStdString())));
auto disp = QUrl::toPercentEncoding(displayName(user));
auto theme = UserSettings::instance()->theme();
if (theme == QStringLiteral("system"))
theme.clear();
user = QUrl::toPercentEncoding(user);
list.reserve(evs.size());
for (const auto &p : evs) {
auto url = QString::fromStdString(p.content.url);
for (const auto &[k, v] : p.content.data)
url.replace("$" + QString::fromStdString(k),
QUrl::toPercentEncoding(QString::fromStdString(v)));
url.replace("$matrix_user_id", user);
url.replace("$matrix_room_id", QUrl::toPercentEncoding(room_id_));
url.replace("$matrix_display_name", disp);
url.replace("$matrix_avatar_url", av);
url.replace("$matrix_widget_id",
QUrl::toPercentEncoding(QString::fromStdString(p.content.id)));
// url.replace("$matrix_client_theme", theme);
url.replace("$org.matrix.msc2873.client_theme", theme);
url.replace("$org.matrix.msc2873.client_id", "im.nheko");
// compat with some widgets, i.e. FOSDEM
url.replace("$theme", theme);
url = QUrl::toPercentEncoding(url, "/:@?#&=%");
list.push_back(
QLatin1String("<a href='%1'>%2</a>")
.arg(url,
QString::fromStdString(p.content.name.empty() ? p.state_key : p.content.name)
.toHtmlEscaped()));
}
return list;
}
crypto::Trust
TimelineModel::trustlevel() const
{
if (!isEncrypted_)
return crypto::Trust::Unverified;
return cache::client()->roomVerificationStatus(room_id_.toStdString());
}
int
TimelineModel::roomMemberCount() const
{
return (int)cache::client()->memberCount(room_id_.toStdString());
}
QString
TimelineModel::directChatOtherUserId() const
{
if (roomMemberCount() < 3) {
QString id;
for (const auto &member : cache::getMembers(room_id_.toStdString()))
if (member.user_id != UserSettings::instance()->userId())
id = member.user_id;
return id;
} else
return {};
}