diff --git a/Telegram/SourceFiles/iv/iv.style b/Telegram/SourceFiles/iv/iv.style index 63cd0e17ca..e42addcdda 100644 --- a/Telegram/SourceFiles/iv/iv.style +++ b/Telegram/SourceFiles/iv/iv.style @@ -81,6 +81,11 @@ MarkdownBlockSkips { displayMath: pixels; table: pixels; photo: pixels; + video: pixels; + audio: pixels; + map: pixels; + channel: pixels; + groupedMedia: pixels; placeholder: pixels; } MarkdownList { @@ -151,6 +156,49 @@ MarkdownPhoto { progressSize: pixels; progressWidth: pixels; } +MarkdownAudio { + padding: margins; + captionSkip: pixels; + textSkip: pixels; + border: pixels; + borderFg: color; + bg: color; + radius: pixels; + titleStyle: TextStyle; + titleFg: color; + subtitleStyle: TextStyle; + subtitleFg: color; +} +MarkdownChannelButton { + padding: margins; + border: pixels; + borderFg: color; + bg: color; + radius: pixels; + textStyle: TextStyle; + textFg: color; +} +MarkdownChannel { + padding: margins; + border: pixels; + borderFg: color; + bg: color; + radius: pixels; + textSkip: pixels; + buttonSkip: pixels; + titleStyle: TextStyle; + titleFg: color; + subtitleStyle: TextStyle; + subtitleFg: color; + button: MarkdownChannelButton; +} +MarkdownGroupedMedia { + padding: margins; + captionSkip: pixels; + itemSkip: pixels; + radius: pixels; + overlayOpacity: double; +} MarkdownFailure { label: FlatLabel; width: pixels; @@ -178,6 +226,9 @@ Markdown { details: MarkdownDetails; placeholder: MarkdownPlaceholder; photo: MarkdownPhoto; + audio: MarkdownAudio; + channel: MarkdownChannel; + groupedMedia: MarkdownGroupedMedia; failure: MarkdownFailure; } @@ -206,6 +257,11 @@ defaultMarkdownBlockSkips: MarkdownBlockSkips { displayMath: 20px; table: 20px; photo: 20px; + video: 20px; + audio: 20px; + map: 20px; + channel: 20px; + groupedMedia: 20px; placeholder: 20px; } defaultMarkdownList: MarkdownList { @@ -283,6 +339,60 @@ defaultMarkdownPhoto: MarkdownPhoto { progressSize: 36px; progressWidth: 3px; } +defaultMarkdownAudioTitleStyle: TextStyle(defaultMarkdownBodyStyle) { + font: font(16px semibold); +} +defaultMarkdownAudioSubtitleStyle: TextStyle(defaultMarkdownDetailsSummaryStyle) { +} +defaultMarkdownAudio: MarkdownAudio { + padding: margins(8px, 12px, 8px, 12px); + captionSkip: 14px; + textSkip: 8px; + border: 1px; + borderFg: inputBorderFg; + bg: windowBgOver; + radius: 6px; + titleStyle: defaultMarkdownAudioTitleStyle; + titleFg: windowFg; + subtitleStyle: defaultMarkdownAudioSubtitleStyle; + subtitleFg: windowSubTextFg; +} +defaultMarkdownChannelTitleStyle: TextStyle(defaultMarkdownAudioTitleStyle) { +} +defaultMarkdownChannelSubtitleStyle: TextStyle(defaultMarkdownDetailsSummaryStyle) { +} +defaultMarkdownChannelButtonStyle: TextStyle(defaultMarkdownDetailsSummaryStyle) { +} +defaultMarkdownChannelButton: MarkdownChannelButton { + padding: margins(10px, 12px, 10px, 12px); + border: 1px; + borderFg: windowActiveTextFg; + bg: windowBg; + radius: 6px; + textStyle: defaultMarkdownChannelButtonStyle; + textFg: windowActiveTextFg; +} +defaultMarkdownChannel: MarkdownChannel { + padding: margins(8px, 12px, 8px, 12px); + border: 1px; + borderFg: inputBorderFg; + bg: windowBgOver; + radius: 6px; + textSkip: 8px; + buttonSkip: 8px; + titleStyle: defaultMarkdownChannelTitleStyle; + titleFg: windowFg; + subtitleStyle: defaultMarkdownChannelSubtitleStyle; + subtitleFg: windowSubTextFg; + button: defaultMarkdownChannelButton; +} +defaultMarkdownGroupedMedia: MarkdownGroupedMedia { + padding: margins(4px, 0px, 0px, 0px); + captionSkip: 12px; + itemSkip: 4px; + radius: 6px; + overlayOpacity: 0.2; +} defaultMarkdownFailureLabel: FlatLabel(defaultFlatLabel) { minWidth: 280px; textFg: windowSubTextFg; @@ -346,5 +456,8 @@ defaultMarkdown: Markdown { details: defaultMarkdownDetails; placeholder: defaultMarkdownPlaceholder; photo: defaultMarkdownPhoto; + audio: defaultMarkdownAudio; + channel: defaultMarkdownChannel; + groupedMedia: defaultMarkdownGroupedMedia; failure: defaultMarkdownFailure; } diff --git a/Telegram/SourceFiles/iv/iv_instance.cpp b/Telegram/SourceFiles/iv/iv_instance.cpp index 869bc16767..33ec0e9922 100644 --- a/Telegram/SourceFiles/iv/iv_instance.cpp +++ b/Telegram/SourceFiles/iv/iv_instance.cpp @@ -50,12 +50,16 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "ui/basic_click_handlers.h" #include "ui/dynamic_image.h" #include "ui/dynamic_thumbnails.h" +#include "ui/painter.h" #include "webview/webview_data_stream_memory.h" #include "webview/webview_interface.h" #include "window/window_controller.h" #include "window/window_session_controller.h" #include "window/window_session_controller_link_info.h" +#include "styles/palette.h" +#include "styles/style_chat.h" + #include #include #include @@ -64,6 +68,39 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include namespace Iv { + +struct NativeIvChannelContext { + uint64 channelId = 0; + QString username; +}; + +[[nodiscard]] NativeIvChannelContext ParseNativeIvChannelContext( + const QString &context) { + const auto separator = context.indexOf(u'\n'); + return { + .channelId = (separator >= 0) + ? context.mid(0, separator).toULongLong() + : context.toULongLong(), + .username = (separator >= 0) ? context.mid(separator + 1) : QString(), + }; +} + +[[nodiscard]] QString SerializeNativeIvChannelContext( + uint64 channelId, + QString username) { + auto result = QString::number(channelId); + if (!username.isEmpty()) { + result += u"\n"_q + username; + } + return result; +} + +[[nodiscard]] QString ResolveNativeIvChannelUsername( + const QString &channelUsername, + const QString &contextUsername) { + return !channelUsername.isEmpty() ? channelUsername : contextUsername; +} + namespace { constexpr auto kGeoPointScale = 1; @@ -242,13 +279,297 @@ private: }; +[[nodiscard]] ImageWithLocation CachedPageMapImageData( + double latitude, + double longitude, + uint64 accessHash, + QSize size, + int zoom) { + const auto location = GeoPointLocation{ + .lat = latitude, + .lon = longitude, + .access = accessHash, + .width = std::max(size.width(), 1), + .height = std::max(size.height(), 1), + .zoom = std::max(zoom, kGeoPointZoomMin), + .scale = kGeoPointScale, + }; + return { + .location = ImageLocation( + { location }, + location.width, + location.height), + }; +} + +class CachedPageDocumentRuntime final : public Markdown::DocumentRuntime { +public: + CachedPageDocumentRuntime( + not_null session, + not_null document, + ::Data::FileOrigin origin) + : _session(session) + , _document(document) + , _origin(std::move(origin)) + , _media(document->createMediaView()) { + } + + [[nodiscard]] std::shared_ptr thumbnail( + QSize size) const override { + Q_UNUSED(size); + return Ui::MakeDocumentThumbnailFit(_document, _origin); + } + + [[nodiscard]] std::shared_ptr full( + QSize size) const override { + Q_UNUSED(size); + return Ui::MakeDocumentThumbnail(_document, _origin); + } + + [[nodiscard]] bool loaded() const override { + return _media->loaded(); + } + + [[nodiscard]] bool loading() const override { + return _document->displayLoading(); + } + + [[nodiscard]] double progress() const override { + return _document->progress(); + } + + void open(Qt::MouseButton button) const override { + if (button != Qt::LeftButton && button != Qt::MiddleButton) { + return; + } + if (const auto window = Core::App().activeWindow()) { + const auto item = (HistoryItem*)nullptr; + window->openInMediaView({ + CurrentSessionController(_session), + _document, + item, + MsgId(0), + PeerId(0), + }); + } + } + +private: + const not_null _session; + const not_null _document; + const ::Data::FileOrigin _origin; + const std::shared_ptr<::Data::DocumentMedia> _media; + +}; + +class CachedPageMapDynamicImage final : public Ui::DynamicImage { +public: + CachedPageMapDynamicImage( + not_null<::Data::CloudImage*> data, + not_null session, + ::Data::FileOrigin origin) + : _data(data) + , _session(session) + , _origin(std::move(origin)) { + } + + [[nodiscard]] std::shared_ptr clone() override { + return std::make_shared( + _data, + _session, + _origin); + } + + [[nodiscard]] QImage image(int size) override { + Q_UNUSED(size); + const auto loaded = _view ? *_view : QImage(); + if (loaded.isNull()) { + return QImage(); + } + const auto paletteVersion = style::PaletteVersion(); + if (_prepared.size() == loaded.size() + && _prepared.devicePixelRatio() == loaded.devicePixelRatio() + && _paletteVersion == paletteVersion) { + return _prepared; + } + _paletteVersion = paletteVersion; + _prepared = loaded.copy(); + _prepared.setDevicePixelRatio(loaded.devicePixelRatio()); + const auto ratio = loaded.devicePixelRatio(); + const auto width = int(loaded.width() / ratio); + const auto height = int(loaded.height() / ratio); + const auto markerSize = std::min(width, height); + auto p = Painter(&_prepared); + auto hq = PainterHighQualityEnabler(p); + const auto pinScale = std::min({ + 1.0, + width / (st::historyMapPoint.height() * 2.5), + height / (st::historyMapPoint.height() * 2.5), + }); + const auto center = QPointF(width / 2.0, height / 2.0); + p.translate(center); + p.scale(pinScale, pinScale); + p.translate(-center); + const auto paintMarker = [&](const style::icon &icon) { + icon.paint( + p, + (width - icon.width()) / 2, + (height / 2) - icon.height(), + markerSize); + }; + paintMarker(st::historyMapPoint); + paintMarker(st::historyMapPointInner); + return _prepared; + } + + void subscribeToUpdates(Fn callback) override { + _subscription.destroy(); + if (!callback) { + _view = nullptr; + _prepared = QImage(); + return; + } + _view = _data->createView(); + _data->load(_session, _origin); + if (!_view->isNull()) { + return; + } + _subscription = _session->downloaderTaskFinished( + ) | rpl::filter([=] { + return !_view->isNull(); + }) | rpl::take(1) | rpl::on_next([=] { + _prepared = QImage(); + callback(); + }); + } + +private: + const not_null<::Data::CloudImage*> _data; + const not_null _session; + const ::Data::FileOrigin _origin; + std::shared_ptr _view; + QImage _prepared; + int _paletteVersion = 0; + rpl::lifetime _subscription; + +}; + +class CachedPageMapRuntime final : public Markdown::MapRuntime { +public: + CachedPageMapRuntime( + not_null session, + ::Data::FileOrigin origin, + double latitude, + double longitude, + uint64 accessHash, + QSize size, + int zoom) + : _session(session) + , _origin(std::move(origin)) + , _image(session, CachedPageMapImageData( + latitude, + longitude, + accessHash, + size, + zoom)) { + } + + [[nodiscard]] std::shared_ptr thumbnail( + QSize size) const override { + Q_UNUSED(size); + ensureLoaded(); + return std::make_shared( + &_image, + _session, + _origin); + } + + [[nodiscard]] std::shared_ptr full( + QSize size) const override { + Q_UNUSED(size); + ensureLoaded(); + return std::make_shared( + &_image, + _session, + _origin); + } + + [[nodiscard]] bool loaded() const override { + ensureLoaded(); + return _image.loadedOnce(); + } + + [[nodiscard]] bool loading() const override { + ensureLoaded(); + return _image.loading(); + } + + [[nodiscard]] double progress() const override { + ensureLoaded(); + return _image.loadedOnce() ? 1. : 0.; + } + +private: + void ensureLoaded() const { + _image.load(_session, _origin); + } + + const not_null _session; + const ::Data::FileOrigin _origin; + mutable ::Data::CloudImage _image; + +}; + +class CachedPageChannelRuntime final : public Markdown::ChannelRuntime { +public: + CachedPageChannelRuntime( + not_null channel, + QString context, + Fn openChannel, + Fn joinChannel) + : _channel(channel) + , _context(std::move(context)) + , _openChannel(std::move(openChannel)) + , _joinChannel(std::move(joinChannel)) { + } + + [[nodiscard]] bool joinVisible() const override { + return !_channel->amIn(); + } + + void open(Qt::MouseButton button) const override { + if ((button == Qt::LeftButton || button == Qt::MiddleButton) + && _openChannel) { + _openChannel(_context); + } + } + + void join(Qt::MouseButton button) const override { + if ((button == Qt::LeftButton || button == Qt::MiddleButton) + && _joinChannel) { + _joinChannel(_context); + } + } + +private: + const not_null _channel; + const QString _context; + const Fn _openChannel; + const Fn _joinChannel; + +}; + class CachedPageMediaRuntime final : public Markdown::MediaRuntime { public: CachedPageMediaRuntime( not_null session, - not_null page) + not_null page, + Fn openChannel, + Fn joinChannel) : _session(session) - , _page(page) { + , _page(page) + , _openChannel(std::move(openChannel)) + , _joinChannel(std::move(joinChannel)) { } [[nodiscard]] std::shared_ptr resolveInlineImage( @@ -274,13 +595,73 @@ public: fileOrigin()); } + [[nodiscard]] std::shared_ptr resolveDocument( + uint64 documentId) const override { + const auto document = _session->data().document(DocumentId(documentId)); + if (document->isNull()) { + return nullptr; + } + return std::make_shared( + _session, + document, + fileOrigin()); + } + + [[nodiscard]] std::shared_ptr resolveMap( + double latitude, + double longitude, + uint64 accessHash, + QSize size, + int zoom) const override { + return std::make_shared( + _session, + fileOrigin(), + latitude, + longitude, + accessHash, + size, + zoom); + } + + [[nodiscard]] std::shared_ptr resolveChannel( + uint64 channelId, + const QString &username) const override { + const auto channel = _session->data().channel(ChannelId(channelId)); + subscribeToChannel(channelId, channel); + return std::make_shared( + channel, + SerializeNativeIvChannelContext(channelId, username), + _openChannel, + _joinChannel); + } + + [[nodiscard]] rpl::producer channelJoinedChanges() const override { + return _channelJoinedChanges.events(); + } + private: + void subscribeToChannel( + uint64 channelId, + not_null channel) const { + if (_channelJoinedSubscriptions.find(channelId) + != end(_channelJoinedSubscriptions)) { + return; + } + Info::Profile::AmInChannelValue(channel) | rpl::on_next([=](bool) { + _channelJoinedChanges.fire_copy(channelId); + }, _channelJoinedSubscriptions[channelId]); + } + [[nodiscard]] ::Data::FileOrigin fileOrigin() const { return ::Data::FileOriginWebPage{ _page->url }; } const not_null _session; const not_null _page; + const Fn _openChannel; + const Fn _joinChannel; + mutable base::flat_map _channelJoinedSubscriptions; + mutable rpl::event_stream _channelJoinedChanges; }; @@ -405,7 +786,9 @@ public: not_null delegate, not_null session, not_null data, - QString hash); + QString hash, + Fn openChannel, + Fn joinChannel); [[nodiscard]] bool showing( not_null session, @@ -506,6 +889,8 @@ private: const not_null _delegate; const not_null _session; + const Fn _openChannel; + const Fn _joinChannel; std::shared_ptr _show; QString _id; std::unique_ptr _controller; @@ -568,9 +953,13 @@ Shown::Shown( not_null delegate, not_null session, not_null data, - QString hash) + QString hash, + Fn openChannel, + Fn joinChannel) : _delegate(delegate) -, _session(session) { +, _session(session) +, _openChannel(std::move(openChannel)) +, _joinChannel(std::move(joinChannel)) { prepare(data, hash); } @@ -877,7 +1266,11 @@ void Shown::showMarkdownWindowed( std::shared_ptr Shown::createMediaRuntime( not_null page) const { - return std::make_shared(_session, page); + return std::make_shared( + _session, + page, + _openChannel, + _joinChannel); } bool Shown::activateMarkdownMedia( @@ -902,6 +1295,24 @@ bool Shown::activateMarkdownMedia( } activation.photo->open(button); return true; + case Markdown::MediaActivationKind::Document: + if (!activation.document) { + return false; + } + activation.document->open(button); + return true; + case Markdown::MediaActivationKind::OpenChannel: + if (!activation.channel) { + return false; + } + activation.channel->open(button); + return true; + case Markdown::MediaActivationKind::JoinChannel: + if (!activation.channel) { + return false; + } + activation.channel->join(button); + return true; } return false; } @@ -1261,6 +1672,8 @@ void Shown::update(not_null data) { void Shown::showJoinedTooltip() { if (_controller) { _controller->showJoinedTooltip(); + } else if (_markdownController) { + _markdownController->showJoinedTooltip(); } } @@ -1353,7 +1766,17 @@ void Instance::show( _shown->moveTo(data, hash); return; } - _shown = std::make_unique(_delegate, session, data, hash); + _shown = std::make_unique( + _delegate, + session, + data, + hash, + [=](QString context) { + processOpenChannel(context); + }, + [=](QString context) { + processJoinChannel(context); + }); _shownSession = session; _shown->events() | rpl::on_next([=](Controller::Event event) { using Type = Controller::Event::Type; @@ -1730,17 +2153,21 @@ WebPageData *Instance::processReceivedPage( void Instance::processOpenChannel(const QString &context) { if (!_shownSession) { return; - } else if (const auto channelId = ChannelId(context.toLongLong())) { + } + const auto parsed = ParseNativeIvChannelContext(context); + if (const auto channelId = ChannelId(parsed.channelId)) { const auto channel = _shownSession->data().channel(channelId); if (channel->isLoaded()) { if (const auto controller = _shownSession->tryResolveWindow(channel)) { controller->showPeerHistory(channel); _shown = nullptr; } - } else if (!channel->username().isEmpty()) { + } else if (const auto username = ResolveNativeIvChannelUsername( + channel->username(), + parsed.username); !username.isEmpty()) { if (const auto controller = _shownSession->tryResolveWindow(channel)) { controller->showPeerByLink({ - .usernameOrId = channel->username(), + .usernameOrId = username, }); _shown = nullptr; } @@ -1751,15 +2178,19 @@ void Instance::processOpenChannel(const QString &context) { void Instance::processJoinChannel(const QString &context) { if (!_shownSession) { return; - } else if (const auto channelId = ChannelId(context.toLongLong())) { + } + const auto parsed = ParseNativeIvChannelContext(context); + if (const auto channelId = ChannelId(parsed.channelId)) { const auto channel = _shownSession->data().channel(channelId); _joining[_shownSession].emplace(channel); if (channel->isLoaded()) { _shownSession->api().joinChannel(channel); - } else if (!channel->username().isEmpty()) { + } else if (const auto username = ResolveNativeIvChannelUsername( + channel->username(), + parsed.username); !username.isEmpty()) { if (const auto controller = _shownSession->tryResolveWindow(channel)) { controller->showPeerByLink({ - .usernameOrId = channel->username(), + .usernameOrId = username, .joinChannel = true, }); } diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_article.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_article.cpp index 597c2a4d76..895fd3e19f 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_article.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_article.cpp @@ -229,13 +229,35 @@ void RebuildVisibleSegmentLookup( auto result = HitSegmentBoundary( segment, after ? SegmentLength(segment) : 0); - if (segment.block) { - result.mediaActivation = segment.block->activation; - if (const auto prepared = PreparedLinkForMediaActivation( - result.mediaActivation)) { + const auto applyActivation = [&](const MediaActivation &activation) { + result.mediaActivation = activation; + if (const auto prepared = PreparedLinkForMediaActivation(activation)) { result.preparedLink = prepared; result.state.link = CreatePreparedLinkHandler(*prepared); } + }; + if (segment.block) { + if (!segment.block->actionRect.isEmpty() + && segment.block->actionRect.contains(point) + && segment.block->channelRuntime + && segment.block->channelRuntime->joinVisible()) { + applyActivation(segment.block->actionActivation); + } else if (segment.block->kind == PreparedBlockKind::GroupedMedia) { + auto matchedItem = false; + for (const auto &item : segment.block->groupedMediaItems) { + if (!item.rect.contains(point)) { + continue; + } + applyActivation(item.activation); + matchedItem = true; + break; + } + if (!matchedItem) { + applyActivation(segment.block->activation); + } + } else { + applyActivation(segment.block->activation); + } } result.direct = true; return result; @@ -548,6 +570,18 @@ public: ClearColorizedFormulaImages(&_blocks); } + void invalidateLayout() { + _width = -1; + _height = 0; + clearPendingHighlightBlockPointers(); + _blocks.clear(); + _anchors.clear(); + _segments.clear(); + _visibleSegmentSpan = {}; + _segmentTops.clear(); + _segmentBottoms.clear(); + } + private: [[nodiscard]] int currentDevicePixelRatio() const { return std::max(style::DevicePixelRatio(), 1); @@ -634,18 +668,6 @@ private: } } - void invalidateLayout() { - _width = -1; - _height = 0; - clearPendingHighlightBlockPointers(); - _blocks.clear(); - _anchors.clear(); - _segments.clear(); - _visibleSegmentSpan = {}; - _segmentTops.clear(); - _segmentBottoms.clear(); - } - void resetFormulaRasterCache() { _formulaRenders.clear(); _formulaRenders.resize(_content.formulas.size()); @@ -727,6 +749,10 @@ void MarkdownArticle::setContent(MarkdownArticleContent content) { _impl->setContent(std::move(content)); } +void MarkdownArticle::invalidateLayout() { + _impl->invalidateLayout(); +} + int MarkdownArticle::maxWidth() const { return const_cast(_impl.get())->maxWidth(); } diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_article.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_article.h index 2ef5d7ef26..93c4ac39a2 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_article.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_article.h @@ -108,6 +108,7 @@ public: void setRenderer(std::shared_ptr renderer); void setContent(MarkdownArticleContent content); + void invalidateLayout(); [[nodiscard]] int maxWidth() const; [[nodiscard]] int resizeGetHeight(int width); void setVisibleTopBottom(int visibleTop, int visibleBottom); 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 b1e082b38e..db20b04955 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_layout_blocks.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_layout_blocks.cpp @@ -9,11 +9,13 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include "iv/markdown/iv_markdown_article_text.h" #include "lang/lang_keys.h" #include "spellcheck/spellcheck_highlight_syntax.h" +#include "ui/grouped_layout.h" #include "styles/style_iv.h" #include "styles/style_widgets.h" #include +#include #include namespace Iv::Markdown { @@ -29,6 +31,7 @@ constexpr auto kIvMarkedTextOptions = TextParseOptions{ constexpr auto kCodeTabColumns = 4; constexpr auto kCodeTrailingGuard = 0x2060; const auto kPhotoCopyLabel = u"Photo"_q; +const auto kUsernamePrefix = u"@"_q; [[nodiscard]] style::align CellAlign(TableAlignment alignment) { switch (alignment) { @@ -148,6 +151,219 @@ const auto kPhotoCopyLabel = u"Photo"_q; return result; } +template +void ResolveRuntimeImages( + const std::shared_ptr &runtime, + QSize size, + std::shared_ptr *thumbnail, + std::shared_ptr *full) { + if (!runtime) { + return; + } + if (thumbnail) { + *thumbnail = runtime->thumbnail(size); + } + if (full) { + *full = runtime->full(size); + } +} + +void SetPlainTextLeaf( + Ui::Text::String *leaf, + const style::TextStyle &textStyle, + const QString &text, + int width) { + *leaf = Ui::Text::String(TextMinResizeWidth(width)); + leaf->setMarkedText( + textStyle, + TextWithEntities::Simple(text), + kIvMarkedTextOptions); +} + +[[nodiscard]] int LeafHeight( + const Ui::Text::String &leaf, + const style::TextStyle &textStyle, + int width) { + return std::max( + leaf.countHeight(width, true), + TextLineHeight(textStyle)); +} + +[[nodiscard]] int MediaHeightForWidth( + int width, + int aspectWidth, + int aspectHeight) { + aspectWidth = std::max(aspectWidth, 1); + aspectHeight = std::max(aspectHeight, 1); + return std::max( + int((int64(width) * aspectHeight + aspectWidth - 1) / aspectWidth), + 1); +} + +[[nodiscard]] int GroupedMediaMinWidth(int width, int spacing) { + return std::max((width - 2 * spacing) / 3, 1); +} + +void LayoutMediaCaption( + LaidOutBlock *block, + const PreparedBlock &prepared, + const std::vector *formulas, + InlineFormulaObjectCache *inlineFormulaObjects, + const std::shared_ptr &mediaRuntime, + const style::Markdown &markdown, + int left, + int top, + int width, + int skip, + int *bottom) { + if (prepared.text.text.isEmpty()) { + return; + } + block->textWidth = std::max(width, 1); + SetTextLeaf( + &block->leaf, + markdown.body, + prepared.text, + formulas, + inlineFormulaObjects, + mediaRuntime, + block->textWidth); + BindLinks(&block->leaf, prepared.links); + const auto captionTop = top + skip; + const auto captionHeight = std::max( + block->leaf.countHeight(block->textWidth, true), + TextLineHeight(markdown.body)); + block->textRect = QRect(left, captionTop, block->textWidth, captionHeight); + *bottom = captionTop + captionHeight; +} + +[[nodiscard]] QString AudioTitleText(const PreparedAudioBlockData &audio) { + if (!audio.title.isEmpty()) { + return audio.title; + } + if (!audio.fileName.isEmpty()) { + return audio.fileName; + } + return tr::lng_in_dlg_audio_file(tr::now); +} + +[[nodiscard]] QString AudioSubtitleText(const PreparedAudioBlockData &audio) { + if (!audio.performer.isEmpty()) { + return audio.performer; + } + if (!audio.fileName.isEmpty() && audio.fileName != AudioTitleText(audio)) { + return audio.fileName; + } + return QString(); +} + +[[nodiscard]] QString AudioCopyText(const PreparedAudioBlockData &audio) { + const auto title = AudioTitleText(audio); + const auto subtitle = AudioSubtitleText(audio); + return subtitle.isEmpty() ? title : (title + u"\n"_q + subtitle); +} + +[[nodiscard]] QString ChannelSubtitleText( + const PreparedChannelBlockData &channel) { + return channel.username.isEmpty() + ? QString() + : (kUsernamePrefix + channel.username); +} + +[[nodiscard]] QString ChannelCopyText(const PreparedChannelBlockData &channel) { + const auto subtitle = ChannelSubtitleText(channel); + return subtitle.isEmpty() + ? channel.title + : (channel.title + u"\n"_q + subtitle); +} + +[[nodiscard]] QString GroupedMediaCopyText( + const PreparedGroupedMediaBlockData &grouped) { + auto photos = 0; + auto videos = 0; + for (const auto &item : grouped.items) { + if (item.media.kind == PreparedMediaItemKind::Photo) { + ++photos; + } else { + ++videos; + } + } + if (photos && !videos) { + return tr::lng_media_selected_photo(tr::now, lt_count, photos); + } else if (videos && !photos) { + return tr::lng_media_selected_video(tr::now, lt_count, videos); + } + return QString(); +} + +[[nodiscard]] QString GroupedMediaItemCopyText(PreparedMediaItemKind kind) { + return (kind == PreparedMediaItemKind::Photo) + ? kPhotoCopyLabel + : tr::lng_in_dlg_video(tr::now); +} + +[[nodiscard]] int GroupedMediaLayoutWidth( + const std::vector &layout) { + auto result = 0; + for (const auto &part : layout) { + result = std::max( + result, + part.geometry.x() + part.geometry.width()); + } + return result; +} + +[[nodiscard]] int GroupedMediaLayoutHeight( + const std::vector &layout) { + auto result = 0; + for (const auto &part : layout) { + result = std::max( + result, + part.geometry.y() + part.geometry.height()); + } + return result; +} + +void ResolveGroupedMediaItemLayout( + LaidOutGroupedMediaItem *item, + const PreparedGroupedMediaItemData &prepared, + const std::shared_ptr &mediaRuntime, + QRect rect) { + if (!item) { + return; + } + item->kind = prepared.media.kind; + item->copyText = GroupedMediaItemCopyText(prepared.media.kind); + item->rect = rect; + if (prepared.media.kind == PreparedMediaItemKind::Photo) { + if (mediaRuntime) { + item->photoRuntime = mediaRuntime->resolvePhoto(prepared.media.id); + } + ResolveRuntimeImages( + item->photoRuntime, + rect.size(), + &item->thumbnailImage, + &item->fullImage); + if (item->photoRuntime) { + item->activation.kind = MediaActivationKind::Photo; + item->activation.photo = item->photoRuntime; + } + return; + } + if (mediaRuntime) { + item->documentRuntime = mediaRuntime->resolveDocument(prepared.media.id); + } + ResolveRuntimeImages( + item->documentRuntime, + rect.size(), + &item->thumbnailImage, + &item->fullImage); + if (item->documentRuntime) { + item->activation.kind = MediaActivationKind::Document; + item->activation.document = item->documentRuntime; + } +} + } // namespace [[nodiscard]] int SingleDigitOrderedMarkerWidth( @@ -286,10 +502,20 @@ int BlockSkip( return skips.table; case PreparedBlockKind::Photo: return skips.photo; + case PreparedBlockKind::Video: + return skips.video; + case PreparedBlockKind::Audio: + return skips.audio; + case PreparedBlockKind::Map: + return skips.map; + case PreparedBlockKind::Channel: + return skips.channel; case PreparedBlockKind::Placeholder: return skips.placeholder; case PreparedBlockKind::Details: return skips.paragraph; + case PreparedBlockKind::GroupedMedia: + return skips.groupedMedia; } return 0; } @@ -371,6 +597,17 @@ LaidOutBlock LayoutFlowBlock( int left, int top, int width) { + if (prepared.kind == PreparedBlockKind::GroupedMedia) { + return LayoutGroupedMediaBlock( + prepared, + formulas, + inlineFormulaObjects, + mediaRuntime, + markdown, + left, + top, + width); + } auto block = LaidOutBlock(); block.kind = prepared.kind; block.anchorId = prepared.anchorId; @@ -717,28 +954,18 @@ LaidOutBlock LayoutPlaceholderBlock( style.labelStyle); auto bottom = top + mediaHeight; - if (!prepared.text.text.isEmpty()) { - block.textWidth = contentWidth; - SetTextLeaf( - &block.leaf, - markdown.body, - prepared.text, - formulas, - inlineFormulaObjects, - mediaRuntime, - block.textWidth); - BindLinks(&block.leaf, prepared.links); - const auto captionTop = bottom + style.captionSkip; - const auto captionHeight = std::max( - block.leaf.countHeight(block.textWidth, true), - TextLineHeight(markdown.body)); - block.textRect = QRect( - contentLeft, - captionTop, - block.textWidth, - captionHeight); - bottom = captionTop + captionHeight; - } + LayoutMediaCaption( + &block, + prepared, + formulas, + inlineFormulaObjects, + mediaRuntime, + markdown, + contentLeft, + bottom, + contentWidth, + style.captionSkip, + &bottom); block.contentRect = QRect( left, @@ -770,22 +997,21 @@ LaidOutBlock LayoutPhotoBlock( const auto mediaWidth = std::max( blockWidth - style.padding.left() - style.padding.right(), 1); - const auto aspectWidth = std::max(prepared.photo.width, 1); - const auto aspectHeight = std::max(prepared.photo.height, 1); - const auto mediaHeight = std::max( - int((int64(mediaWidth) * aspectHeight + aspectWidth - 1) / aspectWidth), - 1); + const auto mediaHeight = MediaHeightForWidth( + mediaWidth, + prepared.photo.width, + prepared.photo.height); block.mediaRect = QRect(mediaLeft, mediaTop, mediaWidth, mediaHeight); block.visibleMediaRect = block.mediaRect; if (mediaRuntime) { block.photoRuntime = mediaRuntime->resolvePhoto(prepared.photo.photoId); } - if (block.photoRuntime) { - const auto size = QSize(mediaWidth, mediaHeight); - block.thumbnailImage = block.photoRuntime->thumbnail(size); - block.fullImage = block.photoRuntime->full(size); - } + ResolveRuntimeImages( + block.photoRuntime, + QSize(mediaWidth, mediaHeight), + &block.thumbnailImage, + &block.fullImage); if (!prepared.photo.urlOverride.isEmpty()) { block.activation.kind = MediaActivationKind::ExternalUrl; block.activation.url = prepared.photo.urlOverride; @@ -795,28 +1021,18 @@ LaidOutBlock LayoutPhotoBlock( } auto bottom = mediaTop + mediaHeight + style.padding.bottom(); - if (!prepared.text.text.isEmpty()) { - block.textWidth = mediaWidth; - SetTextLeaf( - &block.leaf, - markdown.body, - prepared.text, - formulas, - inlineFormulaObjects, - mediaRuntime, - block.textWidth); - BindLinks(&block.leaf, prepared.links); - const auto captionTop = bottom + style.captionSkip; - const auto captionHeight = std::max( - block.leaf.countHeight(block.textWidth, true), - TextLineHeight(markdown.body)); - block.textRect = QRect( - mediaLeft, - captionTop, - block.textWidth, - captionHeight); - bottom = captionTop + captionHeight; - } + LayoutMediaCaption( + &block, + prepared, + formulas, + inlineFormulaObjects, + mediaRuntime, + markdown, + mediaLeft, + bottom, + mediaWidth, + style.captionSkip, + &bottom); block.contentRect = QRect( mediaLeft, @@ -827,4 +1043,454 @@ LaidOutBlock LayoutPhotoBlock( return block; } +LaidOutBlock LayoutVideoBlock( + const PreparedBlock &prepared, + std::vector *formulas, + InlineFormulaObjectCache *inlineFormulaObjects, + const std::shared_ptr &mediaRuntime, + const style::Markdown &markdown, + int left, + int top, + int width) { + auto block = LaidOutBlock(); + block.kind = PreparedBlockKind::Video; + block.anchorId = prepared.anchorId; + block.copyText = tr::lng_in_dlg_video(tr::now); + + const auto &style = markdown.photo; + const auto blockWidth = std::max(width, 1); + const auto mediaLeft = left + style.padding.left(); + const auto mediaTop = top + style.padding.top(); + const auto mediaWidth = std::max( + blockWidth - style.padding.left() - style.padding.right(), + 1); + const auto mediaHeight = MediaHeightForWidth( + mediaWidth, + prepared.video.media.width, + prepared.video.media.height); + block.mediaRect = QRect(mediaLeft, mediaTop, mediaWidth, mediaHeight); + block.visibleMediaRect = block.mediaRect; + + if (mediaRuntime) { + block.documentRuntime = mediaRuntime->resolveDocument( + prepared.video.media.id); + } + ResolveRuntimeImages( + block.documentRuntime, + QSize(mediaWidth, mediaHeight), + &block.thumbnailImage, + &block.fullImage); + if (block.documentRuntime) { + block.activation.kind = MediaActivationKind::Document; + block.activation.document = block.documentRuntime; + } + + auto bottom = mediaTop + mediaHeight + style.padding.bottom(); + LayoutMediaCaption( + &block, + prepared, + formulas, + inlineFormulaObjects, + mediaRuntime, + markdown, + mediaLeft, + bottom, + mediaWidth, + style.captionSkip, + &bottom); + + block.contentRect = QRect( + mediaLeft, + mediaTop, + mediaWidth, + std::max(bottom - mediaTop, mediaHeight)); + block.outer = QRect(left, top, blockWidth, std::max(bottom - top, mediaHeight)); + return block; +} + +LaidOutBlock LayoutAudioBlock( + const PreparedBlock &prepared, + std::vector *formulas, + InlineFormulaObjectCache *inlineFormulaObjects, + const std::shared_ptr &mediaRuntime, + const style::Markdown &markdown, + int left, + int top, + int width) { + auto block = LaidOutBlock(); + block.kind = PreparedBlockKind::Audio; + block.anchorId = prepared.anchorId; + block.labelText = AudioTitleText(prepared.audio); + block.copyText = AudioCopyText(prepared.audio); + + const auto &card = markdown.audio; + const auto &padding = card.padding; + const auto &titleStyle = card.titleStyle; + const auto &subtitleStyle = card.subtitleStyle; + const auto subtitleText = AudioSubtitleText(prepared.audio); + const auto blockWidth = std::max(width, 1); + const auto contentLeft = left + padding.left(); + const auto contentWidth = std::max( + blockWidth - padding.left() - padding.right(), + 1); + + block.labelWidth = contentWidth; + SetPlainTextLeaf( + &block.labelLeaf, + titleStyle, + block.labelText, + block.labelWidth); + const auto titleHeight = LeafHeight( + block.labelLeaf, + titleStyle, + block.labelWidth); + + auto subtitleHeight = 0; + if (!subtitleText.isEmpty()) { + block.subtitleWidth = contentWidth; + SetPlainTextLeaf( + &block.subtitleLeaf, + subtitleStyle, + subtitleText, + block.subtitleWidth); + subtitleHeight = LeafHeight( + block.subtitleLeaf, + subtitleStyle, + block.subtitleWidth); + } + const auto textSkip = subtitleHeight ? card.textSkip : 0; + const auto textHeight = titleHeight + textSkip + subtitleHeight; + const auto cardHeight = padding.top() + textHeight + padding.bottom(); + + block.mediaRect = QRect(left, top, blockWidth, cardHeight); + block.visibleMediaRect = block.mediaRect; + block.labelRect = QRect( + contentLeft, + top + padding.top(), + block.labelWidth, + titleHeight); + if (subtitleHeight) { + block.subtitleRect = QRect( + contentLeft, + block.labelRect.y() + block.labelRect.height() + textSkip, + block.subtitleWidth, + subtitleHeight); + } + block.firstLineBaseline = LeafFirstLineBaseline( + block.labelLeaf, + block.labelRect, + titleStyle); + + if (mediaRuntime) { + block.documentRuntime = mediaRuntime->resolveDocument( + prepared.audio.documentId); + } + if (block.documentRuntime) { + block.activation.kind = MediaActivationKind::Document; + block.activation.document = block.documentRuntime; + } + + auto bottom = top + cardHeight; + LayoutMediaCaption( + &block, + prepared, + formulas, + inlineFormulaObjects, + mediaRuntime, + markdown, + contentLeft, + bottom, + contentWidth, + card.captionSkip, + &bottom); + block.contentRect = QRect(left, top, blockWidth, std::max(bottom - top, cardHeight)); + block.outer = block.contentRect; + return block; +} + +LaidOutBlock LayoutMapBlock( + const PreparedBlock &prepared, + std::vector *formulas, + InlineFormulaObjectCache *inlineFormulaObjects, + const std::shared_ptr &mediaRuntime, + const style::Markdown &markdown, + int left, + int top, + int width) { + auto block = LaidOutBlock(); + block.kind = PreparedBlockKind::Map; + block.anchorId = prepared.anchorId; + block.copyText = tr::lng_maps_point(tr::now); + + const auto &style = markdown.photo; + const auto blockWidth = std::max(width, 1); + const auto mediaLeft = left + style.padding.left(); + const auto mediaTop = top + style.padding.top(); + const auto mediaWidth = std::max( + blockWidth - style.padding.left() - style.padding.right(), + 1); + const auto mediaHeight = MediaHeightForWidth( + mediaWidth, + prepared.map.width, + prepared.map.height); + block.mediaRect = QRect(mediaLeft, mediaTop, mediaWidth, mediaHeight); + block.visibleMediaRect = block.mediaRect; + + if (mediaRuntime) { + block.mapRuntime = mediaRuntime->resolveMap( + prepared.map.latitude, + prepared.map.longitude, + prepared.map.accessHash, + QSize(mediaWidth, mediaHeight), + prepared.map.zoom); + } + ResolveRuntimeImages( + block.mapRuntime, + QSize(mediaWidth, mediaHeight), + &block.thumbnailImage, + &block.fullImage); + if (!prepared.map.url.isEmpty()) { + block.activation.kind = MediaActivationKind::ExternalUrl; + block.activation.url = prepared.map.url; + } + + auto bottom = mediaTop + mediaHeight + style.padding.bottom(); + LayoutMediaCaption( + &block, + prepared, + formulas, + inlineFormulaObjects, + mediaRuntime, + markdown, + mediaLeft, + bottom, + mediaWidth, + style.captionSkip, + &bottom); + + block.contentRect = QRect( + mediaLeft, + mediaTop, + mediaWidth, + std::max(bottom - mediaTop, mediaHeight)); + block.outer = QRect(left, top, blockWidth, std::max(bottom - top, mediaHeight)); + return block; +} + +LaidOutBlock LayoutChannelBlock( + const PreparedBlock &prepared, + std::vector *formulas, + InlineFormulaObjectCache *inlineFormulaObjects, + const std::shared_ptr &mediaRuntime, + const style::Markdown &markdown, + int left, + int top, + int width) { + auto block = LaidOutBlock(); + block.kind = PreparedBlockKind::Channel; + block.anchorId = prepared.anchorId; + block.labelText = prepared.channel.title; + block.copyText = ChannelCopyText(prepared.channel); + + const auto &card = markdown.channel; + const auto &padding = card.padding; + const auto &button = card.button; + const auto &buttonPadding = button.padding; + const auto &titleStyle = card.titleStyle; + const auto &subtitleStyle = card.subtitleStyle; + const auto &actionStyle = button.textStyle; + const auto subtitleText = ChannelSubtitleText(prepared.channel); + const auto blockWidth = std::max(width, 1); + const auto contentLeft = left + padding.left(); + const auto contentWidth = std::max( + blockWidth - padding.left() - padding.right(), + 1); + + if (mediaRuntime) { + block.channelRuntime = mediaRuntime->resolveChannel( + prepared.channel.channelId, + prepared.channel.username); + } + const auto joinVisible = block.channelRuntime + && block.channelRuntime->joinVisible(); + if (block.channelRuntime) { + block.activation.kind = MediaActivationKind::OpenChannel; + block.activation.channel = block.channelRuntime; + } + if (joinVisible) { + block.actionActivation.kind = MediaActivationKind::JoinChannel; + block.actionActivation.channel = block.channelRuntime; + } + + auto actionTextHeight = 0; + auto actionOuterWidth = 0; + auto actionOuterHeight = 0; + if (joinVisible) { + SetPlainTextLeaf( + &block.actionLeaf, + actionStyle, + tr::lng_iv_join_channel(tr::now), + contentWidth); + block.actionWidth = std::max(block.actionLeaf.maxWidth(), 1); + actionTextHeight = LeafHeight( + block.actionLeaf, + actionStyle, + block.actionWidth); + actionOuterWidth = block.actionWidth + + buttonPadding.left() + + buttonPadding.right(); + actionOuterHeight = actionTextHeight + + buttonPadding.top() + + buttonPadding.bottom(); + } + + block.labelWidth = std::max( + contentWidth + - (joinVisible ? (actionOuterWidth + card.buttonSkip) : 0), + 1); + SetPlainTextLeaf( + &block.labelLeaf, + titleStyle, + block.labelText, + block.labelWidth); + const auto titleHeight = LeafHeight( + block.labelLeaf, + titleStyle, + block.labelWidth); + + auto subtitleHeight = 0; + if (!subtitleText.isEmpty()) { + block.subtitleWidth = block.labelWidth; + SetPlainTextLeaf( + &block.subtitleLeaf, + subtitleStyle, + subtitleText, + block.subtitleWidth); + subtitleHeight = LeafHeight( + block.subtitleLeaf, + subtitleStyle, + block.subtitleWidth); + } + const auto textSkip = subtitleHeight ? card.textSkip : 0; + const auto textHeight = titleHeight + textSkip + subtitleHeight; + const auto cardContentHeight = std::max(textHeight, actionOuterHeight); + const auto cardHeight = padding.top() + cardContentHeight + padding.bottom(); + + block.mediaRect = QRect(left, top, blockWidth, cardHeight); + block.visibleMediaRect = block.mediaRect; + + const auto textTop = top + padding.top() + + std::max((cardContentHeight - textHeight) / 2, 0); + block.labelRect = QRect( + contentLeft, + textTop, + block.labelWidth, + titleHeight); + if (subtitleHeight) { + block.subtitleRect = QRect( + contentLeft, + block.labelRect.y() + block.labelRect.height() + textSkip, + block.subtitleWidth, + subtitleHeight); + } + if (joinVisible) { + block.actionRect = QRect( + left + blockWidth - padding.right() - actionOuterWidth, + top + padding.top() + + std::max((cardContentHeight - actionOuterHeight) / 2, 0), + actionOuterWidth, + actionOuterHeight); + } + block.firstLineBaseline = LeafFirstLineBaseline( + block.labelLeaf, + block.labelRect, + titleStyle); + block.contentRect = block.mediaRect; + block.outer = block.mediaRect; + return block; +} + +LaidOutBlock LayoutGroupedMediaBlock( + const PreparedBlock &prepared, + const std::vector *formulas, + InlineFormulaObjectCache *inlineFormulaObjects, + const std::shared_ptr &mediaRuntime, + const style::Markdown &markdown, + int left, + int top, + int width) { + auto block = LaidOutBlock(); + block.kind = PreparedBlockKind::GroupedMedia; + block.anchorId = prepared.anchorId; + block.copyText = GroupedMediaCopyText(prepared.groupedMedia); + + const auto &style = markdown.groupedMedia; + const auto blockWidth = std::max(width, 1); + const auto mediaLeft = left + style.padding.left(); + const auto mediaTop = top + style.padding.top(); + const auto mediaWidth = std::max( + blockWidth - style.padding.left() - style.padding.right(), + 1); + + auto sizes = std::vector(); + sizes.reserve(prepared.groupedMedia.items.size()); + for (const auto &item : prepared.groupedMedia.items) { + sizes.push_back(QSize( + std::max(item.media.width, 1), + std::max(item.media.height, 1))); + } + auto layout = Ui::LayoutMediaGroup( + sizes, + mediaWidth, + GroupedMediaMinWidth(mediaWidth, style.itemSkip), + style.itemSkip); + block.groupedMediaItems.reserve(layout.size()); + for (auto i = 0, count = std::min( + int(layout.size()), + int(prepared.groupedMedia.items.size())); i != count; ++i) { + auto item = LaidOutGroupedMediaItem(); + ResolveGroupedMediaItemLayout( + &item, + prepared.groupedMedia.items[i], + mediaRuntime, + layout[i].geometry.translated(mediaLeft, mediaTop)); + block.groupedMediaItems.push_back(std::move(item)); + } + const auto mediaHeight = GroupedMediaLayoutHeight(layout); + const auto laidOutWidth = GroupedMediaLayoutWidth(layout); + block.mediaRect = QRect( + mediaLeft, + mediaTop, + std::max(laidOutWidth, 1), + std::max(mediaHeight, 1)); + block.visibleMediaRect = block.mediaRect; + + auto bottom = block.mediaRect.y() + block.mediaRect.height() + + style.padding.bottom(); + LayoutMediaCaption( + &block, + prepared, + formulas, + inlineFormulaObjects, + mediaRuntime, + markdown, + mediaLeft, + bottom, + block.mediaRect.width(), + style.captionSkip, + &bottom); + + block.contentRect = QRect( + mediaLeft, + mediaTop, + block.mediaRect.width(), + std::max(bottom - mediaTop, block.mediaRect.height())); + block.outer = QRect( + left, + top, + blockWidth, + std::max(bottom - top, block.mediaRect.height())); + return block; +} + } // namespace Iv::Markdown 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 812b943f9f..d647f6ee1c 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_layout_blocks.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_layout_blocks.h @@ -49,10 +49,25 @@ struct LaidOutTableRow { bool header = false; }; +struct LaidOutGroupedMediaItem { + PreparedMediaItemKind kind = PreparedMediaItemKind::Photo; + QString copyText; + QRect rect; + std::shared_ptr photoRuntime; + std::shared_ptr documentRuntime; + std::shared_ptr thumbnailImage; + std::shared_ptr fullImage; + MediaActivation activation; + mutable bool thumbnailSubscribed = false; + mutable bool fullSubscribed = false; +}; + struct LaidOutBlock { PreparedBlockKind kind = PreparedBlockKind::Paragraph; Ui::Text::String leaf; Ui::Text::String labelLeaf; + Ui::Text::String subtitleLeaf; + Ui::Text::String actionLeaf; Ui::Text::String marker; Ui::Text::String fallbackLeaf; QString copyText; @@ -60,6 +75,7 @@ struct LaidOutBlock { QString codeLanguage; Spellchecker::HighlightProcessId syntaxHighlightProcessId = 0; std::vector children; + std::vector groupedMediaItems; std::vector tableRows; std::vector tableColumnWidths; QRect outer; @@ -68,6 +84,8 @@ struct LaidOutBlock { QRect iconRect; QRect textRect; QRect labelRect; + QRect subtitleRect; + QRect actionRect; QRect markerRect; QRect contentRect; QRect formulaRect; @@ -80,6 +98,8 @@ struct LaidOutBlock { QString anchorId; int textWidth = 0; int labelWidth = 0; + int subtitleWidth = 0; + int actionWidth = 0; int markerWidth = 0; int firstLineBaseline = -1; int headingLevel = 0; @@ -94,9 +114,13 @@ struct LaidOutBlock { int segmentIndex = -1; int secondarySegmentIndex = -1; std::shared_ptr photoRuntime; + std::shared_ptr documentRuntime; + std::shared_ptr mapRuntime; + std::shared_ptr channelRuntime; std::shared_ptr thumbnailImage; std::shared_ptr fullImage; MediaActivation activation; + MediaActivation actionActivation; mutable bool thumbnailSubscribed = false; mutable bool fullSubscribed = false; mutable QImage colorizedFormulaImage; @@ -211,5 +235,50 @@ void RepopulateCodeBlockLeaf( int left, int top, int width); +[[nodiscard]] LaidOutBlock LayoutVideoBlock( + const PreparedBlock &prepared, + std::vector *formulas, + InlineFormulaObjectCache *inlineFormulaObjects, + const std::shared_ptr &mediaRuntime, + const style::Markdown &markdown, + int left, + int top, + int width); +[[nodiscard]] LaidOutBlock LayoutAudioBlock( + const PreparedBlock &prepared, + std::vector *formulas, + InlineFormulaObjectCache *inlineFormulaObjects, + const std::shared_ptr &mediaRuntime, + const style::Markdown &markdown, + int left, + int top, + int width); +[[nodiscard]] LaidOutBlock LayoutMapBlock( + const PreparedBlock &prepared, + std::vector *formulas, + InlineFormulaObjectCache *inlineFormulaObjects, + const std::shared_ptr &mediaRuntime, + const style::Markdown &markdown, + int left, + int top, + int width); +[[nodiscard]] LaidOutBlock LayoutChannelBlock( + const PreparedBlock &prepared, + std::vector *formulas, + InlineFormulaObjectCache *inlineFormulaObjects, + const std::shared_ptr &mediaRuntime, + const style::Markdown &markdown, + int left, + int top, + int width); +[[nodiscard]] LaidOutBlock LayoutGroupedMediaBlock( + const PreparedBlock &prepared, + const std::vector *formulas, + InlineFormulaObjectCache *inlineFormulaObjects, + const std::shared_ptr &mediaRuntime, + const style::Markdown &markdown, + int left, + int top, + int width); } // namespace Iv::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 c97f89e8ad..d08de447ad 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_layout_structure.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_layout_structure.cpp @@ -57,6 +57,11 @@ namespace { case PreparedBlockKind::DisplayMath: case PreparedBlockKind::Table: case PreparedBlockKind::Photo: + case PreparedBlockKind::Video: + case PreparedBlockKind::Audio: + case PreparedBlockKind::Map: + case PreparedBlockKind::Channel: + case PreparedBlockKind::GroupedMedia: case PreparedBlockKind::Placeholder: case PreparedBlockKind::Details: return false; @@ -554,6 +559,46 @@ namespace { left, top, width); + case PreparedBlockKind::Video: + return LayoutVideoBlock( + prepared, + formulas, + inlineFormulaObjects, + mediaRuntime, + markdown, + left, + top, + width); + case PreparedBlockKind::Audio: + return LayoutAudioBlock( + prepared, + formulas, + inlineFormulaObjects, + mediaRuntime, + markdown, + left, + top, + width); + case PreparedBlockKind::Map: + return LayoutMapBlock( + prepared, + formulas, + inlineFormulaObjects, + mediaRuntime, + markdown, + left, + top, + width); + case PreparedBlockKind::Channel: + return LayoutChannelBlock( + prepared, + formulas, + inlineFormulaObjects, + mediaRuntime, + markdown, + left, + top, + width); case PreparedBlockKind::Placeholder: return LayoutPlaceholderBlock( prepared, diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_paint.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_paint.cpp index d0ec61bac6..6bce74af3a 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_paint.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_paint.cpp @@ -459,7 +459,181 @@ void PaintPhotoProgress( -int(std::round(360. * 16. * std::clamp(progress, 0., 1.)))); } -void PaintPhotoBlock( +[[nodiscard]] QPainterPath RoundedRectPath(QRect rect, int radius) { + auto path = QPainterPath(); + path.addRoundedRect(QRectF(rect), radius, radius); + return path; +} + +[[nodiscard]] bool MediaLoading(const LaidOutBlock &block) { + if (block.photoRuntime) { + return block.photoRuntime->loading(); + } else if (block.documentRuntime) { + return block.documentRuntime->loading(); + } else if (block.mapRuntime) { + return block.mapRuntime->loading(); + } + return false; +} + +[[nodiscard]] double MediaProgress(const LaidOutBlock &block) { + if (block.photoRuntime) { + return block.photoRuntime->progress(); + } else if (block.documentRuntime) { + return block.documentRuntime->progress(); + } else if (block.mapRuntime) { + return block.mapRuntime->progress(); + } + return 0.; +} + +[[nodiscard]] bool MediaLoading(const LaidOutGroupedMediaItem &item) { + if (item.photoRuntime) { + return item.photoRuntime->loading(); + } else if (item.documentRuntime) { + return item.documentRuntime->loading(); + } + return false; +} + +[[nodiscard]] double MediaProgress(const LaidOutGroupedMediaItem &item) { + if (item.photoRuntime) { + return item.photoRuntime->progress(); + } else if (item.documentRuntime) { + return item.documentRuntime->progress(); + } + return 0.; +} + +void PaintMediaCaption( + Painter &p, + const LaidOutBlock &block, + const style::Markdown &markdown, + const MarkdownArticlePaintCaches &caches, + const PaintSelectionState &selectionState, + QRect clip) { + if (block.textRect.isEmpty()) { + return; + } + p.setPen(markdown.textColor->c); + PaintTextLeaf( + p, + block.leaf, + caches, + block.textRect, + block.textWidth, + clip, + style::al_left, + TextSelectionForSegmentIndex( + selectionState, + block.secondarySegmentIndex)); +} + +void PaintImageBackedMedia( + Painter &p, + QRect rect, + const QString ©Text, + const std::shared_ptr &thumbnailImage, + const std::shared_ptr &fullImage, + bool *thumbnailSubscribed, + bool *fullSubscribed, + bool loading, + double progress, + const style::Markdown &markdown, + const MarkdownArticlePaintCaches &caches, + bool selected, + QRect clip) { + const auto visible = clip.intersected(rect); + if (!visible.isEmpty()) { + p.save(); + p.setClipRect(visible); + p.fillRect(rect, st::windowBgOver->c); + SubscribeDynamicImage( + thumbnailImage, + caches.repaint, + thumbnailSubscribed); + SubscribeDynamicImage( + fullImage, + caches.repaint, + fullSubscribed); + const auto paintedThumb = PaintDynamicImage( + p, + thumbnailImage, + rect); + const auto paintedFull = PaintDynamicImage( + p, + fullImage, + rect); + if (!paintedThumb && !paintedFull) { + p.setPen(st::windowSubTextFg->c); + p.drawText( + rect, + Qt::AlignCenter | Qt::TextWordWrap, + copyText); + } + if (loading) { + PaintPhotoProgress( + p, + rect, + markdown.photo, + progress); + } + if (selected) { + p.fillRect(rect, p.textPalette().selectOverlay); + } + p.restore(); + } +} + +void PaintImageBackedMediaBlock( + Painter &p, + const LaidOutBlock &block, + const style::Markdown &markdown, + const MarkdownArticlePaintCaches &caches, + const PaintSelectionState &selectionState, + QRect clip) { + PaintImageBackedMedia( + p, + block.mediaRect, + block.copyText, + block.thumbnailImage, + block.fullImage, + &block.thumbnailSubscribed, + &block.fullSubscribed, + MediaLoading(block), + MediaProgress(block), + markdown, + caches, + block.segmentIndex >= 0 + && WholeSegmentSelected(selectionState, block.segmentIndex), + clip); + PaintMediaCaption(p, block, markdown, caches, selectionState, clip); +} + +void PaintCardSurface( + Painter &p, + QRect rect, + int border, + const style::color &borderFg, + const style::color &bg, + int radius) { + if (rect.isEmpty()) { + return; + } + const auto half = border / 2.; + const auto inner = QRectF(rect).marginsRemoved({ + half, + half, + half, + half, + }); + auto hq = PainterHighQualityEnabler(p); + p.setPen(QPen(borderFg->c, border)); + p.setBrush(bg->c); + p.drawRoundedRect(inner, radius, radius); +} + +void PaintAudioBlock( Painter &p, const LaidOutBlock &block, const style::Markdown &markdown, @@ -468,38 +642,33 @@ void PaintPhotoBlock( QRect clip) { const auto visible = clip.intersected(block.visibleMediaRect); if (!visible.isEmpty()) { + const auto &style = markdown.audio; p.save(); p.setClipRect(visible); - p.fillRect(block.mediaRect, st::windowBgOver->c); - SubscribeDynamicImage( - block.thumbnailImage, - caches.repaint, - &block.thumbnailSubscribed); - SubscribeDynamicImage( - block.fullImage, - caches.repaint, - &block.fullSubscribed); - const auto paintedThumb = PaintDynamicImage( + PaintCardSurface( p, - block.thumbnailImage, - block.mediaRect); - const auto paintedFull = PaintDynamicImage( + block.mediaRect, + style.border, + style.borderFg, + style.bg, + style.radius); + p.setPen(style.titleFg->c); + PaintTextLeaf( p, - block.fullImage, - block.mediaRect); - if (!paintedThumb && !paintedFull) { - p.setPen(st::windowSubTextFg->c); - p.drawText( - block.mediaRect, - Qt::AlignCenter | Qt::TextWordWrap, - block.copyText); - } - if (block.photoRuntime && block.photoRuntime->loading()) { - PaintPhotoProgress( + block.labelLeaf, + caches, + block.labelRect, + block.labelWidth, + visible); + if (!block.subtitleRect.isEmpty()) { + p.setPen(style.subtitleFg->c); + PaintTextLeaf( p, - block.mediaRect, - markdown.photo, - block.photoRuntime->progress()); + block.subtitleLeaf, + caches, + block.subtitleRect, + block.subtitleWidth, + visible); } if (block.segmentIndex >= 0 && WholeSegmentSelected(selectionState, block.segmentIndex)) { @@ -507,20 +676,171 @@ void PaintPhotoBlock( } p.restore(); } - if (!block.textRect.isEmpty()) { - p.setPen(markdown.textColor->c); + PaintMediaCaption(p, block, markdown, caches, selectionState, clip); +} + +void PaintChannelBlock( + Painter &p, + const LaidOutBlock &block, + const style::Markdown &markdown, + const MarkdownArticlePaintCaches &caches, + const PaintSelectionState &selectionState, + QRect clip) { + const auto visible = clip.intersected(block.visibleMediaRect); + if (!visible.isEmpty()) { + const auto &style = markdown.channel; + const auto &button = style.button; + p.save(); + p.setClipRect(visible); + PaintCardSurface( + p, + block.mediaRect, + style.border, + style.borderFg, + style.bg, + style.radius); + p.setPen(style.titleFg->c); PaintTextLeaf( p, - block.leaf, + block.labelLeaf, caches, - block.textRect, - block.textWidth, - clip, - style::al_left, - TextSelectionForSegmentIndex( - selectionState, - block.secondarySegmentIndex)); + block.labelRect, + block.labelWidth, + visible); + if (!block.subtitleRect.isEmpty()) { + p.setPen(style.subtitleFg->c); + PaintTextLeaf( + p, + block.subtitleLeaf, + caches, + block.subtitleRect, + block.subtitleWidth, + visible); + } + if (block.channelRuntime + && block.channelRuntime->joinVisible() + && !block.actionRect.isEmpty()) { + const auto innerRect = block.actionRect.marginsRemoved(button.padding); + const auto half = button.border / 2.; + const auto outer = QRectF(block.actionRect).marginsRemoved({ + half, + half, + half, + half, + }); + { + auto hq = PainterHighQualityEnabler(p); + p.setPen(QPen(button.borderFg->c, button.border)); + p.setBrush(button.bg->c); + p.drawRoundedRect(outer, button.radius, button.radius); + } + p.setPen(button.textFg->c); + PaintTextLeaf( + p, + block.actionLeaf, + caches, + innerRect, + block.actionWidth, + visible, + style::al_center); + } + if (block.segmentIndex >= 0 + && WholeSegmentSelected(selectionState, block.segmentIndex)) { + p.fillRect(block.visibleMediaRect, p.textPalette().selectOverlay); + } + p.restore(); } + PaintMediaCaption(p, block, markdown, caches, selectionState, clip); +} + +void PaintPhotoBlock( + Painter &p, + const LaidOutBlock &block, + const style::Markdown &markdown, + const MarkdownArticlePaintCaches &caches, + const PaintSelectionState &selectionState, + QRect clip) { + PaintImageBackedMediaBlock( + p, + block, + markdown, + caches, + selectionState, + clip); +} + +void PaintVideoBlock( + Painter &p, + const LaidOutBlock &block, + const style::Markdown &markdown, + const MarkdownArticlePaintCaches &caches, + const PaintSelectionState &selectionState, + QRect clip) { + PaintImageBackedMediaBlock( + p, + block, + markdown, + caches, + selectionState, + clip); +} + +void PaintMapBlock( + Painter &p, + const LaidOutBlock &block, + const style::Markdown &markdown, + const MarkdownArticlePaintCaches &caches, + const PaintSelectionState &selectionState, + QRect clip) { + PaintImageBackedMediaBlock( + p, + block, + markdown, + caches, + selectionState, + clip); +} + +void PaintGroupedMediaBlock( + Painter &p, + const LaidOutBlock &block, + const style::Markdown &markdown, + const MarkdownArticlePaintCaches &caches, + const PaintSelectionState &selectionState, + QRect clip) { + const auto selected = (block.segmentIndex >= 0) + && WholeSegmentSelected(selectionState, block.segmentIndex); + const auto visible = clip.intersected(block.visibleMediaRect); + if (!visible.isEmpty()) { + const auto &style = markdown.groupedMedia; + p.save(); + p.setClipRect(visible); + const auto path = RoundedRectPath(block.mediaRect, style.radius); + p.setClipPath(path, Qt::IntersectClip); + for (const auto &item : block.groupedMediaItems) { + PaintImageBackedMedia( + p, + item.rect, + item.copyText, + item.thumbnailImage, + item.fullImage, + &item.thumbnailSubscribed, + &item.fullSubscribed, + MediaLoading(item), + MediaProgress(item), + markdown, + caches, + false, + clip); + } + if (selected) { + auto overlay = p.textPalette().selectOverlay->c; + overlay.setAlphaF(std::clamp(style.overlayOpacity, 0., 1.)); + p.fillPath(path, overlay); + } + p.restore(); + } + PaintMediaCaption(p, block, markdown, caches, selectionState, clip); } void PaintDetailsBlock( @@ -748,6 +1068,42 @@ void PaintBlock( selectionState, clip); break; + case PreparedBlockKind::Video: + PaintVideoBlock( + p, + block, + markdown, + caches, + selectionState, + clip); + break; + case PreparedBlockKind::Audio: + PaintAudioBlock( + p, + block, + markdown, + caches, + selectionState, + clip); + break; + case PreparedBlockKind::Map: + PaintMapBlock( + p, + block, + markdown, + caches, + selectionState, + clip); + break; + case PreparedBlockKind::Channel: + PaintChannelBlock( + p, + block, + markdown, + caches, + selectionState, + clip); + break; case PreparedBlockKind::Placeholder: PaintPlaceholderBlock( p, @@ -757,6 +1113,15 @@ void PaintBlock( selectionState, clip); break; + case PreparedBlockKind::GroupedMedia: + PaintGroupedMediaBlock( + p, + block, + markdown, + caches, + selectionState, + clip); + break; case PreparedBlockKind::Details: PaintDetailsBlock( p, diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_selection.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_selection.cpp index 76444e2e7d..7bf78aa1ee 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_selection.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_selection.cpp @@ -101,18 +101,31 @@ const auto kPhotoCopyLabel = u"Photo"_q; [[nodiscard]] TextForMimeData CopyTextForMediaBlock( const QString &label, const Ui::Text::String &captionLeaf) { - auto result = TextForMimeData::Simple(label); + auto result = label.isEmpty() + ? TextForMimeData() + : TextForMimeData::Simple(label); if (!captionLeaf.isEmpty()) { - result.append(u"\n"_q); + if (!result.empty()) { + result.append(u"\n"_q); + } result.append(captionLeaf.toTextForMimeData()); } return result; } +[[nodiscard]] TextForMimeData CopyTextForSingleMediaBlock( + const LaidOutBlock &block, + const QString &fallback = QString()) { + const auto label = !block.copyText.isEmpty() + ? block.copyText + : !block.labelText.isEmpty() + ? block.labelText + : fallback; + return CopyTextForMediaBlock(label, block.leaf); +} + [[nodiscard]] TextForMimeData CopyTextForPhotoBlock(const LaidOutBlock &block) { - return CopyTextForMediaBlock( - block.copyText.isEmpty() ? kPhotoCopyLabel : block.copyText, - block.leaf); + return CopyTextForSingleMediaBlock(block, kPhotoCopyLabel); } [[nodiscard]] TextForMimeData CopyTextForPlaceholderBlock( @@ -350,11 +363,18 @@ void CollectSelectableSegments( } } break; case PreparedBlockKind::Placeholder: - case PreparedBlockKind::Photo: { + case PreparedBlockKind::Photo: + case PreparedBlockKind::Video: + case PreparedBlockKind::Audio: + case PreparedBlockKind::Map: + case PreparedBlockKind::Channel: + case PreparedBlockKind::GroupedMedia: { auto segment = SelectableSegment(); segment.kind = (block.kind == PreparedBlockKind::Photo) ? SelectableSegmentKind::Photo - : SelectableSegmentKind::Placeholder; + : (block.kind == PreparedBlockKind::Placeholder) + ? SelectableSegmentKind::Placeholder + : SelectableSegmentKind::Media; segment.block = █ segment.outerRect = block.mediaRect; segment.length = 1; @@ -541,6 +561,10 @@ TextForMimeData TextForSegment( return segment.block ? CopyTextForPhotoBlock(*segment.block) : TextForMimeData(); + case SelectableSegmentKind::Media: + return segment.block + ? CopyTextForSingleMediaBlock(*segment.block) + : TextForMimeData(); } return TextForMimeData(); } diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_selection.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_selection.h index a7f92720f4..6f93380013 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_selection.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_selection.h @@ -18,6 +18,7 @@ enum class SelectableSegmentKind { Table, Placeholder, Photo, + Media, }; struct SelectableSegment { diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_text.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_text.cpp index df773460f3..07cd1fd1e3 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_article_text.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_article_text.cpp @@ -932,9 +932,9 @@ Ui::Text::CustomEmojiSemantics InlineIvImageObject::semantics() { void InlineIvImageObject::paint(QPainter &p, const Context &context) { if (_image) { - if (!_subscribed && _repaint) { + if (!_subscribed) { _subscribed = true; - _image->subscribeToUpdates(_repaint); + _image->subscribeToUpdates(_repaint ? _repaint : [] {}); } if (const auto image = _image->image(std::max(_width, _height)); !image.isNull()) { diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_common.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_common.h index c097cc4a79..c29cd4b8ed 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_common.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_common.h @@ -11,6 +11,9 @@ https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL #include #include +#include +#include + #include #include @@ -39,6 +42,42 @@ public: virtual void open(Qt::MouseButton button) const = 0; }; +class DocumentRuntime { +public: + virtual ~DocumentRuntime() = default; + + [[nodiscard]] virtual std::shared_ptr thumbnail( + QSize size) const = 0; + [[nodiscard]] virtual std::shared_ptr full( + QSize size) const = 0; + [[nodiscard]] virtual bool loaded() const = 0; + [[nodiscard]] virtual bool loading() const = 0; + [[nodiscard]] virtual double progress() const = 0; + virtual void open(Qt::MouseButton button) const = 0; +}; + +class MapRuntime { +public: + virtual ~MapRuntime() = default; + + [[nodiscard]] virtual std::shared_ptr thumbnail( + QSize size) const = 0; + [[nodiscard]] virtual std::shared_ptr full( + QSize size) const = 0; + [[nodiscard]] virtual bool loaded() const = 0; + [[nodiscard]] virtual bool loading() const = 0; + [[nodiscard]] virtual double progress() const = 0; +}; + +class ChannelRuntime { +public: + virtual ~ChannelRuntime() = default; + + [[nodiscard]] virtual bool joinVisible() const = 0; + virtual void open(Qt::MouseButton button) const = 0; + virtual void join(Qt::MouseButton button) const = 0; +}; + class MediaRuntime { public: virtual ~MediaRuntime() = default; @@ -48,18 +87,43 @@ public: QSize size) const = 0; [[nodiscard]] virtual std::shared_ptr resolvePhoto( uint64 photoId) const = 0; + [[nodiscard]] virtual std::shared_ptr resolveDocument( + uint64 documentId) const { + return nullptr; + } + [[nodiscard]] virtual std::shared_ptr resolveMap( + double latitude, + double longitude, + uint64 accessHash, + QSize size, + int zoom) const { + return nullptr; + } + [[nodiscard]] virtual std::shared_ptr resolveChannel( + uint64 channelId, + const QString &username) const { + return nullptr; + } + [[nodiscard]] virtual rpl::producer channelJoinedChanges() const { + return rpl::never(); + } }; enum class MediaActivationKind { None, ExternalUrl, Photo, + Document, + OpenChannel, + JoinChannel, }; struct MediaActivation { MediaActivationKind kind = MediaActivationKind::None; QString url; std::shared_ptr photo; + std::shared_ptr document; + std::shared_ptr channel; }; enum class ViewerKind { diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.cpp index 9dcf37f5f4..ea637ef3ab 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.cpp @@ -363,6 +363,12 @@ bool Controller::active() const { return _window && _window->isActiveWindow(); } +void Controller::showJoinedTooltip() { + if (_show) { + _show->showToast(tr::lng_action_you_joined(tr::now)); + } +} + void Controller::minimize() { if (_window) { _window->setWindowState(_window->windowState() | Qt::WindowMinimized); diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.h index dcd37a290d..69f59e3237 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_controller.h @@ -51,6 +51,7 @@ public: void updateOptions(OpenOptions options = {}); [[nodiscard]] bool active() const; + void showJoinedTooltip(); void minimize(); [[nodiscard]] rpl::producer events() const { diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.cpp index 3298c9c60d..5ffcd8d962 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.cpp @@ -121,6 +121,12 @@ NativeInstantViewPrepareResult TryPrepareNativeInstantView( 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, diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.h index 1a411b1f1a..76cb9c5f26 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare.h @@ -36,6 +36,11 @@ enum class PreparedBlockKind { Table, Details, Photo, + Video, + Audio, + Map, + Channel, + GroupedMedia, Placeholder, }; @@ -103,6 +108,54 @@ struct PreparedPhotoBlockData { bool viewerOpen = false; }; +enum class PreparedMediaItemKind { + Photo, + Document, +}; + +struct PreparedMediaItemData { + PreparedMediaItemKind kind = PreparedMediaItemKind::Photo; + uint64 id = 0; + int width = 0; + int height = 0; +}; + +struct PreparedVideoBlockData { + PreparedMediaItemData media; +}; + +struct PreparedAudioBlockData { + uint64 documentId = 0; + QString title; + QString performer; + QString fileName; + int duration = 0; +}; + +struct PreparedMapBlockData { + double latitude = 0.; + double longitude = 0.; + uint64 accessHash = 0; + int width = 0; + int height = 0; + int zoom = 0; + QString url; +}; + +struct PreparedChannelBlockData { + uint64 channelId = 0; + QString title; + QString username; +}; + +struct PreparedGroupedMediaItemData { + PreparedMediaItemData media; +}; + +struct PreparedGroupedMediaBlockData { + std::vector items; +}; + struct PreparedPlaceholderBlockData { QString label; QString copyText; @@ -119,6 +172,11 @@ struct PreparedBlock { QString formulaTex; QString anchorId; PreparedPhotoBlockData photo; + PreparedVideoBlockData video; + PreparedAudioBlockData audio; + PreparedMapBlockData map; + PreparedChannelBlockData channel; + PreparedGroupedMediaBlockData groupedMedia; PreparedPlaceholderBlockData placeholder; ListKind listKind = ListKind::Bullet; ListDelimiter listDelimiter = ListDelimiter::None; 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 3b6da1d131..419da04551 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_blocks.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_blocks.cpp @@ -507,11 +507,7 @@ void SortPreparedIvRichText(PreparedIvRichText *text) { }, [&](const MTPDpageBlockPhoto &data) { return PrepareNativeIvPhotoBlock(data, result, state); }, [&](const MTPDpageBlockVideo &data) { - return PrepareNativeIvPlaceholderBlock( - u"Video Placeholder"_q, - data.vcaption(), - result, - state); + return PrepareNativeIvVideoBlock(data, result, state); }, [&](const MTPDpageBlockCover &data) { return PrepareNativeIvBlock(data.vcover(), result, state); }, [&](const MTPDpageBlockEmbed &data) { @@ -527,27 +523,23 @@ void SortPreparedIvRichText(PreparedIvRichText *text) { result, state); }, [&](const MTPDpageBlockCollage &data) { - return PrepareNativeIvPlaceholderBlock( - u"Collage placeholder"_q, + return PrepareNativeIvGroupedMediaBlock( + data.vitems().v, data.vcaption(), + u"Collage placeholder"_q, result, state); }, [&](const MTPDpageBlockSlideshow &data) { - return PrepareNativeIvPlaceholderBlock( + return PrepareNativeIvGroupedMediaBlock( + data.vitems().v, + data.vcaption(), u"Grouped Media Placeholder"_q, - data.vcaption(), result, state); - }, [&](const MTPDpageBlockChannel &) { - return PrepareNativeIvPlainPlaceholderBlock( - u"Channel Placeholder"_q, - result); + }, [&](const MTPDpageBlockChannel &data) { + return PrepareNativeIvChannelBlock(data, result, state); }, [&](const MTPDpageBlockAudio &data) { - return PrepareNativeIvPlaceholderBlock( - u"Audio File Placeholder"_q, - data.vcaption(), - result, - state); + return PrepareNativeIvAudioBlock(data, result, state); }, [&](const MTPDpageBlockKicker &data) { return AppendNativeIvFlowBlock( result, @@ -573,11 +565,7 @@ void SortPreparedIvRichText(PreparedIvRichText *text) { u"Related Articles Placeholder"_q, result); }, [&](const MTPDpageBlockMap &data) { - return PrepareNativeIvPlaceholderBlock( - u"Map Placeholder"_q, - data.vcaption(), - result, - state); + return PrepareNativeIvMapBlock(data, result, state); }); } 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 b55ef1da8c..a3f2ff812a 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_richtext.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_richtext.cpp @@ -6,8 +6,13 @@ For license and copyright information please follow this link: https://github.com/telegramdesktop/tdesktop/blob/master/LEGAL */ #include "iv/markdown/iv_markdown_prepare_native_richtext.h" + +struct GeoPointLocation; + +#include "data/data_location.h" #include "iv/markdown/iv_markdown_prepare_links.h" #include "ui/basic_click_handlers.h" +#include "history/history_location_manager.h" #include @@ -41,6 +46,78 @@ void ShiftEntities(EntitiesInText *entities, int delta) { return nullptr; } +[[nodiscard]] const NativeIvDocumentInfo *FindNativeIvDocument( + uint64 documentId, + const NativeIvPrepareState &state) { + for (const auto &document : state.documents) { + if (document.id == documentId) { + return &document; + } + } + return nullptr; +} + +void MergeNativeIvDocumentInfo( + NativeIvDocumentInfo *existing, + NativeIvDocumentInfo info) { + if (!existing || (existing->id != info.id)) { + return; + } + if (existing->width <= 0 && info.width > 0) { + existing->width = info.width; + } + if (existing->height <= 0 && info.height > 0) { + existing->height = info.height; + } + if (existing->fileName.isEmpty() && !info.fileName.isEmpty()) { + existing->fileName = std::move(info.fileName); + } + if (existing->title.isEmpty() && !info.title.isEmpty()) { + existing->title = std::move(info.title); + } + if (existing->performer.isEmpty() && !info.performer.isEmpty()) { + existing->performer = std::move(info.performer); + } + if (existing->duration <= 0 && info.duration > 0) { + existing->duration = info.duration; + } + if (!existing->isVideoFile && info.isVideoFile) { + existing->isVideoFile = true; + } +} + +[[nodiscard]] bool PrepareNativeIvGroupedMediaItem( + const MTPPageBlock &item, + PreparedGroupedMediaItemData *result, + const NativeIvPrepareState &state) { + return item.match([&](const MTPDpageBlockPhoto &data) { + const auto info = FindNativeIvPhoto(uint64(data.vphoto_id().v), state); + if (!info || info->width <= 0 || info->height <= 0) { + return false; + } + result->media.kind = PreparedMediaItemKind::Photo; + result->media.id = info->id; + result->media.width = info->width; + result->media.height = info->height; + return true; + }, [&](const MTPDpageBlockVideo &data) { + const auto info = FindNativeIvDocument(uint64(data.vvideo_id().v), state); + if (!info + || !info->isVideoFile + || info->width <= 0 + || info->height <= 0) { + return false; + } + result->media.kind = PreparedMediaItemKind::Document; + result->media.id = info->id; + result->media.width = info->width; + result->media.height = info->height; + return true; + }, [](const auto &) { + return false; + }); +} + void SortPreparedIvRichText(PreparedIvRichText *text) { SortEntities(&text->text); } @@ -430,6 +507,51 @@ void RememberNativeIvPhoto( state->photos.push_back(info); } +void RememberNativeIvDocument( + NativeIvPrepareState *state, + const MTPDocument &document) { + auto info = NativeIvDocumentInfo{ + .id = document.match([](const auto &data) { + return data.vid().v; + }), + }; + document.match([](const MTPDdocumentEmpty &) { + }, [&](const MTPDdocument &data) { + const auto assignDimensions = [&](int width, int height, bool force) { + if (width <= 0 || height <= 0) { + return; + } + if (force || info.width <= 0 || info.height <= 0) { + info.width = width; + info.height = height; + } + }; + for (const auto &attribute : data.vattributes().v) { + attribute.match([&](const MTPDdocumentAttributeAudio &data) { + info.duration = data.vduration().v; + info.title = qs(data.vtitle().value_or_empty()); + info.performer = qs(data.vperformer().value_or_empty()); + }, [&](const MTPDdocumentAttributeFilename &data) { + info.fileName = qs(data.vfile_name()); + }, [&](const MTPDdocumentAttributeImageSize &data) { + assignDimensions(data.vw().v, data.vh().v, false); + }, [&](const MTPDdocumentAttributeVideo &data) { + info.isVideoFile = true; + assignDimensions(data.vw().v, data.vh().v, true); + }, [&](const auto &) {}); + } + }); + if (!info.id) { + return; + } + if (const auto existing = FindNativeIvDocument(info.id, *state)) { + const auto index = existing - state->documents.data(); + MergeNativeIvDocumentInfo(&state->documents[index], std::move(info)); + return; + } + state->documents.push_back(std::move(info)); +} + bool PrepareNativeIvRichText( const MTPRichText &text, PreparedIvRichText *result, @@ -507,6 +629,201 @@ bool PrepareNativeIvPhotoBlock( return true; } +bool PrepareNativeIvVideoBlock( + const MTPDpageBlockVideo &data, + std::vector *result, + NativeIvPrepareState *state) { + const auto info = FindNativeIvDocument(uint64(data.vvideo_id().v), *state); + if (!info + || !info->isVideoFile + || info->width <= 0 + || info->height <= 0) { + return PrepareNativeIvPlaceholderBlock( + u"Video Placeholder"_q, + data.vcaption(), + result, + state); + } + auto caption = PreparedIvRichText(); + auto anchorId = QString(); + if (!PrepareNativeIvCaption(data.vcaption(), &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 = std::move(anchorId); + block.video.media.kind = PreparedMediaItemKind::Document; + block.video.media.id = info->id; + block.video.media.width = info->width; + block.video.media.height = info->height; + result->push_back(std::move(block)); + return true; +} + +bool PrepareNativeIvAudioBlock( + const MTPDpageBlockAudio &data, + std::vector *result, + NativeIvPrepareState *state) { + const auto info = FindNativeIvDocument(uint64(data.vaudio_id().v), *state); + if (!info) { + return PrepareNativeIvPlaceholderBlock( + u"Audio File Placeholder"_q, + data.vcaption(), + result, + state); + } + auto caption = PreparedIvRichText(); + auto anchorId = QString(); + if (!PrepareNativeIvCaption(data.vcaption(), &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 = std::move(anchorId); + block.audio.documentId = info->id; + block.audio.title = info->title; + block.audio.performer = info->performer; + block.audio.fileName = info->fileName; + block.audio.duration = info->duration; + result->push_back(std::move(block)); + return true; +} + +bool PrepareNativeIvMapBlock( + const MTPDpageBlockMap &data, + std::vector *result, + NativeIvPrepareState *state) { + auto prepared = PreparedMapBlockData(); + const auto supported = data.vgeo().match([&](const MTPDgeoPoint &geo) { + if (!geo.vaccess_hash().v || data.vw().v <= 0 || data.vh().v <= 0) { + return false; + } + prepared.latitude = geo.vlat().v; + prepared.longitude = geo.vlong().v; + prepared.accessHash = geo.vaccess_hash().v; + prepared.width = data.vw().v; + prepared.height = data.vh().v; + prepared.zoom = data.vzoom().v; + prepared.url = LocationClickHandler::Url(Data::LocationPoint(geo)); + return true; + }, [](const auto &) { + return false; + }); + if (!supported) { + return PrepareNativeIvPlaceholderBlock( + u"Map Placeholder"_q, + data.vcaption(), + result, + state); + } + auto caption = PreparedIvRichText(); + auto anchorId = QString(); + if (!PrepareNativeIvCaption(data.vcaption(), &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 = std::move(anchorId); + block.map = std::move(prepared); + result->push_back(std::move(block)); + return true; +} + +bool PrepareNativeIvChannelBlock( + const MTPDpageBlockChannel &data, + std::vector *result, + NativeIvPrepareState *) { + auto prepared = PreparedChannelBlockData(); + const auto supported = data.vchannel().match([&](const MTPDchannel &channel) { + prepared.channelId = channel.vid().v; + prepared.title = qs(channel.vtitle()); + prepared.username = qs(channel.vusername().value_or_empty()); + return true; + }, [&](const MTPDchannelForbidden &channel) { + prepared.channelId = channel.vid().v; + prepared.title = qs(channel.vtitle()); + return true; + }, [&](const MTPDchat &channel) { + prepared.channelId = channel.vid().v; + prepared.title = qs(channel.vtitle()); + return true; + }, [&](const MTPDchatForbidden &channel) { + prepared.channelId = channel.vid().v; + prepared.title = qs(channel.vtitle()); + return true; + }, [](const auto &) { + return false; + }); + if (!supported || !prepared.channelId || prepared.title.isEmpty()) { + return PrepareNativeIvPlainPlaceholderBlock( + u"Channel Placeholder"_q, + result); + } + auto block = PreparedBlock(); + block.kind = PreparedBlockKind::Channel; + block.channel = std::move(prepared); + result->push_back(std::move(block)); + return true; +} + +bool PrepareNativeIvGroupedMediaBlock( + const QVector &items, + const MTPPageCaption &caption, + QString placeholderLabel, + std::vector *result, + NativeIvPrepareState *state) { + if (items.size() < 2) { + return PrepareNativeIvPlaceholderBlock( + std::move(placeholderLabel), + caption, + result, + state); + } + auto block = PreparedBlock(); + block.kind = PreparedBlockKind::GroupedMedia; + block.groupedMedia.items.reserve(items.size()); + for (const auto &item : items) { + auto prepared = PreparedGroupedMediaItemData(); + if (!PrepareNativeIvGroupedMediaItem(item, &prepared, *state)) { + return PrepareNativeIvPlaceholderBlock( + std::move(placeholderLabel), + caption, + result, + state); + } + block.groupedMedia.items.push_back(std::move(prepared)); + } + if (block.groupedMedia.items.empty()) { + return PrepareNativeIvPlaceholderBlock( + std::move(placeholderLabel), + caption, + result, + state); + } + auto preparedCaption = PreparedIvRichText(); + if (!PrepareNativeIvCaption( + caption, + &preparedCaption, + &block.anchorId, + state)) { + return false; + } + SortPreparedIvRichText(&preparedCaption); + block.text = std::move(preparedCaption.text); + block.links = std::move(preparedCaption.links); + 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 97c771a428..8061e74823 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_richtext.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_native_richtext.h @@ -19,6 +19,9 @@ struct PreparedIvRichText { void RememberNativeIvPhoto( NativeIvPrepareState *state, const MTPPhoto &photo); +void RememberNativeIvDocument( + NativeIvPrepareState *state, + const MTPDocument &document); [[nodiscard]] bool PrepareNativeIvPlainPlaceholderBlock( QString label, std::vector *result); @@ -31,6 +34,28 @@ void RememberNativeIvPhoto( const MTPDpageBlockPhoto &data, std::vector *result, NativeIvPrepareState *state); +[[nodiscard]] bool PrepareNativeIvVideoBlock( + const MTPDpageBlockVideo &data, + std::vector *result, + NativeIvPrepareState *state); +[[nodiscard]] bool PrepareNativeIvAudioBlock( + const MTPDpageBlockAudio &data, + std::vector *result, + NativeIvPrepareState *state); +[[nodiscard]] bool PrepareNativeIvMapBlock( + const MTPDpageBlockMap &data, + std::vector *result, + NativeIvPrepareState *state); +[[nodiscard]] bool PrepareNativeIvChannelBlock( + const MTPDpageBlockChannel &data, + std::vector *result, + NativeIvPrepareState *state); +[[nodiscard]] bool PrepareNativeIvGroupedMediaBlock( + const QVector &items, + const MTPPageCaption &caption, + QString placeholderLabel, + std::vector *result, + NativeIvPrepareState *state); [[nodiscard]] bool PrepareNativeIvRichText( const MTPRichText &text, PreparedIvRichText *result, diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_state.h b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_state.h index 6e63451b05..faf7d8541c 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_state.h +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_prepare_state.h @@ -58,9 +58,21 @@ struct NativeIvPhotoInfo { int height = 0; }; +struct NativeIvDocumentInfo { + uint64 id = 0; + int width = 0; + int height = 0; + QString fileName; + QString title; + QString performer; + int duration = 0; + bool isVideoFile = false; +}; + struct NativeIvPrepareState { MarkdownArticleContent result; std::vector photos; + std::vector documents; int nextGeneratedId = 0; void setFailure( diff --git a/Telegram/SourceFiles/iv/markdown/iv_markdown_view.cpp b/Telegram/SourceFiles/iv/markdown/iv_markdown_view.cpp index 86a8445ad6..2cefcc560f 100644 --- a/Telegram/SourceFiles/iv/markdown/iv_markdown_view.cpp +++ b/Telegram/SourceFiles/iv/markdown/iv_markdown_view.cpp @@ -198,8 +198,10 @@ private: Ui::FlatLabel *_failure = nullptr; Ui::LinkButton *_failureOpen = nullptr; const std::shared_ptr _renderer; + std::shared_ptr _article; QString _pendingFragment; int _devicePixelRatio = 0; + rpl::lifetime _channelJoinedLifetime; }; @@ -451,7 +453,9 @@ void MarkdownPreviewRoot::applyPreparedContent( int prepareMs) { const auto failure = prepared.failure; const auto debug = prepared.debug; + _channelJoinedLifetime.destroy(); if (failure.failed()) { + _article = nullptr; _footnotes.clear(); _scroll->hide(); if (_body) { @@ -473,14 +477,27 @@ void MarkdownPreviewRoot::applyPreparedContent( _footnotes = prepared.footnotes; if (!_body) { + _article = nullptr; logPreparationSummary(failure, debug, prepareMs, 0); return; } + if (prepared.mediaRuntime) { + prepared.mediaRuntime->channelJoinedChanges( + ) | rpl::on_next([=](uint64) { + if (_body && !_body->isHidden() && _article) { + _article->invalidateLayout(); + updateChildrenGeometry(size()); + _body->update(); + } + }, _channelJoinedLifetime); + } + auto article = std::make_shared(_renderer); article->setContent(std::move(prepared)); + _article = article; updateChildrenGeometry(size()); - _body->setArticle(std::move(article)); + _body->setArticle(article); if (_options.delegate) { _body->setZoom(_options.delegate->ivZoom()); } diff --git a/Telegram/SourceFiles/tests/test_markdown_iv.cpp b/Telegram/SourceFiles/tests/test_markdown_iv.cpp index 0502c6e0ef..d793a280d4 100644 --- a/Telegram/SourceFiles/tests/test_markdown_iv.cpp +++ b/Telegram/SourceFiles/tests/test_markdown_iv.cpp @@ -5,11 +5,13 @@ #include "iv/markdown/iv_markdown_microtex.h" #include "iv/markdown/iv_markdown_parse.h" #include "iv/markdown/iv_markdown_prepare.h" +#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_prepare.h" #include "scheme.h" +#include "lang/lang_keys.h" #include "spellcheck/spellcheck_highlight_syntax.h" #include "ui/basic_click_handlers.h" #include "ui/chat/chat_style.h" @@ -25,6 +27,7 @@ #include #include #include +#include #include #include #include @@ -35,6 +38,7 @@ #include #include +#include #include #include @@ -49,6 +53,38 @@ namespace { using namespace Iv::Markdown; +struct TestNativeIvChannelContext { + uint64 channelId = 0; + QString username; +}; + +[[nodiscard]] TestNativeIvChannelContext ParseTestNativeIvChannelContext( + const QString &context) { + const auto separator = context.indexOf(u'\n'); + return { + .channelId = (separator >= 0) + ? context.mid(0, separator).toULongLong() + : context.toULongLong(), + .username = (separator >= 0) ? context.mid(separator + 1) : QString(), + }; +} + +[[nodiscard]] QString SerializeTestNativeIvChannelContext( + uint64 channelId, + QString username) { + auto result = QString::number(channelId); + if (!username.isEmpty()) { + result += u"\n"_q + username; + } + return result; +} + +[[nodiscard]] QString ResolveTestNativeIvChannelUsername( + const QString &channelUsername, + const QString &contextUsername) { + return !channelUsername.isEmpty() ? channelUsername : contextUsername; +} + struct Args { QString markdownPath; QString latexMarkdownPath; @@ -143,27 +179,293 @@ public: mutable std::vector openedButtons; }; +class TestDocumentRuntime final : public DocumentRuntime { +public: + [[nodiscard]] std::shared_ptr thumbnail( + QSize size) const override { + thumbnailSizes.push_back(size); + return thumbnailImage; + } + + [[nodiscard]] std::shared_ptr full( + QSize size) const override { + fullSizes.push_back(size); + return fullImage; + } + + [[nodiscard]] bool loaded() const override { + return loadedValue; + } + + [[nodiscard]] bool loading() const override { + return loadingValue; + } + + [[nodiscard]] double progress() const override { + return progressValue; + } + + void open(Qt::MouseButton button) const override { + openedButtons.push_back(button); + } + + std::shared_ptr thumbnailImage; + std::shared_ptr fullImage; + bool loadedValue = false; + bool loadingValue = false; + double progressValue = 0.; + mutable std::vector thumbnailSizes; + mutable std::vector fullSizes; + mutable std::vector openedButtons; +}; + +class TestMapRuntime final : public MapRuntime { +public: + [[nodiscard]] std::shared_ptr thumbnail( + QSize size) const override { + thumbnailSizes.push_back(size); + return thumbnailImage; + } + + [[nodiscard]] std::shared_ptr full( + QSize size) const override { + fullSizes.push_back(size); + return fullImage; + } + + [[nodiscard]] bool loaded() const override { + return loadedValue; + } + + [[nodiscard]] bool loading() const override { + return loadingValue; + } + + [[nodiscard]] double progress() const override { + return progressValue; + } + + std::shared_ptr thumbnailImage; + std::shared_ptr fullImage; + bool loadedValue = false; + bool loadingValue = false; + double progressValue = 0.; + mutable std::vector thumbnailSizes; + mutable std::vector fullSizes; +}; + +class TestChannelRuntime final : public ChannelRuntime { +public: + [[nodiscard]] bool joinVisible() const override { + return joinVisibleValue; + } + + void open(Qt::MouseButton button) const override { + openedButtons.push_back(button); + } + + void join(Qt::MouseButton button) const override { + joinedButtons.push_back(button); + } + + bool joinVisibleValue = true; + mutable std::vector openedButtons; + mutable std::vector joinedButtons; +}; + class TestMediaRuntime final : public MediaRuntime { public: + struct InlineBinding { + uint64 documentId = 0; + std::shared_ptr image; + }; + + struct PhotoBinding { + uint64 photoId = 0; + std::shared_ptr runtime; + }; + + struct DocumentBinding { + uint64 documentId = 0; + std::shared_ptr runtime; + }; + + struct MapBinding { + double latitude = 0.; + double longitude = 0.; + uint64 accessHash = 0; + int zoom = 0; + std::shared_ptr runtime; + }; + + struct ChannelBinding { + uint64 channelId = 0; + QString username; + std::shared_ptr runtime; + }; + + struct MapRequest { + double latitude = 0.; + double longitude = 0.; + uint64 accessHash = 0; + QSize size; + int zoom = 0; + }; + + struct ChannelRequest { + uint64 channelId = 0; + QString username; + }; + [[nodiscard]] std::shared_ptr resolveInlineImage( uint64 documentId, QSize size) const override { inlineRequests.push_back({ documentId, size }); - return (documentId == inlineDocumentId) ? inlineImage : nullptr; + const auto i = std::find_if( + inlineBindings.begin(), + inlineBindings.end(), + [=](const InlineBinding &binding) { + return binding.documentId == documentId; + }); + return (i != inlineBindings.end()) ? i->image : nullptr; } [[nodiscard]] std::shared_ptr resolvePhoto( uint64 photoId) const override { photoRequests.push_back(photoId); - return (photoId == preparedPhotoId) ? photoRuntime : nullptr; + const auto i = std::find_if( + photoBindings.begin(), + photoBindings.end(), + [=](const PhotoBinding &binding) { + return binding.photoId == photoId; + }); + return (i != photoBindings.end()) ? i->runtime : nullptr; } - uint64 inlineDocumentId = 0; - uint64 preparedPhotoId = 0; - std::shared_ptr inlineImage; - std::shared_ptr photoRuntime; + [[nodiscard]] std::shared_ptr resolveDocument( + uint64 documentId) const override { + documentRequests.push_back(documentId); + const auto i = std::find_if( + documentBindings.begin(), + documentBindings.end(), + [=](const DocumentBinding &binding) { + return binding.documentId == documentId; + }); + return (i != documentBindings.end()) ? i->runtime : nullptr; + } + + [[nodiscard]] std::shared_ptr resolveMap( + double latitude, + double longitude, + uint64 accessHash, + QSize size, + int zoom) const override { + mapRequests.push_back({ + .latitude = latitude, + .longitude = longitude, + .accessHash = accessHash, + .size = size, + .zoom = zoom, + }); + const auto i = std::find_if( + mapBindings.begin(), + mapBindings.end(), + [=](const MapBinding &binding) { + return binding.latitude == latitude + && binding.longitude == longitude + && binding.accessHash == accessHash + && binding.zoom == zoom; + }); + return (i != mapBindings.end()) ? i->runtime : nullptr; + } + + [[nodiscard]] std::shared_ptr resolveChannel( + uint64 channelId, + const QString &username) const override { + channelRequests.push_back({ + .channelId = channelId, + .username = username, + }); + const auto i = std::find_if( + channelBindings.begin(), + channelBindings.end(), + [=](const ChannelBinding &binding) { + return binding.channelId == channelId + && binding.username == username; + }); + return (i != channelBindings.end()) ? i->runtime : nullptr; + } + + [[nodiscard]] rpl::producer channelJoinedChanges() const override { + return _channelJoinedChanges.events(); + } + + void addInlineImage(uint64 documentId, std::shared_ptr image) { + inlineBindings.push_back({ + .documentId = documentId, + .image = std::move(image), + }); + } + + void addPhotoRuntime(uint64 photoId, std::shared_ptr runtime) { + photoBindings.push_back({ + .photoId = photoId, + .runtime = std::move(runtime), + }); + } + + void addDocumentRuntime( + uint64 documentId, + std::shared_ptr runtime) { + documentBindings.push_back({ + .documentId = documentId, + .runtime = std::move(runtime), + }); + } + + void addMapRuntime( + double latitude, + double longitude, + uint64 accessHash, + int zoom, + std::shared_ptr runtime) { + mapBindings.push_back({ + .latitude = latitude, + .longitude = longitude, + .accessHash = accessHash, + .zoom = zoom, + .runtime = std::move(runtime), + }); + } + + void addChannelRuntime( + uint64 channelId, + QString username, + std::shared_ptr runtime) { + channelBindings.push_back({ + .channelId = channelId, + .username = std::move(username), + .runtime = std::move(runtime), + }); + } + + void fireChannelJoinedChange(uint64 channelId) const { + _channelJoinedChanges.fire_copy(channelId); + } + + std::vector inlineBindings; + std::vector photoBindings; + std::vector documentBindings; + std::vector mapBindings; + std::vector channelBindings; mutable std::vector> inlineRequests; mutable std::vector photoRequests; + mutable std::vector documentRequests; + mutable std::vector mapRequests; + mutable std::vector channelRequests; + +private: + mutable rpl::event_stream _channelJoinedChanges; }; enum class NativeIvPlaceholderKind { @@ -183,6 +485,14 @@ struct NativeIvPlaceholderFixture { MTPPageBlock block; }; +struct NativeIvMediaFixture { + QString label; + QString caption; + MTPPageBlock block; + QVector photos; + QVector documents; +}; + [[nodiscard]] QImage SolidTestImage( int width, int height, @@ -280,6 +590,230 @@ struct NativeIvPlaceholderFixture { std::move(urlOverride))); } +[[nodiscard]] MTPDocument NativeIvDocument( + uint64 id, + QString mimeType, + QVector attributes) { + return MTP_document( + MTP_flags(0), + MTP_long(id), + MTP_long(id + 1000), + MTP_bytes(), + MTP_int(0), + MTP_string(mimeType), + MTP_long(0), + MTPVector(), + MTPVector(), + MTP_int(0), + MTP_vector(std::move(attributes))); +} + +[[nodiscard]] MTPDocument NativeIvVideoDocument( + uint64 id, + int width, + int height, + QString fileName = u"video.mp4"_q, + double duration = 0.) { + const auto flags = MTPDdocumentAttributeVideo::Flags( + MTPDdocumentAttributeVideo::Flag::f_supports_streaming); + auto attributes = QVector(); + attributes.push_back(MTP_documentAttributeFilename(MTP_string(fileName))); + attributes.push_back(MTP_documentAttributeVideo( + MTP_flags(flags), + MTP_double(duration), + MTP_int(width), + MTP_int(height), + MTPint(), + MTPdouble(), + MTPstring())); + return NativeIvDocument(id, u"video/mp4"_q, std::move(attributes)); +} + +[[nodiscard]] MTPDocument NativeIvImageDocument( + uint64 id, + int width, + int height, + QString fileName = u"image.jpg"_q) { + auto attributes = QVector(); + attributes.push_back(MTP_documentAttributeFilename(MTP_string(fileName))); + attributes.push_back(MTP_documentAttributeImageSize( + MTP_int(width), + MTP_int(height))); + return NativeIvDocument(id, u"image/jpeg"_q, std::move(attributes)); +} + +[[nodiscard]] MTPDocument NativeIvAudioDocument( + uint64 id, + QString title, + QString performer, + QString fileName, + int duration) { + auto flags = MTPDdocumentAttributeAudio::Flags(0); + if (!title.isEmpty()) { + flags |= MTPDdocumentAttributeAudio::Flag::f_title; + } + if (!performer.isEmpty()) { + flags |= MTPDdocumentAttributeAudio::Flag::f_performer; + } + auto attributes = QVector(); + attributes.push_back(MTP_documentAttributeFilename(MTP_string(fileName))); + attributes.push_back(MTP_documentAttributeAudio( + MTP_flags(flags), + MTP_int(duration), + MTP_string(title), + MTP_string(performer), + MTPbytes())); + return NativeIvDocument(id, u"audio/mpeg"_q, std::move(attributes)); +} + +[[nodiscard]] MTPGeoPoint NativeIvGeoPoint( + double latitude, + double longitude, + uint64 accessHash) { + return MTP_geoPoint( + MTP_flags(0), + MTP_double(longitude), + MTP_double(latitude), + MTP_long(accessHash), + MTPint()); +} + +[[nodiscard]] MTPChat NativeIvChannelChat( + uint64 id, + QString title, + QString username = QString(), + uint64 accessHash = 0x12345678ULL) { + auto flags = MTPDchannel::Flags( + MTPDchannel::Flag::f_broadcast + | MTPDchannel::Flag::f_access_hash); + if (!username.isEmpty()) { + flags |= MTPDchannel::Flag::f_username; + } + return MTP_channel( + MTP_flags(flags), + MTP_long(id), + MTP_long(accessHash), + MTP_string(title), + MTP_string(username), + MTP_chatPhotoEmpty(), + MTP_int(0), + MTPVector(), + MTPChatAdminRights(), + MTPChatBannedRights(), + MTPChatBannedRights(), + MTPint(), + MTPVector(), + MTPrecentStory(), + MTPPeerColor(), + MTPPeerColor(), + MTPEmojiStatus(), + MTPint(), + MTPint(), + MTPlong(), + MTPlong(), + MTPlong()); +} + +[[nodiscard]] NativeIvMediaFixture NativeIvVideoFixture() { + return { + .label = u"Video"_q, + .caption = u"Video caption"_q, + .block = MTP_pageBlockVideo( + MTP_flags(0), + MTP_long(7001), + NativeIvCaption(u"Video caption"_q)), + .documents = { + NativeIvVideoDocument(7001, 1280, 720, u"video.mp4"_q, 42.), + }, + }; +} + +[[nodiscard]] NativeIvMediaFixture NativeIvAudioFixture() { + return { + .label = u"Audio"_q, + .caption = u"Audio caption"_q, + .block = MTP_pageBlockAudio( + MTP_long(7002), + NativeIvCaption(u"Audio caption"_q)), + .documents = { + NativeIvAudioDocument( + 7002, + u"Song Title"_q, + u"Sample Artist"_q, + u"track.mp3"_q, + 215), + }, + }; +} + +[[nodiscard]] NativeIvMediaFixture NativeIvMapFixture() { + return { + .label = u"Map"_q, + .caption = u"Map caption"_q, + .block = MTP_pageBlockMap( + NativeIvGeoPoint(51.5007, -0.1246, 880088), + MTP_int(13), + MTP_int(320), + MTP_int(180), + NativeIvCaption(u"Map caption"_q)), + }; +} + +[[nodiscard]] NativeIvMediaFixture NativeIvChannelFixture() { + return { + .label = u"Channel"_q, + .block = MTP_pageBlockChannel(NativeIvChannelChat( + 7006, + u"Native IV Channel"_q, + u"nativeiv"_q)), + }; +} + +[[nodiscard]] NativeIvMediaFixture NativeIvCollageFixture() { + auto items = QVector(); + items.push_back(NativeIvPhotoBlock(9102)); + items.push_back(MTP_pageBlockVideo( + MTP_flags(0), + MTP_long(7003), + NativeIvCaption())); + return { + .label = u"Collage"_q, + .caption = u"Collage caption"_q, + .block = MTP_pageBlockCollage( + MTP_vector(std::move(items)), + NativeIvCaption(u"Collage caption"_q)), + .photos = { + NativeIvPhoto(9102, 400, 300), + }, + .documents = { + NativeIvVideoDocument(7003, 640, 360, u"collage.mp4"_q, 13.), + }, + }; +} + +[[nodiscard]] NativeIvMediaFixture NativeIvSlideshowFixture() { + auto items = QVector(); + items.push_back(MTP_pageBlockVideo( + MTP_flags(0), + MTP_long(7004), + NativeIvCaption())); + items.push_back(MTP_pageBlockVideo( + MTP_flags(0), + MTP_long(7005), + NativeIvCaption())); + return { + .label = u"Slideshow"_q, + .caption = u"Slideshow caption"_q, + .block = MTP_pageBlockSlideshow( + MTP_vector(std::move(items)), + NativeIvCaption(u"Slideshow caption"_q)), + .documents = { + NativeIvVideoDocument(7004, 960, 540, u"slide-a.mp4"_q, 11.), + NativeIvVideoDocument(7005, 854, 480, u"slide-b.mp4"_q, 9.), + }, + }; +} + [[nodiscard]] QString NativeIvPlaceholderLabel(NativeIvPlaceholderKind kind) { switch (kind) { case NativeIvPlaceholderKind::Video: @@ -1285,6 +1819,18 @@ void PrintPrepareSummary( return image; } +[[nodiscard]] QImage RenderWidgetForTest(QWidget *widget) { + if (!widget || widget->size().isEmpty()) { + return QImage(); + } + auto image = QImage( + widget->size(), + QImage::Format_ARGB32_Premultiplied); + image.fill(Qt::transparent); + widget->render(&image); + return image; +} + class MarkdownArticleHighlightWaiter final { public: explicit MarkdownArticleHighlightWaiter(MarkdownArticle *article) @@ -1333,6 +1879,28 @@ void FlushPendingWidgetEvents() { } } +class WidgetEventCounter final : public QObject { +public: + void reset() { + paintCount = 0; + updateRequestCount = 0; + } + + int paintCount = 0; + int updateRequestCount = 0; + +protected: + bool eventFilter(QObject *object, QEvent *event) override { + Q_UNUSED(object); + if (event->type() == QEvent::Paint) { + ++paintCount; + } else if (event->type() == QEvent::UpdateRequest) { + ++updateRequestCount; + } + return false; + } +}; + template [[nodiscard]] Object *FindChildObject(QObject *root) { if (!root) { @@ -1572,6 +2140,37 @@ template : std::nullopt; } +template +[[nodiscard]] std::optional HitBoundsWhere( + MarkdownArticle *article, + int width, + int height, + Ui::Text::StateRequest::Flags flags, + Predicate &&predicate) { + if (!article || (width <= 0) || (height <= 0)) { + return std::nullopt; + } + auto left = width; + auto top = height; + auto right = -1; + auto bottom = -1; + for (auto y = 0; y != height; ++y) { + for (auto x = 0; x != width; ++x) { + const auto hit = article->hitTest(QPoint(x, y), flags); + if (!hit.valid() || !hit.direct || !predicate(hit)) { + continue; + } + left = std::min(left, x); + top = std::min(top, y); + right = std::max(right, x); + bottom = std::max(bottom, y); + } + } + return (right >= left) && (bottom >= top) + ? std::make_optional(QRect(QPoint(left, top), QPoint(right, bottom))) + : std::nullopt; +} + [[nodiscard]] std::vector MeasuredFormulaPointers( const MarkdownArticleContent &prepared) { auto result = std::vector(); @@ -2371,15 +2970,9 @@ void CheckNativeInstantViewPrepareCoverage(bool *ok) { .replacementText = u"[inline image]"_q, }, }); - Check( - !serialized.isEmpty(), - entityLabel + u" serialize"_q, - ok); + Check(!serialized.isEmpty(), entityLabel + u" serialize"_q, ok); const auto parsedEntity = ParseInlineTextObjectEntity(serialized); - Check( - parsedEntity.has_value(), - entityLabel + u" parse"_q, - ok); + Check(parsedEntity.has_value(), entityLabel + u" parse"_q, ok); if (parsedEntity) { Check( parsedEntity->kind == InlineTextObjectKind::IvImage, @@ -2404,6 +2997,13 @@ void CheckNativeInstantViewPrepareCoverage(bool *ok) { } } + const auto videoFixture = NativeIvVideoFixture(); + const auto audioFixture = NativeIvAudioFixture(); + const auto mapFixture = NativeIvMapFixture(); + const auto channelFixture = NativeIvChannelFixture(); + const auto collageFixture = NativeIvCollageFixture(); + const auto slideshowFixture = NativeIvSlideshowFixture(); + auto placeholderFixtures = std::vector(); const auto addPlaceholder = [&]( NativeIvPlaceholderKind kind, @@ -2415,15 +3015,8 @@ void CheckNativeInstantViewPrepareCoverage(bool *ok) { .block = NativeIvPlaceholderBlock(kind, caption), }); }; - addPlaceholder(NativeIvPlaceholderKind::Video, u"Video caption"_q); addPlaceholder(NativeIvPlaceholderKind::Embed, u"Embed caption"_q); addPlaceholder(NativeIvPlaceholderKind::EmbedPost, u"Embed post caption"_q); - addPlaceholder(NativeIvPlaceholderKind::Collage, u"Collage caption"_q); - addPlaceholder( - NativeIvPlaceholderKind::Slideshow, - u"Grouped caption"_q); - addPlaceholder(NativeIvPlaceholderKind::Audio, u"Audio caption"_q); - addPlaceholder(NativeIvPlaceholderKind::Map, u"Map caption"_q); auto supportedBlocks = QVector(); supportedBlocks.push_back(NativeIvInlineImageParagraph( @@ -2437,18 +3030,44 @@ void CheckNativeInstantViewPrepareCoverage(bool *ok) { u"Photo caption"_q, u"Photo credit"_q, u"https://example.com/photo"_q)); + supportedBlocks.push_back(videoFixture.block); for (const auto &fixture : placeholderFixtures) { supportedBlocks.push_back(fixture.block); } + supportedBlocks.push_back(collageFixture.block); + supportedBlocks.push_back(slideshowFixture.block); + supportedBlocks.push_back(audioFixture.block); + supportedBlocks.push_back(mapFixture.block); + supportedBlocks.push_back(channelFixture.block); supportedBlocks.push_back(NativeIvCoveredPhotoBlock( 9002, u"Cover caption"_q)); + auto supportedPhotos = QVector(); supportedPhotos.push_back(NativeIvPhoto(9001, 640, 360)); supportedPhotos.push_back(NativeIvPhoto(9002, 320, 200)); + for (const auto &photo : collageFixture.photos) { + supportedPhotos.push_back(photo); + } + + auto supportedDocuments = QVector(); + for (const auto &document : videoFixture.documents) { + supportedDocuments.push_back(document); + } + for (const auto &document : audioFixture.documents) { + supportedDocuments.push_back(document); + } + for (const auto &document : collageFixture.documents) { + supportedDocuments.push_back(document); + } + for (const auto &document : slideshowFixture.documents) { + supportedDocuments.push_back(document); + } + auto supportedSource = NativeIvSource( std::move(supportedBlocks), - std::move(supportedPhotos)); + std::move(supportedPhotos), + std::move(supportedDocuments)); const auto supported = TryPrepareNativeInstantView({ .source = &supportedSource, }); @@ -2465,12 +3084,18 @@ void CheckNativeInstantViewPrepareCoverage(bool *ok) { u"native-iv supported source failure state"_q, ok); Check( - supported.content.blocks.blocks.size() == 10, + supported.content.blocks.blocks.size() == 11, u"native-iv supported prepared block count"_q, ok); const PreparedBlock *inlineParagraph = nullptr; const PreparedBlock *photo = nullptr; + const PreparedBlock *video = nullptr; + const PreparedBlock *collage = nullptr; + const PreparedBlock *slideshow = nullptr; + const PreparedBlock *audio = nullptr; + const PreparedBlock *map = nullptr; + const PreparedBlock *channel = nullptr; const PreparedBlock *coveredPhoto = nullptr; auto preparedPlaceholders = std::vector(); ForEachPreparedBlock( @@ -2486,6 +3111,34 @@ void CheckNativeInstantViewPrepareCoverage(bool *ok) { && block.photo.photoId == 9001) { photo = █ } + if (!video + && block.kind == PreparedBlockKind::Video + && block.video.media.id == 7001) { + video = █ + } + if (!collage + && block.kind == PreparedBlockKind::GroupedMedia + && block.text.text == collageFixture.caption) { + collage = █ + } + if (!slideshow + && block.kind == PreparedBlockKind::GroupedMedia + && block.text.text == slideshowFixture.caption) { + slideshow = █ + } + if (!audio + && block.kind == PreparedBlockKind::Audio + && block.audio.documentId == 7002) { + audio = █ + } + if (!map && block.kind == PreparedBlockKind::Map) { + map = █ + } + if (!channel + && block.kind == PreparedBlockKind::Channel + && block.channel.channelId == 7006) { + channel = █ + } if (!coveredPhoto && block.kind == PreparedBlockKind::Photo && block.photo.photoId == 9002) { @@ -2495,13 +3148,13 @@ void CheckNativeInstantViewPrepareCoverage(bool *ok) { preparedPlaceholders.push_back(&block); } }); + Check( inlineParagraph != nullptr, u"native-iv inline-image paragraph block"_q, ok); if (inlineParagraph) { - const auto matches = CollectInlineTextObjectMatches( - inlineParagraph->text); + const auto matches = CollectInlineTextObjectMatches(inlineParagraph->text); Check( matches.size() == 1, u"native-iv inline-image entity count"_q, @@ -2535,16 +3188,14 @@ void CheckNativeInstantViewPrepareCoverage(bool *ok) { } } } + Check(photo != nullptr, u"native-iv photo block"_q, ok); if (photo) { Check( photo->text.text == u"Photo caption\nPhoto credit"_q, u"native-iv photo caption text"_q, ok); - Check( - photo->photo.photoId == 9001, - u"native-iv photo id"_q, - ok); + Check(photo->photo.photoId == 9001, u"native-iv photo id"_q, ok); Check( photo->photo.width == 640 && photo->photo.height == 360, u"native-iv photo aspect metadata"_q, @@ -2553,11 +3204,174 @@ void CheckNativeInstantViewPrepareCoverage(bool *ok) { photo->photo.urlOverride == u"https://example.com/photo"_q, u"native-iv photo url override"_q, ok); + Check(photo->photo.viewerOpen, u"native-iv photo viewer flag"_q, ok); + } + + Check(video != nullptr, u"native-iv video block"_q, ok); + if (video) { Check( - photo->photo.viewerOpen, - u"native-iv photo viewer flag"_q, + video->text.text == videoFixture.caption, + u"native-iv video caption text"_q, + ok); + Check( + video->video.media.kind == PreparedMediaItemKind::Document, + u"native-iv video media kind"_q, + ok); + Check(video->video.media.id == 7001, u"native-iv video id"_q, ok); + Check( + video->video.media.width == 1280 + && video->video.media.height == 720, + u"native-iv video dimensions"_q, ok); } + + Check(audio != nullptr, u"native-iv audio block"_q, ok); + if (audio) { + Check( + audio->text.text == audioFixture.caption, + u"native-iv audio caption text"_q, + ok); + Check(audio->audio.documentId == 7002, u"native-iv audio id"_q, ok); + Check(audio->audio.title == u"Song Title"_q, u"native-iv audio title"_q, ok); + Check( + audio->audio.performer == u"Sample Artist"_q, + u"native-iv audio performer"_q, + ok); + Check( + audio->audio.fileName == u"track.mp3"_q, + u"native-iv audio file name"_q, + ok); + Check(audio->audio.duration == 215, u"native-iv audio duration"_q, ok); + } + + Check(map != nullptr, u"native-iv map block"_q, ok); + if (map) { + Check( + map->text.text == mapFixture.caption, + u"native-iv map caption text"_q, + ok); + Check( + map->map.latitude == 51.5007 + && map->map.longitude == -0.1246, + u"native-iv map coordinates"_q, + ok); + Check(map->map.accessHash == 880088, u"native-iv map access hash"_q, ok); + Check( + map->map.width == 320 && map->map.height == 180, + u"native-iv map dimensions"_q, + ok); + Check(map->map.zoom == 13, u"native-iv map zoom"_q, ok); + Check(!map->map.url.isEmpty(), u"native-iv map url"_q, ok); + } + + Check(channel != nullptr, u"native-iv channel block"_q, ok); + if (channel) { + Check(channel->text.text.isEmpty(), u"native-iv channel text empty"_q, ok); + Check( + channel->channel.channelId == 7006, + u"native-iv channel id"_q, + ok); + Check( + channel->channel.title == u"Native IV Channel"_q, + u"native-iv channel title"_q, + ok); + Check( + channel->channel.username == u"nativeiv"_q, + u"native-iv channel username"_q, + ok); + } + const auto unresolvedChannelLabel = u"native-iv-channel-unresolved"_q; + const auto unresolvedChannelContext = SerializeTestNativeIvChannelContext( + 7006, + u"nativeiv"_q); + const auto parsedChannelContext = ParseTestNativeIvChannelContext( + unresolvedChannelContext); + Check( + parsedChannelContext.channelId == 7006, + unresolvedChannelLabel + u" parsed id"_q, + ok); + Check( + parsedChannelContext.username == u"nativeiv"_q, + unresolvedChannelLabel + u" parsed username"_q, + ok); + Check( + ResolveTestNativeIvChannelUsername( + QString(), + parsedChannelContext.username) == u"nativeiv"_q, + unresolvedChannelLabel + + u" unresolved local peer uses cached-page username"_q, + ok); + Check( + ResolveTestNativeIvChannelUsername( + u"localnativeiv"_q, + parsedChannelContext.username) == u"localnativeiv"_q, + unresolvedChannelLabel + u" loaded local username wins"_q, + ok); + + Check(collage != nullptr, u"native-iv collage block"_q, ok); + if (collage) { + Check( + collage->groupedMedia.items.size() == 2, + u"native-iv collage item count"_q, + ok); + Check( + collage->text.text == collageFixture.caption, + u"native-iv collage caption text"_q, + ok); + if (collage->groupedMedia.items.size() == 2) { + Check( + collage->groupedMedia.items[0].media.kind + == PreparedMediaItemKind::Photo, + u"native-iv collage first item kind"_q, + ok); + Check( + collage->groupedMedia.items[0].media.id == 9102, + u"native-iv collage first item id"_q, + ok); + Check( + collage->groupedMedia.items[1].media.kind + == PreparedMediaItemKind::Document, + u"native-iv collage second item kind"_q, + ok); + Check( + collage->groupedMedia.items[1].media.id == 7003, + u"native-iv collage second item id"_q, + ok); + } + } + + Check(slideshow != nullptr, u"native-iv slideshow block"_q, ok); + if (slideshow) { + Check( + slideshow->groupedMedia.items.size() == 2, + u"native-iv slideshow item count"_q, + ok); + Check( + slideshow->text.text == slideshowFixture.caption, + u"native-iv slideshow caption text"_q, + ok); + if (slideshow->groupedMedia.items.size() == 2) { + Check( + slideshow->groupedMedia.items[0].media.kind + == PreparedMediaItemKind::Document, + u"native-iv slideshow first item kind"_q, + ok); + Check( + slideshow->groupedMedia.items[0].media.id == 7004, + u"native-iv slideshow first item id"_q, + ok); + Check( + slideshow->groupedMedia.items[1].media.kind + == PreparedMediaItemKind::Document, + u"native-iv slideshow second item kind"_q, + ok); + Check( + slideshow->groupedMedia.items[1].media.id == 7005, + u"native-iv slideshow second item id"_q, + ok); + } + } + Check( coveredPhoto != nullptr, u"native-iv covered photo unwrapped"_q, @@ -2572,10 +3386,12 @@ void CheckNativeInstantViewPrepareCoverage(bool *ok) { u"native-iv covered photo id"_q, ok); Check( - coveredPhoto->photo.width == 320 && coveredPhoto->photo.height == 200, + coveredPhoto->photo.width == 320 + && coveredPhoto->photo.height == 200, u"native-iv covered photo size"_q, ok); } + Check( preparedPlaceholders.size() == placeholderFixtures.size(), u"native-iv placeholder prepared count"_q, @@ -2628,14 +3444,14 @@ void CheckNativeInstantViewPrepareCoverage(bool *ok) { u"native-iv unsupported block placeholder kind"_q, ok); Check( - block.placeholder.label == u"Unsupported Block Placeholder"_q, + block.placeholder.label == u"Unsupported Content"_q, u"native-iv unsupported block placeholder label"_q, ok); } - auto missingPhotoBlocks = QVector(); - missingPhotoBlocks.push_back(NativeIvPhotoBlock(9999, u"Missing photo"_q)); - auto missingPhotoSource = NativeIvSource(std::move(missingPhotoBlocks)); + auto missingPhotoSource = NativeIvSource(QVector{ + NativeIvPhotoBlock(9999, u"Missing photo"_q), + }); const auto missingPhoto = TryPrepareNativeInstantView({ .source = &missingPhotoSource, }); @@ -2643,10 +3459,6 @@ void CheckNativeInstantViewPrepareCoverage(bool *ok) { missingPhoto.supported(), u"native-iv missing-photo placeholder classification"_q, ok); - Check( - !missingPhoto.unsupported() && !missingPhoto.failed(), - u"native-iv missing-photo nonterminal classification"_q, - ok); Check( missingPhoto.content.blocks.blocks.size() == 1, u"native-iv missing-photo placeholder count"_q, @@ -2666,6 +3478,273 @@ void CheckNativeInstantViewPrepareCoverage(bool *ok) { u"native-iv missing-photo placeholder caption"_q, ok); } + + auto missingVideoSource = NativeIvSource(QVector{ + videoFixture.block, + }); + const auto missingVideo = TryPrepareNativeInstantView({ + .source = &missingVideoSource, + }); + Check( + missingVideo.supported(), + u"native-iv missing-video placeholder classification"_q, + ok); + if (missingVideo.content.blocks.blocks.size() == 1) { + const auto &block = missingVideo.content.blocks.blocks.front(); + Check( + block.kind == PreparedBlockKind::Placeholder, + u"native-iv missing-video placeholder kind"_q, + ok); + Check( + block.placeholder.label == u"Video Placeholder"_q, + u"native-iv missing-video placeholder label"_q, + ok); + Check( + block.text.text == videoFixture.caption, + u"native-iv missing-video placeholder caption"_q, + ok); + } + + auto nonVideoVideoSource = NativeIvSource( + QVector{ videoFixture.block }, + QVector(), + QVector{ + NativeIvImageDocument(7001, 1280, 720, u"video-poster.jpg"_q), + }); + const auto nonVideoVideo = TryPrepareNativeInstantView({ + .source = &nonVideoVideoSource, + }); + Check( + nonVideoVideo.supported(), + u"native-iv non-video-video placeholder classification"_q, + ok); + if (nonVideoVideo.content.blocks.blocks.size() == 1) { + const auto &block = nonVideoVideo.content.blocks.blocks.front(); + Check( + block.kind == PreparedBlockKind::Placeholder, + u"native-iv non-video-video placeholder kind"_q, + ok); + Check( + block.placeholder.label == u"Video Placeholder"_q, + u"native-iv non-video-video placeholder label"_q, + ok); + Check( + block.text.text == videoFixture.caption, + u"native-iv non-video-video placeholder caption"_q, + ok); + } + + auto duplicatePoorerVideoSource = NativeIvSource( + QVector{ videoFixture.block }, + QVector(), + videoFixture.documents); + duplicatePoorerVideoSource.webpageDocument = NativeIvDocument( + 7001, + u"video/mp4"_q, + QVector()); + const auto duplicatePoorerVideo = TryPrepareNativeInstantView({ + .source = &duplicatePoorerVideoSource, + }); + Check( + duplicatePoorerVideo.supported(), + u"native-iv duplicate poorer video classification"_q, + ok); + if (duplicatePoorerVideo.content.blocks.blocks.size() == 1) { + const auto &block = duplicatePoorerVideo.content.blocks.blocks.front(); + Check( + block.kind == PreparedBlockKind::Video, + u"native-iv duplicate poorer video kind"_q, + ok); + if (block.kind == PreparedBlockKind::Video) { + Check( + block.video.media.id == 7001, + u"native-iv duplicate poorer video id"_q, + ok); + Check( + block.video.media.width == 1280 + && block.video.media.height == 720, + u"native-iv duplicate poorer video dimensions"_q, + ok); + } + } + + auto missingAudioSource = NativeIvSource(QVector{ + audioFixture.block, + }); + const auto missingAudio = TryPrepareNativeInstantView({ + .source = &missingAudioSource, + }); + Check( + missingAudio.supported(), + u"native-iv missing-audio placeholder classification"_q, + ok); + if (missingAudio.content.blocks.blocks.size() == 1) { + const auto &block = missingAudio.content.blocks.blocks.front(); + Check( + block.kind == PreparedBlockKind::Placeholder, + u"native-iv missing-audio placeholder kind"_q, + ok); + Check( + block.placeholder.label == u"Audio File Placeholder"_q, + u"native-iv missing-audio placeholder label"_q, + ok); + Check( + block.text.text == audioFixture.caption, + u"native-iv missing-audio placeholder caption"_q, + ok); + } + + auto invalidMapBlocks = QVector(); + invalidMapBlocks.push_back(MTP_pageBlockMap( + MTP_geoPointEmpty(), + MTP_int(13), + MTP_int(320), + MTP_int(180), + NativeIvCaption(u"Invalid map"_q))); + auto invalidMapSource = NativeIvSource(std::move(invalidMapBlocks)); + const auto invalidMap = TryPrepareNativeInstantView({ + .source = &invalidMapSource, + }); + Check( + invalidMap.supported(), + u"native-iv invalid-map placeholder classification"_q, + ok); + if (invalidMap.content.blocks.blocks.size() == 1) { + const auto &block = invalidMap.content.blocks.blocks.front(); + Check( + block.kind == PreparedBlockKind::Placeholder, + u"native-iv invalid-map placeholder kind"_q, + ok); + Check( + block.placeholder.label == u"Map Placeholder"_q, + u"native-iv invalid-map placeholder label"_q, + ok); + Check( + block.text.text == u"Invalid map"_q, + u"native-iv invalid-map placeholder caption"_q, + ok); + } + + auto unsupportedCollageItems = QVector(); + unsupportedCollageItems.push_back(NativeIvPhotoBlock(9102)); + unsupportedCollageItems.push_back(MTP_pageBlockParagraph(NativeIvText( + u"unsupported child"_q))); + auto unsupportedCollageSource = NativeIvSource( + QVector{ + MTP_pageBlockCollage( + MTP_vector(std::move(unsupportedCollageItems)), + NativeIvCaption(u"Broken collage"_q)), + }, + collageFixture.photos, + collageFixture.documents); + const auto unsupportedCollage = TryPrepareNativeInstantView({ + .source = &unsupportedCollageSource, + }); + Check( + unsupportedCollage.supported(), + u"native-iv unsupported-collage placeholder classification"_q, + ok); + if (unsupportedCollage.content.blocks.blocks.size() == 1) { + const auto &block = unsupportedCollage.content.blocks.blocks.front(); + Check( + block.kind == PreparedBlockKind::Placeholder, + u"native-iv unsupported-collage placeholder kind"_q, + ok); + Check( + block.placeholder.label == u"Collage placeholder"_q, + u"native-iv unsupported-collage placeholder label"_q, + ok); + Check( + block.text.text == u"Broken collage"_q, + u"native-iv unsupported-collage placeholder caption"_q, + ok); + } + + auto nonVideoCollageSource = NativeIvSource( + QVector{ collageFixture.block }, + collageFixture.photos, + QVector{ + NativeIvImageDocument(7003, 640, 360, u"collage-still.jpg"_q), + }); + const auto nonVideoCollage = TryPrepareNativeInstantView({ + .source = &nonVideoCollageSource, + }); + Check( + nonVideoCollage.supported(), + u"native-iv non-video-collage placeholder classification"_q, + ok); + if (nonVideoCollage.content.blocks.blocks.size() == 1) { + const auto &block = nonVideoCollage.content.blocks.blocks.front(); + Check( + block.kind == PreparedBlockKind::Placeholder, + u"native-iv non-video-collage placeholder kind"_q, + ok); + Check( + block.placeholder.label == u"Collage placeholder"_q, + u"native-iv non-video-collage placeholder label"_q, + ok); + Check( + block.text.text == collageFixture.caption, + u"native-iv non-video-collage placeholder caption"_q, + ok); + } + + auto nonVideoSlideshowSource = NativeIvSource( + QVector{ slideshowFixture.block }, + slideshowFixture.photos, + QVector{ + NativeIvImageDocument(7004, 960, 540, u"slide-a.jpg"_q), + slideshowFixture.documents[1], + }); + const auto nonVideoSlideshow = TryPrepareNativeInstantView({ + .source = &nonVideoSlideshowSource, + }); + Check( + nonVideoSlideshow.supported(), + u"native-iv non-video-slideshow placeholder classification"_q, + ok); + if (nonVideoSlideshow.content.blocks.blocks.size() == 1) { + const auto &block = nonVideoSlideshow.content.blocks.blocks.front(); + Check( + block.kind == PreparedBlockKind::Placeholder, + u"native-iv non-video-slideshow placeholder kind"_q, + ok); + Check( + block.placeholder.label == u"Grouped Media Placeholder"_q, + u"native-iv non-video-slideshow placeholder label"_q, + ok); + Check( + block.text.text == slideshowFixture.caption, + u"native-iv non-video-slideshow placeholder caption"_q, + ok); + } + + auto missingGroupedDocumentSource = NativeIvSource( + QVector{ slideshowFixture.block }, + slideshowFixture.photos, + QVector{ slideshowFixture.documents.front() }); + const auto missingGroupedDocument = TryPrepareNativeInstantView({ + .source = &missingGroupedDocumentSource, + }); + Check( + missingGroupedDocument.supported(), + u"native-iv missing-grouped-document placeholder classification"_q, + ok); + if (missingGroupedDocument.content.blocks.blocks.size() == 1) { + const auto &block = missingGroupedDocument.content.blocks.blocks.front(); + Check( + block.kind == PreparedBlockKind::Placeholder, + u"native-iv missing-grouped-document placeholder kind"_q, + ok); + Check( + block.placeholder.label == u"Grouped Media Placeholder"_q, + u"native-iv missing-grouped-document placeholder label"_q, + ok); + Check( + block.text.text == slideshowFixture.caption, + u"native-iv missing-grouped-document placeholder caption"_q, + ok); + } } void CheckCodeBlockTrailingNewlineTrim(bool *ok) { @@ -3018,94 +4097,33 @@ auto text = "native-markdown-iv-phase-z-highlight"; } void CheckNativeInstantViewArticleCoverage(bool *ok) { - const auto placeholderLabel = u"native-iv-placeholder-article"_q; - auto placeholderBlocks = QVector(); - placeholderBlocks.push_back(NativeIvPlaceholderBlock( - NativeIvPlaceholderKind::Video, - u"Placeholder caption"_q)); - auto placeholderSource = NativeIvSource(std::move(placeholderBlocks)); - auto placeholderPrepared = TryPrepareNativeInstantView({ - .source = &placeholderSource, - }); - Check( - placeholderPrepared.supported(), - placeholderLabel + u" prepare supported"_q, - ok); - if (placeholderPrepared.supported()) { - auto placeholderHeight = 0; - auto placeholderArticle = BuildArticleForTest( - std::move(placeholderPrepared.content), - std::make_shared(), - 420, - &placeholderHeight); - const auto placeholderImage = PaintArticleForTest( - placeholderArticle.get(), - 420, - placeholderHeight); - Check( - HasPaintedPixels(placeholderImage), - placeholderLabel + u" paint produced pixels"_q, - ok); - const auto placeholderBounds = SegmentHitBounds( - placeholderArticle.get(), - 420, - placeholderHeight, - 0); - Check( - placeholderBounds.has_value(), - placeholderLabel + u" media hit bounds"_q, - ok); - if (placeholderBounds) { - auto flags = Ui::Text::StateRequest::Flags(); - flags |= Ui::Text::StateRequest::Flag::LookupSymbol; - const auto hit = placeholderArticle->hitTest( - placeholderBounds->center(), - flags); - Check( - hit.valid() && hit.direct && (hit.segmentIndex == 0), - placeholderLabel + u" direct media hit"_q, - ok); - Check( - hit.mediaActivation.kind == MediaActivationKind::None, - placeholderLabel + u" no media activation"_q, - ok); - Check( - !placeholderArticle->segmentIsText(0), - placeholderLabel + u" segment is media"_q, - ok); - Check( - placeholderArticle->segmentLength(0) == 1, - placeholderLabel + u" media segment length"_q, - ok); - const auto context = placeholderArticle->textForContext(hit); - Check( - context.expanded - == u"Video Placeholder\nPlaceholder caption"_q, - placeholderLabel + u" context export text"_q, - ok); - const auto selection = placeholderArticle->textForSelection({ - .from = { .segment = 0, .offset = 0 }, - .to = { .segment = 0, .offset = 1 }, - }, nullptr); - Check( - selection.expanded == context.expanded, - placeholderLabel + u" selection export text"_q, - ok); - } - } + const auto renderer = std::make_shared(); + auto lookupFlags = Ui::Text::StateRequest::Flags(); + lookupFlags |= Ui::Text::StateRequest::Flag::LookupSymbol; + + const auto videoFixture = NativeIvVideoFixture(); + const auto audioFixture = NativeIvAudioFixture(); + const auto mapFixture = NativeIvMapFixture(); + const auto channelFixture = NativeIvChannelFixture(); + const auto collageFixture = NativeIvCollageFixture(); + const auto slideshowFixture = NativeIvSlideshowFixture(); const auto mixedLabel = u"native-iv-mixed-article"_q; auto mixedRuntime = std::make_shared(); - mixedRuntime->inlineDocumentId = 8801; - mixedRuntime->inlineImage = std::make_shared(); - mixedRuntime->preparedPhotoId = 9101; - mixedRuntime->photoRuntime = std::make_shared(); - mixedRuntime->photoRuntime->thumbnailImage - = std::make_shared(); - mixedRuntime->photoRuntime->fullImage - = std::make_shared(); - mixedRuntime->photoRuntime->loadingValue = true; - mixedRuntime->photoRuntime->progressValue = 0.5; + auto inlineImage = std::make_shared(); + auto videoRuntime = std::make_shared(); + videoRuntime->thumbnailImage = std::make_shared(); + videoRuntime->fullImage = std::make_shared(); + videoRuntime->loadingValue = true; + videoRuntime->progressValue = 0.25; + auto mixedPhotoRuntime = std::make_shared(); + mixedPhotoRuntime->thumbnailImage = std::make_shared(); + mixedPhotoRuntime->fullImage = std::make_shared(); + mixedPhotoRuntime->loadingValue = true; + mixedPhotoRuntime->progressValue = 0.5; + mixedRuntime->addInlineImage(8801, inlineImage); + mixedRuntime->addDocumentRuntime(7001, videoRuntime); + mixedRuntime->addPhotoRuntime(9101, mixedPhotoRuntime); auto mixedBlocks = QVector(); mixedBlocks.push_back(NativeIvInlineImageParagraph( @@ -3114,8 +4132,7 @@ void CheckNativeInstantViewArticleCoverage(bool *ok) { 18, u"Intro "_q, u" tail."_q)); - mixedBlocks.push_back(NativeIvPlaceholderBlock( - NativeIvPlaceholderKind::Video)); + mixedBlocks.push_back(videoFixture.block); mixedBlocks.push_back(NativeIvPhotoBlock(9101)); mixedBlocks.push_back(MTP_pageBlockParagraph(NativeIvText( u"Outro paragraph."_q))); @@ -3123,7 +4140,8 @@ void CheckNativeInstantViewArticleCoverage(bool *ok) { mixedPhotos.push_back(NativeIvPhoto(9101, 320, 180)); auto mixedSource = NativeIvSource( std::move(mixedBlocks), - std::move(mixedPhotos)); + std::move(mixedPhotos), + videoFixture.documents); auto mixedPrepared = TryPrepareNativeInstantView({ .source = &mixedSource, .mediaRuntime = mixedRuntime, @@ -3139,14 +4157,16 @@ void CheckNativeInstantViewArticleCoverage(bool *ok) { mixedPrepared.content.mediaRuntime.get() == mixedRuntime.get(), mixedLabel + u" media runtime forwarded"_q, ok); - auto renderer = std::make_shared(); auto wideHeight = 0; - auto article = BuildArticleForTest( + auto mixedArticle = BuildArticleForTest( std::move(mixedPrepared.content), renderer, 520, &wideHeight); - const auto wideImage = PaintArticleForTest(article.get(), 520, wideHeight); + const auto wideImage = PaintArticleForTest( + mixedArticle.get(), + 520, + wideHeight); Check( HasPaintedPixels(wideImage), mixedLabel + u" wide paint produced pixels"_q, @@ -3165,34 +4185,36 @@ void CheckNativeInstantViewArticleCoverage(bool *ok) { mixedLabel + u" inline image resolved size"_q, ok); } + Check( + !mixedRuntime->documentRequests.empty() + && (mixedRuntime->documentRequests.front() == 7001), + mixedLabel + u" video runtime resolve request"_q, + ok); Check( !mixedRuntime->photoRequests.empty() && (mixedRuntime->photoRequests.front() == 9101), mixedLabel + u" photo runtime resolve request"_q, ok); Check( - !mixedRuntime->photoRuntime->thumbnailSizes.empty() - && !mixedRuntime->photoRuntime->fullSizes.empty(), + !videoRuntime->thumbnailSizes.empty() + && !videoRuntime->fullSizes.empty(), + mixedLabel + u" video image size requests"_q, + ok); + Check( + !mixedPhotoRuntime->thumbnailSizes.empty() + && !mixedPhotoRuntime->fullSizes.empty(), mixedLabel + u" photo image size requests"_q, ok); - if (!mixedRuntime->photoRuntime->thumbnailSizes.empty() - && !mixedRuntime->photoRuntime->fullSizes.empty()) { - Check( - mixedRuntime->photoRuntime->thumbnailSizes.front() - == mixedRuntime->photoRuntime->fullSizes.front(), - mixedLabel + u" photo image request sizes match"_q, - ok); - } Check( - article->segmentIsText(0), + mixedArticle->segmentIsText(0), mixedLabel + u" inline-image paragraph remains text segment"_q, ok); - if (article->segmentIsText(0)) { - const auto exported = article->textForSelection({ + if (mixedArticle->segmentIsText(0)) { + const auto exported = mixedArticle->textForSelection({ .from = { .segment = 0, .offset = 0 }, .to = { .segment = 0, - .offset = article->segmentLength(0), + .offset = mixedArticle->segmentLength(0), }, }, nullptr); Check( @@ -3205,9 +4227,9 @@ void CheckNativeInstantViewArticleCoverage(bool *ok) { ok); } - const auto narrowHeight = article->resizeGetHeight(260); + const auto narrowHeight = mixedArticle->resizeGetHeight(260); const auto narrowImage = PaintArticleForTest( - article.get(), + mixedArticle.get(), 260, narrowHeight); Check( @@ -3219,25 +4241,26 @@ void CheckNativeInstantViewArticleCoverage(bool *ok) { mixedLabel + u" narrow relayout grows height"_q, ok); Check( - mixedRuntime->inlineImage->subscriptionCount >= 1, + inlineImage->subscriptionCount >= 1, mixedLabel + u" inline image subscribed for repaint"_q, ok); Check( - !mixedRuntime->inlineImage->requestedSizes.empty() - && (mixedRuntime->inlineImage->requestedSizes.front() == 24), + !inlineImage->requestedSizes.empty() + && (inlineImage->requestedSizes.front() == 24), mixedLabel + u" inline image paint request size"_q, ok); auto segmentBounds = std::vector>(); for (auto segmentIndex = 0; segmentIndex != 4; ++segmentIndex) { segmentBounds.push_back(SegmentHitBounds( - article.get(), + mixedArticle.get(), 260, narrowHeight, segmentIndex)); Check( segmentBounds.back().has_value(), - mixedLabel + u" segment hit bounds "_q + QString::number(segmentIndex), + mixedLabel + u" segment hit bounds "_q + + QString::number(segmentIndex), ok); } auto haveBounds = true; @@ -3257,9 +4280,54 @@ void CheckNativeInstantViewArticleCoverage(bool *ok) { mixedLabel + u" segment order "_q + QString::number(segmentIndex), ok); } - auto flags = Ui::Text::StateRequest::Flags(); - flags |= Ui::Text::StateRequest::Flag::LookupSymbol; - const auto photoHit = article->hitTest(segmentBounds[2]->center(), flags); + + const auto videoHit = mixedArticle->hitTest( + segmentBounds[1]->center(), + lookupFlags); + Check( + videoHit.valid() && videoHit.direct && (videoHit.segmentIndex == 1), + mixedLabel + u" video direct hit"_q, + ok); + Check( + videoHit.mediaActivation.kind == MediaActivationKind::Document, + mixedLabel + u" video activation kind"_q, + ok); + Check( + videoHit.mediaActivation.document.get() == videoRuntime.get(), + mixedLabel + u" video runtime forwarded to hit"_q, + ok); + Check( + !videoHit.preparedLink.has_value(), + mixedLabel + u" video hit has no prepared link"_q, + ok); + const auto videoContext = mixedArticle->textForContext(videoHit); + Check( + videoContext.expanded + == (tr::lng_in_dlg_video(tr::now) + + u"\n"_q + + videoFixture.caption), + mixedLabel + u" video context export text"_q, + ok); + const auto videoSelection = mixedArticle->textForSelection({ + .from = { .segment = 1, .offset = 0 }, + .to = { .segment = 1, .offset = 1 }, + }, nullptr); + Check( + videoSelection.expanded == videoContext.expanded, + mixedLabel + u" video selection export text"_q, + ok); + if (videoHit.mediaActivation.document) { + videoHit.mediaActivation.document->open(Qt::LeftButton); + } + Check( + videoRuntime->openedButtons.size() == 1 + && (videoRuntime->openedButtons.front() == Qt::LeftButton), + mixedLabel + u" video open intent"_q, + ok); + + const auto photoHit = mixedArticle->hitTest( + segmentBounds[2]->center(), + lookupFlags); Check( photoHit.valid() && photoHit.direct && (photoHit.segmentIndex == 2), mixedLabel + u" photo direct hit"_q, @@ -3269,63 +4337,76 @@ void CheckNativeInstantViewArticleCoverage(bool *ok) { mixedLabel + u" photo activation kind"_q, ok); Check( - !photoHit.preparedLink.has_value(), - mixedLabel + u" photo hit has no external prepared link"_q, - ok); - Check( - photoHit.mediaActivation.photo.get() == mixedRuntime->photoRuntime.get(), + photoHit.mediaActivation.photo.get() == mixedPhotoRuntime.get(), mixedLabel + u" photo runtime forwarded to hit"_q, ok); if (photoHit.mediaActivation.photo) { photoHit.mediaActivation.photo->open(Qt::LeftButton); } Check( - mixedRuntime->photoRuntime->openedButtons.size() == 1 - && mixedRuntime->photoRuntime->openedButtons.front() - == Qt::LeftButton, + mixedPhotoRuntime->openedButtons.size() == 1 + && (mixedPhotoRuntime->openedButtons.front() == Qt::LeftButton), mixedLabel + u" photo open intent"_q, ok); - mixedRuntime->inlineImage->setFrame(SolidTestImage( - 24, - 18, - QColor(0, 160, 220))); - mixedRuntime->inlineImage->notify(); - mixedRuntime->photoRuntime->thumbnailImage->setFrame(SolidTestImage( + inlineImage->setFrame(SolidTestImage(24, 18, QColor(0, 160, 220))); + inlineImage->notify(); + videoRuntime->thumbnailImage->setFrame(SolidTestImage( + 160, + 90, + QColor(20, 120, 220))); + videoRuntime->fullImage->setFrame(SolidTestImage( + 320, + 180, + QColor(0, 120, 90))); + videoRuntime->loadingValue = false; + videoRuntime->loadedValue = true; + videoRuntime->progressValue = 1.; + mixedPhotoRuntime->thumbnailImage->setFrame(SolidTestImage( 160, 90, QColor(220, 160, 0))); - mixedRuntime->photoRuntime->fullImage->setFrame(SolidTestImage( + mixedPhotoRuntime->fullImage->setFrame(SolidTestImage( 320, 180, QColor(0, 200, 90))); - mixedRuntime->photoRuntime->loadingValue = false; - mixedRuntime->photoRuntime->loadedValue = true; - mixedRuntime->photoRuntime->progressValue = 1.; + mixedPhotoRuntime->loadingValue = false; + mixedPhotoRuntime->loadedValue = true; + mixedPhotoRuntime->progressValue = 1.; - const auto narrowHeightAfterUpdate = article->resizeGetHeight(260); + const auto narrowHeightAfterUpdate = mixedArticle->resizeGetHeight(260); Check( narrowHeightAfterUpdate == narrowHeight, mixedLabel + u" media repaint keeps layout height"_q, ok); const auto updatedImage = PaintArticleForTest( - article.get(), + mixedArticle.get(), 260, narrowHeightAfterUpdate); Check( HasPaintedPixels(updatedImage), mixedLabel + u" updated paint produced pixels"_q, ok); + const auto updatedVideoBounds = SegmentHitBounds( + mixedArticle.get(), + 260, + narrowHeightAfterUpdate, + 1); const auto updatedPhotoBounds = SegmentHitBounds( - article.get(), + mixedArticle.get(), 260, narrowHeightAfterUpdate, 2); const auto updatedInlineBounds = SegmentHitBounds( - article.get(), + mixedArticle.get(), 260, narrowHeightAfterUpdate, 0); + Check( + updatedVideoBounds.has_value() + && (*updatedVideoBounds == *segmentBounds[1]), + mixedLabel + u" video repaint keeps bounds"_q, + ok); Check( updatedPhotoBounds.has_value() && (*updatedPhotoBounds == *segmentBounds[2]), @@ -3336,6 +4417,799 @@ void CheckNativeInstantViewArticleCoverage(bool *ok) { && (*updatedInlineBounds == *segmentBounds[0]), mixedLabel + u" inline-image repaint keeps bounds"_q, ok); + + const auto audioLabel = u"native-iv-audio-article"_q; + auto audioRuntime = std::make_shared(); + auto audioDocumentRuntime = std::make_shared(); + audioRuntime->addDocumentRuntime(7002, audioDocumentRuntime); + auto audioSource = NativeIvSource( + QVector{ audioFixture.block }, + audioFixture.photos, + audioFixture.documents); + auto audioPrepared = TryPrepareNativeInstantView({ + .source = &audioSource, + .mediaRuntime = audioRuntime, + }); + Check(audioPrepared.supported(), audioLabel + u" prepare supported"_q, ok); + if (audioPrepared.supported()) { + auto audioHeight = 0; + auto audioArticle = BuildArticleForTest( + std::move(audioPrepared.content), + renderer, + 420, + &audioHeight); + const auto audioImage = PaintArticleForTest( + audioArticle.get(), + 420, + audioHeight); + Check( + HasPaintedPixels(audioImage), + audioLabel + u" paint produced pixels"_q, + ok); + Check( + audioRuntime->documentRequests.size() == 1 + && (audioRuntime->documentRequests.front() == 7002), + audioLabel + u" document resolve request"_q, + ok); + const auto audioBounds = SegmentHitBounds( + audioArticle.get(), + 420, + audioHeight, + 0); + Check(audioBounds.has_value(), audioLabel + u" media bounds"_q, ok); + if (audioBounds) { + const auto audioHit = audioArticle->hitTest( + audioBounds->center(), + lookupFlags); + Check( + audioHit.valid() + && audioHit.direct + && (audioHit.segmentIndex == 0), + audioLabel + u" direct hit"_q, + ok); + Check( + audioHit.mediaActivation.kind == MediaActivationKind::Document, + audioLabel + u" activation kind"_q, + ok); + Check( + audioHit.mediaActivation.document.get() + == audioDocumentRuntime.get(), + audioLabel + u" runtime forwarded"_q, + ok); + const auto audioContext = audioArticle->textForContext(audioHit); + Check( + audioContext.expanded + == u"Song Title\nSample Artist\nAudio caption"_q, + audioLabel + u" context export text"_q, + ok); + const auto audioSelection = audioArticle->textForSelection({ + .from = { .segment = 0, .offset = 0 }, + .to = { .segment = 0, .offset = 1 }, + }, nullptr); + Check( + audioSelection.expanded == audioContext.expanded, + audioLabel + u" selection export text"_q, + ok); + if (audioHit.mediaActivation.document) { + audioHit.mediaActivation.document->open(Qt::LeftButton); + } + Check( + audioDocumentRuntime->openedButtons.size() == 1 + && (audioDocumentRuntime->openedButtons.front() + == Qt::LeftButton), + audioLabel + u" open intent"_q, + ok); + } + const auto audioCaptionSelection = audioArticle->textForSelection({ + .from = { .segment = 1, .offset = 0 }, + .to = { + .segment = 1, + .offset = audioArticle->segmentLength(1), + }, + }, nullptr); + Check( + audioCaptionSelection.expanded == audioFixture.caption, + audioLabel + u" caption selection export"_q, + ok); + } + + const auto mapLabel = u"native-iv-map-article"_q; + auto mapRuntime = std::make_shared(); + auto mapImageRuntime = std::make_shared(); + mapImageRuntime->thumbnailImage = std::make_shared(); + mapImageRuntime->fullImage = std::make_shared(); + mapRuntime->addMapRuntime(51.5007, -0.1246, 880088, 13, mapImageRuntime); + auto mapSource = NativeIvSource(QVector{ + mapFixture.block, + }); + auto mapPrepared = TryPrepareNativeInstantView({ + .source = &mapSource, + .mediaRuntime = mapRuntime, + }); + Check(mapPrepared.supported(), mapLabel + u" prepare supported"_q, ok); + if (mapPrepared.supported()) { + auto mapHeight = 0; + auto mapArticle = BuildArticleForTest( + std::move(mapPrepared.content), + renderer, + 420, + &mapHeight); + const auto mapImage = PaintArticleForTest( + mapArticle.get(), + 420, + mapHeight); + Check( + HasPaintedPixels(mapImage), + mapLabel + u" paint produced pixels"_q, + ok); + Check( + mapRuntime->mapRequests.size() == 1, + mapLabel + u" runtime resolve request"_q, + ok); + if (!mapRuntime->mapRequests.empty()) { + const auto &request = mapRuntime->mapRequests.front(); + Check( + request.latitude == 51.5007 + && request.longitude == -0.1246, + mapLabel + u" coordinates forwarded"_q, + ok); + Check( + request.accessHash == 880088 && request.zoom == 13, + mapLabel + u" access hash and zoom forwarded"_q, + ok); + } + const auto mapBounds = SegmentHitBounds( + mapArticle.get(), + 420, + mapHeight, + 0); + Check(mapBounds.has_value(), mapLabel + u" media bounds"_q, ok); + if (mapBounds) { + const auto mapHit = mapArticle->hitTest( + mapBounds->center(), + lookupFlags); + Check( + mapHit.valid() && mapHit.direct && (mapHit.segmentIndex == 0), + mapLabel + u" direct hit"_q, + ok); + Check( + mapHit.mediaActivation.kind == MediaActivationKind::ExternalUrl, + mapLabel + u" activation kind"_q, + ok); + Check( + mapHit.preparedLink.has_value() + && !mapHit.preparedLink->target.isEmpty(), + mapLabel + u" prepared link created"_q, + ok); + if (mapHit.preparedLink) { + Check( + mapHit.mediaActivation.url == mapHit.preparedLink->target, + mapLabel + u" media activation url matches prepared link"_q, + ok); + } + const auto mapContext = mapArticle->textForContext(mapHit); + Check( + mapContext.expanded + == (tr::lng_maps_point(tr::now) + + u"\n"_q + + mapFixture.caption), + mapLabel + u" context export text"_q, + ok); + const auto mapSelection = mapArticle->textForSelection({ + .from = { .segment = 0, .offset = 0 }, + .to = { .segment = 0, .offset = 1 }, + }, nullptr); + Check( + mapSelection.expanded == mapContext.expanded, + mapLabel + u" selection export text"_q, + ok); + } + const auto mapCaptionSelection = mapArticle->textForSelection({ + .from = { .segment = 1, .offset = 0 }, + .to = { + .segment = 1, + .offset = mapArticle->segmentLength(1), + }, + }, nullptr); + Check( + mapCaptionSelection.expanded == mapFixture.caption, + mapLabel + u" caption selection export"_q, + ok); + } + + const auto channelLabel = u"native-iv-channel-article"_q; + auto channelRuntime = std::make_shared(); + auto channelCardRuntime = std::make_shared(); + channelRuntime->addChannelRuntime(7006, u"nativeiv"_q, channelCardRuntime); + auto channelSource = NativeIvSource(QVector{ + channelFixture.block, + }); + auto channelPrepared = TryPrepareNativeInstantView({ + .source = &channelSource, + .mediaRuntime = channelRuntime, + }); + Check( + channelPrepared.supported(), + channelLabel + u" prepare supported"_q, + ok); + if (channelPrepared.supported()) { + auto channelHeight = 0; + auto channelArticle = BuildArticleForTest( + std::move(channelPrepared.content), + renderer, + 420, + &channelHeight); + const auto channelBefore = PaintArticleForTest( + channelArticle.get(), + 420, + channelHeight); + Check( + HasPaintedPixels(channelBefore), + channelLabel + u" paint produced pixels"_q, + ok); + Check( + channelRuntime->channelRequests.size() == 1 + && (channelRuntime->channelRequests.front().channelId == 7006) + && (channelRuntime->channelRequests.front().username + == u"nativeiv"_q), + channelLabel + u" runtime resolve request"_q, + ok); + const auto openBounds = HitBoundsWhere( + channelArticle.get(), + 420, + channelHeight, + lookupFlags, + [](const MarkdownArticleHitTestResult &hit) { + return hit.mediaActivation.kind + == MediaActivationKind::OpenChannel; + }); + const auto joinBounds = HitBoundsWhere( + channelArticle.get(), + 420, + channelHeight, + lookupFlags, + [](const MarkdownArticleHitTestResult &hit) { + return hit.mediaActivation.kind + == MediaActivationKind::JoinChannel; + }); + Check(openBounds.has_value(), channelLabel + u" open bounds"_q, ok); + Check(joinBounds.has_value(), channelLabel + u" join bounds"_q, ok); + if (openBounds) { + const auto openHit = channelArticle->hitTest( + openBounds->center(), + lookupFlags); + Check( + openHit.mediaActivation.kind == MediaActivationKind::OpenChannel, + channelLabel + u" open activation kind"_q, + ok); + Check( + openHit.mediaActivation.channel.get() == channelCardRuntime.get(), + channelLabel + u" open runtime forwarded"_q, + ok); + if (openHit.mediaActivation.channel) { + openHit.mediaActivation.channel->open(Qt::LeftButton); + } + } + if (joinBounds) { + const auto joinHit = channelArticle->hitTest( + joinBounds->center(), + lookupFlags); + Check( + joinHit.mediaActivation.kind == MediaActivationKind::JoinChannel, + channelLabel + u" join activation kind"_q, + ok); + Check( + joinHit.mediaActivation.channel.get() == channelCardRuntime.get(), + channelLabel + u" join runtime forwarded"_q, + ok); + const auto joinContext = channelArticle->textForContext(joinHit); + Check( + joinContext.expanded == u"Native IV Channel\n@nativeiv"_q, + channelLabel + u" context export text"_q, + ok); + const auto joinSelection = channelArticle->textForSelection({ + .from = { .segment = 0, .offset = 0 }, + .to = { .segment = 0, .offset = 1 }, + }, nullptr); + Check( + joinSelection.expanded == joinContext.expanded, + channelLabel + u" selection export text"_q, + ok); + if (joinHit.mediaActivation.channel) { + joinHit.mediaActivation.channel->join(Qt::LeftButton); + } + } + Check( + channelCardRuntime->openedButtons.size() == 1 + && (channelCardRuntime->openedButtons.front() + == Qt::LeftButton), + channelLabel + u" open intent"_q, + ok); + Check( + channelCardRuntime->joinedButtons.size() == 1 + && (channelCardRuntime->joinedButtons.front() + == Qt::LeftButton), + channelLabel + u" join intent"_q, + ok); + if (joinBounds) { + channelCardRuntime->joinVisibleValue = false; + const auto joinedHit = channelArticle->hitTest( + joinBounds->center(), + lookupFlags); + Check( + joinedHit.mediaActivation.kind == MediaActivationKind::OpenChannel, + channelLabel + u" joined state hides join action"_q, + ok); + const auto channelAfter = PaintArticleForTest( + channelArticle.get(), + 420, + channelHeight); + Check( + PixelsDifferInRect(channelBefore, channelAfter, *joinBounds), + channelLabel + u" joined state repaint changes button pixels"_q, + ok); + } + + auto previewRuntime = std::make_shared(); + auto previewChannelRuntime = std::make_shared(); + previewRuntime->addChannelRuntime( + 7006, + u"nativeiv"_q, + previewChannelRuntime); + auto previewSource = NativeIvSource(QVector{ + channelFixture.block, + }); + auto previewPrepared = TryPrepareNativeInstantView({ + .source = &previewSource, + .mediaRuntime = previewRuntime, + }); + Check( + previewPrepared.supported(), + channelLabel + u" preview prepare supported"_q, + ok); + if (previewPrepared.supported()) { + auto window = Ui::RpWindow(); + window.setGeometry(QRect(0, 0, 420, 240)); + window.show(); + FlushPendingWidgetEvents(); + auto preview = CreateMarkdownPreviewWidget( + window.body(), + std::move(previewPrepared.content), + renderer, + [](Event) { + }); + preview->setGeometry(QRect(QPoint(), window.body()->size())); + preview->show(); + FlushPendingWidgetEvents(); + const auto body = FindChildObject( + preview.get()); + Check( + body != nullptr, + channelLabel + u" preview body widget"_q, + ok); + if (body) { + auto counter = WidgetEventCounter(); + body->installEventFilter(&counter); + const auto previewBefore = RenderWidgetForTest(body); + counter.reset(); + previewChannelRuntime->joinVisibleValue = false; + previewRuntime->fireChannelJoinedChange(7006); + FlushPendingWidgetEvents(); + const auto repainted = (counter.paintCount > 0) + || (counter.updateRequestCount > 0); + const auto previewAfter = RenderWidgetForTest(body); + Check( + repainted, + channelLabel + u" joined change triggers preview repaint"_q, + ok); + Check( + PixelsDifferInRect( + previewBefore, + previewAfter, + QRect(QPoint(), previewAfter.size())), + channelLabel + u" preview repaint changes rendered pixels"_q, + ok); + body->removeEventFilter(&counter); + } + } + } + + const auto channelRelayoutLabel = u"native-iv-channel-relayout"_q; + const auto relayoutChannelId = uint64(7007); + const auto relayoutChannelUsername = u"nativeinstantviewrelayout"_q; + const auto relayoutChannelBlock = MTP_pageBlockChannel(NativeIvChannelChat( + relayoutChannelId, + u"Native Instant View Channel Title That Wraps When The Join Button Is Visible"_q, + relayoutChannelUsername)); + auto joinedLayoutRuntime = std::make_shared(); + auto joinedLayoutChannelRuntime = std::make_shared(); + joinedLayoutChannelRuntime->joinVisibleValue = false; + joinedLayoutRuntime->addChannelRuntime( + relayoutChannelId, + relayoutChannelUsername, + joinedLayoutChannelRuntime); + auto joinedLayoutSource = NativeIvSource(QVector{ + relayoutChannelBlock, + }); + auto joinedLayoutPrepared = TryPrepareNativeInstantView({ + .source = &joinedLayoutSource, + .mediaRuntime = joinedLayoutRuntime, + }); + Check( + joinedLayoutPrepared.supported(), + channelRelayoutLabel + u" hidden prepare supported"_q, + ok); + auto joinedLayoutHeight = 0; + if (joinedLayoutPrepared.supported()) { + auto joinedLayoutArticle = BuildArticleForTest( + std::move(joinedLayoutPrepared.content), + renderer, + 320, + &joinedLayoutHeight); + const auto joinedLayoutJoinBounds = HitBoundsWhere( + joinedLayoutArticle.get(), + 320, + joinedLayoutHeight, + lookupFlags, + [](const MarkdownArticleHitTestResult &hit) { + return hit.mediaActivation.kind + == MediaActivationKind::JoinChannel; + }); + Check( + !joinedLayoutJoinBounds.has_value(), + channelRelayoutLabel + u" hidden layout has no join bounds"_q, + ok); + } + + auto articleRelayoutRuntime = std::make_shared(); + auto articleRelayoutChannelRuntime = std::make_shared(); + articleRelayoutRuntime->addChannelRuntime( + relayoutChannelId, + relayoutChannelUsername, + articleRelayoutChannelRuntime); + auto articleRelayoutSource = NativeIvSource(QVector{ + relayoutChannelBlock, + }); + auto articleRelayoutPrepared = TryPrepareNativeInstantView({ + .source = &articleRelayoutSource, + .mediaRuntime = articleRelayoutRuntime, + }); + Check( + articleRelayoutPrepared.supported(), + channelRelayoutLabel + u" article prepare supported"_q, + ok); + if (articleRelayoutPrepared.supported() && (joinedLayoutHeight > 0)) { + auto articleRelayoutHeight = 0; + auto articleRelayout = BuildArticleForTest( + std::move(articleRelayoutPrepared.content), + renderer, + 320, + &articleRelayoutHeight); + const auto articleJoinBounds = HitBoundsWhere( + articleRelayout.get(), + 320, + articleRelayoutHeight, + lookupFlags, + [](const MarkdownArticleHitTestResult &hit) { + return hit.mediaActivation.kind + == MediaActivationKind::JoinChannel; + }); + Check( + articleJoinBounds.has_value(), + channelRelayoutLabel + u" visible layout has join bounds"_q, + ok); + articleRelayoutChannelRuntime->joinVisibleValue = false; + articleRelayout->invalidateLayout(); + articleRelayoutHeight = articleRelayout->resizeGetHeight(320); + const auto articleJoinedBounds = HitBoundsWhere( + articleRelayout.get(), + 320, + articleRelayoutHeight, + lookupFlags, + [](const MarkdownArticleHitTestResult &hit) { + return hit.mediaActivation.kind + == MediaActivationKind::JoinChannel; + }); + Check( + !articleJoinedBounds.has_value(), + channelRelayoutLabel + + u" invalidated relayout hides join bounds"_q, + ok); + Check( + articleRelayoutHeight == joinedLayoutHeight, + channelRelayoutLabel + + u" invalidated relayout matches hidden layout"_q, + ok); + } + + auto previewRelayoutRuntime = std::make_shared(); + auto previewRelayoutChannelRuntime = std::make_shared(); + previewRelayoutRuntime->addChannelRuntime( + relayoutChannelId, + relayoutChannelUsername, + previewRelayoutChannelRuntime); + auto previewRelayoutSource = NativeIvSource(QVector{ + relayoutChannelBlock, + }); + auto previewRelayoutPrepared = TryPrepareNativeInstantView({ + .source = &previewRelayoutSource, + .mediaRuntime = previewRelayoutRuntime, + }); + Check( + previewRelayoutPrepared.supported(), + channelRelayoutLabel + u" preview prepare supported"_q, + ok); + if (previewRelayoutPrepared.supported() && (joinedLayoutHeight > 0)) { + auto window = Ui::RpWindow(); + window.setGeometry(QRect(0, 0, 320, 320)); + window.show(); + FlushPendingWidgetEvents(); + auto preview = CreateMarkdownPreviewWidget( + window.body(), + std::move(previewRelayoutPrepared.content), + renderer, + [](Event) { + }); + preview->setGeometry(QRect(QPoint(), window.body()->size())); + preview->show(); + FlushPendingWidgetEvents(); + const auto body = FindChildObject( + preview.get()); + Check( + body != nullptr, + channelRelayoutLabel + u" preview body widget"_q, + ok); + if (body) { + auto counter = WidgetEventCounter(); + body->installEventFilter(&counter); + const auto beforeHeight = body->height(); + counter.reset(); + previewRelayoutChannelRuntime->joinVisibleValue = false; + previewRelayoutRuntime->fireChannelJoinedChange(relayoutChannelId); + FlushPendingWidgetEvents(); + const auto repainted = (counter.paintCount > 0) + || (counter.updateRequestCount > 0); + const auto afterHeight = body->height(); + Check( + beforeHeight > joinedLayoutHeight, + channelRelayoutLabel + u" join visible height larger"_q, + ok); + Check( + afterHeight == joinedLayoutHeight, + channelRelayoutLabel + + u" joined change relayout matches hidden layout"_q, + ok); + Check( + repainted, + channelRelayoutLabel + u" joined change triggers preview repaint"_q, + ok); + body->removeEventFilter(&counter); + } + } + + const auto collageLabel = u"native-iv-collage-article"_q; + auto collageRuntime = std::make_shared(); + auto collagePhotoRuntime = std::make_shared(); + collagePhotoRuntime->thumbnailImage = std::make_shared(); + collagePhotoRuntime->fullImage = std::make_shared(); + auto collageDocumentRuntime = std::make_shared(); + collageDocumentRuntime->thumbnailImage = std::make_shared(); + collageDocumentRuntime->fullImage = std::make_shared(); + collageRuntime->addPhotoRuntime(9102, collagePhotoRuntime); + collageRuntime->addDocumentRuntime(7003, collageDocumentRuntime); + auto collageSource = NativeIvSource( + QVector{ collageFixture.block }, + collageFixture.photos, + collageFixture.documents); + auto collagePrepared = TryPrepareNativeInstantView({ + .source = &collageSource, + .mediaRuntime = collageRuntime, + }); + Check( + collagePrepared.supported(), + collageLabel + u" prepare supported"_q, + ok); + if (collagePrepared.supported()) { + auto collageHeight = 0; + auto collageArticle = BuildArticleForTest( + std::move(collagePrepared.content), + renderer, + 460, + &collageHeight); + const auto collageImage = PaintArticleForTest( + collageArticle.get(), + 460, + collageHeight); + Check( + HasPaintedPixels(collageImage), + collageLabel + u" paint produced pixels"_q, + ok); + Check( + collageRuntime->photoRequests.size() == 1 + && (collageRuntime->photoRequests.front() == 9102), + collageLabel + u" photo resolve request"_q, + ok); + Check( + collageRuntime->documentRequests.size() == 1 + && (collageRuntime->documentRequests.front() == 7003), + collageLabel + u" document resolve request"_q, + ok); + const auto collagePhotoBounds = HitBoundsWhere( + collageArticle.get(), + 460, + collageHeight, + lookupFlags, + [&](const MarkdownArticleHitTestResult &hit) { + return hit.mediaActivation.kind == MediaActivationKind::Photo + && hit.mediaActivation.photo.get() + == collagePhotoRuntime.get(); + }); + const auto collageVideoBounds = HitBoundsWhere( + collageArticle.get(), + 460, + collageHeight, + lookupFlags, + [&](const MarkdownArticleHitTestResult &hit) { + return hit.mediaActivation.kind == MediaActivationKind::Document + && hit.mediaActivation.document.get() + == collageDocumentRuntime.get(); + }); + Check( + collagePhotoBounds.has_value(), + collageLabel + u" photo item bounds"_q, + ok); + Check( + collageVideoBounds.has_value(), + collageLabel + u" video item bounds"_q, + ok); + if (collagePhotoBounds) { + const auto hit = collageArticle->hitTest( + collagePhotoBounds->center(), + lookupFlags); + if (hit.mediaActivation.photo) { + hit.mediaActivation.photo->open(Qt::LeftButton); + } + const auto context = collageArticle->textForContext(hit); + Check( + context.expanded == collageFixture.caption, + collageLabel + u" context export text"_q, + ok); + } + if (collageVideoBounds) { + const auto hit = collageArticle->hitTest( + collageVideoBounds->center(), + lookupFlags); + if (hit.mediaActivation.document) { + hit.mediaActivation.document->open(Qt::LeftButton); + } + } + Check( + collagePhotoRuntime->openedButtons.size() == 1, + collageLabel + u" photo open intent"_q, + ok); + Check( + collageDocumentRuntime->openedButtons.size() == 1, + collageLabel + u" video open intent"_q, + ok); + const auto collageSelection = collageArticle->textForSelection({ + .from = { .segment = 0, .offset = 0 }, + .to = { .segment = 0, .offset = 1 }, + }, nullptr); + Check( + collageSelection.expanded == collageFixture.caption, + collageLabel + u" selection export text"_q, + ok); + const auto collageCaptionSelection = collageArticle->textForSelection({ + .from = { .segment = 1, .offset = 0 }, + .to = { + .segment = 1, + .offset = collageArticle->segmentLength(1), + }, + }, nullptr); + Check( + collageCaptionSelection.expanded == collageFixture.caption, + collageLabel + u" caption selection export"_q, + ok); + } + + const auto slideshowLabel = u"native-iv-slideshow-article"_q; + auto slideshowRuntime = std::make_shared(); + auto slideshowDocumentA = std::make_shared(); + slideshowDocumentA->thumbnailImage = std::make_shared(); + slideshowDocumentA->fullImage = std::make_shared(); + auto slideshowDocumentB = std::make_shared(); + slideshowDocumentB->thumbnailImage = std::make_shared(); + slideshowDocumentB->fullImage = std::make_shared(); + slideshowRuntime->addDocumentRuntime(7004, slideshowDocumentA); + slideshowRuntime->addDocumentRuntime(7005, slideshowDocumentB); + auto slideshowSource = NativeIvSource( + QVector{ slideshowFixture.block }, + slideshowFixture.photos, + slideshowFixture.documents); + auto slideshowPrepared = TryPrepareNativeInstantView({ + .source = &slideshowSource, + .mediaRuntime = slideshowRuntime, + }); + Check( + slideshowPrepared.supported(), + slideshowLabel + u" prepare supported"_q, + ok); + if (slideshowPrepared.supported()) { + auto slideshowHeight = 0; + auto slideshowArticle = BuildArticleForTest( + std::move(slideshowPrepared.content), + renderer, + 460, + &slideshowHeight); + const auto slideshowImage = PaintArticleForTest( + slideshowArticle.get(), + 460, + slideshowHeight); + Check( + HasPaintedPixels(slideshowImage), + slideshowLabel + u" paint produced pixels"_q, + ok); + Check( + slideshowRuntime->documentRequests.size() == 2, + slideshowLabel + u" document resolve request count"_q, + ok); + const auto slideshowItemBounds = HitBoundsWhere( + slideshowArticle.get(), + 460, + slideshowHeight, + lookupFlags, + [&](const MarkdownArticleHitTestResult &hit) { + return hit.mediaActivation.kind == MediaActivationKind::Document + && hit.mediaActivation.document.get() + == slideshowDocumentA.get(); + }); + Check( + slideshowItemBounds.has_value(), + slideshowLabel + u" document item bounds"_q, + ok); + if (slideshowItemBounds) { + const auto hit = slideshowArticle->hitTest( + slideshowItemBounds->center(), + lookupFlags); + if (hit.mediaActivation.document) { + hit.mediaActivation.document->open(Qt::LeftButton); + } + const auto context = slideshowArticle->textForContext(hit); + Check( + context.expanded + == (tr::lng_media_selected_video(tr::now, lt_count, 2) + + u"\n"_q + + slideshowFixture.caption), + slideshowLabel + u" context export text"_q, + ok); + } + const auto slideshowSelection = slideshowArticle->textForSelection({ + .from = { .segment = 0, .offset = 0 }, + .to = { .segment = 0, .offset = 1 }, + }, nullptr); + Check( + slideshowSelection.expanded + == (tr::lng_media_selected_video(tr::now, lt_count, 2) + + u"\n"_q + + slideshowFixture.caption), + slideshowLabel + u" selection export text"_q, + ok); + const auto slideshowCaptionSelection = slideshowArticle->textForSelection({ + .from = { .segment = 1, .offset = 0 }, + .to = { + .segment = 1, + .offset = slideshowArticle->segmentLength(1), + }, + }, nullptr); + Check( + slideshowCaptionSelection.expanded == slideshowFixture.caption, + slideshowLabel + u" caption selection export"_q, + ok); + Check( + slideshowDocumentA->openedButtons.size() == 1, + slideshowLabel + u" document open intent"_q, + ok); + } } void CheckPrepareCoverage(