From 5c825f352f5a334f9690d5fd73ba814f5c2d78ac Mon Sep 17 00:00:00 2001 From: John Preston Date: Thu, 21 May 2026 16:59:43 +0400 Subject: [PATCH] Support basic display of IV in messages. --- Telegram/CMakeLists.txt | 2 + .../SourceFiles/core/local_url_handlers.cpp | 27 - Telegram/SourceFiles/data/data_session.cpp | 53 +- Telegram/SourceFiles/history/history_item.cpp | 94 +- Telegram/SourceFiles/history/history_item.h | 15 +- .../history/history_item_components.h | 12 +- .../history/history_item_edition.cpp | 4 +- .../history/history_item_edition.h | 9 +- .../history/view/history_view_element.cpp | 153 ++- .../history/view/history_view_element.h | 3 + .../history/view/history_view_message.cpp | 464 ++++++- .../history/view/history_view_message.h | 31 + Telegram/SourceFiles/iv/iv_data.cpp | 80 +- Telegram/SourceFiles/iv/iv_data.h | 34 +- Telegram/SourceFiles/iv/iv_instance.cpp | 101 +- Telegram/SourceFiles/iv/iv_prepare.cpp | 122 +- Telegram/SourceFiles/iv/iv_prepare.h | 6 + Telegram/SourceFiles/iv/iv_rich_page.cpp | 1216 +++++++++++++++++ Telegram/SourceFiles/iv/iv_rich_page.h | 191 +++ .../iv/markdown/iv_markdown_article.cpp | 16 + .../iv/markdown/iv_markdown_article.h | 1 + .../iv/markdown/iv_markdown_prepare.cpp | 82 +- .../iv/markdown/iv_markdown_prepare.h | 7 + .../iv_markdown_prepare_native_blocks.cpp | 745 ++++++++++ .../iv_markdown_prepare_native_blocks.h | 4 + .../iv_markdown_prepare_native_richtext.cpp | 348 +++++ .../iv_markdown_prepare_native_richtext.h | 31 + .../SourceFiles/tests/test_markdown_iv.cpp | 588 ++++++++ 28 files changed, 4170 insertions(+), 269 deletions(-) create mode 100644 Telegram/SourceFiles/iv/iv_rich_page.cpp create mode 100644 Telegram/SourceFiles/iv/iv_rich_page.h diff --git a/Telegram/CMakeLists.txt b/Telegram/CMakeLists.txt index 39404f1a4e..1616053e74 100644 --- a/Telegram/CMakeLists.txt +++ b/Telegram/CMakeLists.txt @@ -1302,6 +1302,8 @@ PRIVATE iv/iv_delegate_impl.h iv/iv_instance.cpp iv/iv_instance.h + iv/iv_rich_page.cpp + iv/iv_rich_page.h lang/lang_cloud_manager.cpp lang/lang_cloud_manager.h lang/lang_instance.cpp diff --git a/Telegram/SourceFiles/core/local_url_handlers.cpp b/Telegram/SourceFiles/core/local_url_handlers.cpp index 0c70d53621..61857485c0 100644 --- a/Telegram/SourceFiles/core/local_url_handlers.cpp +++ b/Telegram/SourceFiles/core/local_url_handlers.cpp @@ -73,7 +73,6 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "inline_bots/bot_attach_web_view.h" #include "history/history.h" #include "history/history_item.h" -#include "history/history_item_components.h" #include "iv/iv_instance.h" #include "apiwrap.h" @@ -1675,28 +1674,6 @@ bool ResolveOAuth( return true; } -bool ShowRichText( - Window::SessionController *controller, - const Match &match, - const QVariant &) { - if (!controller) { - return true; - } - auto peerOk = false; - auto msgOk = false; - const auto peer = PeerId(match->captured(1).toULongLong(&peerOk)); - const auto msg = MsgId(match->captured(2).toLongLong(&msgOk)); - if (!peerOk || !msgOk) { - return true; - } - const auto item = controller->session().data().message(FullMsgId(peer, msg)); - if (!item || !item->Get()) { - return true; - } - Core::App().iv().showRichMessage(controller, not_null{ item }); - return true; -} - } // namespace bool TryRouterForLocalUrl( @@ -1829,10 +1806,6 @@ const std::vector &LocalUrlHandlers() { const std::vector &InternalUrlHandlers() { static auto Result = std::vector{ - { - u"^//?rich_text\\?peer=([0-9]+)&msg=(-?[0-9]+)(&|$)"_q, - ShowRichText - }, { u"^media_timestamp/?\\?base=([a-zA-Z0-9\\.\\_\\-]+)&t=(\\d+)(&|$)"_q, OpenMediaTimestamp diff --git a/Telegram/SourceFiles/data/data_session.cpp b/Telegram/SourceFiles/data/data_session.cpp index 8b308255da..33ff1e9d70 100644 --- a/Telegram/SourceFiles/data/data_session.cpp +++ b/Telegram/SourceFiles/data/data_session.cpp @@ -60,6 +60,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "data/data_file_origin.h" #include "data/data_download_manager.h" #include "data/data_web_page.h" +#include "iv/iv_rich_page.h" #include "data/data_game.h" #include "data/data_poll.h" #include "data/data_replies_list.h" @@ -4236,55 +4237,27 @@ void Session::webpageApplyFields( }, [](const auto &) {}); } } - if (const auto page = data.vcached_page()) { - for (const auto &photo : page->data().vphotos().v) { - processPhoto(photo); - } - for (const auto &document : page->data().vdocuments().v) { - processDocument(document); - } - const auto process = [&]( - const MTPPageBlock &block, - const auto &self) -> void { - block.match([&](const MTPDpageBlockChannel &data) { - processChat(data.vchannel()); - }, [&](const MTPDpageBlockCover &data) { - self(data.vcover(), self); - }, [&](const MTPDpageBlockEmbedPost &data) { - for (const auto &block : data.vblocks().v) { - self(block, self); - } - }, [&](const MTPDpageBlockCollage &data) { - for (const auto &block : data.vitems().v) { - self(block, self); - } - }, [&](const MTPDpageBlockSlideshow &data) { - for (const auto &block : data.vitems().v) { - self(block, self); - } - }, [&](const MTPDpageBlockDetails &data) { - for (const auto &block : data.vblocks().v) { - self(block, self); - } - }, [](const auto &) {}); - }; - for (const auto &block : page->data().vblocks().v) { - process(block, process); - } - } const auto type = story ? WebPageType::Story : ParseWebPageType(data); - auto iv = (data.vcached_page() && !IgnoreIv(type)) - ? std::make_unique(data, *data.vcached_page()) + const auto cachedPage = data.vcached_page(); + const auto ivPhoto = photo ? processPhoto(*photo).get() : nullptr; + const auto ivDocument = document ? processDocument(*document).get() : nullptr; + auto iv = (cachedPage && !IgnoreIv(type)) + ? std::make_unique( + data, + *cachedPage, + Iv::ParseRichPage(_session, *cachedPage), + ivPhoto, + ivDocument) : nullptr; const auto resolvedPhoto = story ? story->photo() : photo - ? processPhoto(*photo).get() + ? ivPhoto : nullptr; const auto resolvedDocument = story ? story->document() : document - ? processDocument(*document).get() + ? ivDocument : lookupThemeDocument(); const auto photoIsVideoCover = data.is_video_cover_photo() || (resolvedDocument diff --git a/Telegram/SourceFiles/history/history_item.cpp b/Telegram/SourceFiles/history/history_item.cpp index 56dba87157..d812dbdf84 100644 --- a/Telegram/SourceFiles/history/history_item.cpp +++ b/Telegram/SourceFiles/history/history_item.cpp @@ -21,6 +21,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "history/history_unread_things.h" #include "history/history.h" #include "iv/iv_data.h" +#include "iv/iv_rich_page.h" #include "mtproto/mtproto_config.h" #include "ui/text/format_values.h" #include "ui/text/text_isolated_emoji.h" @@ -81,12 +82,6 @@ constexpr auto kMinLoginCode = 5; using ItemPreview = HistoryView::ItemPreview; -[[nodiscard]] QString RichMessageInternalUrl(FullMsgId itemId) { - return u"internal://rich_text?peer=%1&msg=%2"_q - .arg(itemId.peer.value) - .arg(itemId.msg.bare); -} - template [[nodiscard]] PreparedServiceText PrepareEmptyText(const T &) { return PreparedServiceText(); @@ -146,6 +141,13 @@ template return false; } +[[nodiscard]] auto ParsedRichPage( + not_null session, + const MTPRichMessage *richMessage) +-> std::shared_ptr { + return richMessage ? Iv::ParseRichPage(session, *richMessage) : nullptr; +} + [[nodiscard]] HistoryItemCommonFields ForwardedFields( HistoryItemCommonFields fields, not_null history, @@ -450,8 +452,9 @@ HistoryItem::HistoryItem( _flags |= MessageFlag::Legacy; createComponents(data); if (const auto richMessage = data.vrich_message()) { - setRichMessage(*richMessage); - setText(richMessageLinkText()); + const auto richPage = Iv::ParseRichPage(&history->session(), *richMessage); + setRichPage(richPage); + setText(Iv::FlattenRichPageSummary(richPage)); } else { setText(UnsupportedMessageText()); } @@ -482,8 +485,9 @@ HistoryItem::HistoryItem( setMedia(*media); } if (const auto richMessage = data.vrich_message()) { - setRichMessage(*richMessage); - setText(richMessageLinkText()); + const auto richPage = Iv::ParseRichPage(&history->session(), *richMessage); + setRichPage(richPage); + setText(Iv::FlattenRichPageSummary(richPage)); } else { auto textWithEntities = TextWithEntities{ qs(data.vmessage()), @@ -2197,19 +2201,19 @@ void HistoryItem::applyEdition(HistoryMessageEdition &&edition) { const auto &checkedMedia = updatingSavedLocalEdit ? Get()->media : _media; - if (edition.hasRichMessage) { - setRichMessage(std::move(edition.richMessage)); + if (edition.richPage) { + setRichPage(edition.richPage); } else { - clearRichMessage(); + clearRichPage(); } - auto updatedText = edition.hasRichMessage - ? richMessageLinkText() + auto updatedText = edition.richPage + ? Iv::FlattenRichPageSummary(edition.richPage) : (mediaCheck == MediaCheckResult::Unsupported) ? UnsupportedMessageText() : checkedMedia ? edition.textWithEntities : EnsureNonEmpty(edition.textWithEntities); - auto serviceText = (!edition.hasRichMessage + auto serviceText = (!edition.richPage && !checkedMedia && edition.textWithEntities.empty() && edition.mtpMedia) @@ -2456,15 +2460,13 @@ void HistoryItem::applySentMessage( const QString &text, const MTPDupdateShortSentMessage &data, bool wasAlready) { - const auto existingRichMessage = Get(); + const auto existingRichPage = Get(); updateSentContent({ text, Api::EntitiesFromMTP( &_history->session(), data.ventities().value_or_empty()) - }, data.vmedia(), existingRichMessage - ? &existingRichMessage->data - : nullptr); + }, data.vmedia(), existingRichPage ? existingRichPage->page : nullptr); contributeToSlowmode(data.vdate().v); if (!wasAlready) { addToSharedMediaIndex(); @@ -2482,6 +2484,16 @@ void HistoryItem::updateSentContent( const TextWithEntities &textWithEntities, const MTPMessageMedia *media, const MTPRichMessage *richMessage) { + updateSentContent( + textWithEntities, + media, + ParsedRichPage(&_history->session(), richMessage)); +} + +void HistoryItem::updateSentContent( + const TextWithEntities &textWithEntities, + const MTPMessageMedia *media, + std::shared_ptr richPage) { if (isEditingMedia()) { return; } @@ -2491,11 +2503,11 @@ void HistoryItem::updateSentContent( if (mediaCheck == MediaCheckResult::Unsupported) { _flags &= ~MessageFlag::HasPostAuthor; _flags |= MessageFlag::Legacy; - if (richMessage) { - setRichMessage(*richMessage); - setText(richMessageLinkText()); + if (richPage) { + setRichPage(richPage); + setText(Iv::FlattenRichPageSummary(richPage)); } else { - clearRichMessage(); + clearRichPage(); setText(UnsupportedMessageText()); } setReplyMarkup(UnsupportedMessageMarkup()); @@ -2503,11 +2515,11 @@ void HistoryItem::updateSentContent( if (_flags & MessageFlag::Legacy) { _flags &= ~MessageFlag::Legacy; } - if (richMessage) { - setRichMessage(*richMessage); - setText(richMessageLinkText()); + if (richPage) { + setRichPage(richPage); + setText(Iv::FlattenRichPageSummary(richPage)); } else { - clearRichMessage(); + clearRichPage(); setText(textWithEntities); } } @@ -4109,24 +4121,22 @@ void HistoryItem::setText(TextWithEntities textWithEntities) { : std::move(textWithEntities)); } -void HistoryItem::setRichMessage(MTPRichMessage data) { - AddComponents(HistoryMessageRichTextSource::Bit()); - Get()->data = std::move(data); +auto HistoryItem::richPage() const -> std::shared_ptr { + const auto source = Get(); + return source ? source->page : nullptr; } -void HistoryItem::clearRichMessage() { - RemoveComponents(HistoryMessageRichTextSource::Bit()); +void HistoryItem::setRichPage(std::shared_ptr page) { + if (page) { + AddComponents(HistoryMessageRichPageSource::Bit()); + Get()->page = std::move(page); + } else { + clearRichPage(); + } } -TextWithEntities HistoryItem::richMessageLinkText() const { - auto result = TextWithEntities{ u"Click to view Rich Text"_q }; - result.entities.push_back({ - EntityType::CustomUrl, - 0, - result.text.size(), - RichMessageInternalUrl(fullId()), - }); - return result; +void HistoryItem::clearRichPage() { + RemoveComponents(HistoryMessageRichPageSource::Bit()); } void HistoryItem::setTextValue(TextWithEntities text, bool force) { diff --git a/Telegram/SourceFiles/history/history_item.h b/Telegram/SourceFiles/history/history_item.h index a80d4bb9e0..a29739a9d8 100644 --- a/Telegram/SourceFiles/history/history_item.h +++ b/Telegram/SourceFiles/history/history_item.h @@ -77,6 +77,10 @@ class Service; class ServiceMessagePainter; } // namespace HistoryView +namespace Iv { +struct RichPage; +} // namespace Iv + namespace Ui { struct ColorCollectible; } // namespace Ui @@ -534,11 +538,12 @@ public: [[nodiscard]] Data::Media *media() const { return _media.get(); } + [[nodiscard]] auto richPage() const + -> std::shared_ptr; [[nodiscard]] bool computeDropForwardedInfo() const; void setText(TextWithEntities textWithEntities); - void setRichMessage(MTPRichMessage data); - void clearRichMessage(); - [[nodiscard]] TextWithEntities richMessageLinkText() const; + void setRichPage(std::shared_ptr page); + void clearRichPage(); [[nodiscard]] MsgId replyToId() const; [[nodiscard]] FullMsgId replyToFullId() const; @@ -664,6 +669,10 @@ private: } [[nodiscard]] bool checkDiscussionLink(ChannelId id) const; + void updateSentContent( + const TextWithEntities &textWithEntities, + const MTPMessageMedia *media, + std::shared_ptr richPage); void setReplyMarkup( HistoryMessageMarkupData &&markup, diff --git a/Telegram/SourceFiles/history/history_item_components.h b/Telegram/SourceFiles/history/history_item_components.h index 8dd3df357c..f50ba94add 100644 --- a/Telegram/SourceFiles/history/history_item_components.h +++ b/Telegram/SourceFiles/history/history_item_components.h @@ -16,6 +16,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/effects/ripple_animation.h" #include "ui/chat/message_bubble.h" +#include + struct WebPageData; struct TodoListItem; class DocumentData; @@ -49,6 +51,10 @@ namespace Images { struct CornersMaskRef; } // namespace Images +namespace Iv { +struct RichPage; +} // namespace Iv + namespace HistoryView { class Element; class Document; @@ -137,9 +143,9 @@ struct HistoryMessageMediaForInstantView base::flat_set> photos; }; -struct HistoryMessageRichTextSource -: RuntimeComponent { - MTPRichMessage data; +struct HistoryMessageRichPageSource +: RuntimeComponent { + std::shared_ptr page; }; class HiddenSenderInfo { diff --git a/Telegram/SourceFiles/history/history_item_edition.cpp b/Telegram/SourceFiles/history/history_item_edition.cpp index f18a34c3f3..8299717e28 100644 --- a/Telegram/SourceFiles/history/history_item_edition.cpp +++ b/Telegram/SourceFiles/history/history_item_edition.cpp @@ -8,6 +8,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "history/history_item_edition.h" #include "api/api_text_entities.h" +#include "iv/iv_rich_page.h" #include "main/main_session.h" HistoryMessageEdition::HistoryMessageEdition( @@ -29,8 +30,7 @@ HistoryMessageEdition::HistoryMessageEdition( mtpReactions = message.vreactions(); mtpFactcheck = message.vfactcheck(); if (const auto data = message.vrich_message()) { - hasRichMessage = true; - richMessage = *data; + richPage = Iv::ParseRichPage(session, *data); } views = message.vviews().value_or(-1); forwards = message.vforwards().value_or(-1); diff --git a/Telegram/SourceFiles/history/history_item_edition.h b/Telegram/SourceFiles/history/history_item_edition.h index 48a6400226..461d141362 100644 --- a/Telegram/SourceFiles/history/history_item_edition.h +++ b/Telegram/SourceFiles/history/history_item_edition.h @@ -9,6 +9,12 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "history/history_item_reply_markup.h" +#include + +namespace Iv { +struct RichPage; +} // namespace Iv + namespace Main { class Session; } // namespace Main @@ -34,12 +40,11 @@ struct HistoryMessageEdition { bool useSameSuggest = false; bool savePreviousMedia = false; bool invertMedia = false; - bool hasRichMessage = false; TextWithEntities textWithEntities; HistoryMessageMarkupData replyMarkup; HistoryMessageRepliesData replies; HistoryMessageSuggestInfo suggest; - MTPRichMessage richMessage; + std::shared_ptr richPage; const MTPMessageMedia *mtpMedia = nullptr; const MTPMessageReactions *mtpReactions = nullptr; const MTPFactCheck *mtpFactcheck = nullptr; diff --git a/Telegram/SourceFiles/history/view/history_view_element.cpp b/Telegram/SourceFiles/history/view/history_view_element.cpp index 5e3171379b..f76e9e7a11 100644 --- a/Telegram/SourceFiles/history/view/history_view_element.cpp +++ b/Telegram/SourceFiles/history/view/history_view_element.cpp @@ -28,6 +28,9 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "history/history.h" #include "history/history_item_components.h" #include "history/history_item_helpers.h" +#include "data/data_channel.h" +#include "data/data_session.h" +#include "iv/iv_cached_media.h" #include "base/unixtime.h" #include "boxes/premium_preview_box.h" #include "core/application.h" @@ -38,8 +41,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "main/main_session.h" #include "chat_helpers/stickers_emoji_pack.h" #include "payments/payments_reaction_process.h" // TryAddingPaidReaction. -#include "window/section_widget.h" #include "window/window_session_controller.h" +#include "window/section_widget.h" #include "ui/chat/chat_style.h" #include "ui/effects/glare.h" #include "ui/effects/path_shift_gradient.h" @@ -51,9 +54,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/rect.h" #include "ui/round_rect.h" #include "data/components/sponsored_messages.h" -#include "data/data_channel.h" #include "data/data_saved_sublist.h" -#include "data/data_session.h" #include "data/data_todo_list.h" #include "data/data_forum.h" #include "data/data_forum_topic.h" @@ -1616,6 +1617,14 @@ const Ui::Text::String &Element::text() const { return _text; } +HistoryMessageRichPage *Element::richpage() { + return Get(); +} + +const HistoryMessageRichPage *Element::richpage() const { + return const_cast(this)->richpage(); +} + OnlyEmojiAndSpaces Element::isOnlyEmojiAndSpaces() const { if (data()->Has()) { return OnlyEmojiAndSpaces::No; @@ -1638,9 +1647,18 @@ int Element::textHeightFor(int textWidth) const { const_cast(this)->validateText(); if (_textWidth != textWidth) { _textWidth = textWidth; - const auto result = _text.countSize(textWidth); - _textRealWidth = std::clamp(result.width(), 0, kMaxWidth); - _textHeight = result.height(); + if (const auto rich = const_cast(this)->richpage()) { + _textHeight = rich->article.resizeGetHeight(textWidth); + rich->article.setVisibleTopBottom(0, _textHeight); + _textRealWidth = std::clamp( + rich->article.lastLayoutWidth(), + 0, + kMaxWidth); + } else { + const auto result = _text.countSize(textWidth); + _textRealWidth = std::clamp(result.width(), 0, kMaxWidth); + _textHeight = result.height(); + } } return _textHeight; } @@ -1793,6 +1811,113 @@ auto Element::contextDependentServiceText() -> TextWithLinks { } void Element::validateText() { + const auto clearRichPage = [&] { + if (Has()) { + RemoveComponents(HistoryMessageRichPage::Bit()); + invalidateTextSizeCache(); + } + }; + const auto ensureRichPage = [&]( + std::shared_ptr page) { + const auto message = dynamic_cast(this); + if (!message || !page) { + clearRichPage(); + return; + } + if (!Has()) { + AddComponents(HistoryMessageRichPage::Bit()); + } + const auto runtime = Get(); + runtime->host.owner = base::make_weak(message); + runtime->article.setMediaBlockHost(&runtime->host); + runtime->article.setTextRepaintCallbacks( + [weak = runtime->host.owner] { + if (const auto owner = weak.get()) { + owner->requestRichPageRepaint(QRect()); + } + }, + [weak = runtime->host.owner](QRect articleRect) { + if (const auto owner = weak.get()) { + owner->requestRichPageRepaint(articleRect); + } + }); + if (runtime->page == page && runtime->mediaRuntime) { + return; + } + const auto session = &history()->session(); + runtime->page = std::move(page); + runtime->mediaRuntime = Iv::CreateMessageMediaRuntime( + session, + data()->fullId(), + [session](QString context) { + const auto separator = context.indexOf(u'\n'); + const auto channelId = ChannelId((separator >= 0) + ? context.mid(0, separator).toULongLong() + : context.toULongLong()); + const auto username = (separator >= 0) + ? context.mid(separator + 1) + : QString(); + if (!channelId) { + return; + } + const auto channel = session->data().channel(channelId); + const auto resolved = !channel->username().isEmpty() + ? channel->username() + : username; + if (channel->isLoaded()) { + if (const auto controller = session->tryResolveWindow(channel)) { + controller->showPeerHistory(channel); + } + } else if (!resolved.isEmpty()) { + if (const auto controller = session->tryResolveWindow(channel)) { + controller->showPeerByLink({ + .usernameOrId = resolved, + }); + } + } + }, + [session](QString context) { + const auto separator = context.indexOf(u'\n'); + const auto channelId = ChannelId((separator >= 0) + ? context.mid(0, separator).toULongLong() + : context.toULongLong()); + const auto username = (separator >= 0) + ? context.mid(separator + 1) + : QString(); + if (!channelId) { + return; + } + const auto channel = session->data().channel(channelId); + const auto resolved = !channel->username().isEmpty() + ? channel->username() + : username; + if (channel->isLoaded()) { + session->api().joinChannel(channel); + } else if (!resolved.isEmpty()) { + if (const auto controller = session->tryResolveWindow(channel)) { + controller->showPeerByLink({ + .usernameOrId = resolved, + .joinChannel = true, + }); + } + } + }); + auto prepared = Iv::Markdown::TryPrepareNativeInstantView({ + .richPage = runtime->page, + .mediaRuntime = runtime->mediaRuntime, + }); + if (!prepared.supported()) { + clearRichPage(); + return; + } + runtime->article.setContent(std::move(prepared.content)); + runtime->handler = nullptr; + runtime->handlerPreparedLink = std::nullopt; + runtime->handlerMediaActivation = {}; + runtime->handlerPlaceholderId = {}; + runtime->handlerPlaceholderPoint = QPoint(); + invalidateTextSizeCache(); + }; const auto item = data(); const auto media = item->media(); const auto storyMention = media && media->storyMention(); @@ -1801,6 +1926,7 @@ void Element::validateText() { if (storyExpired || storyUnsupported) { _media = nullptr; _textItem = item; + clearRichPage(); if (!storyMention) { if (_text.isEmpty()) { setTextWithLinks(tr::italic(storyUnsupported @@ -1824,6 +1950,7 @@ void Element::validateText() { if (summaryShownChanged) { setTextWithLinks(summary.result); } + clearRichPage(); return; } else { _flags &= ~Flag::SummaryShown; @@ -1833,9 +1960,11 @@ void Element::validateText() { if (!_text.isEmpty()) { setTextWithLinks({}); } + clearRichPage(); return; } const auto &text = _textItem->_text; + auto richPage = std::shared_ptr(); if (!summaryShownChanged && _text.isEmpty() == text.empty()) { } else if (_flags & Flag::ServiceMessage) { const auto contextDependentText = contextDependentServiceText(); @@ -1871,8 +2000,15 @@ void Element::validateText() { setTextWithLinks(tr::italic(unavailable)); } else { setTextWithLinks(_textItem->translatedTextWithLocalEntities()); + richPage = _textItem->richPage(); } } + if (!richPage + && !(_flags & Flag::ServiceMessage) + && item->computeUnavailableReason().isEmpty()) { + richPage = _textItem->richPage(); + } + ensureRichPage(std::move(richPage)); } void Element::setTextWithLinks( @@ -2413,6 +2549,11 @@ bool Element::hasVisibleText() const { } int Element::textualMaxWidth() const { + if (const auto rich = richpage()) { + return st::msgPadding.left() + + rich->article.maxWidth() + + st::msgPadding.right(); + } return st::msgPadding.left() + (hasVisibleText() ? text().maxWidth() : 0) + st::msgPadding.right(); diff --git a/Telegram/SourceFiles/history/view/history_view_element.h b/Telegram/SourceFiles/history/view/history_view_element.h index d6b37119f3..045ee9d32c 100644 --- a/Telegram/SourceFiles/history/view/history_view_element.h +++ b/Telegram/SourceFiles/history/view/history_view_element.h @@ -61,6 +61,7 @@ struct StateRequest; struct TextState; class Media; class Reply; +struct HistoryMessageRichPage; enum class Context : char { History, @@ -723,6 +724,8 @@ protected: virtual void refreshDataIdHook(); [[nodiscard]] const Ui::Text::String &text() const; + [[nodiscard]] HistoryMessageRichPage *richpage(); + [[nodiscard]] const HistoryMessageRichPage *richpage() const; [[nodiscard]] int textHeightFor(int textWidth) const; [[nodiscard]] int textRealWidth() const { return _textRealWidth; } void validateText(); diff --git a/Telegram/SourceFiles/history/view/history_view_message.cpp b/Telegram/SourceFiles/history/view/history_view_message.cpp index 154c69ccf0..3191780735 100644 --- a/Telegram/SourceFiles/history/view/history_view_message.cpp +++ b/Telegram/SourceFiles/history/view/history_view_message.cpp @@ -12,6 +12,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "base/options.h" #include "base/qt/qt_key_modifiers.h" #include "base/unixtime.h" +#include "core/application.h" #include "core/click_handler_types.h" // ClickHandlerContext #include "core/ui_integration.h" #include "history/view/history_view_cursor_state.h" @@ -29,6 +30,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "history/view/history_view_summary_header.h" #include "history/view/history_view_view_button.h" // ViewButton. #include "history/history.h" +#include "iv/iv_instance.h" #include "boxes/premium_preview_box.h" #include "boxes/share_box.h" #include "boxes/peers/tag_info_box.h" @@ -82,6 +84,167 @@ constexpr auto kLineHeightAppearDuration = crl::time(100); constexpr auto kLineHeightAppearFinalDuration = crl::time(60); constexpr auto kMinWidthAppearDuration = crl::time(160); +using PreparedLink = Iv::Markdown::PreparedLink; +using PreparedLinkKind = Iv::Markdown::PreparedLinkKind; +using MediaActivation = Iv::Markdown::MediaActivation; +using MediaActivationKind = Iv::Markdown::MediaActivationKind; + +[[nodiscard]] QString OpenableTargetForPreparedLink(const PreparedLink &link) { + return link.fragment.isEmpty() + ? link.target + : (link.target + u"#"_q + link.fragment); +} + +[[nodiscard]] std::optional ExternalEntityLinkData( + const PreparedLink &link) { + if (link.kind != PreparedLinkKind::External || link.target.isEmpty()) { + return std::nullopt; + } + switch (link.entityType) { + case EntityType::Url: + case EntityType::CustomUrl: + case EntityType::Email: + return EntityLinkData{ + .text = !link.copyText.isEmpty() ? link.copyText : link.target, + .data = link.target, + .type = link.entityType, + .shown = link.shown, + }; + default: + return std::nullopt; + } +} + +[[nodiscard]] ClickHandler::TextEntity TextEntityForPreparedLink( + const PreparedLink &link) { + if (const auto external = ExternalEntityLinkData(link)) { + return { + .type = external->type, + .data = external->data, + }; + } + return {}; +} + +[[nodiscard]] QString CopyTextForPreparedLink(const PreparedLink &link) { + if (const auto external = ExternalEntityLinkData(link)) { + return external->text; + } + switch (link.kind) { + case PreparedLinkKind::Anchor: + case PreparedLinkKind::FootnoteBacklink: + return link.target.isEmpty() ? QString() : (u"#"_q + link.target); + case PreparedLinkKind::Footnote: + return !link.copyText.isEmpty() ? link.copyText : link.target; + case PreparedLinkKind::LocalFile: + case PreparedLinkKind::InstantViewPage: + return OpenableTargetForPreparedLink(link); + case PreparedLinkKind::External: + return link.target; + case PreparedLinkKind::RejectedRelative: + case PreparedLinkKind::ToggleDetails: + return QString(); + } + return QString(); +} + +[[nodiscard]] QString CopyLabelForPreparedLink(const PreparedLink &link) { + if (const auto external = ExternalEntityLinkData(link)) { + return (external->type == EntityType::Email) + ? Ui::Integration::Instance().phraseContextCopyEmail() + : Ui::Integration::Instance().phraseContextCopyLink(); + } + switch (link.kind) { + case PreparedLinkKind::RejectedRelative: + case PreparedLinkKind::ToggleDetails: + return QString(); + case PreparedLinkKind::External: + case PreparedLinkKind::InstantViewPage: + case PreparedLinkKind::Anchor: + case PreparedLinkKind::Footnote: + case PreparedLinkKind::FootnoteBacklink: + case PreparedLinkKind::LocalFile: + return tr::lng_context_copy_link(tr::now); + } + return QString(); +} + +[[nodiscard]] bool SamePreparedLink( + const std::optional &a, + const std::optional &b) { + if (!a || !b) { + return !a && !b; + } + return (a->index == b->index) + && (a->kind == b->kind) + && (a->target == b->target) + && (a->fragment == b->fragment) + && (a->copyText == b->copyText) + && (a->entityType == b->entityType) + && (a->shown == b->shown) + && (a->webpageId == b->webpageId); +} + +[[nodiscard]] bool SameEmbedRequest( + const Iv::Markdown::EmbedRequest &a, + const Iv::Markdown::EmbedRequest &b) { + return (a.resourceId == b.resourceId) + && (a.fallbackUrl == b.fallbackUrl) + && (a.width == b.width) + && (a.height == b.height) + && (a.fullWidth == b.fullWidth) + && (a.fixedHeight == b.fixedHeight) + && (a.allowScrolling == b.allowScrolling); +} + +[[nodiscard]] bool SameMediaActivation( + const MediaActivation &a, + const MediaActivation &b) { + return (a.kind == b.kind) + && (a.url == b.url) + && SameEmbedRequest(a.embed, b.embed) + && (a.placeholderId.value == b.placeholderId.value) + && (a.photo.get() == b.photo.get()) + && (a.document.get() == b.document.get()) + && (a.channel.get() == b.channel.get()); +} + +class RichPageActionClickHandler final : public ClickHandler { +public: + RichPageActionClickHandler( + Fn callback, + std::optional link = std::nullopt) + : _callback(std::move(callback)) + , _link(std::move(link)) { + } + + void onClick(ClickContext context) const override { + if (_callback) { + _callback(std::move(context)); + } + } + + QString url() const override { + return _link ? _link->target : QString(); + } + + QString copyToClipboardText() const override { + return _link ? CopyTextForPreparedLink(*_link) : QString(); + } + + QString copyToClipboardContextItemText() const override { + return _link ? CopyLabelForPreparedLink(*_link) : QString(); + } + + TextEntity getTextEntity() const override { + return _link ? TextEntityForPreparedLink(*_link) : TextEntity(); + } + +private: + const Fn _callback; + const std::optional _link; +}; + void ApplyRevealGradient( not_null appearing, QImage &cache, @@ -318,11 +481,148 @@ Message::~Message() { } } +void HistoryMessageRichPage::Host::requestRepaint(QRect articleRect) { + if (const auto strong = owner.get()) { + crl::on_main(strong, [weak = owner, articleRect] { + if (const auto owner = weak.get()) { + owner->requestRichPageRepaint(articleRect); + } + }); + } +} + +void HistoryMessageRichPage::Host::requestRelayout(QRect articleRect) { + if (const auto strong = owner.get()) { + crl::on_main(strong, [weak = owner, articleRect] { + if (const auto owner = weak.get()) { + owner->requestRichPageRelayout(articleRect); + } + }); + } +} + void Message::setInstantViewMediaRuntime(QString pageUrl) { AddComponents(InstantViewMediaRuntime::Bit()); Get()->pageUrl = std::move(pageUrl); } +bool Message::hasRichPage() const { + return (richpage() != nullptr); +} + +void Message::requestRichPageRepaint(QRect articleRect) const { + Q_UNUSED(articleRect); + repaint(); +} + +void Message::requestRichPageRelayout(QRect articleRect) { + Q_UNUSED(articleRect); + if (const auto rich = const_cast(this)->richpage()) { + rich->article.invalidateLayout(); + } + setPendingResize(); + history()->owner().requestViewResize(this); +} + +void Message::activateRichPagePreparedLink( + const PreparedLink &link, + ClickContext context) const { + if (context.button != Qt::LeftButton + && context.button != Qt::MiddleButton) { + return; + } + switch (link.kind) { + case PreparedLinkKind::External: + if (const auto data = ExternalEntityLinkData(link)) { + if (const auto handler = Ui::Integration::Instance() + .createLinkHandler(*data, Ui::Text::MarkedContext())) { + handler->onClick(std::move(context)); + } + } + break; + case PreparedLinkKind::InstantViewPage: { + const auto target = OpenableTargetForPreparedLink(link); + if (target.isEmpty()) { + return; + } + if (const auto controller = ExtractController(context)) { + Core::App().iv().openWithIvPreferred( + controller, + target, + context.other); + } else { + UrlClickHandler::Open(target, context.other); + } + } break; + case PreparedLinkKind::Anchor: + case PreparedLinkKind::Footnote: + case PreparedLinkKind::FootnoteBacklink: + if (const auto controller = ExtractController(context)) { + Core::App().iv().showRichMessage(controller, data()); + } + break; + case PreparedLinkKind::LocalFile: { + const auto target = OpenableTargetForPreparedLink(link); + if (!target.isEmpty()) { + UrlClickHandler::Open(target, context.other); + } + } break; + case PreparedLinkKind::RejectedRelative: + break; + case PreparedLinkKind::ToggleDetails: + if (const auto rich = const_cast(this)->richpage() + ; rich && rich->article.toggleDetails(link.target)) { + const_cast(this)->requestRichPageRelayout(QRect()); + } + break; + } +} + +void Message::activateRichPageMedia( + const MediaActivation &activation, + ClickContext context) const { + if (context.button != Qt::LeftButton + && context.button != Qt::MiddleButton) { + return; + } + switch (activation.kind) { + case MediaActivationKind::None: + break; + case MediaActivationKind::Embed: + if (!activation.embed.fallbackUrl.isEmpty()) { + HiddenUrlClickHandler::Open( + activation.embed.fallbackUrl, + context.other); + } + break; + case MediaActivationKind::ExternalUrl: + if (!activation.url.isEmpty()) { + HiddenUrlClickHandler::Open(activation.url, context.other); + } + break; + case MediaActivationKind::Photo: + if (activation.photo) { + activation.photo->open(context.button); + } + break; + case MediaActivationKind::Document: + if (activation.document) { + activation.document->open(context.button); + } + break; + case MediaActivationKind::OpenChannel: + if (activation.channel) { + activation.channel->open(context.button); + } + break; + case MediaActivationKind::JoinChannel: + if (activation.channel) { + activation.channel->join(context.button); + } + break; + } +} + void Message::refreshSuggestedInfo( not_null item, not_null suggest, @@ -834,7 +1134,11 @@ QSize Message::performCountOptimalSize() { maxWidth = std::max(maxWidth, st::msgMaxWidth); accumulate_max(nonTextMax, st::msgMaxWidth); } - minHeight = withVisibleText ? text().minHeight() : 0; + minHeight = withVisibleText + ? hasRichPage() + ? textHeightFor(bubbleTextWidth(textualWidth)) + : text().minHeight() + : 0; if (reactionsInBubble) { const auto reactionsMaxWidth = st::msgPadding.left() + _reactions->maxWidth() @@ -994,13 +1298,22 @@ QSize Message::performCountOptimalSize() { } } if (bubble && withVisibleText && maxWidth < fullTextualWidth) { - minHeight -= text().minHeight(); + minHeight -= hasRichPage() + ? textHeightFor(bubbleTextWidth(bubbleTextualWidth())) + : text().minHeight(); minHeight += textHeightFor(bubbleTextWidth(maxWidth)); } if (const auto appearing = Get()) { - appearing->geometryValid = false; - appearing->startedForText = false; - appearing->finalizing = item->isRegular(); + if (hasRichPage()) { + appearing->widthAnimation.stop(); + appearing->heightAnimation.stop(); + appearing->use = false; + appearing->geometryValid = false; + } else { + appearing->geometryValid = false; + appearing->startedForText = false; + appearing->finalizing = item->isRegular(); + } } return QSize(maxWidth, minHeight); } @@ -2297,6 +2610,37 @@ void Message::paintText( && !context.gestureHorizontal.translation) { return; } + if (const auto rich = const_cast(this)->richpage()) { + const auto clip = QRect( + QPoint(), + trect.size()).intersected( + context.clip.translated(-trect.topLeft())); + rich->article.setVisibleTopBottom( + std::clamp(clip.top(), 0, trect.height()), + std::clamp(clip.bottom() + 1, 0, trect.height())); + p.save(); + p.translate(trect.topLeft()); + rich->article.paint(p, clip, { + .pre = stm->preCache.get(), + .blockquote = context.quoteCache( + contentColorCollectible(), + contentColorIndex()), + .colors = context.st->highlightColors(), + .repaint = [weak = base::make_weak(const_cast(this))] { + if (const auto owner = weak.get()) { + owner->requestRichPageRepaint(QRect()); + } + }, + .repaintRect = [weak = base::make_weak(const_cast(this))]( + QRect articleRect) { + if (const auto owner = weak.get()) { + owner->requestRichPageRepaint(articleRect); + } + }, + }); + p.restore(); + return; + } prepareCustomEmojiPaint(p, context, text()); const auto rippleLinkRange = (_linkRipple && _linkRipple->link) @@ -2577,6 +2921,17 @@ void Message::clickHandlerPressedChanged( && IsRippleLink(handler) && !text().linkRangeFor(handler).empty()) { startLinkRipple(); + } else if (const auto rich = richpage() + ; rich && (handler == rich->handler)) { + if (rich->handlerPlaceholderId) { + if (pressed) { + rich->article.addPlaceholderRipple( + rich->handlerPlaceholderId, + rich->handlerPlaceholderPoint); + } else { + rich->article.stopPlaceholderRipple(rich->handlerPlaceholderId); + } + } } else if (_reactions) { _reactions->clickHandlerPressedChanged( handler, @@ -3711,14 +4066,64 @@ bool Message::getStateText( } const auto item = this->textItem(); if (base::in_range(point.y(), trect.y(), trect.y() + trect.height())) { - *outResult = TextState(item, text().getState( - point - trect.topLeft(), - std::max(textRealWidth(), trect.width()), - request.forText())); - if (outResult->link - && IsRippleLink(outResult->link) - && !text().linkRangeFor(outResult->link).empty()) { - recordLinkRipplePoint(point, trect.topLeft()); + if (const auto rich = richpage()) { + const auto local = point - trect.topLeft(); + const auto hit = rich->article.hitTest( + local, + request.flags | Ui::Text::StateRequest::Flag::LookupSymbol); + if (!hit.valid()) { + return false; + } + *outResult = TextState(item); + outResult->cursor = CursorState::None; + outResult->symbol = FullSelection.from; + outResult->afterSymbol = false; + if (hit.preparedLink || hit.mediaActivation.kind != MediaActivationKind::None) { + const auto prepared = hit.preparedLink; + const auto activation = hit.mediaActivation; + const auto reuse = SamePreparedLink( + rich->handlerPreparedLink, + prepared) + && SameMediaActivation( + rich->handlerMediaActivation, + activation); + rich->handlerPlaceholderId = hit.mediaActivation.placeholderId; + rich->handlerPlaceholderPoint = hit.placeholderLocalPoint; + if (!reuse) { + rich->handlerPreparedLink = prepared; + rich->handlerMediaActivation = activation; + rich->handler = std::make_shared( + [weak = base::make_weak(const_cast(this)), + prepared, + activation](ClickContext context) { + if (const auto owner = weak.get()) { + if (prepared) { + owner->activateRichPagePreparedLink( + *prepared, + std::move(context)); + } else { + owner->activateRichPageMedia( + activation, + std::move(context)); + } + } + }, + prepared); + } + outResult->link = rich->handler; + } else { + outResult->link = hit.state.link; + } + } else { + *outResult = TextState(item, text().getState( + point - trect.topLeft(), + std::max(textRealWidth(), trect.width()), + request.forText())); + if (outResult->link + && IsRippleLink(outResult->link) + && !text().linkRangeFor(outResult->link).empty()) { + recordLinkRipplePoint(point, trect.topLeft()); + } } return true; } @@ -4243,7 +4648,13 @@ void Message::refreshDataIdHook() { } int Message::monospaceMaxWidth() const { - const auto fromText = hasVisibleText() + const auto fromText = hasRichPage() + ? std::max( + textualMaxWidth() + - st::msgPadding.left() + - st::msgPadding.right(), + richpage()->article.lastLayoutWidth()) + : hasVisibleText() ? text().countMaxMonospaceWidth() : 0; const auto fromMedia = this->media() @@ -4262,6 +4673,14 @@ int Message::bubbleTextWidth(int bubbleWidth) const { int Message::bubbleTextualWidth() const { const auto full = textualMaxWidth(); + if (hasRichPage()) { + const auto innerWidth = bubbleTextWidth(full); + [[maybe_unused]] const auto laidOutHeight = textHeightFor(innerWidth); + const auto laidOutWidth = richpage()->article.lastLayoutWidth(); + return st::msgPadding.left() + + std::max(laidOutWidth, 1) + + st::msgPadding.right(); + } const auto media = this->media(); if (!hasVisibleText() || !media @@ -5270,7 +5689,7 @@ int Message::resizeContentGetHeight(int newWidth) { if (!mediaDisplayed && bubble && hasVisibleText()) { const auto probeTextWidth = bubbleTextWidth(contentWidth); [[maybe_unused]] const auto probe = textHeightFor(probeTextWidth); - if (!Get()) { + if (!Get() || hasRichPage()) { const auto use = textRealWidth(); if (use > 0) { const auto shrunk = std::max( @@ -5288,6 +5707,9 @@ int Message::resizeContentGetHeight(int newWidth) { : bottomInfoWidth; auto appearing = Get(); + if (hasRichPage()) { + appearing = nullptr; + } if (appearing) { if (appearing->textWidth != textWidth) { appearing->geometryValid = false; @@ -5469,6 +5891,13 @@ void Message::invalidateTextDependentCache() { } bool Message::textAppearValidate(not_null appearing) { + if (hasRichPage()) { + appearing->widthAnimation.stop(); + appearing->heightAnimation.stop(); + appearing->use = false; + RemoveComponents(TextAppearing::Bit()); + return false; + } while (true) { if (!textAppearCheckLine(appearing)) { return false; @@ -5682,13 +6111,16 @@ bool Message::hasVisibleText() const { const auto textItem = this->textItem(); if (!textItem) { return false; + } + const auto media = this->media(); + if (hasRichPage()) { + return !media || !media->hideMessageText(); } else if (textItem->emptyText()) { if (const auto media = textItem->media()) { return media->storyExpired() || media->storyUnsupported(); } return false; } - const auto media = this->media(); return !media || !media->hideMessageText(); } diff --git a/Telegram/SourceFiles/history/view/history_view_message.h b/Telegram/SourceFiles/history/view/history_view_message.h index 9506960c9b..36342a5317 100644 --- a/Telegram/SourceFiles/history/view/history_view_message.h +++ b/Telegram/SourceFiles/history/view/history_view_message.h @@ -9,6 +9,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "history/view/history_view_element.h" #include "history/view/history_view_bottom_info.h" +#include "iv/markdown/iv_markdown_article.h" #include "ui/effects/animations.h" class HistoryItem; @@ -32,6 +33,7 @@ namespace HistoryView { class ViewButton; class WebPage; class TranscribeButton; +class Message; namespace Reactions { class InlineList; @@ -68,6 +70,26 @@ struct InstantViewMediaRuntime QString pageUrl; }; +struct HistoryMessageRichPage +: RuntimeComponent { + struct Host final : Iv::Markdown::MediaBlockHost { + base::weak_ptr owner; + + void requestRepaint(QRect articleRect) override; + void requestRelayout(QRect articleRect) override; + }; + + std::shared_ptr page; + std::shared_ptr mediaRuntime; + Iv::Markdown::MarkdownArticle article; + Host host; + mutable ClickHandlerPtr handler; + mutable std::optional handlerPreparedLink; + mutable Iv::Markdown::MediaActivation handlerMediaActivation; + mutable Iv::Markdown::PreparedPlaceholderBlockId handlerPlaceholderId; + mutable QPoint handlerPlaceholderPoint; +}; + enum class BadgeRole : uchar { User, Admin, @@ -221,6 +243,9 @@ public: [[nodiscard]] BottomRippleMask bottomRippleMask(int buttonHeight) const; void setInstantViewMediaRuntime(QString pageUrl); + [[nodiscard]] bool hasRichPage() const; + void requestRichPageRepaint(QRect articleRect) const; + void requestRichPageRelayout(QRect articleRect); private: struct CommentsButton; @@ -394,6 +419,12 @@ private: void updateViewButtonExistence(); [[nodiscard]] int viewButtonHeight() const; + void activateRichPagePreparedLink( + const Iv::Markdown::PreparedLink &link, + ClickContext context) const; + void activateRichPageMedia( + const Iv::Markdown::MediaActivation &activation, + ClickContext context) const; [[nodiscard]] WebPage *logEntryOriginal() const; [[nodiscard]] WebPage *factcheckBlock() const; diff --git a/Telegram/SourceFiles/iv/iv_data.cpp b/Telegram/SourceFiles/iv/iv_data.cpp index 9e3b4c648d..ebdaf57364 100644 --- a/Telegram/SourceFiles/iv/iv_data.cpp +++ b/Telegram/SourceFiles/iv/iv_data.cpp @@ -8,6 +8,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "iv/iv_data.h" #include "iv/iv_prepare.h" +#include "iv/iv_rich_page.h" #include "core/cached_webview_availability.h" #include @@ -45,44 +46,79 @@ Geo GeoPointFromId(QByteArray data) { }; } -Data::Data(const MTPDwebPage &webpage, const MTPPage &page) -: _source(std::make_unique(Source{ - .pageId = webpage.vid().v, - .page = page, - .webpagePhoto = (webpage.vphoto() - ? *webpage.vphoto() - : std::optional()), - .webpageDocument = (webpage.vdocument() - ? *webpage.vdocument() - : std::optional()), - .name = (webpage.vsite_name() - ? qs(*webpage.vsite_name()) - : SiteNameFromUrl(qs(webpage.vurl()))) -})) { +Data::Data( + const MTPDwebPage &webpage, + const MTPPage &page, + std::shared_ptr richPage, + const PhotoData *webpagePhoto, + const DocumentData *webpageDocument) +: _pageId(webpage.vid().v) +, _url(qs(webpage.vurl())) +, _name(webpage.vsite_name() + ? qs(*webpage.vsite_name()) + : SiteNameFromUrl(_url)) +, _partial(page.match([](const MTPDpage &data) { + return data.is_part(); + }, [](const auto &) { + return false; + })) +, _richPage(std::move(richPage)) +, _webpagePhoto(webpagePhoto) +, _webpageDocument(webpageDocument) +, _pageFallback(page) +, _webpagePhotoFallback(webpage.vphoto() + ? std::optional(*webpage.vphoto()) + : std::optional()) +, _webpageDocumentFallback(webpage.vdocument() + ? std::optional(*webpage.vdocument()) + : std::optional()) { } QString Data::id() const { - return qs(_source->page.data().vurl()); + return _url; } bool Data::partial() const { - return _source->page.data().is_part(); + return _partial; } Data::~Data() = default; -const Source &Data::source() const { - return *_source; +auto Data::richPage() const -> const std::shared_ptr & { + return _richPage; +} + +auto Data::sourceFallback() const -> std::optional { + if (!_pageFallback) { + return std::nullopt; + } + return Source{ + .pageId = _pageId, + .richPage = _richPage, + .hasRawPage = true, + .page = *_pageFallback, + .webpagePhoto = _webpagePhotoFallback, + .webpageDocument = _webpageDocumentFallback, + .name = _name, + .updatedCachedViews = _updatedCachedViews, + }; } void Data::updateCachedViews(int cachedViews) { - _source->updatedCachedViews = std::max( - _source->updatedCachedViews, - cachedViews); + _updatedCachedViews = std::max(_updatedCachedViews, cachedViews); } void Data::prepare(const Options &options, Fn done) const { - crl::async([source = *_source, options, done = std::move(done)] { + crl::async([source = Source{ + .pageId = _pageId, + .richPage = _richPage, + .hasRawPage = _pageFallback.has_value(), + .page = _pageFallback.value_or(MTPPage()), + .webpagePhoto = _webpagePhotoFallback, + .webpageDocument = _webpageDocumentFallback, + .name = _name, + .updatedCachedViews = _updatedCachedViews, + }, options, done = std::move(done)]() mutable { done(Prepare(source, options)); }); } diff --git a/Telegram/SourceFiles/iv/iv_data.h b/Telegram/SourceFiles/iv/iv_data.h index 6bdd3a9834..220bc3b129 100644 --- a/Telegram/SourceFiles/iv/iv_data.h +++ b/Telegram/SourceFiles/iv/iv_data.h @@ -7,9 +7,16 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #pragma once -namespace Iv { +#include "iv/iv_prepare.h" -struct Source; +#include +#include + +class DocumentData; +class PhotoData; + +namespace Iv { +struct RichPage; struct Options { }; @@ -39,19 +46,36 @@ struct Geo { class Data final { public: - Data(const MTPDwebPage &webpage, const MTPPage &page); + Data( + const MTPDwebPage &webpage, + const MTPPage &page, + std::shared_ptr richPage, + const PhotoData *webpagePhoto, + const DocumentData *webpageDocument); ~Data(); [[nodiscard]] QString id() const; [[nodiscard]] bool partial() const; - [[nodiscard]] const Source &source() const; + [[nodiscard]] auto richPage() const + -> const std::shared_ptr &; + [[nodiscard]] auto sourceFallback() const -> std::optional; void updateCachedViews(int cachedViews); void prepare(const Options &options, Fn done) const; private: - const std::unique_ptr _source; + const uint64 _pageId = 0; + const QString _url; + const QString _name; + const bool _partial = false; + const std::shared_ptr _richPage; + const PhotoData *_webpagePhoto = nullptr; + const DocumentData *_webpageDocument = nullptr; + const std::optional _pageFallback; + const std::optional _webpagePhotoFallback; + const std::optional _webpageDocumentFallback; + int _updatedCachedViews = 0; }; diff --git a/Telegram/SourceFiles/iv/iv_instance.cpp b/Telegram/SourceFiles/iv/iv_instance.cpp index ee0a0d448f..041164592b 100644 --- a/Telegram/SourceFiles/iv/iv_instance.cpp +++ b/Telegram/SourceFiles/iv/iv_instance.cpp @@ -181,7 +181,7 @@ struct LocalMarkdownTarget { } [[nodiscard]] QString RichMessageKey(FullMsgId itemId) { - return u"internal://rich_text?peer=%1&msg=%2"_q + return u"rich-message:%1:%2"_q .arg(itemId.peer.value) .arg(itemId.msg.bare); } @@ -190,37 +190,6 @@ struct LocalMarkdownTarget { return uint64(itemId.peer.value) ^ (uint64(itemId.msg.bare) << 1); } -[[nodiscard]] MTPPage RichMessagePage( - const QString &url, - const MTPDrichMessage &data) { - auto flags = MTPDpage::Flags(); - flags |= MTPDpage::Flag::f_v2; - if (data.is_part()) { - flags |= MTPDpage::Flag::f_part; - } - if (data.is_rtl()) { - flags |= MTPDpage::Flag::f_rtl; - } - return MTP_page( - MTP_flags(flags), - MTP_string(url), - MTP_vector(data.vblocks().v), - MTP_vector(data.vphotos().v), - MTP_vector(data.vdocuments().v), - MTP_int(0)); -} - -void ProcessRichMessageMedia( - not_null session, - const MTPDrichMessage &data) { - for (const auto &photo : data.vphotos().v) { - session->data().processPhoto(photo); - } - for (const auto &document : data.vdocuments().v) { - session->data().processDocument(document); - } -} - void OpenRichMessageChannel( not_null session, const QString &context) { @@ -407,7 +376,11 @@ private: QString initialFragment, not_null page); - void showWindowed(Prepared result, Source source, bool refresh); + void showWindowed( + Prepared result, + std::shared_ptr richPage, + std::optional sourceFallback, + bool refresh); void showHtmlWindowed(Prepared result, bool refresh); void showMarkdownWindowed( Markdown::MarkdownArticleContent content, @@ -591,13 +564,18 @@ Shown::Shown( void Shown::prepare(not_null data, const QString &hash) { const auto weak = base::make_weak(this); - const auto source = data->source(); + const auto richPage = data->richPage(); + const auto sourceFallback = data->sourceFallback(); _preparing = true; const auto id = _id = data->id(); - data->prepare({}, [=, source = source](Prepared result) { + data->prepare({}, [=, richPage = richPage, sourceFallback = sourceFallback]( + Prepared result) { result.hash = hash; - crl::on_main(weak, [=, source = source, result = std::move(result)]() mutable { + crl::on_main(weak, [=, + richPage = richPage, + sourceFallback = sourceFallback, + result = std::move(result)]() mutable { result.url = id; if (_id != id || !_preparing) { return; @@ -605,7 +583,11 @@ void Shown::prepare(not_null data, const QString &hash) { _preparing = false; fillChannelJoinedValues(result); fillEmbeds(std::move(result.embeds)); - showWindowed(std::move(result), source, false); + showWindowed( + std::move(result), + richPage, + sourceFallback, + false); }); }); } @@ -823,11 +805,16 @@ void Shown::createMarkdownController( }, _markdownController->lifetime()); } -void Shown::showWindowed(Prepared result, Source source, bool refresh) { +void Shown::showWindowed( + Prepared result, + std::shared_ptr richPage, + std::optional sourceFallback, + bool refresh) { if constexpr (true) { const auto page = _session->data().webpage(result.pageId); auto native = Markdown::TryPrepareNativeInstantView({ - .source = &source, + .source = sourceFallback ? &*sourceFallback : nullptr, + .richPage = std::move(richPage), .mediaRuntime = createMediaRuntime(page), }); showMarkdownWindowed( @@ -837,7 +824,8 @@ void Shown::showWindowed(Prepared result, Source source, bool refresh) { page, refresh); } else { - Q_UNUSED(source); + Q_UNUSED(richPage); + Q_UNUSED(sourceFallback); showHtmlWindowed(std::move(result), refresh); } } @@ -1282,15 +1270,24 @@ void Shown::moveTo(not_null data, QString hash) { void Shown::update(not_null data) { const auto weak = base::make_weak(this); - const auto source = data->source(); + const auto richPage = data->richPage(); + const auto sourceFallback = data->sourceFallback(); const auto id = data->id(); - data->prepare({}, [=, source = source](Prepared result) { - crl::on_main(weak, [=, source = source, result = std::move(result)]() mutable { + data->prepare({}, [=, richPage = richPage, sourceFallback = sourceFallback]( + Prepared result) { + crl::on_main(weak, [=, + richPage = richPage, + sourceFallback = sourceFallback, + result = std::move(result)]() mutable { result.url = id; fillChannelJoinedValues(result); fillEmbeds(std::move(result.embeds)); - showWindowed(std::move(result), source, true); + showWindowed( + std::move(result), + richPage, + sourceFallback, + true); }); }); } @@ -1740,8 +1737,8 @@ void Instance::showTonSite( void Instance::showRichMessage( not_null controller, not_null item) { - const auto richMessage = item->Get(); - if (!richMessage) { + const auto richPage = item->richPage(); + if (!richPage) { Ui::Toast::Show(tr::lng_iv_not_supported(tr::now)); return; } else if (Platform::IsMac()) { @@ -1751,14 +1748,6 @@ void Instance::showRichMessage( const auto itemId = item->fullId(); const auto key = RichMessageKey(itemId); const auto title = item->history()->peer->name(); - const auto &data = richMessage->data.data(); - ProcessRichMessageMedia(session, data); - - auto source = Source{ - .pageId = RichMessagePageId(itemId), - .page = RichMessagePage(key, data), - .name = title, - }; auto clickHandlerContext = ClickHandlerContext(); clickHandlerContext.sessionWindow = controller; clickHandlerContext.itemId = itemId; @@ -1773,7 +1762,7 @@ void Instance::showRichMessage( JoinRichMessageChannel(session, context); }); auto prepared = Markdown::TryPrepareNativeInstantView({ - .source = &source, + .richPage = richPage, .mediaRuntime = std::move(mediaRuntime), }); if (!prepared.supported()) { @@ -1782,7 +1771,7 @@ void Instance::showRichMessage( } auto options = Markdown::OpenOptions{ .sourceName = title, - .currentPageId = source.pageId, + .currentPageId = RichMessagePageId(itemId), .viewerKind = Markdown::ViewerKind::InstantView, .clickHandlerContext = context, .activateMedia = [=]( diff --git a/Telegram/SourceFiles/iv/iv_prepare.cpp b/Telegram/SourceFiles/iv/iv_prepare.cpp index bf711e3c14..14bb412d92 100644 --- a/Telegram/SourceFiles/iv/iv_prepare.cpp +++ b/Telegram/SourceFiles/iv/iv_prepare.cpp @@ -10,6 +10,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "base/openssl_help.h" #include "base/unixtime.h" #include "iv/iv_data.h" +#include "iv/iv_rich_page.h" #include "lang/lang_keys.h" #include "ui/image/image_prepare.h" #include "ui/grouped_layout.h" @@ -41,6 +42,9 @@ struct Document { QByteArray minithumbnail; }; +using Block = RichPage::Block; +using BlockKind = RichPage::BlockKind; + template >> [[nodiscard]] QByteArray Number(T value) { return QByteArray::number(value); @@ -71,6 +75,85 @@ template >> return Escape(langDateTimeFull(base::unixtime::parse(date)).toUtf8()); } +[[nodiscard]] QByteArray EmbedResourceId(const QByteArray &html) { + auto binary = std::array{}; + SHA256( + reinterpret_cast(html.data()), + html.size(), + binary.data()); + const auto hex = [](uchar value) -> char { + return (value >= 10) ? ('a' + (value - 10)) : ('0' + value); + }; + auto result = QByteArray(); + result.reserve(binary.size() * 2); + for (const auto byte : binary) { + result.push_back(hex(byte / 16)); + result.push_back(hex(byte % 16)); + } + return result + ".html"; +} + +void CollectPreparedMetadata( + const std::vector &blocks, + Prepared *result) { + for (const auto &block : blocks) { + switch (block.kind) { + case BlockKind::Code: + result->hasCode = result->hasCode || !block.language.isEmpty(); + break; + case BlockKind::Embed: + result->hasEmbeds = true; + if (!block.html.isEmpty()) { + result->embeds.emplace( + EmbedResourceId(block.html.toUtf8()), + block.html.toUtf8()); + } + break; + case BlockKind::Channel: + if (block.channelId) { + result->channelIds.emplace(QByteArray::number(block.channelId)); + } + break; + default: + break; + } + if (!block.blocks.empty()) { + CollectPreparedMetadata(block.blocks, result); + } + for (const auto &item : block.listItems) { + if (!item.blocks.empty()) { + CollectPreparedMetadata(item.blocks, result); + } + } + } +} + +[[nodiscard]] QByteArray WrapSummaryContent( + const RichPage &page, + int views) { + const auto summary = FlattenRichPageSummary(page); + const auto sep = " \xE2\x80\xA2 "; + const auto viewsText = views + ? (tr::lng_stories_views(tr::now, lt_count_decimal, views) + sep) + : QString(); + const auto inner = summary.text.trimmed().isEmpty() + ? QByteArray() + : ("

