From 05e026c38c72ae538b0ffec2ae5d0caaf9f3b667 Mon Sep 17 00:00:00 2001 From: John Preston Date: Mon, 18 May 2026 12:47:54 +0400 Subject: [PATCH] Improve embeds usability. --- Telegram/Resources/langs/lang.strings | 1 + Telegram/SourceFiles/iv/iv.style | 10 +- .../iv/markdown/iv_markdown_article.cpp | 264 ++++++++- .../iv/markdown/iv_markdown_article.h | 20 + .../iv_markdown_article_layout_blocks.cpp | 22 +- .../iv_markdown_article_layout_blocks.h | 7 +- .../iv_markdown_article_layout_structure.cpp | 5 +- .../iv/markdown/iv_markdown_article_paint.cpp | 106 +++- .../iv/markdown/iv_markdown_common.h | 17 + .../iv/markdown/iv_markdown_embed_overlay.cpp | 145 ++++- .../iv/markdown/iv_markdown_embed_overlay.h | 19 + .../iv/markdown/iv_markdown_prepare.h | 9 +- .../iv_markdown_prepare_native_blocks.cpp | 8 +- .../iv_markdown_prepare_native_richtext.cpp | 17 +- .../iv/markdown/iv_markdown_view.cpp | 73 ++- .../iv/markdown/iv_markdown_view_widget.cpp | 52 ++ .../iv/markdown/iv_markdown_view_widget.h | 7 + .../SourceFiles/tests/test_markdown_iv.cpp | 552 +++++++++++++----- 18 files changed, 1120 insertions(+), 214 deletions(-) diff --git a/Telegram/Resources/langs/lang.strings b/Telegram/Resources/langs/lang.strings index 7498125508..ce95fdb5eb 100644 --- a/Telegram/Resources/langs/lang.strings +++ b/Telegram/Resources/langs/lang.strings @@ -7888,6 +7888,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL "lng_contact_send_message" = "Message"; "lng_iv_open_in_browser" = "Open in Browser"; +"lng_iv_click_to_view" = "Click to View"; "lng_iv_share" = "Share"; "lng_iv_join_channel" = "Join"; "lng_iv_window_title" = "Instant View"; diff --git a/Telegram/SourceFiles/iv/iv.style b/Telegram/SourceFiles/iv/iv.style index 59c13acce8..4716f2a6f4 100644 --- a/Telegram/SourceFiles/iv/iv.style +++ b/Telegram/SourceFiles/iv/iv.style @@ -166,6 +166,10 @@ MarkdownEmbedPost { MarkdownPlaceholder { padding: margins; minHeight: pixels; + border: pixels; + radius: pixels; + spinnerWidth: pixels; + spinnerSize: pixels; captionSkip: pixels; labelStyle: TextStyle; } @@ -417,8 +421,12 @@ defaultMarkdownEmbedPost: MarkdownEmbedPost { captionSkip: 12px; } defaultMarkdownPlaceholder: MarkdownPlaceholder { - padding: margins(16px, 12px, 16px, 12px); + padding: margins(16px, 24px, 16px, 24px); minHeight: 48px; + border: 4px; + radius: 16px; + spinnerWidth: 4px; + spinnerSize: 24px; captionSkip: 14px; labelStyle: TextStyle(defaultMarkdownBodyStyle) { font: font(16px semibold); diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_article.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_article.cpp index e27e2262da..73d7fa6fc9 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_article.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_article.cpp @@ -16,10 +16,12 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/dynamic_image.h" #include "styles/style_iv.h" +#include "styles/style_widgets.h" #include #include #include +#include #include namespace Iv::Markdown { @@ -109,6 +111,54 @@ void RestoreRelatedArticleThumbnailStates( } } +void CollectPlaceholderIds( + const std::vector &blocks, + std::unordered_set *result) { + if (!result) { + return; + } + for (const auto &block : blocks) { + if (block.placeholderId) { + result->emplace(block.placeholderId.value); + } + CollectPlaceholderIds(block.children, result); + } +} + +[[nodiscard]] LaidOutBlock *FindPlaceholderBlock( + std::vector *blocks, + PreparedPlaceholderBlockId id) { + if (!blocks || !id) { + return nullptr; + } + for (auto &block : *blocks) { + if (block.placeholderId.value == id.value) { + return █ + } + if (const auto child = FindPlaceholderBlock(&block.children, id)) { + return child; + } + } + return nullptr; +} + +[[nodiscard]] const LaidOutBlock *FindPlaceholderBlock( + const std::vector &blocks, + PreparedPlaceholderBlockId id) { + if (!id) { + return nullptr; + } + for (const auto &block : blocks) { + if (block.placeholderId.value == id.value) { + return █ + } + if (const auto child = FindPlaceholderBlock(block.children, id)) { + return child; + } + } + return nullptr; +} + [[nodiscard]] PendingHighlightKey PendingHighlightKeyForBlock( const LaidOutBlock &block) { return { @@ -310,6 +360,12 @@ void RebuildVisibleSegmentLookup( } } else { applyActivation(segment.block->activation); + if (result.mediaActivation.kind == MediaActivationKind::Embed + && segment.block->placeholderRuntime) { + result.state.link = segment.block->placeholderRuntime->clickHandler; + result.placeholderLocalPoint = point + - segment.block->mediaRect.topLeft(); + } } } result.direct = true; @@ -377,6 +433,18 @@ void ClearColorizedFormulaImages(std::vector *blocks) { } // namespace +PlaceholderBlockRuntime::PlaceholderBlockRuntime(Fn repaint) +: clickHandler(std::make_shared([] { +})) +, loadingAnimation( + [repaint = std::move(repaint)] { + if (repaint) { + repaint(); + } + }, + st::defaultInfiniteRadialAnimation) { +} + class MarkdownArticle::Impl final : public CodeBlockSyntaxHighlightTracker { public: explicit Impl(std::shared_ptr renderer); @@ -446,6 +514,12 @@ public: [[nodiscard]] MediaBlockHost *mediaBlockHost() const; + void setPlaceholderLoading(PreparedPlaceholderBlockId id); + void clearPlaceholderLoading(PreparedPlaceholderBlockId id); + void clearAllPlaceholderLoading(); + void addPlaceholderRipple(PreparedPlaceholderBlockId id, QPoint point); + void stopPlaceholderRipple(PreparedPlaceholderBlockId id); + void invalidateLayout(); private: @@ -459,6 +533,15 @@ private: void refreshMediaBlockHosts(); + void clearPlaceholderRuntimes(); + + [[nodiscard]] std::shared_ptr + getOrCreatePlaceholderRuntime(PreparedPlaceholderBlockId id); + + void prunePlaceholderRuntimes(); + + void requestPlaceholderRepaint(PreparedPlaceholderBlockId id); + [[nodiscard]] std::shared_ptr getOrCreateMediaBlock( const PreparedBlock &prepared); @@ -486,6 +569,10 @@ private: void resetFormulaRasterCache(); + void setPlaceholderLoadingValue( + PreparedPlaceholderBlockId id, + bool loading); + void relayout(int width); mutable MarkdownArticleContent _content; @@ -499,6 +586,8 @@ private: int _height = 0; std::vector _blocks; std::unordered_map> _mediaBlocks; + std::unordered_map> + _placeholderRuntimes; std::unordered_map< uint64, RelatedArticleThumbnailState> _relatedArticleThumbnails; @@ -544,6 +633,7 @@ void MarkdownArticle::Impl::setTextRepaintCallbacks( void MarkdownArticle::Impl::setContent(MarkdownArticleContent content) { clearMediaBlocks(); + clearPlaceholderRuntimes(); _relatedArticleThumbnails.clear(); _content = std::move(content); ClearInlineFormulaObjectCache(_inlineFormulaObjects); @@ -761,6 +851,79 @@ MediaBlockHost *MarkdownArticle::Impl::mediaBlockHost() const { return _mediaBlockHost; } +void MarkdownArticle::Impl::setPlaceholderLoading( + PreparedPlaceholderBlockId id) { + setPlaceholderLoadingValue(id, true); +} + +void MarkdownArticle::Impl::clearPlaceholderLoading( + PreparedPlaceholderBlockId id) { + setPlaceholderLoadingValue(id, false); +} + +void MarkdownArticle::Impl::clearAllPlaceholderLoading() { + auto repaintIds = std::vector(); + repaintIds.reserve(_placeholderRuntimes.size()); + for (const auto &[value, runtime] : _placeholderRuntimes) { + if (!runtime || !runtime->loading) { + continue; + } + runtime->loading = false; + runtime->loadingAnimation.stop(anim::type::instant); + repaintIds.push_back({ .value = value }); + } + for (const auto id : repaintIds) { + requestPlaceholderRepaint(id); + } +} + +void MarkdownArticle::Impl::addPlaceholderRipple( + PreparedPlaceholderBlockId id, + QPoint point) { + const auto block = FindPlaceholderBlock(&_blocks, id); + if (!block) { + return; + } + auto runtime = block->placeholderRuntime + ? block->placeholderRuntime + : getOrCreatePlaceholderRuntime(id); + if (!runtime) { + return; + } + block->placeholderRuntime = runtime; + const auto size = block->mediaRect.size(); + if (!runtime->ripple || runtime->rippleSize != size) { + runtime->ripple = std::make_unique( + st::defaultRippleAnimation, + Ui::RippleAnimation::RoundRectMask( + size, + st::defaultMarkdown.placeholder.radius), + [=] { + requestPlaceholderRepaint(id); + }); + runtime->rippleSize = size; + } + point.setX(std::clamp(point.x(), 0, std::max(size.width() - 1, 0))); + point.setY(std::clamp(point.y(), 0, std::max(size.height() - 1, 0))); + runtime->ripple->add(point); + requestPlaceholderRepaint(id); +} + +void MarkdownArticle::Impl::stopPlaceholderRipple( + PreparedPlaceholderBlockId id) { + if (!id) { + return; + } + const auto i = _placeholderRuntimes.find(id.value); + if (i == end(_placeholderRuntimes) + || !i->second + || !i->second->ripple) { + return; + } + i->second->ripple->lastStop(); + requestPlaceholderRepaint(id); +} + void MarkdownArticle::Impl::invalidateLayout() { _width = -1; _height = 0; @@ -803,6 +966,10 @@ void MarkdownArticle::Impl::clearMediaBlocks() { _mediaBlocks.clear(); } +void MarkdownArticle::Impl::clearPlaceholderRuntimes() { + _placeholderRuntimes.clear(); +} + void MarkdownArticle::Impl::refreshMediaBlockHosts() { for (const auto &[id, block] : _mediaBlocks) { if (block) { @@ -811,6 +978,48 @@ void MarkdownArticle::Impl::refreshMediaBlockHosts() { } } +std::shared_ptr +MarkdownArticle::Impl::getOrCreatePlaceholderRuntime( + PreparedPlaceholderBlockId id) { + if (!id) { + return nullptr; + } + if (const auto i = _placeholderRuntimes.find(id.value); + i != end(_placeholderRuntimes)) { + return i->second; + } + auto runtime = std::make_shared([=] { + requestPlaceholderRepaint(id); + }); + _placeholderRuntimes.emplace(id.value, runtime); + return runtime; +} + +void MarkdownArticle::Impl::prunePlaceholderRuntimes() { + auto live = std::unordered_set(); + CollectPlaceholderIds(_blocks, &live); + for (auto i = _placeholderRuntimes.begin(); i != _placeholderRuntimes.end();) { + if (live.find(i->first) != end(live)) { + ++i; + } else { + i = _placeholderRuntimes.erase(i); + } + } +} + +void MarkdownArticle::Impl::requestPlaceholderRepaint( + PreparedPlaceholderBlockId id) { + if (const auto block = FindPlaceholderBlock(_blocks, id)) { + if (_textRepaintRect) { + _textRepaintRect(block->mediaRect); + } else if (_textRepaint) { + _textRepaint(); + } + } else if (_textRepaint) { + _textRepaint(); + } +} + std::shared_ptr MarkdownArticle::Impl::getOrCreateMediaBlock( const PreparedBlock &prepared) { switch (prepared.kind) { @@ -955,6 +1164,33 @@ void MarkdownArticle::Impl::resetFormulaRasterCache() { _formulaRenders.resize(_content.formulas.size()); } +void MarkdownArticle::Impl::setPlaceholderLoadingValue( + PreparedPlaceholderBlockId id, + bool loading) { + if (!id) { + return; + } + const auto runtime = loading + ? getOrCreatePlaceholderRuntime(id) + : [&]() -> std::shared_ptr { + if (const auto i = _placeholderRuntimes.find(id.value); + i != end(_placeholderRuntimes)) { + return i->second; + } + return nullptr; + }(); + if (!runtime || runtime->loading == loading) { + return; + } + runtime->loading = loading; + if (loading) { + runtime->loadingAnimation.start(); + } else { + runtime->loadingAnimation.stop(anim::type::instant); + } + requestPlaceholderRepaint(id); +} + void MarkdownArticle::Impl::relayout(int width) { width = std::max(width, 1); if (_width == width) { @@ -987,6 +1223,9 @@ void MarkdownArticle::Impl::relayout(int width) { context.mediaBlockFactory = [=](const PreparedBlock &prepared) { return getOrCreateMediaBlock(prepared); }; + context.placeholderRuntimeFactory = [=](PreparedPlaceholderBlockId id) { + return getOrCreatePlaceholderRuntime(id); + }; const auto y = LayoutBlocks( _content.blocks.blocks, &_content.formulas, @@ -999,7 +1238,8 @@ void MarkdownArticle::Impl::relayout(int width) { page.left(), page.top(), innerWidth, - std::move(context)); + context); + prunePlaceholderRuntimes(); RestoreRelatedArticleThumbnailStates( &_blocks, _relatedArticleThumbnails); @@ -1138,4 +1378,26 @@ MediaBlockHost *MarkdownArticle::mediaBlockHost() const { return _impl->mediaBlockHost(); } +void MarkdownArticle::setPlaceholderLoading(PreparedPlaceholderBlockId id) { + _impl->setPlaceholderLoading(id); +} + +void MarkdownArticle::clearPlaceholderLoading(PreparedPlaceholderBlockId id) { + _impl->clearPlaceholderLoading(id); +} + +void MarkdownArticle::clearAllPlaceholderLoading() { + _impl->clearAllPlaceholderLoading(); +} + +void MarkdownArticle::addPlaceholderRipple( + PreparedPlaceholderBlockId id, + QPoint point) { + _impl->addPlaceholderRipple(id, point); +} + +void MarkdownArticle::stopPlaceholderRipple(PreparedPlaceholderBlockId id) { + _impl->stopPlaceholderRipple(id); +} + } // namespace Iv::Markdown diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_article.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_article.h index dffe99637a..78e2d117f0 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_article.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_article.h @@ -12,6 +12,9 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "iv/markdown/iv_markdown_prepare.h" #include "spellcheck/spellcheck_highlight_syntax.h" +#include "ui/click_handler.h" +#include "ui/effects/radial_animation.h" +#include "ui/effects/ripple_animation.h" #include "ui/painter.h" #include "ui/text/text.h" @@ -21,6 +24,16 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL namespace Iv::Markdown { +struct PlaceholderBlockRuntime { + explicit PlaceholderBlockRuntime(Fn repaint); + + ClickHandlerPtr clickHandler; + bool loading = false; + Ui::InfiniteRadialAnimation loadingAnimation; + std::unique_ptr ripple; + QSize rippleSize; +}; + struct MarkdownArticlePaintCaches { Ui::Text::QuotePaintCache *pre = nullptr; Ui::Text::QuotePaintCache *blockquote = nullptr; @@ -35,6 +48,7 @@ struct MarkdownArticleHitTestResult { Ui::Text::StateResult state; std::optional preparedLink; MediaActivation mediaActivation; + QPoint placeholderLocalPoint; int forcedOffset = -1; bool direct = false; @@ -155,11 +169,17 @@ public: void invalidatePaletteCache(); void invalidateRasterCache(); [[nodiscard]] MediaBlockHost *mediaBlockHost() const; + void setPlaceholderLoading(PreparedPlaceholderBlockId id); + void clearPlaceholderLoading(PreparedPlaceholderBlockId id); + void clearAllPlaceholderLoading(); + void addPlaceholderRipple(PreparedPlaceholderBlockId id, QPoint point); + void stopPlaceholderRipple(PreparedPlaceholderBlockId id); private: class Impl; std::unique_ptr _impl; + }; } // namespace Iv::Markdown diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_layout_blocks.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_layout_blocks.cpp index d8fcaed47a..9fbb75297b 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_layout_blocks.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_layout_blocks.cpp @@ -1108,12 +1108,18 @@ LaidOutBlock LayoutPlaceholderBlock( const style::Markdown &markdown, int left, int top, - int width) { + int width, + LayoutContext context) { auto block = LaidOutBlock(); block.kind = PreparedBlockKind::Placeholder; block.anchorId = prepared.anchorId; block.copyText = prepared.placeholder.copyText; block.labelText = prepared.placeholder.label; + block.placeholderId = prepared.placeholder.id; + if (block.placeholderId && context.placeholderRuntimeFactory) { + block.placeholderRuntime = context.placeholderRuntimeFactory( + block.placeholderId); + } block.supplementary = prepared.supplementary; const auto &style = markdown.placeholder; @@ -1136,6 +1142,12 @@ LaidOutBlock LayoutPlaceholderBlock( labelHeight + style.padding.top() + style.padding.bottom()); block.mediaRect = QRect(left, top, blockWidth, mediaHeight); block.visibleMediaRect = block.mediaRect; + if (block.placeholderRuntime + && block.placeholderRuntime->ripple + && block.placeholderRuntime->rippleSize != block.mediaRect.size()) { + block.placeholderRuntime->ripple = nullptr; + block.placeholderRuntime->rippleSize = QSize(); + } block.labelRect = QRect( contentLeft, top + std::max((mediaHeight - labelHeight) / 2, 0), @@ -1148,6 +1160,7 @@ LaidOutBlock LayoutPlaceholderBlock( if (prepared.placeholder.embed) { block.activation.kind = MediaActivationKind::Embed; block.activation.embed = *prepared.placeholder.embed; + block.activation.placeholderId = block.placeholderId; } auto bottom = top + mediaHeight; @@ -1160,9 +1173,10 @@ LaidOutBlock LayoutPlaceholderBlock( markdown, contentLeft, bottom, - contentWidth, - style.captionSkip, - &bottom); + contentWidth, + style.captionSkip, + &bottom, + context); block.contentRect = QRect( left, diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_layout_blocks.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_layout_blocks.h index 8d36a75844..42359ecf2d 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_layout_blocks.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_layout_blocks.h @@ -73,6 +73,7 @@ struct LaidOutBlock { QString codeLanguage; std::optional preparedLink; ClickHandlerPtr preparedLinkHandler; + PreparedPlaceholderBlockId placeholderId; Spellchecker::HighlightProcessId syntaxHighlightProcessId = 0; std::vector children; std::vector tableRows; @@ -118,6 +119,7 @@ struct LaidOutBlock { int secondarySegmentIndex = -1; int tertiarySegmentIndex = -1; std::shared_ptr mediaBlock; + std::shared_ptr placeholderRuntime; std::shared_ptr photoRuntime; MediaActivation activation; uint64 thumbnailPhotoId = 0; @@ -140,6 +142,8 @@ struct LayoutContext { bool allowAsyncSyntaxHighlighting = true; CodeBlockSyntaxHighlightTracker *syntaxHighlightTracker = nullptr; std::function(const PreparedBlock&)> mediaBlockFactory; + std::function( + PreparedPlaceholderBlockId)> placeholderRuntimeFactory; }; struct TableCellLayoutData { @@ -247,7 +251,8 @@ void RepopulateCodeBlockLeaf( const style::Markdown &markdown, int left, int top, - int width); + int width, + LayoutContext context = {}); [[nodiscard]] LaidOutBlock LayoutRelatedArticleBlock( const PreparedBlock &prepared, const style::Markdown &markdown, diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_layout_structure.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_layout_structure.cpp index 3583d51b81..4024bd46c1 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_layout_structure.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_layout_structure.cpp @@ -53,7 +53,6 @@ namespace { case PreparedBlockKind::Channel: case PreparedBlockKind::GroupedMedia: case PreparedBlockKind::RelatedArticle: - case PreparedBlockKind::Placeholder: return true; case PreparedBlockKind::Paragraph: case PreparedBlockKind::Heading: @@ -65,6 +64,7 @@ namespace { case PreparedBlockKind::DisplayMath: case PreparedBlockKind::Table: case PreparedBlockKind::Details: + case PreparedBlockKind::Placeholder: case PreparedBlockKind::EmbedPost: return false; } @@ -904,7 +904,8 @@ void PrepareNestedContext( markdown, left, top, - width); + width, + context); case PreparedBlockKind::Details: return LayoutDetailsBlock( prepared, diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_paint.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_paint.cpp index d7127ecb24..c323deb47e 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_paint.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_paint.cpp @@ -629,6 +629,7 @@ void PaintQuoteBlock( void PaintPlaceholderBlock( Painter &p, const LaidOutBlock &block, + int outerWidth, const style::Markdown &markdown, const MarkdownArticlePaintCaches &caches, const PaintSelectionState &selectionState, @@ -638,28 +639,88 @@ void PaintPlaceholderBlock( p.save(); p.setClipRect(visible); auto hq = PainterHighQualityEnabler(p); - const auto max = block.labelLeaf.maxWidth(); - const auto radius = markdown.placeholder.padding.left(); - p.setBrush(st::windowBgOver); - p.setPen(Qt::NoPen); - const auto skip = (max < block.labelRect.width()) - ? ((block.labelRect.width() - max) / 2) - : 0; - p.drawRoundedRect( - block.labelRect.marginsRemoved( - { skip, 0, skip, 0 } - ).marginsAdded(markdown.placeholder.padding), - radius, - radius); - p.setPen(st::windowSubTextFg->c); - PaintTextLeaf( - p, - block.labelLeaf, - caches, - block.labelRect, - block.labelWidth, - visible, - style::al_center); + if (block.activation.kind == MediaActivationKind::Embed + && block.placeholderRuntime) { + const auto border = markdown.placeholder.border; + const auto radius = markdown.placeholder.radius; + const auto borderSkip = border / 2; + const auto borderRect = block.mediaRect.marginsRemoved(QMargins( + borderSkip, + borderSkip, + borderSkip, + borderSkip)); + const auto active = ClickHandler::showAsActive( + block.placeholderRuntime->clickHandler); + const auto pressed = ClickHandler::showAsPressed( + block.placeholderRuntime->clickHandler); + if (active || pressed) { + p.setPen(Qt::NoPen); + p.setBrush(st::windowBgOver); + p.drawRoundedRect(block.mediaRect, radius, radius); + } + if (const auto &ripple = block.placeholderRuntime->ripple) { + ripple->paint( + p, + block.mediaRect.x(), + block.mediaRect.y(), + outerWidth, + &st::windowBgRipple->c); + } + auto pen = QPen(st::windowActiveTextFg->c); + pen.setWidth(border); + p.setPen(pen); + p.setBrush(Qt::NoBrush); + p.drawRoundedRect(borderRect, radius, radius); + if (block.placeholderRuntime->loading) { + const auto size = QSize( + markdown.placeholder.spinnerSize, + markdown.placeholder.spinnerSize); + const auto spinner = style::centerrect( + block.mediaRect, + QRect(QPoint(), size)); + Ui::InfiniteRadialAnimation::Draw( + p, + block.placeholderRuntime->loadingAnimation.computeState(), + spinner.topLeft(), + spinner.size(), + outerWidth, + QPen(st::windowActiveTextFg->c), + markdown.placeholder.spinnerWidth); + } else { + p.setPen(st::windowActiveTextFg->c); + PaintTextLeaf( + p, + block.labelLeaf, + caches, + block.labelRect, + block.labelWidth, + visible, + style::al_center); + } + } else { + const auto max = block.labelLeaf.maxWidth(); + const auto radius = markdown.placeholder.padding.left(); + p.setBrush(st::windowBgOver); + p.setPen(Qt::NoPen); + const auto skip = (max < block.labelRect.width()) + ? ((block.labelRect.width() - max) / 2) + : 0; + p.drawRoundedRect( + block.labelRect.marginsRemoved( + { skip, 0, skip, 0 } + ).marginsAdded(markdown.placeholder.padding), + radius, + radius); + p.setPen(st::windowSubTextFg->c); + PaintTextLeaf( + p, + block.labelLeaf, + caches, + block.labelRect, + block.labelWidth, + visible, + style::al_center); + } if (block.segmentIndex >= 0 && WholeSegmentSelected(selectionState, block.segmentIndex)) { p.fillRect(block.visibleMediaRect, p.textPalette().selectOverlay); @@ -1340,6 +1401,7 @@ void PaintBlock( PaintPlaceholderBlock( p, block, + outerWidth, markdown, caches, selectionState, diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_common.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_common.h index 1492e2e315..4969953ddf 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_common.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_common.h @@ -88,6 +88,22 @@ public: virtual void join(Qt::MouseButton button) const = 0; }; +struct PreparedMediaBlockId { + uint64 value = 0; + + [[nodiscard]] explicit operator bool() const { + return (value != 0); + } +}; + +struct PreparedPlaceholderBlockId { + uint64 value = 0; + + [[nodiscard]] explicit operator bool() const { + return (value != 0); + } +}; + struct PreparedPhotoBlockData; struct PreparedVideoBlockData; struct PreparedAudioBlockData; @@ -197,6 +213,7 @@ struct MediaActivation { MediaActivationKind kind = MediaActivationKind::None; QString url; EmbedRequest embed; + PreparedPlaceholderBlockId placeholderId; std::shared_ptr photo; std::shared_ptr document; std::shared_ptr channel; diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_embed_overlay.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_embed_overlay.cpp index 8d4b313ed6..458fa4cd04 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_embed_overlay.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_embed_overlay.cpp @@ -7,6 +7,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "iv/markdown/iv_markdown_embed_overlay.h" +#include "base/algorithm.h" #include "core/file_utilities.h" #include "lang/lang_keys.h" #include "ui/cached_round_corners.h" @@ -40,6 +41,8 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL namespace Iv::Markdown { namespace { +constexpr auto kReadyRevealDelay = crl::time(1000); + [[nodiscard]] TextWithEntities GenericWebviewErrorText() { return { u"Error: Could not initialize WebView."_q }; } @@ -211,6 +214,9 @@ EmbedOverlay::EmbedOverlay( , _linkActivationCallback(std::move(linkActivationCallback)) , _storageId(std::move(storageId)) , _dataRequestHandler(std::move(dataRequestHandler)) +, _readyDelayTimer([=] { + revealReadyEmbed(); +}) , _loadingAnimation( [=] { if (!anim::Disabled()) { @@ -244,17 +250,41 @@ EmbedOverlay::EmbedOverlay( EmbedOverlay::~EmbedOverlay() { destroyWebview(); + cancelReadyDelay(); removeEscapeFilter(); } +bool EmbedOverlay::preloadEmbed( + const EmbedRequest &request, + std::function shownCallback, + std::function failedCallback) { + return startEmbed( + request, + false, + std::move(shownCallback), + std::move(failedCallback)); +} + bool EmbedOverlay::showEmbed(const EmbedRequest &request) { + return startEmbed(request, true, {}, {}); +} + +void EmbedOverlay::cancelPreload() { + closeEmbed(); +} + +bool EmbedOverlay::startEmbed( + const EmbedRequest &request, + bool showErrorOnFailure, + std::function shownCallback, + std::function failedCallback) { if (!request) { return false; } + closeEmbed(); if (isHidden()) { _focusRestore = QApplication::focusWidget(); } - destroyWebview(); _request = request; _preferredBodySize = QSize(); _pendingPreferredBodySize = QSize(); @@ -263,10 +293,12 @@ bool EmbedOverlay::showEmbed(const EmbedRequest &request) { _readyFromResource = false; _ready = false; _loading = true; + _showErrorOnFailure = showErrorOnFailure; + _shownCallback = std::move(shownCallback); + _failedCallback = std::move(failedCallback); clearWebviewError(); - QWidget::show(); - installEscapeFilter(); - raiseSurfaces(); + QWidget::hide(); + removeEscapeFilter(); updateContentGeometry(); _loadingAnimation.start(); _content->update(); @@ -306,9 +338,7 @@ bool EmbedOverlay::showEmbed(const EmbedRequest &request) { } void EmbedOverlay::closeEmbed() { - if (isHidden()) { - return; - } + cancelReadyDelay(); _loading = false; _loadingAnimation.stop(anim::type::instant); if (_content) { @@ -325,6 +355,10 @@ void EmbedOverlay::closeEmbed() { _cssToQtScale = 1.; _readyFromResource = false; _ready = false; + _showErrorOnFailure = false; + _shownCallback = nullptr; + _failedCallback = nullptr; + _pressedOutside = false; restoreFocus(); } @@ -342,11 +376,11 @@ void EmbedOverlay::testHandleWebviewMessage(const QJsonDocument &message) { } void EmbedOverlay::testHandleNavigationDone(bool success) { - if (success) { + if (success && _readyFromResource) { handleWebviewMessage(NavigationReadyMessage( - _readyFromResource ? _request.resourceId : QByteArray(), - _readyFromResource ? _readyNavigationToken : QString(), - _readyFromResource ? QString() : _request.fallbackUrl)); + _request.resourceId, + _readyNavigationToken, + QString())); return; } handleNavigationDone(success); @@ -356,6 +390,15 @@ bool EmbedOverlay::testLoadingCoverVisible() const { return _loading; } +bool EmbedOverlay::testReadyDelayScheduled() const { + return _readyDelayTimer.isActive(); +} + +void EmbedOverlay::testFireReadyDelay() { + cancelReadyDelay(); + revealReadyEmbed(); +} + const Webview::StorageId &EmbedOverlay::testEffectiveStorageId() const { return _storageId; } @@ -535,16 +578,15 @@ void EmbedOverlay::handleWebviewMessage(const QJsonDocument &message) { showWebviewError(); return; } - if (_readyFromResource) { - if (object.value("token").toString() != _readyNavigationToken) { - return; - } - if (normalizedRequestId( - object.value("resourceId").toString().toStdString()) - != _request.resourceId) { - return; - } - } else if (object.value("url").toString().isEmpty()) { + if (!_readyFromResource) { + return; + } + if (object.value("token").toString() != _readyNavigationToken) { + return; + } + if (normalizedRequestId( + object.value("resourceId").toString().toStdString()) + != _request.resourceId) { return; } setReady(); @@ -578,12 +620,22 @@ void EmbedOverlay::handleWebviewMessage(const QJsonDocument &message) { } void EmbedOverlay::handleNavigationDone(bool success) { - if (success || !_request || _ready || isHidden()) { + if (!_request || _ready) { + return; + } + if (success) { + if (!_readyFromResource) { + setReady(); + } return; } showWebviewError(); } +void EmbedOverlay::cancelReadyDelay() { + _readyDelayTimer.cancel(); +} + void EmbedOverlay::setReady() { if (_ready || !_webview || !_webview->widget()) { return; @@ -594,19 +646,34 @@ void EmbedOverlay::setReady() { || _pendingPreferredBodySize.height() > 0)) { _preferredBodySize = _pendingPreferredBodySize; _pendingPreferredBodySize = QSize(); - updateContentGeometry(); - } else { - updateWebviewGeometry(); } + updateContentGeometry(); + cancelReadyDelay(); + _readyDelayTimer.callOnce(kReadyRevealDelay); +} + +void EmbedOverlay::revealReadyEmbed() { + if (!_ready || !_request || !_webview || !_webview->widget()) { + return; + } + cancelReadyDelay(); + clearWebviewError(); + QWidget::show(); + installEscapeFilter(); + raiseSurfaces(); + updateContentGeometry(); _loading = false; _loadingAnimation.stop(anim::type::normal); _content->update(); - clearWebviewError(); _webview->widget()->show(); _webview->widget()->raise(); _webview->focus(); update(); _content->update(); + if (const auto shownCallback = base::take(_shownCallback)) { + shownCallback(); + } + _failedCallback = nullptr; } void EmbedOverlay::applyPreferredBodySize(QSize size) { @@ -753,11 +820,34 @@ void EmbedOverlay::showWebviewError() { } void EmbedOverlay::showWebviewError(const TextWithEntities &text) { + cancelReadyDelay(); _ready = false; _loading = false; _loadingAnimation.stop(anim::type::normal); _content->update(); hideWebview(); + destroyWebview(); + _shownCallback = nullptr; + const auto failedCallback = base::take(_failedCallback); + const auto showError = !isHidden() || _showErrorOnFailure; + _showErrorOnFailure = false; + _readyNavigationToken = QString(); + _readyFromResource = false; + if (!showError) { + clearWebviewError(); + _request = EmbedRequest(); + _preferredBodySize = QSize(); + _pendingPreferredBodySize = QSize(); + _cssToQtScale = 1.; + restoreFocus(); + if (failedCallback) { + failedCallback(); + } + return; + } + QWidget::show(); + installEscapeFilter(); + raiseSurfaces(); if (!_error) { _error = Ui::CreateChild>( _content, @@ -783,6 +873,9 @@ void EmbedOverlay::showWebviewError(const TextWithEntities &text) { _errorLabel->setMarkedText(AddFallbackAction(text, _request.fallbackUrl)); _error->show(); updateContentGeometry(); + if (failedCallback) { + failedCallback(); + } } void EmbedOverlay::clearWebviewError() { diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_embed_overlay.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_embed_overlay.h index 9e33437bb2..bf2895c68d 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_embed_overlay.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_embed_overlay.h @@ -8,6 +8,7 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #pragma once #include "base/flat_map.h" +#include "base/timer.h" #include "iv/markdown/iv_markdown_common.h" #include "ui/effects/radial_animation.h" #include "ui/rp_widget.h" @@ -53,12 +54,19 @@ public: dataRequestHandler); ~EmbedOverlay(); + [[nodiscard]] bool preloadEmbed( + const EmbedRequest &request, + std::function shownCallback = {}, + std::function failedCallback = {}); [[nodiscard]] bool showEmbed(const EmbedRequest &request); + void cancelPreload(); void closeEmbed(); void updateGeometry(QRect geometry, int contentWidth); void testHandleWebviewMessage(const QJsonDocument &message); void testHandleNavigationDone(bool success); [[nodiscard]] bool testLoadingCoverVisible() const; + [[nodiscard]] bool testReadyDelayScheduled() const; + void testFireReadyDelay(); [[nodiscard]] const Webview::StorageId &testEffectiveStorageId() const; protected: @@ -71,11 +79,18 @@ private: void installEscapeFilter(); void removeEscapeFilter(); [[nodiscard]] bool eventFromOverlayWindow(QObject *object) const; + [[nodiscard]] bool startEmbed( + const EmbedRequest &request, + bool showErrorOnFailure, + std::function shownCallback, + std::function failedCallback); void ensureWebview(); [[nodiscard]] Webview::WindowConfig makeWindowConfig() const; void handleWebviewMessage(const QJsonDocument &message); void handleNavigationDone(bool success); + void cancelReadyDelay(); void setReady(); + void revealReadyEmbed(); void applyPreferredBodySize(QSize size); void applyPreferredBodyHeight(int height); void updateCssToQtScale(int viewportWidth); @@ -106,6 +121,7 @@ private: Ui::PaddingWrap *_error = nullptr; Ui::FlatLabel *_errorLabel = nullptr; std::unique_ptr _webview; + base::Timer _readyDelayTimer; Ui::InfiniteRadialAnimation _loadingAnimation; EmbedRequest _request; QRect _contentGeometry; @@ -113,6 +129,8 @@ private: QSize _pendingPreferredBodySize; QString _readyNavigationToken; QPointer _focusRestore; + std::function _shownCallback; + std::function _failedCallback; int _contentWidth = 0; int _navigationGeneration = 0; int _webviewGeneration = 0; @@ -121,6 +139,7 @@ private: bool _readyFromResource = false; bool _ready = false; bool _loading = false; + bool _showErrorOnFailure = false; bool _escapeFilterInstalled = false; }; diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.h index e9a60f144b..c728029eb0 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.h @@ -116,14 +116,6 @@ struct PreparedTableRow { bool header = false; }; -struct PreparedMediaBlockId { - uint64 value = 0; - - [[nodiscard]] explicit operator bool() const { - return (value != 0); - } -}; - struct PreparedPhotoBlockData { PreparedMediaBlockId id; uint64 photoId = 0; @@ -194,6 +186,7 @@ struct PreparedGroupedMediaBlockData { }; struct PreparedPlaceholderBlockData { + PreparedPlaceholderBlockId id; QString label; QString copyText; std::optional embed; 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 0d776abc6a..420ea82efa 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_blocks.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_blocks.cpp @@ -765,10 +765,11 @@ using NativeIvHtmlAttributes = std::vector; const MTPDpageBlockEmbed &data, std::vector *result, NativeIvPrepareState *state) { + const auto label = tr::lng_iv_click_to_view(tr::now); const auto html = RenderNativeIvEmbedHtml(data, state, false); if (html.isEmpty()) { return PrepareNativeIvPlaceholderBlock( - u"Embed Placeholder"_q, + label, data.vcaption(), result, state); @@ -787,7 +788,7 @@ using NativeIvHtmlAttributes = std::vector; .allowScrolling = data.is_allow_scrolling(), }; return PrepareNativeIvPlaceholderBlock( - u"Embed Placeholder"_q, + label, data.vcaption(), result, state, @@ -1506,6 +1507,9 @@ void MarkNativeIvTableSlots( data.vtext(), state); }, [&](const MTPDpageBlockAuthorDate &data) { + return PrepareNativeIvPlainPlaceholderBlock( + u"Unsupported Content"_q, + result); AssertIsDebug(); auto prepared = PreparedIvRichText(); auto anchorId = QString(); if (!PrepareNativeIvRichText( 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 566baaef8c..46459fedf0 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_richtext.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_richtext.cpp @@ -20,9 +20,19 @@ struct GeoPointLocation; namespace Iv::Markdown { namespace { +[[nodiscard]] uint64 GeneratePreparedBlockIdValue( + NativeIvPrepareState *state) { + return uint64(++state->nextGeneratedId); +} + [[nodiscard]] PreparedMediaBlockId GeneratePreparedMediaBlockId( NativeIvPrepareState *state) { - return { .value = uint64(++state->nextGeneratedId) }; + return { .value = GeneratePreparedBlockIdValue(state) }; +} + +[[nodiscard]] PreparedPlaceholderBlockId GeneratePreparedPlaceholderBlockId( + NativeIvPrepareState *state) { + return { .value = GeneratePreparedBlockIdValue(state) }; } void ShiftEntities(EntitiesInText *entities, int delta) { @@ -850,8 +860,11 @@ bool PrepareNativeIvPlaceholderBlock( block.links = std::move(prepared.links); block.anchorId = std::move(anchorId); block.supplementary = true; - block.placeholder.label = label; + block.placeholder.label = std::move(label); block.placeholder.embed = std::move(embed); + if (block.placeholder.embed && *block.placeholder.embed) { + block.placeholder.id = GeneratePreparedPlaceholderBlockId(state); + } block.placeholder.copyText = NativeIvPlaceholderCopyText( block.placeholder.label, block.text); diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_view.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_view.cpp index d21d6dcf5d..c2426e944a 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_view.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_view.cpp @@ -173,9 +173,15 @@ public: [[nodiscard]] rpl::producer scrollTopValue() const; private: + struct PendingEmbedState { + PreparedPlaceholderBlockId placeholderId; + uint64 generation = 0; + }; + void setup(); void prepareArticle(); void activateLink(const PreparedLink &link, Qt::MouseButton button); + void closeEmbed(); void openEmbedLink(QString url); void showFootnote(const PreparedLink &link, Qt::MouseButton button); [[nodiscard]] bool showEmbed(const MediaActivation &activation); @@ -208,6 +214,7 @@ private: std::shared_ptr _article; QString _pendingFragment; int _devicePixelRatio = 0; + PendingEmbedState _pendingEmbed; }; @@ -430,13 +437,27 @@ void MarkdownPreviewRoot::activateLink( } } +void MarkdownPreviewRoot::closeEmbed() { + if (_body) { + _body->clearAllPlaceholderLoading(); + } + const auto hadPending = bool(_pendingEmbed.placeholderId); + _pendingEmbed.placeholderId = {}; + if (!_embedOverlay) { + return; + } + if (hadPending) { + _embedOverlay->cancelPreload(); + } else { + _embedOverlay->closeEmbed(); + } +} + void MarkdownPreviewRoot::openEmbedLink(QString url) { if (url.isEmpty()) { return; } - if (_embedOverlay) { - _embedOverlay->closeEmbed(); - } + closeEmbed(); HiddenUrlClickHandler::Open(url, CurrentClickHandlerContext(_options)); } @@ -464,9 +485,45 @@ void MarkdownPreviewRoot::showFootnote( } bool MarkdownPreviewRoot::showEmbed(const MediaActivation &activation) { - return _embedOverlay - ? _embedOverlay->showEmbed(activation.embed) - : false; + if (activation.kind != MediaActivationKind::Embed + || !activation.embed + || !activation.placeholderId) { + return false; + } + const auto placeholderId = activation.placeholderId; + const auto generation = ++_pendingEmbed.generation; + if (_body && _pendingEmbed.placeholderId) { + _body->clearPlaceholderLoading(_pendingEmbed.placeholderId); + } + if (_pendingEmbed.placeholderId && _embedOverlay) { + _embedOverlay->cancelPreload(); + } + _pendingEmbed.placeholderId = placeholderId; + if (_body) { + _body->setPlaceholderLoading(placeholderId); + } + const auto finishPending = [=] { + if (_pendingEmbed.generation != generation + || (_pendingEmbed.placeholderId.value != placeholderId.value)) { + return; + } + if (_body) { + _body->clearPlaceholderLoading(placeholderId); + } + _pendingEmbed.placeholderId = {}; + }; + if (!_embedOverlay) { + finishPending(); + return false; + } + const auto started = _embedOverlay->preloadEmbed( + activation.embed, + finishPending, + finishPending); + if (!started) { + finishPending(); + } + return started; } void MarkdownPreviewRoot::fillFootnoteBox( @@ -509,9 +566,7 @@ void MarkdownPreviewRoot::applyPreparedContent( int prepareMs) { const auto failure = prepared.failure; const auto debug = prepared.debug; - if (_embedOverlay) { - _embedOverlay->closeEmbed(); - } + closeEmbed(); if (failure.failed()) { _article = nullptr; _footnotes.clear(); diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_view_widget.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_view_widget.cpp index 2122e12593..12f975bf1f 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_view_widget.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_view_widget.cpp @@ -322,6 +322,41 @@ void MarkdownDocumentWidget::requestRelayout(QRect articleRect) { }); } +void MarkdownDocumentWidget::setPlaceholderLoading( + PreparedPlaceholderBlockId id) { + if (_article) { + _article->setPlaceholderLoading(id); + } +} + +void MarkdownDocumentWidget::clearPlaceholderLoading( + PreparedPlaceholderBlockId id) { + if (_article) { + _article->clearPlaceholderLoading(id); + } +} + +void MarkdownDocumentWidget::clearAllPlaceholderLoading() { + if (_article) { + _article->clearAllPlaceholderLoading(); + } +} + +void MarkdownDocumentWidget::addPlaceholderRipple( + PreparedPlaceholderBlockId id, + QPoint point) { + if (_article) { + _article->addPlaceholderRipple(id, point); + } +} + +void MarkdownDocumentWidget::stopPlaceholderRipple( + PreparedPlaceholderBlockId id) { + if (_article) { + _article->stopPlaceholderRipple(id); + } +} + void MarkdownDocumentWidget::paintEvent(QPaintEvent *e) { if (!_article) { return; @@ -519,6 +554,7 @@ void MarkdownDocumentWidget::mouseDoubleClickEvent(QMouseEvent *e) { } void MarkdownDocumentWidget::focusOutEvent(QFocusEvent *e) { + stopPressedPlaceholderRipple(); if (!_selection.empty()) { _savedSelection = _selection; _savedSelectionEndpoints = _selectionEndpoints; @@ -868,9 +904,17 @@ MarkdownArticlePaintCaches MarkdownDocumentWidget::textPaintCaches() { }; } +void MarkdownDocumentWidget::stopPressedPlaceholderRipple() { + if (_pressedPlaceholderId) { + stopPlaceholderRipple(_pressedPlaceholderId); + _pressedPlaceholderId = {}; + } +} + void MarkdownDocumentWidget::dragActionStart( QPoint point, Qt::MouseButton button) { + stopPressedPlaceholderRipple(); const auto state = hitTest( point, Ui::Text::StateRequest::Flag::LookupLink @@ -879,6 +923,13 @@ void MarkdownDocumentWidget::dragActionStart( if (button != Qt::LeftButton) { return; } + if (state.mediaActivation.kind == MediaActivationKind::Embed + && state.mediaActivation.placeholderId) { + _pressedPlaceholderId = state.mediaActivation.placeholderId; + addPlaceholderRipple( + state.mediaActivation.placeholderId, + state.placeholderLocalPoint); + } _dragStartPosition = point; _dragStartHadSelection = !selectionForCopy().empty(); _selectionClickPreparedLink = (state.preparedLink @@ -933,6 +984,7 @@ MarkdownArticleHitTestResult MarkdownDocumentWidget::dragActionFinish( QPoint point, Qt::MouseButton button) { const auto state = dragActionUpdate(point); + stopPressedPlaceholderRipple(); auto activated = ClickHandler::unpressed(); const auto dragStartHadSelection = _dragStartHadSelection; const auto toggleFromDetailsClick = !dragStartHadSelection diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_view_widget.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_view_widget.h index 8bdf7b7038..972866220e 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_view_widget.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_view_widget.h @@ -57,6 +57,11 @@ public: int resizeGetHeight(int newWidth) override; void requestRepaint(QRect articleRect) override; void requestRelayout(QRect articleRect) override; + void setPlaceholderLoading(PreparedPlaceholderBlockId id); + void clearPlaceholderLoading(PreparedPlaceholderBlockId id); + void clearAllPlaceholderLoading(); + void addPlaceholderRipple(PreparedPlaceholderBlockId id, QPoint point); + void stopPlaceholderRipple(PreparedPlaceholderBlockId id); protected: void paintEvent(QPaintEvent *e) override; @@ -112,6 +117,7 @@ private: [[nodiscard]] Ui::Text::QuotePaintCache *ensurePrePaintCache(); [[nodiscard]] Ui::Text::QuotePaintCache *ensureBlockquotePaintCache(); [[nodiscard]] MarkdownArticlePaintCaches textPaintCaches(); + void stopPressedPlaceholderRipple(); void dragActionStart(QPoint point, Qt::MouseButton button); MarkdownArticleHitTestResult dragActionUpdate(QPoint point); MarkdownArticleHitTestResult dragActionFinish( @@ -141,6 +147,7 @@ private: int _dragSymbol = 0; TextSelection _dragExpandedSelection; std::optional _selectionClickPreparedLink; + PreparedPlaceholderBlockId _pressedPlaceholderId; bool _dragStartHadSelection = false; int _lastRelayoutMs = 0; int _zoom = 100; diff --git a/Telegram/SourceFiles/tests/test_markdown_iv.cpp b/Telegram/SourceFiles/tests/test_markdown_iv.cpp index aa3301d025..4d6dd81bc1 100644 --- a/Telegram/SourceFiles/tests/test_markdown_iv.cpp +++ b/Telegram/SourceFiles/tests/test_markdown_iv.cpp @@ -1243,7 +1243,7 @@ constexpr auto kNativeIvEmbedPostAuthorPhotoId = uint64(9301); case NativeIvPlaceholderKind::Video: return u"Video Placeholder"_q; case NativeIvPlaceholderKind::Embed: - return u"Embed Placeholder"_q; + return u"Click to View"_q; case NativeIvPlaceholderKind::Collage: return u"Collage placeholder"_q; case NativeIvPlaceholderKind::Slideshow: @@ -4208,6 +4208,8 @@ void CheckNativeInstantViewPrepareCoverage(bool *ok) { const PreparedBlock *embedPost = nullptr; const PreparedBlock *coveredPhoto = nullptr; auto preparedPlaceholders = std::vector(); + auto preparedPlaceholderIds = std::vector< + std::pair>(); ForEachPreparedBlock( supported.content.blocks.blocks, [&](const PreparedBlock &block) { @@ -5207,12 +5209,19 @@ void CheckNativeInstantViewPrepareCoverage(bool *ok) { == (fixture.expectedLabel + u"\n"_q + fixture.caption), fixture.expectedLabel + u" placeholder copy text"_q, ok); + Check( + bool(block->placeholder.id), + fixture.expectedLabel + u" placeholder id"_q, + ok); Check( block->placeholder.embed.has_value(), fixture.expectedLabel + u" placeholder embed metadata"_q, ok); if (block->placeholder.embed) { const auto &embed = *block->placeholder.embed; + preparedPlaceholderIds.emplace_back( + embed.fallbackUrl, + block->placeholder.id); Check( !embed.resourceId.isEmpty(), fixture.expectedLabel + u" placeholder embed resource id"_q, @@ -5470,6 +5479,12 @@ void CheckNativeInstantViewPrepareCoverage(bool *ok) { const auto hit = placeholderArticle->hitTest( bounds->center(), lookupFlags); + const auto preparedId = std::find_if( + preparedPlaceholderIds.begin(), + preparedPlaceholderIds.end(), + [&](const auto &entry) { + return entry.first == fixture.expectedFallbackUrl; + }); Check( hit.mediaActivation.kind == MediaActivationKind::Embed, fixture.expectedLabel + u" article embed activation kind"_q, @@ -5484,22 +5499,40 @@ void CheckNativeInstantViewPrepareCoverage(bool *ok) { == fixture.expectedAllowScrolling, fixture.expectedLabel + u" article embed activation scrolling"_q, ok); + Check( + bool(hit.mediaActivation.placeholderId), + fixture.expectedLabel + u" article embed placeholder id"_q, + ok); + Check( + (preparedId != preparedPlaceholderIds.end()) + && (hit.mediaActivation.placeholderId.value + == preparedId->second.value), + fixture.expectedLabel + u" article embed placeholder id match"_q, + ok); } } } + const auto makePreviewEmbedBlock = []( + QString url, + QString caption) { + return MTP_pageBlockEmbed( + MTP_flags( + MTPDpageBlockEmbed::Flag::f_allow_scrolling + | MTPDpageBlockEmbed::Flag::f_url + | MTPDpageBlockEmbed::Flag::f_w), + MTP_string(url), + MTP_string(), + MTP_long(0), + MTP_int(640), + MTP_int(0), + NativeIvCaption(caption)); + }; + const auto previewEmbedLabel = u"native-iv-embed-preview-overlay"_q; - const auto previewEmbedBlock = MTP_pageBlockEmbed( - MTP_flags( - MTPDpageBlockEmbed::Flag::f_allow_scrolling - | MTPDpageBlockEmbed::Flag::f_url - | MTPDpageBlockEmbed::Flag::f_w), - MTP_string("https://example.com/embed"), - MTP_string(), - MTP_long(0), - MTP_int(640), - MTP_int(0), - NativeIvCaption(u"Embed caption"_q)); + const auto previewEmbedBlock = makePreviewEmbedBlock( + u"https://example.com/embed"_q, + u"Embed caption"_q); auto previewEmbedSource = NativeIvSource(QVector{ previewEmbedBlock, }); @@ -5605,20 +5638,28 @@ void CheckNativeInstantViewPrepareCoverage(bool *ok) { previewEmbedLabel + u" click bounds"_q, ok); if (clickBounds) { + const auto bodyBeforeClick = RenderWidgetForTest(body); Check( !overlayShell->isVisible(), previewEmbedLabel + u" overlay hidden before click"_q, ok); SendMouseClick(body, clickBounds->center(), Qt::LeftButton); FlushPendingWidgetEvents(); + const auto loadingBody = RenderWidgetForTest(body); Check( - overlayShell->isVisible(), - previewEmbedLabel + u" overlay visible after click"_q, + !overlayShell->isVisible(), + previewEmbedLabel + u" overlay hidden after click"_q, ok); Check( overlay->testLoadingCoverVisible(), - previewEmbedLabel - + u" loading cover visible before navigation done"_q, + previewEmbedLabel + u" preload active after click"_q, + ok); + Check( + PixelsDifferInRect( + bodyBeforeClick, + loadingBody, + *clickBounds), + previewEmbedLabel + u" placeholder loading visible"_q, ok); const auto availableRect = NativeIvOverlayAvailableRect( overlay); @@ -5626,148 +5667,387 @@ void CheckNativeInstantViewPrepareCoverage(bool *ok) { availableRect.isValid(), previewEmbedLabel + u" overlay available rect"_q, ok); - const auto overlayBeforeResize = RenderWidgetForTest(overlay); - Check( - !overlayBeforeResize.isNull(), - previewEmbedLabel - + u" overlay render before preferred size"_q, - ok); - const auto loadingGeometry = overlayShell->geometry(); - auto scrimPoint = QPoint(); - auto scrimPixel = uint(0); - auto canCompareScrimPixel = false; - if (availableRect.isValid() && !overlayBeforeResize.isNull()) { - scrimPoint = QPoint( - std::max(availableRect.left() / 2, 0), - std::clamp( - availableRect.center().y(), - 0, - overlayBeforeResize.height() - 1)); - canCompareScrimPixel - = !overlayShell->geometry().contains(scrimPoint); - Check( - canCompareScrimPixel, - previewEmbedLabel - + u" scrim sample sits outside shell"_q, - ok); - if (canCompareScrimPixel) { - scrimPixel = overlayBeforeResize.pixel(scrimPoint); - } - } - const auto preferredBodySize = QSize(180, 96); overlay->testHandleWebviewMessage( - NativeIvPreferredSizeMessage(preferredBodySize)); + NativeIvPreferredSizeMessage(QSize(180, 96))); FlushPendingWidgetEvents(); + Check( + !overlayShell->isVisible(), + previewEmbedLabel + u" overlay stays hidden after preferred size"_q, + ok); + Check( + !overlay->testReadyDelayScheduled(), + previewEmbedLabel + u" ready delay waits for readiness"_q, + ok); + overlay->testHandleNavigationDone(true); + FlushPendingWidgetEvents(); + Check( + !overlayShell->isVisible(), + previewEmbedLabel + u" overlay stays hidden after readiness"_q, + ok); + Check( + overlay->testLoadingCoverVisible(), + previewEmbedLabel + u" preload stays active after readiness"_q, + ok); + Check( + overlay->testReadyDelayScheduled(), + previewEmbedLabel + u" ready delay scheduled after readiness"_q, + ok); + overlay->testHandleNavigationDone(false); + FlushPendingWidgetEvents(); + Check( + !overlayShell->isVisible(), + previewEmbedLabel + + u" late navigation failure keeps ready preload hidden"_q, + ok); Check( overlay->testLoadingCoverVisible(), previewEmbedLabel - + u" loading cover persists after preferred size"_q, + + u" late navigation failure keeps preload active"_q, ok); Check( - overlayShell->geometry() == loadingGeometry, + overlay->testReadyDelayScheduled(), previewEmbedLabel - + u" overlay defers preferred size while loading"_q, + + u" late navigation failure keeps ready delay"_q, ok); - const auto overlayAfterResize = RenderWidgetForTest(overlay); - Check( - !overlayAfterResize.isNull(), - previewEmbedLabel - + u" overlay render after preferred size"_q, - ok); - if (canCompareScrimPixel && !overlayAfterResize.isNull()) { - Check( - !overlayShell->geometry().contains(scrimPoint), - previewEmbedLabel - + u" scrim sample stays outside shell"_q, - ok); - if (!overlayShell->geometry().contains(scrimPoint)) { - Check( - overlayAfterResize.pixel(scrimPoint) - == scrimPixel, - previewEmbedLabel - + u" scrim pixel stable after resize"_q, - ok); - } - } + const auto readyGeometry = overlayShell->geometry(); if (availableRect.isValid()) { overlay->testHandleWebviewMessage( NativeIvPreferredSizeMessage(QSize( availableRect.width() * 2, availableRect.height() * 2))); FlushPendingWidgetEvents(); + } + const auto settledGeometry = overlayShell->geometry(); + Check( + !overlayShell->isVisible(), + previewEmbedLabel + + u" overlay stays hidden during settled resize"_q, + ok); + Check( + overlay->testReadyDelayScheduled(), + previewEmbedLabel + u" ready delay remains scheduled"_q, + ok); + if (availableRect.isValid()) { Check( - overlay->testLoadingCoverVisible(), + readyGeometry != settledGeometry, previewEmbedLabel - + u" loading cover persists before navigation complete"_q, + + u" hidden preload accepts later preferred size"_q, ok); Check( - overlayShell->geometry() == loadingGeometry, + settledGeometry == availableRect, previewEmbedLabel - + u" overlay defers clamped resize while loading"_q, - ok); - const auto overlayAfterClamp = RenderWidgetForTest( - overlay); - Check( - !overlayAfterClamp.isNull(), - previewEmbedLabel - + u" overlay render after clamped resize"_q, - ok); - if (canCompareScrimPixel && !overlayAfterClamp.isNull()) { - Check( - !overlayShell->geometry().contains(scrimPoint), - previewEmbedLabel - + u" scrim sample stays outside clamped shell"_q, - ok); - if (!overlayShell->geometry().contains(scrimPoint)) { - Check( - overlayAfterClamp.pixel(scrimPoint) - == scrimPixel, - previewEmbedLabel - + u" scrim pixel stable after clamped resize"_q, - ok); - } - } - overlay->testHandleNavigationDone(true); - FlushPendingWidgetEvents(); - Check( - !overlay->testLoadingCoverVisible(), - previewEmbedLabel - + u" loading cover hides after navigation done"_q, - ok); - Check( - overlayShell->geometry() == availableRect, - previewEmbedLabel - + u" overlay shell stays clamped after navigation done"_q, - ok); - overlay->closeEmbed(); - FlushPendingWidgetEvents(); - Check( - !overlayShell->isVisible(), - previewEmbedLabel - + u" overlay hides before failed navigation retry"_q, - ok); - SendMouseClick(body, clickBounds->center(), Qt::LeftButton); - FlushPendingWidgetEvents(); - Check( - overlay->testLoadingCoverVisible(), - previewEmbedLabel - + u" loading cover visible before failed navigation"_q, - ok); - overlay->testHandleNavigationDone(false); - FlushPendingWidgetEvents(); - const auto overlayError = preview->findChild( - u"nativeIvEmbedOverlayErrorWrap"_q); - Check( - !overlay->testLoadingCoverVisible(), - previewEmbedLabel - + u" loading cover hides after failed navigation"_q, - ok); - Check( - overlayError && overlayError->isVisible(), - previewEmbedLabel - + u" overlay shows error after failed navigation"_q, + + u" hidden preload clamps latest preferred size"_q, ok); } + overlay->testFireReadyDelay(); + FlushPendingWidgetEvents(); + const auto revealedBody = RenderWidgetForTest(body); + Check( + overlayShell->isVisible(), + previewEmbedLabel + u" overlay reveals after ready delay"_q, + ok); + Check( + !overlay->testLoadingCoverVisible(), + previewEmbedLabel + u" preload clears after reveal"_q, + ok); + Check( + !overlay->testReadyDelayScheduled(), + previewEmbedLabel + u" ready delay clears after reveal"_q, + ok); + Check( + overlayShell->geometry() == settledGeometry, + previewEmbedLabel + + u" reveal uses latest settled geometry"_q, + ok); + Check( + PixelsDifferInRect( + loadingBody, + revealedBody, + *clickBounds), + previewEmbedLabel + u" placeholder loading clears on reveal"_q, + ok); + overlay->testHandleNavigationDone(false); + FlushPendingWidgetEvents(); + Check( + overlayShell->isVisible(), + previewEmbedLabel + + u" late navigation failure keeps revealed overlay"_q, + ok); + overlay->closeEmbed(); + FlushPendingWidgetEvents(); + Check( + !overlayShell->isVisible(), + previewEmbedLabel + u" overlay hides before failure retry"_q, + ok); + const auto bodyBeforeFailureClick = RenderWidgetForTest(body); + SendMouseClick(body, clickBounds->center(), Qt::LeftButton); + FlushPendingWidgetEvents(); + const auto failureLoadingBody = RenderWidgetForTest(body); + Check( + !overlayShell->isVisible(), + previewEmbedLabel + u" overlay stays hidden on failure click"_q, + ok); + Check( + overlay->testLoadingCoverVisible(), + previewEmbedLabel + u" preload active before failure"_q, + ok); + Check( + PixelsDifferInRect( + bodyBeforeFailureClick, + failureLoadingBody, + *clickBounds), + previewEmbedLabel + u" placeholder loading visible on retry"_q, + ok); + overlay->testHandleNavigationDone(false); + FlushPendingWidgetEvents(); + const auto bodyAfterFailure = RenderWidgetForTest(body); + const auto overlayError = preview->findChild( + u"nativeIvEmbedOverlayErrorWrap"_q); + Check( + !overlayShell->isVisible(), + previewEmbedLabel + u" preload failure stays hidden"_q, + ok); + Check( + !overlay->testLoadingCoverVisible(), + previewEmbedLabel + u" preload clears after failure"_q, + ok); + Check( + !overlay->testReadyDelayScheduled(), + previewEmbedLabel + u" ready delay clears after failure"_q, + ok); + Check( + PixelsDifferInRect( + failureLoadingBody, + bodyAfterFailure, + *clickBounds), + previewEmbedLabel + u" placeholder loading clears on failure"_q, + ok); + Check( + !overlayError || overlayError->isHidden(), + previewEmbedLabel + u" preload failure keeps overlay error hidden"_q, + ok); + } + } + } + } + + const auto previewCancelLabel = u"native-iv-embed-preview-cancel"_q; + const auto firstPreviewBlock = makePreviewEmbedBlock( + u"https://example.com/embed-first"_q, + u"First embed"_q); + const auto secondPreviewBlock = makePreviewEmbedBlock( + u"https://example.com/embed-second"_q, + u"Second embed"_q); + auto previewCancelSource = NativeIvSource(QVector{ + firstPreviewBlock, + secondPreviewBlock, + }); + const auto previewCancelPrepared = TryPrepareNativeInstantView({ + .source = &previewCancelSource, + }); + Check( + previewCancelPrepared.supported(), + previewCancelLabel + u" prepare supported"_q, + ok); + Check( + !previewCancelPrepared.content.failure.failed(), + previewCancelLabel + u" prepare failure"_q, + ok); + if (previewCancelPrepared.supported() + && !previewCancelPrepared.content.failure.failed()) { + auto window = Ui::RpWindow(); + window.setGeometry(QRect(0, 0, 420, 400)); + window.show(); + FlushPendingWidgetEvents(); + auto previewOptions = OpenOptions(); + previewOptions.ivWebviewStorageId = { + u"native-iv-preview-cancel"_q, + QByteArray("phase-5"), + }; + auto preview = CreateMarkdownPreviewWidget( + window.body(), + std::move(previewCancelPrepared.content), + std::make_shared(), + [](Event) { + }, + previewOptions); + preview->setGeometry(QRect(QPoint(), window.body()->size())); + preview->show(); + FlushPendingWidgetEvents(); + const auto body = FindChildObject(preview.get()); + const auto overlay = preview->findChild( + u"nativeIvEmbedOverlay"_q); + const auto overlayShell = preview->findChild( + u"nativeIvEmbedOverlayShell"_q); + Check( + body != nullptr, + previewCancelLabel + u" preview body widget"_q, + ok); + Check( + overlay != nullptr, + previewCancelLabel + u" overlay object"_q, + ok); + Check( + overlayShell != nullptr, + previewCancelLabel + u" overlay shell object"_q, + ok); + if (body && overlay && overlayShell) { + auto previewProbeSource = NativeIvSource(QVector{ + firstPreviewBlock, + secondPreviewBlock, + }); + const auto previewProbePrepared = TryPrepareNativeInstantView({ + .source = &previewProbeSource, + }); + Check( + previewProbePrepared.supported(), + previewCancelLabel + u" probe prepare supported"_q, + ok); + Check( + !previewProbePrepared.content.failure.failed(), + previewCancelLabel + u" probe prepare failure"_q, + ok); + if (previewProbePrepared.supported() + && !previewProbePrepared.content.failure.failed()) { + auto probeHeight = 0; + auto probeArticle = BuildArticleForTest( + std::move(previewProbePrepared.content), + std::make_shared(), + body->width(), + &probeHeight); + auto lookupFlags = Ui::Text::StateRequest::Flags(); + lookupFlags |= Ui::Text::StateRequest::Flag::LookupSymbol; + const auto firstBounds = HitBoundsWhere( + probeArticle.get(), + body->width(), + probeHeight, + lookupFlags, + [](const MarkdownArticleHitTestResult &hit) { + return hit.mediaActivation.kind == MediaActivationKind::Embed + && (hit.mediaActivation.embed.fallbackUrl + == u"https://example.com/embed-first"_q); + }); + const auto secondBounds = HitBoundsWhere( + probeArticle.get(), + body->width(), + probeHeight, + lookupFlags, + [](const MarkdownArticleHitTestResult &hit) { + return hit.mediaActivation.kind == MediaActivationKind::Embed + && (hit.mediaActivation.embed.fallbackUrl + == u"https://example.com/embed-second"_q); + }); + Check( + firstBounds.has_value(), + previewCancelLabel + u" first click bounds"_q, + ok); + Check( + secondBounds.has_value(), + previewCancelLabel + u" second click bounds"_q, + ok); + if (firstBounds && secondBounds) { + const auto bodyBeforeFirstClick = RenderWidgetForTest(body); + Check( + !overlayShell->isVisible(), + previewCancelLabel + u" overlay hidden before first click"_q, + ok); + SendMouseClick(body, firstBounds->center(), Qt::LeftButton); + FlushPendingWidgetEvents(); + const auto firstLoadingBody = RenderWidgetForTest(body); + Check( + !overlayShell->isVisible(), + previewCancelLabel + u" overlay hidden after first click"_q, + ok); + Check( + overlay->testLoadingCoverVisible(), + previewCancelLabel + u" first preload active"_q, + ok); + Check( + PixelsDifferInRect( + bodyBeforeFirstClick, + firstLoadingBody, + *firstBounds), + previewCancelLabel + u" first placeholder loading visible"_q, + ok); + overlay->testHandleNavigationDone(true); + FlushPendingWidgetEvents(); + Check( + overlay->testReadyDelayScheduled(), + previewCancelLabel + u" first ready delay scheduled"_q, + ok); + SendMouseClick(body, secondBounds->center(), Qt::LeftButton); + FlushPendingWidgetEvents(); + const auto secondLoadingBody = RenderWidgetForTest(body); + Check( + !overlayShell->isVisible(), + previewCancelLabel + u" overlay hidden after second click"_q, + ok); + Check( + overlay->testLoadingCoverVisible(), + previewCancelLabel + u" second preload active"_q, + ok); + Check( + !overlay->testReadyDelayScheduled(), + previewCancelLabel + u" first ready delay canceled"_q, + ok); + Check( + PixelsDifferInRect( + firstLoadingBody, + secondLoadingBody, + *firstBounds), + previewCancelLabel + u" first placeholder loading cleared"_q, + ok); + Check( + PixelsDifferInRect( + firstLoadingBody, + secondLoadingBody, + *secondBounds), + previewCancelLabel + u" second placeholder loading visible"_q, + ok); + overlay->testFireReadyDelay(); + FlushPendingWidgetEvents(); + Check( + !overlayShell->isVisible(), + previewCancelLabel + u" stale first ready delay does not reveal"_q, + ok); + Check( + overlay->testLoadingCoverVisible(), + previewCancelLabel + u" second preload stays active"_q, + ok); + overlay->testHandleWebviewMessage( + NativeIvPreferredSizeMessage(QSize(220, 120))); + FlushPendingWidgetEvents(); + overlay->testHandleNavigationDone(true); + FlushPendingWidgetEvents(); + Check( + overlay->testReadyDelayScheduled(), + previewCancelLabel + u" second ready delay scheduled"_q, + ok); + const auto secondSettledGeometry = overlayShell->geometry(); + overlay->testFireReadyDelay(); + FlushPendingWidgetEvents(); + const auto revealedBody = RenderWidgetForTest(body); + Check( + overlayShell->isVisible(), + previewCancelLabel + u" second preload reveals"_q, + ok); + Check( + !overlay->testLoadingCoverVisible(), + previewCancelLabel + u" second preload clears after reveal"_q, + ok); + Check( + overlayShell->geometry() == secondSettledGeometry, + previewCancelLabel + u" second reveal uses settled geometry"_q, + ok); + Check( + PixelsDifferInRect( + secondLoadingBody, + revealedBody, + *secondBounds), + previewCancelLabel + u" second placeholder loading clears"_q, + ok); + overlay->closeEmbed(); + FlushPendingWidgetEvents(); } } }