matrixion/src/Utils.cpp
Nicolas Werner 9656304e24
Remove attributes on del tags
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
2024-03-20 21:53:20 +01:00

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("&lt;");
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('"', "&quot;").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;
}