diff --git a/Telegram/Resources/export_html/css/style.css b/Telegram/Resources/export_html/css/style.css index 102f5f3a5deb6a..f9727d68ac6f32 100644 --- a/Telegram/Resources/export_html/css/style.css +++ b/Telegram/Resources/export_html/css/style.css @@ -582,3 +582,55 @@ 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.paid { + background-color: #fdf6e1; + color: #c58523; +} + +.reactions .reaction.active.paid { + background-color: #ecae0a; + color: #fdf6e1; +} + +.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 5f95e671b31e90..bbb363dc5e6d0a 100644 --- a/Telegram/SourceFiles/export/data/export_data_types.cpp +++ b/Telegram/SourceFiles/export/data/export_data_types.cpp @@ -325,6 +325,80 @@ 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"; + case Reaction::Type::Paid: return "paid"; + } + Unexpected("Type in Reaction::Type."); +} + +Utf8String Reaction::Id(const Reaction &reaction) { + auto id = Utf8String(); + 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) { + auto result = Reaction(); + 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 MTPDreactionPaid &data) { + result.type = Reaction::Type::Paid; + }, [&](const MTPDreactionEmpty &data) { + result.type = Reaction::Type::Empty; + }); + return result; +} + +std::vector ParseReactions(const MTPMessageReactions &data) { + auto reactionsMap = std::map(); + auto reactionsOrder = std::vector(); + for (const auto &single : data.data().vresults().v) { + auto reaction = ParseReaction(single.data().vreaction()); + reaction.count = single.data().vcount().v; + auto 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()) { + if (const auto list = data.data().vrecent_reactions()) { + for (const auto &single : list->v) { + auto reaction = ParseReaction(single.data().vreaction()); + auto 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; @@ -1739,6 +1813,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 ce2d97842d88d6..7d6c2e6681722e 100644 --- a/Telegram/SourceFiles/export/data/export_data_types.h +++ b/Telegram/SourceFiles/export/data/export_data_types.h @@ -702,6 +702,30 @@ struct TextPart { } }; +struct Reaction { + enum class Type { + Empty, + Emoji, + CustomEmoji, + Paid, + }; + + 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; @@ -775,6 +799,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..0d408f142bab9e 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,57 @@ Data::FileOrigin ApiWrap::currentFileMessageOrigin() const { return result; } +std::optional ApiWrap::getCustomEmoji(QByteArray &data) { + if (const auto id = data.toULongLong()) { + const auto i = _resolvedCustomEmoji.find(id); + if (i == end(_resolvedCustomEmoji)) { + return Data::TextPart::UnavailableEmoji(); + } + 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 std::nullopt; + } + using SkipReason = Data::File::SkipReason; + if (file.skipReason == SkipReason::Unavailable) { + return Data::TextPart::UnavailableEmoji(); + } else if (file.skipReason == SkipReason::FileType + || file.skipReason == SkipReason::FileSize) { + return QByteArray(); + } else { + return file.relativePath.toUtf8(); + } + } + return data; +} + 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(); - } - } + auto data = getCustomEmoji(part.additional); + if (data.has_value()) { + part.additional = *data; + } else { + return false; + } + } + } + for (auto &reaction : message.reactions) { + if (reaction.type == Data::Reaction::Type::CustomEmoji) { + auto data = getCustomEmoji(reaction.documentId); + if (data.has_value()) { + reaction.documentId = *data; + } else { + return false; } } } diff --git a/Telegram/SourceFiles/export/export_api_wrap.h b/Telegram/SourceFiles/export/export_api_wrap.h index 4723cd882d1750..613d7c5881a0b5 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(); + std::optional getCustomEmoji(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 00a755bd938c57..e9b180b40dd03b 100644 --- a/Telegram/SourceFiles/export/output/export_output_html.cpp +++ b/Telegram/SourceFiles/export/output/export_output_html.cpp @@ -231,6 +231,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, @@ -288,15 +303,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); @@ -1545,6 +1553,73 @@ 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; + } + } + if (reaction.type == Reaction::Type::Paid) { + reactionClass += " paid"; + } + + 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, + "\U0001F44B", + _base)); + break; + case Reaction::Type::Paid: + block.append(SerializeString("\u2B50")); + 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 c7378f392d3052..4acb12d2e5acea 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()) { @@ -919,6 +915,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(); }