mirror of
https://github.com/Nheko-Reborn/nheko.git
synced 2024-11-25 04:28:49 +03:00
9656304e24
There is no use case for those afaik and they do break our replacement in the frontend. Let's instead strip them out in the sanitization step, since there are no valid attributes defined for the del tag currenlty. In theory we could also strip out all attributes here, but that seems excessive for now. Fixes https://github.com/Nheko-Reborn/nheko/issues/1693
2082 lines
84 KiB
C++
2082 lines
84 KiB
C++
// SPDX-FileCopyrightText: Nheko Contributors
|
|
//
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
#include "Utils.h"
|
|
|
|
#include <array>
|
|
#include <cmath>
|
|
#include <unordered_set>
|
|
#include <variant>
|
|
|
|
#include <QApplication>
|
|
#include <QBuffer>
|
|
#include <QComboBox>
|
|
#include <QCryptographicHash>
|
|
#include <QGuiApplication>
|
|
#include <QImageReader>
|
|
#include <QProcessEnvironment>
|
|
#include <QRandomGenerator64>
|
|
#include <QScreen>
|
|
#include <QSettings>
|
|
#include <QStringBuilder>
|
|
#include <QTextBoundaryFinder>
|
|
#include <QTextDocument>
|
|
#include <QTimer>
|
|
#include <QWindow>
|
|
#include <QXmlStreamReader>
|
|
|
|
#include <nlohmann/json.hpp>
|
|
|
|
#include <cmark.h>
|
|
|
|
#include <mtx/responses/messages.hpp>
|
|
|
|
#include "Cache.h"
|
|
#include "Cache_p.h"
|
|
#include "ChatPage.h"
|
|
#include "Config.h"
|
|
#include "EventAccessors.h"
|
|
#include "Logging.h"
|
|
#include "MatrixClient.h"
|
|
#include "UserSettingsPage.h"
|
|
|
|
//! Match widgets/events with a description message.
|
|
namespace {
|
|
template<class T>
|
|
QString
|
|
messageDescription(const QString &username = QString(),
|
|
const QString &body = QString(),
|
|
const bool isLocal = false)
|
|
{
|
|
using Audio = mtx::events::RoomEvent<mtx::events::msg::Audio>;
|
|
using Emote = mtx::events::RoomEvent<mtx::events::msg::Emote>;
|
|
using File = mtx::events::RoomEvent<mtx::events::msg::File>;
|
|
using Image = mtx::events::RoomEvent<mtx::events::msg::Image>;
|
|
using Notice = mtx::events::RoomEvent<mtx::events::msg::Notice>;
|
|
using Sticker = mtx::events::Sticker;
|
|
using Text = mtx::events::RoomEvent<mtx::events::msg::Text>;
|
|
using Unknown = mtx::events::RoomEvent<mtx::events::msg::Unknown>;
|
|
using Video = mtx::events::RoomEvent<mtx::events::msg::Video>;
|
|
using ElementEffect = mtx::events::RoomEvent<mtx::events::msg::ElementEffect>;
|
|
using CallInvite = mtx::events::RoomEvent<mtx::events::voip::CallInvite>;
|
|
using CallAnswer = mtx::events::RoomEvent<mtx::events::voip::CallAnswer>;
|
|
using CallHangUp = mtx::events::RoomEvent<mtx::events::voip::CallHangUp>;
|
|
using CallReject = mtx::events::RoomEvent<mtx::events::voip::CallReject>;
|
|
using Encrypted = mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>;
|
|
|
|
if (std::is_same<T, Audio>::value) {
|
|
if (isLocal)
|
|
return QCoreApplication::translate("message-description sent:",
|
|
"You sent an audio clip");
|
|
else
|
|
return QCoreApplication::translate("message-description sent:", "%1 sent an audio clip")
|
|
.arg(username);
|
|
} else if (std::is_same<T, Image>::value) {
|
|
if (isLocal)
|
|
return QCoreApplication::translate("message-description sent:", "You sent an image");
|
|
else
|
|
return QCoreApplication::translate("message-description sent:", "%1 sent an image")
|
|
.arg(username);
|
|
} else if (std::is_same<T, File>::value) {
|
|
if (isLocal)
|
|
return QCoreApplication::translate("message-description sent:", "You sent a file");
|
|
else
|
|
return QCoreApplication::translate("message-description sent:", "%1 sent a file")
|
|
.arg(username);
|
|
} else if (std::is_same<T, Video>::value) {
|
|
if (isLocal)
|
|
return QCoreApplication::translate("message-description sent:", "You sent a video");
|
|
else
|
|
return QCoreApplication::translate("message-description sent:", "%1 sent a video")
|
|
.arg(username);
|
|
} else if (std::is_same<T, Sticker>::value) {
|
|
if (isLocal)
|
|
return QCoreApplication::translate("message-description sent:", "You sent a sticker");
|
|
else
|
|
return QCoreApplication::translate("message-description sent:", "%1 sent a sticker")
|
|
.arg(username);
|
|
} else if (std::is_same<T, Notice>::value) {
|
|
if (isLocal)
|
|
return QCoreApplication::translate("message-description sent:",
|
|
"You sent a notification");
|
|
else
|
|
return QCoreApplication::translate("message-description sent:",
|
|
"%1 sent a notification")
|
|
.arg(username);
|
|
} else if (std::is_same<T, Text>::value || std::is_same<T, Unknown>::value) {
|
|
if (isLocal)
|
|
return QCoreApplication::translate("message-description sent:", "You: %1").arg(body);
|
|
else
|
|
return QCoreApplication::translate("message-description sent:", "%1: %2")
|
|
.arg(username, body);
|
|
} else if (std::is_same<T, ElementEffect>::value) {
|
|
if (body.isEmpty()) {
|
|
// TODO: what is the best way to handle this?
|
|
if (isLocal)
|
|
return QCoreApplication::translate("message-description sent:",
|
|
"You sent a chat effect");
|
|
else
|
|
return QCoreApplication::translate("message-description sent:",
|
|
"%1 sent a chat effect")
|
|
.arg(username);
|
|
} else {
|
|
if (isLocal)
|
|
return QCoreApplication::translate("message-description sent:", "You: %1")
|
|
.arg(body);
|
|
else
|
|
return QCoreApplication::translate("message-description sent:", "%1: %2")
|
|
.arg(username, body);
|
|
}
|
|
} else if (std::is_same<T, Emote>::value) {
|
|
return QStringLiteral("* %1 %2").arg(username, body);
|
|
} else if (std::is_same<T, Encrypted>::value) {
|
|
if (isLocal)
|
|
return QCoreApplication::translate("message-description sent:",
|
|
"You sent an encrypted message");
|
|
else
|
|
return QCoreApplication::translate("message-description sent:",
|
|
"%1 sent an encrypted message")
|
|
.arg(username);
|
|
} else if (std::is_same<T, CallInvite>::value) {
|
|
if (isLocal)
|
|
return QCoreApplication::translate("message-description sent:", "You placed a call");
|
|
else
|
|
return QCoreApplication::translate("message-description sent:", "%1 placed a call")
|
|
.arg(username);
|
|
} else if (std::is_same<T, CallAnswer>::value) {
|
|
if (isLocal)
|
|
return QCoreApplication::translate("message-description sent:", "You answered a call");
|
|
else
|
|
return QCoreApplication::translate("message-description sent:", "%1 answered a call")
|
|
.arg(username);
|
|
} else if (std::is_same<T, CallHangUp>::value) {
|
|
if (isLocal)
|
|
return QCoreApplication::translate("message-description sent:", "You ended a call");
|
|
else
|
|
return QCoreApplication::translate("message-description sent:", "%1 ended a call")
|
|
.arg(username);
|
|
} else if (std::is_same<T, CallReject>::value) {
|
|
if (isLocal)
|
|
return QCoreApplication::translate("message-description sent:", "You rejected a call");
|
|
else
|
|
return QCoreApplication::translate("message-description sent:", "%1 rejected a call")
|
|
.arg(username);
|
|
} else {
|
|
return QCoreApplication::translate("utils", "Unknown Message Type");
|
|
}
|
|
}
|
|
}
|
|
|
|
template<class T, class Event>
|
|
static DescInfo
|
|
createDescriptionInfo(const Event &event, const QString &localUser, const QString &displayName)
|
|
{
|
|
const auto msg = std::get<T>(event);
|
|
const auto sender = QString::fromStdString(msg.sender);
|
|
|
|
const auto username = displayName;
|
|
const auto ts = QDateTime::fromMSecsSinceEpoch(msg.origin_server_ts);
|
|
auto body = mtx::accessors::body(event);
|
|
if (mtx::accessors::relations(event).reply_to())
|
|
body = utils::stripReplyFromBody(body);
|
|
|
|
return DescInfo{
|
|
QString::fromStdString(msg.event_id),
|
|
sender,
|
|
messageDescription<T>(username, QString::fromStdString(body), sender == localUser),
|
|
utils::descriptiveTime(ts),
|
|
msg.origin_server_ts,
|
|
ts};
|
|
}
|
|
|
|
std::string
|
|
utils::stripReplyFromBody(const std::string &bodyi)
|
|
{
|
|
QString body = QString::fromStdString(bodyi);
|
|
if (body.startsWith(QLatin1String("> <"))) {
|
|
auto segments = body.split('\n');
|
|
while (!segments.isEmpty() && segments.begin()->startsWith('>'))
|
|
segments.erase(segments.cbegin());
|
|
if (!segments.empty() && segments.first().isEmpty())
|
|
segments.erase(segments.cbegin());
|
|
body = segments.join('\n');
|
|
}
|
|
|
|
body.replace(QLatin1String("@room"), QString::fromUtf8("@\u2060room"));
|
|
return body.toStdString();
|
|
}
|
|
|
|
std::string
|
|
utils::stripReplyFromFormattedBody(const std::string &formatted_bodyi)
|
|
{
|
|
QString formatted_body = QString::fromStdString(formatted_bodyi);
|
|
static QRegularExpression replyRegex(QStringLiteral("<mx-reply>.*</mx-reply>"),
|
|
QRegularExpression::DotMatchesEverythingOption);
|
|
formatted_body.remove(replyRegex);
|
|
formatted_body.replace(QLatin1String("@room"), QString::fromUtf8("@\u2060room"));
|
|
return formatted_body.toStdString();
|
|
}
|
|
|
|
RelatedInfo
|
|
utils::stripReplyFallbacks(const mtx::events::collections::TimelineEvents &event,
|
|
std::string id,
|
|
QString room_id_)
|
|
{
|
|
RelatedInfo related = {};
|
|
related.quoted_user = QString::fromStdString(mtx::accessors::sender(event));
|
|
related.related_event = std::move(id);
|
|
related.type = mtx::accessors::msg_type(event);
|
|
|
|
// get body, strip reply fallback, then transform the event to text, if it is a media event
|
|
// etc
|
|
related.quoted_body = QString::fromStdString(mtx::accessors::body(event));
|
|
related.quoted_body =
|
|
QString::fromStdString(stripReplyFromBody(related.quoted_body.toStdString()));
|
|
related.quoted_body = utils::getQuoteBody(related);
|
|
|
|
// get quoted body and strip reply fallback
|
|
related.quoted_formatted_body = mtx::accessors::formattedBodyWithFallback(event);
|
|
related.quoted_formatted_body = QString::fromStdString(
|
|
stripReplyFromFormattedBody(related.quoted_formatted_body.toStdString()));
|
|
related.room = room_id_;
|
|
|
|
return related;
|
|
}
|
|
|
|
QString
|
|
utils::localUser()
|
|
{
|
|
return QString::fromStdString(http::client()->user_id().to_string());
|
|
}
|
|
|
|
bool
|
|
utils::codepointIsEmoji(uint code)
|
|
{
|
|
// TODO: Be more precise here.
|
|
return (code >= 0x2600 && code <= 0x27bf) || (code >= 0x2b00 && code <= 0x2bff) ||
|
|
(code >= 0x1f000 && code <= 0x1faff) || code == 0x200d || code == 0xfe0f ||
|
|
code == 0x231a || code == 0x231b || (code >= 0x23e9 && code <= 0x23ff) ||
|
|
code == 0x25aa || code == 0x25ab || code == 0x25b6 || code == 0x25c0 ||
|
|
(code >= 0x25fb && code <= 0x25fe) || (code >= 0x2460 && code <= 0x24ff);
|
|
}
|
|
|
|
QString
|
|
utils::replaceEmoji(const QString &body)
|
|
{
|
|
QString fmtBody;
|
|
fmtBody.reserve(body.size());
|
|
|
|
QVector<uint> utf32_string = body.toUcs4();
|
|
|
|
bool insideFontBlock = false;
|
|
bool insideTag = false;
|
|
for (auto &code : utf32_string) {
|
|
if (code == U'<')
|
|
insideTag = true;
|
|
else if (code == U'>')
|
|
insideTag = false;
|
|
|
|
if (!insideTag && utils::codepointIsEmoji(code)) {
|
|
if (!insideFontBlock) {
|
|
fmtBody += QStringLiteral("<font face=\"") % UserSettings::instance()->emojiFont() %
|
|
(UserSettings::instance()->enlargeEmojiOnlyMessages()
|
|
? QStringLiteral("\" size=\"4\">")
|
|
: QStringLiteral("\">"));
|
|
insideFontBlock = true;
|
|
} else if (code == 0xfe0f) {
|
|
// BUG(Nico):
|
|
// Workaround https://bugreports.qt.io/browse/QTBUG-97401
|
|
// See also https://github.com/matrix-org/matrix-react-sdk/pull/1458/files
|
|
// Nheko bug: https://github.com/Nheko-Reborn/nheko/issues/439
|
|
continue;
|
|
}
|
|
} else {
|
|
if (insideFontBlock) {
|
|
fmtBody += QStringLiteral("</font>");
|
|
insideFontBlock = false;
|
|
}
|
|
}
|
|
if (QChar::requiresSurrogates(code)) {
|
|
QChar emoji[] = {static_cast<ushort>(QChar::highSurrogate(code)),
|
|
static_cast<ushort>(QChar::lowSurrogate(code))};
|
|
fmtBody.append(emoji, 2);
|
|
} else {
|
|
fmtBody.append(QChar(static_cast<ushort>(code)));
|
|
}
|
|
}
|
|
if (insideFontBlock) {
|
|
fmtBody += QStringLiteral("</font>");
|
|
}
|
|
|
|
return fmtBody;
|
|
}
|
|
|
|
void
|
|
utils::setScaleFactor(float factor)
|
|
{
|
|
if (factor < 1 || factor > 3)
|
|
return;
|
|
|
|
QSettings settings;
|
|
settings.setValue(QStringLiteral("settings/scale_factor"), factor);
|
|
}
|
|
|
|
float
|
|
utils::scaleFactor()
|
|
{
|
|
QSettings settings;
|
|
return settings.value(QStringLiteral("settings/scale_factor"), -1).toFloat();
|
|
}
|
|
|
|
QString
|
|
utils::descriptiveTime(const QDateTime &then)
|
|
{
|
|
const auto now = QDateTime::currentDateTime();
|
|
const auto days = then.daysTo(now);
|
|
|
|
if (days == 0)
|
|
return QLocale::system().toString(then.time(), QLocale::ShortFormat);
|
|
else if (days < 2)
|
|
return QString(QCoreApplication::translate("descriptiveTime", "Yesterday"));
|
|
else if (days < 7)
|
|
return then.toString(QStringLiteral("dddd"));
|
|
|
|
return QLocale::system().toString(then.date(), QLocale::ShortFormat);
|
|
}
|
|
|
|
DescInfo
|
|
utils::getMessageDescription(const mtx::events::collections::TimelineEvents &event,
|
|
const QString &localUser,
|
|
const QString &displayName)
|
|
{
|
|
using Audio = mtx::events::RoomEvent<mtx::events::msg::Audio>;
|
|
using Emote = mtx::events::RoomEvent<mtx::events::msg::Emote>;
|
|
using File = mtx::events::RoomEvent<mtx::events::msg::File>;
|
|
using Image = mtx::events::RoomEvent<mtx::events::msg::Image>;
|
|
using Notice = mtx::events::RoomEvent<mtx::events::msg::Notice>;
|
|
using Text = mtx::events::RoomEvent<mtx::events::msg::Text>;
|
|
using Unknown = mtx::events::RoomEvent<mtx::events::msg::Unknown>;
|
|
using Video = mtx::events::RoomEvent<mtx::events::msg::Video>;
|
|
using ElementEffect = mtx::events::RoomEvent<mtx::events::msg::ElementEffect>;
|
|
using CallInvite = mtx::events::RoomEvent<mtx::events::voip::CallInvite>;
|
|
using CallAnswer = mtx::events::RoomEvent<mtx::events::voip::CallAnswer>;
|
|
using CallHangUp = mtx::events::RoomEvent<mtx::events::voip::CallHangUp>;
|
|
using CallReject = mtx::events::RoomEvent<mtx::events::voip::CallReject>;
|
|
using Encrypted = mtx::events::EncryptedEvent<mtx::events::msg::Encrypted>;
|
|
|
|
if (std::holds_alternative<Audio>(event)) {
|
|
return createDescriptionInfo<Audio>(event, localUser, displayName);
|
|
} else if (std::holds_alternative<Emote>(event)) {
|
|
return createDescriptionInfo<Emote>(event, localUser, displayName);
|
|
} else if (std::holds_alternative<File>(event)) {
|
|
return createDescriptionInfo<File>(event, localUser, displayName);
|
|
} else if (std::holds_alternative<Image>(event)) {
|
|
return createDescriptionInfo<Image>(event, localUser, displayName);
|
|
} else if (std::holds_alternative<Notice>(event)) {
|
|
return createDescriptionInfo<Notice>(event, localUser, displayName);
|
|
} else if (std::holds_alternative<Text>(event)) {
|
|
return createDescriptionInfo<Text>(event, localUser, displayName);
|
|
} else if (std::holds_alternative<Unknown>(event)) {
|
|
return createDescriptionInfo<Unknown>(event, localUser, displayName);
|
|
} else if (std::holds_alternative<Video>(event)) {
|
|
return createDescriptionInfo<Video>(event, localUser, displayName);
|
|
} else if (std::holds_alternative<ElementEffect>(event)) {
|
|
return createDescriptionInfo<ElementEffect>(event, localUser, displayName);
|
|
} else if (std::holds_alternative<CallInvite>(event)) {
|
|
return createDescriptionInfo<CallInvite>(event, localUser, displayName);
|
|
} else if (std::holds_alternative<CallAnswer>(event)) {
|
|
return createDescriptionInfo<CallAnswer>(event, localUser, displayName);
|
|
} else if (std::holds_alternative<CallHangUp>(event)) {
|
|
return createDescriptionInfo<CallHangUp>(event, localUser, displayName);
|
|
} else if (std::holds_alternative<CallReject>(event)) {
|
|
return createDescriptionInfo<CallReject>(event, localUser, displayName);
|
|
} else if (std::holds_alternative<mtx::events::Sticker>(event)) {
|
|
return createDescriptionInfo<mtx::events::Sticker>(event, localUser, displayName);
|
|
} else if (auto msg = std::get_if<Encrypted>(&event); msg != nullptr) {
|
|
const auto sender = QString::fromStdString(msg->sender);
|
|
|
|
const auto username = displayName;
|
|
const auto ts = QDateTime::fromMSecsSinceEpoch(msg->origin_server_ts);
|
|
|
|
DescInfo info;
|
|
info.userid = sender;
|
|
info.body = QStringLiteral(" %1").arg(
|
|
messageDescription<Encrypted>(username, QLatin1String(""), sender == localUser));
|
|
info.timestamp = msg->origin_server_ts;
|
|
info.descriptiveTime = utils::descriptiveTime(ts);
|
|
info.event_id = QString::fromStdString(msg->event_id);
|
|
info.datetime = ts;
|
|
|
|
return info;
|
|
}
|
|
|
|
return DescInfo{};
|
|
}
|
|
|
|
QString
|
|
utils::firstChar(const QString &input)
|
|
{
|
|
if (input.isEmpty())
|
|
return input;
|
|
|
|
for (auto const &c : input.toStdU32String()) {
|
|
if (QString::fromUcs4(&c, 1) != QStringLiteral("#"))
|
|
return QString::fromUcs4(&c, 1).toUpper();
|
|
}
|
|
|
|
return QString::fromUcs4(&input.toStdU32String().at(0), 1).toUpper();
|
|
}
|
|
|
|
QString
|
|
utils::humanReadableFileSize(uint64_t bytes)
|
|
{
|
|
constexpr static const char *units[] = {"B", "KiB", "MiB", "GiB", "TiB"};
|
|
constexpr static const int length = sizeof(units) / sizeof(units[0]);
|
|
|
|
int u = 0;
|
|
double size = static_cast<double>(bytes);
|
|
while (size >= 1024.0 && u < length) {
|
|
++u;
|
|
size /= 1024.0;
|
|
}
|
|
|
|
return QString::number(size, 'g', 4) + ' ' + units[u];
|
|
}
|
|
|
|
int
|
|
utils::levenshtein_distance(const std::string &s1, const std::string &s2)
|
|
{
|
|
const auto nlen = s1.size();
|
|
const auto hlen = s2.size();
|
|
|
|
if (hlen == 0)
|
|
return -1;
|
|
if (nlen == 1)
|
|
return (int)s2.find(s1);
|
|
|
|
std::vector<int> row1(hlen + 1, 0);
|
|
|
|
for (size_t i = 0; i < nlen; ++i) {
|
|
std::vector<int> row2(1, (int)i + 1);
|
|
|
|
for (size_t j = 0; j < hlen; ++j) {
|
|
const int cost = s1[i] != s2[j];
|
|
row2.push_back(std::min(row1[j + 1] + 1, std::min(row2[j] + 1, row1[j] + cost)));
|
|
}
|
|
|
|
row1.swap(row2);
|
|
}
|
|
|
|
return *std::min_element(row1.begin(), row1.end());
|
|
}
|
|
|
|
QPixmap
|
|
utils::scaleImageToPixmap(const QImage &img, int size)
|
|
{
|
|
if (img.isNull())
|
|
return QPixmap();
|
|
|
|
// Deprecated in 5.13: const double sz =
|
|
// std::ceil(QApplication::desktop()->screen()->devicePixelRatioF() * (double)size);
|
|
const int sz = static_cast<int>(
|
|
std::ceil(QGuiApplication::primaryScreen()->devicePixelRatio() * (double)size));
|
|
return QPixmap::fromImage(img.scaled(sz, sz, Qt::IgnoreAspectRatio, Qt::SmoothTransformation));
|
|
}
|
|
|
|
QPixmap
|
|
utils::scaleDown(uint64_t maxWidth, uint64_t maxHeight, const QPixmap &source)
|
|
{
|
|
if (source.isNull())
|
|
return QPixmap();
|
|
|
|
const double widthRatio = (double)maxWidth / (double)source.width();
|
|
const double heightRatio = (double)maxHeight / (double)source.height();
|
|
const double minAspectRatio = std::min(widthRatio, heightRatio);
|
|
|
|
// Size of the output image.
|
|
int w, h = 0;
|
|
|
|
if (minAspectRatio > 1) {
|
|
w = source.width();
|
|
h = source.height();
|
|
} else {
|
|
w = static_cast<int>(static_cast<double>(source.width()) * minAspectRatio);
|
|
h = static_cast<int>(static_cast<double>(source.height()) * minAspectRatio);
|
|
}
|
|
|
|
return source.scaled(w, h, Qt::IgnoreAspectRatio, Qt::SmoothTransformation);
|
|
}
|
|
|
|
QString
|
|
utils::mxcToHttp(const QUrl &url, const QString &server, int port)
|
|
{
|
|
auto mxcParts = mtx::client::utils::parse_mxc_url(url.toString().toStdString());
|
|
|
|
return QStringLiteral("https://%1:%2/_matrix/media/r0/download/%3/%4")
|
|
.arg(server)
|
|
.arg(port)
|
|
.arg(QString::fromStdString(mxcParts.server), QString::fromStdString(mxcParts.media_id));
|
|
}
|
|
|
|
QString
|
|
utils::humanReadableFingerprint(const std::string &ed25519_)
|
|
{
|
|
auto ed25519 = QString::fromStdString(ed25519_);
|
|
QString fingerprint;
|
|
for (int i = 0; i < ed25519.length(); i = i + 4) {
|
|
fingerprint.append(QStringView(ed25519).mid(i, 4));
|
|
if (i > 0 && i == 20)
|
|
fingerprint.append('\n');
|
|
else if (i < ed25519.length())
|
|
fingerprint.append(' ');
|
|
}
|
|
return fingerprint;
|
|
}
|
|
|
|
QString
|
|
utils::linkifyMessage(const QString &body)
|
|
{
|
|
// Convert to valid XML.
|
|
auto doc = body;
|
|
doc.replace(conf::strings::url_regex, conf::strings::url_html);
|
|
|
|
static QRegularExpression matrixURIRegex(
|
|
QStringLiteral("\\b(?<![\"'])(?>(matrix:[\\S]{5,}))(?![\"'])\\b"));
|
|
doc.replace(matrixURIRegex, conf::strings::url_html);
|
|
|
|
return doc;
|
|
}
|
|
|
|
QString
|
|
utils::escapeBlacklistedHtml(const QString &rawStr)
|
|
{
|
|
static const std::set<QByteArray> allowedTags = {
|
|
"font", "/font", "del", "/del", "h1", "/h1", "h2", "/h2",
|
|
"h3", "/h3", "h4", "/h4", "h5", "/h5", "h6", "/h6",
|
|
"blockquote", "/blockquote", "p", "/p", "a", "/a", "ul", "/ul",
|
|
"ol", "/ol", "sup", "/sup", "sub", "/sub", "li", "/li",
|
|
"b", "/b", "i", "/i", "u", "/u", "strong", "/strong",
|
|
"em", "/em", "strike", "/strike", "code", "/code", "hr", "hr/",
|
|
"br", "br/", "div", "/div", "table", "/table", "thead", "/thead",
|
|
"tbody", "/tbody", "tr", "/tr", "th", "/th", "td", "/td",
|
|
"caption", "/caption", "pre", "/pre", "span", "/span", "img", "/img",
|
|
"details", "/details", "summary", "/summary"};
|
|
constexpr static const std::array tagNameEnds = {' ', '>'};
|
|
constexpr static const std::array attrNameEnds = {' ', '>', '=', '\t', '\r', '\n', '/', '\f'};
|
|
constexpr static const std::array attrValueEnds = {' ', '\t', '\r', '\n', '\f', '>'};
|
|
constexpr static const std::array spaceChars = {' ', '\t', '\r', '\n', '\f'};
|
|
|
|
QByteArray data = rawStr.toUtf8();
|
|
QByteArray buffer;
|
|
const int length = data.size();
|
|
buffer.reserve(length);
|
|
const auto end = data.cend();
|
|
for (auto pos = data.cbegin(); pos < end;) {
|
|
auto tagStart = std::find(pos, end, '<');
|
|
buffer.append(pos, static_cast<int>(tagStart - pos));
|
|
if (tagStart == end)
|
|
break;
|
|
|
|
const auto tagNameStart = tagStart + 1;
|
|
const auto tagNameEnd =
|
|
std::find_first_of(tagNameStart, end, tagNameEnds.begin(), tagNameEnds.end());
|
|
|
|
const auto tagName =
|
|
QByteArray(tagNameStart, static_cast<int>(tagNameEnd - tagNameStart)).toLower();
|
|
|
|
if (allowedTags.find(tagName) == allowedTags.end()) {
|
|
// not allowed -> escape
|
|
buffer.append("<");
|
|
pos = tagNameStart;
|
|
continue;
|
|
} else {
|
|
buffer.append(tagStart, static_cast<int>(tagNameEnd - tagStart));
|
|
|
|
pos = tagNameEnd;
|
|
|
|
if (tagNameEnd != end) {
|
|
auto attrStart = tagNameEnd;
|
|
auto attrsEnd = std::find(attrStart, end, '>');
|
|
// we don't want to consume the slash of self closing tags as part of an attribute.
|
|
// However, obviously we don't want to move backwards, if there are no attributes.
|
|
if (*(attrsEnd - 1) == '/' && attrStart < attrsEnd)
|
|
attrsEnd -= 1;
|
|
|
|
pos = attrsEnd;
|
|
|
|
auto consumeSpaces = [attrsEnd](auto p) {
|
|
while (p < attrsEnd &&
|
|
std::find(spaceChars.begin(), spaceChars.end(), *p) != spaceChars.end())
|
|
p++;
|
|
return p;
|
|
};
|
|
|
|
attrStart = consumeSpaces(attrStart);
|
|
|
|
while (attrStart < attrsEnd) {
|
|
auto attrEnd = std::find_first_of(
|
|
attrStart, attrsEnd, attrNameEnds.begin(), attrNameEnds.end());
|
|
|
|
auto attrName =
|
|
QByteArray(attrStart, static_cast<int>(attrEnd - attrStart)).toLower();
|
|
|
|
auto sanitizeValue = [&attrName, tagName](QByteArray val) {
|
|
if (tagName == QByteArrayLiteral("del") ||
|
|
(attrName == QByteArrayLiteral("src") && !val.startsWith("mxc://")))
|
|
return QByteArray();
|
|
else
|
|
return val;
|
|
};
|
|
|
|
attrStart = consumeSpaces(attrEnd);
|
|
|
|
if (attrName.isEmpty()) {
|
|
buffer.append(QUrl::toPercentEncoding(QString(QByteArray(attrStart, 1))));
|
|
attrStart++;
|
|
continue;
|
|
} else if (attrStart < attrsEnd) {
|
|
if (*attrStart == '=') {
|
|
attrStart = consumeSpaces(attrStart + 1);
|
|
|
|
if (attrStart < attrsEnd) {
|
|
// we fall through here if the value is empty to transform attr=""
|
|
// into attr, because otherwise we can't style it
|
|
if (*attrStart == '"') {
|
|
attrStart += 1;
|
|
auto valueEnd = std::find(attrStart, attrsEnd, '"');
|
|
if (valueEnd == attrsEnd)
|
|
break;
|
|
|
|
auto val = sanitizeValue(QByteArray(
|
|
attrStart, static_cast<int>(valueEnd - attrStart)));
|
|
attrStart = consumeSpaces(valueEnd + 1);
|
|
if (!val.isEmpty()) {
|
|
buffer.append(' ');
|
|
buffer.append(attrName);
|
|
buffer.append("=\"");
|
|
buffer.append(val);
|
|
buffer.append('"');
|
|
continue;
|
|
}
|
|
} else if (*attrStart == '\'') {
|
|
attrStart += 1;
|
|
auto valueEnd = std::find(attrStart, attrsEnd, '\'');
|
|
if (valueEnd == attrsEnd)
|
|
break;
|
|
|
|
auto val = sanitizeValue(QByteArray(
|
|
attrStart, static_cast<int>(valueEnd - attrStart)));
|
|
attrStart = consumeSpaces(valueEnd + 1);
|
|
if (!val.isEmpty()) {
|
|
buffer.append(' ');
|
|
buffer.append(attrName);
|
|
buffer.append("=\'");
|
|
buffer.append(val);
|
|
buffer.append('\'');
|
|
continue;
|
|
}
|
|
} else {
|
|
auto valueEnd = std::find_first_of(attrStart,
|
|
attrsEnd,
|
|
attrValueEnds.begin(),
|
|
attrValueEnds.end());
|
|
auto val = sanitizeValue(QByteArray(
|
|
attrStart, static_cast<int>(valueEnd - attrStart)));
|
|
attrStart = consumeSpaces(valueEnd);
|
|
|
|
if (val.contains('"'))
|
|
continue;
|
|
|
|
buffer.append(' ');
|
|
buffer.append(attrName);
|
|
buffer.append("=\"");
|
|
buffer.append(val);
|
|
buffer.append('"');
|
|
continue;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// We don't really want tags on del tags and they make replacement in the
|
|
// frontend more expansive
|
|
if (tagName != QByteArrayLiteral("del")) {
|
|
buffer.append(' ');
|
|
buffer.append(attrName);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return QString::fromUtf8(buffer);
|
|
}
|
|
|
|
static void
|
|
rainbowify(cmark_node *node)
|
|
{
|
|
// create iterator over node
|
|
cmark_iter *iter = cmark_iter_new(node);
|
|
|
|
// First loop to get total text length
|
|
int textLen = 0;
|
|
while (cmark_iter_next(iter) != CMARK_EVENT_DONE) {
|
|
cmark_node *cur = cmark_iter_get_node(iter);
|
|
// only text nodes (no code or semilar)
|
|
if (cmark_node_get_type(cur) != CMARK_NODE_TEXT)
|
|
continue;
|
|
// count up by length of current node's text
|
|
QTextBoundaryFinder tbf(QTextBoundaryFinder::BoundaryType::Grapheme,
|
|
QString(cmark_node_get_literal(cur)));
|
|
while (tbf.toNextBoundary() != -1)
|
|
textLen++;
|
|
}
|
|
|
|
// create new iter to start over
|
|
cmark_iter_free(iter);
|
|
iter = cmark_iter_new(node);
|
|
|
|
// Second loop to rainbowify
|
|
int charIdx = 0;
|
|
while (cmark_iter_next(iter) != CMARK_EVENT_DONE) {
|
|
cmark_node *cur = cmark_iter_get_node(iter);
|
|
// only text nodes (no code or similar)
|
|
if (cmark_node_get_type(cur) != CMARK_NODE_TEXT)
|
|
continue;
|
|
|
|
// get text in current node
|
|
QString nodeText(cmark_node_get_literal(cur));
|
|
// create buffer to append rainbow text to
|
|
QString buf;
|
|
int boundaryStart = 0;
|
|
int boundaryEnd = 0;
|
|
// use QTextBoundaryFinder to iterate over graphemes
|
|
QTextBoundaryFinder tbf(QTextBoundaryFinder::BoundaryType::Grapheme, nodeText);
|
|
while ((boundaryEnd = tbf.toNextBoundary()) != -1) {
|
|
charIdx++;
|
|
// Split text to get current char
|
|
auto curChar = QStringView(nodeText).mid(boundaryStart, boundaryEnd - boundaryStart);
|
|
boundaryStart = boundaryEnd;
|
|
// Don't rainbowify whitespaces
|
|
if (curChar.trimmed().isEmpty() || utils::codepointIsEmoji(curChar.toUcs4().at(0))) {
|
|
buf.append(curChar);
|
|
continue;
|
|
}
|
|
|
|
// get correct color for char index
|
|
// Use colors as described here:
|
|
// https://shark.comfsm.fm/~dleeling/cis/hsl_rainbow.html
|
|
auto color = QColor::fromHslF(
|
|
static_cast<float>((charIdx - 1.0) / textLen * (5. / 6.)), 0.9f, 0.5f);
|
|
// format color for HTML
|
|
auto colorString = color.name(QColor::NameFormat::HexRgb);
|
|
// create HTML element for current char
|
|
auto curCharColored =
|
|
QStringLiteral("<font color=\"%0\">%1</font>").arg(colorString).arg(curChar);
|
|
// append colored HTML element to buffer
|
|
buf.append(curCharColored);
|
|
}
|
|
|
|
// create HTML_INLINE node to prevent HTML from being escaped
|
|
auto htmlNode = cmark_node_new(CMARK_NODE_HTML_INLINE);
|
|
// set content of HTML node to buffer contents
|
|
cmark_node_set_literal(htmlNode, buf.toUtf8().data());
|
|
// replace current node with HTML node
|
|
cmark_node_replace(cur, htmlNode);
|
|
// free memory of old node
|
|
cmark_node_free(cur);
|
|
}
|
|
|
|
cmark_iter_free(iter);
|
|
}
|
|
|
|
static std::string
|
|
extract_spoiler_warning(std::string &inside_spoiler)
|
|
{
|
|
std::string spoiler_text;
|
|
if (auto spoilerTextEnd = inside_spoiler.find("|"); spoilerTextEnd != std::string::npos) {
|
|
spoiler_text = inside_spoiler.substr(0, spoilerTextEnd);
|
|
inside_spoiler = inside_spoiler.substr(spoilerTextEnd + 1);
|
|
}
|
|
return QString::fromStdString(spoiler_text).replace('"', """).toStdString();
|
|
}
|
|
|
|
// TODO(Nico): Add tests :D
|
|
static void
|
|
process_spoilers(cmark_node *node)
|
|
{
|
|
auto iter = cmark_iter_new(node);
|
|
|
|
while (cmark_iter_next(iter) != CMARK_EVENT_DONE) {
|
|
cmark_node *cur = cmark_iter_get_node(iter);
|
|
|
|
// only text nodes (no code or similar)
|
|
if (cmark_node_get_type(cur) != CMARK_NODE_TEXT) {
|
|
continue;
|
|
}
|
|
|
|
std::string_view content = cmark_node_get_literal(cur);
|
|
|
|
if (auto posStart = content.find("||"); posStart != std::string::npos) {
|
|
// we have the start of the spoiler
|
|
if (auto posEnd = content.find("||", posStart + 2); posEnd != std::string::npos) {
|
|
// we have the end of the spoiler in the same node
|
|
|
|
std::string before_spoiler = std::string(content.substr(0, posStart));
|
|
std::string inside_spoiler =
|
|
std::string(content.substr(posStart + 2, posEnd - 2 - posStart));
|
|
std::string after_spoiler = std::string(content.substr(posEnd + 2));
|
|
|
|
std::string spoiler_text = extract_spoiler_warning(inside_spoiler);
|
|
|
|
// create the new nodes
|
|
auto before_node = cmark_node_new(cmark_node_type::CMARK_NODE_TEXT);
|
|
cmark_node_set_literal(before_node, before_spoiler.c_str());
|
|
auto after_node = cmark_node_new(cmark_node_type::CMARK_NODE_TEXT);
|
|
cmark_node_set_literal(after_node, after_spoiler.c_str());
|
|
|
|
auto block = cmark_node_new(cmark_node_type::CMARK_NODE_CUSTOM_INLINE);
|
|
cmark_node_set_on_enter(
|
|
block, ("<span data-mx-spoiler=\"" + spoiler_text + "\">").c_str());
|
|
cmark_node_set_on_exit(block, "</span>");
|
|
auto child_node = cmark_node_new(cmark_node_type::CMARK_NODE_TEXT);
|
|
cmark_node_set_literal(child_node, inside_spoiler.c_str());
|
|
cmark_node_append_child(block, child_node);
|
|
|
|
// insert the new nodes into the tree
|
|
cmark_node_replace(cur, block);
|
|
cmark_node_insert_before(block, before_node);
|
|
cmark_node_insert_after(block, after_node);
|
|
|
|
// cleanup the replaced node
|
|
cmark_node_free(cur);
|
|
|
|
// fixup the iterator
|
|
cmark_iter_reset(iter, block, CMARK_EVENT_EXIT);
|
|
|
|
} else {
|
|
// no end found, but lets try sibling nodes
|
|
for (auto next = cmark_node_next(cur); next != nullptr;
|
|
next = cmark_node_next(next)) {
|
|
// only text nodes again
|
|
if (cmark_node_get_type(next) != CMARK_NODE_TEXT)
|
|
continue;
|
|
|
|
std::string_view next_content = cmark_node_get_literal(next);
|
|
if (auto posEndNext = next_content.find("||");
|
|
posEndNext != std::string_view::npos) {
|
|
// We found the end of the spoiler
|
|
std::string before_spoiler = std::string(content.substr(0, posStart));
|
|
std::string after_spoiler =
|
|
std::string(next_content.substr(posEndNext + 2));
|
|
|
|
std::string inside_spoiler_start =
|
|
std::string(content.substr(posStart + 2));
|
|
std::string inside_spoiler_end =
|
|
std::string(next_content.substr(0, posEndNext));
|
|
|
|
std::string spoiler_text = extract_spoiler_warning(inside_spoiler_start);
|
|
|
|
// save all the nodes inside the spoiler for later
|
|
std::vector<cmark_node *> child_nodes;
|
|
for (auto kid = cmark_node_next(cur); kid != nullptr && kid != next;
|
|
kid = cmark_node_next(kid)) {
|
|
child_nodes.push_back(kid);
|
|
}
|
|
|
|
// create the new nodes
|
|
auto before_node = cmark_node_new(cmark_node_type::CMARK_NODE_TEXT);
|
|
cmark_node_set_literal(before_node, before_spoiler.c_str());
|
|
auto after_node = cmark_node_new(cmark_node_type::CMARK_NODE_TEXT);
|
|
cmark_node_set_literal(after_node, after_spoiler.c_str());
|
|
|
|
auto block = cmark_node_new(cmark_node_type::CMARK_NODE_CUSTOM_INLINE);
|
|
cmark_node_set_on_enter(
|
|
block, ("<span data-mx-spoiler=\"" + spoiler_text + "\">").c_str());
|
|
cmark_node_set_on_exit(block, "</span>");
|
|
|
|
// create the content inside the spoiler by adding the old text at the start
|
|
// and the end as well as all the existing children
|
|
auto child_node_start = cmark_node_new(cmark_node_type::CMARK_NODE_TEXT);
|
|
cmark_node_set_literal(child_node_start, inside_spoiler_start.c_str());
|
|
cmark_node_append_child(block, child_node_start);
|
|
for (auto &child : child_nodes)
|
|
cmark_node_append_child(block, child);
|
|
auto child_node_end = cmark_node_new(cmark_node_type::CMARK_NODE_TEXT);
|
|
cmark_node_set_literal(child_node_end, inside_spoiler_end.c_str());
|
|
cmark_node_append_child(block, child_node_end);
|
|
|
|
// insert the new nodes into the tree
|
|
cmark_node_replace(cur, block);
|
|
cmark_node_insert_before(block, before_node);
|
|
cmark_node_insert_after(block, after_node);
|
|
|
|
// cleanup removed nodes
|
|
cmark_node_free(cur);
|
|
cmark_node_free(next);
|
|
|
|
// fixup the iterator
|
|
cmark_iter_reset(iter, block, CMARK_EVENT_EXIT);
|
|
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
cmark_iter_free(iter);
|
|
}
|
|
|
|
static void
|
|
process_strikethrough(cmark_node *node)
|
|
{
|
|
auto iter = cmark_iter_new(node);
|
|
|
|
while (cmark_iter_next(iter) != CMARK_EVENT_DONE) {
|
|
cmark_node *cur = cmark_iter_get_node(iter);
|
|
|
|
// only text nodes (no code or similar)
|
|
if (cmark_node_get_type(cur) != CMARK_NODE_TEXT) {
|
|
continue;
|
|
}
|
|
|
|
std::string_view content = cmark_node_get_literal(cur);
|
|
|
|
if (auto posStart = content.find("~~"); posStart != std::string::npos) {
|
|
// we have the start of the strikethrough
|
|
if (auto posEnd = content.find("~~", posStart + 2); posEnd != std::string::npos) {
|
|
// we have the end of the strikethrough in the same node
|
|
|
|
std::string before_strike = std::string(content.substr(0, posStart));
|
|
std::string inside_strike =
|
|
std::string(content.substr(posStart + 2, posEnd - 2 - posStart));
|
|
std::string after_strike = std::string(content.substr(posEnd + 2));
|
|
|
|
// create the new nodes
|
|
auto before_node = cmark_node_new(cmark_node_type::CMARK_NODE_TEXT);
|
|
cmark_node_set_literal(before_node, before_strike.c_str());
|
|
auto after_node = cmark_node_new(cmark_node_type::CMARK_NODE_TEXT);
|
|
cmark_node_set_literal(after_node, after_strike.c_str());
|
|
|
|
auto block = cmark_node_new(cmark_node_type::CMARK_NODE_CUSTOM_INLINE);
|
|
cmark_node_set_on_enter(block, "<del>");
|
|
cmark_node_set_on_exit(block, "</del>");
|
|
auto child_node = cmark_node_new(cmark_node_type::CMARK_NODE_TEXT);
|
|
cmark_node_set_literal(child_node, inside_strike.c_str());
|
|
cmark_node_append_child(block, child_node);
|
|
|
|
// insert the new nodes into the tree
|
|
cmark_node_replace(cur, block);
|
|
cmark_node_insert_before(block, before_node);
|
|
cmark_node_insert_after(block, after_node);
|
|
|
|
// cleanup the replaced node
|
|
cmark_node_free(cur);
|
|
|
|
// fixup the iterator
|
|
cmark_iter_reset(iter, block, CMARK_EVENT_EXIT);
|
|
|
|
} else {
|
|
// no end found, but lets try sibling nodes
|
|
for (auto next = cmark_node_next(cur); next != nullptr;
|
|
next = cmark_node_next(next)) {
|
|
// only text nodes again
|
|
if (cmark_node_get_type(next) != CMARK_NODE_TEXT)
|
|
continue;
|
|
|
|
std::string_view next_content = cmark_node_get_literal(next);
|
|
if (auto posEndNext = next_content.find("~~");
|
|
posEndNext != std::string_view::npos) {
|
|
// We found the end of the strikethrough
|
|
std::string before_strike = std::string(content.substr(0, posStart));
|
|
std::string after_strike = std::string(next_content.substr(posEndNext + 2));
|
|
|
|
std::string inside_strike_start = std::string(content.substr(posStart + 2));
|
|
std::string inside_strike_end =
|
|
std::string(next_content.substr(0, posEndNext));
|
|
|
|
// save all the nodes inside the strikethrough for later
|
|
std::vector<cmark_node *> child_nodes;
|
|
for (auto kid = cmark_node_next(cur); kid != nullptr && kid != next;
|
|
kid = cmark_node_next(kid)) {
|
|
child_nodes.push_back(kid);
|
|
}
|
|
|
|
// create the new nodes
|
|
auto before_node = cmark_node_new(cmark_node_type::CMARK_NODE_TEXT);
|
|
cmark_node_set_literal(before_node, before_strike.c_str());
|
|
auto after_node = cmark_node_new(cmark_node_type::CMARK_NODE_TEXT);
|
|
cmark_node_set_literal(after_node, after_strike.c_str());
|
|
|
|
auto block = cmark_node_new(cmark_node_type::CMARK_NODE_CUSTOM_INLINE);
|
|
cmark_node_set_on_enter(block, "<del>");
|
|
cmark_node_set_on_exit(block, "</del>");
|
|
|
|
// create the content inside the strikethrough by adding the old text at the
|
|
// start and the end as well as all the existing children
|
|
auto child_node_start = cmark_node_new(cmark_node_type::CMARK_NODE_TEXT);
|
|
cmark_node_set_literal(child_node_start, inside_strike_start.c_str());
|
|
cmark_node_append_child(block, child_node_start);
|
|
for (auto &child : child_nodes)
|
|
cmark_node_append_child(block, child);
|
|
auto child_node_end = cmark_node_new(cmark_node_type::CMARK_NODE_TEXT);
|
|
cmark_node_set_literal(child_node_end, inside_strike_end.c_str());
|
|
cmark_node_append_child(block, child_node_end);
|
|
|
|
// insert the new nodes into the tree
|
|
cmark_node_replace(cur, block);
|
|
cmark_node_insert_before(block, before_node);
|
|
cmark_node_insert_after(block, after_node);
|
|
|
|
// cleanup removed nodes
|
|
cmark_node_free(cur);
|
|
cmark_node_free(next);
|
|
|
|
// fixup the iterator
|
|
cmark_iter_reset(iter, block, CMARK_EVENT_EXIT);
|
|
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
cmark_iter_free(iter);
|
|
}
|
|
QString
|
|
utils::markdownToHtml(const QString &text, bool rainbowify_, bool noExtensions)
|
|
{
|
|
const auto str = text.toUtf8();
|
|
cmark_node *const node = cmark_parse_document(str.constData(), str.size(), CMARK_OPT_UNSAFE);
|
|
|
|
if (!noExtensions) {
|
|
process_strikethrough(node);
|
|
process_spoilers(node);
|
|
|
|
if (rainbowify_) {
|
|
rainbowify(node);
|
|
}
|
|
}
|
|
|
|
const char *tmp_buf = cmark_render_html(
|
|
node,
|
|
// by default make single linebreaks <br> tags
|
|
noExtensions ? CMARK_OPT_UNSAFE : (CMARK_OPT_UNSAFE | CMARK_OPT_HARDBREAKS));
|
|
// Copy the null terminated output buffer.
|
|
std::string html(tmp_buf);
|
|
|
|
// The buffer is no longer needed.
|
|
free((char *)tmp_buf);
|
|
cmark_node_free(node);
|
|
|
|
auto result = escapeBlacklistedHtml(QString::fromStdString(html)).trimmed();
|
|
|
|
if (!noExtensions) {
|
|
result = linkifyMessage(std::move(result)).trimmed();
|
|
}
|
|
|
|
if (result.count(QStringLiteral("<p>")) == 1 && result.startsWith(QLatin1String("<p>")) &&
|
|
result.endsWith(QLatin1String("</p>"))) {
|
|
result = result.mid(3, result.size() - 3 - 4);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
QString
|
|
utils::getFormattedQuoteBody(const RelatedInfo &related, const QString &html)
|
|
{
|
|
auto getFormattedBody = [related]() -> QString {
|
|
using MsgType = mtx::events::MessageType;
|
|
|
|
switch (related.type) {
|
|
case MsgType::File: {
|
|
return QStringLiteral("sent a file.");
|
|
}
|
|
case MsgType::Image: {
|
|
return QStringLiteral("sent an image.");
|
|
}
|
|
case MsgType::Audio: {
|
|
return QStringLiteral("sent an audio file.");
|
|
}
|
|
case MsgType::Video: {
|
|
return QStringLiteral("sent a video");
|
|
}
|
|
default: {
|
|
return related.quoted_formatted_body;
|
|
}
|
|
}
|
|
};
|
|
return QString("<mx-reply><blockquote><a "
|
|
"href=\"https://matrix.to/#/%1/%2\">In reply "
|
|
"to</a> <a href=\"https://matrix.to/#/%3\">%4</a><br"
|
|
"/>%5</blockquote></mx-reply>")
|
|
.arg(related.room,
|
|
QString::fromStdString(related.related_event),
|
|
related.quoted_user,
|
|
related.quoted_user,
|
|
getFormattedBody()) +
|
|
html;
|
|
}
|
|
|
|
QString
|
|
utils::getQuoteBody(const RelatedInfo &related)
|
|
{
|
|
using MsgType = mtx::events::MessageType;
|
|
|
|
switch (related.type) {
|
|
case MsgType::File: {
|
|
return QStringLiteral("sent a file.");
|
|
}
|
|
case MsgType::Image: {
|
|
return QStringLiteral("sent an image.");
|
|
}
|
|
case MsgType::Audio: {
|
|
return QStringLiteral("sent an audio file.");
|
|
}
|
|
case MsgType::Video: {
|
|
return QStringLiteral("sent a video");
|
|
}
|
|
default: {
|
|
return related.quoted_body;
|
|
}
|
|
}
|
|
}
|
|
|
|
QString
|
|
utils::linkColor()
|
|
{
|
|
const auto theme = UserSettings::instance()->theme();
|
|
|
|
if (theme == QLatin1String("light")) {
|
|
return QStringLiteral("#0077b5");
|
|
} else if (theme == QLatin1String("dark")) {
|
|
return QStringLiteral("#38A3D8");
|
|
} else {
|
|
return QPalette().color(QPalette::Link).name();
|
|
}
|
|
}
|
|
|
|
uint32_t
|
|
utils::hashQString(const QString &input)
|
|
{
|
|
auto h = QCryptographicHash::hash(input.toUtf8(), QCryptographicHash::Sha1);
|
|
|
|
return (static_cast<uint32_t>(h[0]) << 24) ^ (static_cast<uint32_t>(h[1]) << 16) ^
|
|
(static_cast<uint32_t>(h[2]) << 8) ^ static_cast<uint32_t>(h[3]);
|
|
}
|
|
|
|
QColor
|
|
utils::generateContrastingHexColor(const QString &input, const QColor &backgroundCol)
|
|
{
|
|
const qreal backgroundLum = luminance(backgroundCol);
|
|
|
|
// Create a color for the input
|
|
auto hash = hashQString(input);
|
|
// create a hue value based on the hash of the input.
|
|
// Adapted to make Nico blue
|
|
auto userHue = static_cast<double>(hash - static_cast<uint32_t>(0x60'00'00'00)) /
|
|
std::numeric_limits<uint32_t>::max() * 360.;
|
|
// start with moderate saturation and lightness values.
|
|
auto sat = 230.;
|
|
auto lightness = 125.;
|
|
|
|
// converting to a QColor makes the luminance calc easier.
|
|
QColor inputColor = QColor::fromHsl(
|
|
static_cast<int>(userHue), static_cast<int>(sat), static_cast<int>(lightness));
|
|
|
|
// calculate the initial luminance and contrast of the
|
|
// generated color. It's possible that no additional
|
|
// work will be necessary.
|
|
auto lum = luminance(inputColor);
|
|
auto contrast = computeContrast(lum, backgroundLum);
|
|
|
|
// If the contrast doesn't meet our criteria,
|
|
// try again and again until they do by modifying first
|
|
// the lightness and then the saturation of the color.
|
|
int iterationCount = 9;
|
|
while (contrast < 4.5) {
|
|
// if our lightness is at it's bounds, try changing
|
|
// saturation instead.
|
|
if (lightness >= 242 || lightness <= 13) {
|
|
qreal newSat = qBound(26.0, sat * 1.25, 242.0);
|
|
|
|
inputColor.setHsl(static_cast<int>(userHue),
|
|
static_cast<int>(qFloor(newSat)),
|
|
static_cast<int>(lightness));
|
|
auto tmpLum = luminance(inputColor);
|
|
auto higherContrast = computeContrast(tmpLum, backgroundLum);
|
|
if (higherContrast > contrast) {
|
|
contrast = higherContrast;
|
|
sat = newSat;
|
|
} else {
|
|
newSat = qBound(26.0, sat / 1.25, 242.0);
|
|
inputColor.setHsl(static_cast<int>(userHue),
|
|
static_cast<int>(qFloor(newSat)),
|
|
static_cast<int>(lightness));
|
|
tmpLum = luminance(inputColor);
|
|
auto lowerContrast = computeContrast(tmpLum, backgroundLum);
|
|
if (lowerContrast > contrast) {
|
|
contrast = lowerContrast;
|
|
sat = newSat;
|
|
}
|
|
}
|
|
} else {
|
|
qreal newLightness = qBound(13.0, lightness * 1.25, 242.0);
|
|
|
|
inputColor.setHsl(static_cast<int>(userHue),
|
|
static_cast<int>(sat),
|
|
static_cast<int>(qFloor(newLightness)));
|
|
|
|
auto tmpLum = luminance(inputColor);
|
|
auto higherContrast = computeContrast(tmpLum, backgroundLum);
|
|
|
|
// Check to make sure we have actually improved contrast
|
|
if (higherContrast > contrast) {
|
|
contrast = higherContrast;
|
|
lightness = newLightness;
|
|
// otherwise, try going the other way instead.
|
|
} else {
|
|
newLightness = qBound(13.0, lightness / 1.25, 242.0);
|
|
inputColor.setHsl(static_cast<int>(userHue),
|
|
static_cast<int>(sat),
|
|
static_cast<int>(qFloor(newLightness)));
|
|
tmpLum = luminance(inputColor);
|
|
auto lowerContrast = computeContrast(tmpLum, backgroundLum);
|
|
if (lowerContrast > contrast) {
|
|
contrast = lowerContrast;
|
|
lightness = newLightness;
|
|
}
|
|
}
|
|
}
|
|
|
|
// don't loop forever, just give up at some point!
|
|
// Someone smart may find a better solution
|
|
if (--iterationCount < 0)
|
|
break;
|
|
}
|
|
|
|
// get the hex value of the generated color.
|
|
auto colorHex = inputColor.name();
|
|
|
|
return colorHex;
|
|
}
|
|
|
|
qreal
|
|
utils::computeContrast(const qreal &one, const qreal &two)
|
|
{
|
|
auto ratio = (one + 0.05) / (two + 0.05);
|
|
|
|
if (two > one) {
|
|
ratio = 1 / ratio;
|
|
}
|
|
|
|
return ratio;
|
|
}
|
|
|
|
qreal
|
|
utils::luminance(const QColor &col)
|
|
{
|
|
int colRgb[3] = {col.red(), col.green(), col.blue()};
|
|
qreal lumRgb[3];
|
|
|
|
for (int i = 0; i < 3; i++) {
|
|
qreal v = colRgb[i] / 255.0;
|
|
lumRgb[i] = v <= 0.03928 ? v / 12.92 : qPow((v + 0.055) / 1.055, 2.4);
|
|
}
|
|
|
|
auto lum = lumRgb[0] * 0.2126 + lumRgb[1] * 0.7152 + lumRgb[2] * 0.0722;
|
|
|
|
return lum;
|
|
}
|
|
|
|
void
|
|
utils::centerWidget(QWidget *widget, QWindow *parent)
|
|
{
|
|
if (parent) {
|
|
widget->window()->windowHandle()->setTransientParent(parent);
|
|
return;
|
|
}
|
|
|
|
auto findCenter = [childRect = widget->rect()](QRect hostRect) -> QPoint {
|
|
return QPoint(static_cast<int>(hostRect.center().x() - (childRect.width() * 0.5)),
|
|
static_cast<int>(hostRect.center().y() - (childRect.height() * 0.5)));
|
|
};
|
|
widget->move(findCenter(QGuiApplication::primaryScreen()->geometry()));
|
|
}
|
|
|
|
void
|
|
utils::restoreCombobox(QComboBox *combo, const QString &value)
|
|
{
|
|
for (auto i = 0; i < combo->count(); ++i) {
|
|
if (value == combo->itemText(i)) {
|
|
combo->setCurrentIndex(i);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
QImage
|
|
utils::readImageFromFile(const QString &filename)
|
|
{
|
|
QImageReader reader(filename);
|
|
reader.setAutoTransform(true);
|
|
return reader.read();
|
|
}
|
|
QImage
|
|
utils::readImage(const QByteArray &data)
|
|
{
|
|
QBuffer buf;
|
|
buf.setData(data);
|
|
QImageReader reader(&buf);
|
|
reader.setAutoTransform(true);
|
|
return reader.read();
|
|
}
|
|
|
|
bool
|
|
utils::isReply(const mtx::events::collections::TimelineEvents &e)
|
|
{
|
|
return mtx::accessors::relations(e).reply_to().has_value();
|
|
}
|
|
|
|
void
|
|
utils::removeDirectFromRoom(QString roomid)
|
|
{
|
|
http::client()->get_account_data<mtx::events::account_data::Direct>(
|
|
[roomid](mtx::events::account_data::Direct ev, mtx::http::RequestErr e) {
|
|
if (e && e->status_code == 404)
|
|
ev = {};
|
|
else if (e) {
|
|
nhlog::net()->error("Failed to retrieve m.direct: {}", *e);
|
|
return;
|
|
}
|
|
|
|
auto r = roomid.toStdString();
|
|
|
|
for (auto it = ev.user_to_rooms.begin(); it != ev.user_to_rooms.end();) {
|
|
for (auto rit = it->second.begin(); rit != it->second.end();) {
|
|
if (r == *rit)
|
|
rit = it->second.erase(rit);
|
|
else
|
|
++rit;
|
|
}
|
|
|
|
if (it->second.empty())
|
|
it = ev.user_to_rooms.erase(it);
|
|
else
|
|
++it;
|
|
}
|
|
|
|
http::client()->put_account_data(ev, [r](mtx::http::RequestErr e) {
|
|
if (e)
|
|
nhlog::net()->error("Failed to update m.direct: {}", *e);
|
|
});
|
|
});
|
|
}
|
|
void
|
|
utils::markRoomAsDirect(QString roomid, std::vector<RoomMember> members)
|
|
{
|
|
http::client()->get_account_data<mtx::events::account_data::Direct>(
|
|
[roomid, members](mtx::events::account_data::Direct ev, mtx::http::RequestErr e) {
|
|
if (e && e->status_code == 404)
|
|
ev = {};
|
|
else if (e) {
|
|
nhlog::net()->error("Failed to retrieve m.direct: {}", *e);
|
|
return;
|
|
}
|
|
|
|
auto local = utils::localUser();
|
|
auto r = roomid.toStdString();
|
|
|
|
for (const auto &m : members) {
|
|
if (m.user_id != local) {
|
|
ev.user_to_rooms[m.user_id.toStdString()].push_back(r);
|
|
}
|
|
}
|
|
|
|
http::client()->put_account_data(ev, [r](mtx::http::RequestErr e) {
|
|
if (e)
|
|
nhlog::net()->error("Failed to update m.direct: {}", *e);
|
|
});
|
|
});
|
|
}
|
|
|
|
std::vector<std::string>
|
|
utils::roomVias(const std::string &roomid)
|
|
{
|
|
std::vector<std::string> vias;
|
|
|
|
// for joined rooms
|
|
{
|
|
// see https://spec.matrix.org/v1.6/appendices/#routing for the algorithm
|
|
|
|
auto members = cache::roomMembers(roomid);
|
|
if (!members.empty()) {
|
|
auto powerlevels =
|
|
cache::client()->getStateEvent<mtx::events::state::PowerLevels>(roomid).value_or(
|
|
mtx::events::StateEvent<mtx::events::state::PowerLevels>{});
|
|
auto acls = cache::client()->getStateEvent<mtx::events::state::ServerAcl>(roomid);
|
|
|
|
std::vector<QRegularExpression> allowedServers;
|
|
std::vector<QRegularExpression> deniedServers;
|
|
|
|
if (acls) {
|
|
auto globToRegexp = [](const std::string &globExp) {
|
|
auto rawReg = QRegularExpression::escape(QString::fromStdString(globExp))
|
|
.replace("\\*", ".*")
|
|
.replace("\\?", ".");
|
|
return QRegularExpression(QRegularExpression::anchoredPattern(rawReg),
|
|
QRegularExpression::DotMatchesEverythingOption |
|
|
QRegularExpression::DontCaptureOption);
|
|
};
|
|
|
|
allowedServers.reserve(acls->content.allow.size());
|
|
for (const auto &s : acls->content.allow)
|
|
allowedServers.push_back(globToRegexp(s));
|
|
deniedServers.reserve(acls->content.deny.size());
|
|
for (const auto &s : acls->content.deny)
|
|
allowedServers.push_back(globToRegexp(s));
|
|
}
|
|
|
|
auto isHostAllowed = [&acls, &allowedServers, &deniedServers](const std::string &host) {
|
|
if (!acls)
|
|
return true;
|
|
|
|
auto url = QUrl::fromEncoded(
|
|
"https://" + QByteArray::fromRawData(host.data(), host.size()), QUrl::StrictMode);
|
|
if (url.hasQuery() || url.hasFragment())
|
|
return false;
|
|
|
|
auto hostname = url.host();
|
|
|
|
for (const auto &d : deniedServers)
|
|
if (d.match(hostname).hasMatch())
|
|
return false;
|
|
for (const auto &a : allowedServers)
|
|
if (a.match(hostname).hasMatch())
|
|
return true;
|
|
|
|
return false;
|
|
};
|
|
|
|
std::unordered_set<std::string> users_with_high_pl;
|
|
std::set<std::string> users_with_high_pl_in_room;
|
|
// we should pick PL > 50, but imo that is broken, so we just pick users who have admins
|
|
// perm
|
|
for (const auto &user : powerlevels.content.users) {
|
|
if (user.second >= powerlevels.content.events_default &&
|
|
user.second >= powerlevels.content.state_default) {
|
|
auto host =
|
|
mtx::identifiers::parse<mtx::identifiers::User>(user.first).hostname();
|
|
if (isHostAllowed(host))
|
|
users_with_high_pl.insert(user.first);
|
|
}
|
|
}
|
|
|
|
std::unordered_map<std::string, std::size_t> usercount_by_server;
|
|
for (const auto &m : members) {
|
|
auto user_id = mtx::identifiers::parse<mtx::identifiers::User>(m);
|
|
usercount_by_server[user_id.hostname()] += 1;
|
|
|
|
if (users_with_high_pl.count(m))
|
|
users_with_high_pl_in_room.insert(m);
|
|
}
|
|
|
|
std::erase_if(usercount_by_server, [&isHostAllowed](const auto &item) {
|
|
return !isHostAllowed(item.first);
|
|
});
|
|
|
|
// add the highest powerlevel user
|
|
auto max_pl_user = std::max_element(
|
|
users_with_high_pl_in_room.begin(),
|
|
users_with_high_pl_in_room.end(),
|
|
[&pl_content = powerlevels.content](const std::string &a, const std::string &b) {
|
|
return pl_content.user_level(a) < pl_content.user_level(b);
|
|
});
|
|
if (max_pl_user != users_with_high_pl_in_room.end()) {
|
|
auto host =
|
|
mtx::identifiers::parse<mtx::identifiers::User>(*max_pl_user).hostname();
|
|
vias.push_back(host);
|
|
usercount_by_server.erase(host);
|
|
}
|
|
|
|
// add up to 3 users, by usercount size from that server
|
|
std::vector<std::pair<std::size_t, std::string>> servers_sorted_by_usercount;
|
|
servers_sorted_by_usercount.reserve(usercount_by_server.size());
|
|
for (const auto &[server, count] : usercount_by_server)
|
|
servers_sorted_by_usercount.emplace_back(count, server);
|
|
|
|
std::sort(servers_sorted_by_usercount.begin(),
|
|
servers_sorted_by_usercount.end(),
|
|
[](const auto &a, const auto &b) {
|
|
if (a.first == b.first)
|
|
// same pl, sort lex smaller server first
|
|
return a.second < b.second;
|
|
|
|
// sort high user count first
|
|
return a.first > b.first;
|
|
});
|
|
|
|
for (const auto &server : servers_sorted_by_usercount) {
|
|
if (vias.size() >= 3)
|
|
break;
|
|
|
|
vias.push_back(server.second);
|
|
}
|
|
|
|
return vias;
|
|
}
|
|
}
|
|
|
|
// for invites
|
|
{
|
|
auto members = cache::getMembersFromInvite(roomid, 0, 100);
|
|
if (!members.empty()) {
|
|
vias.push_back(http::client()->user_id().hostname());
|
|
for (const auto &m : members) {
|
|
if (vias.size() >= 3)
|
|
break;
|
|
|
|
auto user_id =
|
|
mtx::identifiers::parse<mtx::identifiers::User>(m.user_id.toStdString());
|
|
|
|
auto server = user_id.hostname();
|
|
if (std::find(begin(vias), end(vias), server) == vias.end())
|
|
vias.push_back(server);
|
|
}
|
|
|
|
return vias;
|
|
}
|
|
}
|
|
|
|
// for space previews
|
|
auto parents = cache::client()->getParentRoomIds(roomid);
|
|
for (const auto &p : parents) {
|
|
auto child = cache::client()->getStateEvent<mtx::events::state::space::Child>(p, roomid);
|
|
if (child && child->content.via)
|
|
vias.insert(vias.end(), child->content.via->begin(), child->content.via->end());
|
|
}
|
|
|
|
std::sort(begin(vias), end(vias));
|
|
auto last = std::unique(begin(vias), end(vias));
|
|
vias.erase(last, end(vias));
|
|
|
|
return vias;
|
|
}
|
|
|
|
void
|
|
utils::updateSpaceVias()
|
|
{
|
|
if (!UserSettings::instance()->updateSpaceVias())
|
|
return;
|
|
|
|
nhlog::net()->info("update space vias called");
|
|
|
|
auto rooms = cache::roomInfo(false);
|
|
|
|
auto us = http::client()->user_id().to_string();
|
|
|
|
auto weekAgo = (uint64_t)QDateTime::currentDateTime().addDays(-7).toMSecsSinceEpoch();
|
|
|
|
struct ApplySpaceUpdatesState
|
|
{
|
|
std::vector<mtx::events::StateEvent<mtx::events::state::space::Child>> childrenToUpdate;
|
|
std::vector<mtx::events::StateEvent<mtx::events::state::space::Parent>> parentsToUpdate;
|
|
|
|
static void next(std::shared_ptr<ApplySpaceUpdatesState> state)
|
|
{
|
|
if (!state->childrenToUpdate.empty()) {
|
|
const auto &child = state->childrenToUpdate.back();
|
|
|
|
http::client()->send_state_event(
|
|
child.room_id,
|
|
child.state_key,
|
|
child.content,
|
|
[state = std::move(state)](const mtx::responses::EventId &,
|
|
mtx::http::RequestErr e) mutable {
|
|
const auto &child_ = state->childrenToUpdate.back();
|
|
if (e) {
|
|
if (e->status_code == 429 && e->matrix_error.retry_after.count() != 0) {
|
|
ChatPage::instance()->callFunctionOnGuiThread(
|
|
[state = std::move(state),
|
|
interval = e->matrix_error.retry_after]() {
|
|
QTimer::singleShot(interval * 3,
|
|
ChatPage::instance(),
|
|
[self = std::move(state)]() mutable {
|
|
next(std::move(self));
|
|
});
|
|
});
|
|
return;
|
|
}
|
|
|
|
nhlog::net()->error("Failed to update space child {} -> {}: {}",
|
|
child_.room_id,
|
|
child_.state_key,
|
|
*e);
|
|
}
|
|
nhlog::net()->info(
|
|
"Updated space child {} -> {}", child_.room_id, child_.state_key);
|
|
state->childrenToUpdate.pop_back();
|
|
next(std::move(state));
|
|
});
|
|
return;
|
|
} else if (!state->parentsToUpdate.empty()) {
|
|
const auto &parent = state->parentsToUpdate.back();
|
|
|
|
http::client()->send_state_event(
|
|
parent.room_id,
|
|
parent.state_key,
|
|
parent.content,
|
|
[state = std::move(state)](const mtx::responses::EventId &,
|
|
mtx::http::RequestErr e) mutable {
|
|
const auto &parent_ = state->parentsToUpdate.back();
|
|
if (e) {
|
|
if (e->status_code == 429 && e->matrix_error.retry_after.count() != 0) {
|
|
ChatPage::instance()->callFunctionOnGuiThread(
|
|
[state = std::move(state),
|
|
interval = e->matrix_error.retry_after]() {
|
|
QTimer::singleShot(interval * 3,
|
|
ChatPage::instance(),
|
|
[self = std::move(state)]() mutable {
|
|
next(std::move(self));
|
|
});
|
|
});
|
|
return;
|
|
}
|
|
|
|
nhlog::net()->error("Failed to update space parent {} -> {}: {}",
|
|
parent_.room_id,
|
|
parent_.state_key,
|
|
*e);
|
|
}
|
|
nhlog::net()->info(
|
|
"Updated space parent {} -> {}", parent_.room_id, parent_.state_key);
|
|
state->parentsToUpdate.pop_back();
|
|
next(std::move(state));
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
};
|
|
|
|
auto asus = std::make_shared<ApplySpaceUpdatesState>();
|
|
|
|
for (const auto &[roomid, info] : rooms.toStdMap()) {
|
|
if (!info.is_space)
|
|
continue;
|
|
|
|
auto spaceid = roomid.toStdString();
|
|
|
|
if (auto pl = cache::client()
|
|
->getStateEvent<mtx::events::state::PowerLevels>(spaceid)
|
|
.value_or(mtx::events::StateEvent<mtx::events::state::PowerLevels>{})
|
|
.content;
|
|
pl.user_level(us) < pl.state_level(to_string(mtx::events::EventType::SpaceChild)))
|
|
continue;
|
|
|
|
auto children = cache::client()->getChildRoomIds(spaceid);
|
|
|
|
for (const auto &childid : children) {
|
|
// only update children we are joined to
|
|
if (!rooms.contains(QString::fromStdString(childid)))
|
|
continue;
|
|
|
|
auto child =
|
|
cache::client()->getStateEvent<mtx::events::state::space::Child>(spaceid, childid);
|
|
if (child &&
|
|
// don't update too often
|
|
child->origin_server_ts < weekAgo &&
|
|
// ignore unset spaces
|
|
(child->content.via && !child->content.via->empty())) {
|
|
auto newVias = utils::roomVias(childid);
|
|
|
|
if (!newVias.empty() && newVias != child->content.via) {
|
|
nhlog::net()->info("Will update {} -> {} child relation from {} to {}",
|
|
spaceid,
|
|
childid,
|
|
fmt::join(*child->content.via, ","),
|
|
fmt::join(newVias, ","));
|
|
|
|
child->content.via = std::move(newVias);
|
|
child->room_id = spaceid;
|
|
asus->childrenToUpdate.push_back(*std::move(child));
|
|
}
|
|
}
|
|
|
|
auto parent =
|
|
cache::client()->getStateEvent<mtx::events::state::space::Parent>(childid, spaceid);
|
|
if (parent &&
|
|
// don't update too often
|
|
parent->origin_server_ts < weekAgo &&
|
|
// ignore unset spaces
|
|
(parent->content.via && !parent->content.via->empty())) {
|
|
if (auto pl =
|
|
cache::client()
|
|
->getStateEvent<mtx::events::state::PowerLevels>(childid)
|
|
.value_or(mtx::events::StateEvent<mtx::events::state::PowerLevels>{})
|
|
.content;
|
|
pl.user_level(us) <
|
|
pl.state_level(to_string(mtx::events::EventType::SpaceParent)))
|
|
continue;
|
|
|
|
auto newVias = utils::roomVias(spaceid);
|
|
|
|
if (!newVias.empty() && newVias != parent->content.via) {
|
|
nhlog::net()->info("Will update {} -> {} parent relation from {} to {}",
|
|
childid,
|
|
spaceid,
|
|
fmt::join(*parent->content.via, ","),
|
|
fmt::join(newVias, ","));
|
|
|
|
parent->content.via = std::move(newVias);
|
|
parent->room_id = childid;
|
|
asus->parentsToUpdate.push_back(*std::move(parent));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
ApplySpaceUpdatesState::next(std::move(asus));
|
|
}
|
|
|
|
std::atomic<bool> event_expiration_running = false;
|
|
void
|
|
utils::removeExpiredEvents()
|
|
{
|
|
if (!UserSettings::instance()->expireEvents())
|
|
return;
|
|
|
|
if (event_expiration_running.exchange(true)) {
|
|
nhlog::net()->info("Event expiration still running, not starting second job.");
|
|
return;
|
|
}
|
|
|
|
nhlog::net()->info("Remove expired events starting.");
|
|
|
|
auto rooms = cache::roomInfo(false);
|
|
|
|
auto us = http::client()->user_id().to_string();
|
|
|
|
using ExpType =
|
|
mtx::events::AccountDataEvent<mtx::events::account_data::nheko_extensions::EventExpiry>;
|
|
static auto getExpEv = [](const std::string &room = "") -> std::optional<ExpType> {
|
|
if (auto accountEvent =
|
|
cache::client()->getAccountData(mtx::events::EventType::NhekoEventExpiry, room))
|
|
if (auto ev = std::get_if<ExpType>(&*accountEvent);
|
|
ev && (ev->content.expire_after_ms || ev->content.keep_only_latest))
|
|
return std::optional{*ev};
|
|
return std::nullopt;
|
|
};
|
|
|
|
struct ApplyEventExpiration
|
|
{
|
|
std::optional<ExpType> globalExpiry;
|
|
std::vector<std::string> roomsToUpdate;
|
|
std::string filter;
|
|
|
|
std::string currentRoom;
|
|
bool firstMessagesCall = true;
|
|
std::uint64_t currentRoomCount = 0;
|
|
|
|
// batch token for pagination
|
|
std::string currentRoomPrevToken;
|
|
// event id of an event redacted in a previous run
|
|
std::string currentRoomStopAt;
|
|
// event id of first event redacted in the current run, hoping that the order stays the
|
|
// same.
|
|
std::string currentRoomFirstRedactedEvent;
|
|
// (evtype,state_key) tuple to keep the latest state event of each.
|
|
std::set<std::pair<std::string, std::string>> currentRoomStateEvents;
|
|
// event ids pending redaction
|
|
std::vector<std::string> currentRoomRedactionQueue;
|
|
|
|
mtx::events::account_data::nheko_extensions::EventExpiry currentExpiry;
|
|
|
|
static void next(std::shared_ptr<ApplyEventExpiration> state)
|
|
{
|
|
if (!state->currentRoomRedactionQueue.empty()) {
|
|
auto evid = state->currentRoomRedactionQueue.back();
|
|
auto room = state->currentRoom;
|
|
http::client()->redact_event(
|
|
room,
|
|
evid,
|
|
[state = std::move(state), evid](const mtx::responses::EventId &,
|
|
mtx::http::RequestErr e) mutable {
|
|
if (e) {
|
|
if (e->status_code == 429 && e->matrix_error.retry_after.count() != 0) {
|
|
ChatPage::instance()->callFunctionOnGuiThread(
|
|
[state = std::move(state),
|
|
interval = e->matrix_error.retry_after]() {
|
|
// triple interval to allow other traffic as well
|
|
QTimer::singleShot(interval * 3,
|
|
ChatPage::instance(),
|
|
[self = std::move(state)]() mutable {
|
|
next(std::move(self));
|
|
});
|
|
});
|
|
return;
|
|
} else {
|
|
nhlog::net()->error("Failed to redact event {} in {}: {}",
|
|
evid,
|
|
state->currentRoom,
|
|
*e);
|
|
state->currentRoomRedactionQueue.pop_back();
|
|
next(std::move(state));
|
|
}
|
|
} else {
|
|
nhlog::net()->info("Redacted event {} in {}", evid, state->currentRoom);
|
|
|
|
if (state->currentRoomFirstRedactedEvent.empty())
|
|
state->currentRoomFirstRedactedEvent = evid;
|
|
|
|
state->currentRoomRedactionQueue.pop_back();
|
|
next(std::move(state));
|
|
}
|
|
});
|
|
} else if (!state->currentRoom.empty()) {
|
|
if (state->currentRoomPrevToken.empty() && !state->firstMessagesCall) {
|
|
nhlog::net()->info("Finished room {}", state->currentRoom);
|
|
|
|
if (!state->currentRoomFirstRedactedEvent.empty())
|
|
cache::client()->storeEventExpirationProgress(
|
|
state->currentRoom,
|
|
nlohmann::json(state->currentExpiry).dump(),
|
|
state->currentRoomFirstRedactedEvent);
|
|
|
|
state->currentRoom.clear();
|
|
next(std::move(state));
|
|
return;
|
|
}
|
|
|
|
mtx::http::MessagesOpts opts{};
|
|
opts.dir = mtx::http::PaginationDirection::Backwards;
|
|
opts.from = state->currentRoomPrevToken;
|
|
opts.limit = 1000;
|
|
opts.filter = state->filter;
|
|
opts.room_id = state->currentRoom;
|
|
|
|
state->firstMessagesCall = false;
|
|
|
|
http::client()->messages(
|
|
opts,
|
|
[state = std::move(state)](const mtx::responses::Messages &msgs,
|
|
mtx::http::RequestErr error) mutable {
|
|
if (error) {
|
|
// skip success handler
|
|
nhlog::net()->warn(
|
|
"Finished room {} with error {}", state->currentRoom, *error);
|
|
state->currentRoom.clear();
|
|
} else if (msgs.chunk.empty()) {
|
|
state->currentRoomPrevToken.clear();
|
|
} else {
|
|
state->currentRoomPrevToken = msgs.end;
|
|
|
|
auto now = (uint64_t)QDateTime::currentMSecsSinceEpoch();
|
|
auto us = http::client()->user_id().to_string();
|
|
|
|
for (const auto &e : msgs.chunk) {
|
|
if (std::holds_alternative<
|
|
mtx::events::RedactionEvent<mtx::events::msg::Redaction>>(e))
|
|
continue;
|
|
|
|
if (std::holds_alternative<
|
|
mtx::events::RoomEvent<mtx::events::msg::Redacted>>(e) ||
|
|
std::holds_alternative<
|
|
mtx::events::StateEvent<mtx::events::msg::Redacted>>(e)) {
|
|
if (!state->currentRoomStopAt.empty() &&
|
|
mtx::accessors::event_id(e) == state->currentRoomStopAt) {
|
|
// There is no filter to remove redacted events from
|
|
// pagination, so we try to stop early by caching what event
|
|
// we redacted last if we reached the end of a room.
|
|
nhlog::net()->info(
|
|
"Found previous redaction marker, stopping early: {}",
|
|
state->currentRoom);
|
|
state->currentRoomPrevToken.clear();
|
|
break;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (std::holds_alternative<
|
|
mtx::events::StateEvent<mtx::events::msg::Redacted>>(e))
|
|
continue;
|
|
|
|
// synapse protects these 2 against redaction
|
|
if (std::holds_alternative<
|
|
mtx::events::StateEvent<mtx::events::state::Create>>(e))
|
|
continue;
|
|
|
|
if (std::holds_alternative<
|
|
mtx::events::StateEvent<mtx::events::state::ServerAcl>>(e))
|
|
continue;
|
|
|
|
// skip events we don't know to protect us from mistakes.
|
|
if (std::holds_alternative<
|
|
mtx::events::RoomEvent<mtx::events::Unknown>>(e))
|
|
continue;
|
|
|
|
if (mtx::accessors::sender(e) != us)
|
|
continue;
|
|
|
|
state->currentRoomCount++;
|
|
if (state->currentRoomCount <= state->currentExpiry.protect_latest) {
|
|
continue;
|
|
}
|
|
|
|
if (state->currentExpiry.exclude_state_events &&
|
|
mtx::accessors::is_state_event(e))
|
|
continue;
|
|
|
|
if (mtx::accessors::is_state_event(e)) {
|
|
// skip the first state event of a type
|
|
if (std::visit(
|
|
[&state](const auto &se) {
|
|
if constexpr (requires { se.state_key; })
|
|
return state->currentRoomStateEvents
|
|
.emplace(to_string(se.type), se.state_key)
|
|
.second;
|
|
else
|
|
return true;
|
|
},
|
|
e))
|
|
continue;
|
|
}
|
|
|
|
if (state->currentExpiry.keep_only_latest &&
|
|
state->currentRoomCount > state->currentExpiry.keep_only_latest) {
|
|
state->currentRoomRedactionQueue.push_back(
|
|
mtx::accessors::event_id(e));
|
|
} else if (state->currentExpiry.expire_after_ms &&
|
|
(state->currentExpiry.expire_after_ms +
|
|
mtx::accessors::origin_server_ts(e).toMSecsSinceEpoch()) <
|
|
now) {
|
|
state->currentRoomRedactionQueue.push_back(
|
|
mtx::accessors::event_id(e));
|
|
}
|
|
}
|
|
}
|
|
|
|
next(std::move(state));
|
|
});
|
|
} else if (!state->roomsToUpdate.empty()) {
|
|
const auto &room = state->roomsToUpdate.back();
|
|
|
|
auto localExp = getExpEv(room);
|
|
if (localExp) {
|
|
state->currentRoom = room;
|
|
state->currentExpiry = localExp->content;
|
|
} else if (state->globalExpiry) {
|
|
state->currentRoom = room;
|
|
state->currentExpiry = state->globalExpiry->content;
|
|
}
|
|
state->firstMessagesCall = true;
|
|
state->currentRoomCount = 0;
|
|
state->currentRoomPrevToken = "";
|
|
state->currentRoomRedactionQueue.clear();
|
|
state->currentRoomStateEvents.clear();
|
|
|
|
state->currentRoomStopAt = cache::client()->loadEventExpirationProgress(
|
|
state->currentRoom, nlohmann::json(state->currentExpiry).dump());
|
|
|
|
state->roomsToUpdate.pop_back();
|
|
next(std::move(state));
|
|
} else {
|
|
nhlog::net()->info("Finished event expiry");
|
|
event_expiration_running = false;
|
|
}
|
|
}
|
|
};
|
|
|
|
auto asus = std::make_shared<ApplyEventExpiration>();
|
|
|
|
nlohmann::json filter;
|
|
filter["timeline"]["senders"] = nlohmann::json::array({us});
|
|
filter["timeline"]["not_types"] = nlohmann::json::array({"m.room.redaction"});
|
|
|
|
asus->filter = filter.dump();
|
|
|
|
asus->globalExpiry = getExpEv();
|
|
|
|
for (const auto &[roomid_, info] : rooms.toStdMap()) {
|
|
auto roomid = roomid_.toStdString();
|
|
|
|
if (!asus->globalExpiry && !getExpEv(roomid))
|
|
continue;
|
|
|
|
if (auto pl = cache::client()
|
|
->getStateEvent<mtx::events::state::PowerLevels>(roomid)
|
|
.value_or(mtx::events::StateEvent<mtx::events::state::PowerLevels>{})
|
|
.content;
|
|
pl.user_level(us) < pl.event_level(to_string(mtx::events::EventType::RoomRedaction))) {
|
|
nhlog::net()->warn("Can't react events in {}, not running expiration.", roomid);
|
|
continue;
|
|
}
|
|
|
|
asus->roomsToUpdate.push_back(roomid);
|
|
}
|
|
|
|
nhlog::db()->info("Running expiration in {} rooms", asus->roomsToUpdate.size());
|
|
|
|
ApplyEventExpiration::next(std::move(asus));
|
|
}
|
|
|
|
namespace {
|
|
static const QList<QChar> diacritics = []() {
|
|
QList<QChar> ret;
|
|
for (wchar_t c = u'\u0300'; c <= u'\u036f'; ++c)
|
|
ret.append(QChar(c));
|
|
return ret;
|
|
}();
|
|
}
|
|
|
|
QString
|
|
utils::glitchText(const QString &text)
|
|
{
|
|
QString result;
|
|
for (int i = 0; i < text.size(); ++i) {
|
|
result.append(text.at(i));
|
|
if (QRandomGenerator64::global()->bounded(0, 100) >= 25)
|
|
result.append(
|
|
diacritics.at(QRandomGenerator64::global()->bounded(0, diacritics.size())));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
QString
|
|
utils::graduallyGlitchText(const QString &text)
|
|
{
|
|
QString result;
|
|
|
|
const int noGlitch = text.size() * 0.5;
|
|
const int someGlitch = text.size() * 0.8;
|
|
const int lotsOfGlitch = text.size() * 0.95;
|
|
|
|
for (int i = 0; i < text.size(); ++i) {
|
|
result.append(text.at(i));
|
|
|
|
if (i < noGlitch) // first 40% of text is normal
|
|
continue;
|
|
else if (i < someGlitch) // next 25% is progressively glitchier
|
|
{
|
|
if (QRandomGenerator64::global()->bounded(noGlitch, someGlitch) <
|
|
noGlitch + (i - noGlitch) * 0.05)
|
|
result.append(
|
|
diacritics.at(QRandomGenerator64::global()->bounded(0, diacritics.size())));
|
|
} else if (i < lotsOfGlitch) { // oh no, it's spreading!
|
|
if (QRandomGenerator64::global()->bounded(someGlitch, lotsOfGlitch) < i)
|
|
result.append(
|
|
diacritics.at(QRandomGenerator64::global()->bounded(0, diacritics.size())));
|
|
} else { // just give up, your computer is cursed now
|
|
do {
|
|
if (QRandomGenerator64::global()->bounded(text.size() / 5, text.size()) < i)
|
|
result.append(
|
|
diacritics.at(QRandomGenerator64::global()->bounded(0, diacritics.size())));
|
|
} while (QRandomGenerator64::global()->bounded(0, 100) < 35);
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|