mirror of
https://github.com/AyuGram/AyuGramDesktop.git
synced 2026-08-06 11:52:58 +00:00
6307 lines
177 KiB
C++
6307 lines
177 KiB
C++
/*
|
|
This file is part of Telegram Desktop,
|
|
the official desktop application for the Telegram messaging service.
|
|
|
|
For license and copyright information please follow this link:
|
|
https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL
|
|
*/
|
|
#include "export/output/export_output_html.h"
|
|
|
|
#include "core/utils.h"
|
|
#include "countries/countries_instance.h"
|
|
#include "export/data/export_data_types.h"
|
|
#include "export/output/export_output_result.h"
|
|
#include "ui/text/format_values.h"
|
|
|
|
#include <cmath>
|
|
|
|
#include <QtCore/QDateTime>
|
|
#include <QtCore/QFile>
|
|
#include <QtCore/QSize>
|
|
#include <QtCore/QUrl>
|
|
#include <QtGui/QImageReader>
|
|
|
|
namespace Export {
|
|
namespace Output {
|
|
namespace {
|
|
|
|
constexpr auto kMessagesInFile = 1000;
|
|
constexpr auto kPersonalUserpicSize = 90;
|
|
constexpr auto kEntryUserpicSize = 48;
|
|
constexpr auto kServiceMessagePhotoSize = 60;
|
|
constexpr auto kHistoryUserpicSize = 42;
|
|
constexpr auto kSavedMessagesColorIndex = uint8(3);
|
|
constexpr auto kJoinWithinSeconds = 900;
|
|
constexpr auto kPhotoMaxWidth = 520;
|
|
constexpr auto kPhotoMaxHeight = 520;
|
|
constexpr auto kPhotoMinWidth = 80;
|
|
constexpr auto kPhotoMinHeight = 80;
|
|
constexpr auto kStickerMaxWidth = 384;
|
|
constexpr auto kStickerMaxHeight = 384;
|
|
constexpr auto kStickerMinWidth = 80;
|
|
constexpr auto kStickerMinHeight = 80;
|
|
constexpr auto kStoryThumbWidth = 45;
|
|
constexpr auto kStoryThumbHeight = 80;
|
|
|
|
constexpr auto kChatsPriority = 0;
|
|
constexpr auto kContactsPriority = 2;
|
|
constexpr auto kFrequentContactsPriority = 3;
|
|
constexpr auto kUserpicsPriority = 4;
|
|
constexpr auto kStoriesPriority = 5;
|
|
constexpr auto kProfileMusicPriority = 6;
|
|
constexpr auto kSessionsPriority = 7;
|
|
constexpr auto kWebSessionsPriority = 8;
|
|
constexpr auto kOtherPriority = 9;
|
|
|
|
const auto kLineBreak = QByteArrayLiteral("<br>");
|
|
|
|
using Context = details::HtmlContext;
|
|
using UserpicData = details::UserpicData;
|
|
using StoryData = details::StoryData;
|
|
using PeersMap = details::PeersMap;
|
|
using MediaData = details::MediaData;
|
|
|
|
bool IsGlobalLink(const QString &link) {
|
|
return link.startsWith(u"http://"_q, Qt::CaseInsensitive)
|
|
|| link.startsWith(u"https://"_q, Qt::CaseInsensitive);
|
|
}
|
|
|
|
QByteArray NoFileDescription(Data::File::SkipReason reason) {
|
|
using SkipReason = Data::File::SkipReason;
|
|
switch (reason) {
|
|
case SkipReason::Unavailable:
|
|
return "Unavailable, please try again later.";
|
|
case SkipReason::FileSize:
|
|
return "Exceeds maximum size, "
|
|
"change data exporting settings to download.";
|
|
case SkipReason::FileType:
|
|
return "Not included, "
|
|
"change data exporting settings to download.";
|
|
case SkipReason::None:
|
|
return "";
|
|
}
|
|
Unexpected("Skip reason in NoFileDescription.");
|
|
}
|
|
|
|
Data::File RichFilePresentation(const Data::File *file) {
|
|
using SkipReason = Data::File::SkipReason;
|
|
|
|
auto result = file ? *file : Data::File();
|
|
if (!result.relativePath.isEmpty()) {
|
|
result.skipReason = SkipReason::None;
|
|
} else {
|
|
switch (result.skipReason) {
|
|
case SkipReason::FileType:
|
|
case SkipReason::FileSize:
|
|
break;
|
|
case SkipReason::None:
|
|
case SkipReason::Unavailable:
|
|
case SkipReason::DateLimits:
|
|
result.skipReason = SkipReason::Unavailable;
|
|
break;
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
Data::Photo RichPhotoPresentation(const Data::Photo *photo) {
|
|
auto result = photo ? *photo : Data::Photo();
|
|
result.image.file = RichFilePresentation(
|
|
photo ? &photo->image.file : nullptr);
|
|
return result;
|
|
}
|
|
|
|
Data::Document RichDocumentPresentation(const Data::Document *document) {
|
|
auto result = document ? *document : Data::Document();
|
|
result.file = RichFilePresentation(document ? &document->file : nullptr);
|
|
result.thumb.file = RichFilePresentation(
|
|
document ? &document->thumb.file : nullptr);
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichFileDescription(const Data::File *file) {
|
|
return NoFileDescription(RichFilePresentation(file).skipReason);
|
|
}
|
|
|
|
auto CalculateThumbSize(
|
|
int maxWidth,
|
|
int maxHeight,
|
|
int minWidth,
|
|
int minHeight,
|
|
bool expandForRetina = false) {
|
|
return [=](QSize largeSize) {
|
|
const auto multiplier = (expandForRetina ? 2 : 1);
|
|
const auto checkWidth = largeSize.width() * multiplier;
|
|
const auto checkHeight = largeSize.height() * multiplier;
|
|
const auto smallSize = (checkWidth > maxWidth
|
|
|| checkHeight > maxHeight)
|
|
? largeSize.scaled(
|
|
maxWidth,
|
|
maxHeight,
|
|
Qt::KeepAspectRatio)
|
|
: largeSize;
|
|
const auto retinaSize = QSize(
|
|
smallSize.width() & ~0x01,
|
|
smallSize.height() & ~0x01);
|
|
return (retinaSize.width() < kPhotoMinWidth
|
|
|| retinaSize.height() < kPhotoMinHeight)
|
|
? QSize()
|
|
: retinaSize;
|
|
};
|
|
}
|
|
|
|
QByteArray SerializeString(const QByteArray &value) {
|
|
const auto size = value.size();
|
|
const auto begin = value.data();
|
|
const auto end = begin + size;
|
|
|
|
auto result = QByteArray();
|
|
result.reserve(size * 6);
|
|
for (auto p = begin; p != end; ++p) {
|
|
const auto ch = *p;
|
|
if (ch == '\n') {
|
|
result.append("<br>", 4);
|
|
} else if (ch == '"') {
|
|
result.append(""", 6);
|
|
} else if (ch == '&') {
|
|
result.append("&", 5);
|
|
} else if (ch == '\'') {
|
|
result.append("'", 6);
|
|
} else if (ch == '<') {
|
|
result.append("<", 4);
|
|
} else if (ch == '>') {
|
|
result.append(">", 4);
|
|
} else if (ch >= 0 && ch < 32) {
|
|
result.append("&#x", 3).append('0' + (ch >> 4));
|
|
const auto left = (ch & 0x0F);
|
|
if (left >= 10) {
|
|
result.append('A' + (left - 10));
|
|
} else {
|
|
result.append('0' + left);
|
|
}
|
|
result.append(';');
|
|
} else if (ch == char(0xE2)
|
|
&& (p + 2 < end)
|
|
&& *(p + 1) == char(0x80)) {
|
|
if (*(p + 2) == char(0xA8)) { // Line separator.
|
|
result.append("<br>", 4);
|
|
} else if (*(p + 2) == char(0xA9)) { // Paragraph separator.
|
|
result.append("<br>", 4);
|
|
} else {
|
|
result.append(ch);
|
|
}
|
|
} else {
|
|
result.append(ch);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
QByteArray SerializeList(const std::vector<QByteArray> &values) {
|
|
const auto count = values.size();
|
|
if (count == 1) {
|
|
return values[0];
|
|
} else if (count > 1) {
|
|
auto result = values[0];
|
|
for (auto i = 1; i != count - 1; ++i) {
|
|
result += ", " + values[i];
|
|
}
|
|
return result + " and " + values[count - 1];
|
|
}
|
|
return QByteArray();
|
|
}
|
|
|
|
QByteArray MakeLinks(const QByteArray &value) {
|
|
const auto domain = QByteArray("https://telegram.org/");
|
|
auto result = QByteArray();
|
|
auto offset = 0;
|
|
while (true) {
|
|
const auto start = value.indexOf(domain, offset);
|
|
if (start < 0) {
|
|
break;
|
|
}
|
|
auto end = start + domain.size();
|
|
for (; end != value.size(); ++end) {
|
|
const auto ch = value[end];
|
|
if ((ch < 'a' || ch > 'z')
|
|
&& (ch < 'A' || ch > 'Z')
|
|
&& (ch < '0' || ch > '9')
|
|
&& (ch != '-')
|
|
&& (ch != '_')
|
|
&& (ch != '/')) {
|
|
break;
|
|
}
|
|
}
|
|
if (start > offset) {
|
|
const auto link = value.mid(start, end - start);
|
|
result.append(value.mid(offset, start - offset));
|
|
result.append("<a href=\"").append(link).append("\">");
|
|
result.append(link);
|
|
result.append("</a>");
|
|
offset = end;
|
|
}
|
|
}
|
|
if (result.isEmpty()) {
|
|
return value;
|
|
}
|
|
if (offset < value.size()) {
|
|
result.append(value.mid(offset));
|
|
}
|
|
return result;
|
|
}
|
|
|
|
QByteArray JoinList(
|
|
const QByteArray &separator,
|
|
const std::vector<QByteArray> &list) {
|
|
if (list.empty()) {
|
|
return QByteArray();
|
|
} else if (list.size() == 1) {
|
|
return list[0];
|
|
}
|
|
auto size = (list.size() - 1) * separator.size();
|
|
for (const auto &value : list) {
|
|
size += value.size();
|
|
}
|
|
auto result = QByteArray();
|
|
result.reserve(size);
|
|
auto counter = 0;
|
|
while (true) {
|
|
result.append(list[counter]);
|
|
if (++counter == list.size()) {
|
|
break;
|
|
} else {
|
|
result.append(separator);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
QByteArray FormatCustomEmoji(
|
|
const Data::Utf8String &custom_emoji,
|
|
const QByteArray &text,
|
|
const QString &relativeLinkBase) {
|
|
return (custom_emoji.isEmpty()
|
|
? "<a href=\"\" onclick=\"return ShowNotLoadedEmoji();\">"
|
|
: (custom_emoji == Data::TextPart::UnavailableEmoji())
|
|
? "<a href=\"\" onclick=\"return ShowNotAvailableEmoji();\">"
|
|
: ("<a href = \""
|
|
+ (relativeLinkBase + custom_emoji).toUtf8()
|
|
+ "\">"))
|
|
+ text
|
|
+ "</a>";
|
|
}
|
|
|
|
QByteArray FormatText(
|
|
const std::vector<Data::TextPart> &data,
|
|
const QString &internalLinksDomain,
|
|
const QString &relativeLinkBase) {
|
|
return JoinList(QByteArray(), ranges::views::all(
|
|
data
|
|
) | ranges::views::transform([&](const Data::TextPart &part) {
|
|
const auto text = SerializeString(part.text);
|
|
using Type = Data::TextPart::Type;
|
|
switch (part.type) {
|
|
case Type::Text: return text;
|
|
case Type::Unknown: return text;
|
|
case Type::Mention:
|
|
return "<a href=\""
|
|
+ internalLinksDomain.toUtf8()
|
|
+ text.mid(1)
|
|
+ "\">" + text + "</a>";
|
|
case Type::Hashtag: return "<a href=\"\" "
|
|
"onclick=\"return ShowHashtag("
|
|
+ SerializeString('"' + text.mid(1) + '"')
|
|
+ ")\">" + text + "</a>";
|
|
case Type::BotCommand: return "<a href=\"\" "
|
|
"onclick=\"return ShowBotCommand("
|
|
+ SerializeString('"' + text.mid(1) + '"')
|
|
+ ")\">" + text + "</a>";
|
|
case Type::Url: return "<a href=\""
|
|
+ text
|
|
+ "\">" + text + "</a>";
|
|
case Type::Email: return "<a href=\"mailto:"
|
|
+ text
|
|
+ "\">" + text + "</a>";
|
|
case Type::Bold: return "<strong>" + text + "</strong>";
|
|
case Type::Italic: return "<em>" + text + "</em>";
|
|
case Type::Code: return "<code>" + text + "</code>";
|
|
case Type::Pre: return "<pre>" + text + "</pre>";
|
|
case Type::TextUrl: return "<a href=\""
|
|
+ SerializeString(part.additional)
|
|
+ "\">" + text + "</a>";
|
|
case Type::MentionName: return "<a href=\"\" "
|
|
"onclick=\"return ShowMentionName()\">" + text + "</a>";
|
|
case Type::Phone: return "<a href=\"tel:"
|
|
+ text
|
|
+ "\">" + text + "</a>";
|
|
case Type::Cashtag: return "<a href=\"\" "
|
|
"onclick=\"return ShowCashtag("
|
|
+ SerializeString('"' + text.mid(1) + '"')
|
|
+ ")\">" + text + "</a>";
|
|
case Type::Underline: return "<u>" + text + "</u>";
|
|
case Type::Strike: return "<s>" + text + "</s>";
|
|
case Type::Blockquote:
|
|
return "<blockquote>" + text + "</blockquote>";
|
|
case Type::BankCard:
|
|
return text;
|
|
case Type::Spoiler: return "<span class=\"spoiler hidden\" "
|
|
"onclick=\"ShowSpoiler(this)\">"
|
|
"<span aria-hidden=\"true\">"
|
|
+ text + "</span></span>";
|
|
case Type::CustomEmoji:
|
|
return FormatCustomEmoji(part.additional, text, relativeLinkBase);
|
|
}
|
|
Unexpected("Type in text entities serialization.");
|
|
}) | ranges::to_vector);
|
|
}
|
|
|
|
Data::Utf8String FormatUsername(const Data::Utf8String &username) {
|
|
return username.isEmpty() ? username : ('@' + username);
|
|
}
|
|
|
|
bool DisplayDate(TimeId date, TimeId previousDate) {
|
|
if (!previousDate) {
|
|
return true;
|
|
}
|
|
return QDateTime::fromSecsSinceEpoch(date).date()
|
|
!= QDateTime::fromSecsSinceEpoch(previousDate).date();
|
|
}
|
|
|
|
QByteArray FormatDateText(TimeId date) {
|
|
const auto parsed = QDateTime::fromSecsSinceEpoch(date).date();
|
|
const auto month = [](int index) {
|
|
switch (index) {
|
|
case 1: return "January";
|
|
case 2: return "February";
|
|
case 3: return "March";
|
|
case 4: return "April";
|
|
case 5: return "May";
|
|
case 6: return "June";
|
|
case 7: return "July";
|
|
case 8: return "August";
|
|
case 9: return "September";
|
|
case 10: return "October";
|
|
case 11: return "November";
|
|
case 12: return "December";
|
|
}
|
|
return "Unknown";
|
|
};
|
|
return Data::NumberToString(parsed.day())
|
|
+ ' '
|
|
+ month(parsed.month())
|
|
+ ' '
|
|
+ Data::NumberToString(parsed.year());
|
|
}
|
|
|
|
QByteArray FormatTimeText(TimeId date) {
|
|
const auto parsed = QDateTime::fromSecsSinceEpoch(date).time();
|
|
return Data::NumberToString(parsed.hour(), 2)
|
|
+ ':'
|
|
+ Data::NumberToString(parsed.minute(), 2);
|
|
}
|
|
|
|
} // namespace
|
|
|
|
namespace details {
|
|
|
|
struct UserpicData {
|
|
uint8 colorIndex = 0;
|
|
int pixelSize = 0;
|
|
QString imageLink;
|
|
QString largeLink;
|
|
QByteArray firstName;
|
|
QByteArray lastName;
|
|
QByteArray tooltip;
|
|
};
|
|
|
|
struct StoryData {
|
|
QString imageLink;
|
|
QString largeLink;
|
|
};
|
|
|
|
class PeersMap {
|
|
public:
|
|
using Peer = Data::Peer;
|
|
using User = Data::User;
|
|
using Chat = Data::Chat;
|
|
|
|
PeersMap(const std::map<PeerId, Peer> &data);
|
|
|
|
const Peer &peer(PeerId peerId) const;
|
|
const User &user(UserId userId) const;
|
|
|
|
QByteArray wrapPeerName(PeerId peerId) const;
|
|
QByteArray wrapUserName(UserId userId) const;
|
|
QByteArray wrapUserNames(const std::vector<UserId> &data) const;
|
|
|
|
private:
|
|
const std::map<PeerId, Data::Peer> &_data;
|
|
|
|
};
|
|
|
|
struct MediaData {
|
|
QByteArray title;
|
|
QByteArray description;
|
|
QByteArray status;
|
|
QByteArray classes;
|
|
QString thumb;
|
|
QString link;
|
|
};
|
|
|
|
PeersMap::PeersMap(const std::map<PeerId, Peer> &data) : _data(data) {
|
|
}
|
|
|
|
auto PeersMap::peer(PeerId peerId) const -> const Peer & {
|
|
if (const auto i = _data.find(peerId); i != end(_data)) {
|
|
return i->second;
|
|
}
|
|
static auto empty = Peer{ User() };
|
|
return empty;
|
|
}
|
|
|
|
auto PeersMap::user(UserId userId) const -> const User & {
|
|
if (const auto result = peer(peerFromUser(userId)).user()) {
|
|
return *result;
|
|
}
|
|
static auto empty = User();
|
|
return empty;
|
|
}
|
|
|
|
QByteArray PeersMap::wrapPeerName(PeerId peerId) const {
|
|
const auto result = peer(peerId).name();
|
|
return result.isEmpty()
|
|
? QByteArray("Deleted")
|
|
: SerializeString(result);
|
|
}
|
|
|
|
QByteArray PeersMap::wrapUserName(UserId userId) const {
|
|
const auto result = user(userId).name();
|
|
return result.isEmpty()
|
|
? QByteArray("Deleted Account")
|
|
: SerializeString(result);
|
|
}
|
|
|
|
QByteArray PeersMap::wrapUserNames(const std::vector<UserId> &data) const {
|
|
auto list = std::vector<QByteArray>();
|
|
for (const auto &userId : data) {
|
|
list.push_back(wrapUserName(userId));
|
|
}
|
|
return SerializeList(list);
|
|
}
|
|
|
|
QByteArray HtmlContext::pushTag(
|
|
const QByteArray &tag,
|
|
std::map<QByteArray, QByteArray> &&attributes) {
|
|
auto data = Tag();
|
|
data.name = tag;
|
|
auto empty = false;
|
|
auto inner = QByteArray();
|
|
for (const auto &[name, value] : attributes) {
|
|
if (name == "inline") {
|
|
data.block = false;
|
|
} else if (name == "empty") {
|
|
empty = true;
|
|
} else {
|
|
inner.append(' ').append(name);
|
|
inner.append("=\"").append(SerializeString(value)).append("\"");
|
|
}
|
|
}
|
|
auto result = (data.block ? ("\n" + indent()) : QByteArray())
|
|
+ "<" + data.name + inner + (empty ? "/" : "") + ">"
|
|
+ (data.block ? "\n" : "");
|
|
if (!empty) {
|
|
_tags.push_back(data);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
QByteArray HtmlContext::popTag() {
|
|
Expects(!_tags.empty());
|
|
|
|
const auto data = _tags.back();
|
|
_tags.pop_back();
|
|
return (data.block ? ("\n" + indent()) : QByteArray())
|
|
+ "</" + data.name + ">"
|
|
+ (data.block ? "\n" : "");
|
|
}
|
|
|
|
QByteArray HtmlContext::indent() const {
|
|
return QByteArray(_tags.size(), ' ');
|
|
}
|
|
|
|
bool HtmlContext::empty() const {
|
|
return _tags.empty();
|
|
}
|
|
|
|
} // namespace details
|
|
|
|
namespace {
|
|
|
|
MediaData PrepareAudioMediaData(const Data::Document &data) {
|
|
const auto hasFile = !data.file.relativePath.isEmpty();
|
|
auto result = MediaData();
|
|
result.title = (!data.songPerformer.isEmpty()
|
|
&& !data.songTitle.isEmpty())
|
|
? (data.songPerformer + " \xe2\x80\x93 " + data.songTitle)
|
|
: !data.name.isEmpty()
|
|
? data.name
|
|
: QByteArray("Audio file");
|
|
result.status = Data::FormatDuration(data.duration);
|
|
if (!hasFile) {
|
|
result.status += ", " + Data::FormatFileSize(data.file.size);
|
|
}
|
|
result.classes = "media_audio_file";
|
|
result.link = data.file.relativePath;
|
|
result.description = NoFileDescription(data.file.skipReason);
|
|
return result;
|
|
}
|
|
|
|
struct RichMediaCallbacks {
|
|
Fn<QByteArray(const Data::Photo*)> photo = {};
|
|
Fn<QByteArray(const Data::Document*)> video = {};
|
|
Fn<QByteArray(const Data::Document*)> audio = {};
|
|
Fn<QByteArray(const MediaData&)> generic = {};
|
|
Fn<QByteArray(const MediaData&, const Data::Photo*)> photoCard = {};
|
|
};
|
|
|
|
enum class RichSourceMetaKind {
|
|
Source,
|
|
EmbedPost,
|
|
};
|
|
|
|
enum class RichTailState {
|
|
Empty,
|
|
Media,
|
|
Other,
|
|
};
|
|
|
|
constexpr auto kRichTargetMaxBytes = 4096;
|
|
constexpr auto kRichAnchorDirectBytes = 72;
|
|
constexpr auto kRichAnchorPrefixBytes = 48;
|
|
constexpr auto kRichTableSpanMax = 1000;
|
|
constexpr auto kRichHashtagMinLength = 2;
|
|
constexpr auto kRichHashtagMaxLength = 64;
|
|
constexpr auto kRichCommandMaxLength = 64;
|
|
constexpr auto kRichCashtagMaxLength = 8;
|
|
constexpr auto kRichUsernameMinLength = 5;
|
|
constexpr auto kRichUsernameMaxLength = 32;
|
|
|
|
class RichHtmlRenderer {
|
|
public:
|
|
RichHtmlRenderer(
|
|
Context &context,
|
|
const Data::RichMessage &message,
|
|
int messageId,
|
|
const QString &internalLinksDomain,
|
|
const QByteArray &relativeBase,
|
|
const RichMediaCallbacks &media);
|
|
|
|
[[nodiscard]] QByteArray renderMessage();
|
|
|
|
private:
|
|
[[nodiscard]] const Data::Photo *findPhoto(uint64 id) const;
|
|
[[nodiscard]] const Data::Document *findDocument(uint64 id) const;
|
|
[[nodiscard]] RichTailState tailState(
|
|
const Data::RichBlock &block) const;
|
|
[[nodiscard]] RichTailState tailState(
|
|
const std::vector<Data::RichBlock> &blocks) const;
|
|
[[nodiscard]] QByteArray renderTexts(
|
|
const std::vector<Data::RichText> &texts);
|
|
[[nodiscard]] QByteArray renderText(const Data::RichText &text);
|
|
[[nodiscard]] QByteArray renderTextChildren(const Data::RichText &text);
|
|
[[nodiscard]] QByteArray renderTextLink(
|
|
const Data::RichText &text,
|
|
const std::optional<QByteArray> &href,
|
|
std::map<QByteArray, QByteArray> attributes);
|
|
[[nodiscard]] QByteArray renderCustomEmojiLink(
|
|
const QByteArray &alt,
|
|
std::map<QByteArray, QByteArray> attributes);
|
|
[[nodiscard]] QByteArray renderBlocks(
|
|
const std::vector<Data::RichBlock> &blocks);
|
|
[[nodiscard]] QByteArray renderBlock(const Data::RichBlock &block);
|
|
[[nodiscard]] QByteArray renderPhoto(const Data::RichBlock &block);
|
|
[[nodiscard]] QByteArray renderVideo(const Data::RichBlock &block);
|
|
[[nodiscard]] QByteArray renderAudio(const Data::RichBlock &block);
|
|
[[nodiscard]] QByteArray renderMediaGroup(
|
|
const Data::RichBlock &block,
|
|
const QByteArray &kind,
|
|
bool renderGroupCaption);
|
|
[[nodiscard]] QByteArray renderEmbed(const Data::RichBlock &block);
|
|
[[nodiscard]] QByteArray renderEmbedPost(const Data::RichBlock &block);
|
|
[[nodiscard]] QByteArray renderChannel(const Data::RichBlock &block);
|
|
[[nodiscard]] QByteArray renderMap(
|
|
const Data::RichBlock &block,
|
|
const QByteArray &kind);
|
|
[[nodiscard]] QByteArray renderRelatedArticles(
|
|
const Data::RichBlock &block);
|
|
[[nodiscard]] QByteArray renderRelatedArticle(
|
|
const Data::RichRelatedArticle &article,
|
|
int index);
|
|
[[nodiscard]] QByteArray renderCaption(
|
|
const Data::RichCaption &caption);
|
|
[[nodiscard]] QByteArray renderSourceMeta(
|
|
const QByteArray &source,
|
|
RichSourceMetaKind kind);
|
|
[[nodiscard]] QByteArray renderTextBlock(
|
|
const QByteArray &tag,
|
|
std::map<QByteArray, QByteArray> attributes,
|
|
const Data::RichText &text);
|
|
[[nodiscard]] QByteArray renderAuthorDate(
|
|
const Data::RichBlock &block);
|
|
[[nodiscard]] QByteArray renderCode(const Data::RichBlock &block);
|
|
[[nodiscard]] QByteArray renderList(const Data::RichBlock &block);
|
|
[[nodiscard]] QByteArray renderListItem(
|
|
const Data::RichListItem &item,
|
|
bool ordered);
|
|
[[nodiscard]] QByteArray renderQuote(const Data::RichBlock &block);
|
|
[[nodiscard]] QByteArray renderDetails(const Data::RichBlock &block);
|
|
[[nodiscard]] QByteArray renderTable(const Data::RichBlock &block);
|
|
[[nodiscard]] QByteArray renderTableRow(
|
|
const Data::RichTableRow &row);
|
|
[[nodiscard]] QByteArray renderTableCell(
|
|
const Data::RichTableCell &cell);
|
|
[[nodiscard]] QByteArray renderFallback(
|
|
const QByteArray &kind,
|
|
const QByteArray &label);
|
|
|
|
void collectAnchors();
|
|
void collectTextAnchors(const Data::RichText &text);
|
|
void collectTextAnchors(const std::vector<Data::RichText> &texts);
|
|
void collectCaptionAnchors(const Data::RichCaption &caption);
|
|
void collectBlockAnchors(const Data::RichBlock &block);
|
|
void collectBlockAnchors(const std::vector<Data::RichBlock> &blocks);
|
|
[[nodiscard]] QByteArray registerAnchor(const QByteArray &name);
|
|
[[nodiscard]] std::optional<QByteArray> fragmentHref(
|
|
const QByteArray &target) const;
|
|
[[nodiscard]] std::optional<QByteArray> textAnchorId(
|
|
const Data::RichText &text) const;
|
|
[[nodiscard]] std::optional<QByteArray> blockAnchorId(
|
|
const Data::RichBlock &block) const;
|
|
|
|
Context &_context;
|
|
const Data::RichMessage &_message;
|
|
int _messageId = 0;
|
|
bool _linkActive = false;
|
|
const QString &_internalLinksDomain;
|
|
const QByteArray &_relativeBase;
|
|
const RichMediaCallbacks &_media;
|
|
std::map<QByteArray, QByteArray> _firstAnchorIds;
|
|
std::map<QByteArray, int> _anchorOccurrences;
|
|
std::map<const Data::RichText*, QByteArray> _textAnchorIds;
|
|
std::map<const Data::RichBlock*, QByteArray> _blockAnchorIds;
|
|
|
|
};
|
|
|
|
std::map<QByteArray, QByteArray> RichBlockAttributes(
|
|
const QByteArray &classes,
|
|
const QByteArray &kind) {
|
|
return {
|
|
{ "class", QByteArray("rich_block ") + classes },
|
|
{ "data-rich-kind", kind },
|
|
};
|
|
}
|
|
|
|
QByteArray RichHeadingTag(int level) {
|
|
switch (level) {
|
|
case 1: return "h1";
|
|
case 2: return "h2";
|
|
case 3: return "h3";
|
|
case 4: return "h4";
|
|
case 5: return "h5";
|
|
case 6: return "h6";
|
|
}
|
|
return "h6";
|
|
}
|
|
|
|
QByteArray RichBoolAttribute(bool value) {
|
|
return value ? QByteArray("true") : QByteArray("false");
|
|
}
|
|
|
|
bool RichTextHasOutput(const Data::RichText &text) {
|
|
using Type = Data::RichText::Type;
|
|
switch (text.type) {
|
|
case Type::Empty:
|
|
return false;
|
|
case Type::Plain:
|
|
return !text.text.isEmpty();
|
|
case Type::Concat:
|
|
for (const auto &child : text.children) {
|
|
if (RichTextHasOutput(child)) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
case Type::Bold:
|
|
case Type::Italic:
|
|
case Type::Underline:
|
|
case Type::Strike:
|
|
case Type::Fixed:
|
|
case Type::Url:
|
|
case Type::Email:
|
|
case Type::Phone:
|
|
case Type::Subscript:
|
|
case Type::Superscript:
|
|
case Type::Marked:
|
|
case Type::Anchor:
|
|
case Type::Math:
|
|
case Type::CustomEmoji:
|
|
case Type::Spoiler:
|
|
case Type::Mention:
|
|
case Type::Hashtag:
|
|
case Type::BotCommand:
|
|
case Type::Cashtag:
|
|
case Type::AutoUrl:
|
|
case Type::AutoEmail:
|
|
case Type::AutoPhone:
|
|
case Type::BankCard:
|
|
case Type::MentionName:
|
|
case Type::FormattedDate:
|
|
case Type::InlineImage:
|
|
case Type::Diff:
|
|
return true;
|
|
}
|
|
Unexpected("Type in RichTextHasOutput.");
|
|
}
|
|
|
|
bool RichCaptionHasOutput(const Data::RichCaption &caption) {
|
|
return RichTextHasOutput(caption.text)
|
|
|| RichTextHasOutput(caption.credit);
|
|
}
|
|
|
|
int HexValue(char value) {
|
|
if (value >= '0' && value <= '9') {
|
|
return value - '0';
|
|
} else if (value >= 'a' && value <= 'f') {
|
|
return value - 'a' + 10;
|
|
} else if (value >= 'A' && value <= 'F') {
|
|
return value - 'A' + 10;
|
|
}
|
|
return -1;
|
|
}
|
|
|
|
bool HasEncodedControl(const QByteArray &value) {
|
|
for (auto i = 0; i + 2 < value.size(); ++i) {
|
|
if (value[i] != '%') {
|
|
continue;
|
|
}
|
|
const auto high = HexValue(value[i + 1]);
|
|
const auto low = HexValue(value[i + 2]);
|
|
if (high < 0 || low < 0) {
|
|
continue;
|
|
}
|
|
const auto decoded = (high << 4) | low;
|
|
if (decoded < 32 || decoded == 127) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool IsStrictTarget(const QByteArray &value) {
|
|
if (value.isEmpty() || value.size() > kRichTargetMaxBytes) {
|
|
return false;
|
|
}
|
|
for (const auto character : value) {
|
|
const auto byte = static_cast<uchar>(character);
|
|
if (byte < 32 || byte == 127) {
|
|
return false;
|
|
}
|
|
}
|
|
const auto decoded = QString::fromUtf8(value);
|
|
if (decoded.toUtf8() != value || decoded.trimmed() != decoded) {
|
|
return false;
|
|
}
|
|
for (const auto character : decoded) {
|
|
const auto category = character.category();
|
|
if (category == QChar::Other_Control
|
|
|| category == QChar::Separator_Line
|
|
|| category == QChar::Separator_Paragraph) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
std::optional<QByteArray> SafeHttpHref(const QByteArray &value) {
|
|
if (!IsStrictTarget(value)
|
|
|| value.startsWith("//")
|
|
|| HasEncodedControl(value)) {
|
|
return std::nullopt;
|
|
}
|
|
const auto url = QUrl::fromEncoded(value, QUrl::StrictMode);
|
|
const auto scheme = url.scheme();
|
|
if (!url.isValid()
|
|
|| url.isRelative()
|
|
|| url.host().isEmpty()
|
|
|| (scheme.compare(u"http"_q, Qt::CaseInsensitive) != 0
|
|
&& scheme.compare(u"https"_q, Qt::CaseInsensitive) != 0)) {
|
|
return std::nullopt;
|
|
}
|
|
const auto result = url.toEncoded(QUrl::FullyEncoded);
|
|
return (IsStrictTarget(result) && !HasEncodedControl(result))
|
|
? std::make_optional(result)
|
|
: std::nullopt;
|
|
}
|
|
|
|
std::optional<QByteArray> SafeEmailHref(const QByteArray &address) {
|
|
if (!IsStrictTarget(address)) {
|
|
return std::nullopt;
|
|
}
|
|
const auto separator = address.indexOf('@');
|
|
if (separator <= 0
|
|
|| separator != address.lastIndexOf('@')
|
|
|| separator + 1 == address.size()) {
|
|
return std::nullopt;
|
|
}
|
|
for (const auto character : QString::fromUtf8(address)) {
|
|
if (character.isSpace()) {
|
|
return std::nullopt;
|
|
}
|
|
}
|
|
for (const auto forbidden : QByteArray("/\\:%?#&=")) {
|
|
if (address.contains(forbidden)) {
|
|
return std::nullopt;
|
|
}
|
|
}
|
|
const auto result = QByteArray("mailto:") + address;
|
|
return IsStrictTarget(result)
|
|
? std::make_optional(result)
|
|
: std::nullopt;
|
|
}
|
|
|
|
std::optional<QByteArray> SafePhoneHref(const QByteArray &phone) {
|
|
if (!IsStrictTarget(phone)) {
|
|
return std::nullopt;
|
|
}
|
|
auto hasDigit = false;
|
|
for (const auto character : phone) {
|
|
if (character >= '0' && character <= '9') {
|
|
hasDigit = true;
|
|
} else if (character != '+'
|
|
&& character != '-'
|
|
&& character != ' '
|
|
&& character != '.'
|
|
&& character != '('
|
|
&& character != ')') {
|
|
return std::nullopt;
|
|
}
|
|
}
|
|
const auto result = QByteArray("tel:") + phone;
|
|
return (hasDigit && IsStrictTarget(result))
|
|
? std::make_optional(result)
|
|
: std::nullopt;
|
|
}
|
|
|
|
std::optional<QByteArray> SafeMentionHref(
|
|
const QByteArray &mention,
|
|
const QString &internalLinksDomain) {
|
|
if (!IsStrictTarget(mention)
|
|
|| mention.size() < 2
|
|
|| mention.front() != '@') {
|
|
return std::nullopt;
|
|
}
|
|
const auto username = mention.mid(1);
|
|
for (const auto character : username) {
|
|
if ((character < 'a' || character > 'z')
|
|
&& (character < 'A' || character > 'Z')
|
|
&& (character < '0' || character > '9')
|
|
&& character != '_') {
|
|
return std::nullopt;
|
|
}
|
|
}
|
|
return SafeHttpHref(internalLinksDomain.toUtf8() + username);
|
|
}
|
|
|
|
bool IsAsciiWord(
|
|
const QByteArray &value,
|
|
int minimumLength,
|
|
int maximumLength) {
|
|
if (value.size() < minimumLength || value.size() > maximumLength) {
|
|
return false;
|
|
}
|
|
for (const auto character : value) {
|
|
if ((character < 'a' || character > 'z')
|
|
&& (character < 'A' || character > 'Z')
|
|
&& (character < '0' || character > '9')
|
|
&& character != '_') {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
QByteArray RichChannelSourceName(Data::RichChannel::Source source) {
|
|
using Source = Data::RichChannel::Source;
|
|
switch (source) {
|
|
case Source::ChatEmpty: return "chat_empty";
|
|
case Source::Chat: return "chat";
|
|
case Source::ChatForbidden: return "chat_forbidden";
|
|
case Source::Channel: return "channel";
|
|
case Source::ChannelForbidden: return "channel_forbidden";
|
|
case Source::Community: return "community";
|
|
case Source::CommunityForbidden: return "community_forbidden";
|
|
}
|
|
Unexpected("Source in RichChannelSourceName.");
|
|
}
|
|
|
|
QByteArray RichChannelStatus(const Data::RichChannel &channel) {
|
|
using Source = Data::RichChannel::Source;
|
|
auto label = QByteArray();
|
|
switch (channel.source) {
|
|
case Source::ChatEmpty:
|
|
case Source::Chat:
|
|
label = "Chat";
|
|
break;
|
|
case Source::ChatForbidden:
|
|
label = "Chat unavailable";
|
|
break;
|
|
case Source::Channel:
|
|
label = "Channel";
|
|
break;
|
|
case Source::ChannelForbidden:
|
|
label = "Channel unavailable";
|
|
break;
|
|
case Source::Community:
|
|
label = "Community";
|
|
break;
|
|
case Source::CommunityForbidden:
|
|
label = "Community unavailable";
|
|
break;
|
|
}
|
|
if (label.isEmpty()) {
|
|
Unexpected("Source in RichChannelStatus.");
|
|
}
|
|
return (channel.username && !channel.username->isEmpty())
|
|
? ('@' + *channel.username + ", " + label)
|
|
: label;
|
|
}
|
|
|
|
QByteArray RichMapPointSourceName(Data::RichMapPoint::Source source) {
|
|
using Source = Data::RichMapPoint::Source;
|
|
switch (source) {
|
|
case Source::GeoPointEmpty: return "geo_point_empty";
|
|
case Source::GeoPoint: return "geo_point";
|
|
case Source::InputGeoPointEmpty: return "input_geo_point_empty";
|
|
case Source::InputGeoPoint: return "input_geo_point";
|
|
}
|
|
Unexpected("Source in RichMapPointSourceName.");
|
|
}
|
|
|
|
struct RichMapCoordinateValues {
|
|
QByteArray latitude;
|
|
QByteArray longitude;
|
|
};
|
|
|
|
std::optional<RichMapCoordinateValues> RichMapCoordinates(
|
|
const Data::RichMapPoint &point) {
|
|
using Source = Data::RichMapPoint::Source;
|
|
switch (point.source) {
|
|
case Source::GeoPointEmpty:
|
|
case Source::InputGeoPointEmpty:
|
|
return std::nullopt;
|
|
case Source::GeoPoint:
|
|
case Source::InputGeoPoint:
|
|
break;
|
|
}
|
|
if (!std::isfinite(point.latitude)
|
|
|| !std::isfinite(point.longitude)
|
|
|| point.latitude < -90.
|
|
|| point.latitude > 90.
|
|
|| point.longitude < -180.
|
|
|| point.longitude > 180.) {
|
|
return std::nullopt;
|
|
}
|
|
return RichMapCoordinateValues{
|
|
Data::NumberToString(point.latitude),
|
|
Data::NumberToString(point.longitude),
|
|
};
|
|
}
|
|
|
|
bool IsUnicodeHashtagWord(const QByteArray &value) {
|
|
const auto characters = QString::fromUtf8(value).toUcs4();
|
|
if (characters.size() < kRichHashtagMinLength
|
|
|| characters.size() > kRichHashtagMaxLength) {
|
|
return false;
|
|
}
|
|
auto allDecimalDigits = true;
|
|
for (const auto character : characters) {
|
|
const auto category = QChar::category(character);
|
|
const auto isMark = (category >= QChar::Mark_NonSpacing)
|
|
&& (category <= QChar::Mark_Enclosing);
|
|
if (!QChar::isLetterOrNumber(character)
|
|
&& category != QChar::Punctuation_Connector
|
|
&& !isMark) {
|
|
return false;
|
|
}
|
|
allDecimalDigits = allDecimalDigits && QChar::isDigit(character);
|
|
}
|
|
return !allDecimalDigits;
|
|
}
|
|
|
|
bool IsHashtagAction(const QByteArray &value) {
|
|
const auto separator = value.indexOf('@');
|
|
const auto hashtag = (separator < 0) ? value : value.left(separator);
|
|
if (!IsUnicodeHashtagWord(hashtag)) {
|
|
return false;
|
|
}
|
|
return (separator < 0)
|
|
|| (separator == value.lastIndexOf('@')
|
|
&& IsAsciiWord(
|
|
value.mid(separator + 1),
|
|
1,
|
|
kRichUsernameMaxLength));
|
|
}
|
|
|
|
bool IsBotCommandAction(const QByteArray &value) {
|
|
const auto separator = value.indexOf('@');
|
|
const auto command = (separator < 0) ? value : value.left(separator);
|
|
if (!IsAsciiWord(command, 1, kRichCommandMaxLength)) {
|
|
return false;
|
|
}
|
|
return (separator < 0)
|
|
|| (separator == value.lastIndexOf('@')
|
|
&& IsAsciiWord(
|
|
value.mid(separator + 1),
|
|
kRichUsernameMinLength,
|
|
kRichUsernameMaxLength));
|
|
}
|
|
|
|
bool IsCashtagAction(const QByteArray &value) {
|
|
if (value.isEmpty() || value.size() > kRichCashtagMaxLength) {
|
|
return false;
|
|
}
|
|
for (const auto character : value) {
|
|
if (character < 'A' || character > 'Z') {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
bool IsValidActionValue(const QByteArray &value, char prefix) {
|
|
switch (prefix) {
|
|
case '#': return IsHashtagAction(value);
|
|
case '/': return IsBotCommandAction(value);
|
|
case '$': return IsCashtagAction(value);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
std::optional<QByteArray> SafeActionData(
|
|
const QByteArray &target,
|
|
char prefix) {
|
|
if (!IsStrictTarget(target)
|
|
|| target.front() != prefix
|
|
|| target.size() < 2) {
|
|
return std::nullopt;
|
|
}
|
|
const auto result = target.mid(1);
|
|
return (IsValidActionValue(result, prefix) && IsStrictTarget(result))
|
|
? std::make_optional(result)
|
|
: std::nullopt;
|
|
}
|
|
|
|
bool IsAsciiDecimal(const QByteArray &value) {
|
|
if (value.isEmpty()) {
|
|
return false;
|
|
}
|
|
for (const auto character : value) {
|
|
if (character < '0' || character > '9') {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
std::optional<QByteArray> SafeRelativeEmojiHref(
|
|
const QByteArray &path,
|
|
const QByteArray &relativeBase) {
|
|
if (!IsStrictTarget(path)
|
|
|| path.startsWith('/')
|
|
|| path.contains('\\')
|
|
|| path.contains(':')
|
|
|| path.contains('%')
|
|
|| path.contains('?')
|
|
|| path.contains('#')
|
|
|| path == Data::TextPart::UnavailableEmoji()
|
|
|| IsAsciiDecimal(path)) {
|
|
return std::nullopt;
|
|
}
|
|
for (const auto &component : path.split('/')) {
|
|
if (component.isEmpty() || component == "." || component == "..") {
|
|
return std::nullopt;
|
|
}
|
|
}
|
|
const auto result = relativeBase + path;
|
|
return IsStrictTarget(result)
|
|
? std::make_optional(result)
|
|
: std::nullopt;
|
|
}
|
|
|
|
bool AppendPlainTarget(
|
|
QByteArray &result,
|
|
const QByteArray &value) {
|
|
if (result.size() > kRichTargetMaxBytes
|
|
|| value.size() > kRichTargetMaxBytes - result.size()) {
|
|
return false;
|
|
}
|
|
result.append(value);
|
|
return true;
|
|
}
|
|
|
|
bool AppendPlainTarget(
|
|
QByteArray &result,
|
|
const std::vector<Data::RichText> &texts);
|
|
|
|
bool AppendPlainTarget(
|
|
QByteArray &result,
|
|
const Data::RichText &text) {
|
|
using Type = Data::RichText::Type;
|
|
switch (text.type) {
|
|
case Type::Empty:
|
|
return true;
|
|
case Type::Plain:
|
|
return AppendPlainTarget(result, text.text);
|
|
case Type::Concat:
|
|
case Type::Bold:
|
|
case Type::Italic:
|
|
case Type::Underline:
|
|
case Type::Strike:
|
|
case Type::Fixed:
|
|
case Type::Url:
|
|
case Type::Email:
|
|
case Type::Phone:
|
|
case Type::Subscript:
|
|
case Type::Superscript:
|
|
case Type::Marked:
|
|
case Type::Anchor:
|
|
case Type::Spoiler:
|
|
case Type::Mention:
|
|
case Type::Hashtag:
|
|
case Type::BotCommand:
|
|
case Type::Cashtag:
|
|
case Type::AutoUrl:
|
|
case Type::AutoEmail:
|
|
case Type::AutoPhone:
|
|
case Type::BankCard:
|
|
case Type::MentionName:
|
|
case Type::FormattedDate:
|
|
return AppendPlainTarget(result, text.children);
|
|
case Type::CustomEmoji:
|
|
return AppendPlainTarget(result, text.text);
|
|
case Type::Diff:
|
|
return AppendPlainTarget(result, text.children);
|
|
case Type::Math:
|
|
case Type::InlineImage:
|
|
return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
bool AppendPlainTarget(
|
|
QByteArray &result,
|
|
const std::vector<Data::RichText> &texts) {
|
|
for (const auto &text : texts) {
|
|
if (!AppendPlainTarget(result, text)) {
|
|
return false;
|
|
}
|
|
}
|
|
return true;
|
|
}
|
|
|
|
std::optional<QByteArray> PlainTarget(const Data::RichText &text) {
|
|
auto result = QByteArray();
|
|
return AppendPlainTarget(result, text)
|
|
? std::make_optional(result)
|
|
: std::nullopt;
|
|
}
|
|
|
|
QByteArray AnchorToken(const QByteArray &name) {
|
|
if (name.isEmpty()) {
|
|
return "empty";
|
|
}
|
|
const auto encoded = [](const QByteArray &value) {
|
|
return value.toBase64(
|
|
QByteArray::Base64UrlEncoding
|
|
| QByteArray::OmitTrailingEquals);
|
|
};
|
|
if (name.size() <= kRichAnchorDirectBytes) {
|
|
return encoded(name);
|
|
}
|
|
const auto hash = hashSha256(name.data(), int(name.size()));
|
|
return encoded(name.left(kRichAnchorPrefixBytes))
|
|
+ '-'
|
|
+ QByteArray(hash.data(), int(hash.size())).toHex().left(16);
|
|
}
|
|
|
|
RichHtmlRenderer::RichHtmlRenderer(
|
|
Context &context,
|
|
const Data::RichMessage &message,
|
|
int messageId,
|
|
const QString &internalLinksDomain,
|
|
const QByteArray &relativeBase,
|
|
const RichMediaCallbacks &media)
|
|
: _context(context)
|
|
, _message(message)
|
|
, _messageId(messageId)
|
|
, _internalLinksDomain(internalLinksDomain)
|
|
, _relativeBase(relativeBase)
|
|
, _media(media) {
|
|
}
|
|
|
|
const Data::Photo *RichHtmlRenderer::findPhoto(uint64 id) const {
|
|
if (!id) {
|
|
return nullptr;
|
|
}
|
|
const auto i = _message.photos.find(id);
|
|
return (i != end(_message.photos)) ? &i->second : nullptr;
|
|
}
|
|
|
|
const Data::Document *RichHtmlRenderer::findDocument(uint64 id) const {
|
|
if (!id) {
|
|
return nullptr;
|
|
}
|
|
const auto i = _message.documents.find(id);
|
|
return (i != end(_message.documents)) ? &i->second : nullptr;
|
|
}
|
|
|
|
RichTailState RichHtmlRenderer::tailState(
|
|
const std::vector<Data::RichBlock> &blocks) const {
|
|
for (auto i = blocks.rbegin(); i != blocks.rend(); ++i) {
|
|
const auto state = tailState(*i);
|
|
if (state != RichTailState::Empty) {
|
|
return state;
|
|
}
|
|
}
|
|
return RichTailState::Empty;
|
|
}
|
|
|
|
RichTailState RichHtmlRenderer::tailState(
|
|
const Data::RichBlock &block) const {
|
|
using Content = Data::RichListItemContent;
|
|
using Kind = Data::RichBlock::Kind;
|
|
using QuoteContent = Data::RichQuoteContent;
|
|
|
|
switch (block.kind) {
|
|
case Kind::Unsupported:
|
|
case Kind::Heading:
|
|
case Kind::Paragraph:
|
|
case Kind::Footer:
|
|
case Kind::Thinking:
|
|
case Kind::AuthorDate:
|
|
case Kind::Code:
|
|
case Kind::Divider:
|
|
case Kind::Math:
|
|
case Kind::Table:
|
|
case Kind::Unknown:
|
|
return RichTailState::Other;
|
|
case Kind::Anchor:
|
|
return RichTailState::Empty;
|
|
case Kind::List: {
|
|
if (block.listItems.empty()) {
|
|
return RichTailState::Empty;
|
|
}
|
|
const auto &item = block.listItems.back();
|
|
if (item.content == Content::Blocks) {
|
|
const auto state = tailState(item.blocks);
|
|
if (state != RichTailState::Empty) {
|
|
return state;
|
|
}
|
|
}
|
|
return RichTailState::Other;
|
|
}
|
|
case Kind::Quote:
|
|
if (RichTextHasOutput(block.quoteCaption)) {
|
|
return RichTailState::Other;
|
|
}
|
|
if (block.quoteContent == QuoteContent::Blocks) {
|
|
const auto state = tailState(block.blocks);
|
|
if (state != RichTailState::Empty) {
|
|
return state;
|
|
}
|
|
}
|
|
return RichTailState::Other;
|
|
case Kind::Photo:
|
|
return (RichCaptionHasOutput(block.caption)
|
|
|| (block.optionalUrl && !block.optionalUrl->isEmpty()))
|
|
? RichTailState::Other
|
|
: RichTailState::Media;
|
|
case Kind::Video:
|
|
case Kind::Audio:
|
|
case Kind::Map:
|
|
case Kind::InputMap:
|
|
return RichCaptionHasOutput(block.caption)
|
|
? RichTailState::Other
|
|
: RichTailState::Media;
|
|
case Kind::Cover:
|
|
return tailState(block.blocks);
|
|
case Kind::Embed:
|
|
return (RichCaptionHasOutput(block.caption)
|
|
|| (block.posterPhotoId
|
|
&& block.optionalUrl
|
|
&& !block.optionalUrl->isEmpty()))
|
|
? RichTailState::Other
|
|
: RichTailState::Media;
|
|
case Kind::EmbedPost: {
|
|
if (RichCaptionHasOutput(block.caption)) {
|
|
return RichTailState::Other;
|
|
}
|
|
const auto state = tailState(block.blocks);
|
|
if (state != RichTailState::Empty) {
|
|
return state;
|
|
}
|
|
return block.url.isEmpty()
|
|
? RichTailState::Media
|
|
: RichTailState::Other;
|
|
}
|
|
case Kind::Collage:
|
|
case Kind::Slideshow:
|
|
return RichCaptionHasOutput(block.caption)
|
|
? RichTailState::Other
|
|
: tailState(block.blocks);
|
|
case Kind::Channel:
|
|
return RichTailState::Media;
|
|
case Kind::Details:
|
|
if (block.open) {
|
|
const auto state = tailState(block.blocks);
|
|
if (state != RichTailState::Empty) {
|
|
return state;
|
|
}
|
|
}
|
|
return RichTailState::Other;
|
|
case Kind::RelatedArticles:
|
|
if (block.relatedArticles.empty()) {
|
|
return RichTextHasOutput(block.text)
|
|
? RichTailState::Other
|
|
: RichTailState::Empty;
|
|
} else {
|
|
const auto &article = block.relatedArticles.back();
|
|
if (!article.url.isEmpty()) {
|
|
return RichTailState::Other;
|
|
}
|
|
const auto description = article.description
|
|
? *article.description
|
|
: QByteArray();
|
|
if (article.photoId && !description.isEmpty()) {
|
|
const auto photo = findPhoto(*article.photoId);
|
|
if (!RichFileDescription(
|
|
photo ? &photo->image.file : nullptr).isEmpty()) {
|
|
return RichTailState::Other;
|
|
}
|
|
}
|
|
return RichTailState::Media;
|
|
}
|
|
}
|
|
Unexpected("Kind in RichHtmlRenderer::tailState.");
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderMessage() {
|
|
collectAnchors();
|
|
auto result = _context.pushTag(
|
|
"div",
|
|
{
|
|
{ "class", "text rich_message" },
|
|
{ "data-rich-message", Data::NumberToString(_messageId) },
|
|
{ "data-rich-part", RichBoolAttribute(_message.part) },
|
|
{ "dir", _message.rtl
|
|
? QByteArray("rtl")
|
|
: QByteArray("auto") },
|
|
{ "inline", QByteArray() },
|
|
});
|
|
result += renderBlocks(_message.blocks);
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderCaption(
|
|
const Data::RichCaption &caption) {
|
|
const auto text = renderText(caption.text);
|
|
const auto credit = renderText(caption.credit);
|
|
if (text.isEmpty() && credit.isEmpty()) {
|
|
return QByteArray();
|
|
}
|
|
auto result = _context.pushTag("figcaption", {
|
|
{ "class", "rich_media_caption" },
|
|
{ "dir", "auto" },
|
|
});
|
|
if (!text.isEmpty()) {
|
|
result += _context.pushTag("div", {
|
|
{ "class", "rich_caption_text" },
|
|
});
|
|
result += text;
|
|
result += _context.popTag();
|
|
}
|
|
if (!credit.isEmpty()) {
|
|
result += _context.pushTag("cite", {
|
|
{ "class", "rich_caption_credit" },
|
|
});
|
|
result += credit;
|
|
result += _context.popTag();
|
|
}
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderSourceMeta(
|
|
const QByteArray &source,
|
|
RichSourceMetaKind kind) {
|
|
if (source.isEmpty()) {
|
|
return QByteArray();
|
|
}
|
|
const auto containerClass = [kind] {
|
|
switch (kind) {
|
|
case RichSourceMetaKind::Source:
|
|
return QByteArray("rich_source_meta");
|
|
case RichSourceMetaKind::EmbedPost:
|
|
return QByteArray("rich_embed_post_meta");
|
|
}
|
|
Unexpected("Kind in RichHtmlRenderer::renderSourceMeta.");
|
|
}();
|
|
auto result = _context.pushTag("div", {
|
|
{ "class", containerClass },
|
|
});
|
|
const auto href = SafeHttpHref(source);
|
|
if (href && !_linkActive) {
|
|
result += _context.pushTag("a", {
|
|
{ "class", "rich_source_link" },
|
|
{ "href", *href },
|
|
{ "inline", QByteArray() },
|
|
});
|
|
const auto previous = _linkActive;
|
|
_linkActive = true;
|
|
const auto guard = gsl::finally([&] { _linkActive = previous; });
|
|
result += SerializeString(source);
|
|
result += _context.popTag();
|
|
} else {
|
|
result += _context.pushTag("span", {
|
|
{ "class", "rich_source_text" },
|
|
{ "inline", QByteArray() },
|
|
});
|
|
result += SerializeString(source);
|
|
result += _context.popTag();
|
|
}
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderPhoto(
|
|
const Data::RichBlock &block) {
|
|
auto attributes = RichBlockAttributes("rich_media_item", "photo");
|
|
attributes.emplace(
|
|
"data-photo-id",
|
|
Data::NumberToString(block.photoId));
|
|
attributes.emplace("data-spoiler", RichBoolAttribute(block.spoiler));
|
|
if (block.optionalWebpageId) {
|
|
attributes.emplace(
|
|
"data-webpage-id",
|
|
Data::NumberToString(*block.optionalWebpageId));
|
|
}
|
|
auto result = _context.pushTag("figure", std::move(attributes));
|
|
result += _media.photo(findPhoto(block.photoId));
|
|
if (block.optionalUrl) {
|
|
result += renderSourceMeta(
|
|
*block.optionalUrl,
|
|
RichSourceMetaKind::Source);
|
|
}
|
|
result += renderCaption(block.caption);
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderVideo(
|
|
const Data::RichBlock &block) {
|
|
auto attributes = RichBlockAttributes("rich_media_item", "video");
|
|
attributes.emplace(
|
|
"data-document-id",
|
|
Data::NumberToString(block.documentId));
|
|
attributes.emplace("data-autoplay", RichBoolAttribute(block.autoplay));
|
|
attributes.emplace("data-loop", RichBoolAttribute(block.loop));
|
|
attributes.emplace("data-spoiler", RichBoolAttribute(block.spoiler));
|
|
auto result = _context.pushTag("figure", std::move(attributes));
|
|
result += _media.video(findDocument(block.documentId));
|
|
result += renderCaption(block.caption);
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderAudio(
|
|
const Data::RichBlock &block) {
|
|
auto attributes = RichBlockAttributes("rich_media_item", "audio");
|
|
attributes.emplace(
|
|
"data-document-id",
|
|
Data::NumberToString(block.documentId));
|
|
auto result = _context.pushTag("figure", std::move(attributes));
|
|
result += _media.audio(findDocument(block.documentId));
|
|
result += renderCaption(block.caption);
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderMediaGroup(
|
|
const Data::RichBlock &block,
|
|
const QByteArray &kind,
|
|
bool renderGroupCaption) {
|
|
auto attributes = RichBlockAttributes("rich_media_group", kind);
|
|
attributes.emplace(
|
|
"data-rich-has-items",
|
|
RichBoolAttribute(!block.blocks.empty()));
|
|
attributes.emplace(
|
|
"data-rich-items-end-media",
|
|
RichBoolAttribute(
|
|
tailState(block.blocks) == RichTailState::Media));
|
|
auto result = _context.pushTag("section", std::move(attributes));
|
|
result += _context.pushTag("div", {
|
|
{ "class", "rich_media_group_items" },
|
|
});
|
|
result += renderBlocks(block.blocks);
|
|
result += _context.popTag();
|
|
if (renderGroupCaption) {
|
|
result += renderCaption(block.caption);
|
|
}
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderEmbed(
|
|
const Data::RichBlock &block) {
|
|
const auto hasPoster = block.posterPhotoId.has_value();
|
|
const auto hasMeta = hasPoster
|
|
&& block.optionalUrl
|
|
&& !block.optionalUrl->isEmpty();
|
|
auto attributes = RichBlockAttributes(
|
|
"rich_media_item rich_embed",
|
|
"embed");
|
|
attributes.emplace(
|
|
"data-full-width",
|
|
RichBoolAttribute(block.fullWidth));
|
|
attributes.emplace(
|
|
"data-allow-scrolling",
|
|
RichBoolAttribute(block.allowScrolling));
|
|
attributes.emplace("data-rich-has-meta", RichBoolAttribute(hasMeta));
|
|
if (block.posterPhotoId) {
|
|
attributes.emplace(
|
|
"data-poster-photo-id",
|
|
Data::NumberToString(*block.posterPhotoId));
|
|
}
|
|
if (block.width) {
|
|
attributes.emplace(
|
|
"data-source-width",
|
|
Data::NumberToString(*block.width));
|
|
}
|
|
if (block.height) {
|
|
attributes.emplace(
|
|
"data-source-height",
|
|
Data::NumberToString(*block.height));
|
|
}
|
|
auto result = _context.pushTag("figure", std::move(attributes));
|
|
if (hasPoster) {
|
|
result += _media.photo(findPhoto(*block.posterPhotoId));
|
|
if (block.optionalUrl) {
|
|
result += renderSourceMeta(
|
|
*block.optionalUrl,
|
|
RichSourceMetaKind::Source);
|
|
}
|
|
} else {
|
|
auto media = MediaData();
|
|
media.title = "Embed";
|
|
media.classes = "media_file";
|
|
if (block.optionalUrl) {
|
|
media.status = *block.optionalUrl;
|
|
if (const auto href = SafeHttpHref(*block.optionalUrl)) {
|
|
media.link = QString::fromUtf8(*href);
|
|
}
|
|
}
|
|
result += _media.generic(media);
|
|
}
|
|
result += renderCaption(block.caption);
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderEmbedPost(
|
|
const Data::RichBlock &block) {
|
|
const auto photo = findPhoto(block.authorPhotoId);
|
|
const auto hasMeta = !block.url.isEmpty();
|
|
auto attributes = RichBlockAttributes("rich_embed_post", "embed-post");
|
|
attributes.emplace(
|
|
"data-webpage-id",
|
|
Data::NumberToString(block.webpageId));
|
|
attributes.emplace(
|
|
"data-author-photo-id",
|
|
Data::NumberToString(block.authorPhotoId));
|
|
attributes.emplace("data-date", Data::NumberToString(block.date));
|
|
attributes.emplace("data-rich-has-meta", RichBoolAttribute(hasMeta));
|
|
attributes.emplace(
|
|
"data-rich-has-items",
|
|
RichBoolAttribute(!block.blocks.empty()));
|
|
attributes.emplace(
|
|
"data-rich-items-end-media",
|
|
RichBoolAttribute(
|
|
tailState(block.blocks) == RichTailState::Media));
|
|
auto result = _context.pushTag("section", std::move(attributes));
|
|
result += _context.pushTag("div", {
|
|
{ "class", "rich_embed_post_author" },
|
|
});
|
|
auto media = MediaData();
|
|
media.title = block.author.isEmpty()
|
|
? QByteArray("Embed post")
|
|
: block.author;
|
|
media.description = RichFileDescription(
|
|
photo ? &photo->image.file : nullptr);
|
|
media.status = block.date
|
|
? Data::FormatDateTime(block.date)
|
|
: QByteArray();
|
|
media.classes = "media_contact";
|
|
result += _media.photoCard(media, photo);
|
|
result += _context.popTag();
|
|
result += renderSourceMeta(block.url, RichSourceMetaKind::EmbedPost);
|
|
result += _context.pushTag("div", {
|
|
{ "class", "rich_embed_post_blocks" },
|
|
});
|
|
result += renderBlocks(block.blocks);
|
|
result += _context.popTag();
|
|
result += renderCaption(block.caption);
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderChannel(
|
|
const Data::RichBlock &block) {
|
|
const auto &channel = block.channel;
|
|
auto attributes = RichBlockAttributes(
|
|
"rich_reference_block",
|
|
"channel");
|
|
attributes.emplace(
|
|
"data-channel-source",
|
|
RichChannelSourceName(channel.source));
|
|
attributes.emplace("data-channel-id", Data::NumberToString(channel.id));
|
|
attributes.emplace("data-broadcast", RichBoolAttribute(channel.broadcast));
|
|
attributes.emplace("data-megagroup", RichBoolAttribute(channel.megagroup));
|
|
attributes.emplace("data-monoforum", RichBoolAttribute(channel.monoforum));
|
|
if (channel.accessHash) {
|
|
attributes.emplace(
|
|
"data-access-hash",
|
|
Data::NumberToString(*channel.accessHash));
|
|
}
|
|
auto result = _context.pushTag("section", std::move(attributes));
|
|
auto media = MediaData();
|
|
media.title = (channel.title && !channel.title->isEmpty())
|
|
? *channel.title
|
|
: QByteArray("Channel");
|
|
media.status = RichChannelStatus(channel);
|
|
media.classes = "media_contact";
|
|
if (channel.username
|
|
&& IsAsciiWord(
|
|
*channel.username,
|
|
kRichUsernameMinLength,
|
|
kRichUsernameMaxLength)) {
|
|
const auto target = _internalLinksDomain.toUtf8() + *channel.username;
|
|
if (const auto href = SafeHttpHref(target)) {
|
|
media.link = QString::fromUtf8(*href);
|
|
}
|
|
}
|
|
result += _media.generic(media);
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderMap(
|
|
const Data::RichBlock &block,
|
|
const QByteArray &kind) {
|
|
const auto &point = block.mapPoint;
|
|
auto attributes = RichBlockAttributes("rich_reference_block", kind);
|
|
attributes.emplace(
|
|
"data-point-source",
|
|
RichMapPointSourceName(point.source));
|
|
attributes.emplace("data-zoom", Data::NumberToString(block.zoom));
|
|
attributes.emplace(
|
|
"data-source-width",
|
|
Data::NumberToString(block.mapWidth));
|
|
attributes.emplace(
|
|
"data-source-height",
|
|
Data::NumberToString(block.mapHeight));
|
|
if (point.accessHash) {
|
|
attributes.emplace(
|
|
"data-access-hash",
|
|
Data::NumberToString(*point.accessHash));
|
|
}
|
|
if (point.accuracyRadius) {
|
|
attributes.emplace(
|
|
"data-accuracy-radius",
|
|
Data::NumberToString(*point.accuracyRadius));
|
|
}
|
|
auto result = _context.pushTag("figure", std::move(attributes));
|
|
auto media = MediaData();
|
|
media.title = "Location";
|
|
media.classes = "media_location";
|
|
if (const auto coordinates = RichMapCoordinates(point)) {
|
|
const auto joined = coordinates->latitude
|
|
+ ','
|
|
+ coordinates->longitude;
|
|
media.status = coordinates->latitude
|
|
+ ", "
|
|
+ coordinates->longitude;
|
|
const auto target = QByteArray("https://maps.google.com/maps?q=")
|
|
+ joined
|
|
+ "&ll="
|
|
+ joined
|
|
+ "&z=16";
|
|
if (const auto href = SafeHttpHref(target)) {
|
|
media.link = QString::fromUtf8(*href);
|
|
}
|
|
}
|
|
result += _media.generic(media);
|
|
result += renderCaption(block.caption);
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderRelatedArticle(
|
|
const Data::RichRelatedArticle &article,
|
|
int index) {
|
|
auto attributes = std::map<QByteArray, QByteArray>{
|
|
{ "class", "rich_related_article" },
|
|
{ "data-rich-article-index", Data::NumberToString(index) },
|
|
{ "data-webpage-id", Data::NumberToString(article.webpageId) },
|
|
};
|
|
if (article.photoId) {
|
|
attributes.emplace(
|
|
"data-photo-id",
|
|
Data::NumberToString(*article.photoId));
|
|
}
|
|
if (article.publishedDate) {
|
|
attributes.emplace(
|
|
"data-published-date",
|
|
Data::NumberToString(*article.publishedDate));
|
|
}
|
|
auto result = _context.pushTag("article", std::move(attributes));
|
|
auto media = MediaData();
|
|
media.title = (article.title && !article.title->isEmpty())
|
|
? *article.title
|
|
: QByteArray("Related article");
|
|
if (article.author && !article.author->isEmpty()) {
|
|
media.status = *article.author;
|
|
}
|
|
const auto publishedDate = article.publishedDate
|
|
? Data::FormatDateTime(*article.publishedDate)
|
|
: QByteArray();
|
|
if (!publishedDate.isEmpty()) {
|
|
if (!media.status.isEmpty()) {
|
|
media.status += ", ";
|
|
}
|
|
media.status += publishedDate;
|
|
}
|
|
const auto description = article.description
|
|
? *article.description
|
|
: QByteArray();
|
|
auto displacedDescription = false;
|
|
if (article.photoId) {
|
|
const auto photo = findPhoto(*article.photoId);
|
|
const auto fileDescription = RichFileDescription(
|
|
photo ? &photo->image.file : nullptr);
|
|
media.classes = "media_photo";
|
|
media.description = fileDescription.isEmpty()
|
|
? description
|
|
: fileDescription;
|
|
displacedDescription = !fileDescription.isEmpty()
|
|
&& !description.isEmpty();
|
|
result += _media.photoCard(media, photo);
|
|
} else {
|
|
media.classes = "media_file";
|
|
media.description = description;
|
|
result += _media.generic(media);
|
|
}
|
|
if (displacedDescription) {
|
|
result += _context.pushTag("div", {
|
|
{ "class", "rich_article_description" },
|
|
});
|
|
result += SerializeString(description);
|
|
result += _context.popTag();
|
|
}
|
|
result += renderSourceMeta(article.url, RichSourceMetaKind::Source);
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderRelatedArticles(
|
|
const Data::RichBlock &block) {
|
|
const auto title = renderText(block.text);
|
|
auto result = _context.pushTag(
|
|
"section",
|
|
RichBlockAttributes(
|
|
"rich_related_articles",
|
|
"related-articles"));
|
|
if (!title.isEmpty()) {
|
|
result += _context.pushTag("div", {
|
|
{ "class", "rich_related_title" },
|
|
{ "dir", "auto" },
|
|
});
|
|
result += title;
|
|
result += _context.popTag();
|
|
}
|
|
result += _context.pushTag("div", {
|
|
{ "class", "rich_related_items" },
|
|
});
|
|
auto index = 0;
|
|
for (const auto &article : block.relatedArticles) {
|
|
result += renderRelatedArticle(article, index++);
|
|
}
|
|
result += _context.popTag();
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
|
|
void RichHtmlRenderer::collectAnchors() {
|
|
_firstAnchorIds.clear();
|
|
_anchorOccurrences.clear();
|
|
_textAnchorIds.clear();
|
|
_blockAnchorIds.clear();
|
|
collectBlockAnchors(_message.blocks);
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::registerAnchor(const QByteArray &name) {
|
|
const auto base = QByteArray("rich-message-")
|
|
+ Data::NumberToString(_messageId)
|
|
+ "-anchor-"
|
|
+ AnchorToken(name);
|
|
auto &occurrence = _anchorOccurrences[base];
|
|
++occurrence;
|
|
const auto result = base + ((occurrence > 1)
|
|
? ('-' + Data::NumberToString(occurrence))
|
|
: QByteArray());
|
|
_firstAnchorIds.emplace(name, result);
|
|
return result;
|
|
}
|
|
|
|
void RichHtmlRenderer::collectTextAnchors(const Data::RichText &text) {
|
|
using Type = Data::RichText::Type;
|
|
switch (text.type) {
|
|
case Type::Anchor:
|
|
_textAnchorIds.emplace(&text, registerAnchor(text.data));
|
|
collectTextAnchors(text.children);
|
|
break;
|
|
case Type::Concat:
|
|
case Type::Bold:
|
|
case Type::Italic:
|
|
case Type::Underline:
|
|
case Type::Strike:
|
|
case Type::Fixed:
|
|
case Type::Url:
|
|
case Type::Email:
|
|
case Type::Phone:
|
|
case Type::Subscript:
|
|
case Type::Superscript:
|
|
case Type::Marked:
|
|
case Type::Spoiler:
|
|
case Type::Mention:
|
|
case Type::Hashtag:
|
|
case Type::BotCommand:
|
|
case Type::Cashtag:
|
|
case Type::AutoUrl:
|
|
case Type::AutoEmail:
|
|
case Type::AutoPhone:
|
|
case Type::BankCard:
|
|
case Type::MentionName:
|
|
case Type::FormattedDate:
|
|
collectTextAnchors(text.children);
|
|
break;
|
|
case Type::Diff:
|
|
collectTextAnchors(text.children);
|
|
collectTextAnchors(text.oldChildren);
|
|
break;
|
|
case Type::Empty:
|
|
case Type::Plain:
|
|
case Type::Math:
|
|
case Type::CustomEmoji:
|
|
case Type::InlineImage:
|
|
break;
|
|
}
|
|
}
|
|
|
|
void RichHtmlRenderer::collectTextAnchors(
|
|
const std::vector<Data::RichText> &texts) {
|
|
for (const auto &text : texts) {
|
|
collectTextAnchors(text);
|
|
}
|
|
}
|
|
|
|
void RichHtmlRenderer::collectCaptionAnchors(
|
|
const Data::RichCaption &caption) {
|
|
collectTextAnchors(caption.text);
|
|
collectTextAnchors(caption.credit);
|
|
}
|
|
|
|
void RichHtmlRenderer::collectBlockAnchors(
|
|
const std::vector<Data::RichBlock> &blocks) {
|
|
for (const auto &block : blocks) {
|
|
collectBlockAnchors(block);
|
|
}
|
|
}
|
|
|
|
void RichHtmlRenderer::collectBlockAnchors(const Data::RichBlock &block) {
|
|
using Content = Data::RichListItemContent;
|
|
using Kind = Data::RichBlock::Kind;
|
|
using QuoteContent = Data::RichQuoteContent;
|
|
switch (block.kind) {
|
|
case Kind::Heading:
|
|
case Kind::Paragraph:
|
|
case Kind::Footer:
|
|
case Kind::Thinking:
|
|
case Kind::AuthorDate:
|
|
case Kind::Code:
|
|
collectTextAnchors(block.text);
|
|
break;
|
|
case Kind::Anchor:
|
|
_blockAnchorIds.emplace(&block, registerAnchor(block.name));
|
|
break;
|
|
case Kind::List:
|
|
for (const auto &item : block.listItems) {
|
|
switch (item.content) {
|
|
case Content::Text:
|
|
if (item.text) {
|
|
collectTextAnchors(*item.text);
|
|
}
|
|
break;
|
|
case Content::Blocks:
|
|
collectBlockAnchors(item.blocks);
|
|
break;
|
|
}
|
|
}
|
|
break;
|
|
case Kind::Quote:
|
|
switch (block.quoteContent) {
|
|
case QuoteContent::Text:
|
|
collectTextAnchors(block.text);
|
|
break;
|
|
case QuoteContent::Blocks:
|
|
collectBlockAnchors(block.blocks);
|
|
break;
|
|
}
|
|
collectTextAnchors(block.quoteCaption);
|
|
break;
|
|
case Kind::Photo:
|
|
case Kind::Video:
|
|
case Kind::Audio:
|
|
case Kind::Embed:
|
|
case Kind::Map:
|
|
case Kind::InputMap:
|
|
collectCaptionAnchors(block.caption);
|
|
break;
|
|
case Kind::EmbedPost:
|
|
collectBlockAnchors(block.blocks);
|
|
collectCaptionAnchors(block.caption);
|
|
break;
|
|
case Kind::Cover:
|
|
collectBlockAnchors(block.blocks);
|
|
break;
|
|
case Kind::Collage:
|
|
case Kind::Slideshow:
|
|
collectBlockAnchors(block.blocks);
|
|
collectCaptionAnchors(block.caption);
|
|
break;
|
|
case Kind::Table:
|
|
collectTextAnchors(block.text);
|
|
for (const auto &row : block.tableRows) {
|
|
for (const auto &cell : row.cells) {
|
|
if (cell.text) {
|
|
collectTextAnchors(*cell.text);
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
case Kind::Details:
|
|
collectTextAnchors(block.text);
|
|
collectBlockAnchors(block.blocks);
|
|
break;
|
|
case Kind::RelatedArticles:
|
|
collectTextAnchors(block.text);
|
|
break;
|
|
case Kind::Unsupported:
|
|
case Kind::Divider:
|
|
case Kind::Channel:
|
|
case Kind::Math:
|
|
case Kind::Unknown:
|
|
break;
|
|
}
|
|
}
|
|
|
|
std::optional<QByteArray> RichHtmlRenderer::fragmentHref(
|
|
const QByteArray &target) const {
|
|
if (!IsStrictTarget(target) || target.front() != '#') {
|
|
return std::nullopt;
|
|
}
|
|
const auto i = _firstAnchorIds.find(target.mid(1));
|
|
return (i != end(_firstAnchorIds))
|
|
? std::make_optional('#' + i->second)
|
|
: std::nullopt;
|
|
}
|
|
|
|
std::optional<QByteArray> RichHtmlRenderer::textAnchorId(
|
|
const Data::RichText &text) const {
|
|
const auto i = _textAnchorIds.find(&text);
|
|
return (i != end(_textAnchorIds))
|
|
? std::make_optional(i->second)
|
|
: std::nullopt;
|
|
}
|
|
|
|
std::optional<QByteArray> RichHtmlRenderer::blockAnchorId(
|
|
const Data::RichBlock &block) const {
|
|
const auto i = _blockAnchorIds.find(&block);
|
|
return (i != end(_blockAnchorIds))
|
|
? std::make_optional(i->second)
|
|
: std::nullopt;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderTexts(
|
|
const std::vector<Data::RichText> &texts) {
|
|
auto result = QByteArray();
|
|
for (const auto &text : texts) {
|
|
result += renderText(text);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderTextChildren(
|
|
const Data::RichText &text) {
|
|
if (!text.children.empty()) {
|
|
return renderTexts(text.children);
|
|
}
|
|
auto result = _context.pushTag("span", {
|
|
{ "class", "rich_inline_fallback" },
|
|
{ "data-rich-kind", "unsupported-text" },
|
|
{ "inline", QByteArray() },
|
|
});
|
|
result += "Unsupported rich text";
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderTextLink(
|
|
const Data::RichText &text,
|
|
const std::optional<QByteArray> &href,
|
|
std::map<QByteArray, QByteArray> attributes) {
|
|
attributes.emplace("inline", QByteArray());
|
|
if (!href || _linkActive) {
|
|
attributes.erase("href");
|
|
attributes.erase("onclick");
|
|
attributes.emplace("class", "rich_inert_link");
|
|
auto result = _context.pushTag("span", std::move(attributes));
|
|
result += renderTextChildren(text);
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
attributes.emplace("href", *href);
|
|
auto result = _context.pushTag("a", std::move(attributes));
|
|
const auto previous = _linkActive;
|
|
_linkActive = true;
|
|
const auto guard = gsl::finally([&] { _linkActive = previous; });
|
|
result += renderTextChildren(text);
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderCustomEmojiLink(
|
|
const QByteArray &alt,
|
|
std::map<QByteArray, QByteArray> attributes) {
|
|
attributes.emplace("inline", QByteArray());
|
|
if (_linkActive) {
|
|
attributes.erase("href");
|
|
attributes.erase("onclick");
|
|
attributes.emplace("class", "rich_inert_link");
|
|
auto result = _context.pushTag("span", std::move(attributes));
|
|
result += alt;
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
auto result = _context.pushTag("a", std::move(attributes));
|
|
const auto previous = _linkActive;
|
|
_linkActive = true;
|
|
const auto guard = gsl::finally([&] { _linkActive = previous; });
|
|
result += alt;
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderText(const Data::RichText &text) {
|
|
using Attributes = std::map<QByteArray, QByteArray>;
|
|
using Type = Data::RichText::Type;
|
|
|
|
const auto wrapChildren = [this, &text](
|
|
const QByteArray &tag,
|
|
Attributes attributes) {
|
|
attributes.emplace("inline", QByteArray());
|
|
auto result = _context.pushTag(tag, std::move(attributes));
|
|
result += renderTextChildren(text);
|
|
result += _context.popTag();
|
|
return result;
|
|
};
|
|
switch (text.type) {
|
|
case Type::Empty:
|
|
return QByteArray();
|
|
case Type::Plain:
|
|
return SerializeString(text.text);
|
|
case Type::Concat:
|
|
return renderTexts(text.children);
|
|
case Type::Bold:
|
|
return wrapChildren("strong", {});
|
|
case Type::Italic:
|
|
return wrapChildren("em", {});
|
|
case Type::Underline:
|
|
return wrapChildren("u", {});
|
|
case Type::Strike:
|
|
return wrapChildren("s", {});
|
|
case Type::Fixed:
|
|
return wrapChildren("code", {
|
|
{ "class", "rich_inline_code" },
|
|
});
|
|
case Type::Url: {
|
|
const auto href = text.data.startsWith('#')
|
|
? fragmentHref(text.data)
|
|
: SafeHttpHref(text.data);
|
|
auto attributes = Attributes();
|
|
if (text.id) {
|
|
attributes.emplace(
|
|
"data-webpage-id",
|
|
Data::NumberToString(text.id));
|
|
}
|
|
return renderTextLink(text, href, std::move(attributes));
|
|
}
|
|
case Type::Email:
|
|
return renderTextLink(text, SafeEmailHref(text.data), {});
|
|
case Type::Phone:
|
|
return renderTextLink(text, SafePhoneHref(text.data), {});
|
|
case Type::Subscript:
|
|
return wrapChildren("sub", {});
|
|
case Type::Superscript:
|
|
return wrapChildren("sup", {});
|
|
case Type::Marked:
|
|
return wrapChildren("mark", {});
|
|
case Type::Anchor: {
|
|
auto attributes = Attributes{
|
|
{ "class", "rich_reference" },
|
|
{ "inline", QByteArray() },
|
|
};
|
|
if (const auto id = textAnchorId(text)) {
|
|
attributes.emplace("id", *id);
|
|
}
|
|
auto result = _context.pushTag("span", std::move(attributes));
|
|
result += renderTexts(text.children);
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
case Type::Math: {
|
|
auto result = _context.pushTag("span", {
|
|
{ "class", "rich_math_inline" },
|
|
{ "data-rich-kind", "inline-math" },
|
|
{ "dir", "ltr" },
|
|
{ "inline", QByteArray() },
|
|
});
|
|
result += SerializeString(text.data);
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
case Type::CustomEmoji: {
|
|
auto result = _context.pushTag("span", {
|
|
{ "class", "rich_custom_emoji" },
|
|
{ "data-document-id", Data::NumberToString(text.id) },
|
|
{ "inline", QByteArray() },
|
|
});
|
|
const auto alt = SerializeString(text.text);
|
|
const auto unavailable =
|
|
(text.customEmojiData == Data::TextPart::UnavailableEmoji());
|
|
const auto notLoaded = text.customEmojiData.isEmpty()
|
|
|| IsAsciiDecimal(text.customEmojiData);
|
|
const auto href = (unavailable || notLoaded)
|
|
? std::optional<QByteArray>()
|
|
: SafeRelativeEmojiHref(text.customEmojiData, _relativeBase);
|
|
if (href) {
|
|
result += renderCustomEmojiLink(alt, {
|
|
{ "href", *href },
|
|
});
|
|
} else if (unavailable || notLoaded) {
|
|
result += renderCustomEmojiLink(alt, {
|
|
{ "href", QByteArray() },
|
|
{ "onclick", unavailable
|
|
? QByteArray("return ShowNotAvailableEmoji()")
|
|
: QByteArray("return ShowNotLoadedEmoji()") },
|
|
});
|
|
} else {
|
|
result += alt;
|
|
}
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
case Type::Spoiler: {
|
|
auto result = _context.pushTag("span", {
|
|
{ "class", "spoiler hidden" },
|
|
{ "inline", QByteArray() },
|
|
{ "onclick", "ShowSpoiler(this)" },
|
|
});
|
|
result += _context.pushTag("span", {
|
|
{ "aria-hidden", "true" },
|
|
{ "inline", QByteArray() },
|
|
});
|
|
result += renderTextChildren(text);
|
|
result += _context.popTag();
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
case Type::Mention: {
|
|
const auto target = PlainTarget(text);
|
|
const auto href = target
|
|
? SafeMentionHref(*target, _internalLinksDomain)
|
|
: std::optional<QByteArray>();
|
|
return renderTextLink(text, href, {});
|
|
}
|
|
case Type::Hashtag:
|
|
case Type::BotCommand:
|
|
case Type::Cashtag: {
|
|
const auto target = PlainTarget(text);
|
|
const auto prefix = (text.type == Type::Hashtag)
|
|
? '#'
|
|
: (text.type == Type::BotCommand)
|
|
? '/'
|
|
: '$';
|
|
const auto action = target
|
|
? SafeActionData(*target, prefix)
|
|
: std::optional<QByteArray>();
|
|
if (!action) {
|
|
return renderTextLink(text, std::nullopt, {});
|
|
}
|
|
const auto attribute = (text.type == Type::BotCommand)
|
|
? QByteArray("data-command")
|
|
: QByteArray("data-tag");
|
|
const auto callback = (text.type == Type::Hashtag)
|
|
? QByteArray("return ShowHashtag(this.dataset.tag)")
|
|
: (text.type == Type::BotCommand)
|
|
? QByteArray("return ShowBotCommand(this.dataset.command)")
|
|
: QByteArray("return ShowCashtag(this.dataset.tag)");
|
|
return renderTextLink(text, QByteArray(), {
|
|
{ attribute, *action },
|
|
{ "onclick", callback },
|
|
});
|
|
}
|
|
case Type::AutoUrl: {
|
|
const auto target = PlainTarget(text);
|
|
const auto href = target
|
|
? SafeHttpHref(*target)
|
|
: std::optional<QByteArray>();
|
|
return renderTextLink(text, href, {});
|
|
}
|
|
case Type::AutoEmail: {
|
|
const auto target = PlainTarget(text);
|
|
const auto href = target
|
|
? SafeEmailHref(*target)
|
|
: std::optional<QByteArray>();
|
|
return renderTextLink(text, href, {});
|
|
}
|
|
case Type::AutoPhone: {
|
|
const auto target = PlainTarget(text);
|
|
const auto href = target
|
|
? SafePhoneHref(*target)
|
|
: std::optional<QByteArray>();
|
|
return renderTextLink(text, href, {});
|
|
}
|
|
case Type::BankCard:
|
|
return wrapChildren("span", {
|
|
{ "class", "rich_bank_card" },
|
|
});
|
|
case Type::MentionName:
|
|
return renderTextLink(text, QByteArray(), {
|
|
{ "data-user-id", Data::NumberToString(text.id) },
|
|
{ "onclick", "return ShowMentionName()" },
|
|
});
|
|
case Type::FormattedDate: {
|
|
const auto datetime = QDateTime::fromSecsSinceEpoch(text.date);
|
|
auto result = _context.pushTag("time", {
|
|
{ "class", "rich_formatted_date" },
|
|
{ "data-date", Data::NumberToString(text.date) },
|
|
{ "data-day-of-week", RichBoolAttribute(text.dayOfWeek) },
|
|
{ "data-long-date", RichBoolAttribute(text.longDate) },
|
|
{ "data-long-time", RichBoolAttribute(text.longTime) },
|
|
{ "data-relative", RichBoolAttribute(text.relative) },
|
|
{ "data-short-date", RichBoolAttribute(text.shortDate) },
|
|
{ "data-short-time", RichBoolAttribute(text.shortTime) },
|
|
{ "datetime", datetime.toString(Qt::ISODate).toUtf8() },
|
|
{ "dir", "ltr" },
|
|
{ "inline", QByteArray() },
|
|
{ "title", Data::FormatDateTime(text.date) },
|
|
});
|
|
result += renderTextChildren(text);
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
case Type::InlineImage: {
|
|
auto result = _context.pushTag("span", {
|
|
{ "class", "rich_inline_fallback" },
|
|
{ "data-document-id", Data::NumberToString(text.id) },
|
|
{ "data-rich-kind", "inline-image" },
|
|
{ "data-source-height", Data::NumberToString(text.height) },
|
|
{ "data-source-width", Data::NumberToString(text.width) },
|
|
{ "inline", QByteArray() },
|
|
});
|
|
result += "Inline image";
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
case Type::Diff: {
|
|
auto result = _context.pushTag("span", {
|
|
{ "class", "rich_diff" },
|
|
{ "data-rich-kind", "diff" },
|
|
{ "inline", QByteArray() },
|
|
});
|
|
result += renderTextChildren(text);
|
|
result += _context.pushTag("del", {
|
|
{ "class", "rich_diff_previous" },
|
|
{ "inline", QByteArray() },
|
|
});
|
|
result += "Previous: ";
|
|
if (text.oldChildren.empty()) {
|
|
result += _context.pushTag("span", {
|
|
{ "class", "rich_inline_fallback" },
|
|
{ "data-rich-kind", "unsupported-text" },
|
|
{ "inline", QByteArray() },
|
|
});
|
|
result += "Unsupported rich text";
|
|
result += _context.popTag();
|
|
} else {
|
|
result += renderTexts(text.oldChildren);
|
|
}
|
|
result += _context.popTag();
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
}
|
|
Unexpected("Type in RichHtmlRenderer::renderText.");
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderBlocks(
|
|
const std::vector<Data::RichBlock> &blocks) {
|
|
auto result = QByteArray();
|
|
for (const auto &block : blocks) {
|
|
result += renderBlock(block);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderTextBlock(
|
|
const QByteArray &tag,
|
|
std::map<QByteArray, QByteArray> attributes,
|
|
const Data::RichText &text) {
|
|
auto result = _context.pushTag(tag, std::move(attributes));
|
|
result += renderText(text);
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderAuthorDate(
|
|
const Data::RichBlock &block) {
|
|
auto attributes = RichBlockAttributes("rich_author_date", "author-date");
|
|
attributes.emplace("dir", "auto");
|
|
auto result = _context.pushTag("address", std::move(attributes));
|
|
result += renderText(block.text);
|
|
if (block.date) {
|
|
const auto datetime = QDateTime::fromSecsSinceEpoch(block.date);
|
|
result += " \xE2\x80\xA2 ";
|
|
result += _context.pushTag("time", {
|
|
{ "data-date", Data::NumberToString(block.date) },
|
|
{ "datetime", datetime.toString(Qt::ISODate).toUtf8() },
|
|
{ "dir", "ltr" },
|
|
{ "inline", QByteArray() },
|
|
});
|
|
result += SerializeString(Data::FormatDateTime(block.date));
|
|
result += _context.popTag();
|
|
}
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderCode(const Data::RichBlock &block) {
|
|
auto attributes = RichBlockAttributes("rich_code", "code");
|
|
attributes.emplace("dir", "auto");
|
|
attributes.emplace("inline", QByteArray());
|
|
if (!block.language.isEmpty()) {
|
|
attributes.emplace("data-language", block.language);
|
|
}
|
|
auto result = _context.pushTag("pre", std::move(attributes));
|
|
result += _context.pushTag("code", {
|
|
{ "class", "rich_code_content" },
|
|
{ "inline", QByteArray() },
|
|
});
|
|
result += renderText(block.text);
|
|
result += _context.popTag();
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderList(const Data::RichBlock &block) {
|
|
using Kind = Data::RichListKind;
|
|
|
|
const auto ordered = (block.listKind == Kind::Ordered);
|
|
auto attributes = RichBlockAttributes(
|
|
"rich_list",
|
|
ordered ? QByteArray("ordered-list") : QByteArray("list"));
|
|
if (ordered) {
|
|
if (block.orderedList.reversed) {
|
|
attributes.emplace("reversed", QByteArray());
|
|
}
|
|
if (block.orderedList.start) {
|
|
attributes.emplace(
|
|
"start",
|
|
Data::NumberToString(*block.orderedList.start));
|
|
}
|
|
if (block.orderedList.type) {
|
|
const auto &type = *block.orderedList.type;
|
|
auto native = false;
|
|
if (type.size() == 1) {
|
|
switch (type[0]) {
|
|
case '1':
|
|
case 'a':
|
|
case 'A':
|
|
case 'i':
|
|
case 'I':
|
|
native = true;
|
|
break;
|
|
}
|
|
}
|
|
attributes.emplace(
|
|
native ? QByteArray("type") : QByteArray("data-list-type"),
|
|
type);
|
|
}
|
|
}
|
|
auto result = _context.pushTag(
|
|
ordered ? QByteArray("ol") : QByteArray("ul"),
|
|
std::move(attributes));
|
|
for (const auto &item : block.listItems) {
|
|
result += renderListItem(item, ordered);
|
|
}
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderListItem(
|
|
const Data::RichListItem &item,
|
|
bool ordered) {
|
|
using Content = Data::RichListItemContent;
|
|
using State = Data::RichTaskState;
|
|
|
|
const auto task = (item.taskState != State::None);
|
|
const auto checked = (item.taskState == State::Checked);
|
|
const auto customMarker = ordered
|
|
&& !task
|
|
&& item.num
|
|
&& !item.num->isEmpty();
|
|
auto classes = QByteArray("rich_list_item");
|
|
if (task) {
|
|
classes += " rich_task_item";
|
|
} else if (customMarker) {
|
|
classes += " rich_order_item";
|
|
}
|
|
auto attributes = std::map<QByteArray, QByteArray>{
|
|
{ "class", std::move(classes) },
|
|
{ "data-rich-content", (item.content == Content::Blocks)
|
|
? QByteArray("blocks")
|
|
: QByteArray("text") },
|
|
};
|
|
if (item.num) {
|
|
attributes.emplace("data-num", *item.num);
|
|
}
|
|
if (item.type) {
|
|
attributes.emplace("data-item-type", *item.type);
|
|
}
|
|
if (item.value) {
|
|
attributes.emplace("value", Data::NumberToString(*item.value));
|
|
}
|
|
auto result = _context.pushTag("li", std::move(attributes));
|
|
if (task) {
|
|
result += _context.pushTag("span", {
|
|
{ "aria-checked", RichBoolAttribute(checked) },
|
|
{ "class", "rich_task_marker" },
|
|
{ "inline", QByteArray() },
|
|
{ "role", "checkbox" },
|
|
});
|
|
result += checked ? "\xE2\x98\x91" : "\xE2\x98\x90";
|
|
result += _context.popTag();
|
|
} else if (customMarker) {
|
|
result += _context.pushTag("span", {
|
|
{ "class", "rich_order_marker" },
|
|
{ "inline", QByteArray() },
|
|
});
|
|
result += SerializeString(*item.num);
|
|
result += _context.popTag();
|
|
}
|
|
result += _context.pushTag(
|
|
(item.content == Content::Blocks) ? "div" : "span",
|
|
{
|
|
{ "class", "rich_list_content" },
|
|
{ "inline", QByteArray() },
|
|
});
|
|
switch (item.content) {
|
|
case Content::Text:
|
|
if (item.text) {
|
|
result += renderText(*item.text);
|
|
}
|
|
break;
|
|
case Content::Blocks:
|
|
result += renderBlocks(item.blocks);
|
|
break;
|
|
}
|
|
result += _context.popTag();
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderQuote(const Data::RichBlock &block) {
|
|
using Content = Data::RichQuoteContent;
|
|
|
|
const auto kind = block.pullquote
|
|
? QByteArray("pullquote")
|
|
: (block.quoteContent == Content::Blocks)
|
|
? QByteArray("quote-blocks")
|
|
: QByteArray("quote");
|
|
const auto classes = block.pullquote
|
|
? QByteArray("rich_quote rich_pullquote")
|
|
: QByteArray("rich_quote");
|
|
auto result = _context.pushTag(
|
|
block.pullquote ? QByteArray("aside") : QByteArray("blockquote"),
|
|
RichBlockAttributes(classes, kind));
|
|
result += _context.pushTag("div", {
|
|
{ "class", "rich_quote_body" },
|
|
});
|
|
switch (block.quoteContent) {
|
|
case Content::Text:
|
|
result += renderText(block.text);
|
|
break;
|
|
case Content::Blocks:
|
|
result += renderBlocks(block.blocks);
|
|
break;
|
|
}
|
|
result += _context.popTag();
|
|
const auto caption = renderText(block.quoteCaption);
|
|
if (!caption.isEmpty()) {
|
|
result += _context.pushTag("cite", {
|
|
{ "class", "rich_quote_caption" },
|
|
});
|
|
result += caption;
|
|
result += _context.popTag();
|
|
}
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderDetails(const Data::RichBlock &block) {
|
|
auto attributes = RichBlockAttributes("rich_details", "details");
|
|
if (block.open) {
|
|
attributes.emplace("open", QByteArray());
|
|
}
|
|
auto result = _context.pushTag("details", std::move(attributes));
|
|
result += _context.pushTag("summary", {});
|
|
result += renderText(block.text);
|
|
result += _context.popTag();
|
|
result += _context.pushTag("div", {
|
|
{ "class", "rich_details_body" },
|
|
});
|
|
result += renderBlocks(block.blocks);
|
|
result += _context.popTag();
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderTable(const Data::RichBlock &block) {
|
|
auto wrapperAttributes = RichBlockAttributes("rich_table_wrap", "table");
|
|
wrapperAttributes.emplace("tabindex", "0");
|
|
auto result = _context.pushTag("div", std::move(wrapperAttributes));
|
|
auto classes = QByteArray("rich_table");
|
|
if (block.bordered) {
|
|
classes += " bordered";
|
|
}
|
|
if (block.striped) {
|
|
classes += " striped";
|
|
}
|
|
result += _context.pushTag("table", {
|
|
{ "class", std::move(classes) },
|
|
});
|
|
const auto caption = renderText(block.text);
|
|
if (!caption.isEmpty()) {
|
|
result += _context.pushTag("caption", {});
|
|
result += caption;
|
|
result += _context.popTag();
|
|
}
|
|
result += _context.pushTag("tbody", {});
|
|
for (const auto &row : block.tableRows) {
|
|
result += renderTableRow(row);
|
|
}
|
|
result += _context.popTag();
|
|
result += _context.popTag();
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderTableRow(
|
|
const Data::RichTableRow &row) {
|
|
auto result = _context.pushTag("tr", {});
|
|
for (const auto &cell : row.cells) {
|
|
result += renderTableCell(cell);
|
|
}
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderTableCell(
|
|
const Data::RichTableCell &cell) {
|
|
using Alignment = Data::RichTableAlignment;
|
|
using VerticalAlignment = Data::RichTableVerticalAlignment;
|
|
|
|
auto horizontalClass = QByteArray("rich_align_left");
|
|
switch (cell.alignment) {
|
|
case Alignment::Left:
|
|
break;
|
|
case Alignment::Center:
|
|
horizontalClass = "rich_align_center";
|
|
break;
|
|
case Alignment::Right:
|
|
horizontalClass = "rich_align_right";
|
|
break;
|
|
}
|
|
auto verticalClass = QByteArray("rich_valign_top");
|
|
switch (cell.verticalAlignment) {
|
|
case VerticalAlignment::Top:
|
|
break;
|
|
case VerticalAlignment::Middle:
|
|
verticalClass = "rich_valign_middle";
|
|
break;
|
|
case VerticalAlignment::Bottom:
|
|
verticalClass = "rich_valign_bottom";
|
|
break;
|
|
}
|
|
auto attributes = std::map<QByteArray, QByteArray>{
|
|
{ "class", horizontalClass + ' ' + verticalClass },
|
|
};
|
|
if (cell.colspan && *cell.colspan > 0) {
|
|
const auto source = *cell.colspan;
|
|
const auto span = (source > kRichTableSpanMax)
|
|
? kRichTableSpanMax
|
|
: source;
|
|
attributes.emplace("colspan", Data::NumberToString(span));
|
|
if (span != source) {
|
|
attributes.emplace(
|
|
"data-source-colspan",
|
|
Data::NumberToString(source));
|
|
}
|
|
}
|
|
if (cell.rowspan && *cell.rowspan > 0) {
|
|
const auto source = *cell.rowspan;
|
|
const auto span = (source > kRichTableSpanMax)
|
|
? kRichTableSpanMax
|
|
: source;
|
|
attributes.emplace("rowspan", Data::NumberToString(span));
|
|
if (span != source) {
|
|
attributes.emplace(
|
|
"data-source-rowspan",
|
|
Data::NumberToString(source));
|
|
}
|
|
}
|
|
auto result = _context.pushTag(
|
|
cell.header ? QByteArray("th") : QByteArray("td"),
|
|
std::move(attributes));
|
|
if (cell.text) {
|
|
result += renderText(*cell.text);
|
|
}
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderFallback(
|
|
const QByteArray &kind,
|
|
const QByteArray &label) {
|
|
auto result = _context.pushTag(
|
|
"div",
|
|
RichBlockAttributes("rich_fallback", kind));
|
|
result += SerializeString(label);
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
|
|
QByteArray RichHtmlRenderer::renderBlock(const Data::RichBlock &block) {
|
|
using Kind = Data::RichBlock::Kind;
|
|
|
|
switch (block.kind) {
|
|
case Kind::Unsupported:
|
|
return renderFallback("unsupported", "Unsupported rich content");
|
|
case Kind::Heading: {
|
|
auto attributes = RichBlockAttributes("rich_heading", "heading");
|
|
attributes.emplace(
|
|
"data-level",
|
|
Data::NumberToString(block.headingLevel));
|
|
return renderTextBlock(
|
|
RichHeadingTag(block.headingLevel),
|
|
std::move(attributes),
|
|
block.text);
|
|
}
|
|
case Kind::Paragraph: {
|
|
auto attributes = RichBlockAttributes("rich_paragraph", "paragraph");
|
|
attributes.emplace("dir", "auto");
|
|
return renderTextBlock("p", std::move(attributes), block.text);
|
|
}
|
|
case Kind::Footer: {
|
|
auto attributes = RichBlockAttributes("rich_footer", "footer");
|
|
attributes.emplace("dir", "auto");
|
|
return renderTextBlock("footer", std::move(attributes), block.text);
|
|
}
|
|
case Kind::Thinking: {
|
|
auto attributes = RichBlockAttributes("rich_thinking", "thinking");
|
|
attributes.emplace("dir", "auto");
|
|
return renderTextBlock("p", std::move(attributes), block.text);
|
|
}
|
|
case Kind::AuthorDate:
|
|
return renderAuthorDate(block);
|
|
case Kind::Code:
|
|
return renderCode(block);
|
|
case Kind::Divider: {
|
|
auto attributes = RichBlockAttributes("rich_divider", "divider");
|
|
attributes.emplace("empty", QByteArray());
|
|
return _context.pushTag("hr", std::move(attributes));
|
|
}
|
|
case Kind::Anchor: {
|
|
const auto id = blockAnchorId(block);
|
|
if (!id) {
|
|
return renderFallback("malformed", "Malformed rich content");
|
|
}
|
|
auto attributes = RichBlockAttributes("rich_anchor", "anchor");
|
|
attributes.emplace("id", *id);
|
|
attributes.emplace("inline", QByteArray());
|
|
auto result = _context.pushTag("span", std::move(attributes));
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
case Kind::List:
|
|
return renderList(block);
|
|
case Kind::Quote:
|
|
return renderQuote(block);
|
|
case Kind::Photo:
|
|
return renderPhoto(block);
|
|
case Kind::Video:
|
|
return renderVideo(block);
|
|
case Kind::Cover:
|
|
return renderMediaGroup(block, "cover", false);
|
|
case Kind::Embed:
|
|
return renderEmbed(block);
|
|
case Kind::EmbedPost:
|
|
return renderEmbedPost(block);
|
|
case Kind::Collage:
|
|
return renderMediaGroup(block, "collage", true);
|
|
case Kind::Slideshow:
|
|
return renderMediaGroup(block, "slideshow", true);
|
|
case Kind::Channel:
|
|
return renderChannel(block);
|
|
case Kind::Audio:
|
|
return renderAudio(block);
|
|
case Kind::Math: {
|
|
auto attributes = RichBlockAttributes("rich_math_display", "math");
|
|
attributes.emplace("dir", "ltr");
|
|
attributes.emplace("inline", QByteArray());
|
|
auto result = _context.pushTag("div", std::move(attributes));
|
|
result += SerializeString(block.formula);
|
|
result += _context.popTag();
|
|
return result;
|
|
}
|
|
case Kind::Table:
|
|
return renderTable(block);
|
|
case Kind::Details:
|
|
return renderDetails(block);
|
|
case Kind::RelatedArticles:
|
|
return renderRelatedArticles(block);
|
|
case Kind::Map:
|
|
return renderMap(block, "map");
|
|
case Kind::InputMap:
|
|
return renderMap(block, "input-map");
|
|
case Kind::Unknown:
|
|
return renderFallback("unknown", "Unknown rich content");
|
|
}
|
|
Unexpected("Kind in RichHtmlRenderer::renderBlock.");
|
|
}
|
|
|
|
[[nodiscard]] QByteArray RenderRichMessage(
|
|
Context &context,
|
|
const Data::RichMessage &message,
|
|
int messageId,
|
|
const QString &internalLinksDomain,
|
|
const QByteArray &relativeBase,
|
|
const RichMediaCallbacks &media) {
|
|
return RichHtmlRenderer(
|
|
context,
|
|
message,
|
|
messageId,
|
|
internalLinksDomain,
|
|
relativeBase,
|
|
media).renderMessage();
|
|
}
|
|
|
|
} // namespace
|
|
|
|
struct HtmlWriter::MessageInfo {
|
|
enum class Type {
|
|
Service,
|
|
Default,
|
|
};
|
|
int id = 0;
|
|
Type type = Type::Service;
|
|
PeerId fromId = 0;
|
|
UserId viaBotId = 0;
|
|
TimeId date = 0;
|
|
PeerId forwardedFromId = 0;
|
|
QString forwardedFromName;
|
|
bool forwarded = false;
|
|
bool showForwardedAsOriginal = false;
|
|
TimeId forwardedDate = 0;
|
|
};
|
|
|
|
class HtmlWriter::Wrap {
|
|
public:
|
|
Wrap(const QString &path, const QString &base, Stats *stats);
|
|
|
|
[[nodiscard]] bool empty() const;
|
|
|
|
[[nodiscard]] QByteArray pushTag(
|
|
const QByteArray &tag,
|
|
std::map<QByteArray, QByteArray> &&attributes = {});
|
|
[[nodiscard]] QByteArray popTag();
|
|
[[nodiscard]] QByteArray indent() const;
|
|
|
|
[[nodiscard]] QByteArray pushDiv(
|
|
const QByteArray &className,
|
|
const QByteArray &style = {});
|
|
|
|
[[nodiscard]] QByteArray pushUserpic(const UserpicData &userpic);
|
|
[[nodiscard]] QByteArray pushListEntry(
|
|
const UserpicData &userpic,
|
|
const QByteArray &name,
|
|
const QByteArray &details,
|
|
const QByteArray &info,
|
|
const QString &link = QString());
|
|
[[nodiscard]] QByteArray pushStoriesListEntry(
|
|
const StoryData &story,
|
|
const QByteArray &name,
|
|
const QByteArrayList &details,
|
|
const QByteArray &info,
|
|
const std::vector<Data::TextPart> &caption,
|
|
const QString &internalLinksDomain,
|
|
const QString &link = QString());
|
|
[[nodiscard]] QByteArray pushAudioEntry(
|
|
const QByteArray &name,
|
|
const QByteArray &info,
|
|
const QByteArrayList &details,
|
|
const QByteArray &duration,
|
|
const QString &link = QString());
|
|
[[nodiscard]] QByteArray pushSessionListEntry(
|
|
int apiId,
|
|
const QByteArray &name,
|
|
const QByteArray &subname,
|
|
std::initializer_list<QByteArray> details,
|
|
const QByteArray &info = QByteArray());
|
|
|
|
[[nodiscard]] QByteArray pushHeader(
|
|
const QByteArray &header,
|
|
const QString &path = QString());
|
|
[[nodiscard]] QByteArray pushSection(
|
|
const QByteArray &label,
|
|
const QByteArray &type,
|
|
int count,
|
|
const QString &path);
|
|
[[nodiscard]] QByteArray pushAbout(
|
|
const QByteArray &text,
|
|
bool withDivider = false);
|
|
[[nodiscard]] QByteArray pushServiceMessage(
|
|
int messageId,
|
|
const Data::DialogInfo &dialog,
|
|
const QString &basePath,
|
|
const QByteArray &text,
|
|
const Data::Photo *photo = nullptr);
|
|
[[nodiscard]] std::pair<MessageInfo, QByteArray> pushMessage(
|
|
const Data::Message &message,
|
|
const MessageInfo *previous,
|
|
const Data::DialogInfo &dialog,
|
|
const QString &basePath,
|
|
const PeersMap &peers,
|
|
const QString &internalLinksDomain,
|
|
Fn<QByteArray(int messageId, QByteArray text)> wrapMessageLink);
|
|
|
|
[[nodiscard]] Result writeBlock(const QByteArray &block);
|
|
|
|
[[nodiscard]] Result close();
|
|
|
|
[[nodiscard]] QString relativePath(const QString &path) const;
|
|
[[nodiscard]] QString relativePath(const Data::File &file) const;
|
|
|
|
~Wrap();
|
|
|
|
private:
|
|
[[nodiscard]] QByteArray composeStart();
|
|
[[nodiscard]] QByteArray pushGenericListEntry(
|
|
const QString &link,
|
|
const UserpicData &userpic,
|
|
const QByteArray &name,
|
|
const QByteArray &subname,
|
|
std::initializer_list<QByteArray> details,
|
|
const QByteArray &info);
|
|
|
|
[[nodiscard]] bool messageNeedsWrap(
|
|
const Data::Message &message,
|
|
const MessageInfo *previous) const;
|
|
[[nodiscard]] bool forwardedNeedsWrap(
|
|
const Data::Message &message,
|
|
const MessageInfo *previous) const;
|
|
|
|
[[nodiscard]] MediaData prepareMediaData(
|
|
const Data::Message &message,
|
|
const QString &basePath,
|
|
const PeersMap &peers,
|
|
const QString &internalLinksDomain) const;
|
|
[[nodiscard]] QByteArray pushMedia(
|
|
const Data::Message &message,
|
|
const QString &basePath,
|
|
const PeersMap &peers,
|
|
const QString &internalLinksDomain,
|
|
Fn<QByteArray(int messageId, QByteArray text)> wrapMessageLink);
|
|
[[nodiscard]] QByteArray pushGenericMedia(const MediaData &data);
|
|
[[nodiscard]] QByteArray pushRichPhotoMedia(
|
|
const Data::Photo *data,
|
|
const QString &basePath);
|
|
[[nodiscard]] QByteArray pushRichVideoMedia(
|
|
const Data::Document *data,
|
|
const QString &basePath);
|
|
[[nodiscard]] QByteArray pushRichAudioMedia(
|
|
const Data::Document *data);
|
|
[[nodiscard]] QByteArray pushRichReferenceMedia(
|
|
MediaData data,
|
|
const Data::Photo *photo,
|
|
const QString &basePath);
|
|
[[nodiscard]] QByteArray pushStickerMedia(
|
|
const Data::Document &data,
|
|
const QString &basePath);
|
|
[[nodiscard]] QByteArray pushAnimatedMedia(
|
|
const Data::Document &data,
|
|
const QString &basePath);
|
|
[[nodiscard]] QByteArray pushVideoFileMedia(
|
|
const Data::Document &data,
|
|
const QString &basePath);
|
|
[[nodiscard]] QByteArray pushPhotoMedia(
|
|
const Data::Photo &data,
|
|
const QString &basePath);
|
|
[[nodiscard]] QByteArray pushPoll(
|
|
const Data::Poll &data,
|
|
const QString &internalLinksDomain,
|
|
const QString &relativeLinkBase);
|
|
[[nodiscard]] QByteArray pushTodoList(
|
|
const Data::TodoList &data,
|
|
const QString &internalLinksDomain,
|
|
const QString &relativeLinkBase);
|
|
[[nodiscard]] QByteArray pushGiveaway(
|
|
const PeersMap &peers,
|
|
const Data::GiveawayStart &data);
|
|
[[nodiscard]] QByteArray pushGiveaway(
|
|
const PeersMap &peers,
|
|
const Data::GiveawayResults &data,
|
|
Fn<QByteArray(int messageId, QByteArray text)> wrapMessageLink);
|
|
|
|
File _file;
|
|
QByteArray _composedStart;
|
|
bool _closed = false;
|
|
QByteArray _base;
|
|
Context _context;
|
|
|
|
};
|
|
|
|
struct HtmlWriter::SavedSection {
|
|
int priority = 0;
|
|
QByteArray label;
|
|
QByteArray type;
|
|
int count = 0;
|
|
QString path;
|
|
};
|
|
|
|
void FillUserpicNames(UserpicData &data, const Data::Peer &peer) {
|
|
if (peer.user()) {
|
|
data.firstName = peer.user()->info.firstName;
|
|
data.lastName = peer.user()->info.lastName;
|
|
} else if (peer.chat()) {
|
|
data.firstName = peer.name();
|
|
}
|
|
}
|
|
|
|
void FillUserpicNames(UserpicData &data, const QByteArray &full) {
|
|
const auto names = full.split(' ');
|
|
data.firstName = names[0];
|
|
for (auto i = 1; i != names.size(); ++i) {
|
|
if (names[i].isEmpty()) {
|
|
continue;
|
|
}
|
|
if (!data.lastName.isEmpty()) {
|
|
data.lastName.append(' ');
|
|
}
|
|
data.lastName.append(names[i]);
|
|
}
|
|
}
|
|
|
|
QByteArray ComposeName(const UserpicData &data, const QByteArray &empty) {
|
|
return ((data.firstName.isEmpty() && data.lastName.isEmpty())
|
|
? empty
|
|
: (data.firstName + ' ' + data.lastName));
|
|
}
|
|
|
|
QString WriteUserpicThumb(
|
|
const QString &basePath,
|
|
const QString &largePath,
|
|
const UserpicData &userpic,
|
|
const QString &postfix = "_thumb") {
|
|
return Data::WriteImageThumb(
|
|
basePath,
|
|
largePath,
|
|
userpic.pixelSize * 2,
|
|
userpic.pixelSize * 2,
|
|
postfix);
|
|
}
|
|
|
|
HtmlWriter::Wrap::Wrap(
|
|
const QString &path,
|
|
const QString &base,
|
|
Stats *stats)
|
|
: _file(path, stats) {
|
|
Expects(base.endsWith('/'));
|
|
Expects(path.startsWith(base));
|
|
|
|
const auto left = path.mid(base.size());
|
|
const auto nesting = ranges::count(left, '/');
|
|
_base = QString("../").repeated(nesting).toUtf8();
|
|
|
|
_composedStart = composeStart();
|
|
}
|
|
|
|
bool HtmlWriter::Wrap::empty() const {
|
|
return _file.empty();
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::pushTag(
|
|
const QByteArray &tag,
|
|
std::map<QByteArray, QByteArray> &&attributes) {
|
|
return _context.pushTag(tag, std::move(attributes));
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::popTag() {
|
|
return _context.popTag();
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::indent() const {
|
|
return _context.indent();
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::pushDiv(
|
|
const QByteArray &className,
|
|
const QByteArray &style) {
|
|
return style.isEmpty()
|
|
? _context.pushTag("div", { { "class", className } })
|
|
: _context.pushTag("div", {
|
|
{ "class", className },
|
|
{ "style", style }
|
|
});
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::pushUserpic(const UserpicData &userpic) {
|
|
const auto size = Data::NumberToString(userpic.pixelSize) + "px";
|
|
auto result = QByteArray();
|
|
if (!userpic.largeLink.isEmpty()) {
|
|
result.append(pushTag("a", {
|
|
{ "class", "userpic_link" },
|
|
{ "href", relativePath(userpic.largeLink).toUtf8() }
|
|
}));
|
|
}
|
|
const auto sizeStyle = "width: " + size + "; height: " + size;
|
|
if (!userpic.imageLink.isEmpty()) {
|
|
result.append(pushTag("img", {
|
|
{ "class", "userpic" },
|
|
{ "style", sizeStyle },
|
|
{ "src", relativePath(userpic.imageLink).toUtf8() },
|
|
{ "empty", "" }
|
|
}));
|
|
} else {
|
|
result.append(pushTag("div", {
|
|
{
|
|
"class",
|
|
"userpic userpic"
|
|
+ Data::NumberToString(userpic.colorIndex + 1)
|
|
},
|
|
{ "style", sizeStyle }
|
|
}));
|
|
if (userpic.tooltip.isEmpty()) {
|
|
result.append(pushDiv(
|
|
"initials",
|
|
"line-height: " + size));
|
|
} else {
|
|
result.append(pushTag("div", {
|
|
{ "class", "initials" },
|
|
{ "style", "line-height: " + size },
|
|
{ "title", userpic.tooltip },
|
|
}));
|
|
}
|
|
auto character = [](const QByteArray &from) {
|
|
const auto utf = QString::fromUtf8(from).trimmed();
|
|
return utf.isEmpty()
|
|
? QByteArray()
|
|
: SerializeString(utf.mid(0, 1).toUtf8());
|
|
};
|
|
result.append(character(userpic.firstName));
|
|
result.append(character(userpic.lastName));
|
|
result.append(popTag());
|
|
result.append(popTag());
|
|
}
|
|
if (!userpic.largeLink.isEmpty()) {
|
|
result.append(popTag());
|
|
}
|
|
return result;
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::pushListEntry(
|
|
const UserpicData &userpic,
|
|
const QByteArray &name,
|
|
const QByteArray &details,
|
|
const QByteArray &info,
|
|
const QString &link) {
|
|
return pushGenericListEntry(
|
|
link,
|
|
userpic,
|
|
name,
|
|
{},
|
|
{ details },
|
|
info);
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::pushStoriesListEntry(
|
|
const StoryData &story,
|
|
const QByteArray &name,
|
|
const QByteArrayList &details,
|
|
const QByteArray &info,
|
|
const std::vector<Data::TextPart> &caption,
|
|
const QString &internalLinksDomain,
|
|
const QString &link) {
|
|
auto result = pushDiv("entry clearfix");
|
|
if (!link.isEmpty()) {
|
|
result.append(pushTag("a", {
|
|
{ "class", "pull_left userpic_wrap" },
|
|
{ "href", relativePath(link).toUtf8() + "#allow_back" },
|
|
}));
|
|
} else {
|
|
result.append(pushDiv("pull_left userpic_wrap"));
|
|
}
|
|
if (!story.imageLink.isEmpty()) {
|
|
const auto sizeStyle = "width: "
|
|
+ Data::NumberToString(kStoryThumbWidth)
|
|
+ "px; height: "
|
|
+ Data::NumberToString(kStoryThumbHeight)
|
|
+ "px";
|
|
result.append(pushTag("img", {
|
|
{ "class", "story" },
|
|
{ "style", sizeStyle },
|
|
{ "src", relativePath(story.imageLink).toUtf8() },
|
|
{ "empty", "" }
|
|
}));
|
|
}
|
|
result.append(popTag());
|
|
result.append(pushDiv("body"));
|
|
if (!info.isEmpty()) {
|
|
result.append(pushDiv("pull_right info details"));
|
|
result.append(SerializeString(info));
|
|
result.append(popTag());
|
|
}
|
|
if (!name.isEmpty()) {
|
|
if (!link.isEmpty()) {
|
|
result.append(pushTag("a", {
|
|
{ "class", "block_link expanded" },
|
|
{ "href", relativePath(link).toUtf8() + "#allow_back" },
|
|
}));
|
|
}
|
|
result.append(pushDiv("name bold"));
|
|
result.append(SerializeString(name));
|
|
result.append(popTag());
|
|
if (!link.isEmpty()) {
|
|
result.append(popTag());
|
|
}
|
|
}
|
|
const auto text = caption.empty()
|
|
? QByteArray()
|
|
: FormatText(caption, internalLinksDomain, _base);
|
|
if (!text.isEmpty()) {
|
|
result.append(pushDiv("text"));
|
|
result.append(text);
|
|
result.append(popTag());
|
|
}
|
|
for (const auto &detail : details) {
|
|
result.append(pushDiv("details_entry details"));
|
|
result.append(SerializeString(detail));
|
|
result.append(popTag());
|
|
}
|
|
result.append(popTag());
|
|
result.append(popTag());
|
|
return result;
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::pushAudioEntry(
|
|
const QByteArray &name,
|
|
const QByteArray &info,
|
|
const QByteArrayList &details,
|
|
const QByteArray &duration,
|
|
const QString &link) {
|
|
auto result = pushDiv("entry clearfix");
|
|
if (!link.isEmpty()) {
|
|
result.append(pushTag("a", {
|
|
{ "class", "pull_left userpic_wrap" },
|
|
{ "href", relativePath(link).toUtf8() + "#allow_back" },
|
|
}));
|
|
} else {
|
|
result.append(pushDiv("pull_left userpic_wrap"));
|
|
}
|
|
result.append(pushDiv("userpic audio_icon"));
|
|
result.append(popTag());
|
|
result.append(popTag());
|
|
result.append(pushDiv("body"));
|
|
if (!duration.isEmpty()) {
|
|
result.append(pushDiv("pull_right info details"));
|
|
result.append(SerializeString(duration));
|
|
result.append(popTag());
|
|
}
|
|
if (!info.isEmpty()) {
|
|
if (!link.isEmpty()) {
|
|
result.append(pushTag("a", {
|
|
{ "class", "block_link expanded" },
|
|
{ "href", relativePath(link).toUtf8() + "#allow_back" },
|
|
}));
|
|
}
|
|
result.append(pushDiv("name bold"));
|
|
result.append(SerializeString(info));
|
|
result.append(popTag());
|
|
if (!link.isEmpty()) {
|
|
result.append(popTag());
|
|
}
|
|
}
|
|
for (const auto &detail : details) {
|
|
result.append(pushDiv("details_entry details"));
|
|
result.append(SerializeString(detail));
|
|
result.append(popTag());
|
|
}
|
|
result.append(popTag());
|
|
result.append(popTag());
|
|
return result;
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::pushSessionListEntry(
|
|
int apiId,
|
|
const QByteArray &name,
|
|
const QByteArray &subname,
|
|
std::initializer_list<QByteArray> details,
|
|
const QByteArray &info) {
|
|
const auto link = QString();
|
|
auto userpic = UserpicData{
|
|
Data::ApplicationColorIndex(apiId),
|
|
kEntryUserpicSize
|
|
};
|
|
userpic.firstName = name;
|
|
return pushGenericListEntry(
|
|
link,
|
|
userpic,
|
|
name,
|
|
subname,
|
|
details,
|
|
info);
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::pushGenericListEntry(
|
|
const QString &link,
|
|
const UserpicData &userpic,
|
|
const QByteArray &name,
|
|
const QByteArray &subname,
|
|
std::initializer_list<QByteArray> details,
|
|
const QByteArray &info) {
|
|
auto result = link.isEmpty()
|
|
? pushDiv("entry clearfix")
|
|
: pushTag("a", {
|
|
{ "class", "entry block_link clearfix" },
|
|
{ "href", relativePath(link).toUtf8() + "#allow_back" },
|
|
});
|
|
result.append(pushDiv("pull_left userpic_wrap"));
|
|
result.append(pushUserpic(userpic));
|
|
result.append(popTag());
|
|
result.append(pushDiv("body"));
|
|
if (!info.isEmpty()) {
|
|
result.append(pushDiv("pull_right info details"));
|
|
result.append(SerializeString(info));
|
|
result.append(popTag());
|
|
}
|
|
if (!name.isEmpty()) {
|
|
result.append(pushDiv("name bold"));
|
|
result.append(SerializeString(name));
|
|
result.append(popTag());
|
|
}
|
|
if (!subname.isEmpty()) {
|
|
result.append(pushDiv("subname bold"));
|
|
result.append(SerializeString(subname));
|
|
result.append(popTag());
|
|
}
|
|
for (const auto &detail : details) {
|
|
result.append(pushDiv("details_entry details"));
|
|
result.append(SerializeString(detail));
|
|
result.append(popTag());
|
|
}
|
|
result.append(popTag());
|
|
result.append(popTag());
|
|
return result;
|
|
}
|
|
|
|
Result HtmlWriter::Wrap::writeBlock(const QByteArray &block) {
|
|
Expects(!_closed);
|
|
|
|
const auto result = [&] {
|
|
if (block.isEmpty()) {
|
|
return _file.writeBlock(block);
|
|
} else if (_file.empty()) {
|
|
return _file.writeBlock(_composedStart + block);
|
|
}
|
|
return _file.writeBlock(block);
|
|
}();
|
|
if (!result) {
|
|
_closed = true;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::pushHeader(
|
|
const QByteArray &header,
|
|
const QString &path) {
|
|
auto result = pushDiv("page_header");
|
|
result.append(path.isEmpty()
|
|
? pushDiv("content")
|
|
: pushTag("a", {
|
|
{ "class", "content block_link" },
|
|
{ "href", relativePath(path).toUtf8() },
|
|
{ "onclick", "return GoBack(this)"},
|
|
}));
|
|
result.append(pushDiv("text bold"));
|
|
result.append(SerializeString(header));
|
|
result.append(popTag());
|
|
result.append(popTag());
|
|
result.append(popTag());
|
|
return result;
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::pushSection(
|
|
const QByteArray &header,
|
|
const QByteArray &type,
|
|
int count,
|
|
const QString &link) {
|
|
auto result = pushTag("a", {
|
|
{ "class", "section block_link " + type },
|
|
{ "href", link.toUtf8() + "#allow_back" },
|
|
});
|
|
result.append(pushDiv("counter details"));
|
|
result.append(Data::NumberToString(count));
|
|
result.append(popTag());
|
|
result.append(pushDiv("label bold"));
|
|
result.append(SerializeString(header));
|
|
result.append(popTag());
|
|
result.append(popTag());
|
|
return result;
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::pushAbout(
|
|
const QByteArray &text,
|
|
bool withDivider) {
|
|
auto result = pushDiv(withDivider
|
|
? "page_about details with_divider"
|
|
: "page_about details");
|
|
result.append(MakeLinks(SerializeString(text)));
|
|
result.append(popTag());
|
|
return result;
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::pushServiceMessage(
|
|
int messageId,
|
|
const Data::DialogInfo &dialog,
|
|
const QString &basePath,
|
|
const QByteArray &serialized,
|
|
const Data::Photo *photo) {
|
|
auto result = pushTag("div", {
|
|
{ "class", "message service" },
|
|
{ "id", "message" + Data::NumberToString(messageId) }
|
|
});
|
|
result.append(pushDiv("body details"));
|
|
result.append(serialized);
|
|
result.append(popTag());
|
|
if (photo) {
|
|
auto userpic = UserpicData();
|
|
userpic.colorIndex = dialog.colorIndex;
|
|
userpic.firstName = dialog.name;
|
|
userpic.lastName = dialog.lastName;
|
|
userpic.pixelSize = kServiceMessagePhotoSize;
|
|
userpic.largeLink = photo->image.file.relativePath;
|
|
userpic.imageLink = WriteUserpicThumb(
|
|
basePath,
|
|
userpic.largeLink,
|
|
userpic);
|
|
result.append(pushDiv("userpic_wrap"));
|
|
result.append(pushUserpic(userpic));
|
|
result.append(popTag());
|
|
}
|
|
result.append(popTag());
|
|
return result;
|
|
}
|
|
|
|
auto HtmlWriter::Wrap::pushMessage(
|
|
const Data::Message &message,
|
|
const MessageInfo *previous,
|
|
const Data::DialogInfo &dialog,
|
|
const QString &basePath,
|
|
const PeersMap &peers,
|
|
const QString &internalLinksDomain,
|
|
Fn<QByteArray(int messageId, QByteArray text)> wrapMessageLink
|
|
) -> std::pair<MessageInfo, QByteArray> {
|
|
using namespace Data;
|
|
|
|
auto info = MessageInfo();
|
|
info.id = message.id;
|
|
info.fromId = message.fromId;
|
|
info.viaBotId = message.viaBotId;
|
|
info.date = message.date;
|
|
info.forwardedFromId = message.forwardedFromId;
|
|
info.forwardedFromName = message.forwardedFromName;
|
|
info.forwardedDate = message.forwardedDate;
|
|
info.forwarded = message.forwarded;
|
|
info.showForwardedAsOriginal = message.showForwardedAsOriginal;
|
|
if (v::is<UnsupportedMedia>(message.media.content)) {
|
|
return { info, pushServiceMessage(
|
|
message.id,
|
|
dialog,
|
|
basePath,
|
|
"This message is not supported by this version "
|
|
"of Telegram Desktop. Please update the application.") };
|
|
}
|
|
|
|
const auto wrapReplyToLink = [&](const QByteArray &text) {
|
|
return wrapMessageLink(message.replyToMsgId, text);
|
|
};
|
|
|
|
using DialogType = Data::DialogInfo::Type;
|
|
const auto isChannel = (dialog.type == DialogType::PrivateChannel)
|
|
|| (dialog.type == DialogType::PublicChannel);
|
|
const auto serviceFrom = peers.wrapPeerName(message.fromId);
|
|
const auto serviceText = v::match(message.action.content, [&](
|
|
const ActionChatCreate &data) {
|
|
return serviceFrom
|
|
+ " created group «"
|
|
+ SerializeString(data.title)
|
|
+ "»"
|
|
+ (data.userIds.empty()
|
|
? QByteArray()
|
|
: " with members " + peers.wrapUserNames(data.userIds));
|
|
}, [&](const ActionChatEditTitle &data) {
|
|
return isChannel
|
|
? ("Channel title changed to «"
|
|
+ SerializeString(data.title)
|
|
+ "»")
|
|
: (serviceFrom
|
|
+ " changed group title to «"
|
|
+ SerializeString(data.title)
|
|
+ "»");
|
|
}, [&](const ActionChatEditPhoto &data) {
|
|
return isChannel
|
|
? QByteArray("Channel photo changed")
|
|
: (serviceFrom + " changed group photo");
|
|
}, [&](const ActionChatDeletePhoto &data) {
|
|
return isChannel
|
|
? QByteArray("Channel photo removed")
|
|
: (serviceFrom + " removed group photo");
|
|
}, [&](const ActionChatAddUser &data) {
|
|
return serviceFrom
|
|
+ " invited "
|
|
+ peers.wrapUserNames(data.userIds);
|
|
}, [&](const ActionChatDeleteUser &data) {
|
|
return serviceFrom
|
|
+ " removed "
|
|
+ peers.wrapUserName(data.userId);
|
|
}, [&](const ActionChatJoinedByLink &data) {
|
|
return serviceFrom
|
|
+ " joined group by link from "
|
|
+ peers.wrapUserName(data.inviterId);
|
|
}, [&](const ActionChannelCreate &data) {
|
|
return "Channel «"
|
|
+ SerializeString(data.title)
|
|
+ "» created";
|
|
}, [&](const ActionChatMigrateTo &data) {
|
|
return serviceFrom
|
|
+ " converted this group to a supergroup";
|
|
}, [&](const ActionChannelMigrateFrom &data) {
|
|
return serviceFrom
|
|
+ " converted a basic group to this supergroup "
|
|
+ "«" + SerializeString(data.title) + "»";
|
|
}, [&](const ActionPinMessage &data) {
|
|
return serviceFrom
|
|
+ " pinned "
|
|
+ wrapReplyToLink("this message");
|
|
}, [&](const ActionHistoryClear &data) {
|
|
return QByteArray("History cleared");
|
|
}, [&](const ActionGameScore &data) {
|
|
return serviceFrom
|
|
+ " scored "
|
|
+ NumberToString(data.score)
|
|
+ " in "
|
|
+ wrapReplyToLink("this game");
|
|
}, [&](const ActionPaymentSent &data) {
|
|
const auto amount = FormatMoneyAmount(data.amount, data.currency);
|
|
if (data.recurringUsed) {
|
|
return "You were charged " + amount + " via recurring payment";
|
|
}
|
|
auto result = "You have successfully transferred "
|
|
+ amount
|
|
+ " for "
|
|
+ wrapReplyToLink("this invoice");
|
|
if (data.recurringInit) {
|
|
result += " and allowed future recurring payments";
|
|
}
|
|
return result;
|
|
}, [&](const ActionPhoneCall &data) {
|
|
return QByteArray();
|
|
}, [&](const ActionScreenshotTaken &data) {
|
|
return serviceFrom + " took a screenshot";
|
|
}, [&](const ActionCustomAction &data) {
|
|
return data.message;
|
|
}, [&](const ActionBotAllowed &data) {
|
|
return data.attachMenu
|
|
? "You allowed this bot to message you "
|
|
"when you added it in the attachment menu."_q
|
|
: data.fromRequest
|
|
? "You allowed this bot to message you in his web-app."_q
|
|
: data.app.isEmpty()
|
|
? ("You allowed this bot to message you when you opened "
|
|
+ SerializeString(data.app))
|
|
: ("You allowed this bot to message you when you logged in on "
|
|
+ SerializeString(data.domain));
|
|
}, [&](const ActionSecureValuesSent &data) {
|
|
auto list = std::vector<QByteArray>();
|
|
for (const auto type : data.types) {
|
|
list.push_back([&] {
|
|
using Type = ActionSecureValuesSent::Type;
|
|
switch (type) {
|
|
case Type::PersonalDetails: return "Personal details";
|
|
case Type::Passport: return "Passport";
|
|
case Type::DriverLicense: return "Driver license";
|
|
case Type::IdentityCard: return "Identity card";
|
|
case Type::InternalPassport: return "Internal passport";
|
|
case Type::Address: return "Address information";
|
|
case Type::UtilityBill: return "Utility bill";
|
|
case Type::BankStatement: return "Bank statement";
|
|
case Type::RentalAgreement: return "Rental agreement";
|
|
case Type::PassportRegistration:
|
|
return "Passport registration";
|
|
case Type::TemporaryRegistration:
|
|
return "Temporary registration";
|
|
case Type::Phone: return "Phone number";
|
|
case Type::Email: return "Email";
|
|
}
|
|
return "";
|
|
}());
|
|
}
|
|
return "You have sent the following documents: "
|
|
+ SerializeList(list);
|
|
}, [&](const ActionContactSignUp &data) {
|
|
return serviceFrom + " joined Telegram";
|
|
}, [&](const ActionGeoProximityReached &data) {
|
|
const auto fromName = peers.wrapPeerName(data.fromId);
|
|
const auto toName = peers.wrapPeerName(data.toId);
|
|
const auto distance = [&]() -> QString {
|
|
if (data.distance >= 1000) {
|
|
const auto km = (10 * (data.distance / 10)) / 1000.;
|
|
return QString::number(km) + " km";
|
|
} else if (data.distance == 1) {
|
|
return "1 meter";
|
|
} else {
|
|
return QString::number(data.distance) + " meters";
|
|
}
|
|
}().toUtf8();
|
|
if (data.fromSelf) {
|
|
return "You are now within " + distance + " from " + toName;
|
|
} else if (data.toSelf) {
|
|
return fromName + " is now within " + distance + " from you";
|
|
} else {
|
|
return fromName
|
|
+ " is now within "
|
|
+ distance
|
|
+ " from "
|
|
+ toName;
|
|
}
|
|
}, [&](const ActionPhoneNumberRequest &data) {
|
|
return serviceFrom + " requested your phone number";
|
|
}, [&](const ActionGroupCall &data) {
|
|
const auto durationText = (data.duration
|
|
? (" (" + QString::number(data.duration) + " seconds)")
|
|
: QString()).toUtf8();
|
|
return isChannel
|
|
? ("Voice chat" + durationText)
|
|
: (serviceFrom + " started voice chat" + durationText);
|
|
}, [&](const ActionInviteToGroupCall &data) {
|
|
return serviceFrom
|
|
+ " invited "
|
|
+ peers.wrapUserNames(data.userIds)
|
|
+ " to the voice chat";
|
|
}, [&](const ActionSetMessagesTTL &data) {
|
|
const auto periodText = (data.period == 7 * 86400)
|
|
? "7 days"
|
|
: (data.period == 86400)
|
|
? "24 hours"
|
|
: QByteArray();
|
|
return isChannel
|
|
? (data.period
|
|
? "New messages will auto-delete in " + periodText
|
|
: "New messages will not auto-delete")
|
|
: (data.period
|
|
? (serviceFrom
|
|
+ " has set messages to auto-delete in " + periodText)
|
|
: (serviceFrom
|
|
+ " has set messages not to auto-delete"));
|
|
}, [&](const ActionGroupCallScheduled &data) {
|
|
const auto dateText = FormatDateTime(data.date);
|
|
return isChannel
|
|
? ("Voice chat scheduled for " + dateText)
|
|
: (serviceFrom + " scheduled a voice chat for " + dateText);
|
|
}, [&](const ActionSetChatTheme &data) {
|
|
if (data.emoji.isEmpty()) {
|
|
return isChannel
|
|
? "Channel theme was disabled"
|
|
: (serviceFrom + " disabled chat theme");
|
|
}
|
|
return isChannel
|
|
? ("Channel theme was changed to " + data.emoji).toUtf8()
|
|
: (serviceFrom + " changed chat theme to " + data.emoji).toUtf8();
|
|
}, [&](const ActionChatJoinedByRequest &data) {
|
|
return serviceFrom
|
|
+ " joined group by request";
|
|
}, [&](const ActionWebViewDataSent &data) {
|
|
return "You have just successfully transferred data from the «"
|
|
+ SerializeString(data.text)
|
|
+ "» button to the bot";
|
|
}, [&](const ActionGiftPremium &data) {
|
|
if (!data.days || data.cost.isEmpty()) {
|
|
return serviceFrom + " sent you a gift.";
|
|
}
|
|
return serviceFrom
|
|
+ " sent you a gift for "
|
|
+ data.cost
|
|
+ ": Telegram Premium for "
|
|
+ QString::number(data.days).toUtf8()
|
|
+ " days.";
|
|
}, [&](const ActionTopicCreate &data) {
|
|
return serviceFrom
|
|
+ " created topic «"
|
|
+ SerializeString(data.title)
|
|
+ "»";
|
|
}, [&](const ActionTopicEdit &data) {
|
|
auto parts = QList<QByteArray>();
|
|
if (!data.title.isEmpty()) {
|
|
parts.push_back("title to «"
|
|
+ SerializeString(data.title)
|
|
+ "»");
|
|
}
|
|
if (data.iconEmojiId) {
|
|
parts.push_back("icon to «"
|
|
+ QString::number(*data.iconEmojiId).toUtf8()
|
|
+ "»");
|
|
}
|
|
return serviceFrom + " changed topic " + parts.join(',');
|
|
}, [&](const ActionSuggestProfilePhoto &data) {
|
|
return serviceFrom + " suggests to use this photo";
|
|
}, [&](const ActionRequestedPeer &data) {
|
|
return "requested: "_q/* + data.peerId*/;
|
|
}, [&](const ActionSetChatWallPaper &data) {
|
|
return serviceFrom
|
|
+ (data.same
|
|
? (" set "
|
|
+ wrapReplyToLink("the same background")
|
|
+ " for this chat")
|
|
: " set a new background for this chat");
|
|
}, [&](const ActionGiftCode &data) {
|
|
return data.unclaimed
|
|
? ("This is an unclaimed Telegram Premium for "
|
|
+ NumberToString(data.days)
|
|
+ (data.days > 1 ? " days" : " day")
|
|
+ " prize in a giveaway organized by a channel.")
|
|
: data.viaGiveaway
|
|
? ("You won a Telegram Premium for "
|
|
+ NumberToString(data.days)
|
|
+ (data.days > 1 ? " days" : " day")
|
|
+ " prize in a giveaway organized by a channel.")
|
|
: ("You've received a Telegram Premium for "
|
|
+ NumberToString(data.days)
|
|
+ (data.days > 1 ? " days" : " day")
|
|
+ " gift from a channel.");
|
|
}, [&](const ActionGiveawayLaunch &data) {
|
|
return serviceFrom + " just started a giveaway "
|
|
"of Telegram Premium subscriptions to its followers.";
|
|
}, [&](const ActionGiveawayResults &data) {
|
|
return !data.winners
|
|
? "No winners of the giveaway could be selected."
|
|
: (data.credits && data.unclaimed)
|
|
? "Some winners of the giveaway were randomly selected by "
|
|
"Telegram and received their prize."
|
|
: (!data.credits && data.unclaimed)
|
|
? "Some winners of the giveaway were randomly selected by "
|
|
"Telegram and received private messages with giftcodes."
|
|
: (data.credits && !data.unclaimed)
|
|
? NumberToString(data.winners) + " of the giveaway was randomly "
|
|
"selected by Telegram and received their prize."
|
|
: NumberToString(data.winners) + " of the giveaway was randomly "
|
|
"selected by Telegram and received private messages with "
|
|
"giftcodes.";
|
|
}, [&](const ActionBoostApply &data) {
|
|
return serviceFrom
|
|
+ " boosted the group "
|
|
+ QByteArray::number(data.boosts)
|
|
+ (data.boosts > 1 ? " times" : " time");
|
|
}, [&](const ActionPaymentRefunded &data) {
|
|
const auto amount = FormatMoneyAmount(data.amount, data.currency);
|
|
auto result = peers.wrapPeerName(data.peerId)
|
|
+ " refunded back "
|
|
+ amount;
|
|
return result;
|
|
}, [&](const ActionGiftCredits &data) {
|
|
if (!data.amount || data.cost.isEmpty()) {
|
|
return serviceFrom + " sent you a gift.";
|
|
}
|
|
return serviceFrom
|
|
+ " sent you a gift for "
|
|
+ data.cost
|
|
+ ": "
|
|
+ QString::number(data.amount.value()).toUtf8()
|
|
+ (data.amount.ton() ? " TON." : " Telegram Stars.");
|
|
}, [&](const ActionPrizeStars &data) {
|
|
return "You won a prize in a giveaway organized by "
|
|
+ peers.wrapPeerName(data.peerId)
|
|
+ ".\n Your prize is "
|
|
+ QString::number(data.amount).toUtf8()
|
|
+ " Telegram Stars.";
|
|
}, [&](const ActionStarGift &data) {
|
|
return serviceFrom
|
|
+ " sent you a gift of "
|
|
+ QByteArray::number(data.stars)
|
|
+ " Telegram Stars.";
|
|
}, [&](const ActionPaidMessagesRefunded &data) {
|
|
auto result = message.out
|
|
? ("You refunded "
|
|
+ QString::number(data.stars).toUtf8()
|
|
+ " Stars for "
|
|
+ QString::number(data.messages).toUtf8()
|
|
+ " messages to "
|
|
+ peers.wrapPeerName(dialog.peerId))
|
|
: (peers.wrapPeerName(dialog.peerId)
|
|
+ " refunded "
|
|
+ QString::number(data.stars).toUtf8()
|
|
+ " Stars for "
|
|
+ QString::number(data.messages).toUtf8()
|
|
+ " messages to you");
|
|
return result;
|
|
}, [&](const ActionPaidMessagesPrice &data) {
|
|
if (isChannel) {
|
|
auto result = !data.broadcastAllowed
|
|
? "Direct messages were disabled."
|
|
: ("Price per direct message changed to "
|
|
+ QString::number(data.stars).toUtf8()
|
|
+ " Telegram Stars.");
|
|
return result;
|
|
}
|
|
auto result = "Price per message changed to "
|
|
+ QString::number(data.stars).toUtf8()
|
|
+ " Telegram Stars.";
|
|
return result;
|
|
}, [&](const ActionTodoCompletions &data) {
|
|
auto completed = QByteArrayList();
|
|
for (const auto index : data.completed) {
|
|
completed.push_back(QByteArray::number(index));
|
|
}
|
|
auto incompleted = QByteArrayList();
|
|
for (const auto index : data.incompleted) {
|
|
incompleted.push_back(QByteArray::number(index));
|
|
}
|
|
const auto list = [](const QByteArrayList &v) {
|
|
return v.isEmpty()
|
|
? QByteArray()
|
|
: (v.size() > 1)
|
|
? (v.mid(0, v.size() - 1).join(", ") + " and " + v.back())
|
|
: v.front();
|
|
};
|
|
if (completed.isEmpty() && !incompleted.isEmpty()) {
|
|
return serviceFrom
|
|
+ " marked "
|
|
+ list(incompleted)
|
|
+ " as not done yet in "
|
|
+ wrapReplyToLink("this todo list") + ".";
|
|
} else if (!completed.isEmpty() && incompleted.isEmpty()) {
|
|
return serviceFrom
|
|
+ " marked "
|
|
+ list(completed)
|
|
+ " as done in "
|
|
+ wrapReplyToLink("this todo list") + ".";
|
|
}
|
|
return serviceFrom
|
|
+ " marked "
|
|
+ list(completed)
|
|
+ " as done and "
|
|
+ list(incompleted)
|
|
+ " as not done yet in "
|
|
+ wrapReplyToLink("this todo list") + ".";
|
|
}, [&](const ActionTodoAppendTasks &data) {
|
|
auto tasks = QByteArrayList();
|
|
for (const auto &task : data.items) {
|
|
tasks.push_back("""
|
|
+ FormatText(task.text, internalLinksDomain, _base)
|
|
+ """);
|
|
}
|
|
return serviceFrom + " added tasks: " + tasks.join(", ");
|
|
}, [&](const ActionPollAppendAnswer &data) {
|
|
return serviceFrom + " added ""
|
|
+ data.option
|
|
+ "" to the poll.";
|
|
}, [&](const ActionPollDeleteAnswer &data) {
|
|
return serviceFrom + " removed ""
|
|
+ data.option
|
|
+ "" from the poll.";
|
|
}, [&](const ActionSuggestedPostApproval &data) {
|
|
return serviceFrom
|
|
+ (data.rejected ? " rejected " : " approved ")
|
|
+ "your suggested post"
|
|
+ (data.price
|
|
? (", for "
|
|
+ QString::number(data.price.value()).toUtf8()
|
|
+ (data.price.ton() ? " TON" : " stars"))
|
|
: "")
|
|
+ (data.scheduleDate
|
|
? (", "
|
|
+ FormatDateText(data.scheduleDate)
|
|
+ " at "
|
|
+ FormatTimeText(data.scheduleDate))
|
|
: "")
|
|
+ (data.rejectComment.isEmpty()
|
|
? "."
|
|
: (", with comment: ""
|
|
+ SerializeString(data.rejectComment)
|
|
+ """));
|
|
}, [&](const ActionSuggestedPostSuccess &data) {
|
|
return "The paid post was shown for 24 hours and "
|
|
+ QString::number(data.price.value()).toUtf8()
|
|
+ (data.price.ton() ? " TON" : " stars")
|
|
+ " were transferred to the channel.";
|
|
}, [&](const ActionSuggestedPostRefund &data) {
|
|
return QByteArray() + (data.payerInitiated
|
|
? "The user refunded the payment, post was deleted."
|
|
: "The admin deleted the post early, the payment was refunded.");
|
|
}, [&](const ActionSuggestBirthday &data) {
|
|
return serviceFrom
|
|
+ " suggests to add a date of birth: "
|
|
+ QByteArray::number(data.birthday.day())
|
|
+ [&] {
|
|
switch (data.birthday.month()) {
|
|
case 1: return " January";
|
|
case 2: return " February";
|
|
case 3: return " March";
|
|
case 4: return " April";
|
|
case 5: return " May";
|
|
case 6: return " June";
|
|
case 7: return " July";
|
|
case 8: return " August";
|
|
case 9: return " September";
|
|
case 10: return " October";
|
|
case 11: return " November";
|
|
case 12: return " December";
|
|
}
|
|
return "";
|
|
}() + (data.birthday.year()
|
|
? (' ' + QByteArray::number(data.birthday.year()))
|
|
: QByteArray());
|
|
}, [&](const ActionNoForwardsToggle &data) {
|
|
return serviceFrom
|
|
+ (data.newValue
|
|
? " disabled sharing in this chat"
|
|
: " enabled sharing in this chat");
|
|
}, [&](const ActionNoForwardsRequest &data) {
|
|
return serviceFrom
|
|
+ " requested to enable sharing in this chat";
|
|
}, [&](const ActionNewCreatorPending &data) {
|
|
return peers.wrapUserName(data.newCreatorId)
|
|
+ " will become the new main admin in 7 days if "
|
|
+ serviceFrom
|
|
+ " does not return";
|
|
}, [&](const ActionChangeCreator &data) {
|
|
return serviceFrom
|
|
+ " made "
|
|
+ peers.wrapUserName(data.newCreatorId)
|
|
+ " the new main admin of the group";
|
|
}, [&](const ActionManagedBotCreated &data) {
|
|
return serviceFrom
|
|
+ " created a bot "
|
|
+ peers.wrapUserName(data.botId);
|
|
}, [](v::null_t) { return QByteArray(); });
|
|
|
|
if (!serviceText.isEmpty()) {
|
|
const auto &content = message.action.content;
|
|
const auto photo = v::is<ActionChatEditPhoto>(content)
|
|
? &v::get<ActionChatEditPhoto>(content).photo
|
|
: v::is<ActionSuggestProfilePhoto>(content)
|
|
? &v::get<ActionSuggestProfilePhoto>(content).photo
|
|
: nullptr;
|
|
return { info, pushServiceMessage(
|
|
message.id,
|
|
dialog,
|
|
basePath,
|
|
serviceText,
|
|
photo) };
|
|
}
|
|
info.type = MessageInfo::Type::Default;
|
|
|
|
const auto wrap = messageNeedsWrap(message, previous);
|
|
const auto fromPeerId = message.fromId;
|
|
const auto showForwardedInfo = message.forwarded
|
|
&& !message.showForwardedAsOriginal;
|
|
auto forwardedUserpic = UserpicData();
|
|
if (message.forwarded) {
|
|
forwardedUserpic.colorIndex = message.forwardedFromId
|
|
? PeerColorIndex(message.forwardedFromId)
|
|
: PeerColorIndex(message.id);
|
|
forwardedUserpic.pixelSize = kHistoryUserpicSize;
|
|
if (message.forwardedFromId) {
|
|
FillUserpicNames(
|
|
forwardedUserpic,
|
|
peers.peer(message.forwardedFromId));
|
|
} else {
|
|
FillUserpicNames(forwardedUserpic, message.forwardedFromName);
|
|
}
|
|
}
|
|
auto userpic = UserpicData();
|
|
if (message.showForwardedAsOriginal) {
|
|
userpic = forwardedUserpic;
|
|
} else {
|
|
userpic.colorIndex = PeerColorIndex(fromPeerId);
|
|
userpic.pixelSize = kHistoryUserpicSize;
|
|
FillUserpicNames(userpic, peers.peer(fromPeerId));
|
|
}
|
|
|
|
const auto via = [&] {
|
|
if (message.viaBotId) {
|
|
const auto &user = peers.user(message.viaBotId);
|
|
if (!user.username.isEmpty()) {
|
|
return SerializeString(user.username);
|
|
}
|
|
}
|
|
return QByteArray();
|
|
}();
|
|
|
|
const auto className = wrap
|
|
? "message default clearfix"
|
|
: "message default clearfix joined";
|
|
auto block = pushTag("div", {
|
|
{ "class", className },
|
|
{ "id", "message" + NumberToString(message.id) }
|
|
});
|
|
if (wrap) {
|
|
block.append(pushDiv("pull_left userpic_wrap"));
|
|
block.append(pushUserpic(userpic));
|
|
block.append(popTag());
|
|
}
|
|
block.append(pushDiv("body"));
|
|
block.append(pushTag("div", {
|
|
{ "class", "pull_right date details" },
|
|
{ "title", FormatDateTime(message.date, true) },
|
|
}));
|
|
block.append(FormatTimeText(message.date));
|
|
block.append(popTag());
|
|
if (wrap) {
|
|
block.append(pushDiv("from_name"));
|
|
block.append(SerializeString(
|
|
ComposeName(userpic, "Deleted Account")));
|
|
if (!via.isEmpty()
|
|
&& (!message.forwarded || message.showForwardedAsOriginal)) {
|
|
block.append(" via @" + via);
|
|
}
|
|
block.append(popTag());
|
|
}
|
|
if (showForwardedInfo) {
|
|
const auto forwardedWrap = forwardedNeedsWrap(message, previous);
|
|
if (forwardedWrap) {
|
|
block.append(pushDiv("pull_left forwarded userpic_wrap"));
|
|
block.append(pushUserpic(forwardedUserpic));
|
|
block.append(popTag());
|
|
}
|
|
block.append(pushDiv("forwarded body"));
|
|
if (forwardedWrap) {
|
|
block.append(pushDiv("from_name"));
|
|
block.append(SerializeString(
|
|
ComposeName(forwardedUserpic, "Deleted Account")));
|
|
if (!via.isEmpty()) {
|
|
block.append(" via @" + via);
|
|
}
|
|
block.append(pushTag("span", {
|
|
{ "class", "date details" },
|
|
{ "title", FormatDateTime(message.forwardedDate, true) },
|
|
{ "inline", "" }
|
|
}));
|
|
block.append(' ' + FormatDateTime(message.forwardedDate));
|
|
block.append(popTag());
|
|
block.append(popTag());
|
|
}
|
|
}
|
|
if (message.replyToMsgId) {
|
|
block.append(pushDiv("reply_to details"));
|
|
if (message.replyToPeerId) {
|
|
block.append("In reply to a message in another chat");
|
|
} else {
|
|
block.append("In reply to ");
|
|
block.append(wrapReplyToLink("this message"));
|
|
}
|
|
block.append(popTag());
|
|
}
|
|
|
|
block.append(
|
|
pushMedia(
|
|
message,
|
|
basePath,
|
|
peers,
|
|
internalLinksDomain,
|
|
wrapMessageLink));
|
|
|
|
if (message.richMessage) {
|
|
const auto callbacks = RichMediaCallbacks{
|
|
.photo = [this, basePath](const Data::Photo *photo) {
|
|
return pushRichPhotoMedia(photo, basePath);
|
|
},
|
|
.video = [this, basePath](const Data::Document *document) {
|
|
return pushRichVideoMedia(document, basePath);
|
|
},
|
|
.audio = [this](const Data::Document *document) {
|
|
return pushRichAudioMedia(document);
|
|
},
|
|
.generic = [this](const MediaData &data) {
|
|
return pushGenericMedia(data);
|
|
},
|
|
.photoCard = [this, basePath](
|
|
const MediaData &data,
|
|
const Data::Photo *photo) {
|
|
return pushRichReferenceMedia(data, photo, basePath);
|
|
},
|
|
};
|
|
block.append(RenderRichMessage(
|
|
_context,
|
|
*message.richMessage,
|
|
message.id,
|
|
internalLinksDomain,
|
|
_base,
|
|
callbacks));
|
|
} else {
|
|
const auto text = FormatText(message.text, internalLinksDomain, _base);
|
|
if (!text.isEmpty()) {
|
|
block.append(pushDiv("text"));
|
|
block.append(text);
|
|
block.append(popTag());
|
|
}
|
|
}
|
|
if (!message.inlineButtonRows.empty()) {
|
|
using Type = HistoryMessageMarkupButton::Type;
|
|
const auto endline = u" | "_q;
|
|
block.append(pushTag("table", { { "class", "bot_buttons_table" } }));
|
|
block.append(pushTag("tbody"));
|
|
for (const auto &row : message.inlineButtonRows) {
|
|
block.append(pushTag("tr"));
|
|
block.append(pushTag("td", { { "class", "bot_button_row" } }));
|
|
for (const auto &button : row) {
|
|
using Attribute = std::pair<QByteArray, QByteArray>;
|
|
const auto content = (!button.data.isEmpty()
|
|
? (u"Data: "_q + button.data + endline)
|
|
: QString())
|
|
+ (!button.forwardText.isEmpty()
|
|
? (u"Forward text: "_q + button.forwardText + endline)
|
|
: QString())
|
|
+ (u"Type: "_q
|
|
+ HistoryMessageMarkupButton::TypeToString(button));
|
|
const auto link = (button.type == Type::Url)
|
|
? button.data
|
|
: QByteArray();
|
|
const auto onclick = (button.type != Type::Url)
|
|
? ("return ShowTextCopied('"
|
|
+ QString(content)
|
|
.replace('\\', u"\\\\"_q)
|
|
.replace('\'', u"\\'"_q)
|
|
+ "');").toUtf8()
|
|
: QByteArray();
|
|
block.append(pushTag("div", { { "class", "bot_button" } }));
|
|
block.append(pushTag("a", {
|
|
link.isEmpty() ? Attribute() : Attribute{ "href", link },
|
|
onclick.isEmpty()
|
|
? Attribute()
|
|
: Attribute{ "onclick", onclick },
|
|
}));
|
|
block.append(pushTag("div"));
|
|
block.append(SerializeString(button.text.toUtf8()));
|
|
block.append(popTag());
|
|
block.append(popTag());
|
|
block.append(popTag());
|
|
|
|
if (&button != &row.back()) {
|
|
block.append(pushTag("div", {
|
|
{ "class", "bot_button_column_separator" }
|
|
}));
|
|
block.append(popTag());
|
|
}
|
|
}
|
|
block.append(popTag());
|
|
block.append(popTag());
|
|
}
|
|
block.append(popTag());
|
|
block.append(popTag());
|
|
}
|
|
if (!message.signature.isEmpty()) {
|
|
block.append(pushDiv("signature details"));
|
|
block.append(SerializeString(message.signature));
|
|
block.append(popTag());
|
|
}
|
|
if (showForwardedInfo) {
|
|
block.append(popTag());
|
|
}
|
|
if (!message.reactions.empty()) {
|
|
block.append(pushTag("span", {
|
|
{ "class", "reactions" },
|
|
}));
|
|
for (const auto &reaction : message.reactions) {
|
|
auto reactionClass = QByteArray("reaction");
|
|
for (const auto &recent : reaction.recent) {
|
|
const auto peer = peers.peer(recent.peerId);
|
|
if (peer.user() && peer.user()->isSelf) {
|
|
reactionClass += " active";
|
|
break;
|
|
}
|
|
}
|
|
if (reaction.type == Reaction::Type::Paid) {
|
|
reactionClass += " paid";
|
|
}
|
|
|
|
block.append(pushTag("span", {
|
|
{ "class", reactionClass },
|
|
}));
|
|
block.append(pushTag("span", {
|
|
{ "class", "emoji" },
|
|
}));
|
|
switch (reaction.type) {
|
|
case Reaction::Type::Emoji:
|
|
block.append(SerializeString(reaction.emoji.toUtf8()));
|
|
break;
|
|
case Reaction::Type::CustomEmoji:
|
|
block.append(FormatCustomEmoji(
|
|
reaction.documentId,
|
|
"\U0001F44B",
|
|
_base));
|
|
break;
|
|
case Reaction::Type::Paid:
|
|
block.append(SerializeString("\u2B50"));
|
|
break;
|
|
}
|
|
block.append(popTag());
|
|
if (!reaction.recent.empty()) {
|
|
block.append(pushTag("span", {
|
|
{ "class", "userpics" },
|
|
}));
|
|
for (const auto &recent : reaction.recent) {
|
|
const auto peer = peers.peer(recent.peerId);
|
|
block.append(pushUserpic(UserpicData({
|
|
.colorIndex = peer.colorIndex(),
|
|
.pixelSize = 20,
|
|
.firstName = peer.user()
|
|
? peer.user()->info.firstName
|
|
: peer.name(),
|
|
.lastName = peer.user()
|
|
? peer.user()->info.lastName
|
|
: "",
|
|
.tooltip = peer.name(),
|
|
})));
|
|
}
|
|
block.append(popTag());
|
|
}
|
|
if (reaction.recent.empty()
|
|
|| (reaction.count > reaction.recent.size())) {
|
|
block.append(pushTag("span", {
|
|
{ "class", "count" },
|
|
}));
|
|
block.append(NumberToString(reaction.count));
|
|
block.append(popTag());
|
|
}
|
|
block.append(popTag());
|
|
}
|
|
block.append(popTag());
|
|
}
|
|
block.append(popTag());
|
|
block.append(popTag());
|
|
|
|
return { info, block };
|
|
}
|
|
|
|
bool HtmlWriter::Wrap::messageNeedsWrap(
|
|
const Data::Message &message,
|
|
const MessageInfo *previous) const {
|
|
if (!previous) {
|
|
return true;
|
|
} else if (previous->type != MessageInfo::Type::Default) {
|
|
return true;
|
|
} else if (!message.fromId || previous->fromId != message.fromId) {
|
|
return true;
|
|
} else if (message.viaBotId != previous->viaBotId) {
|
|
return true;
|
|
} else if (QDateTime::fromSecsSinceEpoch(previous->date).date()
|
|
!= QDateTime::fromSecsSinceEpoch(message.date).date()) {
|
|
return true;
|
|
} else if (message.forwarded != previous->forwarded
|
|
|| message.showForwardedAsOriginal != previous->showForwardedAsOriginal
|
|
|| message.forwardedFromId != previous->forwardedFromId
|
|
|| message.forwardedFromName != previous->forwardedFromName) {
|
|
return true;
|
|
} else if (std::abs(message.date - previous->date)
|
|
> ((message.forwardedFromId || !message.forwardedFromName.isEmpty())
|
|
? 1
|
|
: kJoinWithinSeconds)) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::pushMedia(
|
|
const Data::Message &message,
|
|
const QString &basePath,
|
|
const PeersMap &peers,
|
|
const QString &internalLinksDomain,
|
|
Fn<QByteArray(int messageId, QByteArray text)> wrapMessageLink) {
|
|
const auto data = prepareMediaData(
|
|
message,
|
|
basePath,
|
|
peers,
|
|
internalLinksDomain);
|
|
if (!data.classes.isEmpty()) {
|
|
return pushGenericMedia(data);
|
|
}
|
|
using namespace Data;
|
|
const auto &content = message.media.content;
|
|
if (const auto document = std::get_if<Document>(&content)) {
|
|
Assert(!message.media.ttl);
|
|
if (document->isSticker) {
|
|
return pushStickerMedia(*document, basePath);
|
|
} else if (document->isAnimated) {
|
|
return pushAnimatedMedia(*document, basePath);
|
|
} else if (document->isVideoFile) {
|
|
return pushVideoFileMedia(*document, basePath);
|
|
}
|
|
Unexpected("Non generic document in HtmlWriter::Wrap::pushMedia.");
|
|
} else if (const auto photo = std::get_if<Photo>(&content)) {
|
|
Assert(!message.media.ttl);
|
|
return pushPhotoMedia(*photo, basePath);
|
|
} else if (const auto poll = std::get_if<Poll>(&content)) {
|
|
return pushPoll(*poll, internalLinksDomain, _base);
|
|
} else if (const auto todo = std::get_if<TodoList>(&content)) {
|
|
return pushTodoList(*todo, internalLinksDomain, _base);
|
|
} else if (const auto giveaway = std::get_if<GiveawayStart>(&content)) {
|
|
return pushGiveaway(peers, *giveaway);
|
|
} else if (const auto giveaway = std::get_if<GiveawayResults>(&content)) {
|
|
return pushGiveaway(peers, *giveaway, wrapMessageLink);
|
|
}
|
|
Assert(v::is_null(content));
|
|
return QByteArray();
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::pushGenericMedia(const MediaData &data) {
|
|
auto result = pushDiv("media_wrap clearfix");
|
|
if (data.link.isEmpty()) {
|
|
result.append(pushDiv("media clearfix pull_left " + data.classes));
|
|
} else {
|
|
result.append(pushTag("a", {
|
|
{
|
|
"class",
|
|
"media clearfix pull_left block_link " + data.classes
|
|
},
|
|
{
|
|
"href",
|
|
(IsGlobalLink(data.link)
|
|
? data.link.toUtf8()
|
|
: relativePath(data.link).toUtf8())
|
|
}
|
|
}));
|
|
}
|
|
if (data.thumb.isEmpty()) {
|
|
result.append(pushDiv("fill pull_left"));
|
|
result.append(popTag());
|
|
} else {
|
|
result.append(pushTag("img", {
|
|
{ "class", "thumb pull_left" },
|
|
{ "src", relativePath(data.thumb).toUtf8() },
|
|
{ "empty", "" }
|
|
}));
|
|
}
|
|
result.append(pushDiv("body"));
|
|
if (!data.title.isEmpty()) {
|
|
result.append(pushDiv("title bold"));
|
|
result.append(SerializeString(data.title));
|
|
result.append(popTag());
|
|
}
|
|
if (!data.description.isEmpty()) {
|
|
result.append(pushDiv("description"));
|
|
result.append(SerializeString(data.description));
|
|
result.append(popTag());
|
|
}
|
|
if (!data.status.isEmpty()) {
|
|
result.append(pushDiv("status details"));
|
|
result.append(SerializeString(data.status));
|
|
result.append(popTag());
|
|
}
|
|
result.append(popTag());
|
|
result.append(popTag());
|
|
result.append(popTag());
|
|
return result;
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::pushStickerMedia(
|
|
const Data::Document &data,
|
|
const QString &basePath) {
|
|
using namespace Data;
|
|
|
|
const auto &[thumb, size] = WriteImageThumb(
|
|
basePath,
|
|
data.file.relativePath,
|
|
CalculateThumbSize(
|
|
kStickerMaxWidth,
|
|
kStickerMaxHeight,
|
|
kStickerMinWidth,
|
|
kStickerMinHeight),
|
|
"PNG",
|
|
-1);
|
|
if (thumb.isEmpty()) {
|
|
auto generic = MediaData();
|
|
generic.title = "Sticker";
|
|
generic.status = data.stickerEmoji;
|
|
if (data.file.relativePath.isEmpty()) {
|
|
if (!generic.status.isEmpty()) {
|
|
generic.status += ", ";
|
|
}
|
|
generic.status += FormatFileSize(data.file.size);
|
|
} else {
|
|
generic.link = data.file.relativePath;
|
|
}
|
|
generic.description = NoFileDescription(data.file.skipReason);
|
|
generic.classes = "media_photo";
|
|
return pushGenericMedia(generic);
|
|
}
|
|
auto result = pushDiv("media_wrap clearfix");
|
|
result.append(pushTag("a", {
|
|
{ "class", "sticker_wrap clearfix pull_left" },
|
|
{
|
|
"href",
|
|
relativePath(data.file.relativePath).toUtf8()
|
|
}
|
|
}));
|
|
const auto sizeStyle = "width: "
|
|
+ NumberToString(size.width() / 2)
|
|
+ "px; height: "
|
|
+ NumberToString(size.height() / 2)
|
|
+ "px";
|
|
result.append(pushTag("img", {
|
|
{ "class", "sticker" },
|
|
{ "style", sizeStyle },
|
|
{ "src", relativePath(thumb).toUtf8() },
|
|
{ "empty", "" }
|
|
}));
|
|
result.append(popTag());
|
|
result.append(popTag());
|
|
return result;
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::pushAnimatedMedia(
|
|
const Data::Document &data,
|
|
const QString &basePath) {
|
|
using namespace Data;
|
|
|
|
auto size = QSize(data.width, data.height);
|
|
auto thumbSize = CalculateThumbSize(
|
|
kPhotoMaxWidth,
|
|
kPhotoMaxHeight,
|
|
kPhotoMinWidth,
|
|
kPhotoMinHeight,
|
|
true)(size);
|
|
if (data.thumb.file.relativePath.isEmpty()
|
|
|| data.file.relativePath.isEmpty()
|
|
|| !thumbSize.width()
|
|
|| !thumbSize.height()) {
|
|
auto generic = MediaData();
|
|
generic.title = "Animation";
|
|
generic.status = FormatFileSize(data.file.size);
|
|
generic.link = data.file.relativePath;
|
|
generic.description = NoFileDescription(data.file.skipReason);
|
|
generic.classes = "media_video";
|
|
return pushGenericMedia(generic);
|
|
}
|
|
auto result = pushDiv("media_wrap clearfix");
|
|
result.append(pushTag("a", {
|
|
{ "class", "animated_wrap clearfix pull_left" },
|
|
{
|
|
"href",
|
|
relativePath(data.file.relativePath).toUtf8()
|
|
}
|
|
}));
|
|
result.append(pushDiv("video_play_bg"));
|
|
result.append(pushDiv("gif_play"));
|
|
result.append("GIF");
|
|
result.append(popTag());
|
|
result.append(popTag());
|
|
const auto sizeStyle = "width: "
|
|
+ NumberToString(thumbSize.width() / 2)
|
|
+ "px; height: "
|
|
+ NumberToString(thumbSize.height() / 2)
|
|
+ "px";
|
|
result.append(pushTag("img", {
|
|
{ "class", "animated" },
|
|
{ "style", sizeStyle },
|
|
{ "src", relativePath(data.thumb.file.relativePath).toUtf8() },
|
|
{ "empty", "" }
|
|
}));
|
|
result.append(popTag());
|
|
result.append(popTag());
|
|
return result;
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::pushVideoFileMedia(
|
|
const Data::Document &data,
|
|
const QString &basePath) {
|
|
using namespace Data;
|
|
|
|
auto size = QSize(data.width, data.height);
|
|
auto thumbSize = CalculateThumbSize(
|
|
kPhotoMaxWidth,
|
|
kPhotoMaxHeight,
|
|
kPhotoMinWidth,
|
|
kPhotoMinHeight,
|
|
true)(size);
|
|
if (data.thumb.file.relativePath.isEmpty()
|
|
|| data.file.relativePath.isEmpty()
|
|
|| !thumbSize.width()
|
|
|| !thumbSize.height()) {
|
|
auto generic = MediaData();
|
|
generic.title = "Video file";
|
|
generic.status = FormatDuration(data.duration);
|
|
if (data.file.relativePath.isEmpty()) {
|
|
generic.status += ", " + FormatFileSize(data.file.size);
|
|
} else {
|
|
generic.link = data.file.relativePath;
|
|
}
|
|
generic.description = NoFileDescription(data.file.skipReason);
|
|
generic.classes = "media_video";
|
|
return pushGenericMedia(generic);
|
|
}
|
|
auto result = pushDiv("media_wrap clearfix");
|
|
result.append(pushTag("a", {
|
|
{ "class", "video_file_wrap clearfix pull_left" },
|
|
{
|
|
"href",
|
|
relativePath(data.file.relativePath).toUtf8()
|
|
}
|
|
}));
|
|
result.append(pushDiv("video_play_bg"));
|
|
result.append(pushDiv("video_play"));
|
|
result.append(popTag());
|
|
result.append(popTag());
|
|
result.append(pushDiv("video_duration"));
|
|
result.append(FormatDuration(data.duration));
|
|
result.append(popTag());
|
|
const auto sizeStyle = "width: "
|
|
+ NumberToString(thumbSize.width() / 2)
|
|
+ "px; height: "
|
|
+ NumberToString(thumbSize.height() / 2)
|
|
+ "px";
|
|
result.append(pushTag("img", {
|
|
{ "class", "video_file" },
|
|
{ "style", sizeStyle },
|
|
{ "src", relativePath(data.thumb.file.relativePath).toUtf8() },
|
|
{ "empty", "" }
|
|
}));
|
|
result.append(popTag());
|
|
result.append(popTag());
|
|
return result;
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::pushPhotoMedia(
|
|
const Data::Photo &data,
|
|
const QString &basePath) {
|
|
using namespace Data;
|
|
|
|
const auto &[thumb, size] = WriteImageThumb(
|
|
basePath,
|
|
data.image.file.relativePath,
|
|
CalculateThumbSize(
|
|
kPhotoMaxWidth,
|
|
kPhotoMaxHeight,
|
|
kPhotoMinWidth,
|
|
kPhotoMinHeight));
|
|
if (thumb.isEmpty()) {
|
|
auto generic = MediaData();
|
|
generic.title = "Photo";
|
|
generic.status = Ui::FormatImageSizeText(
|
|
QSize(data.image.width, data.image.height)).toUtf8();
|
|
if (data.image.file.relativePath.isEmpty()) {
|
|
generic.status += ", " + FormatFileSize(data.image.file.size);
|
|
} else {
|
|
generic.link = data.image.file.relativePath;
|
|
}
|
|
generic.description = NoFileDescription(data.image.file.skipReason);
|
|
generic.classes = "media_photo";
|
|
return pushGenericMedia(generic);
|
|
}
|
|
auto result = pushDiv("media_wrap clearfix");
|
|
result.append(pushTag("a", {
|
|
{ "class", "photo_wrap clearfix pull_left" },
|
|
{
|
|
"href",
|
|
relativePath(data.image.file.relativePath).toUtf8()
|
|
}
|
|
}));
|
|
const auto sizeStyle = "width: "
|
|
+ NumberToString(size.width() / 2)
|
|
+ "px; height: "
|
|
+ NumberToString(size.height() / 2)
|
|
+ "px";
|
|
result.append(pushTag("img", {
|
|
{ "class", "photo" },
|
|
{ "style", sizeStyle },
|
|
{ "src", relativePath(thumb).toUtf8() },
|
|
{ "empty", "" }
|
|
}));
|
|
result.append(popTag());
|
|
result.append(popTag());
|
|
return result;
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::pushRichPhotoMedia(
|
|
const Data::Photo *data,
|
|
const QString &basePath) {
|
|
const auto presentation = RichPhotoPresentation(data);
|
|
return pushPhotoMedia(presentation, basePath);
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::pushRichVideoMedia(
|
|
const Data::Document *data,
|
|
const QString &basePath) {
|
|
auto presentation = RichDocumentPresentation(data);
|
|
const auto &thumbPath = presentation.thumb.file.relativePath;
|
|
if (!thumbPath.isEmpty()) {
|
|
QImageReader reader(basePath + thumbPath);
|
|
if (!reader.canRead() || reader.read().isNull()) {
|
|
presentation.thumb.file.relativePath.clear();
|
|
}
|
|
}
|
|
return pushVideoFileMedia(presentation, basePath);
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::pushRichAudioMedia(
|
|
const Data::Document *data) {
|
|
const auto presentation = RichDocumentPresentation(data);
|
|
return pushGenericMedia(PrepareAudioMediaData(presentation));
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::pushRichReferenceMedia(
|
|
MediaData data,
|
|
const Data::Photo *photo,
|
|
const QString &basePath) {
|
|
const auto presentation = RichPhotoPresentation(photo);
|
|
const auto &path = presentation.image.file.relativePath;
|
|
data.link = path;
|
|
data.thumb = path.isEmpty()
|
|
? QString()
|
|
: Data::WriteImageThumb(
|
|
basePath,
|
|
path,
|
|
kEntryUserpicSize * 2,
|
|
kEntryUserpicSize * 2,
|
|
u"_rich_card_thumb"_q);
|
|
return pushGenericMedia(data);
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::pushPoll(
|
|
const Data::Poll &data,
|
|
const QString &internalLinksDomain,
|
|
const QString &relativeLinkBase) {
|
|
using namespace Data;
|
|
|
|
auto result = pushDiv("media_wrap clearfix");
|
|
result.append(pushDiv("media_poll"));
|
|
result.append(pushDiv("question bold"));
|
|
result.append(FormatText(
|
|
data.question,
|
|
internalLinksDomain,
|
|
relativeLinkBase));
|
|
result.append(popTag());
|
|
result.append(pushDiv("details"));
|
|
if (data.closed) {
|
|
result.append(SerializeString("Final results"));
|
|
} else {
|
|
result.append(SerializeString("Anonymous poll"));
|
|
}
|
|
result.append(popTag());
|
|
const auto votes = [](int count) {
|
|
if (count > 1) {
|
|
return NumberToString(count) + " votes";
|
|
} else if (count > 0) {
|
|
return NumberToString(count) + " vote";
|
|
}
|
|
return QByteArray("No votes");
|
|
};
|
|
const auto details = [&](const Poll::Answer &answer) {
|
|
if (!answer.votes) {
|
|
return QByteArray("");
|
|
} else if (!answer.my) {
|
|
return " <span class=\"details\">"
|
|
+ votes(answer.votes)
|
|
+ "</span>";
|
|
}
|
|
return " <span class=\"details\">"
|
|
+ votes(answer.votes)
|
|
+ ", chosen vote</span>";
|
|
};
|
|
for (const auto &answer : data.answers) {
|
|
result.append(pushDiv("answer"));
|
|
result.append("- "
|
|
+ FormatText(answer.text, internalLinksDomain, relativeLinkBase)
|
|
+ details(answer));
|
|
result.append(popTag());
|
|
}
|
|
result.append(pushDiv("total details "));
|
|
result.append(votes(data.totalVotes));
|
|
result.append(popTag());
|
|
result.append(popTag());
|
|
result.append(popTag());
|
|
return result;
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::pushTodoList(
|
|
const Data::TodoList &data,
|
|
const QString &internalLinksDomain,
|
|
const QString &relativeLinkBase) {
|
|
using namespace Data;
|
|
|
|
auto result = pushDiv("media_wrap clearfix");
|
|
result.append(pushDiv("media_poll"));
|
|
result.append(pushDiv("question bold"));
|
|
result.append(FormatText(
|
|
data.title,
|
|
internalLinksDomain,
|
|
relativeLinkBase));
|
|
result.append(popTag());
|
|
result.append(pushDiv("details"));
|
|
result.append(SerializeString("To-do List"));
|
|
result.append(popTag());
|
|
const auto details = [&](const TodoListItem &item) {
|
|
return QByteArray(""); // #TODO todo
|
|
};
|
|
for (const auto &item : data.items) {
|
|
result.append(pushDiv("answer"));
|
|
result.append("- "
|
|
+ FormatText(item.text, internalLinksDomain, relativeLinkBase)
|
|
+ details(item));
|
|
result.append(popTag());
|
|
}
|
|
result.append(popTag());
|
|
result.append(popTag());
|
|
return result;
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::pushGiveaway(
|
|
const PeersMap &peers,
|
|
const Data::GiveawayStart &data) {
|
|
auto result = pushDiv("media_wrap clearfix");
|
|
result.append(pushDiv("media_giveaway"));
|
|
|
|
result.append(pushDiv("section_title bold"));
|
|
result.append((data.quantity > 1)
|
|
? SerializeString("Giveaway Prizes")
|
|
: SerializeString("Giveaway Prize"));
|
|
result.append(popTag());
|
|
|
|
{
|
|
result.append(pushDiv("section_body"));
|
|
result.append("<b>"
|
|
+ Data::NumberToString(data.quantity)
|
|
+ "</b> "
|
|
+ SerializeString(data.additionalPrize.toUtf8()));
|
|
result.append(popTag());
|
|
result.append(pushDiv("section_title bold"));
|
|
result.append(SerializeString("with"));
|
|
result.append(popTag());
|
|
};
|
|
result.append(pushDiv("section_body"));
|
|
if (data.credits > 0) {
|
|
result.append("<b>"
|
|
+ Data::NumberToString(data.credits)
|
|
+ (SerializeString(data.credits == 1 ? (" Star") : (" Stars")))
|
|
+ "</b> " + SerializeString("will be distributed ")
|
|
+ ((data.quantity == 1)
|
|
? SerializeString("to ")
|
|
+ "<b>"
|
|
+ Data::NumberToString(data.quantity)
|
|
+ "</b> " + SerializeString("winner.")
|
|
: SerializeString("among ")
|
|
+ "<b>"
|
|
+ Data::NumberToString(data.quantity)
|
|
+ "</b> " + SerializeString("winners.")));
|
|
} else {
|
|
result.append("<b>"
|
|
+ Data::NumberToString(data.quantity)
|
|
+ "</b> "
|
|
+ SerializeString((data.quantity > 1)
|
|
? "Telegram Premium Subscriptions"
|
|
: "Telegram Premium Subscription")
|
|
+ " for <b>" + Data::NumberToString(data.months) + "</b> "
|
|
+ (data.months > 1 ? "months." : "month."));
|
|
}
|
|
result.append(popTag());
|
|
|
|
result.append(pushDiv("section_title bold"));
|
|
result.append(SerializeString("Participants"));
|
|
result.append(popTag());
|
|
result.append(pushDiv("section_body"));
|
|
auto channels = QByteArrayList();
|
|
auto anyChannel = false;
|
|
auto anyGroup = false;
|
|
for (const auto &channel : data.channels) {
|
|
if (const auto chat = peers.peer(channel).chat()) {
|
|
if (chat->isBroadcast) {
|
|
anyChannel = true;
|
|
} else if (chat->isSupergroup) {
|
|
anyGroup = true;
|
|
}
|
|
}
|
|
channels.append("<b>" + peers.wrapPeerName(channel) + "</b>");
|
|
}
|
|
|
|
const auto participants = [&] {
|
|
if (data.all && !anyGroup && anyChannel && channels.size() == 1) {
|
|
return "All subscribers of the channel:";
|
|
}
|
|
if (data.all && !anyGroup && anyChannel && channels.size() > 1) {
|
|
return "All subscribers of the channels:";
|
|
}
|
|
if (data.all && anyGroup && !anyChannel && channels.size() == 1) {
|
|
return "All members of the group:";
|
|
}
|
|
if (data.all && anyGroup && !anyChannel && channels.size() > 1) {
|
|
return "All members of the groups:";
|
|
}
|
|
if (data.all && anyGroup && anyChannel && channels.size() == 1) {
|
|
return "All members of the group:";
|
|
}
|
|
if (data.all && anyGroup && anyChannel && channels.size() > 1) {
|
|
return "All members of the groups and channels:";
|
|
}
|
|
if (!data.all && !anyGroup && anyChannel && channels.size() == 1) {
|
|
return "All users who joined the channel below after this date:";
|
|
}
|
|
if (!data.all && !anyGroup && anyChannel && channels.size() > 1) {
|
|
return "All users who joined the channels below after this date:";
|
|
}
|
|
if (!data.all && anyGroup && !anyChannel && channels.size() == 1) {
|
|
return "All users who joined the group below after this date:";
|
|
}
|
|
if (!data.all && anyGroup && !anyChannel && channels.size() > 1) {
|
|
return "All users who joined the groups below after this date:";
|
|
}
|
|
if (!data.all && anyGroup && anyChannel && channels.size() == 1) {
|
|
return "All users who joined the group below after this date:";
|
|
}
|
|
if (!data.all && anyGroup && anyChannel && channels.size() > 1) {
|
|
return "All users who joined the groups and channels below "
|
|
"after this date:";
|
|
}
|
|
return "";
|
|
}();
|
|
|
|
result.append(SerializeString(participants)) + channels.join(", ");
|
|
result.append(popTag());
|
|
|
|
{
|
|
const auto &instance = Countries::Instance();
|
|
auto countries = QStringList();
|
|
for (const auto &country : data.countries) {
|
|
const auto name = instance.countryNameByISO2(country);
|
|
const auto flag = instance.flagEmojiByISO2(country);
|
|
countries.push_back(flag + QChar(0xA0) + name);
|
|
}
|
|
|
|
if (const auto count = countries.size()) {
|
|
auto united = countries.front();
|
|
for (auto i = 1; i != count; ++i) {
|
|
united = ((i + 1 == count)
|
|
? u"%1 and %2"_q
|
|
: u"%1, %2"_q).arg(united, countries[i]);
|
|
}
|
|
result.append(pushDiv("section_body"));
|
|
result.append(
|
|
SerializeString((u"from %1"_q).arg(united).toUtf8()));
|
|
result.append(popTag());
|
|
}
|
|
}
|
|
result.append(pushDiv("section_title bold"));
|
|
result.append(SerializeString("Winners Selection Date"));
|
|
result.append(popTag());
|
|
result.append(pushDiv("section_body"));
|
|
result.append(Data::FormatDateTime(data.untilDate));
|
|
result.append(popTag());
|
|
|
|
result.append(popTag());
|
|
result.append(popTag());
|
|
return result;
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::pushGiveaway(
|
|
const PeersMap &peers,
|
|
const Data::GiveawayResults &data,
|
|
Fn<QByteArray(int messageId, QByteArray text)> wrapMessageLink) {
|
|
auto result = pushDiv("media_wrap clearfix");
|
|
result.append(pushDiv("media_giveaway"));
|
|
|
|
result.append(pushDiv("section_title bold"));
|
|
result.append((data.winnersCount > 1)
|
|
? SerializeString("Winners Selected!")
|
|
: SerializeString("Winner Selected!"));
|
|
result.append(popTag());
|
|
|
|
result.append(pushDiv("section_body"));
|
|
result.append(
|
|
"<b>" + Data::NumberToString(data.winnersCount) + "</b> "
|
|
+ SerializeString((data.winnersCount > 1) ? "winners" : "winner")
|
|
+ " of the "
|
|
+ wrapMessageLink(data.launchId, "Giveaway")
|
|
+ " was randomly selected by Telegram.");
|
|
result.append(popTag());
|
|
|
|
result.append(pushDiv("section_title bold"));
|
|
result.append((data.winnersCount > 1)
|
|
? SerializeString("Winners")
|
|
: SerializeString("Winner"));
|
|
result.append(popTag());
|
|
|
|
result.append(pushDiv("section_body"));
|
|
auto winners = QByteArrayList();
|
|
for (const auto &winner : data.winners) {
|
|
winners.append("<b>" + peers.wrapPeerName(winner) + "</b>");
|
|
}
|
|
const auto andMore = [&, size = data.winners.size()] {
|
|
if (data.winnersCount > size) {
|
|
return SerializeString(" and ")
|
|
+ Data::NumberToString(data.winnersCount - size)
|
|
+ SerializeString(" more!");
|
|
}
|
|
return QByteArray();
|
|
}();
|
|
result.append(winners.join(", ") + andMore);
|
|
result.append(popTag());
|
|
|
|
result.append(pushDiv("section_body"));
|
|
const auto prize = [&, singleStar = (data.credits == 1)] {
|
|
if (data.credits && data.winnersCount == 1) {
|
|
return SerializeString("The winner received ")
|
|
+ "<b>"
|
|
+ Data::NumberToString(data.credits)
|
|
+ "</b>"
|
|
+ SerializeString(singleStar ? " Star." : " Stars.");
|
|
} else if (data.credits && data.winnersCount > 1) {
|
|
return SerializeString("All winners received ")
|
|
+ "<b>"
|
|
+ Data::NumberToString(data.credits)
|
|
+ "</b>"
|
|
+ SerializeString(singleStar
|
|
? " Star in total."
|
|
: " Stars in total.");
|
|
} else if (data.unclaimedCount) {
|
|
return SerializeString("Some winners couldn't be selected.");
|
|
} else if (data.winnersCount == 1) {
|
|
return SerializeString(
|
|
"The winner received their gift link in a private message.");
|
|
} else if (data.winnersCount > 1) {
|
|
return SerializeString(
|
|
"All winners received gift links in private messages.");
|
|
}
|
|
return QByteArray();
|
|
}();
|
|
result.append(prize);
|
|
result.append(popTag());
|
|
|
|
result.append(popTag());
|
|
result.append(popTag());
|
|
return result;
|
|
}
|
|
|
|
MediaData HtmlWriter::Wrap::prepareMediaData(
|
|
const Data::Message &message,
|
|
const QString &basePath,
|
|
const PeersMap &peers,
|
|
const QString &internalLinksDomain) const {
|
|
using namespace Data;
|
|
|
|
auto result = MediaData();
|
|
const auto &action = message.action;
|
|
if (const auto call = std::get_if<ActionPhoneCall>(&action.content)) {
|
|
result.classes = "media_call";
|
|
result.title = peers.peer(message.out
|
|
? message.peerId
|
|
: message.selfId).name();
|
|
result.status = [&] {
|
|
using State = ActionPhoneCall::State;
|
|
const auto state = call->state;
|
|
if (state == State::Invitation) {
|
|
return "Invitation";
|
|
} else if (state == State::Active) {
|
|
return "Ongoing";
|
|
} else if (message.out) {
|
|
return (state == State::Missed) ? "Cancelled" : "Outgoing";
|
|
} else if (state == State::Missed) {
|
|
return "Missed";
|
|
} else if (state == State::Busy) {
|
|
return "Declined";
|
|
}
|
|
return "Incoming";
|
|
}();
|
|
if (call->duration > 0) {
|
|
result.classes += " success";
|
|
result.status += " ("
|
|
+ NumberToString(call->duration)
|
|
+ " seconds)";
|
|
}
|
|
return result;
|
|
}
|
|
|
|
v::match(message.media.content, [&](const Photo &data) {
|
|
if (message.media.ttl) {
|
|
result.title = "Self-destructing photo";
|
|
result.status = data.id
|
|
? "Please view it on your mobile"
|
|
: "Expired";
|
|
result.classes = "media_photo";
|
|
return;
|
|
}
|
|
// At least try to pushPhotoMedia.
|
|
}, [&](const Document &data) {
|
|
if (message.media.ttl) {
|
|
result.title = "Self-destructing video";
|
|
result.status = data.id
|
|
? "Please view it on your mobile"
|
|
: "Expired";
|
|
result.classes = "media_video";
|
|
return;
|
|
}
|
|
const auto hasFile = !data.file.relativePath.isEmpty();
|
|
result.link = data.file.relativePath;
|
|
result.description = NoFileDescription(data.file.skipReason);
|
|
if (data.isSticker) {
|
|
// At least try to pushStickerMedia.
|
|
} else if (data.isVideoMessage) {
|
|
result.title = "Video message";
|
|
result.status = FormatDuration(data.duration);
|
|
if (!hasFile) {
|
|
result.status += ", " + FormatFileSize(data.file.size);
|
|
}
|
|
result.thumb = data.thumb.file.relativePath;
|
|
result.classes = "media_video";
|
|
} else if (data.isVoiceMessage) {
|
|
result.title = "Voice message";
|
|
result.status = FormatDuration(data.duration);
|
|
if (!hasFile) {
|
|
result.status += ", " + FormatFileSize(data.file.size);
|
|
}
|
|
result.classes = "media_voice_message";
|
|
} else if (data.isAnimated) {
|
|
// At least try to pushAnimatedMedia.
|
|
} else if (data.isVideoFile) {
|
|
// At least try to pushVideoFileMedia.
|
|
} else if (data.isAudioFile) {
|
|
result = PrepareAudioMediaData(data);
|
|
} else {
|
|
result.title = data.name.isEmpty()
|
|
? QByteArray("File")
|
|
: data.name;
|
|
result.status = FormatFileSize(data.file.size);
|
|
result.classes = "media_file";
|
|
}
|
|
}, [&](const SharedContact &data) {
|
|
result.title = data.info.firstName + ' ' + data.info.lastName;
|
|
result.classes = "media_contact";
|
|
result.status = FormatPhoneNumber(data.info.phoneNumber);
|
|
if (!data.vcard.content.isEmpty()) {
|
|
result.status += " - vCard";
|
|
result.link = data.vcard.relativePath;
|
|
}
|
|
}, [&](const GeoPoint &data) {
|
|
if (message.media.ttl) {
|
|
result.classes = "media_live_location";
|
|
result.title = "Live location";
|
|
result.status = "";
|
|
} else {
|
|
result.classes = "media_location";
|
|
result.title = "Location";
|
|
}
|
|
if (data.valid) {
|
|
const auto latitude = NumberToString(data.latitude);
|
|
const auto longitude = NumberToString(data.longitude);
|
|
const auto coords = latitude + ',' + longitude;
|
|
result.status = latitude + ", " + longitude;
|
|
result.link = "https://maps.google.com/maps?q="
|
|
+ coords
|
|
+ "&ll="
|
|
+ coords
|
|
+ "&z=16";
|
|
}
|
|
}, [&](const Venue &data) {
|
|
result.classes = "media_venue";
|
|
result.title = data.title;
|
|
result.description = data.address;
|
|
if (data.point.valid) {
|
|
const auto latitude = NumberToString(data.point.latitude);
|
|
const auto longitude = NumberToString(data.point.longitude);
|
|
const auto coords = latitude + ',' + longitude;
|
|
result.link = "https://maps.google.com/maps?q="
|
|
+ coords
|
|
+ "&ll="
|
|
+ coords
|
|
+ "&z=16";
|
|
}
|
|
}, [&](const Game &data) {
|
|
result.classes = "media_game";
|
|
result.title = data.title;
|
|
result.description = data.description;
|
|
if (data.botId != 0 && !data.shortName.isEmpty()) {
|
|
const auto bot = peers.user(data.botId);
|
|
if (bot.isBot && !bot.username.isEmpty()) {
|
|
const auto link = internalLinksDomain.toUtf8()
|
|
+ bot.username
|
|
+ "?game="
|
|
+ data.shortName;
|
|
result.link = link;
|
|
result.status = link;
|
|
}
|
|
}
|
|
}, [&](const Invoice &data) {
|
|
result.classes = "media_invoice";
|
|
result.title = data.title;
|
|
result.description = data.description;
|
|
result.status = Data::FormatMoneyAmount(data.amount, data.currency);
|
|
}, [](const Poll &data) {
|
|
}, [](const TodoList &data) {
|
|
}, [](const GiveawayStart &data) {
|
|
}, [](const GiveawayResults &data) {
|
|
}, [&](const PaidMedia &data) {
|
|
result.classes = "media_invoice";
|
|
result.status = Data::FormatMoneyAmount(data.stars, "XTR");
|
|
}, [](const UnsupportedMedia &data) {
|
|
Unexpected("Unsupported message.");
|
|
}, [](v::null_t) {});
|
|
return result;
|
|
}
|
|
|
|
bool HtmlWriter::Wrap::forwardedNeedsWrap(
|
|
const Data::Message &message,
|
|
const MessageInfo *previous) const {
|
|
Expects(message.forwarded);
|
|
|
|
if (messageNeedsWrap(message, previous)) {
|
|
return true;
|
|
} else if (!message.forwardedFromId
|
|
|| message.forwardedFromId != previous->forwardedFromId) {
|
|
return true;
|
|
} else if (!peerIsUser(message.forwardedFromId)) {
|
|
return true;
|
|
} else if (abs(message.forwardedDate - previous->forwardedDate)
|
|
> kJoinWithinSeconds) {
|
|
return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
Result HtmlWriter::Wrap::close() {
|
|
if (!std::exchange(_closed, true) && !_file.empty()) {
|
|
auto block = QByteArray();
|
|
while (!_context.empty()) {
|
|
block.append(_context.popTag());
|
|
}
|
|
return _file.writeBlock(block);
|
|
}
|
|
return Result::Success();
|
|
}
|
|
|
|
QString HtmlWriter::Wrap::relativePath(const QString &path) const {
|
|
return _base + path;
|
|
}
|
|
|
|
QString HtmlWriter::Wrap::relativePath(const Data::File &file) const {
|
|
return relativePath(file.relativePath);
|
|
}
|
|
|
|
QByteArray HtmlWriter::Wrap::composeStart() {
|
|
auto result = "<!DOCTYPE html>" + _context.pushTag("html");
|
|
result.append(pushTag("head"));
|
|
result.append(pushTag("meta", {
|
|
{ "charset", "utf-8" },
|
|
{ "empty", "" }
|
|
}));
|
|
result.append(pushTag("title", { { "inline", "" } }));
|
|
result.append("Exported Data");
|
|
result.append(popTag());
|
|
result.append(_context.pushTag("meta", {
|
|
{ "name", "viewport" },
|
|
{ "content", "width=device-width, initial-scale=1.0" },
|
|
{ "empty", "" }
|
|
}));
|
|
result.append(_context.pushTag("link", {
|
|
{ "href", _base + "css/style.css" },
|
|
{ "rel", "stylesheet" },
|
|
{ "empty", "" }
|
|
}));
|
|
result.append(_context.pushTag("script", {
|
|
{ "src", _base + "js/script.js" },
|
|
{ "type", "text/javascript" },
|
|
}));
|
|
result.append(_context.popTag());
|
|
result.append(popTag());
|
|
result.append(pushTag("body", {
|
|
{ "onload", "CheckLocation();" }
|
|
}));
|
|
result.append(pushDiv("page_wrap"));
|
|
return result;
|
|
}
|
|
|
|
HtmlWriter::Wrap::~Wrap() {
|
|
(void)close();
|
|
}
|
|
|
|
HtmlWriter::HtmlWriter() = default;
|
|
|
|
Result HtmlWriter::start(
|
|
const Settings &settings,
|
|
const Environment &environment,
|
|
Stats *stats) {
|
|
Expects(settings.path.endsWith('/'));
|
|
|
|
_settings = base::duplicate(settings);
|
|
_environment = environment;
|
|
_stats = stats;
|
|
|
|
//const auto result = copyFile(
|
|
// ":/export/css/bootstrap.min.css",
|
|
// "css/bootstrap.min.css");
|
|
//if (!result) {
|
|
// return result;
|
|
//}
|
|
const auto copy = [&](const QString &filename) {
|
|
return copyFile(":/export/" + filename, filename);
|
|
};
|
|
const auto files = {
|
|
"css/style.css",
|
|
"images/back.png",
|
|
"images/media_call.png",
|
|
"images/media_contact.png",
|
|
"images/media_file.png",
|
|
"images/media_game.png",
|
|
"images/media_location.png",
|
|
"images/media_music.png",
|
|
"images/media_photo.png",
|
|
"images/media_shop.png",
|
|
"images/media_video.png",
|
|
"images/media_voice.png",
|
|
"images/section_calls.png",
|
|
"images/section_chats.png",
|
|
"images/section_contacts.png",
|
|
"images/section_frequent.png",
|
|
"images/section_music.png",
|
|
"images/section_other.png",
|
|
"images/section_photos.png",
|
|
"images/section_sessions.png",
|
|
"images/section_stories.png",
|
|
"images/section_web.png",
|
|
"js/script.js",
|
|
};
|
|
for (const auto path : files) {
|
|
const auto name = QString(path);
|
|
if (const auto result = copy(name); !result) {
|
|
return result;
|
|
} else if (const auto png = name.indexOf(".png"); png > 0) {
|
|
const auto x2 = name.mid(0, png) + "@2x.png";
|
|
if (const auto result = copy(x2); !result) {
|
|
return result;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (_settings.onlySinglePeer()) {
|
|
return Result::Success();
|
|
}
|
|
_summary = fileWithRelativePath(mainFileRelativePath());
|
|
auto block = _summary->pushHeader("Exported Data");
|
|
block.append(_summary->pushDiv("page_body"));
|
|
return _summary->writeBlock(block);
|
|
}
|
|
|
|
Result HtmlWriter::writePersonal(const Data::PersonalInfo &data) {
|
|
Expects(_summary != nullptr);
|
|
|
|
_selfColorIndex = data.user.info.colorIndex;
|
|
if (_settings.types & Settings::Type::Userpics) {
|
|
_delayedPersonalInfo = std::make_unique<Data::PersonalInfo>(data);
|
|
return Result::Success();
|
|
}
|
|
return writeDefaultPersonal(data);
|
|
}
|
|
|
|
Result HtmlWriter::writeDefaultPersonal(const Data::PersonalInfo &data) {
|
|
return writePreparedPersonal(data, QString());
|
|
}
|
|
|
|
Result HtmlWriter::writeDelayedPersonal(const QString &userpicPath) {
|
|
if (!_delayedPersonalInfo) {
|
|
return Result::Success();
|
|
}
|
|
const auto result = writePreparedPersonal(
|
|
*base::take(_delayedPersonalInfo),
|
|
userpicPath);
|
|
if (!result) {
|
|
return result;
|
|
}
|
|
if (_userpicsCount) {
|
|
pushUserpicsSection();
|
|
}
|
|
return Result::Success();
|
|
}
|
|
|
|
Result HtmlWriter::writePreparedPersonal(
|
|
const Data::PersonalInfo &data,
|
|
const QString &userpicPath) {
|
|
Expects(_summary != nullptr);
|
|
|
|
const auto &info = data.user.info;
|
|
|
|
auto userpic = UserpicData{ _selfColorIndex, kPersonalUserpicSize };
|
|
userpic.largeLink = userpicPath.isEmpty()
|
|
? QString()
|
|
: userpicsFilePath();
|
|
userpic.imageLink = WriteUserpicThumb(
|
|
_settings.path,
|
|
userpicPath,
|
|
userpic,
|
|
"_info");
|
|
userpic.firstName = info.firstName;
|
|
userpic.lastName = info.lastName;
|
|
|
|
auto block = _summary->pushDiv("personal_info clearfix");
|
|
block.append(_summary->pushDiv("pull_right userpic_wrap"));
|
|
block.append(_summary->pushUserpic(userpic));
|
|
block.append(_summary->popTag());
|
|
const auto pushRows = [&](
|
|
QByteArray name,
|
|
std::vector<std::pair<QByteArray, QByteArray>> &&values) {
|
|
block.append(_summary->pushDiv("rows " + name));
|
|
for (const auto &[key, value] : values) {
|
|
if (value.isEmpty()) {
|
|
continue;
|
|
}
|
|
block.append(_summary->pushDiv("row"));
|
|
block.append(_summary->pushDiv("label details"));
|
|
block.append(SerializeString(key));
|
|
block.append(_summary->popTag());
|
|
block.append(_summary->pushDiv("value bold"));
|
|
block.append(SerializeString(value));
|
|
block.append(_summary->popTag());
|
|
block.append(_summary->popTag());
|
|
}
|
|
block.append(_summary->popTag());
|
|
};
|
|
pushRows("names", {
|
|
{ "First name", info.firstName },
|
|
{ "Last name", info.lastName },
|
|
});
|
|
pushRows("info", {
|
|
{ "Phone number", Data::FormatPhoneNumber(info.phoneNumber) },
|
|
{ "Username", FormatUsername(data.user.username) },
|
|
});
|
|
pushRows("bio", { { "Bio", data.bio } });
|
|
block.append(_summary->popTag());
|
|
|
|
_summaryNeedDivider = true;
|
|
return _summary->writeBlock(block);
|
|
}
|
|
|
|
Result HtmlWriter::writeUserpicsStart(const Data::UserpicsInfo &data) {
|
|
Expects(_summary != nullptr);
|
|
Expects(_userpics == nullptr);
|
|
|
|
_userpicsCount = data.count;
|
|
if (!_userpicsCount) {
|
|
return Result::Success();
|
|
}
|
|
_userpics = fileWithRelativePath(userpicsFilePath());
|
|
|
|
auto block = _userpics->pushHeader(
|
|
"Profile pictures",
|
|
mainFileRelativePath());
|
|
block.append(_userpics->pushDiv("page_body list_page"));
|
|
block.append(_userpics->pushDiv("entry_list"));
|
|
if (const auto result = _userpics->writeBlock(block); !result) {
|
|
return result;
|
|
}
|
|
if (!_delayedPersonalInfo) {
|
|
pushUserpicsSection();
|
|
}
|
|
return Result::Success();
|
|
}
|
|
|
|
Result HtmlWriter::writeUserpicsSlice(const Data::UserpicsSlice &data) {
|
|
Expects(_userpics != nullptr);
|
|
Expects(!data.list.empty());
|
|
|
|
const auto firstPath = data.list.front().image.file.relativePath;
|
|
if (const auto result = writeDelayedPersonal(firstPath); !result) {
|
|
return result;
|
|
}
|
|
|
|
auto block = QByteArray();
|
|
for (const auto &userpic : data.list) {
|
|
auto data = UserpicData{ _selfColorIndex, kEntryUserpicSize };
|
|
using SkipReason = Data::File::SkipReason;
|
|
const auto &file = userpic.image.file;
|
|
Assert(!file.relativePath.isEmpty()
|
|
|| file.skipReason != SkipReason::None);
|
|
const auto status = [&]() -> Data::Utf8String {
|
|
switch (file.skipReason) {
|
|
case SkipReason::Unavailable:
|
|
return "(Photo unavailable, please try again later)";
|
|
case SkipReason::FileSize:
|
|
return "(Photo exceeds maximum size. "
|
|
"Change data exporting settings to download.)";
|
|
case SkipReason::FileType:
|
|
return "(Photo not included. "
|
|
"Change data exporting settings to download.)";
|
|
case SkipReason::None: return Data::FormatFileSize(file.size);
|
|
}
|
|
Unexpected("Skip reason while writing photo path.");
|
|
}();
|
|
const auto &path = userpic.image.file.relativePath;
|
|
data.imageLink = WriteUserpicThumb(_settings.path, path, data);
|
|
data.firstName = path.toUtf8();
|
|
block.append(_userpics->pushListEntry(
|
|
data,
|
|
(path.isEmpty() ? QString("Photo unavailable") : path).toUtf8(),
|
|
status,
|
|
(userpic.date > 0
|
|
? Data::FormatDateTime(userpic.date)
|
|
: QByteArray()),
|
|
path));
|
|
}
|
|
return _userpics->writeBlock(block);
|
|
}
|
|
|
|
Result HtmlWriter::writeUserpicsEnd() {
|
|
if (const auto result = writeDelayedPersonal(QString()); !result) {
|
|
return result;
|
|
} else if (_userpics) {
|
|
return base::take(_userpics)->close();
|
|
}
|
|
return Result::Success();
|
|
}
|
|
|
|
QString HtmlWriter::userpicsFilePath() const {
|
|
return "lists/profile_pictures.html";
|
|
}
|
|
|
|
void HtmlWriter::pushUserpicsSection() {
|
|
pushSection(
|
|
kUserpicsPriority,
|
|
"Profile pictures",
|
|
"photos",
|
|
_userpicsCount,
|
|
userpicsFilePath());
|
|
}
|
|
|
|
Result HtmlWriter::writeStoriesStart(const Data::StoriesInfo &data) {
|
|
Expects(_summary != nullptr);
|
|
Expects(_stories == nullptr);
|
|
|
|
_storiesCount = data.count;
|
|
if (!_storiesCount) {
|
|
return Result::Success();
|
|
}
|
|
_stories = fileWithRelativePath(storiesFilePath());
|
|
|
|
auto block = _stories->pushHeader(
|
|
"Stories archive",
|
|
mainFileRelativePath());
|
|
block.append(_stories->pushDiv("page_body list_page"));
|
|
block.append(_stories->pushDiv("entry_list"));
|
|
if (const auto result = _stories->writeBlock(block); !result) {
|
|
return result;
|
|
}
|
|
return Result::Success();
|
|
}
|
|
|
|
Result HtmlWriter::writeStoriesSlice(const Data::StoriesSlice &data) {
|
|
Expects(_stories != nullptr);
|
|
|
|
_storiesCount -= data.skipped;
|
|
if (data.list.empty()) {
|
|
return Result::Success();
|
|
}
|
|
auto block = QByteArray();
|
|
for (const auto &story : data.list) {
|
|
auto data = StoryData{};
|
|
using SkipReason = Data::File::SkipReason;
|
|
const auto &file = story.file();
|
|
Assert(!file.relativePath.isEmpty()
|
|
|| file.skipReason != SkipReason::None);
|
|
auto status = QByteArrayList();
|
|
if (story.pinned) {
|
|
status.append("Saved to Profile");
|
|
}
|
|
if (story.expires > 0) {
|
|
status.append("Expiring: " + Data::FormatDateTime(story.expires));
|
|
}
|
|
status.append([&]() -> Data::Utf8String {
|
|
switch (file.skipReason) {
|
|
case SkipReason::Unavailable:
|
|
return "(Story unavailable, please try again later)";
|
|
case SkipReason::FileSize:
|
|
return "(Story exceeds maximum size. "
|
|
"Change data exporting settings to download.)";
|
|
case SkipReason::FileType:
|
|
return "(Story not included. "
|
|
"Change data exporting settings to download.)";
|
|
case SkipReason::None: return Data::FormatFileSize(file.size);
|
|
}
|
|
Unexpected("Skip reason while writing story path.");
|
|
}());
|
|
const auto &path = story.file().relativePath;
|
|
const auto &image = story.thumb().file.relativePath.isEmpty()
|
|
? story.file().relativePath
|
|
: story.thumb().file.relativePath;
|
|
data.imageLink = Data::WriteImageThumb(
|
|
_settings.path,
|
|
image,
|
|
kStoryThumbWidth * 2,
|
|
kStoryThumbHeight * 2);
|
|
const auto info = (story.date > 0)
|
|
? Data::FormatDateTime(story.date)
|
|
: QByteArray();
|
|
block.append(_stories->pushStoriesListEntry(
|
|
data,
|
|
(path.isEmpty() ? QString("Story unavailable") : path).toUtf8(),
|
|
status,
|
|
info,
|
|
story.caption,
|
|
_environment.internalLinksDomain,
|
|
path));
|
|
}
|
|
return _stories->writeBlock(block);
|
|
}
|
|
|
|
Result HtmlWriter::writeStoriesEnd() {
|
|
pushStoriesSection();
|
|
if (_stories) {
|
|
return base::take(_stories)->close();
|
|
}
|
|
return Result::Success();
|
|
}
|
|
|
|
Result HtmlWriter::writeProfileMusicStart(const Data::ProfileMusicInfo &data) {
|
|
Expects(_summary != nullptr);
|
|
Expects(_profileMusic == nullptr);
|
|
|
|
_profileMusicCount = data.count;
|
|
if (!_profileMusicCount) {
|
|
return Result::Success();
|
|
}
|
|
_profileMusic = fileWithRelativePath(profileMusicFilePath());
|
|
|
|
auto block = _profileMusic->pushHeader(
|
|
"Profile Music",
|
|
mainFileRelativePath());
|
|
block.append(_profileMusic->pushDiv("page_body list_page"));
|
|
block.append(_profileMusic->pushDiv("entry_list"));
|
|
if (const auto result = _profileMusic->writeBlock(block); !result) {
|
|
return result;
|
|
}
|
|
return Result::Success();
|
|
}
|
|
|
|
Result HtmlWriter::writeProfileMusicSlice(const Data::ProfileMusicSlice &data) {
|
|
Expects(_profileMusic != nullptr);
|
|
|
|
_profileMusicCount -= data.skipped;
|
|
if (data.list.empty()) {
|
|
return Result::Success();
|
|
}
|
|
auto block = QByteArray();
|
|
for (const auto &message : data.list) {
|
|
if (!v::is<Data::Document>(message.media.content)) {
|
|
continue;
|
|
}
|
|
const auto &doc = v::get<Data::Document>(message.media.content);
|
|
if (!doc.isAudioFile) {
|
|
continue;
|
|
}
|
|
using SkipReason = Data::File::SkipReason;
|
|
const auto &file = doc.file;
|
|
Assert(!file.relativePath.isEmpty()
|
|
|| file.skipReason != SkipReason::None);
|
|
auto status = QByteArrayList();
|
|
status.append([&]() -> Data::Utf8String {
|
|
switch (file.skipReason) {
|
|
case SkipReason::Unavailable:
|
|
return "(File unavailable, please try again later)";
|
|
case SkipReason::FileSize:
|
|
return "(File exceeds maximum size. "
|
|
"Change data exporting settings to download.)";
|
|
case SkipReason::FileType:
|
|
return "(File not included. "
|
|
"Change data exporting settings to download.)";
|
|
case SkipReason::None: return Data::FormatFileSize(file.size);
|
|
}
|
|
Unexpected("Skip reason while writing profile music path.");
|
|
}());
|
|
const auto &path = file.relativePath;
|
|
const auto title = !doc.songTitle.isEmpty()
|
|
? doc.songTitle
|
|
: !doc.name.isEmpty()
|
|
? doc.name
|
|
: Data::Utf8String("Unknown Track");
|
|
const auto performer = !doc.songPerformer.isEmpty()
|
|
? doc.songPerformer
|
|
: Data::Utf8String("Unknown Artist");
|
|
const auto info = performer + " - " + title;
|
|
const auto duration = doc.duration > 0
|
|
? Data::FormatDuration(doc.duration)
|
|
: QByteArray();
|
|
block.append(_profileMusic->pushAudioEntry(
|
|
(path.isEmpty() ? QString("File unavailable") : path).toUtf8(),
|
|
info,
|
|
status,
|
|
duration,
|
|
path));
|
|
}
|
|
return _profileMusic->writeBlock(block);
|
|
}
|
|
|
|
Result HtmlWriter::writeProfileMusicEnd() {
|
|
pushProfileMusicSection();
|
|
if (_profileMusic) {
|
|
return base::take(_profileMusic)->close();
|
|
}
|
|
return Result::Success();
|
|
}
|
|
|
|
QString HtmlWriter::storiesFilePath() const {
|
|
return "lists/stories.html";
|
|
}
|
|
|
|
void HtmlWriter::pushStoriesSection() {
|
|
pushSection(
|
|
kStoriesPriority,
|
|
"Stories archive",
|
|
"stories",
|
|
_storiesCount,
|
|
storiesFilePath());
|
|
}
|
|
|
|
QString HtmlWriter::profileMusicFilePath() const {
|
|
return "lists/profile_music.html";
|
|
}
|
|
|
|
void HtmlWriter::pushProfileMusicSection() {
|
|
pushSection(
|
|
kProfileMusicPriority,
|
|
"Profile Music",
|
|
"music",
|
|
_profileMusicCount,
|
|
profileMusicFilePath());
|
|
}
|
|
|
|
Result HtmlWriter::writeContactsList(const Data::ContactsList &data) {
|
|
Expects(_summary != nullptr);
|
|
|
|
if (const auto result = writeSavedContacts(data); !result) {
|
|
return result;
|
|
} else if (const auto result = writeFrequentContacts(data); !result) {
|
|
return result;
|
|
}
|
|
return Result::Success();
|
|
}
|
|
|
|
Result HtmlWriter::writeSavedContacts(const Data::ContactsList &data) {
|
|
if (data.list.empty()) {
|
|
return Result::Success();
|
|
}
|
|
|
|
const auto filename = "lists/contacts.html";
|
|
const auto file = fileWithRelativePath(filename);
|
|
auto block = file->pushHeader(
|
|
"Contacts",
|
|
mainFileRelativePath());
|
|
block.append(file->pushDiv("page_body list_page"));
|
|
block.append(file->pushAbout(_environment.aboutContacts));
|
|
block.append(file->pushDiv("entry_list"));
|
|
for (const auto index : Data::SortedContactsIndices(data)) {
|
|
const auto &contact = data.list[index];
|
|
auto userpic = UserpicData{
|
|
Data::ContactColorIndex(contact),
|
|
kEntryUserpicSize
|
|
};
|
|
userpic.firstName = contact.firstName;
|
|
userpic.lastName = contact.lastName;
|
|
if (contact.userId) {
|
|
const auto raw = contact.userId.bare & PeerId::kChatTypeMask;
|
|
userpic.tooltip = (u"ID: "_q + QString::number(raw)).toUtf8();
|
|
}
|
|
block.append(file->pushListEntry(
|
|
userpic,
|
|
ComposeName(userpic, "Deleted Account"),
|
|
Data::FormatPhoneNumber(contact.phoneNumber),
|
|
Data::FormatDateTime(contact.date)));
|
|
}
|
|
if (const auto result = file->writeBlock(block); !result) {
|
|
return result;
|
|
} else if (const auto closed = file->close(); !closed) {
|
|
return closed;
|
|
}
|
|
|
|
pushSection(
|
|
kContactsPriority,
|
|
"Contacts",
|
|
"contacts",
|
|
data.list.size(),
|
|
filename);
|
|
return Result::Success();
|
|
}
|
|
|
|
Result HtmlWriter::writeFrequentContacts(const Data::ContactsList &data) {
|
|
const auto size = data.correspondents.size()
|
|
+ data.inlineBots.size()
|
|
+ data.phoneCalls.size();
|
|
if (!size) {
|
|
return Result::Success();
|
|
}
|
|
|
|
const auto filename = "lists/frequent.html";
|
|
const auto file = fileWithRelativePath(filename);
|
|
auto block = file->pushHeader(
|
|
"Frequent contacts",
|
|
mainFileRelativePath());
|
|
block.append(file->pushDiv("page_body list_page"));
|
|
block.append(file->pushAbout(_environment.aboutFrequent));
|
|
block.append(file->pushDiv("entry_list"));
|
|
const auto writeList = [&](
|
|
const std::vector<Data::TopPeer> &peers,
|
|
Data::Utf8String category) {
|
|
for (const auto &top : peers) {
|
|
const auto name = [&]() -> Data::Utf8String {
|
|
if (top.peer.chat()) {
|
|
return top.peer.name();
|
|
} else if (top.peer.user()->isSelf) {
|
|
return "Saved messages";
|
|
} else {
|
|
return top.peer.user()->info.firstName;
|
|
}
|
|
}();
|
|
const auto lastName = [&]() -> Data::Utf8String {
|
|
if (top.peer.user() && !top.peer.user()->isSelf) {
|
|
return top.peer.user()->info.lastName;
|
|
}
|
|
return {};
|
|
}();
|
|
auto userpic = UserpicData{
|
|
Data::PeerColorIndex(top.peer.id()),
|
|
kEntryUserpicSize
|
|
};
|
|
userpic.firstName = name;
|
|
userpic.lastName = lastName;
|
|
block.append(file->pushListEntry(
|
|
userpic,
|
|
ComposeName(userpic, "Deleted Account"),
|
|
"Rating: " + Data::NumberToString(top.rating),
|
|
category));
|
|
}
|
|
};
|
|
writeList(data.correspondents, "people");
|
|
writeList(data.inlineBots, "inline bots");
|
|
writeList(data.phoneCalls, "calls");
|
|
if (const auto result = file->writeBlock(block); !result) {
|
|
return result;
|
|
} else if (const auto closed = file->close(); !closed) {
|
|
return closed;
|
|
}
|
|
|
|
pushSection(
|
|
kFrequentContactsPriority,
|
|
"Frequent contacts",
|
|
"frequent",
|
|
size,
|
|
filename);
|
|
return Result::Success();
|
|
}
|
|
|
|
Result HtmlWriter::writeSessionsList(const Data::SessionsList &data) {
|
|
Expects(_summary != nullptr);
|
|
|
|
if (const auto result = writeSessions(data); !result) {
|
|
return result;
|
|
} else if (const auto result = writeWebSessions(data); !result) {
|
|
return result;
|
|
}
|
|
return Result::Success();
|
|
}
|
|
|
|
Result HtmlWriter::writeSessions(const Data::SessionsList &data) {
|
|
Expects(_summary != nullptr);
|
|
|
|
if (data.list.empty()) {
|
|
return Result::Success();
|
|
}
|
|
|
|
const auto filename = "lists/sessions.html";
|
|
const auto file = fileWithRelativePath(filename);
|
|
auto block = file->pushHeader(
|
|
"Sessions",
|
|
mainFileRelativePath());
|
|
block.append(file->pushDiv("page_body list_page"));
|
|
block.append(file->pushAbout(_environment.aboutSessions));
|
|
block.append(file->pushDiv("entry_list"));
|
|
for (const auto &session : data.list) {
|
|
block.append(file->pushSessionListEntry(
|
|
session.applicationId,
|
|
((session.applicationName.isEmpty()
|
|
? Data::Utf8String("Unknown")
|
|
: session.applicationName)
|
|
+ ' '
|
|
+ session.applicationVersion),
|
|
(session.deviceModel
|
|
+ ", "
|
|
+ session.platform
|
|
+ ' '
|
|
+ session.systemVersion),
|
|
{
|
|
(session.ip
|
|
+ " \xE2\x80\x93 "
|
|
+ session.region
|
|
+ ((session.region.isEmpty() || session.country.isEmpty())
|
|
? QByteArray()
|
|
: QByteArray(", "))
|
|
+ session.country),
|
|
"Last active: " + Data::FormatDateTime(session.lastActive),
|
|
"Created: " + Data::FormatDateTime(session.created)
|
|
}));
|
|
}
|
|
if (const auto result = file->writeBlock(block); !result) {
|
|
return result;
|
|
} else if (const auto closed = file->close(); !closed) {
|
|
return closed;
|
|
}
|
|
|
|
pushSection(
|
|
kSessionsPriority,
|
|
"Sessions",
|
|
"sessions",
|
|
data.list.size(),
|
|
filename);
|
|
return Result::Success();
|
|
}
|
|
|
|
Result HtmlWriter::writeWebSessions(const Data::SessionsList &data) {
|
|
Expects(_summary != nullptr);
|
|
|
|
if (data.webList.empty()) {
|
|
return Result::Success();
|
|
}
|
|
|
|
const auto filename = "lists/web_sessions.html";
|
|
const auto file = fileWithRelativePath(filename);
|
|
auto block = file->pushHeader(
|
|
"Web sessions",
|
|
mainFileRelativePath());
|
|
block.append(file->pushDiv("page_body list_page"));
|
|
block.append(file->pushAbout(_environment.aboutWebSessions));
|
|
block.append(file->pushDiv("entry_list"));
|
|
for (const auto &session : data.webList) {
|
|
block.append(file->pushSessionListEntry(
|
|
Data::DomainApplicationId(session.domain),
|
|
(session.domain.isEmpty()
|
|
? Data::Utf8String("Unknown")
|
|
: session.domain),
|
|
session.platform + ", " + session.browser,
|
|
{
|
|
session.ip + " \xE2\x80\x93 " + session.region,
|
|
"Last active: " + Data::FormatDateTime(session.lastActive),
|
|
"Created: " + Data::FormatDateTime(session.created)
|
|
},
|
|
(session.botUsername.isEmpty()
|
|
? QByteArray()
|
|
: ('@' + session.botUsername))));
|
|
}
|
|
if (const auto result = file->writeBlock(block); !result) {
|
|
return result;
|
|
} else if (const auto closed = file->close(); !closed) {
|
|
return closed;
|
|
}
|
|
|
|
pushSection(
|
|
kWebSessionsPriority,
|
|
"Web sessions",
|
|
"web",
|
|
data.webList.size(),
|
|
filename);
|
|
return Result::Success();
|
|
}
|
|
|
|
Result HtmlWriter::writeOtherData(const Data::File &data) {
|
|
Expects(_summary != nullptr);
|
|
|
|
pushSection(
|
|
kOtherPriority,
|
|
"Other data",
|
|
"other",
|
|
1,
|
|
data.relativePath);
|
|
return Result::Success();
|
|
}
|
|
|
|
Result HtmlWriter::writeDialogsStart(const Data::DialogsInfo &data) {
|
|
Expects(_chats == nullptr);
|
|
|
|
if (data.chats.empty() && data.left.empty()) {
|
|
return Result::Success();
|
|
} else if (_settings.onlySinglePeer()) {
|
|
return Result::Success();
|
|
}
|
|
|
|
_dialogsRelativePath = "lists/chats.html";
|
|
_chats = fileWithRelativePath(_dialogsRelativePath);
|
|
|
|
auto block = _chats->pushHeader(
|
|
"Chats",
|
|
mainFileRelativePath());
|
|
block.append(_chats->pushDiv("page_body list_page"));
|
|
if (const auto result = _chats->writeBlock(block); !result) {
|
|
return result;
|
|
}
|
|
|
|
pushSection(
|
|
kChatsPriority,
|
|
"Chats",
|
|
"chats",
|
|
data.chats.size() + data.left.size(),
|
|
"lists/chats.html");
|
|
return writeSections();
|
|
}
|
|
|
|
Result HtmlWriter::writeDialogStart(const Data::DialogInfo &data) {
|
|
Expects(_chat == nullptr);
|
|
|
|
_chat = fileWithRelativePath(data.relativePath + messagesFile(0));
|
|
_chatFileEmpty = true;
|
|
_messagesCount = 0;
|
|
_dateMessageId = 0;
|
|
_lastMessageInfo = nullptr;
|
|
_lastMessageIdsPerFile.clear();
|
|
_dialog = data;
|
|
return Result::Success();
|
|
}
|
|
|
|
Result HtmlWriter::writeDialogSlice(const Data::MessagesSlice &data) {
|
|
Expects(_chat != nullptr);
|
|
Expects(!data.list.empty());
|
|
|
|
const auto messageLinkWrapper = [&](int messageId, QByteArray text) {
|
|
return wrapMessageLink(messageId, text);
|
|
};
|
|
auto oldIndex = (_messagesCount > 0)
|
|
? ((_messagesCount - 1) / kMessagesInFile)
|
|
: 0;
|
|
auto previous = _lastMessageInfo.get();
|
|
auto saved = std::optional<MessageInfo>();
|
|
auto block = QByteArray();
|
|
for (const auto &message : data.list) {
|
|
if (Data::SkipMessageByDate(message, _settings)) {
|
|
continue;
|
|
}
|
|
const auto newIndex = (_messagesCount / kMessagesInFile);
|
|
if (oldIndex != newIndex) {
|
|
if (const auto result = _chat->writeBlock(block); !result) {
|
|
return result;
|
|
} else if (const auto next = switchToNextChatFile(newIndex)) {
|
|
Assert(saved.has_value() || _lastMessageInfo != nullptr);
|
|
_lastMessageIdsPerFile.push_back(saved
|
|
? saved->id
|
|
: _lastMessageInfo->id);
|
|
block = QByteArray();
|
|
_lastMessageInfo = nullptr;
|
|
previous = nullptr;
|
|
saved = std::nullopt;
|
|
oldIndex = newIndex;
|
|
} else {
|
|
return next;
|
|
}
|
|
}
|
|
if (_chatFileEmpty) {
|
|
if (const auto result = writeDialogOpening(oldIndex); !result) {
|
|
return result;
|
|
}
|
|
_chatFileEmpty = false;
|
|
}
|
|
const auto date = message.date;
|
|
if (DisplayDate(date, previous ? previous->date : 0)) {
|
|
block.append(_chat->pushServiceMessage(
|
|
--_dateMessageId,
|
|
_dialog,
|
|
_settings.path,
|
|
FormatDateText(date)));
|
|
}
|
|
const auto &[info, content] = _chat->pushMessage(
|
|
message,
|
|
previous,
|
|
_dialog,
|
|
_settings.path,
|
|
data.peers,
|
|
_environment.internalLinksDomain,
|
|
messageLinkWrapper);
|
|
block.append(content);
|
|
|
|
++_messagesCount;
|
|
saved = info;
|
|
previous = &*saved;
|
|
}
|
|
if (saved) {
|
|
_lastMessageInfo = std::make_unique<MessageInfo>(*saved);
|
|
}
|
|
return block.isEmpty() ? Result::Success() : _chat->writeBlock(block);
|
|
}
|
|
|
|
Result HtmlWriter::writeEmptySinglePeer() {
|
|
Expects(_chat != nullptr);
|
|
|
|
if (!_settings.onlySinglePeer() || _messagesCount != 0) {
|
|
return Result::Success();
|
|
}
|
|
Assert(_chatFileEmpty);
|
|
if (const auto result = writeDialogOpening(0); !result) {
|
|
return result;
|
|
}
|
|
return _chat->writeBlock(_chat->pushServiceMessage(
|
|
--_dateMessageId,
|
|
_dialog,
|
|
_settings.path,
|
|
"No exported messages"));
|
|
}
|
|
|
|
Result HtmlWriter::writeDialogEnd() {
|
|
Expects(_settings.onlySinglePeer() || _chats != nullptr);
|
|
Expects(_chat != nullptr);
|
|
|
|
if (const auto result = writeEmptySinglePeer(); !result) {
|
|
return result;
|
|
}
|
|
|
|
if (const auto closed = base::take(_chat)->close(); !closed) {
|
|
return closed;
|
|
} else if (_settings.onlySinglePeer()) {
|
|
return Result::Success();
|
|
}
|
|
|
|
using Type = Data::DialogInfo::Type;
|
|
const auto TypeString = [](Type type) {
|
|
switch (type) {
|
|
case Type::Unknown: return "unknown";
|
|
case Type::Self:
|
|
case Type::Replies:
|
|
case Type::VerifyCodes:
|
|
case Type::Personal: return "private";
|
|
case Type::Bot: return "bot";
|
|
case Type::PrivateGroup:
|
|
case Type::PrivateSupergroup:
|
|
case Type::PublicSupergroup: return "group";
|
|
case Type::PrivateChannel:
|
|
case Type::PublicChannel: return "channel";
|
|
}
|
|
Unexpected("Dialog type in TypeString.");
|
|
};
|
|
const auto DeletedString = [](Type type) {
|
|
switch (type) {
|
|
case Type::Unknown:
|
|
case Type::Self:
|
|
case Type::Replies:
|
|
case Type::VerifyCodes:
|
|
case Type::Personal:
|
|
case Type::Bot: return "Deleted Account";
|
|
case Type::PrivateGroup:
|
|
case Type::PrivateSupergroup:
|
|
case Type::PublicSupergroup: return "Deleted Group";
|
|
case Type::PrivateChannel:
|
|
case Type::PublicChannel: return "Deleted Channel";
|
|
}
|
|
Unexpected("Dialog type in TypeString.");
|
|
};
|
|
const auto NameString = [](
|
|
const Data::DialogInfo &dialog) -> QByteArray {
|
|
if (dialog.type == Type::Self) {
|
|
return "Saved messages";
|
|
} else if (dialog.type == Type::Replies) {
|
|
return "Replies";
|
|
} else if (dialog.type == Type::VerifyCodes) {
|
|
return "Verification Codes";
|
|
}
|
|
return dialog.name;
|
|
};
|
|
const auto LastNameString = [](
|
|
const Data::DialogInfo &dialog) -> QByteArray {
|
|
if (dialog.type != Type::Personal && dialog.type != Type::Bot) {
|
|
return {};
|
|
}
|
|
return dialog.lastName;
|
|
};
|
|
const auto CountString = [](int count, bool outgoing) -> QByteArray {
|
|
if (count == 1) {
|
|
return outgoing ? "1 outgoing message" : "1 message";
|
|
} else if (!count) {
|
|
return outgoing ? "No outgoing messages" : "No messages";
|
|
}
|
|
return Data::NumberToString(count)
|
|
+ (outgoing ? " outgoing messages" : " messages");
|
|
};
|
|
auto userpic = UserpicData{
|
|
((_dialog.type == Type::Self
|
|
|| _dialog.type == Type::Replies
|
|
|| _dialog.type == Type::VerifyCodes)
|
|
? kSavedMessagesColorIndex
|
|
: Data::PeerColorIndex(_dialog.peerId)),
|
|
kEntryUserpicSize
|
|
};
|
|
userpic.firstName = NameString(_dialog);
|
|
userpic.lastName = LastNameString(_dialog);
|
|
|
|
const auto result = validateDialogsMode(_dialog.isLeftChannel);
|
|
if (!result) {
|
|
return result;
|
|
}
|
|
|
|
return _chats->writeBlock(_chats->pushListEntry(
|
|
userpic,
|
|
ComposeName(userpic, DeletedString(_dialog.type)),
|
|
CountString(_messagesCount, _dialog.onlyMyMessages),
|
|
TypeString(_dialog.type),
|
|
(_messagesCount > 0
|
|
? (_dialog.relativePath + "messages.html")
|
|
: QString())));
|
|
}
|
|
|
|
Result HtmlWriter::validateDialogsMode(bool isLeftChannel) {
|
|
const auto mode = isLeftChannel
|
|
? DialogsMode::Left
|
|
: DialogsMode::Chats;
|
|
if (_dialogsMode == mode) {
|
|
return Result::Success();
|
|
} else if (_dialogsMode != DialogsMode::None) {
|
|
const auto result = _chats->writeBlock(_chats->popTag());
|
|
if (!result) {
|
|
return result;
|
|
}
|
|
}
|
|
_dialogsMode = mode;
|
|
auto block = _chats->pushAbout(isLeftChannel
|
|
? _environment.aboutLeftChats
|
|
: _environment.aboutChats);
|
|
block.append(_chats->pushDiv("entry_list"));
|
|
return _chats->writeBlock(block);
|
|
}
|
|
|
|
Result HtmlWriter::writeDialogsEnd() {
|
|
if (_chats) {
|
|
return base::take(_chats)->close();
|
|
}
|
|
return Result::Success();
|
|
}
|
|
|
|
Result HtmlWriter::writeDialogOpening(int index) {
|
|
const auto name = (_dialog.name.isEmpty()
|
|
&& _dialog.lastName.isEmpty())
|
|
? QByteArray("Deleted Account")
|
|
: (_dialog.name + ' ' + _dialog.lastName);
|
|
auto block = _chat->pushHeader(
|
|
name,
|
|
_settings.onlySinglePeer() ? QString() : _dialogsRelativePath);
|
|
block.append(_chat->pushDiv("page_body chat_page"));
|
|
block.append(_chat->pushDiv("history"));
|
|
if (index > 0) {
|
|
const auto previousPath = messagesFile(index - 1);
|
|
block.append(_chat->pushTag("a", {
|
|
{ "class", "pagination block_link" },
|
|
{ "href", previousPath.toUtf8() }
|
|
}));
|
|
block.append("Previous messages");
|
|
block.append(_chat->popTag());
|
|
}
|
|
return _chat->writeBlock(block);
|
|
}
|
|
|
|
void HtmlWriter::pushSection(
|
|
int priority,
|
|
const QByteArray &label,
|
|
const QByteArray &type,
|
|
int count,
|
|
const QString &path) {
|
|
_savedSections.push_back({
|
|
priority,
|
|
label,
|
|
type,
|
|
count,
|
|
path
|
|
});
|
|
}
|
|
|
|
Result HtmlWriter::writeSections() {
|
|
Expects(_summary != nullptr);
|
|
|
|
if (_savedSections.empty()) {
|
|
return Result::Success();
|
|
} else if (!_haveSections) {
|
|
auto block = _summary->pushDiv(
|
|
_summaryNeedDivider ? "sections with_divider" : "sections");
|
|
if (const auto result = _summary->writeBlock(block); !result) {
|
|
return result;
|
|
}
|
|
_haveSections = true;
|
|
_summaryNeedDivider = false;
|
|
}
|
|
|
|
auto block = QByteArray();
|
|
ranges::sort(_savedSections, std::less<>(), [](const SavedSection &data) {
|
|
return data.priority;
|
|
});
|
|
for (const auto §ion : base::take(_savedSections)) {
|
|
block.append(_summary->pushSection(
|
|
section.label,
|
|
section.type,
|
|
section.count,
|
|
_summary->relativePath(section.path)));
|
|
}
|
|
return _summary->writeBlock(block);
|
|
}
|
|
|
|
QByteArray HtmlWriter::wrapMessageLink(int messageId, QByteArray text) {
|
|
const auto it = ranges::find_if(_lastMessageIdsPerFile, [&](int maxMessageId) {
|
|
return messageId <= maxMessageId;
|
|
});
|
|
if (it == end(_lastMessageIdsPerFile)) {
|
|
return "<a href=\"#go_to_message"
|
|
+ Data::NumberToString(messageId)
|
|
+ "\" onclick=\"return GoToMessage("
|
|
+ Data::NumberToString(messageId)
|
|
+ ")\">"
|
|
+ text + "</a>";
|
|
} else {
|
|
const auto index = it - begin(_lastMessageIdsPerFile);
|
|
return "<a href=\"" + messagesFile(index).toUtf8()
|
|
+ "#go_to_message"
|
|
+ Data::NumberToString(messageId)
|
|
+ "\">"
|
|
+ text + "</a>";
|
|
|
|
}
|
|
}
|
|
|
|
Result HtmlWriter::switchToNextChatFile(int index) {
|
|
Expects(_chat != nullptr);
|
|
|
|
const auto nextPath = messagesFile(index);
|
|
auto next = _chat->pushTag("a", {
|
|
{ "class", "pagination block_link" },
|
|
{ "href", nextPath.toUtf8() }
|
|
});
|
|
next.append("Next messages");
|
|
next.append(_chat->popTag());
|
|
if (const auto result = _chat->writeBlock(next); !result) {
|
|
return result;
|
|
} else if (const auto end = _chat->close(); !end) {
|
|
return end;
|
|
}
|
|
_chat = fileWithRelativePath(_dialog.relativePath + nextPath);
|
|
_chatFileEmpty = true;
|
|
return Result::Success();
|
|
}
|
|
|
|
Result HtmlWriter::finish() {
|
|
Expects(_settings.onlySinglePeer() || _summary != nullptr);
|
|
|
|
if (_settings.onlySinglePeer()) {
|
|
return Result::Success();
|
|
}
|
|
|
|
if (const auto result = writeSections(); !result) {
|
|
return result;
|
|
}
|
|
auto block = QByteArray();
|
|
if (_haveSections) {
|
|
block.append(_summary->popTag());
|
|
_summaryNeedDivider = true;
|
|
_haveSections = false;
|
|
}
|
|
block.append(_summary->pushAbout(
|
|
_environment.aboutTelegram,
|
|
_summaryNeedDivider));
|
|
if (const auto result = _summary->writeBlock(block); !result) {
|
|
return result;
|
|
}
|
|
return _summary->close();
|
|
}
|
|
|
|
Result HtmlWriter::copyFile(
|
|
const QString &source,
|
|
const QString &relativePath) const {
|
|
return File::Copy(
|
|
source,
|
|
pathWithRelativePath(relativePath),
|
|
_stats);
|
|
}
|
|
|
|
QString HtmlWriter::mainFilePath() {
|
|
return pathWithRelativePath(_settings.onlySinglePeer()
|
|
? messagesFile(0)
|
|
: mainFileRelativePath());
|
|
}
|
|
|
|
QString HtmlWriter::mainFileRelativePath() const {
|
|
return "export_results.html";
|
|
}
|
|
|
|
QString HtmlWriter::pathWithRelativePath(const QString &path) const {
|
|
return _settings.path + path;
|
|
}
|
|
|
|
QString HtmlWriter::messagesFile(int index) const {
|
|
return "messages"
|
|
+ (index > 0 ? QString::number(index + 1) : QString())
|
|
+ ".html";
|
|
}
|
|
|
|
std::unique_ptr<HtmlWriter::Wrap> HtmlWriter::fileWithRelativePath(
|
|
const QString &path) const {
|
|
return std::make_unique<Wrap>(
|
|
pathWithRelativePath(path),
|
|
_settings.path,
|
|
_stats);
|
|
}
|
|
|
|
HtmlWriter::~HtmlWriter() = default;
|
|
|
|
} // namespace Output
|
|
} // namespace Export
|