" + Escape(summary.text.toUtf8()) + "

"); + return R"( +
+
)"_q + inner + R"(
+
+)"_q; +} + class Parser final { public: Parser(const Source &source, const Options &options); @@ -229,6 +312,7 @@ private: Parser::Parser(const Source &source, const Options &options) : /*_options(options) , */_fileOriginPostfix('/' + Number(source.pageId)) { + Expects(source.hasRawPage); process(source); _result.pageId = source.pageId; _result.name = source.name; @@ -1218,21 +1302,7 @@ QByteArray Parser::documentFullUrl(const Document &document) { } QByteArray Parser::embedUrl(const QByteArray &html) { - auto binary = std::array{}; - SHA256( - reinterpret_cast(html.data()), - html.size(), - binary.data()); - const auto hex = [](uchar value) -> char { - return (value >= 10) ? ('a' + (value - 10)) : ('0' + value); - }; - auto result = QByteArray(); - result.reserve(binary.size() * 2); - for (const auto byte : binary) { - result.push_back(hex(byte / 16)); - result.push_back(hex(byte % 16)); - } - result += ".html"; + auto result = EmbedResourceId(html); _result.embeds.emplace(result, html); return resource("html/" + result); } @@ -1306,8 +1376,26 @@ QSize Parser::computeSlideshowDimensions( } // namespace Prepared Prepare(const Source &source, const Options &options) { - auto parser = Parser(source, options); - return parser.result(); + auto result = source.hasRawPage + ? Parser(source, options).result() + : Prepared(); + if (source.richPage) { + result.pageId = source.pageId; + result.name = source.name; + result.rtl = source.richPage->rtl; + result.hasCode = false; + result.hasEmbeds = false; + result.embeds.clear(); + result.channelIds.clear(); + CollectPreparedMetadata(source.richPage->blocks, &result); + if (!source.hasRawPage) { + const auto views = std::max( + source.richPage->views, + source.updatedCachedViews); + result.content = WrapSummaryContent(*source.richPage, views); + } + } + return result; } } // namespace Iv diff --git a/Telegram/SourceFiles/iv/iv_prepare.h b/Telegram/SourceFiles/iv/iv_prepare.h index ef919db2f7..51d6fc2960 100644 --- a/Telegram/SourceFiles/iv/iv_prepare.h +++ b/Telegram/SourceFiles/iv/iv_prepare.h @@ -7,13 +7,19 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #pragma once +#include +#include + namespace Iv { struct Options; struct Prepared; +struct RichPage; struct Source { uint64 pageId = 0; + std::shared_ptr richPage; + bool hasRawPage = true; MTPPage page; std::optional webpagePhoto; std::optional webpageDocument; diff --git a/Telegram/SourceFiles/iv/iv_rich_page.cpp b/Telegram/SourceFiles/iv/iv_rich_page.cpp new file mode 100644 index 0000000000..9fd8729256 --- /dev/null +++ b/Telegram/SourceFiles/iv/iv_rich_page.cpp @@ -0,0 +1,1216 @@ +/* +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 "iv/iv_rich_page.h" + +#include "base/flat_map.h" +#include "base/unixtime.h" +#include "data/data_peer.h" +#include "data/data_session.h" +#include "data/data_user.h" +#include "data/stickers/data_custom_emoji.h" +#include "iv/markdown/iv_markdown_prepare.h" +#include "iv/markdown/iv_markdown_prepare_links.h" +#include "lang/lang_keys.h" +#include "main/main_session.h" +#include "ui/text/text_utilities.h" + +#include + +namespace Iv { +namespace { + +using Block = RichPage::Block; +using BlockKind = RichPage::BlockKind; +using GroupedMediaIntent = RichPage::GroupedMediaIntent; +using GroupedMediaItem = RichPage::GroupedMediaItem; +using ListItem = RichPage::ListItem; +using ListKind = RichPage::ListKind; +using RelatedArticle = RichPage::RelatedArticle; +using RichLink = RichPage::RichLink; +using RichText = RichPage::RichText; +using TableAlignment = RichPage::TableAlignment; +using TableCell = RichPage::TableCell; +using TableRow = RichPage::TableRow; +using TableVerticalAlignment = RichPage::TableVerticalAlignment; +using TaskState = RichPage::TaskState; + +[[nodiscard]] auto MakeBlock(BlockKind kind) -> Block { + auto result = Block(); + result.kind = kind; + return result; +} + +[[nodiscard]] auto MakeHeadingBlock(int level) -> Block { + auto result = MakeBlock(BlockKind::Heading); + result.headingLevel = level; + return result; +} + +struct ParseContext { + not_null session; + base::flat_map photos; + base::flat_map documents; + base::flat_map photoSizes; + struct DocumentInfo { + int width = 0; + int height = 0; + QString fileName; + QString title; + QString performer; + int duration = 0; + bool isVideoFile = false; + bool isAnimation = false; + }; + base::flat_map documentInfos; +}; + +[[nodiscard]] auto DateText(TimeId date) -> QString { + return langDateTimeFull(base::unixtime::parse(date)); +} + +[[nodiscard]] auto AddEntity( + TextWithEntities *text, + int from, + EntityType type, + const QString &data = QString()) -> bool { + const auto length = text->text.size() - from; + if (length <= 0) { + return true; + } + text->entities.push_back(EntityInText(type, from, length, data)); + return true; +} + +void AddRichLink( + RichText *text, + int from, + QString target, + uint64 webpageId = 0) { + const auto length = text->text.text.size() - from; + if (length <= 0 || target.isEmpty()) { + return; + } + text->links.push_back({ + .offset = from, + .length = length, + .target = std::move(target), + .webpageId = webpageId, + }); +} + +void AppendRich(RichText *to, const RichText &from) { + const auto shift = to->text.text.size(); + to->text.append(from.text); + to->links.reserve(to->links.size() + from.links.size()); + for (auto link : from.links) { + link.offset += shift; + to->links.push_back(std::move(link)); + } +} + +void AppendRich(RichText *to, RichText &&from) { + const auto shift = to->text.text.size(); + to->text.append(std::move(from.text)); + to->links.reserve(to->links.size() + from.links.size()); + for (auto &link : from.links) { + link.offset += shift; + to->links.push_back(std::move(link)); + } +} + +[[nodiscard]] auto MentionNameEntityData( + not_null session, + uint64 userId) -> QString { + if (userId == 0) { + return QString(); + } + const auto loaded = session->data().userLoaded(UserId(userId)); + return TextUtilities::MentionNameDataFromFields({ + .selfId = session->userId().bare, + .userId = userId, + .accessHash = loaded ? loaded->accessHash() : 0, + }); +} + +[[nodiscard]] auto TaskStateFromFlags( + bool checkbox, + bool checked) -> TaskState { + if (!checkbox) { + return TaskState::None; + } + return checked ? TaskState::Checked : TaskState::Unchecked; +} + +[[nodiscard]] auto PhotoIdFromMtp(const MTPPhoto &photo) -> uint64 { + return photo.match([](const auto &data) { + return uint64(data.vid().v); + }); +} + +[[nodiscard]] auto DocumentIdFromMtp(const MTPDocument &document) -> uint64 { + return document.match([](const auto &data) { + return uint64(data.vid().v); + }); +} + +[[nodiscard]] auto PhotoSizeFromMtp(const MTPPhoto &photo) -> QSize { + auto result = QSize(); + photo.match([](const MTPDphotoEmpty &) { + }, [&](const MTPDphoto &data) { + const auto assign = [&](const QString &type, int width, int height) { + if (result.isEmpty() && (type == u"x"_q || type == u"w"_q)) { + result = QSize(width, height); + } + if (type == u"y"_q) { + result = QSize(width, height); + } + }; + for (const auto &size : data.vsizes().v) { + size.match([](const MTPDphotoSizeEmpty &) { + }, [&](const MTPDphotoSize &row) { + assign(qs(row.vtype()), row.vw().v, row.vh().v); + }, [&](const MTPDphotoCachedSize &row) { + assign(qs(row.vtype()), row.vw().v, row.vh().v); + }, [&](const MTPDphotoStrippedSize &) { + }, [&](const MTPDphotoSizeProgressive &row) { + assign(qs(row.vtype()), row.vw().v, row.vh().v); + }, [&](const MTPDphotoPathSize &) { + }); + } + }); + return result; +} + +[[nodiscard]] auto DocumentInfoFromMtp(const MTPDocument &document) +-> ParseContext::DocumentInfo { + auto result = ParseContext::DocumentInfo(); + document.match([](const MTPDdocumentEmpty &) { + }, [&](const MTPDdocument &data) { + for (const auto &attribute : data.vattributes().v) { + attribute.match([&](const MTPDdocumentAttributeAudio &row) { + result.duration = row.vduration().v; + result.title = qs(row.vtitle().value_or_empty()); + result.performer = qs(row.vperformer().value_or_empty()); + }, [&](const MTPDdocumentAttributeFilename &row) { + result.fileName = qs(row.vfile_name()); + }, [&](const MTPDdocumentAttributeImageSize &row) { + if (result.width <= 0 || result.height <= 0) { + result.width = row.vw().v; + result.height = row.vh().v; + } + }, [&](const MTPDdocumentAttributeAnimated &) { + result.isAnimation = true; + }, [&](const MTPDdocumentAttributeVideo &row) { + result.isVideoFile = true; + result.width = row.vw().v; + result.height = row.vh().v; + }, [&](const auto &) { + }); + } + }); + return result; +} + +void RememberPhoto(ParseContext *context, const MTPPhoto &photo) { + const auto id = PhotoIdFromMtp(photo); + context->photos.emplace( + id, + context->session->data().processPhoto(photo).get()); + context->photoSizes.emplace(id, PhotoSizeFromMtp(photo)); +} + +void RememberDocument(ParseContext *context, const MTPDocument &document) { + const auto id = DocumentIdFromMtp(document); + context->documents.emplace( + id, + context->session->data().processDocument(document).get()); + context->documentInfos.emplace(id, DocumentInfoFromMtp(document)); +} + +[[nodiscard]] auto FindPhoto( + const ParseContext &context, + uint64 id) -> PhotoData* { + const auto i = context.photos.find(id); + return (i != end(context.photos)) ? i->second : nullptr; +} + +[[nodiscard]] auto FindDocument( + const ParseContext &context, + uint64 id) -> DocumentData* { + const auto i = context.documents.find(id); + return (i != end(context.documents)) ? i->second : nullptr; +} + +[[nodiscard]] auto FindPhotoSize( + const ParseContext &context, + uint64 id) -> QSize { + const auto i = context.photoSizes.find(id); + return (i != end(context.photoSizes)) ? i->second : QSize(); +} + +[[nodiscard]] auto FindDocumentInfo( + const ParseContext &context, + uint64 id) -> ParseContext::DocumentInfo { + const auto i = context.documentInfos.find(id); + return (i != end(context.documentInfos)) + ? i->second + : ParseContext::DocumentInfo(); +} + +[[nodiscard]] auto TableCellAlignment( + const MTPDpageTableCell &data) -> TableAlignment { + if (data.is_align_right()) { + return TableAlignment::Right; + } else if (data.is_align_center()) { + return TableAlignment::Center; + } + return TableAlignment::Left; +} + +[[nodiscard]] auto TableCellVerticalAlignment( + const MTPDpageTableCell &data) -> TableVerticalAlignment { + if (data.is_valign_bottom()) { + return TableVerticalAlignment::Bottom; + } else if (data.is_valign_middle()) { + return TableVerticalAlignment::Middle; + } + return TableVerticalAlignment::Top; +} + +[[nodiscard]] auto ParseRichText( + const MTPRichText &text, + ParseContext *context) -> RichText; + +[[nodiscard]] auto ParseCaption( + const MTPPageCaption &caption, + ParseContext *context) -> RichText; + +[[nodiscard]] auto AppendRichText( + const MTPRichText &text, + RichText *result, + ParseContext *context, + QString *anchorId) -> bool { + return text.match([&](const MTPDtextEmpty &) { + return true; + }, [&](const MTPDtextPlain &data) { + result->text.append(qs(data.vtext())); + return true; + }, [&](const MTPDtextConcat &data) { + for (const auto &part : data.vtexts().v) { + if (!AppendRichText(part, result, context, anchorId)) { + return false; + } + } + return true; + }, [&](const MTPDtextImage &data) { + const auto replacementText = u"[image]"_q; + if (!data.vdocument_id().v || data.vw().v <= 0 || data.vh().v <= 0) { + result->text.append(replacementText); + return true; + } + const auto entityData = Markdown::SerializeInlineTextObjectEntity({ + .kind = Markdown::InlineTextObjectKind::IvImage, + .data = Markdown::InlineTextObjectIvImageData{ + .documentId = uint64(data.vdocument_id().v), + .width = data.vw().v, + .height = data.vh().v, + .replacementText = replacementText, + }, + }); + if (entityData.isEmpty()) { + result->text.append(replacementText); + return true; + } + const auto from = result->text.text.size(); + result->text.append(QChar::ObjectReplacementCharacter); + result->text.entities.push_back(EntityInText( + EntityType::CustomEmoji, + from, + 1, + entityData)); + return true; + }, [&](const MTPDtextMath &data) { + const auto source = qs(data.vsource()); + const auto entityData = Markdown::SerializeInlineTextObjectEntity({ + .kind = Markdown::InlineTextObjectKind::Formula, + .data = Markdown::InlineTextObjectFormulaData{ + .copySource = source, + .trimmedTex = source.trimmed(), + }, + }); + if (entityData.isEmpty()) { + result->text.append(source); + return true; + } + const auto from = result->text.text.size(); + result->text.append(QChar::ObjectReplacementCharacter); + result->text.entities.push_back(EntityInText( + EntityType::CustomEmoji, + from, + 1, + entityData)); + return true; + }, [&](const MTPDtextCustomEmoji &data) { + result->text.append(Ui::Text::SingleCustomEmoji( + ::Data::SerializeCustomEmojiId(uint64(data.vdocument_id().v)), + qs(data.valt()))); + return true; + }, [&](const MTPDtextBold &data) { + const auto from = result->text.text.size(); + return AppendRichText(data.vtext(), result, context, anchorId) + && AddEntity(&result->text, from, EntityType::Bold); + }, [&](const MTPDtextItalic &data) { + const auto from = result->text.text.size(); + return AppendRichText(data.vtext(), result, context, anchorId) + && AddEntity(&result->text, from, EntityType::Italic); + }, [&](const MTPDtextUnderline &data) { + const auto from = result->text.text.size(); + return AppendRichText(data.vtext(), result, context, anchorId) + && AddEntity(&result->text, from, EntityType::Underline); + }, [&](const MTPDtextStrike &data) { + const auto from = result->text.text.size(); + return AppendRichText(data.vtext(), result, context, anchorId) + && AddEntity(&result->text, from, EntityType::StrikeOut); + }, [&](const MTPDtextFixed &data) { + const auto from = result->text.text.size(); + return AppendRichText(data.vtext(), result, context, anchorId) + && AddEntity(&result->text, from, EntityType::Code); + }, [&](const MTPDtextUrl &data) { + const auto from = result->text.text.size(); + if (!AppendRichText(data.vtext(), result, context, anchorId)) { + return false; + } + const auto target = qs(data.vurl()); + if (result->text.text.size() == from) { + result->text.append(target); + } + AddRichLink(result, from, target, uint64(data.vwebpage_id().v)); + return AddEntity(&result->text, from, EntityType::CustomUrl, target); + }, [&](const MTPDtextEmail &data) { + const auto from = result->text.text.size(); + if (!AppendRichText(data.vtext(), result, context, anchorId)) { + return false; + } + const auto email = qs(data.vemail()); + if (result->text.text.size() == from) { + result->text.append(email); + } + const auto target = u"mailto:"_q + email; + AddRichLink(result, from, target); + return AddEntity(&result->text, from, EntityType::CustomUrl, target); + }, [&](const MTPDtextSubscript &data) { + const auto from = result->text.text.size(); + return AppendRichText(data.vtext(), result, context, anchorId) + && AddEntity(&result->text, from, EntityType::Subscript); + }, [&](const MTPDtextSuperscript &data) { + const auto from = result->text.text.size(); + return AppendRichText(data.vtext(), result, context, anchorId) + && AddEntity(&result->text, from, EntityType::Superscript); + }, [&](const MTPDtextMarked &data) { + const auto from = result->text.text.size(); + return AppendRichText(data.vtext(), result, context, anchorId) + && AddEntity(&result->text, from, EntityType::Marked); + }, [&](const MTPDtextSpoiler &data) { + const auto from = result->text.text.size(); + return AppendRichText(data.vtext(), result, context, anchorId) + && AddEntity(&result->text, from, EntityType::Spoiler); + }, [&](const MTPDtextMention &data) { + const auto from = result->text.text.size(); + return AppendRichText(data.vtext(), result, context, anchorId) + && AddEntity(&result->text, from, EntityType::Mention); + }, [&](const MTPDtextHashtag &data) { + const auto from = result->text.text.size(); + return AppendRichText(data.vtext(), result, context, anchorId) + && AddEntity(&result->text, from, EntityType::Hashtag); + }, [&](const MTPDtextBotCommand &data) { + const auto from = result->text.text.size(); + return AppendRichText(data.vtext(), result, context, anchorId) + && AddEntity(&result->text, from, EntityType::BotCommand); + }, [&](const MTPDtextCashtag &data) { + const auto from = result->text.text.size(); + return AppendRichText(data.vtext(), result, context, anchorId) + && AddEntity(&result->text, from, EntityType::Cashtag); + }, [&](const MTPDtextAutoUrl &data) { + const auto from = result->text.text.size(); + return AppendRichText(data.vtext(), result, context, anchorId) + && AddEntity(&result->text, from, EntityType::Url); + }, [&](const MTPDtextAutoEmail &data) { + const auto from = result->text.text.size(); + return AppendRichText(data.vtext(), result, context, anchorId) + && AddEntity(&result->text, from, EntityType::Email); + }, [&](const MTPDtextAutoPhone &data) { + const auto from = result->text.text.size(); + return AppendRichText(data.vtext(), result, context, anchorId) + && AddEntity(&result->text, from, EntityType::Phone); + }, [&](const MTPDtextBankCard &data) { + const auto from = result->text.text.size(); + return AppendRichText(data.vtext(), result, context, anchorId) + && AddEntity(&result->text, from, EntityType::BankCard); + }, [&](const MTPDtextMentionName &data) { + const auto from = result->text.text.size(); + if (!AppendRichText(data.vtext(), result, context, anchorId)) { + return false; + } + const auto entityData = MentionNameEntityData( + context->session, + uint64(data.vuser_id().v)); + return entityData.isEmpty() + ? true + : AddEntity( + &result->text, + from, + EntityType::MentionName, + entityData); + }, [&](const MTPDtextDate &data) { + const auto from = result->text.text.size(); + if (!AppendRichText(data.vtext(), result, context, anchorId)) { + return false; + } + auto flags = FormattedDateFlags(); + if (data.is_relative()) { + flags |= FormattedDateFlag::Relative; + } + if (data.is_short_time()) { + flags |= FormattedDateFlag::ShortTime; + } + if (data.is_long_time()) { + flags |= FormattedDateFlag::LongTime; + } + if (data.is_short_date()) { + flags |= FormattedDateFlag::ShortDate; + } + if (data.is_long_date()) { + flags |= FormattedDateFlag::LongDate; + } + if (data.is_day_of_week()) { + flags |= FormattedDateFlag::DayOfWeek; + } + return AddEntity( + &result->text, + from, + EntityType::FormattedDate, + SerializeFormattedDateData(data.vdate().v, flags)); + }, [&](const MTPDtextPhone &data) { + const auto from = result->text.text.size(); + if (!AppendRichText(data.vtext(), result, context, anchorId)) { + return false; + } + const auto phone = qs(data.vphone()); + if (result->text.text.size() == from) { + result->text.append(phone); + } + const auto target = u"tel:"_q + phone; + AddRichLink(result, from, target); + return AddEntity(&result->text, from, EntityType::CustomUrl, target); + }, [&](const MTPDtextAnchor &data) { + if (anchorId && anchorId->isEmpty()) { + *anchorId = Markdown::NormalizeFragmentId(qs(data.vname())); + } + return AppendRichText(data.vtext(), result, context, anchorId); + }); +} + +[[nodiscard]] auto ParseRichText( + const MTPRichText &text, + ParseContext *context) -> RichText { + auto result = RichText(); + auto anchorId = QString(); + (void)AppendRichText(text, &result, context, &anchorId); + result.anchorId = std::move(anchorId); + return result; +} + +[[nodiscard]] auto ParseCaption( + const MTPPageCaption &caption, + ParseContext *context) -> RichText { + auto result = RichText(); + auto anchorId = QString(); + (void)AppendRichText(caption.data().vtext(), &result, context, &anchorId); + auto credit = RichText(); + (void)AppendRichText(caption.data().vcredit(), &credit, context, &anchorId); + if (!credit.text.empty()) { + if (!result.text.empty()) { + result.text.append(QChar('\n')); + } + AppendRich(&result, std::move(credit)); + } + result.anchorId = std::move(anchorId); + return result; +} + +void AdoptAnchor(QString *anchorId, RichText *text) { + if (anchorId->isEmpty() && !text->anchorId.isEmpty()) { + *anchorId = text->anchorId; + } + text->anchorId.clear(); +} + +void AppendBlocks( + const QVector &blocks, + std::vector *result, + ParseContext *context); + +void AppendBlock( + const MTPPageBlock &block, + std::vector *result, + ParseContext *context) { + block.match([&](const MTPDpageBlockUnsupported &) { + result->push_back(MakeBlock(BlockKind::Unsupported)); + }, [&](const MTPDpageBlockTitle &data) { + auto parsed = MakeHeadingBlock(1); + parsed.text = ParseRichText(data.vtext(), context); + AdoptAnchor(&parsed.anchorId, &parsed.text); + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockSubtitle &data) { + auto parsed = MakeHeadingBlock(2); + parsed.text = ParseRichText(data.vtext(), context); + AdoptAnchor(&parsed.anchorId, &parsed.text); + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockAuthorDate &data) { + auto parsed = MakeBlock(BlockKind::AuthorDate); + parsed.text = ParseRichText(data.vauthor(), context); + AdoptAnchor(&parsed.anchorId, &parsed.text); + parsed.date = data.vpublished_date().v; + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockHeader &data) { + auto parsed = MakeHeadingBlock(3); + parsed.text = ParseRichText(data.vtext(), context); + AdoptAnchor(&parsed.anchorId, &parsed.text); + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockSubheader &data) { + auto parsed = MakeHeadingBlock(4); + parsed.text = ParseRichText(data.vtext(), context); + AdoptAnchor(&parsed.anchorId, &parsed.text); + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockParagraph &data) { + auto parsed = MakeBlock(BlockKind::Paragraph); + parsed.text = ParseRichText(data.vtext(), context); + AdoptAnchor(&parsed.anchorId, &parsed.text); + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockPreformatted &data) { + auto parsed = MakeBlock(BlockKind::Code); + parsed.language = qs(data.vlanguage()).trimmed(); + parsed.text = ParseRichText(data.vtext(), context); + AdoptAnchor(&parsed.anchorId, &parsed.text); + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockFooter &data) { + auto parsed = MakeBlock(BlockKind::Paragraph); + parsed.text = ParseRichText(data.vtext(), context); + AdoptAnchor(&parsed.anchorId, &parsed.text); + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockDivider &) { + result->push_back(MakeBlock(BlockKind::Divider)); + }, [&](const MTPDpageBlockAnchor &data) { + const auto anchorId = Markdown::NormalizeFragmentId(qs(data.vname())); + if (!anchorId.isEmpty()) { + auto parsed = MakeBlock(BlockKind::Anchor); + parsed.anchorId = anchorId; + result->push_back(std::move(parsed)); + } + }, [&](const MTPDpageBlockList &data) { + auto parsed = MakeBlock(BlockKind::List); + parsed.listKind = ListKind::Bullet; + parsed.listItems.reserve(data.vitems().v.size()); + for (const auto &item : data.vitems().v) { + auto listItem = ListItem(); + item.match([&](const MTPDpageListItemText &row) { + listItem.taskState = TaskStateFromFlags( + row.is_checkbox(), + row.is_checked()); + listItem.text = ParseRichText(row.vtext(), context); + listItem.anchorId = listItem.text.anchorId; + listItem.text.anchorId.clear(); + }, [&](const MTPDpageListItemBlocks &row) { + listItem.taskState = TaskStateFromFlags( + row.is_checkbox(), + row.is_checked()); + AppendBlocks(row.vblocks().v, &listItem.blocks, context); + }); + parsed.listItems.push_back(std::move(listItem)); + } + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockBlockquote &data) { + auto parsed = MakeBlock(BlockKind::Quote); + parsed.text = ParseRichText(data.vtext(), context); + AdoptAnchor(&parsed.anchorId, &parsed.text); + parsed.caption = ParseRichText(data.vcaption(), context); + AdoptAnchor(&parsed.anchorId, &parsed.caption); + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockPullquote &data) { + auto parsed = MakeBlock(BlockKind::Quote); + parsed.pullquote = true; + parsed.text = ParseRichText(data.vtext(), context); + AdoptAnchor(&parsed.anchorId, &parsed.text); + parsed.caption = ParseRichText(data.vcaption(), context); + AdoptAnchor(&parsed.anchorId, &parsed.caption); + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockPhoto &data) { + const auto photoId = uint64(data.vphoto_id().v); + const auto size = FindPhotoSize(*context, photoId); + auto parsed = MakeBlock(BlockKind::Photo); + parsed.url = qs(data.vurl().value_or_empty()); + parsed.width = size.width(); + parsed.height = size.height(); + parsed.photoId = photoId; + parsed.spoiler = data.is_spoiler(); + parsed.photo = FindPhoto(*context, photoId); + parsed.caption = ParseCaption(data.vcaption(), context); + AdoptAnchor(&parsed.anchorId, &parsed.caption); + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockVideo &data) { + const auto documentId = uint64(data.vvideo_id().v); + const auto info = FindDocumentInfo(*context, documentId); + auto parsed = MakeBlock(BlockKind::Video); + parsed.width = info.width; + parsed.height = info.height; + parsed.documentId = documentId; + parsed.autoplay = data.is_autoplay(); + parsed.loop = data.is_loop(); + parsed.spoiler = data.is_spoiler(); + parsed.document = FindDocument(*context, documentId); + parsed.caption = ParseCaption(data.vcaption(), context); + AdoptAnchor(&parsed.anchorId, &parsed.caption); + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockCover &data) { + AppendBlock(data.vcover(), result, context); + }, [&](const MTPDpageBlockEmbed &data) { + auto parsed = MakeBlock(BlockKind::Embed); + parsed.url = qs(data.vurl().value_or_empty()); + parsed.html = data.vhtml() ? qs(data.vhtml()->v) : QString(); + parsed.width = data.vw().value_or_empty(); + parsed.height = data.vh().value_or_empty(); + parsed.fullWidth = data.is_full_width(); + parsed.allowScrolling = data.is_allow_scrolling(); + parsed.caption = ParseCaption(data.vcaption(), context); + AdoptAnchor(&parsed.anchorId, &parsed.caption); + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockEmbedPost &data) { + auto parsed = MakeBlock(BlockKind::EmbedPost); + parsed.url = qs(data.vurl()); + parsed.author = qs(data.vauthor()); + parsed.date = data.vdate().v; + parsed.photoId = uint64(data.vauthor_photo_id().v); + parsed.photo = FindPhoto(*context, parsed.photoId); + AppendBlocks(data.vblocks().v, &parsed.blocks, context); + parsed.caption = ParseCaption(data.vcaption(), context); + AdoptAnchor(&parsed.anchorId, &parsed.caption); + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockCollage &data) { + auto parsed = MakeBlock(BlockKind::GroupedMedia); + parsed.mediaIntent = GroupedMediaIntent::Collage; + parsed.mediaItems.reserve(data.vitems().v.size()); + for (const auto &item : data.vitems().v) { + item.match([&](const MTPDpageBlockPhoto &row) { + const auto photoId = uint64(row.vphoto_id().v); + const auto size = FindPhotoSize(*context, photoId); + parsed.mediaItems.push_back({ + .kind = BlockKind::Photo, + .photo = FindPhoto(*context, photoId), + .photoId = photoId, + .width = size.width(), + .height = size.height(), + .spoiler = row.is_spoiler(), + }); + }, [&](const MTPDpageBlockVideo &row) { + const auto documentId = uint64(row.vvideo_id().v); + const auto info = FindDocumentInfo(*context, documentId); + parsed.mediaItems.push_back({ + .kind = BlockKind::Video, + .document = FindDocument(*context, documentId), + .documentId = documentId, + .width = info.width, + .height = info.height, + .autoplay = row.is_autoplay(), + .loop = row.is_loop(), + .spoiler = row.is_spoiler(), + }); + }, [](const auto &) { + }); + } + parsed.caption = ParseCaption(data.vcaption(), context); + AdoptAnchor(&parsed.anchorId, &parsed.caption); + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockSlideshow &data) { + auto parsed = MakeBlock(BlockKind::GroupedMedia); + parsed.mediaIntent = GroupedMediaIntent::Slideshow; + parsed.mediaItems.reserve(data.vitems().v.size()); + for (const auto &item : data.vitems().v) { + item.match([&](const MTPDpageBlockPhoto &row) { + const auto photoId = uint64(row.vphoto_id().v); + const auto size = FindPhotoSize(*context, photoId); + parsed.mediaItems.push_back({ + .kind = BlockKind::Photo, + .photo = FindPhoto(*context, photoId), + .photoId = photoId, + .width = size.width(), + .height = size.height(), + .spoiler = row.is_spoiler(), + }); + }, [&](const MTPDpageBlockVideo &row) { + const auto documentId = uint64(row.vvideo_id().v); + const auto info = FindDocumentInfo(*context, documentId); + parsed.mediaItems.push_back({ + .kind = BlockKind::Video, + .document = FindDocument(*context, documentId), + .documentId = documentId, + .width = info.width, + .height = info.height, + .autoplay = row.is_autoplay(), + .loop = row.is_loop(), + .spoiler = row.is_spoiler(), + }); + }, [](const auto &) { + }); + } + parsed.caption = ParseCaption(data.vcaption(), context); + AdoptAnchor(&parsed.anchorId, &parsed.caption); + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockChannel &data) { + auto parsed = MakeBlock(BlockKind::Channel); + parsed.peer = context->session->data().processChat(data.vchannel()).get(); + data.vchannel().match([&](const MTPDchannel &row) { + parsed.channelId = row.vid().v; + parsed.channelTitle = qs(row.vtitle()); + if (const auto username = row.vusername()) { + parsed.username = qs(*username); + } + }, [&](const MTPDchannelForbidden &row) { + parsed.channelId = row.vid().v; + parsed.channelTitle = qs(row.vtitle()); + }, [&](const MTPDchat &row) { + parsed.channelId = row.vid().v; + parsed.channelTitle = qs(row.vtitle()); + }, [&](const MTPDchatForbidden &row) { + parsed.channelId = row.vid().v; + parsed.channelTitle = qs(row.vtitle()); + }, [](const auto &) { + }); + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockAudio &data) { + const auto documentId = uint64(data.vaudio_id().v); + const auto info = FindDocumentInfo(*context, documentId); + auto parsed = MakeBlock(BlockKind::Audio); + parsed.audioTitle = info.title; + parsed.audioPerformer = info.performer; + parsed.audioFileName = info.fileName; + parsed.documentId = documentId; + parsed.document = FindDocument(*context, documentId); + parsed.audioDuration = info.duration; + parsed.caption = ParseCaption(data.vcaption(), context); + AdoptAnchor(&parsed.anchorId, &parsed.caption); + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockKicker &data) { + auto parsed = MakeHeadingBlock(5); + parsed.text = ParseRichText(data.vtext(), context); + AdoptAnchor(&parsed.anchorId, &parsed.text); + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockHeading1 &data) { + auto parsed = MakeHeadingBlock(1); + parsed.text = ParseRichText(data.vtext(), context); + AdoptAnchor(&parsed.anchorId, &parsed.text); + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockHeading2 &data) { + auto parsed = MakeHeadingBlock(2); + parsed.text = ParseRichText(data.vtext(), context); + AdoptAnchor(&parsed.anchorId, &parsed.text); + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockHeading3 &data) { + auto parsed = MakeHeadingBlock(3); + parsed.text = ParseRichText(data.vtext(), context); + AdoptAnchor(&parsed.anchorId, &parsed.text); + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockHeading4 &data) { + auto parsed = MakeHeadingBlock(4); + parsed.text = ParseRichText(data.vtext(), context); + AdoptAnchor(&parsed.anchorId, &parsed.text); + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockHeading5 &data) { + auto parsed = MakeHeadingBlock(5); + parsed.text = ParseRichText(data.vtext(), context); + AdoptAnchor(&parsed.anchorId, &parsed.text); + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockHeading6 &data) { + auto parsed = MakeHeadingBlock(6); + parsed.text = ParseRichText(data.vtext(), context); + AdoptAnchor(&parsed.anchorId, &parsed.text); + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockMath &data) { + auto parsed = MakeBlock(BlockKind::Math); + parsed.formula = qs(data.vsource()); + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockTable &data) { + auto parsed = MakeBlock(BlockKind::Table); + parsed.bordered = data.is_bordered(); + parsed.striped = data.is_striped(); + parsed.text = ParseRichText(data.vtitle(), context); + AdoptAnchor(&parsed.anchorId, &parsed.text); + parsed.tableRows.reserve(data.vrows().v.size()); + for (const auto &row : data.vrows().v) { + row.match([&](const MTPDpageTableRow &rowData) { + auto parsedRow = TableRow(); + parsedRow.cells.reserve(rowData.vcells().v.size()); + for (const auto &cell : rowData.vcells().v) { + cell.match([&](const MTPDpageTableCell &cellData) { + auto parsedCell = TableCell{ + .colspan = cellData.vcolspan().value_or(1), + .rowspan = cellData.vrowspan().value_or(1), + .header = cellData.is_header(), + .alignment = TableCellAlignment(cellData), + .verticalAlignment = TableCellVerticalAlignment(cellData), + }; + if (const auto text = cellData.vtext()) { + parsedCell.text = ParseRichText(*text, context); + parsedCell.text.anchorId.clear(); + } + parsedRow.cells.push_back(std::move(parsedCell)); + }); + } + parsed.tableRows.push_back(std::move(parsedRow)); + }); + } + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockOrderedList &data) { + auto parsed = MakeBlock(BlockKind::List); + parsed.listKind = ListKind::Ordered; + parsed.listItems.reserve(data.vitems().v.size()); + for (const auto &item : data.vitems().v) { + auto listItem = ListItem(); + item.match([&](const MTPDpageListOrderedItemText &row) { + listItem.taskState = TaskStateFromFlags( + row.is_checkbox(), + row.is_checked()); + listItem.number = qs(row.vnum()); + listItem.text = ParseRichText(row.vtext(), context); + listItem.anchorId = listItem.text.anchorId; + listItem.text.anchorId.clear(); + }, [&](const MTPDpageListOrderedItemBlocks &row) { + listItem.taskState = TaskStateFromFlags( + row.is_checkbox(), + row.is_checked()); + listItem.number = qs(row.vnum()); + AppendBlocks(row.vblocks().v, &listItem.blocks, context); + }); + parsed.listItems.push_back(std::move(listItem)); + } + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockDetails &data) { + auto parsed = MakeBlock(BlockKind::Details); + parsed.open = data.is_open(); + parsed.text = ParseRichText(data.vtitle(), context); + AdoptAnchor(&parsed.anchorId, &parsed.text); + AppendBlocks(data.vblocks().v, &parsed.blocks, context); + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockRelatedArticles &data) { + auto parsed = MakeBlock(BlockKind::RelatedArticles); + parsed.text = ParseRichText(data.vtitle(), context); + AdoptAnchor(&parsed.anchorId, &parsed.text); + parsed.relatedArticles.reserve(data.varticles().v.size()); + for (const auto &article : data.varticles().v) { + const auto &row = article.data(); + parsed.relatedArticles.push_back({ + .url = qs(row.vurl()), + .webpageId = uint64(row.vwebpage_id().v), + .photo = FindPhoto( + *context, + uint64(row.vphoto_id().value_or_empty())), + .photoId = uint64(row.vphoto_id().value_or_empty()), + .title = qs(row.vtitle().value_or_empty()).trimmed(), + .description = qs(row.vdescription().value_or_empty()).trimmed(), + .author = qs(row.vauthor().value_or_empty()).trimmed(), + .publishedDate = row.vpublished_date().value_or_empty(), + }); + } + result->push_back(std::move(parsed)); + }, [&](const MTPDpageBlockMap &data) { + auto parsed = MakeBlock(BlockKind::Map); + parsed.width = data.vw().v; + parsed.height = data.vh().v; + parsed.zoom = data.vzoom().v; + data.vgeo().match([&](const MTPDgeoPoint &row) { + parsed.latitude = row.vlat().v; + parsed.longitude = row.vlong().v; + parsed.accessHash = row.vaccess_hash().v; + }, [](const auto &) { + }); + parsed.caption = ParseCaption(data.vcaption(), context); + AdoptAnchor(&parsed.anchorId, &parsed.caption); + result->push_back(std::move(parsed)); + }, [&](const MTPDinputPageBlockMap &) { + result->push_back(MakeBlock(BlockKind::Unsupported)); + }, [&](const MTPDinputPageBlockOrderedList &) { + result->push_back(MakeBlock(BlockKind::Unsupported)); + }); +} + +void AppendBlocks( + const QVector &blocks, + std::vector *result, + ParseContext *context) { + result->reserve(result->size() + blocks.size()); + for (const auto &block : blocks) { + AppendBlock(block, result, context); + } +} + +void AppendSummaryLine( + TextWithEntities *result, + TextWithEntities &&line) { + TextUtilities::Trim(line); + if (line.empty()) { + return; + } + if (!result->empty()) { + result->append(QChar('\n')); + } + result->append(std::move(line)); +} + +void AppendSummaryLine( + TextWithEntities *result, + const TextWithEntities &line, + const QString &prefix = QString()) { + auto prepared = TextWithEntities(); + if (!prefix.isEmpty()) { + prepared.append(prefix); + } + prepared.append(line); + AppendSummaryLine(result, std::move(prepared)); +} + +void AppendSummaryLine( + TextWithEntities *result, + const RichText &line, + const QString &prefix = QString()) { + AppendSummaryLine(result, line.text, prefix); +} + +[[nodiscard]] auto FooterText(const RelatedArticle &article) -> QString { + if (article.publishedDate && !article.author.isEmpty()) { + return article.author + u", "_q + DateText(article.publishedDate); + } else if (article.publishedDate) { + return DateText(article.publishedDate); + } + return article.author; +} + +void AppendSummaryBlock(TextWithEntities *result, const Block &block); + +void AppendSummaryBlocks( + TextWithEntities *result, + const std::vector &blocks) { + for (const auto &block : blocks) { + AppendSummaryBlock(result, block); + } +} + +[[nodiscard]] auto FlattenSummaryBlocks( + const std::vector &blocks) -> TextWithEntities { + auto result = TextWithEntities(); + AppendSummaryBlocks(&result, blocks); + TextUtilities::Trim(result); + return result; +} + +void AppendSummaryBlock(TextWithEntities *result, const Block &block) { + switch (block.kind) { + case BlockKind::Unsupported: + case BlockKind::Divider: + case BlockKind::Anchor: + return; + case BlockKind::Heading: + case BlockKind::Paragraph: + case BlockKind::Code: + AppendSummaryLine(result, block.text); + return; + case BlockKind::AuthorDate: { + auto line = block.text.text; + if (block.date) { + if (!line.empty()) { + line.append(u" - "_q); + } + line.append(DateText(block.date)); + } + AppendSummaryLine(result, std::move(line)); + return; + } + case BlockKind::List: { + auto ordered = 1; + for (const auto &item : block.listItems) { + auto prefix = QString(); + if (item.taskState == TaskState::Unchecked) { + prefix = u"[ ] "_q; + } else if (item.taskState == TaskState::Checked) { + prefix = u"[x] "_q; + } else if (block.listKind == ListKind::Ordered) { + const auto number = item.number.isEmpty() + ? QString::number(ordered) + : item.number; + prefix = number + u". "_q; + } else { + prefix = u"- "_q; + } + if (!item.text.text.empty()) { + AppendSummaryLine(result, item.text, prefix); + } else { + auto nested = FlattenSummaryBlocks(item.blocks); + AppendSummaryLine(result, std::move(nested), prefix); + } + ++ordered; + } + return; + } + case BlockKind::Quote: + AppendSummaryLine(result, block.text); + AppendSummaryLine(result, block.caption); + return; + case BlockKind::Photo: + case BlockKind::Video: + case BlockKind::Audio: + case BlockKind::GroupedMedia: + case BlockKind::Map: + AppendSummaryLine(result, block.caption); + return; + case BlockKind::Embed: + if (!block.caption.text.empty()) { + AppendSummaryLine(result, block.caption); + } else if (!block.url.isEmpty()) { + AppendSummaryLine(result, TextWithEntities::Simple(block.url)); + } + return; + case BlockKind::EmbedPost: { + auto line = QString(); + if (!block.author.isEmpty()) { + line = block.author; + } + if (block.date) { + if (!line.isEmpty()) { + line += u", "_q; + } + line += DateText(block.date); + } + if (!line.isEmpty()) { + AppendSummaryLine(result, TextWithEntities::Simple(line)); + } + AppendSummaryBlocks(result, block.blocks); + AppendSummaryLine(result, block.caption); + return; + } + case BlockKind::Channel: + if (block.peer) { + AppendSummaryLine( + result, + TextWithEntities::Simple(block.peer->name())); + } + return; + case BlockKind::Math: + AppendSummaryLine(result, TextWithEntities::Simple(block.formula)); + return; + case BlockKind::Table: { + AppendSummaryLine(result, block.text); + for (const auto &row : block.tableRows) { + auto line = TextWithEntities(); + auto first = true; + for (const auto &cell : row.cells) { + auto cellText = TextUtilities::SingleLine(cell.text.text); + TextUtilities::Trim(cellText); + if (cellText.empty()) { + continue; + } + if (!first) { + line.append(u" | "_q); + } + line.append(cellText); + first = false; + } + AppendSummaryLine(result, std::move(line)); + } + return; + } + case BlockKind::Details: + AppendSummaryLine(result, block.text); + AppendSummaryBlocks(result, block.blocks); + return; + case BlockKind::RelatedArticles: + AppendSummaryLine(result, block.text); + for (const auto &article : block.relatedArticles) { + if (!article.title.isEmpty()) { + AppendSummaryLine( + result, + TextWithEntities::Simple(article.title)); + } + if (!article.description.isEmpty()) { + AppendSummaryLine( + result, + TextWithEntities::Simple(article.description)); + } + const auto footer = FooterText(article); + if (!footer.isEmpty()) { + AppendSummaryLine( + result, + TextWithEntities::Simple(footer)); + } + } + return; + } +} + +} // namespace + +auto ParseRichPage( + not_null session, + const MTPRichMessage &message) -> std::shared_ptr { + auto result = std::make_shared(); + auto context = ParseContext{ session }; + const auto &data = message.data(); + result->rtl = data.is_rtl(); + result->part = data.is_part(); + for (const auto &photo : data.vphotos().v) { + RememberPhoto(&context, photo); + } + for (const auto &document : data.vdocuments().v) { + RememberDocument(&context, document); + } + AppendBlocks(data.vblocks().v, &result->blocks, &context); + return result; +} + +auto ParseRichPage( + not_null session, + const MTPPage &page) -> std::shared_ptr { + return page.match([&](const MTPDpage &data) { + auto result = std::make_shared(); + auto context = ParseContext{ session }; + result->url = qs(data.vurl()); + result->rtl = data.is_rtl(); + result->part = data.is_part(); + result->views = data.vviews().value_or_empty(); + for (const auto &photo : data.vphotos().v) { + RememberPhoto(&context, photo); + } + for (const auto &document : data.vdocuments().v) { + RememberDocument(&context, document); + } + AppendBlocks(data.vblocks().v, &result->blocks, &context); + return std::shared_ptr(std::move(result)); + }, [](const auto &) { + return std::shared_ptr(); + }); +} + +auto FlattenRichPageSummary(const RichPage &page) -> TextWithEntities { + auto result = FlattenSummaryBlocks(page.blocks); + TextUtilities::Trim(result); + return result; +} + +auto FlattenRichPageSummary( + const std::shared_ptr &page) -> TextWithEntities { + return page ? FlattenRichPageSummary(*page) : TextWithEntities(); +} + +} // namespace Iv diff --git a/Telegram/SourceFiles/iv/iv_rich_page.h b/Telegram/SourceFiles/iv/iv_rich_page.h new file mode 100644 index 0000000000..dfba162cdc --- /dev/null +++ b/Telegram/SourceFiles/iv/iv_rich_page.h @@ -0,0 +1,191 @@ +/* +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 +*/ +#pragma once + +#include "base/basic_types.h" +#include "ui/text/text_entity.h" + +#include +#include + +class DocumentData; +class PhotoData; +class PeerData; + +namespace Main { +class Session; +} // namespace Main + +namespace Iv { + +struct RichPage { + struct RichLink { + int offset = 0; + int length = 0; + QString target; + uint64 webpageId = 0; + }; + struct RichText { + TextWithEntities text; + QString anchorId; + std::vector links; + }; + enum class BlockKind : uchar { + Unsupported, + Heading, + Paragraph, + AuthorDate, + Code, + Divider, + Anchor, + List, + Quote, + Photo, + Video, + Embed, + EmbedPost, + GroupedMedia, + Channel, + Audio, + Math, + Table, + Details, + RelatedArticles, + Map, + }; + enum class ListKind : uchar { + Bullet, + Ordered, + }; + enum class TaskState : uchar { + None, + Unchecked, + Checked, + }; + enum class GroupedMediaIntent : uchar { + Collage, + Slideshow, + }; + enum class TableAlignment : uchar { + Left, + Center, + Right, + }; + enum class TableVerticalAlignment : uchar { + Top, + Middle, + Bottom, + }; + struct Block; + struct ListItem { + TaskState taskState = TaskState::None; + QString number; + QString anchorId; + RichText text; + std::vector blocks; + }; + struct GroupedMediaItem { + BlockKind kind = BlockKind::Unsupported; + PhotoData *photo = nullptr; + DocumentData *document = nullptr; + uint64 photoId = 0; + uint64 documentId = 0; + int width = 0; + int height = 0; + bool autoplay = false; + bool loop = false; + bool spoiler = false; + }; + struct TableCell { + RichText text; + int colspan = 1; + int rowspan = 1; + bool header = false; + TableAlignment alignment = TableAlignment::Left; + TableVerticalAlignment verticalAlignment + = TableVerticalAlignment::Top; + }; + struct TableRow { + std::vector cells; + }; + struct RelatedArticle { + QString url; + uint64 webpageId = 0; + PhotoData *photo = nullptr; + uint64 photoId = 0; + QString title; + QString description; + QString author; + TimeId publishedDate = 0; + }; + struct Block { + BlockKind kind = BlockKind::Unsupported; + QString anchorId; + RichText text; + RichText caption; + QString language; + QString formula; + QString url; + QString html; + QString author; + QString username; + QString channelTitle; + QString audioTitle; + QString audioPerformer; + QString audioFileName; + TimeId date = 0; + int audioDuration = 0; + int headingLevel = 0; + int width = 0; + int height = 0; + int zoom = 0; + uint64 photoId = 0; + uint64 documentId = 0; + uint64 channelId = 0; + bool fullWidth = false; + bool allowScrolling = false; + bool autoplay = false; + bool loop = false; + bool spoiler = false; + bool open = false; + bool bordered = false; + bool striped = false; + bool pullquote = false; + ListKind listKind = ListKind::Bullet; + GroupedMediaIntent mediaIntent = GroupedMediaIntent::Collage; + PhotoData *photo = nullptr; + DocumentData *document = nullptr; + PeerData *peer = nullptr; + float64 latitude = 0.; + float64 longitude = 0.; + uint64 accessHash = 0; + std::vector blocks; + std::vector listItems; + std::vector mediaItems; + std::vector tableRows; + std::vector relatedArticles; + }; + QString url; + bool rtl = false; + bool part = false; + int views = 0; + std::vector blocks; +}; + +[[nodiscard]] auto ParseRichPage( + not_null session, + const MTPRichMessage &message) -> std::shared_ptr; +[[nodiscard]] auto ParseRichPage( + not_null session, + const MTPPage &page) -> std::shared_ptr; +[[nodiscard]] auto FlattenRichPageSummary( + const RichPage &page) -> TextWithEntities; +[[nodiscard]] auto FlattenRichPageSummary( + const std::shared_ptr &page) -> TextWithEntities; + +} // namespace Iv diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_article.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_article.cpp index 73d7fa6fc9..d284218d84 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_article.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_article.cpp @@ -460,6 +460,7 @@ public: void setContent(MarkdownArticleContent content); [[nodiscard]] int maxWidth(); + [[nodiscard]] int lastLayoutWidth() const; [[nodiscard]] int resizeGetHeight(int width); @@ -583,6 +584,7 @@ private: Fn _textRepaint; Fn _textRepaintRect; int _width = -1; + int _laidOutWidth = 0; int _height = 0; std::vector _blocks; std::unordered_map> _mediaBlocks; @@ -650,6 +652,10 @@ int MarkdownArticle::Impl::maxWidth() { + 1); } +int MarkdownArticle::Impl::lastLayoutWidth() const { + return _laidOutWidth; +} + int MarkdownArticle::Impl::resizeGetHeight(int width) { relayout(width); return std::max(_height, 1); @@ -926,6 +932,7 @@ void MarkdownArticle::Impl::stopPlaceholderRipple( void MarkdownArticle::Impl::invalidateLayout() { _width = -1; + _laidOutWidth = 0; _height = 0; clearPendingHighlightBlockPointers(); _blocks.clear(); @@ -1239,6 +1246,11 @@ void MarkdownArticle::Impl::relayout(int width) { page.top(), innerWidth, context); + _laidOutWidth = std::min( + width, + std::max( + BlockMaxRight(_blocks) + page.right(), + page.left() + page.right() + 1)); prunePlaceholderRuntimes(); RestoreRelatedArticleThumbnailStates( &_blocks, @@ -1286,6 +1298,10 @@ int MarkdownArticle::maxWidth() const { return const_cast(_impl.get())->maxWidth(); } +int MarkdownArticle::lastLayoutWidth() const { + return _impl->lastLayoutWidth(); +} + int MarkdownArticle::resizeGetHeight(int width) { return _impl->resizeGetHeight(width); } diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_article.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_article.h index 78e2d117f0..c0489b3196 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_article.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_article.h @@ -133,6 +133,7 @@ public: void setContent(MarkdownArticleContent content); void invalidateLayout(); [[nodiscard]] int maxWidth() const; + [[nodiscard]] int lastLayoutWidth() const; [[nodiscard]] int resizeGetHeight(int width); void setVisibleTopBottom(int visibleTop, int visibleBottom); void paint( diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.cpp index a5f3477bce..e758c003bd 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.cpp @@ -6,6 +6,7 @@ For license and copyright information please follow this link: https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "iv/markdown/iv_markdown_prepare.h" +#include "iv/iv_rich_page.h" #include "iv/markdown/iv_markdown_prepare_blocks.h" #include "iv/markdown/iv_markdown_prepare_formulas.h" #include "iv/markdown/iv_markdown_prepare_native_blocks.h" @@ -141,7 +142,7 @@ NativeInstantViewPrepareResult TryPrepareNativeInstantView( }; }; - if (!request.source) { + if (!request.source && !request.richPage) { state.setFailure( PrepareTerminalFailure::InvalidRequest, u"missing-native-iv-source"_q); @@ -151,40 +152,65 @@ NativeInstantViewPrepareResult TryPrepareNativeInstantView( state.result.failure.debugReason); } - for (const auto &photo : request.source->page.data().vphotos().v) { - RememberNativeIvPhoto(&state, photo); - } - if (request.source->webpagePhoto) { - RememberNativeIvPhoto(&state, *request.source->webpagePhoto); - } - for (const auto &document : request.source->page.data().vdocuments().v) { - RememberNativeIvDocument(&state, document); - } - if (request.source->webpageDocument) { - RememberNativeIvDocument(&state, *request.source->webpageDocument); - } + if (request.source) { + for (const auto &photo : request.source->page.data().vphotos().v) { + RememberNativeIvPhoto(&state, photo); + } + if (request.source->webpagePhoto) { + RememberNativeIvPhoto(&state, *request.source->webpagePhoto); + } + for (const auto &document : request.source->page.data().vdocuments().v) { + RememberNativeIvDocument(&state, document); + } + if (request.source->webpageDocument) { + RememberNativeIvDocument(&state, *request.source->webpageDocument); + } - if (!PrepareNativeIvBlocks( - request.source->page.data().vblocks().v, - &state.result.blocks.blocks, - &state)) { + if (!PrepareNativeIvBlocks( + request.source->page.data().vblocks().v, + &state.result.blocks.blocks, + &state)) { + if (state.result.failure.failed()) { + ClearPreparedOutput(&state.result); + return finish( + NativeInstantViewPrepareResultKind::Failure, + state.result.failure.debugReason); + } + (void)PrepareNativeIvPlainPlaceholderBlock( + u"Prepare Failed"_q, + &state.result.blocks.blocks); + } + ApplySoftPreparedBlockLimit(&state.result.blocks.blocks); + MeasureNativeIvPreparedFormulas(&state); + if (state.result.failure.failed()) { + ClearPreparedOutput(&state.result); + return finish( + NativeInstantViewPrepareResultKind::Failure, + state.result.failure.debugReason); + } + } else if (request.richPage) { + if (!PrepareNativeIvBlocks( + *request.richPage, + &state.result.blocks.blocks, + &state)) { + if (state.result.failure.failed()) { + ClearPreparedOutput(&state.result); + return finish( + NativeInstantViewPrepareResultKind::Failure, + state.result.failure.debugReason); + } + (void)PrepareNativeIvPlainPlaceholderBlock( + u"Prepare Failed"_q, + &state.result.blocks.blocks); + } + ApplySoftPreparedBlockLimit(&state.result.blocks.blocks); + MeasureNativeIvPreparedFormulas(&state); if (state.result.failure.failed()) { ClearPreparedOutput(&state.result); return finish( NativeInstantViewPrepareResultKind::Failure, state.result.failure.debugReason); } - (void)PrepareNativeIvPlainPlaceholderBlock( - u"Prepare Failed"_q, - &state.result.blocks.blocks); - } - ApplySoftPreparedBlockLimit(&state.result.blocks.blocks); - MeasureNativeIvPreparedFormulas(&state); - if (state.result.failure.failed()) { - ClearPreparedOutput(&state.result); - return finish( - NativeInstantViewPrepareResultKind::Failure, - state.result.failure.debugReason); } return finish( NativeInstantViewPrepareResultKind::Supported, diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.h index c0a6c371d2..72b67c530e 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.h @@ -20,9 +20,13 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include namespace Iv { +struct RichPage; struct Source; } // namespace Iv +class DocumentData; +class PhotoData; + namespace Iv::Markdown { enum class PreparedBlockKind { @@ -327,6 +331,9 @@ struct PrepareRequest { struct NativeInstantViewPrepareRequest { const Iv::Source *source = nullptr; + std::shared_ptr richPage; + const PhotoData *webpagePhoto = nullptr; + const DocumentData *webpageDocument = nullptr; std::shared_ptr mediaRuntime; }; diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_blocks.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_blocks.cpp index 8f31a0477b..b5a796f271 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_blocks.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_blocks.cpp @@ -9,6 +9,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "base/openssl_help.h" #include "base/unixtime.h" #include "iv/iv_data.h" +#include "iv/iv_rich_page.h" #include "iv/markdown/iv_markdown_prepare_links.h" #include "lang/lang_keys.h" @@ -20,6 +21,14 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL namespace Iv::Markdown { namespace { +using RichPage = Iv::RichPage; +using RichPageBlock = Iv::RichPage::Block; +using RichPageBlockKind = Iv::RichPage::BlockKind; +using RichPageListItem = Iv::RichPage::ListItem; +using RichPageRelatedArticle = Iv::RichPage::RelatedArticle; +using RichPageTableCell = Iv::RichPage::TableCell; +using RichPageTableRow = Iv::RichPage::TableRow; + [[nodiscard]] QString NativeIvDateText(TimeId date) { return langDateTimeFull(base::unixtime::parse(date)); } @@ -1930,4 +1939,740 @@ bool PrepareNativeIvBlocks( return !state->result.failure.failed(); } +namespace { + +[[nodiscard]] bool PrepareNativeIvRichPlaceholderBlock( + QString label, + const RichPage::RichText &caption, + QString anchorId, + std::optional embed, + std::vector *result, + NativeIvPrepareState *state) { + auto prepared = PreparedIvRichText(); + if (!PrepareNativeIvRichText( + caption, + &prepared, + &anchorId, + state)) { + return state->result.failure.failed() + ? false + : PrepareNativeIvPlainPlaceholderBlock(std::move(label), result); + } + SortPreparedIvRichText(&prepared); + auto block = PreparedBlock(); + block.kind = PreparedBlockKind::Placeholder; + block.text = std::move(prepared.text); + block.links = std::move(prepared.links); + block.anchorId = std::move(anchorId); + block.supplementary = true; + block.placeholder.label = std::move(label); + block.placeholder.copyText = block.text.text.isEmpty() + ? block.placeholder.label + : (block.placeholder.label + u"\n"_q + block.text.text); + block.placeholder.embed = std::move(embed); + if (block.placeholder.embed) { + block.placeholder.id = { .value = uint64(++state->nextGeneratedId) }; + } + result->push_back(std::move(block)); + return true; +} + +[[nodiscard]] QByteArray RenderCanonicalNativeIvEmbedHtml( + const RichPageBlock &block, + NativeIvPrepareState *state) { + auto attributes = NativeIvHtmlAttributes(); + const auto fullWidth = block.fullWidth || (block.width <= 0); + if (fullWidth) { + attributes.push_back({ "width", "100%" }); + } else { + attributes.push_back({ + "width", + QByteArray::number(block.width), + }); + } + if (block.height > 0) { + attributes.push_back({ + "height", + QByteArray::number(block.height), + }); + } + if (!block.url.isEmpty()) { + attributes.push_back({ "src", NativeIvHtmlEscape(block.url) }); + } else if (!block.html.isEmpty()) { + const auto resourceId = StoreNativeIvEmbedHtml( + block.html.toUtf8(), + state); + attributes.push_back({ + "src", + NativeIvHtmlEscape( + QString::fromUtf8(NativeIvResourceUrl(resourceId))), + }); + } else { + return QByteArray(); + } + if (!block.allowScrolling) { + attributes.push_back({ "scrolling", "no" }); + } + attributes.push_back({ "frameborder", "0" }); + attributes.push_back({ "allowtransparency", "true" }); + attributes.push_back({ "allowfullscreen", "true" }); + auto content = NativeIvHtmlTag("iframe", attributes); + if (!fullWidth) { + auto wrapAttributes = NativeIvHtmlAttributes{ + { "class", "iframe-wrap" }, + }; + if (block.height > 0) { + wrapAttributes.push_back({ + "style", + QByteArray("height:") + + QByteArray::number(block.height) + + "px", + }); + } + content = NativeIvHtmlTag( + "div", + std::move(wrapAttributes), + content); + } + return NativeIvHtmlTag("figure", {}, content); +} + +[[nodiscard]] auto EmbedRequestFromCanonicalBlock( + const RichPageBlock &block, + NativeIvPrepareState *state) -> std::optional { + const auto html = RenderCanonicalNativeIvEmbedHtml(block, state); + if (html.isEmpty()) { + return std::nullopt; + } + return EmbedRequest{ + .resourceId = StoreNativeIvEmbedHtml( + WrapNativeIvEmbedHtml(html), + state), + .fallbackUrl = block.url.isEmpty() ? QString() : block.url, + .width = block.width, + .height = block.height, + .fullWidth = block.fullWidth || !block.width, + .fixedHeight = (block.height > 0), + .allowScrolling = block.allowScrolling, + }; +} + +[[nodiscard]] bool AppendNativeIvFlowBlock( + std::vector *result, + PreparedBlockKind kind, + int headingLevel, + const RichPage::RichText &text, + QString anchorId, + NativeIvPrepareState *state, + bool allowEmpty = false) { + auto prepared = PreparedIvRichText(); + const auto context = NativeIvRichTextContextForTextSize( + NativeIvFlowTextSize(kind, headingLevel, state->dimensions), + state->dimensions); + if (!PrepareNativeIvRichText( + text, + &prepared, + &anchorId, + state, + context)) { + return false; + } + return AppendPreparedIvRichBlock( + result, + kind, + headingLevel, + std::move(prepared), + std::move(anchorId), + allowEmpty); +} + +[[nodiscard]] bool PrepareCanonicalNativeIvQuoteBlock( + const RichPageBlock &data, + std::vector *result, + NativeIvPrepareState *state) { + auto block = PreparedBlock(); + block.kind = PreparedBlockKind::Quote; + block.anchorId = data.anchorId; + auto body = PreparedIvRichText(); + if (!PrepareNativeIvRichText(data.text, &body, &block.anchorId, state)) { + return false; + } + if (!AppendPreparedIvRichBlock( + &block.children, + PreparedBlockKind::Paragraph, + 0, + std::move(body))) { + return false; + } + auto cite = PreparedIvRichText(); + if (!PrepareNativeIvRichText(data.caption, &cite, &block.anchorId, state)) { + return false; + } + if (!AppendPreparedIvRichBlock( + &block.children, + PreparedBlockKind::Paragraph, + 0, + std::move(cite), + QString(), + false, + true)) { + return false; + } + if (block.children.empty()) { + block.children.push_back(EmptyParagraphBlock()); + } + result->push_back(std::move(block)); + return true; +} + +[[nodiscard]] TaskState NativeIvTaskStateFromRichPage( + RichPage::TaskState state) { + switch (state) { + case RichPage::TaskState::Unchecked: + return TaskState::Unchecked; + case RichPage::TaskState::Checked: + return TaskState::Checked; + case RichPage::TaskState::None: + break; + } + return TaskState::None; +} + +[[nodiscard]] bool PrepareCanonicalNativeIvList( + const std::vector &items, + PreparedBlock *result, + NativeIvPrepareState *state) { + auto firstNumber = true; + for (const auto &item : items) { + auto block = PreparedBlock(); + block.kind = PreparedBlockKind::ListItem; + block.listKind = result->listKind; + block.listDelimiter = result->listDelimiter; + block.taskState = NativeIvTaskStateFromRichPage(item.taskState); + if (result->listKind == ListKind::Ordered) { + auto orderedNumber = 0; + if (!ParseOrderedNumber(item.number, &orderedNumber)) { + orderedNumber = NextNativeIvOrderedNumber(*result); + } + block.orderedNumber = orderedNumber; + if (firstNumber) { + result->startNumber = block.orderedNumber; + firstNumber = false; + } + } + if (!item.text.text.empty()) { + auto prepared = PreparedIvRichText(); + auto anchorId = item.anchorId; + if (!PrepareNativeIvRichText( + item.text, + &prepared, + &anchorId, + state)) { + return false; + } + block.anchorId = std::move(anchorId); + if (!AppendPreparedIvRichBlock( + &block.children, + PreparedBlockKind::Paragraph, + 0, + std::move(prepared))) { + return false; + } + } else if (!PrepareNativeIvBlocks( + RichPage{ + .blocks = item.blocks, + }, + &block.children, + state)) { + return false; + } + if (block.children.empty()) { + block.children.push_back(EmptyParagraphBlock()); + } + result->children.push_back(std::move(block)); + } + return true; +} + +[[nodiscard]] TableAlignment NativeIvTableAlignment( + const RichPageTableCell &cell) { + switch (cell.alignment) { + case RichPage::TableAlignment::Center: + return TableAlignment::Center; + case RichPage::TableAlignment::Right: + return TableAlignment::Right; + case RichPage::TableAlignment::Left: + break; + } + return TableAlignment::Left; +} + +[[nodiscard]] PreparedTableCellVerticalAlignment NativeIvTableVerticalAlignment( + const RichPageTableCell &cell) { + switch (cell.verticalAlignment) { + case RichPage::TableVerticalAlignment::Middle: + return PreparedTableCellVerticalAlignment::Middle; + case RichPage::TableVerticalAlignment::Bottom: + return PreparedTableCellVerticalAlignment::Bottom; + case RichPage::TableVerticalAlignment::Top: + break; + } + return PreparedTableCellVerticalAlignment::Top; +} + +[[nodiscard]] bool PrepareCanonicalNativeIvTableBlock( + const RichPageBlock &data, + std::vector *result, + NativeIvPrepareState *state) { + auto block = PreparedBlock(); + block.kind = PreparedBlockKind::Table; + block.tableBordered = data.bordered; + block.tableStriped = data.striped; + auto title = PreparedIvRichText(); + block.anchorId = data.anchorId; + if (!PrepareNativeIvRichText(data.text, &title, &block.anchorId, state)) { + return false; + } + SortPreparedIvRichText(&title); + block.text = std::move(title.text); + block.links = std::move(title.links); + + const auto &limits = PrepareTableRenderLimitsForIv(); + const auto rowCount = std::min(int(data.tableRows.size()), limits.maxRows); + auto occupancy = NativeIvTableOccupancyGrid(rowCount); + block.tableRows.reserve(rowCount); + auto occupiedSlotCountSoFar = int64(0); + + for (auto rowIndex = 0; rowIndex != rowCount; ++rowIndex) { + const auto &row = data.tableRows[rowIndex]; + auto preparedRow = PreparedTableRow(); + preparedRow.cells.reserve(std::min(int(row.cells.size()), limits.maxColumns)); + for (const auto &cell : row.cells) { + auto preparedCell = PreparedTableCell(); + const auto normalizedColspan = NormalizeNativeIvTableSpan(cell.colspan); + const auto rowspan = ClampNativeIvTableRowspan( + cell.rowspan, + rowIndex, + rowCount); + if (rowspan <= 0) { + continue; + } + const auto column = FirstAvailableNativeIvTableColumn( + occupancy, + rowIndex, + rowspan, + normalizedColspan, + limits.maxColumns); + if (column < 0) { + continue; + } + const auto colspan = ClampNativeIvTableColspan( + normalizedColspan, + column, + limits.maxColumns); + if (colspan <= 0) { + continue; + } + const auto occupiedSlotGrowth = int64(rowspan) * colspan; + if (occupiedSlotGrowth > limits.maxCells + || (occupiedSlotCountSoFar + occupiedSlotGrowth) > limits.maxCells) { + continue; + } + preparedCell.column = column; + preparedCell.alignment = NativeIvTableAlignment(cell); + preparedCell.header = cell.header; + preparedCell.verticalAlignment = NativeIvTableVerticalAlignment(cell); + preparedCell.colspan = colspan; + preparedCell.rowspan = rowspan; + if (!cell.text.text.empty()) { + auto rich = PreparedIvRichText(); + const auto context = NativeIvRichTextContextForTextSize( + cell.header + ? state->dimensions.tableHeaderTextSize + : state->dimensions.tableBodyTextSize, + state->dimensions); + if (!PrepareNativeIvRichText( + cell.text, + &rich, + nullptr, + state, + context)) { + return false; + } + SortPreparedIvRichText(&rich); + preparedCell.text = std::move(rich.text); + preparedCell.links = std::move(rich.links); + } + MarkNativeIvTableSlots( + &occupancy, + rowIndex, + column, + rowspan, + colspan); + occupiedSlotCountSoFar += occupiedSlotGrowth; + preparedRow.cells.push_back(std::move(preparedCell)); + } + preparedRow.header = !preparedRow.cells.empty() + && std::all_of( + preparedRow.cells.begin(), + preparedRow.cells.end(), + [](const PreparedTableCell &cell) { + return cell.header; + }); + block.tableRows.push_back(std::move(preparedRow)); + } + + block.tableColumnCount = NativeIvTableColumnCount(occupancy); + block.tableAlignments.resize(block.tableColumnCount, TableAlignment::Left); + for (const auto &preparedRow : block.tableRows) { + for (const auto &preparedCell : preparedRow.cells) { + const auto from = std::max(preparedCell.column, 0); + const auto to = std::min( + preparedCell.column + preparedCell.colspan, + block.tableColumnCount); + for (auto column = from; column != to; ++column) { + block.tableAlignments[column] = preparedCell.alignment; + } + } + } + result->push_back(std::move(block)); + return true; +} + +[[nodiscard]] bool PrepareCanonicalNativeIvDetailsBlock( + const RichPageBlock &data, + std::vector *result, + NativeIvPrepareState *state) { + auto summary = PreparedIvRichText(); + auto anchorId = NativeIvDetailsAnchorId(state); + if (!PrepareNativeIvRichText( + data.text, + &summary, + &anchorId, + state)) { + return false; + } + if (!summary.links.empty()) { + const auto from = std::remove_if( + summary.text.entities.begin(), + summary.text.entities.end(), + [](const EntityInText &entity) { + return entity.type() == EntityType::CustomUrl; + }); + summary.text.entities.erase(from, summary.text.entities.end()); + summary.links.clear(); + } + SortPreparedIvRichText(&summary); + auto block = PreparedBlock(); + block.kind = PreparedBlockKind::Details; + block.anchorId = std::move(anchorId); + block.collapsed = !data.open; + block.text = std::move(summary.text); + block.links = std::move(summary.links); + return PrepareNativeIvBlocks( + RichPage{ + .blocks = data.blocks, + }, + &block.children, + state) + ? (result->push_back(std::move(block)), true) + : false; +} + +[[nodiscard]] QString NativeIvRelatedArticleFooterText( + const RichPageRelatedArticle &article) { + if (article.publishedDate && !article.author.isEmpty()) { + return article.author + u", "_q + NativeIvDateText(article.publishedDate); + } else if (article.publishedDate) { + return NativeIvDateText(article.publishedDate); + } + return article.author; +} + +[[nodiscard]] bool PrepareCanonicalNativeIvRelatedArticlesBlock( + const RichPageBlock &data, + std::vector *result, + NativeIvPrepareState *state) { + auto related = std::vector(); + related.reserve(data.relatedArticles.size()); + for (const auto &article : data.relatedArticles) { + auto prepared = PreparedBlock(); + prepared.kind = PreparedBlockKind::RelatedArticle; + prepared.relatedArticle.title = article.title.trimmed(); + prepared.relatedArticle.description = article.description.trimmed(); + prepared.relatedArticle.footer = NativeIvRelatedArticleFooterText(article); + prepared.relatedArticle.photoId = article.photoId; + if (prepared.relatedArticle.title.isEmpty() + && prepared.relatedArticle.description.isEmpty() + && prepared.relatedArticle.footer.isEmpty()) { + prepared.relatedArticle.title = article.url.trimmed(); + } + const auto linkText = !prepared.relatedArticle.title.isEmpty() + ? prepared.relatedArticle.title + : !prepared.relatedArticle.description.isEmpty() + ? prepared.relatedArticle.description + : prepared.relatedArticle.footer; + prepared.relatedArticle.link = PrepareNativeIvRelatedArticleLink( + article.url, + article.webpageId, + linkText); + const auto appendLine = [&](QString *copyText, const QString &line) { + if (line.isEmpty()) { + return; + } else if (!copyText->isEmpty()) { + copyText->append(QChar('\n')); + } + copyText->append(line); + }; + appendLine(&prepared.relatedArticle.copyText, prepared.relatedArticle.title); + appendLine( + &prepared.relatedArticle.copyText, + prepared.relatedArticle.description); + appendLine(&prepared.relatedArticle.copyText, prepared.relatedArticle.footer); + if (prepared.relatedArticle.copyText.isEmpty()) { + prepared.relatedArticle.copyText = prepared.relatedArticle.link.target; + } + related.push_back(std::move(prepared)); + } + if (related.empty()) { + return true; + } + + auto title = PreparedIvRichText(); + auto anchorId = data.anchorId; + if (!PrepareNativeIvRichText(data.text, &title, &anchorId, state)) { + return false; + } + SortPreparedIvRichText(&title); + if (!title.text.text.isEmpty()) { + if (!AppendPreparedIvRichBlock( + result, + PreparedBlockKind::Heading, + 4, + std::move(title), + std::move(anchorId))) { + return false; + } + } else if (!anchorId.isEmpty() && related.front().anchorId.isEmpty()) { + related.front().anchorId = std::move(anchorId); + } + result->insert( + result->end(), + std::make_move_iterator(related.begin()), + std::make_move_iterator(related.end())); + return true; +} + +[[nodiscard]] bool PrepareCanonicalNativeIvEmbedPostBlock( + const RichPageBlock &data, + std::vector *result, + NativeIvPrepareState *state) { + auto caption = PreparedIvRichText(); + auto block = PreparedBlock(); + block.kind = PreparedBlockKind::EmbedPost; + block.embedPost.url = data.url.trimmed(); + block.embedPost.authorPhotoId = data.photoId; + block.embedPost.author = data.author.trimmed(); + if (data.date) { + block.embedPost.dateText = NativeIvDateText(data.date); + } + auto anchorId = data.anchorId; + if (!PrepareNativeIvRichText(data.caption, &caption, &anchorId, state)) { + return false; + } + SortPreparedIvRichText(&caption); + block.text = std::move(caption.text); + block.links = std::move(caption.links); + block.anchorId = std::move(anchorId); + block.supplementary = true; + if (data.blocks.empty()) { + block.children.push_back( + PrepareNativeIvEmbedPostFallbackParagraph(block.embedPost.url)); + } else if (!PrepareNativeIvBlocks( + RichPage{ + .blocks = data.blocks, + }, + &block.children, + state)) { + return false; + } + result->push_back(std::move(block)); + return true; +} + +[[nodiscard]] bool PrepareCanonicalNativeIvBlock( + const RichPageBlock &block, + std::vector *result, + NativeIvPrepareState *state) { + if (state->blocked()) { + return false; + } + switch (block.kind) { + case RichPageBlockKind::Unsupported: + return PrepareNativeIvPlainPlaceholderBlock( + u"Unsupported Content"_q, + result); + case RichPageBlockKind::Heading: + return AppendNativeIvFlowBlock( + result, + PreparedBlockKind::Heading, + block.headingLevel, + block.text, + block.anchorId, + state); + case RichPageBlockKind::Paragraph: + return AppendNativeIvFlowBlock( + result, + PreparedBlockKind::Paragraph, + 0, + block.text, + block.anchorId, + state); + case RichPageBlockKind::AuthorDate: { + auto prepared = PreparedIvRichText(); + auto anchorId = block.anchorId; + if (!PrepareNativeIvRichText( + block.text, + &prepared, + &anchorId, + state)) { + return false; + } + if (block.date) { + if (!prepared.text.text.isEmpty()) { + prepared.text.append(u" \u2022 "_q); + } + prepared.text.append(NativeIvDateText(block.date)); + } + return AppendPreparedIvRichBlock( + result, + PreparedBlockKind::Paragraph, + 0, + std::move(prepared), + std::move(anchorId), + false, + true); + } + case RichPageBlockKind::Code: { + auto prepared = PreparedIvRichText(); + auto anchorId = block.anchorId; + if (!PrepareNativeIvRichText( + block.text, + &prepared, + &anchorId, + state)) { + return false; + } + auto code = PreparedBlock(); + code.kind = PreparedBlockKind::CodeBlock; + code.anchorId = std::move(anchorId); + code.codeLanguage = block.language; + code.text.text = StripOneTrailingNewline(prepared.text.text); + result->push_back(std::move(code)); + return true; + } + case RichPageBlockKind::Divider: + result->push_back(PrepareRuleBlock()); + return true; + case RichPageBlockKind::Anchor: { + auto prepared = PreparedIvRichText(); + return AppendPreparedIvRichBlock( + result, + PreparedBlockKind::Paragraph, + 0, + std::move(prepared), + block.anchorId, + true); + } + case RichPageBlockKind::List: { + auto prepared = PreparedBlock(); + prepared.kind = PreparedBlockKind::List; + prepared.listKind = (block.listKind == RichPage::ListKind::Ordered) + ? ListKind::Ordered + : ListKind::Bullet; + if (prepared.listKind == ListKind::Ordered) { + prepared.listDelimiter = ListDelimiter::Period; + prepared.startNumber = 1; + } + return PrepareCanonicalNativeIvList( + block.listItems, + &prepared, + state) + ? (result->push_back(std::move(prepared)), true) + : false; + } + case RichPageBlockKind::Quote: + return PrepareCanonicalNativeIvQuoteBlock(block, result, state); + case RichPageBlockKind::Photo: + return PrepareNativeIvPhotoBlock(block, result, state); + case RichPageBlockKind::Video: + return PrepareNativeIvVideoBlock(block, result, state); + case RichPageBlockKind::Embed: + return PrepareNativeIvRichPlaceholderBlock( + tr::lng_iv_click_to_view(tr::now), + block.caption, + block.anchorId, + EmbedRequestFromCanonicalBlock(block, state), + result, + state); + case RichPageBlockKind::EmbedPost: + return PrepareCanonicalNativeIvEmbedPostBlock(block, result, state); + case RichPageBlockKind::GroupedMedia: + return PrepareNativeIvGroupedMediaBlock(block, result, state); + case RichPageBlockKind::Channel: + return PrepareNativeIvChannelBlock(block, result, state); + case RichPageBlockKind::Audio: + return PrepareNativeIvAudioBlock(block, result, state); + case RichPageBlockKind::Math: + if (block.formula.trimmed().isEmpty()) { + return true; + } else { + auto prepared = PreparedBlock(); + prepared.kind = PreparedBlockKind::DisplayMath; + prepared.formulaTex = block.formula; + prepared.mathKind = MathKind::Display; + prepared.formulaIndex = state->rememberFormula(prepared); + result->push_back(std::move(prepared)); + return true; + } + case RichPageBlockKind::Table: + return PrepareCanonicalNativeIvTableBlock(block, result, state); + case RichPageBlockKind::Details: + return PrepareCanonicalNativeIvDetailsBlock(block, result, state); + case RichPageBlockKind::RelatedArticles: + return PrepareCanonicalNativeIvRelatedArticlesBlock(block, result, state); + case RichPageBlockKind::Map: + return PrepareNativeIvMapBlock(block, result, state); + } + return PrepareNativeIvPlainPlaceholderBlock( + u"Unsupported Content"_q, + result); +} + +[[nodiscard]] bool PrepareCanonicalNativeIvBlocks( + const std::vector &blocks, + std::vector *result, + NativeIvPrepareState *state) { + for (const auto &block : blocks) { + if (!PrepareCanonicalNativeIvBlock(block, result, state)) { + if (state->result.failure.failed()) { + return false; + } + (void)PrepareNativeIvPlainPlaceholderBlock( + u"Unsupported Block"_q, + result); + } + } + return !state->result.failure.failed(); +} + +} // namespace + +bool PrepareNativeIvBlocks( + const Iv::RichPage &page, + std::vector *result, + NativeIvPrepareState *state) { + return PrepareCanonicalNativeIvBlocks(page.blocks, result, state); +} + } // namespace Iv::Markdown diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_blocks.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_blocks.h index d3473efa42..11f66604ea 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_blocks.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_blocks.h @@ -17,5 +17,9 @@ namespace Iv::Markdown { const QVector &blocks, std::vector *result, NativeIvPrepareState *state); +[[nodiscard]] bool PrepareNativeIvBlocks( + const Iv::RichPage &page, + std::vector *result, + NativeIvPrepareState *state); } // namespace Iv::Markdown diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_richtext.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_richtext.cpp index c501c18e8e..c78ed5c89a 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_richtext.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_richtext.cpp @@ -11,6 +11,7 @@ struct GeoPointLocation; #include "data/data_location.h" #include "iv/markdown/iv_markdown_prepare_links.h" +#include "iv/markdown/iv_markdown_prepare_serialize.h" #include "ui/basic_click_handlers.h" #include "ui/text/text_utilities.h" #include "history/history_location_manager.h" @@ -134,6 +135,79 @@ void MergeNativeIvDocumentInfo( }); } +[[nodiscard]] uint64 CanonicalPhotoId(const Iv::RichPage::Block &data) { + return data.photoId; +} + +[[nodiscard]] uint64 CanonicalPhotoId( + const Iv::RichPage::GroupedMediaItem &data) { + return data.photoId; +} + +[[nodiscard]] uint64 CanonicalDocumentId(const Iv::RichPage::Block &data) { + return data.documentId; +} + +[[nodiscard]] uint64 CanonicalDocumentId( + const Iv::RichPage::GroupedMediaItem &data) { + return data.documentId; +} + +[[nodiscard]] int CanonicalWidth(const Iv::RichPage::Block &data) { + return data.width; +} + +[[nodiscard]] int CanonicalWidth( + const Iv::RichPage::GroupedMediaItem &data) { + return data.width; +} + +[[nodiscard]] int CanonicalHeight(const Iv::RichPage::Block &data) { + return data.height; +} + +[[nodiscard]] int CanonicalHeight( + const Iv::RichPage::GroupedMediaItem &data) { + return data.height; +} + +[[nodiscard]] bool PrepareNativeIvGroupedMediaItem( + const Iv::RichPage::GroupedMediaItem &item, + PreparedGroupedMediaItemData *result) { + switch (item.kind) { + case Iv::RichPage::BlockKind::Photo: { + const auto photoId = CanonicalPhotoId(item); + const auto width = CanonicalWidth(item); + const auto height = CanonicalHeight(item); + if (!photoId || width <= 0 || height <= 0) { + return false; + } + result->media.kind = PreparedMediaItemKind::Photo; + result->media.id = photoId; + result->media.width = width; + result->media.height = height; + result->media.spoiler = item.spoiler; + return true; + } + case Iv::RichPage::BlockKind::Video: { + const auto documentId = CanonicalDocumentId(item); + const auto width = CanonicalWidth(item); + const auto height = CanonicalHeight(item); + if (!documentId || width <= 0 || height <= 0) { + return false; + } + result->media.kind = PreparedMediaItemKind::Document; + result->media.id = documentId; + result->media.width = width; + result->media.height = height; + result->media.spoiler = item.spoiler; + return true; + } + default: + return false; + } +} + void SortPreparedIvRichText(PreparedIvRichText *text) { SortEntities(&text->text); } @@ -214,6 +288,68 @@ void SortPreparedIvRichText(PreparedIvRichText *text) { return true; } +void RememberCanonicalInlineFormula( + const EntityInText &entity, + NativeIvPrepareState *state, + NativeIvRichTextContext context) { + if (entity.type() != EntityType::CustomEmoji) { + return; + } + const auto parsed = ParseInlineTextObjectEntity(entity.data()); + if (!parsed || parsed->kind != InlineTextObjectKind::Formula) { + return; + } + const auto formula = std::get_if(&parsed->data); + if (!formula) { + return; + } + (void)state->rememberFormula( + MathKind::Inline, + formula->copySource, + context.textSize, + context.renderWidthCap, + context.renderHeightCap); +} + +void AppendCanonicalNativeIvRichText( + const Iv::RichPage::RichText &text, + PreparedIvRichText *result, + NativeIvPrepareState *state, + NativeIvRichTextContext context) { + const auto shift = result->text.text.size(); + result->text.text.append(text.text.text); + result->text.entities.reserve( + result->text.entities.size() + text.text.entities.size()); + for (const auto &entity : text.text.entities) { + if (entity.type() == EntityType::CustomUrl) { + continue; + } + RememberCanonicalInlineFormula(entity, state, context); + result->text.entities.push_back(EntityInText( + entity.type(), + entity.offset() + shift, + entity.length(), + entity.data())); + } + for (const auto &link : text.links) { + if (link.offset < 0 + || link.length <= 0 + || link.offset >= text.text.text.size()) { + continue; + } + const auto length = std::min( + link.length, + text.text.text.size() - link.offset); + (void)AddNativeIvPreparedLink( + &result->text, + &result->links, + shift + link.offset, + length, + link.target, + link.webpageId); + } +} + [[nodiscard]] int ScaleNativeIvFormulaCap( int cap, int textSize, @@ -718,6 +854,18 @@ void SortPreparedIvRichText(PreparedIvRichText *text) { return true; } +[[nodiscard]] bool PrepareNativeIvCaption( + const Iv::RichPage::RichText &caption, + PreparedIvRichText *result, + QString *blockAnchorId, + NativeIvPrepareState *state) { + return PrepareNativeIvRichText( + caption, + result, + blockAnchorId, + state); +} + } // namespace void RememberNativeIvPhoto( @@ -846,6 +994,20 @@ bool PrepareNativeIvRichText( context); } +bool PrepareNativeIvRichText( + const Iv::RichPage::RichText &text, + PreparedIvRichText *result, + QString *blockAnchorId, + NativeIvPrepareState *state, + NativeIvRichTextContext context) { + context = ResolveNativeIvRichTextContext(state, context); + if (blockAnchorId && blockAnchorId->isEmpty() && !text.anchorId.isEmpty()) { + *blockAnchorId = text.anchorId; + } + AppendCanonicalNativeIvRichText(text, result, state, context); + return true; +} + bool AppendPreparedIvRichBlock( std::vector *result, PreparedBlockKind kind, @@ -1091,6 +1253,192 @@ bool PrepareNativeIvGroupedMediaBlock( return true; } +bool PrepareNativeIvPhotoBlock( + const Iv::RichPage::Block &data, + std::vector *result, + NativeIvPrepareState *state) { + if (!CanonicalPhotoId(data) + || CanonicalWidth(data) <= 0 + || CanonicalHeight(data) <= 0) { + return true; + } + auto caption = PreparedIvRichText(); + auto anchorId = QString(); + if (!PrepareNativeIvCaption(data.caption, &caption, &anchorId, state)) { + return false; + } + SortPreparedIvRichText(&caption); + auto block = PreparedBlock(); + block.kind = PreparedBlockKind::Photo; + block.text = std::move(caption.text); + block.links = std::move(caption.links); + block.anchorId = data.anchorId.isEmpty() ? std::move(anchorId) : data.anchorId; + block.supplementary = true; + block.photo.id = GeneratePreparedMediaBlockId(state); + block.photo.photoId = CanonicalPhotoId(data); + block.photo.width = CanonicalWidth(data); + block.photo.height = CanonicalHeight(data); + block.photo.urlOverride = data.url; + block.photo.spoiler = data.spoiler; + block.photo.viewerOpen = true; + result->push_back(std::move(block)); + return true; +} + +bool PrepareNativeIvVideoBlock( + const Iv::RichPage::Block &data, + std::vector *result, + NativeIvPrepareState *state) { + if (!CanonicalDocumentId(data) + || CanonicalWidth(data) <= 0 + || CanonicalHeight(data) <= 0) { + return true; + } + auto caption = PreparedIvRichText(); + auto anchorId = QString(); + if (!PrepareNativeIvCaption(data.caption, &caption, &anchorId, state)) { + return false; + } + SortPreparedIvRichText(&caption); + auto block = PreparedBlock(); + block.kind = PreparedBlockKind::Video; + block.text = std::move(caption.text); + block.links = std::move(caption.links); + block.anchorId = data.anchorId.isEmpty() ? std::move(anchorId) : data.anchorId; + block.supplementary = true; + block.video.id = GeneratePreparedMediaBlockId(state); + block.video.media.kind = PreparedMediaItemKind::Document; + block.video.media.id = CanonicalDocumentId(data); + block.video.media.width = CanonicalWidth(data); + block.video.media.height = CanonicalHeight(data); + block.video.media.spoiler = data.spoiler; + result->push_back(std::move(block)); + return true; +} + +bool PrepareNativeIvAudioBlock( + const Iv::RichPage::Block &data, + std::vector *result, + NativeIvPrepareState *state) { + if (!CanonicalDocumentId(data)) { + return true; + } + auto caption = PreparedIvRichText(); + auto anchorId = QString(); + if (!PrepareNativeIvCaption(data.caption, &caption, &anchorId, state)) { + return false; + } + SortPreparedIvRichText(&caption); + auto block = PreparedBlock(); + block.kind = PreparedBlockKind::Audio; + block.text = std::move(caption.text); + block.links = std::move(caption.links); + block.anchorId = data.anchorId.isEmpty() ? std::move(anchorId) : data.anchorId; + block.supplementary = true; + block.audio.id = GeneratePreparedMediaBlockId(state); + block.audio.documentId = CanonicalDocumentId(data); + block.audio.title = data.audioTitle; + block.audio.performer = data.audioPerformer; + block.audio.fileName = data.audioFileName; + block.audio.duration = data.audioDuration; + result->push_back(std::move(block)); + return true; +} + +bool PrepareNativeIvMapBlock( + const Iv::RichPage::Block &data, + std::vector *result, + NativeIvPrepareState *state) { + if (!data.accessHash || data.width <= 0 || data.height <= 0) { + return true; + } + auto caption = PreparedIvRichText(); + auto anchorId = QString(); + if (!PrepareNativeIvCaption(data.caption, &caption, &anchorId, state)) { + return false; + } + SortPreparedIvRichText(&caption); + auto block = PreparedBlock(); + block.kind = PreparedBlockKind::Map; + block.text = std::move(caption.text); + block.links = std::move(caption.links); + block.anchorId = data.anchorId.isEmpty() ? std::move(anchorId) : data.anchorId; + block.supplementary = true; + block.map.id = GeneratePreparedMediaBlockId(state); + block.map.latitude = data.latitude; + block.map.longitude = data.longitude; + block.map.accessHash = data.accessHash; + block.map.width = data.width; + block.map.height = data.height; + block.map.zoom = data.zoom; + block.map.url = LocationClickHandler::Url(Data::LocationPoint( + data.latitude, + data.longitude, + Data::LocationPoint::NoAccessHash)); + result->push_back(std::move(block)); + return true; +} + +bool PrepareNativeIvChannelBlock( + const Iv::RichPage::Block &data, + std::vector *result, + NativeIvPrepareState *state) { + auto prepared = PreparedChannelBlockData(); + prepared.channelId = data.channelId; + prepared.title = data.channelTitle; + prepared.username = data.username; + if (!prepared.channelId || prepared.title.isEmpty()) { + return true; + } + auto block = PreparedBlock(); + block.kind = PreparedBlockKind::Channel; + prepared.id = GeneratePreparedMediaBlockId(state); + block.channel = std::move(prepared); + result->push_back(std::move(block)); + return true; +} + +bool PrepareNativeIvGroupedMediaBlock( + const Iv::RichPage::Block &data, + std::vector *result, + NativeIvPrepareState *state) { + auto block = PreparedBlock(); + block.kind = PreparedBlockKind::GroupedMedia; + block.groupedMedia.id = GeneratePreparedMediaBlockId(state); + block.groupedMedia.intent = (data.mediaIntent + == Iv::RichPage::GroupedMediaIntent::Slideshow) + ? PreparedGroupedMediaIntent::Slideshow + : PreparedGroupedMediaIntent::Collage; + block.groupedMedia.items.reserve(data.mediaItems.size()); + for (const auto &item : data.mediaItems) { + auto prepared = PreparedGroupedMediaItemData(); + prepared.id = GeneratePreparedMediaBlockId(state); + if (!PrepareNativeIvGroupedMediaItem(item, &prepared)) { + continue; + } + block.groupedMedia.items.push_back(std::move(prepared)); + } + if (block.groupedMedia.items.empty()) { + return true; + } + auto preparedCaption = PreparedIvRichText(); + auto anchorId = QString(); + if (!PrepareNativeIvCaption( + data.caption, + &preparedCaption, + &anchorId, + state)) { + return false; + } + SortPreparedIvRichText(&preparedCaption); + block.text = std::move(preparedCaption.text); + block.links = std::move(preparedCaption.links); + block.anchorId = data.anchorId.isEmpty() ? std::move(anchorId) : data.anchorId; + block.supplementary = true; + result->push_back(std::move(block)); + return true; +} + namespace { [[nodiscard]] QString NativeIvPlaceholderCopyText( diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_richtext.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_richtext.h index c7415b0bda..ff6d7c71fd 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_richtext.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_richtext.h @@ -7,6 +7,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #pragma once +#include "iv/iv_rich_page.h" #include "iv/markdown/iv_markdown_prepare_state.h" namespace Iv::Markdown { @@ -66,12 +67,42 @@ void RememberNativeIvDocument( QString placeholderLabel, std::vector *result, NativeIvPrepareState *state); +[[nodiscard]] bool PrepareNativeIvPhotoBlock( + const Iv::RichPage::Block &data, + std::vector *result, + NativeIvPrepareState *state); +[[nodiscard]] bool PrepareNativeIvVideoBlock( + const Iv::RichPage::Block &data, + std::vector *result, + NativeIvPrepareState *state); +[[nodiscard]] bool PrepareNativeIvAudioBlock( + const Iv::RichPage::Block &data, + std::vector *result, + NativeIvPrepareState *state); +[[nodiscard]] bool PrepareNativeIvMapBlock( + const Iv::RichPage::Block &data, + std::vector *result, + NativeIvPrepareState *state); +[[nodiscard]] bool PrepareNativeIvChannelBlock( + const Iv::RichPage::Block &data, + std::vector *result, + NativeIvPrepareState *state); +[[nodiscard]] bool PrepareNativeIvGroupedMediaBlock( + const Iv::RichPage::Block &data, + std::vector *result, + NativeIvPrepareState *state); [[nodiscard]] bool PrepareNativeIvRichText( const MTPRichText &text, PreparedIvRichText *result, QString *blockAnchorId, NativeIvPrepareState *state, NativeIvRichTextContext context = {}); +[[nodiscard]] bool PrepareNativeIvRichText( + const Iv::RichPage::RichText &text, + PreparedIvRichText *result, + QString *blockAnchorId, + NativeIvPrepareState *state, + NativeIvRichTextContext context = {}); [[nodiscard]] bool AppendPreparedIvRichBlock( std::vector *result, PreparedBlockKind kind, diff --git a/Telegram/SourceFiles/tests/test_markdown_iv.cpp b/Telegram/SourceFiles/tests/test_markdown_iv.cpp index e14105821e..6c53a8b05f 100644 --- a/Telegram/SourceFiles/tests/test_markdown_iv.cpp +++ b/Telegram/SourceFiles/tests/test_markdown_iv.cpp @@ -9,6 +9,7 @@ #include "iv/markdown/iv_markdown_prepare_serialize.h" #include "iv/markdown/iv_markdown_view.h" #include "iv/markdown/iv_markdown_view_widget.h" +#include "iv/iv_rich_page.h" #include "iv/iv_prepare.h" #include "scheme.h" @@ -1436,6 +1437,33 @@ constexpr auto kNativeIvEmbedPostAuthorPhotoId = uint64(9301); return result; } +void AddCanonicalEntity( + TextWithEntities *text, + EntityType type, + int offset, + int length, + QString data = QString()) { + text->entities.push_back(EntityInText(type, offset, length, std::move(data))); +} + +[[nodiscard]] Iv::RichPage::RichText CanonicalRichText( + TextWithEntities text, + std::vector links = {}, + QString anchorId = QString()) { + return { + .text = std::move(text), + .anchorId = std::move(anchorId), + .links = std::move(links), + }; +} + +[[nodiscard]] std::shared_ptr CanonicalRichPage( + std::vector blocks) { + auto page = std::make_shared(); + page->blocks = std::move(blocks); + return page; +} + [[nodiscard]] QString FromLatin1(const char *value) { return QString::fromLatin1(value); } @@ -7510,6 +7538,563 @@ void CheckNativeInstantViewLayer227SpoilerMediaCoverage(bool *ok) { } } +void CheckNativeInstantViewCanonicalRichTextCoverage(bool *ok) { + const auto label = u"native-iv-canonical-richtext"_q; + const auto formulaSource = u"x^2 + y^2"_q; + const auto customEmojiDocumentId = uint64(8800001); + const auto customEmojiAlt = u"ivemoji"_q; + const auto spoilerText = u"spoiler"_q; + const auto mentionText = u"@mention"_q; + const auto hashtagText = u"#hashtag"_q; + const auto botCommandText = u"/start"_q; + const auto cashtagText = u"$cash"_q; + const auto urlText = u"https://example.com"_q; + const auto emailText = u"user@example.com"_q; + const auto phoneText = u"+15551234567"_q; + const auto bankCardText = u"4242424242424242"_q; + const auto mentionNameText = u"Alice"_q; + const auto dateText = u"date"_q; + const auto explicitExternalText = u"external"_q; + const auto explicitIvText = u"instant view"_q; + const auto mentionNameUserId = uint64(42); + const auto formattedDate = 1715347200; + auto expectedDateFlags = FormattedDateFlags(); + expectedDateFlags |= FormattedDateFlag::ShortTime; + expectedDateFlags |= FormattedDateFlag::LongDate; + expectedDateFlags |= FormattedDateFlag::DayOfWeek; + auto text = TextWithEntities(); + text.text = QString(QChar::ObjectReplacementCharacter) + + u" ivemoji spoiler @mention #hashtag /start $cash "_q + + u"https://example.com user@example.com +15551234567 "_q + + u"4242424242424242 Alice date external instant view"_q; + AddCanonicalEntity( + &text, + EntityType::CustomEmoji, + 0, + 1, + SerializeInlineTextObjectEntity({ + .kind = InlineTextObjectKind::Formula, + .data = InlineTextObjectFormulaData{ + .copySource = formulaSource, + .trimmedTex = formulaSource.trimmed(), + }, + })); + const auto customEmojiOffset = text.text.indexOf(customEmojiAlt); + AddCanonicalEntity( + &text, + EntityType::CustomEmoji, + customEmojiOffset, + customEmojiAlt.size(), + Data::SerializeCustomEmojiId(customEmojiDocumentId)); + const auto addWrappedEntity = [&](EntityType type, const QString &value) { + AddCanonicalEntity( + &text, + type, + text.text.indexOf(value), + value.size()); + }; + addWrappedEntity(EntityType::Spoiler, spoilerText); + addWrappedEntity(EntityType::Mention, mentionText); + addWrappedEntity(EntityType::Hashtag, hashtagText); + addWrappedEntity(EntityType::BotCommand, botCommandText); + addWrappedEntity(EntityType::Cashtag, cashtagText); + addWrappedEntity(EntityType::Url, urlText); + addWrappedEntity(EntityType::Email, emailText); + addWrappedEntity(EntityType::Phone, phoneText); + addWrappedEntity(EntityType::BankCard, bankCardText); + const auto mentionNameOffset = text.text.indexOf(mentionNameText); + AddCanonicalEntity( + &text, + EntityType::MentionName, + mentionNameOffset, + mentionNameText.size(), + TextUtilities::MentionNameDataFromFields({ + .selfId = 1, + .userId = mentionNameUserId, + .accessHash = 2, + })); + const auto dateOffset = text.text.indexOf(dateText); + AddCanonicalEntity( + &text, + EntityType::FormattedDate, + dateOffset, + dateText.size(), + SerializeFormattedDateData(formattedDate, expectedDateFlags)); + const auto externalOffset = text.text.indexOf(explicitExternalText); + const auto ivOffset = text.text.indexOf(explicitIvText); + AddCanonicalEntity( + &text, + EntityType::CustomUrl, + externalOffset, + explicitExternalText.size(), + u"https://example.com/external-link"_q); + AddCanonicalEntity( + &text, + EntityType::CustomUrl, + ivOffset, + explicitIvText.size(), + u"https://telegra.ph/instant-view"_q); + auto richText = CanonicalRichText( + std::move(text), + { + { + externalOffset, + explicitExternalText.size(), + u"https://example.com/external-link"_q, + 0, + }, + { + ivOffset, + explicitIvText.size(), + u"https://telegra.ph/instant-view"_q, + 777, + }, + }); + auto block = Iv::RichPage::Block(); + block.kind = Iv::RichPage::BlockKind::Paragraph; + block.text = std::move(richText); + const auto prepared = TryPrepareNativeInstantView({ + .richPage = CanonicalRichPage({ std::move(block) }), + }); + Check(prepared.supported(), label + u" prepare supported"_q, ok); + if (!prepared.supported()) { + return; + } + const auto paragraph = FindPreparedParagraphContaining( + prepared.content, + mentionNameText); + Check(paragraph != nullptr, label + u" paragraph"_q, ok); + if (!paragraph) { + return; + } + const auto &preparedText = paragraph->text; + Check( + preparedText.text == QString(QChar::ObjectReplacementCharacter) + + u" ivemoji spoiler @mention #hashtag /start $cash "_q + + u"https://example.com user@example.com +15551234567 "_q + + u"4242424242424242 Alice date external instant view"_q, + label + u" visible text"_q, + ok); + auto formulaCount = 0; + auto formulaDataOk = false; + for (const auto &match : CollectInlineTextObjectMatches(preparedText)) { + if (match.object.kind != InlineTextObjectKind::Formula) { + continue; + } + ++formulaCount; + if (const auto formula = std::get_if( + &match.object.data)) { + formulaDataOk = (formula->copySource == formulaSource) + && (formula->trimmedTex == formulaSource.trimmed()); + } + } + Check(formulaCount == 1, label + u" inline formula entity count"_q, ok); + Check(formulaDataOk, label + u" inline formula entity data"_q, ok); + auto formulaSlotFound = false; + for (const auto &slot : prepared.content.formulas) { + if (slot.present + && slot.kind == MathKind::Inline + && slot.trimmedTex == formulaSource.trimmed()) { + formulaSlotFound = true; + break; + } + } + Check(formulaSlotFound, label + u" inline formula slot"_q, ok); + Check( + FindEntityRange( + preparedText, + EntityType::CustomEmoji, + customEmojiOffset, + customEmojiAlt.size()) != nullptr, + label + u" custom emoji entity"_q, + ok); + Check( + FindEntityRange( + preparedText, + EntityType::MentionName, + mentionNameOffset, + mentionNameText.size()) != nullptr, + label + u" mention name entity"_q, + ok); + Check( + FindEntityRange( + preparedText, + EntityType::FormattedDate, + dateOffset, + dateText.size()) != nullptr, + label + u" formatted date entity"_q, + ok); + Check( + FindEntityRange( + preparedText, + EntityType::Colorized, + ivOffset, + explicitIvText.size()) != nullptr, + label + u" instant view colorized entity"_q, + ok); + Check(paragraph->links.size() == 2, label + u" prepared link count"_q, ok); + if (paragraph->links.size() == 2) { + Check( + paragraph->links[0].kind == PreparedLinkKind::External + && paragraph->links[0].target + == u"https://example.com/external-link"_q, + label + u" external prepared link"_q, + ok); + Check( + paragraph->links[1].kind == PreparedLinkKind::InstantViewPage + && paragraph->links[1].webpageId == 777, + label + u" instant view prepared link"_q, + ok); + } +} + +void CheckNativeInstantViewCanonicalBlockCoverage(bool *ok) { + const auto label = u"native-iv-canonical-blocks"_q; + auto blocks = std::vector(); + for (auto i = 1; i != 7; ++i) { + auto heading = Iv::RichPage::Block(); + heading.kind = Iv::RichPage::BlockKind::Heading; + heading.headingLevel = i; + heading.text = CanonicalRichText( + TextWithEntities::Simple(u"Heading "_q + QString::number(i))); + blocks.push_back(std::move(heading)); + } + auto math = Iv::RichPage::Block(); + math.kind = Iv::RichPage::BlockKind::Math; + math.formula = u"E = mc^2"_q; + blocks.push_back(std::move(math)); + + auto unordered = Iv::RichPage::Block(); + unordered.kind = Iv::RichPage::BlockKind::List; + unordered.listKind = Iv::RichPage::ListKind::Bullet; + auto checkedUnordered = Iv::RichPage::ListItem(); + checkedUnordered.taskState = Iv::RichPage::TaskState::Checked; + checkedUnordered.text = CanonicalRichText( + TextWithEntities::Simple(u"checked unordered text"_q)); + auto uncheckedUnordered = Iv::RichPage::ListItem(); + uncheckedUnordered.taskState = Iv::RichPage::TaskState::Unchecked; + auto uncheckedUnorderedBlock = Iv::RichPage::Block(); + uncheckedUnorderedBlock.kind = Iv::RichPage::BlockKind::Paragraph; + uncheckedUnorderedBlock.text = CanonicalRichText( + TextWithEntities::Simple(u"unchecked unordered block"_q)); + uncheckedUnordered.blocks.push_back(std::move(uncheckedUnorderedBlock)); + unordered.listItems.push_back(std::move(checkedUnordered)); + unordered.listItems.push_back(std::move(uncheckedUnordered)); + blocks.push_back(std::move(unordered)); + + auto ordered = Iv::RichPage::Block(); + ordered.kind = Iv::RichPage::BlockKind::List; + ordered.listKind = Iv::RichPage::ListKind::Ordered; + auto checkedOrdered = Iv::RichPage::ListItem(); + checkedOrdered.taskState = Iv::RichPage::TaskState::Checked; + checkedOrdered.number = u"4"_q; + checkedOrdered.text = CanonicalRichText( + TextWithEntities::Simple(u"checked ordered text"_q)); + auto uncheckedOrdered = Iv::RichPage::ListItem(); + uncheckedOrdered.taskState = Iv::RichPage::TaskState::Unchecked; + uncheckedOrdered.number = u"fallback"_q; + auto uncheckedOrderedBlock = Iv::RichPage::Block(); + uncheckedOrderedBlock.kind = Iv::RichPage::BlockKind::Paragraph; + uncheckedOrderedBlock.text = CanonicalRichText( + TextWithEntities::Simple(u"unchecked ordered block"_q)); + uncheckedOrdered.blocks.push_back(std::move(uncheckedOrderedBlock)); + ordered.listItems.push_back(std::move(checkedOrdered)); + ordered.listItems.push_back(std::move(uncheckedOrdered)); + blocks.push_back(std::move(ordered)); + + auto photo = Iv::RichPage::Block(); + photo.kind = Iv::RichPage::BlockKind::Photo; + photo.photoId = 9701; + photo.width = 640; + photo.height = 360; + photo.spoiler = true; + photo.caption = CanonicalRichText( + TextWithEntities::Simple(u"Spoiler photo caption"_q)); + blocks.push_back(std::move(photo)); + + auto video = Iv::RichPage::Block(); + video.kind = Iv::RichPage::BlockKind::Video; + video.documentId = 9702; + video.width = 1280; + video.height = 720; + video.spoiler = true; + video.caption = CanonicalRichText( + TextWithEntities::Simple(u"Spoiler video caption"_q)); + blocks.push_back(std::move(video)); + + const auto embedUrl = u"https://example.com/canonical-embed"_q; + auto embed = Iv::RichPage::Block(); + embed.kind = Iv::RichPage::BlockKind::Embed; + embed.url = embedUrl; + embed.width = 640; + embed.height = 360; + embed.caption = CanonicalRichText( + TextWithEntities::Simple(u"Canonical embed caption"_q)); + blocks.push_back(std::move(embed)); + + auto grouped = Iv::RichPage::Block(); + grouped.kind = Iv::RichPage::BlockKind::GroupedMedia; + grouped.mediaIntent = Iv::RichPage::GroupedMediaIntent::Collage; + grouped.caption = CanonicalRichText( + TextWithEntities::Simple(u"Spoiler grouped caption"_q)); + grouped.mediaItems.push_back({ + .kind = Iv::RichPage::BlockKind::Photo, + .photoId = 9703, + .width = 400, + .height = 300, + .spoiler = true, + }); + grouped.mediaItems.push_back({ + .kind = Iv::RichPage::BlockKind::Video, + .documentId = 9704, + .width = 640, + .height = 360, + .spoiler = true, + }); + blocks.push_back(std::move(grouped)); + + auto details = Iv::RichPage::Block(); + details.kind = Iv::RichPage::BlockKind::Details; + details.text = CanonicalRichText( + TextWithEntities::Simple(u"Details title"_q)); + details.open = false; + auto detailsChild = Iv::RichPage::Block(); + detailsChild.kind = Iv::RichPage::BlockKind::Paragraph; + detailsChild.text = CanonicalRichText( + TextWithEntities::Simple(u"Details body"_q)); + details.blocks.push_back(std::move(detailsChild)); + blocks.push_back(std::move(details)); + + auto channel = Iv::RichPage::Block(); + channel.kind = Iv::RichPage::BlockKind::Channel; + channel.channelId = 7006; + channel.channelTitle = u"Native IV Channel"_q; + channel.username = u"nativeiv"_q; + blocks.push_back(std::move(channel)); + + auto related = Iv::RichPage::Block(); + related.kind = Iv::RichPage::BlockKind::RelatedArticles; + related.relatedArticles.push_back({ + .url = u"https://example.com/related"_q, + .webpageId = 778, + .photoId = 9102, + .title = u"Related title"_q, + .description = u"Related description"_q, + .author = u"Author"_q, + .publishedDate = 1715347200, + }); + blocks.push_back(std::move(related)); + + const auto prepared = TryPrepareNativeInstantView({ + .richPage = CanonicalRichPage(std::move(blocks)), + }); + Check(prepared.supported(), label + u" prepare supported"_q, ok); + if (!prepared.supported()) { + return; + } + const auto headings = CollectPreparedBlocksByKind( + prepared.content.blocks.blocks, + PreparedBlockKind::Heading); + Check(headings.size() >= 6, label + u" heading count"_q, ok); + for (auto i = 0; i != std::min(int(headings.size()), 6); ++i) { + Check( + headings[i]->headingLevel == i + 1, + label + u" heading level "_q + QString::number(i + 1), + ok); + } + const auto displayMathBlocks = CollectPreparedBlocksByKind( + prepared.content.blocks.blocks, + PreparedBlockKind::DisplayMath); + Check(displayMathBlocks.size() == 1, label + u" display math count"_q, ok); + if (!displayMathBlocks.empty()) { + Check( + displayMathBlocks.front()->formulaTex == u"E = mc^2"_q, + label + u" display math source"_q, + ok); + } + const auto lists = CollectPreparedBlocksByKind( + prepared.content.blocks.blocks, + PreparedBlockKind::List); + Check(lists.size() == 2, label + u" list count"_q, ok); + if (lists.size() == 2) { + Check( + lists[0]->children.size() == 2 + && lists[0]->children[0].taskState == TaskState::Checked + && lists[0]->children[1].taskState == TaskState::Unchecked, + label + u" unordered checklist state"_q, + ok); + Check( + lists[1]->children.size() == 2 + && lists[1]->children[0].orderedNumber == 4 + && lists[1]->children[1].orderedNumber == 5, + label + u" ordered numbering"_q, + ok); + } + const auto photos = CollectPreparedBlocksByKind( + prepared.content.blocks.blocks, + PreparedBlockKind::Photo); + Check(photos.size() == 1, label + u" photo block count"_q, ok); + if (!photos.empty()) { + Check(photos.front()->photo.spoiler, label + u" photo spoiler"_q, ok); + } + const auto videos = CollectPreparedBlocksByKind( + prepared.content.blocks.blocks, + PreparedBlockKind::Video); + Check(videos.size() == 1, label + u" video block count"_q, ok); + if (!videos.empty()) { + Check( + videos.front()->video.media.spoiler, + label + u" video spoiler"_q, + ok); + } + const auto placeholders = CollectPreparedBlocksByKind( + prepared.content.blocks.blocks, + PreparedBlockKind::Placeholder); + Check( + placeholders.size() == 1, + label + u" embed placeholder count"_q, + ok); + if (!placeholders.empty()) { + const auto &placeholder = placeholders.front()->placeholder; + Check( + placeholder.embed.has_value(), + label + u" embed placeholder activation"_q, + ok); + Check( + bool(placeholder.id), + label + u" embed placeholder id"_q, + ok); + if (placeholder.embed) { + const auto &embed = *placeholder.embed; + Check( + !embed.resourceId.isEmpty() + && prepared.content.embedHtmlResources.find(embed.resourceId) + != prepared.content.embedHtmlResources.end(), + label + u" embed resource stored"_q, + ok); + Check( + embed.fallbackUrl == embedUrl, + label + u" embed fallback url"_q, + ok); + } + } + const auto groupedBlocks = CollectPreparedBlocksByKind( + prepared.content.blocks.blocks, + PreparedBlockKind::GroupedMedia); + Check( + groupedBlocks.size() == 1, + label + u" grouped media block count"_q, + ok); + if (!groupedBlocks.empty()) { + const auto &items = groupedBlocks.front()->groupedMedia.items; + Check(items.size() == 2, label + u" grouped media item count"_q, ok); + if (items.size() == 2) { + Check( + items[0].media.spoiler && items[1].media.spoiler, + label + u" grouped media spoiler state"_q, + ok); + } + } + const auto detailsBlocks = CollectPreparedBlocksByKind( + prepared.content.blocks.blocks, + PreparedBlockKind::Details); + Check(detailsBlocks.size() == 1, label + u" details block count"_q, ok); + if (!detailsBlocks.empty()) { + Check( + detailsBlocks.front()->collapsed, + label + u" details collapsed state"_q, + ok); + Check( + !detailsBlocks.front()->children.empty(), + label + u" details child count"_q, + ok); + } + const auto channels = CollectPreparedBlocksByKind( + prepared.content.blocks.blocks, + PreparedBlockKind::Channel); + Check(channels.size() == 1, label + u" channel block count"_q, ok); + if (!channels.empty()) { + Check( + channels.front()->channel.channelId == 7006 + && channels.front()->channel.title == u"Native IV Channel"_q + && channels.front()->channel.username == u"nativeiv"_q, + label + u" channel data"_q, + ok); + } + const auto relatedArticles = CollectPreparedBlocksByKind( + prepared.content.blocks.blocks, + PreparedBlockKind::RelatedArticle); + Check( + relatedArticles.size() == 1, + label + u" related article count"_q, + ok); + if (!relatedArticles.empty()) { + Check( + relatedArticles.front()->relatedArticle.title == u"Related title"_q + && relatedArticles.front()->relatedArticle.description + == u"Related description"_q + && relatedArticles.front()->relatedArticle.link.kind + == PreparedLinkKind::InstantViewPage + && relatedArticles.front()->relatedArticle.link.webpageId == 778, + label + u" related article data"_q, + ok); + } +} + +void CheckRichPageSummaryFlatteningCoverage(bool *ok) { + const auto label = u"rich-page-summary-flattening"_q; + const auto placeholder = u"Click to view Rich Text"_q; + const auto target = u"https://example.com/rich-link"_q; + const auto visibleText = u"Visible rich link"_q; + auto text = TextWithEntities{ visibleText }; + const auto linkOffset = visibleText.indexOf(u"link"_q); + AddCanonicalEntity( + &text, + EntityType::CustomUrl, + linkOffset, + 4, + target); + auto paragraph = Iv::RichPage::Block(); + paragraph.kind = Iv::RichPage::BlockKind::Paragraph; + paragraph.text = CanonicalRichText(std::move(text)); + const auto paragraphSummary = Iv::FlattenRichPageSummary( + CanonicalRichPage({ std::move(paragraph) })); + Check( + paragraphSummary.text == visibleText, + label + u" text preserved"_q, + ok); + Check( + paragraphSummary.text != placeholder, + label + u" no placeholder text"_q, + ok); + const auto entity = FindEntityRange( + paragraphSummary, + EntityType::CustomUrl, + linkOffset, + 4); + Check(entity != nullptr, label + u" link entity preserved"_q, ok); + if (entity) { + Check(entity->data() == target, label + u" link target preserved"_q, ok); + } + + auto photo = Iv::RichPage::Block(); + photo.kind = Iv::RichPage::BlockKind::Photo; + photo.photoId = 4001; + photo.width = 640; + photo.height = 480; + const auto mediaOnlySummary = Iv::FlattenRichPageSummary( + CanonicalRichPage({ std::move(photo) })); + Check(mediaOnlySummary.empty(), label + u" media-only empty"_q, ok); + Check( + mediaOnlySummary.text.isEmpty(), + label + u" media-only text empty"_q, + ok); + Check( + mediaOnlySummary.text != placeholder, + label + u" media-only no placeholder"_q, + ok); +} + void CheckCodeBlockTrailingNewlineTrim(bool *ok) { const auto markdownLabel = u"generated-code-block-trailing-newline"_q; const auto parsed = ParseMarkdownForIv(QByteArray(R"(```cpp extra-token @@ -13476,6 +14061,9 @@ ThisIsALongUnbrokenStringToTestWrappingBehavior_ABCD1234EFGH5678IJKL CheckNativeInstantViewLayer227BlockCoverage(&ok); CheckNativeInstantViewLayer227ListCoverage(&ok); CheckNativeInstantViewLayer227SpoilerMediaCoverage(&ok); + CheckNativeInstantViewCanonicalRichTextCoverage(&ok); + CheckNativeInstantViewCanonicalBlockCoverage(&ok); + CheckRichPageSummaryFlatteningCoverage(&ok); CheckCodeBlockTrailingNewlineTrim(&ok); CheckCodeBlockSelectionExportCoverage(&ok); CheckCodeBlockAsyncSyntaxHighlightCoverage(&ok);