From 19c06a7a058ae8c0a87161d8d42282a549a74a23 Mon Sep 17 00:00:00 2001 From: Bohdan Tkachenko Date: Wed, 19 Jun 2024 21:17:58 -0400 Subject: [PATCH] Add reactions to export chat history --- Telegram/Resources/export_html/css/style.css | 42 +++++++++ .../export/data/export_data_types.cpp | 72 +++++++++++++++ .../export/data/export_data_types.h | 24 +++++ .../SourceFiles/export/export_api_wrap.cpp | 81 ++++++++++------- Telegram/SourceFiles/export/export_api_wrap.h | 1 + .../export/output/export_output_html.cpp | 87 +++++++++++++++++-- .../export/output/export_output_json.cpp | 85 ++++++++++++++---- 7 files changed, 338 insertions(+), 54 deletions(-) diff --git a/Telegram/Resources/export_html/css/style.css b/Telegram/Resources/export_html/css/style.css index 102f5f3a5deb6a..f7a8255112e369 100644 --- a/Telegram/Resources/export_html/css/style.css +++ b/Telegram/Resources/export_html/css/style.css @@ -582,3 +582,45 @@ div.toast_shown { .bot_button_column_separator { width: 2px } + +.reactions { + margin: 5px 0; +} + +.reactions .reaction { + display: inline-flex; + height: 20px; + border-radius: 15px; + background-color: #e8f5fc; + color: #168acd; + font-weight: bold; + margin-bottom: 5px; +} + +.reactions .reaction.active { + background-color: #40a6e2; + color: #fff; +} + +.reactions .reaction .emoji { + line-height: 20px; + margin: 0 5px; + font-size: 15px; +} + +.reactions .reaction .userpic:not(:first-child) { + margin-left: -8px; +} + +.reactions .reaction .userpic { + display: inline-block; +} + +.reactions .reaction .userpic .initials { + font-size: 8px; +} + +.reactions .reaction .count { + margin-right: 8px; + line-height: 20px; +} \ No newline at end of file diff --git a/Telegram/SourceFiles/export/data/export_data_types.cpp b/Telegram/SourceFiles/export/data/export_data_types.cpp index f9c9806d07402b..f407ae171d6e84 100644 --- a/Telegram/SourceFiles/export/data/export_data_types.cpp +++ b/Telegram/SourceFiles/export/data/export_data_types.cpp @@ -318,6 +318,75 @@ std::vector ParseText( return result; } +Utf8String Reaction::TypeToString(const Reaction &reaction) { + switch (reaction.type) { + case Reaction::Type::Empty: return "empty"; + case Reaction::Type::Emoji: return "emoji"; + case Reaction::Type::CustomEmoji: return "custom_emoji"; + } + Unexpected("Type in Reaction::Type."); +} + +Utf8String Reaction::Id(const Reaction &reaction) { + Utf8String id; + switch (reaction.type) { + case Reaction::Type::Emoji: + id = reaction.emoji.toUtf8(); + break; + case Reaction::Type::CustomEmoji: + id = reaction.documentId; + break; + } + return Reaction::TypeToString(reaction) + id; +} + +Reaction ParseReaction(const MTPReaction& reaction) { + Reaction result; + reaction.match([&](const MTPDreactionEmoji &data) { + result.type = Reaction::Type::Emoji; + result.emoji = qs(data.vemoticon()); + }, [&](const MTPDreactionCustomEmoji &data) { + result.type = Reaction::Type::CustomEmoji; + result.documentId = NumberToString(data.vdocument_id().v); + }, [&](const MTPDreactionEmpty &data) { + result.type = Reaction::Type::Empty; + }); + return result; +} + +std::vector ParseReactions(const MTPMessageReactions &data) { + std::map reactionsMap; + std::vector reactionsOrder; + for (const auto &single : data.data().vresults().v) { + Reaction reaction = ParseReaction(single.data().vreaction()); + reaction.count = single.data().vcount().v; + Utf8String id = Reaction::Id(reaction); + auto const &[_, inserted] = reactionsMap.try_emplace(id, reaction); + if (inserted) { + reactionsOrder.push_back(id); + } + } + if (data.data().vrecent_reactions().has_value()) { + for (const auto &single : data.data().vrecent_reactions().value_or_empty()) { + Reaction reaction = ParseReaction(single.data().vreaction()); + Utf8String id = Reaction::Id(reaction); + auto const &[it, inserted] = reactionsMap.try_emplace(id, reaction); + if (inserted) { + reactionsOrder.push_back(id); + } + it->second.recent.push_back({ + .peerId = ParsePeerId(single.data().vpeer_id()), + .date = single.data().vdate().v, + }); + } + } + std::vector results; + for (const auto& id : reactionsOrder) { + results.push_back(reactionsMap[id]); + } + return results; +} + Utf8String FillLeft(const Utf8String &data, int length, char filler) { if (length <= data.size()) { return data; @@ -1688,6 +1757,9 @@ Message ParseMessage( result.text = ParseText( data.vmessage(), data.ventities().value_or_empty()); + if (data.vreactions().has_value()) { + result.reactions = ParseReactions(*data.vreactions()); + } }, [&](const MTPDmessageService &data) { result.action = ParseServiceAction( context, diff --git a/Telegram/SourceFiles/export/data/export_data_types.h b/Telegram/SourceFiles/export/data/export_data_types.h index 9639730e423498..bd47b50bb007ec 100644 --- a/Telegram/SourceFiles/export/data/export_data_types.h +++ b/Telegram/SourceFiles/export/data/export_data_types.h @@ -672,6 +672,29 @@ struct TextPart { } }; +struct Reaction { + enum class Type { + Empty, + Emoji, + CustomEmoji, + }; + + static Utf8String TypeToString(const Reaction &); + + static Utf8String Id(const Reaction &); + + struct Recent { + PeerId peerId = 0; + TimeId date = 0; + }; + + Type type; + QString emoji; + Utf8String documentId; + uint32 count = 0; + std::vector recent; +}; + struct MessageId { ChannelId channelId; int32 msgId = 0; @@ -744,6 +767,7 @@ struct Message { int32 replyToMsgId = 0; PeerId replyToPeerId = 0; std::vector text; + std::vector reactions; Media media; ServiceAction action; bool out = false; diff --git a/Telegram/SourceFiles/export/export_api_wrap.cpp b/Telegram/SourceFiles/export/export_api_wrap.cpp index 69861015c3d3dc..b41bd144548b51 100644 --- a/Telegram/SourceFiles/export/export_api_wrap.cpp +++ b/Telegram/SourceFiles/export/export_api_wrap.cpp @@ -1726,6 +1726,15 @@ void ApiWrap::collectMessagesCustomEmoji(const Data::MessagesSlice &slice) { } } } + for (const auto &reaction : message.reactions) { + if (reaction.type == Data::Reaction::Type::CustomEmoji) { + if (const auto id = reaction.documentId.toULongLong()) { + if (!_resolvedCustomEmoji.contains(id)) { + _unresolvedCustomEmoji.emplace(id); + } + } + } + } } } @@ -1803,38 +1812,52 @@ Data::FileOrigin ApiWrap::currentFileMessageOrigin() const { return result; } +bool ApiWrap::renderCustomEmoji(QByteArray *data) { + if (const auto id = data->toULongLong()) { + const auto i = _resolvedCustomEmoji.find(id); + if (i == end(_resolvedCustomEmoji)) { + *data = Data::TextPart::UnavailableEmoji(); + } else { + auto &file = i->second.file; + const auto fileProgress = [=](FileProgress value) { + return loadMessageEmojiProgress(value); + }; + const auto ready = processFileLoad( + file, + { .customEmojiId = id }, + fileProgress, + [=](const QString &path) { + loadMessageEmojiDone(id, path); + }); + if (!ready) { + return false; + } + using SkipReason = Data::File::SkipReason; + if (file.skipReason == SkipReason::Unavailable) { + *data = Data::TextPart::UnavailableEmoji(); + } else if (file.skipReason == SkipReason::FileType + || file.skipReason == SkipReason::FileSize) { + *data = QByteArray(); + } else { + *data = file.relativePath.toUtf8(); + } + } + } + return true; +} + bool ApiWrap::messageCustomEmojiReady(Data::Message &message) { for (auto &part : message.text) { if (part.type == Data::TextPart::Type::CustomEmoji) { - if (const auto id = part.additional.toULongLong()) { - const auto i = _resolvedCustomEmoji.find(id); - if (i == end(_resolvedCustomEmoji)) { - part.additional = Data::TextPart::UnavailableEmoji(); - } else { - auto &file = i->second.file; - const auto fileProgress = [=](FileProgress value) { - return loadMessageEmojiProgress(value); - }; - const auto ready = processFileLoad( - file, - { .customEmojiId = id }, - fileProgress, - [=](const QString &path) { - loadMessageEmojiDone(id, path); - }); - if (!ready) { - return false; - } - using SkipReason = Data::File::SkipReason; - if (file.skipReason == SkipReason::Unavailable) { - part.additional = Data::TextPart::UnavailableEmoji(); - } else if (file.skipReason == SkipReason::FileType - || file.skipReason == SkipReason::FileSize) { - part.additional = QByteArray(); - } else { - part.additional = file.relativePath.toUtf8(); - } - } + if (!renderCustomEmoji(&part.additional)) { + return false; + } + } + } + for (auto &reaction : message.reactions) { + if (reaction.type == Data::Reaction::Type::CustomEmoji) { + if (!renderCustomEmoji(&reaction.documentId)) { + return false; } } } diff --git a/Telegram/SourceFiles/export/export_api_wrap.h b/Telegram/SourceFiles/export/export_api_wrap.h index 4723cd882d1750..b19020354d717e 100644 --- a/Telegram/SourceFiles/export/export_api_wrap.h +++ b/Telegram/SourceFiles/export/export_api_wrap.h @@ -183,6 +183,7 @@ class ApiWrap { void resolveCustomEmoji(); void loadMessagesFiles(Data::MessagesSlice &&slice); void loadNextMessageFile(); + bool renderCustomEmoji(QByteArray *data); bool messageCustomEmojiReady(Data::Message &message); bool loadMessageFileProgress(FileProgress value); void loadMessageFileDone(const QString &relativePath); diff --git a/Telegram/SourceFiles/export/output/export_output_html.cpp b/Telegram/SourceFiles/export/output/export_output_html.cpp index 4d3fa397c4cdab..cf3fd458c613a6 100644 --- a/Telegram/SourceFiles/export/output/export_output_html.cpp +++ b/Telegram/SourceFiles/export/output/export_output_html.cpp @@ -230,6 +230,21 @@ QByteArray JoinList( return result; } +QByteArray FormatCustomEmoji( + const Data::Utf8String &custom_emoji, + const QByteArray &text, + const QString &relativeLinkBase) { + return (custom_emoji.isEmpty() + ? "" + : (custom_emoji == Data::TextPart::UnavailableEmoji()) + ? "" + : ("")) + + text + + ""; +} + QByteArray FormatText( const std::vector &data, const QString &internalLinksDomain, @@ -287,15 +302,8 @@ QByteArray FormatText( "onclick=\"ShowSpoiler(this)\">" "" + text + ""; - case Type::CustomEmoji: return (part.additional.isEmpty() - ? "" - : (part.additional == Data::TextPart::UnavailableEmoji()) - ? "" - : ("")) - + text - + ""; + case Type::CustomEmoji: return FormatCustomEmoji( + part.additional, text, relativeLinkBase); } Unexpected("Type in text entities serialization."); }) | ranges::to_vector); @@ -1516,6 +1524,67 @@ auto HtmlWriter::Wrap::pushMessage( if (showForwardedInfo) { block.append(popTag()); } + if (!message.reactions.empty()) { + block.append(pushDiv("reactions")); + for (const auto& reaction : message.reactions) { + QByteArray reactionClass = "reaction"; + for (const auto& recent : reaction.recent) { + auto peer = peers.peer(recent.peerId); + if (peer.user() && peer.user()->isSelf) { + reactionClass += " active"; + break; + } + } + + block.append(pushTag("div", { + { "class", reactionClass }, + })); + block.append(pushTag("div", { + { "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, + "(custom emoji)", + _base)); + break; + } + block.append(popTag()); + if (!reaction.recent.empty()) { + block.append(pushTag("div", { + { "class", "userpics" }, + })); + for (const auto& recent : reaction.recent) { + 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("div", { + { "class", "count" }, + })); + block.append(NumberToString(reaction.count)); + block.append(popTag()); + } + block.append(popTag()); + } + block.append(popTag()); + } block.append(popTag()); block.append(popTag()); diff --git a/Telegram/SourceFiles/export/output/export_output_json.cpp b/Telegram/SourceFiles/export/output/export_output_json.cpp index 62aaba5f7f498a..5bdafbbcb03579 100644 --- a/Telegram/SourceFiles/export/output/export_output_json.cpp +++ b/Telegram/SourceFiles/export/output/export_output_json.cpp @@ -290,6 +290,17 @@ QByteArray SerializeMessage( pushBare("edited_unixtime", SerializeDateRaw(message.edited)); } + const auto wrapPeerId = [&](PeerId peerId) { + if (const auto chat = peerToChat(peerId)) { + return SerializeString("chat" + + Data::NumberToString(chat.bare)); + } else if (const auto channel = peerToChannel(peerId)) { + return SerializeString("channel" + + Data::NumberToString(channel.bare)); + } + return SerializeString("user" + + Data::NumberToString(peerToUser(peerId).bare)); + }; const auto push = [&](const QByteArray &key, const auto &value) { using V = std::decay_t; if constexpr (std::is_same_v) { @@ -297,22 +308,7 @@ QByteArray SerializeMessage( } else if constexpr (std::is_arithmetic_v) { pushBare(key, Data::NumberToString(value)); } else if constexpr (std::is_same_v) { - if (const auto chat = peerToChat(value)) { - pushBare( - key, - SerializeString("chat" - + Data::NumberToString(chat.bare))); - } else if (const auto channel = peerToChannel(value)) { - pushBare( - key, - SerializeString("channel" - + Data::NumberToString(channel.bare))); - } else { - pushBare( - key, - SerializeString("user" - + Data::NumberToString(peerToUser(value).bare))); - } + pushBare(key, wrapPeerId(value)); } else { const auto wrapped = QByteArray(value); if (!wrapped.isEmpty()) { @@ -867,6 +863,63 @@ QByteArray SerializeMessage( pushBare("inline_bot_buttons", SerializeArray(context, rows)); } + if (!message.reactions.empty()) { + const auto serializeReaction = [&](const Reaction &reaction) { + context.nesting.push_back(Context::kObject); + const auto guard = gsl::finally([&] { context.nesting.pop_back(); }); + + auto pairs = std::vector>(); + pairs.push_back({ + "type", + SerializeString(Reaction::TypeToString(reaction)), + }); + pairs.push_back({ + "count", + NumberToString(reaction.count), + }); + switch (reaction.type) { + case Reaction::Type::Emoji: + pairs.push_back({ + "emoji", + SerializeString(reaction.emoji.toUtf8()), + }); + break; + case Reaction::Type::CustomEmoji: + pairs.push_back({ + "document_id", + SerializeString(reaction.documentId), + }); + break; + } + + if (!reaction.recent.empty()) { + context.nesting.push_back(Context::kArray); + const auto recents = ranges::views::all( + reaction.recent + ) | ranges::views::transform([&](const Reaction::Recent &recent) { + context.nesting.push_back(Context::kArray); + const auto guard = gsl::finally([&] { context.nesting.pop_back(); }); + return SerializeObject(context, { + {"from", wrapPeerName(recent.peerId)}, + {"from_id", wrapPeerId(recent.peerId)}, + {"date", SerializeDate(recent.date)} + }); + }) | ranges::to_vector; + pairs.push_back({"recent", SerializeArray(context, recents)}); + context.nesting.pop_back(); + } + + return SerializeObject(context, pairs); + }; + + context.nesting.push_back(Context::kArray); + const auto reactions = ranges::views::all( + message.reactions + ) | ranges::views::transform(serializeReaction) | ranges::to_vector; + pushBare("reactions", SerializeArray(context, reactions)); + context.nesting.pop_back(); + } + return serialized(); }